ultra-builder-pro-cli 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/CHANGELOG.md +92 -1
  2. package/README.md +11 -6
  3. package/adapters/_shared/codex-assets.cjs +23 -331
  4. package/adapters/_shared/handbook.cjs +2 -0
  5. package/adapters/_shared/runtime-assets.cjs +25 -0
  6. package/adapters/_shared/tests/handbook.test.cjs +3 -0
  7. package/adapters/_shared/tests/runtime-assets.test.cjs +12 -4
  8. package/adapters/claude.js +28 -5
  9. package/adapters/kimi.js +15 -138
  10. package/adapters/opencode.js +59 -63
  11. package/adapters/tests/claude.test.cjs +26 -9
  12. package/adapters/tests/codex.test.cjs +23 -9
  13. package/adapters/tests/kimi.test.cjs +15 -11
  14. package/adapters/tests/opencode.test.cjs +57 -10
  15. package/agents/code-reviewer.md +24 -85
  16. package/agents/debugger.md +30 -132
  17. package/agents/review-code.md +24 -104
  18. package/agents/review-comments.md +20 -83
  19. package/agents/review-coordinator.md +40 -137
  20. package/agents/review-design.md +21 -118
  21. package/agents/review-errors.md +22 -70
  22. package/agents/review-spec.md +36 -0
  23. package/agents/review-tests.md +20 -76
  24. package/agents/tdd-runner.md +26 -84
  25. package/commands/learn.md +5 -36
  26. package/commands/ultra-change.md +5 -43
  27. package/commands/ultra-deliver.md +4 -46
  28. package/commands/ultra-dev.md +4 -48
  29. package/commands/ultra-doctor.md +4 -30
  30. package/commands/ultra-init.md +5 -47
  31. package/commands/ultra-plan.md +5 -48
  32. package/commands/ultra-research.md +5 -54
  33. package/commands/ultra-status.md +5 -44
  34. package/commands/ultra-test.md +5 -45
  35. package/commands/ultra-think.md +5 -40
  36. package/docs/AGENT-CONTEXT.md +34 -12
  37. package/docs/ARCHITECTURE.md +31 -5
  38. package/docs/PLAN.zh-CN.md +3 -2
  39. package/docs/ROADMAP.md +5 -4
  40. package/docs/RUNTIME-COMPAT-MATRIX.md +7 -5
  41. package/docs/SKILL-AUTHORING.md +75 -0
  42. package/docs/USER-HANDBOOK-CONTRACT.md +9 -2
  43. package/hooks/README.md +6 -4
  44. package/hooks/active_task_context.py +14 -0
  45. package/hooks/context_spine.py +218 -0
  46. package/hooks/health_check.py +1 -1
  47. package/hooks/workflow_context.py +16 -123
  48. package/hooks/workflow_resume.py +21 -0
  49. package/mcp-server/lib/change-workflow.cjs +89 -11
  50. package/mcp-server/lib/change-workflow.test.cjs +202 -7
  51. package/mcp-server/lib/context-spine.cjs +331 -0
  52. package/mcp-server/lib/context-spine.test.cjs +73 -0
  53. package/mcp-server/lib/spec-learning.cjs +191 -0
  54. package/mcp-server/lib/spec-learning.test.cjs +62 -0
  55. package/mcp-server/lib/state-db.cjs +72 -10
  56. package/mcp-server/lib/state-db.test.cjs +7 -2
  57. package/mcp-server/server.cjs +31 -1
  58. package/mcp-server/tests/db-ops.test.cjs +3 -3
  59. package/mcp-server/tests/server-contract.test.cjs +74 -0
  60. package/mcp-server/tests/state-db.test.cjs +20 -3
  61. package/package.json +3 -2
  62. package/skills/cc-collab/SKILL.md +16 -18
  63. package/skills/code-review-expert/SKILL.md +39 -114
  64. package/skills/code-review-expert/references/correctness-reliability.md +30 -0
  65. package/skills/code-review-expert/references/design-boundaries.md +26 -0
  66. package/skills/code-review-expert/references/removal-plan.md +17 -45
  67. package/skills/codex-collab/SKILL.md +23 -28
  68. package/skills/codex-collab/evals/evals.json +18 -23
  69. package/skills/integration-rules/SKILL.md +28 -67
  70. package/skills/learn/SKILL.md +46 -105
  71. package/skills/security-rules/SKILL.md +34 -56
  72. package/skills/testing-rules/SKILL.md +28 -52
  73. package/skills/ultra-change/SKILL.md +61 -145
  74. package/skills/ultra-deliver/SKILL.md +63 -229
  75. package/skills/ultra-dev/SKILL.md +46 -338
  76. package/skills/ultra-doctor/SKILL.md +39 -71
  77. package/skills/ultra-init/SKILL.md +59 -139
  78. package/skills/ultra-plan/SKILL.md +50 -310
  79. package/skills/ultra-research/SKILL.md +59 -152
  80. package/skills/ultra-research/references/architecture.md +38 -0
  81. package/skills/ultra-research/references/problem-product.md +39 -0
  82. package/skills/ultra-research/references/quality-delivery.md +34 -0
  83. package/skills/ultra-research/references/synthesis.md +38 -0
  84. package/skills/ultra-research/references/users-scope.md +40 -0
  85. package/skills/ultra-review/SKILL.md +55 -427
  86. package/skills/ultra-review/references/unified-schema.md +76 -101
  87. package/skills/ultra-review/scripts/review_wait.py +242 -77
  88. package/skills/ultra-status/SKILL.md +38 -173
  89. package/skills/ultra-test/SKILL.md +41 -195
  90. package/skills/ultra-think/SKILL.md +43 -101
  91. package/skills/ultra-verify/SKILL.md +44 -84
  92. package/skills/ultra-verify/evals/evals.json +24 -27
  93. package/spec/cli-protocol.md +3 -0
  94. package/spec/mcp-tools.yaml +133 -4
  95. package/spec/schemas/command-manifest.schema.json +7 -25
  96. package/spec/schemas/skill-manifest.schema.json +9 -79
  97. package/spec/schemas/state-db.sql +31 -0
  98. package/skills/code-review-expert/references/code-quality-checklist.md +0 -112
  99. package/skills/code-review-expert/references/integration-checklist.md +0 -46
  100. package/skills/code-review-expert/references/security-checklist.md +0 -100
  101. package/skills/code-review-expert/references/solid-checklist.md +0 -65
  102. package/skills/ultra-research/steps/step-00-problem-validation.md +0 -154
  103. package/skills/ultra-research/steps/step-01-opportunity-discovery.md +0 -172
  104. package/skills/ultra-research/steps/step-02-market-assessment.md +0 -195
  105. package/skills/ultra-research/steps/step-03-competitive-landscape.md +0 -197
  106. package/skills/ultra-research/steps/step-04-product-strategy.md +0 -196
  107. package/skills/ultra-research/steps/step-05-assumptions-validation.md +0 -234
  108. package/skills/ultra-research/steps/step-10-user-personas.md +0 -169
  109. package/skills/ultra-research/steps/step-11-user-scenarios.md +0 -181
  110. package/skills/ultra-research/steps/step-20-user-stories.md +0 -156
  111. package/skills/ultra-research/steps/step-21-features-scope.md +0 -157
  112. package/skills/ultra-research/steps/step-22-success-metrics.md +0 -195
  113. package/skills/ultra-research/steps/step-30-architecture-context.md +0 -154
  114. package/skills/ultra-research/steps/step-31-solution-strategy.md +0 -172
  115. package/skills/ultra-research/steps/step-32-building-blocks.md +0 -188
  116. package/skills/ultra-research/steps/step-40-deployment.md +0 -123
  117. package/skills/ultra-research/steps/step-41-quality-risks.md +0 -135
  118. package/skills/ultra-research/steps/step-99-synthesis.md +0 -188
  119. package/skills/ultra-review/scripts/review_verdict_update.py +0 -99
