super-skill-cli 0.9.2__py3-none-any.whl
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/__init__.py +9 -0
- super_skill/candidate.py +213 -0
- super_skill/capture.py +76 -0
- super_skill/cli.py +381 -0
- super_skill/config.py +25 -0
- super_skill/doctor.py +119 -0
- super_skill/evallite.py +93 -0
- super_skill/gate.py +102 -0
- super_skill/hooks.py +33 -0
- super_skill/mine.py +101 -0
- super_skill/minestate.py +57 -0
- super_skill/redact.py +95 -0
- super_skill/registry.py +221 -0
- super_skill/schemas.py +207 -0
- super_skill/seed.py +75 -0
- super_skill/skillmd.py +61 -0
- super_skill_cli-0.9.2.dist-info/METADATA +213 -0
- super_skill_cli-0.9.2.dist-info/RECORD +21 -0
- super_skill_cli-0.9.2.dist-info/WHEEL +4 -0
- super_skill_cli-0.9.2.dist-info/entry_points.txt +2 -0
- super_skill_cli-0.9.2.dist-info/licenses/LICENSE +21 -0
super_skill/config.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Path resolution. All roots are env-overridable so tests never touch the real
|
|
2
|
+
~/.super-skill state or the host's ~/.claude/skills directory."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def state_root() -> Path:
|
|
11
|
+
"""Registry + control state (git-backed in WS). docs/02 §4.1."""
|
|
12
|
+
return Path(os.environ.get("SUPER_SKILL_HOME", "~/.super-skill")).expanduser()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def host_skills_dir() -> Path:
|
|
16
|
+
"""Claude Code personal skills dir — v1 direct distribution target (R-SCOPE-3)."""
|
|
17
|
+
return Path(os.environ.get("SUPER_SKILL_HOST_SKILLS", "~/.claude/skills")).expanduser()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def registry_dir() -> Path:
|
|
21
|
+
return state_root() / "registry"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def skills_dir() -> Path:
|
|
25
|
+
return registry_dir() / "skills"
|
super_skill/doctor.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Registry integrity self-check and repair (`super-skill doctor [--fix]`).
|
|
2
|
+
|
|
3
|
+
`check_registry` is read-only: it verifies the WS registry is internally
|
|
4
|
+
consistent and, when a host dir is given, in sync with it. The highest-value
|
|
5
|
+
check is content-hash integrity — a version's on-disk SKILL.md must still hash to
|
|
6
|
+
the `artifact_hash` recorded when it was promoted, catching tampering,
|
|
7
|
+
corruption, or a hand-edit that bypassed the registry.
|
|
8
|
+
|
|
9
|
+
`repair` fixes the mechanically-fixable issues: git is the WS backend, so a
|
|
10
|
+
tampered/missing version file is restored from HEAD (which holds the committed,
|
|
11
|
+
correct content), and host drift is re-materialized from the active version.
|
|
12
|
+
Issues that need judgment (a dangling active pointer, a name mismatch) are left
|
|
13
|
+
for the user. Repair always re-verifies afterwards and reports what actually
|
|
14
|
+
remains — an attempted fix is not a fix (see the doctor exit-code lesson).
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from .registry import Registry, RegistryError
|
|
23
|
+
from .skillmd import content_hash
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class DoctorIssue:
|
|
28
|
+
skill_id: str
|
|
29
|
+
severity: str # "error" (integrity broken) | "warn" (drift / cosmetic)
|
|
30
|
+
message: str
|
|
31
|
+
kind: str = "" # machine-dispatchable: see check_registry
|
|
32
|
+
version: str | None = None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def check_registry(reg: Registry, host_dir: Path | None = None) -> list[DoctorIssue]:
|
|
36
|
+
issues: list[DoctorIssue] = []
|
|
37
|
+
for rec in reg.list_skills():
|
|
38
|
+
sid = rec.skill.skill_id
|
|
39
|
+
active = rec.skill.active_version
|
|
40
|
+
|
|
41
|
+
if active is not None and active not in rec.versions:
|
|
42
|
+
issues.append(
|
|
43
|
+
DoctorIssue(sid, "error", f"active pointer {active!r} not in versions",
|
|
44
|
+
kind="dangling_active")
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
for ver, sv in rec.versions.items():
|
|
48
|
+
try:
|
|
49
|
+
raw = reg.version_text(sid, ver)
|
|
50
|
+
except RegistryError:
|
|
51
|
+
issues.append(
|
|
52
|
+
DoctorIssue(sid, "error", f"{ver}: SKILL.md file missing",
|
|
53
|
+
kind="file_missing", version=ver)
|
|
54
|
+
)
|
|
55
|
+
continue
|
|
56
|
+
if content_hash(raw) != sv.artifact_hash:
|
|
57
|
+
issues.append(
|
|
58
|
+
DoctorIssue(sid, "error", f"{ver}: content hash mismatch (tampered/corrupt)",
|
|
59
|
+
kind="hash_mismatch", version=ver)
|
|
60
|
+
)
|
|
61
|
+
if sv.frontmatter.name != sid:
|
|
62
|
+
issues.append(
|
|
63
|
+
DoctorIssue(sid, "warn",
|
|
64
|
+
f"{ver}: frontmatter name {sv.frontmatter.name!r} != skill_id",
|
|
65
|
+
kind="name_mismatch", version=ver)
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
if host_dir is not None and active is not None and active in rec.versions:
|
|
69
|
+
host_md = host_dir / sid / "SKILL.md"
|
|
70
|
+
if not host_md.exists():
|
|
71
|
+
issues.append(
|
|
72
|
+
DoctorIssue(sid, "warn", "active version not materialized to host",
|
|
73
|
+
kind="host_missing")
|
|
74
|
+
)
|
|
75
|
+
elif content_hash(host_md.read_text(encoding="utf-8")) != content_hash(
|
|
76
|
+
reg.version_text(sid, active)
|
|
77
|
+
):
|
|
78
|
+
issues.append(
|
|
79
|
+
DoctorIssue(sid, "warn",
|
|
80
|
+
"host SKILL.md differs from active version (edited/stale)",
|
|
81
|
+
kind="host_drift")
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
return issues
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass(frozen=True)
|
|
88
|
+
class RepairAction:
|
|
89
|
+
issue: DoctorIssue
|
|
90
|
+
action: str # what was attempted
|
|
91
|
+
ok: bool
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def repair(
|
|
95
|
+
reg: Registry, host_dir: Path | None = None
|
|
96
|
+
) -> tuple[list[RepairAction], list[DoctorIssue]]:
|
|
97
|
+
"""Fix the mechanically-fixable issues, then RE-VERIFY.
|
|
98
|
+
|
|
99
|
+
Returns (actions attempted, issues still present after re-check). The caller
|
|
100
|
+
decides exit status from the *remaining* issues, never from attempts."""
|
|
101
|
+
actions: list[RepairAction] = []
|
|
102
|
+
for issue in check_registry(reg, host_dir):
|
|
103
|
+
if issue.kind in ("hash_mismatch", "file_missing") and issue.version is not None:
|
|
104
|
+
rel = f"registry/skills/{issue.skill_id}/versions/{issue.version}/SKILL.md"
|
|
105
|
+
try:
|
|
106
|
+
reg._git("checkout", "HEAD", "--", rel)
|
|
107
|
+
actions.append(RepairAction(issue, f"restored {rel} from git HEAD", True))
|
|
108
|
+
except RegistryError as e:
|
|
109
|
+
actions.append(RepairAction(issue, f"git restore failed: {e}", False))
|
|
110
|
+
elif issue.kind in ("host_missing", "host_drift") and host_dir is not None:
|
|
111
|
+
try:
|
|
112
|
+
reg.materialize(issue.skill_id, host_dir)
|
|
113
|
+
actions.append(RepairAction(issue, "re-materialized active version to host", True))
|
|
114
|
+
except RegistryError as e:
|
|
115
|
+
actions.append(RepairAction(issue, f"materialize failed: {e}", False))
|
|
116
|
+
# dangling_active / name_mismatch need judgment — left for the user.
|
|
117
|
+
|
|
118
|
+
remaining = check_registry(reg, host_dir)
|
|
119
|
+
return actions, remaining
|
super_skill/evallite.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""eval-lite: the deterministic hard-gate layer (docs/04 §1.6, FR-EVAL-2).
|
|
2
|
+
|
|
3
|
+
At personal scale (n=1–3, corpus <~150 cases) the deterministic layer is THE
|
|
4
|
+
hard gate; the statistical layer (precision/recall, +5pp, bootstrap CI) is
|
|
5
|
+
diagnostic only and needs a real corpus, so it is not run here. The four-arm
|
|
6
|
+
protocol degrades to a No Skill / Skill two-arm — and at WS there is neither a
|
|
7
|
+
golden corpus nor an agent harness to run it, so that arm is honestly labelled
|
|
8
|
+
``Insufficient Evidence`` rather than skipped or faked (docs/04 §1.6 统计诚实).
|
|
9
|
+
|
|
10
|
+
What is checked here, without an LLM: structure/schema, zero credential/PII leak
|
|
11
|
+
in the SKILL.md text, and the agentskills body token budget. The instruction
|
|
12
|
+
gate (§2.4bis) is a separate hard gate run first in ``candidate.approve``.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import re
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
|
|
20
|
+
from .redact import redact_text
|
|
21
|
+
from .skillmd import SkillMdError, parse
|
|
22
|
+
|
|
23
|
+
# agentskills.io: SKILL.md body should stay under ~5000 tokens. We have no
|
|
24
|
+
# tokenizer at WS, so approximate (~0.75 words/token) — deliberately conservative.
|
|
25
|
+
TOKEN_BUDGET = 5000
|
|
26
|
+
_WORDS_PER_TOKEN = 0.75
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class EvalCheck:
|
|
31
|
+
name: str
|
|
32
|
+
passed: bool
|
|
33
|
+
detail: str = ""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class EvalReport:
|
|
38
|
+
checks: list[EvalCheck] = field(default_factory=list)
|
|
39
|
+
# The No Skill/Skill two-arm needs a corpus + harness we do not have at WS.
|
|
40
|
+
insufficient_evidence: bool = True
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def passed(self) -> bool:
|
|
44
|
+
"""Deterministic hard gate: every check must pass (一票否决)."""
|
|
45
|
+
return all(c.passed for c in self.checks)
|
|
46
|
+
|
|
47
|
+
def failures(self) -> list[EvalCheck]:
|
|
48
|
+
return [c for c in self.checks if not c.passed]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class EvalError(RuntimeError):
|
|
52
|
+
"""Raised when the deterministic gate fails. Carries the report."""
|
|
53
|
+
|
|
54
|
+
def __init__(self, report: EvalReport) -> None:
|
|
55
|
+
self.report = report
|
|
56
|
+
names = ", ".join(c.name for c in report.failures())
|
|
57
|
+
super().__init__(f"eval-lite deterministic gate failed: {names}")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _approx_tokens(text: str) -> int:
|
|
61
|
+
return int(len(re.findall(r"\S+", text)) / _WORDS_PER_TOKEN)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def eval_lite(raw: str) -> EvalReport:
|
|
65
|
+
"""Run the deterministic hard-gate checks on a candidate SKILL.md."""
|
|
66
|
+
checks: list[EvalCheck] = []
|
|
67
|
+
|
|
68
|
+
# 1. structure / schema
|
|
69
|
+
try:
|
|
70
|
+
parsed = parse(raw)
|
|
71
|
+
checks.append(EvalCheck("schema", True, "frontmatter valid"))
|
|
72
|
+
except SkillMdError as e:
|
|
73
|
+
return EvalReport(checks=[EvalCheck("schema", False, str(e))])
|
|
74
|
+
|
|
75
|
+
# 2. zero credential / PII leak in the instruction text (凭据与 PII 泄漏 = 0)
|
|
76
|
+
text = f"{parsed.frontmatter.description}\n{parsed.body}"
|
|
77
|
+
_, counts = redact_text(text)
|
|
78
|
+
leaks = {k: c for k, c in counts.items() if k != "home_path"}
|
|
79
|
+
checks.append(
|
|
80
|
+
EvalCheck(
|
|
81
|
+
"no_secret_leak",
|
|
82
|
+
not leaks,
|
|
83
|
+
"clean" if not leaks else f"found {leaks}",
|
|
84
|
+
)
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
# 3. agentskills body token budget
|
|
88
|
+
tok = _approx_tokens(parsed.body)
|
|
89
|
+
checks.append(
|
|
90
|
+
EvalCheck("token_budget", tok < TOKEN_BUDGET, f"~{tok} tokens (limit {TOKEN_BUDGET})")
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
return EvalReport(checks=checks, insufficient_evidence=True)
|
super_skill/gate.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Instruction-layer adversarial gate (docs/04 §2.4bis) — WS rule-based form.
|
|
2
|
+
|
|
3
|
+
v1's ONLY mandatory security gate. v1 candidates are pure text with no scripts,
|
|
4
|
+
so the host Agent treats the SKILL.md body + description as TRUSTED instructions
|
|
5
|
+
and runs them in the user's real session — outside any sandbox, with full
|
|
6
|
+
credentials. A poisoned imperative distilled from captured content (dependency
|
|
7
|
+
errors, READMEs, issues, web pages) is exactly the code×instruction seam that
|
|
8
|
+
MalSkillBench shows single-tool scanners miss.
|
|
9
|
+
|
|
10
|
+
This pass scans body + description for external-action imperatives. Per §2.4bis a
|
|
11
|
+
T1 pure-text skill needs *higher*, not lower, injection scrutiny, so it errs
|
|
12
|
+
toward flagging: a false positive costs one human edit; a miss ships a
|
|
13
|
+
`curl | bash` to the host. The cross-modal (script↔manifest) and LLM-judge layers
|
|
14
|
+
land at M1 — v1 candidates carry no scripts, so there is nothing to cross-check.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import re
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
|
|
22
|
+
from .skillmd import parse
|
|
23
|
+
|
|
24
|
+
# (category, pattern). Case-insensitive. Ordered most-severe first.
|
|
25
|
+
_RULES: list[tuple[str, re.Pattern[str]]] = [
|
|
26
|
+
(
|
|
27
|
+
"pipe_to_shell",
|
|
28
|
+
re.compile(
|
|
29
|
+
r"(?i)\b(?:curl|wget|fetch)\b[^\n|]{0,200}\|\s*"
|
|
30
|
+
r"(?:bash|sh|zsh|python3?|node|ruby|perl|eval)\b"
|
|
31
|
+
),
|
|
32
|
+
),
|
|
33
|
+
(
|
|
34
|
+
"run_undeclared_script",
|
|
35
|
+
re.compile(
|
|
36
|
+
r"(?i)\beval\s*\(|\bexec\s*\(|\bos\.system\b|\bsubprocess\.\w+"
|
|
37
|
+
r"|\bchmod\s+\+x\b|\bsudo\b|\bnpx\s+\S|"
|
|
38
|
+
r"(?:run|execute)\s+(?:this|the\s+following|the\s+attached)\s+\w*\s*script"
|
|
39
|
+
),
|
|
40
|
+
),
|
|
41
|
+
(
|
|
42
|
+
"credential_access",
|
|
43
|
+
re.compile(
|
|
44
|
+
r"(?i)~/\.ssh|~/\.aws|\bid_rsa\b|\.env\b|\bprivate[_-]?key\b"
|
|
45
|
+
r"|\b(?:API[_-]?KEY|SECRET[_-]?KEY|ACCESS[_-]?KEY|AWS_SECRET_ACCESS_KEY)\b"
|
|
46
|
+
r"|\b(?:password|passwd)\b"
|
|
47
|
+
),
|
|
48
|
+
),
|
|
49
|
+
(
|
|
50
|
+
"network_fetch",
|
|
51
|
+
re.compile(
|
|
52
|
+
r"(?i)\b(?:curl|wget)\b"
|
|
53
|
+
r"|\b(?:download|fetch|retrieve)\b[^\n]{0,60}https?://"
|
|
54
|
+
),
|
|
55
|
+
),
|
|
56
|
+
(
|
|
57
|
+
"prompt_injection_override",
|
|
58
|
+
re.compile(
|
|
59
|
+
r"(?i)ignore\s+(?:all\s+)?(?:previous|prior|above)"
|
|
60
|
+
r"|disregard\s+(?:the\s+)?(?:above|previous|prior)"
|
|
61
|
+
r"|forget\s+(?:all\s+)?(?:previous|prior)"
|
|
62
|
+
r"|you\s+are\s+now\b|override\s+[^\n]{0,40}instructions"
|
|
63
|
+
),
|
|
64
|
+
),
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(frozen=True)
|
|
69
|
+
class Finding:
|
|
70
|
+
category: str
|
|
71
|
+
location: str # "description" | "body"
|
|
72
|
+
snippet: str
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class InstructionGateError(RuntimeError):
|
|
76
|
+
"""Raised when the gate blocks a promotion. Carries the findings so callers
|
|
77
|
+
can report exactly what tripped it."""
|
|
78
|
+
|
|
79
|
+
def __init__(self, findings: list[Finding]) -> None:
|
|
80
|
+
self.findings = findings
|
|
81
|
+
cats = ", ".join(sorted({f.category for f in findings}))
|
|
82
|
+
super().__init__(f"instruction-layer gate blocked: {cats}")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def scan_text(text: str, location: str) -> list[Finding]:
|
|
86
|
+
findings: list[Finding] = []
|
|
87
|
+
for category, pat in _RULES:
|
|
88
|
+
for m in pat.finditer(text):
|
|
89
|
+
snippet = " ".join(m.group(0).split())[:80]
|
|
90
|
+
findings.append(Finding(category=category, location=location, snippet=snippet))
|
|
91
|
+
return findings
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def scan_skill_md(raw: str) -> list[Finding]:
|
|
95
|
+
"""Scan a SKILL.md's description + body for external-action imperatives.
|
|
96
|
+
|
|
97
|
+
description is scanned separately: §2.4bis forbids imperative / second-person
|
|
98
|
+
external-action language there (it would both win routing and inject)."""
|
|
99
|
+
parsed = parse(raw)
|
|
100
|
+
return scan_text(parsed.frontmatter.description, "description") + scan_text(
|
|
101
|
+
parsed.body, "body"
|
|
102
|
+
)
|
super_skill/hooks.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Claude Code hooks config generation (docs/03 WS: capture from day 1).
|
|
2
|
+
|
|
3
|
+
Produces the ``hooks`` block that wires the six host events to
|
|
4
|
+
``super-skill capture`` so real sessions accumulate into the WAL. We only
|
|
5
|
+
*generate* the config — writing it into ``~/.claude/settings.json`` is the
|
|
6
|
+
user's call (user-global config is §5 hard-AUTH), so ``super-skill hooks-config``
|
|
7
|
+
prints it for the user to merge, rather than editing settings behind their back.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
# Events without a per-tool matcher fire once; PreToolUse/PostToolUse fire
|
|
15
|
+
# per tool call and take a matcher.
|
|
16
|
+
_EVENTS_NO_MATCHER = ("SessionStart", "UserPromptSubmit", "Stop", "SessionEnd")
|
|
17
|
+
_EVENTS_WITH_MATCHER = ("PreToolUse", "PostToolUse")
|
|
18
|
+
ALL_EVENTS = _EVENTS_NO_MATCHER + _EVENTS_WITH_MATCHER
|
|
19
|
+
|
|
20
|
+
DEFAULT_COMMAND = "super-skill capture"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def hooks_settings(command: str = DEFAULT_COMMAND) -> dict[str, Any]:
|
|
24
|
+
"""Return a settings.json fragment: every host event piped to ``command``.
|
|
25
|
+
|
|
26
|
+
capture reads the hook JSON on stdin and never fails the session (NFR-3)."""
|
|
27
|
+
run = {"hooks": [{"type": "command", "command": command}]}
|
|
28
|
+
hooks: dict[str, list[dict[str, Any]]] = {}
|
|
29
|
+
for ev in _EVENTS_NO_MATCHER:
|
|
30
|
+
hooks[ev] = [dict(run)]
|
|
31
|
+
for ev in _EVENTS_WITH_MATCHER:
|
|
32
|
+
hooks[ev] = [{"matcher": "*", **run}]
|
|
33
|
+
return {"hooks": hooks}
|
super_skill/mine.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Coarse opportunity mining (docs/03 WS: "surface you solved X 3 times").
|
|
2
|
+
|
|
3
|
+
Clusters captured events into task families by keyword bigrams shared across
|
|
4
|
+
DISTINCT sessions, surfacing families that recur >=3 sessions — the FR-GEN-1
|
|
5
|
+
primary signal. This is the WS heuristic; the M2 Opportunity Miner with weighted
|
|
6
|
+
scoring (frequency/failure/verifier availability/...) is later.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
from collections import defaultdict
|
|
13
|
+
from collections.abc import Iterable
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
|
|
16
|
+
from .schemas import CaptureEvent
|
|
17
|
+
|
|
18
|
+
_STOP = frozenset(
|
|
19
|
+
"the a an and or of to in on for with without into from at by is are was were be this"
|
|
20
|
+
" that these those it its as not no yes do did use used run running file files code"
|
|
21
|
+
" error add change fix fixed test tests value values case cases new get set".split()
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
# Hook-envelope keys the CLI dumps wholesale into payload. They are metadata, not
|
|
25
|
+
# task content, and dominate families ("userpromptsubmit x", cwd-derived slugs) if
|
|
26
|
+
# mined — coarse mining should read content, not the envelope.
|
|
27
|
+
_ENVELOPE_KEYS = frozenset(
|
|
28
|
+
"hook_event_name session_id event_id transcript_path cwd permission_mode"
|
|
29
|
+
" timestamp consent_scope tool_name".split()
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
# Redaction leaves ``[REDACTED:kind]`` / ``~`` placeholders; drop the whole
|
|
33
|
+
# placeholder so "redacted" and the kind name don't become mined tokens.
|
|
34
|
+
_REDACTION_RE = re.compile(r"\[REDACTED:[^\]]*\]")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _tokens(text: str) -> list[str]:
|
|
38
|
+
text = _REDACTION_RE.sub(" ", text)
|
|
39
|
+
out = []
|
|
40
|
+
for t in re.sub(r"[^a-z0-9/_.\- ]", " ", text.lower()).split():
|
|
41
|
+
t = t.strip("-./_")
|
|
42
|
+
if len(t) >= 3 and not t.isdigit() and t not in _STOP:
|
|
43
|
+
out.append(t)
|
|
44
|
+
return out
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _event_text(ev: CaptureEvent) -> str:
|
|
48
|
+
parts: list[str] = []
|
|
49
|
+
|
|
50
|
+
def walk(o: object) -> None:
|
|
51
|
+
if isinstance(o, str):
|
|
52
|
+
parts.append(o)
|
|
53
|
+
elif isinstance(o, dict):
|
|
54
|
+
for k, v in o.items():
|
|
55
|
+
if k not in _ENVELOPE_KEYS: # skip envelope metadata, mine content
|
|
56
|
+
walk(v)
|
|
57
|
+
elif isinstance(o, list):
|
|
58
|
+
for v in o:
|
|
59
|
+
walk(v)
|
|
60
|
+
|
|
61
|
+
walk(ev.payload)
|
|
62
|
+
return " ".join(parts)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass
|
|
66
|
+
class OpportunityFamily:
|
|
67
|
+
label: str
|
|
68
|
+
session_count: int
|
|
69
|
+
event_count: int
|
|
70
|
+
projects: set[str] = field(default_factory=set)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def mine_families(
|
|
74
|
+
events: Iterable[CaptureEvent], *, min_sessions: int = 3
|
|
75
|
+
) -> list[OpportunityFamily]:
|
|
76
|
+
"""Return recurring keyword-bigram families sorted by session recurrence."""
|
|
77
|
+
bg_sessions: dict[str, set[str]] = defaultdict(set)
|
|
78
|
+
bg_events: dict[str, int] = defaultdict(int)
|
|
79
|
+
bg_projects: dict[str, set[str]] = defaultdict(set)
|
|
80
|
+
|
|
81
|
+
for ev in events:
|
|
82
|
+
toks = _tokens(_event_text(ev))
|
|
83
|
+
grams = {f"{toks[i]} {toks[i + 1]}" for i in range(len(toks) - 1)}
|
|
84
|
+
for g in grams:
|
|
85
|
+
bg_sessions[g].add(ev.session_id)
|
|
86
|
+
bg_events[g] += 1
|
|
87
|
+
if ev.project_id:
|
|
88
|
+
bg_projects[g].add(ev.project_id)
|
|
89
|
+
|
|
90
|
+
families = [
|
|
91
|
+
OpportunityFamily(
|
|
92
|
+
label=g,
|
|
93
|
+
session_count=len(s),
|
|
94
|
+
event_count=bg_events[g],
|
|
95
|
+
projects=bg_projects[g],
|
|
96
|
+
)
|
|
97
|
+
for g, s in bg_sessions.items()
|
|
98
|
+
if len(s) >= min_sessions
|
|
99
|
+
]
|
|
100
|
+
families.sort(key=lambda f: (-f.session_count, -f.event_count))
|
|
101
|
+
return families
|
super_skill/minestate.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Mine watermark (D#67): remember how many distinct sessions had been captured
|
|
2
|
+
the last time the user mined, so ``status``/``mine`` can nudge once enough new
|
|
3
|
+
sessions have accumulated — "you solved X across N unmined sessions, run mine".
|
|
4
|
+
|
|
5
|
+
Deliberately tiny: one JSON file in the state root, overwritten (never appended)
|
|
6
|
+
each mine. Missing/corrupt reads as 0 so a fresh or hand-broken state degrades to
|
|
7
|
+
"everything is unmined" rather than crashing a read-only status.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
_FILE = "mine_state.json"
|
|
17
|
+
_DEFAULT_THRESHOLD = 3
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def reminder_threshold() -> int:
|
|
21
|
+
"""Distinct unmined sessions before status nudges (env-overridable)."""
|
|
22
|
+
raw = os.environ.get("SUPER_SKILL_MINE_REMINDER")
|
|
23
|
+
if raw is None:
|
|
24
|
+
return _DEFAULT_THRESHOLD
|
|
25
|
+
try:
|
|
26
|
+
return int(raw)
|
|
27
|
+
except ValueError:
|
|
28
|
+
return _DEFAULT_THRESHOLD
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _path(root: Path) -> Path:
|
|
32
|
+
return root / _FILE
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def mined_sessions(root: Path) -> int:
|
|
36
|
+
"""Distinct-session count recorded at the last mine (0 if absent/corrupt)."""
|
|
37
|
+
p = _path(root)
|
|
38
|
+
if not p.exists():
|
|
39
|
+
return 0
|
|
40
|
+
try:
|
|
41
|
+
data = json.loads(p.read_text(encoding="utf-8"))
|
|
42
|
+
return int(data["mined_sessions"])
|
|
43
|
+
except (ValueError, KeyError, TypeError):
|
|
44
|
+
return 0
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def record_mined(root: Path, session_count: int) -> None:
|
|
48
|
+
"""Persist the current distinct-session count as the new watermark."""
|
|
49
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
50
|
+
_path(root).write_text(
|
|
51
|
+
json.dumps({"mined_sessions": session_count}), encoding="utf-8"
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def unmined(root: Path, current_sessions: int) -> int:
|
|
56
|
+
"""Distinct sessions captured since the last mine (clamped at 0)."""
|
|
57
|
+
return max(0, current_sessions - mined_sessions(root))
|
super_skill/redact.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Regex redaction (WS degraded form of FR-CAP-2).
|
|
2
|
+
|
|
3
|
+
Runs BEFORE anything is written to the WAL: secret VALUES never reach disk — only
|
|
4
|
+
the kind and the field where one was found are recorded (FR-CAP-2, §8 SAFETY).
|
|
5
|
+
This is the WS regex pass; the full M2 double-redaction + allowlist is later.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
from collections.abc import Callable
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from .schemas import RedactionMark
|
|
16
|
+
|
|
17
|
+
_HOME = os.path.expanduser("~")
|
|
18
|
+
|
|
19
|
+
# (kind, pattern, group). Ordered specific -> generic; earlier wins on overlap.
|
|
20
|
+
# group 0 = redact whole match; group N = redact only that capture, keep context.
|
|
21
|
+
_PATTERNS: list[tuple[str, re.Pattern[str], int]] = [
|
|
22
|
+
("private_key", re.compile(
|
|
23
|
+
r"-----BEGIN [A-Z ]*PRIVATE KEY-----.*?-----END [A-Z ]*PRIVATE KEY-----",
|
|
24
|
+
re.DOTALL,
|
|
25
|
+
), 0),
|
|
26
|
+
("anthropic_key", re.compile(r"sk-ant-[A-Za-z0-9_-]{16,}"), 0),
|
|
27
|
+
("openai_key", re.compile(r"sk-[A-Za-z0-9]{20,}"), 0),
|
|
28
|
+
("github_token", re.compile(r"gh[pousr]_[A-Za-z0-9]{20,}"), 0),
|
|
29
|
+
("aws_key", re.compile(r"AKIA[0-9A-Z]{16}"), 0),
|
|
30
|
+
("slack_token", re.compile(r"xox[baprs]-[A-Za-z0-9-]{10,}"), 0),
|
|
31
|
+
("bearer_token", re.compile(r"(?i)\bBearer\s+[A-Za-z0-9._~+/-]{16,}=*"), 0),
|
|
32
|
+
("assigned_secret", re.compile(
|
|
33
|
+
r"(?i)\b(?:api[_-]?key|secret|token|password|passwd|pwd|access[_-]?key)\b"
|
|
34
|
+
r"\s*[:=]\s*['\"]?([^\s'\"]{6,})",
|
|
35
|
+
), 1),
|
|
36
|
+
("email", re.compile(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b"), 0),
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
# Private home path -> ~ (not a secret, but FR-CAP-2 lists 私有路径).
|
|
40
|
+
_HOME_PATTERNS: list[re.Pattern[str]] = [
|
|
41
|
+
re.compile(re.escape(_HOME)),
|
|
42
|
+
re.compile(r"/home/[^/\s'\"]+"),
|
|
43
|
+
re.compile(r"/Users/[^/\s'\"]+"),
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def redact_text(text: str) -> tuple[str, dict[str, int]]:
|
|
48
|
+
"""Return (redacted_text, {kind: count}). Secret values are replaced with a
|
|
49
|
+
``[REDACTED:kind]`` token; home paths collapse to ``~``."""
|
|
50
|
+
counts: dict[str, int] = {}
|
|
51
|
+
|
|
52
|
+
def _make_repl(kind: str, group: int, token: str) -> Callable[[re.Match[str]], str]:
|
|
53
|
+
def _r(m: re.Match[str]) -> str:
|
|
54
|
+
counts[kind] = counts.get(kind, 0) + 1
|
|
55
|
+
return token if group == 0 else m.group(0).replace(m.group(group), token)
|
|
56
|
+
|
|
57
|
+
return _r
|
|
58
|
+
|
|
59
|
+
for kind, pat, group in _PATTERNS:
|
|
60
|
+
text = pat.sub(_make_repl(kind, group, f"[REDACTED:{kind}]"), text)
|
|
61
|
+
|
|
62
|
+
# home paths collapse to ~ (private path, not a secret value)
|
|
63
|
+
for pat in _HOME_PATTERNS:
|
|
64
|
+
text = pat.sub(_make_repl("home_path", 0, "~"), text)
|
|
65
|
+
|
|
66
|
+
return text, counts
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def redact_payload(obj: Any, _path: str = "") -> tuple[Any, list[RedactionMark]]:
|
|
70
|
+
"""Recursively redact all string leaves in a JSON-like structure, collecting
|
|
71
|
+
marks that record kind + dotted field path (never the value)."""
|
|
72
|
+
marks: list[RedactionMark] = []
|
|
73
|
+
if isinstance(obj, str):
|
|
74
|
+
red, counts = redact_text(obj)
|
|
75
|
+
marks.extend(
|
|
76
|
+
RedactionMark(kind=k, location=_path or "(root)", count=c)
|
|
77
|
+
for k, c in counts.items()
|
|
78
|
+
)
|
|
79
|
+
return red, marks
|
|
80
|
+
if isinstance(obj, dict):
|
|
81
|
+
out_d: dict[str, Any] = {}
|
|
82
|
+
for k, v in obj.items():
|
|
83
|
+
child = f"{_path}.{k}" if _path else str(k)
|
|
84
|
+
out_d[k], sub = redact_payload(v, child)
|
|
85
|
+
marks.extend(sub)
|
|
86
|
+
return out_d, marks
|
|
87
|
+
if isinstance(obj, list):
|
|
88
|
+
out_l: list[Any] = []
|
|
89
|
+
for i, v in enumerate(obj):
|
|
90
|
+
child = f"{_path}[{i}]"
|
|
91
|
+
rv, sub = redact_payload(v, child)
|
|
92
|
+
out_l.append(rv)
|
|
93
|
+
marks.extend(sub)
|
|
94
|
+
return out_l, marks
|
|
95
|
+
return obj, marks
|