ultra-builder-pro-cli 0.20.0 → 0.22.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 (109) hide show
  1. package/.ultra-template/docs/research/README.md +26 -5
  2. package/.ultra-template/specs/discovery.md +1 -1
  3. package/.ultra-template/tasks/tasks.json +10 -3
  4. package/.ultra-template/templates/task-context.md +19 -0
  5. package/CHANGELOG.md +106 -0
  6. package/README.md +215 -41
  7. package/adapters/_shared/codex-assets.cjs +69 -11
  8. package/adapters/_shared/interaction-contract.cjs +36 -3
  9. package/adapters/_shared/runtime-assets.cjs +41 -1
  10. package/adapters/claude.js +7 -5
  11. package/adapters/codex.js +1 -1
  12. package/adapters/kimi.js +2 -8
  13. package/adapters/opencode.js +43 -30
  14. package/bin/orchestrator.js +64 -24
  15. package/docs/AGENT-CONTEXT.md +89 -49
  16. package/docs/ARCHITECTURE.md +112 -45
  17. package/docs/ARTIFACT-AUTHORITY.md +274 -0
  18. package/docs/COMMIT-HASH-BACKFILL.md +33 -90
  19. package/docs/DECISIONS.md +14 -6
  20. package/docs/LEGACY-CLI-CROSSWALK.md +16 -6
  21. package/docs/PLUGIN-ISOLATION-CONTRACT.md +6 -4
  22. package/docs/ROADMAP.md +20 -6
  23. package/docs/RUNTIME-COMPAT-MATRIX.md +29 -16
  24. package/docs/STATE-DB-ACCESS-POLICY.md +43 -13
  25. package/docs/WORKFLOW-LIFECYCLE.md +105 -50
  26. package/hooks/README.md +10 -8
  27. package/hooks/active_task_context.py +105 -18
  28. package/hooks/context_spine.py +16 -4
  29. package/hooks/health_check.py +5 -4
  30. package/hooks/pre_stop_check.py +5 -2
  31. package/hooks/runtime_paths.py +396 -0
  32. package/hooks/subagent_tracker.py +5 -2
  33. package/hooks/workflow_checkpoint.py +16 -3
  34. package/hooks/workflow_context.py +11 -3
  35. package/hooks/workflow_resume.py +7 -4
  36. package/mcp-server/breadcrumb.cjs +20 -2
  37. package/mcp-server/lib/archive-journal.cjs +1026 -111
  38. package/mcp-server/lib/archive-mutation-worker.py +365 -0
  39. package/mcp-server/lib/artifact-registry.cjs +1127 -0
  40. package/mcp-server/lib/baseline-workflow.cjs +69 -28
  41. package/mcp-server/lib/change-packet.cjs +818 -0
  42. package/mcp-server/lib/change-workflow.cjs +932 -176
  43. package/mcp-server/lib/context-paths.cjs +142 -0
  44. package/mcp-server/lib/context-spine.cjs +511 -45
  45. package/mcp-server/lib/decision-dialogue.cjs +345 -3
  46. package/mcp-server/lib/delivery-transaction.cjs +471 -0
  47. package/mcp-server/lib/doctor-backup-worker.cjs +328 -0
  48. package/mcp-server/lib/doctor.cjs +401 -11
  49. package/mcp-server/lib/git-bootstrap.cjs +458 -47
  50. package/mcp-server/lib/init-project.cjs +240 -95
  51. package/mcp-server/lib/plan-publication-worker.cjs +830 -0
  52. package/mcp-server/lib/plan-store.cjs +700 -5
  53. package/mcp-server/lib/project-breadcrumb.cjs +23 -4
  54. package/mcp-server/lib/projector.cjs +211 -42
  55. package/mcp-server/lib/runtime-paths.cjs +1687 -0
  56. package/mcp-server/lib/safe-project-file.cjs +385 -0
  57. package/mcp-server/lib/spec-learning.cjs +28 -12
  58. package/mcp-server/lib/state-authority.cjs +19 -3
  59. package/mcp-server/lib/state-db.cjs +263 -9
  60. package/mcp-server/lib/state-ops.cjs +254 -50
  61. package/mcp-server/lib/task-ledger.cjs +1342 -0
  62. package/mcp-server/lib/telemetry.cjs +12 -3
  63. package/mcp-server/lib/workflow-state.cjs +610 -58
  64. package/mcp-server/server.cjs +423 -42
  65. package/orchestrator/daemon.cjs +2 -10
  66. package/orchestrator/parallel-orchestrator.cjs +314 -50
  67. package/orchestrator/planner/plan-builder.cjs +1 -1
  68. package/orchestrator/recovery.cjs +125 -7
  69. package/orchestrator/session-close-journal-worker.cjs +5 -0
  70. package/orchestrator/session-close-journal.cjs +1014 -0
  71. package/orchestrator/session-runner.cjs +679 -69
  72. package/orchestrator/worktree-manager.cjs +186 -35
  73. package/package.json +2 -3
  74. package/skills/cc-collab/SKILL.md +4 -1
  75. package/skills/codex-collab/SKILL.md +5 -2
  76. package/skills/ultra-change/SKILL.md +34 -8
  77. package/skills/ultra-deliver/SKILL.md +26 -11
  78. package/skills/ultra-dev/SKILL.md +52 -10
  79. package/skills/ultra-doctor/SKILL.md +16 -3
  80. package/skills/ultra-init/SKILL.md +22 -11
  81. package/skills/ultra-plan/SKILL.md +58 -18
  82. package/skills/ultra-research/SKILL.md +37 -35
  83. package/skills/ultra-review/SKILL.md +14 -8
  84. package/skills/ultra-status/SKILL.md +18 -9
  85. package/skills/ultra-test/SKILL.md +11 -5
  86. package/skills/ultra-think/SKILL.md +6 -2
  87. package/skills/ultra-think/references/decision-dialogue.md +17 -6
  88. package/skills/ultra-verify/SKILL.md +4 -1
  89. package/spec/cli-protocol.md +21 -7
  90. package/spec/mcp-tools.yaml +629 -44
  91. package/spec/schemas/change-delta.v1.schema.json +153 -0
  92. package/spec/schemas/context-file.v4.5.schema.json +1 -1
  93. package/spec/schemas/documentation-reconciliation.v1.schema.json +106 -0
  94. package/spec/schemas/mcp-tools.schema.json +1 -1
  95. package/spec/schemas/state-db.sql +51 -14
  96. package/spec/schemas/task-ledger.v1.schema.json +217 -0
  97. package/spec/schemas/tasks.v4.5.schema.json +1 -1
  98. package/ultra-tools/cli.cjs +3 -2
  99. package/ultra-tools/commands/db.cjs +76 -25
  100. package/ultra-tools/commands/migrate.cjs +1000 -82
  101. package/ultra-tools/commands/session.cjs +34 -8
  102. package/ultra-tools/commands/status.cjs +32 -6
  103. package/ultra-tools/commands/system.cjs +129 -22
  104. package/.ultra-template/tasks/contexts/TEMPLATE.md +0 -19
  105. package/commands/learn.md +0 -10
  106. package/orchestrator/skill-miner.cjs +0 -133
  107. package/output-styles/coding-vibes.md +0 -67
  108. package/output-styles/structural-thinking.md +0 -61
  109. package/skills/learn/SKILL.md +0 -58
