agentgov-cli 0.1.8__tar.gz → 0.1.9__tar.gz
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.
- {agentgov_cli-0.1.8 → agentgov_cli-0.1.9}/PKG-INFO +1 -1
- agentgov_cli-0.1.9/agentgov_cli/client_assets/commands/workitem.md +58 -0
- {agentgov_cli-0.1.8 → agentgov_cli-0.1.9}/agentgov_cli/client_assets/hooks/userpromptsubmit_workitem.py +11 -4
- agentgov_cli-0.1.9/agentgov_cli/client_assets/statusline/agentgov_statusline.sh +151 -0
- {agentgov_cli-0.1.8 → agentgov_cli-0.1.9}/agentgov_cli/commands/install.py +61 -2
- agentgov_cli-0.1.9/agentgov_cli/commands/status.py +72 -0
- {agentgov_cli-0.1.8 → agentgov_cli-0.1.9}/agentgov_cli/commands/uninstall.py +56 -2
- agentgov_cli-0.1.9/agentgov_cli/commands/workitem.py +291 -0
- {agentgov_cli-0.1.8 → agentgov_cli-0.1.9}/pyproject.toml +1 -1
- agentgov_cli-0.1.8/agentgov_cli/client_assets/commands/workitem.md +0 -18
- agentgov_cli-0.1.8/agentgov_cli/client_assets/statusline/agentgov_statusline.sh +0 -74
- agentgov_cli-0.1.8/agentgov_cli/commands/status.py +0 -46
- agentgov_cli-0.1.8/agentgov_cli/commands/workitem.py +0 -72
- {agentgov_cli-0.1.8 → agentgov_cli-0.1.9}/.gitignore +0 -0
- {agentgov_cli-0.1.8 → agentgov_cli-0.1.9}/README.md +0 -0
- {agentgov_cli-0.1.8 → agentgov_cli-0.1.9}/agentgov_cli/__init__.py +0 -0
- {agentgov_cli-0.1.8 → agentgov_cli-0.1.9}/agentgov_cli/client_assets/hooks/pretooluse_pathguard.py +0 -0
- {agentgov_cli-0.1.8 → agentgov_cli-0.1.9}/agentgov_cli/client_assets/hooks/sessionstart_register.py +0 -0
- {agentgov_cli-0.1.8 → agentgov_cli-0.1.9}/agentgov_cli/client_assets/managed-settings.json.tmpl +0 -0
- {agentgov_cli-0.1.8 → agentgov_cli-0.1.9}/agentgov_cli/commands/__init__.py +0 -0
- {agentgov_cli-0.1.8 → agentgov_cli-0.1.9}/agentgov_cli/commands/doctor.py +0 -0
- {agentgov_cli-0.1.8 → agentgov_cli-0.1.9}/agentgov_cli/commands/gateway.py +0 -0
- {agentgov_cli-0.1.8 → agentgov_cli-0.1.9}/agentgov_cli/commands/login.py +0 -0
- {agentgov_cli-0.1.8 → agentgov_cli-0.1.9}/agentgov_cli/commands/register_device.py +0 -0
- {agentgov_cli-0.1.8 → agentgov_cli-0.1.9}/agentgov_cli/commands/wrap.py +0 -0
- {agentgov_cli-0.1.8 → agentgov_cli-0.1.9}/agentgov_cli/gateway_process.py +0 -0
- {agentgov_cli-0.1.8 → agentgov_cli-0.1.9}/agentgov_cli/loopback.py +0 -0
- {agentgov_cli-0.1.8 → agentgov_cli-0.1.9}/agentgov_cli/main.py +0 -0
- {agentgov_cli-0.1.8 → agentgov_cli-0.1.9}/agentgov_cli/port_resolver.py +0 -0
- {agentgov_cli-0.1.8 → agentgov_cli-0.1.9}/hatch_build.py +0 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Bind this session to a work item — ticket id, URL, or any short label
|
|
3
|
+
allowed-tools: Bash(agentgov workitem:*)
|
|
4
|
+
argument-hint: <PROJ-1234 | https://tracker/issue/42 | any short label>
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
!`agentgov workitem --session "${CLAUDE_SESSION_ID}" -- '$ARGUMENTS'`
|
|
8
|
+
|
|
9
|
+
The command above has already run and its output is shown. Relay that output to
|
|
10
|
+
the user verbatim — do not restate, reformat, or interpret it — and then stop.
|
|
11
|
+
|
|
12
|
+
If it reported a successful bind, simply acknowledge it and carry on with
|
|
13
|
+
whatever the user was doing. If it reported a failure, show the message as-is;
|
|
14
|
+
it already contains the remediation step.
|
|
15
|
+
|
|
16
|
+
<!--
|
|
17
|
+
WHY THIS FILE LOOKS THE WAY IT DOES
|
|
18
|
+
===================================
|
|
19
|
+
|
|
20
|
+
1. WHY A `!` SHELL BLOCK AND NOT A MODEL-DRIVEN TOOL CALL
|
|
21
|
+
An unbound session is DENIED by the gateway (WorkItemMiddleware →
|
|
22
|
+
NO_WORK_ITEM), so no model turn is possible until the bind succeeds. Asking
|
|
23
|
+
the model to run the bind would deadlock: the request that would fix the
|
|
24
|
+
problem is the request the problem blocks. The bind must therefore complete
|
|
25
|
+
entirely in the shell, before anything is sent upstream.
|
|
26
|
+
|
|
27
|
+
2. WHY `${CLAUDE_SESSION_ID}` IS PASSED EXPLICITLY
|
|
28
|
+
Claude Code substitutes it into slash-command shell lines, and it is the same
|
|
29
|
+
id sent as `x-claude-code-session-id` on every API request — i.e. exactly the
|
|
30
|
+
key the gateway stores sessions under. Passing it is what lets this work in
|
|
31
|
+
the VS Code extension, with no `agentgov wrap` and no loopback listener on
|
|
32
|
+
:8788. Requiring that listener is what made `/workitem` fail for every editor
|
|
33
|
+
user.
|
|
34
|
+
|
|
35
|
+
3. WHY `agentgov workitem` NEVER EXITS NON-ZERO
|
|
36
|
+
Claude Code aborts the entire invocation when a `!` command exits non-zero and
|
|
37
|
+
reports `Shell command failed for pattern "..."`. That message is Claude Code
|
|
38
|
+
quoting the shell line it tried to run — it is NOT AgentGov matching the
|
|
39
|
+
user's text against a pattern, and nothing here inspects the format of what
|
|
40
|
+
the user typed. Our real error text was being swallowed by it, which is why
|
|
41
|
+
the failure was unreadable. The CLI now reports every failure as ordinary
|
|
42
|
+
output with exit code 0.
|
|
43
|
+
|
|
44
|
+
4. WHY `'$ARGUMENTS'` IS SINGLE-QUOTED
|
|
45
|
+
`$ARGUMENTS` is substituted as text into a shell line, so unquoted user input
|
|
46
|
+
would let `$(...)`, backticks, `;`, `|`, `&` and `>` be interpreted by the
|
|
47
|
+
shell. Single quotes make every one of those literal.
|
|
48
|
+
|
|
49
|
+
KNOWN GAP: a literal apostrophe in the user's text (e.g. `don't`) closes the
|
|
50
|
+
quote early and breaks the line. Anthropic's documentation does not state
|
|
51
|
+
whether Claude Code escapes `$ARGUMENTS` before substitution, so this could
|
|
52
|
+
not be settled from the docs and is deliberately not guessed at. The CLI
|
|
53
|
+
defends on its side by stripping a stray surrounding quote pair and by
|
|
54
|
+
joining all argv entries into one reference. The clean fix is a
|
|
55
|
+
`UserPromptExpansion` hook, which receives the typed text as structured JSON
|
|
56
|
+
and never involves a shell — revisit once that hook's schema and exit-code
|
|
57
|
+
semantics are documented.
|
|
58
|
+
-->
|
|
@@ -39,6 +39,12 @@ TIMEOUT_SEC = 4.0
|
|
|
39
39
|
|
|
40
40
|
# Prompts that exist to FIX an unbound session must not be blocked by the
|
|
41
41
|
# unbound session — otherwise the developer is deadlocked with no way out.
|
|
42
|
+
#
|
|
43
|
+
# NOTE: per Anthropic's hook reference, `UserPromptSubmit` does NOT fire for
|
|
44
|
+
# slash commands at all — those raise `UserPromptExpansion` instead. So this
|
|
45
|
+
# list is belt-and-braces, not the thing that lets `/workitem` through, and it
|
|
46
|
+
# must not be relied on. Keep it: it costs nothing, and it still covers a
|
|
47
|
+
# developer who types the command as plain text.
|
|
42
48
|
ESCAPE_PREFIXES = ("/workitem", "/agentgov", "/help", "/status", "/doctor")
|
|
43
49
|
|
|
44
50
|
|
|
@@ -186,10 +192,11 @@ def main() -> None:
|
|
|
186
192
|
"📌 AgentGov: bind this session to a work item before continuing.\n"
|
|
187
193
|
f" Repository: {repo}\n"
|
|
188
194
|
"\n"
|
|
189
|
-
" Run
|
|
190
|
-
"
|
|
191
|
-
" /workitem
|
|
192
|
-
" /workitem https://
|
|
195
|
+
" Run /workitem with anything that identifies the work — a ticket id,\n"
|
|
196
|
+
" a URL, or a short label. There is no required format:\n"
|
|
197
|
+
" /workitem PROJ-1234\n"
|
|
198
|
+
" /workitem https://your-tracker/issue/42\n"
|
|
199
|
+
" /workitem payment retry loop spike\n"
|
|
193
200
|
"\n"
|
|
194
201
|
" Your prompt was not sent and no tokens were used.\n"
|
|
195
202
|
)
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# AgentGov statusline (§R-CC3).
|
|
3
|
+
#
|
|
4
|
+
# Renders one of:
|
|
5
|
+
# 🛡 <repo> │ 📌 <work item> │ 41% weekly · resets 08-18 09:00
|
|
6
|
+
# 🛡 <repo> │ 📌 <work item> │ 8% context
|
|
7
|
+
# 🛡 <repo> │ 📌 UNBOUND — /workitem required
|
|
8
|
+
#
|
|
9
|
+
# WHY THIS WAS REWRITTEN
|
|
10
|
+
# ----------------------
|
|
11
|
+
# It read $AGENTGOV_SESSION and a state file at ~/.agentgov/state/<session>.json.
|
|
12
|
+
# Both only ever existed under `agentgov wrap`: the variable was exported by the
|
|
13
|
+
# launcher, and the file was written by the loopback listener the launcher
|
|
14
|
+
# started. In the VS Code extension neither exists, so the statusline printed
|
|
15
|
+
#
|
|
16
|
+
# 📌 UNGOVERNED — run agentgov wrap claude
|
|
17
|
+
#
|
|
18
|
+
# permanently — for sessions that were registered, bound and governed. It was the
|
|
19
|
+
# main surface a developer would look at to confirm a bind had worked, and it was
|
|
20
|
+
# reporting the opposite of the truth.
|
|
21
|
+
#
|
|
22
|
+
# Claude Code hands the statusline a JSON document on stdin containing
|
|
23
|
+
# `session_id`, `workspace.repo`, `cost`, `context_window` and `rate_limits`
|
|
24
|
+
# (documented at https://code.claude.com/docs/en/statusline.md). That is the same
|
|
25
|
+
# session id the gateway keys sessions under, so the binding can be read straight
|
|
26
|
+
# from the gateway with no wrapper, no exported variable and no state file.
|
|
27
|
+
#
|
|
28
|
+
# The body is Python rather than jq + `read`, because the previous word-splitting
|
|
29
|
+
# approach corrupted any value containing a space — which now includes work-item
|
|
30
|
+
# labels, since those are free text. It is held in a quoted heredoc so the shell
|
|
31
|
+
# performs no substitution on it, and passed via -c so that stdin stays the JSON
|
|
32
|
+
# Claude Code sent.
|
|
33
|
+
|
|
34
|
+
set -u
|
|
35
|
+
|
|
36
|
+
read -r -d '' AGENTGOV_STATUSLINE_PY <<'PYEOF'
|
|
37
|
+
import json
|
|
38
|
+
import os
|
|
39
|
+
import sys
|
|
40
|
+
import urllib.request
|
|
41
|
+
from datetime import datetime, timezone
|
|
42
|
+
|
|
43
|
+
GATEWAY = os.environ.get("AGENTGOV_GATEWAY_URL", "http://127.0.0.1:8000").rstrip("/")
|
|
44
|
+
# Runs on every assistant message, so keep it tight. This is a loopback call to
|
|
45
|
+
# an in-memory lookup; if it is slow, something is wrong and we show less rather
|
|
46
|
+
# than stall the status bar.
|
|
47
|
+
TIMEOUT = 1.0
|
|
48
|
+
|
|
49
|
+
RED = "\033[31m"
|
|
50
|
+
YELLOW = "\033[33m"
|
|
51
|
+
GREEN = "\033[32m"
|
|
52
|
+
DIM = "\033[2m"
|
|
53
|
+
RESET = "\033[0m"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def payload():
|
|
57
|
+
try:
|
|
58
|
+
return json.load(sys.stdin)
|
|
59
|
+
except Exception:
|
|
60
|
+
return {}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def binding(session_id):
|
|
64
|
+
"""Ask the gateway about this session. None means 'could not tell'."""
|
|
65
|
+
if not session_id:
|
|
66
|
+
return None
|
|
67
|
+
try:
|
|
68
|
+
url = GATEWAY + "/admin/session/" + str(session_id) + "/binding"
|
|
69
|
+
with urllib.request.urlopen(url, timeout=TIMEOUT) as r:
|
|
70
|
+
data = json.loads(r.read() or b"{}")
|
|
71
|
+
return data if isinstance(data, dict) else None
|
|
72
|
+
except Exception:
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def repo_label(data, state):
|
|
77
|
+
"""Prefer the repo the gateway registered, then Claude Code's, then the folder."""
|
|
78
|
+
if state and state.get("repo"):
|
|
79
|
+
return str(state["repo"])
|
|
80
|
+
repo = (data.get("workspace") or {}).get("repo") or {}
|
|
81
|
+
owner = repo.get("owner")
|
|
82
|
+
name = repo.get("name")
|
|
83
|
+
if owner and name:
|
|
84
|
+
return "{}/{}".format(owner, name)
|
|
85
|
+
cwd = (data.get("workspace") or {}).get("current_dir") or data.get("cwd") or ""
|
|
86
|
+
return os.path.basename(cwd.rstrip("/")) or "unknown"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def shorten(text, limit=42):
|
|
90
|
+
"""Work items are free text now, so a long label must not crowd out the rest."""
|
|
91
|
+
text = " ".join(str(text).split())
|
|
92
|
+
if len(text) <= limit:
|
|
93
|
+
return text
|
|
94
|
+
return text[: limit - 1] + "…"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def usage_segment(data):
|
|
98
|
+
"""Weekly quota if Claude Code reported it, else context use, else cost.
|
|
99
|
+
|
|
100
|
+
These come from Claude Code itself, which is strictly more accurate than
|
|
101
|
+
anything AgentGov could accumulate locally between ingest acknowledgements.
|
|
102
|
+
"""
|
|
103
|
+
week = (data.get("rate_limits") or {}).get("seven_day") or {}
|
|
104
|
+
pct = week.get("used_percentage")
|
|
105
|
+
if isinstance(pct, (int, float)):
|
|
106
|
+
color = RED if pct >= 85 else YELLOW if pct >= 60 else GREEN
|
|
107
|
+
seg = "{}{:.0f}% weekly{}".format(color, pct, RESET)
|
|
108
|
+
resets = week.get("resets_at")
|
|
109
|
+
if isinstance(resets, (int, float)):
|
|
110
|
+
when = datetime.fromtimestamp(resets, tz=timezone.utc).astimezone()
|
|
111
|
+
seg += when.strftime(" · resets %m-%d %H:%M")
|
|
112
|
+
return seg
|
|
113
|
+
|
|
114
|
+
ctx = (data.get("context_window") or {}).get("used_percentage")
|
|
115
|
+
if isinstance(ctx, (int, float)):
|
|
116
|
+
return "{}{:.0f}% context{}".format(DIM, ctx, RESET)
|
|
117
|
+
|
|
118
|
+
cost = (data.get("cost") or {}).get("total_cost_usd")
|
|
119
|
+
if isinstance(cost, (int, float)):
|
|
120
|
+
return "{}${:.2f}{}".format(DIM, cost, RESET)
|
|
121
|
+
return ""
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def main():
|
|
125
|
+
data = payload()
|
|
126
|
+
state = binding(data.get("session_id"))
|
|
127
|
+
|
|
128
|
+
parts = ["\U0001f6e1 " + repo_label(data, state)]
|
|
129
|
+
|
|
130
|
+
if state is None:
|
|
131
|
+
# Gateway unreachable. Say so plainly — "not bound" and "cannot tell"
|
|
132
|
+
# are different answers, and conflating them is what made the old
|
|
133
|
+
# statusline misleading.
|
|
134
|
+
parts.append("{}\U0001f4cc gateway offline{}".format(YELLOW, RESET))
|
|
135
|
+
elif state.get("bound"):
|
|
136
|
+
label = shorten(state.get("work_item_ref") or "bound")
|
|
137
|
+
parts.append("\U0001f4cc " + label)
|
|
138
|
+
else:
|
|
139
|
+
parts.append("{}\U0001f4cc UNBOUND — /workitem required{}".format(RED, RESET))
|
|
140
|
+
|
|
141
|
+
usage = usage_segment(data)
|
|
142
|
+
if usage:
|
|
143
|
+
parts.append(usage)
|
|
144
|
+
|
|
145
|
+
sys.stdout.write(" │ ".join(parts))
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
main()
|
|
149
|
+
PYEOF
|
|
150
|
+
|
|
151
|
+
exec python3 -c "$AGENTGOV_STATUSLINE_PY"
|
|
@@ -140,6 +140,58 @@ def run(
|
|
|
140
140
|
console.print("\n[dim]Contents (for reference):[/]")
|
|
141
141
|
console.print(settings_json)
|
|
142
142
|
|
|
143
|
+
_print_next_steps(target_path)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _print_next_steps(managed_target: str) -> None:
|
|
147
|
+
"""Say plainly what happens next, and what will NOT happen.
|
|
148
|
+
|
|
149
|
+
Installing hooks is not the last step, and on its own it changes nothing a
|
|
150
|
+
developer can see. Without this, `install` finishes on a wall of JSON and
|
|
151
|
+
the obvious conclusion is "it's set up" — then the work-item prompt never
|
|
152
|
+
appears and the install looks broken when it is behaving correctly.
|
|
153
|
+
"""
|
|
154
|
+
governed_everywhere = Path(managed_target).exists()
|
|
155
|
+
|
|
156
|
+
console.rule("[bold]Next steps[/]")
|
|
157
|
+
console.print("[bold]1.[/] Fully quit and reopen Claude Code / VS Code.")
|
|
158
|
+
console.print(
|
|
159
|
+
" [dim]Hooks are read when a session starts. A window that is already "
|
|
160
|
+
"open will not pick them up.[/]\n"
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
if governed_everywhere:
|
|
164
|
+
console.print(
|
|
165
|
+
"[bold]2.[/] Machine-wide settings are installed, so [green]every[/] Claude Code "
|
|
166
|
+
"session is governed."
|
|
167
|
+
)
|
|
168
|
+
console.print(" Open a granted repo and send a prompt — you will be asked to bind a "
|
|
169
|
+
"work item.\n")
|
|
170
|
+
else:
|
|
171
|
+
console.print(
|
|
172
|
+
"[bold]2.[/] Choose how you want to be governed — "
|
|
173
|
+
"[yellow]until you do, nothing changes[/]:\n"
|
|
174
|
+
)
|
|
175
|
+
console.print(" [cyan]agentgov wrap claude[/] (per session, no sudo)")
|
|
176
|
+
console.print(
|
|
177
|
+
" Governs just that session. Plain `claude` and the VS Code extension "
|
|
178
|
+
"keep talking to Anthropic directly.\n"
|
|
179
|
+
)
|
|
180
|
+
console.print(
|
|
181
|
+
f" [cyan]sudo cp ~/.agentgov/managed-settings.json {managed_target}[/] "
|
|
182
|
+
"(whole machine)"
|
|
183
|
+
)
|
|
184
|
+
console.print(
|
|
185
|
+
" The only way to govern the VS Code / Cursor extension. Requires sudo, and "
|
|
186
|
+
"means a stopped gateway blocks all Claude Code.\n"
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
console.print("[bold]3.[/] Check it took: [cyan]agentgov doctor[/]")
|
|
190
|
+
console.print(
|
|
191
|
+
" [dim]Step 1 CLIENT tells you whether anything is actually routed through the "
|
|
192
|
+
"gateway.[/]"
|
|
193
|
+
)
|
|
194
|
+
|
|
143
195
|
|
|
144
196
|
def _agentgov_hook_entries(hooks_dest: Path) -> dict[str, list[dict[str, object]]]:
|
|
145
197
|
"""The hook registrations AgentGov owns, keyed by Claude Code event name."""
|
|
@@ -224,9 +276,16 @@ def _merge_user_settings(gateway_url: str, hooks_dest: Path, statusline_dest: Pa
|
|
|
224
276
|
# to undo. Routing is the job of managed-settings.json (highest precedence).
|
|
225
277
|
path.write_text(json.dumps(data, indent=2) + "\n")
|
|
226
278
|
console.print(f"[green]Registered[/] hooks + statusline → {path}")
|
|
279
|
+
# Be precise about what this does and does not do. The earlier wording
|
|
280
|
+
# ("these run in every Claude Code session, including the VS Code
|
|
281
|
+
# extension") was wrong twice over: hooks load only at session start, and
|
|
282
|
+
# since 0.1.7 they intentionally do nothing in sessions that are not routed
|
|
283
|
+
# through the gateway. Overstating it makes the next step look broken when
|
|
284
|
+
# it is working exactly as designed.
|
|
227
285
|
console.print(
|
|
228
|
-
" [dim]
|
|
229
|
-
"
|
|
286
|
+
" [dim]Loaded when a Claude Code session STARTS — restart Claude Code.\n"
|
|
287
|
+
" They act only on sessions routed through the gateway; other sessions are "
|
|
288
|
+
"left alone.[/]"
|
|
230
289
|
)
|
|
231
290
|
|
|
232
291
|
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""`agentgov status` — show the current work-item binding for this session.
|
|
2
|
+
|
|
3
|
+
Rewritten for the same reason as `agentgov workitem`: this asked the loopback
|
|
4
|
+
listener on :8788 that only `agentgov wrap` starts, so in the VS Code extension
|
|
5
|
+
it always answered "No active AgentGov session. Start one: agentgov wrap claude"
|
|
6
|
+
about a session that was registered and working. The one command a developer
|
|
7
|
+
runs to check whether their bind took effect was reporting the opposite of the
|
|
8
|
+
truth.
|
|
9
|
+
|
|
10
|
+
It now asks the gateway directly, keyed on Claude Code's own session id — the
|
|
11
|
+
same identifier the gateway stores sessions under.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
import typer
|
|
18
|
+
from rich.console import Console
|
|
19
|
+
|
|
20
|
+
from .workitem import DEFAULT_GATEWAY, _body, _resolve_session
|
|
21
|
+
|
|
22
|
+
console = Console()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def run(
|
|
26
|
+
session: str = typer.Option(
|
|
27
|
+
None,
|
|
28
|
+
"--session",
|
|
29
|
+
help="Claude Code session id. Defaults to $CLAUDE_SESSION_ID / $AGENTGOV_SESSION.",
|
|
30
|
+
),
|
|
31
|
+
gateway: str = typer.Option(
|
|
32
|
+
None,
|
|
33
|
+
"--gateway",
|
|
34
|
+
envvar="AGENTGOV_GATEWAY_URL",
|
|
35
|
+
help=f"Gateway base URL (default {DEFAULT_GATEWAY}).",
|
|
36
|
+
),
|
|
37
|
+
) -> None:
|
|
38
|
+
"""Report whether this session is bound, and to what."""
|
|
39
|
+
base = (gateway or DEFAULT_GATEWAY).rstrip("/")
|
|
40
|
+
session_id = _resolve_session(session)
|
|
41
|
+
|
|
42
|
+
if not session_id:
|
|
43
|
+
console.print(
|
|
44
|
+
"[yellow]No Claude Code session id in this shell.[/]\n"
|
|
45
|
+
" Run `agentgov status` from inside Claude Code, or pass --session <id>.\n"
|
|
46
|
+
" Session ids are listed by: [bold]agentgov doctor[/]"
|
|
47
|
+
)
|
|
48
|
+
raise typer.Exit(0)
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
r = httpx.get(f"{base}/admin/session/{session_id}/binding", timeout=10.0)
|
|
52
|
+
data = _body(r)
|
|
53
|
+
except Exception:
|
|
54
|
+
console.print(
|
|
55
|
+
f"[red]Gateway at {base} is not reachable.[/]\n"
|
|
56
|
+
" Start it with: [bold]agentgov gateway install[/]"
|
|
57
|
+
)
|
|
58
|
+
raise typer.Exit(0) from None
|
|
59
|
+
|
|
60
|
+
if not data.get("known"):
|
|
61
|
+
console.print(
|
|
62
|
+
"[yellow]This session is not registered with the gateway.[/]\n"
|
|
63
|
+
" It will re-register on your next prompt, or when you run `/workitem`."
|
|
64
|
+
)
|
|
65
|
+
raise typer.Exit(0)
|
|
66
|
+
|
|
67
|
+
console.print(f"🛡 repo: [bold]{data.get('repo') or 'unknown'}[/]")
|
|
68
|
+
if data.get("bound"):
|
|
69
|
+
console.print(f"📌 work item: [bold]{data.get('work_item_ref')}[/]")
|
|
70
|
+
else:
|
|
71
|
+
console.print("[red]📌 UNBOUND[/] — run `/workitem <anything>` in Claude Code.")
|
|
72
|
+
raise typer.Exit(0)
|
|
@@ -22,6 +22,7 @@ from __future__ import annotations
|
|
|
22
22
|
import json
|
|
23
23
|
import shutil
|
|
24
24
|
import subprocess
|
|
25
|
+
import sys
|
|
25
26
|
from pathlib import Path
|
|
26
27
|
|
|
27
28
|
import typer
|
|
@@ -34,6 +35,33 @@ SETTINGS = CLAUDE_DIR / "settings.json"
|
|
|
34
35
|
AGENTGOV_DIR = Path.home() / ".agentgov"
|
|
35
36
|
|
|
36
37
|
|
|
38
|
+
def _running_from(directory: Path) -> Path | None:
|
|
39
|
+
"""Is this process's interpreter installed underneath `directory`?
|
|
40
|
+
|
|
41
|
+
The documented install puts the CLI in a venv at ~/.agentgov/venv — INSIDE
|
|
42
|
+
the directory `uninstall` deletes. So `shutil.rmtree(~/.agentgov)` was
|
|
43
|
+
deleting the interpreter and site-packages out from under the running
|
|
44
|
+
process. It did not fail immediately, because Python had already imported
|
|
45
|
+
what it needed; it failed on the NEXT lazy import. In practice that was
|
|
46
|
+
rich's on-demand unicode table, from the very next console.print():
|
|
47
|
+
|
|
48
|
+
ModuleNotFoundError: No module named 'rich._unicode_data.unicode17-0-0'
|
|
49
|
+
|
|
50
|
+
followed by typer's excepthook failing too, because typer's own modules were
|
|
51
|
+
gone as well. It read like a rich/typer version conflict. It was not — it was
|
|
52
|
+
self-deletion, and it left the user with no `agentgov` command at all and no
|
|
53
|
+
obvious way back.
|
|
54
|
+
|
|
55
|
+
Returns the venv root when this process runs from inside `directory`.
|
|
56
|
+
"""
|
|
57
|
+
try:
|
|
58
|
+
prefix = Path(sys.prefix).resolve()
|
|
59
|
+
target = directory.resolve()
|
|
60
|
+
except OSError:
|
|
61
|
+
return None
|
|
62
|
+
return prefix if prefix == target or target in prefix.parents else None
|
|
63
|
+
|
|
64
|
+
|
|
37
65
|
def run(
|
|
38
66
|
keep_credentials: bool = typer.Option(
|
|
39
67
|
False,
|
|
@@ -70,12 +98,38 @@ def run(
|
|
|
70
98
|
workitem.unlink()
|
|
71
99
|
console.print(f"[green]Removed[/] {workitem}")
|
|
72
100
|
|
|
101
|
+
self_venv: Path | None = None
|
|
73
102
|
if not keep_credentials and AGENTGOV_DIR.exists():
|
|
74
|
-
|
|
75
|
-
|
|
103
|
+
self_venv = _running_from(AGENTGOV_DIR)
|
|
104
|
+
if self_venv is None:
|
|
105
|
+
shutil.rmtree(AGENTGOV_DIR, ignore_errors=True)
|
|
106
|
+
console.print(f"[green]Removed[/] {AGENTGOV_DIR}")
|
|
107
|
+
else:
|
|
108
|
+
# Delete everything EXCEPT the venv we are executing from. Removing
|
|
109
|
+
# it here crashes this process mid-teardown (see _running_from) and
|
|
110
|
+
# destroys the only copy of the CLI, so the user cannot even re-run
|
|
111
|
+
# uninstall to finish the job. Leave it and hand them one command.
|
|
112
|
+
for child in AGENTGOV_DIR.iterdir():
|
|
113
|
+
if child == self_venv:
|
|
114
|
+
continue
|
|
115
|
+
if child.is_dir() and not child.is_symlink():
|
|
116
|
+
shutil.rmtree(child, ignore_errors=True)
|
|
117
|
+
else:
|
|
118
|
+
child.unlink(missing_ok=True)
|
|
119
|
+
console.print(f"[green]Removed[/] {AGENTGOV_DIR} [dim](except the venv below)[/]")
|
|
76
120
|
|
|
77
121
|
console.print("\n[bold green]AgentGov removed.[/]")
|
|
78
122
|
console.print("Claude Code is unaffected — restart it to clear the old session.")
|
|
123
|
+
|
|
124
|
+
if self_venv is not None:
|
|
125
|
+
console.print(
|
|
126
|
+
f"\n[yellow]Left in place:[/] {self_venv}\n"
|
|
127
|
+
" That is the virtualenv running this command — deleting it mid-run would "
|
|
128
|
+
"crash before the teardown finished.\n"
|
|
129
|
+
" Remove it once this command has exited:\n"
|
|
130
|
+
f" rm -rf {self_venv}"
|
|
131
|
+
)
|
|
132
|
+
|
|
79
133
|
console.print(
|
|
80
134
|
"\n[dim]Machine-wide managed-settings.json (if you installed it) needs sudo:[/]\n"
|
|
81
135
|
" sudo rm -f /etc/claude-code/managed-settings.json"
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
"""`agentgov workitem [ref...]` — bind the current Claude Code session to a work item.
|
|
2
|
+
|
|
3
|
+
WHY THIS WAS REWRITTEN
|
|
4
|
+
----------------------
|
|
5
|
+
This command used to POST to the loopback admin listener on :8788 that
|
|
6
|
+
`agentgov wrap` starts. That made it unusable for almost everyone: the rest of
|
|
7
|
+
the system had already moved to governing whatever Claude Code the developer
|
|
8
|
+
actually runs — the VS Code / Cursor / JetBrains extension — via hooks that talk
|
|
9
|
+
straight to the gateway using Claude Code's own `session_id` (see
|
|
10
|
+
gateway/agentgov_gateway/session_identity.py). But `workitem` stayed on the old
|
|
11
|
+
wrapper-only path, so in an editor session there was no listener and every
|
|
12
|
+
attempt died with:
|
|
13
|
+
|
|
14
|
+
No AgentGov loopback listener on :8788.
|
|
15
|
+
No active AgentGov session. Start one: agentgov wrap claude
|
|
16
|
+
|
|
17
|
+
...for a session that was perfectly healthy and already registered. The loopback
|
|
18
|
+
listener never did anything except relay to `POST /admin/session/bind`, so this
|
|
19
|
+
now calls that endpoint directly and works identically under `wrap` and under the
|
|
20
|
+
editor extension.
|
|
21
|
+
|
|
22
|
+
THREE RULES THIS COMMAND OBEYS
|
|
23
|
+
------------------------------
|
|
24
|
+
1. **Never exit non-zero.** The `/workitem` slash command runs this inside a
|
|
25
|
+
`` !`...` `` block, and Claude Code aborts the whole invocation on any
|
|
26
|
+
non-zero exit, reporting `Shell command failed for pattern "..."` — which is
|
|
27
|
+
what the developer saw instead of our actual error text. Failures are
|
|
28
|
+
reported as readable output with exit code 0.
|
|
29
|
+
2. **Accept any text.** A reference is whatever the developer typed: a JIRA key,
|
|
30
|
+
a URL, an id from some other tracker, or a plain label. Nothing is validated
|
|
31
|
+
client-side; the only limit is MAX_REF_LEN characters, and over-long input is
|
|
32
|
+
truncated with a notice rather than refused.
|
|
33
|
+
3. **Self-heal a forgotten session.** The gateway holds sessions in memory, so a
|
|
34
|
+
restart drops every editor window that is already open. Rather than telling
|
|
35
|
+
the developer to start a new session, re-register and retry the bind once.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
import json
|
|
41
|
+
import os
|
|
42
|
+
import subprocess
|
|
43
|
+
from typing import Any
|
|
44
|
+
|
|
45
|
+
import httpx
|
|
46
|
+
import typer
|
|
47
|
+
from rich.console import Console
|
|
48
|
+
|
|
49
|
+
console = Console()
|
|
50
|
+
|
|
51
|
+
# Mirrors MAX_REF_LEN in control-tower/lib/work-items.ts and the CHECK
|
|
52
|
+
# constraint in supabase/migrations/016_freeform_work_items.sql.
|
|
53
|
+
MAX_REF_LEN = 200
|
|
54
|
+
|
|
55
|
+
DEFAULT_GATEWAY = "http://127.0.0.1:8000"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def run(
|
|
59
|
+
ref: list[str] = typer.Argument(
|
|
60
|
+
None,
|
|
61
|
+
help=(
|
|
62
|
+
"Anything that identifies the work: a ticket id, a URL, or a short "
|
|
63
|
+
f"description. Free text is fine. Truncated to {MAX_REF_LEN} characters."
|
|
64
|
+
),
|
|
65
|
+
),
|
|
66
|
+
session: str = typer.Option(
|
|
67
|
+
None,
|
|
68
|
+
"--session",
|
|
69
|
+
help="Claude Code session id. Defaults to $CLAUDE_SESSION_ID / $AGENTGOV_SESSION.",
|
|
70
|
+
),
|
|
71
|
+
gateway: str = typer.Option(
|
|
72
|
+
None,
|
|
73
|
+
"--gateway",
|
|
74
|
+
envvar="AGENTGOV_GATEWAY_URL",
|
|
75
|
+
help=f"Gateway base URL (default {DEFAULT_GATEWAY}).",
|
|
76
|
+
),
|
|
77
|
+
) -> None:
|
|
78
|
+
"""Bind this Claude Code session to a work item, so its tokens are attributed to it."""
|
|
79
|
+
base = (gateway or DEFAULT_GATEWAY).rstrip("/")
|
|
80
|
+
session_id = _resolve_session(session)
|
|
81
|
+
|
|
82
|
+
if not session_id:
|
|
83
|
+
console.print(
|
|
84
|
+
"[red]AgentGov: no Claude Code session id.[/]\n"
|
|
85
|
+
" Run /workitem from inside Claude Code, or pass --session <id> explicitly."
|
|
86
|
+
)
|
|
87
|
+
raise typer.Exit(0)
|
|
88
|
+
|
|
89
|
+
raw = _join(ref)
|
|
90
|
+
if not raw:
|
|
91
|
+
_show_current(base, session_id)
|
|
92
|
+
raise typer.Exit(0)
|
|
93
|
+
|
|
94
|
+
work_ref, truncated = _clamp(raw)
|
|
95
|
+
|
|
96
|
+
result = _bind(base, session_id, work_ref)
|
|
97
|
+
if result is None:
|
|
98
|
+
console.print(
|
|
99
|
+
f"[red]AgentGov: the gateway at {base} is not reachable,[/] so the work item "
|
|
100
|
+
"could not be bound.\n"
|
|
101
|
+
" Start it with: [bold]agentgov gateway install[/] (then: agentgov doctor)"
|
|
102
|
+
)
|
|
103
|
+
raise typer.Exit(0)
|
|
104
|
+
|
|
105
|
+
status, payload = result
|
|
106
|
+
|
|
107
|
+
# A session the gateway has forgotten (restart, reinstall) is transient, not
|
|
108
|
+
# an error the developer should have to act on. Re-register and retry once.
|
|
109
|
+
forgotten = status == 409 or _error_code(payload) == "session_not_registered"
|
|
110
|
+
if forgotten and _register(base, session_id):
|
|
111
|
+
retried = _bind(base, session_id, work_ref)
|
|
112
|
+
if retried is not None:
|
|
113
|
+
status, payload = retried
|
|
114
|
+
|
|
115
|
+
if status >= 400:
|
|
116
|
+
console.print(f"[red]AgentGov: could not bind to[/] {work_ref!r}")
|
|
117
|
+
console.print(f" {_error_text(payload)}")
|
|
118
|
+
raise typer.Exit(0)
|
|
119
|
+
|
|
120
|
+
wi = payload.get("work_item") or {}
|
|
121
|
+
bound = str(wi.get("external_id") or work_ref)
|
|
122
|
+
console.print(f"[green]✅ Bound to[/] [bold]{bound}[/]")
|
|
123
|
+
if truncated:
|
|
124
|
+
console.print(
|
|
125
|
+
f" [yellow]Note:[/] your text was longer than {MAX_REF_LEN} characters "
|
|
126
|
+
"and was shortened to the above."
|
|
127
|
+
)
|
|
128
|
+
console.print(" Tokens from here on are attributed to this work item.")
|
|
129
|
+
raise typer.Exit(0)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# -----------------------------------------------------------------------------
|
|
133
|
+
# Reference handling
|
|
134
|
+
# -----------------------------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _join(ref: list[str] | None) -> str:
|
|
138
|
+
"""Join argv into one reference string.
|
|
139
|
+
|
|
140
|
+
The slash command substitutes the developer's text into a shell line, so the
|
|
141
|
+
text may arrive as many argv entries ("fix", "the", "retry", "loop") and may
|
|
142
|
+
carry the quote characters the slash command wrapped it in. Rejoin, collapse
|
|
143
|
+
whitespace, and drop one matching pair of surrounding quotes if present —
|
|
144
|
+
a developer who types `PROJ-1 fix retry` means one work item, not four, and
|
|
145
|
+
should never see stray quotes in their dashboard.
|
|
146
|
+
"""
|
|
147
|
+
if not ref:
|
|
148
|
+
return ""
|
|
149
|
+
joined = " ".join(ref).strip()
|
|
150
|
+
joined = " ".join(joined.split())
|
|
151
|
+
for q in ("'", '"'):
|
|
152
|
+
if len(joined) >= 2 and joined.startswith(q) and joined.endswith(q):
|
|
153
|
+
joined = joined[1:-1].strip()
|
|
154
|
+
break
|
|
155
|
+
return joined
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _clamp(raw: str) -> tuple[str, bool]:
|
|
159
|
+
"""Enforce MAX_REF_LEN. Returns (ref, was_truncated).
|
|
160
|
+
|
|
161
|
+
Truncate rather than refuse. Refusing would block the developer over a
|
|
162
|
+
pasted URL, and the whole point of binding is attribution — a shortened
|
|
163
|
+
label attributes just as well as a long one.
|
|
164
|
+
"""
|
|
165
|
+
if len(raw) <= MAX_REF_LEN:
|
|
166
|
+
return raw, False
|
|
167
|
+
return raw[:MAX_REF_LEN].rstrip(), True
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _resolve_session(explicit: str | None) -> str:
|
|
171
|
+
"""Claude Code's session id, preferring the one Claude Code itself provides.
|
|
172
|
+
|
|
173
|
+
CLAUDE_SESSION_ID is substituted by Claude Code into slash-command shell
|
|
174
|
+
lines and is the id the gateway keys sessions by (it is the same value sent
|
|
175
|
+
as `x-claude-code-session-id` on every API request). AGENTGOV_SESSION is the
|
|
176
|
+
wrapper's own UUIDv7 and is only a fallback for legacy `agentgov wrap` use.
|
|
177
|
+
"""
|
|
178
|
+
candidates = (
|
|
179
|
+
explicit,
|
|
180
|
+
os.environ.get("CLAUDE_SESSION_ID"),
|
|
181
|
+
os.environ.get("AGENTGOV_SESSION"),
|
|
182
|
+
)
|
|
183
|
+
for candidate in candidates:
|
|
184
|
+
value = (candidate or "").strip()
|
|
185
|
+
# Guard against an unsubstituted placeholder reaching us as literal text.
|
|
186
|
+
if value and not value.startswith("$"):
|
|
187
|
+
return value
|
|
188
|
+
return ""
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
# -----------------------------------------------------------------------------
|
|
192
|
+
# Gateway calls. None = transport failure; otherwise (status_code, json_body).
|
|
193
|
+
# -----------------------------------------------------------------------------
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _bind(base: str, session_id: str, work_ref: str) -> tuple[int, dict[str, Any]] | None:
|
|
197
|
+
try:
|
|
198
|
+
r = httpx.post(
|
|
199
|
+
f"{base}/admin/session/bind",
|
|
200
|
+
json={"session_id": session_id, "work_item_ref": work_ref},
|
|
201
|
+
timeout=20.0,
|
|
202
|
+
)
|
|
203
|
+
except Exception:
|
|
204
|
+
return None
|
|
205
|
+
return r.status_code, _body(r)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _register(base: str, session_id: str) -> bool:
|
|
209
|
+
"""Re-register this session from the current working directory."""
|
|
210
|
+
cwd = os.getcwd()
|
|
211
|
+
root = _git(cwd, "rev-parse", "--show-toplevel")
|
|
212
|
+
if not root:
|
|
213
|
+
return False
|
|
214
|
+
try:
|
|
215
|
+
httpx.post(
|
|
216
|
+
f"{base}/admin/session/register",
|
|
217
|
+
json={
|
|
218
|
+
"session_id": session_id,
|
|
219
|
+
"repo": _git(cwd, "remote", "get-url", "origin"),
|
|
220
|
+
"repo_root": root,
|
|
221
|
+
},
|
|
222
|
+
timeout=15.0,
|
|
223
|
+
)
|
|
224
|
+
except Exception:
|
|
225
|
+
return False
|
|
226
|
+
return True
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _show_current(base: str, session_id: str) -> None:
|
|
230
|
+
"""`/workitem` with no arguments: report the binding instead of erroring.
|
|
231
|
+
|
|
232
|
+
This previously fell through to Typer's `Missing argument 'ref'` usage error
|
|
233
|
+
and a non-zero exit, which Claude Code surfaced as a failed shell command.
|
|
234
|
+
Asking "what am I bound to?" is a reasonable thing to do.
|
|
235
|
+
"""
|
|
236
|
+
try:
|
|
237
|
+
r = httpx.get(f"{base}/admin/session/{session_id}/binding", timeout=10.0)
|
|
238
|
+
data = _body(r)
|
|
239
|
+
except Exception:
|
|
240
|
+
data = {}
|
|
241
|
+
|
|
242
|
+
if data.get("bound"):
|
|
243
|
+
console.print(f"[green]📌 Bound to[/] [bold]{data.get('work_item_ref')}[/]")
|
|
244
|
+
else:
|
|
245
|
+
console.print("[yellow]📌 This session is not bound to a work item.[/]")
|
|
246
|
+
|
|
247
|
+
console.print(
|
|
248
|
+
"\n Bind it with anything that identifies the work — there is no required format:\n"
|
|
249
|
+
" /workitem PROJ-1234\n"
|
|
250
|
+
" /workitem https://your-tracker/issue/42\n"
|
|
251
|
+
" /workitem payment retry loop spike\n"
|
|
252
|
+
f"\n [dim]Up to {MAX_REF_LEN} characters; longer text is shortened.[/]"
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
# -----------------------------------------------------------------------------
|
|
257
|
+
# Small helpers
|
|
258
|
+
# -----------------------------------------------------------------------------
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _body(r: httpx.Response) -> dict[str, Any]:
|
|
262
|
+
try:
|
|
263
|
+
parsed = r.json()
|
|
264
|
+
except Exception:
|
|
265
|
+
return {"error": {"detail": r.text[:400]}}
|
|
266
|
+
return parsed if isinstance(parsed, dict) else {"error": {"detail": str(parsed)[:400]}}
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _error_code(payload: dict[str, Any]) -> str:
|
|
270
|
+
err = payload.get("error")
|
|
271
|
+
return str(err.get("code") or "") if isinstance(err, dict) else ""
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _error_text(payload: dict[str, Any]) -> str:
|
|
275
|
+
err = payload.get("error")
|
|
276
|
+
if isinstance(err, dict):
|
|
277
|
+
detail = err.get("detail") or err.get("message") or err.get("code") or ""
|
|
278
|
+
return str(detail)
|
|
279
|
+
if isinstance(err, str):
|
|
280
|
+
return err
|
|
281
|
+
return json.dumps(payload)[:400] if payload else "no detail returned by the gateway"
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _git(cwd: str, *args: str) -> str:
|
|
285
|
+
try:
|
|
286
|
+
out = subprocess.run(
|
|
287
|
+
["git", *args], cwd=cwd, capture_output=True, text=True, timeout=5, check=False
|
|
288
|
+
)
|
|
289
|
+
except (OSError, subprocess.SubprocessError):
|
|
290
|
+
return ""
|
|
291
|
+
return out.stdout.strip() if out.returncode == 0 else ""
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
# PyPI: `agentgov` was rejected as too similar to existing `agent-gov`.
|
|
3
3
|
# Package name is agentgov-cli; the console command it installs is still `agentgov`.
|
|
4
4
|
name = "agentgov-cli"
|
|
5
|
-
version = "0.1.
|
|
5
|
+
version = "0.1.9"
|
|
6
6
|
description = "AgentGov CLI — wrap Claude Code, bind work items, check gateway health."
|
|
7
7
|
readme = "README.md"
|
|
8
8
|
license = { text = "Apache-2.0" }
|
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
description: Bind this session to a work item (JIRA / Notion / PRJ-XXXX)
|
|
3
|
-
allowed-tools: Bash(agentgov workitem:*)
|
|
4
|
-
argument-hint: <PROJ-1234 | https://notion.so/... | PRJ-0042>
|
|
5
|
-
---
|
|
6
|
-
|
|
7
|
-
Bind the current session to a validated work item so token spend is
|
|
8
|
-
attributed to the right story.
|
|
9
|
-
|
|
10
|
-
Run:
|
|
11
|
-
|
|
12
|
-
!`agentgov workitem $ARGUMENTS`
|
|
13
|
-
|
|
14
|
-
If the CLI prints an error (JIRA not reachable, item DONE, etc.), relay it
|
|
15
|
-
verbatim to the user and stop — do not attempt to run other tools until they
|
|
16
|
-
provide a valid work item. If the CLI prints a success line
|
|
17
|
-
(`✅ Bound to PROJ-1234 …`), acknowledge the binding and continue with whatever
|
|
18
|
-
the user was doing.
|
|
@@ -1,74 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env bash
|
|
2
|
-
# AgentGov statusline (§R-CC3, §13.6).
|
|
3
|
-
# Renders one of:
|
|
4
|
-
# 🛡 <repo> │ 📌 <work-item> │ N% weekly · resets <when> (personal mode w/ rate-limit)
|
|
5
|
-
# 🛡 <repo> │ 📌 <work-item> │ N.NNk tok / $C.CC (vault mode w/ cost)
|
|
6
|
-
# 🛡 <repo> │ 📌 UNBOUND — /workitem required (no binding)
|
|
7
|
-
|
|
8
|
-
set -u
|
|
9
|
-
|
|
10
|
-
session="${AGENTGOV_SESSION:-}"
|
|
11
|
-
state_file="${HOME}/.agentgov/state/${session}.json"
|
|
12
|
-
|
|
13
|
-
if [[ -z "${session}" ]]; then
|
|
14
|
-
printf "\033[31m📌 UNGOVERNED — run agentgov wrap claude\033[0m"
|
|
15
|
-
exit 0
|
|
16
|
-
fi
|
|
17
|
-
|
|
18
|
-
if [[ ! -f "${state_file}" ]]; then
|
|
19
|
-
printf "\033[33m🛡 %s\033[0m │ \033[31m📌 UNBOUND — /workitem required\033[0m" "${AGENTGOV_REPO:-unknown}"
|
|
20
|
-
exit 0
|
|
21
|
-
fi
|
|
22
|
-
|
|
23
|
-
# Parse fields — jq preferred; python fallback.
|
|
24
|
-
if command -v jq >/dev/null 2>&1; then
|
|
25
|
-
repo=$(jq -r '.repo // env.AGENTGOV_REPO // "unknown"' "${state_file}")
|
|
26
|
-
wi=$(jq -r '.work_item.external_id // "UNBOUND"' "${state_file}")
|
|
27
|
-
tokens=$(jq -r '.tokens // 0' "${state_file}")
|
|
28
|
-
cost=$(jq -r '.cost_usd // 0' "${state_file}")
|
|
29
|
-
percent=$(jq -r '.rate_limit_percent // ""' "${state_file}")
|
|
30
|
-
reset=$(jq -r '.rate_limit_reset // ""' "${state_file}")
|
|
31
|
-
else
|
|
32
|
-
read -r repo wi tokens cost percent reset < <(python3 - "${state_file}" <<'PY'
|
|
33
|
-
import json, os, sys
|
|
34
|
-
d = json.load(open(sys.argv[1]))
|
|
35
|
-
print(
|
|
36
|
-
d.get("repo") or os.environ.get("AGENTGOV_REPO", "unknown"),
|
|
37
|
-
(d.get("work_item") or {}).get("external_id") or "UNBOUND",
|
|
38
|
-
d.get("tokens") or 0,
|
|
39
|
-
d.get("cost_usd") or 0,
|
|
40
|
-
d.get("rate_limit_percent") or "",
|
|
41
|
-
d.get("rate_limit_reset") or "",
|
|
42
|
-
)
|
|
43
|
-
PY
|
|
44
|
-
)
|
|
45
|
-
fi
|
|
46
|
-
|
|
47
|
-
# Format tokens as N.Nk if >= 1000.
|
|
48
|
-
if [[ "$tokens" -ge 1000 ]] 2>/dev/null; then
|
|
49
|
-
tokens_h=$(awk "BEGIN {printf \"%.1fk\", $tokens/1000}")
|
|
50
|
-
else
|
|
51
|
-
tokens_h="${tokens}"
|
|
52
|
-
fi
|
|
53
|
-
|
|
54
|
-
if [[ "$wi" == "UNBOUND" ]]; then
|
|
55
|
-
wi_display="\033[31m📌 UNBOUND — /workitem required\033[0m"
|
|
56
|
-
else
|
|
57
|
-
wi_display="📌 ${wi}"
|
|
58
|
-
fi
|
|
59
|
-
|
|
60
|
-
# Prefer rate-limit view (personal mode) when we have it; fall back to cost.
|
|
61
|
-
if [[ -n "${percent}" && "${percent}" != "null" && "${percent}" != "None" ]]; then
|
|
62
|
-
# Color the % red under 15%, yellow under 40%, green otherwise.
|
|
63
|
-
if awk "BEGIN {exit !($percent < 15)}"; then color=31
|
|
64
|
-
elif awk "BEGIN {exit !($percent < 40)}"; then color=33
|
|
65
|
-
else color=32; fi
|
|
66
|
-
if [[ -n "${reset}" && "${reset}" != "null" ]]; then
|
|
67
|
-
reset_short=$(printf "%s" "$reset" | cut -c 6-16 | tr T " ")
|
|
68
|
-
printf "🛡 %s │ %b │ \033[%sm%s%% weekly\033[0m · resets %s" "$repo" "$wi_display" "$color" "$percent" "$reset_short"
|
|
69
|
-
else
|
|
70
|
-
printf "🛡 %s │ %b │ \033[%sm%s%% weekly\033[0m" "$repo" "$wi_display" "$color" "$percent"
|
|
71
|
-
fi
|
|
72
|
-
else
|
|
73
|
-
printf "🛡 %s │ %b │ %s tok / \$%.2f" "$repo" "$wi_display" "$tokens_h" "$cost"
|
|
74
|
-
fi
|
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
"""`agentgov status` — show current binding + session totals for cwd."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import httpx
|
|
6
|
-
import typer
|
|
7
|
-
from rich.console import Console
|
|
8
|
-
|
|
9
|
-
from ..port_resolver import list_active_sessions, resolve_port
|
|
10
|
-
|
|
11
|
-
console = Console()
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
def run(
|
|
15
|
-
session: str = typer.Option(None, "--session", envvar="AGENTGOV_SESSION"),
|
|
16
|
-
port: int = typer.Option(0, "--port", help="Override port; 0 = resolve"),
|
|
17
|
-
) -> None:
|
|
18
|
-
resolved_port = port if port > 0 else resolve_port(session)
|
|
19
|
-
if resolved_port is None:
|
|
20
|
-
active = list_active_sessions()
|
|
21
|
-
if not active:
|
|
22
|
-
console.print("[yellow]No active AgentGov session.[/]")
|
|
23
|
-
console.print("Start one: [bold]agentgov wrap claude[/]")
|
|
24
|
-
raise typer.Exit(1)
|
|
25
|
-
console.print("[yellow]Multiple active sessions[/] — pass --session <id>:")
|
|
26
|
-
for sid, prt in active:
|
|
27
|
-
console.print(f" {sid} → :{prt}")
|
|
28
|
-
raise typer.Exit(1)
|
|
29
|
-
|
|
30
|
-
try:
|
|
31
|
-
r = httpx.post(f"http://127.0.0.1:{resolved_port}/status", timeout=5.0)
|
|
32
|
-
r.raise_for_status()
|
|
33
|
-
data = r.json()
|
|
34
|
-
except Exception:
|
|
35
|
-
console.print("[yellow]No active AgentGov session.[/]")
|
|
36
|
-
console.print("Start one: [bold]agentgov wrap claude[/]")
|
|
37
|
-
raise typer.Exit(1) from None
|
|
38
|
-
|
|
39
|
-
wi = data.get("work_item")
|
|
40
|
-
if not wi:
|
|
41
|
-
console.print("[red]📌 UNBOUND[/] — run `/workitem <ref>` in Claude Code.")
|
|
42
|
-
else:
|
|
43
|
-
console.print(
|
|
44
|
-
f"📌 {wi.get('external_id')} — [bold]{wi.get('title')}[/] ({wi.get('item_status')})"
|
|
45
|
-
)
|
|
46
|
-
console.print(f"tokens: {data.get('tokens', 0):,} cost: ${data.get('cost_usd', 0):.2f}")
|
|
@@ -1,72 +0,0 @@
|
|
|
1
|
-
"""`agentgov workitem <ref>` — bind current session to a work item.
|
|
2
|
-
|
|
3
|
-
Posts to the loopback admin listener started by `agentgov wrap`. Port is
|
|
4
|
-
resolved dynamically (gap-P0-3): env AGENTGOV_LOOPBACK_PORT set by wrap →
|
|
5
|
-
per-session state file → single-session convenience.
|
|
6
|
-
"""
|
|
7
|
-
|
|
8
|
-
from __future__ import annotations
|
|
9
|
-
|
|
10
|
-
import httpx
|
|
11
|
-
import typer
|
|
12
|
-
from rich.console import Console
|
|
13
|
-
|
|
14
|
-
from ..port_resolver import list_active_sessions, resolve_port
|
|
15
|
-
|
|
16
|
-
console = Console()
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
def run(
|
|
20
|
-
ref: str = typer.Argument(..., help="JIRA key, Notion URL, or PRJ-XXX id"),
|
|
21
|
-
session: str = typer.Option(
|
|
22
|
-
None,
|
|
23
|
-
"--session",
|
|
24
|
-
envvar="AGENTGOV_SESSION",
|
|
25
|
-
help="Session id (usually inherited from `agentgov wrap`).",
|
|
26
|
-
),
|
|
27
|
-
port: int = typer.Option(
|
|
28
|
-
0,
|
|
29
|
-
"--port",
|
|
30
|
-
help="Override port explicitly. 0 = resolve from env/state.",
|
|
31
|
-
),
|
|
32
|
-
) -> None:
|
|
33
|
-
"""Bind the current Claude Code session to <ref>."""
|
|
34
|
-
resolved_port = port if port > 0 else resolve_port(session)
|
|
35
|
-
if resolved_port is None:
|
|
36
|
-
_no_listener_help()
|
|
37
|
-
raise typer.Exit(2)
|
|
38
|
-
|
|
39
|
-
try:
|
|
40
|
-
r = httpx.post(
|
|
41
|
-
f"http://127.0.0.1:{resolved_port}/workitem",
|
|
42
|
-
json={"ref": ref},
|
|
43
|
-
timeout=15.0,
|
|
44
|
-
)
|
|
45
|
-
except Exception as e:
|
|
46
|
-
console.print(f"[red]No AgentGov loopback listener on :{resolved_port}.[/]")
|
|
47
|
-
_no_listener_help()
|
|
48
|
-
raise typer.Exit(2) from e
|
|
49
|
-
|
|
50
|
-
if r.status_code >= 400:
|
|
51
|
-
console.print(f"[red]bind failed:[/] {r.text}")
|
|
52
|
-
raise typer.Exit(2)
|
|
53
|
-
|
|
54
|
-
data = r.json()
|
|
55
|
-
wi = data.get("work_item") or {}
|
|
56
|
-
console.print(
|
|
57
|
-
f"[green]✅ Bound to[/] {wi.get('external_id')} — [bold]{wi.get('title')}[/] "
|
|
58
|
-
f"({wi.get('item_status')})"
|
|
59
|
-
)
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
def _no_listener_help() -> None:
|
|
63
|
-
active = list_active_sessions()
|
|
64
|
-
if not active:
|
|
65
|
-
console.print(
|
|
66
|
-
"[red]No active AgentGov session.[/] Start one: [bold]agentgov wrap claude[/]"
|
|
67
|
-
)
|
|
68
|
-
return
|
|
69
|
-
if len(active) > 1:
|
|
70
|
-
console.print("[yellow]Multiple active sessions[/] — pass --session <id>:")
|
|
71
|
-
for sid, prt in active:
|
|
72
|
-
console.print(f" {sid} → :{prt}")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{agentgov_cli-0.1.8 → agentgov_cli-0.1.9}/agentgov_cli/client_assets/hooks/pretooluse_pathguard.py
RENAMED
|
File without changes
|
{agentgov_cli-0.1.8 → agentgov_cli-0.1.9}/agentgov_cli/client_assets/hooks/sessionstart_register.py
RENAMED
|
File without changes
|
{agentgov_cli-0.1.8 → agentgov_cli-0.1.9}/agentgov_cli/client_assets/managed-settings.json.tmpl
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|