Skip to content

Commit d4ad014

Browse files
committed
feat(skillopt): auto-commit accepted sleep proposals
1 parent 5ce8787 commit d4ad014

3 files changed

Lines changed: 236 additions & 1 deletion

File tree

hosts/mactraitorpro/default.nix

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@
119119
skilloptSleep.enable = true;
120120
skilloptSleep.maxSessions = 5;
121121
skilloptSleep.maxTasks = 1;
122+
skilloptSleep.autoCommit.enable = true;
122123
};
123124
opencode.enable = true;
124125
};

modules/agents/omp/default.nix

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,14 @@ let
139139
cd ${lib.escapeShellArg "${config.user.home}/.config/dotfiles"}
140140
${pkgs.python3}/bin/python3 ${lib.escapeShellArg "${skilloptSleepPlugin}/scripts/skillopt-sleep-omp.py"} ${lib.escapeShellArgs skilloptSleepArgs}
141141
'';
142+
skilloptSleepAutocommit = pkgs.writeShellScriptBin "omp-skillopt-sleep-autocommit" ''
143+
set -euo pipefail
144+
145+
export HOME=${lib.escapeShellArg config.user.home}
146+
export PATH=${lib.escapeShellArg "/etc/profiles/per-user/${config.user.name}/bin:/run/current-system/sw/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin"}
147+
cd ${lib.escapeShellArg "${config.user.home}/.config/dotfiles"}
148+
${pkgs.python3}/bin/python3 ${lib.escapeShellArg "${skilloptSleepPlugin}/scripts/skillopt-sleep-autocommit.py"}
149+
'';
142150
# config.yml is shared across all omp hosts. Keep machine-specific settings as
143151
# build-time overlays so Seqeratop and MacTraitorPro can diverge without
144152
# copying the whole config.
@@ -464,6 +472,11 @@ in
464472
maxSessions = mkOpt types.int 20;
465473
maxTasks = mkOpt types.int 5;
466474
extraArgs = mkOpt (types.listOf types.str) [ ];
475+
autoCommit = {
476+
enable = mkBoolOpt false;
477+
hour = mkOpt types.int 5;
478+
minute = mkOpt types.int 30;
479+
};
467480
};
468481
};
469482

@@ -474,7 +487,8 @@ in
474487
hassMcpServer
475488
]
476489
++ lib.optional cfg.dailyIntrospection.enable threadIntrospection
477-
++ lib.optional cfg.skilloptSleep.enable skilloptSleepNightly;
490+
++ lib.optional cfg.skilloptSleep.enable skilloptSleepNightly
491+
++ lib.optional cfg.skilloptSleep.autoCommit.enable skilloptSleepAutocommit;
478492

