Skip to content

Commit 7837ca6

Browse files
committed
Accuracy and reproducibility overhaul
- Align every config example with the real v0.18 schema documented in Part 19 (approvals:, command_allowlist:, tools.include/exclude); remove fictional keys from parts 16/17/18/21, skills, wizard, config templates, reference architectures, and outreach drafts - Ship a real benchmark harness: benchmarks/run.sh + tasks/ + render.py; fix the 12-vs-13 model count everywhere - Fix broken runnable artifacts: vps-bootstrap ExecStart path + idempotency + non-destructive ufw, langfuse compose healthcheck + bucket bootstrap, nightly-backup age pipeline, part11 gateway watchdog self-match, Caddyfile reload/basic_auth - Canonical install URL everywhere; dashboard standardized on --host/--port 9119; 25+ platform count synced - README: de-duplicate inlined Parts 1-5 (1832 -> 430 lines); zh/ja translations synced to v0.18 / 27 parts - CI: full-repo link check, new anchor checker, real yamllint targets, stronger skill validation, bash -n over scripts - Remove PII (CoC email, real-looking IDs); fix dead anchors, archived MCP server recommendations, and dozens of drift bugs
1 parent e6a26fe commit 7837ca6

82 files changed

Lines changed: 2107 additions & 2720 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/ISSUE_TEMPLATE/bug-in-guide.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,4 +18,4 @@ _Expected vs actual — include command output or screenshot._
1818
_Debian 12, macOS 14, Termux on Android 14, …_
1919

2020
**Suggested fix (optional)**
21-
_If you know what should say, paste the corrected text._
21+
_If you know what it should say, paste the corrected text._

.github/ISSUE_TEMPLATE/config.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
blank_issues_enabled: false
2+
contact_links:
3+
- name: Nous Research Discord
4+
url: https://discord.gg/nousresearch
5+
about: Questions about Hermes Agent itself (not this guide) — ask the community first.

.github/PULL_REQUEST_TEMPLATE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
- [ ] Cross-links are relative (`./partN-foo.md`) and resolve
1515
- [ ] No secrets in any example — `${VAR}` placeholders only
1616
- [ ] Dates / prices / PR numbers are current (or marked with the date)
17-
- [ ] For skills: security notes included; `trust:` / `bypass_subagents` posture documented
17+
- [ ] For skills: security notes included; untrusted-input handling and `approvals:` posture documented (see part19-security-playbook.md)
1818
- [ ] For templates: every non-obvious field is commented
1919
- [ ] CHANGELOG.md updated if user-facing
2020

