ultra-builder-pro-cli 0.5.0 → 0.5.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.
Files changed (39) hide show
  1. package/CHANGELOG.md +27 -1
  2. package/README.md +1 -1
  3. package/adapters/_shared/codex-assets.cjs +1 -1
  4. package/adapters/opencode.js +32 -1
  5. package/adapters/tests/claude.test.cjs +6 -0
  6. package/adapters/tests/codex-hook.test.cjs +25 -0
  7. package/adapters/tests/codex.test.cjs +7 -0
  8. package/adapters/tests/opencode.test.cjs +37 -0
  9. package/commands/ultra-dev.md +2 -1
  10. package/commands/ultra-plan.md +5 -1
  11. package/commands/ultra-status.md +2 -1
  12. package/commands/ultra-test.md +2 -1
  13. package/docs/COMMIT-HASH-BACKFILL.md +3 -3
  14. package/docs/RUNTIME-COMPAT-MATRIX.md +1 -1
  15. package/hooks/active_task_context.py +28 -0
  16. package/mcp-server/lib/projector.cjs +7 -2
  17. package/mcp-server/lib/state-authority.cjs +73 -0
  18. package/mcp-server/lib/state-db.cjs +19 -1
  19. package/mcp-server/lib/state-ops.cjs +3 -2
  20. package/mcp-server/server.cjs +2 -0
  21. package/mcp-server/tests/migrate.test.cjs +56 -1
  22. package/mcp-server/tests/projector.test.cjs +33 -1
  23. package/mcp-server/tests/server-contract.test.cjs +34 -1
  24. package/mcp-server/tests/state-db.test.cjs +31 -0
  25. package/mcp-server/tests/state-ops.test.cjs +15 -2
  26. package/package.json +1 -1
  27. package/skills/ultra-dev/SKILL.md +22 -17
  28. package/skills/ultra-plan/SKILL.md +28 -13
  29. package/skills/ultra-status/SKILL.md +11 -6
  30. package/skills/ultra-test/SKILL.md +9 -3
  31. package/spec/cli-protocol.md +12 -13
  32. package/spec/mcp-tools.yaml +7 -0
  33. package/spec/schemas/state-db.sql +3 -0
  34. package/spec/schemas/tasks.v4.5.schema.json +1 -0
  35. package/ultra-tools/cli.cjs +5 -5
  36. package/ultra-tools/commands/migrate.cjs +136 -48
  37. package/ultra-tools/commands/task.cjs +4 -6
  38. package/.ultra-template/tasks/contexts/TEMPLATE.md +0 -57
  39. package/.ultra-template/tasks/tasks.json +0 -33
package/CHANGELOG.md CHANGED
@@ -7,6 +7,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.5.1] — 2026-07-16
11
+
12
+ ### Changed
13
+
14
+ - Made task lifecycle workflows fail closed when MCP state is unavailable or
15
+ reports an authority conflict. Removed the nonexistent task create/update/list
16
+ CLI fallbacks from Claude Code, OpenCode, and Codex workflow assets.
17
+ - Advanced the state schema to `8A.2` so `estimated_days` is authoritative and
18
+ survives MCP create/update, projection, and legacy migration.
19
+
20
+ ### Fixed
21
+
22
+ - Fixed the v4.4→v4.5 migration for the real Ultra task shape: top-level
23
+ `version`, `dependencies`, relative `contexts/...` paths, date-only project
24
+ timestamps, and task rows without individual timestamps.
25
+ - Added a state-authority gate that returns `LEGACY_STATE_MIGRATION_REQUIRED`
26
+ when a non-empty v4.4 task projection meets an empty state database, instead
27
+ of returning a misleading empty task list.
28
+ - Made migration task/event inserts atomic, refused merges into a non-empty task
29
+ table, projected immediately after success, and preserved context bodies while
30
+ removing obsolete duplicate status banners and retired memory-hook references.
31
+ - Added deterministic projection-write guards for Claude Code `Edit|Write`,
32
+ OpenCode `tool.execute.before`, and Codex `Edit|Write|apply_patch` during active
33
+ Ultra workflows.
34
+
10
35
  ## [0.5.0] — 2026-07-16
11
36
 
12
37
  ### Added
@@ -186,7 +211,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
186
211
  skill manifest, CLI protocol + mapping table; 5 spec validators.
187
212
  - **Phase 0 — skeleton**: multi-runtime installer scaffolding.
188
213
 
