dsh-taskboard 0.4.4 → 0.5.0

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":"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 = the default `worktree`; non-git projects auto-degrade at run\n * time (the execution record carries an `isolationNote` explaining why).\n */\nexport type IsolationMode = 'worktree' | '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 worktree default). */\nexport function effectiveIsolation(task: Pick<TaskRecord, 'isolation'>): IsolationMode {\n return task.isolation === undefined ? 'worktree' : task.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\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 /** The prompt sent to a fresh session on execution; falls back to title+description. */\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}\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/** 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: explicit prompt, or title+description.\n * @param task - the task.\n */\nexport function effectivePrompt(task: TaskRecord): string {\n if (task.prompt.length > 0) return task.prompt\n const head = task.title\n return task.description.length > 0 ? `${head}\\n\\n${task.description}` : head\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}\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 if (id.length === 0 || id.length > 100) return fail('missing/invalid id')\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 * 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: [] }\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\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 /** The prompt sent to a fresh session on execution; falls back to title+description. */\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/** 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: explicit prompt, or title+description.\n * @param task - the task.\n */\nexport function effectivePrompt(task: TaskRecord): string {\n if (task.prompt.length > 0) return task.prompt\n const head = task.title\n return task.description.length > 0 ? `${head}\\n\\n${task.description}` : head\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 if (id.length === 0 || id.length > 100) return fail('missing/invalid id')\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 * 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.4.4",
4
+ "version": "0.5.0",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "exports": {
@@ -54,12 +54,12 @@
54
54
  },
