@xenonbyte/da-vinci-workflow 0.1.25 → 0.1.26

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 (59) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +39 -10
  3. package/README.zh-CN.md +28 -10
  4. package/SKILL.md +3 -0
  5. package/commands/claude/dv/design.md +1 -0
  6. package/commands/codex/prompts/dv-design.md +1 -0
  7. package/commands/gemini/dv/design.toml +1 -0
  8. package/docs/dv-command-reference.md +14 -2
  9. package/docs/pencil-rendering-workflow.md +9 -7
  10. package/docs/prompt-presets/README.md +4 -0
  11. package/docs/visual-assist-presets/README.md +4 -0
  12. package/docs/workflow-examples.md +13 -11
  13. package/docs/workflow-overview.md +2 -0
  14. package/docs/zh-CN/dv-command-reference.md +14 -2
  15. package/docs/zh-CN/pencil-rendering-workflow.md +9 -7
  16. package/docs/zh-CN/prompt-presets/README.md +5 -1
  17. package/docs/zh-CN/visual-assist-presets/README.md +5 -1
  18. package/docs/zh-CN/workflow-examples.md +13 -11
  19. package/docs/zh-CN/workflow-overview.md +2 -0
  20. package/examples/greenfield-spec-markupflow/README.md +6 -1
  21. package/lib/async-offload-worker.js +26 -0
  22. package/lib/async-offload.js +82 -0
  23. package/lib/audit-parsers.js +152 -32
  24. package/lib/audit.js +84 -23
  25. package/lib/cli.js +749 -433
  26. package/lib/fs-safety.js +1 -4
  27. package/lib/icon-aliases.js +7 -7
  28. package/lib/icon-search.js +21 -14
  29. package/lib/icon-sync.js +220 -41
  30. package/lib/install.js +128 -60
  31. package/lib/mcp-runtime-gate.js +4 -7
  32. package/lib/pen-persistence.js +318 -46
  33. package/lib/pencil-lock.js +237 -25
  34. package/lib/pencil-preflight.js +233 -12
  35. package/lib/pencil-session.js +216 -36
  36. package/lib/supervisor-review.js +56 -34
  37. package/lib/utils.js +121 -0
  38. package/lib/workflow-bootstrap.js +255 -0
  39. package/package.json +13 -3
  40. package/references/checkpoints.md +2 -0
  41. package/references/design-inputs.md +2 -0
  42. package/references/pencil-design-to-code.md +2 -0
  43. package/scripts/fixtures/complex-sample.pen +0 -295
  44. package/scripts/fixtures/mock-pencil.js +0 -49
  45. package/scripts/test-audit-context-delta.js +0 -446
  46. package/scripts/test-audit-design-supervisor.js +0 -691
  47. package/scripts/test-audit-safety.js +0 -92
  48. package/scripts/test-icon-aliases.js +0 -96
  49. package/scripts/test-icon-search.js +0 -77
  50. package/scripts/test-icon-sync.js +0 -178
  51. package/scripts/test-mcp-runtime-gate.js +0 -287
  52. package/scripts/test-mode-consistency.js +0 -344
  53. package/scripts/test-pen-persistence.js +0 -403
  54. package/scripts/test-pencil-lock.js +0 -130
  55. package/scripts/test-pencil-preflight.js +0 -169
  56. package/scripts/test-pencil-session.js +0 -192
  57. package/scripts/test-persistence-flows.js +0 -345
  58. package/scripts/test-supervisor-review-cli.js +0 -619
  59. package/scripts/test-supervisor-review-integration.js +0 -115
@@ -1,9 +1,13 @@
1
1
  const fs = require("fs");
2
2
  const path = require("path");
3
+ const { readJsonFile } = require("./utils");
3
4
  const {
4
5
  ensurePenFile,
5
6
  writePenFromPayloadFiles,
6
7
  comparePenSync,
8
+ comparePenBaselineAlignment,
9
+ formatPenBaselineAlignmentReport,
10
+ syncPenSource,
7
11
  writeJsonFileAtomic
8
12
  } = require("./pen-persistence");
