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.
Files changed (172) hide show
  1. package/.ultra-template/docs/research/README.md +11 -7
  2. package/.ultra-template/specs/architecture.md +36 -36
  3. package/.ultra-template/specs/discovery.md +46 -33
  4. package/.ultra-template/specs/product.md +38 -29
  5. package/.ultra-template/specs/research-distillate.md +48 -0
  6. package/.ultra-template/tasks/contexts/TEMPLATE.md +11 -28
  7. package/CHANGELOG.md +86 -1
  8. package/README.md +40 -26
  9. package/adapters/_shared/codex-assets.cjs +29 -165
  10. package/adapters/_shared/file-ops.cjs +6 -2
  11. package/adapters/_shared/runtime-assets.cjs +1 -0
  12. package/adapters/claude.js +3 -2
  13. package/adapters/codex.js +1 -0
  14. package/adapters/kimi.js +3 -2
  15. package/adapters/opencode.js +33 -122
  16. package/agents/review-coordinator.md +9 -6
  17. package/docs/AGENT-CONTEXT.md +21 -11
  18. package/docs/ARCHITECTURE.md +41 -13
  19. package/docs/PLAN.zh-CN.md +3 -2
  20. package/docs/ROADMAP.md +9 -5
  21. package/docs/RUNTIME-COMPAT-MATRIX.md +6 -6
  22. package/docs/STATE-DB-ACCESS-POLICY.md +27 -25
  23. package/docs/WORKFLOW-LIFECYCLE.md +266 -0
  24. package/hooks/README.md +6 -1
  25. package/hooks/active_task_context.py +22 -50
  26. package/hooks/context_spine.py +61 -332
  27. package/hooks/health_check.py +29 -18
  28. package/hooks/pre_stop_check.py +28 -33
  29. package/hooks/subagent_tracker.py +54 -85
  30. package/hooks/workflow_checkpoint.py +39 -47
  31. package/hooks/workflow_context.py +4 -9
  32. package/hooks/workflow_resume.py +48 -128
  33. package/mcp-server/breadcrumb.cjs +22 -0
  34. package/mcp-server/hook-event.cjs +34 -0
  35. package/mcp-server/lib/archive-journal.cjs +17 -5
  36. package/mcp-server/lib/baseline-workflow.cjs +158 -18
  37. package/mcp-server/lib/change-workflow.cjs +550 -85
  38. package/mcp-server/lib/context-spine.cjs +242 -24
  39. package/mcp-server/lib/doctor.cjs +33 -3
  40. package/mcp-server/lib/init-project.cjs +210 -16
  41. package/mcp-server/lib/plan-store.cjs +4 -1
  42. package/mcp-server/lib/prd-parser.cjs +14 -41
  43. package/mcp-server/lib/pricing.cjs +7 -10
  44. package/mcp-server/lib/project-breadcrumb.cjs +145 -0
  45. package/mcp-server/lib/projector.cjs +57 -3
  46. package/mcp-server/lib/spec-learning.cjs +79 -2
  47. package/mcp-server/lib/state-db.cjs +145 -8
  48. package/mcp-server/lib/state-ops.cjs +160 -9
  49. package/mcp-server/lib/task-expander.cjs +9 -38
  50. package/mcp-server/lib/workflow-state.cjs +2292 -0
  51. package/mcp-server/server.cjs +182 -50
  52. package/orchestrator/daemon.cjs +1 -2
  53. package/orchestrator/dispatch-rules.cjs +5 -31
  54. package/orchestrator/planner/plan-builder.cjs +21 -12
  55. package/package.json +14 -6
  56. package/skills/cc-collab/SKILL.md +2 -2
  57. package/skills/codex-collab/SKILL.md +2 -3
  58. package/skills/ultra-change/SKILL.md +53 -68
  59. package/skills/ultra-change/references/change-contract.md +36 -0
  60. package/skills/ultra-deliver/SKILL.md +69 -79
  61. package/skills/ultra-deliver/references/baseline-reconciliation.md +20 -0
  62. package/skills/ultra-dev/SKILL.md +54 -59
  63. package/skills/ultra-doctor/SKILL.md +40 -71
  64. package/skills/ultra-init/SKILL.md +96 -103
  65. package/skills/ultra-init/references/brownfield-adoption.md +2 -2
  66. package/skills/ultra-plan/SKILL.md +79 -75
  67. package/skills/ultra-plan/references/semantic-preflight.md +18 -0
  68. package/skills/ultra-research/SKILL.md +87 -60
  69. package/skills/ultra-research/references/00-problem-validation.md +36 -0
  70. package/skills/ultra-research/references/01-opportunity-discovery.md +33 -0
  71. package/skills/ultra-research/references/02-market-assessment.md +30 -0
  72. package/skills/ultra-research/references/03-competitive-landscape.md +31 -0
  73. package/skills/ultra-research/references/04-product-strategy.md +32 -0
  74. package/skills/ultra-research/references/05-assumptions-validation.md +31 -0
  75. package/skills/ultra-research/references/10-user-personas.md +29 -0
  76. package/skills/ultra-research/references/11-user-scenarios.md +28 -0
  77. package/skills/ultra-research/references/20-user-stories.md +30 -0
  78. package/skills/ultra-research/references/21-features-scope.md +29 -0
  79. package/skills/ultra-research/references/22-success-metrics.md +28 -0
  80. package/skills/ultra-research/references/30-architecture-context.md +30 -0
  81. package/skills/ultra-research/references/31-solution-strategy.md +28 -0
  82. package/skills/ultra-research/references/32-building-blocks.md +28 -0
  83. package/skills/ultra-research/references/40-deployment.md +29 -0
  84. package/skills/ultra-research/references/41-quality-risks.md +28 -0
  85. package/skills/ultra-research/references/99-synthesis.md +35 -0
  86. package/skills/ultra-review/SKILL.md +57 -73
  87. package/skills/ultra-review/references/review-modes.md +27 -0
  88. package/skills/ultra-review/references/unified-schema.md +25 -0
  89. package/skills/ultra-review/scripts/review_wait.py +40 -3
  90. package/skills/ultra-status/SKILL.md +41 -45
  91. package/skills/ultra-test/SKILL.md +48 -55
  92. package/skills/ultra-verify/SKILL.md +22 -31
  93. package/skills/ultra-verify/references/{confidence-system.md → evidence-status.md} +3 -3
  94. package/skills/ultra-verify/scripts/verify_wait.py +54 -54
  95. package/spec/cli-protocol.md +7 -2
  96. package/spec/mcp-tools.yaml +422 -73
  97. package/spec/schemas/baseline-reconciliation.v1.schema.json +73 -0
  98. package/spec/schemas/delivery-report.v1.schema.json +111 -0
  99. package/spec/schemas/mcp-tools.schema.json +1 -1
  100. package/spec/schemas/state-db.sql +82 -1
  101. package/spec/schemas/tasks.v4.5.schema.json +8 -1
  102. package/spec/schemas/test-report.v1.schema.json +194 -0
  103. package/ultra-tools/cli.cjs +1 -1
  104. package/ultra-tools/commands/migrate.cjs +3 -3
  105. package/ultra-tools/commands/status.cjs +213 -12
  106. package/.ultra-template/delivery-report.json +0 -9
  107. package/.ultra-template/test-report.json +0 -8
  108. package/adapters/_shared/tests/file-ops.test.cjs +0 -54
  109. package/adapters/_shared/tests/frontmatter.test.cjs +0 -47
  110. package/adapters/_shared/tests/handbook.test.cjs +0 -101
  111. package/adapters/_shared/tests/path-rewrite.test.cjs +0 -60
  112. package/adapters/_shared/tests/provenance.test.cjs +0 -133
  113. package/adapters/_shared/tests/resolve-target.test.cjs +0 -75
  114. package/adapters/_shared/tests/runtime-assets.test.cjs +0 -131
  115. package/adapters/_shared/tests/settings-merge.test.cjs +0 -85
  116. package/adapters/_shared/tests/validate.test.cjs +0 -30
  117. package/adapters/tests/claude.test.cjs +0 -137
  118. package/adapters/tests/codex-hook.test.cjs +0 -115
  119. package/adapters/tests/codex.test.cjs +0 -423
  120. package/adapters/tests/kimi-hook.test.cjs +0 -122
  121. package/adapters/tests/kimi.test.cjs +0 -259
  122. package/adapters/tests/opencode.test.cjs +0 -371
  123. package/mcp-server/lib/archive-journal.test.cjs +0 -35
  124. package/mcp-server/lib/baseline-workflow.test.cjs +0 -477
  125. package/mcp-server/lib/change-workflow.test.cjs +0 -971
  126. package/mcp-server/lib/context-spine.test.cjs +0 -93
  127. package/mcp-server/lib/doctor.test.cjs +0 -148
  128. package/mcp-server/lib/llm-client.cjs +0 -157
  129. package/mcp-server/lib/llm-client.test.cjs +0 -156
  130. package/mcp-server/lib/plan-store.test.cjs +0 -132
  131. package/mcp-server/lib/prd-parser.test.cjs +0 -171
  132. package/mcp-server/lib/runtime-state.test.cjs +0 -92
  133. package/mcp-server/lib/spec-learning.test.cjs +0 -67
  134. package/mcp-server/lib/state-db.test.cjs +0 -314
  135. package/mcp-server/lib/task-expander.test.cjs +0 -324
  136. package/mcp-server/lib/topo.test.cjs +0 -158
  137. package/mcp-server/tests/circuit-breaker.test.cjs +0 -125
  138. package/mcp-server/tests/commit-hash-flow.test.cjs +0 -117
  139. package/mcp-server/tests/concurrency-update.test.cjs +0 -96
  140. package/mcp-server/tests/concurrency.test.cjs +0 -112
  141. package/mcp-server/tests/db-ops.test.cjs +0 -121
  142. package/mcp-server/tests/init-project.test.cjs +0 -393
  143. package/mcp-server/tests/migrate.test.cjs +0 -232
  144. package/mcp-server/tests/projector.test.cjs +0 -162
  145. package/mcp-server/tests/server-contract.test.cjs +0 -1471
  146. package/mcp-server/tests/staleness.test.cjs +0 -190
  147. package/mcp-server/tests/state-db.test.cjs +0 -106
  148. package/mcp-server/tests/state-ops.test.cjs +0 -449
  149. package/mcp-server/tests/tagged-tasks.test.cjs +0 -163
  150. package/mcp-server/tests/telemetry.test.cjs +0 -219
  151. package/orchestrator/planner/expand-prompt.cjs +0 -62
  152. package/orchestrator/planner/expand-prompt.test.cjs +0 -52
  153. package/orchestrator/planner/plan-builder.test.cjs +0 -165
  154. package/orchestrator/planner/prd-prompt.cjs +0 -44
  155. package/orchestrator/planner/prd-prompt.test.cjs +0 -30
  156. package/orchestrator/tests/auto-merge.test.cjs +0 -216
  157. package/orchestrator/tests/daemon.test.cjs +0 -311
  158. package/orchestrator/tests/dispatch-rules.test.cjs +0 -124
  159. package/orchestrator/tests/parallel-orchestrator.test.cjs +0 -233
  160. package/orchestrator/tests/phase-8b-gate.test.cjs +0 -183
  161. package/orchestrator/tests/recovery.test.cjs +0 -190
  162. package/orchestrator/tests/session-runner.test.cjs +0 -314
  163. package/orchestrator/tests/skill-miner.test.cjs +0 -147
  164. package/orchestrator/tests/worktree-manager.test.cjs +0 -148
  165. package/skills/ultra-research/references/architecture.md +0 -38
  166. package/skills/ultra-research/references/problem-product.md +0 -39
  167. package/skills/ultra-research/references/quality-delivery.md +0 -34
  168. package/skills/ultra-research/references/synthesis.md +0 -38
  169. package/skills/ultra-research/references/users-scope.md +0 -40
  170. package/ultra-tools/legacy-memory.test.cjs +0 -80
  171. package/ultra-tools/status-command.test.cjs +0 -181
  172. package/ultra-tools/system-command.test.cjs +0 -309
@@ -1,73 +1,67 @@
1
1
  #!/usr/bin/env python3
2
- """Subagent lifecycle tracker.
2
+ """Append minimal Ultra subagent lifecycle metadata to state.db events.
3
3
 
4
- Logs SubagentStart/Stop events to .ultra/debug/subagent-log.jsonl (project-level)
5
- for debugging and cost analysis.
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 random
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
- TERMINAL = {"committed", "completed", "done", "cancelled"}
25
- MAX_LOG_LINES = 5000
16
+ from context_spine import find_root
26
17
 
27
18
 
28
- def get_log_dir(hook_input: dict):
29
- start = Path(hook_input.get("cwd") or Path.cwd()).resolve()
30
- for root in (start, *start.parents):
31
- state_file = root / ".ultra" / "workflow-state.json"
32
- if not state_file.is_file():
33
- continue
34
- try:
35
- state = json.loads(state_file.read_text(encoding="utf-8"))
36
- except (OSError, json.JSONDecodeError) as exc:
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
- action = sys.argv[1]
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
- # Lazy init: avoid module-level subprocess
81
- log_dir = get_log_dir(hook_input)
82
- if log_dir is None:
83
- print(json.dumps({}))
84
- return
85
- log_file = log_dir / "subagent-log.jsonl"
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 minimal, mechanical compaction checkpoint for an active workflow."""
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
- TERMINAL = {"committed", "completed", "done", "cancelled"}
12
-
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
- )
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
- start = Path(data.get("cwd") or Path.cwd()).resolve()
33
- for root in (start, *start.parents):
34
- state_file = root / ".ultra" / "workflow-state.json"
35
- if not state_file.is_file():
36
- continue
37
- try:
38
- state = json.loads(state_file.read_text(encoding="utf-8"))
39
- except (OSError, json.JSONDecodeError) as exc:
40
- print(f"[workflow_checkpoint] cannot read {state_file}: {exc}", file=sys.stderr)
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
- print(json.dumps({}))
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 (sqlite3.Error, OSError) as exc:
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
- context = "\n".join([
36
- "[Ultra baseline]",
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,
@@ -1,84 +1,53 @@
1
1
  #!/usr/bin/env python3
2
- """Restore only the current Ultra workflow boundary after compaction."""
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 tempfile
9
- from datetime import datetime, timezone
6
+ from datetime import datetime
10
7
  from pathlib import Path
11
8
 
12
- from context_spine import find_root, read_breadcrumb, render_breadcrumb
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
- def read_json(file: Path, label: str):
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
- return json.loads(file.read_text(encoding="utf-8"))
33
- except (OSError, json.JSONDecodeError) as exc:
34
- print(f"[workflow_resume] cannot read {label} {file}: {exc}", file=sys.stderr)
35
- return None
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 not isinstance(checkpoint, dict):
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
- captured_at = checkpoint.get("captured_at")
64
- workflow = checkpoint.get("workflow")
65
- if checkpoint.get("schema") != 1 or parse_time(captured_at) is None or not valid_workflow(workflow):
66
- print(f"[workflow_resume] invalid checkpoint schema in {file}", file=sys.stderr)
67
- return None
68
- return checkpoint
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
- start = Path(data.get("cwd") or Path.cwd()).resolve()
92
- root = find_root(start)
93
- if root is not None:
94
- try:
95
- breadcrumb = read_breadcrumb(root)
96
- except (sqlite3.Error, OSError) as exc:
97
- print(f"[workflow_resume] cannot inspect Context Spine: {exc}", file=sys.stderr)
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
- event = data.get("hook_event_name", "SessionStart")
102
- if event == "SessionStart":
103
- print(json.dumps({"hookSpecificOutput": {
104
- "hookEventName": "SessionStart",
105
- "additionalContext": text,
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
- print(json.dumps({}))
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, { change, summary, baselineUpdates, noBaselineChangeReason }) {
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, now = new Date(),
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
  };