adopt-cli 0.4.1__tar.gz → 0.4.2__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 (55) hide show
  1. {adopt_cli-0.4.1 → adopt_cli-0.4.2}/PKG-INFO +1 -1
  2. {adopt_cli-0.4.1 → adopt_cli-0.4.2}/pyproject.toml +1 -1
  3. adopt_cli-0.4.2/src/adopt_cli/_build_info.py +6 -0
  4. {adopt_cli-0.4.1 → adopt_cli-0.4.2}/src/adopt_cli/commands/_ask_support.py +44 -3
  5. adopt_cli-0.4.2/src/adopt_cli/commands/_conflict_support.py +52 -0
  6. adopt_cli-0.4.2/src/adopt_cli/commands/_coverage_support.py +69 -0
  7. {adopt_cli-0.4.1 → adopt_cli-0.4.2}/src/adopt_cli/commands/_handover_support.py +87 -0
  8. {adopt_cli-0.4.1 → adopt_cli-0.4.2}/src/adopt_cli/commands/_pack_support.py +4 -3
  9. {adopt_cli-0.4.1 → adopt_cli-0.4.2}/src/adopt_cli/commands/answer.py +23 -18
  10. {adopt_cli-0.4.1 → adopt_cli-0.4.2}/src/adopt_cli/commands/handover.py +78 -5
  11. {adopt_cli-0.4.1 → adopt_cli-0.4.2}/src/adopt_cli/commands/interchange.py +5 -0
  12. {adopt_cli-0.4.1 → adopt_cli-0.4.2}/src/adopt_cli/commands/knowledge.py +90 -80
  13. {adopt_cli-0.4.1 → adopt_cli-0.4.2}/src/adopt_cli/commands/refresh.py +13 -8
  14. adopt_cli-0.4.1/src/adopt_cli/_build_info.py +0 -6
  15. {adopt_cli-0.4.1 → adopt_cli-0.4.2}/.gitignore +0 -0
  16. {adopt_cli-0.4.1 → adopt_cli-0.4.2}/LICENSE +0 -0
  17. {adopt_cli-0.4.1 → adopt_cli-0.4.2}/NOTICE +0 -0
  18. {adopt_cli-0.4.1 → adopt_cli-0.4.2}/src/adopt_cli/__init__.py +0 -0
  19. {adopt_cli-0.4.1 → adopt_cli-0.4.2}/src/adopt_cli/commands/__init__.py +0 -0
  20. {adopt_cli-0.4.1 → adopt_cli-0.4.2}/src/adopt_cli/commands/_ci_sense_probes.py +0 -0
  21. {adopt_cli-0.4.1 → adopt_cli-0.4.2}/src/adopt_cli/commands/_ci_sense_support.py +0 -0
  22. {adopt_cli-0.4.1 → adopt_cli-0.4.2}/src/adopt_cli/commands/_draft_support.py +0 -0
  23. {adopt_cli-0.4.1 → adopt_cli-0.4.2}/src/adopt_cli/commands/_drafting.py +0 -0
  24. {adopt_cli-0.4.1 → adopt_cli-0.4.2}/src/adopt_cli/commands/_knowledge_support.py +0 -0
  25. {adopt_cli-0.4.1 → adopt_cli-0.4.2}/src/adopt_cli/commands/_map_support.py +0 -0
  26. {adopt_cli-0.4.1 → adopt_cli-0.4.2}/src/adopt_cli/commands/_probe_support.py +0 -0
  27. {adopt_cli-0.4.1 → adopt_cli-0.4.2}/src/adopt_cli/commands/_refresh_support.py +0 -0
  28. {adopt_cli-0.4.1 → adopt_cli-0.4.2}/src/adopt_cli/commands/_remote_support.py +0 -0
  29. {adopt_cli-0.4.1 → adopt_cli-0.4.2}/src/adopt_cli/commands/_review_remote.py +0 -0
  30. {adopt_cli-0.4.1 → adopt_cli-0.4.2}/src/adopt_cli/commands/agent.py +0 -0
  31. {adopt_cli-0.4.1 → adopt_cli-0.4.2}/src/adopt_cli/commands/ask.py +0 -0
  32. {adopt_cli-0.4.1 → adopt_cli-0.4.2}/src/adopt_cli/commands/boundary.py +0 -0
  33. {adopt_cli-0.4.1 → adopt_cli-0.4.2}/src/adopt_cli/commands/ci_sense.py +0 -0
  34. {adopt_cli-0.4.1 → adopt_cli-0.4.2}/src/adopt_cli/commands/coverage.py +0 -0
  35. {adopt_cli-0.4.1 → adopt_cli-0.4.2}/src/adopt_cli/commands/detect.py +0 -0
  36. {adopt_cli-0.4.1 → adopt_cli-0.4.2}/src/adopt_cli/commands/doctor.py +0 -0
  37. {adopt_cli-0.4.1 → adopt_cli-0.4.2}/src/adopt_cli/commands/draft.py +0 -0
  38. {adopt_cli-0.4.1 → adopt_cli-0.4.2}/src/adopt_cli/commands/freshness.py +0 -0
  39. {adopt_cli-0.4.1 → adopt_cli-0.4.2}/src/adopt_cli/commands/identity.py +0 -0
  40. {adopt_cli-0.4.1 → adopt_cli-0.4.2}/src/adopt_cli/commands/init.py +0 -0
  41. {adopt_cli-0.4.1 → adopt_cli-0.4.2}/src/adopt_cli/commands/map_command.py +0 -0
  42. {adopt_cli-0.4.1 → adopt_cli-0.4.2}/src/adopt_cli/commands/pack.py +0 -0
  43. {adopt_cli-0.4.1 → adopt_cli-0.4.2}/src/adopt_cli/commands/policy.py +0 -0
  44. {adopt_cli-0.4.1 → adopt_cli-0.4.2}/src/adopt_cli/commands/probe.py +0 -0
  45. {adopt_cli-0.4.1 → adopt_cli-0.4.2}/src/adopt_cli/commands/pull.py +0 -0
  46. {adopt_cli-0.4.1 → adopt_cli-0.4.2}/src/adopt_cli/commands/serve.py +0 -0
  47. {adopt_cli-0.4.1 → adopt_cli-0.4.2}/src/adopt_cli/commands/store.py +0 -0
  48. {adopt_cli-0.4.1 → adopt_cli-0.4.2}/src/adopt_cli/commands/version.py +0 -0
  49. {adopt_cli-0.4.1 → adopt_cli-0.4.2}/src/adopt_cli/config.py +0 -0
  50. {adopt_cli-0.4.1 → adopt_cli-0.4.2}/src/adopt_cli/json_out.py +0 -0
  51. {adopt_cli-0.4.1 → adopt_cli-0.4.2}/src/adopt_cli/main.py +0 -0
  52. {adopt_cli-0.4.1 → adopt_cli-0.4.2}/src/adopt_cli/py.typed +0 -0
  53. {adopt_cli-0.4.1 → adopt_cli-0.4.2}/src/adopt_cli/remote.py +0 -0
  54. {adopt_cli-0.4.1 → adopt_cli-0.4.2}/src/adopt_cli/replica.py +0 -0
  55. {adopt_cli-0.4.1 → adopt_cli-0.4.2}/src/adopt_cli/store_option.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: adopt-cli
