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.
@@ -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
- if not state_file.is_file():
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
- try:
24
- state = json.loads(state_file.read_text(encoding="utf-8"))
25
- except (OSError, json.JSONDecodeError) as exc:
26
- print(f"[workflow_resume] cannot read {state_file}: {exc}", file=sys.stderr)
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
- if not isinstance(state, dict) or state.get("status") in TERMINAL:
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 .ultra/workflow-state.json and verify state through .ultra/state.db."
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.7.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 requiring diagnosis | diagnosis, diff, tests |
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` and `incident`, add only artifacts needed to make the
101
- decision and evidence inspectable.
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. verify the currently installed plugin/cache version;
70
- 3. reinstall or regenerate the host-native plugin through the adapter;
71
- 4. rerun read-only doctor and a host hook smoke test.
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.
@@ -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 context evidence before closure.
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: