ultra-builder-pro-cli 0.11.0 → 0.13.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 (75) hide show
  1. package/.ultra-template/docs/research/README.md +21 -44
  2. package/.ultra-template/specs/architecture.md +54 -223
  3. package/.ultra-template/specs/discovery.md +41 -228
  4. package/.ultra-template/specs/product.md +44 -200
  5. package/.ultra-template/tasks/contexts/TEMPLATE.md +36 -0
  6. package/.ultra-template/tasks/tasks.json +6 -0
  7. package/CHANGELOG.md +94 -1
  8. package/README.md +37 -9
  9. package/adapters/_shared/codex-assets.cjs +1 -1
  10. package/adapters/_shared/handbook.cjs +5 -4
  11. package/adapters/_shared/tests/handbook.test.cjs +4 -0
  12. package/adapters/opencode.js +8 -2
  13. package/adapters/tests/codex.test.cjs +10 -2
  14. package/adapters/tests/kimi-hook.test.cjs +1 -1
  15. package/adapters/tests/opencode.test.cjs +36 -1
  16. package/commands/ultra-init.md +3 -3
  17. package/docs/AGENT-CONTEXT.md +17 -8
  18. package/docs/ARCHITECTURE.md +51 -8
  19. package/docs/PLAN.zh-CN.md +9 -6
  20. package/docs/ROADMAP.md +5 -3
  21. package/docs/RUNTIME-COMPAT-MATRIX.md +8 -5
  22. package/docs/STATE-DB-ACCESS-POLICY.md +19 -13
  23. package/docs/USER-HANDBOOK-CONTRACT.md +9 -5
  24. package/hooks/README.md +4 -3
  25. package/hooks/adapters/codex.py +1 -1
  26. package/hooks/adapters/kimi.py +1 -1
  27. package/hooks/context_spine.py +153 -16
  28. package/hooks/health_check.py +11 -0
  29. package/hooks/pre_stop_check.py +6 -5
  30. package/mcp-server/lib/archive-journal.cjs +192 -0
  31. package/mcp-server/lib/archive-journal.test.cjs +35 -0
  32. package/mcp-server/lib/baseline-workflow.cjs +781 -0
  33. package/mcp-server/lib/baseline-workflow.test.cjs +477 -0
  34. package/mcp-server/lib/change-workflow.cjs +256 -85
  35. package/mcp-server/lib/change-workflow.test.cjs +436 -6
  36. package/mcp-server/lib/context-spine.cjs +74 -9
  37. package/mcp-server/lib/context-spine.test.cjs +20 -0
  38. package/mcp-server/lib/doctor.cjs +28 -1
  39. package/mcp-server/lib/doctor.test.cjs +63 -1
  40. package/mcp-server/lib/init-project.cjs +334 -16
  41. package/mcp-server/lib/provider-refs.cjs +42 -0
  42. package/mcp-server/lib/spec-learning.test.cjs +5 -0
  43. package/mcp-server/lib/state-authority.cjs +9 -3
  44. package/mcp-server/lib/state-db.cjs +202 -21
  45. package/mcp-server/lib/state-db.test.cjs +140 -3
  46. package/mcp-server/lib/state-ops.cjs +68 -6
  47. package/mcp-server/lib/task-expander.cjs +7 -1
  48. package/mcp-server/lib/task-expander.test.cjs +71 -0
  49. package/mcp-server/server.cjs +62 -6
  50. package/mcp-server/tests/init-project.test.cjs +216 -9
  51. package/mcp-server/tests/migrate.test.cjs +33 -0
  52. package/mcp-server/tests/server-contract.test.cjs +478 -3
  53. package/mcp-server/tests/state-db.test.cjs +23 -2
  54. package/mcp-server/tests/state-ops.test.cjs +108 -0
  55. package/package.json +1 -1
  56. package/skills/ultra-change/SKILL.md +32 -13
  57. package/skills/ultra-deliver/SKILL.md +19 -5
  58. package/skills/ultra-dev/SKILL.md +8 -2
  59. package/skills/ultra-doctor/SKILL.md +30 -9
  60. package/skills/ultra-init/SKILL.md +107 -66
  61. package/skills/ultra-init/references/brownfield-adoption.md +93 -0
  62. package/skills/ultra-plan/SKILL.md +15 -5
  63. package/skills/ultra-research/SKILL.md +17 -5
  64. package/skills/ultra-status/SKILL.md +15 -6
  65. package/spec/cli-protocol.md +15 -1
  66. package/spec/mcp-tools.yaml +316 -11
  67. package/spec/schemas/mcp-tools.schema.json +1 -1
  68. package/spec/schemas/state-db.sql +44 -2
  69. package/ultra-tools/cli.cjs +9 -5
  70. package/ultra-tools/commands/migrate.cjs +62 -36
  71. package/ultra-tools/commands/status.cjs +29 -2
  72. package/ultra-tools/commands/system.cjs +340 -6
  73. package/ultra-tools/commands/task.cjs +11 -2
  74. package/ultra-tools/status-command.test.cjs +49 -0
  75. package/ultra-tools/system-command.test.cjs +256 -0
