ultra-builder-pro-cli 5.0.1 → 6.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. package/CHANGELOG.md +103 -3
  2. package/README.md +67 -478
  3. package/README.zh-CN.md +49 -422
  4. package/adapters/_shared/frontmatter.cjs +1 -18
  5. package/adapters/_shared/plugin-core.cjs +17 -83
  6. package/adapters/_shared/runtime-assets.cjs +6 -120
  7. package/adapters/claude.js +8 -24
  8. package/adapters/codex.js +13 -32
  9. package/bin/install.js +35 -57
  10. package/docs/PLUGIN-ISOLATION-CONTRACT.md +13 -101
  11. package/docs/examples/drive/claude-reviewer.sh +53 -0
  12. package/docs/examples/drive/claude-writer.sh +43 -0
  13. package/engine/FORMATS.md +1051 -0
  14. package/engine/checks.cjs +139 -0
  15. package/engine/cli.cjs +148 -0
  16. package/engine/completion.cjs +48 -0
  17. package/engine/context.cjs +94 -0
  18. package/engine/drive.cjs +220 -0
  19. package/engine/expect-worker.cjs +5 -0
  20. package/engine/expect.cjs +33 -0
  21. package/engine/formats.cjs +189 -0
  22. package/engine/git-state.cjs +112 -0
  23. package/engine/ledger.cjs +205 -0
  24. package/engine/loading.cjs +28 -0
  25. package/engine/merge.cjs +273 -0
  26. package/engine/milestone.cjs +89 -0
  27. package/engine/plan.cjs +232 -0
  28. package/engine/project.cjs +5 -0
  29. package/engine/signals.cjs +3 -0
  30. package/engine/states.cjs +175 -0
  31. package/hooks/session-start.cjs +54 -0
  32. package/package.json +17 -52
  33. package/skills/ultra/SKILL.md +395 -0
  34. package/.ultra-template/changes/abandoned/.gitkeep +0 -0
  35. package/.ultra-template/changes/active/.gitkeep +0 -1
  36. package/.ultra-template/changes/archive/.gitkeep +0 -1
  37. package/.ultra-template/contexts/TEMPLATE.md +0 -135
  38. package/.ultra-template/decisions/.gitkeep +0 -1
  39. package/.ultra-template/evidence/.gitkeep +0 -1
  40. package/.ultra-template/north-star.md +0 -52
  41. package/.ultra-template/project-brief.md +0 -28
  42. package/.ultra-template/research/.gitkeep +0 -1
  43. package/.ultra-template/specs/architecture.md +0 -81
  44. package/.ultra-template/specs/discovery.md +0 -74
  45. package/.ultra-template/specs/product.md +0 -80
  46. package/.ultra-template/specs/research-distillate.md +0 -57
  47. package/.ultra-template/tasks.json +0 -4
  48. package/.ultra-template/test-report.json +0 -44
  49. package/adapters/_shared/host-profile.cjs +0 -5
  50. package/adapters/grok.js +0 -242
  51. package/adapters/kimi.js +0 -232
  52. package/adapters/opencode.js +0 -408
  53. package/adapters/zcode.js +0 -375
  54. package/bin/delegate-worker.cjs +0 -5
  55. package/bin/delegate.cjs +0 -5
  56. package/docs/ARCHITECTURE.md +0 -274
  57. package/docs/ARTIFACT-AUTHORITY.md +0 -305
  58. package/docs/DECISIONS.md +0 -229
  59. package/docs/PHILOSOPHY.md +0 -261
  60. package/docs/RUNTIME-COMPAT-MATRIX.md +0 -316
  61. package/docs/SKILL-AUTHORING.md +0 -113
  62. package/docs/WORKFLOW-LIFECYCLE.md +0 -350
  63. package/docs/evals/adversarial-review-2026-08-14.md +0 -84
  64. package/docs/evals/ultra-5-0-journey-results.md +0 -62
  65. package/docs/evals/ultra-5-0-live-acp-conformance.md +0 -93
  66. package/docs/evals/ultra-5-0-route-journeys.md +0 -145
  67. package/docs/evals/zcode-automation-2026-08-14.md +0 -103
  68. package/hooks/README.md +0 -52
  69. package/hooks/_common.py +0 -1779
  70. package/hooks/adapters/codex.py +0 -295
  71. package/hooks/adapters/grok.py +0 -134
  72. package/hooks/adapters/kimi.py +0 -156
  73. package/hooks/adapters/zcode.py +0 -227
  74. package/hooks/block_dangerous_commands.py +0 -201
  75. package/hooks/compact_context.py +0 -224
  76. package/hooks/mid_workflow_recall.py +0 -47
  77. package/hooks/post_edit_guard.py +0 -215
  78. package/hooks/session_context.py +0 -741
  79. package/skills/ultra-change/SKILL.md +0 -165
  80. package/skills/ultra-change/references/change-contract.md +0 -199
  81. package/skills/ultra-change/references/execution-grant.md +0 -109
  82. package/skills/ultra-change/references/primary-transfer.md +0 -273
  83. package/skills/ultra-change/scripts/validate_primary_transfer.cjs +0 -1928
  84. package/skills/ultra-delegate/SKILL.md +0 -195
  85. package/skills/ultra-delegate/patches/zcode-acp-0.1.0-zcode-0.16.5.patch +0 -184
  86. package/skills/ultra-delegate/references/delegation-contract.md +0 -138
  87. package/skills/ultra-delegate/scripts/acp-conformance.cjs +0 -287
  88. package/skills/ultra-delegate/scripts/acp-driver.cjs +0 -762
  89. package/skills/ultra-delegate/scripts/agent-execution-driver.cjs +0 -320
  90. package/skills/ultra-delegate/scripts/cli-driver.cjs +0 -61
  91. package/skills/ultra-delegate/scripts/delegate-profiles/kimi-read-only.md +0 -15
  92. package/skills/ultra-delegate/scripts/delegate-profiles/kimi-write.md +0 -17
  93. package/skills/ultra-delegate/scripts/delegate-worker.cjs +0 -328
  94. package/skills/ultra-delegate/scripts/delegate.cjs +0 -690
  95. package/skills/ultra-delegate/scripts/delegate_wait.py +0 -86
  96. package/skills/ultra-delegate/scripts/host-profile.cjs +0 -267
  97. package/skills/ultra-deliver/SKILL.md +0 -194
  98. package/skills/ultra-deliver/references/baseline-reconciliation.md +0 -41
  99. package/skills/ultra-dev/SKILL.md +0 -265
  100. package/skills/ultra-dev/references/debugging.md +0 -22
  101. package/skills/ultra-domain-modeling/SKILL.md +0 -79
  102. package/skills/ultra-grilling/SKILL.md +0 -121
  103. package/skills/ultra-grilling/references/reframing.md +0 -76
  104. package/skills/ultra-init/SKILL.md +0 -143
  105. package/skills/ultra-init/references/brownfield-adoption.md +0 -32
  106. package/skills/ultra-init/scripts/init_project.cjs +0 -1031
  107. package/skills/ultra-plan/SKILL.md +0 -160
  108. package/skills/ultra-plan/references/task-evidence-v2.md +0 -300
  109. package/skills/ultra-plan/scripts/validate_task_evidence.cjs +0 -859
  110. package/skills/ultra-research/SKILL.md +0 -167
  111. package/skills/ultra-research/references/00-problem-validation.md +0 -36
  112. package/skills/ultra-research/references/01-opportunity-discovery.md +0 -33
  113. package/skills/ultra-research/references/02-market-assessment.md +0 -31
  114. package/skills/ultra-research/references/03-alternatives.md +0 -31
  115. package/skills/ultra-research/references/04-product-strategy.md +0 -41
  116. package/skills/ultra-research/references/05-assumptions-validation.md +0 -31
  117. package/skills/ultra-research/references/10-user-personas.md +0 -30
  118. package/skills/ultra-research/references/11-user-scenarios.md +0 -28
  119. package/skills/ultra-research/references/20-user-stories.md +0 -31
  120. package/skills/ultra-research/references/21-features-scope.md +0 -38
  121. package/skills/ultra-research/references/22-success-metrics.md +0 -33
  122. package/skills/ultra-research/references/30-architecture-context.md +0 -30
  123. package/skills/ultra-research/references/31-solution-strategy.md +0 -28
  124. package/skills/ultra-research/references/32-building-blocks.md +0 -28
  125. package/skills/ultra-research/references/40-deployment.md +0 -29
  126. package/skills/ultra-research/references/41-quality-risks.md +0 -28
  127. package/skills/ultra-research/references/99-synthesis.md +0 -59
  128. package/skills/ultra-research/references/north-star-v2.md +0 -168
  129. package/skills/ultra-research/references/wayfinding.md +0 -50
  130. package/skills/ultra-research/scripts/validate_north_star.cjs +0 -1174
  131. package/skills/ultra-review/SKILL.md +0 -275
  132. package/skills/ultra-review/references/adversarial-evaluation.md +0 -31
  133. package/skills/ultra-review/references/code.md +0 -17
  134. package/skills/ultra-review/references/comments.md +0 -12
  135. package/skills/ultra-review/references/design.md +0 -12
  136. package/skills/ultra-review/references/errors.md +0 -13
  137. package/skills/ultra-review/references/review-modes.md +0 -29
  138. package/skills/ultra-review/references/spec.md +0 -18
  139. package/skills/ultra-review/references/tests.md +0 -12
  140. package/skills/ultra-review/references/unified-schema.md +0 -284
  141. package/skills/ultra-review/references/worker-packet.md +0 -183
  142. package/skills/ultra-review/scripts/review_wait.py +0 -2943
  143. package/skills/ultra-status/SKILL.md +0 -209
  144. package/skills/ultra-status/references/route-contract.md +0 -100
  145. package/skills/ultra-tdd/SKILL.md +0 -91
  146. package/skills/ultra-tdd/references/templates/README.md +0 -20
  147. package/skills/ultra-tdd/references/templates/feature-flag-default-audit.sh +0 -79
  148. package/skills/ultra-tdd/references/templates/persistence-real.ts +0 -78
  149. package/skills/ultra-tdd/references/templates/testcontainer-postgres.py +0 -78
  150. package/skills/ultra-tdd/references/templates/testcontainer-postgres.ts +0 -73
  151. package/skills/ultra-tdd/references/templates/vertical-slice.ts +0 -66
  152. package/skills/ultra-tdd/references/test-execution.md +0 -23
  153. package/skills/ultra-test/SKILL.md +0 -244
  154. package/skills/ultra-test/references/export-syntax.md +0 -18
  155. package/skills/ultra-test/scripts/validate_review_transport.cjs +0 -549
  156. package/skills/ultra-test/scripts/worktree_digest.cjs +0 -576
  157. package/skills/ultra-think/SKILL.md +0 -56
  158. package/skills/ultra-think/references/autonomy-boundary.md +0 -48
  159. package/skills/ultra-think/references/interaction-boundary.md +0 -42
package/hooks/_common.py DELETED
@@ -1,1779 +0,0 @@
1
- """Small file-first helpers shared by Ultra hooks."""
2
-
3
- from __future__ import annotations
4
-
5
- import hashlib
6
- import json
7
- import os
8
- import re
9
- import secrets
10
- import stat
11
- import sys
12
- from pathlib import Path
13
- from typing import Any
14
-
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
-
68
- def read_payload() -> dict[str, Any]:
69
- try:
70
- value = json.loads(sys.stdin.read() or "{}")
71
- except json.JSONDecodeError:
72
- return {}
73
- return value if isinstance(value, dict) else {}
74
-
75
-
76
- def project_root(payload: dict[str, Any]) -> Path | None:
77
- candidate = payload.get("cwd") or payload.get("workspace_root") or os.getcwd()
78
- path = Path(str(candidate)).resolve()
79
- if path.is_file():
80
- path = path.parent
81
- for current in (path, *path.parents):
82
- if (current / ".ultra").is_dir():
83
- return current
84
- return None
85
-
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
-
451
- def markdown_section(text: str, heading: str) -> str:
452
- pattern = re.compile(
453
- rf"^##\s+{re.escape(heading)}\s*$\n(?P<body>.*?)(?=^##\s+|\Z)",
454
- re.MULTILINE | re.DOTALL | re.IGNORECASE,
455
- )
456
- match = pattern.search(text)
457
- return match.group("body").strip() if match else ""
458
-
459
-
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
- }
818
- try:
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
1326
-
1327
-
1328
- def current_task(root: Path) -> dict[str, Any] | None:
1329
- return current_task_selection(root)["task"]
1330
-
1331
-
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):
1343
- return None
1344
- relative = Path(*parts)
1345
- ultra_shorthands = {"changes", "contexts", "decisions", "evidence", "progress", "reviews", "specs"}
1346
- first = parts[0]
1347
- if first == ".ultra":
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()
1359
- try:
1360
- candidate.relative_to(root.resolve())
1361
- except ValueError:
1362
- return None
1363
- return candidate
1364
-
1365
-
1366
- def context_path(root: Path, task: dict[str, Any]) -> Path | None:
1367
- value = task.get("context_file")
1368
- if not isinstance(value, str) or not value:
1369
- return None
1370
- return project_file(root, value, ultra_default=True)
1371
-
1372
-
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
- })
1485
- return {
1486
- "acceptance": markdown_section(text, "Acceptance Criteria"),
1487
- "resume": markdown_section(text, "Resume Note"),
1488
- "diagnostics": diagnostics,
1489
- "context_available": context_available,
1490
- }
1491
-
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
-
1751
- def emit_context(event: str, text: str) -> None:
1752
- if not text.strip():
1753
- return
1754
- print(json.dumps({
1755
- "hookSpecificOutput": {
1756
- "hookEventName": event,
1757
- "additionalContext": text.strip(),
1758
- }
1759
- }))
1760
-
1761
-
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
- )