codex-autorunner 1.1.0__py3-none-any.whl → 1.2.1__py3-none-any.whl

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 (134) hide show
  1. codex_autorunner/agents/opencode/client.py +113 -4
  2. codex_autorunner/agents/opencode/supervisor.py +4 -0
  3. codex_autorunner/agents/registry.py +17 -7
  4. codex_autorunner/bootstrap.py +219 -1
  5. codex_autorunner/core/__init__.py +17 -1
  6. codex_autorunner/core/about_car.py +124 -11
  7. codex_autorunner/core/app_server_threads.py +6 -0
  8. codex_autorunner/core/config.py +238 -3
  9. codex_autorunner/core/context_awareness.py +39 -0
  10. codex_autorunner/core/docs.py +0 -122
  11. codex_autorunner/core/filebox.py +265 -0
  12. codex_autorunner/core/flows/controller.py +71 -1
  13. codex_autorunner/core/flows/reconciler.py +4 -1
  14. codex_autorunner/core/flows/runtime.py +22 -0
  15. codex_autorunner/core/flows/store.py +61 -9
  16. codex_autorunner/core/flows/transition.py +23 -16
  17. codex_autorunner/core/flows/ux_helpers.py +18 -3
  18. codex_autorunner/core/flows/worker_process.py +32 -6
  19. codex_autorunner/core/hub.py +198 -41
  20. codex_autorunner/core/lifecycle_events.py +253 -0
  21. codex_autorunner/core/path_utils.py +2 -1
  22. codex_autorunner/core/pma_audit.py +224 -0
  23. codex_autorunner/core/pma_context.py +683 -0
  24. codex_autorunner/core/pma_dispatch_interceptor.py +284 -0
  25. codex_autorunner/core/pma_lifecycle.py +527 -0
  26. codex_autorunner/core/pma_queue.py +367 -0
  27. codex_autorunner/core/pma_safety.py +221 -0
  28. codex_autorunner/core/pma_state.py +115 -0
  29. codex_autorunner/core/ports/agent_backend.py +2 -5
  30. codex_autorunner/core/ports/run_event.py +1 -4
  31. codex_autorunner/core/prompt.py +0 -80
  32. codex_autorunner/core/prompts.py +56 -172
  33. codex_autorunner/core/redaction.py +0 -4
  34. codex_autorunner/core/review_context.py +11 -9
  35. codex_autorunner/core/runner_controller.py +35 -33
  36. codex_autorunner/core/runner_state.py +147 -0
  37. codex_autorunner/core/runtime.py +829 -0
  38. codex_autorunner/core/sqlite_utils.py +13 -4
  39. codex_autorunner/core/state.py +7 -10
  40. codex_autorunner/core/state_roots.py +5 -0
  41. codex_autorunner/core/templates/__init__.py +39 -0
  42. codex_autorunner/core/templates/git_mirror.py +234 -0
  43. codex_autorunner/core/templates/provenance.py +56 -0
  44. codex_autorunner/core/templates/scan_cache.py +120 -0
  45. codex_autorunner/core/ticket_linter_cli.py +17 -0
  46. codex_autorunner/core/ticket_manager_cli.py +154 -92
  47. codex_autorunner/core/time_utils.py +11 -0
  48. codex_autorunner/core/types.py +18 -0
  49. codex_autorunner/core/utils.py +34 -6
  50. codex_autorunner/flows/review/service.py +23 -25
  51. codex_autorunner/flows/ticket_flow/definition.py +43 -1
  52. codex_autorunner/integrations/agents/__init__.py +2 -0
  53. codex_autorunner/integrations/agents/backend_orchestrator.py +18 -0
  54. codex_autorunner/integrations/agents/codex_backend.py +19 -8
  55. codex_autorunner/integrations/agents/runner.py +3 -8
  56. codex_autorunner/integrations/agents/wiring.py +8 -0
  57. codex_autorunner/integrations/telegram/adapter.py +1 -1
  58. codex_autorunner/integrations/telegram/config.py +1 -1
  59. codex_autorunner/integrations/telegram/doctor.py +228 -6
  60. codex_autorunner/integrations/telegram/handlers/commands/execution.py +236 -74
  61. codex_autorunner/integrations/telegram/handlers/commands/files.py +314 -75
  62. codex_autorunner/integrations/telegram/handlers/commands/flows.py +346 -58
  63. codex_autorunner/integrations/telegram/handlers/commands/workspace.py +498 -37
  64. codex_autorunner/integrations/telegram/handlers/commands_runtime.py +202 -45
  65. codex_autorunner/integrations/telegram/handlers/commands_spec.py +18 -7
  66. codex_autorunner/integrations/telegram/handlers/messages.py +34 -3
  67. codex_autorunner/integrations/telegram/helpers.py +1 -3
  68. codex_autorunner/integrations/telegram/runtime.py +9 -4
  69. codex_autorunner/integrations/telegram/service.py +30 -0
  70. codex_autorunner/integrations/telegram/state.py +38 -0
  71. codex_autorunner/integrations/telegram/ticket_flow_bridge.py +10 -4
  72. codex_autorunner/integrations/telegram/transport.py +10 -3
  73. codex_autorunner/integrations/templates/__init__.py +27 -0
  74. codex_autorunner/integrations/templates/scan_agent.py +312 -0
  75. codex_autorunner/server.py +2 -2
  76. codex_autorunner/static/agentControls.js +21 -5
  77. codex_autorunner/static/app.js +115 -11
  78. codex_autorunner/static/archive.js +274 -81
  79. codex_autorunner/static/archiveApi.js +21 -0
  80. codex_autorunner/static/chatUploads.js +137 -0
  81. codex_autorunner/static/constants.js +1 -1
  82. codex_autorunner/static/docChatCore.js +185 -13
  83. codex_autorunner/static/fileChat.js +68 -40
  84. codex_autorunner/static/fileboxUi.js +159 -0
  85. codex_autorunner/static/hub.js +46 -81
  86. codex_autorunner/static/index.html +303 -24
  87. codex_autorunner/static/messages.js +82 -4
  88. codex_autorunner/static/notifications.js +288 -0
  89. codex_autorunner/static/pma.js +1167 -0
  90. codex_autorunner/static/settings.js +3 -0
  91. codex_autorunner/static/streamUtils.js +57 -0
  92. codex_autorunner/static/styles.css +9141 -6742
  93. codex_autorunner/static/templateReposSettings.js +225 -0
  94. codex_autorunner/static/terminalManager.js +22 -3
  95. codex_autorunner/static/ticketChatActions.js +165 -3
  96. codex_autorunner/static/ticketChatStream.js +17 -119
  97. codex_autorunner/static/ticketEditor.js +41 -13
  98. codex_autorunner/static/ticketTemplates.js +798 -0
  99. codex_autorunner/static/tickets.js +69 -19
  100. codex_autorunner/static/turnEvents.js +27 -0
  101. codex_autorunner/static/turnResume.js +33 -0
  102. codex_autorunner/static/utils.js +28 -0
  103. codex_autorunner/static/workspace.js +258 -44
  104. codex_autorunner/static/workspaceFileBrowser.js +6 -4
  105. codex_autorunner/surfaces/cli/cli.py +1465 -155
  106. codex_autorunner/surfaces/cli/pma_cli.py +817 -0
  107. codex_autorunner/surfaces/web/app.py +253 -49
  108. codex_autorunner/surfaces/web/routes/__init__.py +4 -0
  109. codex_autorunner/surfaces/web/routes/analytics.py +29 -22
  110. codex_autorunner/surfaces/web/routes/archive.py +197 -0
  111. codex_autorunner/surfaces/web/routes/file_chat.py +297 -36
  112. codex_autorunner/surfaces/web/routes/filebox.py +227 -0
  113. codex_autorunner/surfaces/web/routes/flows.py +219 -29
  114. codex_autorunner/surfaces/web/routes/messages.py +70 -39
  115. codex_autorunner/surfaces/web/routes/pma.py +1652 -0
  116. codex_autorunner/surfaces/web/routes/repos.py +1 -1
  117. codex_autorunner/surfaces/web/routes/shared.py +0 -3
  118. codex_autorunner/surfaces/web/routes/templates.py +634 -0
  119. codex_autorunner/surfaces/web/runner_manager.py +2 -2
  120. codex_autorunner/surfaces/web/schemas.py +81 -18
  121. codex_autorunner/tickets/agent_pool.py +27 -0
  122. codex_autorunner/tickets/files.py +33 -16
  123. codex_autorunner/tickets/lint.py +50 -0
  124. codex_autorunner/tickets/models.py +3 -0
  125. codex_autorunner/tickets/outbox.py +41 -5
  126. codex_autorunner/tickets/runner.py +350 -69
  127. {codex_autorunner-1.1.0.dist-info → codex_autorunner-1.2.1.dist-info}/METADATA +15 -19
  128. {codex_autorunner-1.1.0.dist-info → codex_autorunner-1.2.1.dist-info}/RECORD +132 -101
  129. codex_autorunner/core/adapter_utils.py +0 -21
  130. codex_autorunner/core/engine.py +0 -3302
  131. {codex_autorunner-1.1.0.dist-info → codex_autorunner-1.2.1.dist-info}/WHEEL +0 -0
  132. {codex_autorunner-1.1.0.dist-info → codex_autorunner-1.2.1.dist-info}/entry_points.txt +0 -0
  133. {codex_autorunner-1.1.0.dist-info → codex_autorunner-1.2.1.dist-info}/licenses/LICENSE +0 -0
  134. {codex_autorunner-1.1.0.dist-info → codex_autorunner-1.2.1.dist-info}/top_level.txt +0 -0
