dsh-taskboard 0.5.3 → 0.5.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- {"version":3,"file":"api.js","names":[],"sources":["../../src/shared/api.ts"],"sourcesContent":["/**\n * Wire contract for the /taskboard host routes: the JSON envelope,\n * request/response shapes, and SSE event payloads shared by the host routes\n * and the browser client.\n *\n * @module dsh-taskboard/shared/api\n */\nimport type { BoardSettings, TaskLedger, TaskRecord, TaskSummary } from './protocol.ts'\n\nexport type { TaskRecord }\n\n/** Route prefix on the shared DSH webserver (same origin as the GUI). */\nexport const ROUTE_PREFIX = '/dsh-taskboard'\n\n/** SSE stream path (exact route; longest-prefix wins keep it disjoint). */\nexport const SSE_PATH = '/dsh-taskboard/events'\n\n/** Stable error codes (mirror the tool-level codes plus HTTP mapping). */\nexport type ApiErrorCode =\n | 'invalid_input'\n | 'not_found'\n | 'version_conflict'\n | 'invalid_transition'\n | 'forbidden'\n | 'internal'\n\n/** Success envelope. */\nexport type ApiOk<T> = { ok: true; value: T }\n\n/** Failure envelope. */\nexport type ApiFail = { ok: false; error: { code: ApiErrorCode; message: string } }\n\n/** The envelope either way. */\nexport type ApiResult<T> = ApiOk<T> | ApiFail\n\n// ---------------------------------------------------------------------------\n// payloads\n// ---------------------------------------------------------------------------\n\n/** Full-state response (the reconnect baseline after an SSE gap). */\nexport type StateResponse = TaskLedger\n\n/** Workspace listing for the UI pickers. */\nexport type WorkspaceView = { id: string; path: string; title: string; sessionCount: number; gitAvailable?: boolean }\n\n/** Create-task request body (actor is always the GUI user). */\nexport type CreateTaskBody = {\n title: string\n workspaceId: string\n urgency: string\n description?: string\n prompt?: string\n execution?: { mode?: string; cron?: string }\n model?: { provider: string; model: string }\n /** Code isolation for executions ('worktree' | 'none'); omitted = default. */\n isolation?: string\n /** Agent preset for execution sessions; omitted = deployment default. */\n presetId?: string\n /** Acceptance checklist item texts (host mints ids, all unchecked). */\n checklist?: string[]\n}\n\n/** Update-task request body (ifVersion mandatory). */\nexport type UpdateTaskBody = {\n ifVersion: number\n title?: string\n description?: string\n prompt?: string\n urgency?: string\n blocked?: boolean\n /** Rebind the task to another project (GUI owner surface only). */\n workspaceId?: string\n execution?: { mode?: string; cron?: string }\n model?: { provider: string; model: string } | null\n /** Change isolation; locked once the task has execution history. */\n isolation?: string\n /** Change the execution preset (takes effect on the next run). */\n presetId?: string | null\n /** Replace the whole checklist (GUI owner surface); null clears it. */\n checklist?: unknown\n}\n\n/** Move-task request body (ifVersion mandatory; the user MAY move to done). */\nexport type MoveTaskBody = { ifVersion: number; status: string }\n\n/**\n * Quick-reject request body (card ✗ button): move back to todo plus an\n * optional user comment, committed as ONE ledger mutation so a failed move\n * can never strand an orphan comment.\n */\nexport type RejectTaskBody = { ifVersion: number; body?: string }\n\n/** Comment request body. */\nexport type CommentBody = { body: string }\n\n/** Delete request body (purge=true physically removes a trashed task). */\nexport type DeleteTaskBody = { ifVersion?: number; purge?: boolean }\n\n/** Run request body; `reuse: true` = 续跑 (keep a live worktree as-is). */\nexport type RunTaskBody = { reuse?: boolean }\n\n/** Merge outcome: `noop: true` = the branch had no commits over HEAD (nothing merged). */\nexport type MergeBranchResponse = { merged: boolean; noop?: boolean; branch: string }\n\n/** Remove a task's worktree; optionally delete its branch too. */\nexport type WorktreeRemoveBody = { deleteBranch?: boolean }\n\n/** One orphan worktree directory (exists on disk, owned by no live task). */\nexport type OrphanWorktree = { workspaceId: string; workspacePath: string; taskId: string; path: string }\n\n/** A git-enabled workspace whose .gitignore does not cover the worktree dir. */\nexport type GitignoreSuggestion = { workspaceId: string; workspacePath: string }\n\n/** Health-diagnostics response (⚙ panel). */\nexport type DiagnosticsResponse = {\n revision: number\n tasks: number\n /** Executions currently marked `running`. */\n staleRunning: number\n /** Worktree directories whose task no longer exists in the ledger. */\n orphanWorktrees: OrphanWorktree[]\n /** Git workspaces whose .gitignore does not ignore the worktree dir. */\n gitIgnoreSuggestions: GitignoreSuggestion[]\n}\n\n/** Fields a task template may prefill (0.4.0). */\nexport type TaskTemplateSpec = {\n title?: string\n description?: string\n prompt?: string\n urgency?: string\n execution?: { mode?: string; cron?: string }\n model?: { provider: string; model: string }\n isolation?: string\n presetId?: string\n /** Checklist item texts (host mints ids at create time). */\n checklist?: string[]\n}\n\n/** One reusable task template (0.4.0). */\nexport type TaskTemplate = {\n id: string\n name: string\n task: TaskTemplateSpec\n /** Seeded built-in templates (kept on load, deletable like any other). */\n builtin?: boolean\n createdAt: number\n updatedAt: number\n}\n\n/** Templates listing response. */\nexport type TemplatesResponse = { templates: TaskTemplate[] }\n\n/** Board-settings response (0.5.0; absent fields follow factory defaults). */\nexport type SettingsResponse = BoardSettings\n\n/** Update-board-settings request body (0.5.0; whole-object replace semantics). */\nexport type UpdateSettingsBody = {\n /** Default code isolation for NEW tasks ('worktree' | 'none'). */\n defaultIsolation?: string\n}\n\n/** Import dry-run response (0.4.0): every task classified, nothing written. */\nexport type ImportPreviewResponse = {\n plan: {\n create: Array<{ id: string; title: string; status: string }>\n overwrite: Array<{ id: string; title: string; status: string }>\n invalid: Array<{ id?: string; reason: string }>\n }\n}\n\n/** Import commit response. */\nexport type ImportCommitResponse = {\n mode: 'merge' | 'replace'\n created: number\n overwritten: number\n replacedTotal?: number\n /** The backup file written BEFORE a replace wiped the live ledger. */\n backupFile?: string\n}\n\n/** Diff-viewer response (0.4.0). */\nexport type DiffResponse = { diff: string; truncated: boolean }\n\n/** One task (full record) response. */\nexport type TaskResponse = TaskRecord\n\n/** Summary response used by list-ish endpoints. */\nexport type SummaryResponse = { tasks: TaskSummary[] }\n\n// ---------------------------------------------------------------------------\n// SSE\n// ---------------------------------------------------------------------------\n\n/** Change frame pushed on every committed ledger mutation. */\nexport type ChangeEvent = {\n revision: number\n kind: 'task-created' | 'task-updated' | 'task-moved' | 'task-deleted' | 'comment-added' | 'execution-recorded' | 'settings-updated' | 'ledger-replaced'\n tasks: TaskSummary[]\n}\n"],"mappings":";;AAYA,MAAa,eAAe;;AAG5B,MAAa,WAAW"}
1
+ {"version":3,"file":"api.js","names":[],"sources":["../../src/shared/api.ts"],"sourcesContent":["/**\n * Wire contract for the /taskboard host routes: the JSON envelope,\n * request/response shapes, and SSE event payloads shared by the host routes\n * and the browser client.\n *\n * @module dsh-taskboard/shared/api\n */\nimport type { BoardSettings, TaskLedger, TaskModel, TaskRecord, TaskSummary } from './protocol.ts'\n\nexport type { TaskModel, TaskRecord }\n\n/** Route prefix on the shared DSH webserver (same origin as the GUI). */\nexport const ROUTE_PREFIX = '/dsh-taskboard'\n\n/** SSE stream path (exact route; longest-prefix wins keep it disjoint). */\nexport const SSE_PATH = '/dsh-taskboard/events'\n\n/** Stable error codes (mirror the tool-level codes plus HTTP mapping). */\nexport type ApiErrorCode =\n | 'invalid_input'\n | 'not_found'\n | 'version_conflict'\n | 'invalid_transition'\n | 'forbidden'\n | 'internal'\n\n/** Success envelope. */\nexport type ApiOk<T> = { ok: true; value: T }\n\n/** Failure envelope. */\nexport type ApiFail = { ok: false; error: { code: ApiErrorCode; message: string } }\n\n/** The envelope either way. */\nexport type ApiResult<T> = ApiOk<T> | ApiFail\n\n// ---------------------------------------------------------------------------\n// payloads\n// ---------------------------------------------------------------------------\n\n/** Full-state response (the reconnect baseline after an SSE gap). */\nexport type StateResponse = TaskLedger\n\n/** Workspace listing for the UI pickers. */\nexport type WorkspaceView = { id: string; path: string; title: string; sessionCount: number; gitAvailable?: boolean }\n\n/** Create-task request body (actor is always the GUI user). */\nexport type CreateTaskBody = {\n title: string\n workspaceId: string\n urgency: string\n description?: string\n prompt?: string\n execution?: { mode?: string; cron?: string }\n model?: TaskModel\n /** Code isolation for executions ('worktree' | 'none'); omitted = default. */\n isolation?: string\n /** Agent preset for execution sessions; omitted = deployment default. */\n presetId?: string\n /** Acceptance checklist item texts (host mints ids, all unchecked). */\n checklist?: string[]\n}\n\n/** Update-task request body (ifVersion mandatory). */\nexport type UpdateTaskBody = {\n ifVersion: number\n title?: string\n description?: string\n prompt?: string\n urgency?: string\n blocked?: boolean\n /** Rebind the task to another project (GUI owner surface only). */\n workspaceId?: string\n execution?: { mode?: string; cron?: string }\n model?: TaskModel | null\n /** Change isolation; locked once the task has execution history. */\n isolation?: string\n /** Change the execution preset (takes effect on the next run). */\n presetId?: string | null\n /** Replace the whole checklist (GUI owner surface); null clears it. */\n checklist?: unknown\n}\n\n/** Move-task request body (ifVersion mandatory; the user MAY move to done). */\nexport type MoveTaskBody = { ifVersion: number; status: string }\n\n/**\n * Quick-reject request body (card ✗ button): move back to todo plus an\n * optional user comment, committed as ONE ledger mutation so a failed move\n * can never strand an orphan comment.\n */\nexport type RejectTaskBody = { ifVersion: number; body?: string }\n\n/** Comment request body. */\nexport type CommentBody = { body: string }\n\n/** Delete request body (purge=true physically removes a trashed task). */\nexport type DeleteTaskBody = { ifVersion?: number; purge?: boolean }\n\n/** Run request body; `reuse: true` = 续跑 (keep a live worktree as-is). */\nexport type RunTaskBody = { reuse?: boolean }\n\n/** Merge outcome: `noop: true` = the branch had no commits over HEAD (nothing merged). */\nexport type MergeBranchResponse = { merged: boolean; noop?: boolean; branch: string }\n\n/** Remove a task's worktree; optionally delete its branch too. */\nexport type WorktreeRemoveBody = { deleteBranch?: boolean }\n\n/** One orphan worktree directory (exists on disk, owned by no live task). */\nexport type OrphanWorktree = { workspaceId: string; workspacePath: string; taskId: string; path: string }\n\n/** A git-enabled workspace whose .gitignore does not cover the worktree dir. */\nexport type GitignoreSuggestion = { workspaceId: string; workspacePath: string }\n\n/** Health-diagnostics response (⚙ panel). */\nexport type DiagnosticsResponse = {\n revision: number\n tasks: number\n /** Executions currently marked `running`. */\n staleRunning: number\n /** Worktree directories whose task no longer exists in the ledger. */\n orphanWorktrees: OrphanWorktree[]\n /** Git workspaces whose .gitignore does not ignore the worktree dir. */\n gitIgnoreSuggestions: GitignoreSuggestion[]\n}\n\n/** Fields a task template may prefill (0.4.0). */\nexport type TaskTemplateSpec = {\n title?: string\n description?: string\n prompt?: string\n urgency?: string\n execution?: { mode?: string; cron?: string }\n model?: TaskModel\n isolation?: string\n presetId?: string\n /** Checklist item texts (host mints ids at create time). */\n checklist?: string[]\n}\n\n/** One reusable task template (0.4.0). */\nexport type TaskTemplate = {\n id: string\n name: string\n task: TaskTemplateSpec\n /** Seeded built-in templates (kept on load, deletable like any other). */\n builtin?: boolean\n createdAt: number\n updatedAt: number\n}\n\n/** Templates listing response. */\nexport type TemplatesResponse = { templates: TaskTemplate[] }\n\n/** Board-settings response (0.5.0; absent fields follow factory defaults). */\nexport type SettingsResponse = BoardSettings\n\n/** Update-board-settings request body (0.5.0; whole-object replace semantics). */\nexport type UpdateSettingsBody = {\n /** Default code isolation for NEW tasks ('worktree' | 'none'). */\n defaultIsolation?: string\n}\n\n/** Import dry-run response (0.4.0): every task classified, nothing written. */\nexport type ImportPreviewResponse = {\n plan: {\n create: Array<{ id: string; title: string; status: string }>\n overwrite: Array<{ id: string; title: string; status: string }>\n invalid: Array<{ id?: string; reason: string }>\n }\n}\n\n/** Import commit response. */\nexport type ImportCommitResponse = {\n mode: 'merge' | 'replace'\n created: number\n overwritten: number\n replacedTotal?: number\n /** The backup file written BEFORE a replace wiped the live ledger. */\n backupFile?: string\n}\n\n/** Diff-viewer response (0.4.0). */\nexport type DiffResponse = { diff: string; truncated: boolean }\n\n/** One task (full record) response. */\nexport type TaskResponse = TaskRecord\n\n/** Summary response used by list-ish endpoints. */\nexport type SummaryResponse = { tasks: TaskSummary[] }\n\n// ---------------------------------------------------------------------------\n// SSE\n// ---------------------------------------------------------------------------\n\n/** Change frame pushed on every committed ledger mutation. */\nexport type ChangeEvent = {\n revision: number\n kind: 'task-created' | 'task-updated' | 'task-moved' | 'task-deleted' | 'comment-added' | 'execution-recorded' | 'settings-updated' | 'ledger-replaced'\n tasks: TaskSummary[]\n}\n"],"mappings":";;AAYA,MAAa,eAAe;;AAG5B,MAAa,WAAW"}
@@ -327,22 +327,24 @@ function syncClaim(task, to, now, holder) {
327
327
  }
