@xenonbyte/da-vinci-workflow 0.1.16 → 0.1.17

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.
@@ -1,6 +1,7 @@
1
1
  const fs = require("fs");
2
2
  const os = require("os");
3
3
  const path = require("path");
4
+ const crypto = require("crypto");
4
5
  const { spawnSync } = require("child_process");
5
6
 
6
7
  const DEFAULT_PEN_VERSION = "2.9";
@@ -151,6 +152,23 @@ function normalizeVariablesPayload(payload) {
151
152
  throw new Error("Variables payload must be an object or an object with a `variables` key.");
152
153
  }
153
154
 
155
+ function canonicalizeJson(value) {
156
+ if (Array.isArray(value)) {
157
+ return value.map((item) => canonicalizeJson(item));
158
+ }
159
+
160
+ if (!isPlainObject(value)) {
161
+ return value;
162
+ }
163
+
164
+ return Object.keys(value)
165
+ .sort()
166
+ .reduce((accumulator, key) => {
167
+ accumulator[key] = canonicalizeJson(value[key]);
168
+ return accumulator;
169
+ }, {});
170
+ }
171
+
154
172
  function buildPenDocument({ version = DEFAULT_PEN_VERSION, nodes, variables }) {
155
173
  const document = {
156
174
  version,
@@ -165,6 +183,28 @@ function buildPenDocument({ version = DEFAULT_PEN_VERSION, nodes, variables }) {
165
183
  return document;
166
184
  }
167
185
 
186
+ function hashPenDocument(document) {
187
+ return crypto
188
+ .createHash("sha256")
189
+ .update(JSON.stringify(canonicalizeJson(document)))
190
+ .digest("hex");
191
+ }
192
+
193
+ function getStandardPenStatePath(outputPath) {
194
+ const targetPath = path.resolve(outputPath);
195
+ const marker = `${path.sep}.da-vinci${path.sep}designs${path.sep}`;
196
+ const markerIndex = targetPath.indexOf(marker);
197
+
198
+ if (markerIndex < 0) {
199
+ return `${targetPath}.meta.json`;
200
+ }
201
+
202
+ const projectRoot = targetPath.slice(0, markerIndex);
203
+ const relativePenPath = targetPath.slice(markerIndex + marker.length);
204
+ const stateFileName = `${relativePenPath.split(path.sep).join("__")}.json`;
205
+ return path.join(projectRoot, ".da-vinci", "state", "pens", stateFileName);
206
+ }
207
+
168
208
  function writePenDocumentAtomic(outputPath, document) {
169
209
  const targetPath = path.resolve(outputPath);
170
210
  const targetDir = path.dirname(targetPath);
@@ -179,6 +219,47 @@ function writePenDocumentAtomic(outputPath, document) {
179
219
  return targetPath;
180
220
  }
181
221
 
222
+ function writeJsonFileAtomic(outputPath, payload) {
223
+ const targetPath = path.resolve(outputPath);
224
+ const targetDir = path.dirname(targetPath);
225
+ const tempPath = path.join(
226
+ targetDir,
227
+ `.${path.basename(targetPath)}.tmp-${process.pid}-${Date.now()}`
228
+ );
229
+
230
+ fs.mkdirSync(targetDir, { recursive: true });
231
+ fs.writeFileSync(tempPath, JSON.stringify(payload, null, 2) + "\n", "utf8");
232
+ fs.renameSync(tempPath, targetPath);
233
+ return targetPath;
234
+ }
235
+
236
+ function buildPenState(outputPath, document, options = {}) {
237
+ return {
238
+ schema: 1,
239
+ source: options.source || "write-pen",
240
+ penPath: path.resolve(outputPath),
241
+ version: document.version || DEFAULT_PEN_VERSION,
242
+ snapshotHash: hashPenDocument(document),
243
+ topLevelIds: Array.isArray(document.children) ? document.children.map((node) => node.id) : [],
244
+ topLevelCount: Array.isArray(document.children) ? document.children.length : 0,
245
+ persistedAt: options.persistedAt || new Date().toISOString()
246
+ };
247
+ }
248
+
249
+ function writePenStateAtomic(outputPath, state) {
250
+ const statePath = getStandardPenStatePath(outputPath);
251
+ writeJsonFileAtomic(statePath, state);
252
+ return statePath;
253
+ }
254
+
255
+ function readPenState(outputPath) {
256
+ const statePath = getStandardPenStatePath(outputPath);
257
+ if (!fs.existsSync(statePath)) {
258
+ return null;
259
+ }
260
+ return readJsonPayload(statePath);
261
+ }
262
+
182
263
  function runPencilInteractive(inputPath, commands, options = {}) {
183
264
  const pencilBin = options.pencilBin || "pencil";
184
265
  const unusedOutput = path.join(
@@ -245,6 +326,8 @@ function writePenFromPayloadFiles(options) {
245
326
  });
246
327
 
247
328
  writePenDocumentAtomic(outputPath, document);
329
+ const state = buildPenState(outputPath, document, { source: "write-pen" });
330
+ const statePath = writePenStateAtomic(outputPath, state);
248
331
 
249
332
  const verification = options.verifyWithPencil
250
333
  ? verifyPenFileWithPencil(outputPath, {
@@ -256,7 +339,9 @@ function writePenFromPayloadFiles(options) {
256
339
  return {
257
340
  outputPath,
258
341
  document,
259
- verification
342
+ verification,
343
+ state,
344
+ statePath
260
345
  };
261
346
  }
262
347
 
@@ -293,6 +378,8 @@ function snapshotPenFile(options) {
293
378
  });
294
379
 
295
380
  writePenDocumentAtomic(outputPath, document);
381
+ const state = buildPenState(outputPath, document, { source: "snapshot-pen" });
382
+ const statePath = writePenStateAtomic(outputPath, state);
296
383
 
297
384
  const verification = options.verifyWithPencil
298
385
  ? verifyPenFileWithPencil(outputPath, {
@@ -305,7 +392,98 @@ function snapshotPenFile(options) {
305
392
  inputPath,
306
393
  outputPath,
307
394
  document,
308
- verification
395
+ verification,
396
+ state,
397
+ statePath
398
+ };
399
+ }
400
+
401
+ function readPenDocument(inputPath) {
402
+ return readJsonPayload(inputPath);
403
+ }
404
+
405
+ function ensurePenFile(options) {
406
+ const outputPath = path.resolve(options.outputPath);
407
+ const exists = fs.existsSync(outputPath);
408
+ let document;
409
+ let created = false;
410
+
411
+ if (exists) {
412
+ document = readPenDocument(outputPath);
413
+ if (!Array.isArray(document.children)) {
414
+ throw new Error(`Existing .pen file is invalid or missing \`children\`: ${outputPath}`);
415
+ }
416
+ } else {
417
+ document = buildPenDocument({
418
+ version: options.version || DEFAULT_PEN_VERSION,
419
+ nodes: { nodes: [] },
420
+ variables: {}
421
+ });
422
+ writePenDocumentAtomic(outputPath, document);
423
+ created = true;
424
+ }
425
+
426
+ const state = buildPenState(outputPath, document, {
427
+ source: created ? "ensure-pen:create" : "ensure-pen:existing"
428
+ });
429
+ const statePath = writePenStateAtomic(outputPath, state);
430
+
431
+ const verification = options.verifyWithPencil
432
+ ? verifyPenFileWithPencil(outputPath, {
433
+ pencilBin: options.pencilBin,
434
+ expectedTopLevelIds: document.children.map((node) => node.id)
435
+ })
436
+ : null;
437
+
438
+ return {
439
+ outputPath,
440
+ created,
441
+ document,
442
+ verification,
443
+ state,
444
+ statePath
445
+ };
446
+ }
447
+
448
+ function hashPayloadFiles(options) {
449
+ const nodes = readJsonPayload(options.nodesFile);
450
+ const variables = options.variablesFile ? readJsonPayload(options.variablesFile) : undefined;
451
+ const document = buildPenDocument({
452
+ version: options.version || DEFAULT_PEN_VERSION,
453
+ nodes,
454
+ variables
455
+ });
456
+
457
+ return {
458
+ document,
459
+ snapshotHash: hashPenDocument(document)
460
+ };
461
+ }
462
+
463
+ function comparePenSync(options) {
464
+ const penPath = path.resolve(options.penPath);
465
+ if (!fs.existsSync(penPath)) {
466
+ throw new Error(`Registered .pen file does not exist: ${penPath}`);
467
+ }
468
+
469
+ const persistedDocument = readPenDocument(penPath);
470
+ const live = hashPayloadFiles({
471
+ ...options,
472
+ version: options.version || persistedDocument.version || DEFAULT_PEN_VERSION
473
+ });
474
+ const persistedState = readPenState(penPath);
475
+ const persistedHash = persistedState && persistedState.snapshotHash
476
+ ? String(persistedState.snapshotHash)
477
+ : hashPenDocument(persistedDocument);
478
+
479
+ return {
480
+ penPath,
481
+ statePath: getStandardPenStatePath(penPath),
482
+ persistedHash,
483
+ liveHash: live.snapshotHash,
484
+ inSync: persistedHash === live.snapshotHash,
485
+ usedStateFile: Boolean(persistedState && persistedState.snapshotHash),
486
+ state: persistedState
309
487
  };
310
488
  }
311
489
 
@@ -316,11 +494,22 @@ module.exports = {
316
494
  readJsonPayload,
317
495
  normalizeNodesPayload,
318
496
  normalizeVariablesPayload,
497
+ canonicalizeJson,
319
498
  buildPenDocument,
499
+ hashPenDocument,
500
+ getStandardPenStatePath,
320
501
  writePenDocumentAtomic,
502
+ writeJsonFileAtomic,
503
+ buildPenState,
504
+ writePenStateAtomic,
505
+ readPenState,
321
506
  runPencilInteractive,
322
507
  verifyPenFileWithPencil,
323
508
  writePenFromPayloadFiles,
324
509
  capturePenSnapshot,
325
- snapshotPenFile
510
+ snapshotPenFile,
511
+ readPenDocument,
512
+ ensurePenFile,
513
+ hashPayloadFiles,
514
+ comparePenSync
326
515
  };
@@ -0,0 +1,128 @@
1
+ const fs = require("fs");
2
+ const os = require("os");
3
+ const path = require("path");
4
+
5
+ function getLockPath(options = {}) {
6
+ const homeDir = options.homeDir ? path.resolve(options.homeDir) : os.homedir();
7
+ return path.join(homeDir, ".da-vinci", "pencil-mcp.lock");
8
+ }
9
+
10
+ function readLock(lockPath) {
11
+ const resolvedLockPath = path.resolve(lockPath);
12
+ if (!fs.existsSync(resolvedLockPath)) {
13
+ return null;
14
+ }
15
+
16
+ return JSON.parse(fs.readFileSync(resolvedLockPath, "utf8"));
17
+ }
18
+
19
+ function writeLock(lockPath, payload) {
20
+ const resolvedLockPath = path.resolve(lockPath);
21
+ fs.mkdirSync(path.dirname(resolvedLockPath), { recursive: true });
22
+ const handle = fs.openSync(resolvedLockPath, "wx");
23
+
24
+ try {
25
+ fs.writeFileSync(handle, JSON.stringify(payload, null, 2) + "\n", "utf8");
26
+ } finally {
27
+ fs.closeSync(handle);
28
+ }
29
+ }
30
+
31
+ function acquirePencilLock(options = {}) {
32
+ const projectPath = path.resolve(options.projectPath || process.cwd());
33
+ const owner = options.owner || path.basename(projectPath);
34
+ const waitMs = Number.isFinite(Number(options.waitMs)) ? Number(options.waitMs) : 0;
35
+ const pollIntervalMs = Math.max(50, Number(options.pollIntervalMs) || 100);
36
+ const lockPath = getLockPath(options);
37
+ const deadline = Date.now() + Math.max(0, waitMs);
38
+ const payload = {
39
+ schema: 1,
40
+ projectPath,
41
+ owner,
42
+ pid: process.pid,
43
+ acquiredAt: new Date().toISOString()
44
+ };
45
+
46
+ while (true) {
47
+ try {
48
+ writeLock(lockPath, payload);
49
+ return {
50
+ lockPath,
51
+ acquired: true,
52
+ alreadyHeld: false,
53
+ lock: payload
54
+ };
55
+ } catch (error) {
56
+ if (error.code !== "EEXIST") {
57
+ throw error;
58
+ }
59
+
60
+ const current = readLock(lockPath);
61
+ if (current && current.projectPath === projectPath) {
62
+ return {
63
+ lockPath,
64
+ acquired: true,
65
+ alreadyHeld: true,
66
+ lock: current
67
+ };
68
+ }
69
+
70
+ if (Date.now() >= deadline) {
71
+ const holder = current
72
+ ? `${current.projectPath} (owner: ${current.owner || "unknown"}, pid: ${current.pid || "unknown"})`
73
+ : "unknown holder";
74
+ throw new Error(`Pencil MCP lock is already held by ${holder}.`);
75
+ }
76
+
77
+ const sleepUntil = Date.now() + pollIntervalMs;
78
+ while (Date.now() < sleepUntil) {
79
+ // Busy wait to keep the CLI synchronous without additional runtime dependencies.
80
+ }
81
+ }
82
+ }
83
+ }
84
+
85
+ function releasePencilLock(options = {}) {
86
+ const projectPath = options.projectPath ? path.resolve(options.projectPath) : "";
87
+ const lockPath = getLockPath(options);
88
+ const current = readLock(lockPath);
89
+
90
+ if (!current) {
91
+ return {
92
+ lockPath,
93
+ released: false,
94
+ hadLock: false,
95
+ lock: null
96
+ };
97
+ }
98
+
99
+ if (projectPath && current.projectPath !== projectPath && !options.force) {
100
+ throw new Error(
101
+ `Pencil MCP lock is held by a different project: ${current.projectPath}`
102
+ );
103
+ }
104
+
105
+ fs.rmSync(lockPath, { force: true });
106
+ return {
107
+ lockPath,
108
+ released: true,
109
+ hadLock: true,
110
+ lock: current
111
+ };
112
+ }
113
+
114
+ function getPencilLockStatus(options = {}) {
115
+ const lockPath = getLockPath(options);
116
+ return {
117
+ lockPath,
118
+ lock: readLock(lockPath)
119
+ };
120
+ }
121
+
122
+ module.exports = {
123
+ getLockPath,
124
+ readLock,
125
+ acquirePencilLock,
126
+ releasePencilLock,
127
+ getPencilLockStatus
128
+ };
@@ -0,0 +1,229 @@
1
+ const fs = require("fs");
2
+ const path = require("path");
3
+ const {
4
+ ensurePenFile,
5
+ writePenFromPayloadFiles,
6
+ comparePenSync,
7
+ writeJsonFileAtomic
8
+ } = require("./pen-persistence");
9
+ const {
10
+ acquirePencilLock,
11
+ releasePencilLock,
12
+ getPencilLockStatus
13
+ } = require("./pencil-lock");
14
+
15
+ function resolveProjectRoot(projectPath) {
16
+ return path.resolve(projectPath || process.cwd());
17
+ }
18
+
19
+ function getSessionStatePath(projectPath) {
20
+ return path.join(resolveProjectRoot(projectPath), ".da-vinci", "state", "pencil-session.json");
21
+ }
22
+
23
+ function readSessionState(projectPath) {
24
+ const sessionStatePath = getSessionStatePath(projectPath);
25
+ if (!fs.existsSync(sessionStatePath)) {
26
+ return null;
27
+ }
28
+ return JSON.parse(fs.readFileSync(sessionStatePath, "utf8"));
29
+ }
30
+
31
+ function writeSessionState(projectPath, payload) {
32
+ const sessionStatePath = getSessionStatePath(projectPath);
33
+ writeJsonFileAtomic(sessionStatePath, payload);
34
+ return sessionStatePath;
35
+ }
36
+
37
+ function buildSessionState(projectPath, payload) {
38
+ return {
39
+ schema: 1,
40
+ projectRoot: resolveProjectRoot(projectPath),
41
+ ...payload
42
+ };
43
+ }
44
+
45
+ function assertLockHeldByProject(projectPath, options = {}) {
46
+ const status = getPencilLockStatus(options);
47
+ const expectedProjectRoot = resolveProjectRoot(projectPath);
48
+
49
+ if (!status.lock) {
50
+ throw new Error("No Pencil MCP lock is currently held for this project.");
51
+ }
52
+
53
+ if (status.lock.projectPath !== expectedProjectRoot) {
54
+ throw new Error(
55
+ `Pencil MCP lock is held by a different project: ${status.lock.projectPath}`
56
+ );
57
+ }
58
+
59
+ return status;
60
+ }
61
+
62
+ function beginPencilSession(options) {
63
+ 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
+ });
70
+ const lockResult = acquirePencilLock({
71
+ projectPath: projectRoot,
72
+ owner: options.owner,
73
+ waitMs: options.waitMs,
74
+ homeDir: options.homeDir
75
+ });
76
+
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);
89
+
90
+ return {
91
+ projectRoot,
92
+ penPath: ensureResult.outputPath,
93
+ session,
94
+ sessionStatePath,
95
+ ensureResult,
96
+ lockResult
97
+ };
98
+ }
99
+
100
+ function persistPencilSession(options) {
101
+ const projectRoot = resolveProjectRoot(options.projectPath);
102
+ const session = readSessionState(projectRoot);
103
+ assertLockHeldByProject(projectRoot, { homeDir: options.homeDir });
104
+
105
+ if (!session) {
106
+ throw new Error("No Pencil session state exists for this project. Run `pencil-session begin` first.");
107
+ }
108
+
109
+ const resolvedPenPath = path.resolve(options.penPath || session.penPath || "");
110
+ if (!resolvedPenPath) {
111
+ throw new Error("A registered `.pen` path is required for session persistence.");
112
+ }
113
+
114
+ if (session.penPath && path.resolve(session.penPath) !== resolvedPenPath) {
115
+ throw new Error(
116
+ `Session is bound to ${session.penPath}, but persist was requested for ${resolvedPenPath}.`
117
+ );
118
+ }
119
+
120
+ const writeResult = writePenFromPayloadFiles({
121
+ outputPath: resolvedPenPath,
122
+ nodesFile: options.nodesFile,
123
+ variablesFile: options.variablesFile,
124
+ version: options.version,
125
+ verifyWithPencil: options.verifyWithPencil,
126
+ pencilBin: options.pencilBin
127
+ });
128
+ const syncResult = comparePenSync({
129
+ penPath: resolvedPenPath,
130
+ nodesFile: options.nodesFile,
131
+ variablesFile: options.variablesFile,
132
+ version: options.version
133
+ });
134
+
135
+ const updatedSession = buildSessionState(projectRoot, {
136
+ ...session,
137
+ status: "active",
138
+ penPath: resolvedPenPath,
139
+ penStatePath: writeResult.statePath,
140
+ lastPersistedHash: writeResult.state.snapshotHash,
141
+ lastPersistedAt: writeResult.state.persistedAt,
142
+ lastTopLevelCount: writeResult.state.topLevelCount,
143
+ lastSyncVerifiedAt: new Date().toISOString(),
144
+ inSync: syncResult.inSync
145
+ });
146
+ const sessionStatePath = writeSessionState(projectRoot, updatedSession);
147
+
148
+ return {
149
+ projectRoot,
150
+ penPath: resolvedPenPath,
151
+ session: updatedSession,
152
+ sessionStatePath,
153
+ writeResult,
154
+ syncResult
155
+ };
156
+ }
157
+
158
+ function endPencilSession(options) {
159
+ const projectRoot = resolveProjectRoot(options.projectPath);
160
+ const session = readSessionState(projectRoot);
161
+
162
+ if (!session) {
163
+ throw new Error("No Pencil session state exists for this project. Run `pencil-session begin` first.");
164
+ }
165
+
166
+ const resolvedPenPath = path.resolve(options.penPath || session.penPath || "");
167
+ if (!resolvedPenPath) {
168
+ throw new Error("A registered `.pen` path is required for session shutdown.");
169
+ }
170
+
171
+ let syncResult = null;
172
+ if (options.nodesFile) {
173
+ syncResult = comparePenSync({
174
+ penPath: resolvedPenPath,
175
+ nodesFile: options.nodesFile,
176
+ variablesFile: options.variablesFile,
177
+ version: options.version
178
+ });
179
+
180
+ if (!syncResult.inSync) {
181
+ throw new Error("Cannot end Pencil session while the live MCP snapshot is out of sync with disk.");
182
+ }
183
+ }
184
+
185
+ const releaseResult = releasePencilLock({
186
+ projectPath: projectRoot,
187
+ homeDir: options.homeDir,
188
+ force: options.force
189
+ });
190
+
191
+ const updatedSession = buildSessionState(projectRoot, {
192
+ ...session,
193
+ status: "closed",
194
+ penPath: resolvedPenPath,
195
+ endedAt: new Date().toISOString(),
196
+ lastSyncVerifiedAt: syncResult ? new Date().toISOString() : session.lastSyncVerifiedAt || null,
197
+ inSync: syncResult ? syncResult.inSync : session.inSync === true
198
+ });
199
+ const sessionStatePath = writeSessionState(projectRoot, updatedSession);
200
+
201
+ return {
202
+ projectRoot,
203
+ penPath: resolvedPenPath,
204
+ session: updatedSession,
205
+ sessionStatePath,
206
+ syncResult,
207
+ releaseResult
208
+ };
209
+ }
210
+
211
+ function getPencilSessionStatus(options = {}) {
212
+ const projectRoot = resolveProjectRoot(options.projectPath);
213
+ return {
214
+ projectRoot,
215
+ sessionStatePath: getSessionStatePath(projectRoot),
216
+ session: readSessionState(projectRoot),
217
+ lockStatus: getPencilLockStatus({ homeDir: options.homeDir })
218
+ };
219
+ }
220
+
221
+ module.exports = {
222
+ getSessionStatePath,
223
+ readSessionState,
224
+ writeSessionState,
225
+ beginPencilSession,
226
+ persistPencilSession,
227
+ endPencilSession,
228
+ getPencilSessionStatus
229
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xenonbyte/da-vinci-workflow",
3
- "version": "0.1.16",
3
+ "version": "0.1.17",
4
4
  "description": "Requirement-to-design-to-code workflow skill for Codex, Claude, and Gemini",
5
5
  "bin": {
6
6
  "da-vinci": "bin/da-vinci.js"
@@ -23,6 +23,8 @@
23
23
  "postinstall": "node scripts/postinstall.js",
24
24
  "validate-assets": "node scripts/validate-assets.js",
25
25
  "test:mcp-runtime-gate": "node scripts/test-mcp-runtime-gate.js",
26
+ "test:persistence-flows": "node scripts/test-persistence-flows.js",
27
+ "test:pencil-session": "node scripts/test-pencil-session.js",
26
28
  "test:pencil-preflight": "node scripts/test-pencil-preflight.js",
27
29
  "test:pen-persistence": "node scripts/test-pen-persistence.js"
28
30
  },
@@ -146,8 +146,9 @@ Check:
146
146
  - when shell access is available, `da-vinci audit --mode integrity <project-path>` ran after the first successful Pencil write and before broad expansion moved past the first approved anchor
147
147
  - if Pencil MCP only exposed a live document, the workflow reconstructed and wrote the registered project-local `.pen` file from MCP-readable document data before continuing
148
148
  - the workflow did not treat headless interactive `save()` alone as sufficient persistence evidence; the registered `.pen` was written from MCP-readable snapshot data or other verified filesystem truth
149
- - if no registered project-local `.pen` existed at the start of the redesign pass, the first approved live snapshot was written to the registered path before broad expansion continued
149
+ - if no registered project-local `.pen` existed at the start of the redesign pass, the workflow seeded the registered `.pen` before the first Pencil edit and kept later live work bound to that path
150
150
  - if a registered project-local `.pen` already existed, material live edits were persisted back to that same path from the current MCP snapshot before the workflow claimed the design source was up to date
151
+ - the current live Pencil snapshot hash matches the last persisted project-local `.pen` snapshot hash before completion claims
151
152
  - `.da-vinci/designs/` is being used cleanly for project-local `.pen` files rather than mixed with workflow markdown, screenshots, or other exports
152
153
  - exported screenshots are stored under `.da-vinci/changes/<change-id>/exports/` and are not being used as a substitute for the `.pen` source
153
154
  - `design-registry.md`, `pencil-design.md`, and `pencil-bindings.md` describe the same active project-local `.pen` source clearly enough to map and implement from
@@ -169,7 +170,9 @@ Run inside the active design session when Pencil MCP is available:
169
170
  Check:
170
171
 
171
172
  - the active editor is not still an unnamed live document such as `new`
173
+ - the workflow did not keep using an empty `filePath` after a registered `.pen` existed
172
174
  - the active editor, registered project-local `.pen` path, and shell-visible `.pen` file are converged strongly enough to trust the runtime source
175
+ - completion-stage runtime evidence includes an explicit live-to-disk sync verification for the registered `.pen`
173
176
  - claimed anchor ids exist in the active live editor
174
177
  - claimed reviewed screens and screenshot targets exist in the active live editor
175
178
  - screenshot-reviewed surfaces were not treated as approved while blocker-level review findings were ignored
@@ -184,7 +187,9 @@ Result meanings:
184
187
  Automatic failures:
185
188
 
186
189
  - if the active editor is still `new`, treat the runtime gate as `BLOCK`
190
+ - if a registered `.pen` exists but Pencil writes still used an empty `filePath`, treat the runtime gate as `BLOCK`
187
191
  - if live anchor surfaces exist only in the current editor while no shell-visible `.pen` exists, treat the runtime gate as `BLOCK`
192
+ - if the current live snapshot hash differs from the last persisted `.pen` snapshot hash at completion stage, treat the runtime gate as `BLOCK`
188
193
  - if claimed anchor ids, reviewed screen ids, or screenshot targets do not resolve in the active editor, treat the runtime gate as `BLOCK`
189
194
  - if blocker-level review findings were ignored while the workflow still approved the surface, treat the runtime gate as `BLOCK`
190
195
  - if the workflow presents no structured screenshot-review status or issue list for the approved surfaces, treat the runtime gate as `BLOCK`
@@ -68,12 +68,19 @@ When generating or editing Pencil data:
68
68
  - prefer 12 or fewer operations on anchor surfaces; after two failed batches on the same anchor, drop to micro-batches of 6 or fewer operations until a clean pass lands
69
69
  - if unsupported-property rollbacks repeat on the same anchor surface, stop treating that pass as stable forward progress until the schema usage is corrected
70
70
  - after any rolled-back batch or structure-changing edit, refresh the live node structure before descendant-targeted follow-up operations
71
+ - before the first Pencil edit on a redesign pass, ensure the registered project-local `.pen` exists with `da-vinci ensure-pen --output <path> --verify-open`, then open that exact path instead of working in `new`
72
+ - acquire the global Pencil lock before MCP write operations when multiple redesign sessions could overlap on the same machine
73
+ - prefer the higher-level wrapper:
74
+ `da-vinci pencil-session begin`
75
+ `da-vinci pencil-session persist`
76
+ `da-vinci pencil-session end`
71
77
  - verify the registered project-local `.pen` path becomes shell-visible immediately after the first successful Pencil write
72
78
  - do not treat headless interactive `save()` as authoritative persistence; write the project-local `.pen` from MCP-readable snapshot data instead
73
- - if no registered project-local `.pen` existed at the start of the session, let the first approved anchor surface happen in the live editor, then persist that first approved MCP snapshot under `.da-vinci/designs/`
74
79
  - if a registered project-local `.pen` already existed, reopen it for continuity, but after material live edits persist a fresh MCP snapshot back to that same path instead of assuming live edits were flushed automatically
75
80
  - use `da-vinci write-pen --output <path> --nodes-file <batch-get-json> --variables-file <get-variables-json> --version <version> --verify-open` when you already have MCP-readable snapshot payloads and need an atomic project-local `.pen` write
76
- - use `da-vinci snapshot-pen --input <path> --output <path> --verify-open` when a project-local `.pen` already exists and you need to re-canonicalize it from a fresh MCP-readable snapshot
81
+ - run `da-vinci check-pen-sync --pen <path> --nodes-file <batch-get-json> --variables-file <get-variables-json> --version <version>` after material live edits and before completion claims to prove the live snapshot matches the persisted `.pen`
82
+ - use `da-vinci snapshot-pen --input <path> --output <path> --verify-open` only as a disk-to-disk utility when you need to re-canonicalize an existing `.pen`; it is not a substitute for persisting the current live editor
83
+ - completion audit expects `.da-vinci/state/pencil-session.json` to exist and reflect the latest persisted `.pen` hash
77
84
  - keep workflow markdown out of `.da-vinci/designs/`; reserve that directory for `.pen` files only
78
85
  - keep screenshot exports out of `.da-vinci/designs/`; write them under `.da-vinci/changes/<change-id>/exports/`
79
86
  - after the first approved anchor surfaces, extract a shared primitive family before broad page expansion