479493
home.file.".omp/agent/config.yml" = {
480494
source = ompConfigFile;
@@ -584,6 +598,23 @@ in
584598
};
585599
};
586600
};
601+
}
602+
// optionalAttrs (cfg.skilloptSleep.enable && cfg.skilloptSleep.autoCommit.enable) {
603+
omp-skillopt-sleep-autocommit = {
604+
command = "${skilloptSleepAutocommit}/bin/omp-skillopt-sleep-autocommit";
605+
serviceConfig = {
606+
StartCalendarInterval = {
607+
Hour = cfg.skilloptSleep.autoCommit.hour;
608+
Minute = cfg.skilloptSleep.autoCommit.minute;
609+
};
610+
StandardOutPath = "${config.user.home}/Library/Logs/omp-skillopt-sleep-autocommit.log";
611+
StandardErrorPath = "${config.user.home}/Library/Logs/omp-skillopt-sleep-autocommit.err.log";
612+
EnvironmentVariables = {
613+
HOME = config.user.home;
614+
PATH = "/etc/profiles/per-user/${config.user.name}/bin:/run/current-system/sw/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin";
615+
};
616+
};
617+
};
587618
};
588619
}
589620
);
Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
#!/usr/bin/env python3
2+
"""Commit accepted SkillOpt-Sleep skill proposals into dotfiles.
3+
4+
This intentionally supports one safe path: the generated learned skill. Broader
5+
SkillOpt edits should stay staged for human review or PR mode.
6+
"""
7+
from __future__ import annotations
8+
9+
import argparse
10+
import json
11+
import os
12+
import re
13+
import subprocess
14+
import sys
15+
from pathlib import Path
16+
from typing import Any
17+
18+
ALLOWED_TARGET = Path("skills/catalog/skillopt-sleep-learned/SKILL.md")
19+
COMMIT_MESSAGE = "chore(skillopt): apply accepted sleep proposal"
20+
PRIVATE_PATTERNS = {
21+
"email address": re.compile(r"\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b", re.I),
22+
"home path": re.compile(r"/Users/emiller\b"),
23+
"secret reference": re.compile(r"(?:op://|\b[A-Z0-9_]*(?:TOKEN|SECRET|PASSWORD|KEY)[A-Z0-9_]*\b)"),
24+
"session artifact": re.compile(r"\b(?:memory://|agent://|artifact://|\.omp/agent/sessions)"),
25+
}
26+
27+
28+
29+
def run(cmd: list[str], cwd: Path, *, dry_run: bool = False) -> subprocess.CompletedProcess[str]:
30+
print("+", " ".join(cmd), file=sys.stderr)
31+
if dry_run:
32+
return subprocess.CompletedProcess(cmd, 0, "", "")
33+
return subprocess.run(cmd, cwd=cwd, text=True, capture_output=True, check=False)
34+
35+
36+
def git_lines(repo: Path, args: list[str]) -> list[str]:
37+
result = subprocess.run(["git", *args], cwd=repo, text=True, capture_output=True, check=False)
38+
if result.returncode != 0:
39+
raise SystemExit(result.stderr.strip() or result.stdout.strip() or f"git {' '.join(args)} failed")
40+
return [line for line in result.stdout.splitlines() if line]
41+
42+
43+
def latest_staging(root: Path) -> Path | None:
44+
candidates = [p for p in root.iterdir() if p.is_dir() and (p / "report.json").is_file()]
45+
return max(candidates, default=None, key=lambda p: p.name)
46+
47+
48+
def read_json(path: Path) -> dict[str, Any]:
49+
with path.open(encoding="utf-8") as handle:
50+
value = json.load(handle)
51+
if not isinstance(value, dict):
52+
raise SystemExit(f"{path} did not contain a JSON object")
53+
return value
54+
55+
56+
def validate_public_text(text: str) -> None:
57+
for label, pattern in PRIVATE_PATTERNS.items():
58+
if pattern.search(text):
59+
raise SystemExit(f"proposed skill contains private {label}; refusing to auto-commit")
60+
61+
62+
def ensure_clean(repo: Path) -> None:
63+
dirty = git_lines(repo, ["status", "--porcelain"])
64+
if dirty:
65+
raise SystemExit("dotfiles worktree is dirty; refusing to auto-commit\n" + "\n".join(dirty))
66+
67+
def reset_clone(repo: Path) -> None:
68+
subprocess.run(["git", "reset", "--hard", "origin/main"], cwd=repo, text=True, capture_output=True, check=False)
69+
subprocess.run(["git", "clean", "-fd"], cwd=repo, text=True, capture_output=True, check=False)
70+
71+
72+
73+
def changed_paths(repo: Path) -> set[str]:
74+
paths: set[str] = set()
75+
for line in git_lines(repo, ["status", "--porcelain", "--untracked-files=all"]):
76+
paths.add(line[3:] if line.startswith("?? ") else line[3:])
77+
return paths
78+
79+
80+
def ensure_repo(repo: Path, source_repo: Path, dry_run: bool) -> None:
81+
if (repo / ".git").exists():
82+
return
83+
remote = git_lines(source_repo, ["remote", "get-url", "origin"])[0]
84+
repo.parent.mkdir(parents=True, exist_ok=True)
85+
result = run(["git", "clone", remote, str(repo)], source_repo, dry_run=dry_run)
86+
if result.returncode != 0:
87+
raise SystemExit(result.stderr or result.stdout)
88+
89+
90+
def write_marker(staging: Path, commit: str, dry_run: bool) -> None:
91+
marker = staging / "autocommit.json"
92+
payload = {"commit": commit, "target": str(ALLOWED_TARGET)}
93+
if dry_run:
94+
print(json.dumps({"would_write": str(marker), **payload}, indent=2))
95+
else:
96+
marker.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
97+
98+
99+
def main(argv: list[str] | None = None) -> int:
100+
parser = argparse.ArgumentParser()
101+
parser.add_argument("--repo", default=str(Path.home() / ".skillopt-sleep" / "omp" / "dotfiles-autocommit"))
102+
parser.add_argument("--source-repo", default=str(Path.home() / ".config" / "dotfiles"))
103+
parser.add_argument("--staging-root", default=str(Path.home() / ".skillopt-sleep" / "omp" / "staging"))
104+
parser.add_argument("--dry-run", action="store_true")
105+
args = parser.parse_args(argv)
106+
107+
repo = Path(args.repo).expanduser().resolve()
108+
source_repo = Path(args.source_repo).expanduser().resolve()
109+
staging_root = Path(args.staging_root).expanduser().resolve()
110+
staging = latest_staging(staging_root)
111+
if not staging:
112+
print("no staging reports found")
113+
return 0
114+
115+
marker = staging / "autocommit.json"
116+
if marker.exists():
117+
print(f"already committed: {staging}")
118+
return 0
119+
120+
report = read_json(staging / "report.json")
121+
project = Path(str(report.get("project") or "")).expanduser().resolve()
122+
if project != source_repo:
123+
print(f"latest staging is for {project}, not {source_repo}")
124+
return 0
125+
if not report.get("accepted"):
126+
print(f"latest staging not accepted: {staging}")
127+
return 0
128+
if not report.get("edits"):
129+
print(f"latest staging has no edits: {staging}")
130+
return 0
131+
132+
proposed = staging / "proposed_SKILL.md"
133+
if not proposed.exists():
134+
print(f"no proposed skill file: {proposed}")
135+
return 0
136+
proposed_text = proposed.read_text(encoding="utf-8")
137+
if not proposed_text.strip():
138+
print(f"no proposed skill content: {proposed}")
139+
return 0
140+
validate_public_text(proposed_text)
141+
142+
ensure_repo(repo, source_repo, args.dry_run)
143+
if args.dry_run and not (repo / ".git").exists():
144+
print(json.dumps({"would_clone": str(repo), "staging": str(staging)}, indent=2))
145+
return 0
146+
ensure_clean(repo)
147+
result = run(["git", "fetch", "origin", "main"], repo, dry_run=args.dry_run)
148+
if result.returncode != 0:
149+
raise SystemExit(result.stderr or result.stdout)
150+
current_branch = git_lines(repo, ["branch", "--show-current"])[0]
151+
if current_branch != "main":
152+
result = run(["git", "switch", "main"], repo, dry_run=args.dry_run)
153+
if result.returncode != 0:
154+
raise SystemExit(result.stderr or result.stdout)
155+
result = run(["git", "reset", "--hard", "origin/main"], repo, dry_run=args.dry_run)
156+
if result.returncode != 0:
157+
raise SystemExit(result.stderr or result.stdout)
158+
ensure_clean(repo)
159+
160+
target = repo / ALLOWED_TARGET
161+
target.parent.mkdir(parents=True, exist_ok=True)
162+
if target.exists() and target.read_text(encoding="utf-8") == proposed_text:
163+
print(f"target already matches proposal: {ALLOWED_TARGET}")
164+
write_marker(staging, git_lines(repo, ["rev-parse", "HEAD"])[0], args.dry_run)
165+
return 0
166+
167+
if args.dry_run:
168+
print(json.dumps({"staging": str(staging), "target": str(ALLOWED_TARGET), "would_commit": True}, indent=2))
169+
return 0
170+
171+
try:
172+
target.write_text(proposed_text, encoding="utf-8")
173+
changed = changed_paths(repo)
174+
if changed != {str(ALLOWED_TARGET)}:
175+
raise SystemExit(f"unexpected changed paths: {sorted(changed)}")
176+
177+
check_cmd = ["nix", "develop", "--command", "./bin/hey", "check", "--worktree", str(ALLOWED_TARGET)]
178+
result = run(check_cmd, repo)
179+
if result.returncode != 0:
180+
result = run(check_cmd, repo)
181+
if result.returncode != 0:
182+
raise SystemExit(result.stderr or result.stdout)
183+
184+
changed = changed_paths(repo)
185+
if changed != {str(ALLOWED_TARGET)}:
186+
raise SystemExit(f"unexpected changed paths after checks: {sorted(changed)}")
187+
188+
for cmd in (["git", "add", str(ALLOWED_TARGET)], ["git", "commit", "-m", COMMIT_MESSAGE], ["git", "push", "origin", "main"]):
189+
result = run(cmd, repo)
190+
if result.returncode != 0:
191+
raise SystemExit(result.stderr or result.stdout)
192+
except BaseException:
193+
reset_clone(repo)
194+
raise
195+
196+
commit = git_lines(repo, ["rev-parse", "HEAD"])[0]
197+
write_marker(staging, commit, False)
198+
print(f"committed {commit} from {staging}")
199+
return 0
200+
201+
202+
if __name__ == "__main__":
203+
raise SystemExit(main())

0 commit comments

Comments
 (0)