bmad-module-skill-forge 4.0.0 → 6.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 (128) 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/agents.md +1 -1
  5. package/docs/architecture.md +5 -5
  6. package/docs/campaign.md +1 -1
  7. package/docs/concepts.md +4 -4
  8. package/docs/examples.md +32 -25
  9. package/docs/getting-started.md +3 -3
  10. package/docs/how-it-works.md +2 -2
  11. package/docs/index.md +1 -1
  12. package/docs/skill-model.md +41 -39
  13. package/docs/troubleshooting.md +9 -3
  14. package/docs/verifying-a-skill.md +42 -21
  15. package/docs/why-skf.md +1 -1
  16. package/docs/workflows.md +6 -6
  17. package/package.json +1 -1
  18. package/src/knowledge/tool-resolution.md +6 -6
  19. package/src/shared/references/headless-gate-convention.md +4 -1
  20. package/src/shared/references/output-contract-schema.md +3 -3
  21. package/src/shared/references/pipeline-contracts.md +2 -1
  22. package/src/shared/scripts/schemas/skf-test-result-envelope.v1.json +1 -1
  23. package/src/shared/scripts/schemas/skf-update-result-envelope.v1.json +11 -3
  24. package/src/shared/scripts/schemas/skill-brief.v1.json +1 -1
  25. package/src/shared/scripts/skf-build-change-manifest.py +1180 -189
  26. package/src/shared/scripts/skf-classify-changed-files.py +15 -10
  27. package/src/shared/scripts/skf-count-tokens.py +54 -10
  28. package/src/shared/scripts/skf-detect-workspaces.py +67 -0
  29. package/src/shared/scripts/skf-emit-result-envelope.py +64 -20
  30. package/src/shared/scripts/skf-extract-public-api.py +472 -120
  31. package/src/shared/scripts/skf-extraction-inventory.py +10 -6
  32. package/src/shared/scripts/skf-extraction-snapshot.py +6 -6
  33. package/src/shared/scripts/skf-parse-gaps.py +136 -26
  34. package/src/shared/scripts/skf-render-metadata-stats.py +1 -1
  35. package/src/shared/scripts/skf-resolve-customization.py +197 -0
  36. package/src/shared/scripts/skf-shard-body.py +239 -41
  37. package/src/shared/scripts/skf-validate-output.py +12 -3
  38. package/src/shared/scripts/skf-verify-provenance-completeness.py +574 -45
  39. package/src/shared/tool-requirements.yaml +8 -2
  40. package/src/skf-analyze-source/SKILL.md +3 -2
  41. package/src/skf-analyze-source/assets/skill-brief-schema.md +1 -1
  42. package/src/skf-analyze-source/references/generate-briefs.md +1 -1
  43. package/src/skf-analyze-source/references/headless-contract.md +1 -1
  44. package/src/skf-audit-skill/SKILL.md +4 -3
  45. package/src/skf-audit-skill/references/re-index.md +2 -2
  46. package/src/skf-audit-skill/references/report.md +1 -1
  47. package/src/skf-brief-skill/SKILL.md +3 -2
  48. package/src/skf-brief-skill/assets/skill-brief-schema.md +2 -2
  49. package/src/skf-brief-skill/references/invocation-contract.md +2 -2
  50. package/src/skf-brief-skill/references/scope-definition.md +4 -4
  51. package/src/skf-campaign/SKILL.md +3 -2
  52. package/src/skf-campaign/references/campaign-contracts.md +2 -0
  53. package/src/skf-campaign/references/step-05-skill-loop.md +8 -3
  54. package/src/skf-campaign/references/step-10-export.md +3 -2
  55. package/src/skf-create-skill/SKILL.md +4 -3
  56. package/src/skf-create-skill/assets/skill-sections.md +1 -1
  57. package/src/skf-create-skill/references/ccc-index-check.md +1 -1
  58. package/src/skf-create-skill/references/component-extraction.md +1 -1
  59. package/src/skf-create-skill/references/extract.md +3 -2
  60. package/src/skf-create-skill/references/extraction-patterns.md +3 -3
  61. package/src/skf-create-skill/references/generate-artifacts.md +1 -1
  62. package/src/skf-create-skill/references/report.md +8 -3
  63. package/src/skf-create-skill/references/sub/ccc-discover.md +1 -1
  64. package/src/skf-create-skill/references/validate.md +1 -1
  65. package/src/skf-create-stack-skill/SKILL.md +3 -2
  66. package/src/skf-create-stack-skill/references/generate-output.md +1 -1
  67. package/src/skf-create-stack-skill/references/invocation-contract.md +1 -1
  68. package/src/skf-create-stack-skill/references/parallel-extract.md +2 -2
  69. package/src/skf-create-stack-skill/references/report.md +1 -1
  70. package/src/skf-create-stack-skill/references/validate.md +1 -1
  71. package/src/skf-drop-skill/SKILL.md +3 -2
  72. package/src/skf-drop-skill/references/invocation-contract.md +1 -1
  73. package/src/skf-drop-skill/references/report.md +1 -1
  74. package/src/skf-export-skill/SKILL.md +3 -2
  75. package/src/skf-export-skill/assets/snippet-format.md +5 -5
  76. package/src/skf-export-skill/references/generate-snippet.md +22 -15
  77. package/src/skf-export-skill/references/invocation-contract.md +1 -1
  78. package/src/skf-export-skill/references/load-skill.md +1 -1
  79. package/src/skf-export-skill/references/summary.md +1 -1
  80. package/src/skf-export-skill/references/token-report.md +29 -9
  81. package/src/skf-forger/SKILL.md +3 -2
  82. package/src/skf-forger/references/pipeline-mode.md +2 -2
  83. package/src/skf-quick-skill/SKILL.md +4 -3
  84. package/src/skf-quick-skill/references/finalize.md +1 -1
  85. package/src/skf-quick-skill/references/halt-contract.md +2 -2
  86. package/src/skf-quick-skill/references/write-and-validate.md +1 -1
  87. package/src/skf-refine-architecture/SKILL.md +8 -9
  88. package/src/skf-refine-architecture/customize.toml +1 -1
  89. package/src/skf-refine-architecture/references/report.md +1 -1
  90. package/src/skf-rename-skill/SKILL.md +3 -2
  91. package/src/skf-rename-skill/references/invocation-contract.md +2 -2
  92. package/src/skf-setup/SKILL.md +3 -2
  93. package/src/skf-setup/references/ccc-index.md +1 -1
  94. package/src/skf-setup/references/invocation-contract.md +1 -1
  95. package/src/skf-test-skill/SKILL.md +3 -2
  96. package/src/skf-test-skill/assets/output-section-formats.md +6 -4
  97. package/src/skf-test-skill/references/coherence-check.md +11 -8
  98. package/src/skf-test-skill/references/coverage-check-branches.md +81 -0
  99. package/src/skf-test-skill/references/coverage-check-tiers.md +3 -3
  100. package/src/skf-test-skill/references/coverage-check.md +60 -97
  101. package/src/skf-test-skill/references/init.md +2 -1
  102. package/src/skf-test-skill/references/inventory-contract.md +27 -0
  103. package/src/skf-test-skill/references/invocation-contract.md +3 -3
  104. package/src/skf-test-skill/references/migration-section-rules.md +26 -14
  105. package/src/skf-test-skill/references/report.md +5 -5
  106. package/src/skf-test-skill/references/scoring-rules.md +8 -7
  107. package/src/skf-test-skill/references/signature-comparison-rules.md +72 -0
  108. package/src/skf-test-skill/references/source-access-protocol.md +14 -10
  109. package/src/skf-test-skill/references/step-hard-gate.md +1 -0
  110. package/src/skf-test-skill/scripts/build-result-context.py +20 -3
  111. package/src/skf-test-skill/scripts/gap-ledger.py +456 -176
  112. package/src/skf-test-skill/scripts/load-coverage-inputs.py +516 -79
  113. package/src/skf-test-skill/scripts/locate-export-segments.py +685 -76
  114. package/src/skf-test-skill/scripts/reconcile-coverage.py +192 -39
  115. package/src/skf-test-skill/scripts/score-signatures.py +1205 -65
  116. package/src/skf-test-skill/scripts/stage-helper-payload.py +64 -13
  117. package/src/skf-test-skill/scripts/validate-inventory.py +350 -35
  118. package/src/skf-test-skill/templates/test-report-template.md +1 -0
  119. package/src/skf-update-skill/SKILL.md +3 -2
  120. package/src/skf-update-skill/references/detect-changes.md +1 -1
  121. package/src/skf-update-skill/references/gap-driven.md +54 -33
  122. package/src/skf-update-skill/references/invocation-contract.md +1 -1
  123. package/src/skf-update-skill/references/merge.md +15 -10
  124. package/src/skf-update-skill/references/re-extract.md +3 -1
  125. package/src/skf-update-skill/references/report.md +6 -5
  126. package/src/skf-update-skill/references/write.md +42 -20
  127. package/src/skf-verify-stack/SKILL.md +4 -3
  128. package/tools/cli/lib/tool-check.js +158 -2
