digital-tasks 2.1.3 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/README.md +560 -0
  3. package/package.json +29 -14
  4. package/src/client.ts +268 -0
  5. package/src/index.ts +12 -10
  6. package/src/markdown.ts +63 -48
  7. package/src/project.ts +57 -42
  8. package/src/queue.ts +76 -37
  9. package/src/task.ts +132 -75
  10. package/src/types.ts +177 -40
  11. package/src/worker.ts +959 -0
  12. package/test/project.test.ts +28 -84
  13. package/test/queue.test.ts +51 -24
  14. package/test/task.test.ts +80 -27
  15. package/test/worker.test.ts +1158 -0
  16. package/tsconfig.json +2 -13
  17. package/vitest.config.ts +48 -0
  18. package/wrangler.jsonc +44 -0
  19. package/.turbo/turbo-build.log +0 -4
  20. package/LICENSE +0 -21
  21. package/dist/function-task.d.ts +0 -319
  22. package/dist/function-task.d.ts.map +0 -1
  23. package/dist/function-task.js +0 -286
  24. package/dist/function-task.js.map +0 -1
  25. package/dist/index.d.ts +0 -72
  26. package/dist/index.d.ts.map +0 -1
  27. package/dist/index.js +0 -74
  28. package/dist/index.js.map +0 -1
  29. package/dist/markdown.d.ts +0 -112
  30. package/dist/markdown.d.ts.map +0 -1
  31. package/dist/markdown.js +0 -510
  32. package/dist/markdown.js.map +0 -1
  33. package/dist/project.d.ts +0 -259
  34. package/dist/project.d.ts.map +0 -1
  35. package/dist/project.js +0 -397
  36. package/dist/project.js.map +0 -1
  37. package/dist/queue.d.ts +0 -17
  38. package/dist/queue.d.ts.map +0 -1
  39. package/dist/queue.js +0 -347
  40. package/dist/queue.js.map +0 -1
  41. package/dist/task.d.ts +0 -69
  42. package/dist/task.d.ts.map +0 -1
  43. package/dist/task.js +0 -321
  44. package/dist/task.js.map +0 -1
  45. package/dist/types.d.ts +0 -292
  46. package/dist/types.d.ts.map +0 -1
  47. package/dist/types.js +0 -15
  48. package/dist/types.js.map +0 -1
  49. package/src/index.js +0 -73
  50. package/src/markdown.js +0 -509
  51. package/src/project.js +0 -396
  52. package/src/queue.js +0 -346
  53. package/src/task.js +0 -320
  54. package/src/types.js +0 -14
