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 +11 -0
- adopt_cli/_build_info.py +6 -0
- adopt_cli/commands/__init__.py +1 -0
- adopt_cli/commands/agent.py +193 -0
- adopt_cli/commands/boundary.py +188 -0
- adopt_cli/commands/coverage.py +85 -0
- adopt_cli/commands/detect.py +135 -0
- adopt_cli/commands/doctor.py +99 -0
- adopt_cli/commands/freshness.py +60 -0
- adopt_cli/commands/identity.py +113 -0
- adopt_cli/commands/init.py +268 -0
- adopt_cli/commands/interchange.py +92 -0
- adopt_cli/commands/policy.py +164 -0
- adopt_cli/commands/store.py +98 -0
- adopt_cli/commands/version.py +37 -0
- adopt_cli/config.py +162 -0
- adopt_cli/json_out.py +65 -0
- adopt_cli/main.py +150 -0
- adopt_cli/py.typed +0 -0
- adopt_cli/store_option.py +134 -0
- adopt_cli-0.3.0.dist-info/METADATA +27 -0
- adopt_cli-0.3.0.dist-info/RECORD +26 -0
- adopt_cli-0.3.0.dist-info/WHEEL +4 -0
- adopt_cli-0.3.0.dist-info/entry_points.txt +2 -0
- adopt_cli-0.3.0.dist-info/licenses/LICENSE +201 -0
- adopt_cli-0.3.0.dist-info/licenses/NOTICE +39 -0
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"]
|
adopt_cli/_build_info.py
ADDED
|
@@ -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")
|