@@ -8,42 +8,70 @@
8
8
  Owns the line counting, the section boundaries, the size sort, the file
9
9
  writes and the blockquote replacements of `skf-create-skill`'s
10
10
  `references/step-auto-shard.md` §0 (which runs it on every pass and keeps no
11
- by-hand fallback) and `references/validate.md` §4: counted by eye, an
12
- over-budget body ships silently or the step halts for nothing.
11
+ by-hand fallback) and `references/validate.md` §4, and of
12
+ `skf-update-skill`'s `references/write.md` §7 when skill-check reports
13
+ `body.max_lines` or `body.max_tokens`: counted by eye, an over-budget body
14
+ ships silently or the step halts for nothing.
13
15
 
14
16
  What it does (single positional invocation):
15
17
 
16
18
  1. Split frontmatter (leading `---` … `---`) from body. Frontmatter is
17
19
  NEVER modified.
18
20
  2. Count body lines between the frontmatter close and EOF, excluding
19
- trailing blank lines → `body_lines_before`.
20
- 3. If `body_lines_before` <= `--budget` (default 400): emit
21
- `action: "skip"`, write nothing.
21
+ trailing blank lines → `body_lines_before`, and body words: the
22
+ parts left after splitting the body on JavaScript's `\\s` whitespace
23
+ (`/\\s+/`), empty parts dropped → `body_words_before`: the same split
24
+ skill-check's `body.max_tokens` estimate makes
25
+ (`text.split(/\\s+/).filter(Boolean)`), which update-skill's write.md
26
+ §7 holds under a budget below skill-check's limit.
27
+ 3. If `body_lines_before` <= `--budget` (default 400) and, when
28
+ `--word-budget` is set (default 0, off), `body_words_before` <=
29
+ `--word-budget`: emit `action: "skip"`, write nothing.
22
30
  4. Otherwise enumerate Tier-2 sections — level-2 headings whose text
