@linxin666/dsh-client-ui-task-board 0.3.5 → 0.3.9

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 (49) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +9 -4
  3. package/README.zh.md +9 -4
  4. package/lib/client.js +947 -354
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +712 -180
  7. package/lib/types/client/TaskBoardSettingsCard.d.ts +2 -1
  8. package/lib/types/client/board/TaskBoard.d.ts +3 -0
  9. package/lib/types/client/board/TaskForm.d.ts +28 -0
  10. package/lib/types/client/host-api.d.ts +2 -2
  11. package/lib/types/client/index.d.ts +10 -2
  12. package/lib/types/client/locales.d.ts +19 -0
  13. package/lib/types/client/settings-form.d.ts +35 -43
  14. package/lib/types/core/controller.d.ts +11 -2
  15. package/lib/types/core/freeze-snapshot.d.ts +71 -0
  16. package/lib/types/core/handover.d.ts +58 -0
  17. package/lib/types/core/tasks.d.ts +70 -1
  18. package/lib/types/core/use-cases/task-update.d.ts +16 -7
  19. package/lib/types/host-ledger.d.ts +17 -2
  20. package/lib/types/host-runner.d.ts +44 -17
  21. package/lib/types/host-service.d.ts +8 -5
  22. package/lib/types/index.d.ts +9 -1
  23. package/lib/types/protocol.d.ts +15 -2
  24. package/package.json +26 -17
  25. package/src/client/TaskBoardSettingsCard.tsx +2 -1
  26. package/src/client/board/EditTaskModal.tsx +19 -55
  27. package/src/client/board/NewTaskModal.tsx +59 -42
  28. package/src/client/board/TaskBoard.tsx +5 -3
  29. package/src/client/board/TaskCard.tsx +3 -0
  30. package/src/client/board/TaskDetail.tsx +54 -0
  31. package/src/client/board/TaskForm.tsx +111 -0
  32. package/src/client/board-mount.tsx +7 -1
  33. package/src/client/host-api.ts +5 -5
  34. package/src/client/index.ts +67 -7
  35. package/src/client/locales.ts +40 -2
  36. package/src/client/settings-form.ts +78 -92
  37. package/src/core/controller.ts +36 -16
  38. package/src/core/freeze-snapshot.ts +213 -0
  39. package/src/core/handover.ts +117 -0
  40. package/src/core/store.ts +48 -1
  41. package/src/core/tasks.ts +87 -0
  42. package/src/core/use-cases/task-update.ts +30 -10
  43. package/src/dsh-home.ts +1 -1
  44. package/src/host-ledger.ts +137 -59
  45. package/src/host-routes.ts +1 -1
  46. package/src/host-runner.ts +223 -83
  47. package/src/host-service.ts +14 -10
  48. package/src/index.ts +24 -10
  49. package/src/protocol.ts +98 -14
@@ -23,7 +23,11 @@ import type { TaskBoardAction, TaskBoardEventPayload, TaskBoardSnapshot } from '
23
23
  export interface TaskBoardTransport {
24
24
  bootstrap(legacy: readonly TaskRecord[]): Promise<TaskBoardSnapshot>
25
25
  state(): Promise<TaskBoardSnapshot>
26
- action(action: TaskBoardAction): Promise<TaskBoardSnapshot>
26
+ /**
27
+ * Submit one action; the optional initiator is the current DSH session id
28
+ * (issue #6 audit origin), asserted by the client and recorded by the Host.
29
+ */
30
+ action(action: TaskBoardAction, initiator?: string): Promise<TaskBoardSnapshot>
27
31
  subscribe(listener: (event?: TaskBoardEventPayload) => void): () => void
28
32
  }
29
33
 
@@ -37,6 +41,10 @@ export interface SessionsControllerFace {
37
41
  open(id: string): void
38
42
  }
39
43
 
44
+ function currentOf(sessions: SessionsControllerFace | undefined): string | undefined {
45
+ return sessions?.list.getSnapshot().current
46
+ }
47
+
40
48
  /** Controller dependencies (all swappable in tests). */
