bmad-module-skill-forge 3.0.0 → 4.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 (93) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/docs/_data/pinned.yaml +1 -1
  3. package/docs/examples.md +1 -1
  4. package/docs/troubleshooting.md +8 -0
  5. package/docs/verifying-a-skill.md +7 -3
  6. package/docs/workflows.md +3 -3
  7. package/package.json +1 -1
  8. package/src/knowledge/qmd-registry.md +1 -1
  9. package/src/knowledge/version-paths.md +1 -1
  10. package/src/shared/health-check.md +2 -50
  11. package/src/shared/scripts/schemas/skf-audit-result-envelope.v1.json +2 -2
  12. package/src/shared/scripts/schemas/skf-update-result-envelope.v1.json +2 -2
  13. package/src/shared/scripts/skf-atomic-write.py +212 -59
  14. package/src/shared/scripts/skf-build-change-manifest.py +593 -47
  15. package/src/shared/scripts/skf-classify-changed-files.py +78 -0
  16. package/src/shared/scripts/skf-compare-file-hashes.py +266 -104
  17. package/src/shared/scripts/skf-derive-assembly-shape.py +18 -1
  18. package/src/shared/scripts/skf-detect-scripts-assets.py +196 -54
  19. package/src/shared/scripts/skf-extract-public-api.py +1309 -157
  20. package/src/shared/scripts/skf-extraction-inventory.py +580 -46
  21. package/src/shared/scripts/skf-extraction-snapshot.py +158 -33
  22. package/src/shared/scripts/skf-forge-tier-rw.py +26 -3
  23. package/src/shared/scripts/skf-github-probe.py +47 -8
  24. package/src/shared/scripts/skf-hash-content.py +14 -8
  25. package/src/shared/scripts/skf-load-provenance.py +3 -1
  26. package/src/shared/scripts/skf-merge-doc-urls.py +142 -19
  27. package/src/shared/scripts/skf-preflight.py +15 -1
  28. package/src/shared/scripts/skf-provenance-gap-dispatch.py +25 -1
  29. package/src/shared/scripts/skf-resolve-authoritative-files.py +29 -3
  30. package/src/shared/scripts/skf-structural-diff.py +133 -18
  31. package/src/shared/scripts/skf-update-active-symlink.py +115 -62
  32. package/src/shared/scripts/skf-validate-brief-schema.py +19 -1
  33. package/src/shared/scripts/skf-verify-provenance-completeness.py +977 -49
  34. package/src/shared/scripts/skf-write-skill-brief.py +41 -8
  35. package/src/skf-analyze-source/references/map-unit-exports.md +1 -1
  36. package/src/skf-audit-skill/assets/drift-report-template.md +1 -0
  37. package/src/skf-audit-skill/references/headless-contract.md +1 -1
  38. package/src/skf-audit-skill/references/health-check.md +2 -0
  39. package/src/skf-audit-skill/references/init.md +2 -1
  40. package/src/skf-audit-skill/references/re-index.md +14 -9
  41. package/src/skf-audit-skill/references/report.md +2 -2
  42. package/src/skf-audit-skill/references/semantic-diff.md +15 -15
  43. package/src/skf-audit-skill/references/structural-diff.md +10 -5
  44. package/src/skf-audit-skill/scripts/render-drift-tables.py +29 -2
  45. package/src/skf-brief-skill/SKILL.md +1 -1
  46. package/src/skf-brief-skill/assets/scope-templates.md +1 -1
  47. package/src/skf-brief-skill/assets/skill-brief-schema.md +2 -9
  48. package/src/skf-brief-skill/references/analyze-target.md +6 -6
  49. package/src/skf-brief-skill/references/confirm-brief.md +1 -1
  50. package/src/skf-brief-skill/references/scope-definition.md +4 -4
  51. package/src/skf-brief-skill/references/step-auto-brief.md +11 -29
  52. package/src/skf-brief-skill/references/version-resolution.md +5 -9
  53. package/src/skf-create-skill/assets/compile-assembly-rules.md +5 -3
  54. package/src/skf-create-skill/assets/skill-sections.md +6 -6
  55. package/src/skf-create-skill/references/authoritative-files-protocol.md +2 -23
  56. package/src/skf-create-skill/references/compile.md +31 -60
  57. package/src/skf-create-skill/references/component-extraction.md +3 -3
  58. package/src/skf-create-skill/references/enrich.md +2 -2
  59. package/src/skf-create-skill/references/entry-points-by-hand.md +24 -7
  60. package/src/skf-create-skill/references/extract.md +22 -19
  61. package/src/skf-create-skill/references/extraction-patterns-tracing.md +9 -9
  62. package/src/skf-create-skill/references/extraction-patterns.md +8 -9
  63. package/src/skf-create-skill/references/generate-artifacts.md +1 -1
  64. package/src/skf-create-skill/references/report.md +16 -5
  65. package/src/skf-create-skill/references/source-resolution-protocols.md +12 -17
  66. package/src/skf-create-skill/references/sub/fetch-docs.md +1 -1
  67. package/src/skf-drop-skill/references/execute.md +5 -1
  68. package/src/skf-drop-skill/references/health-check.md +2 -0
  69. package/src/skf-drop-skill/references/invocation-contract.md +1 -1
  70. package/src/skf-drop-skill/references/report.md +2 -2
  71. package/src/skf-drop-skill/references/select.md +7 -3
  72. package/src/skf-test-skill/references/coherence-check.md +1 -1
  73. package/src/skf-test-skill/references/coverage-check-tiers.md +5 -5
  74. package/src/skf-test-skill/references/coverage-check.md +39 -57
  75. package/src/skf-test-skill/references/init.md +3 -5
  76. package/src/skf-test-skill/references/report.md +10 -8
  77. package/src/skf-test-skill/references/scoring-rules.md +6 -5
  78. package/src/skf-test-skill/references/source-access-protocol.md +14 -17
  79. package/src/skf-test-skill/scripts/build-result-context.py +114 -21
  80. package/src/skf-test-skill/scripts/gap-ledger.py +315 -30
  81. package/src/skf-test-skill/scripts/load-coverage-inputs.py +534 -96
  82. package/src/skf-test-skill/scripts/reconcile-coverage.py +276 -2
  83. package/src/skf-test-skill/scripts/score-signatures.py +36 -16
  84. package/src/skf-test-skill/scripts/verify-declared-numerator.py +7 -5
  85. package/src/skf-update-skill/references/detect-changes.md +32 -13
  86. package/src/skf-update-skill/references/gap-driven.md +3 -3
  87. package/src/skf-update-skill/references/init.md +6 -2
  88. package/src/skf-update-skill/references/invocation-contract.md +1 -1
  89. package/src/skf-update-skill/references/merge.md +2 -2
  90. package/src/skf-update-skill/references/re-extract.md +2 -2
  91. package/src/skf-update-skill/references/report.md +3 -1
  92. package/src/skf-update-skill/references/write.md +43 -23
  93. package/src/skf-update-skill/scripts/skf-new-file-diff.py +157 -42