23
31
  starts with `Full` (`## Full API Reference`, `## Full Type
24
32
  Definitions`, …). Each section spans its heading through the line
25
- before the next level-1/level-2 heading (or EOF).
26
- 5. Sort candidates by line count descending (largest first, stable) and
27
- greedily extract the largest, re-counting after each, stopping the
28
- moment the body fits under budget. If selective extraction of every
29
- Tier-2 section still cannot fit, extract them all and report
30
- `under_budget: false` (the caller then trims Tier-1 by editing
31
- judgment — that fallback stays in the prompt).
33
+ before the next level-1/level-2 heading (or EOF). With
34
+ `--skip-manual`, a section that intersects a [MANUAL] block is never
35
+ a candidate: a block is an open marker paired with the earliest
36
+ following close marker of the same name, read over the whole body
37
+ (so a block opened before a section and closed after it counts, and
38
+ a marker split across lines is found, as skf-hash-content.py reads
39
+ them), and a marker left unpaired protects its own lines. update-skill
40
+ inventories [MANUAL] blocks in SKILL.md only, so a moved block would
41
+ leave its next inventory.
42
+ 5. Greedily extract one candidate at a time, re-counting after each,
43
+ stopping the moment the body fits: lines <= `--budget` and, when set,
44
+ words <= `--word-budget`. While the lines exceed the budget the next
45
+ candidate is the largest by lines; once they fit, the largest by
46
+ words (ties keep document order). If extracting every candidate
47
+ still cannot fit, extract them all and report `under_budget: false`
48
+ or `under_word_budget: false`. create-skill then trims Tier-1 by
49
+ editing judgment (that fallback stays in the prompt); update-skill
50
+ records a warning and trims nothing. With no candidate, nothing is
51
+ written.
32
52
  6. Each extracted section is written to `references/{kebab(heading)}.md`