9
13
  const {
@@ -11,6 +15,7 @@ const {
11
15
  releasePencilLock,
12
16
  getPencilLockStatus
13
17
  } = require("./pencil-lock");
18
+ const { runModuleExportInWorker } = require("./async-offload");
14
19
 
15
20
  function resolveProjectRoot(projectPath) {
16
21
  return path.resolve(projectPath || process.cwd());
@@ -25,7 +30,7 @@ function readSessionState(projectPath) {
25
30
  if (!fs.existsSync(sessionStatePath)) {
26
31
  return null;
27
32
  }
28
- return JSON.parse(fs.readFileSync(sessionStatePath, "utf8"));
33
+ return readJsonFile(sessionStatePath, `Pencil session state JSON at ${sessionStatePath}`);
29
34
  }
30
35
 
31
36
  function writeSessionState(projectPath, payload) {
@@ -59,42 +64,170 @@ function assertLockHeldByProject(projectPath, options = {}) {
59
64
  return status;
60
65
  }
61
66
 
67
+ function releaseLockQuietly(projectPath, options = {}) {
68
+ try {
69
+ return releasePencilLock({
70
+ projectPath,
71
+ homeDir: options.homeDir,
72
+ force: options.force
73
+ });
74
+ } catch (error) {
75
+ return {
76
+ lockPath: null,
77
+ released: false,
78
+ hadLock: null,
79
+ lock: null,
80
+ releaseError: error
81
+ };
82
+ }
83
+ }
84
+
85
+ function createSessionLifecycleError(message, cause, extra = {}) {
86
+ const wrapped = new Error(message);
87
+ if (cause && typeof cause === "object") {
88
+ wrapped.cause = cause;
89
+ }
90
+ Object.assign(wrapped, extra);
91
+ return wrapped;
92
+ }
93
+
62
94
  function beginPencilSession(options) {
63
95
  const projectRoot = resolveProjectRoot(options.projectPath);
64
- const ensureResult = ensurePenFile({
65
- outputPath: options.penPath,
66
- version: options.version,
67
- verifyWithPencil: options.verifyWithPencil,
68
- pencilBin: options.pencilBin
69
- });
96
+ const baselinePaths = Array.isArray(options.baselinePaths) ? options.baselinePaths : [];
97
+ const hasBaselinePaths = baselinePaths.length > 0;
98
+ const hasPreferredSource = Boolean(options.preferredSource);
99
+ const shouldSyncPreferredSource = options.syncPreferredSource === true;
100
+
101
+ if (hasPreferredSource && !hasBaselinePaths) {
102
+ throw new Error(
103
+ "Baseline preference requires comparison paths. Provide at least one `--baseline <path>`."
104
+ );
105
+ }
106
+
107
+ if (shouldSyncPreferredSource && !hasPreferredSource) {
108
+ throw new Error("`--sync-preferred-source` requires `--prefer-source <path>`.");
109
+ }
110
+
111
+ if (shouldSyncPreferredSource && !hasBaselinePaths) {
112
+ throw new Error(
113
+ "`--sync-preferred-source` requires baseline comparison input. Provide `--baseline <path>`."
114
+ );
115
+ }
116
+
70
117
  const lockResult = acquirePencilLock({
71
118
  projectPath: projectRoot,
72
119
  owner: options.owner,
73
120
  waitMs: options.waitMs,
74
121
  homeDir: options.homeDir
75
122
  });
123
+ let ensureResult = null;
124
+ let baselineCheck = null;
125
+ let baselineSync = null;
76
126
 
77
- const session = buildSessionState(projectRoot, {
78
- status: "active",
79
- beganAt: new Date().toISOString(),
80
- penPath: path.resolve(options.penPath),
81
- lockPath: lockResult.lockPath,
82
- lockOwner: lockResult.lock.owner,
83
- penStatePath: ensureResult.statePath,
84
- lastPersistedHash: ensureResult.state.snapshotHash,
85
- lastPersistedAt: ensureResult.state.persistedAt,
86
- lastTopLevelCount: ensureResult.state.topLevelCount
87
- });
88
- const sessionStatePath = writeSessionState(projectRoot, session);
127
+ try {
128
+ ensureResult = ensurePenFile({
129
+ outputPath: options.penPath,
130
+ version: options.version,
131
+ verifyWithPencil: options.verifyWithPencil,
132
+ pencilBin: options.pencilBin
133
+ });
89
134
 
90
- return {
91
- projectRoot,
92
- penPath: ensureResult.outputPath,
93
- session,
94
- sessionStatePath,
95
- ensureResult,
96
- lockResult
97
- };
135
+ if (hasBaselinePaths) {
136
+ baselineCheck = comparePenBaselineAlignment({
137
+ penPath: ensureResult.outputPath,
138
+ baselinePaths,
139
+ preferredSource: options.preferredSource
140
+ });
141
+
142
+ if (
143
+ baselineCheck.status === "BLOCK" &&
144
+ baselineCheck.decision === "diverged_prefer_external" &&
145
+ options.syncPreferredSource === true &&
146
+ baselineCheck.preferredSourcePath
147
+ ) {
148
+ baselineSync = syncPenSource({
149
+ sourcePath: baselineCheck.preferredSourcePath,
150
+ targetPath: ensureResult.outputPath,
151
+ stateSource: "pencil-session:begin-sync"
152
+ });
153
+ ensureResult = {
154
+ ...ensureResult,
155
+ statePath: baselineSync.statePath,
156
+ state: baselineSync.state
157
+ };
158
+ baselineCheck = comparePenBaselineAlignment({
159
+ penPath: ensureResult.outputPath,
160
+ baselinePaths,
161
+ preferredSource: options.preferredSource
162
+ });
163
+ }
164
+
165
+ if (baselineCheck.status === "BLOCK") {
166
+ throw new Error(
167
+ [
168
+ "Pencil baseline alignment failed before session start.",
169
+ formatPenBaselineAlignmentReport(baselineCheck)
170
+ ].join("\n")
171
+ );
172
+ }
173
+ }
174
+
175
+ const beganAt = new Date().toISOString();
176
+ const session = buildSessionState(projectRoot, {
177
+ status: "active",
178
+ beganAt,
179
+ lastActivityAt: beganAt,
180
+ penPath: path.resolve(options.penPath),
181
+ lockPath: lockResult.lockPath,
182
+ lockOwner: lockResult.lock.owner,
183
+ penStatePath: ensureResult.statePath,
184
+ lastPersistedHash: ensureResult.state.snapshotHash,
185
+ lastPersistedAt: ensureResult.state.persistedAt,
186
+ lastTopLevelCount: ensureResult.state.topLevelCount,
187
+ baselineCheck: baselineCheck
188
+ ? {
189
+ checkedAt: new Date().toISOString(),
190
+ status: baselineCheck.status,
191
+ decision: baselineCheck.decision,
192
+ preferredSourcePath: baselineCheck.preferredSourcePath,
193
+ baselinePaths: baselineCheck.baselinePaths,
194
+ inSync: baselineCheck.inSync
195
+ }
196
+ : null,
197
+ baselineSync: baselineSync
198
+ ? {
199
+ syncedAt: new Date().toISOString(),
200
+ sourcePath: baselineSync.sourcePath,
201
+ targetPath: baselineSync.targetPath,
202
+ snapshotHash: baselineSync.state.snapshotHash
203
+ }
204
+ : null
205
+ });
206
+ const sessionStatePath = writeSessionState(projectRoot, session);
207
+
208
+ return {
209
+ projectRoot,
210
+ penPath: ensureResult.outputPath,
211
+ session,
212
+ sessionStatePath,
213
+ ensureResult,
214
+ lockResult
215
+ };
216
+ } catch (error) {
217
+ const releaseResult = releaseLockQuietly(projectRoot, {
218
+ homeDir: options.homeDir
219
+ });
220
+ if (releaseResult.releaseError) {
221
+ throw createSessionLifecycleError(
222
+ `${error.message}\nAlso failed to release lock during begin rollback: ${releaseResult.releaseError.message}`,
223
+ error,
224
+ {
225
+ rollbackReleaseError: releaseResult.releaseError
226
+ }
227
+ );
228
+ }
229
+ throw error;
230
+ }
98
231
  }
99
232
 
100
233
  function persistPencilSession(options) {
@@ -132,6 +265,7 @@ function persistPencilSession(options) {
132
265
  version: options.version
133
266
  });
134
267
 
268
+ const persistedAt = new Date().toISOString();
135
269
  const updatedSession = buildSessionState(projectRoot, {
136
270
  ...session,
137
271
  status: "active",
@@ -140,7 +274,8 @@ function persistPencilSession(options) {
140
274
  lastPersistedHash: writeResult.state.snapshotHash,
141
275
  lastPersistedAt: writeResult.state.persistedAt,
142
276
  lastTopLevelCount: writeResult.state.topLevelCount,
143
- lastSyncVerifiedAt: new Date().toISOString(),
277
+ lastSyncVerifiedAt: persistedAt,
278
+ lastActivityAt: persistedAt,
144
279
  inSync: syncResult.inSync
145
280
  });
146
281
  const sessionStatePath = writeSessionState(projectRoot, updatedSession);
@@ -163,6 +298,8 @@ function endPencilSession(options) {
163
298
  throw new Error("No Pencil session state exists for this project. Run `pencil-session begin` first.");
164
299
  }
165
300
 
301
+ assertLockHeldByProject(projectRoot, { homeDir: options.homeDir });
302
+
166
303
  const resolvedPenPath = path.resolve(options.penPath || session.penPath || "");
167
304
  if (!resolvedPenPath) {
168
305
  throw new Error("A registered `.pen` path is required for session shutdown.");
@@ -188,21 +325,44 @@ function endPencilSession(options) {
188
325
  }
189
326
  }
190
327
 
191
- const releaseResult = releasePencilLock({
192
- projectPath: projectRoot,
193
- homeDir: options.homeDir,
194
- force: options.force
195
- });
328
+ const endedWithForce = options.force === true;
329
+ const forceWithoutSync = endedWithForce && !syncResult;
196
330
 
331
+ const endedAt = new Date().toISOString();
197
332
  const updatedSession = buildSessionState(projectRoot, {
198
333
  ...session,
199
334
  status: "closed",
200
335
  penPath: resolvedPenPath,
201
- endedAt: new Date().toISOString(),
202
- lastSyncVerifiedAt: syncResult ? new Date().toISOString() : session.lastSyncVerifiedAt || null,
336
+ endedAt,
337
+ endedWithForce,
338
+ forceWithoutSync,
339
+ lastSyncVerifiedAt: syncResult ? endedAt : session.lastSyncVerifiedAt || null,
340
+ lastActivityAt: endedAt,
203
341
  inSync: syncResult ? syncResult.inSync : session.inSync === true
204
342
  });
205
343
  const sessionStatePath = writeSessionState(projectRoot, updatedSession);
344
+ let releaseResult;
345
+ try {
346
+ releaseResult = releasePencilLock({
347
+ projectPath: projectRoot,
348
+ homeDir: options.homeDir,
349
+ force: options.force
350
+ });
351
+ } catch (releaseError) {
352
+ let rollbackError = null;
353
+ try {
354
+ writeSessionState(projectRoot, session);
355
+ } catch (error) {
356
+ rollbackError = error;
357
+ }
358
+ throw createSessionLifecycleError(
359
+ rollbackError
360
+ ? `Failed to release Pencil lock while ending session: ${releaseError.message}\nAlso failed to restore active session state: ${rollbackError.message}`
361
+ : `Failed to release Pencil lock while ending session: ${releaseError.message}`,
362
+ releaseError,
363
+ rollbackError ? { sessionRollbackError: rollbackError } : {}
364
+ );
365
+ }
206
366
 
207
367
  return {
208
368
  projectRoot,
@@ -224,12 +384,32 @@ function getPencilSessionStatus(options = {}) {
224
384
  };
225
385
  }
226
386
 
387
+ function beginPencilSessionAsync(options = {}) {
388
+ return runModuleExportInWorker(__filename, "beginPencilSession", [options]);
389
+ }
390
+
391
+ function persistPencilSessionAsync(options = {}) {
392
+ return runModuleExportInWorker(__filename, "persistPencilSession", [options]);
393
+ }
394
+
395
+ function endPencilSessionAsync(options = {}) {
396
+ return runModuleExportInWorker(__filename, "endPencilSession", [options]);
397
+ }
398
+
399
+ function getPencilSessionStatusAsync(options = {}) {
400
+ return runModuleExportInWorker(__filename, "getPencilSessionStatus", [options]);
401
+ }
402
+
227
403
  module.exports = {
228
404
  getSessionStatePath,
229
405
  readSessionState,
230
406
  writeSessionState,
231
407
  beginPencilSession,
408
+ beginPencilSessionAsync,
232
409
  persistPencilSession,
410
+ persistPencilSessionAsync,
233
411
  endPencilSession,
234
- getPencilSessionStatus
412
+ endPencilSessionAsync,
413
+ getPencilSessionStatus,
414
+ getPencilSessionStatusAsync
235
415
  };
@@ -3,6 +3,17 @@ const path = require("path");
3
3
  const os = require("os");
4
4
  const { execFile } = require("child_process");
5
5
  const {
6
+ escapeRegExp,
7
+ isPlainObject,
8
+ parseJsonText,
9
+ pathExists,
10
+ readTextIfExists,
11
+ writeFileAtomic
12
+ } = require("./utils");
13
+ const {
14
+ hasMarkdownHeading,
15
+ getMarkdownSection,
16
+ isAcceptedWarnOutcome,
6
17
  parseCheckpointStatusMap,
7
18
  getConfiguredDesignSupervisorReviewers
8
19
  } = require("./audit-parsers");
@@ -14,14 +25,8 @@ const REVIEW_STATUS_SEVERITY = Object.freeze({
14
25
  WARN: 1,
15
26
  BLOCK: 2
16
27
  });
17
-
18
- function pathExists(targetPath) {
19
- return fs.existsSync(targetPath);
20
- }
21
-
22
- function escapeRegExp(value) {
23
- return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
24
- }
28
+ const DEFAULT_REVIEWER_EXEC_MAX_BUFFER = 50 * 1024 * 1024;
29
+ const DEFAULT_REVIEWER_RETRY_MAX_DELAY_MS = 5000;
25
30
 
26
31
  function normalizeStatus(status) {
27
32
  if (!status) {
@@ -46,13 +51,6 @@ function normalizeReviewSource(source) {
46
51
  return normalized;
47
52
  }
48
53
 
49
- function readTextIfExists(filePath) {
50
- if (!pathExists(filePath)) {
51
- return "";
52
- }
53
- return fs.readFileSync(filePath, "utf8");
54
- }
55
-
56
54
  function parseReviewerList(value) {
57
55
  return String(value || "")
58
56
  .split(/[,\n;]/)
@@ -114,7 +112,13 @@ function buildReviewerPrompt({ reviewer, projectRoot, changeId, pencilDesignPath
114
112
  }
115
113
 
116
114
  function parseReviewerPayload(rawText) {
117
- const payload = JSON.parse(String(rawText || "").trim());
115
+ const payload = parseJsonText(String(rawText || "").trim(), "reviewer JSON payload");
116
+ if (!isPlainObject(payload)) {
117
+ throw new Error("Invalid reviewer JSON payload: expected a JSON object.");
118
+ }
119
+ if (!payload.status) {
120
+ throw new Error("Invalid reviewer JSON payload: missing required `status`.");
121
+ }
118
122
  const status = normalizeStatus(payload.status);
119
123
  const issues = Array.isArray(payload.issues)
120
124
  ? payload.issues.map((issue) => String(issue || "").trim()).filter(Boolean)
@@ -161,8 +165,14 @@ async function runReviewerWithCodexOnce(options = {}) {
161
165
  changeId,
162
166
  pencilDesignPath,
163
167
  screenshotPaths,
164
- timeoutMs
168
+ timeoutMs,
169
+ maxBuffer
165
170
  } = options;
171
+ const configuredMaxBuffer = Number(maxBuffer);
172
+ const resolvedMaxBuffer =
173
+ Number.isFinite(configuredMaxBuffer) && configuredMaxBuffer > 0
174
+ ? configuredMaxBuffer
175
+ : DEFAULT_REVIEWER_EXEC_MAX_BUFFER;
166
176
  const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "da-vinci-supervisor-runner-"));
167
177
  const outputPath = path.join(tmpDir, "review.json");
168
178
  const schemaPath = path.join(tmpDir, "schema.json");
@@ -224,7 +234,7 @@ async function runReviewerWithCodexOnce(options = {}) {
224
234
  try {
225
235
  await execFileAsync(codexBin, args, {
226
236
  encoding: "utf8",
227
- maxBuffer: 8 * 1024 * 1024,
237
+ maxBuffer: resolvedMaxBuffer,
228
238
  timeout: timeoutValue > 0 ? timeoutValue : undefined
229
239
  });
230
240
  } catch (error) {
@@ -249,6 +259,12 @@ async function runReviewerWithCodex(options = {}) {
249
259
  const reviewer = String(options.reviewer || "").trim();
250
260
  const retries = normalizePositiveInt(options.retries, 1, 0, 8);
251
261
  const retryDelayMs = normalizePositiveInt(options.retryDelayMs, 400, 0, 60000);
262
+ const retryMaxDelayMs = normalizePositiveInt(
263
+ options.retryMaxDelayMs,
264
+ DEFAULT_REVIEWER_RETRY_MAX_DELAY_MS,
265
+ 0,
266
+ 60000
267
+ );
252
268
  const maxAttempts = retries + 1;
253
269
  let attempt = 0;
254
270
  let lastError = null;
@@ -266,7 +282,7 @@ async function runReviewerWithCodex(options = {}) {
266
282
  if (attempt >= maxAttempts) {
267
283
  break;
268
284
  }
269
- const backoffDelay = retryDelayMs * Math.pow(2, attempt - 1);
285
+ const backoffDelay = Math.min(retryDelayMs * Math.pow(2, attempt - 1), retryMaxDelayMs);
270
286
  await sleep(backoffDelay);
271
287
  }
272
288
  }
@@ -369,14 +385,12 @@ function resolvePencilDesignPath(projectRoot, options = {}) {
369
385
  }
370
386
 
371
387
  function extractMcpRuntimeGateStatus(markdownText) {
372
- const sectionMatch = String(markdownText || "").match(
373
- /(?:^|\n)##\s+MCP Runtime Gate\s*\n([\s\S]*?)(?=\n##\s+|\s*$)/i
374
- );
375
- if (!sectionMatch) {
388
+ const section = getMarkdownSection(markdownText, "MCP Runtime Gate");
389
+ if (!section) {
376
390
  return "";
377
391
  }
378
392
 
379
- const statusMatch = String(sectionMatch[1]).match(
393
+ const statusMatch = String(section).match(
380
394
  /(?:^|\n)\s*-\s*(?:Status|状态)\s*:\s*`?(PASS|WARN|BLOCK)`?\b/i
381
395
  );
382
396
  return statusMatch ? String(statusMatch[1]).toUpperCase() : "";
@@ -396,13 +410,11 @@ function inferReview(markdownText, options = {}) {
396
410
  const designCheckpointStatus =
397
411
  checkpointStatuses["design checkpoint"] || extractCheckpointStatus(markdownText, "Design checkpoint");
398
412
  const runtimeGateStatus = extractMcpRuntimeGateStatus(markdownText);
399
- const hasScreenshotRecords = /(?:^|\n)##\s+Screenshot Review Records\b/i.test(
400
- String(markdownText || "")
401
- );
413
+ const hasScreenshotRecords = hasMarkdownHeading(markdownText, "Screenshot Review Records");
402
414
  let status = "PASS";
403
415
 
404
416
  if (!hasScreenshotRecords) {
405
- issues.push("Missing `## Screenshot Review Records` evidence.");
417
+ issues.push("Missing `Screenshot Review Records` evidence.");
406
418
  status = "BLOCK";
407
419
  }
408
420
 
@@ -446,9 +458,7 @@ function inferReview(markdownText, options = {}) {
446
458
  }
447
459
 
448
460
  function hasAcceptedWarn(revisionOutcome) {
449
- return /(accepted|accepted with follow-up|accepted warning|warn accepted|接受|已接受|接受警告)/i.test(
450
- String(revisionOutcome || "")
451
- );
461
+ return isAcceptedWarnOutcome(revisionOutcome);
452
462
  }
453
463
 
454
464
  function buildReviewSection(review) {
@@ -505,6 +515,12 @@ async function runDesignSupervisorReview(options = {}) {
505
515
  const reviewConcurrency = normalizePositiveInt(options.reviewConcurrency, 2, 1, 16);
506
516
  const reviewerRetries = normalizePositiveInt(options.reviewerRetries, 1, 0, 8);
507
517
  const reviewerRetryDelayMs = normalizePositiveInt(options.reviewerRetryDelayMs, 400, 0, 60000);
518
+ const reviewerRetryMaxDelayMs = normalizePositiveInt(
519
+ options.reviewerRetryMaxDelayMs,
520
+ DEFAULT_REVIEWER_RETRY_MAX_DELAY_MS,
521
+ 0,
522
+ 60000
523
+ );
508
524
  const manualStatus = normalizeStatus(options.status);
509
525
  const reviewerAttempts = {};
510
526
  let review = null;
@@ -531,10 +547,11 @@ async function runDesignSupervisorReview(options = {}) {
531
547
  pencilDesignPath,
532
548
  screenshotPaths,
533
549
  timeoutMs: options.reviewerTimeoutMs,
550
+ maxBuffer: options.reviewerMaxBuffer,
534
551
  retries: reviewerRetries,
535
- retryDelayMs: reviewerRetryDelayMs
552
+ retryDelayMs: reviewerRetryDelayMs,
553
+ retryMaxDelayMs: reviewerRetryMaxDelayMs
536
554
  });
537
- reviewerAttempts[reviewer] = finding.attempts || 1;
538
555
  return {
539
556
  reviewer,
540
557
  ...finding
@@ -542,6 +559,9 @@ async function runDesignSupervisorReview(options = {}) {
542
559
  },
543
560
  reviewConcurrency
544
561
  );
562
+ for (const finding of findings) {
563
+ reviewerAttempts[finding.reviewer] = finding.attempts || 1;
564
+ }
545
565
 
546
566
  const aggregated = aggregateReviewerFindings(findings, {
547
567
  acceptWarn: options.acceptWarn
@@ -608,7 +628,7 @@ async function runDesignSupervisorReview(options = {}) {
608
628
  if (options.write) {
609
629
  fs.mkdirSync(path.dirname(pencilDesignPath), { recursive: true });
610
630
  const nextText = appendReviewSection(existingText, review);
611
- fs.writeFileSync(pencilDesignPath, nextText);
631
+ writeFileAtomic(pencilDesignPath, nextText);
612
632
  wrote = true;
613
633
  }
614
634
 
@@ -627,6 +647,7 @@ async function runDesignSupervisorReview(options = {}) {
627
647
  reviewConcurrency: runReviewers ? reviewConcurrency : 0,
628
648
  reviewerRetries: runReviewers ? reviewerRetries : 0,
629
649
  reviewerRetryDelayMs: runReviewers ? reviewerRetryDelayMs : 0,
650
+ reviewerRetryMaxDelayMs: runReviewers ? reviewerRetryMaxDelayMs : 0,
630
651
  reviewerAttempts: runReviewers ? reviewerAttempts : {},
631
652
  wrote
632
653
  };
@@ -657,6 +678,7 @@ function formatDesignSupervisorReviewReport(result) {
657
678
  lines.push(`Review concurrency: ${result.reviewConcurrency}`);
658
679
  lines.push(`Reviewer retries: ${result.reviewerRetries}`);
659
680
  lines.push(`Reviewer retry delay (ms): ${result.reviewerRetryDelayMs}`);
681
+ lines.push(`Reviewer retry max delay (ms): ${result.reviewerRetryMaxDelayMs}`);
660
682
  const reviewerAttemptTokens = Object.entries(result.reviewerAttempts || {}).map(
661
683
  ([reviewer, attempts]) => `${reviewer}:${attempts}`
662
684
  );
package/lib/utils.js ADDED
@@ -0,0 +1,121 @@
1
+ const fs = require("fs");
2
+ const path = require("path");
3
+
4
+ const HAS_SYNC_WAIT =
5
+ typeof SharedArrayBuffer === "function" &&
6
+ typeof Atomics === "object" &&
7
+ Atomics !== null &&
8
+ typeof Atomics.wait === "function";
9
+ const SYNC_SLEEP_ARRAY = HAS_SYNC_WAIT ? new Int32Array(new SharedArrayBuffer(4)) : null;
10
+
11
+ function isPlainObject(value) {
12
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
13
+ }
14
+
15
+ function escapeRegExp(value) {
16
+ return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
17
+ }
18
+
19
+ function pathExists(targetPath) {
20
+ return fs.existsSync(targetPath);
21
+ }
22
+
23
+ function readTextIfExists(targetPath, options = {}) {
24
+ const encoding = options.encoding || "utf8";
25
+ if (!pathExists(targetPath)) {
26
+ return "";
27
+ }
28
+ return fs.readFileSync(targetPath, encoding);
29
+ }
30
+
31
+ function uniqueValues(values) {
32
+ return Array.from(new Set((values || []).filter(Boolean)));
33
+ }
34
+
35
+ function parseJsonText(raw, context = "JSON payload") {
36
+ try {
37
+ return JSON.parse(String(raw));
38
+ } catch (error) {
39
+ const wrapped = new Error(
40
+ `Invalid ${context}: ${error && error.message ? error.message : String(error)}`
41
+ );
42
+ if (error && typeof error === "object") {
43
+ wrapped.cause = error;
44
+ }
45
+ throw wrapped;
46
+ }
47
+ }
48
+
49
+ function readJsonFile(targetPath, context = null) {
50
+ const resolvedTargetPath = path.resolve(targetPath);
51
+ const label = context || `JSON file at ${resolvedTargetPath}`;
52
+ return parseJsonText(fs.readFileSync(resolvedTargetPath, "utf8"), label);
53
+ }
54
+
55
+ function sleepSync(durationMs, context = "perform synchronous sleep") {
56
+ const timeoutMs = Math.max(0, Number(durationMs) || 0);
57
+ if (timeoutMs === 0) {
58
+ return;
59
+ }
60
+
61
+ if (!HAS_SYNC_WAIT || !SYNC_SLEEP_ARRAY) {
62
+ throw new Error(`Unable to ${context}: Atomics.wait is unavailable in this runtime.`);
63
+ }
64
+
65
+ try {
66
+ Atomics.wait(SYNC_SLEEP_ARRAY, 0, 0, timeoutMs);
67
+ } catch (error) {
68
+ const wrapped = new Error(
69
+ `Unable to ${context}: ${error && error.message ? error.message : String(error)}`
70
+ );
71
+ if (error && typeof error === "object") {
72
+ wrapped.cause = error;
73
+ }
74
+ throw wrapped;
75
+ }
76
+ }
77
+
78
+ function buildAtomicTempPath(targetPath) {
79
+ const resolvedTargetPath = path.resolve(targetPath);
80
+ return path.join(
81
+ path.dirname(resolvedTargetPath),
82
+ `.${path.basename(resolvedTargetPath)}.tmp-${process.pid}-${Date.now()}-${Math.random()
83
+ .toString(16)
84
+ .slice(2)}`
85
+ );
86
+ }
87
+
88
+ function writeFileAtomic(targetPath, content, options = {}) {
89
+ const resolvedTargetPath = path.resolve(targetPath);
90
+ const encoding = options.encoding || "utf8";
91
+ const tempPath = buildAtomicTempPath(resolvedTargetPath);
92
+ fs.mkdirSync(path.dirname(resolvedTargetPath), { recursive: true });
93
+ fs.writeFileSync(tempPath, content, encoding);
94
+ fs.renameSync(tempPath, resolvedTargetPath);
95
+ }
96
+
97
+ function writeFileExclusiveAtomic(targetPath, content, options = {}) {
98
+ const resolvedTargetPath = path.resolve(targetPath);
99
+ const encoding = options.encoding || "utf8";
100
+ const tempPath = buildAtomicTempPath(resolvedTargetPath);
101
+ fs.mkdirSync(path.dirname(resolvedTargetPath), { recursive: true });
102
+ fs.writeFileSync(tempPath, content, encoding);
103
+ try {
104
+ fs.linkSync(tempPath, resolvedTargetPath);
105
+ } finally {
106
+ fs.rmSync(tempPath, { force: true });
107
+ }
108
+ }
109
+
110
+ module.exports = {
111
+ isPlainObject,
112
+ escapeRegExp,
113
+ pathExists,
114
+ readTextIfExists,
115
+ uniqueValues,
116
+ parseJsonText,
117
+ readJsonFile,
118
+ sleepSync,
119
+ writeFileAtomic,
120
+ writeFileExclusiveAtomic
121
+ };