3
- Version: 0.4.1
3
+ Version: 0.4.2
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "adopt-cli"
3
- version = "0.4.1"
3
+ version = "0.4.2"
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"
@@ -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] = 'd9c82e3f0197e2d81b8ef92be4bdbb2f9f38710ee54d8db9ca3baa099047fda8'
6
+ BUILD_ID: Final[str | None] = 'github:onboardux/onboard-core:37286165801:1:b50f2ec2a85b4271c0d445d8432b105574a8978a'
@@ -114,7 +114,7 @@ def answer_question(
114
114
  """
115
115
  from adopt_ask import compose, guard, json_payload, render, retrieve
116
116
  from adopt_ask.branch import Resolved
117
- from adopt_ask.escalate import consented, escalate, may_escalate
117
+ from adopt_ask.escalate import consented, escalate, may_dispute, may_escalate
118
118
  from adopt_ask.questionlog import log_question, should_log
119
119
  from adopt_ask.synthesis import SYNTHESIS_PROMPT_REF, render_with
120
120
 
@@ -134,11 +134,22 @@ def answer_question(
134
134
  [candidate.passage.revision_id for candidate in candidates]
135
135
  )
136
136
 
137
+ from adopt_cli.commands._conflict_support import contradicted_now
138
+
137
139
  freshness_records = handle.freshness_records()
140
+ # A revision a drifted probe currently contradicts is served STALE, whatever
141
+ # its bindings say, so `ask` agrees with the pack's "Contradicted by
142
+ # observation" from the moment `adopt probe run` records the conflict rather
143
+ # than from the next `refresh` (independence transcript T3a).
144
+ contradicted = contradicted_now(handle) if candidates else frozenset()
138
145
  resolved = tuple(
139
146
  Resolved(
140
147
  candidate=candidate,
141
- freshness=resolve_freshness(freshness_records, candidate.passage.item_id, clock=clock),
148
+ freshness=_unless_contradicted(
149
+ resolve_freshness(freshness_records, candidate.passage.item_id, clock=clock),
150
+ candidate.passage.revision_id,
151
+ contradicted,
152
+ ),
142
153
  )
143
154
  for candidate in candidates
144
155
  )
@@ -224,10 +235,21 @@ def answer_question(
224
235
  payload["escalation_id"] = escalation_id
225
236
  payload["routed_to_plane"] = routed_to_plane
226
237
  where = "on the control plane" if routed_to_plane else "in this store"
227
- human += f"\n\nRecorded as open question {escalation_id} {where}."
238
+ if may_dispute(answer):
239
+ human += f"\n\nReported as wrong: open question {escalation_id} {where}."
240
+ else:
241
+ human += f"\n\nRecorded as open question {escalation_id} {where}."
228
242
  human += f'\nAnswer it with: adopt answer {escalation_id} --text "..."'
229
243
  if routed_to_plane:
230
244
  human += "\nIts owner has been routed a draft reply."
245
+ elif may_dispute(answer) and escalate_flag:
246
+ # Remote mode only: the plane composes its own answer, and opens no
247
+ # question for one it answered KNOWN. Said out loud, because the silent
248
+ # version of this (exit 0, nothing recorded) is the defect being fixed.
249
+ human += (
250
+ "\n\nThe control plane answered this KNOWN and recorded no dispute. "
251
+ "Tell the system's owner directly, or correct the cited item with `adopt review`."
252
+ )
231
253
  elif may_escalate(answer) and not escalate_flag:
232
254
  human += "\n\nRecord it as an open question with `--escalate`."
233
255
 
@@ -236,6 +258,25 @@ def answer_question(
236
258
  )
237
259
 
238
260
 
261
+ def _unless_contradicted(resolution: Any, revision_id: str, contradicted: frozenset[str]) -> Any:
262
+ """`resolution`, or STALE with `contradicted_by_observation` if a probe disagrees.
263
+
264
+ Only a resolution that would otherwise serve as KNOWN is replaced: an item
265
+ already stale keeps the rule that staled it, because that rule is the more
266
+ specific statement of what went wrong.
267
+ """
268
+ from adopt_freshness import RULE_CONTRADICTED_BY_OBSERVATION, FreshnessResolution
269
+
270
+ if revision_id not in contradicted or resolution.state not in ("fresh", "unverified"):
271
+ return resolution
272
+ return FreshnessResolution(
273
+ item_id=resolution.item_id,
274
+ state="stale",
275
+ level="knowledge_revision",
276
+ deciding_rule=RULE_CONTRADICTED_BY_OBSERVATION,
277
+ )
278
+
279
+
239
280
  def _synthesize(answer: Any, *, question: str, config: Mapping[str, str | None]) -> Any:
240
281
  """One optional model call. `None` for every reason not to have made it.
241
282
 
@@ -0,0 +1,52 @@
1
+ """The two reads every surface needs to agree about a probe contradiction.
2
+
3
+ `adopt gaps`, `adopt pack`, `adopt handover` and `adopt ask` all report on Bet 4's
4
+ `conflict` rows, and until 2026-10-05 they disagreed: the pack listed a runbook
5
+ as "Contradicted by observation" while `ask` served it KNOWN and fresh
6
+ (independence transcript T3a). Each now applies the same rule
7
+ (`adopt_knowledge.rank_conflicts` / `contradicted_revisions`), fed by these two
8
+ reads.
9
+
10
+ Both are `table_rows` reads -- Build 4's report pattern -- so no query path is
11
+ added to any realized port and the plane's escape-coverage denominator does not
12
+ move.
13
+ """
14
+
15
+ from typing import Any
16
+
17
+ __all__ = ["conflict_rows", "contradicted_now", "superseded_revisions"]
18
+
19
+
20
+ def conflict_rows(handle: Any) -> tuple[Any, ...]:
21
+ """Every `conflict` row in the store."""
22
+ from adopt_model import Conflict
23
+
24
+ return tuple(handle.export_records().table_rows("conflict", Conflict))
25
+
26
+
27
+ def superseded_revisions(handle: Any) -> frozenset[str]:
28
+ """Knowledge revision ids that are no longer their item's head.
29
+
30
+ A conflict against one of these contradicts a claim nobody is making any
31
+ more: the item was re-confirmed or rewritten after the probe drifted.
32
+ """
33
+ from adopt_model import KnowledgeItem, KnowledgeRevision
34
+
35
+ records = handle.export_records()
36
+ heads = {
37
+ item.current_revision_id
38
+ for item in records.table_rows("knowledge_item", KnowledgeItem)
39
+ if item.current_revision_id is not None
40
+ }
41
+ return frozenset(
42
+ revision.id
43
+ for revision in records.table_rows("knowledge_revision", KnowledgeRevision)
44
+ if revision.id not in heads
45
+ )
46
+
47
+
48
+ def contradicted_now(handle: Any) -> frozenset[str]:
49
+ """Head revisions a drifted probe currently contradicts."""
50
+ from adopt_knowledge import contradicted_revisions
51
+
52
+ return contradicted_revisions(conflict_rows(handle), superseded=superseded_revisions(handle))
@@ -0,0 +1,69 @@
1
+ """Keeping the coverage cache current after a command that itself changed coverage.
2
+
3
+ **The alarm was firing on the product's own writes.** `identity.covered_cache`
4
+ is rebuilt *from* `recompute_coverage()` and never the reverse, and any
5
+ disagreement between the two alarms (`COVERAGE_CACHE_DISAGREEMENT`) rather than
6
+ self-healing. But only `adopt coverage recompute --rebuild` ever wrote the
7
+ cache, so every `ingest`, `bind`, `answer`, review resolution and `refresh`
8
+ left it disagreeing, and the next `adopt gaps` paged about a change the product
9
+ had just made on purpose. An alarm that fires on every ordinary write is one an
10
+ operator learns to ignore, which is exactly when the real one -- a cache some
11
+ defect wrote -- would go unread.
12
+
13
+ **The rule that keeps the alarm meaningful: refresh only a cache that agreed.**
14
+ The command recomputes first. If the cache already disagreed, something other
15
+ than this command wrote it, and `store.py`'s incident rule applies -- rebuilding
16
+ a disagreeing cache destroys the evidence of whatever wrote it -- so the cache is
17
+ left alone and the alarm keeps firing. Only when it agreed beforehand does the
18
+ command rebuild it after its own writes, from a recompute that does not compare,
19
+ because the comparison would only report the change this command intended.
20
+
21
+ The rebuild goes through `adopt_coverage.rebuild_cache`, so `no-covered-cache-write`
22
+ still holds: the CLI decides *when*, and `adopt_coverage` remains the only writer.
23
+ """
24
+
25
+ from collections.abc import Iterator
26
+ from contextlib import contextmanager
27
+ from typing import Protocol
28
+
29
+ from adopt_coverage import CacheWriter, CoverageRecords
30
+
31
+ __all__ = ["CoverageStore", "coverage_cache_kept_current"]
32
+
33
+
34
+ class CoverageStore(Protocol):
35
+ """The two things this module needs from a store handle, structurally.
36
+
37
+ Declared rather than importing the SQLite handle, so `no-raw-sqlite` holds:
38
+ `adopt_coverage`'s own ports are the whole surface.
39
+ """
40
+
41
+ @property
42
+ def backend(self) -> CacheWriter: ...
43
+ def coverage_records(self) -> CoverageRecords: ...
44
+
45
+
46
+ @contextmanager
47
+ def coverage_cache_kept_current(handle: CoverageStore, system_id: str | None) -> Iterator[None]:
48
+ """Wrap a command body that changes coverage inputs for `system_id`.
49
+
50
+ Args:
51
+ handle: An open, writable store handle.
52
+ system_id: The system whose coverage the body may change. `None` (a
53
+ command that resolved no system) wraps nothing.
54
+
55
+ The post-write rebuild runs only when the body returns normally: a body that
56
+ raised has rolled its own transaction back, so there is nothing to follow.
57
+ """
58
+ if system_id is None:
59
+ yield
60
+ return
61
+
62
+ from adopt_coverage import rebuild_cache, recompute_coverage
63
+
64
+ before = recompute_coverage(handle.coverage_records(), system_id)
65
+ yield
66
+ if before.disagreements:
67
+ return
68
+ after = recompute_coverage(handle.coverage_records(), system_id, compare_cache=False)
69
+ rebuild_cache(handle.backend, after)
@@ -45,11 +45,13 @@ __all__ = [
45
45
  "ResolvedSystem",
46
46
  "close_handover",
47
47
  "current_owner_of",
48
+ "independence_of",
48
49
  "open_conflicts",
49
50
  "open_gaps",
50
51
  "open_questions",
51
52
  "opening_position",
52
53
  "records_for",
54
+ "referent_facts",
53
55
  "refuse_if_replica",
54
56
  "require_open",
55
57
  "resolve_system",
@@ -345,6 +347,91 @@ def open_conflicts(
345
347
  return build_conflicts(handle, uris=uris)
346
348
 
347
349
 
350
+ def referent_facts(handle: Any, *, system: ResolvedSystem, uris: set[str]) -> dict[str, Any]:
351
+ """What is true now of each drilled referent -- the input `validity.assess` judges.
352
+
353
+ Every read is a report read (`table_rows`, or a coverage port method that
354
+ already exists), so independence status adds no query path to any realized
355
+ port. The URI is looked up **exactly**, not through `resolve_identity`'s alias
356
+ walk: following a moved identity to its successor would report the successor
357
+ `active` and hide the very move that invalidates the drill.
358
+ """
359
+ from adopt_handover.validity import ReferentFacts
360
+
361
+ from adopt_cli.commands._conflict_support import conflict_rows, superseded_revisions
362
+ from adopt_freshness import resolve_freshness
363
+ from adopt_model import Classification, Identity
364
+
365
+ if not uris:
366
+ return {}
367
+ records = handle.export_records()
368
+ by_uri = {row.uri: row for row in records.table_rows("identity", Identity) if row.uri in uris}
369
+ coverage = handle.coverage_records()
370
+ statuses = coverage.head_identity_statuses(system_id=system.system_id, environment_id=None)
371
+ wanted = {row.id for row in by_uri.values()}
372
+
373
+ changes: dict[str, list[tuple[str, _dt.datetime]]] = {}
374
+ for row in records.table_rows("classification", Classification):
375
+ if row.identity_id in wanted:
376
+ changes.setdefault(row.identity_id, []).append((str(row.class_), row.created_at))
377
+
378
+ binding_statuses = coverage.head_binding_statuses(
379
+ system_id=system.system_id, environment_id=None
380
+ )
381
+ verifications = coverage.head_item_verifications(system_id=system.system_id)
382
+ items_by_identity: dict[str, set[str]] = {}
383
+ for binding in coverage.bindings_in_scope(system_id=system.system_id, environment_id=None):
384
+ if (
385
+ binding.identity_id in wanted
386
+ and binding.is_load_bearing
387
+ and binding_statuses.get(binding.id) == "active"
388
+ and verifications.get(binding.item_id) == "verified"
389
+ ):
390
+ items_by_identity.setdefault(binding.identity_id, set()).add(binding.item_id)
391
+
392
+ superseded = superseded_revisions(handle)
393
+ contradicted_identities = {
394
+ row.identity_id
395
+ for row in conflict_rows(handle)
396
+ if str(row.disposition) == "open"
397
+ and row.intent_revision_id is not None
398
+ and row.intent_revision_id not in superseded
399
+ }
400
+
401
+ freshness_records = handle.freshness_records()
402
+ facts: dict[str, Any] = {}
403
+ for uri in uris:
404
+ identity = by_uri.get(uri)
405
+ if identity is None:
406
+ facts[uri] = ReferentFacts(found=False)
407
+ continue
408
+ stale_rule: str | None = None
409
+ degraded_rule: str | None = None
410
+ for item_id in sorted(items_by_identity.get(identity.id, ())):
411
+ resolution = resolve_freshness(freshness_records, item_id)
412
+ if resolution.state == "stale" and stale_rule is None:
413
+ stale_rule = resolution.deciding_rule
414
+ elif resolution.state == "observation_stale" and degraded_rule is None:
415
+ degraded_rule = resolution.deciding_rule
416
+ facts[uri] = ReferentFacts(
417
+ found=True,
418
+ status=statuses.get(identity.id),
419
+ changes=tuple(changes.get(identity.id, ())),
420
+ contradicted=identity.id in contradicted_identities,
421
+ stale_rule=stale_rule,
422
+ degraded_rule=degraded_rule,
423
+ )
424
+ return facts
425
+
426
+
427
+ def independence_of(handle: Any, *, system: ResolvedSystem, record: Any) -> Any:
428
+ """Each passed exit test's validity now (`adopt_handover.validity`)."""
429
+ from adopt_handover.validity import assess, passed_tasks
430
+
431
+ uris = {task.uri for task in passed_tasks(record) if task.uri is not None}
432
+ return assess(record, referent_facts(handle, system=system, uris=uris))
433
+
434
+
348
435
  def open_questions(handle: Any, *, system_id: str) -> tuple[OpenQuestion, ...]:
349
436
  """Unanswered questions for this system, newest first."""
350
437
  return tuple(
@@ -203,9 +203,8 @@ def build_conflicts(handle: Any, *, uris: dict[str, str]) -> tuple[PackConflict,
203
203
  """
204
204
  from adopt_knowledge import rank_conflicts
205
205
 
206
- from adopt_model import Conflict
206
+ from adopt_cli.commands._conflict_support import conflict_rows, superseded_revisions
207
207
 
208
- rows = handle.export_records().table_rows("conflict", Conflict)
209
208
  return tuple(
210
209
  PackConflict(
211
210
  uri=conflict.uri,
@@ -213,7 +212,9 @@ def build_conflicts(handle: Any, *, uris: dict[str, str]) -> tuple[PackConflict,
213
212
  intent_revision_id=conflict.intent_revision_id,
214
213
  detected_at=conflict.detected_at,
215
214
  )
216
- for conflict in rank_conflicts(rows, uris)
215
+ for conflict in rank_conflicts(
216
+ conflict_rows(handle), uris, superseded=superseded_revisions(handle)
217
+ )
217
218
  )
218
219
 
219
220
 
@@ -169,24 +169,29 @@ def answer(
169
169
  identity_ids, unmatched = identities_to_bind(
170
170
  text, identity_views(handle, resolved_scope), explicit_uris=uri or []
171
171
  )
172
- result = capture_answer(
173
- _StoreAdapter(handle),
174
- escalation_id=escalation_id,
175
- scope=resolved_scope,
176
- # The question is the title, so the captured row is findable by the
177
- # words somebody actually asked. Falling back to the answer's first
178
- # line would title it with whatever the human happened to type
179
- # first, which is not what the next asker will search for.
180
- title=title or existing.question or f"Answer to {escalation_id}",
181
- body_md=text,
182
- identity_ids=identity_ids,
183
- # `DEFAULT_AUDIENCES` rather than a literal here: the default lives
184
- # in `adopt_ask.capture` beside the rule it protects, so the CLI and
185
- # the plane cannot drift into tagging captured answers differently.
186
- audiences=tuple(audience) if audience else DEFAULT_AUDIENCES,
187
- actor_id=actor,
188
- unmatched_uris=unmatched,
189
- )
172
+ from adopt_cli.commands._coverage_support import coverage_cache_kept_current
173
+
174
+ system_id = resolved_scope.system.id if resolved_scope.system is not None else None
175
+ with coverage_cache_kept_current(handle, system_id):
176
+ result = capture_answer(
177
+ _StoreAdapter(handle),
178
+ escalation_id=escalation_id,
179
+ scope=resolved_scope,
180
+ # The question is the title, so the captured row is findable by
181
+ # the words somebody actually asked. Falling back to the answer's
182
+ # first line would title it with whatever the human happened to
183
+ # type first, which is not what the next asker will search for.
184
+ title=title or existing.question or f"Answer to {escalation_id}",
185
+ body_md=text,
186
+ identity_ids=identity_ids,
187
+ # `DEFAULT_AUDIENCES` rather than a literal here: the default
188
+ # lives in `adopt_ask.capture` beside the rule it protects, so the
189
+ # CLI and the plane cannot drift into tagging captured answers
190
+ # differently.
191
+ audiences=tuple(audience) if audience else DEFAULT_AUDIENCES,
192
+ actor_id=actor,
193
+ unmatched_uris=unmatched,
194
+ )
190
195
 
191
196
  payload = {
192
197
  "escalation_id": result.escalation_id,
@@ -64,6 +64,14 @@ OutOption = Annotated[
64
64
  ]
65
65
  StoreOption = Annotated[Path | None, typer.Option("--store", help="Path to the store.")]
66
66
  JsonOption = Annotated[bool, typer.Option("--json", help="Machine-readable output.")]
67
+ StrictOption = Annotated[
68
+ bool,
69
+ typer.Option(
70
+ "--strict",
71
+ help="Exit 4 when any exit test the receiving team passed has since been invalidated "
72
+ "by a change. For a pipeline that gates on independence; off by default.",
73
+ ),
74
+ ]
67
75
  AudienceOption = Annotated[
68
76
  list[str] | None,
69
77
  typer.Option("--audience", help="Emit this audience. Repeatable; defaults to all four."),
@@ -433,8 +441,18 @@ def verify(
433
441
  question=question_for(task),
434
442
  )
435
443
  opened.append({"task_id": task.id, "escalation_id": escalation_id})
444
+ # `uri` is what lets the result go stale: it names the referent the
445
+ # task exercised, so a later change to it invalidates this pass
446
+ # (`adopt_handover.validity`). Until 2026-10-05 it was parsed and
447
+ # then dropped, and a passed task could never be invalidated.
436
448
  outcomes.append(
437
- {"id": task.id, "outcome": task.outcome, "escalation_id": escalation_id}
449
+ {
450
+ "id": task.id,
451
+ "outcome": task.outcome,
452
+ "escalation_id": escalation_id,
453
+ "uri": task.uri,
454
+ "performed_by": task.performed_by,
455
+ }
438
456
  )
439
457
 
440
458
  detail = {
@@ -644,14 +662,21 @@ def close(
644
662
  def status(
645
663
  system: SystemOption = None,
646
664
  scope: ScopeOption = None,
665
+ strict: StrictOption = False,
647
666
  store: StoreOption = None,
648
667
  json_output: JsonOption = False,
649
668
  ) -> None:
650
- """Every step recorded, what comes next, and what is still open.
669
+ """Every step recorded, what comes next, what is still open -- and what still holds.
651
670
 
652
671
  Reads only, and answers for a closed handover as readily as an open one --
653
672
  the record is what both parties keep, and it must still describe itself a
654
673
  year later.
674
+
675
+ **Independence leads.** Each exit test the receiving team passed is judged
676
+ against what has changed since (`adopt_handover.validity`): `valid`,
677
+ `invalidated` with its cause, `degraded`, or `unanchored` when the task named
678
+ no `uri`. A drill result that could not go stale would be a certificate;
679
+ this is the proof that says when it stopped being true.
655
680
  """
656
681
  from adopt_handover import VERB_FOR, record_payload
657
682
 
@@ -700,12 +725,18 @@ def status(
700
725
  system_owner=owner,
701
726
  written_by=writer_identity(),
702
727
  ),
728
+ "independence": _independence_payload(
729
+ support.independence_of(handle, system=resolved, record=record)
730
+ ),
703
731
  }
704
732
  finally:
705
733
  handle.close()
706
734
 
735
+ invalidated = bool(payload.get("independence", {}).get("invalidated"))
707
736
  if json_output:
708
737
  emit(payload, as_json=True)
738
+ if strict and invalidated:
739
+ raise typer.Exit(4) # const-sync: ok -- exit code, not a version.
709
740
  return
710
741
  if payload["state"] == "none":
711
742
  typer.echo(
@@ -713,12 +744,22 @@ def status(
713
744
  "`adopt handover start --receiving-owner <group>` opens one."
714
745
  )
715
746
  return
747
+ independence = payload["independence"]
716
748
  lines = [
717
749
  f"Handover {payload['handover_id']} [{payload['state']}] {payload['scope']}",
718
- f" receiving owner: {payload['receiving_owner']}",
719
- f" owner now: {payload['current_owner'] or 'nobody'}",
720
- "",
750
+ f" independence: {independence['summary']}",
721
751
  ]
752
+ for task in independence["tasks"]:
753
+ if task["status"] != "valid":
754
+ since = f" since {task['since']}" if task["since"] else ""
755
+ lines.append(f" {task['status']}: {task['task_id']} ({task['cause']}){since}")
756
+ lines.extend(
757
+ [
758
+ f" receiving owner: {payload['receiving_owner']}",
759
+ f" owner now: {payload['current_owner'] or 'nobody'}",
760
+ "",
761
+ ]
762
+ )
722
763
  for step in payload["steps"]:
723
764
  mark = "x" if step["done"] else " "
724
765
  when = f" {step['at']}" if step["at"] else ""
@@ -740,6 +781,38 @@ def status(
740
781
  "unreadable detail; the trail was edited outside this product."
741
782
  )
742
783
  typer.echo("\n".join(lines))
784
+ if strict and invalidated:
785
+ raise typer.Exit(4) # const-sync: ok -- exit code, not a version.
786
+
787
+
788
+ def _independence_payload(independence: Any) -> dict[str, Any]:
789
+ """The `independence` block of `adopt handover status --json`."""
790
+ from adopt_handover.validity import DEGRADED, INVALIDATED, UNANCHORED, VALID
791
+
792
+ from adopt_obs import format_timestamp
793
+
794
+ def stamp(value: Any) -> str | None:
795
+ return None if value is None else format_timestamp(value)
796
+
797
+ return {
798
+ "summary": independence.summary,
799
+ "passed": independence.passed,
800
+ "valid": independence.count(VALID),
801
+ "invalidated": independence.count(INVALIDATED),
802
+ "degraded": independence.count(DEGRADED),
803
+ "unanchored": independence.count(UNANCHORED),
804
+ "tasks": [
805
+ {
806
+ "task_id": task.task_id,
807
+ "uri": task.uri,
808
+ "status": task.status,
809
+ "cause": task.cause,
810
+ "verified_at": stamp(task.verified_at),
811
+ "since": stamp(task.since),
812
+ }
813
+ for task in independence.tasks
814
+ ],
815
+ }
743
816
 
744
817
 
745
818
  def _write_acceptance(
@@ -87,6 +87,11 @@ def import_(
87
87
  json_output: JsonOption = False,
88
88
  ) -> None:
89
89
  """Verify a bundle whole, then restore it into an empty store."""
90
+ # `--into` is "created empty if absent", and that has to include its
91
+ # directory: the restore is often a beneficiary's first command on their own
92
+ # machine, and SQLite refuses a path whose parent does not exist with a raw
93
+ # `OperationalError` rather than anything an operator can act on.
94
+ into.parent.mkdir(parents=True, exist_ok=True)
90
95
  with open_named_store(into, migrate=True) as handle:
91
96
  manifest = apply_bundle(handle.import_records(), directory)
92
97
  emit(_payload(directory, manifest), as_json=json_output, title="adopt import")
@@ -60,6 +60,7 @@ def ingest(
60
60
  """Turn documents into knowledge, bound to the identities they refer to."""
61
61
  from adopt_knowledge import discover, run_ingest
62
62
 
63
+ from adopt_cli.commands._coverage_support import coverage_cache_kept_current
63
64
  from adopt_cli.commands._knowledge_support import (
64
65
  StoreUnitOfWork,
65
66
  bound_pairs,
@@ -73,19 +74,21 @@ def ingest(
73
74
  try:
74
75
  resolved = resolve_scope(handle, scope)
75
76
  documents = discover(paths, root=Path.cwd(), audience=audience)
76
- report = run_ingest(
77
- documents,
78
- scope=resolved,
79
- identities=identity_views(handle, resolved),
80
- stored=stored_documents(handle, resolved),
81
- knowledge=handle.items(),
82
- bindings=handle.bindings(),
83
- reviews=handle.governance(),
84
- unit=StoreUnitOfWork(handle),
85
- bound_pairs=bound_pairs(handle),
86
- presented_revisions=presented_revisions(handle),
87
- actor_id=actor,
88
- )
77
+ system_id = resolved.system.id if resolved.system is not None else None
78
+ with coverage_cache_kept_current(handle, system_id):
79
+ report = run_ingest(
80
+ documents,
81
+ scope=resolved,
82
+ identities=identity_views(handle, resolved),
83
+ stored=stored_documents(handle, resolved),
84
+ knowledge=handle.items(),
85
+ bindings=handle.bindings(),
86
+ reviews=handle.governance(),
87
+ unit=StoreUnitOfWork(handle),
88
+ bound_pairs=bound_pairs(handle),
89
+ presented_revisions=presented_revisions(handle),
90
+ actor_id=actor,
91
+ )
89
92
  payload = _ingest_payload(report)
90
93
  finally:
91
94
  handle.close()
@@ -307,14 +310,17 @@ def bind(
307
310
  hint="Run `adopt map` first. A moved identity's old URI still resolves, so "
308
311
  "this is genuine absence rather than a stale address.",
309
312
  )
310
- binding_id, revision_id = handle.bindings().bind(
311
- item_id=knowledge_id,
312
- identity_id=identity.id,
313
- is_load_bearing=not not_load_bearing,
314
- extractor=EXTRACTOR_MANUAL,
315
- extractor_version=INGEST_EXTRACTOR_VERSION,
316
- actor_id=actor,
317
- )
313
+ from adopt_cli.commands._coverage_support import coverage_cache_kept_current
314
+
315
+ with coverage_cache_kept_current(handle, identity.system_id):
316
+ binding_id, revision_id = handle.bindings().bind(
317
+ item_id=knowledge_id,
318
+ identity_id=identity.id,
319
+ is_load_bearing=not not_load_bearing,
320
+ extractor=EXTRACTOR_MANUAL,
321
+ extractor_version=INGEST_EXTRACTOR_VERSION,
322
+ actor_id=actor,
323
+ )
318
324
  payload = {
319
325
  "binding": binding_id,
320
326
  "revision": revision_id,
@@ -499,12 +505,13 @@ def _conflicts_payload(handle: Any, result: Any, ranker: Any) -> list[dict[str,
499
505
  coverage just evaluated, so a conflict recorded against another system cannot
500
506
  appear in this scope's queue.
501
507
  """
502
- from adopt_model import Conflict
508
+ from adopt_cli.commands._conflict_support import conflict_rows, superseded_revisions
503
509
 
504
510
  if result is None:
505
511
  return []
506
512
  uris = {row.identity_id: row.uri for row in result.identities}
507
- rows = handle.export_records().table_rows("conflict", Conflict)
513
+ rows = conflict_rows(handle)
514
+ superseded = superseded_revisions(handle)
508
515
  return [
509
516
  {
510
517
  "uri": conflict.uri,
@@ -516,7 +523,7 @@ def _conflicts_payload(handle: Any, result: Any, ranker: Any) -> list[dict[str,
516
523
  # exists, and so a reader of the JSON never has to assume.
517
524
  "disposition": "open",
518
525
  }
519
- for conflict in ranker(rows, uris)
526
+ for conflict in ranker(rows, uris, superseded=superseded)
520
527
  ]
521
528
 
522
529
 
@@ -684,68 +691,71 @@ def review(
684
691
  title="adopt review",
685
692
  )
686
693
  return
694
+ from adopt_cli.commands._coverage_support import coverage_cache_kept_current
695
+
687
696
  handle = open_configured_store(store, read_only=not writing, verb="review")
688
697
  try:
689
698
  resolved = resolve_scope(handle, scope)
690
699
  identities = identity_views(handle, resolved)
691
700
  pending = pending_items(handle, resolved, identities)
692
-
693
- if resolve_item:
694
- payload = _resolve_change(
695
- handle,
696
- pending,
697
- known_review_items(handle),
698
- review_item_id=resolve_item,
699
- action=action or "",
700
- to_uri=to_uri,
701
- actor=actor,
702
- )
703
- elif not writing:
704
- payload = _queue_payload(pending)
705
- payload.update(_refresh_payload(refresh_population(handle, resolved, pending)))
706
- else:
707
- targets = _targets(
708
- pending,
709
- known_review_items(handle),
710
- confirm_item,
711
- reject_item,
712
- edit_item,
713
- confirm_batch,
714
- )
715
- resolutions: list[dict[str, Any]] = []
716
- for item, action in targets:
717
- if action == REJECT:
718
- outcome = reject_pending(item, reviews=handle.governance())
719
- elif action == CORRECT:
720
- outcome = edit_pending(
721
- item,
722
- reviews=handle.governance(),
723
- knowledge=handle.items(),
724
- unit=StoreUnitOfWork(handle),
725
- body_md=body_md,
726
- source_ref=str(file),
727
- actor_id=actor,
728
- )
729
- else:
730
- outcome = confirm_pending(
731
- item,
732
- reviews=handle.governance(),
733
- bindings=handle.bindings(),
734
- unit=StoreUnitOfWork(handle),
735
- knowledge=handle.items(),
736
- bound_pairs=bound_pairs(handle),
737
- actor_id=actor,
738
- )
739
- resolutions.append(
740
- {
741
- "review_item": item.review_item_id,
742
- "action": outcome.resolution,
743
- "source": item.source,
744
- "bindings": len(outcome.bindings),
745
- "revision": outcome.revision_id,
746
- }
701
+ system_id = resolved.system.id if writing and resolved.system is not None else None
702
+ with coverage_cache_kept_current(handle, system_id):
703
+ if resolve_item:
704
+ payload = _resolve_change(
705
+ handle,
706
+ pending,
707
+ known_review_items(handle),
708
+ review_item_id=resolve_item,
709
+ action=action or "",
710
+ to_uri=to_uri,
711
+ actor=actor,
712
+ )
713
+ elif not writing:
714
+ payload = _queue_payload(pending)
715
+ payload.update(_refresh_payload(refresh_population(handle, resolved, pending)))
716
+ else:
717
+ targets = _targets(
718
+ pending,
719
+ known_review_items(handle),
720
+ confirm_item,
721
+ reject_item,
722
+ edit_item,
723
+ confirm_batch,
747
724
  )
748
- payload = {"resolved": len(resolutions), "resolutions": resolutions}
725
+ resolutions: list[dict[str, Any]] = []
726
+ for item, action in targets:
727
+ if action == REJECT:
728
+ outcome = reject_pending(item, reviews=handle.governance())
729
+ elif action == CORRECT:
730
+ outcome = edit_pending(
731
+ item,
732
+ reviews=handle.governance(),
733
+ knowledge=handle.items(),
734
+ unit=StoreUnitOfWork(handle),
735
+ body_md=body_md,
736
+ source_ref=str(file),
737
+ actor_id=actor,
738
+ )
739
+ else:
740
+ outcome = confirm_pending(
741
+ item,
742
+ reviews=handle.governance(),
743
+ bindings=handle.bindings(),
744
+ unit=StoreUnitOfWork(handle),
745
+ knowledge=handle.items(),
746
+ bound_pairs=bound_pairs(handle),
747
+ actor_id=actor,
748
+ )
749
+ resolutions.append(
750
+ {
751
+ "review_item": item.review_item_id,
752
+ "action": outcome.resolution,
753
+ "source": item.source,
754
+ "bindings": len(outcome.bindings),
755
+ "revision": outcome.revision_id,
756
+ }
757
+ )
758
+ payload = {"resolved": len(resolutions), "resolutions": resolutions}
749
759
  finally:
750
760
  handle.close()
751
761
 
@@ -152,14 +152,19 @@ def refresh(
152
152
  allow_network=allow_network,
153
153
  )
154
154
  )
155
- outcome = record_refresh(
156
- handle,
157
- scope=resolved,
158
- diff=diff,
159
- batch_key=batch_key,
160
- probes=probes,
161
- actor_id=actor,
162
- )
155
+ from adopt_cli.commands._coverage_support import coverage_cache_kept_current
156
+
157
+ with coverage_cache_kept_current(
158
+ handle, resolved.system.id if resolved.system is not None else None
159
+ ):
160
+ outcome = record_refresh(
161
+ handle,
162
+ scope=resolved,
163
+ diff=diff,
164
+ batch_key=batch_key,
165
+ probes=probes,
166
+ actor_id=actor,
167
+ )
163
168
  outcome.files_hashed = files_hashed
164
169
  # After the canonical write commits, never before: a snapshot saved
165
170
  # first would let a crash between the two leave the store claiming this
@@ -1,6 +0,0 @@
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] = '6742b345c4d8f4d20905a7e8ca33afd66dd98e023cb9d13fb8982d3f2a3cd493'
6
- BUILD_ID: Final[str | None] = 'github:onboardux/onboard-core:34466475060:1:d639bc04586bda49c77f2b8c71865ceba74f2a0b'
File without changes
File without changes
File without changes