ultra-builder-pro-cli 0.21.1 → 0.22.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 (47) 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 +40 -0
  4. package/README.md +166 -22
  5. package/adapters/opencode.js +23 -12
  6. package/docs/AGENT-CONTEXT.md +22 -13
  7. package/docs/ARCHITECTURE.md +59 -24
  8. package/docs/ARTIFACT-AUTHORITY.md +70 -30
  9. package/docs/COMMIT-HASH-BACKFILL.md +33 -90
  10. package/docs/DECISIONS.md +9 -7
  11. package/docs/LEGACY-CLI-CROSSWALK.md +10 -2
  12. package/docs/PLUGIN-ISOLATION-CONTRACT.md +3 -2
  13. package/docs/ROADMAP.md +3 -3
  14. package/docs/RUNTIME-COMPAT-MATRIX.md +8 -6
  15. package/docs/STATE-DB-ACCESS-POLICY.md +35 -7
  16. package/docs/WORKFLOW-LIFECYCLE.md +40 -16
  17. package/hooks/README.md +5 -4
  18. package/hooks/active_task_context.py +41 -39
  19. package/mcp-server/lib/artifact-registry.cjs +7 -6
  20. package/mcp-server/lib/baseline-workflow.cjs +60 -24
  21. package/mcp-server/lib/context-paths.cjs +34 -3
  22. package/mcp-server/lib/doctor.cjs +28 -0
  23. package/mcp-server/lib/git-bootstrap.cjs +0 -1
  24. package/mcp-server/lib/init-project.cjs +52 -2
  25. package/mcp-server/lib/projector.cjs +43 -8
  26. package/mcp-server/lib/state-authority.cjs +19 -3
  27. package/mcp-server/lib/state-ops.cjs +6 -1
  28. package/mcp-server/lib/task-ledger.cjs +1342 -0
  29. package/mcp-server/server.cjs +145 -9
  30. package/package.json +1 -1
  31. package/skills/ultra-change/SKILL.md +10 -2
  32. package/skills/ultra-deliver/SKILL.md +6 -3
  33. package/skills/ultra-dev/SKILL.md +21 -6
  34. package/skills/ultra-doctor/SKILL.md +10 -1
  35. package/skills/ultra-init/SKILL.md +18 -9
  36. package/skills/ultra-plan/SKILL.md +11 -4
  37. package/skills/ultra-research/SKILL.md +12 -13
  38. package/skills/ultra-review/SKILL.md +5 -3
  39. package/skills/ultra-status/SKILL.md +11 -7
  40. package/skills/ultra-test/SKILL.md +3 -2
  41. package/spec/cli-protocol.md +3 -0
  42. package/spec/mcp-tools.yaml +127 -0
  43. package/spec/schemas/context-file.v4.5.schema.json +1 -1
  44. package/spec/schemas/state-db.sql +1 -1
  45. package/spec/schemas/task-ledger.v1.schema.json +217 -0
  46. package/ultra-tools/commands/migrate.cjs +5 -2
  47. package/.ultra-template/tasks/contexts/TEMPLATE.md +0 -19
@@ -3,12 +3,14 @@
3
3
  > Multi-process access contract. Current authority is defined by
4
4
  > [`DECISIONS.md`](./DECISIONS.md) and the live database schema.
5
5
 
6
- `.ultra/.runtime/state.db` is the only lifecycle, index, transition, freshness, and
7
- coordination authority for baselines, changes, decision threads/items, tasks,
6
+ `.ultra/.runtime/state.db` is the checkout-local lifecycle, index, transition,
7
+ freshness, and coordination authority for baselines, changes, decision threads/items, tasks,
8
8
  workflow runs/steps, events, sessions, incidents, projections, telemetry, and spec
9
9
  references (D18, D52, D54). Registered digest-bound files carry semantic and evidence
10
- bodies; the DB owns their references and current digests. Every process
11
- that touches it must follow the rules below; deviations are bugs.
10
+ bodies; the DB owns their references and current digests. The tracked
11
+ `.ultra/tasks/tasks.json` is an MCP-published handoff checkpoint, not a parallel live
12
+ writer. Every process that touches the DB must follow the rules below; deviations are
13
+ bugs.
12
14
 
