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
@@ -63,6 +63,20 @@ Matching:
63
63
  listed in ambiguous_names[] with the files on each side, for a reviewer
64
64
  to judge.
65
65
 
66
+ Public surface:
67
+ A current entry may carry `public`, true or false: the mark
68
+ skf-extraction-snapshot.py build --scope-type public-api gives each
69
+ export by the public-surface rule a public-api skill's provenance map is
70
+ kept to. After the pairing above, a current entry marked false that
71
+ pairs with none (no baseline entry of its name and file, and no move)
72
+ goes to not_public[], not added[]: create-skill and update-skill never
73
+ document an export off the surface, so it is no drift. One whose name is
74
+ in ambiguous_names[] stays in added[], where a reviewer pairs it. A
75
+ matched or moved entry is compared whatever its mark, so a baseline
76
+ entry off the surface is never reported removed. An entry with no mark
77
+ (any other scope type, a family with no barrel, an older snapshot) is
78
+ added as before. not_public[] never counts toward the exit code.
79
+
66
80
  File scope (--files FILE):
67
81
  For a caller that re-extracted only the files that changed (update-skill's
68
82
  Category B), --files limits the diff to the exports of the files FILE
@@ -116,12 +130,27 @@ Canonicalization (applied symmetrically to BOTH sides before matching):
116
130
  ` = default`), so a runner's export compares with the map's entry.
117
131
 
118
132
  Change detection:
119
- The diffed fields are type, signature, params, return_type and line. A
120
- field is only compared when present (non-null) on BOTH sides: a field
121
- absent on one side means "insufficient data", never an asserted change.
133
+ The diffed fields are type (compared through export-type, below),
134
+ signature, params, return_type and line. A field is only compared when
135
+ present (non-null) on BOTH sides: a field absent on one side means
136
+ "insufficient data", never an asserted change.
122
137
  This avoids false positives when the two inventories carry different
123
138
  metadata. The line of a moved export is not compared: a move changes it.
124
139
 
140
+ export-type: no record is rewritten, but when a matched export's two
141
+ types differ, a semantic kind a by-eye read records is the base kind
142
+ the recipe runner records for the same declaration when the base-kind
143
+ side's canonical signature shows that form: `async_function` is
144
+ `function` for an `async def`, `async function`, `async fn` or
145
+ `async unsafe fn`, or a `const`, `let` or `var` bound to an `async`
146
+ arrow or function (after `export`, `default`, `declare` or `pub`);
147
+ `decorator` is `function` for a Python `def` or `async def`; `enum` is
148
+ `class` for a Python class with a stdlib `Enum`, `IntEnum`, `StrEnum`,
149
+ `ReprEnum`, `Flag` or `IntFlag` base. Either side may hold the by-eye
150
+ kind; with no signature on the base-kind side the types differ. Each
151
+ export whose types it takes as one counts once as `export-type` in
152
+ applied_transforms.
153
+
125
154
  A signature has two parts, the parameters and the return type. A side
126
155
  carries the parameters as params or in its signature, and the return
127
156
  type as return_type or in its signature. When both sides carry a part
@@ -145,7 +174,7 @@ Output:
145
174
  JSON object:
146
175
  summary: { added, removed, changed, moved, unchanged,
147
176
  label_changes, ambiguous_names,
148
- signature_unverified }
177
+ signature_unverified, not_public }
149
178
  added: list of entries present in current but not baseline
150
179
  removed: list of entries present in baseline but not current
