atsfill 0.0.3.dev4__tar.gz → 0.0.3.dev6__tar.gz

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 (24) hide show
  1. {atsfill-0.0.3.dev4 → atsfill-0.0.3.dev6}/PKG-INFO +8 -3
  2. {atsfill-0.0.3.dev4 → atsfill-0.0.3.dev6}/README.md +7 -2
  3. {atsfill-0.0.3.dev4 → atsfill-0.0.3.dev6}/pyproject.toml +1 -1
  4. {atsfill-0.0.3.dev4 → atsfill-0.0.3.dev6}/pyproject.toml.orig +1 -1
  5. {atsfill-0.0.3.dev4 → atsfill-0.0.3.dev6}/src/atsfill/cli.py +2 -1
  6. {atsfill-0.0.3.dev4 → atsfill-0.0.3.dev6}/src/atsfill/core/browser.py +4 -2
  7. {atsfill-0.0.3.dev4 → atsfill-0.0.3.dev6}/src/atsfill/core/commands.py +60 -25
  8. atsfill-0.0.3.dev6/src/atsfill/core/registry.py +215 -0
  9. {atsfill-0.0.3.dev4 → atsfill-0.0.3.dev6}/src/atsfill/core/results.py +7 -1
  10. atsfill-0.0.3.dev6/src/atsfill/registry.toml +41 -0
  11. {atsfill-0.0.3.dev4 → atsfill-0.0.3.dev6}/LICENSE +0 -0
  12. {atsfill-0.0.3.dev4 → atsfill-0.0.3.dev6}/src/atsfill/__init__.py +0 -0
  13. {atsfill-0.0.3.dev4 → atsfill-0.0.3.dev6}/src/atsfill/adapters/__init__.py +0 -0
  14. {atsfill-0.0.3.dev4 → atsfill-0.0.3.dev6}/src/atsfill/adapters/ashby.py +0 -0
  15. {atsfill-0.0.3.dev4 → atsfill-0.0.3.dev6}/src/atsfill/adapters/greenhouse.py +0 -0
  16. {atsfill-0.0.3.dev4 → atsfill-0.0.3.dev6}/src/atsfill/adapters/teamtailor.py +0 -0
  17. {atsfill-0.0.3.dev4 → atsfill-0.0.3.dev6}/src/atsfill/adapters/wttj.py +0 -0
  18. {atsfill-0.0.3.dev4 → atsfill-0.0.3.dev6}/src/atsfill/core/__init__.py +0 -0
  19. {atsfill-0.0.3.dev4 → atsfill-0.0.3.dev6}/src/atsfill/core/accounts.py +0 -0
  20. {atsfill-0.0.3.dev4 → atsfill-0.0.3.dev6}/src/atsfill/core/adapter.py +0 -0
  21. {atsfill-0.0.3.dev4 → atsfill-0.0.3.dev6}/src/atsfill/core/attempts.py +0 -0
  22. {atsfill-0.0.3.dev4 → atsfill-0.0.3.dev6}/src/atsfill/core/matching.py +0 -0
  23. {atsfill-0.0.3.dev4 → atsfill-0.0.3.dev6}/src/atsfill/core/paths.py +0 -0
  24. {atsfill-0.0.3.dev4 → atsfill-0.0.3.dev6}/src/atsfill/py.typed +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: atsfill
3
- Version: 0.0.3.dev4
3
+ Version: 0.0.3.dev6
4
4
  Summary: Read, dry-run and submit job application forms on hiring systems: Ashby, Greenhouse, Teamtailor, Welcome to the Jungle.
5
5
  Keywords: job-applications,ats,ashby,greenhouse,teamtailor,ai-agents,cli
6
6
  Author: shbernal
@@ -52,6 +52,10 @@ It is built for agents with a shell: every command prints one JSON document and
52
52
 
53
53
  ## Supported hiring systems
54
54
 
