bmad-module-skill-forge 3.0.0 → 5.0.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 (141) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/README.md +3 -3
  3. package/docs/_data/pinned.yaml +19 -13
  4. package/docs/architecture.md +2 -2
  5. package/docs/concepts.md +4 -4
  6. package/docs/examples.md +33 -26
  7. package/docs/getting-started.md +3 -3
  8. package/docs/how-it-works.md +2 -2
  9. package/docs/index.md +1 -1
  10. package/docs/skill-model.md +41 -39
  11. package/docs/troubleshooting.md +8 -0
  12. package/docs/verifying-a-skill.md +46 -21
  13. package/docs/why-skf.md +1 -1
  14. package/docs/workflows.md +5 -5
  15. package/package.json +1 -1
  16. package/src/knowledge/qmd-registry.md +1 -1
  17. package/src/knowledge/version-paths.md +1 -1
  18. package/src/shared/health-check.md +2 -50
  19. package/src/shared/scripts/schemas/skf-audit-result-envelope.v1.json +2 -2
  20. package/src/shared/scripts/schemas/skf-update-result-envelope.v1.json +12 -4
  21. package/src/shared/scripts/schemas/skill-brief.v1.json +1 -1
  22. package/src/shared/scripts/skf-atomic-write.py +212 -59
  23. package/src/shared/scripts/skf-build-change-manifest.py +1437 -178
  24. package/src/shared/scripts/skf-classify-changed-files.py +78 -0
  25. package/src/shared/scripts/skf-compare-file-hashes.py +266 -104
  26. package/src/shared/scripts/skf-count-tokens.py +54 -10
  27. package/src/shared/scripts/skf-derive-assembly-shape.py +18 -1
  28. package/src/shared/scripts/skf-detect-scripts-assets.py +196 -54
  29. package/src/shared/scripts/skf-detect-workspaces.py +67 -0
  30. package/src/shared/scripts/skf-emit-result-envelope.py +6 -5
  31. package/src/shared/scripts/skf-extract-public-api.py +1673 -189
  32. package/src/shared/scripts/skf-extraction-inventory.py +584 -46
  33. package/src/shared/scripts/skf-extraction-snapshot.py +158 -33
  34. package/src/shared/scripts/skf-forge-tier-rw.py +26 -3
  35. package/src/shared/scripts/skf-github-probe.py +47 -8
  36. package/src/shared/scripts/skf-hash-content.py +14 -8
  37. package/src/shared/scripts/skf-load-provenance.py +3 -1
  38. package/src/shared/scripts/skf-merge-doc-urls.py +142 -19
  39. package/src/shared/scripts/skf-parse-gaps.py +135 -26
  40. package/src/shared/scripts/skf-preflight.py +15 -1
  41. package/src/shared/scripts/skf-provenance-gap-dispatch.py +25 -1
  42. package/src/shared/scripts/skf-render-metadata-stats.py +1 -1
  43. package/src/shared/scripts/skf-resolve-authoritative-files.py +29 -3
  44. package/src/shared/scripts/skf-resolve-customization.py +197 -0
  45. package/src/shared/scripts/skf-shard-body.py +239 -41
  46. package/src/shared/scripts/skf-structural-diff.py +133 -18
  47. package/src/shared/scripts/skf-update-active-symlink.py +115 -62
  48. package/src/shared/scripts/skf-validate-brief-schema.py +19 -1
  49. package/src/shared/scripts/skf-validate-output.py +12 -3
  50. package/src/shared/scripts/skf-verify-provenance-completeness.py +1439 -79
  51. package/src/shared/scripts/skf-write-skill-brief.py +41 -8
  52. package/src/shared/tool-requirements.yaml +8 -2
  53. package/src/skf-analyze-source/SKILL.md +2 -2
  54. package/src/skf-analyze-source/assets/skill-brief-schema.md +1 -1
  55. package/src/skf-analyze-source/references/map-unit-exports.md +1 -1
  56. package/src/skf-audit-skill/SKILL.md +2 -2
  57. package/src/skf-audit-skill/assets/drift-report-template.md +1 -0
  58. package/src/skf-audit-skill/references/headless-contract.md +1 -1
  59. package/src/skf-audit-skill/references/health-check.md +2 -0
  60. package/src/skf-audit-skill/references/init.md +2 -1
  61. package/src/skf-audit-skill/references/re-index.md +14 -9
  62. package/src/skf-audit-skill/references/report.md +2 -2
  63. package/src/skf-audit-skill/references/semantic-diff.md +15 -15
  64. package/src/skf-audit-skill/references/structural-diff.md +10 -5
  65. package/src/skf-audit-skill/scripts/render-drift-tables.py +29 -2
  66. package/src/skf-brief-skill/SKILL.md +3 -3
  67. package/src/skf-brief-skill/assets/scope-templates.md +1 -1
  68. package/src/skf-brief-skill/assets/skill-brief-schema.md +4 -11
  69. package/src/skf-brief-skill/references/analyze-target.md +6 -6
  70. package/src/skf-brief-skill/references/confirm-brief.md +1 -1
  71. package/src/skf-brief-skill/references/scope-definition.md +4 -4
  72. package/src/skf-brief-skill/references/step-auto-brief.md +11 -29
  73. package/src/skf-brief-skill/references/version-resolution.md +5 -9
  74. package/src/skf-campaign/SKILL.md +2 -2
  75. package/src/skf-campaign/references/step-05-skill-loop.md +6 -1
  76. package/src/skf-create-skill/SKILL.md +2 -2
  77. package/src/skf-create-skill/assets/compile-assembly-rules.md +5 -3
  78. package/src/skf-create-skill/assets/skill-sections.md +7 -7
  79. package/src/skf-create-skill/references/authoritative-files-protocol.md +2 -23
  80. package/src/skf-create-skill/references/compile.md +31 -60
  81. package/src/skf-create-skill/references/component-extraction.md +3 -3
  82. package/src/skf-create-skill/references/enrich.md +2 -2
  83. package/src/skf-create-skill/references/entry-points-by-hand.md +24 -7
  84. package/src/skf-create-skill/references/extract.md +23 -19
  85. package/src/skf-create-skill/references/extraction-patterns-tracing.md +9 -9
  86. package/src/skf-create-skill/references/extraction-patterns.md +10 -11
  87. package/src/skf-create-skill/references/generate-artifacts.md +1 -1
  88. package/src/skf-create-skill/references/report.md +21 -5
  89. package/src/skf-create-skill/references/source-resolution-protocols.md +12 -17
  90. package/src/skf-create-skill/references/sub/fetch-docs.md +1 -1
  91. package/src/skf-create-stack-skill/SKILL.md +2 -2
  92. package/src/skf-create-stack-skill/references/validate.md +1 -1
  93. package/src/skf-drop-skill/SKILL.md +2 -2
  94. package/src/skf-drop-skill/references/execute.md +5 -1
  95. package/src/skf-drop-skill/references/health-check.md +2 -0
  96. package/src/skf-drop-skill/references/invocation-contract.md +1 -1
  97. package/src/skf-drop-skill/references/report.md +2 -2
  98. package/src/skf-drop-skill/references/select.md +7 -3
  99. package/src/skf-export-skill/SKILL.md +2 -2
  100. package/src/skf-export-skill/assets/snippet-format.md +5 -5
  101. package/src/skf-export-skill/references/generate-snippet.md +22 -15
  102. package/src/skf-export-skill/references/load-skill.md +1 -1
  103. package/src/skf-export-skill/references/token-report.md +29 -9
  104. package/src/skf-forger/SKILL.md +2 -2
  105. package/src/skf-quick-skill/SKILL.md +2 -2
  106. package/src/skf-refine-architecture/SKILL.md +6 -8
  107. package/src/skf-rename-skill/SKILL.md +2 -2
  108. package/src/skf-setup/SKILL.md +2 -2
  109. package/src/skf-test-skill/SKILL.md +2 -2
  110. package/src/skf-test-skill/assets/output-section-formats.md +6 -4
  111. package/src/skf-test-skill/references/coherence-check.md +5 -4
  112. package/src/skf-test-skill/references/coverage-check-branches.md +61 -0
  113. package/src/skf-test-skill/references/coverage-check-tiers.md +5 -5
  114. package/src/skf-test-skill/references/coverage-check.md +69 -111
  115. package/src/skf-test-skill/references/init.md +5 -6
  116. package/src/skf-test-skill/references/migration-section-rules.md +26 -14
  117. package/src/skf-test-skill/references/report.md +11 -9
  118. package/src/skf-test-skill/references/scoring-rules.md +8 -6
  119. package/src/skf-test-skill/references/source-access-protocol.md +15 -18
  120. package/src/skf-test-skill/scripts/build-result-context.py +114 -21
  121. package/src/skf-test-skill/scripts/gap-ledger.py +580 -80
  122. package/src/skf-test-skill/scripts/load-coverage-inputs.py +646 -107
  123. package/src/skf-test-skill/scripts/locate-export-segments.py +101 -20
  124. package/src/skf-test-skill/scripts/reconcile-coverage.py +450 -23
  125. package/src/skf-test-skill/scripts/score-signatures.py +356 -61
  126. package/src/skf-test-skill/scripts/stage-helper-payload.py +64 -13
  127. package/src/skf-test-skill/scripts/validate-inventory.py +108 -9
  128. package/src/skf-test-skill/scripts/verify-declared-numerator.py +7 -5
  129. package/src/skf-test-skill/templates/test-report-template.md +1 -0
  130. package/src/skf-update-skill/SKILL.md +2 -2
  131. package/src/skf-update-skill/references/detect-changes.md +32 -13
  132. package/src/skf-update-skill/references/gap-driven.md +37 -22
  133. package/src/skf-update-skill/references/init.md +6 -2
  134. package/src/skf-update-skill/references/invocation-contract.md +1 -1
  135. package/src/skf-update-skill/references/merge.md +17 -12
  136. package/src/skf-update-skill/references/re-extract.md +4 -2
  137. package/src/skf-update-skill/references/report.md +7 -4
  138. package/src/skf-update-skill/references/write.md +77 -36
  139. package/src/skf-update-skill/scripts/skf-new-file-diff.py +157 -42
  140. package/src/skf-verify-stack/SKILL.md +2 -2
  141. package/tools/cli/lib/tool-check.js +158 -2
