bmad-module-skill-forge 7.0.0 → 8.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/README.md +1 -1
  3. package/docs/_data/pinned.yaml +9 -10
  4. package/docs/campaign.md +2 -2
  5. package/docs/skill-model.md +3 -3
  6. package/docs/verifying-a-skill.md +17 -18
  7. package/docs/workflows.md +8 -8
  8. package/package.json +1 -1
  9. package/src/shared/references/description-guard-protocol.md +12 -10
  10. package/src/shared/scripts/schemas/skf-analyze-result-envelope.v1.json +1 -1
  11. package/src/shared/scripts/schemas/skf-audit-result-envelope.v1.json +1 -1
  12. package/src/shared/scripts/schemas/skf-campaign-result-envelope.v1.json +38 -0
  13. package/src/shared/scripts/schemas/skf-create-skill-result-envelope.v1.json +5 -0
  14. package/src/shared/scripts/schemas/skf-drop-skill-result-envelope.v1.json +1 -1
  15. package/src/shared/scripts/schemas/skf-export-result-envelope.v1.json +1 -1
  16. package/src/shared/scripts/schemas/skf-quick-skill-result-envelope.v1.json +8 -3
  17. package/src/shared/scripts/schemas/skf-refine-architecture-result-envelope.v1.json +1 -1
  18. package/src/shared/scripts/schemas/skf-stack-result-envelope.v1.json +6 -1
  19. package/src/shared/scripts/schemas/skf-test-result-envelope.v1.json +1 -1
  20. package/src/shared/scripts/schemas/skf-update-result-envelope.v1.json +7 -2
  21. package/src/shared/scripts/schemas/skf-verify-stack-result-envelope.v1.json +1 -1
  22. package/src/shared/scripts/skf-parse-gaps.py +4 -1
  23. package/src/shared/scripts/skf-quick-batch.py +8 -4
  24. package/src/shared/scripts/skf-verify-provenance-completeness.py +957 -183
  25. package/src/skf-analyze-source/SKILL.md +1 -1
  26. package/src/skf-audit-skill/SKILL.md +1 -1
  27. package/src/skf-audit-skill/customize.toml +4 -3
  28. package/src/skf-audit-skill/references/report.md +1 -1
  29. package/src/skf-brief-skill/SKILL.md +1 -1
  30. package/src/skf-campaign/SKILL.md +1 -1
  31. package/src/skf-campaign/assets/campaign-state-schema.json +58 -0
  32. package/src/skf-campaign/references/campaign-contracts.md +1 -1
  33. package/src/skf-campaign/references/step-06-batch.md +2 -2
  34. package/src/skf-campaign/references/step-07-capstone.md +1 -1
  35. package/src/skf-campaign/references/step-08-verify.md +1 -1
  36. package/src/skf-campaign/references/step-09-refine.md +1 -1
  37. package/src/skf-campaign/references/step-11-maintenance.md +1 -1
  38. package/src/skf-campaign/scripts/campaign-render-batch.py +43 -10
  39. package/src/skf-campaign/scripts/campaign-report.py +60 -4
  40. package/src/skf-campaign/scripts/campaign-state.py +67 -19
  41. package/src/skf-campaign/templates/campaign-report-template.md +4 -0
  42. package/src/skf-create-skill/SKILL.md +1 -1
  43. package/src/skf-create-skill/assets/skill-sections.md +2 -2
  44. package/src/skf-create-skill/references/report.md +2 -2
  45. package/src/skf-create-skill/references/validate.md +49 -29
  46. package/src/skf-create-stack-skill/SKILL.md +1 -1
  47. package/src/skf-create-stack-skill/references/invocation-contract.md +1 -1
  48. package/src/skf-create-stack-skill/references/report.md +3 -3
  49. package/src/skf-create-stack-skill/references/validate.md +9 -4
  50. package/src/skf-drop-skill/SKILL.md +1 -1
  51. package/src/skf-drop-skill/references/invocation-contract.md +1 -1
  52. package/src/skf-export-skill/SKILL.md +1 -1
  53. package/src/skf-export-skill/references/summary.md +1 -1
  54. package/src/skf-forger/SKILL.md +1 -1
  55. package/src/skf-quick-skill/SKILL.md +1 -1
  56. package/src/skf-quick-skill/references/batch-mode.md +28 -2
  57. package/src/skf-quick-skill/references/compile.md +2 -0
  58. package/src/skf-quick-skill/references/finalize.md +3 -2
  59. package/src/skf-quick-skill/references/halt-contract.md +3 -3
  60. package/src/skf-quick-skill/references/quick-extract.md +2 -1
  61. package/src/skf-quick-skill/references/write-and-validate.md +11 -5
  62. package/src/skf-refine-architecture/SKILL.md +1 -1
  63. package/src/skf-refine-architecture/customize.toml +1 -1
  64. package/src/skf-refine-architecture/references/report.md +2 -2
  65. package/src/skf-rename-skill/SKILL.md +1 -1
  66. package/src/skf-rename-skill/references/report.md +1 -1
  67. package/src/skf-setup/SKILL.md +1 -1
  68. package/src/skf-test-skill/SKILL.md +1 -1
  69. package/src/skf-test-skill/customize.toml +10 -0
  70. package/src/skf-test-skill/references/coverage-check-tiers.md +3 -3
  71. package/src/skf-test-skill/references/coverage-check.md +2 -2
  72. package/src/skf-test-skill/references/init.md +1 -1
  73. package/src/skf-test-skill/references/invocation-contract.md +1 -1
  74. package/src/skf-test-skill/references/report.md +3 -2
  75. package/src/skf-test-skill/references/scoring-rules.md +2 -2
  76. package/src/skf-test-skill/references/signature-comparison-rules.md +4 -1
  77. package/src/skf-test-skill/scripts/gap-ledger.py +218 -46
  78. package/src/skf-test-skill/scripts/load-coverage-inputs.py +12 -6
  79. package/src/skf-test-skill/templates/test-report-template.md +4 -3
  80. package/src/skf-update-skill/SKILL.md +1 -1
  81. package/src/skf-update-skill/references/gap-driven.md +1 -1
  82. package/src/skf-update-skill/references/invocation-contract.md +1 -1
  83. package/src/skf-update-skill/references/merge.md +1 -1
  84. package/src/skf-update-skill/references/report.md +18 -7
  85. package/src/skf-update-skill/references/write.md +24 -12
  86. package/src/skf-verify-stack/SKILL.md +1 -1
  87. package/src/skf-verify-stack/references/report.md +1 -1
@@ -87,7 +87,7 @@ These rules apply to every step in this workflow:
87
87
  uv run {resolveCustomizationHelper} --skill {skill-root} --project-root {project-root} --key workflow