@@ -2,6 +2,7 @@
2
2
  """Read and render the compact DB-derived Ultra Context Spine breadcrumb."""
3
3
 
4
4
  import json
5
+ import hashlib
5
6
  import sqlite3
6
7
  import subprocess
7
8
  from pathlib import Path
@@ -13,6 +14,12 @@ CONTEXT_COLUMNS = {
13
14
  "role", "gate", "next_action", "readiness", "blockers_json",
14
15
  "context_json", "manifest_path", "manifest_hash", "git_head", "task_id",
15
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
+ }
16
23
 
17
24
 
18
25
  def find_root(start: Path):
@@ -49,8 +56,90 @@ def git_head(root: Path):
49
56
  return result.stdout.strip() if result.returncode == 0 else None
50
57
 
51
58
 
52
- def _next_action(change, task, tasks, role, readiness, stored, state_changed):
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
+
70
+
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
+
138
+
139
+ def _next_action(change, task, tasks, role, readiness, stored, state_changed, blockers=None):
53
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."
54
143
  return "Resolve the context readiness blockers, then recompile change.context."
55
144
  if not state_changed and isinstance(stored, str) and stored.strip():
56
145
  return stored.strip()
@@ -77,7 +166,7 @@ def _next_action(change, task, tasks, role, readiness, stored, state_changed):
77
166
  return f"Create the first fresh-context tracer-bullet task for change {change['id']}."
78
167
 
79
168
 
80
- def _route(change, task, tasks, readiness):
169
+ def _route(change, task, tasks, readiness, blockers=None):
81
170
  if readiness != "ready" or change["status"] == "blocked":
82
171
  return "ultra-change"
83
172
  if change["status"] == "ready":
@@ -99,15 +188,39 @@ def read_breadcrumb(root: Path):
99
188
  row[0]
100
189
  for row in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
101
190
  }
102
- if not {"changes", "tasks", "context_snapshots"}.issubset(tables):
191
+ if not {"baselines", "changes", "tasks", "context_snapshots"}.issubset(tables):
103
192
  return None
193
+ baseline = baseline_health(conn, root)
104
194
  change = conn.execute(
105
195
  """SELECT id, status FROM changes
106
196
  WHERE status IN ('active', 'blocked', 'ready')
107
197
  ORDER BY updated_at DESC LIMIT 1"""
108
198
  ).fetchone()
109
199
  if change is None:
110
- return 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
+ }
111
224
  tasks = list(conn.execute(
112
225
  "SELECT id, status, session_id FROM tasks WHERE change_id = ? ORDER BY created_at ASC",
113
226
  (change["id"],),
@@ -145,6 +258,7 @@ def read_breadcrumb(root: Path):
145
258
  not isinstance(context.get("resume"), dict)
146
259
  or not isinstance(context.get("context"), dict)
147
260
  or not isinstance(context.get("readiness"), dict)
261
+ or "baseline" not in context
148
262
  )
149
263
  )
150
264
  stored_status = context.get("resume", {}).get("task_status")
@@ -152,6 +266,7 @@ def read_breadcrumb(root: Path):
152
266
  state_changed = stored_status is not None and stored_status != current_status
153
267
  stored_action = snapshot["next_action"] if snapshot else None
154
268
  blockers = []
269
+ warnings = list(dict.fromkeys([*baseline["blockers"], *baseline["warnings"]]))
155
270
  if snapshot_missing:
156
271
  blockers.append("CONTEXT_NOT_COMPILED")
157
272
  else:
@@ -162,8 +277,14 @@ def read_breadcrumb(root: Path):
162
277
  if state_changed:
163
278
  blockers.append("CONTEXT_TASK_STATE_STALE")
164
279
  for blocker in safe_json(snapshot["blockers_json"], []):
165
- if blocker not in blockers:
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:
166
284
  blockers.append(blocker)
285
+ for warning in context.get("readiness", {}).get("warnings", []):
286
+ if warning not in warnings:
287
+ warnings.append(warning)
167
288
  readiness = "blocked" if blockers else snapshot["readiness"]
168
289
  if snapshot_missing:
169
290
  next_action = f"Compile change.context for change {change['id']} before continuing."
@@ -174,7 +295,9 @@ def read_breadcrumb(root: Path):
174
295
  elif state_changed:
175
296
  next_action = "Task state changed after context compilation; recompile change.context."
176
297
  else:
177
- next_action = _next_action(change, task, tasks, role, readiness, stored_action, False)
298
+ next_action = _next_action(
299
+ change, task, tasks, role, readiness, stored_action, False, blockers
300
+ )
178
301
  return {
179
302
  "change_id": change["id"],
180
303
  "change_status": change["status"],
@@ -185,28 +308,42 @@ def read_breadcrumb(root: Path):
185
308
  "gate": gate,
186
309
  "readiness": readiness,
187
310
  "blockers": blockers,
311
+ "warnings": warnings,
188
312
  "next_action": next_action,
189
- "recommended_workflow": _route(change, task, tasks, readiness),
313
+ "recommended_workflow": _route(change, task, tasks, readiness, blockers),
190
314
  "context_manifest_path": snapshot["manifest_path"] if snapshot else None,
191
315
  "context_manifest_hash": snapshot["manifest_hash"] if snapshot else None,
192
316
  "git_head": current_head,
317
+ "baseline": baseline["baseline"],
193
318
  }
194
319
 
195
320
 
196
321
  def render_breadcrumb(root: Path, breadcrumb: dict) -> str:
197
322
  task = breadcrumb.get("task_id") or "none"
198
323
  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
- ]
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
+ ]
208
343
  if breadcrumb.get("blockers"):
209
344
  lines.append("Blockers: " + ", ".join(breadcrumb["blockers"]))
