@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.
@@ -28,7 +28,7 @@ Existing code is the behavior source of truth, not the layout truth.
28
28
  Preserve current behavior, permissions, integrations, validations, and state transitions unless explicitly required otherwise.
29
29
  Inventory the current tablet surfaces and important states before Pencil work.
30
30
  Use the Visual Assist preferences declared in DA-VINCI.md.
31
- 如果当前还没有登记的项目内 `.pen`,先把第一个通过审查的 MCP 快照持久化到 `.da-vinci/designs/`,不要依赖 interactive `save()`。
31
+ 在第一次 Pencil 编辑前,优先执行 `da-vinci pencil-session begin --project <project-path> --pen <path>`,让登记好的项目内 `.pen` 先完成 seed 并持有全局 Pencil 锁;只有 session wrapper 不可用时,再退回 `da-vinci ensure-pen --output <path> --verify-open`。
32
32
  如果 Pencil MCP 可用,在第一次成功写入 Pencil 后运行 MCP runtime gate,并把结果记录到 `pencil-design.md`。
33
33
  在声明 `design complete` 或 `workflow complete` 之前,必须同时通过 MCP runtime gate 和 `da-vinci audit --mode completion --change <change-id> <project-path>`。
34
34
  Do not pass design checkpoint if the result ignores tablet-scale composition or collapses into a stretched phone layout.
@@ -55,7 +55,7 @@ Design 1-3 anchor surfaces first, review screenshots, then expand.
55
55
  如果同一个 anchor surface 连续两次回滚,就切到每批不超过 6 个操作的微批次,直到拿到干净的 schema-safe pass。
56
56
  在第一次成功写入 Pencil 后、继续大范围扩展前,先运行 `da-vinci audit --mode integrity <project-path>`。
57
57
  如果 Pencil MCP 可用,在第一次成功写入 Pencil 后运行 MCP runtime gate,并把结果记录到 `pencil-design.md`。
58
- 如果当前还没有登记的项目内 `.pen`,先把第一个通过审查的 MCP 快照持久化到 `.da-vinci/designs/`;如果原本已有 `.pen`,继续设计时先打开它,但发生实质性 live edit 后要把当前 MCP 快照重新覆盖写回同一路径。
58
+ 在第一次 Pencil 编辑前,优先执行 `da-vinci pencil-session begin --project <project-path> --pen <path>`;发生实质性 live edit 后,优先执行 `da-vinci pencil-session persist --project <project-path> --pen <path> ...`。只有 session wrapper 不可用时,才退回 `ensure-pen + write-pen + check-pen-sync` 这条底层链路。
59
59
  截图导出只能写到 `.da-vinci/changes/<change-id>/exports/`,不能写进 `.da-vinci/designs/`。
60
60
  截图审查必须记录明确的 `PASS` / `WARN` / `BLOCK`、问题列表和是否回改;“看起来很好”不算审查记录。
61
61
  如果登记的 `.pen` 设计源只存在于内存中,或只剩下导出的 PNG,就不能宣布完成。
@@ -76,7 +76,7 @@ Decompose complex pages into real design surfaces before Pencil work.
76
76
  Use the Visual Assist preferences declared in DA-VINCI.md.
77
77
  If the product is complex, design 1-3 anchor surfaces first, review screenshots, then expand.
78
78
  Stop after DA-VINCI.md, design-registry.md, page-map.md, proposal.md, specs, design.md, pencil-design.md, pencil-bindings.md, and tasks.md.
79
- 如果当前还没有登记的项目内 `.pen`,先把第一个通过审查的 MCP 快照持久化到 `.da-vinci/designs/`,不要依赖 interactive `save()`。
79
+ 在第一次 Pencil 编辑前,优先执行 `da-vinci pencil-session begin --project <project-path> --pen <path>`,让登记好的项目内 `.pen` 先完成 seed 并持有锁;只有 session wrapper 不可用时,再退回 `da-vinci ensure-pen --output <path> --verify-open`。
80
80
  如果 Pencil MCP 可用,在第一次成功写入 Pencil 后运行 MCP runtime gate,并把结果记录到 `pencil-design.md`。
81
81
  在声明 `design complete` 之前,必须同时通过 MCP runtime gate 和 `da-vinci audit --mode completion --change <change-id> <project-path>`。
82
82
  Do not start code changes yet.
@@ -89,7 +89,7 @@ $da-vinci use continue for this existing tablet-product redesign workflow.
89
89
 
90
90
  Use the existing Da Vinci artifacts in this project.
91
91
  Do not restart discovery unless an artifact is missing or clearly wrong.
