release-skill 0.1.4 → 0.1.6

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 (71) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +2 -2
  4. package/CHANGELOG.md +104 -0
  5. package/INSTALL.md +81 -1
  6. package/INSTALL.zh-CN.md +69 -1
  7. package/README.md +233 -8
  8. package/README.zh-CN.md +188 -8
  9. package/adapters/claude/.claude-plugin/marketplace.json +1 -1
  10. package/adapters/claude/.claude-plugin/plugin.json +1 -1
  11. package/adapters/claude/bin/release-skill.bundle.mjs +14164 -9912
  12. package/adapters/claude/bin/release-skill.mjs +24 -4
  13. package/adapters/claude/native/safe-write/binding.gyp +2 -1
  14. package/adapters/claude/native/safe-write/prebuilds/darwin-arm64/safe_write.node +0 -0
  15. package/adapters/claude/native/safe-write/prebuilds.json +1 -1
  16. package/adapters/claude/schemas/.render-manifest.json +10 -10
  17. package/adapters/claude/schemas/release-project.schema.json +141 -0
  18. package/adapters/claude/skills/release-help/SKILL.md +21 -0
  19. package/adapters/claude/skills/release-prepare/SKILL.md +17 -6
  20. package/adapters/claude/skills/release-publish/SKILL.md +3 -1
  21. package/adapters/claude/skills/release-reconcile/SKILL.md +1 -1
  22. package/adapters/codex/.codex-plugin/plugin.json +2 -2
  23. package/adapters/codex/bin/release-skill.bundle.mjs +14164 -9912
  24. package/adapters/codex/bin/release-skill.mjs +24 -4
  25. package/adapters/codex/native/safe-write/binding.gyp +2 -1
  26. package/adapters/codex/native/safe-write/prebuilds/darwin-arm64/safe_write.node +0 -0
  27. package/adapters/codex/native/safe-write/prebuilds.json +1 -1
  28. package/adapters/codex/schemas/.render-manifest.json +10 -10
  29. package/adapters/codex/schemas/release-project.schema.json +141 -0
  30. package/adapters/codex/skills/release-help/SKILL.md +21 -0
  31. package/adapters/codex/skills/release-prepare/SKILL.md +17 -6
  32. package/adapters/codex/skills/release-publish/SKILL.md +3 -1
  33. package/adapters/codex/skills/release-reconcile/SKILL.md +1 -1
  34. package/bin/release-skill-cli.mjs +163 -4
  35. package/bin/release-skill.bundle.mjs +14164 -9912
  36. package/bin/release-skill.mjs +24 -4
  37. package/native/safe-write/binding.gyp +2 -1
  38. package/native/safe-write/prebuilds/darwin-arm64/safe_write.node +0 -0
  39. package/native/safe-write/prebuilds.json +1 -1
  40. package/package.json +2 -2
  41. package/references/.render-manifest.json +4 -4
  42. package/references/02-project-config.md +24 -0
  43. package/references/05-evidence-and-errors.md +5 -0
  44. package/schemas/.render-manifest.json +10 -10
  45. package/schemas/release-project.schema.json +141 -0
  46. package/scripts/build-bundle.mjs +15 -2
  47. package/skills/release-help/SKILL.md +21 -0
  48. package/skills/release-prepare/SKILL.md +17 -6
  49. package/skills/release-publish/SKILL.md +3 -1
  50. package/skills/release-reconcile/SKILL.md +1 -1
  51. package/skills-src/release-help/SKILL.md +21 -0
  52. package/skills-src/release-prepare/SKILL.md +17 -6
  53. package/skills-src/release-publish/SKILL.md +3 -1
  54. package/skills-src/release-reconcile/SKILL.md +1 -1
  55. package/src/adapters/plugin-marketplace.mjs +70 -3
  56. package/src/artifacts/transaction-journal.mjs +1126 -105
  57. package/src/artifacts/transaction.mjs +313 -130
  58. package/src/commands/docs.mjs +332 -0
  59. package/src/commands/prepare.mjs +324 -17
  60. package/src/commands/reconcile.mjs +4 -1
  61. package/src/commands/verify.mjs +4 -1
  62. package/src/core/errors.mjs +64 -2
  63. package/src/core/plan.mjs +59 -1
  64. package/src/core/redact.mjs +206 -0
  65. package/src/docs/changelog-renderer.mjs +853 -0
  66. package/src/docs/config.mjs +337 -0
  67. package/src/docs/notes-loader.mjs +432 -0
  68. package/src/docs/notes.mjs +553 -0
  69. package/src/docs/readme-renderer.mjs +647 -0
  70. package/src/docs/refresh-planner.mjs +542 -0
  71. package/src/docs/refresh-service.mjs +675 -0