88
88
  ```
89
89
 
90
- It merges the bundled `{skill-root}/customize.toml` with `{project-root}/_bmad/custom/skf-analyze-source.toml` (team overrides, committed) and `.user.toml` (personal overrides, gitignored). When it exits non-zero, prints no JSON or is missing, print one line, `[activation/warn] customization_resolver_unavailable: <reason>` (`<reason>`: its first stderr line, `not found` when the script is missing, `no JSON` when it printed none). If the resolver cannot run, read `{skill-root}/customize.toml` alone and use its bundled defaults: the `{project-root}/_bmad/custom/` overrides do not apply to this run. In that case keep the reason as `{customization_resolver_unavailable}` (unset when the resolver ran) for step 5 to record in the run sink.
90
+ It merges the bundled `{skill-root}/customize.toml` with `{project-root}/_bmad/custom/skf-analyze-source.toml` (team overrides, committed) and `.user.toml` (personal overrides, gitignored). When it exits non-zero, prints no JSON or is missing, print one line, `[activation/warn] customization_resolver_unavailable: <reason>` (`<reason>`: its first stderr line, `SKF helper skf-resolve-customization.py not found` when the script is missing, `no JSON` when it printed none). If the resolver cannot run, read `{skill-root}/customize.toml` alone and use its bundled defaults: the `{project-root}/_bmad/custom/` overrides do not apply to this run. In that case keep the reason as `{customization_resolver_unavailable}` (unset when the resolver ran) for step 5 to record in the run sink.
91
91
 
92
92
  Then bind, so stage files use the variable with no conditional at the usage site:
93
93
 
@@ -79,7 +79,7 @@ Stage 1b returns to init.md at §5b's **Record for report**, then §6. Step 1 §
79
79
  uv run {resolveCustomizationHelper} --skill {skill-root} --project-root {project-root} --key workflow
80
80
  ```
81
81
 
82
- It merges the bundled `{skill-root}/customize.toml` with `{project-root}/_bmad/custom/skf-audit-skill.toml` (team overrides, committed) and `.user.toml` (personal overrides, gitignored). When it exits non-zero, prints no JSON or is missing, print one line, `[activation/warn] customization_resolver_unavailable: <reason>` (`<reason>`: its first stderr line, `not found` when the script is missing, `no JSON` when it printed none). If the resolver cannot run, read `{skill-root}/customize.toml` alone and use its bundled defaults: the `{project-root}/_bmad/custom/` overrides do not apply to this run. Then keep the reason as `{customization_resolver_unavailable}`, which step 6 hands to the emitter as a warning.
82
+ It merges the bundled `{skill-root}/customize.toml` with `{project-root}/_bmad/custom/skf-audit-skill.toml` (team overrides, committed) and `.user.toml` (personal overrides, gitignored). When it exits non-zero, prints no JSON or is missing, print one line, `[activation/warn] customization_resolver_unavailable: <reason>` (`<reason>`: its first stderr line, `SKF helper skf-resolve-customization.py not found` when the script is missing, `no JSON` when it printed none). If the resolver cannot run, read `{skill-root}/customize.toml` alone and use its bundled defaults: the `{project-root}/_bmad/custom/` overrides do not apply to this run. Then keep the reason as `{customization_resolver_unavailable}`, which step 6 hands to the emitter as a warning.
83
83
 
84
84
  Bind the two scalars the stages read, each to its bundled default when the merged value is empty or absent:
85
85
 
@@ -62,10 +62,11 @@ drift_report_template_path = "assets/drift-report-template.md"
62
62
 
63
63
  # Optional post-audit hook: a shell command run from {project-root}, not an
64
64
  # agent instruction. Step 6 (references/report.md) runs it on a finished
65
- # audit, after the emitter wrote the result files, as:
65
+ # audit, after the emitter wrote the per-run record (`result_path` not null),
66
+ # as:
66
67
  # <on_complete> --result-path=<{forge_version}/audit-skill-result-<YYYYMMDD-HHmmss>.json>
67
- # It never runs on a halt, nor when the result file could not be written. A
68
- # failure is shown to the user and never fails the audit.
68
+ # It never runs on a halt, nor when the per-run record could not be written.
69
+ # A failure is shown to the user and never fails the audit.
69
70
  #
70
71
  # Empty string = no-op (default).
71
72
 
@@ -196,7 +196,7 @@ When On Activation step 3 fell back to the bundled `customize.toml`, add `"custo
196
196
  uv run {emitEnvelopeHelper} emit --workflow skf-audit-skill --run-dir "{run_dir}" --result-dir "{forge_version}" < "{run_dir}/result-context.json"
197
197
  ```
198
198
 