package/src/client.ts ADDED
@@ -0,0 +1,268 @@
1
+ /**
2
+ * RPC Client for digital-tasks
3
+ *
4
+ * Connects to a deployed digital-tasks worker via rpc.do,
5
+ * providing a fully typed client for remote task management.
6
+ *
7
+ * @example
8
+ * ```ts
9
+ * import { createTaskClient } from 'digital-tasks/client'
10
+ *
11
+ * const tasks = createTaskClient('https://digital-tasks.workers.dev')
12
+ * const task = await tasks.create({ name: 'My Task', description: 'Do something', priority: 'high' })
13
+ * await tasks.execute(task.id)
14
+ * await tasks.complete(task.id, { result: 'done' })
15
+ * ```
16
+ *
17
+ * @packageDocumentation
18
+ */
19
+
20
+ import { RPC, http } from 'rpc.do'
21
+
22
+ // ============================================================================
23
+ // Types (mirrored from worker.ts for client-side use)
24
+ // ============================================================================
25
+
26
+ export type TaskStatus =
27
+ | 'pending'
28
+ | 'scheduled'
29
+ | 'queued'
30
+ | 'blocked'
31
+ | 'in_progress'
32
+ | 'completed'
33
+ | 'failed'
34
+ | 'cancelled'
35
+
36
+ export type TaskPriority = 'low' | 'normal' | 'high' | 'urgent' | 'critical'
37
+
38
+ export interface WorkerRef {
39
+ type: 'agent' | 'human' | 'team' | 'any'
40
+ id: string
41
+ name?: string
42
+ }
43
+
44
+ export interface TaskDependency {
45
+ type: 'blocked_by'
46
+ taskId: string
47
+ satisfied: boolean
48
+ }
49
+
50
+ export interface TaskProgress {
51
+ percent: number
52
+ step?: string
53
+ updatedAt: Date
54
+ }
55
+
56
+ export interface TaskAssignment {
57
+ worker: WorkerRef
58
+ assignedAt: Date
59
+ }
60
+
61
+ export interface TaskData<TInput = unknown, TOutput = unknown> {
62
+ id: string
63
+ name: string
64
+ description: string
65
+ status: TaskStatus
66
+ priority: TaskPriority
67
+ input?: TInput
68
+ output?: TOutput
69
+ error?: string
70
+ scheduledFor?: Date
71
+ deadline?: Date
72
+ createdAt: Date
73
+ startedAt?: Date
74
+ completedAt?: Date
75
+ progress?: TaskProgress
76
+ dependencies?: TaskDependency[]
77
+ assignment?: TaskAssignment
78
+ metadata?: Record<string, unknown>
79
+ }
80
+
81
+ export interface CreateTaskOptions<TInput = unknown> {
82
+ id?: string
83
+ name: string
84
+ description: string
85
+ priority?: TaskPriority
86
+ input?: TInput
87
+ scheduledFor?: Date
88
+ deadline?: Date
89
+ tags?: string[]
90
+ metadata?: Record<string, unknown>
91
+ dependencies?: string[]
92
+ }
93
+
94
+ export interface ScheduleOptions {
95
+ priority?: TaskPriority
96
+ }
97
+
98
+ export interface ExecuteOptions {
99
+ worker?: WorkerRef
100
+ }
101
+
102
+ export interface ListOptions {
103
+ status?: TaskStatus | TaskStatus[]
104
+ priority?: TaskPriority
105
+ tags?: string[]
106
+ search?: string
107
+ sortBy?: 'createdAt' | 'priority'
108
+ sortOrder?: 'asc' | 'desc'
109
+ limit?: number
110
+ offset?: number
111
+ }
112
+
113
+ export interface EnqueueOptions {
114
+ delaySeconds?: number
115
+ }
116
+
117
+ export interface TaskStats {
118
+ total: number
119
+ byStatus: Record<string, number>
120
+ byPriority: Record<string, number>
121
+ }
122
+
123
+ // ============================================================================
124
+ // TaskServiceAPI - the RPC interface for the client
125
+ // ============================================================================
126
+
127
+ /**
128
+ * The RPC API surface exposed by the digital-tasks worker.
129
+ *
130
+ * This interface mirrors the methods on TaskServiceCore (worker.ts)
131
+ * and is used to type the RPC client proxy.
132
+ */
133
+ export interface TaskServiceAPI {
134
+ /** Create a new task */
135
+ create(options: CreateTaskOptions): Promise<TaskData>
136
+
137
+ /** Schedule a task for future execution */
138
+ schedule(taskId: string, scheduledFor: Date, options?: ScheduleOptions): Promise<TaskData>
139
+
140
+ /** Start task execution */
141
+ execute(taskId: string, options?: ExecuteOptions): Promise<TaskData>
142
+
143
+ /** Complete a task with output */
144
+ complete(taskId: string, output: unknown): Promise<TaskData>
145
+
146
+ /** Fail a task with error */
147
+ fail(taskId: string, error: string | Error): Promise<TaskData>
148
+
149
+ /** Get task status */
150
+ getStatus(taskId: string): Promise<TaskData | null>
151
+
152
+ /** Update task progress */
153
+ updateProgress(taskId: string, percent: number, step?: string): Promise<TaskData>
154
+
155
+ /** Cancel a task */
156
+ cancel(taskId: string, reason?: string): Promise<boolean>
157
+
158
+ /** List tasks with optional filtering */
159
+ list(options?: ListOptions): Promise<TaskData[]>
160
+
161
+ /** Get task statistics */
162
+ getStats(): Promise<TaskStats>
163
+
164
+ /** Retry a failed task */
165
+ retry(taskId: string): Promise<TaskData>
166
+
167
+ /** Get tasks that are ready for execution */
168
+ getReadyTasks(): Promise<TaskData[]>
169
+
170
+ /** Get tasks that depend on a given task */
171
+ getDependants(taskId: string): Promise<TaskData[]>
172
+
173
+ /** Enqueue a task for background processing */
174
+ enqueue(taskId: string, options?: EnqueueOptions): Promise<TaskData>
175
+
176
+ /** Dequeue and start the next task */
177
+ dequeue(): Promise<TaskData | null>
178
+
179
+ /** Execute a task with AI */
180
+ executeWithAI(taskId: string): Promise<TaskData>
181
+ }
182
+
183
+ // ============================================================================
184
+ // Client Options
185
+ // ============================================================================
186
+
187
+ /**
188
+ * Options for creating a task client
189
+ */
190
+ export interface TaskClientOptions {
191
+ /** Authentication token or API key */
192
+ token?: string
193
+ /** Custom headers to include with requests */
194
+ headers?: Record<string, string>
195
+ }
196
+
197
+ // ============================================================================
198
+ // Client Factory
199
+ // ============================================================================
200
+
201
+ /** Default worker URL for digital-tasks */
202
+ const DEFAULT_URL = 'https://digital-tasks.workers.dev'
203
+
204
+ /**
205
+ * Create a typed RPC client for the digital-tasks worker.
206
+ *
207
+ * @param url - The URL of the deployed digital-tasks worker
208
+ * @param options - Optional configuration (auth token, custom headers)
209
+ * @returns A fully typed RPC client proxy
210
+ *
211
+ * @example
212
+ * ```ts
213
+ * import { createTaskClient } from 'digital-tasks/client'
214
+ *
215
+ * // Connect to a deployed worker
216
+ * const tasks = createTaskClient('https://digital-tasks.workers.dev')
217
+ *
218
+ * // Create and manage tasks
219
+ * const task = await tasks.create({
220
+ * name: 'Analyze data',
221
+ * description: 'Run data analysis pipeline',
222
+ * priority: 'high',
223
+ * })
224
+ *
225
+ * // Execute the task
226
+ * await tasks.execute(task.id)
227
+ *
228
+ * // Track progress
229
+ * await tasks.updateProgress(task.id, 50, 'Processing...')
230
+ *
231
+ * // Complete with output
232
+ * await tasks.complete(task.id, { results: [1, 2, 3] })
233
+ *
234
+ * // List all completed tasks
235
+ * const completed = await tasks.list({ status: 'completed' })
236
+ * ```
237
+ *
238
+ * @example
239
+ * ```ts
240
+ * // With authentication
241
+ * const tasks = createTaskClient('https://digital-tasks.workers.dev', {
242
+ * token: 'my-api-key',
243
+ * })
244
+ * ```
245
+ */
246
+ export function createTaskClient(
247
+ url: string = DEFAULT_URL,
248
+ options?: TaskClientOptions
249
+ ): TaskServiceAPI {
250
+ return RPC<TaskServiceAPI>(http(url, options?.token))
251
+ }
252
+
253
+ /**
254
+ * Default task client connected to digital-tasks.workers.dev
255
+ *
256
+ * @example
257
+ * ```ts
258
+ * import client from 'digital-tasks/client'
259
+ *
260
+ * const task = await client.create({
261
+ * name: 'Quick task',
262
+ * description: 'A task using the default client',
263
+ * })
264
+ * ```
265
+ */
266
+ const client: TaskServiceAPI = createTaskClient()
267
+
268
+ export default client
package/src/index.ts CHANGED
@@ -1,19 +1,21 @@
1
1
  /**
2
2
  * digital-tasks - Task management primitives for digital workers
3
3
  *
4
- * Task = Function + metadata (status, progress, assignment, dependencies)
4
+ * Task = Tool + metadata (status, progress, assignment, dependencies)
5
5
  *
6
- * Every task wraps a function (code, generative, agentic, or human)
7
- * with lifecycle management, worker assignment, and dependency tracking.
6
+ * Every task wraps a Tool (a callable Verb of type code, generative,
7
+ * agentic, or human) with lifecycle management, worker assignment, and
8
+ * dependency tracking. The `function` field is a deprecated alias for
9
+ * `tool` retained for backward compatibility.
8
10
  *
9
11
  * ## Quick Start
10
12
  *
11
13
  * ```ts
12
14
  * import { Task, createTask, taskQueue } from 'digital-tasks'
13
15
  *
14
- * // Create a task from a function
16
+ * // Create a task from a Tool
15
17
  * const task = await createTask({
16
- * function: {
18
+ * tool: {
17
19
  * type: 'generative',
18
20
  * name: 'summarize',
19
21
  * args: { text: 'The text to summarize' },
@@ -79,13 +81,17 @@ export type {
79
81
  WorkerRef,
80
82
  WorkerType,
81
83
  TaskDependency,
84
+ TaskDep,
82
85
  DependencyType,
83
86
  TaskProgress,
84
87
  TaskEvent,
88
+ Comment,
85
89
  CreateTaskOptions,
86
90
  UpdateTaskOptions,
87
91
  TaskQuery,
88
92
  TaskQueueStats,
93
+ // Action supertype (re-exported from digital-objects)
94
+ Action,
89
95
  // Function types (re-exported from ai-functions)
90
96
  FunctionDefinition,
91
97
  CodeFunctionDefinition,
@@ -154,8 +160,4 @@ export {
154
160
 
155
161
  export type { SerializeOptions } from './markdown.js'
156
162
 
157
- export {
158
- parseMarkdown,
159
- toMarkdown,
160
- syncStatusFromMarkdown,
161
- } from './markdown.js'
163
+ export { parseMarkdown, toMarkdown, syncStatusFromMarkdown } from './markdown.js'
package/src/markdown.ts CHANGED
@@ -49,6 +49,7 @@ import type {
49
49
  ParallelGroup,
50
50
  SequentialGroup,
51
51
  ExecutionMode,
52
+ CreateProjectOptions,
52
53
  } from './project.js'
53
54
  import { task, parallel, sequential, createProject } from './project.js'
54
55
 
@@ -61,8 +62,8 @@ import { task, parallel, sequential, createProject } from './project.js'
61
62
  */
