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.
- package/AGENTS.md +37 -0
- package/LICENSE +21 -0
- package/README.md +1013 -0
- package/bin/pantheon-init.mjs +183 -0
- package/commands/pantheon-audit.md +25 -0
- package/commands/pantheon-bg.md +10 -0
- package/commands/pantheon-consolidate.md +11 -0
- package/commands/pantheon-deepwork.md +128 -0
- package/commands/pantheon-doc.md +10 -0
- package/commands/pantheon-focus.md +9 -0
- package/commands/pantheon-forget.md +58 -0
- package/commands/pantheon-hash.md +11 -0
- package/commands/pantheon-optimize.md +79 -0
- package/commands/pantheon-remember.md +44 -0
- package/commands/pantheon-search.md +48 -0
- package/commands/pantheon-status.md +71 -0
- package/commands/pantheon-todo.md +11 -0
- package/commands/pantheon.md +49 -0
- package/docs/AGENT-MCP.md +194 -0
- package/docs/ARCHITECTURE.md +384 -0
- package/docs/BRANCH-PROTECTION.md +142 -0
- package/docs/INDEX.md +81 -0
- package/docs/INSTALLATION.md +217 -0
- package/docs/MCP.md +238 -0
- package/docs/MEMORY.md +471 -0
- package/docs/MIGRATION-MEMORY-BANK.md +139 -0
- package/docs/PLATFORMS.md +5 -0
- package/docs/QUICKSTART.md +49 -0
- package/docs/README.md +18 -0
- package/docs/RELEASING.md +256 -0
- package/docs/SETUP.md +5 -0
- package/docs/UPGRADING.md +41 -0
- package/docs/mcp-recommendations.md +439 -0
- package/docs/mcp-tools.md +156 -0
- package/docs/mcp-user-guide.md +204 -0
- package/docs/persistence-mcp.md +111 -0
- package/package.json +72 -0
- package/pantheon.schema.json +158 -0
- package/scripts/__init__.py +0 -0
- package/scripts/_pantheon_paths.py +68 -0
- package/scripts/check-version-consistency.sh +23 -0
- package/scripts/code_mode_server.py +202 -0
- package/scripts/doctor.mjs +763 -0
- package/scripts/generate-prompts.sh +222 -0
- package/scripts/generate-routing-docs.mjs +104 -0
- package/scripts/hash_verify.py +192 -0
- package/scripts/init-pantheon-mcp.sh +118 -0
- package/scripts/install/agents-md.mjs +214 -0
- package/scripts/install/health-check.mjs +196 -0
- package/scripts/install/migrate.mjs +209 -0
- package/scripts/install/opencode.mjs +645 -0
- package/scripts/install/shared.mjs +655 -0
- package/scripts/install/venv.mjs +116 -0
- package/scripts/install-mcp.mjs +885 -0
- package/scripts/install.mjs +26 -0
- package/scripts/manifest.mjs +622 -0
- package/scripts/mcp_persistence_server.py +459 -0
- package/scripts/mcp_resources_server.py +206 -0
- package/scripts/memory_cache.py +78 -0
- package/scripts/memory_mcp_server.py +605 -0
- package/scripts/paths.py +64 -0
- package/scripts/prune_context.py +72 -0
- package/scripts/release-bundle.mjs +109 -0
- package/scripts/scrub-secrets.py +282 -0
- package/scripts/scrub_secrets.py +281 -0
- package/scripts/test-context-compression.sh +166 -0
- package/scripts/themis_heuristic_scan.py +287 -0
- package/scripts/todo_enforcer.py +242 -0
- package/scripts/uninstall.mjs +1057 -0
- package/scripts/validate-routing.mjs +160 -0
- package/scripts/validate_agent_frontmatter.py +226 -0
- package/scripts/versioning.mjs +254 -0
- package/skills-lock.json +16 -0
- package/src/agents/aphrodite.md +162 -0
- package/src/agents/apollo.md +109 -0
- package/src/agents/athena.md +226 -0
- package/src/agents/demeter.md +146 -0
- package/src/agents/gaia.md +82 -0
- package/src/agents/hephaestus.md +105 -0
- package/src/agents/hermes.md +302 -0
- package/src/agents/iris.md +99 -0
- package/src/agents/mnemosyne.md +226 -0
- package/src/agents/nyx.md +87 -0
- package/src/agents/prometheus.md +199 -0
- package/src/agents/talos.md +93 -0
- package/src/agents/themis.md +187 -0
- package/src/agents/zeus.md +209 -0
- package/src/instructions/agent-return-format.instructions.md +26 -0
- package/src/instructions/backend-standards.instructions.md +45 -0
- package/src/instructions/documentation-standards.instructions.md +53 -0
- package/src/instructions/frontend-standards.instructions.md +46 -0
- package/src/instructions/memory-protocol.instructions.md +67 -0
- package/src/instructions/yagni.instructions.md +21 -0
- package/src/instructions/zeus-anti-stall.instructions.md +72 -0
- package/src/instructions/zeus-communication-rules.instructions.md +15 -0
- package/src/instructions/zeus-council-synthesis.instructions.md +105 -0
- package/src/instructions/zeus-timeout-retry.instructions.md +127 -0
- package/src/mcp/_pantheon_paths.py +67 -0
- package/src/mcp/code_mode_server.py +202 -0
- package/src/mcp/init-pantheon-mcp.sh +118 -0
- package/src/mcp/install-mcp.mjs +885 -0
- package/src/mcp/mcp_persistence_server.py +458 -0
- package/src/mcp/mcp_resources_server.py +205 -0
- package/src/mcp/memory_mcp_server.py +604 -0
- package/src/mcp/requirements-mcp-core.txt +5 -0
- package/src/mcp/requirements-mcp.txt +5 -0
- package/src/plugin.ts +19 -0
- package/src/plugins/tui/dist/tui.js +143 -0
- package/src/plugins/tui/dist/tui.tsx +144 -0
- package/src/plugins/tui/package.json +32 -0
- package/src/plugins/tui/src/index.tsx +144 -0
- package/src/plugins/tui/tsdown.config.ts +22 -0
- package/src/routing.yml +499 -0
- package/src/skills/README.md +230 -0
- package/src/skills/agent-coordination/SKILL.md +95 -0
- package/src/skills/artifact-management/SKILL.md +118 -0
- package/src/skills/auto-continue/SKILL.md +280 -0
- package/src/skills/code-review-checklist/SKILL.md +139 -0
- package/src/skills/context-compression/SKILL.md +861 -0
- package/src/skills/git-workflow-and-versioning/SKILL.md +32 -0
- package/src/skills/incremental-implementation/SKILL.md +27 -0
- package/src/skills/memory-bank/SKILL.md +165 -0
- package/src/skills/orchestration-workflow/SKILL.md +311 -0
- package/src/skills/security-hardening/SKILL.md +36 -0
- package/src/skills/session-goal/SKILL.md +138 -0
- package/src/skills/spec-driven-development/SKILL.md +23 -0
- package/src/skills/tdd-with-agents/SKILL.md +170 -0
- 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()
|