@@ -23,17 +23,22 @@ Subcommands:
23
23
 
24
24
  flip-link Point the link <link> at <target>: create the new link as
25
25
  <link>.skf-tmp-link, then os.replace it over <link>, holding an
26
- exclusive lock on <link>.skf-lock (removed afterwards). Refuses
27
- to replace a <link> that is a real directory or file.
26
+ exclusive lock on <link>.skf-lock, which only the flip holding
27
+ it removes. skf-update-active-symlink.py update takes the same
28
+ lock (take_flip_lock), so one flip of a link runs at a time.
29
+ Refuses to replace a <link> that is a real directory or file.
28
30
 
29
31
  Cross-platform: locking branches between fcntl (POSIX) and msvcrt
30
32
  (Windows). On Windows os.symlink needs Developer Mode or admin; when it
31
33
  fails for lack of privilege, flip-link creates a directory junction
32
34
  (mklink /J) instead, which needs neither but requires <target> to be an
33
- existing directory. A junction cannot be renamed over an existing link,
34
- so on that path flip-link removes the old link first and the flip is not
35
- atomic. No test exercises the junction path: CI's Windows runner holds
36
- symlink privilege.
35
+ existing directory. Windows renames nothing over an existing directory
36
+ link, so on the junction path, and on Windows whenever the rename over the
37
+ old link is refused, replace_link removes the old link first (a junction
38
+ whose folder is gone included) and the flip is not atomic. The Windows
39
+ tests in test-skf-atomic-write.py and test-skf-update-active-symlink.py
40
+ make os.symlink fail as it does without the privilege, so CI's Windows
41
+ runner, which holds it, runs the junction path too.
37
42
 