199
- It writes `{forge_version}/audit-skill-result-{YYYYMMDD-HHmmss}.json` (UTC; it picks the name) and its `audit-skill-result-latest.json` copy (the record of the last run whose emitter wrote this copy, at a stable path: a copy, not a symlink; a caller reads its own run's `SKF_AUDIT_RESULT_JSON` line), stamps the timestamp, `run_id`, `headless_decisions` and `warnings` into both, and prints the `SKF_AUDIT_RESULT_JSON:` line on stdout. When `{headless_mode}` is true, bind `{result_envelope_line}` ← the line and do not display it here: the shared health check displays it verbatim as the run's last line, so a `claude -p` caller reads `drift_score`, `report_path`, `next_workflow`, `upstream_moved` and `upstream_ref` from the final message. A result file that could not be written leaves `result_path` null and a `result_file_write_failed` warning in the line, and the run still finishes. If the emitter exits non-zero, correct `result-context.json` from the message on its stderr and run it once more; if it fails again, HALT with **exit 4**, `halt_reason: "write-failed"`, phase `report:result-contract`: the run's result contract could not be written.
199
+ It writes `{forge_version}/audit-skill-result-{YYYYMMDD-HHmmss}.json` (UTC; it picks the name) and its `audit-skill-result-latest.json` copy (the record of the last run whose emitter wrote this copy, at a stable path: a copy, not a symlink; a caller reads its own run's `SKF_AUDIT_RESULT_JSON` line), stamps the timestamp, `run_id`, `headless_decisions` and `warnings` into both, and prints the `SKF_AUDIT_RESULT_JSON:` line on stdout. When `{headless_mode}` is true, bind `{result_envelope_line}` ← the line and do not display it here: the shared health check displays it verbatim as the run's last line, so a `claude -p` caller reads `drift_score`, `report_path`, `next_workflow`, `upstream_moved` and `upstream_ref` from the final message. A per-run record that could not be written leaves `result_path` null, and a `-latest` copy that could not be written leaves it set; either adds a `result_file_write_failed` warning to the line, and the run still finishes. If the emitter exits non-zero, correct `result-context.json` from the message on its stderr and run it once more; if it fails again, HALT with **exit 4**, `halt_reason: "write-failed"`, phase `report:result-contract`: the run's result contract could not be written.
200
200
 
201
201
  **Hard-halt envelope (headless only).** Every hard halt prints its envelope through the emitter on **stderr** with `status: "error"` and the `exit_code` / `halt_reason` for its failure class, as each step's **Halt envelope** paragraph shows: it is the only failure signal a wrapping pipeline receives. `drift_score` carries its last known value (`null` if classification never ran); `report_path` is `null` when the report write failed.
202
202
 
@@ -65,7 +65,7 @@ Give the halt's own `halt_reason`: the helper derives `exit_code` from it. `skil
65
65
  uv run {resolveCustomizationHelper} --skill {skill-root} --project-root {project-root} --key workflow
66
66
  ```
67
67
 
68
- It merges the bundled `{skill-root}/customize.toml` with `{project-root}/_bmad/custom/skf-brief-skill.toml` (team overrides, committed) and `.user.toml` (personal overrides, gitignored). When it exits non-zero, prints no JSON or is missing, print one line, `[activation/warn] customization_resolver_unavailable: <reason>` (`<reason>`: its first stderr line, `not found` when the script is missing, `no JSON` when it printed none). If the resolver cannot run, read `{skill-root}/customize.toml` alone and use its bundled defaults: the `{project-root}/_bmad/custom/` overrides do not apply to this run. In that case add `customization_resolver_unavailable: <reason>` to `workflow_warnings[]` as the run's first warning.
68
+ It merges the bundled `{skill-root}/customize.toml` with `{project-root}/_bmad/custom/skf-brief-skill.toml` (team overrides, committed) and `.user.toml` (personal overrides, gitignored). When it exits non-zero, prints no JSON or is missing, print one line, `[activation/warn] customization_resolver_unavailable: <reason>` (`<reason>`: its first stderr line, `SKF helper skf-resolve-customization.py not found` when the script is missing, `no JSON` when it printed none). If the resolver cannot run, read `{skill-root}/customize.toml` alone and use its bundled defaults: the `{project-root}/_bmad/custom/` overrides do not apply to this run. In that case add `customization_resolver_unavailable: <reason>` to `workflow_warnings[]` as the run's first warning.
69
69
 
70
70
  Bind the values the stage files use, taking the bundled default when the merged value is empty or absent:
71
71
 
@@ -37,7 +37,7 @@ Run these steps once, in order, before dispatching to Mode Routing.
37
37
  uv run {resolveCustomizationHelper} --skill {skill-root} --project-root {project-root} --key workflow
38
38
  ```
39
39
 
40
- It merges the bundled `{skill-root}/customize.toml` with `{project-root}/_bmad/custom/skf-campaign.toml` (team overrides, committed) and `.user.toml` (personal overrides, gitignored). When it exits non-zero, prints no JSON or is missing, print one line, `[activation/warn] customization_resolver_unavailable: <reason>` (`<reason>`: its first stderr line, `not found` when the script is missing, `no JSON` when it printed none). If the resolver cannot run, read `{skill-root}/customize.toml` alone and use its bundled defaults: the `{project-root}/_bmad/custom/` overrides do not apply to this run. In that case, unless the invocation is `campaign status` (which writes nothing), log `customization_resolver_unavailable: <reason>` as an `event` in the decision log once `{campaignWorkspacePath}` is bound below (`references/campaign-contracts.md`, Decision Log).
40
+ It merges the bundled `{skill-root}/customize.toml` with `{project-root}/_bmad/custom/skf-campaign.toml` (team overrides, committed) and `.user.toml` (personal overrides, gitignored). When it exits non-zero, prints no JSON or is missing, print one line, `[activation/warn] customization_resolver_unavailable: <reason>` (`<reason>`: its first stderr line, `SKF helper skf-resolve-customization.py not found` when the script is missing, `no JSON` when it printed none). If the resolver cannot run, read `{skill-root}/customize.toml` alone and use its bundled defaults: the `{project-root}/_bmad/custom/` overrides do not apply to this run. In that case, unless the invocation is `campaign status` (which writes nothing), log `customization_resolver_unavailable: <reason>` as an `event` in the decision log once `{campaignWorkspacePath}` is bound below (`references/campaign-contracts.md`, Decision Log).
41
41
 
42
42
  Resolve each scalar now (so step files never repeat the conditional) and stash as workflow-context variables:
43
43
 
@@ -115,6 +115,64 @@
115
115
  "type": ["integer", "null"]
116
116
  }
117
117
  }
118
+ },
119
+ "stage_outcomes": {
120
+ "type": "object",
121
+ "additionalProperties": false,
122
+ "description": "What the capstone (Stage 6), verification (Stage 7) and refinement (Stage 8) stages did, written by campaign-state.py set-campaign beside each summary: completed, skipped (a stage skipped on purpose, --no-<field>) or failed, with the failure's halt_reason (no-result when the sub-skill printed no line, result-unreadable when its line could not be read) and exit_code (null when it gave none). Optional: a stage is absent until set-campaign records it, so older states still validate.",
123
+ "properties": {
124
+ "capstone": {
125
+ "type": "object",
126
+ "additionalProperties": false,
127
+ "required": ["outcome"],
128
+ "properties": {
129
+ "outcome": {
130
+ "type": "string",
131
+ "enum": ["completed", "skipped", "failed"]
132
+ },
133
+ "halt_reason": {
134
+ "type": ["string", "null"]
135
+ },
136
+ "exit_code": {
137
+ "type": ["integer", "null"]
138
+ }
139
+ }
140
+ },
141
+ "verification": {
142
+ "type": "object",
143
+ "additionalProperties": false,
144
+ "required": ["outcome"],
145
+ "properties": {
146
+ "outcome": {
147
+ "type": "string",
148
+ "enum": ["completed", "skipped", "failed"]
149
+ },
150
+ "halt_reason": {
151
+ "type": ["string", "null"]
152
+ },
153
+ "exit_code": {
154
+ "type": ["integer", "null"]
155
+ }
156
+ }
157
+ },
158
+ "refinement": {
159
+ "type": "object",
160
+ "additionalProperties": false,
161
+ "required": ["outcome"],
162
+ "properties": {
163
+ "outcome": {
164
+ "type": "string",
165
+ "enum": ["completed", "skipped", "failed"]
166
+ },
167
+ "halt_reason": {
168
+ "type": ["string", "null"]
169
+ },
170
+ "exit_code": {
171
+ "type": ["integer", "null"]
172
+ }
173
+ }
174
+ }
175
+ }
118
176
  }
119
177
  }
120
178
  },
@@ -51,7 +51,7 @@ Every write to `_campaign-state.yaml` goes through `scripts/campaign-state.py`.
51
51
  | `set-skill` | step-05, step-10, step-resume | a skill's `status`, `quality_score`, `skill_path`, `brief_path` or `export` outcome (`--export`); `active` stamps `started_at` once, `completed` stamps `completed_at`, `pending` clears both and the `export` outcome |
52
52
  | `append-workarounds` | step-05 | entries of a skill's `workarounds_applied`, each once |
53
53
  | `apply-batch` | step-06 | the Tier B batch from its line-to-skill map: `--start`, `--results-file` or `--no-results` |