151
180
  changed: list of { name, field, baseline_value, current_value,
@@ -168,17 +197,26 @@ Output:
168
197
  params and return_type each side carries.
169
198
  Informational: never a change, never in the exit
170
199
  code
200
+ not_public: list of current entries marked `public: false`
201
+ that pair with no baseline entry and whose name is
202
+ not ambiguous (see Public surface): exports off a
203
+ public-api skill's public surface, left out of
204
+ added[]. Not drift, never in the exit code
171
205
  unchanged_count: number of entries that matched with no field change
172
206
  (a label change or an unverified signature alone
173
207
  leaves an entry unchanged)
174
208
  applied_transforms: list of { transform, count }: which canonicalization
175
- transforms actually fired and how many values each
176
- touched (empty when none fired). Surfaced by audit
209
+ transforms, and the export-type comparison,
210
+ actually fired and how many values each touched
211
+ (export-type: how many exports; empty when none
212
+ fired). Surfaced by audit
177
213
  step 6's Provenance section so a reviewer can tell
178
214
  which differences the diff collapsed.
179
215
 
180
- added[] and removed[] hold normalized records: name, type, signature,
181
- params, return_type, file, line, confidence and extraction_method.
216
+ added[], removed[] and not_public[] hold normalized records: name, type,
217
+ signature, params, return_type, file, line, confidence and
218
+ extraction_method, and `public` when the entry carries a true or false
219
+ mark (see Public surface).
182
220
 
183
221
  With --group-by source_library, each side is split by its entries'
184
222
  source_library (a stack's libraries; an entry without one falls in a
@@ -208,8 +246,9 @@ Exit codes:
208
246
  inventory whose entries all lack the field while the other's have it,
209
247
  an output that cannot be written, or a usage error ({status: "error",
210
248
  error} on stdout, or argparse's usage on stderr)
211
- Label differences (label_changes) and unverified signatures
212
- (signature_unverified) do not count toward the exit code.
249
+ Label differences (label_changes), unverified signatures
250
+ (signature_unverified) and exports off the public surface (not_public) do
251
+ not count toward the exit code.
213
252
  """
214
253
 
215
254
  from __future__ import annotations
@@ -275,6 +314,37 @@ _STDLIB_PREFIX_RE = re.compile(
275
314
  r"(?<![\w.])(?:typing|dataclasses|collections\.abc|collections|enum)\."
276
315
  )
277
316
 
317
+ # export-type: a semantic kind a by-eye read records, against the base kind
318
+ # the recipe runner records for the same declaration, names one kind when
319
+ # the base-kind side's canonical signature shows that form. Any other pair
320
+ # of types, or a base-kind side with no signature, is compared as written.
321
+ # An async form: `async def`, `async function`, `async fn` or `async unsafe
322
+ # fn`, or a `const` / `let` / `var` bound to an `async` arrow or function
323
+ # (`export const add = async (x) => x;`), after any `export`, `default`,
324
+ # `declare` or `pub` modifier.
325
+ _ASYNC_FORM_RE = re.compile(
326
+ r"^\s*(?:(?:export|default|declare|pub(?:\s*\([^()]*\))?)\s+)*"
327
+ r"(?:async\s+(?:unsafe\s+)?(?:def|function|fn)\b"
328
+ r"|(?:const|let|var)\s+[\w$]+\s*(?::(?:[^=]|=>)*)?=\s*async\b)"
329
+ )
330
+ _DEF_FORM_RE = re.compile(r"^\s*(?:async\s+)?def\s")
331
+ _CLASS_BASES_RE = re.compile(r"^\s*class\s+\w+\s*\(([^()]*)\)")
332
+ _ENUM_BASES = frozenset({"Enum", "IntEnum", "StrEnum", "ReprEnum", "Flag", "IntFlag"})
333
+
334
+
335
+ def _enum_class_form(signature: str) -> bool:
336
+ """True when a Python class declaration lists a stdlib enum base."""
337
+ m = _CLASS_BASES_RE.match(signature)
338
+ return m is not None and any(base.strip() in _ENUM_BASES for base in m.group(1).split(","))
339
+
340
+
341
+ # (by-eye kind, base kind) -> the test of the base-kind side's signature.
342
+ EXPORT_TYPE_FORMS = {
343
+ ("async_function", "function"): lambda signature: _ASYNC_FORM_RE.match(signature) is not None,
344
+ ("decorator", "function"): lambda signature: _DEF_FORM_RE.match(signature) is not None,
345
+ ("enum", "class"): _enum_class_form,
346
+ }
347
+
278
348
 
279
349
  # --------------------------------------------------------------------------
280
350
  # Loading
@@ -561,6 +631,10 @@ def _normalize_entries(
561
631
  "confidence": entry.get("confidence"),
562
632
  "extraction_method": entry.get("extraction_method"),
563
633
  }
634
+ # The snapshot's public-surface mark, kept only when it is one, so an
635
+ # unmarked inventory's records are as before.
636
+ if isinstance(entry.get("public"), bool):
637
+ record["public"] = entry["public"]
564
638
  key = (name, _file_key(record["file"]))
565
639
  kept = result.get(key)
566
640
  if kept is not None and not (
@@ -616,6 +690,21 @@ def _label_change(base_rec: dict, curr_rec: dict) -> dict | None:
616
690
  return None
617
691
 
618
692
 
693
+ def _export_type_collapses(base_rec: dict, curr_rec: dict) -> bool:
694
+ """True when two matched records' types name one kind (EXPORT_TYPE_FORMS):
695
+ one side's by-eye kind against the other's base kind, whose canonical
696
+ signature shows that form. Either side may hold the by-eye kind."""
697
+ for by_eye, base in ((base_rec, curr_rec), (curr_rec, base_rec)):
698
+ kinds = (by_eye.get("type"), base.get("type"))
699
+ signature = base.get("signature")
700
+ if not all(isinstance(kind, str) for kind in kinds) or not isinstance(signature, str):
701
+ continue
702
+ form = EXPORT_TYPE_FORMS.get(kinds)
703
+ if form is not None and form(signature):
704
+ return True
705
+ return False
706
+
707
+
619
708
  def _signature_fields(rec: dict) -> list[str]:
620
709
  return [field for field in SIGNATURE_FIELDS if rec.get(field) is not None]
621
710
 
@@ -641,7 +730,8 @@ def _unverified_signature(base_rec: dict, curr_rec: dict) -> dict | None:
641
730
  # --------------------------------------------------------------------------
642
731
 
643
732
 
644
- LISTS = ["added", "removed", "changed", "moved", "ambiguous_names", "label_changes", "signature_unverified"]
733
+ LISTS = ["added", "removed", "changed", "moved", "ambiguous_names", "label_changes", "signature_unverified",
734
+ "not_public"]
645
735
 
646
736
 
647
737
  def _key_order(key: tuple) -> tuple:
@@ -656,14 +746,18 @@ def _diff_records(
656
746
  baseline: dict[tuple, dict],
657
747
  current: dict[tuple, dict],
658
748
  unchanged_names: collections.Counter | None = None,
749
+ transform_counts: collections.Counter | None = None,
659
750
  ) -> dict:
660
751
  """Diff two (name, file)-keyed record sets: the LISTS, plus the counts
661
752
  unchanged_count and changed_exports (exports with a changed field).
662
753
 
663
754
  unchanged_names counts the names of exports left out of both sets as
664
755
  unchanged (--files): each counts on both sides when a move needs a name
665
- unique on both sides.
756
+ unique on both sides. transform_counts gains one export-type for each
757
+ matched export whose types name one kind (_export_type_collapses).
666
758
  """
759
+ if transform_counts is None:
760
+ transform_counts = collections.Counter()
667
761
  removed_keys = sorted((k for k in baseline if k not in current), key=_key_order)
668
762
  added_keys = sorted((k for k in current if k not in baseline), key=_key_order)
669
763
 
@@ -697,8 +791,20 @@ def _diff_records(
697
791
 
698
792
  # Emit normalized entries (uniform shape) so added[] and removed[] render
699
793
  # into the same report table even though they originate from the
700
- # snapshot and provenance-map shapes.
701
- added = [current[k] for k in added_keys if k not in paired_curr]
794
+ # snapshot and provenance-map shapes. After the pairing, a leftover
795
+ # current entry marked off the public surface is no addition, unless its
796
+ # name is ambiguous: a reviewer may pair it as a move.
797
+ ambiguous = {item["name"] for item in ambiguous_names}
798
+ added: list[dict] = []
799
+ not_public: list[dict] = []
800
+ for key in added_keys:
801
+ if key in paired_curr:
802
+ continue
803
+ rec = current[key]
804
+ if rec.get("public") is False and rec["name"] not in ambiguous:
805
+ not_public.append(rec)
806
+ else:
807
+ added.append(rec)
702
808
  removed = [baseline[k] for k in removed_keys if k not in paired_base]
703
809
 
704
810
  changed: list[dict] = []
@@ -736,6 +842,11 @@ def _diff_records(
736
842
  if base_val is None or curr_val is None:
737
843
  continue
738
844
  if base_val != curr_val:
845
+ # A by-eye kind and the runner's base kind of one declaration
846
+ # are one type: compared pairwise, never rewritten.
847
+ if field == "type" and _export_type_collapses(base_rec, curr_rec):
848
+ transform_counts["export-type"] += 1
849
+ continue
739
850
  changed.append({
740
851
  "name": curr_rec["name"],
741
852
  "field": field,
@@ -771,6 +882,7 @@ def _diff_records(
771
882
  "ambiguous_names": ambiguous_names,
772
883
  "label_changes": label_changes,
773
884
  "signature_unverified": signature_unverified,
885
+ "not_public": not_public,
774
886
  "unchanged_count": unchanged_count,
775
887
  "changed_exports": changed_exports,
776
888
  }
@@ -786,6 +898,7 @@ def _summary(part: dict) -> dict:
786
898
  "label_changes": len(part["label_changes"]),
787
899
  "ambiguous_names": len(part["ambiguous_names"]),
788
900
  "signature_unverified": len(part["signature_unverified"]),
901
+ "not_public": len(part["not_public"]),
789
902
  }
790
903
 
791
904
 
@@ -813,10 +926,11 @@ def diff_inventories(
813
926
  """Compute the structural diff between two export inventories.
814
927
 
815
928
  Returns a dict with keys: summary, added, removed, changed, moved,
816
- ambiguous_names, label_changes, signature_unverified, unchanged_count,
817
- applied_transforms; with group_by, group_by and groups; and with files,
818
- file_scope (see the module docstring). files limits the diff to the
819
- exports of those files, and every other export is taken as unchanged.
929
+ ambiguous_names, label_changes, signature_unverified, not_public,
930
+ unchanged_count, applied_transforms; with group_by, group_by and
931
+ groups; and with files, file_scope (see the module docstring). files
932
+ limits the diff to the exports of those files, and every other export
933
+ is taken as unchanged.
820
934
 
821
935
  Raises ValueError when group_by is set and one side has entries but
822
936
  none with a group_by value while the other side has some; the message
@@ -869,6 +983,7 @@ def diff_inventories(
869
983
  _normalize_entries(base_part, reexport_map, transform_counts),
870
984
  _normalize_entries(curr_part, reexport_map, transform_counts),
871
985
  unchanged_names,
986
+ transform_counts,
872
987
  )
873
988
  if group_by:
874
989
  for name in LISTS:
@@ -16,12 +16,17 @@ to verify it points where expected and halts on divergence.
16
16
  This helper consolidates the two steps into one workflow-specific
17
17
  invocation:
18
18
 
19
+ - holds {skill-group}/active.skf-lock, the lock skf-atomic-write.py
20
+ flip-link takes, from the read through the re-read, so one flip of
21
+ the link runs at a time; while another flip holds it, waits up to
22
+ FLIP_LOCK_WAIT_SECONDS, then returns busy without touching the link
19
23
  - detects whether a flip is required (idempotent: already pointing
20
24
  at the target → no-op)
21
- - performs the flip atomically (symlink → temp name → os.replace)
22
- so concurrent readers never see a missing symlink
25
+ - performs the flip through skf-atomic-write.py's replace_link, the
26
+ code flip-link runs (link at a temp name → os.replace), so on POSIX
27
+ concurrent readers never see a missing symlink
23
28
  - verifies the post-state and surfaces ok / flipped / mismatch
24
- / missing-target
29
+ / missing-target / busy
25
30
 
26
31
  Two subcommands:
27
32
 
@@ -41,7 +46,7 @@ silently breaks downstream consumers that expect it to resolve.
41
46
 
42
47
  Output JSON (stdout):
43
48
  {
44
- "status": "ok" | "flipped" | "mismatch" | "missing-target",
49
+ "status": "ok" | "flipped" | "mismatch" | "missing-target" | "busy",
45
50
  "skill_group": "<abs path>",
46
51
  "active_link": "<abs path to active>",
47
52
  "expected_target": "<version>",
@@ -52,19 +57,28 @@ Output JSON (stdout):
52
57
  }
53
58
 
54
59
  Exit codes:
55
- 0 — ok (already correct) or flipped (just updated)
56
- 1 — script error (bad args, group path invalid)
57
- 2 — mismatch (verify mode and target diverges) OR missing-target
58
- (the {version} directory doesn't exist; caller must halt)
59
-
60
- Platform: POSIX symlinks. On Windows this script intentionally
61
- exits 1 with a clear error — update-skill's documented supported
62
- platform is WSL2 (same as skf-atomic-write.py's flip-link).
60
+ 0: ok (already correct) or flipped (just updated)
61
+ 1: script error, one `error:` line on stderr and nothing on stdout
62
+ (group path invalid, a real folder or file at {skill-group}/active,
63
+ skf-atomic-write.py not loadable from this script's folder, or the
64
+ link could not be moved); argparse exits 2 on bad arguments
65
+ 2: mismatch (verify mode and target diverges), missing-target
66
+ (the {version} directory doesn't exist; caller must halt), or
67
+ busy (update: another flip held the link's lock for the whole
68
+ wait; the link is unchanged, caller must halt)
69
+
70
+ Platform: Linux, macOS and native Windows, as skf-atomic-write.py's
71
+ flip-link. On Windows the flip removes the old link first whenever the
72
+ rename over it is refused, which it always is over a directory link, so
73
+ it is not atomic there; without symlink privilege the new link is a
74
+ directory junction. A junction reads back as the name of the version folder it
75
+ resolves to, so `ok` means the link names {version} whichever kind it is.
63
76
  """
64
77
 
65
78
  from __future__ import annotations
66
79
 
67
80
  import argparse
81
+ import importlib.util
68
82
  import json
69
83
  import os
70
84
  import sys
@@ -72,6 +86,23 @@ from pathlib import Path
72
86
 
73
87
 
74
88
  ACTIVE_NAME = "active"
89
+ ATOMIC_WRITE_HELPER = Path(__file__).resolve().parent / "skf-atomic-write.py"
90
+ # A flip holds <link>.skf-lock for well under a millisecond, so update waits
91
+ # this long for another flip to let it go before it returns busy.
92
+ FLIP_LOCK_WAIT_SECONDS = 5.0
93
+
94
+
95
+ def _load_atomic_write():
96
+ """Load skf-atomic-write.py, which installs into the same folder as this script."""
97
+ try:
98
+ spec = importlib.util.spec_from_file_location("skf_atomic_write", ATOMIC_WRITE_HELPER)
99
+ if spec is None or spec.loader is None:
100
+ raise ImportError("no import spec")
101
+ module = importlib.util.module_from_spec(spec)
102
+ spec.loader.exec_module(module)
103
+ except (OSError, ImportError, SyntaxError) as e:
104
+ raise OSError(f"cannot load the flip-lock helper {ATOMIC_WRITE_HELPER}: {e}") from e
105
+ return module
75
106
 
76
107
 
77
108
  # --------------------------------------------------------------------------
@@ -80,46 +111,51 @@ ACTIVE_NAME = "active"
80
111
 
81
112
 
82
113
  def read_link_target(link_path: Path) -> str | None:
83
- """Return the symlink target as a relative string, or None if the link
84
- doesn't exist. Raises ValueError if `link_path` exists but isn't a symlink
85
- (someone replaced it with a regular dir/file)."""
86
- if not link_path.exists() and not link_path.is_symlink():
114
+ """Return what the link at `link_path` names, or None if nothing is there.
115
+
116
+ A symlink gives its stored target (`1.6.2` for the links SKF makes). A
117
+ Windows junction, which Path.is_symlink() does not report and whose
118
+ os.readlink is an absolute `\\\\?\\` path, gives the name of the folder it
119
+ resolves to when that folder sits beside it, else its stored path (a
120
+ junction whose folder is gone, one that leads elsewhere, or a loop). Raises
121
+ ValueError if `link_path` exists but is not a link (someone replaced it
122
+ with a regular dir/file)."""
123
+ if not os.path.lexists(link_path):
87
124
  return None
88
- if not link_path.is_symlink():
125
+ if link_path.is_symlink():
126
+ return os.readlink(link_path)
127
+ try:
128
+ stored = os.readlink(link_path)
129
+ except (OSError, ValueError):
89
130
  raise ValueError(
90
131
  f"{link_path} exists but is not a symlink — refusing to overwrite "
91
132
  "(manual recovery required)"
92
- )
93
- return os.readlink(link_path)
133
+ ) from None
134
+ try:
135
+ resolved = link_path.resolve(strict=True)
136
+ except (OSError, RuntimeError): # Python 3.11 and 3.12 raise RuntimeError on a loop
137
+ return stored
138
+ if resolved.parent == link_path.parent.resolve():
139
+ return resolved.name
140
+ return stored
94
141
 
95
142
 
96
143
  def atomic_flip_symlink(link_path: Path, target_name: str) -> None:
97
- """Atomically point `link_path` at `target_name` using the
98
- create-temp + rename pattern. `target_name` is a relative path
99
- interpreted in the parent of `link_path` (same convention as
100
- {skill_group}/active → version-dir-sibling).
101
-
102
- Implementation: os.symlink writes a new link at link_path.tmp,
103
- then os.replace atomically renames it over link_path. POSIX
104
- rename is atomic for symlinks, so concurrent readers either see
105
- the old target or the new one, never a missing file.
144
+ """Point `link_path` at `target_name` using the create-temp + rename
145
+ pattern. `target_name` is a relative path interpreted in the parent of
146
+ `link_path` (same convention as {skill_group}/active → version-dir-sibling).
147
+
148
+ Implementation: skf-atomic-write.py's replace_link, the code flip-link
149
+ runs, with `.{name}.skf-symlink.tmp` as the temp name: it removes a stale
150
+ temp from a prior crash, writes the new link there and os.replace renames
151
+ it over link_path. POSIX rename is atomic for symlinks, so concurrent
152
+ readers either see the old target or the new one, never a missing file.
153
+ On Windows the old link is removed first whenever the rename over it is
154
+ refused, which it always is over a directory link; without symlink
155
+ privilege the new link is a junction.
106
156
  """
107
- parent = link_path.parent
108
- tmp = parent / f".{link_path.name}.skf-symlink.tmp"
109
- # Clean up a stale temp from a prior crash, if any.
110
- if tmp.is_symlink() or tmp.exists():
111
- os.unlink(tmp)
112
- try:
113
- os.symlink(target_name, tmp)
114
- os.replace(tmp, link_path)
115
- except OSError:
116
- # Best-effort cleanup; raise the original error.
117
- try:
118
- if tmp.is_symlink() or tmp.exists():
119
- os.unlink(tmp)
120
- except OSError:
121
- pass
122
- raise
157
+ tmp = link_path.parent / f".{link_path.name}.skf-symlink.tmp"
158
+ _load_atomic_write().replace_link(link_path, target_name, tmp)
123
159
 
124
160
 
125
161
  # --------------------------------------------------------------------------
@@ -191,8 +227,25 @@ def _envelope_mismatch(skill_group: Path, version: str, current: str | None) ->
191
227
  f"Active symlink divergence. `{skill_group / ACTIVE_NAME}` "
192
228
  f"resolves to `{cur}` but metadata.json reports `version: "
193
229
  f"{version}`. The flip did not apply. Re-point the symlink manually "
194
- f"(`ln -sfn {version} {skill_group / ACTIVE_NAME}`) or re-run "
195
- "update-skill, then re-verify."
230
+ f"(`uv run \"{ATOMIC_WRITE_HELPER}\" flip-link --link \"{skill_group / ACTIVE_NAME}\" "
231
+ f"--target \"{version}\"`) or re-run update-skill, then re-verify."
232
+ ),
233
+ }
234
+
235
+
236
+ def _envelope_busy(skill_group: Path, version: str, message: str) -> dict:
237
+ return {
238
+ "status": "busy",
239
+ "skill_group": str(skill_group),
240
+ "active_link": str(skill_group / ACTIVE_NAME),
241
+ "expected_target": version,
242
+ "current_target": None,
243
+ "action_taken": "halt",
244
+ "log_message": f"active_symlink_update: busy ({message})",
245
+ "halt_message": (
246
+ f"Another flip of `{skill_group / ACTIVE_NAME}` held its lock for "
247
+ f"{FLIP_LOCK_WAIT_SECONDS:g} s, so the link is unchanged. "
248
+ "Re-run once the other run ends."
196
249
  ),
197
250
  }
198
251
 
@@ -204,16 +257,24 @@ def update(skill_group: Path, version: str) -> dict:
204
257
  return _envelope_missing_target(skill_group, version)
205
258
 
206
259
  link_path = skill_group / ACTIVE_NAME
207
- current = read_link_target(link_path)
208
- if current == version:
209
- return _envelope_ok(skill_group, version, current)
260
+ atomic_write = _load_atomic_write()
261
+ try:
262
+ lock_fd = atomic_write.take_flip_lock(link_path, wait=FLIP_LOCK_WAIT_SECONDS)
263
+ except atomic_write.FlipLockHeld as exc:
264
+ return _envelope_busy(skill_group, version, str(exc))
265
+ try:
266
+ current = read_link_target(link_path)
267
+ if current == version:
268
+ return _envelope_ok(skill_group, version, current)
210
269
 
211
- atomic_flip_symlink(link_path, version)
270
+ atomic_flip_symlink(link_path, version)
212
271
 
213
- # Re-read to confirm
214
- post = read_link_target(link_path)
272
+ # Re-read to confirm
273
+ post = read_link_target(link_path)
274
+ finally:
275
+ atomic_write.release_flip_lock(link_path, lock_fd)
215
276
  if post != version:
216
- # Should be impossible — os.replace would have raised — but defend
277
+ # Only a writer that skips the lock, such as a hand-run `ln -sfn`, gets here.
217
278
  return _envelope_mismatch(skill_group, version, post)
218
279
  return _envelope_flipped(skill_group, version, current)
219
280
 
@@ -235,7 +296,7 @@ def verify(skill_group: Path, version: str) -> dict:
235
296
  def _exit_code_for_status(status: str) -> int:
236
297
  if status in ("ok", "flipped"):
237
298
  return 0
238
- if status in ("mismatch", "missing-target"):
299
+ if status in ("mismatch", "missing-target", "busy"):
239
300
  return 2
240
301
  return 1
241
302
 
@@ -298,14 +359,6 @@ def _build_parser() -> argparse.ArgumentParser:
298
359
 
299
360
 
300
361
  def main(argv: list[str] | None = None) -> int:
301
- if sys.platform.startswith("win"):
302
- print(
303
- "error: native Windows symlinks require admin/developer mode; "
304
- "use WSL2 to run update-skill (same constraint as "
305
- "skf-atomic-write.py flip-link)",
306
- file=sys.stderr,
307
- )
308
- return 1
309
362
  parser = _build_parser()
310
363
  args = parser.parse_args(argv)
311
364
  return args.func(args)
@@ -52,6 +52,7 @@ from __future__ import annotations
52
52
 
53
53
  import argparse
54
54
  import datetime
55
+ import importlib.util
55
56
  import json
56
57
  import sys
57
58
  from pathlib import Path
@@ -62,6 +63,23 @@ from jsonschema import Draft202012Validator
62
63
 
63
64
 
64
65
  SCHEMA_PATH = Path(__file__).resolve().parent / "schemas" / "skill-brief.v1.json"
66
+ RESOLVER_PATH = Path(__file__).resolve().parent / "skf-resolve-authoritative-files.py"
67
+
68
+
69
+ def _load_resolver():
70
+ """skf-resolve-authoritative-files.py from this folder: its
71
+ parse_brief_yaml reads a brief's YAML, an SKF 1.x brief's trailing `---`
72
+ included. A missing file raises OSError, which skf-quick-batch.py
73
+ reports as a validator that cannot be loaded."""
74
+ spec = importlib.util.spec_from_file_location("skf_resolve_authoritative_files", RESOLVER_PATH)
75
+ if spec is None or spec.loader is None:
76
+ raise ImportError(f"cannot load {RESOLVER_PATH.name} beside {Path(__file__).name}")
77
+ module = importlib.util.module_from_spec(spec)
78
+ spec.loader.exec_module(module)
79
+ return module
80
+
81
+
82
+ _RESOLVER = _load_resolver()
65
83
 
66
84
 
67
85
  # --------------------------------------------------------------------------
@@ -77,7 +95,7 @@ def load_schema() -> dict:
77
95
  def load_brief_text(text: str) -> tuple[dict | None, str | None]:
78
96
  """Parse YAML text. Returns (brief, error_message)."""
79
97
  try:
80
- brief = yaml.safe_load(text)
98
+ brief = _RESOLVER.parse_brief_yaml(text)
81
99
  except yaml.YAMLError as exc:
82
100
  return None, f"Brief is not valid YAML: {exc}"
83
101
  if brief is None:
@@ -146,6 +146,14 @@ def validate_body_structure(content):
146
146
  return issues
147
147
 
148
148
 
149
+ # The context snippet's length bounds, in `len(text)//4` tokens. The ceiling is
150
+ # the hard 300-token ceiling export-skill's generate-snippet.md §4 sets (a Deep
151
+ # snippet may pass the ~80-120 target when its gotchas carry load-bearing
152
+ # breaking-change notices), so a snippet that step accepts is never flagged.
153
+ SNIPPET_TOKEN_FLOOR = 40
154
+ SNIPPET_TOKEN_CEILING = 300
155
+
156
+
149
157
  def validate_context_snippet(content):
150
158
  """Validate context-snippet.md format. Returns list of issues."""
151
159
  issues = []
@@ -169,10 +177,11 @@ def validate_context_snippet(content):
169
177
 
170
178
  # Approximate token count (rough: ~4 chars per token)
171
179
  approx_tokens = len(content) // 4
172
- if approx_tokens < 40:
180
+ if approx_tokens < SNIPPET_TOKEN_FLOOR:
173
181
  issues.append({"severity": "low", "field": "length", "message": f"Context snippet may be too short (~{approx_tokens} tokens)"})
174
- elif approx_tokens > 200:
175
- issues.append({"severity": "low", "field": "length", "message": f"Context snippet may be too long (~{approx_tokens} tokens)"})
182
+ elif approx_tokens > SNIPPET_TOKEN_CEILING:
183
+ issues.append({"severity": "low", "field": "length",
184
+ "message": f"Context snippet is above the {SNIPPET_TOKEN_CEILING}-token ceiling (~{approx_tokens} tokens)"})
176
185
 
177
186
  return issues
178
187