@linxin666/dsh-client-ui-task-board 0.4.4 → 0.4.5

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 (108) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +42 -11
  3. package/README.zh.md +42 -11
  4. package/lib/client.js +4958 -472
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +2841 -121
  7. package/lib/types/client/PluginSettingsCard.d.ts +7 -0
  8. package/lib/types/client/TaskBoardSettingsCard.d.ts +19 -3
  9. package/lib/types/client/board/ClampedMarkdown.d.ts +3 -0
  10. package/lib/types/client/board/ConfirmDialog.d.ts +4 -1
  11. package/lib/types/client/board/EditTaskModal.d.ts +5 -2
  12. package/lib/types/client/board/LinkSubtaskModal.d.ts +5 -2
  13. package/lib/types/client/board/NewTaskModal.d.ts +4 -1
  14. package/lib/types/client/board/TagManagerModal.d.ts +26 -0
  15. package/lib/types/client/board/TaskBoard.d.ts +10 -1
  16. package/lib/types/client/board/TaskCard.d.ts +14 -1
  17. package/lib/types/client/board/TaskDetail.d.ts +3 -1
  18. package/lib/types/client/board/TaskForm.d.ts +4 -1
  19. package/lib/types/client/board/VerificationReport.d.ts +23 -0
  20. package/lib/types/client/board/card-view.d.ts +113 -0
  21. package/lib/types/client/board/icons.d.ts +32 -0
  22. package/lib/types/client/board/overlay.d.ts +69 -0
  23. package/lib/types/client/board/status-key.d.ts +15 -0
  24. package/lib/types/client/board/task-markdown.d.ts +19 -0
  25. package/lib/types/client/board-card-seat.d.ts +34 -0
  26. package/lib/types/client/index.d.ts +19 -0
  27. package/lib/types/client/locales.d.ts +103 -1
  28. package/lib/types/client/native-panel.d.ts +6 -20
  29. package/lib/types/client/seats.d.ts +18 -0
  30. package/lib/types/client/service.d.ts +32 -0
  31. package/lib/types/client/settings-form.d.ts +16 -0
  32. package/lib/types/core/controller.d.ts +37 -1
  33. package/lib/types/core/extension.d.ts +283 -0
  34. package/lib/types/core/handover.d.ts +13 -1
  35. package/lib/types/core/issue-form.d.ts +16 -0
  36. package/lib/types/core/tasks.d.ts +84 -3
  37. package/lib/types/core/use-cases/task-tag.d.ts +48 -0
  38. package/lib/types/core/use-cases/task-update.d.ts +5 -3
  39. package/lib/types/core/verification.d.ts +386 -0
  40. package/lib/types/core/workspace-target.d.ts +41 -0
  41. package/lib/types/host/agent-tools.d.ts +1 -1
  42. package/lib/types/host/extension-registry.d.ts +111 -0
  43. package/lib/types/host/llm-dispatch.d.ts +63 -0
  44. package/lib/types/host/verification-gate.d.ts +71 -0
  45. package/lib/types/host/verification-runner.d.ts +108 -0
  46. package/lib/types/host/workspace-evidence.d.ts +53 -0
  47. package/lib/types/host-ledger.d.ts +52 -8
  48. package/lib/types/host-runner.d.ts +23 -1
  49. package/lib/types/host-service.d.ts +88 -2
  50. package/lib/types/index.d.ts +80 -26
  51. package/lib/types/protocol.d.ts +49 -5
  52. package/lib/types/tool-surface.d.ts +71 -0
  53. package/package.json +27 -26
  54. package/src/client/PluginSettingsCard.tsx +9 -0
  55. package/src/client/TaskBoardSettingsCard.tsx +304 -65
  56. package/src/client/board/ClampedMarkdown.tsx +69 -0
  57. package/src/client/board/ConfirmDialog.tsx +7 -3
  58. package/src/client/board/EditTaskModal.tsx +5 -2
  59. package/src/client/board/LinkSubtaskModal.tsx +12 -7
  60. package/src/client/board/NewTaskModal.tsx +17 -1
  61. package/src/client/board/TagManagerModal.tsx +235 -0
  62. package/src/client/board/TaskBoard.tsx +259 -103
  63. package/src/client/board/TaskCard.tsx +214 -81
  64. package/src/client/board/TaskDetail.tsx +71 -26
  65. package/src/client/board/TaskForm.tsx +13 -3
  66. package/src/client/board/VerificationReport.tsx +203 -0
  67. package/src/client/board/card-view.ts +220 -0
  68. package/src/client/board/icons.tsx +89 -0
  69. package/src/client/board/overlay.tsx +189 -0
  70. package/src/client/board/status-key.ts +27 -0
  71. package/src/client/board/task-markdown.module.css +137 -0
  72. package/src/client/board/task-markdown.tsx +106 -0
  73. package/src/client/board-card-seat.ts +102 -0
  74. package/src/client/board-settings.module.css +66 -0
  75. package/src/client/board.module.css +488 -63
  76. package/src/client/index.ts +43 -5
  77. package/src/client/locales.ts +208 -4
  78. package/src/client/native-panel.tsx +18 -2
  79. package/src/client/seats.tsx +58 -0
  80. package/src/client/service.ts +94 -0
  81. package/src/client/settings-card.module.css +31 -5
  82. package/src/client/settings-form.ts +27 -1
  83. package/src/core/controller.ts +94 -3
  84. package/src/core/extension.ts +376 -0
  85. package/src/core/freeze-snapshot.ts +2 -2
  86. package/src/core/handover.ts +16 -1
  87. package/src/core/issue-form.ts +19 -0
  88. package/src/core/store.ts +16 -0
  89. package/src/core/tasks.ts +100 -6
  90. package/src/core/use-cases/task-parent.ts +3 -2
  91. package/src/core/use-cases/task-tag.ts +110 -0
  92. package/src/core/use-cases/task-update.ts +10 -4
  93. package/src/core/verification.ts +826 -0
  94. package/src/core/workspace-target.ts +59 -0
  95. package/src/host/agent-tools.ts +20 -18
  96. package/src/host/extension-registry.ts +509 -0
  97. package/src/host/llm-dispatch.ts +87 -0
  98. package/src/host/verification-gate.ts +331 -0
  99. package/src/host/verification-runner.ts +535 -0
  100. package/src/host/workspace-evidence.ts +176 -0
  101. package/src/host-ai.ts +15 -9
  102. package/src/host-ledger.ts +143 -32
  103. package/src/host-routes.ts +15 -2
  104. package/src/host-runner.ts +60 -11
  105. package/src/host-service.ts +253 -10
  106. package/src/index.ts +204 -16
  107. package/src/protocol.ts +102 -8
  108. package/src/tool-surface.ts +95 -0
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Host-observed workspace changes, as acceptance evidence.
3
+ *
4
+ * The judge must not take the agent's word for what changed on disk: a patch
5
+ * that was never applied, or a file edited again afterwards, looks identical to
6
+ * a real edit when only the tool-call arguments are read. The DSH host records
7
+ * what a turn actually changed and serves it through the optional
8
+ * @@workspaceChanges@@ service, so that observation is offered next to the
9
+ * trajectory instead of being trusted from the transcript.
10
+ *
11
+ * The service is described STRUCTURALLY and resolved optionally: a deployment
12
+ * that serves none (or a cohort whose shape differs) degrades to "no extra
13
+ * evidence" and never breaks an acceptance that has already spent judge budget.
14
+ */
15
+ import { createHash } from 'node:crypto'
16
+
17
+ /** The subset of the host change service this module drives. */
18
+ export interface WorkspaceChangeSource {
19
+ summary(sessionId: string, seq: number): unknown
20
+ diff(sessionId: string, seq: number, index: number, signal: AbortSignal): Promise<unknown>
21
+ }
22
+
23
+ /** One session event, as the host log carries it. */
24
+ interface RawEvent {
25
+ type?: unknown
26
+ seq?: unknown
27
+ }
28
+
29
+ /** How many changed files one evidence block renders. */
30
+ export const MAX_WORKSPACE_FILES = 8
31
+ /** Character ceiling for the whole rendered block. */
32
+ export const WORKSPACE_EVIDENCE_MAX_CHARS = 24_000
33
+ /** Character ceiling for one file's rendered comparison. */
34
+ export const WORKSPACE_EVIDENCE_MAX_FILE_CHARS = 6_000
35
+
36
+ /**
37
+ * Resolve the optional host change service from a context-like object.
38
+ * @param ctx - anything exposing @@get(name)@@.
39
+ * @returns the service, or undefined when this deployment serves none.
40
+ */
41
+ export function probeWorkspaceChanges(ctx: { get(name: string): unknown }): WorkspaceChangeSource | undefined {
42
+ let service: unknown
43
+ try {
44
+ service = ctx.get('workspaceChanges')
45
+ } catch {
46
+ return undefined
47
+ }
48
+ if (typeof service !== 'object' || service === null) return undefined
49
+ const candidate = service as { summary?: unknown; diff?: unknown }
50
+ if (typeof candidate.summary !== 'function' || typeof candidate.diff !== 'function') return undefined
51
+ return service as WorkspaceChangeSource
52
+ }
53
+
54
+ /** Read a finite number, else 0. */
55
+ function count(value: unknown): number {
56
+ return typeof value === 'number' && Number.isFinite(value) ? value : 0
57
+ }
58
+
59
+ /** Read a string, else ''. */
60
+ function text(value: unknown): string {
61
+ return typeof value === 'string' ? value : ''
62
+ }
63
+
64
+ /** Bound one text to a character budget, keeping the head. */
65
+ function bound(value: string, maxChars: number): string {
66
+ if (value.length <= maxChars) return value
67
+ const notice = '\n[Truncated ' + (value.length - maxChars) + ' characters]'
68
+ if (notice.length >= maxChars) return value.slice(0, maxChars)
69
+ return value.slice(0, maxChars - notice.length) + notice
70
+ }
71
+
72
+ /** The sequence of the newest change event inside the reviewed window. */
73
+ export function latestChangeEvent(events: readonly RawEvent[]): { seq: number; turn?: number } | undefined {
74
+ let latest: { seq: number; turn?: number } | undefined
75
+ for (const event of events) {
76
+ if (event.type !== 'workspace/changes') continue
77
+ if (typeof event.seq !== 'number') continue
78
+ if (latest === undefined || event.seq >= latest.seq) latest = { seq: event.seq }
79
+ }
80
+ return latest
81
+ }
82
+
83
+ /** Render one file's comparison from whatever shape the service answered. */
84
+ function renderDiff(value: unknown, maxChars: number): string {
85
+ if (typeof value !== 'object' || value === null) return '[no comparison available]'
86
+ const diff = value as Record<string, unknown>
87
+ if (diff.binary === true || text(diff.kind) === 'binary') return '[binary file]'
88
+ if (diff.oversized === true) return '[file too large to compare]'
89
+ const patch = text(diff.patch)
90
+ if (patch !== '') return bound(patch, maxChars)
91
+ const hunks = Array.isArray(diff.hunks) ? diff.hunks : []
92
+ const lines: string[] = []
93
+ for (const entry of hunks) {
94
+ if (typeof entry !== 'object' || entry === null) continue
95
+ const hunk = entry as Record<string, unknown>
96
+ lines.push('@@ -' + count(hunk.oldStart) + ',' + count(hunk.oldLines) + ' +' + count(hunk.newStart) + ',' + count(hunk.newLines) + ' @@')
97
+ const body = Array.isArray(hunk.lines) ? hunk.lines : []
98
+ for (const line of body) {
99
+ if (typeof line === 'string') lines.push(line)
100
+ else if (typeof line === 'object' && line !== null) {
101
+ const row = line as Record<string, unknown>
102
+ const marker = text(row.kind) === 'add' ? '+' : text(row.kind) === 'delete' ? '-' : ' '
103
+ lines.push(marker + text(row.text))
104
+ }
105
+ }
106
+ }
107
+ return lines.length === 0 ? '' : bound(lines.join('\n'), maxChars)
108
+ }
109
+
110
+ /**
111
+ * Render the host's own record of what the reviewed window changed.
112
+ *
113
+ * Bounded twice — at most {@link MAX_WORKSPACE_FILES} files, and a shared
114
+ * character budget split across them — so one enormous generated file cannot
115
+ * crowd out every other change. Every failure degrades to less evidence (or an
116
+ * empty block) instead of an error, because the acceptance being prepared must
117
+ * still reach a verdict.
118
+ * @param events - session events already bounded to the execution's window.
119
+ * @param sessionId - the session whose changes these are.
120
+ * @param source - the probed host service.
121
+ * @param options - budget, cancellation and the diagnostic sink.
122
+ * @returns the rendered block plus how many files it shows.
123
+ */
124
+ export async function renderWorkspaceEvidence(
125
+ events: readonly RawEvent[],
126
+ sessionId: string,
127
+ source: WorkspaceChangeSource,
128
+ options: { signal: AbortSignal; warn?: (message: string) => void; maxChars?: number },
129
+ ): Promise<{ text: string; files: number; hash: string }> {
130
+ const warn = options.warn ?? ((): void => {})
131
+ const maxChars = options.maxChars ?? WORKSPACE_EVIDENCE_MAX_CHARS
132
+ try {
133
+ const event = latestChangeEvent(events)
134
+ if (event === undefined) return { text: '', files: 0, hash: '' }
135
+ const summary = source.summary(sessionId, event.seq)
136
+ if (typeof summary !== 'object' || summary === null) return { text: '', files: 0, hash: '' }
137
+ const view = summary as Record<string, unknown>
138
+ const listed = (Array.isArray(view.files) ? view.files : [])
139
+ .map((file, index) => ({ file, index }))
140
+ .filter((entry): entry is { file: Record<string, unknown>, index: number } => typeof entry.file === 'object' && entry.file !== null)
141
+ if (listed.length === 0) return { text: '', files: 0, hash: '' }
142
+ const shown = listed.slice(0, MAX_WORKSPACE_FILES)
143
+ const added = count(view.added)
144
+ const deleted = count(view.deleted)
145
+ const header = 'Host-recorded workspace changes'
146
+ + (typeof view.turn === 'number' ? ' for turn ' + view.turn : '')
147
+ + ': ' + listed.length + ' changed file(s)'
148
+ + (added > 0 || deleted > 0 ? ' (+' + added + '/-' + deleted + ' lines)' : '')
149
+ + (listed.length > shown.length ? '; showing the first ' + shown.length : '')
150
+ + '.\nObserved by the DSH host from the workspace itself, not reported by the agent.'
151
+ const perFile = Math.max(1, Math.min(WORKSPACE_EVIDENCE_MAX_FILE_CHARS, Math.floor((maxChars - header.length) / shown.length) - 40))
152
+ const sections: string[] = []
153
+ for (const entry of shown) {
154
+ const path = text(entry.file.path) !== '' ? text(entry.file.path) : text(entry.file.display) !== '' ? text(entry.file.display) : 'file'
155
+ const fileAdded = count(entry.file.added)
156
+ const fileDeleted = count(entry.file.deleted)
157
+ let comparison = ''
158
+ try {
159
+ comparison = renderDiff(await source.diff(sessionId, event.seq, entry.index, options.signal), perFile)
160
+ } catch (error) {
161
+ comparison = '[comparison unavailable]'
162
+ warn('task-board workspace evidence unavailable for one file: ' + (error instanceof Error ? error.message : String(error)))
163
+ }
164
+ sections.push('- ' + path + (fileAdded > 0 || fileDeleted > 0 ? ' (+' + fileAdded + '/-' + fileDeleted + ')' : '') + '\n' + comparison)
165
+ }
166
+ const rendered = bound([header, ...sections].join('\n'), maxChars)
167
+ return {
168
+ text: rendered,
169
+ files: shown.length,
170
+ hash: createHash('sha256').update(rendered).digest('hex'),
171
+ }
172
+ } catch (error) {
173
+ warn('task-board workspace evidence unavailable: ' + (error instanceof Error ? error.message : String(error)))
174
+ return { text: '', files: 0, hash: '' }
175
+ }
176
+ }
package/src/host-ai.ts CHANGED
@@ -9,7 +9,8 @@
9
9
  * stripped, the first JSON object wins, and an unusable reply falls back to
