ultra-builder-pro-cli 0.8.1 → 0.10.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 (61) hide show
  1. package/CHANGELOG.md +79 -1
  2. package/README.md +45 -26
  3. package/adapters/_shared/codex-assets.cjs +38 -0
  4. package/adapters/_shared/handbook.cjs +10 -1
  5. package/adapters/_shared/path-rewrite.cjs +3 -0
  6. package/adapters/_shared/runtime-assets.cjs +3 -1
  7. package/adapters/_shared/tests/handbook.test.cjs +9 -1
  8. package/adapters/_shared/tests/path-rewrite.test.cjs +3 -1
  9. package/adapters/_shared/tests/runtime-assets.test.cjs +5 -1
  10. package/adapters/kimi.js +642 -0
  11. package/adapters/opencode.js +46 -17
  12. package/adapters/tests/claude.test.cjs +4 -1
  13. package/adapters/tests/codex.test.cjs +4 -3
  14. package/adapters/tests/kimi-hook.test.cjs +122 -0
  15. package/adapters/tests/kimi.test.cjs +259 -0
  16. package/adapters/tests/opencode.test.cjs +46 -3
  17. package/bin/handbook.js +2 -2
  18. package/bin/install.js +9 -4
  19. package/bin/orchestrator.js +1 -1
  20. package/docs/AGENT-CONTEXT.md +39 -15
  21. package/docs/ARCHITECTURE.md +35 -9
  22. package/docs/PLAN.zh-CN.md +5 -4
  23. package/docs/ROADMAP.md +8 -7
  24. package/docs/RUNTIME-COMPAT-MATRIX.md +110 -64
  25. package/docs/USER-HANDBOOK-CONTRACT.md +13 -3
  26. package/hooks/README.md +6 -4
  27. package/hooks/active_task_context.py +14 -0
  28. package/hooks/adapters/kimi.py +162 -0
  29. package/hooks/context_spine.py +218 -0
  30. package/hooks/health_check.py +1 -1
  31. package/hooks/workflow_context.py +16 -123
  32. package/hooks/workflow_resume.py +21 -0
  33. package/mcp-server/lib/change-workflow.cjs +89 -11
  34. package/mcp-server/lib/change-workflow.test.cjs +202 -7
  35. package/mcp-server/lib/context-spine.cjs +331 -0
  36. package/mcp-server/lib/context-spine.test.cjs +73 -0
  37. package/mcp-server/lib/spec-learning.cjs +191 -0
  38. package/mcp-server/lib/spec-learning.test.cjs +62 -0
  39. package/mcp-server/lib/state-db.cjs +149 -4
  40. package/mcp-server/lib/state-db.test.cjs +61 -0
  41. package/mcp-server/server.cjs +31 -1
  42. package/mcp-server/tests/db-ops.test.cjs +3 -3
  43. package/mcp-server/tests/init-project.test.cjs +2 -2
  44. package/mcp-server/tests/server-contract.test.cjs +74 -0
  45. package/mcp-server/tests/state-db.test.cjs +20 -3
  46. package/package.json +4 -2
  47. package/skills/learn/SKILL.md +1 -0
  48. package/skills/ultra-change/SKILL.md +91 -118
  49. package/skills/ultra-deliver/SKILL.md +93 -221
  50. package/skills/ultra-dev/SKILL.md +81 -310
  51. package/skills/ultra-doctor/SKILL.md +1 -0
  52. package/skills/ultra-init/SKILL.md +1 -0
  53. package/skills/ultra-plan/SKILL.md +72 -292
  54. package/skills/ultra-review/SKILL.md +79 -407
  55. package/skills/ultra-status/SKILL.md +55 -168
  56. package/skills/ultra-test/SKILL.md +64 -183
  57. package/skills/ultra-think/SKILL.md +1 -0
  58. package/spec/cli-protocol.md +3 -0
  59. package/spec/mcp-tools.yaml +136 -7
  60. package/spec/schemas/skill-manifest.schema.json +2 -2
  61. package/spec/schemas/state-db.sql +35 -2
@@ -7,18 +7,26 @@ only one small managed section describing its runtime boundary.
7
7
  ## Source and render targets
8
8
 
9
9
  `adapters/_shared/handbook.cjs` is the canonical source. It renders the common
