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.
- package/.ultra-template/changes/active/.gitkeep +1 -0
- package/.ultra-template/changes/archive/.gitkeep +1 -0
- package/CHANGELOG.md +90 -11
- package/README.md +39 -19
- package/adapters/_shared/handbook.cjs +8 -7
- package/adapters/_shared/provenance.cjs +286 -0
- package/adapters/_shared/runtime-assets.cjs +2 -0
- package/adapters/_shared/tests/handbook.test.cjs +4 -1
- package/adapters/_shared/tests/provenance.test.cjs +133 -0
- package/adapters/_shared/tests/runtime-assets.test.cjs +2 -0
- package/adapters/claude.js +33 -0
- package/adapters/codex.js +87 -0
- package/adapters/opencode.js +147 -20
- package/adapters/tests/codex.test.cjs +42 -3
- package/adapters/tests/opencode.test.cjs +16 -9
- package/agents/debugger.md +12 -0
- package/bin/install.js +63 -27
- package/bin/orchestrator.js +1 -26
- package/commands/ultra-change.md +48 -0
- package/commands/ultra-deliver.md +14 -7
- package/commands/ultra-dev.md +6 -3
- package/commands/ultra-doctor.md +36 -0
- package/commands/ultra-init.md +4 -3
- package/commands/ultra-status.md +7 -5
- package/commands/ultra-test.md +2 -1
- package/docs/AGENT-CONTEXT.md +31 -23
- package/docs/ARCHITECTURE.md +80 -17
- package/docs/PLAN.zh-CN.md +37 -29
- package/docs/ROADMAP.md +14 -14
- package/docs/RUNTIME-COMPAT-MATRIX.md +19 -12
- package/hooks/README.md +14 -9
- package/hooks/active_task_context.py +22 -15
- package/hooks/health_check.py +136 -38
- package/hooks/workflow_checkpoint.py +15 -1
- package/hooks/workflow_context.py +120 -30
- package/hooks/workflow_resume.py +96 -7
- package/mcp-server/lib/change-workflow.cjs +625 -0
- package/mcp-server/lib/change-workflow.test.cjs +346 -0
- package/mcp-server/lib/doctor.cjs +112 -0
- package/mcp-server/lib/doctor.test.cjs +86 -0
- package/mcp-server/lib/init-project.cjs +32 -6
- package/mcp-server/lib/runtime-state.cjs +222 -0
- package/mcp-server/lib/runtime-state.test.cjs +92 -0
- package/mcp-server/lib/state-db.cjs +31 -8
- package/mcp-server/lib/state-ops.cjs +17 -3
- package/mcp-server/server.cjs +102 -10
- package/mcp-server/tests/db-ops.test.cjs +5 -1
- package/mcp-server/tests/init-project.test.cjs +45 -0
- package/mcp-server/tests/server-contract.test.cjs +123 -1
- package/mcp-server/tests/state-db.test.cjs +44 -7
- package/mcp-server/tests/state-ops.test.cjs +19 -0
- package/orchestrator/daemon.cjs +39 -0
- package/orchestrator/tests/daemon.test.cjs +50 -0
- package/package.json +3 -3
- package/settings.json +1 -2
- package/skills/ultra-change/SKILL.md +149 -0
- package/skills/ultra-deliver/SKILL.md +83 -15
- package/skills/ultra-dev/SKILL.md +32 -12
- package/skills/ultra-doctor/SKILL.md +97 -0
- package/skills/ultra-init/SKILL.md +8 -5
- package/skills/ultra-review/SKILL.md +16 -0
- package/skills/ultra-status/SKILL.md +30 -7
- package/skills/ultra-test/SKILL.md +14 -2
- package/spec/cli-protocol.md +12 -1
- package/spec/mcp-tools.yaml +337 -1
- package/spec/schemas/mcp-tools.schema.json +1 -1
- package/spec/schemas/state-db.sql +117 -0
- package/ultra-tools/cli.cjs +3 -0
- package/ultra-tools/commands/system.cjs +57 -0
- package/ultra-tools/system-command.test.cjs +53 -0
- package/adapters/_shared/rtk-detect.cjs +0 -86
- package/adapters/_shared/tests/rtk-detect.test.cjs +0 -116
- package/docs/LEGACY-HERMES.md +0 -1099
- package/orchestrator/code-graph-watcher.cjs +0 -121
- package/orchestrator/tests/code-graph-watcher.test.cjs +0 -183
package/hooks/health_check.py
CHANGED
|
@@ -1,59 +1,157 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""
|
|
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
|
-
|
|
13
|
-
REQUIRED_TABLES = {
|
|
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
|
|
18
|
+
def hook_input() -> dict:
|
|
17
19
|
try:
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
except
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
24
|
+
return {}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def find_root(start: Path):
|
|
26
28
|
for candidate in (start, *start.parents):
|
|
27
|
-
|
|
28
|
-
if
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
28
|
-
if
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
43
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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,
|
package/hooks/workflow_resume.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
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
|
|
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":
|