Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,64 @@ All notable changes to Code Context Control (C3) are documented here.
The format is loosely based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [2.158.0] - 2026-10-20

### Added — `format="json"` for c3_jira and c3_bitbucket

Programs that read these tools, such as the Work Agents Work page, had to
parse text replies capped at 2400 tokens, which cut long descriptions and
comment threads. With `format="json"` the read actions return the normalized
data instead: `{"ok": true, "action": ..., "data": {...}}`, under a
400,000-character cap. A reply over the cap shrinks its largest fields until
it fits, so the output is always valid JSON, and `"truncated"` names the
fields that were cut. Comments and PR activities keep their newest entries.
Errors come back as `{"ok": false, "action", "error", "status"?, "code"?}`.
Writes and actions with no structured form return their text reply in
`data.text`. Text stays the default, so agents see no change.

- `cli/tools/_json_reply.py`: the shared envelope and trimming.
- `cli/mcp_server.py`: the auto-snapshot notice is no longer appended to a
JSON reply, which would have made it invalid.
- `c3_bitbucket` JSON covers whoami, list_projects, list_repos, get_repo,
readme, get_file, list_prs, get_pr, get_pr_diff, get_pr_activities
(oldest first, replies nested), get_pr_commits, pr_issues, list_pr_tasks,
list_branches, list_commits and build_status. Commit DTOs carry no author
email.

### Added — Jira: rendered HTML, issue types, development panel

- `get_issue` requests `expand=renderedFields`. The DTO gains
`description_html` and a `body_html` on each comment (the server's own
HTML, unsanitized: a caller that displays it must sanitize it), plus `id`,
`status_category`, `priority`, `issue_type_subtask`, `issue_type_icon`,
`parent_type`, `subtasks`, `components`, `fix_versions`, `due`, issue
links and attachments with their URLs.
- `search` requests issue type and priority, so its rows carry them along
with the status category. `validate="warn"` sends `validateQuery=warn` on
Data Center, so `key in (...)` naming a missing issue returns the others
instead of failing. Cloud's `/search/jql` has no such parameter and
ignores it.
- New read action `dev_status(issue)`: pull requests, branches and commits
from the issue's development panel (`/rest/dev-status/1.0/issue/detail`).
That API is internal and some instances block it; HTTP 401/403/404/405 on
the first call returns `{"available": false, "reason": ...}`.

### Added — Bitbucket: README, files, linked Jira issues, richer repos and branches

- `get_repo` shows the description, default branch, browse URL and clone
links.
- New `readme(project, repo, at)`: picks the first `README*` in the repo
root (Markdown first, then `.rst`, `.txt`, no extension) and returns its
raw text. New
`get_file(project, repo, path, at)` uses the same code. Both refuse binary
files and cap text at 256 KB.
- New `pr_issues(pr_id)`: the Jira issues Bitbucket links to a pull request,
through `/rest/jira/1.0/` (needs the Bitbucket–Jira integration).
- `list_branches` sends `details=true`. When the server provides branch
metadata, rows gain ahead/behind counts, the last commit, Jira keys, the
outgoing pull request and build counts. It also takes `filter_text` and
`order` (`alphabetical` or `modification`).

## [2.157.2] - 2026-10-16

### Fixed — the shell scans stop refusing commands that name no protected file
Expand Down
2 changes: 1 addition & 1 deletion cli/c3.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@
# Config
CONFIG_DIR = ".c3"
CONFIG_FILE = ".c3/config.json"
__version__ = "2.157.2"
__version__ = "2.158.0"