55
55
  "devDependencies": {
56
56
  "@deepseek-ai/cordis": "^4.0.1",
57
- "@deepseek-ai/dsh-agent": "^0.1.0-rc.6",
58
- "@deepseek-ai/dsh-home-paths": "^0.1.0-rc.6",
59
- "@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.6",
60
- "@deepseek-ai/dsh-system-prompt": "^0.1.0-rc.6",
61
- "@deepseek-ai/dsh-tools": "^0.1.0-rc.6",
62
- "@deepseek-ai/dsh-workspace": "^0.1.0-rc.6",
57
+ "@deepseek-ai/dsh-agent": "^0.1.1-rc.2",
58
+ "@deepseek-ai/dsh-home-paths": "^0.1.1-rc.2",
59
+ "@deepseek-ai/dsh-host-webserver": "^0.1.1-rc.2",
60
+ "@deepseek-ai/dsh-system-prompt": "^0.1.1-rc.2",
61
+ "@deepseek-ai/dsh-tools": "^0.1.1-rc.2",
62
+ "@deepseek-ai/dsh-workspace": "^0.1.1-rc.2",
63
63
  "@deepseek-ai/schemastery": "^3.18.1",
64
64
  "@types/node": "^22.20.1",
65
65
  "@types/react": "~18.3.1",
package/src/client/api.ts CHANGED
@@ -18,10 +18,12 @@ import type {
18
18
  MoveTaskBody,
19
19
  RejectTaskBody,
20
20
  RunTaskBody,
21
+ SettingsResponse,
21
22
  StateResponse,
22
23
  TaskRecord,
23
24
  TaskTemplate,
24
25
  TemplatesResponse,
26
+ UpdateSettingsBody,
25
27
  UpdateTaskBody,
26
28
  WorktreeRemoveBody,
27
29
  WorkspaceView,
@@ -82,6 +84,10 @@ export interface TaskboardClient {
82
84
  templateUpsert(body: { id?: string; name: string; task: TaskTemplate['task'] }): Promise<TaskTemplate>
83
85
  /** Delete a template by id. */
84
86
  templateDelete(id: string): Promise<{ deleted: boolean }>
87
+ /** Board settings (0.5.0; absent fields = factory defaults). */
88
+ settings(): Promise<SettingsResponse>
89
+ /** Replace board settings (whole-object semantics; affects new tasks only). */
90
+ updateSettings(body: UpdateSettingsBody): Promise<SettingsResponse>
85
91
  /** Subscribe to change frames; the disposer stops the stream. */
86
92
  stream(onChange: (event: ChangeEvent) => void, onGap: () => void): () => void
87
93
  }
@@ -115,6 +121,8 @@ export function createClient(): TaskboardClient {
115
121
  templates: () => unwrap<TemplatesResponse>(fetch('/dsh-taskboard/templates')),
116
122
  templateUpsert: body => post('/dsh-taskboard/templates', body),
117
123
  templateDelete: id => post('/dsh-taskboard/templates/delete', { id }),
124
+ settings: () => unwrap<SettingsResponse>(fetch('/dsh-taskboard/settings')),
125
+ updateSettings: body => post('/dsh-taskboard/settings/update', body),
118
126
  stream(onChange, onGap) {
119
127
  const es = new EventSource('/dsh-taskboard/events')
120
128
  let revision: number | undefined
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Board-settings modal (0.5.0): the user-owned defaults applied when a NEW
3
+ * task is created without an explicit choice. Currently one section — 默认执行
4
+ * 隔离 (worktree vs original directory); further sections can slot into the
5
+ * body below. Saving goes through the host route (whole-object replace) and
6
+ * the SSE change stream refreshes every open view.
7
+ *
8
+ * @module dsh-taskboard/client/board/SettingsModal
9
+ */
10
+ import { useState } from 'react'
11
+ import type { BoardController } from '../controller.ts'
12
+ import { DEFAULT_ISOLATION, type IsolationMode } from '../../shared/protocol.ts'
13
+
14
+ /** The isolation options with one-line hints (mirrors the task form). */
15
+ const ISOLATION_OPTIONS: ReadonlyArray<{ value: IsolationMode; name: string; hint: string }> = [
16
+ { value: 'none', name: '📁 原目录执行', hint: '不使用 git,直接在项目目录工作(出厂默认)' },
17
+ { value: 'worktree', name: '🌿 Worktree 隔离', hint: '每次执行在独立 worktree 分支上进行(task/标题+ID),互不污染' },
18
+ ]
19
+
20
+ /**
21
+ * The 看板设置 modal: reads the live ledger settings, stages a local draft,
22
+ * and writes back through the controller on save.
23
+ * @param controller - the board controller.
24
+ */
25
+ export function SettingsModal({ controller }: { controller: BoardController }) {
26
+ const state = controller.getSnapshot()
27
+ const current = state.ledger.settings?.defaultIsolation ?? DEFAULT_ISOLATION
28
+ const [draft, setDraft] = useState<IsolationMode>(current)
29
+ const dirty = draft !== current
30
+
31
+ const save = (): void => {
32
+ void controller.updateSettings({ defaultIsolation: draft }).then(ok => {
33
+ if (ok) controller.closeSettings()
34
+ })
35
+ }
36
+
37
+ return (
38
+ <div className="dsh-atb-modal-backdrop" onClick={e => { if (e.target === e.currentTarget) controller.closeSettings() }}>
39
+ <div className="dsh-atb-modal dsh-atb-set" role="dialog" aria-modal="true" aria-label="看板设置">
40
+ <div className="dsh-atb-modal-head">
41
+ <span className="dsh-atb-modal-headicon">🛠</span>
42
+ <div className="dsh-atb-modal-headtext">
43
+ <h3>看板设置</h3>
44
+ <p>新建任务时应用的默认值(不影响已有任务)</p>
45
+ </div>
46
+ <button type="button" className="dsh-atb-modal-close" aria-label="关闭" onClick={() => controller.closeSettings()}>✕</button>
47
+ </div>
48
+
49
+ <div className="dsh-atb-modal-body">
50
+ <section className="dsh-atb-diag-sec">
51
+ <h4>默认执行隔离</h4>
52
+ <div className="dsh-atb-mode-picker">
53
+ {ISOLATION_OPTIONS.map(o => (
54
+ <button
55
+ key={o.value}
56
+ type="button"
57
+ className="dsh-atb-mode-opt"
58
+ data-on={draft === o.value}
59
+ title={o.hint}
60
+ onClick={() => setDraft(o.value)}
61
+ >
62
+ <span className="dsh-atb-mode-name">{o.name}</span>
63
+ <span className="dsh-atb-mode-hint">{o.hint}</span>
64
+ </button>
65
+ ))}
66
+ </div>
67
+ <span className="dsh-atb-isolation-note">
68
+ 当前保存的默认:{current === 'worktree' ? '🌿 Worktree 隔离' : '📁 原目录执行'}。
69
+ 仅影响之后新建的任务;已有任务保持创建时的选择,非 git 项目运行时仍自动降级原目录。
70
+ </span>
71
+ </section>
72
+ </div>
73
+
74
+ <div className="dsh-atb-modal-foot">
75
+ <span className="dsh-atb-modal-hint">{dirty ? '有未保存的修改' : '与看板当前设置一致'}</span>
76
+ <span className="dsh-atb-modal-footbtns">
77
+ <button type="button" className="dsh-atb-btn" onClick={() => controller.closeSettings()}>取消</button>
78
+ <button type="button" className="dsh-atb-btn" data-primary="true" disabled={!dirty} onClick={save}>保存设置</button>
79
+ </span>
80
+ </div>
81
+ </div>
82
+ </div>
83
+ )
84
+ }
@@ -12,6 +12,7 @@ import { PLUGIN_VERSION } from '../../shared/version.ts'
12
12
  import { DRAG_TYPE, TaskCard } from './TaskCard.tsx'
13
13
  import { TaskDetail } from './TaskDetail.tsx'
14
14
  import { TaskFormModal } from './TaskFormModal.tsx'
15
+ import { SettingsModal } from './SettingsModal.tsx'
15
16
  import { ImportModal } from './ImportModal.tsx'
16
17
  import { TemplateManager } from './TemplateManager.tsx'
17
18
  import { useAlert } from './AlertModal.tsx'
@@ -90,6 +91,9 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
90
91
  // + 新建任务 ▼ dropdown (0.4.0): blank / templates / manage / import.
91
92
  const [newMenuOpen, setNewMenuOpen] = useState(false)
92
93
  const closeMenu = (): void => setNewMenuOpen(false)
94
+ // ⬇ 导出 ▼ dropdown (0.5.1): whole-ledger JSON backup or task-list CSV.
95
+ const [exportOpen, setExportOpen] = useState(false)
96
+ const closeExport = (): void => setExportOpen(false)
93
97
 
94
98
  return (
95
99
  <div className="dsh-atb-board">
@@ -174,10 +178,42 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
174
178
  <button type="button" className="dsh-atb-btn" onClick={() => controller.toggleSecondary()}>
175
179
  {state.secondaryOpen ? '返回看板' : '其它任务'}
176
180
  </button>
181
+ <button type="button" className="dsh-atb-btn" title="看板设置:新建任务的默认执行隔离等" onClick={() => controller.openSettings()}>🛠 设置</button>
177
182
  <button type="button" className="dsh-atb-btn" title="健康诊断:遗留 worktree、台账基本项" onClick={() => controller.openDiagnostics()}>⚙ 诊断</button>
178
183
  <button type="button" className="dsh-atb-btn" title="从 JSON 备份文件导入台账(预览后合并或整册替换)" onClick={() => controller.openImport()}>⬆ 导入</button>
179
- <button type="button" className="dsh-atb-btn" title="下载完整台账备份(JSON)" onClick={() => controller.exportJson()}>⬇ JSON</button>
180
- <button type="button" className="dsh-atb-btn" title="下载任务清单(CSV)" onClick={() => controller.exportCsv()}>⬇ CSV</button>
184
+ <div className="dsh-atb-newmenu">
185
+ <button
186
+ type="button"
187
+ className="dsh-atb-btn"
188
+ title="导出台账:完整 JSON 备份或任务清单 CSV"
189
+ onClick={() => setExportOpen(!exportOpen)}
190
+ >
191
+ ⬇ 导出 ▼
192
+ </button>
193
+ {exportOpen && (
194
+ <>
195
+ <div className="dsh-atb-newmenu-backdrop" onClick={closeExport} />
196
+ <div className="dsh-atb-newmenu-list">
197
+ <button
198
+ type="button"
199
+ className="dsh-atb-newmenu-opt"
200
+ title="完整台账备份(含执行历史与看板设置),可用于导入恢复"
201
+ onClick={() => { closeExport(); controller.exportJson() }}
202
+ >
203
+ 完整台账(JSON)
204
+ </button>
205
+ <button
206
+ type="button"
207
+ className="dsh-atb-newmenu-opt"
208
+ title="任务清单表格(Excel 可直接打开,中文已加 BOM)"
209
+ onClick={() => { closeExport(); controller.exportCsv() }}
210
+ >
211
+ 任务清单(CSV)
212
+ </button>
213
+ </div>
214
+ </>
215
+ )}
216
+ </div>
181
217
  <a
182
218
  className="dsh-atb-ver"
183
219
  href="https://github.com/cloader/dsh-taskboard"
@@ -261,6 +297,8 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
261
297
 
262
298
  {state.diagOpen && <DiagnosticsPanel controller={controller} />}
263
299
 
300
+ {state.settingsOpen && <SettingsModal controller={controller} />}
301
+
264
302
  {state.importOpen && <ImportModal controller={controller} />}
265
303
 
266
304
  {state.tplManagerOpen && <TemplateManager controller={controller} />}
@@ -10,10 +10,9 @@
10
10
  */
11
11
  import { useEffect, useRef, useState, type ReactNode } from 'react'
12
12
  import type { BoardController } from '../controller.ts'
13
- import { loadDefaultIsolation, saveDefaultIsolation } from '../controller.ts'
14
13
  import type { TaskTemplateSpec } from '../../shared/api.ts'
15
14
  import type { ChecklistItem, IsolationMode, Urgency } from '../../shared/protocol.ts'
16
- import { MAX_CHECKLIST_ITEMS, nextCronTime, parseCron } from '../../shared/protocol.ts'
15
+ import { MAX_CHECKLIST_ITEMS, defaultIsolationOf, nextCronTime, parseCron } from '../../shared/protocol.ts'
17
16
  import { fmtTime } from './TaskBoard.tsx'
18
17
 
19
18
  /** One row of the configured model catalog (from llm.models). */
@@ -134,10 +133,10 @@ export function TaskFormModal({ controller, task }: { controller: BoardControlle
134
133
  const [presetId, setPresetId] = useState(initialPreset)
135
134
  const [presets, setPresets] = useState<Array<{ id: string; name?: string }>>([])
136
135
  const [presetDefault, setPresetDefault] = useState<string | undefined>(undefined)
137
- // Isolation toggle: create mode starts from the remembered choice (default
138
- // on) or the template's choice; edit mode starts from the task and locks
139
- // once execution began.
140
- const [isolation, setIsolation] = useState<IsolationMode>(task?.isolation ?? (prefill?.isolation === 'none' ? 'none' : prefill?.isolation === 'worktree' ? 'worktree' : loadDefaultIsolation()))
136
+ // Isolation toggle: create mode starts from the board setting (0.5.0
137
+ // 看板设置 → 默认执行隔离) or the template's choice; edit mode starts from
138
+ // the task and locks once execution began.
139
+ const [isolation, setIsolation] = useState<IsolationMode>(task?.isolation ?? (prefill?.isolation === 'none' ? 'none' : prefill?.isolation === 'worktree' ? 'worktree' : defaultIsolationOf(state.ledger.settings)))
141
140
  // Checklist (0.4.0): create = template texts / blank rows; edit = live items.
142
141
  const [checkRows, setCheckRows] = useState<CheckRow[]>(
143
142
  task?.checklist !== undefined && task.checklist.length > 0
@@ -193,10 +192,12 @@ export function TaskFormModal({ controller, task }: { controller: BoardControlle
193
192
  // default (runtime auto-degrades with a note) instead of persisting 'none'.
194
193
  const isolationDisabled = isolationLocked || !gitOk
195
194
 
196
- /** Isolation payload for submit: undefined keeps the default (degrades naturally). */
195
+ /**
196
+ * Isolation payload for submit: undefined lets the HOST materialize the
197
+ * current board default at creation (non-git projects degrade naturally).
198
+ */
197
199
  const isolationPayload = (): string | undefined => {
198
200
  if (!gitOk) return undefined
199
- if (!editing) saveDefaultIsolation(isolation)
200
201
  return isolation
201
202
  }
202
203
 
@@ -8,7 +8,7 @@
8
8
  * @module dsh-taskboard/client/controller
9
9
  */
10
10
  import type { ChangeEvent, DiagnosticsResponse, DiffResponse, ImportCommitResponse, ImportPreviewResponse, TaskTemplate, TaskTemplateSpec, UpdateTaskBody, WorkspaceView } from '../shared/api.ts'
11
- import type { ChecklistItem, IsolationMode, TaskLedger, TaskRecord, Urgency } from '../shared/protocol.ts'
11
+ import type { ChecklistItem, TaskLedger, TaskRecord, Urgency } from '../shared/protocol.ts'
12
12
  import { emptyLedger } from '../shared/protocol.ts'
13
13
  import type { TaskboardClient } from './api.ts'
14
14
  import type { SessionJumpResult } from './session-jump.ts'
@@ -27,26 +27,6 @@ export type SortBy = 'default' | 'updated' | 'urgency' | 'created'
27
27
  /** localStorage key for persisted view state (filters + sort). */
28
28
  const VIEW_KEY = 'dsh-taskboard-view-v1'
29
29
 
30
- /** localStorage key for the remembered isolation toggle choice (0.3.0). */
31
- const ISOLATION_KEY = 'dsh-taskboard-isolation-v1'
32
-
33
- /** Load the remembered default isolation (worktree unless explicitly turned off). */
34
- export function loadDefaultIsolation(): IsolationMode {
35
- try {
36
- const raw = localStorage.getItem(ISOLATION_KEY)
37
- return raw === 'none' ? 'none' : 'worktree'
38
- } catch {
39
- return 'worktree'
40
- }
41
- }
42
-
43
- /** Remember the isolation toggle choice across forms (best effort). */
44
- export function saveDefaultIsolation(mode: IsolationMode): void {
45
- try {
46
- localStorage.setItem(ISOLATION_KEY, mode)
47
- } catch { /* storage unavailable — choice just won't persist */ }
48
- }
49
-
50
30
  /** Load the persisted view state (never throws; fresh on any parse error). */
51
31
  function loadView(): { workspaceId?: string; urgencies: Urgency[]; sortBy: SortBy } {
52
32
  try {
@@ -92,6 +72,8 @@ export interface ControllerState {
92
72
  tplManagerOpen: boolean
93
73
  /** Import modal visible (0.4.0). */
94
74
  importOpen: boolean
75
+ /** Board-settings modal visible (0.5.0). */
76
+ settingsOpen: boolean
95
77
  /** Fields a chosen template prefills into the create form (consumed on open). */
96
78
  templatePrefill?: TaskTemplateSpec
97
79
  /** Transient error surface (action failures); cleared on next success. */
@@ -114,6 +96,7 @@ function initialState(): ControllerState {
114
96
  templates: [],
115
97
  tplManagerOpen: false,
116
98
  importOpen: false,
99
+ settingsOpen: false,
117
100
  }
118
101
  }
119
102
 
@@ -459,6 +442,28 @@ export class BoardController {
459
442
  /** Close the ⚙ diagnostics panel. */
460
443
  closeDiagnostics(): void { this.setState({ diagOpen: false }) }
461
444
 
445
+ /** Open the board-settings modal (0.5.0). */
446
+ openSettings(): void { this.setState({ settingsOpen: true }) }
447
+
448
+ /** Close the board-settings modal. */
449
+ closeSettings(): void { this.setState({ settingsOpen: false }) }
450
+
451
+ /**
452
+ * Replace board settings (0.5.0). The host broadcasts a settings-updated
453
+ * frame; refresh() pulls ledger.settings so every open view follows.
454
+ * @returns whether the write succeeded.
455
+ */
456
+ async updateSettings(body: Parameters<TaskboardClient['updateSettings']>[0]): Promise<boolean> {
457
+ try {
458
+ await this.client.updateSettings(body)
459
+ await this.refresh()
460
+ return true
461
+ } catch (error) {
462
+ this.setState({ error: error instanceof Error ? error.message : String(error) })
463
+ return false
464
+ }
465
+ }
466
+
462
467
  /** Clean one orphan worktree (⚙ panel); refreshes the diagnostics payload. */
463
468
  async cleanupOrphan(workspaceId: string, taskId: string): Promise<void> {
464
469
  try {
@@ -49,11 +49,6 @@ interface ClientContextFace {
49
49
  export function apply(ctx: ClientContextFace): void {
50
50
  try {
51
51
  injectStyles()
52
- // Stylesheet watchdog (0.4.4): re-assert the stylesheet if anything
53
- // removed it mid-session — defense-in-depth on top of the ownership
54
- // tag (see styles.ts for the claim/reload mechanism it guards against).
55
- // One getElementById per 2s tick; cleared with the plugin teardown.
56
- const styleWatch = setInterval(() => { injectStyles() }, 2_000)
57
52
  const client = createClient()
58
53
  const controller = new BoardController(client)
59
54
 
@@ -114,7 +109,6 @@ export function apply(ctx: ClientContextFace): void {
114
109
  // race a same-lifetime re-apply); its rules are dsh-atb-* scoped, so a
115
110
  // leftover tag after a full disable is inert.
116
111
  ctx.effect?.(() => () => {
117
- clearInterval(styleWatch)
118
112
  for (const d of disposers.splice(0)) d()
119
113
  controller.dispose()
120
114
  }, 'dsh-taskboard: client mount')