@@ -2,9 +2,12 @@
2
2
  """Protect Ultra projections and restate an active task boundary before edits."""
3
3
 
4
4
  import json
5
+ import sqlite3
5
6
  import sys
6
7
  from pathlib import Path
7
8
 
9
+ from context_spine import read_breadcrumb, render_breadcrumb
10
+
8
11
  TERMINAL = {"committed", "completed", "done", "cancelled"}
9
12
  TASKS_PROJECTION = Path(".ultra/tasks/tasks.json")
10
13
 
@@ -53,6 +56,17 @@ def main() -> None:
53
56
  ),
54
57
  }}))
55
58
  return
59
+ try:
60
+ breadcrumb = read_breadcrumb(root)
61
+ except (sqlite3.Error, OSError) as exc:
62
+ print(f"[active_task_context] cannot inspect Context Spine: {exc}", file=sys.stderr)
63
+ breadcrumb = None
64
+ if breadcrumb and breadcrumb.get("task_id"):
65
+ print(json.dumps({"hookSpecificOutput": {
66
+ "hookEventName": "PreToolUse",
67
+ "additionalContext": render_breadcrumb(root, breadcrumb),
68
+ }}))
69
+ return
56
70
  if not state_file.is_file():
57
71
  break
58
72
  try:
@@ -0,0 +1,218 @@
1
+ #!/usr/bin/env python3
2
+ """Read and render the compact DB-derived Ultra Context Spine breadcrumb."""
3
+
4
+ import json
5
+ import sqlite3
6
+ import subprocess
7
+ from pathlib import Path
8
+
9
+
10
+ ACTIVE_CHANGE_STATUSES = ("active", "blocked", "ready")
11
+ TERMINAL_TASK_STATUSES = ("completed", "expanded")
12
+ CONTEXT_COLUMNS = {
13
+ "role", "gate", "next_action", "readiness", "blockers_json",
14
+ "context_json", "manifest_path", "manifest_hash", "git_head", "task_id",
15
+ }
16
+
17
+
18
+ def find_root(start: Path):
19
+ for root in (start, *start.parents):
20
+ ultra = root / ".ultra"
21
+ if (
22
+ (ultra / "state.db").is_file()
23
+ or (ultra / "workflow-state.json").is_file()
24
+ or (ultra / "changes" / "active").is_dir()
25
+ ):
26
+ return root
27
+ return None
28
+
29
+
30
+ def safe_json(raw, fallback):
31
+ if not isinstance(raw, str) or not raw.strip():
32
+ return fallback
33
+ try:
34
+ value = json.loads(raw)
35
+ return value
36
+ except json.JSONDecodeError:
37
+ return fallback
38
+
39
+
40
+ def git_head(root: Path):
41
+ result = subprocess.run(
42
+ ["git", "rev-parse", "HEAD"],
43
+ cwd=root,
44
+ text=True,
45
+ capture_output=True,
46
+ check=False,
47
+ timeout=2,
48
+ )
49
+ return result.stdout.strip() if result.returncode == 0 else None
50
+
51
+
52
+ def _next_action(change, task, tasks, role, readiness, stored, state_changed):
53
+ if readiness != "ready":
54
+ return "Resolve the context readiness blockers, then recompile change.context."
55
+ if not state_changed and isinstance(stored, str) and stored.strip():
56
+ return stored.strip()
57
+ if change["status"] == "ready":
58
+ return f"Run ultra-deliver for change {change['id']}."
59
+ if change["status"] == "blocked":
60
+ return f"Resolve the blockers for change {change['id']}, then recompile context."
61
+ if task and task["status"] == "pending":
62
+ return f"Start task {task['id']} through ultra-dev."
63
+ if task and task["status"] == "in_progress":
64
+ if role == "check":
65
+ return f"Run the exact verification command for task {task['id']}."
66
+ if role == "review":
67
+ return (
68
+ "Complete independent spec-fidelity and engineering-standards "
69
+ f"review for task {task['id']}."
70
+ )
71
+ return f"Continue the approved vertical slice for task {task['id']}."
72
+ if tasks and all(row["status"] in TERMINAL_TASK_STATUSES for row in tasks):
73
+ return f"Run ultra-test for change {change['id']}."
74
+ pending = next((row for row in tasks if row["status"] == "pending"), None)
75
+ if pending:
76
+ return f"Compile implement context for task {pending['id']}."
77
+ return f"Create the first fresh-context tracer-bullet task for change {change['id']}."
78
+
79
+
80
+ def _route(change, task, tasks, readiness):
81
+ if readiness != "ready" or change["status"] == "blocked":
82
+ return "ultra-change"
83
+ if change["status"] == "ready":
84
+ return "ultra-deliver"
85
+ if task and task["status"] in ("pending", "in_progress"):
86
+ return "ultra-dev"
87
+ if tasks and all(row["status"] in TERMINAL_TASK_STATUSES for row in tasks):
88
+ return "ultra-test"
89
+ return "ultra-change"
90
+
91
+
92
+ def read_breadcrumb(root: Path):
93
+ db_path = root / ".ultra" / "state.db"
94
+ if not db_path.is_file():
95
+ return None
96
+ with sqlite3.connect(f"file:{db_path}?mode=ro", uri=True, timeout=1) as conn:
97
+ conn.row_factory = sqlite3.Row
98
+ tables = {
99
+ row[0]
100
+ for row in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
101
+ }
102
+ if not {"changes", "tasks", "context_snapshots"}.issubset(tables):
103
+ return None
104
+ change = conn.execute(
105
+ """SELECT id, status FROM changes
106
+ WHERE status IN ('active', 'blocked', 'ready')
107
+ ORDER BY updated_at DESC LIMIT 1"""
108
+ ).fetchone()
109
+ if change is None:
110
+ return None
111
+ tasks = list(conn.execute(
112
+ "SELECT id, status, session_id FROM tasks WHERE change_id = ? ORDER BY created_at ASC",
113
+ (change["id"],),
114
+ ))
115
+ columns = {
116
+ row[1] for row in conn.execute("PRAGMA table_info(context_snapshots)")
117
+ }
118
+ snapshot = None
119
+ if CONTEXT_COLUMNS.issubset(columns):
120
+ snapshot = conn.execute(
121
+ """SELECT task_id, git_head, manifest_path, manifest_hash, role, gate,
122
+ next_action, readiness, blockers_json, context_json
123
+ FROM context_snapshots WHERE change_id = ?
124
+ ORDER BY created_at DESC, rowid DESC LIMIT 1""",
125
+ (change["id"],),
126
+ ).fetchone()
127
+
128
+ task_id = snapshot["task_id"] if snapshot else None
129
+ if not task_id:
130
+ active = next((row for row in tasks if row["status"] == "in_progress"), None)
131
+ pending = next((row for row in tasks if row["status"] == "pending"), None)
132
+ task_id = (active or pending)["id"] if (active or pending) else None
133
+ task = next((row for row in tasks if row["id"] == task_id), None)
134
+ context = safe_json(snapshot["context_json"], {}) if snapshot else {}
135
+ role = snapshot["role"] if snapshot else "plan"
136
+ gate = snapshot["gate"] if snapshot else "alignment"
137
+ current_head = git_head(root)
138
+ head_stale = bool(
139
+ snapshot and snapshot["git_head"] and current_head
140
+ and snapshot["git_head"] != current_head
141
+ )
142
+ snapshot_missing = snapshot is None
143
+ legacy_context = bool(
144
+ snapshot and (
145
+ not isinstance(context.get("resume"), dict)
146
+ or not isinstance(context.get("context"), dict)
147
+ or not isinstance(context.get("readiness"), dict)
148
+ )
149
+ )
150
+ stored_status = context.get("resume", {}).get("task_status")
151
+ current_status = task["status"] if task else None
152
+ state_changed = stored_status is not None and stored_status != current_status
153
+ stored_action = snapshot["next_action"] if snapshot else None
154
+ blockers = []
155
+ if snapshot_missing:
156
+ blockers.append("CONTEXT_NOT_COMPILED")
157
+ else:
158
+ if legacy_context:
159
+ blockers.append("CONTEXT_SNAPSHOT_UPGRADE_REQUIRED")
160
+ if head_stale:
161
+ blockers.append("CONTEXT_HEAD_STALE")
162
+ if state_changed:
163
+ blockers.append("CONTEXT_TASK_STATE_STALE")
164
+ for blocker in safe_json(snapshot["blockers_json"], []):
165
+ if blocker not in blockers:
166
+ blockers.append(blocker)
167
+ readiness = "blocked" if blockers else snapshot["readiness"]
168
+ if snapshot_missing:
169
+ next_action = f"Compile change.context for change {change['id']} before continuing."
170
+ elif legacy_context:
171
+ next_action = "Recompile change.context to upgrade this legacy context snapshot."
172
+ elif head_stale:
173
+ next_action = "Git HEAD changed after context compilation; recompile change.context."
174
+ elif state_changed:
175
+ next_action = "Task state changed after context compilation; recompile change.context."
176
+ else:
177
+ next_action = _next_action(change, task, tasks, role, readiness, stored_action, False)
178
+ return {
179
+ "change_id": change["id"],
180
+ "change_status": change["status"],
181
+ "task_id": task["id"] if task else None,
182
+ "task_status": task["status"] if task else None,
183
+ "session_id": task["session_id"] if task else None,
184
+ "role": role,
185
+ "gate": gate,
186
+ "readiness": readiness,
187
+ "blockers": blockers,
188
+ "next_action": next_action,
189
+ "recommended_workflow": _route(change, task, tasks, readiness),
190
+ "context_manifest_path": snapshot["manifest_path"] if snapshot else None,
191
+ "context_manifest_hash": snapshot["manifest_hash"] if snapshot else None,
192
+ "git_head": current_head,
193
+ }
194
+
195
+
196
+ def render_breadcrumb(root: Path, breadcrumb: dict) -> str:
197
+ task = breadcrumb.get("task_id") or "none"
198
+ task_status = breadcrumb.get("task_status") or "none"
199
+ lines = [
200
+ "[Ultra context spine]",
201
+ f"Project: {root}",
202
+ f"Change: {breadcrumb['change_id']} ({breadcrumb.get('change_status', 'active')})",
203
+ f"Task: {task} ({task_status})",
204
+ f"Role: {breadcrumb.get('role', 'plan')}",
205
+ f"Gate: {breadcrumb.get('gate', 'alignment')}",
206
+ f"Readiness: {breadcrumb.get('readiness', 'ready')}",
207
+ ]
208
+ if breadcrumb.get("blockers"):
209
+ lines.append("Blockers: " + ", ".join(breadcrumb["blockers"]))
210
+ lines.extend([
211
+ f"Next: {breadcrumb.get('next_action', 'Inspect Ultra status.')}",
212
+ f"Route: {breadcrumb.get('recommended_workflow', 'ultra-change')}",
213
+ ])
214
+ if breadcrumb.get("context_manifest_path"):
215
+ digest = breadcrumb.get("context_manifest_hash") or "unknown"
216
+ lines.append(f"Context: {breadcrumb['context_manifest_path']} sha256={digest}")
217
+ lines.append("Authority: .ultra/state.db; JSON/Markdown remain projections or evidence artifacts.")
218
+ return "\n".join(lines)
@@ -10,7 +10,7 @@ from pathlib import Path
10
10
  REQUIRED_TABLES = {
11
11
  "tasks", "events", "sessions", "schema_version", "migration_history",
12
12
  "telemetry", "specs_refs", "circuit_breaker", "changes", "artifacts",
13
- "context_snapshots", "trace_links", "incidents", "projection_jobs",
13
+ "context_snapshots", "spec_learning_candidates", "trace_links", "incidents", "projection_jobs",
14
14
  "event_consumers",
15
15
  }
