@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.
- package/CHANGELOG.md +9 -3
- package/README.md +15 -4
- package/README.zh-CN.md +15 -5
- package/SKILL.md +11 -2
- package/commands/claude/dv/design.md +3 -2
- package/commands/codex/prompts/dv-design.md +3 -2
- package/commands/gemini/dv/design.toml +3 -2
- package/docs/mode-use-cases.md +2 -2
- package/docs/prompt-presets/README.md +1 -1
- package/docs/prompt-presets/desktop-app.md +4 -4
- package/docs/prompt-presets/mobile-app.md +4 -4
- package/docs/prompt-presets/tablet-app.md +4 -4
- package/docs/prompt-presets/web-app.md +4 -4
- package/docs/workflow-examples.md +5 -5
- package/docs/zh-CN/mode-use-cases.md +7 -6
- package/docs/zh-CN/prompt-presets/README.md +1 -1
- package/docs/zh-CN/prompt-presets/desktop-app.md +4 -4
- package/docs/zh-CN/prompt-presets/mobile-app.md +4 -4
- package/docs/zh-CN/prompt-presets/tablet-app.md +4 -4
- package/docs/zh-CN/prompt-presets/web-app.md +4 -4
- package/docs/zh-CN/workflow-examples.md +3 -3
- package/lib/audit.js +66 -2
- package/lib/cli.js +262 -2
- package/lib/mcp-runtime-gate.js +53 -1
- package/lib/pen-persistence.js +192 -3
- package/lib/pencil-lock.js +128 -0
- package/lib/pencil-session.js +229 -0
- package/package.json +3 -1
- package/references/checkpoints.md +6 -1
- package/references/pencil-design-to-code.md +9 -2
- package/scripts/test-mcp-runtime-gate.js +88 -0
- package/scripts/test-pen-persistence.js +146 -6
- package/scripts/test-pencil-session.js +152 -0
- package/scripts/test-persistence-flows.js +315 -0
package/lib/pen-persistence.js
CHANGED
|
@@ -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.
|
|
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
|
|
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
|
-
-
|
|
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
|