dsh-taskboard 0.3.3 → 0.4.1

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.
Files changed (44) hide show
  1. package/README.md +27 -6
  2. package/lib/client.js +1201 -42
  3. package/lib/host/execution.js +6 -1
  4. package/lib/host/execution.js.map +1 -1
  5. package/lib/host/git.js +95 -2
  6. package/lib/host/git.js.map +1 -1
  7. package/lib/host/protocol-text.js +5 -3
  8. package/lib/host/protocol-text.js.map +1 -1
  9. package/lib/host/routes.js +184 -2
  10. package/lib/host/routes.js.map +1 -1
  11. package/lib/host/store.js +12 -0
  12. package/lib/host/store.js.map +1 -1
  13. package/lib/host/templates.js +166 -0
  14. package/lib/host/templates.js.map +1 -0
  15. package/lib/host/tools.js +202 -2
  16. package/lib/host/tools.js.map +1 -1
  17. package/lib/index.js +7 -2
  18. package/lib/index.js.map +1 -1
  19. package/lib/shared/api.js.map +1 -1
  20. package/lib/shared/protocol.js +277 -1
  21. package/lib/shared/protocol.js.map +1 -1
  22. package/package.json +1 -1
  23. package/src/client/api.ts +28 -0
  24. package/src/client/board/ImportModal.tsx +182 -0
  25. package/src/client/board/TaskBoard.tsx +45 -3
  26. package/src/client/board/TaskCard.tsx +9 -0
  27. package/src/client/board/TaskDetail.tsx +192 -8
  28. package/src/client/board/TaskFormModal.tsx +100 -18
  29. package/src/client/board/TemplateManager.tsx +121 -0
  30. package/src/client/board-mount.tsx +8 -0
  31. package/src/client/controller.ts +152 -8
  32. package/src/client/sidebar-entry.ts +6 -3
  33. package/src/client/styles.ts +153 -0
  34. package/src/host/execution.ts +10 -2
  35. package/src/host/git.ts +77 -0
  36. package/src/host/protocol-text.ts +5 -3
  37. package/src/host/routes.ts +215 -0
  38. package/src/host/store.ts +13 -0
  39. package/src/host/templates.ts +143 -0
  40. package/src/host/tools.ts +198 -2
  41. package/src/index.ts +6 -0
  42. package/src/shared/api.ts +54 -0
  43. package/src/shared/protocol.ts +344 -0
  44. package/src/shared/version.ts +1 -1
