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,71 @@
1
+ """Exit codes, typed exceptions, and command result type for guard middleware."""
2
+
3
+ from collections import namedtuple
4
+
5
+ # ---------------------------------------------------------------------------
6
+ # Exit codes — machine-readable, consumidos por el harness
7
+ # ---------------------------------------------------------------------------
8
+
9
+ EXIT_OK = 0
10
+ EXIT_GENERIC = 1 # corrupt manifest, missing session
11
+ EXIT_LOCK_HELD = 2 # another agent holds the lock/claim — retry with backoff
12
+ EXIT_LOCK_CONTENDED = 3 # lost the takeover race — retryable
13
+ EXIT_VALIDATION = 4 # artifact missing / [PENDING] / too long / wrong language
14
+ EXIT_BAD_TRANSITION = 5 # phase not authorized by the DAG — do NOT retry
15
+ EXIT_APPROVAL_REQUIRED = 6 # human approval missing — only a human resolves it
16
+
17
+
18
+ # ---------------------------------------------------------------------------
19
+ # Command result — retornado por toda función de negocio
20
+ # ---------------------------------------------------------------------------
21
+
22
+ CommandResult = namedtuple("CommandResult", ["message", "exit_code"])
23
+
24
+
25
+ # ---------------------------------------------------------------------------
26
+ # Typed exceptions — para errores irrecuperables
27
+ # ---------------------------------------------------------------------------
28
+
29
+ class GuardError(Exception):
30
+ """Error base del middleware. Incluye exit_code para que cli.py traduzca."""
31
+ def __init__(self, message, exit_code=EXIT_GENERIC):
32
+ super().__init__(message)
33
+ self.message = message
34
+ self.exit_code = exit_code
35
+
36
+
37
+ class ManifestCorruptError(GuardError):
38
+ """manifest.json existe pero no es JSON válido."""
39
+ def __init__(self, message):
40
+ super().__init__(f"FAIL|CORRUPT_MANIFEST|{message}", EXIT_GENERIC)
41
+
42
+
43
+ class ValidationError(GuardError):
44
+ """Un artefacto no pasó validación (faltante, excede cap, etc.)."""
45
+ def __init__(self, failures):
46
+ msg = "\n".join(f"FAIL|{f}" for f in failures)
47
+ super().__init__(msg, EXIT_VALIDATION)
48
+
49
+
50
+ class AmbiguousChangeError(GuardError):
51
+ """Varios changes activos y ninguno indicado explícitamente.
52
+
53
+ Never resolved by picking one: guessing here means the agent operates on a
54
+ change it did not choose while believing it did.
55
+ """
56
+ def __init__(self, message):
57
+ super().__init__(message, EXIT_VALIDATION)
58
+
59
+
60
+ class LegacyLayoutError(GuardError):
61
+ """El contexto usa el layout plano de 1.x y necesita migración.
62
+
63
+ Reported rather than silently ignored: starting a fresh empty change on
64
+ top of a 1.x context makes the user's existing work look like it vanished.
65
+ """
66
+ def __init__(self, base):
67
+ super().__init__(
68
+ f"FAIL|LEGACY_LAYOUT|{base}|run `cg migrate` to convert it",
69
+ EXIT_GENERIC,
70
+ )
71
+
@@ -0,0 +1,181 @@
1
+ """Locking primitives for guard middleware.
2
+
3
+ Two independent lock levels:
4
+ - Session lock: OS-level lockfile (.lock) for cold-boot and archival
5
+ - Write lock: short-lived mutex (.write.lock) for serializing read-modify-write
6
+ """
7
+
8
+ import os
9
+ import time
10
+ from datetime import datetime
11
+
12
+ from .paths import get_paths, generate_agent_id
13
+ from .manifest import load_manifest, save_manifest, create_initial_manifest
14
+ from .errors import (
15
+ CommandResult,
16
+ EXIT_OK,
17
+ EXIT_LOCK_HELD,
18
+ EXIT_LOCK_CONTENDED,
19
+ )
20
+
21
+
22
+ # ---------------------------------------------------------------------------
23
+ # Write lock — mutex de milisegundos para serializar read-modify-write
24
+ # ---------------------------------------------------------------------------
25
+
26
+ WRITE_LOCK_MAX_AGE = 30 # seconds before a write lock is considered stale
27
+ WRITE_LOCK_HARD_CAP_FACTOR = 10 # age past which we stop trusting the PID
28
+
29
+
30
+ def _is_write_lock_stale(lockfile):
31
+ """Detecta si un .write.lock es huérfano (proceso muerto o demasiado viejo).
32
+
33
+ Liveness is the primary signal. Age alone must NOT declare a lock stale:
34
+ the write lock serializes read-modify-write on the manifest, and tearing
35
+ it away from a process that is still running lets two writers race and
36
+ silently lose one of the writes.
37
+
38
+ The one exception is the hard cap. A PID can be reused by an unrelated
39
+ process, and trusting liveness forever would turn a recycled PID into a
40
+ permanent deadlock, so past WRITE_LOCK_HARD_CAP_FACTOR x WRITE_LOCK_MAX_AGE
41
+ we stop believing the PID belongs to the original owner.
42
+
43
+ Returns:
44
+ True si el lock es stale y puede ser removido de forma segura.
45
+ """
46
+ try:
47
+ with open(lockfile, "r") as f:
48
+ lines = f.readlines()
49
+
50
+ pid_alive = False
51
+ if len(lines) >= 1:
52
+ pid = int(lines[0].strip())
53
+ try:
54
+ os.kill(pid, 0)
55
+ pid_alive = True
56
+ except OSError:
57
+ return True # proceso muerto, lock huérfano
58
+
59
+ if len(lines) >= 2:
60
+ created = float(lines[1].strip())
61
+ age = time.time() - created
62
+ if pid_alive:
63
+ return age > WRITE_LOCK_MAX_AGE * WRITE_LOCK_HARD_CAP_FACTOR
64
+ if age > WRITE_LOCK_MAX_AGE:
65
+ return True
66
+ except (ValueError, IOError):
67
+ return True # no se puede leer, asumir stale
68
+ return False
69
+
70
+
71
+ def with_write_lock(context, fn, timeout=5, retry_interval=0.05, change=None):
72
+ """Mutex de milisegundos para serializar read-modify-write.
73
+
74
+ Independiente del lock de negocio (que dura toda la sesión).
75
+ Escribe PID + timestamp en el lockfile para stale-detection.
76
+ Cada change tiene su propio write lock: dos changes son dos workstreams y
77
+ no deben serializarse entre sí.
78
+ """
79
+ p = get_paths(context, change)
80
+ lockfile = p["write_lock"]
81
+ os.makedirs(os.path.dirname(lockfile), exist_ok=True)
82
+ start = time.time()
83
+ while True:
84
+ try:
85
+ fd = os.open(lockfile, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
86
+ os.write(fd, f"{os.getpid()}\n{time.time()}\n".encode())
87
+ os.close(fd)
88
+ break
89
+ except FileExistsError:
90
+ if _is_write_lock_stale(lockfile):
91
+ try:
92
+ os.remove(lockfile)
93
+ continue
94
+ except FileNotFoundError:
95
+ continue
96
+ if time.time() - start > timeout:
97
+ raise TimeoutError("write lock contention")
98
+ time.sleep(retry_interval)
99
+ try:
100
+ return fn()
101
+ finally:
102
+ try:
103
+ os.remove(lockfile)
104
+ except FileNotFoundError:
105
+ pass
106
+
107
+
108
+ # ---------------------------------------------------------------------------
109
+ # Session lock — lockfile a nivel de SO
110
+ # ---------------------------------------------------------------------------
111
+
112
+ def try_create_lockfile(context, change=None):
113
+ """Atomic test-and-set at the OS level. Returns True if acquired."""
114
+ p = get_paths(context, change)
115
+ os.makedirs(os.path.dirname(p["lock"]), exist_ok=True)
116
+ try:
117
+ fd = os.open(p["lock"], os.O_CREAT | os.O_EXCL | os.O_WRONLY)
118
+ os.close(fd)
119
+ return True
120
+ except FileExistsError:
121
+ return False
122
+
123
+
124
+ def acquire(context, ttl, change=None):
125
+ """Lógica compartida de claim/acquire: intenta tomar el lock, hace
126
+ stale-takeover si corresponde.
127
+
128
+ Returns:
129
+ CommandResult con message y exit_code.
130
+ """
131
+ p = get_paths(context, change)
132
+ os.makedirs(p["base"], exist_ok=True)
133
+ m = load_manifest(context, change)
134
+ if not m:
135
+ m = create_initial_manifest(context, p["change"])
136
+
137
+
138
+ if not try_create_lockfile(context, change):
139
+ existing = m.get("lock", {})
140
+ acquired_at = existing.get("acquired_at")
141
+ ttl_existing = existing.get("ttl_seconds", ttl)
142
+ stale = False
143
+ if acquired_at:
144
+ elapsed = (datetime.now() - datetime.fromisoformat(acquired_at)).total_seconds()
145
+ stale = elapsed > ttl_existing
146
+ else:
147
+ # Orphan lockfile: a peer died between creating .lock and recording
148
+ # its metadata. Without a fallback the staleness check silently
149
+ # evaluates to False and the session deadlocks forever, so age the
150
+ # lock by the file's own mtime instead.
151
+ try:
152
+ elapsed = time.time() - os.path.getmtime(p["lock"])
153
+ stale = elapsed > ttl_existing
154
+ except OSError:
155
+ # Lockfile vanished between the failed create and this stat —
156
+ # another agent released it; fall through and retry the create.
157
+ stale = True
158
+
159
+ if not stale:
160
+ return CommandResult(
161
+ f"FAIL|LOCK_HELD|{existing.get('acquired_by')}",
162
+ EXIT_LOCK_HELD,
163
+ )
164
+
165
+ try:
166
+ os.remove(p["lock"])
167
+ except FileNotFoundError:
168
+ # Another agent released or took over the lock first; the create
169
+ # below is what decides who actually wins.
170
+ pass
171
+ if not try_create_lockfile(context, change):
172
+ return CommandResult("FAIL|LOCK_CONTENDED", EXIT_LOCK_CONTENDED)
173
+
174
+ m["lock"] = {
175
+ "held": True,
176
+ "acquired_at": datetime.now().isoformat(),
177
+ "acquired_by": generate_agent_id(),
178
+ "ttl_seconds": ttl,
179
+ }
180
+ save_manifest(context, m, change)
181
+ return CommandResult("SUCCESS|LOCK_ACQUIRED", EXIT_OK)
@@ -0,0 +1,69 @@
1
+ """Manifest I/O with atomic writes for guard middleware."""
2
+
3
+ import json
4
+ import os
5
+
6
+ from .paths import get_paths
7
+ from .errors import ManifestCorruptError
8
+
9
+
10
+ DEFAULT_PIPELINE = ["PLAN", "EXECUTE", "VERIFY"]
11
+
12
+ # Bumped for the multi-change layout: manifests now live per change under
13
+ # .context-guard/changes/{name}/ and carry the change name.
14
+ SCHEMA_VERSION = 3
15
+
16
+
17
+ def create_initial_manifest(context, change=None):
18
+ """Crea una estructura de manifest inicial con el pipeline de 3 estados."""
19
+ return {
20
+ "schema_version": SCHEMA_VERSION,
21
+ "context_name": context,
22
+ "change_name": change,
23
+ "current_phase": "PLAN",
24
+ "lock_phase": "PLAN",
25
+ "completed_phases": [],
26
+ "pending_phases": list(DEFAULT_PIPELINE),
27
+ "lock": {},
28
+ "transaction": {
29
+ "txn_status": "idle",
30
+ "txn_phase": "None",
31
+ "txn_started_at": None,
32
+ },
33
+ "reference_docs": [],
34
+ "files_in_scope": [],
35
+ "task_claims": {},
36
+ }
37
+
38
+
39
+ def load_manifest(context, change=None):
40
+ """Carga el manifest del change dado.
41
+
42
+ Returns:
43
+ dict or None: El manifest parseado, o None si no existe.
44
+
45
+ Raises:
46
+ ManifestCorruptError: Si el archivo existe pero no es JSON válido.
47
+ """
48
+ p = get_paths(context, change)
49
+ if not os.path.exists(p["manifest"]):
50
+ return None
51
+ try:
52
+ with open(p["manifest"], "r") as f:
53
+ return json.load(f)
54
+ except (json.JSONDecodeError, ValueError) as e:
55
+ raise ManifestCorruptError(str(e))
56
+
57
+
58
+ def save_manifest(context, data, change=None):
59
+ """Escribe el manifest con write atómico (tmp + rename).
60
+
61
+ Crea los directorios necesarios si no existen.
62
+ """
63
+ p = get_paths(context, change)
64
+ os.makedirs(os.path.dirname(p["manifest"]), exist_ok=True)
65
+ tmp_path = p["manifest"] + ".tmp"
66
+ with open(tmp_path, "w") as f:
67
+ json.dump(data, f, indent=2)
68
+ os.rename(tmp_path, p["manifest"])
69
+
@@ -0,0 +1,288 @@
1
+ """Migration from the two legacy layouts into the multi-change layout.
2
+
3
+ Two sources are supported:
4
+
5
+ - state-guard's `.state-guard/changes/{name}/state.ini` (schema v2, INI)
6
+ - context-guard 1.x's flat `.context-guard/manifest.json`
7
+
8
+ Both are *copied*, never moved: if a migration produces something wrong the
9
+ user still has the original to go back to. Migration is idempotent, and it
10
+ refuses to overwrite a change that already exists — the one thing worse than
11
+ not migrating is migrating over live work.
12
+ """
13
+
14
+ import configparser
15
+ import os
16
+ import shutil
17
+
18
+ from .manifest import (
19
+ DEFAULT_PIPELINE,
20
+ SCHEMA_VERSION,
21
+ create_initial_manifest,
22
+ load_manifest,
23
+ save_manifest,
24
+ )
25
+ from .paths import (
26
+ DEFAULT_CHANGE,
27
+ get_archive_dir,
28
+ get_base,
29
+ get_changes_dir,
30
+ get_paths,
31
+ get_root,
32
+ list_changes,
33
+ )
34
+ from .errors import CommandResult, EXIT_OK, EXIT_VALIDATION
35
+
36
+ STATE_GUARD_DIRNAME = ".state-guard"
37
+
38
+ # state-guard writes phases in lowercase; this pipeline is uppercase. A missed
39
+ # conversion leaves lock_phase="execute", which matches no valid phase and
40
+ # locks the change out of every transition.
41
+ _NO_PHASE = {"", "none", "None"}
42
+
43
+ # Artifacts that must exist for the normal gates to work. A migrated change
44
+ # genuinely lacks some of them, and saying so with [PENDING] routes it through
45
+ # the usual validation instead of pretending it is complete.
46
+ _REQUIRED_ARTIFACTS = {
47
+ "objective.md": "[PENDING] Define objective here",
48
+ "snapshot.md": "[PENDING] Define snapshot here",
49
+ "tasks.md": "[PENDING] Define tasks here",
50
+ "review-report.md": "[PENDING] Write static review here",
51
+ "verify-report.md": "[PENDING] Write dynamic verification here",
52
+ }
53
+
54
+
55
+ def _normalise_phase(value, default="PLAN"):
56
+ if value is None:
57
+ return default
58
+ value = value.strip()
59
+ if value in _NO_PHASE:
60
+ return default
61
+ return value.upper()
62
+
63
+
64
+ def _normalise_phase_list(value):
65
+ if not value:
66
+ return []
67
+ out = []
68
+ for item in value.split(","):
69
+ item = item.strip()
70
+ if item and item not in _NO_PHASE:
71
+ out.append(item.upper())
72
+ return out
73
+
74
+
75
+ def _split_recognised_phases(phases):
76
+ """Separate phases this pipeline knows about from ones it does not.
77
+
78
+ state-guard tracked pseudo-phases (a 'hotfix' bypass state, in the audit
79
+ that found this) that are not PLAN/EXECUTE/VERIFY. Carrying one straight
80
+ into completed_phases plants a value no future DAG invariant check would
81
+ expect. Silently dropping it would be just as wrong — it is real history
82
+ the user might want back — so the unrecognised ones go to legacy_phases
83
+ instead of vanishing.
84
+ """
85
+ recognised = [p for p in phases if p in DEFAULT_PIPELINE]
86
+ unrecognised = [p for p in phases if p not in DEFAULT_PIPELINE]
87
+ return recognised, unrecognised
88
+
89
+
90
+ def _state_guard_changes_dir(context):
91
+ return os.path.join(get_root(context), STATE_GUARD_DIRNAME, "changes")
92
+
93
+
94
+ def _find_state_guard_changes(context):
95
+ """Names of state-guard changes that carry a state.ini."""
96
+ root = _state_guard_changes_dir(context)
97
+ if not os.path.isdir(root):
98
+ return []
99
+ names = []
100
+ for entry in sorted(os.listdir(root)):
101
+ if entry == "archive":
102
+ continue
103
+ if os.path.exists(os.path.join(root, entry, "state.ini")):
104
+ names.append(entry)
105
+ return names
106
+
107
+
108
+ def _manifest_from_state_ini(context, change, ini_path):
109
+ """Translate a state-guard state.ini (schema v2) into a manifest v3."""
110
+ config = configparser.ConfigParser()
111
+ config.read(ini_path, encoding="utf-8")
112
+
113
+ manifest = create_initial_manifest(context, change)
114
+
115
+ current = _normalise_phase(config.get("Graph", "current_phase", fallback=None))
116
+ lock_phase = _normalise_phase(config.get("Graph", "lock_phase", fallback=None))
117
+ completed_raw = _normalise_phase_list(
118
+ config.get("Graph", "completed_phases", fallback=""))
119
+ completed, legacy_phases = _split_recognised_phases(completed_raw)
120
+ pending = _normalise_phase_list(
121
+ config.get("Graph", "pending_phases", fallback=""))
122
+
123
+ manifest["current_phase"] = current
124
+ manifest["lock_phase"] = lock_phase
125
+ manifest["completed_phases"] = completed
126
+ manifest["pending_phases"] = pending or [
127
+ p for p in DEFAULT_PIPELINE if p not in completed
128
+ ]
129
+ if legacy_phases:
130
+ manifest["legacy_phases"] = legacy_phases
131
+
132
+ summary = config.get("Session", "session_summary", fallback="").strip()
133
+ if summary:
134
+ manifest.setdefault("session", {})["session_summary"] = summary
135
+
136
+ # A recorded human approval is preserved. Discarding it would make the user
137
+ # re-approve work they already signed off on. Read only — the approval flow
138
+ # itself is not wired up here.
139
+ if config.has_section("Gate"):
140
+ approved_at = config.get("Gate", "plan_approved_at", fallback="").strip()
141
+ approved_by = config.get("Gate", "plan_approved_by", fallback="").strip()
142
+ if approved_at or approved_by:
143
+ approval = {"by": approved_by or "unknown", "at": approved_at or None}
144
+ reason = config.get("Gate", "hotfix_bypass_reason", fallback="").strip()
145
+ if reason:
146
+ approval["hotfix"] = True
147
+ approval["reason"] = reason
148
+ manifest["approval"] = approval
149
+
150
+ manifest["migrated_from"] = "state-guard/state.ini"
151
+ return manifest
152
+
153
+
154
+ def _copy_artifacts(src_dir, dest_dir, skip=()):
155
+ """Copy every regular file from a legacy change directory."""
156
+ os.makedirs(dest_dir, exist_ok=True)
157
+ for entry in sorted(os.listdir(src_dir)):
158
+ if entry in skip:
159
+ continue
160
+ src = os.path.join(src_dir, entry)
161
+ if not os.path.isfile(src):
162
+ continue
163
+ shutil.copy2(src, os.path.join(dest_dir, entry))
164
+
165
+
166
+ def _scaffold_missing_artifacts(dest_dir):
167
+ for name, placeholder in _REQUIRED_ARTIFACTS.items():
168
+ path = os.path.join(dest_dir, name)
169
+ if not os.path.exists(path):
170
+ with open(path, "w", encoding="utf-8") as f:
171
+ f.write(placeholder)
172
+
173
+
174
+ def _migrate_state_guard(context, findings):
175
+ """Copy every state-guard change into changes/{name}/."""
176
+ src_root = _state_guard_changes_dir(context)
177
+ for name in _find_state_guard_changes(context):
178
+ src_dir = os.path.join(src_root, name)
179
+ p = get_paths(context, name)
180
+
181
+ # Idempotence and safety are the same rule here: an existing change may
182
+ # already carry work done after a previous migration.
183
+ if os.path.exists(p["manifest"]):
184
+ findings.append(f"SKIP|{name}|already migrated")
185
+ continue
186
+
187
+ os.makedirs(p["base"], exist_ok=True)
188
+ _copy_artifacts(src_dir, p["base"], skip=("state.ini",))
189
+ _scaffold_missing_artifacts(p["base"])
190
+ manifest = _manifest_from_state_ini(
191
+ context, name, os.path.join(src_dir, "state.ini"))
192
+ save_manifest(context, manifest, name)
193
+ findings.append(f"MIGRATED|{name}|from state-guard")
194
+
195
+
196
+ def _migrate_flat_layout(context, findings):
197
+ """Move a context-guard 1.x flat layout into changes/default/."""
198
+ base = get_base(context)
199
+ flat_manifest = os.path.join(base, "manifest.json")
200
+ if not os.path.exists(flat_manifest):
201
+ return None
202
+
203
+ dest = get_paths(context, DEFAULT_CHANGE)
204
+ if os.path.exists(dest["manifest"]):
205
+ # The flat data and a live `default` change cannot both be right, and
206
+ # picking one silently would destroy the other.
207
+ return CommandResult(
208
+ f"FAIL|MIGRATE_CONFLICT|{DEFAULT_CHANGE}|"
209
+ "a flat 1.x manifest and an existing change both claim this name",
210
+ EXIT_VALIDATION,
211
+ )
212
+
213
+ with open(flat_manifest, "r", encoding="utf-8") as f:
214
+ import json
215
+ manifest = json.load(f)
216
+
217
+ manifest["schema_version"] = SCHEMA_VERSION
218
+ manifest["change_name"] = DEFAULT_CHANGE
219
+ manifest["migrated_from"] = "context-guard/1.x-flat"
220
+
221
+ os.makedirs(dest["base"], exist_ok=True)
222
+ for entry in sorted(os.listdir(base)):
223
+ if entry in ("manifest.json", "changes", "archive"):
224
+ continue
225
+ src = os.path.join(base, entry)
226
+ if os.path.isfile(src):
227
+ shutil.copy2(src, os.path.join(dest["base"], entry))
228
+
229
+ _scaffold_missing_artifacts(dest["base"])
230
+ save_manifest(context, manifest, DEFAULT_CHANGE)
231
+
232
+ # The flat manifest has to go, or is_legacy_flat_layout keeps firing and
233
+ # the context stays permanently "unmigrated".
234
+ os.remove(flat_manifest)
235
+ findings.append(f"MIGRATED|{DEFAULT_CHANGE}|from context-guard 1.x flat layout")
236
+ return None
237
+
238
+
239
+ def _migrate_flat_archive(context, findings):
240
+ """Copy a 1.x flat layout's own archive/ subdirectories into
241
+ changes/archive/.
242
+
243
+ _migrate_flat_layout only ever copied files (`if os.path.isfile(src)`),
244
+ so a 1.x archive/ full of completed changes was silently left behind,
245
+ orphaned next to the new (empty) changes/archive/ — two archive
246
+ directories where the old one nothing pointed at anymore. Runs
247
+ independently of whether a flat manifest.json still exists, since the
248
+ archive can outlive it (e.g. the main manifest was already migrated by
249
+ an older `cg migrate` before this fix shipped).
250
+
251
+ Copies, not moves, like every other path in this module: if this
252
+ produces something wrong the original archive is still there.
253
+ """
254
+ old_archive = os.path.join(get_base(context), "archive")
255
+ if not os.path.isdir(old_archive):
256
+ return
257
+
258
+ new_archive = get_archive_dir(context)
259
+ for entry in sorted(os.listdir(old_archive)):
260
+ src = os.path.join(old_archive, entry)
261
+ if not os.path.isdir(src):
262
+ continue
263
+ dest = os.path.join(new_archive, entry)
264
+ if os.path.exists(dest):
265
+ findings.append(f"SKIP|archive/{entry}|already migrated")
266
+ continue
267
+ os.makedirs(new_archive, exist_ok=True)
268
+ shutil.copytree(src, dest)
269
+ findings.append(f"MIGRATED|archive/{entry}|from context-guard 1.x flat archive")
270
+
271
+
272
+ def cmd_migrate(context):
273
+ """Convierte los layouts legacy al layout multi-change.
274
+
275
+ Idempotent: changes that already exist are skipped, not overwritten.
276
+ """
277
+ findings = []
278
+
279
+ conflict = _migrate_flat_layout(context, findings)
280
+ if conflict is not None:
281
+ return conflict
282
+
283
+ _migrate_flat_archive(context, findings)
284
+ _migrate_state_guard(context, findings)
285
+
286
+ if not findings:
287
+ return CommandResult("SUCCESS|NOTHING_TO_MIGRATE", EXIT_OK)
288
+ return CommandResult("\n".join(findings), EXIT_OK)