@@ -0,0 +1,396 @@
1
+ """Read-only canonical project and state-path resolution for Ultra hooks."""
2
+
3
+ import json
4
+ import os
5
+ import sqlite3
6
+ import subprocess
7
+ from pathlib import Path
8
+
9
+
10
+ class RuntimePathError(RuntimeError):
11
+ """Ultra runtime paths are invalid or have competing authorities."""
12
+
13
+ STATE_TOMBSTONE_MARKER = "MIGRATED_TO_RUNTIME.json"
14
+ STATE_TOMBSTONE_KIND = "ultra-state-migration-tombstone"
15
+
16
+
17
+ def _absolute(path: Path) -> Path:
18
+ expanded = path.expanduser()
19
+ return expanded if expanded.is_absolute() else Path.cwd() / expanded
20
+
21
+
22
+ def _assert_root(root: Path, *, explicit: bool) -> Path:
23
+ absolute = _absolute(root)
24
+ try:
25
+ stat = absolute.lstat()
26
+ except FileNotFoundError as error:
27
+ raise RuntimePathError(f"UBP_ROOT_DIR does not exist: {absolute}") from error
28
+ if (explicit and absolute.is_symlink()) or not absolute.is_dir():
29
+ raise RuntimePathError(
30
+ f"UBP_ROOT_DIR must be a real directory, not a symlink or special entry: {absolute}"
31
+ )
32
+ return absolute.resolve()
33
+
34
+
35
+ def _assert_directory(path: Path, label: str, *, allow_runtime_link_to: Path | None = None) -> None:
36
+ try:
37
+ path.lstat()
38
+ except FileNotFoundError:
39
+ return
40
+ if path.is_symlink():
41
+ if allow_runtime_link_to is not None:
42
+ try:
43
+ if (
44
+ path.resolve(strict=True) == allow_runtime_link_to.parent.resolve(strict=True)
45
+ and (path / "state.db").resolve(strict=True)
46
+ == allow_runtime_link_to.resolve(strict=True)
47
+ ):
48
+ return
49
+ except FileNotFoundError:
50
+ pass
51
+ raise RuntimePathError(f"{label} may not be a symlink: {path}")
52
+ if not path.is_dir():
53
+ raise RuntimePathError(f"{label} must be a directory: {path}")
54
+
55
+
56
+ def _is_managed_legacy_tombstone(main: Path) -> bool:
57
+ try:
58
+ stat = main.lstat()
59
+ except FileNotFoundError:
60
+ return False
61
+ if main.is_symlink():
62
+ return False
63
+ marker = main / STATE_TOMBSTONE_MARKER if main.is_dir() else main
64
+ try:
65
+ if main.is_dir():
66
+ if [entry.name for entry in main.iterdir()] != [STATE_TOMBSTONE_MARKER]:
67
+ return False
68
+ elif not main.is_file():
69
+ return False
70
+ marker_stat = marker.lstat()
71
+ if marker.is_symlink() or not marker_stat.st_mode:
72
+ return False
73
+ payload = json.loads(marker.read_text(encoding="utf-8"))
74
+ except (FileNotFoundError, OSError, ValueError, json.JSONDecodeError):
75
+ return False
76
+ canonical = (main.parent / str(payload.get("canonical_state_db", ""))).resolve()
77
+ expected = (main.parent / ".runtime" / "state.db").resolve()
78
+ return (
79
+ payload.get("version") == 1
80
+ and payload.get("kind") == STATE_TOMBSTONE_KIND
81
+ and marker.is_file()
82
+ and canonical == expected
83
+ )
84
+
85
+
86
+ def _assert_state_set(
87
+ main: Path, label: str, *, allow_managed_tombstone: bool = False
88
+ ) -> None:
89
+ present: dict[str, bool] = {}
90
+ for suffix in ("", "-wal", "-shm"):
91
+ candidate = Path(f"{main}{suffix}")
92
+ try:
93
+ candidate.lstat()
94
+ except FileNotFoundError:
95
+ present[suffix] = False
96
+ continue
97
+ tombstone = (
98
+ suffix == ""
99
+ and allow_managed_tombstone
100
+ and _is_managed_legacy_tombstone(candidate)
101
+ )
102
+ if not tombstone and (candidate.is_symlink() or not candidate.is_file()):
103
+ raise RuntimePathError(
104
+ f"{label} state entry must be a regular file and may not be a symlink: {candidate}"
105
+ )
106
+ present[suffix] = not tombstone
107
+ if not present[""] and (present["-wal"] or present["-shm"]):
108
+ raise RuntimePathError(f"{label} SQLite sidecar exists without state.db authority")
109
+
110
+
111
+ def _physical_state_paths(root: Path) -> tuple[Path, Path]:
112
+ resolved = root.resolve()
113
+ return (
114
+ resolved / ".ultra" / ".runtime" / "state.db",
115
+ resolved / ".ultra" / "state.db",
116
+ )
117
+
118
+
119
+ def _same_physical_authority(left: Path, right: Path) -> bool:
120
+ if left == right:
121
+ return True
122
+ try:
123
+ return left.resolve(strict=True) == right.resolve(strict=True)
124
+ except FileNotFoundError:
125
+ return False
126
+
127
+
128
+ def _is_task_authority_link(
129
+ runtime: Path, candidate: Path, physical_runtime: Path, runtime_state: Path
130
+ ) -> bool:
131
+ try:
132
+ parts = candidate.relative_to(runtime).parts
133
+ except ValueError:
134
+ return False
135
+ if (
136
+ len(parts) != 4
137
+ or parts[0] != "worktrees"
138
+ or not parts[1]
139
+ or len(parts[1]) > 128
140
+ or not parts[1][0].isalnum()
141
+ or any(not (char.isalnum() or char in "._-") for char in parts[1])
142
+ or parts[2:] != (".ultra", ".runtime")
143
+ ):
144
+ return False
145
+ try:
146
+ return (
147
+ candidate.resolve(strict=True) == physical_runtime
148
+ and (candidate / "state.db").resolve(strict=True)
149
+ == runtime_state.resolve(strict=True)
150
+ )
151
+ except FileNotFoundError:
152
+ return False
153
+
154
+
155
+ def _has_git_metadata(root: Path) -> bool:
156
+ for candidate in (root, *root.parents):
157
+ if (candidate / ".git").exists() or (candidate / ".git").is_symlink():
158
+ return True
159
+ return False
160
+
161
+
162
+ def _registered_worktrees(root: Path) -> set[Path]:
163
+ probe = subprocess.run(
164
+ ["git", "rev-parse", "--is-inside-work-tree"],
165
+ cwd=root,
166
+ text=True,
167
+ capture_output=True,
168
+ check=False,
169
+ )
170
+ if probe.returncode != 0 or probe.stdout.strip() != "true":
171
+ detail = (probe.stderr or probe.stdout or "unknown Git probe failure").strip()
172
+ if not _has_git_metadata(root) and "not a git repository" in detail.lower():
173
+ return set()
174
+ raise RuntimePathError(f"cannot classify Git worktree authority: {detail}")
175
+ result = subprocess.run(
176
+ ["git", "worktree", "list", "--porcelain"],
177
+ cwd=root,
178
+ text=True,
179
+ capture_output=True,
180
+ check=False,
181
+ )
182
+ if result.returncode != 0:
183
+ detail = (result.stderr or result.stdout or "unknown Git probe failure").strip()
184
+ raise RuntimePathError(f"cannot inspect registered Git worktrees: {detail}")
185
+ registered: set[Path] = set()
186
+ for line in result.stdout.splitlines():
187
+ if line.startswith("worktree "):
188
+ registered.add(Path(line.removeprefix("worktree ")).resolve())
189
+ return registered
190
+
191
+
192
+ def _assert_registered_worktree_boundary(
193
+ runtime: Path, candidate: Path, physical_runtime: Path
194
+ ) -> None:
195
+ sid = candidate.name
196
+ if (
197
+ not sid
198
+ or len(sid) > 128
199
+ or not sid[0].isalnum()
200
+ or any(not (char.isalnum() or char in "._-") for char in sid)
201
+ ):
202
+ raise RuntimePathError(f"registered worktree has an unsafe session id: {candidate}")
203
+ authority_link = candidate / ".ultra" / ".runtime"
204
+ if (
205
+ not authority_link.is_symlink()
206
+ or not _is_task_authority_link(
207
+ runtime, authority_link, physical_runtime, runtime / "state.db"
208
+ )
209
+ ):
210
+ raise RuntimePathError(
211
+ "registered worktree must contain the exact central Ultra "
212
+ f"authority link: {authority_link}"
213
+ )
214
+
215
+
216
+ def _assert_runtime_tree(root: Path, runtime: Path) -> None:
217
+ try:
218
+ runtime.lstat()
219
+ except FileNotFoundError:
220
+ return
221
+ try:
222
+ physical_runtime = runtime.resolve(strict=True)
223
+ except FileNotFoundError as error:
224
+ raise RuntimePathError(
225
+ f"canonical runtime root cannot be resolved: {runtime}"
226
+ ) from error
227
+ pending = list(runtime.iterdir())
228
+ runtime_state = runtime / "state.db"
229
+ registered: set[Path] | None = None
230
+ while pending:
231
+ candidate = pending.pop()
232
+ try:
233
+ candidate.lstat()
234
+ except FileNotFoundError:
235
+ continue
236
+ if candidate.is_symlink():
237
+ if _is_task_authority_link(
238
+ runtime, candidate, physical_runtime, runtime_state
239
+ ):
240
+ continue
241
+ raise RuntimePathError(
242
+ f"canonical runtime entry may not be a symlink: {candidate}"
243
+ )
244
+ if not candidate.is_dir() and not candidate.is_file():
245
+ raise RuntimePathError(
246
+ "canonical runtime entry must be a regular file or real "
247
+ f"directory: {candidate}"
248
+ )
249
+ try:
250
+ physical_candidate = candidate.resolve(strict=True)
251
+ physical_candidate.relative_to(physical_runtime)
252
+ except (FileNotFoundError, ValueError) as error:
253
+ raise RuntimePathError(
254
+ f"canonical runtime entry escapes its physical root: {candidate}"
255
+ ) from error
256
+ try:
257
+ relative_parts = candidate.relative_to(runtime).parts
258
+ except ValueError:
259
+ relative_parts = ()
260
+ if (
261
+ candidate.is_dir()
262
+ and len(relative_parts) == 2
263
+ and relative_parts[0] == "worktrees"
264
+ ):
265
+ if registered is None:
266
+ registered = _registered_worktrees(root)
267
+ if physical_candidate in registered:
268
+ _assert_registered_worktree_boundary(
269
+ runtime, candidate, physical_runtime
270
+ )
271
+ continue
272
+ if candidate.is_dir():
273
+ pending.extend(candidate.iterdir())
274
+
275
+
276
+ def _project_root_from_state_db(configured: Path) -> Path:
277
+ if (
278
+ configured.name == "state.db"
279
+ and configured.parent.name == ".runtime"
280
+ and configured.parent.parent.name == ".ultra"
281
+ ):
282
+ return configured.parent.parent.parent
283
+ raise RuntimePathError(
284
+ f"configured DB is not a canonical project runtime authority: {configured}"
285
+ )
286
+
287
+
288
+ def _assert_task_authority_binding(root: Path, configured: Path) -> None:
289
+ try:
290
+ authority_root = _project_root_from_state_db(configured).resolve(strict=True)
291
+ canonical_db = authority_root / ".ultra" / ".runtime" / "state.db"
292
+ if configured.resolve(strict=True) != canonical_db.resolve(strict=True):
293
+ raise RuntimePathError("configured DB is not the canonical authority")
294
+ task_root = root.resolve(strict=True)
295
+ worktrees_root = (
296
+ authority_root / ".ultra" / ".runtime" / "worktrees"
297
+ ).resolve(strict=True)
298
+ relative = task_root.relative_to(worktrees_root)
299
+ if len(relative.parts) != 1:
300
+ raise RuntimePathError("task root is not a direct managed worktree child")
301
+ sid = relative.parts[0]
302
+ if task_root not in _registered_worktrees(authority_root):
303
+ raise RuntimePathError("task root is not a registered Git worktree")
304
+ runtime_link = root / ".ultra" / ".runtime"
305
+ if (
306
+ not runtime_link.is_symlink()
307
+ or runtime_link.resolve(strict=True) != canonical_db.parent.resolve(strict=True)
308
+ ):
309
+ raise RuntimePathError("task authority link does not resolve to the configured DB")
310
+ uri = f"{configured.resolve(strict=True).as_uri()}?mode=ro"
311
+ with sqlite3.connect(uri, uri=True) as connection:
312
+ row = connection.execute(
313
+ "SELECT worktree_path FROM sessions WHERE sid = ?", (sid,)
314
+ ).fetchone()
315
+ if row is None:
316
+ raise RuntimePathError(f"authority has no session binding for {sid}")
317
+ if Path(row[0]).resolve(strict=True) != task_root:
318
+ raise RuntimePathError(f"session {sid} is bound to a different worktree")
319
+ except (FileNotFoundError, ValueError, sqlite3.Error) as error:
320
+ raise RuntimePathError(
321
+ f"task root is not bound to the configured Ultra authority: {error}"
322
+ ) from error
323
+
324
+
325
+ def validate_project_layout(
326
+ root: Path, *, validate_runtime_tree: bool = False
327
+ ) -> tuple[Path, Path, Path | None]:
328
+ runtime, legacy = _physical_state_paths(root)
329
+ configured_value = os.environ.get("UBP_DB_PATH", "").strip()
330
+ configured = _absolute(Path(configured_value)) if configured_value else None
331
+ if configured is not None:
332
+ _assert_state_set(configured, "configured")
333
+ _assert_directory(root / ".ultra", ".ultra")
334
+ _assert_directory(
335
+ root / ".ultra" / ".runtime",
336
+ ".ultra/.runtime",
337
+ allow_runtime_link_to=configured,
338
+ )
339
+ _assert_state_set(runtime, "runtime")
340
+ _assert_state_set(legacy, "legacy", allow_managed_tombstone=True)
341
+ if validate_runtime_tree:
342
+ _assert_runtime_tree(root, root.resolve() / ".ultra" / ".runtime")
343
+ return runtime, legacy, configured
344
+
345
+
346
+ def _assert_no_competing_state(root: Path) -> tuple[Path, Path]:
347
+ runtime, legacy, configured = validate_project_layout(root)
348
+ legacy_active = legacy.is_file() and not _is_managed_legacy_tombstone(legacy)
349
+ if runtime.is_file() and legacy_active:
350
+ raise RuntimePathError(
351
+ "both legacy .ultra/state.db and runtime "
352
+ ".ultra/.runtime/state.db exist; refusing to choose either authority"
353
+ )
354
+ if configured is not None:
355
+ active = runtime if runtime.is_file() else (legacy if legacy_active else None)
356
+ allowed = (
357
+ _same_physical_authority(configured, active)
358
+ if active is not None
359
+ else configured == runtime
360
+ )
361
+ if not allowed:
362
+ raise RuntimePathError(
363
+ "UBP_DB_PATH does not name this project's canonical or "
364
+ f"task-linked authority: {configured}"
365
+ )
366
+ if (root / ".ultra" / ".runtime").is_symlink():
367
+ _assert_task_authority_binding(root, configured)
368
+ return runtime, legacy
369
+
370
+
371
+ def state_db_path(root: Path) -> Path:
372
+ """Return configured, runtime, or legacy state without mutating the project."""
373
+ runtime, legacy = _assert_no_competing_state(root)
374
+ configured = os.environ.get("UBP_DB_PATH", "").strip()
375
+ if configured:
376
+ return _absolute(Path(configured)).resolve()
377
+ if runtime.is_file():
378
+ return runtime
379
+ if legacy.is_file() and not _is_managed_legacy_tombstone(legacy):
380
+ return legacy
381
+ return runtime
382
+
383
+
384
+ def find_project_root(start: Path) -> Path | None:
385
+ """Resolve one project root without letting UBP_DB_PATH invent a root."""
386
+ configured_root = os.environ.get("UBP_ROOT_DIR", "").strip()
387
+ if configured_root:
388
+ root = _assert_root(Path(configured_root), explicit=True)
389
+ return root if state_db_path(root).is_file() else None
390
+
391
+ current = _assert_root(start, explicit=False)
392
+ for root in (current, *current.parents):
393
+ runtime, legacy = _assert_no_competing_state(root)
394
+ if runtime.is_file() or legacy.is_file():
395
+ return root
396
+ return None
@@ -13,7 +13,7 @@ import subprocess
13
13
  import sys
