super-skill-cli 0.10.0__tar.gz → 0.11.1__tar.gz
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.
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/.claude-plugin/marketplace.json +2 -2
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/.claude-plugin/plugin.json +1 -1
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/CHANGELOG.md +90 -0
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/PKG-INFO +1 -1
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/pyproject.toml +1 -1
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/super_skill/candidate.py +11 -5
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/super_skill/capture.py +25 -4
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/super_skill/cli.py +24 -14
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/super_skill/evallite.py +10 -2
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/super_skill/gate.py +40 -5
- super_skill_cli-0.11.1/super_skill/minestate.py +69 -0
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/super_skill/redact.py +33 -6
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/super_skill/registry.py +35 -12
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/super_skill/seed.py +6 -0
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/tests/test_candidate.py +21 -0
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/tests/test_capture.py +48 -0
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/tests/test_cli.py +45 -0
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/tests/test_evallite.py +23 -0
- super_skill_cli-0.11.1/tests/test_gate.py +126 -0
- super_skill_cli-0.11.1/tests/test_minestate.py +55 -0
- super_skill_cli-0.11.1/tests/test_redact.py +162 -0
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/tests/test_registry.py +42 -0
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/tests/test_seed.py +15 -0
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/uv.lock +1 -1
- super_skill_cli-0.10.0/super_skill/minestate.py +0 -57
- super_skill_cli-0.10.0/tests/test_gate.py +0 -54
- super_skill_cli-0.10.0/tests/test_minestate.py +0 -48
- super_skill_cli-0.10.0/tests/test_redact.py +0 -71
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/.gitignore +0 -0
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/LICENSE +0 -0
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/README.md +0 -0
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/README.zh-CN.md +0 -0
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/codex/README.md +0 -0
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/codex/install.sh +0 -0
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/codex/skills/super-skill/SKILL.md +0 -0
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/codex/skills/super-skill/agents/openai.yaml +0 -0
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/commands/candidates.md +0 -0
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/commands/doctor.md +0 -0
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/commands/mine.md +0 -0
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/commands/seed.md +0 -0
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/commands/status.md +0 -0
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/evals/test-fix-family/README.md +0 -0
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/evals/test-fix-family/cases/case1/PROMPT.md +0 -0
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/evals/test-fix-family/cases/case1/broken.py +0 -0
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/evals/test-fix-family/cases/case1/fixed.py +0 -0
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/evals/test-fix-family/cases/case1/verify_test.py +0 -0
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/evals/test-fix-family/cases/case2/PROMPT.md +0 -0
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/evals/test-fix-family/cases/case2/broken.py +0 -0
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/evals/test-fix-family/cases/case2/fixed.py +0 -0
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/evals/test-fix-family/cases/case2/verify_test.py +0 -0
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/evals/test-fix-family/cases/case3/PROMPT.md +0 -0
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/evals/test-fix-family/cases/case3/broken.py +0 -0
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/evals/test-fix-family/cases/case3/fixed.py +0 -0
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/evals/test-fix-family/cases/case3/verify_test.py +0 -0
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/evals/test-fix-family/gate2_report.py +0 -0
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/evals/test-fix-family/grader.py +0 -0
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/evals/test-fix-family/handwritten/SKILL.md +0 -0
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/evals/test-fix-family/results.template.json +0 -0
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/hooks/hooks.json +0 -0
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/skills/super-skill/SKILL.md +0 -0
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/super_skill/__init__.py +0 -0
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/super_skill/config.py +0 -0
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/super_skill/doctor.py +0 -0
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/super_skill/hooks.py +0 -0
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/super_skill/mine.py +0 -0
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/super_skill/schemas.py +0 -0
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/super_skill/skillmd.py +0 -0
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/tests/test_codex_package.py +0 -0
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/tests/test_doctor.py +0 -0
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/tests/test_gate2_harness.py +0 -0
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/tests/test_hooks.py +0 -0
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/tests/test_plugin_manifest.py +0 -0
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/tests/test_skillmd.py +0 -0
- {super_skill_cli-0.10.0 → super_skill_cli-0.11.1}/tests/test_target_adapter.py +0 -0
|
@@ -6,14 +6,14 @@
|
|
|
6
6
|
},
|
|
7
7
|
"metadata": {
|
|
8
8
|
"description": "super-skill: personal Agent-Skill package manager (versioned registry, rollback, mining).",
|
|
9
|
-
"version": "0.
|
|
9
|
+
"version": "0.11.1"
|
|
10
10
|
},
|
|
11
11
|
"plugins": [
|
|
12
12
|
{
|
|
13
13
|
"name": "super-skill",
|
|
14
14
|
"source": "./",
|
|
15
15
|
"description": "Git-backed versioned registry for your Claude Code skills: seed import, explain/provenance, one-command rollback, integrity doctor, and opportunity mining. Wraps the `super-skill` CLI (pip/uv installable as super-skill-cli).",
|
|
16
|
-
"version": "0.
|
|
16
|
+
"version": "0.11.1",
|
|
17
17
|
"category": "development"
|
|
18
18
|
}
|
|
19
19
|
]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "super-skill",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.1",
|
|
4
4
|
"description": "Personal Agent-Skill package manager: a git-backed, versioned registry for your Claude Code skills with seed import, provenance/explain, one-command rollback, integrity doctor, and opportunity mining from captured sessions.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "sdsrss",
|
|
@@ -3,6 +3,96 @@
|
|
|
3
3
|
All notable changes to this project are documented here. Format follows
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/); this project uses semantic versioning.
|
|
5
5
|
|
|
6
|
+
## [0.11.1] - 2026-07-12
|
|
7
|
+
|
|
8
|
+
Follow-up patch from a code review of 0.11.0. Fixes a **Critical ReDoS regression
|
|
9
|
+
the 0.11.0 redaction hardening introduced** plus four gaps. Backward-compatible;
|
|
10
|
+
upgrade recommended over 0.11.0 (`pip install -U super-skill-cli`).
|
|
11
|
+
|
|
12
|
+
### Security
|
|
13
|
+
- **Fix ReDoS in the redaction `assigned_secret` rule** (regression in 0.11.0) —
|
|
14
|
+
the broadened rule used unbounded `[A-Za-z0-9_]*` runs, so underscore-heavy
|
|
15
|
+
input (snake_case blobs, env dumps) backtracked catastrophically: a ~6 KB
|
|
16
|
+
payload took ~21 s. Since redaction runs on every captured event, a normal
|
|
17
|
+
hook payload could hang `super-skill capture` (worse than the crash 0.11.0 was
|
|
18
|
+
hardening). Runs are now bounded (`{0,64}`); the same payload processes in
|
|
19
|
+
~18 ms and secrets still redact.
|
|
20
|
+
- **`sk-proj-`/`sk-svcacct-` OpenAI keys are now fully redacted** — the body may
|
|
21
|
+
contain `_`/`-`, and the previous rule stopped at the first such char, leaking
|
|
22
|
+
the tail.
|
|
23
|
+
- **A secret in a keyword-named frontmatter field is now caught** — the gate and
|
|
24
|
+
eval-lite serialized frontmatter with JSON, which quoted the key (`"token":`)
|
|
25
|
+
and broke the `keyword: value` secret pattern; they now use YAML, preserving
|
|
26
|
+
adjacency.
|
|
27
|
+
- **The gate folds uppercase homoglyphs** — the confusables map was lowercase
|
|
28
|
+
only, so `СURL … | bash` (uppercase Cyrillic) slipped through; normalization
|
|
29
|
+
now casefolds first.
|
|
30
|
+
|
|
31
|
+
### Fixed
|
|
32
|
+
- **`capture` survives deeply-nested JSON** — `json.loads` raises `RecursionError`
|
|
33
|
+
(a `RuntimeError`, not `JSONDecodeError`) on very deep input; the parse guard
|
|
34
|
+
now exits 0 on any error (NFR-3).
|
|
35
|
+
- The WAL append loops on short writes so a very large line can't be truncated.
|
|
36
|
+
|
|
37
|
+
## [0.11.0] - 2026-07-12
|
|
38
|
+
|
|
39
|
+
Security & reliability hardening from a full production-readiness audit. All fixes
|
|
40
|
+
are backward-compatible; defaults unchanged. **Recommended upgrade** if you wire
|
|
41
|
+
live capture via `hooks-config` — the capture/redaction path had secret-leak and
|
|
42
|
+
crash-safety gaps that this release closes.
|
|
43
|
+
|
|
44
|
+
**Migration**: none required. An existing `mine_state.json` from 0.10.0 is auto-
|
|
45
|
+
handled (the watermark format changed to a session-id set; an old file reads as
|
|
46
|
+
"nothing mined" and self-heals on the next `mine`). **Revert path**: pin the prior
|
|
47
|
+
release with `pip install super-skill-cli==0.10.0`.
|
|
48
|
+
|
|
49
|
+
### Security
|
|
50
|
+
- **Redaction no longer leaks underscore-delimited env-var secrets** — `DB_PASSWORD=…`,
|
|
51
|
+
`AWS_SECRET_ACCESS_KEY=…`, `SECRET_KEY=…` etc. were written verbatim to the WAL
|
|
52
|
+
because the keyword rule anchored on `\b` (an underscore is a word char). The
|
|
53
|
+
same `redact_text` feeds the eval-lite secret gate, so this closed a leak in
|
|
54
|
+
both paths at once.
|
|
55
|
+
- **Redaction now matches current key formats** — OpenAI `sk-proj-`/`sk-svcacct-`,
|
|
56
|
+
Stripe `sk_live_`/`sk_test_`, GitHub fine-grained PATs `github_pat_`, and GCP
|
|
57
|
+
`AIza…` keys were previously unmatched.
|
|
58
|
+
- **The instruction-layer gate + eval-lite scan the full frontmatter**, not just
|
|
59
|
+
`description` + body — an injection or secret in any other frontmatter field
|
|
60
|
+
(e.g. `instructions:`, `metadata:`) previously shipped to the host unscanned.
|
|
61
|
+
- **The gate normalizes cheap obfuscation** (NFKC + zero-width strip + common
|
|
62
|
+
Cyrillic/Greek homoglyph fold) so `сurl … | bash` and homoglyph variants no
|
|
63
|
+
longer slip a shell pipe past the ASCII rules.
|
|
64
|
+
- **The capture WAL and mine watermark are gitignored** — `events/` and
|
|
65
|
+
`mine_state.json` are no longer swept into the registry's audit history by
|
|
66
|
+
`git add -A`, keeping (redacted-but-private) session content out of the repo.
|
|
67
|
+
- **Redaction depth is capped** so a pathologically nested payload can't
|
|
68
|
+
`RecursionError`; the over-deep subtree is dropped rather than leaked.
|
|
69
|
+
|
|
70
|
+
### Fixed
|
|
71
|
+
- **`capture` never fails the host session (NFR-3)** — non-object JSON on stdin
|
|
72
|
+
and any WAL write error are now swallowed with exit 0; previously a list/scalar
|
|
73
|
+
payload raised and exited 1.
|
|
74
|
+
- **The WAL tolerates a torn/partial line** — a hook killed mid-append no longer
|
|
75
|
+
bricks every reader (`status`/`mine`/`count`); the bad line is skipped.
|
|
76
|
+
- **Concurrent captures no longer corrupt the WAL** — each event is one atomic
|
|
77
|
+
`O_APPEND` write instead of a buffered write that could interleave across
|
|
78
|
+
processes.
|
|
79
|
+
- **Registry/candidate/watermark writes are atomic** (temp + `os.replace`), and a
|
|
80
|
+
corrupt `meta.json`/`candidate.json` surfaces as a typed error instead of a raw
|
|
81
|
+
traceback that crashed `status`/`list`/`doctor`.
|
|
82
|
+
- **`approve` is crash-idempotent** — a crash between the registry commit and
|
|
83
|
+
marking the candidate approved no longer double-promotes on re-run.
|
|
84
|
+
- **`seed` skips a skill whose frontmatter `name` ≠ its directory name**, so two
|
|
85
|
+
host dirs sharing a name can no longer collapse into one version chain.
|
|
86
|
+
- **The `mine` reminder survives WAL TTL pruning** — the watermark tracks mined
|
|
87
|
+
session ids, so new sessions still count as "unmined" after old ones roll off.
|
|
88
|
+
- **`init` rebuilds a missing/stale `.gitignore`** even when `.git` already exists.
|
|
89
|
+
|
|
90
|
+
### Changed
|
|
91
|
+
- Docs `02`/`03`/`04` bumped to v1.4 with WS-vs-target implementation notes
|
|
92
|
+
(registry-as-source distribution, WS entity/eval-lite subsets) for doc↔code
|
|
93
|
+
consistency. Test suite grew 127 → 159; `gate.py` and `redact.py` at 100%
|
|
94
|
+
line coverage.
|
|
95
|
+
|
|
6
96
|
## [0.10.0] - 2026-07-12
|
|
7
97
|
|
|
8
98
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: super-skill-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.11.1
|
|
4
4
|
Summary: Personal Agent-Skill package manager: versioned registry, seed import, explain, rollback (M0+WS scope).
|
|
5
5
|
Project-URL: Homepage, https://github.com/sdsrss/super-skill
|
|
6
6
|
Project-URL: Repository, https://github.com/sdsrss/super-skill
|
|
@@ -10,11 +10,12 @@ candidate -> gate (human approve) -> promote, never bypassed.
|
|
|
10
10
|
|
|
11
11
|
from __future__ import annotations
|
|
12
12
|
|
|
13
|
+
import os
|
|
13
14
|
import re
|
|
14
15
|
from datetime import datetime
|
|
15
16
|
from pathlib import Path
|
|
16
17
|
|
|
17
|
-
from pydantic import BaseModel, ConfigDict, Field
|
|
18
|
+
from pydantic import BaseModel, ConfigDict, Field, ValidationError
|
|
18
19
|
|
|
19
20
|
from . import config
|
|
20
21
|
from .evallite import EvalError, eval_lite
|
|
@@ -95,7 +96,10 @@ class CandidateStore:
|
|
|
95
96
|
meta = self._cdir(cand_id) / "candidate.json"
|
|
96
97
|
if not meta.exists():
|
|
97
98
|
return None
|
|
98
|
-
|
|
99
|
+
try:
|
|
100
|
+
return Candidate.model_validate_json(meta.read_text(encoding="utf-8"))
|
|
101
|
+
except ValidationError as e:
|
|
102
|
+
raise CandidateError(f"{cand_id}: corrupt candidate.json ({e})") from e
|
|
99
103
|
|
|
100
104
|
def list(self) -> list[Candidate]:
|
|
101
105
|
if not self.dir.exists():
|
|
@@ -116,9 +120,11 @@ class CandidateStore:
|
|
|
116
120
|
def save(self, cand: Candidate) -> None:
|
|
117
121
|
cdir = self._cdir(cand.candidate_id)
|
|
118
122
|
cdir.mkdir(parents=True, exist_ok=True)
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
)
|
|
123
|
+
# Atomic write so a crash can't leave a truncated candidate.json (M12).
|
|
124
|
+
p = cdir / "candidate.json"
|
|
125
|
+
tmp = p.with_suffix(".json.tmp")
|
|
126
|
+
tmp.write_text(cand.model_dump_json(indent=2), encoding="utf-8")
|
|
127
|
+
os.replace(tmp, p)
|
|
122
128
|
|
|
123
129
|
|
|
124
130
|
def draft_from_families(
|
|
@@ -7,10 +7,13 @@ events are TTL-bounded (FR-CAP-6); structured products live elsewhere.
|
|
|
7
7
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
|
+
import os
|
|
10
11
|
import uuid
|
|
11
12
|
from collections.abc import Iterator
|
|
12
13
|
from pathlib import Path
|
|
13
14
|
|
|
15
|
+
from pydantic import ValidationError
|
|
16
|
+
|
|
14
17
|
from . import config
|
|
15
18
|
from .redact import redact_payload, redact_text
|
|
16
19
|
from .schemas import CaptureEvent, EventType
|
|
@@ -53,8 +56,17 @@ class EventLog:
|
|
|
53
56
|
day = event.timestamp.strftime("%Y-%m-%d")
|
|
54
57
|
out = self.events_dir / day / "events.jsonl"
|
|
55
58
|
out.parent.mkdir(parents=True, exist_ok=True)
|
|
56
|
-
|
|
57
|
-
|
|
59
|
+
# One atomic append per event: a single O_APPEND os.write, not a buffered
|
|
60
|
+
# writer that can split a large line into multiple write() calls and let
|
|
61
|
+
# two concurrent captures interleave into a corrupt line (H4).
|
|
62
|
+
data = (event.model_dump_json() + "\n").encode("utf-8")
|
|
63
|
+
fd = os.open(out, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o644)
|
|
64
|
+
try:
|
|
65
|
+
# Loop on short writes so a very large line isn't silently truncated.
|
|
66
|
+
while data:
|
|
67
|
+
data = data[os.write(fd, data):]
|
|
68
|
+
finally:
|
|
69
|
+
os.close(fd)
|
|
58
70
|
return event
|
|
59
71
|
|
|
60
72
|
def iter_events(self) -> Iterator[CaptureEvent]:
|
|
@@ -66,11 +78,20 @@ class EventLog:
|
|
|
66
78
|
continue
|
|
67
79
|
for line in wal.read_text(encoding="utf-8").splitlines():
|
|
68
80
|
line = line.strip()
|
|
69
|
-
if line:
|
|
81
|
+
if not line:
|
|
82
|
+
continue
|
|
83
|
+
try:
|
|
70
84
|
yield CaptureEvent.model_validate_json(line)
|
|
85
|
+
except ValidationError:
|
|
86
|
+
# A killed hook can leave a torn final line; one bad record
|
|
87
|
+
# must not brick every reader (status/mine/count). Skip it.
|
|
88
|
+
continue
|
|
71
89
|
|
|
72
90
|
def count(self) -> int:
|
|
73
91
|
return sum(1 for _ in self.iter_events())
|
|
74
92
|
|
|
93
|
+
def session_ids(self) -> set[str]:
|
|
94
|
+
return {ev.session_id for ev in self.iter_events()}
|
|
95
|
+
|
|
75
96
|
def distinct_sessions(self) -> int:
|
|
76
|
-
return len(
|
|
97
|
+
return len(self.session_ids())
|
|
@@ -97,37 +97,47 @@ def capture(
|
|
|
97
97
|
try:
|
|
98
98
|
raw = sys.stdin.read()
|
|
99
99
|
data = json.loads(raw) if raw.strip() else {}
|
|
100
|
-
except
|
|
100
|
+
except Exception:
|
|
101
|
+
# NFR-3: never fail the session. Covers JSONDecodeError, OSError, and
|
|
102
|
+
# RecursionError (deeply-nested JSON — a RuntimeError, not JSONDecodeError).
|
|
101
103
|
raise typer.Exit(0) from None
|
|
102
|
-
|
|
104
|
+
# NFR-3: the hook must NEVER fail the host session. Everything past here —
|
|
105
|
+
# non-dict JSON (data.get would raise), an unknown event type, or any WAL
|
|
106
|
+
# write error — is swallowed and exits 0. Capture-never-fails beats
|
|
107
|
+
# capture-complete.
|
|
103
108
|
try:
|
|
104
|
-
|
|
105
|
-
|
|
109
|
+
if not isinstance(data, dict):
|
|
110
|
+
raise typer.Exit(0)
|
|
111
|
+
name = event_type or str(data.get("hook_event_name", ""))
|
|
112
|
+
etype = EventType(name) # ValueError on unknown type
|
|
113
|
+
session_id = str(data.get("session_id", "unknown"))
|
|
114
|
+
project_id = data.get("cwd")
|
|
115
|
+
EventLog().append(
|
|
116
|
+
etype, session_id, data, project_id=str(project_id) if project_id else None
|
|
117
|
+
)
|
|
118
|
+
except typer.Exit:
|
|
119
|
+
raise
|
|
120
|
+
except Exception:
|
|
106
121
|
raise typer.Exit(0) from None
|
|
107
|
-
session_id = str(data.get("session_id", "unknown"))
|
|
108
|
-
project_id = data.get("cwd")
|
|
109
|
-
EventLog().append(
|
|
110
|
-
etype, session_id, data, project_id=str(project_id) if project_id else None
|
|
111
|
-
)
|
|
112
122
|
|
|
113
123
|
|
|
114
124
|
@app.command()
|
|
115
125
|
def mine(min_sessions: int = typer.Option(3, "--min-sessions")) -> None:
|
|
116
126
|
"""Surface recurring task families from captured events (FR-GEN-1 signal)."""
|
|
117
127
|
log = EventLog()
|
|
128
|
+
session_ids = log.session_ids()
|
|
118
129
|
families = mine_families(log.iter_events(), min_sessions=min_sessions)
|
|
119
|
-
distinct = log.distinct_sessions()
|
|
120
130
|
if not families:
|
|
121
131
|
typer.echo(
|
|
122
132
|
f"no families recurring across >={min_sessions} sessions yet "
|
|
123
|
-
f"({
|
|
133
|
+
f"({len(session_ids)} distinct sessions captured)"
|
|
124
134
|
)
|
|
125
135
|
else:
|
|
126
136
|
typer.echo(f"{'sessions':>8} {'events':>6} family")
|
|
127
137
|
for fam in families:
|
|
128
138
|
typer.echo(f"{fam.session_count:>8} {fam.event_count:>6} {fam.label}")
|
|
129
139
|
# Mining acknowledges the accumulated sessions: reset the reminder watermark.
|
|
130
|
-
minestate.record_mined(log.root,
|
|
140
|
+
minestate.record_mined(log.root, session_ids)
|
|
131
141
|
|
|
132
142
|
|
|
133
143
|
@app.command()
|
|
@@ -149,7 +159,7 @@ def status() -> None:
|
|
|
149
159
|
log = EventLog(reg.root)
|
|
150
160
|
typer.echo(f"events : {log.count()}")
|
|
151
161
|
typer.echo(f"candidates : {len(cands)} ({breakdown})")
|
|
152
|
-
unmined = minestate.unmined(reg.root, log.
|
|
162
|
+
unmined = minestate.unmined(reg.root, log.session_ids())
|
|
153
163
|
if unmined >= minestate.reminder_threshold():
|
|
154
164
|
typer.echo(f"reminder : {unmined} distinct sessions unmined "
|
|
155
165
|
f"— run `super-skill mine`")
|
|
@@ -322,7 +332,7 @@ def candidate_draft(min_sessions: int = typer.Option(3, "--min-sessions")) -> No
|
|
|
322
332
|
store = CandidateStore(config.state_root())
|
|
323
333
|
created = draft_from_families(store, families)
|
|
324
334
|
# Drafting acts on the mining, so it too clears the status reminder.
|
|
325
|
-
minestate.record_mined(log.root, log.
|
|
335
|
+
minestate.record_mined(log.root, log.session_ids())
|
|
326
336
|
if not created:
|
|
327
337
|
typer.echo("no new candidates (nothing mined, or all already drafted)")
|
|
328
338
|
return
|
|
@@ -17,6 +17,8 @@ from __future__ import annotations
|
|
|
17
17
|
import re
|
|
18
18
|
from dataclasses import dataclass, field
|
|
19
19
|
|
|
20
|
+
import yaml
|
|
21
|
+
|
|
20
22
|
from .redact import redact_text
|
|
21
23
|
from .skillmd import SkillMdError, parse
|
|
22
24
|
|
|
@@ -72,8 +74,14 @@ def eval_lite(raw: str) -> EvalReport:
|
|
|
72
74
|
except SkillMdError as e:
|
|
73
75
|
return EvalReport(checks=[EvalCheck("schema", False, str(e))])
|
|
74
76
|
|
|
75
|
-
# 2. zero credential / PII leak in the instruction text (凭据与 PII 泄漏 = 0)
|
|
76
|
-
|
|
77
|
+
# 2. zero credential / PII leak in the instruction text (凭据与 PII 泄漏 = 0).
|
|
78
|
+
# Scan the FULL frontmatter (extra="allow" ships every field to the host, M6),
|
|
79
|
+
# not just description + body. YAML (not JSON) keeps ``keyword: value``
|
|
80
|
+
# adjacency so a secret in a keyword-named field is caught (v0.11.1 #4).
|
|
81
|
+
fm = yaml.safe_dump(
|
|
82
|
+
parsed.frontmatter.model_dump(), default_flow_style=False, allow_unicode=True
|
|
83
|
+
)
|
|
84
|
+
text = f"{fm}\n{parsed.body}"
|
|
77
85
|
_, counts = redact_text(text)
|
|
78
86
|
leaks = {k: c for k, c in counts.items() if k != "home_path"}
|
|
79
87
|
checks.append(
|
|
@@ -17,10 +17,33 @@ land at M1 — v1 candidates carry no scripts, so there is nothing to cross-chec
|
|
|
17
17
|
from __future__ import annotations
|
|
18
18
|
|
|
19
19
|
import re
|
|
20
|
+
import unicodedata
|
|
20
21
|
from dataclasses import dataclass
|
|
21
22
|
|
|
23
|
+
import yaml
|
|
24
|
+
|
|
22
25
|
from .skillmd import parse
|
|
23
26
|
|
|
27
|
+
# Cheap-obfuscation defenses (M7): strip zero-width/format chars and fold the
|
|
28
|
+
# common Cyrillic/Greek homoglyphs so ``сurl`` (ZWSP) and ``сurl`` (Cyrillic es)
|
|
29
|
+
# can't slip a shell pipe past the ASCII regexes. Base64-encoded payloads remain
|
|
30
|
+
# out of scope until the M1 LLM-judge layer.
|
|
31
|
+
_ZERO_WIDTH = dict.fromkeys(map(ord, ""), None)
|
|
32
|
+
_CONFUSABLES = str.maketrans({
|
|
33
|
+
"а": "a", "с": "c", "ԁ": "d", "е": "e", "ɡ": "g", "һ": "h", "і": "i",
|
|
34
|
+
"ј": "j", "ո": "n", "о": "o", "р": "p", "ѕ": "s", "т": "t", "υ": "u",
|
|
35
|
+
"ν": "v", "х": "x", "у": "y", "α": "a", "ε": "e", "ο": "o", "ρ": "p", "κ": "k",
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _normalize(text: str) -> str:
|
|
40
|
+
"""Fold cheap obfuscations before pattern-matching. casefold() runs before the
|
|
41
|
+
confusables map so UPPERCASE homoglyphs (e.g. Cyrillic ``С``) fold too."""
|
|
42
|
+
text = unicodedata.normalize("NFKC", text)
|
|
43
|
+
text = text.translate(_ZERO_WIDTH)
|
|
44
|
+
text = text.casefold()
|
|
45
|
+
return text.translate(_CONFUSABLES)
|
|
46
|
+
|
|
24
47
|
# (category, pattern). Case-insensitive. Ordered most-severe first.
|
|
25
48
|
_RULES: list[tuple[str, re.Pattern[str]]] = [
|
|
26
49
|
(
|
|
@@ -84,6 +107,7 @@ class InstructionGateError(RuntimeError):
|
|
|
84
107
|
|
|
85
108
|
def scan_text(text: str, location: str) -> list[Finding]:
|
|
86
109
|
findings: list[Finding] = []
|
|
110
|
+
text = _normalize(text)
|
|
87
111
|
for category, pat in _RULES:
|
|
88
112
|
for m in pat.finditer(text):
|
|
89
113
|
snippet = " ".join(m.group(0).split())[:80]
|
|
@@ -92,11 +116,22 @@ def scan_text(text: str, location: str) -> list[Finding]:
|
|
|
92
116
|
|
|
93
117
|
|
|
94
118
|
def scan_skill_md(raw: str) -> list[Finding]:
|
|
95
|
-
"""Scan a SKILL.md's
|
|
119
|
+
"""Scan a SKILL.md's frontmatter + body for external-action imperatives.
|
|
96
120
|
|
|
97
121
|
description is scanned separately: §2.4bis forbids imperative / second-person
|
|
98
|
-
external-action language there (it would both win routing and inject).
|
|
122
|
+
external-action language there (it would both win routing and inject). Every
|
|
123
|
+
OTHER frontmatter field is scanned too (M6): ``extra="allow"`` means fields
|
|
124
|
+
like ``instructions:`` / ``metadata:`` ship verbatim to the host, so they
|
|
125
|
+
need the same scrutiny — scanning only description + body left them a bypass."""
|
|
99
126
|
parsed = parse(raw)
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
)
|
|
127
|
+
findings = scan_text(parsed.frontmatter.description, "description")
|
|
128
|
+
fm = parsed.frontmatter.model_dump()
|
|
129
|
+
# name is NAME_RE-constrained (safe); description already scanned above.
|
|
130
|
+
extra = {k: v for k, v in fm.items() if k not in ("name", "description") and v is not None}
|
|
131
|
+
if extra:
|
|
132
|
+
# YAML (not JSON) keeps ``keyword: value`` adjacency — JSON quotes the key
|
|
133
|
+
# (``"token":``), which breaks the credential/secret patterns that expect
|
|
134
|
+
# the keyword immediately before ``:`` (v0.11.1 #4).
|
|
135
|
+
dumped = yaml.safe_dump(extra, default_flow_style=False, allow_unicode=True)
|
|
136
|
+
findings += scan_text(dumped, "frontmatter")
|
|
137
|
+
return findings + scan_text(parsed.body, "body")
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Mine watermark (D#67): remember WHICH distinct sessions had been mined last,
|
|
2
|
+
so ``status``/``mine`` can nudge once enough new sessions accumulate — "you
|
|
3
|
+
solved X across N unmined sessions, run mine".
|
|
4
|
+
|
|
5
|
+
It stores the SET of mined session ids, not a count (M13): the WAL is TTL-bounded,
|
|
6
|
+
so an absolute count went silently wrong once old sessions rolled off — new
|
|
7
|
+
sessions could no longer exceed the stale high-water count. A set is robust:
|
|
8
|
+
unmined = current session ids not in the mined set.
|
|
9
|
+
|
|
10
|
+
Deliberately tiny: one JSON file in the state root, overwritten (never appended)
|
|
11
|
+
each mine. Missing/corrupt reads as the empty set so a fresh or hand-broken state
|
|
12
|
+
degrades to "everything is unmined" rather than crashing a read-only status.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import tempfile
|
|
20
|
+
from collections.abc import Iterable
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
_FILE = "mine_state.json"
|
|
24
|
+
_DEFAULT_THRESHOLD = 3
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def reminder_threshold() -> int:
|
|
28
|
+
"""Distinct unmined sessions before status nudges (env-overridable)."""
|
|
29
|
+
raw = os.environ.get("SUPER_SKILL_MINE_REMINDER")
|
|
30
|
+
if raw is None:
|
|
31
|
+
return _DEFAULT_THRESHOLD
|
|
32
|
+
try:
|
|
33
|
+
return int(raw)
|
|
34
|
+
except ValueError:
|
|
35
|
+
return _DEFAULT_THRESHOLD
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _path(root: Path) -> Path:
|
|
39
|
+
return root / _FILE
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def mined_sessions(root: Path) -> set[str]:
|
|
43
|
+
"""Set of session ids mined last (empty set if absent/corrupt)."""
|
|
44
|
+
p = _path(root)
|
|
45
|
+
if not p.exists():
|
|
46
|
+
return set()
|
|
47
|
+
try:
|
|
48
|
+
data = json.loads(p.read_text(encoding="utf-8"))
|
|
49
|
+
return set(data["mined_session_ids"])
|
|
50
|
+
except (ValueError, KeyError, TypeError):
|
|
51
|
+
return set()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def record_mined(root: Path, session_ids: Iterable[str]) -> None:
|
|
55
|
+
"""Persist the set of session ids seen at this mine as the new watermark."""
|
|
56
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
57
|
+
p = _path(root)
|
|
58
|
+
# Atomic write so a crash can't leave a truncated watermark (M12); a corrupt
|
|
59
|
+
# read already degrades to empty, but atomicity keeps the good value intact.
|
|
60
|
+
fd, tmp = tempfile.mkstemp(dir=root, suffix=".tmp")
|
|
61
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
62
|
+
json.dump({"mined_session_ids": sorted(set(session_ids))}, f)
|
|
63
|
+
os.replace(tmp, p)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def unmined(root: Path, current_sessions: Iterable[str]) -> int:
|
|
67
|
+
"""How many currently-captured sessions have not been mined yet. Robust to
|
|
68
|
+
WAL TTL pruning: pruned-and-mined sessions simply don't appear in current."""
|
|
69
|
+
return len(set(current_sessions) - mined_sessions(root))
|
|
@@ -24,14 +24,28 @@ _PATTERNS: list[tuple[str, re.Pattern[str], int]] = [
|
|
|
24
24
|
re.DOTALL,
|
|
25
25
|
), 0),
|
|
26
26
|
("anthropic_key", re.compile(r"sk-ant-[A-Za-z0-9_-]{16,}"), 0),
|
|
27
|
-
(
|
|
27
|
+
# OpenAI legacy (sk-…) + prefixed project/service keys (sk-proj-…, sk-svcacct-…).
|
|
28
|
+
# The 20+ alnum CORE keeps prose (short hyphenated words) from matching; the
|
|
29
|
+
# trailing _/- -joined segments capture project-key bodies fully (no tail leak).
|
|
30
|
+
("openai_key", re.compile(r"sk-(?:[a-z]+-)?[A-Za-z0-9]{20,}(?:[_-][A-Za-z0-9]+)*"), 0),
|
|
31
|
+
("stripe_key", re.compile(r"sk_(?:live|test)_[A-Za-z0-9]{16,}"), 0),
|
|
32
|
+
("github_pat", re.compile(r"github_pat_[A-Za-z0-9_]{20,}"), 0),
|
|
28
33
|
("github_token", re.compile(r"gh[pousr]_[A-Za-z0-9]{20,}"), 0),
|
|
29
34
|
("aws_key", re.compile(r"AKIA[0-9A-Z]{16}"), 0),
|
|
35
|
+
("gcp_key", re.compile(r"AIza[0-9A-Za-z_-]{35}"), 0),
|
|
30
36
|
("slack_token", re.compile(r"xox[baprs]-[A-Za-z0-9-]{10,}"), 0),
|
|
31
37
|
("bearer_token", re.compile(r"(?i)\bBearer\s+[A-Za-z0-9._~+/-]{16,}=*"), 0),
|
|
38
|
+
# Match a full identifier token that CONTAINS a sensitive word, up to the
|
|
39
|
+
# assignment. A ``\b`` left-anchor missed ``DB_PASSWORD`` (``_`` is a word
|
|
40
|
+
# char) and a bare keyword missed ``SECRET_KEY`` (the ``_KEY`` suffix broke
|
|
41
|
+
# ``[:=]`` adjacency) — H1. The lookbehind + surrounding identifier runs admit
|
|
42
|
+
# the keyword anywhere inside an env-var-shaped name. The runs are BOUNDED
|
|
43
|
+
# ({0,64}, not *) — an unbounded run over an underscore-heavy blob backtracks
|
|
44
|
+
# catastrophically (ReDoS on the capture hot path); env-var names are short.
|
|
32
45
|
("assigned_secret", re.compile(
|
|
33
|
-
r"(?i)
|
|
34
|
-
r"
|
|
46
|
+
r"(?i)(?<![A-Za-z0-9])[A-Za-z0-9_]{0,64}"
|
|
47
|
+
r"(?:api[_-]?key|access[_-]?key|secret|password|passwd|pwd|token)"
|
|
48
|
+
r"[A-Za-z0-9_]{0,64}\s*[:=]\s*['\"]?([^\s'\"]{6,})",
|
|
35
49
|
), 1),
|
|
36
50
|
("email", re.compile(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b"), 0),
|
|
37
51
|
]
|
|
@@ -66,10 +80,23 @@ def redact_text(text: str) -> tuple[str, dict[str, int]]:
|
|
|
66
80
|
return text, counts
|
|
67
81
|
|
|
68
82
|
|
|
69
|
-
|
|
83
|
+
# Recursion cap: a pathologically nested payload must not RecursionError (which
|
|
84
|
+
# would blow past capture's guard). Well under CPython's ~1000-frame limit.
|
|
85
|
+
_MAX_DEPTH = 60
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def redact_payload(
|
|
89
|
+
obj: Any, _path: str = "", _depth: int = 0
|
|
90
|
+
) -> tuple[Any, list[RedactionMark]]:
|
|
70
91
|
"""Recursively redact all string leaves in a JSON-like structure, collecting
|
|
71
92
|
marks that record kind + dotted field path (never the value)."""
|
|
72
93
|
marks: list[RedactionMark] = []
|
|
94
|
+
if _depth > _MAX_DEPTH:
|
|
95
|
+
# Drop (do not stringify — str() of a deep subtree would itself recurse)
|
|
96
|
+
# the over-deep subtree; safer to lose it than to leak or crash (L18).
|
|
97
|
+
return "[REDACTED:max_depth]", [
|
|
98
|
+
RedactionMark(kind="max_depth", location=_path or "(root)", count=1)
|
|
99
|
+
]
|
|
73
100
|
if isinstance(obj, str):
|
|
74
101
|
red, counts = redact_text(obj)
|
|
75
102
|
marks.extend(
|
|
@@ -81,14 +108,14 @@ def redact_payload(obj: Any, _path: str = "") -> tuple[Any, list[RedactionMark]]
|
|
|
81
108
|
out_d: dict[str, Any] = {}
|
|
82
109
|
for k, v in obj.items():
|
|
83
110
|
child = f"{_path}.{k}" if _path else str(k)
|
|
84
|
-
out_d[k], sub = redact_payload(v, child)
|
|
111
|
+
out_d[k], sub = redact_payload(v, child, _depth + 1)
|
|
85
112
|
marks.extend(sub)
|
|
86
113
|
return out_d, marks
|
|
87
114
|
if isinstance(obj, list):
|
|
88
115
|
out_l: list[Any] = []
|
|
89
116
|
for i, v in enumerate(obj):
|
|
90
117
|
child = f"{_path}[{i}]"
|
|
91
|
-
rv, sub = redact_payload(v, child)
|
|
118
|
+
rv, sub = redact_payload(v, child, _depth + 1)
|
|
92
119
|
out_l.append(rv)
|
|
93
120
|
marks.extend(sub)
|
|
94
121
|
return out_l, marks
|
|
@@ -9,10 +9,11 @@ without changing the entity shapes (docs/03 §3 M1, docs/02 §7.7).
|
|
|
9
9
|
|
|
10
10
|
from __future__ import annotations
|
|
11
11
|
|
|
12
|
+
import os
|
|
12
13
|
import subprocess
|
|
13
14
|
from pathlib import Path
|
|
14
15
|
|
|
15
|
-
from pydantic import BaseModel, ConfigDict, Field
|
|
16
|
+
from pydantic import BaseModel, ConfigDict, Field, ValidationError
|
|
16
17
|
|
|
17
18
|
from . import config
|
|
18
19
|
from .schemas import (
|
|
@@ -29,6 +30,11 @@ from .skillmd import content_hash, parse
|
|
|
29
30
|
|
|
30
31
|
_GIT_ID = ["-c", "user.name=super-skill", "-c", "user.email=super-skill@localhost"]
|
|
31
32
|
|
|
33
|
+
# Untracked by the registry git backend: pre-promotion scratch (candidates/,
|
|
34
|
+
# locks/), the capture WAL + mine watermark (events/, mine_state.json — private
|
|
35
|
+
# session content that must not enter audit history, M9), and atomic-write temps.
|
|
36
|
+
_GITIGNORE = "locks/\ncandidates/\nevents/\nmine_state.json\n*.tmp\n"
|
|
37
|
+
|
|
32
38
|
|
|
33
39
|
class SkillRecord(BaseModel):
|
|
34
40
|
"""Per-skill aggregate persisted as meta.json."""
|
|
@@ -63,17 +69,16 @@ class Registry:
|
|
|
63
69
|
# ---- lifecycle ---------------------------------------------------------
|
|
64
70
|
def init(self) -> None:
|
|
65
71
|
self.skills_root.mkdir(parents=True, exist_ok=True)
|
|
66
|
-
# candidates/ holds pre-promotion draft scratch — kept out of tracked
|
|
67
|
-
# history so only approve (the Publisher path) writes registry state.
|
|
68
|
-
gitignore = "locks/\ncandidates/\n"
|
|
69
|
-
gi = self.root / ".gitignore"
|
|
70
72
|
if not (self.root / ".git").exists():
|
|
71
73
|
self._git("init", "-q")
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
74
|
+
gi = self.root / ".gitignore"
|
|
75
|
+
# Rewrite whenever missing or stale — unconditionally, not gated on ``.git``
|
|
76
|
+
# existing (a crash between ``git init`` and the first write, or a manual
|
|
77
|
+
# ``git init``, used to leave it un-created and candidates/ tracked, M10).
|
|
78
|
+
if not gi.exists() or gi.read_text(encoding="utf-8") != _GITIGNORE:
|
|
79
|
+
gi.write_text(_GITIGNORE, encoding="utf-8")
|
|
80
|
+
# Idempotent: no-op when nothing is staged (already-initialized re-run).
|
|
81
|
+
self._commit("chore: initialize super-skill registry (.gitignore)")
|
|
77
82
|
|
|
78
83
|
def _git(self, *args: str) -> str:
|
|
79
84
|
proc = subprocess.run(
|
|
@@ -113,7 +118,13 @@ class Registry:
|
|
|
113
118
|
p = self._meta_path(skill_id)
|
|
114
119
|
if not p.exists():
|
|
115
120
|
return None
|
|
116
|
-
|
|
121
|
+
try:
|
|
122
|
+
return SkillRecord.model_validate_json(p.read_text(encoding="utf-8"))
|
|
123
|
+
except ValidationError as e:
|
|
124
|
+
# A truncated/corrupt meta.json (crash mid-write, hand-edit) must
|
|
125
|
+
# surface as RegistryError so status/list/doctor report it cleanly
|
|
126
|
+
# rather than dying on a raw pydantic traceback (M12).
|
|
127
|
+
raise RegistryError(f"{skill_id}: corrupt meta.json ({e})") from e
|
|
117
128
|
|
|
118
129
|
def list_skills(self) -> list[SkillRecord]:
|
|
119
130
|
if not self.skills_root.exists():
|
|
@@ -145,6 +156,14 @@ class Registry:
|
|
|
145
156
|
"""Register a new immutable version; optionally make it the active pointer."""
|
|
146
157
|
parsed = parse(raw)
|
|
147
158
|
rec = self.get(skill_id) or SkillRecord(skill=Skill(skill_id=skill_id, scope=scope))
|
|
159
|
+
# Idempotent promotion (M8): re-adding content identical to the current
|
|
160
|
+
# active version is a no-op. Guards the approve crash window (candidate
|
|
161
|
+
# left 'pending' after commit) from double-promoting on re-run.
|
|
162
|
+
new_hash = content_hash(raw)
|
|
163
|
+
if make_active and rec.skill.active_version:
|
|
164
|
+
cur = rec.versions.get(rec.skill.active_version)
|
|
165
|
+
if cur is not None and cur.artifact_hash == new_hash:
|
|
166
|
+
return cur
|
|
148
167
|
version = rec.next_version()
|
|
149
168
|
parents = [rec.skill.active_version] if rec.skill.active_version else []
|
|
150
169
|
sv = SkillVersion(
|
|
@@ -205,7 +224,11 @@ class Registry:
|
|
|
205
224
|
def _write(self, rec: SkillRecord) -> None:
|
|
206
225
|
p = self._meta_path(rec.skill.skill_id)
|
|
207
226
|
p.parent.mkdir(parents=True, exist_ok=True)
|
|
208
|
-
|
|
227
|
+
# Atomic write: a crash mid-write must never leave a truncated meta.json
|
|
228
|
+
# (M12). Write to a sibling temp file, then os.replace (atomic rename).
|
|
229
|
+
tmp = p.with_suffix(".json.tmp")
|
|
230
|
+
tmp.write_text(rec.model_dump_json(indent=2), encoding="utf-8")
|
|
231
|
+
os.replace(tmp, p)
|
|
209
232
|
|
|
210
233
|
# ---- distribution ------------------------------------------------------
|
|
211
234
|
def materialize(self, skill_id: str, host_dir: Path) -> Path:
|