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