ultra-builder-pro-cli 0.13.0 → 0.14.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 (45) hide show
  1. package/CHANGELOG.md +43 -1
  2. package/README.md +7 -8
  3. package/adapters/_shared/codex-assets.cjs +23 -163
  4. package/adapters/_shared/runtime-assets.cjs +1 -0
  5. package/adapters/_shared/tests/runtime-assets.test.cjs +18 -0
  6. package/adapters/opencode.js +30 -120
  7. package/adapters/tests/claude.test.cjs +3 -2
  8. package/adapters/tests/codex-hook.test.cjs +34 -7
  9. package/adapters/tests/codex.test.cjs +13 -6
  10. package/adapters/tests/kimi-hook.test.cjs +34 -7
  11. package/adapters/tests/opencode.test.cjs +70 -84
  12. package/docs/AGENT-CONTEXT.md +6 -3
  13. package/docs/ARCHITECTURE.md +8 -1
  14. package/docs/ROADMAP.md +1 -1
  15. package/docs/RUNTIME-COMPAT-MATRIX.md +4 -4
  16. package/hooks/active_task_context.py +22 -50
  17. package/hooks/context_spine.py +61 -332
  18. package/hooks/health_check.py +19 -17
  19. package/hooks/pre_stop_check.py +28 -33
  20. package/hooks/subagent_tracker.py +13 -19
  21. package/hooks/workflow_checkpoint.py +39 -47
  22. package/hooks/workflow_context.py +4 -9
  23. package/hooks/workflow_resume.py +48 -128
  24. package/mcp-server/breadcrumb.cjs +22 -0
  25. package/mcp-server/lib/prd-parser.cjs +14 -41
  26. package/mcp-server/lib/prd-parser.test.cjs +49 -138
  27. package/mcp-server/lib/project-breadcrumb.cjs +139 -0
  28. package/mcp-server/lib/project-breadcrumb.test.cjs +112 -0
  29. package/mcp-server/lib/task-expander.cjs +9 -38
  30. package/mcp-server/lib/task-expander.test.cjs +54 -209
  31. package/mcp-server/server.cjs +21 -35
  32. package/mcp-server/tests/server-contract.test.cjs +41 -45
  33. package/package.json +2 -4
  34. package/skills/cc-collab/SKILL.md +2 -2
  35. package/skills/codex-collab/SKILL.md +2 -3
  36. package/skills/ultra-plan/SKILL.md +15 -5
  37. package/skills/ultra-verify/SKILL.md +21 -30
  38. package/skills/ultra-verify/scripts/verify_wait.py +54 -54
  39. package/spec/mcp-tools.yaml +38 -10
  40. package/mcp-server/lib/llm-client.cjs +0 -157
  41. package/mcp-server/lib/llm-client.test.cjs +0 -156
  42. package/orchestrator/planner/expand-prompt.cjs +0 -62
  43. package/orchestrator/planner/expand-prompt.test.cjs +0 -52
  44. package/orchestrator/planner/prd-prompt.cjs +0 -44
  45. package/orchestrator/planner/prd-prompt.test.cjs +0 -30
@@ -1,355 +1,84 @@
1
1
  #!/usr/bin/env python3
2
- """Read and render the compact DB-derived Ultra Context Spine breadcrumb."""
2
+ """Thin bridge to the single bundled JavaScript Context Spine reader."""
3
3
 
4
4
  import json
5
- import hashlib
6
- import sqlite3
5
+ import os
6
+ import shutil
7
7
  import subprocess
8
8
  from pathlib import Path
9
9
 
10
10
 
11
- ACTIVE_CHANGE_STATUSES = ("active", "blocked", "ready")
12
- TERMINAL_TASK_STATUSES = ("completed", "expanded")
13
- CONTEXT_COLUMNS = {
14
- "role", "gate", "next_action", "readiness", "blockers_json",
15
- "context_json", "manifest_path", "manifest_hash", "git_head", "task_id",
16
- }
17
- ADVISORY_CONTEXT_CODES = {
18
- "CONTEXT_FILE_BUDGET_EXCEEDED",
19
- "CONTEXT_TOKEN_BUDGET_EXCEEDED",
20
- "EXECUTION_CONTEXT_BUDGET_EXCEEDED",
21
- "EXECUTION_CONTEXT_BUDGET_ADVISORY",
22
- }
11
+ class ContextSpineError(RuntimeError):
12
+ """The canonical breadcrumb helper could not be executed or decoded."""
13
+
14
+
15
+ class Breadcrumb(dict):
16
+ """Dictionary breadcrumb carrying its canonical rendered injection text."""
17
+
18
+ def __init__(self, value: dict, rendered: str):
19
+ super().__init__(value)
20
+ self.rendered = rendered
23
21
 
