ultra-builder-pro-cli 0.6.0 → 0.8.1

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 (75) hide show
  1. package/.ultra-template/changes/active/.gitkeep +1 -0
  2. package/.ultra-template/changes/archive/.gitkeep +1 -0
  3. package/CHANGELOG.md +90 -11
  4. package/README.md +39 -19
  5. package/adapters/_shared/handbook.cjs +8 -7
  6. package/adapters/_shared/provenance.cjs +286 -0
  7. package/adapters/_shared/runtime-assets.cjs +2 -0
  8. package/adapters/_shared/tests/handbook.test.cjs +4 -1
  9. package/adapters/_shared/tests/provenance.test.cjs +133 -0
  10. package/adapters/_shared/tests/runtime-assets.test.cjs +2 -0
  11. package/adapters/claude.js +33 -0
  12. package/adapters/codex.js +87 -0
  13. package/adapters/opencode.js +147 -20
  14. package/adapters/tests/codex.test.cjs +42 -3
  15. package/adapters/tests/opencode.test.cjs +16 -9
  16. package/agents/debugger.md +12 -0
  17. package/bin/install.js +63 -27
  18. package/bin/orchestrator.js +1 -26
  19. package/commands/ultra-change.md +48 -0
  20. package/commands/ultra-deliver.md +14 -7
  21. package/commands/ultra-dev.md +6 -3
  22. package/commands/ultra-doctor.md +36 -0
  23. package/commands/ultra-init.md +4 -3
  24. package/commands/ultra-status.md +7 -5
  25. package/commands/ultra-test.md +2 -1
  26. package/docs/AGENT-CONTEXT.md +31 -23
  27. package/docs/ARCHITECTURE.md +80 -17
  28. package/docs/PLAN.zh-CN.md +37 -29
  29. package/docs/ROADMAP.md +14 -14
  30. package/docs/RUNTIME-COMPAT-MATRIX.md +19 -12
  31. package/hooks/README.md +14 -9
  32. package/hooks/active_task_context.py +22 -15
  33. package/hooks/health_check.py +136 -38
  34. package/hooks/workflow_checkpoint.py +15 -1
  35. package/hooks/workflow_context.py +120 -30
  36. package/hooks/workflow_resume.py +96 -7
  37. package/mcp-server/lib/change-workflow.cjs +625 -0
  38. package/mcp-server/lib/change-workflow.test.cjs +346 -0
  39. package/mcp-server/lib/doctor.cjs +112 -0
  40. package/mcp-server/lib/doctor.test.cjs +86 -0
  41. package/mcp-server/lib/init-project.cjs +32 -6
  42. package/mcp-server/lib/runtime-state.cjs +222 -0
  43. package/mcp-server/lib/runtime-state.test.cjs +92 -0
  44. package/mcp-server/lib/state-db.cjs +31 -8
  45. package/mcp-server/lib/state-ops.cjs +17 -3
  46. package/mcp-server/server.cjs +102 -10
  47. package/mcp-server/tests/db-ops.test.cjs +5 -1
  48. package/mcp-server/tests/init-project.test.cjs +45 -0
  49. package/mcp-server/tests/server-contract.test.cjs +123 -1
  50. package/mcp-server/tests/state-db.test.cjs +44 -7
  51. package/mcp-server/tests/state-ops.test.cjs +19 -0
  52. package/orchestrator/daemon.cjs +39 -0
  53. package/orchestrator/tests/daemon.test.cjs +50 -0
  54. package/package.json +3 -3
  55. package/settings.json +1 -2
  56. package/skills/ultra-change/SKILL.md +149 -0
  57. package/skills/ultra-deliver/SKILL.md +83 -15
  58. package/skills/ultra-dev/SKILL.md +32 -12
  59. package/skills/ultra-doctor/SKILL.md +97 -0
  60. package/skills/ultra-init/SKILL.md +8 -5
  61. package/skills/ultra-review/SKILL.md +16 -0
  62. package/skills/ultra-status/SKILL.md +30 -7
  63. package/skills/ultra-test/SKILL.md +14 -2
  64. package/spec/cli-protocol.md +12 -1
  65. package/spec/mcp-tools.yaml +337 -1
  66. package/spec/schemas/mcp-tools.schema.json +1 -1
  67. package/spec/schemas/state-db.sql +117 -0
  68. package/ultra-tools/cli.cjs +3 -0
  69. package/ultra-tools/commands/system.cjs +57 -0
  70. package/ultra-tools/system-command.test.cjs +53 -0
  71. package/adapters/_shared/rtk-detect.cjs +0 -86
  72. package/adapters/_shared/tests/rtk-detect.test.cjs +0 -116
  73. package/docs/LEGACY-HERMES.md +0 -1099
  74. package/orchestrator/code-graph-watcher.cjs +0 -121
  75. package/orchestrator/tests/code-graph-watcher.test.cjs +0 -183