92
- 把登记的项目内 Pencil 源保持在 `.da-vinci/designs/` 下,作为设计真相源。继续设计时先打开这个文件,但发生实质性 live edit 后,要把当前 MCP 快照重新覆盖写回同一路径,不要假设 interactive `save()` 已经刷回磁盘。
92
+ 把登记的项目内 Pencil 源保持在 `.da-vinci/designs/` 下,作为设计真相源。继续设计时优先执行 `da-vinci pencil-session begin --project <project-path> --pen <path>`,发生实质性 live edit 后优先执行 `da-vinci pencil-session persist --project <project-path> --pen <path> ...`;只有 session wrapper 不可用时,才退回 `write-pen + check-pen-sync` 这条底层链路。
93
93
  If the redesign is complex, continue from the approved anchor surfaces instead of restarting broad scaffolding.
94
94
  如果 Pencil MCP 可用,且这一轮继续做了新的 Pencil 写入,就重新运行 MCP runtime gate,并把最新结果记录到 `pencil-design.md`。
95
95
  在声明 `design complete` 或 `workflow complete` 之前,必须同时通过 MCP runtime gate 和 `da-vinci audit --mode completion --change <change-id> <project-path>`。
@@ -28,7 +28,7 @@ Existing code is the behavior source of truth, not the layout truth.
28
28
  Preserve current business logic, routes, permissions, integrations, validations, and state transitions unless explicitly required otherwise.
29
29
  Inventory the current product surfaces and important states before Pencil work.
30
30
  Use the Visual Assist preferences declared in DA-VINCI.md.
31
- 如果当前还没有登记的项目内 `.pen`,先把第一个通过审查的 MCP 快照持久化到 `.da-vinci/designs/`,不要依赖 interactive `save()`。
31
+ 在第一次 Pencil 编辑前,优先执行 `da-vinci pencil-session begin --project <project-path> --pen <path>`,让登记好的项目内 `.pen` 先完成 seed 并持有全局 Pencil 锁;只有 session wrapper 不可用时,再退回 `da-vinci ensure-pen --output <path> --verify-open`。
32
32
  如果 Pencil MCP 可用,在第一次成功写入 Pencil 后运行 MCP runtime gate,并把结果记录到 `pencil-design.md`。
33
33
  在声明 `design complete` 或 `workflow complete` 之前,必须同时通过 MCP runtime gate 和 `da-vinci audit --mode completion --change <change-id> <project-path>`。
34
34
  Do not pass design checkpoint if the result is a generic SaaS card grid or a recolor of the old interface.
@@ -56,7 +56,7 @@ Design 1-3 anchor surfaces first, review screenshots, then expand.
56
56
  如果同一个 anchor surface 连续两次回滚,就切到每批不超过 6 个操作的微批次,直到拿到干净的 schema-safe pass。
57
57
  在第一次成功写入 Pencil 后、继续大范围扩展前,先运行 `da-vinci audit --mode integrity <project-path>`。
58
58
  如果 Pencil MCP 可用,在第一次成功写入 Pencil 后运行 MCP runtime gate,并把结果记录到 `pencil-design.md`。
59
- 如果当前还没有登记的项目内 `.pen`,先把第一个通过审查的 MCP 快照持久化到 `.da-vinci/designs/`;如果原本已有 `.pen`,继续设计时先打开它,但发生实质性 live edit 后要把当前 MCP 快照重新覆盖写回同一路径。
59
+ 在第一次 Pencil 编辑前,优先执行 `da-vinci pencil-session begin --project <project-path> --pen <path>`;发生实质性 live edit 后,优先执行 `da-vinci pencil-session persist --project <project-path> --pen <path> ...`。只有 session wrapper 不可用时,才退回 `ensure-pen + write-pen + check-pen-sync` 这条底层链路。
60
60
  截图导出只能写到 `.da-vinci/changes/<change-id>/exports/`,不能写进 `.da-vinci/designs/`。
61
61
  截图审查必须记录明确的 `PASS` / `WARN` / `BLOCK`、问题列表和是否回改;“看起来很好”不算审查记录。
62
62
  如果登记的 `.pen` 设计源只存在于内存中,或只剩下导出的 PNG,就不能宣布完成。
@@ -77,7 +77,7 @@ Decompose complex pages into real design surfaces before Pencil work.
77
77
  Use the Visual Assist preferences declared in DA-VINCI.md.
78
78
  If the product is complex, design 1-3 anchor surfaces first, review screenshots, then expand.
79
79
  Stop after DA-VINCI.md, design-registry.md, page-map.md, proposal.md, specs, design.md, pencil-design.md, pencil-bindings.md, and tasks.md.
80
- 如果当前还没有登记的项目内 `.pen`,先把第一个通过审查的 MCP 快照持久化到 `.da-vinci/designs/`,不要依赖 interactive `save()`。
80
+ 在第一次 Pencil 编辑前,优先执行 `da-vinci pencil-session begin --project <project-path> --pen <path>`,让登记好的项目内 `.pen` 先完成 seed 并持有锁;只有 session wrapper 不可用时,再退回 `da-vinci ensure-pen --output <path> --verify-open`。
81
81
  如果 Pencil MCP 可用,在第一次成功写入 Pencil 后运行 MCP runtime gate,并把结果记录到 `pencil-design.md`。