54
- | `set-campaign` | steps 07 to 09 | `architecture_doc_path`, and the capstone, verification or refinement summary read from the sub-skill's envelope line (`capstone.verified` with the verification); null for a stage skipped on purpose (`--no-<field>`) or a sub-skill that printed no line (`--no-result <field>`, a `not_recorded` row with halt reason `no-result`) |
54
+ | `set-campaign` | steps 07 to 09 | `architecture_doc_path`, and the capstone, verification or refinement summary read from the sub-skill's envelope line (`capstone.verified` with the verification); null for a stage skipped on purpose (`--no-<field>`), a sub-skill that printed no line (`--no-result <field>`, a `not_recorded` row with halt reason `no-result`) or a line it cannot read (one that does not parse, or has no `status` or `exit_code`: a `not_recorded` row with halt reason `result-unreadable`, exit 0); and `stage_outcomes`, each stage `completed`, `skipped` or `failed` with its halt reason and exit code |
55
55
  | `set-stage` | steps 05, 10, 11 | `current_stage` alone |
56
56
  | `recover` | step-resume | `.bak` over the primary, when `.bak` validates (exit 9 otherwise) |
57
57
  | `archive` | SKILL.md (overwrite) | nothing: it moves the state, its `.bak` and the brief into a new folder under `archive/` and returns it |
@@ -66,7 +66,7 @@ Invoke QS in `--batch` mode with the generated batch file:
66
66
  skf-quick-skill --batch {batchFile}
67
67
  ```
68
68
 
69
- QS `--batch` implies `--headless`. When the batch ends it prints a `batch_summary` event on stderr: keep its `summary_path`, the batch summary file that holds each target's result.
69
+ QS `--batch` implies `--headless`. When the batch ends it prints a `batch_summary` event on stderr: keep its `summary_path`, the batch summary file that holds each target's result. When its `summary_path` is null, QS could not write that file: keep the event's `batch_file` (QS's `batch.jsonl`, which holds each target's result) as `<summary_path>` instead, and log the event's `summary_write_failed` warning, type `event`.
70
70
 
71
71
  ### §5: Record Results
72
72
 
@@ -84,7 +84,7 @@ uv run {stateScript} apply-batch --state-file {stateFile} --map-file {batchMapFi
84
84
 
85
85
  A `completed` result sets the skill `completed`, with `completed_at` from the clock, its `quality_score` (the skill-check score QS records, not a test-skill score) and its `skill_path`; a `failed` one sets it `failed`. Log each entry of the helper's `failed[]` with its `error_code` (`no-batch-result` when the summary holds no result for its line), type `event`.
86
86
 
87
- When QS printed no `batch_summary` event (run no record call), or the record call exits 2 (a missing summary or map, or a summary that belongs to another batch file), fail every skill the map lists instead, and log why: never guess which target built which skill.
87
+ When QS printed no `batch_summary` event (run no record call), or the record call exits 2 (a missing or unreadable summary, batch file or map, or results that belong to another batch file), fail every skill the map lists instead, and log why: never guess which target built which skill.
88
88
 
89
89
  ```
90
90
  uv run {stateScript} apply-batch --state-file {stateFile} --map-file {batchMapFile} --no-results --stage 5
@@ -69,7 +69,7 @@ From a `success` envelope it sets `campaign.capstone`, the campaign-level summar
69
69
  - `campaign.capstone.verified`: `null` for now; set by the verify stage (step-08) once the stack is checked
70
70
  - `campaign.capstone.completed_at`: the time of the write, from the clock
71
71
 
72
- An `error` envelope sets `campaign.capstone` to `null` (the helper names it in `not_recorded`). When §2 found no skill to compose, record no capstone instead: `uv run {stateScript} set-campaign --state-file {stateFile} --no-capstone --stage 6`. When SS printed no `SKF_STACK_RESULT_JSON` line on stdout or stderr, there is no result to read, whatever its exit code: record it with `uv run {stateScript} set-campaign --state-file {stateFile} --no-result capstone --stage 6`, log "no SKF_STACK_RESULT_JSON line (exit <code>)" with SS's exit code (type `event`), and go on.
72
+ An `error` envelope sets `campaign.capstone` to `null` (the helper names it in `not_recorded`). When §2 found no skill to compose, record no capstone instead: `uv run {stateScript} set-campaign --state-file {stateFile} --no-capstone --stage 6`. When SS printed no `SKF_STACK_RESULT_JSON` line on stdout or stderr, there is no result to read, whatever its exit code: record it with `uv run {stateScript} set-campaign --state-file {stateFile} --no-result capstone --stage 6`, log "no SKF_STACK_RESULT_JSON line (exit <code>)" with SS's exit code (type `event`), and go on. When the helper's `not_recorded` row gives the halt reason `result-unreadable` (SS printed a line that does not parse, or one with no `status` or `exit_code`), log "unparseable SKF_STACK_RESULT_JSON line" (type `event`) and go on.
73
73
 
74
74
  The capstone is a derived artifact: it is **not** tracked as a skill entry in the `skills[]` array. The constituent skill list and any verbose detail are reported in the step output and are available to downstream steps (verify, refine).
75
75
 
@@ -67,7 +67,7 @@ SKF_RESULT
67
67
 
68
68
  It also sets `campaign.capstone.verified` to `true` when `overall_verdict` is `FEASIBLE`, otherwise `false` (only if a `campaign.capstone` entry exists from step-07).
69
69
 
70
- **On VS failure** (a line whose `status` is `error` or whose `exit_code` is non-zero): the helper sets `campaign.verification` to `null` and names it in `not_recorded`; log that line's `exit_code` and `halt_reason` (type `event`). Verification failure does NOT block the campaign: it produces diagnostic information for operator review. When §3 skipped VS, record no verification instead: `uv run {stateScript} set-campaign --state-file {stateFile} --no-verification --stage 7`. When VS printed no `SKF_VERIFY_STACK_RESULT_JSON` line on stdout or stderr, there is no result to read, whatever its exit code: record it with `uv run {stateScript} set-campaign --state-file {stateFile} --no-result verification --stage 7`, log "no SKF_VERIFY_STACK_RESULT_JSON line (exit <code>)" with VS's exit code (type `event`), and go on.
70
+ **On VS failure** (a line whose `status` is `error` or whose `exit_code` is non-zero): the helper sets `campaign.verification` to `null` and names it in `not_recorded`; log that line's `exit_code` and `halt_reason` (type `event`). Verification failure does NOT block the campaign: it produces diagnostic information for operator review. When §3 skipped VS, record no verification instead: `uv run {stateScript} set-campaign --state-file {stateFile} --no-verification --stage 7`. When VS printed no `SKF_VERIFY_STACK_RESULT_JSON` line on stdout or stderr, there is no result to read, whatever its exit code: record it with `uv run {stateScript} set-campaign --state-file {stateFile} --no-result verification --stage 7`, log "no SKF_VERIFY_STACK_RESULT_JSON line (exit <code>)" with VS's exit code (type `event`), and go on. When the helper's `not_recorded` row gives the halt reason `result-unreadable` (VS printed a line that does not parse, or one with no `status` or `exit_code`), log "unparseable SKF_VERIFY_STACK_RESULT_JSON line" (type `event`) and go on.
71
71
 
72
72
  ## OUTPUT
73
73
 
@@ -75,7 +75,7 @@ SKF_RESULT
75
75
  - `campaign.refinement.issue_count`: from the envelope
76
76
  - `campaign.refinement.improvement_count`: from the envelope
77
77
 
78
- **On RA failure** (a line whose `status` is `error` or whose `exit_code` is non-zero): the helper sets `campaign.refinement` to `null` and names it in `not_recorded`; log that line's `exit_code` and `halt_reason` (type `event`). Refinement failure does NOT block the campaign: the campaign continues to export with whatever state exists. When §3 skipped RA, record no refinement instead: `uv run {stateScript} set-campaign --state-file {stateFile} --no-refinement --stage 8`. When RA printed no `SKF_REFINE_ARCHITECTURE_RESULT_JSON` line on stdout or stderr, there is no result to read, whatever its exit code: record it with `uv run {stateScript} set-campaign --state-file {stateFile} --no-result refinement --stage 8`, log "no SKF_REFINE_ARCHITECTURE_RESULT_JSON line (exit <code>)" with RA's exit code (type `event`), and go on.
78
+ **On RA failure** (a line whose `status` is `error` or whose `exit_code` is non-zero): the helper sets `campaign.refinement` to `null` and names it in `not_recorded`; log that line's `exit_code` and `halt_reason` (type `event`). Refinement failure does NOT block the campaign: the campaign continues to export with whatever state exists. When §3 skipped RA, record no refinement instead: `uv run {stateScript} set-campaign --state-file {stateFile} --no-refinement --stage 8`. When RA printed no `SKF_REFINE_ARCHITECTURE_RESULT_JSON` line on stdout or stderr, there is no result to read, whatever its exit code: record it with `uv run {stateScript} set-campaign --state-file {stateFile} --no-result refinement --stage 8`, log "no SKF_REFINE_ARCHITECTURE_RESULT_JSON line (exit <code>)" with RA's exit code (type `event`), and go on. When the helper's `not_recorded` row gives the halt reason `result-unreadable` (RA printed a line that does not parse, or one with no `status` or `exit_code`), log "unparseable SKF_REFINE_ARCHITECTURE_RESULT_JSON line" (type `event`) and go on.
79
79
 
80
80
  ## OUTPUT
81
81
 
@@ -44,7 +44,7 @@ uv run {reportScript} \
44
44
  --decision-log {decisionLogFile}
45
45
  ```
46
46
 
47
- Its JSON result on stdout carries `skills_completed`, `skills_failed`, `quality_scores`, `export_verdicts` (each completed skill's quality-gate verdict at Export), `skills_excluded` (the completed skills the gate kept from export), `skills_exported` and `export_failures` (what step-10 recorded: the skills `skf-export-skill` exported, and each skill whose export failed, with its `halt_reason` and `exit_code`; both null when Export did not run) and `duration`, already computed: §6 displays them, and §5 builds the envelope from `{resultContextFile}`, the payload the script also writes, on success and on failure alike.
47
+ Its JSON result on stdout carries `skills_completed`, `skills_failed`, `quality_scores`, `export_verdicts` (each completed skill's quality-gate verdict at Export), `skills_excluded` (the completed skills the gate kept from export), `skills_exported` and `export_failures` (what step-10 recorded: the skills `skf-export-skill` exported, and each skill whose export failed, with its `halt_reason` and `exit_code`; both null when Export did not run), `stage_outcomes` (whether the capstone, verification and refinement stages completed, were skipped or failed, with a failure's `halt_reason` and `exit_code`; null before the capstone stage ran) and `duration`, already computed: §6 displays them, and §5 builds the envelope from `{resultContextFile}`, the payload the script also writes, on success and on failure alike.
48
48
 
49
49
  **On success** (exit code 0): log the report path and the summary stats from the result JSON (type `event`).
50
50
 
@@ -47,12 +47,17 @@ Record mode (--record): joins the QS batch summary (`summary_path` of the
47
47
  `quality_score`) or `failed` (with QS's `error_code`, or `no-batch-result`
48
48
  when the summary holds no result for its line). A result whose target is not
49
49
  the mapped line's target means the summary belongs to another batch file, and
50
- nothing is joined.
50
+ nothing is joined. When QS could not write the summary (`summary_path` null),
51
+ the event's `batch_file` names the batch run folder's `batch.jsonl`, and
52
+ --record takes that file instead: a path named `batch.jsonl` is read through
53
+ the shared skf-quick-batch.py's `load` (a later result line for a batch
54
+ number wins, a spoiled line is skipped), and its results are joined as a
55
+ summary's are.
51
56
 
52
57
  CLI:
53
58
  uv run campaign-render-batch.py --state-file <p> --brief-file <p> \
54
59
  [-o <batchFile>] [--map <mapFile>] [--directive-file <p>]
55
- uv run campaign-render-batch.py --record <summary_path> --map <mapFile>
60
+ uv run campaign-render-batch.py --record <summary_path|batch_file> --map <mapFile>
56
61
 
57
62
  Output:
58
63
  render: batch text (one target per line) to {batchFile} via -o, else to
@@ -68,8 +73,8 @@ Output:
68
73
  Exit codes:
69
74
  0 batch file written (0+ targets), or results joined
70
75
  2 state file missing / unreadable / bad YAML, a directive that cannot be
71
- read, a missing or unreadable map or summary, or a summary that does not
72
- match the map (SUMMARY_MISMATCH)
76
+ read, a missing or unreadable map, summary or batch.jsonl, or a summary
77
+ or batch.jsonl that does not match the map (SUMMARY_MISMATCH)
73
78
  8 brief missing / unreadable (missing-brief HALT), or a selected Tier B skill
74
79
  has no matching brief target with a repo_url (unmatched-target)
75
80
  """
@@ -87,18 +92,25 @@ import yaml
87
92
 
88
93
  GATE_SCRIPT = Path(__file__).resolve().with_name("campaign-quality-gate.py")
89
94
  RESOLVE_PACKAGE_SCRIPT = Path(__file__).resolve().parent.parent.parent / "shared" / "scripts" / "skf-resolve-package.py"
95
+ SHARED_SCRIPTS = RESOLVE_PACKAGE_SCRIPT.parent
90
96
  SELECTED_STATUSES = ("pending", "active")
91
97
 
92
98
 
93
- def _version_re() -> Any:
94
- """skf-resolve-package.py's _VERSION_RE: the pins parse-target reads after `@`."""
95
- spec = importlib.util.spec_from_file_location("skf_resolve_package", RESOLVE_PACKAGE_SCRIPT)
99
+ def _shared(file_name: str) -> Any:
100
+ spec = importlib.util.spec_from_file_location(file_name[:-3].replace("-", "_"), SHARED_SCRIPTS / file_name)
96
101
  module = importlib.util.module_from_spec(spec)
97
102
  spec.loader.exec_module(module)
98
- return module._VERSION_RE
103
+ return module
104
+
105
+
106
+ def _version_re() -> Any:
107
+ """skf-resolve-package.py's _VERSION_RE: the pins parse-target reads after `@`."""
108
+ return _shared(RESOLVE_PACKAGE_SCRIPT.name)._VERSION_RE
99
109
 
100
110
 
101
111
  VERSION_RE = _version_re()
112
+ # QS's batch helper: --record reads a batch's batch.jsonl through its own load.
113
+ qb = _shared("skf-quick-batch.py")
102
114
 
103
115
 
104
116
  def _err(message: str, code: str, exit_code: int, **extra: Any) -> int:
@@ -365,11 +377,30 @@ def _load_json(path: str, label: str) -> Tuple[Optional[Dict[str, Any]], int]:
365
377
  return data, 0
366
378
 
367
379
 
380
+ def _load_batch_file(path: str) -> Tuple[Optional[Dict[str, Any]], int]:
381
+ """A QS batch run folder's batch.jsonl, read as a summary: its results, by batch number.
382
+
383
+ Read through skf-quick-batch.py's own load, so a later result line for a
384
+ batch number replaces an earlier one and a spoiled line is skipped.
385
+ """
386
+ if not Path(path).is_file():
387
+ return None, _err(f"Batch result file not found: {path}", "SUMMARY_NOT_FOUND", 2)
388
+ try:
389
+ state = qb.load(Path(path).parent)
390
+ except qb.BatchError as exc:
391
+ return None, _err(f"Batch result file unreadable: {exc}", "SUMMARY_PARSE_ERROR", 2)
392
+ return {"results": [state["results"][n] for n in sorted(state["results"])]}, 0
393
+
394
+
368
395
  def record(summary_file: str, map_file: str) -> int:
369
396
  batch_map, rc = _load_json(map_file, "map")
370
397
  if batch_map is None:
371
398
  return rc
372
- batch_summary, rc = _load_json(summary_file, "summary")
399
+ if Path(summary_file).name == qb.BATCH_FILE:
400
+ # the event's batch_file: QS could not write the summary, so batch.jsonl holds the results
401
+ batch_summary, rc = _load_batch_file(summary_file)
402
+ else:
403
+ batch_summary, rc = _load_json(summary_file, "summary")
373
404
  if batch_summary is None:
374
405
  return rc
375
406
  try:
@@ -392,7 +423,9 @@ def main(argv: list[str] | None = None) -> int:
392
423
  parser.add_argument("-o", "--output", dest="output", help="batch file path (default: stdout)")
393
424
  parser.add_argument("--map", dest="map_file", help="line-to-skill map path (written, or read by --record)")
394
425
  parser.add_argument("--directive-file", help="campaign directive whose Skip List is applied")
395
- parser.add_argument("--record", dest="summary_file", help="QS batch summary to join to --map")
426
+ parser.add_argument("--record", dest="summary_file",
427
+ help="QS batch summary to join to --map, or the batch's batch.jsonl when summary_path "
428
+ "is null")
396
429
  args = parser.parse_args(argv)
397
430
  if args.summary_file:
398
431
  if not args.map_file:
@@ -14,7 +14,8 @@ Output (JSON on stdout):
14
14
  "quality_scores":{"skill":score,...},"duration":"...",
15
15
  "export_verdicts":{"skill":"pass|fallback|fail",...},"skills_excluded":["skill",...],
16
16
  "skills_exported":["skill",...],
17
- "export_failures":[{"skill","halt_reason","exit_code"},...]}
17
+ "export_failures":[{"skill","halt_reason","exit_code"},...],
18
+ "stage_outcomes":{"capstone|verification|refinement":{"outcome","halt_reason","exit_code"}}}
18
19
 
19
20
  `export_verdicts` holds each completed skill's quality-gate verdict at Export
20
21
  and `skills_excluded` the completed skills the gate kept from export, both
@@ -30,6 +31,14 @@ halt_reason and exit code. Both are null when the Export stage did not run
30
31
  (`campaign.current_stage` below 9). The report's Export Gate table shows the
31
32
  same outcome per skill in its Export column.
32
33
 
34
+ `stage_outcomes` is what the capstone, verification and refinement stages
35
+ did: the `campaign.stage_outcomes` entry campaign-state.py set-campaign
36
+ recorded for each one, as {"outcome", "halt_reason", "exit_code"} with
37
+ `outcome` completed, skipped or failed (null for a stage it holds no
38
+ outcome for). It is null when the capstone stage did not run
39
+ (`campaign.current_stage` below 6). The report's Stages section shows the
40
+ same outcome per stage, a failure with its halt_reason, else its exit code.
41
+
33
42
  --context-file also writes the payload skf-emit-result-envelope.py turns into
34
43
  the campaign's SKF_CAMPAIGN_RESULT_JSON line (schemas/
35
44
  skf-campaign-result-envelope.v1.json): the result above, with the report as
@@ -116,14 +125,19 @@ def _export_record(skill: Any) -> Optional[Dict[str, Any]]:
116
125
  return record if isinstance(record, dict) and record.get("status") in ("exported", "failed") else None
117
126
 
118
127
 
128
+ def _failed_cell(record: Dict[str, Any]) -> str:
129
+ """A failure as the report shows it: its halt_reason, else its exit code, else none."""
130
+ why = record.get("halt_reason") or (
131
+ f"exit {record['exit_code']}" if record.get("exit_code") is not None else "no exit code")
132
+ return f"failed ({why})"
133
+
134
+
119
135
  def _export_cell(record: Optional[Dict[str, Any]], verdict: str) -> str:
120
136
  if record is None:
121
137
  return "not exported" if verdict == "fail" else "N/A"
122
138
  if record["status"] == "exported":
123
139
  return "exported"
124
- why = record.get("halt_reason") or (
125
- f"exit {record['exit_code']}" if record.get("exit_code") is not None else "no exit code")
126
- return f"failed ({why})"
140
+ return _failed_cell(record)
127
141
 
128
142
 
129
143
  def _export_outcomes(state: Any) -> Dict[str, Any]:
@@ -146,6 +160,44 @@ def _export_outcomes(state: Any) -> Dict[str, Any]:
146
160
  return {"skills_exported": exported, "export_failures": failures}
147
161
 
148
162
 
163
+ STAGES = (("capstone", "Capstone"), ("verification", "Verification"), ("refinement", "Refinement"))
164
+ STAGE_OUTCOMES = ("completed", "skipped", "failed")
165
+
166
+
167
+ def _stage_record(entry: Any) -> Optional[Dict[str, Any]]:
168
+ """A stage outcome campaign-state.py recorded, with its three keys, or None."""
169
+ if not isinstance(entry, dict) or entry.get("outcome") not in STAGE_OUTCOMES:
170
+ return None
171
+ return {"outcome": entry["outcome"], "halt_reason": entry.get("halt_reason"), "exit_code": entry.get("exit_code")}
172
+
173
+
174
+ def _stage_outcomes(state: Any) -> Optional[Dict[str, Any]]:
175
+ """What the capstone, verification and refinement stages did; null before stage 6 ran."""
176
+ campaign = state.get("campaign") if isinstance(state, dict) else None
177
+ stage = campaign.get("current_stage") if isinstance(campaign, dict) else None
178
+ if not isinstance(stage, int) or isinstance(stage, bool) or stage < 6:
179
+ return None
180
+ held = campaign.get("stage_outcomes")
181
+ held = held if isinstance(held, dict) else {}
182
+ return {field: _stage_record(held.get(field)) for field, _label in STAGES}
183
+
184
+
185
+ def _stages_section(outcomes: Optional[Dict[str, Any]]) -> str:
186
+ if outcomes is None:
187
+ return "The capstone, verification and refinement stages have not run."
188
+ lines = ["| Stage | Outcome |", "|-------|---------|"]
189
+ for field, label in STAGES:
190
+ record = outcomes[field]
191
+ if record is None:
192
+ cell = "not recorded"
193
+ elif record["outcome"] == "failed":
194
+ cell = _failed_cell(record)
195
+ else:
196
+ cell = record["outcome"]
197
+ lines.append(f"| {label} | {cell} |")
198
+ return "\n".join(lines)
199
+
200
+
149
201
  def _export_gate_section(
150
202
  classification: Optional[Dict[str, Any]], state: Optional[Dict[str, Any]] = None
151
203
  ) -> str:
@@ -273,6 +325,7 @@ def _compute_aggregates(
273
325
  "duration_table": "\n".join(duration_table_rows),
274
326
  "failed_skipped_section": "\n".join(failed_skipped_lines),
275
327
  "export_gate_section": _export_gate_section(classification, state),
328
+ "stages_section": _stages_section(_stage_outcomes(state)),
276
329
  }
277
330
 
278
331
 
@@ -324,6 +377,7 @@ def run(
324
377
  "halt_reason": "report-failure",
325
378
  **_counts(state),
326
379
  **_export_outcomes(state),
380
+ "stage_outcomes": _stage_outcomes(state),
327
381
  "campaign_report_path": None,
328
382
  "decision_log": decision_log,
329
383
  })
@@ -377,6 +431,7 @@ def run(
377
431
  None if classification is None else [e["name"] for e in classification["excluded"]]
378
432
  ),
379
433
  **_export_outcomes(state),
434
+ "stage_outcomes": _stage_outcomes(state),
380
435
  }
381
436
  _write_context(context_file, {
382
437
  "status": "success",
@@ -387,6 +442,7 @@ def run(
387
442
  "skills_excluded": result["skills_excluded"],
388
443
  "skills_exported": result["skills_exported"],
389
444
  "export_failures": result["export_failures"],
445
+ "stage_outcomes": result["stage_outcomes"],
390
446
  "campaign_report_path": result["report_path"],
391
447
  "decision_log": decision_log,
392
448
  "duration": result["duration"],
@@ -81,12 +81,21 @@ Operations:
81
81
  (a file or `-`; the line's SKF_*_RESULT_JSON: prefix is
82
82
  optional). A success envelope is recorded; an error
83
83
  envelope, or --no-capstone and the like (a stage
84
- skipped on purpose), records null. --no-result <field>
85
- (repeatable) records a sub-skill that printed no result
86
- line: null, with halt reason no-result in its
87
- not_recorded row; it does not go with that field's
88
- --<field> or --no-<field>. A recorded verification also
89
- sets capstone.verified.
84
+ skipped on purpose), records null. A line that does
85
+ not parse, is not a JSON object, or has no string
86
+ `status` or integer `exit_code` records null with halt
87
+ reason result-unreadable in its not_recorded row (an
88
+ empty line, or a file it cannot read or decode, is
89
+ still exit 2). --no-result <field> (repeatable) records
90
+ a sub-skill that printed no result line: null, with
91
+ halt reason no-result in its not_recorded row; it does
92
+ not go with that field's --<field> or --no-<field>.
93
+ Each field it writes also sets
94
+ campaign.stage_outcomes.<field>: `completed` with the
95
+ envelope's exit_code, `skipped` for --no-<field>, or
96
+ `failed` with the not_recorded row's halt_reason and
97
+ exit_code. A recorded verification also sets
98
+ capstone.verified.
90
99
  recover Copy a .bak that validates over the primary (step-resume).
91
100
  archive Move the state, its .bak and the brief, each one that
92
101
  exists, into archive/<campaign name>-<UTC stamp>/ beside
@@ -150,7 +159,9 @@ Output (one JSON object on stdout):
150
159
  "last_updated", "current_stage", "changed": [...]} plus the
151
160
  operation's own keys (init: "quality_gate", "brief_file", null
152
161
  without --brief-out; apply-batch: "activated", "skipped",
153
- "completed", "failed"; set-campaign: "recorded", "not_recorded")
162
+ "completed", "failed"; set-campaign: "recorded", and
163
+ "not_recorded", one {"field", "status", "halt_reason",
164
+ "exit_code"} row for each field it set to null)
154
165
  recover: {"op", "state_file", "backup_file", "recovered", "last_updated",
155
166
  "current_stage"}
156
167
  archive: {"op", "archive_dir", "moved": [...]}
@@ -423,8 +434,16 @@ def read_json(source: str, label: str) -> dict:
423
434
  return data
424
435
 
425
436
 
426
- def read_envelope(source: str, label: str) -> dict:
427
- """A sub-skill's result envelope: the last SKF_*_RESULT_JSON line, or bare JSON."""
437
+ def read_envelope(source: str, label: str) -> dict | None:
438
+ """A sub-skill's result envelope: the last SKF_*_RESULT_JSON line, or bare JSON.
439
+
440
+ None when that payload does not parse or is not a JSON object, which
441
+ set-campaign records as result-unreadable. json.loads raises ValueError
442
+ on a payload it cannot parse (JSONDecodeError is one, an integer of more
443
+ than 4,300 digits another) and RecursionError on nesting too deep for
444
+ it; both read as a payload that does not parse. An empty payload, or
445
+ input it cannot read or decode, is still exit 2.
446
+ """
428
447
  text = read_text(source, label)
429
448
  lines = [line for line in text.splitlines() if "_RESULT_JSON:" in line and "SKF_" in line]
430
449
  payload = lines[-1].split("_RESULT_JSON:", 1)[1].strip() if lines else text.strip()
@@ -432,11 +451,9 @@ def read_envelope(source: str, label: str) -> dict:
432
451
  raise StateError(EXIT_INPUT, "input-unreadable", f"{label} holds no result envelope")
433
452
  try:
434
453
  data = json.loads(payload)
435
- except json.JSONDecodeError as exc:
436
- raise StateError(EXIT_INPUT, "input-unreadable", f"{label} is not a result envelope: {exc}") from exc
437
- if not isinstance(data, dict):
438
- raise StateError(EXIT_INPUT, "input-unreadable", f"{label} is not a JSON object")
439
- return data
454
+ except (ValueError, RecursionError):
455
+ return None
456
+ return data if isinstance(data, dict) else None
440
457
 
441
458
 
442
459
  def _skills_by_name(state: dict) -> dict[str, dict]:
@@ -792,6 +809,25 @@ def _refinement(envelope: dict, stamp: str) -> dict | None:
792
809
  SUMMARIES = (("capstone", _capstone), ("verification", _verification), ("refinement", _refinement))
793
810
 
794
811
 
812
+ def _readable(envelope: dict | None) -> bool:
813
+ """An envelope set-campaign can read: an object with a string `status` and an integer `exit_code`.
814
+
815
+ The SS, VS and RA envelope schemas all require both, so a line missing
816
+ either is recorded result-unreadable, as step-10 records an Export line.
817
+ """
818
+ if not isinstance(envelope, dict):
819
+ return False
820
+ exit_code = envelope.get("exit_code")
821
+ return isinstance(envelope.get("status"), str) and isinstance(exit_code, int) and not isinstance(exit_code, bool)
822
+
823
+
824
+ def _failed_outcome(row: dict) -> dict:
825
+ """The stage outcome of a field set-campaign did not record, from its not_recorded row."""
826
+ reason = row["halt_reason"]
827
+ return {"outcome": "failed", "halt_reason": reason if isinstance(reason, str) else None,
828
+ "exit_code": row["exit_code"]}
829
+
830
+
795
831
  def op_set_campaign(args: argparse.Namespace) -> dict:
796
832
  no_result = set(args.no_result or ())
797
833
  for field, _build in SUMMARIES:
@@ -811,24 +847,36 @@ def op_set_campaign(args: argparse.Namespace) -> dict:
811
847
  campaign["architecture_doc_path"] = args.architecture_doc_path
812
848
  changed.append("campaign.architecture_doc_path")
813
849
  for field, build in SUMMARIES:
814
- if field in envelopes:
850
+ # Each branch decides the stage outcome; a failed one takes its row's halt_reason and exit_code.
851
+ if field in envelopes and not _readable(envelopes[field]):
852
+ value = None
853
+ row = {"field": field, "status": None, "halt_reason": "result-unreadable", "exit_code": None}
854
+ not_recorded.append(row)
855
+ outcome = _failed_outcome(row)
856
+ elif field in envelopes:
815
857
  envelope = envelopes[field]
816
858
  value = build(envelope, stamp)
817
859
  if value is None:
818
- not_recorded.append({"field": field, "status": envelope.get("status"),
819
- "halt_reason": envelope.get("halt_reason"),
820
- "exit_code": envelope.get("exit_code")})
860
+ row = {"field": field, "status": envelope.get("status"),
861
+ "halt_reason": envelope.get("halt_reason"), "exit_code": envelope.get("exit_code")}
862
+ not_recorded.append(row)
863
+ outcome = _failed_outcome(row)
821
864
  else:
822
865
  recorded.append(field)
866
+ outcome = {"outcome": "completed", "halt_reason": None, "exit_code": envelope["exit_code"]}
823
867
  elif getattr(args, f"no_{field}"):
824
868
  value = None
825
869
  not_recorded.append({"field": field, "status": None, "halt_reason": None, "exit_code": None})
870
+ outcome = {"outcome": "skipped", "halt_reason": None, "exit_code": None}
826
871
  elif field in no_result:
827
872
  value = None
828
- not_recorded.append({"field": field, "status": None, "halt_reason": "no-result", "exit_code": None})
873
+ row = {"field": field, "status": None, "halt_reason": "no-result", "exit_code": None}
874
+ not_recorded.append(row)
875
+ outcome = _failed_outcome(row)
829
876
  else:
830
877
  continue
831
878
  campaign[field] = value
879
+ campaign.setdefault("stage_outcomes", {})[field] = outcome
832
880
  changed.append(f"campaign.{field}")
833
881
  if field == "verification" and isinstance(campaign.get("capstone"), dict):
834
882
  verdict = (value or {}).get("overall_verdict")
@@ -21,6 +21,10 @@
21
21
  |------|------|--------|---------------|-----|-------------|
22
22
  {{skills_table}}
23
23
 
24
+ ## Stages
25
+
26
+ {{stages_section}}
27
+
24
28
  ## Export Gate
25
29
 
26
30
  {{export_gate_section}}
@@ -82,7 +82,7 @@ These rules apply to every step in this workflow:
82
82
  uv run {resolveCustomizationHelper} --skill {skill-root} --project-root {project-root} --key workflow
83
83
  ```
