bmad-module-skill-forge 3.0.0 → 4.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.
- package/.claude-plugin/marketplace.json +1 -1
- package/docs/_data/pinned.yaml +1 -1
- package/docs/examples.md +1 -1
- package/docs/troubleshooting.md +8 -0
- package/docs/verifying-a-skill.md +7 -3
- package/docs/workflows.md +3 -3
- package/package.json +1 -1
- package/src/knowledge/qmd-registry.md +1 -1
- package/src/knowledge/version-paths.md +1 -1
- package/src/shared/health-check.md +2 -50
- package/src/shared/scripts/schemas/skf-audit-result-envelope.v1.json +2 -2
- package/src/shared/scripts/schemas/skf-update-result-envelope.v1.json +2 -2
- package/src/shared/scripts/skf-atomic-write.py +212 -59
- package/src/shared/scripts/skf-build-change-manifest.py +593 -47
- package/src/shared/scripts/skf-classify-changed-files.py +78 -0
- package/src/shared/scripts/skf-compare-file-hashes.py +266 -104
- package/src/shared/scripts/skf-derive-assembly-shape.py +18 -1
- package/src/shared/scripts/skf-detect-scripts-assets.py +196 -54
- package/src/shared/scripts/skf-extract-public-api.py +1309 -157
- package/src/shared/scripts/skf-extraction-inventory.py +580 -46
- package/src/shared/scripts/skf-extraction-snapshot.py +158 -33
- package/src/shared/scripts/skf-forge-tier-rw.py +26 -3
- package/src/shared/scripts/skf-github-probe.py +47 -8
- package/src/shared/scripts/skf-hash-content.py +14 -8
- package/src/shared/scripts/skf-load-provenance.py +3 -1
- package/src/shared/scripts/skf-merge-doc-urls.py +142 -19
- package/src/shared/scripts/skf-preflight.py +15 -1
- package/src/shared/scripts/skf-provenance-gap-dispatch.py +25 -1
- package/src/shared/scripts/skf-resolve-authoritative-files.py +29 -3
- package/src/shared/scripts/skf-structural-diff.py +133 -18
- package/src/shared/scripts/skf-update-active-symlink.py +115 -62
- package/src/shared/scripts/skf-validate-brief-schema.py +19 -1
- package/src/shared/scripts/skf-verify-provenance-completeness.py +977 -49
- package/src/shared/scripts/skf-write-skill-brief.py +41 -8
- package/src/skf-analyze-source/references/map-unit-exports.md +1 -1
- package/src/skf-audit-skill/assets/drift-report-template.md +1 -0
- package/src/skf-audit-skill/references/headless-contract.md +1 -1
- package/src/skf-audit-skill/references/health-check.md +2 -0
- package/src/skf-audit-skill/references/init.md +2 -1
- package/src/skf-audit-skill/references/re-index.md +14 -9
- package/src/skf-audit-skill/references/report.md +2 -2
- package/src/skf-audit-skill/references/semantic-diff.md +15 -15
- package/src/skf-audit-skill/references/structural-diff.md +10 -5
- package/src/skf-audit-skill/scripts/render-drift-tables.py +29 -2
- package/src/skf-brief-skill/SKILL.md +1 -1
- package/src/skf-brief-skill/assets/scope-templates.md +1 -1
- package/src/skf-brief-skill/assets/skill-brief-schema.md +2 -9
- package/src/skf-brief-skill/references/analyze-target.md +6 -6
- package/src/skf-brief-skill/references/confirm-brief.md +1 -1
- package/src/skf-brief-skill/references/scope-definition.md +4 -4
- package/src/skf-brief-skill/references/step-auto-brief.md +11 -29
- package/src/skf-brief-skill/references/version-resolution.md +5 -9
- package/src/skf-create-skill/assets/compile-assembly-rules.md +5 -3
- package/src/skf-create-skill/assets/skill-sections.md +6 -6
- package/src/skf-create-skill/references/authoritative-files-protocol.md +2 -23
- package/src/skf-create-skill/references/compile.md +31 -60
- package/src/skf-create-skill/references/component-extraction.md +3 -3
- package/src/skf-create-skill/references/enrich.md +2 -2
- package/src/skf-create-skill/references/entry-points-by-hand.md +24 -7
- package/src/skf-create-skill/references/extract.md +22 -19
- package/src/skf-create-skill/references/extraction-patterns-tracing.md +9 -9
- package/src/skf-create-skill/references/extraction-patterns.md +8 -9
- package/src/skf-create-skill/references/generate-artifacts.md +1 -1
- package/src/skf-create-skill/references/report.md +16 -5
- package/src/skf-create-skill/references/source-resolution-protocols.md +12 -17
- package/src/skf-create-skill/references/sub/fetch-docs.md +1 -1
- package/src/skf-drop-skill/references/execute.md +5 -1
- package/src/skf-drop-skill/references/health-check.md +2 -0
- package/src/skf-drop-skill/references/invocation-contract.md +1 -1
- package/src/skf-drop-skill/references/report.md +2 -2
- package/src/skf-drop-skill/references/select.md +7 -3
- package/src/skf-test-skill/references/coherence-check.md +1 -1
- package/src/skf-test-skill/references/coverage-check-tiers.md +5 -5
- package/src/skf-test-skill/references/coverage-check.md +39 -57
- package/src/skf-test-skill/references/init.md +3 -5
- package/src/skf-test-skill/references/report.md +10 -8
- package/src/skf-test-skill/references/scoring-rules.md +6 -5
- package/src/skf-test-skill/references/source-access-protocol.md +14 -17
- package/src/skf-test-skill/scripts/build-result-context.py +114 -21
- package/src/skf-test-skill/scripts/gap-ledger.py +315 -30
- package/src/skf-test-skill/scripts/load-coverage-inputs.py +534 -96
- package/src/skf-test-skill/scripts/reconcile-coverage.py +276 -2
- package/src/skf-test-skill/scripts/score-signatures.py +36 -16
- package/src/skf-test-skill/scripts/verify-declared-numerator.py +7 -5
- package/src/skf-update-skill/references/detect-changes.md +32 -13
- package/src/skf-update-skill/references/gap-driven.md +3 -3
- package/src/skf-update-skill/references/init.md +6 -2
- package/src/skf-update-skill/references/invocation-contract.md +1 -1
- package/src/skf-update-skill/references/merge.md +2 -2
- package/src/skf-update-skill/references/re-extract.md +2 -2
- package/src/skf-update-skill/references/report.md +3 -1
- package/src/skf-update-skill/references/write.md +43 -23
- package/src/skf-update-skill/scripts/skf-new-file-diff.py +157 -42
package/docs/_data/pinned.yaml
CHANGED
|
@@ -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: "
|
|
35
|
+
skf_version: "4.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/examples.md
CHANGED
|
@@ -325,7 +325,7 @@ Generated skills automatically follow authoring best practices: third-person des
|
|
|
325
325
|
|
|
326
326
|
### Scripts & Assets
|
|
327
327
|
|
|
328
|
-
If your source repo includes executable scripts (such as `scripts/`, `bin/` or `
|
|
328
|
+
If your source repo includes executable scripts (such as `scripts/`, `bin/`, `tools/` or `cli/`; when one of these folders is a Python package, holding `__init__.py`, only its `__main__.py` and the modules with a shebang or an `if __name__ == "__main__":` block count as scripts) or static assets (such as `assets/`, `templates/` or `schemas/`), SKF detects and packages them automatically with provenance tracking. Only the files the brief's `scope.include` takes and its `scope.exclude` leaves are packaged, and Audit Skill reports a new one the same way. Scripts and assets you add yourself under `scripts/[MANUAL]/` or `assets/[MANUAL]/` are preserved during updates, just like `<!-- [MANUAL] -->` markers in SKILL.md.
|
|
329
329
|
|
|
330
330
|
### Let the Health Check Run
|
|
331
331
|
|
package/docs/troubleshooting.md
CHANGED
|
@@ -49,6 +49,12 @@ Install [cocoindex-code](https://github.com/cocoindex-io/cocoindex-code) (the `c
|
|
|
49
49
|
|
|
50
50
|
When ccc is available (Forge+, or Deep with ccc), create-skill indexes the skill's source with it. A local source folder inside your project or inside another git checkout (a subfolder such as `./packages/lib`, a linked worktree or a submodule) becomes a ccc project of its own, and ccc does not gitignore an index there. create-skill keeps it out of git by writing a `.gitignore` holding `*` inside that `.cocoindex_code/` folder, and says so in one line; it never edits your own `.gitignore`. For a folder an earlier SKF release left untracked, run create-skill for that source again, or create `<source>/.cocoindex_code/.gitignore` holding the single line `*`. If you already committed the index, `git rm -r --cached <source>/.cocoindex_code` stops tracking it.
|
|
51
51
|
|
|
52
|
+
### Setup says the CCC index failed
|
|
53
|
+
|
|
54
|
+
Surfaced by `@Ferris SF` when ccc is available and `ccc index` exits with an error: the forge status shows `indexing failed, semantic discovery unavailable this session` with ccc's own message. Setup still finishes at your tier; only semantic discovery is off until the index builds. One cause is an index that an older ccc wrote and the installed ccc cannot read, with a message such as `Failed to deserialize pickle payload`.
|
|
55
|
+
|
|
56
|
+
**Fix:** from the project root, run `ccc reset`, then `ccc daemon restart`, then re-run `@Ferris SF`. `ccc reset` deletes only the index databases and keeps `.cocoindex_code/settings.yml` with SKF's exclusions. Restart the daemon before indexing again: it still holds the databases `ccc reset` deleted, so an index built right after the reset can fail with `environment already open in this program`.
|
|
57
|
+
|
|
52
58
|
### `@Ferris deepwiki` shows a deprecation notice
|
|
53
59
|
|
|
54
60
|
The auto pipeline was briefly named `deepwiki`; it's now [`forge-auto`](/docs/forge-auto.md). It was renamed to avoid confusion with the DeepWiki MCP server, since the pipeline compiles a verified skill from source and does **not** call that server. `deepwiki` still works (it resolves to `forge-auto`) but prints a one-time notice. Switch your commands to `@Ferris forge-auto <repo-or-doc-url>`.
|
|
@@ -145,6 +151,8 @@ Surfaced by `@Ferris US` when another update of the same skill holds its run loc
|
|
|
145
151
|
|
|
146
152
|
**`run-lock-lost`:** the same status with this reason means the update waited at a question past the stale time, so another update may have changed the skill (`error.phase` is `merge:run-lock` or `write:run-lock`). It stops before it writes `metadata.json`, and the halt undoes anything the update already wrote: it removes the new version folders, or, for a repair from a test report, restores the files it edited in place from the copy it took first, and the message says which. Re-run the update once no other update of that skill runs; if a version folder the message names is still on disk, delete it first.
|
|
147
153
|
|
|
154
|
+
**`busy`:** the same status with this reason means another run's flip of the skill's `active` link (Create Skill, Quick Skill or Drop Skill) held the link's lock for 5 seconds while the update was pointing the link at its version (`error.phase` is `write:active-symlink`). The update did not change the link, and the halt undid what the update wrote. Re-run the update once that run ends.
|
|
155
|
+
|
|
148
156
|
### A report says `Tessl Review: off`, `not run`, `no result` or `failed`
|
|
149
157
|
|
|
150
158
|
Create Skill and Test Skill record one `Tessl Review:` line in the evidence report and the test report. Tessl Review is optional and never stops a run, so the line says why it produced no score:
|
|
@@ -42,7 +42,7 @@ Provenance maps live in `forge-data/{skill}/{version}/provenance-map.json` in th
|
|
|
42
42
|
}
|
|
43
43
|
```
|
|
44
44
|
|
|
45
|
-
This is the entry SKF 3.0.0 writes for cognee's `search` when ast-grep matches it, shortened to its first two parameters (`search` takes 20). It points at line 27, where `search` is defined at cognee v1.0.0's commit (`3c048aa4`), and its labels follow the tool that read it: an ast-grep match gives `T1`, `ast-grep`, the node kind its rule matched and an `[AST:]` citation. An export read by eye instead, because ast-grep could not parse its file or no rule matched it, gets `T1-low`, `source-read`, no node kind and an `[SRC:]` citation, at every forge tier.
|
|
45
|
+
This is the entry SKF 3.0.0 writes for cognee's `search` when ast-grep matches it, shortened to its first two parameters (`search` takes 20). It points at line 27, where `search` is defined at cognee v1.0.0's commit (`3c048aa4`), and its labels follow the tool that read it: an ast-grep match gives `T1`, `ast-grep`, the node kind its rule matched and an `[AST:]` citation. An export read by eye instead, because ast-grep could not parse its file, an ast-grep call that failed or timed out left its file unread, or no rule matched it, gets `T1-low`, `source-read`, no node kind and an `[SRC:]` citation, at every forge tier.
|
|
46
46
|
|
|
47
47
|
The published entry in oh-my-skills' [`forge-data/oms-cognee/1.0.0/provenance-map.json`](https://github.com/armelhbobdad/oh-my-skills/blob/main/forge-data/oms-cognee/1.0.0/provenance-map.json) is a good one to check by hand, because it was compiled before labels followed the tool. It records `search` as `source-read` but labels it `T1`, and it points at line 26, the blank line above the definition, while the skill's `SKILL.md` cites `[AST:cognee/api/v1/search/search.py:L27]`. SKF 3.0.0's [line, label and citation checks](#workflow-time-enforcement) flag all three when Create Skill or Update Skill writes such a map: a source read is `T1-low` with an `[SRC:]` citation, and its line is 27, the `def` line.
|
|
48
48
|
|
|
@@ -205,9 +205,13 @@ Each step rates every gap it records against the Gap Severity table in Test Skil
|
|
|
205
205
|
|---|---|
|
|
206
206
|
| **Critical** | A wrong signature, or a fabricated one (a documented export that the source does not define at the line the skill cites); a broken reference in a stack skill (a file, skill, type, or `scripts/` or `assets/` file that does not exist) |
|
|
207
207
|
| **High** | An inaccurate reference in a stack skill (the target exists but does not match), or a reference whose real path leads outside the skill and its sources; a split-body mismatch (`SKILL.md` and a `references/` file document one export differently); `metadata.json` stats that count every export as documented while some are missing from the skill; in an individual skill, a missing required section, an unbalanced code fence, or an example that awaits a sync function or skips the `await` of an async one |
|
|
208
|
-
| **Medium** | A missing export or type (a missing export lowers Export Coverage); stale documentation (a documented export the source
|
|
208
|
+
| **Medium** | A missing export or type (a missing export lowers Export Coverage); stale documentation (a documented export outside the coverage surface that the run did not match to one declaration in the source, or that a brief scopes out); an incomplete integration pattern in a stack skill; in an individual skill, a code fence with no language tag, a function no example names, or a table row whose column count differs from its header |
|
|
209
209
|
| **Low** | Gaps in metadata, the description or provenance lines, such as missing optional metadata or a provenance line that is not the definition line |
|
|
210
|
-
| **Info** | Observations such as style suggestions; discovery testing not performed |
|
|
210
|
+
| **Info** | Observations such as style suggestions or a documented extra (a documented name outside the coverage surface that the source still declares); discovery testing not performed |
|
|
211
|
+
|
|
212
|
+
Only some runs can find a documented extra: the source must be Python or TypeScript/JavaScript, and the run needs the Forge, Forge+ or Deep tier, `analysis_confidence` `full`, `workspaceDrift` not `overridden`, a provenance map and a coverage surface that a complete extraction built. On any other run, every documented export outside the coverage surface is stale documentation.
|
|
213
|
+
|
|
214
|
+
A declaration the run finds only in an `example`, `demo`, `demos`, `sample`, `samples`, `scripts`, `playground`, `bench`, `benchmark` or `migrations` folder right below a package's root, or in an `e2e` or `__fixtures__` folder anywhere, does not count, so a name declared only in `scripts/foo.py` stays stale documentation unless the provenance map cites a file in that folder. Nor does a module named after the name count (such as `client.py` for a documented `client()`) when the skill documents the name as a function, hook, class, interface, enum or type, unless the provenance map records the name as a module or package, or the module is a TypeScript or JavaScript file that is itself the export (`export default`, `module.exports =`).
|
|
211
215
|
|
|
212
216
|
Discovery testing runs after the hard gate, so a discovery gap (Info, Medium or High) is counted in the Gap Report and blocks nothing.
|
|
213
217
|
|
package/docs/workflows.md
CHANGED
|
@@ -208,7 +208,7 @@ Each workflow is also a skill you can run directly, without Ferris: `/skf-setup`
|
|
|
208
208
|
|
|
209
209
|
**Baseline:** An audit compares the skill with the baseline it recorded when it was built. An interactive audit shows its baseline summary and analysis plan and goes straight on. It stops to ask only when this run's forge tier is below the tier the skill was compiled at (so the diff can report as drift what only the tools changed; a compose-mode stack or a docs-only skill re-reads no source and never stops for this), or when the provenance map is more than 90 days old. At that stop, `[C]` continues, and `[X]` stops the run with exit `6` (`user-cancelled`) and keeps the drift report, which then holds the baseline only. A headless run continues. A skill with no provenance map, such as a Quick Skill, stops with `no-baseline` (exit `3`) in every mode, and the message points to `@Ferris TS` (test it against its source) or `@Ferris CS` (create it again so it can be audited). A docs-only skill is audited by its documents: each tracked document's hash is compared with the one recorded at compile time, and a changed document is graded HIGH, so the score is SIGNIFICANT, with `next_workflow` `update-skill`, or CLEAN when nothing changed. A docs-only skill that records no hash stops with `no-baseline`, one with no document that could be compared stops with `source-unreadable` (exit `3`), and a partial comparison adds a `doc_fetch_failed` or `doc_not_hashed` warning. The audit checks `uv` and its helpers before its first question and stops with `helper-missing` (exit `3`) when one is missing. The `degraded` input and the `severity_rules_path` setting are gone.
|
|
210
210
|
|
|
211
|
-
**Provenance labels:** An export's labels, its confidence tier and its extraction method, follow the tool that read it, so a later read can change them, such as T1 to T1-low when ast-grep could not parse the file this time, or back. The drift report lists such changes in a **Provenance label differences (not drift)** table: they are not drift, are not counted in Total Drift Items and move no score.
|
|
211
|
+
**Provenance labels:** An export's labels, its confidence tier and its extraction method, follow the tool that read it, so a later read can change them, such as T1 to T1-low when ast-grep could not parse the file this time, or back. The drift report lists such changes in a **Provenance label differences (not drift)** table: they are not drift, are not counted in Total Drift Items and move no score. For a `public-api` skill, the new exports off the public surface Create Skill and Update Skill keep the provenance map to are listed apart, named in an **Off the public surface** note, and not counted in Total Drift Items; when the audit cannot read the surface (at Quick tier, for example), every new export is reported as added and the run warns `public_surface_not_applied`.
|
|
212
212
|
|
|
213
213
|
**Doc drift:** A skill compiled before SKF 3.0.0 recorded its README as a `blob/main` page, or as a URL with no scheme, so doc drift keeps flagging its README until Create Skill compiles it again and records the raw README at the ref the skill was built from.
|
|
214
214
|
|
|
@@ -368,7 +368,7 @@ Default pass threshold: **80%**. Inside a pipeline the default follows the alias
|
|
|
368
368
|
|
|
369
369
|
**Key Steps:** Select skill → Select version(s) + mode → Update manifest → Rebuild context files → Delete files (if purge)
|
|
370
370
|
|
|
371
|
-
**Safety:** Active version guard: you cannot drop the currently active version while other non-deprecated versions exist. To drop it and keep another version, make the version you keep active first: point the skill's `active` link at it (`
|
|
371
|
+
**Safety:** Active version guard: you cannot drop the currently active version while other non-deprecated versions exist. To drop it and keep another version, make the version you keep active first: point the skill's `active` link at it (`uv run _bmad/skf/shared/scripts/skf-atomic-write.py flip-link --link <skills_output_folder>/<name>/active --target <version to keep>`, which works in Git Bash, PowerShell and WSL2), run `@Ferris EX` on the skill, which exports that version and archives the old one, then drop the old one. To drop the whole skill instead, choose the All versions option. Soft drop is reversible by editing the manifest. Drop offers a skill folder SKF did not generate only when the manifest lists it, and then only for deprecate. A purge deletes only SKF output: a whole-skill purge of a folder that also holds other files is refused, and so is a purge through a link. The same goes for the skill's folder in `forge_data_folder`, except that a folder there SKF did not generate (another tool's folder of the same name, or a link) is left in place and named in the report. A headless drop of a skill with no manifest entry needs `--mode purge`. A headless drop of a skill in the export manifest needs `version=all` or the one version to drop: without it, the run stops with exit `2` (`input-missing`) before it changes anything. A specific version for a draft skill (one with no manifest entry) stops with exit `2` (`input-invalid`) in every mode, since a draft is dropped whole.
|
|
372
372
|
|
|
373
373
|
**Preview first:** Add `--dry-run` to `@Ferris RS` or `@Ferris DS` to see which folders, manifest entries and context files would change. Nothing is written or deleted.
|
|
374
374
|
|
|
@@ -517,7 +517,7 @@ SKF_SETUP_RESULT_JSON: {"skf_setup":{"status":"success","tier":"Deep","previous_
|
|
|
517
517
|
|
|
518
518
|
Parent skills and CI pipelines `grep` one line out of the workflow log to learn the outcome, with no ASCII-art parsing and no race against the [`forge-tier.yaml`](https://github.com/armelhbobdad/bmad-module-skill-forge/blob/main/src/skf-setup/references/write-config.md) writer. Branch on the top-level `status` field (`success`, `tier_failure`, or `blocked`) rather than composing the outcome from `require_tier_satisfied` + `error`. A write failure arrives as `blocked`, with an `error.phase` of `step 2:write-tools` or `step 2:forge-data-dir`. The envelope schema is versioned at [`src/shared/scripts/schemas/skf-setup-result-envelope.v1.json`](https://github.com/armelhbobdad/bmad-module-skill-forge/blob/main/src/shared/scripts/schemas/skf-setup-result-envelope.v1.json) and asserted against on every emit.
|
|
519
519
|
|
|
520
|
-
Setup is not the only workflow whose result line is the run's final message: a headless Test Skill, Quick Skill (one target, not `--batch`), Brief Skill (also under `[auto]`), Update Skill, Export Skill (a `--dry-run` too) or campaign run hands its `SKF_TEST_RESULT_JSON`, `SKF_QUICK_SKILL_RESULT_JSON`, `SKF_BRIEF_RESULT_JSON`, `SKF_UPDATE_RESULT_JSON`, `SKF_EXPORT_RESULT_JSON` or `SKF_CAMPAIGN_RESULT_JSON` line to the shared health check, which displays it last, after everything else it shows (under `--no-health-check`, Test Skill displays it itself as its last line).
|
|
520
|
+
Setup is not the only workflow whose result line is the run's final message: a headless Test Skill, Quick Skill (one target, not `--batch`), Brief Skill (also under `[auto]`), Update Skill, Audit Skill, Drop Skill, Export Skill (a `--dry-run` too) or campaign run hands its `SKF_TEST_RESULT_JSON`, `SKF_QUICK_SKILL_RESULT_JSON`, `SKF_BRIEF_RESULT_JSON`, `SKF_UPDATE_RESULT_JSON`, `SKF_AUDIT_RESULT_JSON`, `SKF_DROP_SKILL_RESULT_JSON`, `SKF_EXPORT_RESULT_JSON` or `SKF_CAMPAIGN_RESULT_JSON` line to the shared health check, which displays it last, after everything else it shows (under `--no-health-check`, Test Skill displays it itself as its last line). A Drop Skill `--dry-run` stops at its confirmation gate and displays its line itself, last.
|
|
521
521
|
|
|
522
522
|
**Exception: `/skf-quick-skill` headless emits structured progress and result envelopes.** Headless `/skf-quick-skill` runs are first-class building blocks for batch automators. Four operational contracts go beyond per-gate auto-proceed:
|
|
523
523
|
|
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": "
|
|
4
|
+
"version": "4.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",
|
|
@@ -137,7 +137,7 @@ Then append/replace the registry entry in forge-tier.yaml. Failures never block
|
|
|
137
137
|
|
|
138
138
|
**Context:** Audit-skill's Deep-tier semantic diff retrieves what the skill documents about each export, then checks each claim against the current source.
|
|
139
139
|
|
|
140
|
-
**Implementation:** Read `qmd_collections` from forge-tier.yaml. Find entry where `skill_name` matches AND `type` is `"extraction"`. If found, query via qmd_bridge. If not found, log and
|
|
140
|
+
**Implementation:** Read `qmd_collections` from forge-tier.yaml. Find entry where `skill_name` matches AND `type` is `"extraction"`. If found, query via qmd_bridge. If not found, log it and read the skill's own files instead (the direct-content fallback, labeled T1-low-fallback): not an error.
|
|
141
141
|
|
|
142
142
|
### Example 3: Janitor Hygiene (setup)
|
|
143
143
|
|
|
@@ -266,7 +266,7 @@ Drops a specific version or the entire skill with two modes:
|
|
|
266
266
|
|
|
267
267
|
**Hard drop (`--purge`):** Same as soft drop, plus deletes the version directory (`{skill_package}`) and forge data directory (`{forge_version}`, only when SKF generated it). Irreversible.
|
|
268
268
|
|
|
269
|
-
**Active version guard:** Cannot drop the active version while other non-deprecated versions exist. The user must either make the version they keep active first (point the `active` link at it with `
|
|
269
|
+
**Active version guard:** Cannot drop the active version while other non-deprecated versions exist. The user must either make the version they keep active first (point the `active` link at it with `uv run _bmad/skf/shared/scripts/skf-atomic-write.py flip-link --link {skill_group}/active --target <version to keep>`, then run export-skill: the Manifest-lag guard makes it export that version, and its manifest update archives the old one), or drop all versions at once.
|
|
270
270
|
|
|
271
271
|
**Skill-level drop:** Removes the entire `{skill_group}` from the manifest. If purge, also deletes `{skill_group}` and `{forge_group}` directories (the forge one only when SKF generated it).
|
|
272
272
|
|
|
@@ -24,17 +24,8 @@ Reflect on the workflow that just completed. If real friction, bugs, or gaps wer
|
|
|
24
24
|
- Read the complete step file before taking any action
|
|
25
25
|
- Speak in `{communication_language}`
|
|
26
26
|
|
|
27
|
-
###
|
|
28
|
-
|
|
29
|
-
- You report ONLY what you actually experienced during THIS session
|
|
30
|
-
- You are NOT a creative writer looking for things to say
|
|
31
|
-
|
|
32
|
-
### Anti-Hallucination Rules:
|
|
33
|
-
- **DO NOT FABRICATE ISSUES.** If the workflow ran smoothly, say so and exit. Inventing issues to appear thorough is a SYSTEM FAILURE.
|
|
34
|
-
- Only report issues you **ACTUALLY encountered** during THIS workflow execution
|
|
35
|
-
- Every finding MUST cite the **specific step file path and section** where the issue occurred
|
|
36
|
-
- If you are unsure whether something was a real issue or your own confusion, DO NOT report it
|
|
37
|
-
- Reporting zero issues is the EXPECTED outcome for a well-designed workflow
|
|
27
|
+
### Findings Rule:
|
|
28
|
+
Zero findings is the expected outcome of a good run. Report only a bug, friction or gap you hit in this session, each with the step file and section where it happened, and leave out anything you cannot tell apart from your own confusion: a finding you invent can become a real GitHub issue filed against the module.
|
|
38
29
|
|
|
39
30
|
---
|
|
40
31
|
|
|
@@ -264,7 +255,6 @@ After the issue is created, write the fingerprint → issue-url mapping to the s
|
|
|
264
255
|
- **Respect the length budgets.** Finding, Expected, Actual, Impact are each ONE sentence. Evidence is 2-5 bullets, not prose. Suggested Fix is 1-3 sentences with ONE recommendation — multiple options go in the `Alternatives considered` collapsible or (better) not at all.
|
|
265
256
|
- **Quote, don't paraphrase.** In Evidence, cite the exact `file:line` and put the quoted text in quotes. Link the convention to the instruction that caused it.
|
|
266
257
|
- **Never narrate the session.** The reader wants the defect, not the story. If a sentence starts with "During my run..." or "I was trying to...", delete it.
|
|
267
|
-
- **If unsure whether it's a real issue, do not submit it.** Reporting zero findings is a healthy outcome.
|
|
268
258
|
|
|
269
259
|
**Issue body format:**
|
|
270
260
|
```markdown
|
|
@@ -389,41 +379,3 @@ Or open them manually at: <https://github.com/{healthCheckRepo}/issues/new/choos
|
|
|
389
379
|
Workflow complete."
|
|
390
380
|
|
|
391
381
|
When `{result_envelope_line}` is bound (§0), display it verbatim after that block, as the last line.
|
|
392
|
-
|
|
393
|
-
---
|
|
394
|
-
|
|
395
|
-
## CRITICAL STEP COMPLETION NOTE
|
|
396
|
-
|
|
397
|
-
This is the TERMINAL step — shared across all SKF workflows. After the health check completes (clean run, or findings submitted, queued or discarded), the workflow is fully done. No further steps to load.
|
|
398
|
-
|
|
399
|
-
---
|
|
400
|
-
|
|
401
|
-
## SYSTEM SUCCESS/FAILURE METRICS
|
|
402
|
-
|
|
403
|
-
### SUCCESS:
|
|
404
|
-
|
|
405
|
-
- Clean runs exit immediately with no findings (most common outcome)
|
|
406
|
-
- Findings cite specific step files and sections with real evidence
|
|
407
|
-
- User review gate presented before any submission
|
|
408
|
-
- Severity gate respected: only `bug` submits live by default; `friction`/`gap` require explicit opt-in
|
|
409
|
-
- Fingerprint computed deterministically and applied to both title prefix and `fp-*` label
|
|
410
|
-
- `fp-*` label is ensured idempotently (`gh label create ... || true`) before `gh issue create`, so a first reporter of a defect never hard-fails
|
|
411
|
-
- Remote dedup search performed before every live submission; existing issues get reactions/delta-comments rather than duplicates
|
|
412
|
-
- Seen-cache at `{seenCachePath}` updated after every submission/reaction and consulted before every search
|
|
413
|
-
- Local fallback files written with clear manual submission instructions (when `gh` unavailable)
|
|
414
|
-
- Workflow ends cleanly
|
|
415
|
-
|
|
416
|
-
### SYSTEM FAILURE:
|
|
417
|
-
|
|
418
|
-
- Fabricating issues that were not actually encountered during the session
|
|
419
|
-
- Reporting vague issues without step file citations ("the workflow was confusing")
|
|
420
|
-
- Skipping the user review gate (resolving it to its listed headless default **\[Q]** is not skipping it: nothing is submitted)
|
|
421
|
-
- Creating issues without user confirmation
|
|
422
|
-
- Creating a new issue when a matching `fp-*` open issue already exists (without explicit user \[N] override)
|
|
423
|
-
- Submitting `friction` or `gap` findings live without the explicit severity-gate opt-in
|
|
424
|
-
- Using LLM-judged "similarity" in place of the deterministic fingerprint
|
|
425
|
-
- Not updating the seen-cache, causing the same user to re-report identical fingerprints
|
|
426
|
-
- Not providing the local fallback when `gh` is unavailable
|
|
427
|
-
- Continuing to load steps after this one (this is terminal)
|
|
428
|
-
|
|
429
|
-
**Master Rule:** Honesty is the only policy. Zero findings is the expected, healthy outcome. Fabricating issues to appear thorough undermines the entire self-improvement system and constitutes SYSTEM FAILURE.
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
3
|
"$id": "https://github.com/armelhbobdad/bmad-module-skill-forge/src/shared/scripts/schemas/skf-audit-result-envelope.v1.json",
|
|
4
4
|
"title": "SKF_AUDIT_RESULT_JSON envelope (v1)",
|
|
5
|
-
"description": "Single-line JSON document skf-audit-skill emits when {headless_mode} is true, prefixed with the literal 'SKF_AUDIT_RESULT_JSON: ': once on stdout when step 6 (report) finishes,
|
|
5
|
+
"description": "Single-line JSON document skf-audit-skill emits when {headless_mode} is true, prefixed with the literal 'SKF_AUDIT_RESULT_JSON: ': once on stdout when step 6 (report) finishes, which the health check displays as the run's last line, and once on stderr at every HARD HALT (status error). The shared emitter, skf-emit-result-envelope.py, builds every line from the payload a stage stages in the run folder, folds in the auto-decisions and warnings the run recorded there, derives exit_code from halt_reason, stamps run_id and result_path, writes the result files on success, and checks the line against this schema, so the model never types it. The skill's references/headless-contract.md holds the exit-code table and its stage files show the halt command.",
|
|
6
6
|
"$defs": {
|
|
7
7
|
"skf-envelope": {
|
|
8
8
|
"description": "The shared emitter's settings for this workflow, read by skf-emit-result-envelope.py: the workflow, the line's prefix, the wrapper, the status a halt carries, the exit code of each halt_reason and the result file stem. Nothing in the schema references this entry, so it constrains no envelope.",
|
|
@@ -164,7 +164,7 @@
|
|
|
164
164
|
"warnings": {
|
|
165
165
|
"type": "array",
|
|
166
166
|
"items": { "type": "string", "minLength": 1 },
|
|
167
|
-
"description": "Optional. Non-fatal anomalies the run recorded in its sink or the emitter raised, one string each, starting with its code: upstream_tree_unavailable when [C] could not read the upstream ref into a private tree and the audit stayed on the baseline, source_tree_not_removed when the private tree could not be removed, doc_fetch_failed and doc_not_hashed (each with its count) when a docs-only skill's score leaves out documents that could not be fetched or have no recorded hash, customization_resolver_unavailable when the customization resolver failed and only the bundled customize.toml applied, and result_file_write_failed when a result file could not be written. Present only when there is at least one."
|
|
167
|
+
"description": "Optional. Non-fatal anomalies the run recorded in its sink or the emitter raised, one string each, starting with its code: upstream_tree_unavailable when [C] could not read the upstream ref into a private tree and the audit stayed on the baseline, source_tree_not_removed when the private tree could not be removed, recipe_runner_failed (with the recipe runner's error line) when the runner left no JSON and named an error on stderr, so re-index extracted without it, public_surface_not_applied (with the rule's problem) when re-index could read no public surface for a public-api skill (Quick tier, a runner that could not run, an incomplete run, one with errors, or an entry point it could not trace), so structural diff reported every export the provenance map does not hold as added, doc_fetch_failed and doc_not_hashed (each with its count) when a docs-only skill's score leaves out documents that could not be fetched or have no recorded hash, new_files_not_checked (with the reason) when the Script/Asset Drift check looked for no new script, asset or document because the skill has no brief it could read or a helper it needs could not be loaded, customization_resolver_unavailable when the customization resolver failed and only the bundled customize.toml applied, and result_file_write_failed when a result file could not be written. Present only when there is at least one."
|
|
168
168
|
},
|
|
169
169
|
"error": {
|
|
170
170
|
"type": "object",
|
|
@@ -50,7 +50,7 @@
|
|
|
50
50
|
"halted-for-concurrent-run",
|
|
51
51
|
"blocked"
|
|
52
52
|
],
|
|
53
|
-
"description": "Single-field outcome for pipeline branching. 'success' when the run completed and wrote the updated artifacts. 'no-changes' when detect-changes found nothing to update. 'detect-only' when the workflow was invoked with --detect-only and exited after step 2 without modifying any artifact. 'dry-run' when the workflow was invoked with --dry-run and exited after step 3 (re-extract) without merging or writing: the envelope describes what merge+write WOULD have done. Each 'halted-for-*' value corresponds to a documented halt in the stage prose (workspace-drift from gap-driven.md §3, brief-refinement from detect-changes.md §1c [U] / §2.2 [B], audit from §2.2 [A], remediation-path from gap-driven.md §4a, manual-mismatch from write.md §1, after write.md §6's provenance fixes and after write.md §7's skill-check edits (all error.phase 'write:verify-manual-integrity'), from merge.md §6b's check of the merged SKILL.md before it is published (error.phase 'merge:verify-manual-integrity') and from a headless merge that meets a [MANUAL] conflict (error.phase 'merge:conflict-resolution'), write-failure for a merge.md §6b version-folder or SKILL.md write failure (among them error.phase 'merge:new-version-folder' when the version folder already exists or the next version cannot be computed), a failed write in write.md §2 to §5 (error.phase 'write:artifact-write') and a write.md §8/§8a active-link failure, concurrent-run when init.md §1b's acquire finds another run's fresh run lock (error.phase 'init:concurrency-guard'), or when this run no longer holds its lock at the renewal before merge.md §6b or write.md §2 writes (error.phase 'merge:run-lock' or 'write:run-lock'; error.reason 'run-lock-lost: ...' when the lock lapsed while the run waited). error.reason names the run that holds a lock, never a PID). 'blocked' is the catch-all for halts that don't map to a documented status code, among them a headless run given no skill name (error.phase 'init:skill-name', error.reason starting 'input-missing'), a stack skill (error.phase 'init:stack-skill-guard'), init.md §6b (error.phase 'init:source-tree') when no commit to read could be obtained, a helper that failed (each its own '<step>:<section>' phase), and a private source tree that disappeared mid-run (error.phase '<step>:source-tree-missing'). A halt after merge.md §6b's first write, until write.md §10 closes the rollback window, first undoes it: a gap-driven repair's package is restored from the snapshot it took, and a new version's folders are removed, unless the active link already names the new version, which the halt keeps. write.md §8 points the active link at the new version only after §6 and §7, the last checks that can halt, so a [MANUAL] halt never leaves a live version behind. NOTE: detect-only and dry-run are successful exits: pipelines treating non-success as failure must include them in the success set."
|
|
53
|
+
"description": "Single-field outcome for pipeline branching. 'success' when the run completed and wrote the updated artifacts. 'no-changes' when detect-changes found nothing to update. 'detect-only' when the workflow was invoked with --detect-only and exited after step 2 without modifying any artifact. 'dry-run' when the workflow was invoked with --dry-run and exited after step 3 (re-extract) without merging or writing: the envelope describes what merge+write WOULD have done. Each 'halted-for-*' value corresponds to a documented halt in the stage prose (workspace-drift from gap-driven.md §3, brief-refinement from detect-changes.md §1c [U] / §2.2 [B], audit from §2.2 [A], remediation-path from gap-driven.md §4a, manual-mismatch from write.md §1, after write.md §6's provenance fixes and after write.md §7's skill-check edits (all error.phase 'write:verify-manual-integrity'), from merge.md §6b's check of the merged SKILL.md before it is published (error.phase 'merge:verify-manual-integrity') and from a headless merge that meets a [MANUAL] conflict (error.phase 'merge:conflict-resolution'), write-failure for a merge.md §6b version-folder or SKILL.md write failure (among them error.phase 'merge:new-version-folder' when the version folder already exists or the next version cannot be computed), a failed write in write.md §2 to §5 (error.phase 'write:artifact-write') and a write.md §8/§8a active-link failure, concurrent-run when init.md §1b's acquire finds another run's fresh run lock (error.phase 'init:concurrency-guard'), or when this run no longer holds its lock at the renewal before merge.md §6b or write.md §2 writes (error.phase 'merge:run-lock' or 'write:run-lock'; error.reason 'run-lock-lost: ...' when the lock lapsed while the run waited), or when write.md §8 finds another flip of the active link holding its lock for 5 s (error.phase 'write:active-symlink', error.reason 'busy: ...'). error.reason names the run that holds a run lock, never a PID). 'blocked' is the catch-all for halts that don't map to a documented status code, among them a headless run given no skill name (error.phase 'init:skill-name', error.reason starting 'input-missing'), a stack skill (error.phase 'init:stack-skill-guard'), init.md §6b (error.phase 'init:source-tree') when no commit to read could be obtained, a helper that failed (each its own '<step>:<section>' phase), and a private source tree that disappeared mid-run (error.phase '<step>:source-tree-missing'). A halt after merge.md §6b's first write, until write.md §10 closes the rollback window, first undoes it: a gap-driven repair's package is restored from the snapshot it took, and a new version's folders are removed, unless the active link already names the new version, which the halt keeps. write.md §8 points the active link at the new version only after §6 and §7, the last checks that can halt, so a [MANUAL] halt never leaves a live version behind. NOTE: detect-only and dry-run are successful exits: pipelines treating non-success as failure must include them in the success set."
|
|
54
54
|
},
|
|
55
55
|
"skill_name": {
|
|
56
56
|
"type": "string",
|
|
@@ -128,7 +128,7 @@
|
|
|
128
128
|
"warnings": {
|
|
129
129
|
"type": "array",
|
|
130
130
|
"items": { "type": "string" },
|
|
131
|
-
"description": "Non-fatal anomalies surfaced during the run. May include workspace_drift_overridden, scope_reconciliation_post entries, qmd indexing failures, source-tree:, source-not-fetched, source-version-lower, file-diff-unavailable, no-baseline-time, moved-check-skipped and unknown-language (detect-changes.md §2.1 Category A), workspace-clone-not-updated, target-ref-not-recorded, source-tree-not-removed, run-lock-replaced (init.md §1b took over a stale run lock) and run-lock-not-released entries, unconsumed-test-report (init.md §4b: a failing test report newer than the skill that this normal run did not apply), interrupted-run-cleaned and interrupted-run-not-cleaned (init.md §1b undid what an update whose stale lock it replaced had written), proposed-amendment: entries (a --detect-only or --dry-run run's skill brief decisions, not written), doc-fetch-failed, doc-not-hashed and doc-drift-not-checked (a docs-only skill's documents), rollback-incomplete (a halt could not undo all of this run's writes), run-state-not-finished, customization_resolver_unavailable, test-report: entries (what the test report helpers could not read, and test-report: not routed: for each gap a gap-driven run does not repair), etc. Empty when none."
|
|
131
|
+
"description": "Non-fatal anomalies surfaced during the run. May include workspace_drift_overridden, scope_reconciliation_post entries, qmd indexing failures, source-tree:, source-not-fetched, source-version-lower, file-diff-unavailable, no-baseline-time, moved-check-skipped and unknown-language (detect-changes.md §2.1 Category A), new-files-not-checked (Category D: a skill with no brief, so no new script or asset was looked for), workspace-clone-not-updated, target-ref-not-recorded, source-tree-not-removed, run-lock-replaced (init.md §1b took over a stale run lock) and run-lock-not-released entries, unconsumed-test-report (init.md §4b: a failing test report newer than the skill that this normal run did not apply), interrupted-run-cleaned and interrupted-run-not-cleaned (init.md §1b undid what an update whose stale lock it replaced had written), proposed-amendment: entries (a --detect-only or --dry-run run's skill brief decisions, not written), doc-fetch-failed, doc-not-hashed and doc-drift-not-checked (a docs-only skill's documents), rollback-incomplete (a halt could not undo all of this run's writes), run-state-not-finished, public-api-counted-by-hand: entries (write.md §2: the recipe runner exited 2, so the public names were found without it; each quotes the runner's error line), customization_resolver_unavailable, test-report: entries (what the test report helpers could not read, and test-report: not routed: for each gap a gap-driven run does not repair), etc. Empty when none."
|
|
132
132
|
},
|
|
133
133
|
"error": {
|
|
134
134
|
"oneOf": [
|
|
@@ -23,17 +23,22 @@ Subcommands:
|
|
|
23
23
|
|
|
24
24
|
flip-link Point the link <link> at <target>: create the new link as
|
|
25
25
|
<link>.skf-tmp-link, then os.replace it over <link>, holding an
|
|
26
|
-
exclusive lock on <link>.skf-lock
|
|
27
|
-
|
|
26
|
+
exclusive lock on <link>.skf-lock, which only the flip holding
|
|
27
|
+
it removes. skf-update-active-symlink.py update takes the same
|
|
28
|
+
lock (take_flip_lock), so one flip of a link runs at a time.
|
|
29
|
+
Refuses to replace a <link> that is a real directory or file.
|
|
28
30
|
|
|
29
31
|
Cross-platform: locking branches between fcntl (POSIX) and msvcrt
|
|
30
32
|
(Windows). On Windows os.symlink needs Developer Mode or admin; when it
|
|
31
33
|
fails for lack of privilege, flip-link creates a directory junction
|
|
32
34
|
(mklink /J) instead, which needs neither but requires <target> to be an
|
|
33
|
-
existing directory.
|
|
34
|
-
so on
|
|
35
|
-
|
|
36
|
-
|
|
35
|
+
existing directory. Windows renames nothing over an existing directory
|
|
36
|
+
link, so on the junction path, and on Windows whenever the rename over the
|
|
37
|
+
old link is refused, replace_link removes the old link first (a junction
|
|
38
|
+
whose folder is gone included) and the flip is not atomic. The Windows
|
|
39
|
+
tests in test-skf-atomic-write.py and test-skf-update-active-symlink.py
|
|
40
|
+
make os.symlink fail as it does without the privilege, so CI's Windows
|
|
41
|
+
runner, which holds it, runs the junction path too.
|
|
37
42
|
|
|
38
43
|
Exit codes:
|
|
39
44
|
0 on success (JSON on stdout)
|
|
@@ -44,8 +49,11 @@ Exit codes:
|
|
|
44
49
|
2 on a refused or failed operation, with {"status": "error", "message":
|
|
45
50
|
...} on stderr: write failed, stage-dir could not clear a stale entry,
|
|
46
51
|
commit-dir found a non-directory target or could not swap, flip-link
|
|
47
|
-
found a non-link at <link
|
|
48
|
-
|
|
52
|
+
found a non-link at <link>, another process holding its lock, a
|
|
53
|
+
lock file Windows kept refusing to open for its short wait while the
|
|
54
|
+
file was being removed, or (on POSIX) a locked lock file that kept
|
|
55
|
+
leaving the path for that same wait; and on bad arguments (argparse
|
|
56
|
+
usage on stderr, no JSON)
|
|
49
57
|
|
|
50
58
|
CLI examples:
|
|
51
59
|
cat metadata.json | python3 skf-atomic-write.py write --target /path/to/metadata.json
|
|
@@ -63,6 +71,7 @@ import os
|
|
|
63
71
|
import shutil
|
|
64
72
|
import subprocess
|
|
65
73
|
import sys
|
|
74
|
+
import time
|
|
66
75
|
from pathlib import Path
|
|
67
76
|
|
|
68
77
|
if os.name == "nt":
|
|
@@ -70,6 +79,18 @@ if os.name == "nt":
|
|
|
70
79
|
else:
|
|
71
80
|
import fcntl
|
|
72
81
|
|
|
82
|
+
# While the flip that held <link>.skf-lock is removing it, Windows refuses a
|
|
83
|
+
# new open of the file with PermissionError. flip-link takes that for a held
|
|
84
|
+
# lock and tries again for up to this long (a flip fails fast on contention
|
|
85
|
+
# by design, so the wait is short), then exits 2 as it does for a held lock.
|
|
86
|
+
# The same wait bounds a POSIX flip that keeps finding the file it locked
|
|
87
|
+
# gone from the path: it opens the path again until then, then exits 2.
|
|
88
|
+
# A caller that gives take_flip_lock a longer `wait` (skf-update-active-symlink.py
|
|
89
|
+
# update) keeps both retries going until that wait ends.
|
|
90
|
+
_IS_WINDOWS = os.name == "nt"
|
|
91
|
+
_LOCK_OPEN_WAIT_SECONDS = 1.0
|
|
92
|
+
_LOCK_OPEN_POLL_SECONDS = 0.02
|
|
93
|
+
|
|
73
94
|
|
|
74
95
|
def _create_symlink_or_junction(target: str, link_path: Path) -> str:
|
|
75
96
|
"""Create a directory link from link_path to target.
|
|
@@ -141,6 +162,16 @@ def _release_lock(fd: int) -> None:
|
|
|
141
162
|
pass
|
|
142
163
|
|
|
143
164
|
|
|
165
|
+
# Keep identical to _names_open_file in gap-ledger.py
|
|
166
|
+
# (test/test-skf-atomic-write.py pins the copy).
|
|
167
|
+
def _names_open_file(fd: int, path: Path) -> bool:
|
|
168
|
+
"""True when `path` still names the file open as `fd`."""
|
|
169
|
+
try:
|
|
170
|
+
return os.path.samestat(os.fstat(fd), os.stat(path))
|
|
171
|
+
except OSError:
|
|
172
|
+
return False
|
|
173
|
+
|
|
174
|
+
|
|
144
175
|
def _die(code: int, message: str) -> None:
|
|
145
176
|
print(json.dumps({"status": "error", "message": message}), file=sys.stderr)
|
|
146
177
|
sys.exit(code)
|
|
@@ -282,18 +313,182 @@ def _is_link_or_junction(p: Path) -> bool:
|
|
|
282
313
|
return False
|
|
283
314
|
|
|
284
315
|
|
|
316
|
+
def _remove_link(p: Path) -> None:
|
|
317
|
+
"""Remove the symlink or junction at p, whether or not the folder it names exists.
|
|
318
|
+
|
|
319
|
+
os.readlink raises for a real folder or file (OSError) and for another
|
|
320
|
+
Windows reparse point (ValueError), so neither is ever removed. os.unlink
|
|
321
|
+
removes a symlink, and on Windows a directory symlink or a junction too;
|
|
322
|
+
os.rmdir is the fallback for a directory link unlink refuses.
|
|
323
|
+
"""
|
|
324
|
+
if not p.is_symlink():
|
|
325
|
+
os.readlink(p)
|
|
326
|
+
try:
|
|
327
|
+
os.unlink(p)
|
|
328
|
+
except OSError:
|
|
329
|
+
os.rmdir(p)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def replace_link(link: Path, target: str, tmp_link: Path | None = None) -> str:
|
|
333
|
+
"""Point the link <link> at <target> through <tmp_link>; return "symlink" or "junction".
|
|
334
|
+
|
|
335
|
+
The caller holds take_flip_lock(link) and has refused a real folder or
|
|
336
|
+
file at <link>. A stale <tmp_link> from a crashed flip is removed first.
|
|
337
|
+
The new link is made at <tmp_link> and renamed over <link>, which is
|
|
338
|
+
atomic for a symlink on POSIX. Windows renames nothing over an existing
|
|
339
|
+
directory link, so the old link is removed first on the junction path,
|
|
340
|
+
and on Windows when the rename is refused: a reader can then find no
|
|
341
|
+
<link> for a moment. A failure removes <tmp_link> and raises.
|
|
342
|
+
"""
|
|
343
|
+
if tmp_link is None:
|
|
344
|
+
tmp_link = link.with_name(link.name + ".skf-tmp-link")
|
|
345
|
+
if os.path.lexists(tmp_link):
|
|
346
|
+
_remove_link(tmp_link)
|
|
347
|
+
link_kind = _create_symlink_or_junction(target, tmp_link)
|
|
348
|
+
try:
|
|
349
|
+
if link_kind == "junction" and os.path.lexists(link):
|
|
350
|
+
_remove_link(link)
|
|
351
|
+
try:
|
|
352
|
+
os.replace(tmp_link, link)
|
|
353
|
+
except OSError:
|
|
354
|
+
if not _IS_WINDOWS or not os.path.lexists(link):
|
|
355
|
+
raise
|
|
356
|
+
_remove_link(link)
|
|
357
|
+
os.replace(tmp_link, link)
|
|
358
|
+
except BaseException:
|
|
359
|
+
try:
|
|
360
|
+
if os.path.lexists(tmp_link):
|
|
361
|
+
_remove_link(tmp_link)
|
|
362
|
+
except (OSError, ValueError):
|
|
363
|
+
pass
|
|
364
|
+
raise
|
|
365
|
+
return link_kind
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
class FlipLockHeld(Exception):
|
|
369
|
+
"""Another flip holds <link>.skf-lock; the message names <link>."""
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def take_flip_lock(link: Path, wait: float = 0.0) -> int:
|
|
373
|
+
"""Take <link>.skf-lock, the lock every flip of <link> holds, and return its fd.
|
|
374
|
+
|
|
375
|
+
flip-link and skf-update-active-symlink.py update both take it, so one
|
|
376
|
+
flip of a link runs at a time. `wait` is how long to keep trying a lock
|
|
377
|
+
another flip holds: the call closes the file, sleeps
|
|
378
|
+
_LOCK_OPEN_POLL_SECONDS and opens the path again. flip-link passes 0
|
|
379
|
+
and is refused at once. Raises FlipLockHeld when the lock stays held
|
|
380
|
+
past `wait`, and when the retries cmd_flip_link's docstring describes
|
|
381
|
+
(Windows refusing the open, a locked file that left the path) run out:
|
|
382
|
+
they run until max(_LOCK_OPEN_WAIT_SECONDS, wait). release_flip_lock
|
|
383
|
+
gives the lock back.
|
|
384
|
+
"""
|
|
385
|
+
lock_path = link.with_name(link.name + ".skf-lock")
|
|
386
|
+
held_deadline = time.monotonic() + wait
|
|
387
|
+
deadline = time.monotonic() + max(_LOCK_OPEN_WAIT_SECONDS, wait)
|
|
388
|
+
vanished = False
|
|
389
|
+
while True:
|
|
390
|
+
try:
|
|
391
|
+
lock_fd = os.open(lock_path, os.O_WRONLY | os.O_CREAT, 0o644)
|
|
392
|
+
except PermissionError as exc:
|
|
393
|
+
# Windows refuses to open a file whose removal is pending. A folder
|
|
394
|
+
# at the path, or nothing there twice running (once can be the
|
|
395
|
+
# removal ending just now), is a lasting denial: raise it.
|
|
396
|
+
if not _IS_WINDOWS or os.path.isdir(lock_path):
|
|
397
|
+
raise
|
|
398
|
+
if not os.path.lexists(lock_path):
|
|
399
|
+
if vanished:
|
|
400
|
+
raise
|
|
401
|
+
vanished = True
|
|
402
|
+
continue
|
|
403
|
+
vanished = False
|
|
404
|
+
if time.monotonic() >= deadline:
|
|
405
|
+
raise FlipLockHeld(f"another process holds flip lock on {link} (last open refused: {exc})")
|
|
406
|
+
time.sleep(_LOCK_OPEN_POLL_SECONDS)
|
|
407
|
+
continue
|
|
408
|
+
held = False
|
|
409
|
+
try:
|
|
410
|
+
try:
|
|
411
|
+
_acquire_lock(lock_fd)
|
|
412
|
+
except OSError as e:
|
|
413
|
+
if e.errno != errno.EAGAIN:
|
|
414
|
+
raise
|
|
415
|
+
if time.monotonic() >= held_deadline:
|
|
416
|
+
raise FlipLockHeld(f"another process holds flip lock on {link}")
|
|
417
|
+
held = True
|
|
418
|
+
else:
|
|
419
|
+
# os.name here and in release_flip_lock follows the real OS; _IS_WINDOWS is the test seam of the
|
|
420
|
+
# open retry and of replace_link's remove-first retry.
|
|
421
|
+
if os.name == "nt" or _names_open_file(lock_fd, lock_path):
|
|
422
|
+
return lock_fd
|
|
423
|
+
_release_lock(lock_fd)
|
|
424
|
+
except BaseException:
|
|
425
|
+
os.close(lock_fd)
|
|
426
|
+
raise
|
|
427
|
+
os.close(lock_fd)
|
|
428
|
+
if held:
|
|
429
|
+
time.sleep(_LOCK_OPEN_POLL_SECONDS)
|
|
430
|
+
continue
|
|
431
|
+
if time.monotonic() >= deadline:
|
|
432
|
+
raise FlipLockHeld(f"another process holds flip lock on {link}")
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def release_flip_lock(link: Path, lock_fd: int) -> None:
|
|
436
|
+
"""Give back the lock take_flip_lock returned, removing <link>.skf-lock."""
|
|
437
|
+
lock_path = link.with_name(link.name + ".skf-lock")
|
|
438
|
+
# Only the flip holding the lock gets here, so it alone removes the
|
|
439
|
+
# lock file: on POSIX while the lock is still held, so that a flip on
|
|
440
|
+
# the old file sees the path no longer names it; on Windows, which
|
|
441
|
+
# removes no open file, after the release and the close. Best effort:
|
|
442
|
+
# a leftover file is locked and removed by the next flip.
|
|
443
|
+
if os.name != "nt":
|
|
444
|
+
try:
|
|
445
|
+
lock_path.unlink()
|
|
446
|
+
except OSError:
|
|
447
|
+
pass
|
|
448
|
+
_release_lock(lock_fd)
|
|
449
|
+
os.close(lock_fd)
|
|
450
|
+
if os.name == "nt":
|
|
451
|
+
try:
|
|
452
|
+
lock_path.unlink()
|
|
453
|
+
except OSError:
|
|
454
|
+
pass
|
|
455
|
+
|
|
456
|
+
|
|
285
457
|
def cmd_flip_link(link: Path, target: str) -> None:
|
|
286
458
|
"""Atomically point <link> at <target> using rename-over-symlink pattern.
|
|
287
459
|
|
|
288
460
|
target is the value of the symlink (may be relative, as is convention
|
|
289
|
-
for `active -> 1.0.0`). A lock on <link>.skf-lock
|
|
461
|
+
for `active -> 1.0.0`). A lock on <link>.skf-lock, which
|
|
462
|
+
skf-update-active-symlink.py update takes too, prevents concurrent flips.
|
|
290
463
|
|
|
291
464
|
On Windows, os.symlink requires Developer Mode or admin. When that fails
|
|
292
465
|
with PRIVILEGE_NOT_HELD/ACCESS_DENIED the helper falls back to a directory
|
|
293
466
|
junction (no elevation needed); junctions resolve identically for
|
|
294
|
-
skf-skill-inventory's resolve_active_version().
|
|
467
|
+
skf-skill-inventory's resolve_active_version(). replace_link makes the
|
|
468
|
+
new link and renames it over <link>; skf-update-active-symlink.py update
|
|
469
|
+
flips through it too.
|
|
470
|
+
|
|
471
|
+
The lock follows ledger_lock in gap-ledger.py (the same identity check
|
|
472
|
+
and removal order): a fix to one belongs in both. The lock file is
|
|
473
|
+
removed on release, by the flip that held the lock: a flip refused by a
|
|
474
|
+
held lock exits 2 and leaves the file to its holder. On POSIX the
|
|
475
|
+
removal happens while the lock is still held, and a flip checks that
|
|
476
|
+
the file it locked is still the one at the path: otherwise a flip on
|
|
477
|
+
the removed file and a newcomer on a new one would both get a lock. A
|
|
478
|
+
flip whose file is no longer at the path lets it go and opens the path
|
|
479
|
+
again: it goes on as soon as it locks the file that is at the path, and
|
|
480
|
+
exits 2 at once when another flip holds that file. Only when the files
|
|
481
|
+
it locks keep leaving the path until _LOCK_OPEN_WAIT_SECONDS runs out
|
|
482
|
+
does it exit 2 with the "another process holds flip lock" message a
|
|
483
|
+
held lock gives. Windows removes no file that is open, so there the file goes after the
|
|
484
|
+
release, unless another flip has it open. While the removal is under
|
|
485
|
+
way (the remover's own delete handle is open, or a virus scanner's or
|
|
486
|
+
the search indexer's), Windows refuses a new open of the file with
|
|
487
|
+
PermissionError: the flip retries for up to _LOCK_OPEN_WAIT_SECONDS,
|
|
488
|
+
then exits 2 with the held-lock message, naming the last refusal. A
|
|
489
|
+
refusal with a folder at the path, or with nothing there on an
|
|
490
|
+
immediate second try, is a lasting denial and is raised as before.
|
|
295
491
|
"""
|
|
296
|
-
lock_path = link.with_name(link.name + ".skf-lock")
|
|
297
492
|
link.parent.mkdir(parents=True, exist_ok=True)
|
|
298
493
|
|
|
299
494
|
# Refuse only if <link> is a real directory/file (not a symlink or junction).
|
|
@@ -301,56 +496,14 @@ def cmd_flip_link(link: Path, target: str) -> None:
|
|
|
301
496
|
if link.exists() and not _is_link_or_junction(link):
|
|
302
497
|
_die(2, f"refusing to replace non-link: {link}")
|
|
303
498
|
|
|
304
|
-
lock_fd = os.open(lock_path, os.O_WRONLY | os.O_CREAT, 0o644)
|
|
305
|
-
lock_held = False
|
|
306
499
|
try:
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
_die(2, f"another process holds flip lock on {link}")
|
|
313
|
-
raise
|
|
314
|
-
|
|
315
|
-
tmp_link = link.with_name(link.name + ".skf-tmp-link")
|
|
316
|
-
if tmp_link.is_symlink() or tmp_link.exists():
|
|
317
|
-
# On Windows a junction returns is_dir()=True, exists()=True but
|
|
318
|
-
# is_symlink()=False — must rmdir/unlink based on type.
|
|
319
|
-
if tmp_link.is_dir() and not tmp_link.is_symlink():
|
|
320
|
-
tmp_link.rmdir()
|
|
321
|
-
else:
|
|
322
|
-
tmp_link.unlink()
|
|
323
|
-
try:
|
|
324
|
-
link_kind = _create_symlink_or_junction(target, tmp_link)
|
|
325
|
-
except OSError as e:
|
|
326
|
-
if os.name == "nt" and getattr(e, "winerror", None) in (1314, 5):
|
|
327
|
-
_die(
|
|
328
|
-
2,
|
|
329
|
-
"symlink/junction creation failed on Windows. Junction fallback "
|
|
330
|
-
"requires the target directory to exist. Either enable Developer "
|
|
331
|
-
"Mode (Settings → Privacy & Security → For Developers) or use WSL2.",
|
|
332
|
-
)
|
|
333
|
-
raise
|
|
334
|
-
# os.replace can swap a symlink-over-symlink atomically, but Windows
|
|
335
|
-
# rejects renaming a directory (junction) over an existing directory
|
|
336
|
-
# (junction or real). Drop the existing link first when junction-based.
|
|
337
|
-
if link_kind == "junction" and (link.is_dir() or link.is_symlink() or link.exists()):
|
|
338
|
-
if link.is_symlink() or not link.is_dir():
|
|
339
|
-
link.unlink()
|
|
340
|
-
else:
|
|
341
|
-
link.rmdir()
|
|
342
|
-
os.replace(tmp_link, link)
|
|
500
|
+
lock_fd = take_flip_lock(link)
|
|
501
|
+
except FlipLockHeld as exc:
|
|
502
|
+
_die(2, str(exc))
|
|
503
|
+
try:
|
|
504
|
+
link_kind = replace_link(link, target)
|
|
343
505
|
finally:
|
|
344
|
-
|
|
345
|
-
_release_lock(lock_fd)
|
|
346
|
-
os.close(lock_fd)
|
|
347
|
-
# Lock file is bookkeeping only — once the fd is closed the lock is
|
|
348
|
-
# gone, so leaving the file around just litters the skill_group dir.
|
|
349
|
-
# Best-effort unlink; a concurrent flipper may have already removed it.
|
|
350
|
-
try:
|
|
351
|
-
lock_path.unlink()
|
|
352
|
-
except OSError:
|
|
353
|
-
pass
|
|
506
|
+
release_flip_lock(link, lock_fd)
|
|
354
507
|
|
|
355
508
|
_ok({"link": str(link), "points_to": target, "kind": link_kind})
|
|
356
509
|
|