33
53
  (heading preserved) via `skf-atomic-write.py`, and the section in
34
54
  SKILL.md is replaced by a `> See [Title](references/…md)` blockquote.
35
- The rewritten SKILL.md is itself written via `skf-atomic-write.py`.
55
+ A file is never overwritten: when `references/{kebab}.md` already
56
+ holds other content, or an earlier extracted section of this run took
57
+ the name, the next free `{kebab}-2.md`, `{kebab}-3.md`, … is used (as
58
+ skill-check's split-body names them), in document order, and the
59
+ blockquote points at it. The rewritten SKILL.md is itself written via
60
+ `skf-atomic-write.py`.
36
61
  7. Verify every Tier-1 heading that was inline before extraction is still
37
62
  inline (`tier1_preserved`, with `tier1_missing[]`), and every emitted
38
63
  blockquote resolves to a written reference file (`xref_ok`).
39
64
 
40
- Deterministic: same input + budget → same JSON and same on-disk result.
65
+ Deterministic: same input + budgets + flags → same JSON and same on-disk
66
+ result.
41
67
 
42
68
  Output JSON (stdout, or `-o`):
43
69
  {
44
70
  "action": "skip" | "shard",
45
71
  "body_lines_before": <int>,
46
72
  "body_lines_after": <int>,
73
+ "body_words_before": <int>,
74
+ "body_words_after": <int>,
47
75
  "sections_extracted": [
48
76
  {"heading": "Full API Reference",
49
77
  "file": "references/full-api-reference.md",
@@ -53,8 +81,10 @@ Output JSON (stdout, or `-o`):
53
81
  "tier1_missing": ["Overview", ...],
54
82
  "xref_ok": <bool>,
55
83
  "under_budget": <bool>,
84
+ "under_word_budget": <bool>, (true when --word-budget is 0)
56
85
  "dry_run": <bool>,
57
- "budget": <int>
86
+ "budget": <int>,
87
+ "word_budget": <int> (0 when off)
58
88
  }
59
89
 
60
90
  Heading parsing reuses `skf-scan-skill-md-structure.py`'s `_HEADING_RE`
@@ -68,11 +98,14 @@ Exit codes:
68
98
  gates on `tier1_preserved` / `xref_ok`.
69
99
  1 — user error (file not found, unreadable, bad frontmatter).
70
100
  2 — operation failure (a reference/SKILL.md write could not complete).
101
+ The stderr line names the file that failed and each reference file
102
+ already written (`already written: none` when there is none).
71
103
  """
72
104
 
73
105
  from __future__ import annotations
74
106
 
75
107
  import argparse
108
+ import bisect
76
109
  import importlib.util
77
110
  import json
78
111
  import re
@@ -141,13 +174,16 @@ TIER1_HEADINGS: list[str] = [
141
174
  class Section:
142
175
  """A Tier-2 (`## Full …`) section as a half-open line range [start, end)."""
143
176
 
144
- __slots__ = ("start", "end", "heading", "kebab")
177
+ __slots__ = ("start", "end", "heading", "kebab", "name")
145
178
 
146
179
  def __init__(self, start: int, end: int, heading: str) -> None:
147
180
  self.start = start
148
181
  self.end = end
149
182
  self.heading = heading
150
183
  self.kebab = kebab_case(heading)
184
+ # The reference file's stem: the kebab name, or a `-2`, `-3` …
185
+ # variant `assign_names` picks so no file is overwritten.
186
+ self.name = self.kebab
151
187
 
152
188
  @property
153
189
  def lines(self) -> int:
@@ -155,11 +191,11 @@ class Section:
155
191
 
156
192
  @property
157
193
  def blockquote(self) -> str:
158
- return f"> See [{self.heading}](references/{self.kebab}.md)"
194
+ return f"> See [{self.heading}](references/{self.name}.md)"
159
195
 
160
196
  @property
161
197
  def ref_rel(self) -> str:
162
- return f"references/{self.kebab}.md"
198
+ return f"references/{self.name}.md"
163
199
 
164
200
 
165
201
  def kebab_case(text: str) -> str:
@@ -200,6 +236,83 @@ def _headings(body: list[str]) -> list[tuple[int, int, str]]:
200
236
  return out
201
237
 
202
238
 
239
+ # JavaScript's `\s`, what skill-check's `/\s+/` splits on: tab, LF, VT, FF,
240
+ # CR, space, U+00A0, U+1680, U+2000-U+200A, U+2028, U+2029, U+202F, U+205F,
241
+ # U+3000 and U+FEFF. Python's `str.split()` differs: it also splits on
242
+ # U+001C-U+001F and U+0085, and never on U+FEFF.
243
+ _JS_WHITESPACE_RE = re.compile(
244
+ "[\t\n\v\f\r \u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000\ufeff]+"
245
+ )
246
+
247
+
248
+ def count_words(text: str) -> int:
249
+ """Words in `text`: the parts a JavaScript `/\\s+/` split leaves, empty parts dropped."""
250
+ return sum(1 for part in _JS_WHITESPACE_RE.split(text) if part)
251
+
252
+
253
+ def count_body_words(body: list[str]) -> int:
254
+ """Number of body words (see `count_words`)."""
255
+ return count_words("\n".join(body))
256
+
257
+
258
+ # [MANUAL] markers as skf-hash-content.py reads them for the inventory:
259
+ # whitespace-tolerant, so a marker may run across lines. `_MANUAL_LOOSE_RE`
260
+ # also catches a marker too malformed for the inventory to read.
261
+ _MANUAL_OPEN_RE = re.compile(r"<!--\s*\[MANUAL:([^\]]+)\]\s*-->")
262
+ _MANUAL_CLOSE_RE = re.compile(r"<!--\s*\[/MANUAL:([^\]]+)\]\s*-->")
263
+ _MANUAL_LOOSE_RE = re.compile(r"<!--\s*\[/?MANUAL:")
264
+
265
+
266
+ def manual_ranges(body: list[str]) -> list[tuple[int, int]]:
267
+ """Half-open body line ranges [first, last + 1) the [MANUAL] blocks cover.
268
+
269
+ Read over the whole body, joined, so a block opened before a section and
270
+ closed after it, and a marker split across lines, are both found. A block
271
+ is an open marker paired with the earliest following close marker of the
272
+ same name, as skf-hash-content.py pairs them; an unpaired marker, or a
273
+ marker too malformed to pair, covers its own lines.
274
+ """
275
+ text = "\n".join(body)
276
+ starts: list[int] = []
277
+ offset = 0
278
+ for line in body:
279
+ starts.append(offset)
280
+ offset += len(line) + 1
281
+
282
+ def line_of(pos: int) -> int:
283
+ return bisect.bisect_right(starts, pos) - 1
284
+
285
+ def span(begin: int, finish: int) -> tuple[int, int]:
286
+ return line_of(begin), line_of(max(begin, finish - 1)) + 1
287
+
288
+ closes = [(m.start(), m.end(), m.group(1).strip()) for m in _MANUAL_CLOSE_RE.finditer(text)]
289
+ ranges: list[tuple[int, int]] = []
290
+ paired: set[int] = set()
291
+ for om in _MANUAL_OPEN_RE.finditer(text):
292
+ name = om.group(1).strip()
293
+ close = next((c for c in closes if c[0] >= om.end() and c[2] == name), None)
294
+ if close is None:
295
+ ranges.append(span(om.start(), om.end()))
296
+ else:
297
+ paired.add(close[0])
298
+ ranges.append(span(om.start(), close[1]))
299
+ for c_start, c_end, _ in closes:
300
+ if c_start not in paired:
301
+ ranges.append(span(c_start, c_end))
302
+ for m in _MANUAL_LOOSE_RE.finditer(text):
303
+ ranges.append(span(m.start(), m.end()))
304
+ return ranges
305
+
306
+
307
+ def holds_manual(
308
+ body: list[str], sec: "Section", ranges: list[tuple[int, int]] | None = None
309
+ ) -> bool:
310
+ """True when the section intersects a [MANUAL] block (see `manual_ranges`)."""
311
+ if ranges is None:
312
+ ranges = manual_ranges(body)
313
+ return any(sec.start < last and first < sec.end for first, last in ranges)
314
+
315
+
203
316
  def find_full_sections(body: list[str]) -> list[Section]:
204
317
  """Enumerate Tier-2 `## Full …` sections in document order.
205
318
 
@@ -250,25 +363,69 @@ def rebuild_body(body: list[str], selected: list[Section]) -> list[str]:
250
363
  return out
251
364
 
252
365
 
253
- def select_sections(
254
- body: list[str], sections: list[Section], budget: int
255
- ) -> tuple[list[Section], list[str], int]:
256
- """Greedily extract largest-first until the body fits, or all are taken.
366
+ def fits(lines: int, words: int, budget: int, word_budget: int) -> bool:
367
+ """True when the body is within the line budget and, when set, the word budget."""
368
+ return lines <= budget and (word_budget <= 0 or words <= word_budget)
369
+
257
370
 
258
- Returns (selected, new_body, body_lines_after). `sorted(reverse=True)` is
259
- stable, so equal-size sections keep document order — deterministic.
371
+ def select_sections(
372
+ body: list[str], sections: list[Section], budget: int, word_budget: int = 0
373
+ ) -> tuple[list[Section], list[str], int, int]:
374
+ """Greedily extract one section at a time until the body fits, or all are taken.
375
+
376
+ The body fits when its lines are <= `budget` and, when `word_budget` is
377
+ above 0, its words are <= `word_budget`. Whichever budget the body still
378
+ exceeds picks the next section: the largest by lines while the lines
379
+ exceed `budget`, then the largest by words. `max` keeps the first of equal
380
+ sections, so ties go in document order: deterministic. Returns (selected,
381
+ new_body, body_lines_after, body_words_after).
260
382
  """
261
- candidates = sorted(sections, key=lambda s: s.lines, reverse=True)
383
+ words = {sec.start: count_words("\n".join(body[sec.start : sec.end])) for sec in sections}
384
+ remaining = list(sections)
262
385
  selected: list[Section] = []
263
386
  new_body = body
264
387
  after = count_body_lines(body)
265
- for sec in candidates:
266
- selected.append(sec)
388
+ words_after = count_body_words(body)
389
+ while remaining and not fits(after, words_after, budget, word_budget):
390
+ if after > budget:
391
+ pick = max(remaining, key=lambda s: s.lines)
392
+ else:
393
+ pick = max(remaining, key=lambda s: words[s.start])
394
+ remaining.remove(pick)
395
+ selected.append(pick)
267
396
  new_body = rebuild_body(body, selected)
268
397
  after = count_body_lines(new_body)
269
- if after <= budget:
270
- break
271
- return selected, new_body, after
398
+ words_after = count_body_words(new_body)
399
+ return selected, new_body, after, words_after
400
+
401
+
402
+ def _same_content(path: Path, content: str) -> bool:
403
+ try:
404
+ return path.is_file() and path.read_text(encoding="utf-8") == content
405
+ except (OSError, UnicodeDecodeError):
406
+ return False
407
+
408
+
409
+ def assign_names(body: list[str], selected: list[Section], references_dir: Path) -> None:
410
+ """Give each selected section a reference file name that overwrites nothing.
411
+
412
+ In document order, each takes `{kebab}.md`, or the first free
413
+ `{kebab}-2.md`, `{kebab}-3.md`, ... when that file already holds other
414
+ content or an earlier section of this run took the name. A file that
415
+ already holds exactly the section's content is reused.
416
+ """
417
+ taken: set[str] = set()
418
+ for sec in sorted(selected, key=lambda s: s.start):
419
+ content = _section_content(body, sec)
420
+ n = 1
421
+ while True:
422
+ name = sec.kebab if n == 1 else f"{sec.kebab}-{n}"
423
+ path = references_dir / f"{name}.md"
424
+ if name not in taken and (not path.exists() or _same_content(path, content)):
425
+ break
426
+ n += 1
427
+ sec.name = name
428
+ taken.add(name)
272
429
 
273
430
 
274
431
  # --------------------------------------------------------------------------
@@ -325,40 +482,57 @@ def _cmd_shard(args: argparse.Namespace) -> int:
325
482
  return 1
326
483
 
327
484
  budget: int = args.budget
485
+ word_budget: int = args.word_budget
328
486
  dry_run: bool = args.dry_run
329
487
 
330
488
  frontmatter, body = split_frontmatter(text)
331
489
  body_lines_before = count_body_lines(body)
490
+ body_words_before = count_body_words(body)
332
491
  tier1_before = present_tier1(body)
333
492
 
334
493
  if args.verbose:
335
494
  print(
336
- f"skf-shard-body: body_lines_before={body_lines_before} budget={budget}",
495
+ f"skf-shard-body: body_lines_before={body_lines_before} budget={budget} "
496
+ f"body_words_before={body_words_before} word_budget={word_budget}",
337
497
  file=sys.stderr,
338
498
  )
339
499
 
340
500
  # --- Skip path: already within budget -----------------------------------
341
- if body_lines_before <= budget:
501
+ if fits(body_lines_before, body_words_before, budget, word_budget):
342
502
  _emit(
343
503
  {
344
504
  "action": "skip",
345
505
  "body_lines_before": body_lines_before,
346
506
  "body_lines_after": body_lines_before,
507
+ "body_words_before": body_words_before,
508
+ "body_words_after": body_words_before,
347
509
  "sections_extracted": [],
348
510
  "tier1_preserved": True,
349
511
  "tier1_missing": [],
350
512
  "xref_ok": True,
351
513
  "under_budget": True,
514
+ "under_word_budget": True,
352
515
  "dry_run": dry_run,
353
516
  "budget": budget,
517
+ "word_budget": word_budget,
354
518
  },
355
519
  args.output,
356
520
  )
357
521
  return 0
358
522
 
359
523
  # --- Shard path ---------------------------------------------------------
524
+ references_dir = skill_md.parent / "references"
360
525
  sections = find_full_sections(body)
361
- selected, new_body, body_lines_after = select_sections(body, sections, budget)
526
+ if args.skip_manual:
527
+ ranges = manual_ranges(body)
528
+ sections = [sec for sec in sections if not holds_manual(body, sec, ranges)]
529
+ selected, new_body, body_lines_after, body_words_after = select_sections(
530
+ body, sections, budget, word_budget
531
+ )
532
+ # A reference file's name never changes a count (no whitespace in it), so
533
+ # the names are picked once the selection is final.
534
+ assign_names(body, selected, references_dir)
535
+ new_body = rebuild_body(body, selected)
362
536
 
363
537
  tier1_after = present_tier1(new_body)
364
538
  tier1_missing = [
@@ -366,28 +540,32 @@ def _cmd_shard(args: argparse.Namespace) -> int:
366
540
  ]
367
541
  tier1_preserved = not tier1_missing
368
542
  under_budget = body_lines_after <= budget
369
-
370
- references_dir = skill_md.parent / "references"
543
+ under_word_budget = word_budget <= 0 or body_words_after <= word_budget
371
544
 
372
545
  # Compute the plan fully before touching disk. Only write when we have a
373
546
  # real extraction AND Tier-1 survived — never leave a half-mutated tree.
374
547
  should_write = (not dry_run) and bool(selected) and tier1_preserved
375
548
  if should_write:
549
+ written: list[str] = []
376
550
  try:
377
551
  for sec in selected:
378
552
  _atomic_write(
379
- references_dir / f"{sec.kebab}.md", _section_content(body, sec)
553
+ references_dir / f"{sec.name}.md", _section_content(body, sec)
380
554
  )
555
+ written.append(sec.ref_rel)
381
556
  new_text = "\n".join(frontmatter + new_body) + "\n"
382
557
  _atomic_write(skill_md, new_text)
383
558
  except OSError as exc:
384
- print(f"error: {exc}", file=sys.stderr)
559
+ print(
560
+ f"error: {exc}; already written: {', '.join(written) or 'none'}",
561
+ file=sys.stderr,
562
+ )
385
563
  return 2
386
564
 
387
565
  blockquotes_ok = all(sec.blockquote in new_body for sec in selected)
388
566
  if should_write:
389
567
  xref_ok = blockquotes_ok and all(
390
- (references_dir / f"{sec.kebab}.md").is_file() for sec in selected
568
+ (references_dir / f"{sec.name}.md").is_file() for sec in selected
391
569
  )
392
570
  else:
393
571
  # dry-run, nothing-to-extract, or aborted tier1 violation
@@ -398,6 +576,8 @@ def _cmd_shard(args: argparse.Namespace) -> int:
398
576
  "action": "shard",
399
577
  "body_lines_before": body_lines_before,
400
578
  "body_lines_after": body_lines_after,
579
+ "body_words_before": body_words_before,
580
+ "body_words_after": body_words_after,
401
581
  "sections_extracted": [
402
582
  {"heading": sec.heading, "file": sec.ref_rel, "lines": sec.lines}
403
583
  for sec in selected
@@ -406,8 +586,10 @@ def _cmd_shard(args: argparse.Namespace) -> int:
406
586
  "tier1_missing": tier1_missing,
407
587
  "xref_ok": xref_ok,
408
588
  "under_budget": under_budget,
589
+ "under_word_budget": under_word_budget,
409
590
  "dry_run": dry_run,
410
591
  "budget": budget,
592
+ "word_budget": word_budget,
411
593
  },
412
594
  args.output,
413
595
  )
@@ -420,9 +602,10 @@ def _build_parser() -> argparse.ArgumentParser:
420
602
  description=(
421
603
  "Deterministically shard an oversized SKILL.md body: extract the "
422
604
  "largest `## Full …` Tier-2 sections to references/ until the body "
423
- "fits under --budget, replacing each with a cross-reference "
424
- "blockquote, and emit a JSON report the auto-shard / validate "
425
- "steps consume instead of counting and splitting by hand."
605
+ "fits under --budget (and --word-budget, when set), replacing each "
606
+ "with a cross-reference blockquote, and emit a JSON report the "
607
+ "auto-shard / validate / update write steps consume instead of "
608
+ "counting and splitting by hand."
426
609
  ),
427
610
  )
428
611
  parser.add_argument("skill_md", help="path to the staging SKILL.md file")
@@ -432,6 +615,21 @@ def _build_parser() -> argparse.ArgumentParser:
432
615
  default=400,
433
616
  help="body line budget below which no shard occurs (default 400)",
434
617
  )
618
+ parser.add_argument(
619
+ "--word-budget",
620
+ type=int,
621
+ default=0,
622
+ help=(
623
+ "body word budget (words split as JavaScript's /\\s+/ splits them, "
624
+ "as skill-check's body.max_tokens estimate does); 0 turns it off "
625
+ "(default 0)"
626
+ ),
627
+ )
628
+ parser.add_argument(
629
+ "--skip-manual",
630
+ action="store_true",
631
+ help="never move a `## Full` section that intersects a [MANUAL] block",
632
+ )
435
633
  parser.add_argument(
436
634
  "--dry-run",
437
635
  action="store_true",
@@ -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