release-skill 0.1.1

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 (125) hide show
  1. package/.agents/plugins/marketplace.json +23 -0
  2. package/.claude-plugin/marketplace.json +16 -0
  3. package/.claude-plugin/plugin.json +10 -0
  4. package/.codex-plugin/plugin.json +26 -0
  5. package/CHANGELOG.md +68 -0
  6. package/CODE_OF_CONDUCT.md +76 -0
  7. package/CONTRIBUTING.md +49 -0
  8. package/INSTALL.md +182 -0
  9. package/LICENSE +21 -0
  10. package/NOTICE +25 -0
  11. package/README.md +501 -0
  12. package/README.zh-CN.md +463 -0
  13. package/SECURITY.md +48 -0
  14. package/adapters/claude/.claude-plugin/marketplace.json +16 -0
  15. package/adapters/claude/.claude-plugin/plugin.json +10 -0
  16. package/adapters/claude/skills/release-assess/SKILL.md +52 -0
  17. package/adapters/claude/skills/release-help/SKILL.md +60 -0
  18. package/adapters/claude/skills/release-prepare/SKILL.md +71 -0
  19. package/adapters/claude/skills/release-publish/SKILL.md +55 -0
  20. package/adapters/claude/skills/release-reconcile/SKILL.md +73 -0
  21. package/adapters/claude/skills/release-verify/SKILL.md +70 -0
  22. package/adapters/codex/.codex-plugin/plugin.json +26 -0
  23. package/adapters/codex/skills/release-assess/SKILL.md +52 -0
  24. package/adapters/codex/skills/release-help/SKILL.md +60 -0
  25. package/adapters/codex/skills/release-prepare/SKILL.md +71 -0
  26. package/adapters/codex/skills/release-publish/SKILL.md +55 -0
  27. package/adapters/codex/skills/release-reconcile/SKILL.md +73 -0
  28. package/adapters/codex/skills/release-verify/SKILL.md +70 -0
  29. package/bin/release-skill.mjs +743 -0
  30. package/native/safe-write/binding.gyp +40 -0
  31. package/native/safe-write/prebuilds.json +4 -0
  32. package/native/safe-write/src/safe_write.cc +2023 -0
  33. package/package.json +75 -0
  34. package/references/.render-manifest.json +33 -0
  35. package/references/00-target-state.md +124 -0
  36. package/references/01-state-machine.md +155 -0
  37. package/references/02-project-config.md +217 -0
  38. package/references/03-readme-quality.md +136 -0
  39. package/references/04-supply-chain.md +147 -0
  40. package/references/05-evidence-and-errors.md +164 -0
  41. package/references/06-adapter-contract.md +178 -0
  42. package/schemas/.render-manifest.json +37 -0
  43. package/schemas/approval-record.schema.json +115 -0
  44. package/schemas/artifact-lock.schema.json +111 -0
  45. package/schemas/artifact-plan.schema.json +52 -0
  46. package/schemas/artifact-policy.schema.json +76 -0
  47. package/schemas/evidence-event.schema.json +89 -0
  48. package/schemas/release-plan.schema.json +369 -0
  49. package/schemas/release-project.schema.json +359 -0
  50. package/schemas/release-run.schema.json +195 -0
  51. package/skills/release-assess/SKILL.md +52 -0
  52. package/skills/release-help/SKILL.md +60 -0
  53. package/skills/release-prepare/SKILL.md +71 -0
  54. package/skills/release-publish/SKILL.md +55 -0
  55. package/skills/release-reconcile/SKILL.md +73 -0
  56. package/skills/release-verify/SKILL.md +70 -0
  57. package/skills-src/release-assess/SKILL.md +52 -0
  58. package/skills-src/release-help/SKILL.md +60 -0
  59. package/skills-src/release-prepare/SKILL.md +71 -0
  60. package/skills-src/release-publish/SKILL.md +55 -0
  61. package/skills-src/release-reconcile/SKILL.md +73 -0
  62. package/skills-src/release-verify/SKILL.md +70 -0
  63. package/src/adapters/contract.mjs +214 -0
  64. package/src/adapters/git-github.mjs +214 -0
  65. package/src/adapters/npm.mjs +947 -0
  66. package/src/adapters/plugin-marketplace.mjs +1365 -0
  67. package/src/adapters/push-snapshot.mjs +216 -0
  68. package/src/artifacts/adoption.mjs +743 -0
  69. package/src/artifacts/artifact-plan.mjs +162 -0
  70. package/src/artifacts/entry.mjs +240 -0
  71. package/src/artifacts/git-authority.mjs +637 -0
  72. package/src/artifacts/graph.mjs +189 -0
  73. package/src/artifacts/inspect.mjs +520 -0
  74. package/src/artifacts/inventory.mjs +192 -0
  75. package/src/artifacts/merge/binary.mjs +77 -0
  76. package/src/artifacts/merge/entry-merge.mjs +228 -0
  77. package/src/artifacts/merge/json.mjs +641 -0
  78. package/src/artifacts/merge/markdown.mjs +246 -0
  79. package/src/artifacts/merge/regions.mjs +156 -0
  80. package/src/artifacts/merge/text.mjs +432 -0
  81. package/src/artifacts/merge/tree.mjs +202 -0
  82. package/src/artifacts/merge/yaml.mjs +669 -0
  83. package/src/artifacts/path-key.mjs +94 -0
  84. package/src/artifacts/policy.mjs +319 -0
  85. package/src/artifacts/producer-registry.mjs +439 -0
  86. package/src/artifacts/project-lock.mjs +732 -0
  87. package/src/artifacts/resolution.mjs +658 -0
  88. package/src/artifacts/safe-fs-backend-internal.mjs +680 -0
  89. package/src/artifacts/safe-fs.mjs +72 -0
  90. package/src/artifacts/state.mjs +495 -0
  91. package/src/artifacts/transaction-journal.mjs +983 -0
  92. package/src/artifacts/transaction.mjs +1361 -0
  93. package/src/commands/approve.mjs +280 -0
  94. package/src/commands/artifacts.mjs +627 -0
  95. package/src/commands/assess.mjs +838 -0
  96. package/src/commands/prepare.mjs +1377 -0
  97. package/src/commands/publish.mjs +883 -0
  98. package/src/commands/reconcile.mjs +1255 -0
  99. package/src/commands/verify.mjs +915 -0
  100. package/src/core/approval.mjs +332 -0
  101. package/src/core/baseline.mjs +272 -0
  102. package/src/core/blackbox-hard-gates.mjs +142 -0
  103. package/src/core/config.mjs +448 -0
  104. package/src/core/digest.mjs +90 -0
  105. package/src/core/errors.mjs +113 -0
  106. package/src/core/evidence.mjs +167 -0
  107. package/src/core/hooks.mjs +241 -0
  108. package/src/core/node-version.mjs +64 -0
  109. package/src/core/plan.mjs +735 -0
  110. package/src/core/previous-public-baseline.mjs +204 -0
  111. package/src/core/run.mjs +681 -0
  112. package/src/core/state-machine.mjs +76 -0
  113. package/src/core/version-consistency.mjs +111 -0
  114. package/src/producers/build-adapters.mjs +231 -0
  115. package/src/producers/render-public-assets.mjs +152 -0
  116. package/src/producers/sync-skills.mjs +96 -0
  117. package/src/readme/contract.mjs +297 -0
  118. package/src/readme/examples.mjs +288 -0
  119. package/src/readme/parity.mjs +122 -0
  120. package/src/snapshot/export.mjs +99 -0
  121. package/src/snapshot/frozen.mjs +401 -0
  122. package/src/snapshot/manifest.mjs +207 -0
  123. package/src/snapshot/public-map.mjs +1459 -0
  124. package/src/snapshot/public-path.mjs +110 -0
  125. package/src/snapshot/scan.mjs +419 -0