@@ -1,59 +1,157 @@
1
1
  #!/usr/bin/env python3
2
- """Advisory health check for an active Ultra workflow.
3
-
4
- No memory, prompt, transcript, or generic host policy is inspected here.
5
- """
2
+ """Fast, read-only Ultra runtime health inspection at session start."""
6
3
 
7
4
  import json
8
5
  import sqlite3
9
6
  import sys
10
7
  from pathlib import Path
11
8
 
12
- TERMINAL = {"committed", "completed", "done", "cancelled"}
13
- REQUIRED_TABLES = {"tasks", "events", "sessions", "schema_version"}
9
+
10
+ REQUIRED_TABLES = {
11
+ "tasks", "events", "sessions", "schema_version", "migration_history",
12
+ "telemetry", "specs_refs", "circuit_breaker", "changes", "artifacts",
13
+ "context_snapshots", "trace_links", "incidents", "projection_jobs",
14
+ "event_consumers",
15
+ }
14
16
 
15
17
 
16
- def main():
18
+ def hook_input() -> dict:
17
19
  try:
18
- raw = sys.stdin.read()
19
- data = json.loads(raw) if raw.strip() else {}
20
- except (json.JSONDecodeError, OSError) as exc:
20
+ value = json.loads(sys.stdin.read() or "{}")
21
+ return value if isinstance(value, dict) else {}
22
+ except json.JSONDecodeError as exc:
21
23
  print(f"[health_check] invalid hook input: {exc}", file=sys.stderr)
22
- data = {}
23
- start = Path(data.get("cwd") or Path.cwd()).resolve()
24
- root = None
25
- state = None
24
+ return {}
25
+
26
+
27
+ def find_root(start: Path):
26
28
  for candidate in (start, *start.parents):
27
- state_file = candidate / ".ultra" / "workflow-state.json"
28
- if state_file.is_file():
29
- try:
30
- value = json.loads(state_file.read_text(encoding="utf-8"))
31
- except (OSError, json.JSONDecodeError) as exc:
32
- print(f"[health_check] cannot read {state_file}: {exc}", file=sys.stderr)
33
- value = None
34
- if isinstance(value, dict) and value.get("status") not in TERMINAL:
35
- root, state = candidate, value
36
- break
37
- if root is None or state is None:
38
- print(json.dumps({}))
39
- return
40
- issues = []
29
+ ultra = candidate / ".ultra"
30
+ if (
31
+ (ultra / "state.db").is_file()
32
+ or (ultra / "workflow-state.json").is_file()
33
+ or (ultra / "changes" / "active").is_dir()
34
+ ):
35
+ return candidate
36
+ return None
37
+
38
+
39
+ def inspect(root: Path) -> dict:
41
40
  db_path = root / ".ultra" / "state.db"
41
+ report = {"status": "healthy", "project": str(root), "checks": {}}
42
+ issues = []
42
43
  if not db_path.is_file():
