ultra-builder-pro-cli 0.26.1 → 3.0.2
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/contexts/TEMPLATE.md +64 -29
- package/.ultra-template/north-star.md +36 -30
- package/.ultra-template/project-brief.md +28 -0
- package/.ultra-template/specs/product.md +4 -0
- package/.ultra-template/specs/research-distillate.md +8 -0
- package/.ultra-template/tasks.json +1 -0
- package/.ultra-template/test-report.json +18 -1
- package/CHANGELOG.md +227 -1
- package/README.md +161 -59
- package/adapters/_shared/host-profile.cjs +68 -12
- package/adapters/_shared/plugin-core.cjs +4 -5
- package/adapters/_shared/runtime-assets.cjs +11 -2
- package/adapters/zcode.js +375 -0
- package/bin/delegate-worker.cjs +20 -0
- package/bin/delegate.cjs +94 -25
- package/bin/install.js +11 -6
- package/docs/ARCHITECTURE.md +109 -25
- package/docs/ARTIFACT-AUTHORITY.md +204 -30
- package/docs/DECISIONS.md +141 -10
- package/docs/PHILOSOPHY.md +61 -8
- package/docs/PLUGIN-ISOLATION-CONTRACT.md +6 -0
- package/docs/RUNTIME-COMPAT-MATRIX.md +106 -13
- package/docs/SKILL-AUTHORING.md +14 -9
- package/docs/WORKFLOW-LIFECYCLE.md +211 -53
- package/docs/evals/adversarial-review-2026-08-14.md +84 -0
- package/docs/evals/zcode-automation-2026-08-14.md +103 -0
- package/hooks/README.md +31 -3
- package/hooks/_common.py +1699 -33
- package/hooks/adapters/zcode.py +227 -0
- package/hooks/compact_context.py +176 -17
- package/hooks/mid_workflow_recall.py +27 -5
- package/hooks/post_edit_guard.py +133 -23
- package/hooks/session_context.py +722 -11
- package/package.json +6 -3
- package/skills/ultra-change/SKILL.md +84 -64
- package/skills/ultra-change/references/change-contract.md +130 -32
- package/skills/ultra-change/references/execution-grant.md +109 -0
- package/skills/ultra-change/references/primary-transfer.md +273 -0
- package/skills/ultra-change/scripts/validate_primary_transfer.cjs +1928 -0
- package/skills/ultra-delegate/SKILL.md +38 -5
- package/skills/ultra-delegate/references/delegation-contract.md +21 -1
- package/skills/ultra-deliver/SKILL.md +123 -44
- package/skills/ultra-deliver/references/baseline-reconciliation.md +5 -3
- package/skills/ultra-dev/SKILL.md +173 -43
- package/skills/ultra-grilling/SKILL.md +19 -16
- package/skills/ultra-grilling/references/reframing.md +7 -3
- package/skills/ultra-init/SKILL.md +99 -87
- package/skills/ultra-init/references/brownfield-adoption.md +23 -40
- package/skills/ultra-init/scripts/init_project.cjs +968 -21
- package/skills/ultra-plan/SKILL.md +83 -46
- package/skills/ultra-plan/references/task-evidence-v2.md +300 -0
- package/skills/ultra-plan/scripts/validate_task_evidence.cjs +859 -0
- package/skills/ultra-research/SKILL.md +119 -68
- package/skills/ultra-research/references/{03-competitive-landscape.md → 03-alternatives.md} +6 -6
- package/skills/ultra-research/references/04-product-strategy.md +11 -2
- package/skills/ultra-research/references/21-features-scope.md +8 -0
- package/skills/ultra-research/references/22-success-metrics.md +9 -4
- package/skills/ultra-research/references/99-synthesis.md +25 -1
- package/skills/ultra-research/references/north-star-v2.md +168 -0
- package/skills/ultra-research/references/wayfinding.md +50 -0
- package/skills/ultra-research/scripts/validate_north_star.cjs +1174 -0
- package/skills/ultra-review/SKILL.md +201 -24
- package/skills/ultra-review/references/adversarial-evaluation.md +31 -0
- package/skills/ultra-review/references/spec.md +5 -0
- package/skills/ultra-review/references/unified-schema.md +146 -11
- package/skills/ultra-review/references/worker-packet.md +151 -8
- package/skills/ultra-review/scripts/review_wait.py +2653 -63
- package/skills/ultra-status/SKILL.md +146 -32
- package/skills/ultra-tdd/SKILL.md +14 -4
- package/skills/ultra-tdd/references/test-execution.md +6 -1
- package/skills/ultra-test/SKILL.md +157 -15
- package/skills/ultra-test/scripts/validate_review_transport.cjs +549 -0
- package/skills/ultra-test/scripts/worktree_digest.cjs +551 -29
- package/skills/ultra-think/SKILL.md +5 -10
- package/skills/ultra-think/references/interaction-boundary.md +2 -1
package/hooks/_common.py
CHANGED
|
@@ -2,14 +2,69 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import hashlib
|
|
5
6
|
import json
|
|
6
7
|
import os
|
|
7
8
|
import re
|
|
9
|
+
import secrets
|
|
10
|
+
import stat
|
|
8
11
|
import sys
|
|
9
12
|
from pathlib import Path
|
|
10
13
|
from typing import Any
|
|
11
14
|
|
|
12
15
|
|
|
16
|
+
NORTH_STAR_VALIDATOR_RELATIVE = (
|
|
17
|
+
"ultra-research",
|
|
18
|
+
"scripts",
|
|
19
|
+
"validate_north_star.cjs",
|
|
20
|
+
)
|
|
21
|
+
STABLE_SNAPSHOT_READ_CHUNK_BYTES = 64 * 1024
|
|
22
|
+
TASK_LEDGER_SNAPSHOT_MAX_BYTES = 8 * 1024 * 1024
|
|
23
|
+
TASK_CONTEXT_SNAPSHOT_MAX_BYTES = 8 * 1024 * 1024
|
|
24
|
+
ACTIVE_CHANGE_INTENT_SNAPSHOT_MAX_BYTES = 8 * 1024 * 1024
|
|
25
|
+
ACTIVE_CHANGE_DIRECTORY_MAX_ENTRIES = 4096
|
|
26
|
+
TASK_LEDGER_SCHEMA_V2 = "ultra-task-ledger-v2"
|
|
27
|
+
TASK_ID_MAX_BYTES = 128
|
|
28
|
+
TASK_ROW_REQUIRED_KEYS = frozenset({
|
|
29
|
+
"id",
|
|
30
|
+
"title",
|
|
31
|
+
"type",
|
|
32
|
+
"priority",
|
|
33
|
+
"status",
|
|
34
|
+
"dependencies",
|
|
35
|
+
"context_file",
|
|
36
|
+
"trace_to",
|
|
37
|
+
"change_id",
|
|
38
|
+
})
|
|
39
|
+
TASK_STATUSES = frozenset({"pending", "in_progress", "completed"})
|
|
40
|
+
RESUME_NAVIGATION_LIMITATION = (
|
|
41
|
+
"Resume Note is navigational context. It cannot override current owner authority, approved scope/budget, task acceptance, or a validated Review verdict."
|
|
42
|
+
)
|
|
43
|
+
ACTIVE_CHANGE_ABANDONMENT_INSTRUCTION = (
|
|
44
|
+
"obtain explicit owner authorization and use `ultra-change` to append the exact "
|
|
45
|
+
"`## Abandonment` closure before moving the Change to "
|
|
46
|
+
"`.ultra/changes/abandoned/<change_id>`."
|
|
47
|
+
)
|
|
48
|
+
ACTIVE_CHANGE_SYMLINK_REPAIR = (
|
|
49
|
+
"Remove the symlink and restore one ordinary active Change directory containing "
|
|
50
|
+
"its own regular intent.md. To abandon the Change instead, "
|
|
51
|
+
+ ACTIVE_CHANGE_ABANDONMENT_INSTRUCTION
|
|
52
|
+
)
|
|
53
|
+
ACTIVE_CHANGE_ROOT_REPAIR = (
|
|
54
|
+
"Restore .ultra/changes/active and its .ultra/changes ancestors as ordinary "
|
|
55
|
+
"non-symlink directories, then retry task selection."
|
|
56
|
+
)
|
|
57
|
+
ACTIVE_CHANGE_INTENT_REPAIR = (
|
|
58
|
+
"Restore one readable regular intent.md inside the ordinary active Change "
|
|
59
|
+
"directory, then retry task selection. To abandon the Change instead, "
|
|
60
|
+
+ ACTIVE_CHANGE_ABANDONMENT_INSTRUCTION
|
|
61
|
+
)
|
|
62
|
+
DERIVED_FILE_PREFIXES = {
|
|
63
|
+
(".ultra", ".runtime"),
|
|
64
|
+
(".ultra", "progress"),
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
|
|
13
68
|
def read_payload() -> dict[str, Any]:
|
|
14
69
|
try:
|
|
15
70
|
value = json.loads(sys.stdin.read() or "{}")
|
|
@@ -29,6 +84,370 @@ def project_root(payload: dict[str, Any]) -> Path | None:
|
|
|
29
84
|
return None
|
|
30
85
|
|
|
31
86
|
|
|
87
|
+
def _path_is_within(file: Path, directory: Path) -> bool:
|
|
88
|
+
try:
|
|
89
|
+
file.relative_to(directory)
|
|
90
|
+
return True
|
|
91
|
+
except ValueError:
|
|
92
|
+
return False
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def find_north_star_validator(hook_file: Path) -> Path | None:
|
|
96
|
+
"""Resolve only the source/nested-plugin or OpenCode managed skill layouts."""
|
|
97
|
+
try:
|
|
98
|
+
hook_path = hook_file.resolve(strict=True)
|
|
99
|
+
except (OSError, RuntimeError):
|
|
100
|
+
return None
|
|
101
|
+
hook_root = hook_path.parent
|
|
102
|
+
skill_roots = [hook_root.parent / "skills"]
|
|
103
|
+
if hook_root.parent.name == ".ultra-builder-pro":
|
|
104
|
+
# OpenCode keeps hooks in its bundle while managed Skills live one parent up.
|
|
105
|
+
skill_roots.append(hook_root.parent.parent / "skills")
|
|
106
|
+
for skill_root in skill_roots:
|
|
107
|
+
candidate = skill_root.joinpath(*NORTH_STAR_VALIDATOR_RELATIVE)
|
|
108
|
+
try:
|
|
109
|
+
skill_root_real = skill_root.resolve(strict=True)
|
|
110
|
+
candidate_real = candidate.resolve(strict=True)
|
|
111
|
+
except (OSError, RuntimeError):
|
|
112
|
+
continue
|
|
113
|
+
expected = skill_root_real.joinpath(*NORTH_STAR_VALIDATOR_RELATIVE)
|
|
114
|
+
if (
|
|
115
|
+
candidate_real == expected
|
|
116
|
+
and _path_is_within(candidate_real, skill_root_real)
|
|
117
|
+
and candidate_real.is_file()
|
|
118
|
+
):
|
|
119
|
+
return candidate_real
|
|
120
|
+
return None
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class _SnapshotInvariantError(Exception):
|
|
124
|
+
def __init__(self, kind: str):
|
|
125
|
+
super().__init__(kind)
|
|
126
|
+
self.kind = kind
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _directory_identity(value: os.stat_result) -> tuple[int, int, int]:
|
|
130
|
+
return value.st_dev, value.st_ino, stat.S_IFMT(value.st_mode)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _file_identity(value: os.stat_result) -> tuple[int, int, int, int, int, int]:
|
|
134
|
+
return (
|
|
135
|
+
value.st_dev,
|
|
136
|
+
value.st_ino,
|
|
137
|
+
value.st_mode,
|
|
138
|
+
value.st_size,
|
|
139
|
+
value.st_mtime_ns,
|
|
140
|
+
value.st_ctime_ns,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _open_stable_directory_chain(
|
|
145
|
+
root: Path,
|
|
146
|
+
components: tuple[str, ...],
|
|
147
|
+
flags: int,
|
|
148
|
+
) -> tuple[list[int], list[tuple[int, int, int]]]:
|
|
149
|
+
descriptors: list[int] = []
|
|
150
|
+
identities: list[tuple[int, int, int]] = []
|
|
151
|
+
try:
|
|
152
|
+
path_stat = os.stat(root, follow_symlinks=False)
|
|
153
|
+
if stat.S_ISLNK(path_stat.st_mode):
|
|
154
|
+
raise _SnapshotInvariantError("symlink")
|
|
155
|
+
if not stat.S_ISDIR(path_stat.st_mode):
|
|
156
|
+
raise _SnapshotInvariantError("not_regular")
|
|
157
|
+
descriptor = os.open(root, flags)
|
|
158
|
+
descriptors.append(descriptor)
|
|
159
|
+
descriptor_stat = os.fstat(descriptor)
|
|
160
|
+
if _directory_identity(path_stat) != _directory_identity(descriptor_stat):
|
|
161
|
+
raise _SnapshotInvariantError("changed")
|
|
162
|
+
identities.append(_directory_identity(descriptor_stat))
|
|
163
|
+
|
|
164
|
+
for component in components:
|
|
165
|
+
path_stat = os.stat(component, dir_fd=descriptor, follow_symlinks=False)
|
|
166
|
+
if stat.S_ISLNK(path_stat.st_mode):
|
|
167
|
+
raise _SnapshotInvariantError("symlink")
|
|
168
|
+
if not stat.S_ISDIR(path_stat.st_mode):
|
|
169
|
+
raise _SnapshotInvariantError("not_regular")
|
|
170
|
+
descriptor = os.open(component, flags, dir_fd=descriptor)
|
|
171
|
+
descriptors.append(descriptor)
|
|
172
|
+
descriptor_stat = os.fstat(descriptor)
|
|
173
|
+
if _directory_identity(path_stat) != _directory_identity(descriptor_stat):
|
|
174
|
+
raise _SnapshotInvariantError("changed")
|
|
175
|
+
identities.append(_directory_identity(descriptor_stat))
|
|
176
|
+
return descriptors, identities
|
|
177
|
+
except Exception:
|
|
178
|
+
for descriptor in reversed(descriptors):
|
|
179
|
+
os.close(descriptor)
|
|
180
|
+
raise
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _directory_entries_snapshot(
|
|
184
|
+
descriptor: int,
|
|
185
|
+
*,
|
|
186
|
+
max_entries: int,
|
|
187
|
+
) -> list[dict[str, Any]]:
|
|
188
|
+
entries = []
|
|
189
|
+
with os.scandir(descriptor) as iterator:
|
|
190
|
+
directory_entries = []
|
|
191
|
+
for entry in iterator:
|
|
192
|
+
if len(directory_entries) == max_entries:
|
|
193
|
+
raise _SnapshotInvariantError("oversize")
|
|
194
|
+
directory_entries.append(entry)
|
|
195
|
+
for entry in directory_entries:
|
|
196
|
+
observed = entry.stat(follow_symlinks=False)
|
|
197
|
+
entries.append({
|
|
198
|
+
"name": entry.name,
|
|
199
|
+
"identity": _file_identity(observed),
|
|
200
|
+
"mode": observed.st_mode,
|
|
201
|
+
})
|
|
202
|
+
return sorted(entries, key=lambda entry: entry["name"])
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def read_stable_project_directory_snapshot(
|
|
206
|
+
root: Path,
|
|
207
|
+
relative_directory: Path | str,
|
|
208
|
+
*,
|
|
209
|
+
max_entries: int,
|
|
210
|
+
code_prefix: str,
|
|
211
|
+
label: str,
|
|
212
|
+
) -> tuple[list[dict[str, Any]] | None, dict[str, str] | None]:
|
|
213
|
+
"""Observe one bounded ordinary directory through two no-symlink dirfd walks."""
|
|
214
|
+
relative = Path(relative_directory)
|
|
215
|
+
canonical = root.absolute() / relative
|
|
216
|
+
|
|
217
|
+
def failure(kind: str, message: str | None = None):
|
|
218
|
+
messages = {
|
|
219
|
+
"missing": f"{label} is missing.",
|
|
220
|
+
"symlink": f"{label} and its ancestors must not be symlinks.",
|
|
221
|
+
"not_regular": f"{label} and its ancestors must be ordinary directories.",
|
|
222
|
+
"oversize": f"{label} exceeds the {max_entries}-entry snapshot limit.",
|
|
223
|
+
"changed": f"{label} changed during its stable directory snapshot; retry.",
|
|
224
|
+
"read_error": f"{label} could not be scanned as a stable ordinary directory.",
|
|
225
|
+
}
|
|
226
|
+
return None, {
|
|
227
|
+
"code": f"{code_prefix}_{kind}",
|
|
228
|
+
"message": message or messages[kind],
|
|
229
|
+
"path": str(canonical),
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (
|
|
233
|
+
relative.is_absolute()
|
|
234
|
+
or not relative.parts
|
|
235
|
+
or any(part in {"", ".", ".."} for part in relative.parts)
|
|
236
|
+
):
|
|
237
|
+
return failure(
|
|
238
|
+
"read_error",
|
|
239
|
+
f"{label} must be a normalized repository-relative path.",
|
|
240
|
+
)
|
|
241
|
+
try:
|
|
242
|
+
root_real = root.resolve(strict=True)
|
|
243
|
+
except (OSError, RuntimeError) as error:
|
|
244
|
+
return failure("read_error", f"{label} repository root is unavailable: {error}")
|
|
245
|
+
canonical = root_real.joinpath(*relative.parts)
|
|
246
|
+
directory_flags = (
|
|
247
|
+
os.O_RDONLY
|
|
248
|
+
| getattr(os, "O_DIRECTORY", 0)
|
|
249
|
+
| getattr(os, "O_NOFOLLOW", 0)
|
|
250
|
+
)
|
|
251
|
+
all_descriptors: list[int] = []
|
|
252
|
+
snapshot_started = False
|
|
253
|
+
fresh_rewalk_started = False
|
|
254
|
+
try:
|
|
255
|
+
initial_descriptors, initial_identities = _open_stable_directory_chain(
|
|
256
|
+
root_real,
|
|
257
|
+
relative.parts,
|
|
258
|
+
directory_flags,
|
|
259
|
+
)
|
|
260
|
+
all_descriptors.extend(initial_descriptors)
|
|
261
|
+
snapshot_started = True
|
|
262
|
+
entries = _directory_entries_snapshot(
|
|
263
|
+
initial_descriptors[-1],
|
|
264
|
+
max_entries=max_entries,
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
fresh_rewalk_started = True
|
|
268
|
+
fresh_descriptors, fresh_identities = _open_stable_directory_chain(
|
|
269
|
+
root_real,
|
|
270
|
+
relative.parts,
|
|
271
|
+
directory_flags,
|
|
272
|
+
)
|
|
273
|
+
all_descriptors.extend(fresh_descriptors)
|
|
274
|
+
if fresh_identities != initial_identities:
|
|
275
|
+
raise _SnapshotInvariantError("changed")
|
|
276
|
+
fresh_entries = _directory_entries_snapshot(
|
|
277
|
+
fresh_descriptors[-1],
|
|
278
|
+
max_entries=max_entries,
|
|
279
|
+
)
|
|
280
|
+
if fresh_entries != entries:
|
|
281
|
+
raise _SnapshotInvariantError("changed")
|
|
282
|
+
return entries, None
|
|
283
|
+
except FileNotFoundError:
|
|
284
|
+
return failure("changed" if snapshot_started else "missing")
|
|
285
|
+
except _SnapshotInvariantError as error:
|
|
286
|
+
return failure("changed" if fresh_rewalk_started else error.kind)
|
|
287
|
+
except OSError as error:
|
|
288
|
+
if fresh_rewalk_started:
|
|
289
|
+
return failure("changed")
|
|
290
|
+
return failure(
|
|
291
|
+
"read_error",
|
|
292
|
+
f"{label} could not be scanned as a stable ordinary directory: {error}",
|
|
293
|
+
)
|
|
294
|
+
finally:
|
|
295
|
+
for descriptor in reversed(all_descriptors):
|
|
296
|
+
os.close(descriptor)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def read_stable_project_file_snapshot(
|
|
300
|
+
root: Path,
|
|
301
|
+
relative_file: Path | str,
|
|
302
|
+
*,
|
|
303
|
+
max_bytes: int,
|
|
304
|
+
code_prefix: str,
|
|
305
|
+
label: str,
|
|
306
|
+
) -> tuple[dict[str, Any] | None, dict[str, str] | None]:
|
|
307
|
+
"""Read one bounded regular file and verify a fresh canonical-root rewalk."""
|
|
308
|
+
relative = Path(relative_file)
|
|
309
|
+
canonical = root.absolute() / relative
|
|
310
|
+
|
|
311
|
+
def failure(kind: str, message: str | None = None):
|
|
312
|
+
messages = {
|
|
313
|
+
"missing": f"{label} is missing.",
|
|
314
|
+
"symlink": f"{label} path components and file must not be symlinks.",
|
|
315
|
+
"not_regular": f"{label} must be a regular file beneath ordinary directories.",
|
|
316
|
+
"oversize": f"{label} exceeds the {max_bytes}-byte snapshot limit.",
|
|
317
|
+
"changed": f"{label} or its canonical path changed; retry SessionStart.",
|
|
318
|
+
"read_error": f"{label} could not be read as a stable regular file.",
|
|
319
|
+
}
|
|
320
|
+
return None, {
|
|
321
|
+
"code": f"{code_prefix}_{kind}",
|
|
322
|
+
"message": message or messages[kind],
|
|
323
|
+
"path": str(canonical),
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (
|
|
327
|
+
relative.is_absolute()
|
|
328
|
+
or not relative.parts
|
|
329
|
+
or any(part in {"", ".", ".."} for part in relative.parts)
|
|
330
|
+
):
|
|
331
|
+
return failure(
|
|
332
|
+
"read_error",
|
|
333
|
+
f"{label} must be a normalized repository-relative path.",
|
|
334
|
+
)
|
|
335
|
+
try:
|
|
336
|
+
root_real = root.resolve(strict=True)
|
|
337
|
+
except (OSError, RuntimeError) as error:
|
|
338
|
+
return failure("read_error", f"{label} repository root is unavailable: {error}")
|
|
339
|
+
canonical = root_real.joinpath(*relative.parts)
|
|
340
|
+
directory_parts = relative.parts[:-1]
|
|
341
|
+
file_name = relative.parts[-1]
|
|
342
|
+
directory_flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0)
|
|
343
|
+
file_flags = os.O_RDONLY | getattr(os, "O_NONBLOCK", 0) | getattr(os, "O_NOFOLLOW", 0)
|
|
344
|
+
all_descriptors: list[int] = []
|
|
345
|
+
snapshot_started = False
|
|
346
|
+
fresh_rewalk_started = False
|
|
347
|
+
|
|
348
|
+
try:
|
|
349
|
+
initial_descriptors, initial_directory_identities = _open_stable_directory_chain(
|
|
350
|
+
root_real,
|
|
351
|
+
directory_parts,
|
|
352
|
+
directory_flags,
|
|
353
|
+
)
|
|
354
|
+
all_descriptors.extend(initial_descriptors)
|
|
355
|
+
parent_descriptor = initial_descriptors[-1]
|
|
356
|
+
before_path = os.stat(file_name, dir_fd=parent_descriptor, follow_symlinks=False)
|
|
357
|
+
if stat.S_ISLNK(before_path.st_mode):
|
|
358
|
+
raise _SnapshotInvariantError("symlink")
|
|
359
|
+
if not stat.S_ISREG(before_path.st_mode):
|
|
360
|
+
raise _SnapshotInvariantError("not_regular")
|
|
361
|
+
if before_path.st_size > max_bytes:
|
|
362
|
+
raise _SnapshotInvariantError("oversize")
|
|
363
|
+
|
|
364
|
+
file_descriptor = os.open(file_name, file_flags, dir_fd=parent_descriptor)
|
|
365
|
+
all_descriptors.append(file_descriptor)
|
|
366
|
+
before_fd = os.fstat(file_descriptor)
|
|
367
|
+
if not stat.S_ISREG(before_fd.st_mode):
|
|
368
|
+
raise _SnapshotInvariantError("not_regular")
|
|
369
|
+
if _file_identity(before_path) != _file_identity(before_fd):
|
|
370
|
+
raise _SnapshotInvariantError("changed")
|
|
371
|
+
snapshot_started = True
|
|
372
|
+
|
|
373
|
+
chunks: list[bytes] = []
|
|
374
|
+
observed = 0
|
|
375
|
+
while True:
|
|
376
|
+
chunk = os.read(
|
|
377
|
+
file_descriptor,
|
|
378
|
+
min(STABLE_SNAPSHOT_READ_CHUNK_BYTES, max_bytes + 1 - observed),
|
|
379
|
+
)
|
|
380
|
+
if not chunk:
|
|
381
|
+
break
|
|
382
|
+
chunks.append(chunk)
|
|
383
|
+
observed += len(chunk)
|
|
384
|
+
if observed > max_bytes:
|
|
385
|
+
raise _SnapshotInvariantError("oversize")
|
|
386
|
+
|
|
387
|
+
after_fd = os.fstat(file_descriptor)
|
|
388
|
+
after_path = os.stat(file_name, dir_fd=parent_descriptor, follow_symlinks=False)
|
|
389
|
+
if not (
|
|
390
|
+
_file_identity(before_fd)
|
|
391
|
+
== _file_identity(after_fd)
|
|
392
|
+
== _file_identity(after_path)
|
|
393
|
+
):
|
|
394
|
+
raise _SnapshotInvariantError("changed")
|
|
395
|
+
for index, component in enumerate(directory_parts):
|
|
396
|
+
path_identity = _directory_identity(os.stat(
|
|
397
|
+
component,
|
|
398
|
+
dir_fd=initial_descriptors[index],
|
|
399
|
+
follow_symlinks=False,
|
|
400
|
+
))
|
|
401
|
+
descriptor_identity = _directory_identity(os.fstat(initial_descriptors[index + 1]))
|
|
402
|
+
if not path_identity == descriptor_identity == initial_directory_identities[index + 1]:
|
|
403
|
+
raise _SnapshotInvariantError("changed")
|
|
404
|
+
|
|
405
|
+
fresh_rewalk_started = True
|
|
406
|
+
fresh_descriptors, fresh_directory_identities = _open_stable_directory_chain(
|
|
407
|
+
root_real,
|
|
408
|
+
directory_parts,
|
|
409
|
+
directory_flags,
|
|
410
|
+
)
|
|
411
|
+
all_descriptors.extend(fresh_descriptors)
|
|
412
|
+
if fresh_directory_identities != initial_directory_identities:
|
|
413
|
+
raise _SnapshotInvariantError("changed")
|
|
414
|
+
fresh_parent_descriptor = fresh_descriptors[-1]
|
|
415
|
+
fresh_path = os.stat(file_name, dir_fd=fresh_parent_descriptor, follow_symlinks=False)
|
|
416
|
+
if not stat.S_ISREG(fresh_path.st_mode):
|
|
417
|
+
raise _SnapshotInvariantError("changed")
|
|
418
|
+
fresh_file_descriptor = os.open(file_name, file_flags, dir_fd=fresh_parent_descriptor)
|
|
419
|
+
all_descriptors.append(fresh_file_descriptor)
|
|
420
|
+
fresh_fd = os.fstat(fresh_file_descriptor)
|
|
421
|
+
if not (
|
|
422
|
+
stat.S_ISREG(fresh_fd.st_mode)
|
|
423
|
+
and _file_identity(fresh_path)
|
|
424
|
+
== _file_identity(fresh_fd)
|
|
425
|
+
== _file_identity(after_fd)
|
|
426
|
+
):
|
|
427
|
+
raise _SnapshotInvariantError("changed")
|
|
428
|
+
|
|
429
|
+
data = b"".join(chunks)
|
|
430
|
+
if len(data) != after_fd.st_size:
|
|
431
|
+
raise _SnapshotInvariantError("changed")
|
|
432
|
+
return {
|
|
433
|
+
"bytes": data,
|
|
434
|
+
"byte_length": len(data),
|
|
435
|
+
"sha256": hashlib.sha256(data).hexdigest(),
|
|
436
|
+
"path": str(canonical),
|
|
437
|
+
}, None
|
|
438
|
+
except FileNotFoundError:
|
|
439
|
+
return failure("changed" if snapshot_started else "missing")
|
|
440
|
+
except _SnapshotInvariantError as error:
|
|
441
|
+
return failure("changed" if fresh_rewalk_started else error.kind)
|
|
442
|
+
except OSError as error:
|
|
443
|
+
if fresh_rewalk_started:
|
|
444
|
+
return failure("changed")
|
|
445
|
+
return failure("read_error", f"{label} could not be read as a stable regular file: {error}")
|
|
446
|
+
finally:
|
|
447
|
+
for descriptor in reversed(all_descriptors):
|
|
448
|
+
os.close(descriptor)
|
|
449
|
+
|
|
450
|
+
|
|
32
451
|
def markdown_section(text: str, heading: str) -> str:
|
|
33
452
|
pattern = re.compile(
|
|
34
453
|
rf"^##\s+{re.escape(heading)}\s*$\n(?P<body>.*?)(?=^##\s+|\Z)",
|
|
@@ -38,37 +457,905 @@ def markdown_section(text: str, heading: str) -> str:
|
|
|
38
457
|
return match.group("body").strip() if match else ""
|
|
39
458
|
|
|
40
459
|
|
|
41
|
-
def
|
|
460
|
+
def _validate_task_row(
|
|
461
|
+
row: Any,
|
|
462
|
+
index: int,
|
|
463
|
+
classification: str,
|
|
464
|
+
ledger_path: str,
|
|
465
|
+
) -> tuple[dict[str, Any] | None, list[dict[str, Any]]]:
|
|
466
|
+
if not isinstance(row, dict):
|
|
467
|
+
return None, [{
|
|
468
|
+
"code": "task_ledger_row_invalid",
|
|
469
|
+
"message": f"Task ledger row {index} must be a JSON object.",
|
|
470
|
+
"path": ledger_path,
|
|
471
|
+
"row_index": index,
|
|
472
|
+
"problems": ["row_not_object"],
|
|
473
|
+
"repair": (
|
|
474
|
+
f"Repair task ledger row {index} by resolving these exact problems: "
|
|
475
|
+
"row_not_object; then retry task selection."
|
|
476
|
+
),
|
|
477
|
+
}]
|
|
478
|
+
|
|
479
|
+
keys = set(row)
|
|
480
|
+
legacy_root = classification in {"legacy_object", "legacy_array"}
|
|
481
|
+
uses_change_ref = legacy_root and "change_id" not in keys and "change_ref" in keys
|
|
482
|
+
required = set(TASK_ROW_REQUIRED_KEYS)
|
|
483
|
+
if uses_change_ref:
|
|
484
|
+
required.remove("change_id")
|
|
485
|
+
required.add("change_ref")
|
|
486
|
+
optional = {"complexity"}
|
|
487
|
+
if legacy_root:
|
|
488
|
+
optional.add("change_ref")
|
|
489
|
+
problems = []
|
|
490
|
+
missing = sorted(required - keys)
|
|
491
|
+
unknown = sorted(keys - required - optional)
|
|
492
|
+
if missing:
|
|
493
|
+
problems.append(f"missing_keys:{','.join(missing)}")
|
|
494
|
+
if unknown:
|
|
495
|
+
problems.append(f"unknown_keys:{','.join(unknown)}")
|
|
496
|
+
|
|
497
|
+
task_id = row.get("id")
|
|
498
|
+
if normalized_task_id(task_id) is None:
|
|
499
|
+
problems.append("invalid_id")
|
|
500
|
+
for field in ("title", "type", "priority"):
|
|
501
|
+
value = row.get(field)
|
|
502
|
+
if not isinstance(value, str) or not value or value.strip() != value:
|
|
503
|
+
problems.append(f"invalid_{field}")
|
|
504
|
+
if row.get("status") not in TASK_STATUSES:
|
|
505
|
+
problems.append("invalid_status")
|
|
506
|
+
|
|
507
|
+
dependencies = row.get("dependencies")
|
|
508
|
+
if not isinstance(dependencies, list):
|
|
509
|
+
problems.append("invalid_dependencies_type")
|
|
510
|
+
elif any(normalized_task_id(dependency) is None for dependency in dependencies):
|
|
511
|
+
problems.append("invalid_dependency_id")
|
|
512
|
+
|
|
513
|
+
context_value = row.get("context_file")
|
|
514
|
+
context_relative = (
|
|
515
|
+
project_relative_path(context_value, ultra_default=True)
|
|
516
|
+
if isinstance(context_value, str)
|
|
517
|
+
else None
|
|
518
|
+
)
|
|
519
|
+
expected_context = (
|
|
520
|
+
Path(".ultra") / "contexts" / f"task-{task_id}.md"
|
|
521
|
+
if normalized_task_id(task_id) is not None
|
|
522
|
+
else None
|
|
523
|
+
)
|
|
524
|
+
if context_relative is None or context_relative != expected_context:
|
|
525
|
+
problems.append("invalid_context_file")
|
|
526
|
+
|
|
527
|
+
trace = row.get("trace_to")
|
|
528
|
+
if not isinstance(trace, str) or trace.count("#") != 1:
|
|
529
|
+
problems.append("invalid_trace_to")
|
|
530
|
+
else:
|
|
531
|
+
trace_path, anchor = trace.split("#", 1)
|
|
532
|
+
if (
|
|
533
|
+
project_relative_path(trace_path, ultra_default=True) is None
|
|
534
|
+
or re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]*", anchor) is None
|
|
535
|
+
):
|
|
536
|
+
problems.append("invalid_trace_to")
|
|
537
|
+
|
|
538
|
+
if "change_id" in row and normalized_task_id(row.get("change_id")) is None:
|
|
539
|
+
problems.append("invalid_change_id")
|
|
540
|
+
if "change_ref" in row and legacy_task_change_id(row) is None:
|
|
541
|
+
problems.append("invalid_change_ref")
|
|
542
|
+
if "complexity" in row and (
|
|
543
|
+
not isinstance(row.get("complexity"), int)
|
|
544
|
+
or isinstance(row.get("complexity"), bool)
|
|
545
|
+
):
|
|
546
|
+
problems.append("invalid_legacy_complexity")
|
|
547
|
+
|
|
548
|
+
if problems:
|
|
549
|
+
return None, [{
|
|
550
|
+
"code": "task_ledger_row_invalid",
|
|
551
|
+
"message": (
|
|
552
|
+
f"Task ledger row {index} has an invalid mechanical shape; repair the "
|
|
553
|
+
"row before task selection."
|
|
554
|
+
),
|
|
555
|
+
"path": ledger_path,
|
|
556
|
+
"row_index": index,
|
|
557
|
+
"task_id": task_id if isinstance(task_id, str) else None,
|
|
558
|
+
"problems": problems,
|
|
559
|
+
"repair": (
|
|
560
|
+
f"Repair task ledger row {index} by resolving these exact problems: "
|
|
561
|
+
f"{', '.join(problems)}; then retry task selection."
|
|
562
|
+
),
|
|
563
|
+
}]
|
|
564
|
+
|
|
565
|
+
diagnostics = []
|
|
566
|
+
if "complexity" in row:
|
|
567
|
+
diagnostics.append({
|
|
568
|
+
"code": "legacy_task_complexity",
|
|
569
|
+
"message": (
|
|
570
|
+
f"Task `{task_id}` contains legacy `complexity`; it remains readable but "
|
|
571
|
+
"does not choose task scope, quality, duration, or completion."
|
|
572
|
+
),
|
|
573
|
+
"path": ledger_path,
|
|
574
|
+
"row_index": index,
|
|
575
|
+
"task_id": task_id,
|
|
576
|
+
"observed_complexity": row["complexity"],
|
|
577
|
+
})
|
|
578
|
+
if "change_ref" in row:
|
|
579
|
+
diagnostics.append({
|
|
580
|
+
"code": "legacy_task_change_ref",
|
|
581
|
+
"message": (
|
|
582
|
+
f"Task `{task_id}` contains legacy `change_ref`; a present `change_id` "
|
|
583
|
+
"wins, otherwise the active-path reference is compatibility-only."
|
|
584
|
+
),
|
|
585
|
+
"path": ledger_path,
|
|
586
|
+
"row_index": index,
|
|
587
|
+
"task_id": task_id,
|
|
588
|
+
})
|
|
589
|
+
return row, diagnostics
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def _task_graph_change_id(task: dict[str, Any]) -> str | None:
|
|
593
|
+
value = task.get("change_id")
|
|
594
|
+
if isinstance(value, str):
|
|
595
|
+
return value
|
|
596
|
+
return legacy_task_change_id(task)
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def _canonical_dependency_cycle(nodes: list[str]) -> tuple[str, ...]:
|
|
600
|
+
first = min(range(len(nodes)), key=nodes.__getitem__)
|
|
601
|
+
canonical = tuple(nodes[first:] + nodes[:first])
|
|
602
|
+
return (*canonical, canonical[0])
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def _validate_task_graph(
|
|
606
|
+
tasks: list[dict[str, Any]],
|
|
607
|
+
ledger_path: str,
|
|
608
|
+
) -> list[dict[str, Any]]:
|
|
609
|
+
"""Validate exact task identity and dependency edges without judging task meaning."""
|
|
610
|
+
diagnostics: list[dict[str, Any]] = []
|
|
611
|
+
indexes_by_id: dict[str, list[int]] = {}
|
|
612
|
+
for index, task in enumerate(tasks):
|
|
613
|
+
task_id = task["id"]
|
|
614
|
+
indexes_by_id.setdefault(task_id, []).append(index)
|
|
615
|
+
|
|
616
|
+
for task_id, row_indexes in indexes_by_id.items():
|
|
617
|
+
if len(row_indexes) > 1:
|
|
618
|
+
diagnostics.append({
|
|
619
|
+
"code": "task_ledger_duplicate_id",
|
|
620
|
+
"message": (
|
|
621
|
+
f"Task id `{task_id}` appears in rows "
|
|
622
|
+
f"{', '.join(str(index) for index in row_indexes)}; global task "
|
|
623
|
+
"identity and Status are ambiguous."
|
|
624
|
+
),
|
|
625
|
+
"path": ledger_path,
|
|
626
|
+
"task_id": task_id,
|
|
627
|
+
"row_indexes": row_indexes,
|
|
628
|
+
"repair": (
|
|
629
|
+
f"Keep exactly one row for task id `{task_id}`; reconcile its status "
|
|
630
|
+
"and history, then retry task selection."
|
|
631
|
+
),
|
|
632
|
+
})
|
|
633
|
+
|
|
634
|
+
unique_tasks = {
|
|
635
|
+
task_id: tasks[row_indexes[0]]
|
|
636
|
+
for task_id, row_indexes in indexes_by_id.items()
|
|
637
|
+
if len(row_indexes) == 1
|
|
638
|
+
}
|
|
639
|
+
graph: dict[str, list[str]] = {task_id: [] for task_id in unique_tasks}
|
|
640
|
+
for row_index, task in enumerate(tasks):
|
|
641
|
+
task_id = task["id"]
|
|
642
|
+
dependencies = task["dependencies"]
|
|
643
|
+
dependency_indexes: dict[str, list[int]] = {}
|
|
644
|
+
for dependency_index, dependency_id in enumerate(dependencies):
|
|
645
|
+
dependency_indexes.setdefault(dependency_id, []).append(dependency_index)
|
|
646
|
+
for dependency_id, positions in dependency_indexes.items():
|
|
647
|
+
if len(positions) > 1:
|
|
648
|
+
diagnostics.append({
|
|
649
|
+
"code": "task_ledger_duplicate_dependency",
|
|
650
|
+
"message": (
|
|
651
|
+
f"Task `{task_id}` repeats dependency `{dependency_id}` at "
|
|
652
|
+
f"positions {', '.join(str(position) for position in positions)}."
|
|
653
|
+
),
|
|
654
|
+
"path": ledger_path,
|
|
655
|
+
"row_index": row_index,
|
|
656
|
+
"task_id": task_id,
|
|
657
|
+
"dependency_id": dependency_id,
|
|
658
|
+
"dependency_indexes": positions,
|
|
659
|
+
"repair": f"Keep dependency `{dependency_id}` once in task `{task_id}`.",
|
|
660
|
+
})
|
|
661
|
+
|
|
662
|
+
if task_id not in unique_tasks:
|
|
663
|
+
continue
|
|
664
|
+
task_change_id = _task_graph_change_id(task)
|
|
665
|
+
for dependency_id in dependency_indexes:
|
|
666
|
+
if dependency_id == task_id:
|
|
667
|
+
diagnostics.append({
|
|
668
|
+
"code": "task_ledger_dependency_self",
|
|
669
|
+
"message": f"Task `{task_id}` depends on itself.",
|
|
670
|
+
"path": ledger_path,
|
|
671
|
+
"row_index": row_index,
|
|
672
|
+
"task_id": task_id,
|
|
673
|
+
"dependency_id": dependency_id,
|
|
674
|
+
"repair": f"Remove self-dependency `{dependency_id}` from task `{task_id}`.",
|
|
675
|
+
})
|
|
676
|
+
continue
|
|
677
|
+
dependency_rows = indexes_by_id.get(dependency_id)
|
|
678
|
+
if dependency_rows is None:
|
|
679
|
+
diagnostics.append({
|
|
680
|
+
"code": "task_ledger_dependency_missing",
|
|
681
|
+
"message": (
|
|
682
|
+
f"Task `{task_id}` depends on missing task `{dependency_id}` in "
|
|
683
|
+
f"Change `{task_change_id}`."
|
|
684
|
+
),
|
|
685
|
+
"path": ledger_path,
|
|
686
|
+
"row_index": row_index,
|
|
687
|
+
"task_id": task_id,
|
|
688
|
+
"change_id": task_change_id,
|
|
689
|
+
"dependency_id": dependency_id,
|
|
690
|
+
"repair": (
|
|
691
|
+
f"Add the missing task row `{dependency_id}` to Change "
|
|
692
|
+
f"`{task_change_id}`, or remove the stale dependency from task "
|
|
693
|
+
f"`{task_id}`."
|
|
694
|
+
),
|
|
695
|
+
})
|
|
696
|
+
continue
|
|
697
|
+
if len(dependency_rows) != 1:
|
|
698
|
+
continue
|
|
699
|
+
dependency = unique_tasks[dependency_id]
|
|
700
|
+
dependency_change_id = _task_graph_change_id(dependency)
|
|
701
|
+
if dependency_change_id != task_change_id:
|
|
702
|
+
diagnostics.append({
|
|
703
|
+
"code": "task_ledger_dependency_cross_change",
|
|
704
|
+
"message": (
|
|
705
|
+
f"Task `{task_id}` in Change `{task_change_id}` depends on task "
|
|
706
|
+
f"`{dependency_id}` in Change `{dependency_change_id}`."
|
|
707
|
+
),
|
|
708
|
+
"path": ledger_path,
|
|
709
|
+
"row_index": row_index,
|
|
710
|
+
"task_id": task_id,
|
|
711
|
+
"change_id": task_change_id,
|
|
712
|
+
"dependency_id": dependency_id,
|
|
713
|
+
"dependency_change_id": dependency_change_id,
|
|
714
|
+
"repair": (
|
|
715
|
+
f"Move dependency `{dependency_id}` into Change `{task_change_id}`, "
|
|
716
|
+
f"or remove that cross-Change edge from task `{task_id}`."
|
|
717
|
+
),
|
|
718
|
+
})
|
|
719
|
+
continue
|
|
720
|
+
graph[task_id].append(dependency_id)
|
|
721
|
+
|
|
722
|
+
state = {task_id: 0 for task_id in graph}
|
|
723
|
+
cycles: set[tuple[str, ...]] = set()
|
|
724
|
+
|
|
725
|
+
for task_id in graph:
|
|
726
|
+
if state[task_id] != 0:
|
|
727
|
+
continue
|
|
728
|
+
state[task_id] = 1
|
|
729
|
+
path = [task_id]
|
|
730
|
+
path_indexes = {task_id: 0}
|
|
731
|
+
frames = [(task_id, 0)]
|
|
732
|
+
while frames:
|
|
733
|
+
current, dependency_index = frames[-1]
|
|
734
|
+
dependencies = graph[current]
|
|
735
|
+
if dependency_index >= len(dependencies):
|
|
736
|
+
frames.pop()
|
|
737
|
+
path.pop()
|
|
738
|
+
path_indexes.pop(current)
|
|
739
|
+
state[current] = 2
|
|
740
|
+
continue
|
|
741
|
+
dependency_id = dependencies[dependency_index]
|
|
742
|
+
frames[-1] = (current, dependency_index + 1)
|
|
743
|
+
if state[dependency_id] == 0:
|
|
744
|
+
state[dependency_id] = 1
|
|
745
|
+
path_indexes[dependency_id] = len(path)
|
|
746
|
+
path.append(dependency_id)
|
|
747
|
+
frames.append((dependency_id, 0))
|
|
748
|
+
elif state[dependency_id] == 1:
|
|
749
|
+
cycle_nodes = path[path_indexes[dependency_id]:]
|
|
750
|
+
cycles.add(_canonical_dependency_cycle(cycle_nodes))
|
|
751
|
+
|
|
752
|
+
for cycle in sorted(cycles):
|
|
753
|
+
chain = " -> ".join(cycle)
|
|
754
|
+
diagnostics.append({
|
|
755
|
+
"code": "task_ledger_dependency_cycle",
|
|
756
|
+
"message": f"Task dependency cycle detected: `{chain}`.",
|
|
757
|
+
"path": ledger_path,
|
|
758
|
+
"task_ids": list(cycle),
|
|
759
|
+
"repair": (
|
|
760
|
+
f"Break the dependency cycle `{chain}` by removing at least one stale edge."
|
|
761
|
+
),
|
|
762
|
+
})
|
|
763
|
+
return diagnostics
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
def _task_ledger_snapshot_repair(code: str) -> str:
|
|
767
|
+
if code == "task_ledger_snapshot_missing":
|
|
768
|
+
return (
|
|
769
|
+
"Restore `.ultra/tasks.json` as an ordinary regular file containing the "
|
|
770
|
+
"canonical task ledger, then retry task selection."
|
|
771
|
+
)
|
|
772
|
+
if code == "task_ledger_snapshot_symlink":
|
|
773
|
+
return (
|
|
774
|
+
"Remove the symlink and restore `.ultra/tasks.json` as an ordinary "
|
|
775
|
+
"non-symlink regular file inside the repository, then retry task selection."
|
|
776
|
+
)
|
|
777
|
+
if code == "task_ledger_snapshot_not_regular":
|
|
778
|
+
return (
|
|
779
|
+
"Replace `.ultra/tasks.json` with an ordinary non-symlink regular file "
|
|
780
|
+
"containing the canonical task ledger, then retry task selection."
|
|
781
|
+
)
|
|
782
|
+
if code == "task_ledger_snapshot_oversize":
|
|
783
|
+
return (
|
|
784
|
+
f"Reduce `.ultra/tasks.json` below the {TASK_LEDGER_SNAPSHOT_MAX_BYTES}-byte "
|
|
785
|
+
"snapshot ceiling without discarding task history, then retry task selection."
|
|
786
|
+
)
|
|
787
|
+
if code == "task_ledger_snapshot_changed":
|
|
788
|
+
return (
|
|
789
|
+
"Retry task selection after `.ultra/tasks.json` writes settle; if instability "
|
|
790
|
+
"persists, restore one ordinary regular ledger file."
|
|
791
|
+
)
|
|
792
|
+
return (
|
|
793
|
+
"Repair read access to `.ultra/tasks.json` as one stable ordinary regular file "
|
|
794
|
+
"inside the repository, then retry task selection."
|
|
795
|
+
)
|
|
796
|
+
|
|
797
|
+
|
|
798
|
+
def read_task_ledger(root: Path) -> dict[str, Any]:
|
|
799
|
+
"""Read and classify the bounded canonical ledger without judging task meaning."""
|
|
800
|
+
snapshot, failure = read_stable_project_file_snapshot(
|
|
801
|
+
root,
|
|
802
|
+
Path(".ultra/tasks.json"),
|
|
803
|
+
max_bytes=TASK_LEDGER_SNAPSHOT_MAX_BYTES,
|
|
804
|
+
code_prefix="task_ledger_snapshot",
|
|
805
|
+
label="Canonical task ledger",
|
|
806
|
+
)
|
|
807
|
+
if snapshot is None:
|
|
808
|
+
diagnostics = []
|
|
809
|
+
if failure is not None:
|
|
810
|
+
diagnostic = dict(failure)
|
|
811
|
+
diagnostic["repair"] = _task_ledger_snapshot_repair(diagnostic["code"])
|
|
812
|
+
diagnostics.append(diagnostic)
|
|
813
|
+
return {
|
|
814
|
+
"classification": "unavailable",
|
|
815
|
+
"tasks": [],
|
|
816
|
+
"diagnostics": diagnostics,
|
|
817
|
+
}
|
|
42
818
|
try:
|
|
43
|
-
|
|
44
|
-
except
|
|
45
|
-
return
|
|
46
|
-
|
|
47
|
-
|
|
819
|
+
text = snapshot["bytes"].decode("utf-8")
|
|
820
|
+
except UnicodeDecodeError:
|
|
821
|
+
return {
|
|
822
|
+
"classification": "invalid",
|
|
823
|
+
"tasks": [],
|
|
824
|
+
"diagnostics": [{
|
|
825
|
+
"code": "task_ledger_invalid_utf8",
|
|
826
|
+
"message": "Canonical task ledger must be valid UTF-8 JSON.",
|
|
827
|
+
"path": snapshot["path"],
|
|
828
|
+
"repair": (
|
|
829
|
+
"Rewrite `.ultra/tasks.json` as valid UTF-8 without changing task "
|
|
830
|
+
"status or history, then retry task selection."
|
|
831
|
+
),
|
|
832
|
+
}],
|
|
833
|
+
}
|
|
834
|
+
try:
|
|
835
|
+
value = json.loads(text)
|
|
836
|
+
except json.JSONDecodeError:
|
|
837
|
+
return {
|
|
838
|
+
"classification": "invalid",
|
|
839
|
+
"tasks": [],
|
|
840
|
+
"diagnostics": [{
|
|
841
|
+
"code": "task_ledger_invalid_json",
|
|
842
|
+
"message": "Canonical task ledger must contain valid JSON.",
|
|
843
|
+
"path": snapshot["path"],
|
|
844
|
+
"repair": (
|
|
845
|
+
"Repair `.ultra/tasks.json` as valid JSON without changing task status "
|
|
846
|
+
"or history, then retry task selection."
|
|
847
|
+
),
|
|
848
|
+
}],
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
classification = "invalid"
|
|
852
|
+
tasks: Any = None
|
|
853
|
+
if isinstance(value, dict) and value.get("$schema") == TASK_LEDGER_SCHEMA_V2:
|
|
854
|
+
if set(value) == {"$schema", "tasks"}:
|
|
855
|
+
classification = "v2"
|
|
856
|
+
tasks = value.get("tasks")
|
|
857
|
+
elif isinstance(value, dict) and "$schema" not in value:
|
|
858
|
+
classification = "legacy_object"
|
|
859
|
+
tasks = value.get("tasks")
|
|
860
|
+
elif isinstance(value, list):
|
|
861
|
+
classification = "legacy_array"
|
|
862
|
+
tasks = value
|
|
863
|
+
|
|
864
|
+
if not isinstance(tasks, list):
|
|
865
|
+
return {
|
|
866
|
+
"classification": "invalid",
|
|
867
|
+
"tasks": [],
|
|
868
|
+
"diagnostics": [{
|
|
869
|
+
"code": "task_ledger_invalid_root",
|
|
870
|
+
"message": (
|
|
871
|
+
"Canonical task ledger must be an exact ultra-task-ledger-v2 root, "
|
|
872
|
+
"a legacy {tasks} root, or a legacy top-level task array."
|
|
873
|
+
),
|
|
874
|
+
"path": snapshot["path"],
|
|
875
|
+
"repair": (
|
|
876
|
+
"Restore the exact `ultra-task-ledger-v2` root with only `$schema` and "
|
|
877
|
+
"a `tasks` array while preserving every task row and status, then retry "
|
|
878
|
+
"task selection."
|
|
879
|
+
),
|
|
880
|
+
}],
|
|
881
|
+
}
|
|
882
|
+
diagnostics = []
|
|
883
|
+
if classification in {"legacy_object", "legacy_array"}:
|
|
884
|
+
diagnostics.append({
|
|
885
|
+
"code": "legacy_task_ledger_root",
|
|
886
|
+
"message": (
|
|
887
|
+
f"Task ledger uses the `{classification}` compatibility root; migrate it "
|
|
888
|
+
f"to the exact `{TASK_LEDGER_SCHEMA_V2}` root without changing task status."
|
|
889
|
+
),
|
|
890
|
+
"path": snapshot["path"],
|
|
891
|
+
"classification": classification,
|
|
892
|
+
})
|
|
893
|
+
validated_tasks = []
|
|
894
|
+
malformed = False
|
|
895
|
+
for index, row in enumerate(tasks):
|
|
896
|
+
validated, row_diagnostics = _validate_task_row(
|
|
897
|
+
row,
|
|
898
|
+
index,
|
|
899
|
+
classification,
|
|
900
|
+
snapshot["path"],
|
|
901
|
+
)
|
|
902
|
+
diagnostics.extend(row_diagnostics)
|
|
903
|
+
if validated is None:
|
|
904
|
+
malformed = True
|
|
905
|
+
else:
|
|
906
|
+
validated_tasks.append(validated)
|
|
907
|
+
graph_diagnostics = (
|
|
908
|
+
_validate_task_graph(validated_tasks, snapshot["path"])
|
|
909
|
+
if not malformed
|
|
910
|
+
else []
|
|
911
|
+
)
|
|
912
|
+
diagnostics.extend(graph_diagnostics)
|
|
913
|
+
invalid = malformed or bool(graph_diagnostics)
|
|
914
|
+
return {
|
|
915
|
+
"classification": "invalid" if invalid else classification,
|
|
916
|
+
"tasks": [] if invalid else validated_tasks,
|
|
917
|
+
"diagnostics": diagnostics,
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
|
|
921
|
+
def read_tasks(root: Path) -> list[dict[str, Any]]:
|
|
922
|
+
return read_task_ledger(root)["tasks"]
|
|
923
|
+
|
|
924
|
+
|
|
925
|
+
def normalized_task_id(value: Any) -> str | None:
|
|
926
|
+
"""Return one filesystem-safe task-id component without rewriting its identity."""
|
|
927
|
+
if (
|
|
928
|
+
not isinstance(value, str)
|
|
929
|
+
or re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]*", value) is None
|
|
930
|
+
or len(value.encode("utf-8")) > TASK_ID_MAX_BYTES
|
|
931
|
+
):
|
|
932
|
+
return None
|
|
933
|
+
return value
|
|
934
|
+
|
|
935
|
+
|
|
936
|
+
def _active_change_root_repair(code: str) -> str:
|
|
937
|
+
if code == "active_change_root_oversize":
|
|
938
|
+
return (
|
|
939
|
+
f"Move inactive entries out of .ultra/changes/active until it contains no "
|
|
940
|
+
f"more than {ACTIVE_CHANGE_DIRECTORY_MAX_ENTRIES} entries, normally exactly "
|
|
941
|
+
"one active Change, then retry task selection."
|
|
942
|
+
)
|
|
943
|
+
if code == "active_change_root_changed":
|
|
944
|
+
return (
|
|
945
|
+
"Retry task selection after active Change writes settle; if the surface "
|
|
946
|
+
"remains unsafe, restore its ordinary directory chain first."
|
|
947
|
+
)
|
|
948
|
+
return ACTIVE_CHANGE_ROOT_REPAIR
|
|
949
|
+
|
|
950
|
+
|
|
951
|
+
def _active_change_intent_repair(code: str) -> str:
|
|
952
|
+
if code == "active_change_intent_symlink":
|
|
953
|
+
return ACTIVE_CHANGE_SYMLINK_REPAIR
|
|
954
|
+
if code == "active_change_intent_oversize":
|
|
955
|
+
return (
|
|
956
|
+
f"Reduce intent.md below the {ACTIVE_CHANGE_INTENT_SNAPSHOT_MAX_BYTES}-byte "
|
|
957
|
+
"snapshot ceiling, then retry task selection. To abandon the Change instead, "
|
|
958
|
+
+ ACTIVE_CHANGE_ABANDONMENT_INSTRUCTION
|
|
959
|
+
)
|
|
960
|
+
if code == "active_change_intent_changed":
|
|
961
|
+
return (
|
|
962
|
+
"Retry task selection after intent.md writes settle. To abandon the Change "
|
|
963
|
+
"instead, " + ACTIVE_CHANGE_ABANDONMENT_INSTRUCTION
|
|
964
|
+
)
|
|
965
|
+
return ACTIVE_CHANGE_INTENT_REPAIR
|
|
966
|
+
|
|
967
|
+
|
|
968
|
+
def _active_change_selection(root: Path) -> dict[str, Any]:
|
|
969
|
+
active_relative = Path(".ultra") / "changes" / "active"
|
|
970
|
+
active = root / active_relative
|
|
971
|
+
diagnostics = []
|
|
972
|
+
entries, root_failure = read_stable_project_directory_snapshot(
|
|
973
|
+
root,
|
|
974
|
+
active_relative,
|
|
975
|
+
max_entries=ACTIVE_CHANGE_DIRECTORY_MAX_ENTRIES,
|
|
976
|
+
code_prefix="active_change_root",
|
|
977
|
+
label="Active Change directory",
|
|
978
|
+
)
|
|
979
|
+
if root_failure is not None:
|
|
980
|
+
diagnostic = dict(root_failure)
|
|
981
|
+
diagnostic["repair"] = _active_change_root_repair(diagnostic["code"])
|
|
982
|
+
diagnostics.append(diagnostic)
|
|
983
|
+
return {"change_id": None, "diagnostics": diagnostics}
|
|
984
|
+
assert entries is not None
|
|
985
|
+
|
|
986
|
+
ordinary_change_entries = []
|
|
987
|
+
for entry in entries:
|
|
988
|
+
entry_name = entry["name"]
|
|
989
|
+
entry_path = active / entry_name
|
|
990
|
+
if entry_name == ".gitkeep":
|
|
991
|
+
if stat.S_ISREG(entry["mode"]):
|
|
992
|
+
continue
|
|
993
|
+
diagnostics.append({
|
|
994
|
+
"code": "active_change_marker_not_regular",
|
|
995
|
+
"message": (
|
|
996
|
+
"Active Change marker `.gitkeep` must be an ordinary regular "
|
|
997
|
+
"non-symlink file."
|
|
998
|
+
),
|
|
999
|
+
"path": str(entry_path),
|
|
1000
|
+
"repair": (
|
|
1001
|
+
"Remove malformed .ultra/changes/active/.gitkeep or replace it with "
|
|
1002
|
+
"an ordinary regular non-symlink file, then retry task selection."
|
|
1003
|
+
),
|
|
1004
|
+
})
|
|
1005
|
+
continue
|
|
1006
|
+
if stat.S_ISLNK(entry["mode"]):
|
|
1007
|
+
diagnostics.append({
|
|
1008
|
+
"code": "active_change_directory_symlink",
|
|
1009
|
+
"message": (
|
|
1010
|
+
f"Active Change entry `{entry_name}` is a symlink and cannot "
|
|
1011
|
+
"establish current task authority."
|
|
1012
|
+
),
|
|
1013
|
+
"path": str(entry_path),
|
|
1014
|
+
"repair": ACTIVE_CHANGE_SYMLINK_REPAIR,
|
|
1015
|
+
})
|
|
1016
|
+
continue
|
|
1017
|
+
if stat.S_ISDIR(entry["mode"]):
|
|
1018
|
+
if normalized_task_id(entry_name) is None:
|
|
1019
|
+
diagnostics.append({
|
|
1020
|
+
"code": "active_change_id_invalid",
|
|
1021
|
+
"message": (
|
|
1022
|
+
f"Active Change directory `{entry_name}` is not one normalized "
|
|
1023
|
+
"filesystem-safe identifier."
|
|
1024
|
+
),
|
|
1025
|
+
"path": str(entry_path),
|
|
1026
|
+
"repair": (
|
|
1027
|
+
"Rename the active Change and its ledger change_id references to one "
|
|
1028
|
+
"matching normalized identifier, then retry task selection."
|
|
1029
|
+
),
|
|
1030
|
+
})
|
|
1031
|
+
continue
|
|
1032
|
+
ordinary_change_entries.append(entry)
|
|
1033
|
+
continue
|
|
1034
|
+
diagnostics.append({
|
|
1035
|
+
"code": "active_change_entry_not_directory",
|
|
1036
|
+
"message": (
|
|
1037
|
+
f"Active Change entry `{entry_name}` must be an ordinary directory."
|
|
1038
|
+
),
|
|
1039
|
+
"path": str(entry_path),
|
|
1040
|
+
"repair": (
|
|
1041
|
+
f"Remove or move `{entry_name}` out of .ultra/changes/active if it is "
|
|
1042
|
+
"stray, or restore it as one ordinary active Change directory containing "
|
|
1043
|
+
"its own regular intent.md; then retry task selection."
|
|
1044
|
+
),
|
|
1045
|
+
})
|
|
1046
|
+
|
|
1047
|
+
if len(ordinary_change_entries) > 1:
|
|
1048
|
+
change_ids = [entry["name"] for entry in ordinary_change_entries]
|
|
1049
|
+
rendered_change_ids = ", ".join(f"`{change_id}`" for change_id in change_ids)
|
|
1050
|
+
diagnostics.append({
|
|
1051
|
+
"code": "active_change_ambiguous",
|
|
1052
|
+
"message": (
|
|
1053
|
+
"More than one ordinary active Change exists; current task authority "
|
|
1054
|
+
"is ambiguous."
|
|
1055
|
+
),
|
|
1056
|
+
"path": str(active),
|
|
1057
|
+
"repair": (
|
|
1058
|
+
"Bootstrap recovery: do not invoke a current-Change workflow while authority "
|
|
1059
|
+
"is ambiguous. Stable-list and explicitly choose one of these candidate ids "
|
|
1060
|
+
f"to keep active in this worktree: {rendered_change_ids}. For every other "
|
|
1061
|
+
"named candidate, use native filesystem and Git tools to preserve unfinished "
|
|
1062
|
+
"work in an independent worktree; if an already-durable delivery closure "
|
|
1063
|
+
"proves it complete, move it to `.ultra/changes/archive/<change_id>`; or "
|
|
1064
|
+
"obtain explicit owner authorization, append the exact `## Abandonment` "
|
|
1065
|
+
"closure to that candidate's own `intent.md`, and move it to "
|
|
1066
|
+
"`.ultra/changes/abandoned/<change_id>`. Stable-list the active root again; "
|
|
1067
|
+
"only after exactly the chosen candidate remains may a current-Change "
|
|
1068
|
+
"workflow run and task selection be retried."
|
|
1069
|
+
),
|
|
1070
|
+
"change_ids": change_ids,
|
|
1071
|
+
})
|
|
1072
|
+
if diagnostics:
|
|
1073
|
+
return {"change_id": None, "diagnostics": diagnostics}
|
|
1074
|
+
|
|
1075
|
+
change_ids = []
|
|
1076
|
+
intent_receipts = {}
|
|
1077
|
+
for entry in ordinary_change_entries:
|
|
1078
|
+
entry_name = entry["name"]
|
|
1079
|
+
entry_path = active / entry_name
|
|
1080
|
+
intent_relative = active_relative / entry_name / "intent.md"
|
|
1081
|
+
intent_snapshot, intent_failure = read_stable_project_file_snapshot(
|
|
1082
|
+
root,
|
|
1083
|
+
intent_relative,
|
|
1084
|
+
max_bytes=ACTIVE_CHANGE_INTENT_SNAPSHOT_MAX_BYTES,
|
|
1085
|
+
code_prefix="active_change_intent",
|
|
1086
|
+
label=f"Active Change `{entry_name}` intent.md",
|
|
1087
|
+
)
|
|
1088
|
+
if intent_failure is not None:
|
|
1089
|
+
diagnostic = dict(intent_failure)
|
|
1090
|
+
diagnostic["repair"] = _active_change_intent_repair(diagnostic["code"])
|
|
1091
|
+
diagnostics.append(diagnostic)
|
|
1092
|
+
continue
|
|
1093
|
+
assert intent_snapshot is not None
|
|
1094
|
+
change_ids.append(entry_name)
|
|
1095
|
+
intent_receipts[entry_name] = (
|
|
1096
|
+
intent_snapshot["sha256"],
|
|
1097
|
+
intent_snapshot["byte_length"],
|
|
1098
|
+
)
|
|
1099
|
+
|
|
1100
|
+
fresh_entries, fresh_failure = read_stable_project_directory_snapshot(
|
|
1101
|
+
root,
|
|
1102
|
+
active_relative,
|
|
1103
|
+
max_entries=ACTIVE_CHANGE_DIRECTORY_MAX_ENTRIES,
|
|
1104
|
+
code_prefix="active_change_root",
|
|
1105
|
+
label="Active Change directory",
|
|
1106
|
+
)
|
|
1107
|
+
if fresh_failure is not None or fresh_entries != entries:
|
|
1108
|
+
diagnostics.append({
|
|
1109
|
+
"code": "active_change_root_changed",
|
|
1110
|
+
"message": (
|
|
1111
|
+
"Active Change directory authority changed during task selection; "
|
|
1112
|
+
"no task was selected."
|
|
1113
|
+
),
|
|
1114
|
+
"path": str(active),
|
|
1115
|
+
"repair": _active_change_root_repair("active_change_root_changed"),
|
|
1116
|
+
})
|
|
1117
|
+
else:
|
|
1118
|
+
for change_id, receipt in intent_receipts.items():
|
|
1119
|
+
fresh_intent, fresh_intent_failure = read_stable_project_file_snapshot(
|
|
1120
|
+
root,
|
|
1121
|
+
active_relative / change_id / "intent.md",
|
|
1122
|
+
max_bytes=ACTIVE_CHANGE_INTENT_SNAPSHOT_MAX_BYTES,
|
|
1123
|
+
code_prefix="active_change_intent",
|
|
1124
|
+
label=f"Active Change `{change_id}` intent.md",
|
|
1125
|
+
)
|
|
1126
|
+
if fresh_intent_failure is not None:
|
|
1127
|
+
diagnostic = dict(fresh_intent_failure)
|
|
1128
|
+
diagnostic["repair"] = _active_change_intent_repair(diagnostic["code"])
|
|
1129
|
+
diagnostics.append(diagnostic)
|
|
1130
|
+
continue
|
|
1131
|
+
assert fresh_intent is not None
|
|
1132
|
+
if (fresh_intent["sha256"], fresh_intent["byte_length"]) != receipt:
|
|
1133
|
+
diagnostics.append({
|
|
1134
|
+
"code": "active_change_intent_changed",
|
|
1135
|
+
"message": (
|
|
1136
|
+
f"Active Change `{change_id}` intent.md changed during task "
|
|
1137
|
+
"selection; no task was selected."
|
|
1138
|
+
),
|
|
1139
|
+
"path": fresh_intent["path"],
|
|
1140
|
+
"repair": _active_change_intent_repair(
|
|
1141
|
+
"active_change_intent_changed"
|
|
1142
|
+
),
|
|
1143
|
+
})
|
|
1144
|
+
|
|
1145
|
+
if diagnostics:
|
|
1146
|
+
return {"change_id": None, "diagnostics": diagnostics}
|
|
1147
|
+
if len(change_ids) != 1:
|
|
1148
|
+
return {"change_id": None, "diagnostics": diagnostics}
|
|
1149
|
+
return {"change_id": change_ids[0], "diagnostics": diagnostics}
|
|
1150
|
+
|
|
1151
|
+
|
|
1152
|
+
def active_change_id(root: Path) -> str | None:
|
|
1153
|
+
return _active_change_selection(root)["change_id"]
|
|
1154
|
+
|
|
1155
|
+
|
|
1156
|
+
def legacy_task_change_id(task: dict[str, Any]) -> str | None:
|
|
1157
|
+
value = task.get("change_ref")
|
|
1158
|
+
if not isinstance(value, str):
|
|
1159
|
+
return None
|
|
1160
|
+
match = re.fullmatch(
|
|
1161
|
+
r"(?:\.ultra/)?changes/active/([A-Za-z0-9][A-Za-z0-9._-]*)/intent\.md",
|
|
1162
|
+
value,
|
|
1163
|
+
)
|
|
1164
|
+
return match.group(1) if match else None
|
|
1165
|
+
|
|
1166
|
+
|
|
1167
|
+
def current_task_selection(root: Path, trusted_task_id: str | None = None) -> dict[str, Any]:
|
|
1168
|
+
"""Select the one live task: the exact task id carried by one trusted
|
|
1169
|
+
invocation, or otherwise the unique `in_progress` ledger row.
|
|
1170
|
+
|
|
1171
|
+
`trusted_task_id` is invocation-local. A caller may supply it only when the
|
|
1172
|
+
current owner-invoked public workflow or an authorized delegation argv
|
|
1173
|
+
explicitly carries that exact task id; it must mechanically match the ledger
|
|
1174
|
+
row of the active Change and is never persisted, cached, or written back as
|
|
1175
|
+
a selector. When supplied it is validated first and is the selection
|
|
1176
|
+
authority for this invocation, even when other rows claim `in_progress`;
|
|
1177
|
+
an invalid id returns its typed diagnostic and never silently selects
|
|
1178
|
+
another task. Resume Notes, progress records, directory order, the first
|
|
1179
|
+
`pending` row, compact snapshots, and historical reviews are never trusted
|
|
1180
|
+
invocations. A `pending` row is a frontier candidate only: this selection
|
|
1181
|
+
never auto-activates it without the explicit invocation-local id.
|
|
1182
|
+
"""
|
|
1183
|
+
ledger = read_task_ledger(root)
|
|
1184
|
+
selection = {
|
|
1185
|
+
"task": None,
|
|
1186
|
+
"diagnostics": [
|
|
1187
|
+
dict(diagnostic)
|
|
1188
|
+
for diagnostic in ledger["diagnostics"]
|
|
1189
|
+
if isinstance(diagnostic, dict)
|
|
1190
|
+
],
|
|
1191
|
+
}
|
|
1192
|
+
active_change = _active_change_selection(root)
|
|
1193
|
+
selection["diagnostics"].extend(active_change["diagnostics"])
|
|
1194
|
+
change_id = active_change["change_id"]
|
|
1195
|
+
if change_id is None:
|
|
1196
|
+
return selection
|
|
1197
|
+
tasks = [
|
|
1198
|
+
task for task in ledger["tasks"]
|
|
1199
|
+
if (
|
|
1200
|
+
task.get("change_id") == change_id
|
|
1201
|
+
or ("change_id" not in task and legacy_task_change_id(task) == change_id)
|
|
1202
|
+
)
|
|
1203
|
+
]
|
|
1204
|
+
|
|
1205
|
+
def selected(task: dict[str, Any]) -> dict[str, Any]:
|
|
1206
|
+
result = dict(task)
|
|
1207
|
+
result["_ultra_diagnostics"] = selection["diagnostics"]
|
|
1208
|
+
return result
|
|
1209
|
+
|
|
1210
|
+
if trusted_task_id is not None:
|
|
1211
|
+
# The invocation-local exact id is the selection authority: validate it
|
|
1212
|
+
# first and never silently fall back to the unique or ambiguous
|
|
1213
|
+
# `in_progress` rows it overrides for this invocation.
|
|
1214
|
+
safe_tasks = [
|
|
1215
|
+
task for task in tasks
|
|
1216
|
+
if normalized_task_id(task.get("id")) is not None
|
|
1217
|
+
and _task_context_relative_path(task) is not None
|
|
1218
|
+
]
|
|
1219
|
+
by_id = {task["id"]: task for task in safe_tasks}
|
|
1220
|
+
invoked_id = normalized_task_id(trusted_task_id)
|
|
1221
|
+
candidate = by_id.get(invoked_id) if invoked_id is not None else None
|
|
1222
|
+
if candidate is None:
|
|
1223
|
+
selection["diagnostics"].append({
|
|
1224
|
+
"code": "task_invocation_unknown",
|
|
1225
|
+
"message": (
|
|
1226
|
+
f"Trusted invocation names task `{trusted_task_id}`, which is not a "
|
|
1227
|
+
f"valid task of active Change `{change_id}`."
|
|
1228
|
+
),
|
|
1229
|
+
"path": str(root / ".ultra" / "tasks.json"),
|
|
1230
|
+
"change_id": change_id,
|
|
1231
|
+
"task_id": trusted_task_id,
|
|
1232
|
+
"repair": (
|
|
1233
|
+
"Invoke `ultra-dev` naming one exact task id that exists in the active "
|
|
1234
|
+
"Change's ledger, or obtain owner-authorized `ultra-plan` "
|
|
1235
|
+
"reconciliation; do not guess or persist a selector."
|
|
1236
|
+
),
|
|
1237
|
+
})
|
|
1238
|
+
return selection
|
|
1239
|
+
status = candidate.get("status")
|
|
1240
|
+
if status == "completed":
|
|
1241
|
+
selection["diagnostics"].append({
|
|
1242
|
+
"code": "task_invocation_not_activatable",
|
|
1243
|
+
"message": (
|
|
1244
|
+
f"Trusted invocation names task `{invoked_id}`, whose ledger row is "
|
|
1245
|
+
"`completed`; a completed task never returns to active work without "
|
|
1246
|
+
"the explicit Dev reopen."
|
|
1247
|
+
),
|
|
1248
|
+
"path": str(root / ".ultra" / "tasks.json"),
|
|
1249
|
+
"change_id": change_id,
|
|
1250
|
+
"task_id": invoked_id,
|
|
1251
|
+
"repair": (
|
|
1252
|
+
"Use `ultra-dev`'s explicit reopen path when current evidence "
|
|
1253
|
+
"invalidates the completed row; never silently demote it."
|
|
1254
|
+
),
|
|
1255
|
+
})
|
|
1256
|
+
return selection
|
|
1257
|
+
if status == "pending":
|
|
1258
|
+
dependencies = candidate.get("dependencies", [])
|
|
1259
|
+
ready = isinstance(dependencies, list) and all(
|
|
1260
|
+
isinstance(dependency, str)
|
|
1261
|
+
and dependency in by_id
|
|
1262
|
+
and by_id[dependency].get("status") == "completed"
|
|
1263
|
+
for dependency in dependencies
|
|
1264
|
+
)
|
|
1265
|
+
if not ready:
|
|
1266
|
+
selection["diagnostics"].append({
|
|
1267
|
+
"code": "task_invocation_dependency_blocked",
|
|
1268
|
+
"message": (
|
|
1269
|
+
f"Trusted invocation names pending task `{invoked_id}`, whose "
|
|
1270
|
+
"same-Change dependencies are not all completed."
|
|
1271
|
+
),
|
|
1272
|
+
"path": str(root / ".ultra" / "tasks.json"),
|
|
1273
|
+
"change_id": change_id,
|
|
1274
|
+
"task_id": invoked_id,
|
|
1275
|
+
"repair": (
|
|
1276
|
+
"Invoke a dependency-ready task id, or obtain owner-authorized "
|
|
1277
|
+
"`ultra-plan` dependency reconciliation first."
|
|
1278
|
+
),
|
|
1279
|
+
})
|
|
1280
|
+
return selection
|
|
1281
|
+
selection["task"] = selected(candidate)
|
|
1282
|
+
return selection
|
|
1283
|
+
|
|
1284
|
+
in_progress = [task for task in tasks if task.get("status") == "in_progress"]
|
|
1285
|
+
if len(in_progress) == 1:
|
|
1286
|
+
candidate = in_progress[0]
|
|
1287
|
+
if (
|
|
1288
|
+
normalized_task_id(candidate.get("id")) is None
|
|
1289
|
+
or _task_context_relative_path(candidate) is None
|
|
1290
|
+
):
|
|
1291
|
+
return selection
|
|
1292
|
+
selection["task"] = selected(candidate)
|
|
1293
|
+
return selection
|
|
1294
|
+
if len(in_progress) > 1:
|
|
1295
|
+
task_ids = [task["id"] for task in in_progress]
|
|
1296
|
+
rendered_ids = ", ".join(f"`{task_id}`" for task_id in task_ids)
|
|
1297
|
+
selection["diagnostics"].append({
|
|
1298
|
+
"code": "task_in_progress_ambiguous",
|
|
1299
|
+
"message": (
|
|
1300
|
+
f"More than one task in active Change `{change_id}` claims status "
|
|
1301
|
+
f"`in_progress`: {rendered_ids}. Selection authority is ambiguous."
|
|
1302
|
+
),
|
|
1303
|
+
"path": str(root / ".ultra" / "tasks.json"),
|
|
1304
|
+
"change_id": change_id,
|
|
1305
|
+
"task_ids": task_ids,
|
|
1306
|
+
"repair": (
|
|
1307
|
+
"Inspect each named task's canonical context and Resume Note together with "
|
|
1308
|
+
"current Git/worktree evidence before changing any ledger row. Do not demote "
|
|
1309
|
+
"an `in_progress` task merely to restore unique selection. A conflicting row "
|
|
1310
|
+
"may return to `pending` only through an explicit owner/Plan correction that "
|
|
1311
|
+
"establishes the task never started. If multiple tasks contain real partial "
|
|
1312
|
+
"work, keep every such row `in_progress`; Hooks stay task-silent and "
|
|
1313
|
+
"progress-silent. Recover by explicitly owner-invoking `ultra-dev` for one "
|
|
1314
|
+
"exact task id, or obtain owner-authorized `ultra-plan` reconciliation that "
|
|
1315
|
+
"preserves each task's work, using a separate worktree when needed. Completing "
|
|
1316
|
+
"any task still requires final review, canonical v2 evidence, context "
|
|
1317
|
+
"publication, ledger write, and readback; then retry task selection."
|
|
1318
|
+
),
|
|
1319
|
+
})
|
|
1320
|
+
return selection
|
|
1321
|
+
|
|
1322
|
+
# No live task: `pending` rows stay frontier candidates. Hooks remain
|
|
1323
|
+
# task-silent and progress-silent until an owner invocation or the unique
|
|
1324
|
+
# `in_progress` row establishes live authority.
|
|
1325
|
+
return selection
|
|
48
1326
|
|
|
49
1327
|
|
|
50
1328
|
def current_task(root: Path) -> dict[str, Any] | None:
|
|
51
|
-
|
|
52
|
-
for status in ("in_progress", "pending"):
|
|
53
|
-
for task in tasks:
|
|
54
|
-
if task.get("status") == status:
|
|
55
|
-
return task
|
|
56
|
-
return None
|
|
1329
|
+
return current_task_selection(root)["task"]
|
|
57
1330
|
|
|
58
1331
|
|
|
59
|
-
def
|
|
60
|
-
|
|
61
|
-
|
|
1332
|
+
def project_relative_path(value: str, *, ultra_default: bool = False) -> Path | None:
|
|
1333
|
+
if (
|
|
1334
|
+
not value
|
|
1335
|
+
or "\0" in value
|
|
1336
|
+
or "\\" in value
|
|
1337
|
+
or value.startswith("/")
|
|
1338
|
+
or re.match(r"^[A-Za-z]:", value) is not None
|
|
1339
|
+
):
|
|
1340
|
+
return None
|
|
1341
|
+
parts = value.split("/")
|
|
1342
|
+
if any(part in {"", ".", ".."} for part in parts):
|
|
62
1343
|
return None
|
|
1344
|
+
relative = Path(*parts)
|
|
63
1345
|
ultra_shorthands = {"changes", "contexts", "decisions", "evidence", "progress", "reviews", "specs"}
|
|
64
|
-
first =
|
|
1346
|
+
first = parts[0]
|
|
65
1347
|
if first == ".ultra":
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
1348
|
+
return relative
|
|
1349
|
+
if ultra_default and first in ultra_shorthands:
|
|
1350
|
+
return Path(".ultra") / relative
|
|
1351
|
+
return relative
|
|
1352
|
+
|
|
1353
|
+
|
|
1354
|
+
def project_file(root: Path, value: str, *, ultra_default: bool = False) -> Path | None:
|
|
1355
|
+
relative = project_relative_path(value, ultra_default=ultra_default)
|
|
1356
|
+
if relative is None:
|
|
1357
|
+
return None
|
|
1358
|
+
candidate = (root / relative).resolve()
|
|
72
1359
|
try:
|
|
73
1360
|
candidate.relative_to(root.resolve())
|
|
74
1361
|
except ValueError:
|
|
@@ -83,18 +1370,384 @@ def context_path(root: Path, task: dict[str, Any]) -> Path | None:
|
|
|
83
1370
|
return project_file(root, value, ultra_default=True)
|
|
84
1371
|
|
|
85
1372
|
|
|
86
|
-
def
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
1373
|
+
def _task_context_relative_path(task: dict[str, Any]) -> Path | None:
|
|
1374
|
+
value = task.get("context_file")
|
|
1375
|
+
if not isinstance(value, str) or not value:
|
|
1376
|
+
return None
|
|
1377
|
+
return project_relative_path(value, ultra_default=True)
|
|
1378
|
+
|
|
1379
|
+
|
|
1380
|
+
def _task_context_restore_repair(relative: Path | None) -> str:
|
|
1381
|
+
context = (
|
|
1382
|
+
f"`{relative.as_posix()}`"
|
|
1383
|
+
if relative is not None
|
|
1384
|
+
else "the task context recorded by the `.ultra/tasks.json` row"
|
|
1385
|
+
)
|
|
1386
|
+
return (
|
|
1387
|
+
f"Restore {context} from Git or owner-readable history as one stable ordinary "
|
|
1388
|
+
"regular UTF-8 file, keep the `.ultra/tasks.json` row unchanged, and retry the "
|
|
1389
|
+
"Hook event."
|
|
1390
|
+
)
|
|
1391
|
+
|
|
1392
|
+
|
|
1393
|
+
def _legacy_context_field(text: str, field: str) -> str | None:
|
|
1394
|
+
first_section = re.search(r"^##\s+", text, re.MULTILINE)
|
|
1395
|
+
preamble = text[:first_section.start()] if first_section else text
|
|
1396
|
+
match = re.search(
|
|
1397
|
+
rf"\*\*{re.escape(field)}\*\*:\s*(?P<value>[^|\n]*)",
|
|
1398
|
+
preamble,
|
|
1399
|
+
re.MULTILINE,
|
|
1400
|
+
)
|
|
1401
|
+
if match is None:
|
|
1402
|
+
return None
|
|
1403
|
+
return match.group("value").strip().strip("`")
|
|
1404
|
+
|
|
1405
|
+
|
|
1406
|
+
def task_sections(root: Path, task: dict[str, Any]) -> dict[str, Any]:
|
|
1407
|
+
relative = _task_context_relative_path(task)
|
|
1408
|
+
repair = _task_context_restore_repair(relative)
|
|
1409
|
+
inherited_diagnostics = task.get("_ultra_diagnostics")
|
|
1410
|
+
diagnostics: list[dict[str, Any]] = [
|
|
1411
|
+
dict(diagnostic)
|
|
1412
|
+
for diagnostic in inherited_diagnostics
|
|
1413
|
+
if isinstance(diagnostic, dict)
|
|
1414
|
+
] if isinstance(inherited_diagnostics, list) else []
|
|
1415
|
+
text = ""
|
|
1416
|
+
context_available = False
|
|
1417
|
+
context_file = str((root.absolute() / relative) if relative else "")
|
|
1418
|
+
if relative is None:
|
|
1419
|
+
diagnostics.append({
|
|
1420
|
+
"code": "task_context_path_invalid",
|
|
1421
|
+
"message": "Task context_file must be a normalized repository-relative path.",
|
|
1422
|
+
"path": str(task.get("context_file") or ""),
|
|
1423
|
+
"repair": repair,
|
|
1424
|
+
})
|
|
1425
|
+
else:
|
|
1426
|
+
snapshot, failure = read_stable_project_file_snapshot(
|
|
1427
|
+
root,
|
|
1428
|
+
relative,
|
|
1429
|
+
max_bytes=TASK_CONTEXT_SNAPSHOT_MAX_BYTES,
|
|
1430
|
+
code_prefix="task_context_snapshot",
|
|
1431
|
+
label="Current task context",
|
|
1432
|
+
)
|
|
1433
|
+
if snapshot is None:
|
|
1434
|
+
if failure is not None:
|
|
1435
|
+
diagnostics.append({**failure, "repair": repair})
|
|
1436
|
+
else:
|
|
1437
|
+
context_file = snapshot["path"]
|
|
1438
|
+
try:
|
|
1439
|
+
text = snapshot["bytes"].decode("utf-8")
|
|
1440
|
+
context_available = True
|
|
1441
|
+
except UnicodeDecodeError:
|
|
1442
|
+
diagnostics.append({
|
|
1443
|
+
"code": "task_context_invalid_utf8",
|
|
1444
|
+
"message": "Current task context must be valid UTF-8 Markdown.",
|
|
1445
|
+
"path": context_file,
|
|
1446
|
+
"repair": repair,
|
|
1447
|
+
})
|
|
1448
|
+
|
|
1449
|
+
legacy_status = _legacy_context_field(text, "Status")
|
|
1450
|
+
if legacy_status is not None:
|
|
1451
|
+
ledger_status = task.get("status")
|
|
1452
|
+
diagnostics.append({
|
|
1453
|
+
"code": "legacy_context_status",
|
|
1454
|
+
"message": (
|
|
1455
|
+
f"Task context records legacy Status `{legacy_status}`; "
|
|
1456
|
+
"`.ultra/tasks.json` remains the sole task-status authority."
|
|
1457
|
+
),
|
|
1458
|
+
"path": context_file,
|
|
1459
|
+
"observed_status": legacy_status,
|
|
1460
|
+
"ledger_status": ledger_status,
|
|
1461
|
+
})
|
|
1462
|
+
if legacy_status != ledger_status:
|
|
1463
|
+
diagnostics.append({
|
|
1464
|
+
"code": "legacy_context_status_mismatch",
|
|
1465
|
+
"message": (
|
|
1466
|
+
f"Legacy context Status `{legacy_status}` differs from ledger Status "
|
|
1467
|
+
f"`{ledger_status}`; the ledger value wins and the context remains a "
|
|
1468
|
+
"migration observation only."
|
|
1469
|
+
),
|
|
1470
|
+
"path": context_file,
|
|
1471
|
+
"observed_status": legacy_status,
|
|
1472
|
+
"ledger_status": ledger_status,
|
|
1473
|
+
})
|
|
1474
|
+
legacy_complexity = _legacy_context_field(text, "Complexity")
|
|
1475
|
+
if legacy_complexity is not None:
|
|
1476
|
+
diagnostics.append({
|
|
1477
|
+
"code": "legacy_context_complexity",
|
|
1478
|
+
"message": (
|
|
1479
|
+
f"Task context records legacy Complexity `{legacy_complexity}`; it is a "
|
|
1480
|
+
"migration observation and never a scope, quality, or completion gate."
|
|
1481
|
+
),
|
|
1482
|
+
"path": context_file,
|
|
1483
|
+
"observed_complexity": legacy_complexity,
|
|
1484
|
+
})
|
|
92
1485
|
return {
|
|
93
1486
|
"acceptance": markdown_section(text, "Acceptance Criteria"),
|
|
94
1487
|
"resume": markdown_section(text, "Resume Note"),
|
|
1488
|
+
"diagnostics": diagnostics,
|
|
1489
|
+
"context_available": context_available,
|
|
95
1490
|
}
|
|
96
1491
|
|
|
97
1492
|
|
|
1493
|
+
def render_task_diagnostics(diagnostics: Any) -> str:
|
|
1494
|
+
if not isinstance(diagnostics, list):
|
|
1495
|
+
return ""
|
|
1496
|
+
lines = []
|
|
1497
|
+
for diagnostic in diagnostics:
|
|
1498
|
+
if not isinstance(diagnostic, dict):
|
|
1499
|
+
continue
|
|
1500
|
+
code = diagnostic.get("code")
|
|
1501
|
+
message = diagnostic.get("message")
|
|
1502
|
+
if isinstance(code, str) and code and isinstance(message, str) and message:
|
|
1503
|
+
lines.append(f"- `{code}`: {message}")
|
|
1504
|
+
repair = diagnostic.get("repair")
|
|
1505
|
+
if isinstance(repair, str) and repair.strip():
|
|
1506
|
+
lines.append(f" Repair: {repair}")
|
|
1507
|
+
return "\n".join(lines)
|
|
1508
|
+
|
|
1509
|
+
|
|
1510
|
+
def _normalized_derived_path(relative_file: Path | str) -> Path | None:
|
|
1511
|
+
relative = Path(relative_file)
|
|
1512
|
+
if (
|
|
1513
|
+
relative.is_absolute()
|
|
1514
|
+
or len(relative.parts) != 3
|
|
1515
|
+
or any(part in {"", ".", ".."} for part in relative.parts)
|
|
1516
|
+
or tuple(relative.parts[:2]) not in DERIVED_FILE_PREFIXES
|
|
1517
|
+
):
|
|
1518
|
+
return None
|
|
1519
|
+
if relative.parts[:2] == (".ultra", "progress"):
|
|
1520
|
+
filename = relative.parts[2]
|
|
1521
|
+
if not filename.endswith(".json") or normalized_task_id(filename[:-5]) is None:
|
|
1522
|
+
return None
|
|
1523
|
+
elif relative.parts != (".ultra", ".runtime", "compact-snapshot.md"):
|
|
1524
|
+
return None
|
|
1525
|
+
return relative
|
|
1526
|
+
|
|
1527
|
+
|
|
1528
|
+
def read_derived_project_file_snapshot(
|
|
1529
|
+
root: Path,
|
|
1530
|
+
relative_file: Path | str,
|
|
1531
|
+
*,
|
|
1532
|
+
max_bytes: int,
|
|
1533
|
+
code_prefix: str,
|
|
1534
|
+
label: str,
|
|
1535
|
+
) -> tuple[dict[str, Any] | None, dict[str, str] | None]:
|
|
1536
|
+
relative = _normalized_derived_path(relative_file)
|
|
1537
|
+
if relative is None:
|
|
1538
|
+
return None, {
|
|
1539
|
+
"code": f"{code_prefix}_path_invalid",
|
|
1540
|
+
"message": f"{label} path is not an allowed normalized derived-artifact path.",
|
|
1541
|
+
"path": str(relative_file),
|
|
1542
|
+
}
|
|
1543
|
+
return read_stable_project_file_snapshot(
|
|
1544
|
+
root,
|
|
1545
|
+
relative,
|
|
1546
|
+
max_bytes=max_bytes,
|
|
1547
|
+
code_prefix=code_prefix,
|
|
1548
|
+
label=label,
|
|
1549
|
+
)
|
|
1550
|
+
|
|
1551
|
+
|
|
1552
|
+
def read_derived_json(
|
|
1553
|
+
root: Path,
|
|
1554
|
+
relative_file: Path | str,
|
|
1555
|
+
*,
|
|
1556
|
+
max_bytes: int,
|
|
1557
|
+
code_prefix: str,
|
|
1558
|
+
label: str,
|
|
1559
|
+
) -> tuple[dict[str, Any] | None, dict[str, str] | None]:
|
|
1560
|
+
snapshot, failure = read_derived_project_file_snapshot(
|
|
1561
|
+
root,
|
|
1562
|
+
relative_file,
|
|
1563
|
+
max_bytes=max_bytes,
|
|
1564
|
+
code_prefix=code_prefix,
|
|
1565
|
+
label=label,
|
|
1566
|
+
)
|
|
1567
|
+
if snapshot is None:
|
|
1568
|
+
return None, failure
|
|
1569
|
+
try:
|
|
1570
|
+
text = snapshot["bytes"].decode("utf-8")
|
|
1571
|
+
except UnicodeDecodeError:
|
|
1572
|
+
return None, {
|
|
1573
|
+
"code": f"{code_prefix}_invalid_utf8",
|
|
1574
|
+
"message": f"{label} must be valid UTF-8 JSON.",
|
|
1575
|
+
"path": snapshot["path"],
|
|
1576
|
+
}
|
|
1577
|
+
try:
|
|
1578
|
+
value = json.loads(text)
|
|
1579
|
+
except json.JSONDecodeError:
|
|
1580
|
+
return None, {
|
|
1581
|
+
"code": f"{code_prefix}_invalid_json",
|
|
1582
|
+
"message": f"{label} contains invalid JSON and can be rebuilt.",
|
|
1583
|
+
"path": snapshot["path"],
|
|
1584
|
+
}
|
|
1585
|
+
if not isinstance(value, dict):
|
|
1586
|
+
return None, {
|
|
1587
|
+
"code": f"{code_prefix}_invalid_root",
|
|
1588
|
+
"message": f"{label} JSON root must be an object and can be rebuilt.",
|
|
1589
|
+
"path": snapshot["path"],
|
|
1590
|
+
}
|
|
1591
|
+
return value, None
|
|
1592
|
+
|
|
1593
|
+
|
|
1594
|
+
def _open_or_create_stable_directory_chain(
|
|
1595
|
+
root: Path,
|
|
1596
|
+
components: tuple[str, ...],
|
|
1597
|
+
flags: int,
|
|
1598
|
+
) -> tuple[list[int], list[tuple[int, int, int]]]:
|
|
1599
|
+
descriptors: list[int] = []
|
|
1600
|
+
identities: list[tuple[int, int, int]] = []
|
|
1601
|
+
try:
|
|
1602
|
+
root_stat = os.stat(root, follow_symlinks=False)
|
|
1603
|
+
if stat.S_ISLNK(root_stat.st_mode):
|
|
1604
|
+
raise _SnapshotInvariantError("symlink")
|
|
1605
|
+
if not stat.S_ISDIR(root_stat.st_mode):
|
|
1606
|
+
raise _SnapshotInvariantError("not_regular")
|
|
1607
|
+
descriptor = os.open(root, flags)
|
|
1608
|
+
descriptors.append(descriptor)
|
|
1609
|
+
descriptor_stat = os.fstat(descriptor)
|
|
1610
|
+
if _directory_identity(root_stat) != _directory_identity(descriptor_stat):
|
|
1611
|
+
raise _SnapshotInvariantError("changed")
|
|
1612
|
+
identities.append(_directory_identity(descriptor_stat))
|
|
1613
|
+
|
|
1614
|
+
for component in components:
|
|
1615
|
+
try:
|
|
1616
|
+
path_stat = os.stat(component, dir_fd=descriptor, follow_symlinks=False)
|
|
1617
|
+
except FileNotFoundError:
|
|
1618
|
+
try:
|
|
1619
|
+
os.mkdir(component, mode=0o700, dir_fd=descriptor)
|
|
1620
|
+
except FileExistsError:
|
|
1621
|
+
pass
|
|
1622
|
+
path_stat = os.stat(component, dir_fd=descriptor, follow_symlinks=False)
|
|
1623
|
+
if stat.S_ISLNK(path_stat.st_mode):
|
|
1624
|
+
raise _SnapshotInvariantError("symlink")
|
|
1625
|
+
if not stat.S_ISDIR(path_stat.st_mode):
|
|
1626
|
+
raise _SnapshotInvariantError("not_regular")
|
|
1627
|
+
descriptor = os.open(component, flags, dir_fd=descriptor)
|
|
1628
|
+
descriptors.append(descriptor)
|
|
1629
|
+
descriptor_stat = os.fstat(descriptor)
|
|
1630
|
+
if _directory_identity(path_stat) != _directory_identity(descriptor_stat):
|
|
1631
|
+
raise _SnapshotInvariantError("changed")
|
|
1632
|
+
identities.append(_directory_identity(descriptor_stat))
|
|
1633
|
+
return descriptors, identities
|
|
1634
|
+
except Exception:
|
|
1635
|
+
for descriptor in reversed(descriptors):
|
|
1636
|
+
os.close(descriptor)
|
|
1637
|
+
raise
|
|
1638
|
+
|
|
1639
|
+
|
|
1640
|
+
def write_derived_project_file_atomic(
|
|
1641
|
+
root: Path,
|
|
1642
|
+
relative_file: Path | str,
|
|
1643
|
+
data: bytes,
|
|
1644
|
+
*,
|
|
1645
|
+
max_bytes: int,
|
|
1646
|
+
code_prefix: str,
|
|
1647
|
+
label: str,
|
|
1648
|
+
) -> dict[str, str] | None:
|
|
1649
|
+
"""Atomically publish one bounded derived file without following a symlink."""
|
|
1650
|
+
relative = _normalized_derived_path(relative_file)
|
|
1651
|
+
canonical = root.absolute() / Path(relative_file)
|
|
1652
|
+
|
|
1653
|
+
def failure(kind: str, message: str) -> dict[str, str]:
|
|
1654
|
+
return {
|
|
1655
|
+
"code": f"{code_prefix}_{kind}",
|
|
1656
|
+
"message": message,
|
|
1657
|
+
"path": str(canonical),
|
|
1658
|
+
}
|
|
1659
|
+
|
|
1660
|
+
if relative is None:
|
|
1661
|
+
return failure(
|
|
1662
|
+
"path_invalid",
|
|
1663
|
+
f"{label} path is not an allowed normalized derived-artifact path.",
|
|
1664
|
+
)
|
|
1665
|
+
if len(data) > max_bytes:
|
|
1666
|
+
return failure("oversize", f"{label} exceeds the {max_bytes}-byte publication limit.")
|
|
1667
|
+
try:
|
|
1668
|
+
root_real = root.resolve(strict=True)
|
|
1669
|
+
except (OSError, RuntimeError) as error:
|
|
1670
|
+
return failure("read_error", f"{label} repository root is unavailable: {error}")
|
|
1671
|
+
canonical = root_real.joinpath(*relative.parts)
|
|
1672
|
+
directory_flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0)
|
|
1673
|
+
file_flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0)
|
|
1674
|
+
descriptors: list[int] = []
|
|
1675
|
+
temporary_descriptor: int | None = None
|
|
1676
|
+
temporary_name: str | None = None
|
|
1677
|
+
parent_descriptor: int | None = None
|
|
1678
|
+
try:
|
|
1679
|
+
descriptors, identities = _open_or_create_stable_directory_chain(
|
|
1680
|
+
root_real,
|
|
1681
|
+
relative.parts[:-1],
|
|
1682
|
+
directory_flags,
|
|
1683
|
+
)
|
|
1684
|
+
parent_descriptor = descriptors[-1]
|
|
1685
|
+
filename = relative.parts[-1]
|
|
1686
|
+
try:
|
|
1687
|
+
existing = os.stat(filename, dir_fd=parent_descriptor, follow_symlinks=False)
|
|
1688
|
+
except FileNotFoundError:
|
|
1689
|
+
existing = None
|
|
1690
|
+
if existing is not None:
|
|
1691
|
+
if stat.S_ISLNK(existing.st_mode):
|
|
1692
|
+
raise _SnapshotInvariantError("symlink")
|
|
1693
|
+
if not stat.S_ISREG(existing.st_mode):
|
|
1694
|
+
raise _SnapshotInvariantError("not_regular")
|
|
1695
|
+
|
|
1696
|
+
temporary_name = f".{filename}.tmp-{os.getpid()}-{secrets.token_hex(8)}"
|
|
1697
|
+
temporary_descriptor = os.open(
|
|
1698
|
+
temporary_name,
|
|
1699
|
+
file_flags,
|
|
1700
|
+
0o600,
|
|
1701
|
+
dir_fd=parent_descriptor,
|
|
1702
|
+
)
|
|
1703
|
+
written = 0
|
|
1704
|
+
while written < len(data):
|
|
1705
|
+
written += os.write(temporary_descriptor, data[written:])
|
|
1706
|
+
os.fsync(temporary_descriptor)
|
|
1707
|
+
temporary_stat = os.fstat(temporary_descriptor)
|
|
1708
|
+
if not stat.S_ISREG(temporary_stat.st_mode) or temporary_stat.st_size != len(data):
|
|
1709
|
+
raise _SnapshotInvariantError("changed")
|
|
1710
|
+
os.close(temporary_descriptor)
|
|
1711
|
+
temporary_descriptor = None
|
|
1712
|
+
|
|
1713
|
+
fresh_descriptors, fresh_identities = _open_stable_directory_chain(
|
|
1714
|
+
root_real,
|
|
1715
|
+
relative.parts[:-1],
|
|
1716
|
+
directory_flags,
|
|
1717
|
+
)
|
|
1718
|
+
descriptors.extend(fresh_descriptors)
|
|
1719
|
+
if fresh_identities != identities:
|
|
1720
|
+
raise _SnapshotInvariantError("changed")
|
|
1721
|
+
os.replace(
|
|
1722
|
+
temporary_name,
|
|
1723
|
+
filename,
|
|
1724
|
+
src_dir_fd=parent_descriptor,
|
|
1725
|
+
dst_dir_fd=parent_descriptor,
|
|
1726
|
+
)
|
|
1727
|
+
temporary_name = None
|
|
1728
|
+
os.fsync(parent_descriptor)
|
|
1729
|
+
return None
|
|
1730
|
+
except _SnapshotInvariantError as error:
|
|
1731
|
+
messages = {
|
|
1732
|
+
"symlink": f"{label} path and target must not contain symlinks.",
|
|
1733
|
+
"not_regular": f"{label} target must be a regular file beneath ordinary directories.",
|
|
1734
|
+
"changed": f"{label} path changed during publication; retry the Hook event.",
|
|
1735
|
+
}
|
|
1736
|
+
return failure(error.kind, messages[error.kind])
|
|
1737
|
+
except OSError as error:
|
|
1738
|
+
return failure("write_error", f"{label} could not be published safely: {error}")
|
|
1739
|
+
finally:
|
|
1740
|
+
if temporary_descriptor is not None:
|
|
1741
|
+
os.close(temporary_descriptor)
|
|
1742
|
+
if temporary_name is not None and parent_descriptor is not None:
|
|
1743
|
+
try:
|
|
1744
|
+
os.unlink(temporary_name, dir_fd=parent_descriptor)
|
|
1745
|
+
except OSError:
|
|
1746
|
+
pass
|
|
1747
|
+
for descriptor in reversed(descriptors):
|
|
1748
|
+
os.close(descriptor)
|
|
1749
|
+
|
|
1750
|
+
|
|
98
1751
|
def emit_context(event: str, text: str) -> None:
|
|
99
1752
|
if not text.strip():
|
|
100
1753
|
return
|
|
@@ -106,8 +1759,21 @@ def emit_context(event: str, text: str) -> None:
|
|
|
106
1759
|
}))
|
|
107
1760
|
|
|
108
1761
|
|
|
109
|
-
def write_json_atomic(
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
1762
|
+
def write_json_atomic(
|
|
1763
|
+
root: Path,
|
|
1764
|
+
relative_file: Path | str,
|
|
1765
|
+
payload: dict[str, Any],
|
|
1766
|
+
*,
|
|
1767
|
+
max_bytes: int,
|
|
1768
|
+
code_prefix: str,
|
|
1769
|
+
label: str,
|
|
1770
|
+
) -> dict[str, str] | None:
|
|
1771
|
+
data = f"{json.dumps(payload, indent=2)}\n".encode("utf-8")
|
|
1772
|
+
return write_derived_project_file_atomic(
|
|
1773
|
+
root,
|
|
1774
|
+
relative_file,
|
|
1775
|
+
data,
|
|
1776
|
+
max_bytes=max_bytes,
|
|
1777
|
+
code_prefix=code_prefix,
|
|
1778
|
+
label=label,
|
|
1779
|
+
)
|