release-skill 0.4.1 → 0.5.0

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 (100) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codebuddy-plugin/plugin.json +1 -1
  4. package/.codex-plugin/plugin.json +2 -2
  5. package/.kimi-plugin/plugin.json +1 -1
  6. package/CHANGELOG.md +41 -0
  7. package/INSTALL.md +2 -2
  8. package/INSTALL.zh-CN.md +2 -2
  9. package/README.md +27 -9
  10. package/README.zh-CN.md +23 -8
  11. package/adapters/claude/.claude-plugin/marketplace.json +1 -1
  12. package/adapters/claude/.claude-plugin/plugin.json +1 -1
  13. package/adapters/claude/bin/error-codes.json +154 -0
  14. package/adapters/claude/bin/kernel-protocol.json +60 -0
  15. package/adapters/claude/bin/registry.json +165 -0
  16. package/adapters/claude/bin/release-skill.bundle.mjs +3918 -1462
  17. package/adapters/claude/bin/rules.json +104 -0
  18. package/adapters/claude/schemas/release-plan.schema.json +5 -0
  19. package/adapters/claude/schemas/release-project.schema.json +5 -0
  20. package/adapters/claude/skills/release-assess/SKILL.md +2 -0
  21. package/adapters/claude/skills/release-help/SKILL.md +3 -1
  22. package/adapters/claude/skills/release-prepare/SKILL.md +2 -2
  23. package/adapters/claude/skills/release-verify/SKILL.md +2 -0
  24. package/adapters/codex/.codex-plugin/plugin.json +2 -2
  25. package/adapters/codex/bin/error-codes.json +154 -0
  26. package/adapters/codex/bin/kernel-protocol.json +60 -0
  27. package/adapters/codex/bin/registry.json +165 -0
  28. package/adapters/codex/bin/release-skill.bundle.mjs +3918 -1462
  29. package/adapters/codex/bin/rules.json +104 -0
  30. package/adapters/codex/schemas/release-plan.schema.json +5 -0
  31. package/adapters/codex/schemas/release-project.schema.json +5 -0
  32. package/adapters/codex/skills/release-assess/SKILL.md +2 -0
  33. package/adapters/codex/skills/release-help/SKILL.md +3 -1
  34. package/adapters/codex/skills/release-prepare/SKILL.md +2 -2
  35. package/adapters/codex/skills/release-verify/SKILL.md +2 -0
  36. package/adapters/kimi/.kimi-plugin/plugin.json +1 -1
  37. package/adapters/kimi/bin/error-codes.json +154 -0
  38. package/adapters/kimi/bin/kernel-protocol.json +60 -0
  39. package/adapters/kimi/bin/registry.json +165 -0
  40. package/adapters/kimi/bin/release-skill.bundle.mjs +3918 -1462
  41. package/adapters/kimi/bin/rules.json +104 -0
  42. package/adapters/kimi/schemas/release-plan.schema.json +5 -0
  43. package/adapters/kimi/schemas/release-project.schema.json +5 -0
  44. package/adapters/kimi/skills/release-assess/SKILL.md +2 -0
  45. package/adapters/kimi/skills/release-help/SKILL.md +3 -1
  46. package/adapters/kimi/skills/release-prepare/SKILL.md +2 -2
  47. package/adapters/kimi/skills/release-verify/SKILL.md +2 -0
  48. package/adapters/workbuddy/.codebuddy-plugin/plugin.json +1 -1
  49. package/adapters/workbuddy/bin/error-codes.json +154 -0
  50. package/adapters/workbuddy/bin/kernel-protocol.json +60 -0
  51. package/adapters/workbuddy/bin/registry.json +165 -0
  52. package/adapters/workbuddy/bin/release-skill.bundle.mjs +3918 -1462
  53. package/adapters/workbuddy/bin/rules.json +104 -0
  54. package/adapters/workbuddy/schemas/release-plan.schema.json +5 -0
  55. package/adapters/workbuddy/schemas/release-project.schema.json +5 -0
  56. package/adapters/workbuddy/skills/release-assess/SKILL.md +2 -0
  57. package/adapters/workbuddy/skills/release-help/SKILL.md +3 -1
  58. package/adapters/workbuddy/skills/release-prepare/SKILL.md +2 -2
  59. package/adapters/workbuddy/skills/release-verify/SKILL.md +2 -0
  60. package/bin/error-codes.json +154 -0
  61. package/bin/kernel-protocol.json +60 -0
  62. package/bin/registry.json +165 -0
  63. package/bin/release-skill.bundle.mjs +3918 -1462
  64. package/bin/rules.json +104 -0
  65. package/package.json +3 -1
  66. package/schemas/release-plan.schema.json +5 -0
  67. package/schemas/release-project.schema.json +5 -0
  68. package/scripts/build-bundle.mjs +159 -5
  69. package/scripts/sync-public-files.mjs +5 -0
  70. package/skills/release-assess/SKILL.md +2 -0
  71. package/skills/release-help/SKILL.md +3 -1
  72. package/skills/release-prepare/SKILL.md +2 -2
  73. package/skills/release-verify/SKILL.md +2 -0
  74. package/skills-src/release-assess/SKILL.md +2 -0
  75. package/skills-src/release-help/SKILL.md +3 -1
  76. package/skills-src/release-prepare/SKILL.md +2 -2
  77. package/skills-src/release-verify/SKILL.md +2 -0
  78. package/src/adapters/contract.mjs +10 -10
  79. package/src/adapters/plugin-marketplace.mjs +21 -5
  80. package/src/artifacts/entry.mjs +4 -1
  81. package/src/artifacts/producer-registry.mjs +9 -6
  82. package/src/artifacts/project-lock.mjs +324 -214
  83. package/src/commands/assess.mjs +60 -2
  84. package/src/commands/attest.mjs +11 -0
  85. package/src/commands/prepare.mjs +19 -13
  86. package/src/core/approval.mjs +5 -2
  87. package/src/core/digest.mjs +47 -12
  88. package/src/core/foundation-inflight.mjs +39 -0
  89. package/src/core/hook-cache.mjs +3 -0
  90. package/src/core/installation-contract.mjs +4 -2
  91. package/src/core/plan.mjs +34 -30
  92. package/src/core/run.mjs +22 -36
  93. package/src/core/skill-resource-closure.mjs +6 -4
  94. package/src/core/trusted-resource.mjs +64 -15
  95. package/src/platforms/codebuddy.mjs +92 -10
  96. package/src/platforms/registry.mjs +4 -3
  97. package/src/producers/build-adapters.mjs +27 -9
  98. package/src/producers/render-public-assets.mjs +12 -7
  99. package/src/producers/sync-skills.mjs +12 -7
  100. package/src/snapshot/public-map.mjs +10 -2
