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.
- package/CHANGELOG.md +38 -1
- package/README.md +1 -1
- package/adapters/_shared/codex-assets.cjs +1 -1
- package/adapters/codex.js +85 -13
- package/adapters/opencode.js +32 -1
- package/adapters/tests/claude.test.cjs +6 -0
- package/adapters/tests/codex-hook.test.cjs +25 -0
- package/adapters/tests/codex.test.cjs +7 -0
- package/adapters/tests/opencode.test.cjs +37 -0
- package/commands/ultra-dev.md +2 -1
- package/commands/ultra-plan.md +5 -1
- package/commands/ultra-status.md +2 -1
- package/commands/ultra-test.md +2 -1
- package/docs/COMMIT-HASH-BACKFILL.md +3 -3
- package/docs/RUNTIME-COMPAT-MATRIX.md +1 -1
- package/hooks/active_task_context.py +28 -0
- package/mcp-server/lib/projector.cjs +7 -2
- package/mcp-server/lib/state-authority.cjs +73 -0
- package/mcp-server/lib/state-db.cjs +19 -1
- package/mcp-server/lib/state-ops.cjs +3 -2
- package/mcp-server/server.cjs +2 -0
- package/mcp-server/tests/migrate.test.cjs +56 -1
- package/mcp-server/tests/projector.test.cjs +33 -1
- package/mcp-server/tests/server-contract.test.cjs +34 -1
- package/mcp-server/tests/state-db.test.cjs +31 -0
- package/mcp-server/tests/state-ops.test.cjs +15 -2
- package/package.json +1 -1
- package/skills/ultra-dev/SKILL.md +22 -17
- package/skills/ultra-plan/SKILL.md +28 -13
- package/skills/ultra-status/SKILL.md +11 -6
- package/skills/ultra-test/SKILL.md +9 -3
- package/spec/cli-protocol.md +12 -13
- package/spec/mcp-tools.yaml +7 -0
- package/spec/schemas/state-db.sql +3 -0
- package/spec/schemas/tasks.v4.5.schema.json +1 -0
- package/ultra-tools/cli.cjs +5 -5
- package/ultra-tools/commands/migrate.cjs +136 -48
- package/ultra-tools/commands/task.cjs +4 -6
- package/.ultra-template/tasks/contexts/TEMPLATE.md +0 -57
- 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.
|
|
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
|
-
[](./CHANGELOG.md)
|
|
12
12
|
[](#verification)
|
|
13
13
|
[](./LICENSE)
|
|
14
14
|
[](./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
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
|
258
|
-
|
|
259
|
-
marketplace
|
|
260
|
-
|
|
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 =
|
|
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
|
};
|
package/adapters/opencode.js
CHANGED
|
@@ -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 () => {
|
|
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');
|
package/commands/ultra-dev.md
CHANGED
|
@@ -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
|
|
package/commands/ultra-plan.md
CHANGED
|
@@ -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
|
| 位 | 含义 | 缺省 |
|
package/commands/ultra-status.md
CHANGED
|
@@ -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
|
|
package/commands/ultra-test.md
CHANGED
|
@@ -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
|
|
80
|
+
post-commit workflow → if commit message starts with "feat:" and the
|
|
81
81
|
working tree contains contexts/task-N.md →
|
|
82
|
-
|
|
83
|
-
|
|
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` |
|
|
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
|
+
};
|