@xenonbyte/da-vinci-workflow 0.2.5 → 0.2.7

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.
@@ -65,8 +65,37 @@ function normalizeTaskExecutionEnvelope(input = {}) {
65
65
  }
66
66
  const changedFiles = normalizeList(input.changedFiles);
67
67
  const testEvidence = normalizeList(input.testEvidence);
68
+ const pendingTestEvidence = normalizeList(input.pendingTestEvidence);
69
+ const confirmTestEvidenceExecuted = input.confirmTestEvidenceExecuted === true;
68
70
  const concerns = normalizeList(input.concerns);
69
71
  const blockers = normalizeList(input.blockers);
72
+ const outOfScopeWrites = normalizeList(input.outOfScopeWrites);
73
+ const partial = input.partial === true;
74
+
75
+ const overlap = testEvidence.filter((item) => pendingTestEvidence.includes(item));
76
+ if (overlap.length > 0) {
77
+ throw new Error(
78
+ `task-execution cannot mark the same command as both executed and pending: ${overlap.join(", ")}`
79
+ );
80
+ }
81
+
82
+ if (testEvidence.length > 0 && !confirmTestEvidenceExecuted) {
83
+ throw new Error(
84
+ "`task-execution` requires explicit executed-evidence confirmation when `--test-evidence` is provided. Pass `--confirm-test-evidence-executed`."
85
+ );
86
+ }
87
+
88
+ if (pendingTestEvidence.length > 0 && status === "DONE") {
89
+ throw new Error(
90
+ "`task-execution` cannot use status DONE when pending test evidence exists. Use DONE_WITH_CONCERNS, NEEDS_CONTEXT, or BLOCKED."
91
+ );
92
+ }
93
+
94
+ if (partial && status === "DONE") {
95
+ throw new Error(
96
+ "`task-execution` cannot use status DONE when --partial is set. Use DONE_WITH_CONCERNS, NEEDS_CONTEXT, or BLOCKED."
97
+ );
98
+ }
70
99
 
71
100
  return {
72
101
  taskGroupId,
@@ -74,8 +103,12 @@ function normalizeTaskExecutionEnvelope(input = {}) {
74
103
  summary,
75
104
  changedFiles,
76
105
  testEvidence,
106
+ pendingTestEvidence,
107
+ confirmTestEvidenceExecuted,
77
108
  concerns,
78
109
  blockers,
110
+ outOfScopeWrites,
111
+ partial,
79
112
  recordedAt: new Date().toISOString()
80
113
  };
81
114
  }
@@ -93,35 +126,65 @@ function writeTaskExecutionEnvelope(projectPathInput, options = {}) {
93
126
  }
94
127
 
95
128
  const envelope = normalizeTaskExecutionEnvelope(options);
