bmad-module-skill-forge 1.3.0 → 1.4.1
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/docs/_data/pinned.yaml +1 -1
- package/docs/workflows.md +34 -15
- package/package.json +2 -2
- package/src/shared/scripts/schemas/skf-brief-result-envelope.v1.json +58 -0
- package/src/shared/scripts/schemas/skill-brief.v1.json +77 -0
- package/src/shared/scripts/schemas/workspace-detection.v1.json +44 -0
- package/src/shared/scripts/skf-detect-language.py +277 -0
- package/src/shared/scripts/skf-detect-workspaces.py +427 -0
- package/src/shared/scripts/skf-emit-brief-result-envelope.py +257 -0
- package/src/shared/scripts/skf-extract-public-api.py +29 -0
- package/src/shared/scripts/skf-forge-tier-rw.py +73 -0
- package/src/shared/scripts/skf-merge-ccc-exclusions.py +14 -0
- package/src/shared/scripts/skf-recommend-scope-type.py +369 -0
- package/src/shared/scripts/skf-validate-brief-inputs.py +293 -0
- package/src/shared/scripts/skf-write-skill-brief.py +509 -0
- package/src/skf-audit-skill/steps-c/step-01-init.md +49 -3
- package/src/skf-audit-skill/steps-c/step-03-structural-diff.md +6 -5
- package/src/skf-brief-skill/SKILL.md +41 -12
- package/src/skf-brief-skill/assets/description-voice-examples.md +19 -0
- package/src/skf-brief-skill/assets/scope-templates.md +5 -0
- package/src/skf-brief-skill/assets/skill-brief-schema.md +1 -40
- package/src/skf-brief-skill/references/draft-checkpoint.md +46 -0
- package/src/skf-brief-skill/references/headless-args.md +22 -0
- package/src/skf-brief-skill/references/headless-source-authority-detection.md +26 -0
- package/src/skf-brief-skill/references/portfolio-similarity-check.md +35 -0
- package/src/skf-brief-skill/references/qmd-collection-registration.md +52 -0
- package/src/skf-brief-skill/references/version-resolution.md +46 -0
- package/src/skf-brief-skill/steps-c/step-01-gather-intent.md +164 -14
- package/src/skf-brief-skill/steps-c/step-02-analyze-target.md +118 -50
- package/src/skf-brief-skill/steps-c/step-03-scope-definition.md +48 -5
- package/src/skf-brief-skill/steps-c/step-04-confirm-brief.md +33 -19
- package/src/skf-brief-skill/steps-c/step-05-write-brief.md +93 -97
- package/src/skf-brief-skill/steps-c/step-06-health-check.md +11 -2
- package/src/skf-create-skill/steps-c/step-07-generate-artifacts.md +9 -1
- package/src/skf-export-skill/steps-c/step-01-load-skill.md +11 -3
- package/src/skf-export-skill/steps-c/step-03-generate-snippet.md +8 -2
- package/src/skf-export-skill/steps-c/step-04-update-context.md +29 -0
- package/src/skf-export-skill/steps-c/step-06-summary.md +12 -0
- package/src/skf-setup/steps-c/step-01b-ccc-index.md +10 -3
|
@@ -0,0 +1,509 @@
|
|
|
1
|
+
# /// script
|
|
2
|
+
# requires-python = ">=3.10"
|
|
3
|
+
# dependencies = ["pyyaml"]
|
|
4
|
+
# ///
|
|
5
|
+
"""SKF Write Skill Brief — Schema-validated atomic writer for skill-brief.yaml.
|
|
6
|
+
|
|
7
|
+
Replaces the prose-driven YAML emission, version-precedence resolution,
|
|
8
|
+
conditional optional-field rendering, and non-atomic file write currently
|
|
9
|
+
inlined in `src/skf-brief-skill/steps-c/step-05-write-brief.md` §3-§4.
|
|
10
|
+
|
|
11
|
+
Each of those operations is purely deterministic: there is no LLM
|
|
12
|
+
judgement required to render the YAML, decide which optional fields
|
|
13
|
+
appear, resolve target_version vs. detected vs. default, or write the
|
|
14
|
+
file atomically. Keeping that work in prose creates four schema-drift
|
|
15
|
+
seams (key order, YAML formatting, conditional field inclusion rules,
|
|
16
|
+
atomic-write behaviour) that the LLM cannot fully close on every
|
|
17
|
+
invocation. This script is the single source of truth.
|
|
18
|
+
|
|
19
|
+
Subcommand:
|
|
20
|
+
|
|
21
|
+
write Read brief context as JSON on stdin, validate against
|
|
22
|
+
src/shared/scripts/schemas/skill-brief.v1.json, apply
|
|
23
|
+
version-precedence rules, render the canonical YAML, and
|
|
24
|
+
atomically write to --target. Emits a JSON success envelope
|
|
25
|
+
on stdout.
|
|
26
|
+
|
|
27
|
+
Context payload shape (consumed by `write`):
|
|
28
|
+
|
|
29
|
+
{
|
|
30
|
+
"name": "marked",
|
|
31
|
+
"version_resolved": "1.2.3", # OR omit and provide:
|
|
32
|
+
"target_version": "1.2.3" | null,
|
|
33
|
+
"detected_version": "1.2.3" | null,
|
|
34
|
+
|
|
35
|
+
"source_type": "source" | "docs-only",
|
|
36
|
+
"source_repo": "https://github.com/...",
|
|
37
|
+
"language": "javascript",
|
|
38
|
+
"description": "...",
|
|
39
|
+
"forge_tier": "Quick" | "Forge" | "Forge+" | "Deep",
|
|
40
|
+
"created": "2026-05-02", # ISO date
|
|
41
|
+
"created_by": "armel",
|
|
42
|
+
|
|
43
|
+
"scope": {
|
|
44
|
+
"type": "full-library" | ...,
|
|
45
|
+
"include": ["src/**/*.ts"],
|
|
46
|
+
"exclude": ["**/*.test.*"],
|
|
47
|
+
"notes": ""
|
|
48
|
+
},
|
|
49
|
+
|
|
50
|
+
# Conditionally present:
|
|
51
|
+
"doc_urls": [{"url": "...", "label": "..."}],
|
|
52
|
+
"scripts_intent": "detect" | "none" | free-text,
|
|
53
|
+
"assets_intent": "detect" | "none" | free-text,
|
|
54
|
+
"source_authority": "official" | "community" | "internal"
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
Version precedence (resolved into the rendered YAML's `version` field):
|
|
58
|
+
1. version_resolved if explicitly supplied (caller already ran the
|
|
59
|
+
precedence rule). Used by step-05 when it has confirmed values.
|
|
60
|
+
2. Otherwise: target_version if non-null.
|
|
61
|
+
3. Otherwise: detected_version if non-null.
|
|
62
|
+
4. Otherwise: "1.0.0".
|
|
63
|
+
|
|
64
|
+
When target_version is set, the rendered YAML includes a `target_version`
|
|
65
|
+
field whose value MUST match `version` (the script enforces this
|
|
66
|
+
invariant before write — refuses to emit a brief that violates it).
|
|
67
|
+
|
|
68
|
+
Flat input form (`--from-flat`):
|
|
69
|
+
|
|
70
|
+
Identical semantics, friendlier shape for prose-driven callers — scope
|
|
71
|
+
is split across four top-level keys instead of nested, and every
|
|
72
|
+
optional field can be passed as `null` without the caller deciding
|
|
73
|
+
what to omit. The script translates flat → nested and runs the same
|
|
74
|
+
validator + writer pipeline.
|
|
75
|
+
|
|
76
|
+
{
|
|
77
|
+
"name": "marked",
|
|
78
|
+
"target_version": "1.2.3" | null,
|
|
79
|
+
"detected_version": "1.2.3" | null,
|
|
80
|
+
"source_type": "source",
|
|
81
|
+
"source_repo": "https://github.com/...",
|
|
82
|
+
"language": "javascript",
|
|
83
|
+
"description": "...",
|
|
84
|
+
"forge_tier": "Quick",
|
|
85
|
+
"created": "2026-05-02",
|
|
86
|
+
"created_by": "armel",
|
|
87
|
+
"scope_type": "full-library",
|
|
88
|
+
"scope_include": ["src/**/*.ts"],
|
|
89
|
+
"scope_exclude": ["**/*.test.*"],
|
|
90
|
+
"scope_notes": "",
|
|
91
|
+
"doc_urls": null | [...],
|
|
92
|
+
"scripts_intent": null | "detect" | "none" | "...",
|
|
93
|
+
"assets_intent": null | "detect" | "none" | "...",
|
|
94
|
+
"source_authority": null | "official" | "community" | "internal"
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
Output (success):
|
|
98
|
+
|
|
99
|
+
{
|
|
100
|
+
"status": "ok",
|
|
101
|
+
"brief_path": "/abs/path/skill-brief.yaml",
|
|
102
|
+
"version": "<resolved version>",
|
|
103
|
+
"bytes": <integer>,
|
|
104
|
+
"warnings": ["string", ...]
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
Errors emit `{"status": "error", "message": "...", "field": "..."|null}`
|
|
108
|
+
to stderr and exit non-zero.
|
|
109
|
+
|
|
110
|
+
Exit codes:
|
|
111
|
+
0 — success
|
|
112
|
+
1 — validation failure (bad context, schema violation, invariant
|
|
113
|
+
violation, version-precedence underflow with no fallback path)
|
|
114
|
+
2 — I/O failure (atomic write failed, parent directory not writable)
|
|
115
|
+
|
|
116
|
+
Cross-platform: pure stdlib + PyYAML. Atomic write via temp + fsync +
|
|
117
|
+
rename, mirroring skf-atomic-write.py and the helper in
|
|
118
|
+
skf-forge-tier-rw.py.
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
from __future__ import annotations
|
|
122
|
+
|
|
123
|
+
import argparse
|
|
124
|
+
import json
|
|
125
|
+
import os
|
|
126
|
+
import re
|
|
127
|
+
import sys
|
|
128
|
+
from pathlib import Path
|
|
129
|
+
from typing import Any
|
|
130
|
+
|
|
131
|
+
import yaml
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
KEBAB_RE = re.compile(r"^[a-z0-9]([a-z0-9-]*[a-z0-9])?$")
|
|
135
|
+
SEMVER_RE = re.compile(
|
|
136
|
+
r"^v?\d+\.\d+\.\d+([.\-+][0-9A-Za-z][0-9A-Za-z.\-+]*)?$"
|
|
137
|
+
)
|
|
138
|
+
ISO_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
|
139
|
+
|
|
140
|
+
VALID_SOURCE_TYPES = {"source", "docs-only"}
|
|
141
|
+
VALID_SOURCE_AUTHORITIES = {"official", "community", "internal"}
|
|
142
|
+
VALID_FORGE_TIERS = {"Quick", "Forge", "Forge+", "Deep"}
|
|
143
|
+
VALID_SCOPE_TYPES = {
|
|
144
|
+
"full-library",
|
|
145
|
+
"specific-modules",
|
|
146
|
+
"public-api",
|
|
147
|
+
"component-library",
|
|
148
|
+
"reference-app",
|
|
149
|
+
"docs-only",
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _die(message: str, field: str | None = None, code: int = 1) -> None:
|
|
154
|
+
payload = {"status": "error", "message": message}
|
|
155
|
+
if field is not None:
|
|
156
|
+
payload["field"] = field
|
|
157
|
+
sys.stderr.write(json.dumps(payload) + "\n")
|
|
158
|
+
sys.exit(code)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def resolve_version(ctx: dict[str, Any]) -> str:
|
|
162
|
+
"""Apply the version-precedence rule. Returns the resolved version string.
|
|
163
|
+
|
|
164
|
+
Uses `is not None` checks (not truthiness) so an explicitly-supplied
|
|
165
|
+
empty string surfaces as a SEMVER_RE validation failure downstream
|
|
166
|
+
rather than silently falling through to the next precedence level.
|
|
167
|
+
"""
|
|
168
|
+
vr = ctx.get("version_resolved")
|
|
169
|
+
if vr is not None:
|
|
170
|
+
return vr
|
|
171
|
+
tv = ctx.get("target_version")
|
|
172
|
+
if tv is not None:
|
|
173
|
+
return tv
|
|
174
|
+
dv = ctx.get("detected_version")
|
|
175
|
+
if dv is not None:
|
|
176
|
+
return dv
|
|
177
|
+
return "1.0.0"
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def validate_context(ctx: dict[str, Any]) -> list[str]:
|
|
181
|
+
"""Validate the context payload. Raises via _die on hard errors; returns warnings."""
|
|
182
|
+
warnings: list[str] = []
|
|
183
|
+
|
|
184
|
+
# Required string fields
|
|
185
|
+
for field in ("name", "source_repo", "language", "description",
|
|
186
|
+
"forge_tier", "created", "created_by"):
|
|
187
|
+
v = ctx.get(field)
|
|
188
|
+
if not v or not isinstance(v, str):
|
|
189
|
+
_die(f"required field {field!r} missing or not a non-empty string", field=field)
|
|
190
|
+
|
|
191
|
+
# Name must be kebab
|
|
192
|
+
if not KEBAB_RE.match(ctx["name"]):
|
|
193
|
+
_die(
|
|
194
|
+
f"name must be kebab-case (lowercase letters/digits/hyphens, no leading/trailing hyphen). "
|
|
195
|
+
f"Got: {ctx['name']!r}",
|
|
196
|
+
field="name",
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
# forge_tier enum
|
|
200
|
+
if ctx["forge_tier"] not in VALID_FORGE_TIERS:
|
|
201
|
+
_die(
|
|
202
|
+
f"forge_tier must be one of {sorted(VALID_FORGE_TIERS)}. Got: {ctx['forge_tier']!r}",
|
|
203
|
+
field="forge_tier",
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
# created ISO date
|
|
207
|
+
if not ISO_DATE_RE.match(ctx["created"]):
|
|
208
|
+
_die(
|
|
209
|
+
f"created must be an ISO date (YYYY-MM-DD). Got: {ctx['created']!r}",
|
|
210
|
+
field="created",
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
# source_type (default 'source') and conditional doc_urls
|
|
214
|
+
source_type = ctx.get("source_type", "source")
|
|
215
|
+
if source_type not in VALID_SOURCE_TYPES:
|
|
216
|
+
_die(
|
|
217
|
+
f"source_type must be one of {sorted(VALID_SOURCE_TYPES)}. Got: {source_type!r}",
|
|
218
|
+
field="source_type",
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
doc_urls = ctx.get("doc_urls")
|
|
222
|
+
if source_type == "docs-only":
|
|
223
|
+
if not doc_urls or not isinstance(doc_urls, list) or len(doc_urls) == 0:
|
|
224
|
+
_die("source_type=docs-only requires at least one entry in doc_urls", field="doc_urls")
|
|
225
|
+
if doc_urls is not None:
|
|
226
|
+
if not isinstance(doc_urls, list):
|
|
227
|
+
_die("doc_urls must be an array of objects", field="doc_urls")
|
|
228
|
+
for i, entry in enumerate(doc_urls):
|
|
229
|
+
if not isinstance(entry, dict):
|
|
230
|
+
_die(f"doc_urls[{i}] must be an object with at least a 'url' field", field="doc_urls")
|
|
231
|
+
url = entry.get("url")
|
|
232
|
+
if not url or not isinstance(url, str):
|
|
233
|
+
_die(f"doc_urls[{i}].url is required and must be a non-empty string", field="doc_urls")
|
|
234
|
+
|
|
235
|
+
# source_authority (default 'community') with docs-only force rule
|
|
236
|
+
source_authority = ctx.get("source_authority", "community")
|
|
237
|
+
if source_authority not in VALID_SOURCE_AUTHORITIES:
|
|
238
|
+
_die(
|
|
239
|
+
f"source_authority must be one of {sorted(VALID_SOURCE_AUTHORITIES)}. Got: {source_authority!r}",
|
|
240
|
+
field="source_authority",
|
|
241
|
+
)
|
|
242
|
+
if source_type == "docs-only" and source_authority != "community":
|
|
243
|
+
warnings.append(
|
|
244
|
+
f"source_authority forced to 'community' for docs-only (was {source_authority!r})"
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
# scope object
|
|
248
|
+
scope = ctx.get("scope")
|
|
249
|
+
if not isinstance(scope, dict):
|
|
250
|
+
_die("scope must be an object", field="scope")
|
|
251
|
+
for sf in ("type", "include", "exclude", "notes"):
|
|
252
|
+
if sf not in scope:
|
|
253
|
+
_die(f"scope.{sf} is required", field=f"scope.{sf}")
|
|
254
|
+
if scope["type"] not in VALID_SCOPE_TYPES:
|
|
255
|
+
_die(
|
|
256
|
+
f"scope.type must be one of {sorted(VALID_SCOPE_TYPES)}. Got: {scope['type']!r}",
|
|
257
|
+
field="scope.type",
|
|
258
|
+
)
|
|
259
|
+
if not isinstance(scope["include"], list):
|
|
260
|
+
_die("scope.include must be an array of glob strings", field="scope.include")
|
|
261
|
+
if not isinstance(scope["exclude"], list):
|
|
262
|
+
_die("scope.exclude must be an array of glob strings", field="scope.exclude")
|
|
263
|
+
if not isinstance(scope["notes"], str):
|
|
264
|
+
_die("scope.notes must be a string (use empty string when no notes)", field="scope.notes")
|
|
265
|
+
|
|
266
|
+
# target_version semver shape (when present)
|
|
267
|
+
tv = ctx.get("target_version")
|
|
268
|
+
if tv is not None:
|
|
269
|
+
if not isinstance(tv, str) or not SEMVER_RE.match(tv):
|
|
270
|
+
_die(
|
|
271
|
+
f"target_version must be full X.Y.Z semver (with optional v prefix and pre-release/build). "
|
|
272
|
+
f"Got: {tv!r}",
|
|
273
|
+
field="target_version",
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
detected = ctx.get("detected_version")
|
|
277
|
+
if detected is not None and (not isinstance(detected, str) or not SEMVER_RE.match(detected)):
|
|
278
|
+
# Warn rather than HALT — auto-detection upstream may surface odd shapes
|
|
279
|
+
warnings.append(
|
|
280
|
+
f"detected_version {detected!r} is not full X.Y.Z semver — falling through to default 1.0.0"
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
return warnings
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def assemble_brief(ctx: dict[str, Any], resolved_version: str) -> dict[str, Any]:
|
|
287
|
+
"""Build the final brief dict that will be YAML-dumped, in canonical key order."""
|
|
288
|
+
source_type = ctx.get("source_type", "source")
|
|
289
|
+
source_authority = ctx.get("source_authority", "community")
|
|
290
|
+
if source_type == "docs-only":
|
|
291
|
+
source_authority = "community" # forced
|
|
292
|
+
|
|
293
|
+
brief: dict[str, Any] = {
|
|
294
|
+
"name": ctx["name"],
|
|
295
|
+
"version": resolved_version,
|
|
296
|
+
"source_type": source_type,
|
|
297
|
+
"source_repo": ctx["source_repo"],
|
|
298
|
+
"language": ctx["language"],
|
|
299
|
+
"description": ctx["description"],
|
|
300
|
+
"forge_tier": ctx["forge_tier"],
|
|
301
|
+
"created": ctx["created"],
|
|
302
|
+
"created_by": ctx["created_by"],
|
|
303
|
+
"scope": {
|
|
304
|
+
"type": ctx["scope"]["type"],
|
|
305
|
+
"include": list(ctx["scope"]["include"]),
|
|
306
|
+
"exclude": list(ctx["scope"]["exclude"]),
|
|
307
|
+
"notes": ctx["scope"]["notes"],
|
|
308
|
+
},
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
# Conditional: target_version (must equal version)
|
|
312
|
+
tv = ctx.get("target_version")
|
|
313
|
+
if tv is not None:
|
|
314
|
+
if tv != resolved_version:
|
|
315
|
+
_die(
|
|
316
|
+
f"invariant violation: target_version ({tv!r}) must equal version "
|
|
317
|
+
f"({resolved_version!r}); see references/version-resolution.md",
|
|
318
|
+
field="target_version",
|
|
319
|
+
)
|
|
320
|
+
brief["target_version"] = tv
|
|
321
|
+
|
|
322
|
+
# Conditional: doc_urls (always emitted when present)
|
|
323
|
+
doc_urls = ctx.get("doc_urls")
|
|
324
|
+
if doc_urls:
|
|
325
|
+
brief["doc_urls"] = [
|
|
326
|
+
{"url": e["url"], "label": e.get("label", "")} for e in doc_urls
|
|
327
|
+
]
|
|
328
|
+
|
|
329
|
+
# Conditional: scripts_intent / assets_intent — emit when explicitly non-detect
|
|
330
|
+
for intent_field in ("scripts_intent", "assets_intent"):
|
|
331
|
+
v = ctx.get(intent_field)
|
|
332
|
+
if v is not None and v != "detect":
|
|
333
|
+
brief[intent_field] = v
|
|
334
|
+
|
|
335
|
+
# Emit source_authority only when non-default — schema lists it as Optional
|
|
336
|
+
# in src/skf-brief-skill/assets/skill-brief-schema.md, and unconditional
|
|
337
|
+
# emission would inject the field into round-tripped briefs that previously
|
|
338
|
+
# omitted it (false-drift signal for diff tooling). Consumers default to
|
|
339
|
+
# "community" when the field is absent.
|
|
340
|
+
if source_authority != "community":
|
|
341
|
+
brief["source_authority"] = source_authority
|
|
342
|
+
|
|
343
|
+
return brief
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def render_yaml(brief: dict[str, Any]) -> str:
|
|
347
|
+
"""Dump the brief dict as YAML in canonical key order with a leading document marker.
|
|
348
|
+
|
|
349
|
+
The step-05 §3 template shows leading and trailing `---` markers, but those were
|
|
350
|
+
wrapping the example YAML for documentation purposes — actual on-disk YAML uses
|
|
351
|
+
only the leading `---` (or none). A trailing `---` would start a second empty
|
|
352
|
+
document and break callers that use `yaml.safe_load` (which expects a single
|
|
353
|
+
document) — and skf-create-skill / audit-skill / update-skill all use
|
|
354
|
+
`yaml.safe_load`, so consistency with their loaders matters.
|
|
355
|
+
"""
|
|
356
|
+
body = yaml.safe_dump(
|
|
357
|
+
brief,
|
|
358
|
+
default_flow_style=False,
|
|
359
|
+
sort_keys=False,
|
|
360
|
+
allow_unicode=True,
|
|
361
|
+
)
|
|
362
|
+
return "---\n" + body
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def atomic_write(target: Path, content: str) -> int:
|
|
366
|
+
"""Crash-safe write via temp + fsync + rename. Returns bytes written."""
|
|
367
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
368
|
+
tmp = target.with_name(target.name + ".skf-tmp")
|
|
369
|
+
encoded = content.encode("utf-8")
|
|
370
|
+
try:
|
|
371
|
+
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o644)
|
|
372
|
+
try:
|
|
373
|
+
os.write(fd, encoded)
|
|
374
|
+
os.fsync(fd)
|
|
375
|
+
finally:
|
|
376
|
+
os.close(fd)
|
|
377
|
+
os.replace(tmp, target)
|
|
378
|
+
except OSError as e:
|
|
379
|
+
if tmp.exists():
|
|
380
|
+
try:
|
|
381
|
+
tmp.unlink()
|
|
382
|
+
except OSError:
|
|
383
|
+
pass
|
|
384
|
+
_die(f"atomic write failed for {target}: {e}", code=2)
|
|
385
|
+
return len(encoded)
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
# Top-level keys that get folded into the nested `scope` sub-object — every
|
|
389
|
+
# other top-level key passes through unchanged so future additions to the
|
|
390
|
+
# schema don't require a translator update.
|
|
391
|
+
_FLAT_SCOPE_KEYS = ("scope_type", "scope_include", "scope_exclude", "scope_notes")
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def flat_to_nested(flat: dict[str, Any]) -> dict[str, Any]:
|
|
395
|
+
"""Translate the flat brief-context shape into the nested shape consumed by validate_context.
|
|
396
|
+
|
|
397
|
+
Flat shape: scope is split across four top-level keys (`scope_type`,
|
|
398
|
+
`scope_include`, `scope_exclude`, `scope_notes`) instead of nested
|
|
399
|
+
under a `scope` object. Optional top-level fields (`doc_urls`,
|
|
400
|
+
`scripts_intent`, `assets_intent`, `source_authority`,
|
|
401
|
+
`target_version`, `detected_version`) may be absent or null —
|
|
402
|
+
they are simply dropped from the nested output, which matches the
|
|
403
|
+
existing validator's `ctx.get(...) is None` semantics.
|
|
404
|
+
|
|
405
|
+
Any unknown top-level keys are passed through unchanged so future
|
|
406
|
+
additions don't require a translator update.
|
|
407
|
+
"""
|
|
408
|
+
if not isinstance(flat, dict):
|
|
409
|
+
_die("write --from-flat: payload must be a JSON object")
|
|
410
|
+
|
|
411
|
+
nested: dict[str, Any] = {}
|
|
412
|
+
|
|
413
|
+
# Pass through every top-level key that isn't part of the flat-scope
|
|
414
|
+
# split. Drop None values so optional fields behave as "absent" in
|
|
415
|
+
# the nested form (validate_context uses `is not None` checks).
|
|
416
|
+
for key, value in flat.items():
|
|
417
|
+
if key in _FLAT_SCOPE_KEYS:
|
|
418
|
+
continue
|
|
419
|
+
if value is None:
|
|
420
|
+
continue
|
|
421
|
+
nested[key] = value
|
|
422
|
+
|
|
423
|
+
# Build the nested scope object. All four scope_* keys (type, include,
|
|
424
|
+
# exclude, notes) are required at the schema level — null is
|
|
425
|
+
# intentionally treated as "absent" here (same as omitting the key)
|
|
426
|
+
# so validate_context surfaces `scope.<field> is required` rather
|
|
427
|
+
# than e.g. `scope.notes must be a string`. The `""` valid minimum
|
|
428
|
+
# for scope_notes must therefore be passed as the literal empty
|
|
429
|
+
# string, not null.
|
|
430
|
+
if any(k in flat for k in _FLAT_SCOPE_KEYS):
|
|
431
|
+
scope: dict[str, Any] = {}
|
|
432
|
+
if "scope_type" in flat and flat["scope_type"] is not None:
|
|
433
|
+
scope["type"] = flat["scope_type"]
|
|
434
|
+
if "scope_include" in flat and flat["scope_include"] is not None:
|
|
435
|
+
scope["include"] = flat["scope_include"]
|
|
436
|
+
if "scope_exclude" in flat and flat["scope_exclude"] is not None:
|
|
437
|
+
scope["exclude"] = flat["scope_exclude"]
|
|
438
|
+
if "scope_notes" in flat and flat["scope_notes"] is not None:
|
|
439
|
+
scope["notes"] = flat["scope_notes"]
|
|
440
|
+
nested["scope"] = scope
|
|
441
|
+
return nested
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def cmd_write(target: Path, from_flat: bool = False) -> int:
|
|
445
|
+
raw = sys.stdin.read()
|
|
446
|
+
if not raw or not raw.strip():
|
|
447
|
+
_die("write: empty stdin (expected JSON brief context)")
|
|
448
|
+
try:
|
|
449
|
+
ctx = json.loads(raw)
|
|
450
|
+
except json.JSONDecodeError as e:
|
|
451
|
+
_die(f"write: invalid JSON on stdin: {e}")
|
|
452
|
+
if not isinstance(ctx, dict):
|
|
453
|
+
_die("write: context payload must be a JSON object")
|
|
454
|
+
|
|
455
|
+
if from_flat:
|
|
456
|
+
ctx = flat_to_nested(ctx)
|
|
457
|
+
|
|
458
|
+
warnings = validate_context(ctx)
|
|
459
|
+
resolved_version = resolve_version(ctx)
|
|
460
|
+
if not SEMVER_RE.match(resolved_version):
|
|
461
|
+
_die(
|
|
462
|
+
f"resolved version {resolved_version!r} is not full X.Y.Z semver — "
|
|
463
|
+
f"check version_resolved / target_version / detected_version inputs",
|
|
464
|
+
field="version",
|
|
465
|
+
)
|
|
466
|
+
|
|
467
|
+
brief = assemble_brief(ctx, resolved_version)
|
|
468
|
+
content = render_yaml(brief)
|
|
469
|
+
bytes_written = atomic_write(target, content)
|
|
470
|
+
|
|
471
|
+
response = {
|
|
472
|
+
"status": "ok",
|
|
473
|
+
"brief_path": str(target.resolve()),
|
|
474
|
+
"version": resolved_version,
|
|
475
|
+
"bytes": bytes_written,
|
|
476
|
+
"warnings": warnings,
|
|
477
|
+
}
|
|
478
|
+
print(json.dumps(response))
|
|
479
|
+
return 0
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def main() -> int:
|
|
483
|
+
parser = argparse.ArgumentParser(
|
|
484
|
+
prog="skf-write-skill-brief",
|
|
485
|
+
description="Schema-validated atomic writer for skill-brief.yaml.",
|
|
486
|
+
)
|
|
487
|
+
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
488
|
+
p_write = sub.add_parser("write", help="Read brief context JSON on stdin, validate, render YAML, atomic write")
|
|
489
|
+
p_write.add_argument("--target", type=Path, required=True, help="Absolute path to skill-brief.yaml")
|
|
490
|
+
p_write.add_argument(
|
|
491
|
+
"--from-flat",
|
|
492
|
+
action="store_true",
|
|
493
|
+
help=(
|
|
494
|
+
"Accept the flat brief-context shape (scope split across "
|
|
495
|
+
"scope_type/scope_include/scope_exclude/scope_notes top-level keys, "
|
|
496
|
+
"optional fields nullable) instead of the nested shape. Eliminates "
|
|
497
|
+
"the conditional-omit logic the LLM currently walks at the §3 "
|
|
498
|
+
"assembly site in step-05."
|
|
499
|
+
),
|
|
500
|
+
)
|
|
501
|
+
|
|
502
|
+
args = parser.parse_args()
|
|
503
|
+
if args.cmd == "write":
|
|
504
|
+
return cmd_write(args.target, from_flat=args.from_flat)
|
|
505
|
+
return 2
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
if __name__ == "__main__":
|
|
509
|
+
sys.exit(main())
|
|
@@ -29,10 +29,31 @@ Which skill would you like to audit? Please provide the skill name or path."
|
|
|
29
29
|
|
|
30
30
|
**If user provides skill name (not full path) — version-aware path resolution (see `knowledge/version-paths.md`):**
|
|
31
31
|
1. Read `{skills_output_folder}/.export-manifest.json` and look up the skill name in `exports` to get `active_version`
|
|
32
|
-
2. If found: resolve
|
|
32
|
+
2. If found: tentatively resolve `{skill_package}` = `{skills_output_folder}/{skill_name}/{active_version}/{skill_name}/`. **Manifest-vs-symlink drift gate:** before committing, also read the `active` symlink at `{skills_output_folder}/{skill_name}/active`. When the symlink target disagrees with `active_version`, the manifest lags behind on-disk state — typical sequence is `update-skill` flipped the symlink but `export-skill` has not yet rewritten the manifest. Auditing the older manifest version would re-audit a skill the user no longer cares about (or has already audited). Decide which version to audit:
|
|
33
|
+
- Read `forge_data_folder/{skill_name}/{active_version}/provenance-map.json` (manifest version) and `forge_data_folder/{skill_name}/{symlink_target}/provenance-map.json` (symlink target). Compare their `generated_at` timestamps (or `mtime` if the field is absent).
|
|
34
|
+
- If both versions exist and the symlink target's provenance is **fresher** than the manifest's `last_exported`, present a gate:
|
|
35
|
+
|
|
36
|
+
"**Manifest lags behind active symlink.**
|
|
37
|
+
|
|
38
|
+
| | Manifest | Symlink |
|
|
39
|
+
|---|---|---|
|
|
40
|
+
| Version | `{active_version}` | `{symlink_target}` |
|
|
41
|
+
| Exported / forged | `{manifest.last_exported}` | `{symlink_provenance_generated_at}` |
|
|
42
|
+
|
|
43
|
+
The manifest's `active_version` was set by an earlier export-skill run; the symlink was flipped later (typically by update-skill). Auditing the manifest version will re-audit a skill the user no longer treats as active. Options:
|
|
44
|
+
|
|
45
|
+
- **[N] Audit symlink target ({symlink_target})** — recommended. The drift report describes the version the skill currently resolves to.
|
|
46
|
+
- **[M] Audit manifest version ({active_version})** — only useful when investigating the older version specifically.
|
|
47
|
+
- **[X] Abort** — halt without producing a report. Run `[EX] Export Skill` to reconcile the manifest before re-running audit-skill."
|
|
48
|
+
|
|
49
|
+
Default is **[N]**. Headless mode auto-selects **[N]** with a loud log line: `"headless: manifest active_version ({active_version}) is older than symlink target ({symlink_target}); auditing symlink target. Run export-skill to reconcile."` This mirrors §5b's upstream-drift handling — when the manifest and the working tree disagree, the working tree is the more honest signal under automation.
|
|
50
|
+
|
|
51
|
+
- When the symlink target's provenance is **older** than (or equal to) the manifest's `last_exported`, the symlink predates the export — this is the normal post-export shape, no gate needed. Resolve to the manifest's `active_version`.
|
|
52
|
+
- When only one of the two versions has a provenance map, resolve to the version that has one (the other is inert — auditing it would degrade to text-diff). Log the choice.
|
|
53
|
+
|
|
33
54
|
3. If not in manifest: check for `active` symlink at `{skills_output_folder}/{skill_name}/active` — resolve to `{skill_group}/active/{skill_name}/`
|
|
34
55
|
4. If neither: fall back to flat path `{skills_output_folder}/{skill_name}/`. If SKILL.md exists at the flat path, auto-migrate per `knowledge/version-paths.md` migration rules
|
|
35
|
-
5. Store the resolved path as `{resolved_skill_package}`
|
|
56
|
+
5. Store the resolved path as `{resolved_skill_package}`. Also store `audit_target_version` = the version that was actually selected (manifest, symlink, or flat) for step-06 Provenance to surface. When the gate above fired, also record the rejected version under `manifest_symlink_drift = {manifest: {active_version}, symlink: {symlink_target}, audited: {audit_target_version}, reason: {fresher-provenance|operator-choice|headless-default}}` so reviewers can audit the choice.
|
|
36
57
|
|
|
37
58
|
**If user provides full path:**
|
|
38
59
|
- Use as provided
|
|
@@ -161,7 +182,32 @@ When skipping, log the reason, then set the audit-ref context variables to basel
|
|
|
161
182
|
**Select:** [C] / [S] / [X]"
|
|
162
183
|
|
|
163
184
|
**Gate handling:**
|
|
164
|
-
- **[C]:** Acquire an exclusive lock on `{source_root}/.skf-workspace.lock` (`flock -x` or `fcntl.flock(LOCK_EX)`) before mutating the working tree — matches the concurrency discipline in `src/skf-create-skill/references/source-resolution-protocols.md` and avoids racing with a concurrent create-skill / test-skill run against the same workspace clone. If `flock` is unavailable, emit a warning and proceed.
|
|
185
|
+
- **[C]:** Acquire an exclusive lock on `{source_root}/.skf-workspace.lock` (`flock -x` or `fcntl.flock(LOCK_EX)`) before mutating the working tree — matches the concurrency discipline in `src/skf-create-skill/references/source-resolution-protocols.md` and avoids racing with a concurrent create-skill / test-skill run against the same workspace clone. If `flock` is unavailable, emit a warning and proceed.
|
|
186
|
+
|
|
187
|
+
**Dirty-worktree probe (mandatory before checkout).** Run `git -C {source_root} status --porcelain` after acquiring the lock and before the checkout. If the output is non-empty, the working tree has uncommitted changes — `git checkout {chosen_ref}` will abort with `error: Your local changes to the following files would be overwritten by checkout`, halting the workflow mid-step. The most common benign cause is a tooling-generated edit (e.g. the CCC daemon appending a `.cocoindex_code/` line to `.gitignore` after `setup-forge` pointed it at this clone), but the changes could also be the operator's in-progress work. Surface a sub-gate before mutating:
|
|
188
|
+
|
|
189
|
+
"**Working tree has uncommitted changes.** `git status --porcelain` returned:
|
|
190
|
+
|
|
191
|
+
```
|
|
192
|
+
{first 20 lines of porcelain output, ellipsis if more}
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
A `git checkout` would abort. Options:
|
|
196
|
+
- **[T] Transient stash** — `git stash push -m 'skf-audit: pre-checkout' --include-untracked`, perform the checkout, and pop the stash on the way out. Recommended when the changes look tooling-generated (e.g. a `.gitignore` line referencing `.cocoindex_code/`, lockfile churn from an indexer).
|
|
197
|
+
- **[A] Abort** — halt the workflow and let the operator commit, stash, or discard manually before retrying.
|
|
198
|
+
- **[F] Force checkout** — `git checkout --force` discards uncommitted changes irrecoverably. Only choose this after confirming the changes are safe to lose."
|
|
199
|
+
|
|
200
|
+
**Gate handling:**
|
|
201
|
+
- **[T]:** Run `git -C {source_root} stash push -m 'skf-audit-skill: pre-checkout {chosen_ref}' --include-untracked`. Capture the stash ref from the command output (e.g. `stash@{0}`) and store as `pre_checkout_stash_ref` in workflow context for step-06 Provenance to surface. Proceed to the checkout. (After audit completes, the operator restores the stash with `git stash pop` — step-06 puts the literal command in the report as a workflow-level convention rather than per-author ad-hoc prose.)
|
|
202
|
+
- **[A]:** HALT the workflow. Do not write a drift report — the audit was never started.
|
|
203
|
+
- **[F]:** Run `git -C {source_root} checkout --force {chosen_ref}` instead of the plain checkout. Record `pre_checkout_force_discard: true` in workflow context for step-06 to surface as a loud warning. Skip the stash path.
|
|
204
|
+
- **Other input:** help user, redisplay the sub-gate.
|
|
205
|
+
|
|
206
|
+
**Headless default** (when `{headless_mode}`): auto-select **[A] Abort** rather than silently mutating the working tree. Emit a loud log line: `"headless: dirty worktree detected at {source_root}; refusing to checkout {chosen_ref} or stash. Re-run interactively to choose [T]/[A]/[F]."` Stashing under automation could lose work if the operator never returns to pop; force-checkout under automation could destroy uncommitted work outright. Abort is the only safe non-interactive default.
|
|
207
|
+
|
|
208
|
+
If `git status --porcelain` is empty, skip the sub-gate and proceed directly to the checkout.
|
|
209
|
+
|
|
210
|
+
Then `git -C {source_root} checkout {chosen_ref}` (prefer `latest_tag` when present, else `remote_head`). Set `audit_ref = {chosen_ref}`, `audit_ref_source = "checkout-latest"`, `audit_commit = git rev-parse HEAD`. Hold the lock through step-02 re-extraction and release only after the extraction snapshot is complete.
|
|
165
211
|
- **[S]:** Keep baseline. Set `audit_ref = baseline_ref`, `audit_ref_source = "baseline"`, `audit_commit = baseline_commit`.
|
|
166
212
|
- **[X]:** HALT workflow — do not create drift report.
|
|
167
213
|
- **Other input:** help user, redisplay gate.
|
|
@@ -89,11 +89,12 @@ For each changed export, record:
|
|
|
89
89
|
|
|
90
90
|
For each entry in `file_entries`:
|
|
91
91
|
1. Locate the source file at the original `source_file` path
|
|
92
|
-
2. Compute current SHA-256 content hash
|
|
93
|
-
3.
|
|
94
|
-
|
|
95
|
-
-
|
|
96
|
-
-
|
|
92
|
+
2. Compute current SHA-256 content hash (bare hex — `hashlib.sha256(...).hexdigest()`, `sha256sum`, and `openssl dgst` all produce this form)
|
|
93
|
+
3. **Normalize the stored hash before comparison.** `skf-create-skill` writes `content_hash` with a leading algorithm-name prefix (`"sha256:879bfcc2…"`). A bare-hex hash from `hashlib` will never equal the prefixed value byte-for-byte, so without normalization every `file_entry` flags as CHANGED on every audit. Strip the algorithm prefix before comparing — accept any leading lowercase-alphanumeric prefix terminated by `:` (e.g. `sha256:`, `sha1:`, `md5:`); if no prefix is present, leave the value unchanged. The reader-side normalization is unconditionally safe — applying it to bare-hex values produced by a fixed writer is a no-op.
|
|
94
|
+
4. Compare the normalized stored hash against the freshly computed hash:
|
|
95
|
+
- CHANGED: hash mismatch → record as script/asset content drift
|
|
96
|
+
- MISSING: source file no longer exists → record as removed
|
|
97
|
+
- NEW: source contains files matching script/asset patterns not in `file_entries` → record as added
|
|
97
98
|
|
|
98
99
|
Append results to the Structural Drift section as "### Script/Asset Drift ({count})".
|
|
99
100
|
|