pantheon-opencode 1.0.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.
Files changed (128) hide show
  1. package/AGENTS.md +37 -0
  2. package/LICENSE +21 -0
  3. package/README.md +1013 -0
  4. package/bin/pantheon-init.mjs +183 -0
  5. package/commands/pantheon-audit.md +25 -0
  6. package/commands/pantheon-bg.md +10 -0
  7. package/commands/pantheon-consolidate.md +11 -0
  8. package/commands/pantheon-deepwork.md +128 -0
  9. package/commands/pantheon-doc.md +10 -0
  10. package/commands/pantheon-focus.md +9 -0
  11. package/commands/pantheon-forget.md +58 -0
  12. package/commands/pantheon-hash.md +11 -0
  13. package/commands/pantheon-optimize.md +79 -0
  14. package/commands/pantheon-remember.md +44 -0
  15. package/commands/pantheon-search.md +48 -0
  16. package/commands/pantheon-status.md +71 -0
  17. package/commands/pantheon-todo.md +11 -0
  18. package/commands/pantheon.md +49 -0
  19. package/docs/AGENT-MCP.md +194 -0
  20. package/docs/ARCHITECTURE.md +384 -0
  21. package/docs/BRANCH-PROTECTION.md +142 -0
  22. package/docs/INDEX.md +81 -0
  23. package/docs/INSTALLATION.md +217 -0
  24. package/docs/MCP.md +238 -0
  25. package/docs/MEMORY.md +471 -0
  26. package/docs/MIGRATION-MEMORY-BANK.md +139 -0
  27. package/docs/PLATFORMS.md +5 -0
  28. package/docs/QUICKSTART.md +49 -0
  29. package/docs/README.md +18 -0
  30. package/docs/RELEASING.md +256 -0
  31. package/docs/SETUP.md +5 -0
  32. package/docs/UPGRADING.md +41 -0
  33. package/docs/mcp-recommendations.md +439 -0
  34. package/docs/mcp-tools.md +156 -0
  35. package/docs/mcp-user-guide.md +204 -0
  36. package/docs/persistence-mcp.md +111 -0
  37. package/package.json +72 -0
  38. package/pantheon.schema.json +158 -0
  39. package/scripts/__init__.py +0 -0
  40. package/scripts/_pantheon_paths.py +68 -0
  41. package/scripts/check-version-consistency.sh +23 -0
  42. package/scripts/code_mode_server.py +202 -0
  43. package/scripts/doctor.mjs +763 -0
  44. package/scripts/generate-prompts.sh +222 -0
  45. package/scripts/generate-routing-docs.mjs +104 -0
  46. package/scripts/hash_verify.py +192 -0
  47. package/scripts/init-pantheon-mcp.sh +118 -0
  48. package/scripts/install/agents-md.mjs +214 -0
  49. package/scripts/install/health-check.mjs +196 -0
  50. package/scripts/install/migrate.mjs +209 -0
  51. package/scripts/install/opencode.mjs +645 -0
  52. package/scripts/install/shared.mjs +655 -0
  53. package/scripts/install/venv.mjs +116 -0
  54. package/scripts/install-mcp.mjs +885 -0
  55. package/scripts/install.mjs +26 -0
  56. package/scripts/manifest.mjs +622 -0
  57. package/scripts/mcp_persistence_server.py +459 -0
  58. package/scripts/mcp_resources_server.py +206 -0
  59. package/scripts/memory_cache.py +78 -0
  60. package/scripts/memory_mcp_server.py +605 -0
  61. package/scripts/paths.py +64 -0
  62. package/scripts/prune_context.py +72 -0
  63. package/scripts/release-bundle.mjs +109 -0
  64. package/scripts/scrub-secrets.py +282 -0
  65. package/scripts/scrub_secrets.py +281 -0
  66. package/scripts/test-context-compression.sh +166 -0
  67. package/scripts/themis_heuristic_scan.py +287 -0
  68. package/scripts/todo_enforcer.py +242 -0
  69. package/scripts/uninstall.mjs +1057 -0
  70. package/scripts/validate-routing.mjs +160 -0
  71. package/scripts/validate_agent_frontmatter.py +226 -0
  72. package/scripts/versioning.mjs +254 -0
  73. package/skills-lock.json +16 -0
  74. package/src/agents/aphrodite.md +162 -0
  75. package/src/agents/apollo.md +109 -0
  76. package/src/agents/athena.md +226 -0
  77. package/src/agents/demeter.md +146 -0
  78. package/src/agents/gaia.md +82 -0
  79. package/src/agents/hephaestus.md +105 -0
  80. package/src/agents/hermes.md +302 -0
  81. package/src/agents/iris.md +99 -0
  82. package/src/agents/mnemosyne.md +226 -0
  83. package/src/agents/nyx.md +87 -0
  84. package/src/agents/prometheus.md +199 -0
  85. package/src/agents/talos.md +93 -0
  86. package/src/agents/themis.md +187 -0
  87. package/src/agents/zeus.md +209 -0
  88. package/src/instructions/agent-return-format.instructions.md +26 -0
  89. package/src/instructions/backend-standards.instructions.md +45 -0
  90. package/src/instructions/documentation-standards.instructions.md +53 -0
  91. package/src/instructions/frontend-standards.instructions.md +46 -0
  92. package/src/instructions/memory-protocol.instructions.md +67 -0
  93. package/src/instructions/yagni.instructions.md +21 -0
  94. package/src/instructions/zeus-anti-stall.instructions.md +72 -0
  95. package/src/instructions/zeus-communication-rules.instructions.md +15 -0
  96. package/src/instructions/zeus-council-synthesis.instructions.md +105 -0
  97. package/src/instructions/zeus-timeout-retry.instructions.md +127 -0
  98. package/src/mcp/_pantheon_paths.py +67 -0
  99. package/src/mcp/code_mode_server.py +202 -0
  100. package/src/mcp/init-pantheon-mcp.sh +118 -0
  101. package/src/mcp/install-mcp.mjs +885 -0
  102. package/src/mcp/mcp_persistence_server.py +458 -0
  103. package/src/mcp/mcp_resources_server.py +205 -0
  104. package/src/mcp/memory_mcp_server.py +604 -0
  105. package/src/mcp/requirements-mcp-core.txt +5 -0
  106. package/src/mcp/requirements-mcp.txt +5 -0
  107. package/src/plugin.ts +19 -0
  108. package/src/plugins/tui/dist/tui.js +143 -0
  109. package/src/plugins/tui/dist/tui.tsx +144 -0
  110. package/src/plugins/tui/package.json +32 -0
  111. package/src/plugins/tui/src/index.tsx +144 -0
  112. package/src/plugins/tui/tsdown.config.ts +22 -0
  113. package/src/routing.yml +499 -0
  114. package/src/skills/README.md +230 -0
  115. package/src/skills/agent-coordination/SKILL.md +95 -0
  116. package/src/skills/artifact-management/SKILL.md +118 -0
  117. package/src/skills/auto-continue/SKILL.md +280 -0
  118. package/src/skills/code-review-checklist/SKILL.md +139 -0
  119. package/src/skills/context-compression/SKILL.md +861 -0
  120. package/src/skills/git-workflow-and-versioning/SKILL.md +32 -0
  121. package/src/skills/incremental-implementation/SKILL.md +27 -0
  122. package/src/skills/memory-bank/SKILL.md +165 -0
  123. package/src/skills/orchestration-workflow/SKILL.md +311 -0
  124. package/src/skills/security-hardening/SKILL.md +36 -0
  125. package/src/skills/session-goal/SKILL.md +138 -0
  126. package/src/skills/spec-driven-development/SKILL.md +23 -0
  127. package/src/skills/tdd-with-agents/SKILL.md +170 -0
  128. package/src/skills/visual-review-pipeline/SKILL.md +200 -0