10
- authority, memory, hook, agent, and package boundaries with host-native command
11
- syntax:
10
+ authority, Context Spine, specification-learning, memory, hook, agent, and
11
+ package boundaries with host-native command syntax:
12
12
 
13
13
  | Runtime | Default target | Main invocation form |
14
14
  |---|---|---|
15
15
  | Claude Code | `~/.claude/CLAUDE.md` | `/ultra-plan` |
16
16
  | Codex | `~/.codex/AGENTS.md` | `$ultra-builder-pro:ultra-plan` |
17
17
  | OpenCode | `~/.config/opencode/AGENTS.md` | `/ultra-plan` |
18
+ | Kimi Code | `~/.kimi-code/AGENTS.md` | `/ultra-builder-pro:ultra-plan` |
18
19
 
19
- The renderer never copies Claude Code-only APIs into Codex or OpenCode. Each
20
+ The renderer never copies Claude Code-only APIs into Codex, OpenCode, or Kimi. Each
20
21
  host receives only its native invocation and collaboration names.
21
22
 
23
+ The managed block deliberately stays short. It says that `.ultra/state.db` is
24
+ authoritative; `change.context` compiles role/gate readiness, bounded references,
25
+ fresh-context budget, public seam, and exact verification; hooks inject only the
26
+ DB-derived `change.breadcrumb`; and unresolved specification learning or either
27
+ failed review axis blocks convergence. Detailed procedures remain in the plugin
28
+ skills so user handbooks do not accumulate duplicated prompt sediment.
29
+
22
30
  ## Managed-block contract
23
31
 
24
32
  The owned content is bounded by:
@@ -42,6 +50,7 @@ Preview the full merged result before applying:
42
50
  ubp-handbook preview --runtime claude
43
51
  ubp-handbook preview --runtime codex
44
52
  ubp-handbook preview --runtime opencode
53
+ ubp-handbook preview --runtime kimi
45
54
  ```
46
55
 
47
56
  Apply when the preview is correct:
@@ -50,6 +59,7 @@ Apply when the preview is correct:
50
59
  ubp-handbook apply --runtime claude
51
60
  ubp-handbook apply --runtime codex
52
61
  ubp-handbook apply --runtime opencode
62
+ ubp-handbook apply --runtime kimi
53
63
  ```
54
64
 
55
65
  An existing target is copied to
package/hooks/README.md CHANGED
@@ -7,10 +7,11 @@ bounded to the relevant Ultra projection or a non-terminal workflow.
7
7
  | Lifecycle | Hook | Purpose |
8
8
  |---|---|---|
9
9
  | Session start | `health_check.py` | Read-only integrity, incident, projection, session, and change-artifact checks |
10
- | Session start | `workflow_context.py` | Inject baseline/active workflow/change context and filtered provider metadata references |
11
- | Before edit | `active_task_context.py` | Protect `tasks.json` projection and restate an active task boundary |
10
+ | Shared helper | `context_spine.py` | Read the latest role/gate/readiness snapshot from state.db and derive one compact breadcrumb |
11
+ | Session start | `workflow_context.py` | Inject the DB-derived breadcrumb without intent or provider payloads |
12
+ | Before edit | `active_task_context.py` | Protect `tasks.json` and restate the same DB-derived task breadcrumb |
12
13
  | Before compact | `workflow_checkpoint.py` | Validate and atomically save a minimal workflow checkpoint |
13
- | After compact/resume | `workflow_resume.py` | Select the newest valid live/checkpoint state, restore it atomically when needed, and re-inject the boundary |
14
+ | After compact/resume | `workflow_resume.py` | Prefer the DB breadcrumb; restore the minimal file checkpoint only when no active change owns recovery |
14
15
  | Stop | `pre_stop_check.py` | Block once when the Ultra workflow is incomplete |
15
16
  | Subagent lifecycle | `subagent_tracker.py` | Append lifecycle evidence under `.ultra/runtime/` |
16
17
 
@@ -18,7 +19,8 @@ The plugin deliberately does not capture prompts, tool observations, transcripts
18
19
  cross-session memory or code-graph content. Generic command blocking and post-edit policy are
19
20
  user/repository governance. Persistent Memory/graph content belongs to separately installed providers.
20
21
 
