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.
Files changed (40) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/docs/_data/pinned.yaml +1 -1
  3. package/docs/workflows.md +34 -15
  4. package/package.json +2 -2
  5. package/src/shared/scripts/schemas/skf-brief-result-envelope.v1.json +58 -0
  6. package/src/shared/scripts/schemas/skill-brief.v1.json +77 -0
  7. package/src/shared/scripts/schemas/workspace-detection.v1.json +44 -0
  8. package/src/shared/scripts/skf-detect-language.py +277 -0
  9. package/src/shared/scripts/skf-detect-workspaces.py +427 -0
  10. package/src/shared/scripts/skf-emit-brief-result-envelope.py +257 -0
  11. package/src/shared/scripts/skf-extract-public-api.py +29 -0
  12. package/src/shared/scripts/skf-forge-tier-rw.py +73 -0
  13. package/src/shared/scripts/skf-merge-ccc-exclusions.py +14 -0
  14. package/src/shared/scripts/skf-recommend-scope-type.py +369 -0
  15. package/src/shared/scripts/skf-validate-brief-inputs.py +293 -0
  16. package/src/shared/scripts/skf-write-skill-brief.py +509 -0
  17. package/src/skf-audit-skill/steps-c/step-01-init.md +49 -3
  18. package/src/skf-audit-skill/steps-c/step-03-structural-diff.md +6 -5
  19. package/src/skf-brief-skill/SKILL.md +41 -12
  20. package/src/skf-brief-skill/assets/description-voice-examples.md +19 -0
  21. package/src/skf-brief-skill/assets/scope-templates.md +5 -0
  22. package/src/skf-brief-skill/assets/skill-brief-schema.md +1 -40
  23. package/src/skf-brief-skill/references/draft-checkpoint.md +46 -0
  24. package/src/skf-brief-skill/references/headless-args.md +22 -0
  25. package/src/skf-brief-skill/references/headless-source-authority-detection.md +26 -0
  26. package/src/skf-brief-skill/references/portfolio-similarity-check.md +35 -0
  27. package/src/skf-brief-skill/references/qmd-collection-registration.md +52 -0
  28. package/src/skf-brief-skill/references/version-resolution.md +46 -0
  29. package/src/skf-brief-skill/steps-c/step-01-gather-intent.md +164 -14
  30. package/src/skf-brief-skill/steps-c/step-02-analyze-target.md +118 -50
  31. package/src/skf-brief-skill/steps-c/step-03-scope-definition.md +48 -5
  32. package/src/skf-brief-skill/steps-c/step-04-confirm-brief.md +33 -19
  33. package/src/skf-brief-skill/steps-c/step-05-write-brief.md +93 -97
  34. package/src/skf-brief-skill/steps-c/step-06-health-check.md +11 -2
  35. package/src/skf-create-skill/steps-c/step-07-generate-artifacts.md +9 -1
  36. package/src/skf-export-skill/steps-c/step-01-load-skill.md +11 -3
  37. package/src/skf-export-skill/steps-c/step-03-generate-snippet.md +8 -2
  38. package/src/skf-export-skill/steps-c/step-04-update-context.md +29 -0
  39. package/src/skf-export-skill/steps-c/step-06-summary.md +12 -0
  40. package/src/skf-setup/steps-c/step-01b-ccc-index.md +10 -3