13
15
  ## 1. Three-role write matrix
14
16
 
@@ -87,10 +89,33 @@ resolve the mount/runtime constraint before retrying.
87
89
  | `migration_history`| schema initializer and `ultra-tools migrate` or doctor repair |
88
90
  | `schema_version` | schema initializer, `ultra-tools db init`, migration, and doctor repair |
89
91
 
90
- ## 5. Forbidden patterns
92
+ ## 5. Git team checkpoint
93
+
94
+ Only `task.ledger_publish` may derive `.ultra/tasks/tasks.json` from SQLite, and only
95
+ `task.ledger_import` may merge it into SQLite. Server startup performs one import after
96
+ opening project authority; later pulls in a long-running process require an explicit
97
+ import. The same checkpoint is idempotent and must not append an event again.
98
+
99
+ The checkpoint carries portable baseline, Change, and durable task records with
100
+ per-record revisions and digests. It excludes local `in_progress` ownership, sessions,
101
+ leases, worktrees, telemetry, and completion commit hashes. Import validates document
102
+ ancestry plus record-level common bases. It fast-forwards clean records and fails
103
+ before commit on same-record concurrency, parent/owner gaps, non-descendant history, or
104
+ an active-task collision. A Git-ready baseline is downgraded to checkout-local
105
+ revalidation rather than trusted as execution authority.
106
+
107
+ Legacy v4.4/v4.5 task projections are backed up and replaced only when every available
108
+ durable field matches current SQLite. Doctor reports checkpoint condition but never
109
+ performs semantic import, publication, or conflict selection.
110
+
111
+ ## 6. Forbidden patterns
91
112
 
92
113
  - **No file copies for state.** Don't `cp .ultra/.runtime/state.db