345
+ if breadcrumb.get("warnings"):
346
+ lines.append("Warnings: " + ", ".join(breadcrumb["warnings"]))
210
347
  lines.extend([
211
348
  f"Next: {breadcrumb.get('next_action', 'Inspect Ultra status.')}",
212
349
  f"Route: {breadcrumb.get('recommended_workflow', 'ultra-change')}",
@@ -6,8 +6,11 @@ import sqlite3
6
6
  import sys
7
7
  from pathlib import Path
8
8
 
9
+ from context_spine import baseline_health
10
+
9
11
 
10
12
  REQUIRED_TABLES = {
13
+ "baselines",
11
14
  "tasks", "events", "sessions", "schema_version", "migration_history",
12
15
  "telemetry", "specs_refs", "circuit_breaker", "changes", "artifacts",
13
16
  "context_snapshots", "spec_learning_candidates", "trace_links", "incidents", "projection_jobs",
@@ -48,6 +51,7 @@ def inspect(root: Path) -> dict:
48
51
  try:
49
52
  uri = f"file:{db_path}?mode=ro"
50
53
  with sqlite3.connect(uri, uri=True, timeout=1) as conn:
54
+ conn.row_factory = sqlite3.Row
51
55
  quick = conn.execute("PRAGMA quick_check").fetchone()[0]
52
56
  tables = {
53
57
  row[0] for row in conn.execute(
@@ -66,6 +70,13 @@ def inspect(root: Path) -> dict:
66
70
  report["status"] = "degraded"
67
71
  return report
68
72
 
73
+ baseline = baseline_health(conn, root)
74
+ report["checks"]["baseline"] = {
75
+ **baseline,
76
+ "readiness": baseline["status"],
77
+ "status": "pass" if baseline["status"] == "pass" else "warning",
78
+ }
79
+
69
80
  incidents = [
70
81
  {"id": row[0], "code": row[1], "severity": row[2], "retryable": bool(row[3])}
71
82
  for row in conn.execute(
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env python3
2
- """Block stop only when the active Ultra workflow itself is incomplete."""
2
+ """Report unfinished Ultra workflow position without trapping session stop."""
3
3
 
4
4
  import sys
5
5
  import json
@@ -12,8 +12,9 @@ def allow_stop() -> None:
12
12
  print(json.dumps({}))
13
13
 
14
14
 
15
- def block_stop(reason: str) -> None:
16
- print(json.dumps({"decision": "block", "reason": reason}))
15
+ def advise_stop(reason: str) -> None:
16
+ print(f"[Ultra stop advisory] {reason}", file=sys.stderr)
17
+ allow_stop()
17
18
 
18
19
 
19
20
  def main():
@@ -41,12 +42,12 @@ def main():
41
42
  if not isinstance(state, dict) or state.get("status") in TERMINAL:
42
43
  allow_stop()
43
44
  return
44
- block_stop(
45
+ advise_stop(
45
46
  "Active Ultra workflow is incomplete: "
46
47
  f"command={state.get('command', 'unknown')}, "
47
48
  f"task={state.get('task_id', state.get('task', 'unknown'))}, "
48
49
  f"step={state.get('step', 'unknown')}, status={state.get('status', 'active')}. "
49
- "Complete or explicitly cancel the Ultra workflow before stopping."
50
+ "Resume or explicitly cancel it in a later session; stop is allowed."
50
51
  )
51
52
  return
52
53
  allow_stop()
@@ -0,0 +1,192 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+
6
+ const INTENT_FILE = '.archive-intent.json';
7
+ const INTENT_VERSION = '1.0';
8
+
9
+ class ArchiveJournalError extends Error {
10
+ constructor(code, message) {
11
+ super(message);
12
+ this.name = 'ArchiveJournalError';
13
+ this.code = code;
14
+ }
15
+ }
16
+
17
+ function resolveInside(rootDir, relative, field) {
18
+ if (typeof relative !== 'string' || !relative.trim() || path.isAbsolute(relative)) {
19
+ throw new ArchiveJournalError('ARCHIVE_INTENT_INVALID', `${field} must be project-relative`);
20
+ }
21
+ const root = path.resolve(rootDir);
22
+ const resolved = path.resolve(root, relative);
23
+ if (resolved !== root && !resolved.startsWith(`${root}${path.sep}`)) {
24
+ throw new ArchiveJournalError('ARCHIVE_INTENT_INVALID', `${field} escapes project root`);
25
+ }
26
+ return resolved;
27
+ }
28
+
29
+ function assertIntent(intent, rootDir) {
30
+ if (!intent || typeof intent !== 'object' || Array.isArray(intent)
31
+ || intent.schema_version !== INTENT_VERSION
32
+ || typeof intent.change_id !== 'string' || !intent.change_id
33
+ || !Array.isArray(intent.baseline_updates)
34
+ || typeof intent.summary !== 'string' || intent.summary.trim().length < 3) {
35
+ throw new ArchiveJournalError('ARCHIVE_INTENT_INVALID', 'archive intent shape is invalid');
36
+ }
37
+ const source = resolveInside(rootDir, intent.source, 'source');
38
+ const destination = resolveInside(rootDir, intent.destination, 'destination');
39
+ const activeRoot = path.resolve(rootDir, '.ultra', 'changes', 'active');
40
+ const archiveRoot = path.resolve(rootDir, '.ultra', 'changes', 'archive');
41
+ if (path.dirname(source) !== activeRoot || path.basename(source) !== intent.change_id) {
42
+ throw new ArchiveJournalError('ARCHIVE_INTENT_INVALID', 'archive intent source is not the active change root');
43
+ }
44
+ if (path.dirname(destination) !== archiveRoot
45
+ || !path.basename(destination).endsWith(`-${intent.change_id}`)) {
46
+ throw new ArchiveJournalError('ARCHIVE_INTENT_INVALID', 'archive intent destination is not the change archive root');
47
+ }
48
+ for (const update of intent.baseline_updates) resolveInside(rootDir, update, 'baseline update');
49
+ if (intent.previous_summary !== null && typeof intent.previous_summary !== 'string') {
50
+ throw new ArchiveJournalError('ARCHIVE_INTENT_INVALID', 'previous_summary must be string or null');
51
+ }
52
+ return { intent, source, destination };
53
+ }
54
+
55
+ function readIntentFile(file, rootDir) {
56
+ let intent;
57
+ try { intent = JSON.parse(fs.readFileSync(file, 'utf8')); }
58
+ catch (error) {
59
+ throw new ArchiveJournalError('ARCHIVE_INTENT_CORRUPT', `cannot read ${file}: ${error.message}`);
60
+ }
61
+ return assertIntent(intent, rootDir);
62
+ }
63
+
64
+ function writeJsonAtomic(file, value) {
65
+ const temp = `${file}.${process.pid}.tmp`;
66
+ fs.writeFileSync(temp, `${JSON.stringify(value, null, 2)}\n`, { flag: 'wx' });
67
+ fs.renameSync(temp, file);
68
+ }
69
+
70
+ function renderSummary(change, summary, baselineUpdates, noBaselineChangeReason) {
71
+ return [
72
+ `# Archived change: ${change.title}`, '', summary.trim(), '',
73
+ '## Baseline reconciliation', '',
74
+ ...(baselineUpdates.length > 0
75
+ ? baselineUpdates.map((file) => `- ${file}`)
76
+ : [noBaselineChangeReason]), '',
77
+ ].join('\n');
78
+ }
79
+
80
+ function sameOperation(intent, { change, summary, baselineUpdates, noBaselineChangeReason }) {
81
+ return intent.change_id === change.id
82
+ && intent.source === change.artifact_root
83
+ && intent.summary === summary.trim()
84
+ && JSON.stringify(intent.baseline_updates) === JSON.stringify(baselineUpdates)
85
+ && (intent.no_baseline_change_reason || null) === (noBaselineChangeReason || null);
86
+ }
87
+
88
+ function prepareArchiveMove({
89
+ rootDir, change, summary, baselineUpdates, noBaselineChangeReason, now = new Date(),
90
+ }) {
91
+ const date = now.toISOString().slice(0, 10);
92
+ const source = resolveInside(rootDir, change.artifact_root, 'source');
93
+ const destinationRelative = path.join('.ultra', 'changes', 'archive', `${date}-${change.id}`);
94
+ const destination = resolveInside(rootDir, destinationRelative, 'destination');
95
+ const sourceIntentFile = path.join(source, INTENT_FILE);
96
+ const destinationIntentFile = path.join(destination, INTENT_FILE);
97
+
98
+ if (!fs.existsSync(source) && fs.existsSync(destinationIntentFile)) {
99
+ const resumed = readIntentFile(destinationIntentFile, rootDir);
100
+ if (!sameOperation(resumed.intent, {
101
+ change, summary, baselineUpdates, noBaselineChangeReason,
102
+ })) {
103
+ throw new ArchiveJournalError('ARCHIVE_INTENT_CONFLICT', 'existing archive intent differs from retry input');
104
+ }
105
+ return { ...resumed, resumed: true };
106
+ }
107
+ if (!fs.existsSync(source)) {
108
+ throw new ArchiveJournalError('ARCHIVE_SOURCE_MISSING', `active change root missing: ${change.artifact_root}`);
109
+ }
110
+ if (fs.existsSync(destination)) {
111
+ throw new ArchiveJournalError('ARCHIVE_EXISTS', `archive already exists: ${destination}`);
112
+ }
113
+
114
+ let prepared;
115
+ if (fs.existsSync(sourceIntentFile)) {
116
+ prepared = readIntentFile(sourceIntentFile, rootDir);
117
+ if (!sameOperation(prepared.intent, {
118
+ change, summary, baselineUpdates, noBaselineChangeReason,
119
+ })) {
120
+ throw new ArchiveJournalError('ARCHIVE_INTENT_CONFLICT', 'prepared archive intent differs from retry input');
121
+ }
122
+ } else {
123
+ const summaryPath = path.join(source, 'archive-summary.md');
124
+ const previousSummary = fs.existsSync(summaryPath) ? fs.readFileSync(summaryPath, 'utf8') : null;
125
+ const intent = {
126
+ schema_version: INTENT_VERSION,
127
+ change_id: change.id,
128
+ source: change.artifact_root,
129
+ destination: destinationRelative,
130
+ summary: summary.trim(),
131
+ baseline_updates: baselineUpdates,
132
+ no_baseline_change_reason: noBaselineChangeReason || null,
133
+ previous_summary: previousSummary,
134
+ created_at: now.toISOString(),
135
+ };
136
+ writeJsonAtomic(sourceIntentFile, intent);
137
+ prepared = assertIntent(intent, rootDir);
138
+ }
139
+
140
+ fs.writeFileSync(
141
+ path.join(source, 'archive-summary.md'),
142
+ renderSummary(change, summary, baselineUpdates, noBaselineChangeReason),
143
+ );
144
+ fs.mkdirSync(path.dirname(destination), { recursive: true });
145
+ fs.renameSync(source, destination);
146
+ return { ...prepared, source, destination, resumed: false };
147
+ }
148
+
149
+ function listArchiveIntents(rootDir) {
150
+ const roots = [
151
+ path.join(rootDir, '.ultra', 'changes', 'active'),
152
+ path.join(rootDir, '.ultra', 'changes', 'archive'),
153
+ ];
154
+ const found = [];
155
+ for (const parent of roots) {
156
+ if (!fs.existsSync(parent)) continue;
157
+ for (const entry of fs.readdirSync(parent, { withFileTypes: true })) {
158
+ if (!entry.isDirectory()) continue;
159
+ const file = path.join(parent, entry.name, INTENT_FILE);
160
+ if (!fs.existsSync(file)) continue;
161
+ try { found.push({ ...readIntentFile(file, rootDir), file, error: null }); }
162
+ catch (error) { found.push({ file, error }); }
163
+ }
164
+ }
165
+ return found;
166
+ }
167
+
168
+ function completeArchiveIntent(rootDir, intent) {
169
+ const { destination } = assertIntent(intent, rootDir);
170
+ fs.rmSync(path.join(destination, INTENT_FILE), { force: true });
171
+ }
172
+
173
+ function rollbackArchiveIntent(rootDir, intent) {
174
+ const { source, destination } = assertIntent(intent, rootDir);
175
+ if (fs.existsSync(destination) && !fs.existsSync(source)) fs.renameSync(destination, source);
176
+ if (!fs.existsSync(source) || fs.existsSync(destination)) {
177
+ throw new ArchiveJournalError('ARCHIVE_ROLLBACK_FAILED', 'archive roots are not in a recoverable state');
178
+ }
179
+ const summaryPath = path.join(source, 'archive-summary.md');
180
+ if (intent.previous_summary === null) fs.rmSync(summaryPath, { force: true });
181
+ else fs.writeFileSync(summaryPath, intent.previous_summary);
182
+ fs.rmSync(path.join(source, INTENT_FILE), { force: true });
183
+ }
184
+
185
+ module.exports = {
186
+ ArchiveJournalError,
187
+ INTENT_FILE,
188
+ prepareArchiveMove,
189
+ listArchiveIntents,
190
+ completeArchiveIntent,
191
+ rollbackArchiveIntent,
192
+ };
@@ -0,0 +1,35 @@
1
+ 'use strict';
2
+
3
+ const test = require('node:test');
4
+ const assert = require('node:assert/strict');
5
+ const fs = require('node:fs');
6
+ const os = require('node:os');
7
+ const path = require('node:path');
8
+
9
+ const journal = require('./archive-journal.cjs');
10
+
11
+ test('archive journal restores the exact active directory when finalization cannot run', () => {
12
+ const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ubp-archive-journal-'));
13
+ const source = path.join(rootDir, '.ultra', 'changes', 'active', 'change-a');
14
+ fs.mkdirSync(source, { recursive: true });
15
+ fs.writeFileSync(path.join(source, 'intent.md'), '# Change A\n');
16
+ try {
17
+ const prepared = journal.prepareArchiveMove({
18
+ rootDir,
19
+ change: { id: 'change-a', title: 'Change A', artifact_root: '.ultra/changes/active/change-a' },
20
+ summary: 'Archive Change A.', baselineUpdates: [],
21
+ noBaselineChangeReason: 'No baseline content changed.',
22
+ now: new Date('2026-07-18T00:00:00.000Z'),
23
+ });
24
+ assert.equal(fs.existsSync(prepared.source), false);
25
+ assert.equal(journal.listArchiveIntents(rootDir).length, 1);
26
+
27
+ journal.rollbackArchiveIntent(rootDir, prepared.intent);
28
+ assert.equal(fs.existsSync(source), true);
29
+ assert.equal(fs.existsSync(path.join(source, 'intent.md')), true);
30
+ assert.equal(fs.existsSync(path.join(source, 'archive-summary.md')), false);
31
+ assert.equal(journal.listArchiveIntents(rootDir).length, 0);
32
+ } finally {
33
+ fs.rmSync(rootDir, { recursive: true, force: true });
34
+ }
35
+ });