38
43
  Exit codes:
39
44
  0 on success (JSON on stdout)
@@ -44,8 +49,11 @@ Exit codes:
44
49
  2 on a refused or failed operation, with {"status": "error", "message":
45
50
  ...} on stderr: write failed, stage-dir could not clear a stale entry,
46
51
  commit-dir found a non-directory target or could not swap, flip-link
47
- found a non-link at <link> or another process holding its lock; and
48
- on bad arguments (argparse usage on stderr, no JSON)
52
+ found a non-link at <link>, another process holding its lock, a
53
+ lock file Windows kept refusing to open for its short wait while the
54
+ file was being removed, or (on POSIX) a locked lock file that kept
55
+ leaving the path for that same wait; and on bad arguments (argparse
56
+ usage on stderr, no JSON)
49
57
 
50
58
  CLI examples:
51
59
  cat metadata.json | python3 skf-atomic-write.py write --target /path/to/metadata.json
@@ -63,6 +71,7 @@ import os
63
71
  import shutil
64
72
  import subprocess
65
73
  import sys
74
+ import time
66
75
  from pathlib import Path
67
76
 
68
77
  if os.name == "nt":
@@ -70,6 +79,18 @@ if os.name == "nt":
70
79
  else:
71
80
  import fcntl
72
81
 
