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.
Files changed (32) hide show
  1. context_guard/__init__.py +1 -0
  2. context_guard/_data/hosts/antigravity/hooks.snippet.json +16 -0
  3. context_guard/_data/hosts/antigravity/rules/context-guard.md +15 -0
  4. context_guard/_data/hosts/claude-code/commands/cg-continue.md +13 -0
  5. context_guard/_data/hosts/claude-code/commands/cg-new.md +11 -0
  6. context_guard/_data/hosts/claude-code/mcp.snippet.json +7 -0
  7. context_guard/_data/hosts/claude-code/settings.snippet.json +12 -0
  8. context_guard/_data/hosts/opencode/agent.snippet.json +7 -0
  9. context_guard/_data/hosts/opencode/commands/cg-continue.md +16 -0
  10. context_guard/_data/hosts/opencode/commands/cg-new.md +12 -0
  11. context_guard/_data/hosts/opencode/mcp.snippet.json +9 -0
  12. context_guard/_data/hosts/opencode/permissions.snippet.json +12 -0
  13. context_guard/_data/phases/execute.md +99 -0
  14. context_guard/_data/phases/plan.md +136 -0
  15. context_guard/_data/phases/verify.md +129 -0
  16. context_guard/guard/__init__.py +1 -0
  17. context_guard/guard/assets.py +94 -0
  18. context_guard/guard/cli.py +307 -0
  19. context_guard/guard/commands.py +811 -0
  20. context_guard/guard/errors.py +71 -0
  21. context_guard/guard/locking.py +181 -0
  22. context_guard/guard/manifest.py +69 -0
  23. context_guard/guard/migrate.py +288 -0
  24. context_guard/guard/paths.py +199 -0
  25. context_guard/guard/setup.py +476 -0
  26. context_guard/guard/transaction.py +403 -0
  27. context_guard/mcp_server.py +280 -0
  28. context_guard_cli-2.1.0.dist-info/METADATA +296 -0
  29. context_guard_cli-2.1.0.dist-info/RECORD +32 -0
  30. context_guard_cli-2.1.0.dist-info/WHEEL +4 -0
  31. context_guard_cli-2.1.0.dist-info/entry_points.txt +4 -0
  32. 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)