16
16
 
@@ -1,16 +1,12 @@
1
1
  #!/usr/bin/env python3
2
- """Inject current Ultra workflow/change context without persistent memory."""
2
+ """Inject one compact Ultra state position without persistent memory."""
3
3
 
4
4
  import json
5
5
  import sqlite3
6
6
  import sys
7
7
  from pathlib import Path
8
8
 
9
-
10
- TERMINAL_WORKFLOW = {"committed", "completed", "done", "cancelled"}
11
- PROVIDER_FIELDS = {
12
- "provider", "project", "reference", "revision", "indexed_head", "status",
13
- }
9
+ from context_spine import find_root, read_breadcrumb, render_breadcrumb
14
10
 
15
11
 
16
12
  def payload() -> dict:
@@ -22,132 +18,29 @@ def payload() -> dict:
22
18
  return {}
23
19
 
24
20
 
25
- def find_root(start: Path):
26
- for root in (start, *start.parents):
27
- ultra = root / ".ultra"
28
- if (
29
- (ultra / "state.db").is_file()
30
- or (ultra / "workflow-state.json").is_file()
31
- or (ultra / "changes" / "active").is_dir()
32
- ):
33
- return root
34
- return None
35
-
36
-
37
- def read_workflow(root: Path):
38
- state_file = root / ".ultra" / "workflow-state.json"
39
- if not state_file.is_file():
40
- return None
41
- try:
42
- state = json.loads(state_file.read_text(encoding="utf-8"))
43
- except (OSError, json.JSONDecodeError) as exc:
44
- print(f"[workflow_context] cannot read {state_file}: {exc}", file=sys.stderr)
45
- return None
46
- if isinstance(state, dict) and state.get("status") not in TERMINAL_WORKFLOW:
47
- return state
48
- return None
49
-
50
-
51
- def safe_json(raw, fallback):
52
- try:
53
- value = json.loads(raw)
54
- return value
55
- except (TypeError, json.JSONDecodeError):
56
- return fallback
57
-
58
-
59
- def provider_metadata(raw) -> dict:
60
- refs = safe_json(raw, {})
61
- if not isinstance(refs, dict):
62
- return {}
63
- clean = {}
64
- for kind, ref in refs.items():
65
- if kind not in {"memory", "code_graph"} or not isinstance(ref, dict):
66
- continue
67
- clean[kind] = {key: ref[key] for key in PROVIDER_FIELDS if key in ref}
68
- return clean
69
-
70
-
71
- def read_changes(root: Path):
72
- db_path = root / ".ultra" / "state.db"
73
- if not db_path.is_file():
74
- return []
75
- try:
76
- with sqlite3.connect(f"file:{db_path}?mode=ro", uri=True, timeout=1) as conn:
77
- exists = conn.execute(
78
- "SELECT 1 FROM sqlite_master WHERE type='table' AND name='changes'"
79
- ).fetchone()
80
- if not exists:
81
- return []
82
- rows = conn.execute(
83
- """SELECT id, title, kind, status, intent, docs_impact_json,
84
- provider_refs_json, artifact_root
85
- FROM changes WHERE status IN ('active', 'blocked', 'ready')
86
- ORDER BY updated_at DESC"""
87
- ).fetchall()
88
- except sqlite3.Error as exc:
89
- print(f"[workflow_context] cannot inspect {db_path}: {exc}", file=sys.stderr)
90
- return []
91
- return [
92
- {
93
- "id": row[0], "title": row[1], "kind": row[2], "status": row[3],
94
- "intent": row[4], "docs_impact": safe_json(row[5], {}),
95
- "providers": provider_metadata(row[6]), "artifact_root": row[7],
96
- }
97
- for row in rows
98
- ]
99
-
100
-
101
- def context_text(root: Path, workflow, changes) -> str:
102
- lines = []
103
- if workflow:
104
- lines.extend([
105
- "[Ultra active workflow]",
106
- f"Project: {root}",
107
- f"Command: {workflow.get('command', 'unknown')}",
108
- f"Task: {workflow.get('task_id', workflow.get('task', 'unknown'))}",
109
- f"Step: {workflow.get('step', 'unknown')}",
110
- f"Status: {workflow.get('status', 'active')}",
111
- ])
112
- if changes:
113
- change = changes[0]
114
- lines.extend([
115
- "[Ultra continuous change]",
116
- f"Project: {root}",
117
- f"Change: {change['id']} ({change['kind']}, {change['status']})",
118
- f"Title: {change['title']}",
119
- f"Intent: {change['intent']}",
120
- "Docs impact: " + json.dumps(change["docs_impact"], ensure_ascii=False, sort_keys=True),
121
- f"Context manifest: {change['artifact_root']}/context-manifest.json",
122
- "External providers (metadata references only): "
123
- + json.dumps(change["providers"], ensure_ascii=False, sort_keys=True),
124
- ])
125
- if len(changes) > 1:
126
- lines.append(f"Active change count: {len(changes)}; resolve scope with change.list before mutation.")
127
- if not workflow and not changes:
128
- lines.extend([
129
- "[Ultra baseline]",
130
- f"Project: {root}",
131
- "No active continuous change. Start daily work with the ultra-change workflow.",
132
- ])
133
- lines.append("Authority: .ultra/state.db; generated JSON/Markdown are projections or artifacts.")
134
- return "\n".join(lines)
135
-
136
-
137
21
  def main() -> None:
138
22
  data = payload()
139
23
  root = find_root(Path(data.get("cwd") or Path.cwd()).resolve())
140
24
  if root is None:
141
25
  print(json.dumps({}))
142
26
  return
143
- workflow = read_workflow(root)
144
- changes = read_changes(root)
145
- context = context_text(root, workflow, changes)
27
+ try:
28
+ breadcrumb = read_breadcrumb(root)
29
+ except (sqlite3.Error, OSError) as exc:
30
+ print(f"[workflow_context] cannot inspect Context Spine: {exc}", file=sys.stderr)
31
+ breadcrumb = None
32
+ if breadcrumb:
33
+ context = render_breadcrumb(root, breadcrumb)
34
+ else:
35
+ context = "\n".join([
36
+ "[Ultra baseline]",
37
+ f"Project: {root}",
38
+ "No active continuous change. Start daily work with the ultra-change workflow.",
39
+ "Authority: .ultra/state.db; JSON/Markdown remain projections or evidence artifacts.",
40
+ ])
146
41
  print(json.dumps({"hookSpecificOutput": {
147
42
  "hookEventName": "SessionStart",
148
43
  "additionalContext": context,
149
44
  }}))
150
-
151
-
152
45
  if __name__ == "__main__":
153
46
  main()
@@ -3,11 +3,14 @@
3
3
 