@@ -13,19 +13,28 @@ SQLITE_PRAGMAS = (
13
13
  "PRAGMA temp_store=MEMORY;",
14
14
  )
15
15
 
16
+ SQLITE_PRAGMAS_DURABLE = (
17
+ "PRAGMA journal_mode=WAL;",
18
+ "PRAGMA synchronous=FULL;",
19
+ "PRAGMA foreign_keys=ON;",
20
+ "PRAGMA busy_timeout=5000;",
21
+ "PRAGMA temp_store=MEMORY;",
22
+ )
23
+
16
24
 
17
- def connect_sqlite(path: Path) -> sqlite3.Connection:
25
+ def connect_sqlite(path: Path, durable: bool = False) -> sqlite3.Connection:
18
26
  path.parent.mkdir(parents=True, exist_ok=True)
19
27
  conn = sqlite3.connect(path)
20
28
  conn.row_factory = sqlite3.Row
21
- for pragma in SQLITE_PRAGMAS:
29
+ pragmas = SQLITE_PRAGMAS_DURABLE if durable else SQLITE_PRAGMAS
30
+ for pragma in pragmas:
22
31
  conn.execute(pragma)
23
32
  return conn
24
33
 
25
34
 
26
35
  @contextmanager
27
- def open_sqlite(path: Path) -> Iterator[sqlite3.Connection]:
28
- conn = connect_sqlite(path)
36
+ def open_sqlite(path: Path, durable: bool = False) -> Iterator[sqlite3.Connection]:
37
+ conn = connect_sqlite(path, durable=durable)
29
38
  try:
30
39
  yield conn
31
40
  finally:
@@ -1,12 +1,12 @@
1
1
  import dataclasses
2
2
  import json
3
3
  from contextlib import contextmanager
4
- from datetime import datetime, timezone
5
4
  from pathlib import Path
6
5
  from typing import Any, Iterator, Optional
7
6
 
8
7
  from .locks import file_lock
9
8
  from .sqlite_utils import open_sqlite
9
+ from .time_utils import now_iso
10
10
 
11
11
 
12
12
  @dataclasses.dataclass
@@ -93,10 +93,6 @@ class SessionRecord:
93
93
  }
94
94
 
95
95
 
96
- def now_iso() -> str:
97
- return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
98
-
99
-
100
96
  def _ensure_state_schema(conn) -> None:
101
97
  with conn:
102
98
  conn.execute(
@@ -197,8 +193,8 @@ def _apply_overrides(state: RunnerState, raw: Optional[str]) -> None:
197
193
  state.runner_stop_after_runs = runner_stop_after_runs
198
194
 
199
195
 
200
- def load_state(state_path: Path) -> RunnerState:
201
- with open_sqlite(state_path) as conn:
196
+ def load_state(state_path: Path, durable: bool = False) -> RunnerState:
197
+ with open_sqlite(state_path, durable=durable) as conn:
202
198
  _ensure_state_schema(conn)
203
199
  row = conn.execute(
204
200
  """
@@ -253,9 +249,9 @@ def load_state(state_path: Path) -> RunnerState:
253
249
  return state
254
250
 
255
251
 
256
- def save_state(state_path: Path, state: RunnerState) -> None:
252
+ def save_state(state_path: Path, state: RunnerState, durable: bool = False) -> None:
257
253
  overrides_json = _encode_overrides(state)
258
- with open_sqlite(state_path) as conn:
254
+ with open_sqlite(state_path, durable=durable) as conn:
259
255
  _ensure_state_schema(conn)
260
256
  updated_at = now_iso()
261
257
  with conn:
@@ -343,9 +339,10 @@ def persist_session_registry(
343
339
  state_path: Path,
344
340
  sessions: dict[str, SessionRecord],
345
341
  repo_to_session: dict[str, str],
342
+ durable: bool = False,
346
343
  ) -> None:
347
344
  with state_lock(state_path):
348
- with open_sqlite(state_path) as conn:
345
+ with open_sqlite(state_path, durable=durable) as conn:
349
346
  _ensure_state_schema(conn)
350
347
  with conn:
351
348
  conn.execute("DELETE FROM sessions")
@@ -55,3 +55,8 @@ def resolve_global_state_root(
55
55
  def resolve_repo_state_root(repo_root: Path) -> Path:
56
56
  """Return the repo-local state root (.codex-autorunner)."""
57
57
  return repo_root / ".codex-autorunner"
58
+
59
+
60
+ def resolve_hub_templates_root(hub_root: Path) -> Path:
61
+ """Return the hub-scoped templates root."""
62
+ return hub_root / ".codex-autorunner" / "templates"
@@ -0,0 +1,39 @@
1
+ from .git_mirror import (
2
+ FetchedTemplate,
3
+ NetworkUnavailableError,
4
+ RefNotFoundError,
5
+ RepoNotConfiguredError,
6
+ TemplateNotFoundError,
7
+ TemplateRef,
8
+ ensure_git_mirror,
9
+ fetch_template,
10
+ parse_template_ref,
11
+ )
12
+ from .provenance import inject_provenance
13
+ from .scan_cache import (
14
+ TemplateScanRecord,
15
+ get_scan_record,
16
+ scan_lock,
17
+ scan_lock_path,
18
+ scan_record_path,
19
+ write_scan_record,
20
+ )
21
+
22
+ __all__ = [
23
+ "FetchedTemplate",
24
+ "NetworkUnavailableError",
25
+ "RefNotFoundError",
26
+ "RepoNotConfiguredError",
27
+ "TemplateNotFoundError",
28
+ "TemplateRef",
29
+ "ensure_git_mirror",
30
+ "fetch_template",
31
+ "parse_template_ref",
32
+ "inject_provenance",
33
+ "TemplateScanRecord",
34
+ "get_scan_record",
35
+ "scan_lock",
36
+ "scan_lock_path",
37
+ "scan_record_path",
38
+ "write_scan_record",
39
+ ]
@@ -0,0 +1,234 @@
1
+ from __future__ import annotations
2
+
3
+ import dataclasses
4
+ from pathlib import Path
5
+ from typing import Optional
6
+
7
+ from ..config import TemplateRepoConfig
8
+ from ..git_utils import GitError, run_git
9
+ from ..state_roots import resolve_hub_templates_root
10
+
11
+
12
+ class RepoNotConfiguredError(Exception):
13
+ def __init__(self, repo_id: str, *, detail: Optional[str] = None) -> None:
14
+ message = f"Template repo not configured: {repo_id}"
15
+ if detail:
16
+ message = f"{message} ({detail})"
17
+ super().__init__(message)
18
+ self.repo_id = repo_id
19
+ self.detail = detail
20
+
21
+
22
+ class TemplateNotFoundError(Exception):
23
+ def __init__(self, repo_id: str, path: str, ref: str) -> None:
24
+ super().__init__(f"Template not found: repo_id={repo_id} path={path} ref={ref}")
25
+ self.repo_id = repo_id
26
+ self.path = path
27
+ self.ref = ref
28
+
29
+
30
+ class RefNotFoundError(Exception):
31
+ def __init__(self, repo_id: str, ref: str) -> None:
32
+ super().__init__(f"Ref not found: repo_id={repo_id} ref={ref}")
33
+ self.repo_id = repo_id
34
+ self.ref = ref
35
+
36
+
37
+ class NetworkUnavailableError(Exception):
38
+ def __init__(
39
+ self,
40
+ repo_id: str,
41
+ ref: str,
42
+ path: str,
43
+ *,
44
+ detail: Optional[str] = None,
45
+ ) -> None:
46
+ message = (
47
+ "Template fetch failed and cache is unavailable: "
48
+ f"repo_id={repo_id} ref={ref} path={path}"
49
+ )
50
+ if detail:
51
+ message = f"{message} ({detail})"
52
+ super().__init__(message)
53
+ self.repo_id = repo_id
54
+ self.ref = ref
55
+ self.path = path
56
+ self.detail = detail
57
+
58
+
59
+ @dataclasses.dataclass(frozen=True)
60
+ class TemplateRef:
61
+ repo_id: str
62
+ path: str
63
+ ref: Optional[str]
64
+
65
+
66
+ @dataclasses.dataclass(frozen=True)
67
+ class FetchedTemplate:
68
+ repo_id: str
69
+ url: str
70
+ trusted: bool
71
+ path: str
72
+ ref: str
73
+ commit_sha: str
74
+ blob_sha: str
75
+ content: str
76
+
77
+
78
+ def parse_template_ref(raw: str) -> TemplateRef:
79
+ """Parse canonical template reference strings: REPO_ID:PATH[@REF]."""
80
+ if ":" not in raw:
81
+ raise ValueError("template ref must be formatted as REPO_ID:PATH[@REF]")
82
+ repo_id, remainder = raw.split(":", 1)
83
+ if not repo_id:
84
+ raise ValueError("template ref missing repo_id")
85
+ if not remainder:
86
+ raise ValueError("template ref missing path")
87
+
88
+ path: str
89
+ ref: Optional[str]
90
+ if "@" in remainder:
91
+ path, ref = remainder.rsplit("@", 1)
92
+ if not ref:
93
+ raise ValueError("template ref missing ref after '@'")
94
+ else:
95
+ path, ref = remainder, None
96
+
97
+ if not path:
98
+ raise ValueError("template ref missing path")
99
+
100
+ return TemplateRef(repo_id=repo_id, path=path, ref=ref)
101
+
102
+
103
+ def ensure_git_mirror(repo: TemplateRepoConfig, hub_root: Path) -> Path:
104
+ templates_root = resolve_hub_templates_root(hub_root)
105
+ mirror_path = templates_root / "git" / f"{repo.id}.git"
106
+ if mirror_path.exists():
107
+ _ensure_origin_remote(mirror_path, repo.url)
108
+ return mirror_path
109
+
110
+ mirror_path.parent.mkdir(parents=True, exist_ok=True)
111
+ run_git(["init", "--bare", str(mirror_path)], mirror_path.parent, check=True)
112
+ _ensure_origin_remote(mirror_path, repo.url)
113
+ return mirror_path
114
+
115
+
116
+ def _ensure_origin_remote(mirror_path: Path, url: str) -> None:
117
+ try:
118
+ proc = run_git(["remote", "get-url", "origin"], mirror_path, check=False)
119
+ except GitError:
120
+ proc = None
121
+ if proc and proc.returncode == 0:
122
+ current = (proc.stdout or "").strip()
123
+ if current and current != url:
124
+ run_git(["remote", "set-url", "origin", url], mirror_path, check=True)
125
+ else:
126
+ run_git(["remote", "add", "origin", url], mirror_path, check=True)
127
+ _configure_mirror_remote(mirror_path)
128
+
129
+
130
+ def _configure_mirror_remote(mirror_path: Path) -> None:
131
+ run_git(
132
+ ["config", "remote.origin.fetch", "+refs/*:refs/*"],
133
+ mirror_path,
134
+ check=True,
135
+ )
136
+ run_git(["config", "remote.origin.mirror", "true"], mirror_path, check=True)
137
+
138
+
139
+ def fetch_template(
140
+ *,
141
+ repo: TemplateRepoConfig,
142
+ hub_root: Path,
143
+ template_ref: str,
144
+ fetch_timeout_seconds: int = 30,
145
+ ) -> FetchedTemplate:
146
+ parsed = parse_template_ref(template_ref)
147
+ if parsed.repo_id != repo.id:
148
+ raise RepoNotConfiguredError(
149
+ parsed.repo_id,
150
+ detail=f"expected repo_id {repo.id}",
151
+ )
152
+
153
+ ref = parsed.ref or repo.default_ref
154
+ mirror_path = ensure_git_mirror(repo, hub_root)
155
+
156
+ fetch_error: Optional[str] = None
157
+ try:
158
+ run_git(
159
+ ["fetch", "--prune", "origin"],
160
+ mirror_path,
161
+ timeout_seconds=fetch_timeout_seconds,
162
+ check=True,
163
+ )
164
+ except GitError as exc:
165
+ fetch_error = str(exc)
166
+
167
+ try:
168
+ commit_sha = _resolve_commit(mirror_path, repo.id, ref)
169
+ blob_sha = _resolve_blob(mirror_path, commit_sha, parsed.path, repo.id, ref)
170
+ content = _read_blob(mirror_path, blob_sha)
171
+ except (RefNotFoundError, TemplateNotFoundError) as exc:
172
+ if fetch_error:
173
+ raise NetworkUnavailableError(
174
+ repo.id,
175
+ ref,
176
+ parsed.path,
177
+ detail=fetch_error,
178
+ ) from exc
179
+ raise
180
+
181
+ return FetchedTemplate(
182
+ repo_id=repo.id,
183
+ url=repo.url,
184
+ trusted=repo.trusted,
185
+ path=parsed.path,
186
+ ref=ref,
187
+ commit_sha=commit_sha,
188
+ blob_sha=blob_sha,
189
+ content=content,
190
+ )
191
+
192
+
193
+ def _resolve_commit(mirror_path: Path, repo_id: str, ref: str) -> str:
194
+ try:
195
+ proc = run_git(
196
+ ["rev-parse", f"{ref}^{{commit}}"],
197
+ mirror_path,
198
+ check=True,
199
+ )
200
+ except GitError as exc:
201
+ raise RefNotFoundError(repo_id, ref) from exc
202
+ return (proc.stdout or "").strip()
203
+
204
+
205
+ def _resolve_blob(
206
+ mirror_path: Path,
207
+ commit_sha: str,
208
+ path: str,
209
+ repo_id: str,
210
+ ref: str,
211
+ ) -> str:
212
+ try:
213
+ proc = run_git(
214
+ ["ls-tree", commit_sha, "--", path],
215
+ mirror_path,
216
+ check=True,
217
+ )
218
+ except GitError as exc:
219
+ raise TemplateNotFoundError(repo_id, path, ref) from exc
220
+
221
+ raw = (proc.stdout or "").strip()
222
+ if not raw:
223
+ raise TemplateNotFoundError(repo_id, path, ref)
224
+
225
+ # Format: "<mode> <type> <sha>\t<path>"
226
+ parts = raw.split()
227
+ if len(parts) < 3:
228
+ raise TemplateNotFoundError(repo_id, path, ref)
229
+ return parts[2]
230
+
231
+
232
+ def _read_blob(mirror_path: Path, blob_sha: str) -> str:
233
+ proc = run_git(["cat-file", "-p", blob_sha], mirror_path, check=True)
234
+ return proc.stdout or ""
@@ -0,0 +1,56 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Optional
4
+
5
+ import yaml
6
+
7
+ from .git_mirror import FetchedTemplate
8
+ from .scan_cache import TemplateScanRecord
9
+
10
+
11
+ def inject_provenance(
12
+ content: str,
13
+ fetched: FetchedTemplate,
14
+ scan_record: Optional[TemplateScanRecord],
15
+ ) -> str:
16
+ """Inject deterministic provenance keys into ticket frontmatter.
17
+
18
+ Args:
19
+ content: Ticket markdown content
20
+ fetched: The fetched template metadata
21
+ scan_record: Optional scan record for the template
22
+
23
+ Returns:
24
+ Updated markdown content with provenance keys in frontmatter
25
+
26
+ Notes:
27
+ - Does not embed scan evidence
28
+ - Does not add timestamps (avoids nondeterminism)
29
+ - Creates frontmatter if missing
30
+ - Merges with existing frontmatter without clobbering unrelated keys
31
+ """
32
+ from ...tickets.frontmatter import split_markdown_frontmatter
33
+
34
+ fm_yaml, body = split_markdown_frontmatter(content)
35
+
36
+ data = {}
37
+ if fm_yaml is not None:
38
+ try:
39
+ parsed = yaml.safe_load(fm_yaml)
40
+ if isinstance(parsed, dict):
41
+ data = parsed
42
+ except yaml.YAMLError:
43
+ pass
44
+
45
+ data["template"] = f"{fetched.repo_id}:{fetched.path}@{fetched.ref}"
46
+ data["template_commit"] = fetched.commit_sha
47
+ data["template_blob"] = fetched.blob_sha
48
+ data["template_trusted"] = fetched.trusted
49
+
50
+ if scan_record is not None:
51
+ data["template_scan"] = scan_record.decision
52
+ else:
53
+ data["template_scan"] = "skipped"
54
+
55
+ rendered = yaml.safe_dump(data, sort_keys=False).rstrip()
56
+ return f"---\n{rendered}\n---\n{body}"
@@ -0,0 +1,120 @@
1
+ from __future__ import annotations
2
+
3
+ import dataclasses
4
+ import json
5
+ from contextlib import contextmanager
6
+ from pathlib import Path
7
+ from typing import Any, Iterator, Optional
8
+
9
+ from ..locks import FileLock
10
+ from ..state_roots import resolve_hub_templates_root
11
+ from ..utils import atomic_write
12
+
13
+
14
+ @dataclasses.dataclass(frozen=True)
15
+ class TemplateScanRecord:
16
+ blob_sha: str
17
+ repo_id: str
18
+ path: str
19
+ ref: str
20
+ commit_sha: str
21
+ trusted: bool
22
+ decision: str
23
+ severity: str
24
+ reason: str
25
+ evidence: Optional[list[str]]
26
+ scanned_at: str
27
+ scanner: Optional[dict[str, str]]
28
+
29
+ def to_dict(self, *, include_evidence: bool = True) -> dict[str, Any]:
30
+ payload: dict[str, Any] = {
31
+ "blob_sha": self.blob_sha,
32
+ "repo_id": self.repo_id,
33
+ "path": self.path,
34
+ "ref": self.ref,
35
+ "commit_sha": self.commit_sha,
36
+ "trusted": self.trusted,
37
+ "decision": self.decision,
38
+ "severity": self.severity,
39
+ "reason": self.reason,
40
+ "scanned_at": self.scanned_at,
41
+ }
42
+ if include_evidence and self.evidence:
43
+ payload["evidence"] = list(self.evidence)
44
+ if self.scanner:
45
+ payload["scanner"] = dict(self.scanner)
46
+ return payload
47
+
48
+ @staticmethod
49
+ def from_dict(payload: dict[str, Any]) -> "TemplateScanRecord":
50
+ return TemplateScanRecord(
51
+ blob_sha=str(payload.get("blob_sha", "")),
52
+ repo_id=str(payload.get("repo_id", "")),
53
+ path=str(payload.get("path", "")),
54
+ ref=str(payload.get("ref", "")),
55
+ commit_sha=str(payload.get("commit_sha", "")),
56
+ trusted=bool(payload.get("trusted", False)),
57
+ decision=str(payload.get("decision", "")),
58
+ severity=str(payload.get("severity", "")),
59
+ reason=str(payload.get("reason", "")),
60
+ evidence=_coerce_evidence(payload.get("evidence")),
61
+ scanned_at=str(payload.get("scanned_at", "")),
62
+ scanner=_coerce_scanner(payload.get("scanner")),
63
+ )
64
+
65
+
66
+ def _coerce_evidence(value: Any) -> Optional[list[str]]:
67
+ if not value:
68
+ return None
69
+ if isinstance(value, list):
70
+ return [str(item) for item in value]
71
+ return [str(value)]
72
+
73
+
74
+ def _coerce_scanner(value: Any) -> Optional[dict[str, str]]:
75
+ if not value or not isinstance(value, dict):
76
+ return None
77
+ return {str(key): str(val) for key, val in value.items()}
78
+
79
+
80
+ def _scan_root(hub_root: Path) -> Path:
81
+ return resolve_hub_templates_root(hub_root) / "scans"
82
+
83
+
84
+ def scan_record_path(hub_root: Path, blob_sha: str) -> Path:
85
+ return _scan_root(hub_root) / f"{blob_sha}.json"
86
+
87
+
88
+ def scan_lock_path(hub_root: Path, blob_sha: str) -> Path:
89
+ return _scan_root(hub_root) / "locks" / f"{blob_sha}.lock"
90
+
91
+
92
+ def get_scan_record(hub_root: Path, blob_sha: str) -> Optional[TemplateScanRecord]:
93
+ path = scan_record_path(hub_root, blob_sha)
94
+ if not path.exists():
95
+ return None
96
+ with path.open("r", encoding="utf-8") as handle:
97
+ payload = json.load(handle)
98
+ if not isinstance(payload, dict):
99
+ return None
100
+ return TemplateScanRecord.from_dict(payload)
101
+
102
+
103
+ def write_scan_record(record: TemplateScanRecord, hub_root: Path) -> None:
104
+ path = scan_record_path(hub_root, record.blob_sha)
105
+ payload = record.to_dict(include_evidence=False)
106
+ if record.evidence:
107
+ payload["evidence_redacted"] = True
108
+ atomic_write(path, json.dumps(payload, indent=2) + "\n")
109
+
110
+
111
+ @contextmanager
112
+ def scan_lock(hub_root: Path, blob_sha: str) -> Iterator[None]:
113
+ path = scan_lock_path(hub_root, blob_sha)
114
+ path.parent.mkdir(parents=True, exist_ok=True)
115
+ lock = FileLock(path)
116
+ lock.acquire()
117
+ try:
118
+ yield
119
+ finally:
120
+ lock.release()
@@ -43,9 +43,12 @@ _SCRIPT = dedent(
43
43
 
44
44
  tickets: List[tuple[int, Path]] = []
45
45
  errors: List[str] = []
46
+ index_to_paths: dict[int, List[Path]] = {}
46
47
  for path in sorted(tickets_dir.iterdir()):
47
48
  if not path.is_file():
48
49
  continue
50
+ if path.name == "AGENTS.md":
51
+ continue
49
52
  match = _TICKET_NAME_RE.match(path.name)
50
53
  if not match:
51
54
  errors.append(
@@ -60,7 +63,21 @@ _SCRIPT = dedent(
60
63
  )
61
64
  continue
62
65
  tickets.append((idx, path))
66
+ # Track paths by index to detect duplicates
67
+ if idx not in index_to_paths:
68
+ index_to_paths[idx] = []
69
+ index_to_paths[idx].append(path)
63
70
  tickets.sort(key=lambda pair: pair[0])
71
+
72
+ # Check for duplicate indices
73
+ for idx, paths in index_to_paths.items():
74
+ if len(paths) > 1:
75
+ paths_str = ", ".join([str(p) for p in paths])
76
+ errors.append(
77
+ f\"Duplicate ticket index {idx:03d}: multiple files share the same index ({paths_str}). \"
78
+ \"Rename or remove duplicates to ensure deterministic ordering.\"
79
+ )
80
+
64
81
  return [p for _, p in tickets], errors
65
82
 
66
83