82
82
  在声明 `design complete` 之前,必须同时通过 MCP runtime gate 和 `da-vinci audit --mode completion --change <change-id> <project-path>`。
83
83
  Do not start code changes yet.
@@ -90,7 +90,7 @@ $da-vinci use continue for this existing web-product redesign workflow.
90
90
 
91
91
  Use the existing Da Vinci artifacts in this project.
92
92
  Do not restart discovery unless an artifact is missing or clearly wrong.
93
- 把登记的项目内 Pencil 源保持在 `.da-vinci/designs/` 下,作为设计真相源。继续设计时先打开这个文件,但发生实质性 live edit 后,要把当前 MCP 快照重新覆盖写回同一路径,不要假设 interactive `save()` 已经刷回磁盘。
93
+ 把登记的项目内 Pencil 源保持在 `.da-vinci/designs/` 下,作为设计真相源。继续设计时优先执行 `da-vinci pencil-session begin --project <project-path> --pen <path>`,发生实质性 live edit 后优先执行 `da-vinci pencil-session persist --project <project-path> --pen <path> ...`;只有 session wrapper 不可用时,才退回 `write-pen + check-pen-sync` 这条底层链路。
94
94
  If the redesign is complex, continue from the approved anchor surfaces instead of restarting broad scaffolding.
95
95
  如果 Pencil MCP 可用,且这一轮继续做了新的 Pencil 写入,就重新运行 MCP runtime gate,并把最新结果记录到 `pencil-design.md`。
96
96
  在声明 `design complete` 或 `workflow complete` 之前,必须同时通过 MCP runtime gate 和 `da-vinci audit --mode completion --change <change-id> <project-path>`。
@@ -127,8 +127,8 @@ Do not treat screenshot analysis as an automatic pass if it reports hierarchy, s
127
127
  Before non-trivial `batch_design` calls, preflight the Pencil operations when shell access is available.
128
128
  If the same anchor surface rolls back twice, switch to micro-batches of 6 or fewer operations until a clean schema-safe pass succeeds.
129
129
  Use only Pencil-supported properties; do not use web-only props like flex or margin.
130
- 如果这一轮开始时还没有登记的项目内 `.pen`,先把第一个通过审查的 MCP live 快照持久化到 `.da-vinci/designs/`,不要依赖 interactive `save()`。
131
- 如果项目里原本已有登记的 `.pen`,继续设计时先打开它,但实质性 live edit 后要把当前 MCP 快照重新覆盖写回同一路径。
130
+ 优先在第一次 Pencil 编辑前执行 `da-vinci pencil-session begin --project <project-path> --pen <path>`,这样会先 seed 登记好的 `.pen` 并持有全局 Pencil 锁。
131
+ 如果项目里原本已有登记的 `.pen`,继续设计时先打开它,但实质性 live edit 后优先通过 `da-vinci pencil-session persist` 把当前 live MCP 快照重新覆盖写回同一路径。
132
132
  Verify the registered project-local `.pen` file exists as a shell-visible file after the first Pencil write.
133
133
  在第一次成功写入 Pencil 后、继续大范围扩展前,先运行 `da-vinci audit --mode integrity <project-path>`。
134
134
  Keep `.da-vinci/designs/` reserved for `.pen` files only.
@@ -174,7 +174,7 @@ Use the visual-adapter preferences declared in DA-VINCI.md.
174
174
  If frontend-skill is available, use it as the primary visual adapter.
175
175
  If it is unavailable, fall back to native Da Vinci design rules and continue.
176
176
  Persist project-local Pencil files under .da-vinci/designs/.
177
- 如果当前还没有登记的项目内 `.pen`,先把第一个通过审查的 MCP 快照持久化到这里。
177
+ 如果当前还没有登记的项目内 `.pen`,先在这里 seed 一个登记好的 `.pen`,再开始第一次 Pencil 编辑,并把后续 live 编辑持续绑定到这个路径。
178
178
  如果项目里原本已有 `.pen`,继续设计后要把当前 MCP 快照覆盖写回同一路径。
