adopt-cli 0.3.0__tar.gz → 0.4.0__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 (54) hide show
  1. {adopt_cli-0.3.0 → adopt_cli-0.4.0}/PKG-INFO +7 -1
  2. {adopt_cli-0.3.0 → adopt_cli-0.4.0}/pyproject.toml +21 -1
  3. adopt_cli-0.4.0/src/adopt_cli/_build_info.py +6 -0
  4. adopt_cli-0.4.0/src/adopt_cli/commands/_ask_support.py +311 -0
  5. adopt_cli-0.4.0/src/adopt_cli/commands/_ci_sense_probes.py +229 -0
  6. adopt_cli-0.4.0/src/adopt_cli/commands/_ci_sense_support.py +230 -0
  7. adopt_cli-0.4.0/src/adopt_cli/commands/_draft_support.py +379 -0
  8. adopt_cli-0.4.0/src/adopt_cli/commands/_drafting.py +127 -0
  9. adopt_cli-0.4.0/src/adopt_cli/commands/_handover_support.py +583 -0
  10. adopt_cli-0.4.0/src/adopt_cli/commands/_knowledge_support.py +726 -0
  11. adopt_cli-0.4.0/src/adopt_cli/commands/_map_support.py +207 -0
  12. adopt_cli-0.4.0/src/adopt_cli/commands/_pack_support.py +317 -0
  13. adopt_cli-0.4.0/src/adopt_cli/commands/_probe_support.py +809 -0
  14. adopt_cli-0.4.0/src/adopt_cli/commands/_refresh_support.py +633 -0
  15. adopt_cli-0.4.0/src/adopt_cli/commands/_remote_support.py +57 -0
  16. adopt_cli-0.4.0/src/adopt_cli/commands/_review_remote.py +86 -0
  17. adopt_cli-0.4.0/src/adopt_cli/commands/answer.py +318 -0
  18. adopt_cli-0.4.0/src/adopt_cli/commands/ask.py +127 -0
  19. {adopt_cli-0.3.0 → adopt_cli-0.4.0}/src/adopt_cli/commands/boundary.py +1 -1
  20. adopt_cli-0.4.0/src/adopt_cli/commands/ci_sense.py +226 -0
  21. {adopt_cli-0.3.0 → adopt_cli-0.4.0}/src/adopt_cli/commands/coverage.py +9 -1
  22. adopt_cli-0.4.0/src/adopt_cli/commands/draft.py +81 -0
  23. adopt_cli-0.4.0/src/adopt_cli/commands/handover.py +809 -0
  24. {adopt_cli-0.3.0 → adopt_cli-0.4.0}/src/adopt_cli/commands/init.py +2 -0
  25. adopt_cli-0.4.0/src/adopt_cli/commands/knowledge.py +1191 -0
  26. adopt_cli-0.4.0/src/adopt_cli/commands/map_command.py +230 -0
  27. adopt_cli-0.4.0/src/adopt_cli/commands/pack.py +225 -0
  28. adopt_cli-0.4.0/src/adopt_cli/commands/probe.py +243 -0
  29. adopt_cli-0.4.0/src/adopt_cli/commands/pull.py +287 -0
  30. adopt_cli-0.4.0/src/adopt_cli/commands/refresh.py +316 -0
  31. adopt_cli-0.4.0/src/adopt_cli/commands/serve.py +101 -0
  32. {adopt_cli-0.3.0 → adopt_cli-0.4.0}/src/adopt_cli/config.py +81 -4
  33. adopt_cli-0.4.0/src/adopt_cli/main.py +279 -0
  34. adopt_cli-0.4.0/src/adopt_cli/remote.py +307 -0
  35. adopt_cli-0.4.0/src/adopt_cli/replica.py +240 -0
  36. {adopt_cli-0.3.0 → adopt_cli-0.4.0}/src/adopt_cli/store_option.py +99 -5
  37. adopt_cli-0.3.0/src/adopt_cli/_build_info.py +0 -6
  38. adopt_cli-0.3.0/src/adopt_cli/main.py +0 -150
  39. {adopt_cli-0.3.0 → adopt_cli-0.4.0}/.gitignore +0 -0
  40. {adopt_cli-0.3.0 → adopt_cli-0.4.0}/LICENSE +0 -0
  41. {adopt_cli-0.3.0 → adopt_cli-0.4.0}/NOTICE +0 -0
  42. {adopt_cli-0.3.0 → adopt_cli-0.4.0}/src/adopt_cli/__init__.py +0 -0
  43. {adopt_cli-0.3.0 → adopt_cli-0.4.0}/src/adopt_cli/commands/__init__.py +0 -0
  44. {adopt_cli-0.3.0 → adopt_cli-0.4.0}/src/adopt_cli/commands/agent.py +0 -0
  45. {adopt_cli-0.3.0 → adopt_cli-0.4.0}/src/adopt_cli/commands/detect.py +0 -0
  46. {adopt_cli-0.3.0 → adopt_cli-0.4.0}/src/adopt_cli/commands/doctor.py +0 -0
  47. {adopt_cli-0.3.0 → adopt_cli-0.4.0}/src/adopt_cli/commands/freshness.py +0 -0
  48. {adopt_cli-0.3.0 → adopt_cli-0.4.0}/src/adopt_cli/commands/identity.py +0 -0
  49. {adopt_cli-0.3.0 → adopt_cli-0.4.0}/src/adopt_cli/commands/interchange.py +0 -0
  50. {adopt_cli-0.3.0 → adopt_cli-0.4.0}/src/adopt_cli/commands/policy.py +0 -0
  51. {adopt_cli-0.3.0 → adopt_cli-0.4.0}/src/adopt_cli/commands/store.py +0 -0
  52. {adopt_cli-0.3.0 → adopt_cli-0.4.0}/src/adopt_cli/commands/version.py +0 -0
  53. {adopt_cli-0.3.0 → adopt_cli-0.4.0}/src/adopt_cli/json_out.py +0 -0
  54. {adopt_cli-0.3.0 → adopt_cli-0.4.0}/src/adopt_cli/py.typed +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: adopt-cli
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: The adopt command surface: config resolution, JSON mode, exit codes.
5
5
  Project-URL: Homepage, https://github.com/onboardux/onboard-core