41
49
  export interface ControllerDeps {
42
50
  store: TaskStore
@@ -83,7 +91,7 @@ export interface ControllerSnapshot {
83
91
  executionOptions: ExecutionOptionsSnapshot
84
92
  pendingTaskIds: readonly string[]
85
93
  transportError?: string
86
- host?: Pick<TaskBoardSnapshot, 'revision' | 'scheduler' | 'power'>
94
+ host?: Pick<TaskBoardSnapshot, 'revision' | 'scheduler' | 'power' | 'sessionDefaultPermission'>
87
95
  }
88
96
 
89
97
  /** The selected task (resolved from the ledger), or undefined. */
@@ -129,7 +137,7 @@ export class BoardController {
129
137
  private readonly pendingTaskIds = new Set<string>()
130
138
  private readonly taskQueues = new Map<string, Promise<void>>()
131
139
  private transportError: string | undefined
132
- private hostState: Pick<TaskBoardSnapshot, 'revision' | 'scheduler' | 'power'> | undefined
140
+ private hostState: Pick<TaskBoardSnapshot, 'revision' | 'scheduler' | 'power' | 'sessionDefaultPermission'> | undefined
133
141
  private remoteSubscribed = false
134
142
  private remoteInitialization: Promise<boolean> | undefined
135
143
 
@@ -200,17 +208,11 @@ export class BoardController {
200
208
 
201
209
  openBoard(): void {
202
210
  if (this.boardOpen) return
203
- // The board stays open until the user explicitly closes it: by clicking
204
- // the sidebar entry again, a sidebar session/workspace row, or a board
205
- // action (openSession / close). Session-list churn (background
206
- // navigation, the Host runner selecting a fresh execution session,
207
- // settlement, other plugins) never evicts the board.
208
211
  this.boardOpen = true
209
212
  this.notify()
210
213
  }
211
214
 
212
215
  closeBoard(): void {
213
- if (!this.boardOpen) return
214
216
  this.boardOpen = false
215
217
  this.notify()
216
218
  }
@@ -396,6 +398,7 @@ export class BoardController {
396
398
  * @param sessionId - the execution session to open.
397
399
  */
398
400
  openSession(sessionId: string): void {
401
+ this.closeBoard()
399
402
  this.deps.sessions.open(sessionId)
400
403
  }
401
404
 
@@ -411,7 +414,24 @@ export class BoardController {
411
414
  const task = this.tasks.find(candidate => candidate.id === id)
412
415
  if (task === undefined || task.archivedAt !== undefined || task.status === 'running') return false
413
416
  if (this.deps.transport === undefined) return false
414
- return await this.commitRemote({ kind: 'run', taskId: id }, id)
417
+ return await this.commitRemote({ kind: 'run', taskId: id }, id, currentOf(this.deps.sessions))
418
+ }
419
+
420
+ /**
421
+ * Confirm a card's above-default permission binding through the Host
422
+ * (resolves the pending-confirmation transaction; no-op otherwise).
423
+ */
424
+ async confirmPermission(id: string): Promise<boolean> {
425
+ const task = this.tasks.find(candidate => candidate.id === id)
426
+ if (task === undefined) return false
427
+ if (this.deps.transport === undefined) {
428
+ this.tasks = this.tasks.map(candidate => candidate.id === id
429
+ ? { ...candidate, permissionConfirmedAt: this.now(), updatedAt: this.now() }
430
+ : candidate)
431
+ this.persistAndNotify()
432
+ return true
433
+ }
434
+ return await this.commitRemote({ kind: 'confirm-permission', taskId: id }, id)
415
435
  }
416
436
 
417
437
  /** Re-run a settled task through the Host (the Host replans and executes). */
@@ -419,7 +439,7 @@ export class BoardController {
419
439
  const task = this.tasks.find(candidate => candidate.id === id)
420
440
  if (task === undefined || task.archivedAt !== undefined) return
421
441
  if (this.deps.transport === undefined) return
422
- await this.commitRemote({ kind: 'rerun', taskId: id }, id)
442
+ await this.commitRemote({ kind: 'rerun', taskId: id }, id, currentOf(this.deps.sessions))
423
443
  }
424
444
 
425
445
  // --- internals ---------------------------------------------------------------
@@ -443,12 +463,12 @@ export class BoardController {
443
463
  this.notify()
444
464
  }
445
465
 
446
- private async commitRemote(action: TaskBoardAction, taskId?: string): Promise<boolean> {
466
+ private async commitRemote(action: TaskBoardAction, taskId?: string, initiator?: string): Promise<boolean> {
447
467
  const transport = this.deps.transport
448
468
  if (transport === undefined) return true
449
- if (taskId === undefined) return await this.performRemote(action)
469
+ if (taskId === undefined) return await this.performRemote(action, initiator)
450
470
  const previous = this.taskQueues.get(taskId) ?? Promise.resolve()
451
- const operation = previous.catch(() => {}).then(async () => await this.performRemote(action))
471
+ const operation = previous.catch(() => {}).then(async () => await this.performRemote(action, initiator))
452
472
  const tail = operation.then(() => {}, () => {})
453
473
  this.taskQueues.set(taskId, tail)
454
474
  this.pendingTaskIds.add(taskId)
@@ -464,13 +484,13 @@ export class BoardController {
464
484
  }
465
485
  }
466
486
 
467
- private async performRemote(action: TaskBoardAction): Promise<boolean> {
487
+ private async performRemote(action: TaskBoardAction, initiator?: string): Promise<boolean> {
468
488
  const transport = this.deps.transport
469
489
  if (transport === undefined) return true
470
490
  this.transportError = undefined
471
491
  this.notify()
472
492
  try {
473
- const accepted = this.acceptRemote(await transport.action(action))
493
+ const accepted = this.acceptRemote(await transport.action(action, initiator))
474
494
  return accepted || await this.refreshRemote()
475
495
  } catch (error) {
476
496
  await this.refreshRemote(messageOf(error))
@@ -0,0 +1,213 @@
1
+ /**
2
+ * Freeze-request parser and security gate: parses the system-prompt-agreed
3
+ * freeze format into a structured context snapshot (goal / progress / next),
4
+ * redacts sensitive patterns to markers, rejects slash-prefixed DSH command
5
+ * lines, and enforces the 8 KiB per-field byte limit.
6
+ * Pure functions only: no session, network, or filesystem access.
7
+ */
8
+
9
+ /** Hard per-field limit: 8 KiB measured in UTF-8 bytes. */
10
+ export const FREEZE_FIELD_BYTE_LIMIT = 8 * 1024
11
+
12
+ /** Marker replacing every sensitive match. */
13
+ export const REDACTED_MARKER = '[REDACTED]'
14
+
15
+ /** Structured context snapshot carried by a continuation card. */
16
+ export interface FreezeSnapshot {
17
+ goal: string
18
+ progress: string
19
+ next: string
20
+ }
21
+
22
+ /** Non-fatal notices produced by the security gate. */
23
+ export type FreezeWarning = 'redacted'
24
+
25
+ export type FreezeResult =
26
+ | { ok: true; snapshot: FreezeSnapshot; warnings: FreezeWarning[] }
27
+ | { ok: false; error: { code: string; message: string } }
28
+
29
+ const BEGIN = '<<<FREEZE'
30
+ const END = '>>>FREEZE'
31
+
32
+ const SECTION_KEYS: ReadonlyMap<string, SectionKey> = new Map([
33
+ ['目标:', 'goal'],
34
+ ['进度:', 'progress'],
35
+ ['下一步:', 'next'],
36
+ ])
37
+
38
+ type SectionKey = 'goal' | 'progress' | 'next'
39
+
40
+ const SECTION_ORDER: readonly SectionKey[] = ['goal', 'progress', 'next']
41
+
42
+ const SECTION_NAMES: Record<SectionKey, string> = {
43
+ goal: '目标',
44
+ progress: '进度',
45
+ next: '下一步',
46
+ }
47
+
48
+ /**
49
+ * Sensitive patterns, each matched globally over every field body:
50
+ * PEM private key blocks (whole block collapses to one marker), Bearer
51
+ * credentials, OpenAI sk-, GitHub ghp_, GitLab glpat-, Slack xox* tokens,
52
+ * and AWS access key ids.
53
+ */
54
+ const SENSITIVE_PATTERNS: RegExp[] = [
55
+ /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g,
56
+ /Bearer\s+[A-Za-z0-9._~+\/=:-]{8,}/gi,
57
+ /sk-[A-Za-z0-9_-]{8,}/g,
58
+ /ghp_[A-Za-z0-9]{20,}/g,
59
+ /glpat-[A-Za-z0-9_-]{15,}/g,
60
+ /xox[bpars]-[A-Za-z0-9-]{10,}/g,
61
+ /AKIA[0-9A-Z]{16}/g,
62
+ ]
63
+
64
+ function fail(code: string, message: string): FreezeResult {
65
+ return { ok: false, error: { code, message } }
66
+ }
67
+
68
+ function utf8Bytes(text: string): number {
69
+ return new TextEncoder().encode(text).length
70
+ }
71
+
72
+ /** Redact sensitive patterns to the marker; reports whether any hit occurred. */
73
+ export function redactSensitive(text: string): { text: string; redacted: boolean } {
74
+ let out = text
75
+ let redacted = false
76
+ for (const pattern of SENSITIVE_PATTERNS) {
77
+ if (pattern.test(out)) {
78
+ redacted = true
79
+ out = out.replace(pattern, REDACTED_MARKER)
80
+ }
81
+ pattern.lastIndex = 0
82
+ }
83
+ return { text: out, redacted }
84
+ }
85
+
86
+ /**
87
+ * True when any line of the text starts with "/" (a DSH command line).
88
+ * Leading horizontal whitespace before the slash counts too: a frozen
89
+ * body line like ` /kill` is still a command, not prose.
90
+ */
91
+ export function hasSlashCommandLines(text: string): boolean {
92
+ return text.split(/\r?\n/).some(line => /^[ \t]+\//.test(line) || line.startsWith('/'))
93
+ }
94
+
95
+ /**
96
+ * Sanitize a structured freeze snapshot (the create/update action payload):
97
+ * shape check, slash-command taint rejection, sensitive redaction, and the
98
+ * per-field byte cap - the same gate parseFreezeRequest applies to
99
+ * free-text freeze requests, exposed for the action data plane (issue #4).
100
+ * @param value - the freeze object carried by an action or read back from disk.
101
+ * @param extraKeys - keys allowed alongside goal/progress/next (e.g. the
102
+ * protocol redacted flag, the ledger frozenAt stamp) and preserved verbatim
103
+ * when present; their validation stays with the caller.
104
+ */
105
+ export function sanitizeFreezeSnapshot(
106
+ value: unknown,
107
+ extraKeys: readonly string[] = [],
108
+ ): { ok: true; snapshot: FreezeSnapshot; redacted: boolean; extras: Record<string, unknown> } | { ok: false; error: { code: string; message: string } } {
109
+ const bad = (code: string, message: string) => ({ ok: false as const, error: { code, message } })
110
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
111
+ return bad('invalid-freeze', '冻结快照必须是 goal/progress/next 字符串对象')
112
+ }
113
+ const record = value as Record<string, unknown>
114
+ const allowed = ['goal', 'progress', 'next', ...extraKeys]
115
+ if (!Object.keys(record).every(key => allowed.includes(key))) {
116
+ return bad('invalid-freeze', '冻结快照包含未知字段')
117
+ }
118
+ for (const key of SECTION_ORDER) {
119
+ if (typeof record[key] !== 'string') {
120
+ return bad('invalid-freeze', '冻结快照字段 ' + SECTION_NAMES[key] + ' 必须是字符串')
121
+ }
122
+ }
123
+ for (const key of SECTION_ORDER) {
124
+ if (hasSlashCommandLines(record[key] as string)) {
125
+ return bad('dsh-command-line', '冻结文本的' + SECTION_NAMES[key] + '包含以 / 开头的命令行,整体拒绝')
126
+ }
127
+ }
128
+ let redacted = false
129
+ const snapshot: Record<SectionKey, string> = { goal: '', progress: '', next: '' }
130
+ for (const key of SECTION_ORDER) {
131
+ const result = redactSensitive(record[key] as string)
132
+ snapshot[key] = result.text
133
+ redacted = redacted || result.redacted
134
+ }
135
+ for (const key of SECTION_ORDER) {
136
+ if (utf8Bytes(snapshot[key]) > FREEZE_FIELD_BYTE_LIMIT) {
137
+ return bad('field-too-large', '冻结快照字段 ' + SECTION_NAMES[key] + ' 超过 8 KiB 上限')
138
+ }
139
+ }
140
+ const extras: Record<string, unknown> = {}
141
+ for (const key of extraKeys) if (key in record) extras[key] = record[key]
142
+ return { ok: true, snapshot: { goal: snapshot.goal, progress: snapshot.progress, next: snapshot.next }, redacted, extras }
143
+ }
144
+
145
+ /**
146
+ * Parse the freeze-request format: a <<<FREEZE ... >>>FREEZE block whose body
147
+ * is 目标: / 进度: / 下一步: section headers, each followed by body lines.
148
+ * The gate applies before returning: slash-command taint rejects the whole
149
+ * request, sensitive patterns are redacted to markers, and each field is
150
+ * capped at FREEZE_FIELD_BYTE_LIMIT UTF-8 bytes.
151
+ */
152
+ export function parseFreezeRequest(input: string): FreezeResult {
153
+ const beginIndex = input.indexOf(BEGIN)
154
+ if (beginIndex === -1) {
155
+ return fail('missing-block', '未找到冻结请求块(需要 <<<FREEZE ... >>>FREEZE)')
156
+ }
157
+ const bodyStart = beginIndex + BEGIN.length
158
+ const endIndex = input.indexOf(END, bodyStart)
159
+ if (endIndex === -1) {
160
+ return fail('unterminated-block', '冻结请求块未闭合(缺少 >>>FREEZE)')
161
+ }
162
+ const block = input.slice(bodyStart, endIndex)
163
+
164
+ const raw = new Map<SectionKey, string[]>()
165
+ let current: SectionKey | undefined
166
+ for (const line of block.split(/\r?\n/)) {
167
+ const key = SECTION_KEYS.get(line)
168
+ if (key !== undefined) {
169
+ if (raw.has(key)) {
170
+ return fail('duplicate-section', `重复的段落标题:${SECTION_NAMES[key]}`)
171
+ }
172
+ raw.set(key, [])
173
+ current = key
174
+ } else if (current !== undefined) {
175
+ raw.get(current)!.push(line)
176
+ }
177
+ }
178
+
179
+ const snapshot: Record<SectionKey, string> = { goal: '', progress: '', next: '' }
180
+ for (const key of SECTION_ORDER) {
181
+ if (!raw.has(key)) {
182
+ return fail('missing-section', `冻结请求缺少段落:${SECTION_NAMES[key]}`)
183
+ }
184
+ const lines = raw.get(key)!
185
+ while (lines.length > 0 && lines[lines.length - 1].trim() === '') lines.pop()
186
+ snapshot[key] = lines.join('\n')
187
+ }
188
+
189
+ for (const key of SECTION_ORDER) {
190
+ if (hasSlashCommandLines(snapshot[key])) {
191
+ return fail('dsh-command-line', `冻结文本的${SECTION_NAMES[key]}包含以 / 开头的命令行,整体拒绝`)
192
+ }
193
+ }
194
+
195
+ let redacted = false
196
+ for (const key of SECTION_ORDER) {
197
+ const result = redactSensitive(snapshot[key])
198
+ snapshot[key] = result.text
199
+ redacted = redacted || result.redacted
200
+ }
201
+
202
+ for (const key of SECTION_ORDER) {
203
+ if (utf8Bytes(snapshot[key]) > FREEZE_FIELD_BYTE_LIMIT) {
204
+ return fail('field-too-large', `冻结快照字段 ${SECTION_NAMES[key]} 超过 8 KiB 上限`)
205
+ }
206
+ }
207
+
208
+ return {
209
+ ok: true,
210
+ snapshot: { goal: snapshot.goal, progress: snapshot.progress, next: snapshot.next },
211
+ warnings: redacted ? ['redacted'] : [],
212
+ }
213
+ }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Handover bundle (issue #5, ADR 0001): the resource reference set a
3
+ * continuation card carries — the pinned execution triplet (workspace /
4
+ * agent preset / permission) plus doc/script references — so a picking-up
5
+ * session can start with zero context.
6
+ *
7
+ * Security gate (adversarial scenario b): a bundle whose effective
8
+ * permission is HIGHER than the session default is an unconfirmed binding.
9
+ * It enters the pending-confirmation state (the card stores the binding
10
+ * without a permissionConfirmedAt stamp); manual execution refuses, cron
11
+ * refuses to schedule, and the human confirm-permission action resolves the
12
+ * transaction by stamping the confirmation. Any later permission or
13
+ * handover change re-arms the gate (the confirmation binds the exact
14
+ * permission value, blocking confirm-then-swap escalation).
15
+ */
16
+ import { isTaskPermission, type TaskPermission, type TaskRecord } from './tasks.ts'
17
+
18
+ export type { TaskPermission }
19
+
20
+ /** Wire shape of a handover bundle (before bundledAt is stamped). */
21
+ export interface TaskHandoverInput {
22
+ workspaceId?: string
23
+ mode?: string
24
+ permission?: TaskPermission
25
+ references: string[]
26
+ }
27
+
28
+ /** Persisted handover bundle on a task record. */
29
+ export interface TaskHandover extends TaskHandoverInput {
30
+ /** When the bundle was attached/replaced (ms epoch, stamped by the use case). */
31
+ bundledAt: number
32
+ }
33
+
34
+ /** Most references a bundle may carry. */
35
+ export const HANDOVER_MAX_REFERENCES = 32
36
+ /** Per-reference UTF-8 byte cap. */
37
+ export const HANDOVER_MAX_REFERENCE_BYTES = 512
38
+ /** Total references UTF-8 byte cap. */
39
+ export const HANDOVER_MAX_TOTAL_BYTES = 8 * 1024
40
+ /** Per target-id (workspace/preset) byte cap. */
41
+ export const HANDOVER_MAX_TARGET_BYTES = 256
42
+
43
+ /** The board's notion of the deployment session-default permission (fail-safe default). */
44
+ export const DEFAULT_SESSION_PERMISSION: TaskPermission = 'read-only'
45
+
46
+ /** Permission elevation rank (higher = more authority). */
47
+ export const PERMISSION_RANK: ReadonlyMap<TaskPermission, number> = new Map([
48
+ ['read-only', 0],
49
+ ['workspace-write', 1],
50
+ ['danger-full-access', 2],
51
+ ])
52
+
53
+ function byteLength(value: string): number {
54
+ return new TextEncoder().encode(value).length
55
+ }
56
+
57
+ /**
58
+ * Gate a handover bundle from the wire or disk: exact keys, string targets
59
+ * under the byte cap, a known permission, and a bounded string reference
60
+ * list. Returns the sanitized bundle, or undefined when rejected.
61
+ */
62
+ export function sanitizeHandover(value: unknown): TaskHandoverInput | undefined {
63
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined
64
+ const bundle = value as Record<string, unknown>
65
+ const keys = Object.keys(bundle)
66
+ if (!keys.every(key => ['workspaceId', 'mode', 'permission', 'references'].includes(key))) return undefined
67
+ if (!Array.isArray(bundle.references)) return undefined
68
+ if (bundle.references.length > HANDOVER_MAX_REFERENCES) return undefined
69
+ let total = 0
70
+ for (const reference of bundle.references) {
71
+ if (typeof reference !== 'string' || reference === '') return undefined
72
+ const size = byteLength(reference)
73
+ if (size > HANDOVER_MAX_REFERENCE_BYTES) return undefined
74
+ total += size
75
+ if (total > HANDOVER_MAX_TOTAL_BYTES) return undefined
76
+ }
77
+ for (const key of ['workspaceId', 'mode'] as const) {
78
+ const target: unknown = bundle[key]
79
+ if (target !== undefined && (typeof target !== 'string' || byteLength(target) > HANDOVER_MAX_TARGET_BYTES)) return undefined
80
+ }
81
+ if (bundle.permission !== undefined && !isTaskPermission(bundle.permission)) return undefined
82
+ const workspaceId = bundle.workspaceId
83
+ const mode = bundle.mode
84
+ return {
85
+ ...(typeof workspaceId === 'string' ? { workspaceId } : {}),
86
+ ...(typeof mode === 'string' ? { mode } : {}),
87
+ ...(bundle.permission === undefined ? {} : { permission: bundle.permission as TaskPermission }),
88
+ references: [...bundle.references as string[]],
89
+ }
90
+ }
91
+
92
+ /** Build the persisted bundle from a sanitized input, stamping bundledAt. */
93
+ export function handoverOf(input: TaskHandoverInput, now: number): TaskHandover {
94
+ return { ...input, bundledAt: now }
95
+ }
96
+
97
+ /** The permission an execution session would actually run under. */
98
+ export function effectivePermission(task: Pick<TaskRecord, 'permission' | 'handover'>): TaskPermission | undefined {
99
+ return task.handover?.permission ?? task.permission
100
+ }
101
+
102
+ /** Whether a binding's permission is elevated above the session default. */
103
+ export function exceedsSessionDefault(permission: TaskPermission | undefined, sessionDefault: TaskPermission): boolean {
104
+ if (permission === undefined) return false
105
+ return (PERMISSION_RANK.get(permission) ?? -1) > (PERMISSION_RANK.get(sessionDefault) ?? -1)
106
+ }
107
+
108
+ /**
109
+ * The confirmation-gate predicate: an elevated permission without a human
110
+ * confirmation stamp. Manual run/rerun and cron must refuse such a card.
111
+ */
112
+ export function requiresPermissionConfirmation(
113
+ task: Pick<TaskRecord, 'permission' | 'handover' | 'permissionConfirmedAt'>,
114
+ sessionDefault: TaskPermission = DEFAULT_SESSION_PERMISSION,
115
+ ): boolean {
116
+ return exceedsSessionDefault(effectivePermission(task), sessionDefault) && task.permissionConfirmedAt === undefined
117
+ }
package/src/core/store.ts CHANGED
@@ -10,7 +10,10 @@
10
10
  * localStorage backend.
11
11
  */
12
12
  import { isValidCron } from './schedule.ts'
13
- import { isTaskPermission, isTaskStatus, normalizeTargetId, type ScheduleRule, type TaskRecord, type TaskPermission, type TaskStatus } from './tasks.ts'
13
+ import { isTaskPermission, isTaskStatus, normalizeTargetId, type ScheduleRule, type TaskFreeze, type TaskRecord, type TaskPermission, type TaskStatus } from './tasks.ts'
14
+ import type { TaskHandover } from './handover.ts'
15
+ import { sanitizeFreezeSnapshot } from './freeze-snapshot.ts'
16
+ import { sanitizeHandover } from './handover.ts'
14
17
 
15
18
  /** Persistence seam for the task ledger. */
16
19
  export interface TaskStore {
@@ -72,6 +75,9 @@ function isTaskRecordShape(value: unknown): value is Omit<TaskRecord, 'status'>
72
75
  if (entry.endedAt !== undefined && typeof entry.endedAt !== 'number') return false
73
76
  if (entry.result !== undefined && entry.result !== 'succeeded' && entry.result !== 'failed' && entry.result !== 'cancelled') return false
74
77
  if (entry.error !== undefined && typeof entry.error !== 'string') return false
78
+ if (entry.initiatedBy !== undefined && typeof entry.initiatedBy !== 'string') return false
79
+ if (entry.frozenBy !== undefined && typeof entry.frozenBy !== 'string') return false
80
+ if (entry.frozenAt !== undefined && typeof entry.frozenAt !== 'number') return false
75
81
  }
76
82
  return true
77
83
  }
@@ -107,6 +113,44 @@ function normalizeSchedule(schedule: unknown): ScheduleRule | undefined {
107
113
  }
108
114
  }
109
115
 
116
+ /**
117
+ * Repair a persisted freeze snapshot: shape + gate re-check (slash taint,
118
+ * redaction idempotence, byte cap); a malformed or tainted snapshot is
119
+ * dropped (undefined) rather than dropping the whole task row, mirroring
120
+ * the schedule repair policy.
121
+ */
122
+ function normalizeFreeze(value: unknown): TaskFreeze | undefined {
123
+ const result = sanitizeFreezeSnapshot(value, ['frozenAt', 'redacted', 'frozenBy'])
124
+ if (!result.ok) return undefined
125
+ const frozenAt = result.extras.frozenAt
126
+ if (typeof frozenAt !== 'number' || !Number.isFinite(frozenAt)) return undefined
127
+ if (result.extras.redacted !== undefined && result.extras.redacted !== true) return undefined
128
+ const frozenBy = result.extras.frozenBy
129
+ if (frozenBy !== undefined && (typeof frozenBy !== 'string' || frozenBy === '')) return undefined
130
+ return {
131
+ goal: result.snapshot.goal,
132
+ progress: result.snapshot.progress,
133
+ next: result.snapshot.next,
134
+ frozenAt,
135
+ ...(result.redacted || result.extras.redacted === true ? { redacted: true } : {}),
136
+ ...(frozenBy === undefined ? {} : { frozenBy }),
137
+ }
138
+ }
139
+
140
+ /**
141
+ * Repair a persisted handover bundle: shape re-check through the same gate
142
+ * as the wire path; a malformed bundle is dropped rather than dropping the
143
+ * task row (mirroring the schedule/freeze repair policy).
144
+ */
145
+ function normalizeHandover(value: unknown): TaskHandover | undefined {
146
+ if (typeof value !== 'object' || value === null) return undefined
147
+ const { bundledAt, ...rest } = value as Record<string, unknown> & { bundledAt: unknown }
148
+ const bundle = sanitizeHandover(rest)
149
+ if (bundle === undefined) return undefined
150
+ if (typeof bundledAt !== 'number' || !Number.isFinite(bundledAt)) return undefined
151
+ return { ...bundle, bundledAt }
152
+ }
153
+
110
154
  /** Parse + validate a persisted ledger document; invalid rows are dropped. */
111
155
  export function parseLedger(raw: string | null): TaskRecord[] {
112
156
  if (raw === null) return []
@@ -141,6 +185,9 @@ export function parseLedger(raw: string | null): TaskRecord[] {
141
185
  task.mode = normalizeTargetId(row.mode)
142
186
  task.archivedAt = typeof row.archivedAt === 'number' && Number.isFinite(row.archivedAt) ? row.archivedAt : undefined
143
187
  task.permission = isTaskPermission(row.permission) ? row.permission as TaskPermission : undefined
188
+ task.freeze = normalizeFreeze(row.freeze)
189
+ task.handover = normalizeHandover(row.handover)
190
+ task.permissionConfirmedAt = typeof row.permissionConfirmedAt === 'number' && Number.isFinite(row.permissionConfirmedAt) ? row.permissionConfirmedAt : undefined
144
191
  tasks.push(task)
145
192
  }
146
193
  return tasks
package/src/core/tasks.ts CHANGED
@@ -4,6 +4,8 @@
4
4
  * Framework-free (no cordis, no runtime imports) so the state machine is
5
5
  * unit-testable in isolation.
6
6
  */
7
+ import type { FreezeSnapshot } from './freeze-snapshot.ts'
8
+ import type { TaskHandover, TaskHandoverInput } from './handover.ts'
7
9
 
8
10
  /** Task lifecycle status, one per kanban column. */
9
11
  export type TaskStatus = 'backlog' | 'todo' | 'running' | 'done' | 'failed'
@@ -26,6 +28,16 @@ export interface ExecutionRecord {
26
28
  result: 'succeeded' | 'failed' | 'cancelled' | undefined
27
29
  /** Human failure text when the run failed (prompt rejection or agent error). */
28
30
  error: string | undefined
31
+ /**
32
+ * Session id of the DSH session that issued the run/rerun action (issue #6
33
+ * audit origin). Client-asserted, not a trust boundary; absent when the run
34
+ * was triggered by cron (source unknown).
35
+ */
36
+ initiatedBy?: string
37
+ /** Freeze instant captured from the card snapshot when the run opened. */
38
+ frozenAt?: number
39
+ /** Freeze source session captured from the card snapshot when the run opened. */
40
+ frozenBy?: string
29
41
  }
30
42
 
31
43
  /**
@@ -64,6 +76,24 @@ export interface ScheduleRule {
64
76
  lastTriggeredAt: number | undefined
65
77
  }
66
78
 
79
+ /**
80
+ * Frozen context snapshot carried by a continuation card (issue #4): the
81
+ * goal/progress/next text (already sanitized by the freeze gates) plus the
82
+ * freeze instant and the redaction warning flag.
83
+ */
84
+ export interface TaskFreeze extends FreezeSnapshot {
85
+ /** When the snapshot was frozen (ms epoch, stamped by the create/update use case). */
86
+ frozenAt: number
87
+ /** True when the freeze gates redacted sensitive patterns out of the text. */
88
+ redacted?: boolean
89
+ /**
90
+ * Session id of the DSH session that authored the frozen snapshot (issue #6
91
+ * provenance): stamped by the Host ledger from the create/update action's
92
+ * initiator, or kept from the wire payload when no initiator was asserted.
93
+ */
94
+ frozenBy?: string
95
+ }
96
+
67
97
  /** One task on the board. */
68
98
  export interface TaskRecord {
69
99
  /** Stable task id (uuid). */
@@ -102,6 +132,25 @@ export interface TaskRecord {
102
132
  * `/permission <id>` slash command; absent leaves the session default.
103
133
  */
104
134
  permission?: TaskPermission
135
+ /**
136
+ * Frozen context snapshot for a continuation card; absent on plain tasks.
137
+ * Sanitized before it enters the ledger (redaction, slash-command taint,
138
+ * 8 KiB per-field cap) by the protocol gate and re-normalized on load.
139
+ */
140
+ freeze?: TaskFreeze
141
+ /**
142
+ * Handover bundle carried by a continuation card (issue #5): the pinned
143
+ * execution triplet plus doc/script references. Sanitized before it
144
+ * enters the ledger by the protocol gate and re-normalized on load; the
145
+ * bundle's triplet overrides the legacy pin fields at execution time.
146
+ */
147
+ handover?: TaskHandover
148
+ /**
149
+ * Human confirmation stamp for an above-default effective permission
150
+ * (ms epoch). Absent while the binding awaits confirmation; any permission
151
+ * or handover change re-arms the gate by clearing it.
152
+ */
153
+ permissionConfirmedAt?: number
105
154
  /**
106
155
  * When the task was archived (ms epoch). Archived tasks keep their status
107
156
  * and execution history, leave the main board, and cannot run until restored;
@@ -142,6 +191,16 @@ export interface NewTaskInput {
142
191
  * case arms it only when enabled and the expression is valid.
143
192
  */
144
193
  schedule?: { enabled: boolean; cron: string }
194
+ /**
195
+ * Optional frozen context snapshot (goal/progress/next, sanitized by the
196
+ * protocol gate) turning the new task into a continuation card.
197
+ */
198
+ freeze?: FreezeSnapshot & { redacted?: boolean; frozenBy?: string }
199
+ /**
200
+ * Optional handover bundle (pinned triplet + doc/script references,
201
+ * sanitized by the protocol gate) attached at creation.
202
+ */
203
+ handover?: TaskHandoverInput
145
204
  }
146
205
 
147
206
  /** The five kanban columns, in display order. */
@@ -180,6 +239,24 @@ export function normalizeTargetId(value: string | undefined): string | undefined
180
239
  return trimmed === undefined || trimmed === '' ? undefined : trimmed
181
240
  }
182
241
 
242
+ /**
243
+ * Build the persisted freeze snapshot from a sanitized input, stamping the
244
+ * freeze instant (shared by the create and update use cases).
245
+ */
246
+ export function freezeOf(
247
+ input: FreezeSnapshot & { redacted?: boolean; frozenBy?: string },
248
+ now: number,
249
+ ): TaskFreeze {
250
+ return {
251
+ goal: input.goal,
252
+ progress: input.progress,
253
+ next: input.next,
254
+ frozenAt: now,
255
+ ...(input.redacted === true ? { redacted: true } : {}),
256
+ ...(input.frozenBy === undefined || input.frozenBy === '' ? {} : { frozenBy: input.frozenBy }),
257
+ }
258
+ }
259
+
183
260
  /** Create a task from user input. */
184
261
  export function createTask(input: NewTaskInput, now: number, id: string): TaskRecord {
185
262
  return {
@@ -194,6 +271,8 @@ export function createTask(input: NewTaskInput, now: number, id: string): TaskRe
194
271
  workspaceId: normalizeTargetId(input.workspaceId),
195
272
  mode: normalizeTargetId(input.mode),
196
273
  permission: isTaskPermission(input.permission) ? input.permission : undefined,
274
+ ...(input.freeze === undefined ? {} : { freeze: freezeOf(input.freeze, now) }),
275
+ ...(input.handover === undefined ? {} : { handover: { ...input.handover, bundledAt: now } }),
197
276
  }
198
277
  }
199
278
 
@@ -235,6 +314,7 @@ export function startExecution(
235
314
  task: TaskRecord,
236
315
  now: number,
237
316
  executionId: string,
317
+ initiatedBy?: string,
238
318
  ): { task: TaskRecord; execution: ExecutionRecord } {
239
319
  const execution: ExecutionRecord = {
240
320
  id: executionId,
@@ -243,6 +323,13 @@ export function startExecution(
243
323
  endedAt: undefined,
244
324
  result: undefined,
245
325
  error: undefined,
326
+ ...(initiatedBy === undefined || initiatedBy === '' ? {} : { initiatedBy }),
327
+ // Capture the card's freeze provenance on the execution record so the
328
+ // audit trail stays queryable even if the snapshot is replaced later.
329
+ ...(task.freeze === undefined ? {} : {
330
+ frozenAt: task.freeze.frozenAt,
331
+ ...(task.freeze.frozenBy === undefined ? {} : { frozenBy: task.freeze.frozenBy }),
332
+ }),
246
333
  }
247
334
  return {
248
335
  task: {