10
10
  * the user's own words instead of an empty form.
11
11
  */
12
- import { createUserMessage, type GenerateOptions, type LlmRuntime } from '@deepseek-ai/dsh-llm'
12
+ import { createUserMessage, type LlmRuntime } from '@deepseek-ai/dsh-llm'
13
+ import { openOneShotStream } from './host/llm-dispatch.ts'
13
14
  import type { TaskBoardParseDraft, TaskBoardParseRequest } from './protocol.ts'
14
15
 
15
16
  /** How long one parse may take before the Host gives up on the model. */
@@ -127,15 +128,20 @@ export async function parseTaskDraft(
127
128
  const abortFromCaller = (): void => { timeout.abort() }
128
129
  signal?.addEventListener('abort', abortFromCaller, { once: true })
129
130
  try {
130
- const options: GenerateOptions = {
131
- provider: route.provider,
132
- model: route.model,
133
- system: SYSTEM_PROMPT,
134
- messages: [createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'user' } })],
135
- signal: timeout.signal,
136
- }
131
+ // Same registration-bound dispatch as the acceptance judge: the public
132
+ // `llm.stream()` is a mutable instance property a third-party provider
133
+ // plugin may replace with its `llm/stream` listener signature.
134
+ const stream = await openOneShotStream(
135
+ llm,
136
+ { provider: route.provider, model: route.model },
137
+ {
138
+ system: SYSTEM_PROMPT,
139
+ messages: [createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'user' } })],
140
+ signal: timeout.signal,
141
+ },
142
+ )
137
143
  let reply = ''