6
6
  Project-URL: Source, https://github.com/onboardux/onboard-core
@@ -9,14 +9,20 @@ Author: The Adopt Authors
9
9
  License-Expression: Apache-2.0
10
10
  Requires-Python: >=3.12
11
11
  Requires-Dist: adopt-agent
12
+ Requires-Dist: adopt-ask
12
13
  Requires-Dist: adopt-const
13
14
  Requires-Dist: adopt-coverage
14
15
  Requires-Dist: adopt-detect
15
16
  Requires-Dist: adopt-export
16
17
  Requires-Dist: adopt-freshness
18
+ Requires-Dist: adopt-handover
17
19
  Requires-Dist: adopt-identity
20
+ Requires-Dist: adopt-knowledge
21
+ Requires-Dist: adopt-map
22
+ Requires-Dist: adopt-model
18
23
  Requires-Dist: adopt-obs
19
24
  Requires-Dist: adopt-policy
25
+ Requires-Dist: adopt-probe
20
26
  Requires-Dist: adopt-scope
21
27
  Requires-Dist: adopt-store
22
28
  Requires-Dist: click<9,>=8
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "adopt-cli"
3
- version = "0.3.0"
3
+ version = "0.4.0"
4
4
  description = "The adopt command surface: config resolution, JSON mode, exit codes."
5
5
  requires-python = ">=3.12"
6
6
  license = "Apache-2.0"
@@ -12,14 +12,34 @@ authors = [{ name = "The Adopt Authors" }]
12
12
  # `adopt_cli` (CR-36) -- sees nothing new.
