Skip to content

v0.6.0: Branch lifecycle management + security hardening - #17

Merged
padak merged 3 commits into
mainfrom
feature/branch-lifecycle
Mar 3, 2026
Merged

padak merged 3 commits into
mainfrom
feature/branch-lifecycle

Conversation

@padak

@padak padak commented Mar 3, 2026 •

Copy link
Copy Markdown
Member

Summary

Complete development branch lifecycle management for Keboola projects, eliminating the 5-step manual workflow described in the ideal-workflow-proposal. Users can now create, switch, reset, delete, and merge branches entirely from the CLI, with persistent active branch state that auto-resolves in all MCP tool operations.

Also adds LLM export (wraps kbc llm export) and version check commands, plus security hardening and architecture improvements.

New Commands

Branch Lifecycle

Command Description
kbagent branch create --project ALIAS --name "fix-x" Create branch + auto-activate
kbagent branch use --project ALIAS --branch ID Switch to existing branch
kbagent branch reset --project ALIAS Revert to main branch
kbagent branch delete --project ALIAS --branch ID Delete branch (auto-resets if active)
kbagent branch merge --project ALIAS Get KBC UI merge URL (no force-merge)

LLM Export

Command Description
kbagent llm export [--project ALIAS] AI-optimized project export via kbc Go binary

Wraps kbc llm export with credentials auto-resolved from config store. Detects kbc binary, validates project, runs export to per-project output dirs.

Version Check

Command Description
kbagent version Show kbagent version + check kbc and MCP server updates

Parallel version fetching from GitHub releases (kbc) and PyPI (keboola-mcp-server), with rich formatted output.

Key Features

Active Branch Persistence

  • active_branch_id stored per-project in config.json
  • tool list and tool call auto-resolve active branch (no --branch flag needed)
  • Visible in project list, project status, and branch list tables (>>> marker)

Async Job Handling

  • Branch create/delete are async Storage API operations (POST returns a job, not branch data)
  • KeboolaClient._wait_for_storage_job() polls until completion (1s interval, 60s max)
  • Real branch ID extracted from job.results.id (not the job ID)

Typical Branch Workflow

kbagent branch create --project prod --name "fix-transform-x"
  # creates branch AND sets it as active
kbagent tool call list_configs --project prod
  # auto-uses active branch
kbagent tool call update_sql_transformation --project prod --input '{...}'
  # auto-uses active branch
kbagent branch merge --project prod
  # returns KBC UI URL for safe review, resets to main

Security Fixes

Fix File Description
TOCTOU race config_store.py Atomic write via os.open(0o600) + os.replace() - config file never exists with world-readable permissions
XSS kbc-explorer/index.html escapeHtml() helper for all innerHTML with dynamic data (project names, bucket names)
None key lineage_service.py Skip projects with project_id=None in lineage lookup (was creating None dict key)
None in set org_service.py Filter None from existing_project_ids set

Architecture Improvements

Change File(s) Description
Lazy MCP timeouts mcp_service.py _get_tool_timeout() / _get_init_timeout() read env vars at call time, not import time
Centralized constants constants.py VALID_COMPONENT_TYPES, VALID_STATUSES moved from command layer
Branch type commands/tool.py --branch changed from str to int (consistent with branch.py)
Dedup validation commands/tool.py _validate_branch_requires_project() extracted from duplicate blocks
Output dir explorer_service.py _default_output_dir() uses Path.cwd() instead of package-relative path
Import constant explorer_service.py Uses MAX_JOB_LIMIT from constants.py instead of local duplicate

Files Changed

  • 41 files changed, +3447 / -662 lines
  • 687 tests passed, 3 skipped, 0 failures
  • Lint clean (ruff)

Test Plan

  • All 687 unit tests pass
  • Ruff lint clean
  • Security review passed (0 high-confidence findings)
  • Architecture review completed
  • Live-tested branch create/list/merge with real Keboola project
  • Active branch >>> marker verified in branch list output
  • Backward compatible - existing config.json without active_branch_id loads correctly
  • Manual smoke test of full branch workflow on staging project

padak added 3 commits March 3, 2026 23:07
Branch lifecycle commands:
- branch create: create dev branch + auto-activate for subsequent tool calls
- branch use: switch to existing branch (validates via API)
- branch reset: revert to main/production branch
- branch delete: delete branch (auto-resets if it was active)
- branch merge: generate KBC UI merge URL (safe review, no force-merge)

Active branch persistence:
- active_branch_id stored per-project in config.json
- Auto-resolved in tool list/call (no need to pass --branch every time)
- Displayed in project list/status and branch list tables

Async job handling:
- Branch create/delete are async Storage API operations
- Client polls job status until completion (1s interval, 60s max)
- Real branch ID extracted from job.results (not job ID)

Security fixes:
- TOCTOU race in config_store: atomic write via os.open(0o600) + os.replace()
- XSS in kbc-explorer: escapeHtml() for all innerHTML with dynamic data
- None key validation in lineage_service and org_service

Architecture improvements:
- MCP timeouts read lazily at runtime (not at import)
- Domain constants (VALID_COMPONENT_TYPES, VALID_STATUSES) centralized
- --branch flag typed as int (consistent with branch.py)
- Duplicate branch validation extracted to helper
- explorer DEFAULT_OUTPUT_DIR uses Path.cwd() instead of package-relative path
- explorer uses MAX_JOB_LIMIT from constants instead of local duplicate

Tests: 687 passed, 3 skipped
- kbagent llm export: AI-optimized project export via kbc Go binary
  Wraps 'kbc llm export' with credentials auto-resolved from config store.
  Detects kbc binary, validates project, runs export to per-project dirs.

- kbagent version: Show kbagent version and check for dependency updates
  Checks kbc (via GitHub releases) and keboola-mcp-server (via PyPI).
  Parallel version fetching with rich formatted output.

