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.
Files changed (58) hide show
  1. package/.ultra-template/tasks/tasks.json +10 -3
  2. package/.ultra-template/templates/task-context.md +19 -0
  3. package/CHANGELOG.md +78 -0
  4. package/README.md +210 -36
  5. package/adapters/_shared/codex-assets.cjs +8 -3
  6. package/adapters/_shared/interaction-contract.cjs +13 -6
  7. package/adapters/_shared/runtime-assets.cjs +1 -1
  8. package/adapters/claude.js +1 -1
  9. package/adapters/kimi.js +1 -1
  10. package/adapters/opencode.js +24 -13
  11. package/docs/AGENT-CONTEXT.md +58 -51
  12. package/docs/ARCHITECTURE.md +82 -51
  13. package/docs/ARTIFACT-AUTHORITY.md +83 -44
  14. package/docs/COMMIT-HASH-BACKFILL.md +33 -90
  15. package/docs/DECISIONS.md +9 -7
  16. package/docs/LEGACY-CLI-CROSSWALK.md +10 -2
  17. package/docs/PLUGIN-ISOLATION-CONTRACT.md +3 -2
  18. package/docs/ROADMAP.md +3 -3
  19. package/docs/RUNTIME-COMPAT-MATRIX.md +13 -10
  20. package/docs/STATE-DB-ACCESS-POLICY.md +47 -19
  21. package/docs/WORKFLOW-LIFECYCLE.md +79 -49
  22. package/hooks/README.md +5 -4
  23. package/hooks/active_task_context.py +41 -39
  24. package/mcp-server/lib/artifact-registry.cjs +32 -7
  25. package/mcp-server/lib/baseline-workflow.cjs +60 -24
  26. package/mcp-server/lib/change-workflow.cjs +42 -6
  27. package/mcp-server/lib/context-paths.cjs +34 -3
  28. package/mcp-server/lib/doctor.cjs +28 -0
  29. package/mcp-server/lib/git-bootstrap.cjs +0 -1
  30. package/mcp-server/lib/init-project.cjs +52 -2
  31. package/mcp-server/lib/projector.cjs +43 -8
  32. package/mcp-server/lib/state-authority.cjs +19 -3
  33. package/mcp-server/lib/state-ops.cjs +6 -1
  34. package/mcp-server/lib/task-ledger.cjs +1342 -0
  35. package/mcp-server/lib/ultra-facade.cjs +646 -0
  36. package/mcp-server/lib/workflow-state.cjs +166 -2
  37. package/mcp-server/server.cjs +214 -26
  38. package/package.json +1 -1
  39. package/skills/ultra-change/SKILL.md +38 -90
  40. package/skills/ultra-deliver/SKILL.md +42 -62
  41. package/skills/ultra-dev/SKILL.md +34 -73
  42. package/skills/ultra-doctor/SKILL.md +26 -39
  43. package/skills/ultra-init/SKILL.md +37 -69
  44. package/skills/ultra-plan/SKILL.md +52 -125
  45. package/skills/ultra-research/SKILL.md +41 -101
  46. package/skills/ultra-review/SKILL.md +25 -44
  47. package/skills/ultra-status/SKILL.md +21 -40
  48. package/skills/ultra-test/SKILL.md +25 -60
  49. package/skills/ultra-think/SKILL.md +29 -48
  50. package/skills/ultra-think/references/decision-dialogue.md +6 -4
  51. package/spec/cli-protocol.md +15 -0
  52. package/spec/mcp-tools.yaml +286 -0
  53. package/spec/schemas/context-file.v4.5.schema.json +1 -1
  54. package/spec/schemas/mcp-tools.schema.json +1 -1
  55. package/spec/schemas/state-db.sql +1 -1
  56. package/spec/schemas/task-ledger.v1.schema.json +217 -0
  57. package/ultra-tools/commands/migrate.cjs +5 -2
  58. 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 diff = head
80
- ? spawnSync(
81
- 'git', ['diff', '--binary', '--no-ext-diff', 'HEAD', ...args],
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 || diff.status !== 0 || untracked.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 digest = crypto.createHash('sha256');
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: digest.digest('hex'),
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) blockers.add('BASELINE_HEAD_STALE');
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
- blockers.push('BASELINE_HEAD_STALE');
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 snapshotId = `ctx-${crypto.randomUUID().slice(0, 12)}`;
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 (['ready', 'archived', 'cancelled'].includes(current.status)) {
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/tasks/contexts';
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 = `.ultra/tasks/${raw}`;
81
+ raw = `${LEGACY_CONTEXT_ROOT_RELATIVE}/${raw.slice('contexts/'.length)}`;
81
82
  } else if (allowLegacyAliases && !raw.includes('/')) {
82
- raw = `${CONTEXT_ROOT_RELATIVE}/${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',
@@ -62,7 +62,6 @@ const ULTRA_GIT_ARTIFACT_CLASSES = Object.freeze({
62
62
  '.ultra/tasks/tasks.json',
63
63
  '.ultra/tasks/plan.md',
64
64
  '.ultra/tasks/plan.json',
65
- '.ultra/tasks/contexts/example.md',
66
65
  ]),
67
66
  research: Object.freeze([
68
67
  '.ultra/docs/research/README.md',
@@ -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 && projection.taskCount > 0) assertStateAuthority(db, absTarget);
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.code && ['LEGACY_STATE_MIGRATION_REQUIRED', 'STATE_AUTHORITY_CONFLICT', 'STATE_PROJECTION_INVALID'].includes(error.code)) {
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
- // complete projections — humans may read them, never write them. The projector
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', 'tasks', 'tasks.json'),
61
- contextsDir: path.join(rootDir, '.ultra', 'tasks', 'contexts'),
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 promoted = promoteAuthoredContext(db, taskId, target, rootDir);
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 pruneGeneratedGhosts(db, rootDir, expected) {
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.sort();
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 (!projection || projection.taskCount === 0) return;
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
- if (!allowed.has(nextStatus) && nextStatus !== current.status) {
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}`,