13
13
  dependencies = [
14
14
  "adopt-agent",
15
+ # Build 3. `commands/ask.py` imports it inside the command body, but the
16
+ # `first-party-deps` gate is about what the distribution needs installed, not
17
+ # about when the import runs: `pip install adopt-cli` must always yield a
18
+ # working `adopt ask`.
19
+ "adopt-ask",
15
20
  "adopt-const",
16
21
  "adopt-coverage",
17
22
  "adopt-detect",
18
23
  "adopt-export",
19
24
  "adopt-freshness",
25
+ # Build 4. `commands/pack.py` imports it inside the command body; the
26
+ # `first-party-deps` gate is about what the distribution needs installed,
27
+ # so `pip install adopt-cli` must always yield a working `adopt pack`.
28
+ "adopt-handover",
20
29
  "adopt-identity",
30
+ "adopt-knowledge",
31
+ "adopt-map",
32
+ # `adopt_model._enums` is imported directly by `commands/init.py` and
33
+ # `commands/boundary.py`. It arrived transitively through `adopt-store` until
34
+ # someone installed a subset that did not include it, which is why an
35
+ # undeclared dependency is a defect even while it happens to resolve.
36
+ "adopt-model",
21
37
  "adopt-obs",
22
38
  "adopt-policy",
39
+ # Build 5. `commands/probe.py` imports it inside the command body; the
40
+ # `first-party-deps` gate is about what the distribution needs installed,
41
+ # so `pip install adopt-cli` must always yield a working `adopt probe run`.
42
+ "adopt-probe",
23
43
  "adopt-scope",
24
44
  "adopt-store",
25
45
  "typer>=0.12,<1",
@@ -0,0 +1,6 @@
1
+ """Generated by scripts/embed_build_info.py; do not edit in a release build."""
2
+
3
+ from typing import Final
4
+
5
+ SBOM_SHA256: Final[str | None] = '86636f02cad8578ffdc6c4b39fe311c0a8191cafd5fcb323ddb732447dc6b0c8'
6
+ BUILD_ID: Final[str | None] = 'github:onboardux/onboard-core:34144382673:1:a1b32208f0a296bc708e283d860023ce7fdf4609'
@@ -0,0 +1,311 @@
1
+ """The ask pipeline, in one place, so `adopt ask` and `adopt serve` cannot diverge.
2
+
3
+ v6.1 says `adopt serve` "exposes the same paths on loopback". *Same* is doing
4
+ real work in that sentence: two implementations of the ask pipeline would be two
5
+ places the freshness check could be skipped, two boundary guards to keep in step
6
+ and two answers to one question depending on which door you came through. So the
7
+ composition root moved here when serve arrived, and both verbs call
8
+ `answer_question`. The payload-parity test asserts what this arrangement makes
9
+ structurally true.
10
+
11
+ **The order below is the contract**, not an implementation detail:
12
+
13
+ refresh the derived index -> retrieve candidates -> resolve freshness for
14
+ every one -> compose -> guard against the boundary -> log/escalate -> render
15
+
16
+ `compose` accepts only candidates already paired with a resolution, so the third
17
+ step cannot be skipped by editing this file: the worst a future edit can do is
18
+ fail to type-check. That is critical semantic invariant #5, and
19
+ `adopt_ask.branch` records why it is a type rather than a check.
20
+
21
+ **The store is opened by the caller**, because the two callers want different
22
+ lifetimes -- one command, versus one request on a server that must not hold a
23
+ connection across threads.
24
+
25
+ **Build 7 gives escalation a second destination.** With a control plane
26
+ configured, a consented escalation is posted to
27
+ `POST /v1/systems/{id}/escalations` and opened in the tenant's canon, where the
28
+ owner is resolved and the draft is routed; with none, it is written to the local
29
+ store exactly as Build 3 wrote it. **The consent decision is unchanged and
30
+ happens first** -- `consented()` still decides whether the text may be stored at
31
+ all, and only then does the destination question arise. Reversing those two
32
+ would make a network configuration able to change what a human agreed to.
33
+
34
+ **Answering is never routed.** Only the escalation is: reading is what a replica
35
+ is for, so `adopt ask` on an operated system answers from the local store at
36
+ local latency and reaches the plane only when a question turns into work
37
+ somebody else has to do.
38
+
39
+ **Synthesis is the last step and changes nothing but the rendering.** It runs
40
+ only when an adapter is configured, only over passages the branch already
41
+ approved, and only if `ground` accepts what came back (invariant #7). The
42
+ `--json` payload is deliberately identical either way except for one added
43
+ `synthesis` block: a caller that scripts on `branch` and `citations` must not
44
+ see a different shape because somebody set `ADOPT_ADAPTER` -- that would make
45
+ the no-model mode a different product rather than a complete one (R3).
46
+ """
47
+
48
+ from collections.abc import Callable, Mapping
49
+ from dataclasses import dataclass
50
+ from typing import Any
51
+
52
+ from adopt_cli.store_option import configured_question_log, configured_search
53
+ from adopt_obs import Clock, SystemClock
54
+
55
+ __all__ = ["AskOutcome", "answer_question"]
56
+
57
+ #: The config key passive logging reads. Named once so the registry entry and
58
+ #: the read have exactly one spelling between them.
59
+ LOG_QUESTIONS_KEY = "ADOPT_ASK_LOG_QUESTIONS"
60
+
61
+
62
+ @dataclass(frozen=True, slots=True)
63
+ class AskOutcome:
64
+ """One answered question: the machine payload, the human text, the branch."""
65
+
66
+ payload: dict[str, Any]
67
+ human: str
68
+ branch: str
69
+ escalation_id: str | None
70
+
71
+
72
+ def answer_question(
73
+ handle: Any,
74
+ question: str,
75
+ *,
76
+ scope: str | None = None,
77
+ reindex: bool = False,
78
+ escalate_flag: bool = False,
79
+ interactive: bool = False,
80
+ confirm: Callable[[str], bool] | None = None,
81
+ resolved_config: Mapping[str, str | None] | None = None,
82
+ synthesize: bool = True,
83
+ ) -> AskOutcome:
84
+ """Answer `question` against the open store `handle`.
85
+
86
+ Args:
87
+ handle: An open, writable store handle. Writable because answering may
88
+ rebuild the retrieval index in the annex beside it -- answering
89
+ itself writes no canon, and `escalate_flag` is the only thing here
90
+ that does.
91
+ question: The question, verbatim.
92
+ scope: `firm/engagement/system/environment`, or `None` for the store's.
93
+ reindex: Rebuild the index even if its stamp says it is current.
94
+ escalate_flag: The `--escalate` flag, or the request's `escalate` field.
95
+ **The action is the consent** (F2), so this alone records the text.
96
+ interactive: Whether a human can answer a prompt. `False` for `--json`
97
+ and for **every** serve request, because an HTTP client is not a
98
+ person and a server that prompted would hang holding a socket.
99
+ confirm: How to ask, injected. `None` in every non-interactive context.
100
+ resolved_config: Config key -> value, for the passive-logging switch.
101
+ Injected so a test can drive the key without the environment.
102
+ synthesize: Whether to attempt the optional synthesis pass. `True` means
103
+ *try*, not *require*: with no adapter configured it is a no-op and
104
+ the extractive answer serves, which is the complete no-model mode
105
+ (R3). Passed `False` by tests that assert the extractive path.
106
+
107
+ Returns:
108
+ The `--json` payload, the human rendering, and the branch.
109
+
110
+ Raises:
111
+ AdoptError: ``ASK_OUTSIDE_BOUNDARY`` when the declared boundary refuses
112
+ the answer, or none is declared; ``ESCALATION_NOT_FOUND`` when a
113
+ consented escalation has no system to attach to.
114
+ """
115
+ from adopt_ask import compose, guard, json_payload, render, retrieve
116
+ from adopt_ask.branch import Resolved
117
+ from adopt_ask.escalate import consented, escalate, may_escalate
118
+ from adopt_ask.questionlog import log_question, should_log
119
+ from adopt_ask.synthesis import SYNTHESIS_PROMPT_REF, render_with
120
+
121
+ from adopt_cli.commands._map_support import resolve_scope
122
+ from adopt_cli.config import resolve_all
123
+ from adopt_detect import BoundaryView
124
+ from adopt_freshness import resolve_freshness
125
+ from adopt_obs import AdoptError, ErrorCode
126
+
127
+ resolved_scope = resolve_scope(handle, scope)
128
+ clock: Clock = handle.clock if handle.clock is not None else SystemClock()
129
+
130
+ with configured_search(handle, clock=clock) as search:
131
+ search.refresh(force=reindex)
132
+ candidates = retrieve(search, question)
133
+ verified = search.verified_in_store(
134
+ [candidate.passage.revision_id for candidate in candidates]
135
+ )
136
+
137
+ freshness_records = handle.freshness_records()
138
+ resolved = tuple(
139
+ Resolved(
140
+ candidate=candidate,
141
+ freshness=resolve_freshness(freshness_records, candidate.passage.item_id, clock=clock),
142
+ )
143
+ for candidate in candidates
144
+ )
145
+
146
+ answer = compose(resolved, verified, question)
147
+
148
+ system = resolved_scope.system
149
+ environment = resolved_scope.environment
150
+ row = (
151
+ None
152
+ if system is None
153
+ else handle.boundary().current(
154
+ system_id=system.id,
155
+ environment_id=None if environment is None else environment.id,
156
+ )
157
+ )
158
+ guard(
159
+ answer,
160
+ None if row is None else BoundaryView.of(row, archetype=None),
161
+ scope=resolved_scope,
162
+ occurred_at=clock.now(),
163
+ )
164
+
165
+ # Passive logging is asked **before** escalation and is entirely separate
166
+ # from it: `--escalate` is consent to store one question, and the config key
167
+ # is an operator's standing decision to store all of them. Reading either
168
+ # from the other would collapse F2's two halves into one.
169
+ config = (
170
+ resolved_config
171
+ if resolved_config is not None
172
+ else {item.key: item.value for item in resolve_all()}
173
+ )
174
+ if should_log(config, key=LOG_QUESTIONS_KEY):
175
+ with configured_question_log(handle) as question_log:
176
+ log_question(
177
+ question_log, answer, scope_ref=resolved_scope.path(), asked_at=clock.now()
178
+ )
179
+
180
+ escalation_id: str | None = None
181
+ routed_to_plane = False
182
+ if consented(answer, escalate_flag=escalate_flag, interactive=interactive, confirm=confirm):
183
+ if system is None:
184
+ raise AdoptError(
185
+ ErrorCode.ESCALATION_NOT_FOUND,
186
+ message=f"the scope {resolved_scope.path()!r} names no system, so there is "
187
+ "nothing to open a question against",
188
+ hint="Pass --scope firm/engagement/system/environment, or open a store whose "
189
+ "default scope resolves to a system.",
190
+ )
191
+ from adopt_cli.commands._remote_support import configured_remote
192
+
193
+ remote = configured_remote(config)
194
+ if remote is None:
195
+ # **The one capture this module performs, and the one write here that
196
+ # is canon.** `ask` and `serve` open the store writable with the
197
+ # guard opted out, because answering rebuilds the retrieval index in
198
+ # the annex and a replica exists to be asked questions. An
199
+ # escalation is different: it lands an `escalation` row that the
200
+ # next `adopt pull` replaces wholesale, so the question a human
201
+ # recorded would be gone with no trace. The check is here rather
202
+ # than at the open because which branch is taken is decided here --
203
+ # a configured remote routes to the plane and writes nothing local.
204
+ from adopt_cli.replica import refuse_write_to_replica
205
+
206
+ refuse_write_to_replica(handle.backend.path, verb="ask --escalate")
207
+ escalation_id = escalate(handle.governance(), answer, system_id=system.id)
208
+ else:
209
+ escalation_id = _escalate_remotely(remote, question)
210
+ routed_to_plane = True
211
+
212
+ payload = dict(json_payload(answer))
213
+ human = render(answer)
214
+
215
+ synthesis = _synthesize(answer, question=question, config=config) if synthesize else None
216
+ if synthesis is not None:
217
+ payload["synthesis"] = {
218
+ "answer_md": synthesis.answer_md,
219
+ "cited_revision_ids": list(synthesis.cited_revision_ids),
220
+ "prompt_ref": SYNTHESIS_PROMPT_REF,
221
+ }
222
+ human = render_with(answer, synthesis)
223
+ if escalation_id is not None:
224
+ payload["escalation_id"] = escalation_id
225
+ payload["routed_to_plane"] = routed_to_plane
226
+ where = "on the control plane" if routed_to_plane else "in this store"
227
+ human += f"\n\nRecorded as open question {escalation_id} {where}."
228
+ human += f'\nAnswer it with: adopt answer {escalation_id} --text "..."'
229
+ if routed_to_plane:
230
+ human += "\nIts owner has been routed a draft reply."
231
+ elif may_escalate(answer) and not escalate_flag:
232
+ human += "\n\nRecord it as an open question with `--escalate`."
233
+
234
+ return AskOutcome(
235
+ payload=payload, human=human, branch=answer.branch, escalation_id=escalation_id
236
+ )
237
+
238
+
239
+ def _synthesize(answer: Any, *, question: str, config: Mapping[str, str | None]) -> Any:
240
+ """One optional model call. `None` for every reason not to have made it.
241
+
242
+ **Every failure here is silent and returns `None`**, because the answer is
243
+ already complete without it: no adapter configured, offline refused, budget
244
+ exhausted, provider error, ungrounded output. Turning any of those into a
245
+ visible failure would make an optional improvement a required dependency,
246
+ which is exactly what R3's no-model mode forbids.
247
+
248
+ The idempotency key is the question plus the cited set, so re-asking an
249
+ unchanged question over unchanged knowledge replays the recorded run instead
250
+ of paying for it again (PRD F13.5) -- and a store whose knowledge has moved
251
+ on asks a genuinely new question.
252
+ """
253
+ import hashlib
254
+
255
+ from adopt_ask.synthesis import synthesize as run_synthesis
256
+
257
+ from adopt_agent import Runner
258
+ from adopt_cli.commands.agent import adapter_settings, prompts_root
259
+ from adopt_cli.store_option import configured_annex
260
+ from adopt_obs import AdoptError
261
+
262
+ if not answer.citations:
263
+ return None
264
+ offline, adapter_id, model, endpoint = adapter_settings()
265
+ if not adapter_id:
266
+ # No adapter is the ordinary case and is not a failure: the extractive
267
+ # answer is the product (`04` §3), not a fallback from one.
268
+ return None
269
+
270
+ material = "\0".join([question, *(c.revision_id for c in answer.citations)])
271
+ key = f"ask-001:{hashlib.sha256(material.encode('utf-8')).hexdigest()}"
272
+
273
+ try:
274
+ with configured_annex() as annex:
275
+ runner = Runner(
276
+ annex=annex,
277
+ scope_ref=question,
278
+ skills_root=prompts_root(),
279
+ offline=offline,
280
+ adapter_id=adapter_id,
281
+ model=model,
282
+ endpoint=endpoint,
283
+ )
284
+ return run_synthesis(runner, answer, idempotency_key=key)
285
+ except AdoptError:
286
+ return None
287
+
288
+
289
+ def _escalate_remotely(remote: Any, question: str) -> str | None:
290
+ """Open the question on the configured plane. Returns its id.
291
+
292
+ **The plane runs the same `adopt_ask` code on its own side**, so the branch
293
+ it decides and the branch decided here agree -- and the id that comes back is
294
+ the plane's, which is the one `adopt answer` will confirm against. Returning
295
+ a local id would be worse than returning none: it would name a row nobody
296
+ else can see, in a command whose whole point is that somebody else acts on it.
297
+
298
+ `None` comes back only when the plane answered `escalated: false`, meaning
299
+ *it* could answer the question. That is a real outcome rather than a failure:
300
+ the replica is behind the plane's canon, which is exactly what `adopt pull`
301
+ is for.
302
+ """
303
+ from adopt_cli.remote import post_json
304
+
305
+ response = post_json(
306
+ remote,
307
+ f"/v1/systems/{remote.system_id}/escalations",
308
+ {"question": question},
309
+ )
310
+ escalation_id = response.get("escalation_id")
311
+ return str(escalation_id) if isinstance(escalation_id, str) else None
@@ -0,0 +1,229 @@
1
+ """The probe half of `adopt ci-sense`: run them here, decide nothing.
2
+
3
+ **Sensing executes where access exists** (v6.1 §6 Build 8 F4). A probe reaches
4
+ a client's own deployment, so the CI is where it can be run at all for every
5
+ system behind a network the plane cannot enter. This module runs the probes the
6
+ local replica declares, through `adopt_probe.runner` -- the one module in the
7
+ programme permitted to open a probe socket -- and renders what they observed
8
+ into the sense payload.
9
+
10
+ **Nothing is stored and nothing is compared.** `execute_probe` is called with
11
+ `records=None` and with a sink that carries the heartbeat instead of writing it,
12
+ so no `probe_run`, no `probe_observation` and no `sensor_heartbeat` lands in the
13
+ replica: `adopt ci-sense` writes nothing locally, because after activation the
14
+ plane is the sole writer of an operated system's canon (R9). The comparison is
15
+ the plane's too, against **its** baseline, for `_ci_sense_support`'s reason -- a
16
+ replica is only as fresh as its last `adopt pull`, and comparing against a stale
17
+ baseline would report drift that a current baseline explains.
18
+
19
+ **What travels is what a comparison needs and nothing more.** The step outputs
20
+ travel because Build 5's `compare_run` is a text comparison and cannot be done
21
+ on digests; they are the client's *own system's* responses, already canon in
22
+ their store, and already redacted of every declared secret by the runner before
23
+ this module can see them. No line of the repository travels here any more than
24
+ it does in the artifact half.
25
+
26
+ **The revision id travels with every result, and it is load-bearing.** A run of
27
+ revision B compared against a baseline of revision A is the *question* changing,
28
+ not the system -- so the plane reports `probe_changed` and produces no change
29
+ event at all. Without the revision id on the wire that distinction is
30
+ unreachable, and every probe edit would arrive as a client incident.
31
+ """
32
+
33
+ from collections.abc import Sequence
34
+ from dataclasses import dataclass, field
35
+ from typing import Any
36
+
37
+ __all__ = ["ProbeSection", "run_payload_probes"]
38
+
39
+
40
+ @dataclass(slots=True)
41
+ class ProbeSection:
42
+ """What the probe half of one ci-sense run observed, ready to be rendered.
43
+
44
+ **Every field that is not a result is a statement about what was not looked
45
+ at**, which is `ProbeDelta`'s rule and its reason: a run that examined
46
+ nothing must not read as a clean one. `reason` says why the half did not run
47
+ at all; `skipped` says which probes had no revision to execute.
48
+ """
49
+
50
+ ran: bool = False
51
+ reason: str | None = None
52
+ results: list[dict[str, Any]] = field(default_factory=list)
53
+ skipped: list[dict[str, str]] = field(default_factory=list)
54
+ heartbeats: list[dict[str, Any]] = field(default_factory=list)
55
+
56
+ def payload(self) -> dict[str, Any]:
57
+ """The `probes` object as the payload carries it.
58
+
59
+ Sorted by probe name for `build_payload`'s reason: the framing fixture
60
+ is compared byte for byte across two repositories, and a section that
61
+ reordered per run could not be pinned.
62
+ """
63
+ return {
64
+ "ran": self.ran,
65
+ "reason": self.reason,
66
+ "results": sorted(self.results, key=lambda row: str(row["probe"])),
67
+ "skipped": sorted(self.skipped, key=lambda row: row["probe"]),
68
+ "heartbeats": sorted(self.heartbeats, key=lambda row: str(row["probe"])),
69
+ }
70
+
71
+ @property
72
+ def drifted_candidates(self) -> int:
73
+ """How many probes completed and could therefore be compared.
74
+
75
+ Named *candidates* rather than *drifted* deliberately: this side does not
76
+ compare anything, so it cannot know what drifted. The count exists for
77
+ the CI log line, where "ran 3, 3 comparable" and "ran 3, 0 comparable"
78
+ are very different mornings.
79
+ """
80
+ return sum(1 for row in self.results if row["outcome"] in ("success", "diff"))
81
+
82
+
83
+ def run_payload_probes(handle: Any, scope: Any, *, allow_network: bool = False) -> ProbeSection:
84
+ """Execute the probes this scope declares and render what they observed.
85
+
86
+ Args:
87
+ handle: The read-only store handle ci-sense already opened. Used for
88
+ **reads only** -- the definitions and their active revisions.
89
+ scope: The resolved four-level scope.
90
+ allow_network: Permits the model adapter for a probe's `prompt` steps.
91
+ An `http` step needs no such flag; the host allow-list is the
92
+ invariant there and it is enforced inside the runner.
93
+
94
+ Returns:
95
+ A `ProbeSection`. `ran=False` with a reason when there is nothing to run,
96
+ which is not the same answer as "ran and found nothing".
97
+ """
98
+ import os
99
+ from contextlib import ExitStack
100
+
101
+ from adopt_probe import execute_probe, parse_probe
102
+
103
+ from adopt_cli.commands._probe_support import (
104
+ active_revision,
105
+ probes_in_scope,
106
+ )
107
+
108
+ definitions = probes_in_scope(handle, scope)
109
+ if not definitions:
110
+ return ProbeSection(ran=False, reason="no probes are defined in this scope")
111
+
112
+ section = ProbeSection(ran=True)
113
+ pairs: list[tuple[Any, Any, Any]] = []
114
+ for probe in definitions:
115
+ revision = active_revision(handle, probe)
116
+ if revision is None:
117
+ section.skipped.append({"probe": probe.name, "reason": "no revision"})
118
+ continue
119
+ if revision.status != "active":
120
+ section.skipped.append(
121
+ {"probe": probe.name, "reason": f"revision is {revision.status}"}
122
+ )
123
+ continue
124
+ pairs.append((probe, revision, parse_probe(revision.capability_manifest)))
125
+
126
+ if not pairs:
127
+ section.ran = False
128
+ section.reason = "no probe in this scope has an active revision"
129
+ return section
130
+
131
+ # The annex stays open for the whole set and closes with the stack, exactly
132
+ # as `execute_in_scope` holds it -- a `prompt` step's model call is metered
133
+ # through the one seam, and opening an annex per probe would make each run's
134
+ # budget its own.
135
+ with ExitStack() as stack:
136
+ agent = _agent_for([spec for _, _, spec in pairs], stack, allow_network=allow_network)
137
+ for probe, revision, spec in pairs:
138
+ sink = _CarriedHeartbeat()
139
+ report = execute_probe(
140
+ spec,
141
+ probe_definition_id=probe.id,
142
+ probe_definition_revision_id=revision.id,
143
+ # **`records=None`, and that is the verb's whole discipline.** A
144
+ # recorded run here would be a canon write from a caller that is
145
+ # not the writer (R9), so nothing lands in the replica: the run,
146
+ # its observations and its heartbeat all travel instead.
147
+ records=None,
148
+ environ=os.environ,
149
+ agent=agent,
150
+ sensor=sink,
151
+ )
152
+ section.results.append(_result(probe, revision, spec, report))
153
+ section.heartbeats.append(sink.payload(probe.name))
154
+
155
+ return section
156
+
157
+
158
+ def _agent_for(specs: Sequence[Any], stack: Any, *, allow_network: bool) -> Any:
159
+ """The model seam, opened only when a probe actually has a `prompt` step.
160
+
161
+ `_probe_support._agent_for` unchanged and by import, rather than a second
162
+ construction of the same runner: the adapter resolution, the annex lifetime
163
+ and the offline refusal are all decisions that must be identical to the
164
+ local loop's, and two spellings of them would differ the first time one was
165
+ edited.
166
+ """
167
+ from adopt_cli.commands._probe_support import _agent_for as build_agent
168
+
169
+ return build_agent(list(specs), stack, allow_network=allow_network, audience="probe")
170
+
171
+
172
+ def _result(probe: Any, revision: Any, spec: Any, report: Any) -> dict[str, Any]:
173
+ """One probe's run, as the payload carries it.
174
+
175
+ `exercises` travels because it is **the only probe -> identity link** (B5's
176
+ `conflict.py` reading, which Build 6's refresh already inherits): the plane
177
+ turns a drift into a change event about the referents the probe declares it
178
+ exercises, and a payload without them would be a finding about nothing.
179
+
180
+ `outputs` and `fingerprints` are parallel to `steps`, in step order, because
181
+ that is the order `compare_run` pairs them against the baseline in. Ordered
182
+ by the loop that ran them rather than sorted -- step 2 compared against step
183
+ 0's baseline is a comparison of two unrelated things that would report drift
184
+ with complete confidence.
185
+ """
186
+ return {
187
+ "probe": probe.name,
188
+ "probe_definition_id": probe.id,
189
+ "probe_definition_revision_id": revision.id,
190
+ "outcome": report.outcome,
191
+ "exercises": list(spec.exercises),
192
+ "outputs": [step.output for step in report.steps],
193
+ "fingerprints": [step.fingerprint for step in report.steps],
194
+ # Already redacted of every declared secret by the runner -- `redact` is
195
+ # applied to a refusal before it reaches the report, because a refusal
196
+ # message is the one place a bad request's interpolated value would
197
+ # otherwise appear.
198
+ "refusal": report.refusal,
199
+ }
200
+
201
+
202
+ class _CarriedHeartbeat:
203
+ """A `SensorSink` that carries the fact instead of writing it.
204
+
205
+ v6.1 §6 B5 requires probe runs to *"emit per-run sensor-health facts locally
206
+ so Builds 6/8 treat probes as sensors without rework"*. In CI there is no
207
+ local sensor worth writing to -- the replica's health is nobody's oracle,
208
+ and a heartbeat there would leave the plane's sensor (the row
209
+ `resolve_freshness` actually reads) hearing nothing while the scope looked
210
+ healthy. So the fact travels.
211
+
212
+ **A sink rather than a mapping of outcome to health**, which was the first
213
+ spelling here. The runner already owns that mapping and applies it before
214
+ calling this; re-deriving it on this side would be a second copy of a rule
215
+ whose two halves would differ the first time either was edited -- and the
216
+ symptom would be a scope whose sensor health disagreed with the run that
217
+ produced it.
218
+ """
219
+
220
+ def __init__(self) -> None:
221
+ self.outcome: str | None = None
222
+ self.detail: str | None = None
223
+
224
+ def heartbeat(self, *, outcome: str, detail: str | None = None) -> None:
225
+ self.outcome = outcome
226
+ self.detail = detail
227
+
228
+ def payload(self, probe_name: str) -> dict[str, Any]:
229
+ return {"probe": probe_name, "outcome": self.outcome, "detail": self.detail}