ultra-builder-pro-cli 0.7.0 → 0.8.1
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 +29 -1
- package/README.md +15 -3
- package/adapters/_shared/handbook.cjs +1 -0
- package/adapters/_shared/provenance.cjs +286 -0
- package/adapters/_shared/tests/provenance.test.cjs +133 -0
- package/adapters/claude.js +33 -0
- package/adapters/codex.js +87 -0
- package/adapters/opencode.js +66 -0
- package/adapters/tests/codex.test.cjs +37 -0
- package/agents/debugger.md +12 -0
- package/bin/install.js +63 -8
- package/commands/ultra-change.md +3 -2
- package/docs/ARCHITECTURE.md +37 -3
- package/docs/PLAN.zh-CN.md +6 -4
- package/docs/RUNTIME-COMPAT-MATRIX.md +8 -1
- package/hooks/README.md +6 -2
- 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/package.json +1 -1
- 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/spec/mcp-tools.yaml +2 -2
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
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ultra-builder-pro-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.1",
|
|
4
4
|
"description": "Multi-runtime installer for the Ultra Builder Pro agent engineering system — deploys commands, agents, skills, hooks, and MCP server to Claude Code, OpenCode, and Codex CLI.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"ultra-builder-pro-cli": "bin/install.js",
|
|
@@ -58,7 +58,7 @@ Choose the smallest honest kind:
|
|
|
58
58
|
| `quick` | localized fix or tiny behavior change | diff, tests, spec |
|
|
59
59
|
| `standard` | normal feature or cross-file change | diff, tests, spec, docs, review |
|
|
60
60
|
| `major` | architecture, public contract, migration, or broad redesign | diff, tests, spec, docs, review |
|
|
61
|
-
| `incident` | production/runtime failure
|
|
61
|
+
| `incident` | canonical debug lane for a production/runtime failure | diagnosis, diff, tests |
|
|
62
62
|
|
|
63
63
|
When uncertain between two kinds, choose the higher-risk kind. Classification
|
|
64
64
|
changes the proof required; it must not be used to bypass evidence.
|
|
@@ -91,14 +91,18 @@ The change directory is the bounded execution packet:
|
|
|
91
91
|
context-manifest.json
|
|
92
92
|
delta/ # required for standard and major
|
|
93
93
|
plan.md # required for standard and major
|
|
94
|
+
diagnosis.md # created and structurally required for incident
|
|
94
95
|
verification.md # generated by convergence
|
|
95
96
|
```
|
|
96
97
|
|
|
97
98
|
For `standard` and `major`, write delta documents under `delta/` describing
|
|
98
99
|
what changes relative to the current baseline. Do not copy the whole baseline.
|
|
99
100
|
Write `plan.md` with live entry points, files/contracts, tests, documentation,
|
|
100
|
-
and rollback. For `quick
|
|
101
|
-
|
|
101
|
+
and rollback. For `quick`, add only artifacts needed to make the decision and
|
|
102
|
+
evidence inspectable. For `incident`, complete the generated `diagnosis.md` with
|
|
103
|
+
non-empty `Reproduction`, `Hypotheses`, `Root cause`, `Regression test`, and
|
|
104
|
+
`Recovery` sections. Free-form convergence evidence never substitutes for this
|
|
105
|
+
durable diagnosis artifact.
|
|
102
106
|
|
|
103
107
|
Create at least one task through `task.create` with `change_id` set. Split only
|
|
104
108
|
when ownership, dependencies, or independent verification justify it. All task
|
|
@@ -116,6 +120,11 @@ context, not permission to silently refresh or mutate the external provider.
|
|
|
116
120
|
|
|
117
121
|
## 6. Route execution
|
|
118
122
|
|
|
123
|
+
- For `incident`, diagnosis is the first execution slice. Route it to the bundled
|
|
124
|
+
`debugger` agent when the Host exposes native subagents; otherwise the primary
|
|
125
|
+
Host follows the same four-phase method. Supply only the compiled change/task
|
|
126
|
+
context. The primary Host owns the fix, updates `diagnosis.md`, and reruns the
|
|
127
|
+
discriminating regression test.
|
|
119
128
|
- Implement one linked task with `/ultra-dev <task-id>`.
|
|
120
129
|
- Run `/ultra-test` for project-level evidence.
|
|
121
130
|
- Run `/ultra-review` when the change kind requires review.
|
|
@@ -141,7 +141,9 @@ evidence required by kind:
|
|
|
141
141
|
|
|
142
142
|
- `quick`: diff, tests, spec;
|
|
143
143
|
- `standard` / `major`: diff, tests, spec, docs, review;
|
|
144
|
-
- `incident`: diagnosis, diff, tests.
|
|
144
|
+
- `incident`: diagnosis, diff, tests. The diagnosis evidence must point to the
|
|
145
|
+
completed `diagnosis.md`; convergence separately validates all five required
|
|
146
|
+
debugging sections and cannot be satisfied by the evidence string alone.
|
|
145
147
|
|
|
146
148
|
Evidence must name concrete commands, artifacts, or file paths. If blockers are
|
|
147
149
|
returned, stop before tag/push and route to the owning workflow. Do not weaken
|
|
@@ -66,9 +66,16 @@ remaining incident unresolved.
|
|
|
66
66
|
For an installed hook that points to a missing cached file:
|
|
67
67
|
|
|
68
68
|
1. capture the exact hook config path and missing target;
|
|
69
|
-
2.
|
|
70
|
-
|
|
71
|
-
|
|
69
|
+
2. run the separate read-only installer boundary check:
|
|
70
|
+
`npx ultra-builder-pro-cli --<runtime> --local --doctor --json` (or use
|
|
71
|
+
`--global` when that is the actual install scope);
|
|
72
|
+
3. preserve its provenance, asset-hash, and host-contract findings;
|
|
73
|
+
4. reinstall or regenerate the host-native plugin through the adapter;
|
|
74
|
+
5. rerun both installer doctor and `system.doctor`, then a host hook smoke test.
|
|
75
|
+
|
|
76
|
+
`system.doctor` diagnoses project `.ultra/state.db` and workflow recovery.
|
|
77
|
+
Installer `ubp --doctor` diagnoses the installed plugin files and host wiring.
|
|
78
|
+
Neither report substitutes for the other.
|
|
72
79
|
|
|
73
80
|
Do not bypass a broken hook by changing application implementation, and do not
|
|
74
81
|
treat a hook failure as project-code evidence.
|
package/spec/mcp-tools.yaml
CHANGED
|
@@ -630,7 +630,7 @@ tools:
|
|
|
630
630
|
# ────────────────────────── change family ──────────────────────────────
|
|
631
631
|
- name: change.create
|
|
632
632
|
family: change
|
|
633
|
-
description: Create a continuous feature, fix, redesign, or incident change unit and its initial context manifest.
|
|
633
|
+
description: Create a continuous feature, fix, redesign, or incident change unit and its initial context manifest; incidents also register a five-section diagnosis artifact.
|
|
634
634
|
phase: '9'
|
|
635
635
|
writer_role: mcp
|
|
636
636
|
input_schema:
|
|
@@ -860,7 +860,7 @@ tools:
|
|
|
860
860
|
|
|
861
861
|
- name: change.converge
|
|
862
862
|
family: change
|
|
863
|
-
description: Deterministically verify task, spec, test, documentation, review, and
|
|
863
|
+
description: Deterministically verify task, spec, test, documentation, review, context, and incident diagnosis structure before closure.
|
|
864
864
|
phase: '9'
|
|
865
865
|
writer_role: mcp
|
|
866
866
|
input_schema:
|