@@ -0,0 +1,732 @@
1
+ /**
2
+ * Shared project lock for artifact commands.
3
+ *
4
+ * All mutating artifact commands (apply, accept, recover, resolve submit,
5
+ * prepare) share a single project lock domain. The lock is acquired via
6
+ * exclusive `mkdir(.release-skill/lock)` — atomic on all POSIX filesystems.
7
+ *
8
+ * Owner record contains: pid, host, bootId (or session id), nonce, command,
9
+ * startedAt. The owner JSON and parent directory are fsynced before the
10
+ * acquire call returns success.
11
+ *
12
+ * TTL is informational only — aging a lock directory never permits automatic
13
+ * deletion. Only the exact owner can release, or an operator can break the
14
+ * lock with `breakProjectLock` which requires matching the exact owner and
15
+ * writes audit evidence.
16
+ *
17
+ * @module artifacts/project-lock
18
+ */
19
+
20
+ import { mkdir, rm, writeFile, readFile, readdir, stat, lstat, open } from 'node:fs/promises';
21
+ import { readFileSync } from 'node:fs';
22
+ import { join } from 'node:path';
23
+ import { hostname } from 'node:os';
24
+ import { randomBytes } from 'node:crypto';
25
+
26
+ import {
27
+ ReleaseError,
28
+ TRANSACTION_INCOMPLETE,
29
+ PATH_UNSAFE,
30
+ } from '../core/errors.mjs';
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // Constants
34
+ // ---------------------------------------------------------------------------
35
+
36
+ const LOCK_DIR_NAME = 'lock';
37
+ const OWNER_FILE_NAME = '.owner';
38
+ const AUDIT_DIR_NAME = 'lock-audit';
39
+
40
+ /** All owner fields that must match exactly. */
41
+ const OWNER_FIELDS = ['pid', 'host', 'bootId', 'nonce', 'command', 'startedAt'];
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // Owner construction
45
+ // ---------------------------------------------------------------------------
46
+
47
+ /**
48
+ * Build an owner record for the current process.
49
+ *
50
+ * @param {string} command - The command acquiring the lock.
51
+ * @param {() => string} [clock] - Clock function for timestamps.
52
+ * @returns {object} Frozen owner record.
53
+ */
54
+ function buildOwner(command, clock) {
55
+ const startedAt = clock ? clock() : new Date().toISOString();
56
+ assertIsoTimestamp(startedAt, 'clock result');
57
+ return Object.freeze({
58
+ pid: process.pid,
59
+ host: hostname(),
60
+ bootId: getBootId(),
61
+ nonce: randomBytes(16).toString('hex'),
62
+ command,
63
+ startedAt,
64
+ });
65
+ }
66
+
67
+ /**
68
+ * Get a boot or session identifier.
69
+ *
70
+ * On Linux, reads /proc/sys/kernel/random/boot_id. On other platforms,
71
+ * falls back to a process-lifetime constant derived from process.pid +
72
+ * start time.
73
+ *
74
+ * @returns {string} Boot/session identifier.
75
+ */
76
+ function getBootId() {
77
+ try {
78
+ // Linux: stable across reboots
79
+ return readFileSync('/proc/sys/kernel/random/boot_id', 'utf8').trim();
80
+ } catch {
81
+ // Fallback: pid + uptime at module load time (stable within process)
82
+ return `pid-${process.pid}-uptime-${Math.floor(process.uptime())}`;
83
+ }
84
+ }
85
+
86
+ // ---------------------------------------------------------------------------
87
+ // Internal: path helpers
88
+ // ---------------------------------------------------------------------------
89
+
90
+ function lockDir(root) {
91
+ return join(root, '.release-skill', LOCK_DIR_NAME);
92
+ }
93
+
94
+ function ownerPath(root) {
95
+ return join(lockDir(root), OWNER_FILE_NAME);
96
+ }
97
+
98
+ function auditDir(root) {
99
+ return join(root, '.release-skill', AUDIT_DIR_NAME);
100
+ }
101
+
102
+ // ---------------------------------------------------------------------------
103
+ // Internal: owner validation
104
+ // ---------------------------------------------------------------------------
105
+
106
+ /**
107
+ * Validate that an expectedOwner object is structurally sound:
108
+ * - Must be a plain object (not array, not null)
109
+ * - Must have exactly the 6 required fields (no extra, no missing)
110
+ * - pid must be a positive integer
111
+ * - All other fields must be non-empty strings
112
+ * - String fields must not contain control characters
113
+ *
114
+ * @param {object} owner - The owner object to validate.
115
+ * @throws {ReleaseError} PATH_UNSAFE on any violation.
116
+ */
117
+ function validateOwnerObject(owner) {
118
+ if (!owner || typeof owner !== 'object' || Array.isArray(owner)) {
119
+ throw new ReleaseError(PATH_UNSAFE, 'expectedOwner must be a plain object', {});
120
+ }
121
+
122
+ const keys = Object.keys(owner);
123
+ const expectedSet = new Set(OWNER_FIELDS);
124
+ const actualSet = new Set(keys);
125
+
126
+ if (keys.length !== OWNER_FIELDS.length) {
127
+ throw new ReleaseError(
128
+ PATH_UNSAFE,
129
+ `expectedOwner must have exactly ${OWNER_FIELDS.length} fields; got ${keys.length}`,
130
+ {},
131
+ );
132
+ }
133
+
134
+ for (const field of OWNER_FIELDS) {
135
+ if (!actualSet.has(field)) {
136
+ throw new ReleaseError(PATH_UNSAFE, `expectedOwner missing required field: ${field}`, {});
137
+ }
138
+ }
139
+
140
+ for (const key of keys) {
141
+ if (!expectedSet.has(key)) {
142
+ throw new ReleaseError(PATH_UNSAFE, `expectedOwner has unexpected field: ${key}`, {});
143
+ }
144
+ }
145
+
146
+ // Type validation
147
+ if (typeof owner.pid !== 'number' || !Number.isInteger(owner.pid) || owner.pid <= 0) {
148
+ throw new ReleaseError(PATH_UNSAFE, 'expectedOwner.pid must be a positive integer', {});
149
+ }
150
+
151
+ const stringFields = OWNER_FIELDS.filter((f) => f !== 'pid');
152
+ for (const field of stringFields) {
153
+ if (typeof owner[field] !== 'string' || owner[field].trim().length === 0) {
154
+ throw new ReleaseError(PATH_UNSAFE, `expectedOwner.${field} must be a non-empty string`, {});
155
+ }
156
+ }
157
+
158
+ // Control character check on all string fields
159
+ for (const field of stringFields) {
160
+ if (/[\x00-\x1f\x7f]/.test(owner[field])) {
161
+ throw new ReleaseError(PATH_UNSAFE, `expectedOwner.${field} contains control characters`, {});
162
+ }
163
+ }
164
+
165
+ // nonce becomes part of an audit filename, so accept only the format
166
+ // produced by buildOwner(). This excludes separators, dot segments and
167
+ // platform-specific path syntax by construction.
168
+ if (!/^[a-f0-9]{32}$/.test(owner.nonce)) {
169
+ throw new ReleaseError(
170
+ PATH_UNSAFE,
171
+ 'expectedOwner.nonce must be exactly 32 lowercase hexadecimal characters',
172
+ {},
173
+ );
174
+ }
175
+ if (!/^[A-Za-z0-9._:-]+$/.test(owner.host) || !/^[A-Za-z0-9._:-]+$/.test(owner.bootId)) {
176
+ throw new ReleaseError(PATH_UNSAFE, 'expectedOwner host/bootId contains unsafe characters', {});
177
+ }
178
+ if (sanitizeReason(owner.command) !== owner.command) {
179
+ throw new ReleaseError(PATH_UNSAFE, 'expectedOwner.command must not contain absolute paths', {});
180
+ }
181
+ assertIsoTimestamp(owner.startedAt, 'expectedOwner.startedAt');
182
+ }
183
+
184
+ function assertIsoTimestamp(value, label) {
185
+ if (
186
+ typeof value !== 'string'
187
+ || !Number.isFinite(Date.parse(value))
188
+ || new Date(value).toISOString() !== value
189
+ ) {
190
+ throw new ReleaseError(PATH_UNSAFE, `${label} must be a canonical ISO-8601 timestamp`, {});
191
+ }
192
+ }
193
+
194
+ // ---------------------------------------------------------------------------
195
+ // Internal: reason sanitization
196
+ // ---------------------------------------------------------------------------
197
+
198
+ /**
199
+ * Sanitize a reason string for audit records.
200
+ *
201
+ * If the reason contains absolute paths (e.g. /Users/..., /home/...),
202
+ * they are deterministically replaced with path-agnostic placeholders.
203
+ * This prevents leaking local filesystem layout into audit files.
204
+ *
205
+ * @param {string} reason - Raw reason text.
206
+ * @returns {string} Sanitized reason text.
207
+ */
208
+ function sanitizeReason(reason) {
209
+ return reason
210
+ .replace(/\/Users\/[^\s,;:'")\]]+/g, '<user-path>')
211
+ .replace(/\/home\/[^\s,;:'")\]]+/g, '<user-path>')
212
+ .replace(/\/tmp\/[^\s,;:'")\]]+/g, '<temp-path>')
213
+ .replace(/(^|[\s("'=])\/(?!\/)[^\s,;:'")\]]+/g, '$1<absolute-path>')
214
+ .replace(/(^|[\s("'=])(?:[A-Za-z]:[\\/]|\\\\)[^\s,;:'")\]]+/g, '$1<absolute-path>');
215
+ }
216
+
217
+ async function emitDurability(observer, event) {
218
+ if (!observer) return;
219
+ try {
220
+ await observer(Object.freeze(event));
221
+ } catch {
222
+ // Observation must never weaken, skip or fail a durability operation.
223
+ }
224
+ }
225
+
226
+ async function fsyncFileObserved(filePath, observer) {
227
+ await fsyncFile(filePath);
228
+ await emitDurability(observer, { operation: 'fsync-file', path: filePath });
229
+ }
230
+
231
+ async function fsyncDirObserved(dirPath, observer) {
232
+ await fsyncDir(dirPath);
233
+ await emitDurability(observer, { operation: 'fsync-dir', path: dirPath });
234
+ }
235
+
236
+ // ---------------------------------------------------------------------------
237
+ // Internal: assert owner matches on disk
238
+ // ---------------------------------------------------------------------------
239
+
240
+ /**
241
+ * Read the persisted owner from disk and compare with the expected owner.
242
+ * All six fields (pid, host, bootId, nonce, command, startedAt) must match
243
+ * exactly.
244
+ *
245
+ * @param {object} expected - The expected owner record.
246
+ * @param {string} root - Repository root.
247
+ * @returns {Promise<void>}
248
+ * @throws {ReleaseError} TRANSACTION_INCOMPLETE if owner mismatch or missing.
249
+ */
250
+ async function assertOwnerOnDisk(expected, root) {
251
+ let raw;
252
+ try {
253
+ raw = await readFile(ownerPath(root), 'utf8');
254
+ } catch (err) {
255
+ if (err.code === 'ENOENT') {
256
+ throw new ReleaseError(
257
+ TRANSACTION_INCOMPLETE,
258
+ 'project lock directory does not exist — ownership lost',
259
+ { root },
260
+ );
261
+ }
262
+ throw err;
263
+ }
264
+
265
+ let actual;
266
+ try {
267
+ actual = JSON.parse(raw);
268
+ } catch {
269
+ throw new ReleaseError(
270
+ TRANSACTION_INCOMPLETE,
271
+ 'project lock owner file is corrupt',
272
+ { root },
273
+ );
274
+ }
275
+
276
+ for (const field of OWNER_FIELDS) {
277
+ if (actual[field] !== expected[field]) {
278
+ throw new ReleaseError(
279
+ TRANSACTION_INCOMPLETE,
280
+ `project lock owner does not match — field "${field}" differs`,
281
+ { root, field, expected: field === 'nonce' ? expected[field]?.slice(0, 8) : undefined },
282
+ );
283
+ }
284
+ }
285
+ }
286
+
287
+ /**
288
+ * Remove the lock directory only if the persisted owner matches exactly.
289
+ * After removal, fsync the parent .release-skill directory for durability.
290
+ *
291
+ * @param {object} expected - The expected owner record.
292
+ * @param {string} root - Repository root.
293
+ * @param {(event: object) => Promise<void>} [durabilityObserver] - Observe completed durability operations.
294
+ * @returns {Promise<void>}
295
+ * @throws {ReleaseError} TRANSACTION_INCOMPLETE if owner mismatch.
296
+ */
297
+ async function removeLockIfExactOwner(expected, root, durabilityObserver) {
298
+ await assertOwnerOnDisk(expected, root);
299
+ await rm(lockDir(root), { recursive: true, force: true });
300
+ await emitDurability(durabilityObserver, { operation: 'remove-dir', path: lockDir(root) });
301
+ // Fsync parent directory to persist the lock removal
302
+ await fsyncDirObserved(join(root, '.release-skill'), durabilityObserver);
303
+ }
304
+
305
+ // ---------------------------------------------------------------------------
306
+ // Internal: fsync helpers
307
+ // ---------------------------------------------------------------------------
308
+
309
+ /**
310
+ * Fsync a file by path — opens, syncs, closes.
311
+ *
312
+ * @param {string} filePath
313
+ */
314
+ async function fsyncFile(filePath) {
315
+ const fh = await open(filePath, 'r');
316
+ try {
317
+ await fh.sync();
318
+ } finally {
319
+ await fh.close();
320
+ }
321
+ }
322
+
323
+ /**
324
+ * Fsync a directory by path.
325
+ *
326
+ * @param {string} dirPath
327
+ */
328
+ async function fsyncDir(dirPath) {
329
+ const fh = await open(dirPath, 'r');
330
+ try {
331
+ await fh.sync();
332
+ } finally {
333
+ await fh.close();
334
+ }
335
+ }
336
+
337
+ // ---------------------------------------------------------------------------
338
+ // Internal: symlink/non-directory fail-closed checks
339
+ // ---------------------------------------------------------------------------
340
+
341
+ /**
342
+ * Assert that a path is not a symlink and, if it exists, is a directory.
343
+ * Fails closed with PATH_UNSAFE on any violation.
344
+ *
345
+ * @param {string} dirPath - Path to check.
346
+ * @param {string} label - Human label for error messages.
347
+ * @returns {Promise<void>}
348
+ * @throws {ReleaseError} PATH_UNSAFE if symlink or non-directory.
349
+ */
350
+ async function assertNotSymlinkOrFile(dirPath, label) {
351
+ let st;
352
+ try {
353
+ st = await lstat(dirPath);
354
+ } catch (err) {
355
+ if (err.code === 'ENOENT') return; // doesn't exist yet — OK
356
+ throw err;
357
+ }
358
+ if (st.isSymbolicLink()) {
359
+ throw new ReleaseError(
360
+ PATH_UNSAFE,
361
+ `${label} is a symlink — refusing to operate on symlinked path`,
362
+ { path: dirPath },
363
+ );
364
+ }
365
+ if (!st.isDirectory()) {
366
+ throw new ReleaseError(
367
+ PATH_UNSAFE,
368
+ `${label} exists but is not a directory`,
369
+ { path: dirPath },
370
+ );
371
+ }
372
+ }
373
+
374
+ /**
375
+ * Assert that the .release-skill and lock directories are not symlinks.
376
+ * Checks every level: .release-skill, .release-skill/lock, .release-skill/lock-audit.
377
+ *
378
+ * @param {string} root - Repository root.
379
+ * @returns {Promise<void>}
380
+ */
381
+ async function assertLockPathsNotSymlinks(root) {
382
+ const releaseSkillDir = join(root, '.release-skill');
383
+ await assertNotSymlinkOrFile(releaseSkillDir, '.release-skill');
384
+ await assertNotSymlinkOrFile(lockDir(root), '.release-skill/lock');
385
+ }
386
+
387
+ /**
388
+ * Assert that the audit directory is not a symlink.
389
+ *
390
+ * @param {string} root - Repository root.
391
+ * @returns {Promise<void>}
392
+ */
393
+ async function assertAuditPathNotSymlink(root) {
394
+ const releaseSkillDir = join(root, '.release-skill');
395
+ await assertNotSymlinkOrFile(releaseSkillDir, '.release-skill');
396
+ await assertNotSymlinkOrFile(auditDir(root), '.release-skill/lock-audit');
397
+ }
398
+
399
+ // ---------------------------------------------------------------------------
400
+ // Public API
401
+ // ---------------------------------------------------------------------------
402
+
403
+ /**
404
+ * Acquire the project lock.
405
+ *
406
+ * Uses exclusive `mkdir` to atomically claim the lock. The owner record
407
+ * is written to `.owner` and fsynced before returning.
408
+ *
409
+ * If the lock is already held, throws `TRANSACTION_INCOMPLETE` — there is
410
+ * no automatic stale lock breakage based on TTL.
411
+ *
412
+ * @param {object} options
413
+ * @param {string} options.root - Repository root (absolute).
414
+ * @param {string} options.command - The command acquiring the lock (e.g. 'apply', 'accept').
415
+ * @param {'exclusive'} [options.mode='exclusive'] - Lock mode (currently only exclusive).
416
+ * @param {() => string} [options.clock] - Clock function for timestamps.
417
+ * @param {(event: object) => Promise<void>} [options.durabilityObserver] - Best-effort observer; cannot replace or interrupt fsync.
418
+ * @param {(point: string) => Promise<void>} [options.faultInjector] - Test-only safe failure injection.
419
+ * @returns {Promise<ProjectLock>}
420
+ * @throws {ReleaseError} TRANSACTION_INCOMPLETE if lock is already held.
421
+ */
422
+ export async function acquireProjectLock({
423
+ root,
424
+ command,
425
+ mode = 'exclusive',
426
+ clock,
427
+ durabilityObserver,
428
+ faultInjector,
429
+ } = {}) {
430
+ if (!root || typeof root !== 'string') {
431
+ throw new ReleaseError(PATH_UNSAFE, 'root must be a non-empty string', { root });
432
+ }
433
+ if (!command || typeof command !== 'string' || command.trim().length === 0) {
434
+ throw new ReleaseError(PATH_UNSAFE, 'command must be a non-empty string', { command });
435
+ }
436
+ if (/[\x00-\x1f\x7f]/.test(command)) {
437
+ throw new ReleaseError(PATH_UNSAFE, 'command contains control characters', {});
438
+ }
439
+ if (sanitizeReason(command) !== command) {
440
+ throw new ReleaseError(PATH_UNSAFE, 'command must not contain absolute paths', {});
441
+ }
442
+ if (mode !== 'exclusive') {
443
+ throw new ReleaseError(
444
+ PATH_UNSAFE,
445
+ `lock mode must be "exclusive"; "${mode}" is not supported`,
446
+ { mode },
447
+ );
448
+ }
449
+
450
+ // Construct and validate the owner before touching the filesystem so a bad
451
+ // injected clock cannot leave a directory without an owner.
452
+ const owner = buildOwner(command, clock);
453
+
454
+ // Symlink/non-directory fail-closed: check every path level before touching fs
455
+ const releaseSkillDir = join(root, '.release-skill');
456
+ await assertLockPathsNotSymlinks(root);
457
+
458
+ // Ensure parent directory exists (only if not already checked as non-symlink)
459
+ let parentExisted = false;
460
+ try {
461
+ await lstat(releaseSkillDir);
462
+ parentExisted = true;
463
+ } catch (err) {
464
+ if (err.code !== 'ENOENT') throw err;
465
+ }
466
+ if (!parentExisted) {
467
+ await mkdir(releaseSkillDir, { recursive: true, mode: 0o700 });
468
+ await emitDurability(durabilityObserver, { operation: 'create-dir', path: releaseSkillDir });
469
+ // Persist the new .release-skill directory entry in root.
470
+ await fsyncDirObserved(root, durabilityObserver);
471
+ }
472
+
473
+ const dir = lockDir(root);
474
+
475
+ // Atomic lock acquisition via mkdir. Everything after successful mkdir and
476
+ // before returning is inside one cleanup boundary, so any write/fsync/fault
477
+ // failure cannot leave an ownerless lock directory.
478
+ let lockCreated = false;
479
+ try {
480
+ await mkdir(dir, { recursive: false, mode: 0o700 });
481
+ lockCreated = true;
482
+ } catch (err) {
483
+ if (err.code === 'EEXIST') {
484
+ // Lock is held — TTL never permits automatic breakage
485
+ throw new ReleaseError(
486
+ TRANSACTION_INCOMPLETE,
487
+ 'project lock is already held; another command is in progress',
488
+ { root, lockDir: dir },
489
+ );
490
+ }
491
+ throw err;
492
+ }
493
+
494
+ const ownerFilePath = join(dir, OWNER_FILE_NAME);
495
+ try {
496
+ await emitDurability(durabilityObserver, { operation: 'create-dir', path: dir });
497
+ if (faultInjector) await faultInjector('after-lock-create');
498
+ await fsyncDirObserved(releaseSkillDir, durabilityObserver);
499
+
500
+ await writeFile(ownerFilePath, JSON.stringify(owner), { mode: 0o600, flag: 'wx' });
501
+ await emitDurability(durabilityObserver, { operation: 'write-file', path: ownerFilePath });
502
+ if (faultInjector) await faultInjector('after-owner-write');
503
+
504
+ // Fsync owner file then lock directory for durability
505
+ await fsyncFileObserved(ownerFilePath, durabilityObserver);
506
+ await fsyncDirObserved(dir, durabilityObserver);
507
+ } catch (writeErr) {
508
+ // Cleanup: remove the lock dir if owner write failed — prevents zombie lock
509
+ // without an owner file (which cannot be broken since break requires owner).
510
+ if (!lockCreated) throw writeErr;
511
+ try {
512
+ await rm(dir, { recursive: true, force: true });
513
+ await emitDurability(durabilityObserver, { operation: 'remove-dir', path: dir });
514
+ await fsyncDirObserved(releaseSkillDir, durabilityObserver);
515
+ } catch (cleanupErr) {
516
+ const incomplete = new ReleaseError(
517
+ TRANSACTION_INCOMPLETE,
518
+ 'project lock acquisition failed and cleanup could not be made durable',
519
+ {
520
+ acquireErrorCode: typeof writeErr?.code === 'string' ? writeErr.code : null,
521
+ cleanupErrorCode: typeof cleanupErr?.code === 'string' ? cleanupErr.code : null,
522
+ },
523
+ );
524
+ incomplete.cause = writeErr;
525
+ incomplete.cleanupCause = cleanupErr;
526
+ throw incomplete;
527
+ }
528
+ throw writeErr;
529
+ }
530
+
531
+ return Object.freeze({
532
+ owner,
533
+
534
+ /**
535
+ * Run a function while asserting lock ownership before and after.
536
+ *
537
+ * Post-owner check runs regardless of whether fn succeeds or throws.
538
+ * If fn throws AND post-owner check fails, the error is TRANSACTION_INCOMPLETE
539
+ * with the original business error as `cause` (fail-closed, never loses the error).
540
+ *
541
+ * @param {() => Promise<T>} fn - Function to execute under lock.
542
+ * @returns {Promise<T>} Result of fn.
543
+ * @throws {ReleaseError} if ownership verification fails.
544
+ */
545
+ async capture(fn) {
546
+ await assertOwnerOnDisk(owner, root);
547
+ let fnResult;
548
+ try {
549
+ fnResult = await fn();
550
+ } catch (fnErr) {
551
+ // fn threw — still perform post-owner check (fail-closed)
552
+ try {
553
+ await assertOwnerOnDisk(owner, root);
554
+ } catch {
555
+ // Both fn error AND owner lost — fail closed with TRANSACTION_INCOMPLETE,
556
+ // preserve the original business error as cause
557
+ const lockError = new ReleaseError(
558
+ TRANSACTION_INCOMPLETE,
559
+ 'business error and lock ownership lost during capture',
560
+ { businessErrorCode: typeof fnErr?.code === 'string' ? fnErr.code : null },
561
+ );
562
+ lockError.cause = fnErr;
563
+ throw lockError;
564
+ }
565
+ // Owner still held — re-throw the original business error
566
+ throw fnErr;
567
+ }
568
+ // fn succeeded — post-owner check
569
+ await assertOwnerOnDisk(owner, root);
570
+ return fnResult;
571
+ },
572
+
573
+ /**
574
+ * Assert that the current process still owns the lock.
575
+ *
576
+ * @returns {Promise<void>}
577
+ * @throws {ReleaseError} TRANSACTION_INCOMPLETE if ownership lost.
578
+ */
579
+ async assertOwner() {
580
+ return assertOwnerOnDisk(owner, root);
581
+ },
582
+
583
+ /**
584
+ * Release the lock. Only succeeds if the persisted owner matches exactly.
585
+ * After removal, fsyncs the parent .release-skill directory for durability.
586
+ *
587
+ * @returns {Promise<void>}
588
+ * @throws {ReleaseError} TRANSACTION_INCOMPLETE if owner mismatch.
589
+ */
590
+ async release() {
591
+ return removeLockIfExactOwner(owner, root, durabilityObserver);
592
+ },
593
+ });
594
+ }
595
+
596
+ /**
597
+ * Break a project lock by force.
598
+ *
599
+ * Requires the exact owner record to match what is persisted on disk.
600
+ * Writes an audit record to `.release-skill/lock-audit/` before removing
601
+ * the lock directory.
602
+ *
603
+ * @param {object} options
604
+ * @param {string} options.root - Repository root (absolute).
605
+ * @param {object} options.expectedOwner - The exact owner record to match.
606
+ * @param {string} options.reason - Human-readable reason for breaking the lock.
607
+ * @param {() => string} [options.clock] - Clock function for timestamps.
608
+ * @param {(event: object) => Promise<void>} [options.durabilityObserver] - Observe completed durability operations; cannot replace fsync.
609
+ * @returns {Promise<AuditRecord>}
610
+ * @throws {ReleaseError} if owner does not match or lock does not exist.
611
+ */
612
+ export async function breakProjectLock({ root, expectedOwner, reason, clock, durabilityObserver } = {}) {
613
+ if (!root || typeof root !== 'string') {
614
+ throw new ReleaseError(PATH_UNSAFE, 'root must be a non-empty string', { root });
615
+ }
616
+
617
+ // Strict owner validation before any filesystem operations
618
+ validateOwnerObject(expectedOwner);
619
+
620
+ if (typeof reason !== 'string' || reason.trim().length === 0) {
621
+ throw new ReleaseError(PATH_UNSAFE, 'reason must be a non-empty string (trimmed)', {});
622
+ }
623
+ if (/[\x00-\x1f\x7f]/.test(reason)) {
624
+ throw new ReleaseError(PATH_UNSAFE, 'reason contains control characters', {});
625
+ }
626
+ const trimmedReason = sanitizeReason(reason.trim());
627
+ const brokenAt = clock ? clock() : new Date().toISOString();
628
+ assertIsoTimestamp(brokenAt, 'clock result');
629
+
630
+ // Symlink/non-directory fail-closed check before reading owner
631
+ await assertLockPathsNotSymlinks(root);
632
+
633
+ // Read persisted owner
634
+ let raw;
635
+ try {
636
+ raw = await readFile(ownerPath(root), 'utf8');
637
+ } catch (err) {
638
+ if (err.code === 'ENOENT') {
639
+ throw new ReleaseError(
640
+ TRANSACTION_INCOMPLETE,
641
+ 'no project lock to break — lock directory does not exist',
642
+ { root },
643
+ );
644
+ }
645
+ throw err;
646
+ }
647
+
648
+ let actualOwner;
649
+ try {
650
+ actualOwner = JSON.parse(raw);
651
+ } catch {
652
+ throw new ReleaseError(
653
+ TRANSACTION_INCOMPLETE,
654
+ 'project lock owner file is corrupt — cannot break safely',
655
+ { root },
656
+ );
657
+ }
658
+
659
+ // Exact owner match: all six fields must match
660
+ for (const field of OWNER_FIELDS) {
661
+ if (actualOwner[field] !== expectedOwner[field]) {
662
+ throw new ReleaseError(
663
+ TRANSACTION_INCOMPLETE,
664
+ `break-lock rejected: expectedOwner does not match persisted owner (field: ${field})`,
665
+ { root, field },
666
+ );
667
+ }
668
+ }
669
+
670
+ // Check audit path is not a symlink before writing
671
+ await assertAuditPathNotSymlink(root);
672
+
673
+ // Build audit record — sanitize: no absolute paths in the JSON
674
+ const safeOriginalOwner = Object.freeze({
675
+ pid: actualOwner.pid,
676
+ host: actualOwner.host,
677
+ bootId: actualOwner.bootId,
678
+ nonce: actualOwner.nonce,
679
+ command: actualOwner.command,
680
+ startedAt: actualOwner.startedAt,
681
+ });
682
+
683
+ const auditRecord = Object.freeze({
684
+ brokenAt,
685
+ reason: trimmedReason,
686
+ originalOwner: safeOriginalOwner,
687
+ breakerPid: process.pid,
688
+ breakerHost: hostname(),
689
+ });
690
+
691
+ // Write audit evidence before removing lock
692
+ const auditDirectory = auditDir(root);
693
+ await mkdir(auditDirectory, { recursive: true, mode: 0o700 });
694
+ await emitDurability(durabilityObserver, { operation: 'create-dir', path: auditDirectory });
695
+
696
+ // Fsync .release-skill after creating audit directory
697
+ await fsyncDirObserved(join(root, '.release-skill'), durabilityObserver);
698
+
699
+ const safeTimestamp = auditRecord.brokenAt.replace(/[^A-Za-z0-9_-]/g, '-');
700
+ const auditFileName = `${safeTimestamp}-${actualOwner.nonce}.json`;
701
+ const auditFilePath = join(auditDirectory, auditFileName);
702
+ await writeFile(auditFilePath, JSON.stringify(auditRecord, null, 2), { mode: 0o600, flag: 'wx' });
703
+ await emitDurability(durabilityObserver, { operation: 'write-file', path: auditFilePath });
704
+ await fsyncFileObserved(auditFilePath, durabilityObserver);
705
+ await fsyncDirObserved(auditDirectory, durabilityObserver);
706
+
707
+ // Remove the lock directory
708
+ await rm(lockDir(root), { recursive: true, force: true });
709
+ await emitDurability(durabilityObserver, { operation: 'remove-dir', path: lockDir(root) });
710
+
711
+ // Fsync parent after lock removal
712
+ await fsyncDirObserved(join(root, '.release-skill'), durabilityObserver);
713
+
714
+ return auditRecord;
715
+ }
716
+
717
+ /**
718
+ * @typedef {object} ProjectLock
719
+ * @property {object} owner - The owner record.
720
+ * @property {(fn: () => Promise<T>) => Promise<T>} capture - Run fn under lock ownership assertion.
721
+ * @property {() => Promise<void>} assertOwner - Verify current process owns the lock.
722
+ * @property {() => Promise<void>} release - Release the lock.
723
+ */
724
+
725
+ /**
726
+ * @typedef {object} AuditRecord
727
+ * @property {string} brokenAt - ISO timestamp of when the lock was broken.
728
+ * @property {string} reason - Human-readable reason.
729
+ * @property {object} originalOwner - The owner that was broken.
730
+ * @property {number} breakerPid - PID of the process that broke the lock.
731
+ * @property {string} breakerHost - Hostname of the breaker.
732
+ */