bmad-module-skill-forge 2.1.0 → 2.2.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.
- package/.claude-plugin/marketplace.json +1 -1
- package/README.md +3 -1
- package/docs/_data/pinned.yaml +1 -1
- package/docs/_internal/RELEASING.md +34 -0
- package/package.json +1 -1
- package/src/shared/references/description-guard-protocol.md +2 -0
- package/src/shared/scripts/skf-description-guard.py +22 -2
- package/src/shared/scripts/skf-detect-docs.py +198 -2
- package/src/shared/scripts/skf-detect-tools.py +6 -0
- package/src/skf-create-skill/assets/skill-sections.md +2 -1
- package/src/skf-create-skill/references/step-doc-sources.md +31 -3
- package/src/skf-create-skill/references/sub/fetch-docs.md +4 -2
- package/src/skf-create-skill/references/validate.md +2 -2
- package/src/skf-setup/references/ccc-index.md +5 -4
- package/src/skf-setup/references/detect-and-tier.md +1 -0
- package/src/skf-update-skill/references/write.md +2 -2
package/README.md
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
|
|
2
|
+
|
|
1
3
|
<div align="center">
|
|
2
4
|
|
|
3
5
|
<img src="website/public/img/skf-logo-animated.svg" alt="Skill Forge Logo" width="120" />
|
|
@@ -75,7 +77,7 @@ You'll be prompted for project name, output folders, and IDE configuration. When
|
|
|
75
77
|
1. **Set up your environment:** `@Ferris SF` _(Setup Forge)_ — detects your tools and sets your capability tier
|
|
76
78
|
2. **Zero-ceremony path:** `@Ferris forge-auto <repo-or-doc-url>` _(Forge-Auto)_ — one command turns a repo or doc URL into a verified skill (auto-scope, auto-brief, 90% quality gate, export)
|
|
77
79
|
3. **Fast path:** `@Ferris QS <package-name>` _(Quick Skill)_ — creates a verified skill in under a minute
|
|
78
|
-
4. **Full quality path:** `@Ferris forge <your-library>` chains Brief → Create → Test → Export automatically — or run manually: `@Ferris BS` → clear session → `@Ferris CS` for maximum control
|
|
80
|
+
4. **Full quality path:** `@Ferris forge <your-library>` chains Brief → Create → Test → Export automatically — or run manually: `@Ferris BS` _(Brief Skill)_ → clear session → `@Ferris CS` _(Create Skill)_ for maximum control
|
|
79
81
|
|
|
80
82
|
For producing **many coordinated skills** across sessions, `@Ferris campaign` _(Campaign)_ orchestrates the whole set with dependency tracking and resume.
|
|
81
83
|
|
package/docs/_data/pinned.yaml
CHANGED
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
# Enforced two ways: (1) release.yaml bumps this line in the same commit
|
|
27
27
|
# as package.json + marketplace.json on every release; (2) validate-docs-drift.js
|
|
28
28
|
# cross-checks this value against package.json.version and fails on mismatch.
|
|
29
|
-
skf_version: "2.
|
|
29
|
+
skf_version: "2.2.0"
|
|
30
30
|
|
|
31
31
|
# Path to the oh-my-skills repo, resolved relative to this repo's root.
|
|
32
32
|
# Override at runtime with the OMS environment variable if oh-my-skills
|
|
@@ -189,6 +189,40 @@ The future `release.yaml` workflow (Story 3.1) publishes to npm via **OIDC trust
|
|
|
189
189
|
|
|
190
190
|
**Fixing a bad registration.** The npm UI exposes both **Edit** and **Delete** on an existing Trusted Publisher entry (observed 2026-04-20). Prefer edit for a single-field typo; prefer delete-and-re-add if multiple fields are wrong or the edit form ever feels ambiguous. **Pre-Story 3.2**: there is no destructive side effect because no publish is attempted yet, and delete-and-re-add keeps the audit trail cleaner. **Post-Story 3.2**: a publish that fires during the delete-and-re-add window will 404 — gate any delete-and-re-add behind a manual publish freeze (pause any active `release.yaml` runs, confirm no tags are in-flight) before touching the entry.
|
|
191
191
|
|
|
192
|
+
## Cutting a Release
|
|
193
|
+
|
|
194
|
+
The dispatch mechanics are worked end-to-end in [§ Cutting v1.0.0 under --tag latest](#cutting-v100-under---tag-latest). That section is a launch-specific record, but its dispatch command, two-gate sequence, and verification block are the same for every cut. The two steps below are general, apply to every cut, and have no other home — both are invisible until they bite, because the default state of each is the harmless one.
|
|
195
|
+
|
|
196
|
+
### Pre-dispatch — reconcile a non-empty `## [Unreleased]`
|
|
197
|
+
|
|
198
|
+
The `Restore CHANGELOG preamble` step splits `CHANGELOG.md` at the **first** `## [X.Y.Z]` header and treats everything above it as preamble. The generated block for the new version is therefore inserted _below_ any prose sitting under `## [Unreleased]`, and the workflow neither empties that section nor warns about it. Check before dispatching:
|
|
199
|
+
|
|
200
|
+
```bash
|
|
201
|
+
sed -n '/^## \[Unreleased\]/,/^## \[[0-9]/p' CHANGELOG.md
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
If the `## [Unreleased]` header is immediately followed by the first released header, there is nothing to do — that was the state at every cut through v2.0.2, which is why the interaction went unexercised for so long. If it carries prose, those entries describe work that is about to ship, and after the cut they read as unreleased directly above the release containing them.
|
|
205
|
+
|
|
206
|
+
**Do not hand-edit it before dispatch.** The `## [X.Y.Z]` header the prose belongs under does not exist until the workflow generates it. Reconcile after publish in a normal PR: move the prose under the generated version header and leave the `## [Unreleased]` header in place with an empty body — deleting the header itself breaks the section split the preamble-restore step depends on.
|
|
207
|
+
|
|
208
|
+
Prefer moving the prose over dropping it in favour of the generated list. Under `conventionalcommits`, `test:` and `chore:` subjects produce no generated entry at all, and one `fix:` subject often stands in for several distinct fixes, so the hand-written text is frequently the only record of both. v2.1.0 is the worked example: five bullets stranded, moved under `## [2.1.0]` in a follow-up PR.
|
|
209
|
+
|
|
210
|
+
### Post-publish — delete the bot temp branch after an admin-bypass merge
|
|
211
|
+
|
|
212
|
+
`Auto-merge bot PR` runs `gh pr merge --auto --merge --delete-branch`, so on the auto-merge path the temp branch `release/bot/vX.Y.Z-<run_id>` is removed when the queued merge fires. Clearing gate 2 with the PR merge button instead — admin bypass, the observed pattern for most cuts — merges the PR directly, and the queued `--delete-branch` never runs. The branch stays on origin.
|
|
213
|
+
|
|
214
|
+
```bash
|
|
215
|
+
# Expect zero once a cut is fully closed out.
|
|
216
|
+
git ls-remote --heads origin 'release/bot/*'
|
|
217
|
+
|
|
218
|
+
# Delete a leftover only after confirming its commit is reachable from main.
|
|
219
|
+
git fetch origin --quiet
|
|
220
|
+
git merge-base --is-ancestor <temp-branch-sha> origin/main \
|
|
221
|
+
&& git push origin --delete release/bot/vX.Y.Z-<run_id>
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
The tag anchors on the bot PR's merge commit on `main`, not on the temp branch (see the `Create and push tag` step's main-dispatch path), so deleting the branch after the merge orphans nothing. The reachability check is what distinguishes this from the tag-orphan case in [§ Scenario D](#scenario-d--tag-exists-but-npm-publish-failed); if the commit is _not_ reachable, the merge did not land and the branch is still load-bearing.
|
|
225
|
+
|
|
192
226
|
<!-- Rollback Playbook — added in Story 4.1 -->
|
|
193
227
|
|
|
194
228
|
## Rollback Playbook
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://json.schemastore.org/package.json",
|
|
3
3
|
"name": "bmad-module-skill-forge",
|
|
4
|
-
"version": "2.
|
|
4
|
+
"version": "2.2.0",
|
|
5
5
|
"description": "BMAD module — Turn code and docs into instructions AI agents can actually follow. Progressive capability tiers (Quick/Forge/Forge+/Deep).",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"bmad",
|
|
@@ -63,6 +63,8 @@ Output JSON:
|
|
|
63
63
|
|
|
64
64
|
If `restored == true`, also update the in-context copy of `description` to match `guarded_description` so subsequent stages do not work from a stale tool-mutated version. Record `description_guard_restored: true` (with the tool name) in workflow context for the evidence report.
|
|
65
65
|
|
|
66
|
+
**Empty snapshot refusal.** The helper exits 1 and leaves the file untouched when `--captured-description` is empty or whitespace-only. There is nothing to restore from: either `guarded_description` was lost from context between capture and verify, or the field was already empty before the tool ran. Never retry with the empty value — writing it back would blank the field the guard exists to protect. If the compiled description is still available in context (the in-context SKILL.md copy), re-run `verify-restore` with that value. Otherwise record `description_guard_restored: false` and `description_guard_refused: empty-capture` (with the tool name) in workflow context; the calling stage's evidence-report populator renders that as a fired guard (`Restored: false`, the triggering tool, `Original description preserved: false`, `Notes: guard refused — empty captured snapshot (empty-capture)`) rather than as the all-`—` clean-run state, and the frontmatter validator surfaces the empty field through the normal artifact path.
|
|
67
|
+
|
|
66
68
|
## Why Token-Stream Comparison
|
|
67
69
|
|
|
68
70
|
Token-stream comparison is the documented sweet spot between two failure modes:
|
|
@@ -32,6 +32,9 @@ Subcommands:
|
|
|
32
32
|
"current_description": "..."}
|
|
33
33
|
If not diverged (including whitespace-only differences), emit
|
|
34
34
|
{"diverged": false, "restored": false, "diff_kind": "none"|"whitespace-only"}
|
|
35
|
+
An empty or whitespace-only --captured-description is refused with
|
|
36
|
+
exit 1 before the file is read: there is nothing to restore from, and
|
|
37
|
+
writing it back would blank the very field this guard protects.
|
|
35
38
|
|
|
36
39
|
Token-stream comparison is the documented sweet spot — catches replaced
|
|
37
40
|
words, truncation, and reintroduced angle-brackets while ignoring cosmetic
|
|
@@ -47,7 +50,8 @@ is auto-resolved:
|
|
|
47
50
|
|
|
48
51
|
Exit codes:
|
|
49
52
|
0 — operation succeeded (including no-divergence verify)
|
|
50
|
-
1 — user error (bad args, file missing,
|
|
53
|
+
1 — user error (bad args, empty captured description, file missing,
|
|
54
|
+
frontmatter unparseable)
|
|
51
55
|
2 — operation failure (restore write failed)
|
|
52
56
|
"""
|
|
53
57
|
|
|
@@ -173,6 +177,11 @@ def restore_description(skill_md: Path, captured: str) -> None:
|
|
|
173
177
|
chooses for each scalar — downstream readers parse YAML, so any valid
|
|
174
178
|
YAML emission is acceptable.
|
|
175
179
|
"""
|
|
180
|
+
if not captured.strip():
|
|
181
|
+
# Defence in depth for library callers: the CLI refuses this earlier,
|
|
182
|
+
# but no code path may ever write an empty description.
|
|
183
|
+
raise ValueError("cannot restore: captured description is empty or whitespace-only")
|
|
184
|
+
|
|
176
185
|
text = skill_md.read_text(encoding="utf-8")
|
|
177
186
|
leading, fm_yaml, body = _split_frontmatter(text)
|
|
178
187
|
if not fm_yaml:
|
|
@@ -238,6 +247,18 @@ def _cmd_capture(args: argparse.Namespace) -> int:
|
|
|
238
247
|
|
|
239
248
|
|
|
240
249
|
def _cmd_verify_restore(args: argparse.Namespace) -> int:
|
|
250
|
+
captured = args.captured_description
|
|
251
|
+
if not captured.strip():
|
|
252
|
+
# Restoring an empty snapshot would blank the field the guard exists
|
|
253
|
+
# to protect. The snapshot was lost from context, or the description
|
|
254
|
+
# was already empty before the tool ran; either way there is nothing
|
|
255
|
+
# to restore, so refuse before touching the file.
|
|
256
|
+
_fail(
|
|
257
|
+
"--captured-description is empty or whitespace-only; refusing to "
|
|
258
|
+
"overwrite the on-disk description with an empty value (the capture "
|
|
259
|
+
"was lost, or the field was already empty before the tool ran)"
|
|
260
|
+
)
|
|
261
|
+
|
|
241
262
|
skill_md = Path(args.skill_md)
|
|
242
263
|
if not skill_md.is_file():
|
|
243
264
|
_fail(f"file not found: {skill_md}")
|
|
@@ -246,7 +267,6 @@ def _cmd_verify_restore(args: argparse.Namespace) -> int:
|
|
|
246
267
|
except ValueError as exc:
|
|
247
268
|
_fail(str(exc))
|
|
248
269
|
|
|
249
|
-
captured = args.captured_description
|
|
250
270
|
diff_kind = classify_divergence(captured, current)
|
|
251
271
|
diverged = is_diverged(diff_kind)
|
|
252
272
|
|
|
@@ -74,6 +74,42 @@ Each list is stably sorted by url. Stored hashes are normalized (leading
|
|
|
74
74
|
`algo:` prefix stripped) before comparison so a bare-hex writer form still
|
|
75
75
|
matches. Exit 0 on any well-formed input (never blocks the informational
|
|
76
76
|
audit); exit 2 on malformed args or JSON.
|
|
77
|
+
|
|
78
|
+
---------------------------------------------------------------------------
|
|
79
|
+
Subcommand: hash-urls
|
|
80
|
+
---------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
Compile-time `doc_sources[]` for docs-only skills (create-skill step 5a).
|
|
83
|
+
The detect path above needs a GitHub repository; a docs-only brief has none
|
|
84
|
+
— its `source_repo` is the documentation site and its corpus is the brief's
|
|
85
|
+
`doc_urls`. This subcommand fetches each URL and hashes the raw response
|
|
86
|
+
bytes with the SAME primitive `compare-hashes` uses, so a hash recorded here
|
|
87
|
+
compares byte-for-byte at audit time. (Hashing the markdown a web-fetch tool
|
|
88
|
+
rendered would not: the audit re-fetches the raw bytes.)
|
|
89
|
+
|
|
90
|
+
uv run src/shared/scripts/skf-detect-docs.py hash-urls <source>
|
|
91
|
+
|
|
92
|
+
Input `<source>`:
|
|
93
|
+
- a path to a JSON file that is EITHER an array whose items are URL
|
|
94
|
+
strings or `{url, ...}` objects (the brief's `doc_urls[]` entry shape)
|
|
95
|
+
OR an object with a `doc_urls` array (the brief block itself), OR
|
|
96
|
+
- `-` to read that JSON from stdin.
|
|
97
|
+
|
|
98
|
+
Output (JSON object on stdout):
|
|
99
|
+
{
|
|
100
|
+
"doc_sources": [{"url", "detected_via": "brief_doc_urls",
|
|
101
|
+
"content_hash": "sha256:..."|null, "recorded_at"}],
|
|
102
|
+
"fetch_failed": [{"url", "reason"}],
|
|
103
|
+
"stats": {"total", "hashed", "fetch_failed", "invalid"}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
URLs keep their input order and are deduplicated on first occurrence; an
|
|
107
|
+
entry without a usable url string is dropped and counted under
|
|
108
|
+
`stats.invalid`. A URL that cannot be fetched still gets an entry with
|
|
109
|
+
`content_hash: null` — the audit side files it under `skipped_null_hash`
|
|
110
|
+
instead of reporting drift. `doc_sources` is already in the metadata.json
|
|
111
|
+
schema, and the whole object is valid `compare-hashes` input. Exit 0 on any
|
|
112
|
+
well-formed input; exit 2 on malformed args or JSON.
|
|
77
113
|
"""
|
|
78
114
|
|
|
79
115
|
from __future__ import annotations
|
|
@@ -88,6 +124,7 @@ import subprocess
|
|
|
88
124
|
import sys
|
|
89
125
|
import urllib.error
|
|
90
126
|
import urllib.request
|
|
127
|
+
from datetime import datetime, timezone
|
|
91
128
|
from pathlib import Path
|
|
92
129
|
from typing import Any, Dict, List, Optional, Tuple
|
|
93
130
|
|
|
@@ -252,8 +289,10 @@ def _fetch_and_hash(url: str) -> Optional[str]:
|
|
|
252
289
|
return "sha256:" + hashlib.sha256(content).hexdigest()
|
|
253
290
|
except (OSError, IOError):
|
|
254
291
|
return None
|
|
255
|
-
req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
|
|
256
292
|
try:
|
|
293
|
+
# Request() itself raises ValueError on a scheme-less or relative URL;
|
|
294
|
+
# it must sit inside the guard so a bad entry degrades to None.
|
|
295
|
+
req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
|
|
257
296
|
with urllib.request.urlopen(req, timeout=_FETCH_TIMEOUT) as resp:
|
|
258
297
|
content = resp.read()
|
|
259
298
|
return "sha256:" + hashlib.sha256(content).hexdigest()
|
|
@@ -294,7 +333,14 @@ def _fetch_and_hash_reason(url: str) -> Tuple[Optional[str], Optional[str]]:
|
|
|
294
333
|
except OSError as exc:
|
|
295
334
|
return None, f"local read failed: {exc}"
|
|
296
335
|
return "sha256:" + hashlib.sha256(content).hexdigest(), None
|
|
297
|
-
|
|
336
|
+
try:
|
|
337
|
+
# Request() raises ValueError ("unknown url type") on a scheme-less or
|
|
338
|
+
# relative URL — a legal brief entry, since the brief schema only
|
|
339
|
+
# requires a non-empty string. Keep it inside the guard so one bad
|
|
340
|
+
# entry lands in fetch_failed instead of aborting the whole run.
|
|
341
|
+
req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
|
|
342
|
+
except ValueError as exc:
|
|
343
|
+
return None, f"invalid URL: {exc}"
|
|
298
344
|
try:
|
|
299
345
|
with urllib.request.urlopen(req, timeout=_FETCH_TIMEOUT) as resp:
|
|
300
346
|
content = resp.read()
|
|
@@ -601,16 +647,166 @@ def _cmd_compare_hashes(argv: List[str]) -> int:
|
|
|
601
647
|
return 0
|
|
602
648
|
|
|
603
649
|
|
|
650
|
+
# ---------------------------------------------------------------------------
|
|
651
|
+
# Subcommand: hash-urls — docs-only doc_sources from the brief's doc_urls
|
|
652
|
+
# ---------------------------------------------------------------------------
|
|
653
|
+
|
|
654
|
+
# `doc_sources[].detected_via` value for entries that come from the brief's
|
|
655
|
+
# `doc_urls` (and the subpages step 3c fetched from them) rather than from
|
|
656
|
+
# repository detection. Listed in skf-create-skill/assets/skill-sections.md.
|
|
657
|
+
BRIEF_DOC_URLS_DETECTED_VIA = "brief_doc_urls"
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
def _load_url_list(raw_text: str, source_label: str) -> List[Any]:
|
|
661
|
+
"""Parse a hash-urls input blob into a list of URL entries.
|
|
662
|
+
|
|
663
|
+
Accepts a top-level JSON array whose items are URL strings or objects
|
|
664
|
+
with a `url` key (the brief's `doc_urls[]` entry shape), or a top-level
|
|
665
|
+
object with a `doc_urls` array (the brief block itself). An object without
|
|
666
|
+
`doc_urls` yields an empty list (nothing to hash).
|
|
667
|
+
|
|
668
|
+
Raises ValueError on malformed JSON or an unexpected top-level shape.
|
|
669
|
+
"""
|
|
670
|
+
try:
|
|
671
|
+
data = json.loads(raw_text)
|
|
672
|
+
except json.JSONDecodeError as exc:
|
|
673
|
+
raise ValueError(f"malformed JSON in {source_label}: {exc}") from exc
|
|
674
|
+
if isinstance(data, list):
|
|
675
|
+
return data
|
|
676
|
+
if isinstance(data, dict):
|
|
677
|
+
entries = data.get("doc_urls")
|
|
678
|
+
if entries is None:
|
|
679
|
+
return []
|
|
680
|
+
if not isinstance(entries, list):
|
|
681
|
+
raise ValueError(f"`doc_urls` in {source_label} is not an array")
|
|
682
|
+
return entries
|
|
683
|
+
raise ValueError(
|
|
684
|
+
f"{source_label} must be a JSON array or an object with a "
|
|
685
|
+
f"`doc_urls` array; got {type(data).__name__}"
|
|
686
|
+
)
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
def hash_urls(entries: List[Any], recorded_at: Optional[str] = None) -> Dict[str, Any]:
|
|
690
|
+
"""Fetch and hash each URL into a compile-time `doc_sources[]` entry.
|
|
691
|
+
|
|
692
|
+
Byte-symmetric with `compare_doc_hashes`: both go through
|
|
693
|
+
`_fetch_and_hash_reason`, so a hash recorded here reads as `unchanged` at
|
|
694
|
+
audit time for unchanged bytes. URLs keep their input order (brief order,
|
|
695
|
+
then discovered subpages) and are deduplicated on first occurrence. A URL
|
|
696
|
+
that cannot be fetched still gets an entry with `content_hash: None`, which
|
|
697
|
+
the audit side skips rather than reporting as drift.
|
|
698
|
+
"""
|
|
699
|
+
stamp = recorded_at or datetime.now(timezone.utc).isoformat()
|
|
700
|
+
doc_sources: List[Dict[str, Any]] = []
|
|
701
|
+
fetch_failed: List[Dict[str, Any]] = []
|
|
702
|
+
seen: set = set()
|
|
703
|
+
invalid = 0
|
|
704
|
+
for entry in entries:
|
|
705
|
+
url = entry.get("url") if isinstance(entry, dict) else entry
|
|
706
|
+
if not isinstance(url, str) or not url.strip():
|
|
707
|
+
invalid += 1
|
|
708
|
+
continue
|
|
709
|
+
if url in seen:
|
|
710
|
+
continue
|
|
711
|
+
seen.add(url)
|
|
712
|
+
content_hash, reason = _fetch_and_hash_reason(url)
|
|
713
|
+
doc_sources.append({
|
|
714
|
+
"url": url,
|
|
715
|
+
"detected_via": BRIEF_DOC_URLS_DETECTED_VIA,
|
|
716
|
+
"content_hash": content_hash,
|
|
717
|
+
"recorded_at": stamp,
|
|
718
|
+
})
|
|
719
|
+
if content_hash is None:
|
|
720
|
+
fetch_failed.append({"url": url, "reason": reason})
|
|
721
|
+
return {
|
|
722
|
+
"doc_sources": doc_sources,
|
|
723
|
+
"fetch_failed": fetch_failed,
|
|
724
|
+
"stats": {
|
|
725
|
+
"total": len(doc_sources),
|
|
726
|
+
"hashed": len(doc_sources) - len(fetch_failed),
|
|
727
|
+
"fetch_failed": len(fetch_failed),
|
|
728
|
+
"invalid": invalid,
|
|
729
|
+
},
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
def _cmd_hash_urls(argv: List[str]) -> int:
|
|
734
|
+
parser = argparse.ArgumentParser(
|
|
735
|
+
prog="skf-detect-docs.py hash-urls",
|
|
736
|
+
description=(
|
|
737
|
+
"Fetch each documentation URL, hash the response bytes, and emit "
|
|
738
|
+
"compile-time doc_sources entries (detected_via: brief_doc_urls) "
|
|
739
|
+
"for a docs-only skill."
|
|
740
|
+
),
|
|
741
|
+
)
|
|
742
|
+
parser.add_argument(
|
|
743
|
+
"source",
|
|
744
|
+
help=(
|
|
745
|
+
"path to a JSON file (array of URL strings or {url, ...} objects, "
|
|
746
|
+
"or an object with a doc_urls array such as the brief's block), "
|
|
747
|
+
"or - to read that JSON from stdin"
|
|
748
|
+
),
|
|
749
|
+
)
|
|
750
|
+
args = parser.parse_args(argv)
|
|
751
|
+
|
|
752
|
+
if args.source == "-":
|
|
753
|
+
raw = sys.stdin.read()
|
|
754
|
+
label = "<stdin>"
|
|
755
|
+
else:
|
|
756
|
+
path = Path(args.source)
|
|
757
|
+
try:
|
|
758
|
+
raw = path.read_text(encoding="utf-8")
|
|
759
|
+
except OSError as exc:
|
|
760
|
+
json.dump({"error": f"cannot read {path}: {exc}", "code": "READ_ERROR"}, sys.stderr)
|
|
761
|
+
sys.stderr.write("\n")
|
|
762
|
+
return 2
|
|
763
|
+
label = str(path)
|
|
764
|
+
|
|
765
|
+
try:
|
|
766
|
+
entries = _load_url_list(raw, label)
|
|
767
|
+
except ValueError as exc:
|
|
768
|
+
json.dump({"error": str(exc), "code": "INVALID_JSON"}, sys.stderr)
|
|
769
|
+
sys.stderr.write("\n")
|
|
770
|
+
return 2
|
|
771
|
+
|
|
772
|
+
result = hash_urls(entries)
|
|
773
|
+
json.dump(result, sys.stdout, separators=(",", ":"))
|
|
774
|
+
sys.stdout.write("\n")
|
|
775
|
+
return 0
|
|
776
|
+
|
|
777
|
+
|
|
604
778
|
# ---------------------------------------------------------------------------
|
|
605
779
|
# CLI entry point
|
|
606
780
|
# ---------------------------------------------------------------------------
|
|
607
781
|
|
|
782
|
+
def _force_utf8(*streams) -> None:
|
|
783
|
+
"""Reconfigure JSON-carrying streams to UTF-8 (issue #465).
|
|
784
|
+
|
|
785
|
+
A default Windows console decodes stdio as cp1252, which cannot carry
|
|
786
|
+
non-ASCII JSON (raw UTF-8 input on stdin for `compare-hashes -` and
|
|
787
|
+
`hash-urls -`). Preserves each stream's existing error handler —
|
|
788
|
+
reconfigure(encoding=...) alone would reset it to 'strict', downgrading
|
|
789
|
+
e.g. an already-UTF-8 stderr on Linux. For stdin this must run before the
|
|
790
|
+
first read. Skips in-process test doubles without reconfigure().
|
|
791
|
+
"""
|
|
792
|
+
for stream in streams:
|
|
793
|
+
if hasattr(stream, "reconfigure"):
|
|
794
|
+
errors = getattr(stream, "errors", None)
|
|
795
|
+
if errors is None:
|
|
796
|
+
stream.reconfigure(encoding="utf-8")
|
|
797
|
+
else:
|
|
798
|
+
stream.reconfigure(encoding="utf-8", errors=errors)
|
|
799
|
+
|
|
800
|
+
|
|
608
801
|
def main() -> int:
|
|
802
|
+
_force_utf8(sys.stdin, sys.stdout)
|
|
609
803
|
# Additive subcommand: route `compare-hashes ...` to the doc-drift path
|
|
610
804
|
# before the existing detect parser runs. The default (no subcommand)
|
|
611
805
|
# invocation `--repo-url <url> ...` is unchanged.
|
|
612
806
|
if len(sys.argv) > 1 and sys.argv[1] == "compare-hashes":
|
|
613
807
|
return _cmd_compare_hashes(sys.argv[2:])
|
|
808
|
+
if len(sys.argv) > 1 and sys.argv[1] == "hash-urls":
|
|
809
|
+
return _cmd_hash_urls(sys.argv[2:])
|
|
614
810
|
|
|
615
811
|
parser = argparse.ArgumentParser(
|
|
616
812
|
description="Detect documentation URLs for a GitHub repository.",
|
|
@@ -89,6 +89,7 @@ DETECT_OUTPUT_SCHEMA (v1):
|
|
|
89
89
|
"previous_ccc_indexed_path": str|null,
|
|
90
90
|
"previous_ccc_last_indexed": str|null,
|
|
91
91
|
"previous_ccc_staleness_threshold_hours": int|null,
|
|
92
|
+
"previous_ccc_file_count": int|null,
|
|
92
93
|
# Deterministic CCC-index freshness verdict — computed against
|
|
93
94
|
# --project-root and datetime.now(UTC) captured at detect time, so the
|
|
94
95
|
# ccc-index.md step branches on a boolean instead of doing timestamp math.
|
|
@@ -369,6 +370,7 @@ def read_prior_state(prior_state_path) -> dict:
|
|
|
369
370
|
"previous_ccc_indexed_path": None,
|
|
370
371
|
"previous_ccc_last_indexed": None,
|
|
371
372
|
"previous_ccc_staleness_threshold_hours": None,
|
|
373
|
+
"previous_ccc_file_count": None,
|
|
372
374
|
}
|
|
373
375
|
if not prior_state_path:
|
|
374
376
|
return empty
|
|
@@ -393,6 +395,10 @@ def read_prior_state(prior_state_path) -> dict:
|
|
|
393
395
|
"previous_ccc_indexed_path": ccc_index.get("indexed_path"),
|
|
394
396
|
"previous_ccc_last_indexed": ccc_index.get("last_indexed"),
|
|
395
397
|
"previous_ccc_staleness_threshold_hours": ccc_index.get("staleness_threshold_hours"),
|
|
398
|
+
# Surfaced so ccc-index.md's fresh-index branch can carry the count
|
|
399
|
+
# forward instead of leaving `{ccc_file_count}` unbound (or nulling a
|
|
400
|
+
# value the index still has) when nothing re-indexes this run.
|
|
401
|
+
"previous_ccc_file_count": ccc_index.get("file_count"),
|
|
396
402
|
}
|
|
397
403
|
|
|
398
404
|
|
|
@@ -140,7 +140,8 @@ Indexed pipe-delimited format for CLAUDE.md managed section (~80-120 tokens per
|
|
|
140
140
|
// "scripts": [{ "file": "scripts/{name}", "purpose": "{description}", "source_file": "{source-path}", "confidence": "T1-low" }],
|
|
141
141
|
// "assets": [{ "file": "assets/{name}", "purpose": "{description}", "source_file": "{source-path}", "confidence": "T1-low" }],
|
|
142
142
|
// doc_sources[] — include ONLY when doc detection ran; omit if detection was skipped entirely
|
|
143
|
-
// "doc_sources": [{ "url": "https://...", "detected_via": "homepageUrl|readme_link|pages_api|docs_folder|readme_always", "content_hash": "sha256:{hex}|null", "recorded_at": "ISO-8601" }],
|
|
143
|
+
// "doc_sources": [{ "url": "https://...", "detected_via": "homepageUrl|readme_link|pages_api|docs_folder|readme_always|brief_doc_urls", "content_hash": "sha256:{hex}|null", "recorded_at": "ISO-8601" }],
|
|
144
|
+
// detected_via "brief_doc_urls" = docs-only skills: the brief's doc_urls (plus subpages step 3c fetched), hashed by `skf-detect-docs.py hash-urls`
|
|
144
145
|
"generated_by": "{quick-skill|create-skill}",
|
|
145
146
|
"dependencies": [],
|
|
146
147
|
"compatibility": "{semver-range}"
|
|
@@ -3,7 +3,8 @@ nextStepFile: 'step-auto-shard.md'
|
|
|
3
3
|
# Resolve `{detectDocsHelper}` by probing `{detectDocsProbeOrder}` in order
|
|
4
4
|
# (installed SKF module path first, src/ dev-checkout fallback); first existing
|
|
5
5
|
# path wins. HALT if neither resolves — §2 has no prose fallback for doc-source
|
|
6
|
-
# detection (Pages-API walk, docs/ folder scan, content hashing)
|
|
6
|
+
# detection (Pages-API walk, docs/ folder scan, content hashing), and §2a none
|
|
7
|
+
# for docs-only URL hashing.
|
|
7
8
|
detectDocsProbeOrder:
|
|
8
9
|
- '{project-root}/_bmad/skf/shared/scripts/skf-detect-docs.py'
|
|
9
10
|
- '{project-root}/src/shared/scripts/skf-detect-docs.py'
|
|
@@ -15,7 +16,7 @@ detectDocsProbeOrder:
|
|
|
15
16
|
|
|
16
17
|
## STEP GOAL:
|
|
17
18
|
|
|
18
|
-
Record detected documentation pages and README with content hashes in metadata.json so that downstream audit (analyze-skill) can detect when upstream docs have changed since the skill was compiled.
|
|
19
|
+
Record detected documentation pages and README — or, for a docs-only skill, the brief's documentation URLs — with content hashes in metadata.json so that downstream audit (analyze-skill) can detect when upstream docs have changed since the skill was compiled.
|
|
19
20
|
|
|
20
21
|
## Rules
|
|
21
22
|
|
|
@@ -35,7 +36,9 @@ Check if `doc_detection_results` is already populated in the workflow context (s
|
|
|
35
36
|
|
|
36
37
|
### 2. Run Doc Detection (if needed)
|
|
37
38
|
|
|
38
|
-
|
|
39
|
+
**If `source_type` is `"docs-only"`:** skip detection — `source_repo` is the documentation site, not a GitHub repository, so `{detectDocsHelper} --repo-url` would exit 2 with `INVALID_URL` and leave `doc_sources[]` empty. The brief's `doc_urls` are the doc sources; continue to §2a.
|
|
40
|
+
|
|
41
|
+
Otherwise check that `{source_repo}` is available from the skill brief.
|
|
39
42
|
|
|
40
43
|
**If `{source_repo}` is not available:**
|
|
41
44
|
- Set `doc_detection_results` to an empty array `[]`
|
|
@@ -62,8 +65,30 @@ Pass `--local-path {source_path}` when a local clone exists from the extraction
|
|
|
62
65
|
- **Exit 1** (none found): set `doc_detection_results` to empty array `[]`
|
|
63
66
|
- **Exit 2** (error): set `doc_detection_results` to empty array `[]`, add evidence note: `"Doc sources: detection partial — skf-detect-docs.py error (exit 2)"`
|
|
64
67
|
|
|
68
|
+
### 2a. Docs-Only: Hash the Brief's Documentation URLs
|
|
69
|
+
|
|
70
|
+
Runs only when `source_type` is `"docs-only"` (§2 routed here). Build the URL list: every `doc_urls[].url` from the brief, in brief order, followed by every subpage URL that step 3c (fetch-docs) actually fetched through subpage discovery — the `[EXT:{url}]` provenance set of the doc-fetch inventory. Include a brief URL even if step 3c could not fetch it; the helper fetches independently of the agent's web tools.
|
|
71
|
+
|
|
72
|
+
**Resolve `{detectDocsHelper}`** from `{detectDocsProbeOrder}`; first existing path wins. HALT if no candidate exists.
|
|
73
|
+
|
|
74
|
+
Pipe the list to the `hash-urls` subcommand. It fetches each URL and hashes the raw response bytes with the same primitive `skf-audit-skill` step 5a (`compare-hashes`) uses, so a hash recorded here compares byte-for-byte at audit time. Do not hash the markdown a web-fetch tool rendered in step 3c — the audit re-fetches raw bytes, and a rendered-markdown hash would report every page as drifted.
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
printf '%s' '["{url-1}", "{url-2}", "..."]' | uv run {detectDocsHelper} hash-urls -
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
The input may also be the brief's `doc_urls` block verbatim — an array of `{url, label}` objects, or an object with a `doc_urls` array.
|
|
81
|
+
|
|
82
|
+
**Handle exit codes:**
|
|
83
|
+
- **Exit 0:** parse the JSON object. Set `doc_detection_results` ← its `doc_sources` array — entries are already in the §4 schema (`url`, `detected_via: "brief_doc_urls"`, `content_hash` — `null` for a URL that could not be fetched — and `recorded_at`). Keep `fetch_failed[]` and `stats` for the §5 evidence note.
|
|
84
|
+
- **Exit 2** (malformed input, unreadable file): set `doc_detection_results` to empty array `[]`, add evidence note: `"Doc sources: detection partial — skf-detect-docs.py hash-urls error (exit 2)"`
|
|
85
|
+
|
|
86
|
+
Skip §3 — a docs-only brief has no repository README to track. Continue at §4.
|
|
87
|
+
|
|
65
88
|
### 3. Ensure README Entry
|
|
66
89
|
|
|
90
|
+
**If `source_type` is `"docs-only"`:** skip this section — `source_repo` is the documentation site, not a repository, so there is no README to track and `{source_repo}/blob/main/README.md` would be a fabricated URL. Continue at §4.
|
|
91
|
+
|
|
67
92
|
After obtaining detection results, check if any entry has a URL matching `*/README.md` or `*/readme.md`.
|
|
68
93
|
|
|
69
94
|
**If a README entry already exists** from detection (e.g., `detected_via: "docs_folder"` found a README): keep it as-is.
|
|
@@ -99,6 +124,8 @@ Field mapping from `skf-detect-docs.py` output:
|
|
|
99
124
|
|
|
100
125
|
Note: `content_type` from detect-docs output is not carried into `doc_sources`.
|
|
101
126
|
|
|
127
|
+
For a docs-only skill the `hash-urls` output from §2a is already in this shape (`detected_via: "brief_doc_urls"`, `recorded_at` stamped by the helper) — copy its `doc_sources` entries verbatim.
|
|
128
|
+
|
|
102
129
|
### 5. Update metadata.json
|
|
103
130
|
|
|
104
131
|
Read the staging `_bmad-output/{skill-name}/metadata.json` that compile (step 5) wrote.
|
|
@@ -117,6 +144,7 @@ Add evidence note summarizing the result:
|
|
|
117
144
|
- Success: `"Doc sources: {N} detected, README tracked"`
|
|
118
145
|
- Skip: `"Doc sources: detection skipped — {reason}"`
|
|
119
146
|
- Partial: `"Doc sources: detection partial — {N} found, {errors}"`
|
|
147
|
+
- Docs-only: `"Doc sources: {stats.hashed}/{stats.total} brief doc URL(s) hashed, {stats.fetch_failed} fetch failed"`
|
|
120
148
|
|
|
121
149
|
### 6. Auto-Proceed
|
|
122
150
|
|
|
@@ -28,6 +28,7 @@ Fetch remote documentation from brief-specified URLs using whatever web fetching
|
|
|
28
28
|
- Tool-agnostic — use whatever web fetching capability is available
|
|
29
29
|
- Do not halt the workflow if web fetching is unavailable or fails
|
|
30
30
|
- Do not override existing T1, T1-low, or T2 extraction data with T3 content
|
|
31
|
+
- Never delete the staging directory of a `docs-only` skill — the fetched pages are its only source corpus
|
|
31
32
|
|
|
32
33
|
## MANDATORY SEQUENCE
|
|
33
34
|
|
|
@@ -173,7 +174,7 @@ This artifact is a **distinct** carrier — it is not merged into the extraction
|
|
|
173
174
|
|
|
174
175
|
**If tier is Deep and at least one URL was fetched successfully:**
|
|
175
176
|
|
|
176
|
-
1. Write fetched markdown files to a staging directory: `_bmad-output/{skill-name}-docs/`
|
|
177
|
+
1. Write fetched markdown files to a staging directory: `_bmad-output/{skill-name}-docs/` — clear any previous contents first, so a corpus retained from an earlier docs-only run (step 5 below) does not mix with this run's pages.
|
|
177
178
|
2. Index into QMD with atomic replace + rollback: if a `{skill-name}-docs` collection already exists, run `qmd collection remove {skill-name}-docs` first, then `qmd collection add {project-root}/_bmad-output/{skill-name}-docs/ --name {skill-name}-docs --mask "*.md"`. **If `qmd collection add` fails after a successful `remove`:** remove any matching `{skill-name}-docs` entry from `forge-tier.yaml` → `qmd_collections[]` to keep the registry consistent with QMD's actual state, warn in evidence-report, and skip the embed — docs enrichment degrades gracefully.
|
|
178
179
|
3. Generate embeddings scoped to this collection (only if step 2 `add` succeeded): `qmd embed --collection {skill-name}-docs` (required for semantic `type:'vec'` and HyDE `type:'hyde'` sub-queries within the QMD `query` tool). If the installed `qmd` CLI does not accept `--collection`, gate the embed behind a freshness check: skip re-embedding if the existing `{skill-name}-docs` registry entry is within 24 hours, and log the skip in the evidence report to prevent unbounded batch-mode re-embedding.
|
|
179
180
|
4. Register in forge-tier.yaml `qmd_collections` array — **acquire an exclusive `flock` on `{sidecar_path}/forge-tier.yaml.lock` for the read-modify-write** (see the locking pattern documented in step 3b §4). Write via `python3 {atomicWriteHelper} write --target {sidecar_path}/forge-tier.yaml`. If `flock` is unavailable, fall back to read-CAS-by-mtime (capture `st_mtime` before, re-check after; refuse to clobber if a concurrent run wrote in between).
|
|
@@ -186,7 +187,7 @@ This artifact is a **distinct** carrier — it is not merged into the extraction
|
|
|
186
187
|
created_at: "{current ISO date}"
|
|
187
188
|
```
|
|
188
189
|
|
|
189
|
-
5. Clean up staging directory after indexing
|
|
190
|
+
5. Clean up the staging directory after indexing — **only when `source_type` is `"source"`**: `rm -rf {project-root}/_bmad-output/{skill-name}-docs/`. Note that this directory is the source path of the `{skill-name}-docs` collection registered in step 4; removing it is accepted for supplemental docs, whose T3 items already live in the extraction inventory. **When `source_type` is `"docs-only"`, keep the directory.** The fetched pages are the skill's only source corpus — there is no code tree — so deleting them would leave the just-registered collection with nothing to refresh from and nothing to verify citations against. Record the retained path in the evidence report.
|
|
190
191
|
|
|
191
192
|
**If QMD indexing fails:** Warn: "QMD indexing of fetched docs failed. T3 items are still in the extraction inventory — enrichment will proceed without QMD-indexed docs." Continue.
|
|
192
193
|
|
|
@@ -199,6 +200,7 @@ Display:
|
|
|
199
200
|
**T3 items extracted:** {count}
|
|
200
201
|
**Confidence:** All doc-fetched items are T3 — `[EXT:{url}]` citations applied.
|
|
201
202
|
{If docs-only mode: '**Mode:** Docs-only — all skill content is T3. source_authority: community'}
|
|
203
|
+
{If docs-only mode AND tier is Deep: '**Docs corpus retained:** `_bmad-output/{skill-name}-docs/`{if the `{skill-name}-docs` collection was registered in §5b: ' — source path of QMD collection `{skill-name}-docs`'}'}
|
|
202
204
|
|
|
203
205
|
Proceeding to enrichment..."
|
|
204
206
|
|
|
@@ -62,7 +62,7 @@ To validate the compiled SKILL.md content against the agentskills.io specificati
|
|
|
62
62
|
|
|
63
63
|
**Used by:** §2 (`skill-check check --fix`), §4 (`split-body`), and any future tool invocation that may modify SKILL.md.
|
|
64
64
|
|
|
65
|
-
Load `{descriptionGuardProtocol}` for the full prose explanation of the four-phase guard (why it exists, what counts as divergence, why token-stream comparison is the right shape). The deterministic phases are executed via `{descriptionGuardHelper}` — the calling sections (§2 and §4) invoke the helper at the capture and verify-restore points.
|
|
65
|
+
Load `{descriptionGuardProtocol}` for the full prose explanation of the four-phase guard (why it exists, what counts as divergence, why token-stream comparison is the right shape). The deterministic phases are executed via `{descriptionGuardHelper}` — the calling sections (§2 and §4) invoke the helper at the capture and verify-restore points. `verify-restore` refuses an empty `--captured-description` (exit 1, file untouched); when that happens, follow the protocol's empty-snapshot rule instead of re-running with the empty value.
|
|
66
66
|
|
|
67
67
|
**This skill's post-restore re-validation hook:** after `{descriptionGuardHelper}` reports `restored: true`, resolve `{frontmatterValidator}` from `{frontmatterValidatorProbeOrder}` (first existing path wins), run `uv run {frontmatterValidator} <staging-skill-dir>/SKILL.md` and capture `schema_revalidation_result` in context. If the validator exits non-zero OR reports failure for the `description` field, flip the Schema result back to `FAIL` in the evidence report (overriding any prior PASS/WARN from §2), record `description_guard_revalidation: FAIL` with the validator's diagnostic message, and continue — do not halt (step 9 health-check and result contract still need to run so the failure is surfaced through the normal artifact path).
|
|
68
68
|
|
|
@@ -320,7 +320,7 @@ If the sink, the on-disk rows, and `headless_decisions[]` are all empty, keep th
|
|
|
320
320
|
- `Original description preserved`: `true` if the restore succeeded (on-disk now matches the pre-tool snapshot), `false` if restoration itself failed (rare — treat as a halt condition in a future version).
|
|
321
321
|
- `Notes`: a one-sentence description of what the tool had changed. Typical values: `"replaced with generic summary"`, `"truncated at N chars"`, `"angle-bracket tokens re-introduced"`, `"field deleted entirely"`. If `Restored: false`, use `—`.
|
|
322
322
|
|
|
323
|
-
When `Restored: false`, the three follow-up fields are all `—` — this is the clean-run expected state.
|
|
323
|
+
When `Restored: false`, the three follow-up fields are all `—` — this is the clean-run expected state — with one exception: when `description_guard_refused == "empty-capture"` (the protocol's empty-snapshot rule — `verify-restore` exited 1 and no in-context copy allowed a re-run), the guard did fire and must not render as a clean run. Set `Restored: false`, `Triggering tool` to the recorded tool name, `Original description preserved: false`, and `Notes: guard refused — empty captured snapshot (empty-capture)`.
|
|
324
324
|
|
|
325
325
|
### 9. Auto-Proceed
|
|
326
326
|
|
|
@@ -25,6 +25,7 @@ For Quick and Forge tiers, or when ccc is unavailable, skip silently and proceed
|
|
|
25
25
|
- Do not display skip messages for Quick/Forge tiers
|
|
26
26
|
- Do not fail the workflow if ccc indexing fails
|
|
27
27
|
- The script owns exclusion-pattern validation — do not reimplement it in prose
|
|
28
|
+
- Every branch that leaves this step binds all four of `ccc_index_result`, `ccc_indexed_path`, `ccc_last_indexed`, and `ccc_file_count` — step 2 interpolates each bare into the `write-tools` JSON payload, so an unbound flag would emit its literal placeholder and fail the forge-tier.yaml write
|
|
28
29
|
|
|
29
30
|
## MANDATORY SEQUENCE
|
|
30
31
|
|
|
@@ -32,9 +33,9 @@ For Quick and Forge tiers, or when ccc is unavailable, skip silently and proceed
|
|
|
32
33
|
|
|
33
34
|
Read `{ccc}` and `{ccc_skip_index}` from context.
|
|
34
35
|
|
|
35
|
-
**If `{ccc}` is false:** Set `{ccc_index_result: "none", ccc_indexed_path: null, ccc_last_indexed: null, ccc_exclude_patterns: [], ccc_exclusion_warnings: [], settings_yml_written: false, settings_yml_patterns_added: 0}`. Proceed directly to section 5 (Auto-Proceed) — no output, no messaging.
|
|
36
|
+
**If `{ccc}` is false:** Set `{ccc_index_result: "none", ccc_indexed_path: null, ccc_last_indexed: null, ccc_file_count: null, ccc_exclude_patterns: [], ccc_exclusion_warnings: [], settings_yml_written: false, settings_yml_patterns_added: 0}`. Proceed directly to section 5 (Auto-Proceed) — no output, no messaging.
|
|
36
37
|
|
|
37
|
-
**If `{ccc}` is true AND `{ccc_skip_index}` is true:** Run the exclusion-merge (section 3) so settings.yml stays current, then set `{ccc_index_result: "skipped", ccc_indexed_path: null, ccc_last_indexed: null}` and proceed to section 5 (Auto-Proceed) — do not run `ccc init` or `ccc index`. The envelope's `ccc_index.status` will be `"skipped"` so pipelines that plan an out-of-band re-index can distinguish "the operator opted out" from "indexing failed".
|
|
38
|
+
**If `{ccc}` is true AND `{ccc_skip_index}` is true:** Run the exclusion-merge (section 3) so settings.yml stays current, then set `{ccc_index_result: "skipped", ccc_indexed_path: null, ccc_last_indexed: null, ccc_file_count: null}` and proceed to section 5 (Auto-Proceed) — do not run `ccc init` or `ccc index`. The envelope's `ccc_index.status` will be `"skipped"` so pipelines that plan an out-of-band re-index can distinguish "the operator opted out" from "indexing failed".
|
|
38
39
|
|
|
39
40
|
**If `{ccc}` is true AND `{ccc_skip_index}` is false:** Continue to section 2.
|
|
40
41
|
|
|
@@ -44,7 +45,7 @@ Consume prior CCC state from stage 1's detector output (`prior.*` context flags)
|
|
|
44
45
|
|
|
45
46
|
Decide `{needs_reindex}` and `{ccc_index_result}` from `{ccc_index_fresh}`:
|
|
46
47
|
|
|
47
|
-
- If `{ccc_index_fresh}` is true → index is fresh. Set `{needs_reindex: false}`, `{ccc_index_result: "fresh", ccc_indexed_path: {project-root}, ccc_last_indexed: {previous_ccc_last_indexed}}
|
|
48
|
+
- If `{ccc_index_fresh}` is true → index is fresh. Set `{needs_reindex: false}`, `{ccc_index_result: "fresh", ccc_indexed_path: {project-root}, ccc_last_indexed: {previous_ccc_last_indexed}, ccc_file_count: {previous_ccc_file_count}}` (the count carried over from the prior forge-tier.yaml by step 1 — nothing re-counts on this path). Exclusions still merge in section 3 (which may force a re-index).
|
|
48
49
|
- If `{ccc_index_fresh}` is false (path mismatch, non-fresh status, stale timestamp, or any required prior CCC field null) → `{needs_reindex: true}`.
|
|
49
50
|
|
|
50
51
|
### 3. Merge SKF Exclusion Patterns
|
|
@@ -111,7 +112,7 @@ ccc index
|
|
|
111
112
|
|
|
112
113
|
**If fails:**
|
|
113
114
|
|
|
114
|
-
- Store `{ccc_index_result: "failed", ccc_indexed_path: null, ccc_last_indexed: null, ccc_indexing_failed_reason: {error}}` (the failed-reason flag flows into step 4's envelope warnings)
|
|
115
|
+
- Store `{ccc_index_result: "failed", ccc_indexed_path: null, ccc_last_indexed: null, ccc_file_count: null, ccc_indexing_failed_reason: {error}}` (the failed-reason flag flows into step 4's envelope warnings)
|
|
115
116
|
- Display: "CCC indexing failed: {error}. Extraction will use direct AST scanning — semantic pre-ranking unavailable this session."
|
|
116
117
|
- Continue — this is not a workflow error
|
|
117
118
|
|
|
@@ -100,6 +100,7 @@ From `prior` (populated by `--prior-state-from`; all fields null/empty on first
|
|
|
100
100
|
- `{previous_ccc_indexed_path}` ← `prior.previous_ccc_indexed_path`
|
|
101
101
|
- `{previous_ccc_last_indexed}` ← `prior.previous_ccc_last_indexed`
|
|
102
102
|
- `{previous_ccc_staleness_threshold_hours}` ← `prior.previous_ccc_staleness_threshold_hours`
|
|
103
|
+
- `{previous_ccc_file_count}` ← `prior.previous_ccc_file_count` (integer or null; step 1b carries it forward on the fresh-index path so `forge-tier.yaml` keeps its `file_count` across re-runs that do not re-index)
|
|
103
104
|
- `{ccc_index_fresh}` ← `prior.ccc_index_fresh` (boolean; the script's deterministic freshness verdict — prior index covers this project AND status was fresh/created AND `last_indexed` is within the staleness threshold of now. Step 1b branches on this directly instead of doing timestamp math.)
|
|
104
105
|
|
|
105
106
|
From `deltas` (computed by the script from current tools + prior; eliminates LLM-side set arithmetic in the report banner):
|
|
@@ -65,7 +65,7 @@ Verify the merged SKILL.md that step 4 section 6b wrote to disk, then write the
|
|
|
65
65
|
|
|
66
66
|
**Used by:** §7 (`skill-check check --fix` and `skill-check split-body --write`), and any future tool invocation that may modify SKILL.md's frontmatter on disk.
|
|
67
67
|
|
|
68
|
-
Load `{descriptionGuardProtocol}` for the full prose explanation of the four-phase guard (why it exists, what counts as divergence, why token-stream comparison is the right shape). The deterministic phases are executed via `{descriptionGuardHelper}` — §7 invokes the helper at the capture and verify-restore points around every `skill-check` call.
|
|
68
|
+
Load `{descriptionGuardProtocol}` for the full prose explanation of the four-phase guard (why it exists, what counts as divergence, why token-stream comparison is the right shape). The deterministic phases are executed via `{descriptionGuardHelper}` — §7 invokes the helper at the capture and verify-restore points around every `skill-check` call. `verify-restore` refuses an empty `--captured-description` (exit 1, file untouched); when that happens, follow the protocol's empty-snapshot rule instead of re-running with the empty value.
|
|
69
69
|
|
|
70
70
|
Update-skill does not run the optional post-restore frontmatter re-validation today — the post-write checks in §1 catch downstream issues, and a `restored: true` outcome is already surfaced through the evidence report (§4).
|
|
71
71
|
|
|
@@ -203,7 +203,7 @@ Append update operation section to `{forge_version}/evidence-report.md` (create
|
|
|
203
203
|
- Notes: {one-sentence detail or —}
|
|
204
204
|
```
|
|
205
205
|
|
|
206
|
-
**Description Guard population** (used by §7 Post-Write Validation when the §0 protocol fires): fill all four fields from context when `description_guard_restored == true` (triggering tool, whether restore succeeded, what changed). When `Restored: false`, the other three fields are `—` — this is the clean-run expected state. Same field semantics and populator logic as create-skill step 6 §8.
|
|
206
|
+
**Description Guard population** (used by §7 Post-Write Validation when the §0 protocol fires): fill all four fields from context when `description_guard_restored == true` (triggering tool, whether restore succeeded, what changed). When `Restored: false`, the other three fields are `—` — this is the clean-run expected state — except when `description_guard_refused == "empty-capture"` (the §0 protocol's empty-snapshot rule): then set `Triggering tool` to the recorded tool name, `Original description preserved: false`, and `Notes: guard refused — empty captured snapshot (empty-capture)`, so a refused restore is distinguishable from a run where the guard never fired. Same field semantics and populator logic as create-skill step 6 §8.
|
|
207
207
|
|
|
208
208
|
**Context Snippet population** (used by §5 after the staleness check runs): §4 writes the sub-block with placeholders; §5 updates the on-disk evidence report in place after deciding whether to regenerate. Set `Regenerated: true` and populate `Triggers fired` with any combination of `headline-exports`, `version`, `gotchas` when at least one trigger fired. Set `Regenerated: false` and `Triggers fired: —` when none fired (the gap-driven / internals-only outcome). Always fill `Notes` with a one-sentence reason (e.g., `"Gap-driven repair — no snippet surface changed"`, `"Version bumped 0.1.0 → 0.2.0; headline exports re-ranked"`).
|
|
209
209
|
|