14
14
  from pathlib import Path
15
15
 
16
- from context_spine import find_root
16
+ from context_spine import find_root_for_hook
17
17
 
18
18
 
19
19
  def helper_path() -> Path:
@@ -71,7 +71,10 @@ def main() -> None:
71
71
  print(f"[subagent_tracker] invalid hook input: {exc}", file=sys.stderr)
72
72
  hook_input = {}
73
73
 
74
- root = find_root(Path(hook_input.get("cwd") or Path.cwd()).resolve())
74
+ root = find_root_for_hook(
75
+ Path(hook_input.get("cwd") or Path.cwd()).resolve(),
76
+ "subagent_tracker",
77
+ )
75
78
  if root is not None:
76
79
  try:
77
80
  append_event(root, action, hook_input)
@@ -10,10 +10,11 @@ from pathlib import Path
10
10
 
11
11
  from context_spine import (
12
12
  ContextSpineError,
13
- find_root,
13
+ find_root_for_hook,
14
14
  read_breadcrumb,
15
15
  render_breadcrumb,
16
16
  )
17
+ from runtime_paths import RuntimePathError, validate_project_layout
17
18
 
18
19
 
19
20
  def main() -> None:
@@ -23,7 +24,10 @@ def main() -> None:
23
24
  except json.JSONDecodeError as exc:
24
25
  print(f"[workflow_checkpoint] invalid hook input: {exc}", file=sys.stderr)
25
26
  data = {}
26
- root = find_root(Path(data.get("cwd") or Path.cwd()).resolve())
27
+ root = find_root_for_hook(
28
+ Path(data.get("cwd") or Path.cwd()).resolve(),
29
+ "workflow_checkpoint",
30
+ )
27
31
  if root is None:
28
32
  print(json.dumps({}))
29
33
  return
@@ -37,7 +41,16 @@ def main() -> None:
37
41
  print(json.dumps({}))
38
42
  return
39
43
 
40
- runtime_dir = root / ".ultra" / "runtime"
44
+ try:
45
+ validate_project_layout(root, validate_runtime_tree=True)
46
+ except RuntimePathError as exc:
47
+ print(
48
+ f"[workflow_checkpoint] unsafe Ultra runtime: {exc}",
49
+ file=sys.stderr,
50
+ )
51
+ print(json.dumps({}))
52
+ return
53
+ runtime_dir = root / ".ultra" / ".runtime"
41
54
  runtime_dir.mkdir(parents=True, exist_ok=True)
42
55
  checkpoint = {
43
56
  "schema": 2,
@@ -1,11 +1,16 @@
1
1
  #!/usr/bin/env python3
2
- """Inject one compact Ultra state position without persistent memory."""
2
+ """Inject one compact Ultra workflow-memory position without provider payloads."""
3
3
 
4
4
  import json
5
5
  import sys
6
6
  from pathlib import Path
7
7
 
8
- from context_spine import ContextSpineError, find_root, read_breadcrumb, render_breadcrumb
8
+ from context_spine import (
9
+ ContextSpineError,
10
+ find_root_for_hook,
11
+ read_breadcrumb,
12
+ render_breadcrumb,
13
+ )
9
14
 
10
15
 
11
16
  def payload() -> dict:
@@ -19,7 +24,10 @@ def payload() -> dict:
19
24
 
20
25
  def main() -> None:
21
26
  data = payload()
22
- root = find_root(Path(data.get("cwd") or Path.cwd()).resolve())
27
+ root = find_root_for_hook(
28
+ Path(data.get("cwd") or Path.cwd()).resolve(),
29
+ "workflow_context",
30
+ )
23
31
  if root is None:
24
32
  print(json.dumps({}))
25
33
  return
@@ -8,7 +8,7 @@ from pathlib import Path
8
8
 
9
9
  from context_spine import (
10
10
  ContextSpineError,
11
- find_root,
11
+ find_root_for_hook,
12
12
  read_breadcrumb,
13
13
  render_breadcrumb,
14
14
  )
@@ -24,7 +24,7 @@ def _hook_output(event: str, text: str) -> dict:
24
24
 
25
25
 
26
26
  def _checkpoint_advisory(root: Path):
27
- file = root / ".ultra" / "runtime" / "checkpoint.json"
27
+ file = root / ".ultra" / ".runtime" / "checkpoint.json"
28
28
  if not file.is_file():
29
29
  return None
30
30
  try:
@@ -44,7 +44,7 @@ def _checkpoint_advisory(root: Path):
44
44
  return None
45
45
  return "\n".join([
46
46
  "[Ultra checkpoint advisory]",
47
- "Live .ultra/state.db could not be read; this checkpoint is not authority.",
47
+ "Live .ultra/.runtime/state.db could not be read; this checkpoint is not authority.",
48
48
  value["rendered"],
49
49
  "Run ultra-doctor before mutation.",
50
50
  ])
@@ -57,7 +57,10 @@ def main() -> None:
57
57
  except json.JSONDecodeError as exc:
58
58
  print(f"[workflow_resume] invalid hook input: {exc}", file=sys.stderr)
59
59
  data = {}
60
- root = find_root(Path(data.get("cwd") or Path.cwd()).resolve())
60
+ root = find_root_for_hook(
61
+ Path(data.get("cwd") or Path.cwd()).resolve(),
62
+ "workflow_resume",
63
+ )
61
64
  if root is None:
62
65
  print(json.dumps({}))
63
66
  return
@@ -9,14 +9,32 @@ const runtime = fs.existsSync(path.join(__dirname, 'index.cjs'))
9
9
  : require('./server.cjs');
10
10
 
11
11
  function main() {
12
- const rootDir = path.resolve(process.argv[2] || process.cwd());
12
+ const discover = process.argv[2] === '--discover';
13
+ const requested = path.resolve(process.argv[discover ? 3 : 2] || process.cwd());
14
+ const rootDir = discover ? runtime.findProjectRoot(requested) : requested;
15
+ if (!rootDir) {
16
+ process.stdout.write(`${JSON.stringify({
17
+ root: null,
18
+ breadcrumb: null,
19
+ text: null,
20
+ })}\n`);
21
+ return;
22
+ }
13
23
  const breadcrumb = runtime.readProjectBreadcrumb(rootDir);
14
24
  process.stdout.write(`${JSON.stringify({
25
+ root: rootDir,
15
26
  breadcrumb,
16
27
  text: breadcrumb ? runtime.renderProjectBreadcrumb(rootDir, breadcrumb) : null,
17
28
  })}\n`);
18
29
  }
19
30
 
20
- if (require.main === module) main();
31
+ if (require.main === module) {
32
+ try {
33
+ main();
34
+ } catch (error) {
35
+ process.stderr.write(`${error.code || 'BREADCRUMB_FAILED'}: ${error.message}\n`);
36
+ process.exitCode = 2;
37
+ }
38
+ }
21
39
 
22
40
  module.exports = { main };