context-guard-cli 2.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.
- context_guard/__init__.py +1 -0
- context_guard/_data/hosts/antigravity/hooks.snippet.json +16 -0
- context_guard/_data/hosts/antigravity/rules/context-guard.md +15 -0
- context_guard/_data/hosts/claude-code/commands/cg-continue.md +13 -0
- context_guard/_data/hosts/claude-code/commands/cg-new.md +11 -0
- context_guard/_data/hosts/claude-code/mcp.snippet.json +7 -0
- context_guard/_data/hosts/claude-code/settings.snippet.json +12 -0
- context_guard/_data/hosts/opencode/agent.snippet.json +7 -0
- context_guard/_data/hosts/opencode/commands/cg-continue.md +16 -0
- context_guard/_data/hosts/opencode/commands/cg-new.md +12 -0
- context_guard/_data/hosts/opencode/mcp.snippet.json +9 -0
- context_guard/_data/hosts/opencode/permissions.snippet.json +12 -0
- context_guard/_data/phases/execute.md +99 -0
- context_guard/_data/phases/plan.md +136 -0
- context_guard/_data/phases/verify.md +129 -0
- context_guard/guard/__init__.py +1 -0
- context_guard/guard/assets.py +94 -0
- context_guard/guard/cli.py +307 -0
- context_guard/guard/commands.py +811 -0
- context_guard/guard/errors.py +71 -0
- context_guard/guard/locking.py +181 -0
- context_guard/guard/manifest.py +69 -0
- context_guard/guard/migrate.py +288 -0
- context_guard/guard/paths.py +199 -0
- context_guard/guard/setup.py +476 -0
- context_guard/guard/transaction.py +403 -0
- context_guard/mcp_server.py +280 -0
- context_guard_cli-2.1.0.dist-info/METADATA +296 -0
- context_guard_cli-2.1.0.dist-info/RECORD +32 -0
- context_guard_cli-2.1.0.dist-info/WHEEL +4 -0
- context_guard_cli-2.1.0.dist-info/entry_points.txt +4 -0
- context_guard_cli-2.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,811 @@
|
|
|
1
|
+
"""Business logic for all guard CLI commands.
|
|
2
|
+
|
|
3
|
+
Every function returns a CommandResult or raises a GuardError.
|
|
4
|
+
No function calls sys.exit() — that's cli.py's job.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import shutil
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
|
|
11
|
+
from .paths import (
|
|
12
|
+
get_paths,
|
|
13
|
+
missing_session_result,
|
|
14
|
+
generate_agent_id,
|
|
15
|
+
get_archive_dir,
|
|
16
|
+
get_changes_dir,
|
|
17
|
+
list_changes,
|
|
18
|
+
validate_change_name,
|
|
19
|
+
TASK_LINE_RE,
|
|
20
|
+
MAX_ARTIFACT_CHARS,
|
|
21
|
+
)
|
|
22
|
+
from .manifest import load_manifest, save_manifest, create_initial_manifest
|
|
23
|
+
from .locking import with_write_lock, acquire
|
|
24
|
+
from .transaction import (
|
|
25
|
+
cmd_approve,
|
|
26
|
+
cmd_begin,
|
|
27
|
+
cmd_commit,
|
|
28
|
+
cmd_rollback,
|
|
29
|
+
cmd_checkpoint,
|
|
30
|
+
)
|
|
31
|
+
from .migrate import cmd_migrate
|
|
32
|
+
from .setup import (
|
|
33
|
+
antigravity_detected,
|
|
34
|
+
diverged_phases,
|
|
35
|
+
materialise_antigravity_rule,
|
|
36
|
+
materialise_phases,
|
|
37
|
+
run_setup,
|
|
38
|
+
)
|
|
39
|
+
from .errors import (
|
|
40
|
+
CommandResult,
|
|
41
|
+
EXIT_OK,
|
|
42
|
+
EXIT_LOCK_HELD,
|
|
43
|
+
EXIT_GENERIC,
|
|
44
|
+
EXIT_VALIDATION,
|
|
45
|
+
ValidationError,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
# How long a task claim stays valid without renewal. A claim that outlives its
|
|
49
|
+
# lease is treated as abandoned, so a crashed agent cannot hold a task forever.
|
|
50
|
+
DEFAULT_LEASE_SECONDS = 1800
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _claim_is_expired(claim):
|
|
54
|
+
"""True if a claim has outlived its lease.
|
|
55
|
+
|
|
56
|
+
An unparseable or missing timestamp counts as expired: a claim we cannot
|
|
57
|
+
date is a claim we cannot trust to be alive, and the alternative is the
|
|
58
|
+
permanent deadlock this lease exists to prevent.
|
|
59
|
+
"""
|
|
60
|
+
claimed_at = claim.get("claimed_at")
|
|
61
|
+
if not claimed_at:
|
|
62
|
+
return True
|
|
63
|
+
lease = claim.get("lease_seconds", DEFAULT_LEASE_SECONDS)
|
|
64
|
+
try:
|
|
65
|
+
elapsed = (datetime.now() - datetime.fromisoformat(claimed_at)).total_seconds()
|
|
66
|
+
except (ValueError, TypeError):
|
|
67
|
+
return True
|
|
68
|
+
return elapsed > lease
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _pid_from_agent_id(agent_id):
|
|
72
|
+
"""Extract the PID from an agent_id of the form '{pid}-{host}-{ts}'.
|
|
73
|
+
|
|
74
|
+
agent_id is free-form — callers may pass anything — so this returns None
|
|
75
|
+
rather than raising when the leading token is not a PID.
|
|
76
|
+
"""
|
|
77
|
+
if not agent_id or not isinstance(agent_id, str):
|
|
78
|
+
return None
|
|
79
|
+
head = agent_id.split("-", 1)[0]
|
|
80
|
+
try:
|
|
81
|
+
return int(head)
|
|
82
|
+
except ValueError:
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _pid_is_alive(pid):
|
|
87
|
+
try:
|
|
88
|
+
os.kill(pid, 0)
|
|
89
|
+
return True
|
|
90
|
+
except OSError:
|
|
91
|
+
return False
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# ---------------------------------------------------------------------------
|
|
95
|
+
# Changes
|
|
96
|
+
# ---------------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
def cmd_setup(host="all", with_mcp=False, project=None, no_hooks=False):
|
|
99
|
+
"""Install the host adapters. See guard/setup.py for the scope rules."""
|
|
100
|
+
return run_setup(host=host, with_mcp=with_mcp, project=project,
|
|
101
|
+
no_hooks=no_hooks)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def cmd_new(context, change, host=None):
|
|
105
|
+
"""Crea un change nuevo y deja la fase PLAN iniciada.
|
|
106
|
+
|
|
107
|
+
Beginning PLAN here rather than leaving it to a separate call is
|
|
108
|
+
deliberate: a change created but not begun sits at lock_phase=PLAN with
|
|
109
|
+
no transaction open, so nothing yet enforces the pipeline and the agent
|
|
110
|
+
has to remember one more step. Remembered steps are what F1 showed to be
|
|
111
|
+
unreliable.
|
|
112
|
+
|
|
113
|
+
Refuses to touch an existing change rather than reinitialising it — a
|
|
114
|
+
`new` that silently reset a manifest would discard work whose whole
|
|
115
|
+
purpose is to survive.
|
|
116
|
+
"""
|
|
117
|
+
name = validate_change_name(change)
|
|
118
|
+
p = get_paths(context, name)
|
|
119
|
+
|
|
120
|
+
if os.path.exists(p["manifest"]):
|
|
121
|
+
return CommandResult(
|
|
122
|
+
f"FAIL|CHANGE_EXISTS|{name}",
|
|
123
|
+
EXIT_VALIDATION,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
os.makedirs(p["base"], exist_ok=True)
|
|
127
|
+
save_manifest(context, create_initial_manifest(context, name), name)
|
|
128
|
+
|
|
129
|
+
# cmd_begin scaffolds the PLAN artifacts itself.
|
|
130
|
+
begin = cmd_begin(context, "PLAN", change=name)
|
|
131
|
+
if begin.exit_code != EXIT_OK:
|
|
132
|
+
return begin
|
|
133
|
+
|
|
134
|
+
# The installed slash commands point at .context-guard/phases/*.md, so a
|
|
135
|
+
# project only becomes operable once those exist. Writing them here is
|
|
136
|
+
# what lets a global `cg setup` work in a project nobody prepared.
|
|
137
|
+
materialise_phases(context)
|
|
138
|
+
if host == "antigravity" or (host is None and antigravity_detected()):
|
|
139
|
+
materialise_antigravity_rule(context)
|
|
140
|
+
|
|
141
|
+
return CommandResult(f"SUCCESS|CHANGE_CREATED|{name}|phase=PLAN", EXIT_OK)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def cmd_list(context):
|
|
145
|
+
"""Lista los changes activos con su fase actual.
|
|
146
|
+
|
|
147
|
+
Ordering here is for human display only; resolve_change never uses it to
|
|
148
|
+
pick a change implicitly.
|
|
149
|
+
"""
|
|
150
|
+
names = list_changes(context)
|
|
151
|
+
if not names:
|
|
152
|
+
return CommandResult("NONE|NO_ACTIVE_CHANGES", EXIT_OK)
|
|
153
|
+
|
|
154
|
+
lines = []
|
|
155
|
+
for name in names:
|
|
156
|
+
m = load_manifest(context, name)
|
|
157
|
+
if not m:
|
|
158
|
+
lines.append(f"{name}|(no manifest)")
|
|
159
|
+
continue
|
|
160
|
+
lock = m.get("lock", {})
|
|
161
|
+
lines.append(
|
|
162
|
+
f"{name}|lock_phase={m.get('lock_phase', 'PLAN')}"
|
|
163
|
+
f"|session_lock={'HELD' if lock.get('held') else 'FREE'}"
|
|
164
|
+
)
|
|
165
|
+
return CommandResult("\n".join(lines), EXIT_OK)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
# ---------------------------------------------------------------------------
|
|
169
|
+
# Sesión
|
|
170
|
+
# ---------------------------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
def cmd_check_lock(context, change=None):
|
|
173
|
+
"""Solo lectura — para mostrar estado al desarrollador. NO usar como gate
|
|
174
|
+
antes de acquire/claim: usar `claim` directamente evita la carrera de
|
|
175
|
+
secuenciar dos llamadas separadas."""
|
|
176
|
+
m = load_manifest(context, change)
|
|
177
|
+
if not m or not m.get("lock", {}).get("held", False):
|
|
178
|
+
return CommandResult("FREE", EXIT_OK)
|
|
179
|
+
|
|
180
|
+
acquired = datetime.fromisoformat(m["lock"]["acquired_at"])
|
|
181
|
+
elapsed = int((datetime.now() - acquired).total_seconds())
|
|
182
|
+
ttl = m["lock"].get("ttl_seconds", 1800)
|
|
183
|
+
|
|
184
|
+
if elapsed > ttl:
|
|
185
|
+
msg = f"STALE|{elapsed}|{ttl}"
|
|
186
|
+
else:
|
|
187
|
+
msg = f"ACTIVE|{elapsed}|{ttl}|{m['lock'].get('acquired_by')}"
|
|
188
|
+
return CommandResult(msg, EXIT_OK)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def cmd_claim(context, ttl, change=None):
|
|
192
|
+
"""Un solo comando: check + acquire atómico. Reemplaza la secuencia
|
|
193
|
+
check-lock → acquire del protocolo viejo, que dependía de que el modelo
|
|
194
|
+
encadenara bien dos llamadas."""
|
|
195
|
+
def _do():
|
|
196
|
+
return acquire(context, ttl, change)
|
|
197
|
+
return with_write_lock(context, _do, change=change)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def cmd_release(context, agent_id=None, force=False, change=None):
|
|
201
|
+
"""Libera el lock de sesión, validando ownership.
|
|
202
|
+
|
|
203
|
+
An anonymous release is an error: ownership that is only checked when the
|
|
204
|
+
caller volunteers its identity is not ownership at all. `force` remains
|
|
205
|
+
available for genuine deadlocks and is recorded in the manifest.
|
|
206
|
+
"""
|
|
207
|
+
if not agent_id and not force:
|
|
208
|
+
return CommandResult(
|
|
209
|
+
"FAIL|AGENT_ID_REQUIRED|pass --agent-id, or --force to override",
|
|
210
|
+
EXIT_VALIDATION,
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
def _do():
|
|
214
|
+
p = get_paths(context, change)
|
|
215
|
+
m = load_manifest(context, change)
|
|
216
|
+
if m and "lock" in m:
|
|
217
|
+
owner = m["lock"].get("acquired_by")
|
|
218
|
+
if owner and agent_id and not force and owner != agent_id:
|
|
219
|
+
return CommandResult(
|
|
220
|
+
f"FAIL|OWNERSHIP_MISMATCH|session|owner={owner}",
|
|
221
|
+
EXIT_LOCK_HELD,
|
|
222
|
+
)
|
|
223
|
+
m["lock"]["held"] = False
|
|
224
|
+
m["lock"]["acquired_at"] = None
|
|
225
|
+
m["lock"]["acquired_by"] = None
|
|
226
|
+
if force:
|
|
227
|
+
m["lock"]["force_released_at"] = datetime.now().isoformat()
|
|
228
|
+
m["lock"]["force_released_by"] = agent_id
|
|
229
|
+
save_manifest(context, m, change)
|
|
230
|
+
if os.path.exists(p["lock"]):
|
|
231
|
+
os.remove(p["lock"])
|
|
232
|
+
return CommandResult("SUCCESS|LOCK_RELEASED", EXIT_OK)
|
|
233
|
+
return with_write_lock(context, _do, change=change)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
# ---------------------------------------------------------------------------
|
|
237
|
+
# Tareas (lock granular por ítem)
|
|
238
|
+
# ---------------------------------------------------------------------------
|
|
239
|
+
|
|
240
|
+
def cmd_claim_task(context, task_id, agent_id=None, lease_seconds=DEFAULT_LEASE_SECONDS, change=None):
|
|
241
|
+
"""Reclama una tarea específica para un agente.
|
|
242
|
+
|
|
243
|
+
A claim carries a lease. An expired claim is taken over rather than
|
|
244
|
+
respected, and the takeover is recorded on the claim so a swarm's history
|
|
245
|
+
stays auditable.
|
|
246
|
+
"""
|
|
247
|
+
if not agent_id:
|
|
248
|
+
agent_id = generate_agent_id()
|
|
249
|
+
|
|
250
|
+
def _do():
|
|
251
|
+
m = load_manifest(context, change)
|
|
252
|
+
if not m:
|
|
253
|
+
return missing_session_result(context, change)
|
|
254
|
+
tasks = m.setdefault("task_claims", {})
|
|
255
|
+
existing = tasks.get(task_id)
|
|
256
|
+
|
|
257
|
+
takeovers = []
|
|
258
|
+
if existing and existing["status"] == "claimed":
|
|
259
|
+
if not _claim_is_expired(existing):
|
|
260
|
+
return CommandResult(
|
|
261
|
+
f"FAIL|TASK_CLAIMED|{existing['agent_id']}",
|
|
262
|
+
EXIT_LOCK_HELD,
|
|
263
|
+
)
|
|
264
|
+
takeovers = list(existing.get("takeovers", []))
|
|
265
|
+
takeovers.append({
|
|
266
|
+
"from_agent": existing.get("agent_id"),
|
|
267
|
+
"to_agent": agent_id,
|
|
268
|
+
"at": datetime.now().isoformat(),
|
|
269
|
+
"reason": "lease_expired",
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
claim = {
|
|
273
|
+
"status": "claimed",
|
|
274
|
+
"agent_id": agent_id,
|
|
275
|
+
"claimed_at": datetime.now().isoformat(),
|
|
276
|
+
"lease_seconds": lease_seconds,
|
|
277
|
+
}
|
|
278
|
+
if takeovers:
|
|
279
|
+
claim["takeovers"] = takeovers
|
|
280
|
+
tasks[task_id] = claim
|
|
281
|
+
save_manifest(context, m, change)
|
|
282
|
+
return CommandResult(f"SUCCESS|TASK_CLAIMED|{task_id}", EXIT_OK)
|
|
283
|
+
return with_write_lock(context, _do, change=change)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def cmd_release_task(context, task_id, agent_id=None, force=False, change=None):
|
|
287
|
+
"""Libera una tarea, validando ownership.
|
|
288
|
+
|
|
289
|
+
Releasing without declaring an identity is an error: an ownership check
|
|
290
|
+
that only runs when the caller volunteers its agent_id is opt-in, and the
|
|
291
|
+
agent with something to gain by skipping it is the one who will. `force`
|
|
292
|
+
remains available and is recorded on the claim.
|
|
293
|
+
"""
|
|
294
|
+
def _do():
|
|
295
|
+
m = load_manifest(context, change)
|
|
296
|
+
if not m:
|
|
297
|
+
return missing_session_result(context, change)
|
|
298
|
+
tasks = m.get("task_claims", {})
|
|
299
|
+
task = tasks.get(task_id)
|
|
300
|
+
# Checked before identity: you cannot violate the ownership of a claim
|
|
301
|
+
# that does not exist, and the caller deserves the more specific error.
|
|
302
|
+
if not task or task["status"] != "claimed":
|
|
303
|
+
return CommandResult(
|
|
304
|
+
f"FAIL|TASK_NOT_CLAIMED|{task_id}",
|
|
305
|
+
EXIT_LOCK_HELD,
|
|
306
|
+
)
|
|
307
|
+
if not agent_id and not force:
|
|
308
|
+
return CommandResult(
|
|
309
|
+
"FAIL|AGENT_ID_REQUIRED|pass --agent-id, or --force to override",
|
|
310
|
+
EXIT_VALIDATION,
|
|
311
|
+
)
|
|
312
|
+
if agent_id and not force and task["agent_id"] != agent_id:
|
|
313
|
+
return CommandResult(
|
|
314
|
+
f"FAIL|OWNERSHIP_MISMATCH|{task_id}|owner={task['agent_id']}",
|
|
315
|
+
EXIT_LOCK_HELD,
|
|
316
|
+
)
|
|
317
|
+
task["status"] = "done"
|
|
318
|
+
task["released_at"] = datetime.now().isoformat()
|
|
319
|
+
if force:
|
|
320
|
+
task["force_released"] = True
|
|
321
|
+
task["force_released_by"] = agent_id
|
|
322
|
+
save_manifest(context, m, change)
|
|
323
|
+
return CommandResult(f"SUCCESS|TASK_RELEASED|{task_id}", EXIT_OK)
|
|
324
|
+
return with_write_lock(context, _do, change=change)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
# ---------------------------------------------------------------------------
|
|
328
|
+
# Utilidades
|
|
329
|
+
# ---------------------------------------------------------------------------
|
|
330
|
+
|
|
331
|
+
def _count_tasks_in_file(filepath):
|
|
332
|
+
"""Cuenta checkboxes en un archivo markdown.
|
|
333
|
+
|
|
334
|
+
Returns:
|
|
335
|
+
(total, completed) o None si el archivo no existe.
|
|
336
|
+
"""
|
|
337
|
+
if not os.path.exists(filepath):
|
|
338
|
+
return None
|
|
339
|
+
with open(filepath, "r", encoding="utf-8") as f:
|
|
340
|
+
lines = f.readlines()
|
|
341
|
+
total = completed = 0
|
|
342
|
+
for line in lines:
|
|
343
|
+
m = TASK_LINE_RE.match(line)
|
|
344
|
+
if not m:
|
|
345
|
+
continue
|
|
346
|
+
total += 1
|
|
347
|
+
if m.group(1).lower() == "x":
|
|
348
|
+
completed += 1
|
|
349
|
+
return total, completed
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def cmd_check_completion(context, change=None):
|
|
353
|
+
"""Parser determinista de tasks.md — el modelo no
|
|
354
|
+
cuenta checkboxes a mano."""
|
|
355
|
+
p = get_paths(context, change)
|
|
356
|
+
lines = []
|
|
357
|
+
|
|
358
|
+
tasks = _count_tasks_in_file(p["tasks"])
|
|
359
|
+
|
|
360
|
+
if tasks is not None:
|
|
361
|
+
t_total, t_completed = tasks
|
|
362
|
+
t_all = t_total > 0 and t_completed == t_total
|
|
363
|
+
lines.append(f"source=tasks.md")
|
|
364
|
+
lines.append(f"total={t_total}")
|
|
365
|
+
lines.append(f"completed={t_completed}")
|
|
366
|
+
lines.append(f"all_complete={'true' if t_all else 'false'}")
|
|
367
|
+
else:
|
|
368
|
+
lines.append("total=0")
|
|
369
|
+
lines.append("completed=0")
|
|
370
|
+
lines.append("all_complete=false")
|
|
371
|
+
|
|
372
|
+
return CommandResult("\n".join(lines), EXIT_OK)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def cmd_validate(context, max_length=None, change=None):
|
|
376
|
+
"""Lint de los artefactos de sesión: existencia + cap de longitud.
|
|
377
|
+
Determinista, no depende de que el modelo se autoevalúe.
|
|
378
|
+
|
|
379
|
+
Requiere: objective.md + snapshot.md + tasks.md.
|
|
380
|
+
Opcionalmente valida: review-report.md, verify-report.md si existen.
|
|
381
|
+
"""
|
|
382
|
+
p = get_paths(context, change)
|
|
383
|
+
session_dir = p["base"]
|
|
384
|
+
|
|
385
|
+
if max_length is None:
|
|
386
|
+
from .paths import MAX_ARTIFACT_CHARS
|
|
387
|
+
max_length = MAX_ARTIFACT_CHARS
|
|
388
|
+
|
|
389
|
+
# Artefactos obligatorios (siempre deben existir)
|
|
390
|
+
required = ["objective.md", "snapshot.md"]
|
|
391
|
+
# El archivo de tareas debe existir
|
|
392
|
+
task_files = ["tasks.md"]
|
|
393
|
+
# El archivo de tareas debe existir
|
|
394
|
+
# Artefactos opcionales (se validan solo si existen)
|
|
395
|
+
optional = ["review-report.md", "verify-report.md"]
|
|
396
|
+
|
|
397
|
+
failures = []
|
|
398
|
+
|
|
399
|
+
for fname in required:
|
|
400
|
+
path = os.path.join(session_dir, fname)
|
|
401
|
+
if not os.path.exists(path):
|
|
402
|
+
failures.append(f"MISSING|{fname}")
|
|
403
|
+
continue
|
|
404
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
405
|
+
content = f.read()
|
|
406
|
+
if len(content) > max_length:
|
|
407
|
+
failures.append(f"TOO_LONG|{fname}|{len(content)}/{max_length}")
|
|
408
|
+
|
|
409
|
+
# Archivo de tareas debe existir
|
|
410
|
+
path = os.path.join(session_dir, "tasks.md")
|
|
411
|
+
if not os.path.exists(path):
|
|
412
|
+
failures.append("MISSING|tasks.md")
|
|
413
|
+
else:
|
|
414
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
415
|
+
content = f.read()
|
|
416
|
+
if len(content) > max_length:
|
|
417
|
+
failures.append(f"TOO_LONG|tasks.md|{len(content)}/{max_length}")
|
|
418
|
+
|
|
419
|
+
# Artefactos opcionales — solo validar tamaño si existen
|
|
420
|
+
for fname in optional:
|
|
421
|
+
path = os.path.join(session_dir, fname)
|
|
422
|
+
if os.path.exists(path):
|
|
423
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
424
|
+
content = f.read()
|
|
425
|
+
if len(content) > max_length:
|
|
426
|
+
failures.append(f"TOO_LONG|{fname}|{len(content)}/{max_length}")
|
|
427
|
+
|
|
428
|
+
# Validacion estricta de idioma
|
|
429
|
+
for fname in required + task_files + optional:
|
|
430
|
+
path = os.path.join(session_dir, fname)
|
|
431
|
+
if not os.path.exists(path):
|
|
432
|
+
continue
|
|
433
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
434
|
+
content = f.read()
|
|
435
|
+
if not content.strip():
|
|
436
|
+
continue
|
|
437
|
+
spanish_indicators = ["á", "é", "í", "ó", "ú", "ñ", "¿", "¡"]
|
|
438
|
+
spanish_count = sum(content.lower().count(c) for c in spanish_indicators)
|
|
439
|
+
if spanish_count > 5:
|
|
440
|
+
failures.append(f"LANGUAGE_BOUNDARY|{fname}|Spanish text detected. Artifacts must be in English.")
|
|
441
|
+
|
|
442
|
+
if failures:
|
|
443
|
+
raise ValidationError(failures)
|
|
444
|
+
|
|
445
|
+
return CommandResult("SUCCESS|VALIDATE_OK", EXIT_OK)
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def _parse_task_lines(filepath):
|
|
449
|
+
"""Parse un archivo de tareas y retorna lista de (task_id, description, status).
|
|
450
|
+
|
|
451
|
+
status es 'done', 'wip', o 'pending'.
|
|
452
|
+
task_id se extrae del primer token numérico (ej. '1.1') o se genera
|
|
453
|
+
como índice secuencial.
|
|
454
|
+
"""
|
|
455
|
+
import re
|
|
456
|
+
if not os.path.exists(filepath):
|
|
457
|
+
return []
|
|
458
|
+
with open(filepath, "r", encoding="utf-8") as f:
|
|
459
|
+
lines = f.readlines()
|
|
460
|
+
tasks = []
|
|
461
|
+
idx = 0
|
|
462
|
+
task_id_re = re.compile(r"^(\d+(?:\.\d+)?)\s+(.*)$")
|
|
463
|
+
for line in lines:
|
|
464
|
+
m = TASK_LINE_RE.match(line)
|
|
465
|
+
if not m:
|
|
466
|
+
continue
|
|
467
|
+
idx += 1
|
|
468
|
+
marker = m.group(1)
|
|
469
|
+
description = m.group(2).strip()
|
|
470
|
+
if marker.lower() == "x":
|
|
471
|
+
status = "done"
|
|
472
|
+
elif marker == "/":
|
|
473
|
+
status = "wip"
|
|
474
|
+
else:
|
|
475
|
+
status = "pending"
|
|
476
|
+
# Extract task_id from description (e.g. "1.1 Create the foo")
|
|
477
|
+
id_match = task_id_re.match(description)
|
|
478
|
+
if id_match:
|
|
479
|
+
task_id = id_match.group(1)
|
|
480
|
+
else:
|
|
481
|
+
task_id = str(idx)
|
|
482
|
+
tasks.append((task_id, description, status))
|
|
483
|
+
return tasks
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def cmd_next_task(context, agent_id=None, change=None):
|
|
487
|
+
"""Encuentra la siguiente tarea pendiente no reclamada y la reclama
|
|
488
|
+
atómicamente. Elimina la necesidad de que el modelo itere manualmente."""
|
|
489
|
+
if not agent_id:
|
|
490
|
+
agent_id = generate_agent_id()
|
|
491
|
+
|
|
492
|
+
p = get_paths(context, change)
|
|
493
|
+
m = load_manifest(context, change)
|
|
494
|
+
if not m:
|
|
495
|
+
return missing_session_result(context, change)
|
|
496
|
+
|
|
497
|
+
# Buscar en tasks.md
|
|
498
|
+
all_tasks = []
|
|
499
|
+
filepath = p["tasks"]
|
|
500
|
+
all_tasks.extend(_parse_task_lines(filepath))
|
|
501
|
+
|
|
502
|
+
claimed = m.get("task_claims", {})
|
|
503
|
+
|
|
504
|
+
for task_id, description, status in all_tasks:
|
|
505
|
+
if status == "done":
|
|
506
|
+
continue
|
|
507
|
+
existing = claimed.get(task_id)
|
|
508
|
+
# An expired claim is an abandoned one: skipping it regardless of age
|
|
509
|
+
# let a single crashed agent retire a task from the queue permanently,
|
|
510
|
+
# and the run then reported DONE with work left undone.
|
|
511
|
+
if (existing and existing["status"] == "claimed"
|
|
512
|
+
and not _claim_is_expired(existing)):
|
|
513
|
+
continue
|
|
514
|
+
# Tarea disponible — reclamarla atómicamente
|
|
515
|
+
result = cmd_claim_task(context, task_id, agent_id, change=change)
|
|
516
|
+
if result.exit_code == EXIT_OK:
|
|
517
|
+
# The agent_id is part of the contract: next-task claims on the
|
|
518
|
+
# caller's behalf, so a caller that is never told which identity
|
|
519
|
+
# won cannot release what it just claimed.
|
|
520
|
+
return CommandResult(
|
|
521
|
+
f"SUCCESS|NEXT_TASK|{task_id}|{agent_id}|{description}",
|
|
522
|
+
EXIT_OK,
|
|
523
|
+
)
|
|
524
|
+
|
|
525
|
+
return CommandResult("DONE|NO_PENDING_TASKS", EXIT_OK)
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def cmd_status(context, change=None):
|
|
529
|
+
"""Resumen one-shot del estado del contexto para rehidratación rápida."""
|
|
530
|
+
p = get_paths(context, change)
|
|
531
|
+
m = load_manifest(context, change)
|
|
532
|
+
lines = []
|
|
533
|
+
|
|
534
|
+
if not m:
|
|
535
|
+
return missing_session_result(context, change)
|
|
536
|
+
|
|
537
|
+
lines.append(f"CONTEXT: {m.get('context_name', context)}")
|
|
538
|
+
|
|
539
|
+
# Objective
|
|
540
|
+
obj_path = os.path.join(p["base"], "objective.md")
|
|
541
|
+
if os.path.exists(obj_path):
|
|
542
|
+
with open(obj_path, "r", encoding="utf-8") as f:
|
|
543
|
+
obj_text = f.read().strip()
|
|
544
|
+
# Take first non-header, non-empty line as summary
|
|
545
|
+
for obj_line in obj_text.split("\n"):
|
|
546
|
+
stripped = obj_line.strip()
|
|
547
|
+
if stripped and not stripped.startswith("#"):
|
|
548
|
+
lines.append(f"OBJECTIVE: {stripped}")
|
|
549
|
+
break
|
|
550
|
+
else:
|
|
551
|
+
lines.append("OBJECTIVE: (missing)")
|
|
552
|
+
|
|
553
|
+
# Progress
|
|
554
|
+
completion = cmd_check_completion(context, change)
|
|
555
|
+
for comp_line in completion.message.split("\n"):
|
|
556
|
+
if comp_line.startswith("total="):
|
|
557
|
+
total = comp_line.split("=")[1]
|
|
558
|
+
if comp_line.startswith("completed="):
|
|
559
|
+
completed = comp_line.split("=")[1]
|
|
560
|
+
if comp_line.startswith("aggregate_total="):
|
|
561
|
+
total = comp_line.split("=")[1]
|
|
562
|
+
if comp_line.startswith("aggregate_completed="):
|
|
563
|
+
completed = comp_line.split("=")[1]
|
|
564
|
+
lines.append(f"PROGRESS: {completed}/{total} tasks complete")
|
|
565
|
+
|
|
566
|
+
# Next pending task
|
|
567
|
+
all_tasks = []
|
|
568
|
+
filepath = p["tasks"]
|
|
569
|
+
all_tasks.extend(_parse_task_lines(filepath))
|
|
570
|
+
claimed = m.get("task_claims", {})
|
|
571
|
+
next_task = None
|
|
572
|
+
for task_id, description, status in all_tasks:
|
|
573
|
+
if status == "done":
|
|
574
|
+
continue
|
|
575
|
+
existing = claimed.get(task_id)
|
|
576
|
+
if existing and existing["status"] == "claimed":
|
|
577
|
+
continue
|
|
578
|
+
next_task = f"{task_id} - {description}"
|
|
579
|
+
break
|
|
580
|
+
if next_task:
|
|
581
|
+
lines.append(f"NEXT: {next_task}")
|
|
582
|
+
else:
|
|
583
|
+
lines.append("NEXT: (none)")
|
|
584
|
+
|
|
585
|
+
# Lock status
|
|
586
|
+
lock = m.get("lock", {})
|
|
587
|
+
if lock.get("held"):
|
|
588
|
+
lines.append(f"LOCK: HELD by {lock.get('acquired_by', 'unknown')}")
|
|
589
|
+
else:
|
|
590
|
+
lines.append("LOCK: FREE")
|
|
591
|
+
|
|
592
|
+
return CommandResult("\n".join(lines), EXIT_OK)
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
# ---------------------------------------------------------------------------
|
|
596
|
+
# Doctor — diagnóstico de salud
|
|
597
|
+
# ---------------------------------------------------------------------------
|
|
598
|
+
|
|
599
|
+
def cmd_doctor(context, fix=False, change=None):
|
|
600
|
+
"""Diagnóstico de salud del contexto. Detecta problemas comunes que un
|
|
601
|
+
modelo free-tier puede causar: artefactos faltantes, language boundary
|
|
602
|
+
violations, task claims huérfanos, manifest corrupto.
|
|
603
|
+
|
|
604
|
+
With fix=True, also releases task claims whose owning PID is gone. This is
|
|
605
|
+
the operator's escape hatch when a whole swarm died at once; diagnosis and
|
|
606
|
+
repair stay separate verbs so a read-only check never mutates state.
|
|
607
|
+
"""
|
|
608
|
+
p = get_paths(context, change)
|
|
609
|
+
findings = []
|
|
610
|
+
|
|
611
|
+
# 1. Check session exists
|
|
612
|
+
m = load_manifest(context, change)
|
|
613
|
+
if not m:
|
|
614
|
+
findings.append("ERROR: No session found (manifest.json missing)")
|
|
615
|
+
return CommandResult("\n".join(findings), EXIT_GENERIC)
|
|
616
|
+
findings.append("OK: manifest.json is valid")
|
|
617
|
+
|
|
618
|
+
# 2. Check required artifacts
|
|
619
|
+
required = ["objective.md", "snapshot.md"]
|
|
620
|
+
task_files = ["tasks.md"]
|
|
621
|
+
for fname in required:
|
|
622
|
+
path = os.path.join(p["base"], fname)
|
|
623
|
+
if not os.path.exists(path):
|
|
624
|
+
findings.append(f"ERROR: {fname} is missing")
|
|
625
|
+
else:
|
|
626
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
627
|
+
content = f.read()
|
|
628
|
+
if len(content) > MAX_ARTIFACT_CHARS:
|
|
629
|
+
findings.append(
|
|
630
|
+
f"WARN: {fname} exceeds size limit "
|
|
631
|
+
f"({len(content)}/{MAX_ARTIFACT_CHARS} chars)")
|
|
632
|
+
else:
|
|
633
|
+
findings.append(f"OK: {fname} exists ({len(content)} chars)")
|
|
634
|
+
|
|
635
|
+
has_task_file = False
|
|
636
|
+
for fname in task_files:
|
|
637
|
+
path = os.path.join(p["base"], fname)
|
|
638
|
+
if os.path.exists(path):
|
|
639
|
+
has_task_file = True
|
|
640
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
641
|
+
content = f.read()
|
|
642
|
+
if len(content) > MAX_ARTIFACT_CHARS:
|
|
643
|
+
findings.append(
|
|
644
|
+
f"WARN: {fname} exceeds size limit "
|
|
645
|
+
f"({len(content)}/{MAX_ARTIFACT_CHARS} chars)")
|
|
646
|
+
else:
|
|
647
|
+
findings.append(f"OK: {fname} exists ({len(content)} chars)")
|
|
648
|
+
if not has_task_file:
|
|
649
|
+
findings.append("ERROR: No task file found (need tasks.md)")
|
|
650
|
+
|
|
651
|
+
# 3. Check for non-ASCII in artifacts (removed from doctor, moved to validate)
|
|
652
|
+
|
|
653
|
+
# 3b. Phase documents that differ from the embedded copy. Reported as
|
|
654
|
+
# INFO, never as a problem: `cg new` deliberately refuses to overwrite a
|
|
655
|
+
# customised phase file, so a project that tailored one would otherwise
|
|
656
|
+
# look permanently broken. Informational findings must not move the exit
|
|
657
|
+
# code — that is what makes them informational.
|
|
658
|
+
for fname in diverged_phases(context):
|
|
659
|
+
findings.append(
|
|
660
|
+
f"INFO: .context-guard/phases/{fname} differs from the packaged "
|
|
661
|
+
f"copy (customised locally; it is never overwritten)")
|
|
662
|
+
|
|
663
|
+
# 4. Check stale task claims
|
|
664
|
+
claims = m.get("task_claims", {})
|
|
665
|
+
repaired = []
|
|
666
|
+
for task_id, claim in claims.items():
|
|
667
|
+
if claim.get("status") == "claimed":
|
|
668
|
+
claimed_at = claim.get("claimed_at", "")
|
|
669
|
+
agent = claim.get("agent_id", "unknown")
|
|
670
|
+
if fix:
|
|
671
|
+
pid = _pid_from_agent_id(agent)
|
|
672
|
+
# An opaque agent_id names no PID we can probe, so we leave it
|
|
673
|
+
# alone: guessing wrong here would trample a working agent.
|
|
674
|
+
if pid is not None and not _pid_is_alive(pid):
|
|
675
|
+
claim["status"] = "released"
|
|
676
|
+
claim["released_at"] = datetime.now().isoformat()
|
|
677
|
+
claim["released_reason"] = "dead_pid"
|
|
678
|
+
repaired.append(task_id)
|
|
679
|
+
findings.append(
|
|
680
|
+
f"FIXED: Task {task_id} released — owner {agent} "
|
|
681
|
+
f"(pid {pid}) is gone")
|
|
682
|
+
continue
|
|
683
|
+
if claimed_at:
|
|
684
|
+
try:
|
|
685
|
+
claimed_time = datetime.fromisoformat(claimed_at)
|
|
686
|
+
elapsed = (datetime.now() - claimed_time).total_seconds()
|
|
687
|
+
if elapsed > 1800: # 30 minutes
|
|
688
|
+
findings.append(
|
|
689
|
+
f"WARN: Task {task_id} claimed by {agent} "
|
|
690
|
+
f"{int(elapsed)}s ago (possibly stale)")
|
|
691
|
+
else:
|
|
692
|
+
findings.append(
|
|
693
|
+
f"OK: Task {task_id} claimed by {agent} "
|
|
694
|
+
f"({int(elapsed)}s ago)")
|
|
695
|
+
except (ValueError, TypeError):
|
|
696
|
+
findings.append(
|
|
697
|
+
f"WARN: Task {task_id} has unparseable claimed_at: {claimed_at}")
|
|
698
|
+
|
|
699
|
+
# 5. Lock status
|
|
700
|
+
lock = m.get("lock", {})
|
|
701
|
+
if lock.get("held"):
|
|
702
|
+
acquired_at = lock.get("acquired_at")
|
|
703
|
+
if acquired_at:
|
|
704
|
+
try:
|
|
705
|
+
elapsed = (datetime.now() - datetime.fromisoformat(acquired_at)).total_seconds()
|
|
706
|
+
ttl = lock.get("ttl_seconds", 1800)
|
|
707
|
+
if elapsed > ttl:
|
|
708
|
+
findings.append(
|
|
709
|
+
f"WARN: Session lock is stale "
|
|
710
|
+
f"(held {int(elapsed)}s, TTL={ttl}s)")
|
|
711
|
+
else:
|
|
712
|
+
findings.append(
|
|
713
|
+
f"OK: Session lock active ({int(elapsed)}s/{ttl}s)")
|
|
714
|
+
except (ValueError, TypeError):
|
|
715
|
+
findings.append("WARN: Session lock has unparseable timestamp")
|
|
716
|
+
else:
|
|
717
|
+
findings.append("OK: Session lock is FREE")
|
|
718
|
+
|
|
719
|
+
if repaired:
|
|
720
|
+
save_manifest(context, m, change)
|
|
721
|
+
|
|
722
|
+
return CommandResult("\n".join(findings), EXIT_OK)
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
|
|
726
|
+
# ---------------------------------------------------------------------------
|
|
727
|
+
# Archive
|
|
728
|
+
# ---------------------------------------------------------------------------
|
|
729
|
+
|
|
730
|
+
def cmd_archive(context, change=None):
|
|
731
|
+
"""Archiva un contexto completado.
|
|
732
|
+
|
|
733
|
+
1. Verifica que todas las tareas estén completas
|
|
734
|
+
2. Valida artefactos
|
|
735
|
+
3. Acquiere session lock (dentro de write lock para atomicidad)
|
|
736
|
+
4. Copia sesión a archive/
|
|
737
|
+
5. Verifica que el archive no esté vacío
|
|
738
|
+
6. Borra sesión original
|
|
739
|
+
7. Libera session lock
|
|
740
|
+
"""
|
|
741
|
+
p = get_paths(context, change)
|
|
742
|
+
|
|
743
|
+
# 1. Verificar completitud
|
|
744
|
+
completion = cmd_check_completion(context, change)
|
|
745
|
+
output = completion.message
|
|
746
|
+
# Determinar si todo está completo
|
|
747
|
+
all_complete = False
|
|
748
|
+
for line in output.split("\n"):
|
|
749
|
+
# Si hay aggregate, usar ese; si no, usar el único all_complete
|
|
750
|
+
if line.startswith("aggregate_all_complete="):
|
|
751
|
+
all_complete = line.split("=")[1] == "true"
|
|
752
|
+
break
|
|
753
|
+
if line.startswith("all_complete="):
|
|
754
|
+
all_complete = line.split("=")[1] == "true"
|
|
755
|
+
|
|
756
|
+
if not all_complete:
|
|
757
|
+
return CommandResult(
|
|
758
|
+
"FAIL|ARCHIVE_BLOCKED|tasks_incomplete",
|
|
759
|
+
EXIT_VALIDATION,
|
|
760
|
+
)
|
|
761
|
+
|
|
762
|
+
# 2. Validar artefactos (puede lanzar ValidationError)
|
|
763
|
+
cmd_validate(context, change=change)
|
|
764
|
+
|
|
765
|
+
# 3-7. Lock + copy + verify + delete + unlock — todo dentro de write_lock
|
|
766
|
+
def _do_archive():
|
|
767
|
+
# Acquire session lock con TTL corto para el archivado
|
|
768
|
+
claim_result = acquire(context, ttl=60, change=change)
|
|
769
|
+
if claim_result.exit_code != EXIT_OK:
|
|
770
|
+
return claim_result
|
|
771
|
+
|
|
772
|
+
archived_ok = False
|
|
773
|
+
try:
|
|
774
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
775
|
+
# The change name goes in the directory name: without it two
|
|
776
|
+
# archived changes are indistinguishable after the fact.
|
|
777
|
+
archive_dir = os.path.join(p["archive"], f"{timestamp}_{p['change']}")
|
|
778
|
+
os.makedirs(p["archive"], exist_ok=True)
|
|
779
|
+
|
|
780
|
+
shutil.copytree(p["base"], archive_dir)
|
|
781
|
+
|
|
782
|
+
# Verificar que el archive no esté vacío
|
|
783
|
+
archive_contents = os.listdir(archive_dir)
|
|
784
|
+
if not archive_contents:
|
|
785
|
+
return CommandResult(
|
|
786
|
+
"FAIL|ARCHIVE_EMPTY",
|
|
787
|
+
EXIT_VALIDATION,
|
|
788
|
+
)
|
|
789
|
+
|
|
790
|
+
archived_ok = True
|
|
791
|
+
# Remove the change directory itself, not just its contents: an
|
|
792
|
+
# emptied-but-present directory keeps the change looking active in
|
|
793
|
+
# `cg list` and makes the next resolution ambiguous against a
|
|
794
|
+
# change that no longer exists.
|
|
795
|
+
shutil.rmtree(p["base"], ignore_errors=True)
|
|
796
|
+
|
|
797
|
+
return CommandResult(
|
|
798
|
+
f"SUCCESS|ARCHIVED|{p['change']}|{archive_dir}",
|
|
799
|
+
EXIT_OK,
|
|
800
|
+
)
|
|
801
|
+
finally:
|
|
802
|
+
# Liberar session lock. If the archive succeeded the whole change
|
|
803
|
+
# directory is gone with it; otherwise the lockfile must not be
|
|
804
|
+
# left behind holding a change that is still active.
|
|
805
|
+
if not archived_ok and os.path.exists(p["lock"]):
|
|
806
|
+
try:
|
|
807
|
+
os.remove(p["lock"])
|
|
808
|
+
except FileNotFoundError:
|
|
809
|
+
pass
|
|
810
|
+
|
|
811
|
+
return with_write_lock(context, _do_archive, change=change)
|