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.
Files changed (121) hide show
  1. ergane_cli-0.1.0.dist-info/METADATA +148 -0
  2. ergane_cli-0.1.0.dist-info/RECORD +121 -0
  3. ergane_cli-0.1.0.dist-info/WHEEL +4 -0
  4. ergane_cli-0.1.0.dist-info/entry_points.txt +2 -0
  5. ergane_cli-0.1.0.dist-info/licenses/LICENSE +202 -0
  6. ergane_cli-0.1.0.dist-info/licenses/NOTICE +27 -0
  7. factory/__init__.py +0 -0
  8. factory/activities/__init__.py +0 -0
  9. factory/activities/agent_activities.py +745 -0
  10. factory/activities/merge_activities.py +654 -0
  11. factory/activities/notify_activities.py +1078 -0
  12. factory/activities/roadmap_activities.py +577 -0
  13. factory/activities/usage_activities.py +593 -0
  14. factory/activities/verify_activities.py +617 -0
  15. factory/cli/__init__.py +0 -0
  16. factory/cli/completion.py +150 -0
  17. factory/cli/doctor.py +484 -0
  18. factory/cli/env.py +247 -0
  19. factory/cli/errors.py +77 -0
  20. factory/cli/init.py +1057 -0
  21. factory/cli/install.py +679 -0
  22. factory/cli/main.py +212 -0
  23. factory/cli/nouns/__init__.py +59 -0
  24. factory/cli/nouns/answer.py +134 -0
  25. factory/cli/nouns/build.py +1222 -0
  26. factory/cli/nouns/completion.py +13 -0
  27. factory/cli/nouns/doctor.py +13 -0
  28. factory/cli/nouns/env.py +13 -0
  29. factory/cli/nouns/escalations.py +93 -0
  30. factory/cli/nouns/findings.py +13 -0
  31. factory/cli/nouns/init.py +13 -0
  32. factory/cli/nouns/install.py +99 -0
  33. factory/cli/nouns/repo.py +13 -0
  34. factory/cli/nouns/roadmap.py +13 -0
  35. factory/cli/nouns/spec.py +511 -0
  36. factory/cli/nouns/status.py +18 -0
  37. factory/cli/nouns/usage.py +13 -0
  38. factory/cli/nouns/worker.py +69 -0
  39. factory/cli/repo.py +569 -0
  40. factory/cli/repo_export.py +282 -0
  41. factory/cli/roadmap.py +434 -0
  42. factory/cli/status.py +844 -0
  43. factory/cli/usage.py +129 -0
  44. factory/config.py +262 -0
  45. factory/controlplane/__init__.py +0 -0
  46. factory/controlplane/config.py +863 -0
  47. factory/controlplane/resolve.py +434 -0
  48. factory/controlplane/verify.py +827 -0
  49. factory/discovery/__init__.py +3 -0
  50. factory/discovery/llm_scanner.py +176 -0
  51. factory/doctor/__init__.py +0 -0
  52. factory/doctor/cli.py +392 -0
  53. factory/doctor/models.py +165 -0
  54. factory/doctor/probes.py +522 -0
  55. factory/doctor/scaffold.py +184 -0
  56. factory/doctor/store.py +449 -0
  57. factory/env.py +131 -0
  58. factory/escalation/__init__.py +0 -0
  59. factory/escalation/client.py +168 -0
  60. factory/escalation/question.py +161 -0
  61. factory/escalation/workflow.py +470 -0
  62. factory/locking.py +85 -0
  63. factory/mergequeue/__init__.py +21 -0
  64. factory/mergequeue/classify.py +133 -0
  65. factory/mergequeue/forge.py +401 -0
  66. factory/mergequeue/gh.py +484 -0
  67. factory/mergequeue/github_forge.py +349 -0
  68. factory/mergequeue/merge_queue_ruleset.json +32 -0
  69. factory/mergequeue/messages.py +141 -0
  70. factory/mergequeue/models.py +324 -0
  71. factory/mergequeue/onboard.py +537 -0
  72. factory/mergequeue/wiring.py +466 -0
  73. factory/notify/__init__.py +0 -0
  74. factory/notify/adapter.py +263 -0
  75. factory/notify/messages.py +474 -0
  76. factory/notify/service.py +809 -0
  77. factory/notify/webhook.py +151 -0
  78. factory/personas.yaml +161 -0
  79. factory/registry.py +583 -0
  80. factory/roadmap/__init__.py +8 -0
  81. factory/roadmap/cli.py +125 -0
  82. factory/roadmap/discovery.py +286 -0
  83. factory/roadmap/models.py +583 -0
  84. factory/roadmap/schedule.py +377 -0
  85. factory/roadmap/workflow.py +1364 -0
  86. factory/supervision/__init__.py +0 -0
  87. factory/supervision/alert.py +239 -0
  88. factory/supervision/probe.py +467 -0
  89. factory/supervision/temporal_server.py +121 -0
  90. factory/supervision/units.py +649 -0
  91. factory/usage/__init__.py +0 -0
  92. factory/usage/aggregate.py +113 -0
  93. factory/usage/cli.py +223 -0
  94. factory/usage/ledger.py +295 -0
  95. factory/usage/litellm_client.py +486 -0
  96. factory/usage/models.py +149 -0
  97. factory/verify/__init__.py +0 -0
  98. factory/verify/criteria.py +488 -0
  99. factory/verify/diffbounds.py +159 -0
  100. factory/verify/diffcheck.py +506 -0
  101. factory/verify/factory_yaml.py +842 -0
  102. factory/verify/gates.py +1341 -0
  103. factory/verify/judge.py +775 -0
  104. factory/verify/ladder.py +145 -0
  105. factory/verify/models.py +745 -0
  106. factory/verify/question.py +167 -0
  107. factory/verify/store.py +1366 -0
  108. factory/verify/toolchain.py +370 -0
  109. factory/worker.py +296 -0
  110. factory/workgraph/__init__.py +0 -0
  111. factory/workgraph/adapter.py +1333 -0
  112. factory/workgraph/cli.py +801 -0
  113. factory/workgraph/delta.py +278 -0
  114. factory/workgraph/derive.py +607 -0
  115. factory/workgraph/detector.py +509 -0
  116. factory/workgraph/landed.py +496 -0
  117. factory/workgraph/models.py +494 -0
  118. factory/workgraph/preflight.py +651 -0
  119. factory/workgraph/prompt.py +796 -0
  120. factory/workgraph/workflow.py +2733 -0
  121. 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)