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,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()
|