82
+ # While the flip that held <link>.skf-lock is removing it, Windows refuses a
83
+ # new open of the file with PermissionError. flip-link takes that for a held
84
+ # lock and tries again for up to this long (a flip fails fast on contention
85
+ # by design, so the wait is short), then exits 2 as it does for a held lock.
86
+ # The same wait bounds a POSIX flip that keeps finding the file it locked
87
+ # gone from the path: it opens the path again until then, then exits 2.
88
+ # A caller that gives take_flip_lock a longer `wait` (skf-update-active-symlink.py
89
+ # update) keeps both retries going until that wait ends.
90
+ _IS_WINDOWS = os.name == "nt"
91
+ _LOCK_OPEN_WAIT_SECONDS = 1.0
92
+ _LOCK_OPEN_POLL_SECONDS = 0.02
93
+
73
94
 
74
95
  def _create_symlink_or_junction(target: str, link_path: Path) -> str:
75
96
  """Create a directory link from link_path to target.
@@ -141,6 +162,16 @@ def _release_lock(fd: int) -> None:
141
162
  pass
142
163
 
143
164
 
165
+ # Keep identical to _names_open_file in gap-ledger.py
166
+ # (test/test-skf-atomic-write.py pins the copy).
167
+ def _names_open_file(fd: int, path: Path) -> bool:
168
+ """True when `path` still names the file open as `fd`."""
169
+ try:
170
+ return os.path.samestat(os.fstat(fd), os.stat(path))
171
+ except OSError:
172
+ return False
173
+
174
+
144
175
  def _die(code: int, message: str) -> None:
145
176
  print(json.dumps({"status": "error", "message": message}), file=sys.stderr)
146
177
  sys.exit(code)
@@ -282,18 +313,182 @@ def _is_link_or_junction(p: Path) -> bool:
282
313
  return False
283
314
 
284
315
 
316
+ def _remove_link(p: Path) -> None:
317
+ """Remove the symlink or junction at p, whether or not the folder it names exists.
318
+
319
+ os.readlink raises for a real folder or file (OSError) and for another
320
+ Windows reparse point (ValueError), so neither is ever removed. os.unlink
321
+ removes a symlink, and on Windows a directory symlink or a junction too;
322
+ os.rmdir is the fallback for a directory link unlink refuses.
323
+ """
324
+ if not p.is_symlink():
325
+ os.readlink(p)
326
+ try:
327
+ os.unlink(p)
328
+ except OSError:
329
+ os.rmdir(p)
330
+
331
+
332
+ def replace_link(link: Path, target: str, tmp_link: Path | None = None) -> str:
333
+ """Point the link <link> at <target> through <tmp_link>; return "symlink" or "junction".
334
+
335
+ The caller holds take_flip_lock(link) and has refused a real folder or
336
+ file at <link>. A stale <tmp_link> from a crashed flip is removed first.
337
+ The new link is made at <tmp_link> and renamed over <link>, which is
338
+ atomic for a symlink on POSIX. Windows renames nothing over an existing
339
+ directory link, so the old link is removed first on the junction path,
340
+ and on Windows when the rename is refused: a reader can then find no
341
+ <link> for a moment. A failure removes <tmp_link> and raises.
342
+ """
343
+ if tmp_link is None:
344
+ tmp_link = link.with_name(link.name + ".skf-tmp-link")
345
+ if os.path.lexists(tmp_link):
346
+ _remove_link(tmp_link)
347
+ link_kind = _create_symlink_or_junction(target, tmp_link)
348
+ try:
349
+ if link_kind == "junction" and os.path.lexists(link):
350
+ _remove_link(link)
351
+ try:
352
+ os.replace(tmp_link, link)
353
+ except OSError:
354
+ if not _IS_WINDOWS or not os.path.lexists(link):
355
+ raise
356
+ _remove_link(link)
357
+ os.replace(tmp_link, link)
358
+ except BaseException:
359
+ try:
360
+ if os.path.lexists(tmp_link):
361
+ _remove_link(tmp_link)
362
+ except (OSError, ValueError):
363
+ pass
364
+ raise
365
+ return link_kind
366
+
367
+
368
+ class FlipLockHeld(Exception):
369
+ """Another flip holds <link>.skf-lock; the message names <link>."""
370
+
371
+
372
+ def take_flip_lock(link: Path, wait: float = 0.0) -> int:
373
+ """Take <link>.skf-lock, the lock every flip of <link> holds, and return its fd.
374
+
375
+ flip-link and skf-update-active-symlink.py update both take it, so one
376
+ flip of a link runs at a time. `wait` is how long to keep trying a lock
377
+ another flip holds: the call closes the file, sleeps
378
+ _LOCK_OPEN_POLL_SECONDS and opens the path again. flip-link passes 0
379
+ and is refused at once. Raises FlipLockHeld when the lock stays held
380
+ past `wait`, and when the retries cmd_flip_link's docstring describes
381
+ (Windows refusing the open, a locked file that left the path) run out:
382
+ they run until max(_LOCK_OPEN_WAIT_SECONDS, wait). release_flip_lock
383
+ gives the lock back.
384
+ """
385
+ lock_path = link.with_name(link.name + ".skf-lock")
386
+ held_deadline = time.monotonic() + wait
387
+ deadline = time.monotonic() + max(_LOCK_OPEN_WAIT_SECONDS, wait)
388
+ vanished = False
389
+ while True:
390
+ try:
391
+ lock_fd = os.open(lock_path, os.O_WRONLY | os.O_CREAT, 0o644)
392
+ except PermissionError as exc:
393
+ # Windows refuses to open a file whose removal is pending. A folder
394
+ # at the path, or nothing there twice running (once can be the
395
+ # removal ending just now), is a lasting denial: raise it.
396
+ if not _IS_WINDOWS or os.path.isdir(lock_path):
397
+ raise
398
+ if not os.path.lexists(lock_path):
399
+ if vanished:
400
+ raise
401
+ vanished = True
402
+ continue
403
+ vanished = False
404
+ if time.monotonic() >= deadline:
405
+ raise FlipLockHeld(f"another process holds flip lock on {link} (last open refused: {exc})")
406
+ time.sleep(_LOCK_OPEN_POLL_SECONDS)
407
+ continue
408
+ held = False
409
+ try:
410
+ try:
411
+ _acquire_lock(lock_fd)
412
+ except OSError as e:
413
+ if e.errno != errno.EAGAIN:
414
+ raise
415
+ if time.monotonic() >= held_deadline:
416
+ raise FlipLockHeld(f"another process holds flip lock on {link}")
417
+ held = True
418
+ else:
419
+ # os.name here and in release_flip_lock follows the real OS; _IS_WINDOWS is the test seam of the
420
+ # open retry and of replace_link's remove-first retry.
421
+ if os.name == "nt" or _names_open_file(lock_fd, lock_path):
422
+ return lock_fd
423
+ _release_lock(lock_fd)
424
+ except BaseException:
425
+ os.close(lock_fd)
426
+ raise
427
+ os.close(lock_fd)
428
+ if held:
429
+ time.sleep(_LOCK_OPEN_POLL_SECONDS)
430
+ continue
431
+ if time.monotonic() >= deadline:
432
+ raise FlipLockHeld(f"another process holds flip lock on {link}")
433
+
434
+
435
+ def release_flip_lock(link: Path, lock_fd: int) -> None:
436
+ """Give back the lock take_flip_lock returned, removing <link>.skf-lock."""
437
+ lock_path = link.with_name(link.name + ".skf-lock")
438
+ # Only the flip holding the lock gets here, so it alone removes the
439
+ # lock file: on POSIX while the lock is still held, so that a flip on
440
+ # the old file sees the path no longer names it; on Windows, which
441
+ # removes no open file, after the release and the close. Best effort:
442
+ # a leftover file is locked and removed by the next flip.
443
+ if os.name != "nt":
444
+ try:
445
+ lock_path.unlink()
446
+ except OSError:
447
+ pass
448
+ _release_lock(lock_fd)
449
+ os.close(lock_fd)
450
+ if os.name == "nt":
451
+ try:
452
+ lock_path.unlink()
453
+ except OSError:
454
+ pass
455
+
456
+
285
457
  def cmd_flip_link(link: Path, target: str) -> None:
286
458
  """Atomically point <link> at <target> using rename-over-symlink pattern.