62
63
  const STATUS_MARKERS: Record<string, TaskStatus> = {
63
64
  ' ': 'pending',
64
- 'x': 'completed',
65
- 'X': 'completed',
65
+ x: 'completed',
66
+ X: 'completed',
66
67
  '-': 'in_progress',
67
68
  '~': 'blocked',
68
69
  '!': 'failed',
@@ -93,7 +94,7 @@ const PRIORITY_MARKERS: Record<string, TaskPriority> = {
93
94
  '!': 'urgent',
94
95
  '^': 'high',
95
96
  '': 'normal',
96
- 'v': 'low',
97
+ v: 'low',
97
98
  }
98
99
 
99
100
  // ============================================================================
@@ -135,20 +136,23 @@ function parseLine(line: string): ParsedLine {
135
136
  const raw = line
136
137
 
137
138
  // Count leading spaces for indent (2 spaces = 1 level)
138
- const leadingSpaces = line.match(/^(\s*)/)?.[1].length || 0
139
+ const leadingSpacesMatch = line.match(/^(\s*)/)
140
+ const leadingSpaces = leadingSpacesMatch?.[1]?.length ?? 0
139
141
  const indent = Math.floor(leadingSpaces / 2)
140
142
  const trimmed = line.slice(leadingSpaces)
141
143
 
142
144
  // Check for heading
143
145
  const headingMatch = trimmed.match(/^(#{1,6})\s+(.+)$/)
144
146
  if (headingMatch) {
147
+ const matchedHashes = headingMatch[1]!
148
+ const matchedTitle = headingMatch[2]!
145
149
  return {
146
150
  indent,
147
151
  isTask: false,
148
152
  isOrdered: false,
149
- title: headingMatch[2].trim(),
153
+ title: matchedTitle.trim(),
150
154
  isHeading: true,
151
- headingLevel: headingMatch[1].length,
155
+ headingLevel: matchedHashes.length,
152
156
  raw,
153
157
  }
154
158
  }
@@ -156,8 +160,8 @@ function parseLine(line: string): ParsedLine {
156
160
  // Check for unordered task: - [ ] or - [x] etc.
157
161
  const unorderedMatch = trimmed.match(/^[-*]\s+\[([^\]]*)\]\s*(.*)$/)
158
162
  if (unorderedMatch) {
159
- const marker = unorderedMatch[1]
160
- let title = unorderedMatch[2].trim()
163
+ const marker = unorderedMatch[1]!
164
+ let title = unorderedMatch[2]!.trim()
161
165
  let priority: TaskPriority = 'normal'
162
166
 
163
167
  // Check for priority marker at start of title
@@ -179,7 +183,7 @@ function parseLine(line: string): ParsedLine {
179
183
  indent,
180
184
  isTask: true,
181
185
  isOrdered: false,
182
- status: STATUS_MARKERS[marker] || 'pending',
186
+ status: STATUS_MARKERS[marker] ?? 'pending',
183
187
  priority,
184
188
  title,
185
189
  isHeading: false,
@@ -190,8 +194,9 @@ function parseLine(line: string): ParsedLine {
190
194
  // Check for ordered task: 1. [ ] or 1. [x] etc.
191
195
  const orderedMatch = trimmed.match(/^(\d+)\.\s+\[([^\]]*)\]\s*(.*)$/)
192
196
  if (orderedMatch) {
193
- const marker = orderedMatch[2]
194
- let title = orderedMatch[3].trim()
197
+ const orderNum = orderedMatch[1]!
198
+ const marker = orderedMatch[2]!
199
+ let title = orderedMatch[3]!.trim()
195
200
  let priority: TaskPriority = 'normal'
196
201
 
197
202
  // Check for priority marker
@@ -213,8 +218,8 @@ function parseLine(line: string): ParsedLine {
213
218
  indent,
214
219
  isTask: true,
215
220
  isOrdered: true,
216
- orderNumber: parseInt(orderedMatch[1], 10),
217
- status: STATUS_MARKERS[marker] || 'pending',
221
+ orderNumber: parseInt(orderNum, 10),
222
+ status: STATUS_MARKERS[marker] ?? 'pending',
218
223
  priority,
219
224
  title,
220
225
  isHeading: false,
@@ -247,7 +252,7 @@ function parseTasksAtIndent(
247
252
  let modeSet = false
248
253
 
249
254
  while (index < lines.length) {
250
- const line = lines[index]
255
+ const line = lines[index]!
251
256
 
252
257
  // Stop if we've gone back to a lower indent level
253
258
  if (line.indent < baseIndent && (line.isTask || line.isHeading)) {
@@ -269,20 +274,21 @@ function parseTasksAtIndent(
269
274
  }
270
275
 
271
276
  // Parse subtasks
272
- const { tasks: subtasks, nextIndex } = parseTasksAtIndent(
273
- lines,
274
- index + 1,
275
- baseIndent + 1
276
- )
277
-
278
- const taskDef = task(line.title, {
279
- priority: line.priority,
280
- subtasks: subtasks.length > 0 ? subtasks : undefined,
277
+ const { tasks: subtasks, nextIndex } = parseTasksAtIndent(lines, index + 1, baseIndent + 1)
278
+
279
+ const taskOptions: Partial<Omit<TaskDefinition, '__type' | 'title'>> = {
281
280
  metadata: {
282
281
  _originalStatus: line.status,
283
282
  _lineNumber: index,
284
283
  },
285
- })
284
+ }
285
+ if (line.priority !== undefined) {
286
+ taskOptions.priority = line.priority
287
+ }
288
+ if (subtasks.length > 0) {
289
+ taskOptions.subtasks = subtasks
290
+ }
291
+ const taskDef = task(line.title, taskOptions)
286
292
 
287
293
  tasks.push(taskDef)
288
294
  index = nextIndex
@@ -324,9 +330,9 @@ export function parseMarkdown(markdown: string): Project {
324
330
  const allTasks: TaskNode[] = []
325
331
 
326
332
  // Find project name from first h1
327
- const h1Index = lines.findIndex(l => l.isHeading && l.headingLevel === 1)
333
+ const h1Index = lines.findIndex((l) => l.isHeading && l.headingLevel === 1)
328
334
  if (h1Index !== -1) {
329
- projectName = lines[h1Index].title
335
+ projectName = lines[h1Index]!.title
330
336
  }
331
337
 
332
338
  // Process sections and tasks
@@ -334,7 +340,7 @@ export function parseMarkdown(markdown: string): Project {
334
340
  let index = 0
335
341
 
336
342
  while (index < lines.length) {
337
- const line = lines[index]
343
+ const line = lines[index]!
338
344
 
339
345
  // New section (h2)
340
346
  if (line.isHeading && line.headingLevel === 2) {
@@ -353,7 +359,7 @@ export function parseMarkdown(markdown: string): Project {
353
359
 
354
360
  const modeMatch = sectionName.match(/\((parallel|sequential)\)\s*$/i)
355
361
  if (modeMatch) {
356
- sectionMode = modeMatch[1].toLowerCase() as ExecutionMode
362
+ sectionMode = modeMatch[1]!.toLowerCase() as ExecutionMode
357
363
  sectionName = sectionName.replace(/\s*\((parallel|sequential)\)\s*$/i, '')
358
364
  }
359
365
 
@@ -396,11 +402,15 @@ export function parseMarkdown(markdown: string): Project {
396
402
  }
397
403
  }
398
404
 
399
- return createProject({
405
+ const projectOptions: CreateProjectOptions = {
400
406
  name: projectName,
401
- description: projectDescription,
402
407
  tasks: allTasks,
403
- })
408
+ }
409
+ if (projectDescription !== undefined) {
410
+ projectOptions.description = projectDescription
411
+ }
412
+
413
+ return createProject(projectOptions)
404
414
  }
405
415
 
406
416
  // ============================================================================
@@ -435,13 +445,13 @@ function serializeTaskNode(
435
445
 
436
446
  if (node.__type === 'task') {
437
447
  const taskDef = node as TaskDefinition
438
- const status = (taskDef.metadata?._originalStatus as TaskStatus) || 'pending'
448
+ const status = (taskDef.metadata?.['_originalStatus'] as TaskStatus) || 'pending'
439
449
  const marker = options.includeStatus !== false ? STATUS_TO_MARKER[status] : ' '
440
450
 
441
451
  let prefix: string
442
452
  if (isSequential) {
443
453
  // Use numbered list for sequential
444
- const num = (taskDef.metadata?._sequenceNumber as number) || 1
454
+ const num = (taskDef.metadata?.['_sequenceNumber'] as number) || 1
445
455
  prefix = `${num}. [${marker}]`
446
456
  } else {
447
457
  // Use bullet for parallel
@@ -450,11 +460,16 @@ function serializeTaskNode(
450
460
 
451
461
  let title = taskDef.title
452
462
  if (options.includePriority && taskDef.priority && taskDef.priority !== 'normal') {
453
- const priorityMarker = taskDef.priority === 'critical' ? '!!'
454
- : taskDef.priority === 'urgent' ? '!'
455
- : taskDef.priority === 'high' ? '^'
456
- : taskDef.priority === 'low' ? 'v'
457
- : ''
463
+ const priorityMarker =
464
+ taskDef.priority === 'critical'
465
+ ? '!!'
466
+ : taskDef.priority === 'urgent'
467
+ ? '!'
468
+ : taskDef.priority === 'high'
469
+ ? '^'
470
+ : taskDef.priority === 'low'
471
+ ? 'v'
472
+ : ''
458
473
  title = `${priorityMarker}${title}`
459
474
  }
460
475
 
@@ -471,7 +486,7 @@ function serializeTaskNode(
471
486
  let seqNum = 1
472
487
  for (const child of group.tasks) {
473
488
  if (child.__type === 'task') {
474
- (child as TaskDefinition).metadata = {
489
+ ;(child as TaskDefinition).metadata = {
475
490
  ...(child as TaskDefinition).metadata,
476
491
  _sequenceNumber: seqNum++,
477
492
  }
@@ -483,7 +498,7 @@ function serializeTaskNode(
483
498
  let seqNum = 1
484
499
  for (const child of group.tasks) {
485
500
  if (child.__type === 'task') {
486
- (child as TaskDefinition).metadata = {
501
+ ;(child as TaskDefinition).metadata = {
487
502
  ...(child as TaskDefinition).metadata,
488
503
  _sequenceNumber: seqNum++,
489
504
  }
@@ -558,10 +573,7 @@ export function toMarkdown(project: Project, options: SerializeOptions = {}): st
558
573
  * Update task statuses in a project from markdown
559
574
  * (Useful for syncing when markdown is edited externally)
560
575
  */
561
- export function syncStatusFromMarkdown(
562
- project: Project,
563
- markdown: string
564
- ): Project {
576
+ export function syncStatusFromMarkdown(project: Project, markdown: string): Project {
565
577
  const parsed = parseMarkdown(markdown)
566
578
 
567
579
  // Build a map of task titles to statuses from parsed markdown
@@ -570,7 +582,7 @@ export function syncStatusFromMarkdown(
570
582
  function collectStatuses(node: TaskNode) {
571
583
  if (node.__type === 'task') {
572
584
  const taskDef = node as TaskDefinition
573
- const status = taskDef.metadata?._originalStatus as TaskStatus
585
+ const status = taskDef.metadata?.['_originalStatus'] as TaskStatus
574
586
  if (status) {
575
587
  statusMap.set(taskDef.title, status)
576
588
  }
@@ -590,14 +602,17 @@ export function syncStatusFromMarkdown(
590
602
  if (node.__type === 'task') {
591
603
  const taskDef = node as TaskDefinition
592
604
  const newStatus = statusMap.get(taskDef.title)
593
- return {
605
+ const result: TaskDefinition = {
594
606
  ...taskDef,
595
607
  metadata: {
596
608
  ...taskDef.metadata,
597
- _originalStatus: newStatus || taskDef.metadata?._originalStatus,
609
+ _originalStatus: newStatus || taskDef.metadata?.['_originalStatus'],
598
610
  },
599
- subtasks: taskDef.subtasks?.map(updateStatuses),
600
611
  }
612
+ if (taskDef.subtasks) {
613
+ result.subtasks = taskDef.subtasks.map(updateStatuses)
614
+ }
615
+ return result
601
616
  } else if (node.__type === 'parallel') {
602
617
  const group = node as ParallelGroup
603
618
  return {