93
114
  somewhere/state.db` and edit; use `ultra-tools db backup`.
115
+ - **No direct checkpoint edits.** Don't merge or repair
116
+ `.ultra/tasks/tasks.json` by hand; use the typed import/publish conflict path.
117
+ - **No committed live projections.** `.ultra/.runtime/projections/**` remains ignored
118
+ checkout-local output.
94
119
  - **No long-held writer connections** outside the MCP server. Pop a
95
120
  short transaction, finish, close.
96
121
  - **No `PRAGMA journal_mode` toggling at runtime** by any process other
@@ -100,14 +125,14 @@ resolve the mount/runtime constraint before retrying.
100
125
  - **No `vacuum` / `wal_checkpoint(TRUNCATE)` from inside a transaction.**
101
126
  Maintenance subcommands open their own connection.
102
127
 
103
- ## 6. Maintenance windows
128
+ ## 7. Maintenance windows
104
129
 
105
130
  `ultra-tools db checkpoint` and `ultra-tools db vacuum` are safe to run
106
131
  while the MCP server is up (they take their own connection and obey the
107
132
  busy timeout). `db backup` uses better-sqlite3's online `.backup` API
108
133
  and produces a consistent snapshot without blocking writers.
109
134
 
110
- ## 7. Verification
135
+ ## 8. Verification
111
136
 
112
137
  The contract on this page is enforced by:
113
138
 
@@ -119,3 +144,6 @@ The contract on this page is enforced by:
119
144
  status transitions, task contracts, and event coupling.
120
145
  - `mcp-server/lib/workflow-state.test.cjs` — ordered workflow transitions,
121
146
  evidence/output requirements, blocking/resume, and cross-stage gates.
147
+ - `mcp-server/lib/task-ledger.test.cjs` — portable-field filtering, digest ancestry,
148
+ idempotent import, baseline/Change/task fast-forward and conflict behavior, legacy
149
+ backup, and active-session protection.
@@ -12,10 +12,11 @@ host and project owner. It is the operational companion to
12
12
  | Active host and owner | Product judgment, research content, implementation, review findings, and authorization for destructive or external effects | Durable workflow status or fabricated evidence digests |
13
13
  | Skill | One reusable procedure and its evidence requirements | Project state, static product doctrine, host-specific tool fiction |
14
14
  | MCP | IDs, valid transitions, hard recovery requirements, task contracts, evidence references, output hashes, and gate verdicts | Semantic route selection, research prose, code generation, general memory-provider payloads, or model calls |
15
- | `.ultra/.runtime/state.db` | Lifecycle, index, transition, freshness, and coordination authority for baseline, change, decision, task, workflow, session, event, incident, projection, and evidence references | Prompts, transcripts, general external-memory payloads, semantic prose, or code-graph payloads |
15
+ | `.ultra/.runtime/state.db` | Checkout-local lifecycle, index, transition, freshness, and coordination authority for baseline, change, decision, task, workflow, session, event, incident, projection, and evidence references | Prompts, transcripts, general external-memory payloads, semantic prose, or code-graph payloads |
16
+ | `.ultra/tasks/tasks.json` | MCP-published Git checkpoint for portable baseline, Change, task-contract, dependency, and durable status records | Live sessions, leases, `in_progress` ownership, completion hashes, or a parallel write API |
16
17
  | Digest-bound artifacts | Registered specification, research, Change, context, test, review, delivery, and verification bodies | Independent lifecycle state or content whose digest no longer matches |
17
- | Generated projections and scratch | Human-readable views and temporary working material | Lifecycle, semantic, or evidence authority before supported promotion |
18
- | Hook | Compact DB-derived lifecycle observation, minimal event metadata, and projection protection | Generic engineering judgment, prompt/transcript capture, parallel lifecycle logs, or arbitrary edit blocking |
18
+ | Generated projections and scratch | Checkout-local views below `.ultra/.runtime/` and temporary working material | Lifecycle, semantic, or evidence authority before supported promotion |
19
+ | Hook | Compact DB-derived lifecycle observation, minimal event metadata, and protection of MCP-owned checkpoint/projection paths | Generic engineering judgment, prompt/transcript capture, parallel lifecycle logs, or arbitrary edit blocking |
19
20
 
20
21
  Together, `.ultra/` is project-local cross-session workflow memory. The detailed
21
22
  artifact classes and promotion rules live in
@@ -76,8 +77,10 @@ signals. In a monorepo, the owner selects the repository scope; baseline
76
77
  worktree digests and accepted dirty files are restricted to that scope.
77
78
 
78
79
  Classification and readiness are separate. Initialization immediately creates
79
- or resumes `.ultra/.runtime/state.db`, installs only the required scaffold, records the
80
- classification, and completes an `init` workflow after read-back verification. It
80
+ or resumes `.ultra/.runtime/state.db`, creates or imports the Git team checkpoint,
81
+ installs only the required scaffold, records the classification, and completes an
82
+ `init` workflow after read-back verification. A ready checkpoint baseline is held for
83
+ checkout-local revalidation. Init
81
84
  does not create a research run. A later explicit `ultra-research` invocation selects
82
85
  `full` for greenfield or `adoption` for brownfield, records coverage dispositions, and
83
86
  works toward baseline readiness. Empty templates are not an approved baseline.
@@ -226,24 +229,41 @@ stores the conversation transcript.
226
229
 
227
230
  | Stage | Authoritative writes | Completion verification | Available continuation |
228
231
  |---|---|---|---|
229
- | `init` | Baseline classification/scope, Git bootstrap state, completed init run, initialization event, projection job | DB/schema opens, scaffold/projection and read-back succeed; existing Git is preserved or missing local Git is initialized | Explicitly chosen `research`, `status`, or `doctor`; an already-ready project may also enter `change` |
232
+ | `init` | Baseline classification/scope, Git bootstrap state, completed init run, initialization event, empty or imported team checkpoint, projection job | DB/schema opens, scaffold/local projection/checkpoint and read-back succeed; existing Git is preserved or missing local Git is initialized; imported ready baseline is not trusted before local revalidation | Explicitly chosen `research`, `status`, or `doctor`; an already-ready project may also enter `change` |
230
233
  | `research` | Accepted coverage dispositions, selected step evidence, normalized material decisions, optional artifact checkpoints, typed semantic records with source digests, immutable reports, output paths and SHA-256 digests | Every included coverage area is dispositioned; only executed/verified/reused areas require work evidence; baseline synthesis binds current specifications and distillate, while Change synthesis stays in its overlay; all material decisions and digests are current | Baseline record/convergence for initial research; bounded Change research records its delta and returns to plan |
231
- | baseline convergence | Spec/source/runtime refs, verification results, known failures, unknowns, gaps, branch/HEAD/worktree, approval | Full/adoption research complete; required discovery/product/architecture refs current; revision/worktree exact; blocking gaps resolved or explicitly accepted where allowed | `change` for every initial or daily outcome |
232
- | `change` | Complete Change Contract, profile/risk rationale, research disposition, isolated Change root, typed delta, and a completed capture run; a decision thread only when material intent remains unresolved | Intent, acceptance, recovery, classification, research disposition, and exact baseline anchors are current | Model recommends; owner selects bounded `research` or direct `plan`; both routes converge on plan before development |
233
- | `plan` | Accepted planning posture, immutable `plan/planning` context, selected planning evidence, complete change-owned task rows, acceptance coverage matrix, and deterministic `<artifact_root>/plan.json` plus `plan.md`; approval only for a material owner decision | Every Change acceptance id is owned by a task; trace targets, ownership, dependencies, topology, task contracts, planning-context binding, and exported digests match current authority | Any dependency-ready task may enter `dev` |
234
- | `dev` | Task/session transitions, real worktree, step evidence, immutable implementation context, completion/review references | Task completed; no live session; task contract unchanged; starting context and review artifacts valid; completion commits are integrated and remain ancestors of current HEAD | Next task, then aggregate `test` |
234
+ | baseline convergence | Spec/source/runtime refs, verification results, known failures, unknowns, gaps, branch/HEAD/worktree, approval, and a published team checkpoint | Full/adoption research complete; required discovery/product/architecture refs current; revision/worktree exact; blocking gaps resolved or explicitly accepted where allowed | `change` for every initial or daily outcome |
235
+ | `change` | Complete Change Contract, profile/risk rationale, research disposition, isolated Change root, typed delta, completed capture run, and portable Change checkpoint; a decision thread only when material intent remains unresolved | Intent, acceptance, recovery, classification, research disposition, exact baseline anchors, and checkpoint digest are current | Model recommends; owner selects bounded `research` or direct `plan`; both routes converge on plan before development |
236
+ | `plan` | Accepted planning posture, immutable `plan/planning` context, selected planning evidence, complete change-owned task rows, acceptance coverage matrix, deterministic `<artifact_root>/plan.json` plus `plan.md`, and published task contracts; approval only for a material owner decision | Every Change acceptance id is owned by a task; trace targets, ownership, dependencies, topology, task contracts, planning-context binding, exported digests, and checkpoint generation match current authority | Any dependency-ready task may enter `dev` |
237
+ | `dev` | Task/session transitions, real worktree, step evidence, immutable implementation context, completion/review references, and published durable task transitions | Task completed; no live session; task contract unchanged; starting context and review artifacts valid; the single completion commit is integrated and remains an ancestor of current HEAD; its hash is backfilled locally without another commit | Next task, then aggregate `test` |
235
238
  | `test` | Immutable checking context, an explicit risk-selected verification profile, and `<artifact_root>/test/<workflow-id>/report.json` | Selected dimensions and exact Change acceptance ids pass; every excluded dimension has a rationale; report change/task set, HEAD, worktree, context, commands, blockers, and digests agree | `review` when passing, otherwise resume `dev`/`test` |
236
239
  | `review` | Immutable review context plus risk-selected specialist and summary artifacts below `<artifact_root>/review/<workflow-id>/` | Current diff/HEAD and context match; the complete worker roster is selected or skipped with rationale; completed workers match specialist artifacts; both axes complete; findings and axis verdicts derive the final verdict | `deliver` only from a passing `change` review, otherwise resume implementation or checking |
237
240
  | `deliver` | Convergence context, typed delta, documentation reconciliation, recoverable overlay transaction, self-contained archive, and immutable delivery report | Tasks/test/change-review and acceptance are current; every before/after digest and baseline anchor is current; the archive transaction is complete; the report contains no release action | Report local completion or start the next `change`; publish/deploy/push are separate explicitly authorized operations |
238
- | `status` | None | Reads doctor, baseline, workflows, breadcrumb, tasks, sessions, Git, and DB-referenced reports | Shows allowed transitions, any hard required transition, and a host-owned recommendation |
239
- | `doctor` | None by default; explicit repair writes only mechanical recovery state | Re-runs integrity, schema, workflow-output, projection, session, incident, archive, and install checks | Mechanically required recovery when unique; otherwise a set of safe recovery capabilities |
241
+ | `status` | None | Reads doctor, team-checkpoint condition, baseline, workflows, breadcrumb, tasks, sessions, Git, and DB-referenced reports | Shows allowed transitions, any hard required transition, and a host-owned recommendation |
242
+ | `doctor` | None by default; explicit repair writes only mechanical recovery state | Re-runs integrity, schema, workflow-output, local projection, team-checkpoint, session, incident, archive, and install checks without choosing import/publish conflict resolution | Mechanically required recovery when unique; otherwise a set of safe recovery capabilities |
240
243
 
241
- Every mutating MCP operation appends an event and enqueues projection work when
242
- the corresponding read-only view can change. Projection failure is recorded as
244
+ Every mutating MCP operation appends an event and enqueues local projection work when
245
+ the corresponding checkout-local read-only view can change. Projection failure is recorded as
243
246
  a retryable incident; it does not silently convert a failed write into success.
247
+ Semantic handoff operations separately publish the Git team checkpoint.
244
248
  `task.append_event` accepts only non-authoritative observations. Lifecycle events are
245
249
  emitted by their owning DB mutation and never act as a substitute state transition.
246
250
 
251
+ ### Team handoff checkpoint
252
+
253
+ The Git checkpoint is intentionally lower-frequency than SQLite. It publishes accepted
254
+ baseline, Change, plan, task-contract, and durable task-status boundaries; it does not
255
+ mirror heartbeats, leases, active processes, telemetry, or provisional completion
256
+ hashes. On MCP startup or explicit post-pull import, the receiving checkout validates
257
+ checkpoint ancestry and record digests. Clean baseline, Change, and task records
258
+ fast-forward independently. Concurrent edits to the same record or a remote edit to a
259
+ locally active task stop without partial import.
260
+
261
+ A checkpoint can restore portable project intent after clone, but cannot establish
262
+ checkout truth by itself. An imported ready baseline becomes a local adoption/draft
263
+ with `team-ledger-revalidation-required`; current scope content, HEAD, verification,
264
+ known failures, and approval must converge again. Because baseline content hashing
265
+ excludes `.ultra/`, committing the checkpoint cannot make its own baseline stale.
266
+
247
267
  ## 5. Human-agent alignment lifecycle
248
268
 
249
269
  The host first reads `decision.list` and resumes matching authority. It starts a new
@@ -361,8 +381,12 @@ evidence do block.
361
381
  overwrite historical evidence.
362
382
  - `.ultra` workflow artifacts are excluded from application-worktree drift, but
363
383
  their own recorded SHA-256 digests are still authoritative.
364
- - Generated `tasks.json` and complete task-context files are projections. Manual edits
365
- are overwritten and never repair DB authority.
384
+ - Generated `.ultra/.runtime/projections/tasks.json` and complete runtime task-context
385
+ files are local projections. Manual edits are overwritten and never repair DB
386
+ authority.
387
+ - `.ultra/tasks/tasks.json` is a tracked MCP checkpoint, not a local projection.
388
+ Its full and per-record digests reject manual edits; publish/import performs typed
389
+ fast-forward or conflict handling.
366
390
  - A changed checkpoint artifact digest or superseded decision invalidates the linked
367
391
  decision checkpoint and blocks matching workflow advancement until reconfirmed.
368
392
  - `workflow.revise` creates a candidate without deleting the completed prior run.
package/hooks/README.md CHANGED
@@ -1,16 +1,17 @@
1
1
  # Ultra Builder Pro workflow hooks
2
2
 
3
3
  These hooks observe only Ultra-owned state. Session health/context recognizes an initialized
4
- `.ultra/.runtime/state.db` and active continuous changes. Direct projection writes remain blocked;
5
- context, compact, stop, and subagent lifecycle behavior is recovery-oriented and does not turn
6
- advisory workflow conditions into refusal gates.
4
+ `.ultra/.runtime/state.db` and active continuous changes. Direct writes to the MCP-owned Git
5
+ checkpoint and checkout-local projections remain blocked; context, compact, stop, and subagent
6
+ lifecycle behavior is recovery-oriented and does not turn advisory workflow conditions into
7
+ refusal gates.
7
8
 
8
9
  | Lifecycle | Hook | Purpose |
9
10
  |---|---|---|
10
11
  | Session start | `health_check.py` | Read-only integrity, incident, projection, session, and change-artifact checks |
11
12
  | Shared helper | `context_spine.py` | Read the latest role/gate/readiness snapshot from state.db and derive one compact breadcrumb |
12
13
  | Session start | `workflow_context.py` | Inject the DB-derived breadcrumb with bounded normalized accepted intent, never raw interaction or provider payloads |
13
- | Before edit | `active_task_context.py` | Protect `tasks.json` and restate the same DB-derived task breadcrumb |
14
+ | Before edit | `active_task_context.py` | Protect `.ultra/tasks/tasks.json` (Git team checkpoint) and `.ultra/.runtime/projections/tasks.json` (local view), then restate the same DB-derived task breadcrumb |
14
15
  | Before compact | `workflow_checkpoint.py` | Validate and atomically save a minimal workflow checkpoint |
15
16
  | After compact/resume | `workflow_resume.py` | Prefer the DB breadcrumb; restore the minimal file checkpoint only when no active change owns recovery |
16
17
  | Stop | `pre_stop_check.py` | Report an incomplete workflow boundary and allow stop |
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env python3
2
- """Protect Ultra projections and restate an active task boundary before edits."""
2
+ """Protect Ultra managed task files and restate the active workflow boundary."""
3
3
 
4
4
  import json
5
5
  import sys
@@ -12,7 +12,9 @@ from context_spine import (
12
12
  render_breadcrumb,
13
13
  )
14
14
 
15
- TASKS_PROJECTION = Path(".ultra/tasks/tasks.json")
15
+ TEAM_TASK_LEDGER = Path(".ultra/tasks/tasks.json")
16
+ LIVE_TASK_PROJECTION = Path(".ultra/.runtime/projections/tasks.json")
17
+ PROTECTED_TASK_FILES = (TEAM_TASK_LEDGER, LIVE_TASK_PROJECTION)
16
18
 
17
19
 
18
20
  def tool_paths(data: dict, start: Path):
@@ -28,25 +30,24 @@ def tool_paths(data: dict, start: Path):
28
30
  yield candidate.resolve() if candidate.is_absolute() else (start / candidate).resolve()
29
31
 
30
32
 
31
- def projection_root_for_target(target: Path) -> Path | None:
32
- """Return the project root only for the exact generated tasks projection."""
33
- if (
34
- target.name == "tasks.json"
35
- and target.parent.name == "tasks"
36
- and target.parent.parent.name == ".ultra"
37
- ):
38
- return target.parent.parent.parent
33
+ def protected_root_for_target(target: Path) -> Path | None:
34
+ """Return the project root for an exact MCP-managed task file."""
35
+ parts = target.parts
36
+ for relative in PROTECTED_TASK_FILES:
37
+ suffix = relative.parts
38
+ if len(parts) >= len(suffix) and parts[-len(suffix):] == suffix:
39
+ return Path(*parts[:-len(suffix)])
39
40
  return None
40
41
 
41
42
 
42
- def targets_tasks_projection(data: dict, root: Path) -> bool:
43
+ def targets_managed_task_file(data: dict, root: Path) -> bool:
43
44
  for resolved in tool_paths(data, root):
44
- if resolved == (root / TASKS_PROJECTION).resolve():
45
+ if any(resolved == (root / relative).resolve() for relative in PROTECTED_TASK_FILES):
45
46
  return True
46
47
  return False
47
48
 
48
49
 
49
- def projection_denial(reason: str) -> dict:
50
+ def managed_file_denial(reason: str) -> dict:
50
51
  return {"hookSpecificOutput": {
51
52
  "hookEventName": "PreToolUse",
52
53
  "permissionDecision": "deny",
@@ -54,19 +55,19 @@ def projection_denial(reason: str) -> dict:
54
55
  }}
55
56
 
56
57
 
57
- def conflicted_projection_root(data: dict, start: Path) -> Path | None:
58
+ def conflicted_managed_root(data: dict, start: Path) -> Path | None:
58
59
  for candidate in (start, *start.parents):
59
60
  if not (candidate / ".ultra").exists():
60
61
  continue
61
- if targets_tasks_projection(data, candidate):
62
+ if targets_managed_task_file(data, candidate):
62
63
  return candidate
63
64
  return None
64
65
 
65
66
 
66
- def projection_roots(data: dict, start: Path):
67
+ def managed_roots(data: dict, start: Path):
67
68
  """Yield initialized project roots derived from every resolved tool target."""
68
69
  for target in tool_paths(data, start):
69
- root = projection_root_for_target(target)
70
+ root = protected_root_for_target(target)
70
71
  if root is not None and (root / ".ultra").exists():
71
72
  yield root
72
73
 
@@ -82,23 +83,24 @@ def main() -> None:
82
83
  # An absolute tool target carries its own project boundary. Resolve and
83
84
  # protect that authority before consulting cwd, because hooks may run from
84
85
  # a host-owned temporary directory or another repository.
85
- for target_root in projection_roots(data, start):
86
+ for target_root in managed_roots(data, start):
86
87
  try:
87
88
  target_authority = find_root(target_root)
88
89
  except ContextSpineError as exc:
89
- print(json.dumps(projection_denial(
90
- "Refusing a direct write to an Ultra projection while the "
90
+ print(json.dumps(managed_file_denial(
91
+ "Refusing a direct write to an MCP-managed Ultra task file while the "
91
92
  f"runtime authority is conflicted: {exc}. Resolve the "
92
- "state.db conflict with ultra-doctor before editing generated "
93
- "project artifacts."
93
+ "state.db conflict with ultra-doctor before retrying the owning "
94
+ "MCP operation."
94
95
  )))
95
96
  return
96
97
  if target_authority is None:
97
98
  continue
98
- print(json.dumps(projection_denial(
99
- "Refusing a direct write to the .ultra/tasks/tasks.json projection. "
100
- ".ultra/.runtime/state.db is authoritative; use the Ultra MCP task tools and "
101
- "run ultra-doctor when state or projection health is degraded."
99
+ print(json.dumps(managed_file_denial(
100
+ "Refusing a direct write to an MCP-managed Ultra task file. "
101
+ ".ultra/tasks/tasks.json is the MCP-published team checkpoint and "
102
+ ".ultra/.runtime/projections/tasks.json is the checkout-local DB view. "
103
+ "Use the Ultra MCP task or task.ledger tools."
102
104
  )))
103
105
  return
104
106
 
@@ -110,26 +112,26 @@ def main() -> None:
110
112
  file=sys.stderr,
111
113
  )
112
114
  # Ordinary context injection remains fail-open. A direct write to a
113
- # known generated projection is different: an authority conflict
114
- # means the hook cannot prove which DB would regenerate that file, so
115
- # allowing the write would destroy the projection boundary.
116
- if conflicted_projection_root(data, start) is not None:
117
- print(json.dumps(projection_denial(
118
- "Refusing a direct write to an Ultra projection while the "
115
+ # known managed task file is different: an authority conflict means the
116
+ # hook cannot prove which DB owns the publish or projection operation.
117
+ if conflicted_managed_root(data, start) is not None:
118
+ print(json.dumps(managed_file_denial(
119
+ "Refusing a direct write to an MCP-managed Ultra task file while the "
119
120
  f"runtime authority is conflicted: {exc}. Resolve the "
120
- "state.db conflict with ultra-doctor before editing generated "
121
- "project artifacts."
121
+ "state.db conflict with ultra-doctor before retrying the owning "
122
+ "MCP operation."
122
123
  )))
123
124
  return
124
125
  root = None
125
126
  if root is None:
126
127
  print(json.dumps({}))
127
128
  return
128
- if targets_tasks_projection(data, root):
129
- print(json.dumps(projection_denial(
130
- "Refusing a direct write to the .ultra/tasks/tasks.json projection. "
131
- ".ultra/.runtime/state.db is authoritative; use the Ultra MCP task tools and "
132
- "run ultra-doctor when state or projection health is degraded."
129
+ if targets_managed_task_file(data, root):
130
+ print(json.dumps(managed_file_denial(
131
+ "Refusing a direct write to an MCP-managed Ultra task file. "
132
+ ".ultra/tasks/tasks.json is the MCP-published team checkpoint and "
133
+ ".ultra/.runtime/projections/tasks.json is the checkout-local DB view. "
134
+ "Use the Ultra MCP task or task.ledger tools."
133
135
  )))
134
136
  return
135
137
  try:
@@ -27,7 +27,7 @@ const STATIC_EXEMPT_PATHS = new Set([
27
27
  '.ultra/docs/research/README.md',
28
28
  '.ultra/reports/templates/delivery-report.json',
29
29
  '.ultra/reports/templates/test-report.json',
30
- '.ultra/tasks/contexts/TEMPLATE.md',
30
+ '.ultra/templates/task-context.md',
31
31
  '.ultra/tasks/tasks.json',
32
32
  '.ultra/changes/active/.gitkeep',
33
33
  '.ultra/changes/archive/.gitkeep',
@@ -81,9 +81,10 @@ function isExemptArtifactPath(value) {
81
81
 
82
82
  function isGeneratedProjectionPath(value) {
83
83
  const relative = normalizeRelativePath(value);
84
- return relative === '.ultra/tasks/contexts'
85
- || relative.startsWith('.ultra/tasks/contexts/')
86
- || relative === '.ultra/tasks/tasks.json';
84
+ return relative === '.ultra/.runtime/projections'
85
+ || relative.startsWith('.ultra/.runtime/projections/')
86
+ || relative === '.ultra/tasks/contexts'
87
+ || relative.startsWith('.ultra/tasks/contexts/');
87
88
  }
88
89
 
89
90
  function artifactPathError(message, details = undefined) {
@@ -950,7 +951,7 @@ function knownArtifactPaths(db) {
950
951
  }
951
952
  for (const row of db.prepare('SELECT id, context_file FROM tasks').all()) {
952
953
  known.add(normalizeRelativePath(
953
- row.context_file || `.ultra/tasks/contexts/task-${row.id}.md`,
954
+ contextPaths.resolveContextPath('.', row.context_file, { taskId: row.id }).relative,
954
955
  ));
955
956
  }
956
957
  return known;
@@ -1078,7 +1079,7 @@ function inspectArtifactHealth(db, { rootDir = process.cwd() } = {}) {
1078
1079
  for (const relative of tree.files) {
1079
1080
  if (isExemptArtifactPath(relative) || known.has(relative)) continue;
1080
1081
  if (!readyBaseline && PROVISIONAL_BASELINE_SPEC_PATHS.has(relative)) continue;
1081
- if (relative.startsWith(`${contextPaths.CONTEXT_ROOT_RELATIVE}/`)) {
1082
+ if (relative.startsWith(`${contextPaths.LEGACY_CONTEXT_ROOT_RELATIVE}/`)) {
1082
1083
  let contents;
1083
1084
  try {
1084
1085
  contents = readStableProjectFile(rootDir, relative).bytes;
@@ -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,
@@ -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
  };