328
328
  }
329
329
  /**
330
- * Validate and normalize a pinned model: `{ provider, model }`, both
331
- * non-empty trimmed strings.
330
+ * Validate and normalize a pinned model: `{ provider, model, reasoningEffort? }`,
331
+ * provider and model must be non-empty trimmed strings.
332
332
  * @param raw - the raw input.
333
333
  * @returns the normalized model.
334
334
  * @throws when the shape or the fields are invalid.
335
335
  */
336
336
  function normalizeModel(raw) {
337
337
  if (typeof raw !== "object" || raw === null) throw new Error("model must be { provider: string, model: string }");
338
- const { provider, model } = raw;
338
+ const { provider, model, reasoningEffort } = raw;
339
339
  if (typeof provider !== "string" || typeof model !== "string") throw new Error("model must be { provider: string, model: string }");
340
340
  const p = provider.trim();
341
341
  const m = model.trim();
342
342
  if (p.length === 0 || m.length === 0) throw new Error("model.provider and model.model must be non-empty strings");
343
+ const eff = typeof reasoningEffort === "string" && reasoningEffort.trim().length > 0 ? reasoningEffort.trim() : void 0;
343
344
  return {
344
345
  provider: p,
345
- model: m
346
+ model: m,
347
+ ...eff !== void 0 ? { reasoningEffort: eff } : {}
346
348
  };
347
349
  }