129
+ const envelopeWithIdentity = {
130
+ ...envelope,
131
+ changeId: resolved.changeId
132
+ };
133
+ const signalStatus = mapImplementerStatusToSignal(envelopeWithIdentity.status);
134
+ const outOfScopeWriteWarnings = envelopeWithIdentity.outOfScopeWrites.map(
135
+ (item) => `out-of-scope write: ${item}`
136
+ );
137
+ const pendingTestWarnings = envelopeWithIdentity.pendingTestEvidence.map(
138
+ (item) => `test not executed: ${item}`
139
+ );
96
140
  const signalPath = writeExecutionSignal(projectRoot, {
97
141
  changeId: resolved.changeId,
98
- surface: buildSurface(envelope.taskGroupId),
99
- status: mapImplementerStatusToSignal(envelope.status),
142
+ surface: buildSurface(envelopeWithIdentity.taskGroupId),
143
+ status: signalStatus,
100
144
  advisory: false,
101
145
  strict: true,
102
- failures: envelope.status === "BLOCKED" ? envelope.blockers : [],
146
+ failures: envelopeWithIdentity.status === "BLOCKED" ? envelopeWithIdentity.blockers : [],
103
147
  warnings:
104
- envelope.status === "DONE_WITH_CONCERNS" || envelope.status === "NEEDS_CONTEXT"
105
- ? envelope.concerns
106
- : [],
107
- notes: [envelope.summary, ...envelope.testEvidence.map((item) => `test: ${item}`)],
148
+ envelopeWithIdentity.status === "DONE_WITH_CONCERNS" || envelopeWithIdentity.status === "NEEDS_CONTEXT"
149
+ ? unique([...envelopeWithIdentity.concerns, ...outOfScopeWriteWarnings, ...pendingTestWarnings])
150
+ : unique([...outOfScopeWriteWarnings, ...pendingTestWarnings]),
151
+ notes: [
152
+ envelopeWithIdentity.summary,
153
+ ...envelopeWithIdentity.testEvidence.map((item) => `test: ${item}`)
154
+ ],
108
155
  details: {
109
156
  type: "task_execution",
110
- envelope
157
+ envelope: {
158
+ taskGroupId: envelopeWithIdentity.taskGroupId,
159
+ changeId: envelopeWithIdentity.changeId,
160
+ status: envelopeWithIdentity.status,
161
+ summary: envelopeWithIdentity.summary,
162
+ changedFiles: envelopeWithIdentity.changedFiles,
163
+ testEvidence: envelopeWithIdentity.testEvidence,
164
+ pendingTestEvidence: envelopeWithIdentity.pendingTestEvidence,
165
+ concerns: envelopeWithIdentity.concerns,
166
+ blockers: envelopeWithIdentity.blockers,
167
+ recordedAt: envelopeWithIdentity.recordedAt
168
+ },
169
+ outOfScopeWrites: envelopeWithIdentity.outOfScopeWrites,
170
+ partial: envelopeWithIdentity.partial
111
171
  }
112
172
  });
113
173
 
114
174
  return {
115
- status: mapImplementerStatusToSignal(envelope.status),
175
+ status: signalStatus,
116
176
  projectRoot,
117
177
  changeId: resolved.changeId,
118
- taskGroupId: envelope.taskGroupId,
119
- implementerStatus: envelope.status,
120
- summary: envelope.summary,
121
- changedFiles: envelope.changedFiles,
122
- testEvidence: envelope.testEvidence,
123
- concerns: envelope.concerns,
124
- blockers: envelope.blockers,
178
+ taskGroupId: envelopeWithIdentity.taskGroupId,
179
+ implementerStatus: envelopeWithIdentity.status,
180
+ summary: envelopeWithIdentity.summary,
181
+ changedFiles: envelopeWithIdentity.changedFiles,
182
+ testEvidence: envelopeWithIdentity.testEvidence,
183
+ pendingTestEvidence: envelopeWithIdentity.pendingTestEvidence,
184
+ concerns: envelopeWithIdentity.concerns,
185
+ blockers: envelopeWithIdentity.blockers,
186
+ outOfScopeWrites: envelopeWithIdentity.outOfScopeWrites,
187
+ partial: envelopeWithIdentity.partial,
125
188
  signalPath
126
189
  };
127
190
  }
@@ -143,12 +206,21 @@ function formatTaskExecutionReport(result) {
143
206
  if (result.testEvidence.length > 0) {
144
207
  lines.push(`Test evidence: ${result.testEvidence.join(", ")}`);
145
208
  }
209
+ if (result.pendingTestEvidence.length > 0) {
210
+ lines.push(`Pending test evidence: ${result.pendingTestEvidence.join(", ")}`);
211
+ }
146
212
  if (result.concerns.length > 0) {
147
213
  lines.push(`Concerns: ${result.concerns.join(", ")}`);
148
214
  }
149
215
  if (result.blockers.length > 0) {
150
216
  lines.push(`Blockers: ${result.blockers.join(", ")}`);
151
217
  }
218
+ if (result.outOfScopeWrites.length > 0) {
219
+ lines.push(`Out-of-scope writes: ${result.outOfScopeWrites.join(", ")}`);
220
+ }
221
+ if (result.partial) {
222
+ lines.push("Partial: true");
223
+ }
152
224
  return lines.join("\n");
153
225
  }
