@event4u/agent-config 2.20.1 → 2.23.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/.agent-src/commands/agent-status.md +16 -0
- package/.agent-src/rules/caveman-speak.md +2 -0
- package/.agent-src/skills/adversarial-review/SKILL.md +2 -1
- package/.agent-src/skills/canvas-design/SKILL.md +11 -6
- package/.agent-src/skills/compress-memory/SKILL.md +119 -0
- package/.agent-src/skills/fe-design/SKILL.md +8 -0
- package/.agent-src/skills/prompt-optimizer/SKILL.md +29 -5
- package/.agent-src/skills/react-shadcn-ui/SKILL.md +9 -0
- package/.agent-src/skills/refine-prompt/SKILL.md +57 -0
- package/.agent-src/skills/tailwind-engineer/SKILL.md +14 -0
- package/.agent-src/templates/agents/agent-project-settings.example.yml +53 -1
- package/.claude-plugin/marketplace.json +2 -1
- package/CHANGELOG.md +101 -138
- package/README.md +5 -5
- package/docs/architecture.md +2 -2
- package/docs/archive/CHANGELOG-pre-2.20.0.md +159 -0
- package/docs/benchmarks.md +74 -0
- package/docs/catalog.md +5 -3
- package/docs/contracts/caveman-telemetry.md +83 -0
- package/docs/contracts/compression-default-kill-criterion.md +82 -35
- package/docs/contracts/cost-summary-schema.md +107 -0
- package/docs/contracts/file-ownership-matrix.json +48 -0
- package/docs/guidelines/prompt-templates.md +166 -0
- package/package.json +1 -1
- package/scripts/_lib/bench_caveman.py +273 -0
- package/scripts/_lib/bench_caveman_report.py +152 -0
- package/scripts/bench_compress_memory.py +168 -0
- package/scripts/bench_run.py +119 -1
- package/scripts/caveman_stats.py +119 -0
- package/scripts/check_command_count_messaging.py +2 -2
- package/scripts/compress_memory.py +172 -0
- package/scripts/cost_by_conversation.py +78 -0
- package/scripts/cost_summary.py +97 -0
- package/scripts/update_counts.py +7 -5
- package/scripts/validate_caveman_carveouts.py +129 -0
- package/scripts/validate_safe_paths.py +118 -0
- package/scripts/verify_roadmap_closure.py +327 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Mechanical carve-out validator for caveman-compressed replies.
|
|
3
|
+
|
|
4
|
+
Given a pre-compression reply and a post-compression reply, assert that
|
|
5
|
+
every carve-out region from `.agent-src.uncompressed/rules/caveman-speak.md`
|
|
6
|
+
§ Carve-outs survived byte-for-byte:
|
|
7
|
+
|
|
8
|
+
1. Triple-backtick code blocks (any language).
|
|
9
|
+
2. Numbered-option lines (`^>?\\s*\\d+\\.\\s` plus the
|
|
10
|
+
`**Recommendation:**` / `**Empfehlung:**` label).
|
|
11
|
+
3. Backtick spans (file paths, command names, identifiers).
|
|
12
|
+
4. Status / error marker lines (prefix `❌`, `⚠️`, `✅`).
|
|
13
|
+
5. Triple-backtick ALL-CAPS Iron-Law literal fences (subset of (1) —
|
|
14
|
+
reported separately for diagnostics).
|
|
15
|
+
|
|
16
|
+
Stdlib only. Exit 0 = all carve-outs preserved; exit 1 = drift detected.
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import argparse
|
|
21
|
+
import difflib
|
|
22
|
+
import re
|
|
23
|
+
import sys
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
# Triple-backtick fenced blocks (greedy across lines). Group 1 = body.
|
|
27
|
+
RE_CODE_FENCE = re.compile(r"```[^\n]*\n(.*?)\n```", re.DOTALL)
|
|
28
|
+
# Numbered-option line: optional `> ` quote prefix, digits, dot, space.
|
|
29
|
+
RE_NUMBERED = re.compile(r"^>?\s*\d+\.\s.*$", re.MULTILINE)
|
|
30
|
+
# Recommendation labels (both languages).
|
|
31
|
+
RE_RECOMMEND = re.compile(r"^\*\*(Recommendation|Empfehlung):\*\*.*$", re.MULTILINE)
|
|
32
|
+
# Backtick spans — single-tick, non-greedy, no newlines inside.
|
|
33
|
+
RE_BACKTICK_SPAN = re.compile(r"`[^`\n]+`")
|
|
34
|
+
# Status / error marker lines (full line containing the marker).
|
|
35
|
+
RE_STATUS_LINE = re.compile(r"^.*[❌⚠✅].*$", re.MULTILINE)
|
|
36
|
+
# Iron-Law ALL-CAPS fence body — letters + spaces + basic punctuation, ≥ 80 % uppercase.
|
|
37
|
+
RE_ALLCAPS_LINE = re.compile(r"^[A-Z0-9 ,\.\-—:'\"·/\(\)]+$")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _extract_code_fences(text: str) -> list[str]:
|
|
41
|
+
return [m.group(0) for m in RE_CODE_FENCE.finditer(text)]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _extract_lines(text: str, pattern: re.Pattern) -> list[str]:
|
|
45
|
+
return [m.group(0) for m in pattern.finditer(text)]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _extract_backtick_spans(text: str) -> list[str]:
|
|
49
|
+
# Excludes triple-backtick fences (handled separately).
|
|
50
|
+
stripped = RE_CODE_FENCE.sub("", text)
|
|
51
|
+
return RE_BACKTICK_SPAN.findall(stripped)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _is_allcaps_fence_body(body: str) -> bool:
|
|
55
|
+
lines = [ln.strip() for ln in body.splitlines() if ln.strip()]
|
|
56
|
+
if not lines:
|
|
57
|
+
return False
|
|
58
|
+
return all(RE_ALLCAPS_LINE.match(ln) for ln in lines)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _extract_allcaps_fences(text: str) -> list[str]:
|
|
62
|
+
out: list[str] = []
|
|
63
|
+
for m in RE_CODE_FENCE.finditer(text):
|
|
64
|
+
if _is_allcaps_fence_body(m.group(1)):
|
|
65
|
+
out.append(m.group(0))
|
|
66
|
+
return out
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
CHECKS = (
|
|
70
|
+
("code_fences", _extract_code_fences),
|
|
71
|
+
("numbered_options", lambda t: _extract_lines(t, RE_NUMBERED)),
|
|
72
|
+
("recommendation_labels", lambda t: _extract_lines(t, RE_RECOMMEND)),
|
|
73
|
+
("backtick_spans", _extract_backtick_spans),
|
|
74
|
+
("status_markers", lambda t: _extract_lines(t, RE_STATUS_LINE)),
|
|
75
|
+
("allcaps_iron_law_fences", _extract_allcaps_fences),
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def validate(pre: str, post: str) -> list[tuple[str, list[str]]]:
|
|
80
|
+
"""Return list of (carve_out_name, unified_diff_lines) per drifted category."""
|
|
81
|
+
failures: list[tuple[str, list[str]]] = []
|
|
82
|
+
for name, extractor in CHECKS:
|
|
83
|
+
pre_list = extractor(pre)
|
|
84
|
+
post_list = extractor(post)
|
|
85
|
+
if pre_list == post_list:
|
|
86
|
+
continue
|
|
87
|
+
diff = list(difflib.unified_diff(
|
|
88
|
+
[s + "\n" for s in pre_list],
|
|
89
|
+
[s + "\n" for s in post_list],
|
|
90
|
+
fromfile=f"pre/{name}",
|
|
91
|
+
tofile=f"post/{name}",
|
|
92
|
+
lineterm="",
|
|
93
|
+
))
|
|
94
|
+
failures.append((name, diff))
|
|
95
|
+
return failures
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _render(failures: list[tuple[str, list[str]]]) -> str:
|
|
99
|
+
out = ["caveman carve-out validator: DRIFT DETECTED", ""]
|
|
100
|
+
for name, diff in failures:
|
|
101
|
+
out.append(f"❌ carve-out `{name}` drifted:")
|
|
102
|
+
out.extend(diff)
|
|
103
|
+
out.append("")
|
|
104
|
+
return "\n".join(out)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def main(argv: list[str] | None = None) -> int:
|
|
108
|
+
p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
|
109
|
+
p.add_argument("pre", type=Path, help="Pre-compression reply file.")
|
|
110
|
+
p.add_argument("post", type=Path, help="Post-compression reply file.")
|
|
111
|
+
args = p.parse_args(argv)
|
|
112
|
+
if not args.pre.is_file():
|
|
113
|
+
print(f"pre file not found: {args.pre}", file=sys.stderr)
|
|
114
|
+
return 2
|
|
115
|
+
if not args.post.is_file():
|
|
116
|
+
print(f"post file not found: {args.post}", file=sys.stderr)
|
|
117
|
+
return 2
|
|
118
|
+
pre = args.pre.read_text(encoding="utf-8")
|
|
119
|
+
post = args.post.read_text(encoding="utf-8")
|
|
120
|
+
failures = validate(pre, post)
|
|
121
|
+
if failures:
|
|
122
|
+
print(_render(failures))
|
|
123
|
+
return 1
|
|
124
|
+
print("caveman carve-out validator: all carve-outs preserved ✅")
|
|
125
|
+
return 0
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
if __name__ == "__main__":
|
|
129
|
+
sys.exit(main())
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Sensitive-path denylist — refuses files that almost certainly hold secrets or PII.
|
|
3
|
+
|
|
4
|
+
Phase 0 of step-16-caveman-substance. Gates Phase 2 (`scripts/compress_memory.py`):
|
|
5
|
+
any consumer-supplied path must pass `assert_safe()` before bytes are read or
|
|
6
|
+
shipped to a third-party API.
|
|
7
|
+
|
|
8
|
+
Ported from Caveman `plugins/caveman/skills/caveman-compress/scripts/compress.py`
|
|
9
|
+
(upstream `63a91ec`). Adapted to repo conventions: explicit `SensitivePathError`,
|
|
10
|
+
CLI entry point, no `anthropic` import.
|
|
11
|
+
|
|
12
|
+
Public API:
|
|
13
|
+
is_sensitive(path: pathlib.Path) -> bool
|
|
14
|
+
assert_safe(path: pathlib.Path) -> None # raises SensitivePathError
|
|
15
|
+
|
|
16
|
+
CLI:
|
|
17
|
+
python3 scripts/validate_safe_paths.py <path> # exit 0 = safe, 2 = sensitive
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import re
|
|
22
|
+
import sys
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
__all__ = ["SensitivePathError", "is_sensitive", "assert_safe"]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class SensitivePathError(ValueError):
|
|
29
|
+
"""Raised when a path matches the sensitive-file denylist."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# Filenames that almost certainly hold secrets or PII. Matched against the
|
|
33
|
+
# basename only (case-insensitive). Compressing or shipping these to an LLM API
|
|
34
|
+
# is a third-party data boundary developers on sensitive codebases cannot cross.
|
|
35
|
+
SENSITIVE_BASENAME_REGEX = re.compile(
|
|
36
|
+
r"(?ix)^("
|
|
37
|
+
r"\.env(\..+)?"
|
|
38
|
+
r"|\.netrc"
|
|
39
|
+
r"|credentials(\..+)?"
|
|
40
|
+
r"|secrets?(\..+)?"
|
|
41
|
+
r"|passwords?(\..+)?"
|
|
42
|
+
r"|id_(rsa|dsa|ecdsa|ed25519)(\.pub)?"
|
|
43
|
+
r"|authorized_keys"
|
|
44
|
+
r"|known_hosts"
|
|
45
|
+
r"|.*\.(pem|key|p12|pfx|crt|cer|jks|keystore|asc|gpg)"
|
|
46
|
+
r")$"
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
# Path components (any segment, case-insensitive) that mark a sensitive
|
|
50
|
+
# directory. Catches `~/.ssh/known_hosts` even when the basename slips past the
|
|
51
|
+
# regex above.
|
|
52
|
+
SENSITIVE_PATH_COMPONENTS = frozenset({".ssh", ".aws", ".gnupg", ".kube", ".docker"})
|
|
53
|
+
|
|
54
|
+
# Substring tokens checked against the normalised basename (separators stripped
|
|
55
|
+
# so `api-key`, `api_key`, `apikey` all match). Catches creative renames like
|
|
56
|
+
# `prod-secret-token.txt` that bypass the explicit basename regex.
|
|
57
|
+
SENSITIVE_NAME_TOKENS = (
|
|
58
|
+
"secret",
|
|
59
|
+
"credential",
|
|
60
|
+
"password",
|
|
61
|
+
"passwd",
|
|
62
|
+
"apikey",
|
|
63
|
+
"accesskey",
|
|
64
|
+
"token",
|
|
65
|
+
"privatekey",
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
_SEP_STRIP_RE = re.compile(r"[_\-\s.]")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def is_sensitive(path: Path) -> bool:
|
|
72
|
+
"""Return True if `path` matches the sensitive-file denylist."""
|
|
73
|
+
name = path.name
|
|
74
|
+
if SENSITIVE_BASENAME_REGEX.match(name):
|
|
75
|
+
return True
|
|
76
|
+
lowered_parts = {p.lower() for p in path.parts}
|
|
77
|
+
if lowered_parts & SENSITIVE_PATH_COMPONENTS:
|
|
78
|
+
return True
|
|
79
|
+
lower = _SEP_STRIP_RE.sub("", name.lower())
|
|
80
|
+
return any(tok in lower for tok in SENSITIVE_NAME_TOKENS)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def assert_safe(path: Path) -> None:
|
|
84
|
+
"""Raise `SensitivePathError` if `path` matches the denylist.
|
|
85
|
+
|
|
86
|
+
Intended as a hard guard at the top of any function that reads bytes from
|
|
87
|
+
a consumer-supplied path and ships them to a third-party API. Override is
|
|
88
|
+
intentional: the user must rename the file if the heuristic is wrong.
|
|
89
|
+
"""
|
|
90
|
+
if is_sensitive(path):
|
|
91
|
+
raise SensitivePathError(
|
|
92
|
+
f"Refusing to operate on {path}: filename or path looks sensitive "
|
|
93
|
+
"(credentials, keys, secrets, or known private directories). "
|
|
94
|
+
"Rename the file if this is a false positive."
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _main(argv: list[str]) -> int:
|
|
99
|
+
if len(argv) != 2 or argv[1] in ("-h", "--help"):
|
|
100
|
+
print(
|
|
101
|
+
"usage: validate_safe_paths.py <path>\n"
|
|
102
|
+
" exit 0 — path is safe\n"
|
|
103
|
+
" exit 2 — path matches the sensitive-file denylist",
|
|
104
|
+
file=sys.stderr,
|
|
105
|
+
)
|
|
106
|
+
return 0 if (len(argv) == 2 and argv[1] in ("-h", "--help")) else 2
|
|
107
|
+
target = Path(argv[1])
|
|
108
|
+
try:
|
|
109
|
+
assert_safe(target)
|
|
110
|
+
except SensitivePathError as exc:
|
|
111
|
+
print(f"SensitivePathError: {exc}", file=sys.stderr)
|
|
112
|
+
return 2
|
|
113
|
+
print(f"safe: {target}")
|
|
114
|
+
return 0
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
if __name__ == "__main__":
|
|
118
|
+
sys.exit(_main(sys.argv))
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""verify_roadmap_closure — scan archived roadmaps for phantom-shipping.
|
|
3
|
+
|
|
4
|
+
For each `agents/roadmaps/archive/*.md` file:
|
|
5
|
+
|
|
6
|
+
1. Locate the closure-decision block (heuristic: `## Closure decision`,
|
|
7
|
+
`## Sunset`, `maintainer override`).
|
|
8
|
+
2. Extract file-path-shaped tokens from the block (backtick paths +
|
|
9
|
+
markdown link targets). Sibling-roadmap references are filtered out.
|
|
10
|
+
3. Verify each token: exists on disk? If not, was it ever in git history?
|
|
11
|
+
4. Classify the roadmap (verified / partial / phantom / no-claims /
|
|
12
|
+
not-closure-marked) and emit a per-roadmap + aggregate report.
|
|
13
|
+
|
|
14
|
+
Run: `python3 scripts/verify_roadmap_closure.py [--json out.json]`
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import argparse
|
|
20
|
+
import json
|
|
21
|
+
import re
|
|
22
|
+
import subprocess
|
|
23
|
+
import sys
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Iterable
|
|
27
|
+
|
|
28
|
+
REPO = Path(__file__).resolve().parent.parent
|
|
29
|
+
ARCHIVE = REPO / "agents" / "roadmaps" / "archive"
|
|
30
|
+
|
|
31
|
+
CLOSURE_HEADERS = re.compile(
|
|
32
|
+
r"^##\s+(closure decision|sunset|cancellation|maintainer override)",
|
|
33
|
+
re.IGNORECASE | re.MULTILINE,
|
|
34
|
+
)
|
|
35
|
+
NEXT_H2 = re.compile(r"^##\s+", re.MULTILINE)
|
|
36
|
+
|
|
37
|
+
BACKTICK_TOKEN = re.compile(r"`([^`\n]+?)`")
|
|
38
|
+
MD_LINK = re.compile(r"\]\(([^)\s]+?)\)")
|
|
39
|
+
TASK_TARGET = re.compile(r"^task\s+([a-z][\w:-]*)$")
|
|
40
|
+
SLASH_CMD = re.compile(r"^/([a-z][\w-]*(?::[a-z][\w-]*)?)$")
|
|
41
|
+
HEADING_PAT = re.compile(r"^##+\s+(.+)$")
|
|
42
|
+
|
|
43
|
+
PATH_HINT = re.compile(
|
|
44
|
+
r"^(scripts/|docs/|agents/|templates/|"
|
|
45
|
+
r"\.agent-src\.uncompressed/|\.agent-src/|\.augment/|\.claude/|\.cursor/|"
|
|
46
|
+
r"taskfiles/|Taskfile)"
|
|
47
|
+
)
|
|
48
|
+
PATH_SHAPED = re.compile(r"^[\w.-]+/.+|\.[a-z]{1,5}$")
|
|
49
|
+
CONCEPT_NAME = re.compile(r"^[a-z][\w-]{2,}$")
|
|
50
|
+
PUNCT_ONLY = re.compile(r"^[^A-Za-z0-9]+$")
|
|
51
|
+
SKIP_PREFIX = ("http://", "https://", "mailto:", "#")
|
|
52
|
+
SKIP_SUFFIX_FRAGMENT = re.compile(r"#.*$")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
SHIPPED_MARKERS = re.compile(
|
|
56
|
+
r"\b(shipped|landed|live|live in|delivered|completed|complete|exists?|in tree|"
|
|
57
|
+
r"in place|wired|active|adopted|published|are live|partially shipped)\b",
|
|
58
|
+
re.IGNORECASE,
|
|
59
|
+
)
|
|
60
|
+
DROPPED_MARKERS = re.compile(
|
|
61
|
+
r"\b(sunset|sunsetted|dropped|drop\b|cancell?ed|deferred|retracted|phantom|"
|
|
62
|
+
r"never materiali[sz]ed|not shipped|does not exist|doesn't exist|missing|"
|
|
63
|
+
r"out of scope|deprioriti[sz]ed|out\-of\-scope|won't ship|will not ship)\b",
|
|
64
|
+
re.IGNORECASE,
|
|
65
|
+
)
|
|
66
|
+
BULLET_SPLIT = re.compile(r"^[ \t]*[-*]\s+", re.MULTILINE)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def bullet_sentiment(bullet_text: str) -> str:
|
|
70
|
+
has_dropped = bool(DROPPED_MARKERS.search(bullet_text))
|
|
71
|
+
has_shipped = bool(SHIPPED_MARKERS.search(bullet_text))
|
|
72
|
+
if has_dropped and not has_shipped:
|
|
73
|
+
return "dropped"
|
|
74
|
+
if has_shipped and not has_dropped:
|
|
75
|
+
return "shipped"
|
|
76
|
+
if has_shipped and has_dropped:
|
|
77
|
+
return "mixed"
|
|
78
|
+
return "neutral"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass
|
|
82
|
+
class Claim:
|
|
83
|
+
token: str
|
|
84
|
+
kind: str # path | task | md-link | slash-cmd | heading | concept
|
|
85
|
+
sentiment: str = "neutral" # shipped | dropped | mixed | neutral
|
|
86
|
+
exists: bool = False
|
|
87
|
+
ever_in_git: bool = False
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass
|
|
91
|
+
class Verdict:
|
|
92
|
+
roadmap: str
|
|
93
|
+
has_closure: bool
|
|
94
|
+
block: str = ""
|
|
95
|
+
claims: list[Claim] = field(default_factory=list)
|
|
96
|
+
classification: str = "no-claims"
|
|
97
|
+
phantom_rate: float = 0.0
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def find_block(text: str) -> str:
|
|
101
|
+
m = CLOSURE_HEADERS.search(text)
|
|
102
|
+
if not m:
|
|
103
|
+
return ""
|
|
104
|
+
start = m.start()
|
|
105
|
+
end_match = NEXT_H2.search(text, m.end())
|
|
106
|
+
end = end_match.start() if end_match else len(text)
|
|
107
|
+
return text[start:end]
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def is_self_roadmap_ref(token: str, roadmap_name: str) -> bool:
|
|
111
|
+
base = token.rsplit("/", 1)[-1]
|
|
112
|
+
return base.endswith(".md") and (
|
|
113
|
+
base.startswith("step-") or base.startswith("road-to-") or base == roadmap_name
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def classify_token(tok: str) -> tuple[str, str] | None:
|
|
118
|
+
tok = SKIP_SUFFIX_FRAGMENT.sub("", tok).strip()
|
|
119
|
+
if not tok or PUNCT_ONLY.match(tok) or any(tok.startswith(p) for p in SKIP_PREFIX):
|
|
120
|
+
return None
|
|
121
|
+
m = TASK_TARGET.match(tok)
|
|
122
|
+
if m:
|
|
123
|
+
return ("task", m.group(1))
|
|
124
|
+
m = SLASH_CMD.match(tok)
|
|
125
|
+
if m:
|
|
126
|
+
return ("slash-cmd", m.group(1))
|
|
127
|
+
m = HEADING_PAT.match(tok)
|
|
128
|
+
if m:
|
|
129
|
+
return ("heading", m.group(1).strip())
|
|
130
|
+
if PATH_HINT.match(tok) or "/" in tok or tok.endswith((".md", ".py", ".sh", ".yml", ".json")):
|
|
131
|
+
return ("path", tok)
|
|
132
|
+
if CONCEPT_NAME.match(tok):
|
|
133
|
+
return ("concept", tok)
|
|
134
|
+
return None
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def split_bullets(block: str) -> list[str]:
|
|
138
|
+
parts = BULLET_SPLIT.split(block)
|
|
139
|
+
return [p.strip() for p in parts[1:] if p.strip()]
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _ingest(seen: dict, kind: str, value: str, sentiment: str) -> None:
|
|
143
|
+
key = (kind, value)
|
|
144
|
+
if key in seen:
|
|
145
|
+
existing = seen[key]
|
|
146
|
+
if existing.sentiment == "neutral" and sentiment != "neutral":
|
|
147
|
+
existing.sentiment = sentiment
|
|
148
|
+
elif existing.sentiment != sentiment and sentiment != "neutral":
|
|
149
|
+
existing.sentiment = "mixed"
|
|
150
|
+
return
|
|
151
|
+
seen[key] = Claim(value, kind, sentiment)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def extract_claims(block: str, roadmap_name: str) -> list[Claim]:
|
|
155
|
+
seen: dict[tuple[str, str], Claim] = {}
|
|
156
|
+
bullets = split_bullets(block) or [block]
|
|
157
|
+
for bullet in bullets:
|
|
158
|
+
sent = bullet_sentiment(bullet)
|
|
159
|
+
for m in BACKTICK_TOKEN.finditer(bullet):
|
|
160
|
+
cls = classify_token(m.group(1))
|
|
161
|
+
if not cls:
|
|
162
|
+
continue
|
|
163
|
+
kind, value = cls
|
|
164
|
+
if kind == "path" and is_self_roadmap_ref(value, roadmap_name):
|
|
165
|
+
continue
|
|
166
|
+
_ingest(seen, kind, value, sent)
|
|
167
|
+
for m in MD_LINK.finditer(bullet):
|
|
168
|
+
cls = classify_token(m.group(1))
|
|
169
|
+
if not cls:
|
|
170
|
+
continue
|
|
171
|
+
kind, value = cls
|
|
172
|
+
if kind == "path" and is_self_roadmap_ref(value, roadmap_name):
|
|
173
|
+
continue
|
|
174
|
+
_ingest(seen, "md-link", value, sent)
|
|
175
|
+
return list(seen.values())
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def verify_path(token: str) -> bool:
|
|
179
|
+
return (REPO / token).exists()
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def verify_task(target: str) -> bool:
|
|
183
|
+
for tf in [REPO / "Taskfile.yml", *((REPO / "taskfiles").glob("*.yml") if (REPO / "taskfiles").exists() else [])]:
|
|
184
|
+
if not tf.exists():
|
|
185
|
+
continue
|
|
186
|
+
if re.search(rf"^\s+{re.escape(target)}:\s*$", tf.read_text(), re.MULTILINE):
|
|
187
|
+
return True
|
|
188
|
+
return False
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def verify_slash_cmd(name: str) -> bool:
|
|
192
|
+
base = name.split(":")[0]
|
|
193
|
+
candidates = [
|
|
194
|
+
REPO / ".agent-src.uncompressed" / "commands" / f"{base}.md",
|
|
195
|
+
REPO / ".agent-src.uncompressed" / "commands" / base,
|
|
196
|
+
REPO / ".agent-src.uncompressed" / "skills" / base,
|
|
197
|
+
REPO / ".claude" / "skills" / base,
|
|
198
|
+
]
|
|
199
|
+
return any(c.exists() for c in candidates)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def verify_heading(heading: str) -> bool:
|
|
203
|
+
# Look for the heading text in skills/rules/contexts as evidence of pattern adoption
|
|
204
|
+
pattern = re.compile(rf"^##+\s+{re.escape(heading)}\b", re.MULTILINE)
|
|
205
|
+
for root in (REPO / ".agent-src.uncompressed", REPO / "agents", REPO / "docs"):
|
|
206
|
+
if not root.exists():
|
|
207
|
+
continue
|
|
208
|
+
for f in root.rglob("*.md"):
|
|
209
|
+
try:
|
|
210
|
+
if pattern.search(f.read_text(errors="ignore")):
|
|
211
|
+
return True
|
|
212
|
+
except Exception:
|
|
213
|
+
continue
|
|
214
|
+
return False
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def verify_concept(name: str) -> bool:
|
|
218
|
+
# grep across source-of-truth tree for any literal mention as evidence
|
|
219
|
+
try:
|
|
220
|
+
r = subprocess.run(
|
|
221
|
+
["git", "grep", "-l", "-w", name, "--",
|
|
222
|
+
".agent-src.uncompressed/", "docs/", "scripts/", "agents/contexts/"],
|
|
223
|
+
cwd=REPO, capture_output=True, text=True, timeout=15,
|
|
224
|
+
)
|
|
225
|
+
return bool(r.stdout.strip())
|
|
226
|
+
except Exception:
|
|
227
|
+
return False
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def git_history(token: str) -> bool:
|
|
231
|
+
try:
|
|
232
|
+
r = subprocess.run(
|
|
233
|
+
["git", "log", "--all", "--oneline", "-n", "1", "--", token],
|
|
234
|
+
cwd=REPO, capture_output=True, text=True, timeout=10,
|
|
235
|
+
)
|
|
236
|
+
return bool(r.stdout.strip())
|
|
237
|
+
except Exception:
|
|
238
|
+
return False
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def classify(claims: list[Claim]) -> tuple[str, float]:
|
|
242
|
+
# Phantom rate is computed only over claims the closure block *asserts as
|
|
243
|
+
# shipped*. Claims explicitly marked as dropped/sunset are excluded —
|
|
244
|
+
# missing them is consistent with the rationale, not a phantom.
|
|
245
|
+
shipped = [c for c in claims if c.sentiment in ("shipped", "mixed")]
|
|
246
|
+
if not shipped:
|
|
247
|
+
if not claims:
|
|
248
|
+
return "no-claims", 0.0
|
|
249
|
+
return "no-shipped-claims", 0.0
|
|
250
|
+
missing = [c for c in shipped if not c.exists]
|
|
251
|
+
rate = len(missing) / len(shipped)
|
|
252
|
+
if rate == 0:
|
|
253
|
+
return "verified", 0.0
|
|
254
|
+
if rate >= 0.5:
|
|
255
|
+
return "phantom", rate
|
|
256
|
+
return "partial-phantom", rate
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def audit(roadmap: Path) -> Verdict:
|
|
260
|
+
text = roadmap.read_text()
|
|
261
|
+
block = find_block(text)
|
|
262
|
+
if not block:
|
|
263
|
+
return Verdict(roadmap.name, has_closure=False)
|
|
264
|
+
claims = extract_claims(block, roadmap.name)
|
|
265
|
+
for c in claims:
|
|
266
|
+
if c.kind == "task":
|
|
267
|
+
c.exists = verify_task(c.token)
|
|
268
|
+
elif c.kind == "slash-cmd":
|
|
269
|
+
c.exists = verify_slash_cmd(c.token)
|
|
270
|
+
elif c.kind == "heading":
|
|
271
|
+
c.exists = verify_heading(c.token)
|
|
272
|
+
elif c.kind == "concept":
|
|
273
|
+
c.exists = verify_concept(c.token)
|
|
274
|
+
else:
|
|
275
|
+
c.exists = verify_path(c.token)
|
|
276
|
+
if not c.exists and c.kind in ("path", "md-link"):
|
|
277
|
+
c.ever_in_git = git_history(c.token)
|
|
278
|
+
cls, rate = classify(claims)
|
|
279
|
+
return Verdict(roadmap.name, True, block.strip()[:200], claims, cls, rate)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def main(argv: Iterable[str]) -> int:
|
|
283
|
+
ap = argparse.ArgumentParser()
|
|
284
|
+
ap.add_argument("--json", type=Path)
|
|
285
|
+
ap.add_argument("--only", help="filter by roadmap name substring")
|
|
286
|
+
args = ap.parse_args(list(argv))
|
|
287
|
+
|
|
288
|
+
verdicts = []
|
|
289
|
+
for md in sorted(ARCHIVE.glob("*.md")):
|
|
290
|
+
if args.only and args.only not in md.name:
|
|
291
|
+
continue
|
|
292
|
+
verdicts.append(audit(md))
|
|
293
|
+
|
|
294
|
+
closure_set = [v for v in verdicts if v.has_closure]
|
|
295
|
+
by_cls: dict[str, list[Verdict]] = {}
|
|
296
|
+
for v in closure_set:
|
|
297
|
+
by_cls.setdefault(v.classification, []).append(v)
|
|
298
|
+
|
|
299
|
+
print(f"# Archive Closure-Verification Report\n")
|
|
300
|
+
print(f"- archive total: {len(verdicts)}")
|
|
301
|
+
print(f"- with closure block: {len(closure_set)}")
|
|
302
|
+
for cls in ("phantom", "partial-phantom", "verified", "no-shipped-claims", "no-claims"):
|
|
303
|
+
print(f"- {cls}: {len(by_cls.get(cls, []))}")
|
|
304
|
+
print()
|
|
305
|
+
|
|
306
|
+
for cls in ("phantom", "partial-phantom"):
|
|
307
|
+
rows = by_cls.get(cls, [])
|
|
308
|
+
if not rows:
|
|
309
|
+
continue
|
|
310
|
+
print(f"## {cls.upper()} ({len(rows)})\n")
|
|
311
|
+
for v in sorted(rows, key=lambda x: -x.phantom_rate):
|
|
312
|
+
print(f"### {v.roadmap} · phantom-rate {v.phantom_rate:.0%} (shipped-claim basis)")
|
|
313
|
+
for c in v.claims:
|
|
314
|
+
mark = "✅" if c.exists else "❌"
|
|
315
|
+
git = " (git: ever-existed)" if (not c.exists and c.ever_in_git) else ""
|
|
316
|
+
sentinel = {"shipped": "[SHIP]", "dropped": "[DROP]", "mixed": "[MIX]", "neutral": "[--]"}.get(c.sentiment, "[?]")
|
|
317
|
+
print(f" {mark} {sentinel} [{c.kind}] `{c.token}`{git}")
|
|
318
|
+
print()
|
|
319
|
+
|
|
320
|
+
if args.json:
|
|
321
|
+
args.json.write_text(json.dumps([v.__dict__ for v in verdicts], default=lambda o: o.__dict__, indent=2))
|
|
322
|
+
print(f"\n→ JSON written to {args.json}", file=sys.stderr)
|
|
323
|
+
return 0
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
if __name__ == "__main__":
|
|
327
|
+
sys.exit(main(sys.argv[1:]))
|