21
- The checkpoint is a recovery artifact, not a second authority. A valid newer
22
+ `context_spine.py` is an imported library, not an eighth lifecycle registration.
23
+ Registering it separately would duplicate hook execution. The checkpoint is a recovery artifact, not a second authority. A valid newer
22
24
  live workflow wins; a newer valid checkpoint may restore a missing or older
23
25
  live file; corrupt, non-object, or terminal checkpoints are ignored.
24
26
 
@@ -2,9 +2,12 @@
2
2
  """Protect Ultra projections and restate an active task boundary before edits."""
3
3
 
4
4
  import json
5
+ import sqlite3
5
6
  import sys
6
7
  from pathlib import Path
7
8
 
9
+ from context_spine import read_breadcrumb, render_breadcrumb
10
+
8
11
  TERMINAL = {"committed", "completed", "done", "cancelled"}
9
12
  TASKS_PROJECTION = Path(".ultra/tasks/tasks.json")
10
13
 
@@ -53,6 +56,17 @@ def main() -> None:
53
56
  ),
54
57
  }}))
55
58
  return
59
+ try:
60
+ breadcrumb = read_breadcrumb(root)
61
+ except (sqlite3.Error, OSError) as exc:
62
+ print(f"[active_task_context] cannot inspect Context Spine: {exc}", file=sys.stderr)
63
+ breadcrumb = None
64
+ if breadcrumb and breadcrumb.get("task_id"):
65
+ print(json.dumps({"hookSpecificOutput": {
66
+ "hookEventName": "PreToolUse",
67
+ "additionalContext": render_breadcrumb(root, breadcrumb),
68
+ }}))
69
+ return
56
70
  if not state_file.is_file():
57
71
  break
58
72
  try:
@@ -0,0 +1,162 @@
1
+ #!/usr/bin/env python3
2
+ """Kimi Code wire adapter for Ultra Builder Pro lifecycle hooks.
3
+
4
+ Kimi 0.26 uses snake_case JSON input compatible with the workflow hooks, but
5
+ its output contract accepts only ``message`` plus an optional structured deny.
6
+ This adapter preserves real projection/stop blocks, converts foreign context
7
+ fields into Kimi messages, and fails open with diagnostic evidence.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import copy
13
+ import json
14
+ import os
15
+ import subprocess
16
+ import sys
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+
21
+ HOOK_ROOT = Path(__file__).resolve().parent.parent
22
+ ALLOWED_FEATURES = {
23
+ "active_task_context.py",
24
+ "health_check.py",
25
+ "pre_stop_check.py",
26
+ "subagent_tracker.py",
27
+ "workflow_checkpoint.py",
28
+ "workflow_context.py",
29
+ "workflow_resume.py",
30
+ }
31
+
32
+
33
+ def normalize_input(payload: dict[str, Any]) -> dict[str, Any]:
34
+ """Add only the legacy aliases required by the shared workflow hooks."""
35
+ normalized = copy.deepcopy(payload)
36
+ agent_name = normalized.get("agent_name")
37
+ if isinstance(agent_name, str) and agent_name:
38
+ normalized.setdefault("agent_id", agent_name)
39
+ normalized.setdefault("agent_type", agent_name)
40
+ if normalized.get("tool_response") is not None and normalized.get("tool_output") is None:
41
+ normalized["tool_output"] = normalized["tool_response"]
42
+ return normalized
43
+
44
+
45
+ def _text(value: Any) -> str:
46
+ return str(value).strip() if value is not None else ""
47
+
48
+
49
+ def adapt_output(output: dict[str, Any], event: str) -> dict[str, Any]:
50
+ """Convert one legacy hook result to Kimi's exact structured schema."""
51
+ if not isinstance(output, dict):
52
+ return {}
53
+ specific = output.get("hookSpecificOutput")
54
+ specific = specific if isinstance(specific, dict) else {}
55
+
56
+ decision = specific.get("permissionDecision")
57
+ blocked = output.get("decision") == "block" or decision == "deny"
58
+ reason = (
59
+ _text(specific.get("permissionDecisionReason"))
60
+ or _text(output.get("reason"))
61
+ or f"Ultra {event} gate blocked continuation"
62
+ )
63
+ if blocked:
64
+ return {
65
+ "hookSpecificOutput": {
66
+ "message": reason,
67
+ "permissionDecision": "deny",
68
+ "permissionDecisionReason": reason,
69
+ }
70
+ }
71
+
72
+ context = (
73
+ _text(specific.get("additionalContext"))
74
+ or _text(output.get("additionalContext"))
75
+ or _text(output.get("systemMessage"))
76
+ or _text(specific.get("message"))
77
+ or _text(output.get("message"))
78
+ )
79
+ advisory = _text(specific.get("permissionDecisionReason")) if decision == "ask" else ""
80
+ message = "\n\n".join(part for part in [advisory, context] if part)
81
+ return {"message": message} if message else {}
82
+
83
+
84
+ def _parse_output(stdout: str) -> dict[str, Any]:
85
+ text = stdout.strip()
86
+ if not text:
87
+ return {}
88
+ try:
89
+ value = json.loads(text)
90
+ except json.JSONDecodeError:
91
+ return {"message": f"Ultra hook returned non-JSON output: {text[:500]}"}
92
+ return value if isinstance(value, dict) else {}
93
+
94
+
95
+ def run_feature(feature: str, payload: dict[str, Any], feature_args: list[str]) -> dict[str, Any]:
96
+ if feature not in ALLOWED_FEATURES or Path(feature).name != feature:
97
+ return {"message": f"Ultra hook adapter refused unknown feature: {feature}"}
98
+ script = HOOK_ROOT / feature
99
+ if not script.is_file():
100
+ return {"message": f"Ultra hook feature is missing: {feature}"}
101
+
102
+ event = str(payload.get("hook_event_name") or "")
103
+ normalized = normalize_input(payload)
104
+ if feature == "workflow_resume.py" and event == "PostCompact":
105
+ normalized["hook_event_name"] = "SessionStart"
106
+ normalized["source"] = "compact"
107
+
108
+ env = os.environ.copy()
109
+ env["UBP_HOOK_RUNTIME"] = "kimi"
110
+ plugin_data = env.get("PLUGIN_DATA") or env.get("KIMI_PLUGIN_ROOT")
111
+ if plugin_data:
112
+ env["UBP_HOOK_DATA"] = plugin_data
113
+
114
+ cwd = normalized.get("cwd")
115
+ run_cwd = str(cwd) if isinstance(cwd, str) and Path(cwd).is_dir() else os.getcwd()
116
+ proc = subprocess.run(
117
+ [sys.executable, str(script), *feature_args],
118
+ input=json.dumps(normalized),
119
+ text=True,
120
+ capture_output=True,
121
+ cwd=run_cwd,
122
+ env=env,
123
+ timeout=30,
124
+ check=False,
125
+ )
126
+ if proc.stderr:
127
+ print(proc.stderr.rstrip(), file=sys.stderr)
128
+ if proc.returncode != 0:
129
+ return {
130
+ "message": (
131
+ f"Ultra hook {feature} failed with exit {proc.returncode}: "
132
+ f"{proc.stderr.strip()[:500]}"
133
+ )
134
+ }
135
+ return adapt_output(_parse_output(proc.stdout), event)
136
+
137
+
138
+ def main() -> int:
139
+ if len(sys.argv) < 2:
140
+ print(json.dumps({"message": "Ultra Kimi hook adapter requires a feature name"}))
141
+ return 0
142
+ feature = sys.argv[1]
143
+ feature_args = sys.argv[2:]
144
+ try:
145
+ payload = json.loads(sys.stdin.read() or "{}")
146
+ if not isinstance(payload, dict):
147
+ payload = {}
148
+ except json.JSONDecodeError as exc:
149
+ print(json.dumps({"message": f"Ultra hook input parse error: {exc}"}))
150
+ return 0
151
+ try:
152
+ output = run_feature(feature, payload, feature_args)
153
+ except subprocess.TimeoutExpired:
154
+ output = {"message": f"Ultra hook {feature} timed out"}
155
+ except Exception as exc: # Kimi hooks are fail-open at this adapter boundary.
156
+ output = {"message": f"Ultra hook adapter error in {feature}: {exc}"}
157
+ print(json.dumps(output, ensure_ascii=False))
158
+ return 0
159
+
160
+
161
+ if __name__ == "__main__":
162
+ raise SystemExit(main())
@@ -0,0 +1,218 @@
1
+ #!/usr/bin/env python3
2
+ """Read and render the compact DB-derived Ultra Context Spine breadcrumb."""
3
+
4
+ import json
5
+ import sqlite3
6
+ import subprocess
7
+ from pathlib import Path
8
+
9
+
10
+ ACTIVE_CHANGE_STATUSES = ("active", "blocked", "ready")
11
+ TERMINAL_TASK_STATUSES = ("completed", "expanded")
12
+ CONTEXT_COLUMNS = {
13
+ "role", "gate", "next_action", "readiness", "blockers_json",
14
+ "context_json", "manifest_path", "manifest_hash", "git_head", "task_id",
15
+ }
16
+
17
+
18
+ def find_root(start: Path):
19
+ for root in (start, *start.parents):
20
+ ultra = root / ".ultra"
21
+ if (
22
+ (ultra / "state.db").is_file()
23
+ or (ultra / "workflow-state.json").is_file()
24
+ or (ultra / "changes" / "active").is_dir()
25
+ ):
26
+ return root
27
+ return None
28
+
29
+
30
+ def safe_json(raw, fallback):
31
+ if not isinstance(raw, str) or not raw.strip():
32
+ return fallback
33
+ try:
34
+ value = json.loads(raw)
35
+ return value
36
+ except json.JSONDecodeError:
37
+ return fallback
38
+
39
+
40
+ def git_head(root: Path):
41
+ result = subprocess.run(
42
+ ["git", "rev-parse", "HEAD"],
43
+ cwd=root,
44
+ text=True,
45
+ capture_output=True,
46
+ check=False,
47
+ timeout=2,
48
+ )
49
+ return result.stdout.strip() if result.returncode == 0 else None
50
+
51
+
52
+ def _next_action(change, task, tasks, role, readiness, stored, state_changed):
53
+ if readiness != "ready":
54
+ return "Resolve the context readiness blockers, then recompile change.context."
55
+ if not state_changed and isinstance(stored, str) and stored.strip():
56
+ return stored.strip()
57
+ if change["status"] == "ready":
58
+ return f"Run ultra-deliver for change {change['id']}."
59
+ if change["status"] == "blocked":
60
+ return f"Resolve the blockers for change {change['id']}, then recompile context."
61
+ if task and task["status"] == "pending":
62
+ return f"Start task {task['id']} through ultra-dev."
63
+ if task and task["status"] == "in_progress":
64
+ if role == "check":
65
+ return f"Run the exact verification command for task {task['id']}."
66
+ if role == "review":
67
+ return (
68
+ "Complete independent spec-fidelity and engineering-standards "
69
+ f"review for task {task['id']}."
70
+ )
71
+ return f"Continue the approved vertical slice for task {task['id']}."
72
+ if tasks and all(row["status"] in TERMINAL_TASK_STATUSES for row in tasks):
73
+ return f"Run ultra-test for change {change['id']}."
74
+ pending = next((row for row in tasks if row["status"] == "pending"), None)
75
+ if pending:
76
+ return f"Compile implement context for task {pending['id']}."
77
+ return f"Create the first fresh-context tracer-bullet task for change {change['id']}."
78
+
79
+
80
+ def _route(change, task, tasks, readiness):
81
+ if readiness != "ready" or change["status"] == "blocked":
82
+ return "ultra-change"
83
+ if change["status"] == "ready":
84
+ return "ultra-deliver"
85
+ if task and task["status"] in ("pending", "in_progress"):
86
+ return "ultra-dev"
87
+ if tasks and all(row["status"] in TERMINAL_TASK_STATUSES for row in tasks):
88
+ return "ultra-test"
89
+ return "ultra-change"
90
+
91
+
92
+ def read_breadcrumb(root: Path):
93
+ db_path = root / ".ultra" / "state.db"
94
+ if not db_path.is_file():
95
+ return None
96
+ with sqlite3.connect(f"file:{db_path}?mode=ro", uri=True, timeout=1) as conn:
97
+ conn.row_factory = sqlite3.Row
98
+ tables = {
99
+ row[0]
100
+ for row in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
101
+ }
102
+ if not {"changes", "tasks", "context_snapshots"}.issubset(tables):
103
+ return None
104
+ change = conn.execute(
105
+ """SELECT id, status FROM changes
106
+ WHERE status IN ('active', 'blocked', 'ready')
107
+ ORDER BY updated_at DESC LIMIT 1"""
108
+ ).fetchone()
109
+ if change is None:
110
+ return None
111
+ tasks = list(conn.execute(
112
+ "SELECT id, status, session_id FROM tasks WHERE change_id = ? ORDER BY created_at ASC",
113
+ (change["id"],),
114
+ ))
115
+ columns = {
116
+ row[1] for row in conn.execute("PRAGMA table_info(context_snapshots)")
117
+ }
118
+ snapshot = None
119
+ if CONTEXT_COLUMNS.issubset(columns):
120
+ snapshot = conn.execute(
121
+ """SELECT task_id, git_head, manifest_path, manifest_hash, role, gate,
122
+ next_action, readiness, blockers_json, context_json
123
+ FROM context_snapshots WHERE change_id = ?
124
+ ORDER BY created_at DESC, rowid DESC LIMIT 1""",
125
+ (change["id"],),
126
+ ).fetchone()
127
+
128
+ task_id = snapshot["task_id"] if snapshot else None
129
+ if not task_id:
130
+ active = next((row for row in tasks if row["status"] == "in_progress"), None)
131
+ pending = next((row for row in tasks if row["status"] == "pending"), None)
132
+ task_id = (active or pending)["id"] if (active or pending) else None
133
+ task = next((row for row in tasks if row["id"] == task_id), None)
134
+ context = safe_json(snapshot["context_json"], {}) if snapshot else {}
135
+ role = snapshot["role"] if snapshot else "plan"
136
+ gate = snapshot["gate"] if snapshot else "alignment"
137
+ current_head = git_head(root)
138
+ head_stale = bool(
139
+ snapshot and snapshot["git_head"] and current_head
140
+ and snapshot["git_head"] != current_head
141
+ )
142
+ snapshot_missing = snapshot is None
143
+ legacy_context = bool(
144
+ snapshot and (
145
+ not isinstance(context.get("resume"), dict)
146
+ or not isinstance(context.get("context"), dict)
147
+ or not isinstance(context.get("readiness"), dict)
148
+ )
149
+ )
150
+ stored_status = context.get("resume", {}).get("task_status")
151
+ current_status = task["status"] if task else None
152
+ state_changed = stored_status is not None and stored_status != current_status
153
+ stored_action = snapshot["next_action"] if snapshot else None
154
+ blockers = []
155
+ if snapshot_missing:
156
+ blockers.append("CONTEXT_NOT_COMPILED")
157
+ else:
158
+ if legacy_context:
159
+ blockers.append("CONTEXT_SNAPSHOT_UPGRADE_REQUIRED")
160
+ if head_stale:
161
+ blockers.append("CONTEXT_HEAD_STALE")
162
+ if state_changed:
163
+ blockers.append("CONTEXT_TASK_STATE_STALE")
164
+ for blocker in safe_json(snapshot["blockers_json"], []):
165
+ if blocker not in blockers:
166
+ blockers.append(blocker)
167
+ readiness = "blocked" if blockers else snapshot["readiness"]
168
+ if snapshot_missing:
169
+ next_action = f"Compile change.context for change {change['id']} before continuing."
170
+ elif legacy_context:
171
+ next_action = "Recompile change.context to upgrade this legacy context snapshot."
172
+ elif head_stale:
173
+ next_action = "Git HEAD changed after context compilation; recompile change.context."
174
+ elif state_changed:
175
+ next_action = "Task state changed after context compilation; recompile change.context."
176
+ else:
177
+ next_action = _next_action(change, task, tasks, role, readiness, stored_action, False)
178
+ return {
179
+ "change_id": change["id"],
180
+ "change_status": change["status"],
181
+ "task_id": task["id"] if task else None,
182
+ "task_status": task["status"] if task else None,
183
+ "session_id": task["session_id"] if task else None,
184
+ "role": role,
185
+ "gate": gate,
186
+ "readiness": readiness,
187
+ "blockers": blockers,
188
+ "next_action": next_action,
189
+ "recommended_workflow": _route(change, task, tasks, readiness),
190
+ "context_manifest_path": snapshot["manifest_path"] if snapshot else None,
191
+ "context_manifest_hash": snapshot["manifest_hash"] if snapshot else None,
192
+ "git_head": current_head,
193
+ }
194
+
195
+
196
+ def render_breadcrumb(root: Path, breadcrumb: dict) -> str:
197
+ task = breadcrumb.get("task_id") or "none"
198
+ task_status = breadcrumb.get("task_status") or "none"
199
+ lines = [
200
+ "[Ultra context spine]",
201
+ f"Project: {root}",
202
+ f"Change: {breadcrumb['change_id']} ({breadcrumb.get('change_status', 'active')})",
203
+ f"Task: {task} ({task_status})",
204
+ f"Role: {breadcrumb.get('role', 'plan')}",
205
+ f"Gate: {breadcrumb.get('gate', 'alignment')}",
206
+ f"Readiness: {breadcrumb.get('readiness', 'ready')}",
207
+ ]
208
+ if breadcrumb.get("blockers"):
209
+ lines.append("Blockers: " + ", ".join(breadcrumb["blockers"]))
210
+ lines.extend([
211
+ f"Next: {breadcrumb.get('next_action', 'Inspect Ultra status.')}",
212
+ f"Route: {breadcrumb.get('recommended_workflow', 'ultra-change')}",
213
+ ])
214
+ if breadcrumb.get("context_manifest_path"):
215
+ digest = breadcrumb.get("context_manifest_hash") or "unknown"
216
+ lines.append(f"Context: {breadcrumb['context_manifest_path']} sha256={digest}")
217
+ lines.append("Authority: .ultra/state.db; JSON/Markdown remain projections or evidence artifacts.")
218
+ return "\n".join(lines)
@@ -10,7 +10,7 @@ from pathlib import Path
10
10
  REQUIRED_TABLES = {
11
11
  "tasks", "events", "sessions", "schema_version", "migration_history",
12
12
  "telemetry", "specs_refs", "circuit_breaker", "changes", "artifacts",
13
- "context_snapshots", "trace_links", "incidents", "projection_jobs",
13
+ "context_snapshots", "spec_learning_candidates", "trace_links", "incidents", "projection_jobs",
14
14
  "event_consumers",
15
15
  }