@@ -1,23 +1,35 @@
1
1
  /**
2
- * Shared project lock for artifact commands.
2
+ * Shared project lock for artifact commands (G4: token-lock adoption).
3
3
  *
4
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.
5
+ * prepare) share a single project lock domain. The lock is acquired via the
6
+ * Foundation in-flight `token-lock` mechanism: `.release-skill/lock` is a
7
+ * SINGLE FILE (0600) holding the lock record
8
+ * `{schemaVersion:1, owner:<owner6-JSON string>, tokenDigest, acquiredAt}`.
9
+ * The owner record is published atomically together with the lock — there is
10
+ * no ownerless-lock window (G4 conflict-surface.md 第六节).
7
11
  *
8
12
  * 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.
13
+ * startedAt. It is embedded as the lock record `owner` string.
11
14
  *
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.
15
+ * TTL is informational only — aging a lock never permits automatic deletion.
16
+ * Only the exact owner can release (token + identity), or an operator can
17
+ * break the lock with `breakProjectLock` which requires matching the exact
18
+ * owner and writes audit evidence before recovery.
19
+ *
20
+ * Old lock domain: `.release-skill/lock` as a DIRECTORY (with `.owner`).
21
+ * Detecting a directory at the lock path fails closed with
22
+ * LOCK_MIGRATION_REQUIRED — never auto-deleted, never auto-converted
23
+ * (same-path different-form makes old/new code mutually exclusive).
24
+ *
25
+ * Bridge: in-flight Foundation capabilities are imported through
26
+ * `../core/foundation-inflight.mjs` (relative-path import of the Foundation
27
+ * workspace; released form must be the bundle).
16
28
  *
17
29
  * @module artifacts/project-lock
18
30
  */
19
31
 
20
- import { mkdir, rm, writeFile, readFile, readdir, stat, lstat, open } from 'node:fs/promises';
32
+ import { mkdir, writeFile, lstat, open } from 'node:fs/promises';
21
33
  import { readFileSync } from 'node:fs';
22
34
  import { join } from 'node:path';
23
35
  import { hostname } from 'node:os';