4
4
  import json
5
5
  import os
6
+ import sqlite3
6
7
  import sys
7
8
  import tempfile
8
9
  from datetime import datetime, timezone
9
10
  from pathlib import Path
10
11
 
12
+ from context_spine import find_root, read_breadcrumb, render_breadcrumb
13
+
11
14
  TERMINAL = {"committed", "completed", "done", "cancelled"}
12
15
 
13
16
 
@@ -86,6 +89,24 @@ def main() -> None:
86
89
  print(f"[workflow_resume] invalid hook input: {exc}", file=sys.stderr)
87
90
  data = {}
88
91
  start = Path(data.get("cwd") or Path.cwd()).resolve()
92
+ root = find_root(start)
93
+ if root is not None:
94
+ try:
95
+ breadcrumb = read_breadcrumb(root)
96
+ except (sqlite3.Error, OSError) as exc:
97
+ print(f"[workflow_resume] cannot inspect Context Spine: {exc}", file=sys.stderr)
98
+ breadcrumb = None
99
+ if breadcrumb:
100
+ text = render_breadcrumb(root, breadcrumb)
101
+ event = data.get("hook_event_name", "SessionStart")
102
+ if event == "SessionStart":
103
+ print(json.dumps({"hookSpecificOutput": {
104
+ "hookEventName": "SessionStart",
105
+ "additionalContext": text,
106
+ }}))
107
+ else:
108
+ print(json.dumps({"additionalContext": text}))
109
+ return
89
110
  for root in (start, *start.parents):
