ultra-builder-pro-cli 0.21.1 → 0.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.ultra-template/tasks/tasks.json +10 -3
- package/.ultra-template/templates/task-context.md +19 -0
- package/CHANGELOG.md +78 -0
- package/README.md +210 -36
- package/adapters/_shared/codex-assets.cjs +8 -3
- package/adapters/_shared/interaction-contract.cjs +13 -6
- package/adapters/_shared/runtime-assets.cjs +1 -1
- package/adapters/claude.js +1 -1
- package/adapters/kimi.js +1 -1
- package/adapters/opencode.js +24 -13
- package/docs/AGENT-CONTEXT.md +58 -51
- package/docs/ARCHITECTURE.md +82 -51
- package/docs/ARTIFACT-AUTHORITY.md +83 -44
- package/docs/COMMIT-HASH-BACKFILL.md +33 -90
- package/docs/DECISIONS.md +9 -7
- package/docs/LEGACY-CLI-CROSSWALK.md +10 -2
- package/docs/PLUGIN-ISOLATION-CONTRACT.md +3 -2
- package/docs/ROADMAP.md +3 -3
- package/docs/RUNTIME-COMPAT-MATRIX.md +13 -10
- package/docs/STATE-DB-ACCESS-POLICY.md +47 -19
- package/docs/WORKFLOW-LIFECYCLE.md +79 -49
- package/hooks/README.md +5 -4
- package/hooks/active_task_context.py +41 -39
- package/mcp-server/lib/artifact-registry.cjs +32 -7
- package/mcp-server/lib/baseline-workflow.cjs +60 -24
- package/mcp-server/lib/change-workflow.cjs +42 -6
- package/mcp-server/lib/context-paths.cjs +34 -3
- package/mcp-server/lib/doctor.cjs +28 -0
- package/mcp-server/lib/git-bootstrap.cjs +0 -1
- package/mcp-server/lib/init-project.cjs +52 -2
- package/mcp-server/lib/projector.cjs +43 -8
- package/mcp-server/lib/state-authority.cjs +19 -3
- package/mcp-server/lib/state-ops.cjs +6 -1
- package/mcp-server/lib/task-ledger.cjs +1342 -0
- package/mcp-server/lib/ultra-facade.cjs +646 -0
- package/mcp-server/lib/workflow-state.cjs +166 -2
- package/mcp-server/server.cjs +214 -26
- package/package.json +1 -1
- package/skills/ultra-change/SKILL.md +38 -90
- package/skills/ultra-deliver/SKILL.md +42 -62
- package/skills/ultra-dev/SKILL.md +34 -73
- package/skills/ultra-doctor/SKILL.md +26 -39
- package/skills/ultra-init/SKILL.md +37 -69
- package/skills/ultra-plan/SKILL.md +52 -125
- package/skills/ultra-research/SKILL.md +41 -101
- package/skills/ultra-review/SKILL.md +25 -44
- package/skills/ultra-status/SKILL.md +21 -40
- package/skills/ultra-test/SKILL.md +25 -60
- package/skills/ultra-think/SKILL.md +29 -48
- package/skills/ultra-think/references/decision-dialogue.md +6 -4
- package/spec/cli-protocol.md +15 -0
- package/spec/mcp-tools.yaml +286 -0
- package/spec/schemas/context-file.v4.5.schema.json +1 -1
- package/spec/schemas/mcp-tools.schema.json +1 -1
- package/spec/schemas/state-db.sql +1 -1
- package/spec/schemas/task-ledger.v1.schema.json +217 -0
- package/ultra-tools/commands/migrate.cjs +5 -2
- package/.ultra-template/tasks/contexts/TEMPLATE.md +0 -19
|
@@ -59,6 +59,49 @@ function gitBranch(rootDir) {
|
|
|
59
59
|
return result.status === 0 ? (result.stdout.trim() || null) : null;
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
function gitCommitIsAncestor(rootDir, ancestor, descendant) {
|
|
63
|
+
if (!ancestor || !descendant) return false;
|
|
64
|
+
const result = spawnSync(
|
|
65
|
+
'git', ['merge-base', '--is-ancestor', ancestor, descendant],
|
|
66
|
+
{ cwd: rootDir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] },
|
|
67
|
+
);
|
|
68
|
+
return result.status === 0;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function hashScopedWorkingTree(rootDir, relativePaths) {
|
|
72
|
+
const root = path.resolve(rootDir);
|
|
73
|
+
const digest = crypto.createHash('sha256');
|
|
74
|
+
for (const relative of [...new Set(relativePaths)].sort()) {
|
|
75
|
+
const file = path.resolve(root, relative);
|
|
76
|
+
if (file !== root && !file.startsWith(`${root}${path.sep}`)) {
|
|
77
|
+
throw new BaselineWorkflowError(
|
|
78
|
+
'BASELINE_SCOPE_INVALID',
|
|
79
|
+
`Git returned a path outside the repository: ${relative}`,
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
digest.update(relative).update('\0');
|
|
83
|
+
const stat = fs.lstatSync(file, { throwIfNoEntry: false });
|
|
84
|
+
if (!stat) {
|
|
85
|
+
digest.update('missing\0');
|
|
86
|
+
} else if (stat.isSymbolicLink()) {
|
|
87
|
+
digest.update('symlink\0').update(fs.readlinkSync(file)).update('\0');
|
|
88
|
+
} else if (stat.isFile()) {
|
|
89
|
+
digest.update(stat.mode & 0o111 ? 'file+x\0' : 'file\0');
|
|
90
|
+
digest.update(fs.readFileSync(file)).update('\0');
|
|
91
|
+
} else if (stat.isDirectory()) {
|
|
92
|
+
const submoduleHead = spawnSync('git', ['-C', file, 'rev-parse', 'HEAD'], {
|
|
93
|
+
cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
|
|
94
|
+
});
|
|
95
|
+
digest.update('directory\0');
|
|
96
|
+
if (submoduleHead.status === 0) digest.update(submoduleHead.stdout.trim());
|
|
97
|
+
digest.update('\0');
|
|
98
|
+
} else {
|
|
99
|
+
digest.update(`special:${stat.mode}\0`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return digest.digest('hex');
|
|
103
|
+
}
|
|
104
|
+
|
|
62
105
|
function gitWorktreeSnapshot(rootDir, scope = ['.']) {
|
|
63
106
|
const repositoryRoot = gitRepositoryRoot(rootDir);
|
|
64
107
|
if (!repositoryRoot) {
|
|
@@ -76,40 +119,24 @@ function gitWorktreeSnapshot(rootDir, scope = ['.']) {
|
|
|
76
119
|
'git', ['status', '--porcelain=v1', '-z', '--untracked-files=all', ...args],
|
|
77
120
|
{ cwd: rootDir, encoding: 'buffer', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 32 * 1024 * 1024 },
|
|
78
121
|
);
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
'
|
|
82
|
-
{ cwd: rootDir, encoding: 'buffer', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 32 * 1024 * 1024 },
|
|
83
|
-
)
|
|
84
|
-
: { status: 0, stdout: Buffer.alloc(0) };
|
|
85
|
-
const untracked = spawnSync(
|
|
86
|
-
'git', ['ls-files', '--others', '--exclude-standard', '-z', ...args],
|
|
122
|
+
const inventory = spawnSync(
|
|
123
|
+
'git', ['ls-files', '--cached', '--others', '--exclude-standard', '-z',
|
|
124
|
+
'--', ...pathspecs, ':(exclude).ultra', ':(exclude).ultra/**'],
|
|
87
125
|
{ cwd: rootDir, encoding: 'buffer', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 32 * 1024 * 1024 },
|
|
88
126
|
);
|
|
89
|
-
if (status.status !== 0 ||
|
|
127
|
+
if (status.status !== 0 || inventory.status !== 0) {
|
|
90
128
|
return {
|
|
91
129
|
head, branch: gitBranch(rootDir), state: 'unavailable', digest: null, files: [],
|
|
92
130
|
};
|
|
93
131
|
}
|
|
94
132
|
const statusText = status.stdout.toString('utf8');
|
|
95
133
|
const files = statusText.split('\0').filter(Boolean);
|
|
96
|
-
const
|
|
97
|
-
digest.update(head || 'unborn').update('\0').update(gitBranch(rootDir) || '').update('\0');
|
|
98
|
-
digest.update(status.stdout).update(diff.stdout);
|
|
99
|
-
for (const relative of untracked.stdout.toString('utf8').split('\0').filter(Boolean).sort()) {
|
|
100
|
-
digest.update(relative).update('\0');
|
|
101
|
-
const file = path.resolve(rootDir, relative);
|
|
102
|
-
if (file.startsWith(`${path.resolve(rootDir)}${path.sep}`)
|
|
103
|
-
&& fs.existsSync(file) && fs.statSync(file).isFile()) {
|
|
104
|
-
digest.update(fs.readFileSync(file));
|
|
105
|
-
}
|
|
106
|
-
digest.update('\0');
|
|
107
|
-
}
|
|
134
|
+
const scopedFiles = inventory.stdout.toString('utf8').split('\0').filter(Boolean);
|
|
108
135
|
return {
|
|
109
136
|
head,
|
|
110
137
|
branch: gitBranch(rootDir),
|
|
111
138
|
state: head ? (files.length > 0 ? 'dirty' : 'clean') : 'unborn',
|
|
112
|
-
digest:
|
|
139
|
+
digest: hashScopedWorkingTree(rootDir, scopedFiles),
|
|
113
140
|
files,
|
|
114
141
|
};
|
|
115
142
|
}
|
|
@@ -741,7 +768,12 @@ function convergenceBlockers(db, baseline, input, rootDir) {
|
|
|
741
768
|
else if (baseline.repository_revision !== expected) blockers.add('BASELINE_REVISION_MISMATCH');
|
|
742
769
|
const snapshot = gitWorktreeSnapshot(rootDir, baseline.scope);
|
|
743
770
|
if (snapshot.state === 'unborn') blockers.add('BASELINE_GIT_HEAD_REQUIRED');
|
|
744
|
-
if (snapshot.head && expected && snapshot.head !== expected
|
|
771
|
+
if (snapshot.head && expected && snapshot.head !== expected
|
|
772
|
+
&& (!gitCommitIsAncestor(rootDir, expected, snapshot.head)
|
|
773
|
+
|| (baseline.worktree_digest && snapshot.digest
|
|
774
|
+
&& baseline.worktree_digest !== snapshot.digest))) {
|
|
775
|
+
blockers.add('BASELINE_HEAD_STALE');
|
|
776
|
+
}
|
|
745
777
|
if (baseline.repository_branch && snapshot.branch
|
|
746
778
|
&& baseline.repository_branch !== snapshot.branch) blockers.add('BASELINE_BRANCH_STALE');
|
|
747
779
|
if (!Array.isArray(baseline.scope) || baseline.scope.length === 0) blockers.add('BASELINE_SCOPE_MISSING');
|
|
@@ -857,7 +889,11 @@ function inspectBaseline(db, { rootDir = process.cwd(), id } = {}) {
|
|
|
857
889
|
blockers.push('BASELINE_GIT_HEAD_REQUIRED');
|
|
858
890
|
}
|
|
859
891
|
if (snapshot.head && baseline.repository_revision && snapshot.head !== baseline.repository_revision) {
|
|
860
|
-
|
|
892
|
+
if (!gitCommitIsAncestor(rootDir, baseline.repository_revision, snapshot.head)
|
|
893
|
+
|| (baseline.worktree_digest && snapshot.digest
|
|
894
|
+
&& baseline.worktree_digest !== snapshot.digest)) {
|
|
895
|
+
blockers.push('BASELINE_HEAD_STALE');
|
|
896
|
+
}
|
|
861
897
|
} else if (!snapshot.head && baseline.repository_revision?.startsWith('workspace:')) {
|
|
862
898
|
const revision = workspaceRevision({
|
|
863
899
|
scope: baseline.scope, specs: baseline.spec_refs, evidence: baseline.evidence,
|
|
@@ -39,7 +39,7 @@ const SEMANTIC_AUTHORITY_FIELDS = new Set([
|
|
|
39
39
|
const CHANGE_TRANSITIONS = Object.freeze({
|
|
40
40
|
active: new Set(['active', 'blocked', 'cancelled']),
|
|
41
41
|
blocked: new Set(['active', 'blocked', 'cancelled']),
|
|
42
|
-
ready: new Set(['ready']),
|
|
42
|
+
ready: new Set(['active', 'ready', 'cancelled']),
|
|
43
43
|
archived: new Set(),
|
|
44
44
|
cancelled: new Set(),
|
|
45
45
|
});
|
|
@@ -702,11 +702,8 @@ function compileContext(db, input, { rootDir = process.cwd() } = {}) {
|
|
|
702
702
|
const recommendation = spine.recommendation;
|
|
703
703
|
const durableSpine = { ...spine };
|
|
704
704
|
delete durableSpine.recommendation;
|
|
705
|
-
const
|
|
706
|
-
const manifest = {
|
|
705
|
+
const semanticManifest = {
|
|
707
706
|
schema_version: '3.0',
|
|
708
|
-
snapshot_id: snapshotId,
|
|
709
|
-
generated_at: nowIso(),
|
|
710
707
|
change: {
|
|
711
708
|
id: change.id, title: change.title, kind: change.kind, status: change.status,
|
|
712
709
|
intent: change.intent, docs_impact: change.docs_impact, base_commit: change.base_commit,
|
|
@@ -734,6 +731,43 @@ function compileContext(db, input, { rootDir = process.cwd() } = {}) {
|
|
|
734
731
|
providers,
|
|
735
732
|
provider_boundary: 'metadata references only; memory and code graph content remain external',
|
|
736
733
|
};
|
|
734
|
+
const identityHash = crypto.createHash('sha256')
|
|
735
|
+
.update(JSON.stringify(semanticManifest))
|
|
736
|
+
.digest('hex');
|
|
737
|
+
const snapshotId = `ctx-${identityHash.slice(0, 12)}`;
|
|
738
|
+
const existing = db.prepare(
|
|
739
|
+
`SELECT manifest_path, manifest_hash
|
|
740
|
+
FROM context_snapshots WHERE id = ?`,
|
|
741
|
+
).get(snapshotId);
|
|
742
|
+
if (existing) {
|
|
743
|
+
const existingPath = path.resolve(rootDir, existing.manifest_path);
|
|
744
|
+
if (!fs.existsSync(existingPath)) {
|
|
745
|
+
throw new ChangeWorkflowError(
|
|
746
|
+
'CONTEXT_SNAPSHOT_CORRUPT',
|
|
747
|
+
`content-addressed Context snapshot is missing: ${existing.manifest_path}`,
|
|
748
|
+
);
|
|
749
|
+
}
|
|
750
|
+
const bytes = fs.readFileSync(existingPath);
|
|
751
|
+
const existingHash = crypto.createHash('sha256').update(bytes).digest('hex');
|
|
752
|
+
if (existingHash !== existing.manifest_hash) {
|
|
753
|
+
throw new ChangeWorkflowError(
|
|
754
|
+
'CONTEXT_SNAPSHOT_CORRUPT',
|
|
755
|
+
`content-addressed Context snapshot digest changed: ${existing.manifest_path}`,
|
|
756
|
+
);
|
|
757
|
+
}
|
|
758
|
+
return {
|
|
759
|
+
manifest: JSON.parse(bytes.toString('utf8')),
|
|
760
|
+
recommendation,
|
|
761
|
+
context_manifest_path: existingPath,
|
|
762
|
+
manifest_hash: existingHash,
|
|
763
|
+
};
|
|
764
|
+
}
|
|
765
|
+
const manifest = {
|
|
766
|
+
schema_version: semanticManifest.schema_version,
|
|
767
|
+
snapshot_id: snapshotId,
|
|
768
|
+
generated_at: nowIso(),
|
|
769
|
+
...Object.fromEntries(Object.entries(semanticManifest).filter(([key]) => key !== 'schema_version')),
|
|
770
|
+
};
|
|
737
771
|
const artifactDir = path.resolve(rootDir, change.artifact_root);
|
|
738
772
|
fs.mkdirSync(artifactDir, { recursive: true });
|
|
739
773
|
const manifestPath = path.join(artifactDir, 'contexts', `${snapshotId}.json`);
|
|
@@ -1005,7 +1039,7 @@ function assertTaskCreationAllowed(db, input = {}, { rootDir = process.cwd() } =
|
|
|
1005
1039
|
function updateChange(db, id, patch = {}, { rootDir = process.cwd() } = {}) {
|
|
1006
1040
|
const current = readChange(db, id);
|
|
1007
1041
|
if (!current) throw new ChangeWorkflowError('CHANGE_NOT_FOUND', `change ${id} not found`);
|
|
1008
|
-
if (['
|
|
1042
|
+
if (['archived', 'cancelled'].includes(current.status)) {
|
|
1009
1043
|
throw new ChangeWorkflowError('CHANGE_NOT_MUTABLE', `change ${id} is ${current.status}`);
|
|
1010
1044
|
}
|
|
1011
1045
|
for (const field of Object.keys(patch)) {
|
|
@@ -1059,6 +1093,8 @@ function updateChange(db, id, patch = {}, { rootDir = process.cwd() } = {}) {
|
|
|
1059
1093
|
);
|
|
1060
1094
|
}
|
|
1061
1095
|
sets.push('status = ?'); values.push(patch.status);
|
|
1096
|
+
} else if (current.status === 'ready' && Object.keys(patch).length > 0) {
|
|
1097
|
+
sets.push('status = ?'); values.push('active');
|
|
1062
1098
|
}
|
|
1063
1099
|
if (sets.length === 0) return current;
|
|
1064
1100
|
sets.push('updated_at = ?'); values.push(nowIso(), id);
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
const fs = require('node:fs');
|
|
4
4
|
const path = require('node:path');
|
|
5
5
|
|
|
6
|
-
const CONTEXT_ROOT_RELATIVE = '.ultra/
|
|
6
|
+
const CONTEXT_ROOT_RELATIVE = '.ultra/.runtime/projections/contexts';
|
|
7
|
+
const LEGACY_CONTEXT_ROOT_RELATIVE = '.ultra/tasks/contexts';
|
|
7
8
|
const GENERATED_BY = 'ultra-projector';
|
|
8
9
|
|
|
9
10
|
class ContextPathError extends Error {
|
|
@@ -77,9 +78,12 @@ function resolveContextPath(rootDir, value, {
|
|
|
77
78
|
if (raw.split('/').includes('..')) invalid(original, 'parent traversal is not allowed');
|
|
78
79
|
|
|
79
80
|
if (allowLegacyAliases && raw.startsWith('contexts/')) {
|
|
80
|
-
raw =
|
|
81
|
+
raw = `${LEGACY_CONTEXT_ROOT_RELATIVE}/${raw.slice('contexts/'.length)}`;
|
|
81
82
|
} else if (allowLegacyAliases && !raw.includes('/')) {
|
|
82
|
-
raw = `${
|
|
83
|
+
raw = `${LEGACY_CONTEXT_ROOT_RELATIVE}/${raw}`;
|
|
84
|
+
}
|
|
85
|
+
if (raw.startsWith(`${LEGACY_CONTEXT_ROOT_RELATIVE}/`)) {
|
|
86
|
+
raw = `${CONTEXT_ROOT_RELATIVE}/${raw.slice(LEGACY_CONTEXT_ROOT_RELATIVE.length + 1)}`;
|
|
83
87
|
}
|
|
84
88
|
|
|
85
89
|
const relative = path.posix.normalize(raw);
|
|
@@ -98,14 +102,41 @@ function resolveContextPath(rootDir, value, {
|
|
|
98
102
|
return { root, contextRoot, relative, file };
|
|
99
103
|
}
|
|
100
104
|
|
|
105
|
+
function resolveLegacyContextPath(rootDir, value, { taskId } = {}) {
|
|
106
|
+
const root = path.resolve(rootDir || '.');
|
|
107
|
+
const original = value || `${LEGACY_CONTEXT_ROOT_RELATIVE}/task-${taskId}.md`;
|
|
108
|
+
if (typeof original !== 'string' || original.trim() === '' || original.includes('\0')) {
|
|
109
|
+
invalid(original, 'a non-empty legacy path is required');
|
|
110
|
+
}
|
|
111
|
+
if (path.isAbsolute(original)) invalid(original, 'legacy paths must be project-relative');
|
|
112
|
+
const raw = original.trim().replaceAll('\\', '/').replace(/^\.\//, '');
|
|
113
|
+
if (raw.split('/').includes('..')) invalid(original, 'parent traversal is not allowed');
|
|
114
|
+
const relative = path.posix.normalize(raw);
|
|
115
|
+
if (!relative.startsWith(`${LEGACY_CONTEXT_ROOT_RELATIVE}/`)
|
|
116
|
+
|| relative === `${LEGACY_CONTEXT_ROOT_RELATIVE}/`
|
|
117
|
+
|| relative.endsWith('/')) {
|
|
118
|
+
invalid(original, `legacy path must be a file below ${LEGACY_CONTEXT_ROOT_RELATIVE}`);
|
|
119
|
+
}
|
|
120
|
+
const file = path.resolve(root, ...relative.split('/'));
|
|
121
|
+
const contextRoot = path.resolve(root, ...LEGACY_CONTEXT_ROOT_RELATIVE.split('/'));
|
|
122
|
+
if (!file.startsWith(`${contextRoot}${path.sep}`)) {
|
|
123
|
+
invalid(original, `legacy path escapes ${LEGACY_CONTEXT_ROOT_RELATIVE}`);
|
|
124
|
+
}
|
|
125
|
+
assertNoSymlinkAncestors(root, relative, original);
|
|
126
|
+
assertRealContainment(root, file, original);
|
|
127
|
+
return { root, contextRoot, relative, file };
|
|
128
|
+
}
|
|
129
|
+
|
|
101
130
|
function isGeneratedContextContents(contents) {
|
|
102
131
|
return new RegExp(`^generated_by:\\s*${GENERATED_BY}\\s*$`, 'm').test(String(contents));
|
|
103
132
|
}
|
|
104
133
|
|
|
105
134
|
module.exports = {
|
|
106
135
|
CONTEXT_ROOT_RELATIVE,
|
|
136
|
+
LEGACY_CONTEXT_ROOT_RELATIVE,
|
|
107
137
|
GENERATED_BY,
|
|
108
138
|
ContextPathError,
|
|
109
139
|
isGeneratedContextContents,
|
|
110
140
|
resolveContextPath,
|
|
141
|
+
resolveLegacyContextPath,
|
|
111
142
|
};
|
|
@@ -18,6 +18,7 @@ const decisions = require('./decision-dialogue.cjs');
|
|
|
18
18
|
const runtimePaths = require('./runtime-paths.cjs');
|
|
19
19
|
const artifactRegistry = require('./artifact-registry.cjs');
|
|
20
20
|
const planStore = require('./plan-store.cjs');
|
|
21
|
+
const taskLedger = require('./task-ledger.cjs');
|
|
21
22
|
const closeJournal = require('../../orchestrator/session-close-journal.cjs');
|
|
22
23
|
|
|
23
24
|
const SPEC_CONSUMER = 'spec-staleness';
|
|
@@ -84,6 +85,28 @@ function inspectSessionCloses(db, {
|
|
|
84
85
|
};
|
|
85
86
|
}
|
|
86
87
|
|
|
88
|
+
function inspectTeamTaskLedger(db, rootDir) {
|
|
89
|
+
try {
|
|
90
|
+
const health = taskLedger.inspectTaskLedger(db, { rootDir });
|
|
91
|
+
const taskCount = db.prepare('SELECT COUNT(*) AS count FROM tasks').get().count;
|
|
92
|
+
let status = 'warning';
|
|
93
|
+
if (health.status === 'current') status = 'pass';
|
|
94
|
+
return {
|
|
95
|
+
...health,
|
|
96
|
+
condition: health.status,
|
|
97
|
+
status,
|
|
98
|
+
task_count: taskCount,
|
|
99
|
+
};
|
|
100
|
+
} catch (error) {
|
|
101
|
+
return {
|
|
102
|
+
status: 'fail',
|
|
103
|
+
condition: 'invalid',
|
|
104
|
+
code: error.code || 'TASK_LEDGER_INVALID',
|
|
105
|
+
message: error.message,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
87
110
|
function inspectSystem(db, { rootDir = process.cwd() } = {}) {
|
|
88
111
|
const tables = new Set(tableNames(db));
|
|
89
112
|
const missing = REQUIRED_TABLES.filter((name) => !tables.has(name));
|
|
@@ -137,6 +160,9 @@ function inspectSystem(db, { rootDir = process.cwd() } = {}) {
|
|
|
137
160
|
const planPublications = missing.length === 0
|
|
138
161
|
? planStore.inspectPlanPublications(db, { rootDir })
|
|
139
162
|
: { status: 'fail', pending: 0, transactions: [], issues: [] };
|
|
163
|
+
const taskLedgerHealth = missing.length === 0
|
|
164
|
+
? inspectTeamTaskLedger(db, rootDir)
|
|
165
|
+
: { status: 'fail', condition: 'state_unavailable', code: 'STATE_DB_UNAVAILABLE' };
|
|
140
166
|
const baselineCheckStatus = baseline.status === 'pass'
|
|
141
167
|
? 'pass'
|
|
142
168
|
: (baseline.baseline?.status === 'ready' ? 'fail' : 'warning');
|
|
@@ -148,6 +174,7 @@ function inspectSystem(db, { rootDir = process.cwd() } = {}) {
|
|
|
148
174
|
|| decisionHealth.status === 'fail'
|
|
149
175
|
|| artifactHealth.status === 'fail'
|
|
150
176
|
|| planPublications.status === 'fail'
|
|
177
|
+
|| taskLedgerHealth.status === 'fail'
|
|
151
178
|
|| baselineCheckStatus === 'fail';
|
|
152
179
|
return {
|
|
153
180
|
status: degraded ? 'degraded' : 'healthy',
|
|
@@ -167,6 +194,7 @@ function inspectSystem(db, { rootDir = process.cwd() } = {}) {
|
|
|
167
194
|
},
|
|
168
195
|
change_artifacts: { status: activeMissing === 0 ? 'pass' : 'fail', missing: activeMissing },
|
|
169
196
|
artifacts: artifactHealth,
|
|
197
|
+
task_ledger: taskLedgerHealth,
|
|
170
198
|
plan_publications: planPublications,
|
|
171
199
|
archive_recovery: {
|
|
172
200
|
status: archiveIntents.length === 0 ? 'pass' : 'fail',
|
|
@@ -17,6 +17,7 @@ const Database = require('better-sqlite3');
|
|
|
17
17
|
|
|
18
18
|
const { EXPECTED_VERSION, initStateDb } = require('./state-db.cjs');
|
|
19
19
|
const { assertStateAuthority, inspectProjection } = require('./state-authority.cjs');
|
|
20
|
+
const taskLedger = require('./task-ledger.cjs');
|
|
20
21
|
const baselines = require('./baseline-workflow.cjs');
|
|
21
22
|
const gitBootstrap = require('./git-bootstrap.cjs');
|
|
22
23
|
const runtimePaths = require('./runtime-paths.cjs');
|
|
@@ -417,6 +418,8 @@ function createResumeSnapshot(ultraDir, stateDbPath) {
|
|
|
417
418
|
const projectionTargets = [
|
|
418
419
|
path.join('tasks', 'tasks.json'),
|
|
419
420
|
path.join('tasks', 'contexts'),
|
|
421
|
+
path.join('.runtime', 'projections'),
|
|
422
|
+
path.join('.runtime', 'backups', 'task-ledger'),
|
|
420
423
|
];
|
|
421
424
|
let db;
|
|
422
425
|
try {
|
|
@@ -621,6 +624,7 @@ function resumeProject({
|
|
|
621
624
|
let copiedFiles = [];
|
|
622
625
|
let baselineResult;
|
|
623
626
|
let workflowResult;
|
|
627
|
+
let teamCheckpoint;
|
|
624
628
|
let resumeSnapshot;
|
|
625
629
|
const completeResume = () => {
|
|
626
630
|
try {
|
|
@@ -631,7 +635,11 @@ function resumeProject({
|
|
|
631
635
|
}
|
|
632
636
|
initialized = initStateDb(stateDbPath);
|
|
633
637
|
const { db } = initialized;
|
|
634
|
-
if (projection &&
|
|
638
|
+
if (projection && (
|
|
639
|
+
projection.taskCount > 0 || projection.kind === taskLedger.LEDGER_KIND
|
|
640
|
+
)) {
|
|
641
|
+
teamCheckpoint = assertStateAuthority(db, absTarget, { importTeamLedger: true });
|
|
642
|
+
}
|
|
635
643
|
copiedFiles = copyMissingTemplate(source, ultraDir);
|
|
636
644
|
validateTasksTemplate(ultraDir);
|
|
637
645
|
repositoryProfile.git = gitSetup.result;
|
|
@@ -653,6 +661,26 @@ function resumeProject({
|
|
|
653
661
|
}, { rootDir: absTarget });
|
|
654
662
|
}
|
|
655
663
|
workflowResult = ensureInitializationWorkflows(db, baselineResult, { rootDir: absTarget });
|
|
664
|
+
if (projection?.kind === 'legacy-task-projection') {
|
|
665
|
+
const publication = taskLedger.publishTaskLedger(db, {
|
|
666
|
+
rootDir: absTarget,
|
|
667
|
+
reason: 'legacy_projection_upgraded',
|
|
668
|
+
});
|
|
669
|
+
teamCheckpoint = {
|
|
670
|
+
generation: publication.ledger.generation,
|
|
671
|
+
state_digest: publication.ledger.state_digest,
|
|
672
|
+
imported: 0,
|
|
673
|
+
deleted: 0,
|
|
674
|
+
imported_changes: 0,
|
|
675
|
+
imported_baseline: false,
|
|
676
|
+
requires_plan_revalidation: false,
|
|
677
|
+
requires_baseline_revalidation: false,
|
|
678
|
+
already_current: true,
|
|
679
|
+
published: publication.changed,
|
|
680
|
+
migrated_legacy_projection: Boolean(publication.migrated_legacy_projection),
|
|
681
|
+
legacy_backup_path: publication.legacy_backup_path || null,
|
|
682
|
+
};
|
|
683
|
+
}
|
|
656
684
|
ops.appendEvent(db, {
|
|
657
685
|
type: 'project_resumed',
|
|
658
686
|
payload: {
|
|
@@ -687,7 +715,12 @@ function resumeProject({
|
|
|
687
715
|
}
|
|
688
716
|
}
|
|
689
717
|
const rollbackErrors = [...stateRollbackErrors, ...gitRollbackErrors];
|
|
690
|
-
if (error
|
|
718
|
+
if (error instanceof taskLedger.TaskLedgerError
|
|
719
|
+
|| (error.code && [
|
|
720
|
+
'LEGACY_STATE_MIGRATION_REQUIRED',
|
|
721
|
+
'STATE_AUTHORITY_CONFLICT',
|
|
722
|
+
'STATE_PROJECTION_INVALID',
|
|
723
|
+
].includes(error.code))) {
|
|
691
724
|
throw new InitProjectError(
|
|
692
725
|
error.code,
|
|
693
726
|
error.message,
|
|
@@ -751,6 +784,22 @@ function resumeProject({
|
|
|
751
784
|
git: gitSetup.result,
|
|
752
785
|
workflow: workflowResult,
|
|
753
786
|
};
|
|
787
|
+
if (teamCheckpoint) {
|
|
788
|
+
result.team_checkpoint = {
|
|
789
|
+
generation: teamCheckpoint.generation,
|
|
790
|
+
state_digest: teamCheckpoint.state_digest,
|
|
791
|
+
imported_tasks: teamCheckpoint.imported,
|
|
792
|
+
deleted_tasks: teamCheckpoint.deleted,
|
|
793
|
+
imported_changes: teamCheckpoint.imported_changes,
|
|
794
|
+
imported_baseline: teamCheckpoint.imported_baseline,
|
|
795
|
+
requires_plan_revalidation: teamCheckpoint.requires_plan_revalidation,
|
|
796
|
+
requires_baseline_revalidation: teamCheckpoint.requires_baseline_revalidation,
|
|
797
|
+
already_current: teamCheckpoint.already_current,
|
|
798
|
+
published: Boolean(teamCheckpoint.published),
|
|
799
|
+
migrated_legacy_projection: Boolean(teamCheckpoint.migrated_legacy_projection),
|
|
800
|
+
legacy_backup_path: teamCheckpoint.legacy_backup_path || undefined,
|
|
801
|
+
};
|
|
802
|
+
}
|
|
754
803
|
if (initialized.backup_path) result.migration_backup_path = initialized.backup_path;
|
|
755
804
|
if (runtimeLayout.backupPath) {
|
|
756
805
|
result.runtime_migration_backup_path = runtimeLayout.backupPath;
|
|
@@ -815,6 +864,7 @@ function initProject({
|
|
|
815
864
|
);
|
|
816
865
|
}
|
|
817
866
|
if (projection && projection.taskCount > 0
|
|
867
|
+
&& projection.kind !== taskLedger.LEDGER_KIND
|
|
818
868
|
&& !fs.existsSync(locatedStatePath)) {
|
|
819
869
|
throw projectionMigrationError(projection);
|
|
820
870
|
}
|
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
// Projector: regenerates the read-only file views from .ultra/.runtime/state.db.
|
|
4
4
|
//
|
|
5
5
|
// state.db owns lifecycle and artifact bindings (D32); registered digest-bound
|
|
6
|
-
// files own semantic/evidence bodies. tasks.json and contexts/task-*.md are
|
|
7
|
-
//
|
|
6
|
+
// files own semantic/evidence bodies. Live tasks.json and contexts/task-*.md are
|
|
7
|
+
// checkout-local projections — humans may read them, never write them. The projector
|
|
8
8
|
// is trigger-based: state-ops calls projectAll() after each successful
|
|
9
9
|
// write transaction. Projection output passes the v4.5 schemas under
|
|
10
10
|
// spec/schemas/ — see mcp-server/tests/projector.test.cjs for the
|
|
@@ -57,8 +57,8 @@ function rowToProjection(row) {
|
|
|
57
57
|
|
|
58
58
|
function defaultPaths(rootDir) {
|
|
59
59
|
return {
|
|
60
|
-
tasksJson: path.join(rootDir, '.ultra', '
|
|
61
|
-
contextsDir: path.join(rootDir, '.ultra', '
|
|
60
|
+
tasksJson: path.join(rootDir, '.ultra', '.runtime', 'projections', 'tasks.json'),
|
|
61
|
+
contextsDir: path.join(rootDir, '.ultra', '.runtime', 'projections', 'contexts'),
|
|
62
62
|
};
|
|
63
63
|
}
|
|
64
64
|
|
|
@@ -266,7 +266,27 @@ function projectContext(db, taskId, { contextsDir } = {}, opts = {}) {
|
|
|
266
266
|
}
|
|
267
267
|
}
|
|
268
268
|
const target = contextPaths.resolveContextPath(rootDir, projection.context_file, { taskId });
|
|
269
|
-
const
|
|
269
|
+
const legacyValue = String(projection.context_file || '')
|
|
270
|
+
.replaceAll('\\', '/')
|
|
271
|
+
.replace(/^\.\//, '');
|
|
272
|
+
const legacyDefault = `${contextPaths.LEGACY_CONTEXT_ROOT_RELATIVE}/${
|
|
273
|
+
path.posix.basename(target.relative)
|
|
274
|
+
}`;
|
|
275
|
+
const legacyTarget = contextPaths.resolveLegacyContextPath(
|
|
276
|
+
rootDir,
|
|
277
|
+
legacyValue.startsWith(`${contextPaths.LEGACY_CONTEXT_ROOT_RELATIVE}/`)
|
|
278
|
+
? legacyValue
|
|
279
|
+
: legacyDefault,
|
|
280
|
+
{ taskId },
|
|
281
|
+
);
|
|
282
|
+
const legacyPromoted = promoteAuthoredContext(db, taskId, legacyTarget, rootDir);
|
|
283
|
+
if (fs.existsSync(legacyTarget.file)
|
|
284
|
+
&& contextPaths.isGeneratedContextContents(fs.readFileSync(legacyTarget.file, 'utf8'))) {
|
|
285
|
+
fs.rmSync(legacyTarget.file, { force: true });
|
|
286
|
+
} else if (legacyPromoted) {
|
|
287
|
+
fs.rmSync(legacyTarget.file, { force: true });
|
|
288
|
+
}
|
|
289
|
+
const promoted = promoteAuthoredContext(db, taskId, target, rootDir) || legacyPromoted;
|
|
270
290
|
const next = buildContextDoc(projection);
|
|
271
291
|
writeAtomic(target.file, next);
|
|
272
292
|
return { path: target.file, task_id: taskId, promoted };
|
|
@@ -284,8 +304,7 @@ function listContextFiles(directory) {
|
|
|
284
304
|
return output;
|
|
285
305
|
}
|
|
286
306
|
|
|
287
|
-
function
|
|
288
|
-
const contextsDir = defaultPaths(rootDir).contextsDir;
|
|
307
|
+
function pruneGeneratedGhostsIn(db, rootDir, contextsDir, expected) {
|
|
289
308
|
const pruned = [];
|
|
290
309
|
for (const file of listContextFiles(contextsDir)) {
|
|
291
310
|
const relative = path.relative(rootDir, file).split(path.sep).join('/');
|
|
@@ -306,7 +325,23 @@ function pruneGeneratedGhosts(db, rootDir, expected) {
|
|
|
306
325
|
throw error;
|
|
307
326
|
}
|
|
308
327
|
}
|
|
309
|
-
return pruned
|
|
328
|
+
return pruned;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function pruneGeneratedGhosts(db, rootDir, expected) {
|
|
332
|
+
const runtime = pruneGeneratedGhostsIn(
|
|
333
|
+
db,
|
|
334
|
+
rootDir,
|
|
335
|
+
defaultPaths(rootDir).contextsDir,
|
|
336
|
+
expected,
|
|
337
|
+
);
|
|
338
|
+
const legacy = pruneGeneratedGhostsIn(
|
|
339
|
+
db,
|
|
340
|
+
rootDir,
|
|
341
|
+
path.join(rootDir, ...contextPaths.LEGACY_CONTEXT_ROOT_RELATIVE.split('/')),
|
|
342
|
+
new Set(),
|
|
343
|
+
);
|
|
344
|
+
return [...runtime, ...legacy].sort();
|
|
310
345
|
}
|
|
311
346
|
|
|
312
347
|
function projectAll(db, { rootDir = '.', tasksJson, contextsDir } = {}) {
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
const fs = require('node:fs');
|
|
4
4
|
const path = require('node:path');
|
|
5
|
+
const taskLedger = require('./task-ledger.cjs');
|
|
5
6
|
|
|
6
7
|
class StateAuthorityError extends Error {
|
|
7
8
|
constructor(code, message, details) {
|
|
@@ -32,24 +33,39 @@ function inspectProjection(rootDir) {
|
|
|
32
33
|
{ projection_path: projectionPath },
|
|
33
34
|
);
|
|
34
35
|
}
|
|
36
|
+
if (projection.kind === taskLedger.LEDGER_KIND) {
|
|
37
|
+
const ledger = taskLedger.validateLedger(projection, projectionPath);
|
|
38
|
+
return {
|
|
39
|
+
path: projectionPath,
|
|
40
|
+
version: ledger.schema_version,
|
|
41
|
+
kind: ledger.kind,
|
|
42
|
+
taskCount: ledger.tasks.length,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
35
45
|
return {
|
|
36
46
|
path: projectionPath,
|
|
37
47
|
version: String(projection.version || projection.schema_version || 'unknown'),
|
|
48
|
+
kind: 'legacy-task-projection',
|
|
38
49
|
taskCount: projection.tasks.length,
|
|
39
50
|
};
|
|
40
51
|
}
|
|
41
52
|
|
|
42
|
-
function assertStateAuthority(db, rootDir) {
|
|
53
|
+
function assertStateAuthority(db, rootDir, { importTeamLedger = false } = {}) {
|
|
43
54
|
const authoritativeCount = db.prepare('SELECT COUNT(*) AS count FROM tasks').get().count;
|
|
44
|
-
if (authoritativeCount > 0) return;
|
|
45
55
|
const projection = inspectProjection(rootDir);
|
|
46
|
-
if (
|
|
56
|
+
if (projection?.kind === taskLedger.LEDGER_KIND) {
|
|
57
|
+
return importTeamLedger
|
|
58
|
+
? taskLedger.syncTaskLedger(db, { rootDir })
|
|
59
|
+
: taskLedger.inspectTaskLedger(db, { rootDir });
|
|
60
|
+
}
|
|
61
|
+
if (authoritativeCount > 0 || !projection || projection.taskCount === 0) return null;
|
|
47
62
|
|
|
48
63
|
const details = {
|
|
49
64
|
authoritative_task_count: authoritativeCount,
|
|
50
65
|
legacy_task_count: projection.taskCount,
|
|
51
66
|
projection_path: projection.path,
|
|
52
67
|
projection_version: projection.version,
|
|
68
|
+
projection_kind: projection.kind,
|
|
53
69
|
};
|
|
54
70
|
const migrationTargets = {
|
|
55
71
|
'4.4': '4.5',
|
|
@@ -518,7 +518,12 @@ function patchTask(db, id, patch = {}) {
|
|
|
518
518
|
}
|
|
519
519
|
if (nextStatus !== null) {
|
|
520
520
|
const allowed = STATUS_TRANSITIONS[current.status] || new Set();
|
|
521
|
-
|
|
521
|
+
const recoveringUncommittedCompletion = current.status === 'completed'
|
|
522
|
+
&& nextStatus === 'blocked'
|
|
523
|
+
&& !current.completion_commit
|
|
524
|
+
&& Boolean(current.session_id);
|
|
525
|
+
if (!allowed.has(nextStatus) && nextStatus !== current.status
|
|
526
|
+
&& !recoveringUncommittedCompletion) {
|
|
522
527
|
throw new StateOpsError(
|
|
523
528
|
'ILLEGAL_STATUS_TRANSITION',
|
|
524
529
|
`cannot transition task ${id} from ${current.status} to ${nextStatus}`,
|