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.
- package/.ultra-template/tasks/tasks.json +10 -3
- package/.ultra-template/templates/task-context.md +19 -0
- package/CHANGELOG.md +40 -0
- package/README.md +166 -22
- package/adapters/opencode.js +23 -12
- package/docs/AGENT-CONTEXT.md +22 -13
- package/docs/ARCHITECTURE.md +59 -24
- package/docs/ARTIFACT-AUTHORITY.md +70 -30
- 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 +8 -6
- package/docs/STATE-DB-ACCESS-POLICY.md +35 -7
- package/docs/WORKFLOW-LIFECYCLE.md +40 -16
- package/hooks/README.md +5 -4
- package/hooks/active_task_context.py +41 -39
- package/mcp-server/lib/artifact-registry.cjs +7 -6
- package/mcp-server/lib/baseline-workflow.cjs +60 -24
- 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/server.cjs +145 -9
- package/package.json +1 -1
- package/skills/ultra-change/SKILL.md +10 -2
- package/skills/ultra-deliver/SKILL.md +6 -3
- package/skills/ultra-dev/SKILL.md +21 -6
- package/skills/ultra-doctor/SKILL.md +10 -1
- package/skills/ultra-init/SKILL.md +18 -9
- package/skills/ultra-plan/SKILL.md +11 -4
- package/skills/ultra-research/SKILL.md +12 -13
- package/skills/ultra-review/SKILL.md +5 -3
- package/skills/ultra-status/SKILL.md +11 -7
- package/skills/ultra-test/SKILL.md +3 -2
- package/spec/cli-protocol.md +3 -0
- package/spec/mcp-tools.yaml +127 -0
- package/spec/schemas/context-file.v4.5.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
|
@@ -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
|
|
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.
|
|
11
|
-
|
|
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.
|
|
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
|
-
##
|
|
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
|
-
##
|
|
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` |
|
|
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 |
|
|
18
|
-
| Hook | Compact DB-derived lifecycle observation, minimal event metadata, and projection
|
|
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`,
|
|
80
|
-
|
|
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,
|
|
233
|
-
| `plan` | Accepted planning posture, immutable `plan/planning` context, selected planning evidence, complete change-owned task rows, acceptance coverage matrix,
|
|
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
|
|
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
|
|
365
|
-
are overwritten and never repair DB
|
|
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
|
|
5
|
-
context, compact, stop, and subagent
|
|
6
|
-
advisory workflow conditions into
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
32
|
-
"""Return the project root
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
and
|
|
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
|
|
43
|
+
def targets_managed_task_file(data: dict, root: Path) -> bool:
|
|
43
44
|
for resolved in tool_paths(data, root):
|
|
44
|
-
if resolved == (root /
|
|
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
|
|
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
|
|
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
|
|
62
|
+
if targets_managed_task_file(data, candidate):
|
|
62
63
|
return candidate
|
|
63
64
|
return None
|
|
64
65
|
|
|
65
66
|
|
|
66
|
-
def
|
|
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 =
|
|
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
|
|
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(
|
|
90
|
-
"Refusing a direct write to an Ultra
|
|
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
|
|
93
|
-
"
|
|
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(
|
|
99
|
-
"Refusing a direct write to
|
|
100
|
-
".ultra
|
|
101
|
-
"
|
|
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
|
|
114
|
-
#
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
|
121
|
-
"
|
|
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
|
|
129
|
-
print(json.dumps(
|
|
130
|
-
"Refusing a direct write to
|
|
131
|
-
".ultra
|
|
132
|
-
"
|
|
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/
|
|
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/
|
|
85
|
-
|| relative.startsWith('.ultra/
|
|
86
|
-
|| relative === '.ultra/tasks/
|
|
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
|
|
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.
|
|
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
|
|
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,
|
|
@@ -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
|
};
|