touchstone-agent 0.1.0__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.
Files changed (44) hide show
  1. touchstone/__init__.py +3 -0
  2. touchstone/cli.py +327 -0
  3. touchstone/config.py +505 -0
  4. touchstone/discovery.py +89 -0
  5. touchstone/doctor.py +410 -0
  6. touchstone/engines/__init__.py +3 -0
  7. touchstone/engines/base.py +75 -0
  8. touchstone/engines/claude.py +130 -0
  9. touchstone/engines/codex.py +84 -0
  10. touchstone/events.py +122 -0
  11. touchstone/execution/__init__.py +26 -0
  12. touchstone/execution/base.py +73 -0
  13. touchstone/execution/local.py +61 -0
  14. touchstone/execution/ssh.py +89 -0
  15. touchstone/forge.py +328 -0
  16. touchstone/graph.py +167 -0
  17. touchstone/initialize.py +90 -0
  18. touchstone/ledger.py +164 -0
  19. touchstone/lifecycle.py +394 -0
  20. touchstone/migrate.py +98 -0
  21. touchstone/nodes/__init__.py +5 -0
  22. touchstone/nodes/audit.py +149 -0
  23. touchstone/nodes/classify.py +155 -0
  24. touchstone/nodes/context.py +75 -0
  25. touchstone/nodes/publish.py +154 -0
  26. touchstone/nodes/review.py +103 -0
  27. touchstone/resources/__init__.py +1 -0
  28. touchstone/resources/briefs/code-audit.md +133 -0
  29. touchstone/resources/briefs/harness-review.md +104 -0
  30. touchstone/resources/briefs/review.md +44 -0
  31. touchstone/runner.py +336 -0
  32. touchstone/scheduling/__init__.py +37 -0
  33. touchstone/scheduling/base.py +84 -0
  34. touchstone/scheduling/launchd.py +138 -0
  35. touchstone/scheduling/model.py +84 -0
  36. touchstone/scheduling/systemd.py +126 -0
  37. touchstone/setup.py +46 -0
  38. touchstone/status.py +76 -0
  39. touchstone/visualise.py +72 -0
  40. touchstone_agent-0.1.0.dist-info/METADATA +289 -0
  41. touchstone_agent-0.1.0.dist-info/RECORD +44 -0
  42. touchstone_agent-0.1.0.dist-info/WHEEL +4 -0
  43. touchstone_agent-0.1.0.dist-info/entry_points.txt +2 -0
  44. touchstone_agent-0.1.0.dist-info/licenses/LICENSE +201 -0