@@ -19,7 +19,7 @@ It covers the four branches §2c enumerates:
19
19
  Missing = barrel_set − documented_set, Stale = documented_set − barrel_set,
20
20
  Export Coverage = |Documented| / |barrel_set| * 100.
21
21
 
22
- * "scalar" (§2b priority-1 effective_denominator, no enumerated name set):
22
+ * "scalar" (§2b's scalar branch, stats.effective_denominator, no enumerated name set):
23
23
  look each documented name up in SKILL.md ∪ references/*.md;
24
24
  Documented = count present, denominator = effective_denominator,
25
25
  Missing = max(0, denominator − Documented), Stale not enumerable (empty),
@@ -98,6 +98,7 @@ Output (stdout, one object):
98
98
  "denominatorSource": "barrel" | "scalar" | "stack" | "docsOnly",
99
99
  "denominator": <int>,
100
100
  "documented": <int>,
101
+ "documentedNames": [names], # enumerated only: the documented names the denominator counts
101
102
  "missing": [names], # enumerated: source names not documented; others: []
102
103
  "missingCount": <int>, # always present; never negative
103
104
  "stale": [names], # enumerated: documented names not in source; others: []
@@ -115,6 +116,43 @@ Exit codes:
115
116
  0 reconciliation emitted successfully
116
117
  1 no input, input that does not parse as JSON, or an input file that cannot be read
117
118
  2 input parsed but schema/semantics invalid (error object emitted as JSON)
119
+
120
+ Subcommand render (coverage-check.md §5 writes its output unchanged):
121
+ uv run reconcile-coverage.py render --coverage <coverage.json>
122
+ [--surface <surface.json>] [--signatures <signatures.json>]
123
+
124
+ prints the Markdown of the Coverage Analysis section that follows its
125
+ header lines, from the run files:
126
+ **Files Analyzed:** the distinct files the --surface export records name
127
+ (N/A without --surface: a docs-only run has none)
128
+ ### Export Coverage on the enumerated branch, one row per name the
129
+ denominator counts, per stale name and per name whose
130
+ signature mismatches, by name:
131
+ Type the first kind the surface records for the name, else -
132
+ Documented no for a missing name, else yes
133
+ Signature no for a mismatch, yes for a compared name that
134
+ matches, unverified for a documented name when no
135
+ --signatures was given, - otherwise
136
+ Source Location the file:line the surface records for the name, a
137
+ record with a line preferred (gap-ledger.py's rule
138
+ for a missing export's Source), else -
139
+ Status FAIL for a signature mismatch, WARN for a missing or
140
+ stale name, PASS otherwise
141
+ The scalar and stack branches count without naming
142
+ the exports, and a docs-only run has no source
143
+ surface: one line says so instead (NO_TABLE).
144
+ ### Coverage Summary the --coverage counts, the --signatures mismatch count
145
+ and warnings, the numerator surplus when the ratio
146
+ was capped, and the names the --surface leaves out
147
+ or puts back: `excluded.outsideScope` (scoped out
148
+ by the brief), `excluded.nestedEntries` (a nested
149
+ Python entry point's names) and
150
+ `guards.staleScope.restored`, each line only when
151
+ it names one
152
+ ### Category Scores Export Coverage, and Signature Accuracy and Type
153
+ Coverage from --signatures (N/A without it)
154
+ Exit 0 when it printed; 1 when an input cannot be read or is not the
155
+ result of the script that writes it (one line on stderr); 2 a usage error.
118
156
  """
119
157
 
120
158
  from __future__ import annotations
@@ -286,6 +324,7 @@ def reconcile(inp, doc_text=None):
286
324
  "denominatorSource": source,
287
325
  "denominator": denominator,
288
326
  "documented": documented,
327
+ "documentedNames": sorted(documented_names),
289
328
  "missing": missing,
290
329
  "missingCount": len(missing),
291
330
  "stale": stale,
@@ -350,6 +389,235 @@ def reconcile(inp, doc_text=None):
350
389
  return result
351
390
 
352
391
 
392
+ # --- The Coverage Analysis section (render) -----------------------------------
393
+
394
+ BRANCHES = ("enumerated", "scalar", "stack", "docsOnly")
395
+ # Why a branch names no export: the line it writes in place of the table, and its stale count.
396
+ NO_NAMES = {
397
+ "scalar": "the scalar denominator (`stats.effective_denominator`) counts the exports without naming them",
398
+ "stack": "the stack denominator counts the composition surface without naming each export",
399
+ "docsOnly": "a docs-only run has no source surface",
400
+ }
401
+ NO_TABLE = {
402
+ "scalar": f"No per-export table: {NO_NAMES['scalar']}. The summary below gives the counts.",
403
+ "stack": f"No per-export table: {NO_NAMES['stack']}. The summary below gives the counts.",
404
+ "docsOnly": f"No per-export table: {NO_NAMES['docsOnly']} to compare against. The summary below gives its "
405
+ "documentation completeness.",
406
+ }
407
+
408
+ _GAP_LEDGER_MODULE = None
409
+
410
+
411
+ class RenderError(Exception):
412
+ """A render input that is not the result of the script that writes it."""
413
+
414
+
415
+ def _gap_ledger():
416
+ """gap-ledger.py beside this script: its rule for a missing export's Source gives the Source Location."""
417
+ global _GAP_LEDGER_MODULE
418
+ if _GAP_LEDGER_MODULE is None:
419
+ sibling = Path(__file__).resolve().parent / "gap-ledger.py"
420
+ spec = importlib.util.spec_from_file_location("skf_gap_ledger_render", sibling)
421
+ if spec is None or spec.loader is None or not sibling.is_file():
422
+ raise RenderError(f"gap-ledger.py not found beside {Path(__file__).name}")
423
+ module = importlib.util.module_from_spec(spec)
424
+ spec.loader.exec_module(module)
425
+ _GAP_LEDGER_MODULE = module
426
+ return _GAP_LEDGER_MODULE
427
+
428
+
429
+ def _pct(value):
430
+ """A score as the report writes it.
431
+
432
+ >>> _pct(60.0), _pct(66.67), _pct(None)
433
+ ('60%', '66.67%', 'N/A')
434
+ """
435
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
436
+ return "N/A"
437
+ return f"{int(value)}%" if float(value).is_integer() else f"{value}%"
438
+
439
+
440
+ def _cell(text):
441
+ """A table cell: a `|` would end it."""
442
+ return str(text).replace("|", "\\|")
443
+
444
+
445
+ def _string_list(data, key, label):
446
+ value = data.get(key)
447
+ if value is None:
448
+ return []
449
+ if not isinstance(value, list):
450
+ raise RenderError(f"{label}: `{key}` is not a list")
451
+ return [v for v in value if isinstance(v, str) and v]
452
+
453
+
454
+ def _incomplete(item):
455
+ """A docs-only item and the fields it lacks.
456
+
457
+ >>> _incomplete({"name": "load", "kind": "function", "missing": ["params", "return_type"]})
458
+ '`load` (function) lacks `params`, `return_type`'
459
+ """
460
+ fields = ", ".join(f"`{field}`" for field in item.get("missing") or [])
461
+ return f"`{item.get('name')}` ({item.get('kind')}) lacks {fields}"
462
+
463
+
464
+ def _left_out(surface):
465
+ """The Coverage Summary lines for the names the surface leaves out or puts back, each only when it names one.
466
+
467
+ >>> for line in _left_out({
468
+ ... "excluded": {"outsideScope": [{"name": "gen", "file": "src/gen.ts"}],
469
+ ... "nestedEntries": [{"entry": "pkg/api/__init__.py", "within": "pkg/__init__.py",
470
+ ... "names": ["router"]}]},
471
+ ... "guards": {"staleScope": {"restored": [{"name": "old", "file": "src/old.ts", "line": 4}]}}}):
472
+ ... print(line)
473
+ - **Scoped Out by the Brief:** `gen` (src/gen.ts)
474
+ - **Nested Entry Points:** `pkg/api/__init__.py` inside `pkg/__init__.py`: `router`
475
+ - **Restored by the Stale-Scope Guard:** `old` (src/old.ts:4)
476
+ >>> _left_out({"excluded": {"outsideScope": [], "nestedEntries": []}, "guards": None})
477
+ []
478
+ """
479
+ def items(value):
480
+ return [i for i in value if isinstance(i, dict)] if isinstance(value, list) else []
481
+
482
+ def where(item):
483
+ file, line = item.get("file"), item.get("line")
484
+ if not isinstance(file, str) or not file:
485
+ return ""
486
+ return f" ({file}:{line})" if isinstance(line, int) and not isinstance(line, bool) else f" ({file})"
487
+
488
+ excluded = surface.get("excluded") if isinstance(surface.get("excluded"), dict) else {}
489
+ guards = surface.get("guards") if isinstance(surface.get("guards"), dict) else {}
490
+ stale_scope = guards.get("staleScope") if isinstance(guards.get("staleScope"), dict) else {}
491
+ lines = []
492
+ scoped = [f"`{i['name']}`{where(i)}" for i in items(excluded.get("outsideScope"))
493
+ if isinstance(i.get("name"), str)]
494
+ if scoped:
495
+ lines.append(f"- **Scoped Out by the Brief:** {', '.join(scoped)}")
496
+ nested = [f"`{i['entry']}` inside `{i.get('within')}`: "
497
+ + ", ".join(f"`{n}`" for n in i.get("names") or [] if isinstance(n, str))
498
+ for i in items(excluded.get("nestedEntries")) if isinstance(i.get("entry"), str)]
499
+ if nested:
500
+ lines.append(f"- **Nested Entry Points:** {'; '.join(nested)}")
501
+ restored = [f"`{i['name']}`{where(i)}" for i in items(stale_scope.get("restored"))
502
+ if isinstance(i.get("name"), str)]
503
+ if restored:
504
+ lines.append(f"- **Restored by the Stale-Scope Guard:** {', '.join(restored)}")
505
+ return lines
506
+
507
+
508
+ def _rows(coverage, surface, signatures):
509
+ """The Export Coverage table of the enumerated branch."""
510
+ if "documentedNames" not in coverage:
511
+ raise RenderError("--coverage has no `documentedNames`: write it again with this reconcile-coverage.py")
512
+ documented = set(_string_list(coverage, "documentedNames", "--coverage"))
513
+ missing = set(_string_list(coverage, "missing", "--coverage"))
514
+ stale = set(_string_list(coverage, "stale", "--coverage"))
515
+ mismatched, compared = set(), set()
516
+ if signatures is not None:
517
+ mismatched = {m["name"] for m in signatures.get("mismatches") or []
518
+ if isinstance(m, dict) and isinstance(m.get("name"), str)}
519
+ compared = set(_string_list(signatures, "comparedNames", "--signatures"))
520
+ kinds, sources = {}, {}
521
+ if surface is not None:
522
+ for record in surface["exports"]:
523
+ if isinstance(record, dict) and isinstance(record.get("name"), str) and record.get("kind"):
524
+ kinds.setdefault(record["name"], str(record["kind"]))
525
+ sources = _gap_ledger()._surface_sources(surface)
526
+ rows = ["| Export | Type | Documented | Signature | Source Location | Status |",
527
+ "|--------|------|------------|-----------|-----------------|--------|"]
528
+ for name in sorted(documented | missing | stale | mismatched):
529
+ if name in mismatched:
530
+ signature = "no"
531
+ elif name in compared:
532
+ signature = "yes"
533
+ elif signatures is None and name not in missing:
534
+ signature = "unverified"
535
+ else:
536
+ signature = "-"
537
+ status = "FAIL" if name in mismatched else "WARN" if name in missing or name in stale else "PASS"
538
+ rows.append(f"| `{_cell(name)}` | {_cell(kinds.get(name, '-'))} | {'no' if name in missing else 'yes'} "
539
+ f"| {signature} | {_cell(sources.get(name, '-'))} | {status} |")
540
+ return rows
541
+
542
+
543
+ def render(coverage, surface=None, signatures=None):
544
+ """The Coverage Analysis section after its header lines, as Markdown (see the module docstring)."""
545
+ if not isinstance(coverage, dict) or coverage.get("branch") not in BRANCHES:
546
+ raise RenderError("--coverage is not reconcile-coverage.py's result (no known `branch`)")
547
+ if surface is not None and not (isinstance(surface, dict) and isinstance(surface.get("exports"), list)):
548
+ raise RenderError("--surface is not load-coverage-inputs.py surface's result (no `exports` list)")
549
+ if signatures is not None and not (isinstance(signatures, dict) and signatures.get("valid") is True):
550
+ raise RenderError("--signatures is not score-signatures.py score's result (no `valid: true`)")
551
+ branch = coverage["branch"]
552
+ docs_only = branch == "docsOnly"
553
+
554
+ if surface is None:
555
+ files = "N/A: no source surface"
556
+ else:
557
+ files = len({r["file"] for r in surface["exports"]
558
+ if isinstance(r, dict) and isinstance(r.get("file"), str) and r["file"]})
559
+ lines = [f"**Files Analyzed:** {files}", "", "### Export Coverage", ""]
560
+ lines += _rows(coverage, surface, signatures) if branch == "enumerated" else [NO_TABLE[branch]]
561
+
562
+ denominator, documented = coverage.get("denominator"), coverage.get("documented")
563
+ missing_count, coverage_pct = coverage.get("missingCount"), _pct(coverage.get("exportCoverage"))
564
+ lines += ["", "### Coverage Summary", ""]
565
+ if docs_only:
566
+ incomplete = [_incomplete(item) for item in coverage.get("incomplete") or [] if isinstance(item, dict)]
567
+ lines += [f"- **Exports Found:** {denominator} documented items",
568
+ f"- **Documented:** {documented} complete ({coverage_pct})",
569
+ f"- **Missing Documentation:** {missing_count} incomplete"
570
+ + (f": {'; '.join(incomplete)}" if incomplete else "")]
571
+ else:
572
+ capped = coverage.get("coverageCapped")
573
+ floored = " (a floored residual: the numerator exceeds the denominator)" if capped else ""
574
+ lines += [f"- **Exports Found:** {denominator}",
575
+ f"- **Documented:** {documented} ({coverage_pct})",
576
+ f"- **Missing Documentation:** {missing_count}{floored}"]
577
+ mismatches = signatures.get("mismatches") if signatures is not None else None
578
+ lines.append(f"- **Signature Mismatches:** {len(mismatches) if isinstance(mismatches, list) else 'N/A'}")
579
+ if coverage.get("staleApplicable"):
580
+ lines.append(f"- **Stale Documentation:** {coverage.get('staleCount')}, documented extras included")
581
+ else:
582
+ lines.append(f"- **Stale Documentation:** N/A: {NO_NAMES.get(branch, 'the denominator names no stale export')}")
583
+ warnings = _string_list(signatures, "warnings", "--signatures") if signatures is not None else []
584
+ if warnings:
585
+ lines.append("- **Scoring Warnings:**")
586
+ lines += [f" - {w}" for w in warnings]
587
+ if coverage.get("coverageCapped"):
588
+ lines.append(f"- **Numerator Surplus:** {coverage.get('numeratorSurplus')}, uncapped coverage "
589
+ f"{_pct(coverage.get('coverageUncapped'))}")
590
+ if surface is not None:
591
+ lines += _left_out(surface)
592
+
593
+ scores = signatures or {}
594
+ lines += ["", "### Category Scores", "", "| Category | Score |", "|----------|-------|",
595
+ f"| Export Coverage | {coverage_pct} |",
596
+ f"| Signature Accuracy | {_pct(scores.get('signatureAccuracy'))} |",
597
+ f"| Type Coverage | {_pct(scores.get('typeCoverage'))} |"]
598
+ return "\n".join(lines) + "\n"
599
+
600
+
601
+ def _render_main(argv):
602
+ parser = argparse.ArgumentParser(
603
+ prog="reconcile-coverage render",
604
+ description="Print the Coverage Analysis section's Markdown from the run files (coverage-check.md §5).",
605
+ )
606
+ parser.add_argument("--coverage", required=True, metavar="PATH", help="this script's result (coverage.json)")
607
+ parser.add_argument("--surface", metavar="PATH", help="load-coverage-inputs.py surface output (surface.json)")
608
+ parser.add_argument("--signatures", metavar="PATH", help="score-signatures.py score output (signatures.json)")
609
+ args = parser.parse_args(argv)
610
+ try:
611
+ text = render(_read_json_file(args.coverage, "--coverage"),
612
+ _read_json_file(args.surface, "--surface") if args.surface else None,
613
+ _read_json_file(args.signatures, "--signatures") if args.signatures else None)
614
+ except (InputFileError, RenderError) as exc:
615
+ print(f"error: {exc}", file=sys.stderr)
616
+ return 1
617
+ sys.stdout.write(text)
618
+ return 0
619
+
620
+
353
621
  # --- Run-folder inputs --------------------------------------------------------
354
622
 
355
623
 
@@ -432,7 +700,10 @@ def _build_parser():
432
700
  epilog=(
433
701
  "Example (barrel branch):\n"
434
702
  " uv run reconcile-coverage.py --denominator-source barrel "
435
- "--inventory run/inventory.json --surface run/surface.json --output run/coverage.json"
703
+ "--inventory run/inventory.json --surface run/surface.json --output run/coverage.json\n"
704
+ "Render the Coverage Analysis section (see `render --help`):\n"
705
+ " uv run reconcile-coverage.py render --coverage run/coverage.json "
706
+ "[--surface run/surface.json] [--signatures run/signatures.json]"
436
707
  ),
437
708
  )
438
709
  src = parser.add_mutually_exclusive_group()
@@ -492,6 +763,9 @@ def _write_output(path, result):
492
763
 
493
764
 
494
765
  def main(argv=None):
766
+ argv = sys.argv[1:] if argv is None else list(argv)
767
+ if argv[:1] == ["render"]:
768
+ return _render_main(argv[1:])
495
769
  parser = _build_parser()
496
770
  args = parser.parse_args(argv)
497
771
  raw = _resolve_input(args)
@@ -15,18 +15,21 @@ ratios and the gap records of the wrong signatures.
15
15
 
16
16
  plan the comparisons to make. For each documented export with a
17
17
  signature (an inventory entry of any kind but `method` that carries
18
- `params`, `return_type` or `usage_signature`) that the source
19
- surface lists, grouped by source file, with the line that defines
20
- it in the source and the documented signature. Also the whole
18
+ `params`, `return_type` or `usage_signature`) in the signature
19
+ universe, grouped by source file, with the line that defines it in
20
+ the source and the documented signature. The signature universe is
21
+ the surface's `all` set plus every name on its export records, so
22
+ the names of a Python sub-package load-coverage-inputs.py keeps out
23
+ of `all` (excluded.nestedEntries) keep their checks. Also the whole
21
24
  documented-signature map, for the fallback scan that has no surface
22
25
  lines yet.
23
26
  score the categories. Reads every subagent result, checks its schema,
24
27
  and computes:
25
28
  Signature Accuracy = matching / compared * 100, where compared is
26
- the documented signatures the whole surface lists (the `all`
27
- set, with a fallback scan's exports_found) and matching those
28
- no result names in signature_mismatches[]: a wrong signature is
29
- wrong whichever set the denominator counts
29
+ the documented signatures of the signature universe (with a
30
+ fallback scan's exports_found) and matching those no result
31
+ names in signature_mismatches[]: a wrong signature is wrong
32
+ whichever set the denominator counts
30
33
  Type Coverage = documented types / total types * 100, where
31
34
  total types counts the names of the --surface-set set (the
32
35
  denominator set coverage-check section 2b picked) whose kind is
@@ -45,8 +48,9 @@ Inputs, all files in the run folder:
45
48
  --inventory the validated inventory validate-inventory.py wrote (--output)
46
49
  --surface load-coverage-inputs.py surface output: `exports[]` (name,
47
50
  kind, file, line, signatureLine) and the name `sets`;
48
- --surface-set picks the set (default "all"): plan's
49
- comparisons and score's Type Coverage
51
+ --surface-set picks the set (default "all") score's Type
52
+ Coverage counts, while plan and Signature Accuracy compare
53
+ the signature universe whichever set it picks
50
54
  --results a subagent result, as the subagent returned it (a wrapping
51
55
  markdown fence is stripped), repeatable. One object, or a
52
56
  list of them:
@@ -67,7 +71,8 @@ Output (stdout; --output also writes it, --gaps-output writes the records):
67
71
  plan: {"compared": N, "files": [{"file", "checks": [{"name", "line",
68
72
  "signatureLine", "documented": {...}}]}], "documentedSignatures": {...}}
69
73
  score: {"valid": true, "signatureAccuracy", "matchingSignatures",
70
- "totalDocumented", "typeCoverage", "documentedTypes", "totalTypes",
74
+ "totalDocumented", "comparedNames": [the names compared],
75
+ "typeCoverage", "documentedTypes", "totalTypes",
71
76
  "missingTypes": [...], "mismatches": [...], "gapRecords": [...],
72
77
  "warnings": [...]}
73
78
  or {"valid": false, "violations": [...]} when a result breaks the schema
@@ -259,13 +264,23 @@ def _records_by_name(exports: list[dict]) -> dict[str, list[dict]]:
259
264
  return by_name
260
265
 
261
266
 
267
+ def signature_universe(names: list[str], exports: list[dict]) -> set[str]:
268
+ """The names whose documented signature is compared: the `all` set
269
+ (`names`) plus every name on the surface's export records. A nested
270
+ Python top's names leave `all` but keep their records, so a documented
271
+ one still has its signature checked."""
272
+ return set(names) | set(_records_by_name(exports))
273
+
274
+
262
275
  def plan(inventory: list[dict], names: list[str] | None, exports: list[dict]) -> dict:
276
+ """The comparisons over the signature universe of `names` (the `all`
277
+ set) and `exports`; none without a surface (`names` None)."""
263
278
  signatures = documented_signatures(inventory)
264
279
  files: dict[str, list[dict]] = {}
265
280
  compared = 0
266
281
  if names is not None:
267
282
  by_name = _records_by_name(exports)
268
- for name in sorted(set(names) & set(signatures)):
283
+ for name in sorted(signature_universe(names, exports) & set(signatures)):
269
284
  record = (by_name.get(name) or [{}])[0]
270
285
  compared += 1
271
286
  files.setdefault(str(record.get("file") or ""), []).append({
@@ -297,15 +312,17 @@ def _gap_record(m: dict) -> dict:
297
312
 
298
313
  def score(inventory: list[dict], names: list[str], exports: list[dict], results: list[dict],
299
314
  type_names: list[str] | None = None) -> dict:
300
- """The two categories: Signature Accuracy over `names` (the whole surface),
301
- Type Coverage over `type_names` when given, else over `names` too."""
315
+ """The two categories: Signature Accuracy over the signature universe of
316
+ `names` (the `all` set) and `exports`, Type Coverage over `type_names`
317
+ when given, else over `names`. A fallback scan's exports_found join the
318
+ universe, and `names` when no `type_names` is given."""
302
319
  by_name = _records_by_name(exports)
303
320
  # A fallback scan reports its own names and types.
304
321
  fallback_types = {n for r in results for n in r.get("types_found") or []}
305
322
  surface = set(names) | {n for r in results for n in r.get("exports_found") or []}
306
323
  documented = documented_names(inventory)
307
324
  signatures = documented_signatures(inventory)
308
- compared = surface & set(signatures)
325
+ compared = (surface | signature_universe(names, exports)) & set(signatures)
309
326
  warnings = []
310
327
 
311
328
  mismatches = []
@@ -353,6 +370,7 @@ def score(inventory: list[dict], names: list[str], exports: list[dict], results:
353
370
  "signatureAccuracy": signature_accuracy,
354
371
  "matchingSignatures": len(compared) - len(wrong),
355
372
  "totalDocumented": len(compared),
373
+ "comparedNames": sorted(compared),
356
374
  "typeCoverage": type_coverage,
357
375
  "documentedTypes": len(documented_types),
358
376
  "totalTypes": len(types),
@@ -378,7 +396,7 @@ def _cmd_plan(args: argparse.Namespace) -> tuple[dict, int]:
378
396
  inventory = load_inventory(args.inventory)
379
397
  names, exports = (None, [])
380
398
  if args.surface is not None:
381
- names, exports, _ = load_surface(args.surface, args.surface_set)
399
+ _, exports, names = load_surface(args.surface, args.surface_set)
382
400
  out = plan(inventory, names, exports)
383
401
  if args.output:
384
402
  _write_json(args.output, out)
@@ -412,7 +430,9 @@ def _build_parser() -> argparse.ArgumentParser:
412
430
  p = sub.add_parser("plan", help="the signature comparisons for the subagents to make")
413
431
  p.add_argument("--inventory", required=True, help="the validated inventory file")
414
432
  p.add_argument("--surface", help="load-coverage-inputs.py surface output")
415
- p.add_argument("--surface-set", default="all", help="the surface name set (default: all)")
433
+ p.add_argument("--surface-set", default="all",
434
+ help="a surface name set, which must exist (default: all); the plan compares the signature "
435
+ "universe whichever set it names")
416
436
  p.add_argument("--output", help="also write the plan to this file")
417
437
  p.set_defaults(func=_cmd_plan)
418
438
 
@@ -8,11 +8,13 @@
8
8
  Lookup-and-count helper for the SKF test-skill workflow (coverage-check.md
9
9
  §2b, its "Numerator ground truth" paragraph; §4b records the gap). It fires
10
10
  only on the inflation signature
11
- (`stats.exports_documented == effective_denominator` exactly): the numerator
12
- equals the denominator, the tell of a documented count padded to force 100%
13
- coverage. On that signature the declared count is not trusted: every declared
14
- export name is looked up in the skill's documentation surface and only the
15
- names that actually appear count toward coverage.
11
+ (`stats.exports_documented == effective_denominator` exactly, or, for a
12
+ provenance map with no `::` entry, its distinct entry names number
13
+ `effective_denominator`): the numerator equals the denominator, the tell of a
14
+ documented count padded to force 100% coverage. On that signature the declared
15
+ count is not trusted: every declared export name is looked up in the skill's
16
+ documentation surface and only the names that actually appear count toward
17
+ coverage.
16
18
 
17
19
  Distinct from reconcile-coverage.py (whose scalar/stack branches look up the §1
18
20
  inventory names and return only a residual missing *count*): this script looks
@@ -7,8 +7,8 @@ hashContentProbeOrder:
7
7
  - '{project-root}/_bmad/skf/shared/scripts/skf-hash-content.py'
8
8
  - '{project-root}/src/shared/scripts/skf-hash-content.py'
9
9
  # `{buildChangeManifestHelper}`: §3's `build` (and §1's for a docs-only
10
- # skill), §2.2's `deletion-ratio` and Category C's `rename-candidates`. HALT
11
- # if neither exists.
10
+ # skill), §2.2's `deletion-ratio`, Category B's `baseline-gaps` and Category
11
+ # C's `rename-candidates`. HALT if neither exists.
12
12
  buildChangeManifestProbeOrder:
13
13
  - '{project-root}/_bmad/skf/shared/scripts/skf-build-change-manifest.py'
14
14
  - '{project-root}/src/shared/scripts/skf-build-change-manifest.py'
@@ -17,13 +17,13 @@ buildChangeManifestProbeOrder:
17
17
  provenanceGapDispatchProbeOrder:
18
18
  - '{project-root}/_bmad/skf/shared/scripts/skf-provenance-gap-dispatch.py'
19
19
  - '{project-root}/src/shared/scripts/skf-provenance-gap-dispatch.py'
20
- # `{detectScriptsAssetsHelper}`: Category D's script and asset walk, as
21
- # create-skill's. HALT if neither exists.
20
+ # `{detectScriptsAssetsHelper}`: Category D's script and asset walk, the
21
+ # detector create-skill runs, over the whole source. HALT if neither exists.
22
22
  detectScriptsAssetsProbeOrder:
23
23
  - '{project-root}/_bmad/skf/shared/scripts/skf-detect-scripts-assets.py'
24
24
  - '{project-root}/src/shared/scripts/skf-detect-scripts-assets.py'
25
- # `{newFileDiffHelper}`: Category D's NEW_FILE set difference, bundled with
26
- # this skill.
25
+ # `{newFileDiffHelper}`: Category D's NEW_FILE set difference, kept to the
26
+ # skill brief's scope, bundled with this skill.
27
27
  newFileDiffHelper: 'scripts/skf-new-file-diff.py'
28
28
  # `{resolveAuthoritativeFilesHelper}`: §1b's mirror. HALT if neither exists.
29
29
  resolveAuthoritativeFilesProbeOrder:
@@ -103,6 +103,8 @@ It writes `{"mode": "docs-only", "no_changes", "changed_urls", "fetch_failed", "
103
103
 
104
104
  **The brief this run reads.** Bind `{brief_path}` ← `{forge_data_folder}/{skill_name}/skill-brief.yaml`. When `detect_only_mode` or `dry_run_mode` is true and that file exists, copy it to `{run_dir}/skill-brief.yaml` and bind `{brief_path}` to the copy: §1b and §1c write each decision there, Category A reads the scope from there, and the brief itself stays as it was. Each amendment a read-only run writes to the copy is a proposed one: add `proposed-amendment: {action} {path} ({category}); not written: {--dry-run or --detect-only}` to `warnings[]` as you write it, and step 6 lists them with the change manifest.
105
105
 
106
+ **The scope type this run reads.** Bind `{scope_type}` ← the `scope_type` metadata.json records (init.md §2), else the `scope.type` of `{brief_path}` when that file exists and names one: a skill whose metadata.json records no scope type updates under its brief's. Category B's runner takes it as `--scope-type` and writes it to `{run_dir}/extraction.json` as `scope.type`, where step 3's `records` and step 5's `apply` read it: under `public-api`, a normal-mode update neither documents nor adds to the provenance map a new export off the runner's public surface (re-extract.md §4, write.md §3).
107
+
106
108
  ### 1b. Discovered Authoritative Files Protocol (Mirror)
107
109
 
108
110
  **Purpose:** mirror `skf-create-skill` §2a into update-skill. `skf-create-skill` §2a catches authoritative AI documentation files (`llms.txt`, `AGENTS.md`, `.cursorrules`, etc.) during **creation**, but a project may add these files *after* the skill was created. Without this mirror, update-skill would either miss the new file entirely (if it doesn't match the provenance map's file patterns) or classify it as a generic ADDED file in §2 Category A with no authoritative-file treatment. The mirror surfaces the discovery with the same P/S/U prompt create-skill uses, honoring any prior amendments.
@@ -309,9 +311,22 @@ Pass `--provenance-map` whenever init.md §4 found one; `--brief` (as §1c left
309
311
  -o "{run_dir}/extraction.json"
310
312
  ```
311
313
 
312
- Pass `--language` and `--scope-type` when metadata.json records them. It takes no `--brief`: Category A applied the scope, and a tracked file the brief's scope leaves out (gap-driven.md §1's rule R1 excludes a rescoped export's file) keeps its exports. `--head-cap 0` keeps every match, since a dropped one would read as a deleted export. Step 3 reads this file and never runs the runner again. **Exit 1** (`incomplete`): run it once more; when still incomplete, step 2 below reads by eye each listed file with no export in `exports[]`. **Exit 2 or 3, no JSON, no candidate resolves, or Quick tier:** write `{"exports": []}` to `{run_dir}/extraction.json`; step 2 reads every listed file by eye (at Quick tier, by text pattern). Tell the user which files were read by eye.
314
+ Pass `--language` when metadata.json records it, and `--scope-type` when §1 bound `{scope_type}`. It takes no `--brief`: Category A applied the scope, and a tracked file the brief's scope leaves out (gap-driven.md §1's rule R1 excludes a rescoped export's file) keeps its exports. `--head-cap 0` keeps every match, since a dropped one would read as a deleted export. Step 3 reads this file and never runs the runner again. **Exit 1** (`incomplete`, its JSON written): run it once more; when still incomplete, step 2 below reads by eye each listed file with no export in `exports[]` or that an `errors[]` item lists in `unread`, and tells the user which files were read by eye. **Exit 2** (an input error, such as a file list it cannot read, or a failure it did not foresee, named in one stderr line with no JSON; `uv` failing to start it exits 2 too): HALT with status `blocked` (halt procedure: `phase: "detect-changes:category-b"`, the stderr line that holds `error:` as `reason`). No by-eye read stands in for it: the skill is not written yet, and reading every file by eye would hide the error. **Exit 3 (`no-ast-grep`), no JSON on another exit, no candidate resolves, or Quick tier:** write `{"exports": []}` to `{run_dir}/extraction.json`, with `"scope": {"type": "{scope_type}"}` when §1 bound `{scope_type}`, so step 5's `apply` can warn that a `public-api` surface was not applied; step 2 reads every listed file by eye (at Quick tier, by text pattern). Tell the user which files were read by eye.
315
+
316
+ Then, at every tier (in degraded mode there is no map: skip it), list the map's entries the runner left out that their file still declares: the runner never reports a module, an alias that renames, a dunder such as `__version__` or another underscore name, and step 3's diff would read each as a deleted export. From `{project-root}`, run:
317
+
318
+ ```bash
319
+ uv run {buildChangeManifestHelper} baseline-gaps \
320
+ --provenance-map "{provenance_map_path}" \
321
+ --extraction "{run_dir}/extraction.json" \
322
+ --files "{run_dir}/modified-files.json" \
323
+ --source-root "{source_root}" \
324
+ -o "{run_dir}/baseline-gaps.json"
325
+ ```
326
+
327
+ It writes `{"gaps": [{name, file, line, entry, export_type}], "unchecked": [{name, file, export_type}]}`: in `gaps[]` each entry of a modified file that no export of `{run_dir}/extraction.json` holds and whose file still declares it, at the line that declares it, by the verifier's rule (audit-skill's extraction snapshot applies the same one), and in `unchecked[]` each such entry no rule can check (its file neither Python nor TS/JS, a file the rule will not read, such as a test file or one with a symlink on its path, or a dotted module name), which step 2 reads by eye. Never add a gap or drop one by eye. On exit 1 (an input it cannot read, or a `skf-verify-provenance-completeness.py` beside it that cannot be loaded), no JSON, or no candidate resolves: HALT with status `blocked` (halt procedure: `phase: "detect-changes:category-b"`, its stderr as `reason`): without it, every name the runner leaves out would read as deleted.
313
328
 
314
- 2. **What the recipes do not record.** Workers, in parallel batches (Pattern 4) when the list is long, read the modified and added files and each return ONLY `{"exports": [...]}`, with no prose and no fences. For each function export the runner found whose `params` it left null (it could not read the signature): `export_name` and `source_file` as it wrote them, with `params` (each parameter as the source writes it, `name: type`, the form the provenance map's `params` hold) and `return_type` (null when there is none), read at its `source_line`. For each export it could not find (a name `entry_point_diff.extraction_gaps[]` lists, a form Known Limitation #11 in `{extractionPatternsData}` lists, an export of a file `file_issues[]` names or that was read by eye): the same, plus `export_type`, `source_line`, `confidence: T1-low` and `extraction_method: source-read`. Write the union of their `exports` arrays to `{run_dir}/export-details.json`.
329
+ 2. **What the recipes do not record.** Workers, in parallel batches (Pattern 4) when the list is long, read the modified and added files and each return ONLY `{"exports": [...]}`, with no prose and no fences. For each function export the runner found whose `params` it left null (it could not read the signature): `export_name` and `source_file` as it wrote them, with `params` (each parameter as the source writes it, `name: type`, the form the provenance map's `params` hold) and `return_type` (null when there is none), read at its `source_line`. For each export it could not find (a name `entry_point_diff.extraction_gaps[]` lists, a name `{run_dir}/baseline-gaps.json` lists in `gaps[]`, a form Known Limitation #11 in `{extractionPatternsData}` lists, an export of a file `file_issues[]` names or that was read by eye): the same, plus `export_type`, `source_line`, `confidence: T1-low` and `extraction_method: source-read`. Record a baseline gap at its `file` and `line` (`source_line`), with, as `export_type`, its `export_type` while the declaration there still has that kind, else the kind it has now (a `variable` now declared by a `def` is a `function`, which step 3 reports as a changed kind), and the declaration's kind when the gap gives none; `params` and `return_type` as the declaration writes them, null when it has none (a module, an alias or a variable). For each `unchecked[]` entry, read its file by eye and record it the same way, at the line that declares it, only when the file still declares it. Record each name and file once: when a file read by eye whole (at Quick tier, or when the runner could not run or left the file unread) holds a name a gap or an `unchecked[]` entry lists, that read's record stands and the gap adds none. Write the union of their `exports` arrays to `{run_dir}/export-details.json`.
315
330
 
316
331
  3. **The diff.** Resolve `{structuralDiffHelper}` ← first existing path in `{structuralDiffProbeOrder}` and, from `{project-root}`, run:
317
332
 
@@ -368,15 +383,19 @@ The helper emits:
368
383
  }
369
384
  ```
370
385
 
371
- The compare helper reports only tracked files; NEW_FILE detection (a file present in source but absent from the provenance map) is a set-difference, so it runs through a script rather than the prompt. Pipe the same deterministic detector create-skill step 3 §4c uses (resolved via `detectScriptsAssetsProbeOrder`) into `{newFileDiffHelper}`, which subtracts the provenance map's `file_entries[].source_file` and sets aside user-authored `[MANUAL]` paths:
386
+ A map with no `file_entries` field, or a null one (a skill with no script, asset or promoted document, or a map written before the field existed), tracks no file: the comparison lists no row and the set difference below subtracts nothing.
387
+
388
+ The compare helper reports only tracked files; NEW_FILE detection (a file present in source but absent from the provenance map) is a set-difference, so it runs through a script rather than the prompt. **When `{brief_path}` does not exist** (a skill built without a brief, such as a quick skill), skip it: with no scope to keep to, the walk would read every script and asset of the repository as new. Add `new-files-not-checked: no skill brief, so no new script or asset was looked for` to `warnings[]`, write no `{run_dir}/new-files.json`, and leave `--new-files` out of §3's `build` and step 5's `apply`. Otherwise pipe the detector create-skill step 3 §4c runs (resolved via `detectScriptsAssetsProbeOrder`), which walks the whole source, into `{newFileDiffHelper}`, which keeps what the brief takes and subtracts what the map tracks:
372
389
 
373
390
  ```bash
374
391
  uv run {detectScriptsAssetsHelper} detect "{source_root}" \
375
- | uv run {newFileDiffHelper} "{provenance_map_path}" \
392
+ | uv run {newFileDiffHelper} "{provenance_map_path}" [--brief "{brief_path}"] \
376
393
  > "{run_dir}/new-files.json"
377
394
  ```
378
395
 
379
- It writes `{"new_files":[{source_file, kind}], "skipped_manual":[...], "already_tracked":[...], "stats":{...}}`. §3's `build` reads both files, typing each compare row by its `file_entries[]` row's `file_type` and each new file by its `kind`: sort no row by hand. `skipped_manual[]` are user-authored files under `scripts/[MANUAL]/` or `assets/[MANUAL]/`, preserved and not touched; `already_tracked[]` were handled by the compare above.
396
+ Pass `--brief` as Category A does, `{brief_path}` as §1c left it (in a read-only run, its copy in `{run_dir}`). On exit 2 (the detector's JSON, the map or the brief it cannot read, named in its JSON `error` on stderr, which also catches a detector that printed nothing), no JSON, or no candidate resolves: HALT with status `blocked` (halt procedure: `phase: "detect-changes:category-d"`, its `error` as `reason`).
397
+
398
+ It writes `{"new_files":[{source_file, kind}], "skipped_manual":[...], "already_tracked":[...], "tracked_code":[...], "out_of_scope":[...], "intent_none":[...], "stats":{...}}`. §3's `build` reads both files, typing each compare row by its `file_entries[]` row's `file_type` and each new file by its `kind`: sort no row by hand. `skipped_manual[]` are user-authored files under `scripts/[MANUAL]/` or `assets/[MANUAL]/`, preserved and not touched; `already_tracked[]` were handled by the compare above; `tracked_code[]` are code files the map's `entries[]` cite, which Categories A and B compare; `out_of_scope[]` are paths the brief's scope leaves out, by Category A's in-scope test; `intent_none[]` are paths of a kind whose `scripts_intent` or `assets_intent` is `none` (an absent or free-text intent detects). A path in these lists is no new file and no `stats` count, so step 6's report counts each of the three lists from this file, where it shows Category D: a brief scoped wrongly shows as many paths out of scope. A new file Category A lists too stays a new file: Category A lists every changed in-scope file, a genuine script included.
380
399
 
381
400
  **Write the category JSON** to `{run_dir}/categories.json`, where §2.2 and §3 read it beside the helper files: the two flags, `degraded_mode` and `update_mode: "normal"`. Category C stays in the files its steps wrote, which §2.2, §3 and step 5's `apply` take as `--category-c` and `--ccc-pairs`:
382
401
 
@@ -488,13 +507,13 @@ The helper emits the unified manifest envelope:
488
507
  "total_export_changes": N,
489
508
  "per_file": [
490
509
  {"file_path": "...", "status": "MODIFIED|ADDED|DELETED|MOVED",
491
- "exports_affected": [{name, change_type, old_line, new_line}, ...]}
510
+ "exports_affected": [{name, change_type, old_line, new_line, old_file?}, ...]}
492
511
  ],
493
512
  "category_d": {"scripts_modified": [...], ..., "docs_deleted": [...]}
494
513
  }
495
514
  ```
496
515
 
497
- `per_file` entries are sorted MODIFIED → ADDED → DELETED → MOVED, then alphabetically within each status group, so downstream stages can rely on stable ordering. MOVED entries include an extra `old_path` field. `category_d` lists the paths behind the `scripts_*`, `assets_*` and `docs_*` counts: a `docs_*` path is a tracked document, which only Category D detects, so a change to one alone is a change. `{run_dir}/change-manifest.json` is the change manifest: step 3, step 6 and step 5's `apply` read it there, so it survives a compacted context.
516
+ `per_file` entries are sorted MODIFIED → ADDED → DELETED → MOVED, then alphabetically within each status group, so downstream stages can rely on stable ordering. MOVED entries include an extra `old_path` field, and a `MOVED_EXPORT` across files an `old_file`, the file it left, where `apply` finds its entry. `category_d` lists the paths behind the `scripts_*`, `assets_*` and `docs_*` counts: a `docs_*` path is a tracked document, which only Category D detects, so a change to one alone is a change. `{run_dir}/change-manifest.json` is the change manifest: step 3, step 6 and step 5's `apply` read it there, so it survives a compacted context.
498
517
 
499
518
  ### 4. Check for No-Change Shortcut
500
519
 
@@ -88,14 +88,14 @@ Read the gaps of the test report at `{test_report_path}` and translate them into
88
88
  |------------|-------------------------------------------|-----------------|
89
89
  | `missing-export`, `missing-type` | Missing export, type or interface documentation | NEW_EXPORT (an undocumented public export or type), unless the remediation says the export is internal or out of scope: then DELETED_EXPORT (rescope), see rule R1 |
90
90
  | `split-body-mismatch` | Split-body inconsistency: the SKILL.md body and a `references/*.md` file document one export differently, with a `Source:` inside the skill package | STRUCTURAL_FIX, see rule R2 (for an older report, this row takes precedence over the Signature mismatch row below) |
91
- | `signature-mismatch`, `fabricated-signature`, `stale-documentation` | Signature mismatch; stale documentation (the skill documents an export the source no longer has) | MODIFIED_EXPORT (the documentation needs updating from source) |
91
+ | `signature-mismatch`, `fabricated-signature`, `stale-documentation` | Signature mismatch; stale documentation (the skill documents an export the source's enumerated surface lacks and that is not a documented extra, an Info `observation` this table never routes) | MODIFIED_EXPORT (the documentation needs updating from source) |
92
92
  | `structural`, `broken-reference`, `inaccurate-reference`, `reference-escape`, `integration-pattern`, `migration-section`, `scripts-assets` | Structural or coherence drift in the output files only, no source change | STRUCTURAL_FIX, see rule R2 |
93
93
  | `provenance-completeness` | Export documented in SKILL.md/references but **missing from the provenance-map** | NEW_EXPORT (provenance-completeness), see rule R3 |
94
94
  | `provenance-line` | Provenance line is not the export's definition | MOVED_EXPORT (provenance line), see rule R5 |
95
95
  | `metadata-drift`, `metadata` | Divergent export counts in the metadata; missing metadata or examples | metadata update, see rule R4 |
96
96
  | any other | any other gap | not routed |
97
97
 
98
- **Not routed:** update-skill has no repair for the other categories. `provenance-unverified` asks for a check by hand, `denominator-inflation` and `multi-denominator` concern the brief's scope and the count design, and `numerator-inflation`, `scripts-assets-provenance`, `observation`, `discovery`, `description` and `external-validator` need a person or another workflow. Such a gap takes no manifest entry (bullet 3 lists it).
98
+ **Not routed:** update-skill has no repair for the other categories. `provenance-unverified` asks for a check by hand, `denominator-inflation`, `brief-scope-stale` and `multi-denominator` concern the brief's scope and the count design, and `numerator-inflation`, `scripts-assets-provenance`, `observation`, `discovery`, `description` and `external-validator` need a person or another workflow. Such a gap takes no manifest entry (bullet 3 lists it).
99
99
 
100
100
  **Translation rules (referenced by the table above):**
101
101
 
@@ -310,7 +310,7 @@ Spot-check each gap's citation instead of re-extracting the files: confirm each
310
310
  -o "{run_dir}/remediation-exports.json"
311
311
  ```
312
312
 
313
- Pass `--scope-type` when step 1 read a `scope_type` from metadata.json, so a component library runs its recipe set. It takes no `--brief`: a test report can name a file the brief's scope leaves out as an export's home, and the runner would skip it. On exit 1 (`incomplete`) run it once more; on exit 2 or 3, no JSON, or no candidate resolves, or at Quick tier, read the files by eye (by text pattern). Read by eye only what the recipes leave out: an export a file defines in a form Known Limitation #11 in `{extractionPatternsData}` lists, a file `file_issues[]` names and a name `entry_point_diff.extraction_gaps[]` lists. Write each export read by eye, and the `params` and `return_type` of each function export whose `params` the runner left null, in the shape detect-changes.md §2.1 Category B step 2 gives them, to `{run_dir}/remediation-details.json` as `{"exports": [...]}`. Launch subprocesses in parallel (Pattern 4) when available; sequential fallback otherwise. Follow the tier-degradation rules in `{tierDegradationRulesData}` when AST tools fail on individual files. Then, from `{project-root}`, build the file set's records:
313
+ Pass `--scope-type` when step 1 read a `scope_type` from metadata.json, so a component library runs its recipe set. It takes no `--brief`: a test report can name a file the brief's scope leaves out as an export's home, and the runner would skip it. On exit 1 (`incomplete`, its JSON written) run it once more. When it is still incomplete, read by eye each file of the file set that has no export in `exports[]` or that an `errors[]` item lists in `unread`, and tell the user which files were read by eye. On exit 2 (an input error, such as a file list it cannot read, or a failure it did not foresee, named in one stderr line with no JSON; `uv` failing to start it exits 2 too), HALT with status `blocked` (halt procedure: `phase: "re-extract:records"`, the stderr line that holds `error:` as `reason`): no by-eye read stands in for it, since this step has written nothing to the skill and reading the files by eye would hide the error. On exit 3, no JSON on another exit, or no candidate resolves, or at Quick tier, read the files by eye (by text pattern). Read by eye only what the recipes leave out: an export a file defines in a form Known Limitation #11 in `{extractionPatternsData}` lists, a file `file_issues[]` names, a name `entry_point_diff.extraction_gaps[]` lists and a file the rule above reads after a still-incomplete run. Write each export read by eye, and the `params` and `return_type` of each function export whose `params` the runner left null, in the shape detect-changes.md §2.1 Category B step 2 gives them, to `{run_dir}/remediation-details.json` as `{"exports": [...]}`. Launch subprocesses in parallel (Pattern 4) when available; sequential fallback otherwise. Follow the tier-degradation rules in `{tierDegradationRulesData}` when AST tools fail on individual files. Then, from `{project-root}`, build the file set's records:
314
314
 
315
315
  ```bash
316
316
  uv run {buildChangeManifestHelper} records \