138
- for await (const chunk of llm.stream(options)) {
144
+ for await (const chunk of stream) {
139
145
  if (chunk.type === 'text-delta') reply += chunk.text
140
146
  }
141
147
  return draftFromReply(reply, text)
@@ -5,7 +5,7 @@ import { dirname, join } from 'node:path'
5
5
  import { dshHome } from './dsh-home.ts'
6
6
  import { isValidCron, isValidTimeZone, nextRunAtMs, resolveHostTimeZone } from './core/schedule.ts'
7
7
  import { isTaskRecord, parseLedger } from './core/store.ts'
8
- import { canMoveManually, retainRecentExecutions, settleExecution, startExecution, withStatus, type ExecutionOutcome, type ExecutionRecord, type TaskRecord } from './core/tasks.ts'
8
+ import { canMoveManually, hasOpenExecution, retainRecentExecutions, settleExecution, startExecution, withStatus, type ExecutionOutcome, type ExecutionRecord, type TaskRecord } from './core/tasks.ts'
9
9
  import {
10
10
  DEFAULT_SUBTASK_DEPTH,
11
11
  cascadeTargets,
@@ -20,9 +20,11 @@ import { applyCreateTask } from './core/use-cases/task-create.ts'
20
20
  import { applyDeleteTask } from './core/use-cases/task-delete.ts'
21
21
  import { applySetSchedule, applyScheduleNextRun } from './core/use-cases/task-schedule.ts'
22
22
  import { applySetParent } from './core/use-cases/task-parent.ts'
23
+ import { applyDeleteTag, applyRenameTag } from './core/use-cases/task-tag.ts'
23
24
  import { applyUpdateTask, canEditTaskContent, hasContentPatch } from './core/use-cases/task-update.ts'
24
- import { TASK_BOARD_LEGACY_SCHEMA_VERSION, TASK_BOARD_OLDER_SCHEMA_VERSION, TASK_BOARD_SCHEMA_VERSION, type TaskBoardAction, type TaskBoardSchedulerSnapshot } from './protocol.ts'
25
- import { DEFAULT_SESSION_PERMISSION, requiresPermissionConfirmation, type TaskPermission } from './core/handover.ts'
25
+ import { TASK_BOARD_MIGRATABLE_SCHEMA_VERSIONS, TASK_BOARD_SCHEMA_VERSION, type TaskBoardAction, type TaskBoardSchedulerSnapshot } from './protocol.ts'
26
+ import type { ExecutionVerification } from './core/verification.ts'
27
+ import { DEFAULT_SESSION_PERMISSION, effectivePermission, permissionCarriedBy, requiresPermissionConfirmation, type TaskPermission } from './core/handover.ts'
26
28
 
27
29
  interface PersistedScheduler extends TaskBoardSchedulerSnapshot {
28
30
  importedSources?: string[]
@@ -57,7 +59,7 @@ function bindingRefusalMessage(
57
59
  surface: 'run' | 'schedule',
58
60
  ): string {
59
61
  if (refusal.kind === 'subtask-pin') {
60
- return `team run cannot honor the permission binding of subtask "${refusal.title}": a teammate runs inside the Lead session; clear that card's permission or run the tree without Agent Team`
62
+ return `team run cannot honor the permission binding of subtask "${refusal.title}": a teammate runs inside the Lead session and inherits its permission, which that pin exceeds; clear the card's permission or run the tree without Agent Team`
61
63
  }
62
64
  if (surface === 'schedule') return `task "${refusal.title}" has an unconfirmed above-default permission`
63
65
  if (refusal.kind === 'root') {
@@ -127,10 +129,6 @@ function cloneTasks(tasks: readonly TaskRecord[]): TaskRecord[] {
127
129
  return JSON.parse(JSON.stringify(tasks)) as TaskRecord[]
128
130
  }
129
131
 
130
- function hasOpenExecution(task: TaskRecord): boolean {
131
- return task.executions.some(execution => execution.endedAt === undefined)
132
- }
133
-
134
132
  /**
135
133
  * Process states that are dead but still occupy the PID table: `Z` (zombie)
136
134
  * and `X` (dead, being reaped). `process.kill(pid, 0)` reports such PIDs as
@@ -341,7 +339,17 @@ function mergeTask(a: TaskRecord, b: TaskRecord): TaskRecord {
341
339
  const previous = byId.get(entry.id)
342
340
  byId.set(entry.id, previous === undefined ? entry : betterExecution(previous, entry))
343
341
  }
344
- const executions = [...byId.values()].sort((x, y) => x.startedAt - y.startedAt)
342
+ // Acceptance evidence is the one field "betterExecution" cannot choose: a
343
+ // browser half that also wrote this execution carries no attempts, and taking
344
+ // its copy would erase the Host's verdict. The copy with the most recorded
345
+ // attempts wins, and one execution's verdict is never copied onto another.
346
+ const executions = [...byId.values()].sort((x, y) => x.startedAt - y.startedAt).map(entry => {
347
+ if (entry.verification !== undefined) return entry
348
+ const richer = [...a.executions, ...b.executions]
349
+ .filter(other => other.id === entry.id && other.verification !== undefined)
350
+ .sort((x, y) => (y.verification?.attempts.length ?? 0) - (x.verification?.attempts.length ?? 0))[0]
351
+ return richer?.verification === undefined ? entry : { ...entry, verification: richer.verification }
352
+ })
345
353
  return { ...newer, executions: retainRecentExecutions(executions) }
346
354
  }
347
355
 
@@ -416,8 +424,18 @@ export class HostTaskLedger {
416
424
  /** Small sidecar for the 30 s scheduler heartbeat (lastTickAt only). */
417
425
  readonly schedulerFile: string
418
426
 
419
- /** Session-default permission the confirmation gate compares against. */
420
- readonly sessionDefaultPermission: TaskPermission
427
+ /** Resolves the baseline the confirmation gate compares a binding against. */
428
+ private readonly sessionDefault: () => TaskPermission
429
+
430
+ /**
431
+ * Session-default permission the confirmation gate compares against. Read
432
+ * through the resolver on every use, so a baseline that follows the Host's
433
+ * own default preset tracks a Settings change made while the board runs.
434
+ */
435
+ get sessionDefaultPermission(): TaskPermission {
436
+ return this.sessionDefault()
437
+ }
438
+
421
439
  /**
422
440
  * Deployment subtask depth limit (1..3): the lineage gate every write obeys.
423
441
  * The settings card edits it live, so {@link setMaxSubtaskDepth} mutates it
@@ -425,8 +443,10 @@ export class HostTaskLedger {
425
443
  */
426
444
  private depthLimit: number
427
445
 
428
- constructor(dir: string = join(dshHome(), 'task-board'), private readonly now: () => number = Date.now, options: { sessionDefaultPermission?: TaskPermission; maxSubtaskDepth?: number } = {}) {
429
- this.sessionDefaultPermission = options.sessionDefaultPermission ?? DEFAULT_SESSION_PERMISSION
446
+ constructor(dir: string = join(dshHome(), 'task-board'), private readonly now: () => number = Date.now, options: { sessionDefaultPermission?: TaskPermission | (() => TaskPermission); maxSubtaskDepth?: number } = {}) {
447
+ // Resolved before the load/repair passes below: they can evaluate the gate.
448
+ const baseline = options.sessionDefaultPermission
449
+ this.sessionDefault = typeof baseline === 'function' ? baseline : () => baseline ?? DEFAULT_SESSION_PERMISSION
430
450
  this.depthLimit = normalizeSubtaskDepth(options.maxSubtaskDepth ?? DEFAULT_SUBTASK_DEPTH)
431
451
  mkdirSync(dir, { recursive: true })
432
452
  this.file = join(dir, 'ledger-v2.json')
@@ -644,7 +664,7 @@ export class HostTaskLedger {
644
664
  rollForward()
645
665
  return []
646
666
  }
647
- if (task.status === 'running' || hasOpenExecution(task)) {
667
+ if (hasOpenExecution(task)) {
648
668
  rollForward()
649
669
  return []
650
670
  }
@@ -736,6 +756,24 @@ export class HostTaskLedger {
736
756
  this.commit()
737
757
  }
738
758
 
759
+ allTasks(): readonly TaskRecord[] {
760
+ return this.document.tasks
761
+ }
762
+
763
+ getTask(id: string): TaskRecord | undefined {
764
+ return this.document.tasks.find(item => item.id === id)
765
+ }
766
+
767
+ saveTaskRecord(task: TaskRecord): void {
768
+ const exists = this.document.tasks.some(item => item.id === task.id)
769
+ if (exists) {
770
+ this.document.tasks = this.document.tasks.map(item => item.id === task.id ? task : item)
771
+ } else {
772
+ this.document.tasks = [...this.document.tasks, task]
773
+ }
774
+ this.commit()
775
+ }
776
+
739
777
  private apply(action: TaskBoardAction, initiator?: string): { state: LedgerState; runs?: OpenedRun[] } {
740
778
  const now = this.now()
741
779
  let runs: OpenedRun[] | undefined
@@ -801,7 +839,7 @@ export class HostTaskLedger {
801
839
  case 'delete': {
802
840
  const task = this.document.tasks.find(task => task.id === action.taskId)
803
841
  if (task === undefined) throw new Error('task not found')
804
- if (task.status === 'running' || hasOpenExecution(task)) throw new Error('running task cannot be deleted')
842
+ if (hasOpenExecution(task)) throw new Error('running task cannot be deleted')
805
843
  // Subtasks keep their link: deleting the parent would leave dangling
806
844
  // children, so the user detaches or deletes them explicitly first.
807
845
  if (this.document.tasks.some(item => item.parentId === action.taskId)) {
@@ -820,7 +858,10 @@ export class HostTaskLedger {
820
858
  const task = this.document.tasks.find(item => item.id === action.taskId)
821
859
  if (task === undefined) throw new Error('task not found')
822
860
  if (task.archivedAt !== undefined) throw new Error('archived task is read-only')
823
- if (task.status === 'running' || hasOpenExecution(task)) throw new Error('running task cannot be moved')
861
+ // The lock belongs to an open execution, not to the column: a card
862
+ // parked in 'running' by hand has no session and stays movable, while
863
+ // a card the runner is executing cannot be moved out from under it.
864
+ if (hasOpenExecution(task)) throw new Error('running task cannot be moved')
824
865
  if (!canMoveManually(task.status, action.status)) throw new Error('invalid manual status')
825
866
  this.document.tasks = this.document.tasks.map(item => item.id === action.taskId ? withStatus(item, action.status, now) : item)
826
867
  break
@@ -876,6 +917,18 @@ export class HostTaskLedger {
876
917
  : item)
877
918
  break
878
919
  }
920
+ case 'rename-tag': {
921
+ const result = applyRenameTag(this.document.tasks, action.from, action.to, now)
922
+ if (result.error !== undefined) throw new Error(result.error)
923
+ if (result.changed) this.document.tasks = [...result.tasks]
924
+ break
925
+ }
926
+ case 'delete-tag': {
927
+ const result = applyDeleteTag(this.document.tasks, action.name, now)
928
+ if (result.error !== undefined) throw new Error(result.error)
929
+ if (result.changed) this.document.tasks = [...result.tasks]
930
+ break
931
+ }
879
932
  case 'set-schedule': {
880
933
  const task = this.document.tasks.find(task => task.id === action.taskId)
881
934
  if (task?.archivedAt !== undefined) throw new Error('archived task is read-only')
@@ -888,7 +941,7 @@ export class HostTaskLedger {
888
941
  case 'run': {
889
942
  const task = this.document.tasks.find(item => item.id === action.taskId)
890
943
  if (task?.archivedAt !== undefined) throw new Error('archived task is read-only')
891
- if (task === undefined || task.status === 'running' || hasOpenExecution(task)) throw new Error('task is already running or missing')
944
+ if (task === undefined || hasOpenExecution(task)) throw new Error('task is already running or missing')
892
945
  // The confirmation gate judges the RESOLVED binding: a subtask that
893
946
  // inherits an elevated permission from its parent is exactly as
894
947
  // unconfirmed as the parent would be without its own stamp.
@@ -909,10 +962,12 @@ export class HostTaskLedger {
909
962
  *
910
963
  * A plain cascade launches one session per participant, so every participant
911
964
  * carries its own resolved binding and each one gates the run. A team run
912
- * launches only the Lead session: the Lead's binding gates it, while a
913
- * subtask's OWN above-default pin cannot be applied to a teammate and is
914
- * refused instead of being silently dropped (an inherited binding is the
915
- * Lead's own and stays allowed once the Lead is confirmed).
965
+ * launches only the Lead session: the Lead's binding gates it, and a
966
+ * teammate inherits that session's permission, so a subtask's OWN pin is
967
+ * refused exactly when the Lead session does not already carry it — the
968
+ * teammate could never be given that authority, and dropping the pin
969
+ * silently would misreport the work (an inherited binding is the Lead's own
970
+ * and stays allowed once the Lead is confirmed).
916
971
  * @param root - the task being run.
917
972
  * @returns the first refusal, or undefined when the run may start.
918
973
  */
@@ -923,12 +978,17 @@ export class HostTaskLedger {
923
978
  if (lead !== undefined && requiresPermissionConfirmation(lead, this.sessionDefaultPermission)) {
924
979
  return { kind: 'root', title: lead.title }
925
980
  }
926
- // The raw record carries the subtask's OWN binding: the resolved
927
- // participant above already folded the inherited one into its permission.
981
+ // The Lead session runs at the Lead's own binding, or at the deployment
982
+ // default when it pins none; a teammate inherits that permission and
983
+ // cannot be narrowed below it, so it carries a subtask pin when it is
984
+ // already at least as wide. The raw record carries the subtask's OWN
985
+ // binding: the resolved participant above already folded an inherited one
986
+ // into its permission.
987
+ const leadPermission = effectivePermission(lead ?? root) ?? this.sessionDefaultPermission
928
988
  const pinned = participants.find(participant => {
929
989
  if (participant.id === root.id) return false
930
990
  const raw = this.document.tasks.find(task => task.id === participant.id)
931
- return raw !== undefined && requiresPermissionConfirmation(raw, this.sessionDefaultPermission)
991
+ return raw !== undefined && !permissionCarriedBy(leadPermission, effectivePermission(raw))
932
992
  })
933
993
  return pinned === undefined ? undefined : { kind: 'subtask-pin', title: pinned.title }
934
994
  }
@@ -947,14 +1007,13 @@ export class HostTaskLedger {
947
1007
  return cascadeTargets(this.document.tasks, rootId, this.maxSubtaskDepth)
948
1008
  .map(participant => resolveExecutionTargets(participant, this.document.tasks, this.maxSubtaskDepth))
949
1009
  .filter(participant => participant.archivedAt === undefined
950
- && participant.status !== 'running'
951
1010
  && !hasOpenExecution(participant))
952
1011
  }
953
1012
 
954
1013
  /** Whether a task or any of its subtasks still has an open execution. */
955
1014
  private subtreeHasOpenExecution(id: string): boolean {
956
1015
  return cascadeTargets(this.document.tasks, id, this.maxSubtaskDepth)
957
- .some(task => task.status === 'running' || hasOpenExecution(task))
1016
+ .some(task => hasOpenExecution(task))
958
1017
  }
959
1018
 
960
1019
  /**
@@ -979,7 +1038,7 @@ export class HostTaskLedger {
979
1038
  const runs: OpenedRun[] = []
980
1039
  for (const task of cascadeTargets(before, root.id, this.maxSubtaskDepth)) {
981
1040
  if (task.archivedAt !== undefined) continue
982
- if (task.status === 'running' || hasOpenExecution(task)) continue
1041
+ if (hasOpenExecution(task)) continue
983
1042
  const base = rerun && task.id === root.id ? withStatus(task, 'todo', now) : task
984
1043
  const opened = startExecution(base, now, crypto.randomUUID(), initiator, groupId)
985
1044
  started.set(task.id, opened.task)
@@ -1147,7 +1206,6 @@ export class HostTaskLedger {
1147
1206
  let changed = false
1148
1207
  const interrupted: Array<{ taskId: string; runGroupId: string | undefined }> = []
1149
1208
  this.document.tasks = this.document.tasks.map(task => {
1150
- if (task.status !== 'running') return task
1151
1209
  const execution = task.executions.at(-1)
1152
1210
  if (execution === undefined || execution.endedAt !== undefined || execution.sessionId !== undefined) return task
1153
1211
  changed = true
@@ -1166,7 +1224,7 @@ export class HostTaskLedger {
1166
1224
  }
1167
1225
 
1168
1226
  /**
1169
- * Field-preserving migration of a pre-v4 document. It first proves every task
1227
+ * Field-preserving migration of an older document. It first proves every task
1170
1228
  * row is structurally valid, so a document that would silently drop or coerce
1171
1229
  * rows fails loudly instead (no quarantined-empty restart), and then reuses
1172
1230
  * the current normalization.
@@ -1177,6 +1235,11 @@ export class HostTaskLedger {
1177
1235
  * (the stored `nextRunAt` already encodes the old zone), but the rule stops
1178
1236
  * following a later `TZ` change, which is what made an existing schedule
1179
1237
  * silently move when the Host's zone changed.
1238
+ *
1239
+ * v5 adds the per-execution acceptance block. The migration deliberately does
1240
+ * NOT stamp one: an execution opened before v5 keeps no contract, so it
1241
+ * settles on its own historical verdict and no in-flight run is retroactively
1242
+ * judged by a gate that was never armed for it.
1180
1243
  */
1181
1244
  private migrateLegacyDocument(parsed: ParsedLedgerDocument): LedgerDocument {
1182
1245
  if (!Array.isArray(parsed.tasks) || !parsed.tasks.every(row => isTaskRecord(row))) {
@@ -1194,6 +1257,54 @@ export class HostTaskLedger {
1194
1257
  return this.normalizeDocument({ ...parsed, tasks: rows as TaskRecord[] })
1195
1258
  }
1196
1259
 
1260
+ /**
1261
+ * Find the still-open execution that ran in one session, with its task.
1262
+ *
1263
+ * The completion gate keys on the session id its tool call arrives from: the
1264
+ * execution record is the only object binding a session to a card, and an
1265
+ * already-settled execution is deliberately not returned (a session the board
1266
+ * no longer owns must not be gated).
1267
+ * @param sessionId - the DSH session the tool call runs in.
1268
+ * @returns the open execution and its task, or undefined.
1269
+ */
1270
+ findOpenExecutionBySession(sessionId: string): { task: TaskRecord; execution: ExecutionRecord } | undefined {
1271
+ if (sessionId === '') return undefined
1272
+ for (const task of this.document.tasks) {
1273
+ for (const execution of task.executions) {
1274
+ if (execution.endedAt !== undefined) continue
1275
+ if (execution.sessionId === sessionId) return { task, execution }
1276
+ }
1277
+ }
1278
+ return undefined
1279
+ }
1280
+
1281
+ /**
1282
+ * Replace one execution's acceptance block (creating it when absent).
1283
+ *
1284
+ * A no-op when the serialized value is unchanged, so a coarse caller that
1285
+ * re-writes the same state never bumps the ledger revision. A settled
1286
+ * execution keeps its record: the report of a finished cycle must survive,
1287
+ * and only the board's own settlement moves the card.
1288
+ * @param taskId - the task owning the execution.
1289
+ * @param executionId - the execution to update.
1290
+ * @param verification - the new block.
1291
+ * @returns true when the ledger changed.
1292
+ */
1293
+ setVerification(taskId: string, executionId: string, verification: ExecutionVerification): boolean {
1294
+ const now = this.now()
1295
+ const task = this.document.tasks.find(item => item.id === taskId)
1296
+ const execution = task?.executions.find(entry => entry.id === executionId)
1297
+ if (task === undefined || execution === undefined) return false
1298
+ if (JSON.stringify(execution.verification ?? null) === JSON.stringify(verification)) return false
1299
+ this.document.tasks = this.document.tasks.map(item => item.id !== taskId ? item : {
1300
+ ...item,
1301
+ updatedAt: now,
1302
+ executions: item.executions.map(entry => entry.id === executionId ? { ...entry, verification } : entry),
1303
+ })
1304
+ this.commit()
1305
+ return true
1306
+ }
1307
+
1197
1308
  private load(dir: string): LedgerDocument {
1198
1309
  const existed = existsSync(this.file)
1199
1310
  // schemaVersion stays unknown-typed here: on-disk documents may be v2 or
@@ -1204,8 +1315,8 @@ export class HostTaskLedger {
1204
1315
  } catch (error) {
1205
1316
  return this.recoverCorrupt(dir, existed, error)
1206
1317
  }
1207
- if (parsed.schemaVersion === TASK_BOARD_LEGACY_SCHEMA_VERSION
1208
- || parsed.schemaVersion === TASK_BOARD_OLDER_SCHEMA_VERSION) {
1318
+ if (typeof parsed.schemaVersion === 'number'
1319
+ && TASK_BOARD_MIGRATABLE_SCHEMA_VERSIONS.includes(parsed.schemaVersion)) {
1209
1320
  try {
1210
1321
  return this.migrateLegacyDocument(parsed)
1211
1322
  } catch (error) {
@@ -1426,4 +1537,4 @@ export class HostTaskLedger {
1426
1537
  }
1427
1538
  throw new Error(`task-board ledger lock could not be acquired: ${this.lockFile}`)
1428
1539
  }
1429
- }
1540
+ }
@@ -208,7 +208,7 @@ export function makeTaskBoardRoutes(
208
208
  if (parsed.action.kind !== 'import' && Buffer.byteLength(body.raw) > ACTION_LIMIT) {
209
209
  return writeJson(res, 413, { ok: false, error: 'body-too-large' }, { 'cache-control': 'no-store' })
210
210
  }
211
- writeJson(res, 200, service.apply(parsed.requestId, parsed.action, parsed.initiator), { 'cache-control': 'no-store' })
211
+ writeJson(res, 200, await service.apply(parsed.requestId, parsed.action, parsed.initiator), { 'cache-control': 'no-store' })
212
212
  } catch (error) {
213
213
  const message = error instanceof Error ? error.message : String(error)
214
214
  writeJson(res, message === 'body-too-large' ? 413 : 400, { ok: false, error: message }, { 'cache-control': 'no-store' })
@@ -289,5 +289,18 @@ export function makeTaskBoardRoutes(
289
289
  }
290
290
  },
291
291
  }
292
- return [state, action, events, parse]
292
+ const verification: WebRoute = {
293
+ kind: 'exact',
294
+ path: `${TASK_BOARD_API_PREFIX}/verification`,
295
+ handler: async (req, res): Promise<void> => {
296
+ if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'method-not-allowed' }, { 'cache-control': 'no-store' })
297
+ if (!guard(req, res)) return
298
+ try {
299
+ writeJson(res, 200, await service.verificationOptions(), { 'cache-control': 'no-store' })
300
+ } catch (error) {
301
+ writeJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) }, { 'cache-control': 'no-store' })
302
+ }
303
+ },
304
+ }
305
+ return [state, action, events, parse, verification]
293
306
  }