aiblueprint-cli 1.4.99 → 1.4.100

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 (96) hide show
  1. package/agents-config/skills/agents-manager/SKILL.md +2 -2
  2. package/agents-config/skills/agents-manager/agents/openai.yaml +7 -0
  3. package/agents-config/skills/agents-manager/assets/codex-icon.svg +20 -0
  4. package/agents-config/skills/apex/SKILL.md +120 -118
  5. package/agents-config/skills/apex/agents/openai.yaml +10 -0
  6. package/agents-config/skills/apex/assets/codex-icon.svg +15 -0
  7. package/agents-config/skills/apex/scripts/apex-state.py +740 -0
  8. package/agents-config/skills/apex/scripts/setup-templates.sh +27 -145
  9. package/agents-config/skills/apex/scripts/test_apex_state.py +413 -0
  10. package/agents-config/skills/apex/scripts/update-progress.sh +17 -73
  11. package/agents-config/skills/apex/steps/step-00-init.md +85 -231
  12. package/agents-config/skills/apex/steps/step-00b-branch.md +10 -118
  13. package/agents-config/skills/apex/steps/step-00b-economy.md +12 -239
  14. package/agents-config/skills/apex/steps/step-00b-interactive.md +13 -162
  15. package/agents-config/skills/apex/steps/step-00b-save.md +13 -114
  16. package/agents-config/skills/apex/steps/step-01-analyze.md +40 -361
  17. package/agents-config/skills/apex/steps/step-02-plan.md +55 -562
  18. package/agents-config/skills/apex/steps/step-02b-tasks.md +15 -291
  19. package/agents-config/skills/apex/steps/step-03-execute-teams.md +47 -267
  20. package/agents-config/skills/apex/steps/step-03-execute.md +32 -212
  21. package/agents-config/skills/apex/steps/step-04-validate.md +42 -246
  22. package/agents-config/skills/apex/steps/step-05-examine.md +47 -371
  23. package/agents-config/skills/apex/steps/step-06-resolve.md +19 -221
  24. package/agents-config/skills/apex/steps/step-07-tests.md +19 -234
  25. package/agents-config/skills/apex/steps/step-08-run-tests.md +13 -300
  26. package/agents-config/skills/apex/steps/step-09-finish.md +36 -200
  27. package/agents-config/skills/apex/steps/step-10-verify.md +46 -264
  28. package/agents-config/skills/appstore-connect/agents/openai.yaml +7 -0
  29. package/agents-config/skills/appstore-connect/assets/codex-icon.svg +17 -0
  30. package/agents-config/skills/commit/agents/openai.yaml +10 -0
  31. package/agents-config/skills/commit/assets/codex-icon.svg +17 -0
  32. package/agents-config/skills/create-pr/agents/openai.yaml +10 -0
  33. package/agents-config/skills/create-pr/assets/codex-icon.svg +17 -0
  34. package/agents-config/skills/environments-manager/SKILL.md +1 -1
  35. package/agents-config/skills/environments-manager/agents/openai.yaml +7 -0
  36. package/agents-config/skills/environments-manager/assets/codex-icon.svg +16 -0
  37. package/agents-config/skills/environments-manager/examples/scripts/claude-worktree-remove.sh +19 -3
  38. package/agents-config/skills/environments-manager/examples/scripts/worktree-up.sh +1 -1
  39. package/agents-config/skills/environments-manager/references/claude.md +1 -1
  40. package/agents-config/skills/fix-pr-comments/agents/openai.yaml +10 -0
  41. package/agents-config/skills/fix-pr-comments/assets/codex-icon.svg +17 -0
  42. package/agents-config/skills/grill-me/SKILL.md +25 -4
  43. package/agents-config/skills/grill-me/agents/openai.yaml +8 -0
  44. package/agents-config/skills/grill-me/assets/codex-icon.svg +16 -0
  45. package/agents-config/skills/hooks-manager/SKILL.md +19 -9
  46. package/agents-config/skills/hooks-manager/assets/codex-icon.svg +15 -4
  47. package/agents-config/skills/hooks-manager/references/claude-code.md +32 -0
  48. package/agents-config/skills/hooks-manager/references/codex.md +23 -0
  49. package/agents-config/skills/hooks-manager/references/cursor.md +18 -0
  50. package/agents-config/skills/hooks-manager/references/hook-types.md +5 -3
  51. package/agents-config/skills/hooks-manager/references/input-output-schemas.md +2 -2
  52. package/agents-config/skills/hooks-manager/references/research-sources.md +25 -0
  53. package/agents-config/skills/hooks-manager/references/router.md +32 -0
  54. package/agents-config/skills/hooks-manager/references/troubleshooting.md +3 -3
  55. package/agents-config/skills/merge/agents/openai.yaml +10 -0
  56. package/agents-config/skills/merge/assets/codex-icon.svg +17 -0
  57. package/agents-config/skills/oneshot/SKILL.md +4 -0
  58. package/agents-config/skills/oneshot/agents/openai.yaml +10 -0
  59. package/agents-config/skills/oneshot/assets/codex-icon.svg +18 -0
  60. package/agents-config/skills/prompt-creator/agents/openai.yaml +7 -0
  61. package/agents-config/skills/prompt-creator/assets/codex-icon.svg +16 -0
  62. package/agents-config/skills/rules-manager/agents/openai.yaml +7 -0
  63. package/agents-config/skills/rules-manager/assets/codex-icon.svg +23 -0
  64. package/agents-config/skills/skill-manager/SKILL.md +45 -3
  65. package/agents-config/skills/skill-manager/agents/openai.yaml +7 -0
  66. package/agents-config/skills/skill-manager/assets/codex-icon.svg +23 -0
  67. package/agents-config/skills/skill-manager/references/skill-writing-glossary.md +201 -0
  68. package/agents-config/skills/skill-manager/scripts/setup-codex-icons.ts +143 -0
  69. package/agents-config/skills/ultrathink/agents/openai.yaml +10 -0
  70. package/agents-config/skills/ultrathink/assets/codex-icon.svg +20 -0
  71. package/agents-config/skills/use-artifacts/SKILL.md +102 -51
  72. package/agents-config/skills/use-artifacts/assets/local-runtime.js +299 -0
  73. package/agents-config/skills/use-artifacts/scripts/create_artifact.py +1 -1
  74. package/agents-config/skills/use-delegate/SKILL.md +4 -0
  75. package/agents-config/skills/use-delegate/agents/openai.yaml +10 -0
  76. package/agents-config/skills/use-delegate/assets/codex-icon.svg +20 -0
  77. package/agents-config/skills/use-goal/SKILL.md +70 -9
  78. package/agents-config/skills/use-goal/agents/openai.yaml +1 -1
  79. package/agents-config/skills/use-goal/assets/codex-icon.svg +17 -3
  80. package/agents-config/skills/use-goal/references/claude-code-goal.md +54 -6
  81. package/agents-config/skills/use-goal/references/codex-goal.md +59 -4
  82. package/agents-config/skills/use-goal/references/verification-harnesses.md +104 -3
  83. package/package.json +1 -1
  84. package/agents-config/skills/apex/templates/00-context.md +0 -55
  85. package/agents-config/skills/apex/templates/01-analyze.md +0 -10
  86. package/agents-config/skills/apex/templates/02-plan.md +0 -10
  87. package/agents-config/skills/apex/templates/03-execute.md +0 -10
  88. package/agents-config/skills/apex/templates/04-validate.md +0 -10
  89. package/agents-config/skills/apex/templates/05-examine.md +0 -10
  90. package/agents-config/skills/apex/templates/06-resolve.md +0 -10
  91. package/agents-config/skills/apex/templates/07-tests.md +0 -10
  92. package/agents-config/skills/apex/templates/08-run-tests.md +0 -10
  93. package/agents-config/skills/apex/templates/09-finish.md +0 -10
  94. package/agents-config/skills/apex/templates/10-verify.md +0 -9
  95. package/agents-config/skills/apex/templates/README.md +0 -195
  96. package/agents-config/skills/apex/templates/step-complete.md +0 -7
