adopt-cli 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
adopt_cli/__init__.py ADDED
@@ -0,0 +1,11 @@
1
+ """The `adopt` command surface.
2
+
3
+ Deliberately thin. This package holds no business logic: it resolves
4
+ configuration, dispatches, renders, and maps errors to exit codes. Rich is used
5
+ here and nowhere else -- a library that formats for a terminal cannot be
6
+ embedded.
7
+ """
8
+
9
+ from adopt_cli.main import app, main
10
+
11
+ __all__ = ["app", "main"]
@@ -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] = '93c0332703403696e3bb379a3fae5e8db8c60641d43647a041713b2f65625bf5'
6
+ BUILD_ID: Final[str | None] = 'github:onboardux/onboard-core:31959050258:1:c5d546bf9de4282ce7124a5d14bd3031827091a8'
@@ -0,0 +1 @@
1
+ """Command implementations. Each renders a payload; none holds business logic."""
@@ -0,0 +1,193 @@
1
+ """`adopt agent adapters | check` -- contracts §14.
2
+
3
+ **A coverage gap, closed here.** `02` §14 defines this command group and `05` S7's
4
+ Final Output Validation item 4 runs it, and **no sprint task ever created it** --
5
+ the behaviour underneath was built and asserted at S7 while nothing exposed it on
6
+ a command line. Recorded as a gap rather than folded in silently, exactly as S8
7
+ did for `adopt store info` and S6 for `adopt init`.
8
+
9
+ Both subcommands emit the one envelope §14 declares -- `{adapters[{id, kind,
10
+ available, reason}]}` -- and that shape is `AdapterInfo` (CR-44), rendered rather
11
+ than re-shaped. A group whose subcommands each invented a payload would make one
12
+ contract into two.
13
+
14
+ **`adapters` reports and `check` acts, which is why their exit codes differ.**
15
+ The same division `02` §14 records for `adopt boundary` against `adopt init`:
16
+ `adapters` lists every registered adapter with the reason each unusable one is
17
+ unusable and exits `0`, because being told Anthropic is denied offline *is* the
18
+ answer. `check` attempts construction, so an offline hosted adapter is a policy
19
+ refusal and exits `3` -- and nothing was sent, because the refusal happens before
20
+ the adapter module is imported.
21
+
22
+ **No store is opened and no annex is touched.** Neither subcommand runs a model,
23
+ so neither needs the runtime annex; `adopt_agent.adapters.base` is reached
24
+ directly. That keeps `adopt agent check` usable before there is a store at all,
25
+ which is when an operator is configuring credentials.
26
+ """
27
+
28
+ from pathlib import Path
29
+ from typing import Annotated, Any
30
+
31
+ import typer
32
+
33
+ from adopt_agent import AdapterInfo
34
+ from adopt_agent.adapters.base import build_adapter, describe_adapters
35
+ from adopt_cli.config import resolve_all
36
+ from adopt_cli.json_out import emit
37
+ from adopt_obs import AdoptError, ErrorCode
38
+
39
+ __all__ = [
40
+ "adapter_settings",
41
+ "app",
42
+ "build_payload",
43
+ "disambiguation_enabled",
44
+ "prompts_root",
45
+ ]
46
+
47
+ app = typer.Typer(
48
+ name="agent",
49
+ help="Inspect and check model adapters. Offline by default; nothing is sent.",
50
+ no_args_is_help=True,
51
+ )
52
+
53
+ JsonOption = Annotated[bool, typer.Option("--json", help="Emit the strict JSON envelope only.")]
54
+ AdapterOption = Annotated[
55
+ str | None,
56
+ typer.Option("--adapter", help="Adapter id to check. Defaults to the resolved ADOPT_ADAPTER."),
57
+ ]
58
+
59
+ #: Values that mean "not offline". Written as a set of spellings rather than a
60
+ #: truthiness test because `ADOPT_OFFLINE=false` must not read as "true" merely
61
+ #: for being a non-empty string -- the same three spellings `adopt doctor`
62
+ #: already accepts, and one home would be better than two if either grows.
63
+ _FALSEY: frozenset[str] = frozenset({"0", "false", "no"})
64
+
65
+
66
+ def _allow_network(ctx: typer.Context) -> bool:
67
+ """The root `--allow-network` flag, or `False` when invoked without a context."""
68
+ obj = ctx.obj if isinstance(ctx.obj, dict) else {}
69
+ return bool(obj.get("allow_network", False))
70
+
71
+
72
+ def adapter_settings(
73
+ *, allow_network: bool = False
74
+ ) -> tuple[bool, str | None, str | None, str | None]:
75
+ """`(offline, adapter, model, endpoint)` from the §3 config registry.
76
+
77
+ Resolved through `resolve_all` so the order is flag > env > project > user >
78
+ default and `adopt doctor` can explain any of them. Defaulting offline to
79
+ **on** when the key is absent is the posture, not a fallback: a seam whose
80
+ default were online would make "offline by default" a property of whoever
81
+ happened to set the variable.
82
+
83
+ **`--allow-network` is the flag layer of that order and it was not wired.**
84
+ `main._root` wrote `ctx.obj["allow_network"]` and **nothing anywhere read
85
+ it**, so the root flag `adopt_cli.main`'s own docstring calls the way to
86
+ permit egress did nothing at all -- while `AGENT_OFFLINE_ADAPTER_DENIED`'s
87
+ hint told operators to pass it. A remedy a document names and no code
88
+ honours costs whoever hits it an hour, and the CI preflight hit it first.
89
+ """
90
+ by_key = {resolution.key: resolution.value for resolution in resolve_all()}
91
+ offline = (by_key.get("ADOPT_OFFLINE") or "1").strip().lower() not in _FALSEY
92
+ if allow_network:
93
+ offline = False
94
+ return (
95
+ offline,
96
+ by_key.get("ADOPT_ADAPTER"),
97
+ by_key.get("ADOPT_MODEL"),
98
+ by_key.get("ADOPT_ADAPTER_ENDPOINT"),
99
+ )
100
+
101
+
102
+ def disambiguation_enabled() -> bool:
103
+ """Whether `ADOPT_FEATURE_AGENT_DISAMBIGUATION` is on.
104
+
105
+ **Default off, without exception** (`03` §5): new behaviour arrives behind a
106
+ flag that is off until it has earned being on, and this flag is the one that
107
+ decides whether a model is called at all. Read through the registry so
108
+ `adopt doctor` can say where the value came from -- "it made a model call on my
109
+ machine" is a resolution-order question before it is anything else.
110
+ """
111
+ by_key = {resolution.key: resolution.value for resolution in resolve_all()}
112
+ return (by_key.get("ADOPT_FEATURE_AGENT_DISAMBIGUATION") or "0").strip().lower() not in _FALSEY
113
+
114
+
115
+ def prompts_root() -> Path:
116
+ """Where immutable prompt versions live (AI spec §5, `03` §1.2).
117
+
118
+ Configuration rather than a computed path: `03` §1.2 places `prompts/` at the
119
+ **repository root**, which is inside no package, so nothing importable can
120
+ derive it. `ADOPT_PROMPTS_DIR` defaults to `prompts` relative to the working
121
+ directory, and `doctor` reports which layer supplied it.
122
+ """
123
+ by_key = {resolution.key: resolution.value for resolution in resolve_all()}
124
+ return Path(by_key.get("ADOPT_PROMPTS_DIR") or "prompts")
125
+
126
+
127
+ def build_payload(infos: list[AdapterInfo]) -> dict[str, Any]:
128
+ """Contracts §14's `{adapters[{id, kind, available, reason}]}`."""
129
+ return {
130
+ "adapters": [
131
+ {
132
+ "id": info.id,
133
+ "kind": info.kind,
134
+ "available": info.available,
135
+ "reason": info.reason,
136
+ }
137
+ for info in infos
138
+ ]
139
+ }
140
+
141
+
142
+ def _no_adapter_named() -> AdoptError:
143
+ """`check` with no id anywhere. A usage error, not a policy refusal.
144
+
145
+ The seam raises for the same reason (`04` §2, no fallback): picking one on
146
+ the operator's behalf would change cost, behaviour and data residency
147
+ without them knowing.
148
+ """
149
+ return AdoptError(
150
+ ErrorCode.AGENT_ADAPTER_UNKNOWN,
151
+ message="no adapter was named and ADOPT_ADAPTER has no value",
152
+ hint="Pass --adapter, or set ADOPT_ADAPTER. Run `adopt agent adapters` to "
153
+ "see every registered id and why each unavailable one is unavailable.",
154
+ )
155
+
156
+
157
+ @app.command()
158
+ def adapters(ctx: typer.Context, json_output: JsonOption = False) -> None:
159
+ """List every registered adapter and why each unusable one is unusable.
160
+
161
+ Exits `0` even when nothing is available: the report is the answer.
162
+ """
163
+ offline, _, model, endpoint = adapter_settings(allow_network=_allow_network(ctx))
164
+ payload = build_payload(describe_adapters(offline=offline, model=model, endpoint=endpoint))
165
+ emit(payload, as_json=json_output, title="adopt agent adapters")
166
+
167
+
168
+ @app.command()
169
+ def check(
170
+ ctx: typer.Context, adapter: AdapterOption = None, json_output: JsonOption = False
171
+ ) -> None:
172
+ """Construct one adapter, or refuse with the reason.
173
+
174
+ Raises `AGENT_ADAPTER_UNKNOWN` (usage, exit `2`) for an unregistered id or
175
+ when none is configured, and `AGENT_OFFLINE_ADAPTER_DENIED` (policy, exit
176
+ `3`) for a hosted adapter offline. The envelope and the exit code are
177
+ `adopt_cli.main`'s doing, so this command does not restate the mapping.
178
+ """
179
+ offline, configured, model, endpoint = adapter_settings(allow_network=_allow_network(ctx))
180
+ chosen = adapter or configured
181
+ if chosen is None:
182
+ raise _no_adapter_named()
183
+
184
+ # Construction is the check. Asking `describe_adapters` instead would report
185
+ # availability without ever proving the adapter can be built -- and building
186
+ # is where a hosted adapter refuses, before its module is imported.
187
+ built = build_adapter(chosen, offline=offline, model=model, endpoint=endpoint)
188
+ reported = [
189
+ info
190
+ for info in describe_adapters(offline=offline, model=model, endpoint=endpoint)
191
+ if info.id == built.id
192
+ ]
193
+ emit(build_payload(reported), as_json=json_output, title="adopt agent check")
@@ -0,0 +1,188 @@
1
+ """`adopt boundary` -- contracts §14.
2
+
3
+ Negotiate a tier from the three qualification answers (CR-38) and report the
4
+ boundary it implies.
5
+
6
+ **Persisting is optional, and that is the workflow rather than a convenience.**
7
+ With `--scope` and a store, the boundary row is written and its id reported. With
8
+ neither, the negotiation is computed and reported and nothing is written --
9
+ because PRD F10.6's `T0` case is exactly the one an FDE reaches *before* there is
10
+ a firm, an engagement or a store: they are deciding whether to take the
11
+ engagement at all. Requiring a store in order to be told "decline" inverts the
12
+ order the product is used in.
13
+
14
+ **Exit `4`, not `0`, whenever there is a finding** -- a `T0` decline or an `ai`
15
+ system below its floor. Contracts §14 gives this command exits `0` and `4`, and
16
+ `4` is *degraded success with findings*: the boundary is real and reportable, and
17
+ something about it needs a human.
18
+ """
19
+
20
+ import json
21
+ from pathlib import Path
22
+ from typing import Annotated, Any
23
+
24
+ import typer
25
+
26
+ from adopt_cli.json_out import emit
27
+ from adopt_cli.store_option import open_configured_store
28
+ from adopt_detect import (
29
+ ARCHETYPES,
30
+ DEFAULT_OUTBOUND_CATEGORIES,
31
+ BoundaryView,
32
+ declare_boundary,
33
+ negotiate,
34
+ parse_answers,
35
+ render_markdown,
36
+ unavailable_capabilities,
37
+ violates_archetype_floor,
38
+ )
39
+ from adopt_detect.negotiate import TierDecision
40
+ from adopt_detect.render import render_json
41
+ from adopt_model._enums import Archetype
42
+ from adopt_obs import AdoptError, ErrorCode, ExitCode, now
43
+ from adopt_scope import ScopePath
44
+
45
+ __all__ = ["answers_from_file", "boundary", "build_payload"]
46
+
47
+ AnswersOption = Annotated[
48
+ Path,
49
+ typer.Option(
50
+ "--answers",
51
+ help="JSON file carrying the three qualification answers "
52
+ "(artifact_access, deploy_signal, safe_interaction).",
53
+ show_default=False,
54
+ ),
55
+ ]
56
+ ScopeOption = Annotated[
57
+ str | None,
58
+ typer.Option(
59
+ "--scope",
60
+ help="firm/engagement/system[/environment]. Given, the boundary is written to "
61
+ "the store; omitted, it is computed and reported without writing.",
62
+ ),
63
+ ]
64
+ ArchetypeOption = Annotated[
65
+ str | None,
66
+ typer.Option("--archetype", help="The detected archetype, for the ai-below-T3 floor check."),
67
+ ]
68
+ StoreOption = Annotated[Path | None, typer.Option("--store", help="Store path override.")]
69
+ MarkdownOption = Annotated[
70
+ Path | None,
71
+ typer.Option(
72
+ "--write-statement",
73
+ help="Also write the human-readable statement here. Same row, same facts.",
74
+ ),
75
+ ]
76
+ JsonOption = Annotated[bool, typer.Option("--json", help="Emit the strict JSON envelope only.")]
77
+
78
+
79
+ def answers_from_file(path: Path) -> dict[str, Any]:
80
+ """Read and parse the answers document.
81
+
82
+ Raises:
83
+ AdoptError: ``TIER_ANSWERS_INVALID`` when the file is absent or is not
84
+ JSON. Both are the caller's mistake and neither is defaulted -- an
85
+ unreadable answers file must never resolve to a tier.
86
+ """
87
+ try:
88
+ text = path.read_text(encoding="utf-8")
89
+ except OSError as error:
90
+ raise AdoptError(
91
+ ErrorCode.TIER_ANSWERS_INVALID,
92
+ message=f"cannot read the answers file {path}",
93
+ hint="Point --answers at a JSON file carrying the three answers.",
94
+ ) from error
95
+ try:
96
+ document = json.loads(text)
97
+ except json.JSONDecodeError as error:
98
+ raise AdoptError(
99
+ ErrorCode.TIER_ANSWERS_INVALID,
100
+ message=f"{path} is not valid JSON: {error}",
101
+ hint="The file is a JSON object with three boolean keys.",
102
+ ) from error
103
+ if not isinstance(document, dict):
104
+ raise AdoptError(
105
+ ErrorCode.TIER_ANSWERS_INVALID,
106
+ message=f"{path} is a {type(document).__name__}, not a JSON object",
107
+ hint="The file is a JSON object with three boolean keys.",
108
+ )
109
+ return document
110
+
111
+
112
+ def build_payload(view: BoundaryView) -> dict[str, Any]:
113
+ """Contracts §14's `adopt boundary` shape, from the one rendering function."""
114
+ return render_json(view)
115
+
116
+
117
+ def _unpersisted_view(decision: TierDecision, archetype: Archetype | None) -> BoundaryView:
118
+ """The view for a negotiation that was not written.
119
+
120
+ `boundary_id` is empty rather than invented. A synthetic id here would be a
121
+ string that looks like a stored boundary and resolves to nothing, which is
122
+ worse than an obviously absent one.
123
+ """
124
+ return BoundaryView(
125
+ boundary_id="",
126
+ system_id="",
127
+ environment_id=None,
128
+ tier=decision.tier,
129
+ archetype=archetype,
130
+ knowledge_plane_location="customer",
131
+ control_plane_location="vendor",
132
+ permitted_outbound_categories=DEFAULT_OUTBOUND_CATEGORIES,
133
+ unavailable_capabilities=unavailable_capabilities(decision.tier),
134
+ contractual_approval_ref=None,
135
+ declared_at=now(),
136
+ decline_recommended=decision.decline_recommended,
137
+ archetype_floor_violated=violates_archetype_floor(archetype, decision.tier),
138
+ )
139
+
140
+
141
+ def boundary(
142
+ answers: AnswersOption,
143
+ scope: ScopeOption = None,
144
+ archetype: ArchetypeOption = None,
145
+ store: StoreOption = None,
146
+ write_statement: MarkdownOption = None,
147
+ json_output: JsonOption = False,
148
+ ) -> None:
149
+ """Negotiate the observability boundary and report what it permits."""
150
+ decision = negotiate(parse_answers(answers_from_file(answers)))
151
+ typed_archetype: Archetype | None = None
152
+ if archetype is not None:
153
+ typed_archetype = _require_archetype(archetype)
154
+
155
+ if scope is None:
156
+ view = _unpersisted_view(decision, typed_archetype)
157
+ else:
158
+ with open_configured_store(store, read_only=False) as handle:
159
+ resolved = handle.scope().resolve(ScopePath.parse(scope))
160
+ view = declare_boundary(
161
+ handle.boundary(),
162
+ scope=resolved,
163
+ decision=decision,
164
+ archetype=typed_archetype,
165
+ )
166
+
167
+ payload = build_payload(view)
168
+ payload["persisted"] = bool(view.boundary_id)
169
+ if write_statement is not None:
170
+ write_statement.write_text(render_markdown(view), encoding="utf-8", newline="\n")
171
+ payload["statement_path"] = str(write_statement)
172
+
173
+ emit(payload, as_json=json_output, title="adopt boundary")
174
+ if view.decline_recommended or view.archetype_floor_violated:
175
+ raise typer.Exit(ExitCode.DEGRADED_WITH_FINDINGS)
176
+
177
+
178
+ def _require_archetype(value: str) -> Archetype:
179
+ """`value` as a declared archetype, or a usage refusal naming the set."""
180
+ for archetype in ARCHETYPES:
181
+ if value == archetype:
182
+ return archetype
183
+ raise AdoptError(
184
+ ErrorCode.ADOPT_CONFIG_UNRESOLVED,
185
+ message=f"{value!r} is not a declared archetype",
186
+ hint=f"Archetypes are exactly {list(ARCHETYPES)} (contracts §2.1). Detection "
187
+ "returns one of these; nothing else can be scored against the tier floors.",
188
+ )
@@ -0,0 +1,85 @@
1
+ """`adopt coverage recompute` -- contracts §14.
2
+
3
+ Emits `{covered, uncovered, disagreements[]}` and exits `4` when the cache
4
+ disagrees with the recompute: **degraded success with findings**, not failure.
5
+ The distinction matters operationally -- the recompute itself worked, and its
6
+ answer is the one to trust; what is broken is the cache, and something wrote it.
7
+
8
+ `--rebuild` is opt-in and off by default, so the default invocation is the one
9
+ `store doctor` also performs: look, report, change nothing. A tool whose default
10
+ repaired the drift would destroy the evidence of the writer that caused it every
11
+ time an operator ran it to find out what was wrong (implementation spec §8).
12
+ """
13
+
14
+ from pathlib import Path
15
+ from typing import Annotated
16
+
17
+ import typer
18
+
19
+ from adopt_cli.json_out import emit
20
+ from adopt_cli.store_option import open_configured_store
21
+ from adopt_coverage import rebuild_cache, recompute_coverage
22
+ from adopt_obs import ExitCode
23
+
24
+ __all__ = ["app"]
25
+
26
+ app = typer.Typer(
27
+ name="coverage",
28
+ help="Recompute coverage. The function is the authority; the cache must alarm.",
29
+ no_args_is_help=True,
30
+ )
31
+
32
+ SystemOption = Annotated[
33
+ str,
34
+ typer.Option("--system", help="The system id to evaluate.", show_default=False),
35
+ ]
36
+ EnvironmentOption = Annotated[
37
+ str | None,
38
+ typer.Option("--environment", help="One environment id. Omit for every environment."),
39
+ ]
40
+ StoreOption = Annotated[
41
+ Path | None,
42
+ typer.Option("--store", help="Store path. Defaults to the resolved ADOPT_STORE_PATH."),
43
+ ]
44
+ RebuildOption = Annotated[
45
+ bool,
46
+ typer.Option(
47
+ "--rebuild",
48
+ help="Write the result to covered_cache. Off by default: looking must not repair.",
49
+ ),
50
+ ]
51
+ JsonOption = Annotated[bool, typer.Option("--json", help="Emit the strict JSON envelope only.")]
52
+
53
+
54
+ @app.command()
55
+ def recompute(
56
+ system: SystemOption,
57
+ environment: EnvironmentOption = None,
58
+ store: StoreOption = None,
59
+ rebuild: RebuildOption = False,
60
+ json_output: JsonOption = False,
61
+ ) -> None:
62
+ """Evaluate the six inputs of contracts §6 for every identity in scope."""
63
+ with open_configured_store(store, read_only=not rebuild) as handle:
64
+ result = recompute_coverage(handle.coverage_records(), system, environment)
65
+ rebuilt = rebuild_cache(handle.backend, result) if rebuild else 0
66
+
67
+ payload = {
68
+ "covered": result.covered,
69
+ "uncovered": result.uncovered,
70
+ "disagreements": [
71
+ {
72
+ "identity_id": entry.identity_id,
73
+ "uri": entry.uri,
74
+ "cached": entry.cached,
75
+ "recomputed": entry.recomputed,
76
+ }
77
+ for entry in result.disagreements
78
+ ],
79
+ }
80
+ if rebuild:
81
+ payload["cache_rows_written"] = rebuilt
82
+
83
+ emit(payload, as_json=json_output, title="adopt coverage recompute")
84
+ if result.disagreements:
85
+ raise typer.Exit(ExitCode.DEGRADED_WITH_FINDINGS)
@@ -0,0 +1,135 @@
1
+ """`adopt detect` -- contracts §14.
2
+
3
+ Pure filesystem, no store, no socket. The command is a thin rendering of
4
+ `adopt_detect.detect`; every decision it reports is made there.
5
+
6
+ **Ambiguity exits `2` and names the way forward** (PRD F10.3, `04` §4 step 3).
7
+ The payload carries the ranked scores, the rules that fired and the exact
8
+ environment variable to set for a disambiguation pass.
9
+
10
+ **With `ADOPT_FEATURE_AGENT_DISAMBIGUATION` on, one model call becomes possible
11
+ here -- and the exit code does not change.** That is the human-accept step in its
12
+ cheapest honest form: the pass returns a **proposal**, the proposal is printed,
13
+ and `adopt detect` still exits `2` with `DETECT_AMBIGUOUS`, because nothing has
14
+ been decided and nothing has been written. `01` §8 requires human approval for
15
+ "writing the archetype -- always, no confidence exemption", so the only way a
16
+ proposal becomes state is an operator reading it and passing
17
+ `adopt init --archetype`. There is deliberately no `--accept` flag that would let
18
+ one invocation both propose and persist.
19
+
20
+ **A failed pass is not a failed command.** `04` §3's last row is load-bearing:
21
+ every Build 0 capability degrades to a working deterministic behaviour. If the
22
+ pass cannot run -- no adapter, no credential, offline, a budget crossing, an
23
+ unusable reply -- the ranked scores are still reported and the reason is carried
24
+ in the payload. The deterministic path *is* the product.
25
+ """
26
+
27
+ import json
28
+ from pathlib import Path
29
+ from typing import Annotated, Any
30
+
31
+ import typer
32
+
33
+ from adopt_agent import Runner
34
+ from adopt_cli.commands.agent import adapter_settings, disambiguation_enabled, prompts_root
35
+ from adopt_cli.json_out import emit
36
+ from adopt_cli.store_option import configured_annex
37
+ from adopt_detect import detect as run_detect
38
+ from adopt_detect.detect import DISAMBIGUATION_FLAG, DetectionResult
39
+ from adopt_detect.disambiguate import propose
40
+ from adopt_obs import AdoptError, ErrorCode, get_logger
41
+
42
+ __all__ = ["build_payload", "detect"]
43
+
44
+ PathArgument = Annotated[Path, typer.Argument(help="The tree to classify. Read, never executed.")]
45
+ JsonOption = Annotated[bool, typer.Option("--json", help="Emit the strict JSON envelope only.")]
46
+
47
+
48
+ def build_payload(result: DetectionResult) -> dict[str, Any]:
49
+ """Contracts §14's `adopt detect` shape: archetype, confidence, scores, rules_fired."""
50
+ return {
51
+ "archetype": result.archetype,
52
+ "confidence": result.confidence,
53
+ "scores": dict(result.ranked()),
54
+ "rules_fired": [
55
+ {"archetype": hit.archetype, "rule": hit.rule_id, "path": hit.path, "why": hit.why}
56
+ for hit in result.rules_fired
57
+ ],
58
+ "files_considered": result.files_considered,
59
+ "truncated": result.truncated,
60
+ }
61
+
62
+
63
+ def _proposal(path: Path, result: DetectionResult) -> dict[str, Any]:
64
+ """`04` §4 steps 4-5, or the reason they did not run.
65
+
66
+ Reached only when detection has already declined (step 2) **and** the flag is
67
+ on (step 3). Every failure below degrades to reporting the deterministic
68
+ answer, because `04` §3 makes that the product rather than a fallback.
69
+ """
70
+ log = get_logger("adopt_cli")
71
+ offline, adapter_id, model, endpoint = adapter_settings()
72
+ try:
73
+ with configured_annex() as annex:
74
+ runner = Runner(
75
+ annex=annex,
76
+ # The annex row's scope, and the pass runs before any store
77
+ # exists -- `adopt detect` takes no `--scope` and creates nothing.
78
+ scope_ref=str(path),
79
+ skills_root=prompts_root(),
80
+ offline=offline,
81
+ adapter_id=adapter_id,
82
+ model=model,
83
+ endpoint=endpoint,
84
+ )
85
+ proposal = propose(result, root=path, runner=runner)
86
+ except AdoptError as error:
87
+ # Named, structured, and no payload: the code and the category, exactly
88
+ # what `03` §4.2 permits a log line to carry.
89
+ log.warn("detect.disambiguation_unavailable", code=str(error.code))
90
+ return {"available": False, "reason": error.code.value, "accepted": False}
91
+
92
+ return {
93
+ "available": True,
94
+ "primary": proposal.primary,
95
+ "confidence": proposal.confidence,
96
+ "reasoning": proposal.reasoning,
97
+ "secondary": list(proposal.secondary),
98
+ # **Always false, and there is no code path that sets it true.** `01` §8
99
+ # allows no confidence exemption for writing an archetype, so acceptance
100
+ # happens in a separate invocation an operator types.
101
+ "accepted": False,
102
+ }
103
+
104
+
105
+ def detect(path: PathArgument = Path(), json_output: JsonOption = False) -> None:
106
+ """Classify a file tree into one archetype, or refuse and rank.
107
+
108
+ Exits `2` with `DETECT_AMBIGUOUS` when confidence is below the threshold --
109
+ including when a disambiguation proposal was obtained, because a proposal is
110
+ not a decision.
111
+ """
112
+ result = run_detect(path)
113
+ payload = build_payload(result)
114
+ if result.ambiguous:
115
+ # Step 3 of `04` §4: with the flag off this returns here, having made no
116
+ # model call and having formed no request. The flag is checked before the
117
+ # pass is imported into the call path at all.
118
+ if disambiguation_enabled():
119
+ payload["proposal"] = _proposal(path, result)
120
+ # Emitted before raising so the operator gets the evidence, not only the
121
+ # refusal. The error envelope alone would say "ambiguous" and nothing
122
+ # about which archetypes were close or which rules fired.
123
+ emit(payload, as_json=json_output, title="adopt detect")
124
+ raise AdoptError(
125
+ ErrorCode.DETECT_AMBIGUOUS,
126
+ message=(
127
+ f"confidence {result.confidence} is below the threshold; "
128
+ f"ranked scores {json.dumps(dict(result.ranked()))}"
129
+ ),
130
+ hint=f"Detection does not guess -- a wrong archetype is a different set of "
131
+ f"extractors, not a slightly wrong answer. Narrow the path to one system, "
132
+ f"or set {DISAMBIGUATION_FLAG}=1 to enable the reasoning pass, whose "
133
+ f"proposal a human must accept before anything is written.",
134
+ )
135
+ emit(payload, as_json=json_output, title="adopt detect")