ultra-builder-pro-cli 0.5.0 → 0.5.2

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 (40) hide show
  1. package/CHANGELOG.md +38 -1
  2. package/README.md +1 -1
  3. package/adapters/_shared/codex-assets.cjs +1 -1
  4. package/adapters/codex.js +85 -13
  5. package/adapters/opencode.js +32 -1
  6. package/adapters/tests/claude.test.cjs +6 -0
  7. package/adapters/tests/codex-hook.test.cjs +25 -0
  8. package/adapters/tests/codex.test.cjs +7 -0
  9. package/adapters/tests/opencode.test.cjs +37 -0
  10. package/commands/ultra-dev.md +2 -1
  11. package/commands/ultra-plan.md +5 -1
  12. package/commands/ultra-status.md +2 -1
  13. package/commands/ultra-test.md +2 -1
  14. package/docs/COMMIT-HASH-BACKFILL.md +3 -3
  15. package/docs/RUNTIME-COMPAT-MATRIX.md +1 -1
  16. package/hooks/active_task_context.py +28 -0
  17. package/mcp-server/lib/projector.cjs +7 -2
  18. package/mcp-server/lib/state-authority.cjs +73 -0
  19. package/mcp-server/lib/state-db.cjs +19 -1
  20. package/mcp-server/lib/state-ops.cjs +3 -2
  21. package/mcp-server/server.cjs +2 -0
  22. package/mcp-server/tests/migrate.test.cjs +56 -1
  23. package/mcp-server/tests/projector.test.cjs +33 -1
  24. package/mcp-server/tests/server-contract.test.cjs +34 -1
  25. package/mcp-server/tests/state-db.test.cjs +31 -0
  26. package/mcp-server/tests/state-ops.test.cjs +15 -2
  27. package/package.json +1 -1
  28. package/skills/ultra-dev/SKILL.md +22 -17
  29. package/skills/ultra-plan/SKILL.md +28 -13
  30. package/skills/ultra-status/SKILL.md +11 -6
  31. package/skills/ultra-test/SKILL.md +9 -3
  32. package/spec/cli-protocol.md +12 -13
  33. package/spec/mcp-tools.yaml +7 -0
  34. package/spec/schemas/state-db.sql +3 -0
  35. package/spec/schemas/tasks.v4.5.schema.json +1 -0
  36. package/ultra-tools/cli.cjs +5 -5
  37. package/ultra-tools/commands/migrate.cjs +136 -48
  38. package/ultra-tools/commands/task.cjs +4 -6
  39. package/.ultra-template/tasks/contexts/TEMPLATE.md +0 -57
  40. package/.ultra-template/tasks/tasks.json +0 -33
package/CHANGELOG.md CHANGED
@@ -7,6 +7,41 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.5.2] — 2026-07-16
11
+
12
+ ### Fixed
13
+
14
+ - Preserved Codex hook entry points for already-running tasks across plugin
15
+ refreshes, including cache versions whose adapter file or entire cache
16
+ directory had already disappeared. Historical cache versions are now kept in
17
+ the managed runtime manifest and restored as validated forwarders to the
18
+ current adapter.
19
+
20
+ ## [0.5.1] — 2026-07-16
21
+
22
+ ### Changed
23
+
24
+ - Made task lifecycle workflows fail closed when MCP state is unavailable or
25
+ reports an authority conflict. Removed the nonexistent task create/update/list
26
+ CLI fallbacks from Claude Code, OpenCode, and Codex workflow assets.
27
+ - Advanced the state schema to `8A.2` so `estimated_days` is authoritative and
28
+ survives MCP create/update, projection, and legacy migration.
29
+
30
+ ### Fixed
31
+
32
+ - Fixed the v4.4→v4.5 migration for the real Ultra task shape: top-level
33
+ `version`, `dependencies`, relative `contexts/...` paths, date-only project
34
+ timestamps, and task rows without individual timestamps.
35
+ - Added a state-authority gate that returns `LEGACY_STATE_MIGRATION_REQUIRED`
36
+ when a non-empty v4.4 task projection meets an empty state database, instead
37
+ of returning a misleading empty task list.
38
+ - Made migration task/event inserts atomic, refused merges into a non-empty task
39
+ table, projected immediately after success, and preserved context bodies while
40
+ removing obsolete duplicate status banners and retired memory-hook references.
41
+ - Added deterministic projection-write guards for Claude Code `Edit|Write`,
42
+ OpenCode `tool.execute.before`, and Codex `Edit|Write|apply_patch` during active
43
+ Ultra workflows.
44
+
10
45
  ## [0.5.0] — 2026-07-16