90
111
  state_file = root / ".ultra" / "workflow-state.json"
91
112
  checkpoint_file = root / ".ultra" / "runtime" / "checkpoint.json"
@@ -6,6 +6,8 @@ const path = require('node:path');
6
6
  const { spawnSync } = require('node:child_process');
7
7
 
8
8
  const ops = require('./state-ops.cjs');
9
+ const contextSpine = require('./context-spine.cjs');
10
+ const specLearning = require('./spec-learning.cjs');
9
11
 
10
12
  const CHANGE_ID = /^[a-zA-Z0-9_-]+$/;
11
13
  const PROVIDER_KINDS = new Set(['memory', 'code_graph']);
@@ -227,13 +229,16 @@ function compileContext(db, input, { rootDir = process.cwd() } = {}) {
227
229
  ? change.provider_refs
228
230
  : normalizeProviderRefs(input.provider_refs);
229
231
  const tasks = db.prepare(
230
- 'SELECT id, title, status, priority, stale, trace_to, context_file FROM tasks WHERE change_id = ? ORDER BY created_at ASC',
232
+ `SELECT id, title, type, status, priority, complexity, stale, trace_to,
233
+ context_file, files_modified, session_id, created_at, updated_at
234
+ FROM tasks WHERE change_id = ? ORDER BY created_at ASC`,
231
235
  ).all(change.id).map((task) => ({ ...task, stale: Boolean(task.stale) }));
232
236
  const specs = Array.isArray(input.spec_refs) ? input.spec_refs : [];
233
237
  const allowedPaths = Array.isArray(input.allowed_paths) ? input.allowed_paths : [];
234
238
  const head = gitHead(rootDir);
239
+ const spine = contextSpine.compileRoleContext(db, { input, change, tasks, rootDir });
235
240
  const manifest = {
236
- schema_version: '1.0',
241
+ schema_version: '2.0',
237
242
  generated_at: nowIso(),
238
243
  change: {
239
244
  id: change.id, title: change.title, kind: change.kind, status: change.status,
@@ -241,7 +246,15 @@ function compileContext(db, input, { rootDir = process.cwd() } = {}) {
241
246
  },
242
247
  git: { head },
243
248
  tasks,
249
+ selected_task: spine.selected_task,
244
250
  specs,
251
+ role: spine.role,
252
+ gate: spine.gate,
253
+ next_action: spine.next_action,
254
+ readiness: spine.readiness,
255
+ context: spine.context,
256
+ execution_contract: spine.execution_contract,
257
+ resume: spine.resume,
245
258
  providers,
246
259
  provider_boundary: 'metadata references only; memory and code graph content remain external',
247
260
  tool_policy: { allowed_paths: allowedPaths },
@@ -257,15 +270,24 @@ function compileContext(db, input, { rootDir = process.cwd() } = {}) {
257
270
  ops.tx(db, () => {
258
271
  db.prepare(
259
272
  `INSERT INTO context_snapshots
260
- (id, change_id, task_id, git_head, provider_refs_json, manifest_path, manifest_hash)
261
- VALUES (?, ?, ?, ?, ?, ?, ?)`,
273
+ (id, change_id, task_id, git_head, provider_refs_json, manifest_path, manifest_hash,
274
+ role, gate, next_action, readiness, blockers_json, context_json,
275
+ token_estimate, token_budget)
276
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
262
277
  ).run(
263
278
  `ctx-${crypto.randomUUID().slice(0, 12)}`, change.id, input.task_id || null,
264
279
  head, JSON.stringify(providers), relative, hash,
280
+ spine.role, spine.gate, spine.next_action, spine.readiness.status,
281
+ JSON.stringify(spine.readiness.blockers), JSON.stringify(spine),
282
+ spine.context.token_estimate, spine.context.budget.max_tokens,
265
283
  );
266
284
  upsertArtifact(db, {
267
285
  change_id: change.id, task_id: input.task_id || null, kind: 'context_manifest',
268
- artifactPath: relative, contentHash: hash, metadata: { git_head: head },
286
+ artifactPath: relative, contentHash: hash,
287
+ metadata: {
288
+ git_head: head, role: spine.role, gate: spine.gate,
289
+ readiness: spine.readiness.status, token_estimate: spine.context.token_estimate,
290
+ },
269
291
  });
270
292
  for (const task of tasks) {
271
293
  if (!task.trace_to) continue;
@@ -278,7 +300,11 @@ function compileContext(db, input, { rootDir = process.cwd() } = {}) {
278
300
  .run(JSON.stringify(providers), nowIso(), change.id);
279
301
  ops.appendEventInTx(db, {
280
302
  type: 'context_updated', change_id: change.id, task_id: input.task_id || null,
281
- payload: { manifest_path: relative, manifest_hash: hash, git_head: head },
303
+ payload: {
304
+ manifest_path: relative, manifest_hash: hash, git_head: head,
305
+ role: spine.role, gate: spine.gate, readiness: spine.readiness.status,
306
+ next_action: spine.next_action,
307
+ },
282
308
  });
283
309
  });
284
310
  return { manifest, context_manifest_path: manifestPath, manifest_hash: hash };
@@ -438,16 +464,43 @@ function filesUnder(dir) {
438
464
  return out;
439
465
  }
440
466
 
441
- function evidenceBlockers(change, evidence) {
467
+ function evidenceBlockers(change, evidence, tasks) {
442
468
  const blockers = [];
443
469
  const rows = Array.isArray(evidence) ? evidence : [];
444
470
  const byCategory = new Map(rows.map((row) => [row.category, row]));
445
- for (const category of REQUIRED_EVIDENCE[change.kind]) {
471
+ for (const category of REQUIRED_EVIDENCE[change.kind].filter((value) => value !== 'review')) {
446
472
  const row = byCategory.get(category);
447
473
  if (!row) blockers.push(`EVIDENCE_MISSING:${category}`);
448
474
  else if (!['pass', 'not_applicable'].includes(row.status)) blockers.push(`EVIDENCE_FAILED:${category}`);
449
475
  else if (!row.evidence || String(row.evidence).trim().length < 3) blockers.push(`EVIDENCE_EMPTY:${category}`);
450
476
  }
477
+ if (['standard', 'major'].includes(change.kind)) {
478
+ const reviews = rows.filter((row) => row.category === 'review');
479
+ for (const axis of ['spec_fidelity', 'engineering_standards']) {
480
+ const row = reviews.find((candidate) => candidate.axis === axis);
481
+ if (!row) blockers.push(`REVIEW_AXIS_MISSING:${axis}`);
482
+ else if (row.status !== 'pass') blockers.push(`REVIEW_AXIS_FAILED:${axis}`);
483
+ else if (!row.evidence || String(row.evidence).trim().length < 3) {
484
+ blockers.push(`REVIEW_AXIS_EMPTY:${axis}`);
485
+ }
486
+ }
487
+ }
488
+ const tests = byCategory.get('tests');
489
+ if (tests && ['standard', 'major', 'incident'].includes(change.kind)
490
+ && (!tests.seam || String(tests.seam).trim().length < 3)) {
491
+ blockers.push('TEST_SEAM_MISSING');
492
+ }
493
+ if (tests && (change.kind === 'incident' || tasks.some((task) => task.type === 'bugfix'))) {
494
+ const signal = tests.signal;
495
+ if (!signal) blockers.push('TEST_RED_SIGNAL_MISSING');
496
+ else {
497
+ if (!signal.command || String(signal.command).trim().length < 3) blockers.push('TEST_SIGNAL_COMMAND_MISSING');
498
+ if (!signal.expected_red || String(signal.expected_red).trim().length < 3) blockers.push('TEST_EXPECTED_RED_MISSING');
499
+ if (signal.observed_red !== true) blockers.push('TEST_RED_NOT_OBSERVED');
500
+ if (signal.observed_green !== true) blockers.push('TEST_GREEN_NOT_OBSERVED');
501
+ if (signal.deterministic !== true) blockers.push('TEST_SIGNAL_NOT_DETERMINISTIC');
502
+ }
503
+ }
451
504
  return blockers;
452
505
  }
453
506
 
@@ -457,11 +510,31 @@ function convergeChange(db, input, { rootDir = process.cwd() } = {}) {
457
510
  if (!['active', 'blocked'].includes(change.status)) {
458
511
  throw new ChangeWorkflowError('CHANGE_NOT_CONVERGEABLE', `change ${input.id} is ${change.status}`);
459
512
  }
460
- const blockers = new Set(evidenceBlockers(change, input.evidence));
461
- const tasks = db.prepare('SELECT id, status, stale FROM tasks WHERE change_id = ? ORDER BY id').all(change.id);
513
+ const tasks = db.prepare('SELECT id, type, status, stale FROM tasks WHERE change_id = ? ORDER BY id').all(change.id);
514
+ const blockers = new Set(evidenceBlockers(change, input.evidence, tasks));
462
515
  if (tasks.length === 0) blockers.add('NO_TASKS');
463
516
  if (tasks.some((task) => !['completed', 'expanded'].includes(task.status))) blockers.add('TASKS_INCOMPLETE');
464
517
  if (tasks.some((task) => Boolean(task.stale))) blockers.add('TASK_CONTEXT_STALE');
518
+ for (const task of tasks) {
519
+ const snapshot = db.prepare(
520
+ `SELECT readiness, context_json FROM context_snapshots
521
+ WHERE change_id = ? AND task_id = ?
522
+ ORDER BY created_at DESC, rowid DESC LIMIT 1`,
523
+ ).get(change.id, task.id);
524
+ if (!snapshot) {
525
+ blockers.add(`TASK_CONTEXT_MISSING:${task.id}`);
526
+ continue;
527
+ }
528
+ const context = parseJson(snapshot.context_json, 'context_snapshots.context_json');
529
+ if (snapshot.readiness !== 'ready') blockers.add(`TASK_CONTEXT_NOT_READY:${task.id}`);
530
+ if (!context.execution_contract?.public_seam) blockers.add(`TASK_SEAM_MISSING:${task.id}`);
531
+ if (!context.execution_contract?.verification_command) blockers.add(`TASK_VERIFICATION_MISSING:${task.id}`);
532
+ }
533
+
534
+ for (const candidate of specLearning.listSpecLearning(db, change.id)) {
535
+ if (candidate.status === 'proposed') blockers.add(`SPEC_LEARNING_UNRESOLVED:${candidate.id}`);
536
+ if (candidate.status === 'approved') blockers.add(`SPEC_LEARNING_NOT_APPLIED:${candidate.id}`);
537
+ }
465
538
 
466
539
  if (change.docs_impact.status === 'unknown') blockers.add('DOCS_IMPACT_UNKNOWN');
467
540
  if (change.docs_impact.status === 'none' && (!change.docs_impact.rationale || change.docs_impact.rationale.length < 3)) {
@@ -513,7 +586,8 @@ function convergeChange(db, input, { rootDir = process.cwd() } = {}) {
513
586
  const lines = [
514
587
  `# Verification: ${change.title}`, '',
515
588
  ...input.evidence.flatMap((row) => [
516
- `## ${row.category}`, '', `Status: **${row.status}**`, '', String(row.evidence).trim(), '',
589
+ `## ${row.category}${row.axis ? `:${row.axis}` : ''}`, '',
590
+ `Status: **${row.status}**`, '', String(row.evidence).trim(), '',
517
591
  ]),
518
592
  ];
519
593
  fs.writeFileSync(verificationPath, `${lines.join('\n')}\n`);
@@ -618,6 +692,10 @@ module.exports = {
618
692
  listChanges,
619
693
  updateChange,
620
694
  compileContext,
695
+ readBreadcrumb: contextSpine.readBreadcrumb,
696
+ proposeSpecLearning: specLearning.proposeSpecLearning,
697
+ resolveSpecLearning: specLearning.resolveSpecLearning,
698
+ listSpecLearning: specLearning.listSpecLearning,
621
699
  convergeChange,
622
700
  archiveChange,
623
701
  normalizeDocsImpact,