ultra-builder-pro-cli 0.7.0 → 0.9.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/CHANGELOG.md +60 -1
- package/README.md +49 -23
- package/adapters/_shared/codex-assets.cjs +28 -0
- package/adapters/_shared/handbook.cjs +9 -1
- package/adapters/_shared/path-rewrite.cjs +3 -0
- package/adapters/_shared/provenance.cjs +286 -0
- package/adapters/_shared/runtime-assets.cjs +2 -1
- package/adapters/_shared/tests/handbook.test.cjs +6 -1
- package/adapters/_shared/tests/path-rewrite.test.cjs +3 -1
- package/adapters/_shared/tests/provenance.test.cjs +133 -0
- package/adapters/_shared/tests/runtime-assets.test.cjs +4 -1
- package/adapters/claude.js +33 -0
- package/adapters/codex.js +87 -0
- package/adapters/kimi.js +642 -0
- package/adapters/opencode.js +66 -0
- package/adapters/tests/codex.test.cjs +37 -0
- package/adapters/tests/kimi-hook.test.cjs +122 -0
- package/adapters/tests/kimi.test.cjs +255 -0
- package/agents/debugger.md +12 -0
- package/bin/handbook.js +2 -2
- package/bin/install.js +72 -12
- package/bin/orchestrator.js +1 -1
- package/commands/ultra-change.md +3 -2
- package/docs/AGENT-CONTEXT.md +5 -3
- package/docs/ARCHITECTURE.md +40 -6
- package/docs/PLAN.zh-CN.md +8 -6
- package/docs/ROADMAP.md +4 -4
- package/docs/RUNTIME-COMPAT-MATRIX.md +109 -58
- package/docs/USER-HANDBOOK-CONTRACT.md +4 -1
- package/hooks/README.md +6 -2
- package/hooks/adapters/kimi.py +162 -0
- package/hooks/workflow_checkpoint.py +15 -1
- package/hooks/workflow_resume.py +96 -7
- package/mcp-server/lib/change-workflow.cjs +69 -0
- package/mcp-server/lib/change-workflow.test.cjs +87 -0
- package/mcp-server/lib/state-db.cjs +85 -2
- package/mcp-server/lib/state-db.test.cjs +56 -0
- package/mcp-server/tests/init-project.test.cjs +2 -2
- package/mcp-server/tests/state-db.test.cjs +3 -3
- package/package.json +4 -2
- package/skills/ultra-change/SKILL.md +12 -3
- package/skills/ultra-deliver/SKILL.md +3 -1
- package/skills/ultra-doctor/SKILL.md +10 -3
- package/skills/ultra-review/SKILL.md +3 -3
- package/spec/mcp-tools.yaml +5 -5
- package/spec/schemas/skill-manifest.schema.json +2 -2
- package/spec/schemas/state-db.sql +4 -2
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Kimi Code wire adapter for Ultra Builder Pro lifecycle hooks.
|
|
3
|
+
|
|
4
|
+
Kimi 0.26 uses snake_case JSON input compatible with the workflow hooks, but
|
|
5
|
+
its output contract accepts only ``message`` plus an optional structured deny.
|
|
6
|
+
This adapter preserves real projection/stop blocks, converts foreign context
|
|
7
|
+
fields into Kimi messages, and fails open with diagnostic evidence.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import copy
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import subprocess
|
|
16
|
+
import sys
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
HOOK_ROOT = Path(__file__).resolve().parent.parent
|
|
22
|
+
ALLOWED_FEATURES = {
|
|
23
|
+
"active_task_context.py",
|
|
24
|
+
"health_check.py",
|
|
25
|
+
"pre_stop_check.py",
|
|
26
|
+
"subagent_tracker.py",
|
|
27
|
+
"workflow_checkpoint.py",
|
|
28
|
+
"workflow_context.py",
|
|
29
|
+
"workflow_resume.py",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def normalize_input(payload: dict[str, Any]) -> dict[str, Any]:
|
|
34
|
+
"""Add only the legacy aliases required by the shared workflow hooks."""
|
|
35
|
+
normalized = copy.deepcopy(payload)
|
|
36
|
+
agent_name = normalized.get("agent_name")
|
|
37
|
+
if isinstance(agent_name, str) and agent_name:
|
|
38
|
+
normalized.setdefault("agent_id", agent_name)
|
|
39
|
+
normalized.setdefault("agent_type", agent_name)
|
|
40
|
+
if normalized.get("tool_response") is not None and normalized.get("tool_output") is None:
|
|
41
|
+
normalized["tool_output"] = normalized["tool_response"]
|
|
42
|
+
return normalized
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _text(value: Any) -> str:
|
|
46
|
+
return str(value).strip() if value is not None else ""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def adapt_output(output: dict[str, Any], event: str) -> dict[str, Any]:
|
|
50
|
+
"""Convert one legacy hook result to Kimi's exact structured schema."""
|
|
51
|
+
if not isinstance(output, dict):
|
|
52
|
+
return {}
|
|
53
|
+
specific = output.get("hookSpecificOutput")
|
|
54
|
+
specific = specific if isinstance(specific, dict) else {}
|
|
55
|
+
|
|
56
|
+
decision = specific.get("permissionDecision")
|
|
57
|
+
blocked = output.get("decision") == "block" or decision == "deny"
|
|
58
|
+
reason = (
|
|
59
|
+
_text(specific.get("permissionDecisionReason"))
|
|
60
|
+
or _text(output.get("reason"))
|
|
61
|
+
or f"Ultra {event} gate blocked continuation"
|
|
62
|
+
)
|
|
63
|
+
if blocked:
|
|
64
|
+
return {
|
|
65
|
+
"hookSpecificOutput": {
|
|
66
|
+
"message": reason,
|
|
67
|
+
"permissionDecision": "deny",
|
|
68
|
+
"permissionDecisionReason": reason,
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
context = (
|
|
73
|
+
_text(specific.get("additionalContext"))
|
|
74
|
+
or _text(output.get("additionalContext"))
|
|
75
|
+
or _text(output.get("systemMessage"))
|
|
76
|
+
or _text(specific.get("message"))
|
|
77
|
+
or _text(output.get("message"))
|
|
78
|
+
)
|
|
79
|
+
advisory = _text(specific.get("permissionDecisionReason")) if decision == "ask" else ""
|
|
80
|
+
message = "\n\n".join(part for part in [advisory, context] if part)
|
|
81
|
+
return {"message": message} if message else {}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _parse_output(stdout: str) -> dict[str, Any]:
|
|
85
|
+
text = stdout.strip()
|
|
86
|
+
if not text:
|
|
87
|
+
return {}
|
|
88
|
+
try:
|
|
89
|
+
value = json.loads(text)
|
|
90
|
+
except json.JSONDecodeError:
|
|
91
|
+
return {"message": f"Ultra hook returned non-JSON output: {text[:500]}"}
|
|
92
|
+
return value if isinstance(value, dict) else {}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def run_feature(feature: str, payload: dict[str, Any], feature_args: list[str]) -> dict[str, Any]:
|
|
96
|
+
if feature not in ALLOWED_FEATURES or Path(feature).name != feature:
|
|
97
|
+
return {"message": f"Ultra hook adapter refused unknown feature: {feature}"}
|
|
98
|
+
script = HOOK_ROOT / feature
|
|
99
|
+
if not script.is_file():
|
|
100
|
+
return {"message": f"Ultra hook feature is missing: {feature}"}
|
|
101
|
+
|
|
102
|
+
event = str(payload.get("hook_event_name") or "")
|
|
103
|
+
normalized = normalize_input(payload)
|
|
104
|
+
if feature == "workflow_resume.py" and event == "PostCompact":
|
|
105
|
+
normalized["hook_event_name"] = "SessionStart"
|
|
106
|
+
normalized["source"] = "compact"
|
|
107
|
+
|
|
108
|
+
env = os.environ.copy()
|
|
109
|
+
env["UBP_HOOK_RUNTIME"] = "kimi"
|
|
110
|
+
plugin_data = env.get("PLUGIN_DATA") or env.get("KIMI_PLUGIN_ROOT")
|
|
111
|
+
if plugin_data:
|
|
112
|
+
env["UBP_HOOK_DATA"] = plugin_data
|
|
113
|
+
|
|
114
|
+
cwd = normalized.get("cwd")
|
|
115
|
+
run_cwd = str(cwd) if isinstance(cwd, str) and Path(cwd).is_dir() else os.getcwd()
|
|
116
|
+
proc = subprocess.run(
|
|
117
|
+
[sys.executable, str(script), *feature_args],
|
|
118
|
+
input=json.dumps(normalized),
|
|
119
|
+
text=True,
|
|
120
|
+
capture_output=True,
|
|
121
|
+
cwd=run_cwd,
|
|
122
|
+
env=env,
|
|
123
|
+
timeout=30,
|
|
124
|
+
check=False,
|
|
125
|
+
)
|
|
126
|
+
if proc.stderr:
|
|
127
|
+
print(proc.stderr.rstrip(), file=sys.stderr)
|
|
128
|
+
if proc.returncode != 0:
|
|
129
|
+
return {
|
|
130
|
+
"message": (
|
|
131
|
+
f"Ultra hook {feature} failed with exit {proc.returncode}: "
|
|
132
|
+
f"{proc.stderr.strip()[:500]}"
|
|
133
|
+
)
|
|
134
|
+
}
|
|
135
|
+
return adapt_output(_parse_output(proc.stdout), event)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def main() -> int:
|
|
139
|
+
if len(sys.argv) < 2:
|
|
140
|
+
print(json.dumps({"message": "Ultra Kimi hook adapter requires a feature name"}))
|
|
141
|
+
return 0
|
|
142
|
+
feature = sys.argv[1]
|
|
143
|
+
feature_args = sys.argv[2:]
|
|
144
|
+
try:
|
|
145
|
+
payload = json.loads(sys.stdin.read() or "{}")
|
|
146
|
+
if not isinstance(payload, dict):
|
|
147
|
+
payload = {}
|
|
148
|
+
except json.JSONDecodeError as exc:
|
|
149
|
+
print(json.dumps({"message": f"Ultra hook input parse error: {exc}"}))
|
|
150
|
+
return 0
|
|
151
|
+
try:
|
|
152
|
+
output = run_feature(feature, payload, feature_args)
|
|
153
|
+
except subprocess.TimeoutExpired:
|
|
154
|
+
output = {"message": f"Ultra hook {feature} timed out"}
|
|
155
|
+
except Exception as exc: # Kimi hooks are fail-open at this adapter boundary.
|
|
156
|
+
output = {"message": f"Ultra hook adapter error in {feature}: {exc}"}
|
|
157
|
+
print(json.dumps(output, ensure_ascii=False))
|
|
158
|
+
return 0
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
if __name__ == "__main__":
|
|
162
|
+
raise SystemExit(main())
|
|
@@ -11,6 +11,17 @@ from pathlib import Path
|
|
|
11
11
|
TERMINAL = {"committed", "completed", "done", "cancelled"}
|
|
12
12
|
|
|
13
13
|
|
|
14
|
+
def valid_workflow(state: object) -> bool:
|
|
15
|
+
return (
|
|
16
|
+
isinstance(state, dict)
|
|
17
|
+
and isinstance(state.get("command"), str)
|
|
18
|
+
and bool(state["command"].strip())
|
|
19
|
+
and state.get("step") is not None
|
|
20
|
+
and isinstance(state.get("status"), str)
|
|
21
|
+
and bool(state["status"].strip())
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
14
25
|
def main() -> None:
|
|
15
26
|
try:
|
|
16
27
|
data = json.loads(sys.stdin.read() or "{}")
|
|
@@ -28,7 +39,10 @@ def main() -> None:
|
|
|
28
39
|
except (OSError, json.JSONDecodeError) as exc:
|
|
29
40
|
print(f"[workflow_checkpoint] cannot read {state_file}: {exc}", file=sys.stderr)
|
|
30
41
|
break
|
|
31
|
-
if not
|
|
42
|
+
if not valid_workflow(state):
|
|
43
|
+
print(f"[workflow_checkpoint] invalid workflow state in {state_file}", file=sys.stderr)
|
|
44
|
+
break
|
|
45
|
+
if state.get("status") in TERMINAL:
|
|
32
46
|
break
|
|
33
47
|
runtime_dir = root / ".ultra" / "runtime"
|
|
34
48
|
runtime_dir.mkdir(parents=True, exist_ok=True)
|
package/hooks/workflow_resume.py
CHANGED
|
@@ -2,12 +2,82 @@
|
|
|
2
2
|
"""Restore only the current Ultra workflow boundary after compaction."""
|
|
3
3
|
|
|
4
4
|
import json
|
|
5
|
+
import os
|
|
5
6
|
import sys
|
|
7
|
+
import tempfile
|
|
8
|
+
from datetime import datetime, timezone
|
|
6
9
|
from pathlib import Path
|
|
7
10
|
|
|
8
11
|
TERMINAL = {"committed", "completed", "done", "cancelled"}
|
|
9
12
|
|
|
10
13
|
|
|
14
|
+
def valid_workflow(state: object) -> bool:
|
|
15
|
+
return (
|
|
16
|
+
isinstance(state, dict)
|
|
17
|
+
and isinstance(state.get("command"), str)
|
|
18
|
+
and bool(state["command"].strip())
|
|
19
|
+
and state.get("step") is not None
|
|
20
|
+
and isinstance(state.get("status"), str)
|
|
21
|
+
and bool(state["status"].strip())
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def read_json(file: Path, label: str):
|
|
26
|
+
if not file.is_file():
|
|
27
|
+
return None
|
|
28
|
+
try:
|
|
29
|
+
return json.loads(file.read_text(encoding="utf-8"))
|
|
30
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
31
|
+
print(f"[workflow_resume] cannot read {label} {file}: {exc}", file=sys.stderr)
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def parse_time(value: object):
|
|
36
|
+
if not isinstance(value, str) or not value.strip():
|
|
37
|
+
return None
|
|
38
|
+
try:
|
|
39
|
+
return datetime.fromisoformat(value.replace("Z", "+00:00")).astimezone(timezone.utc)
|
|
40
|
+
except ValueError:
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def newest_time(file: Path, *values: object) -> datetime:
|
|
45
|
+
candidates = [parsed for value in values if (parsed := parse_time(value)) is not None]
|
|
46
|
+
try:
|
|
47
|
+
candidates.append(datetime.fromtimestamp(file.stat().st_mtime, tz=timezone.utc))
|
|
48
|
+
except OSError:
|
|
49
|
+
pass
|
|
50
|
+
return max(candidates, default=datetime.min.replace(tzinfo=timezone.utc))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def load_checkpoint(file: Path):
|
|
54
|
+
checkpoint = read_json(file, "checkpoint")
|
|
55
|
+
if checkpoint is None:
|
|
56
|
+
return None
|
|
57
|
+
if not isinstance(checkpoint, dict):
|
|
58
|
+
print(f"[workflow_resume] invalid checkpoint schema in {file}", file=sys.stderr)
|
|
59
|
+
return None
|
|
60
|
+
captured_at = checkpoint.get("captured_at")
|
|
61
|
+
workflow = checkpoint.get("workflow")
|
|
62
|
+
if checkpoint.get("schema") != 1 or parse_time(captured_at) is None or not valid_workflow(workflow):
|
|
63
|
+
print(f"[workflow_resume] invalid checkpoint schema in {file}", file=sys.stderr)
|
|
64
|
+
return None
|
|
65
|
+
return checkpoint
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def restore_state(file: Path, state: dict) -> None:
|
|
69
|
+
file.parent.mkdir(parents=True, exist_ok=True)
|
|
70
|
+
fd, temp_name = tempfile.mkstemp(prefix="workflow-state.", suffix=".json", dir=file.parent)
|
|
71
|
+
try:
|
|
72
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
73
|
+
json.dump(state, handle, ensure_ascii=False, indent=2)
|
|
74
|
+
handle.write("\n")
|
|
75
|
+
os.replace(temp_name, file)
|
|
76
|
+
finally:
|
|
77
|
+
if os.path.exists(temp_name):
|
|
78
|
+
os.unlink(temp_name)
|
|
79
|
+
|
|
80
|
+
|
|
11
81
|
def main() -> None:
|
|
12
82
|
try:
|
|
13
83
|
data = json.loads(sys.stdin.read() or "{}")
|
|
@@ -18,21 +88,40 @@ def main() -> None:
|
|
|
18
88
|
start = Path(data.get("cwd") or Path.cwd()).resolve()
|
|
19
89
|
for root in (start, *start.parents):
|
|
20
90
|
state_file = root / ".ultra" / "workflow-state.json"
|
|
21
|
-
|
|
91
|
+
checkpoint_file = root / ".ultra" / "runtime" / "checkpoint.json"
|
|
92
|
+
if not state_file.is_file() and not checkpoint_file.is_file():
|
|
22
93
|
continue
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
94
|
+
live = read_json(state_file, "workflow state")
|
|
95
|
+
if live is not None and not valid_workflow(live):
|
|
96
|
+
print(f"[workflow_resume] invalid workflow state in {state_file}", file=sys.stderr)
|
|
97
|
+
live = None
|
|
98
|
+
if live is not None and live.get("status") in TERMINAL:
|
|
27
99
|
break
|
|
28
|
-
|
|
100
|
+
checkpoint = load_checkpoint(checkpoint_file)
|
|
101
|
+
checkpoint_state = checkpoint["workflow"] if checkpoint is not None else None
|
|
102
|
+
if checkpoint_state is not None and checkpoint_state.get("status") in TERMINAL:
|
|
103
|
+
checkpoint_state = None
|
|
104
|
+
|
|
105
|
+
source = ".ultra/workflow-state.json"
|
|
106
|
+
state = live
|
|
107
|
+
if checkpoint_state is not None:
|
|
108
|
+
checkpoint_time = newest_time(
|
|
109
|
+
checkpoint_file, checkpoint_state.get("ts"), checkpoint.get("captured_at")
|
|
110
|
+
)
|
|
111
|
+
live_time = newest_time(state_file, live.get("ts")) if live is not None else None
|
|
112
|
+
if live_time is None or checkpoint_time >= live_time:
|
|
113
|
+
state = checkpoint_state
|
|
114
|
+
source = ".ultra/runtime/checkpoint.json"
|
|
115
|
+
restore_state(state_file, state)
|
|
116
|
+
if state is None:
|
|
29
117
|
break
|
|
30
118
|
text = (
|
|
31
119
|
"[Ultra workflow resumed] "
|
|
32
120
|
f"command={state.get('command', 'unknown')}; "
|
|
33
121
|
f"task={state.get('task_id', state.get('task', 'unknown'))}; "
|
|
34
122
|
f"step={state.get('step', 'unknown')}; status={state.get('status', 'active')}. "
|
|
35
|
-
"Continue from
|
|
123
|
+
f"source={source}. Continue from the recovered workflow boundary and verify "
|
|
124
|
+
"task/session/change state through .ultra/state.db."
|
|
36
125
|
)
|
|
37
126
|
event = data.get("hook_event_name", "SessionStart")
|
|
38
127
|
if event == "SessionStart":
|
|
@@ -26,6 +26,13 @@ const REQUIRED_EVIDENCE = Object.freeze({
|
|
|
26
26
|
major: ['diff', 'tests', 'spec', 'docs', 'review'],
|
|
27
27
|
incident: ['diagnosis', 'diff', 'tests'],
|
|
28
28
|
});
|
|
29
|
+
const INCIDENT_DIAGNOSIS_SECTIONS = Object.freeze([
|
|
30
|
+
{ heading: 'Reproduction', key: 'reproduction' },
|
|
31
|
+
{ heading: 'Hypotheses', key: 'hypotheses' },
|
|
32
|
+
{ heading: 'Root cause', key: 'root-cause' },
|
|
33
|
+
{ heading: 'Regression test', key: 'regression-test' },
|
|
34
|
+
{ heading: 'Recovery', key: 'recovery' },
|
|
35
|
+
]);
|
|
29
36
|
|
|
30
37
|
class ChangeWorkflowError extends Error {
|
|
31
38
|
constructor(code, message, details) {
|
|
@@ -149,6 +156,53 @@ function writeIntent(file, change) {
|
|
|
149
156
|
fs.writeFileSync(file, `${lines.join('\n')}\n`);
|
|
150
157
|
}
|
|
151
158
|
|
|
159
|
+
function writeIncidentDiagnosis(file, change) {
|
|
160
|
+
const lines = [
|
|
161
|
+
`# Incident diagnosis: ${change.title}`,
|
|
162
|
+
'',
|
|
163
|
+
...INCIDENT_DIAGNOSIS_SECTIONS.flatMap(({ heading }) => [`## ${heading}`, '', '']),
|
|
164
|
+
];
|
|
165
|
+
fs.writeFileSync(file, `${lines.join('\n')}\n`);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function readMarkdownSections(text) {
|
|
169
|
+
const sections = new Map();
|
|
170
|
+
let current = null;
|
|
171
|
+
for (const line of String(text).split(/\r?\n/)) {
|
|
172
|
+
const heading = /^##\s+(.+?)\s*$/.exec(line);
|
|
173
|
+
if (heading) {
|
|
174
|
+
current = heading[1].trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
|
175
|
+
if (!sections.has(current)) sections.set(current, []);
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
if (current) sections.get(current).push(line);
|
|
179
|
+
}
|
|
180
|
+
return new Map([...sections].map(([key, lines]) => [key, lines.join('\n').trim()]));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function inspectIncidentDiagnosis(change, artifactDir, rootDir) {
|
|
184
|
+
if (change.kind !== 'incident') return { blockers: [], artifact: null };
|
|
185
|
+
const diagnosisPath = path.join(artifactDir, 'diagnosis.md');
|
|
186
|
+
if (!fs.existsSync(diagnosisPath)) {
|
|
187
|
+
return { blockers: ['DIAGNOSIS_ARTIFACT_MISSING'], artifact: null };
|
|
188
|
+
}
|
|
189
|
+
const content = fs.readFileSync(diagnosisPath);
|
|
190
|
+
const sections = readMarkdownSections(content.toString('utf8'));
|
|
191
|
+
const blockers = INCIDENT_DIAGNOSIS_SECTIONS
|
|
192
|
+
.filter(({ key }) => !sections.get(key) || sections.get(key).length < 3)
|
|
193
|
+
.map(({ key }) => `DIAGNOSIS_SECTION_MISSING:${key}`);
|
|
194
|
+
return {
|
|
195
|
+
blockers,
|
|
196
|
+
artifact: {
|
|
197
|
+
change_id: change.id,
|
|
198
|
+
kind: 'diagnosis',
|
|
199
|
+
artifactPath: path.relative(rootDir, diagnosisPath),
|
|
200
|
+
contentHash: crypto.createHash('sha256').update(content).digest('hex'),
|
|
201
|
+
metadata: { required_sections: INCIDENT_DIAGNOSIS_SECTIONS.map(({ key }) => key) },
|
|
202
|
+
},
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
152
206
|
function upsertArtifact(db, { change_id, task_id = null, kind, artifactPath, contentHash, metadata }) {
|
|
153
207
|
const id = `art-${crypto.randomUUID().slice(0, 12)}`;
|
|
154
208
|
db.prepare(
|
|
@@ -275,6 +329,17 @@ function createChange(db, input, { rootDir = process.cwd() } = {}) {
|
|
|
275
329
|
change_id: row.id, kind: 'intent', artifactPath: path.relative(rootDir, intentPath),
|
|
276
330
|
contentHash: crypto.createHash('sha256').update(fs.readFileSync(intentPath)).digest('hex'),
|
|
277
331
|
});
|
|
332
|
+
if (row.kind === 'incident') {
|
|
333
|
+
const diagnosisPath = path.join(artifactDir, 'diagnosis.md');
|
|
334
|
+
writeIncidentDiagnosis(diagnosisPath, row);
|
|
335
|
+
upsertArtifact(db, {
|
|
336
|
+
change_id: row.id,
|
|
337
|
+
kind: 'diagnosis',
|
|
338
|
+
artifactPath: path.relative(rootDir, diagnosisPath),
|
|
339
|
+
contentHash: crypto.createHash('sha256').update(fs.readFileSync(diagnosisPath)).digest('hex'),
|
|
340
|
+
metadata: { required_sections: INCIDENT_DIAGNOSIS_SECTIONS.map(({ key }) => key) },
|
|
341
|
+
});
|
|
342
|
+
}
|
|
278
343
|
ops.appendEventInTx(db, {
|
|
279
344
|
type: 'change_created', change_id: row.id,
|
|
280
345
|
payload: { kind: row.kind, artifact_root: row.artifact_root },
|
|
@@ -410,6 +475,8 @@ function convergeChange(db, input, { rootDir = process.cwd() } = {}) {
|
|
|
410
475
|
}
|
|
411
476
|
|
|
412
477
|
const artifactDir = path.resolve(rootDir, change.artifact_root);
|
|
478
|
+
const diagnosis = inspectIncidentDiagnosis(change, artifactDir, rootDir);
|
|
479
|
+
for (const blocker of diagnosis.blockers) blockers.add(blocker);
|
|
413
480
|
if (['standard', 'major'].includes(change.kind)) {
|
|
414
481
|
if (filesUnder(path.join(artifactDir, 'delta')).length === 0) blockers.add('SPEC_DELTA_MISSING');
|
|
415
482
|
if (!fs.existsSync(path.join(artifactDir, 'plan.md'))) blockers.add('CHANGE_PLAN_MISSING');
|
|
@@ -434,6 +501,7 @@ function convergeChange(db, input, { rootDir = process.cwd() } = {}) {
|
|
|
434
501
|
ops.tx(db, () => {
|
|
435
502
|
db.prepare("UPDATE changes SET status = 'blocked', updated_at = ? WHERE id = ?")
|
|
436
503
|
.run(nowIso(), change.id);
|
|
504
|
+
if (diagnosis.artifact) upsertArtifact(db, diagnosis.artifact);
|
|
437
505
|
ops.appendEventInTx(db, {
|
|
438
506
|
type: 'change_blocked', change_id: change.id, payload: { blockers: blockerList },
|
|
439
507
|
});
|
|
@@ -452,6 +520,7 @@ function convergeChange(db, input, { rootDir = process.cwd() } = {}) {
|
|
|
452
520
|
ops.tx(db, () => {
|
|
453
521
|
db.prepare("UPDATE changes SET status = 'ready', updated_at = ? WHERE id = ?")
|
|
454
522
|
.run(nowIso(), change.id);
|
|
523
|
+
if (diagnosis.artifact) upsertArtifact(db, diagnosis.artifact);
|
|
455
524
|
upsertArtifact(db, {
|
|
456
525
|
change_id: change.id, kind: 'verification', artifactPath: path.relative(rootDir, verificationPath),
|
|
457
526
|
contentHash: crypto.createHash('sha256').update(fs.readFileSync(verificationPath)).digest('hex'),
|
|
@@ -38,6 +38,14 @@ function standardEvidence() {
|
|
|
38
38
|
];
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
function incidentEvidence() {
|
|
42
|
+
return [
|
|
43
|
+
{ category: 'diagnosis', status: 'pass', evidence: 'Structured diagnosis artifact reviewed.' },
|
|
44
|
+
{ category: 'diff', status: 'pass', evidence: 'Root-cause fix diff reviewed.' },
|
|
45
|
+
{ category: 'tests', status: 'pass', evidence: 'Regression test reproduces then verifies the fix.' },
|
|
46
|
+
];
|
|
47
|
+
}
|
|
48
|
+
|
|
41
49
|
test('createChange persists a change and an inspectable external-provider context manifest', () => {
|
|
42
50
|
const fx = fixture();
|
|
43
51
|
try {
|
|
@@ -257,3 +265,82 @@ test('updateChange rejects whitespace-only title or intent outside the MCP bound
|
|
|
257
265
|
cleanup(fx);
|
|
258
266
|
}
|
|
259
267
|
});
|
|
268
|
+
|
|
269
|
+
test('incident changes create a durable structured diagnosis artifact', () => {
|
|
270
|
+
const fx = fixture();
|
|
271
|
+
try {
|
|
272
|
+
const created = changes.createChange(fx.db, {
|
|
273
|
+
id: 'chg-incident-diagnosis', title: 'Diagnose runtime failure', kind: 'incident',
|
|
274
|
+
intent: 'Reproduce and fix the runtime failure at its root cause.',
|
|
275
|
+
docs_impact: { status: 'none', files: [], rationale: 'Internal runtime repair.' },
|
|
276
|
+
}, { rootDir: fx.rootDir });
|
|
277
|
+
|
|
278
|
+
const diagnosisPath = path.join(path.dirname(created.intent_path), 'diagnosis.md');
|
|
279
|
+
assert.ok(fs.existsSync(diagnosisPath));
|
|
280
|
+
const text = fs.readFileSync(diagnosisPath, 'utf8');
|
|
281
|
+
for (const heading of ['Reproduction', 'Hypotheses', 'Root cause', 'Regression test', 'Recovery']) {
|
|
282
|
+
assert.match(text, new RegExp(`^## ${heading}$`, 'm'));
|
|
283
|
+
}
|
|
284
|
+
const artifact = fx.db.prepare(
|
|
285
|
+
"SELECT kind, path, content_hash FROM artifacts WHERE change_id = ? AND kind = 'diagnosis'",
|
|
286
|
+
).get('chg-incident-diagnosis');
|
|
287
|
+
assert.equal(artifact.kind, 'diagnosis');
|
|
288
|
+
assert.match(artifact.content_hash, /^[0-9a-f]{64}$/);
|
|
289
|
+
assert.equal(path.join(fx.rootDir, artifact.path), diagnosisPath);
|
|
290
|
+
} finally {
|
|
291
|
+
cleanup(fx);
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
test('incident convergence requires every structured diagnosis section and refreshes its artifact hash', () => {
|
|
296
|
+
const fx = fixture();
|
|
297
|
+
try {
|
|
298
|
+
const created = changes.createChange(fx.db, {
|
|
299
|
+
id: 'chg-incident-converge', title: 'Converge runtime diagnosis', kind: 'incident',
|
|
300
|
+
intent: 'Require inspectable debugging evidence before incident closure.',
|
|
301
|
+
docs_impact: { status: 'none', files: [], rationale: 'Internal runtime repair.' },
|
|
302
|
+
}, { rootDir: fx.rootDir });
|
|
303
|
+
ops.createTask(fx.db, {
|
|
304
|
+
id: 'incident-task', title: 'Fix diagnosed failure', type: 'bugfix', priority: 'P0',
|
|
305
|
+
change_id: 'chg-incident-converge',
|
|
306
|
+
});
|
|
307
|
+
ops.updateTaskStatus(fx.db, 'incident-task', 'in_progress');
|
|
308
|
+
ops.updateTaskStatus(fx.db, 'incident-task', 'completed');
|
|
309
|
+
changes.compileContext(fx.db, { id: 'chg-incident-converge' }, { rootDir: fx.rootDir });
|
|
310
|
+
|
|
311
|
+
const blocked = changes.convergeChange(fx.db, {
|
|
312
|
+
id: 'chg-incident-converge', evidence: incidentEvidence(),
|
|
313
|
+
}, { rootDir: fx.rootDir });
|
|
314
|
+
assert.equal(blocked.ready, false);
|
|
315
|
+
assert.ok(blocked.blockers.includes('DIAGNOSIS_SECTION_MISSING:reproduction'));
|
|
316
|
+
assert.ok(blocked.blockers.includes('DIAGNOSIS_SECTION_MISSING:root-cause'));
|
|
317
|
+
|
|
318
|
+
const diagnosisPath = path.join(path.dirname(created.intent_path), 'diagnosis.md');
|
|
319
|
+
const beforeHash = fx.db.prepare(
|
|
320
|
+
"SELECT content_hash FROM artifacts WHERE change_id = ? AND kind = 'diagnosis'",
|
|
321
|
+
).get('chg-incident-converge').content_hash;
|
|
322
|
+
fs.writeFileSync(diagnosisPath, [
|
|
323
|
+
'# Incident diagnosis: Converge runtime diagnosis', '',
|
|
324
|
+
'## Reproduction', '', 'The projection worker fails after an interrupted claim.', '',
|
|
325
|
+
'## Hypotheses', '', 'A stale running job is never returned to the pending queue.', '',
|
|
326
|
+
'## Root cause', '', 'Boot recovery did not consume interrupted projection state.', '',
|
|
327
|
+
'## Regression test', '', 'The test seeds a stale running job and verifies requeue.', '',
|
|
328
|
+
'## Recovery', '', 'Requeue the job after the stale cutoff and replay projection.', '',
|
|
329
|
+
].join('\n'));
|
|
330
|
+
|
|
331
|
+
const ready = changes.convergeChange(fx.db, {
|
|
332
|
+
id: 'chg-incident-converge', evidence: incidentEvidence(),
|
|
333
|
+
}, { rootDir: fx.rootDir });
|
|
334
|
+
assert.equal(ready.ready, true);
|
|
335
|
+
const afterHash = fx.db.prepare(
|
|
336
|
+
"SELECT content_hash FROM artifacts WHERE change_id = ? AND kind = 'diagnosis'",
|
|
337
|
+
).get('chg-incident-converge').content_hash;
|
|
338
|
+
assert.notEqual(afterHash, beforeHash);
|
|
339
|
+
assert.equal(
|
|
340
|
+
afterHash,
|
|
341
|
+
require('node:crypto').createHash('sha256').update(fs.readFileSync(diagnosisPath)).digest('hex'),
|
|
342
|
+
);
|
|
343
|
+
} finally {
|
|
344
|
+
cleanup(fx);
|
|
345
|
+
}
|
|
346
|
+
});
|
|
@@ -9,7 +9,7 @@ const REPO_ROOT = process.env.UBP_RUNTIME_ROOT
|
|
|
9
9
|
? path.resolve(process.env.UBP_RUNTIME_ROOT)
|
|
10
10
|
: path.resolve(__dirname, '..', '..');
|
|
11
11
|
const SCHEMA_FILE = path.join(REPO_ROOT, 'spec', 'schemas', 'state-db.sql');
|
|
12
|
-
const EXPECTED_VERSION = '9.
|
|
12
|
+
const EXPECTED_VERSION = '9.1';
|
|
13
13
|
|
|
14
14
|
const REQUIRED_TABLES = Object.freeze([
|
|
15
15
|
'tasks',
|
|
@@ -87,14 +87,97 @@ function applyCompatibleColumns(db) {
|
|
|
87
87
|
}
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
+
function tableSupportsKimi(db, table) {
|
|
91
|
+
const row = db.prepare(
|
|
92
|
+
"SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?",
|
|
93
|
+
).get(table);
|
|
94
|
+
return typeof row?.sql === 'string' && row.sql.includes("'kimi'");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function upgradeRuntimeConstraints(db) {
|
|
98
|
+
if (tableSupportsKimi(db, 'events') && tableSupportsKimi(db, 'sessions')) return false;
|
|
99
|
+
const previousVersion = db.prepare(
|
|
100
|
+
'SELECT version FROM schema_version ORDER BY applied_at DESC LIMIT 1',
|
|
101
|
+
).get()?.version || 'unknown';
|
|
102
|
+
const foreignKeys = db.pragma('foreign_keys', { simple: true });
|
|
103
|
+
db.pragma('foreign_keys = OFF');
|
|
104
|
+
try {
|
|
105
|
+
db.transaction(() => {
|
|
106
|
+
db.exec(`
|
|
107
|
+
DROP TABLE IF EXISTS events_runtime_upgrade;
|
|
108
|
+
CREATE TABLE events_runtime_upgrade (
|
|
109
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
110
|
+
ts TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
|
111
|
+
type TEXT NOT NULL,
|
|
112
|
+
task_id TEXT,
|
|
113
|
+
change_id TEXT,
|
|
114
|
+
session_id TEXT,
|
|
115
|
+
runtime TEXT CHECK (runtime IS NULL OR runtime IN ('claude', 'opencode', 'codex', 'kimi')),
|
|
116
|
+
payload_json TEXT
|
|
117
|
+
);
|
|
118
|
+
INSERT INTO events_runtime_upgrade
|
|
119
|
+
(id, ts, type, task_id, change_id, session_id, runtime, payload_json)
|
|
120
|
+
SELECT id, ts, type, task_id, change_id, session_id, runtime, payload_json FROM events;
|
|
121
|
+
DROP TABLE events;
|
|
122
|
+
ALTER TABLE events_runtime_upgrade RENAME TO events;
|
|
123
|
+
CREATE INDEX events_ts_type ON events(ts, type);
|
|
124
|
+
CREATE INDEX events_task ON events(task_id, id);
|
|
125
|
+
CREATE INDEX events_session ON events(session_id, id);
|
|
126
|
+
CREATE INDEX events_change ON events(change_id, id);
|
|
127
|
+
|
|
128
|
+
DROP TABLE IF EXISTS sessions_runtime_upgrade;
|
|
129
|
+
CREATE TABLE sessions_runtime_upgrade (
|
|
130
|
+
sid TEXT PRIMARY KEY,
|
|
131
|
+
task_id TEXT NOT NULL REFERENCES tasks(id),
|
|
132
|
+
runtime TEXT NOT NULL CHECK (runtime IN ('claude', 'opencode', 'codex', 'kimi')),
|
|
133
|
+
pid INTEGER,
|
|
134
|
+
worktree_path TEXT NOT NULL,
|
|
135
|
+
artifact_dir TEXT NOT NULL,
|
|
136
|
+
status TEXT NOT NULL DEFAULT 'running'
|
|
137
|
+
CHECK (status IN ('running', 'completed', 'crashed', 'orphan')),
|
|
138
|
+
lease_expires_at TEXT NOT NULL,
|
|
139
|
+
heartbeat_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
|
140
|
+
started_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
|
141
|
+
);
|
|
142
|
+
INSERT INTO sessions_runtime_upgrade
|
|
143
|
+
(sid, task_id, runtime, pid, worktree_path, artifact_dir, status,
|
|
144
|
+
lease_expires_at, heartbeat_at, started_at)
|
|
145
|
+
SELECT sid, task_id, runtime, pid, worktree_path, artifact_dir, status,
|
|
146
|
+
lease_expires_at, heartbeat_at, started_at FROM sessions;
|
|
147
|
+
DROP TABLE sessions;
|
|
148
|
+
ALTER TABLE sessions_runtime_upgrade RENAME TO sessions;
|
|
149
|
+
CREATE INDEX sessions_active ON sessions(status, task_id);
|
|
150
|
+
CREATE INDEX sessions_lease ON sessions(lease_expires_at) WHERE status = 'running';
|
|
151
|
+
`);
|
|
152
|
+
db.prepare(
|
|
153
|
+
`INSERT INTO migration_history
|
|
154
|
+
(from_version, to_version, direction, status, notes)
|
|
155
|
+
VALUES (?, ?, 'forward', 'success', ?)`,
|
|
156
|
+
).run(
|
|
157
|
+
previousVersion,
|
|
158
|
+
EXPECTED_VERSION,
|
|
159
|
+
'Add Kimi to durable event and session runtime constraints',
|
|
160
|
+
);
|
|
161
|
+
const violations = db.pragma('foreign_key_check');
|
|
162
|
+
if (violations.length > 0) {
|
|
163
|
+
throw new Error(`runtime constraint migration produced ${violations.length} foreign key violation(s)`);
|
|
164
|
+
}
|
|
165
|
+
})();
|
|
166
|
+
} finally {
|
|
167
|
+
db.pragma(`foreign_keys = ${foreignKeys ? 'ON' : 'OFF'}`);
|
|
168
|
+
}
|
|
169
|
+
return true;
|
|
170
|
+
}
|
|
171
|
+
|
|
90
172
|
function applyCompatibleUpgrades(db) {
|
|
173
|
+
upgradeRuntimeConstraints(db);
|
|
91
174
|
db.transaction(() => {
|
|
92
175
|
applyCompatibleColumns(db);
|
|
93
176
|
db.exec('CREATE INDEX IF NOT EXISTS tasks_change ON tasks(change_id) WHERE change_id IS NOT NULL');
|
|
94
177
|
db.exec('CREATE INDEX IF NOT EXISTS events_change ON events(change_id, id)');
|
|
95
178
|
db.prepare(
|
|
96
179
|
'INSERT OR IGNORE INTO schema_version (version, description) VALUES (?, ?)',
|
|
97
|
-
).run(EXPECTED_VERSION, '
|
|
180
|
+
).run(EXPECTED_VERSION, 'Kimi runtime support with durable event and session state');
|
|
98
181
|
})();
|
|
99
182
|
}
|
|
100
183
|
|