@@ -183,6 +183,10 @@ function newTaskId() {
183
183
  function newCommentId() {
184
184
  return `c-${Date.now().toString(36)}-${suffix()}`;
185
185
  }
186
+ /** Mint a checklist item id. */
187
+ function newChecklistItemId() {
188
+ return `k-${Date.now().toString(36)}-${suffix()}`;
189
+ }
186
190
  /** Mint an execution id. */
187
191
  function newExecutionId() {
188
192
  return `e-${Date.now().toString(36)}-${suffix()}`;
@@ -311,11 +315,282 @@ function normalizeModel(raw) {
311
315
  };
312
316
  }
313
317
  /**
318
+ * Validate and normalize one checklist text line: trimmed, 1..200 chars.
319
+ * @param raw - the raw text.
320
+ * @throws when empty or too long.
321
+ */
322
+ function normalizeChecklistText(raw) {
323
+ const t = raw.trim();
324
+ if (t.length === 0 || t.length > 200) throw new Error(`checklist item text must be 1..200 characters`);
325
+ return t;
326
+ }
327
+ /**
328
+ * Build a fresh unchecked checklist from plain text lines (create route /
329
+ * templates / tool adds).
330
+ * @param texts - the item texts (validated individually).
331
+ */
332
+ function checklistFromTexts(texts) {
333
+ const items = texts.map((text) => ({
334
+ id: newChecklistItemId(),
335
+ text: normalizeChecklistText(text),
336
+ checked: false
337
+ }));
338
+ if (items.length > 30) throw new Error(`checklist may hold at most 30 items`);
339
+ return items;
340
+ }
341
+ /**
342
+ * Validate and normalize a full checklist array (GUI update route, import):
343
+ * missing ids are minted, text is checked, checked flags must be booleans,
344
+ * checkedBy/checkedAt are kept only on checked items.
345
+ * @param raw - untyped array from the wire.
346
+ * @throws with a readable reason on any invalid entry.
347
+ */
348
+ function normalizeChecklist(raw) {
349
+ if (!Array.isArray(raw)) throw new Error("checklist must be an array");
350
+ if (raw.length > 30) throw new Error(`checklist may hold at most 30 items`);
351
+ return raw.map((entry) => {
352
+ if (typeof entry !== "object" || entry === null) throw new Error("checklist item must be an object");
353
+ const e = entry;
354
+ const text = normalizeChecklistText(typeof e.text === "string" ? e.text : "");
355
+ const id = typeof e.id === "string" && e.id.trim().length > 0 ? e.id.trim() : newChecklistItemId();
356
+ const checked = e.checked === true;
357
+ const checkedBy = typeof e.checkedBy === "string" ? e.checkedBy.trim().slice(0, 100) : void 0;
358
+ const checkedAt = typeof e.checkedAt === "number" && Number.isFinite(e.checkedAt) ? e.checkedAt : void 0;
359
+ const note = typeof e.note === "string" && e.note.trim().length > 0 ? e.note.trim().slice(0, 400) : void 0;
360
+ if (!checked) return {
361
+ id,
362
+ text,
363
+ checked: false
364
+ };
365
+ return {
366
+ id,
367
+ text,
368
+ checked: true,
369
+ ...checkedBy !== void 0 && checkedBy.length > 0 ? { checkedBy } : {},
370
+ ...checkedAt !== void 0 ? { checkedAt } : {},
371
+ ...note !== void 0 ? { note } : {}
372
+ };
373
+ });
374
+ }
375
+ /** Checklist progress: how many items are checked (absent checklist → 0/0). */
376
+ function checklistProgress(task) {
377
+ const items = task.checklist ?? [];
378
+ return {
379
+ done: items.filter((i) => i.checked).length,
380
+ total: items.length
381
+ };
382
+ }
383
+ /** Report string-list caps. */
384
+ const REPORT_LIST_CAPS = {
385
+ changedFiles: 50,
386
+ checks: 50,
387
+ artifacts: 30
388
+ };
389
+ /** Per-entry cap for report lists (chars). */
390
+ const REPORT_ENTRY_MAX = 300;
391
+ /** Validate one report string list: strings trimmed 1..300 chars. */
392
+ function normalizeReportList(raw, field) {
393
+ if (raw === void 0) return [];
394
+ if (!Array.isArray(raw)) throw new Error(`report.${field} must be an array of strings`);
395
+ const out = raw.map((entry) => {
396
+ if (typeof entry !== "string") throw new Error(`report.${field} must be an array of strings`);
397
+ const t = entry.trim();
398
+ if (t.length === 0 || t.length > REPORT_ENTRY_MAX) throw new Error(`report.${field} entries must be 1..${REPORT_ENTRY_MAX} characters`);
399
+ return t;
400
+ });
401
+ if (out.length > REPORT_LIST_CAPS[field]) throw new Error(`report.${field} may hold at most ${REPORT_LIST_CAPS[field]} entries`);
402
+ return out;
403
+ }
404
+ /**
405
+ * Validate and normalize a structured execution report.
406
+ * @param raw - untyped tool/route input.
407
+ * @throws with a readable reason on any invalid field.
408
+ */
409
+ function normalizeExecutionReport(raw) {
410
+ if (typeof raw !== "object" || raw === null) throw new Error("report must be an object");
411
+ const e = raw;
412
+ const summary = typeof e.summary === "string" ? e.summary.trim() : "";
413
+ if (summary.length === 0 || summary.length > 2e3) throw new Error("report.summary must be 1..2000 characters");
414
+ const risk = typeof e.risk === "string" ? e.risk.trim().slice(0, 2e3) : "";
415
+ return {
416
+ summary,
417
+ changedFiles: normalizeReportList(e.changedFiles, "changedFiles"),
418
+ checks: normalizeReportList(e.checks, "checks"),
419
+ artifacts: normalizeReportList(e.artifacts, "artifacts"),
420
+ risk
421
+ };
422
+ }
423
+ /** One unknown-value read helper: string fields with defaults. */
424
+ function strOr(raw, key, fallback) {
425
+ const v = raw[key];
426
+ return typeof v === "string" ? v : fallback;
427
+ }
428
+ /** One unknown-value read helper: finite numbers with defaults. */
429
+ function numOr(raw, key, fallback) {
430
+ const v = raw[key];
431
+ return typeof v === "number" && Number.isFinite(v) ? v : fallback;
432
+ }
433
+ /**
434
+ * Validate ONE imported task record (pure): rebuilds it field by field with
435
+ * the normal validators, minting missing ids and re-arming cron. Executions
436
+ * left `running` by the exporting machine are marked failed — their
437
+ * settlement watchers died there and can never settle here.
438
+ * @param raw - the untyped record.
439
+ * @param now - current epoch ms (defaults for timestamps).
440
+ * @returns the rebuilt record, or a rejection reason.
441
+ */
442
+ function validateImportedTask(raw, now) {
443
+ if (typeof raw !== "object" || raw === null) return {
444
+ ok: false,
445
+ reason: "not an object"
446
+ };
447
+ const e = raw;
448
+ const id = typeof e.id === "string" ? e.id.trim() : "";
449
+ const fail = (reason) => ({
450
+ ok: false,
451
+ reason
452
+ });
453
+ if (id.length === 0 || id.length > 100) return fail("missing/invalid id");
454
+ try {
455
+ const execution = normalizeExecution(typeof e.execution === "object" && e.execution !== null ? e.execution : {}, now);
456
+ const comments = [];
457
+ if (Array.isArray(e.comments)) for (const c of e.comments) {
458
+ if (typeof c !== "object" || c === null) return fail("invalid comment entry");
459
+ const ce = c;
460
+ const body = typeof ce.body === "string" ? ce.body : "";
461
+ if (body.trim().length === 0 || body.length > 4e3) return fail("invalid comment body");
462
+ comments.push({
463
+ id: typeof ce.id === "string" && ce.id.length > 0 ? ce.id : newCommentId(),
464
+ body,
465
+ version: numOr(ce, "version", 1),
466
+ createdAt: numOr(ce, "createdAt", now),
467
+ ...typeof ce.threadId === "string" ? { threadId: ce.threadId } : {}
468
+ });
469
+ }
470
+ else return fail("comments must be an array");
471
+ const executions = [];
472
+ if (Array.isArray(e.executions)) for (const x of e.executions) {
473
+ if (typeof x !== "object" || x === null) return fail("invalid execution entry");
474
+ const xe = x;
475
+ const trigger = xe.trigger === "scheduled" ? "scheduled" : "manual";
476
+ const outcomeRaw = xe.outcome;
477
+ if (outcomeRaw !== "running" && outcomeRaw !== "succeeded" && outcomeRaw !== "failed" && outcomeRaw !== "cancelled") return fail("invalid execution outcome");
478
+ const outcome = outcomeRaw === "running" ? "failed" : outcomeRaw;
479
+ executions.push({
480
+ id: typeof xe.id === "string" && xe.id.length > 0 ? xe.id : newExecutionId(),
481
+ ...typeof xe.sessionId === "string" ? { sessionId: xe.sessionId } : {},
482
+ trigger,
483
+ ...typeof xe.startedAt === "number" ? { startedAt: xe.startedAt } : {},
484
+ ...typeof xe.endedAt === "number" ? { endedAt: xe.endedAt } : {},
485
+ outcome,
486
+ ...outcomeRaw === "running" ? { error: "imported while still running (settlement watcher died with the exporting host)" } : typeof xe.error === "string" ? { error: xe.error } : {},
487
+ ...typeof xe.isolation === "string" && (xe.isolation === "worktree" || xe.isolation === "none") ? { isolation: xe.isolation } : {},
488
+ ...typeof xe.isolationNote === "string" ? { isolationNote: xe.isolationNote } : {},
489
+ ...typeof xe.branch === "string" ? { branch: xe.branch } : {},
490
+ ...typeof xe.worktreePath === "string" ? { worktreePath: xe.worktreePath } : {},
491
+ ...typeof xe.baseCommit === "string" ? { baseCommit: xe.baseCommit } : {},
492
+ ...typeof xe.headCommit === "string" ? { headCommit: xe.headCommit } : {},
493
+ ...Array.isArray(xe.commits) ? { commits: xe.commits.filter((c) => typeof c === "object" && c !== null && typeof c.hash === "string" && typeof c.subject === "string") } : {},
494
+ ...typeof xe.commitsTotal === "number" ? { commitsTotal: xe.commitsTotal } : {},
495
+ ...Array.isArray(xe.dirtyFiles) ? { dirtyFiles: xe.dirtyFiles.filter((l) => typeof l === "string") } : {},
496
+ ...typeof xe.dirtyFilesTotal === "number" ? { dirtyFilesTotal: xe.dirtyFilesTotal } : {},
497
+ ...typeof xe.diffStat === "string" ? { diffStat: xe.diffStat } : {},
498
+ ...typeof xe.changedFiles === "number" ? { changedFiles: xe.changedFiles } : {},
499
+ ...typeof xe.report === "object" && xe.report !== null ? { report: normalizeExecutionReport(xe.report) } : {}
500
+ });
501
+ }
502
+ else return fail("executions must be an array");
503
+ const status = asStatus(strOr(e, "status", "todo"));
504
+ const actorOf = (v) => typeof v === "object" && v !== null && v.kind === "agent" && typeof v.sessionId === "string" ? {
505
+ kind: "agent",
506
+ sessionId: v.sessionId
507
+ } : { kind: "user" };
508
+ const task = {
509
+ id,
510
+ title: normalizeTitle(strOr(e, "title", "")),
511
+ description: strOr(e, "description", "").trim(),
512
+ prompt: normalizePrompt(strOr(e, "prompt", "")),
513
+ workspaceId: strOr(e, "workspaceId", ""),
514
+ urgency: asUrgency(strOr(e, "urgency", "normal")),
515
+ status,
516
+ blocked: e.blocked === true,
517
+ execution,
518
+ ...typeof e.model === "object" && e.model !== null ? { model: normalizeModel(e.model) } : {},
519
+ ...typeof e.isolation === "string" && (e.isolation === "worktree" || e.isolation === "none") ? { isolation: e.isolation } : {},
520
+ ...typeof e.presetId === "string" && e.presetId.trim().length > 0 ? { presetId: e.presetId.trim() } : {},
521
+ ...Array.isArray(e.checklist) ? { checklist: normalizeChecklist(e.checklist) } : {},
522
+ ...typeof e.branch === "string" ? { branch: e.branch } : {},
523
+ ...status === "in_progress" && typeof e.claimedBy === "string" ? { claimedBy: e.claimedBy } : {},
524
+ ...status === "in_progress" && typeof e.claimedAt === "number" ? { claimedAt: e.claimedAt } : {},
525
+ version: Math.max(1, Math.trunc(numOr(e, "version", 1))),
526
+ createdAt: numOr(e, "createdAt", now),
527
+ updatedAt: numOr(e, "updatedAt", now),
528
+ createdBy: actorOf(e.createdBy),
529
+ updatedBy: actorOf(e.updatedBy),
530
+ comments,
531
+ executions,
532
+ ...typeof e.executionsPruned === "number" ? { executionsPruned: e.executionsPruned } : {},
533
+ ...typeof e.trashedAt === "number" ? { trashedAt: e.trashedAt } : {}
534
+ };
535
+ if (task.workspaceId.length === 0) return fail("missing workspaceId");
536
+ return {
537
+ ok: true,
538
+ task
539
+ };
540
+ } catch (error) {
541
+ return fail(error instanceof Error ? error.message : String(error));
542
+ }
543
+ }
544
+ /**
545
+ * Validate a whole imported ledger and classify its tasks against the live
546
+ * one (pure). Duplicate ids INSIDE the file are invalid (first wins, later
547
+ * copies reported); schemaVersion must match {@link LEDGER_SCHEMA_VERSION}.
548
+ * @param raw - the parsed import file.
549
+ * @param knownIds - live ledger task ids.
550
+ * @param now - current epoch ms.
551
+ * @throws when the file is not a ledger or the schemaVersion is unsupported.
552
+ */
553
+ function validateLedgerImport(raw, knownIds, now) {
554
+ if (typeof raw !== "object" || raw === null) throw new Error("导入文件不是 JSON 对象");
555
+ const e = raw;
556
+ if (e.schemaVersion !== 1) throw new Error(`不支持的 schemaVersion ${String(e.schemaVersion)}(当前支持 1)`);
557
+ if (!Array.isArray(e.tasks)) throw new Error("导入文件的 tasks 不是数组");
558
+ const plan = {
559
+ create: [],
560
+ overwrite: [],
561
+ invalid: []
562
+ };
563
+ const seen = /* @__PURE__ */ new Set();
564
+ for (const entry of e.tasks) {
565
+ const id = typeof entry?.id === "string" ? entry.id : void 0;
566
+ const result = validateImportedTask(entry, now);
567
+ if (!result.ok) {
568
+ plan.invalid.push({
569
+ ...id !== void 0 ? { id } : {},
570
+ reason: result.reason
571
+ });
572
+ continue;
573
+ }
574
+ if (seen.has(result.task.id)) {
575
+ plan.invalid.push({
576
+ id: result.task.id,
577
+ reason: "文件内重复 id"
578
+ });
579
+ continue;
580
+ }
581
+ seen.add(result.task.id);
582
+ if (knownIds.has(result.task.id)) plan.overwrite.push(result.task);
583
+ else plan.create.push(result.task);
584
+ }
585
+ return plan;
586
+ }
587
+ /**
314
588
  * Build the compact summary of a task.
315
589
  * @param task - the task.
316
590
  */
317
591
  function summarize(task) {
318
592
  const last = task.executions.length > 0 ? task.executions[task.executions.length - 1] : void 0;
593
+ const checklist = task.checklist !== void 0 && task.checklist.length > 0 ? checklistProgress(task) : void 0;
319
594
  return {
320
595
  id: task.id,
321
596
  title: task.title,
@@ -330,10 +605,11 @@ function summarize(task) {
330
605
  claimOwner: isClaimedBy(task),
331
606
  commentCount: task.comments.length,
332
607
  lastExecutionOutcome: last?.outcome,
608
+ ...checklist !== void 0 ? { checklist } : {},
333
609
  trashed: task.trashedAt !== void 0
334
610
  };
335
611
  }
336
612
  //#endregion
337
- export { ALL_STATUSES, MAIN_STATUSES, SECONDARY_STATUSES, URGENCIES, asIsolation, asStatus, asUrgency, canTransition, effectiveIsolation, effectivePrompt, emptyLedger, isClaim, isClaimedBy, newCommentId, newExecutionId, newTaskId, nextCronTime, normalizeBody, normalizeExecution, normalizeModel, normalizePrompt, normalizeTitle, parseCron, pruneExecutions, summarize, syncClaim };
613
+ export { ALL_STATUSES, MAIN_STATUSES, SECONDARY_STATUSES, URGENCIES, asIsolation, asStatus, asUrgency, canTransition, checklistFromTexts, checklistProgress, effectiveIsolation, effectivePrompt, emptyLedger, isClaim, isClaimedBy, newChecklistItemId, newCommentId, newExecutionId, newTaskId, nextCronTime, normalizeBody, normalizeChecklist, normalizeChecklistText, normalizeExecution, normalizeExecutionReport, normalizeModel, normalizePrompt, normalizeTitle, parseCron, pruneExecutions, summarize, syncClaim, validateImportedTask, validateLedgerImport };
338
614
 
339
615
  //# sourceMappingURL=protocol.js.map
@@ -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/** 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}\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 * 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 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 * 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 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 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 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 = 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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"}
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.3.3",
4
+ "version": "0.4.1",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "exports": {
package/src/client/api.ts CHANGED
@@ -11,12 +11,17 @@ import type {
11
11
  CreateTaskBody,
12
12
  DeleteTaskBody,
13
13
  DiagnosticsResponse,
14
+ DiffResponse,
15
+ ImportCommitResponse,
16
+ ImportPreviewResponse,
14
17
  MergeBranchResponse,
15
18
  MoveTaskBody,
16
19
  RejectTaskBody,
17
20
  RunTaskBody,
18
21
  StateResponse,
19
22
  TaskRecord,
23
+ TaskTemplate,
24
+ TemplatesResponse,
20
25
  UpdateTaskBody,
21
26
  WorktreeRemoveBody,
22
27
  WorkspaceView,
@@ -65,6 +70,18 @@ export interface TaskboardClient {
65
70
  diagnostics(): Promise<DiagnosticsResponse>
66
71
  /** Clean up one orphan worktree directory (task no longer in the ledger). */
67
72
  worktreeCleanup(workspaceId: string, taskId: string): Promise<{ cleaned: true; path: string }>
73
+ /** Diff view: one execution's commit or changed path (read-only, capped). */
74
+ diff(taskId: string, query: { execution: string; commit?: string; path?: string }): Promise<DiffResponse>
75
+ /** Import dry-run: classify the uploaded ledger against the live one. */
76
+ importPreview(file: unknown): Promise<ImportPreviewResponse>
77
+ /** Commit an import (merge upserts; replace swaps the whole ledger, backing it up first). */
78
+ importCommit(mode: 'merge' | 'replace', ledger: unknown): Promise<ImportCommitResponse>
79
+ /** List task templates. */
80
+ templates(): Promise<TemplatesResponse>
81
+ /** Create or replace a template. */
82
+ templateUpsert(body: { id?: string; name: string; task: TaskTemplate['task'] }): Promise<TaskTemplate>
83
+ /** Delete a template by id. */
84
+ templateDelete(id: string): Promise<{ deleted: boolean }>
68
85
  /** Subscribe to change frames; the disposer stops the stream. */
69
86
  stream(onChange: (event: ChangeEvent) => void, onGap: () => void): () => void
70
87
  }
@@ -87,6 +104,17 @@ export function createClient(): TaskboardClient {
87
104
  worktreeRemove: (id, body) => post(`/dsh-taskboard/tasks/${encodeURIComponent(id)}/worktree-remove`, body),
88
105
  diagnostics: () => unwrap<DiagnosticsResponse>(fetch('/dsh-taskboard/diagnostics')),
89
106
  worktreeCleanup: (workspaceId, taskId) => post('/dsh-taskboard/worktree-cleanup', { workspaceId, taskId }),
107
+ diff: (taskId, query) => {
108
+ const params = new URLSearchParams({ execution: query.execution })
109
+ if (query.commit !== undefined) params.set('commit', query.commit)
110
+ if (query.path !== undefined) params.set('path', query.path)
111
+ return unwrap<DiffResponse>(fetch(`/dsh-taskboard/tasks/${encodeURIComponent(taskId)}/diff?${params.toString()}`))
112
+ },
113
+ importPreview: file => post('/dsh-taskboard/import/preview', file),
114
+ importCommit: (mode, ledger) => post('/dsh-taskboard/import', { mode, ledger }),
115
+ templates: () => unwrap<TemplatesResponse>(fetch('/dsh-taskboard/templates')),
116
+ templateUpsert: body => post('/dsh-taskboard/templates', body),
117
+ templateDelete: id => post('/dsh-taskboard/templates/delete', { id }),
90
118
  stream(onChange, onGap) {
91
119
  const es = new EventSource('/dsh-taskboard/events')
92
120
  let revision: number | undefined