@@ -27,15 +39,25 @@ import {
27
39
  ReleaseError,
28
40
  TRANSACTION_INCOMPLETE,
29
41
  PATH_UNSAFE,
42
+ LOCK_MIGRATION_REQUIRED,
30
43
  } from '../core/errors.mjs';
44
+ import {
45
+ acquireFilesystemLock,
46
+ inspectFilesystemLock,
47
+ releaseFilesystemLock,
48
+ recoverFilesystemLock,
49
+ HARNESS_ERROR_KINDS as INFLIGHT_KINDS,
50
+ } from '../core/foundation-inflight.mjs';
31
51
 
32
52
  // ---------------------------------------------------------------------------
33
53
  // Constants
34
54
  // ---------------------------------------------------------------------------
35
55
 
36
- const LOCK_DIR_NAME = 'lock';
37
- const OWNER_FILE_NAME = '.owner';
56
+ /** Foundation token-lock relative path (single file — replaces old directory). */
57
+ const LOCK_REL_PATH = join('.release-skill', 'lock');
38
58
  const AUDIT_DIR_NAME = 'lock-audit';
59
+ /** Foundation assertOwner upper bound on the owner string (token-lock.mjs). */
60
+ const MAX_OWNER_JSON_LENGTH = 200;
39
61
 
40
62
  /** All owner fields that must match exactly. */
41
63
  const OWNER_FIELDS = ['pid', 'host', 'bootId', 'nonce', 'command', 'startedAt'];
@@ -87,12 +109,8 @@ function getBootId() {
87
109
  // Internal: path helpers
88
110
  // ---------------------------------------------------------------------------
89
111
 
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);
112
+ function lockPath(root) {
113
+ return join(root, LOCK_REL_PATH);
96
114
  }
97
115
 
98
116
  function auditDir(root) {
@@ -214,6 +232,10 @@ function sanitizeReason(reason) {
214
232
  .replace(/(^|[\s("'=])(?:[A-Za-z]:[\\/]|\\\\)[^\s,;:'")\]]+/g, '$1<absolute-path>');
215
233
  }
216
234
 
235
+ // ---------------------------------------------------------------------------
236
+ // Internal: durability observation (real fsync at wrapper boundaries)
237
+ // ---------------------------------------------------------------------------
238
+
217
239
  async function emitDurability(observer, event) {
218
240
  if (!observer) return;
219
241
  try {
@@ -233,124 +255,140 @@ async function fsyncDirObserved(dirPath, observer) {
233
255
  await emitDurability(observer, { operation: 'fsync-dir', path: dirPath });
234
256
  }
235
257
 
236
- // ---------------------------------------------------------------------------
237
- // Internal: assert owner matches on disk
238
- // ---------------------------------------------------------------------------
239
-
240
258
  /**
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.
259
+ * Fsync a file by path opens, syncs, closes.
244
260
  *
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.
261
+ * @param {string} filePath
249
262
  */
250
- async function assertOwnerOnDisk(expected, root) {
251
- let raw;
263
+ async function fsyncFile(filePath) {
264
+ const fh = await open(filePath, 'r');
252
265
  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;
266
+ await fh.sync();
267
+ } finally {
268
+ await fh.close();
263
269
  }
270
+ }
264
271
 
265
- let actual;
272
+ /**
273
+ * Fsync a directory by path.
274
+ *
275
+ * @param {string} dirPath
276
+ */
277
+ async function fsyncDir(dirPath) {
278
+ const fh = await open(dirPath, 'r');
266
279
  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
- );
280
+ await fh.sync();
281
+ } finally {
282
+ await fh.close();
274
283
  }
284
+ }
275
285
 
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
- }
286
+ // ---------------------------------------------------------------------------
287
+ // Internal: Foundation error mapping (G4 conflict-surface.md 第五节)
288
+ // ---------------------------------------------------------------------------
289
+
290
+ function mapKindToCode(kind) {
291
+ switch (kind) {
292
+ case INFLIGHT_KINDS.STORE_LOCKED:
293
+ case INFLIGHT_KINDS.LOCK_CORRUPT:
294
+ case INFLIGHT_KINDS.LOCK_RECOVERY_REFUSED:
295
+ case INFLIGHT_KINDS.EXCLUSIVE_PUBLISH_CONFLICT:
296
+ return TRANSACTION_INCOMPLETE;
297
+ case INFLIGHT_KINDS.UNSAFE_STATE_ENTRY:
298
+ return PATH_UNSAFE;
299
+ default:
300
+ // 未知 kind → fail-closed,默认拒绝
301
+ return PATH_UNSAFE;
284
302
  }
285
303
  }