16
16
 
@@ -1,16 +1,12 @@
1
1
  #!/usr/bin/env python3
2
- """Inject current Ultra workflow/change context without persistent memory."""
2
+ """Inject one compact Ultra state position without persistent memory."""
3
3
 
4
4
  import json
5
5
  import sqlite3
6
6
  import sys
7
7
  from pathlib import Path
8
8
 
9
-
10
- TERMINAL_WORKFLOW = {"committed", "completed", "done", "cancelled"}
11
- PROVIDER_FIELDS = {
12
- "provider", "project", "reference", "revision", "indexed_head", "status",
13
- }
9
+ from context_spine import find_root, read_breadcrumb, render_breadcrumb
14
10
 
15
11
 
16
12
  def payload() -> dict:
@@ -22,132 +18,29 @@ def payload() -> dict:
22
18
  return {}
23
19
 
24
20
 
25
- def find_root(start: Path):
26
- for root in (start, *start.parents):
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)
45
- return None
46
- if isinstance(state, dict) and state.get("status") not in TERMINAL_WORKFLOW:
47
- return state
48
- return None
49
-
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
-
137
21
  def main() -> None:
138
22
  data = payload()
139
23
  root = find_root(Path(data.get("cwd") or Path.cwd()).resolve())
140
24
  if root is None:
141
25
  print(json.dumps({}))
142
26
  return
143
- workflow = read_workflow(root)
144
- changes = read_changes(root)
145
- context = context_text(root, workflow, changes)
27
+ try:
28
+ breadcrumb = read_breadcrumb(root)
29
+ except (sqlite3.Error, OSError) as exc:
30
+ print(f"[workflow_context] cannot inspect Context Spine: {exc}", file=sys.stderr)
31
+ breadcrumb = None
32
+ if breadcrumb:
33
+ context = render_breadcrumb(root, breadcrumb)
34
+ 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
+ ])
146
41
  print(json.dumps({"hookSpecificOutput": {
147
42
  "hookEventName": "SessionStart",
148
43
  "additionalContext": context,
149
44
  }}))
150
-
151
-
152
45
  if __name__ == "__main__":
153
46
  main()