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,403 @@
1
+ """Transaction and checkpoint manager for guard middleware.
2
+
3
+ Provides state snapshot, rollback, begin, commit, and checkpointing for context-guard sessions.
4
+ Follows the 3-state pipeline model: PLAN -> EXECUTE -> VERIFY -> ARCHIVE.
5
+ """
6
+
7
+ from datetime import datetime
8
+ import getpass
9
+ import os
10
+
11
+ from .paths import get_paths, missing_session_result
12
+ from .manifest import load_manifest, save_manifest, create_initial_manifest
13
+ from .locking import with_write_lock
14
+ from .errors import (
15
+ CommandResult,
16
+ EXIT_OK,
17
+ EXIT_LOCK_HELD,
18
+ EXIT_GENERIC,
19
+ EXIT_VALIDATION,
20
+ EXIT_BAD_TRANSITION,
21
+ EXIT_APPROVAL_REQUIRED,
22
+ )
23
+
24
+ DEFAULT_TTL = 1800
25
+ MAX_SUMMARY_CHARS = 2000
26
+
27
+ VALID_PHASES = ["PLAN", "EXECUTE", "VERIFY"]
28
+
29
+ TRANSITIONS = {
30
+ "PLAN": "EXECUTE",
31
+ "EXECUTE": "VERIFY",
32
+ "VERIFY": "ARCHIVE",
33
+ }
34
+
35
+
36
+ def is_stale(started_at_iso, ttl_seconds):
37
+ """Verifica si una transacción ha superado su TTL."""
38
+ if not started_at_iso or started_at_iso == "None":
39
+ return False
40
+ try:
41
+ elapsed = (datetime.now() - datetime.fromisoformat(started_at_iso)).total_seconds()
42
+ return elapsed > ttl_seconds
43
+ except (ValueError, TypeError):
44
+ return False
45
+
46
+
47
+ def _scaffold_artifacts(context_path, change=None):
48
+ """Genera plantillas por defecto en .context-guard/ para la fase PLAN si no existen."""
49
+ p = get_paths(context_path, change)
50
+ base_dir = p["base"]
51
+ os.makedirs(base_dir, exist_ok=True)
52
+
53
+ artifacts = {
54
+ "objective.md": "[PENDING] Define objective here",
55
+ "snapshot.md": "[PENDING] Define snapshot here",
56
+ "tasks.md": "[PENDING] Define tasks here",
57
+ "review-report.md": "[PENDING] Write static review here",
58
+ "verify-report.md": "[PENDING] Write dynamic verification here",
59
+ }
60
+
61
+ for filename, default_content in artifacts.items():
62
+ filepath = os.path.join(base_dir, filename)
63
+ if not os.path.exists(filepath):
64
+ with open(filepath, "w", encoding="utf-8") as f:
65
+ f.write(default_content)
66
+
67
+
68
+ def _record_approval(m, approval, consumed_as):
69
+ """Move an approval into the audit trail, spending it.
70
+
71
+ An approval left live in the manifest authorizes every future transition
72
+ into EXECUTE, not just the one the human looked at. Consuming it here is
73
+ what makes a sign-off single-use.
74
+ """
75
+ entry = dict(approval)
76
+ entry["consumed_at"] = datetime.now().isoformat()
77
+ entry["consumed_as"] = consumed_as
78
+ m.setdefault("approval_history", []).append(entry)
79
+ m.pop("approval", None)
80
+
81
+
82
+ def cmd_approve(context, by=None, hotfix=False, reason=None, change=None):
83
+ """Records the human sign-off that PLAN -> EXECUTE requires.
84
+
85
+ This command is cooperative and makes no pretence otherwise: an agent with
86
+ a shell can run it. What it buys is that the transition cannot happen
87
+ without *someone* running it, and that whoever did is named in the
88
+ manifest. The hard control is the harness permission prompt documented in
89
+ adapters/ — see PLAN.md 0.6.
90
+
91
+ `hotfix` is the audited door out of the pipeline: it spends the approval
92
+ immediately and jumps lock_phase straight to EXECUTE, recording why. It
93
+ replaces state-guard's parallel bypass flow, whose problem was never that
94
+ it existed but that nothing survived it.
95
+ """
96
+ if hotfix and not (reason or "").strip():
97
+ return CommandResult(
98
+ 'FAIL|HOTFIX_REASON_REQUIRED|pass --reason "<text>"',
99
+ EXIT_VALIDATION,
100
+ )
101
+
102
+ # Defaults to the OS user (PLAN-2.1 F2.1), reversing 2.0's decision to
103
+ # require it. 2.0 argued that inheriting the environment made an
104
+ # agent-run approve indistinguishable from a human-run one — but that
105
+ # was only ever half true, since an agent could pass any string it liked.
106
+ # The flag never authenticated anyone; it only forced an active choice.
107
+ #
108
+ # What it did cost is friction on the one step that must not be
109
+ # automated, and friction there is precisely what pushes people into
110
+ # letting the agent run it. This value is audit metadata — who to ask
111
+ # about this approval later — not proof of who ran the command. The
112
+ # authentication was, and remains, the harness permission prompt.
113
+ who = (by or "").strip() or getpass.getuser()
114
+
115
+ # Checked before taking the write lock: acquiring it would create the
116
+ # change directory as a side effect, inventing the change the caller
117
+ # mistyped.
118
+ if load_manifest(context, change) is None:
119
+ return missing_session_result(context, change)
120
+
121
+ def _do():
122
+ m = load_manifest(context, change)
123
+ if not m:
124
+ return missing_session_result(context, change)
125
+
126
+ lock_phase = m.get("lock_phase", "PLAN")
127
+ if lock_phase != "PLAN":
128
+ # Only PLAN -> EXECUTE consumes an approval. Recording one anywhere
129
+ # else leaves a live sign-off that nothing will spend, waiting to
130
+ # authorize a transition nobody was asked about.
131
+ return CommandResult(
132
+ f"FAIL|APPROVAL_NOT_APPLICABLE|lock_phase={lock_phase}",
133
+ EXIT_BAD_TRANSITION,
134
+ )
135
+
136
+ approval = {"by": who, "at": datetime.now().isoformat()}
137
+
138
+ if not hotfix:
139
+ m["approval"] = approval
140
+ save_manifest(context, m, change)
141
+ return CommandResult(
142
+ f"SUCCESS|APPROVED|{get_paths(context, change)['change']}|by={who}",
143
+ EXIT_OK,
144
+ )
145
+
146
+ txn = m.get("transaction", {})
147
+ if txn.get("txn_status", "idle") == "in_progress":
148
+ # The open transaction holds a snapshot taken at lock_phase=PLAN.
149
+ # Jumping the pipeline behind its back means a later rollback
150
+ # restores a state the change already left, silently undoing the
151
+ # hotfix.
152
+ return CommandResult(
153
+ f"FAIL|TXN_IN_PROGRESS|{txn.get('txn_phase')}",
154
+ EXIT_LOCK_HELD,
155
+ )
156
+
157
+ approval["hotfix"] = True
158
+ approval["reason"] = reason.strip()
159
+ _record_approval(m, approval, "PLAN->EXECUTE (hotfix)")
160
+
161
+ m["lock_phase"] = "EXECUTE"
162
+ # PLAN was skipped, not done. Writing it into completed_phases would
163
+ # make the manifest claim a plan was produced and reviewed; leaving it
164
+ # pending would make the pipeline ask for it again.
165
+ pending = m.get("pending_phases", [])
166
+ if "PLAN" in pending:
167
+ pending.remove("PLAN")
168
+ m["pending_phases"] = pending
169
+ skipped = m.setdefault("skipped_phases", [])
170
+ if "PLAN" not in skipped:
171
+ skipped.append("PLAN")
172
+
173
+ save_manifest(context, m, change)
174
+ return CommandResult(
175
+ f"SUCCESS|APPROVED_HOTFIX|{get_paths(context, change)['change']}"
176
+ f"|by={who}|lock_phase=EXECUTE",
177
+ EXIT_OK,
178
+ )
179
+
180
+ return with_write_lock(context, _do, change=change)
181
+
182
+
183
+ def cmd_begin(context, phase, ttl=DEFAULT_TTL, change=None):
184
+ """Inicia una transacción para la fase dada (PLAN, EXECUTE, VERIFY)."""
185
+ if phase not in VALID_PHASES:
186
+ return CommandResult(f"FAIL|INVALID_PHASE|{phase}", EXIT_VALIDATION)
187
+
188
+ # A named change that does not exist is a typo, and it is checked here
189
+ # rather than inside _do because taking the write lock creates the change
190
+ # directory as a side effect — begin would answer a misspelled name by
191
+ # bringing that change into being. Only an explicitly named one: with no
192
+ # --change, creating the manifest below is the documented bootstrap.
193
+ if change and load_manifest(context, change) is None:
194
+ return missing_session_result(context, change)
195
+
196
+ def _do():
197
+ p = get_paths(context, change)
198
+ m = load_manifest(context, change)
199
+ if not m:
200
+ m = create_initial_manifest(context, p["change"])
201
+
202
+ # DAG enforcement: the manifest decides which phase may start, not the
203
+ # caller. Without this, the pipeline was only checked on commit — and
204
+ # an agent that never commits was never stopped.
205
+ lock_phase = m.get("lock_phase", "PLAN")
206
+ if phase != lock_phase:
207
+ return CommandResult(
208
+ f"FAIL|PHASE_NOT_AUTHORIZED|requested={phase}|lock_phase={lock_phase}",
209
+ EXIT_BAD_TRANSITION,
210
+ )
211
+
212
+ txn = m.setdefault("transaction", {})
213
+ status = txn.get("txn_status", "idle")
214
+ started_at = txn.get("txn_started_at", None)
215
+
216
+ if status == "in_progress" and not is_stale(started_at, ttl):
217
+ return CommandResult(
218
+ f"FAIL|TXN_IN_PROGRESS|{txn.get('txn_phase')}",
219
+ EXIT_LOCK_HELD,
220
+ )
221
+
222
+ if phase == "PLAN":
223
+ _scaffold_artifacts(context, change)
224
+
225
+ # Snapshot de estado previo para rollback
226
+ snapshot = {
227
+ "current_phase": m.get("current_phase", "PLAN"),
228
+ "lock_phase": m.get("lock_phase", "PLAN"),
229
+ "completed_phases": list(m.get("completed_phases", [])),
230
+ "pending_phases": list(m.get("pending_phases", list(VALID_PHASES))),
231
+ "session_summary": m.get("session", {}).get("session_summary", ""),
232
+ }
233
+
234
+ txn["txn_status"] = "in_progress"
235
+ txn["txn_phase"] = phase
236
+ txn["txn_started_at"] = datetime.now().isoformat()
237
+ txn["snapshot"] = snapshot
238
+
239
+ m["transaction"] = txn
240
+ save_manifest(context, m, change)
241
+ return CommandResult(f"SUCCESS|BEGIN|phase={phase}", EXIT_OK)
242
+
243
+ return with_write_lock(context, _do, change=change)
244
+
245
+
246
+ def cmd_commit(context, next_phase, change=None):
247
+ """Finaliza exitosamente la transacción y avanza en el DAG de 3 estados."""
248
+ def _do():
249
+ m = load_manifest(context, change)
250
+ if not m:
251
+ return missing_session_result(context, change)
252
+
253
+ txn = m.get("transaction", {})
254
+ if txn.get("txn_status", "idle") != "in_progress":
255
+ return CommandResult("FAIL|NO_TXN_IN_PROGRESS", EXIT_GENERIC)
256
+
257
+ phase = txn.get("txn_phase")
258
+ expected_next = TRANSITIONS.get(phase)
259
+ if expected_next != next_phase:
260
+ return CommandResult(
261
+ f"FAIL|BAD_TRANSITION|from={phase}|to={next_phase}|expected={expected_next}",
262
+ EXIT_BAD_TRANSITION,
263
+ )
264
+
265
+ # Validaciones estrictas (Hard Gates) antes de autorizar el cambio de fase
266
+ p = get_paths(context, change)
267
+ base_dir = p["base"]
268
+
269
+ if phase == "PLAN" and next_phase == "EXECUTE":
270
+ required_files = ["objective.md", "tasks.md"]
271
+ for fname in required_files:
272
+ fpath = os.path.join(base_dir, fname)
273
+ if not os.path.exists(fpath):
274
+ return CommandResult(
275
+ "FAIL|VALIDATION|Debe completar objective.md y tasks.md antes de avanzar a EXECUTE",
276
+ EXIT_VALIDATION,
277
+ )
278
+ with open(fpath, "r", encoding="utf-8") as f:
279
+ content = f.read()
280
+ if "[PENDING]" in content:
281
+ return CommandResult(
282
+ "FAIL|VALIDATION|Debe completar objective.md y tasks.md antes de avanzar a EXECUTE",
283
+ EXIT_VALIDATION,
284
+ )
285
+
286
+ # Checked after the artifacts, deliberately: an approval must not
287
+ # buy a pass through validation, and a human who signed off on a
288
+ # still-[PENDING] plan should be told the plan is empty, not that
289
+ # their approval is missing.
290
+ if not m.get("approval"):
291
+ return CommandResult(
292
+ "FAIL|APPROVAL_REQUIRED|run `cg approve` before entering EXECUTE",
293
+ EXIT_APPROVAL_REQUIRED,
294
+ )
295
+
296
+ elif phase == "VERIFY" and next_phase == "ARCHIVE":
297
+ required_files = ["review-report.md", "verify-report.md"]
298
+ for fname in required_files:
299
+ fpath = os.path.join(base_dir, fname)
300
+ if not os.path.exists(fpath):
301
+ return CommandResult(
302
+ "FAIL|VALIDATION|Debe completar la auditoría en review-report.md y verify-report.md antes de archivar",
303
+ EXIT_VALIDATION,
304
+ )
305
+ with open(fpath, "r", encoding="utf-8") as f:
306
+ content = f.read()
307
+ if "[PENDING]" in content:
308
+ return CommandResult(
309
+ "FAIL|VALIDATION|Debe completar la auditoría en review-report.md y verify-report.md antes de archivar",
310
+ EXIT_VALIDATION,
311
+ )
312
+
313
+ # The approval is spent by the transition it authorized, so the next
314
+ # iteration of the plan needs a new one.
315
+ if phase == "PLAN" and next_phase == "EXECUTE" and m.get("approval"):
316
+ _record_approval(m, m["approval"], "PLAN->EXECUTE")
317
+
318
+ # Actualizar grafo de fases
319
+ m["current_phase"] = phase
320
+ m["lock_phase"] = next_phase
321
+
322
+ completed = m.get("completed_phases", [])
323
+ if phase not in completed:
324
+ completed.append(phase)
325
+ m["completed_phases"] = completed
326
+
327
+ pending = m.get("pending_phases", [])
328
+ if phase in pending:
329
+ pending.remove(phase)
330
+ m["pending_phases"] = pending
331
+
332
+ txn["txn_status"] = "idle"
333
+ txn["txn_phase"] = "None"
334
+ txn["txn_started_at"] = None
335
+ txn.pop("snapshot", None)
336
+
337
+ # Generar auto_summary determinístico
338
+ auto_summary = (
339
+ f"completed_phase={phase}\n"
340
+ f"next_phase={next_phase}\n"
341
+ f"completed={', '.join(completed)}\n"
342
+ f"pending={', '.join(pending)}"
343
+ )
344
+ session_sec = m.setdefault("session", {})
345
+ session_sec["session_summary"] = auto_summary
346
+
347
+ save_manifest(context, m, change)
348
+ return CommandResult(f"SUCCESS|COMMIT|lock_phase={next_phase}", EXIT_OK)
349
+
350
+ return with_write_lock(context, _do, change=change)
351
+
352
+
353
+ def cmd_rollback(context, change=None):
354
+ """Revierte la transacción actual restaurando el snapshot previo."""
355
+ def _do():
356
+ m = load_manifest(context, change)
357
+ if not m:
358
+ return missing_session_result(context, change)
359
+
360
+ txn = m.get("transaction", {})
361
+ if txn.get("txn_status", "idle") != "in_progress":
362
+ return CommandResult("FAIL|NO_TXN_IN_PROGRESS", EXIT_GENERIC)
363
+
364
+ snapshot = txn.get("snapshot")
365
+ if snapshot:
366
+ m["current_phase"] = snapshot.get("current_phase", "PLAN")
367
+ m["lock_phase"] = snapshot.get("lock_phase", "PLAN")
368
+ m["completed_phases"] = snapshot.get("completed_phases", [])
369
+ m["pending_phases"] = snapshot.get("pending_phases", list(VALID_PHASES))
370
+ if "session_summary" in snapshot:
371
+ session_sec = m.setdefault("session", {})
372
+ session_sec["session_summary"] = snapshot["session_summary"]
373
+
374
+ txn["txn_status"] = "idle"
375
+ txn["txn_phase"] = "None"
376
+ txn["txn_started_at"] = None
377
+ txn.pop("snapshot", None)
378
+
379
+ save_manifest(context, m, change)
380
+ return CommandResult("SUCCESS|ROLLBACK|restored", EXIT_OK)
381
+
382
+ return with_write_lock(context, _do, change=change)
383
+
384
+
385
+ def cmd_checkpoint(context, summary, change=None):
386
+ """Guarda un checkpoint con el resumen de la sesión en manifest.json."""
387
+ if len(summary) > MAX_SUMMARY_CHARS:
388
+ return CommandResult(
389
+ f"FAIL|SUMMARY_TOO_LONG|{len(summary)}/{MAX_SUMMARY_CHARS}",
390
+ EXIT_VALIDATION,
391
+ )
392
+
393
+ def _do():
394
+ m = load_manifest(context, change)
395
+ if not m:
396
+ return missing_session_result(context, change)
397
+
398
+ session_sec = m.setdefault("session", {})
399
+ session_sec["session_summary"] = summary
400
+ save_manifest(context, m, change)
401
+ return CommandResult("SUCCESS|CHECKPOINT_SAVED", EXIT_OK)
402
+
403
+ return with_write_lock(context, _do, change=change)
@@ -0,0 +1,280 @@
1
+ #!/usr/bin/env python3
2
+ """MCP Server for Context Guard.
3
+
4
+ Exposes the transactional pipeline of context-guard as native MCP tools
5
+ over stdio transport. All persistent state lives in
6
+ `{context}/.context-guard/manifest.json`, where `context` is the absolute
7
+ path to the project directory.
8
+
9
+ Pipeline contract (strictly enforced):
10
+ PLAN -> EXECUTE -> VERIFY -> ARCHIVE
11
+
12
+ A typical lifecycle:
13
+ 1. begin_transaction("/home/user/my-project", "PLAN")
14
+ 2. save_checkpoint("/home/user/my-project", "objective defined, tasks decomposed")
15
+ 3. commit_transaction("/home/user/my-project", "EXECUTE")
16
+ 4. begin_transaction("/home/user/my-project", "EXECUTE")
17
+ 5. ... work on tasks ...
18
+ 6. save_checkpoint("/home/user/my-project", "all tasks complete")
19
+ 7. commit_transaction("/home/user/my-project", "VERIFY")
20
+ 8. begin_transaction("/home/user/my-project", "VERIFY")
21
+ 9. ... run tests, validate artifacts ...
22
+ 10. commit_transaction("/home/user/my-project", "ARCHIVE")
23
+ """
24
+
25
+ import os
26
+ import sys
27
+
28
+ from mcp.server.fastmcp import FastMCP
29
+ from context_guard.guard.transaction import (
30
+ cmd_begin,
31
+ cmd_commit,
32
+ cmd_rollback,
33
+ cmd_checkpoint,
34
+ )
35
+ from context_guard.guard.commands import (
36
+ cmd_check_completion,
37
+ cmd_next_task,
38
+ cmd_status,
39
+ cmd_validate,
40
+ )
41
+ from context_guard.guard.errors import GuardError, EXIT_GENERIC
42
+
43
+ mcp = FastMCP("context-guard")
44
+
45
+
46
+ def _format_result(fn, *args, **kwargs) -> str:
47
+ """Execute a guard command function, unwrap CommandResult to a readable string."""
48
+ try:
49
+ res = fn(*args, **kwargs)
50
+ return f"[{res.exit_code}] {res.message}"
51
+ except GuardError as e:
52
+ return f"[{e.exit_code}] {e.message}"
53
+ except Exception as e:
54
+ return f"[{EXIT_GENERIC}] FAIL|UNEXPECTED_ERROR|{str(e)}"
55
+
56
+
57
+ @mcp.tool()
58
+ def begin_transaction(context: str, phase: str, change: str | None = None) -> str:
59
+ """Start a transactional phase in the 3-state pipeline.
60
+
61
+ The pipeline enforces strict ordering: PLAN -> EXECUTE -> VERIFY -> ARCHIVE.
62
+ You must begin a phase before doing work in it, and commit it before
63
+ advancing to the next. Only one transaction can be active at a time per
64
+ context; attempting to begin while another is in progress returns an error
65
+ (unless the existing transaction has expired past its TTL).
66
+
67
+ If phase is 'PLAN', this tool automatically scaffolds 5 markdown files in
68
+ .context-guard/: objective.md, snapshot.md, tasks.md, review-report.md,
69
+ and verify-report.md with default placeholder text if they do not exist.
70
+
71
+ A snapshot of the current manifest state is captured automatically so that
72
+ rollback_transaction can restore it if the phase fails.
73
+
74
+ Args:
75
+ context: ABSOLUTE PATH to the current project directory
76
+ (e.g. /home/user/workspace/my-project).
77
+ phase: One of 'PLAN', 'EXECUTE', or 'VERIFY'. Any other value is rejected.
78
+
79
+ change: OPTIONAL name of the change to operate on. Omit it when the
80
+ project has exactly one active change; if several are active,
81
+ omitting it is an error naming them, never a silent guess.
82
+
83
+ Returns:
84
+ '[0] SUCCESS|BEGIN|phase={phase}' on success, or an error string with
85
+ a non-zero exit code.
86
+ """
87
+ return _format_result(cmd_begin, context, phase, change=change)
88
+
89
+
90
+ @mcp.tool()
91
+ def commit_transaction(context: str, next_phase: str, change: str | None = None) -> str:
92
+ """Finalize the active phase and advance the pipeline to the next state.
93
+
94
+ Validates that the transition is legal according to the DAG:
95
+ PLAN -> EXECUTE, EXECUTE -> VERIFY, VERIFY -> ARCHIVE.
96
+ Skipping phases (e.g. PLAN -> VERIFY) is rejected with EXIT_BAD_TRANSITION.
97
+
98
+ Phase transitions will be rejected with EXIT_VALIDATION if required artifact
99
+ files contain '[PENDING]' placeholder text or are missing:
100
+ - PLAN -> EXECUTE requires completing objective.md and tasks.md.
101
+ - VERIFY -> ARCHIVE requires completing review-report.md and verify-report.md.
102
+
103
+ Committing consolidates state: marks the current phase as completed, updates
104
+ lock_phase to next_phase, and generates a deterministic auto-summary.
105
+
106
+ Args:
107
+ context: ABSOLUTE PATH to the current project directory
108
+ (e.g. /home/user/workspace/my-project).
109
+ next_phase: The phase to advance to. Must be the legal successor of the
110
+ currently active phase ('EXECUTE', 'VERIFY', or 'ARCHIVE').
111
+
112
+ change: OPTIONAL name of the change to operate on. Omit it when the
113
+ project has exactly one active change; if several are active,
114
+ omitting it is an error naming them, never a silent guess.
115
+
116
+ Returns:
117
+ '[0] SUCCESS|COMMIT|lock_phase={next_phase}' on success, or an error
118
+ string with a non-zero exit code.
119
+ """
120
+ return _format_result(cmd_commit, context, next_phase, change=change)
121
+
122
+
123
+ @mcp.tool()
124
+ def rollback_transaction(context: str, change: str | None = None) -> str:
125
+ """Abort the active transaction and restore the pre-begin manifest snapshot.
126
+
127
+ Use this when a phase fails (e.g. tests don't pass during VERIFY, or an
128
+ execution step produces broken state). The manifest is rolled back to
129
+ exactly the state it was in before begin_transaction was called, and the
130
+ transaction status is reset to idle.
131
+
132
+ Args:
133
+ context: ABSOLUTE PATH to the current project directory
134
+ (e.g. /home/user/workspace/my-project).
135
+
136
+ change: OPTIONAL name of the change to operate on. Omit it when the
137
+ project has exactly one active change; if several are active,
138
+ omitting it is an error naming them, never a silent guess.
139
+
140
+ Returns:
141
+ '[0] SUCCESS|ROLLBACK|restored' on success, or an error string with a
142
+ non-zero exit code if no transaction is in progress.
143
+ """
144
+ return _format_result(cmd_rollback, context, change=change)
145
+
146
+
147
+ @mcp.tool()
148
+ def save_checkpoint(context: str, summary: str, change: str | None = None) -> str:
149
+ """Persist a session summary as a lightweight checkpoint in manifest.json.
150
+
151
+ Checkpoints serve as warm-boot state: if the agent loses context (e.g.
152
+ session timeout, token limit), it can read the last checkpoint to resume.
153
+ The summary is stored in manifest.json under session.session_summary.
154
+
155
+ Note: commit_transaction also writes an auto-generated checkpoint, so
156
+ manual checkpoints are mainly useful mid-phase to record intermediate
157
+ progress before committing.
158
+
159
+ Args:
160
+ context: ABSOLUTE PATH to the current project directory
161
+ (e.g. /home/user/workspace/my-project).
162
+ summary: Free-form text summarizing current progress. Maximum 2000
163
+ characters (~500 tokens). Exceeding the limit returns
164
+ EXIT_VALIDATION.
165
+
166
+ change: OPTIONAL name of the change to operate on. Omit it when the
167
+ project has exactly one active change; if several are active,
168
+ omitting it is an error naming them, never a silent guess.
169
+
170
+ Returns:
171
+ '[0] SUCCESS|CHECKPOINT_SAVED' on success, or an error string with a
172
+ non-zero exit code.
173
+ """
174
+ return _format_result(cmd_checkpoint, context, summary, change=change)
175
+
176
+
177
+ @mcp.tool()
178
+ def get_status(context: str, change: str | None = None) -> str:
179
+ """Read the whole state of a change in one call, for warm-boot rehydration.
180
+
181
+ Returns the context name, the first line of objective.md, task progress as
182
+ completed/total, the next unclaimed pending task, and whether the session
183
+ lock is held. This is the tool to call first after losing context: it is
184
+ cheaper than reading the artifacts and it never guesses.
185
+
186
+ Args:
187
+ context: ABSOLUTE PATH to the current project directory.
188
+ change: OPTIONAL name of the change to operate on. Omit it when the
189
+ project has exactly one active change; if several are active,
190
+ omitting it is an error naming them, never a silent guess.
191
+
192
+ Returns:
193
+ '[0] CONTEXT: ... / OBJECTIVE: ... / PROGRESS: n/m tasks complete /
194
+ NEXT: ... / LOCK: ...' on success.
195
+ """
196
+ return _format_result(cmd_status, context, change)
197
+
198
+
199
+ @mcp.tool()
200
+ def check_completion(context: str, change: str | None = None) -> str:
201
+ """Count the checkboxes in tasks.md deterministically.
202
+
203
+ Use this instead of counting by eye before advancing a phase — the parser
204
+ is the source of truth for whether the work is done, and it does not
205
+ hallucinate a total.
206
+
207
+ Args:
208
+ context: ABSOLUTE PATH to the current project directory.
209
+ change: OPTIONAL name of the change to operate on. Omit it when the
210
+ project has exactly one active change; if several are active,
211
+ omitting it is an error naming them, never a silent guess.
212
+
213
+ Returns:
214
+ '[0] source=tasks.md / total=N / completed=M / all_complete=true|false'.
215
+ """
216
+ return _format_result(cmd_check_completion, context, change)
217
+
218
+
219
+ @mcp.tool()
220
+ def validate(context: str, change: str | None = None) -> str:
221
+ """Lint the session artifacts: existence, size cap, and language.
222
+
223
+ Requires objective.md, snapshot.md and tasks.md to exist; checks every
224
+ artifact against the size cap and rejects Spanish text (artifacts are
225
+ English by contract). Does NOT check for leftover '[PENDING]' markers —
226
+ that is enforced by commit_transaction.
227
+
228
+ Args:
229
+ context: ABSOLUTE PATH to the current project directory.
230
+ change: OPTIONAL name of the change to operate on. Omit it when the
231
+ project has exactly one active change; if several are active,
232
+ omitting it is an error naming them, never a silent guess.
233
+
234
+ Returns:
235
+ '[0] SUCCESS|VALIDATE_OK', or '[4]' followed by one FAIL line per
236
+ problem found.
237
+ """
238
+ return _format_result(cmd_validate, context, None, change)
239
+
240
+
241
+ @mcp.tool()
242
+ def next_task(context: str, change: str | None = None,
243
+ agent_id: str | None = None) -> str:
244
+ """Claim the next pending task in tasks.md and return it.
245
+
246
+ Not read-only: the claim is taken atomically as part of the same call,
247
+ which is what makes it safe for several agents to work one change at once.
248
+ The returned agent_id is the identity that won the claim — keep it, since
249
+ releasing the task requires passing it back.
250
+
251
+ Claims carry a lease; a task whose lease expired is treated as abandoned
252
+ and handed to the next caller, with the takeover recorded in the manifest.
253
+
254
+ Args:
255
+ context: ABSOLUTE PATH to the current project directory.
256
+ change: OPTIONAL name of the change to operate on. Omit it when the
257
+ project has exactly one active change; if several are active,
258
+ omitting it is an error naming them, never a silent guess.
259
+ agent_id: OPTIONAL identity to claim as. Generated if omitted.
260
+
261
+ Returns:
262
+ '[0] SUCCESS|NEXT_TASK|{task_id}|{agent_id}|{description}', or
263
+ '[0] DONE|NO_PENDING_TASKS' when nothing is left.
264
+ """
265
+ return _format_result(cmd_next_task, context, agent_id, change)
266
+
267
+
268
+ # Deliberately NOT exposed as a tool: `approve`. PLAN.md 0.6 names the
269
+ # harness's permission prompt on `cg approve` as the only hard control in the
270
+ # enforcement model, and an MCP tool is precisely the channel that routes
271
+ # around it. The MCP is a transport; the human's confirmation is not.
272
+
273
+
274
+ def main():
275
+ """Run FastMCP server over stdio transport."""
276
+ mcp.run(transport="stdio")
277
+
278
+
279
+ if __name__ == "__main__":
280
+ main()