286
304
 
287
305
  /**
288
- * Remove the lock directory only if the persisted owner matches exactly.
289
- * After removal, fsync the parent .release-skill directory for durability.
306
+ * Map a Foundation mechanism error to a local ReleaseError.
290
307
  *
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.
308
+ * Errors WITHOUT `details.kind` are genuine local I/O failures (ENOENT,
309
+ * ENOSPC, EACCES, ...) and are rethrown unchanged — they must surface with
310
+ * their native message and code.
311
+ *
312
+ * @param {Error} cause - The caught error.
313
+ * @param {string} fallbackMessage - Message when the cause has none.
314
+ * @returns {ReleaseError}
296
315
  */
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);
316
+ function mapFoundationError(cause, fallbackMessage) {
317
+ const kind = cause?.details?.kind;
318
+ if (typeof kind === 'string') {
319
+ return new ReleaseError(mapKindToCode(kind), cause.message ?? fallbackMessage, {
320
+ ...cause.details,
321
+ });
322
+ }
323
+ if (cause instanceof TypeError) {
324
+ // Foundation assertOwner 上限(owner 非字符串/超长)——已由 wrapper 写前预检兜底
325
+ return new ReleaseError(PATH_UNSAFE, cause.message ?? fallbackMessage, {});
326
+ }
327
+ throw cause;
303
328
  }
304
329
 
305
330
  // ---------------------------------------------------------------------------
306
- // Internal: fsync helpers
331
+ // Internal: lock path state & fail-closed checks
307
332
  // ---------------------------------------------------------------------------
308
333
 
309
334
  /**
310
- * Fsync a file by path — opens, syncs, closes.
335
+ * Classify the lock path state.
311
336
  *
312
- * @param {string} filePath
337
+ * @param {string} root - Repository root.
338
+ * @returns {Promise<'absent'|'file'|'dir'|'symlink'>}
313
339
  */