.github/markdown-link-check.json

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
{
22
"ignorePatterns": [
33
{ "pattern": "^https://t.me/" },
4-
{ "pattern": "^https://install.hermes.nous.ai" },
4+
{ "pattern": "^https://hermes-agent.nousresearch.com" },
55
{ "pattern": "^https://langfuse.yourdomain.com" },
66
{ "pattern": "^https://hermes.yourdomain.com" },
77
{ "pattern": "^https://hooks.yourdomain.com" },
88
{ "pattern": "^https://discord.gg/" },
9-
{ "pattern": "^http://localhost" },
10-
{ "pattern": "^http://127.0.0.1" }
9+
{ "pattern": "^https?://localhost" },
10+
{ "pattern": "^https?://127.0.0.1" },
11+
{ "pattern": "^https?://0.0.0.0" }
1112
],
1213
"httpHeaders": [
1314
{
@@ -18,5 +19,5 @@
1819
"retryOn429": true,
1920
"retryCount": 3,
2021
"fallbackRetryDelay": "30s",
21-
"aliveStatusCodes": [200, 206, 429, 403]
22+
"aliveStatusCodes": [200, 206, 403, 429]
2223
}
8.13 KB
Binary file not shown.

.github/scripts/check_anchors.py

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
#!/usr/bin/env python3
2+
"""Check that every relative markdown link resolves.
3+
4+
For each *.md in the repo:
5+
- [text](./other.md) -> other.md must exist
6+
- [text](./other.md#section) -> other.md must exist AND contain a heading
7+
whose GitHub-style slug is `section`
8+
- [text](#section) -> this file must contain that heading
9+
10+
External URLs (http/https/mailto/tel), pure images, and links inside fenced
11+
code blocks are skipped. Slugs follow GitHub's algorithm: lowercase, strip
12+
everything but word chars/spaces/hyphens, spaces -> hyphens, duplicates get
13+
-1/-2/... suffixes. Explicit <a name="..."> / id="..." anchors also count.
14+
15+
Run via: python .github/scripts/check_anchors.py
16+
Exit code 0 = all links resolve; 1 = violations (each printed as ::error).
17+
"""
18+
from __future__ import annotations
19+
20+
import pathlib
21+
import re
22+
import sys
23+
import unicodedata
24+
from urllib.parse import unquote
25+
26+
REPO_ROOT = pathlib.Path(__file__).resolve().parents[2]
27+
28+
SKIP_DIRS = {".git", "node_modules", ".venv", "__pycache__"}
29+
EXTERNAL_RE = re.compile(r"^[a-zA-Z][a-zA-Z0-9+.-]*:") # any URI scheme
30+
LINK_RE = re.compile(r"(!?)\[(?:[^\[\]]|\[[^\]]*\])*\]\(\s*<?([^)<>\s]+)>?(?:\s+[\"'][^\"']*[\"'])?\s*\)")
31+
HEADING_RE = re.compile(r"^(#{1,6})\s+(.*?)\s*#*\s*$")
32+
FENCE_RE = re.compile(r"^\s*(```|~~~)")
33+
EXPLICIT_ANCHOR_RE = re.compile(r"<a\s+(?:name|id)\s*=\s*[\"']([^\"']+)[\"']|\bid\s*=\s*[\"']([^\"']+)[\"']")
34+
35+
# strip inline markdown from heading text before slugging
36+
INLINE_MD_RE = re.compile(r"`([^`]*)`|\*\*([^*]*)\*\*|\*([^*]*)\*|__([^_]*)__|_([^_]*)_")
37+
HEADING_LINK_RE = re.compile(r"\[([^\]]*)\]\([^)]*\)")
38+
39+
40+
def github_slug(text: str) -> str:
41+
"""GitHub heading slug: strip markdown, lowercase, keep word chars/hyphens,
42+
spaces -> hyphens."""
43+
text = HEADING_LINK_RE.sub(r"\1", text)
44+
text = INLINE_MD_RE.sub(lambda m: next(g for g in m.groups() if g is not None), text)
45+
text = unicodedata.normalize("NFC", text)
46+
out = []
47+
for ch in text:
48+
if ch.isalnum() or ch == "_":
49+
out.append(ch.lower())
50+
elif ch == " ":
51+
out.append("-") # every space becomes a hyphen (GitHub does NOT collapse)
52+
elif ch == "-":
53+
out.append("-")
54+
# other punctuation dropped
55+
return "".join(out)
56+
57+
58+
def iter_md_files() -> list[pathlib.Path]:
59+
files = []
60+
for p in sorted(REPO_ROOT.rglob("*.md")):
61+
if any(part in SKIP_DIRS for part in p.relative_to(REPO_ROOT).parts):
62+
continue
63+
files.append(p)
64+
return files
65+
66+
67+
def strip_fences(lines: list[str]) -> list[str]:
68+
"""Blank out lines inside fenced code blocks (keep line count stable)."""
69+
out = []
70+
fence = None
71+
for line in lines:
72+
m = FENCE_RE.match(line)
73+
if m:
74+
if fence is None:
75+
fence = m.group(1)
76+
elif m.group(1) == fence:
77+
fence = None
78+
out.append("")
79+
continue
80+
out.append("" if fence is not None else line)
81+
return out
82+
83+
84+
def collect_anchors(p: pathlib.Path, cache: dict[pathlib.Path, set[str]]) -> set[str]:
85+
if p in cache:
86+
return cache[p]
87+
anchors: set[str] = set()
88+
text = p.read_text(encoding="utf-8-sig", errors="replace").replace("\r\n", "\n")
89+
lines = strip_fences(text.split("\n"))
90+
seen: dict[str, int] = {}
91+
for line in lines:
92+
m = HEADING_RE.match(line)
93+
if m:
94+
base = github_slug(m.group(2))
95+
n = seen.get(base, 0)
96+
seen[base] = n + 1
97+
anchors.add(base if n == 0 else f"{base}-{n}")
98+
for am in EXPLICIT_ANCHOR_RE.finditer(line):
99+
anchors.add(am.group(1) or am.group(2))
100+
cache[p] = anchors
101+
return anchors
102+
103+
104+
def main() -> int:
105+
anchor_cache: dict[pathlib.Path, set[str]] = {}
106+
violations = 0
107+
108+
for md in iter_md_files():
109+
rel = md.relative_to(REPO_ROOT)
110+
text = md.read_text(encoding="utf-8-sig", errors="replace").replace("\r\n", "\n")
111+
lines = strip_fences(text.split("\n"))
112+
for lineno, line in enumerate(lines, 1):
113+
line = re.sub(r"`[^`]*`", "``", line) # links inside inline code aren't links
114+
for m in LINK_RE.finditer(line):
115+
is_image, target = m.group(1) == "!", m.group(2)
116+
target = unquote(target)
117+
if EXTERNAL_RE.match(target) or target.startswith("//"):
118+
continue # external URL or protocol-relative
119+
path_part, _, frag = target.partition("#")
120+
if path_part:
121+
dest = (md.parent / path_part).resolve()
122+
if not dest.exists():
123+
violations += 1
124+
print(f"::error file={rel},line={lineno}::broken link '{target}' — file not found")
125+
continue
126+
else:
127+
dest = md
128+
if is_image or not frag:
129+
continue
130+
if dest.suffix.lower() != ".md" or dest.is_dir():
131+
continue # can't check anchors in non-markdown targets
132+
if frag not in collect_anchors(dest, anchor_cache):
133+
violations += 1
134+
where = "" if dest == md else f" in {dest.relative_to(REPO_ROOT)}"
135+
print(f"::error file={rel},line={lineno}::broken anchor '#{frag}'{where} ('{target}')")
136+
137+
if violations:
138+
print(f"\n{violations} broken relative link(s)/anchor(s)", file=sys.stderr)
139+
return 1
140+
print("All relative links and anchors resolve.")
141+
return 0
142+
143+
144+
if __name__ == "__main__":
145+
sys.exit(main())

.github/scripts/validate_skills.py

100644100755
Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,13 @@
2828
"memory",
2929
}
3030

31+
KEBAB_RE = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$")
3132
FRONTMATTER_RE = re.compile(r"^---\n(.*?)\n---", re.DOTALL)
3233

3334

3435
def extract_frontmatter(p: pathlib.Path) -> dict | None:
35-
text = p.read_text(encoding="utf-8")
36+
# utf-8-sig strips a BOM if present; normalize CRLF so the regex matches.
37+
text = p.read_text(encoding="utf-8-sig").replace("\r\n", "\n")
3638
m = FRONTMATTER_RE.match(text)
3739
if not m:
3840
return None
@@ -43,6 +45,46 @@ def extract_frontmatter(p: pathlib.Path) -> dict | None:
4345
return None
4446

4547

48+
def validate_parameters(params: object) -> list[str]:
49+
"""Accept either shape used in the wild:
50+
51+
- list form: [{name: pr, type: string, required: true}, ...]
52+
- mapping form: {pr: {type: string, required: true}, ...}
53+
54+
Both normalize to (name, spec) pairs validated identically.
55+
"""
56+
errs: list[str] = []
57+
if isinstance(params, dict):
58+
pairs = [(str(k), v, f"parameters.{k}") for k, v in params.items()]
59+
elif isinstance(params, list):
60+
pairs = []
61+
for i, item in enumerate(params):
62+
if not isinstance(item, dict):
63+
errs.append(f"parameters[{i}] must be a mapping, got {type(item).__name__}")
64+
continue
65+
name = item.get("name")
66+
if not isinstance(name, str) or not name:
67+
errs.append(f"parameters[{i}] missing/invalid 'name' (non-empty string required)")
68+
continue
69+
spec = {k: v for k, v in item.items() if k != "name"}
70+
pairs.append((name, spec, f"parameters[{i}]"))
71+
else:
72+
return ["parameters must be a list of {name, type, required?} mappings or a name->spec mapping"]
73+
74+
for name, spec, where in pairs:
75+
if not isinstance(spec, dict):
76+
errs.append(f"{where} spec must be a mapping, got {type(spec).__name__}")
77+
continue
78+
if not isinstance(spec.get("type"), str) or not spec.get("type"):
79+
errs.append(f"{where} missing/invalid 'type' (non-empty string required)")
80+
if "required" in spec and not isinstance(spec["required"], bool):
81+
errs.append(f"{where}.required must be a boolean")
82+
unknown = set(spec) - {"type", "required", "description", "default", "enum"}
83+
if unknown:
84+
errs.append(f"{where} has unknown keys: {sorted(unknown)}")
85+
return errs
86+
87+
4688
def validate(p: pathlib.Path) -> list[str]:
4789
errs: list[str] = []
4890
fm = extract_frontmatter(p)
@@ -53,6 +95,15 @@ def validate(p: pathlib.Path) -> list[str]:
5395
if missing:
5496
errs.append(f"missing required keys: {sorted(missing)}")
5597

98+
name = fm.get("name")
99+
if isinstance(name, str):
100+
if not KEBAB_RE.match(name):
101+
errs.append(f"name '{name}' is not kebab-case")
102+
if name != p.parent.name:
103+
errs.append(f"name '{name}' != parent directory '{p.parent.name}' (cron wiring depends on this)")
104+
elif "name" in fm:
105+
errs.append("name must be a string")
106+
56107
toolsets = fm.get("toolsets", [])
57108
if not isinstance(toolsets, list):
58109
errs.append("toolsets must be a list")
@@ -69,6 +120,9 @@ def validate(p: pathlib.Path) -> list[str]:
69120
if not isinstance(desc, str) or len(desc) < 10:
70121
errs.append("description must be a >=10-char string")
71122

123+
if "parameters" in fm:
124+
errs.extend(validate_parameters(fm["parameters"]))
125+
72126
return errs
73127

74128

@@ -84,9 +138,19 @@ def main() -> int:
84138
return 0
85139

86140
total_fails = 0
141+
seen_names: dict[str, pathlib.Path] = {}
87142
for p in skills:
88143
rel = p.relative_to(root.parent)
89144
errs = validate(p)
145+
146+
fm = extract_frontmatter(p)
147+
name = fm.get("name") if isinstance(fm, dict) else None
148+
if isinstance(name, str):
149+
if name in seen_names:
150+
errs.append(f"duplicate skill name '{name}' (also in {seen_names[name].relative_to(root.parent)})")
151+
else:
152+
seen_names[name] = p
153+
90154
if errs:
91155
total_fails += 1
92156
print(f"::error file={rel}::{'; '.join(errs)}")

.github/workflows/ci.yml

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,25 @@ jobs:
1212
runs-on: ubuntu-latest
1313
steps:
1414
- uses: actions/checkout@v4
15-
- name: Check markdown links
15+
- name: Check markdown links (full repo)
1616
uses: gaurav-nelson/github-action-markdown-link-check@v1
1717
with:
1818
config-file: '.github/markdown-link-check.json'
1919
folder-path: '.'
2020
use-quiet-mode: 'yes'
21-
check-modified-files-only: 'yes'
22-
base-branch: 'main'
21+
# Full-repo scan on every run; 403/429 from rate-limited hosts are
22+
# tolerated via aliveStatusCodes in the config file.
23+
24+
anchor-check:
25+
name: internal anchors
26+
runs-on: ubuntu-latest
27+
steps:
28+
- uses: actions/checkout@v4
29+
- uses: actions/setup-python@v5
30+
with:
31+
python-version: '3.11'
32+
- name: Check relative links and #anchors resolve
33+
run: python .github/scripts/check_anchors.py
2334

2435
yaml-lint:
2536
name: yamllint
@@ -29,7 +40,9 @@ jobs:
2940
- name: Install yamllint
3041
run: pip install yamllint
3142
- name: Run yamllint
32-
run: yamllint -c .github/yamllint.yml templates/ benchmarks/ skills/
43+
# templates/ and benchmarks/ hold the shipped YAML; .github/ covers
44+
# this workflow, the yamllint config itself, and ISSUE_TEMPLATE/config.yml.
45+
run: yamllint -c .github/yamllint.yml templates/ benchmarks/ .github/
3346

3447
skill-frontmatter:
3548
name: skill frontmatter
@@ -44,6 +57,21 @@ jobs:
4457
- name: Validate every SKILL.md frontmatter
4558
run: python .github/scripts/validate_skills.py
4659

60+
shell-syntax:
61+
name: bash -n
62+
runs-on: ubuntu-latest
63+
steps:
64+
- uses: actions/checkout@v4
65+
- name: Syntax-check all shell scripts
66+
run: |
67+
shopt -s nullglob
68+
rc=0
69+
for f in scripts/*.sh benchmarks/*.sh; do
70+
echo "bash -n $f"
71+
bash -n "$f" || rc=1
72+
done
73+
exit $rc
74+
4775
prettier-check:
4876
name: prettier (soft)
4977
runs-on: ubuntu-latest

.github/yamllint.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ extends: default
33
# Goal: catch real syntax errors in template YAMLs, not formatting nits.
44
# Templates often align values / use flow-mapping shorthand for cron/approval rules;
55
# those are legit, so we relax those rules.
6+
#
7+
# CI runs: yamllint -c .github/yamllint.yml templates/ benchmarks/ .github/
68

79
rules:
810
line-length: disable

0 commit comments

Comments
 (0)