- Remove docs/PLAN-catalog.md (implemented as kbagent explorer)
@padak
padak merged commit 5f5ae47 into main Mar 3, 2026
padak added a commit that referenced this pull request Mar 24, 2026
- Add sync commands section to kbagent context (agent instructions)
- Add sync workflow tip (#17) to Tips for AI Agents
- Regenerate SKILL.md with 8 new sync commands in decision table
padak added a commit that referenced this pull request Mar 24, 2026
* Add sync init/pull/status commands (Phase 1)

Implement GitOps workflow for downloading Keboola project configurations
to local filesystem in Go CLI-compatible format (manifest.json v2, _config.yml).

New commands:
- kbagent sync init --project ALIAS: create .keboola/manifest.json
- kbagent sync pull --project ALIAS: download all configs as YAML files
- kbagent sync status: detect local modifications via SHA256 hashing

New modules:
- sync/manifest.py: Pydantic v2 models with camelCase aliases
- sync/config_format.py: API JSON <-> YAML _config.yml conversion
- sync/secrets.py: encrypted value detection (KBC::*Secure::)
- sync/naming.py: path generation from naming templates
- sync/git_utils.py: git helpers (branch detection, repo check)

Also: naming collision resolution, 106 new tests, lint clean.

* Add sync diff/push commands (Phase 2)

Implement bidirectional sync: detect changes between local files and
remote Keboola project, then push modifications via API.

New commands:
- kbagent sync diff --project ALIAS: compare local vs remote with deep diff
- kbagent sync push --project ALIAS: create/update/delete configs [--dry-run] [--force]

New modules:
- sync/diff_engine.py: normalize, hash, deep_diff, compute_changeset
  - Encrypted value normalization prevents false diffs from nonces
  - Human-readable diff output with depth/line limits

Extended client.py with config CRUD:
- create_config, update_config, create_config_row, update_config_row, delete_config_row

Also: stable paths on pull (renames don't change filesystem paths),
36 new tests (981 total), lint clean.

* Add git branch mapping commands (Phase 3)

Implement git-to-Keboola branch mapping for GitOps workflow.
Each git branch maps to a Keboola dev branch via branch-mapping.json.

New commands:
- kbagent sync branch-link: link git branch to Keboola branch (auto-creates)
- kbagent sync branch-unlink: remove branch mapping
- kbagent sync branch-status: show current branch mapping

New module:
- sync/branch_mapping.py: BranchMapping model, load/save (Go CLI compatible)

Features: auto-create Keboola branch, search by name/ID, default branch
protection, already-linked detection. 42 new tests (1023 total).

* Add SQL/Python code extraction (Phase 4)

Extract embedded code from configs into separate files on pull,
merge them back on push. Makes code git-diffable and IDE-friendly.

SQL transformations: parameters.blocks -> transform.sql with markers
  /* ===== BLOCK: name ===== */ / /* ===== CODE: name ===== */
Python transformations: blocks -> transform.py, packages -> pyproject.toml
Custom Python apps: parameters.code -> code.py, packages -> pyproject.toml

Code files are merged back into config before diff comparison and push,
so the round-trip is lossless. 22 new tests (1045 total).

* Update context and SKILL.md with sync commands

- Add sync commands section to kbagent context (agent instructions)
- Add sync workflow tip (#17) to Tips for AI Agents
- Regenerate SKILL.md with 8 new sync commands in decision table

* Fix ruff formatting in 4 files

* Fix ANSI escape codes in help test assertions

* Fix remaining ANSI assertion in branch-link help test
@padak
padak deleted the feature/branch-lifecycle branch March 26, 2026 11:43
ottomansky pushed a commit to ottomansky/keboola-agent-cli that referenced this pull request Apr 30, 2026
Expand CONTRIBUTING.md "Documentation changes (mandatory!)" with three
sub-categories (CLI surface / auto-generated / plugin hand-maintained)
and explicit sub-bullets for plugins/kbagent/agents/keboola-expert.md:
Rule 6 VERSION GATE examples, the tool-selection matrix, and inline
gotchas. None of these have CI freshness checks today; the new
"Plugin synchronization map" table makes the silent-drift surface
visible at a glance, and the new "Releasing a new version" checklist
walks the manual review steps that CI cannot perform.

Add CLAUDE.md coding convention keboola#17 that points at these new sections
and lists the silent-drift surfaces by name. Replace the stale parallel
update list under "Claude Code Plugin (Marketplace)" (which still
told contributors to bump plugin.json by hand even though the
pre-commit hook auto-syncs it from pyproject.toml) with a single
pointer to CONTRIBUTING.md, keeping one authoritative source of truth.

Add a heads-up note above CLAUDE.md "## All CLI Commands" so anyone
editing the table sees the cross-file obligations before touching it.
padak pushed a commit that referenced this pull request May 12, 2026
…hat preserves schema (#277)

Adds a typed wrapper for the Storage API row-truncate endpoint
(DELETE /v2/storage/[branch/{id}/]tables/{id}/rows?allowTruncate=1).
Closes the only confirmed FIIA-migration gap (Coverage Matrix Row 25;
R5 per-phase reload invariant).

- Client (src/keboola_agent_cli/client.py): truncate_table(table_id,
  branch_id=None) primitive. Live-validated against project 1143 on
  connection.europe-west3.gcp.keboola.com — the endpoint is uniformly
  async-via-job on every branch and REJECTS async=true with HTTP 400
  ("async: This field was not expected."), a deliberate departure
  from sibling destructive endpoints (delete_table, delete_bucket).
  The endpoint always returns HTTP 202 + a queued tableRowsDelete job
  that the existing _wait_for_storage_job helper polls to completion.

- Service (src/keboola_agent_cli/services/storage_service.py):
  truncate_tables(alias, table_ids[], dry_run, branch_id) mirrors
  delete_tables's batch-tolerant pattern. Captures rows_before via
  get_table_detail with a defensive int() guard that handles None,
  missing rowsCount, non-numeric strings, lists, and dicts. Per-target
  KeboolaApiError accumulates into failed[] without aborting the
  batch. Envelope: {truncated:[{table_id,rows_before,rows_after,
  branch_id}], failed:[{id,error}], dry_run, project_alias,
  would_truncate?} matching delete-table sibling naming.

- Command (src/keboola_agent_cli/commands/storage.py):
  storage_truncate_table under rich_help_panel=_TABLES, immediately
  after delete-table. Same --yes / --dry-run / --branch contract,
  ConfigError → exit 5, failed[] → exit 1.

- Hint registry (hints/definitions/storage.py): storage.truncate-table
  registered with both client and service paths.

- Permission registry (permissions.py): storage.truncate-table
  classified as 'destructive' alongside delete-table, delete-column,
  delete-bucket, swap-tables.

- Tests (tests/test_storage_truncate.py): 21 unit tests (5 client
  via pytest-httpx, 10 service via MagicMock, 6 CLI via CliRunner)
  covering URL/query-params, branch_id prefix, async-poll roundtrip,
  URL encoding, 4xx propagation, happy path, defensive rowsCount
  guard (non-numeric + missing), batch partial failure, dry-run, all
  exit codes, active-branch fallback.

- E2E (tests/test_e2e.py): _test_truncate_table_roundtrip at step 11.1
  snapshots schema → dry-runs → truncates → verifies preservation →
  restores. Live-validated against project 1143: rows 5→0, PK / columns
  / descriptions intact, restore round-trip clean.

- 7 silent-drift surfaces synced per convention #17:
  context.py, CLAUDE.md ## All CLI Commands, keboola-expert.md
  (Rule 6 VERSION GATE + Tool Selection Matrix + inline gotcha),
  SKILL.md (auto-regenerated), commands-reference.md, gotchas.md
  (new (since v0.32.0) section), changelog.py.

- Version bump 0.31.0 → 0.32.0 (minor, new feature on top of upstream
  0.31.0 which shipped project edit --new-alias + sql_resplit).
  Propagated via make version-sync to plugin.json + marketplace.json
  + uv.lock.

Independent review iterations 1+2+3 (security + references + code
quality) all converged to zero material findings.
padak added a commit that referenced this pull request May 12, 2026
…281)

Wires the project's quality bar to what an LLM-assisted contributor can
get right on first try. Three layers:

1. CONTRIBUTING.md gains a 'Code Quality Patterns' section: dataclasses
   (not bare tuples) for multi-value returns; categorical arguments
   before variable ones; ErrorCode enum only (raw strings already
   rejected by make check-error-codes); per-layer file-size budgets
   (soft/hard ceilings); context managers over lambdas; named functions
   over assigned anonymous functions; ty type-checker mandatory for new
   code, grandfathered for legacy.

2. ty (Astral) added to dev deps with project config in pyproject.toml.
   Exposed via 'make typecheck' and 'make typecheck-warn'. Pre-commit
   hook runs ty in warning-only mode (mirrors keboola/component-onedrive
   pattern) so the migration of 386 legacy diagnostics does not block
   commits.

3. .claude/settings.json gains a PostToolUse hook on Edit|Write|
   MultiEdit|NotebookEdit that runs scripts/post-edit-quality.sh:
   ruff check --fix + ruff format + ty check on the edited Python file.
   Ruff issues fail the hook (Claude must address before continuing);
   ty issues are reported but do not block (warning-only while legacy
   diagnostics are migrated).

4. CLAUDE.md gains a BINDING 'convention 0' that points every contributor
   -- human or AI -- at CONTRIBUTING.md as the source of truth, and
   names the post-edit hooks explicitly. Numbered as 0 to preserve
   stable references to convention #17 (silent-drift surfaces) in
   plugin docs.

Existing-code audit captured in #280:
- 10 files over hard file-size ceilings (client.py at 3016 LOC the worst)
- 51 tuple[...] returns ripe for dataclass conversion
- 386 ty diagnostics across legacy code
- 0 raw error_code strings (already enforced)
- 0 ruff issues
padak added a commit that referenced this pull request May 12, 2026
…se of #278 + #279) (#282)

* feat(0.31.1): config new --push for one-shot remote create

Extends `kbagent config new` with a `--push` flag that POSTs to the
Storage API in a single CLI call. Without `--push`, today's scaffold-
only behavior is byte-for-byte preserved. Closes the F2 gap in
`kbagent-feature-gaps.md` -- previously the only "FIIA HTTP call ->
kbagent CLI" mapping that lacked a first-class command.

Service: ConfigService.create_config + _validate_config_body helper
  (jsonschema.Draft7Validator against AI Service `configurationSchema`).
  Fail-closed on explicit-body mismatch, graceful skip on missing /
  malformed schema, auto-skip on empty-shell (FIIA pattern).
Command: extend config_new with 8 new flags -- --push, --no-files,
  --description, --configuration JSON|@file|-, --configuration-file PATH,
  --no-validate, --branch ID, --dry-run.
Hint: config.new hint emits create_config snippets only when --push set.
Tests: 35 new (15 service + 20 CLI) + 1 new E2E hop with try/finally
  cleanup; pre-existing TestConfigNew preserved byte-for-byte. Live E2E
  passed against project 1143: dry-run + real create + detail verify +
  update --set patch + delete cleanup.
Docs: 7 sync-map surfaces synced (convention #17); new gotchas entry
  clarifies scaffold-vs-push split + MCP `create_config` Snowflake
  refusal nuance + validation behavior. Snowflake row in
  keboola-expert.md updated to surface --push --no-files as a
  first-choice one-shot path.

Version bump 0.31.0 -> 0.31.1 (patch). plugin.json + marketplace.json
synced via `make version-sync`; uv.lock refreshed via `uv sync`.

* fix(0.31.1): data-app --json emits app_id (renamed from id) for --app-id input/output symmetry

BREAKING (output-only). Every `kbagent --json data-app <subcommand>` envelope
now emits the data-app's own identifier under the key `app_id`. Pre-0.31.1 the
same key was named bare `id`, which did not match the `--app-id` input flag
and surprised AI agents templating `jq -r '.apps[].app_id'`. Affects 12
subcommands: list, detail, create, deploy, start, stop, delete, password,
secrets-set, secrets-list, secrets-get, secrets-remove. `config_id` is
unchanged. The Storage config back-pointer at `parameters.id` (writeup §5;
lives INSIDE the configuration body sent TO Storage, not in kbagent output)
is unchanged.

Pipe-friendly chain enabled:
  kbagent --json data-app list | jq -r '.apps[].app_id' \
    | xargs -I{} kbagent data-app deploy --project P --app-id {}

Migration: rename `.id` -> `.app_id` in jq filters / scripts. No deprecation
alias is emitted; no in-tree consumers found.

Live-validated 2026-05-12 against europe-west3.gcp projects 1143, 2738, 2959.
The more dramatic null-id symptom in the original v0.30.2 report could NOT be
reproduced -- v0.30.6 is byte-identical to v0.30.2 for these files, and the
Data Science API on europe-west3.gcp serves all-camelCase keys. This release
is scoped to the rename only. Reported by @ottomansky.

Structural changes:
- Service: 9 envelopes in services/data_app_service.py rewrite "id" -> "app_id"
- Command: 4 human-formatter sites in commands/data_app.py follow the rename
- Tests: new TestDataAppListOutputKeys + 11-test TestDataAppEnvelopesNoBareIdKey
  regression class in tests/test_data_app_service.py; CLI + secrets-CLI fixture
  renames; E2E list-step round-trip assertion in test_data_app_lifecycle_public
- Docs: (since v0.31.1) gotcha entry, commands-reference data-app callout,
  keboola-expert VERSION GATE Rule 6 entry, changelog
- Version: pyproject 0.31.0 -> 0.31.1, plugin.json + marketplace.json synced

* chore(0.33.0): bump version + PR #278 review fixes + combined release prep

This commit prepares the joint 0.33.0 release that bundles two cherry-picked
contributions from @ottomansky:
  - PR #278 (feat: config new --push for one-shot remote create)
  - PR #279 (fix: data-app --json emits app_id, renamed from id)

Both PRs originally targeted 0.31.1 against a base before #277 (truncate-table)
landed; rebased onto current main (v0.32.0) and renumbered to 0.33.0 minor.

Changes in this commit (on top of the two cherry-picked author commits):

- Version: 0.32.0 -> 0.33.0 in pyproject.toml, propagated via
  make version-sync to plugin.json + marketplace.json; uv.lock refreshed.
- Doc references: all 12 occurrences of "0.31.1" rewritten to "0.33.0"
  across keboola-expert.md (VERSION GATE Rule 6, Tool Selection Matrix,
  inline Snowflake gotcha), gotchas.md ((since v0.33.0) section headers),
  commands-reference.md, scaffold-workflow.md, commands/context.py.
- keboola-expert.md VERSION GATE Rule 6: kept both new entries
  ("storage truncate-table needs 0.32.0+" from #277; "data-app * JSON
  output uses key app_id on 0.33.0+" from #279; "config new --push needs
  0.33.0+" from #278) -- merge-conflict resolution kept both, not either-or.
- gotchas.md: kept both new (since v0.33.0) sections side by side
  (config new --push scaffold-vs-push split + MCP refusal nuance;
  data-app app_id rename rationale + camelCase wire-key note).
- changelog.py: new 0.33.0 entry with 5 bullets covering both features,
  combined test count (47 new tests: 15 service + 20 CLI for #278, 12
  for #279), 7-surface doc sync, and the review fixes below.

Review fixes (addresses PR #278 review B-2 blocker):

- src/keboola_agent_cli/commands/config.py:1334 -- the --push --output-dir
  path called _write_scaffold_to_disk(..., json_mode=False) unconditionally,
  which emitted a plain-text "Written: ..." dim line above the JSON envelope
  when --json was set, silently breaking jq pipes. Now honors
  formatter.json_mode like every sibling call site.
- tests/test_config_create_cli.py::test_push_with_output_dir_scaffolds_and_posts
  now asserts json.loads(result.output) parses cleanly, locking the
  regression behavior down.

Closes (will be closed when this branch merges): #278, #279.

* fix(0.33.0): silent scaffold-write on --push --output-dir (avoids double-JSON)

The previous review-fix attempt for B-2 changed the caller from
`json_mode=False` to `json_mode=formatter.json_mode`. That swap closed
the plain-text leak in human mode but introduced the opposite regression
in JSON mode: `_write_scaffold_to_disk` then emitted its own
`{directory, files_written}` JSON envelope and the push-result envelope
right after, producing two concatenated JSON documents on stdout and
breaking `jq` consumers.

The function's docstring already pointed at the correct design: in
scaffold+push mode, the caller is supposed to suppress the
scaffold-banner side-channel because the push-result envelope is the
authoritative output. The clean primitive for that is an explicit
`silent=True` parameter -- write files, emit nothing.

- `_write_scaffold_to_disk` gets an optional `silent: bool = False`
  parameter. When True, the file write proceeds and the function
  returns before any banner/envelope emission. Default behavior
  (scaffold-only mode caller at line 1345) is unchanged.
- The `--push --output-dir` caller now passes `silent=True`. The
  push-result envelope (single JSON in --json mode; "Created config ..."
  line in human mode) is the only output for this path.
- The regression test
  `test_push_with_output_dir_scaffolds_and_posts` already asserts
  `json.loads(result.output)` parses cleanly -- now green.

Closes the B-2 finding from the PR #278 review.

* fix(0.33.0): PR #282 review follow-ups (dry-run guard, cleanup, test labels)

Addresses three findings from the kbagent-pr-reviewer pass on PR #282:

- NB-1 (`commands/config.py:1338`): the ``--push --output-dir`` scaffold
  write branch had no ``and not dry_run`` clause, so
  ``--push --output-dir --dry-run`` silently created files on disk
  alongside the preview envelope. Dry-run is a preview; it must have
  zero filesystem side effects. New regression test
  ``test_push_output_dir_dry_run_does_not_write_files`` asserts the
  envelope reports ``dry_run: True`` AND no ``*.yml`` / ``*.json`` files
  exist under output_dir after the invocation.
- NIT-1 (same call site, line 1340): ``json_mode=formatter.json_mode``
  was dead code -- when ``silent=True`` the helper returns before
  reading ``json_mode``. Replaced with a sentinel ``json_mode=False`` and
  a comment explaining why the argument is required by the signature
  but ignored by the body.
- NB-2: 9 leftover ``0.31.1`` references in ``tests/test_data_app_service.py``
  (lines 920, 925, 927, 960, 968 -- class comment block and the
  ``TestDataAppListOutputKeys`` docstring) and ``tests/test_e2e.py``
  (lines 440, 1659, 1661, 6525 -- ``_step`` label, method docstring,
  inline comment). Renumbered to ``0.33.0``; the rewrite scope in
  commit 18dbcbb was ``src/`` + ``plugins/`` only, ``tests/`` was
  intentionally out-of-scope at the time, this commit closes that gap.

Deferred (acknowledged but not in this commit):
- NB-3 (no ``keboola.snowflake-transformation`` unit test for ``--push``):
  the claim is correct by design (the service calls
  ``client.create_config`` directly with no Snowflake-specific branch);
  an explicit regression test is nice-to-have but not blocking. Tracked
  as a follow-up.
- NIT-2 (changelog bullet 5 wording on the ``silent=True`` mechanism):
  the bullet is technically correct but undersells the structural fix.
  Cosmetic; not worth re-publishing the changelog for.

``make check`` clean -- 2953 tests pass (one more than before, the
new dry-run guard test).

---------

Co-authored-by: ottomansky <ottomansky.max@gmail.com>
padak added a commit that referenced this pull request May 13, 2026
- CLAUDE.md `## All CLI Commands` was missing `kbagent serve` -- the section
  is hand-maintained per coding convention #17 and CI doesn't catch drift,
  so AI agents on the user's installed kbagent would have no `serve` entry
  in the canonical command reference.
- AGENT_CONTEXT serve entry now documents `--ui` and `--ui-dist` plus the
  cookie-bootstrap single-process flow; the prior text still described the
  old web/backend + web/frontend three-process setup that's no longer the
  recommended path.
- `kbagent http get/post/patch/delete` gain `--hint client|service` support:
  wire should_hint/emit_hint into commands/http_client.py and register the
  hints/definitions/http_client.py module so the existing _render_kbagent_serve_client
  renderer (which generates a raw httpx.Client snippet -- not a KeboolaClient
  one, since there is no Keboola API client here) actually gets reached.
- `kai preflight` and `kai chat-detail` get 8 service-layer tests and 7
  CLI-layer tests covering happy paths, KAI_NOT_ENABLED, KaiError wrapping,
  the legacy-message-skip case, and human/JSON output. Per CONTRIBUTING.md
  every new CLI command needs both a service test and a CliRunner test.

make check: 3087 passed, 7 skipped (+15 vs baseline).
padak added a commit that referenced this pull request May 13, 2026
Three coordinated additions to surface Agent Tasks as a headline
capability (not just a list item buried in "scheduled AI agents"):

1. New ## Agent Tasks H2 section between ## Web UI and ## For AI agents
   -- explains the three action flavours (AI agent / MCP tool / kbagent
   CLI), the self-call subprocess pattern (KBAGENT_SERVE_URL token
   inheritance), persisted JSONL timeline with authoritative Claude 4.x
   pricing, and the Artifacts tab. Positioned right after Web UI because
   that's the primary surface where users discover the feature.

2. New row in the "What it does" table between MCP tools and Workspaces,
   matching the (since 0.40.0) tag for the current release.

3. One additional "What you can ask your agent" example showing the
   chat-driven entry point -- "schedule a weekly Storage Cleanup advisor"
   -- so users see that Agent Tasks aren't just a UI feature, they can
   be triggered from natural-language conversation too.

No other docs touched: Agent Tasks is a web-UI surface, not a CLI command,
so CLAUDE.md ## All CLI Commands and the plugin reference files stay out
of scope (per CLAUDE.md convention #17, which is CLI-specific).
padak added a commit that referenced this pull request May 14, 2026
…283)

* feat: kbagent web UI -- three-tier kernel-UI architecture

Adds a complete web UI over kbagent: Python FastAPI server (`kbagent serve`),
Node.js BFF (web/backend), and React SPA (web/frontend). Each tier is a
different language so the boundaries are HTTP/JSON only -- the React UI
calls the BFF, the BFF proxies to the kbagent kernel, the kernel runs the
existing Python services unchanged.

Python kernel (`kbagent serve`):
- New optional dep `[server]` (fastapi, uvicorn, sse-starlette)
- 19 routers wrapping every kbagent service area: projects, configs,
  components, storage, jobs (+ SSE log stream), branches, workspaces
  (+ SQL query), flows, schedules, lineage, sharing, data-apps, mcp,
  kai, encrypt, search, org, members
- Bearer-token auth (random secret printed at startup)
- X-Manage-Token header forwarded per-request, never persisted
- 141 endpoints, OpenAPI 3.1 schema served at /openapi.json

Node BFF (`web/backend`):
- Fastify + undici + TypeScript
- /api/* proxies to kbagent serve with bearer injection
- SSE pass-through for streaming endpoints
- Optional static frontend serving in production

React UI (`web/frontend`):
- Vite + React 18 + TanStack Query + Tailwind v3 + Monaco + Mermaid
- 20 pages, one per command group; dark NERD theme with neon accents
- Project + branch switcher in top bar (state-driven nav)
- Manage-token modal for write ops requiring KBC manage token
- SQL workspace with Monaco editor + CSV result rendering
- Mermaid lineage diagram + edge table
- Doctor health checks, changelog viewer, MCP tool browser

Tests + docs:
- tests/test_server_smoke.py (7 tests, all green) -- builds the FastAPI
  app against an empty config dir and exercises auth + OpenAPI wiring
- web/README.md with quick-start (3-terminal dev, prod single-process)
- Makefile targets: install-server, web-install, web-dev-backend,
  web-dev-frontend, web-build, web-clean
- AGENT_CONTEXT lists `kbagent serve` so the CLI reference stays in sync
- permissions.py registers `serve` (admin) so the registry test passes

Verified end-to-end against /tmp/kbagent/.kbagent (6 real Keboola
projects): Projects page lists all 6 with status, Storage shows buckets,
Doctor reports 12/13 checks pass, Lineage renders Mermaid graph + table,
Configs lists keboola.sandboxes config. Full test suite: 2960 passed.

* fix(web): project switcher z-index + storage files default int params

Two UI bugs found on first user pass:

1. Project / branch switcher dropdowns hidden behind page content.
   `<main>`'s `overflow-auto` creates a separate stacking context, so
   z-30 in the TopBar dropdown was below tables in the scrolled area.
   Lifted the header to z-40 and dropdowns to z-50 so they stack
   above any page content.

2. Storage Files endpoint returned 400 "offset: Value '' cannot be
   safely casted to int". The router declared `limit/offset` as
   Optional[int], so FastAPI happily passed None into the service,
   which the underlying httpx client serialized as `?offset=` --
   empty string -> 400. Service signature requires `int` with
   defaults (limit=20, offset=0); match that in the router too.
   Also fixed `upload_file` kwarg: `permanent` -> `is_permanent`
   to match StorageService.upload_file.

Verified both fixes in the browser against /tmp/kbagent/.kbagent --
dropdowns render above the Storage page table, and Files tab on the
padak project lists 10 files (notebooks, docs, anomalies, backups).

* feat(web): one-cmd start, drawer UX, agent scheduler, AI-agent actions

User feedback iteration: turn the experiment into something usable.

== Developer experience ==
- New `scripts/web-dev.sh` (+ `make web-dev`) spins up all three
  processes -- kbagent serve, Node BFF, Vite -- in ONE terminal with
  prefixed output and shared bearer token. Ctrl+C kills the lot.

== UI fixes ==
- MCP Tools: clicking a tool opens a Drawer instead of an inline
  panel below the scrolled grid (was off-screen). Pre-fills the
  required-param skeleton from inputSchema.
- Storage table preview/detail: Drawer with Info / Schema / Preview
  / Raw tabs. Preview hits the synchronous data-preview endpoint
  instead of the async export flow (which 404'd on big tables).
- Storage router: split `/tables/{p}/{id:path}` into three explicit
  paths -- table-detail / table-preview / table-download -- because
  the `:path` matcher was greedy and ate `/preview` and `/download`
  into the table_id, routing every preview to the detail endpoint.
- Lineage page: dropped duplicit "Sharing" sidebar entry (Sharing
  graph lives inside Lineage as a tab). Added a "Build deep lineage"
  form that wraps `kbagent sync pull` + `kbagent lineage build`,
  auto-loads the resulting JSON, exposes upstream/downstream walks.

== Agent scheduler (new) ==
- `kbagent serve` spawns an asyncio scheduler in its lifespan.
  Tasks in `<config_dir>/agents.json` (0600), runs in
  `<config_dir>/agent_runs/<task_id>.jsonl`. croniter parses cron;
  the loop ticks once a minute.
- Three action types:
  - `mcp_tool`: call an MCP server tool.
  - `cli_command`: spawn `kbagent <argv>` subprocess.
  - `ai_agent` (NEW): spawn `claude -p`, `codex exec`, or `gemini -p`
    with a free-text prompt. Captures the full AI response. Without
    this it would be just a cron caller, not an agent.
- Endpoints: GET/POST/PATCH/DELETE /agents, POST /agents/{id}/run,
  GET /agents/{id}/runs, GET /agents/cron/preview.
- React /agents page: enabled-pulse indicator, next-run countdown,
  run-now button, history drawer that renders AI response/stdout
  inline (collapsed JSON below).
- pyproject `[server]` extra picks up `croniter>=2.0`.

== Tests ==
- `tests/test_server_smoke.py` still 7/7 green.

Verified end-to-end in browser against /tmp/kbagent/.kbagent:
projects/storage/lineage/agents pages work; MCP drawer opens above
the grid; table preview returns 2 rows from out.c-disabling.ranked
synchronously; agent task running `kbagent doctor` captured stdout
(exit 0); cron preview shows next 3 firings.

* fix(web): mermaid lifecycle, build auto-mkdir, workspace prompt, agent test

User reported four blockers + asked what I learned from clicking through
the real Keboola UI. Honest answer to the last one: nothing -- I cannot
sign in to connection.keboola.com without credentials, so this iteration
is still based on architecture knowledge, not actual UI inspection.

== Sharing graph mermaid disappears ==
- Render id was stable across re-renders, which collided in dev
  StrictMode (first effect inserts SVG, second pass fails to mount).
  Switched to `mmd_${Date.now()}_${seq}` so each render gets a fresh id;
  added cleanup that removes the off-DOM measuring sibling mermaid
  leaves behind; added a "cancelled" guard so stale promises don't
  overwrite the SVG when the project switcher refetches edges.

== Build button does nothing ==
- Endpoint required the working directory to exist and 404'd if not --
  user-hostile because the dir is empty before the first sync pull
  anyway. Now mkdir(parents=True, exist_ok=True) on both directory and
  output.parent.
- React: build mutation now resets prior errors and trusts the
  server-returned `output_path` (server resolves the absolute path)
  instead of recomputing it client-side.
- Added a visible "in flight" indicator so users see something is
  happening during the multi-minute build.

== Workspace creation: no prompt + SHOW TABLES errors ==
- New workspace button now opens a Drawer with: name input
  (defaults to `kbagent-<project>`), read-only toggle, ui_mode toggle.
  No more silent creation under a generated name.
- SQL editor lives in a Drawer (matches the table-detail / MCP-tool
  drawer pattern) and detects the common "I tried SHOW TABLES" 422.
  Surfaces a backend-aware hint pointing to INFORMATION_SCHEMA --
  Snowflake / BigQuery have different paths, the hint matches the
  workspace's backend.

== Agent task: no way to test before saving ==
- New `POST /agents/test` endpoint runs the action via the SAME
  run_task_once code path the scheduler uses, but writes nothing to
  agents.json or run history (uses an in-memory _NullStore).
- React: NewTaskDrawer has a "Test now" button alongside Create. Shows
  an elapsed-seconds counter while running (AI agents take 30-120s);
  when finished, renders the response inline (AI), stdout+stderr (CLI),
  or full JSON (MCP) depending on the action type.

Lint + smoke tests still green. Touched ~480 lines.

* feat(web): inspired-by-Keboola-UI overhaul (Dashboard, drawer, cards, AI)

After actually clicking through the real Keboola UI at
connection.keboola.com (signed in via the user's session, not me),
borrow the patterns that earned their wages.

== New Dashboard page (was: redirect to Projects) ==
- Greeting header ("Good Morning, Petr") + sub-line that reflects the
  selected project.
- Big Kai chat input as the hero -- mimics Keboola's dashboard "Ask Kai
  anything about your project..." input. Wired to /kai/chat.
- 4 stat tiles: Projects connected (X/N), Agent tasks (X active of N
  total), Doctor (color-coded by failed/warning), Recent jobs.
- Scheduled agents card with "NEXT FIRING" panel + cron list.
- Suggested next steps card with state-aware items (offers "Add a
  project" if 0 projects, "Schedule an AI agent" if 0 tasks, etc.).
- Recent jobs in <project> -- direct link to /jobs.
- Set as default page; sidebar gets a new "Home" section with
  Dashboard at the top.

== Job detail: 8 cards + parameters + mapping (was: JSON dump) ==
Full rebuild of JobDetail to match Keboola UI's job page:
- 8 stat cards in a grid: Status (with "open in Keboola UI →" link),
  Duration (big number), Times (Created/Started/Ended), Created By,
  Configuration (Component / Config ID / Branch), Backend, Run IDs,
  Project.
- Parameters & Mapping (Input + Output tables) side-by-side cards.
- Live log tail (SSE) preserved; raw JSON moved into a collapsed
  details element.
- Refactored from inline panel to Drawer (consistent with MCP /
  Storage / Workspaces).

== Workspace detail: info page + actions sidebar (was: SQL editor) ==
- Click on a workspace now opens an info Drawer with: description
  card (with placeholder "Generate" sparkle button), parameters
  card (8 fields), credentials card with "Reset & show password".
- Right-side actions sidebar mirrors Keboola UI: Open SQL Editor
  (the big primary action), Load tables, Browse storage, Enable
  sharing, Run AI agent here, Delete workspace.
- Open SQL Editor opens a SECOND nested Drawer (full-height) with
  the actual editor + Storage Explorer sidebar (see below).

== SQL Editor: Storage Explorer sidebar (was: just editor) ==
- Left sidebar lists every bucket in the workspace's project as a
  clickable tree. Expand a bucket → list of tables. Click a table
  to insert its quoted name at the SQL cursor.
- Backend-aware quoting: BigQuery uses `dataset`.`table`, Snowflake
  uses "TABLE_NAME". Mirrors the Keboola UI Storage Explorer that
  feeds Working Tables.

== Storage table preview: per-column filter + sortable headers ==
- Each column gets a filter input below the header (substring match,
  case-insensitive, ANDed across columns).
- Click a header to sort; second click reverses; numeric columns
  sort numerically.
- "X of N row(s)" indicator + "clear filters + sort" link.
- Sticky header for big tables.

== Components page: AI-style search bar ==
- Hero search input with Sparkle icon and rotating placeholder
  ("I need to download data from marketing campaigns", "I want to
  write data to BigQuery", ...). Click a hint to fill the query.
- Type filter as button row (All / Data Source / Data Destination /
  Application / Transformation).
- Components rendered as 3-col tile grid (was: table). Each tile
  shows name + type pill + component_id (mono) + 3-line clamp
  description. Click → Drawer with component detail.

== Flow page: visual builder (was: JSON dump) ==
- New Mermaid `graph TD` rendering of the phase DAG. Each phase is a
  node showing name + task count. Edges from dependsOn (or implicit
  declaration order when no deps).
- Below the diagram, per-phase task list: component / config / name.
- Builder | Raw JSON tabs in the Drawer header.
- Read-only for now (drag-and-drop edit needs a layout engine).

== Branch indicator: prominent dev-branch badge ==
- Branch picker turns neon-amber and shows a "DEV" pill when a non-
  default branch is selected (vs subtle "prod" tag for main). Hard
  to miss that you're on a dev branch.

== Two-path empty state ==
- New `TwoPathEmpty` component renders two side-by-side option
  tiles -- mimics Keboola's empty Flows page that offers "Build
  it yourself" vs "Try Keboola MCP Server".
- Used on /agents (CLI vs AI agent paths). Workspaces import
  prepared for follow-up.

== Honest note ==
The user signed me in to Keboola UI in a shared Chrome tab. Without
that I would have continued shipping UI based on architecture knowledge
instead of actual UX research -- which is what produced the previous
two iterations' problems (single Empty CTA, JSON-dump detail panels,
SQL editor that doesn't surface storage).

TS clean, lint clean, smoke tests 7/7 still green.

* fix(web): Lineage Build button -- default value + stale-closure auto-load

Two bugs that combined into "build does nothing":

1. Empty default for the working-directory input. The placeholder text
   ("/tmp/kbagent-lineage") looked identical to a real value, so the
   user thought they had typed it. The button was actually disabled
   because `buildDir` was empty -- but the disabled state was visually
   subtle, so the click looked silent. Pre-fill the default so the
   button is enabled out of the box, and add an explicit yellow warning
   plus visible disabled-button styling (opacity + not-allowed cursor +
   title="Working directory required") for the case when the user
   clears the field.

2. Stale closure on the post-build auto-load. After a successful build
   the onSuccess handler called `setPath(resolved)` and immediately
   `loadMu.mutate()`. React batches state updates, so loadMu's
   mutationFn read the *previous* (empty) `path` through closure and
   sent `GET /lineage/info?load=` -> 500. Switch loadMu to take the
   path as a mutate() argument so the resolved path is passed
   explicitly; standalone Load button passes `undefined` to fall back
   to current state.

Verified end-to-end against /tmp/kbagent/.kbagent: Build now reports
"Build complete -- 133 tables, 122 configurations, 30 edges", auto-load
fires GET /lineage/info?load=%2Fprivate%2Ftmp%2Fkbagent-lineage%2F
lineage.json (200), Graph summary panel renders below.

* feat(web): embed `kbagent lineage server` HTML browser instead of re-impl

The Lineage page had a hand-rolled "Walk the graph" form that returned
JSON. Meanwhile `kbagent lineage server` already shipped a full
interactive browser (sidebar tree per project, search, upstream /
downstream walk with Mermaid + ER views + columns toggle). Reuse it
instead of duplicating renderer logic in React.

== New endpoints (server/routers/lineage.py) ==
- GET /lineage/browser?load=PATH -- serves the existing
  `_LINEAGE_HTML_TEMPLATE` from commands/lineage.py. Rewrites the
  hard-coded `/data.json`, `/api/query`, `/api/mermaid` fetch URLs to
  point at our `/api/lineage/...` paths so the same HTML works inside
  an iframe under the BFF.
- GET /lineage/data?load=PATH -- raw JSON for the browser's bootstrap.
- GET /lineage/walk?load=&node=&direction=&depth= -- mirror of
  /api/query (but GET, no body) for the in-browser node walk.
- GET /lineage/mermaid?load=&node=&direction=&depth=&view=&columns= --
  mirror of /api/mermaid; returns text/plain with Mermaid code.

Wire reuses DeepLineageService.query_upstream/downstream/render_mermaid/
render_er_diagram -- zero duplicated rendering logic.

== React Lineage page (web/frontend/src/pages/Lineage.tsx) ==
- After info loads, the panel that used to show "Walk the graph" form
  + "Walk result" JsonView now renders an iframe pointing at
  `/api/lineage/browser?load=...`. 70vh tall, white background to match
  the browser's light theme. "open in new tab" link in the header for
  full-window use.
- Old "Graph summary" card collapsed to a `<details>` (still useful
  for verifying a fresh build, but no longer the main attraction).
- Headless "Walk the graph" form preserved in a collapsed `<details>`
  for scripting use (e.g. AI agents that want JSON, not pixels).

Verified: build → 200, browser iframe loads, sidebar shows projects/
tables/configs, mermaid + ER + columns work because we are literally
serving the same HTML the CLI command does.

Tests + lint clean.

* fix(web): swallow AbortError in Run live / Test now SSE handlers

User pressed "Run live" on a freshly-created agent task and saw:

  Error: signal is aborted without reason

That string is DOMException.message when a fetch() with an
AbortController gets cancelled (Chrome 120+ doesn't ship a reason).
Tracing it back: React StrictMode mounts every component twice in
dev, with the cleanup of the FIRST mount firing in between. The
RunLiveDrawer has an unconditional `useEffect(() => () => abort(), [])`
to kill the SSE on drawer close -- that cleanup fires during the
StrictMode interlude, aborting the in-flight fetch before the user
has done anything. The fetch promise rejects, the catch sets
liveError, the user sees a misleading red box.

Same pattern in the NewTaskDrawer's "Test now" stream.

The kill-on-unmount intent is correct -- we DO want to terminate the
spawned process when the drawer closes. The mistake is treating an
expected, user-triggered abort (cancelTest / cancelLive / drawer
close / StrictMode cleanup) as an error to display.

Fix: a small `isAbortError(err)` helper at the top of Agents.tsx
matches by DOMException.name and by message substring (Chrome's
"signal is aborted without reason" sometimes lacks the name). Both
handle.done.catch sites now swallow it instead of calling
setError/setLiveError.

Verified end-to-end:
- Failed Jobs Triage task: previous runs (176s + 217s) reappeared as
  status=ok with full Claude transcript captured (AI response in
  Czech, complete with the "★ Insight" prefix from the injected
  prompt header).
- No red error box on drawer open.
- Cancel button still works (handle.abort -> stream ends -> finally
  block clears state).

* feat(web): agent run timeline persistence + 3-panel UI + light theme

Why
---
The Agent Tasks detail panel was a single mono-font dump that mixed
thinking, tool calls, stdout/stderr, and final response into one
unreadable scroll. Worse, only the final response was persisted: the
entire timeline (per-tool-call inputs/outputs, thinking, token usage)
was lost the moment the drawer closed. Operators could not tell what
an AI agent had actually done in a past run, nor how much each run
cost.

Backend (per-run timeline persistence + cost calc)
--------------------------------------------------
- New `server/pricing.py` module: per-model rates (Opus / Sonnet /
  Haiku 4.x), `compute_cost()`, `build_run_summary()` aggregates
  tokens, computes cost (preferring claude's authoritative
  `total_cost_usd`), counts per-tool calls and errors.
- `agents_store.py`: AgentRun gains `summary` + `events_path`. New
  `append_events()` / `load_events()` / `get_run()` persist the full
  JSONL stream to `agent_runs/<task_id>/<run_id>.jsonl` (0600).
- `run_broadcaster.py` and `agent_runner.py` (cron path) both persist
  the timeline + summary on completion. Unifies UI-driven and
  scheduler-driven runs into one persisted shape.
- `routers/agents.py`: new `GET /agents/{task_id}/runs/{run_id}` and
  `GET .../events` endpoints power detail-drawer replay.
- 47 new tests, full suite (3014) passes, ruff clean.

Frontend (3-panel UI + light/dark theme)
----------------------------------------
- New `components/AgentRunView.tsx`: 3-panel layout
    Steps timeline | Step detail | Cost / Tokens / Tools card
  Distills claude stream-json into UI-level steps (thinking, tool
  call, text, result), pairs tool_use with tool_result, summarises
  inputs ("kbagent http get /configs/...", file_path, etc.). Optional
  AgentRunRaw collapsed JSONL fallback for power users.
- Live-tail UX (Slack/terminal pattern): clicking the latest step
  keeps auto-follow alive; clicking a historical step pins the view
  and surfaces a "Live tail +N" affordance to re-attach.
- New `theme.tsx` ThemeProvider + `useTheme()`, anti-FOUC bootstrap
  in index.html, theme toggle in TopBar (preserves to localStorage,
  honours OS preference on first visit).
- Refactored every page (Dashboard, Workspaces, Storage, Jobs,
  Flows, Lineage, MCP, Kai, Components, Doctor, Changelog, Projects)
  for proper light/dark contrast.
- Mermaid diagrams: dynamic palette (light: white bg + zinc text +
  keboola-green border + cyan-600 lines; dark: original neon).
- Top bar: `connected to BFF` -> `kbagent serve` (BFF was an
  internal acronym for the Node proxy, surfaced needlessly to users).
- RunItem in Agents.tsx shows inline summary chips ($, tokens, tool
  count) on collapsed rows; expanded view replays the persisted
  timeline through AgentRunView.
- Kai description corrected: it requires the 'AI Agent Chat' feature
  flag on the project's Storage API token (not a manage token, as a
  prior assumption suggested).

* feat(serve): `kbagent serve --ui` mounts the React SPA single-process

Why
---
After the timeline-persistence + 3-panel UI work, end-users wanting to
try the web UI still had to spawn three processes (kbagent serve, Node
BFF, Vite dev server) and manually paste a bearer token between them.
For "just give me the dashboard" deployments that's friction the
project doesn't need: the FastAPI app already speaks every endpoint
the SPA calls, and uvicorn already serves arbitrary static files.

What
----
- New `--ui` / `--ui-dist` flags on `kbagent serve`. When enabled, the
  same uvicorn process additionally:
  1) accepts `/api/<path>` as an alias for the bare `<path>` (ASGI
     path-rewrite middleware) so the SPA's existing `/api/*` calls
     reach the routers unchanged,
  2) intercepts `GET /` + `GET /index.html` to inject
     `<script>window.__KBAGENT_TOKEN = "..."</script>` so the browser
     boots already authenticated -- no paste step, no Node BFF,
  3) mounts the built React `dist/` at `/` with `html=True` so SPA
     client-side routes (`/workspaces`, `/agents/123`, ...) fall
     through to `index.html` on direct navigation.
- Auth middleware: SPA shell (`/`, `/index.html`, `/assets/*`,
  favicons, SPA route URLs) is bypassed via a tight allow-list set on
  `app.state.is_ui_public`. Plus `?_kbagent_token=...` is now accepted
  as an Authorization fallback, because `EventSource` (used by the Jobs
  page) cannot send custom request headers.
- Frontend `api/client.ts`: reads the injected token at module load
  time and attaches it as `Authorization: Bearer <token>` on every
  fetch. SSE flows do the same; `EventSource` falls back to the query
  param. In BFF mode the token isn't present so behaviour is unchanged.
- Auto-discovery: `--ui` looks for `web/frontend/dist` next to the
  installed package, then in `$KBAGENT_UI_DIST`, then `$CWD/web/...`,
  and exits non-zero with a "run `make web-build`" hint if none found.
- Building your own client? `kbagent serve` (without `--ui`) is the
  API-only flavour. OpenAPI lives at `/openapi.json`, auth is
  `Authorization: Bearer <token>`, and `kbagent http` is a thin shell
  wrapper. README spells this out so users know `--ui` is opt-in
  batteries-included on top of an unchanged API surface.

Tests
-----
- 10 new tests in `test_serve_ui.py`: token injection, public-bypass
  for SPA shell, `/api/*` alias only fires when `--ui` is on,
  EventSource query-param auth, missing-dist warns + skips, etc.
- Full suite: 3024 passed (was 3014).
- End-to-end smoke: `kbagent serve --ui --port 8002`, opened the
  browser, all dashboard tiles loaded against the real config dir
  without a separate BFF.

* feat(build): bundle React SPA into wheel via hatchling build hook

Why
---
The previous commit added ``kbagent serve --ui``, but the standard
end-user install path is ``uv tool install git+https://...`` -- which
clones the repo, builds a wheel, installs it, then deletes the clone.
That meant users got the CLI but no SPA on disk: ``--ui`` failed with
"no built React dist/ found" until they re-cloned and ran the dev
workflow. Not acceptable for a "one command and you have the UI"
story.

What
----
Wheel now ships the built SPA inside the package itself.

- ``hatch_build.py`` -- new custom hatchling build hook
  (``[tool.hatch.build.targets.wheel.hooks.custom]``). Before wheel
  collection it:
  1) checks for a prebuilt ``web/frontend/dist`` (maintainer ran
     ``make web-build`` first);
  2) if missing AND ``npm`` is on PATH, runs ``npm ci && npm run
     build`` in ``web/frontend`` to produce it;
  3) copies the result into ``src/keboola_agent_cli/_ui_dist/`` so
     hatchling's normal collection picks it up.

  If neither prebuilt dist nor npm is available, logs a warning and
  proceeds without the UI -- the CLI still works; only ``--ui`` will
  fail with a refreshed error message pointing at install
  instructions.

- ``[tool.hatch.build.targets.wheel.force-include]`` overrides the
  default ``.gitignore`` exclusion for ``_ui_dist/`` so the generated
  asset tree actually lands in the wheel. Without this, hatchling
  silently respects ``.gitignore`` and the wheel comes out empty of
  UI files.

- ``[tool.hatch.build.targets.sdist]`` explicitly lists what goes in
  the sdist (``src/``, ``tests/``, ``web/frontend/`` source, top-level
  docs / build files). Excludes ``node_modules`` and prior build
  artifacts so the sdist stays small and reproducible -- the hook
  then rebuilds the SPA on each wheel build.

- ``commands/serve.py`` discovery: ``_autodetect_ui_dist`` now checks
  the installed-package location
  (``Path(__file__).parent.parent / "_ui_dist"``) *first* (after
  ``$KBAGENT_UI_DIST``), then the repo paths. So a wheel-installed
  ``kbagent`` finds its bundled SPA without falling through to the
  source tree. Error message refreshed to enumerate every searched
  path and the install method that produced each.

- ``.gitignore`` adds ``src/keboola_agent_cli/_ui_dist/`` next to the
  existing ``web/frontend/dist/`` entry.

- README + web/README updated: the canonical install command is now
  ``uv tool install --with 'keboola-agent-cli[server]'
  git+https://...`` followed by ``kbagent serve --ui``. Node 20+ on
  the install host is documented as a soft requirement (the build
  hook needs it; runtime does not).

Verification
------------
- ``uv build --wheel`` -> 246-file 18MB wheel containing
  ``keboola_agent_cli/_ui_dist/index.html`` and all assets.
- Clean ``python -m venv /tmp/v && /tmp/v/bin/pip install
  ./dist/*.whl[server]`` -> ``kbagent serve --ui --port 8003``
  serves the SPA with a fresh injected token; ``curl /health/ping``
  with the bearer header returns the expected envelope.
- Tests: 3024 passed (no regressions in the existing ui-bypass /
  api-alias / token-fallback suite).

* chore(0.40.0): bump version + extract HttpForwarderService + fix version tags

PR #283 review (kbagent-pr-reviewer) flagged two BLOCKING issues:

[B-1] gotchas.md:3 + keboola-expert.md:149 tagged kbagent http as
  '(since v0.33.x)' / '(0.33.x+)'. Command introduced in this PR (0.40.0),
  not 0.33.x -- AI agents on 0.33.0 hit 'No such command' with no
  VERSION GATE refusal path. Fix: corrected both tags + extended Rule 6
  VERSION GATE block with kbagent http, kbagent serve --ui, and
  AI-agent run timeline persistence (all 0.40.0+).

[B-2] commands/http_client.py imported httpx directly. CONTRIBUTING.md
  3-Layer architecture forbids httpx in commands/. Fix: extracted
  services/http_forwarder_service.py (HttpForwarderService +
  ForwarderError + ForwardedResponse). commands/http_client.py becomes
  a thin Typer wrapper. Service registered in cli.py as
  ctx.obj['http_forwarder_service']. 16 new service-layer tests; the
  existing 7 CLI tests pass byte-for-byte (refactor is transparent).

Version bump: 0.33.0 -> 0.40.0. The previously-deferred 0.34.0 changelog
block (kbagent http alone) folded into a comprehensive 0.40.0 block
covering everything PR #283 ships -- timeline persistence + cost
summary + pricing module, single-process serve --ui, wheel bundling
via hatch hook, 3-panel agent-run UI + light/dark theme + live-tail UX,
BFF -> 'kbagent serve' relabel, Kai description fix, HttpForwarderService
extraction. make version-sync propagated to plugin.json + marketplace.json.

Verification: make check -- 3041 passed, ruff clean, version-check OK,
changelog-check OK, error-codes enum check OK.

* feat(web): AI prompt helper for scheduled agent task creation

Adds a collapsible "Help me write this prompt" panel to the NewTaskDrawer
on the Agents page. The user describes the goal in plain English; the
selected AI CLI (claude / codex / gemini) is invoked via a new SSE
endpoint to rewrite the goal (and any existing draft) into a polished
prompt anchored in real kbagent commands. Result is streamed live and
applied to the Prompt textarea via Use this / Discard controls.

- New endpoint: POST /agents/prompt/improve/stream (reuses
  stream_ai_agent_events so it inherits the existing JSONL parsing,
  timeouts, and SSE shape).
- New pure-text helpers in agent_runner.py:
  - build_prompt_helper_meta_prompt() composes a meta-prompt with an
    OUTPUT CONTRACT and concrete kbagent command examples so the AI
    recommends real commands rather than inventing API calls.
  - clean_prompt_helper_response() trims code fences, "Here is the
    prompt:" preambles, and the exact-double-body artifact produced
    by claude's stream-json (assistant turn text duplicated in the
    final result event).
- New PromptHelperPanel React component renders inside NewTaskDrawer
  for action_type == ai_agent. Uses the same CLI the user picked for
  the task, streams progress via ssePost(), and aborts cleanly on
  cancel / drawer close.
- 20 unit + integration tests in tests/test_agent_prompt_helper.py.

* feat(kai): preflight + chat-detail commands (3-layer compliant)

Adds two new CLI commands that surface Kai readiness and chat transcripts
without raising on the agent-chat feature flag:

  kbagent kai preflight [--project NAME]
    Inspect token without raising. Returns {ok, is_master_token,
    has_agent_chat_feature, token_description, error}. Backs the UI
    preflight banner so it can show 'use the master "owner" token' as a
    cohesive warning instead of letting /ping cascade KAI_NOT_ENABLED.

  kbagent kai chat-detail --chat-id ID [--project NAME]
    Fetch the full transcript of one chat (flat [{role, content,
    created_at}] list; tool calls and other non-text parts skipped).
    Backs the UI conversation-history sidebar that restores a chat when
    the user navigates away and back.

Plus a clearer KAI_NOT_ENABLED message that explicitly names the
master-token requirement, since custom Storage API tokens fail silently
otherwise.

Wired through every silent-drift surface in CONTRIBUTING.md:
  - permissions.py registry entries
  - hints/definitions/kai.py with ClientCall + ServiceCall for both
  - AGENT_CONTEXT (kbagent context) Kai section
  - CLAUDE.md ## All CLI Commands
  - plugins/.../commands-reference.md preamble + entries
  - plugins/.../SKILL.md table (regenerated via make skill-gen)

Also syncs uv.lock to the current 0.40.0 from the prior version-bump
commit which did not regenerate it.

* feat(web): Kai chat UI — markdown, history sidebar, preflight banner

Rebuilds the Kai page in five connected ways. Each one was a paper cut
visible in the screenshots; together they take the page from a raw text
dump to a usable assistant view.

1. Markdown rendering via react-markdown + remark-gfm. Kai routinely
   returns tables, fenced code, links, and headings — previously rendered
   as raw '**bold**' / pipe-delimited text. GFM unlocks tables and task
   lists; no raw HTML allowed (react-markdown default) so injection-safe.
   Tailwind-styled table / code / link / heading components matching the
   existing nerd-* design tokens.

2. icon:NAME pre-processor. Kai uses Keboola Connection's Font Awesome
   shorthand ('icon:database Storage', 'icon:circle-exclamation') that
   our UI doesn't ship. Normalize to leading emoji for the common ones
   (database, lightbulb, hand-pointer, etc.); drop unknowns. Cheaper
   than mapping to Lucide and visually consistent with Kai's own emoji.

3. History sidebar. Calls /api/kai/history on mount and lists recent
   chats; clicking one loads the transcript via /api/kai/chat/{id}.
   Active chat is highlighted; 'new' / 'refresh' buttons in the header.
   The active chat ID is persisted to localStorage keyed on project
   alias, so navigating away and back restores the conversation —
   previously it vanished on every page change.

4. Preflight banner. Calls /api/kai/preflight (also new) to detect the
   two failure modes before /ping cascades. When the configured token
   isn't the master 'owner' token or the project lacks the AI Agent
   Chat feature, render a red banner spelling out *which* condition
   failed, with the load-bearing nouns ('master', 'owner', 'AI Agent
   Chat') bold + red so the user can scan it in 2 seconds. /ping and
   /history queries are gated on preflight.ok so they don't fire
   redundant error toasts on top of the banner.

5. Optimistic submit. The previous flow pushed the user message to the
   transcript only in onSuccess — meaning the submit looked frozen for
   the 5-15 seconds Kai takes to respond, with the user's text still
   sitting in the input. Now: snapshot input on submit, push both the
   user bubble AND a pulsing 'Kai is thinking…' placeholder, clear the
   input. onSuccess/onError replace the placeholder. The mutation
   payload is plumbed explicitly so a later setInput doesn't leak into
   the request.

No business logic in the frontend — all four new endpoints
(/preflight, /chat/{id}, plus the existing /history, /chat) are thin
proxies in the FastAPI router over KaiService methods that are also
callable as `kbagent kai ...` commands.

* fix(web): Drawer overlay was too transparent in light mode

The Drawer's left-side overlay was `bg-black/60` on a transparent flex
child. In light mode that 60%-opacity black gets diluted further when:

- TopBar (`bg-white/80 backdrop-blur`) sits over its top edge and
  re-blurs whatever is behind, including the drawer overlay
- Sidebar (`bg-white/80 backdrop-blur`) does the same on the left edge

The net effect: page content (notably the per-row action buttons on
the Agents table — play/edit/pause/delete) and the "+ New task"
trigger bled through behind the drawer, making the modality unclear.

Move the solid color onto the container itself
(`bg-zinc-900/70 dark:bg-black/75 backdrop-blur-sm`) and keep the
click-catcher flex child transparent. The panel's `bg-white` /
`dark:bg-zinc-950` stays opaque, so only the click-out region shows
the (now stronger) tint.

* fix(web): render Drawer through a portal + bump overlay opacity to 90%

Two compounding bugs on the Agents → New task drawer:

1. The Drawer was rendered as a child of `<div className="space-y-4">`
   on the page. Tailwind's `space-y-4` applies `margin-top: 1rem` to
   every non-first child — including fixed-position descendants. The
   computed style on the drawer container was
   `margin: 16px 0px 0px`, so the drawer started 16px below the top
   of the viewport. Modality leaked through that strip.

2. Even with the previous fix raising the click-catcher tint to
   `bg-zinc-900/70`, 30% transparency was enough that page content
   underneath (row action buttons, table text) remained legibly
   visible. The user reported page content "bleeding through" the
   drawer regardless.

Fix:

- Render the drawer through `createPortal(..., document.body)` so it
  escapes the page tree entirely (no `space-y-4` margin, no ancestor
  stacking-context surprises).
- Tighten the overlay to `bg-zinc-950/90` — opaque enough that the
  page is suppressed but the user still gets a hint of context behind
  the drawer (helps reorient on close). Same value in light and dark
  modes; zinc-950 stays legible on both.

Verified via Claude in Chrome:
- `getBoundingClientRect` on the overlay: `top:0, height:1042`
  (full viewport, was `top:16, height:1026`)
- `getComputedStyle.margin`: `0px` (was `16px 0px 0px`)
- `parentElement`: BODY (was a `space-y-4` div inside Agents page)
- `getComputedStyle.backgroundColor`: `rgba(9, 9, 11, 0.9)`

* fix(auth): cookie-based SPA auth -- token never enters URL, JS heap, or access log

Why
---
PR #283 review (round 2) flagged [B-2]: the prior `?_kbagent_token=...`
EventSource fallback put the bearer token into uvicorn's access log
because ``get_path_with_query_string`` includes query strings in the
default access log format. The companion ``window.__KBAGENT_TOKEN``
script injection put the same token into the page's JS heap where
any XSS could exfiltrate it.

Both attack surfaces are eliminated by switching the SPA's auth path
to a HttpOnly session cookie. The token now lives only in the
``Cookie:`` request header, which is not part of the access log
format and not readable by JS (HttpOnly).

What
----
Server side:

- ``server/__init__._install_ui`` no longer injects
  ``window.__KBAGENT_TOKEN`` into ``index.html``. ``GET /`` and
  ``GET /index.html`` now return the unmodified SPA shell with a
  ``Set-Cookie: kbagent_session=<token>; HttpOnly; SameSite=Strict;
  Path=/`` header. ``Secure`` flag intentionally omitted so http://
  localhost dev installs work; operators behind TLS termination layer
  it via the proxy. Session cookie (no ``max_age``) -- cleared when the
  browser tab closes.
- ``server/auth.py`` middleware: replaced the legacy
  ``?_kbagent_token=...`` query fallback with a cookie fallback that
  reads ``request.cookies["kbagent_session"]`` only when no
  Authorization header was sent. Scripted callers (``kbagent http``,
  curl, BFF proxy) keep using the header path, so the token never
  enters URLs or access logs by either route.
- The legacy query parameter is now explicitly REJECTED -- a request
  carrying ``?_kbagent_token=...`` and nothing else returns 401.
  Verified by ``test_query_param_no_longer_accepted``.
- ``_js_string_literal`` helper deleted (no JS literal injection
  anymore).

Frontend side:

- ``web/frontend/src/api/client.ts``: removed the
  ``window.__KBAGENT_TOKEN`` read, removed the ``Authorization: Bearer
  <token>`` header path on browser fetches, removed the
  ``?_kbagent_token=...`` query smuggle on EventSource. All three
  surfaces replaced by ``credentials: "include"`` on ``fetch`` and
  ``withCredentials: true`` on ``EventSource``. The browser attaches
  the same-origin ``kbagent_session`` cookie automatically; in BFF
  mode the cookie is absent and the BFF injects auth upstream, so the
  call shape is identical across both transports.
- The frontend never sees the bearer token in any form -- not in
  ``window``, not in ``localStorage``, not in URL query strings.

Tests:

- ``tests/test_serve_ui.py``:
  - Replaced ``test_index_served_unauthenticated_with_token_injected``
    with ``test_index_served_unauthenticated_with_session_cookie``:
    asserts the token is NOT in the body, the Set-Cookie header is
    present with HttpOnly + SameSite=Strict + Path=/.
  - Replaced ``TestEventSourceQueryFallback`` (3 tests) with
    ``TestCookieAuth`` (4 tests): cookie bootstrap on /, subsequent
    request authenticates via cookie, invalid cookie -> 401, query
    parameter path explicitly rejected, header still takes precedence.
  - Removed ``test_token_html_special_chars_escaped`` -- no longer
    relevant once the JS literal injection is gone.
- All 10 ``test_serve_ui.py`` tests pass; full suite 3061 passed.

End-to-end smoke verification:

- Built the wheel + ran ``kbagent serve --ui --port 8004``:
  - ``GET /`` -> 200 with Set-Cookie present; body contains ZERO
    occurrences of the token (grep -c).
  - ``GET /api/projects`` with cookie jar -> 200 + live data.
  - ``GET /api/projects`` with nothing -> 401.
  - ``GET /api/projects?_kbagent_token=...`` -> 401 (legacy path
    rejected).
  - ``GET /api/projects -H "Authorization: Bearer ..."`` -> 200
    (header path still works for scripted callers).
  - Browser load + ``window.__KBAGENT_TOKEN`` is undefined,
    ``document.cookie`` is empty (HttpOnly invisible to JS).
  - Access log shows ``GET /``, ``GET /api/projects``, etc. -- no
    token in any line that originated from the browser.

Misc:

- Updated changelog 0.40.0 entry to reflect the cookie design.
- Updated the auth middleware comment that previously claimed the
  query token "never lands in server access logs by default" -- the
  claim was factually wrong (uvicorn's default access format includes
  the query string) and is now obsolete (no query path exists).

* feat(agents): manual tasks, chained triggers, runtime prompt input

Three connected additions to scheduled agent tasks.

1) Manual tasks (no cron)
   - `AgentTask.manual: bool = False`. When True, the scheduler skips the
     task entirely; cron is preserved on disk so the operator can flip
     back to scheduled mode without retyping. UI hides the cron field +
     presets while manual is on (showing "0 0 * * *" next to a "Manual
     trigger only" checkbox was confusing).
   - `next_run_at` is nulled on manual tasks; the agents-list cron
     column renders a "manual" pill instead of a stale future stamp.

2) Chained downstream triggers
   - New `Trigger { on: "success"|"error"|"always", task_id }` model.
     `AgentTask.trigger: Trigger | None = None`.
   - After `run_task_once` persists its run record, the trigger filter
     is matched against the run status and the downstream task runs
     synchronously with this run threaded through as its upstream
     context. The chain awaits the downstream so its persist depends on
     the upstream's persist already being on disk.
   - Downstream subprocesses receive three env vars to discover the
     upstream output: KBAGENT_UPSTREAM_TASK_ID + KBAGENT_UPSTREAM_RUN_ID
     + KBAGENT_UPSTREAM_STATUS. AI agents additionally get a one-line
     prompt prefix with `kbagent http get /agents/<task>/runs/<run>` so
     they know exactly where to fetch the upstream payload — no
     guessing the API shape.
   - Self-loops (`trigger.task_id == owner.id`) and references to
     unknown task ids are rejected at the API boundary (422).
   - Disabled or missing downstream is logged + skipped silently —
     disabling is the operator's "off switch" for a chain.
   - Downstream exceptions are swallowed so an upstream's `ok` status
     doesn't retro-flip to `error` if the downstream crashes; the
     downstream's own run record captures its own failure.

3) Per-run runtime input (manual ai_agent UX)
   - `POST /agents/{id}/run` accepts an optional `runtime_input` body.
     For ai_agent tasks `runtime_input.prompt` is appended to the saved
     prompt as a labeled section — for this run only. The persisted
     task is not modified, so the next cron / chain firing sees only
     the operator's static instructions.
   - cli_command and mcp_tool have analogous merge semantics
     (`runtime_input.argv` extends argv; mcp dict shallow-merges into
     `input`) but no UI surface yet — that lands when those manual
     flavors get the modal too.
   - `run_task_once` and `RunBroadcaster._run` now refetch the
     persisted task before bumping `last_run_at` / `next_run_at`, so
     a router-merged ghost task can't leak its runtime params back
     onto disk.

UI additions on the Agents page (`web/frontend/src/pages/Agents.tsx`):
- "Manual trigger only" checkbox in the cron section; cron block
  hidden while on.
- Chain selector ("Run another task on success/error/always") with
  dropdown of sibling tasks (self filtered out). Downstream is shown
  as a `→ <name>` pill next to the task name in the list.
- New ManualRunModal: for `manual && ai_agent` tasks, the Play button
  opens a modal with a runtime-prompt textarea + "Persisted prompt"
  read-only collapsible. Submit POSTs to the blocking `/run` endpoint
  with `runtime_input`. Other action types keep the streaming detail
  drawer as before.

Tests added (tests/test_agent_runner.py, 11 cases):
- `_trigger_should_fire` policy matrix (success/error/always × ok/error,
  plus unknown-value fail-closed).
- `_build_subprocess_env` injects upstream env vars when chained and
  omits them when not.
- `_upstream_prompt_prefix` empty without upstream; references the
  upstream task id + run id + name when chained.
Existing 3061 tests untouched (3072 total green).

* fix(web): stack description under task name in Agents list

A separate "Description" column for an optional, often-empty field would
steal width from the action and cron columns that operators check first.
Move description into the Name cell as a smaller secondary line, mirroring
how Linear/GitHub stack title + summary in list views. Truncated to ~40ch
with a native `title` tooltip carrying the full text. The ChainBadge stays
next to the task name on the primary line so it's not buried.

* chore: gitignore lineage caches (lineage.json, *.lineage.json)

`kbagent lineage build --output PATH` writes a JSON cache that contains
project aliases, fully-qualified table IDs, and SQL bodies — accidentally
committing one would leak per-project metadata into the public repo. The
default output filename + the `.lineage.json` suffix convention are both
ignored.

* fix(web-dev): launch kbagent serve in caller's cwd, not repo root

Why
---
``make web-dev`` was creating nested-sync workspace directories
(``<alias>/.keboola/manifest.json``) in the kbagent repo root regardless
of where the operator invoked it from. Operators running ``CONFIG_DIR=...
make web-dev`` from a sibling project (e.g. ``./crm``) reasonably
expected ``kbagent sync`` operations to materialize relative to that
sibling directory -- not pollute keboola_agent_cli with six per-alias
config dumps containing live Keboola project data.

Root cause: ``scripts/web-dev.sh`` does ``cd "${REPO_DIR}"`` before
launching anything (uv needs the project root for venv resolution),
which fixes cwd to the kbagent repo for ALL child processes. Any
``kbagent sync init/pull <alias>`` running inside that serve process
(scheduler-driven or via ``kbagent http``) then writes
``<repo>/<alias>/`` because sync paths are relative to the process cwd
(intentional design -- sync is per-checkout, config is per-user).

What
----
- Capture ``USER_CWD="${PWD}"`` before any ``cd`` so the operator's
  invocation directory is preserved.
- Launch ``kbagent serve`` with ``(cd "${USER_CWD}" && uv run --project
  "${REPO_DIR}" kbagent serve ...)``. ``--project`` tells uv where the
  pyproject lives so it can chdir freely; the resulting Python process
  has cwd = USER_CWD, which is what every ``kbagent sync`` call inherits.
- Echo the chosen cwd at startup so the operator can spot
  misconfiguration before it materializes a workspace they didn't want.
- Vite + Node BFF child processes still need ``REPO_DIR`` cwd (their
  package.json + node_modules live there); only the Python serve was
  buggy.

Verified by launching ``./scripts/web-dev.sh --config-dir
/tmp/kbagent/.kbagent`` from ``/tmp/test-cwd`` and confirming the
serve process's cwd via lsof: ``/private/tmp/test-cwd`` (correct).

* ci: install [server] extras so server tests can collect

`uv sync` only installs project dependencies + dev group; it does NOT
install `[project.optional-dependencies]`. That means `fastapi` was
absent in CI, so every test module that imports from
`keboola_agent_cli.server.*` failed at collection time:

    src/keboola_agent_cli/server/__init__.py:22: in <module>
        from fastapi import FastAPI
    E   ModuleNotFoundError: No module named 'fastapi'

Five test files broken this way: test_agent_runner, test_pricing,
test_run_broadcaster, test_agents_store_events, test_serve_ui (plus
test_server_smoke which the workflow had been silently skipping until
now). Lokal `make install` pulls extras so the issue was invisible
during development; PR #283 is the first time these tests actually run
in CI because the parent commit (`feat: kbagent web UI` 9af1ea0) is
still only on the feature branch.

Switch CI to `uv sync --extra server` so fastapi + uvicorn +
sse-starlette + python-multipart + croniter are present at collection.

* fix(pr-review): resolve 4 blocking findings from PR #283 review

- CLAUDE.md `## All CLI Commands` was missing `kbagent serve` -- the section
  is hand-maintained per coding convention #17 and CI doesn't catch drift,
  so AI agents on the user's installed kbagent would have no `serve` entry
  in the canonical command reference.
- AGENT_CONTEXT serve entry now documents `--ui` and `--ui-dist` plus the
  cookie-bootstrap single-process flow; the prior text still described the
  old web/backend + web/frontend three-process setup that's no longer the
  recommended path.
- `kbagent http get/post/patch/delete` gain `--hint client|service` support:
  wire should_hint/emit_hint into commands/http_client.py and register the
  hints/definitions/http_client.py module so the existing _render_kbagent_serve_client
  renderer (which generates a raw httpx.Client snippet -- not a KeboolaClient
  one, since there is no Keboola API client here) actually gets reached.
- `kai preflight` and `kai chat-detail` get 8 service-layer tests and 7
  CLI-layer tests covering happy paths, KAI_NOT_ENABLED, KaiError wrapping,
  the legacy-message-skip case, and human/JSON output. Per CONTRIBUTING.md
  every new CLI command needs both a service test and a CliRunner test.

make check: 3087 passed, 7 skipped (+15 vs baseline).

* docs(pr-review): follow-up cleanups + build-your-own-client guide

Addresses the non-blocking and nit findings from the PR #283 review that
were intentionally deferred when fix(pr-review) a18487c shipped the four
blocking items.

- docs/build-your-own-client.md (NEW, 594 lines): single-document spec
  for anyone -- human or AI agent -- writing a custom client against
  `kbagent serve`. Covers the three auth paths (Bearer header / HttpOnly
  cookie / X-Manage-Token), endpoint discovery via /openapi.json + /docs,
  the kbagent error envelope with the full ErrorCode-to-HTTP-status map,
  REST patterns, SSE consumer recipes for both Python and the browser,
  CORS defaults, and three complete client examples (httpx script,
  Streamlit dashboard skeleton, the existing TypeScript fetch wrapper).
  Closes the gap that surfaced in the PR review: previously a Claude
  trying to "generate a UI on demand" had only the 6-line "Building your
  own client" paragraph in web/README.md to go on.

- commands/serve.py: stdout helper and `--ui` flag help string were
  still describing the deprecated `window.__KBAGENT_TOKEN` injection
  flow. Replaced with the HttpOnly cookie description that actually
  ships (commit 327574d). Caught only because test_serve_ui.py asserts
  `"__KBAGENT_TOKEN" not in body` -- the docstring lied to its own test.

- errors.py: add HTTP_ERROR, INTERNAL_ERROR, UNAUTHORIZED to the
  ErrorCode enum. They were already in the wire envelope (server raises
  them) but as raw strings -- check-error-codes.py only blocks raw
  literals at `raise X(error_code="...")` callsites, not bespoke
  JSONResponse bodies. Promoting them to the enum closes the gap and
  lets `map_error_code_to_type` resolve them consistently.

- server/__init__.py + server/auth.py: route the four server-emitted
  codes (CONFIG_ERROR / API_ERROR / HTTP_ERROR / INTERNAL_ERROR /
  UNAUTHORIZED) through ErrorCode.* instead of raw strings. Wire format
  unchanged because StrEnum serialises as plain strings.

- commands/http_client.py: drop the `_ = get_service` import-keeper
  hack. It dated to before `should_hint` / `emit_hint` were wired in --
  now those are real callers and the symmetry comment no longer applies.

- plugins/kbagent/skills/.../commands-reference.md: §"Self-Call HTTP"
  version tag drift from `v0.33.x` to `v0.40.0`, matching the corrected
  tag already present in gotchas.md and keboola-expert.md (the
  changelog entry for 0.40.0 claimed the fix landed everywhere; it
  landed in two of the three surfaces).

- plugins/kbagent/agents/keboola-expert.md: Tool Selection Matrix gets
  a row for `kbagent serve --ui` (First choice / Fallback / NEVER).
  Per CONTRIBUTING.md a missing matrix row means the subagent silently
  falls back to MCP `tool call` or refuses the task entirely -- §1 Rule
  6 VERSION GATE mentioned `--ui` but the §2 matrix didn't.

make check: 3087 passed, 7 skipped (no change vs baseline).

* revert(http): remove --hint support; document infra-level exception in CONTRIBUTING

PR review on a18487c correctly flagged that `kbagent http get/post/patch/delete`
is a pass-through to HttpForwarderService that never touches a Keboola API
client. The "client hint" generated an httpx.Client snippet against
$KBAGENT_SERVE_URL -- which is the canonical generic recipe for any
HTTP call, not a kbagent-specific equivalent the user would otherwise
have to reconstruct. The "service hint" generated
`HttpForwarderService.request(...)` -- which is literally the same call
the command makes one frame deeper. Neither hint carried business value
beyond "an httpx.Client looks like this".

This is the same shape as `doctor` / `context` / `serve` -- commands that
manage kbagent itself rather than wrap a Keboola API. None of those have
`--hint` support, and the CONTRIBUTING checklist used to imply that was
oversight; treat it as the rule instead.

Changes:
- commands/http_client.py: drop should_hint/emit_hint wiring and the
  helper imports it dragged in.
- hints/definitions/__init__.py + http_client.py: remove the registry
  entries and delete the definitions file.
- hints/renderer.py: drop _render_kbagent_serve_client (84 lines) and
  the kbagent_serve client-type branch in ClientRenderer.render. The
  renderer is back to its pre-PR-283 shape.
- CONTRIBUTING.md §Code changes / --hint support: add an explicit
  exception bullet listing doctor / context / init / serve / version /
  update / changelog / permissions / http. Future authors adding an
  infrastructure-level command get a citation to point at instead of
  having to re-litigate this PR review.

The original BLOCKING finding ("4 nové http.* příkazy nemají --hint
podporu") in commit a18487c was prematurely accepted -- the reviewer
was right to revisit. The Open question reviewer flagged
(https://github.com/padak/keboola_agent_cli/pull/283) is now resolved
by the CONTRIBUTING.md exception, not by re-adding the hints.

make check: 3087 passed, 7 skipped (no change vs baseline).

* feat(web): Artifacts tab + VSCode-style markdown viewer for agent runs

Agent runs frequently produce rich markdown reports (3-4 KB) that the
3-panel run view rendered as monospace plain text in a fixed ~896px
column -- treating long-form output the same as a one-line chat reply.
Reports were effectively unreadable unless the user copied them out by
hand. This adds a dedicated viewer with three goals: SURFACE reports as
first-class artifacts; RENDER them with GFM (tables, bold, lists) in a
VSCode-preview register; EXPORT them via Copy / Download .md.

Why a scoring heuristic instead of a fixed "heading at top" rule:
agents wrap their reports in different preambles (Insight blocks, YAML
frontmatter, plain-prose context). Requiring "# Title in first 8 lines"
gave false negatives on 2-3 KB bodies that obviously WERE reports. The
new ``looksLikeMarkdownArtifact`` sums signals -- heading, pipe-table,
list, bold spans, length >= 1000 -- and triggers at score >= 2. Hard
floor of 500 chars excludes short chat replies regardless of formatting.

Why client-side extraction (no backend changes): the same events that
feed the timeline already carry the markdown body. A server-side
``/artifacts`` endpoint would duplicate the data and force a JSONL
migration; instead ``extractArtifacts(steps)`` walks the already-distilled
step list. Zero backend touch, zero risk to persistence.

Why ``max-w-[75vw]`` / ``maxWidth: "90vw"`` instead of Tailwind ``max-w-*``
classes: Tailwind utilities are pixel-fixed (768/896/1024/1280px) which
gives a ~40-67% viewport ratio depending on monitor; vw units scale
predictably. ``Drawer`` keeps backward-compat by detecting whether
``width`` starts with ``max-w-`` (legacy callers continue working) vs a
raw CSS value (preferred for new callers).

Why the Esc handler uses ``capture: true`` + ``stopImmediatePropagation``:
both the parent ``Drawer`` and the artifact modal register window-level
keydown listeners. Without intercepting Esc in the capture phase, both
fire and the user gets dumped from the modal all the way back to the
agent-task overview instead of returning to the run detail.

Files:
- AgentRunView.tsx: extractArtifacts, ArtifactsView, ArtifactCard,
  MarkdownViewerModal, StepBody (auto-renders markdown when it scores
  >= 2), CopyDownloadButtons, Timeline/Artifacts tab switcher.
- Drawer.tsx: width prop accepts Tailwind class OR CSS value; default
  bumped from max-w-3xl (768px) to 75vw.
- index.css: ``.markdown-body`` overrides body's global font-mono with
  sans-serif (GitHub README register); ``.markdown-body-lg`` modifier
  for the modal viewer ups body to 16px, h1 to 2em, with breathing room
  in tables and headings.
- Agents.tsx: task-detail drawer goes 75vw default, 90vw in Run-live mode.

Build: npm run build green (3087 tests upstream unaffected).

* docs(readme): mention Artifacts tab in Web UI feature inventory

One-sentence addition to the existing Web UI section: agent runs that
produce long-form markdown reports (e.g. "Storage Cleanup Advisor",
"Schedule Drift Detector") surface in a dedicated Artifacts tab with
GFM preview + Copy/Download. Scoped narrowly to the user-facing feature
inventory — the implementation details (scoring heuristic, modal layering,
Drawer width math) live in the feat(web) commit message, not here.

* docs(readme): elevate Agent Tasks to first-class feature

Three coordinated additions to surface Agent Tasks as a headline
capability (not just a list item buried in "scheduled AI agents"):

1. New ## Agent Tasks H2 section between ## Web UI and ## For AI agents
   -- explains the three action flavours (AI agent / MCP tool / kbagent
   CLI), the self-call subprocess pattern (KBAGENT_SERVE_URL token
   inheritance), persisted JSONL timeline with authoritative Claude 4.x
   pricing, and the Artifacts tab. Positioned right after Web UI because
   that's the primary surface where users discover the feature.

2. New row in the "What it does" table between MCP tools and Workspaces,
   matching the (since 0.40.0) tag for the current release.

3. One additional "What you can ask your agent" example showing the
   chat-driven entry point -- "schedule a weekly Storage Cleanup advisor"
   -- so users see that Agent Tasks aren't just a UI feature, they can
   be triggered from natural-language conversation too.

No other docs touched: Agent Tasks is a web-UI surface, not a CLI command,
so CLAUDE.md ## All CLI Commands and the plugin reference files stay out
of scope (per CLAUDE.md convention #17, which is CLI-specific).

* docs(readme): add Get started subsection to Agent Tasks

The H2 ## Agent Tasks section described WHAT the feature does but never
told the user HOW to actually reach it. The required path -- install
with [server] extras, run kbagent serve --ui, look for "Agent Tasks" in
the sidebar -- was implicit, scattered between this section and ## Web
UI above. A new reader had to read both sections and connect the dots.

Add a self-contained ### Get started subsection that:
  - States explicitly that the scheduler lives inside kbagent serve
    (same Python process as the Web UI)
  - Repeats the install line for users who skipped the Web UI section
  - Names the UI surface (sidebar "Agent Tasks" -> "+ New task") so the
    user knows what to click after the URL opens
  - Sets lifecycle expectation: scheduler dies with the server, run
    history persists. Avoids the "I closed the terminal, where did my
    schedule go?" support question.
  - Points to docs/web-server.md for architecture / endpoint reference,
    so the README stays a marketing surface and the deep docs stay deep.

* docs(plugin): add agent-tasks-workflow + wire it into SKILL + keboola-expert

User test: "if a user asks me to spin up a weekly storage cleanup agent,
do I know enough to do it end-to-end?" The serve command was already in
the tool matrix, but the Agent Tasks lifecycle (create / run / chain /
inspect history) had no first-class reference -- agents would have to
introspect /openapi.json and guess payload shapes.

New file: agent-tasks-workflow.md
  - Prereqs (install with [server] extras, kbagent serve --ui running)
  - Three action flavours with concrete JSON payloads (mcp_tool /
    cli_command / ai_agent), each annotated with what env vars the
    subprocess inherits
  - CRUD over REST: list, create (cron + manual), patch, run on-demand
    (fire-and-forget + SSE stream + prompt override), inspect runs,
    delete
  - Chained triggers with KBAGENT_UPSTREAM_TASK_ID / _RUN_ID hand-off
  - Helper endpoints: /agents/cron/preview, /agents/test,
    /agents/prompt/improve/stream
  - Discovery pointer (kbagent http get /openapi.json) so the workflow
    doesn't claim to be exhaustive when the OpenAPI is the source of
    truth
  - Lifecycle gotchas: scheduler dies with server (history persists),
    manual:true ignores cron, ai_agent gets privileged env (sandboxing
    note), one run per task (HTTP 409), cost summary requires AI CLI
    with structured output

Wiring:
  - SKILL.md decision table: new row pointing to agent-tasks-workflow.md
    (positioned next to workspace-workflow.md as another "run something
    inside the kbagent surface" workflow)
  - keboola-expert.md tool selection matrix: new row "Schedule / manage
    Agent Tasks" sitting next to the existing "Launch the web UI" row,
    so the matrix groups all kbagent-serve-related decisions together

This is plugin-side docs only -- no CLI commands added or changed,
nothing in commands-reference.md or gotchas.md needs touching.
martinsifra added a commit that referenced this pull request Sep 3, 2026
…ranch merge; E2E [DMD-1900]

The silent-drift surfaces, all of them (nothing but check_command_sync
gates any of this):

- CLAUDE.md "All CLI Commands": the eleven signatures plus the block that
  matters most -- what may happen without a human saying so (merge is
  destructive; arming auto-merge is a delayed production merge; the
  --json explicit-target rule; the 0-approval facts; no `close`; the
  conflict loop; the error shapes; the feature-blind allowed_actions).
- commands/context.py AGENT_CONTEXT: a Merge Requests section after
  Branches, same content compressed for the agent.
- commands-reference.md: the group's cheat sheet.
- gotchas.md: one `(since vNEXT)` entry covering every non-obvious
  behaviour the RFC listed for it.
- keboola-expert.md: a tool-selection-matrix row with the anti-patterns
  (--auto-merge-strategy treated as metadata; --json merge with no
  target; a partial --resolved body; reading allowed_actions as
  feature-aware; approve on a 0-approval project).
- SKILL.md: triggers (merge request, mr, merge branch, auto-merge, review
  request), the description, the workflow link; decision table via
  `make skill-gen`.
- New merge-request-workflow.md: the short path, the --json path, the
  auto-merge table, the conflict loop, output semantics, the error table.
- branch-workflow.md points at the new group.

`branch merge` is deprecated with a CONDITIONAL pointer: it only builds a
UI URL (and unconditionally resets the active branch), but it works on
projects WITHOUT the feature, so it is not a 1:1 replacement. Behaviour
unchanged; `deprecation` key in --json, a warning in human mode.

E2E (convention #16): TestE2EMergeRequestLifecycle -- branch -> config on
the branch -> create -> list/detail/conflicts (id and --branch) -> approve
asserts the 422 -> bare --json merge exits 2 -> merge -> config in
production -> explicit teardown. GATED ON THE FEATURE: `list` on a
feature-less project answers feature_enabled: false and the suite skips
with the one-time enable command in the reason -- explicit, never silent.
The E2E project does not carry the feature today and this environment has
no E2E credentials; recorded in the ship ledger, not hidden.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
martinsifra added a commit that referenced this pull request Sep 3, 2026
…ranch merge; E2E [DMD-1900]

The silent-drift surfaces, all of them (nothing but check_command_sync
gates any of this):

- CLAUDE.md "All CLI Commands": the eleven signatures plus the block that
  matters most -- what may happen without a human saying so (merge is
  destructive; arming auto-merge is a delayed production merge; the
  --json explicit-target rule; the 0-approval facts; no `close`; the
  conflict loop; the error shapes; the feature-blind allowed_actions).
- commands/context.py AGENT_CONTEXT: a Merge Requests section after
  Branches, same content compressed for the agent.
- commands-reference.md: the group's cheat sheet.
- gotchas.md: one `(since vNEXT)` entry covering every non-obvious
  behaviour the RFC listed for it.
- keboola-expert.md: a tool-selection-matrix row with the anti-patterns
  (--auto-merge-strategy treated as metadata; --json merge with no
  target; a partial --resolved body; reading allowed_actions as
  feature-aware; approve on a 0-approval project).
- SKILL.md: triggers (merge request, mr, merge branch, auto-merge, review
  request), the description, the workflow link; decision table via
  `make skill-gen`.
- New merge-request-workflow.md: the short path, the --json path, the
  auto-merge table, the conflict loop, output semantics, the error table.
- branch-workflow.md points at the new group.

`branch merge` is deprecated with a CONDITIONAL pointer: it only builds a
UI URL (and unconditionally resets the active branch), but it works on
projects WITHOUT the feature, so it is not a 1:1 replacement. Behaviour
unchanged; `deprecation` key in --json, a warning in human mode.

E2E (convention #16): TestE2EMergeRequestLifecycle -- branch -> config on
the branch -> create -> list/detail/conflicts (id and --branch) -> approve
asserts the 422 -> bare --json merge exits 2 -> merge -> config in
production -> explicit teardown. GATED ON THE FEATURE: `list` on a
feature-less project answers feature_enabled: false and the suite skips
with the one-time enable command in the reason -- explicit, never silent.
The E2E project does not carry the feature today and this environment has
no E2E credentials; recorded in the ship ledger, not hidden.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
martinsifra added a commit that referenced this pull request Sep 3, 2026
…ranch merge; E2E [DMD-1900]

The silent-drift surfaces, all of them (nothing but check_command_sync
gates any of this):

- CLAUDE.md "All CLI Commands": the eleven signatures plus the block that
  matters most -- what may happen without a human saying so (merge is
  destructive; arming auto-merge is a delayed production merge; the
  --json explicit-target rule; the 0-approval facts; no `close`; the
  conflict loop; the error shapes; the feature-blind allowed_actions).
- commands/context.py AGENT_CONTEXT: a Merge Requests section after
  Branches, same content compressed for the agent.
- commands-reference.md: the group's cheat sheet.
- gotchas.md: one `(since vNEXT)` entry covering every non-obvious
  behaviour the RFC listed for it.
- keboola-expert.md: a tool-selection-matrix row with the anti-patterns
  (--auto-merge-strategy treated as metadata; --json merge with no
  target; a partial --resolved body; reading allowed_actions as
  feature-aware; approve on a 0-approval project).
- SKILL.md: triggers (merge request, mr, merge branch, auto-merge, review
  request), the description, the workflow link; decision table via
  `make skill-gen`.
- New merge-request-workflow.md: the short path, the --json path, the
  auto-merge table, the conflict loop, output semantics, the error table.
- branch-workflow.md points at the new group.

`branch merge` is deprecated with a CONDITIONAL pointer: it only builds a
UI URL (and unconditionally resets the active branch), but it works on
projects WITHOUT the feature, so it is not a 1:1 replacement. Behaviour
unchanged; `deprecation` key in --json, a warning in human mode.

E2E (convention #16): TestE2EMergeRequestLifecycle -- branch -> config on
the branch -> create -> list/detail/conflicts (id and --branch) -> approve
asserts the 422 -> bare --json merge exits 2 -> merge -> config in
production -> explicit teardown. GATED ON THE FEATURE: `list` on a
feature-less project answers feature_enabled: false and the suite skips
with the one-time enable command in the reason -- explicit, never silent.
The E2E project does not carry the feature today and this environment has
no E2E credentials; recorded in the ship ledger, not hidden.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
martinsifra added a commit that referenced this pull request Sep 3, 2026
…ranch merge; E2E [DMD-1900]

The silent-drift surfaces, all of them (nothing but check_command_sync
gates any of this):

- CLAUDE.md "All CLI Commands": the eleven signatures plus the block that
  matters most -- what may happen without a human saying so (merge is
  destructive; arming auto-merge is a delayed production merge; the
  --json explicit-target rule; the 0-approval facts; no `close`; the
  conflict loop; the error shapes; the feature-blind allowed_actions).
- commands/context.py AGENT_CONTEXT: a Merge Requests section after
  Branches, same content compressed for the agent.
- commands-reference.md: the group's cheat sheet.
- gotchas.md: one `(since vNEXT)` entry covering every non-obvious
  behaviour the RFC listed for it.
- keboola-expert.md: a tool-selection-matrix row with the anti-patterns
  (--auto-merge-strategy treated as metadata; --json merge with no
  target; a partial --resolved body; reading allowed_actions as
  feature-aware; approve on a 0-approval project).
- SKILL.md: triggers (merge request, mr, merge branch, auto-merge, review
  request), the description, the workflow link; decision table via
  `make skill-gen`.
- New merge-request-workflow.md: the short path, the --json path, the
  auto-merge table, the conflict loop, output semantics, the error table.
- branch-workflow.md points at the new group.

`branch merge` is deprecated with a CONDITIONAL pointer: it only builds a
UI URL (and unconditionally resets the active branch), but it works on
projects WITHOUT the feature, so it is not a 1:1 replacement. Behaviour
unchanged; `deprecation` key in --json, a warning in human mode.

E2E (convention #16): TestE2EMergeRequestLifecycle -- branch -> config on
the branch -> create -> list/detail/conflicts (id and --branch) -> approve
asserts the 422 -> bare --json merge exits 2 -> merge -> config in
production -> explicit teardown. GATED ON THE FEATURE: `list` on a
feature-less project answers feature_enabled: false and the suite skips
with the one-time enable command in the reason -- explicit, never silent.
The E2E project does not carry the feature today and this environment has
no E2E credentials; recorded in the ship ledger, not hidden.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
martinsifra added a commit that referenced this pull request Sep 3, 2026
…ranch merge; E2E [DMD-1900]

The silent-drift surfaces, all of them (nothing but check_command_sync
gates any of this):

- CLAUDE.md "All CLI Commands": the eleven signatures plus the block that
  matters most -- what may happen without a human saying so (merge is
  destructive; arming auto-merge is a delayed production merge; the
  --json explicit-target rule; the 0-approval facts; no `close`; the
  conflict loop; the error shapes; the feature-blind allowed_actions).
- commands/context.py AGENT_CONTEXT: a Merge Requests section after
  Branches, same content compressed for the agent.
- commands-reference.md: the group's cheat sheet.
- gotchas.md: one `(since vNEXT)` entry covering every non-obvious
  behaviour the RFC listed for it.
- keboola-expert.md: a tool-selection-matrix row with the anti-patterns
  (--auto-merge-strategy treated as metadata; --json merge with no
  target; a partial --resolved body; reading allowed_actions as
  feature-aware; approve on a 0-approval project).
- SKILL.md: triggers (merge request, mr, merge branch, auto-merge, review
  request), the description, the workflow link; decision table via
  `make skill-gen`.
- New merge-request-workflow.md: the short path, the --json path, the
  auto-merge table, the conflict loop, output semantics, the error table.
- branch-workflow.md points at the new group.

`branch merge` is deprecated with a CONDITIONAL pointer: it only builds a
UI URL (and unconditionally resets the active branch), but it works on
projects WITHOUT the feature, so it is not a 1:1 replacement. Behaviour
unchanged; `deprecation` key in --json, a warning in human mode.

E2E (convention #16): TestE2EMergeRequestLifecycle -- branch -> config on
the branch -> create -> list/detail/conflicts (id and --branch) -> approve
asserts the 422 -> bare --json merge exits 2 -> merge -> config in
production -> explicit teardown. GATED ON THE FEATURE: `list` on a
feature-less project answers feature_enabled: false and the suite skips
with the one-time enable command in the reason -- explicit, never silent.
The E2E project does not carry the feature today and this environment has
no E2E credentials; recorded in the ship ledger, not hidden.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
martinsifra added a commit that referenced this pull request Sep 4, 2026
…ranch merge; E2E [DMD-1900]

The silent-drift surfaces, all of them (nothing but check_command_sync
gates any of this):

- CLAUDE.md "All CLI Commands": the eleven signatures plus the block that
  matters most -- what may happen without a human saying so (merge is
  destructive; arming auto-merge is a delayed production merge; the
  --json explicit-target rule; the 0-approval facts; no `close`; the
  conflict loop; the error shapes; the feature-blind allowed_actions).
- commands/context.py AGENT_CONTEXT: a Merge Requests section after
  Branches, same content compressed for the agent.
- commands-reference.md: the group's cheat sheet.
- gotchas.md: one `(since vNEXT)` entry covering every non-obvious
  behaviour the RFC listed for it.
- keboola-expert.md: a tool-selection-matrix row with the anti-patterns
  (--auto-merge-strategy treated as metadata; --json merge with no
  target; a partial --resolved body; reading allowed_actions as
  feature-aware; approve on a 0-approval project).
- SKILL.md: triggers (merge request, mr, merge branch, auto-merge, review
  request), the description, the workflow link; decision table via
  `make skill-gen`.
- New merge-request-workflow.md: the short path, the --json path, the
  auto-merge table, the conflict loop, output semantics, the error table.
- branch-workflow.md points at the new group.

`branch merge` is deprecated with a CONDITIONAL pointer: it only builds a
UI URL (and unconditionally resets the active branch), but it works on
projects WITHOUT the feature, so it is not a 1:1 replacement. Behaviour
unchanged; `deprecation` key in --json, a warning in human mode.

E2E (convention #16): TestE2EMergeRequestLifecycle -- branch -> config on
the branch -> create -> list/detail/conflicts (id and --branch) -> approve
asserts the 422 -> bare --json merge exits 2 -> merge -> config in
production -> explicit teardown. GATED ON THE FEATURE: `list` on a
feature-less project answers feature_enabled: false and the suite skips
with the one-time enable command in the reason -- explicit, never silent.
The E2E project does not carry the feature today and this environment has
no E2E credentials; recorded in the ship ledger, not hidden.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
martinsifra added a commit that referenced this pull request Sep 4, 2026
…ranch merge; E2E [DMD-1900]

The silent-drift surfaces, all of them (nothing but check_command_sync
gates any of this):

- CLAUDE.md "All CLI Commands": the eleven signatures plus the block that
  matters most -- what may happen without a human saying so (merge is
  destructive; arming auto-merge is a delayed production merge; the
  --json explicit-target rule; the 0-approval facts; no `close`; the
  conflict loop; the error shapes; the feature-blind allowed_actions).
- commands/context.py AGENT_CONTEXT: a Merge Requests section after
  Branches, same content compressed for the agent.
- commands-reference.md: the group's cheat sheet.
- gotchas.md: one `(since vNEXT)` entry covering every non-obvious
  behaviour the RFC listed for it.
- keboola-expert.md: a tool-selection-matrix row with the anti-patterns
  (--auto-merge-strategy treated as metadata; --json merge with no
  target; a partial --resolved body; reading allowed_actions as
  feature-aware; approve on a 0-approval project).
- SKILL.md: triggers (merge request, mr, merge branch, auto-merge, review
  request), the description, the workflow link; decision table via
  `make skill-gen`.
- New merge-request-workflow.md: the short path, the --json path, the
  auto-merge table, the conflict loop, output semantics, the error table.
- branch-workflow.md points at the new group.

`branch merge` is deprecated with a CONDITIONAL pointer: it only builds a
UI URL (and unconditionally resets the active branch), but it works on
projects WITHOUT the feature, so it is not a 1:1 replacement. Behaviour
unchanged; `deprecation` key in --json, a warning in human mode.

E2E (convention #16): TestE2EMergeRequestLifecycle -- branch -> config on
the branch -> create -> list/detail/conflicts (id and --branch) -> approve
asserts the 422 -> bare --json merge exits 2 -> merge -> config in
production -> explicit teardown. GATED ON THE FEATURE: `list` on a
feature-less project answers feature_enabled: false and the suite skips
with the one-time enable command in the reason -- explicit, never silent.
The E2E project does not carry the feature today and this environment has
no E2E credentials; recorded in the ship ledger, not hidden.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
martinsifra added a commit that referenced this pull request Sep 5, 2026
…ranch merge; E2E [DMD-1900]

The silent-drift surfaces, all of them (nothing but check_command_sync
gates any of this):

- CLAUDE.md "All CLI Commands": the eleven signatures plus the block that
  matters most -- what may happen without a human saying so (merge is
  destructive; arming auto-merge is a delayed production merge; the
  --json explicit-target rule; the 0-approval facts; no `close`; the
  conflict loop; the error shapes; the feature-blind allowed_actions).
- commands/context.py AGENT_CONTEXT: a Merge Requests section after
  Branches, same content compressed for the agent.
- commands-reference.md: the group's cheat sheet.
- gotchas.md: one `(since vNEXT)` entry covering every non-obvious
  behaviour the RFC listed for it.
- keboola-expert.md: a tool-selection-matrix row with the anti-patterns
  (--auto-merge-strategy treated as metadata; --json merge with no
  target; a partial --resolved body; reading allowed_actions as
  feature-aware; approve on a 0-approval project).
- SKILL.md: triggers (merge request, mr, merge branch, auto-merge, review
  request), the description, the workflow link; decision table via
  `make skill-gen`.
- New merge-request-workflow.md: the short path, the --json path, the
  auto-merge table, the conflict loop, output semantics, the error table.
- branch-workflow.md points at the new group.

`branch merge` is deprecated with a CONDITIONAL pointer: it only builds a
UI URL (and unconditionally resets the active branch), but it works on
projects WITHOUT the feature, so it is not a 1:1 replacement. Behaviour
unchanged; `deprecation` key in --json, a warning in human mode.

E2E (convention #16): TestE2EMergeRequestLifecycle -- branch -> config on
the branch -> create -> list/detail/conflicts (id and --branch) -> approve
asserts the 422 -> bare --json merge exits 2 -> merge -> config in
production -> explicit teardown. GATED ON THE FEATURE: `list` on a
feature-less project answers feature_enabled: false and the suite skips
with the one-time enable command in the reason -- explicit, never silent.
The E2E project does not carry the feature today and this environment has
no E2E credentials; recorded in the ship ledger, not hidden.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
martinsifra added a commit that referenced this pull request Sep 10, 2026
…ranch merge; E2E [DMD-1900]

The silent-drift surfaces, all of them (nothing but check_command_sync
gates any of this):

- CLAUDE.md "All CLI Commands": the eleven signatures plus the block that
  matters most -- what may happen without a human saying so (merge is
  destructive; arming auto-merge is a delayed production merge; the
  --json explicit-target rule; the 0-approval facts; no `close`; the
  conflict loop; the error shapes; the feature-blind allowed_actions).
- commands/context.py AGENT_CONTEXT: a Merge Requests section after
  Branches, same content compressed for the agent.
- commands-reference.md: the group's cheat sheet.
- gotchas.md: one `(since vNEXT)` entry covering every non-obvious
  behaviour the RFC listed for it.
- keboola-expert.md: a tool-selection-matrix row with the anti-patterns
  (--auto-merge-strategy treated as metadata; --json merge with no
  target; a partial --resolved body; reading allowed_actions as
  feature-aware; approve on a 0-approval project).
- SKILL.md: triggers (merge request, mr, merge branch, auto-merge, review
  request), the description, the workflow link; decision table via
  `make skill-gen`.
- New merge-request-workflow.md: the short path, the --json path, the
  auto-merge table, the conflict loop, output semantics, the error table.
- branch-workflow.md points at the new group.

`branch merge` is deprecated with a CONDITIONAL pointer: it only builds a
UI URL (and unconditionally resets the active branch), but it works on
projects WITHOUT the feature, so it is not a 1:1 replacement. Behaviour
unchanged; `deprecation` key in --json, a warning in human mode.

E2E (convention #16): TestE2EMergeRequestLifecycle -- branch -> config on
the branch -> create -> list/detail/conflicts (id and --branch) -> approve
asserts the 422 -> bare --json merge exits 2 -> merge -> config in
production -> explicit teardown. GATED ON THE FEATURE: `list` on a
feature-less project answers feature_enabled: false and the suite skips
with the one-time enable command in the reason -- explicit, never silent.
The E2E project does not carry the feature today and this environment has
no E2E credentials; recorded in the ship ledger, not hidden.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
martinsifra added a commit that referenced this pull request Sep 10, 2026
…classes, `auto-merge` command [DMD-1900]

Replaces the flag- and state-derived escalations of the first draft with
one static rule: anything that moves a merge request toward or into
production is destructive, always. Decided with Martin 2026-09-10 after
Zajca's review of #736 pointed at the same hazard twice (a permission
that depends on a GET; an exit 2 that depends on state nobody typed) and
Martin asked for the flag to leave the condition entirely.

  request-review  destructive  (0-approval default lands directly in approved)
  approve         destructive  (the last approval is what a merge waits for)
  resolve         destructive  (removes the blocker a merge waits on)
  merge           destructive
  auto-merge      destructive  (NEW; arms the backend scheduler = delayed merge)
  create/update/request-changes  write

- `auto-merge --strategy immediately|scheduled|none [--at TS]` is its own
  command; `create`/`update` no longer take `--auto-merge-strategy`. Arming
  is a consciously separate step, prompts in human mode; the disarm rides
  the same command, same class (a caller who could not arm never needs to
  disarm). Under the hood: update_merge_request(auto_merge_*). L2 untouched.
- FLAG_ESCALATIONS is back to its single original entry. The five
  merge-request escalation keys, `_escalate_if_armed` (CLI + router copies),
  `_warn_armed`, `_Target.armed`, `auto_merge_armed`,
  `armed_escalation_operation` are gone. The router's permission check is
  the route dependency alone -- no body inspection, no prior GET.
- The --json explicit-target rule now runs BEFORE any network call for every
  destructive command, since the class is known from the name. The
  transitions no longer fetch the MR row; the armed warning is read off the
  write's own result.
- PUT /merge-requests/{p}/{id}/auto-merge added (router, SERVE_COMMAND_MAP).
- Zajca's must-fixes from the review ride along: _deleted_side_message None
  ordering (a missing side recommended --take ours, which resolves as
  DELETE); reason/external-id caps validated once in the service from
  constants.py; derived_state escaped in the shared success renderer;
  get_merge_request_row runs the feature pre-flight lazily on a 403;
  register() through Typer's public app.command(name)(fn); --output OSError
  branch emits warnings first; route-level 403 coverage for every
  destructive route incl. the disarm.
- Tests regrouped by behaviour (TestStaticDestructiveClass, TestAutoMerge;
  provenance-named classes dissolved). Docs: all six convention-#17
  surfaces; gotchas answers the --timeout question (retry is harmless
  behind the merge lock).

BREAKING for anyone on the unreleased draft only: --auto-merge-strategy /
--auto-merge-at on create/update are gone; request-review, approve and
resolve are denied under --deny-destructive.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
martinsifra added a commit that referenced this pull request Sep 10, 2026
…ranch merge; E2E [DMD-1900]

The silent-drift surfaces, all of them (nothing but check_command_sync
gates any of this):

- CLAUDE.md "All CLI Commands": the eleven signatures plus the block that
  matters most -- what may happen without a human saying so (merge is
  destructive; arming auto-merge is a delayed production merge; the
  --json explicit-target rule; the 0-approval facts; no `close`; the
  conflict loop; the error shapes; the feature-blind allowed_actions).
- commands/context.py AGENT_CONTEXT: a Merge Requests section after
  Branches, same content compressed for the agent.
- commands-reference.md: the group's cheat sheet.
- gotchas.md: one `(since vNEXT)` entry covering every non-obvious
  behaviour the RFC listed for it.
- keboola-expert.md: a tool-selection-matrix row with the anti-patterns
  (--auto-merge-strategy treated as metadata; --json merge with no
  target; a partial --resolved body; reading allowed_actions as
  feature-aware; approve on a 0-approval project).
- SKILL.md: triggers (merge request, mr, merge branch, auto-merge, review
  request), the description, the workflow link; decision table via
  `make skill-gen`.
- New merge-request-workflow.md: the short path, the --json path, the
  auto-merge table, the conflict loop, output semantics, the error table.
- branch-workflow.md points at the new group.

`branch merge` is deprecated with a CONDITIONAL pointer: it only builds a
UI URL (and unconditionally resets the active branch), but it works on
projects WITHOUT the feature, so it is not a 1:1 replacement. Behaviour
unchanged; `deprecation` key in --json, a warning in human mode.

E2E (convention #16): TestE2EMergeRequestLifecycle -- branch -> config on
the branch -> create -> list/detail/conflicts (id and --branch) -> approve
asserts the 422 -> bare --json merge exits 2 -> merge -> config in
production -> explicit teardown. GATED ON THE FEATURE: `list` on a
feature-less project answers feature_enabled: false and the suite skips
with the one-time enable command in the reason -- explicit, never silent.
The E2E project does not carry the feature today and this environment has
no E2E credentials; recorded in the ship ledger, not hidden.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
martinsifra added a commit that referenced this pull request Sep 10, 2026
…classes, `auto-merge` command [DMD-1900]

Replaces the flag- and state-derived escalations of the first draft with
one static rule: anything that moves a merge request toward or into
production is destructive, always. Decided with Martin 2026-09-10 after
Zajca's review of #736 pointed at the same hazard twice (a permission
that depends on a GET; an exit 2 that depends on state nobody typed) and
Martin asked for the flag to leave the condition entirely.

  request-review  destructive  (0-approval default lands directly in approved)
  approve         destructive  (the last approval is what a merge waits for)
  resolve         destructive  (removes the blocker a merge waits on)
  merge           destructive
  auto-merge      destructive  (NEW; arms the backend scheduler = delayed merge)
  create/update/request-changes  write

- `auto-merge --strategy immediately|scheduled|none [--at TS]` is its own
  command; `create`/`update` no longer take `--auto-merge-strategy`. Arming
  is a consciously separate step, prompts in human mode; the disarm rides
  the same command, same class (a caller who could not arm never needs to
  disarm). Under the hood: update_merge_request(auto_merge_*). L2 untouched.
- FLAG_ESCALATIONS is back to its single original entry. The five
  merge-request escalation keys, `_escalate_if_armed` (CLI + router copies),
  `_warn_armed`, `_Target.armed`, `auto_merge_armed`,
  `armed_escalation_operation` are gone. The router's permission check is
  the route dependency alone -- no body inspection, no prior GET.
- The --json explicit-target rule now runs BEFORE any network call for every
  destructive command, since the class is known from the name. The
  transitions no longer fetch the MR row; the armed warning is read off the
  write's own result.
- PUT /merge-requests/{p}/{id}/auto-merge added (router, SERVE_COMMAND_MAP).
- Zajca's must-fixes from the review ride along: _deleted_side_message None
  ordering (a missing side recommended --take ours, which resolves as
  DELETE); reason/external-id caps validated once in the service from
  constants.py; derived_state escaped in the shared success renderer;
  get_merge_request_row runs the feature pre-flight lazily on a 403;
  register() through Typer's public app.command(name)(fn); --output OSError
  branch emits warnings first; route-level 403 coverage for every
  destructive route incl. the disarm.
- Tests regrouped by behaviour (TestStaticDestructiveClass, TestAutoMerge;
  provenance-named classes dissolved). Docs: all six convention-#17
  surfaces; gotchas answers the --timeout question (retry is harmless
  behind the merge lock).

BREAKING for anyone on the unreleased draft only: --auto-merge-strategy /
--auto-merge-at on create/update are gone; request-review, approve and
resolve are denied under --deny-destructive.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
martinsifra added a commit that referenced this pull request Sep 14, 2026
…ranch merge; E2E [DMD-1900]

The silent-drift surfaces, all of them (nothing but check_command_sync
gates any of this):

- CLAUDE.md "All CLI Commands": the eleven signatures plus the block that
  matters most -- what may happen without a human saying so (merge is
  destructive; arming auto-merge is a delayed production merge; the
  --json explicit-target rule; the 0-approval facts; no `close`; the
  conflict loop; the error shapes; the feature-blind allowed_actions).
- commands/context.py AGENT_CONTEXT: a Merge Requests section after
  Branches, same content compressed for the agent.
- commands-reference.md: the group's cheat sheet.
- gotchas.md: one `(since vNEXT)` entry covering every non-obvious
  behaviour the RFC listed for it.
- keboola-expert.md: a tool-selection-matrix row with the anti-patterns
  (--auto-merge-strategy treated as metadata; --json merge with no
  target; a partial --resolved body; reading allowed_actions as
  feature-aware; approve on a 0-approval project).
- SKILL.md: triggers (merge request, mr, merge branch, auto-merge, review
  request), the description, the workflow link; decision table via
  `make skill-gen`.
- New merge-request-workflow.md: the short path, the --json path, the
  auto-merge table, the conflict loop, output semantics, the error table.
- branch-workflow.md points at the new group.

`branch merge` is deprecated with a CONDITIONAL pointer: it only builds a
UI URL (and unconditionally resets the active branch), but it works on
projects WITHOUT the feature, so it is not a 1:1 replacement. Behaviour
unchanged; `deprecation` key in --json, a warning in human mode.

E2E (convention #16): TestE2EMergeRequestLifecycle -- branch -> config on
the branch -> create -> list/detail/conflicts (id and --branch) -> approve
asserts the 422 -> bare --json merge exits 2 -> merge -> config in
production -> explicit teardown. GATED ON THE FEATURE: `list` on a
feature-less project answers feature_enabled: false and the suite skips
with the one-time enable command in the reason -- explicit, never silent.
The E2E project does not carry the feature today and this environment has
no E2E credentials; recorded in the ship ledger, not hidden.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
martinsifra added a commit that referenced this pull request Sep 14, 2026
…classes, `auto-merge` command [DMD-1900]

Replaces the flag- and state-derived escalations of the first draft with
one static rule: anything that moves a merge request toward or into
production is destructive, always. Decided with Martin 2026-09-10 after
Zajca's review of #736 pointed at the same hazard twice (a permission
that depends on a GET; an exit 2 that depends on state nobody typed) and
Martin asked for the flag to leave the condition entirely.

  request-review  destructive  (0-approval default lands directly in approved)
  approve         destructive  (the last approval is what a merge waits for)
  resolve         destructive  (removes the blocker a merge waits on)
  merge           destructive
  auto-merge      destructive  (NEW; arms the backend scheduler = delayed merge)
  create/update/request-changes  write

- `auto-merge --strategy immediately|scheduled|none [--at TS]` is its own
  command; `create`/`update` no longer take `--auto-merge-strategy`. Arming
  is a consciously separate step, prompts in human mode; the disarm rides
  the same command, same class (a caller who could not arm never needs to
  disarm). Under the hood: update_merge_request(auto_merge_*). L2 untouched.
- FLAG_ESCALATIONS is back to its single original entry. The five
  merge-request escalation keys, `_escalate_if_armed` (CLI + router copies),
  `_warn_armed`, `_Target.armed`, `auto_merge_armed`,
  `armed_escalation_operation` are gone. The router's permission check is
  the route dependency alone -- no body inspection, no prior GET.
- The --json explicit-target rule now runs BEFORE any network call for every
  destructive command, since the class is known from the name. The
  transitions no longer fetch the MR row; the armed warning is read off the
  write's own result.
- PUT /merge-requests/{p}/{id}/auto-merge added (router, SERVE_COMMAND_MAP).
- Zajca's must-fixes from the review ride along: _deleted_side_message None
  ordering (a missing side recommended --take ours, which resolves as
  DELETE); reason/external-id caps validated once in the service from
  constants.py; derived_state escaped in the shared success renderer;
  get_merge_request_row runs the feature pre-flight lazily on a 403;
  register() through Typer's public app.command(name)(fn); --output OSError
  branch emits warnings first; route-level 403 coverage for every
  destructive route incl. the disarm.
- Tests regrouped by behaviour (TestStaticDestructiveClass, TestAutoMerge;
  provenance-named classes dissolved). Docs: all six convention-#17
  surfaces; gotchas answers the --timeout question (retry is harmless
  behind the merge lock).

BREAKING for anyone on the unreleased draft only: --auto-merge-strategy /
--auto-merge-at on create/update are gone; request-review, approve and
resolve are denied under --deny-destructive.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
martinsifra added a commit that referenced this pull request Sep 15, 2026
…rvice (DMD-1900) (#736)

* Merge requests RFCs: general notes + per-layer (L3 as-built, L2 as-built, L1 as-implemented) [DMD-1899, DMD-1900]

The five documents, at the state that holds after PR #703 (Layer 2, merged
to main as 5281eef) and the Layer 1 implementation branch (PR #736):

- merge-requests-notes.md      verified backend facts, all layers
- merge-requests-layer3.md     the HTTP client, as shipped in #556
- merge-requests-layer2.md     the service RFC + "Additions made for Layer 1"
                               (get_merge_request_row, resolution_candidate,
                               merge() cleanup_warnings -> warnings)
- merge-requests-layer1.md     the command RFC after walking #703's review
                               findings into it (seven decisions), plus the
                               pointer to the follow-ups below
- merge-requests-layer2-followups.md  non-blocking leftovers of #703 for L1
                               (F1 done on L1; F2-F8 open)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(service): the three Layer 2 additions the Layer 1 RFC needs [DMD-1900]

Decided while walking PR #703's review findings into the Layer 1 RFC
(docs/merge-requests-layer1.md, "Layer 2 changes shipping with this PR").
Each exists so Layer 1 does not re-derive something the service knows.

- get_merge_request_row(alias, id): the row tier (_enrich_row) by id --
  one GET, no conflicts(), no verify_token(). list/find already return
  rows but only by branch; the sole by-id method was the detail, three
  round trips and a dependency on the conflicts endpoint that a write
  (request-review on an armed MR, the merge confirmation prompt) has no
  business inheriting. L3's merge_requests.get() was always this GET.

- get_config_diff -> resolution_candidate: the ours envelope through
  _DIFF_CONTENT_KEYS, description as an explicit null, changeDescription
  excluded; null when ours is absent/isDeleted. Composed in L2 so the
  `diff --output` prefill and the five-key replace guard in
  resolve_conflict share one constant -- a candidate built in L1 that
  dropped a null description would be a file kbagent writes and then
  refuses. Pinned by a round-trip test: candidate -> resolve_conflict
  unmodified.

- merge(): cleanup_warnings -> warnings. One soft-failure key for the
  group (resolve_conflict already used `warnings`); a renderer reading
  `warnings` must not silently drop the post-merge ones a user must act
  on. Specificity stays in the text.

Tests: 5 new (row tier cost pinned via assert_not_called on conflicts +
verify_token; candidate shape; null cases; round trip; warnings key).
L2 RFC updated in place.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(cli): merge-request group skeleton and the four read commands [DMD-1900]

The `kbagent merge-request` group (hidden alias `mr`) over
MergeRequestService: wiring, the helpers every command shares, and
list / detail / conflicts / diff with their renderers. Writes follow.

Skeleton -- the Layer 1 decisions from docs/merge-requests-layer1.md:

- Target resolution (_resolve_target): --merge-request-id/--id optional;
  omitted -> resolve_branch() (--branch, else active branch) ->
  find_merge_request_for_branch(). Both flags at once is exit 2, not
  silent precedence. The resolution is reported on stderr in human mode
  and stamped into every --json result (merge_request_id, branch_from_id,
  resolved_from_branch) so a machine caller can assert on what was
  operated upon.
- One error handler (_handle_error), no per-command except: keeps
  FeatureNotEnabledError's FEATURE_NOT_ENABLED code, which now surfaces
  from the resolver behind every omitted id -- reads included.
- The destructive-under-json rule and the armed-auto-merge escalation
  helpers (used by the writes next): policy check first, then the
  explicit-target rule, which for state-derived escalations can only fire
  after resolution.
- warnings[] rendered identically everywhere; hint-next Rich-only.

Permissions: 11 registry entries (merge = destructive) + the serve-only
by-branch; FLAG_ESCALATIONS gains five state/flag-derived destructive
entries (arming auto-merge on create/update; request-review / approve /
resolve on an armed MR) with the Connection citations that justify them.

Renderers (_merge_request_render.py): every wire string escaped;
derived_state never raw state; list preserves server order and shows
optional columns only when populated; empty list tells feature-off apart
via feature_enabled; detail says the change log is empty by design in
development; diff checks the *_deleted flags BEFORE the table and
recommends the --take, since a null side yields zero rows; --output
writes the service's resolution_candidate verbatim and refuses when there
is nothing to prefill. Table value columns fold rather than crop so
--format full is actually full.

Also hoists parse_json_arg into _helpers (transformation.py had the
private copy; resolve is the third consumer).

35 CLI tests via CliRunner.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(cli): merge-request writes -- create, update, transitions, merge, resolve [DMD-1900]

The seven write commands, each routed through the shared skeleton
(_resolve_target, _handle_error, _stamp_target, warnings, hint-next).

Where a human says so, and where a policy does (docs/merge-requests-layer1.md):

- merge: statically destructive. Under --json the explicit-target rule
  fires BEFORE any lookup (no --merge-request-id/--branch -> exit 2); in
  human mode the active-branch fallback stays and the prompt names the MR,
  its title and the branch that will be deleted. --yes skips the prompt.
- create/update --auto-merge-strategy immediately|scheduled: arming is a
  delayed production merge, so it escalates to destructive
  (FLAG_ESCALATIONS), needs an explicit target under --json (--branch for
  create), prompts in human mode worded as arming, and warns afterwards.
  `none` is the disarm and escalates nothing. The strategy/--auto-merge-at
  pairing is validated at exit 2.
- request-review / approve / resolve on an ALREADY-armed MR escalate via
  the state-derived operation strings; the row comes free on the implicit
  path and via get_merge_request_row (one GET, never the detail) on the
  explicit path. Under --json with an implicit target this exits 2 only
  AFTER resolution -- deliberate, the information does not exist earlier;
  the error names the MR and the flag to pass. request-changes moves the
  MR away from approved and never escalates.
- update with no field flags is exit 2 (PUT {} is a server no-op).
  --reviewer-id is normalised to None when absent -- [] would clear the set.
- resolve: exactly one of --take/--resolved (exit 2 otherwise); --resolved
  parsed via the hoisted parse_json_arg and must be an object; a
  --change-description on a delete is the service's warning, not a Layer 1
  refusal (the implicit-delete collapse is only known after the diff).

Escalations are tested against the real engine (--deny-destructive ->
exit 6), not a mocked check. 38 more CLI tests (73 total).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(cli): split merge_request.py at the 800-code-line soft ceiling [DMD-1900]

829 code lines after the writes landed -- exactly what the RFC predicted
for eleven commands at ~75 each. CONTRIBUTING lets a file sit over the
soft ceiling until the next PR adds to it, but a brand-new module born
over it is debt on day one, so split now:

- merge_request.py      -- app, callback, the four reads; mounts the writes
- _merge_request_common -- what both need: option declarations, the ONE
                           error handler, target resolution, the
                           destructive-under-json rule, escalation, output
- _merge_request_writes -- the seven writes on their own Typer, mounted
                           flat via register(app) so permission keys stay
                           merge-request.* and --help lists one group
                           (precedent: _storage_describe.register)
- _merge_request_render -- unchanged

A third module instead of reads importing writes (or vice versa): both
import common, only merge_request imports writes -- no cycle. Behaviour
unchanged; 73 CLI tests green; every module well under 800.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(serve): merge-requests router, 1:1 with the CLI group, permission-enforced [DMD-1900]

server/routers/merge_requests.py: twelve routes under /merge-requests --
one per CLI command plus GET /{project}/by-branch/{branch_id}, the
branch->MR resolver the CLI hides behind an omitted --merge-request-id
(no active-branch idiom over HTTP; registered as the serve-only
merge-request.by-branch). Declared before /{project}/{merge_request_id}
so FastAPI never tries to read 'by-branch' as an id. Skipped on purpose:
`diff --output PATH` -- GET .../diff returns resolution_candidate and the
caller writes its own file.

Every route declares Depends(require_permission(...)). Until now only
/auth/* did; here it is not optional -- the CLI classifies merge as
destructive and escalates arming auto-merge and the transitions on an
armed MR (FLAG_ESCALATIONS), and without the same checks over HTTP that
analysis would be decorative for serve callers. The static class is a
route dependency; the flag/state-derived escalations run in the route
body: arming in the create/update body -> check_or_raise the flag string;
request-review/approve/resolve -> one row GET (get_merge_request_row,
never the three-call detail) and check_or_raise when armed.

Caller errors (unknown state/take, both-or-neither take/resolved, empty
update body, broken auto-merge pairing) raise INVALID_ARGUMENT -> 400,
the REST twin of the CLI's exit 2. POST .../merge documents that it is
synchronous for up to 600 s.

Wiring: ServiceRegistry.merge_request, include_router, an OPENAPI_TAGS
entry (endpoints-gen would otherwise emit an untagged section), and
docs/web-server-endpoints.md regenerated (endpoints-check green).

14 router tests: kwarg parity per route (the drift this file exists to
catch) and the permission story over HTTP -- merge 403 under
deny_destructive, arming 403 while `none` passes, armed request-review
403 via the row tier, reads pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(cli): merge-request on every convention-#17 surface; deprecate branch merge; E2E [DMD-1900]

The silent-drift surfaces, all of them (nothing but check_command_sync
gates any of this):

- CLAUDE.md "All CLI Commands": the eleven signatures plus the block that
  matters most -- what may happen without a human saying so (merge is
  destructive; arming auto-merge is a delayed production merge; the
  --json explicit-target rule; the 0-approval facts; no `close`; the
  conflict loop; the error shapes; the feature-blind allowed_actions).
- commands/context.py AGENT_CONTEXT: a Merge Requests section after
  Branches, same content compressed for the agent.
- commands-reference.md: the group's cheat sheet.
- gotchas.md: one `(since vNEXT)` entry covering every non-obvious
  behaviour the RFC listed for it.
- keboola-expert.md: a tool-selection-matrix row with the anti-patterns
  (--auto-merge-strategy treated as metadata; --json merge with no
  target; a partial --resolved body; reading allowed_actions as
  feature-aware; approve on a 0-approval project).
- SKILL.md: triggers (merge request, mr, merge branch, auto-merge, review
  request), the description, the workflow link; decision table via
  `make skill-gen`.
- New merge-request-workflow.md: the short path, the --json path, the
  auto-merge table, the conflict loop, output semantics, the error table.
- branch-workflow.md points at the new group.

`branch merge` is deprecated with a CONDITIONAL pointer: it only builds a
UI URL (and unconditionally resets the active branch), but it works on
projects WITHOUT the feature, so it is not a 1:1 replacement. Behaviour
unchanged; `deprecation` key in --json, a warning in human mode.

E2E (convention #16): TestE2EMergeRequestLifecycle -- branch -> config on
the branch -> create -> list/detail/conflicts (id and --branch) -> approve
asserts the 422 -> bare --json merge exits 2 -> merge -> config in
production -> explicit teardown. GATED ON THE FEATURE: `list` on a
feature-less project answers feature_enabled: false and the suite skips
with the one-time enable command in the reason -- explicit, never silent.
The E2E project does not carry the feature today and this environment has
no E2E credentials; recorded in the ship ledger, not hidden.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(plugin): vNEXT tags out of headings; SKILL description back under 1024 chars [DMD-1900]

check_version_gates: a vNEXT inside a heading would rewrite the anchor
slug on release; the three new sections carry the tag on their first body
line instead. test_skill_frontmatter: the description hit 1130/1024 chars;
kept the 'merge request' trigger, dropped the redundant ones, and
compressed three neutral list phrases (and -> /). No trigger word lost.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(cli,serve,service): apply the Phase-5 self-review round [DMD-1900]

Three reviews (Opus, Sonnet, /code-review) over the implementation; every
finding either fixed here with a pin, or recorded below as deferred.

High:
- _handle_error dropped exc.details, so the merge 409's conflict list and
  the truncation marker never reached --json, and the RFC's human render
  of MR_MERGE_CONFLICT (entries + "list truncated -- run conflicts") was
  never implemented. Both fixed; details flow through, the list renders
  escaped, the truncation line names no number.
- Unescaped wire/user strings in the ad-hoc console.print sites outside
  the renderer module (conflicts hint, diff hint, resolve success line,
  merge message): a `[/x]` in a config id raised MarkupError AFTER the
  rebase had landed server-side. Escaped everywhere.

Medium:
- branch_from_id was null on every explicit --merge-request-id path,
  beside a payload saying branches.branchFromId: 123. _stamp_target now
  derives it from the result (row branches, diff branch_id); conflicts
  fetches the row tier (one GET) since its result carries no branch.
- The auto-merge vocabulary was copied into the CLI and the router -- the
  exact drift the RFC forbids, and a SAFETY divergence (one surface would
  stop escalating an arming value the other still knows). Now
  AUTO_MERGE_STRATEGIES / AUTO_MERGE_DISARMED / validate_auto_merge_flags /
  arms_auto_merge live in the service module; both surfaces import them.
- next_step_hints silently dropped unknown action names; once DMD-1988
  serialises a camelCase vocabulary every hint-next line would vanish.
  Falls back to the raw name.
- Over `serve`, MR_MERGE_CONFLICT / MR_NOT_READY_TO_MERGE answered 502 with
  no details (retry-inviting, list dropped). app.py maps them to 409 and
  _format_error carries non-empty details.
- The service's two caller-mistake refusals (resolving/diffing a finished
  MR, a config outside the conflict set) were VALIDATION_ERROR -> 502 over
  serve; now INVALID_ARGUMENT -> 400. CLI exit code unchanged.
- resolve_conflict coerced a caller body's isDisabled with bool(), so a
  hand-edited "false" DISABLED the config on replace and returned 200.
  Non-bool is refused (the guard's refuse-don't-default policy).

Low:
- The armed-auto-merge warning is human-only (formatter.warning), no
  longer injected into the payload -- Layer 1 does not manufacture data.
- A hole in the ours envelope no longer becomes an explicit-null candidate
  that resolve then blames the caller for; get_config_diff returns
  resolution_candidate: null + a warning, and --output words the three
  null shapes apart (deleted / absent / envelope hole).
- parse_json_arg turns OSError (a directory, permissions) into the
  ValueError the callers expect; docstring stops claiming config.py's
  copy is gone. --output on an unwritable path is a readable exit 2.
- merge skips the row GET when no prompt will show (--yes / --json).
- --reason cap enforced on the REST route too. CLAUDE.md --state line
  stops hand-listing a subset of the vocabulary.
- FEATURE_NOT_ENABLED pinned on every command, as the RFC promised; the
  misnamed CLI "round-trip" test renamed (the real round trip is pinned at
  the service layer).

Deferred to PR #703 (Layer 2 design/refactor findings from /code-review,
which reviewed the L2 branch; too large for the tail of this run):
_classify_three_way missing `both` rows for nested-vs-parent edits; the
`or code is None` 409 fallback; SOX-project reads reporting
feature_enabled: false; the tuple return in http_base._bound_error_params;
the post-merge cleanup being a third copy of BranchService's; the
try/finally client idiom vs the context manager.

Tests: 6643 passed. The 9 failures in test_release_kbagent_ai_kit_sync
are environmental (git commit signing via 1Password unavailable to the
test process), unrelated to this diff.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(cli,service): align Layer 1 with Zajca's third Layer 2 review [DMD-1900]

Three second-order effects of rebasing onto 7cd1855:

- _branch_from_id_of: the L2 round split the message for an absent vs a
  non-numeric branchFromId, but the Phase-5 INVALID_ARGUMENT re-code had
  made both faults carry the caller's code. They blame different parties:
  absent = the caller is resolving a finished MR (INVALID_ARGUMENT, 400
  over serve); non-numeric = the server's payload (VALIDATION_ERROR).
- diff renderer: the L2 round makes _classify_three_way return zero rows
  for an empty-envelope side too, not only a null/deleted one. With no
  deletion flag set the renderer would have claimed "this conflict has
  cleared" while the service's warning beside it said "envelope hole".
  When there are no rows and the result carries warnings, say that no
  classification could be produced and let the warning explain.
- RFC: the five-key rule is the CALLER-body rule; a --take side composes
  an absent description as null (wire-identical -- the rebase omits the
  key), and a hole or non-boolean isDisabled there is a backend contract
  violation (VALIDATION_ERROR), never a caller error. The table said
  "refused when absent" for both paths.

Pinned: the two error codes; the no-rows-with-warning render.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(cli,service): apply the Layer 2 follow-ups inherited by Layer 1 (F2-F5, F7) [DMD-1900]

docs/merge-requests-layer2-followups.md collects the non-blocking leftovers
of PR #703 that go through this PR. Per item:

- F2: _classify_three_way's docstring now says what is true -- the
  classifier is deliberately STRICTER than resolve_conflict (an empty
  envelope yields no rows here, a VALIDATION_ERROR there; collapsing it to
  the delete resolution would destroy a configuration). And the
  empty-envelope half finally has a test. Beyond the docstring: an empty
  envelope on EITHER side is now reported in `warnings` (_diff_warnings
  replaces the ours-only _candidate_warnings), so the diff renderer's
  "no rows + warnings" branch fires instead of claiming the conflict
  cleared -- which it would have done for a theirs-side hole.
- F3: merge() records the branch-id degradation structurally --
  `cleanup_skipped: true` + `branch_from_id_raw` -- and the message says
  "Source branch id could not be read; see warnings." instead of nothing.
  The CLI's merge renderer keys on the flag (a "Local cleanup skipped"
  line naming the raw value) and its hint-next points at branch reset +
  sync branch-unlink. A legitimate published-MR null carries no flag.
- F4: find_default_branch_id logs the skipped non-numeric entry instead of
  folding it into None silently (the callers then say "no default branch"
  for a project that DID report one). The `sync init` exits-0-with-empty-
  branches decision is a UX call left for Martin -- not changed.
- F5: the detail tier is feature-aware for free (has_feature after the
  verify_token it already pays): `feature_enabled` on the detail payload,
  a "Feature: not enabled" line in the panel, and hint-next refusing to
  recommend a write that cannot succeed. `list` stays feature-blind on
  non-empty results, as Layer 2 decided; docs say which is which.
- F7: config_service.py's last `if folder_branch_id:` truthiness test ->
  `is not None`; the positive assertion for "Active branch reset to main."
  on a successful reset; the 110-char docstring line rewrapped. The
  isDisabled-before-missing ordering is left as noted (house pattern).

Not in this PR: F6 (cleanup_branch_id_from_mapping project scope -- both
call sites, standalone PR) and F8 (test_changelog_render under
FORCE_COLOR -- main, unrelated).

9 new tests; make check exit 0 (6672).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* style(cli): parenthesize implicit string concatenation in tuples (ruff 0.16 ISC004) [DMD-1900]

main's ruff upgrade (9d823d5, >=0.16 default rule set) fires ISC004 on two
tuple items in the detail renderer. No behaviour change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(serve): register the merge-requests routes in SERVE_COMMAND_MAP [DMD-1900]

main's #731 (command telemetry) requires every serve route in the
route->CLI-command map, enforced by
test_serve_telemetry::test_command_map_matches_every_route_exactly. The
twelve /merge-requests routes mirror their commands; by-branch is
serve-only (empty string, logged under its route label).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(cli,service,e2e): apply Copilot's (Balanced) review of #736 [DMD-1900]

Eight inline findings, all confirmed against the code and fixed with pins:

- E2E setup skipped on ANY `merge-request list` failure, turning a crash or
  an auth regression into a green run. It now asserts success and skips
  only on feature_enabled: false -- the one gate the class documents.
- The E2E covered 6 of 11 commands. The scenario now manufactures a REAL
  conflict (config in production, branch inherits it, both sides change
  it) and walks every command: create, update, list, detail, conflicts
  (via --branch), diff (+ --output candidate), resolve --take ours,
  request-review (-> approved), request-changes (-> development), approve,
  the bare --json merge exit 2, merge, and the production content check.
- `approve`'s refusal was asserted as "any error but FEATURE_NOT_ENABLED";
  it now asserts API_ERROR with the 422 in the message.
- warnings[] text (backend / exception prose) reached Rich unescaped -- an
  unbalanced tag would raise MarkupError after the irreversible operation
  succeeded. Escaped.
- `diff --output` wrote with the platform encoding (a name outside a
  Windows code page would fail the promised round trip); utf-8 now. And the
  path was interpolated into Rich markup unescaped.
- A HOLED (partial) envelope still classified: content() omitted the
  missing key and the intersection reported it as that side's removal;
  holes on theirs were not warned about. A side missing any required
  content key is now unclassifiable on either side, and _diff_warnings
  names the holes for both (one shared _envelope_holes criterion feeds the
  classifier, the candidate and the warnings).

The eighth finding (a stale "code-less conflict" rationale in the L2 RFC)
is fixed on ms/merge-requests-rfcs (76a2adb); this branch's first commit
is rebuilt from it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(cli,serve)!: destructive is a property of the command -- static classes, `auto-merge` command [DMD-1900]

Replaces the flag- and state-derived escalations of the first draft with
one static rule: anything that moves a merge request toward or into
production is destructive, always. Decided with Martin 2026-09-10 after
Zajca's review of #736 pointed at the same hazard twice (a permission
that depends on a GET; an exit 2 that depends on state nobody typed) and
Martin asked for the flag to leave the condition entirely.

  request-review  destructive  (0-approval default lands directly in approved)
  approve         destructive  (the last approval is what a merge waits for)
  resolve         destructive  (removes the blocker a merge waits on)
  merge           destructive
  auto-merge      destructive  (NEW; arms the backend scheduler = delayed merge)
  create/update/request-changes  write

- `auto-merge --strategy immediately|scheduled|none [--at TS]` is its own
  command; `create`/`update` no longer take `--auto-merge-strategy`. Arming
  is a consciously separate step, prompts in human mode; the disarm rides
  the same command, same class (a caller who could not arm never needs to
  disarm). Under the hood: update_merge_request(auto_merge_*). L2 untouched.
- FLAG_ESCALATIONS is back to its single original entry. The five
  merge-request escalation keys, `_escalate_if_armed` (CLI + router copies),
  `_warn_armed`, `_Target.armed`, `auto_merge_armed`,
  `armed_escalation_operation` are gone. The router's permission check is
  the route dependency alone -- no body inspection, no prior GET.
- The --json explicit-target rule now runs BEFORE any network call for every
  destructive command, since the class is known from the name. The
  transitions no longer fetch the MR row; the armed warning is read off the
  write's own result.
- PUT /merge-requests/{p}/{id}/auto-merge added (router, SERVE_COMMAND_MAP).
- Zajca's must-fixes from the review ride along: _deleted_side_message None
  ordering (a missing side recommended --take ours, which resolves as
  DELETE); reason/external-id caps validated once in the service from
  constants.py; derived_state escaped in the shared success renderer;
  get_merge_request_row runs the feature pre-flight lazily on a 403;
  register() through Typer's public app.command(name)(fn); --output OSError
  branch emits warnings first; route-level 403 coverage for every
  destructive route incl. the disarm.
- Tests regrouped by behaviour (TestStaticDestructiveClass, TestAutoMerge;
  provenance-named classes dissolved). Docs: all six convention-#17
  surfaces; gotchas answers the --timeout question (retry is harmless
  behind the merge lock).

BREAKING for anyone on the unreleased draft only: --auto-merge-strategy /
--auto-merge-at on create/update are gone; request-review, approve and
resolve are denied under --deny-destructive.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(cli,service): the candidate uses the resolve guard's criterion; both field caps exit 2 [DMD-1900]

Zajca's second review of #736, two findings.

1. `_envelope_holes` (the predicate behind `resolution_candidate`, the
   diff warnings and `classifiable()`) tested key PRESENCE, while the
   replace guard in `resolve_conflict` refuses a key that is present but
   `None` and a blank `name`. An ours envelope with `"name": null` thus
   composed into a candidate that `diff --output` wrote and
   `resolve --resolved @file` then refused -- kbagent blaming the caller
   for its own file, the exact drift the shared constant was meant to
   prevent. The predicate now mirrors the guard: absent OR `None` is a
   hole, so is a blank `name`. Candidate suppressed, reason in
   `warnings[]`, side excluded from classification. Pinned in the
   service suite for `None` and `"   "`.

2. `--reason` over its cap was pre-checked to exit 2, `--external-id`
   over its cap reached the service, whose INVALID_ARGUMENT
   `map_error_to_exit_code` does not map -- exit 1. Same kind of flag
   error, two exit codes. `create`/`update` now pre-check `--external-id`
   the same way (`_check_external_id`, one call per command); the service
   keeps the cap as the single rule (serve still answers 400 from it).
   Pinned for all three flag/command pairs, asserting no service call.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
vojtabiberle added a commit that referenced this pull request Sep 21, 2026
…775)

Adds the provisioning path from DMD-1806 so an agent gets from a machine
with no Keboola identity to a working, authenticated project in one
command.

`kbagent project create --url URL [--project ALIAS] [--name NAME]
[--backend snowflake|bigquery] [--sync-backend-init]`:

1. POSTs the unauthenticated `/manage/programmatic-projects` endpoint,
   gated by the `agent-provisioning` stack feature
   (STACK_FEATURES__AGENT_PROVISIONING),
2. stores the returned project-pinned, Manage-less AT/RT session in
   `auth.json` exactly like `auth login` does -- so the existing
   `POST /token/refresh` path keeps it alive with no new token plumbing,
3. registers the project in `config.json` under the existing
   `kbc-session://` sentinel, becoming the default project when nothing
   else was registered.

The project it creates is owned by nobody until a human opens the
single-use `confirm_url`, and confirming revokes the agent session. The
command therefore names the next step (`auth login --stack URL`) rather
than pretending the credential is durable, and the link is persisted on
`StackSession.agent_confirm_url` and re-printed by `auth status` while
the claim is outstanding, so losing the terminal does not strand a
billable project.

Safety properties:

- The POST is never auto-retried, on any status: it is not idempotent,
  and each success creates an organization, a billable project and a
  credit grant. A timeout is classified rather than flattened -- a
  connect/pool timeout never reached the stack (retryable), a delivered
  read timeout may have succeeded (non-retryable, points at
  `auth status`). `--sync-backend-init` raises that call's read budget
  to 300 s, since waiting for the backend is that flag's whole purpose.
- The command is always registered but the capability is not, so the 404
  answer carries the whole story: the missing feature, the flag an
  operator flips, and how to connect an existing project instead.
- Refuses when a session for the stack already exists, checked before
  the POST and again under the state store's transaction on the write,
  so a racing call never silently overwrites a session -- and reports
  the project it created with its claim link instead of orphaning it.
- Local input (the `--project` alias) is validated before the call;
  `--url` is required and deliberately not read from the environment.
- `project.create` is `admin` in OPERATION_REGISTRY. Access and refresh
  tokens never reach a result dataclass or `--json`.

Docs updated per convention #17; no version bump and no changelog entry,
per the release policy.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant