bmad-module-skill-forge 6.0.0 → 7.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/docs/_data/pinned.yaml +1 -1
  3. package/docs/workflows.md +4 -4
  4. package/package.json +1 -1
  5. package/src/shared/references/output-contract-schema.md +1 -1
  6. package/src/shared/scripts/skf-quick-batch.py +16 -3
  7. package/src/shared/scripts/skf-verify-provenance-completeness.py +209 -33
  8. package/src/skf-analyze-source/customize.toml +3 -2
  9. package/src/skf-analyze-source/references/auto-docs-only.md +2 -2
  10. package/src/skf-analyze-source/references/generate-briefs.md +5 -3
  11. package/src/skf-analyze-source/references/headless-contract.md +2 -2
  12. package/src/skf-analyze-source/references/step-auto-scope.md +2 -2
  13. package/src/skf-campaign/references/campaign-contracts.md +1 -1
  14. package/src/skf-campaign/references/step-07-capstone.md +3 -3
  15. package/src/skf-campaign/references/step-08-verify.md +3 -3
  16. package/src/skf-campaign/references/step-09-refine.md +3 -3
  17. package/src/skf-campaign/scripts/campaign-state.py +22 -5
  18. package/src/skf-create-skill/SKILL.md +2 -2
  19. package/src/skf-create-skill/customize.toml +4 -4
  20. package/src/skf-create-skill/references/batch-mode.md +3 -1
  21. package/src/skf-create-skill/references/report.md +7 -2
  22. package/src/skf-create-skill/references/validate.md +1 -1
  23. package/src/skf-create-stack-skill/references/validate.md +12 -4
  24. package/src/skf-quick-skill/SKILL.md +1 -1
  25. package/src/skf-quick-skill/references/batch-mode.md +2 -0
  26. package/src/skf-quick-skill/references/write-and-validate.md +19 -9
  27. package/src/skf-test-skill/references/coverage-check.md +2 -2
  28. package/src/skf-test-skill/references/inventory-contract.md +1 -1
  29. package/src/skf-test-skill/references/scoring-rules.md +2 -2
  30. package/src/skf-test-skill/references/signature-comparison-rules.md +3 -1
  31. package/src/skf-test-skill/scripts/load-coverage-inputs.py +241 -11
  32. package/src/skf-test-skill/scripts/score-signatures.py +9 -13
  33. package/src/skf-test-skill/scripts/validate-inventory.py +91 -12
  34. package/src/skf-update-skill/customize.toml +1 -1
  35. package/src/skf-update-skill/references/report.md +10 -3
  36. package/src/skf-update-skill/references/write.md +1 -1
@@ -30,7 +30,7 @@
30
30
  "name": "skill-forge",
31
31
  "source": "./",
32
32
  "description": "Evidence-based agent skills compiler with progressive capability tiers (Quick/Forge/Forge+/Deep).",
33
- "version": "6.0.0",
33
+ "version": "7.0.0",
34
34
  "author": {
35
35
  "name": "Armel"
36
36
  },
@@ -32,7 +32,7 @@
32
32
  # Enforced two ways: (1) release.yaml bumps this line in the same commit
33
33
  # as package.json + marketplace.json on every release; (2) validate-docs-drift.js
34
34
  # cross-checks this value against package.json.version and fails on mismatch.
35
- skf_version: "6.0.0"
35
+ skf_version: "7.0.0"
36
36
 
37
37
  # Path to the oh-my-skills repo, resolved relative to this repo's root.
38
38
  # Override at runtime with the OMS environment variable if oh-my-skills
package/docs/workflows.md CHANGED
@@ -73,7 +73,7 @@ Each workflow is also a skill you can run directly, without Ferris: `/skf-setup`
73
73
 
74
74
  **Which brief:** Name the brief by its path or by the skill's name. With no brief path, skill name or `--batch`, CS loads the only brief in `forge_data_folder` and names it in its opening banner. When the folder holds several, an interactive run asks which one to compile, and a headless run stops with exit `2` (`brief-missing`), naming them.
75
75
 
76
- **Batch mode:** The paths after `--batch` name `skill-brief.yaml` files or folders of briefs; with no path, CS takes the briefs in `forge_data_folder`. Every brief is validated before the first one compiles, and the plan lists the briefs to compile and each refused brief: one that fails the brief schema, with the validator's first message, or one whose `name` an earlier brief of the batch already has. A HARD HALT ends only its own brief. The batch writes `create-skill-batch-<timestamp>.json` and `create-skill-batch-latest.json` under `<skills_output_folder>/_batch/` and exits with the highest exit code among its briefs, `0` when every brief finished. Run again with the same briefs, a batch that an ended session left goes on where it stopped.
76
+ **Batch mode:** The paths after `--batch` name `skill-brief.yaml` files or folders of briefs; with no path, CS takes the briefs in `forge_data_folder`. Every brief is validated before the first one compiles, and the plan lists the briefs to compile and each refused brief: one that fails the brief schema, with the validator's first message, or one whose `name` an earlier brief of the batch already has. A HARD HALT ends only its own brief. The batch writes `create-skill-batch-<timestamp>.json` and `create-skill-batch-latest.json` under `<skills_output_folder>/_batch/` and exits with the highest exit code among its briefs, `0` when every brief finished. Its result is the file its `batch_summary` event's `summary_path` names: the `-latest.json` copy holds the last batch summary written, which may be another batch's. Run again with the same briefs, a batch that an ended session left goes on where it stopped.
77
77
 
78
78
  **Safety:** Writes a version only into a skill folder SKF generated, or a new one. When `skills_output_folder` already holds a folder with the skill's name that SKF did not generate, or a version folder SKF did not generate, it stops before writing anything (`not-skf-output`); an SKF skill still in the old flat layout stops with `flat-layout` until `@Ferris TS` moves it. Set a different `name` in the brief to create the skill beside a folder SKF did not generate.
79
79
 
@@ -549,7 +549,7 @@ Setup is not the only workflow whose result line is the run's final message: a h
549
549
 
550
550
  4. **Success result contract.** A finished run prints one `SKF_QUICK_SKILL_RESULT_JSON: {…}` line on `stdout`. Its `summary` carries `quality_score` (the skill-check score of the written package, not a Test Skill score), `validation_issues`, `repo_shape`, `language_resolution` and `active_pointer`, beside the top-level `headless_decisions` and `warnings`, such as `also_found_in` when a headless run kept the first registry's pick of a package name another registry also holds. The run also writes `quick-skill-result-latest.json` in the skill package.
551
551
 
552
- **Batch mode (`--batch <file>`).** Drives N targets in sequence: each target runs steps 1 to 6, and the health check runs once, after the batch summary. Input format: one target per line, `#` comments and blank lines ignored, optional per-line modifiers `language=<lang>` and `scope=<path>`. Each target's output lands in its own `{skill_package}/`; an aggregated summary writes to `{skills_output_folder}/_batch/quick-skill-batch-{ts}.json` (with `quick-skill-batch-latest.json` copy). Per-target boundary events (`{"batch":N,"target":"…","status":"start|done|fail",…}`) and a final `{"batch_summary":true,…}` event extend the per-step event stream above. The batch exits with the highest exit code among its failed targets (`0` when none failed); `--fail-fast` stops it at the first failed target and exits with that target's code. A batch refused before its first target writes no summary and exits `2`, or `4` when its run folder cannot be written. Full input grammar, summary schema, and exit-code rules in [`src/skf-quick-skill/references/batch-mode.md`](https://github.com/armelhbobdad/bmad-module-skill-forge/blob/main/src/skf-quick-skill/references/batch-mode.md).
552
+ **Batch mode (`--batch <file>`).** Drives N targets in sequence: each target runs steps 1 to 6, and the health check runs once, after the batch summary. Input format: one target per line, `#` comments and blank lines ignored, optional per-line modifiers `language=<lang>` and `scope=<path>`. Each target's output lands in its own `{skill_package}/`; an aggregated summary writes to `{skills_output_folder}/_batch/quick-skill-batch-{ts}.json` (with `quick-skill-batch-latest.json` copy). Per-target boundary events (`{"batch":N,"target":"…","status":"start|done|fail",…}`) and a final `{"batch_summary":true,…}` event extend the per-step event stream above. The batch's result is the file that event's `summary_path` names: the `-latest` copy holds the last batch summary written, which may be another batch's. The batch exits with the highest exit code among its failed targets (`0` when none failed); `--fail-fast` stops it at the first failed target and exits with that target's code. A batch refused before its first target writes no summary and exits `2`, or `4` when its run folder cannot be written. Full input grammar, summary schema, and exit-code rules in [`src/skf-quick-skill/references/batch-mode.md`](https://github.com/armelhbobdad/bmad-module-skill-forge/blob/main/src/skf-quick-skill/references/batch-mode.md).
553
553
 
554
554
  **Exception: `/skf-brief-skill` headless emits a final result envelope and supports presets.** Headless `/skf-brief-skill` is a first-class building block for scripted brief generation (for example, seeding briefs for N SaaS SDKs that share scope and authority defaults). Three operational contracts go beyond per-gate auto-proceed:
555
555
 
@@ -576,7 +576,7 @@ Setup is not the only workflow whose result line is the run's final message: a h
576
576
  - **`/skf-rename-skill` (RS)**: exit `2` `input-missing`/`input-invalid`, `3` resolution-failure (`manifest-corrupt`, `nothing-to-rename`), `4` write-failure (`copy-failed`, `write-failed`, `manifest-write-failed`; the context-file rebuild is best-effort and never halts), `5` state-conflict (`name-collision`, `source-authority-blocked`, `halted-for-concurrent-run` while another rename of the same skill holds its run lock or when this run's own lock went stale while it waited at a prompt, `not-skf-output` for a folder SKF did not generate, or a forge folder that also holds other files or that is a link, is not a folder, or cannot be listed; `flat-layout` for a skill not yet migrated; `verify-failed` when the old name is still in the renamed files, which rolls the rename back with the old skill intact, see [Troubleshooting](/docs/troubleshooting.md)). The run lock is `.skf-rename-<name>.lock` in `forge_data_folder`, and it goes stale 60 minutes after it was taken or renewed: when no other rename of that skill is running, delete the lock file the message names, or wait until the time it gives, then run the rename again. When this run's own lock went stale at a prompt, nothing was changed: run the rename again. Exit codes in [`src/skf-rename-skill/references/exit-codes.md`](https://github.com/armelhbobdad/bmad-module-skill-forge/blob/main/src/skf-rename-skill/references/exit-codes.md); the envelope in [`src/skf-rename-skill/references/invocation-contract.md`](https://github.com/armelhbobdad/bmad-module-skill-forge/blob/main/src/skf-rename-skill/references/invocation-contract.md) § "Result Contract (Headless)".
577
577
  - **`/skf-refine-architecture` (RA)**: exit `2` `input-missing`/`input-invalid` (`input-invalid` also covers a Verify Stack report with a `schemaVersion` other than `1.0`, a missing or doubled verdict table, or an unknown verdict token), `3` resolution-failure (`output-folder-unconfigured`, `forge-folder-unconfigured`, and `resolution-failure` when the result emitter or `scripts/skf-check-preservation.py` cannot start, when the refinement rules cannot be read, lack one of their six tables or break a tier rule (phase `init:rules`), or when the skill inventory helper (phase `init:inventory`) or the co-mention helper (phase `gap-analysis:comention`) is missing or its call fails, or the Verify Stack report validator (phase `init:feasibility-validator`) is missing), `4` write-failure (`write-failed`, and `preservation-failed` when the draft would lose or change a line of the original: at once in a headless run, or when the promotion still finds it after one rebuild), `5` state-conflict (`insufficient-skills`: no skill SKF generated was found), `7` `inventory-unreliable`, `8` `recovery-failed` (at step 3, the Verify Stack report cannot be read again, Verify Stack rewrote it during the run, or the refinement rules no longer pass step 1's check; the saved state is not enough to rebuild the findings; or a record of the draft's build or promotion is missing at step 6). Exit codes and the envelope in [`src/skf-refine-architecture/references/exit-codes.md`](https://github.com/armelhbobdad/bmad-module-skill-forge/blob/main/src/skf-refine-architecture/references/exit-codes.md).
578
578
 
579
- **Reading the result lines.** Read the run's own line: a run that printed none has no result to read, whatever the process exit code (`claude -p` exits `0` when the agent ends its turn early): it was cut off, or could not write its result. Treat it as a failure, and never read a `-latest.json` result file in its place, since that file may then describe an earlier run. A cut-off run can also leave its run lock until the lock goes stale, 60 minutes after it was taken or renewed: Test Skill's `.test-skill.lock`, Update Skill's `.skf-update.lock` or Rename Skill's `.skf-rename-<name>.lock` ([Troubleshooting](/docs/troubleshooting.md#test-skill-stops-with-another-run-active)). Check each line's `status` field first (for US, `skf_update.status`). For most workflows it only says whether the run finished or stopped on an error, so read the outcome from the fields below: a TS run whose skill scores below its threshold still reports `status: "success"`, with `verdict: "FAIL"`. AN, CS, SS, AS, TS, EX and VS also give each outcome a stable exit code, which the line carries as `exit_code` (a CS halt has `2` to `6`).
579
+ **Reading the result lines.** Read the run's own line: a run that printed none has no result to read, whatever the process exit code (`claude -p` exits `0` when the agent ends its turn early): it was cut off, or could not write its result. Treat it as a failure, and never read a `-latest.json` result file in its place, since that file may then describe an earlier run. A Quick Skill or Create Skill `--batch` run is read from its `batch_summary` event and the summary file that event's `summary_path` names (the event plus the batch run folder's `batch.jsonl` when `summary_path` is null), never from `quick-skill-batch-latest.json` or `create-skill-batch-latest.json`: each holds the last batch summary written, which may be another batch's. The event comes before the health check, so a caller reads it from the session's output stream (`claude -p --output-format stream-json`) or transcript, not from the final message. A printed `batch_summary` event stays the batch's result even if a halt line follows it. A batch that printed no `batch_summary` event has no batch result, whatever the process exit code: a halt line of its own (phase `batch-mode`, or `on-activation` for Quick Skill) says why it stopped, and with no halt line it was cut off or could not print its halt line. Each target it printed a `done` or `fail` event for ended as that event says, and a target with only a `start` event has no result. After a cut-off batch, run it again: a Quick Skill batch starts over from the first target, and a Create Skill batch run with the same briefs goes on with the brief it was working on. A cut-off run can also leave its run lock until the lock goes stale, 60 minutes after it was taken or renewed: Test Skill's `.test-skill.lock`, Update Skill's `.skf-update.lock` or Rename Skill's `.skf-rename-<name>.lock` ([Troubleshooting](/docs/troubleshooting.md#test-skill-stops-with-another-run-active)). Check each line's `status` field first (for US, `skf_update.status`). For most workflows it only says whether the run finished or stopped on an error, so read the outcome from the fields below: a TS run whose skill scores below its threshold still reports `status: "success"`, with `verdict: "FAIL"`. AN, CS, SS, AS, TS, EX and VS also give each outcome a stable exit code, which the line carries as `exit_code` (a CS halt has `2` to `6`).
580
580
 
581
581
  | Workflow | Result line | Fields worth reading |
582
582
  | --- | --- | --- |
@@ -605,7 +605,7 @@ Settings every workflow has:
605
605
 
606
606
  - `persistent_facts`: rules the workflow keeps in mind for the whole run. Every workflow except Setup Forge loads any `project-context.md` in your project by default. In a team or personal override, an entry that starts with `!` drops each earlier entry it names and loads nothing itself. To turn that default off for one workflow, add `persistent_facts = ["!file:{project-root}/**/project-context.md"]` under `[workflow]` in `_bmad/custom/<skill-name>.toml`. Every workflow that loads the default honours such an entry, and Campaign's kickoff loader drops the default from each skill's kickoff the same way.
607
607
  - `activation_steps_prepend` and `activation_steps_append`: extra steps to run before or after start-up.
608
- - `on_complete`: a command to run after the workflow writes its result file, such as a notifier. Every workflow except Campaign, Stack Skill, Verify Stack and Setup Forge (Quick Skill included) calls it with `--result-path=<result file>` (for Brief Skill, the `skill-brief.yaml` it wrote), and the hook is skipped when the emitter wrote no result file. Quick Skill's hook gets `--result-path=<skill package>/quick-skill-result-latest.json`, where the skill package is that file's folder, and is skipped when the emitter wrote no result file, or did not write that `-latest` copy. Campaign passes `--report-path=<report>` when it wrote its report, Stack Skill runs the command with no argument, and Setup Forge and Verify Stack carry it out as an instruction once they finish. A failing command never fails the workflow.
608
+ - `on_complete`: a command to run after the workflow writes its result file, such as a notifier. Every workflow except Campaign, Stack Skill, Verify Stack and Setup Forge (Quick Skill included) calls it with `--result-path=<result file>` (for Brief Skill, the `skill-brief.yaml` it wrote), and the hook is skipped when the emitter wrote no result file. Quick Skill's hook gets `--result-path=<skill package>/quick-skill-result-latest.json`, where the skill package is that file's folder, and is skipped when the emitter wrote no result file, or did not write that `-latest` copy. Analyze Source, Create Skill and Update Skill pass their `-latest` copy too (`<forge_data_folder>/analyze-source-result-latest.json`, `<version folder>/create-skill-result-latest.json` and `<version folder>/update-skill-result-latest.json`), and skip the hook, with a line that says so, when the run did not write it. Campaign passes `--report-path=<report>` when it wrote its report, Stack Skill runs the command with no argument, and Setup Forge and Verify Stack carry it out as an instruction once they finish. A failing command never fails the workflow.
609
609
 
610
610
  Useful settings in specific workflows:
611
611
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "bmad-module-skill-forge",
4
- "version": "6.0.0",
4
+ "version": "7.0.0",
5
5
  "description": "BMAD module — Turn code and docs into instructions AI agents can actually follow. Progressive capability tiers (Quick/Forge/Forge+/Deep).",
6
6
  "keywords": [
7
7
  "bmad",
@@ -38,7 +38,7 @@ Each run writes **two files** to `{output_dir}`:
38
38
 
39
39
  Write the per-run record first, then copy it to the `-latest.json` path. If the copy fails, the per-run record still exists — the run is not lost.
40
40
 
41
- **Consumers (forger, CI, chained workflows):** a caller that started a run reads that run's own `SKF_<NAME>_RESULT_JSON` envelope line. A run that printed no envelope line has no result to read, whatever the process exit code: it was cut off, or could not write its result, and `{skill-name}-result-latest.json` may then describe an earlier run, so it is never read in its place. A tool that looks up the last run of a skill rather than one it started (skf-find-test-report.py, forge-status.py) may read the copy. Do not enumerate timestamped files unless inspecting prior-run history.
41
+ **Consumers (forger, CI, chained workflows):** a caller that started a run reads that run's own `SKF_<NAME>_RESULT_JSON` envelope line. A run that printed no envelope line has no result to read, whatever the process exit code: it was cut off, or could not write its result, and `{skill-name}-result-latest.json` may then describe an earlier run, so it is never read in its place. A tool that looks up the last run of a skill rather than one it started (skf-find-test-report.py, forge-status.py) may read the copy. Do not enumerate timestamped files unless inspecting prior-run history. Under `--batch` (skf-quick-skill, skf-create-skill), a batch's result is its `batch_summary` event and the summary file its `summary_path` names, never the `quick-skill-batch-latest.json` or `create-skill-batch-latest.json` copy: that copy holds the last batch summary written, which may be another batch's, and a batch that printed no `batch_summary` event has no batch result (each workflow's `references/batch-mode.md`).
42
42
 
43
43
  ## Writing the Files
44
44
 
@@ -124,6 +124,15 @@ summarize
124
124
  "succeeded", "failed", "status", "fail_fast_triggered", "exit_code",
125
125
  "summary_path"}, with "warnings" when a summary file could not be
126
126
  written. A batch already summarized prints its event again.
127
+ The batch's result is that event and the file its summary_path names
128
+ (with summary_path null, when the per-batch file could not be written,
129
+ the event and batch.jsonl; a summary_write_failed warning beside a set
130
+ summary_path means only the copy failed): <stem>-latest.json holds the
131
+ last batch summary written, which may be another batch's, so it never
132
+ stands for this batch, and a batch cut off before summarize leaves it in
133
+ place. A summarize on a run folder that is gone (a batch whose targets
134
+ all succeeded, summarized and removed) exits 1, naming the per-batch
135
+ file whose input_file (or input_briefs) is this batch's.
127
136
 
128
137
  The batch file is one JSON object per line, so a write cut short spoils at
129
138
  most its own line, which is skipped when read: the next write ends that
@@ -628,10 +637,14 @@ def summary_event(summary: dict) -> dict:
628
637
 
629
638
  def cmd_summarize(run_dir: Path, output_dir: Path) -> dict:
630
639
  if not (run_dir / BATCH_FILE).exists() and not run_dir.exists():
631
- stem = SUMMARY_STEMS[CREATE_WORKFLOW if run_dir.name.startswith(f"{CREATE_WORKFLOW}-") else WORKFLOW]
640
+ create = run_dir.name.startswith(f"{CREATE_WORKFLOW}-")
641
+ stem = SUMMARY_STEMS[CREATE_WORKFLOW if create else WORKFLOW]
642
+ names = "input_briefs name its briefs" if create else "input_file names its batch file"
632
643
  raise BatchError(f"no batch in {run_dir.as_posix()}: a batch whose targets all succeeded removes its "
633
- f"run folder once summarized; its summary is "
634
- f"{(output_dir / f'{stem}-latest.json').as_posix()}")
644
+ f"run folder once summarized; its summary is the {stem}-<YYYYMMDD-HHmmss>.json in "
645
+ f"{output_dir.as_posix()} whose {names}, and "
646
+ f"{(output_dir / f'{stem}-latest.json').as_posix()} holds the last batch summary "
647
+ f"written, which may be another batch's")
635
648
  state = load(run_dir)
636
649
  name = workflow(state)
637
650
  if state["summary"] is not None:
@@ -455,7 +455,8 @@ Subcommands:
455
455
  "source": "<source_file>:<source_line>" | "<source_file>" | null,
456
456
  "defined_at": "<path>:<line>" | null,
457
457
  "declared_in": ["<path>:<line>", ...] | null,
458
- "reason": "not-defined" | "defined" | "unchecked" | "no-entry",
458
+ "reason": "not-defined" | "aliased" | "defined" | "unchecked"
459
+ | "no-entry",
459
460
  "entries": [{"source_file": "<as recorded>",
460
461
  "source_line": <as recorded>,
461
462
  "line_check": "checked" | "file-missing"
@@ -469,9 +470,30 @@ Subcommands:
469
470
  `fabricated` is true, with reason `not-defined`, when the name has an
470
471
  entry and every entry's `line_check` is `file-missing`, or `checked`
471
472
  with an empty `definition_lines`: the export is in neither the surface
472
- nor the cited file. Otherwise the reason is `defined` (a cited file
473
- defines the name), `unchecked` (no rule could check a cited file, and
474
- none defines it) or `no-entry` (the map has no entry for the name).
473
+ nor the cited file, and no item of an export list or of a Python
474
+ `from X import` clause elsewhere renames to it a name that file
475
+ declares. Such an alias (`export { API_Entry as Entry }` or
476
+ `export type { ... }`, on one line or an item of a multi-line list;
477
+ `from X import L as NAME`, on one line or an item in parentheses or
478
+ after a backslash) gives the reason `aliased` and `fabricated` false:
479
+ the first of the name's declarations the lookup below finds (its
480
+ `declared_in`, else the cited file's) that is such an item
481
+ `L as NAME`, where a file of a `checked` entry of the name declares
482
+ `L` by the same rule (no module or package so named counting), is its
483
+ `defined_at`. A TS/JS line is read with its comments removed and its
484
+ one-line strings blanked, a Python line with its `#` comment cut.
485
+ Neither the rename's `from` module nor `L`'s own binding is resolved,
486
+ so an `L` that another file declares under the same name counts too.
487
+ Any other rename stays fabricated: `export { default as NAME }`,
488
+ `const NAME = L`, `type NAME = L`, an import then an export of NAME,
489
+ Python `NAME = L`, a TS type assertion (`x as NAME`) and `as NAME` in
490
+ a comment or a string; and so does a name whose cited file is the
491
+ module so named and itself the export (a column-0 `export default` or
492
+ `module.exports =`), which settles it at its line 1, so no walk looks
493
+ for an alias elsewhere.
494
+ Otherwise the reason is `defined` (a cited file defines the name),
495
+ `unchecked` (no rule could check a cited file, and none defines it) or
496
+ `no-entry` (the map has no entry for the name).
475
497
  An entry with no `source_file` (update-skill writes one for an
476
498
  `unknown` NEW_EXPORT) cites no file to check: its `line_check` is
477
499
  `no-file`, which, like a skipped or unreadable check, neither proves
@@ -483,7 +505,8 @@ Subcommands:
483
505
  `defined_at` is where the source declares a name that is not
484
506
  fabricated, so test-skill can tell a documented extra (a name outside
485
507
  the surface that the source still declares) from stale documentation;
486
- a fabricated name is not looked up (null). A declaration is a line at
508
+ a fabricated name has none and no `declared_in` (both null), the
509
+ lookup reading it only for an alias. A declaration is a line at
487
510
  column 0 that the definition-line rules match with imports left out:
488
511
  a `def`, `class`, assignment, annotation, type alias, TS/JS
489
512
  declaration, `exports.NAME =`, or an alias that renames
@@ -498,9 +521,12 @@ Subcommands:
498
521
  `const NAME = require(...)`, `const { NAME } = require(...)`,
499
522
  `exports.NAME = require(...)`, `{ NAME: require(...) }`), an
500
523
  indented line and any line of a Python
501
- file that does not tokenize never count. A module or package named
502
- NAME (`NAME.py`, `NAME/__init__.py`, a TS/JS `NAME.<ext>` or
503
- `NAME/index.<ext>`) declares NAME too: at its line 1 when no line of it
524
+ file that does not tokenize never count (an item of a multi-line list
525
+ counts at its own line, the list's opening line at column 0, so an
526
+ `aliased` name's `defined_at` is its rename's line). A module or
527
+ package named NAME (`NAME.py`, `NAME/__init__.py`, a TS/JS
528
+ `NAME.<ext>` or `NAME/index.<ext>`) declares NAME too: at its line 1
529
+ when no line of it
504
530
  declares NAME, unless `--inventory` gives NAME a `function`, `hook`,
505
531
  `class`, `interface`, `enum` or `type` kind (any of its kinds, when it
506
532
  gives several; a `method` kind is not read), no entry of NAME is a
@@ -524,10 +550,12 @@ Subcommands:
524
550
  and `defined_at` is the one item when the list has exactly one and no
525
551
  walked Python file that does not tokenize holds the name (a homonym,
526
552
  two files or more, gives null: test-skill decides it by the files the
527
- skill cites). The walk reads, sorted by path, every file below the
528
- walk root: the deepest folder that the files of the map's entries
529
- share, widened to its package root (Python: the nearest folder at or
530
- above it that holds `__init__.py`, then up while the parent holds
553
+ skill cites), save for an `aliased` name, whose `defined_at` is its
554
+ alias whatever the list holds. The walk reads, sorted by path, every
555
+ file below the walk root: the deepest folder that the files of the
556
+ map's entries share, widened to its package root (Python: the nearest
557
+ folder at or above it that holds `__init__.py`, then up while the
558
+ parent holds
531
559
  one, never the source root itself; TS/JS: the nearest folder at or
532
560
  above it that holds `package.json`), leaving out the entries whose
533
561
  file lies outside every package; the source root when none lies in
@@ -551,9 +579,12 @@ Subcommands:
551
579
  skips, those walk-only ones aside. The path is relative to the source
552
580
  root. A dotted name, or a map whose entries cite files
553
581
  but no Python or TS/JS one, is not looked up (`defined_at` and
554
- `declared_in` null, as for a fabricated name), and a filesystem error
555
- during the lookup gives every name null in both (the exit code stays
556
- 0).
582
+ `declared_in` null, as for a fabricated name). A filesystem error
583
+ during the lookup of the names that are not fabricated gives each of
584
+ them null in both (the exit code stays 0). The `not-defined` names are
585
+ looked up apart, after the others and whether or not that lookup
586
+ failed: an error in their walk or their alias check keeps only them
587
+ fabricated, and clears no other name's answer.
557
588
 
558
589
  kind-at --file <path> --line <n> --recipes <recipes>
559
590
  [--language <lang>] [--name <export_name>]
@@ -2360,6 +2391,8 @@ STALE_NOT_DEFINED = "not-defined"
2360
2391
  STALE_DEFINED = "defined"
2361
2392
  STALE_UNCHECKED = "unchecked"
2362
2393
  STALE_NO_ENTRY = "no-entry"
2394
+ # a not-defined name a declaration renames to it from a name a cited file declares
2395
+ STALE_ALIASED = "aliased"
2363
2396
 
2364
2397
 
2365
2398
  def stale_names(data: object) -> list[str]:
@@ -2745,6 +2778,68 @@ def baseline_gaps(
2745
2778
  return gaps, unchecked
2746
2779
 
2747
2780
 
2781
+ # A one-line TS/JS string literal, blanked before an export list is read.
2782
+ _JS_STRING_RE = re.compile(r"'(?:[^'\\]|\\.)*'|\"(?:[^\"\\]|\\.)*\"|`(?:[^`\\]|\\.)*`")
2783
+ _JS_IDENT_RE = re.compile(r"[A-Za-z_$][\w$]*")
2784
+
2785
+
2786
+ def _renamed_locals(lines: list[str], index: int, name: str, family: str) -> list[str]:
2787
+ """The names line `index` (from 0) of a file renames to `name` as an item
2788
+ of an export list (`export { L as NAME }` or `export type { ... }`, on
2789
+ one line or an item of a multi-line list) or of a Python
2790
+ `from X import` clause (on one line, or an item of one in parentheses
2791
+ or continued with a backslash): each `L` an identifier other than
2792
+ `name`, and in TS/JS other than `default` (`export { default as NAME }`
2793
+ renames the default export, no declared name). A TS/JS line is read
2794
+ with its `//` and `/* */` comments removed and each one-line string
2795
+ blanked, a Python line with its `#` comment cut (a `from X import`
2796
+ clause holds no string). The list opens at the nearest column-0
2797
+ `export {` (`from X import`) at or above the line, and must not close
2798
+ above it. Empty for any other line: `as NAME` in a comment or a string,
2799
+ a TS type assertion (`const NAME = x as NAME`), `import { L as NAME }`
2800
+ and Python's `import M as NAME` rename nothing here. Neither the `from`
2801
+ module nor the binding of `L` is resolved: the caller takes `L` as
2802
+ the name a cited file declares."""
2803
+ if not 0 <= index < len(lines) or not re.search(
2804
+ rf"(?<![\w$])as\s+{re.escape(name)}(?![\w$])", lines[index]
2805
+ ):
2806
+ return []
2807
+ python = family == "python"
2808
+ if python:
2809
+ code = [line.split("#", 1)[0] for line in lines[: index + 1]]
2810
+ opener, close, item_re = _PY_FROM_IMPORT_RE, ")", _PY_IMPORT_ITEM_RE
2811
+ else:
2812
+ code = [_JS_STRING_RE.sub('""', line) for line in _js_code_lines(lines[: index + 1])]
2813
+ opener, close, item_re = _JS_EXPORT_LIST_RE, "}", _JS_EXPORT_ITEM_RE
2814
+ for start in range(index, -1, -1):
2815
+ opened = opener.match(code[start]) if _is_column_0(code[start]) else None
2816
+ if opened is not None:
2817
+ break
2818
+ if start < index and close in code[start]:
2819
+ return [] # a list that closes above the line holds it not
2820
+ else:
2821
+ return []
2822
+ head = opened.group(1)
2823
+ if start < index:
2824
+ # an item of a list the opening line leaves open: in parentheses, or
2825
+ # a Python clause each of whose lines above ends with a backslash
2826
+ continued = all(line.rstrip().endswith("\\") for line in code[start:index])
2827
+ if close in head or (python and not head.lstrip().startswith("(") and not continued):
2828
+ return []
2829
+ head = code[index]
2830
+ locals_: list[str] = []
2831
+ for item in head.split(close, 1)[0].replace("(", " ").replace("\\", " ").split(","):
2832
+ found = item_re.match(item.strip())
2833
+ if found is None or found.group(2) != name:
2834
+ continue
2835
+ local = found.group(1)
2836
+ if local == name or (not python and local == "default"):
2837
+ continue
2838
+ if (_PY_IDENT_RE if python else _JS_IDENT_RE).fullmatch(local):
2839
+ locals_.append(local)
2840
+ return locals_
2841
+
2842
+
2748
2843
  class _DeclarationLookup:
2749
2844
  """Where the source declares the stale names (classify-stale's
2750
2845
  `defined_at`): a column-0 declaration, an alias that renames included, a
@@ -2766,7 +2861,10 @@ class _DeclarationLookup:
2766
2861
  on its path and the same skips but the walk-only ones, judged below the
2767
2862
  walk root when it lies there. The walk runs once for all the names no
2768
2863
  cited file settles: each file is read once, and only a file whose text
2769
- holds a name goes through the rules. Any error of the filesystem raises
2864
+ holds a name goes through the rules. `renamed` then tells which of a
2865
+ name's declarations is an alias of a name a cited file declares (an
2866
+ item of an export list or of a Python `from X import` clause), from
2867
+ the lines the lookup read. Any error of the filesystem raises
2770
2868
  (`_LOOKUP_ERRORS`)."""
2771
2869
 
2772
2870
  def __init__(self, entries: list[dict], source_root: Path) -> None:
@@ -2786,6 +2884,9 @@ class _DeclarationLookup:
2786
2884
  # the cited files read, kept for the walk: a few files at most
2787
2885
  self._cited: dict[tuple[str, ...], list[str] | None] = {}
2788
2886
  self._cited_cache = _SourceCache()
2887
+ # during the suspects' pass only (`keep_declaring`): the lines of each
2888
+ # file a declaration was found in, by path, which `renamed` reads
2889
+ self._declaring: dict[str, list[str]] | None = None
2789
2890
 
2790
2891
  # -- the walk root ------------------------------------------------------
2791
2892
 
@@ -2921,8 +3022,8 @@ class _DeclarationLookup:
2921
3022
  found.append((*below, filename))
2922
3023
  return sorted(found)
2923
3024
 
2924
- @staticmethod
2925
3025
  def _first(
3026
+ self,
2926
3027
  parts: tuple[str, ...],
2927
3028
  lines: list[str],
2928
3029
  name: str,
@@ -2931,11 +3032,16 @@ class _DeclarationLookup:
2931
3032
  ) -> str | None:
2932
3033
  """`file:line` of the first line of a file that declares `name`, else,
2933
3034
  with `module_fallback`, its line 1 when it is the module or package
2934
- so named (`declared_line`)."""
3035
+ so named (`declared_line`). After `keep_declaring`, the file's lines
3036
+ are kept for `renamed`."""
2935
3037
  line = declared_line(
2936
3038
  parts, name, lines=lines, cache=cache, module_fallback=module_fallback
2937
3039
  )
2938
- return f"{'/'.join(parts)}:{line}" if line is not None else None
3040
+ if line is None:
3041
+ return None
3042
+ if self._declaring is not None:
3043
+ self._declaring['/'.join(parts)] = lines
3044
+ return f"{'/'.join(parts)}:{line}"
2939
3045
 
2940
3046
  # -- the answer ---------------------------------------------------------
2941
3047
 
@@ -3018,6 +3124,36 @@ class _DeclarationLookup:
3018
3124
  hits[name].append(hit)
3019
3125
  return {name: sorted(_without_stubs(h)) for name, h in hits.items()}, unsure
3020
3126
 
3127
+ def keep_declaring(self) -> None:
3128
+ """From now on, keep the lines of each file a declaration is found in,
3129
+ for `renamed`: the suspects' pass, which comes last."""
3130
+ self._declaring = {}
3131
+
3132
+ def renamed(self, name: str, hits: list[str], files: list[str]) -> str | None:
3133
+ """The first of `hits` (`file:line` declarations of `name` the lookup
3134
+ found, sorted) whose line renames to `name`, as an item of an export
3135
+ list or of a Python `from X import` clause (`_renamed_locals`), a
3136
+ name `L` one of `files` (source_file values, as recorded) declares:
3137
+ `declared_line` gives `L` a line of that file, no module or package
3138
+ so named counting. The lines are the ones the lookup read after
3139
+ `keep_declaring` (`_first`), never read again. None when no hit is
3140
+ such an alias."""
3141
+ cited = list(dict.fromkeys(filter(None, map(_cited_parts, files))))
3142
+ for hit in sorted(hits):
3143
+ file, _, number = hit.rpartition(":")
3144
+ lines, family = (self._declaring or {}).get(file), _rule_family(file)
3145
+ if lines is None or family is None or not number.isdigit():
3146
+ continue
3147
+ for local in _renamed_locals(lines, int(number) - 1, name, family):
3148
+ for parts in cited:
3149
+ source = self._cited_lines(parts)
3150
+ if source is not None and declared_line(
3151
+ parts, local, lines=source, cache=self._cited_cache,
3152
+ module_fallback=False,
3153
+ ) is not None:
3154
+ return hit
3155
+ return None
3156
+
3021
3157
 
3022
3158
  def inventory_kinds(data: object, path: str) -> dict[str, frozenset[str]]:
3023
3159
  """name -> the kinds a documented inventory gives it (`--inventory`):
@@ -3054,18 +3190,26 @@ def classify_stale(
3054
3190
  """The fabricated-signature test for each stale name: the definition-lines
3055
3191
  rules at every provenance entry whose raw `export_name` is the name, and
3056
3192
  for a name that is not fabricated, where the source declares it
3057
- (`defined_at`, and `declared_in` when the walk looked for it). The
3058
- classify-stale section of the module docstring gives each reason, what
3059
- a `no-file` entry means, which entry gives `source` and how
3060
- `defined_at` and `declared_in` are found. `kinds` (`inventory_kinds`,
3061
- None without `--inventory`) keeps a name of a `NO_MODULE_KINDS` kind
3062
- from a module or package so named, unless an entry of the name is a
3063
- `module` or `package` one.
3193
+ (`defined_at`, and `declared_in` when the walk looked for it). A name
3194
+ the cited files do not define is looked up too, after the others and
3195
+ in a lookup of its own (an error there keeps it fabricated and clears
3196
+ no other name), with no module or package so named counting unless it
3197
+ is the export, and is `aliased`, not fabricated, when one of its
3198
+ declarations is an export-list or `from X import` item renaming to it
3199
+ a name a `checked` entry's file declares (`_DeclarationLookup.renamed`);
3200
+ any other such name keeps both null. The classify-stale section of the
3201
+ module docstring gives each reason, what a `no-file` entry means, which
3202
+ entry gives `source` and how `defined_at` and `declared_in` are found.
3203
+ `kinds` (`inventory_kinds`, None without `--inventory`) keeps a name of
3204
+ a `NO_MODULE_KINDS` kind from a module or package so named, unless an
3205
+ entry of the name is a `module` or `package` one.
3064
3206
  """
3065
3207
  entries = [e for e in prov.get("entries") or [] if isinstance(e, dict)]
3066
3208
  results: list[dict] = []
3067
- # the names to look up (never a fabricated one): name -> its cited files
3209
+ # the names to look up that are not fabricated: name -> its cited files
3068
3210
  cited: dict[str, list[str]] = {}
3211
+ # the not-defined names an alias may clear: name -> its checked entries' files
3212
+ suspects: dict[str, list[str]] = {}
3069
3213
  # the names a module so named does not declare
3070
3214
  strict: set[str] = set()
3071
3215
  for name in names:
@@ -3097,7 +3241,12 @@ def classify_stale(
3097
3241
  reason = STALE_UNCHECKED
3098
3242
  source = next(filter(None, map(_entry_citation, mine)), None)
3099
3243
  fabricated = reason == STALE_NOT_DEFINED
3100
- if not fabricated:
3244
+ if fabricated:
3245
+ # a missing file declares nothing an alias could rename
3246
+ checked = [c["source_file"] for c in checks if c["line_check"] == "checked"]
3247
+ if checked:
3248
+ suspects[name] = checked
3249
+ else:
3101
3250
  cited[name] = [f for f in map(_entry_file, mine) if f is not None]
3102
3251
  documented = (kinds or {}).get(name, frozenset())
3103
3252
  if documented & NO_MODULE_KINDS and not any(map(_module_entry, mine)):
@@ -3113,16 +3262,43 @@ def classify_stale(
3113
3262
  "entries": checks,
3114
3263
  }
3115
3264
  )
3265
+ lookup: _DeclarationLookup | None = None
3116
3266
  try:
3117
- found, declared = _DeclarationLookup(entries, source_root).find(
3118
- cited, frozenset(strict)
3119
- )
3267
+ lookup = _DeclarationLookup(entries, source_root)
3268
+ found, declared = lookup.find(cited, frozenset(strict))
3120
3269
  except _LOOKUP_ERRORS:
3121
3270
  # a lookup that fails proves nothing: every name stays null
3122
3271
  found, declared = {}, {}
3272
+ # the suspects' own lookup, after: an error there clears no name settled
3273
+ # above, and keeps every suspect fabricated
3274
+ aliased: dict[str, str] = {}
3275
+ suspect_declared: dict[str, list[str]] = {}
3276
+ if suspects and lookup is not None:
3277
+ try:
3278
+ # a suspect is strict: a cited module so named settles it before
3279
+ # the walk only when it is the export
3280
+ lookup.keep_declaring()
3281
+ suspect_found, suspect_declared = lookup.find(suspects, frozenset(suspects))
3282
+ for name, files in suspects.items():
3283
+ hits = suspect_declared[name] if name in suspect_declared else (
3284
+ [suspect_found[name]] if name in suspect_found else []
3285
+ )
3286
+ hit = lookup.renamed(name, hits, files)
3287
+ if hit is not None:
3288
+ aliased[name] = hit
3289
+ except _LOOKUP_ERRORS:
3290
+ aliased, suspect_declared = {}, {}
3123
3291
  for item in results:
3124
- item["defined_at"] = found.get(item["name"])
3125
- item["declared_in"] = declared.get(item["name"])
3292
+ name = item["name"]
3293
+ if name in suspects:
3294
+ if name in aliased:
3295
+ item.update(
3296
+ fabricated=False, reason=STALE_ALIASED,
3297
+ defined_at=aliased[name], declared_in=suspect_declared.get(name),
3298
+ )
3299
+ continue # any other suspect stays fabricated, both null
3300
+ item["defined_at"] = found.get(name)
3301
+ item["declared_in"] = declared.get(name)
3126
3302
  return results
3127
3303
 
3128
3304
 
@@ -59,8 +59,9 @@ analysis_report_template_path = "templates/analysis-report-template.md"
59
59
  # written the result files, at the end of every run that writes them
60
60
  # (generate-briefs.md, step-auto-scope.md and auto-docs-only.md), as:
61
61
  # <on_complete> --result-path=<forge_data_folder>/analyze-source-result-latest.json
62
- # A run whose result file could not be written (result_path null in the
63
- # envelope) and a HALT call no hook. Useful for Slack notifications,
62
+ # A HALT calls no hook, nor does a run whose result file or its -latest
63
+ # copy could not be written (result_path null, or a result_file_write_failed
64
+ # warning naming the copy, in the envelope). Useful for Slack notifications,
64
65
  # dashboard ingest, CI hooks, or chaining into a brief linter. A hook
65
66
  # failure is displayed and never fails the run.
66
67
  #
@@ -145,9 +145,9 @@ Add `"coexistence": "alongside"` when `{coexistence_suffix}` is non-empty ([A]lo
145
145
  uv run {emitEnvelopeHelper} emit --workflow skf-analyze-source --run-dir "{run_dir}" --result-dir "{forge_data_folder}" < "{run_dir}/result-context.json"
146
146
  ```
147
147
 
148
- The emitter stamps the UTC time and the run id into the record, writes `{forge_data_folder}/analyze-source-result-{YYYYMMDD-HHmmss}.json` and its `analyze-source-result-latest.json` copy, and prints the `SKF_ANALYZE_RESULT_JSON:` line on stdout. When `{headless_mode}` is true, display the line verbatim. A result file that could not be written leaves the line's `result_path` null and a `result_file_write_failed` warning in it, 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, HARD HALT (exit code 4, `halt_reason: "write-failed"`, phase `auto-docs-only:5`, path `{forge_data_folder}`): "The result contract could not be written: {its message}."
148
+ The emitter stamps the UTC time and the run id into the record, writes `{forge_data_folder}/analyze-source-result-{YYYYMMDD-HHmmss}.json` and its `analyze-source-result-latest.json` copy, and prints the `SKF_ANALYZE_RESULT_JSON:` line on stdout. When `{headless_mode}` is true, display the line verbatim. 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, HARD HALT (exit code 4, `halt_reason: "write-failed"`, phase `auto-docs-only:5`, path `{forge_data_folder}`): "The result contract could not be written: {its message}."
149
149
 
150
- If `{onCompleteCommand}` is non-empty and the line's `result_path` is not null, invoke it now: `{onCompleteCommand} --result-path={forge_data_folder}/analyze-source-result-latest.json`. Display a hook failure; it never fails the run.
150
+ If `{onCompleteCommand}` is non-empty, the line's `result_path` is not null and its `warnings` hold no `result_file_write_failed` entry naming `analyze-source-result-latest.json`, invoke it now: `{onCompleteCommand} --result-path={forge_data_folder}/analyze-source-result-latest.json`. Display a hook failure; it never fails the run. When `{onCompleteCommand}` is non-empty but `result_path` is null or a `result_file_write_failed` warning names `analyze-source-result-latest.json` (the copy alone failed), skip the hook and say so: "on_complete skipped: `analyze-source-result-latest.json` was not written."
151
151
 
152
152
  ### 6. Chain to health check
153
153
 
@@ -287,11 +287,11 @@ The shared emitter writes the result files and prints the envelope; this section
287
287
  uv run {emitEnvelopeHelper} emit --workflow skf-analyze-source --run-dir "{run_dir}" --result-dir "{forge_data_folder}" < "{run_dir}/result-context.json"
288
288
  ```
289
289
 
290
- The emitter stamps the UTC time and the run id into the record, writes `{forge_data_folder}/analyze-source-result-{YYYYMMDD-HHmmss}.json` and its `analyze-source-result-latest.json` copy (the record of the last run whose emitter wrote this copy, at a stable path; a caller reads its own run's `SKF_ANALYZE_RESULT_JSON` line), and prints the `SKF_ANALYZE_RESULT_JSON:` line on stdout (field rules in `references/headless-contract.md`). When `{headless_mode}` is true, display the line verbatim: it is the success signal of a headless run. A result file that could not be written leaves the line's `result_path` null and a `result_file_write_failed` warning in it, 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, HARD HALT (exit code 4, `halt_reason: "write-failed"`, phase `generate-briefs:9`, path `{forge_data_folder}`, with `brief_paths` and `unit_counts` from the payload): "The result contract could not be written: {its message}."
290
+ The emitter stamps the UTC time and the run id into the record, writes `{forge_data_folder}/analyze-source-result-{YYYYMMDD-HHmmss}.json` and its `analyze-source-result-latest.json` copy (the record of the last run whose emitter wrote this copy, at a stable path; a caller reads its own run's `SKF_ANALYZE_RESULT_JSON` line), and prints the `SKF_ANALYZE_RESULT_JSON:` line on stdout (field rules in `references/headless-contract.md`). When `{headless_mode}` is true, display the line verbatim: it is the success signal of a headless run. 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, HARD HALT (exit code 4, `halt_reason: "write-failed"`, phase `generate-briefs:9`, path `{forge_data_folder}`, with `brief_paths` and `unit_counts` from the payload): "The result contract could not be written: {its message}."
291
291
 
292
292
  ### 9b. On-Complete Hook (pipeline integration)
293
293
 
294
- If `{onCompleteCommand}` is non-empty and the §9 line's `result_path` is not null, invoke it now, after the emitter wrote the timestamped result JSON and its `analyze-source-result-latest.json` copy:
294
+ If `{onCompleteCommand}` is non-empty, the §9 line's `result_path` is not null and its `warnings` hold no `result_file_write_failed` entry naming `analyze-source-result-latest.json` (the emitter wrote the timestamped result JSON and its `analyze-source-result-latest.json` copy), invoke it now:
295
295
 
296
296
  ```
297
297
  {onCompleteCommand} --result-path={forge_data_folder}/analyze-source-result-latest.json
@@ -303,8 +303,10 @@ The stable `-latest` path is preferred over the timestamped copy so downstream c
303
303
  - On non-zero exit / process error: display the hook failure (`on_complete hook failed (exit {code}): {stderr_snippet}`); it never fails the run.
304
304
  - **Never fail the workflow on hook errors** — the hook is for pipeline integration (Slack, dashboards, CI), not for gating skill-brief production.
305
305
 
306
+ When `{onCompleteCommand}` is non-empty but `result_path` is null or a `result_file_write_failed` warning names `analyze-source-result-latest.json` (the copy alone failed), skip the hook and say so: "on_complete skipped: `analyze-source-result-latest.json` was not written." The hook never gets a `-latest` copy this run failed to write.
307
+
306
308
  If `{onCompleteCommand}` is empty, skip this section entirely (default behavior — no hook configured).
307
309
 
308
310
  ### 10. Chain to Health Check
309
311
 
310
- After the briefs are written (or skipped, or none was confirmed), the report updated, the summary presented, the run ended in §9 and the on-complete hook invoked (or skipped per empty `{onCompleteCommand}`), delete the run folder, whose payloads the emitter has read: `rm -rf "{run_dir}"`. Then load, read the full file, and execute `{nextStepFile}`. The health-check step is the true terminal step: continue past the summary even though it reads as final.
312
+ After the briefs are written (or skipped, or none was confirmed), the report updated, the summary presented, the run ended in §9 and the on-complete hook invoked (or skipped as §9b says), delete the run folder, whose payloads the emitter has read: `rm -rf "{run_dir}"`. Then load, read the full file, and execute `{nextStepFile}`. The health-check step is the true terminal step: continue past the summary even though it reads as final.
@@ -17,7 +17,7 @@ SKF_ANALYZE_RESULT_JSON: {"status":"success|error|redirect|skipped","report_path
17
17
  - `brief_paths`: absolute path of every `skill-brief.yaml` the run wrote (empty array if none). A brief is written only after it passed the brief writer and, outside the docs-only branch, the schema gate.
18
18
  - `unit_counts` — `confirmed` (units approved for briefs) and `skipped` (rejected in step 5, or the skipped target in coexistence §0c) counts; auto mode reports `confirmed:N, skipped:0`. `maybe` is a reserved slot, currently always `0`.
19
19
  - `mode`: `"auto"` when the invocation carries `[auto]`, from activation through step 1a and the docs-only branch; `"interactive"` otherwise, also in the step-by-step chain an `[auto]` run falls back to (step 1a §3, exit 1).
20
- - `result_path`: the per-run result file the emitter wrote, stamped by it; `null` for a HALT, which writes no result file, and when the write failed (a `result_file_write_failed` warning then says why).
20
+ - `result_path`: the per-run result file the emitter wrote, stamped by it; `null` for a HALT, which writes no result file, when `--result-dir` names no folder (with no warning), and when the per-run record could not be written (a `result_file_write_failed` warning then says why); a `-latest` copy that could not be written leaves it set, and a `result_file_write_failed` warning names the copy.
21
21
  - `error`: on a HALT only, `{"phase", "reason"}` plus `path` when the halt names one.
22
22
  - `headless_decisions`: present when the run recorded an auto-decision: step 1a's §3b merge-or-split decision (gate `auto-scope.cohesion`), its §4 choices of shape for a repository on a framework or a parser library (gate `auto-scope.shape`) and its §5 choice of the documented language (gate `auto-scope.language`), and each gate default a headless run of the step-by-step chain took: `scan-project.scan`, `identify-units.classifications`, `map-and-detect.findings` (with the composite merges it accepted), `recommend.recommendations` and `generate-briefs.write`.
23
23
  - `warnings`: present when the run recorded at least one.
@@ -32,7 +32,7 @@ The step that ends the run stages `{run_dir}/result-context.json` with the envel
32
32
  uv run {emitEnvelopeHelper} emit --workflow skf-analyze-source --run-dir "{run_dir}" --result-dir "{forge_data_folder}" < "{run_dir}/result-context.json"
33
33
  ```
34
34
 
35
- The emitter stamps the UTC time, the run id, the decisions and warnings the run recorded into the record, writes `{forge_data_folder}/analyze-source-result-{YYYYMMDD-HHmmss}.json` and its `analyze-source-result-latest.json` copy, and prints the line. 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, the step corrects the payload from the message on its stderr and runs it once more; if it fails again, it HARD HALTs with exit code 4 (`write-failed`). Then `{onCompleteCommand}` runs, when it is set and `result_path` is not null, with `--result-path={forge_data_folder}/analyze-source-result-latest.json`; a hook failure is displayed and never fails the run. Last, the step deletes `{run_dir}` and chains to step 7.
35
+ The emitter stamps the UTC time, the run id, the decisions and warnings the run recorded into the record, writes `{forge_data_folder}/analyze-source-result-{YYYYMMDD-HHmmss}.json` and its `analyze-source-result-latest.json` copy, and prints the line. 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, the step corrects the payload from the message on its stderr and runs it once more; if it fails again, it HARD HALTs with exit code 4 (`write-failed`). Then `{onCompleteCommand}` runs, when it is set, `result_path` is not null and no `result_file_write_failed` warning names `analyze-source-result-latest.json`, with `--result-path={forge_data_folder}/analyze-source-result-latest.json`, and a hook failure is displayed and never fails the run; otherwise a set hook is skipped with "on_complete skipped: `analyze-source-result-latest.json` was not written." Last, the step deletes `{run_dir}` and chains to step 7.
36
36
 
37
37
  ## Halt Envelope
38
38
 
@@ -447,8 +447,8 @@ Add `"coexistence": "alongside"` when `{coexistence_suffix}` is non-empty ([A]lo
447
447
  uv run {emitEnvelopeHelper} emit --workflow skf-analyze-source --run-dir "{run_dir}" --result-dir "{forge_data_folder}" < "{run_dir}/result-context.json"
448
448
  ```
449
449
 
450
- The emitter stamps the UTC time, the run id and the decisions and warnings the run recorded into the record, writes `{forge_data_folder}/analyze-source-result-{YYYYMMDD-HHmmss}.json` and its `analyze-source-result-latest.json` copy, and prints the `SKF_ANALYZE_RESULT_JSON:` line on stdout (field rules in `references/headless-contract.md`). When `{headless_mode}` is true, display the line verbatim. A result file that could not be written leaves the line's `result_path` null and a `result_file_write_failed` warning in it, 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, HARD HALT (exit code 4, `halt_reason: "write-failed"`, phase `step-auto-scope:9`, path `{forge_data_folder}`): "The result contract could not be written: {its message}."
450
+ The emitter stamps the UTC time, the run id and the decisions and warnings the run recorded into the record, writes `{forge_data_folder}/analyze-source-result-{YYYYMMDD-HHmmss}.json` and its `analyze-source-result-latest.json` copy, and prints the `SKF_ANALYZE_RESULT_JSON:` line on stdout (field rules in `references/headless-contract.md`). When `{headless_mode}` is true, display the line verbatim. 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, HARD HALT (exit code 4, `halt_reason: "write-failed"`, phase `step-auto-scope:9`, path `{forge_data_folder}`): "The result contract could not be written: {its message}."
451
451
 
452
- If `{onCompleteCommand}` is non-empty and the line's `result_path` is not null, invoke it now: `{onCompleteCommand} --result-path={forge_data_folder}/analyze-source-result-latest.json`. Display a hook failure; it never fails the run.
452
+ If `{onCompleteCommand}` is non-empty, the line's `result_path` is not null and its `warnings` hold no `result_file_write_failed` entry naming `analyze-source-result-latest.json`, invoke it now: `{onCompleteCommand} --result-path={forge_data_folder}/analyze-source-result-latest.json`. Display a hook failure; it never fails the run. When `{onCompleteCommand}` is non-empty but `result_path` is null or a `result_file_write_failed` warning names `analyze-source-result-latest.json` (the copy alone failed), skip the hook and say so: "on_complete skipped: `analyze-source-result-latest.json` was not written."
453
453
 
454
454
  Then delete the run folder, whose payloads the emitter has read: `rm -rf "{run_dir}"`. Load, read fully, then execute {nextStepFile} to run the shared workflow health check.