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.
- package/.ultra-template/docs/research/README.md +21 -44
- package/.ultra-template/specs/architecture.md +54 -223
- package/.ultra-template/specs/discovery.md +41 -228
- package/.ultra-template/specs/product.md +44 -200
- package/.ultra-template/tasks/contexts/TEMPLATE.md +36 -0
- package/.ultra-template/tasks/tasks.json +6 -0
- package/CHANGELOG.md +94 -1
- package/README.md +37 -9
- package/adapters/_shared/codex-assets.cjs +1 -1
- package/adapters/_shared/handbook.cjs +5 -4
- package/adapters/_shared/tests/handbook.test.cjs +4 -0
- package/adapters/opencode.js +8 -2
- package/adapters/tests/codex.test.cjs +10 -2
- package/adapters/tests/kimi-hook.test.cjs +1 -1
- package/adapters/tests/opencode.test.cjs +36 -1
- package/commands/ultra-init.md +3 -3
- package/docs/AGENT-CONTEXT.md +17 -8
- package/docs/ARCHITECTURE.md +51 -8
- package/docs/PLAN.zh-CN.md +9 -6
- package/docs/ROADMAP.md +5 -3
- package/docs/RUNTIME-COMPAT-MATRIX.md +8 -5
- package/docs/STATE-DB-ACCESS-POLICY.md +19 -13
- package/docs/USER-HANDBOOK-CONTRACT.md +9 -5
- package/hooks/README.md +4 -3
- package/hooks/adapters/codex.py +1 -1
- package/hooks/adapters/kimi.py +1 -1
- package/hooks/context_spine.py +153 -16
- package/hooks/health_check.py +11 -0
- package/hooks/pre_stop_check.py +6 -5
- package/mcp-server/lib/archive-journal.cjs +192 -0
- package/mcp-server/lib/archive-journal.test.cjs +35 -0
- package/mcp-server/lib/baseline-workflow.cjs +781 -0
- package/mcp-server/lib/baseline-workflow.test.cjs +477 -0
- package/mcp-server/lib/change-workflow.cjs +256 -85
- package/mcp-server/lib/change-workflow.test.cjs +436 -6
- package/mcp-server/lib/context-spine.cjs +74 -9
- package/mcp-server/lib/context-spine.test.cjs +20 -0
- package/mcp-server/lib/doctor.cjs +28 -1
- package/mcp-server/lib/doctor.test.cjs +63 -1
- package/mcp-server/lib/init-project.cjs +334 -16
- package/mcp-server/lib/provider-refs.cjs +42 -0
- package/mcp-server/lib/spec-learning.test.cjs +5 -0
- package/mcp-server/lib/state-authority.cjs +9 -3
- package/mcp-server/lib/state-db.cjs +202 -21
- package/mcp-server/lib/state-db.test.cjs +140 -3
- package/mcp-server/lib/state-ops.cjs +68 -6
- package/mcp-server/lib/task-expander.cjs +7 -1
- package/mcp-server/lib/task-expander.test.cjs +71 -0
- package/mcp-server/server.cjs +62 -6
- package/mcp-server/tests/init-project.test.cjs +216 -9
- package/mcp-server/tests/migrate.test.cjs +33 -0
- package/mcp-server/tests/server-contract.test.cjs +478 -3
- package/mcp-server/tests/state-db.test.cjs +23 -2
- package/mcp-server/tests/state-ops.test.cjs +108 -0
- package/package.json +1 -1
- package/skills/ultra-change/SKILL.md +32 -13
- package/skills/ultra-deliver/SKILL.md +19 -5
- package/skills/ultra-dev/SKILL.md +8 -2
- package/skills/ultra-doctor/SKILL.md +30 -9
- package/skills/ultra-init/SKILL.md +107 -66
- package/skills/ultra-init/references/brownfield-adoption.md +93 -0
- package/skills/ultra-plan/SKILL.md +15 -5
- package/skills/ultra-research/SKILL.md +17 -5
- package/skills/ultra-status/SKILL.md +15 -6
- package/spec/cli-protocol.md +15 -1
- package/spec/mcp-tools.yaml +316 -11
- package/spec/schemas/mcp-tools.schema.json +1 -1
- package/spec/schemas/state-db.sql +44 -2
- package/ultra-tools/cli.cjs +9 -5
- package/ultra-tools/commands/migrate.cjs +62 -36
- package/ultra-tools/commands/status.cjs +29 -2
- package/ultra-tools/commands/system.cjs +340 -6
- package/ultra-tools/commands/task.cjs +11 -2
- package/ultra-tools/status-command.test.cjs +49 -0
- package/ultra-tools/system-command.test.cjs +256 -0
package/hooks/context_spine.py
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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')}",
|
package/hooks/health_check.py
CHANGED
|
@@ -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(
|
package/hooks/pre_stop_check.py
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""
|
|
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
|
|
16
|
-
print(
|
|
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
|
-
|
|
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
|
-
"
|
|
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
|
+
});
|