43
- issues.append(".ultra/state.db is missing")
44
- else:
45
- try:
46
- with sqlite3.connect(str(db_path), timeout=1) as conn:
47
- tables = {row[0] for row in conn.execute(
44
+ report["checks"]["state_db"] = {"status": "fail", "reason": "missing"}
45
+ report["status"] = "degraded"
46
+ return report
47
+
48
+ try:
49
+ uri = f"file:{db_path}?mode=ro"
50
+ with sqlite3.connect(uri, uri=True, timeout=1) as conn:
51
+ quick = conn.execute("PRAGMA quick_check").fetchone()[0]
52
+ tables = {
53
+ row[0] for row in conn.execute(
48
54
  "SELECT name FROM sqlite_master WHERE type='table'"
49
- )}
55
+ )
56
+ }
50
57
  missing = sorted(REQUIRED_TABLES - tables)
58
+ report["checks"]["state_db"] = {
59
+ "status": "pass" if quick == "ok" and not missing else "fail",
60
+ "integrity": quick,
61
+ "missing_tables": missing,
62
+ }
63
+ if quick != "ok" or missing:
64
+ issues.append("state_db")
51
65
  if missing:
52
- issues.append(f"state.db missing tables: {', '.join(missing)}")
53
- except sqlite3.Error as exc:
54
- issues.append(f"state.db unreadable: {exc}")
66
+ report["status"] = "degraded"
67
+ return report
68
+
69
+ incidents = [
70
+ {"id": row[0], "code": row[1], "severity": row[2], "retryable": bool(row[3])}
71
+ for row in conn.execute(
72
+ """SELECT id, code, severity, retryable FROM incidents
73
+ WHERE status = 'open' ORDER BY last_seen_at DESC LIMIT 20"""
74
+ )
75
+ ]
76
+ report["checks"]["incidents"] = {
77
+ "status": "pass" if not incidents else "fail",
78
+ "open": incidents,
79
+ }
80
+ if incidents:
81
+ issues.append("incidents")
82
+
83
+ pending = conn.execute(
84
+ "SELECT COUNT(*) FROM projection_jobs WHERE status = 'pending'"
85
+ ).fetchone()[0]
86
+ running = conn.execute(
87
+ "SELECT COUNT(*) FROM projection_jobs WHERE status = 'running'"
88
+ ).fetchone()[0]
89
+ failed = conn.execute(
90
+ "SELECT COUNT(*) FROM projection_jobs WHERE status = 'failed'"
91
+ ).fetchone()[0]
92
+ event_cursor = conn.execute(
93
+ "SELECT COALESCE(MAX(id), 0) FROM events"
94
+ ).fetchone()[0]
95
+ projected_cursor = conn.execute(
96
+ """SELECT COALESCE(MAX(event_cursor), 0) FROM projection_jobs
97
+ WHERE status = 'completed'"""
98
+ ).fetchone()[0]
99
+ projection_ok = (
100
+ pending == 0 and running == 0 and failed == 0
101
+ and event_cursor <= projected_cursor
102
+ )
103
+ report["checks"]["projections"] = {
104
+ "status": "pass" if projection_ok else "fail",
105
+ "pending": pending,
106
+ "running": running,
107
+ "failed": failed,
108
+ "event_cursor": event_cursor,
109
+ "projected_cursor": projected_cursor,
110
+ }
111
+ if not projection_ok:
112
+ issues.append("projections")
113
+
114
+ orphan = conn.execute(
115
+ "SELECT COUNT(*) FROM sessions WHERE status = 'orphan'"
116
+ ).fetchone()[0]
117
+ report["checks"]["sessions"] = {
118
+ "status": "pass" if orphan == 0 else "fail", "orphan": orphan,
119
+ }
120
+ if orphan:
121
+ issues.append("sessions")
122
+
123
+ missing_artifacts = []
124
+ for change_id, artifact_root in conn.execute(
125
+ """SELECT id, artifact_root FROM changes
126
+ WHERE status IN ('active', 'blocked', 'ready')"""
127
+ ):
128
+ if artifact_root and not (root / artifact_root).exists():
129
+ missing_artifacts.append(change_id)
130
+ report["checks"]["change_artifacts"] = {
131
+ "status": "pass" if not missing_artifacts else "fail",
132
+ "missing": missing_artifacts,
133
+ }
134
+ if missing_artifacts:
135
+ issues.append("change_artifacts")
136
+ except sqlite3.Error as exc:
137
+ report["checks"]["state_db"] = {"status": "fail", "reason": str(exc)}
138
+ issues.append("state_db")
139
+
55
140
  if issues:
56
- print("[Ultra health] " + "; ".join(issues), file=sys.stderr)
141
+ report["status"] = "degraded"
142
+ return report
143
+
144
+
145
+ def main() -> None:
146
+ data = hook_input()
147
+ start = Path(data.get("cwd") or Path.cwd()).resolve()
148
+ root = find_root(start)
149
+ if root is None:
150
+ print(json.dumps({}))
151
+ return
152
+ report = inspect(root)
153
+ if report["status"] != "healthy":
154
+ print("[Ultra health] " + json.dumps(report, ensure_ascii=False, sort_keys=True), file=sys.stderr)
57
155
  print(json.dumps({}))
58
156
 
59
157
 
@@ -11,6 +11,17 @@ from pathlib import Path
11
11
  TERMINAL = {"committed", "completed", "done", "cancelled"}
12
12
 
13
13
 
14
+ def valid_workflow(state: object) -> bool:
15
+ return (
16
+ isinstance(state, dict)
17
+ and isinstance(state.get("command"), str)
18
+ and bool(state["command"].strip())
19
+ and state.get("step") is not None
20
+ and isinstance(state.get("status"), str)
21
+ and bool(state["status"].strip())
22
+ )
23
+
24
+
14
25
  def main() -> None:
15
26
  try:
16
27
  data = json.loads(sys.stdin.read() or "{}")
@@ -28,7 +39,10 @@ def main() -> None:
28
39
  except (OSError, json.JSONDecodeError) as exc:
29
40
  print(f"[workflow_checkpoint] cannot read {state_file}: {exc}", file=sys.stderr)
30
41
  break
31
- if not isinstance(state, dict) or state.get("status") in TERMINAL:
42
+ if not valid_workflow(state):
43
+ print(f"[workflow_checkpoint] invalid workflow state in {state_file}", file=sys.stderr)
44
+ break
45
+ if state.get("status") in TERMINAL:
32
46
  break
33
47
  runtime_dir = root / ".ultra" / "runtime"
34
48
  runtime_dir.mkdir(parents=True, exist_ok=True)
@@ -1,15 +1,16 @@
1
1
  #!/usr/bin/env python3
2
- """Inject the live Ultra workflow state at session start.
3
-
4
- This hook is deliberately project-local and contains no cross-session memory.
5
- It is a no-op unless an active .ultra/workflow-state.json exists.
6
- """
2
+ """Inject current Ultra workflow/change context without persistent memory."""
7
3
 
8
4
  import json
5
+ import sqlite3
9
6
  import sys
10
7
  from pathlib import Path
11
8
 
12
- TERMINAL = {"committed", "completed", "done", "cancelled"}
9
+
10
+ TERMINAL_WORKFLOW = {"committed", "completed", "done", "cancelled"}
11
+ PROVIDER_FIELDS = {
12
+ "provider", "project", "reference", "revision", "indexed_head", "status",
13
+ }
13
14
 
14
15
 
15
16
  def payload() -> dict:
@@ -21,38 +22,127 @@ def payload() -> dict:
21
22
  return {}
22
23
 
23
24
 
24
- def active_state(data: dict):
25
- start = Path(data.get("cwd") or Path.cwd()).resolve()
25
+ def find_root(start: Path):
26
26
  for root in (start, *start.parents):