189
- [Unreleased]: https://github.com/rocky2431/ultra-builder-pro-cli/compare/v0.5.0...HEAD
214
+ [Unreleased]: https://github.com/rocky2431/ultra-builder-pro-cli/compare/v0.5.1...HEAD
215
+ [0.5.1]: https://github.com/rocky2431/ultra-builder-pro-cli/compare/v0.5.0...v0.5.1
190
216
  [0.5.0]: https://github.com/rocky2431/ultra-builder-pro-cli/releases/tag/v0.5.0
191
217
  [0.4.0]: https://github.com/rocky2431/ultra-builder-pro-cli/releases/tag/v0.4.0
192
218
  [0.3.0]: https://github.com/rocky2431/ultra-builder-pro-cli/releases/tag/v0.3.0
package/README.md CHANGED
@@ -8,7 +8,7 @@ authoritative `.ultra/state.db`.
8
8
 
9
9
  <div align="center">
10
10
 
11
- [![Version](https://img.shields.io/badge/version-0.5.0-blue)](./CHANGELOG.md)
11
+ [![Version](https://img.shields.io/badge/version-0.5.1-blue)](./CHANGELOG.md)
12
12
  [![Tests](https://img.shields.io/badge/tests-passing-brightgreen)](#verification)
13
13
  [![License: MIT](https://img.shields.io/badge/license-MIT-green)](./LICENSE)
14
14
  [![Node](https://img.shields.io/badge/node-%E2%89%A522-informational)](./package.json)
@@ -818,7 +818,7 @@ function buildHooksManifest() {
818
818
  { hooks: [commandHook('workflow_context.py', 10, 'Loading active Ultra workflow')] },
819
819
  ],
820
820
  PreToolUse: [
821
- { matcher: 'Edit|Write', hooks: [commandHook('active_task_context.py', 3, 'Checking active Ultra task')] },
821
+ { matcher: 'Edit|Write|apply_patch', hooks: [commandHook('active_task_context.py', 3, 'Checking active Ultra task')] },
822
822
  ],
823
823
  PreCompact: [
824
824
  { matcher: 'manual|auto', hooks: [commandHook('workflow_checkpoint.py', 10, 'Saving Ultra workflow checkpoint')] },
@@ -341,6 +341,7 @@ import fs from "node:fs";
341
341
  import path from "node:path";
342
342
 
343
343
  const TERMINAL = new Set(["committed", "completed", "done", "cancelled"]);
344
+ const TASKS_PROJECTION = ".ultra/tasks/tasks.json";
344
345
 
345
346
  function findActiveWorkflow(directory) {
346
347
  let current = path.resolve(directory);
@@ -374,6 +375,25 @@ function contextText(active) {
374
375
  ].join("\\n");
375
376
  }
376
377
 
378
+ function targetPaths(tool, args) {
379
+ if (!args || typeof args !== "object") return [];
380
+ const paths = [args.file_path, args.filePath, args.filepath, args.path]
381
+ .filter((value) => typeof value === "string" && value.length > 0);
382
+ const patch = args.patch ?? args.command;
383
+ if (String(tool).toLowerCase() === "apply_patch" && typeof patch === "string") {
384
+ const pattern = /^\\*\\*\\* (?:Add|Update|Delete) File: (.+?)\\s*$/gm;
385
+ for (const match of patch.matchAll(pattern)) paths.push(match[1].trim());
386
+ }
387
+ return [...new Set(paths)];
388
+ }
389
+
390
+ function isTasksProjection(active, candidate) {
391
+ const target = path.isAbsolute(candidate)
392
+ ? path.resolve(candidate)
393
+ : path.resolve(active.root, candidate);
394
+ return target === path.resolve(active.root, TASKS_PROJECTION);
395
+ }
396
+
377
397
  export const UltraBuilderProPlugin = async ({ directory, worktree }) => {
378
398
  const root = worktree || directory;
379
399
  let active = findActiveWorkflow(root);
@@ -390,7 +410,18 @@ export const UltraBuilderProPlugin = async ({ directory, worktree }) => {
390
410
  refresh();
391
411
  if (active) output.context.push(contextText(active));
392
412
  },
393
- "tool.execute.before": async () => { refresh(); },
413
+ "tool.execute.before": async (input, output) => {
414
+ refresh();
415
+ if (!active) return;
416
+ const tool = String(input?.tool ?? "").toLowerCase();
417
+ if (!["write", "edit", "apply_patch"].includes(tool)) return;
418
+ if (targetPaths(tool, output?.args).some((candidate) => isTasksProjection(active, candidate))) {
419
+ throw new Error(
420
+ "Ultra Builder Pro refused a direct write to .ultra/tasks/tasks.json. " +
421
+ ".ultra/state.db is authoritative; use MCP task tools or run the required v4.4 to v4.5 migration."
422
+ );
423
+ }
424
+ },
394
425
  "tool.execute.after": async () => { refresh(); },
395
426
  };
396
427
  };
@@ -55,6 +55,8 @@ test('Claude plugin collaboration and learn workflows are safe native plugin ass
55
55
  const geminiCollab = read('gemini-collab');
56
56
  const verify = read('ultra-verify');
57
57
  const review = read('ultra-review');
58
+ const plan = read('ultra-plan');
59
+ const status = read('ultra-status');
58
60
 
59
61
  assert.match(learn, /~\/.claude\/skills\/learned-<pattern-slug>-unverified\/SKILL\.md/);
60
62
  assert.doesNotMatch(learn, /_unverified\.md/);
@@ -64,6 +66,10 @@ test('Claude plugin collaboration and learn workflows are safe native plugin ass
64
66
  assert.match(verify, /-s read-only/);
65
67
  assert.match(verify, /\$CLAUDE_PLUGIN_ROOT\/skills\/ultra-verify\/scripts\/verify_wait\.py/);
66
68
  assert.match(review, /\$CLAUDE_PLUGIN_ROOT\/skills\/ultra-review\/scripts\/review_wait\.py/);
69
+ assert.match(plan, /LEGACY_STATE_MIGRATION_REQUIRED/);
70
+ assert.match(plan, /never read or write .*tasks\.json/i);
71
+ assert.match(status, /never fall back .*tasks\.json/i);
72
+ assert.doesNotMatch(plan, /ultra-tools task create/);
67
73
 
68
74
  for (const [name, contents] of Object.entries({ codexCollab, geminiCollab, verify })) {
69
75
  assert.doesNotMatch(contents, /--yolo|--full-auto|\/codex:/, name);
@@ -74,6 +74,31 @@ test('Codex hook adapter maps apply_patch payloads to Edit-compatible file input
74
74
  assert.ok(outputs.every((entry) => entry.tool_name === 'Edit'));
75
75
  });
76
76
 
77
+ test('Codex hook adapter denies apply_patch writes to tasks.json during an active Ultra workflow', () => {
78
+ const project = fs.mkdtempSync(path.join(os.tmpdir(), 'ubp-codex-hook-deny-'));
79
+ try {
80
+ fs.mkdirSync(path.join(project, '.ultra'));
81
+ fs.writeFileSync(path.join(project, '.ultra', 'workflow-state.json'), JSON.stringify({
82
+ command: 'ultra-plan', task_id: 'plan', step: 'persist', status: 'active',
83
+ }));
84
+ const result = run('active_task_context.py', {
85
+ session_id: 'session-deny',
86
+ cwd: project,
87
+ hook_event_name: 'PreToolUse',
88
+ tool_name: 'apply_patch',
89
+ tool_input: {
90
+ patch: '*** Begin Patch\n*** Update File: .ultra/tasks/tasks.json\n*** End Patch',
91
+ },
92
+ });
93
+ assert.equal(result.status, 0, result.stderr);
94
+ const output = JSON.parse(result.stdout);
95
+ assert.equal(output.hookSpecificOutput.permissionDecision, 'deny');
96
+ assert.match(output.hookSpecificOutput.permissionDecisionReason, /\.ultra\/state\.db/);
97
+ } finally {
98
+ fs.rmSync(project, { recursive: true, force: true });
99
+ }
100
+ });
101
+
77
102
  test('Codex hook adapter converts compact recovery context to PostCompact schema', () => {
78
103
  const script = [
79
104
  'import importlib.util, json, pathlib',
@@ -147,6 +147,12 @@ test('every generated skill is Codex-valid, UI-visible, and free of Claude host
147
147
  assert.match(review, /native Codex custom agents/);
148
148
  assert.match(review, /~\/plugins\/ultra-builder-pro\/skills\/ultra-review/);
149
149
 
150
+ const plan = fs.readFileSync(path.join(layout.pluginRoot, 'skills', 'ultra-plan', 'SKILL.md'), 'utf8');
151
+ const status = fs.readFileSync(path.join(layout.pluginRoot, 'skills', 'ultra-status', 'SKILL.md'), 'utf8');
152
+ assert.match(plan, /LEGACY_STATE_MIGRATION_REQUIRED/);
153
+ assert.match(plan, /Never read or write .*tasks\.json/i);
154
+ assert.match(status, /Never fall back to .*tasks\.json/i);
155
+
150
156
  const coreWorkflowText = [
151
157
  ...COMMANDS,
152
158
  'ultra-review',
@@ -196,6 +202,7 @@ test('plugin declares current Codex hooks and a project-local Ultra MCP server',
196
202
  }
197
203
  assert.doesNotMatch(serializedHooks, /memory|recall|journal|observation_capture|user_prompt_capture|block_dangerous|post_edit_guard/);
198
204
  assert.match(serializedHooks, /\$PLUGIN_ROOT\/hooks\/adapters\/codex\.py/);
205
+ assert.match(serializedHooks, /apply_patch/);
199
206
 
200
207
  const mcp = JSON.parse(fs.readFileSync(path.join(layout.pluginRoot, '.mcp.json'), 'utf8'));
201
208
  const server = mcp.mcpServers['ultra-builder-pro'];
@@ -5,6 +5,7 @@ const assert = require('node:assert/strict');
5
5
  const fs = require('node:fs');
6
6
  const os = require('node:os');
7
7
  const path = require('node:path');
8
+ const { pathToFileURL } = require('node:url');
8
9
 
9
10
  const opencode = require('../opencode.js');
10
11
  const { parse: parseFm } = require('../_shared/frontmatter.cjs');
@@ -70,6 +71,9 @@ test('install performs content-level OpenCode adaptation for commands, skills, r
70
71
  assert.match(geminiCollab, /OpenCode remains primary/);
71
72
  assert.match(verify, /OpenCode remains primary/);
72
73
  assert.match(verify, /opencode-analysis\.md/);
74
+ assert.match(plan, /LEGACY_STATE_MIGRATION_REQUIRED/);
75
+ assert.match(plan, /never read or write .*tasks\.json/i);
76
+ assert.doesNotMatch(plan, /ultra-tools task create/);
73
77
 
74
78
  const markdown = [];
75
79
  for (const root of ['commands', 'skills', 'agents']) {
@@ -165,6 +169,8 @@ test('install writes a schema-safe opencode.json and keeps ownership outside hos
165
169
  assert.match(plugin, /experimental\.chat\.system\.transform/);
166
170
  assert.match(plugin, /experimental\.session\.compacting/);
167
171
  assert.match(plugin, /tool\.execute\.before/);
172
+ assert.match(plugin, /\.ultra[\\/]tasks[\\/]tasks\.json/);
173
+ assert.match(plugin, /throw new Error/);
168
174
  assert.match(plugin, /session\.compacted/);
169
175
  assert.doesNotMatch(plugin, /memory|recall|journal|observation/);
170
176
  } finally {
@@ -172,6 +178,37 @@ test('install writes a schema-safe opencode.json and keeps ownership outside hos
172
178
  }
173
179
  });
174
180
 
181
+ test('OpenCode before-tool hook blocks tasks.json writes only during an active workflow', async () => {
182
+ const target = mkTarget();
183
+ const project = mkTarget();
184
+ try {
185
+ opencode.install({ configDir: target, repoRoot: REPO_ROOT });
186
+ const pluginFile = path.join(target, 'plugins', 'ultra-builder-pro.js');
187
+ const module = await import(pathToFileURL(pluginFile).href);
188
+ const plugin = await module.UltraBuilderProPlugin({ directory: project });
189
+
190
+ await plugin['tool.execute.before'](
191
+ { tool: 'edit' },
192
+ { args: { filePath: '.ultra/tasks/tasks.json' } },
193
+ );
194
+
195
+ fs.mkdirSync(path.join(project, '.ultra'), { recursive: true });
196
+ fs.writeFileSync(path.join(project, '.ultra', 'workflow-state.json'), JSON.stringify({
197
+ command: 'ultra-plan', task_id: 'plan', step: 'persist', status: 'active',
198
+ }));
199
+ await assert.rejects(
200
+ plugin['tool.execute.before'](
201
+ { tool: 'apply_patch' },
202
+ { args: { patch: '*** Begin Patch\n*** Update File: .ultra/tasks/tasks.json\n*** End Patch' } },
203
+ ),
204
+ /state\.db is authoritative/,
205
+ );
206
+ } finally {
207
+ fs.rmSync(target, { recursive: true, force: true });
208
+ fs.rmSync(project, { recursive: true, force: true });
209
+ }
210
+ });
211
+
175
212
  test('install preserves user mcp entries; uninstall removes only owned Ultra assets', () => {
176
213
  const target = mkTarget();
177
214
  const configFile = path.join(target, 'opencode.json');
@@ -8,7 +8,6 @@ mcp_tools_required:
8
8
  - task.update
9
9
  - task.get
10
10
  - task.list
11
- cli_fallback: "task update"
12
11
  ---
13
12
 
14
13
  # /ultra-dev
@@ -18,6 +17,8 @@ cli_fallback: "task update"
18
17
  把一个 task 从 `pending` 推到 `completed`:进入 feature 分支 → RED/GREEN/REFACTOR
19
18
  TDD → 质量门 → `/ultra-review all` → 完成 → commit/merge。**单写**:状态只通过
20
19
  MCP `task.update` 改一次;projector 自动更新 tasks.json + context-md frontmatter。
20
+ MCP 状态工具失败时 fail closed;`LEGACY_STATE_MIGRATION_REQUIRED` 只允许先跑
21
+ v4.4→v4.5 迁移,绝不从 `tasks.json` 回退或直接改投影。
21
22
 
22
23
  ## 参数
23
24
 
@@ -6,7 +6,6 @@ model: opus
6
6
  workflow-ref: "@skills/ultra-plan/SKILL.md"
7
7
  mcp_tools_required:
8
8
  - task.create
9
- cli_fallback: "task create"
10
9
  ---
11
10
 
12
11
  # /ultra-plan
@@ -18,6 +17,11 @@ Skeleton 贯穿所有层,再切 vertical slices,每 3-4 个 feature task 插
18
17
  Integration Checkpoint。任务写入 state.db(MCP `task.create`),projector
19
18
  自动生成 `.ultra/tasks/tasks.json` + contexts/*.md frontmatter。
20
19
 
20
+ 入口先写 active `.ultra/workflow-state.json`。MCP 不可用或返回
21
+ `LEGACY_STATE_MIGRATION_REQUIRED` 时立即停止,提示运行
22
+ `ultra-tools migrate --from=4.4 --to=4.5 --source-dir <project-root>`;绝不读取或写入
23
+ `tasks.json` 作为回退。
24
+
21
25
  ## 参数
22
26
 
23
27
  | 位 | 含义 | 缺省 |
@@ -7,7 +7,6 @@ workflow-ref: "@skills/ultra-status/SKILL.md"
7
7
  mcp_tools_required:
8
8
  - task.list
9
9
  - task.get
10
- cli_fallback: "task list"
11
10
  ---
12
11
 
13
12
  # /ultra-status
@@ -16,6 +15,8 @@ cli_fallback: "task list"
16
15
 
17
16
  一次调用拿整个项目近况:任务进度(来自 state.db `task.list`)+ 测试状态(`test-report.json`)+
18
17
  发布状态(`delivery-report.json`)+ 风险检测 + 下一步命令路由。只读。
18
+ 任务状态只接受 MCP `task.list`/`task.get`;失败时原样报告并停止,绝不读取
19
+ `tasks.json` 冒充权威数据。旧 v4.4 数据必须先执行正式迁移。
19
20
 
20
21
  ## 参数
21
22
 
@@ -6,7 +6,6 @@ model: opus
6
6
  workflow-ref: "@skills/ultra-test/SKILL.md"
7
7
  mcp_tools_required:
8
8
  - task.list
9
- cli_fallback: "task list"
10
9
  ---
11
10
 
12
11
  # /ultra-test
@@ -16,6 +15,8 @@ cli_fallback: "task list"
16
15
  `/ultra-deliver` 前的项目级质量审计:6 类 gate(Anti-Pattern / Coverage Gap /
17
16
  Wiring / E2E / Performance / Security)× auto-fix loop。**只读** state.db,
18
17
  写 `.ultra/test-report.json` 作为 `/ultra-deliver` 的准入凭证。
18
+ completed-task 前置检查必须来自 MCP `task.list`;失败时停止,绝不回退到
19
+ `tasks.json`。`LEGACY_STATE_MIGRATION_REQUIRED` 必须先完成 v4.4→v4.5 迁移。
19
20
 
20
21
  **注意**:这不是跑单测(那是 `/ultra-dev`)。这是交付前的体检。
21
22
 
@@ -77,10 +77,10 @@ The post-commit hook installed by `skills/ultra-dev/` will invoke this
77
77
  flow as soon as a feat commit lands on a task branch:
78
78
 
79
79
  ```
80
- post-commit hook → if commit message starts with "feat:" and the
80
+ post-commit workflow → if commit message starts with "feat:" and the
81
81
  working tree contains contexts/task-N.md →
82
- ultra-tools task update --id task-N \
83
- --completion-commit $(git rev-parse HEAD) &&
82
+ MCP task.update {id: task-N,
83
+ patch: {completion_commit: $(git rev-parse HEAD)}} &&
84
84
  git add contexts/task-N.md &&
85
85
  git commit -m "chore: record task-N completion hash"
86
86
  ```
@@ -41,7 +41,7 @@ result for primary verification.
41
41
  | Lifecycle | Claude Code | OpenCode | Codex | Gemini CLI |
42
42
  |---|---|---|---|---|
43
43
  | Session context/health | FULL, native `SessionStart` | FULL, system transform + event refresh | FULL, native `SessionStart` | N/A |
44
- | Active edit boundary | FULL, `PreToolUse Edit|Write` | DEGRADED, tool lifecycle can refresh but not inject a native pre-edit message | FULL, `PreToolUse Edit|Write` | N/A |
44
+ | Active edit boundary | FULL, `PreToolUse Edit|Write` | FULL, `tool.execute.before` rejects projection writes | FULL, `PreToolUse Edit|Write|apply_patch` | N/A |
45
45
  | Compaction recovery | FULL, `PreCompact` + compact resume matcher | FULL, native compacting context | FULL, `PreCompact` + `PostCompact` | N/A |
46
46
  | Incomplete-stop gate | FULL, native blocking `Stop` hook | DEGRADED, no equivalent blocking stop hook | FULL, native blocking `Stop` hook | N/A |
47
47
  | Subagent lifecycle evidence | FULL | DEGRADED, no equivalent packaged event | FULL | N/A |
@@ -6,6 +6,22 @@ import sys
6
6
  from pathlib import Path
7
7
 
8
8
  TERMINAL = {"committed", "completed", "done", "cancelled"}
9
+ TASKS_PROJECTION = Path(".ultra/tasks/tasks.json")
10
+
11
+
12
+ def targets_tasks_projection(data: dict, root: Path) -> bool:
13
+ tool_input = data.get("tool_input")
14
+ if not isinstance(tool_input, dict):
15
+ return False
16
+ for key in ("file_path", "filePath", "filepath", "path"):
17
+ value = tool_input.get(key)
18
+ if not isinstance(value, str) or not value.strip():
19
+ continue
20
+ candidate = Path(value)
21
+ resolved = candidate.resolve() if candidate.is_absolute() else (root / candidate).resolve()
22
+ if resolved == (root / TASKS_PROJECTION).resolve():
23
+ return True
24
+ return False
9
25
 
10
26
 
11
27
  def main() -> None:
@@ -29,6 +45,18 @@ def main() -> None:
29
45
  break
30
46
  task = state.get("task_id", state.get("task", "unknown"))
31
47
  step = state.get("step", "unknown")
48
+ if targets_tasks_projection(data, root):
49
+ print(json.dumps({"hookSpecificOutput": {
50
+ "hookEventName": "PreToolUse",
51
+ "permissionDecision": "deny",
52
+ "permissionDecisionReason": (
53
+ "Refusing a direct write to .ultra/tasks/tasks.json during an active Ultra "
54
+ "workflow. .ultra/state.db is authoritative; use the Ultra MCP task tools. "
55
+ "If they report LEGACY_STATE_MIGRATION_REQUIRED, run the v4.4 to v4.5 "
56
+ "migration before continuing."
57
+ ),
58
+ }}))
59
+ return
32
60
  print(json.dumps({"hookSpecificOutput": {
33
61
  "hookEventName": "PreToolUse",
34
62
  "additionalContext": (
@@ -16,8 +16,8 @@ const SCHEMA_VERSION = '4.5';
16
16
  const SOURCE_TAG = '.ultra/state.db';
17
17
 
18
18
  // Frozen SELECTs — values bind through @placeholders.
19
- const LIST_TASKS_FOR_PROJECTION_SQL = "SELECT id, title, type, priority, complexity, status, deps, files_modified, session_id, stale, complexity_hint, tag, trace_to, context_file, completion_commit, created_at, updated_at FROM tasks ORDER BY created_at ASC";
20
- const READ_TASK_FOR_PROJECTION_SQL = "SELECT id, title, type, priority, complexity, status, deps, files_modified, session_id, stale, complexity_hint, tag, trace_to, context_file, completion_commit, created_at, updated_at FROM tasks WHERE id = @id";
19
+ const LIST_TASKS_FOR_PROJECTION_SQL = "SELECT id, title, type, priority, complexity, estimated_days, status, deps, files_modified, session_id, stale, complexity_hint, tag, trace_to, context_file, completion_commit, created_at, updated_at FROM tasks ORDER BY created_at ASC";
20
+ const READ_TASK_FOR_PROJECTION_SQL = "SELECT id, title, type, priority, complexity, estimated_days, status, deps, files_modified, session_id, stale, complexity_hint, tag, trace_to, context_file, completion_commit, created_at, updated_at FROM tasks WHERE id = @id";
21
21
 
22
22
  function rowToProjection(row) {
23
23
  if (!row) return null;
@@ -87,6 +87,11 @@ function buildContextDoc(taskRow, existingBody) {
87
87
  headerLines.push('---', '');
88
88
 
89
89
  let body = existingBody || '';
90
+ // v4.4 contexts carried a second generated status banner in the Markdown
91
+ // body. Once frontmatter is authoritative, retaining that line creates two
92
+ // state surfaces that can disagree. Remove only the legacy generated line;
93
+ // all task-authored body content remains intact.
94
+ body = body.replace(/^>\s*\*\*Status\*\*:.*(?:\r?\n|$)(?:\r?\n)?/mi, '');
90
95
  // Strip any previous STALE banner so re-projection after reset is clean.
91
96
  body = body.replace(/^> ⚠️\s*STALE[^\n]*\n(?:>[^\n]*\n)*\n?/m, '');
92
97
  if (taskRow.stale) {
@@ -0,0 +1,73 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+
6
+ class StateAuthorityError extends Error {
7
+ constructor(code, message, details) {
8
+ super(message);
9
+ this.code = code;
10
+ this.retriable = false;
11
+ this.details = details;
12
+ }
13
+ }
14
+
15
+ function inspectProjection(rootDir) {
16
+ const projectionPath = path.join(rootDir, '.ultra', 'tasks', 'tasks.json');
17
+ if (!fs.existsSync(projectionPath)) return null;
18
+ let projection;
19
+ try {
20
+ projection = JSON.parse(fs.readFileSync(projectionPath, 'utf8'));
21
+ } catch (error) {
22
+ throw new StateAuthorityError(
23
+ 'STATE_PROJECTION_INVALID',
24
+ `cannot validate ${projectionPath}: ${error.message}`,
25
+ { projection_path: projectionPath },
26
+ );
27
+ }
28
+ if (!projection || !Array.isArray(projection.tasks)) {
29
+ throw new StateAuthorityError(
30
+ 'STATE_PROJECTION_INVALID',
31
+ `${projectionPath} does not contain a tasks array`,
32
+ { projection_path: projectionPath },
33
+ );
34
+ }
35
+ return {
36
+ path: projectionPath,
37
+ version: String(projection.version || projection.schema_version || 'unknown'),
38
+ taskCount: projection.tasks.length,
39
+ };
40
+ }
41
+
42
+ function assertStateAuthority(db, rootDir) {
43
+ const authoritativeCount = db.prepare('SELECT COUNT(*) AS count FROM tasks').get().count;
44
+ if (authoritativeCount > 0) return;
45
+ const projection = inspectProjection(rootDir);
46
+ if (!projection || projection.taskCount === 0) return;
47
+
48
+ const details = {
49
+ authoritative_task_count: authoritativeCount,
50
+ legacy_task_count: projection.taskCount,
51
+ projection_path: projection.path,
52
+ projection_version: projection.version,
53
+ };
54
+ if (projection.version === '4.4') {
55
+ throw new StateAuthorityError(
56
+ 'LEGACY_STATE_MIGRATION_REQUIRED',
57
+ `state.db has no tasks but ${projection.path} contains ${projection.taskCount} v4.4 tasks; `
58
+ + 'run ultra-tools migrate --from=4.4 --to=4.5 --source-dir <project-root> before using Ultra MCP tools',
59
+ details,
60
+ );
61
+ }
62
+ throw new StateAuthorityError(
63
+ 'STATE_AUTHORITY_CONFLICT',
64
+ `state.db has no tasks but ${projection.path} contains ${projection.taskCount} tasks; refusing projection fallback`,
65
+ details,
66
+ );
67
+ }
68
+
69
+ module.exports = {
70
+ StateAuthorityError,
71
+ inspectProjection,
72
+ assertStateAuthority,
73
+ };
@@ -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 = '8A.1';
12
+ const EXPECTED_VERSION = '8A.2';
13
13
 
14
14
  const REQUIRED_TABLES = Object.freeze([
15
15
  'tasks',
@@ -60,6 +60,22 @@ function applySchema(db) {
60
60
  runScript(db, readSchemaSql());
61
61
  }
62
62
 
63
+ function columnNames(db, table) {
64
+ return new Set(db.prepare(`PRAGMA table_info(${table})`).all().map((row) => row.name));
65
+ }
66
+
67
+ function applyCompatibleUpgrades(db) {
68
+ const taskColumns = columnNames(db, 'tasks');
69
+ if (!taskColumns.has('estimated_days')) {
70
+ db.transaction(() => {
71
+ db.exec('ALTER TABLE tasks ADD COLUMN estimated_days REAL CHECK (estimated_days IS NULL OR estimated_days > 0)');
72
+ db.prepare(
73
+ 'INSERT OR IGNORE INTO schema_version (version, description) VALUES (?, ?)',
74
+ ).run(EXPECTED_VERSION, 'Phase 8A.2 — tasks.estimated_days preserved across MCP and v4.4 migration');
75
+ })();
76
+ }
77
+ }
78
+
63
79
  function ensureSchemaVersion(db) {
64
80
  // schema_version is an audit trail — multiple rows across phase upgrades.
65
81
  // Guard by checking whether the expected version row exists rather than
@@ -82,6 +98,7 @@ function initStateDb(dbPath) {
82
98
  if (missing.length > 0) {
83
99
  applySchema(db);
84
100
  }
101
+ applyCompatibleUpgrades(db);
85
102
  const version = ensureSchemaVersion(db);
86
103
  return {
87
104
  db,
@@ -104,6 +121,7 @@ module.exports = {
104
121
  SCHEMA_FILE,
105
122
  openStateDb,
106
123
  applySchema,
124
+ applyCompatibleUpgrades,
107
125
  applyPragmas,
108
126
  ensureSchemaVersion,
109
127
  initStateDb,
@@ -23,14 +23,14 @@ const STATUS_TRANSITIONS = Object.freeze({
23
23
  });
24
24
 
25
25
  const TASK_FIELDS = Object.freeze([
26
- 'id', 'title', 'type', 'priority', 'complexity', 'status',
26
+ 'id', 'title', 'type', 'priority', 'complexity', 'estimated_days', 'status',
27
27
  'deps', 'files_modified', 'session_id', 'stale', 'complexity_hint',
28
28
  'tag', 'trace_to', 'context_file', 'completion_commit', 'parent_id',
29
29
  'created_at', 'updated_at',
30
30
  ]);
31
31
 
32
32
  const PATCHABLE_FIELDS = Object.freeze([
33
- 'priority', 'complexity', 'deps', 'files_modified',
33
+ 'priority', 'complexity', 'estimated_days', 'deps', 'files_modified',
34
34
  'session_id', 'stale', 'complexity_hint', 'tag', 'trace_to',
35
35
  'context_file', 'completion_commit',
36
36
  ]);
@@ -140,6 +140,7 @@ function createTask(db, input) {
140
140
  type: input.type,
141
141
  priority: input.priority,
142
142
  complexity: input.complexity ?? null,
143
+ estimated_days: input.estimated_days ?? null,
143
144
  status: 'pending',
144
145
  deps: input.deps ? JSON.stringify(input.deps) : null,
145
146
  files_modified: input.files_modified ? JSON.stringify(input.files_modified) : null,
@@ -18,6 +18,7 @@ const { ListToolsRequestSchema, CallToolRequestSchema } = require('@modelcontext
18
18
  const { version: PACKAGE_VERSION } = require('../package.json');
19
19
 
20
20
  const { initStateDb, closeStateDb } = require('./lib/state-db.cjs');
21
+ const { assertStateAuthority } = require('./lib/state-authority.cjs');
21
22
  const ops = require('./lib/state-ops.cjs');
22
23
  const projector = require('./lib/projector.cjs');
23
24
  const telemetry = require('./lib/telemetry.cjs');
@@ -413,6 +414,7 @@ function startServer({ dbPath, rootDir, projectOnWrite = true }) {
413
414
  let toolDb = null;
414
415
  try {
415
416
  toolDb = STATELESS_TOOLS.has(name) ? null : getDb();
417
+ if (toolDb) assertStateAuthority(toolDb, rootDir);
416
418
  result = await dispatchTool(name, args, toolDb, { rootDir });
417
419
  } catch (err) {
418
420
  const code = err.code || (err instanceof ops.StateOpsError ? err.code : 'STATE_DB_ERROR');