From eee2cd0526fe6a1e8d9674f4e06766d72fdd1ce1 Mon Sep 17 00:00:00 2001 From: NiveditJain Date: Tue, 22 Sep 2026 00:35:22 +0530 Subject: [PATCH 1/3] feat(fp): close, archive, and bulk-clear issues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `fp issues` gains four commands, matching new endpoints on the AgentEye server. `close` is a second terminal state: "we're done with it", not "we fixed it". The difference is what happens next — a *resolved* issue reopens when its audit finding recurs, because a claimed fix that did not hold is worth surfacing, while a *closed* one does not, because a recurrence is not new information about a decision. On an audit issue it marks the finding `dismissed` and deliberately writes no org-wide fingerprint suppression, so closing one issue never hides that pattern in another audit. `archive` / `unarchive` toggle a flag orthogonal to state, so taking an issue off the board never overwrites how it ended. `clear` is the operation this work started from: a customer who has rewritten their agents wants the board reset, and resolving issues one at a time is not a realistic ask. It resolves every open issue in a scope plus the audit findings behind them, in one server-side transaction. Exactly one of `--audit `, `--all-audits` and `--everything` is required — the three have very different blast radii, so there is no default. `--dry-run` previews from the same server-side scope predicate the write uses, so the count a user confirms is the count that changes. Clearing writes NO suppression, and the help text and skill both lead with that: a pattern the agent changes genuinely fixed stays gone, one that survived them opens a NEW issue on the next run. Users hear "clear" and expect "silence"; the honest behaviour is the opposite, and the CLI has to say so. `--state closed` is accepted by `issues list` (the client-side allowlist would otherwise reject, with exit 2, a state the server accepts), and `Incident` carries `closed_at` / `archived_at` — `from_dict` is an allowlist, so a field missing there renders as a permanently blank column with no error anywhere. Also updates the agent skill (including a "resolve vs close" note, since they are not synonyms and a model will otherwise pick either) and the public docs. 12 tests cover the new paths: that `close` posts to `/close` and not `/resolve`, that a declined confirm writes nothing, and that `--dry-run` issues exactly one request. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016fc87ZDEutekXnwy75Gq1e --- CHANGELOG.md | 10 + docs/audits/findings-and-issues.mdx | 57 +++++ docs/reference/cloud-cli.mdx | 8 +- fp-cloud-cli/CHANGELOG.md | 23 +- fp-cloud-cli/README.md | 2 +- fp-cloud-cli/fp_cli/client.py | 44 ++++ .../fp_cli/commands/incidents_cmds.py | 227 +++++++++++++++++- fp-cloud-cli/fp_cli/models.py | 11 + fp-cloud-cli/fp_cli/output.py | 67 ++++++ fp-cloud-cli/skill/SKILL.md | 14 +- fp-cloud-cli/skill/references/commands.md | 9 +- fp-cloud-cli/tests/test_alerting.py | 184 ++++++++++++++ 12 files changed, 641 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 023bb293e..26de8f15c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## 1.0.7-beta.0 — 2026-09-22 + +### Added + +- `fp issues` gains `close`, `archive`, `unarchive` and `clear`. `close` is the second terminal state — "we're done with it", not "we fixed it" — and the difference is what happens next: a **resolved** issue reopens when its audit finding recurs, a **closed** one does not. `archive`/`unarchive` toggle a flag that is orthogonal to state, so taking an issue off the board never overwrites how it ended. `clear` is the bulk operation behind "we changed our agents, give us a fresh board": it resolves every open issue in a scope (`--audit `, `--all-audits`, or `--everything` — exactly one required, no default) plus the audit findings behind them, needs `issues:close` **and** `audits:write`, and previews with `--dry-run` from the same server-side scope predicate the write uses. It writes **no** suppression, so a pattern that survived the agent changes opens a new issue on the next run rather than staying hidden. `--state closed` is accepted by `issues list`, and `Incident` carries `closed_at` / `archived_at`. (#815) + +### Docs + +- Document ending an issue three ways (resolve / close / archive) and clearing a board after an agent change, in the audits guide and the Cloud CLI reference. (#815) + ## 1.0.6 — 2026-09-16 Stable native Hermes policy-enforcement release, validated across CLI and diff --git a/docs/audits/findings-and-issues.mdx b/docs/audits/findings-and-issues.mdx index 728815f80..5e62172ab 100644 --- a/docs/audits/findings-and-issues.mdx +++ b/docs/audits/findings-and-issues.mdx @@ -43,6 +43,8 @@ A finding is the audit's evidence-backed statement about a failure. An issue is fp issues assign --assignee engineer@example.com fp issues comment-add --body "policy is in observe mode" fp issues resolve --yes + fp issues close --yes + fp issues archive ``` Use `fp issues subscribe `, `fp issues unsubscribe `, and `fp issues subscribers ` to manage watchers. @@ -67,6 +69,61 @@ Create or link an issue when the finding needs assignment, discussion, status ch Resolve the issue when remediation is deployed and verified. Resolve the finding when the failure mode has been addressed for the audit population. Those moments may differ. +## End an issue: resolve, close, or archive + +An issue ends once, and how you end it decides what happens the next time the audit +sees the same pattern. + +| Action | Means | If the pattern comes back | +| --- | --- | --- | +| **Resolve** | You fixed it. | The issue **reopens**, so you find out the fix did not hold. | +| **Close** | You are done with it: won't fix, not a problem, or no longer relevant. | It **stays closed**. | +| **Archive** | Take it off the board. Says nothing about how it ended. | A live issue returns to the board automatically. | + +Resolve and close are both final and neither can overwrite the other, so an issue +someone resolved keeps that record. Archiving is separate from both: you can archive +an issue in any state, and it keeps whatever state it ended in. If an archived issue +is still live and the problem recurs, it comes back to the board on its own — archive +hides history, it cannot hide an active problem. + +Closing an issue that came from an audit also dismisses the finding behind it. It +does not silence that pattern in your other audits; for that, mute or dismiss the +finding itself. + +## Start fresh after changing your agents + +When you ship a round of changes to your agents, the issues already on the board +describe the behavior you just replaced. Clearing resolves them in one step, along +with the audit findings behind them. + + + + 1. Go to **Analyze → Issues** and select **clear**, or open a single audit and select **clear issues** to limit it to that audit's work. + 2. Choose the scope. Each one shows how many issues it covers before you commit to it. + 3. Confirm. The issues are resolved, and so are the audit findings behind them. + + + ```bash + fp issues clear --all-audits --dry-run + fp issues clear --all-audits --yes + + fp issues clear --audit --yes + fp issues clear --everything --yes + ``` + + `--dry-run` reports what would change without changing it. Exactly one of + `--audit`, `--all-audits`, and `--everything` is required. + + + +**Clearing suppresses nothing.** A pattern your changes genuinely fixed stays gone. A +pattern that survived them opens a **new** issue on the next audit run, so a fresh +start cannot quietly hide a problem you still have. When you do want a pattern +silenced for good, mute or dismiss the finding instead. + +Clearing needs permission to both close issues and write audits, because it resolves +the findings as well as the issues. + ## Turn an issue into a policy draft diff --git a/docs/reference/cloud-cli.mdx b/docs/reference/cloud-cli.mdx index 404acc524..9eca539b5 100644 --- a/docs/reference/cloud-cli.mdx +++ b/docs/reference/cloud-cli.mdx @@ -291,13 +291,17 @@ Include context during creation when the first run needs it. Creation commits th | Command | Purpose | Options | | --- | --- | --- | -| `fp issues list` | List issues. | `--state`; `--alert-id`; `--limit`, `-n`; `--show-id` | +| `fp issues list` | List issues. Archived issues are hidden. | `--state`; `--alert-id`; `--limit`, `-n`; `--show-id` | | `fp issues count` | Count open or selected issue states. | `--state` | | `fp issues show INCIDENT_ID` | Show issue details, comments, subscribers, and activity. | — | | `fp issues open` | Open a manual or alert-linked issue. | required `--summary`; optional `--title`, `--alert-id`, `--severity` | | `fp issues ack INCIDENT_ID` | Acknowledge an issue. | — | | `fp issues assign INCIDENT_ID` | Replace assignees; omit the option to clear them. | repeatable `--assignee` | -| `fp issues resolve INCIDENT_ID` | Resolve an issue. | `--yes`, `-y` | +| `fp issues resolve INCIDENT_ID` | Resolve an issue: the problem is fixed. A recurring audit finding reopens it. | `--yes`, `-y` | +| `fp issues close INCIDENT_ID` | Close an issue: you are done with it, fixed or not. A recurrence does not reopen it. | `--yes`, `-y` | +| `fp issues archive INCIDENT_ID` | Take an issue off the board without changing how it ended. | — | +| `fp issues unarchive INCIDENT_ID` | Put an archived issue back on the board. | — | +| `fp issues clear` | Resolve every open issue in a scope, plus the audit findings behind them. Requires exactly one scope flag. | one of `--audit`, `--all-audits`, `--everything`; `--dry-run`; `--yes`, `-y` | | `fp issues comment-list INCIDENT_ID` | List comments. | — | | `fp issues comment-add INCIDENT_ID` | Add a comment. | exactly one of `--body`, `--file` | | `fp issues comment-delete INCIDENT_ID COMMENT_ID` | Delete a comment. | `--yes`, `-y` | diff --git a/fp-cloud-cli/CHANGELOG.md b/fp-cloud-cli/CHANGELOG.md index f22fdc71f..40e6bf946 100644 --- a/fp-cloud-cli/CHANGELOG.md +++ b/fp-cloud-cli/CHANGELOG.md @@ -3,9 +3,26 @@ ## 0.0.1b3 — 2026-09-12 Open for the next release. `0.0.1b2` published on 2026-09-12 and the `bump` job -moved the version here automatically; nothing has landed against `0.0.1b3` yet. -Add entries as changes merge — this section becomes the GitHub Release body when -it ships. +moved the version here automatically. This section becomes the GitHub Release +body when it ships. + +### Added + +- `fp issues close ` — end an issue as won't-fix. Distinct from `resolve`: a + recurring audit finding reopens a **resolved** issue and leaves a **closed** one + alone. On an audit issue it marks the finding `dismissed`, without writing the + org-wide fingerprint suppression that `fp audits dismiss` writes. Exit 9 if the + issue already ended. (#815) +- `fp issues archive ` / `unarchive ` — take an issue off the board and put + it back. Orthogonal to state, allowed in any state, no confirmation prompt. (#815) +- `fp issues clear (--audit | --all-audits | --everything) [--dry-run]` — resolve + every open issue in a scope plus the audit findings behind them, in one server-side + transaction. Exactly one scope flag is required. Needs `issues:close` **and** + `audits:write`. Writes no suppression, so anything still broken returns as a new + issue on the next run. The confirm names the count from a dry run that shares its + scope predicate with the write. (#815) +- `issues list --state closed` is accepted, and `Incident` carries `closed_at` and + `archived_at`. (#815) ## 0.0.1b2 — 2026-08-25 diff --git a/fp-cloud-cli/README.md b/fp-cloud-cli/README.md index a66b83c77..f07ec157c 100644 --- a/fp-cloud-cli/README.md +++ b/fp-cloud-cli/README.md @@ -90,7 +90,7 @@ fp keys list|show|create|update|disable|regenerate # API keys (secret shown fp users list|show|create|update|disable|enable fp settings list|schema|set fp alerts list|show|create|update|delete|test -fp issues list|count|show|ack|assign|resolve|comment-add|comment-list|comment-delete|subscribe|subscribers|unsubscribe|open +fp issues list|count|show|ack|assign|resolve|close|archive|unarchive|clear|comment-add|comment-list|comment-delete|subscribe|subscribers|unsubscribe|open fp audits list|show|create|edit|delete|run|runs|findings|finding # scheduled audits fp audits ack|assign|resolve|dismiss|mute|reopen # triage a finding fp audits context-show|context-set|context-refresh # reference context diff --git a/fp-cloud-cli/fp_cli/client.py b/fp-cloud-cli/fp_cli/client.py index fbaeeddf1..e4c1577bb 100644 --- a/fp-cloud-cli/fp_cli/client.py +++ b/fp-cloud-cli/fp_cli/client.py @@ -1161,6 +1161,50 @@ def resolve_incident(ctx: ClientContext, incident_id: str) -> None: _post_json(ctx, f"/api/issues/{incident_id}/resolve") +def close_incident(ctx: ClientContext, incident_id: str) -> None: + """POST /api/issues/{id}/close — end the issue as won't-fix. + + The sibling of ``resolve_incident``, and the difference is what happens next: + a resolved issue REOPENS if its audit finding recurs, a closed one does not. + 409 if the issue already ended. + """ + _post_json(ctx, f"/api/issues/{incident_id}/close") + + +def set_incident_archived(ctx: ClientContext, incident_id: str, archived: bool) -> None: + """POST /api/issues/{id}/archive or /unarchive — hide from the board, or restore. + + Does not change ``state``: an issue ends once, and archiving says nothing + about how. Idempotent on both sides, so a caller never has to read the + current value first. + """ + verb = "archive" if archived else "unarchive" + _post_json(ctx, f"/api/issues/{incident_id}/{verb}") + + +def clear_issues( + ctx: ClientContext, + *, + scope: str, + audit_id: Optional[str] = None, + dry_run: bool = False, +) -> Dict[str, Any]: + """POST /api/issues/bulk-clear — resolve every live issue in a scope. + + ``scope`` is ``audit`` (with ``audit_id``), ``all_audits`` or ``everything``. + Returns ``{issues, findings, dry_run, scope}``. + + Needs ``issues:close`` AND ``audits:write`` — clearing resolves the audit + findings behind the issues, and the second grant is what stops a key that + cannot touch one finding from resolving all of them at once. + """ + body: Dict[str, Any] = {"scope": scope, "dry_run": dry_run} + if audit_id: + body["audit_id"] = audit_id + data = _post_json(ctx, "/api/issues/bulk-clear", body) + return data if isinstance(data, dict) else {} + + def list_incident_comments(ctx: ClientContext, incident_id: str) -> List[IncidentComment]: data = _get_json(ctx, f"/api/issues/{incident_id}/comments") return [IncidentComment.from_dict(c) for c in (data if isinstance(data, list) else [])] diff --git a/fp-cloud-cli/fp_cli/commands/incidents_cmds.py b/fp-cloud-cli/fp_cli/commands/incidents_cmds.py index 92d18dea6..609074427 100644 --- a/fp-cloud-cli/fp_cli/commands/incidents_cmds.py +++ b/fp-cloud-cli/fp_cli/commands/incidents_cmds.py @@ -1,4 +1,4 @@ -"""Incident triage: incidents list/count/show/ack/assign/resolve/comment*/subscribe*/open. +"""Incident triage: incidents list/count/show/ack/assign/resolve/close/archive/clear/comment*/subscribe*/open. Incidents live under /api/issues but the triage workflow is distinct, so it gets its own top-level group. The id IS the handle (incidents have no human name), so the @@ -19,13 +19,16 @@ from . import _write _SEVERITIES = ("info", "warning", "critical") -_STATES = ("firing", "acknowledged", "resolved") +# Mirrors `issue_sync::ISSUE_STATES` on the server. `closed` is the second +# TERMINAL state (won't-fix); missing it here would reject `--state closed` +# client-side with exit 2 for a state the server accepts. +_STATES = ("firing", "acknowledged", "resolved", "closed") _UUID_RE = re.compile(r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$") def _validate_states(value: Optional[str]) -> None: """Reject an unknown ``--state`` value up front (exit 2) rather than letting the server - silently drop it and return a confusing set. Accepts a CSV of firing/acknowledged/resolved.""" + silently drop it and return a confusing set. Accepts a CSV of firing/acknowledged/resolved/closed.""" if value is None: return for s in value.split(","): @@ -67,7 +70,7 @@ def _fail(state: AppState, exc: Exception, *, incident_id: str = "") -> None: def incidents_list( ctx: typer.Context, - state_filter: Optional[str] = typer.Option(None, "--state", help="Filter by state(s): firing, acknowledged, resolved (CSV)."), + state_filter: Optional[str] = typer.Option(None, "--state", help="Filter by state(s): firing, acknowledged, resolved, closed (CSV)."), alert_id: Optional[str] = typer.Option(None, "--alert-id", help="Only incidents for this alert."), limit: int = typer.Option(50, "--limit", "-n", help="Max incidents to return."), show_id: bool = typer.Option(False, "--show-id", help="Show the full incident id instead of the short form (always full in --json)."), @@ -244,6 +247,216 @@ def incidents_resolve( output.incident_resolved(incident_id) +def incidents_close( + ctx: typer.Context, + incident_id: str = typer.Argument(..., help="Issue id."), + yes: bool = typer.Option(False, "--yes", "-y", help="Skip the confirmation prompt. The prompt only appears on an interactive terminal: under --json, or with stdin redirected, this command proceeds without asking."), +) -> None: + """Close an issue — "we're done with it", not "we fixed it". + + The difference from `resolve` is what happens next. A **resolved** issue REOPENS if its + audit finding recurs: someone claimed a fix and the fix did not hold, which is worth + knowing. A **closed** issue does not — closing records a decision (won't fix, not a + problem, stale), and a recurrence is not news about a decision. + + For an issue that came from an audit, this also marks the finding `dismissed`. It does + NOT write the finding's org-wide suppression: closing one issue never hides that pattern + in your other audits. Use `fp audits finding-status --action dismiss` for that. + + Needs `issues:close`. Exits 9 (conflict) if the issue already ended — an issue ends once, + and closing must not overwrite the record that someone believed they had fixed it. + + With `--json`: `{"closed": true, "id": ""}` (or `{cancelled: true}` on a declined + prompt). + + Example: + + * `fp issues close --yes` + """ + state: AppState = ctx.obj + cctx = require_auth(state) + if _write.should_prompt(state, yes): + alert_name = None + try: + alert_name = api.get_incident(cctx, incident_id).alert_name + except NotFoundError as exc: + _fail(state, exc, incident_id=incident_id) + except (ApiError, ForbiddenError): + alert_name = None + if not output.confirm_incident_close(incident_id, alert_name): + if state.json: + output.emit_json({"cancelled": True}) + else: + output.cancelled_plain("nothing changed") + return + try: + api.close_incident(cctx, incident_id) + except (ApiError, ForbiddenError, NotFoundError) as exc: + _fail(state, exc, incident_id=incident_id) + _write.record_action("incident_closed", resource="incident", success=True) + if state.json: + output.emit_json({"closed": True, "id": incident_id}) + else: + output.incident_closed(incident_id) + + +def incidents_archive( + ctx: typer.Context, + incident_id: str = typer.Argument(..., help="Issue id."), +) -> None: + """Take an issue off the issues board, keeping its history. + + Archiving is separate from state: it does not resolve or close anything, and an issue + that has already ended keeps the record of HOW it ended. It works on a live issue too — + you should not have to triage something in order to stop looking at it — because the + safety net is on the other side: a fresh alert breach or a recurring audit finding puts + a live issue back on the board automatically. Archive can hide a problem; it cannot keep + hiding one that is still happening. A closed (won't-fix) issue is the exception and stays + hidden, since nobody asked for it back. + + No confirmation: archiving destroys nothing and `fp issues unarchive` undoes it. + + Needs `issues:close`. With `--json`: `{"archived": true, "id": ""}`. + + Example: + + * `fp issues archive ` + """ + _set_archived(ctx, incident_id, True) + + +def incidents_unarchive( + ctx: typer.Context, + incident_id: str = typer.Argument(..., help="Issue id."), +) -> None: + """Put an archived issue back on the issues board. See `fp issues archive`. + + Needs `issues:close`. With `--json`: `{"archived": false, "id": ""}`. + + Example: + + * `fp issues unarchive ` + """ + _set_archived(ctx, incident_id, False) + + +def _set_archived(ctx: typer.Context, incident_id: str, archived: bool) -> None: + """Shared body of archive/unarchive. Two commands rather than one with a flag, so each + reads as the verb it is and neither can be invoked meaning the opposite.""" + state: AppState = ctx.obj + cctx = require_auth(state) + try: + api.set_incident_archived(cctx, incident_id, archived) + except (ApiError, ForbiddenError, NotFoundError) as exc: + _fail(state, exc, incident_id=incident_id) + _write.record_action( + "incident_archived" if archived else "incident_unarchived", + resource="incident", success=True, + ) + if state.json: + output.emit_json({"archived": archived, "id": incident_id}) + else: + output.incident_archived(incident_id, archived) + + +def incidents_clear( + ctx: typer.Context, + audit_id: Optional[str] = typer.Option(None, "--audit", help="Clear only the issues this audit raised."), + all_audits: bool = typer.Option(False, "--all-audits", help="Clear every issue any audit raised. Alert and hand-opened issues are left alone."), + everything: bool = typer.Option(False, "--everything", help="Clear every open issue in the workspace, whatever opened it."), + dry_run: bool = typer.Option(False, "--dry-run", help="Print what would be cleared and change nothing."), + yes: bool = typer.Option(False, "--yes", "-y", help="Skip the confirmation prompt. The prompt only appears on an interactive terminal: under --json, or with stdin redirected, this command proceeds without asking."), +) -> None: + """Resolve every open issue in a scope — a fresh start after changing your agents. + + Exactly one of `--audit `, `--all-audits` or `--everything` is required: the three + have very different blast radii, so there is deliberately no default. + + This is the same thing as resolving each issue by hand, including what it does NOT do. + **Nothing is deleted, and nothing is suppressed.** A pattern your agent changes genuinely + fixed stays gone; one they did not opens a NEW issue on the next audit run. If you want a + pattern silenced for good, that is `fp audits finding-status --action mute`, which is + a different and much bigger hammer. + + Run it with `--dry-run` first: the count comes from the server and is taken with the same + scope predicate the write uses, so the number you see is the number of rows that change. + + Needs `issues:close` AND `audits:write` — clearing resolves the audit findings behind the + issues, so a key that cannot touch one finding cannot resolve all of them at once. + + With `--json`: `{"issues": n, "findings": n, "dry_run": bool, "scope": "..."}` (or + `{cancelled: true}` on a declined prompt). + + Examples: + + * `fp issues clear --all-audits --dry-run` + * `fp issues clear --audit --yes` + """ + state: AppState = ctx.obj + picked = [bool(audit_id), all_audits, everything] + if sum(picked) != 1: + raise typer.BadParameter( + "choose exactly one of --audit , --all-audits, or --everything.", + param_hint="--all-audits", + ) + if audit_id: + scope, label = "audit", "this audit" + elif all_audits: + scope, label = "all_audits", "every audit" + else: + scope, label = "everything", "the whole workspace" + + cctx = require_auth(state) + + # The dry run is also the preview behind the prompt, so a `--dry-run` call and a + # confirmed clear count the same rows the same way. + try: + preview = api.clear_issues(cctx, scope=scope, audit_id=audit_id, dry_run=True) + except (ApiError, ForbiddenError, NotFoundError) as exc: + _fail(state, exc) + return + issues = int(preview.get("issues") or 0) + findings = int(preview.get("findings") or 0) + + if dry_run: + if state.json: + output.emit_json(preview) + else: + output.issues_cleared(issues, findings, dry_run=True) + return + + if issues == 0: + # Nothing to do is not a failure, and prompting to confirm zero rows is noise. + if state.json: + output.emit_json({"issues": 0, "findings": 0, "dry_run": False, "scope": scope}) + else: + output.issues_cleared(0, 0) + return + + if _write.should_prompt(state, yes) and not output.confirm_issues_clear(label, issues, findings): + if state.json: + output.emit_json({"cancelled": True}) + else: + output.cancelled_plain("nothing changed") + return + + try: + result = api.clear_issues(cctx, scope=scope, audit_id=audit_id) + except (ApiError, ForbiddenError, NotFoundError) as exc: + _fail(state, exc) + return + # `mode` and `count` are both on _SAFE_PROP_KEYS, and both are shape: a closed enum we + # authored and an integer. Never an audit id or an issue title. + _write.record_action( + "issues_cleared", resource="incident", success=True, destructive=True, + mode=scope, count=int(result.get("issues") or 0), + ) + if state.json: + output.emit_json(result) + else: + output.issues_cleared(int(result.get("issues") or 0), int(result.get("findings") or 0)) + + def incidents_comment_list( ctx: typer.Context, incident_id: str = typer.Argument(..., help="Incident id."), @@ -454,7 +667,7 @@ def register(app: typer.Typer) -> None: no_args_is_help=True, rich_markup_mode="markdown", context_settings={"help_option_names": ["-h", "--help"]}, - help="Triage issues (list / count / show / ack / assign / resolve / comment-* / subscribe* / open).", + help="Triage issues (list / count / show / ack / assign / resolve / close / archive / unarchive / clear / comment-* / subscribe* / open).", ) inc.command("list", epilog=GLOBALS_EPILOG)(incidents_list) inc.command("count", epilog=GLOBALS_EPILOG)(incidents_count) @@ -462,6 +675,10 @@ def register(app: typer.Typer) -> None: inc.command("ack", epilog=GLOBALS_EPILOG)(incidents_ack) inc.command("assign", epilog=GLOBALS_EPILOG)(incidents_assign) inc.command("resolve", epilog=GLOBALS_EPILOG)(incidents_resolve) + inc.command("close", epilog=GLOBALS_EPILOG)(incidents_close) + inc.command("archive", epilog=GLOBALS_EPILOG)(incidents_archive) + inc.command("unarchive", epilog=GLOBALS_EPILOG)(incidents_unarchive) + inc.command("clear", epilog=GLOBALS_EPILOG)(incidents_clear) inc.command("comment-list", epilog=GLOBALS_EPILOG)(incidents_comment_list) inc.command("comment-add", epilog=GLOBALS_EPILOG)(incidents_comment_add) inc.command("comment-delete", epilog=GLOBALS_EPILOG)(incidents_comment_delete) diff --git a/fp-cloud-cli/fp_cli/models.py b/fp-cloud-cli/fp_cli/models.py index 014b84669..c78ee8453 100644 --- a/fp-cloud-cli/fp_cli/models.py +++ b/fp-cloud-cli/fp_cli/models.py @@ -417,6 +417,12 @@ class Incident: acknowledged_by: Optional[str] = None assignees: List[str] = field(default_factory=list) resolved_at: Optional[str] = None + #: When the issue was CLOSED (won't-fix) rather than resolved (fixed). At most + #: one of the two is ever set. Both absent on a live issue. + closed_at: Optional[str] = None + #: When the issue was hidden from the board. Orthogonal to ``state`` — an + #: archived issue keeps whatever state it ended in. + archived_at: Optional[str] = None breach_value: Optional[float] = None breach_summary: Optional[str] = None evidence: Optional[Dict[str, Any]] = None @@ -443,6 +449,11 @@ def from_dict(cls, d: Dict[str, Any]) -> "Incident": acknowledged_by=d.get("acknowledged_by"), assignees=list(d.get("assignees") or []), resolved_at=d.get("resolved_at"), + # `from_dict` is an ALLOWLIST: a key missing here is dropped with no + # error, so a column added server-side renders as a blank column here + # and nowhere complains. That is why these two lines exist at all. + closed_at=d.get("closed_at"), + archived_at=d.get("archived_at"), breach_value=d.get("breach_value"), breach_summary=d.get("breach_summary"), evidence=d.get("evidence"), diff --git a/fp-cloud-cli/fp_cli/output.py b/fp-cloud-cli/fp_cli/output.py index ffd8b5d14..a8cc51ef9 100644 --- a/fp-cloud-cli/fp_cli/output.py +++ b/fp-cloud-cli/fp_cli/output.py @@ -4563,6 +4563,73 @@ def incident_resolved(incident_id: str) -> None: (_short_id(incident_id or "-"), theme.ACCENT)) +def confirm_incident_close(incident_id: str, alert_name: Optional[str] = None) -> bool: + """Plain close confirm (stderr). Says what separates close from resolve — a closed issue + does NOT come back when the pattern recurs — because that is the whole distinction and + nobody can infer it from the word.""" + h = Text() + h.append("⚠ ", style=f"bold {theme.AMBER}") + h.append("close issue ", style=theme.TEXT) + h.append(_short_id(incident_id or "-"), style=f"bold {theme.ACCENT}") + if alert_name: + h.append(f" ({alert_name})", style=theme.LABEL) + h.append("?", style=theme.TEXT) + return confirm_line( + h, Text("won't fix — it stays closed even if the pattern comes back", style=theme.LABEL) + ) + + +def incident_closed(incident_id: str) -> None: + """Plain green line (stderr): ``✓ closed issue {short id}``.""" + if _quiet: + return + _stderr.print() + _plain(("✓ ", theme.SUCCESS), ("closed issue ", theme.TEXT), + (_short_id(incident_id or "-"), theme.ACCENT)) + + +def incident_archived(incident_id: str, archived: bool) -> None: + """Plain green line (stderr): ``✓ archived issue {short id}`` / ``✓ unarchived …``.""" + if _quiet: + return + _stderr.print() + _plain(("✓ ", theme.SUCCESS), + ("archived issue " if archived else "unarchived issue ", theme.TEXT), + (_short_id(incident_id or "-"), theme.ACCENT)) + + +def confirm_issues_clear(scope_label: str, issues: int, findings: int) -> bool: + """The bulk-clear confirm (stderr). Names the COUNT from the server's dry run rather than + the scope alone — a confirmation that cannot say how many rows it will change is a + confirmation in name only — and states the part operators actually need: nothing is + suppressed, so a pattern that survived their agent changes comes back as a new issue.""" + h = Text() + h.append("⚠ ", style=f"bold {theme.AMBER}") + h.append("clear ", style=theme.TEXT) + h.append(f"{issues} issue{'' if issues == 1 else 's'}", style=f"bold {theme.ACCENT}") + h.append(f" ({scope_label})", style=theme.LABEL) + h.append("?", style=theme.TEXT) + tail = f"resolves them" + if findings: + tail += f" and {findings} audit finding{'' if findings == 1 else 's'}" + tail += " — nothing is deleted or suppressed" + return confirm_line(h, Text(tail, style=theme.LABEL)) + + +def issues_cleared(issues: int, findings: int, dry_run: bool = False) -> None: + """Plain green line (stderr) for a completed bulk clear, or the dry-run preview.""" + if _quiet: + return + _stderr.print() + noun = f"{issues} issue{'' if issues == 1 else 's'}" + if dry_run: + _plain(("○ ", theme.FAINT), ("would clear ", theme.LABEL), (noun, theme.ACCENT), + (f" · {findings} finding{'' if findings == 1 else 's'}", theme.FAINT)) + return + _plain(("✓ ", theme.SUCCESS), ("cleared ", theme.TEXT), (noun, theme.ACCENT), + (f" · {findings} finding{'' if findings == 1 else 's'} resolved", theme.FAINT)) + + def incident_assigned(incident_id: str, assignees: Sequence[str]) -> None: """Plain green line (stderr): ``✓ assigned {short id} · a@x, b@x`` — or ``✓ cleared assignees on {short id}`` when the list is empty.""" diff --git a/fp-cloud-cli/skill/SKILL.md b/fp-cloud-cli/skill/SKILL.md index 29a7c5ed4..f09190b09 100644 --- a/fp-cloud-cli/skill/SKILL.md +++ b/fp-cloud-cli/skill/SKILL.md @@ -192,7 +192,7 @@ you need a flag you don't already know. - `users list|show|create|update|disable|enable` — referenced by **email**. - `settings list|schema|set` — fixed registry; `schema` shows what each key accepts. - `alerts list|show|create|update|delete|test` — referenced by **name**. -- `issues list|count|show|ack|assign|resolve|comment-add|comment-list|comment-delete|subscribe|subscribers|unsubscribe|open` — by id (short ids accepted). **One board for everything needing attention**: alert breaches, hand-raised issues, and audit findings, told apart by a `source` of `alert` / `manual` / `audit`. (This group was called `incidents` before; the old name is gone.) +- `issues list|count|show|ack|assign|resolve|close|archive|unarchive|clear|comment-add|comment-list|comment-delete|subscribe|subscribers|unsubscribe|open` — by id (short ids accepted). **One board for everything needing attention**: alert breaches, hand-raised issues, and audit findings, told apart by a `source` of `alert` / `manual` / `audit`. (This group was called `incidents` before; the old name is gone.) - `audits list|show|create|edit|delete|run|runs` — scheduled sweeps, referenced by **name**; `audits findings|finding` + the triage verbs `ack|mute|dismiss|resolve|reopen|assign` act on a finding **id**. `audits run ` only *queues* a run (poll `audits runs ` for completion). See §8. **Enforce (cloud-managed policy, session-only — see §2):** @@ -225,6 +225,9 @@ command (`list `, `whoami`, a `list` subcommand) before committing. | "what has this org used this metering window?" | `usage` (or `--json usage` for the complete response) | | "is anything on fire?", "any alerts firing / open issues?" | `alerts list` + `issues list` (and `issues count`) | | "ack / look at / resolve that issue" | `issues list` → `issues show ` → **confirm** → `issues ack`/`resolve ` | +| "clear all our issues", "fresh start", "we changed the agents" | `issues clear --all-audits --dry-run` → show the count → **confirm** → `issues clear --all-audits` | +| "we're not going to fix that one" | `issues close ` (NOT `resolve` — closed survives a recurrence) | +| "get that off my board" | `issues archive ` | | "run an audit", "what did the audit find?", "any findings to triage?" | `audits list` → `audits run ` (queues) → `audits runs ` (wait for `succeeded`) → `audits findings --audit `; triage with `audits resolve/mute/dismiss ` — **confirm first** | | "give CI / this service an API key" | `keys create --add events:add` (scope to what they describe) — **state it, then create**; capture the one-time secret | | "who has access?", "add / remove a teammate", "make them read-only" | `users list` / `users show ` / `users create`/`update`/`disable` | @@ -267,6 +270,15 @@ fp --json events --full --session-id run-001 --all | jq '.events[].payload' # reliable cross-check that you pulled everything. - **Triage flow:** `issues list` → `issues show ` (read the activity log) → confirm with the user → `issues ack ` or `resolve `. +- **`resolve` vs `close`:** `resolve` claims a fix, so a recurring audit finding + REOPENS it — that is the signal that the fix did not hold. `close` records a + decision (won't fix / not a problem / stale) and survives the recurrence. Pick + the one that matches what the user actually said; they are not synonyms. +- **"clear all our issues" / "fresh start":** that is `issues clear`, not a loop + of `resolve`. Run it `--dry-run` first, tell the user the number it returns, + and only then run it for real. It resolves the audit findings too and writes + **no** suppression, so anything still broken comes back as a new issue — say + that, because users often expect "clear" to mean "silence". - **Investigate a regression:** `evals --aggregate` to see which score dropped → `evals --score helpfulness:..0.5` to list the bad runs → `events --session-id ` to see what happened inside one. diff --git a/fp-cloud-cli/skill/references/commands.md b/fp-cloud-cli/skill/references/commands.md index d3f464c5d..7627f53bf 100644 --- a/fp-cloud-cli/skill/references/commands.md +++ b/fp-cloud-cli/skill/references/commands.md @@ -177,14 +177,17 @@ The title column truncates to whatever width is left so the fixed columns always **`audits run` is async — it only queues.** Success is `{"queued": true}`, not a finished run; the analysis can take minutes. Poll `audits runs ` until the newest row is `succeeded`/`failed` before reading `audits findings`, rather than assuming results exist on the call that queued them. -**Findings and issues are one bucket.** Every finding graduates to an issue (`source: audit`) that stores the finding's full content, so the same problem appears under both `audits findings` and `issues list`. Triage is consistent in **both** directions and needs `audits:write` either way: `audits resolve|mute|dismiss|ack|reopen ` mirrors onto the linked issue, and `issues resolve ` on an audit issue mirrors back onto the finding — the two never disagree. **resolve** leaves no suppression (a genuine recurrence reopens as new); **mute/dismiss** suppress the pattern org-wide by fingerprint. +**Findings and issues are one bucket.** Every finding graduates to an issue (`source: audit`) that stores the finding's full content, so the same problem appears under both `audits findings` and `issues list`. Triage is consistent in **both** directions and needs `audits:write` either way: `audits resolve|mute|dismiss|ack|reopen ` mirrors onto the linked issue, and `issues resolve ` on an audit issue mirrors back onto the finding — the two never disagree. **resolve** leaves no suppression (a genuine recurrence reopens as new); **mute/dismiss** suppress the pattern org-wide by fingerprint. `issues close ` sits between the two: it marks the finding `dismissed` and the issue stays closed through a recurrence, but it writes no org-wide suppression. `issues clear` is bulk `resolve`, with the same absence of suppression. ## issues -The single board for everything needing human attention — alert breaches (`source: alert`), hand-raised issues (`manual`), and audit findings (`audit`). Referenced by id (short ids accepted, `--show-id` shows them). This group was **renamed from `incidents`**; the old name no longer exists. Reads and ack/comment need `issues:read`; opening, assigning, and subscribing others need `issues:create`; resolving needs `issues:close`. -- `issues list [--state firing|acknowledged|resolved] [--alert-id ] [--limit N] [--show-id]` — there is **no** `--severity` filter on this command. +The single board for everything needing human attention — alert breaches (`source: alert`), hand-raised issues (`manual`), and audit findings (`audit`). Referenced by id (short ids accepted, `--show-id` shows them). This group was **renamed from `incidents`**; the old name no longer exists. Reads and ack/comment need `issues:read`; opening, assigning, and subscribing others need `issues:create`; resolving, closing, archiving and clearing need `issues:close` (and `clear` also needs `audits:write`, because it resolves the findings behind the issues). +- `issues list [--state firing|acknowledged|resolved|closed] [--alert-id ] [--limit N] [--show-id]` — there is **no** `--severity` filter on this command. Archived issues are hidden. - `issues count` - `issues show ` — identity + comments + subscribers + **activity log** (read this before acting). An audit-born issue (`source: audit`) carries the full finding it graduated from and back-links to the audit/run. - `issues ack ` · `issues assign --assignee ` (repeatable; omit to clear all assignees; each must be an operator) · `issues resolve ` (calm confirm). **On an audit issue these stay in sync with the finding** — resolving the issue resolves the underlying audit finding, so it can't reappear on the next run (equivalently, triage it with `audits resolve `; both surfaces agree). +- `issues close ` (calm confirm) — the second terminal state: **"we're done with it", not "we fixed it"** (won't fix / not a problem / stale). The difference from `resolve` is what happens next: a **resolved** issue REOPENS when its audit finding recurs, a **closed** one does not. On an audit issue this marks the finding `dismissed`; it does **not** write the org-wide fingerprint suppression that `audits dismiss` writes, so closing one issue never hides that pattern in your other audits. Exit 9 if the issue already ended — an issue ends once, and neither terminal state may overwrite the other. +- `issues archive ` · `issues unarchive ` — take an issue off the board / put it back. **Orthogonal to state**: archiving resolves nothing and never overwrites how an issue ended, and it works on a live issue too. No confirm (it destroys nothing). A fresh alert breach or a recurring audit finding un-archives a live issue automatically, so archive can hide a problem but cannot keep hiding one that is still happening; a `closed` issue is the exception and stays hidden. +- `issues clear (--audit | --all-audits | --everything) [--dry-run] [--yes]` — **the "we changed our agents, give us a fresh board" command.** Resolves every open issue in the scope in one server-side transaction, plus the audit findings behind them. Exactly one scope flag is required. Needs `issues:close` **and** `audits:write`. **Nothing is deleted and nothing is suppressed** — a pattern the agent changes genuinely fixed stays gone, one they did not opens a NEW issue on the next run; for permanent silence use `audits mute`/`dismiss` instead. Always `--dry-run` first: the count comes from the server using the same scope predicate the write uses, so it cannot disagree with what the write changes. - `issues open --summary (--title | --alert-id ) [--title ...] [--severity ...]` — `--title` is **required** for a standalone issue (nothing to borrow a name from); with `--alert-id` it is optional and defaults to the alert's name. Missing `--title` on the standalone path → exit 2. - `issues comment-add (--body | --file )` — `--file -` reads stdin; exactly one of the two · `comment-list ` · `comment-delete ` - `issues subscribe [--email ]` · `unsubscribe [--email ]` · `subscribers ` — `--email` defaults to you; naming someone else needs `issues:create` diff --git a/fp-cloud-cli/tests/test_alerting.py b/fp-cloud-cli/tests/test_alerting.py index 90134f248..38e5baf8d 100644 --- a/fp-cloud-cli/tests/test_alerting.py +++ b/fp-cloud-cli/tests/test_alerting.py @@ -642,3 +642,187 @@ def test_issues_show_malformed_id_is_not_found(logged_in, runner): result = runner.invoke(app, ["--json", "issues", "show", "not-a-uuid"]) assert result.exit_code == 6 assert "no issue not-a-uuid" in json.loads(result.stdout)["error"] + +# --- close / archive / clear ------------------------------------------------ +# +# The issue lifecycle grew a second terminal state (`closed`) and an orthogonal +# archived flag. The CLI restates the server's contract by hand — the state +# allowlist, the paths, the response keys — with nothing checking the two agree, +# so these pin the CLI half. + + +@respx.mock +def test_issues_close_posts_to_close_not_resolve(logged_in, runner): + # The whole point of `close` is that it is NOT `resolve`: a resolved issue + # reopens when its audit finding recurs and a closed one does not, so a + # close that quietly hit /resolve would silently give the opposite + # behaviour with an identical success line. + resolve = respx.post(f"{BASE}/api/issues/i1/resolve").mock(return_value=httpx.Response(200, json={})) + close = respx.post(f"{BASE}/api/issues/i1/close").mock( + return_value=httpx.Response(200, json={"id": "i1", "state": "closed", "closed_at": "t"})) + result = runner.invoke(app, ["--json", "issues", "close", "i1", "--yes"]) + assert result.exit_code == 0, result.output + assert json.loads(result.stdout) == {"closed": True, "id": "i1"} + assert close.called + assert not resolve.called + + +@respx.mock +def test_issues_close_human_line(logged_in, runner): + respx.post(f"{BASE}/api/issues/i1/close").mock(return_value=httpx.Response(200, json={})) + result = runner.invoke(app, ["issues", "close", "i1", "--yes"]) + assert result.exit_code == 0, result.output + assert "closed issue" in result.stderr + + +@respx.mock +def test_issues_close_declined_emits_the_json_cancel_envelope(logged_in, runner, monkeypatch): + monkeypatch.setattr(_write, "should_prompt", lambda *a, **k: True) + monkeypatch.setattr(output, "confirm_incident_close", lambda *a, **k: False) + respx.get(f"{BASE}/api/issues/i1").mock( + return_value=httpx.Response(200, json={"id": "i1", "alert_name": "p95"})) + close = respx.post(f"{BASE}/api/issues/i1/close").mock(return_value=httpx.Response(200, json={})) + result = runner.invoke(app, ["--json", "issues", "close", "i1"]) + assert result.exit_code == 0, result.output + assert json.loads(result.stdout) == {"cancelled": True} + assert not close.called + + +@respx.mock +def test_issues_archive_and_unarchive_hit_their_own_verbs(logged_in, runner): + arch = respx.post(f"{BASE}/api/issues/i1/archive").mock(return_value=httpx.Response(200, json={})) + unarch = respx.post(f"{BASE}/api/issues/i1/unarchive").mock(return_value=httpx.Response(200, json={})) + + result = runner.invoke(app, ["--json", "issues", "archive", "i1"]) + assert result.exit_code == 0, result.output + assert json.loads(result.stdout) == {"archived": True, "id": "i1"} + assert arch.called and not unarch.called + + result = runner.invoke(app, ["--json", "issues", "unarchive", "i1"]) + assert result.exit_code == 0, result.output + assert json.loads(result.stdout) == {"archived": False, "id": "i1"} + assert unarch.called + + +@respx.mock +def test_issues_archive_does_not_prompt(logged_in, runner, monkeypatch): + # Archiving destroys nothing and `unarchive` undoes it, so it must not ask — + # a prompt here would be the only blocking one in a cleanup loop. + monkeypatch.setattr(_write, "should_prompt", lambda *a, **k: True) + route = respx.post(f"{BASE}/api/issues/i1/archive").mock(return_value=httpx.Response(200, json={})) + result = runner.invoke(app, ["--json", "issues", "archive", "i1"]) + assert result.exit_code == 0, result.output + assert route.called + + +def test_issues_clear_requires_exactly_one_scope(logged_in, runner): + # The three scopes have very different blast radii, so there is deliberately + # no default and no way to pass two. + none_given = runner.invoke(app, ["--json", "issues", "clear"]) + assert none_given.exit_code == 2, none_given.output + + two_given = runner.invoke(app, ["--json", "issues", "clear", "--all-audits", "--everything"]) + assert two_given.exit_code == 2, two_given.output + + +@respx.mock +def test_issues_clear_dry_run_writes_nothing(logged_in, runner): + calls = [] + + def record(request): + calls.append(json.loads(request.content)) + return httpx.Response(200, json={"issues": 12, "findings": 9, "dry_run": True, "scope": "all_audits"}) + + respx.post(f"{BASE}/api/issues/bulk-clear").mock(side_effect=record) + result = runner.invoke(app, ["--json", "issues", "clear", "--all-audits", "--dry-run"]) + assert result.exit_code == 0, result.output + assert json.loads(result.stdout)["issues"] == 12 + # Exactly one request, and it carried dry_run — a second (writing) call here + # would mean --dry-run cleared the board. + assert len(calls) == 1 + assert calls[0] == {"scope": "all_audits", "dry_run": True} + + +@respx.mock +def test_issues_clear_previews_then_writes(logged_in, runner): + calls = [] + + def record(request): + body = json.loads(request.content) + calls.append(body) + return httpx.Response(200, json={ + "issues": 3, "findings": 2, "dry_run": body["dry_run"], "scope": "audit"}) + + respx.post(f"{BASE}/api/issues/bulk-clear").mock(side_effect=record) + result = runner.invoke(app, ["--json", "issues", "clear", "--audit", "a1", "--yes"]) + assert result.exit_code == 0, result.output + # Preview first (so the confirm can name a number), then the real write. + assert [c["dry_run"] for c in calls] == [True, False] + assert all(c["audit_id"] == "a1" and c["scope"] == "audit" for c in calls) + assert json.loads(result.stdout)["issues"] == 3 + + +@respx.mock +def test_issues_clear_declined_emits_the_json_cancel_envelope(logged_in, runner, monkeypatch): + monkeypatch.setattr(_write, "should_prompt", lambda *a, **k: True) + monkeypatch.setattr(output, "confirm_issues_clear", lambda *a, **k: False) + calls = [] + + def record(request): + calls.append(json.loads(request.content)) + return httpx.Response(200, json={"issues": 5, "findings": 4, "dry_run": True, "scope": "everything"}) + + respx.post(f"{BASE}/api/issues/bulk-clear").mock(side_effect=record) + result = runner.invoke(app, ["--json", "issues", "clear", "--everything"]) + assert result.exit_code == 0, result.output + assert json.loads(result.stdout) == {"cancelled": True} + # Only the preview ran. A declined confirm that had already written would be + # the worst possible version of this bug. + assert [c["dry_run"] for c in calls] == [True] + + +@respx.mock +def test_issues_clear_on_an_empty_scope_does_not_prompt_or_write(logged_in, runner, monkeypatch): + monkeypatch.setattr(_write, "should_prompt", lambda *a, **k: True) + + def boom(*a, **k): # pragma: no cover - asserts it is never reached + raise AssertionError("prompted to confirm clearing zero issues") + + monkeypatch.setattr(output, "confirm_issues_clear", boom) + calls = [] + + def record(request): + calls.append(json.loads(request.content)) + return httpx.Response(200, json={"issues": 0, "findings": 0, "dry_run": True, "scope": "all_audits"}) + + respx.post(f"{BASE}/api/issues/bulk-clear").mock(side_effect=record) + result = runner.invoke(app, ["--json", "issues", "clear", "--all-audits"]) + assert result.exit_code == 0, result.output + assert json.loads(result.stdout)["issues"] == 0 + assert [c["dry_run"] for c in calls] == [True] + + +def test_issues_list_accepts_closed_as_a_state(logged_in, runner): + # `--state` is validated client-side (exit 2) against a hand-copied list. A + # state missing from it is rejected here for something the server accepts. + from fp_cli.commands import incidents_cmds + + assert "closed" in incidents_cmds._STATES + bad = runner.invoke(app, ["--json", "issues", "list", "--state", "nope"]) + assert bad.exit_code == 2, bad.output + + +@respx.mock +def test_issues_show_surfaces_closed_at_and_archived_at(logged_in, runner): + # `Incident.from_dict` is an ALLOWLIST — a key it does not name is dropped + # with no error, which renders as a permanently blank column. + respx.get(f"{BASE}/api/issues/i1").mock(return_value=httpx.Response(200, json={ + "id": "i1", "state": "closed", "closed_at": "2026-09-22T10:00:00Z", + "archived_at": "2026-09-22T11:00:00Z", "alert_severity": "warning", + })) + from fp_cli import client as api + + inc = api.get_incident(api.ClientContext(base_url=BASE, token="s"), "i1") + assert inc.closed_at == "2026-09-22T10:00:00Z" + assert inc.archived_at == "2026-09-22T11:00:00Z" + assert inc.state == "closed" From b8e208fbfdf99ac5c57aeb0456dc36e9dc7bb770 Mon Sep 17 00:00:00 2001 From: NiveditJain Date: Tue, 22 Sep 2026 17:27:46 +0530 Subject: [PATCH 2/3] chore(fp-cloud-cli): cut the first stable release, 0.0.1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The commands this PR adds drive server endpoints being deployed to FailproofAI Cloud, so the CLI half stops being a pre-release. Per CLAUDE.md, a version file is hand-edited only to LEAVE the current beta line: `0.0.1b3` — opened by the `bump` job and never published — is cut to `0.0.1` rather than advanced to `0.0.1b4`, and the publish workflow's scheme then opens `0.0.2b0` on main after the upload. `Development Status` moves to `5 - Production/Stable` with it. The classifier and the version are the two things a pip user reads, and the 0.1.22 entry in this changelog is on record about what it costs when they disagree — that was a stable version under a beta classifier; this would have been the reverse. The changelog heading moves with the version, because `scripts/changelog-section.py` refuses a release whose version has no section and that failure is only reachable at release time. Nothing about the command surface changes at the cut. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01D7kTYLLdxYMA6dSSWf61v3 --- CHANGELOG.md | 1 + fp-cloud-cli/CHANGELOG.md | 18 +++++++++++++----- fp-cloud-cli/fp_cli/_version.py | 2 +- fp-cloud-cli/pyproject.toml | 2 +- 4 files changed, 16 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 26de8f15c..389efcf63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Added - `fp issues` gains `close`, `archive`, `unarchive` and `clear`. `close` is the second terminal state — "we're done with it", not "we fixed it" — and the difference is what happens next: a **resolved** issue reopens when its audit finding recurs, a **closed** one does not. `archive`/`unarchive` toggle a flag that is orthogonal to state, so taking an issue off the board never overwrites how it ended. `clear` is the bulk operation behind "we changed our agents, give us a fresh board": it resolves every open issue in a scope (`--audit `, `--all-audits`, or `--everything` — exactly one required, no default) plus the audit findings behind them, needs `issues:close` **and** `audits:write`, and previews with `--dry-run` from the same server-side scope predicate the write uses. It writes **no** suppression, so a pattern that survived the agent changes opens a new issue on the next run rather than staying hidden. `--state closed` is accepted by `issues list`, and `Incident` carries `closed_at` / `archived_at`. (#815) +- **`fp-cloud-cli` cuts its first stable release, `0.0.1`.** The commands above drive server endpoints that are being deployed to FailproofAI Cloud, so the CLI half stops being a pre-release: the `0.0.1b3` line the `bump` job opened is cut stable rather than published, `Development Status` moves to `5 - Production/Stable` so the classifier agrees with the one string pip reads, and `0.0.2b0` opens the next beta line. Nothing about the command surface changes at the cut — `pipx install fp-cloud-cli` already resolved the betas, because they were the only releases; now it resolves a version that says so. This is the PyPI package's own line and does not move the npm version, which stays at `1.0.7-beta.0`. (#815) ### Docs diff --git a/fp-cloud-cli/CHANGELOG.md b/fp-cloud-cli/CHANGELOG.md index 40e6bf946..872d25de5 100644 --- a/fp-cloud-cli/CHANGELOG.md +++ b/fp-cloud-cli/CHANGELOG.md @@ -1,10 +1,18 @@ # Changelog — `fp` CLI -## 0.0.1b3 — 2026-09-12 - -Open for the next release. `0.0.1b2` published on 2026-09-12 and the `bump` job -moved the version here automatically. This section becomes the GitHub Release -body when it ships. +## 0.0.1 — 2026-09-22 + +**First stable release.** `0.0.1b1` and `0.0.1b2` are the two betas behind it; the +`0.0.1b3` line the `bump` job opened is cut stable here rather than published, because +the server endpoints these commands drive are deployed to FailproofAI Cloud and the +CLI half should not stay a pre-release behind them. Until now `pipx install +fp-cloud-cli` resolved a beta only because no stable existed — a default that would +have silently changed the day one did. `Development Status` moves to +`5 - Production/Stable` with the version, so the classifier and the one string pip +reads say the same thing. + +Nothing about the command surface changes at the cut: the entries below are the +entire diff from `0.0.1b2`, and `0.0.2b0` opens the next beta line. ### Added diff --git a/fp-cloud-cli/fp_cli/_version.py b/fp-cloud-cli/fp_cli/_version.py index cac3a585a..f102a9cad 100644 --- a/fp-cloud-cli/fp_cli/_version.py +++ b/fp-cloud-cli/fp_cli/_version.py @@ -1 +1 @@ -__version__ = "0.0.1b3" +__version__ = "0.0.1" diff --git a/fp-cloud-cli/pyproject.toml b/fp-cloud-cli/pyproject.toml index cbf6b8f0e..c0513ea91 100644 --- a/fp-cloud-cli/pyproject.toml +++ b/fp-cloud-cli/pyproject.toml @@ -12,7 +12,7 @@ license = { file = "LICENSE" } authors = [{ name = "Failproof AI", email = "failproofai@exosphere.host" }] keywords = ["agents", "observability", "ai", "monitoring", "failproofai", "cli"] classifiers = [ - "Development Status :: 4 - Beta", + "Development Status :: 5 - Production/Stable", "Environment :: Console", "Intended Audience :: Developers", "Programming Language :: Python :: 3", From 55b06863c0402e8ac2d1a9bd456be5523f6343bc Mon Sep 17 00:00:00 2001 From: NiveditJain Date: Tue, 22 Sep 2026 17:41:28 +0530 Subject: [PATCH 3/3] fix(fp): say "reopens" once, drop a guarantee nothing enforces, scope "clear all" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three CodeRabbit findings on #815, all verified against the tree before acting. **The recurrence sentence said two different things in one release.** `audits resolve`'s confirm line read `a genuine recurrence re-opens as new`, and the skill, the command reference and this PR's new `clear` text inherited it — while `issues close --help`, the audits guide's table and the Cloud CLI reference said the issue REOPENS. Only the second reading makes the rest coherent: a recurrence that opened a different issue leaves `close` with nothing to stay closed through, and cannot un-archive "a live issue" the way archiving is documented to. So `reopens` everywhere — including the confirm a user reads before resolving, and the skill a model reads before acting. `audits resolve`'s own "the next run should raise it as new" is left alone: that is the FINDING, which is raised per run, not the issue it mirrors onto. **`clear` promised the preview count could not disagree with the write.** The dry run and the write share a scope predicate, not a row set — the write re-runs it, so an issue entering the scope in between is cleared without being in the confirmed number. That is correct for a command whose argument is a scope, but it is not the guarantee that was written, and binding the two would need a preview token the server does not offer (its endpoints live in the AgentEye repo). The help now states what holds, says plainly that it is a count and not a lease, and points at the closing line, which reports what actually changed. **The skill answered "clear all our issues" with `--all-audits`.** That leaves alert-born and hand-opened issues on the board, so a model following the row would clear part of it and report a fresh start. Settling the scope is now the first step in both the intent table and the workflow note. Docs-and-strings only; no behaviour change. 927 pytest, 4921 vitest, 1051 MDX pages clean. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01D7kTYLLdxYMA6dSSWf61v3 --- CHANGELOG.md | 8 +++++++- docs/audits/findings-and-issues.mdx | 5 +++-- fp-cloud-cli/CHANGELOG.md | 20 +++++++++++++++++-- .../fp_cli/commands/incidents_cmds.py | 16 +++++++++------ fp-cloud-cli/fp_cli/output.py | 4 ++-- fp-cloud-cli/skill/SKILL.md | 15 ++++++++------ fp-cloud-cli/skill/references/commands.md | 6 +++--- 7 files changed, 52 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 389efcf63..06f730a6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,9 +4,15 @@ ### Added -- `fp issues` gains `close`, `archive`, `unarchive` and `clear`. `close` is the second terminal state — "we're done with it", not "we fixed it" — and the difference is what happens next: a **resolved** issue reopens when its audit finding recurs, a **closed** one does not. `archive`/`unarchive` toggle a flag that is orthogonal to state, so taking an issue off the board never overwrites how it ended. `clear` is the bulk operation behind "we changed our agents, give us a fresh board": it resolves every open issue in a scope (`--audit `, `--all-audits`, or `--everything` — exactly one required, no default) plus the audit findings behind them, needs `issues:close` **and** `audits:write`, and previews with `--dry-run` from the same server-side scope predicate the write uses. It writes **no** suppression, so a pattern that survived the agent changes opens a new issue on the next run rather than staying hidden. `--state closed` is accepted by `issues list`, and `Incident` carries `closed_at` / `archived_at`. (#815) +- `fp issues` gains `close`, `archive`, `unarchive` and `clear`. `close` is the second terminal state — "we're done with it", not "we fixed it" — and the difference is what happens next: a **resolved** issue reopens when its audit finding recurs, a **closed** one does not. `archive`/`unarchive` toggle a flag that is orthogonal to state, so taking an issue off the board never overwrites how it ended. `clear` is the bulk operation behind "we changed our agents, give us a fresh board": it resolves every open issue in a scope (`--audit `, `--all-audits`, or `--everything` — exactly one required, no default) plus the audit findings behind them, needs `issues:close` **and** `audits:write`, and previews with `--dry-run` from the same server-side scope predicate the write uses. It writes **no** suppression, so a pattern that survived the agent changes reopens its issue on the next run rather than staying hidden. `--state closed` is accepted by `issues list`, and `Incident` carries `closed_at` / `archived_at`. (#815) - **`fp-cloud-cli` cuts its first stable release, `0.0.1`.** The commands above drive server endpoints that are being deployed to FailproofAI Cloud, so the CLI half stops being a pre-release: the `0.0.1b3` line the `bump` job opened is cut stable rather than published, `Development Status` moves to `5 - Production/Stable` so the classifier agrees with the one string pip reads, and `0.0.2b0` opens the next beta line. Nothing about the command surface changes at the cut — `pipx install fp-cloud-cli` already resolved the betas, because they were the only releases; now it resolves a version that says so. This is the PyPI package's own line and does not move the npm version, which stays at `1.0.7-beta.0`. (#815) +### Fixes + +- **One event, two answers: "a recurring finding reopens the resolved issue" and "it opens a new one" were both written down, four lines apart.** `fp audits resolve --help`'s confirm line said `a genuine recurrence re-opens as new`, the skill and its command reference said the same, and the new `clear` text inherited it — while `issues close --help`, the audits guide's own table and the Cloud CLI reference all said the issue **reopens**. Only the second reading makes the rest coherent: a recurrence that opened a different issue would leave `close` with nothing to stay closed *through*, and could not un-archive "a live issue" the way archiving documents. The recurrence sentence now says `reopens` everywhere it appears, in the confirm text a user reads before resolving and in the skill a model reads before acting. (#815) +- **`clear`'s help promised the preview count could not disagree with the write, which nothing enforces.** The dry run and the write share a scope predicate, not a row set: the write re-runs it, so an issue that enters the scope in between is cleared without being in the number the user confirmed. That is the right behaviour for a command whose argument is a *scope* — but the guarantee as written was a stronger one than the two requests can make. The help now says what holds (the count is the real size of the scope, taken server-side) and what does not (it is a count, not a lease), and points at the closing line, which reports what actually changed. (#815) +- **The skill mapped "clear all our issues" to `--all-audits`, which is not all issues.** `--all-audits` leaves alert-born and hand-opened issues on the board, so a model following that row would clear part of it and report a fresh start. Both the intent table and the workflow note now make settling the scope the first step, with what each flag does and does not cover. (#815) + ### Docs - Document ending an issue three ways (resolve / close / archive) and clearing a board after an agent change, in the audits guide and the Cloud CLI reference. (#815) diff --git a/docs/audits/findings-and-issues.mdx b/docs/audits/findings-and-issues.mdx index 5e62172ab..4c98d0b59 100644 --- a/docs/audits/findings-and-issues.mdx +++ b/docs/audits/findings-and-issues.mdx @@ -117,8 +117,9 @@ with the audit findings behind them. **Clearing suppresses nothing.** A pattern your changes genuinely fixed stays gone. A -pattern that survived them opens a **new** issue on the next audit run, so a fresh -start cannot quietly hide a problem you still have. When you do want a pattern +pattern that survived them **reopens** its issue on the next audit run — the same thing +resolving one by hand does — so a fresh start cannot quietly hide a problem you still +have. When you do want a pattern silenced for good, mute or dismiss the finding instead. Clearing needs permission to both close issues and write audits, because it resolves diff --git a/fp-cloud-cli/CHANGELOG.md b/fp-cloud-cli/CHANGELOG.md index 872d25de5..86fcbaca9 100644 --- a/fp-cloud-cli/CHANGELOG.md +++ b/fp-cloud-cli/CHANGELOG.md @@ -26,12 +26,28 @@ entire diff from `0.0.1b2`, and `0.0.2b0` opens the next beta line. - `fp issues clear (--audit | --all-audits | --everything) [--dry-run]` — resolve every open issue in a scope plus the audit findings behind them, in one server-side transaction. Exactly one scope flag is required. Needs `issues:close` **and** - `audits:write`. Writes no suppression, so anything still broken returns as a new - issue on the next run. The confirm names the count from a dry run that shares its + `audits:write`. Writes no suppression, so anything still broken reopens its issue + on the next run. The confirm names the count from a dry run that shares its scope predicate with the write. (#815) - `issues list --state closed` is accepted, and `Incident` carries `closed_at` and `archived_at`. (#815) +### Fixes + +- The recurrence sentence said two different things in one release. `audits resolve`'s + confirm line (`a genuine recurrence re-opens as new`), the skill and the command + reference implied a recurrence opens a **different** issue; `issues close --help`, the + audits guide and the CLI reference said the resolved issue **reopens**. The second is + the one the rest of the model needs — a `closed` issue has to have something to stay + closed through — so `reopens` is now what every surface says. (#815) +- `issues clear --help` claimed the `--dry-run` count "cannot disagree" with the write. + The two requests share a scope predicate, not a row set, so an issue entering the scope + between them is cleared without being in the confirmed number — correct for a scoped + clear, but not the guarantee that was written. The help now states the real one, and + names the closing line as the count of what changed. (#815) +- The skill answered "clear all our issues" with `--all-audits`, which leaves alert-born + and hand-opened issues untouched. Settling the scope is now the first step. (#815) + ## 0.0.1b2 — 2026-08-25 Open for the next release. `0.0.1b1` published on 2026-08-24 and the `bump` job diff --git a/fp-cloud-cli/fp_cli/commands/incidents_cmds.py b/fp-cloud-cli/fp_cli/commands/incidents_cmds.py index 609074427..741c996bd 100644 --- a/fp-cloud-cli/fp_cli/commands/incidents_cmds.py +++ b/fp-cloud-cli/fp_cli/commands/incidents_cmds.py @@ -374,12 +374,16 @@ def incidents_clear( This is the same thing as resolving each issue by hand, including what it does NOT do. **Nothing is deleted, and nothing is suppressed.** A pattern your agent changes genuinely - fixed stays gone; one they did not opens a NEW issue on the next audit run. If you want a - pattern silenced for good, that is `fp audits finding-status --action mute`, which is - a different and much bigger hammer. - - Run it with `--dry-run` first: the count comes from the server and is taken with the same - scope predicate the write uses, so the number you see is the number of rows that change. + fixed stays gone; one they did not comes back on the next audit run and REOPENS the issue + it was raised under — a cleared board is not a quiet one. If you want a pattern silenced + for good, that is `fp audits finding-status --action mute`, which is a different and + much bigger hammer. + + Run it with `--dry-run` first: the count comes from the server, taken with the same scope + predicate the write uses, so it is the real size of the scope rather than a client-side + guess. It is a count, not a lease — the confirmed write re-runs that predicate, so an + issue that entered the scope since the preview is cleared along with the rest, which is + what clearing a SCOPE means. The line printed at the end is what actually changed. Needs `issues:close` AND `audits:write` — clearing resolves the audit findings behind the issues, so a key that cannot touch one finding cannot resolve all of them at once. diff --git a/fp-cloud-cli/fp_cli/output.py b/fp-cloud-cli/fp_cli/output.py index a8cc51ef9..c69e55997 100644 --- a/fp-cloud-cli/fp_cli/output.py +++ b/fp-cloud-cli/fp_cli/output.py @@ -4602,7 +4602,7 @@ def confirm_issues_clear(scope_label: str, issues: int, findings: int) -> bool: """The bulk-clear confirm (stderr). Names the COUNT from the server's dry run rather than the scope alone — a confirmation that cannot say how many rows it will change is a confirmation in name only — and states the part operators actually need: nothing is - suppressed, so a pattern that survived their agent changes comes back as a new issue.""" + suppressed, so a pattern that survived their agent changes comes back and reopens its issue.""" h = Text() h.append("⚠ ", style=f"bold {theme.AMBER}") h.append("clear ", style=theme.TEXT) @@ -5821,7 +5821,7 @@ def confirm_finding_action(action: str, finding_id: str, *, title: Optional[str] consequence = { "mute": "future runs stop surfacing this pattern", "dismiss": "it's suppressed as not worth acting on", - "resolve": "it closes; a genuine recurrence re-opens as new", + "resolve": "it closes; a genuine recurrence re-opens it", }.get(action, "this changes the finding's status") return confirm_line(h, Text(consequence, style=theme.LABEL)) diff --git a/fp-cloud-cli/skill/SKILL.md b/fp-cloud-cli/skill/SKILL.md index f09190b09..1cb5392fd 100644 --- a/fp-cloud-cli/skill/SKILL.md +++ b/fp-cloud-cli/skill/SKILL.md @@ -225,7 +225,7 @@ command (`list `, `whoami`, a `list` subcommand) before committing. | "what has this org used this metering window?" | `usage` (or `--json usage` for the complete response) | | "is anything on fire?", "any alerts firing / open issues?" | `alerts list` + `issues list` (and `issues count`) | | "ack / look at / resolve that issue" | `issues list` → `issues show ` → **confirm** → `issues ack`/`resolve ` | -| "clear all our issues", "fresh start", "we changed the agents" | `issues clear --all-audits --dry-run` → show the count → **confirm** → `issues clear --all-audits` | +| "clear all our issues", "fresh start", "we changed the agents" | **settle the scope first** (`--all-audits` leaves alert and hand-opened issues alone; `--everything` does not; `--audit ` is one audit) → `issues clear --dry-run` → show the count → **confirm** → `issues clear ` | | "we're not going to fix that one" | `issues close ` (NOT `resolve` — closed survives a recurrence) | | "get that off my board" | `issues archive ` | | "run an audit", "what did the audit find?", "any findings to triage?" | `audits list` → `audits run ` (queues) → `audits runs ` (wait for `succeeded`) → `audits findings --audit `; triage with `audits resolve/mute/dismiss ` — **confirm first** | @@ -275,10 +275,13 @@ fp --json events --full --session-id run-001 --all | jq '.events[].payload' # decision (won't fix / not a problem / stale) and survives the recurrence. Pick the one that matches what the user actually said; they are not synonyms. - **"clear all our issues" / "fresh start":** that is `issues clear`, not a loop - of `resolve`. Run it `--dry-run` first, tell the user the number it returns, - and only then run it for real. It resolves the audit findings too and writes - **no** suppression, so anything still broken comes back as a new issue — say - that, because users often expect "clear" to mean "silence". + of `resolve`. **Settle the scope before you run anything** — "all our issues" + does not pick one: `--all-audits` leaves alert and hand-opened issues on the + board, `--everything` takes them too, and `--audit ` is one audit's work. + Ask which they mean, then `--dry-run` first, tell the user the number it + returns, and only then run it for real. It resolves the audit findings too and + writes **no** suppression, so anything still broken comes back and reopens its + issue — say that, because users often expect "clear" to mean "silence". - **Investigate a regression:** `evals --aggregate` to see which score dropped → `evals --score helpfulness:..0.5` to list the bad runs → `events --session-id ` to see what happened inside one. @@ -305,7 +308,7 @@ Two things about the flow matter when driving it from the CLI: either surface works, they never disagree. Triage a finding with `audits ack|mute|dismiss|resolve|reopen ` (durable **mute/dismiss** suppress the pattern org-wide by fingerprint; **resolve** leaves no suppression, so a true - recurrence reopens as new). Reads need `audits:read`, every mutation + recurrence reopens the issue). Reads need `audits:read`, every mutation `audits:write` (note: triaging a finding needs `audits:write`, not an `issues:*` permission — the audit is the system of record and the issue follows it). diff --git a/fp-cloud-cli/skill/references/commands.md b/fp-cloud-cli/skill/references/commands.md index 7627f53bf..bcd683afb 100644 --- a/fp-cloud-cli/skill/references/commands.md +++ b/fp-cloud-cli/skill/references/commands.md @@ -169,7 +169,7 @@ Context is a **separate sub-resource on purpose**: `audits edit` read-merges the - `audits ack [--reason ...]` — seen, stays visible, ranked lower. No confirm. - `audits mute [--reason ...] [--yes]` — stop surfacing this pattern in future runs (durable). Confirms first. - `audits dismiss [--reason ...] [--yes]` — judged not worth acting on; suppressed like mute. Confirms first. -- `audits resolve [--yes]` — you fixed it. Leaves **no** suppression, so a genuine recurrence is raised as new. Confirms first. +- `audits resolve [--yes]` — you fixed it. Leaves **no** suppression, so a genuine recurrence reopens the issue it graduated to. Confirms first. - `audits reopen ` — back to `open` **and** clears any mute/dismiss suppression. The undo for the three above. - `audits assign --to ` — set the owner; the status is untouched. `--to` is required (exit 2 without it). @@ -177,7 +177,7 @@ The title column truncates to whatever width is left so the fixed columns always **`audits run` is async — it only queues.** Success is `{"queued": true}`, not a finished run; the analysis can take minutes. Poll `audits runs ` until the newest row is `succeeded`/`failed` before reading `audits findings`, rather than assuming results exist on the call that queued them. -**Findings and issues are one bucket.** Every finding graduates to an issue (`source: audit`) that stores the finding's full content, so the same problem appears under both `audits findings` and `issues list`. Triage is consistent in **both** directions and needs `audits:write` either way: `audits resolve|mute|dismiss|ack|reopen ` mirrors onto the linked issue, and `issues resolve ` on an audit issue mirrors back onto the finding — the two never disagree. **resolve** leaves no suppression (a genuine recurrence reopens as new); **mute/dismiss** suppress the pattern org-wide by fingerprint. `issues close ` sits between the two: it marks the finding `dismissed` and the issue stays closed through a recurrence, but it writes no org-wide suppression. `issues clear` is bulk `resolve`, with the same absence of suppression. +**Findings and issues are one bucket.** Every finding graduates to an issue (`source: audit`) that stores the finding's full content, so the same problem appears under both `audits findings` and `issues list`. Triage is consistent in **both** directions and needs `audits:write` either way: `audits resolve|mute|dismiss|ack|reopen ` mirrors onto the linked issue, and `issues resolve ` on an audit issue mirrors back onto the finding — the two never disagree. **resolve** leaves no suppression (a genuine recurrence reopens the issue); **mute/dismiss** suppress the pattern org-wide by fingerprint. `issues close ` sits between the two: it marks the finding `dismissed` and the issue stays closed through a recurrence, but it writes no org-wide suppression. `issues clear` is bulk `resolve`, with the same absence of suppression. ## issues The single board for everything needing human attention — alert breaches (`source: alert`), hand-raised issues (`manual`), and audit findings (`audit`). Referenced by id (short ids accepted, `--show-id` shows them). This group was **renamed from `incidents`**; the old name no longer exists. Reads and ack/comment need `issues:read`; opening, assigning, and subscribing others need `issues:create`; resolving, closing, archiving and clearing need `issues:close` (and `clear` also needs `audits:write`, because it resolves the findings behind the issues). @@ -187,7 +187,7 @@ The single board for everything needing human attention — alert breaches (`sou - `issues ack ` · `issues assign --assignee ` (repeatable; omit to clear all assignees; each must be an operator) · `issues resolve ` (calm confirm). **On an audit issue these stay in sync with the finding** — resolving the issue resolves the underlying audit finding, so it can't reappear on the next run (equivalently, triage it with `audits resolve `; both surfaces agree). - `issues close ` (calm confirm) — the second terminal state: **"we're done with it", not "we fixed it"** (won't fix / not a problem / stale). The difference from `resolve` is what happens next: a **resolved** issue REOPENS when its audit finding recurs, a **closed** one does not. On an audit issue this marks the finding `dismissed`; it does **not** write the org-wide fingerprint suppression that `audits dismiss` writes, so closing one issue never hides that pattern in your other audits. Exit 9 if the issue already ended — an issue ends once, and neither terminal state may overwrite the other. - `issues archive ` · `issues unarchive ` — take an issue off the board / put it back. **Orthogonal to state**: archiving resolves nothing and never overwrites how an issue ended, and it works on a live issue too. No confirm (it destroys nothing). A fresh alert breach or a recurring audit finding un-archives a live issue automatically, so archive can hide a problem but cannot keep hiding one that is still happening; a `closed` issue is the exception and stays hidden. -- `issues clear (--audit | --all-audits | --everything) [--dry-run] [--yes]` — **the "we changed our agents, give us a fresh board" command.** Resolves every open issue in the scope in one server-side transaction, plus the audit findings behind them. Exactly one scope flag is required. Needs `issues:close` **and** `audits:write`. **Nothing is deleted and nothing is suppressed** — a pattern the agent changes genuinely fixed stays gone, one they did not opens a NEW issue on the next run; for permanent silence use `audits mute`/`dismiss` instead. Always `--dry-run` first: the count comes from the server using the same scope predicate the write uses, so it cannot disagree with what the write changes. +- `issues clear (--audit | --all-audits | --everything) [--dry-run] [--yes]` — **the "we changed our agents, give us a fresh board" command.** Resolves every open issue in the scope in one server-side transaction, plus the audit findings behind them. Exactly one scope flag is required. Needs `issues:close` **and** `audits:write`. **Nothing is deleted and nothing is suppressed** — a pattern the agent changes genuinely fixed stays gone, one they did not comes back on the next run and reopens its issue; for permanent silence use `audits mute`/`dismiss` instead. Always `--dry-run` first: the count comes from the server using the same scope predicate the write uses, so it is the real size of the scope — but it is a count, not a lease: the write re-runs the predicate, so anything that entered the scope since the preview is cleared too, and the closing line reports what actually changed. - `issues open --summary (--title | --alert-id ) [--title ...] [--severity ...]` — `--title` is **required** for a standalone issue (nothing to borrow a name from); with `--alert-id` it is optional and defaults to the alert's name. Missing `--title` on the standalone path → exit 2. - `issues comment-add (--body | --file )` — `--file -` reads stdin; exactly one of the two · `comment-list ` · `comment-delete ` - `issues subscribe [--email ]` · `unsubscribe [--email ]` · `subscribers ` — `--email` defaults to you; naming someone else needs `issues:create`