@xenonbyte/da-vinci-workflow 0.2.7 → 0.2.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.
@@ -0,0 +1,881 @@
1
+ const { parseTasksArtifact } = require("./planning-parsers");
2
+ const { STATUS, CHECKPOINT_LABELS } = require("./workflow-contract");
3
+ const { dedupeMessages, pathExists, readTextIfExists } = require("./utils");
4
+ const {
5
+ resolveWorkflowStatePath,
6
+ resolveTaskGroupMetadataPath,
7
+ readTaskGroupMetadata,
8
+ digestForPath
9
+ } = require("./workflow-persisted-state");
10
+ const { formatPathRef } = require("./workflow-decision-trace");
11
+
12
+ const TRACEABLE_TASK_GROUP_FOCUS_REASONS = new Set([
13
+ "implementer_block",
14
+ "implementer_warn",
15
+ "spec_review_missing",
16
+ "spec_review_block",
17
+ "spec_review_warn",
18
+ "quality_review_missing",
19
+ "quality_review_block",
20
+ "quality_review_warn"
21
+ ]);
22
+ const TASK_GROUP_SEED_TRACE_KEYS = Object.freeze({
23
+ missing: "seed_missing",
24
+ unreadable: "seed_unreadable",
25
+ "digest-mismatch": "seed_digest_mismatch",
26
+ legacy: "seed_legacy_embedded"
27
+ });
28
+
29
+ /**
30
+ * @typedef {Object} TaskGroupPlan
31
+ * @property {string} taskGroupId
32
+ * @property {string} title
33
+ * @property {string} status
34
+ * @property {number} completion
35
+ * @property {string} checkpointOutcome
36
+ * @property {string[]} evidence
37
+ * @property {string} nextAction
38
+ * @property {string[]} [targetFiles]
39
+ * @property {string[]} [fileReferences]
40
+ * @property {string[]} [verificationActions]
41
+ * @property {string[]} [verificationCommands]
42
+ * @property {string[]} [executionIntent]
43
+ * @property {boolean} [reviewIntent]
44
+ * @property {boolean} [testingIntent]
45
+ * @property {boolean} [codeChangeLikely]
46
+ * @property {{ groupIndex: number | null, nextUncheckedItem: string | null }} [resumeCursor]
47
+ */
48
+
49
+ /**
50
+ * @typedef {Object} TaskGroupRuntimeEnvelope
51
+ * @property {boolean} present
52
+ * @property {string | null} [signalStatus]
53
+ * @property {string | null} [implementerStatus]
54
+ * @property {string | null} [summary]
55
+ * @property {string[]} [changedFiles]
56
+ * @property {string[]} [testEvidence]
57
+ * @property {string[]} [concerns]
58
+ * @property {string[]} [blockers]
59
+ * @property {string[]} [outOfScopeWrites]
60
+ * @property {string | null} [recordedAt]
61
+ */
62
+
63
+ /**
64
+ * @typedef {Object} TaskGroupRuntimeState
65
+ * @property {string} taskGroupId
66
+ * @property {string} title
67
+ * @property {string} status
68
+ * @property {number} completion
69
+ * @property {string} checkpointOutcome
70
+ * @property {string[]} evidence
71
+ * @property {string} nextAction
72
+ * @property {string[]} targetFiles
73
+ * @property {string[]} fileReferences
74
+ * @property {string[]} verificationActions
75
+ * @property {string[]} verificationCommands
76
+ * @property {string[]} executionIntent
77
+ * @property {boolean} reviewIntent
78
+ * @property {boolean} testingIntent
79
+ * @property {boolean} codeChangeLikely
80
+ * @property {{ groupIndex: number | null, nextUncheckedItem: string | null, liveFocus?: string | null }} resumeCursor
81
+ * @property {object} planned
82
+ * @property {TaskGroupRuntimeEnvelope} implementer
83
+ * @property {object} review
84
+ * @property {object} effective
85
+ */
86
+
87
+ /**
88
+ * @typedef {Object} TaskGroupSeedResolution
89
+ * @property {Array<object>} taskGroups
90
+ * @property {string[]} notes
91
+ */
92
+
93
+ function recordDecision(callback, records, record) {
94
+ if (typeof callback === "function") {
95
+ callback(records, record);
96
+ }
97
+ }
98
+
99
+ function buildTaskGroupFocusEvidenceRefs(taskGroupId, reason) {
100
+ if (reason === "implementer_block" || reason === "implementer_warn") {
101
+ return [`signal:task-execution.${taskGroupId}`];
102
+ }
103
+ if (reason === "spec_review_block" || reason === "spec_review_warn") {
104
+ return [`signal:task-review.${taskGroupId}.spec`];
105
+ }
106
+ if (reason === "quality_review_block" || reason === "quality_review_warn") {
107
+ return [`signal:task-review.${taskGroupId}.quality`];
108
+ }
109
+ return [];
110
+ }
111
+
112
+ function buildTaskGroupFocusReasonSummary(taskGroupId, reason) {
113
+ switch (reason) {
114
+ case "implementer_block":
115
+ return `Implementer BLOCK overrides planned checklist focus for task group ${taskGroupId}.`;
116
+ case "implementer_warn":
117
+ return `Implementer WARN overrides planned checklist focus for task group ${taskGroupId}.`;
118
+ case "spec_review_missing":
119
+ return `Missing spec review takes focus over planned checklist work for task group ${taskGroupId}.`;
120
+ case "spec_review_block":
121
+ return `Spec review BLOCK takes focus over planned checklist work for task group ${taskGroupId}.`;
122
+ case "spec_review_warn":
123
+ return `Spec review WARN follow-up takes focus over planned checklist work for task group ${taskGroupId}.`;
124
+ case "quality_review_missing":
125
+ return `Missing quality review takes focus over planned checklist work for task group ${taskGroupId}.`;
126
+ case "quality_review_block":
127
+ return `Quality review BLOCK takes focus over planned checklist work for task group ${taskGroupId}.`;
128
+ case "quality_review_warn":
129
+ return `Quality review WARN follow-up takes focus over planned checklist work for task group ${taskGroupId}.`;
130
+ default:
131
+ return "";
132
+ }
133
+ }
134
+
135
+ /**
136
+ * Parse `tasks.md` into canonical task-group plan metadata.
137
+ *
138
+ * @param {string} tasksMarkdownText
139
+ * @param {Object.<string, string>} checkpointStatuses
140
+ * @returns {TaskGroupPlan[]}
141
+ */
142
+ function deriveTaskGroupMetadata(tasksMarkdownText, checkpointStatuses) {
143
+ if (!tasksMarkdownText) {
144
+ return [];
145
+ }
146
+
147
+ const taskArtifact = parseTasksArtifact(tasksMarkdownText);
148
+ const lines = String(tasksMarkdownText || "").replace(/\r\n?/g, "\n").split("\n");
149
+ const sections = [];
150
+ let current = null;
151
+ for (const line of lines) {
152
+ const headingMatch = line.match(/^\s{0,3}##\s+(\d+(?:\.\d+)*)\.\s+(.+)$/);
153
+ if (headingMatch) {
154
+ if (current) {
155
+ sections.push(current);
156
+ }
157
+ current = {
158
+ id: headingMatch[1],
159
+ title: String(headingMatch[2] || "").trim(),
160
+ checklistItems: []
161
+ };
162
+ continue;
163
+ }
164
+ if (!current) {
165
+ continue;
166
+ }
167
+ const checklistMatch = line.match(/^\s*-\s*\[([ xX])\]\s+(.+)$/);
168
+ if (checklistMatch) {
169
+ current.checklistItems.push({
170
+ checked: String(checklistMatch[1] || "").toLowerCase() === "x",
171
+ text: String(checklistMatch[2] || "").trim()
172
+ });
173
+ }
174
+ }
175
+ if (current) {
176
+ sections.push(current);
177
+ }
178
+
179
+ const normalizedTaskCheckpoint = checkpointStatuses[CHECKPOINT_LABELS.TASK] || STATUS.WARN;
180
+ const groupMetadataById = new Map(
181
+ (taskArtifact.taskGroups || []).map((group) => [
182
+ group.id,
183
+ {
184
+ targetFiles: Array.isArray(group.targetFiles) ? group.targetFiles : [],
185
+ fileReferences: Array.isArray(group.fileReferences) ? group.fileReferences : [],
186
+ verificationActions: Array.isArray(group.verificationActions) ? group.verificationActions : [],
187
+ verificationCommands: Array.isArray(group.verificationCommands) ? group.verificationCommands : [],
188
+ executionIntent: Array.isArray(group.executionIntent) ? group.executionIntent : [],
189
+ reviewIntent: group.reviewIntent === true,
190
+ testingIntent: group.testingIntent === true,
191
+ codeChangeLikely: group.codeChangeLikely === true
192
+ }
193
+ ])
194
+ );
195
+ const metadata = sections.map((section, index) => {
196
+ const total = section.checklistItems.length;
197
+ const done = section.checklistItems.filter((item) => item.checked).length;
198
+ const completion = total > 0 ? Math.round((done / total) * 100) : 0;
199
+ const sectionStatus =
200
+ total === 0 ? "pending" : done === 0 ? "pending" : done < total ? "in_progress" : "completed";
201
+ const nextAction =
202
+ sectionStatus === "completed"
203
+ ? "advance to next task group"
204
+ : section.checklistItems.find((item) => !item.checked)?.text || "continue group work";
205
+ const groupMetadata = groupMetadataById.get(section.id) || {};
206
+
207
+ return {
208
+ taskGroupId: section.id,
209
+ title: section.title,
210
+ status: sectionStatus,
211
+ completion,
212
+ checkpointOutcome: normalizedTaskCheckpoint,
213
+ evidence: section.checklistItems.filter((item) => item.checked).map((item) => item.text),
214
+ nextAction,
215
+ targetFiles: groupMetadata.targetFiles || [],
216
+ fileReferences: groupMetadata.fileReferences || [],
217
+ verificationActions: groupMetadata.verificationActions || [],
218
+ verificationCommands: groupMetadata.verificationCommands || [],
219
+ executionIntent: groupMetadata.executionIntent || [],
220
+ reviewIntent: groupMetadata.reviewIntent === true,
221
+ testingIntent: groupMetadata.testingIntent === true,
222
+ codeChangeLikely: groupMetadata.codeChangeLikely === true,
223
+ resumeCursor: {
224
+ groupIndex: index,
225
+ nextUncheckedItem:
226
+ section.checklistItems.find((item) => !item.checked)?.text ||
227
+ section.checklistItems[section.checklistItems.length - 1]?.text ||
228
+ null
229
+ }
230
+ };
231
+ });
232
+
233
+ if (metadata.length === 0 && taskArtifact.taskGroups.length > 0) {
234
+ return taskArtifact.taskGroups.map((group, index) => {
235
+ const groupMetadata = groupMetadataById.get(group.id) || {};
236
+ return {
237
+ taskGroupId: group.id,
238
+ title: group.title,
239
+ status: "pending",
240
+ completion: 0,
241
+ checkpointOutcome: normalizedTaskCheckpoint,
242
+ evidence: [],
243
+ nextAction: "start task group",
244
+ targetFiles: groupMetadata.targetFiles || [],
245
+ fileReferences: groupMetadata.fileReferences || [],
246
+ verificationActions: groupMetadata.verificationActions || [],
247
+ verificationCommands: groupMetadata.verificationCommands || [],
248
+ executionIntent: groupMetadata.executionIntent || [],
249
+ reviewIntent: groupMetadata.reviewIntent === true,
250
+ testingIntent: groupMetadata.testingIntent === true,
251
+ codeChangeLikely: groupMetadata.codeChangeLikely === true,
252
+ resumeCursor: {
253
+ groupIndex: index,
254
+ nextUncheckedItem: null
255
+ }
256
+ };
257
+ });
258
+ }
259
+
260
+ return metadata;
261
+ }
262
+
263
+ function normalizeTaskGroupId(value) {
264
+ return String(value || "").trim();
265
+ }
266
+
267
+ function normalizeResumeCursor(cursor, fallbackGroupIndex = null) {
268
+ const groupIndex =
269
+ cursor && Number.isInteger(cursor.groupIndex)
270
+ ? cursor.groupIndex
271
+ : Number.isInteger(fallbackGroupIndex)
272
+ ? fallbackGroupIndex
273
+ : null;
274
+ return {
275
+ groupIndex,
276
+ nextUncheckedItem:
277
+ cursor && Object.prototype.hasOwnProperty.call(cursor, "nextUncheckedItem")
278
+ ? cursor.nextUncheckedItem
279
+ : null
280
+ };
281
+ }
282
+
283
+ function normalizeTaskGroupSeedMap(taskGroups) {
284
+ const byId = new Map();
285
+ for (const group of Array.isArray(taskGroups) ? taskGroups : []) {
286
+ const taskGroupId = normalizeTaskGroupId(group && (group.taskGroupId || group.id));
287
+ if (!taskGroupId || byId.has(taskGroupId)) {
288
+ continue;
289
+ }
290
+ byId.set(taskGroupId, group);
291
+ }
292
+ return byId;
293
+ }
294
+
295
+ function findLatestSignalBySurface(signals, surface) {
296
+ const normalizedSurface = String(surface || "").trim();
297
+ if (!normalizedSurface) {
298
+ return null;
299
+ }
300
+ return (signals || []).find((signal) => String(signal.surface || "").trim() === normalizedSurface) || null;
301
+ }
302
+
303
+ function summarizeSignalIssues(signal, envelopeItems) {
304
+ if (!signal) {
305
+ return [];
306
+ }
307
+ const fromEnvelope = Array.isArray(envelopeItems) ? envelopeItems : [];
308
+ const fromSignal = [
309
+ ...(Array.isArray(signal.failures) ? signal.failures : []),
310
+ ...(Array.isArray(signal.warnings) ? signal.warnings : [])
311
+ ];
312
+ return dedupeMessages([...fromEnvelope, ...fromSignal].map((item) => String(item || "").trim()).filter(Boolean));
313
+ }
314
+
315
+ function buildTaskGroupImplementerState(taskGroupId, signals, fallbackState) {
316
+ const fallback = fallbackState && typeof fallbackState === "object" ? fallbackState : {};
317
+ const signal = findLatestSignalBySurface(signals, `task-execution.${taskGroupId}`);
318
+ const envelope = signal && signal.details && signal.details.envelope ? signal.details.envelope : null;
319
+ if (!signal) {
320
+ return {
321
+ present: false,
322
+ signalStatus: null,
323
+ implementerStatus: fallback.implementerStatus || null,
324
+ summary: fallback.summary || null,
325
+ changedFiles: Array.isArray(fallback.changedFiles) ? fallback.changedFiles : [],
326
+ testEvidence: Array.isArray(fallback.testEvidence) ? fallback.testEvidence : [],
327
+ concerns: Array.isArray(fallback.concerns) ? fallback.concerns : [],
328
+ blockers: Array.isArray(fallback.blockers) ? fallback.blockers : [],
329
+ outOfScopeWrites: Array.isArray(fallback.outOfScopeWrites) ? fallback.outOfScopeWrites : [],
330
+ recordedAt: fallback.recordedAt || null
331
+ };
332
+ }
333
+
334
+ return {
335
+ present: true,
336
+ signalStatus: signal.status || null,
337
+ implementerStatus: envelope && envelope.status ? envelope.status : fallback.implementerStatus || null,
338
+ summary: envelope && envelope.summary ? envelope.summary : fallback.summary || null,
339
+ changedFiles:
340
+ envelope && Array.isArray(envelope.changedFiles)
341
+ ? envelope.changedFiles
342
+ : Array.isArray(fallback.changedFiles)
343
+ ? fallback.changedFiles
344
+ : [],
345
+ testEvidence:
346
+ envelope && Array.isArray(envelope.testEvidence)
347
+ ? envelope.testEvidence
348
+ : Array.isArray(fallback.testEvidence)
349
+ ? fallback.testEvidence
350
+ : [],
351
+ concerns: summarizeSignalIssues(signal, envelope && envelope.concerns),
352
+ blockers: summarizeSignalIssues(signal, envelope && envelope.blockers),
353
+ outOfScopeWrites:
354
+ signal.details && Array.isArray(signal.details.outOfScopeWrites)
355
+ ? dedupeMessages(signal.details.outOfScopeWrites.map((item) => String(item || "").trim()).filter(Boolean))
356
+ : Array.isArray(fallback.outOfScopeWrites)
357
+ ? fallback.outOfScopeWrites
358
+ : [],
359
+ recordedAt: (envelope && envelope.recordedAt) || signal.timestamp || fallback.recordedAt || null
360
+ };
361
+ }
362
+
363
+ function buildTaskGroupReviewStageState(taskGroupId, stage, signals, fallbackState) {
364
+ const fallback = fallbackState && typeof fallbackState === "object" ? fallbackState : {};
365
+ const signal = findLatestSignalBySurface(signals, `task-review.${taskGroupId}.${stage}`);
366
+ const envelope = signal && signal.details && signal.details.envelope ? signal.details.envelope : null;
367
+ if (!signal) {
368
+ return {
369
+ present: false,
370
+ status: fallback.status || "missing",
371
+ summary: fallback.summary || null,
372
+ reviewer: fallback.reviewer || null,
373
+ issues: Array.isArray(fallback.issues) ? fallback.issues : [],
374
+ recordedAt: fallback.recordedAt || null
375
+ };
376
+ }
377
+
378
+ return {
379
+ present: true,
380
+ status: signal.status || "missing",
381
+ summary: envelope && envelope.summary ? envelope.summary : fallback.summary || null,
382
+ reviewer: envelope && envelope.reviewer ? envelope.reviewer : fallback.reviewer || null,
383
+ issues: summarizeSignalIssues(signal, envelope && envelope.issues),
384
+ recordedAt: (envelope && envelope.recordedAt) || signal.timestamp || fallback.recordedAt || null
385
+ };
386
+ }
387
+
388
+ function buildTaskGroupReviewState(taskGroup, signals, fallbackState) {
389
+ const fallback = fallbackState && typeof fallbackState === "object" ? fallbackState : {};
390
+ const taskGroupId = normalizeTaskGroupId(taskGroup.taskGroupId || taskGroup.id);
391
+ const required = taskGroup.reviewIntent === true;
392
+ const spec = buildTaskGroupReviewStageState(taskGroupId, "spec", signals, fallback.spec);
393
+ const quality = buildTaskGroupReviewStageState(taskGroupId, "quality", signals, fallback.quality);
394
+
395
+ return {
396
+ required,
397
+ ordering: required ? "spec_then_quality" : "none",
398
+ spec,
399
+ quality
400
+ };
401
+ }
402
+
403
+ function buildEffectiveTaskGroupState(group, planned, implementer, review) {
404
+ const fallbackCursor = normalizeResumeCursor(planned.resumeCursor, null);
405
+ const effective = {
406
+ status: planned.status,
407
+ nextAction: planned.nextAction,
408
+ resumeCursor: fallbackCursor,
409
+ source: "planned",
410
+ reason: "planned_checklist"
411
+ };
412
+
413
+ if (implementer.present && implementer.outOfScopeWrites.length > 0) {
414
+ return {
415
+ status: "blocked",
416
+ nextAction:
417
+ `resolve out-of-scope writes for task group ${group.taskGroupId}: ${implementer.outOfScopeWrites.join(", ")}`,
418
+ resumeCursor: {
419
+ groupIndex: fallbackCursor.groupIndex,
420
+ nextUncheckedItem: null,
421
+ liveFocus: "out_of_scope_write"
422
+ },
423
+ source: "implementer",
424
+ reason: "out_of_scope_write"
425
+ };
426
+ }
427
+
428
+ if (implementer.present && implementer.signalStatus === STATUS.BLOCK) {
429
+ return {
430
+ status: "blocked",
431
+ nextAction:
432
+ implementer.blockers[0] ||
433
+ implementer.summary ||
434
+ `resolve implementer blocker for task group ${group.taskGroupId}`,
435
+ resumeCursor: {
436
+ groupIndex: fallbackCursor.groupIndex,
437
+ nextUncheckedItem: null,
438
+ liveFocus: "implementer_block"
439
+ },
440
+ source: "implementer",
441
+ reason: "implementer_block"
442
+ };
443
+ }
444
+
445
+ const reviewSignalsPresent = review.spec.present || review.quality.present;
446
+ const plannedCompletion = Number.isFinite(Number(planned.completion))
447
+ ? Number(planned.completion)
448
+ : 0;
449
+ const reviewNearComplete = planned.status !== "completed" && plannedCompletion >= 75;
450
+ const reviewHardDue = planned.status === "completed" || reviewNearComplete;
451
+ const reviewContextReady = review.required && (reviewSignalsPresent || reviewHardDue || implementer.present);
452
+
453
+ if (reviewContextReady) {
454
+ if (
455
+ review.quality.present &&
456
+ (!review.spec.present || review.spec.status === "missing" || review.spec.status === STATUS.WARN)
457
+ ) {
458
+ return {
459
+ status: "blocked",
460
+ nextAction:
461
+ `remove or rerun out-of-order quality review for task group ${group.taskGroupId} after spec review PASS`,
462
+ resumeCursor: {
463
+ groupIndex: fallbackCursor.groupIndex,
464
+ nextUncheckedItem: null,
465
+ liveFocus: "review_ordering_violation"
466
+ },
467
+ source: "review",
468
+ reason: "review_ordering_violation"
469
+ };
470
+ }
471
+ if (review.spec.status === STATUS.BLOCK) {
472
+ return {
473
+ status: "blocked",
474
+ nextAction:
475
+ review.spec.issues[0] || review.spec.summary || `resolve spec review BLOCK for task group ${group.taskGroupId}`,
476
+ resumeCursor: {
477
+ groupIndex: fallbackCursor.groupIndex,
478
+ nextUncheckedItem: null,
479
+ liveFocus: "spec_review_block"
480
+ },
481
+ source: "review",
482
+ reason: "spec_review_block"
483
+ };
484
+ }
485
+ if (!review.spec.present || review.spec.status === "missing") {
486
+ if (reviewHardDue || reviewSignalsPresent) {
487
+ return {
488
+ status: "review_pending",
489
+ nextAction: `record spec review PASS or WARN for task group ${group.taskGroupId} before quality review`,
490
+ resumeCursor: {
491
+ groupIndex: fallbackCursor.groupIndex,
492
+ nextUncheckedItem: null,
493
+ liveFocus: "spec_review_missing"
494
+ },
495
+ source: "review",
496
+ reason: "spec_review_missing"
497
+ };
498
+ }
499
+ } else {
500
+ const specWarn = review.spec.status === STATUS.WARN;
501
+ if (review.quality.status === STATUS.BLOCK) {
502
+ return {
503
+ status: "blocked",
504
+ nextAction:
505
+ review.quality.issues[0] ||
506
+ review.quality.summary ||
507
+ `resolve quality review BLOCK for task group ${group.taskGroupId}`,
508
+ resumeCursor: {
509
+ groupIndex: fallbackCursor.groupIndex,
510
+ nextUncheckedItem: null,
511
+ liveFocus: "quality_review_block"
512
+ },
513
+ source: "review",
514
+ reason: "quality_review_block"
515
+ };
516
+ }
517
+ if (!review.quality.present || review.quality.status === "missing") {
518
+ if (reviewHardDue) {
519
+ return {
520
+ status: "review_pending",
521
+ nextAction: `record quality review PASS or WARN for task group ${group.taskGroupId}`,
522
+ resumeCursor: {
523
+ groupIndex: fallbackCursor.groupIndex,
524
+ nextUncheckedItem: null,
525
+ liveFocus: "quality_review_missing"
526
+ },
527
+ source: "review",
528
+ reason: "quality_review_missing"
529
+ };
530
+ }
531
+ if (specWarn) {
532
+ return {
533
+ status: "in_progress",
534
+ nextAction:
535
+ review.spec.issues[0] ||
536
+ review.spec.summary ||
537
+ `resolve spec review follow-up for task group ${group.taskGroupId}`,
538
+ resumeCursor: {
539
+ groupIndex: fallbackCursor.groupIndex,
540
+ nextUncheckedItem: null,
541
+ liveFocus: "spec_review_warn"
542
+ },
543
+ source: "review",
544
+ reason: "spec_review_warn"
545
+ };
546
+ }
547
+ } else if (review.quality.status === STATUS.WARN) {
548
+ return {
549
+ status: "in_progress",
550
+ nextAction:
551
+ review.quality.issues[0] ||
552
+ review.quality.summary ||
553
+ `resolve quality review follow-up for task group ${group.taskGroupId}`,
554
+ resumeCursor: {
555
+ groupIndex: fallbackCursor.groupIndex,
556
+ nextUncheckedItem: null,
557
+ liveFocus: "quality_review_warn"
558
+ },
559
+ source: "review",
560
+ reason: "quality_review_warn"
561
+ };
562
+ } else if (specWarn) {
563
+ return {
564
+ status: "in_progress",
565
+ nextAction:
566
+ review.spec.issues[0] ||
567
+ review.spec.summary ||
568
+ `resolve spec review follow-up for task group ${group.taskGroupId}`,
569
+ resumeCursor: {
570
+ groupIndex: fallbackCursor.groupIndex,
571
+ nextUncheckedItem: null,
572
+ liveFocus: "spec_review_warn"
573
+ },
574
+ source: "review",
575
+ reason: "spec_review_warn"
576
+ };
577
+ }
578
+ }
579
+ }
580
+
581
+ if (implementer.present && implementer.signalStatus === STATUS.WARN) {
582
+ return {
583
+ status: "in_progress",
584
+ nextAction:
585
+ implementer.concerns[0] ||
586
+ implementer.summary ||
587
+ `resolve implementer concerns for task group ${group.taskGroupId}`,
588
+ resumeCursor: {
589
+ groupIndex: fallbackCursor.groupIndex,
590
+ nextUncheckedItem: null,
591
+ liveFocus: "implementer_warn"
592
+ },
593
+ source: "implementer",
594
+ reason: "implementer_warn"
595
+ };
596
+ }
597
+
598
+ return effective;
599
+ }
600
+
601
+ /**
602
+ * Overlay runtime implementer/review signals on top of planned task groups.
603
+ *
604
+ * @param {TaskGroupPlan[]} plannedTaskGroups
605
+ * @param {Array<object>} signals
606
+ * @param {Array<object>} seedTaskGroups
607
+ * @param {Array<object> | null} decisionTraceRecords
608
+ * @param {Function | null} recordWorkflowDecision
609
+ * @returns {TaskGroupRuntimeState[]}
610
+ */
611
+ function deriveTaskGroupRuntimeState(
612
+ plannedTaskGroups,
613
+ signals,
614
+ seedTaskGroups,
615
+ decisionTraceRecords,
616
+ recordWorkflowDecision
617
+ ) {
618
+ const plannedGroups = Array.isArray(plannedTaskGroups) ? plannedTaskGroups : [];
619
+ const seedMap = normalizeTaskGroupSeedMap(seedTaskGroups);
620
+
621
+ return plannedGroups.map((plannedGroup, index) => {
622
+ const taskGroupId = normalizeTaskGroupId(plannedGroup.taskGroupId || plannedGroup.id);
623
+ const seed = seedMap.get(taskGroupId) || {};
624
+ const planned = {
625
+ status: plannedGroup.status,
626
+ completion: plannedGroup.completion,
627
+ checkpointOutcome: plannedGroup.checkpointOutcome,
628
+ evidence: Array.isArray(plannedGroup.evidence) ? plannedGroup.evidence : [],
629
+ nextAction: plannedGroup.nextAction,
630
+ resumeCursor: normalizeResumeCursor(plannedGroup.resumeCursor, index)
631
+ };
632
+ const implementer = buildTaskGroupImplementerState(taskGroupId, signals, seed.implementer);
633
+ const review = buildTaskGroupReviewState(plannedGroup, signals, seed.review);
634
+ const effective = buildEffectiveTaskGroupState(plannedGroup, planned, implementer, review);
635
+ if (TRACEABLE_TASK_GROUP_FOCUS_REASONS.has(effective.reason)) {
636
+ recordDecision(recordWorkflowDecision, decisionTraceRecords, {
637
+ decisionFamily: "task_group_focus_resolution",
638
+ decisionKey: effective.reason,
639
+ outcome: "selected_focus",
640
+ reasonSummary: buildTaskGroupFocusReasonSummary(taskGroupId, effective.reason),
641
+ context: {
642
+ taskGroupId,
643
+ plannedStatus: planned.status || null,
644
+ effectiveStatus: effective.status || null,
645
+ liveFocus: effective.resumeCursor ? effective.resumeCursor.liveFocus || null : null,
646
+ nextAction: effective.nextAction || null
647
+ },
648
+ evidenceRefs: buildTaskGroupFocusEvidenceRefs(taskGroupId, effective.reason)
649
+ });
650
+ }
651
+
652
+ return {
653
+ taskGroupId,
654
+ title: plannedGroup.title,
655
+ status: effective.status,
656
+ completion: planned.completion,
657
+ checkpointOutcome: planned.checkpointOutcome,
658
+ evidence: planned.evidence,
659
+ nextAction: effective.nextAction,
660
+ targetFiles: Array.isArray(plannedGroup.targetFiles) ? plannedGroup.targetFiles : [],
661
+ fileReferences: Array.isArray(plannedGroup.fileReferences) ? plannedGroup.fileReferences : [],
662
+ verificationActions: Array.isArray(plannedGroup.verificationActions)
663
+ ? plannedGroup.verificationActions
664
+ : [],
665
+ verificationCommands: Array.isArray(plannedGroup.verificationCommands)
666
+ ? plannedGroup.verificationCommands
667
+ : [],
668
+ executionIntent: Array.isArray(plannedGroup.executionIntent) ? plannedGroup.executionIntent : [],
669
+ reviewIntent: plannedGroup.reviewIntent === true,
670
+ testingIntent: plannedGroup.testingIntent === true,
671
+ codeChangeLikely: plannedGroup.codeChangeLikely === true,
672
+ resumeCursor: effective.resumeCursor,
673
+ planned,
674
+ implementer,
675
+ review,
676
+ effective
677
+ };
678
+ });
679
+ }
680
+
681
+ /**
682
+ * Build the canonical persisted task-group metadata payload written next to workflow snapshots.
683
+ *
684
+ * @param {string} changeId
685
+ * @param {Object.<string, string>} checkpointStatuses
686
+ * @param {Array<object>} taskGroups
687
+ * @returns {{ version: number, changeId: string, checkpointOutcome: string, taskGroups: Array<object>, updatedAt: string }}
688
+ */
689
+ function buildTaskGroupMetadataPayload(changeId, checkpointStatuses, taskGroups) {
690
+ return {
691
+ version: 2,
692
+ changeId,
693
+ checkpointOutcome: checkpointStatuses[CHECKPOINT_LABELS.TASK] || STATUS.WARN,
694
+ taskGroups: Array.isArray(taskGroups) ? taskGroups : [],
695
+ updatedAt: new Date().toISOString()
696
+ };
697
+ }
698
+
699
+ function loadTaskGroupMetadataFromPath(targetPath) {
700
+ if (!targetPath || !pathExists(targetPath)) {
701
+ return null;
702
+ }
703
+ try {
704
+ return JSON.parse(readTextIfExists(targetPath));
705
+ } catch (_error) {
706
+ return null;
707
+ }
708
+ }
709
+
710
+ /**
711
+ * Resolve the task-group seed used when starting from a trusted persisted workflow snapshot.
712
+ *
713
+ * @param {string} projectRoot
714
+ * @param {string} changeId
715
+ * @param {object} persistedRecord
716
+ * @param {TaskGroupPlan[]} plannedTaskGroups
717
+ * @param {Array<object> | null} decisionTraceRecords
718
+ * @param {Function | null} recordWorkflowDecision
719
+ * @returns {TaskGroupSeedResolution}
720
+ */
721
+ function resolvePersistedTaskGroupSeed(
722
+ projectRoot,
723
+ changeId,
724
+ persistedRecord,
725
+ plannedTaskGroups,
726
+ decisionTraceRecords,
727
+ recordWorkflowDecision
728
+ ) {
729
+ const metadataRefs =
730
+ persistedRecord && persistedRecord.metadataRefs && typeof persistedRecord.metadataRefs === "object"
731
+ ? persistedRecord.metadataRefs
732
+ : {};
733
+ const canonicalPath =
734
+ metadataRefs.taskGroupsPath || resolveTaskGroupMetadataPath(projectRoot, changeId);
735
+ const notes = [];
736
+
737
+ if (canonicalPath && pathExists(canonicalPath)) {
738
+ const actualDigest = digestForPath(canonicalPath);
739
+ const expectedDigest = metadataRefs.taskGroupsDigest || null;
740
+ if (expectedDigest && actualDigest && expectedDigest !== actualDigest) {
741
+ const message =
742
+ "Canonical task-group runtime state digest mismatch; rebuilding task-group state from artifacts.";
743
+ notes.push(message);
744
+ recordDecision(recordWorkflowDecision, decisionTraceRecords, {
745
+ decisionFamily: "task_group_seed_fallback",
746
+ decisionKey: TASK_GROUP_SEED_TRACE_KEYS["digest-mismatch"],
747
+ outcome: "fallback",
748
+ reasonSummary: message,
749
+ context: {
750
+ metadataPath: formatPathRef(projectRoot, canonicalPath),
751
+ taskGroupCount: Array.isArray(plannedTaskGroups) ? plannedTaskGroups.length : 0,
752
+ expectedDigestPresent: true
753
+ },
754
+ evidenceRefs: [`state:${formatPathRef(projectRoot, canonicalPath)}`]
755
+ });
756
+ return {
757
+ taskGroups: plannedTaskGroups,
758
+ notes
759
+ };
760
+ }
761
+
762
+ const loaded =
763
+ canonicalPath === resolveTaskGroupMetadataPath(projectRoot, changeId)
764
+ ? readTaskGroupMetadata(projectRoot, changeId)
765
+ : loadTaskGroupMetadataFromPath(canonicalPath);
766
+ if (loaded && Array.isArray(loaded.taskGroups)) {
767
+ return {
768
+ taskGroups: loaded.taskGroups,
769
+ notes
770
+ };
771
+ }
772
+ {
773
+ const message =
774
+ "Canonical task-group runtime state is unreadable; rebuilding task-group state from artifacts.";
775
+ notes.push(message);
776
+ recordDecision(recordWorkflowDecision, decisionTraceRecords, {
777
+ decisionFamily: "task_group_seed_fallback",
778
+ decisionKey: TASK_GROUP_SEED_TRACE_KEYS.unreadable,
779
+ outcome: "fallback",
780
+ reasonSummary: message,
781
+ context: {
782
+ metadataPath: formatPathRef(projectRoot, canonicalPath),
783
+ taskGroupCount: Array.isArray(plannedTaskGroups) ? plannedTaskGroups.length : 0
784
+ },
785
+ evidenceRefs: [`state:${formatPathRef(projectRoot, canonicalPath)}`]
786
+ });
787
+ }
788
+ return {
789
+ taskGroups: plannedTaskGroups,
790
+ notes
791
+ };
792
+ }
793
+
794
+ if (Array.isArray(persistedRecord && persistedRecord.taskGroups) && persistedRecord.taskGroups.length > 0) {
795
+ {
796
+ const message = "Using legacy embedded task-group state as migration fallback.";
797
+ notes.push(message);
798
+ recordDecision(recordWorkflowDecision, decisionTraceRecords, {
799
+ decisionFamily: "task_group_seed_fallback",
800
+ decisionKey: TASK_GROUP_SEED_TRACE_KEYS.legacy,
801
+ outcome: "fallback",
802
+ reasonSummary: message,
803
+ context: {
804
+ metadataPath: canonicalPath ? formatPathRef(projectRoot, canonicalPath) : null,
805
+ taskGroupCount: persistedRecord.taskGroups.length
806
+ },
807
+ evidenceRefs: [`state:${formatPathRef(projectRoot, resolveWorkflowStatePath(projectRoot))}`]
808
+ });
809
+ }
810
+ return {
811
+ taskGroups: persistedRecord.taskGroups,
812
+ notes
813
+ };
814
+ }
815
+
816
+ {
817
+ const message = "Canonical task-group runtime state is missing; rebuilding task-group state from artifacts.";
818
+ notes.push(message);
819
+ recordDecision(recordWorkflowDecision, decisionTraceRecords, {
820
+ decisionFamily: "task_group_seed_fallback",
821
+ decisionKey: TASK_GROUP_SEED_TRACE_KEYS.missing,
822
+ outcome: "fallback",
823
+ reasonSummary: message,
824
+ context: {
825
+ metadataPath: canonicalPath ? formatPathRef(projectRoot, canonicalPath) : null,
826
+ taskGroupCount: Array.isArray(plannedTaskGroups) ? plannedTaskGroups.length : 0
827
+ },
828
+ evidenceRefs:
829
+ canonicalPath && String(canonicalPath).trim()
830
+ ? [`state:${formatPathRef(projectRoot, canonicalPath)}`]
831
+ : []
832
+ });
833
+ }
834
+ return {
835
+ taskGroups: plannedTaskGroups,
836
+ notes
837
+ };
838
+ }
839
+
840
+ /**
841
+ * Select the highest-priority task-group focus for next-step reporting.
842
+ *
843
+ * @param {Array<{ status?: string, resumeCursor?: { groupIndex?: number } }>} taskGroups
844
+ * @returns {TaskGroupRuntimeState | object | null}
845
+ */
846
+ function selectFocusedTaskGroup(taskGroups) {
847
+ const priority = {
848
+ blocked: 0,
849
+ review_pending: 1,
850
+ in_progress: 2,
851
+ pending: 3,
852
+ completed: 4
853
+ };
854
+ const groups = Array.isArray(taskGroups) ? taskGroups : [];
855
+ return groups
856
+ .slice()
857
+ .sort((left, right) => {
858
+ const leftRank = Object.prototype.hasOwnProperty.call(priority, left.status) ? priority[left.status] : 5;
859
+ const rightRank = Object.prototype.hasOwnProperty.call(priority, right.status) ? priority[right.status] : 5;
860
+ if (leftRank !== rightRank) {
861
+ return leftRank - rightRank;
862
+ }
863
+ const leftIndex =
864
+ left && left.resumeCursor && Number.isInteger(left.resumeCursor.groupIndex)
865
+ ? left.resumeCursor.groupIndex
866
+ : Number.MAX_SAFE_INTEGER;
867
+ const rightIndex =
868
+ right && right.resumeCursor && Number.isInteger(right.resumeCursor.groupIndex)
869
+ ? right.resumeCursor.groupIndex
870
+ : Number.MAX_SAFE_INTEGER;
871
+ return leftIndex - rightIndex;
872
+ })[0] || null;
873
+ }
874
+
875
+ module.exports = {
876
+ deriveTaskGroupMetadata,
877
+ deriveTaskGroupRuntimeState,
878
+ buildTaskGroupMetadataPayload,
879
+ resolvePersistedTaskGroupSeed,
880
+ selectFocusedTaskGroup
881
+ };