55
+ <!-- badges -->
56
+ ![ashby: send broken 2026-10-19](https://img.shields.io/badge/ashby-send%20broken%202026--10--19-f7768e) ![greenhouse: unverified](https://img.shields.io/badge/greenhouse-unverified-565f89) ![teamtailor: unverified](https://img.shields.io/badge/teamtailor-unverified-565f89) ![wttj: unverified](https://img.shields.io/badge/wttj-unverified-565f89)
57
+ <!-- /badges -->
58
+
55
59
  | System | Account needed | `read` without a browser |
56
60
  | --- | --- | --- |
57
61
  | Ashby | no | yes |
@@ -124,7 +128,8 @@ Every push to `main` that passes CI is also published as a dev release; `uvx --p
124
128
  ```
125
129
 
126
130
  A dry run is not invisible: some systems save answers or the resume as they are typed.
127
- `atsfill status` lists each system's side effects and whether it is allowed to submit.
131
+ `atsfill status` lists each system's side effects, whether it is allowed to submit, and how far atsfill has verified it: [the systems](docs/adapters/README.md).
132
+ A `send` through an adapter no send was verified on goes ahead with a `warning`.
128
133
 
129
134
  ## How it works
130
135
 
@@ -147,7 +152,7 @@ The exit code is 0 for `ready` and `submitted`, 1 otherwise, 2 for a usage error
147
152
  ## More
148
153
 
149
154
  - [Architecture decisions](docs/adr/README.md): the contract, the submit gate, the browser, sessions.
150
- - [Hiring systems](docs/adapters/): each system's form as observed.
155
+ - [Hiring systems](docs/adapters/README.md): every system mapped so far, in backlog order, and each one's form as observed.
151
156
  - `atsfill --help` for every command and option.
152
157
 
153
158
  [pypi]: https://pypi.org/project/atsfill/
@@ -28,6 +28,10 @@ It is built for agents with a shell: every command prints one JSON document and
28
28
 
29
29
  ## Supported hiring systems
30
30
 
31
+ <!-- badges -->
32
+ ![ashby: send broken 2026-10-19](https://img.shields.io/badge/ashby-send%20broken%202026--10--19-f7768e) ![greenhouse: unverified](https://img.shields.io/badge/greenhouse-unverified-565f89) ![teamtailor: unverified](https://img.shields.io/badge/teamtailor-unverified-565f89) ![wttj: unverified](https://img.shields.io/badge/wttj-unverified-565f89)
33
+ <!-- /badges -->
34
+
31
35
  | System | Account needed | `read` without a browser |
32
36
  | --- | --- | --- |
33
37
  | Ashby | no | yes |
@@ -100,7 +104,8 @@ Every push to `main` that passes CI is also published as a dev release; `uvx --p
100
104
  ```
101
105
 
102
106
  A dry run is not invisible: some systems save answers or the resume as they are typed.
103
- `atsfill status` lists each system's side effects and whether it is allowed to submit.
107
+ `atsfill status` lists each system's side effects, whether it is allowed to submit, and how far atsfill has verified it: [the systems](docs/adapters/README.md).
108
+ A `send` through an adapter no send was verified on goes ahead with a `warning`.
104
109
 
105
110
  ## How it works
106
111
 
@@ -123,7 +128,7 @@ The exit code is 0 for `ready` and `submitted`, 1 otherwise, 2 for a usage error
123
128
  ## More
124
129
 
125
130
  - [Architecture decisions](docs/adr/README.md): the contract, the submit gate, the browser, sessions.
126
- - [Hiring systems](docs/adapters/): each system's form as observed.
131
+ - [Hiring systems](docs/adapters/README.md): every system mapped so far, in backlog order, and each one's form as observed.
127
132
  - `atsfill --help` for every command and option.
128
133
 
129
134
  [pypi]: https://pypi.org/project/atsfill/
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "atsfill"
3
- version = "0.0.3.dev4"
3
+ version = "0.0.3.dev6"
4
4
  description = "Read, dry-run and submit job application forms on hiring systems: Ashby, Greenhouse, Teamtailor, Welcome to the Jungle."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "atsfill"
3
- version = "0.0.3.dev4"
3
+ version = "0.0.3.dev6"
4
4
  description = "Read, dry-run and submit job application forms on hiring systems: Ashby, Greenhouse, Teamtailor, Welcome to the Jungle."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -103,7 +103,8 @@ def run(args: argparse.Namespace) -> int:
103
103
  case "status":
104
104
  report = core.status(data_dir=args.data_dir)
105
105
  emit(report)
106
- return 1 if any(a.session == "signed_out" for a in report.adapters) else 0
106
+ signed_out = any(a.session == "signed_out" for a in report.adapters)
107
+ return 1 if signed_out or report.browser != report.browser_pinned else 0
107
108
  case "install-browser":
108
109
  return browser.install()
109
110
  case "login":
@@ -22,6 +22,7 @@ from playwright.sync_api import Error as BrowserError
22
22
  from playwright.sync_api import TimeoutError as Timeout
23
23
 
24
24
  __all__ = [
25
+ "BUILD",
25
26
  "BrowserError",
26
27
  "Covered",
27
28
  "Locator",
@@ -43,6 +44,7 @@ __all__ = [
43
44
  MIN_SCREEN_HEIGHT = 1050 # leaves the window the 900px Greenhouse and Teamtailor need
44
45
  HOST_OS = {"Linux": "linux", "Darwin": "macos", "Windows": "windows"}[platform.system()]
45
46
  BUDGET = 180.0 # seconds per command, lock wait included
47
+ BUILD = "156.0.1-beta.36" # the Camoufox browser build, pinned so results compare across machines
46
48
 
47
49
 
48
50
  class OutOfTime(Exception):
@@ -62,8 +64,8 @@ def version() -> str:
62
64
 
63
65
 
64
66
  def install() -> int:
65
- """Fetches the browser build the pinned camoufox release pairs with."""
66
- return subprocess.call([sys.executable, "-m", "camoufox", "fetch"])
67
+ """Fetches the pinned browser build."""
68
+ return subprocess.call([sys.executable, "-m", "camoufox", "fetch", f"official/{BUILD}"])
67
69
 
68
70
 
69
71
  def identity(data_dir: Path) -> dict:
@@ -4,7 +4,7 @@ from importlib.metadata import version
4
4
  from pathlib import Path
5
5
 
6
6
  from atsfill.adapters import ADAPTERS
7
- from atsfill.core import accounts, attempts, browser, matching, paths
7
+ from atsfill.core import accounts, attempts, browser, matching, paths, registry
8
8
  from atsfill.core.adapter import Adapter, Blocked, NeedsAccount, Unsupported
9
9
  from atsfill.core.browser import Covered, OutOfTime, Page, open_page
10
10
  from atsfill.core.results import (
@@ -30,12 +30,14 @@ def _login_required(url: str, adapter: Adapter) -> Result:
30
30
 
31
31
  @contextmanager
32
32
  def _opened(
33
- data: Path, url: str, sessions: Sessions | None
33
+ data: Path, url: str, sessions: Sessions | None, detected: list[str]
34
34
  ) -> Generator[tuple[Page, Adapter] | Result]:
35
35
  """The loaded page and the adapter that claims it by its URL, else by its page; or the
36
36
  result that ends the command first. An adapter that needs an account gets its session,
37
- and only that adapter's (ADR 0007)."""
37
+ and only that adapter's (ADR 0007). The adapter's name goes in `detected` once known."""
38
38
  adapter = _detect(url)
39
+ if adapter:
40
+ detected.append(adapter.name)
39
41
  session = None
40
42
  if isinstance(adapter, NeedsAccount):
41
43
  session = (sessions or {}).get(adapter.name) or accounts.session_file(data, adapter.name)
@@ -44,7 +46,8 @@ def _opened(
44
46
  return
45
47
  with open_page(data, session) as page:
46
48
  page.goto(url)
47
- adapter = adapter or _detect(url, page)
49
+ if adapter is None and (adapter := _detect(url, page)):
50
+ detected.append(adapter.name)
48
51
  if adapter is None:
49
52
  yield _no_adapter(url)
50
53
  elif isinstance(adapter, NeedsAccount) and not (
@@ -77,16 +80,18 @@ def _stops(result: Result) -> Generator[None]:
77
80
  result.status, result.reason = Status.BLOCKED, f"covered: {e}"
78
81
 
79
82
 
80
- def _in_budget(url: str, command: Callable[[], Result]) -> Result:
83
+ def _in_budget(url: str, command: Callable[[list[str]], Result]) -> Result:
81
84
  """Runs a command to a status whatever happens: past the click it reports `unknown` itself,
82
- before it an error the adapter did not expect means the page is not one it handles."""
85
+ before it an error the adapter did not expect means the page is not one it handles. The
86
+ result keeps the adapter detected before the stop."""
87
+ detected: list[str] = []
83
88
  try:
84
- return command()
89
+ return command(detected)
85
90
  except OutOfTime as e:
86
- return Result(Status.BLOCKED, url, reason=f"time_budget: {e}")
91
+ return Result(Status.BLOCKED, url, (detected or [None])[-1], f"time_budget: {e}")
87
92
  except Exception as e:
88
93
  reason = f"adapter_error: {type(e).__name__}: {e}"
89
- return Result(Status.UNSUPPORTED, url, reason=reason)
94
+ return Result(Status.UNSUPPORTED, url, (detected or [None])[-1], reason)
90
95
 
91
96
 
92
97
  def _fill(page: Page, adapter: Adapter, url: str, answers: dict[str, Answer]) -> Result:
@@ -104,8 +109,8 @@ def _fill(page: Page, adapter: Adapter, url: str, answers: dict[str, Answer]) ->
104
109
  return result
105
110
 
106
111
 
107
- def _read_page(data: Path, url: str, sessions: Sessions | None) -> Result:
108
- with _opened(data, url, sessions) as opened:
112
+ def _read_page(data: Path, url: str, sessions: Sessions | None, detected: list[str]) -> Result:
113
+ with _opened(data, url, sessions, detected) as opened:
109
114
  if isinstance(opened, Result):
110
115
  return opened
111
116
  page, adapter = opened
@@ -122,8 +127,9 @@ def _form_command(
122
127
  sessions: Sessions | None,
123
128
  send: bool,
124
129
  resend: bool,
130
+ detected: list[str],
125
131
  ) -> Result:
126
- with _opened(data, url, sessions) as opened:
132
+ with _opened(data, url, sessions, detected) as opened:
127
133
  if isinstance(opened, Result):
128
134
  return opened
129
135
  page, adapter = opened
@@ -138,6 +144,7 @@ def _form_command(
138
144
  result = _fill(page, adapter, url, answers)
139
145
  if not send or result.status is not Status.READY:
140
146
  return result
147
+ result.warning = _unverified(adapter.name)
141
148
  attempts.record(data, posting, attempts.PENDING, url=url)
142
149
  try:
143
150
  result.evidence = adapter.submit(page)
@@ -164,7 +171,7 @@ def read(url: str, *, data_dir: Path | None = None, sessions: Sessions | None =
164
171
  result.fields = fields
165
172
  return result
166
173
  data = paths.data_dir(data_dir)
167
- return _in_budget(url, lambda: _read_page(data, url, sessions))
174
+ return _in_budget(url, lambda seen: _read_page(data, url, sessions, seen))
168
175
 
169
176
 
170
177
  def fill(
@@ -176,7 +183,7 @@ def fill(
176
183
  ) -> Result:
177
184
  data = paths.data_dir(data_dir)
178
185
  return _in_budget(
179
- url, lambda: _form_command(data, url, answers, sessions, send=False, resend=False)
186
+ url, lambda seen: _form_command(data, url, answers, sessions, False, False, seen)
180
187
  )
181
188
 
182
189
 
@@ -190,10 +197,22 @@ def send(
190
197
  ) -> Result:
191
198
  data = paths.data_dir(data_dir)
192
199
  return _in_budget(
193
- url, lambda: _form_command(data, url, answers, sessions, send=True, resend=resend)
200
+ url, lambda seen: _form_command(data, url, answers, sessions, True, resend, seen)
194
201
  )
195
202
 
196
203
 
204
+ def _unverified(adapter: str) -> str | None:
205
+ """The warning a send carries when no send through the adapter was verified, or the last
206
+ verified one fails now (ADR 0012)."""
207
+ system = registry.for_adapter(adapter)
208
+ if system and system.send_broken:
209
+ return f"send_broken: {_broken(system)}"
210
+ if system and system.send_verified:
211
+ return None
212
+ state = system.state if system else None
213
+ return f"not_send_verified: {adapter} is {state or 'not in the registry'}"
214
+
215
+
197
216
  def _session_state(adapter: Adapter, data: Path) -> str | None:
198
217
  if not isinstance(adapter, NeedsAccount):
199
218
  return None
@@ -208,16 +227,32 @@ def status(*, data_dir: Path | None = None) -> StatusReport:
208
227
  data_dir=data,
209
228
  config=paths.config_file(),
210
229
  browser=browser.version(),
211
- adapters=[
212
- AdapterState(
213
- a.name,
214
- submit_enabled=paths.submit_enabled(a.name),
215
- side_effects=a.side_effects,
216
- side_effects_acknowledged=paths.acknowledged(a.name),
217
- session=_session_state(a, data),
218
- )
219
- for a in ADAPTERS
220
- ],
230
+ browser_pinned=browser.BUILD,
231
+ adapters=[_adapter_state(a, data) for a in ADAPTERS],
232
+ )
233
+
234
+
235
+ def _broken(system: registry.System) -> str:
236
+ broken, last = system.send_broken, system.send_verified
237
+ assert broken
238
+ since = f"send broken since {broken.date} ({broken.version}), {broken.reason}"
239
+ return since + (f"; last verified {last.date} ({last.version})" if last else "")
240
+
241
+
242
+ def _adapter_state(adapter: Adapter, data: Path) -> AdapterState:
243
+ system = registry.for_adapter(adapter.name)
244
+ verified = system.state if system else None
245
+ v = getattr(system, verified) if system and verified else None
246
+ return AdapterState(
247
+ adapter.name,
248
+ submit_enabled=paths.submit_enabled(adapter.name),
249
+ side_effects=adapter.side_effects,
250
+ side_effects_acknowledged=paths.acknowledged(adapter.name),
251
+ verified=verified,
252
+ verified_version=v.version if v else None,
253
+ verified_release=registry.release(v.version, version("atsfill")) if v else None,
254
+ send_broken=_broken(system) if system and system.send_broken else None,
255
+ session=_session_state(adapter, data),
221
256
  )
222
257
 
223
258
 
@@ -0,0 +1,215 @@
1
+ """The committed registry of hiring systems (ADR 0012)."""
2
+
3
+ import tomllib
4
+ from dataclasses import dataclass, fields
5
+ from datetime import date
6
+ from functools import cache
7
+ from importlib.resources import files
8
+
9
+ KINDS = ("ats", "board", "aggregator", "custom")
10
+ ACCOUNTS = ("none", "to_send", "to_read") # also the backlog order (ADR 0013)
11
+ HOSTING = ("hosted", "embedded")
12
+ VENDORS = ("DataDome", "Cloudflare", "Akamai", "PerimeterX", "unknown")
13
+ STEPS = ("posting", "form", "upload")
14
+ STATES = ("mapped", "dry_run_verified", "send_verified")
15
+ CAUSES = ("system", "code", "unknown")
16
+ RELEASES = ("this_release", "older", "newer")
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class Verification:
21
+ date: date
22
+ version: str # the release, compared with the installed one
23
+ by: str
24
+ commit: str | None = None # the exact code
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class Broken:
29
+ """A send that worked fails now (ADR 0012); `reason` names fields and steps, never postings."""
30
+
31
+ date: date
32
+ version: str
33
+ commit: str
34
+ by: str
35
+ reason: str
36
+ cause: str = "unknown"
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class Block:
41
+ vendor: str
42
+ signal: str
43
+ step: str
44
+ date: date
45
+ version: str
46
+ camoufox: str
47
+ browser: str
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class System:
52
+ source: str
53
+ kind: str
54
+ account: str
55
+ hosting: tuple[str, ...]
56
+ adapter: str | None = None
57
+ share: float | None = None
58
+ block: Block | None = None
59
+ mapped: Verification | None = None
60
+ dry_run_verified: Verification | None = None
61
+ send_verified: Verification | None = None # the last known good, kept through a break
62
+ send_broken: Broken | None = None
63
+
64
+ @property
65
+ def state(self) -> str | None:
66
+ """The furthest state reached; each one implies the ones before it."""
67
+ return next((s for s in reversed(STATES) if getattr(self, s)), None)
68
+
69
+
70
+ def _one_of(source: str, key: str, value: object, allowed: tuple[str, ...]) -> None:
71
+ if value not in allowed:
72
+ raise ValueError(f"{source}: {key} = {value!r}, expected one of {', '.join(allowed)}")
73
+
74
+
75
+ def _system(source: str, entry: dict) -> System:
76
+ known = {f.name for f in fields(System)} - {"source"}
77
+ if unknown := entry.keys() - known:
78
+ raise ValueError(f"{source}: unknown keys {', '.join(sorted(unknown))}")
79
+ _one_of(source, "kind", entry.get("kind"), KINDS)
80
+ _one_of(source, "account", entry.get("account"), ACCOUNTS)
81
+ for h in entry.get("hosting", []):
82
+ _one_of(source, "hosting", h, HOSTING)
83
+ block = entry.get("block")
84
+ if block is not None:
85
+ _one_of(source, "block.vendor", block.get("vendor"), VENDORS)
86
+ _one_of(source, "block.step", block.get("step"), STEPS)
87
+ block = Block(**block)
88
+ states = {s: Verification(**entry[s]) for s in STATES if s in entry}
89
+ broken = entry.get("send_broken")
90
+ if broken is not None:
91
+ _one_of(source, "send_broken.cause", broken.get("cause", "unknown"), CAUSES)
92
+ broken = Broken(**broken)
93
+ return System(
94
+ source,
95
+ entry["kind"],
96
+ entry["account"],
97
+ tuple(entry.get("hosting", [])),
98
+ entry.get("adapter"),
99
+ entry.get("share"),
100
+ block,
101
+ **states,
102
+ send_broken=broken,
103
+ )
104
+
105
+
106
+ def parse(text: str) -> dict[str, System]:
107
+ return {source: _system(source, entry) for source, entry in tomllib.loads(text).items()}
108
+
109
+
110
+ @cache
111
+ def systems() -> dict[str, System]:
112
+ return parse(files("atsfill").joinpath("registry.toml").read_text())
113
+
114
+
115
+ def _toml(value: object) -> str:
116
+ return str(value) if isinstance(value, date) else f'"{value}"'
117
+
118
+
119
+ def with_state(text: str, source: str, key: str, value: Verification | Broken) -> str:
120
+ """The registry file's text with `key` of `source` set to `value`, the rest untouched.
121
+ Writing `send_verified` ends a `send_broken`."""
122
+ lines = text.splitlines()
123
+ start = lines.index(f"[{source}]")
124
+ end = next((i for i in range(start + 1, len(lines)) if lines[i].startswith("[")), len(lines))
125
+ while lines[end - 1] == "":
126
+ end -= 1
127
+ gone = {key, "send_broken"} if key == "send_verified" else {key}
128
+ entry = lines[start:end]
129
+ at = next((i for i, x in enumerate(entry) if x.split(" =")[0] in gone), len(entry))
130
+ entry = [x for x in entry if x.split(" =")[0] not in gone]
131
+ pairs = ", ".join(
132
+ f"{f.name} = {_toml(getattr(value, f.name))}"
133
+ for f in fields(value)
134
+ if getattr(value, f.name) is not None
135
+ )
136
+ entry.insert(min(at, len(entry)), f"{key} = {{ {pairs} }}")
137
+ return "\n".join(lines[:start] + entry + lines[end:]) + "\n"
138
+
139
+
140
+ def release(verified: str, installed: str) -> str:
141
+ """Whether a verified release is the installed one, `older` or `newer`."""
142
+ as_tuple = lambda v: tuple(int(n) for n in v.split("+")[0].split(".")) # noqa: E731
143
+ a, b = as_tuple(verified), as_tuple(installed)
144
+ return "this_release" if a == b else "older" if a < b else "newer"
145
+
146
+
147
+ def for_adapter(name: str) -> System | None:
148
+ return next((s for s in systems().values() if s.adapter == name), None)
149
+
150
+
151
+ def _backlog_key(s: System) -> tuple:
152
+ return (s.block is not None, ACCOUNTS.index(s.account), -(s.share or 0), s.source)
153
+
154
+
155
+ def _row(s: System) -> str:
156
+ share = f"{s.share:.1%}" if s.share is not None else ""
157
+ block = f"{s.block.vendor} at {s.block.step}" if s.block else ""
158
+ state = "send_broken" if s.send_broken else s.state or ""
159
+ if s.adapter:
160
+ state = f"[{state}]({s.adapter}.md)" if state else f"[adapter]({s.adapter}.md)"
161
+ cells = [s.source, s.kind, share, s.account, ", ".join(s.hosting), block, state]
162
+ return "| " + " | ".join(cells) + " |"
163
+
164
+
165
+ def backlog(registry: dict[str, System] | None = None) -> str:
166
+ """`docs/adapters/README.md`: the systems in backlog order (ADR 0013)."""
167
+ registry = systems() if registry is None else registry
168
+ head = "| Source | Kind | Share | Account | Hosting | Block | State |\n" + "|---" * 7 + "|"
169
+ adapters = sorted(
170
+ (s for s in registry.values() if s.kind in ("ats", "board")), key=_backlog_key
171
+ )
172
+ others = sorted(
173
+ (s for s in registry.values() if s.kind not in ("ats", "board")), key=_backlog_key
174
+ )
175
+ text = [
176
+ "# Hiring systems",
177
+ "",
178
+ "Generated from [src/atsfill/registry.toml](../../src/atsfill/registry.toml) by"
179
+ " `uv run scripts/backlog.py`. The registry is described in"
180
+ " [ADR 0012](../adr/0012-systems-registry.md), the order in"
181
+ " [ADR 0013](../adr/0013-survey-and-adapter-rounds.md): no account first, blocked last,"
182
+ " then by share of postings.",
183
+ "",
184
+ head,
185
+ *map(_row, adapters),
186
+ ]
187
+ if others:
188
+ text += ["", "Aggregators and company-built forms, counted and not adapted:", "", head]
189
+ text += map(_row, others)
190
+ return "\n".join(text) + "\n"
191
+
192
+
193
+ BADGES = ("<!-- badges -->", "<!-- /badges -->")
194
+ COLORS = {"send_verified": "9ece6a", "dry_run_verified": "7aa2f7", "mapped": "a9b1d6"}
195
+
196
+
197
+ def _badge(s: System) -> str:
198
+ if s.send_broken:
199
+ message, color = f"send broken {s.send_broken.date}", "f7768e"
200
+ elif s.state:
201
+ v: Verification = getattr(s, s.state)
202
+ message, color = f"{s.state.replace('_', ' ')} {v.date}", COLORS[s.state]
203
+ else:
204
+ message, color = "unverified", "565f89"
205
+ escape = lambda t: t.replace("-", "--").replace("_", "__").replace(" ", "%20") # noqa: E731
206
+ url = f"https://img.shields.io/badge/{escape(s.adapter or s.source)}-{escape(message)}-{color}"
207
+ return f"![{s.adapter}: {message}]({url})"
208
+
209
+
210
+ def with_badges(readme: str, registry: dict[str, System] | None = None) -> str:
211
+ """The README with one badge per adapter between its badge markers."""
212
+ registry = systems() if registry is None else registry
213
+ start, end = readme.index(BADGES[0]) + len(BADGES[0]), readme.index(BADGES[1])
214
+ badges = " ".join(_badge(s) for s in sorted(registry.values(), key=_backlog_key) if s.adapter)
215
+ return f"{readme[:start]}\n{badges}\n{readme[end:]}"
@@ -61,6 +61,7 @@ class Result:
61
61
  checks: list[Check] = field(default_factory=list)
62
62
  side_effects: list[SideEffect] = field(default_factory=list)
63
63
  evidence: str | None = None
64
+ warning: str | None = None # `code: detail`, on a send that went ahead anyway
64
65
 
65
66
 
66
67
  @dataclass
@@ -69,6 +70,10 @@ class AdapterState:
69
70
  submit_enabled: bool
70
71
  side_effects: list[SideEffect]
71
72
  side_effects_acknowledged: bool
73
+ verified: str | None = None # mapped | dry_run_verified | send_verified (ADR 0012)
74
+ verified_version: str | None = None
75
+ verified_release: str | None = None # this_release | older | newer than the installed one
76
+ send_broken: str | None = None # since when and why, with the last verified send
72
77
  session: str | None = None # signed_in | signed_out | missing, for a system with accounts
73
78
 
74
79
 
@@ -77,7 +82,8 @@ class StatusReport:
77
82
  version: str
78
83
  data_dir: Path
79
84
  config: Path
80
- browser: str
85
+ browser: str # the installed build
86
+ browser_pinned: str
81
87
  adapters: list[AdapterState]
82
88
 
83
89
 
@@ -0,0 +1,41 @@
1
+ # The hiring systems atsfill has mapped (ADR 0012), one table per freehire `source`.
2
+ # `docs/adapters/README.md` is generated from this file: `uv run scripts/backlog.py`.
3
+ #
4
+ # kind ats | board | aggregator | custom
5
+ # share fraction of freehire's postings, worldwide, from its `source` facet
6
+ # account none | to_send | to_read
7
+ # hosting hosted, embedded: where the form is found
8
+ # adapter the adapter's name, when there is one
9
+ # block vendor, signal, step (posting | form | upload), date, version, camoufox, browser
10
+ # mapped, dry_run_verified, send_verified
11
+ # date, version (the release), commit (the exact code), by; only the user sets
12
+ # send_verified
13
+ # send_broken
14
+ # date, version, commit, by, cause (system | code | unknown), reason: a send that
15
+ # worked fails now; send_verified stays as the last known good
16
+
17
+ [ashby]
18
+ kind = "ats"
19
+ account = "none"
20
+ hosting = ["hosted", "embedded"]
21
+ adapter = "ashby"
22
+ send_verified = { date = 2026-10-18, version = "0.0.2", by = "shbernal", commit = "ff8ea46" }
23
+ send_broken = { date = 2026-10-19, version = "0.0.2", commit = "916319c", by = "shbernal", reason = "the résumé reads back empty after its upload", cause = "unknown" }
24
+
25
+ [teamtailor]
26
+ kind = "ats"
27
+ account = "none"
28
+ hosting = ["hosted"]
29
+ adapter = "teamtailor"
30
+
31
+ [greenhouse]
32
+ kind = "ats"
33
+ account = "none"
34
+ hosting = ["hosted", "embedded"]
35
+ adapter = "greenhouse"
36
+
37
+ [wttj]
38
+ kind = "board"
39
+ account = "to_send"
40
+ hosting = ["hosted"]
41
+ adapter = "wttj"
File without changes