@@ -0,0 +1,427 @@
1
+ # /// script
2
+ # requires-python = ">=3.11"
3
+ # dependencies = ["pyyaml"]
4
+ # ///
5
+ """SKF Detect Workspaces — pure detector for monorepo / multi-package layouts.
6
+
7
+ Takes a JSON payload on stdin describing a target repo's file tree plus the
8
+ contents of a small set of root manifests, and returns whether a workspace
9
+ layout is present, which manifest kind drives it, and the list of resolved
10
+ workspaces. The helper does NO file I/O — the caller (typically
11
+ skf-brief-skill step-02 §1b) fetches files via `gh api` / local filesystem
12
+ and pipes the relevant content in.
13
+
14
+ Detection runs in priority order; the first matching detector wins:
15
+
16
+ 1. npm-workspaces package.json has `workspaces: [...]` or `{packages: [...]}`
17
+ 2. pnpm-workspaces pnpm-workspace.yaml exists with a `packages:` list
18
+ 3. lerna lerna.json exists (`packages` field optional, defaults to `packages/*`)
19
+ 4. cargo-workspace Cargo.toml has `[workspace]` with `members = [...]`
20
+ 5. python-multi-package multiple pyproject.toml under packages/* or apps/*
21
+ 6. generic-folders apps/, packages/, libs/, or code/ each with subdirs
22
+ containing a recognisable manifest
23
+
24
+ Glob members (`packages/*`, `apps/*`, etc.) are resolved against the supplied
25
+ file tree; only directories whose recognisable manifest is present in the tree
26
+ become workspaces. A detector that finds zero non-excluded matches falls
27
+ through to the next detector instead of returning is_monorepo: true with an
28
+ empty list.
29
+
30
+ Input JSON shape (stdin):
31
+
32
+ {
33
+ "tree": ["package.json", "packages/foo/package.json", "packages/foo/src/index.js", ...],
34
+ "manifests": {
35
+ "package.json": "<raw text>",
36
+ "Cargo.toml": "<raw text>",
37
+ "pnpm-workspace.yaml": "<raw text>",
38
+ "lerna.json": "<raw text>"
39
+ }
40
+ }
41
+
42
+ - `tree` is a flat list of repository-relative file paths (POSIX separators).
43
+ Per-workspace child manifests (e.g. `packages/foo/package.json`) MUST appear
44
+ here for glob resolution; their contents are optional.
45
+ - `manifests` is a dict keyed by repository-relative path; only root-level
46
+ manifests must be supplied. Per-workspace manifest contents may be
47
+ included to populate the workspace `name` field, but absence is fine
48
+ (the path basename is used as a fallback).
49
+
50
+ Output JSON shape (stdout):
51
+
52
+ {
53
+ "is_monorepo": true | false,
54
+ "manifest_kind": "npm-workspaces" | "pnpm-workspaces" | "lerna"
55
+ | "cargo-workspace" | "python-multi-package"
56
+ | "generic-folders" | null,
57
+ "workspaces": [
58
+ {"name": "foo", "path": "packages/foo", "manifest": "packages/foo/package.json"},
59
+ ...
60
+ ],
61
+ "warnings": ["..."]
62
+ }
63
+
64
+ Exit codes:
65
+
66
+ 0 success (regardless of is_monorepo value)
67
+ 1 payload error (malformed top-level JSON shape)
68
+ 2 stdin / argparse / JSON-decode error
69
+ """
70
+
71
+ from __future__ import annotations
72
+
73
+ import argparse
74
+ import fnmatch
75
+ import json
76
+ import re
77
+ import sys
78
+ import tomllib
79
+ from typing import Optional
80
+
81
+
82
+ GENERIC_PARENTS = ("apps", "packages", "libs", "code")
83
+ RECOGNISED_MANIFESTS = (
84
+ "package.json",
85
+ "Cargo.toml",
86
+ "pyproject.toml",
87
+ "setup.py",
88
+ "go.mod",
89
+ "build.gradle",
90
+ "build.gradle.kts",
91
+ "pom.xml",
92
+ )
93
+
94
+
95
+ # --------------------------------------------------------------------------
96
+ # Helpers
97
+ # --------------------------------------------------------------------------
98
+
99
+
100
+ def _normalise_path(path: str) -> str:
101
+ """Strip leading './' and trailing '/' so comparisons are stable."""
102
+ while path.startswith("./"):
103
+ path = path[2:]
104
+ return path.rstrip("/")
105
+
106
+
107
+ def _match_globs(tree: set[str], globs: list[str]) -> list[str]:
108
+ """Resolve workspace globs against the tree.
109
+
110
+ Returns the sorted list of *directory paths* that have at least one matching
111
+ manifest under them. Patterns starting with `!` are exclusions applied after
112
+ the inclusion pass. Brace expansion is not supported (npm/pnpm rarely use
113
+ it in published configs); a literal pattern without `*` is treated as a
114
+ literal directory path.
115
+ """
116
+ includes: list[str] = []
117
+ excludes: list[str] = []
118
+ for raw in globs:
119
+ if not isinstance(raw, str) or not raw.strip():
120
+ continue
121
+ cleaned = _normalise_path(raw.strip())
122
+ if cleaned.startswith("!"):
123
+ excludes.append(cleaned[1:])
124
+ else:
125
+ includes.append(cleaned)
126
+
127
+ candidate_dirs: set[str] = set()
128
+ for pat in includes:
129
+ if "*" in pat:
130
+ for path in tree:
131
+ # match the directory portion of every file path against the glob
132
+ parts = path.split("/")
133
+ for depth in range(1, len(parts)):
134
+ candidate = "/".join(parts[:depth])
135
+ if fnmatch.fnmatchcase(candidate, pat):
136
+ candidate_dirs.add(candidate)
137
+ else:
138
+ # literal directory; include only if the tree contains a manifest under it
139
+ candidate_dirs.add(pat)
140
+
141
+ # Apply excludes
142
+ survivors: set[str] = set()
143
+ for cand in candidate_dirs:
144
+ if any(fnmatch.fnmatchcase(cand, ex) or cand == ex for ex in excludes):
145
+ continue
146
+ survivors.add(cand)
147
+
148
+ return sorted(survivors)
149
+
150
+
151
+ def _find_workspace_manifest(workspace_path: str, tree: set[str]) -> Optional[str]:
152
+ """Return the first recognised manifest under `workspace_path`, or None."""
153
+ for manifest in RECOGNISED_MANIFESTS:
154
+ candidate = f"{workspace_path}/{manifest}"
155
+ if candidate in tree:
156
+ return candidate
157
+ return None
158
+
159
+
160
+ def _read_workspace_name(manifest_path: str, manifests: dict[str, str], fallback: str) -> str:
161
+ """Extract a sensible name from a workspace's manifest.
162
+
163
+ Best-effort: returns the manifest's declared package name when parseable,
164
+ otherwise the directory basename.
165
+ """
166
+ content = manifests.get(manifest_path)
167
+ if content is None:
168
+ return fallback
169
+ if manifest_path.endswith("package.json"):
170
+ try:
171
+ data = json.loads(content)
172
+ name = data.get("name")
173
+ if isinstance(name, str) and name:
174
+ return name
175
+ except (json.JSONDecodeError, AttributeError):
176
+ pass
177
+ elif manifest_path.endswith("Cargo.toml"):
178
+ try:
179
+ data = tomllib.loads(content)
180
+ name = data.get("package", {}).get("name")
181
+ if isinstance(name, str) and name:
182
+ return name
183
+ except tomllib.TOMLDecodeError:
184
+ pass
185
+ elif manifest_path.endswith("pyproject.toml"):
186
+ try:
187
+ data = tomllib.loads(content)
188
+ name = data.get("project", {}).get("name")
189
+ if isinstance(name, str) and name:
190
+ return name
191
+ except tomllib.TOMLDecodeError:
192
+ pass
193
+ return fallback
194
+
195
+
196
+ def _build_workspaces(
197
+ workspace_dirs: list[str],
198
+ tree: set[str],
199
+ manifests: dict[str, str],
200
+ ) -> list[dict]:
201
+ """Resolve each workspace dir to its manifest + name, drop dirs without a manifest."""
202
+ out: list[dict] = []
203
+ for ws_path in workspace_dirs:
204
+ manifest = _find_workspace_manifest(ws_path, tree)
205
+ if manifest is None:
206
+ continue
207
+ fallback_name = ws_path.rsplit("/", 1)[-1] or ws_path
208
+ name = _read_workspace_name(manifest, manifests, fallback_name)
209
+ out.append({"name": name, "path": ws_path, "manifest": manifest})
210
+ return out
211
+
212
+
213
+ # --------------------------------------------------------------------------
214
+ # Detectors
215
+ # --------------------------------------------------------------------------
216
+
217
+
218
+ def detect_npm_workspaces(
219
+ tree: set[str], manifests: dict[str, str], warnings: list[str]
220
+ ) -> Optional[list[dict]]:
221
+ content = manifests.get("package.json")
222
+ if content is None:
223
+ return None
224
+ try:
225
+ data = json.loads(content)
226
+ except json.JSONDecodeError as e:
227
+ warnings.append(f"package.json JSON parse error: {e}")
228
+ return None
229
+ if not isinstance(data, dict):
230
+ return None
231
+ ws = data.get("workspaces")
232
+ if ws is None:
233
+ return None
234
+ if isinstance(ws, dict):
235
+ ws = ws.get("packages", [])
236
+ if not isinstance(ws, list) or not ws:
237
+ return None
238
+ dirs = _match_globs(tree, ws)
239
+ return _build_workspaces(dirs, tree, manifests)
240
+
241
+
242
+ def detect_pnpm_workspaces(
243
+ tree: set[str], manifests: dict[str, str], warnings: list[str]
244
+ ) -> Optional[list[dict]]:
245
+ content = manifests.get("pnpm-workspace.yaml")
246
+ if content is None:
247
+ return None
248
+ try:
249
+ import yaml # local import keeps top-level cheap when YAML is unused
250
+ except ImportError:
251
+ warnings.append("pyyaml not available — pnpm-workspace.yaml not parsed")
252
+ return None
253
+ try:
254
+ data = yaml.safe_load(content)
255
+ except yaml.YAMLError as e:
256
+ warnings.append(f"pnpm-workspace.yaml parse error: {e}")
257
+ return None
258
+ if not isinstance(data, dict):
259
+ return None
260
+ pkgs = data.get("packages")
261
+ if not isinstance(pkgs, list) or not pkgs:
262
+ return None
263
+ dirs = _match_globs(tree, pkgs)
264
+ return _build_workspaces(dirs, tree, manifests)
265
+
266
+
267
+ def detect_lerna(
268
+ tree: set[str], manifests: dict[str, str], warnings: list[str]
269
+ ) -> Optional[list[dict]]:
270
+ content = manifests.get("lerna.json")
271
+ if content is None:
272
+ return None
273
+ try:
274
+ data = json.loads(content)
275
+ except json.JSONDecodeError as e:
276
+ warnings.append(f"lerna.json JSON parse error: {e}")
277
+ return None
278
+ if not isinstance(data, dict):
279
+ return None
280
+ pkgs = data.get("packages")
281
+ if pkgs is None:
282
+ return None
283
+ if not isinstance(pkgs, list) or not pkgs:
284
+ return None
285
+ dirs = _match_globs(tree, pkgs)
286
+ return _build_workspaces(dirs, tree, manifests)
287
+
288
+
289
+ def detect_cargo_workspace(
290
+ tree: set[str], manifests: dict[str, str], warnings: list[str]
291
+ ) -> Optional[list[dict]]:
292
+ content = manifests.get("Cargo.toml")
293
+ if content is None:
294
+ return None
295
+ try:
296
+ data = tomllib.loads(content)
297
+ except tomllib.TOMLDecodeError as e:
298
+ warnings.append(f"Cargo.toml parse error: {e}")
299
+ return None
300
+ workspace = data.get("workspace")
301
+ if not isinstance(workspace, dict):
302
+ return None
303
+ members = workspace.get("members")
304
+ if not isinstance(members, list) or not members:
305
+ return None
306
+ excludes = workspace.get("exclude", [])
307
+ patterns = list(members) + [f"!{e}" for e in excludes if isinstance(e, str)]
308
+ dirs = _match_globs(tree, patterns)
309
+ return _build_workspaces(dirs, tree, manifests)
310
+
311
+
312
+ def detect_python_multi_package(
313
+ tree: set[str], manifests: dict[str, str], warnings: list[str]
314
+ ) -> Optional[list[dict]]:
315
+ candidates: list[str] = []
316
+ for path in tree:
317
+ if not path.endswith("/pyproject.toml"):
318
+ continue
319
+ parent = path[: -len("/pyproject.toml")]
320
+ # match `packages/<x>/pyproject.toml`, `apps/<x>/pyproject.toml`, or `libs/<x>/pyproject.toml`
321
+ if re.match(r"^(packages|apps|libs)/[^/]+$", parent):
322
+ candidates.append(parent)
323
+ if len(candidates) < 2:
324
+ return None
325
+ return _build_workspaces(sorted(set(candidates)), tree, manifests)
326
+
327
+
328
+ def detect_generic_folders(
329
+ tree: set[str], manifests: dict[str, str], warnings: list[str]
330
+ ) -> Optional[list[dict]]:
331
+ """Last-resort: ≥2 manifested subdirs under any combination of apps/, packages/, libs/, code/.
332
+
333
+ Accumulates across all GENERIC_PARENTS — a repo with one manifested child under apps/
334
+ and one under packages/ counts as a 2-workspace monorepo. Earlier detectors (npm, pnpm,
335
+ cargo) take priority for repos with a root workspace manifest.
336
+ """
337
+ discovered: list[str] = []
338
+ for parent in GENERIC_PARENTS:
339
+ children: set[str] = set()
340
+ for path in tree:
341
+ if not path.startswith(parent + "/"):
342
+ continue
343
+ parts = path.split("/")
344
+ if len(parts) < 3:
345
+ continue # need at least parent/child/file
346
+ children.add(f"{parent}/{parts[1]}")
347
+ for child in children:
348
+ if _find_workspace_manifest(child, tree) is not None:
349
+ discovered.append(child)
350
+ if len(discovered) < 2:
351
+ return None
352
+ return _build_workspaces(sorted(set(discovered)), tree, manifests)
353
+
354
+
355
+ DETECTORS: list[tuple[str, callable]] = [
356
+ ("npm-workspaces", detect_npm_workspaces),
357
+ ("pnpm-workspaces", detect_pnpm_workspaces),
358
+ ("lerna", detect_lerna),
359
+ ("cargo-workspace", detect_cargo_workspace),
360
+ ("python-multi-package", detect_python_multi_package),
361
+ ("generic-folders", detect_generic_folders),
362
+ ]
363
+
364
+
365
+ # --------------------------------------------------------------------------
366
+ # Entry point
367
+ # --------------------------------------------------------------------------
368
+
369
+
370
+ def detect(payload: dict) -> dict:
371
+ """Pure detection — the CLI wraps this with stdin/stdout I/O and exit-code mapping."""
372
+ tree_raw = payload.get("tree")
373
+ manifests = payload.get("manifests")
374
+ warnings: list[str] = []
375
+ if not isinstance(tree_raw, list):
376
+ return {"_payload_error": "missing or non-list 'tree' field"}
377
+ if not isinstance(manifests, dict):
378
+ return {"_payload_error": "missing or non-dict 'manifests' field"}
379
+ tree: set[str] = {_normalise_path(p) for p in tree_raw if isinstance(p, str) and p.strip()}
380
+
381
+ for kind, detector in DETECTORS:
382
+ result = detector(tree, manifests, warnings)
383
+ if result and len(result) >= 1:
384
+ return {
385
+ "is_monorepo": True,
386
+ "manifest_kind": kind,
387
+ "workspaces": result,
388
+ "warnings": warnings,
389
+ }
390
+
391
+ return {
392
+ "is_monorepo": False,
393
+ "manifest_kind": None,
394
+ "workspaces": [],
395
+ "warnings": warnings,
396
+ }
397
+
398
+
399
+ def main(argv: Optional[list[str]] = None) -> int:
400
+ parser = argparse.ArgumentParser(description="Detect monorepo / workspace layouts from a tree + manifests payload.")
401
+ parser.add_argument("--json", help="Inline JSON payload (otherwise read from stdin).")
402
+ args = parser.parse_args(argv)
403
+
404
+ raw = args.json
405
+ if raw is None:
406
+ raw = sys.stdin.read()
407
+ if not raw.strip():
408
+ print(json.dumps({"error": "empty input"}), file=sys.stderr)
409
+ return 2
410
+
411
+ try:
412
+ payload = json.loads(raw)
413
+ except json.JSONDecodeError as e:
414
+ print(json.dumps({"error": f"json decode error: {e}"}), file=sys.stderr)
415
+ return 2
416
+
417
+ result = detect(payload)
418
+ if "_payload_error" in result:
419
+ print(json.dumps({"error": result["_payload_error"]}), file=sys.stderr)
420
+ return 1
421
+
422
+ print(json.dumps(result, separators=(",", ":")))
423
+ return 0
424
+
425
+
426
+ if __name__ == "__main__":
427
+ sys.exit(main())
@@ -0,0 +1,257 @@
1
+ # /// script
2
+ # requires-python = ">=3.10"
3
+ # dependencies = []
4
+ # ///
5
+ """SKF Emit Brief Result Envelope — Schema-locked headless output for skf-brief-skill.
6
+
7
+ Replaces the prose-driven envelope assembly in `src/skf-brief-skill/steps-c/
8
+ step-05-write-brief.md` §4b with one Python invocation. The envelope contract
9
+ (SKF_BRIEF_RESULT_JSON) is documented in src/skf-brief-skill/SKILL.md Result
10
+ Contract section.
11
+
12
+ LLM-rendered envelopes risk silent schema drift on every invocation —
13
+ a pipeline that grep's `SKF_BRIEF_RESULT_JSON: {…}` out of the workflow
14
+ log can break if the model decides to rename a key, reorder properties,
15
+ or swap a null for an empty string. This script is the single source of
16
+ truth: it takes a context payload as JSON on stdin, derives the
17
+ exit_code from the halt_reason deterministically, validates the
18
+ assembled envelope against the JSON Schema at
19
+ `src/shared/scripts/schemas/skf-brief-result-envelope.v1.json`, and
20
+ emits the line in a fixed shape.
21
+
22
+ Subcommands:
23
+
24
+ emit Read context payload as JSON on stdin, derive exit_code,
25
+ validate against the schema, emit the
26
+ `SKF_BRIEF_RESULT_JSON: {one-line JSON}` prefix line on
27
+ stdout (or stderr if --target=stderr). Default subcommand.
28
+
29
+ validate Read an envelope (without the prefix) as JSON on stdin
30
+ and verify it against the schema. Silent + exit 0 on
31
+ success; non-zero exit + stderr error on failure. Useful
32
+ for paranoid pipelines that want to validate a received
33
+ envelope before consuming it.
34
+
35
+ Context payload shape (consumed by `emit`):
36
+
37
+ {
38
+ "status": "success" | "error",
39
+ "brief_path": "/abs/path/skill-brief.yaml" | null,
40
+ "skill_name": "marked",
41
+ "version": "1.2.3" | null,
42
+ "language": "javascript" | null,
43
+ "scope_type": "public-api" | null,
44
+ "halt_reason": null | "input-missing" | "input-invalid" |
45
+ "forge-tier-missing" | "target-inaccessible" |
46
+ "gh-auth-failed" | "write-failed" |
47
+ "overwrite-cancelled" | "user-cancelled"
48
+ }
49
+
50
+ The caller does NOT supply exit_code — the script derives it from
51
+ halt_reason via the canonical mapping (null→0; input-*→2;
52
+ forge-tier-missing/target-inaccessible/gh-auth-failed→3;
53
+ write-failed→4; overwrite-cancelled→5; user-cancelled→6).
54
+
55
+ Cross-platform: pure stdlib, no third-party deps.
56
+
57
+ CLI:
58
+
59
+ echo '{...}' | uv run skf-emit-brief-result-envelope.py emit
60
+ echo '{...}' | uv run skf-emit-brief-result-envelope.py emit --target stderr
61
+ echo '{...}' | uv run skf-emit-brief-result-envelope.py validate
62
+ """
63
+
64
+ from __future__ import annotations
65
+
66
+ import argparse
67
+ import json
68
+ import sys
69
+ from typing import Any
70
+
71
+ PREFIX = "SKF_BRIEF_RESULT_JSON: "
72
+
73
+ VALID_STATUS = {"success", "error"}
74
+ VALID_HALT_REASONS = {
75
+ None,
76
+ "input-missing",
77
+ "input-invalid",
78
+ "forge-tier-missing",
79
+ "target-inaccessible",
80
+ "gh-auth-failed",
81
+ "write-failed",
82
+ "overwrite-cancelled",
83
+ "user-cancelled",
84
+ }
85
+ VALID_SCOPE_TYPES = {
86
+ None,
87
+ "full-library",
88
+ "specific-modules",
89
+ "public-api",
90
+ "component-library",
91
+ "reference-app",
92
+ "docs-only",
93
+ }
94
+
95
+ # Canonical halt_reason → exit_code mapping (mirrors SKILL.md Exit Codes table).
96
+ HALT_TO_EXIT = {
97
+ None: 0,
98
+ "input-missing": 2,
99
+ "input-invalid": 2,
100
+ "forge-tier-missing": 3,
101
+ "target-inaccessible": 3,
102
+ "gh-auth-failed": 3,
103
+ "write-failed": 4,
104
+ "overwrite-cancelled": 5,
105
+ "user-cancelled": 6,
106
+ }
107
+
108
+ # Envelope key order — fixed so byte-stable diffs are possible.
109
+ KEY_ORDER = [
110
+ "status",
111
+ "brief_path",
112
+ "skill_name",
113
+ "version",
114
+ "language",
115
+ "scope_type",
116
+ "exit_code",
117
+ "halt_reason",
118
+ ]
119
+
120
+
121
+ def _die(message: str, code: int = 1) -> None:
122
+ sys.stderr.write(f"skf-emit-brief-result-envelope: {message}\n")
123
+ sys.exit(code)
124
+
125
+
126
+ def assemble(ctx: dict[str, Any]) -> dict[str, Any]:
127
+ """Build the envelope from a context payload, deriving exit_code."""
128
+ status = ctx.get("status")
129
+ if status not in VALID_STATUS:
130
+ _die(f"status must be one of {sorted(VALID_STATUS)}; got {status!r}")
131
+
132
+ halt_reason = ctx.get("halt_reason")
133
+ if halt_reason not in VALID_HALT_REASONS:
134
+ _die(
135
+ f"halt_reason must be one of {sorted(r for r in VALID_HALT_REASONS if r is not None)} or null; "
136
+ f"got {halt_reason!r}"
137
+ )
138
+
139
+ if status == "success" and halt_reason is not None:
140
+ _die(f"halt_reason must be null when status is 'success'; got {halt_reason!r}")
141
+ if status == "error" and halt_reason is None:
142
+ _die("halt_reason must be set when status is 'error'")
143
+
144
+ scope_type = ctx.get("scope_type")
145
+ if scope_type not in VALID_SCOPE_TYPES:
146
+ _die(
147
+ f"scope_type must be one of {sorted(t for t in VALID_SCOPE_TYPES if t is not None)} or null; "
148
+ f"got {scope_type!r}"
149
+ )
150
+
151
+ skill_name = ctx.get("skill_name")
152
+ if not skill_name or not isinstance(skill_name, str):
153
+ _die(f"skill_name is required and must be a non-empty string; got {skill_name!r}")
154
+
155
+ envelope = {
156
+ "status": status,
157
+ "brief_path": ctx.get("brief_path"),
158
+ "skill_name": skill_name,
159
+ "version": ctx.get("version"),
160
+ "language": ctx.get("language"),
161
+ "scope_type": scope_type,
162
+ "exit_code": HALT_TO_EXIT[halt_reason],
163
+ "halt_reason": halt_reason,
164
+ }
165
+ # Re-emit in canonical key order
166
+ return {k: envelope[k] for k in KEY_ORDER}
167
+
168
+
169
+ def validate(envelope: dict[str, Any]) -> None:
170
+ """Validate an envelope dict against the schema. Exits non-zero on failure."""
171
+ required = set(KEY_ORDER)
172
+ missing = required - set(envelope.keys())
173
+ if missing:
174
+ _die(f"envelope missing required keys: {sorted(missing)}")
175
+ extra = set(envelope.keys()) - required
176
+ if extra:
177
+ _die(f"envelope has unexpected keys: {sorted(extra)}")
178
+ if envelope.get("status") not in VALID_STATUS:
179
+ _die(f"status invalid: {envelope.get('status')!r}")
180
+ if envelope.get("halt_reason") not in VALID_HALT_REASONS:
181
+ _die(f"halt_reason invalid: {envelope.get('halt_reason')!r}")
182
+ if envelope.get("scope_type") not in VALID_SCOPE_TYPES:
183
+ _die(f"scope_type invalid: {envelope.get('scope_type')!r}")
184
+ if envelope.get("exit_code") not in {0, 2, 3, 4, 5, 6}:
185
+ _die(f"exit_code invalid: {envelope.get('exit_code')!r}")
186
+ expected_exit = HALT_TO_EXIT[envelope.get("halt_reason")]
187
+ if envelope.get("exit_code") != expected_exit:
188
+ _die(
189
+ f"exit_code {envelope.get('exit_code')!r} does not match canonical mapping "
190
+ f"for halt_reason {envelope.get('halt_reason')!r} (expected {expected_exit})"
191
+ )
192
+ if not envelope.get("skill_name") or not isinstance(envelope.get("skill_name"), str):
193
+ _die("skill_name must be a non-empty string")
194
+
195
+
196
+ def cmd_emit(target_stream: str) -> int:
197
+ raw = sys.stdin.read()
198
+ if not raw or not raw.strip():
199
+ _die("emit: empty stdin (expected JSON context payload)")
200
+ try:
201
+ ctx = json.loads(raw)
202
+ except json.JSONDecodeError as e:
203
+ _die(f"emit: invalid JSON on stdin: {e}")
204
+ if not isinstance(ctx, dict):
205
+ _die("emit: context payload must be a JSON object")
206
+
207
+ envelope = assemble(ctx)
208
+ line = PREFIX + json.dumps(envelope, separators=(",", ":"))
209
+ if target_stream == "stderr":
210
+ print(line, file=sys.stderr)
211
+ else:
212
+ print(line)
213
+ return 0
214
+
215
+
216
+ def cmd_validate() -> int:
217
+ raw = sys.stdin.read()
218
+ if not raw or not raw.strip():
219
+ _die("validate: empty stdin (expected envelope JSON)")
220
+ try:
221
+ envelope = json.loads(raw)
222
+ except json.JSONDecodeError as e:
223
+ _die(f"validate: invalid JSON on stdin: {e}")
224
+ if not isinstance(envelope, dict):
225
+ _die("validate: envelope must be a JSON object")
226
+ validate(envelope)
227
+ return 0
228
+
229
+
230
+ def main() -> int:
231
+ parser = argparse.ArgumentParser(
232
+ prog="skf-emit-brief-result-envelope",
233
+ description="Schema-locked SKF_BRIEF_RESULT_JSON envelope emitter for skf-brief-skill.",
234
+ )
235
+ sub = parser.add_subparsers(dest="cmd", required=True)
236
+
237
+ p_emit = sub.add_parser("emit", help="Read context JSON on stdin, emit prefixed envelope line")
238
+ p_emit.add_argument(
239
+ "--target",
240
+ choices=["stdout", "stderr"],
241
+ default="stdout",
242
+ help="Output stream for the prefixed envelope line. step-05 §4b uses stdout on success and stderr on HARD HALT.",
243
+ )
244
+
245
+ sub.add_parser("validate", help="Read envelope JSON on stdin, exit 0 if schema-valid")
246
+
247
+ args = parser.parse_args()
248
+
249
+ if args.cmd == "emit":
250
+ return cmd_emit(args.target)
251
+ elif args.cmd == "validate":
252
+ return cmd_validate()
253
+ return 2
254
+
255
+
256
+ if __name__ == "__main__":
257
+ sys.exit(main())