claude-dev-env 2.7.1 → 2.9.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 (127) hide show
  1. package/CLAUDE.md +7 -1
  2. package/_shared/CLAUDE.md +1 -0
  3. package/_shared/advisor/advisor-protocol.md +19 -9
  4. package/_shared/pr-loop/audit-contract.md +4 -4
  5. package/_shared/pr-loop/precatch-rubric.md +2 -2
  6. package/_shared/process-tree/CLAUDE.md +41 -0
  7. package/_shared/process-tree/scripts/config/process_tree_scripts_constants/__init__.py +1 -0
  8. package/_shared/process-tree/scripts/config/process_tree_scripts_constants/process_tree_kill_constants.py +27 -0
  9. package/_shared/process-tree/scripts/process_tree_kill.py +141 -0
  10. package/_shared/process-tree/scripts/pyproject.toml +16 -0
  11. package/_shared/process-tree/scripts/test_process_tree_kill.py +278 -0
  12. package/agents/clean-coder.md +9 -19
  13. package/agents/code-quality-agent.md +6 -5
  14. package/agents/deep-research.md +7 -24
  15. package/agents/docs-agent.md +1 -27
  16. package/agents/issue-tracker.md +1 -7
  17. package/agents/skill-writer-agent.md +1 -2
  18. package/agents/test_agent_frontmatter.py +335 -12
  19. package/docs/CODE_RULES.md +4 -2
  20. package/docs/references/CLAUDE.md +2 -2
  21. package/docs/references/advisor-tool.md +44 -6
  22. package/docs/references/team-advisor-skill.md +14 -8
  23. package/hooks/blocking/CLAUDE.md +2 -0
  24. package/hooks/blocking/fable_spawn_gate.py +187 -0
  25. package/hooks/blocking/piped_pytest_blocker.py +1223 -0
  26. package/hooks/blocking/plain_language_blocker.py +287 -15
  27. package/hooks/blocking/test_fable_spawn_gate.py +374 -0
  28. package/hooks/blocking/test_piped_pytest_blocker.py +587 -0
  29. package/hooks/blocking/test_plain_language_blocker.py +277 -2
  30. package/hooks/blocking/test_pre_tool_use_dispatcher.py +34 -2
  31. package/hooks/git-hooks/CLAUDE.md +2 -2
  32. package/hooks/git-hooks/git_hooks_constants/__init__.py +28 -0
  33. package/hooks/git-hooks/pre_push.py +343 -54
  34. package/hooks/git-hooks/test_pre_push.py +852 -6
  35. package/hooks/hooks.json +9 -19
  36. package/hooks/hooks_constants/CLAUDE.md +2 -0
  37. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +1 -0
  38. package/hooks/hooks_constants/code_rules_path_utils_constants.py +1 -0
  39. package/hooks/hooks_constants/fable_spawn_gate_constants.py +62 -0
  40. package/hooks/hooks_constants/piped_pytest_blocker_constants.py +360 -0
  41. package/hooks/hooks_constants/plain_language_blocker_constants.py +64 -1
  42. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +10 -0
  43. package/hooks/hooks_constants/shell_command_segments.py +1 -1
  44. package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +1 -0
  45. package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +27 -0
  46. package/hooks/hooks_constants/test_prose_metrics_parity.py +124 -0
  47. package/output-styles/CLAUDE.md +17 -0
  48. package/output-styles/caveman-agent.md +37 -0
  49. package/package.json +2 -1
  50. package/rules/CLAUDE.md +1 -0
  51. package/rules/ask-user-question-required.md +26 -0
  52. package/rules/claims-as-quotes.md +65 -0
  53. package/rules/code-standards.md +33 -7
  54. package/rules/eli11-replies.md +1 -1
  55. package/scripts/CLAUDE.md +3 -3
  56. package/scripts/_code_review_test_support.py +6 -0
  57. package/scripts/check.ps1 +18 -5
  58. package/scripts/claude_chain_runner.py +203 -31
  59. package/scripts/codec_forwarding_test_support.py +2 -0
  60. package/scripts/dev_env_scripts_constants/CLAUDE.md +4 -4
  61. package/scripts/dev_env_scripts_constants/claude_chain_constants.py +38 -0
  62. package/scripts/dev_env_scripts_constants/code_review_constants.py +403 -2
  63. package/scripts/dev_env_scripts_constants/grok_run_ledger_constants.py +50 -0
  64. package/scripts/dev_env_scripts_constants/grok_worker_constants.py +132 -24
  65. package/scripts/grok_headless_runner.py +16 -83
  66. package/scripts/grok_patch_artifacts.py +123 -0
  67. package/scripts/grok_run_ledger.py +318 -0
  68. package/scripts/spawn_grok_batch.py +591 -10
  69. package/scripts/test_claude_chain_runner.py +358 -0
  70. package/scripts/test_grok_headless_runner.py +18 -63
  71. package/scripts/test_grok_patch_artifacts.py +82 -0
  72. package/scripts/test_grok_run_ledger.py +116 -0
  73. package/scripts/test_invoke_code_review.py +298 -0
  74. package/scripts/test_resolve_worker_spawn.py +6 -0
  75. package/scripts/test_spawn_grok_batch.py +396 -0
  76. package/scripts/tests/CLAUDE.md +1 -0
  77. package/scripts/tests/test_grok_worker_constants.py +59 -0
  78. package/skills/CLAUDE.md +4 -2
  79. package/skills/_shared/CLAUDE.md +37 -4
  80. package/skills/_shared/advisor/CLAUDE.md +9 -0
  81. package/skills/_shared/advisor/advisor-protocol.md +5 -0
  82. package/skills/_shared/advisor/scripts/README.md +9 -0
  83. package/skills/_shared/end-of-run-gotcha-recommendations.md +156 -0
  84. package/skills/_shared/pr-loop/CLAUDE.md +18 -1
  85. package/skills/_shared/pr-loop/audit-contract.md +5 -0
  86. package/skills/_shared/pr-loop/audit-reply-template.md +5 -0
  87. package/skills/_shared/pr-loop/code-rules-gate.md +5 -0
  88. package/skills/_shared/pr-loop/fix-protocol.md +5 -0
  89. package/skills/_shared/pr-loop/gh-payloads.md +5 -0
  90. package/skills/_shared/pr-loop/post-audit-thread-contract.md +5 -0
  91. package/skills/_shared/pr-loop/precatch-rubric.md +5 -0
  92. package/skills/_shared/pr-loop/scripts/CLAUDE.md +8 -1
  93. package/skills/_shared/pr-loop/scripts/RUNTIME_SCRIPTS.md +29 -0
  94. package/skills/_shared/pr-loop/scripts/test_build_audit_prompt.py +46 -0
  95. package/skills/_shared/pr-loop/state-schema.md +5 -0
  96. package/skills/_shared/pr-loop/worker-spawn.md +5 -0
  97. package/skills/autoconverge/workflow/converge.contract.test.mjs +105 -2
  98. package/skills/autoconverge/workflow/converge.fix-recovery.test.mjs +34 -1
  99. package/skills/autoconverge/workflow/converge.mjs +66 -33
  100. package/skills/codex-review/scripts/codex_review_scripts_constants/codex_usage_probe_constants.py +0 -4
  101. package/skills/codex-review/scripts/codex_usage_probe.py +20 -33
  102. package/skills/codex-review/scripts/run_codex_review.py +16 -64
  103. package/skills/codex-review/scripts/test_codex_usage_probe.py +46 -41
  104. package/skills/codex-review/scripts/test_run_codex_review.py +1 -33
  105. package/skills/e-code-review/SKILL.md +15 -9
  106. package/skills/e-code-review/reference/fix.md +29 -7
  107. package/skills/e-code-review/reference/loop.md +230 -14
  108. package/skills/e-code-review/reference/low.md +33 -15
  109. package/skills/e-code-review/reference/medium.md +55 -21
  110. package/skills/e-code-review/reference/runner-selection.md +40 -0
  111. package/skills/e-code-review/reference/xhigh.md +30 -12
  112. package/skills/e-code-review/scripts/e_code_review_scripts_constants/__init__.py +1 -0
  113. package/skills/e-code-review/scripts/e_code_review_scripts_constants/grok_code_review_constants.py +55 -0
  114. package/skills/e-code-review/scripts/grok_code_review.py +221 -0
  115. package/skills/e-code-review/scripts/test_grok_code_review.py +212 -0
  116. package/skills/fresh-branch/CLAUDE.md +5 -5
  117. package/skills/fresh-branch/SKILL.md +14 -6
  118. package/skills/fresh-branch/scripts/create_fresh_branch.py +122 -39
  119. package/skills/fresh-branch/scripts/fresh_branch_scripts_constants/fresh_branch_cli_constants.py +10 -3
  120. package/skills/fresh-branch/scripts/test_create_fresh_branch.py +251 -0
  121. package/skills/grok-spawn/SKILL.md +10 -0
  122. package/skills/orchestrator/SKILL.md +9 -1
  123. package/skills/orchestrator-refresh/SKILL.md +5 -1
  124. package/skills/task-build/reference/tool-routing.md +3 -0
  125. package/skills/team-advisor/SKILL.md +23 -41
  126. package/system-prompts/software-engineer.xml +6 -3
  127. package/skills/test_markdown_link_integrity.py +0 -107