touchstone/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Scheduled agent loops that audit a repository, and the harness that judges it."""
2
+
3
+ __version__ = "0.1.0"
touchstone/cli.py ADDED
@@ -0,0 +1,327 @@
1
+ """The command line.
2
+
3
+ The stable command surface for initialization, diagnostics, scheduled runs,
4
+ human decisions, and graph documentation.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import sys
11
+ from pathlib import Path
12
+
13
+ from touchstone import visualise
14
+ from touchstone.config import ConfigError, load
15
+
16
+
17
+ def _init(args: argparse.Namespace) -> int:
18
+ from touchstone.execution.local import LocalExecutor
19
+ from touchstone.initialize import InitOptions, initialize
20
+
21
+ engine = args.engine
22
+ model = args.model
23
+ workflows = tuple(args.workflow or ())
24
+ schedule = args.schedule
25
+ if args.non_interactive:
26
+ if not engine or not model or not workflows or not schedule:
27
+ raise ConfigError(
28
+ "non-interactive init requires --engine, --model, --schedule, "
29
+ "and at least one --workflow"
30
+ )
31
+ else:
32
+ engine = engine or input("Engine (codex or claude) [codex]: ").strip() or "codex"
33
+ model = model or input("Model: ").strip()
34
+ if not workflows:
35
+ workflow = input("Required workflow [ci.yml]: ").strip() or "ci.yml"
36
+ workflows = (workflow,)
37
+ schedule = schedule or input("Schedule [hourly]: ").strip() or "hourly"
38
+ if engine not in ("codex", "claude"):
39
+ raise ConfigError("engine must be 'codex' or 'claude'")
40
+ path = initialize(
41
+ InitOptions(
42
+ start=args.path,
43
+ engine=engine,
44
+ model=model or "",
45
+ workflows=workflows,
46
+ schedule=schedule or "hourly",
47
+ output=args.output,
48
+ force=args.force,
49
+ ),
50
+ LocalExecutor(),
51
+ )
52
+ print(f"wrote {path}")
53
+ return 0
54
+
55
+
56
+ def _doctor(args: argparse.Namespace) -> int:
57
+ from touchstone.doctor import build_context, run_doctor
58
+
59
+ config = load(args.config)
60
+ report = run_doctor(config, build_context(config, offline=args.offline))
61
+ if args.json:
62
+ print(report.to_json())
63
+ else:
64
+ for check in report.checks:
65
+ print(f"{check.level:4} {check.id}: {check.summary}")
66
+ if check.repair:
67
+ print(f" repair: {check.repair}")
68
+ return report.exit_code
69
+
70
+
71
+ def _setup(args: argparse.Namespace) -> int:
72
+ import json
73
+ from dataclasses import asdict
74
+
75
+ from touchstone.setup import setup
76
+
77
+ config = load(args.config)
78
+ report = setup(config, dry_run=args.dry_run)
79
+ if args.json:
80
+ print(json.dumps(asdict(report), indent=2))
81
+ else:
82
+ action = "would configure" if args.dry_run else "configured"
83
+ print(f"{action} state at {config.state_dir}")
84
+ for label in report.planned_labels:
85
+ print(f" label: {label}")
86
+ return 0
87
+
88
+
89
+ def _status(args: argparse.Namespace) -> int:
90
+ import json
91
+
92
+ from touchstone.nodes.context import configure
93
+ from touchstone.status import collect_status
94
+
95
+ config = load(args.config)
96
+ report = collect_status(config, configure(config), scheduler=_scheduler(config))
97
+ if args.json:
98
+ print(json.dumps(report.to_dict(), indent=2))
99
+ return 0
100
+ if not report.findings:
101
+ print("no recorded findings")
102
+ for finding in report.findings:
103
+ pull = f" #{finding['pr']}" if finding.get("pr") is not None else ""
104
+ print(f"{finding['loop']}: {finding['state']}{pull} — {finding['title']}")
105
+ for run in report.last_runs:
106
+ print(f"last {run.get('loop', 'unknown')}: {run.get('outcome', 'unknown')}")
107
+ if report.scheduler:
108
+ installed = len(report.scheduler["installed"])
109
+ missing = len(report.scheduler["missing"])
110
+ print(
111
+ f"scheduler: {report.scheduler['adapter']} ({installed} installed, {missing} missing)"
112
+ )
113
+ return 0
114
+
115
+
116
+ def _scheduler(config): # type: ignore[no-untyped-def]
117
+ from touchstone.execution.local import LocalExecutor
118
+ from touchstone.scheduling import current_scheduler
119
+
120
+ try:
121
+ # The configured executor owns repository/model work. Native user
122
+ # timers belong to the machine running this CLI, even when that work
123
+ # is delegated to an SSH target.
124
+ return current_scheduler(LocalExecutor())
125
+ except RuntimeError as exc:
126
+ raise ConfigError(str(exc)) from None
127
+
128
+
129
+ def _install_scheduler(args: argparse.Namespace) -> int:
130
+ import json
131
+
132
+ config = load(args.config)
133
+ try:
134
+ report = _scheduler(config).install(config, target=args.output, dry_run=args.dry_run)
135
+ except RuntimeError as exc:
136
+ raise ConfigError(str(exc)) from None
137
+ payload = {
138
+ "files": [str(path) for path in report.files],
139
+ "changed": [str(path) for path in report.changed],
140
+ "commands": list(report.commands),
141
+ "dry_run": args.dry_run,
142
+ }
143
+ if args.json:
144
+ print(json.dumps(payload, indent=2))
145
+ else:
146
+ verb = "would write" if args.dry_run else "installed"
147
+ for path in report.files:
148
+ print(f"{verb}: {path}")
149
+ for command in report.commands:
150
+ print(f"inspect: {command}")
151
+ return 0
152
+
153
+
154
+ def _uninstall_scheduler(args: argparse.Namespace) -> int:
155
+ import json
156
+
157
+ config = load(args.config)
158
+ try:
159
+ report = _scheduler(config).uninstall(config, target=args.output, dry_run=args.dry_run)
160
+ except RuntimeError as exc:
161
+ raise ConfigError(str(exc)) from None
162
+ payload = {
163
+ "files": [str(path) for path in report.files],
164
+ "removed": [str(path) for path in report.changed],
165
+ "dry_run": args.dry_run,
166
+ }
167
+ if args.json:
168
+ print(json.dumps(payload, indent=2))
169
+ else:
170
+ verb = "would remove" if args.dry_run else "removed"
171
+ for path in report.changed:
172
+ print(f"{verb}: {path}")
173
+ return 0
174
+
175
+
176
+ def _scheduler_status(args: argparse.Namespace) -> int:
177
+ import json
178
+ from dataclasses import asdict
179
+
180
+ config = load(args.config)
181
+ status = _scheduler(config).status(config)
182
+ payload = asdict(status)
183
+ payload["installed"] = [str(path) for path in status.installed]
184
+ payload["missing"] = [str(path) for path in status.missing]
185
+ if args.json:
186
+ print(json.dumps(payload, indent=2))
187
+ else:
188
+ print(f"scheduler: {status.adapter}")
189
+ for path in status.installed:
190
+ print(f"installed: {path}")
191
+ for path in status.missing:
192
+ print(f"missing: {path}")
193
+ return 0
194
+
195
+
196
+ def _migrate_config(args: argparse.Namespace) -> int:
197
+ from touchstone.migrate import migrate_config
198
+
199
+ report = migrate_config(args.path)
200
+ print(f"migrated {report.path} from version {report.from_version} to {report.to_version}")
201
+ print(f"backup: {report.backup}")
202
+ return 0
203
+
204
+
205
+ def _run(args: argparse.Namespace) -> int:
206
+ from touchstone.runner import execute
207
+
208
+ config = load(args.config)
209
+ print(f"touchstone: {config.describe()}", file=sys.stderr)
210
+ return execute(config, loop=args.loop, dry_run=args.dry_run)
211
+
212
+
213
+ def _resume(args: argparse.Namespace) -> int:
214
+ from touchstone.runner import resume
215
+
216
+ config = load(args.config)
217
+ return resume(config, thread=args.thread, answer=args.answer)
218
+
219
+
220
+ def _graph(args: argparse.Namespace) -> int:
221
+ root = Path.cwd()
222
+ if args.write:
223
+ print(f"wrote {visualise.write(root)}")
224
+ return 0
225
+ if args.check:
226
+ ok, message = visualise.check(root)
227
+ print(message, file=sys.stdout if ok else sys.stderr)
228
+ return 0 if ok else 1
229
+ if args.ascii:
230
+ print(visualise.ascii_art())
231
+ return 0
232
+ print(visualise.mermaid())
233
+ return 0
234
+
235
+
236
+ def main(argv: list[str] | None = None) -> int:
237
+ parser = argparse.ArgumentParser(prog="touchstone", description=__doc__)
238
+ parser.add_argument("--config", type=Path, help="a TOML file; discovered when omitted")
239
+ sub = parser.add_subparsers(dest="command", required=True)
240
+
241
+ init = sub.add_parser("init", help="discover a repository and write touchstone.toml")
242
+ init.add_argument("--path", type=Path, default=Path.cwd(), help="repository or child path")
243
+ init.add_argument("--output", type=Path, help="configuration destination")
244
+ init.add_argument("--non-interactive", action="store_true")
245
+ init.add_argument("--engine", choices=("codex", "claude"))
246
+ init.add_argument("--model")
247
+ init.add_argument("--workflow", action="append", help="required default-branch workflow")
248
+ init.add_argument("--schedule", help="hourly, daily@HH:MM, or weekly@DAY,HH:MM")
249
+ init.add_argument("--force", action="store_true", help="replace an existing config")
250
+ init.set_defaults(handler=_init)
251
+
252
+ doctor = sub.add_parser("doctor", help="check prerequisites without changing them")
253
+ doctor.add_argument("--json", action="store_true")
254
+ doctor.add_argument("--offline", action="store_true", help="skip GitHub network checks")
255
+ doctor.set_defaults(handler=_doctor)
256
+
257
+ setup = sub.add_parser("setup", help="create state and configured GitHub labels")
258
+ setup.add_argument("--dry-run", action="store_true")
259
+ setup.add_argument("--json", action="store_true")
260
+ setup.set_defaults(handler=_setup)
261
+
262
+ status = sub.add_parser("status", help="reconcile and report repository lifecycle state")
263
+ status.add_argument("--json", action="store_true")
264
+ status.set_defaults(handler=_status)
265
+
266
+ install_scheduler = sub.add_parser(
267
+ "install-scheduler", help="install native per-user loop timers"
268
+ )
269
+ install_scheduler.add_argument("--dry-run", action="store_true")
270
+ install_scheduler.add_argument("--output", type=Path, help="render without enabling")
271
+ install_scheduler.add_argument("--json", action="store_true")
272
+ install_scheduler.set_defaults(handler=_install_scheduler)
273
+
274
+ uninstall_scheduler = sub.add_parser(
275
+ "uninstall-scheduler", help="remove native per-user loop timers"
276
+ )
277
+ uninstall_scheduler.add_argument("--dry-run", action="store_true")
278
+ uninstall_scheduler.add_argument("--output", type=Path, help="remove rendered files here")
279
+ uninstall_scheduler.add_argument("--json", action="store_true")
280
+ uninstall_scheduler.set_defaults(handler=_uninstall_scheduler)
281
+
282
+ scheduler_status = sub.add_parser(
283
+ "scheduler-status", help="report native timer installation state"
284
+ )
285
+ scheduler_status.add_argument("--json", action="store_true")
286
+ scheduler_status.set_defaults(handler=_scheduler_status)
287
+
288
+ config = sub.add_parser("config", help="inspect or migrate configuration")
289
+ config_sub = config.add_subparsers(dest="config_command", required=True)
290
+ migrate = config_sub.add_parser("migrate", help="migrate an unversioned config")
291
+ migrate.add_argument("path", type=Path)
292
+ migrate.set_defaults(handler=_migrate_config)
293
+
294
+ run = sub.add_parser("run", help="one iteration of a loop")
295
+ run.add_argument("loop", help="which loop, by its [loop.*] name")
296
+ run.add_argument(
297
+ "--dry-run",
298
+ action="store_true",
299
+ help="audit, classify and review for real; stop before publishing",
300
+ )
301
+ run.set_defaults(handler=_run)
302
+
303
+ resume = sub.add_parser("resume", help="answer a parked draft and continue that thread")
304
+ resume.add_argument("thread", help="the thread id the parked run reported")
305
+ resume.add_argument("answer", choices=("merge", "close"))
306
+ resume.set_defaults(handler=_resume)
307
+
308
+ graph = sub.add_parser("graph", help="draw the graph")
309
+ graph.add_argument("--write", action="store_true", help=f"regenerate {visualise.DIAGRAM}")
310
+ graph.add_argument(
311
+ "--check", action="store_true", help="fail if the committed diagram is stale"
312
+ )
313
+ graph.add_argument(
314
+ "--ascii", action="store_true", help="ASCII instead of Mermaid (needs grandalf)"
315
+ )
316
+ graph.set_defaults(handler=_graph)
317
+
318
+ args = parser.parse_args(argv)
319
+ try:
320
+ return int(args.handler(args))
321
+ except ConfigError as exc:
322
+ print(f"touchstone: {exc}", file=sys.stderr)
323
+ return 78 # EX_CONFIG, the same code launchd uses for a job it cannot start
324
+
325
+
326
+ if __name__ == "__main__":
327
+ raise SystemExit(main())