348
350
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"protocol.js","names":[],"sources":["../../src/shared/protocol.ts"],"sourcesContent":["/**\n * Task domain model, state machine, urgency classes, and cron math — the\n * framework-free core shared verbatim by the host half (tools, store, routes,\n * scheduler) and, from P2 on, the browser half (board view).\n *\n * Everything here is a pure function over plain data: no imports beyond the\n * standard library, no I/O, no globals. Tests drive it directly.\n *\n * @module dsh-taskboard/shared/protocol\n */\n\n// ---------------------------------------------------------------------------\n// Status vocabulary\n// ---------------------------------------------------------------------------\n\n/**\n * Task lifecycle states. Main board columns render `backlog → todo →\n * in_progress → in_review → done`; `canceled` and `archived` are secondary\n * states collected under an \"other tasks\" tab. `blocked` is NOT a status —\n * it is a horizontal marker any non-terminal state may carry.\n */\nexport type TaskStatus =\n | 'backlog'\n | 'todo'\n | 'in_progress'\n | 'in_review'\n | 'done'\n | 'canceled'\n | 'archived'\n\n/** Statuses shown as the five main board columns, in order. */\nexport const MAIN_STATUSES: readonly TaskStatus[] = [\n 'backlog',\n 'todo',\n 'in_progress',\n 'in_review',\n 'done',\n]\n\n/** Statuses collected under the secondary tab. */\nexport const SECONDARY_STATUSES: readonly TaskStatus[] = ['canceled', 'archived']\n\n/** Every valid status, main first. */\nexport const ALL_STATUSES: readonly TaskStatus[] = [...MAIN_STATUSES, ...SECONDARY_STATUSES]\n\n/**\n * Legal forward/sideways transitions. Anything not listed is rejected with\n * `invalid_transition`. `archived` is terminal.\n */\nconst TRANSITIONS: Readonly<Record<TaskStatus, readonly TaskStatus[]>> = {\n backlog: ['todo', 'canceled'],\n todo: ['in_progress', 'backlog', 'canceled'],\n in_progress: ['in_review', 'todo', 'canceled'],\n in_review: ['in_progress', 'todo', 'done', 'canceled'],\n done: ['archived'],\n canceled: ['archived', 'todo'],\n archived: [],\n}\n\n/**\n * Whether a status move is legal per the state machine.\n * @param from - current status.\n * @param to - requested status.\n * @returns true when the transition is allowed.\n */\nexport function canTransition(from: TaskStatus, to: TaskStatus): boolean {\n return TRANSITIONS[from].includes(to)\n}\n\n/**\n * The claim move: the one transition that transfers ownership of a task to\n * the calling session. Guarded by the project (workspace) boundary in the\n * tool layer.\n */\nexport function isClaim(from: TaskStatus, to: TaskStatus): boolean {\n return from === 'todo' && to === 'in_progress'\n}\n\n/** Statuses a `done` move may depart from (user confirmation only). */\nexport function canCompleteFrom(from: TaskStatus): boolean {\n return from === 'in_review'\n}\n\n// ---------------------------------------------------------------------------\n// Urgency\n// ---------------------------------------------------------------------------\n\n/** Urgency classes with fixed UI colors. */\nexport type Urgency = 'urgent' | 'normal' | 'relaxed'\n\n/** All valid urgency values. */\nexport const URGENCIES: readonly Urgency[] = ['urgent', 'normal', 'relaxed']\n\n/** CSS color token per urgency: red / purple / blue. */\nexport const URGENCY_COLOR: Readonly<Record<Urgency, string>> = {\n urgent: '#e5484d',\n normal: '#8e4ec6',\n relaxed: '#3e63dd',\n}\n\n// ---------------------------------------------------------------------------\n// Execution\n// ---------------------------------------------------------------------------\n\n/**\n * Per-task code isolation mode (0.3.0).\n * - `worktree`: each execution runs in a fresh `git worktree` on a dedicated\n * task branch (`task/<标题>+<taskId>`) under `<workspace>/.dsh-worktrees/`.\n * - `none`: run in the workspace directory as before, zero git interaction.\n * Omitted = {@link DEFAULT_ISOLATION}; since 0.5.0 creation materializes the\n * board default onto the record (看板设置 → 执行隔离), and non-git projects\n * still auto-degrade at run time (the execution record carries an\n * `isolationNote` explaining why).\n */\nexport type IsolationMode = 'worktree' | 'none'\n\n/**\n * Factory-default isolation (0.5.0): 原目录执行. Applies when neither the\n * task record nor the board setting (`BoardSettings.defaultIsolation`)\n * says otherwise. Before 0.5.0 the implicit default was 'worktree'.\n */\nexport const DEFAULT_ISOLATION: IsolationMode = 'none'\n\n/** Validate an isolation value. */\nexport function asIsolation(raw: string): IsolationMode {\n if (raw !== 'worktree' && raw !== 'none') {\n throw new Error(\"isolation must be 'worktree' or 'none'\")\n }\n return raw\n}\n\n/** Resolve a task's effective isolation (omitted → the factory default). */\nexport function effectiveIsolation(task: Pick<TaskRecord, 'isolation'>): IsolationMode {\n return task.isolation === undefined ? DEFAULT_ISOLATION : task.isolation\n}\n\n/**\n * Board-level settings persisted with the ledger (0.5.0). Only fields the\n * user explicitly set are present; absent fields follow factory defaults.\n */\nexport type BoardSettings = {\n /** Default code isolation applied when a NEW task is created without an explicit choice. */\n defaultIsolation?: IsolationMode\n}\n\n/** Validate raw input into sanitized {@link BoardSettings} (unknown fields dropped). */\nexport function asBoardSettings(raw: unknown): BoardSettings {\n if (typeof raw !== 'object' || raw === null) {\n throw new Error('board settings must be an object')\n }\n const e = raw as Record<string, unknown>\n const out: BoardSettings = {}\n if (e.defaultIsolation !== undefined) {\n if (typeof e.defaultIsolation !== 'string') {\n throw new Error(\"defaultIsolation must be 'worktree' or 'none'\")\n }\n out.defaultIsolation = asIsolation(e.defaultIsolation)\n }\n return out\n}\n\n/** The effective default isolation for NEW tasks (board setting → factory default). */\nexport function defaultIsolationOf(settings?: BoardSettings): IsolationMode {\n return settings?.defaultIsolation ?? DEFAULT_ISOLATION\n}\n\n/** How a task may run. */\nexport type ExecutionMode = 'claim' | 'scheduled'\n\n/**\n * Per-task execution configuration. `claim` tasks wait for an in-project\n * session to claim them; `scheduled` tasks run on the host cron scheduler.\n */\nexport interface ExecutionConfig {\n mode: ExecutionMode\n /** Five-field cron expression (minute hour day month weekday); required for `scheduled`. */\n cron?: string\n /** Next due time (epoch ms); maintained by the host scheduler. */\n nextRunAt?: number\n /** Last time the scheduler triggered this task (epoch ms). */\n lastTriggeredAt?: number\n}\n\n/**\n * Parse a five-field cron expression. Supported field syntax: star, star/step\n * (`* / n` without spaces), a single number, an `a-b` range, and comma lists\n * of those. Day-of-week accepts both 0 and 7 as Sunday (normalized to 0).\n *\n * @param expr - the expression to parse.\n * @returns the match sets per field, or null when invalid.\n */\nexport function parseCron(expr: string): CronMatch | null {\n const fields = expr.trim().split(/\\s+/)\n if (fields.length !== 5) return null\n const ranges: ReadonlyArray<readonly [number, number]> = [\n [0, 59],\n [0, 23],\n [1, 31],\n [1, 12],\n [0, 7],\n ]\n const sets: Array<Set<number>> = []\n for (let i = 0; i < 5; i++) {\n const [min, max] = ranges[i]!\n const set = new Set<number>()\n if (!parseCronField(fields[i]!, min, max, set)) return null\n sets.push(set)\n }\n const weekdays = new Set<number>()\n for (const day of sets[4]!) weekdays.add(day === 7 ? 0 : day)\n return { minutes: sets[0]!, hours: sets[1]!, days: sets[2]!, months: sets[3]!, weekdays }\n}\n\n/** Parsed cron field match sets. */\nexport type CronMatch = {\n minutes: ReadonlySet<number>\n hours: ReadonlySet<number>\n days: ReadonlySet<number>\n months: ReadonlySet<number>\n weekdays: ReadonlySet<number>\n}\n\n/** Parse one cron field into a match set; false on any syntax error. */\nfunction parseCronField(field: string, min: number, max: number, out: Set<number>): boolean {\n for (const part of field.split(',')) {\n const [range, stepRaw] = part.split('/')\n const step = stepRaw === undefined ? 1 : Number.parseInt(stepRaw, 10)\n if (!Number.isInteger(step) || step < 1) return false\n let lo: number\n let hi: number\n if (range === undefined || range === '') return false\n if (range === '*') {\n lo = min\n hi = max\n } else if (range.includes('-')) {\n const [a, b] = range.split('-')\n lo = Number.parseInt(a ?? '', 10)\n hi = Number.parseInt(b ?? '', 10)\n if (!Number.isInteger(lo) || !Number.isInteger(hi)) return false\n } else {\n lo = Number.parseInt(range, 10)\n if (!Number.isInteger(lo)) return false\n hi = stepRaw === undefined ? lo : max\n }\n if (lo < min || hi > max || lo > hi) return false\n for (let v = lo; v <= hi; v += step) out.add(v)\n }\n return out.size > 0\n}\n\n/**\n * The next time at or after `from` matching the cron sets (local time),\n * or null when no match exists within four years (e.g. Feb 30).\n * @param match - parsed cron sets.\n * @param from - epoch ms start point (inclusive match candidate).\n * @returns the next match's epoch ms, or null.\n */\nexport function nextCronTime(match: CronMatch, from: number): number | null {\n // Walk minute by minute from the next whole minute, capped at ~4 years.\n const start = new Date(from)\n start.setSeconds(0, 0)\n start.setMinutes(start.getMinutes() + 1)\n const cap = from + 4 * 366 * 24 * 60 * 60 * 1000\n let t = start.getTime()\n while (t <= cap) {\n const d = new Date(t)\n if (\n match.months.has(d.getMonth() + 1)\n && match.days.has(d.getDate())\n && match.weekdays.has(d.getDay())\n && match.hours.has(d.getHours())\n && match.minutes.has(d.getMinutes())\n ) {\n return t\n }\n t += 60_000\n }\n return null\n}\n\n// ---------------------------------------------------------------------------\n// Records\n// ---------------------------------------------------------------------------\n\n/** Who performed a write. */\nexport type Actor =\n | { kind: 'user' }\n | { kind: 'agent'; sessionId: string }\n | { kind: 'system' }\n\n/** A progress/report comment on a task. */\nexport type CommentRecord = {\n id: string\n /** Comment body (plain text; UI renders as pre-wrapped). */\n body: string\n /** Optimistic-concurrency version of this comment. */\n version: number\n createdAt: number\n /** The session that wrote this comment; absent for user-written ones. */\n threadId?: string\n}\n\n/** One commit produced by an isolated execution (hash + subject). */\nexport type CommitInfo = { hash: string; subject: string }\n\n/**\n * The structured execution report an agent submits at handoff (0.4.0).\n * Commits/dirty/diff facts are host-collected git evidence — the report\n * covers the BUSINESS side the host cannot see.\n */\nexport type ExecutionReport = {\n /** What was done (1..2000 chars, required). */\n summary: string\n /** Files the agent changed (paths, ≤50 × 300 chars). */\n changedFiles: string[]\n /** How the work was self-verified (≤50 × 300 chars). */\n checks: string[]\n /** Produced artifacts worth reviewing (≤30 × 300 chars). */\n artifacts: string[]\n /** Known remaining risks / follow-ups (≤2000 chars, '' allowed). */\n risk: string\n}\n\n/** One Definition-of-Done checklist item (0.4.0). */\nexport type ChecklistItem = {\n id: string\n /** What must be true for acceptance (1..200 chars). */\n text: string\n checked: boolean\n /** Who checked it: an agent session id, or 'user' for GUI toggles. */\n checkedBy?: string\n /** When it was checked (epoch ms). */\n checkedAt?: number\n /** Evidence note attached when checking (≤400 chars). */\n note?: string\n}\n\n/** One execution attempt of a task. */\nexport type ExecutionRecord = {\n id: string\n /** The session this execution ran in; set once the session is really started. */\n sessionId?: string\n /** Trigger: manual button or the host scheduler. */\n trigger: 'manual' | 'scheduled'\n startedAt?: number\n endedAt?: number\n outcome: 'running' | 'succeeded' | 'failed' | 'cancelled'\n error?: string\n /** Code isolation actually used (`none` also covers degraded worktree runs). */\n isolation?: IsolationMode\n /** Why worktree isolation degraded to running in the original directory. */\n isolationNote?: string\n /** The task branch this execution worked on (worktree runs only). */\n branch?: string\n /** Absolute path of the dedicated worktree (worktree runs only). */\n worktreePath?: string\n /** HEAD of the task branch before the execution started. */\n baseCommit?: string\n /** HEAD at settlement. */\n headCommit?: string\n /** Commits between baseCommit and headCommit (hash + subject; capped at 50, newest first). */\n commits?: CommitInfo[]\n /** Total commits before the evidence cap (equals commits.length when under it). */\n commitsTotal?: number\n /** Uncommitted changes present at settlement (`git status --porcelain` lines; capped at 100). */\n dirtyFiles?: string[]\n /** Total uncommitted lines before the evidence cap. */\n dirtyFilesTotal?: number\n /** Aggregate diff stat between baseCommit and headCommit. */\n diffStat?: string\n /** How many files differ between baseCommit and headCommit. */\n changedFiles?: number\n /** The agent's structured report, submitted via taskboard_execution_report. */\n report?: ExecutionReport\n}\n\n/** The per-model override a task may carry; absent = session default model. */\nexport type TaskModel = {\n provider: string\n model: string\n}\n\n/** One task on the board. */\nexport type TaskRecord = {\n id: string\n title: string\n description: string\n /** Extra execution prompt; the execution session receives title+description+prompt. */\n prompt: string\n /** Owning project: a DSH workspace id. */\n workspaceId: string\n urgency: Urgency\n status: TaskStatus\n /** Horizontal marker: work cannot continue right now (any non-terminal status). */\n blocked: boolean\n execution: ExecutionConfig\n model?: TaskModel\n /** Code isolation for executions (omitted = the worktree default; see {@link IsolationMode}). */\n isolation?: IsolationMode\n /**\n * The agent preset execution sessions are composed from (omitted = the\n * deployment default preset). Recorded on the session header and mounted\n * via the presets service at creation — this is what hands the session its\n * tool set. Editable any time (each run composes fresh).\n */\n presetId?: string\n /**\n * Definition-of-Done acceptance checklist (0.4.0). Agents may append items\n * and check/uncheck them (with evidence); the GUI may edit the whole list.\n * Unchecked items highlight at review time; done stays user-only.\n */\n checklist?: ChecklistItem[]\n /**\n * The task branch fixed at the FIRST worktree creation (`task/<标题>+<taskId>`).\n * Renaming the task afterwards never changes it (history preservation).\n */\n branch?: string\n /**\n * The session currently holding the in-progress claim (explicit claim or a\n * live execution). Present only while `status === 'in_progress'`: any move\n * out of in_progress releases it. `updatedBy` is audit-only — user edits no\n * longer erase the holder.\n */\n claimedBy?: string\n /** When the current holder claimed the task (epoch ms). */\n claimedAt?: number\n version: number\n createdAt: number\n updatedAt: number\n createdBy: Actor\n updatedBy: Actor\n comments: CommentRecord[]\n executions: ExecutionRecord[]\n /** How many older execution records were pruned by the retention cap. */\n executionsPruned?: number\n /** Soft-delete marker set by agent `taskboard_delete`; user confirms the purge. */\n trashedAt?: number\n}\n\n/** Retention cap: how many execution records each task keeps (oldest pruned). */\nexport const MAX_EXECUTIONS = 20\n\n/**\n * Enforce the execution-record retention cap on one task (in place): keep the\n * newest {@link MAX_EXECUTIONS} records, count the dropped ones in\n * `executionsPruned`. Running records are always the newest, never dropped.\n * @param task - the task to prune.\n */\nexport function pruneExecutions(task: TaskRecord): void {\n if (task.executions.length <= MAX_EXECUTIONS) return\n const dropped = task.executions.length - MAX_EXECUTIONS\n task.executions = task.executions.slice(-MAX_EXECUTIONS)\n task.executionsPruned = (task.executionsPruned ?? 0) + dropped\n}\n\n/** The whole durable ledger. */\nexport type TaskLedger = {\n schemaVersion: number\n /** Global monotonic revision; every mutation bumps it. */\n revision: number\n tasks: TaskRecord[]\n /** Board-level settings (0.5.0); absent on ledgers never touched by 设置. */\n settings?: BoardSettings\n}\n\n/** Current ledger format version. */\nexport const LEDGER_SCHEMA_VERSION = 1\n\n/** An empty ledger. */\nexport function emptyLedger(): TaskLedger {\n return { schemaVersion: LEDGER_SCHEMA_VERSION, revision: 0, tasks: [] }\n}\n\n// ---------------------------------------------------------------------------\n// ids\n// ---------------------------------------------------------------------------\n\n/** Random base36 suffix. */\nfunction suffix(): string {\n return Math.random().toString(36).slice(2, 8)\n}\n\n/**\n * Legal task id charset (R4): `t-<base36>-<base36>` from {@link newTaskId},\n * and the ONLY shape accepted from the outside (import) or used to build\n * filesystem paths (worktree dirs). Ids ride into `join(ws, '.dsh-worktrees',\n * id)` — a lax charset here is an arbitrary-directory delete primitive.\n */\nexport function isValidTaskId(id: string): boolean {\n return /^[A-Za-z0-9][A-Za-z0-9_-]{0,99}$/.test(id)\n}\n\n/** Mint a task id. */\nexport function newTaskId(): string {\n return `t-${Date.now().toString(36)}-${suffix()}`\n}\n\n/** Mint a comment id. */\nexport function newCommentId(): string {\n return `c-${Date.now().toString(36)}-${suffix()}`\n}\n\n/** Mint a checklist item id. */\nexport function newChecklistItemId(): string {\n return `k-${Date.now().toString(36)}-${suffix()}`\n}\n\n/** Mint an execution id. */\nexport function newExecutionId(): string {\n return `e-${Date.now().toString(36)}-${suffix()}`\n}\n\n// ---------------------------------------------------------------------------\n// validation helpers (input shaping for tools and routes)\n// ---------------------------------------------------------------------------\n\n/**\n * Validate and normalize a title: trimmed, 1..200 chars.\n * @param raw - the raw input.\n * @returns the normalized title.\n * @throws when empty or too long.\n */\nexport function normalizeTitle(raw: string): string {\n const t = raw.trim()\n if (t.length === 0 || t.length > 200) {\n throw new Error('title must be 1..200 characters')\n }\n return t\n}\n\n/**\n * Validate a task prompt: trimmed, at most 8000 chars; empty becomes ''.\n * @param raw - the raw input.\n */\nexport function normalizePrompt(raw: string | undefined): string {\n const t = (raw ?? '').trim()\n if (t.length > 8000) throw new Error('prompt must be at most 8000 characters')\n return t\n}\n\n/**\n * Validate and normalize a comment body: trimmed, 1..4000 chars.\n * @param raw - the raw input.\n */\nexport function normalizeBody(raw: string): string {\n const t = raw.trim()\n if (t.length === 0 || t.length > 4000) {\n throw new Error('comment body must be 1..4000 characters')\n }\n return t\n}\n\n/**\n * Validate an urgency value.\n * @param raw - the raw input.\n */\nexport function asUrgency(raw: string): Urgency {\n if (!URGENCIES.includes(raw as Urgency)) {\n throw new Error(`urgency must be one of: ${URGENCIES.join(', ')}`)\n }\n return raw as Urgency\n}\n\n/**\n * Validate a status value.\n * @param raw - the raw input.\n */\nexport function asStatus(raw: string): TaskStatus {\n if (!ALL_STATUSES.includes(raw as TaskStatus)) {\n throw new Error(`status must be one of: ${ALL_STATUSES.join(', ')}`)\n }\n return raw as TaskStatus\n}\n\n/**\n * Validate an execution config request from raw tool/route input.\n * `scheduled` requires a valid cron; computes the first `nextRunAt` from\n * `now`.\n * @param raw - raw execution input ({@link ExecutionConfig} fields, untyped).\n * @param now - current epoch ms.\n * @returns the normalized config.\n */\nexport function normalizeExecution(\n raw: { mode?: string; cron?: string },\n now: number,\n): ExecutionConfig {\n const mode = raw.mode ?? 'claim'\n if (mode !== 'claim' && mode !== 'scheduled') {\n throw new Error(\"execution.mode must be 'claim' or 'scheduled'\")\n }\n if (mode === 'claim') return { mode }\n const cron = (raw.cron ?? '').trim()\n const match = parseCron(cron)\n if (match === null) throw new Error('execution.cron is not a valid 5-field cron expression')\n const next = nextCronTime(match, now)\n if (next === null) throw new Error('execution.cron never matches within 4 years')\n return { mode, cron, nextRunAt: next }\n}\n\n/**\n * The effective prompt of a task: title+description, with the explicit\n * prompt appended when set — title+description+prompt.\n * @param task - the task.\n */\nexport function effectivePrompt(task: TaskRecord): string {\n const head = task.title\n const body = task.description.length > 0 ? `${head}\\n\\n${task.description}` : head\n return task.prompt.length > 0 ? `${body}\\n\\n${task.prompt}` : body\n}\n\n/**\n * Whether the task is currently claimed by a session (running state).\n * @param task - the task.\n */\nexport function isClaimedBy(task: TaskRecord): string | undefined {\n return task.status === 'in_progress' && task.claimedBy !== undefined ? task.claimedBy : undefined\n}\n\n/**\n * Maintain the explicit claim fields around a status change: entering\n * in_progress under a session records the holder (an execution-start or an\n * agent claim); every move out of in_progress releases the claim (handoff,\n * give-back, cancel). A user-driven move into in_progress records no holder —\n * no session works on it yet.\n * @param task - the task being written (mutated in place).\n * @param to - the target status.\n * @param now - current epoch ms.\n * @param holder - the session id claiming the task, when applicable.\n */\nexport function syncClaim(task: TaskRecord, to: TaskStatus, now: number, holder?: string): void {\n if (to !== 'in_progress') {\n delete task.claimedBy\n delete task.claimedAt\n } else if (holder !== undefined) {\n task.claimedBy = holder\n task.claimedAt = now\n }\n}\n\n/**\n * Validate and normalize a pinned model: `{ provider, model }`, both\n * non-empty trimmed strings.\n * @param raw - the raw input.\n * @returns the normalized model.\n * @throws when the shape or the fields are invalid.\n */\nexport function normalizeModel(raw: unknown): TaskModel {\n if (typeof raw !== 'object' || raw === null) {\n throw new Error('model must be { provider: string, model: string }')\n }\n const { provider, model } = raw as { provider?: unknown; model?: unknown }\n if (typeof provider !== 'string' || typeof model !== 'string') {\n throw new Error('model must be { provider: string, model: string }')\n }\n const p = provider.trim()\n const m = model.trim()\n if (p.length === 0 || m.length === 0) {\n throw new Error('model.provider and model.model must be non-empty strings')\n }\n return { provider: p, model: m }\n}\n\n// ---------------------------------------------------------------------------\n// checklist + report validation (0.4.0)\n// ---------------------------------------------------------------------------\n\n/** Checklist size cap per task. */\nexport const MAX_CHECKLIST_ITEMS = 30\n\n/** Checklist item text cap (chars). */\nexport const MAX_CHECKLIST_TEXT = 200\n\n/**\n * Validate and normalize one checklist text line: trimmed, 1..200 chars.\n * @param raw - the raw text.\n * @throws when empty or too long.\n */\nexport function normalizeChecklistText(raw: string): string {\n const t = raw.trim()\n if (t.length === 0 || t.length > MAX_CHECKLIST_TEXT) {\n throw new Error(`checklist item text must be 1..${MAX_CHECKLIST_TEXT} characters`)\n }\n return t\n}\n\n/**\n * Build a fresh unchecked checklist from plain text lines (create route /\n * templates / tool adds).\n * @param texts - the item texts (validated individually).\n */\nexport function checklistFromTexts(texts: readonly string[]): ChecklistItem[] {\n const items = texts.map(text => ({ id: newChecklistItemId(), text: normalizeChecklistText(text), checked: false }))\n if (items.length > MAX_CHECKLIST_ITEMS) {\n throw new Error(`checklist may hold at most ${MAX_CHECKLIST_ITEMS} items`)\n }\n return items\n}\n\n/**\n * Validate and normalize a full checklist array (GUI update route, import):\n * missing ids are minted, text is checked, checked flags must be booleans,\n * checkedBy/checkedAt are kept only on checked items.\n * @param raw - untyped array from the wire.\n * @throws with a readable reason on any invalid entry.\n */\nexport function normalizeChecklist(raw: unknown): ChecklistItem[] {\n if (!Array.isArray(raw)) throw new Error('checklist must be an array')\n if (raw.length > MAX_CHECKLIST_ITEMS) {\n throw new Error(`checklist may hold at most ${MAX_CHECKLIST_ITEMS} items`)\n }\n return raw.map((entry): ChecklistItem => {\n if (typeof entry !== 'object' || entry === null) throw new Error('checklist item must be an object')\n const e = entry as Record<string, unknown>\n const text = normalizeChecklistText(typeof e.text === 'string' ? e.text : '')\n const id = typeof e.id === 'string' && e.id.trim().length > 0 ? e.id.trim() : newChecklistItemId()\n const checked = e.checked === true\n const checkedBy = typeof e.checkedBy === 'string' ? e.checkedBy.trim().slice(0, 100) : undefined\n const checkedAt = typeof e.checkedAt === 'number' && Number.isFinite(e.checkedAt) ? e.checkedAt : undefined\n const note = typeof e.note === 'string' && e.note.trim().length > 0 ? e.note.trim().slice(0, 400) : undefined\n if (!checked) return { id, text, checked: false }\n return {\n id,\n text,\n checked: true,\n ...(checkedBy !== undefined && checkedBy.length > 0 ? { checkedBy } : {}),\n ...(checkedAt !== undefined ? { checkedAt } : {}),\n ...(note !== undefined ? { note } : {}),\n }\n })\n}\n\n/** Checklist progress: how many items are checked (absent checklist → 0/0). */\nexport function checklistProgress(task: Pick<TaskRecord, 'checklist'>): { done: number; total: number } {\n const items = task.checklist ?? []\n return { done: items.filter(i => i.checked).length, total: items.length }\n}\n\n/** Report string-list caps. */\nconst REPORT_LIST_CAPS = { changedFiles: 50, checks: 50, artifacts: 30 } as const\n\n/** Per-entry cap for report lists (chars). */\nconst REPORT_ENTRY_MAX = 300\n\n/** Validate one report string list: strings trimmed 1..300 chars. */\nfunction normalizeReportList(raw: unknown, field: keyof typeof REPORT_LIST_CAPS): string[] {\n if (raw === undefined) return []\n if (!Array.isArray(raw)) throw new Error(`report.${field} must be an array of strings`)\n const out = raw.map(entry => {\n if (typeof entry !== 'string') throw new Error(`report.${field} must be an array of strings`)\n const t = entry.trim()\n if (t.length === 0 || t.length > REPORT_ENTRY_MAX) {\n throw new Error(`report.${field} entries must be 1..${REPORT_ENTRY_MAX} characters`)\n }\n return t\n })\n if (out.length > REPORT_LIST_CAPS[field]) {\n throw new Error(`report.${field} may hold at most ${REPORT_LIST_CAPS[field]} entries`)\n }\n return out\n}\n\n/**\n * Validate and normalize a structured execution report.\n * @param raw - untyped tool/route input.\n * @throws with a readable reason on any invalid field.\n */\nexport function normalizeExecutionReport(raw: unknown): ExecutionReport {\n if (typeof raw !== 'object' || raw === null) throw new Error('report must be an object')\n const e = raw as Record<string, unknown>\n const summary = typeof e.summary === 'string' ? e.summary.trim() : ''\n if (summary.length === 0 || summary.length > 2000) {\n throw new Error('report.summary must be 1..2000 characters')\n }\n const risk = typeof e.risk === 'string' ? e.risk.trim().slice(0, 2000) : ''\n return {\n summary,\n changedFiles: normalizeReportList(e.changedFiles, 'changedFiles'),\n checks: normalizeReportList(e.checks, 'checks'),\n artifacts: normalizeReportList(e.artifacts, 'artifacts'),\n risk,\n }\n}\n\n// ---------------------------------------------------------------------------\n// ledger import validation (0.4.0)\n// ---------------------------------------------------------------------------\n\n/** Result classifying every task in an import file against the live ledger. */\nexport type ImportPlan = {\n /** Structurally valid tasks whose ids are new (merge adds them). */\n create: TaskRecord[]\n /** Structurally valid tasks whose ids already exist (merge replaces them). */\n overwrite: TaskRecord[]\n /** Invalid entries with a human-readable reason (never imported). */\n invalid: Array<{ id?: string; reason: string }>\n /** The file's board settings (0.5.0); replace-mode swaps them, merge keeps the live ones. */\n settings?: BoardSettings\n}\n\n/** One unknown-value read helper: string fields with defaults. */\nfunction strOr(raw: Record<string, unknown>, key: string, fallback: string): string {\n const v = raw[key]\n return typeof v === 'string' ? v : fallback\n}\n\n/** One unknown-value read helper: finite numbers with defaults. */\nfunction numOr(raw: Record<string, unknown>, key: string, fallback: number): number {\n const v = raw[key]\n return typeof v === 'number' && Number.isFinite(v) ? v : fallback\n}\n\n/**\n * Validate ONE imported task record (pure): rebuilds it field by field with\n * the normal validators, minting missing ids and re-arming cron. Executions\n * left `running` by the exporting machine are marked failed — their\n * settlement watchers died there and can never settle here.\n * @param raw - the untyped record.\n * @param now - current epoch ms (defaults for timestamps).\n * @returns the rebuilt record, or a rejection reason.\n */\nexport function validateImportedTask(raw: unknown, now: number): { ok: true; task: TaskRecord } | { ok: false; reason: string } {\n if (typeof raw !== 'object' || raw === null) return { ok: false, reason: 'not an object' }\n const e = raw as Record<string, unknown>\n const id = typeof e.id === 'string' ? e.id.trim() : ''\n const fail = (reason: string): { ok: false; reason: string } => ({ ok: false, reason })\n // R4①: length alone let traversal-shaped ids (`../../x`, `..\\..\\x`) into\n // the ledger; the charset gate is the primary defense for every downstream\n // filesystem use of a task id.\n if (!isValidTaskId(id)) return fail('missing/invalid id (must match ^[A-Za-z0-9][A-Za-z0-9_-]{0,99}$)')\n try {\n const execution = normalizeExecution(\n typeof e.execution === 'object' && e.execution !== null ? e.execution as { mode?: string; cron?: string } : {},\n now,\n )\n const comments: CommentRecord[] = []\n if (Array.isArray(e.comments)) {\n for (const c of e.comments) {\n if (typeof c !== 'object' || c === null) return fail('invalid comment entry')\n const ce = c as Record<string, unknown>\n const body = typeof ce.body === 'string' ? ce.body : ''\n if (body.trim().length === 0 || body.length > 4000) return fail('invalid comment body')\n comments.push({\n id: typeof ce.id === 'string' && ce.id.length > 0 ? ce.id : newCommentId(),\n body,\n version: numOr(ce, 'version', 1),\n createdAt: numOr(ce, 'createdAt', now),\n ...(typeof ce.threadId === 'string' ? { threadId: ce.threadId } : {}),\n })\n }\n } else return fail('comments must be an array')\n const executions: ExecutionRecord[] = []\n if (Array.isArray(e.executions)) {\n for (const x of e.executions) {\n if (typeof x !== 'object' || x === null) return fail('invalid execution entry')\n const xe = x as Record<string, unknown>\n const trigger = xe.trigger === 'scheduled' ? 'scheduled' : 'manual'\n const outcomeRaw = xe.outcome\n if (outcomeRaw !== 'running' && outcomeRaw !== 'succeeded' && outcomeRaw !== 'failed' && outcomeRaw !== 'cancelled') {\n return fail('invalid execution outcome')\n }\n // A running execution from the exporting machine can never settle\n // here — import it as failed with the reason recorded.\n const outcome = outcomeRaw === 'running' ? 'failed' as const : outcomeRaw\n executions.push({\n id: typeof xe.id === 'string' && xe.id.length > 0 ? xe.id : newExecutionId(),\n ...(typeof xe.sessionId === 'string' ? { sessionId: xe.sessionId } : {}),\n trigger,\n ...(typeof xe.startedAt === 'number' ? { startedAt: xe.startedAt } : {}),\n ...(typeof xe.endedAt === 'number' ? { endedAt: xe.endedAt } : {}),\n outcome,\n ...(outcomeRaw === 'running' ? { error: 'imported while still running (settlement watcher died with the exporting host)' } : (typeof xe.error === 'string' ? { error: xe.error } : {})),\n ...(typeof xe.isolation === 'string' && (xe.isolation === 'worktree' || xe.isolation === 'none') ? { isolation: xe.isolation } : {}),\n ...(typeof xe.isolationNote === 'string' ? { isolationNote: xe.isolationNote } : {}),\n ...(typeof xe.branch === 'string' ? { branch: xe.branch } : {}),\n ...(typeof xe.worktreePath === 'string' ? { worktreePath: xe.worktreePath } : {}),\n ...(typeof xe.baseCommit === 'string' ? { baseCommit: xe.baseCommit } : {}),\n ...(typeof xe.headCommit === 'string' ? { headCommit: xe.headCommit } : {}),\n ...(Array.isArray(xe.commits) ? { commits: xe.commits.filter((c): c is CommitInfo =>\n typeof c === 'object' && c !== null && typeof (c as CommitInfo).hash === 'string' && typeof (c as CommitInfo).subject === 'string') } : {}),\n ...(typeof xe.commitsTotal === 'number' ? { commitsTotal: xe.commitsTotal } : {}),\n ...(Array.isArray(xe.dirtyFiles) ? { dirtyFiles: xe.dirtyFiles.filter((l): l is string => typeof l === 'string') } : {}),\n ...(typeof xe.dirtyFilesTotal === 'number' ? { dirtyFilesTotal: xe.dirtyFilesTotal } : {}),\n ...(typeof xe.diffStat === 'string' ? { diffStat: xe.diffStat } : {}),\n ...(typeof xe.changedFiles === 'number' ? { changedFiles: xe.changedFiles } : {}),\n ...(typeof xe.report === 'object' && xe.report !== null ? { report: normalizeExecutionReport(xe.report) } : {}),\n })\n }\n } else return fail('executions must be an array')\n const status = asStatus(strOr(e, 'status', 'todo'))\n const actorOf = (v: unknown): Actor => (typeof v === 'object' && v !== null && (v as Actor).kind === 'agent' && typeof (v as { sessionId?: unknown }).sessionId === 'string'\n ? { kind: 'agent', sessionId: (v as { sessionId: string }).sessionId }\n : { kind: 'user' })\n const task: TaskRecord = {\n id,\n title: normalizeTitle(strOr(e, 'title', '')),\n description: strOr(e, 'description', '').trim(),\n prompt: normalizePrompt(strOr(e, 'prompt', '')),\n workspaceId: strOr(e, 'workspaceId', ''),\n urgency: asUrgency(strOr(e, 'urgency', 'normal')),\n status,\n blocked: e.blocked === true,\n execution,\n ...(typeof e.model === 'object' && e.model !== null ? { model: normalizeModel(e.model) } : {}),\n ...(typeof e.isolation === 'string' && (e.isolation === 'worktree' || e.isolation === 'none') ? { isolation: e.isolation } : {}),\n ...(typeof e.presetId === 'string' && e.presetId.trim().length > 0 ? { presetId: e.presetId.trim() } : {}),\n ...(Array.isArray(e.checklist) ? { checklist: normalizeChecklist(e.checklist) } : {}),\n ...(typeof e.branch === 'string' ? { branch: e.branch } : {}),\n ...(status === 'in_progress' && typeof e.claimedBy === 'string' ? { claimedBy: e.claimedBy } : {}),\n ...(status === 'in_progress' && typeof e.claimedAt === 'number' ? { claimedAt: e.claimedAt } : {}),\n version: Math.max(1, Math.trunc(numOr(e, 'version', 1))),\n createdAt: numOr(e, 'createdAt', now),\n updatedAt: numOr(e, 'updatedAt', now),\n createdBy: actorOf(e.createdBy),\n updatedBy: actorOf(e.updatedBy),\n comments,\n executions,\n ...(typeof e.executionsPruned === 'number' ? { executionsPruned: e.executionsPruned } : {}),\n ...(typeof e.trashedAt === 'number' ? { trashedAt: e.trashedAt } : {}),\n }\n if (task.workspaceId.length === 0) return fail('missing workspaceId')\n return { ok: true, task }\n } catch (error) {\n return fail(error instanceof Error ? error.message : String(error))\n }\n}\n\n/**\n * Minimal structural check for ONE ledger record at load time (S11): unlike\n * {@link validateImportedTask} this REBUILDS NOTHING (cron state, ids and\n * timestamps must survive a load untouched) — it only rejects entries whose\n * shape would break downstream consumers, including the R4 id charset.\n * @param raw - the untyped record.\n */\nexport function isPlausibleTaskRecord(raw: unknown): boolean {\n if (typeof raw !== 'object' || raw === null) return false\n const t = raw as Record<string, unknown>\n return typeof t.id === 'string' && isValidTaskId(t.id)\n && typeof t.title === 'string' && t.title.length > 0\n && typeof t.workspaceId === 'string' && t.workspaceId.length > 0\n && ALL_STATUSES.includes(t.status as TaskStatus)\n && typeof t.version === 'number' && Number.isFinite(t.version) && t.version >= 1\n && Array.isArray(t.comments) && Array.isArray(t.executions)\n && typeof t.execution === 'object' && t.execution !== null\n && (t.execution as { mode?: unknown }).mode !== undefined\n}\n\n/**\n * Validate a whole imported ledger and classify its tasks against the live\n * one (pure). Duplicate ids INSIDE the file are invalid (first wins, later\n * copies reported); schemaVersion must match {@link LEDGER_SCHEMA_VERSION}.\n * @param raw - the parsed import file.\n * @param knownIds - live ledger task ids.\n * @param now - current epoch ms.\n * @throws when the file is not a ledger or the schemaVersion is unsupported.\n */\nexport function validateLedgerImport(raw: unknown, knownIds: ReadonlySet<string>, now: number): ImportPlan {\n if (typeof raw !== 'object' || raw === null) throw new Error('导入文件不是 JSON 对象')\n const e = raw as Record<string, unknown>\n if (e.schemaVersion !== LEDGER_SCHEMA_VERSION) {\n throw new Error(`不支持的 schemaVersion ${String(e.schemaVersion)}(当前支持 ${LEDGER_SCHEMA_VERSION})`)\n }\n if (!Array.isArray(e.tasks)) throw new Error('导入文件的 tasks 不是数组')\n const plan: ImportPlan = { create: [], overwrite: [], invalid: [], ...(e.settings !== undefined ? { settings: asBoardSettings(e.settings) } : {}) }\n const seen = new Set<string>()\n for (const entry of e.tasks) {\n const id = typeof (entry as { id?: unknown })?.id === 'string' ? (entry as { id: string }).id : undefined\n const result = validateImportedTask(entry, now)\n if (!result.ok) {\n plan.invalid.push({ ...(id !== undefined ? { id } : {}), reason: result.reason })\n continue\n }\n if (seen.has(result.task.id)) {\n plan.invalid.push({ id: result.task.id, reason: '文件内重复 id' })\n continue\n }\n seen.add(result.task.id)\n if (knownIds.has(result.task.id)) plan.overwrite.push(result.task)\n else plan.create.push(result.task)\n }\n return plan\n}\n\n/**\n * Compact list-projection of a task (token-friendly for `taskboard_list`).\n * @param task - the task.\n */\nexport type TaskSummary = {\n id: string\n title: string\n workspaceId: string\n urgency: Urgency\n status: TaskStatus\n blocked: boolean\n executionMode: ExecutionMode\n nextRunAt?: number\n model?: TaskModel\n version: number\n claimOwner?: string\n commentCount: number\n lastExecutionOutcome?: ExecutionRecord['outcome']\n /** Checklist progress (present only when the task has a checklist). */\n checklist?: { done: number; total: number }\n trashed: boolean\n}\n\n/**\n * Build the compact summary of a task.\n * @param task - the task.\n */\nexport function summarize(task: TaskRecord): TaskSummary {\n const last = task.executions.length > 0 ? task.executions[task.executions.length - 1] : undefined\n const checklist = task.checklist !== undefined && task.checklist.length > 0 ? checklistProgress(task) : undefined\n return {\n id: task.id,\n title: task.title,\n workspaceId: task.workspaceId,\n urgency: task.urgency,\n status: task.status,\n blocked: task.blocked,\n executionMode: task.execution.mode,\n nextRunAt: task.execution.nextRunAt,\n model: task.model,\n version: task.version,\n claimOwner: isClaimedBy(task),\n commentCount: task.comments.length,\n lastExecutionOutcome: last?.outcome,\n ...(checklist !== undefined ? { checklist } : {}),\n trashed: task.trashedAt !== undefined,\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"protocol.js","names":[],"sources":["../../src/shared/protocol.ts"],"sourcesContent":["/**\n * Task domain model, state machine, urgency classes, and cron math — the\n * framework-free core shared verbatim by the host half (tools, store, routes,\n * scheduler) and, from P2 on, the browser half (board view).\n *\n * Everything here is a pure function over plain data: no imports beyond the\n * standard library, no I/O, no globals. Tests drive it directly.\n *\n * @module dsh-taskboard/shared/protocol\n */\n\n// ---------------------------------------------------------------------------\n// Status vocabulary\n// ---------------------------------------------------------------------------\n\n/**\n * Task lifecycle states. Main board columns render `backlog → todo →\n * in_progress → in_review → done`; `canceled` and `archived` are secondary\n * states collected under an \"other tasks\" tab. `blocked` is NOT a status —\n * it is a horizontal marker any non-terminal state may carry.\n */\nexport type TaskStatus =\n | 'backlog'\n | 'todo'\n | 'in_progress'\n | 'in_review'\n | 'done'\n | 'canceled'\n | 'archived'\n\n/** Statuses shown as the five main board columns, in order. */\nexport const MAIN_STATUSES: readonly TaskStatus[] = [\n 'backlog',\n 'todo',\n 'in_progress',\n 'in_review',\n 'done',\n]\n\n/** Statuses collected under the secondary tab. */\nexport const SECONDARY_STATUSES: readonly TaskStatus[] = ['canceled', 'archived']\n\n/** Every valid status, main first. */\nexport const ALL_STATUSES: readonly TaskStatus[] = [...MAIN_STATUSES, ...SECONDARY_STATUSES]\n\n/**\n * Legal forward/sideways transitions. Anything not listed is rejected with\n * `invalid_transition`. `archived` is terminal.\n */\nconst TRANSITIONS: Readonly<Record<TaskStatus, readonly TaskStatus[]>> = {\n backlog: ['todo', 'canceled'],\n todo: ['in_progress', 'backlog', 'canceled'],\n in_progress: ['in_review', 'todo', 'canceled'],\n in_review: ['in_progress', 'todo', 'done', 'canceled'],\n done: ['archived'],\n canceled: ['archived', 'todo'],\n archived: [],\n}\n\n/**\n * Whether a status move is legal per the state machine.\n * @param from - current status.\n * @param to - requested status.\n * @returns true when the transition is allowed.\n */\nexport function canTransition(from: TaskStatus, to: TaskStatus): boolean {\n return TRANSITIONS[from].includes(to)\n}\n\n/**\n * The claim move: the one transition that transfers ownership of a task to\n * the calling session. Guarded by the project (workspace) boundary in the\n * tool layer.\n */\nexport function isClaim(from: TaskStatus, to: TaskStatus): boolean {\n return from === 'todo' && to === 'in_progress'\n}\n\n/** Statuses a `done` move may depart from (user confirmation only). */\nexport function canCompleteFrom(from: TaskStatus): boolean {\n return from === 'in_review'\n}\n\n// ---------------------------------------------------------------------------\n// Urgency\n// ---------------------------------------------------------------------------\n\n/** Urgency classes with fixed UI colors. */\nexport type Urgency = 'urgent' | 'normal' | 'relaxed'\n\n/** All valid urgency values. */\nexport const URGENCIES: readonly Urgency[] = ['urgent', 'normal', 'relaxed']\n\n/** CSS color token per urgency: red / purple / blue. */\nexport const URGENCY_COLOR: Readonly<Record<Urgency, string>> = {\n urgent: '#e5484d',\n normal: '#8e4ec6',\n relaxed: '#3e63dd',\n}\n\n// ---------------------------------------------------------------------------\n// Execution\n// ---------------------------------------------------------------------------\n\n/**\n * Per-task code isolation mode (0.3.0).\n * - `worktree`: each execution runs in a fresh `git worktree` on a dedicated\n * task branch (`task/<标题>+<taskId>`) under `<workspace>/.dsh-worktrees/`.\n * - `none`: run in the workspace directory as before, zero git interaction.\n * Omitted = {@link DEFAULT_ISOLATION}; since 0.5.0 creation materializes the\n * board default onto the record (看板设置 → 执行隔离), and non-git projects\n * still auto-degrade at run time (the execution record carries an\n * `isolationNote` explaining why).\n */\nexport type IsolationMode = 'worktree' | 'none'\n\n/**\n * Factory-default isolation (0.5.0): 原目录执行. Applies when neither the\n * task record nor the board setting (`BoardSettings.defaultIsolation`)\n * says otherwise. Before 0.5.0 the implicit default was 'worktree'.\n */\nexport const DEFAULT_ISOLATION: IsolationMode = 'none'\n\n/** Validate an isolation value. */\nexport function asIsolation(raw: string): IsolationMode {\n if (raw !== 'worktree' && raw !== 'none') {\n throw new Error(\"isolation must be 'worktree' or 'none'\")\n }\n return raw\n}\n\n/** Resolve a task's effective isolation (omitted → the factory default). */\nexport function effectiveIsolation(task: Pick<TaskRecord, 'isolation'>): IsolationMode {\n return task.isolation === undefined ? DEFAULT_ISOLATION : task.isolation\n}\n\n/**\n * Board-level settings persisted with the ledger (0.5.0). Only fields the\n * user explicitly set are present; absent fields follow factory defaults.\n */\nexport type BoardSettings = {\n /** Default code isolation applied when a NEW task is created without an explicit choice. */\n defaultIsolation?: IsolationMode\n}\n\n/** Validate raw input into sanitized {@link BoardSettings} (unknown fields dropped). */\nexport function asBoardSettings(raw: unknown): BoardSettings {\n if (typeof raw !== 'object' || raw === null) {\n throw new Error('board settings must be an object')\n }\n const e = raw as Record<string, unknown>\n const out: BoardSettings = {}\n if (e.defaultIsolation !== undefined) {\n if (typeof e.defaultIsolation !== 'string') {\n throw new Error(\"defaultIsolation must be 'worktree' or 'none'\")\n }\n out.defaultIsolation = asIsolation(e.defaultIsolation)\n }\n return out\n}\n\n/** The effective default isolation for NEW tasks (board setting → factory default). */\nexport function defaultIsolationOf(settings?: BoardSettings): IsolationMode {\n return settings?.defaultIsolation ?? DEFAULT_ISOLATION\n}\n\n/** How a task may run. */\nexport type ExecutionMode = 'claim' | 'scheduled'\n\n/**\n * Per-task execution configuration. `claim` tasks wait for an in-project\n * session to claim them; `scheduled` tasks run on the host cron scheduler.\n */\nexport interface ExecutionConfig {\n mode: ExecutionMode\n /** Five-field cron expression (minute hour day month weekday); required for `scheduled`. */\n cron?: string\n /** Next due time (epoch ms); maintained by the host scheduler. */\n nextRunAt?: number\n /** Last time the scheduler triggered this task (epoch ms). */\n lastTriggeredAt?: number\n}\n\n/**\n * Parse a five-field cron expression. Supported field syntax: star, star/step\n * (`* / n` without spaces), a single number, an `a-b` range, and comma lists\n * of those. Day-of-week accepts both 0 and 7 as Sunday (normalized to 0).\n *\n * @param expr - the expression to parse.\n * @returns the match sets per field, or null when invalid.\n */\nexport function parseCron(expr: string): CronMatch | null {\n const fields = expr.trim().split(/\\s+/)\n if (fields.length !== 5) return null\n const ranges: ReadonlyArray<readonly [number, number]> = [\n [0, 59],\n [0, 23],\n [1, 31],\n [1, 12],\n [0, 7],\n ]\n const sets: Array<Set<number>> = []\n for (let i = 0; i < 5; i++) {\n const [min, max] = ranges[i]!\n const set = new Set<number>()\n if (!parseCronField(fields[i]!, min, max, set)) return null\n sets.push(set)\n }\n const weekdays = new Set<number>()\n for (const day of sets[4]!) weekdays.add(day === 7 ? 0 : day)\n return { minutes: sets[0]!, hours: sets[1]!, days: sets[2]!, months: sets[3]!, weekdays }\n}\n\n/** Parsed cron field match sets. */\nexport type CronMatch = {\n minutes: ReadonlySet<number>\n hours: ReadonlySet<number>\n days: ReadonlySet<number>\n months: ReadonlySet<number>\n weekdays: ReadonlySet<number>\n}\n\n/** Parse one cron field into a match set; false on any syntax error. */\nfunction parseCronField(field: string, min: number, max: number, out: Set<number>): boolean {\n for (const part of field.split(',')) {\n const [range, stepRaw] = part.split('/')\n const step = stepRaw === undefined ? 1 : Number.parseInt(stepRaw, 10)\n if (!Number.isInteger(step) || step < 1) return false\n let lo: number\n let hi: number\n if (range === undefined || range === '') return false\n if (range === '*') {\n lo = min\n hi = max\n } else if (range.includes('-')) {\n const [a, b] = range.split('-')\n lo = Number.parseInt(a ?? '', 10)\n hi = Number.parseInt(b ?? '', 10)\n if (!Number.isInteger(lo) || !Number.isInteger(hi)) return false\n } else {\n lo = Number.parseInt(range, 10)\n if (!Number.isInteger(lo)) return false\n hi = stepRaw === undefined ? lo : max\n }\n if (lo < min || hi > max || lo > hi) return false\n for (let v = lo; v <= hi; v += step) out.add(v)\n }\n return out.size > 0\n}\n\n/**\n * The next time at or after `from` matching the cron sets (local time),\n * or null when no match exists within four years (e.g. Feb 30).\n * @param match - parsed cron sets.\n * @param from - epoch ms start point (inclusive match candidate).\n * @returns the next match's epoch ms, or null.\n */\nexport function nextCronTime(match: CronMatch, from: number): number | null {\n // Walk minute by minute from the next whole minute, capped at ~4 years.\n const start = new Date(from)\n start.setSeconds(0, 0)\n start.setMinutes(start.getMinutes() + 1)\n const cap = from + 4 * 366 * 24 * 60 * 60 * 1000\n let t = start.getTime()\n while (t <= cap) {\n const d = new Date(t)\n if (\n match.months.has(d.getMonth() + 1)\n && match.days.has(d.getDate())\n && match.weekdays.has(d.getDay())\n && match.hours.has(d.getHours())\n && match.minutes.has(d.getMinutes())\n ) {\n return t\n }\n t += 60_000\n }\n return null\n}\n\n// ---------------------------------------------------------------------------\n// Records\n// ---------------------------------------------------------------------------\n\n/** Who performed a write. */\nexport type Actor =\n | { kind: 'user' }\n | { kind: 'agent'; sessionId: string }\n | { kind: 'system' }\n\n/** A progress/report comment on a task. */\nexport type CommentRecord = {\n id: string\n /** Comment body (plain text; UI renders as pre-wrapped). */\n body: string\n /** Optimistic-concurrency version of this comment. */\n version: number\n createdAt: number\n /** The session that wrote this comment; absent for user-written ones. */\n threadId?: string\n}\n\n/** One commit produced by an isolated execution (hash + subject). */\nexport type CommitInfo = { hash: string; subject: string }\n\n/**\n * The structured execution report an agent submits at handoff (0.4.0).\n * Commits/dirty/diff facts are host-collected git evidence — the report\n * covers the BUSINESS side the host cannot see.\n */\nexport type ExecutionReport = {\n /** What was done (1..2000 chars, required). */\n summary: string\n /** Files the agent changed (paths, ≤50 × 300 chars). */\n changedFiles: string[]\n /** How the work was self-verified (≤50 × 300 chars). */\n checks: string[]\n /** Produced artifacts worth reviewing (≤30 × 300 chars). */\n artifacts: string[]\n /** Known remaining risks / follow-ups (≤2000 chars, '' allowed). */\n risk: string\n}\n\n/** One Definition-of-Done checklist item (0.4.0). */\nexport type ChecklistItem = {\n id: string\n /** What must be true for acceptance (1..200 chars). */\n text: string\n checked: boolean\n /** Who checked it: an agent session id, or 'user' for GUI toggles. */\n checkedBy?: string\n /** When it was checked (epoch ms). */\n checkedAt?: number\n /** Evidence note attached when checking (≤400 chars). */\n note?: string\n}\n\n/** One execution attempt of a task. */\nexport type ExecutionRecord = {\n id: string\n /** The session this execution ran in; set once the session is really started. */\n sessionId?: string\n /** Trigger: manual button or the host scheduler. */\n trigger: 'manual' | 'scheduled'\n startedAt?: number\n endedAt?: number\n outcome: 'running' | 'succeeded' | 'failed' | 'cancelled'\n error?: string\n /** Code isolation actually used (`none` also covers degraded worktree runs). */\n isolation?: IsolationMode\n /** Why worktree isolation degraded to running in the original directory. */\n isolationNote?: string\n /** The task branch this execution worked on (worktree runs only). */\n branch?: string\n /** Absolute path of the dedicated worktree (worktree runs only). */\n worktreePath?: string\n /** HEAD of the task branch before the execution started. */\n baseCommit?: string\n /** HEAD at settlement. */\n headCommit?: string\n /** Commits between baseCommit and headCommit (hash + subject; capped at 50, newest first). */\n commits?: CommitInfo[]\n /** Total commits before the evidence cap (equals commits.length when under it). */\n commitsTotal?: number\n /** Uncommitted changes present at settlement (`git status --porcelain` lines; capped at 100). */\n dirtyFiles?: string[]\n /** Total uncommitted lines before the evidence cap. */\n dirtyFilesTotal?: number\n /** Aggregate diff stat between baseCommit and headCommit. */\n diffStat?: string\n /** How many files differ between baseCommit and headCommit. */\n changedFiles?: number\n /** The agent's structured report, submitted via taskboard_execution_report. */\n report?: ExecutionReport\n}\n\n/** The per-model override a task may carry; absent = session default model. */\nexport type TaskModel = {\n provider: string\n model: string\n /** Thinking intensity / reasoning effort (optional; e.g. 'low', 'medium', 'high', 'none'). */\n reasoningEffort?: string\n}\n\n/** One task on the board. */\nexport type TaskRecord = {\n id: string\n title: string\n description: string\n /** Extra execution prompt; the execution session receives title+description+prompt. */\n prompt: string\n /** Owning project: a DSH workspace id. */\n workspaceId: string\n urgency: Urgency\n status: TaskStatus\n /** Horizontal marker: work cannot continue right now (any non-terminal status). */\n blocked: boolean\n execution: ExecutionConfig\n model?: TaskModel\n /** Code isolation for executions (omitted = the worktree default; see {@link IsolationMode}). */\n isolation?: IsolationMode\n /**\n * The agent preset execution sessions are composed from (omitted = the\n * deployment default preset). Recorded on the session header and mounted\n * via the presets service at creation — this is what hands the session its\n * tool set. Editable any time (each run composes fresh).\n */\n presetId?: string\n /**\n * Definition-of-Done acceptance checklist (0.4.0). Agents may append items\n * and check/uncheck them (with evidence); the GUI may edit the whole list.\n * Unchecked items highlight at review time; done stays user-only.\n */\n checklist?: ChecklistItem[]\n /**\n * The task branch fixed at the FIRST worktree creation (`task/<标题>+<taskId>`).\n * Renaming the task afterwards never changes it (history preservation).\n */\n branch?: string\n /**\n * The session currently holding the in-progress claim (explicit claim or a\n * live execution). Present only while `status === 'in_progress'`: any move\n * out of in_progress releases it. `updatedBy` is audit-only — user edits no\n * longer erase the holder.\n */\n claimedBy?: string\n /** When the current holder claimed the task (epoch ms). */\n claimedAt?: number\n version: number\n createdAt: number\n updatedAt: number\n createdBy: Actor\n updatedBy: Actor\n comments: CommentRecord[]\n executions: ExecutionRecord[]\n /** How many older execution records were pruned by the retention cap. */\n executionsPruned?: number\n /** Soft-delete marker set by agent `taskboard_delete`; user confirms the purge. */\n trashedAt?: number\n}\n\n/** Retention cap: how many execution records each task keeps (oldest pruned). */\nexport const MAX_EXECUTIONS = 20\n\n/**\n * Enforce the execution-record retention cap on one task (in place): keep the\n * newest {@link MAX_EXECUTIONS} records, count the dropped ones in\n * `executionsPruned`. Running records are always the newest, never dropped.\n * @param task - the task to prune.\n */\nexport function pruneExecutions(task: TaskRecord): void {\n if (task.executions.length <= MAX_EXECUTIONS) return\n const dropped = task.executions.length - MAX_EXECUTIONS\n task.executions = task.executions.slice(-MAX_EXECUTIONS)\n task.executionsPruned = (task.executionsPruned ?? 0) + dropped\n}\n\n/** The whole durable ledger. */\nexport type TaskLedger = {\n schemaVersion: number\n /** Global monotonic revision; every mutation bumps it. */\n revision: number\n tasks: TaskRecord[]\n /** Board-level settings (0.5.0); absent on ledgers never touched by 设置. */\n settings?: BoardSettings\n}\n\n/** Current ledger format version. */\nexport const LEDGER_SCHEMA_VERSION = 1\n\n/** An empty ledger. */\nexport function emptyLedger(): TaskLedger {\n return { schemaVersion: LEDGER_SCHEMA_VERSION, revision: 0, tasks: [] }\n}\n\n// ---------------------------------------------------------------------------\n// ids\n// ---------------------------------------------------------------------------\n\n/** Random base36 suffix. */\nfunction suffix(): string {\n return Math.random().toString(36).slice(2, 8)\n}\n\n/**\n * Legal task id charset (R4): `t-<base36>-<base36>` from {@link newTaskId},\n * and the ONLY shape accepted from the outside (import) or used to build\n * filesystem paths (worktree dirs). Ids ride into `join(ws, '.dsh-worktrees',\n * id)` — a lax charset here is an arbitrary-directory delete primitive.\n */\nexport function isValidTaskId(id: string): boolean {\n return /^[A-Za-z0-9][A-Za-z0-9_-]{0,99}$/.test(id)\n}\n\n/** Mint a task id. */\nexport function newTaskId(): string {\n return `t-${Date.now().toString(36)}-${suffix()}`\n}\n\n/** Mint a comment id. */\nexport function newCommentId(): string {\n return `c-${Date.now().toString(36)}-${suffix()}`\n}\n\n/** Mint a checklist item id. */\nexport function newChecklistItemId(): string {\n return `k-${Date.now().toString(36)}-${suffix()}`\n}\n\n/** Mint an execution id. */\nexport function newExecutionId(): string {\n return `e-${Date.now().toString(36)}-${suffix()}`\n}\n\n// ---------------------------------------------------------------------------\n// validation helpers (input shaping for tools and routes)\n// ---------------------------------------------------------------------------\n\n/**\n * Validate and normalize a title: trimmed, 1..200 chars.\n * @param raw - the raw input.\n * @returns the normalized title.\n * @throws when empty or too long.\n */\nexport function normalizeTitle(raw: string): string {\n const t = raw.trim()\n if (t.length === 0 || t.length > 200) {\n throw new Error('title must be 1..200 characters')\n }\n return t\n}\n\n/**\n * Validate a task prompt: trimmed, at most 8000 chars; empty becomes ''.\n * @param raw - the raw input.\n */\nexport function normalizePrompt(raw: string | undefined): string {\n const t = (raw ?? '').trim()\n if (t.length > 8000) throw new Error('prompt must be at most 8000 characters')\n return t\n}\n\n/**\n * Validate and normalize a comment body: trimmed, 1..4000 chars.\n * @param raw - the raw input.\n */\nexport function normalizeBody(raw: string): string {\n const t = raw.trim()\n if (t.length === 0 || t.length > 4000) {\n throw new Error('comment body must be 1..4000 characters')\n }\n return t\n}\n\n/**\n * Validate an urgency value.\n * @param raw - the raw input.\n */\nexport function asUrgency(raw: string): Urgency {\n if (!URGENCIES.includes(raw as Urgency)) {\n throw new Error(`urgency must be one of: ${URGENCIES.join(', ')}`)\n }\n return raw as Urgency\n}\n\n/**\n * Validate a status value.\n * @param raw - the raw input.\n */\nexport function asStatus(raw: string): TaskStatus {\n if (!ALL_STATUSES.includes(raw as TaskStatus)) {\n throw new Error(`status must be one of: ${ALL_STATUSES.join(', ')}`)\n }\n return raw as TaskStatus\n}\n\n/**\n * Validate an execution config request from raw tool/route input.\n * `scheduled` requires a valid cron; computes the first `nextRunAt` from\n * `now`.\n * @param raw - raw execution input ({@link ExecutionConfig} fields, untyped).\n * @param now - current epoch ms.\n * @returns the normalized config.\n */\nexport function normalizeExecution(\n raw: { mode?: string; cron?: string },\n now: number,\n): ExecutionConfig {\n const mode = raw.mode ?? 'claim'\n if (mode !== 'claim' && mode !== 'scheduled') {\n throw new Error(\"execution.mode must be 'claim' or 'scheduled'\")\n }\n if (mode === 'claim') return { mode }\n const cron = (raw.cron ?? '').trim()\n const match = parseCron(cron)\n if (match === null) throw new Error('execution.cron is not a valid 5-field cron expression')\n const next = nextCronTime(match, now)\n if (next === null) throw new Error('execution.cron never matches within 4 years')\n return { mode, cron, nextRunAt: next }\n}\n\n/**\n * The effective prompt of a task: title+description, with the explicit\n * prompt appended when set — title+description+prompt.\n * @param task - the task.\n */\nexport function effectivePrompt(task: TaskRecord): string {\n const head = task.title\n const body = task.description.length > 0 ? `${head}\\n\\n${task.description}` : head\n return task.prompt.length > 0 ? `${body}\\n\\n${task.prompt}` : body\n}\n\n/**\n * Whether the task is currently claimed by a session (running state).\n * @param task - the task.\n */\nexport function isClaimedBy(task: TaskRecord): string | undefined {\n return task.status === 'in_progress' && task.claimedBy !== undefined ? task.claimedBy : undefined\n}\n\n/**\n * Maintain the explicit claim fields around a status change: entering\n * in_progress under a session records the holder (an execution-start or an\n * agent claim); every move out of in_progress releases the claim (handoff,\n * give-back, cancel). A user-driven move into in_progress records no holder —\n * no session works on it yet.\n * @param task - the task being written (mutated in place).\n * @param to - the target status.\n * @param now - current epoch ms.\n * @param holder - the session id claiming the task, when applicable.\n */\nexport function syncClaim(task: TaskRecord, to: TaskStatus, now: number, holder?: string): void {\n if (to !== 'in_progress') {\n delete task.claimedBy\n delete task.claimedAt\n } else if (holder !== undefined) {\n task.claimedBy = holder\n task.claimedAt = now\n }\n}\n\n/**\n * Validate and normalize a pinned model: `{ provider, model, reasoningEffort? }`,\n * provider and model must be non-empty trimmed strings.\n * @param raw - the raw input.\n * @returns the normalized model.\n * @throws when the shape or the fields are invalid.\n */\nexport function normalizeModel(raw: unknown): TaskModel {\n if (typeof raw !== 'object' || raw === null) {\n throw new Error('model must be { provider: string, model: string }')\n }\n const { provider, model, reasoningEffort } = raw as { provider?: unknown; model?: unknown; reasoningEffort?: unknown }\n if (typeof provider !== 'string' || typeof model !== 'string') {\n throw new Error('model must be { provider: string, model: string }')\n }\n const p = provider.trim()\n const m = model.trim()\n if (p.length === 0 || m.length === 0) {\n throw new Error('model.provider and model.model must be non-empty strings')\n }\n const eff = typeof reasoningEffort === 'string' && reasoningEffort.trim().length > 0 ? reasoningEffort.trim() : undefined\n return { provider: p, model: m, ...(eff !== undefined ? { reasoningEffort: eff } : {}) }\n}\n\n// ---------------------------------------------------------------------------\n// checklist + report validation (0.4.0)\n// ---------------------------------------------------------------------------\n\n/** Checklist size cap per task. */\nexport const MAX_CHECKLIST_ITEMS = 30\n\n/** Checklist item text cap (chars). */\nexport const MAX_CHECKLIST_TEXT = 200\n\n/**\n * Validate and normalize one checklist text line: trimmed, 1..200 chars.\n * @param raw - the raw text.\n * @throws when empty or too long.\n */\nexport function normalizeChecklistText(raw: string): string {\n const t = raw.trim()\n if (t.length === 0 || t.length > MAX_CHECKLIST_TEXT) {\n throw new Error(`checklist item text must be 1..${MAX_CHECKLIST_TEXT} characters`)\n }\n return t\n}\n\n/**\n * Build a fresh unchecked checklist from plain text lines (create route /\n * templates / tool adds).\n * @param texts - the item texts (validated individually).\n */\nexport function checklistFromTexts(texts: readonly string[]): ChecklistItem[] {\n const items = texts.map(text => ({ id: newChecklistItemId(), text: normalizeChecklistText(text), checked: false }))\n if (items.length > MAX_CHECKLIST_ITEMS) {\n throw new Error(`checklist may hold at most ${MAX_CHECKLIST_ITEMS} items`)\n }\n return items\n}\n\n/**\n * Validate and normalize a full checklist array (GUI update route, import):\n * missing ids are minted, text is checked, checked flags must be booleans,\n * checkedBy/checkedAt are kept only on checked items.\n * @param raw - untyped array from the wire.\n * @throws with a readable reason on any invalid entry.\n */\nexport function normalizeChecklist(raw: unknown): ChecklistItem[] {\n if (!Array.isArray(raw)) throw new Error('checklist must be an array')\n if (raw.length > MAX_CHECKLIST_ITEMS) {\n throw new Error(`checklist may hold at most ${MAX_CHECKLIST_ITEMS} items`)\n }\n return raw.map((entry): ChecklistItem => {\n if (typeof entry !== 'object' || entry === null) throw new Error('checklist item must be an object')\n const e = entry as Record<string, unknown>\n const text = normalizeChecklistText(typeof e.text === 'string' ? e.text : '')\n const id = typeof e.id === 'string' && e.id.trim().length > 0 ? e.id.trim() : newChecklistItemId()\n const checked = e.checked === true\n const checkedBy = typeof e.checkedBy === 'string' ? e.checkedBy.trim().slice(0, 100) : undefined\n const checkedAt = typeof e.checkedAt === 'number' && Number.isFinite(e.checkedAt) ? e.checkedAt : undefined\n const note = typeof e.note === 'string' && e.note.trim().length > 0 ? e.note.trim().slice(0, 400) : undefined\n if (!checked) return { id, text, checked: false }\n return {\n id,\n text,\n checked: true,\n ...(checkedBy !== undefined && checkedBy.length > 0 ? { checkedBy } : {}),\n ...(checkedAt !== undefined ? { checkedAt } : {}),\n ...(note !== undefined ? { note } : {}),\n }\n })\n}\n\n/** Checklist progress: how many items are checked (absent checklist → 0/0). */\nexport function checklistProgress(task: Pick<TaskRecord, 'checklist'>): { done: number; total: number } {\n const items = task.checklist ?? []\n return { done: items.filter(i => i.checked).length, total: items.length }\n}\n\n/** Report string-list caps. */\nconst REPORT_LIST_CAPS = { changedFiles: 50, checks: 50, artifacts: 30 } as const\n\n/** Per-entry cap for report lists (chars). */\nconst REPORT_ENTRY_MAX = 300\n\n/** Validate one report string list: strings trimmed 1..300 chars. */\nfunction normalizeReportList(raw: unknown, field: keyof typeof REPORT_LIST_CAPS): string[] {\n if (raw === undefined) return []\n if (!Array.isArray(raw)) throw new Error(`report.${field} must be an array of strings`)\n const out = raw.map(entry => {\n if (typeof entry !== 'string') throw new Error(`report.${field} must be an array of strings`)\n const t = entry.trim()\n if (t.length === 0 || t.length > REPORT_ENTRY_MAX) {\n throw new Error(`report.${field} entries must be 1..${REPORT_ENTRY_MAX} characters`)\n }\n return t\n })\n if (out.length > REPORT_LIST_CAPS[field]) {\n throw new Error(`report.${field} may hold at most ${REPORT_LIST_CAPS[field]} entries`)\n }\n return out\n}\n\n/**\n * Validate and normalize a structured execution report.\n * @param raw - untyped tool/route input.\n * @throws with a readable reason on any invalid field.\n */\nexport function normalizeExecutionReport(raw: unknown): ExecutionReport {\n if (typeof raw !== 'object' || raw === null) throw new Error('report must be an object')\n const e = raw as Record<string, unknown>\n const summary = typeof e.summary === 'string' ? e.summary.trim() : ''\n if (summary.length === 0 || summary.length > 2000) {\n throw new Error('report.summary must be 1..2000 characters')\n }\n const risk = typeof e.risk === 'string' ? e.risk.trim().slice(0, 2000) : ''\n return {\n summary,\n changedFiles: normalizeReportList(e.changedFiles, 'changedFiles'),\n checks: normalizeReportList(e.checks, 'checks'),\n artifacts: normalizeReportList(e.artifacts, 'artifacts'),\n risk,\n }\n}\n\n// ---------------------------------------------------------------------------\n// ledger import validation (0.4.0)\n// ---------------------------------------------------------------------------\n\n/** Result classifying every task in an import file against the live ledger. */\nexport type ImportPlan = {\n /** Structurally valid tasks whose ids are new (merge adds them). */\n create: TaskRecord[]\n /** Structurally valid tasks whose ids already exist (merge replaces them). */\n overwrite: TaskRecord[]\n /** Invalid entries with a human-readable reason (never imported). */\n invalid: Array<{ id?: string; reason: string }>\n /** The file's board settings (0.5.0); replace-mode swaps them, merge keeps the live ones. */\n settings?: BoardSettings\n}\n\n/** One unknown-value read helper: string fields with defaults. */\nfunction strOr(raw: Record<string, unknown>, key: string, fallback: string): string {\n const v = raw[key]\n return typeof v === 'string' ? v : fallback\n}\n\n/** One unknown-value read helper: finite numbers with defaults. */\nfunction numOr(raw: Record<string, unknown>, key: string, fallback: number): number {\n const v = raw[key]\n return typeof v === 'number' && Number.isFinite(v) ? v : fallback\n}\n\n/**\n * Validate ONE imported task record (pure): rebuilds it field by field with\n * the normal validators, minting missing ids and re-arming cron. Executions\n * left `running` by the exporting machine are marked failed — their\n * settlement watchers died there and can never settle here.\n * @param raw - the untyped record.\n * @param now - current epoch ms (defaults for timestamps).\n * @returns the rebuilt record, or a rejection reason.\n */\nexport function validateImportedTask(raw: unknown, now: number): { ok: true; task: TaskRecord } | { ok: false; reason: string } {\n if (typeof raw !== 'object' || raw === null) return { ok: false, reason: 'not an object' }\n const e = raw as Record<string, unknown>\n const id = typeof e.id === 'string' ? e.id.trim() : ''\n const fail = (reason: string): { ok: false; reason: string } => ({ ok: false, reason })\n // R4①: length alone let traversal-shaped ids (`../../x`, `..\\..\\x`) into\n // the ledger; the charset gate is the primary defense for every downstream\n // filesystem use of a task id.\n if (!isValidTaskId(id)) return fail('missing/invalid id (must match ^[A-Za-z0-9][A-Za-z0-9_-]{0,99}$)')\n try {\n const execution = normalizeExecution(\n typeof e.execution === 'object' && e.execution !== null ? e.execution as { mode?: string; cron?: string } : {},\n now,\n )\n const comments: CommentRecord[] = []\n if (Array.isArray(e.comments)) {\n for (const c of e.comments) {\n if (typeof c !== 'object' || c === null) return fail('invalid comment entry')\n const ce = c as Record<string, unknown>\n const body = typeof ce.body === 'string' ? ce.body : ''\n if (body.trim().length === 0 || body.length > 4000) return fail('invalid comment body')\n comments.push({\n id: typeof ce.id === 'string' && ce.id.length > 0 ? ce.id : newCommentId(),\n body,\n version: numOr(ce, 'version', 1),\n createdAt: numOr(ce, 'createdAt', now),\n ...(typeof ce.threadId === 'string' ? { threadId: ce.threadId } : {}),\n })\n }\n } else return fail('comments must be an array')\n const executions: ExecutionRecord[] = []\n if (Array.isArray(e.executions)) {\n for (const x of e.executions) {\n if (typeof x !== 'object' || x === null) return fail('invalid execution entry')\n const xe = x as Record<string, unknown>\n const trigger = xe.trigger === 'scheduled' ? 'scheduled' : 'manual'\n const outcomeRaw = xe.outcome\n if (outcomeRaw !== 'running' && outcomeRaw !== 'succeeded' && outcomeRaw !== 'failed' && outcomeRaw !== 'cancelled') {\n return fail('invalid execution outcome')\n }\n // A running execution from the exporting machine can never settle\n // here — import it as failed with the reason recorded.\n const outcome = outcomeRaw === 'running' ? 'failed' as const : outcomeRaw\n executions.push({\n id: typeof xe.id === 'string' && xe.id.length > 0 ? xe.id : newExecutionId(),\n ...(typeof xe.sessionId === 'string' ? { sessionId: xe.sessionId } : {}),\n trigger,\n ...(typeof xe.startedAt === 'number' ? { startedAt: xe.startedAt } : {}),\n ...(typeof xe.endedAt === 'number' ? { endedAt: xe.endedAt } : {}),\n outcome,\n ...(outcomeRaw === 'running' ? { error: 'imported while still running (settlement watcher died with the exporting host)' } : (typeof xe.error === 'string' ? { error: xe.error } : {})),\n ...(typeof xe.isolation === 'string' && (xe.isolation === 'worktree' || xe.isolation === 'none') ? { isolation: xe.isolation } : {}),\n ...(typeof xe.isolationNote === 'string' ? { isolationNote: xe.isolationNote } : {}),\n ...(typeof xe.branch === 'string' ? { branch: xe.branch } : {}),\n ...(typeof xe.worktreePath === 'string' ? { worktreePath: xe.worktreePath } : {}),\n ...(typeof xe.baseCommit === 'string' ? { baseCommit: xe.baseCommit } : {}),\n ...(typeof xe.headCommit === 'string' ? { headCommit: xe.headCommit } : {}),\n ...(Array.isArray(xe.commits) ? { commits: xe.commits.filter((c): c is CommitInfo =>\n typeof c === 'object' && c !== null && typeof (c as CommitInfo).hash === 'string' && typeof (c as CommitInfo).subject === 'string') } : {}),\n ...(typeof xe.commitsTotal === 'number' ? { commitsTotal: xe.commitsTotal } : {}),\n ...(Array.isArray(xe.dirtyFiles) ? { dirtyFiles: xe.dirtyFiles.filter((l): l is string => typeof l === 'string') } : {}),\n ...(typeof xe.dirtyFilesTotal === 'number' ? { dirtyFilesTotal: xe.dirtyFilesTotal } : {}),\n ...(typeof xe.diffStat === 'string' ? { diffStat: xe.diffStat } : {}),\n ...(typeof xe.changedFiles === 'number' ? { changedFiles: xe.changedFiles } : {}),\n ...(typeof xe.report === 'object' && xe.report !== null ? { report: normalizeExecutionReport(xe.report) } : {}),\n })\n }\n } else return fail('executions must be an array')\n const status = asStatus(strOr(e, 'status', 'todo'))\n const actorOf = (v: unknown): Actor => (typeof v === 'object' && v !== null && (v as Actor).kind === 'agent' && typeof (v as { sessionId?: unknown }).sessionId === 'string'\n ? { kind: 'agent', sessionId: (v as { sessionId: string }).sessionId }\n : { kind: 'user' })\n const task: TaskRecord = {\n id,\n title: normalizeTitle(strOr(e, 'title', '')),\n description: strOr(e, 'description', '').trim(),\n prompt: normalizePrompt(strOr(e, 'prompt', '')),\n workspaceId: strOr(e, 'workspaceId', ''),\n urgency: asUrgency(strOr(e, 'urgency', 'normal')),\n status,\n blocked: e.blocked === true,\n execution,\n ...(typeof e.model === 'object' && e.model !== null ? { model: normalizeModel(e.model) } : {}),\n ...(typeof e.isolation === 'string' && (e.isolation === 'worktree' || e.isolation === 'none') ? { isolation: e.isolation } : {}),\n ...(typeof e.presetId === 'string' && e.presetId.trim().length > 0 ? { presetId: e.presetId.trim() } : {}),\n ...(Array.isArray(e.checklist) ? { checklist: normalizeChecklist(e.checklist) } : {}),\n ...(typeof e.branch === 'string' ? { branch: e.branch } : {}),\n ...(status === 'in_progress' && typeof e.claimedBy === 'string' ? { claimedBy: e.claimedBy } : {}),\n ...(status === 'in_progress' && typeof e.claimedAt === 'number' ? { claimedAt: e.claimedAt } : {}),\n version: Math.max(1, Math.trunc(numOr(e, 'version', 1))),\n createdAt: numOr(e, 'createdAt', now),\n updatedAt: numOr(e, 'updatedAt', now),\n createdBy: actorOf(e.createdBy),\n updatedBy: actorOf(e.updatedBy),\n comments,\n executions,\n ...(typeof e.executionsPruned === 'number' ? { executionsPruned: e.executionsPruned } : {}),\n ...(typeof e.trashedAt === 'number' ? { trashedAt: e.trashedAt } : {}),\n }\n if (task.workspaceId.length === 0) return fail('missing workspaceId')\n return { ok: true, task }\n } catch (error) {\n return fail(error instanceof Error ? error.message : String(error))\n }\n}\n\n/**\n * Minimal structural check for ONE ledger record at load time (S11): unlike\n * {@link validateImportedTask} this REBUILDS NOTHING (cron state, ids and\n * timestamps must survive a load untouched) — it only rejects entries whose\n * shape would break downstream consumers, including the R4 id charset.\n * @param raw - the untyped record.\n */\nexport function isPlausibleTaskRecord(raw: unknown): boolean {\n if (typeof raw !== 'object' || raw === null) return false\n const t = raw as Record<string, unknown>\n return typeof t.id === 'string' && isValidTaskId(t.id)\n && typeof t.title === 'string' && t.title.length > 0\n && typeof t.workspaceId === 'string' && t.workspaceId.length > 0\n && ALL_STATUSES.includes(t.status as TaskStatus)\n && typeof t.version === 'number' && Number.isFinite(t.version) && t.version >= 1\n && Array.isArray(t.comments) && Array.isArray(t.executions)\n && typeof t.execution === 'object' && t.execution !== null\n && (t.execution as { mode?: unknown }).mode !== undefined\n}\n\n/**\n * Validate a whole imported ledger and classify its tasks against the live\n * one (pure). Duplicate ids INSIDE the file are invalid (first wins, later\n * copies reported); schemaVersion must match {@link LEDGER_SCHEMA_VERSION}.\n * @param raw - the parsed import file.\n * @param knownIds - live ledger task ids.\n * @param now - current epoch ms.\n * @throws when the file is not a ledger or the schemaVersion is unsupported.\n */\nexport function validateLedgerImport(raw: unknown, knownIds: ReadonlySet<string>, now: number): ImportPlan {\n if (typeof raw !== 'object' || raw === null) throw new Error('导入文件不是 JSON 对象')\n const e = raw as Record<string, unknown>\n if (e.schemaVersion !== LEDGER_SCHEMA_VERSION) {\n throw new Error(`不支持的 schemaVersion ${String(e.schemaVersion)}(当前支持 ${LEDGER_SCHEMA_VERSION})`)\n }\n if (!Array.isArray(e.tasks)) throw new Error('导入文件的 tasks 不是数组')\n const plan: ImportPlan = { create: [], overwrite: [], invalid: [], ...(e.settings !== undefined ? { settings: asBoardSettings(e.settings) } : {}) }\n const seen = new Set<string>()\n for (const entry of e.tasks) {\n const id = typeof (entry as { id?: unknown })?.id === 'string' ? (entry as { id: string }).id : undefined\n const result = validateImportedTask(entry, now)\n if (!result.ok) {\n plan.invalid.push({ ...(id !== undefined ? { id } : {}), reason: result.reason })\n continue\n }\n if (seen.has(result.task.id)) {\n plan.invalid.push({ id: result.task.id, reason: '文件内重复 id' })\n continue\n }\n seen.add(result.task.id)\n if (knownIds.has(result.task.id)) plan.overwrite.push(result.task)\n else plan.create.push(result.task)\n }\n return plan\n}\n\n/**\n * Compact list-projection of a task (token-friendly for `taskboard_list`).\n * @param task - the task.\n */\nexport type TaskSummary = {\n id: string\n title: string\n workspaceId: string\n urgency: Urgency\n status: TaskStatus\n blocked: boolean\n executionMode: ExecutionMode\n nextRunAt?: number\n model?: TaskModel\n version: number\n claimOwner?: string\n commentCount: number\n lastExecutionOutcome?: ExecutionRecord['outcome']\n /** Checklist progress (present only when the task has a checklist). */\n checklist?: { done: number; total: number }\n trashed: boolean\n}\n\n/**\n * Build the compact summary of a task.\n * @param task - the task.\n */\nexport function summarize(task: TaskRecord): TaskSummary {\n const last = task.executions.length > 0 ? task.executions[task.executions.length - 1] : undefined\n const checklist = task.checklist !== undefined && task.checklist.length > 0 ? checklistProgress(task) : undefined\n return {\n id: task.id,\n title: task.title,\n workspaceId: task.workspaceId,\n urgency: task.urgency,\n status: task.status,\n blocked: task.blocked,\n executionMode: task.execution.mode,\n nextRunAt: task.execution.nextRunAt,\n model: task.model,\n version: task.version,\n claimOwner: isClaimedBy(task),\n commentCount: task.comments.length,\n lastExecutionOutcome: last?.outcome,\n ...(checklist !== undefined ? { checklist } : {}),\n trashed: task.trashedAt !== undefined,\n }\n}\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-taskboard",
3
3
  "description": "Agent-first task board for the DSH web GUI: host-authoritative task ledger with taskboard_* agent tools, project (= workspace) claim boundaries, per-task model execution in fresh sessions, optional per-task git-worktree isolation (dedicated task branches, commit evidence, one-click merge), host-side cron scheduling, and a live SSE kanban view. Mounts via the official dsh plugin system — no DSH source changes.",
4
- "version": "0.5.3",
4
+ "version": "0.5.4",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "exports": {
@@ -34,6 +34,7 @@ export function filterTasks(state: ControllerState, tasks: TaskRecord[]): TaskRe
34
34
  if (state.sortBy === 'updated') sorted.sort((a, b) => b.updatedAt - a.updatedAt)
35
35
  else if (state.sortBy === 'created') sorted.sort((a, b) => b.createdAt - a.createdAt)
36
36
  else if (state.sortBy === 'urgency') sorted.sort((a, b) => URGENCY_RANK[a.urgency] - URGENCY_RANK[b.urgency] || b.updatedAt - a.updatedAt)
37
+ else if (state.sortBy === 'title') sorted.sort((a, b) => a.title.localeCompare(b.title, undefined, { numeric: true }))
37
38
  return sorted
38
39
  }
39
40
 
@@ -128,6 +129,7 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
128
129
  <option value="updated">最近更新</option>
129
130
  <option value="urgency">按紧急度</option>
130
131
  <option value="created">创建时间</option>
132
+ <option value="title">按标题</option>
131
133
  </select>
132
134
  {(['urgent', 'normal', 'relaxed'] as const).map(u => (
133
135
  <button
@@ -20,6 +20,12 @@ import { OUTCOME_LABEL, URGENCY_LABEL } from './labels.ts'
20
20
  /** dataTransfer type carrying the dragged task id. */
21
21
  export const DRAG_TYPE = 'application/x-dsh-atb-task'
22
22
 
23
+ /** Compact session-id display (execution sessions carry the taskboard infix). */
24
+ function shortId(id: string | undefined): string {
25
+ if (id === undefined) return ''
26
+ return id.replace(/^session-(taskboard-)?/, '').slice(0, 8)
27
+ }
28
+
23
29
  /**
24
30
  * The card view.
25
31
  * @param task - the task record.
@@ -35,6 +41,8 @@ export function TaskCard({ task, controller, draggable = false, now, onAlert }:
35
41
  const running = task.executions.find(ex => ex.outcome === 'running')
36
42
  const stale = now !== undefined && isStaleClaim(task, now)
37
43
  const reviewing = task.status === 'in_review' && task.trashedAt === undefined
44
+ const sessionExecution = [...task.executions].reverse().find(ex => ex.sessionId !== undefined)
45
+ const targetSessionId = running?.sessionId ?? sessionExecution?.sessionId ?? (task.claimedBy?.startsWith('session-') ? task.claimedBy : undefined)
38
46
 
39
47
  /** Submit the quick-reject: one atomic route (move + optional note). */
40
48
  const submitReject = (): void => {
@@ -83,7 +91,14 @@ export function TaskCard({ task, controller, draggable = false, now, onAlert }:
83
91
  {task.execution.mode === 'scheduled' && (
84
92
  <span className="dsh-atb-badge" data-kind="scheduled">⏰ {fmtTime(task.execution.nextRunAt)}</span>
85
93
  )}
86
- {task.model !== undefined && <span className="dsh-atb-badge">{task.model.model}</span>}
94
+ {task.model !== undefined && (
95
+ <span
96
+ className="dsh-atb-badge"
97
+ title={`固定模型: ${task.model.provider}/${task.model.model}${task.model.reasoningEffort !== undefined ? ` · 思考强度: ${task.model.reasoningEffort}` : ''}`}
98
+ >
99
+ {task.model.model}{task.model.reasoningEffort !== undefined ? ` (${task.model.reasoningEffort})` : ''}
100
+ </span>
101
+ )}
87
102
  {task.checklist !== undefined && task.checklist.length > 0 && (
88
103
  <span
89
104
  className="dsh-atb-badge"
@@ -99,6 +114,33 @@ export function TaskCard({ task, controller, draggable = false, now, onAlert }:
99
114
  {OUTCOME_LABEL[last.outcome] ?? last.outcome}
100
115
  </span>
101
116
  )}
117
+ {targetSessionId !== undefined && (
118
+ <button
119
+ type="button"
120
+ className="dsh-atb-card-session"
121
+ title={`点击一键跳转到会话:${targetSessionId}`}
122
+ onClick={(e) => {
123
+ e.stopPropagation()
124
+ void controller.openSession(targetSessionId).then(result => {
125
+ if (result === 'missing') {
126
+ const msg = `该会话已被删除(${shortId(targetSessionId)}),无法打开`
127
+ if (onAlert !== undefined) onAlert(msg)
128
+ else alert(msg)
129
+ } else if (result === 'archived') {
130
+ const msg = `该会话已归档(${shortId(targetSessionId)}),已从会话列表隐藏`
131
+ if (onAlert !== undefined) onAlert(msg)
132
+ else alert(msg)
133
+ } else if (result === 'unavailable') {
134
+ const msg = `会话导航不可用,会话 ID:${targetSessionId}`
135
+ if (onAlert !== undefined) onAlert(msg)
136
+ else alert(msg)
137
+ }
138
+ })
139
+ }}
140
+ >
141
+ 🤖 {shortId(targetSessionId)} ↗
142
+ </button>
143
+ )}
102
144
  {task.comments.length > 0 && <span>💬 {task.comments.length}</span>}
103
145
  {task.trashedAt !== undefined && <span className="dsh-atb-badge" data-kind="trashed">待清除</span>}
104
146
  <span style={{ marginLeft: 'auto' }}>{fmtTime(task.updatedAt)}</span>
@@ -37,8 +37,8 @@ function duration(startedAt: number | undefined, endedAt: number | undefined): s
37
37
  }
38
38
 
39
39
  /** Small labelled meta chip. */
40
- function Chip({ icon, children, tone }: { icon?: string; children: ReactNode; tone?: string }) {
41
- return <span className="dsh-atb-chip2" data-tone={tone}>{icon !== undefined && <span className="dsh-atb-chip2-icon">{icon}</span>}{children}</span>
40
+ function Chip({ icon, children, tone, title }: { icon?: string; children: ReactNode; tone?: string; title?: string }) {
41
+ return <span className="dsh-atb-chip2" data-tone={tone} title={title}>{icon !== undefined && <span className="dsh-atb-chip2-icon">{icon}</span>}{children}</span>
42
42
  }
43
43
 
44
44
  /** The most recent execution carrying isolation facts, newest first. */
@@ -378,6 +378,8 @@ export function TaskDetail({ task, controller, now }: { task: TaskRecord; contro
378
378
  const holder = task.status === 'in_progress' ? task.claimedBy : undefined
379
379
  const stale = now !== undefined && isStaleClaim(task, now)
380
380
  const unchecked = (task.checklist ?? []).filter(i => !i.checked).length
381
+ const sessionExecution = [...task.executions].reverse().find(e => e.sessionId !== undefined)
382
+ const targetSessionId = runningExecution?.sessionId ?? sessionExecution?.sessionId ?? (task.claimedBy?.startsWith('session-') ? task.claimedBy : undefined)
381
383
 
382
384
  /** Fire one top action under the shared busy guard; re-enable on settle. */
383
385
  const runAction = (action: () => Promise<unknown>): void => {
@@ -406,7 +408,14 @@ export function TaskDetail({ task, controller, now }: { task: TaskRecord; contro
406
408
  <div className="dsh-atb-detail-chips">
407
409
  <Chip tone={task.urgency}>● {URGENCY_LABEL[task.urgency] ?? task.urgency}</Chip>
408
410
  <Chip icon="📁">{ws?.title ?? shortId(task.workspaceId)}</Chip>
409
- {task.model !== undefined && <Chip icon="✦">{task.model.model}</Chip>}
411
+ {task.model !== undefined && (
412
+ <Chip
413
+ icon="✦"
414
+ title={`固定模型: ${task.model.provider}/${task.model.model}${task.model.reasoningEffort !== undefined ? ` · 思考强度: ${task.model.reasoningEffort}` : ''}`}
415
+ >
416
+ {task.model.model}{task.model.reasoningEffort !== undefined ? ` · ${task.model.reasoningEffort}` : ''}
417
+ </Chip>
418
+ )}
410
419
  {task.presetId !== undefined && <Chip icon="🎛" >{task.presetId}</Chip>}
411
420
  {task.execution.mode === 'scheduled' && (
412
421
  <Chip icon="⏰">{task.execution.cron} · 下次 {fmtTime(task.execution.nextRunAt)}</Chip>
@@ -422,9 +431,16 @@ export function TaskDetail({ task, controller, now }: { task: TaskRecord; contro
422
431
  )}
423
432
  {(task.isolation === undefined || task.isolation === 'worktree') && task.branch === undefined && <Chip icon="🌿">Worktree 隔离</Chip>}
424
433
  {holder !== undefined && (
425
- <Chip icon={stale ? '⏱' : '🔑'} tone={stale ? 'urgent' : undefined}>
426
- {stale ? '认领超时 · ' : '由 '}{shortId(holder)} 持有
427
- </Chip>
434
+ <button
435
+ type="button"
436
+ className="dsh-atb-chip2 dsh-atb-chip-btn"
437
+ data-tone={stale ? 'urgent' : undefined}
438
+ title={`点击跳转至该会话:${holder}`}
439
+ onClick={() => jumpToSession(holder)}
440
+ >
441
+ <span className="dsh-atb-chip2-icon">{stale ? '⏱' : '🤖'}</span>
442
+ {stale ? '认领超时 · ' : '由 '}{shortId(holder)} 持有 ↗
443
+ </button>
428
444
  )}
429
445
  {task.trashedAt !== undefined && <Chip icon="🗑" tone="urgent">已删除待清除</Chip>}
430
446
  <Chip>v{task.version}</Chip>
@@ -434,6 +450,16 @@ export function TaskDetail({ task, controller, now }: { task: TaskRecord; contro
434
450
  </div>
435
451
  </div>
436
452
  <div className="dsh-atb-detail-topbtns">
453
+ {targetSessionId !== undefined && (
454
+ <button
455
+ type="button"
456
+ className="dsh-atb-detail-session"
457
+ title={`一键跳转到对应会话:${targetSessionId}`}
458
+ onClick={() => jumpToSession(targetSessionId)}
459
+ >
460
+ 🤖 跳转会话 ↗
461
+ </button>
462
+ )}
437
463
  <button type="button" className="dsh-atb-detail-edit" onClick={() => controller.openEditor(task.id)}>✎ 编辑</button>
438
464
  <button
439
465
  type="button"