24
22
 
25
23
  def find_root(start: Path):
26
24
  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
- ):
25
+ if (root / ".ultra").is_dir():
33
26
  return root
34
27
  return None
35
28
 
36
29
 
37
- def safe_json(raw, fallback):
38
- if not isinstance(raw, str) or not raw.strip():
39
- return fallback
40
- try:
41
- value = json.loads(raw)
42
- return value
43
- except json.JSONDecodeError:
44
- return fallback
45
-
46
-
47
- def git_head(root: Path):
48
- result = subprocess.run(
49
- ["git", "rev-parse", "HEAD"],
50
- cwd=root,
51
- text=True,
52
- capture_output=True,
53
- check=False,
54
- timeout=2,
55
- )
56
- return result.stdout.strip() if result.returncode == 0 else None
57
-
58
-
59
- def git_branch(root: Path):
60
- result = subprocess.run(
61
- ["git", "branch", "--show-current"],
62
- cwd=root,
63
- text=True,
64
- capture_output=True,
65
- check=False,
66
- timeout=2,
67
- )
68
- return (result.stdout.strip() or None) if result.returncode == 0 else None
69
-
30
+ def _helper_path() -> Path:
31
+ hook_root = Path(__file__).resolve().parent
32
+ installed = hook_root.parent / "runtime" / "breadcrumb.cjs"
33
+ if installed.is_file():
34
+ return installed
35
+ source = hook_root.parent / "mcp-server" / "breadcrumb.cjs"
36
+ if source.is_file():
37
+ return source
38
+ raise ContextSpineError("bundled breadcrumb helper is missing")
70
39
 
71
- def baseline_health(conn, root: Path):
72
- columns = {row[1] for row in conn.execute("PRAGMA table_info(baselines)")}
73
- optional = [
74
- name for name in (
75
- "repository_branch", "worktree_state", "worktree_accepted", "gaps_json"
76
- ) if name in columns
77
- ]
78
- row = conn.execute(
79
- f"""SELECT id, mode, status, repository_revision, spec_refs_json{''.join(f', {name}' for name in optional)}
80
- FROM baselines WHERE status != 'superseded'
81
- ORDER BY updated_at DESC, rowid DESC LIMIT 1"""
82
- ).fetchone()
83
- if row is None:
84
- return {"status": "fail", "blockers": ["BASELINE_MISSING"], "warnings": [], "baseline": None}
85
- baseline = dict(row)
86
- blockers = []
87
- warnings = []
88
- if baseline["status"] != "ready":
89
- blockers.append(f"BASELINE_NOT_READY:{baseline['status']}")
90
- elif baseline["mode"] == "migrated":
91
- blockers.append("BASELINE_MIGRATION_REVIEW_REQUIRED")
92
- else:
93
- if "gaps_json" not in columns:
94
- blockers.append("BASELINE_SCHEMA_MIGRATION_REQUIRED")
95
- for gap in safe_json(baseline.get("gaps_json"), []):
96
- if (
97
- isinstance(gap, dict)
98
- and gap.get("status") == "open"
99
- and gap.get("blocking") is True
100
- ):
101
- blockers.append(f"BASELINE_GAP_BLOCKING:{gap.get('id', 'unknown')}")
102
- if baseline.get("worktree_state") == "dirty" and not baseline.get("worktree_accepted"):
103
- blockers.append("BASELINE_DIRTY_WORKTREE_NOT_ACCEPTED")
104
- branch = git_branch(root)
105
- if baseline.get("repository_branch") and branch and baseline["repository_branch"] != branch:
106
- blockers.append("BASELINE_BRANCH_STALE")
107
- for spec in safe_json(baseline.get("spec_refs_json"), []):
108
- ref = spec.get("path") if isinstance(spec, dict) else None
109
- expected = spec.get("digest") if isinstance(spec, dict) else None
110
- if not isinstance(ref, str) or not ref or Path(ref).is_absolute():
111
- blockers.append(f"BASELINE_SPEC_INVALID:{ref}")
112
- continue
113
- candidate = (root / ref).resolve()
114
- try:
115
- candidate.relative_to(root.resolve())
116
- except ValueError:
117
- blockers.append(f"BASELINE_SPEC_INVALID:{ref}")
118
- continue
119
- if not candidate.is_file():
120
- blockers.append(f"BASELINE_SPEC_MISSING:{ref}")
121
- continue
122
- digest = hashlib.sha256(candidate.read_bytes()).hexdigest()
123
- if not expected or digest != expected:
124
- blockers.append(f"BASELINE_SPEC_STALE:{ref}")
125
- head = git_head(root)
126
- revision = baseline.get("repository_revision")
127
- if head and revision and not str(revision).startswith("workspace:") and head != revision:
128
- blockers.append("BASELINE_HEAD_STALE")
129
- baseline.pop("spec_refs_json", None)
130
- baseline.pop("gaps_json", None)
131
- return {
132
- "status": "fail" if blockers else "pass",
133
- "blockers": blockers,
134
- "warnings": warnings,
135
- "baseline": baseline,
136
- }
137
40
 
