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.
- package/CHANGELOG.md +27 -1
- package/README.md +1 -1
- package/adapters/_shared/codex-assets.cjs +1 -1
- 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,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.
|
|
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
|
-
[](./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/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
|
+
};
|
|
@@ -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.
|
|
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,
|
package/mcp-server/server.cjs
CHANGED
|
@@ -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');
|