@@ -0,0 +1,740 @@
1
+ #!/usr/bin/env python3
2
+ """Durable, append-only state helper for APEX runs."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import errno
8
+ import fcntl
9
+ import hashlib
10
+ import json
11
+ import os
12
+ import re
13
+ import stat
14
+ import subprocess
15
+ import tempfile
16
+ import uuid
17
+ from contextlib import contextmanager
18
+ from datetime import datetime, timezone
19
+ from pathlib import Path
20
+ from typing import Any, Iterator
21
+
22
+
23
+ SCHEMA_VERSION = 1
24
+ RUN_ID_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$")
25
+ METADATA_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$")
26
+ EVENT_STATUSES = {"started", "in_progress", "complete", "blocked", "recorded", "failed"}
27
+ SECRET_PATTERNS = (
28
+ re.compile(r"(?i)\b(authorization|api[_-]?key|access[_-]?token|refresh[_-]?token|password|secret|cookie)\s*[:=]\s*[^\s,;]+"),
29
+ re.compile(r"(?i)\bbearer\s+[a-z0-9._~+/=-]{12,}"),
30
+ re.compile(r"\b(?:sk-[A-Za-z0-9_-]{12,}|ghp_[A-Za-z0-9]{12,}|github_pat_[A-Za-z0-9_]{12,}|xox[baprs]-[A-Za-z0-9-]{12,})\b"),
31
+ re.compile(r"-----BEGIN [^-]*PRIVATE KEY-----.*?-----END [^-]*PRIVATE KEY-----", re.DOTALL),
32
+ )
33
+
34
+
35
+ def now() -> str:
36
+ return datetime.now(timezone.utc).isoformat(timespec="seconds")
37
+
38
+
39
+ def fail(message: str) -> None:
40
+ raise SystemExit(f"apex-state: {message}")
41
+
42
+
43
+ def redact(value: str | None) -> str | None:
44
+ if value is None:
45
+ return None
46
+ redacted = value
47
+ for pattern in SECRET_PATTERNS:
48
+ redacted = pattern.sub("[REDACTED_SECRET]", redacted)
49
+ return redacted
50
+
51
+
52
+ def resolve_root(value: str) -> Path:
53
+ root = Path(value).expanduser().resolve()
54
+ if not root.is_dir():
55
+ fail(f"root is not a directory: {root}")
56
+ if redact(str(root)) != str(root):
57
+ fail("root path contains secret-like content")
58
+ return root
59
+
60
+
61
+ def validate_run_id(run_id: str) -> str:
62
+ if redact(run_id) != run_id:
63
+ fail("run ID contains secret-like content")
64
+ if not RUN_ID_RE.fullmatch(run_id):
65
+ fail("run ID must contain only letters, numbers, dot, underscore, or hyphen")
66
+ return run_id
67
+
68
+
69
+ def validate_metadata(value: str, label: str) -> str:
70
+ if redact(value) != value:
71
+ fail(f"{label} contains secret-like content")
72
+ if not METADATA_RE.fullmatch(value):
73
+ fail(f"{label} must contain only letters, numbers, dot, underscore, or hyphen")
74
+ return value
75
+
76
+
77
+ def slugify(value: str) -> str:
78
+ slug = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
79
+ return slug[:48] or "task"
80
+
81
+
82
+ def ensure_directory(path: Path, *, create: bool) -> Path:
83
+ try:
84
+ metadata = path.lstat()
85
+ except FileNotFoundError:
86
+ if not create:
87
+ fail(f"missing state directory: {path}")
88
+ try:
89
+ path.mkdir(mode=0o700)
90
+ except FileExistsError:
91
+ pass
92
+ metadata = path.lstat()
93
+ if stat.S_ISLNK(metadata.st_mode):
94
+ fail(f"state path must not be a symlink: {path}")
95
+ if not stat.S_ISDIR(metadata.st_mode):
96
+ fail(f"state path is not a directory: {path}")
97
+ os.chmod(path, 0o700)
98
+ return path
99
+
100
+
101
+ def state_root(root: Path, *, create: bool) -> Path:
102
+ current = root
103
+ for component in (".agents", "apex", "runs"):
104
+ current = ensure_directory(current / component, create=create)
105
+ try:
106
+ current.resolve().relative_to(root)
107
+ except ValueError:
108
+ fail(f"state path escapes repository: {current}")
109
+ return current
110
+
111
+
112
+ def existing_run_dir(root: Path, run_id: str) -> Path:
113
+ base = state_root(root, create=False)
114
+ directory = ensure_directory(base / validate_run_id(run_id), create=False)
115
+ try:
116
+ directory.resolve().relative_to(base.resolve())
117
+ except ValueError:
118
+ fail(f"run directory escapes state root: {directory}")
119
+ return directory
120
+
121
+
122
+ def assert_regular_or_missing(path: Path) -> None:
123
+ try:
124
+ metadata = path.lstat()
125
+ except FileNotFoundError:
126
+ return
127
+ if stat.S_ISLNK(metadata.st_mode):
128
+ fail(f"state file must not be a symlink: {path}")
129
+ if not stat.S_ISREG(metadata.st_mode):
130
+ fail(f"state path is not a regular file: {path}")
131
+
132
+
133
+ def fsync_directory(path: Path) -> None:
134
+ descriptor = os.open(path, os.O_RDONLY)
135
+ try:
136
+ os.fsync(descriptor)
137
+ finally:
138
+ os.close(descriptor)
139
+
140
+
141
+ def write_json(path: Path, data: dict[str, Any]) -> None:
142
+ assert_regular_or_missing(path)
143
+ handle, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
144
+ try:
145
+ os.fchmod(handle, 0o600)
146
+ with os.fdopen(handle, "w", encoding="utf-8") as stream:
147
+ json.dump(data, stream, indent=2, sort_keys=True)
148
+ stream.write("\n")
149
+ stream.flush()
150
+ os.fsync(stream.fileno())
151
+ os.replace(temporary, path)
152
+ os.chmod(path, 0o600)
153
+ fsync_directory(path.parent)
154
+ finally:
155
+ if os.path.exists(temporary):
156
+ os.unlink(temporary)
157
+
158
+
159
+ def read_json(path: Path) -> dict[str, Any]:
160
+ assert_regular_or_missing(path)
161
+ try:
162
+ data = json.loads(path.read_text(encoding="utf-8"))
163
+ except FileNotFoundError:
164
+ fail(f"missing state file: {path}")
165
+ except json.JSONDecodeError as error:
166
+ fail(f"invalid JSON in {path}: {error}")
167
+ if not isinstance(data, dict):
168
+ fail(f"expected a JSON object in {path}")
169
+ return data
170
+
171
+
172
+ @contextmanager
173
+ def run_lock(directory: Path) -> Iterator[None]:
174
+ lock_path = directory / ".state.lock"
175
+ assert_regular_or_missing(lock_path)
176
+ flags = os.O_CREAT | os.O_RDWR
177
+ if hasattr(os, "O_NOFOLLOW"):
178
+ flags |= os.O_NOFOLLOW
179
+ descriptor = os.open(lock_path, flags, 0o600)
180
+ try:
181
+ os.fchmod(descriptor, 0o600)
182
+ fcntl.flock(descriptor, fcntl.LOCK_EX)
183
+ yield
184
+ finally:
185
+ fcntl.flock(descriptor, fcntl.LOCK_UN)
186
+ os.close(descriptor)
187
+
188
+
189
+ def journal_path(directory: Path) -> Path:
190
+ return directory / "journal.jsonl"
191
+
192
+
193
+ def append_journal(directory: Path, event: dict[str, Any]) -> dict[str, Any]:
194
+ path = journal_path(directory)
195
+ assert_regular_or_missing(path)
196
+ recorded = dict(event)
197
+ recorded["id"] = str(uuid.uuid4())
198
+ recorded["timestamp"] = now()
199
+ payload = (json.dumps(recorded, sort_keys=True) + "\n").encode()
200
+ flags = os.O_CREAT | os.O_APPEND | os.O_WRONLY
201
+ if hasattr(os, "O_NOFOLLOW"):
202
+ flags |= os.O_NOFOLLOW
203
+ descriptor = os.open(path, flags, 0o600)
204
+ try:
205
+ os.fchmod(descriptor, 0o600)
206
+ view = memoryview(payload)
207
+ while view:
208
+ written = os.write(descriptor, view)
209
+ view = view[written:]
210
+ os.fsync(descriptor)
211
+ finally:
212
+ os.close(descriptor)
213
+ return recorded
214
+
215
+
216
+ def read_journal(directory: Path) -> list[dict[str, Any]]:
217
+ path = journal_path(directory)
218
+ assert_regular_or_missing(path)
219
+ try:
220
+ lines = path.read_text(encoding="utf-8").splitlines()
221
+ except FileNotFoundError:
222
+ return []
223
+ events: list[dict[str, Any]] = []
224
+ for index, line in enumerate(lines):
225
+ try:
226
+ event = json.loads(line)
227
+ except json.JSONDecodeError:
228
+ if index == len(lines) - 1:
229
+ break
230
+ fail(f"corrupt journal entry at line {index + 1}")
231
+ if not isinstance(event, dict) or not isinstance(event.get("id"), str):
232
+ fail(f"invalid journal entry at line {index + 1}")
233
+ events.append(event)
234
+ return events
235
+
236
+
237
+ def project_state(base: dict[str, Any], events: list[dict[str, Any]]) -> dict[str, Any]:
238
+ state = dict(base)
239
+ state["status"] = "active"
240
+ state["current_phase"] = "preflight"
241
+ state["updated_at"] = state.get("created_at")
242
+ state["last_event_id"] = None
243
+ checkpoints: list[dict[str, Any]] = []
244
+ for event in events:
245
+ phase = event.get("phase")
246
+ status = event.get("status")
247
+ if isinstance(phase, str):
248
+ state["current_phase"] = phase
249
+ if event.get("kind") == "checkpoint" and isinstance(event.get("checkpoint"), dict):
250
+ checkpoints.append(event["checkpoint"])
251
+ if phase == "handoff" and status == "complete":
252
+ state["status"] = "complete"
253
+ elif status == "blocked":
254
+ state["status"] = "blocked"
255
+ elif state.get("status") != "complete" and status in {"started", "in_progress", "complete", "recorded"}:
256
+ state["status"] = "active"
257
+ state["last_event_id"] = event["id"]
258
+ state["updated_at"] = event["timestamp"]
259
+ state["checkpoints"] = checkpoints
260
+ return state
261
+
262
+
263
+ def project_evidence(events: list[dict[str, Any]]) -> dict[str, Any]:
264
+ artifacts: dict[str, dict[str, Any]] = {}
265
+ order: list[str] = []
266
+ for event in events:
267
+ if event.get("kind") != "artifact":
268
+ continue
269
+ action = event.get("action")
270
+ if action == "recorded" and isinstance(event.get("artifact"), dict):
271
+ artifact = dict(event["artifact"])
272
+ artifact_id = artifact.get("id")
273
+ if isinstance(artifact_id, str):
274
+ artifacts[artifact_id] = artifact
275
+ if artifact_id not in order:
276
+ order.append(artifact_id)
277
+ elif action == "invalidated":
278
+ artifact_id = event.get("artifact_id")
279
+ if artifact_id in artifacts:
280
+ artifacts[artifact_id]["status"] = "invalidated"
281
+ artifacts[artifact_id]["invalidated_at"] = event["timestamp"]
282
+ artifacts[artifact_id]["invalidation_reason"] = event.get("message")
283
+ return {"schema_version": SCHEMA_VERSION, "artifacts": [artifacts[key] for key in order]}
284
+
285
+
286
+ def load_bundle(
287
+ root: Path, directory: Path
288
+ ) -> tuple[dict[str, Any], dict[str, Any], list[dict[str, Any]], dict[str, Any]]:
289
+ events = read_journal(directory)
290
+ run_event = next(
291
+ (event for event in events if event.get("kind") == "run" and isinstance(event.get("run"), dict)),
292
+ None,
293
+ )
294
+ if run_event is None:
295
+ fail(f"journal has no run record: {journal_path(directory)}")
296
+ base = dict(run_event["run"])
297
+ recorded_root = Path(str(base.get("root", ""))).resolve()
298
+ if recorded_root != root:
299
+ fail(f"run belongs to {recorded_root}, not {root}")
300
+ if base.get("schema_version") != SCHEMA_VERSION:
301
+ fail(f"unsupported schema version: {base.get('schema_version')}")
302
+ return base, project_state(base, events), events, project_evidence(events)
303
+
304
+
305
+ def run_git(root: Path, *arguments: str) -> tuple[bool, bytes, str | None]:
306
+ try:
307
+ result = subprocess.run(
308
+ ("git", *arguments),
309
+ cwd=root,
310
+ check=False,
311
+ capture_output=True,
312
+ timeout=10,
313
+ )
314
+ except subprocess.TimeoutExpired:
315
+ return False, b"", "git command timed out"
316
+ except OSError as error:
317
+ return False, b"", redact(str(error))
318
+ if result.returncode != 0:
319
+ error = result.stderr.decode("utf-8", errors="replace").strip()
320
+ return False, result.stdout, redact(error or f"git exited {result.returncode}")
321
+ return True, result.stdout, None
322
+
323
+
324
+ def parse_porcelain(raw: bytes) -> tuple[list[dict[str, str]], bool]:
325
+ tokens = raw.split(b"\0")
326
+ changes: list[dict[str, str]] = []
327
+ paths_redacted = False
328
+ index = 0
329
+ while index < len(tokens):
330
+ token = tokens[index]
331
+ index += 1
332
+ if not token:
333
+ continue
334
+ text = token.decode("utf-8", errors="surrogateescape")
335
+ if len(text) < 4:
336
+ continue
337
+ status_code = text[:2]
338
+ raw_path = text[3:]
339
+ safe_path = redact(raw_path) or "[REDACTED_SECRET]"
340
+ paths_redacted = paths_redacted or safe_path != raw_path
341
+ entry = {"status": status_code, "path": safe_path}
342
+ if ("R" in status_code or "C" in status_code) and index < len(tokens):
343
+ original = tokens[index]
344
+ index += 1
345
+ original_path = original.decode("utf-8", errors="surrogateescape")
346
+ safe_original = redact(original_path) or "[REDACTED_SECRET]"
347
+ paths_redacted = paths_redacted or safe_original != original_path
348
+ entry["original_path"] = safe_original
349
+ changes.append(entry)
350
+ return changes, paths_redacted
351
+
352
+
353
+ def git_snapshot(root: Path) -> dict[str, Any]:
354
+ inside_ok, inside, inside_error = run_git(root, "rev-parse", "--is-inside-work-tree")
355
+ if not inside_ok or inside.strip() != b"true":
356
+ return {"available": False, "error": inside_error or "not a Git working tree"}
357
+
358
+ revision_ok, revision_raw, revision_error = run_git(root, "rev-parse", "HEAD")
359
+ branch_ok, branch_raw, branch_error = run_git(root, "branch", "--show-current")
360
+ upstream_ok, upstream_raw, upstream_error = run_git(
361
+ root, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"
362
+ )
363
+ status_ok, status_raw, status_error = run_git(root, "status", "--porcelain=v1", "-z")
364
+ changes, paths_redacted = parse_porcelain(status_raw) if status_ok else (None, False)
365
+ errors = [error for error in (revision_error, branch_error, status_error) if error]
366
+ return {
367
+ "available": True,
368
+ "verified": status_ok and revision_ok and branch_ok,
369
+ "revision": revision_raw.decode().strip() if revision_ok else None,
370
+ "branch": redact(branch_raw.decode().strip()) if branch_ok else None,
371
+ "branch_fingerprint": hashlib.sha256(branch_raw).hexdigest() if branch_ok else None,
372
+ "upstream": redact(upstream_raw.decode().strip()) if upstream_ok else None,
373
+ "changes": changes,
374
+ "status_fingerprint": hashlib.sha256(status_raw).hexdigest() if status_ok else None,
375
+ "paths_redacted": paths_redacted,
376
+ "errors": errors,
377
+ "upstream_error": upstream_error if not upstream_ok else None,
378
+ }
379
+
380
+
381
+ def persist_projections(directory: Path, state: dict[str, Any], evidence: dict[str, Any]) -> None:
382
+ write_json(directory / "run.json", state)
383
+ write_json(directory / "evidence.json", evidence)
384
+
385
+
386
+ def cleanup_staging(directory: Path) -> None:
387
+ """Remove only the known files created by an incomplete init."""
388
+
389
+ if not directory.exists():
390
+ return
391
+ for name in ("run.json", "tasks.json", "evidence.json", "journal.jsonl", ".state.lock"):
392
+ path = directory / name
393
+ if path.exists() and path.is_file() and not path.is_symlink():
394
+ path.unlink()
395
+ for name in ("artifacts", "tasks"):
396
+ path = directory / name
397
+ if path.exists() and path.is_dir() and not path.is_symlink():
398
+ try:
399
+ path.rmdir()
400
+ except OSError:
401
+ pass
402
+ try:
403
+ directory.rmdir()
404
+ except OSError:
405
+ pass
406
+
407
+
408
+ def command_init(args: argparse.Namespace) -> None:
409
+ root = resolve_root(args.root)
410
+ base = state_root(root, create=True)
411
+ task = redact(args.task) or "task"
412
+ prefix = args.run_id or f"{datetime.now().strftime('%Y%m%d-%H%M%S')}-{slugify(task)}-{uuid.uuid4().hex[:8]}"
413
+ validate_run_id(prefix)
414
+ staging = base / f".initializing-{uuid.uuid4()}"
415
+ staging.mkdir(mode=0o700)
416
+ published = False
417
+ try:
418
+ (staging / "artifacts").mkdir(mode=0o700)
419
+ (staging / "tasks").mkdir(mode=0o700)
420
+ timestamp = now()
421
+ base_state: dict[str, Any] = {
422
+ "schema_version": SCHEMA_VERSION,
423
+ "run_id": prefix,
424
+ "root": str(root),
425
+ "task": task,
426
+ "status": "active",
427
+ "current_phase": "preflight",
428
+ "created_at": timestamp,
429
+ "updated_at": timestamp,
430
+ "baseline": git_snapshot(root),
431
+ "last_event_id": None,
432
+ "checkpoints": [],
433
+ }
434
+ write_json(staging / "tasks.json", {"schema_version": SCHEMA_VERSION, "tasks": []})
435
+ event = append_journal(
436
+ staging,
437
+ {
438
+ "kind": "run",
439
+ "phase": "preflight",
440
+ "status": "started",
441
+ "message": "Run initialized",
442
+ "run": base_state,
443
+ },
444
+ )
445
+ state = project_state(base_state, [event])
446
+ evidence = project_evidence([event])
447
+ persist_projections(staging, state, evidence)
448
+ fsync_directory(staging / "artifacts")
449
+ fsync_directory(staging / "tasks")
450
+ fsync_directory(staging)
451
+
452
+ candidate = prefix
453
+ directory = base / candidate
454
+ if directory.exists():
455
+ fail(f"run already exists: {candidate}")
456
+ try:
457
+ os.rename(staging, directory)
458
+ published = True
459
+ except OSError as error:
460
+ if error.errno in {errno.EEXIST, errno.ENOTEMPTY}:
461
+ fail(f"run already exists: {candidate}")
462
+ raise
463
+ fsync_directory(base)
464
+ print(json.dumps({"run_id": candidate, "run_dir": str(directory)}))
465
+ finally:
466
+ if not published:
467
+ cleanup_staging(staging)
468
+
469
+
470
+ def command_event(args: argparse.Namespace) -> None:
471
+ root = resolve_root(args.root)
472
+ directory = existing_run_dir(root, args.run_id)
473
+ phase = validate_metadata(args.phase, "phase")
474
+ task_id = validate_metadata(args.task_id, "task ID") if args.task_id else None
475
+ if args.status not in EVENT_STATUSES:
476
+ fail("unsupported event status")
477
+ with run_lock(directory):
478
+ base, _, events, _ = load_bundle(root, directory)
479
+ payload: dict[str, Any] = {
480
+ "kind": "task" if args.task_id else "phase",
481
+ "phase": phase,
482
+ "status": args.status,
483
+ "message": redact(args.message),
484
+ }
485
+ if task_id:
486
+ payload["task_id"] = task_id
487
+ recorded = append_journal(directory, payload)
488
+ events.append(recorded)
489
+ persist_projections(directory, project_state(base, events), project_evidence(events))
490
+ print(json.dumps(recorded))
491
+
492
+
493
+ def command_checkpoint(args: argparse.Namespace) -> None:
494
+ root = resolve_root(args.root)
495
+ directory = existing_run_dir(root, args.run_id)
496
+ phase = validate_metadata(args.phase, "phase")
497
+ with run_lock(directory):
498
+ base, _, events, _ = load_bundle(root, directory)
499
+ checkpoint = {
500
+ "id": str(uuid.uuid4()),
501
+ "phase": phase,
502
+ "message": redact(args.message),
503
+ "timestamp": now(),
504
+ "git": git_snapshot(root),
505
+ }
506
+ recorded = append_journal(
507
+ directory,
508
+ {
509
+ "kind": "checkpoint",
510
+ "phase": phase,
511
+ "status": "recorded",
512
+ "message": checkpoint["message"],
513
+ "checkpoint": checkpoint,
514
+ },
515
+ )
516
+ events.append(recorded)
517
+ persist_projections(directory, project_state(base, events), project_evidence(events))
518
+ print(json.dumps(checkpoint))
519
+
520
+
521
+ def inspect_file(path: Path) -> tuple[str, int]:
522
+ digest = hashlib.sha256()
523
+ flags = os.O_RDONLY
524
+ if hasattr(os, "O_NOFOLLOW"):
525
+ flags |= os.O_NOFOLLOW
526
+ descriptor = os.open(path, flags)
527
+ try:
528
+ before = os.fstat(descriptor)
529
+ if not stat.S_ISREG(before.st_mode):
530
+ fail(f"artifact is not a regular file: {path}")
531
+ while True:
532
+ chunk = os.read(descriptor, 1024 * 1024)
533
+ if not chunk:
534
+ break
535
+ digest.update(chunk)
536
+ after = os.fstat(descriptor)
537
+ finally:
538
+ os.close(descriptor)
539
+ identity_before = (before.st_dev, before.st_ino, before.st_size, before.st_mtime_ns)
540
+ identity_after = (after.st_dev, after.st_ino, after.st_size, after.st_mtime_ns)
541
+ if identity_before != identity_after:
542
+ fail(f"artifact changed while being recorded: {path}")
543
+ return digest.hexdigest(), before.st_size
544
+
545
+
546
+ def command_artifact(args: argparse.Namespace) -> None:
547
+ root = resolve_root(args.root)
548
+ directory = existing_run_dir(root, args.run_id)
549
+ phase = validate_metadata(args.phase, "phase")
550
+ artifact_type = validate_metadata(args.type, "artifact type")
551
+ task_id = validate_metadata(args.task_id, "task ID") if args.task_id else None
552
+ if args.layer not in {"local", "provider", "public", "authenticated-live"}:
553
+ fail("unsupported artifact layer")
554
+ with run_lock(directory):
555
+ base, _, events, evidence = load_bundle(root, directory)
556
+ artifact_path = Path(args.path).expanduser()
557
+ artifact_path = (root / artifact_path).resolve() if not artifact_path.is_absolute() else artifact_path.resolve()
558
+ if redact(str(artifact_path)) != str(artifact_path):
559
+ fail("artifact path contains secret-like content")
560
+ if not artifact_path.is_file():
561
+ fail(f"artifact is not a file: {artifact_path}")
562
+ try:
563
+ stored_path = str(artifact_path.relative_to(root))
564
+ except ValueError:
565
+ if not args.allow_external:
566
+ fail("artifact is outside the repository; pass --allow-external explicitly")
567
+ stored_path = str(artifact_path)
568
+ digest, size = inspect_file(artifact_path)
569
+ artifact = {
570
+ "id": str(uuid.uuid4()),
571
+ "type": artifact_type,
572
+ "phase": phase,
573
+ "task_id": task_id,
574
+ "layer": args.layer,
575
+ "status": "current",
576
+ "path": stored_path,
577
+ "sha256": digest,
578
+ "size": size,
579
+ "revision": git_snapshot(root).get("revision"),
580
+ "created_at": now(),
581
+ }
582
+ recorded = append_journal(
583
+ directory,
584
+ {
585
+ "kind": "artifact",
586
+ "action": "recorded",
587
+ "phase": phase,
588
+ "status": "recorded",
589
+ "message": "Artifact recorded",
590
+ "artifact": artifact,
591
+ },
592
+ )
593
+ events.append(recorded)
594
+ persist_projections(directory, project_state(base, events), project_evidence(events))
595
+ print(json.dumps(artifact))
596
+
597
+
598
+ def command_invalidate(args: argparse.Namespace) -> None:
599
+ root = resolve_root(args.root)
600
+ directory = existing_run_dir(root, args.run_id)
601
+ with run_lock(directory):
602
+ base, state, events, evidence = load_bundle(root, directory)
603
+ known_ids = {item.get("id") for item in evidence.get("artifacts", [])}
604
+ if args.artifact_id not in known_ids:
605
+ fail("unknown artifact ID")
606
+ recorded = append_journal(
607
+ directory,
608
+ {
609
+ "kind": "artifact",
610
+ "action": "invalidated",
611
+ "phase": state.get("current_phase"),
612
+ "status": "invalidated",
613
+ "message": redact(args.message),
614
+ "artifact_id": args.artifact_id,
615
+ },
616
+ )
617
+ events.append(recorded)
618
+ projected = project_evidence(events)
619
+ persist_projections(directory, project_state(base, events), projected)
620
+ target = next(item for item in projected["artifacts"] if item["id"] == args.artifact_id)
621
+ print(json.dumps(target))
622
+
623
+
624
+ def drift(reference: dict[str, Any], current: dict[str, Any]) -> dict[str, bool | None]:
625
+ if not reference.get("available") or not current.get("available"):
626
+ return {"known": False, "revision_changed": None, "branch_changed": None, "changes_changed": None}
627
+ if not reference.get("verified") or not current.get("verified"):
628
+ return {"known": False, "revision_changed": None, "branch_changed": None, "changes_changed": None}
629
+ return {
630
+ "known": True,
631
+ "revision_changed": reference.get("revision") != current.get("revision"),
632
+ "branch_changed": reference.get("branch_fingerprint") != current.get("branch_fingerprint"),
633
+ "changes_changed": reference.get("status_fingerprint") != current.get("status_fingerprint"),
634
+ }
635
+
636
+
637
+ def verify_artifacts(root: Path, evidence: dict[str, Any]) -> dict[str, Any]:
638
+ verified = json.loads(json.dumps(evidence))
639
+ for artifact in verified.get("artifacts", []):
640
+ if artifact.get("status") != "current":
641
+ artifact["integrity"] = "not-current"
642
+ continue
643
+ stored_path = artifact.get("path")
644
+ if not isinstance(stored_path, str):
645
+ artifact["status"] = "stale"
646
+ artifact["integrity"] = "missing-path"
647
+ continue
648
+ candidate = Path(stored_path).expanduser()
649
+ candidate = (root / candidate).resolve() if not candidate.is_absolute() else candidate.resolve()
650
+ try:
651
+ digest, size = inspect_file(candidate)
652
+ except (OSError, SystemExit) as error:
653
+ artifact["status"] = "stale"
654
+ artifact["integrity"] = "unavailable"
655
+ artifact["integrity_error"] = redact(str(error))
656
+ continue
657
+ if digest != artifact.get("sha256") or size != artifact.get("size"):
658
+ artifact["status"] = "stale"
659
+ artifact["integrity"] = "mismatch"
660
+ else:
661
+ artifact["integrity"] = "verified"
662
+ return verified
663
+
664
+
665
+ def command_status(args: argparse.Namespace) -> None:
666
+ root = resolve_root(args.root)
667
+ directory = existing_run_dir(root, args.run_id)
668
+ with run_lock(directory):
669
+ _, state, _, evidence = load_bundle(root, directory)
670
+ current = git_snapshot(root)
671
+ checkpoints = state.get("checkpoints", [])
672
+ reference = checkpoints[-1].get("git", {}) if checkpoints else state.get("baseline", {})
673
+ result = {
674
+ "run_dir": str(directory),
675
+ "state": state,
676
+ "evidence": verify_artifacts(root, evidence),
677
+ "current_git": current,
678
+ "drift": drift(reference, current),
679
+ }
680
+ print(json.dumps(result, indent=2))
681
+
682
+
683
+ def build_parser() -> argparse.ArgumentParser:
684
+ parser = argparse.ArgumentParser(description="Manage durable APEX run state")
685
+ commands = parser.add_subparsers(dest="command", required=True)
686
+
687
+ init = commands.add_parser("init")
688
+ init.add_argument("--root", required=True)
689
+ init.add_argument("--task", required=True)
690
+ init.add_argument("--run-id")
691
+ init.set_defaults(handler=command_init)
692
+
693
+ event = commands.add_parser("event")
694
+ event.add_argument("--root", required=True)
695
+ event.add_argument("--run-id", required=True)
696
+ event.add_argument("--phase", required=True)
697
+ event.add_argument("--status", required=True)
698
+ event.add_argument("--message", required=True)
699
+ event.add_argument("--task-id")
700
+ event.set_defaults(handler=command_event)
701
+
702
+ checkpoint = commands.add_parser("checkpoint")
703
+ checkpoint.add_argument("--root", required=True)
704
+ checkpoint.add_argument("--run-id", required=True)
705
+ checkpoint.add_argument("--phase", required=True)
706
+ checkpoint.add_argument("--message", required=True)
707
+ checkpoint.set_defaults(handler=command_checkpoint)
708
+
709
+ artifact = commands.add_parser("artifact")
710
+ artifact.add_argument("--root", required=True)
711
+ artifact.add_argument("--run-id", required=True)
712
+ artifact.add_argument("--path", required=True)
713
+ artifact.add_argument("--type", required=True)
714
+ artifact.add_argument("--phase", required=True)
715
+ artifact.add_argument("--task-id")
716
+ artifact.add_argument("--allow-external", action="store_true")
717
+ artifact.add_argument("--layer", default="local")
718
+ artifact.set_defaults(handler=command_artifact)
719
+
720
+ invalidate = commands.add_parser("invalidate")
721
+ invalidate.add_argument("--root", required=True)
722
+ invalidate.add_argument("--run-id", required=True)
723
+ invalidate.add_argument("--artifact-id", required=True)
724
+ invalidate.add_argument("--message", required=True)
725
+ invalidate.set_defaults(handler=command_invalidate)
726
+
727
+ status = commands.add_parser("status")
728
+ status.add_argument("--root", required=True)
729
+ status.add_argument("--run-id", required=True)
730
+ status.set_defaults(handler=command_status)
731
+ return parser
732
+
733
+
734
+ def main() -> None:
735
+ args = build_parser().parse_args()
736
+ args.handler(args)
737
+
738
+
739
+ if __name__ == "__main__":
740
+ main()