ultra-builder-pro-cli 0.13.0 → 0.15.0
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/docs/research/README.md +11 -7
- package/.ultra-template/specs/architecture.md +36 -36
- package/.ultra-template/specs/discovery.md +46 -33
- package/.ultra-template/specs/product.md +38 -29
- package/.ultra-template/specs/research-distillate.md +48 -0
- package/.ultra-template/tasks/contexts/TEMPLATE.md +11 -28
- package/CHANGELOG.md +86 -1
- package/README.md +40 -26
- package/adapters/_shared/codex-assets.cjs +29 -165
- package/adapters/_shared/file-ops.cjs +6 -2
- package/adapters/_shared/runtime-assets.cjs +1 -0
- package/adapters/claude.js +3 -2
- package/adapters/codex.js +1 -0
- package/adapters/kimi.js +3 -2
- package/adapters/opencode.js +33 -122
- package/agents/review-coordinator.md +9 -6
- package/docs/AGENT-CONTEXT.md +21 -11
- package/docs/ARCHITECTURE.md +41 -13
- package/docs/PLAN.zh-CN.md +3 -2
- package/docs/ROADMAP.md +9 -5
- package/docs/RUNTIME-COMPAT-MATRIX.md +6 -6
- package/docs/STATE-DB-ACCESS-POLICY.md +27 -25
- package/docs/WORKFLOW-LIFECYCLE.md +266 -0
- package/hooks/README.md +6 -1
- package/hooks/active_task_context.py +22 -50
- package/hooks/context_spine.py +61 -332
- package/hooks/health_check.py +29 -18
- package/hooks/pre_stop_check.py +28 -33
- package/hooks/subagent_tracker.py +54 -85
- package/hooks/workflow_checkpoint.py +39 -47
- package/hooks/workflow_context.py +4 -9
- package/hooks/workflow_resume.py +48 -128
- package/mcp-server/breadcrumb.cjs +22 -0
- package/mcp-server/hook-event.cjs +34 -0
- package/mcp-server/lib/archive-journal.cjs +17 -5
- package/mcp-server/lib/baseline-workflow.cjs +158 -18
- package/mcp-server/lib/change-workflow.cjs +550 -85
- package/mcp-server/lib/context-spine.cjs +242 -24
- package/mcp-server/lib/doctor.cjs +33 -3
- package/mcp-server/lib/init-project.cjs +210 -16
- package/mcp-server/lib/plan-store.cjs +4 -1
- package/mcp-server/lib/prd-parser.cjs +14 -41
- package/mcp-server/lib/pricing.cjs +7 -10
- package/mcp-server/lib/project-breadcrumb.cjs +145 -0
- package/mcp-server/lib/projector.cjs +57 -3
- package/mcp-server/lib/spec-learning.cjs +79 -2
- package/mcp-server/lib/state-db.cjs +145 -8
- package/mcp-server/lib/state-ops.cjs +160 -9
- package/mcp-server/lib/task-expander.cjs +9 -38
- package/mcp-server/lib/workflow-state.cjs +2292 -0
- package/mcp-server/server.cjs +182 -50
- package/orchestrator/daemon.cjs +1 -2
- package/orchestrator/dispatch-rules.cjs +5 -31
- package/orchestrator/planner/plan-builder.cjs +21 -12
- package/package.json +14 -6
- package/skills/cc-collab/SKILL.md +2 -2
- package/skills/codex-collab/SKILL.md +2 -3
- package/skills/ultra-change/SKILL.md +53 -68
- package/skills/ultra-change/references/change-contract.md +36 -0
- package/skills/ultra-deliver/SKILL.md +69 -79
- package/skills/ultra-deliver/references/baseline-reconciliation.md +20 -0
- package/skills/ultra-dev/SKILL.md +54 -59
- package/skills/ultra-doctor/SKILL.md +40 -71
- package/skills/ultra-init/SKILL.md +96 -103
- package/skills/ultra-init/references/brownfield-adoption.md +2 -2
- package/skills/ultra-plan/SKILL.md +79 -75
- package/skills/ultra-plan/references/semantic-preflight.md +18 -0
- package/skills/ultra-research/SKILL.md +87 -60
- package/skills/ultra-research/references/00-problem-validation.md +36 -0
- package/skills/ultra-research/references/01-opportunity-discovery.md +33 -0
- package/skills/ultra-research/references/02-market-assessment.md +30 -0
- package/skills/ultra-research/references/03-competitive-landscape.md +31 -0
- package/skills/ultra-research/references/04-product-strategy.md +32 -0
- package/skills/ultra-research/references/05-assumptions-validation.md +31 -0
- package/skills/ultra-research/references/10-user-personas.md +29 -0
- package/skills/ultra-research/references/11-user-scenarios.md +28 -0
- package/skills/ultra-research/references/20-user-stories.md +30 -0
- package/skills/ultra-research/references/21-features-scope.md +29 -0
- package/skills/ultra-research/references/22-success-metrics.md +28 -0
- package/skills/ultra-research/references/30-architecture-context.md +30 -0
- package/skills/ultra-research/references/31-solution-strategy.md +28 -0
- package/skills/ultra-research/references/32-building-blocks.md +28 -0
- package/skills/ultra-research/references/40-deployment.md +29 -0
- package/skills/ultra-research/references/41-quality-risks.md +28 -0
- package/skills/ultra-research/references/99-synthesis.md +35 -0
- package/skills/ultra-review/SKILL.md +57 -73
- package/skills/ultra-review/references/review-modes.md +27 -0
- package/skills/ultra-review/references/unified-schema.md +25 -0
- package/skills/ultra-review/scripts/review_wait.py +40 -3
- package/skills/ultra-status/SKILL.md +41 -45
- package/skills/ultra-test/SKILL.md +48 -55
- package/skills/ultra-verify/SKILL.md +22 -31
- package/skills/ultra-verify/references/{confidence-system.md → evidence-status.md} +3 -3
- package/skills/ultra-verify/scripts/verify_wait.py +54 -54
- package/spec/cli-protocol.md +7 -2
- package/spec/mcp-tools.yaml +422 -73
- package/spec/schemas/baseline-reconciliation.v1.schema.json +73 -0
- package/spec/schemas/delivery-report.v1.schema.json +111 -0
- package/spec/schemas/mcp-tools.schema.json +1 -1
- package/spec/schemas/state-db.sql +82 -1
- package/spec/schemas/tasks.v4.5.schema.json +8 -1
- package/spec/schemas/test-report.v1.schema.json +194 -0
- package/ultra-tools/cli.cjs +1 -1
- package/ultra-tools/commands/migrate.cjs +3 -3
- package/ultra-tools/commands/status.cjs +213 -12
- package/.ultra-template/delivery-report.json +0 -9
- package/.ultra-template/test-report.json +0 -8
- package/adapters/_shared/tests/file-ops.test.cjs +0 -54
- package/adapters/_shared/tests/frontmatter.test.cjs +0 -47
- package/adapters/_shared/tests/handbook.test.cjs +0 -101
- package/adapters/_shared/tests/path-rewrite.test.cjs +0 -60
- package/adapters/_shared/tests/provenance.test.cjs +0 -133
- package/adapters/_shared/tests/resolve-target.test.cjs +0 -75
- package/adapters/_shared/tests/runtime-assets.test.cjs +0 -131
- package/adapters/_shared/tests/settings-merge.test.cjs +0 -85
- package/adapters/_shared/tests/validate.test.cjs +0 -30
- package/adapters/tests/claude.test.cjs +0 -137
- package/adapters/tests/codex-hook.test.cjs +0 -115
- package/adapters/tests/codex.test.cjs +0 -423
- package/adapters/tests/kimi-hook.test.cjs +0 -122
- package/adapters/tests/kimi.test.cjs +0 -259
- package/adapters/tests/opencode.test.cjs +0 -371
- package/mcp-server/lib/archive-journal.test.cjs +0 -35
- package/mcp-server/lib/baseline-workflow.test.cjs +0 -477
- package/mcp-server/lib/change-workflow.test.cjs +0 -971
- package/mcp-server/lib/context-spine.test.cjs +0 -93
- package/mcp-server/lib/doctor.test.cjs +0 -148
- package/mcp-server/lib/llm-client.cjs +0 -157
- package/mcp-server/lib/llm-client.test.cjs +0 -156
- package/mcp-server/lib/plan-store.test.cjs +0 -132
- package/mcp-server/lib/prd-parser.test.cjs +0 -171
- package/mcp-server/lib/runtime-state.test.cjs +0 -92
- package/mcp-server/lib/spec-learning.test.cjs +0 -67
- package/mcp-server/lib/state-db.test.cjs +0 -314
- package/mcp-server/lib/task-expander.test.cjs +0 -324
- package/mcp-server/lib/topo.test.cjs +0 -158
- package/mcp-server/tests/circuit-breaker.test.cjs +0 -125
- package/mcp-server/tests/commit-hash-flow.test.cjs +0 -117
- package/mcp-server/tests/concurrency-update.test.cjs +0 -96
- package/mcp-server/tests/concurrency.test.cjs +0 -112
- package/mcp-server/tests/db-ops.test.cjs +0 -121
- package/mcp-server/tests/init-project.test.cjs +0 -393
- package/mcp-server/tests/migrate.test.cjs +0 -232
- package/mcp-server/tests/projector.test.cjs +0 -162
- package/mcp-server/tests/server-contract.test.cjs +0 -1471
- package/mcp-server/tests/staleness.test.cjs +0 -190
- package/mcp-server/tests/state-db.test.cjs +0 -106
- package/mcp-server/tests/state-ops.test.cjs +0 -449
- package/mcp-server/tests/tagged-tasks.test.cjs +0 -163
- package/mcp-server/tests/telemetry.test.cjs +0 -219
- package/orchestrator/planner/expand-prompt.cjs +0 -62
- package/orchestrator/planner/expand-prompt.test.cjs +0 -52
- package/orchestrator/planner/plan-builder.test.cjs +0 -165
- package/orchestrator/planner/prd-prompt.cjs +0 -44
- package/orchestrator/planner/prd-prompt.test.cjs +0 -30
- package/orchestrator/tests/auto-merge.test.cjs +0 -216
- package/orchestrator/tests/daemon.test.cjs +0 -311
- package/orchestrator/tests/dispatch-rules.test.cjs +0 -124
- package/orchestrator/tests/parallel-orchestrator.test.cjs +0 -233
- package/orchestrator/tests/phase-8b-gate.test.cjs +0 -183
- package/orchestrator/tests/recovery.test.cjs +0 -190
- package/orchestrator/tests/session-runner.test.cjs +0 -314
- package/orchestrator/tests/skill-miner.test.cjs +0 -147
- package/orchestrator/tests/worktree-manager.test.cjs +0 -148
- package/skills/ultra-research/references/architecture.md +0 -38
- package/skills/ultra-research/references/problem-product.md +0 -39
- package/skills/ultra-research/references/quality-delivery.md +0 -34
- package/skills/ultra-research/references/synthesis.md +0 -38
- package/skills/ultra-research/references/users-scope.md +0 -40
- package/ultra-tools/legacy-memory.test.cjs +0 -80
- package/ultra-tools/status-command.test.cjs +0 -181
- package/ultra-tools/system-command.test.cjs +0 -309
|
@@ -1,73 +1,67 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""
|
|
2
|
+
"""Append minimal Ultra subagent lifecycle metadata to state.db events.
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
Protocol fields:
|
|
8
|
-
SubagentStart: agent_id, agent_type, session_id
|
|
9
|
-
SubagentStop: agent_id, agent_type, session_id, agent_transcript_path,
|
|
10
|
-
last_assistant_message, stop_hook_active
|
|
11
|
-
|
|
12
|
-
Usage:
|
|
13
|
-
python3 subagent_tracker.py start # called by SubagentStart hook
|
|
14
|
-
python3 subagent_tracker.py stop # called by SubagentStop hook
|
|
4
|
+
The hook never stores prompts, messages, transcript paths, or a parallel JSONL log.
|
|
5
|
+
It is advisory: a failed lifecycle event is reported on stderr but never blocks the
|
|
6
|
+
host's subagent operation.
|
|
15
7
|
"""
|
|
16
8
|
|
|
17
9
|
import json
|
|
18
10
|
import os
|
|
19
|
-
import
|
|
11
|
+
import shutil
|
|
12
|
+
import subprocess
|
|
20
13
|
import sys
|
|
21
|
-
from datetime import datetime, timezone
|
|
22
14
|
from pathlib import Path
|
|
23
15
|
|
|
24
|
-
|
|
25
|
-
MAX_LOG_LINES = 5000
|
|
16
|
+
from context_spine import find_root
|
|
26
17
|
|
|
27
18
|
|
|
28
|
-
def
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
print(f"[subagent_tracker] cannot read {state_file}: {exc}", file=sys.stderr)
|
|
38
|
-
return None
|
|
39
|
-
if isinstance(state, dict) and state.get("status") not in TERMINAL:
|
|
40
|
-
return root / ".ultra" / "runtime"
|
|
41
|
-
return None
|
|
42
|
-
return None
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
def rotate_log(log_file: Path) -> None:
|
|
46
|
-
"""Keep last MAX_LOG_LINES entries to prevent unbounded growth."""
|
|
47
|
-
try:
|
|
48
|
-
if not log_file.exists():
|
|
49
|
-
return
|
|
50
|
-
with open(log_file, "r", encoding="utf-8") as f:
|
|
51
|
-
lines = f.readlines()
|
|
52
|
-
if len(lines) <= MAX_LOG_LINES:
|
|
53
|
-
return
|
|
54
|
-
with open(log_file, "w", encoding="utf-8") as f:
|
|
55
|
-
f.writelines(lines[-MAX_LOG_LINES:])
|
|
56
|
-
except OSError as exc:
|
|
57
|
-
print(f"[subagent_tracker] log rotation failed: {exc}", file=sys.stderr)
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
def main():
|
|
61
|
-
if len(sys.argv) < 2:
|
|
62
|
-
print(json.dumps({}))
|
|
63
|
-
return
|
|
19
|
+
def helper_path() -> Path:
|
|
20
|
+
hook_root = Path(__file__).resolve().parent
|
|
21
|
+
installed = hook_root.parent / "runtime" / "hook-event.cjs"
|
|
22
|
+
if installed.is_file():
|
|
23
|
+
return installed
|
|
24
|
+
source = hook_root.parent / "mcp-server" / "hook-event.cjs"
|
|
25
|
+
if source.is_file():
|
|
26
|
+
return source
|
|
27
|
+
raise RuntimeError("bundled hook lifecycle helper is missing")
|
|
64
28
|
|
|
65
|
-
|
|
29
|
+
|
|
30
|
+
def node_binary() -> str:
|
|
31
|
+
configured = os.environ.get("UBP_NODE")
|
|
32
|
+
if configured:
|
|
33
|
+
return configured
|
|
34
|
+
discovered = shutil.which("node")
|
|
35
|
+
if discovered:
|
|
36
|
+
return discovered
|
|
37
|
+
raise RuntimeError("node executable is unavailable")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def append_event(root: Path, action: str, hook_input: dict) -> None:
|
|
41
|
+
payload = {
|
|
42
|
+
"agent_id": hook_input.get("agent_id", "unknown"),
|
|
43
|
+
"agent_type": hook_input.get("agent_type", "unknown"),
|
|
44
|
+
"session_id": hook_input.get("session_id", ""),
|
|
45
|
+
}
|
|
46
|
+
result = subprocess.run(
|
|
47
|
+
[node_binary(), str(helper_path()), str(root.resolve()), action],
|
|
48
|
+
input=json.dumps(payload),
|
|
49
|
+
text=True,
|
|
50
|
+
capture_output=True,
|
|
51
|
+
check=False,
|
|
52
|
+
timeout=5,
|
|
53
|
+
)
|
|
54
|
+
if result.returncode != 0:
|
|
55
|
+
detail = result.stderr.strip() or f"exit {result.returncode}"
|
|
56
|
+
raise RuntimeError(detail)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def main() -> None:
|
|
60
|
+
action = sys.argv[1] if len(sys.argv) > 1 else ""
|
|
66
61
|
if action not in ("start", "stop"):
|
|
67
62
|
print(json.dumps({}))
|
|
68
63
|
return
|
|
69
64
|
|
|
70
|
-
# Read hook input from stdin
|
|
71
65
|
try:
|
|
72
66
|
raw = sys.stdin.read()
|
|
73
67
|
hook_input = json.loads(raw) if raw.strip() else {}
|
|
@@ -77,37 +71,12 @@ def main():
|
|
|
77
71
|
print(f"[subagent_tracker] invalid hook input: {exc}", file=sys.stderr)
|
|
78
72
|
hook_input = {}
|
|
79
73
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
log_dir.mkdir(parents=True, exist_ok=True)
|
|
87
|
-
|
|
88
|
-
entry = {
|
|
89
|
-
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
90
|
-
"event": f"subagent_{action}",
|
|
91
|
-
"agent_id": hook_input.get("agent_id", "unknown"),
|
|
92
|
-
"agent_type": hook_input.get("agent_type", "unknown"),
|
|
93
|
-
"session_id": hook_input.get("session_id", ""),
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
# SubagentStop provides additional fields
|
|
97
|
-
if action == "stop":
|
|
98
|
-
transcript = hook_input.get("agent_transcript_path", "")
|
|
99
|
-
if transcript:
|
|
100
|
-
entry["agent_transcript_path"] = transcript
|
|
101
|
-
|
|
102
|
-
try:
|
|
103
|
-
with open(log_file, "a", encoding="utf-8") as f:
|
|
104
|
-
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
|
|
105
|
-
except OSError as exc:
|
|
106
|
-
print(f"[subagent_tracker] event write failed: {exc}", file=sys.stderr)
|
|
107
|
-
|
|
108
|
-
# Periodic log rotation (~1% of writes)
|
|
109
|
-
if random.random() < 0.01:
|
|
110
|
-
rotate_log(log_file)
|
|
74
|
+
root = find_root(Path(hook_input.get("cwd") or Path.cwd()).resolve())
|
|
75
|
+
if root is not None:
|
|
76
|
+
try:
|
|
77
|
+
append_event(root, action, hook_input)
|
|
78
|
+
except (OSError, RuntimeError, subprocess.TimeoutExpired) as exc:
|
|
79
|
+
print(f"[subagent_tracker] lifecycle event unavailable: {exc}", file=sys.stderr)
|
|
111
80
|
|
|
112
81
|
print(json.dumps({}))
|
|
113
82
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""Write a
|
|
2
|
+
"""Write a recovery projection of the canonical DB breadcrumb before compaction."""
|
|
3
3
|
|
|
4
4
|
import json
|
|
5
5
|
import os
|
|
@@ -8,18 +8,12 @@ import tempfile
|
|
|
8
8
|
from datetime import datetime, timezone
|
|
9
9
|
from pathlib import Path
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
)
|
|
11
|
+
from context_spine import (
|
|
12
|
+
ContextSpineError,
|
|
13
|
+
find_root,
|
|
14
|
+
read_breadcrumb,
|
|
15
|
+
render_breadcrumb,
|
|
16
|
+
)
|
|
23
17
|
|
|
24
18
|
|
|
25
19
|
def main() -> None:
|
|
@@ -29,41 +23,39 @@ def main() -> None:
|
|
|
29
23
|
except json.JSONDecodeError as exc:
|
|
30
24
|
print(f"[workflow_checkpoint] invalid hook input: {exc}", file=sys.stderr)
|
|
31
25
|
data = {}
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
break
|
|
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:
|
|
46
|
-
break
|
|
47
|
-
runtime_dir = root / ".ultra" / "runtime"
|
|
48
|
-
runtime_dir.mkdir(parents=True, exist_ok=True)
|
|
49
|
-
checkpoint = {
|
|
50
|
-
"schema": 1,
|
|
51
|
-
"captured_at": datetime.now(timezone.utc).isoformat(),
|
|
52
|
-
"session_id": data.get("session_id", ""),
|
|
53
|
-
"workflow": state,
|
|
54
|
-
}
|
|
55
|
-
fd, temp_name = tempfile.mkstemp(prefix="checkpoint.", suffix=".json", dir=runtime_dir)
|
|
56
|
-
try:
|
|
57
|
-
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
58
|
-
json.dump(checkpoint, handle, ensure_ascii=False, indent=2)
|
|
59
|
-
handle.write("\n")
|
|
60
|
-
os.replace(temp_name, runtime_dir / "checkpoint.json")
|
|
61
|
-
finally:
|
|
62
|
-
if os.path.exists(temp_name):
|
|
63
|
-
os.unlink(temp_name)
|
|
64
|
-
print(json.dumps({"systemMessage": "Ultra workflow checkpoint saved."}))
|
|
26
|
+
root = find_root(Path(data.get("cwd") or Path.cwd()).resolve())
|
|
27
|
+
if root is None:
|
|
28
|
+
print(json.dumps({}))
|
|
29
|
+
return
|
|
30
|
+
try:
|
|
31
|
+
breadcrumb = read_breadcrumb(root)
|
|
32
|
+
except ContextSpineError as exc:
|
|
33
|
+
print(f"[workflow_checkpoint] cannot inspect Context Spine: {exc}", file=sys.stderr)
|
|
34
|
+
print(json.dumps({}))
|
|
65
35
|
return
|
|
66
|
-
|
|
36
|
+
if not breadcrumb or not breadcrumb.get("change_id"):
|
|
37
|
+
print(json.dumps({}))
|
|
38
|
+
return
|
|
39
|
+
|
|
40
|
+
runtime_dir = root / ".ultra" / "runtime"
|
|
41
|
+
runtime_dir.mkdir(parents=True, exist_ok=True)
|
|
42
|
+
checkpoint = {
|
|
43
|
+
"schema": 2,
|
|
44
|
+
"captured_at": datetime.now(timezone.utc).isoformat(),
|
|
45
|
+
"session_id": data.get("session_id", ""),
|
|
46
|
+
"breadcrumb": dict(breadcrumb),
|
|
47
|
+
"rendered": render_breadcrumb(root, breadcrumb),
|
|
48
|
+
}
|
|
49
|
+
fd, temp_name = tempfile.mkstemp(prefix="checkpoint.", suffix=".json", dir=runtime_dir)
|
|
50
|
+
try:
|
|
51
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
52
|
+
json.dump(checkpoint, handle, ensure_ascii=False, indent=2)
|
|
53
|
+
handle.write("\n")
|
|
54
|
+
os.replace(temp_name, runtime_dir / "checkpoint.json")
|
|
55
|
+
finally:
|
|
56
|
+
if os.path.exists(temp_name):
|
|
57
|
+
os.unlink(temp_name)
|
|
58
|
+
print(json.dumps({"systemMessage": "Ultra workflow checkpoint saved."}))
|
|
67
59
|
|
|
68
60
|
|
|
69
61
|
if __name__ == "__main__":
|
|
@@ -2,11 +2,10 @@
|
|
|
2
2
|
"""Inject one compact Ultra state position without persistent memory."""
|
|
3
3
|
|
|
4
4
|
import json
|
|
5
|
-
import sqlite3
|
|
6
5
|
import sys
|
|
7
6
|
from pathlib import Path
|
|
8
7
|
|
|
9
|
-
from context_spine import find_root, read_breadcrumb, render_breadcrumb
|
|
8
|
+
from context_spine import ContextSpineError, find_root, read_breadcrumb, render_breadcrumb
|
|
10
9
|
|
|
11
10
|
|
|
12
11
|
def payload() -> dict:
|
|
@@ -26,18 +25,14 @@ def main() -> None:
|
|
|
26
25
|
return
|
|
27
26
|
try:
|
|
28
27
|
breadcrumb = read_breadcrumb(root)
|
|
29
|
-
except
|
|
28
|
+
except ContextSpineError as exc:
|
|
30
29
|
print(f"[workflow_context] cannot inspect Context Spine: {exc}", file=sys.stderr)
|
|
31
30
|
breadcrumb = None
|
|
32
31
|
if breadcrumb:
|
|
33
32
|
context = render_breadcrumb(root, breadcrumb)
|
|
34
33
|
else:
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
f"Project: {root}",
|
|
38
|
-
"No active continuous change. Start daily work with the ultra-change workflow.",
|
|
39
|
-
"Authority: .ultra/state.db; JSON/Markdown remain projections or evidence artifacts.",
|
|
40
|
-
])
|
|
34
|
+
print(json.dumps({}))
|
|
35
|
+
return
|
|
41
36
|
print(json.dumps({"hookSpecificOutput": {
|
|
42
37
|
"hookEventName": "SessionStart",
|
|
43
38
|
"additionalContext": context,
|
package/hooks/workflow_resume.py
CHANGED
|
@@ -1,84 +1,53 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""
|
|
2
|
+
"""Re-inject live DB authority after compaction; checkpoints are advisory only."""
|
|
3
3
|
|
|
4
4
|
import json
|
|
5
|
-
import os
|
|
6
|
-
import sqlite3
|
|
7
5
|
import sys
|
|
8
|
-
import
|
|
9
|
-
from datetime import datetime, timezone
|
|
6
|
+
from datetime import datetime
|
|
10
7
|
from pathlib import Path
|
|
11
8
|
|
|
12
|
-
from context_spine import
|
|
9
|
+
from context_spine import (
|
|
10
|
+
ContextSpineError,
|
|
11
|
+
find_root,
|
|
12
|
+
read_breadcrumb,
|
|
13
|
+
render_breadcrumb,
|
|
14
|
+
)
|
|
13
15
|
|
|
14
|
-
TERMINAL = {"committed", "completed", "done", "cancelled"}
|
|
15
16
|
|
|
17
|
+
def _hook_output(event: str, text: str) -> dict:
|
|
18
|
+
if event == "SessionStart":
|
|
19
|
+
return {"hookSpecificOutput": {
|
|
20
|
+
"hookEventName": "SessionStart",
|
|
21
|
+
"additionalContext": text,
|
|
22
|
+
}}
|
|
23
|
+
return {"additionalContext": text}
|
|
16
24
|
|
|
17
|
-
def valid_workflow(state: object) -> bool:
|
|
18
|
-
return (
|
|
19
|
-
isinstance(state, dict)
|
|
20
|
-
and isinstance(state.get("command"), str)
|
|
21
|
-
and bool(state["command"].strip())
|
|
22
|
-
and state.get("step") is not None
|
|
23
|
-
and isinstance(state.get("status"), str)
|
|
24
|
-
and bool(state["status"].strip())
|
|
25
|
-
)
|
|
26
25
|
|
|
27
|
-
|
|
28
|
-
|
|
26
|
+
def _checkpoint_advisory(root: Path):
|
|
27
|
+
file = root / ".ultra" / "runtime" / "checkpoint.json"
|
|
29
28
|
if not file.is_file():
|
|
30
29
|
return None
|
|
31
30
|
try:
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
def parse_time(value: object):
|
|
39
|
-
if not isinstance(value, str) or not value.strip():
|
|
40
|
-
return None
|
|
41
|
-
try:
|
|
42
|
-
return datetime.fromisoformat(value.replace("Z", "+00:00")).astimezone(timezone.utc)
|
|
43
|
-
except ValueError:
|
|
44
|
-
return None
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
def newest_time(file: Path, *values: object) -> datetime:
|
|
48
|
-
candidates = [parsed for value in values if (parsed := parse_time(value)) is not None]
|
|
49
|
-
try:
|
|
50
|
-
candidates.append(datetime.fromtimestamp(file.stat().st_mtime, tz=timezone.utc))
|
|
51
|
-
except OSError:
|
|
52
|
-
pass
|
|
53
|
-
return max(candidates, default=datetime.min.replace(tzinfo=timezone.utc))
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
def load_checkpoint(file: Path):
|
|
57
|
-
checkpoint = read_json(file, "checkpoint")
|
|
58
|
-
if checkpoint is None:
|
|
31
|
+
value = json.loads(file.read_text(encoding="utf-8"))
|
|
32
|
+
datetime.fromisoformat(str(value.get("captured_at", "")).replace("Z", "+00:00"))
|
|
33
|
+
except (OSError, json.JSONDecodeError, ValueError, AttributeError) as exc:
|
|
34
|
+
print(f"[workflow_resume] cannot read checkpoint {file}: {exc}", file=sys.stderr)
|
|
59
35
|
return None
|
|
60
|
-
if
|
|
36
|
+
if (
|
|
37
|
+
not isinstance(value, dict)
|
|
38
|
+
or value.get("schema") != 2
|
|
39
|
+
or not isinstance(value.get("breadcrumb"), dict)
|
|
40
|
+
or not isinstance(value.get("rendered"), str)
|
|
41
|
+
or not value["rendered"].strip()
|
|
42
|
+
):
|
|
61
43
|
print(f"[workflow_resume] invalid checkpoint schema in {file}", file=sys.stderr)
|
|
62
44
|
return None
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
def restore_state(file: Path, state: dict) -> None:
|
|
72
|
-
file.parent.mkdir(parents=True, exist_ok=True)
|
|
73
|
-
fd, temp_name = tempfile.mkstemp(prefix="workflow-state.", suffix=".json", dir=file.parent)
|
|
74
|
-
try:
|
|
75
|
-
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
76
|
-
json.dump(state, handle, ensure_ascii=False, indent=2)
|
|
77
|
-
handle.write("\n")
|
|
78
|
-
os.replace(temp_name, file)
|
|
79
|
-
finally:
|
|
80
|
-
if os.path.exists(temp_name):
|
|
81
|
-
os.unlink(temp_name)
|
|
45
|
+
return "\n".join([
|
|
46
|
+
"[Ultra checkpoint advisory]",
|
|
47
|
+
"Live .ultra/state.db could not be read; this checkpoint is not authority.",
|
|
48
|
+
value["rendered"],
|
|
49
|
+
"Run ultra-doctor before mutation.",
|
|
50
|
+
])
|
|
82
51
|
|
|
83
52
|
|
|
84
53
|
def main() -> None:
|
|
@@ -88,72 +57,23 @@ def main() -> None:
|
|
|
88
57
|
except json.JSONDecodeError as exc:
|
|
89
58
|
print(f"[workflow_resume] invalid hook input: {exc}", file=sys.stderr)
|
|
90
59
|
data = {}
|
|
91
|
-
|
|
92
|
-
root
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
breadcrumb = None
|
|
60
|
+
root = find_root(Path(data.get("cwd") or Path.cwd()).resolve())
|
|
61
|
+
if root is None:
|
|
62
|
+
print(json.dumps({}))
|
|
63
|
+
return
|
|
64
|
+
text = None
|
|
65
|
+
try:
|
|
66
|
+
breadcrumb = read_breadcrumb(root)
|
|
99
67
|
if breadcrumb:
|
|
100
68
|
text = render_breadcrumb(root, breadcrumb)
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
}}))
|
|
107
|
-
else:
|
|
108
|
-
print(json.dumps({"additionalContext": text}))
|
|
109
|
-
return
|
|
110
|
-
for root in (start, *start.parents):
|
|
111
|
-
state_file = root / ".ultra" / "workflow-state.json"
|
|
112
|
-
checkpoint_file = root / ".ultra" / "runtime" / "checkpoint.json"
|
|
113
|
-
if not state_file.is_file() and not checkpoint_file.is_file():
|
|
114
|
-
continue
|
|
115
|
-
live = read_json(state_file, "workflow state")
|
|
116
|
-
if live is not None and not valid_workflow(live):
|
|
117
|
-
print(f"[workflow_resume] invalid workflow state in {state_file}", file=sys.stderr)
|
|
118
|
-
live = None
|
|
119
|
-
if live is not None and live.get("status") in TERMINAL:
|
|
120
|
-
break
|
|
121
|
-
checkpoint = load_checkpoint(checkpoint_file)
|
|
122
|
-
checkpoint_state = checkpoint["workflow"] if checkpoint is not None else None
|
|
123
|
-
if checkpoint_state is not None and checkpoint_state.get("status") in TERMINAL:
|
|
124
|
-
checkpoint_state = None
|
|
125
|
-
|
|
126
|
-
source = ".ultra/workflow-state.json"
|
|
127
|
-
state = live
|
|
128
|
-
if checkpoint_state is not None:
|
|
129
|
-
checkpoint_time = newest_time(
|
|
130
|
-
checkpoint_file, checkpoint_state.get("ts"), checkpoint.get("captured_at")
|
|
131
|
-
)
|
|
132
|
-
live_time = newest_time(state_file, live.get("ts")) if live is not None else None
|
|
133
|
-
if live_time is None or checkpoint_time >= live_time:
|
|
134
|
-
state = checkpoint_state
|
|
135
|
-
source = ".ultra/runtime/checkpoint.json"
|
|
136
|
-
restore_state(state_file, state)
|
|
137
|
-
if state is None:
|
|
138
|
-
break
|
|
139
|
-
text = (
|
|
140
|
-
"[Ultra workflow resumed] "
|
|
141
|
-
f"command={state.get('command', 'unknown')}; "
|
|
142
|
-
f"task={state.get('task_id', state.get('task', 'unknown'))}; "
|
|
143
|
-
f"step={state.get('step', 'unknown')}; status={state.get('status', 'active')}. "
|
|
144
|
-
f"source={source}. Continue from the recovered workflow boundary and verify "
|
|
145
|
-
"task/session/change state through .ultra/state.db."
|
|
146
|
-
)
|
|
147
|
-
event = data.get("hook_event_name", "SessionStart")
|
|
148
|
-
if event == "SessionStart":
|
|
149
|
-
print(json.dumps({"hookSpecificOutput": {
|
|
150
|
-
"hookEventName": "SessionStart",
|
|
151
|
-
"additionalContext": text,
|
|
152
|
-
}}))
|
|
153
|
-
else:
|
|
154
|
-
print(json.dumps({"additionalContext": text}))
|
|
69
|
+
except ContextSpineError as exc:
|
|
70
|
+
print(f"[workflow_resume] cannot inspect Context Spine: {exc}", file=sys.stderr)
|
|
71
|
+
text = _checkpoint_advisory(root)
|
|
72
|
+
if not text:
|
|
73
|
+
print(json.dumps({}))
|
|
155
74
|
return
|
|
156
|
-
|
|
75
|
+
event = data.get("hook_event_name", "SessionStart")
|
|
76
|
+
print(json.dumps(_hook_output(event, text)))
|
|
157
77
|
|
|
158
78
|
|
|
159
79
|
if __name__ == "__main__":
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const fs = require('node:fs');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
|
|
7
|
+
const runtime = fs.existsSync(path.join(__dirname, 'index.cjs'))
|
|
8
|
+
? require('./index.cjs')
|
|
9
|
+
: require('./server.cjs');
|
|
10
|
+
|
|
11
|
+
function main() {
|
|
12
|
+
const rootDir = path.resolve(process.argv[2] || process.cwd());
|
|
13
|
+
const breadcrumb = runtime.readProjectBreadcrumb(rootDir);
|
|
14
|
+
process.stdout.write(`${JSON.stringify({
|
|
15
|
+
breadcrumb,
|
|
16
|
+
text: breadcrumb ? runtime.renderProjectBreadcrumb(rootDir, breadcrumb) : null,
|
|
17
|
+
})}\n`);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (require.main === module) main();
|
|
21
|
+
|
|
22
|
+
module.exports = { main };
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const fs = require('node:fs');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
|
|
7
|
+
const runtime = fs.existsSync(path.join(__dirname, 'index.cjs'))
|
|
8
|
+
? require('./index.cjs')
|
|
9
|
+
: require('./server.cjs');
|
|
10
|
+
|
|
11
|
+
function main() {
|
|
12
|
+
const rootDir = path.resolve(process.argv[2] || process.cwd());
|
|
13
|
+
const action = process.argv[3];
|
|
14
|
+
let hookInput = {};
|
|
15
|
+
const raw = fs.readFileSync(0, 'utf8');
|
|
16
|
+
if (raw.trim()) {
|
|
17
|
+
hookInput = JSON.parse(raw);
|
|
18
|
+
if (!hookInput || typeof hookInput !== 'object' || Array.isArray(hookInput)) {
|
|
19
|
+
throw new Error('hook input must be a JSON object');
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
const result = runtime.appendHookLifecycleEvent({ rootDir, action, hookInput });
|
|
23
|
+
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (require.main === module) {
|
|
27
|
+
try { main(); }
|
|
28
|
+
catch (error) {
|
|
29
|
+
process.stderr.write(`hook lifecycle event failed: ${error.message}\n`);
|
|
30
|
+
process.exit(1);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
module.exports = { main };
|
|
@@ -31,6 +31,9 @@ function assertIntent(intent, rootDir) {
|
|
|
31
31
|
|| intent.schema_version !== INTENT_VERSION
|
|
32
32
|
|| typeof intent.change_id !== 'string' || !intent.change_id
|
|
33
33
|
|| !Array.isArray(intent.baseline_updates)
|
|
34
|
+
|| typeof intent.reconciliation_path !== 'string' || !intent.reconciliation_path
|
|
35
|
+
|| !/^[0-9a-f]{64}$/.test(String(intent.reconciliation_digest || ''))
|
|
36
|
+
|| !intent.reconciliation_manifest || typeof intent.reconciliation_manifest !== 'object'
|
|
34
37
|
|| typeof intent.summary !== 'string' || intent.summary.trim().length < 3) {
|
|
35
38
|
throw new ArchiveJournalError('ARCHIVE_INTENT_INVALID', 'archive intent shape is invalid');
|
|
36
39
|
}
|
|
@@ -46,6 +49,7 @@ function assertIntent(intent, rootDir) {
|
|
|
46
49
|
throw new ArchiveJournalError('ARCHIVE_INTENT_INVALID', 'archive intent destination is not the change archive root');
|
|
47
50
|
}
|
|
48
51
|
for (const update of intent.baseline_updates) resolveInside(rootDir, update, 'baseline update');
|
|
52
|
+
resolveInside(rootDir, intent.reconciliation_path, 'reconciliation path');
|
|
49
53
|
if (intent.previous_summary !== null && typeof intent.previous_summary !== 'string') {
|
|
50
54
|
throw new ArchiveJournalError('ARCHIVE_INTENT_INVALID', 'previous_summary must be string or null');
|
|
51
55
|
}
|
|
@@ -77,16 +81,21 @@ function renderSummary(change, summary, baselineUpdates, noBaselineChangeReason)
|
|
|
77
81
|
].join('\n');
|
|
78
82
|
}
|
|
79
83
|
|
|
80
|
-
function sameOperation(intent, {
|
|
84
|
+
function sameOperation(intent, {
|
|
85
|
+
change, summary, baselineUpdates, noBaselineChangeReason, reconciliationPath, reconciliationDigest,
|
|
86
|
+
}) {
|
|
81
87
|
return intent.change_id === change.id
|
|
82
88
|
&& intent.source === change.artifact_root
|
|
83
89
|
&& intent.summary === summary.trim()
|
|
84
90
|
&& JSON.stringify(intent.baseline_updates) === JSON.stringify(baselineUpdates)
|
|
85
|
-
&& (intent.no_baseline_change_reason || null) === (noBaselineChangeReason || null)
|
|
91
|
+
&& (intent.no_baseline_change_reason || null) === (noBaselineChangeReason || null)
|
|
92
|
+
&& intent.reconciliation_path === reconciliationPath
|
|
93
|
+
&& intent.reconciliation_digest === reconciliationDigest;
|
|
86
94
|
}
|
|
87
95
|
|
|
88
96
|
function prepareArchiveMove({
|
|
89
|
-
rootDir, change, summary, baselineUpdates, noBaselineChangeReason,
|
|
97
|
+
rootDir, change, summary, baselineUpdates, noBaselineChangeReason,
|
|
98
|
+
reconciliationPath, reconciliationDigest, reconciliationManifest, now = new Date(),
|
|
90
99
|
}) {
|
|
91
100
|
const date = now.toISOString().slice(0, 10);
|
|
92
101
|
const source = resolveInside(rootDir, change.artifact_root, 'source');
|
|
@@ -98,7 +107,7 @@ function prepareArchiveMove({
|
|
|
98
107
|
if (!fs.existsSync(source) && fs.existsSync(destinationIntentFile)) {
|
|
99
108
|
const resumed = readIntentFile(destinationIntentFile, rootDir);
|
|
100
109
|
if (!sameOperation(resumed.intent, {
|
|
101
|
-
change, summary, baselineUpdates, noBaselineChangeReason,
|
|
110
|
+
change, summary, baselineUpdates, noBaselineChangeReason, reconciliationPath, reconciliationDigest,
|
|
102
111
|
})) {
|
|
103
112
|
throw new ArchiveJournalError('ARCHIVE_INTENT_CONFLICT', 'existing archive intent differs from retry input');
|
|
104
113
|
}
|
|
@@ -115,7 +124,7 @@ function prepareArchiveMove({
|
|
|
115
124
|
if (fs.existsSync(sourceIntentFile)) {
|
|
116
125
|
prepared = readIntentFile(sourceIntentFile, rootDir);
|
|
117
126
|
if (!sameOperation(prepared.intent, {
|
|
118
|
-
change, summary, baselineUpdates, noBaselineChangeReason,
|
|
127
|
+
change, summary, baselineUpdates, noBaselineChangeReason, reconciliationPath, reconciliationDigest,
|
|
119
128
|
})) {
|
|
120
129
|
throw new ArchiveJournalError('ARCHIVE_INTENT_CONFLICT', 'prepared archive intent differs from retry input');
|
|
121
130
|
}
|
|
@@ -130,6 +139,9 @@ function prepareArchiveMove({
|
|
|
130
139
|
summary: summary.trim(),
|
|
131
140
|
baseline_updates: baselineUpdates,
|
|
132
141
|
no_baseline_change_reason: noBaselineChangeReason || null,
|
|
142
|
+
reconciliation_path: reconciliationPath,
|
|
143
|
+
reconciliation_digest: reconciliationDigest,
|
|
144
|
+
reconciliation_manifest: reconciliationManifest,
|
|
133
145
|
previous_summary: previousSummary,
|
|
134
146
|
created_at: now.toISOString(),
|
|
135
147
|
};
|