27
- state_file = root / ".ultra" / "workflow-state.json"
28
- if not state_file.is_file():
29
- continue
30
- try:
31
- state = json.loads(state_file.read_text(encoding="utf-8"))
32
- except (OSError, json.JSONDecodeError) as exc:
33
- print(f"[workflow_context] cannot read {state_file}: {exc}", file=sys.stderr)
34
- return None
35
- if isinstance(state, dict) and state.get("status") not in TERMINAL:
36
- return root, state
27
+ ultra = root / ".ultra"
28
+ if (
29
+ (ultra / "state.db").is_file()
30
+ or (ultra / "workflow-state.json").is_file()
31
+ or (ultra / "changes" / "active").is_dir()
32
+ ):
33
+ return root
34
+ return None
35
+
36
+
37
+ def read_workflow(root: Path):
38
+ state_file = root / ".ultra" / "workflow-state.json"
39
+ if not state_file.is_file():
40
+ return None
41
+ try:
42
+ state = json.loads(state_file.read_text(encoding="utf-8"))
43
+ except (OSError, json.JSONDecodeError) as exc:
44
+ print(f"[workflow_context] cannot read {state_file}: {exc}", file=sys.stderr)
37
45
  return None
46
+ if isinstance(state, dict) and state.get("status") not in TERMINAL_WORKFLOW:
47
+ return state
38
48
  return None
39
49
 
40
50
 
51
+ def safe_json(raw, fallback):
52
+ try:
53
+ value = json.loads(raw)
54
+ return value
55
+ except (TypeError, json.JSONDecodeError):
56
+ return fallback
57
+
58
+
59
+ def provider_metadata(raw) -> dict:
60
+ refs = safe_json(raw, {})
61
+ if not isinstance(refs, dict):
62
+ return {}
63
+ clean = {}
64
+ for kind, ref in refs.items():
65
+ if kind not in {"memory", "code_graph"} or not isinstance(ref, dict):
66
+ continue
67
+ clean[kind] = {key: ref[key] for key in PROVIDER_FIELDS if key in ref}
68
+ return clean
69
+
70
+
71
+ def read_changes(root: Path):
72
+ db_path = root / ".ultra" / "state.db"
73
+ if not db_path.is_file():
74
+ return []
75
+ try:
76
+ with sqlite3.connect(f"file:{db_path}?mode=ro", uri=True, timeout=1) as conn:
77
+ exists = conn.execute(
78
+ "SELECT 1 FROM sqlite_master WHERE type='table' AND name='changes'"
79
+ ).fetchone()
80
+ if not exists:
81
+ return []
82
+ rows = conn.execute(
83
+ """SELECT id, title, kind, status, intent, docs_impact_json,
84
+ provider_refs_json, artifact_root
85
+ FROM changes WHERE status IN ('active', 'blocked', 'ready')
86
+ ORDER BY updated_at DESC"""
87
+ ).fetchall()
88
+ except sqlite3.Error as exc:
89
+ print(f"[workflow_context] cannot inspect {db_path}: {exc}", file=sys.stderr)
90
+ return []
91
+ return [
92
+ {
93
+ "id": row[0], "title": row[1], "kind": row[2], "status": row[3],
94
+ "intent": row[4], "docs_impact": safe_json(row[5], {}),
95
+ "providers": provider_metadata(row[6]), "artifact_root": row[7],
96
+ }
97
+ for row in rows
98
+ ]
99
+
100
+
101
+ def context_text(root: Path, workflow, changes) -> str:
102
+ lines = []
103
+ if workflow:
104
+ lines.extend([
105
+ "[Ultra active workflow]",
106
+ f"Project: {root}",
107
+ f"Command: {workflow.get('command', 'unknown')}",
108
+ f"Task: {workflow.get('task_id', workflow.get('task', 'unknown'))}",
109
+ f"Step: {workflow.get('step', 'unknown')}",
110
+ f"Status: {workflow.get('status', 'active')}",
111
+ ])
112
+ if changes:
113
+ change = changes[0]
114
+ lines.extend([
115
+ "[Ultra continuous change]",
116
+ f"Project: {root}",
117
+ f"Change: {change['id']} ({change['kind']}, {change['status']})",
118
+ f"Title: {change['title']}",
119
+ f"Intent: {change['intent']}",
120
+ "Docs impact: " + json.dumps(change["docs_impact"], ensure_ascii=False, sort_keys=True),
121
+ f"Context manifest: {change['artifact_root']}/context-manifest.json",
122
+ "External providers (metadata references only): "
123
+ + json.dumps(change["providers"], ensure_ascii=False, sort_keys=True),
124
+ ])
125
+ if len(changes) > 1:
126
+ lines.append(f"Active change count: {len(changes)}; resolve scope with change.list before mutation.")
127
+ if not workflow and not changes:
128
+ lines.extend([
129
+ "[Ultra baseline]",
130
+ f"Project: {root}",
131
+ "No active continuous change. Start daily work with the ultra-change workflow.",
132
+ ])
133
+ lines.append("Authority: .ultra/state.db; generated JSON/Markdown are projections or artifacts.")
134
+ return "\n".join(lines)
135
+
136
+
41
137
  def main() -> None:
42
- found = active_state(payload())
43
- if not found:
138
+ data = payload()
139
+ root = find_root(Path(data.get("cwd") or Path.cwd()).resolve())
140
+ if root is None:
44
141
  print(json.dumps({}))
45
142
  return
46
- root, state = found
47
- context = "\n".join([
48
- "[Ultra active workflow]",
49
- f"Project: {root}",
50
- f"Command: {state.get('command', 'unknown')}",
51
- f"Task: {state.get('task_id', state.get('task', 'unknown'))}",
52
- f"Step: {state.get('step', 'unknown')}",
53
- f"Status: {state.get('status', 'active')}",
54
- "Authority: .ultra/state.db; workflow recovery: .ultra/workflow-state.json.",
55
- ])
143
+ workflow = read_workflow(root)
144
+ changes = read_changes(root)
145
+ context = context_text(root, workflow, changes)
56
146
  print(json.dumps({"hookSpecificOutput": {
57
147
  "hookEventName": "SessionStart",
58
148
  "additionalContext": context,
@@ -2,12 +2,82 @@
2
2
  """Restore only the current Ultra workflow boundary after compaction."""
3
3
 
4
4
  import json
5
+ import os
5
6
  import sys
7
+ import tempfile
8
+ from datetime import datetime, timezone
6
9
  from pathlib import Path
7
10
 
8
11
  TERMINAL = {"committed", "completed", "done", "cancelled"}
9
12
 
10
13
 
14
+ def valid_workflow(state: object) -> bool:
15
+ return (
16
+ isinstance(state, dict)
17
+ and isinstance(state.get("command"), str)
18
+ and bool(state["command"].strip())
19
+ and state.get("step") is not None
20
+ and isinstance(state.get("status"), str)
21
+ and bool(state["status"].strip())
22
+ )
23
+
24
+
25
+ def read_json(file: Path, label: str):
26
+ if not file.is_file():
27
+ return None
28
+ try:
29
+ return json.loads(file.read_text(encoding="utf-8"))
30
+ except (OSError, json.JSONDecodeError) as exc:
31
+ print(f"[workflow_resume] cannot read {label} {file}: {exc}", file=sys.stderr)
32
+ return None
33
+
34
+
35
+ def parse_time(value: object):
36
+ if not isinstance(value, str) or not value.strip():
37
+ return None
38
+ try:
39
+ return datetime.fromisoformat(value.replace("Z", "+00:00")).astimezone(timezone.utc)
40
+ except ValueError:
41
+ return None
42
+
43
+
44
+ def newest_time(file: Path, *values: object) -> datetime:
45
+ candidates = [parsed for value in values if (parsed := parse_time(value)) is not None]
46
+ try:
47
+ candidates.append(datetime.fromtimestamp(file.stat().st_mtime, tz=timezone.utc))
48
+ except OSError:
49
+ pass
50
+ return max(candidates, default=datetime.min.replace(tzinfo=timezone.utc))
51
+
52
+
53
+ def load_checkpoint(file: Path):
54
+ checkpoint = read_json(file, "checkpoint")
55
+ if checkpoint is None:
56
+ return None
57
+ if not isinstance(checkpoint, dict):
58
+ print(f"[workflow_resume] invalid checkpoint schema in {file}", file=sys.stderr)
59
+ return None
60
+ captured_at = checkpoint.get("captured_at")
61
+ workflow = checkpoint.get("workflow")
62
+ if checkpoint.get("schema") != 1 or parse_time(captured_at) is None or not valid_workflow(workflow):
63
+ print(f"[workflow_resume] invalid checkpoint schema in {file}", file=sys.stderr)
64
+ return None
65
+ return checkpoint
66
+
67
+
68
+ def restore_state(file: Path, state: dict) -> None:
69
+ file.parent.mkdir(parents=True, exist_ok=True)
70
+ fd, temp_name = tempfile.mkstemp(prefix="workflow-state.", suffix=".json", dir=file.parent)
71
+ try:
72
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
73
+ json.dump(state, handle, ensure_ascii=False, indent=2)
74
+ handle.write("\n")
75
+ os.replace(temp_name, file)
76
+ finally:
77
+ if os.path.exists(temp_name):
78
+ os.unlink(temp_name)
79
+
80
+
11
81
  def main() -> None:
12
82
  try:
13
83
  data = json.loads(sys.stdin.read() or "{}")
@@ -18,21 +88,40 @@ def main() -> None:
18
88
  start = Path(data.get("cwd") or Path.cwd()).resolve()
19
89
  for root in (start, *start.parents):
20
90
  state_file = root / ".ultra" / "workflow-state.json"
21
- if not state_file.is_file():
91
+ checkpoint_file = root / ".ultra" / "runtime" / "checkpoint.json"
92
+ if not state_file.is_file() and not checkpoint_file.is_file():
22
93
  continue
23
- try:
24
- state = json.loads(state_file.read_text(encoding="utf-8"))
25
- except (OSError, json.JSONDecodeError) as exc:
26
- print(f"[workflow_resume] cannot read {state_file}: {exc}", file=sys.stderr)
94
+ live = read_json(state_file, "workflow state")
95
+ if live is not None and not valid_workflow(live):
96
+ print(f"[workflow_resume] invalid workflow state in {state_file}", file=sys.stderr)
97
+ live = None
98
+ if live is not None and live.get("status") in TERMINAL:
27
99
  break
28
- if not isinstance(state, dict) or state.get("status") in TERMINAL:
100
+ checkpoint = load_checkpoint(checkpoint_file)
101
+ checkpoint_state = checkpoint["workflow"] if checkpoint is not None else None
102
+ if checkpoint_state is not None and checkpoint_state.get("status") in TERMINAL:
103
+ checkpoint_state = None
104
+
105
+ source = ".ultra/workflow-state.json"
106
+ state = live
107
+ if checkpoint_state is not None:
108
+ checkpoint_time = newest_time(
109
+ checkpoint_file, checkpoint_state.get("ts"), checkpoint.get("captured_at")
110
+ )
111
+ live_time = newest_time(state_file, live.get("ts")) if live is not None else None
112
+ if live_time is None or checkpoint_time >= live_time:
113
+ state = checkpoint_state
114
+ source = ".ultra/runtime/checkpoint.json"
115
+ restore_state(state_file, state)
116
+ if state is None:
29
117
  break
30
118
  text = (
31
119
  "[Ultra workflow resumed] "
32
120
  f"command={state.get('command', 'unknown')}; "
33
121
  f"task={state.get('task_id', state.get('task', 'unknown'))}; "
34
122
  f"step={state.get('step', 'unknown')}; status={state.get('status', 'active')}. "
35
- "Continue from .ultra/workflow-state.json and verify state through .ultra/state.db."
123
+ f"source={source}. Continue from the recovered workflow boundary and verify "
124
+ "task/session/change state through .ultra/state.db."
36
125
  )
37
126
  event = data.get("hook_event_name", "SessionStart")
38
127
  if event == "SessionStart":