179
179
  ```
180
180
 
package/lib/audit.js CHANGED
@@ -1,5 +1,7 @@
1
1
  const fs = require("fs");
2
2
  const path = require("path");
3
+ const { getStandardPenStatePath, readPenState, hashPenDocument, readPenDocument } = require("./pen-persistence");
4
+ const { getSessionStatePath, readSessionState } = require("./pencil-session");
3
5
 
4
6
  const IMAGE_EXPORT_PATTERN = /\.(png|jpe?g|webp|pdf)$/i;
5
7
 
@@ -105,9 +107,12 @@ function resolveScopedChangeDirs(projectRoot, changesDir, options, failures, war
105
107
  }
106
108
 
107
109
  function addMissingArtifacts(projectRoot, artifactPaths, targetList) {
108
- for (const artifactPath of artifactPaths) {
110
+ for (const artifactPath of [...new Set(artifactPaths)]) {
109
111
  if (!pathExists(artifactPath)) {
110
- targetList.push(`Missing required artifact: ${relativeTo(projectRoot, artifactPath)}`);
112
+ const message = `Missing required artifact: ${relativeTo(projectRoot, artifactPath)}`;
113
+ if (!targetList.includes(message)) {
114
+ targetList.push(message);
115
+ }
111
116
  }
112
117
  }
113
118
  }
@@ -119,6 +124,7 @@ function auditProject(projectPathInput, options = {}) {
119
124
  const designsDir = path.join(daVinciDir, "designs");
120
125
  const changesDir = path.join(daVinciDir, "changes");
121
126
  const designRegistryPath = path.join(daVinciDir, "design-registry.md");
127
+ const pencilSessionPath = getSessionStatePath(projectRoot);
122
128
 
123
129
  const failures = [];
124
130
  const warnings = [];
@@ -228,6 +234,64 @@ function auditProject(projectPathInput, options = {}) {
228
234
  failures.push(
229
235
  `Registered design source is missing on disk: ${relativeTo(projectRoot, registeredPenPath)}`
230
236
  );
237
+ continue;
238
+ }
239
+
240
+ const statePath = getStandardPenStatePath(registeredPenPath);
241
+ const state = readPenState(registeredPenPath);
242
+ if (!state) {
243
+ const message = `Registered design source is missing state metadata: ${relativeTo(projectRoot, statePath)}`;
244
+ if (mode === "completion") {
245
+ failures.push(message);
246
+ } else {
247
+ warnings.push(message);
248
+ }
249
+ continue;
250
+ }
251
+
252
+ const persistedDocument = readPenDocument(registeredPenPath);
253
+ const currentHash = hashPenDocument(persistedDocument);
254
+ if (state.snapshotHash !== currentHash) {
255
+ failures.push(
256
+ `Registered design source hash does not match its state metadata: ${relativeTo(projectRoot, registeredPenPath)}`
257
+ );
258
+ } else {
259
+ notes.push(
260
+ `Detected persisted design-source hash for ${relativeTo(projectRoot, registeredPenPath)}.`
261
+ );
262
+ }
263
+ }
264
+
265
+ const sessionState = readSessionState(projectRoot);
266
+ if (registeredPenPaths.length > 0 || penFiles.length > 0) {
267
+ if (!sessionState) {
268
+ const message = `Missing Pencil session state: ${relativeTo(projectRoot, pencilSessionPath)}`;
269
+ if (mode === "completion") {
270
+ failures.push(message);
271
+ } else {
272
+ warnings.push(message);
273
+ }
274
+ } else {
275
+ if (!sessionState.penPath) {
276
+ failures.push("Pencil session state is missing the bound `.pen` path.");
277
+ }
278
+
279
+ if (mode === "completion" && sessionState.status !== "closed") {
280
+ failures.push("Completion audit requires the Pencil session to be ended and recorded as `closed`.");
281
+ }
282
+
283
+ if (sessionState.penPath && pathExists(sessionState.penPath)) {
284
+ const penState = readPenState(sessionState.penPath);
285
+ const persistedHash = penState && penState.snapshotHash ? String(penState.snapshotHash) : "";
286
+
287
+ if (sessionState.lastPersistedHash && persistedHash && sessionState.lastPersistedHash !== persistedHash) {
288
+ failures.push("Pencil session state hash does not match the current persisted `.pen` state hash.");
289
+ } else if (persistedHash) {
290
+ notes.push(
291
+ `Detected Pencil session state for ${relativeTo(projectRoot, sessionState.penPath)}.`
292
+ );
293
+ }
294
+ }
231
295
  }
232
296
  }
233
297
 
package/lib/cli.js CHANGED
@@ -14,8 +14,21 @@ const {
14
14
  } = require("./pencil-preflight");
15
15
  const {
16
16
  writePenFromPayloadFiles,
17
- snapshotPenFile
17
+ snapshotPenFile,
18
+ ensurePenFile,
19
+ comparePenSync
18
20
  } = require("./pen-persistence");
21
+ const {
22
+ acquirePencilLock,
23
+ releasePencilLock,
24
+ getPencilLockStatus
25
+ } = require("./pencil-lock");
26
+ const {
27
+ beginPencilSession,
28
+ persistPencilSession,
29
+ endPencilSession,
30
+ getPencilSessionStatus
31
+ } = require("./pencil-session");
19
32
 
20
33
  function getOption(args, name) {
21
34
  const direct = args.find((arg) => arg.startsWith(`${name}=`));
@@ -78,14 +91,24 @@ function printHelp() {
78
91
  " da-vinci validate-assets",
79
92
  " da-vinci audit [project-path]",
80
93
  " da-vinci preflight-pencil --ops-file <path>",
94
+ " da-vinci ensure-pen --output <path>",
81
95
  " da-vinci write-pen --output <path> --nodes-file <path> [--variables-file <path>]",
96
+ " da-vinci check-pen-sync --pen <path> --nodes-file <path> [--variables-file <path>]",
82
97
  " da-vinci snapshot-pen --input <path> --output <path>",
98
+ " da-vinci pencil-lock acquire --project <path>",
99
+ " da-vinci pencil-lock release --project <path>",
100
+ " da-vinci pencil-lock status",
101
+ " da-vinci pencil-session begin --project <path> --pen <path>",
102
+ " da-vinci pencil-session persist --project <path> --pen <path> --nodes-file <path> [--variables-file <path>]",
103
+ " da-vinci pencil-session end --project <path> --pen <path> [--nodes-file <path>]",
104
+ " da-vinci pencil-session status --project <path>",
83
105
  " da-vinci --version",
84
106
  "",
85
107
  "Options:",
86
108
  " --platform <value> codex, claude, gemini, or all",
87
109
  " --home <path> override HOME for installation targets",
88
110
  " --project <path> override project path for audit",
111
+ " --pen <path> registered .pen path for sync checks",
89
112
  " --ops-file <path> Pencil batch operations file for preflight",
90
113
  " --input <path> input .pen file for snapshot-pen",
91
114
  " --output <path> output .pen file for write-pen or snapshot-pen",
@@ -94,7 +117,10 @@ function printHelp() {
94
117
  " --verify-open reopen the written .pen with Pencil after writing",
95
118
  " --version <value> explicit .pen version when writing from MCP payloads",
96
119
  " --mode <value> integrity or completion",
97
- " --change <id> scope completion audit to one change id"
120
+ " --change <id> scope completion audit to one change id",
121
+ " --wait-ms <value> wait for the global Pencil lock before failing",
122
+ " --owner <value> human-readable lock owner label",
123
+ " --force force a lock release held by another project"
98
124
  ].join("\n")
99
125
  );
100
126
  }
@@ -181,6 +207,30 @@ async function runCli(argv) {
181
207
  return;
182
208
  }
183
209
 
210
+ if (command === "ensure-pen") {
211
+ const outputPath = getOption(argv, "--output");
212
+ const version = getOption(argv, "--version");
213
+ const verifyWithPencil = argv.includes("--verify-open");
214
+
215
+ if (!outputPath) {
216
+ throw new Error("`ensure-pen` requires `--output <path>`.");
217
+ }
218
+
219
+ const result = ensurePenFile({
220
+ outputPath,
221
+ version,
222
+ verifyWithPencil
223
+ });
224
+
225
+ console.log(`${result.created ? "Created" : "Verified existing"} .pen source at ${result.outputPath}`);
226
+ console.log(`State file: ${result.statePath}`);
227
+ console.log(`Snapshot hash: ${result.state.snapshotHash}`);
228
+ if (result.verification) {
229
+ console.log(`Verified reopen with Pencil (${result.verification.topLevelCount} top-level nodes).`);
230
+ }
231
+ return;
232
+ }
233
+
184
234
  if (command === "write-pen") {
185
235
  const outputPath = getOption(argv, "--output");
186
236
  const nodesFile = getOption(argv, "--nodes-file");
@@ -201,6 +251,8 @@ async function runCli(argv) {
201
251
  });
202
252
 
203
253
  console.log(`Wrote .pen file to ${result.outputPath}`);
254
+ console.log(`State file: ${result.statePath}`);
255
+ console.log(`Snapshot hash: ${result.state.snapshotHash}`);
204
256
  console.log(`Top-level nodes: ${result.document.children.length}`);
205
257
  if (result.verification) {
206
258
  console.log(`Verified reopen with Pencil (${result.verification.topLevelCount} top-level nodes).`);
@@ -208,6 +260,44 @@ async function runCli(argv) {
208
260
  return;
209
261
  }
210
262
 
263
+ if (command === "check-pen-sync") {
264
+ const penPath = getOption(argv, "--pen");
265
+ const nodesFile = getOption(argv, "--nodes-file");
266
+ const variablesFile = getOption(argv, "--variables-file");
267
+ const version = getOption(argv, "--version");
268
+
269
+ if (!penPath || !nodesFile) {
270
+ throw new Error("`check-pen-sync` requires `--pen <path>` and `--nodes-file <path>`.");
271
+ }
272
+
273
+ const result = comparePenSync({
274
+ penPath,
275
+ nodesFile,
276
+ variablesFile,
277
+ version
278
+ });
279
+
280
+ if (!result.inSync) {
281
+ throw new Error(
282
+ [
283
+ "Registered `.pen` is out of sync with the provided live payload.",
284
+ `Pen: ${result.penPath}`,
285
+ `State file: ${result.statePath}`,
286
+ `Persisted hash: ${result.persistedHash}`,
287
+ `Live hash: ${result.liveHash}`
288
+ ].join("\n")
289
+ );
290
+ }
291
+
292
+ console.log(`Registered .pen is in sync: ${result.penPath}`);
293
+ console.log(`State file: ${result.statePath}`);
294
+ console.log(`Snapshot hash: ${result.liveHash}`);
295
+ if (!result.usedStateFile) {
296
+ console.log("State file was missing; sync comparison fell back to hashing the disk .pen file directly.");
297
+ }
298
+ return;
299
+ }
300
+
211
301
  if (command === "snapshot-pen") {
212
302
  const inputPath = getOption(argv, "--input");
213
303
  const outputPath = getOption(argv, "--output");
@@ -226,6 +316,8 @@ async function runCli(argv) {
226
316
  });
227
317
 
228
318
  console.log(`Snapshotted ${result.inputPath} to ${result.outputPath}`);
319
+ console.log(`State file: ${result.statePath}`);
320
+ console.log(`Snapshot hash: ${result.state.snapshotHash}`);
229
321
  console.log(`Top-level nodes: ${result.document.children.length}`);
230
322
  if (result.verification) {
231
323
  console.log(`Verified reopen with Pencil (${result.verification.topLevelCount} top-level nodes).`);
@@ -233,6 +325,174 @@ async function runCli(argv) {
233
325
  return;
234
326
  }
235
327
 
328
+ if (command === "pencil-lock") {
329
+ const subcommand = getPositionalArgs(argv.slice(1), [
330
+ "--home",
331
+ "--project",
332
+ "--owner",
333
+ "--wait-ms"
334
+ ])[0];
335
+ const projectPath = getOption(argv, "--project") || process.cwd();
336
+ const owner = getOption(argv, "--owner");
337
+ const waitMs = getOption(argv, "--wait-ms");
338
+ const force = argv.includes("--force");
339
+
340
+ if (!subcommand || ["acquire", "release", "status"].includes(subcommand) === false) {
341
+ throw new Error("`pencil-lock` requires one of: acquire, release, status.");
342
+ }
343
+
344
+ if (subcommand === "acquire") {
345
+ const result = acquirePencilLock({
346
+ projectPath,
347
+ owner,
348
+ waitMs,
349
+ homeDir
350
+ });
351
+ console.log(`${result.alreadyHeld ? "Reused" : "Acquired"} Pencil lock at ${result.lockPath}`);
352
+ console.log(`Project: ${result.lock.projectPath}`);
353
+ console.log(`Owner: ${result.lock.owner}`);
354
+ return;
355
+ }
356
+
357
+ if (subcommand === "release") {
358
+ const result = releasePencilLock({
359
+ projectPath,
360
+ force,
361
+ homeDir
362
+ });
363
+ if (!result.hadLock) {
364
+ console.log(`No Pencil lock was present at ${result.lockPath}`);
365
+ return;
366
+ }
367
+ console.log(`Released Pencil lock at ${result.lockPath}`);
368
+ console.log(`Previous project: ${result.lock.projectPath}`);
369
+ return;
370
+ }
371
+
372
+ const result = getPencilLockStatus({ homeDir });
373
+ console.log(`Lock path: ${result.lockPath}`);
374
+ if (!result.lock) {
375
+ console.log("Status: unlocked");
376
+ return;
377
+ }
378
+ console.log("Status: locked");
379
+ console.log(`Project: ${result.lock.projectPath}`);
380
+ console.log(`Owner: ${result.lock.owner}`);
381
+ console.log(`PID: ${result.lock.pid}`);
382
+ return;
383
+ }
384
+
385
+ if (command === "pencil-session") {
386
+ const subcommand = getPositionalArgs(argv.slice(1), [
387
+ "--home",
388
+ "--project",
389
+ "--pen",
390
+ "--nodes-file",
391
+ "--variables-file",
392
+ "--version",
393
+ "--owner",
394
+ "--wait-ms"
395
+ ])[0];
396
+ const projectPath = getOption(argv, "--project") || process.cwd();
397
+ const penPath = getOption(argv, "--pen");
398
+ const nodesFile = getOption(argv, "--nodes-file");
399
+ const variablesFile = getOption(argv, "--variables-file");
400
+ const version = getOption(argv, "--version");
401
+ const verifyWithPencil = argv.includes("--verify-open");
402
+ const owner = getOption(argv, "--owner");
403
+ const waitMs = getOption(argv, "--wait-ms");
404
+ const force = argv.includes("--force");
405
+
406
+ if (!subcommand || ["begin", "persist", "end", "status"].includes(subcommand) === false) {
407
+ throw new Error("`pencil-session` requires one of: begin, persist, end, status.");
408
+ }
409
+
410
+ if (subcommand === "begin") {
411
+ if (!penPath) {
412
+ throw new Error("`pencil-session begin` requires `--pen <path>`.");
413
+ }
414
+
415
+ const result = beginPencilSession({
416
+ projectPath,
417
+ penPath,
418
+ version,
419
+ verifyWithPencil,
420
+ owner,
421
+ waitMs,
422
+ homeDir
423
+ });
424
+
425
+ console.log(`Began Pencil session for ${result.projectRoot}`);
426
+ console.log(`Pen path: ${result.penPath}`);
427
+ console.log(`Session state: ${result.sessionStatePath}`);
428
+ console.log(`Snapshot hash: ${result.session.lastPersistedHash}`);
429
+ return;
430
+ }
431
+
432
+ if (subcommand === "persist") {
433
+ if (!penPath || !nodesFile) {
434
+ throw new Error("`pencil-session persist` requires `--pen <path>` and `--nodes-file <path>`.");
435
+ }
436
+
437
+ const result = persistPencilSession({
438
+ projectPath,
439
+ penPath,
440
+ nodesFile,
441
+ variablesFile,
442
+ version,
443
+ verifyWithPencil,
444
+ homeDir
445
+ });
446
+
447
+ console.log(`Persisted Pencil session for ${result.projectRoot}`);
448
+ console.log(`Pen path: ${result.penPath}`);
449
+ console.log(`Session state: ${result.sessionStatePath}`);
450
+ console.log(`Snapshot hash: ${result.session.lastPersistedHash}`);
451
+ console.log(`In sync: ${result.syncResult.inSync ? "yes" : "no"}`);
452
+ return;
453
+ }
454
+
455
+ if (subcommand === "end") {
456
+ if (!penPath) {
457
+ throw new Error("`pencil-session end` requires `--pen <path>`.");
458
+ }
459
+
460
+ const result = endPencilSession({
461
+ projectPath,
462
+ penPath,
463
+ nodesFile,
464
+ variablesFile,
465
+ version,
466
+ homeDir,
467
+ force
468
+ });
469
+
470
+ console.log(`Ended Pencil session for ${result.projectRoot}`);
471
+ console.log(`Pen path: ${result.penPath}`);
472
+ console.log(`Session state: ${result.sessionStatePath}`);
473
+ console.log(`Final status: ${result.session.status}`);
474
+ if (result.syncResult) {
475
+ console.log(`Final sync verified: ${result.syncResult.inSync ? "yes" : "no"}`);
476
+ }
477
+ return;
478
+ }
479
+
480
+ const result = getPencilSessionStatus({
481
+ projectPath,
482
+ homeDir
483
+ });
484
+
485
+ console.log(`Project: ${result.projectRoot}`);
486
+ console.log(`Session state: ${result.sessionStatePath}`);
487
+ console.log(`Session status: ${result.session ? result.session.status : "missing"}`);
488
+ console.log(`Lock status: ${result.lockStatus.lock ? "locked" : "unlocked"}`);
489
+ if (result.session) {
490
+ console.log(`Pen path: ${result.session.penPath}`);
491
+ console.log(`Last persisted hash: ${result.session.lastPersistedHash || "(missing)"}`);
492
+ }
493
+ return;
494
+ }
495
+
236
496
  throw new Error(`Unknown command: ${command}`);
237
497
  }
238
498
 
@@ -52,6 +52,10 @@ function resolveProjectPath(projectRoot, targetPath) {
52
52
  return path.normalize(path.resolve(projectRoot, targetPath));
53
53
  }
54
54
 
55
+ function normalizeHash(value) {
56
+ return typeof value === "string" ? value.trim() : "";
57
+ }
58
+
55
59
  function isUnnamedEditor(activeEditor) {
56
60
  const normalized = normalizeEditorName(activeEditor).toLowerCase();
57
61
  return normalized === "" || normalized === "new";
@@ -91,12 +95,17 @@ function derivePhase(snapshot) {
91
95
 
92
96
  function evaluateSourceConvergence(snapshot) {
93
97
  const notes = [];
98
+ const phase = derivePhase(snapshot);
94
99
  const activeEditor = normalizeEditorName(snapshot.activeEditor);
95
100
  const registeredPenPath = normalizeRegisteredPath(snapshot.registeredPenPath);
96
101
  const projectRoot = typeof snapshot.projectRoot === "string" ? snapshot.projectRoot.trim() : "";
97
102
  const shellVisiblePenExists = Boolean(snapshot.shellVisiblePenExists);
98
103
  const documentedReconciliation = Boolean(snapshot.documentedReconciliation);
99
104
  const noNewPencilEditsYet = Boolean(snapshot.noNewPencilEditsYet);
105
+ const usedEmptyFilePath = Boolean(snapshot.usedEmptyFilePath);
106
+ const penSyncVerified = snapshot.penSyncVerified === true;
107
+ const liveSnapshotHash = normalizeHash(snapshot.liveSnapshotHash);
108
+ const persistedSnapshotHash = normalizeHash(snapshot.persistedSnapshotHash);
100
109
 
101
110
  if (Boolean(snapshot.mcpAvailable) === false) {
102
111
  return {
@@ -119,6 +128,13 @@ function evaluateSourceConvergence(snapshot) {
119
128
  };
120
129
  }
121
130
 
131
+ if (usedEmptyFilePath) {
132
+ return {
133
+ status: BLOCK,
134
+ notes: ["Pencil operations used an empty `filePath` even though the workflow has a registered project-local `.pen` source."]
135
+ };
136
+ }
137
+
122
138
  if (!shellVisiblePenExists) {
123
139
  return {
124
140
  status: BLOCK,
@@ -142,6 +158,29 @@ function evaluateSourceConvergence(snapshot) {
142
158
  };
143
159
  }
144
160
 
161
+ if (phase === "completion") {
162
+ if (!penSyncVerified) {
163
+ return {
164
+ status: BLOCK,
165
+ notes: ["No explicit live-to-disk `.pen` sync verification was recorded before completion."]
166
+ };
167
+ }
168
+
169
+ if (!liveSnapshotHash || !persistedSnapshotHash) {
170
+ return {
171
+ status: BLOCK,
172
+ notes: ["Completion requires both live and persisted `.pen` snapshot hashes."]
173
+ };
174
+ }
175
+
176
+ if (liveSnapshotHash !== persistedSnapshotHash) {
177
+ return {
178
+ status: BLOCK,
179
+ notes: ["Current live Pencil snapshot does not match the last persisted `.pen` snapshot."]
180
+ };
181
+ }
182
+ }
183
+
145
184
  if (noNewPencilEditsYet) {
146
185
  notes.push("No new Pencil edits were recorded yet; source convergence is only provisionally confirmed.");
147
186
  return {
@@ -150,9 +189,16 @@ function evaluateSourceConvergence(snapshot) {
150
189
  };
151
190
  }
152
191
 
192
+ if (penSyncVerified) {
193
+ notes.push("Live Pencil snapshot was explicitly verified against the persisted project-local `.pen` source.");
194
+ }
195
+
153
196
  return {
154
197
  status: PASS,
155
- notes: ["Active Pencil editor matches the registered project-local `.pen` source and the file exists on disk."]
198
+ notes: [
199
+ "Active Pencil editor matches the registered project-local `.pen` source and the file exists on disk.",
200
+ ...notes
201
+ ]
156
202
  };
157
203
  }
158
204
 
@@ -296,6 +342,9 @@ function evaluateMcpRuntimeGate(snapshot = {}) {
296
342
  shellVisiblePenPath:
297
343
  typeof snapshot.shellVisiblePenPath === "string" ? snapshot.shellVisiblePenPath.trim() : "",
298
344
  shellVisiblePenExists: Boolean(snapshot.shellVisiblePenExists),
345
+ penSyncVerified: snapshot.penSyncVerified === true,
346
+ liveSnapshotHash: normalizeHash(snapshot.liveSnapshotHash),
347
+ persistedSnapshotHash: normalizeHash(snapshot.persistedSnapshotHash),
299
348
  claimedAnchorIds: normalizeArray(snapshot.claimedAnchorIds),
300
349
  claimedReviewedScreenIds: normalizeArray(snapshot.claimedReviewedScreenIds),
301
350
  reviewTargets: normalizeArray(snapshot.reviewTargets),
@@ -320,6 +369,9 @@ function formatMcpRuntimeGateSection(snapshot, result, options = {}) {
320
369
  `- Registered \`.pen\` path: ${result.registeredPenPath || "(missing)"}`,
321
370
  `- Shell-visible \`.pen\` path: ${result.shellVisiblePenPath || "(missing)"}`,
322
371
  `- Shell-visible \`.pen\` exists: ${result.shellVisiblePenExists ? "yes" : "no"}`,
372
+ `- Pen sync verified: ${result.penSyncVerified ? "yes" : "no"}`,
373
+ `- Live snapshot hash: ${result.liveSnapshotHash || "(missing)"}`,
374
+ `- Persisted snapshot hash: ${result.persistedSnapshotHash || "(missing)"}`,
323
375
  `- Claimed anchor ids: ${result.claimedAnchorIds.length > 0 ? result.claimedAnchorIds.join(", ") : "(none)"}`,
324
376
  `- Reviewed screen ids: ${result.claimedReviewedScreenIds.length > 0 ? result.claimedReviewedScreenIds.join(", ") : "(none)"}`,
325
377
  `- Screenshot target ids: ${result.reviewTargets.length > 0 ? result.reviewTargets.join(", ") : "(none)"}`,