287
459
 
288
460
  target is the value of the symlink (may be relative, as is convention
289
- for `active -> 1.0.0`). A lock on <link>.skf-lock prevents concurrent flips.
461
+ for `active -> 1.0.0`). A lock on <link>.skf-lock, which
462
+ skf-update-active-symlink.py update takes too, prevents concurrent flips.
290
463
 
291
464
  On Windows, os.symlink requires Developer Mode or admin. When that fails
292
465
  with PRIVILEGE_NOT_HELD/ACCESS_DENIED the helper falls back to a directory
293
466
  junction (no elevation needed); junctions resolve identically for
294
- skf-skill-inventory's resolve_active_version().
467
+ skf-skill-inventory's resolve_active_version(). replace_link makes the
468
+ new link and renames it over <link>; skf-update-active-symlink.py update
469
+ flips through it too.
470
+
471
+ The lock follows ledger_lock in gap-ledger.py (the same identity check
472
+ and removal order): a fix to one belongs in both. The lock file is
473
+ removed on release, by the flip that held the lock: a flip refused by a
474
+ held lock exits 2 and leaves the file to its holder. On POSIX the
475
+ removal happens while the lock is still held, and a flip checks that
476
+ the file it locked is still the one at the path: otherwise a flip on
477
+ the removed file and a newcomer on a new one would both get a lock. A
478
+ flip whose file is no longer at the path lets it go and opens the path
479
+ again: it goes on as soon as it locks the file that is at the path, and
480
+ exits 2 at once when another flip holds that file. Only when the files
481
+ it locks keep leaving the path until _LOCK_OPEN_WAIT_SECONDS runs out
482
+ does it exit 2 with the "another process holds flip lock" message a
483
+ held lock gives. Windows removes no file that is open, so there the file goes after the
484
+ release, unless another flip has it open. While the removal is under
485
+ way (the remover's own delete handle is open, or a virus scanner's or
486
+ the search indexer's), Windows refuses a new open of the file with
487
+ PermissionError: the flip retries for up to _LOCK_OPEN_WAIT_SECONDS,
488
+ then exits 2 with the held-lock message, naming the last refusal. A
489
+ refusal with a folder at the path, or with nothing there on an
490
+ immediate second try, is a lasting denial and is raised as before.
295
491
  """
296
- lock_path = link.with_name(link.name + ".skf-lock")
297
492
  link.parent.mkdir(parents=True, exist_ok=True)
298
493
 
299
494
  # Refuse only if <link> is a real directory/file (not a symlink or junction).
@@ -301,56 +496,14 @@ def cmd_flip_link(link: Path, target: str) -> None:
301
496
  if link.exists() and not _is_link_or_junction(link):
302
497
  _die(2, f"refusing to replace non-link: {link}")
303
498
 
304
- lock_fd = os.open(lock_path, os.O_WRONLY | os.O_CREAT, 0o644)
305
- lock_held = False
306
499
  try:
307
- try:
308
- _acquire_lock(lock_fd)
309
- lock_held = True
310
- except OSError as e:
311
- if e.errno == errno.EAGAIN:
312
- _die(2, f"another process holds flip lock on {link}")
313
- raise
314
-
315
- tmp_link = link.with_name(link.name + ".skf-tmp-link")
316
- if tmp_link.is_symlink() or tmp_link.exists():
317
- # On Windows a junction returns is_dir()=True, exists()=True but
318
- # is_symlink()=False — must rmdir/unlink based on type.
319
- if tmp_link.is_dir() and not tmp_link.is_symlink():
320
- tmp_link.rmdir()
321
- else:
322
- tmp_link.unlink()
323
- try:
324
- link_kind = _create_symlink_or_junction(target, tmp_link)
325
- except OSError as e:
326
- if os.name == "nt" and getattr(e, "winerror", None) in (1314, 5):
327
- _die(
328
- 2,
329
- "symlink/junction creation failed on Windows. Junction fallback "
330
- "requires the target directory to exist. Either enable Developer "
331
- "Mode (Settings → Privacy & Security → For Developers) or use WSL2.",
332
- )
333
- raise
334
- # os.replace can swap a symlink-over-symlink atomically, but Windows
335
- # rejects renaming a directory (junction) over an existing directory
336
- # (junction or real). Drop the existing link first when junction-based.
337
- if link_kind == "junction" and (link.is_dir() or link.is_symlink() or link.exists()):
338
- if link.is_symlink() or not link.is_dir():
339
- link.unlink()
340
- else:
341
- link.rmdir()
342
- os.replace(tmp_link, link)
500
+ lock_fd = take_flip_lock(link)
501
+ except FlipLockHeld as exc:
502
+ _die(2, str(exc))
503
+ try:
504
+ link_kind = replace_link(link, target)
343
505
  finally:
344
- if lock_held:
345
- _release_lock(lock_fd)
346
- os.close(lock_fd)
347
- # Lock file is bookkeeping only — once the fd is closed the lock is
348
- # gone, so leaving the file around just litters the skill_group dir.
349
- # Best-effort unlink; a concurrent flipper may have already removed it.
350
- try:
351
- lock_path.unlink()
352
- except OSError:
353
- pass
506
+ release_flip_lock(link, lock_fd)
354
507
 
355
508
  _ok({"link": str(link), "points_to": target, "kind": link_kind})
356
509