# The PreToolUse matcher for native subagent calls (installer and hub migration).
AGENT_MATCHER = "Agent|Task"
Expand Down
45 changes: 36 additions & 9 deletions cli/mcp_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -768,10 +768,13 @@ def _finalize_response(ctx: Context, tool_name: str, args: dict,
reasoning=f"Budget at {snap_pct}% — auto-snapshot before potential /clear",
description="", summary="", event_type="auto",
svc=svc, finalize=lambda *a, **kw: "")
response += (
f"\n\n[c3:auto_snapshot] Budget {snap_pct}%. Snapshot saved. "
f"Tell user to /clear, then restore."
)
# A format="json" reply must stay parseable: no text appended.
from cli.tools._json_reply import is_json_reply
if not is_json_reply(response):
response += (
f"\n\n[c3:auto_snapshot] Budget {snap_pct}%. Snapshot saved. "
f"Tell user to /clear, then restore."
)
svc.activity_log.log("auto_snapshot", {"budget_pct": snap_pct})
except Exception:
pass
Expand Down Expand Up @@ -1289,11 +1292,20 @@ async def c3_bitbucket(
webhook_id: int = 0,
comment_id: int = 0,
limit: int = 50,
path: str = "",
at: str = "",
filter_text: str = "",
order: str = "",
format: str = "",
ctx: Context = None,
) -> str:
"""BITBUCKET (Data Center / Server) — see and act on PRs, branches, builds, repo admin.
actions: status, whoami, list_projects, list_repos, get_repo,
actions: status, whoami, list_projects, list_repos, get_repo (description,
default branch, browse and clone URLs),
readme ([at=branch/commit] — the root README, markdown preferred),
get_file (path [at] — one text file, 256 KB cap, binary refused),
list_prs, get_pr, get_pr_diff, get_pr_activities, get_pr_commits,
pr_issues (pr_id — Jira issues Bitbucket's Jira integration links to the PR),
create_pr, update_pr (pr_id + any of title/description/reviewers/to_branch —
unchanged fields are preserved; reviewers is the FULL comma-separated
replacement set), comment_pr,
Expand All @@ -1305,10 +1317,13 @@ async def c3_bitbucket(
list_pr_tasks (state filters OPEN/RESOLVED), create_pr_task (pr_id, body —
a blocker task on the PR), resolve_pr_task (pr_id, comment_id from
list_pr_tasks),
list_branches, create_branch, delete_branch,
list_commits, list_activity, build_status,
list_branches ([filter_text] [order=MODIFICATION|ALPHABETICAL]), create_branch,
delete_branch, list_commits ([branch] [path]), list_activity, build_status (commit),
repo_settings, update_repo_settings, list_webhooks, create_webhook, delete_webhook,
list_permissions.
format="json": reads return {"ok", "action", "data"} JSON (normalized fields, a
~400k-char cap that trims lists and sets "truncated"); writes return the text
reply in data.text; errors return {"ok": false, "error"}. For programs, not agents.
project/repo fall back to bitbucket.default_project/default_repo from .c3/config.json.
Tokens live in the OS keyring — `c3 bitbucket login` to set them up first."""
svc = _svc(ctx)
Expand All @@ -1324,7 +1339,8 @@ def finalize(fname, fargs, fresp, fsumm, **kw):
description=description, reviewers=reviewers, name=name, url=url,
events=events, start_point=start_point, commit=commit,
settings=settings, webhook_id=webhook_id, comment_id=comment_id,
limit=limit,
limit=limit, path=path, at=at, filter_text=filter_text, order=order,
format=format,
)


Expand Down Expand Up @@ -1357,10 +1373,16 @@ async def c3_jira(
file_path: str = "",
delete_subtasks: bool = False,
limit: int = 25,
validate: str = "",
format: str = "",
ctx: Context = None,
) -> str:
"""JIRA (Cloud + Data Center) — search, read, create, update, link, sprint, comment, transition, assign issues.
actions: status, whoami, search (jql), get_issue (issue — shows parent, links, attachments),
actions: status, whoami, search (jql [validate=warn — Data Center: unknown keys in
`key in (...)` become warnings instead of failing the query]),
get_issue (issue — shows type, parent, subtasks, links, attachments),
dev_status (issue — pull requests, branches and commits from Jira's development panel;
some instances disable that API, which reports as unavailable),
my_issues, list_projects, list_transitions (issue), get_create_metadata (project, issue_type),
search_users (query), list_link_types,
list_boards ([project]), list_sprints (board_id [sprint_state=active|future|closed]),
Expand All @@ -1383,6 +1405,10 @@ async def c3_jira(
get_create_metadata reflects Jira's field configuration, not the create screen — if
create_issue rejects a listed field ('not on the appropriate screen'), create without
it and set it via update_issue (the edit screen is configured separately).
format="json": reads return {"ok", "action", "data"} JSON (normalized fields incl.
rendered HTML description/comments, a ~400k-char cap that trims lists and sets
"truncated"); writes return the text reply in data.text; errors return
{"ok": false, "error"}. For programs, not agents.
project falls back to the account's default_project; account to jira.default_account.
Mutating actions are ledger-logged. Tokens live in the OS keyring — `c3 jira login` first."""
svc = _svc(ctx)
Expand All @@ -1402,6 +1428,7 @@ def finalize(fname, fargs, fresp, fsumm, **kw):
sprint_id=sprint_id, sprint_state=sprint_state,
time_spent=time_spent, file_path=file_path,
delete_subtasks=delete_subtasks, limit=limit,
validate=validate, format=format,
)