314
- async function fsyncFile(filePath) {
315
- const fh = await open(filePath, 'r');
340
+ async function lockPathState(root) {
341
+ let st;
316
342
  try {
317
- await fh.sync();
318
- } finally {
319
- await fh.close();
343
+ st = await lstat(lockPath(root));
344
+ } catch (err) {
345
+ if (err.code === 'ENOENT') return 'absent';
346
+ throw err;
320
347
  }
348
+ if (st.isSymbolicLink()) return 'symlink';
349
+ if (st.isDirectory()) return 'dir';
350
+ return 'file';
321
351
  }
322
352
 
323
353
  /**
324
- * Fsync a directory by path.
354
+ * Fail closed when the lock path holds an unsafe or old-domain form.
325
355
  *
326
- * @param {string} dirPath
356
+ * - symlink → PATH_UNSAFE (same as pre-migration assertion)
357
+ * - directory → LOCK_MIGRATION_REQUIRED (old lock domain; never auto-migrated)
358
+ * - absent/file → OK (Foundation acquires / inspects the single-file lock)
359
+ *
360
+ * @param {string} root - Repository root.
361
+ * @returns {Promise<void>}
327
362
  */
328
- async function fsyncDir(dirPath) {
329
- const fh = await open(dirPath, 'r');
330
- try {
331
- await fh.sync();
332
- } finally {
333
- await fh.close();
363
+ async function assertLockPathSafe(root) {
364
+ const state = await lockPathState(root);
365
+ if (state === 'symlink') {
366
+ throw new ReleaseError(
367
+ PATH_UNSAFE,
368
+ '.release-skill/lock is a symlink — refusing to operate on symlinked path',
369
+ { path: lockPath(root) },
370
+ );
371
+ }
372
+ if (state === 'dir') {
373
+ throw new ReleaseError(
374
+ LOCK_MIGRATION_REQUIRED,
375
+ 'project lock uses the old directory format; migration required',
376
+ { root, lockPath: lockPath(root), hint: 'confirm no command is in progress, then remove the .release-skill/lock directory' },
377
+ );
334
378
  }
335
379
  }
336
380
 
337
- // ---------------------------------------------------------------------------
338
- // Internal: symlink/non-directory fail-closed checks
339
- // ---------------------------------------------------------------------------
340
-
341
381
  /**
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.
382
+ * Assert that .release-skill exists as a real directory (not a symlink).
344
383
  *
345
- * @param {string} dirPath - Path to check.
346
- * @param {string} label - Human label for error messages.
384
+ * @param {string} root - Repository root.
347
385
  * @returns {Promise<void>}
348
- * @throws {ReleaseError} PATH_UNSAFE if symlink or non-directory.
349
386
  */
350
- async function assertNotSymlinkOrFile(dirPath, label) {
387
+ async function assertReleaseSkillDirSafe(root) {
388
+ const releaseSkillDir = join(root, '.release-skill');
351
389
  let st;
352
390
  try {
353
- st = await lstat(dirPath);
391
+ st = await lstat(releaseSkillDir);
354
392
  } catch (err) {
355
393
  if (err.code === 'ENOENT') return; // doesn't exist yet — OK
356
394
  throw err;
@@ -358,42 +396,112 @@ async function assertNotSymlinkOrFile(dirPath, label) {
358
396
  if (st.isSymbolicLink()) {
359
397
  throw new ReleaseError(
360
398
  PATH_UNSAFE,
361
- `${label} is a symlink — refusing to operate on symlinked path`,
362
- { path: dirPath },
399
+ '.release-skill is a symlink — refusing to operate on symlinked path',
400
+ { path: releaseSkillDir },
363
401
  );
364
402
  }
365
403
  if (!st.isDirectory()) {
366
404
  throw new ReleaseError(
367
405
  PATH_UNSAFE,
368
- `${label} exists but is not a directory`,
369
- { path: dirPath },
406
+ '.release-skill exists but is not a directory',
407
+ { path: releaseSkillDir },
370
408
  );
371
409
  }
372
410
  }
373
411
 
374
412
  /**
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.
413
+ * Assert that the audit directory is not a symlink.
377
414
  *
378
415
  * @param {string} root - Repository root.
379
416
  * @returns {Promise<void>}
380
417
  */
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');
418
+ async function assertAuditPathNotSymlink(root) {
419
+ await assertReleaseSkillDirSafe(root);
420
+ const auditPath = auditDir(root);
421
+ let st;
422
+ try {
423
+ st = await lstat(auditPath);
424
+ } catch (err) {
425
+ if (err.code === 'ENOENT') return;
426
+ throw err;
427
+ }
428
+ if (st.isSymbolicLink()) {
429
+ throw new ReleaseError(
430
+ PATH_UNSAFE,
431
+ '.release-skill/lock-audit is a symlink — refusing to operate on symlinked path',
432
+ { path: auditPath },
433
+ );
434
+ }
385
435
  }
386
436
 
437
+ // ---------------------------------------------------------------------------
438
+ // Internal: assert owner matches persisted lock record
439
+ // ---------------------------------------------------------------------------
440
+
387
441
  /**
388
- * Assert that the audit directory is not a symlink.
442
+ * Parse the persisted owner string from a lock record and compare all six
443
+ * fields exactly with the expected owner.
444
+ *
445
+ * @param {string} persistedOwnerJson - `record.owner` from the lock file.
446
+ * @param {object} expected - The expected owner record.
447
+ * @param {string} root - Repository root.
448
+ * @param {string} messagePrefix - Prefix for the mismatch message.
449
+ * @returns {void}
450
+ * @throws {ReleaseError} TRANSACTION_INCOMPLETE on mismatch or corrupt record.
451
+ */
452
+ function assertOwnerMatches(persistedOwnerJson, expected, root, messagePrefix) {
453
+ let actual;
454
+ try {
455
+ actual = JSON.parse(persistedOwnerJson);
456
+ } catch {
457
+ throw new ReleaseError(
458
+ TRANSACTION_INCOMPLETE,
459
+ 'project lock owner record is corrupt',
460
+ { root },
461
+ );
462
+ }
463
+ for (const field of OWNER_FIELDS) {
464
+ if (actual[field] !== expected[field]) {
465
+ throw new ReleaseError(
466
+ TRANSACTION_INCOMPLETE,
467
+ `${messagePrefix} — field "${field}" differs`,
468
+ { root, field, expected: field === 'nonce' ? expected[field]?.slice(0, 8) : undefined },
469
+ );
470
+ }
471
+ }
472
+ }
473
+
474
+ /**
475
+ * Assert that the persisted lock record still belongs to `owner`.
389
476
  *
477
+ * @param {object} owner - The expected owner record.
390
478
  * @param {string} root - Repository root.
391
479
  * @returns {Promise<void>}
480
+ * @throws {ReleaseError} TRANSACTION_INCOMPLETE if lock missing or owner mismatch.
392
481
  */
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');
482
+ async function assertOwned(owner, root) {
483
+ let inspected;
484
+ try {
485
+ inspected = await inspectFilesystemLock(root, LOCK_REL_PATH);
486
+ } catch (cause) {
487
+ if (cause?.code === 'ENOENT') {
488
+ // Lock domain (or .release-skill) does not exist at all — ownership lost.
489
+ throw new ReleaseError(
490
+ TRANSACTION_INCOMPLETE,
491
+ 'project lock does not exist — ownership lost',
492
+ { root },
493
+ );
494
+ }
495
+ throw mapFoundationError(cause, 'project lock inspection failed');
496
+ }
497
+ if (!inspected.locked) {
498
+ throw new ReleaseError(
499
+ TRANSACTION_INCOMPLETE,
500
+ 'project lock does not exist — ownership lost',
501
+ { root },
502
+ );
503
+ }
504
+ assertOwnerMatches(inspected.owner, owner, root, 'project lock owner does not match');
397
505
  }
398
506
 
399
507
  // ---------------------------------------------------------------------------
@@ -403,8 +511,10 @@ async function assertAuditPathNotSymlink(root) {
403
511
  /**
404
512
  * Acquire the project lock.
405
513
  *
406
- * Uses exclusive `mkdir` to atomically claim the lock. The owner record
407
- * is written to `.owner` and fsynced before returning.
514
+ * Delegates to Foundation `acquireFilesystemLock` one exclusive create of
515
+ * the lock record file (temp + fsync + exclusive link + verify + directory
516
+ * fsync). The owner is published atomically with the lock, so there is no
517
+ * ownerless-lock window.
408
518
  *
409
519
  * If the lock is already held, throws `TRANSACTION_INCOMPLETE` — there is
410
520
  * no automatic stale lock breakage based on TTL.
@@ -448,20 +558,30 @@ export async function acquireProjectLock({
448
558
  }
449
559
 
450
560
  // Construct and validate the owner before touching the filesystem so a bad
451
- // injected clock cannot leave a directory without an owner.
561
+ // injected clock cannot shape any persisted state.
452
562
  const owner = buildOwner(command, clock);
563
+ const ownerJson = JSON.stringify(owner);
564
+ if (ownerJson.length > MAX_OWNER_JSON_LENGTH) {
565
+ // Foundation assertOwner 上限(200 字符);写前预检 fail-closed(G4 C.4)
566
+ throw new ReleaseError(
567
+ PATH_UNSAFE,
568
+ 'lock owner record exceeds the 200-character limit; refusing to acquire',
569
+ { length: ownerJson.length },
570
+ );
571
+ }
453
572
 
454
- // Symlink/non-directory fail-closed: check every path level before touching fs
573
+ // Symlink / old-domain fail-closed: check every path level before touching fs
455
574
  const releaseSkillDir = join(root, '.release-skill');
456
- await assertLockPathsNotSymlinks(root);
575
+ await assertReleaseSkillDirSafe(root);
576
+ await assertLockPathSafe(root);
457
577
 
458
- // Ensure parent directory exists (only if not already checked as non-symlink)
459
- let parentExisted = false;
578
+ // Ensure parent directory exists (only fsync root when newly created)
579
+ let parentExisted = true;
460
580
  try {
461
581
  await lstat(releaseSkillDir);
462
- parentExisted = true;
463
582
  } catch (err) {
464
583
  if (err.code !== 'ENOENT') throw err;
584
+ parentExisted = false;
465
585
  }
466
586
  if (!parentExisted) {
467
587
  await mkdir(releaseSkillDir, { recursive: true, mode: 0o700 });
@@ -470,62 +590,54 @@ export async function acquireProjectLock({
470
590
  await fsyncDirObserved(root, durabilityObserver);
471
591
  }
472
592
 
473
- const dir = lockDir(root);
593
+ if (faultInjector) await faultInjector('after-lock-create');
474
594
 
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;
595
+ // Foundation single-file atomic lock acquisition. A failure before commit
596
+ // leaves nothing behind (staging is cleaned by publishFileExclusive).
597
+ let handle;
479
598
  try {
480
- await mkdir(dir, { recursive: false, mode: 0o700 });
481
- lockCreated = true;
482
- } catch (err) {
483
- if (err.code === 'EEXIST') {
599
+ handle = await acquireFilesystemLock(root, LOCK_REL_PATH, { owner: ownerJson });
600
+ } catch (cause) {
601
+ if (cause?.details?.kind === INFLIGHT_KINDS.STORE_LOCKED) {
484
602
  // Lock is held — TTL never permits automatic breakage
485
603
  throw new ReleaseError(
486
604
  TRANSACTION_INCOMPLETE,
487
605
  'project lock is already held; another command is in progress',
488
- { root, lockDir: dir },
606
+ { root, lockPath: lockPath(root) },
489
607
  );
490
608
  }
491
- throw err;
609
+ throw mapFoundationError(cause, 'project lock acquisition failed');
492
610
  }
493
611
 
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;
612
+ // Foundation publishFileExclusive already fsynced .release-skill; record the
613
+ // completed boundary durability at the wrapper edge.
614
+ await emitDurability(durabilityObserver, { operation: 'fsync-dir', path: releaseSkillDir });
615
+
616
+ if (faultInjector) {
511
617
  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;
618
+ await faultInjector('after-owner-write');
619
+ } catch (writeErr) {
620
+ // The atomic commit already happened; roll the lock back so an
621
+ // "acquire reported failure" never leaves a held lock behind.
622
+ try {
623
+ await releaseFilesystemLock(handle);
624
+ await emitDurability(durabilityObserver, { operation: 'remove-dir', path: lockPath(root) });
625
+ await emitDurability(durabilityObserver, { operation: 'fsync-dir', path: releaseSkillDir });
626
+ } catch (cleanupErr) {
627
+ const incomplete = new ReleaseError(
628
+ TRANSACTION_INCOMPLETE,
629
+ 'project lock acquisition failed and cleanup could not be made durable',
630
+ {
631
+ acquireErrorCode: typeof writeErr?.code === 'string' ? writeErr.code : null,
632
+ cleanupErrorCode: typeof cleanupErr?.code === 'string' ? cleanupErr.code : null,
633
+ },
634
+ );
635
+ incomplete.cause = writeErr;
636
+ incomplete.cleanupCause = cleanupErr;
637
+ throw incomplete;
638
+ }
639
+ throw writeErr;
527
640
  }
528
- throw writeErr;
529
641
  }
530
642
 
531
643
  return Object.freeze({
@@ -543,14 +655,14 @@ export async function acquireProjectLock({
543
655
  * @throws {ReleaseError} if ownership verification fails.
544
656
  */
545
657
  async capture(fn) {
546
- await assertOwnerOnDisk(owner, root);
658
+ await assertOwned(owner, root);
547
659
  let fnResult;
548
660
  try {
549
661
  fnResult = await fn();
550
662
  } catch (fnErr) {
551
663
  // fn threw — still perform post-owner check (fail-closed)
552
664
  try {
553
- await assertOwnerOnDisk(owner, root);
665
+ await assertOwned(owner, root);
554
666
  } catch {
555
667
  // Both fn error AND owner lost — fail closed with TRANSACTION_INCOMPLETE,
556
668
  // preserve the original business error as cause
@@ -566,7 +678,7 @@ export async function acquireProjectLock({
566
678
  throw fnErr;
567
679
  }
568
680
  // fn succeeded — post-owner check
569
- await assertOwnerOnDisk(owner, root);
681
+ await assertOwned(owner, root);
570
682
  return fnResult;
571
683
  },
572
684
 
@@ -577,18 +689,25 @@ export async function acquireProjectLock({
577
689
  * @throws {ReleaseError} TRANSACTION_INCOMPLETE if ownership lost.
578
690
  */
579
691
  async assertOwner() {
580
- return assertOwnerOnDisk(owner, root);
692
+ return assertOwned(owner, root);
581
693
  },
582
694
 
583
695
  /**
584
- * Release the lock. Only succeeds if the persisted owner matches exactly.
585
- * After removal, fsyncs the parent .release-skill directory for durability.
696
+ * Release the lock. Only succeeds if the persisted record still matches
697
+ * the token and identity captured at acquire. Foundation `unlinkSame`
698
+ * fsyncs `.release-skill` as part of removal.
586
699
  *
587
700
  * @returns {Promise<void>}
588
- * @throws {ReleaseError} TRANSACTION_INCOMPLETE if owner mismatch.
701
+ * @throws {ReleaseError} TRANSACTION_INCOMPLETE if token/owner mismatch.
589
702
  */
590
703
  async release() {
591
- return removeLockIfExactOwner(owner, root, durabilityObserver);
704
+ try {
705
+ await releaseFilesystemLock(handle);
706
+ } catch (cause) {
707
+ throw mapFoundationError(cause, 'project lock release failed — token or owner does not match');
708
+ }
709
+ await emitDurability(durabilityObserver, { operation: 'remove-dir', path: lockPath(root) });
710
+ await emitDurability(durabilityObserver, { operation: 'fsync-dir', path: releaseSkillDir });
592
711
  },
593
712
  });
594
713
  }
@@ -596,9 +715,10 @@ export async function acquireProjectLock({
596
715
  /**
597
716
  * Break a project lock by force.
598
717
  *
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.
718
+ * Requires the exact owner record to match what is persisted in the lock
719
+ * record. Writes an audit record to `.release-skill/lock-audit/` before
720
+ * recovering (removing) the lock file via Foundation `recoverFilesystemLock`
721
+ * (explicit operator recovery — no age/PID/liveness inference).
602
722
  *
603
723
  * @param {object} options
604
724
  * @param {string} options.root - Repository root (absolute).
@@ -627,45 +747,26 @@ export async function breakProjectLock({ root, expectedOwner, reason, clock, dur
627
747
  const brokenAt = clock ? clock() : new Date().toISOString();
628
748
  assertIsoTimestamp(brokenAt, 'clock result');
629
749
 
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 {
750
+ // Symlink / old-domain / absent fail-closed check before reading the lock record
751
+ await assertReleaseSkillDirSafe(root);
752
+ await assertLockPathSafe(root);
753
+ if ((await lockPathState(root)) === 'absent') {
652
754
  throw new ReleaseError(
653
755
  TRANSACTION_INCOMPLETE,
654
- 'project lock owner file is corrupt cannot break safely',
756
+ 'no project lock to break lock file does not exist',
655
757
  { root },
656
758
  );
657
759
  }
658
760
 
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
- }
761
+ let inspected;
762
+ try {
763
+ inspected = await inspectFilesystemLock(root, LOCK_REL_PATH);
764
+ } catch (cause) {
765
+ throw mapFoundationError(cause, 'project lock inspection failed');
668
766
  }
767
+ assertOwnerMatches(inspected.owner, expectedOwner, root, 'break-lock rejected: expectedOwner does not match persisted owner');
768
+
769
+ const actualOwner = JSON.parse(inspected.owner); // 已通过 assertOwnerMatches 校验
669
770
 
670
771
  // Check audit path is not a symlink before writing
671
772
  await assertAuditPathNotSymlink(root);
@@ -688,7 +789,7 @@ export async function breakProjectLock({ root, expectedOwner, reason, clock, dur
688
789
  breakerHost: hostname(),
689
790
  });
690
791
 
691
- // Write audit evidence before removing lock
792
+ // Write audit evidence before recovering the lock
692
793
  const auditDirectory = auditDir(root);
693
794
  await mkdir(auditDirectory, { recursive: true, mode: 0o700 });
694
795
  await emitDurability(durabilityObserver, { operation: 'create-dir', path: auditDirectory });
@@ -704,12 +805,21 @@ export async function breakProjectLock({ root, expectedOwner, reason, clock, dur
704
805
  await fsyncFileObserved(auditFilePath, durabilityObserver);
705
806
  await fsyncDirObserved(auditDirectory, durabilityObserver);
706
807
 
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);
808
+ // Explicit operator recovery: digest must still match the inspected record
809
+ // (TOCTOU fail-closed the lock may not have changed between inspect and recover).
810
+ try {
811
+ await recoverFilesystemLock(root, LOCK_REL_PATH, {
812
+ expectedTokenDigest: inspected.tokenDigest,
813
+ confirmAbandoned: true,
814
+ });
815
+ } catch (cause) {
816
+ throw mapFoundationError(cause, 'project lock break failed — lock changed after inspection');
817
+ }
818
+
819
+ // Foundation unlinkSame fsynced .release-skill as part of removal; record
820
+ // the completed boundary durability at the wrapper edge.
821
+ await emitDurability(durabilityObserver, { operation: 'remove-dir', path: lockPath(root) });
822
+ await emitDurability(durabilityObserver, { operation: 'fsync-dir', path: join(root, '.release-skill') });
713
823
 
714
824
  return auditRecord;
715
825
  }