138
-
139
- def _next_action(change, task, tasks, role, readiness, stored, state_changed, blockers=None):
140
- if readiness != "ready":
141
- if any(str(item).startswith("BASELINE_") for item in (blockers or [])):
142
- return "Complete or refresh the Ultra project baseline through ultra-init."
143
- return "Resolve the context readiness blockers, then recompile change.context."
144
- if not state_changed and isinstance(stored, str) and stored.strip():
145
- return stored.strip()
146
- if change["status"] == "ready":
147
- return f"Run ultra-deliver for change {change['id']}."
148
- if change["status"] == "blocked":
149
- return f"Resolve the blockers for change {change['id']}, then recompile context."
150
- if task and task["status"] == "pending":
151
- return f"Start task {task['id']} through ultra-dev."
152
- if task and task["status"] == "in_progress":
153
- if role == "check":
154
- return f"Run the exact verification command for task {task['id']}."
155
- if role == "review":
156
- return (
157
- "Complete independent spec-fidelity and engineering-standards "
158
- f"review for task {task['id']}."
159
- )
160
- return f"Continue the approved vertical slice for task {task['id']}."
161
- if tasks and all(row["status"] in TERMINAL_TASK_STATUSES for row in tasks):
162
- return f"Run ultra-test for change {change['id']}."
163
- pending = next((row for row in tasks if row["status"] == "pending"), None)
164
- if pending:
165
- return f"Compile implement context for task {pending['id']}."
166
- return f"Create the first fresh-context tracer-bullet task for change {change['id']}."
167
-
168
-
169
- def _route(change, task, tasks, readiness, blockers=None):
170
- if readiness != "ready" or change["status"] == "blocked":
171
- return "ultra-change"
172
- if change["status"] == "ready":
173
- return "ultra-deliver"
174
- if task and task["status"] in ("pending", "in_progress"):
175
- return "ultra-dev"
176
- if tasks and all(row["status"] in TERMINAL_TASK_STATUSES for row in tasks):
177
- return "ultra-test"
178
- return "ultra-change"
41
+ def _node_binary() -> str:
42
+ configured = os.environ.get("UBP_NODE")
43
+ if configured:
44
+ return configured
45
+ discovered = shutil.which("node")
46
+ if discovered:
47
+ return discovered
48
+ raise ContextSpineError("node executable is unavailable")
179
49
 
180
50
 
181
51
  def read_breadcrumb(root: Path):