@@ -0,0 +1,675 @@
1
+ /**
2
+ * Release-document refresh service: the two-phase local closed loop
3
+ * (2026-07-21-release-docs-command-and-prepare-gate §2/§3,
4
+ * 2026-07-21-release-docs-refresh-protocol).
5
+ *
6
+ * `planReleaseDocsRefreshForUnit({ root, config, unit, version,
7
+ * backendFactory })` is the shared read-only planning primitive (used by the
8
+ * prepare freshness gate and by this service): it selects the unit's
9
+ * releaseDocuments block, reads the notes source through
10
+ * `loadReleaseNotesSource`, reads EVERY configured target through the
11
+ * exported `readSafeFileThroughHandles` safe-read primitive (root handle,
12
+ * per-segment openat/O_NOFOLLOW, regular file, nlink === 1, size cap, stable
13
+ * identity — no absolute-path fs fallback; fail closed when the backend is
14
+ * unavailable), captures the permission mode of each target from the same
15
+ * stable readFile, and returns the deeply frozen `{ plan, display, modes }`
16
+ * triple. It performs ZERO workspace/control-plane writes: no `.release-skill`
17
+ * directory, no lock, no plan file, no journal, no probe.
18
+ *
19
+ * `runReleaseDocsRefresh({ root, unitId, write, confirmRefresh,
20
+ * ackLocalDocumentWrite, explicitVersion, backendFactory, faultInjector,
21
+ * clock })`:
22
+ * - dry-run (write falsy): selects the unique unit from `loadProjectConfig`
23
+ * (UNIT_NOT_FOUND / UNIT_DUPLICATE fail closed), resolves the authoritative
24
+ * version from `unit.version.source` (an explicitVersion is only a
25
+ * consistency assertion — mismatch fails closed with GATE_FAILED), and
26
+ * returns the safe display projection. Zero writes.
27
+ * - write (write === true): requires ALL THREE authorizations — `write`,
28
+ * an exact `confirmRefresh` digest match, and
29
+ * `ackLocalDocumentWrite === true` — validated before ANY I/O. Under the
30
+ * exclusive project lock it reloads config/version/notes/targets and
31
+ * re-plans; a diverging refreshDigest converges to RELEASE_DOCS_REFRESH_STALE
32
+ * with zero target writes; a clean plan is a zero-write no-op. Otherwise it
33
+ * commits every changed target through the generic
34
+ * `applyWriteSetUnderLock` transaction core (multi-file preflight CAS
35
+ * before the first write, per-entry re-CAS, durable journal/backup,
36
+ * RECOVERY_REQUIRED recovery with the unique recover command), then
37
+ * re-plans read-only under the lock and requires `clean`. A mid-flight
38
+ * failure restores the exact old bytes under the still-held lock (the
39
+ * journal stays RECOVERY_REQUIRED and the unique recover command remains
40
+ * authoritative).
41
+ *
42
+ * All user-visible output carries canonical relative paths only: never
43
+ * `/Users/...`, never note/old body text, never credentials, never full
44
+ * diffs, never serialized buffers.
45
+ *
46
+ * @module src/docs/refresh-service
47
+ */
48
+
49
+ import { isAbsolute, relative, resolve, sep } from 'node:path';
50
+
51
+ import { loadSafeFs } from '../artifacts/safe-fs.mjs';
52
+ import { acquireProjectLock } from '../artifacts/project-lock.mjs';
53
+ import { applyWriteSetUnderLock } from '../artifacts/transaction.mjs';
54
+ import { loadProjectConfig } from '../core/config.mjs';
55
+ import {
56
+ ReleaseError,
57
+ MISSING_PARAMETERS,
58
+ PATH_UNSAFE,
59
+ RELEASE_DOCS_INVALID,
60
+ RELEASE_DOCS_REFRESH_STALE,
61
+ TRANSACTION_INCOMPLETE,
62
+ } from '../core/errors.mjs';
63
+ import { resolveUnitVersion } from '../commands/prepare.mjs';
64
+
65
+ import { normalizeReleaseDocumentsConfig } from './config.mjs';
66
+ import { loadReleaseNotesSource, readSafeFileThroughHandles } from './notes-loader.mjs';
67
+ import { DEFAULT_MAX_NOTES_BYTES } from './notes.mjs';
68
+ import {
69
+ createReleaseDocsRefreshPlan,
70
+ projectReleaseDocsRefreshDisplay,
71
+ } from './refresh-planner.mjs';
72
+
73
+ // ---------------------------------------------------------------------------
74
+ // Small utilities
75
+ // ---------------------------------------------------------------------------
76
+
77
+ /**
78
+ * Deeply freeze a plain object/array structure. Typed arrays (Buffer) are
79
+ * skipped: the JS specification forbids freezing non-empty ArrayBuffer
80
+ * views; byte buffers stay immutable by always being fresh copies.
81
+ */
82
+ function deepFreeze(value) {
83
+ if (Array.isArray(value)) {
84
+ for (const item of value) deepFreeze(item);
85
+ return Object.freeze(value);
86
+ }
87
+ if (value !== null && typeof value === 'object') {
88
+ if (ArrayBuffer.isView(value)) return value;
89
+ for (const item of Object.values(value)) deepFreeze(item);
90
+ return Object.freeze(value);
91
+ }
92
+ return value;
93
+ }
94
+
95
+ /**
96
+ * Render a permission mode captured by a stable readFile (a number such as
97
+ * 0o644 === 420) as the six-digit octal string the transaction entry schema
98
+ * expects ('000644'); the last three digits drive CAS and journal checks.
99
+ */
100
+ function modeToString(mode) {
101
+ if (typeof mode === 'string' && mode.length > 0) return mode;
102
+ if (Number.isSafeInteger(mode) && mode >= 0 && mode <= 0o777) {
103
+ return mode.toString(8).padStart(6, '0');
104
+ }
105
+ return '000644';
106
+ }
107
+
108
+ /**
109
+ * Select the unique release unit by id from the loaded project config.
110
+ * Unknown and duplicated ids fail closed with precise stable errors.
111
+ */
112
+ function selectUnit(config, unitId) {
113
+ const units = Array.isArray(config?.releaseUnits) ? config.releaseUnits : [];
114
+ const matches = units.filter((unit) => unit?.id === unitId);
115
+ if (matches.length === 0) {
116
+ throw new ReleaseError(
117
+ RELEASE_DOCS_INVALID,
118
+ `release unit "${unitId}" was not found in the project configuration`,
119
+ { reason: 'UNIT_NOT_FOUND', unitId, available: units.map((unit) => unit?.id) },
120
+ );
121
+ }
122
+ if (matches.length > 1) {
123
+ throw new ReleaseError(
124
+ RELEASE_DOCS_INVALID,
125
+ `release unit "${unitId}" is declared more than once in the project configuration`,
126
+ { reason: 'UNIT_DUPLICATE', unitId },
127
+ );
128
+ }
129
+ return matches[0];
130
+ }
131
+
132
+ // ---------------------------------------------------------------------------
133
+ // Read-only planning primitive (shared by the service and the prepare gate)
134
+ // ---------------------------------------------------------------------------
135
+
136
+ /**
137
+ * Plan the release-document refresh for one release unit, read-only.
138
+ *
139
+ * Reads the notes source and every configured target exclusively through
140
+ * the safe-fs backend handle API; performs ZERO workspace or control-plane
141
+ * writes (no `.release-skill`, no lock, no plan file, no journal, no probe).
142
+ *
143
+ * @param {object} options
144
+ * @param {string} options.root — Absolute project root.
145
+ * @param {object} [options.config] — Loaded project config (reserved for
146
+ * gate/service context; the unit object below is authoritative).
147
+ * @param {object} options.unit — Release unit object from config.releaseUnits.
148
+ * @param {string} options.version — Already-resolved authoritative version.
149
+ * @param {() => Promise<object>} [options.backendFactory] — Safe-fs backend
150
+ * factory (default loadSafeFs); fail closed when unavailable.
151
+ * @returns {Promise<Readonly<{ plan: object, display: object, modes: Map<string, number> }>>}
152
+ * Deeply frozen triple; `modes` maps canonical target paths to the
153
+ * permission mode captured by the same stable readFile (write-phase
154
+ * internal only — never part of the display projection).
155
+ * @throws {ReleaseError} RELEASE_DOCS_INVALID (unit/config/semantic),
156
+ * PATH_UNSAFE (path/race), SAFE_WRITE_UNAVAILABLE (backend), plus
157
+ * STRUCTURE_INVALID / RELEASE_DOCS_CONFLICT / RELEASE_DOCS_TRANSLATION_MISSING
158
+ * propagated from the renderers/parser.
159
+ */
160
+ export async function planReleaseDocsRefreshForUnit({
161
+ root,
162
+ config,
163
+ unit,
164
+ version,
165
+ backendFactory,
166
+ } = {}) {
167
+ if (typeof root !== 'string' || root.length === 0) {
168
+ throw new ReleaseError(RELEASE_DOCS_INVALID, 'root must be a non-empty string', {
169
+ reason: 'INVALID_OPTIONS',
170
+ field: 'root',
171
+ });
172
+ }
173
+ if (!unit || typeof unit !== 'object' || Array.isArray(unit)) {
174
+ throw new ReleaseError(RELEASE_DOCS_INVALID, 'unit must be a release unit object', {
175
+ reason: 'INVALID_OPTIONS',
176
+ field: 'unit',
177
+ });
178
+ }
179
+ if (typeof unit.id !== 'string' || unit.id.length === 0) {
180
+ throw new ReleaseError(RELEASE_DOCS_INVALID, 'unit.id must be a non-empty string', {
181
+ reason: 'INVALID_OPTIONS',
182
+ field: 'unit.id',
183
+ });
184
+ }
185
+ if (typeof version !== 'string' || version.length === 0) {
186
+ throw new ReleaseError(RELEASE_DOCS_INVALID, 'version must be a non-empty string', {
187
+ reason: 'INVALID_OPTIONS',
188
+ field: 'version',
189
+ });
190
+ }
191
+ if (unit.releaseDocuments === undefined || unit.releaseDocuments === null) {
192
+ throw new ReleaseError(
193
+ RELEASE_DOCS_INVALID,
194
+ `release unit "${unit.id}" does not configure releaseDocuments`,
195
+ { reason: 'RELEASE_DOCUMENTS_NOT_CONFIGURED', unitId: unit.id },
196
+ );
197
+ }
198
+ if (typeof unit.source !== 'string' || unit.source.length === 0) {
199
+ throw new ReleaseError(RELEASE_DOCS_INVALID, 'unit.source must be a non-empty string', {
200
+ reason: 'INVALID_OPTIONS',
201
+ field: 'unit.source',
202
+ });
203
+ }
204
+
205
+ const unitRoot = resolve(root, unit.source);
206
+
207
+ // One backend instance per planning pass; fail closed when unavailable —
208
+ // there is no absolute-path fallback.
209
+ const backend = await (backendFactory ?? loadSafeFs)();
210
+ const sharedFactory = async () => backend;
211
+
212
+ // 1. Structured notes source through the existing safe loader.
213
+ const notesSource = await loadReleaseNotesSource({
214
+ unitRoot,
215
+ config: unit.releaseDocuments,
216
+ version,
217
+ backendFactory: sharedFactory,
218
+ });
219
+
220
+ // 2. Closed semantic normalization, then every target through the same
221
+ // safe-read primitive (regular file, nlink === 1, size cap, identity).
222
+ const normalized = normalizeReleaseDocumentsConfig(unit.releaseDocuments);
223
+ const targets = [
224
+ ...normalized.changelogs.map((entry) => ({ path: entry.path, kind: 'changelog', locale: entry.locale })),
225
+ ...normalized.readmes.map((entry) => ({ path: entry.path, kind: 'readme', locale: entry.locale })),
226
+ ];
227
+
228
+ const oldFiles = [];
229
+ const modes = new Map();
230
+ for (const target of targets) {
231
+ const read = await readSafeFileThroughHandles(
232
+ backend,
233
+ unitRoot,
234
+ target.path,
235
+ DEFAULT_MAX_NOTES_BYTES,
236
+ undefined,
237
+ 'release document target',
238
+ );
239
+ oldFiles.push({
240
+ path: target.path,
241
+ kind: target.kind,
242
+ locale: target.locale,
243
+ bytes: read.bytes,
244
+ });
245
+ modes.set(target.path, read.mode);
246
+ }
247
+
248
+ // 3. Pure deterministic planning + safe display projection.
249
+ const plan = createReleaseDocsRefreshPlan({
250
+ unitId: unit.id,
251
+ version,
252
+ config: normalized,
253
+ notes: notesSource.notes,
254
+ notesSourceDigest: notesSource.bytesDigest,
255
+ oldFiles,
256
+ });
257
+ const display = projectReleaseDocsRefreshDisplay(plan);
258
+
259
+ return deepFreeze({ plan, display, modes });
260
+ }
261
+
262
+ // ---------------------------------------------------------------------------
263
+ // Mid-flight failure: restore the exact old bytes under the held lock
264
+ // ---------------------------------------------------------------------------
265
+
266
+ /**
267
+ * Restore one target to its exact old bytes through the safe-fs backend
268
+ * handle API (stable read → identity-bound createTemp+rename → fsync).
269
+ * Fails closed; handles are closed in reverse order.
270
+ */
271
+ async function restoreOneTarget(backend, unitRoot, file, mode) {
272
+ const segments = file.path.split('/');
273
+ const handleStack = [];
274
+ let primaryError = null;
275
+
276
+ try {
277
+ handleStack.push(await backend.openRoot(unitRoot));
278
+ for (let i = 0; i < segments.length - 1; i += 1) {
279
+ handleStack.push(await handleStack[handleStack.length - 1].openDir(segments[i]));
280
+ }
281
+ const parent = handleStack[handleStack.length - 1];
282
+ const leaf = segments[segments.length - 1];
283
+
284
+ // Stable read of the current state: its identity authorizes the
285
+ // identity-bound rename (and tells us whether a write is needed at all).
286
+ const current = await parent.readFile(leaf);
287
+ const oldBytes = Buffer.from(file.oldBytes);
288
+ const alreadyOld = current !== null
289
+ && current !== undefined
290
+ && Buffer.isBuffer(current.bytes)
291
+ && current.bytes.equals(oldBytes);
292
+
293
+ if (!alreadyOld) {
294
+ const writeMode = Number.isSafeInteger(mode) && mode >= 0 && mode <= 0o777 ? mode : 0o644;
295
+ const token = await parent.createTemp(leaf, writeMode, oldBytes);
296
+ try {
297
+ await parent.rename(token, leaf, current ?? undefined);
298
+ } catch (renameError) {
299
+ try {
300
+ await parent.abortTemp(token);
301
+ } catch {
302
+ // Best effort: the primary rename error is authoritative.
303
+ }
304
+ throw renameError;
305
+ }
306
+ await parent.fsync();
307
+ }
308
+ } catch (err) {
309
+ primaryError = err;
310
+ }
311
+
312
+ const closeFailures = [];
313
+ for (let i = handleStack.length - 1; i >= 0; i -= 1) {
314
+ try {
315
+ await handleStack[i].close();
316
+ } catch (closeErr) {
317
+ closeFailures.push(closeErr?.code ?? 'CLOSE_FAILED');
318
+ }
319
+ }
320
+ if (primaryError) throw primaryError;
321
+ if (closeFailures.length > 0) {
322
+ throw new ReleaseError(
323
+ TRANSACTION_INCOMPLETE,
324
+ 'release document restore handle close failed',
325
+ { closeFailures },
326
+ );
327
+ }
328
+ }
329
+
330
+ /**
331
+ * Restore every changed target to its exact old bytes under the still-held
332
+ * project lock after a mid-flight transaction failure. Returns true only
333
+ * when every target is back at its old bytes; any failure resolves false so
334
+ * the caller surfaces the original RECOVERY_REQUIRED error unchanged.
335
+ */
336
+ async function tryRestoreOldBytes(backend, unitRoot, changedFiles, modes) {
337
+ try {
338
+ for (const file of changedFiles) {
339
+ await restoreOneTarget(backend, unitRoot, file, modes.get(file.path));
340
+ }
341
+ return true;
342
+ } catch {
343
+ return false;
344
+ }
345
+ }
346
+
347
+ // ---------------------------------------------------------------------------
348
+ // Public command entry: dry-run projection and authorized atomic write
349
+ // ---------------------------------------------------------------------------
350
+
351
+ /**
352
+ * Run the two-phase release-document refresh for one release unit.
353
+ *
354
+ * Dry-run (write falsy): read-only, zero workspace/control-plane writes,
355
+ * returns the safe display projection plus the authoritative version.
356
+ *
357
+ * Write (write === true): requires the exact `confirmRefresh` digest AND
358
+ * `ackLocalDocumentWrite === true` (validated before any I/O); re-plans
359
+ * everything under the exclusive project lock; commits changed targets
360
+ * through the durable transaction core; re-plans read-only afterwards and
361
+ * requires a clean result.
362
+ *
363
+ * @param {object} options
364
+ * @param {string} options.root — Absolute project root.
365
+ * @param {string} options.unitId — Release unit identifier.
366
+ * @param {boolean} [options.write] — Explicit write authorization.
367
+ * @param {string} [options.confirmRefresh] — Exact dry-run refreshDigest.
368
+ * @param {boolean} [options.ackLocalDocumentWrite] — Explicit local document
369
+ * write acknowledgement.
370
+ * @param {string} [options.explicitVersion] — Consistency assertion only;
371
+ * never overrides `unit.version.source`.
372
+ * @param {() => Promise<object>} [options.backendFactory] — Safe-fs backend
373
+ * factory (default loadSafeFs).
374
+ * @param {Function} [options.faultInjector] — Test-only fault injection for
375
+ * the transaction core.
376
+ * @param {Function} [options.clock] — Test-only clock.
377
+ * @returns {Promise<object>} Frozen dry-run/write/clean result.
378
+ * @throws {ReleaseError} MISSING_PARAMETERS (24), RELEASE_DOCS_INVALID (42),
379
+ * RELEASE_DOCS_REFRESH_STALE (45), GATE_FAILED (13), PATH_UNSAFE (28),
380
+ * SAFE_WRITE_UNAVAILABLE (39), PLAN_STALE (36), TRANSACTION_INCOMPLETE (38).
381
+ */
382
+ export async function runReleaseDocsRefresh({
383
+ root,
384
+ unitId,
385
+ write = false,
386
+ confirmRefresh,
387
+ ackLocalDocumentWrite = false,
388
+ explicitVersion,
389
+ backendFactory,
390
+ faultInjector,
391
+ clock,
392
+ } = {}) {
393
+ // 1. Parameters precede everything.
394
+ if (typeof root !== 'string' || root.length === 0) {
395
+ throw new ReleaseError(MISSING_PARAMETERS, 'root is required', { field: 'root' });
396
+ }
397
+ if (typeof unitId !== 'string' || unitId.length === 0) {
398
+ throw new ReleaseError(MISSING_PARAMETERS, 'unitId is required', { field: 'unitId' });
399
+ }
400
+
401
+ // 2. Write authorization precedes ALL I/O (defense in depth: the CLI
402
+ // validates the same three-way binding before invoking the service).
403
+ if (write) {
404
+ const missing = [];
405
+ if (typeof confirmRefresh !== 'string' || confirmRefresh.length === 0) {
406
+ missing.push('confirmRefresh');
407
+ }
408
+ if (ackLocalDocumentWrite !== true) {
409
+ missing.push('ackLocalDocumentWrite');
410
+ }
411
+ if (missing.length > 0) {
412
+ throw new ReleaseError(
413
+ MISSING_PARAMETERS,
414
+ 'docs refresh --write requires the exact confirmRefresh digest and an explicit local document write acknowledgement',
415
+ { reason: 'MISSING_WRITE_PARAMETERS', missing },
416
+ );
417
+ }
418
+ }
419
+
420
+ // 3. One backend instance per invocation; fail closed when unavailable.
421
+ const backend = await (backendFactory ?? loadSafeFs)();
422
+ const sharedFactory = async () => backend;
423
+
424
+ // -----------------------------------------------------------------
425
+ // DRY-RUN — read-only, zero writes
426
+ // -----------------------------------------------------------------
427
+ if (!write) {
428
+ const { config } = await loadProjectConfig({ root });
429
+ const unit = selectUnit(config, unitId);
430
+ const version = await resolveUnitVersion(unit, root, explicitVersion);
431
+ const { display } = await planReleaseDocsRefreshForUnit({
432
+ root,
433
+ config,
434
+ unit,
435
+ version,
436
+ backendFactory: sharedFactory,
437
+ });
438
+
439
+ const result = {
440
+ command: 'docs-refresh',
441
+ mode: 'dry-run',
442
+ status: display.status,
443
+ unitId,
444
+ locales: [...display.locales],
445
+ inputDigest: display.inputDigest,
446
+ refreshDigest: display.refreshDigest,
447
+ files: display.files,
448
+ nextCommand: display.nextCommand,
449
+ };
450
+ // The authoritative version is exposed to callers but kept off the
451
+ // enumerable projection surface; the CLI re-adds it to its JSON shape.
452
+ Object.defineProperty(result, 'version', {
453
+ value: version,
454
+ enumerable: false,
455
+ writable: false,
456
+ configurable: false,
457
+ });
458
+ return Object.freeze(result);
459
+ }
460
+
461
+ // -----------------------------------------------------------------
462
+ // WRITE — exclusive lock, under-lock re-plan, transactional commit
463
+ // -----------------------------------------------------------------
464
+ // The project lock keeps its own wall-clock owner timestamps (its clock
465
+ // contract requires ISO-8601); the injected test clock is reserved for the
466
+ // deterministic transaction-id generation inside the transaction core.
467
+ const lock = await acquireProjectLock({
468
+ root,
469
+ command: 'docs refresh',
470
+ mode: 'exclusive',
471
+ });
472
+
473
+ let result;
474
+ let primaryError;
475
+ try {
476
+ result = await lock.capture(async () => {
477
+ // Reload everything under the exclusive lock and re-plan from
478
+ // scratch: config, authoritative version, notes source, all targets.
479
+ const { config } = await loadProjectConfig({ root });
480
+ const unit = selectUnit(config, unitId);
481
+ const version = await resolveUnitVersion(unit, root, explicitVersion);
482
+ const { plan, modes } = await planReleaseDocsRefreshForUnit({
483
+ root,
484
+ config,
485
+ unit,
486
+ version,
487
+ backendFactory: sharedFactory,
488
+ });
489
+
490
+ // Exact refreshDigest confirmation. ANY concurrent change (config,
491
+ // notes source, version, or any target) changes the digest and
492
+ // converges here with ZERO target writes.
493
+ if (plan.refreshDigest !== confirmRefresh) {
494
+ throw new ReleaseError(
495
+ RELEASE_DOCS_REFRESH_STALE,
496
+ `release documents for unit "${unitId}" changed since the confirmed dry-run`,
497
+ {
498
+ unitId,
499
+ version,
500
+ expected: confirmRefresh,
501
+ actual: plan.refreshDigest,
502
+ },
503
+ );
504
+ }
505
+
506
+ // Clean plans are a zero-write no-op even in write mode (no
507
+ // transaction, no journal).
508
+ if (plan.status === 'clean') {
509
+ return Object.freeze({
510
+ command: 'docs-refresh',
511
+ mode: 'write',
512
+ status: 'clean',
513
+ refreshed: false,
514
+ unitId,
515
+ version,
516
+ refreshDigest: plan.refreshDigest,
517
+ });
518
+ }
519
+
520
+ // Build the write set (changed targets only; canonical path order)
521
+ // and the closed docs-refresh v1 canonical plan (no bytes, no
522
+ // absolute paths) persisted as the journal authority.
523
+ //
524
+ // The transaction core resolves write-set paths against the project
525
+ // root (where the durable journal lives), while planning, target
526
+ // reads/restores, and every user-facing projection stay unit-relative.
527
+ // Prefix each unit-relative target path with the unit's canonical
528
+ // project-relative location ('' when the unit sits at the root).
529
+ const changedFiles = plan.files.filter((file) => file.changed);
530
+ const unitRoot = resolve(root, unit.source);
531
+ const unitLocation = relative(root, unitRoot);
532
+ if (unitLocation === '..'
533
+ || unitLocation.startsWith(`..${sep}`)
534
+ || isAbsolute(unitLocation)) {
535
+ throw new ReleaseError(
536
+ PATH_UNSAFE,
537
+ 'release unit source escapes the project root',
538
+ { reason: 'UNIT_SOURCE_ESCAPE', unitId },
539
+ );
540
+ }
541
+ const unitPrefix = unitLocation === ''
542
+ ? ''
543
+ : `${unitLocation.split(sep).join('/')}/`;
544
+ const projectPath = (targetPath) => `${unitPrefix}${targetPath}`;
545
+ const writeSet = changedFiles.map((file) => ({
546
+ id: `${file.kind}:${projectPath(file.path)}`,
547
+ path: projectPath(file.path),
548
+ oldEntry: {
549
+ kind: 'regular',
550
+ sha256: file.oldDigest,
551
+ size: file.summary.oldSize,
552
+ mode: modeToString(modes.get(file.path)),
553
+ },
554
+ newEntry: {
555
+ kind: 'regular',
556
+ bytes: Buffer.from(file.newBytes),
557
+ sha256: file.newDigest,
558
+ size: file.summary.newSize,
559
+ mode: modeToString(modes.get(file.path)),
560
+ },
561
+ }));
562
+ const canonicalPlan = {
563
+ apiVersion: 'release-skill.dev/docs-refresh/v1',
564
+ operation: 'refresh',
565
+ unitId,
566
+ version,
567
+ refreshDigest: plan.refreshDigest,
568
+ files: changedFiles.map((file) => ({
569
+ id: `${file.kind}:${projectPath(file.path)}`,
570
+ path: projectPath(file.path),
571
+ kind: file.kind,
572
+ locale: file.locale,
573
+ oldDigest: file.oldDigest,
574
+ newDigest: file.newDigest,
575
+ change: file.change,
576
+ })),
577
+ };
578
+
579
+ let applyResult;
580
+ try {
581
+ applyResult = await applyWriteSetUnderLock({
582
+ root,
583
+ writeSet,
584
+ canonicalPlan,
585
+ planDigest: plan.refreshDigest,
586
+ safeFs: backend,
587
+ faultInjector,
588
+ clock,
589
+ assertLockOwner: () => lock.assertOwner(),
590
+ });
591
+ } catch (err) {
592
+ // Mid-flight failure: the transaction core has already entered the
593
+ // durable RECOVERY_REQUIRED protocol (journal + marker + unique
594
+ // recover command). Restore the exact old bytes under the
595
+ // still-held lock so the workspace never keeps an undeclared
596
+ // partial refresh; the journal state and the recover command
597
+ // remain authoritative for reconciliation.
598
+ if (err instanceof ReleaseError
599
+ && err.code === TRANSACTION_INCOMPLETE
600
+ && typeof err.details?.recover === 'string') {
601
+ const restored = await tryRestoreOldBytes(
602
+ backend,
603
+ resolve(root, unit.source),
604
+ changedFiles,
605
+ modes,
606
+ );
607
+ if (restored) {
608
+ const adjusted = new ReleaseError(TRANSACTION_INCOMPLETE, err.message, {
609
+ ...err.details,
610
+ targetUnchanged: true,
611
+ });
612
+ adjusted.transactionId = err.transactionId;
613
+ throw adjusted;
614
+ }
615
+ }
616
+ throw err;
617
+ }
618
+
619
+ // Post-write read-only re-plan under the lock: success requires a
620
+ // clean plan (the freshly written bytes render to themselves).
621
+ const { plan: recheckPlan } = await planReleaseDocsRefreshForUnit({
622
+ root,
623
+ config,
624
+ unit,
625
+ version,
626
+ backendFactory: sharedFactory,
627
+ });
628
+ if (recheckPlan.status !== 'clean') {
629
+ throw new ReleaseError(
630
+ TRANSACTION_INCOMPLETE,
631
+ 'release documents are not clean after the transactional write',
632
+ { reason: 'POST_WRITE_NOT_CLEAN', unitId, version },
633
+ );
634
+ }
635
+
636
+ return Object.freeze({
637
+ command: 'docs-refresh',
638
+ mode: 'write',
639
+ status: 'refreshed',
640
+ refreshed: true,
641
+ unitId,
642
+ version,
643
+ refreshDigest: plan.refreshDigest,
644
+ transactionId: applyResult.transactionId,
645
+ refreshedPaths: changedFiles.map((file) => file.path),
646
+ });
647
+ });
648
+ } catch (error) {
649
+ primaryError = error;
650
+ }
651
+
652
+ // Release the lock; mirror the artifact-apply combined-error pattern on
653
+ // release failure — never swallow the business error.
654
+ try {
655
+ await lock.release();
656
+ } catch (releaseError) {
657
+ if (primaryError) {
658
+ const combined = new ReleaseError(
659
+ TRANSACTION_INCOMPLETE,
660
+ 'docs refresh failed and project lock release also failed',
661
+ {
662
+ businessErrorCode: primaryError?.code || null,
663
+ releaseErrorCode: releaseError?.code || null,
664
+ },
665
+ );
666
+ combined.cause = primaryError;
667
+ combined.releaseCause = releaseError;
668
+ throw combined;
669
+ }
670
+ throw releaseError;
671
+ }
672
+
673
+ if (primaryError) throw primaryError;
674
+ return result;
675
+ }