11
46
 
12
47
  ### Added
@@ -186,7 +221,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
186
221
  skill manifest, CLI protocol + mapping table; 5 spec validators.
187
222
  - **Phase 0 — skeleton**: multi-runtime installer scaffolding.
188
223
 
189
- [Unreleased]: https://github.com/rocky2431/ultra-builder-pro-cli/compare/v0.5.0...HEAD
224
+ [Unreleased]: https://github.com/rocky2431/ultra-builder-pro-cli/compare/v0.5.2...HEAD
225
+ [0.5.2]: https://github.com/rocky2431/ultra-builder-pro-cli/compare/v0.5.1...v0.5.2
226
+ [0.5.1]: https://github.com/rocky2431/ultra-builder-pro-cli/compare/v0.5.0...v0.5.1
190
227
  [0.5.0]: https://github.com/rocky2431/ultra-builder-pro-cli/releases/tag/v0.5.0
191
228
  [0.4.0]: https://github.com/rocky2431/ultra-builder-pro-cli/releases/tag/v0.4.0
192
229
  [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.2-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')] },
package/adapters/codex.js CHANGED
@@ -191,14 +191,53 @@ function pluginCacheRoot(configDir, marketplaceName) {
191
191
  return path.join(configDir, 'plugins', 'cache', marketplaceName, PLUGIN_NAME);
192
192
  }
193
193
 
194
- function listCachedHookAdapters(configDir, marketplaceName) {
194
+ function normalizeHookCacheVersions(versions) {
195
+ const normalized = new Set();
196
+ for (const version of versions) {
197
+ if (typeof version !== 'string'
198
+ || !/^[A-Za-z0-9][A-Za-z0-9.+_-]*$/.test(version)) {
199
+ throw new Error(`invalid Codex plugin cache version: ${String(version)}`);
200
+ }
201
+ normalized.add(version);
202
+ }
203
+ return [...normalized].sort();
204
+ }
205
+
206
+ function listKnownHookCacheVersions(configDir, marketplaceName, manifest = null) {
207
+ const root = pluginCacheRoot(configDir, marketplaceName);
208
+ const versions = [];
209
+ if (fs.existsSync(root)) {
210
+ versions.push(...fs.readdirSync(root, { withFileTypes: true })
211
+ .filter((entry) => entry.isDirectory() || entry.isSymbolicLink())
212
+ .map((entry) => entry.name));
213
+ }
214
+
215
+ if (manifest && manifest.source === SOURCE_TAG && manifest.adapter === 'codex') {
216
+ if (manifest.plugin && manifest.plugin.version !== undefined) {
217
+ versions.push(manifest.plugin.version);
218
+ }
219
+ if (manifest.hook_cache_versions !== undefined) {
220
+ if (!Array.isArray(manifest.hook_cache_versions)) {
221
+ throw new Error('invalid Codex runtime manifest hook_cache_versions');
222
+ }
223
+ versions.push(...manifest.hook_cache_versions);
224
+ }
225
+ }
226
+ return normalizeHookCacheVersions(versions);
227
+ }
228
+
229
+ function hookAdaptersForVersions(configDir, marketplaceName, versions) {
195
230
  const root = pluginCacheRoot(configDir, marketplaceName);
196
- if (!fs.existsSync(root)) return [];
197
- return fs.readdirSync(root, { withFileTypes: true })
198
- .filter((entry) => entry.isDirectory())
199
- .map((entry) => path.join(root, entry.name, HOOK_ADAPTER_RELATIVE))
200
- .filter((file) => fs.existsSync(file))
201
- .sort();
231
+ return normalizeHookCacheVersions(versions)
232
+ .map((version) => path.join(root, version, HOOK_ADAPTER_RELATIVE));
233
+ }
234
+
235
+ function listCachedHookAdapters(configDir, marketplaceName, manifest = null) {
236
+ return hookAdaptersForVersions(
237
+ configDir,
238
+ marketplaceName,
239
+ listKnownHookCacheVersions(configDir, marketplaceName, manifest),
240
+ );
202
241
  }
203
242
 
204
243
  function hookForwarder(target) {
@@ -211,6 +250,20 @@ runpy.run_path(${JSON.stringify(path.resolve(target))}, run_name="__main__")
211
250
  `;
212
251
  }
213
252
 
253
+ function prepareHookAdapterPath(file) {
254
+ const versionRoot = path.dirname(path.dirname(path.dirname(file)));
255
+ try {
256
+ const stat = fs.lstatSync(versionRoot);
257
+ if (stat.isSymbolicLink()) {
258
+ fs.unlinkSync(versionRoot);
259
+ } else if (!stat.isDirectory()) {
260
+ throw new Error(`Codex plugin cache version is not a directory: ${versionRoot}`);
261
+ }
262
+ } catch (error) {
263
+ if (error.code !== 'ENOENT') throw error;
264
+ }
265
+ }
266
+
214
267
  function restoreCachedHookAdapters(previousAdapters, target) {
215
268
  const resolvedTarget = path.resolve(target);
216
269
  if (!fs.existsSync(resolvedTarget)) {
@@ -219,6 +272,7 @@ function restoreCachedHookAdapters(previousAdapters, target) {
219
272
  const restored = [];
220
273
  for (const previous of previousAdapters) {
221
274
  if (path.resolve(previous) === resolvedTarget || fs.existsSync(previous)) continue;
275
+ prepareHookAdapterPath(previous);
222
276
  writeAtomic(previous, hookForwarder(resolvedTarget));
223
277
  restored.push(previous);
224
278
  }
@@ -239,6 +293,7 @@ function writeRuntimeManifest(configDir, data) {
239
293
  plugin: data.plugin,
240
294
  marketplace: data.marketplace,
241
295
  agents: data.agents,
296
+ hook_cache_versions: data.hookCacheVersions,
242
297
  }, null, 2) + '\n');
243
298
  return file;
244
299
  }
@@ -250,20 +305,25 @@ function install(ctx = {}) {
250
305
  const marketplaceFile = resolveMarketplaceFile(ctx);
251
306
  ensureDir(configDir);
252
307
 
308
+ const previousManifest = readRuntimeManifest(configDir);
253
309
  const legacy = cleanupLegacyConfig(configDir);
254
310
  const plugin = buildPlugin({ repoRoot, pluginRoot });
255
311
  const agents = installAgents({ repoRoot, configDir });
256
312
  const marketplace = upsertMarketplace(marketplaceFile);
257
- const manifestFile = writeRuntimeManifest(configDir, {
258
- plugin: { root: plugin.root, version: plugin.version },
259
- marketplace: { file: marketplace.file, name: marketplace.name },
260
- agents: agents.installed,
261
- });
313
+ const previousVersions = listKnownHookCacheVersions(
314
+ configDir,
315
+ marketplace.name,
316
+ previousManifest,
317
+ );
262
318
 
263
319
  let registration = null;
264
320
  let hookCompatibility = null;
265
321
  if (shouldRunPluginCli(ctx)) {
266
- const previousAdapters = listCachedHookAdapters(configDir, marketplace.name);
322
+ const previousAdapters = hookAdaptersForVersions(
323
+ configDir,
324
+ marketplace.name,
325
+ previousVersions,
326
+ );
267
327
  try {
268
328
  registration = runPluginCli('add', marketplace.name, ctx);
269
329
  } catch (registrationError) {
@@ -285,6 +345,16 @@ function install(ctx = {}) {
285
345
  currentHookAdapter(configDir, marketplace.name, plugin),
286
346
  );
287
347
  }
348
+ const hookCacheVersions = normalizeHookCacheVersions([
349
+ ...previousVersions,
350
+ plugin.version,
351
+ ]);
352
+ const manifestFile = writeRuntimeManifest(configDir, {
353
+ plugin: { root: plugin.root, version: plugin.version },
354
+ marketplace: { file: marketplace.file, name: marketplace.name },
355
+ agents: agents.installed,
356
+ hookCacheVersions,
357
+ });
288
358
 
289
359
  return {
290
360
  target: configDir,
@@ -379,7 +449,9 @@ module.exports = {
379
449
  removeMarketplaceEntry,
380
450
  runPluginCli,
381
451
  pluginCacheRoot,
452
+ listKnownHookCacheVersions,
382
453
  listCachedHookAdapters,
454
+ prepareHookAdapterPath,
383
455
  restoreCachedHookAdapters,
384
456
  },
385
457
  };
@@ -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
+ };