182
- db_path = root / ".ultra" / "state.db"
183
- if not db_path.is_file():
184
- return None
185
- with sqlite3.connect(f"file:{db_path}?mode=ro", uri=True, timeout=1) as conn:
186
- conn.row_factory = sqlite3.Row
187
- tables = {
188
- row[0]
189
- for row in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
190
- }
191
- if not {"baselines", "changes", "tasks", "context_snapshots"}.issubset(tables):
192
- return None
193
- baseline = baseline_health(conn, root)
194
- change = conn.execute(
195
- """SELECT id, status FROM changes
196
- WHERE status IN ('active', 'blocked', 'ready')
197
- ORDER BY updated_at DESC LIMIT 1"""
198
- ).fetchone()
199
- if change is None:
200
- current = baseline["baseline"]
201
- if baseline["status"] == "pass":
202
- next_action = "Start daily work with ultra-change."
203
- route = "ultra-change"
204
- readiness = "ready"
205
- else:
206
- next_action = "Initialize or complete project baseline adoption through ultra-init."
207
- route = "ultra-init"
208
- readiness = "blocked"
209
- return {
210
- "change_id": None,
211
- "task_id": None,
212
- "role": "plan",
213
- "gate": "alignment",
214
- "readiness": readiness,
215
- "blockers": baseline["blockers"],
216
- "warnings": baseline["warnings"],
217
- "next_action": next_action,
218
- "recommended_workflow": route,
219
- "context_manifest_path": None,
220
- "context_manifest_hash": None,
221
- "git_head": git_head(root),
222
- "baseline": current,
223
- }
224
- tasks = list(conn.execute(
225
- "SELECT id, status, session_id FROM tasks WHERE change_id = ? ORDER BY created_at ASC",
226
- (change["id"],),
227
- ))
228
- columns = {
229
- row[1] for row in conn.execute("PRAGMA table_info(context_snapshots)")
230
- }
231
- snapshot = None
232
- if CONTEXT_COLUMNS.issubset(columns):
233
- snapshot = conn.execute(
234
- """SELECT task_id, git_head, manifest_path, manifest_hash, role, gate,
235
- next_action, readiness, blockers_json, context_json
236
- FROM context_snapshots WHERE change_id = ?
237
- ORDER BY created_at DESC, rowid DESC LIMIT 1""",
238
- (change["id"],),
239
- ).fetchone()
240
-
241
- task_id = snapshot["task_id"] if snapshot else None
242
- if not task_id:
243
- active = next((row for row in tasks if row["status"] == "in_progress"), None)
244
- pending = next((row for row in tasks if row["status"] == "pending"), None)
245
- task_id = (active or pending)["id"] if (active or pending) else None
246
- task = next((row for row in tasks if row["id"] == task_id), None)
247
- context = safe_json(snapshot["context_json"], {}) if snapshot else {}
248
- role = snapshot["role"] if snapshot else "plan"
249
- gate = snapshot["gate"] if snapshot else "alignment"
250
- current_head = git_head(root)
251
- head_stale = bool(
252
- snapshot and snapshot["git_head"] and current_head
253
- and snapshot["git_head"] != current_head
254
- )
255
- snapshot_missing = snapshot is None
256
- legacy_context = bool(
257
- snapshot and (
258
- not isinstance(context.get("resume"), dict)
259
- or not isinstance(context.get("context"), dict)
260
- or not isinstance(context.get("readiness"), dict)
261
- or "baseline" not in context
262
- )
263
- )
264
- stored_status = context.get("resume", {}).get("task_status")
265
- current_status = task["status"] if task else None
266
- state_changed = stored_status is not None and stored_status != current_status
267
- stored_action = snapshot["next_action"] if snapshot else None
268
- blockers = []
269
- warnings = list(dict.fromkeys([*baseline["blockers"], *baseline["warnings"]]))
270
- if snapshot_missing:
271
- blockers.append("CONTEXT_NOT_COMPILED")
272
- else:
273
- if legacy_context:
274
- blockers.append("CONTEXT_SNAPSHOT_UPGRADE_REQUIRED")
275
- if head_stale:
276
- blockers.append("CONTEXT_HEAD_STALE")
277
- if state_changed:
278
- blockers.append("CONTEXT_TASK_STATE_STALE")
279
- for blocker in safe_json(snapshot["blockers_json"], []):
280
- if blocker in ADVISORY_CONTEXT_CODES or str(blocker).startswith("BASELINE_"):
281
- if blocker not in warnings:
282
- warnings.append(blocker)
283
- elif blocker not in blockers:
284
- blockers.append(blocker)
285
- for warning in context.get("readiness", {}).get("warnings", []):
286
- if warning not in warnings:
287
- warnings.append(warning)
288
- readiness = "blocked" if blockers else snapshot["readiness"]
289
- if snapshot_missing:
290
- next_action = f"Compile change.context for change {change['id']} before continuing."
291
- elif legacy_context:
292
- next_action = "Recompile change.context to upgrade this legacy context snapshot."
293
- elif head_stale:
294
- next_action = "Git HEAD changed after context compilation; recompile change.context."
295
- elif state_changed:
296
- next_action = "Task state changed after context compilation; recompile change.context."
297
- else:
298
- next_action = _next_action(
299
- change, task, tasks, role, readiness, stored_action, False, blockers
52
+ try:
53
+ result = subprocess.run(
54
+ [_node_binary(), str(_helper_path()), str(root.resolve())],
55
+ text=True,
56
+ capture_output=True,
57
+ check=False,
58
+ timeout=5,
300
59
  )
301
- return {
302
- "change_id": change["id"],
303
- "change_status": change["status"],
304
- "task_id": task["id"] if task else None,
305
- "task_status": task["status"] if task else None,
306
- "session_id": task["session_id"] if task else None,
307
- "role": role,
308
- "gate": gate,
309
- "readiness": readiness,
310
- "blockers": blockers,
311
- "warnings": warnings,
312
- "next_action": next_action,
313
- "recommended_workflow": _route(change, task, tasks, readiness, blockers),
314
- "context_manifest_path": snapshot["manifest_path"] if snapshot else None,
315
- "context_manifest_hash": snapshot["manifest_hash"] if snapshot else None,
316
- "git_head": current_head,
317
- "baseline": baseline["baseline"],
318
- }
60
+ except (OSError, subprocess.TimeoutExpired) as exc:
61
+ raise ContextSpineError(str(exc)) from exc
62
+ if result.returncode != 0:
63
+ detail = result.stderr.strip() or f"exit {result.returncode}"
64
+ raise ContextSpineError(detail)
65
+ try:
66
+ payload = json.loads(result.stdout)
67
+ except json.JSONDecodeError as exc:
68
+ raise ContextSpineError(f"invalid breadcrumb response: {exc}") from exc
69
+ if not isinstance(payload, dict):
70
+ raise ContextSpineError("breadcrumb response must be an object")
71
+ value = payload.get("breadcrumb")
72
+ if value is None:
73
+ return None
74
+ rendered = payload.get("text")
75
+ if not isinstance(value, dict) or not isinstance(rendered, str) or not rendered:
76
+ raise ContextSpineError("breadcrumb response is missing canonical state or text")
77
+ return Breadcrumb(value, rendered)
319
78
 
320
79
 
321
- def render_breadcrumb(root: Path, breadcrumb: dict) -> str:
322
- task = breadcrumb.get("task_id") or "none"
323
- task_status = breadcrumb.get("task_status") or "none"
324
- baseline = breadcrumb.get("baseline") or {}
325
- if breadcrumb.get("change_id"):
326
- lines = [
327
- "[Ultra context spine]",
328
- f"Project: {root}",
329
- f"Baseline: {baseline.get('id', 'unknown')} ({baseline.get('mode', 'unknown')}/{baseline.get('status', 'unknown')})",
330
- f"Change: {breadcrumb['change_id']} ({breadcrumb.get('change_status', 'active')})",
331
- f"Task: {task} ({task_status})",
332
- f"Role: {breadcrumb.get('role', 'plan')}",
333
- f"Gate: {breadcrumb.get('gate', 'alignment')}",
334
- f"Readiness: {breadcrumb.get('readiness', 'ready')}",
335
- ]
336
- else:
337
- lines = [
338
- "[Ultra baseline]",
339
- f"Project: {root}",
340
- f"Baseline: {baseline.get('id', 'missing')} ({baseline.get('mode', 'unknown')}/{baseline.get('status', 'missing')})",
341
- f"Readiness: {breadcrumb.get('readiness', 'blocked')}",
342
- ]
343
- if breadcrumb.get("blockers"):
344
- lines.append("Blockers: " + ", ".join(breadcrumb["blockers"]))
345
- if breadcrumb.get("warnings"):
346
- lines.append("Warnings: " + ", ".join(breadcrumb["warnings"]))
347
- lines.extend([
348
- f"Next: {breadcrumb.get('next_action', 'Inspect Ultra status.')}",
349
- f"Route: {breadcrumb.get('recommended_workflow', 'ultra-change')}",
350
- ])
351
- if breadcrumb.get("context_manifest_path"):
352
- digest = breadcrumb.get("context_manifest_hash") or "unknown"
353
- lines.append(f"Context: {breadcrumb['context_manifest_path']} sha256={digest}")
354
- lines.append("Authority: .ultra/state.db; JSON/Markdown remain projections or evidence artifacts.")
355
- return "\n".join(lines)
80
+ def render_breadcrumb(_root: Path, breadcrumb: dict) -> str:
81
+ rendered = getattr(breadcrumb, "rendered", None)
82
+ if not isinstance(rendered, str) or not rendered:
83
+ raise ContextSpineError("breadcrumb was not produced by the canonical reader")
84
+ return rendered
@@ -6,7 +6,7 @@ import sqlite3
6
6
  import sys
7
7
  from pathlib import Path
8
8
 
9
- from context_spine import baseline_health
9
+ from context_spine import ContextSpineError, find_root, read_breadcrumb
10
10
 
11
11
 
12
12
  REQUIRED_TABLES = {
@@ -27,18 +27,6 @@ def hook_input() -> dict:
27
27
  return {}
28
28
 
29
29
 
30
- def find_root(start: Path):
31
- for candidate in (start, *start.parents):
32
- ultra = candidate / ".ultra"
33
- if (
34
- (ultra / "state.db").is_file()
35
- or (ultra / "workflow-state.json").is_file()
36
- or (ultra / "changes" / "active").is_dir()
37
- ):
38
- return candidate
39
- return None
40
-
41
-
42
30
  def inspect(root: Path) -> dict:
43
31
  db_path = root / ".ultra" / "state.db"
44
32
  report = {"status": "healthy", "project": str(root), "checks": {}}
@@ -48,6 +36,13 @@ def inspect(root: Path) -> dict:
48
36
  report["status"] = "degraded"
49
37
  return report
50
38
 
39
+ try:
40
+ breadcrumb = read_breadcrumb(root)
41
+ except ContextSpineError as exc:
42
+ report["checks"]["authority"] = {"status": "fail", "reason": str(exc)}
43
+ report["status"] = "degraded"
44
+ return report
45
+
51
46
  try:
52
47
  uri = f"file:{db_path}?mode=ro"
53
48
  with sqlite3.connect(uri, uri=True, timeout=1) as conn:
@@ -70,11 +65,18 @@ def inspect(root: Path) -> dict:
70
65
  report["status"] = "degraded"
71
66
  return report
72
67
 
73
- baseline = baseline_health(conn, root)
68
+ baseline_codes = [
69
+ code for code in [
70
+ *(breadcrumb.get("blockers", []) if breadcrumb else []),
71
+ *(breadcrumb.get("warnings", []) if breadcrumb else []),
72
+ ]
73
+ if str(code).startswith("BASELINE_")
74
+ ]
74
75
  report["checks"]["baseline"] = {
75
- **baseline,
76
- "readiness": baseline["status"],
77
- "status": "pass" if baseline["status"] == "pass" else "warning",
76
+ "baseline": breadcrumb.get("baseline") if breadcrumb else None,
77
+ "blockers": baseline_codes,
78
+ "readiness": "blocked" if baseline_codes else "ready",
79
+ "status": "warning" if baseline_codes else "pass",
78
80
  }
79
81
 
80
82
  incidents = [
@@ -1,26 +1,22 @@
1
1
  #!/usr/bin/env python3
2
- """Report unfinished Ultra workflow position without trapping session stop."""
2
+ """Report unfinished DB-authoritative Ultra work without trapping session stop."""
3
3
 
4
- import sys
5
4
  import json
5
+ import sys
6
6
  from pathlib import Path
7
7
 
8
- TERMINAL = {"committed", "completed", "done", "cancelled"}
8
+ from context_spine import ContextSpineError, find_root, read_breadcrumb
9
9
 
10
10
 
11
11
  def allow_stop() -> None:
12
12
  print(json.dumps({}))
13
13
 
14
14
 
15
- def advise_stop(reason: str) -> None:
16
- print(f"[Ultra stop advisory] {reason}", file=sys.stderr)
17
- allow_stop()
18
-
19
-
20
- def main():
15
+ def main() -> None:
21
16
  try:
22
- hook_data = json.loads(sys.stdin.read())
23
- except (json.JSONDecodeError, OSError) as exc:
17
+ hook_data = json.loads(sys.stdin.read() or "{}")
18
+ hook_data = hook_data if isinstance(hook_data, dict) else {}
19
+ except json.JSONDecodeError as exc:
24
20
  print(f"[pre_stop_check] invalid hook input: {exc}", file=sys.stderr)
25
21
  allow_stop()
26
22
  return
@@ -28,30 +24,29 @@ def main():
28
24
  if hook_data.get("stop_hook_active", False):
29
25
  allow_stop()
30
26
  return
31
- start = Path(hook_data.get("cwd") or Path.cwd()).resolve()
32
- for root in (start, *start.parents):
33
- state_file = root / ".ultra" / "workflow-state.json"
34
- if not state_file.is_file():
35
- continue
36
- try:
37
- state = json.loads(state_file.read_text(encoding="utf-8"))
38
- except (OSError, json.JSONDecodeError) as exc:
39
- print(f"[pre_stop_check] cannot read {state_file}: {exc}", file=sys.stderr)
40
- allow_stop()
41
- return
42
- if not isinstance(state, dict) or state.get("status") in TERMINAL:
43
- allow_stop()
44
- return
45
- advise_stop(
46
- "Active Ultra workflow is incomplete: "
47
- f"command={state.get('command', 'unknown')}, "
48
- f"task={state.get('task_id', state.get('task', 'unknown'))}, "
49
- f"step={state.get('step', 'unknown')}, status={state.get('status', 'active')}. "
50
- "Resume or explicitly cancel it in a later session; stop is allowed."
51
- )
27
+ root = find_root(Path(hook_data.get("cwd") or Path.cwd()).resolve())
28
+ if root is None:
29
+ allow_stop()
52
30
  return
31
+ try:
32
+ breadcrumb = read_breadcrumb(root)
33
+ except ContextSpineError as exc:
34
+ print(f"[pre_stop_check] cannot inspect Context Spine: {exc}", file=sys.stderr)
35
+ allow_stop()
36
+ return
37
+ if breadcrumb and breadcrumb.get("change_id"):
38
+ print(
39
+ "[Ultra stop advisory] Active Ultra change remains open: "
40
+ f"change={breadcrumb['change_id']}, "
41
+ f"task={breadcrumb.get('task_id') or 'none'}, "
42
+ f"gate={breadcrumb.get('gate') or 'alignment'}, "
43
+ f"route={breadcrumb.get('recommended_workflow') or 'ultra-status'}. "
44
+ f"Next: {breadcrumb.get('next_action') or 'Inspect Ultra status.'} "
45
+ "Stop is allowed; resume or explicitly archive the change later.",
46
+ file=sys.stderr,
47
+ )
53
48
  allow_stop()
54
49
 
55
50
 
56
- if __name__ == '__main__':
51
+ if __name__ == "__main__":
57
52
  main()
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env python3
2
2
  """Subagent lifecycle tracker.
3
3
 
4
- Logs SubagentStart/Stop events to .ultra/debug/subagent-log.jsonl (project-level)
4
+ Logs SubagentStart/Stop events to .ultra/runtime/subagent-log.jsonl (project-level)
5
5
  for debugging and cost analysis.
6
6
 
7
7
  Protocol fields:
@@ -15,30 +15,26 @@ Usage:
15
15
  """
16
16
 
17
17
  import json
18
- import os
19
- import random
20
18
  import sys
21
19
  from datetime import datetime, timezone
22
20
  from pathlib import Path
23
21
 
24
- TERMINAL = {"committed", "completed", "done", "cancelled"}
22
+ from context_spine import ContextSpineError, find_root, read_breadcrumb
23
+
25
24
  MAX_LOG_LINES = 5000
26
25
 
27
26
 
28
27
  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"
28
+ root = find_root(Path(hook_input.get("cwd") or Path.cwd()).resolve())
29
+ if root is None:
30
+ return None
31
+ try:
32
+ breadcrumb = read_breadcrumb(root)
33
+ except ContextSpineError as exc:
34
+ print(f"[subagent_tracker] cannot inspect Context Spine: {exc}", file=sys.stderr)
41
35
  return None
36
+ if breadcrumb and breadcrumb.get("change_id"):
37
+ return root / ".ultra" / "runtime"
42
38
  return None
43
39
 
44
40
 
@@ -105,9 +101,7 @@ def main():
105
101
  except OSError as exc:
106
102
  print(f"[subagent_tracker] event write failed: {exc}", file=sys.stderr)
107
103
 
108
- # Periodic log rotation (~1% of writes)
109
- if random.random() < 0.01:
110
- rotate_log(log_file)
104
+ rotate_log(log_file)
111
105
 
112
106
  print(json.dumps({}))
113
107