154
226
 
@@ -123,25 +123,31 @@ function writeTaskReviewEnvelope(projectPathInput, options = {}) {
123
123
 
124
124
  if (envelope.stage === "quality") {
125
125
  const specSignal = findLatestTaskReviewSignal(existingSignals, envelope.taskGroupId, "spec");
126
- if (!specSignal || String(specSignal.status || "").toUpperCase() !== "PASS") {
126
+ const specStatus = specSignal ? String(specSignal.status || "").toUpperCase() : "";
127
+ if (!specSignal || specStatus !== "PASS") {
127
128
  throw new Error(
128
129
  `Quality review for task group ${envelope.taskGroupId} requires a prior spec review PASS.`
129
130
  );
130
131
  }
131
132
  }
132
133
 
134
+ const envelopeWithIdentity = {
135
+ ...envelope,
136
+ changeId: resolved.changeId
137
+ };
138
+
133
139
  const signalPath = writeExecutionSignal(projectRoot, {
134
140
  changeId: resolved.changeId,
135
- surface: buildTaskReviewSurface(envelope.taskGroupId, envelope.stage),
136
- status: mapReviewStatusToSignalStatus(envelope.status),
141
+ surface: buildTaskReviewSurface(envelopeWithIdentity.taskGroupId, envelopeWithIdentity.stage),
142
+ status: mapReviewStatusToSignalStatus(envelopeWithIdentity.status),
137
143
  advisory: false,
138
144
  strict: true,
139
- failures: envelope.status === "BLOCK" ? envelope.issues : [],
140
- warnings: envelope.status === "WARN" ? envelope.issues : [],
141
- notes: [envelope.summary, `reviewer: ${envelope.reviewer}`],
145
+ failures: envelopeWithIdentity.status === "BLOCK" ? envelopeWithIdentity.issues : [],
146
+ warnings: envelopeWithIdentity.status === "WARN" ? envelopeWithIdentity.issues : [],
147
+ notes: [envelopeWithIdentity.summary, `reviewer: ${envelopeWithIdentity.reviewer}`],
142
148
  details: {
143
149
  type: "task_review",
144
- envelope
150
+ envelope: envelopeWithIdentity
145
151
  }
146
152
  });
147
153
 
@@ -149,7 +155,7 @@ function writeTaskReviewEnvelope(projectPathInput, options = {}) {
149
155
  if (options.writeVerification === true) {
150
156
  verificationPath = path.join(resolved.changeDir, "verification.md");
151
157
  fs.mkdirSync(path.dirname(verificationPath), { recursive: true });
152
- const nextVerification = appendTaskReviewEvidence(readTextIfExists(verificationPath), envelope);
158
+ const nextVerification = appendTaskReviewEvidence(readTextIfExists(verificationPath), envelopeWithIdentity);
153
159
  writeFileAtomic(verificationPath, nextVerification);
154
160
  }
155
161
 
package/lib/utils.js CHANGED
@@ -49,6 +49,20 @@ function uniqueValues(values) {
49
49
  return Array.from(new Set((values || []).filter(Boolean)));
50
50
  }
51
51
 
52
+ function dedupeMessages(items, options = {}) {
53
+ const normalize =
54
+ typeof options.normalize === "function"
55
+ ? options.normalize
56
+ : (value) => value;
57
+ return Array.from(
58
+ new Set(
59
+ (Array.isArray(items) ? items : [])
60
+ .map((item) => normalize(item))
61
+ .filter(Boolean)
62
+ )
63
+ );
64
+ }
65
+
52
66
  function parseJsonText(raw, context = "JSON payload") {
53
67
  try {
54
68
  return JSON.parse(String(raw));
@@ -140,6 +154,7 @@ module.exports = {
140
154
  normalizeRelativePath,
141
155
  pathWithinRoot,
142
156
  uniqueValues,
157
+ dedupeMessages,
143
158
  parseJsonText,
144
159
  readJsonFile,
145
160
  sleepSync,
@@ -1,8 +1,11 @@
1
1
  const fs = require("fs");
2
2
  const path = require("path");
3
+ const crypto = require("crypto");
3
4
  const { writeFileAtomic, pathExists, readTextIfExists } = require("./utils");
4
5
 
5
- const WORKFLOW_STATE_VERSION = 2;
6
+ // Route snapshot schema version; task-group metadata keeps its own version in workflow-state.js.
7
+ const WORKFLOW_STATE_VERSION = 3;
8
+ const SUPPORTED_WORKFLOW_STATE_VERSIONS = new Set([2, WORKFLOW_STATE_VERSION]);
6
9
  const DEFAULT_STALE_WINDOW_MS = 24 * 60 * 60 * 1000;
7
10
  const PERSISTED_NOTE_EXCLUDE_PATTERNS = [
8
11
  /^No persisted workflow state found for this change; deriving from artifacts\.$/i,
@@ -19,16 +22,29 @@ function resolveTaskGroupMetadataPath(projectRoot, changeId) {
19
22
  return path.join(projectRoot, ".da-vinci", "state", "task-groups", `${changeId}.json`);
20
23
  }
21
24
 
25
+ function hashContent(value) {
26
+ return crypto.createHash("sha256").update(value).digest("hex");
27
+ }
28
+
29
+ function digestForPath(targetPath) {
30
+ if (!pathExists(targetPath)) {
31
+ return null;
32
+ }
33
+ try {
34
+ return hashContent(fs.readFileSync(targetPath));
35
+ } catch (_error) {
36
+ return null;
37
+ }
38
+ }
39
+
22
40
  function fingerprintForPath(targetPath) {
23
41
  if (!pathExists(targetPath)) {
24
42
  return null;
25
43
  }
26
44
  try {
27
- const stat = fs.statSync(targetPath);
28
45
  return {
29
46
  path: targetPath,
30
- mtimeMs: stat.mtimeMs,
31
- size: stat.size
47
+ digest: digestForPath(targetPath)
32
48
  };
33
49
  } catch (_error) {
34
50
  return null;
@@ -46,8 +62,7 @@ function buildWorkflowFingerprint(projectRoot, changeId) {
46
62
  path.join(changeRoot, "pencil-design.md"),
47
63
  path.join(changeRoot, "pencil-bindings.md"),
48
64
  path.join(changeRoot, "tasks.md"),
49
- path.join(changeRoot, "verification.md"),
50
- path.join(changeRoot, "workflow.json")
65
+ path.join(changeRoot, "verification.md")
51
66
  ];
52
67
 
53
68
  const specRoot = path.join(changeRoot, "specs");
@@ -96,8 +111,7 @@ function stableFingerprintHash(fingerprint) {
96
111
  return JSON.stringify(
97
112
  (fingerprint || []).map((entry) => ({
98
113
  path: entry.path,
99
- mtimeMs: entry.mtimeMs,
100
- size: entry.size
114
+ digest: entry.digest
101
115
  }))
102
116
  );
103
117
  }
@@ -163,7 +177,7 @@ function selectPersistedStateForChange(projectRoot, changeId, options = {}) {
163
177
  };
164
178
  }
165
179
 
166
- if (loaded.state.version !== WORKFLOW_STATE_VERSION) {
180
+ if (!SUPPORTED_WORKFLOW_STATE_VERSIONS.has(Number(loaded.state.version))) {
167
181
  return {
168
182
  usable: false,
169
183
  reason: "version-mismatch",
@@ -183,25 +197,6 @@ function selectPersistedStateForChange(projectRoot, changeId, options = {}) {
183
197
  };
184
198
  }
185
199
 
186
- const persistedAt = Date.parse(String(changeRecord.persistedAt || ""));
187
- if (!Number.isFinite(persistedAt)) {
188
- return {
189
- usable: false,
190
- reason: "invalid-timestamp",
191
- statePath: loaded.statePath,
192
- persisted: loaded.state
193
- };
194
- }
195
-
196
- if (Date.now() - persistedAt > staleWindowMs) {
197
- return {
198
- usable: false,
199
- reason: "time-stale",
200
- statePath: loaded.statePath,
201
- persisted: loaded.state
202
- };
203
- }
204
-
205
200
  const currentFingerprint = buildWorkflowFingerprint(projectRoot, changeId);
206
201
  const currentHash = stableFingerprintHash(currentFingerprint);
207
202
  const persistedHash = String(changeRecord.fingerprintHash || "");
@@ -219,7 +214,20 @@ function selectPersistedStateForChange(projectRoot, changeId, options = {}) {
219
214
  reason: null,
220
215
  statePath: loaded.statePath,
221
216
  persisted: loaded.state,
222
- changeRecord
217
+ changeRecord,
218
+ advisoryNotes:
219
+ (() => {
220
+ const persistedAt = Date.parse(String(changeRecord.persistedAt || ""));
221
+ if (!Number.isFinite(persistedAt)) {
222
+ return [];
223
+ }
224
+ if (Date.now() - persistedAt > staleWindowMs) {
225
+ return [
226
+ "Persisted workflow state is older than the historical stale-time threshold but remains usable because artifact content digests still match."
227
+ ];
228
+ }
229
+ return [];
230
+ })()
223
231
  };
224
232
  }
225
233
 
@@ -234,6 +242,14 @@ function persistDerivedWorkflowResult(projectRoot, changeId, workflowResult, opt
234
242
  }
235
243
 
236
244
  const fingerprint = buildWorkflowFingerprint(projectRoot, changeId);
245
+ const metadataRefsNormalized =
246
+ metadataRefs && typeof metadataRefs === "object"
247
+ ? {
248
+ ...metadataRefs,
249
+ taskGroupsPath: metadataRefs.taskGroupsPath || null,
250
+ taskGroupsDigest: metadataRefs.taskGroupsDigest || null
251
+ }
252
+ : {};
237
253
  changes[changeId] = {
238
254
  stage: workflowResult.stage,
239
255
  checkpointState: workflowResult.checkpointState,
@@ -243,8 +259,7 @@ function persistDerivedWorkflowResult(projectRoot, changeId, workflowResult, opt
243
259
  failures: workflowResult.failures,
244
260
  warnings: workflowResult.warnings,
245
261
  notes: sanitizePersistedNotes(workflowResult.notes),
246
- taskGroups: workflowResult.taskGroups || null,
247
- metadataRefs,
262
+ metadataRefs: metadataRefsNormalized,
248
263
  fingerprintHash: stableFingerprintHash(fingerprint),
249
264
  persistedAt: new Date().toISOString()
250
265
  };
@@ -264,7 +279,10 @@ function writeTaskGroupMetadata(projectRoot, changeId, metadataPayload) {
264
279
  const targetPath = resolveTaskGroupMetadataPath(projectRoot, changeId);
265
280
  fs.mkdirSync(path.dirname(targetPath), { recursive: true });
266
281
  writeFileAtomic(targetPath, `${JSON.stringify(metadataPayload, null, 2)}\n`);
267
- return targetPath;
282
+ return {
283
+ path: targetPath,
284
+ digest: digestForPath(targetPath)
285
+ };
268
286
  }
269
287
 
270
288
  function readTaskGroupMetadata(projectRoot, changeId) {
@@ -284,11 +302,13 @@ function readTaskGroupMetadata(projectRoot, changeId) {
284
302
 
285
303
  module.exports = {
286
304
  WORKFLOW_STATE_VERSION,
305
+ SUPPORTED_WORKFLOW_STATE_VERSIONS,
287
306
  DEFAULT_STALE_WINDOW_MS,
288
307
  resolveWorkflowStatePath,
289
308
  resolveTaskGroupMetadataPath,
290
309
  buildWorkflowFingerprint,
291
310
  stableFingerprintHash,
311
+ digestForPath,
292
312
  sanitizePersistedNotes,
293
313
  readPersistedWorkflowState,
294
314
  writePersistedWorkflowState,