Expand Down
90 changes: 90 additions & 0 deletions cli/tools/_json_reply.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
"""JSON replies for c3_jira / c3_bitbucket ``format="json"``.

Programs (dashboards, the Work Agents Work page) read these tools too, and
the text replies are capped at a few thousand tokens for agents. JSON mode
returns the normalized data instead, under a much larger character cap that
trims list and string fields so the reply always stays valid JSON:

{"ok": true, "action": "get_issue", "data": {...}, "truncated": ["comments"]}
{"ok": false, "action": "get_issue", "error": "...", "status": 404}
"""
from __future__ import annotations

import json
from typing import Any

JSON_CHAR_CAP = 400_000

# Lists ordered oldest first: trimming keeps their newest entries.
_KEEP_TAIL = {"comments", "activities"}

# A string shorter than this is not worth cutting; the next field is.
_MIN_CUT = 1000


def wants_json(kwargs: dict) -> bool:
return str(kwargs.get("format") or "").strip().lower() == "json"


def _dumps(obj: Any) -> str:
return json.dumps(obj, ensure_ascii=False, default=str)


def _fit(data: dict, cap: int, overhead: int) -> list[str]:
"""Shrink the largest top-level fields of *data* in place until the
serialized dict fits in ``cap - overhead`` characters. Returns the names
of the fields that were cut."""
truncated: list[str] = []
budget = max(0, cap - overhead)
for _ in range(200):
if len(_dumps(data)) <= budget:
break
sizes = {k: len(_dumps(v)) for k, v in data.items()}
key = max(sizes, key=sizes.get)
value = data[key]
if isinstance(value, list) and len(value) > 1:
keep = max(1, len(value) * 3 // 4)
data[key] = value[-keep:] if key in _KEEP_TAIL else value[:keep]
elif isinstance(value, str) and len(value) > _MIN_CUT:
data[key] = value[: len(value) * 3 // 4]
elif isinstance(value, (list, dict)) and value:
data[key] = [] if isinstance(value, list) else {}
else:
data[key] = None
if key not in truncated:
truncated.append(key)
return truncated


def ok(action: str, data: Any, *, cap: int = JSON_CHAR_CAP) -> str:
"""Serialize a successful read. A non-dict *data* is wrapped as
``{"values": data}`` so every reply trims the same way."""
if not isinstance(data, dict):
data = {"values": data}
data = dict(data)
head = {"ok": True, "action": action}
truncated = _fit(data, cap, len(_dumps(head)) + 64)
out = {**head, "data": data}
if truncated:
out["truncated"] = truncated
return _dumps(out)


def error(action: str, message: str, *, status: int = 0, code: str = "") -> str:
out: dict[str, Any] = {"ok": False, "action": action, "error": message}
if status:
out["status"] = status
if code:
out["code"] = code
return _dumps(out)


def text(action: str, resp: str) -> str:
"""An action with no structured form (writes, diffs) in JSON mode: the
text reply rides in ``data.text`` so the caller still gets JSON."""
return ok(action, {"text": resp})


def is_json_reply(response: str) -> bool:
"""True for a reply built here; callers must not append text to it."""
return response.startswith('{"ok": ')
Loading
Loading