@@ -25,14 +25,24 @@ Import ``run_headless_worker`` for the outcome object::
25
25
 
26
26
  from __future__ import annotations
27
27
 
28
- import os
29
- import signal
30
28
  import subprocess
29
+ import sys
31
30
  import uuid
32
31
  from dataclasses import dataclass
33
32
  from pathlib import Path
34
33
 
35
- from dev_env_scripts_constants.grok_worker_constants import (
34
+ _shared_process_tree_scripts_directory = (
35
+ Path(__file__).resolve().parents[1] / "_shared" / "process-tree" / "scripts"
36
+ )
37
+ if str(_shared_process_tree_scripts_directory) not in sys.path:
38
+ sys.path.insert(0, str(_shared_process_tree_scripts_directory))
39
+
40
+ from process_tree_kill import ( # noqa: E402
41
+ should_start_new_session,
42
+ terminate_process_tree,
43
+ )
44
+
45
+ from dev_env_scripts_constants.grok_worker_constants import ( # noqa: E402
36
46
  AGENT_FLAG,
37
47
  ALL_AUTH_FAILURE_SIGNATURES,
38
48
  ALL_USAGE_LIMIT_SIGNATURES,
@@ -65,21 +75,14 @@ from dev_env_scripts_constants.grok_worker_constants import (
65
75
  OUTPUT_FORMAT_FLAG,
66
76
  OUTPUT_FORMAT_JSON,
67
77
  PROCESS_TREE_KILL_ATTEMPT_LIMIT,
68
- PROCESS_TREE_KILL_TIMEOUT_SECONDS,
69
78
  PROMPT_FILE_FLAG,
70
79
  TIMEOUT_RETURN_CODE,
71
80
  UTF8_DECODE_ERRORS,
72
81
  UTF8_ENCODING,
73
- WINDOWS_OS_NAME,
74
- WINDOWS_TASKKILL_COMMAND,
75
- WINDOWS_TASKKILL_FORCE_FLAG,
76
- WINDOWS_TASKKILL_PID_FLAG,
77
- WINDOWS_TASKKILL_TREE_FLAG,
78
82
  WORKER_SPEC_TIMEOUT_KEY,
79
83
  )
80
84
 
81
85
  runner_popen = subprocess.Popen
82
- runner_subprocess_run = subprocess.run
83
86
 
84
87
 
85
88
  class WorkerTimeoutOutOfBoundsError(ValueError):
@@ -204,76 +207,6 @@ def _resolve_returncode(process: subprocess.Popen[str]) -> int:
204
207
  return TIMEOUT_RETURN_CODE
205
208
 
206
209
 
207
- def _kill_windows_process_tree(process_identifier: int) -> None:
208
- """End a Windows process and every descendant it started, by process id.
209
-
210
- Swallows taskkill failures so the caller still falls back to
211
- ``Popen.kill()`` and a timed drain.
212
- """
213
- try:
214
- runner_subprocess_run(
215
- [
216
- WINDOWS_TASKKILL_COMMAND,
217
- WINDOWS_TASKKILL_TREE_FLAG,
218
- WINDOWS_TASKKILL_FORCE_FLAG,
219
- WINDOWS_TASKKILL_PID_FLAG,
220
- str(process_identifier),
221
- ],
222
- stdout=subprocess.DEVNULL,
223
- stderr=subprocess.DEVNULL,
224
- check=False,
225
- timeout=PROCESS_TREE_KILL_TIMEOUT_SECONDS,
226
- )
227
- except (subprocess.TimeoutExpired, OSError):
228
- return
229
-
230
-
231
- def _kill_posix_process_group(process_identifier: int) -> None:
232
- """End a POSIX process group so no grandchild keeps the capture pipe open.
233
-
234
- Reached only through the caller's ``os.name`` branch, so the process-group
235
- calls run on the platforms that define them.
236
-
237
- A process id that is already reaped raises ``OSError``; this swallows it and
238
- returns, so the caller falls back to ``Popen.kill()``.
239
- """
240
- try:
241
- process_group_identifier = os.getpgid(process_identifier) # type: ignore[attr-defined] # POSIX-only, reached via the caller's os.name branch
242
- os.killpg(process_group_identifier, signal.SIGKILL) # type: ignore[attr-defined] # POSIX-only, reached via the caller's os.name branch
243
- except OSError:
244
- return
245
-
246
-
247
- def _kill_process_tree_by_identifier(process_identifier: int) -> None:
248
- """Issue the platform's tree kill for one process id, with no liveness check."""
249
- if os.name == WINDOWS_OS_NAME:
250
- _kill_windows_process_tree(process_identifier)
251
- return
252
- _kill_posix_process_group(process_identifier)
253
-
254
-
255
- def _terminate_process_tree(process: subprocess.Popen[str]) -> None:
256
- """End the worker process and every descendant it spawned.
257
-
258
- ::
259
-
260
- tree kill (taskkill /T or killpg) ok: grandchildren die, pipes close
261
- Popen.kill() alone flag: grandchildren outlive the worker
262
-
263
- Falls back to ``Popen.kill()`` when the direct child survives the tree kill,
264
- so the caller never waits on a live process.
265
- """
266
- if process.poll() is not None:
267
- return
268
- _kill_process_tree_by_identifier(process.pid)
269
- if process.poll() is not None:
270
- return
271
- try:
272
- process.kill()
273
- except ProcessLookupError:
274
- return
275
-
276
-
277
210
  def require_timeout_within_bounds(timeout_seconds: int | None) -> None:
278
211
  """Refuse a timeout that is missing, below the floor, or above the ceiling.
279
212
 
@@ -332,7 +265,7 @@ def _kill_and_drain_within_attempt_limit(
332
265
 
333
266
  A tree kill that returns without taking leaves the drain waiting on a live
334
267
  pipe, so a timed-out drain is followed by another kill-and-drain round.
335
- ``_terminate_process_tree`` re-issues the kill only while the worker
268
+ ``terminate_process_tree`` re-issues the kill only while the worker
336
269
  process is still alive; once it has exited, the next round is a second
337
270
  drain window for the descendants still holding the pipe open.
338
271
 
@@ -344,7 +277,7 @@ def _kill_and_drain_within_attempt_limit(
344
277
  """
345
278
  attempts_made = 0
346
279
  while attempts_made < PROCESS_TREE_KILL_ATTEMPT_LIMIT:
347
- _terminate_process_tree(process)
280
+ terminate_process_tree(process)
348
281
  all_captured_streams = _drain_after_kill(process)
349
282
  if all_captured_streams is not None:
350
283
  return all_captured_streams
@@ -424,7 +357,7 @@ def _invoke_process(
424
357
  text=True,
425
358
  encoding=UTF8_ENCODING,
426
359
  errors=UTF8_DECODE_ERRORS,
427
- start_new_session=os.name != WINDOWS_OS_NAME,
360
+ start_new_session=should_start_new_session(),
428
361
  )
429
362
  except FileNotFoundError:
430
363
  return _missing_binary_outcome()
@@ -0,0 +1,123 @@
1
+ #!/usr/bin/env python3
2
+ """Extract patch artifacts and SHA-256 manifests from isolated worktrees.
3
+
4
+ ::
5
+
6
+ manifest = write_patch_manifest(
7
+ run_state_directory=run_dir,
8
+ task_id="O-04",
9
+ base_sha="abc",
10
+ worktree_path=worktree,
11
+ worker_report_text="ok",
12
+ )
13
+ ok: manifest carries content hash and changed paths
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import hashlib
19
+ import json
20
+ import subprocess
21
+ from pathlib import Path
22
+
23
+ from dev_env_scripts_constants.grok_run_ledger_constants import (
24
+ JSON_INDENT,
25
+ LEDGER_SCHEMA_VERSION,
26
+ PATCH_MANIFEST_FILENAME,
27
+ UTF8_ENCODING,
28
+ )
29
+
30
+
31
+ def compute_sha256_hex(content: bytes) -> str:
32
+ """Return the hex SHA-256 digest of raw bytes.
33
+
34
+ Args:
35
+ content: Bytes to hash.
36
+
37
+ Returns:
38
+ Lowercase hex digest string.
39
+ """
40
+ return hashlib.sha256(content).hexdigest()
41
+
42
+
43
+ def extract_worktree_diff(
44
+ *,
45
+ worktree_path: Path,
46
+ base_sha: str,
47
+ ) -> tuple[str, tuple[str, ...]]:
48
+ """Return unified diff text and changed paths against base_sha.
49
+
50
+ Args:
51
+ worktree_path: Isolated worker worktree.
52
+ base_sha: Base commit SHA the worker started from.
53
+
54
+ Returns:
55
+ ``(diff_text, changed_paths)``.
56
+ """
57
+ diff_text = subprocess.check_output(
58
+ ["git", "-C", str(worktree_path), "diff", base_sha],
59
+ text=True,
60
+ encoding=UTF8_ENCODING,
61
+ )
62
+ changed_paths_listing = subprocess.check_output(
63
+ ["git", "-C", str(worktree_path), "diff", "--name-only", base_sha],
64
+ text=True,
65
+ encoding=UTF8_ENCODING,
66
+ )
67
+ changed_paths = tuple(
68
+ each_line.strip()
69
+ for each_line in changed_paths_listing.splitlines()
70
+ if each_line.strip()
71
+ )
72
+ return diff_text, changed_paths
73
+
74
+
75
+ def write_patch_manifest(
76
+ *,
77
+ run_state_directory: Path,
78
+ task_id: str,
79
+ base_sha: str,
80
+ worktree_path: Path,
81
+ worker_report_text: str,
82
+ patch_filename: str | None = None,
83
+ ) -> dict[str, object]:
84
+ """Write a patch file and JSON manifest binding hashes and paths.
85
+
86
+ Args:
87
+ run_state_directory: Directory that holds ledger and patch artifacts.
88
+ task_id: Task the patch belongs to.
89
+ base_sha: Base commit SHA.
90
+ worktree_path: Worker worktree to diff.
91
+ worker_report_text: Worker report body bound into the manifest.
92
+ patch_filename: Optional override for the ``.patch`` filename.
93
+
94
+ Returns:
95
+ The manifest document written to disk.
96
+ """
97
+ run_state_directory = Path(run_state_directory)
98
+ run_state_directory.mkdir(parents=True, exist_ok=True)
99
+ diff_text, changed_paths = extract_worktree_diff(
100
+ worktree_path=Path(worktree_path),
101
+ base_sha=base_sha,
102
+ )
103
+ patch_name = patch_filename or f"{task_id}.patch"
104
+ patch_path = run_state_directory / patch_name
105
+ patch_bytes = diff_text.encode(UTF8_ENCODING)
106
+ patch_path.write_bytes(patch_bytes)
107
+ content_hash = compute_sha256_hex(patch_bytes)
108
+ report_hash = compute_sha256_hex(worker_report_text.encode(UTF8_ENCODING))
109
+ manifest = {
110
+ "schema_version": LEDGER_SCHEMA_VERSION,
111
+ "task_id": task_id,
112
+ "base_sha": base_sha,
113
+ "changed_paths": list(changed_paths),
114
+ "patch_path": str(patch_path),
115
+ "content_sha256": content_hash,
116
+ "worker_report_sha256": report_hash,
117
+ }
118
+ manifest_path = run_state_directory / PATCH_MANIFEST_FILENAME
119
+ manifest_path.write_text(
120
+ json.dumps(manifest, indent=JSON_INDENT) + "\n",
121
+ encoding=UTF8_ENCODING,
122
+ )
123
+ return manifest
@@ -0,0 +1,318 @@
1
+ #!/usr/bin/env python3
2
+ """Host-neutral file-backed ledger for Grok orchestration task state.
3
+
4
+ Records every delegated unit before dispatch, enforces one live owner and one
5
+ unique advisor session per in-progress task, blocks on unfinished dependencies,
6
+ and reopens tasks when the base snapshot drifts.
7
+
8
+ ::
9
+
10
+ ledger = GrokRunLedger(run_state_directory)
11
+ ledger.register_task(task_id="O-04", dependencies=())
12
+ ledger.mark_in_progress(
13
+ task_id="O-04",
14
+ owner_id="worker-1",
15
+ advisor_session_id="sess-1",
16
+ base_sha="abc",
17
+ )
18
+ ok: task status becomes in_progress with that owner and session
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ import os
25
+ import tempfile
26
+ from dataclasses import asdict, dataclass, field
27
+ from pathlib import Path
28
+
29
+ from dev_env_scripts_constants.grok_run_ledger_constants import (
30
+ ALL_LEGAL_TASK_STATUSES,
31
+ JSON_INDENT,
32
+ LEDGER_FILENAME,
33
+ LEDGER_SCHEMA_VERSION,
34
+ TASK_STATUS_ADVISOR_BLOCKED,
35
+ TASK_STATUS_COMPLETED,
36
+ TASK_STATUS_IN_PROGRESS,
37
+ TASK_STATUS_PENDING,
38
+ TASK_STATUS_PENDING_REVIEW,
39
+ TEMPORARY_LEDGER_PREFIX,
40
+ TEMPORARY_LEDGER_SUFFIX,
41
+ UTF8_ENCODING,
42
+ )
43
+
44
+
45
+ @dataclass
46
+ class LedgerTaskRecord:
47
+ """One delegated unit tracked in the run ledger."""
48
+
49
+ task_id: str
50
+ status: str = TASK_STATUS_PENDING
51
+ dependencies: tuple[str, ...] = ()
52
+ owner_id: str | None = None
53
+ advisor_session_id: str | None = None
54
+ advisor_verdict: str | None = None
55
+ base_sha: str | None = None
56
+ reviewed_head: str | None = None
57
+ changed_paths: tuple[str, ...] = ()
58
+ acceptance_mapping: dict[str, str] = field(default_factory=dict)
59
+ test_evidence: list[str] = field(default_factory=list)
60
+
61
+
62
+ class GrokRunLedger:
63
+ """Atomic file-backed ledger: register_task, get_task, can_dispatch, mark_in_progress, mark_completed, mark_advisor_blocked, invalidate_on_snapshot_drift, all_tasks."""
64
+
65
+ def __init__(self, run_state_directory: Path) -> None:
66
+ self.run_state_directory = Path(run_state_directory)
67
+ self.ledger_path = self.run_state_directory / LEDGER_FILENAME
68
+ self._task_by_id: dict[str, LedgerTaskRecord] = {}
69
+ self.run_state_directory.mkdir(parents=True, exist_ok=True)
70
+ if self.ledger_path.is_file():
71
+ self._load()
72
+
73
+ def _load(self) -> None:
74
+ payload = json.loads(self.ledger_path.read_text(encoding=UTF8_ENCODING))
75
+ for each_task in payload.get("tasks", []):
76
+ record = LedgerTaskRecord(
77
+ task_id=each_task["task_id"],
78
+ status=each_task["status"],
79
+ dependencies=tuple(each_task.get("dependencies") or ()),
80
+ owner_id=each_task.get("owner_id"),
81
+ advisor_session_id=each_task.get("advisor_session_id"),
82
+ advisor_verdict=each_task.get("advisor_verdict"),
83
+ base_sha=each_task.get("base_sha"),
84
+ reviewed_head=each_task.get("reviewed_head"),
85
+ changed_paths=tuple(each_task.get("changed_paths") or ()),
86
+ acceptance_mapping=dict(each_task.get("acceptance_mapping") or {}),
87
+ test_evidence=list(each_task.get("test_evidence") or []),
88
+ )
89
+ self._task_by_id[record.task_id] = record
90
+
91
+ def _atomic_write(self) -> None:
92
+ document = {
93
+ "schema_version": LEDGER_SCHEMA_VERSION,
94
+ "tasks": [
95
+ {
96
+ **asdict(each_record),
97
+ "dependencies": list(each_record.dependencies),
98
+ "changed_paths": list(each_record.changed_paths),
99
+ }
100
+ for each_record in sorted(
101
+ self._task_by_id.values(), key=lambda item: item.task_id
102
+ )
103
+ ],
104
+ }
105
+ encoded = json.dumps(document, indent=JSON_INDENT) + "\n"
106
+ file_descriptor, temporary_path = tempfile.mkstemp(
107
+ dir=self.run_state_directory,
108
+ prefix=TEMPORARY_LEDGER_PREFIX,
109
+ suffix=TEMPORARY_LEDGER_SUFFIX,
110
+ )
111
+ try:
112
+ with os.fdopen(file_descriptor, "w", encoding=UTF8_ENCODING) as handle:
113
+ handle.write(encoded)
114
+ handle.flush()
115
+ os.fsync(handle.fileno())
116
+ os.replace(temporary_path, self.ledger_path)
117
+ except (OSError, TypeError, ValueError):
118
+ if os.path.exists(temporary_path):
119
+ os.unlink(temporary_path)
120
+ raise
121
+
122
+ def register_task(
123
+ self,
124
+ *,
125
+ task_id: str,
126
+ all_dependencies: tuple[str, ...] = (),
127
+ ) -> LedgerTaskRecord:
128
+ """Record a delegated unit before dispatch.
129
+
130
+ Args:
131
+ task_id: Stable task identifier.
132
+ all_dependencies: Task ids that must complete before dispatch.
133
+
134
+ Returns:
135
+ The registered pending task record.
136
+
137
+ Raises:
138
+ ValueError: When the task id already exists.
139
+ """
140
+ if task_id in self._task_by_id:
141
+ raise ValueError(f"task already registered: {task_id}")
142
+ record = LedgerTaskRecord(
143
+ task_id=task_id,
144
+ status=TASK_STATUS_PENDING,
145
+ dependencies=all_dependencies,
146
+ )
147
+ self._task_by_id[task_id] = record
148
+ self._atomic_write()
149
+ return record
150
+
151
+ def get_task(self, task_id: str) -> LedgerTaskRecord:
152
+ """Return one task record.
153
+
154
+ Args:
155
+ task_id: Stable task identifier.
156
+
157
+ Returns:
158
+ The stored task record.
159
+
160
+ Raises:
161
+ KeyError: When the task id is unknown.
162
+ """
163
+ return self._task_by_id[task_id]
164
+
165
+ def can_dispatch(self, task_id: str) -> bool:
166
+ """Return whether every dependency has completed successfully.
167
+
168
+ Args:
169
+ task_id: Task to evaluate.
170
+
171
+ Returns:
172
+ True when every dependency is ``completed``.
173
+ """
174
+ record = self.get_task(task_id)
175
+ for each_dependency in record.dependencies:
176
+ dependency_record = self._task_by_id.get(each_dependency)
177
+ if dependency_record is None:
178
+ return False
179
+ if dependency_record.status != TASK_STATUS_COMPLETED:
180
+ return False
181
+ return True
182
+
183
+ def mark_in_progress(
184
+ self,
185
+ *,
186
+ task_id: str,
187
+ owner_id: str,
188
+ advisor_session_id: str,
189
+ base_sha: str,
190
+ ) -> LedgerTaskRecord:
191
+ """Assign one owner and unique advisor session and start the task.
192
+
193
+ Args:
194
+ task_id: Task to start.
195
+ owner_id: Live owner identity.
196
+ advisor_session_id: Unique advisor session for this worker.
197
+ base_sha: Snapshot SHA at dispatch.
198
+
199
+ Returns:
200
+ The updated task record.
201
+
202
+ Raises:
203
+ ValueError: When dispatch is blocked, another owner is live, the
204
+ advisor session is reused, or status is illegal.
205
+ """
206
+ if not self.can_dispatch(task_id):
207
+ raise ValueError(f"dependencies incomplete for {task_id}")
208
+ for each_record in self._task_by_id.values():
209
+ if (
210
+ each_record.status == TASK_STATUS_IN_PROGRESS
211
+ and each_record.owner_id == owner_id
212
+ and each_record.task_id != task_id
213
+ ):
214
+ raise ValueError(f"owner already live on {each_record.task_id}")
215
+ if (
216
+ each_record.advisor_session_id == advisor_session_id
217
+ and each_record.task_id != task_id
218
+ ):
219
+ raise ValueError(
220
+ f"advisor session already bound to {each_record.task_id}"
221
+ )
222
+ record = self.get_task(task_id)
223
+ if record.status not in {TASK_STATUS_PENDING, TASK_STATUS_PENDING_REVIEW}:
224
+ raise ValueError(f"cannot start task from status {record.status}")
225
+ record.status = TASK_STATUS_IN_PROGRESS
226
+ record.owner_id = owner_id
227
+ record.advisor_session_id = advisor_session_id
228
+ record.base_sha = base_sha
229
+ self._atomic_write()
230
+ return record
231
+
232
+ def mark_completed(
233
+ self,
234
+ *,
235
+ task_id: str,
236
+ reviewed_head: str,
237
+ all_changed_paths: tuple[str, ...],
238
+ advisor_verdict: str,
239
+ all_acceptance_mapping: dict[str, str],
240
+ all_test_evidence: list[str],
241
+ ) -> LedgerTaskRecord:
242
+ """Record successful terminal state for an in-progress task.
243
+
244
+ Args:
245
+ task_id: Task to complete.
246
+ reviewed_head: Final reviewed commit SHA.
247
+ all_changed_paths: Paths changed by the worker.
248
+ advisor_verdict: Opening advisor signal (for example ENDORSE).
249
+ all_acceptance_mapping: Acceptance criterion to evidence map.
250
+ all_test_evidence: Commands or artifacts proving tests.
251
+
252
+ Returns:
253
+ The completed task record.
254
+
255
+ Raises:
256
+ ValueError: When the task is not in progress.
257
+ """
258
+ record = self.get_task(task_id)
259
+ if record.status != TASK_STATUS_IN_PROGRESS:
260
+ raise ValueError(f"cannot complete task from status {record.status}")
261
+ record.status = TASK_STATUS_COMPLETED
262
+ record.reviewed_head = reviewed_head
263
+ record.changed_paths = all_changed_paths
264
+ record.advisor_verdict = advisor_verdict
265
+ record.acceptance_mapping = dict(all_acceptance_mapping)
266
+ record.test_evidence = list(all_test_evidence)
267
+ self._atomic_write()
268
+ return record
269
+
270
+ def mark_advisor_blocked(self, *, task_id: str, reason: str) -> LedgerTaskRecord:
271
+ """Stop a task because the advisor path failed closed.
272
+
273
+ Args:
274
+ task_id: Task to block.
275
+ reason: Short failure reason stored in test evidence.
276
+
277
+ Returns:
278
+ The blocked task record.
279
+ """
280
+ record = self.get_task(task_id)
281
+ record.status = TASK_STATUS_ADVISOR_BLOCKED
282
+ record.test_evidence = [reason]
283
+ self._atomic_write()
284
+ return record
285
+
286
+ def invalidate_on_snapshot_drift(
287
+ self,
288
+ *,
289
+ task_id: str,
290
+ current_sha: str,
291
+ ) -> LedgerTaskRecord:
292
+ """Move a task back to pending review when the base snapshot drifts.
293
+
294
+ Args:
295
+ task_id: Task to invalidate.
296
+ current_sha: Live SHA compared to the recorded base.
297
+
298
+ Returns:
299
+ The updated task record (unchanged when SHAs match).
300
+ """
301
+ record = self.get_task(task_id)
302
+ if record.base_sha is None or record.base_sha == current_sha:
303
+ return record
304
+ record.status = TASK_STATUS_PENDING_REVIEW
305
+ record.owner_id = None
306
+ self._atomic_write()
307
+ return record
308
+
309
+ def all_tasks(self) -> tuple[LedgerTaskRecord, ...]:
310
+ """Return every task record sorted by task id."""
311
+ return tuple(
312
+ sorted(self._task_by_id.values(), key=lambda item: item.task_id)
313
+ )
314
+
315
+
316
+ def is_legal_status(status: str) -> bool:
317
+ """Return whether a status token is in the legal set."""
318
+ return status in ALL_LEGAL_TASK_STATUSES