84
84
 
85
- It merges the bundled `{skill-root}/customize.toml` with `{project-root}/_bmad/custom/skf-create-skill.toml` (team overrides, committed) and `.user.toml` (personal overrides, gitignored). When it exits non-zero, prints no JSON or is missing, print one line, `[activation/warn] customization_resolver_unavailable: <reason>` (`<reason>`: its first stderr line, `not found` when the script is missing, `no JSON` when it printed none). If the resolver cannot run, read `{skill-root}/customize.toml` alone and use its bundled defaults: the `{project-root}/_bmad/custom/` overrides do not apply to this run. In that case keep the reason as `{customization_resolver_unavailable}` (unset when the resolver ran) for step 1 §0 to record in each brief's run folder.
85
+ It merges the bundled `{skill-root}/customize.toml` with `{project-root}/_bmad/custom/skf-create-skill.toml` (team overrides, committed) and `.user.toml` (personal overrides, gitignored). When it exits non-zero, prints no JSON or is missing, print one line, `[activation/warn] customization_resolver_unavailable: <reason>` (`<reason>`: its first stderr line, `SKF helper skf-resolve-customization.py not found` when the script is missing, `no JSON` when it printed none). If the resolver cannot run, read `{skill-root}/customize.toml` alone and use its bundled defaults: the `{project-root}/_bmad/custom/` overrides do not apply to this run. In that case keep the reason as `{customization_resolver_unavailable}` (unset when the resolver ran) for step 1 §0 to record in each brief's run folder.
86
86
 
87
87
  Apply the resolved values so the surface is not a silent no-op: execute each entry in `workflow.activation_steps_prepend` in order now; treat every entry in `workflow.persistent_facts` as standing context for the whole run (entries prefixed `file:` are paths or globs whose contents load as facts), except a `!` entry and the entry it names (`!file:{project-root}/**/project-context.md` drops the bundled project-context default); and stash `{onCompleteCommand}` ← `workflow.on_complete` (empty string = no-op) for step 8 to invoke once each brief's result contract is written. After activation completes, execute each entry in `workflow.activation_steps_append` in order.
88
88