ergane-cli 0.1.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.
- ergane_cli-0.1.0.dist-info/METADATA +148 -0
- ergane_cli-0.1.0.dist-info/RECORD +121 -0
- ergane_cli-0.1.0.dist-info/WHEEL +4 -0
- ergane_cli-0.1.0.dist-info/entry_points.txt +2 -0
- ergane_cli-0.1.0.dist-info/licenses/LICENSE +202 -0
- ergane_cli-0.1.0.dist-info/licenses/NOTICE +27 -0
- factory/__init__.py +0 -0
- factory/activities/__init__.py +0 -0
- factory/activities/agent_activities.py +745 -0
- factory/activities/merge_activities.py +654 -0
- factory/activities/notify_activities.py +1078 -0
- factory/activities/roadmap_activities.py +577 -0
- factory/activities/usage_activities.py +593 -0
- factory/activities/verify_activities.py +617 -0
- factory/cli/__init__.py +0 -0
- factory/cli/completion.py +150 -0
- factory/cli/doctor.py +484 -0
- factory/cli/env.py +247 -0
- factory/cli/errors.py +77 -0
- factory/cli/init.py +1057 -0
- factory/cli/install.py +679 -0
- factory/cli/main.py +212 -0
- factory/cli/nouns/__init__.py +59 -0
- factory/cli/nouns/answer.py +134 -0
- factory/cli/nouns/build.py +1222 -0
- factory/cli/nouns/completion.py +13 -0
- factory/cli/nouns/doctor.py +13 -0
- factory/cli/nouns/env.py +13 -0
- factory/cli/nouns/escalations.py +93 -0
- factory/cli/nouns/findings.py +13 -0
- factory/cli/nouns/init.py +13 -0
- factory/cli/nouns/install.py +99 -0
- factory/cli/nouns/repo.py +13 -0
- factory/cli/nouns/roadmap.py +13 -0
- factory/cli/nouns/spec.py +511 -0
- factory/cli/nouns/status.py +18 -0
- factory/cli/nouns/usage.py +13 -0
- factory/cli/nouns/worker.py +69 -0
- factory/cli/repo.py +569 -0
- factory/cli/repo_export.py +282 -0
- factory/cli/roadmap.py +434 -0
- factory/cli/status.py +844 -0
- factory/cli/usage.py +129 -0
- factory/config.py +262 -0
- factory/controlplane/__init__.py +0 -0
- factory/controlplane/config.py +863 -0
- factory/controlplane/resolve.py +434 -0
- factory/controlplane/verify.py +827 -0
- factory/discovery/__init__.py +3 -0
- factory/discovery/llm_scanner.py +176 -0
- factory/doctor/__init__.py +0 -0
- factory/doctor/cli.py +392 -0
- factory/doctor/models.py +165 -0
- factory/doctor/probes.py +522 -0
- factory/doctor/scaffold.py +184 -0
- factory/doctor/store.py +449 -0
- factory/env.py +131 -0
- factory/escalation/__init__.py +0 -0
- factory/escalation/client.py +168 -0
- factory/escalation/question.py +161 -0
- factory/escalation/workflow.py +470 -0
- factory/locking.py +85 -0
- factory/mergequeue/__init__.py +21 -0
- factory/mergequeue/classify.py +133 -0
- factory/mergequeue/forge.py +401 -0
- factory/mergequeue/gh.py +484 -0
- factory/mergequeue/github_forge.py +349 -0
- factory/mergequeue/merge_queue_ruleset.json +32 -0
- factory/mergequeue/messages.py +141 -0
- factory/mergequeue/models.py +324 -0
- factory/mergequeue/onboard.py +537 -0
- factory/mergequeue/wiring.py +466 -0
- factory/notify/__init__.py +0 -0
- factory/notify/adapter.py +263 -0
- factory/notify/messages.py +474 -0
- factory/notify/service.py +809 -0
- factory/notify/webhook.py +151 -0
- factory/personas.yaml +161 -0
- factory/registry.py +583 -0
- factory/roadmap/__init__.py +8 -0
- factory/roadmap/cli.py +125 -0
- factory/roadmap/discovery.py +286 -0
- factory/roadmap/models.py +583 -0
- factory/roadmap/schedule.py +377 -0
- factory/roadmap/workflow.py +1364 -0
- factory/supervision/__init__.py +0 -0
- factory/supervision/alert.py +239 -0
- factory/supervision/probe.py +467 -0
- factory/supervision/temporal_server.py +121 -0
- factory/supervision/units.py +649 -0
- factory/usage/__init__.py +0 -0
- factory/usage/aggregate.py +113 -0
- factory/usage/cli.py +223 -0
- factory/usage/ledger.py +295 -0
- factory/usage/litellm_client.py +486 -0
- factory/usage/models.py +149 -0
- factory/verify/__init__.py +0 -0
- factory/verify/criteria.py +488 -0
- factory/verify/diffbounds.py +159 -0
- factory/verify/diffcheck.py +506 -0
- factory/verify/factory_yaml.py +842 -0
- factory/verify/gates.py +1341 -0
- factory/verify/judge.py +775 -0
- factory/verify/ladder.py +145 -0
- factory/verify/models.py +745 -0
- factory/verify/question.py +167 -0
- factory/verify/store.py +1366 -0
- factory/verify/toolchain.py +370 -0
- factory/worker.py +296 -0
- factory/workgraph/__init__.py +0 -0
- factory/workgraph/adapter.py +1333 -0
- factory/workgraph/cli.py +801 -0
- factory/workgraph/delta.py +278 -0
- factory/workgraph/derive.py +607 -0
- factory/workgraph/detector.py +509 -0
- factory/workgraph/landed.py +496 -0
- factory/workgraph/models.py +494 -0
- factory/workgraph/preflight.py +651 -0
- factory/workgraph/prompt.py +796 -0
- factory/workgraph/workflow.py +2733 -0
- factory/workgraph/worktree.py +1322 -0
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Implementation of `ergane completion`.
|
|
2
|
+
|
|
3
|
+
Emits a small static completion script for bash or zsh. The script is built
|
|
4
|
+
from the live parser tree, so it completes both top-level nouns and each
|
|
5
|
+
noun's verbs. Any other shell is rejected with exit code 2 and a message naming
|
|
6
|
+
the two supported shells.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import shlex
|
|
13
|
+
|
|
14
|
+
from factory.cli.errors import EXIT_USAGE, OperatorError
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def add_completion_parser(subparsers: argparse._SubParsersAction) -> argparse.ArgumentParser:
|
|
18
|
+
parser = subparsers.add_parser(
|
|
19
|
+
"completion",
|
|
20
|
+
help="emit a shell completion script",
|
|
21
|
+
description="Print a completion script for bash or zsh.",
|
|
22
|
+
)
|
|
23
|
+
parser.add_argument(
|
|
24
|
+
"shell",
|
|
25
|
+
choices=["bash", "zsh"],
|
|
26
|
+
help="target shell (bash or zsh)",
|
|
27
|
+
)
|
|
28
|
+
parser.set_defaults(run=completion_command)
|
|
29
|
+
return parser
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _noun_and_verb_map() -> tuple[list[str], dict[str, list[str]]]:
|
|
33
|
+
"""Return (all noun names, noun -> sorted verb names) from the parser tree.
|
|
34
|
+
|
|
35
|
+
Verbs come from a noun's own subparsers, or from positional arguments (not
|
|
36
|
+
options) that declare a fixed set of choices. Nouns with neither still appear
|
|
37
|
+
in the noun list, but produce no second-level completions.
|
|
38
|
+
"""
|
|
39
|
+
from factory.cli import main as main_module
|
|
40
|
+
|
|
41
|
+
root = main_module._build_parser()
|
|
42
|
+
nouns: list[str] = []
|
|
43
|
+
mapping: dict[str, list[str]] = {}
|
|
44
|
+
for action in root._subparsers._group_actions:
|
|
45
|
+
if not isinstance(action, argparse._SubParsersAction):
|
|
46
|
+
continue
|
|
47
|
+
for noun, noun_parser in action._name_parser_map.items():
|
|
48
|
+
nouns.append(noun)
|
|
49
|
+
verbs: set[str] = set()
|
|
50
|
+
if noun_parser._subparsers is not None:
|
|
51
|
+
for sub_action in noun_parser._subparsers._group_actions:
|
|
52
|
+
if isinstance(sub_action, argparse._SubParsersAction):
|
|
53
|
+
verbs.update(sub_action._name_parser_map.keys())
|
|
54
|
+
for sub_action in noun_parser._actions:
|
|
55
|
+
if (
|
|
56
|
+
isinstance(sub_action, argparse._StoreAction)
|
|
57
|
+
and not sub_action.option_strings
|
|
58
|
+
and sub_action.choices is not None
|
|
59
|
+
):
|
|
60
|
+
verbs.update(sub_action.choices)
|
|
61
|
+
if verbs:
|
|
62
|
+
mapping[noun] = sorted(verbs)
|
|
63
|
+
nouns.sort()
|
|
64
|
+
return nouns, mapping
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _bash_script(nouns: list[str], verbs: dict[str, list[str]]) -> str:
|
|
68
|
+
noun_list = " ".join(shlex.quote(n) for n in nouns)
|
|
69
|
+
case_branches = []
|
|
70
|
+
for noun in nouns:
|
|
71
|
+
noun_verbs = verbs.get(noun, [])
|
|
72
|
+
case_branches.append(
|
|
73
|
+
f" {shlex.quote(noun)}) local verbs={shlex.quote(' '.join(noun_verbs))} ;;"
|
|
74
|
+
)
|
|
75
|
+
case_block = "\n".join(case_branches)
|
|
76
|
+
return f"""_ergane_completion() {{
|
|
77
|
+
local cur prev words cword
|
|
78
|
+
_init_completion || return
|
|
79
|
+
local nouns={noun_list}
|
|
80
|
+
local noun="${{words[1]}}"
|
|
81
|
+
local verbs=""
|
|
82
|
+
case "$noun" in
|
|
83
|
+
{case_block}
|
|
84
|
+
esac
|
|
85
|
+
if [ "$cword" -eq 1 ]; then
|
|
86
|
+
COMPREPLY=( $(compgen -W "$nouns" -- "$cur") )
|
|
87
|
+
elif [ "$cword" -eq 2 ] && [ -n "$verbs" ]; then
|
|
88
|
+
COMPREPLY=( $(compgen -W "$verbs" -- "$cur") )
|
|
89
|
+
fi
|
|
90
|
+
}}
|
|
91
|
+
complete -F _ergane_completion ergane"""
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _zsh_script(nouns: list[str], verbs: dict[str, list[str]]) -> str:
|
|
95
|
+
noun_array = " ".join(shlex.quote(n) for n in nouns)
|
|
96
|
+
case_branches = []
|
|
97
|
+
for noun in nouns:
|
|
98
|
+
noun_verbs = verbs.get(noun, [])
|
|
99
|
+
if noun_verbs:
|
|
100
|
+
verb_array = " ".join(shlex.quote(v) for v in noun_verbs)
|
|
101
|
+
case_branches.append(
|
|
102
|
+
f" {shlex.quote(noun)}) verbs=({verb_array}) ;;"
|
|
103
|
+
)
|
|
104
|
+
else:
|
|
105
|
+
case_branches.append(
|
|
106
|
+
f" {shlex.quote(noun)}) verbs=() ;;"
|
|
107
|
+
)
|
|
108
|
+
case_block = "\n".join(case_branches)
|
|
109
|
+
return f"""#compdef ergane
|
|
110
|
+
_ergane() {{
|
|
111
|
+
local curcontext="$curcontext" state line
|
|
112
|
+
typeset -A opt_args
|
|
113
|
+
_arguments -C \\
|
|
114
|
+
'1: :->noun' \\
|
|
115
|
+
'2: :->verb' \\
|
|
116
|
+
'*: :->args'
|
|
117
|
+
case "$state" in
|
|
118
|
+
noun)
|
|
119
|
+
local -a nouns
|
|
120
|
+
nouns=({noun_array})
|
|
121
|
+
_describe -t nouns "ergane nouns" nouns
|
|
122
|
+
;;
|
|
123
|
+
verb)
|
|
124
|
+
local noun="$line[1]"
|
|
125
|
+
local -a verbs
|
|
126
|
+
case "$noun" in
|
|
127
|
+
{case_block}
|
|
128
|
+
esac
|
|
129
|
+
if [[ ${{#verbs}} -gt 0 ]]; then
|
|
130
|
+
_describe -t verbs "ergane $noun verbs" verbs
|
|
131
|
+
fi
|
|
132
|
+
;;
|
|
133
|
+
esac
|
|
134
|
+
}}
|
|
135
|
+
compdef _ergane ergane"""
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def completion_command(args: argparse.Namespace) -> int:
|
|
139
|
+
shell = args.shell.lower()
|
|
140
|
+
nouns, verbs = _noun_and_verb_map()
|
|
141
|
+
if shell == "bash":
|
|
142
|
+
print(_bash_script(nouns, verbs))
|
|
143
|
+
return 0
|
|
144
|
+
if shell == "zsh":
|
|
145
|
+
print(_zsh_script(nouns, verbs))
|
|
146
|
+
return 0
|
|
147
|
+
raise OperatorError(
|
|
148
|
+
f"unsupported shell {args.shell!r}; supported shells: bash, zsh",
|
|
149
|
+
EXIT_USAGE,
|
|
150
|
+
)
|
factory/cli/doctor.py
ADDED
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
"""Implementation of `ergane doctor` and `ergane findings`.
|
|
2
|
+
|
|
3
|
+
`doctor` runs every registered probe under the unified error boundary. A probe
|
|
4
|
+
whose service is unreachable is reported and forces exit 3; a probe that raises
|
|
5
|
+
any other exception is reported as one line naming `--debug` and continues so the
|
|
6
|
+
rest of the examination still runs.
|
|
7
|
+
|
|
8
|
+
`findings` exposes the other four legacy doctor verbs (report, list,
|
|
9
|
+
resolve, promote) with the unified prefix and exit-code contract.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import json
|
|
16
|
+
import re
|
|
17
|
+
import sqlite3
|
|
18
|
+
import sys
|
|
19
|
+
import tempfile
|
|
20
|
+
from dataclasses import asdict
|
|
21
|
+
from datetime import datetime, timezone
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
from factory.cli.errors import EXIT_OK, EXIT_TRANSPORT, EXIT_USER, OperatorError
|
|
26
|
+
from factory.doctor.models import Finding, Severity, Status, parse_findings_batch
|
|
27
|
+
import factory.doctor.probes as _probes
|
|
28
|
+
from factory.doctor.scaffold import scaffold_spec
|
|
29
|
+
from factory.doctor.store import (
|
|
30
|
+
connect,
|
|
31
|
+
get_finding,
|
|
32
|
+
list_findings,
|
|
33
|
+
promote,
|
|
34
|
+
report,
|
|
35
|
+
resolve,
|
|
36
|
+
resolve_by_spec,
|
|
37
|
+
)
|
|
38
|
+
from factory.roadmap.models import _split_frontmatter
|
|
39
|
+
from factory.workgraph.derive import DerivationError, derive_workgraph
|
|
40
|
+
from factory.workgraph.worktree import resolve_factory_root
|
|
41
|
+
import factory.doctor.cli as _doctor_cli
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
#: Credential-like values must never reach findings or output.
|
|
45
|
+
_CREDENTIAL_RE = re.compile(r"sk-[A-Za-z0-9_\-]{8,}")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _utcnow() -> str:
|
|
49
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _store_path(args: argparse.Namespace) -> Path:
|
|
53
|
+
explicit = getattr(args, "db", None)
|
|
54
|
+
if explicit is not None:
|
|
55
|
+
return Path(explicit)
|
|
56
|
+
root, _choice, _source = resolve_factory_root()
|
|
57
|
+
return _doctor_cli._resolve_store_path(root)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _contains_secret(value: str | None) -> bool:
|
|
61
|
+
if value is None:
|
|
62
|
+
return False
|
|
63
|
+
return bool(_CREDENTIAL_RE.search(value))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _sanitize_text(value: str | None) -> str | None:
|
|
67
|
+
if value is None:
|
|
68
|
+
return None
|
|
69
|
+
return _CREDENTIAL_RE.sub("[REDACTED]", value)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _sanitize_finding(finding: Finding) -> Finding:
|
|
73
|
+
"""Return a finding with any credential-like strings redacted."""
|
|
74
|
+
return Finding(
|
|
75
|
+
key=finding.key,
|
|
76
|
+
category=finding.category,
|
|
77
|
+
severity=finding.severity,
|
|
78
|
+
status=finding.status,
|
|
79
|
+
summary=_sanitize_text(finding.summary),
|
|
80
|
+
refs=[_sanitize_text(ref) or "" for ref in finding.refs],
|
|
81
|
+
notes=_sanitize_text(finding.notes),
|
|
82
|
+
source=finding.source,
|
|
83
|
+
occurrences=finding.occurrences,
|
|
84
|
+
first_seen=finding.first_seen,
|
|
85
|
+
last_seen=finding.last_seen,
|
|
86
|
+
promoted_spec=finding.promoted_spec,
|
|
87
|
+
resolved_at=finding.resolved_at,
|
|
88
|
+
resolution=finding.resolution,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _render_age(now: datetime, last_seen: str) -> str:
|
|
93
|
+
if not last_seen:
|
|
94
|
+
return "-"
|
|
95
|
+
try:
|
|
96
|
+
seen = datetime.fromisoformat(last_seen.replace("Z", "+00:00"))
|
|
97
|
+
except ValueError:
|
|
98
|
+
return "-"
|
|
99
|
+
delta = now - seen
|
|
100
|
+
total_seconds = int(delta.total_seconds())
|
|
101
|
+
if total_seconds < 0:
|
|
102
|
+
return "-"
|
|
103
|
+
if total_seconds < 3600:
|
|
104
|
+
return f"{total_seconds // 60}m"
|
|
105
|
+
if total_seconds < 86400:
|
|
106
|
+
return f"{total_seconds // 3600}h"
|
|
107
|
+
return f"{total_seconds // 86400}d"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _resolve_promoted_findings(conn: sqlite3.Connection) -> None:
|
|
111
|
+
"""Resolve promoted findings whose spec now attests `state: landed`."""
|
|
112
|
+
resolved_at = _utcnow()
|
|
113
|
+
for finding in list_findings(conn):
|
|
114
|
+
if finding.status is not Status.PROMOTED or not finding.promoted_spec:
|
|
115
|
+
continue
|
|
116
|
+
spec_path = Path(finding.promoted_spec) / "spec.md"
|
|
117
|
+
try:
|
|
118
|
+
text = spec_path.read_text(encoding="utf-8")
|
|
119
|
+
except OSError:
|
|
120
|
+
continue
|
|
121
|
+
state = _read_spec_state(text)
|
|
122
|
+
if state == "landed":
|
|
123
|
+
resolve_by_spec(
|
|
124
|
+
conn,
|
|
125
|
+
finding.key,
|
|
126
|
+
spec_dir=finding.promoted_spec,
|
|
127
|
+
resolved_at=resolved_at,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _read_spec_state(text: str) -> str | None:
|
|
132
|
+
block_text, _body = _split_frontmatter(text)
|
|
133
|
+
if block_text is None:
|
|
134
|
+
return None
|
|
135
|
+
import yaml
|
|
136
|
+
|
|
137
|
+
try:
|
|
138
|
+
loaded = yaml.safe_load(block_text)
|
|
139
|
+
except yaml.YAMLError:
|
|
140
|
+
return None
|
|
141
|
+
if not isinstance(loaded, dict):
|
|
142
|
+
return None
|
|
143
|
+
return loaded.get("state")
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# --- doctor -------------------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def add_doctor_parser(subparsers: argparse._SubParsersAction) -> argparse.ArgumentParser:
|
|
150
|
+
parser = subparsers.add_parser(
|
|
151
|
+
"doctor",
|
|
152
|
+
help="run all registered probes",
|
|
153
|
+
description="Run every registered probe and file any findings.",
|
|
154
|
+
)
|
|
155
|
+
parser.add_argument(
|
|
156
|
+
"--db",
|
|
157
|
+
default=None,
|
|
158
|
+
help="path to the findings store (default: resolved runtime root / doctor.db)",
|
|
159
|
+
)
|
|
160
|
+
parser.set_defaults(run=doctor_command)
|
|
161
|
+
return parser
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def doctor_command(args: argparse.Namespace) -> int:
|
|
165
|
+
"""Run every probe, file findings, and return the unified exit code."""
|
|
166
|
+
path = _store_path(args)
|
|
167
|
+
conn = connect(path)
|
|
168
|
+
try:
|
|
169
|
+
_resolve_promoted_findings(conn)
|
|
170
|
+
return _run_all_probes(conn)
|
|
171
|
+
finally:
|
|
172
|
+
conn.close()
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _run_all_probes(conn: sqlite3.Connection) -> int:
|
|
176
|
+
seen_at = _utcnow()
|
|
177
|
+
skipped_services: list[str] = []
|
|
178
|
+
new_findings: list[Finding] = []
|
|
179
|
+
unexpected_seen = False
|
|
180
|
+
|
|
181
|
+
for probe in _probes.REGISTRY:
|
|
182
|
+
reports, probe_unexpected = _run_one_probe(probe, skipped_services)
|
|
183
|
+
if probe_unexpected:
|
|
184
|
+
unexpected_seen = True
|
|
185
|
+
if reports is None:
|
|
186
|
+
continue
|
|
187
|
+
for report in reports:
|
|
188
|
+
finding = _sanitize_finding(report.to_finding(source=probe.name))
|
|
189
|
+
was_new = _report_if_new(conn, finding, seen_at=seen_at)
|
|
190
|
+
if was_new and finding.severity is Severity.CRITICAL:
|
|
191
|
+
new_findings.append(finding)
|
|
192
|
+
|
|
193
|
+
if skipped_services:
|
|
194
|
+
return EXIT_TRANSPORT
|
|
195
|
+
if unexpected_seen:
|
|
196
|
+
return EXIT_USER
|
|
197
|
+
if new_findings:
|
|
198
|
+
return EXIT_USER
|
|
199
|
+
return EXIT_OK
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _run_one_probe(
|
|
203
|
+
probe: _probes.Probe,
|
|
204
|
+
skipped_services: list[str],
|
|
205
|
+
) -> tuple[list[_probes.FindingReport] | None, bool]:
|
|
206
|
+
"""Gather and evaluate one probe, swallowing non-service exceptions."""
|
|
207
|
+
try:
|
|
208
|
+
snapshot = probe.gather()
|
|
209
|
+
except _probes.ServiceNotAnswering as exc:
|
|
210
|
+
reason = f" ({exc})" if str(exc) else ""
|
|
211
|
+
print(
|
|
212
|
+
f"ergane doctor: {probe.name}: skipped ({exc.service} not answering){reason}",
|
|
213
|
+
file=sys.stderr,
|
|
214
|
+
)
|
|
215
|
+
skipped_services.append(exc.service)
|
|
216
|
+
return None, False
|
|
217
|
+
except Exception as exc:
|
|
218
|
+
print(
|
|
219
|
+
f"ergane doctor: {probe.name}: unexpected error ({exc}); "
|
|
220
|
+
"re-run with --debug for the traceback",
|
|
221
|
+
file=sys.stderr,
|
|
222
|
+
)
|
|
223
|
+
return None, True
|
|
224
|
+
return probe.evaluate(snapshot), False
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _report_if_new(
|
|
228
|
+
conn: sqlite3.Connection, finding: Finding, *, seen_at: str
|
|
229
|
+
) -> bool:
|
|
230
|
+
prior = get_finding(conn, finding.key)
|
|
231
|
+
report(conn, finding, seen_at=seen_at)
|
|
232
|
+
return prior is None
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
# --- findings -----------------------------------------------------------------
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def add_findings_parser(subparsers: argparse._SubParsersAction) -> argparse.ArgumentParser:
|
|
239
|
+
db_parent = argparse.ArgumentParser(add_help=False)
|
|
240
|
+
db_parent.add_argument(
|
|
241
|
+
"--db",
|
|
242
|
+
default=None,
|
|
243
|
+
help="path to the findings store (default: resolved runtime root / doctor.db)",
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
parser = subparsers.add_parser(
|
|
247
|
+
"findings",
|
|
248
|
+
help="manage the findings ledger",
|
|
249
|
+
description="Report, list, resolve, or promote findings.",
|
|
250
|
+
)
|
|
251
|
+
verbs = parser.add_subparsers(dest="verb", required=True)
|
|
252
|
+
|
|
253
|
+
list_parser = verbs.add_parser("list", help="list findings", parents=[db_parent])
|
|
254
|
+
list_parser.add_argument(
|
|
255
|
+
"--severity", choices=[s.value for s in Severity], help="filter by severity"
|
|
256
|
+
)
|
|
257
|
+
list_parser.add_argument(
|
|
258
|
+
"--status", choices=[s.value for s in Status], help="filter by status"
|
|
259
|
+
)
|
|
260
|
+
list_parser.add_argument("--json", action="store_true", help="emit JSON")
|
|
261
|
+
list_parser.set_defaults(run=_with_store(findings_list_command))
|
|
262
|
+
|
|
263
|
+
report_parser = verbs.add_parser("report", help="record a finding", parents=[db_parent])
|
|
264
|
+
report_parser.add_argument("--key", help="category/slug identity")
|
|
265
|
+
report_parser.add_argument("--category", help="finding category")
|
|
266
|
+
report_parser.add_argument(
|
|
267
|
+
"--severity", choices=[s.value for s in Severity], help="critical|warning|info"
|
|
268
|
+
)
|
|
269
|
+
report_parser.add_argument("--summary", help="short description")
|
|
270
|
+
report_parser.add_argument(
|
|
271
|
+
"--refs", nargs="+", default=[], help="file:line reference strings"
|
|
272
|
+
)
|
|
273
|
+
report_parser.add_argument("--notes", default=None, help="extra evidence")
|
|
274
|
+
report_parser.add_argument("--source", default="operator", help="reporter source")
|
|
275
|
+
report_parser.add_argument(
|
|
276
|
+
"--batch",
|
|
277
|
+
metavar="FILE",
|
|
278
|
+
help="ingest findings from a JSON batch file (all-or-nothing)",
|
|
279
|
+
)
|
|
280
|
+
report_parser.set_defaults(run=_with_store(findings_report_command))
|
|
281
|
+
|
|
282
|
+
resolve_parser = verbs.add_parser("resolve", help="resolve a finding", parents=[db_parent])
|
|
283
|
+
resolve_parser.add_argument("--key", required=True, help="finding to resolve")
|
|
284
|
+
resolve_parser.add_argument("--reason", required=True, help="why it is resolved")
|
|
285
|
+
resolve_parser.set_defaults(run=_with_store(findings_resolve_command))
|
|
286
|
+
|
|
287
|
+
promote_parser = verbs.add_parser("promote", help="scaffold a spec from findings", parents=[db_parent])
|
|
288
|
+
promote_parser.add_argument("--slug", required=True, help="target spec directory name")
|
|
289
|
+
promote_parser.add_argument(
|
|
290
|
+
"--keys", nargs="+", default=[], help="finding keys to promote"
|
|
291
|
+
)
|
|
292
|
+
promote_parser.add_argument(
|
|
293
|
+
"--specs-root",
|
|
294
|
+
required=True,
|
|
295
|
+
help="parent directory where the spec directory will be created",
|
|
296
|
+
)
|
|
297
|
+
promote_parser.add_argument(
|
|
298
|
+
"--target-repo",
|
|
299
|
+
required=True,
|
|
300
|
+
help="target repo path recorded in the compiled workgraph",
|
|
301
|
+
)
|
|
302
|
+
promote_parser.set_defaults(run=_with_store(findings_promote_command))
|
|
303
|
+
|
|
304
|
+
return parser
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _with_store(command: Any) -> Any:
|
|
308
|
+
"""Return a runner that opens the store and calls the verb."""
|
|
309
|
+
|
|
310
|
+
def run(args: argparse.Namespace) -> int:
|
|
311
|
+
path = _store_path(args)
|
|
312
|
+
conn = connect(path)
|
|
313
|
+
try:
|
|
314
|
+
_resolve_promoted_findings(conn)
|
|
315
|
+
return int(command(args, conn))
|
|
316
|
+
finally:
|
|
317
|
+
conn.close()
|
|
318
|
+
|
|
319
|
+
return run
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def findings_list_command(args: argparse.Namespace, conn: sqlite3.Connection) -> int:
|
|
323
|
+
severity = Severity(args.severity) if args.severity else None
|
|
324
|
+
status = Status(args.status) if args.status else None
|
|
325
|
+
|
|
326
|
+
findings = list_findings(conn)
|
|
327
|
+
if severity is not None:
|
|
328
|
+
findings = [f for f in findings if f.severity is severity]
|
|
329
|
+
if status is not None:
|
|
330
|
+
findings = [f for f in findings if f.status is status]
|
|
331
|
+
|
|
332
|
+
if args.json:
|
|
333
|
+
print(json.dumps([asdict(f) for f in findings], indent=2))
|
|
334
|
+
return EXIT_OK
|
|
335
|
+
|
|
336
|
+
now = datetime.fromisoformat(_utcnow().replace("Z", "+00:00"))
|
|
337
|
+
header = f"{'KEY':<45} {'SEV':<8} {'STATUS':<10} {'#':>5} {'AGE':<6}"
|
|
338
|
+
print(header)
|
|
339
|
+
for finding in findings:
|
|
340
|
+
age = _render_age(now, finding.last_seen)
|
|
341
|
+
print(
|
|
342
|
+
f"{finding.key:<45} {finding.severity.value:<8} "
|
|
343
|
+
f"{finding.status.value:<10} {finding.occurrences:>5} {age:<6}"
|
|
344
|
+
)
|
|
345
|
+
return EXIT_OK
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def findings_report_command(args: argparse.Namespace, conn: sqlite3.Connection) -> int:
|
|
349
|
+
seen_at = _utcnow()
|
|
350
|
+
if args.batch:
|
|
351
|
+
try:
|
|
352
|
+
findings = parse_findings_batch(Path(args.batch).read_text())
|
|
353
|
+
except (ValueError, OSError) as exc:
|
|
354
|
+
raise OperatorError(f"batch refused: {exc}") from exc
|
|
355
|
+
for raw in findings:
|
|
356
|
+
if _contains_secret(raw.summary) or _contains_secret(raw.notes):
|
|
357
|
+
raise OperatorError(
|
|
358
|
+
f"batch refused: finding {raw.key!r} contains a credential-like value"
|
|
359
|
+
)
|
|
360
|
+
if any(_contains_secret(ref) for ref in raw.refs):
|
|
361
|
+
raise OperatorError(
|
|
362
|
+
f"batch refused: finding {raw.key!r} contains a credential-like value"
|
|
363
|
+
)
|
|
364
|
+
report(conn, raw, seen_at=seen_at)
|
|
365
|
+
return EXIT_OK
|
|
366
|
+
|
|
367
|
+
missing = [
|
|
368
|
+
name
|
|
369
|
+
for name in ("key", "category", "severity", "summary")
|
|
370
|
+
if getattr(args, name) is None
|
|
371
|
+
]
|
|
372
|
+
if missing:
|
|
373
|
+
raise OperatorError(f"missing required flags: {', '.join(missing)}")
|
|
374
|
+
|
|
375
|
+
try:
|
|
376
|
+
severity = Severity(args.severity)
|
|
377
|
+
except ValueError as exc:
|
|
378
|
+
raise OperatorError(f"unknown severity: {args.severity}") from exc
|
|
379
|
+
|
|
380
|
+
if _contains_secret(args.summary) or _contains_secret(args.notes):
|
|
381
|
+
raise OperatorError("finding evidence contains a credential-like value; refusing")
|
|
382
|
+
if any(_contains_secret(ref) for ref in args.refs):
|
|
383
|
+
raise OperatorError("finding evidence contains a credential-like value; refusing")
|
|
384
|
+
|
|
385
|
+
finding = Finding(
|
|
386
|
+
key=args.key,
|
|
387
|
+
category=args.category,
|
|
388
|
+
severity=severity,
|
|
389
|
+
status=Status.OPEN,
|
|
390
|
+
summary=args.summary,
|
|
391
|
+
refs=args.refs,
|
|
392
|
+
notes=args.notes,
|
|
393
|
+
source=args.source,
|
|
394
|
+
occurrences=1,
|
|
395
|
+
first_seen=seen_at,
|
|
396
|
+
last_seen=seen_at,
|
|
397
|
+
promoted_spec=None,
|
|
398
|
+
resolved_at=None,
|
|
399
|
+
resolution=None,
|
|
400
|
+
)
|
|
401
|
+
report(conn, finding, seen_at=seen_at)
|
|
402
|
+
return EXIT_OK
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def findings_resolve_command(args: argparse.Namespace, conn: sqlite3.Connection) -> int:
|
|
406
|
+
seen_at = _utcnow()
|
|
407
|
+
if not resolve(conn, args.key, reason=args.reason, resolved_at=seen_at):
|
|
408
|
+
raise OperatorError(f"finding {args.key!r} is not known or already resolved")
|
|
409
|
+
return EXIT_OK
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def findings_promote_command(args: argparse.Namespace, conn: sqlite3.Connection) -> int:
|
|
413
|
+
seen_at = _utcnow()
|
|
414
|
+
specs_root = Path(args.specs_root)
|
|
415
|
+
target_repo = Path(args.target_repo)
|
|
416
|
+
slug: str = args.slug
|
|
417
|
+
keys: list[str] = list(args.keys)
|
|
418
|
+
|
|
419
|
+
if not keys:
|
|
420
|
+
raise OperatorError("promote requires at least one --keys value")
|
|
421
|
+
|
|
422
|
+
unknown_or_bad: list[str] = []
|
|
423
|
+
findings: list[Finding] = []
|
|
424
|
+
for key in keys:
|
|
425
|
+
finding = get_finding(conn, key)
|
|
426
|
+
if finding is None:
|
|
427
|
+
unknown_or_bad.append(f"{key}: not known")
|
|
428
|
+
continue
|
|
429
|
+
if finding.status in (Status.PROMOTED, Status.RESOLVED):
|
|
430
|
+
unknown_or_bad.append(
|
|
431
|
+
f"{key}: already {finding.status.value}"
|
|
432
|
+
+ (f" into {finding.promoted_spec}" if finding.promoted_spec else "")
|
|
433
|
+
)
|
|
434
|
+
continue
|
|
435
|
+
findings.append(_sanitize_finding(finding))
|
|
436
|
+
|
|
437
|
+
if unknown_or_bad:
|
|
438
|
+
raise OperatorError("promote refused:\n" + "\n".join(unknown_or_bad))
|
|
439
|
+
|
|
440
|
+
specs_root.mkdir(parents=True, exist_ok=True)
|
|
441
|
+
|
|
442
|
+
spec_dir = specs_root / slug
|
|
443
|
+
if spec_dir.exists():
|
|
444
|
+
raise OperatorError(
|
|
445
|
+
f"spec directory {spec_dir} already exists; promote refuses to overwrite"
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
spec_text, plan_text, tasks_text = scaffold_spec(
|
|
449
|
+
slug=slug,
|
|
450
|
+
findings=findings,
|
|
451
|
+
specs_root=str(specs_root),
|
|
452
|
+
target_repo=str(target_repo),
|
|
453
|
+
)
|
|
454
|
+
|
|
455
|
+
with tempfile.TemporaryDirectory(
|
|
456
|
+
dir=specs_root, prefix=f".tmp-promote-{slug}-"
|
|
457
|
+
) as tmp:
|
|
458
|
+
temp_dir = Path(tmp)
|
|
459
|
+
(temp_dir / "spec.md").write_text(spec_text, encoding="utf-8")
|
|
460
|
+
(temp_dir / "plan.md").write_text(plan_text, encoding="utf-8")
|
|
461
|
+
(temp_dir / "tasks.md").write_text(tasks_text, encoding="utf-8")
|
|
462
|
+
|
|
463
|
+
try:
|
|
464
|
+
derive_workgraph(
|
|
465
|
+
spec_text,
|
|
466
|
+
epic_id=slug,
|
|
467
|
+
feature=slug,
|
|
468
|
+
specs_root=str(specs_root),
|
|
469
|
+
target_repo=str(target_repo),
|
|
470
|
+
)
|
|
471
|
+
except DerivationError as error:
|
|
472
|
+
raise OperatorError(f"scaffold does not compile: {error}") from error
|
|
473
|
+
|
|
474
|
+
temp_dir.rename(spec_dir)
|
|
475
|
+
|
|
476
|
+
spec_dir_abs = spec_dir.resolve()
|
|
477
|
+
promote(conn, keys, spec_dir=str(spec_dir_abs), seen_at=seen_at)
|
|
478
|
+
print(spec_dir_abs)
|
|
479
|
+
return EXIT_OK
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def findings_command(args: argparse.Namespace) -> int:
|
|
483
|
+
"""Entry point the noun module wires in."""
|
|
484
|
+
return _findings_command(args)
|