@@ -0,0 +1,287 @@
1
+ #!/usr/bin/env python3
2
+ """Themis Layer 1 — Heuristic Scanner.
3
+ Zero LLM tokens. Roda em <2s. Retorna score 0-100 + blocking verdict.
4
+
5
+ Usage: python3 scripts/themis_heuristic_scan.py [--path=<dir>] [--diff-only]
6
+ """
7
+
8
+ import argparse
9
+ import re
10
+ import subprocess
11
+ import sys
12
+ from pathlib import Path
13
+
14
+ EXCLUDE_DIRS = {
15
+ "node_modules",
16
+ ".pantheon",
17
+ ".git",
18
+ "__pycache__",
19
+ ".venv",
20
+ "venv",
21
+ ".mypy_cache",
22
+ ".ruff_cache",
23
+ ".hypothesis",
24
+ }
25
+
26
+ RUFF_ISSUE_THRESHOLD = 500
27
+ SLOP_PATTERN_THRESHOLD = 10
28
+
29
+ # 20 anti-patterns de IA slop
30
+ SLOP_PATTERNS = [
31
+ (
32
+ r"// This (function|method|class) (is used|is responsible|handles)",
33
+ "obvious comment",
34
+ ),
35
+ (
36
+ r"# (This|A) (function|method|class) (is used|is responsible|handles)",
37
+ "obvious comment",
38
+ ),
39
+ (r"// TODO: (fix|implement|add|remove)", "generic TODO"),
40
+ (r"# TODO (fix|implement|add)", "generic TODO"),
41
+ (r"// (Getter|Setter) for", "obvious getter/setter"),
42
+ (r"# (Getter|Setter) for", "obvious getter/setter"),
43
+ (r"// (Private|Protected|Public) (method|field|helper)", "obvious access"),
44
+ (r"// (Initialize|Cleanup)", "obvious init/cleanup"),
45
+ (r"# (Initialize|Cleanup)", "obvious init/cleanup"),
46
+ (r"// Return (the|a|true|false)", "obvious return"),
47
+ (r'""" ?(This|A) (module|function|class)', "generic docstring"),
48
+ (
49
+ r'<input.*type=["](text|number)["].*/>',
50
+ "native input type available (datetime, color, etc.)",
51
+ ),
52
+ (r"import (date-fns|moment|dayjs|luxon)", "Intl.DateTimeFormat natively available"),
53
+ (
54
+ r"import (react-datepicker|flatpickr|react-calendar)",
55
+ '<input type="date"> natively available',
56
+ ),
57
+ (
58
+ r"import (react-color|react-colorful|color-picker)",
59
+ '<input type="color"> natively available',
60
+ ),
61
+ (r"import (react-slider|rc-slider)", '<input type="range"> natively available'),
62
+ (r"import (axios|superagent|got)", "fetch() natively available"),
63
+ (
64
+ r"import (lodash|underscore)",
65
+ "modern JS stdlib covers this (Array.toSorted, Object.groupBy, etc.)",
66
+ ),
67
+ (
68
+ r"import (clsx|classnames)",
69
+ "`template literals` or className join natively available",
70
+ ),
71
+ (
72
+ r".css$|.scss$|.less$",
73
+ "Tailwind or CSS Modules already available — avoid new CSS files",
74
+ ),
75
+ (
76
+ r"new Date\(\)",
77
+ "use Intl.DateTimeFormat for formatting, not manual Date methods",
78
+ ),
79
+ (r"function.*\(\) \{$", "prefer one-liner arrow functions"),
80
+ ]
81
+
82
+ Score = int
83
+ Verdict = str # "APPROVED" | "BLOCKING"
84
+
85
+
86
+ class Scanner:
87
+ def __init__(self, target: str, diff_only: bool = False):
88
+ self.target = Path(target).resolve()
89
+ self.diff_only = diff_only
90
+ self.score: int = 100
91
+ self.report: list[str] = []
92
+ self.blocking: bool = False
93
+
94
+ def deduct(self, points: int) -> None:
95
+ self.score = max(0, self.score - points)
96
+
97
+ def find_files(self, *exts: str) -> list[Path]:
98
+ files = []
99
+ for ext in exts:
100
+ for f in self.target.rglob(f"*.{ext}"):
101
+ if not any(p in f.parts for p in EXCLUDE_DIRS):
102
+ files.append(f)
103
+ return files[:20] # limit to 20 files for speed
104
+
105
+ def run_ruff(self) -> None:
106
+ files = self.find_files("py")
107
+ if not files:
108
+ self.report.append(" ⏭️ ruff: no Python files")
109
+ return
110
+ try:
111
+ result = subprocess.run(
112
+ [
113
+ "ruff",
114
+ "check",
115
+ "--select",
116
+ "F,E,W,I,N,UP,B,SIM,PL,RUF",
117
+ "--output-format",
118
+ "concise",
119
+ *[str(f) for f in files],
120
+ ],
121
+ capture_output=True,
122
+ text=True,
123
+ timeout=10,
124
+ check=False,
125
+ )
126
+ except (subprocess.TimeoutExpired, FileNotFoundError):
127
+ self.report.append(" ⚠️ ruff: not available or timeout")
128
+ self.deduct(5)
129
+ return
130
+
131
+ if result.returncode == 0:
132
+ self.report.append(" ✅ ruff: clean")
133
+ else:
134
+ count = len([line for line in result.stdout.split("\n") if line.strip()])
135
+ self.report.append(f" ⚠️ ruff: {count} issues")
136
+ if count > RUFF_ISSUE_THRESHOLD:
137
+ self.blocking = True
138
+ self.deduct(15)
139
+
140
+ def run_biome(self) -> None:
141
+ files = self.find_files("ts", "tsx", "js", "jsx")
142
+ if not files:
143
+ self.report.append(" ⏭️ biome: no JS/TS files")
144
+ return
145
+ try:
146
+ result = subprocess.run(
147
+ [
148
+ "npx",
149
+ "biome",
150
+ "check",
151
+ "--write",
152
+ "--unsafe",
153
+ *[str(f) for f in files],
154
+ ],
155
+ capture_output=True,
156
+ text=True,
157
+ timeout=15,
158
+ check=False,
159
+ )
160
+ except (subprocess.TimeoutExpired, FileNotFoundError):
161
+ self.report.append(" ⏭️ biome: not available")
162
+ return
163
+
164
+ if result.returncode == 0 or "safe fixes" in result.stdout:
165
+ self.report.append(" ✅ biome: clean or auto-fixed")
166
+ else:
167
+ self.report.append(" ⚠️ biome: issues found")
168
+ # self.deduct(10) # biome internal bug, not our code
169
+
170
+ def run_antipattern(self) -> None:
171
+ hits = 0
172
+ for ext in ("py", "ts", "tsx", "js", "jsx"):
173
+ for f in self.find_files(ext):
174
+ try:
175
+ content = f.read_text(errors="ignore")
176
+ for pat, label in SLOP_PATTERNS:
177
+ if re.search(pat, content):
178
+ hits += 1
179
+ self.report.append(f" 🧹 slop: {label} → {f.name}")
180
+ except (OSError, UnicodeDecodeError):
181
+ continue
182
+
183
+ if hits == 0:
184
+ self.report.append(" ✅ anti-pattern: clean")
185
+ else:
186
+ self.report.append(f" 🧹 anti-pattern: {hits} slop patterns")
187
+ self.deduct(hits * 2)
188
+ if hits > SLOP_PATTERN_THRESHOLD:
189
+ self.blocking = True
190
+
191
+ def run_hash_verify(self) -> None:
192
+ """Verify that staged edits actually changed the file."""
193
+ try:
194
+ result = subprocess.run(
195
+ ["git", "diff", "--cached", "--name-only"],
196
+ capture_output=True,
197
+ text=True,
198
+ timeout=5,
199
+ check=False,
200
+ )
201
+ except (subprocess.TimeoutExpired, FileNotFoundError):
202
+ self.report.append(" ⏭️ hash-verify: git not available")
203
+ return
204
+
205
+ staged = [f for f in result.stdout.strip().split("\n") if f][:10]
206
+ if not staged:
207
+ self.report.append(" ⏭️ hash-verify: no staged files")
208
+ return
209
+
210
+ failed = 0
211
+ for f in staged:
212
+ if not Path(f).exists():
213
+ continue
214
+ try:
215
+ diff = subprocess.run(
216
+ ["git", "diff", "--cached", f],
217
+ capture_output=True,
218
+ text=True,
219
+ timeout=5,
220
+ check=False,
221
+ )
222
+ before = len(
223
+ [
224
+ line
225
+ for line in diff.stdout.split("\n")
226
+ if line.startswith("-") and not line.startswith("---")
227
+ ]
228
+ )
229
+ after = len(
230
+ [
231
+ line
232
+ for line in diff.stdout.split("\n")
233
+ if line.startswith("+") and not line.startswith("+++")
234
+ ]
235
+ )
236
+ except (subprocess.TimeoutExpired, FileNotFoundError):
237
+ continue
238
+
239
+ if before == 0 and after == 0:
240
+ failed += 1
241
+ self.report.append(f" ⚠️ hash-verify: {f} — no change detected")
242
+
243
+ if failed == 0:
244
+ self.report.append(" ✅ hash-verify: all edits verified")
245
+ else:
246
+ self.report.append(f" ⚠️ hash-verify: {failed} files with failed edits")
247
+ self.blocking = True
248
+ self.deduct(20)
249
+
250
+ def run(self) -> tuple[int, str]:
251
+ print("🔍 Themis Heuristic Scan")
252
+ print("━━━━━━━━━━━━━━━━━━━━━━━")
253
+
254
+ self.run_ruff()
255
+ self.run_biome()
256
+ self.run_antipattern()
257
+ self.run_hash_verify()
258
+
259
+ verdict = "BLOCKING" if self.score < 60 else "APPROVED" # noqa: PLR2004
260
+ print("━━━━━━━━━━━━━━━━━━━━━━━")
261
+ for line in self.report:
262
+ print(line)
263
+ print("━━━━━━━━━━━━━━━━━━━━━━━")
264
+ print(f"Score: {self.score}/100 | Verdict: {verdict}")
265
+
266
+ if self.score < 60: # noqa: PLR2004
267
+ print("\033[31m→ BLOCKING: issues found\033[0m")
268
+ return 1
269
+ else:
270
+ print("\033[32m→ Passed: ready for Layer 2\033[0m")
271
+ return 0
272
+
273
+
274
+ def main() -> None:
275
+ parser = argparse.ArgumentParser(description="Themis Layer 1 Heuristic Scanner")
276
+ parser.add_argument("--path", default=".", help="Target directory")
277
+ parser.add_argument(
278
+ "--diff-only", action="store_true", help="Only check staged diffs"
279
+ )
280
+ args = parser.parse_args()
281
+
282
+ scanner = Scanner(args.path, args.diff_only)
283
+ sys.exit(scanner.run())
284
+
285
+
286
+ if __name__ == "__main__":
287
+ main()
@@ -0,0 +1,242 @@
1
+ #!/usr/bin/env python3
2
+ """TODO Enforcer — Idle detection and recovery for Pantheon agents.
3
+
4
+ Monitors agent tool-call activity and enforces timeouts:
5
+ - Idle > 60s → log warning
6
+ - Idle > 120s → force continue with task prompt
7
+ - Idle > 300s → auto-save checkpoint and pause
8
+
9
+ Usage:
10
+ python3 scripts/todo_enforcer.py <task> [--timeout-warn=60] [--timeout-force=120] [--timeout-pause=300]
11
+ python3 scripts/todo_enforcer.py --status # Show current enforcer state
12
+ python3 scripts/todo_enforcer.py --reset # Reset idle timer
13
+ """
14
+
15
+ import argparse
16
+ import json
17
+ import sys
18
+ import time
19
+ from pathlib import Path
20
+
21
+ ENFORCER_DIR = Path(".pantheon/todo-enforcer")
22
+ STATE_FILE = ENFORCER_DIR / "state.json"
23
+
24
+
25
+ class TodoEnforcer:
26
+ """Stateful idle monitor for agent sessions."""
27
+
28
+ def __init__(
29
+ self,
30
+ task: str,
31
+ timeout_warn: int = 60,
32
+ timeout_force: int = 120,
33
+ timeout_pause: int = 300,
34
+ max_force_continues: int = 3,
35
+ ):
36
+ self.task = task
37
+ self.timeout_warn = timeout_warn
38
+ self.timeout_force = timeout_force
39
+ self.timeout_pause = timeout_pause
40
+ self.max_force_continues = max_force_continues
41
+ self._ensure_dir()
42
+
43
+ # ------------------------------------------------------------------
44
+ # Public API
45
+ # ------------------------------------------------------------------
46
+
47
+ def tick(self) -> dict:
48
+ """Record a tool-call heartbeat (resets idle timer)."""
49
+ state = self._load()
50
+ state["last_tool_call"] = time.time()
51
+ state["task"] = self.task
52
+ state["warnings_sent"] = state.get("warnings_sent", [])
53
+ state["forced_continues"] = state.get("forced_continues", 0)
54
+ state["paused"] = False
55
+ self._save(state)
56
+ return {"status": "ok", "idle": 0.0}
57
+
58
+ def check(self) -> dict:
59
+ """Check idle duration and return action if needed."""
60
+ state = self._load()
61
+ if state.get("paused"):
62
+ return {"status": "paused", "action": None}
63
+
64
+ last_call = state.get("last_tool_call")
65
+ if last_call is None:
66
+ return {"status": "no_data", "action": None}
67
+
68
+ idle = time.time() - last_call
69
+ action = None
70
+
71
+ if idle >= self.timeout_pause:
72
+ action = self._build_action("pause", idle)
73
+ state["paused"] = True
74
+ state["stop_reason"] = "timeout_pause"
75
+ elif idle >= self.timeout_force:
76
+ fc_count = state.get("forced_continues", 0)
77
+ if fc_count >= self.max_force_continues:
78
+ action = self._build_action("pause", idle)
79
+ state["paused"] = True
80
+ state["stop_reason"] = "max_force_continues"
81
+ else:
82
+ action = self._build_action("force_continue", idle)
83
+ state["forced_continues"] = fc_count + 1
84
+ elif idle >= self.timeout_warn:
85
+ action = self._build_action("warn", idle)
86
+ if "warn" not in state.get("warnings_sent", []):
87
+ state.setdefault("warnings_sent", []).append("warn")
88
+
89
+ self._save(state)
90
+ return {
91
+ "status": "idle" if idle > 0 else "active",
92
+ "idle": idle,
93
+ "action": action,
94
+ }
95
+
96
+ def reset(self) -> dict:
97
+ """Reset idle timer and unpause."""
98
+ state = self._load()
99
+ state["last_tool_call"] = time.time()
100
+ state["paused"] = False
101
+ state["warnings_sent"] = []
102
+ state["forced_continues"] = 0
103
+ state["stop_reason"] = None
104
+ self._save(state)
105
+ return {"status": "reset", "idle": 0.0}
106
+
107
+ def status(self) -> dict:
108
+ """Return current state without side effects."""
109
+ state = self._load()
110
+ idle = time.time() - state.get("last_tool_call", time.time())
111
+ return {
112
+ "task": state.get("task", self.task),
113
+ "idle": round(idle, 1),
114
+ "paused": state.get("paused", False),
115
+ "forced_continues": state.get("forced_continues", 0),
116
+ "stop_reason": state.get("stop_reason"),
117
+ "timeout_warn": self.timeout_warn,
118
+ "timeout_force": self.timeout_force,
119
+ "timeout_pause": self.timeout_pause,
120
+ }
121
+
122
+ def pause(self) -> dict:
123
+ """Pause the enforcer (mark session stopped)."""
124
+ state = self._load()
125
+ state["paused"] = True
126
+ state["stop_reason"] = "manual_pause"
127
+ self._save(state)
128
+ return {"status": "paused"}
129
+
130
+ # ------------------------------------------------------------------
131
+ # Internal helpers
132
+ # ------------------------------------------------------------------
133
+
134
+ def _build_action(self, level: str, idle: float) -> dict:
135
+ prompts = {
136
+ "warn": f"[TODO ENFORCER] Agent idle for {idle:.0f}s. Continuing task: {self.task}",
137
+ "force_continue": f"[TODO ENFORCER] FORCE CONTINUE — Agent idle for {idle:.0f}s. Resume work on: {self.task}",
138
+ "pause": f"[TODO ENFORCER] AUTO-PAUSE — Agent idle for {idle:.0f}s. Session paused due to idle timeout.",
139
+ }
140
+ return {
141
+ "level": level,
142
+ "idle": round(idle, 1),
143
+ "message": prompts[level],
144
+ "checkpoint": level == "pause",
145
+ }
146
+
147
+ def _ensure_dir(self) -> None:
148
+ ENFORCER_DIR.mkdir(parents=True, exist_ok=True)
149
+
150
+ def _load(self) -> dict:
151
+ if STATE_FILE.exists():
152
+ try:
153
+ return json.loads(STATE_FILE.read_text())
154
+ except (json.JSONDecodeError, OSError):
155
+ pass
156
+ return {
157
+ "task": self.task,
158
+ "last_tool_call": time.time(),
159
+ "paused": False,
160
+ "warnings_sent": [],
161
+ "forced_continues": 0,
162
+ "stop_reason": None,
163
+ }
164
+
165
+ def _save(self, state: dict) -> None:
166
+ STATE_FILE.write_text(json.dumps(state, indent=2, ensure_ascii=False))
167
+
168
+
169
+ def main() -> None:
170
+ parser = argparse.ArgumentParser(
171
+ description="TODO Enforcer — idle detection and recovery for Pantheon agents."
172
+ )
173
+ parser.add_argument(
174
+ "task", nargs="?", default="", help="Task description for force-continue prompt"
175
+ )
176
+ parser.add_argument(
177
+ "--timeout-warn",
178
+ type=int,
179
+ default=60,
180
+ help="Idle seconds before warning (default: 60)",
181
+ )
182
+ parser.add_argument(
183
+ "--timeout-force",
184
+ type=int,
185
+ default=120,
186
+ help="Idle seconds before force continue (default: 120)",
187
+ )
188
+ parser.add_argument(
189
+ "--timeout-pause",
190
+ type=int,
191
+ default=300,
192
+ help="Idle seconds before auto-pause (default: 300)",
193
+ )
194
+ parser.add_argument(
195
+ "--status", action="store_true", help="Show current enforcer state"
196
+ )
197
+ parser.add_argument("--reset", action="store_true", help="Reset idle timer")
198
+ parser.add_argument(
199
+ "--tick", action="store_true", help="Record a heartbeat (tool call)"
200
+ )
201
+ parser.add_argument(
202
+ "--check", action="store_true", help="Check idle status and return action"
203
+ )
204
+ parser.add_argument("--pause", action="store_true", help="Pause the enforcer")
205
+ args = parser.parse_args()
206
+
207
+ enforcer = TodoEnforcer(
208
+ task=args.task or "unknown",
209
+ timeout_warn=args.timeout_warn,
210
+ timeout_force=args.timeout_force,
211
+ timeout_pause=args.timeout_pause,
212
+ )
213
+
214
+ if args.status:
215
+ result = enforcer.status()
216
+ elif args.reset:
217
+ result = enforcer.reset()
218
+ elif args.tick:
219
+ result = enforcer.tick()
220
+ elif args.check:
221
+ result = enforcer.check()
222
+ elif args.pause:
223
+ result = enforcer.pause()
224
+ else:
225
+ result = enforcer.check()
226
+
227
+ print(json.dumps(result, indent=2, ensure_ascii=False))
228
+
229
+ # Exit codes: 0=normal, 1=warn, 2=force, 3=pause
230
+ if isinstance(result, dict) and result.get("action"):
231
+ level = result["action"].get("level")
232
+ if level == "pause":
233
+ sys.exit(3)
234
+ elif level == "force_continue":
235
+ sys.exit(2)
236
+ elif level == "warn":
237
+ sys.exit(1)
238
+ sys.exit(0)
239
+
240
+
241
+ if __name__ == "__main__":
242
+ main()