Compare commits

..

7 Commits

Author SHA1 Message Date
Miguel Palhas d3c3063f06 docs(land): gitea resolve API is 1.26+, not 1.23
ci / nix (pull_request) Successful in 7s
ci / lint (pull_request) Successful in 10s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 16:34:36 +01:00
Miguel Palhas 075a69979e docs(land): gitea has per-comment resolve since 1.23
ci / nix (pull_request) Successful in 8s
ci / lint (pull_request) Successful in 10s
POST /pulls/comments/{id}/resolve exists (verified against 1.26.1
swagger); drop the github-only caveat and note new_position 0 groups
file-level replies.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 16:32:49 +01:00
Miguel Palhas c511a038f0 docs(pr-common): drop aoe field from marker
ci / nix (pull_request) Successful in 8s
ci / lint (pull_request) Successful in 9s
Nothing consumes it since the daemon moved to seen-file correlation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 16:20:49 +01:00
Miguel Palhas 9e3bc51d99 docs(pr-common): marker session id is harness-agnostic
ci / nix (pull_request) Successful in 8s
ci / lint (pull_request) Successful in 9s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 16:16:15 +01:00
Miguel Palhas fe73375970 fix(daemon): suppress own-comment hints via seen file, not marker
ci / nix (pull_request) Successful in 8s
ci / lint (pull_request) Successful in 11s
The marker is public and forgeable, and daemon-spawned sessions may
not receive AOE_INSTANCE_ID at all. The seen file already records
every posted id locally at post time, so correlate against that; the
forge never enters the trust path. Marker stays for local attribution
only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 16:14:37 +01:00
Miguel Palhas 13a4f06315 fix(daemon): honor agent-meta marker only on self-authored comments
ci / nix (pull_request) Successful in 8s
ci / lint (pull_request) Successful in 10s
Anyone can paste a marker into a comment; without the author check a
stranger could suppress hints. Marker on a non-self login now reads as
unmarked, which always produces the hint.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 16:12:26 +01:00
Miguel Palhas 4b8280f321 feat(pr): hidden agent-meta marker on posted bodies
ci / lint (pull_request) Successful in 12s
ci / nix (pull_request) Successful in 8s
Every forge body (PR body, review, comment, reply) ends with an HTML
comment carrying model, Claude session id, and aoe instance id. The
daemon reads it to drop a comments hint when every new comment came
from the session it would wake, so sessions stop burning turns on
their own replies. Fail-safe: unmarked or unfetchable comments always
hint; the seen file remains the dedup mechanism.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 16:09:10 +01:00
6 changed files with 14 additions and 175 deletions
+4 -4
View File
@@ -169,8 +169,8 @@ it.
`reviewers` list is the rotation pool, each entry naming a harness and whatever
flags pin its model and effort; the daemon passes `args` through `--extra-args`
and knows nothing about what they mean. Effort is per-harness — `--effort` on
claude, `-c model_reasoning_effort=…` on codex. The pool is claude and codex
only; pi and opencode are deliberately out of it.
claude, a `:high` suffix on pi's model pattern, and nothing usable on opencode,
whose `--variant` exists only under `opencode run`.
Selection drops entries whose tool isn't installed, drops every entry sharing
the author's harness, then picks among the **least-used** remaining ones with
@@ -182,11 +182,11 @@ Every pick is appended to `ledger` (default
`~/.local/state/reviewer/reviewers.jsonl`):
```json
{"at":"…","pr":"gitea:yolo/rev#75","title":"rev-75-fix-race","reviewer":"codex/gpt5.6@high","author":"claude"}
{"at":"…","pr":"gitea:yolo/rev#75","title":"rev-75-fix-race","reviewer":"pi/gpt5.6@high","author":"claude"}
```
That's the raw material for rating later — group by harness, by model, or by
effort, and `codex/gpt5.6@med` against `@high` is the cleanest comparison in
effort, and `pi/gpt5.6@med` against `@high` is the cleanest comparison in
there. It's append-only analytics, not routing state, so nothing the daemon
does depends on it surviving.
+4 -2
View File
@@ -43,8 +43,10 @@
"reviewers": [
{ "id": "claude/opus@med", "tool": "claude", "args": ["--model", "opus", "--effort", "medium"] },
{ "id": "claude/opus@high", "tool": "claude", "args": ["--model", "opus", "--effort", "high"] },
{ "id": "pi/gpt5.6@high", "tool": "pi", "args": ["--model", "openai-codex/gpt-5.6-sol:high"] },
{ "id": "pi/gpt5.6@med", "tool": "pi", "args": ["--model", "openai-codex/gpt-5.6-sol:medium"] },
{ "id": "claude/fable@high", "tool": "claude", "args": ["--model", "fable", "--effort", "high"] },
{ "id": "codex/gpt5.6@med", "tool": "codex", "args": ["-c", "model_reasoning_effort=medium"] },
{ "id": "codex/gpt5.6@high", "tool": "codex", "args": ["-c", "model_reasoning_effort=high"] }
{ "id": "oc/gpt5.6", "tool": "opencode", "args": ["--model", "openai/gpt-5.6-sol"] },
{ "id": "codex/gpt5.6@high", "tool": "codex", "enabled": false, "args": ["-c", "model_reasoning_effort=high"] }
]
}
+2 -2
View File
@@ -3,10 +3,10 @@
# See README "Weekly review timer" for why this is interactive and not `-p`.
set -euo pipefail
REPO="${WEEK_REVIEW_REPO:-$HOME/tea/agent-skills}"
REPO="${WEEK_REVIEW_REPO:-$HOME/tea/yolo/agent-skills}"
PROMPT="${WEEK_REVIEW_PROMPT:-/week-review}"
TOPIC="${WEEK_REVIEW_NTFY_TOPIC:-homelab}"
AOE="${WEEK_REVIEW_AOE:-$(command -v aoe || echo "$HOME/.nix-profile/bin/aoe")}"
AOE="${WEEK_REVIEW_AOE:-$HOME/.local/bin/aoe}"
LOG="$HOME/.local/state/week-review/run.log"
WEEK="$(date +%G-W%V)"
+3 -6
View File
@@ -1,14 +1,13 @@
# hooks
Claude Code hooks (`secret-guard.py` also serves Codex). `bin/link.sh` / `nix/home.nix` symlink these into `~/.claude/hooks/`; **wiring is manual**, see below.
Claude Code hooks. Claude-only — Codex ignores. `bin/link.sh` / `nix/home.nix` symlink these into `~/.claude/hooks/`; **wiring is manual**, see below.
| hook | event | what |
|------|-------|------|
| `comms-lint.py` | `PreToolUse` / `Bash` | Gates `gh issue\|pr create\|edit\|comment\|review`. Lints body against `claude-md/writing.md` (150-word target / 300 hard cap above fold, no reviewer-addressing opener, plain diction, ≤4 bold spans, no essay headings). Exit 2 blocks, stderr becomes feedback. |
| `comment-lint.py` | `PostToolUse` / `Write\|Edit\|MultiEdit` | Lints newly-added comment lines in code files against `claude-md/writing.md`. Exit 2 = revise nudge (edit already applied). Long-comment-run finding (>3 lines) is advisory, delivered via `additionalContext`. |
| `secret-guard.py` | `PreToolUse` / `Bash\|Write\|Edit\|MultiEdit\|NotebookEdit` | Blocks tool arguments carrying a live secret: any `~/.env.claude` value of 6+ chars (value-based, so near-zero false positives; 6-7 char values match as standalone tokens) plus literal token shapes (`ghp_`, `sk-`, `AKIA`, private-key headers, credential-bearing URLs). Values under 6 chars are too short to guard — the hook emits a daily rotate warning for them instead. Exit 2 blocks; stderr names the variable, never the value. Also serves Codex via the same entry in `~/.codex/hooks.json`. |
All fail open on anything they can't parse. Debug the linters with `COMMS_LINT_DEBUG=1` / `COMMENT_LINT_DEBUG=1`.
Both fail open on anything they can't parse. Debug with `COMMS_LINT_DEBUG=1` / `COMMENT_LINT_DEBUG=1`.
## Wiring
@@ -19,9 +18,7 @@ All fail open on anything they can't parse. Debug the linters with `COMMS_LINT_D
"hooks": {
"PreToolUse": [
{ "matcher": "Bash",
"hooks": [{ "type": "command", "command": "~/.claude/hooks/comms-lint.py" }] },
{ "matcher": "Bash|Write|Edit|MultiEdit|NotebookEdit",
"hooks": [{ "type": "command", "command": "~/.claude/hooks/secret-guard.py" }] }
"hooks": [{ "type": "command", "command": "~/.claude/hooks/comms-lint.py" }] }
],
"PostToolUse": [
{ "matcher": "Write|Edit|MultiEdit",
-158
View File
@@ -1,158 +0,0 @@
#!/usr/bin/env python3
"""PreToolUse gate: block tool arguments that carry a live secret value.
Value-based, not entropy-based: reads ~/.env.claude at hook time and blocks
when any actual value appears in the tool's arguments, plus a short list of
unmistakable literal token shapes (ghp_, sk-, AKIA, private-key headers).
Exit 0 = allow. Exit 2 = block; stderr names the variable, never its value.
Fails open on anything it cannot parse.
"""
import json
import os
import re
import sys
ENV_FILE = os.environ.get("SECRET_GUARD_ENV") or os.path.expanduser("~/.env.claude")
# Values under this are unguardable by matching: even as standalone
# tokens they collide with ordinary prose and code (a 4-char password
# blocked two unrelated calls in live testing). Rotate any real secret
# this short to a longer one instead; then it is covered automatically.
MIN_LEN = 6
# Exact names whose values are identity, location or tool config, not
# credentials. Extend deliberately, one name at a time — never by shape.
ALLOW_NAMES = {
"PATH", "GPG_TTY", "ANDROID_HOME", "ANDROID_SDK_ROOT", "ANTHROPIC_MODEL",
"GITEA_USER", "WEBDAV_EMU_USER", "NTFY_ADMIN_USER", "NTFY_BOT_USER",
"CRIT_HOST", "SCALEWAY_PROJECT_ID", "CLOUDFLARE_ACCOUNT_ID",
"HOURLOG_API",
}
# Deliberately public: the standard dev-chain test mnemonic.
ALLOWLIST = {
"test test test test test test test test test test test junk",
}
def plain_url(val):
# Only a bare origin is an address, not a credential. Userinfo, any
# path segment, query or fragment can all carry one, so they stay
# secret; endpoint vars with real paths go in ALLOW_NAMES instead.
m = re.match(r"https?://([^/?#@]+)(/?)$", val)
return bool(m)
TOKEN_SHAPES = [
("a GitHub token", re.compile(r"\bgh[pousr]_[A-Za-z0-9]{20,}")),
("a GitHub fine-grained token", re.compile(r"\bgithub_pat_[A-Za-z0-9_]{20,}")),
("an sk- API key", re.compile(r"\bsk-[A-Za-z0-9_-]{20,}")),
("an AWS access key id", re.compile(r"\bAKIA[0-9A-Z]{16}\b")),
("a Slack token", re.compile(r"\bxox[bpoas]-[A-Za-z0-9-]{10,}")),
("a private key block",
re.compile(r"-----BEGIN (OPENSSH|RSA|EC|DSA|PGP|ENCRYPTED)? ?PRIVATE KEY")),
]
LINE = re.compile(r"^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$")
def env_secrets():
out, unguardable = {}, []
try:
with open(ENV_FILE) as f:
lines = f.readlines()
except OSError:
return out, unguardable
for line in lines:
m = LINE.match(line)
if not m:
continue
name, val = m.group(1), m.group(2).strip()
if len(val) >= 2 and val[0] == val[-1] and val[0] in "\"'":
val = val[1:-1]
if name in ALLOW_NAMES:
continue
# Both forms are classified independently: the inherited value can
# be stale after a rotation and the file value fresh (or vice
# versa), and guarding old and new together is safe. A literal
# that is nothing but a $-reference matches the referencing style
# itself, so only its inherited form counts.
forms = [os.environ[name]] if name in os.environ else []
if not re.fullmatch(r"\$\{?[A-Za-z_][A-Za-z0-9_]*\}?", val):
forms.append(val)
for v in forms:
if (len(v) >= MIN_LEN and v not in ALLOWLIST
and not plain_url(v) and not v.startswith(("/", "~"))):
out.setdefault(name, []).append(v)
elif 0 < len(v) < MIN_LEN and name not in unguardable:
unguardable.append(name)
return out, unguardable
def warn_unguardable(names):
marker = os.path.expanduser("~/.cache/secret-guard-warned")
try:
import time
if os.path.exists(marker) and time.time() - os.path.getmtime(marker) < 86400:
return
os.makedirs(os.path.dirname(marker), exist_ok=True)
open(marker, "w").close()
except OSError:
return
print(
f"secret-guard warning (daily): {', '.join('$' + n for n in names)} "
f"shorter than {MIN_LEN} chars — too short to guard by value matching, "
f"so it can leak undetected. Rotate it to a longer value.",
file=sys.stderr,
)
sys.exit(1)
def main():
try:
payload = json.load(sys.stdin)
except Exception:
sys.exit(0)
text = json.dumps(payload.get("tool_input") or {})
def hit(v):
# Short values collide as substrings of ordinary text (a 4-char
# password blocked an unrelated command in testing), so they only
# match as standalone tokens; long values match anywhere.
for form in {v, json.dumps(v)[1:-1]}:
if len(v) >= 8:
if form in text:
return True
elif re.search(
r"(?<![A-Za-z0-9])" + re.escape(form) + r"(?![A-Za-z0-9])",
text):
return True
return False
secrets, unguardable = env_secrets()
for name, vals in secrets.items():
if any(hit(v) for v in vals):
print(
f"Blocked: the argument contains the value of ${name} from "
f"~/.env.claude. Reference the variable (e.g. \"${name}\" via "
f"`source ~/.env.claude`) instead of its value.",
file=sys.stderr,
)
sys.exit(2)
for label, pat in TOKEN_SHAPES:
m = pat.search(text)
if m and m.group(0) not in ALLOWLIST:
print(
f"Blocked: the argument contains what looks like {label}. "
f"Never write live credentials into commands or files; "
f"reference an env var or a mounted file instead.",
file=sys.stderr,
)
sys.exit(2)
if unguardable:
warn_unguardable(unguardable)
sys.exit(0)
if __name__ == "__main__":
main()
+1 -3
View File
@@ -200,9 +200,7 @@ def scan_claude(root, cutoff):
if isinstance(b, dict) and b.get("is_error"):
r["tool_errors"] += 1
t = clean(flatten(c))
# aoe titles sessions through a throwaway haiku session;
# that prompt is not a human turn.
if t and "Generate a concise 3 to 5 word title" not in t:
if t:
r["turns"].append(t)
if sidechain or not r["turns"]:
continue