From d8a6630ac055e7e6aea6a132a46dfdc60643a253 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Tue, 1 Sep 2026 18:58:18 -0400 Subject: [PATCH 01/10] feat!: remove implicit agent writes and retired surfaces BREAKING CHANGE: Hack no longer exposes Tickets, hosted migration stubs, legacy dispatch PR flags, or automatic agent-integration mutation from ordinary commands, update, and doctor repair. --- .codex/skills/hack-cli/SKILL.md | 12 +- .cursor/rules/hack.mdc | 12 +- .github/workflows/ci.yml | 2 +- AGENTS.md | 12 +- CLAUDE.md | 12 +- docker/slim-runtime/Dockerfile | 1 - docs/README.md | 2 +- docs/architecture.md | 6 +- docs/cli.md | 39 +- docs/docs-ia.md | 1 - docs/env.md | 1 - docs/extensions.md | 16 +- docs/guides/codex-managed-environments.md | 1 - docs/guides/tickets.md | 294 -- docs/integrations.md | 18 +- docs/reference.md | 3 +- docs/reference/cli.md | 181 +- examples/basic/AGENTS.md | 8 +- examples/basic/CLAUDE.md | 8 +- scripts/build-release.ts | 1 - scripts/install-codex-slim.sh | 1 - scripts/portable-container-smoke.sh | 1 - src/agents/instruction-source.ts | 8 +- src/agents/integration-revision.ts | 2 +- src/agents/shared-skill.ts | 68 - src/cli/integration-sync.ts | 260 +- src/cli/run.ts | 9 +- src/cli/spec.ts | 10 - src/commands/auth.ts | 11 - src/commands/dispatch.ts | 145 - src/commands/doctor.ts | 50 +- src/commands/linear.ts | 10 - src/commands/org.ts | 10 - src/commands/removed-surface.ts | 59 - src/commands/setup.ts | 109 +- src/commands/team.ts | 10 - src/commands/tickets.ts | 225 -- src/commands/update.ts | 43 +- src/commands/x.ts | 9 - src/control-plane/extensions/builtins.ts | 2 - .../extensions/tickets/agent-docs.ts | 295 -- .../extensions/tickets/commands.ts | 2414 --------------- .../extensions/tickets/documents.ts | 97 - .../extensions/tickets/domain.ts | 265 -- .../extensions/tickets/enablement.ts | 76 - .../extensions/tickets/extension.ts | 13 - .../extensions/tickets/provenance.ts | 459 --- .../extensions/tickets/repo-state.ts | 216 -- .../extensions/tickets/runs-channel.ts | 144 - .../extensions/tickets/sqlite-projection.ts | 432 --- src/control-plane/extensions/tickets/store.ts | 2743 ----------------- .../extensions/tickets/tickets-git-channel.ts | 2031 ------------ .../extensions/tickets/tickets-skill.ts | 247 -- src/control-plane/extensions/tickets/util.ts | 142 - src/control-plane/sdk/config.ts | 30 - src/lib/doctor-generated-files.ts | 7 - src/lib/project-views.ts | 2 - src/mcp/agent-docs.ts | 2 +- src/templates.ts | 18 - src/tui/tickets-tui.ts | 1606 ---------- tests/agent-instruction-source.test.ts | 4 +- tests/agent-onboard-command.test.ts | 14 +- tests/cli-command.test.ts | 45 +- tests/cli-help.test.ts | 19 +- tests/config-command.test.ts | 4 - tests/control-plane-config.test.ts | 1 - tests/control-plane-extensions.test.ts | 10 +- tests/dispatch-command.test.ts | 15 - tests/doctor-command.test.ts | 5 - tests/doctor-generated-files.test.ts | 49 - tests/doctor-tickets-dir-gating.test.ts | 240 -- tests/e2e/README.md | 2 +- tests/e2e/harness.ts | 1 - tests/e2e/scenarios/agent-docs-sync.ts | 106 +- tests/env-command-modern.test.ts | 8 - tests/experimental-gating.test.ts | 8 - tests/hack-gitignore.test.ts | 2 - tests/init-with-command.test.ts | 8 +- tests/lifecycle-json.test.ts | 8 - tests/project-config.test.ts | 15 +- tests/project-down-worktree-safety.test.ts | 4 - tests/project-owner-command.test.ts | 8 - tests/project-up-command.test.ts | 8 - tests/run-exec-branch-default.test.ts | 8 - tests/setup.test.ts | 31 - tests/shared-agent-skill.test.ts | 46 +- tests/tickets-enablement.test.ts | 137 - tests/tickets-extension.test.ts | 715 ----- tests/tickets-git-channel.test.ts | 859 ------ tests/tickets-id-generation.test.ts | 199 -- tests/tickets-store.test.ts | 1445 --------- tests/tickets-util-id-generation.test.ts | 10 - tests/tickets-util.test.ts | 27 - 93 files changed, 109 insertions(+), 16863 deletions(-) delete mode 100644 docs/guides/tickets.md delete mode 100644 src/commands/auth.ts delete mode 100644 src/commands/linear.ts delete mode 100644 src/commands/org.ts delete mode 100644 src/commands/removed-surface.ts delete mode 100644 src/commands/team.ts delete mode 100644 src/commands/tickets.ts delete mode 100644 src/control-plane/extensions/tickets/agent-docs.ts delete mode 100644 src/control-plane/extensions/tickets/commands.ts delete mode 100644 src/control-plane/extensions/tickets/documents.ts delete mode 100644 src/control-plane/extensions/tickets/domain.ts delete mode 100644 src/control-plane/extensions/tickets/enablement.ts delete mode 100644 src/control-plane/extensions/tickets/extension.ts delete mode 100644 src/control-plane/extensions/tickets/provenance.ts delete mode 100644 src/control-plane/extensions/tickets/repo-state.ts delete mode 100644 src/control-plane/extensions/tickets/runs-channel.ts delete mode 100644 src/control-plane/extensions/tickets/sqlite-projection.ts delete mode 100644 src/control-plane/extensions/tickets/store.ts delete mode 100644 src/control-plane/extensions/tickets/tickets-git-channel.ts delete mode 100644 src/control-plane/extensions/tickets/tickets-skill.ts delete mode 100644 src/control-plane/extensions/tickets/util.ts delete mode 100644 src/tui/tickets-tui.ts delete mode 100644 tests/dispatch-command.test.ts delete mode 100644 tests/doctor-tickets-dir-gating.test.ts delete mode 100644 tests/tickets-enablement.test.ts delete mode 100644 tests/tickets-extension.test.ts delete mode 100644 tests/tickets-git-channel.test.ts delete mode 100644 tests/tickets-id-generation.test.ts delete mode 100644 tests/tickets-store.test.ts delete mode 100644 tests/tickets-util-id-generation.test.ts delete mode 100644 tests/tickets-util.test.ts diff --git a/.codex/skills/hack-cli/SKILL.md b/.codex/skills/hack-cli/SKILL.md index f1b5013e..92689982 100644 --- a/.codex/skills/hack-cli/SKILL.md +++ b/.codex/skills/hack-cli/SKILL.md @@ -12,11 +12,11 @@ Use `hack` as the primary interface for local-first development. ## Integration freshness -- These instructions were generated by hack CLI v3.5.0; treat cached rules from another version as potentially stale. +- These instructions were generated by hack CLI v3.5.2; treat cached rules from another version as potentially stale. - At session start, audit project and global integrations with `hack setup sync --all-scopes --check`. -- If anything is stale, missing, or deprecated, run `hack setup sync --all-scopes`, then reload the agent session so cached instructions are replaced. +- If anything is stale or missing, run `hack setup sync --all-scopes`, then reload the agent session so cached instructions are replaced. - Never copy or hand-edit generated Hack rules to refresh them; update the CLI and run the sync command. -- Content revision: `1319d93c89c3` (version alone is not a freshness guarantee). +- Content revision: `9e40aaab2d26` (version alone is not a freshness guarantee). ## Product boundary @@ -190,9 +190,9 @@ Use `hack` as the primary interface for local-first development. ## Agent integration maintenance -- Project-level hack commands auto-check integration drift and attempt auto-sync (project docs, client skills/rules, shared global skills, and MCP). -- When drift is detected, Hack reports it before repair and tells the agent to reload after repair; it never silently leaves the session using cached rules. -- Set `HACK_SETUP_SYNC_MODE=warn` to only warn, or `HACK_SETUP_SYNC_MODE=off` to disable. +- Ordinary Hack commands, `hack update`, and `hack doctor --fix` never inspect, render, repair, or remove agent integration files. +- Use `hack setup sync` only when explicitly choosing to manage project or user docs, skills, rules, hooks, or MCP config. +- Read-only freshness checks are available through `hack setup sync --all-scopes --check`, `hack doctor`, and `hack agent prime`. - Refresh project + user integrations: `hack setup sync --all-scopes` - Audit integration state only: `hack setup sync --all-scopes --check` - Remove generated integration artifacts: `hack setup sync --all-scopes --remove` diff --git a/.cursor/rules/hack.mdc b/.cursor/rules/hack.mdc index 27737fc4..cdf2157d 100644 --- a/.cursor/rules/hack.mdc +++ b/.cursor/rules/hack.mdc @@ -7,11 +7,11 @@ Prefer `hack` when shell access is available. Use MCP only when shell access is ## Integration freshness -- These instructions were generated by hack CLI v3.5.0; treat cached rules from another version as potentially stale. +- These instructions were generated by hack CLI v3.5.2; treat cached rules from another version as potentially stale. - At session start, audit project and global integrations with `hack setup sync --all-scopes --check`. -- If anything is stale, missing, or deprecated, run `hack setup sync --all-scopes`, then reload the agent session so cached instructions are replaced. +- If anything is stale or missing, run `hack setup sync --all-scopes`, then reload the agent session so cached instructions are replaced. - Never copy or hand-edit generated Hack rules to refresh them; update the CLI and run the sync command. -- Content revision: `1319d93c89c3` (version alone is not a freshness guarantee). +- Content revision: `9e40aaab2d26` (version alone is not a freshness guarantee). ## Product boundary @@ -91,9 +91,9 @@ Prefer `hack` when shell access is available. Use MCP only when shell access is ## Agent integration maintenance -- Project-level hack commands auto-check integration drift and attempt auto-sync (project docs, client skills/rules, shared global skills, and MCP). -- When drift is detected, Hack reports it before repair and tells the agent to reload after repair; it never silently leaves the session using cached rules. -- Set `HACK_SETUP_SYNC_MODE=warn` to only warn, or `HACK_SETUP_SYNC_MODE=off` to disable. +- Ordinary Hack commands, `hack update`, and `hack doctor --fix` never inspect, render, repair, or remove agent integration files. +- Use `hack setup sync` only when explicitly choosing to manage project or user docs, skills, rules, hooks, or MCP config. +- Read-only freshness checks are available through `hack setup sync --all-scopes --check`, `hack doctor`, and `hack agent prime`. - Refresh project + user integrations: `hack setup sync --all-scopes` - Audit integration state only: `hack setup sync --all-scopes --check` - Remove generated integration artifacts: `hack setup sync --all-scopes --remove` diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 016f8f59..83fe38fc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,7 +52,7 @@ jobs: --tag hack-runtime-ci:slim - name: Smoke slim runtime image defaults run: | - docker run --rm --entrypoint sh hack-runtime-ci:slim -lc 'command -v bun >/dev/null && command -v hack >/dev/null && test "${HACK_EXECUTION_MODE}" = "codex" && test "${HACK_DAEMON_DISABLE_DOCKER_EVENTS}" = "1" && test "${HACK_SETUP_SYNC_MODE}" = "warn" && hack --help >/tmp/hack-help.txt && grep -q "Usage:" /tmp/hack-help.txt' + docker run --rm --entrypoint sh hack-runtime-ci:slim -lc 'command -v bun >/dev/null && command -v hack >/dev/null && test "${HACK_EXECUTION_MODE}" = "codex" && test "${HACK_DAEMON_DISABLE_DOCKER_EVENTS}" = "1" && hack --help >/tmp/hack-help.txt && grep -q "Usage:" /tmp/hack-help.txt' - name: Smoke slim runtime mounted-project env flow run: bash scripts/portable-container-smoke.sh hack-runtime-ci:slim linux/amd64 diff --git a/AGENTS.md b/AGENTS.md index 44041bc8..bca3a2ea 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -210,11 +210,11 @@ Most formatting and common issues are automatically fixed by Biome. Run `bun x u Use `hack` as the single interface for local-first runtime orchestration (compose, DNS/TLS, logs, env, and persistent project workspaces). Integration freshness: -- These instructions were generated by hack CLI v3.5.0; treat cached rules from another version as potentially stale. +- These instructions were generated by hack CLI v3.5.2; treat cached rules from another version as potentially stale. - At session start, audit project and global integrations with `hack setup sync --all-scopes --check`. -- If anything is stale, missing, or deprecated, run `hack setup sync --all-scopes`, then reload the agent session so cached instructions are replaced. +- If anything is stale or missing, run `hack setup sync --all-scopes`, then reload the agent session so cached instructions are replaced. - Never copy or hand-edit generated Hack rules to refresh them; update the CLI and run the sync command. -- Content revision: `1319d93c89c3` (version alone is not a freshness guarantee). +- Content revision: `9e40aaab2d26` (version alone is not a freshness guarantee). Product boundary: - Supported v3 surface: project init, up/down/restart, open, logs, env, host exec/shell, sessions, doctor, and daemon. @@ -366,9 +366,9 @@ Docker compose notes: - Use `docker compose -f .hack/docker-compose.yml exec ` only if you need exec into a running container. Agent integration maintenance: -- Project-level hack commands auto-check integration drift and attempt auto-sync (project docs, client skills/rules, shared global skills, and MCP). -- When drift is detected, Hack reports it before repair and tells the agent to reload after repair; it never silently leaves the session using cached rules. -- Set `HACK_SETUP_SYNC_MODE=warn` to only warn, or `HACK_SETUP_SYNC_MODE=off` to disable. +- Ordinary Hack commands, `hack update`, and `hack doctor --fix` never inspect, render, repair, or remove agent integration files. +- Use `hack setup sync` only when explicitly choosing to manage project or user docs, skills, rules, hooks, or MCP config. +- Read-only freshness checks are available through `hack setup sync --all-scopes --check`, `hack doctor`, and `hack agent prime`. - Refresh project + user integrations: `hack setup sync --all-scopes` - Audit integration state only: `hack setup sync --all-scopes --check` - Remove generated integration artifacts: `hack setup sync --all-scopes --remove` diff --git a/CLAUDE.md b/CLAUDE.md index e78dc3c7..8f729cae 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -80,11 +80,11 @@ This project uses Obsidian for project context, specs, research, and progress tr Use `hack` as the single interface for local-first runtime orchestration (compose, DNS/TLS, logs, env, and persistent project workspaces). Integration freshness: -- These instructions were generated by hack CLI v3.5.0; treat cached rules from another version as potentially stale. +- These instructions were generated by hack CLI v3.5.2; treat cached rules from another version as potentially stale. - At session start, audit project and global integrations with `hack setup sync --all-scopes --check`. -- If anything is stale, missing, or deprecated, run `hack setup sync --all-scopes`, then reload the agent session so cached instructions are replaced. +- If anything is stale or missing, run `hack setup sync --all-scopes`, then reload the agent session so cached instructions are replaced. - Never copy or hand-edit generated Hack rules to refresh them; update the CLI and run the sync command. -- Content revision: `1319d93c89c3` (version alone is not a freshness guarantee). +- Content revision: `9e40aaab2d26` (version alone is not a freshness guarantee). Product boundary: - Supported v3 surface: project init, up/down/restart, open, logs, env, host exec/shell, sessions, doctor, and daemon. @@ -236,9 +236,9 @@ Docker compose notes: - Use `docker compose -f .hack/docker-compose.yml exec ` only if you need exec into a running container. Agent integration maintenance: -- Project-level hack commands auto-check integration drift and attempt auto-sync (project docs, client skills/rules, shared global skills, and MCP). -- When drift is detected, Hack reports it before repair and tells the agent to reload after repair; it never silently leaves the session using cached rules. -- Set `HACK_SETUP_SYNC_MODE=warn` to only warn, or `HACK_SETUP_SYNC_MODE=off` to disable. +- Ordinary Hack commands, `hack update`, and `hack doctor --fix` never inspect, render, repair, or remove agent integration files. +- Use `hack setup sync` only when explicitly choosing to manage project or user docs, skills, rules, hooks, or MCP config. +- Read-only freshness checks are available through `hack setup sync --all-scopes --check`, `hack doctor`, and `hack agent prime`. - Refresh project + user integrations: `hack setup sync --all-scopes` - Audit integration state only: `hack setup sync --all-scopes --check` - Remove generated integration artifacts: `hack setup sync --all-scopes --remove` diff --git a/docker/slim-runtime/Dockerfile b/docker/slim-runtime/Dockerfile index fef9b09e..17ef7f3d 100644 --- a/docker/slim-runtime/Dockerfile +++ b/docker/slim-runtime/Dockerfile @@ -40,7 +40,6 @@ RUN chmod +x /usr/local/bin/hack \ ENV HOME=/var/lib/hack ENV HACK_EXECUTION_MODE=codex ENV HACK_DAEMON_DISABLE_DOCKER_EVENTS=1 -ENV HACK_SETUP_SYNC_MODE=warn WORKDIR /workspace VOLUME ["/var/lib/hack", "/workspace"] diff --git a/docs/README.md b/docs/README.md index fc4a54b4..e26c1fab 100644 --- a/docs/README.md +++ b/docs/README.md @@ -39,7 +39,7 @@ Use this path for: - full command reference: [CLI overview](cli.md) plus the generated [CLI reference](reference/cli.md) (every command and flag) - extension configuration and authoring -- tickets and integrations +- integrations - gateway API and SDK details This section is easy to find, but it does not lead the product story. diff --git a/docs/architecture.md b/docs/architecture.md index 75b88bd8..f79d5d61 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -243,8 +243,7 @@ graph LR The control plane keeps the core CLI minimal while adding features as extensions. `hackd` loads extension manifests and exposes their APIs; the CLI dispatches extension commands via `hack x`. -Builtin extensions: **Tickets** (opt-in local ticket store — disabled by default, requires enabling -before use), **Supervisor** (job execution + streaming for agents), **Gateway** (optional HTTP/WS +Builtin extensions: **Supervisor** (job execution + streaming for agents), **Gateway** (optional HTTP/WS access to `hackd`), **Cloudflare**, and **Tailscale** (exposure/tunnel helpers). > Gateway, remote, node, and dispatch surfaces are experimental and unsupported. They are hidden @@ -258,7 +257,6 @@ graph LR Hackd --> ExtMgr["ExtensionManager"] ExtMgr --> Gateway["Gateway"] ExtMgr --> Supervisor["Supervisor"] - ExtMgr --> Tickets["Tickets (opt-in)"] ExtMgr --> Cloudflare["Cloudflare"] ExtMgr --> Tailscale["Tailscale"] Remote["Remote client"] -->|HTTP/WS| Gateway @@ -319,7 +317,7 @@ one, or set `worktree.auto_branch=false` to opt into the base instance explicitl - `hack.config.json` - `hack.branches.json` (optional) - `.gitignore` (committed, self-healing on `init`/`up`; covers machine-local generated files — - `.internal/`, `.branch/`, `.env`, `.env.state.json`, `hack.env*.local.yaml`, `tickets/`. If generated files + `.internal/`, `.branch/`, `.env`, `.env.state.json`, and `hack.env*.local.yaml`. If generated files leaked into git, `hack doctor --fix` untracks them without deleting them from disk.) - `hack.env.default.yaml` plus optional `hack.env..yaml` (committed env) - `hack.env.local.yaml` / `hack.env..local.yaml` (worktree-local overrides) diff --git a/docs/cli.md b/docs/cli.md index 0a772891..f16600b2 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -24,7 +24,6 @@ terminal). - `hack daemon` — optional local daemon for faster JSON status/ps - `hack agent onboard` — agent-assisted onboarding for existing projects - `hack setup` — install/refresh agent integrations (Cursor rules, Claude hooks, Codex skill, MCP) -- `hack tickets` — deprecated compatibility surface for existing Tickets data Interactive diagnostics use compact status rows: healthy groups stay on one line, while warnings and errors expand with wrapped detail and recovery guidance. `hack doctor --json` remains the stable, @@ -35,16 +34,8 @@ Run `hack help` for the full command list, or `hack help --all` to include hidde experimental commands. Every command and flag on this page is also in the generated [CLI reference](reference/cli.md). -## Removed surfaces - -These commands remain only as migration stubs that print the removal reason and any replacement: - -- `hack auth` -- `hack linear` -- `hack org` -- `hack team` - -Built-in GitHub workflows were also removed. Use native `git` and `gh`. +Hosted auth/account/org/team, built-in GitHub and Linear workflows, and Hack Tickets are outside the +CLI surface. Use native `git` and `gh` for repository collaboration. ## Unsupported experimental @@ -68,9 +59,9 @@ See [Beta workflows](beta.md) for guides on this surface. Generated agent docs, Cursor rules, Codex skills, and the shared `~/.ai/skills/hack-cli` skill carry the Hack CLI version that generated them. Audit both project and global surfaces with -`hack setup sync --all-scopes --check`; repair them with `hack setup sync --all-scopes`, then reload -the agent session so it stops using cached guidance. Interactive project commands also report drift -before auto-repair instead of repairing silently. +`hack setup sync --all-scopes --check`; repair them with the explicit +`hack setup sync --all-scopes`, then reload the agent session so it stops using cached guidance. +Ordinary commands, `hack update`, and `hack doctor --fix` never inspect or rewrite these files. ## First-run path @@ -309,8 +300,7 @@ materialized `.hack/.env` or `.hack/.env.state.json` is stale and should be rege ## Project files Hack owns a committed `.hack/.gitignore` (self-healing on `init`/`up`) that ignores machine-local -generated files (`.internal/`, `.branch/`, `.env`, `.env.state.json`, `hack.env*.local.yaml`, -`tickets/`). Keep +generated files (`.internal/`, `.branch/`, `.env`, `.env.state.json`, `hack.env*.local.yaml`). Keep it committed. If generated files ever leak into git, `hack doctor --fix` untracks them (the files stay on disk). Runtime metadata is written to `.internal/compose.runtime.override.yml` for the base instance and `.branch/compose..runtime.override.yml` for branch instances. See @@ -318,23 +308,6 @@ instance and `.branch/compose..runtime.override.yml` for branch instance The global config root defaults to `~/.hack`; override it with `HACK_HOME`. -## Tickets - -Hack Tickets is deprecated. It is no longer installed into agent instructions or skills, and -`hack setup sync --all-scopes` removes legacy Tickets agent artifacts. Existing commands remain -available only for compatibility and migration when the extension is explicitly enabled. - -```bash -hack tickets create --title "Investigate flaky lifecycle cleanup" -hack tickets list -hack tickets show T-00001 -hack tickets sync -``` - -`hack tickets setup` now removes deprecated agent skills/instruction blocks and performs compatible -storage hygiene; it does not enable Tickets or reinstall guidance. See the migration reference: -[Tickets](guides/tickets.md). - ## Lifecycle Use `.hack/hack.config.json` `lifecycle` or `startup` for host-side setup instead of ad-hoc diff --git a/docs/docs-ia.md b/docs/docs-ia.md index eed86c9f..170a7dfb 100644 --- a/docs/docs-ia.md +++ b/docs/docs-ia.md @@ -63,7 +63,6 @@ control-plane SDK, and API-level detail for the beta surface. - `docs/extensions.md` — extension model, dispatch (`hack x `), built-in extensions - `docs/integrations.md` — what shipped, what was removed, optional agent-setup helpers - `docs/guides/create-extension.md` — how to author a new extension -- `docs/guides/tickets.md` — deprecated compatibility and migration reference - `docs/gateway-api.md` — gateway HTTP/WS API surface (unsupported experimental) - `docs/sdk.md` — control-plane SDK diff --git a/docs/env.md b/docs/env.md index 51cf5c96..014ac521 100644 --- a/docs/env.md +++ b/docs/env.md @@ -51,7 +51,6 @@ worktrees inherit the rules with zero setup. It covers (patterns relative to - `.env.state.json` - `hack.env.local.yaml` - `hack.env.*.local.yaml` -- `tickets/` (deprecated Tickets compatibility cache) How it is maintained: diff --git a/docs/extensions.md b/docs/extensions.md index 0f0b8f7c..487f25fd 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -11,16 +11,11 @@ built-in GitHub integration, or built-in Linear integration. ## Scope -- Deprecated compatibility helpers: - - Tickets: `hack x tickets ...` (existing data migration only) - Unsupported experimental workflows: - Gateway: `hack x gateway ...` - Supervisor: `hack x supervisor ...` - Remote node / dispatch flows documented under [beta.md](beta.md) -- Retired from the supported product: - - `hack auth` - - `hack linear` - - built-in GitHub browser/profile flows +- Hosted auth, GitHub, Linear, and Tickets integrations are not registered. ## Behavior @@ -37,20 +32,13 @@ built-in GitHub integration, or built-in Linear integration. ## Built-in Extensions -- Tickets (deprecated; disabled unless `controlPlane.extensions["dance.hack.tickets"].enabled` is - explicitly set to `true`; no agent skills or instructions are installed): - - `hack x tickets setup|create|update|comment|review-note|document|list|show|status|resolve-conflict|sync|tui` - - Also available as the top-level alias `hack tickets ` (see - [docs/guides/tickets.md](guides/tickets.md)). - - `setup` removes legacy Tickets guidance and repairs storage hygiene; it no longer enables the - extension or installs agent integrations. - Unsupported experimental: - `hack x gateway token-create|token-list|token-revoke` - `hack x supervisor job-create|job-list|job-show|job-tail|job-attach|job-cancel|shell` - `hack x cloudflare ...` - `hack x tailscale ...` -GitHub and Linear are intentionally not part of the shipped built-in extension set for Hack v3. +GitHub, Linear, and Tickets are intentionally not part of the shipped built-in extension set. ## Dispatch Model diff --git a/docs/guides/codex-managed-environments.md b/docs/guides/codex-managed-environments.md index b5bf0267..0c428116 100644 --- a/docs/guides/codex-managed-environments.md +++ b/docs/guides/codex-managed-environments.md @@ -117,7 +117,6 @@ binary and bundled assets under `~/.hack/` (override with `HACK_INSTALL_BIN`), a - `HACK_EXECUTION_MODE=codex` - `HACK_DAEMON_DISABLE_DOCKER_EVENTS=1` -- `HACK_SETUP_SYNC_MODE=warn` - `HACK_ASSETS_DIR` pointed at the installed assets For reproducible CI, pin the release with `HACK_INSTALL_TAG` or `HACK_INSTALL_VERSION` (and diff --git a/docs/guides/tickets.md b/docs/guides/tickets.md deleted file mode 100644 index 11aa8867..00000000 --- a/docs/guides/tickets.md +++ /dev/null @@ -1,294 +0,0 @@ -# Tickets (git-backed) - -This page is part of [Extensions & reference](../reference.md). -If you are learning the core local product flow, start with [Core docs](../core.md). - -> **Deprecated.** Hack Tickets remains available only for compatibility and migration of existing -> data. It is no longer installed into agent instructions or skills. New projects should use the -> tracker selected by the project instead. - -The tickets extension is a lightweight, git-backed ticket log intended for small teams and solo dev. -It stores events in a dedicated git ref (`refs/hack/tickets` by default, hidden from branch lists) so -ticket history is versioned and syncable without requiring an external service. - -- CLI namespace: `tickets` -- Extension id: `dance.hack.tickets` -- Storage: `.hack/tickets/` (local working state) + a git ref for syncing - -## Compatibility enablement - -Existing repositories must explicitly enable the extension in `.hack/hack.config.json` or global -config. `hack x tickets setup` no longer enables it and no longer installs skills or agent-doc -snippets. The setup command now removes those deprecated artifacts and keeps storage hygiene usable -for migration. - -From inside an existing Tickets repo, clean up deprecated agent integrations: - -```bash -hack x tickets setup -``` - -Options: -- `--global` audits or removes the deprecated user-scoped Tickets skill. -- `--agents` / `--claude` / `--all` select legacy agent-doc blocks to audit or remove. -- `--check` exits non-zero when deprecated Tickets guidance is still installed. -- `--remove` is accepted explicitly; the default setup action also removes agent guidance. -- `--json` prints a machine-readable result shaped like - `{ skill, docs, repo: { gitignore, tracking } }` instead of the human-readable summary. - -Notes: -- Tickets commands may prompt for repository storage hygiene, but never install agent docs or skills. -- Setup also prompts to repair legacy tickets branches or stray files in the tickets ref. -- In `--json` mode this setup-health check is skipped entirely and silently — no warning is - printed. In non-interactive terminals (no TTY, or `gum` unavailable) the CLI instead prints a - `Tickets setup incomplete: ...` warning rather than prompting. Note that the global - `--no-interactive` flag / `HACK_NO_INTERACTIVE` env var is **not** consulted by this check — it - gates purely on TTY-and-`gum`-availability, independent of that flag. - -### Manual / global-only enable - -Manually editing config is mainly useful for enabling tickets globally (so it's available in every -project) or for config-only workflows where you don't want to run `setup` yet. - -Enable the extension globally: - -```bash -hack config set --global 'controlPlane.extensions["dance.hack.tickets"].enabled' true -``` - -Or enable per-project by adding `.hack/hack.config.json`: - -```json -{ - "$schema": "https://schemas.hack/hack.config.schema.json", - "name": "my-project", - "dev_host": "my-project.hack", - "controlPlane": { - "extensions": { - "dance.hack.tickets": { "enabled": true } - } - } -} -``` - -This `controlPlane.extensions["dance.hack.tickets"].enabled` flag is **extension enablement** — -it's what gates whether any `hack x tickets` / `hack tickets` command runs at all. It is a -different flag from `controlPlane.tickets.git.enabled` (see [Configuration](#configuration)), -which is **git-sync enablement** for an already-enabled extension. Don't conflate the two: an -extension can be enabled with git-sync disabled (fully local, no ref push/pull), though the -default for both is on. - -## Basic usage - -Every `hack x tickets ` below also works as the top-level alias `hack tickets ` -(for example `hack tickets list`). The alias requires the extension to already be enabled. `setup` -can run while disabled, but it does not enable the extension. - -Create a ticket: - -```bash -hack x tickets create --title "Investigate flaky test" --body "Found in CI on macOS" -``` - -For big unstructured bodies, prefer a file or stdin: - -```bash -hack x tickets create --title "Deep dive" --body-file ./notes.md -``` - -```bash -echo "long body..." | hack x tickets create --title "Deep dive" --body-stdin -``` - -`create` and `update` also accept: `--owner`, `--source`, `--assignee` / `--clear-assignee`, -`--tags` / `--tag` / `--clear-tags`, `--actor`, `--json`, and external-linkage flags -(`--external-system`, `--external-id`, `--external-key`, `--external-url`, -`--external-project-id`, `--external-project-name`, `--external-team-id`) for linking a ticket to -an external tracker record. - -Open the TUI: - -```bash -hack x tickets tui -``` - -List tickets: - -```bash -hack x tickets list -``` - -Show a ticket: - -```bash -hack x tickets show -``` - -Update a ticket: - -```bash -hack x tickets update --title "Investigate flaky test in CI" --body-file ./notes.md -``` - -Change status: - -```bash -hack x tickets status in_progress -``` - -Dependencies: - -```bash -hack x tickets create --title "Ship API" --depends-on --blocks -hack x tickets update --depends-on --blocks -hack x tickets update --clear-depends-on --clear-blocks -``` - -Append an immutable comment (unlike `update`, comments are never edited or removed, only added): - -```bash -hack x tickets comment --body "Repro'd on CI, filing upstream issue" [--source hack] -``` - -Add a review note: - -```bash -hack x tickets review-note --body "LGTM once tests are green" -``` - -Attach a structured document to a ticket (`--kind` is required; `--role` defaults to the kind): - -```bash -hack x tickets document --kind spec --role spec --body-file ./spec.md -``` - -`--kind` accepts `description | spec | notes`; `--role` accepts `description | spec | notes | handoff`. - -Resolve a sync conflict recorded during `sync`: - -```bash -hack x tickets resolve-conflict --conflict-id --resolution accept_local --summary "kept local edit" -``` - -`--resolution` accepts `accept_local | accept_remote | merged | ignore`. - -Sync to git remote (normalizes logs and pushes the tickets ref when a remote exists): - -```bash -hack x tickets sync -``` - -Recommended body template (Markdown): - -```md -## Context -## Goals -## Notes -## Links -``` - -Tip: use `--body-stdin` for multi-line markdown. - -## How it works - -- Ticket history is an append-only event log (`ticket.created`, etc.) stored as monthly JSONL files. -- Each event carries a normalized journal envelope (`eventId`, schema version, occurrence/recording times, source metadata, and idempotency key). -- The journal is the portable source of truth for tickets. -- The extension projects journal state into a local SQLite cache for durable reads and rebuilds that cache automatically when it is missing or stale. -- Ticket writes automatically commit and push to the tickets ref when git sync is enabled and a remote exists. -- `sync` normalizes the event logs, commits, and pushes the tickets ref. -- Read paths fall back to the last healthy local tickets state when the git remote is temporarily unreachable, so `list` and `show` still work offline after an initial hydration. -- When git remote auth fails, tickets surfaces return explicit SSH guidance and do not wait on interactive prompts. Check with `ssh -T git@github.com`. - -### Storage layout - -Local tickets state under your project: - -- `.hack/tickets/events/events-YYYY-MM.jsonl` — event log segments (UTC month) -- `.hack/tickets/projection.sqlite` — local SQLite projection cache rebuilt from the journal -- `.hack/tickets/git/bare.git` — a bare repo used to manage the tickets ref -- `.hack/tickets/git/worktree` — a worktree used for reading/writing ticket data -- `.hack/tickets/git/worktree/.hack/tickets/events/events-YYYY-MM.jsonl` — local checkout of the durable event log - -Path inside the tickets ref: - -- `.hack/tickets/events/events-YYYY-MM.jsonl` — portable event log segments (UTC month) - -### Durability and portability - -The durable portable layer is the event log in the tickets ref. - -- inside the ref, `.hack/tickets/events/*.jsonl` is the source of truth -- locally, those files are materialized under `.hack/tickets/git/worktree/.hack/tickets/events/*.jsonl` -- `list`, `show`, and related views are rebuilt by replaying the event log -- deleting local projection state must not lose ticket history - -Rebuildable local state includes: - -- `.hack/tickets/git/bare.git` -- `.hack/tickets/git/worktree` -- `.hack/tickets/git/.mutation.lock` — coordination lock for concurrent tickets writes -- `.hack/tickets/git/bare.git/index.lock` — transient git index lock; if left stale (e.g. after a - crash), the CLI removes it automatically and retries - -These paths exist to coordinate sync and local writes. They can be recreated from the repo and the tickets ref. - -### Hidden refs and legacy branch compatibility - -By default, tickets sync to the hidden ref `refs/hack/tickets`. - -Compatibility rules: - -- the CLI fetches the hidden ref first -- if the hidden ref is missing, it falls back to the legacy branch ref `refs/heads/hack/tickets` -- when legacy ref data is imported, event logs are deduped by `eventId` and normalized before the next push - -If your remote rejects hidden refs, set `controlPlane.tickets.git.refMode` to `heads` and use `refs/heads/hack/tickets` instead. - -Portability rules: - -- Only the journal under `.hack/tickets/events/` is portable ticket state. -- The hidden ref stores only the journal tree; it does not include `projection.sqlite` or other local cache files. -- After `hack x tickets sync`, the checked-out tickets worktree materializes that journal under `.hack/tickets/git/worktree/.hack/tickets/events/`. -- The SQLite projection is local-only and can be deleted safely. -- After sync or clone, peers rebuild `.hack/tickets/projection.sqlite` from the journal on first read. - -## Configuration - -Tickets git configuration lives under `controlPlane.tickets.git`. This `enabled` flag is -**git-sync enablement**, not extension enablement — it only takes effect once -`controlPlane.extensions["dance.hack.tickets"].enabled` is already `true` (see -[Manual / global-only enable](#manual--global-only-enable)). With git-sync `enabled: true` (the -default) writes auto-commit and push to the tickets ref; with it `false`, tickets stays fully -local. - -Defaults: - -- `enabled: true` -- `branch: "hack/tickets"` -- `remote: "origin"` -- `forceBareClone: false` -- `refMode: "hidden"` - -Example override: - -```bash -hack config set --global 'controlPlane.tickets.git.branch' 'hack/tickets' -hack config set --global 'controlPlane.tickets.git.remote' 'origin' -hack config set --global 'controlPlane.tickets.git.refMode' 'hidden' -``` - -Notes: -- If your remote rejects hidden refs, set `refMode` to `heads` to use `refs/heads/` and - protect the branch in your git hosting UI. - -## When to use this - -Use tickets when you want: -- A local-first backlog that works offline. -- A shared ticket stream without adding another external tracker. -- A simple paper trail for small projects. - -Don’t use it when: -- You need multi-user assignment, workflow states, or strict permissions. -- You need rich issue templates or deep integrations. diff --git a/docs/integrations.md b/docs/integrations.md index b27a0dc1..5574f8e7 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -14,17 +14,8 @@ path and reason for stale, missing, or failed artifacts. Exit status remains the and the individual `hack setup cursor|claude|codex|agents|mcp --check` commands remain available when you need per-artifact detail. -What was removed: - -- Hack Tickets agent integration; legacy commands remain compatibility-only and are deprecated -- built-in GitHub integration -- built-in Linear integration -- hosted auth/account/org/team surfaces -- web dashboard control plane - -Removed surfaces still exist as explicit tombstone commands (`hack auth`, `hack org`, `hack team`, -`hack linear`) that print a removal reason and the replacement, so hitting them redirects you -instead of failing hard. +What does not ship: Hack Tickets, built-in GitHub or Linear integrations, hosted +auth/account/org/team surfaces, and the web dashboard control plane. Recommended replacements: @@ -40,5 +31,6 @@ surfaces. Generated guidance identifies the CLI version that rendered it. - Repair project and global integrations: `hack setup sync --all-scopes` - After repair: reload the agent session so cached rules are discarded -Interactive project commands announce detected drift before auto-repair. `hack agent prime` performs -the same read-only audit at session start and prints a warning before any Hack operating guidance. +Ordinary commands, `hack update`, and `hack doctor --fix` do not inspect or modify these surfaces. +`hack agent prime` performs a read-only audit at session start and prints a warning before any Hack +operating guidance. diff --git a/docs/reference.md b/docs/reference.md index ac99302f..b8cc2759 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -15,12 +15,11 @@ - [Sessions](./sessions.md) - [Agent-first setup](./guides/agent-first-setup.md) -## Extensions & legacy compatibility +## Extensions - [Extensions](./extensions.md) - [Creating an extension](./guides/create-extension.md) - [Integrations](./integrations.md) -- [Tickets migration reference (deprecated compatibility surface)](./guides/tickets.md) - [SDK](./sdk.md) ## Experimental / beta diff --git a/docs/reference/cli.md b/docs/reference/cli.md index afda0668..44a3c124 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -1645,9 +1645,8 @@ hack setup [options] | `hack setup cursor` | Install Cursor rules for hack CLI usage | | `hack setup claude` | Install Claude Code hooks for hack CLI usage | | `hack setup codex` | Install Codex skill for hack CLI usage | -| `hack setup tickets` | Remove or audit the deprecated Hack Tickets skill | | `hack setup agents` | Install AGENTS.md / CLAUDE.md snippets for hack CLI usage | -| `hack setup sync` | Refresh project/global agent guidance and remove deprecated artifacts | +| `hack setup sync` | Refresh project/global agent guidance | | `hack setup mcp` | Install MCP configs for hack CLI usage (no-shell only) | ### Options @@ -1744,28 +1743,6 @@ hack setup codex [options] | `--help, -h` | Show help | | `--version, -v` | Show version | -## `hack setup tickets` - -Remove or audit the deprecated Hack Tickets skill - -### Usage - -```bash -hack setup tickets [options] -``` - -### Options - -| Option | Description | -| --- | --- | -| `--path, -p ` | Run a project command against a repo path (overrides cwd search) | -| `--global` | Use global (user) scope instead of project scope | -| `--check` | Check whether integration is installed | -| `--remove` | Remove integration files/config | -| `--no-interactive` | Never prompt: apply documented defaults or fail with E_INTERACTIVE_REQUIRED (also via HACK_NO_INTERACTIVE=1) | -| `--help, -h` | Show help | -| `--version, -v` | Show version | - ## `hack setup agents` Install AGENTS.md / CLAUDE.md snippets for hack CLI usage @@ -1792,7 +1769,7 @@ hack setup agents [options] ## `hack setup sync` -Refresh project/global agent guidance and remove deprecated artifacts +Refresh project/global agent guidance ### Usage @@ -2050,122 +2027,6 @@ hack secrets delete [name] [options] ## Integrations -## `hack auth [args...]` - -Removed: Hack account sign-in no longer ships with the local-first CLI - -### Usage - -```bash -hack auth [args...] [options] -``` - -Removed in v3: Hack no longer ships a centralized auth broker or hosted account surface. - -Migration: Local workflows no longer require Hack account sign-in. Use local project ownership, tickets, env, and sessions directly. - -### Arguments - -| Arg | Description | -| --- | --- | -| `args` | Legacy subcommand and arguments | - -### Options - -| Option | Description | -| --- | --- | -| `--no-interactive` | Never prompt: apply documented defaults or fail with E_INTERACTIVE_REQUIRED (also via HACK_NO_INTERACTIVE=1) | -| `--help, -h` | Show help | -| `--version, -v` | Show version | - - -## `hack org [args...]` - -Removed: hosted organization management is no longer part of Hack - -### Usage - -```bash -hack org [args...] [options] -``` - -Removed in v3: Hack v3 dropped centralized org and membership administration with the broker-backed control plane. - -Migration: Keep project ownership local and use native git collaboration plus repo-local tickets instead. - -### Arguments - -| Arg | Description | -| --- | --- | -| `args` | Legacy subcommand and arguments | - -### Options - -| Option | Description | -| --- | --- | -| `--no-interactive` | Never prompt: apply documented defaults or fail with E_INTERACTIVE_REQUIRED (also via HACK_NO_INTERACTIVE=1) | -| `--help, -h` | Show help | -| `--version, -v` | Show version | - - -## `hack team [args...]` - -Removed: hosted team management is no longer part of Hack - -### Usage - -```bash -hack team [args...] [options] -``` - -Removed in v3: Hack v3 removed broker-backed team and membership lifecycle management. - -Migration: Use repo-local ownership plus native collaboration tools outside Hack. - -### Arguments - -| Arg | Description | -| --- | --- | -| `args` | Legacy subcommand and arguments | - -### Options - -| Option | Description | -| --- | --- | -| `--no-interactive` | Never prompt: apply documented defaults or fail with E_INTERACTIVE_REQUIRED (also via HACK_NO_INTERACTIVE=1) | -| `--help, -h` | Show help | -| `--version, -v` | Show version | - - -## `hack linear [args...]` - -Removed: Linear integration is no longer part of Hack v3 - -### Usage - -```bash -hack linear [args...] [options] -``` - -Removed in v3: Hack v3 removed hosted planning and sync integrations to stay self-contained and local-first. - -Migration: Use repo-local tickets for optional in-repo tracking and keep Linear outside Hack. - -### Arguments - -| Arg | Description | -| --- | --- | -| `args` | Legacy subcommand and arguments | - -### Options - -| Option | Description | -| --- | --- | -| `--no-interactive` | Never prompt: apply documented defaults or fail with E_INTERACTIVE_REQUIRED (also via HACK_NO_INTERACTIVE=1) | -| `--help, -h` | Show help | -| `--version, -v` | Show version | - - ## `hack env` Set project env vars and local secrets @@ -2557,44 +2418,6 @@ Start an interactive host shell with the selected Hack env overlay injected. Use | `--version, -v` | Show version | -## `hack tickets [args...]` - -Deprecated: legacy repo-local Tickets compatibility commands - -### Usage - -```bash -hack tickets [args...] [options] -``` - -Usage: - hack tickets list - hack tickets create --title "..." - hack tickets show - hack tickets status - hack tickets update [--title "..."] [--body "..."] - hack tickets sync - hack tickets setup - hack tickets tui - -Deprecated compatibility surface. It is no longer installed into agent instructions or skills. -Alias for `hack x tickets `. Requires extension enabled. - -### Arguments - -| Arg | Description | -| --- | --- | -| `args` | | - -### Options - -| Option | Description | -| --- | --- | -| `--no-interactive` | Never prompt: apply documented defaults or fail with E_INTERACTIVE_REQUIRED (also via HACK_NO_INTERACTIVE=1) | -| `--help, -h` | Show help | -| `--version, -v` | Show version | - - ## Extensions ## `hack x [args...]` diff --git a/examples/basic/AGENTS.md b/examples/basic/AGENTS.md index 29a8fe2c..e7795766 100644 --- a/examples/basic/AGENTS.md +++ b/examples/basic/AGENTS.md @@ -6,7 +6,7 @@ Use `hack` as the single interface for local-first runtime orchestration (compos Integration freshness: - These instructions were generated by hack CLI v3.3.5; treat cached rules from another version as potentially stale. - At session start, audit project and global integrations with `hack setup sync --all-scopes --check`. -- If anything is stale, missing, or deprecated, run `hack setup sync --all-scopes`, then reload the agent session so cached instructions are replaced. +- If anything is stale or missing, run `hack setup sync --all-scopes`, then reload the agent session so cached instructions are replaced. - Never copy or hand-edit generated Hack rules to refresh them; update the CLI and run the sync command. - Content revision: `b8663fad3ef4` (version alone is not a freshness guarantee). @@ -156,9 +156,9 @@ Docker compose notes: - Use `docker compose -f .hack/docker-compose.yml exec ` only if you need exec into a running container. Agent integration maintenance: -- Project-level hack commands auto-check integration drift and attempt auto-sync (project docs, client skills/rules, shared global skills, and MCP). -- When drift is detected, Hack reports it before repair and tells the agent to reload after repair; it never silently leaves the session using cached rules. -- Set `HACK_SETUP_SYNC_MODE=warn` to only warn, or `HACK_SETUP_SYNC_MODE=off` to disable. +- Ordinary Hack commands, `hack update`, and `hack doctor --fix` never inspect, render, repair, or remove agent integration files. +- Use `hack setup sync` only when explicitly choosing to manage project or user docs, skills, rules, hooks, or MCP config. +- Read-only freshness checks are available through `hack setup sync --all-scopes --check`, `hack doctor`, and `hack agent prime`. - Refresh project + user integrations: `hack setup sync --all-scopes` - Audit integration state only: `hack setup sync --all-scopes --check` - Remove generated integration artifacts: `hack setup sync --all-scopes --remove` diff --git a/examples/basic/CLAUDE.md b/examples/basic/CLAUDE.md index 29a8fe2c..e7795766 100644 --- a/examples/basic/CLAUDE.md +++ b/examples/basic/CLAUDE.md @@ -6,7 +6,7 @@ Use `hack` as the single interface for local-first runtime orchestration (compos Integration freshness: - These instructions were generated by hack CLI v3.3.5; treat cached rules from another version as potentially stale. - At session start, audit project and global integrations with `hack setup sync --all-scopes --check`. -- If anything is stale, missing, or deprecated, run `hack setup sync --all-scopes`, then reload the agent session so cached instructions are replaced. +- If anything is stale or missing, run `hack setup sync --all-scopes`, then reload the agent session so cached instructions are replaced. - Never copy or hand-edit generated Hack rules to refresh them; update the CLI and run the sync command. - Content revision: `b8663fad3ef4` (version alone is not a freshness guarantee). @@ -156,9 +156,9 @@ Docker compose notes: - Use `docker compose -f .hack/docker-compose.yml exec ` only if you need exec into a running container. Agent integration maintenance: -- Project-level hack commands auto-check integration drift and attempt auto-sync (project docs, client skills/rules, shared global skills, and MCP). -- When drift is detected, Hack reports it before repair and tells the agent to reload after repair; it never silently leaves the session using cached rules. -- Set `HACK_SETUP_SYNC_MODE=warn` to only warn, or `HACK_SETUP_SYNC_MODE=off` to disable. +- Ordinary Hack commands, `hack update`, and `hack doctor --fix` never inspect, render, repair, or remove agent integration files. +- Use `hack setup sync` only when explicitly choosing to manage project or user docs, skills, rules, hooks, or MCP config. +- Read-only freshness checks are available through `hack setup sync --all-scopes --check`, `hack doctor`, and `hack agent prime`. - Refresh project + user integrations: `hack setup sync --all-scopes` - Audit integration state only: `hack setup sync --all-scopes --check` - Remove generated integration artifacts: `hack setup sync --all-scopes --remove` diff --git a/scripts/build-release.ts b/scripts/build-release.ts index 6e7cb7ea..68395cfd 100644 --- a/scripts/build-release.ts +++ b/scripts/build-release.ts @@ -479,7 +479,6 @@ function renderCodexSlimInstallScript(): string { ` printf '%s\\n' '#!/usr/bin/env bash' 'set -euo pipefail'`, ` printf '%s\\n' 'export HACK_EXECUTION_MODE="\${HACK_EXECUTION_MODE:-codex}"'`, ` printf '%s\\n' 'export HACK_DAEMON_DISABLE_DOCKER_EVENTS="\${HACK_DAEMON_DISABLE_DOCKER_EVENTS:-1}"'`, - ` printf '%s\\n' 'export HACK_SETUP_SYNC_MODE="\${HACK_SETUP_SYNC_MODE:-warn}"'`, ` printf 'export HACK_ASSETS_DIR="\${HACK_ASSETS_DIR:-%s}"\\n' "$INSTALL_ASSETS"`, ` printf 'exec "%s" "$@"\\n' "$REAL_BIN"`, '} > "$WRAPPER_BIN"', diff --git a/scripts/install-codex-slim.sh b/scripts/install-codex-slim.sh index 0df6ee75..bebbe301 100755 --- a/scripts/install-codex-slim.sh +++ b/scripts/install-codex-slim.sh @@ -34,7 +34,6 @@ if [ -d "\${HOME}/.local/share/mise/shims" ]; then fi export HACK_EXECUTION_MODE="${mode}" export HACK_DAEMON_DISABLE_DOCKER_EVENTS="\${HACK_DAEMON_DISABLE_DOCKER_EVENTS:-1}" -export HACK_SETUP_SYNC_MODE="\${HACK_SETUP_SYNC_MODE:-warn}" export HACK_ASSETS_DIR="\${HACK_ASSETS_DIR:-${assets_dir}}" exec "${bun_bin}" "${repo_root}/index.ts" "\$@" EOF diff --git a/scripts/portable-container-smoke.sh b/scripts/portable-container-smoke.sh index 72d6a54b..44fb27b2 100644 --- a/scripts/portable-container-smoke.sh +++ b/scripts/portable-container-smoke.sh @@ -40,7 +40,6 @@ docker run \ --rm \ --platform "${platform}" \ -e HACK_ENV_SECRET_KEY="${secret_key}" \ - -e HACK_SETUP_SYNC_MODE=off \ -v "${fixture_project}:/workspace/project" \ --workdir /workspace/project \ --entrypoint sh \ diff --git a/src/agents/instruction-source.ts b/src/agents/instruction-source.ts index ae1ea2a2..62c13db4 100644 --- a/src/agents/instruction-source.ts +++ b/src/agents/instruction-source.ts @@ -44,7 +44,7 @@ export const INSTRUCTION_SECTIONS: readonly InstructionSection[] = [ bullets: [ `These instructions were generated by hack CLI v${HACK_AGENT_INTEGRATION_CLI_VERSION}; treat cached rules from another version as potentially stale.`, "At session start, audit project and global integrations with `hack setup sync --all-scopes --check`.", - "If anything is stale, missing, or deprecated, run `hack setup sync --all-scopes`, then reload the agent session so cached instructions are replaced.", + "If anything is stale or missing, run `hack setup sync --all-scopes`, then reload the agent session so cached instructions are replaced.", "Never copy or hand-edit generated Hack rules to refresh them; update the CLI and run the sync command.", ], }, @@ -307,9 +307,9 @@ export const INSTRUCTION_SECTIONS: readonly InstructionSection[] = [ title: "Agent integration maintenance", surfaces: ALL_SURFACES, bullets: [ - "Project-level hack commands auto-check integration drift and attempt auto-sync (project docs, client skills/rules, shared global skills, and MCP).", - "When drift is detected, Hack reports it before repair and tells the agent to reload after repair; it never silently leaves the session using cached rules.", - "Set `HACK_SETUP_SYNC_MODE=warn` to only warn, or `HACK_SETUP_SYNC_MODE=off` to disable.", + "Ordinary Hack commands, `hack update`, and `hack doctor --fix` never inspect, render, repair, or remove agent integration files.", + "Use `hack setup sync` only when explicitly choosing to manage project or user docs, skills, rules, hooks, or MCP config.", + "Read-only freshness checks are available through `hack setup sync --all-scopes --check`, `hack doctor`, and `hack agent prime`.", "Refresh project + user integrations: `hack setup sync --all-scopes`", "Audit integration state only: `hack setup sync --all-scopes --check`", "Remove generated integration artifacts: `hack setup sync --all-scopes --remove`", diff --git a/src/agents/integration-revision.ts b/src/agents/integration-revision.ts index 886f2f91..5187e589 100644 --- a/src/agents/integration-revision.ts +++ b/src/agents/integration-revision.ts @@ -3,4 +3,4 @@ * source test recomputes this value and fails whenever guidance changes * without a revision update. */ -export const HACK_AGENT_INTEGRATION_CONTENT_REVISION = "1319d93c89c3"; +export const HACK_AGENT_INTEGRATION_CONTENT_REVISION = "9e40aaab2d26"; diff --git a/src/agents/shared-skill.ts b/src/agents/shared-skill.ts index 52c6d870..253285a3 100644 --- a/src/agents/shared-skill.ts +++ b/src/agents/shared-skill.ts @@ -17,7 +17,6 @@ export type SharedSkillResult = { | "noop" | "absent" | "stale" - | "deprecated" | "removed" | "missing" | "error"; @@ -30,20 +29,6 @@ const HACK_CLI_SKILL_NAME = "hack-cli"; const SKILL_FILENAME = "SKILL.md"; const HACK_CLI_MARKER = /name:\s*hack-cli\b/i; -const LEGACY_SHARED_SKILLS = [ - { - name: "hack", - markers: [ - /name:\s*hack\b/i, - /homepage:\s*https:\/\/github\.com\/hack-dance\/hack-cli/i, - ], - }, - { - name: "hack-tickets", - markers: [/name:\s*hack-tickets\b/i], - }, -] as const; - /** Install the canonical Hack skill in the shared agent skill root. */ export async function installSharedHackSkill(): Promise { const resolved = resolveSharedSkillPath({ skillName: HACK_CLI_SKILL_NAME }); @@ -107,59 +92,6 @@ export async function removeSharedHackSkill(): Promise { return { status: "removed", path: resolved.path }; } -/** Report known superseded shared skills without treating arbitrary user skills as owned. */ -export async function checkDeprecatedSharedHackSkills(): Promise< - SharedSkillResult[] -> { - const results: SharedSkillResult[] = []; - for (const legacy of LEGACY_SHARED_SKILLS) { - const resolved = resolveSharedSkillPath({ skillName: legacy.name }); - if (!resolved.ok) { - results.push({ - status: "error", - path: SKILL_FILENAME, - message: resolved.message, - }); - continue; - } - const content = await readTextFile(resolved.path); - if (!content) { - results.push({ status: "absent", path: resolved.path }); - continue; - } - const owned = legacy.markers.every((marker) => marker.test(content)); - results.push({ - status: owned ? "deprecated" : "error", - path: resolved.path, - message: owned - ? `Deprecated Hack skill is still installed at ${resolved.path}. Run: hack setup sync --all-scopes` - : `Refusing to remove unrecognized skill at ${resolved.path}`, - }); - } - return results; -} - -/** Remove only legacy skills whose known ownership markers still match. */ -export async function removeDeprecatedSharedHackSkills(): Promise< - SharedSkillResult[] -> { - const checked = await checkDeprecatedSharedHackSkills(); - const results: SharedSkillResult[] = []; - for (const result of checked) { - if ( - result.status === "noop" || - result.status === "absent" || - result.status === "error" - ) { - results.push(result); - continue; - } - await rm(dirname(result.path), { recursive: true, force: true }); - results.push({ status: "removed", path: result.path }); - } - return results; -} - function resolveSharedSkillPath(opts: { readonly skillName: string; }): diff --git a/src/cli/integration-sync.ts b/src/cli/integration-sync.ts index 7caa9a06..7530c7ab 100644 --- a/src/cli/integration-sync.ts +++ b/src/cli/integration-sync.ts @@ -1,36 +1,10 @@ -import { checkClaudeHooks, installClaudeHooks } from "../agents/claude.ts"; -import { checkCodexSkill, installCodexSkill } from "../agents/codex-skill.ts"; -import { checkCursorRules, installCursorRules } from "../agents/cursor.ts"; +import { checkClaudeHooks } from "../agents/claude.ts"; +import { checkCodexSkill } from "../agents/codex-skill.ts"; +import { checkCursorRules } from "../agents/cursor.ts"; import { HACK_AGENT_INTEGRATION_CLI_VERSION } from "../agents/instruction-source.ts"; -import { - checkDeprecatedSharedHackSkills, - checkSharedHackSkill, - installSharedHackSkill, - removeDeprecatedSharedHackSkills, -} from "../agents/shared-skill.ts"; -import { - checkDeprecatedTicketsAgentDocs, - removeTicketsAgentDocs, -} from "../control-plane/extensions/tickets/agent-docs.ts"; -import { - checkDeprecatedTicketsSkill, - removeTicketsSkill, -} from "../control-plane/extensions/tickets/tickets-skill.ts"; -import { findProjectContext } from "../lib/project.ts"; -import { - type AgentDocCheckResult, - checkAgentDocs, - upsertAgentDocs, -} from "../mcp/agent-docs.ts"; -import { - checkMcpConfig, - installMcpConfig, - type McpCheckResult, - type McpInstallResult, -} from "../mcp/install.ts"; -import { logger } from "../ui/logger.ts"; - -type IntegrationSyncMode = "auto" | "warn" | "off"; +import { checkSharedHackSkill } from "../agents/shared-skill.ts"; +import { type AgentDocCheckResult, checkAgentDocs } from "../mcp/agent-docs.ts"; +import { checkMcpConfig, type McpCheckResult } from "../mcp/install.ts"; export type AgentIntegrationFreshnessReport = { readonly status: "current" | "stale"; @@ -40,73 +14,7 @@ export type AgentIntegrationFreshnessReport = { }; const SYNC_COMMAND = "hack setup sync --all-scopes"; -const INTEGRATION_SYNC_MODE_ENV = "HACK_SETUP_SYNC_MODE"; const VERIFY_COMMAND = "hack setup sync --all-scopes --check"; -const SKIP_TOP_LEVEL = new Set([ - "setup", - "mcp", - "agent", - "update", - "help", - "version", -]); - -/** - * Project-level integration guard. - * - * For interactive sessions, detect drift in generated docs/skills/MCP configs. - * Default behavior is auto-heal; fallback is a compact warning with the fix command. - */ -export async function maybeEnsureAgentIntegrations(opts: { - readonly cwd: string; - readonly commandPath: readonly string[]; -}): Promise { - if (!shouldRunIntegrationGuard({ commandPath: opts.commandPath })) { - return; - } - - const mode = resolveIntegrationSyncMode(); - if (mode === "off") { - return; - } - - const project = await findProjectContext(opts.cwd); - if (!project) { - return; - } - - const drift = await detectIntegrationDrift({ - projectRoot: project.projectRoot, - }); - if (!drift.hasDrift) { - return; - } - - if (mode === "auto") { - logger.warn({ - message: - "Detected stale Hack agent integrations. Refreshing project and global rules before this command continues.", - }); - const autoSync = await autoSyncIntegrations({ - projectRoot: project.projectRoot, - }); - if (autoSync.ok) { - logger.warn({ - message: - "Refreshed Hack agent integrations. Reload the agent session before relying on cached Hack rules; verify with: hack setup sync --all-scopes --check", - }); - return; - } - logger.warn({ - message: `Agent integrations are out of sync and auto-sync could not fully repair them. Run: ${SYNC_COMMAND}`, - }); - return; - } - - logger.warn({ - message: `Hack agent integrations are stale (project/global docs, skills, or MCP). Do not rely on cached rules. Run: ${SYNC_COMMAND}, then reload the agent session.`, - }); -} /** Inspect project and global generated guidance without mutating it. */ export async function inspectAgentIntegrationFreshness(opts: { @@ -137,34 +45,6 @@ export function renderAgentIntegrationFreshnessNotice(opts: { ].join("\n"); } -function shouldRunIntegrationGuard(opts: { - readonly commandPath: readonly string[]; -}): boolean { - const explicitMode = (process.env[INTEGRATION_SYNC_MODE_ENV] ?? "").trim(); - if (!(process.stdout.isTTY || process.stderr.isTTY || explicitMode)) { - return false; - } - - const topLevel = opts.commandPath[0]; - if (typeof topLevel !== "string" || topLevel.length === 0) { - return false; - } - return !SKIP_TOP_LEVEL.has(topLevel); -} - -function resolveIntegrationSyncMode(): IntegrationSyncMode { - const raw = (process.env[INTEGRATION_SYNC_MODE_ENV] ?? "") - .trim() - .toLowerCase(); - if (raw === "off") { - return "off"; - } - if (raw === "warn") { - return "warn"; - } - return "auto"; -} - async function detectIntegrationDrift(opts: { readonly projectRoot: string; }): Promise<{ readonly hasDrift: boolean }> { @@ -175,11 +55,7 @@ async function detectIntegrationDrift(opts: { claudeUser, codexProject, codexUser, - ticketsProject, - ticketsUser, - ticketsDocs, sharedSkill, - deprecatedSharedSkills, mcpProject, mcpUser, docs, @@ -190,17 +66,7 @@ async function detectIntegrationDrift(opts: { checkClaudeHooks({ scope: "user" }), checkCodexSkill({ scope: "project", projectRoot: opts.projectRoot }), checkCodexSkill({ scope: "user" }), - checkDeprecatedTicketsSkill({ - scope: "project", - projectRoot: opts.projectRoot, - }), - checkDeprecatedTicketsSkill({ scope: "user" }), - checkDeprecatedTicketsAgentDocs({ - projectRoot: opts.projectRoot, - targets: ["agents", "claude"], - }), checkSharedHackSkill(), - checkDeprecatedSharedHackSkills(), checkMcpConfig({ scope: "project", projectRoot: opts.projectRoot, @@ -223,8 +89,6 @@ async function detectIntegrationDrift(opts: { claudeUser.status, codexProject.status, codexUser.status, - ticketsProject.status, - ticketsUser.status, sharedSkill.status, ] as const; @@ -233,20 +97,8 @@ async function detectIntegrationDrift(opts: { ); const mcpDrift = hasMcpDrift({ checks: [...mcpProject, ...mcpUser] }); const docsDrift = hasDocDrift({ checks: docs }); - const deprecatedDocsDrift = ticketsDocs.some( - (check) => check.status !== "noop" && check.status !== "absent" - ); - const deprecatedSharedDrift = deprecatedSharedSkills.some( - (check) => check.status !== "noop" && check.status !== "absent" - ); - return { - hasDrift: - singleDrift || - mcpDrift || - docsDrift || - deprecatedDocsDrift || - deprecatedSharedDrift, + hasDrift: singleDrift || mcpDrift || docsDrift, }; } @@ -265,101 +117,3 @@ function hasDocDrift(opts: { }): boolean { return opts.checks.some((check) => check.status !== "present"); } - -async function autoSyncIntegrations(opts: { - readonly projectRoot: string; -}): Promise<{ readonly ok: boolean }> { - const [ - cursorProject, - cursorUser, - claudeProject, - claudeUser, - codexProject, - codexUser, - ticketsProject, - ticketsUser, - ticketsDocs, - sharedSkill, - deprecatedSharedSkills, - mcpProject, - mcpUser, - docs, - ] = await Promise.all([ - installCursorRules({ scope: "project", projectRoot: opts.projectRoot }), - installCursorRules({ scope: "user" }), - installClaudeHooks({ scope: "project", projectRoot: opts.projectRoot }), - installClaudeHooks({ scope: "user" }), - installCodexSkill({ scope: "project", projectRoot: opts.projectRoot }), - installCodexSkill({ scope: "user" }), - removeTicketsSkill({ scope: "project", projectRoot: opts.projectRoot }), - removeTicketsSkill({ scope: "user" }), - removeTicketsAgentDocs({ - projectRoot: opts.projectRoot, - targets: ["agents", "claude"], - }), - installSharedHackSkill(), - removeDeprecatedSharedHackSkills(), - installMcpConfig({ - scope: "project", - projectRoot: opts.projectRoot, - targets: ["cursor", "claude", "codex"], - }), - installMcpConfig({ - scope: "user", - targets: ["cursor", "claude", "codex"], - }), - upsertAgentDocs({ - projectRoot: opts.projectRoot, - targets: ["agents", "claude"], - }), - ]); - - const singleStatuses = [ - cursorProject.status, - cursorUser.status, - claudeProject.status, - claudeUser.status, - codexProject.status, - codexUser.status, - ticketsProject.status, - ticketsUser.status, - sharedSkill.status, - ] as const; - const singleErrors = singleStatuses.some((status) => - hasSingleInstallError(status) - ); - const mcpErrors = hasMcpInstallErrors({ - results: [...mcpProject, ...mcpUser], - }); - const docsErrors = hasDocInstallErrors({ results: docs }); - const ticketsDocsErrors = hasDocInstallErrors({ results: ticketsDocs }); - const deprecatedSharedErrors = deprecatedSharedSkills.some( - (result) => result.status === "error" - ); - - return { - ok: !( - singleErrors || - mcpErrors || - docsErrors || - ticketsDocsErrors || - deprecatedSharedErrors - ), - }; -} - -function hasSingleInstallError(status: string): boolean { - return status === "error"; -} - -function hasMcpInstallErrors(opts: { - readonly results: readonly McpInstallResult[]; -}): boolean { - return opts.results.some((result) => result.status === "error"); -} - -function hasDocInstallErrors(opts: { - readonly results: readonly { readonly status: string }[]; -}): boolean { - return opts.results.some((result) => result.status === "error"); -} diff --git a/src/cli/run.ts b/src/cli/run.ts index 8d2682fb..ac9f4313 100644 --- a/src/cli/run.ts +++ b/src/cli/run.ts @@ -19,7 +19,6 @@ import { resolveCommand, } from "./command.ts"; import { printHelpForPath } from "./help.ts"; -import { maybeEnsureAgentIntegrations } from "./integration-sync.ts"; import { CLI_SPEC } from "./spec.ts"; function isTruthyEnv(value: string | undefined): boolean { @@ -91,11 +90,6 @@ export async function runCli(argv: readonly string[]): Promise { jsonRequested: parsed.values.json === true, }); - await maybeEnsureAgentIntegrations({ - cwd: process.cwd(), - commandPath: resolved.path.map((command) => command.name), - }); - return await resolved.command.handler({ ctx: { cwd: process.cwd(), cli }, args: { @@ -188,7 +182,7 @@ function isExtensionDispatch(opts: { if (token.startsWith("-")) { continue; } - return token === "x" || token === "tickets"; + return token === "x"; } return false; } @@ -221,7 +215,6 @@ function validateResolvedCommandOptions(opts: { }): void { if ( opts.command.name === "x" || - opts.command.name === "tickets" || allowsUnknownOptions({ command: opts.command }) ) { return; diff --git a/src/cli/spec.ts b/src/cli/spec.ts index 40adddb7..136dc440 100644 --- a/src/cli/spec.ts +++ b/src/cli/spec.ts @@ -1,6 +1,5 @@ import pkg from "../../package.json"; import { agentCommand } from "../commands/agent.ts"; -import { authCommand } from "../commands/auth.ts"; import { branchCommand } from "../commands/branch.ts"; import { configCommand } from "../commands/config.ts"; import { crashCaptureCommand } from "../commands/crash-capture.ts"; @@ -12,11 +11,9 @@ import { gatewayCommand } from "../commands/gateway.ts"; import { globalCommand } from "../commands/global.ts"; import { helpCommand } from "../commands/help.ts"; import { internalCommand } from "../commands/internal.ts"; -import { linearCommand } from "../commands/linear.ts"; import { logPipeCommand } from "../commands/log-pipe.ts"; import { mcpCommand } from "../commands/mcp.ts"; import { nodeCommand } from "../commands/node.ts"; -import { orgCommand } from "../commands/org.ts"; import { downCommand, execCommand, @@ -35,9 +32,7 @@ import { secretsCommand } from "../commands/secrets.ts"; import { sessionCommand } from "../commands/session.ts"; import { setupCommand } from "../commands/setup.ts"; import { sshCommand } from "../commands/ssh.ts"; -import { teamCommand } from "../commands/team.ts"; import { theCommand } from "../commands/the.ts"; -import { ticketsCommand } from "../commands/tickets.ts"; import { tuiCommand } from "../commands/tui.ts"; import { updateCommand } from "../commands/update.ts"; import { usageCommand } from "../commands/usage.ts"; @@ -64,9 +59,6 @@ export const CLI_SPEC = defineCli({ globalOptions: [optNoInteractive], commands: [ globalCommand, - authCommand, - orgCommand, - teamCommand, statusCommand, usageCommand, projectsCommand, @@ -83,7 +75,6 @@ export const CLI_SPEC = defineCli({ openCommand, branchCommand, logPipeCommand, - linearCommand, doctorCommand, crashCaptureCommand, daemonCommand, @@ -97,7 +88,6 @@ export const CLI_SPEC = defineCli({ setupCommand, sessionCommand, sshCommand, - ticketsCommand, agentCommand, gatewayCommand, nodeCommand, diff --git a/src/commands/auth.ts b/src/commands/auth.ts deleted file mode 100644 index 01c1426f..00000000 --- a/src/commands/auth.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { createRemovedSurfaceCommand } from "./removed-surface.ts"; - -export const authCommand = createRemovedSurfaceCommand({ - name: "auth", - summary: - "Removed: Hack account sign-in no longer ships with the local-first CLI", - reason: - "Hack no longer ships a centralized auth broker or hosted account surface.", - replacement: - "Local workflows no longer require Hack account sign-in. Use local project ownership, tickets, env, and sessions directly.", -}); diff --git a/src/commands/dispatch.ts b/src/commands/dispatch.ts index 786217aa..759bab92 100644 --- a/src/commands/dispatch.ts +++ b/src/commands/dispatch.ts @@ -6,7 +6,6 @@ import type { CliContext, CommandArgs } from "../cli/command.ts"; import { defineCommand, defineOption, withHandler } from "../cli/command.ts"; import { optFollow, optJson, optTail } from "../cli/options.ts"; import type { JobMeta } from "../control-plane/extensions/supervisor/job-store.ts"; -import { persistDispatchRunToTicketsChannel } from "../control-plane/extensions/tickets/runs-channel.ts"; import { appendPolicyAuditEvent } from "../control-plane/policy/audit.ts"; import { resolvePolicyDecision } from "../control-plane/policy/engine.ts"; import { assessCommandRisk } from "../control-plane/policy/risk.ts"; @@ -135,45 +134,6 @@ const optApprove = defineOption({ description: "Approve high/critical command risk without interactive prompt", } as const); -const optPr = defineOption({ - name: "pr", - type: "boolean", - long: "--pr", - description: "Removed in v3: legacy GitHub PR automation flag", -} as const); - -const optPrBase = defineOption({ - name: "prBase", - type: "string", - long: "--pr-base", - valueHint: "", - description: "Removed in v3: legacy GitHub PR automation flag", -} as const); - -const optPrTitle = defineOption({ - name: "prTitle", - type: "string", - long: "--pr-title", - valueHint: "", - description: "Removed in v3: legacy GitHub PR automation flag", -} as const); - -const optPrBody = defineOption({ - name: "prBody", - type: "string", - long: "--pr-body", - valueHint: "<markdown>", - description: "Removed in v3: legacy GitHub PR automation flag", -} as const); - -const optGitHubProfile = defineOption({ - name: "githubProfile", - type: "string", - long: "--github-profile", - valueHint: "<profile-id>", - description: "Removed in v3: legacy GitHub PR automation flag", -} as const); - const runOptions = [ optNode, optProvider, @@ -184,11 +144,6 @@ const runOptions = [ optTicket, optRunner, optApprove, - optPr, - optPrBase, - optPrTitle, - optPrBody, - optGitHubProfile, optJson, ] as const; @@ -290,16 +245,6 @@ async function handleDispatchRun({ }); return 1; } - const removedPrMessage = resolveRemovedDispatchPrAutomationMessage({ - pr: args.options.pr, - prBase: args.options.prBase, - prTitle: args.options.prTitle, - prBody: args.options.prBody, - githubProfile: args.options.githubProfile, - }); - if (removedPrMessage) { - logger.warn({ message: removedPrMessage }); - } const runner = (args.options.runner ?? "generic").trim() || "generic"; const ticketId = (args.options.ticket ?? "").trim() || undefined; const branch = (args.options.branch ?? "").trim() || undefined; @@ -439,8 +384,6 @@ async function handleDispatchRun({ errorMessage: policy.error, status: "cancelled", reason: "policy_denied", - controlPlaneConfig: controlPlane.config, - actor, }); logger.error({ message: policy.error }); return 1; @@ -494,8 +437,6 @@ async function handleDispatchRun({ errorMessage, status: "error", reason: "workspace_ensure_failed", - controlPlaneConfig: controlPlane.config, - actor, }); logger.error({ message: errorMessage }); return 1; @@ -556,8 +497,6 @@ async function handleDispatchRun({ errorMessage: preparedSync.error, status: "error", reason: preparedSync.reason, - controlPlaneConfig: controlPlane.config, - actor, }); logger.error({ message: preparedSync.error }); return 1; @@ -591,8 +530,6 @@ async function handleDispatchRun({ errorMessage, status: "error", reason: "job_create_failed", - controlPlaneConfig: controlPlane.config, - actor, }); logger.error({ message: errorMessage }); return 1; @@ -719,13 +656,6 @@ async function handleDispatchRun({ }, }); - const currentRun = (await readDispatchRunRecord({ runId })) ?? run; - await persistRunArtifactsToCanonicalTickets({ - run: currentRun, - controlPlaneConfig: controlPlane.config, - actor, - }); - const exitCode = status === "completed" ? 0 : 1; if (args.options.json) { process.stdout.write( @@ -779,8 +709,6 @@ async function handleDispatchRun({ errorMessage: failure, status: "error", reason: "job_stream_failed", - controlPlaneConfig: controlPlane.config, - actor, }); logger.error({ message: failure }); return 1; @@ -1926,8 +1854,6 @@ async function finalizeFailedRun(input: { readonly errorMessage: string; readonly status: DispatchRunStatus; readonly reason: string; - readonly controlPlaneConfig: ControlPlaneConfig; - readonly actor: string; }): Promise<void> { await updateDispatchRunRecord({ runId: input.run.runId, @@ -1963,13 +1889,6 @@ async function finalizeFailedRun(input: { reason: input.reason, }, }); - const currentRun = - (await readDispatchRunRecord({ runId: input.run.runId })) ?? input.run; - await persistRunArtifactsToCanonicalTickets({ - run: currentRun, - controlPlaneConfig: input.controlPlaneConfig, - actor: input.actor, - }); } async function refreshRunStatusFromRemote(input: { @@ -2038,43 +1957,6 @@ function runStatusToExitCode(input: { return 1; } -async function persistRunArtifactsToCanonicalTickets(input: { - readonly run: DispatchRunRecord; - readonly controlPlaneConfig: ControlPlaneConfig; - readonly actor: string; -}): Promise<void> { - if (!input.run.projectRoot) { - return; - } - const persisted = await persistDispatchRunToTicketsChannel({ - projectRoot: input.run.projectRoot, - controlPlaneConfig: input.controlPlaneConfig, - run: input.run, - actor: input.actor, - logger, - }); - if (!persisted.ok) { - logger.warn({ - message: `Failed to persist canonical run artifacts: ${persisted.error}`, - }); - await appendDispatchRunEvent({ - runId: input.run.runId, - event: { - type: "run.artifacts.persist_failed", - error: persisted.error, - }, - }); - return; - } - await appendDispatchRunEvent({ - runId: input.run.runId, - event: { - type: "run.artifacts.persisted", - canonicalPath: `.hack/tickets/runs/${input.run.runId}`, - }, - }); -} - type StreamOutcome = { readonly job: JobMeta | null; readonly logsOffset: number; @@ -2325,33 +2207,6 @@ function normalizeOptionalString(value: unknown): string | undefined { return trimmed.length > 0 ? trimmed : undefined; } -function resolveRemovedDispatchPrAutomationMessage(input: { - readonly pr?: boolean; - readonly prBase?: string; - readonly prTitle?: string; - readonly prBody?: string; - readonly githubProfile?: string; -}): string | null { - const requested = - input.pr === true || - normalizeOptionalString(input.prBase) !== undefined || - normalizeOptionalString(input.prTitle) !== undefined || - normalizeOptionalString(input.prBody) !== undefined || - normalizeOptionalString(input.githubProfile) !== undefined; - if (!requested) { - return null; - } - return [ - "Built-in GitHub PR automation was removed in Hack v3.", - "Dispatch still runs the remote command, but push and PR follow-up must now use native git and gh outside Hack.", - "Migration: run `git push -u origin <branch>` and `gh pr create` or `gh pr edit` after the dispatch completes.", - ].join(" "); -} - -export const __testOnlyDispatch = { - resolveRemovedDispatchPrAutomationMessage, -}; - function parseConfigBoolean(input: { readonly config: Record<string, unknown>; readonly key: string; diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index de039817..cecb26a2 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -2412,12 +2412,11 @@ async function runDoctorFix(opts: { await maybeInstallMutagenForDoctorFix(); await maybeUntrackGeneratedFiles({ startDir: opts.startDir }); await maybeRepairHackd(); - await maybeRepairAgentIntegrations({ startDir: opts.startDir }); const dockerOk = await dockerInfoOk(); if (!dockerOk) { note( - "Docker is not reachable; daemon and agent repairs were still applied, but Docker-backed fixes were skipped.", + "Docker is not reachable; daemon repairs were still applied, but Docker-backed fixes were skipped.", "doctor" ); return; @@ -2917,8 +2916,8 @@ async function maybeInstallMutagenForDoctorFix(): Promise<void> { * `hack doctor --fix` confirmation helper. * * Wraps {@link confirmSafe} with the doctor-specific non-interactive - * defaults: safe remediations (network/CoreDNS/CA/daemon restarts, generated - * file writes, tickets git repair) proceed automatically + * defaults: safe remediations (network/CoreDNS/CA/daemon restarts and generated + * file writes) proceed automatically * (`nonInteractive: "accept-default"`); destructive/system-level steps * (anything invoking `sudo`, touching the macOS keychain, or writing outside * the project) decline automatically and print a note via @@ -2998,35 +2997,6 @@ async function maybeRepairHackd(): Promise<void> { } } -async function maybeRepairAgentIntegrations(opts: { - readonly startDir: string; -}): Promise<void> { - const project = await findProjectContext(opts.startDir); - const report = await inspectDoctorAgentIntegrations({ - projectRoot: project?.projectRoot ?? null, - }); - if (report.status !== "stale") { - return; - } - const ok = await doctorConfirm({ - message: project - ? "Refresh stale project and global agent integrations now?" - : "Refresh stale global agent integrations now?", - initialValue: true, - }); - if (ok) { - await runHackSubcommand({ - args: project - ? ["setup", "sync", "--all-scopes"] - : ["setup", "sync", "--global"], - }); - note( - "Agent integrations refreshed. Reload active agent sessions so cached rules are replaced.", - "agent integrations" - ); - } -} - export async function inspectDoctorAgentIntegrations(opts: { readonly projectRoot: string | null; readonly homeDir?: string; @@ -3572,10 +3542,7 @@ export function buildDoctorSummaryLines(input: { const ungrouped = input.results.filter( (result) => - !( - isHiddenDoctorCheck(result) || - DOCTOR_SUMMARY_GROUPS.some((group) => group.checks.has(result.name)) - ) + !DOCTOR_SUMMARY_GROUPS.some((group) => group.checks.has(result.name)) ); if (ungrouped.length > 0) { const issues = ungrouped.filter( @@ -3592,10 +3559,6 @@ export function buildDoctorSummaryLines(input: { return lines; } -function isHiddenDoctorCheck(result: RecoveryCheckResult): boolean { - return result.name === "tickets git"; -} - export function buildDoctorSummaryStatusItems(input: { readonly results: readonly RecoveryCheckResult[]; }): readonly DisplayStatusItem[] { @@ -3613,10 +3576,7 @@ export function buildDoctorSummaryStatusItems(input: { const ungrouped = input.results.filter( (result) => - !( - isHiddenDoctorCheck(result) || - DOCTOR_SUMMARY_GROUPS.some((group) => group.checks.has(result.name)) - ) + !DOCTOR_SUMMARY_GROUPS.some((group) => group.checks.has(result.name)) ); if (ungrouped.length > 0) { items.push( diff --git a/src/commands/linear.ts b/src/commands/linear.ts deleted file mode 100644 index dae5e088..00000000 --- a/src/commands/linear.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { createRemovedSurfaceCommand } from "./removed-surface.ts"; - -export const linearCommand = createRemovedSurfaceCommand({ - name: "linear", - summary: "Removed: Linear integration is no longer part of Hack v3", - reason: - "Hack v3 removed hosted planning and sync integrations to stay self-contained and local-first.", - replacement: - "Use repo-local tickets for optional in-repo tracking and keep Linear outside Hack.", -}); diff --git a/src/commands/org.ts b/src/commands/org.ts deleted file mode 100644 index 32c3b585..00000000 --- a/src/commands/org.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { createRemovedSurfaceCommand } from "./removed-surface.ts"; - -export const orgCommand = createRemovedSurfaceCommand({ - name: "org", - summary: "Removed: hosted organization management is no longer part of Hack", - reason: - "Hack v3 dropped centralized org and membership administration with the broker-backed control plane.", - replacement: - "Keep project ownership local and use native git collaboration plus repo-local tickets instead.", -}); diff --git a/src/commands/removed-surface.ts b/src/commands/removed-surface.ts deleted file mode 100644 index 05c14f20..00000000 --- a/src/commands/removed-surface.ts +++ /dev/null @@ -1,59 +0,0 @@ -import type { - CliContext, - CommandArgs, - PositionalSpec, -} from "../cli/command.ts"; -import { defineCommand, withHandler } from "../cli/command.ts"; -import { logger } from "../ui/logger.ts"; - -const removedArgsPositionals = [ - { - name: "args", - required: false, - multiple: true, - description: "Legacy subcommand and arguments", - }, -] as const satisfies readonly PositionalSpec[]; - -export function createRemovedSurfaceCommand(input: { - readonly name: string; - readonly summary: string; - readonly replacement: string; - readonly reason: string; -}) { - return withHandler( - defineCommand({ - name: input.name, - summary: input.summary, - description: [ - `Removed in v3: ${input.reason}`, - "", - `Migration: ${input.replacement}`, - ].join("\n"), - group: "Integrations", - options: [] as const, - allowUnknownOptions: true, - positionals: removedArgsPositionals, - subcommands: [] as const, - } as const), - ({ - args, - }: { - readonly ctx: CliContext; - readonly args: CommandArgs<readonly [], typeof removedArgsPositionals>; - }): Promise<number> => { - const attempted = - args.positionals.args.length > 0 - ? `${input.name} ${args.positionals.args.join(" ")}` - : input.name; - logger.error({ - message: [ - `\`hack ${attempted}\` was removed in v3.`, - input.reason, - `Migration: ${input.replacement}`, - ].join(" "), - }); - return Promise.resolve(1); - } - ); -} diff --git a/src/commands/setup.ts b/src/commands/setup.ts index 594cb3c5..b9ed30e3 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -22,10 +22,8 @@ import { removeCursorRules, } from "../agents/cursor.ts"; import { - checkDeprecatedSharedHackSkills, checkSharedHackSkill, installSharedHackSkill, - removeDeprecatedSharedHackSkills, removeSharedHackSkill, } from "../agents/shared-skill.ts"; import type { CliContext, CommandArgs } from "../cli/command.ts"; @@ -36,15 +34,6 @@ import { withHandler, } from "../cli/command.ts"; import { optPath } from "../cli/options.ts"; -import { - checkDeprecatedTicketsAgentDocs, - removeTicketsAgentDocs, -} from "../control-plane/extensions/tickets/agent-docs.ts"; -import { - checkDeprecatedTicketsSkill, - removeTicketsSkill, - type TicketsSkillResult, -} from "../control-plane/extensions/tickets/tickets-skill.ts"; import { pathExists, readTextFile, writeTextFile } from "../lib/fs.ts"; import { canPrompt } from "../lib/interactivity.ts"; import { findRepoRootForInit } from "../lib/project.ts"; @@ -138,7 +127,6 @@ const setupTmuxOptions = [optCheck, optRemove] as const; const setupCursorOptions = [optPath, optGlobal, optCheck, optRemove] as const; const setupClaudeOptions = [optPath, optGlobal, optCheck, optRemove] as const; const setupCodexOptions = [optPath, optGlobal, optCheck, optRemove] as const; -const setupTicketsOptions = [optPath, optGlobal, optCheck, optRemove] as const; const setupAgentsOptions = [ optPath, optAll, @@ -169,7 +157,6 @@ type SetupTmuxArgs = CommandArgs<typeof setupTmuxOptions, readonly []>; type SetupCursorArgs = CommandArgs<typeof setupCursorOptions, readonly []>; type SetupClaudeArgs = CommandArgs<typeof setupClaudeOptions, readonly []>; type SetupCodexArgs = CommandArgs<typeof setupCodexOptions, readonly []>; -type SetupTicketsArgs = CommandArgs<typeof setupTicketsOptions, readonly []>; type SetupAgentsArgs = CommandArgs<typeof setupAgentsOptions, readonly []>; type SetupSyncArgs = CommandArgs<typeof setupSyncOptions, readonly []>; type SetupMcpArgs = CommandArgs<typeof setupMcpOptions, readonly []>; @@ -216,15 +203,6 @@ const codexSpec = defineCommand({ subcommands: [], } as const); -const ticketsSpec = defineCommand({ - name: "tickets", - summary: "Remove or audit the deprecated Hack Tickets skill", - group: "Agents", - options: setupTicketsOptions, - positionals: [], - subcommands: [], -} as const); - const agentsSpec = defineCommand({ name: "agents", summary: "Install AGENTS.md / CLAUDE.md snippets for hack CLI usage", @@ -236,8 +214,7 @@ const agentsSpec = defineCommand({ const syncSpec = defineCommand({ name: "sync", - summary: - "Refresh project/global agent guidance and remove deprecated artifacts", + summary: "Refresh project/global agent guidance", group: "Agents", options: setupSyncOptions, positionals: [], @@ -265,7 +242,6 @@ export const setupCommand = defineCommand({ withHandler(cursorSpec, handleSetupCursor), withHandler(claudeSpec, handleSetupClaude), withHandler(codexSpec, handleSetupCodex), - withHandler(ticketsSpec, handleSetupTickets), withHandler(agentsSpec, handleSetupAgents), withHandler(syncSpec, handleSetupSync), withHandler(mcpSpec, handleSetupMcp), @@ -618,39 +594,6 @@ async function handleSetupCodex({ }); } -async function handleSetupTickets({ - ctx, - args, -}: { - readonly ctx: CliContext; - readonly args: SetupTicketsArgs; -}): Promise<number> { - const action = resolveAction(args.options); - const scope = resolveScope({ global: args.options.global === true }); - const projectRoot = - scope === "project" - ? await resolveSetupRoot({ ctx, pathOpt: args.options.path }) - : undefined; - - logger.info({ - message: - "Hack Tickets agent integrations are deprecated. This command now audits or removes the legacy skill; it never installs it.", - }); - - let result: TicketsSkillResult; - if (action === "check") { - result = await checkDeprecatedTicketsSkill({ scope, projectRoot }); - } else { - result = await removeTicketsSkill({ scope, projectRoot }); - } - - return logSingleResult({ - action, - okMessage: "Deprecated Tickets skill", - result, - }); -} - async function handleSetupAgents({ ctx, args, @@ -732,8 +675,7 @@ export function buildSetupSyncScopeResult(input: { return true; } return ( - input.action === "check" && - ["missing", "stale", "deprecated"].includes(entry.status) + input.action === "check" && ["missing", "stale"].includes(entry.status) ); }); const errorCount = failures.filter( @@ -827,10 +769,7 @@ async function handleSetupSync({ return Math.max(0, ...scopeResults.map((result) => result.exitCode)); } -/** - * Run one sync action across all project-scope integrations and log results. - * Deprecated Tickets agent artifacts are always audited and removed by sync. - */ +/** Run one explicit sync action across all project-scope integrations. */ async function runProjectScopeSync(opts: { readonly action: SetupSyncAction; readonly projectRoot: string; @@ -839,8 +778,6 @@ async function runProjectScopeSync(opts: { let cursorResult: Awaited<ReturnType<typeof checkCursorRules>>; let claudeResult: Awaited<ReturnType<typeof checkClaudeHooks>>; let codexResult: Awaited<ReturnType<typeof checkCodexSkill>>; - let ticketsResult: TicketsSkillResult; - let ticketsDocsResults: SetupMultiLogResult[]; let mcpResults: SetupMultiLogResult[]; let docsResults: SetupMultiLogResult[]; @@ -848,14 +785,6 @@ async function runProjectScopeSync(opts: { cursorResult = await checkCursorRules({ scope: "project", projectRoot }); claudeResult = await checkClaudeHooks({ scope: "project", projectRoot }); codexResult = await checkCodexSkill({ scope: "project", projectRoot }); - ticketsResult = await checkDeprecatedTicketsSkill({ - scope: "project", - projectRoot, - }); - ticketsDocsResults = await checkDeprecatedTicketsAgentDocs({ - projectRoot, - targets: ["agents", "claude"], - }); mcpResults = await checkMcpConfig({ scope: "project", targets: ["cursor", "claude", "codex"], @@ -869,11 +798,6 @@ async function runProjectScopeSync(opts: { cursorResult = await removeCursorRules({ scope: "project", projectRoot }); claudeResult = await removeClaudeHooks({ scope: "project", projectRoot }); codexResult = await removeCodexSkill({ scope: "project", projectRoot }); - ticketsResult = await removeTicketsSkill({ scope: "project", projectRoot }); - ticketsDocsResults = await removeTicketsAgentDocs({ - projectRoot, - targets: ["agents", "claude"], - }); mcpResults = await removeMcpConfig({ scope: "project", targets: ["cursor", "claude", "codex"], @@ -887,11 +811,6 @@ async function runProjectScopeSync(opts: { cursorResult = await installCursorRules({ scope: "project", projectRoot }); claudeResult = await installClaudeHooks({ scope: "project", projectRoot }); codexResult = await installCodexSkill({ scope: "project", projectRoot }); - ticketsResult = await removeTicketsSkill({ scope: "project", projectRoot }); - ticketsDocsResults = await removeTicketsAgentDocs({ - projectRoot, - targets: ["agents", "claude"], - }); mcpResults = await installMcpConfig({ scope: "project", targets: ["cursor", "claude", "codex"], @@ -910,8 +829,6 @@ async function runProjectScopeSync(opts: { { label: "Cursor", results: [cursorResult] }, { label: "Claude", results: [claudeResult] }, { label: "Codex", results: [codexResult] }, - { label: "Deprecated Tickets skill", results: [ticketsResult] }, - { label: "Deprecated Tickets instructions", results: ticketsDocsResults }, { label: "MCP config", results: mcpResults }, { label: "Agent docs", results: docsResults }, ], @@ -921,7 +838,7 @@ async function runProjectScopeSync(opts: { /** * Run one sync action across all global (user) scope integrations and log * results. Shared `~/.ai/skills` guidance is managed alongside client-specific - * integrations, and known legacy Hack skills are cleaned up safely. + * integrations. */ async function runUserScopeSync(opts: { readonly action: SetupSyncAction; @@ -930,18 +847,14 @@ async function runUserScopeSync(opts: { let cursorResult: Awaited<ReturnType<typeof checkCursorRules>>; let claudeResult: Awaited<ReturnType<typeof checkClaudeHooks>>; let codexResult: Awaited<ReturnType<typeof checkCodexSkill>>; - let ticketsResult: TicketsSkillResult; let sharedSkillResult: SetupMultiLogResult & { readonly path: string }; - let legacySharedResults: SetupMultiLogResult[]; let mcpResults: SetupMultiLogResult[]; if (action === "check") { cursorResult = await checkCursorRules({ scope: "user" }); claudeResult = await checkClaudeHooks({ scope: "user" }); codexResult = await checkCodexSkill({ scope: "user" }); - ticketsResult = await checkDeprecatedTicketsSkill({ scope: "user" }); sharedSkillResult = await checkSharedHackSkill(); - legacySharedResults = await checkDeprecatedSharedHackSkills(); mcpResults = await checkMcpConfig({ scope: "user", targets: ["cursor", "claude", "codex"], @@ -950,9 +863,7 @@ async function runUserScopeSync(opts: { cursorResult = await removeCursorRules({ scope: "user" }); claudeResult = await removeClaudeHooks({ scope: "user" }); codexResult = await removeCodexSkill({ scope: "user" }); - ticketsResult = await removeTicketsSkill({ scope: "user" }); sharedSkillResult = await removeSharedHackSkill(); - legacySharedResults = await removeDeprecatedSharedHackSkills(); mcpResults = await removeMcpConfig({ scope: "user", targets: ["cursor", "claude", "codex"], @@ -961,9 +872,7 @@ async function runUserScopeSync(opts: { cursorResult = await installCursorRules({ scope: "user" }); claudeResult = await installClaudeHooks({ scope: "user" }); codexResult = await installCodexSkill({ scope: "user" }); - ticketsResult = await removeTicketsSkill({ scope: "user" }); sharedSkillResult = await installSharedHackSkill(); - legacySharedResults = await removeDeprecatedSharedHackSkills(); mcpResults = await installMcpConfig({ scope: "user", targets: ["cursor", "claude", "codex"], @@ -978,8 +887,6 @@ async function runUserScopeSync(opts: { { label: "Claude", results: [claudeResult] }, { label: "Codex", results: [codexResult] }, { label: "Shared Hack skill", results: [sharedSkillResult] }, - { label: "Deprecated Tickets skill", results: [ticketsResult] }, - { label: "Deprecated shared Hack skills", results: legacySharedResults }, { label: "MCP config", results: mcpResults }, ], }); @@ -1208,14 +1115,6 @@ function logCheckResult(opts: { }); return 1; } - if (opts.result.status === "deprecated") { - logger.warn({ - message: - opts.result.message ?? - `${opts.okMessage} is deprecated at ${opts.path}`, - }); - return 1; - } logger.success({ message: `${opts.okMessage} installed at ${opts.path}` }); return 0; } diff --git a/src/commands/team.ts b/src/commands/team.ts deleted file mode 100644 index 758f21fa..00000000 --- a/src/commands/team.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { createRemovedSurfaceCommand } from "./removed-surface.ts"; - -export const teamCommand = createRemovedSurfaceCommand({ - name: "team", - summary: "Removed: hosted team management is no longer part of Hack", - reason: - "Hack v3 removed broker-backed team and membership lifecycle management.", - replacement: - "Use repo-local ownership plus native collaboration tools outside Hack.", -}); diff --git a/src/commands/tickets.ts b/src/commands/tickets.ts deleted file mode 100644 index 3e8c3e19..00000000 --- a/src/commands/tickets.ts +++ /dev/null @@ -1,225 +0,0 @@ -import type { CliContext, CommandArgs } from "../cli/command.ts"; -import { defineCommand, withHandler } from "../cli/command.ts"; -import { loadExtensionManagerForCli } from "../control-plane/extensions/cli.ts"; -import { - buildEnableInstructions, - maybeEnableExtension, -} from "../control-plane/extensions/enable.ts"; -import { display } from "../ui/display.ts"; -import { logger } from "../ui/logger.ts"; - -/** - * Top-level tickets command that delegates to the tickets extension. - * Provides convenient access to `hack x tickets` functionality. - */ -const ticketsSpec = defineCommand({ - name: "tickets", - summary: "Deprecated: legacy repo-local Tickets compatibility commands", - description: [ - "Usage:", - " hack tickets list", - ' hack tickets create --title "..."', - " hack tickets show <ticket-id>", - " hack tickets status <ticket-id> <open|in_progress|blocked|done>", - ' hack tickets update <ticket-id> [--title "..."] [--body "..."]', - " hack tickets sync", - " hack tickets setup", - " hack tickets tui", - "", - "Deprecated compatibility surface. It is no longer installed into agent instructions or skills.", - "Alias for `hack x tickets <command>`. Requires extension enabled.", - ].join("\n"), - group: "Integrations", - options: [], - positionals: [{ name: "args", required: false, multiple: true }], - subcommands: [], -} as const); - -type TicketsArgs = CommandArgs<readonly [], readonly []>; - -export const ticketsCommand = withHandler(ticketsSpec, handleTickets); - -async function handleTickets({ - ctx, - args, -}: { - readonly ctx: CliContext; - readonly args: TicketsArgs; -}): Promise<number> { - // Parse command early to check if it's setup (which bypasses enable check) - const invocation = parseTicketsInvocation({ argv: args.raw.argv }); - warnTicketsDeprecationUnlessJson({ invocation }); - const isSetupCommand = invocation.command === "setup"; - - const loaded = await loadExtensionManagerForCli({ cwd: ctx.cwd }); - - if (loaded.configError) { - logger.warn({ - message: `Control plane config error: ${loaded.configError}`, - }); - } - for (const warning of loaded.warnings) { - logger.warn({ message: warning }); - } - - const extension = loaded.manager.getExtensionByNamespace({ - namespace: "tickets", - }); - if (!extension) { - logger.error({ message: "Tickets extension not found. Is it registered?" }); - return 1; - } - - // Allow setup command to run even when extension is disabled - if (!(extension.enabled || isSetupCommand)) { - const instructions = buildEnableInstructions({ - extension, - namespace: "tickets", - command: invocation.command, - args: invocation.args, - }); - - await display.panel({ - title: "Extension disabled", - tone: "warn", - lines: instructions.lines, - }); - - // Offer interactive enable prompt - const didEnable = await maybeEnableExtension({ - extension, - namespace: "tickets", - command: invocation.command, - args: invocation.args, - projectDir: loaded.context.project?.projectDir, - }); - - if (didEnable) { - // Reload and continue with the original command - const reloaded = await loadExtensionManagerForCli({ cwd: ctx.cwd }); - const nextExtension = reloaded.manager.getExtensionByNamespace({ - namespace: "tickets", - }); - if (!nextExtension?.enabled) { - logger.warn({ - message: "Extension still disabled after enable attempt.", - }); - return 1; - } - - if (!invocation.command || invocation.command === "help") { - await renderTicketsHelp({ - commands: reloaded.manager.listCommands({ namespace: "tickets" }), - }); - return 0; - } - - const resolved = reloaded.manager.resolveCommand({ - namespace: "tickets", - commandName: invocation.command, - }); - if (!resolved) { - logger.error({ - message: `Unknown tickets command: ${invocation.command}`, - }); - return 1; - } - - return await resolved.command.handler({ - ctx: reloaded.context, - args: invocation.args, - }); - } - - return 1; - } - - if (!invocation.command || invocation.command === "help") { - await renderTicketsHelp({ - commands: loaded.manager.listCommands({ namespace: "tickets" }), - }); - return 0; - } - - const resolved = loaded.manager.resolveCommand({ - namespace: "tickets", - commandName: invocation.command, - }); - - if (!resolved) { - logger.error({ message: `Unknown tickets command: ${invocation.command}` }); - await renderTicketsHelp({ - commands: loaded.manager.listCommands({ namespace: "tickets" }), - }); - return 1; - } - - return await resolved.command.handler({ - ctx: loaded.context, - args: invocation.args, - }); -} - -function warnTicketsDeprecationUnlessJson(opts: { - readonly invocation: TicketsInvocation; -}): void { - if (opts.invocation.args.includes("--json")) { - return; - } - logger.warn({ - message: - "Hack Tickets is deprecated and no longer part of agent guidance. Commands remain available only for compatibility and migration.", - }); -} - -type TicketsInvocation = { - readonly command?: string; - readonly args: readonly string[]; -}; - -function parseTicketsInvocation(opts: { - readonly argv: readonly string[]; -}): TicketsInvocation { - const index = findTicketsIndex({ argv: opts.argv }); - if (index === -1) { - return { args: [] }; - } - - const command = opts.argv[index + 1]; - const rawArgs = opts.argv.slice(index + 2); - const args = rawArgs[0] === "--" ? rawArgs.slice(1) : rawArgs; - - return { command, args }; -} - -function findTicketsIndex(opts: { readonly argv: readonly string[] }): number { - for (let i = 0; i < opts.argv.length; i += 1) { - const token = opts.argv[i] ?? ""; - if (token === "tickets") { - return i; - } - } - return -1; -} - -async function renderTicketsHelp(opts: { - readonly commands: readonly { - readonly name: string; - readonly summary: string; - readonly commandId: string; - }[]; -}): Promise<void> { - if (opts.commands.length === 0) { - await display.panel({ - title: "Tickets", - tone: "info", - lines: ["No commands available."], - }); - return; - } - - await display.table({ - columns: ["Command", "Summary"], - rows: opts.commands.map((cmd) => [`hack tickets ${cmd.name}`, cmd.summary]), - }); -} diff --git a/src/commands/update.ts b/src/commands/update.ts index 579f259e..3a025437 100644 --- a/src/commands/update.ts +++ b/src/commands/update.ts @@ -11,7 +11,6 @@ import { ensureBundledMutagenInstalled, getMutagenPath, } from "../lib/mutagen.ts"; -import { findProjectContext } from "../lib/project.ts"; import { compareVersions, detectHackInstall, @@ -21,7 +20,6 @@ import { resolveUpdateTarget, selectCliTarballAsset, } from "../lib/self-update.ts"; -import { exec } from "../lib/shell.ts"; const optCheck = defineOption({ name: "check", @@ -230,9 +228,6 @@ const handleUpdate: CommandHandlerFor<Spec> = async ({ } const mutagenProvision = await ensureMutagenAfterUpdate(); - const agentIntegrations = await syncAgentIntegrationsAfterUpdate({ - binaryPath: bin.path, - }); return writeResult({ json: args.options.json === true, @@ -246,11 +241,8 @@ const handleUpdate: CommandHandlerFor<Spec> = async ({ binaryPath: bin.path, assetsDir, mutagen: mutagenProvision, - agentIntegrations, }, - human: agentIntegrations.synced - ? `Updated to v${latestVersion}; refreshed ${agentIntegrations.scope === "all" ? "project and global" : "global"} agent integrations.` - : `Updated to v${latestVersion}. ${agentIntegrations.warning}`, + human: `Updated to v${latestVersion}.`, }); }; @@ -295,38 +287,6 @@ type MutagenProvisionResult = { readonly warning?: string; }; -type AgentIntegrationUpdateResult = { - readonly synced: boolean; - readonly scope: "global" | "all"; - readonly warning?: string; -}; - -/** Refresh the newly installed CLI's generated rules before the old process exits. */ -async function syncAgentIntegrationsAfterUpdate(opts: { - readonly binaryPath: string; -}): Promise<AgentIntegrationUpdateResult> { - const project = await findProjectContext(process.cwd()); - const scope = project ? "all" : "global"; - const args = [ - opts.binaryPath, - "setup", - "sync", - ...(project ? ["--all-scopes"] : ["--global"]), - ]; - const result = await exec(args, { stdin: "ignore" }); - if (result.exitCode === 0) { - return { synced: true, scope }; - } - const detail = result.stderr.trim() || result.stdout.trim(); - return { - synced: false, - scope, - warning: detail - ? `Agent integration refresh failed: ${detail}` - : "Agent integration refresh failed; run hack setup sync --all-scopes.", - }; -} - async function ensureMutagenAfterUpdate(): Promise<MutagenProvisionResult> { const existing = getMutagenPath(); if (existing) { @@ -457,7 +417,6 @@ type UpdateOutput = readonly binaryPath: string; readonly assetsDir: string; readonly mutagen?: MutagenProvisionResult; - readonly agentIntegrations?: AgentIntegrationUpdateResult; } | { readonly ok: false; diff --git a/src/commands/x.ts b/src/commands/x.ts index b77bae54..4a3fa044 100644 --- a/src/commands/x.ts +++ b/src/commands/x.ts @@ -59,15 +59,6 @@ async function handleX({ const namespace = invocation.namespace ?? ""; const extension = loaded.manager.getExtensionByNamespace({ namespace }); if (!extension) { - if (namespace === "github" || namespace === "linear") { - logger.error({ - message: - namespace === "github" - ? "Built-in GitHub integration was removed in v3. Use native git and gh workflows instead." - : "Built-in Linear integration was removed in v3. Use repo-local tickets and keep Linear outside Hack.", - }); - return 1; - } logger.error({ message: `Unknown extension namespace: ${namespace}` }); return 1; } diff --git a/src/control-plane/extensions/builtins.ts b/src/control-plane/extensions/builtins.ts index b81ae1a6..0d9eed99 100644 --- a/src/control-plane/extensions/builtins.ts +++ b/src/control-plane/extensions/builtins.ts @@ -2,10 +2,8 @@ import { CLOUDFLARE_EXTENSION } from "./cloudflare/extension.ts"; import { GATEWAY_EXTENSION } from "./gateway/extension.ts"; import { SUPERVISOR_EXTENSION } from "./supervisor/extension.ts"; import { TAILSCALE_EXTENSION } from "./tailscale/extension.ts"; -import { TICKETS_EXTENSION } from "./tickets/extension.ts"; export const BUILTIN_EXTENSIONS = [ - TICKETS_EXTENSION, SUPERVISOR_EXTENSION, GATEWAY_EXTENSION, CLOUDFLARE_EXTENSION, diff --git a/src/control-plane/extensions/tickets/agent-docs.ts b/src/control-plane/extensions/tickets/agent-docs.ts deleted file mode 100644 index 916a5020..00000000 --- a/src/control-plane/extensions/tickets/agent-docs.ts +++ /dev/null @@ -1,295 +0,0 @@ -import { resolve } from "node:path"; - -import { - pathExists, - readTextFile, - writeTextFileIfChanged, -} from "../../../lib/fs.ts"; - -export type TicketsAgentDocTarget = "agents" | "claude"; - -export type TicketsAgentDocUpdateResult = { - readonly target: TicketsAgentDocTarget; - readonly status: "created" | "updated" | "noop" | "error"; - readonly path: string; - readonly message?: string; -}; - -export type TicketsAgentDocCheckResult = { - readonly target: TicketsAgentDocTarget; - readonly status: - | "present" - | "missing" - | "noop" - | "absent" - | "deprecated" - | "error"; - readonly path: string; - readonly message?: string; -}; - -export type TicketsAgentDocRemoveResult = { - readonly target: TicketsAgentDocTarget; - readonly status: "removed" | "noop" | "error"; - readonly path: string; - readonly message?: string; -}; - -const DOC_MARKER_START = "<!-- hack:tickets:start -->"; -const DOC_MARKER_END = "<!-- hack:tickets:end -->"; - -export async function upsertTicketsAgentDocs(opts: { - readonly projectRoot: string; - readonly targets: readonly TicketsAgentDocTarget[]; -}): Promise<TicketsAgentDocUpdateResult[]> { - const results: TicketsAgentDocUpdateResult[] = []; - const snippet = renderTicketsAgentDocsSnippet(); - - for (const target of opts.targets) { - const path = resolveTicketsAgentDocPath({ - projectRoot: opts.projectRoot, - target, - }); - try { - const existed = await pathExists(path); - const existing = (await readTextFile(path)) ?? ""; - const next = upsertSnippet({ existing, snippet }); - const result = await writeTextFileIfChanged(path, next); - const status = resolveUpsertStatus({ changed: result.changed, existed }); - results.push({ target, status, path }); - } catch (error: unknown) { - const message = - error instanceof Error ? error.message : "Failed to update file"; - results.push({ target, status: "error", path, message }); - } - } - - return results; -} - -/** Check that deprecated tickets-only instruction blocks are absent. */ -export async function checkDeprecatedTicketsAgentDocs(opts: { - readonly projectRoot: string; - readonly targets: readonly TicketsAgentDocTarget[]; -}): Promise<TicketsAgentDocCheckResult[]> { - const results: TicketsAgentDocCheckResult[] = []; - for (const target of opts.targets) { - const path = resolveTicketsAgentDocPath({ - projectRoot: opts.projectRoot, - target, - }); - try { - const existing = await readTextFile(path); - const hasStart = existing?.includes(DOC_MARKER_START) === true; - const hasEnd = existing?.includes(DOC_MARKER_END) === true; - if (!(hasStart || hasEnd)) { - results.push({ target, status: "absent", path }); - continue; - } - if (!(hasStart && hasEnd)) { - results.push({ - target, - status: "error", - path, - message: "Malformed deprecated Hack Tickets markers.", - }); - continue; - } - results.push({ - target, - status: "deprecated", - path, - message: `Deprecated Hack Tickets instructions remain at ${path}. Run: hack setup sync --all-scopes`, - }); - } catch (error: unknown) { - results.push({ - target, - status: "error", - path, - message: error instanceof Error ? error.message : "Failed to read file", - }); - } - } - return results; -} - -export async function checkTicketsAgentDocs(opts: { - readonly projectRoot: string; - readonly targets: readonly TicketsAgentDocTarget[]; -}): Promise<TicketsAgentDocCheckResult[]> { - const results: TicketsAgentDocCheckResult[] = []; - - for (const target of opts.targets) { - const path = resolveTicketsAgentDocPath({ - projectRoot: opts.projectRoot, - target, - }); - try { - const existing = await readTextFile(path); - if (!existing) { - results.push({ target, status: "missing", path }); - continue; - } - - if (!hasTicketsAgentDocSnippet({ content: existing })) { - results.push({ - target, - status: "error", - path, - message: "Missing hack tickets markers.", - }); - continue; - } - - results.push({ target, status: "present", path }); - } catch (error: unknown) { - const message = - error instanceof Error ? error.message : "Failed to read file"; - results.push({ target, status: "error", path, message }); - } - } - - return results; -} - -export async function removeTicketsAgentDocs(opts: { - readonly projectRoot: string; - readonly targets: readonly TicketsAgentDocTarget[]; -}): Promise<TicketsAgentDocRemoveResult[]> { - const results: TicketsAgentDocRemoveResult[] = []; - - for (const target of opts.targets) { - const path = resolveTicketsAgentDocPath({ - projectRoot: opts.projectRoot, - target, - }); - try { - const existing = await readTextFile(path); - if (!existing) { - results.push({ target, status: "noop", path }); - continue; - } - - const next = removeSnippet({ existing }); - if (next === existing) { - results.push({ target, status: "noop", path }); - continue; - } - - await writeTextFileIfChanged(path, next); - results.push({ target, status: "removed", path }); - } catch (error: unknown) { - const message = - error instanceof Error ? error.message : "Failed to update file"; - results.push({ target, status: "error", path, message }); - } - } - - return results; -} - -export function renderTicketsAgentDocsSnippet(): string { - const lines = [ - DOC_MARKER_START, - "## Tickets (git-backed)", - "", - "This project uses `hack` tickets (extension: `dance.hack.tickets`).", - "", - "Common commands:", - '- Create: `hack tickets create --title "..." --body-stdin [--depends-on "T-AB12CD34EF"] [--blocks "T-ZX98NM12QR"]`', - "- List: `hack tickets list`", - "- Tui: `hack tickets tui`", - "- Show: `hack tickets show T-AB12CD34EF`", - '- Update: `hack tickets update T-AB12CD34EF [--title "..."] [--body "..."] [--depends-on "..."] [--blocks "..."]`', - "- Status: `hack tickets status T-AB12CD34EF in_progress`", - "- Sync: `hack tickets sync`", - "", - "Recommended body template (Markdown):", - "```md", - "## Context", - "## Goals", - "## Notes", - "## Links", - "```", - "", - "Tip: use `--body-stdin` for multi-line markdown.", - "", - "Data lives in `.hack/tickets/` (gitignored on the main branch) and syncs to hidden ref `refs/hack/tickets` by default.", - DOC_MARKER_END, - "", - ]; - - return lines.join("\n"); -} - -function resolveUpsertStatus(opts: { - readonly changed: boolean; - readonly existed: boolean; -}): "created" | "updated" | "noop" { - if (!opts.changed) { - return "noop"; - } - return opts.existed ? "updated" : "created"; -} - -function resolveTicketsAgentDocPath(opts: { - readonly projectRoot: string; - readonly target: TicketsAgentDocTarget; -}): string { - return resolve( - opts.projectRoot, - opts.target === "agents" ? "AGENTS.md" : "CLAUDE.md" - ); -} - -function hasTicketsAgentDocSnippet(opts: { - readonly content: string; -}): boolean { - return ( - opts.content.includes(DOC_MARKER_START) && - opts.content.includes(DOC_MARKER_END) - ); -} - -function upsertSnippet(opts: { - readonly existing: string; - readonly snippet: string; -}): string { - const existing = opts.existing; - const start = existing.indexOf(DOC_MARKER_START); - const end = existing.indexOf(DOC_MARKER_END); - - if (start !== -1 && end !== -1 && end > start) { - const afterEnd = end + DOC_MARKER_END.length; - const prefix = existing.slice(0, start).trimEnd(); - const suffix = existing.slice(afterEnd).trimStart(); - const glued = [prefix, opts.snippet.trim(), suffix] - .filter(Boolean) - .join("\n\n"); - return `${glued.trimEnd()}\n`; - } - - const trimmed = existing.trimEnd(); - if (!trimmed) { - return `${opts.snippet.trim()}\n`; - } - - return `${trimmed}\n\n${opts.snippet.trim()}\n`; -} - -function removeSnippet(opts: { readonly existing: string }): string { - const existing = opts.existing; - const start = existing.indexOf(DOC_MARKER_START); - const end = existing.indexOf(DOC_MARKER_END); - - if (start === -1 || end === -1 || end < start) { - return existing; - } - - const afterEnd = end + DOC_MARKER_END.length; - const prefix = existing.slice(0, start).trimEnd(); - const suffix = existing.slice(afterEnd).trimStart(); - - const next = [prefix, suffix].filter(Boolean).join("\n\n"); - return next ? `${next.trimEnd()}\n` : ""; -} diff --git a/src/control-plane/extensions/tickets/commands.ts b/src/control-plane/extensions/tickets/commands.ts deleted file mode 100644 index eabaaba3..00000000 --- a/src/control-plane/extensions/tickets/commands.ts +++ /dev/null @@ -1,2414 +0,0 @@ -import { runTicketsTui } from "../../../tui/tickets-tui.ts"; -import { display } from "../../../ui/display.ts"; -import { gumConfirm, isGumAvailable } from "../../../ui/gum.ts"; -import { isTty } from "../../../ui/terminal.ts"; -import type { ExtensionCommand, ExtensionCommandContext } from "../types.ts"; -import { - checkDeprecatedTicketsAgentDocs, - removeTicketsAgentDocs, - type TicketsAgentDocCheckResult, - type TicketsAgentDocRemoveResult, - type TicketsAgentDocUpdateResult, -} from "./agent-docs.ts"; -import { - isTicketDocumentKind, - isTicketDocumentRole, - type TicketDocumentKind, - type TicketDocumentRole, -} from "./documents.ts"; -import { - checkTicketsRepoState, - ensureTicketsGitignore, - type TicketsRepoGitignoreFixStatus, - type TicketsRepoGitignoreStatus, - type TicketsRepoTrackedStatus, - type TicketsRepoUntrackStatus, - untrackTicketsRepo, -} from "./repo-state.ts"; -import { - createTicketsStore, - type TicketSyncConflictResolution, -} from "./store.ts"; -import { createGitTicketsChannel } from "./tickets-git-channel.ts"; -import { - checkDeprecatedTicketsSkill, - removeTicketsSkill, -} from "./tickets-skill.ts"; -import { normalizeTicketRef, normalizeTicketRefs } from "./util.ts"; - -const TICKET_REF_SEPARATOR_PATTERN = /[,\s]+/; - -let didPromptTicketsGitHealth = false; -let didWarnTicketsDeprecation = false; - -export const TICKETS_COMMANDS: readonly ExtensionCommand[] = [ - { - name: "setup", - summary: "Deprecated: remove Tickets agent integrations and repair storage", - scope: "project", - // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Tickets setup intentionally coordinates repo, skill, and docs repair in one CLI flow. - handler: async ({ ctx, args }) => { - if (!ctx.project) { - ctx.logger.error({ message: "No project found. Run inside a repo." }); - return 1; - } - - const parsed = parseTicketsSetupArgs({ args }); - if (!parsed.ok) { - ctx.logger.error({ message: parsed.error }); - return 1; - } - - const targets = parsed.value.all - ? (["agents", "claude"] as const) - : ([ - ...(parsed.value.agents ? (["agents"] as const) : []), - ...(parsed.value.claude ? (["claude"] as const) : []), - ] as const); - - const resolvedTargets = - targets.length > 0 ? targets : (["agents", "claude"] as const); - - const scope = parsed.value.global ? "user" : "project"; - const projectRoot = ctx.project.projectRoot; - - let action: "install" | "check" | "remove"; - if (parsed.value.remove) { - action = "remove"; - } else if (parsed.value.check) { - action = "check"; - } else { - action = "install"; - } - const repoState = await checkTicketsRepoState({ projectRoot }); - - let repoGitignore: { - status: TicketsRepoGitignoreStatus | TicketsRepoGitignoreFixStatus; - path: string; - message?: string; - } = { - status: repoState.gitignore.status, - path: repoState.gitignore.path, - message: repoState.gitignore.message, - }; - let repoTracking: { - status: TicketsRepoTrackedStatus | TicketsRepoUntrackStatus; - message?: string; - } = { - status: repoState.tracked.status, - message: repoState.tracked.message, - }; - - if (action === "install") { - if (repoState.gitignore.status === "missing") { - repoGitignore = await ensureTicketsGitignore({ projectRoot }); - } else if (repoState.gitignore.status === "present") { - repoGitignore = { status: "noop", path: repoState.gitignore.path }; - } - - if (repoState.tracked.status === "tracked") { - const canPrompt = isTty() && isGumAvailable() && !parsed.value.json; - if (canPrompt) { - const confirmed = await gumConfirm({ - prompt: - "Untrack .hack/tickets from the main branch? (keeps files on disk)", - default: true, - }); - if (confirmed.ok && confirmed.value) { - repoTracking = await untrackTicketsRepo({ projectRoot }); - } else { - repoTracking = { - status: "skipped", - message: "Skipped untracking .hack/tickets.", - }; - } - } else { - repoTracking = { - status: "skipped", - message: "Run: git rm -r --cached .hack/tickets", - }; - } - } - } - - let skill: Awaited<ReturnType<typeof checkDeprecatedTicketsSkill>>; - const skillProjectRoot = scope === "project" ? projectRoot : undefined; - if (action === "check") { - skill = await checkDeprecatedTicketsSkill({ - scope, - projectRoot: skillProjectRoot, - }); - } else { - skill = await removeTicketsSkill({ - scope, - projectRoot: skillProjectRoot, - }); - } - - let docs: - | TicketsAgentDocCheckResult[] - | TicketsAgentDocRemoveResult[] - | TicketsAgentDocUpdateResult[]; - if (action === "check") { - docs = await checkDeprecatedTicketsAgentDocs({ - projectRoot, - targets: resolvedTargets, - }); - } else { - docs = await removeTicketsAgentDocs({ - projectRoot, - targets: resolvedTargets, - }); - } - - if (parsed.value.json) { - process.stdout.write( - `${JSON.stringify({ skill, docs, repo: { gitignore: repoGitignore, tracking: repoTracking } }, null, 2)}\n` - ); - return 0; - } - - await display.panel({ - title: "Tickets deprecated", - tone: "warn", - lines: [ - "Agent skills and instruction blocks are no longer installed.", - `skill: ${skill.status} (${skill.path})`, - ...docs.map((r) => `${r.target}: ${r.status} (${r.path})`), - `repo.gitignore: ${repoGitignore.status} (${repoGitignore.path})`, - `repo.tracking: ${repoTracking.status}${ - repoTracking.message ? ` (${repoTracking.message})` : "" - }`, - ], - }); - - if (action === "install") { - await maybeEnsureTicketsGitHealth({ ctx, json: parsed.value.json }); - } - - const deprecatedFound = - action === "check" && - (skill.status === "deprecated" || - docs.some((result) => result.status === "deprecated")); - return docs.some((r) => r.status === "error") || - skill.status === "error" || - deprecatedFound - ? 1 - : 0; - }, - }, - { - name: "create", - summary: "Create a new ticket", - scope: "project", - // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Ticket creation keeps validation and projection writeback together for CLI UX. - handler: async ({ ctx, args }) => { - if (!ctx.project) { - ctx.logger.error({ message: "No project found. Run inside a repo." }); - return 1; - } - - const parsed = parseTicketsArgs({ args }); - if (!parsed.ok) { - ctx.logger.error({ message: parsed.error }); - return 1; - } - - const title = (parsed.value.title ?? "").trim(); - if (!title) { - ctx.logger.error({ - message: 'Usage: hack x tickets create --title "..."', - }); - return 1; - } - - await maybeEnsureTicketsSetup({ ctx, json: parsed.value.json }); - - const store = createTicketsStore({ - projectRoot: ctx.project.projectRoot, - projectId: ctx.projectId, - projectName: ctx.projectName, - controlPlaneConfig: ctx.controlPlaneConfig, - logger: ctx.logger, - }); - - const body = await resolveTicketBody({ - body: parsed.value.body, - bodyFile: parsed.value.bodyFile, - bodyStdin: parsed.value.bodyStdin, - }); - - const dependsOnResult = resolveTicketRefs({ - values: parsed.value.dependsOn, - label: "--depends-on", - }); - if (!dependsOnResult.ok) { - ctx.logger.error({ message: dependsOnResult.error }); - return 1; - } - - const blocksResult = resolveTicketRefs({ - values: parsed.value.blocks, - label: "--blocks", - }); - if (!blocksResult.ok) { - ctx.logger.error({ message: blocksResult.error }); - return 1; - } - - const created = await store.createTicket({ - title, - body, - ...(dependsOnResult.refs.length > 0 - ? { dependsOn: dependsOnResult.refs } - : {}), - ...(blocksResult.refs.length > 0 ? { blocks: blocksResult.refs } : {}), - ...(parsed.value.owner ? { owner: parsed.value.owner } : {}), - ...(parsed.value.source ? { source: parsed.value.source } : {}), - ...(parsed.value.assignee ? { assignee: parsed.value.assignee } : {}), - ...(parsed.value.tags.length > 0 ? { tags: parsed.value.tags } : {}), - ...(parsed.value.externalSystem - ? { externalSystem: parsed.value.externalSystem } - : {}), - ...(parsed.value.externalId - ? { externalId: parsed.value.externalId } - : {}), - ...(parsed.value.externalKey - ? { externalKey: parsed.value.externalKey } - : {}), - ...(parsed.value.externalUrl - ? { externalUrl: parsed.value.externalUrl } - : {}), - ...(parsed.value.externalProjectId - ? { externalProjectId: parsed.value.externalProjectId } - : {}), - ...(parsed.value.externalProjectName - ? { externalProjectName: parsed.value.externalProjectName } - : {}), - ...(parsed.value.externalTeamId - ? { externalTeamId: parsed.value.externalTeamId } - : {}), - actor: parsed.value.actor, - }); - - if (!created.ok) { - ctx.logger.error({ message: created.error }); - return 1; - } - - if (parsed.value.json) { - process.stdout.write( - `${JSON.stringify({ ticket: created.ticket }, null, 2)}\n` - ); - return 0; - } - - await display.kv({ - title: "Ticket created", - entries: [ - ["ticket_id", created.ticket.ticketId], - ["title", created.ticket.title], - ["status", created.ticket.status], - ["owner", created.ticket.owner], - ["source", created.ticket.source], - ["created_at", created.ticket.createdAt], - ["updated_at", created.ticket.updatedAt], - ], - }); - return 0; - }, - }, - { - name: "update", - summary: "Update a ticket", - scope: "project", - // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Ticket update keeps patch validation and projection writeback together for CLI UX. - handler: async ({ ctx, args }) => { - if (!ctx.project) { - ctx.logger.error({ message: "No project found. Run inside a repo." }); - return 1; - } - - const parsed = parseTicketsArgs({ args }); - if (!parsed.ok) { - ctx.logger.error({ message: parsed.error }); - return 1; - } - - const ticketId = (parsed.value.rest[0] ?? "").trim(); - if (!ticketId) { - ctx.logger.error({ - message: - 'Usage: hack x tickets update <ticket-id> [--title "..."] [--body "..."] [--body-file <path>] [--body-stdin] [--depends-on "..."] [--blocks "..."] [--clear-depends-on] [--clear-blocks] [--json]', - }); - return 1; - } - - const title = parsed.value.title?.trim(); - if (parsed.value.title !== undefined && !title) { - ctx.logger.error({ message: "Title cannot be empty." }); - return 1; - } - - if (parsed.value.clearDependsOn && parsed.value.dependsOn.length > 0) { - ctx.logger.error({ - message: "--clear-depends-on cannot be combined with --depends-on.", - }); - return 1; - } - - if (parsed.value.clearBlocks && parsed.value.blocks.length > 0) { - ctx.logger.error({ - message: "--clear-blocks cannot be combined with --blocks.", - }); - return 1; - } - if (parsed.value.clearTags && parsed.value.tags.length > 0) { - ctx.logger.error({ - message: "--clear-tags cannot be combined with --tags/--tag.", - }); - return 1; - } - if (parsed.value.clearAssignee && parsed.value.assignee !== undefined) { - ctx.logger.error({ - message: "--clear-assignee cannot be combined with --assignee.", - }); - return 1; - } - - const bodyRequested = - parsed.value.body !== undefined || - parsed.value.bodyFile !== undefined || - parsed.value.bodyStdin; - - const body = bodyRequested - ? await resolveTicketBody({ - body: parsed.value.body, - bodyFile: parsed.value.bodyFile, - bodyStdin: parsed.value.bodyStdin, - allowEmpty: true, - }) - : undefined; - - const dependsOnResult = - parsed.value.dependsOn.length > 0 - ? resolveTicketRefs({ - values: parsed.value.dependsOn, - label: "--depends-on", - }) - : { ok: true as const, refs: [] }; - - if (!dependsOnResult.ok) { - ctx.logger.error({ message: dependsOnResult.error }); - return 1; - } - - const blocksResult = - parsed.value.blocks.length > 0 - ? resolveTicketRefs({ - values: parsed.value.blocks, - label: "--blocks", - }) - : { ok: true as const, refs: [] }; - - if (!blocksResult.ok) { - ctx.logger.error({ message: blocksResult.error }); - return 1; - } - - let dependsOn: string[] | undefined; - if (parsed.value.clearDependsOn) { - dependsOn = []; - } else if (parsed.value.dependsOn.length > 0) { - dependsOn = dependsOnResult.refs; - } else { - dependsOn = undefined; - } - - let blocks: string[] | undefined; - if (parsed.value.clearBlocks) { - blocks = []; - } else if (parsed.value.blocks.length > 0) { - blocks = blocksResult.refs; - } else { - blocks = undefined; - } - - let tags: string[] | undefined; - if (parsed.value.clearTags) { - tags = []; - } else if (parsed.value.tags.length > 0) { - tags = [...parsed.value.tags]; - } else { - tags = undefined; - } - - const hasUpdates = - title !== undefined || - bodyRequested || - dependsOn !== undefined || - blocks !== undefined || - parsed.value.assignee !== undefined || - parsed.value.clearAssignee || - tags !== undefined || - parsed.value.owner !== undefined || - parsed.value.source !== undefined || - parsed.value.externalSystem !== undefined || - parsed.value.externalId !== undefined || - parsed.value.externalKey !== undefined || - parsed.value.externalUrl !== undefined || - parsed.value.externalProjectId !== undefined || - parsed.value.externalProjectName !== undefined || - parsed.value.externalTeamId !== undefined; - - if (!hasUpdates) { - ctx.logger.error({ message: "No updates provided." }); - return 1; - } - - await maybeEnsureTicketsSetup({ ctx, json: parsed.value.json }); - - const store = createTicketsStore({ - projectRoot: ctx.project.projectRoot, - projectId: ctx.projectId, - projectName: ctx.projectName, - controlPlaneConfig: ctx.controlPlaneConfig, - logger: ctx.logger, - }); - - const updated = await store.updateTicket({ - ticketId, - ...(title !== undefined ? { title } : {}), - ...(bodyRequested ? { body } : {}), - ...(dependsOn !== undefined ? { dependsOn } : {}), - ...(blocks !== undefined ? { blocks } : {}), - ...(tags !== undefined ? { tags } : {}), - ...(parsed.value.owner !== undefined - ? { owner: parsed.value.owner } - : {}), - ...(parsed.value.source !== undefined - ? { source: parsed.value.source } - : {}), - ...(parsed.value.clearAssignee ? { assignee: "" } : {}), - ...(!parsed.value.clearAssignee && parsed.value.assignee !== undefined - ? { assignee: parsed.value.assignee } - : {}), - ...(parsed.value.externalSystem !== undefined - ? { externalSystem: parsed.value.externalSystem } - : {}), - ...(parsed.value.externalId !== undefined - ? { externalId: parsed.value.externalId } - : {}), - ...(parsed.value.externalKey !== undefined - ? { externalKey: parsed.value.externalKey } - : {}), - ...(parsed.value.externalUrl !== undefined - ? { externalUrl: parsed.value.externalUrl } - : {}), - ...(parsed.value.externalProjectId !== undefined - ? { externalProjectId: parsed.value.externalProjectId } - : {}), - ...(parsed.value.externalProjectName !== undefined - ? { externalProjectName: parsed.value.externalProjectName } - : {}), - ...(parsed.value.externalTeamId !== undefined - ? { externalTeamId: parsed.value.externalTeamId } - : {}), - actor: parsed.value.actor, - }); - - if (!updated.ok) { - ctx.logger.error({ message: updated.error }); - return 1; - } - - if (parsed.value.json) { - process.stdout.write( - `${JSON.stringify({ ok: true, ticketId }, null, 2)}\n` - ); - return 0; - } - - await display.panel({ - title: "Ticket updated", - tone: "success", - lines: [`${ticketId} updated`], - }); - - return 0; - }, - }, - { - name: "comment", - summary: "Append an immutable ticket comment", - scope: "project", - handler: async ({ ctx, args }) => { - if (!ctx.project) { - ctx.logger.error({ message: "No project found. Run inside a repo." }); - return 1; - } - - const parsed = parseTicketsArgs({ args }); - if (!parsed.ok) { - ctx.logger.error({ message: parsed.error }); - return 1; - } - - const ticketId = (parsed.value.rest[0] ?? "").trim(); - if (!ticketId) { - ctx.logger.error({ - message: - 'Usage: hack x tickets comment <ticket-id> [--body "..."] [--body-file <path>] [--body-stdin] [--source hack] [--json]', - }); - return 1; - } - - const body = await resolveTicketBody({ - body: parsed.value.body, - bodyFile: parsed.value.bodyFile, - bodyStdin: parsed.value.bodyStdin, - }); - if (!body) { - ctx.logger.error({ - message: - "Comment body is required. Use --body, --body-file, or --body-stdin.", - }); - return 1; - } - - await maybeEnsureTicketsSetup({ ctx, json: parsed.value.json }); - - const store = createTicketsStore({ - projectRoot: ctx.project.projectRoot, - projectId: ctx.projectId, - projectName: ctx.projectName, - controlPlaneConfig: ctx.controlPlaneConfig, - logger: ctx.logger, - }); - - const appended = await store.appendComment({ - ticketId, - body, - ...(parsed.value.source ? { source: parsed.value.source } : {}), - actor: parsed.value.actor, - }); - if (!appended.ok) { - ctx.logger.error({ message: appended.error }); - return 1; - } - - if (parsed.value.json) { - process.stdout.write( - `${JSON.stringify({ comment: appended.comment }, null, 2)}\n` - ); - return 0; - } - - await display.panel({ - title: "Ticket comment", - tone: "success", - lines: [`${ticketId} comment appended`, appended.comment.body], - }); - return 0; - }, - }, - { - name: "review-note", - summary: "Append a shared ticket review note", - scope: "project", - handler: async ({ ctx, args }) => { - if (!ctx.project) { - ctx.logger.error({ message: "No project found. Run inside a repo." }); - return 1; - } - - const parsed = parseTicketsArgs({ args }); - if (!parsed.ok) { - ctx.logger.error({ message: parsed.error }); - return 1; - } - - const ticketId = (parsed.value.rest[0] ?? "").trim(); - if (!ticketId) { - ctx.logger.error({ - message: - 'Usage: hack x tickets review-note <ticket-id> [--body "..."] [--body-file <path>] [--body-stdin] [--json]', - }); - return 1; - } - - const body = await resolveTicketBody({ - body: parsed.value.body, - bodyFile: parsed.value.bodyFile, - bodyStdin: parsed.value.bodyStdin, - }); - if (!body) { - ctx.logger.error({ - message: - "Review note body is required. Use --body, --body-file, or --body-stdin.", - }); - return 1; - } - - await maybeEnsureTicketsSetup({ ctx, json: parsed.value.json }); - - const store = createTicketsStore({ - projectRoot: ctx.project.projectRoot, - projectId: ctx.projectId, - projectName: ctx.projectName, - controlPlaneConfig: ctx.controlPlaneConfig, - logger: ctx.logger, - }); - - const appended = await store.appendReviewNote({ - ticketId, - body, - actor: parsed.value.actor, - }); - if (!appended.ok) { - ctx.logger.error({ message: appended.error }); - return 1; - } - - if (parsed.value.json) { - process.stdout.write( - `${JSON.stringify({ reviewNote: appended.reviewNote }, null, 2)}\n` - ); - return 0; - } - - await display.panel({ - title: "Ticket review note", - tone: "success", - lines: [`${ticketId} review note appended`, appended.reviewNote.body], - }); - return 0; - }, - }, - { - name: "document", - summary: "Append an immutable ticket document", - scope: "project", - handler: async ({ ctx, args }) => { - if (!ctx.project) { - ctx.logger.error({ message: "No project found. Run inside a repo." }); - return 1; - } - - const parsed = parseTicketsArgs({ args }); - if (!parsed.ok) { - ctx.logger.error({ message: parsed.error }); - return 1; - } - - const ticketId = (parsed.value.rest[0] ?? "").trim(); - if (!ticketId) { - ctx.logger.error({ - message: - 'Usage: hack x tickets document <ticket-id> --kind <description|spec|notes> [--role <description|spec|notes|handoff>] [--body "..."] [--body-file <path>] [--body-stdin] [--json]', - }); - return 1; - } - if (!parsed.value.kind) { - ctx.logger.error({ - message: - "Document kind is required. Use --kind <description|spec|notes>.", - }); - return 1; - } - - const body = await resolveTicketBody({ - body: parsed.value.body, - bodyFile: parsed.value.bodyFile, - bodyStdin: parsed.value.bodyStdin, - }); - if (!body) { - ctx.logger.error({ - message: - "Document body is required. Use --body, --body-file, or --body-stdin.", - }); - return 1; - } - - await maybeEnsureTicketsSetup({ ctx, json: parsed.value.json }); - - const store = createTicketsStore({ - projectRoot: ctx.project.projectRoot, - projectId: ctx.projectId, - projectName: ctx.projectName, - controlPlaneConfig: ctx.controlPlaneConfig, - logger: ctx.logger, - }); - - const appended = await store.appendDocument({ - ticketId, - kind: parsed.value.kind, - ...(parsed.value.role ? { role: parsed.value.role } : {}), - content: body, - actor: parsed.value.actor, - }); - if (!appended.ok) { - ctx.logger.error({ message: appended.error }); - return 1; - } - - if (parsed.value.json) { - process.stdout.write( - `${JSON.stringify({ document: appended.document }, null, 2)}\n` - ); - return 0; - } - - await display.panel({ - title: "Ticket document", - tone: "success", - lines: [ - `${ticketId} ${appended.document.role} document appended`, - appended.document.content, - ], - }); - return 0; - }, - }, - { - name: "list", - summary: "List tickets", - scope: "project", - handler: async ({ ctx, args }) => { - if (!ctx.project) { - ctx.logger.error({ message: "No project found. Run inside a repo." }); - return 1; - } - - const parsed = parseTicketsArgs({ args }); - if (!parsed.ok) { - ctx.logger.error({ message: parsed.error }); - return 1; - } - - await maybeEnsureTicketsSetup({ ctx, json: parsed.value.json }); - - const store = createTicketsStore({ - projectRoot: ctx.project.projectRoot, - projectId: ctx.projectId, - projectName: ctx.projectName, - controlPlaneConfig: ctx.controlPlaneConfig, - logger: ctx.logger, - }); - - const tickets = (await store.listTickets()).filter((ticket) => { - if ( - parsed.value.owner && - ticket.owner.toLowerCase() !== parsed.value.owner.toLowerCase() - ) { - return false; - } - if ( - parsed.value.source && - ticket.source.toLowerCase() !== parsed.value.source.toLowerCase() - ) { - return false; - } - if ( - parsed.value.externalSystem && - (ticket.externalSystem ?? "").toLowerCase() !== - parsed.value.externalSystem.toLowerCase() - ) { - return false; - } - return true; - }); - - if (parsed.value.json) { - process.stdout.write(`${JSON.stringify({ tickets }, null, 2)}\n`); - return 0; - } - - if (tickets.length === 0) { - await display.panel({ - title: "Tickets", - tone: "info", - lines: ["No tickets found."], - }); - return 0; - } - - await display.table({ - columns: ["Id", "Title", "Status", "Owner", "Source", "Updated"], - rows: tickets.map((ticket) => [ - ticket.ticketId, - ticket.title, - ticket.status, - ticket.owner, - ticket.source, - ticket.updatedAt, - ]), - }); - return 0; - }, - }, - { - name: "tui", - summary: "Open tickets TUI", - scope: "project", - handler: async ({ ctx, args }) => { - if (!ctx.project) { - ctx.logger.error({ message: "No project found. Run inside a repo." }); - return 1; - } - - if (args.length > 0) { - ctx.logger.error({ message: "Usage: hack x tickets tui" }); - return 1; - } - - await maybeEnsureTicketsSetup({ ctx, json: false }); - - return await runTicketsTui({ - projectRoot: ctx.project.projectRoot, - projectId: ctx.projectId, - projectName: ctx.projectName, - controlPlaneConfig: ctx.controlPlaneConfig, - logger: ctx.logger, - }); - }, - }, - { - name: "show", - summary: "Show a ticket", - scope: "project", - handler: async ({ ctx, args }) => { - const project = ctx.project; - if (!project) { - ctx.logger.error({ message: "No project found. Run inside a repo." }); - return 1; - } - - const parsed = parseTicketsArgs({ args }); - if (!parsed.ok) { - ctx.logger.error({ message: parsed.error }); - return 1; - } - - const ticketId = (parsed.value.rest[0] ?? "").trim(); - if (!ticketId) { - ctx.logger.error({ message: "Usage: hack x tickets show <ticket-id>" }); - return 1; - } - - await maybeEnsureTicketsSetup({ ctx, json: parsed.value.json }); - - const store = createTicketsStore({ - projectRoot: project.projectRoot, - projectId: ctx.projectId, - projectName: ctx.projectName, - controlPlaneConfig: ctx.controlPlaneConfig, - logger: ctx.logger, - }); - - const detail = await store.getTicketDetail({ ticketId }); - const ticket = detail.ticket; - if (!ticket) { - ctx.logger.error({ message: `Ticket not found: ${ticketId}` }); - return 1; - } - - await renderTicketDetail({ - detail, - json: parsed.value.json, - }); - return 0; - }, - }, - { - name: "resolve-conflict", - summary: "Resolve a recorded ticket sync conflict", - scope: "project", - handler: async ({ ctx, args }) => { - if (!ctx.project) { - ctx.logger.error({ message: "No project found. Run inside a repo." }); - return 1; - } - - const parsed = parseResolveConflictArgs({ args }); - if (!parsed.ok) { - ctx.logger.error({ message: parsed.error }); - return 1; - } - - const ticketId = (parsed.value.rest[0] ?? "").trim(); - if (!ticketId) { - ctx.logger.error({ - message: - 'Usage: hack x tickets resolve-conflict <ticket-id> --conflict-id <id> --resolution <accept_local|accept_remote|merged|ignore> [--summary "..."] [--json]', - }); - return 1; - } - - await maybeEnsureTicketsSetup({ ctx, json: parsed.value.json }); - - const store = createTicketsStore({ - projectRoot: ctx.project.projectRoot, - projectId: ctx.projectId, - projectName: ctx.projectName, - controlPlaneConfig: ctx.controlPlaneConfig, - logger: ctx.logger, - }); - - const resolved = await store.resolveSyncConflict({ - ticketId, - conflictId: parsed.value.conflictId, - resolution: parsed.value.resolution, - ...(parsed.value.summary !== undefined - ? { summary: parsed.value.summary } - : {}), - actor: parsed.value.actor, - }); - if (!resolved.ok) { - ctx.logger.error({ message: resolved.error }); - return 1; - } - - if (parsed.value.json) { - process.stdout.write( - `${JSON.stringify( - { - ok: true, - ticketId, - conflictId: parsed.value.conflictId, - resolution: parsed.value.resolution, - }, - null, - 2 - )}\n` - ); - return 0; - } - - await display.panel({ - title: "Ticket conflict resolved", - tone: "success", - lines: [ - `${ticketId} ${parsed.value.conflictId} → ${parsed.value.resolution}`, - ], - }); - return 0; - }, - }, - { - name: "status", - summary: "Change ticket status", - scope: "project", - handler: async ({ ctx, args }) => { - if (!ctx.project) { - ctx.logger.error({ message: "No project found. Run inside a repo." }); - return 1; - } - - const parsed = parseTicketsArgs({ args }); - if (!parsed.ok) { - ctx.logger.error({ message: parsed.error }); - return 1; - } - - const ticketId = (parsed.value.rest[0] ?? "").trim(); - const status = (parsed.value.rest[1] ?? "").trim(); - if (!(ticketId && status)) { - ctx.logger.error({ - message: - "Usage: hack x tickets status <ticket-id> <open|in_progress|blocked|done>", - }); - return 1; - } - - if ( - status !== "open" && - status !== "in_progress" && - status !== "blocked" && - status !== "done" - ) { - ctx.logger.error({ message: `Invalid status: ${status}` }); - return 1; - } - - await maybeEnsureTicketsSetup({ ctx, json: parsed.value.json }); - - const store = createTicketsStore({ - projectRoot: ctx.project.projectRoot, - projectId: ctx.projectId, - projectName: ctx.projectName, - controlPlaneConfig: ctx.controlPlaneConfig, - logger: ctx.logger, - }); - - const updated = await store.setStatus({ - ticketId, - status, - actor: parsed.value.actor, - }); - - if (!updated.ok) { - ctx.logger.error({ message: updated.error }); - return 1; - } - - if (parsed.value.json) { - process.stdout.write( - `${JSON.stringify({ ok: true, ticketId, status }, null, 2)}\n` - ); - return 0; - } - - await display.panel({ - title: "Ticket status", - tone: "success", - lines: [`${ticketId} → ${status}`], - }); - - return 0; - }, - }, - { - name: "sync", - summary: "Sync ticket events with git remote", - scope: "project", - handler: async ({ ctx, args }) => { - if (!ctx.project) { - ctx.logger.error({ message: "No project found. Run inside a repo." }); - return 1; - } - - const parsed = parseTicketsArgs({ args }); - if (!parsed.ok) { - ctx.logger.error({ message: parsed.error }); - return 1; - } - - await maybeEnsureTicketsSetup({ ctx, json: parsed.value.json }); - - const store = createTicketsStore({ - projectRoot: ctx.project.projectRoot, - projectId: ctx.projectId, - projectName: ctx.projectName, - controlPlaneConfig: ctx.controlPlaneConfig, - logger: ctx.logger, - }); - - const synced = await store.sync(); - if (!synced.ok) { - ctx.logger.error({ message: synced.error }); - return 1; - } - - if (parsed.value.json) { - process.stdout.write(`${JSON.stringify({ sync: synced }, null, 2)}\n`); - return 0; - } - - await display.panel({ - title: "Tickets sync", - tone: "success", - lines: [ - `branch: ${synced.branch}`, - `remote: ${synced.remote ?? "(none)"}`, - `committed: ${synced.didCommit ? "yes" : "no"}`, - `pushed: ${synced.didPush ? "yes" : "no"}`, - ], - }); - return 0; - }, - }, -]; - -type TicketsArgs = { - readonly title?: string; - readonly body?: string; - readonly bodyFile?: string; - readonly bodyStdin: boolean; - readonly kind?: TicketDocumentKind; - readonly role?: TicketDocumentRole; - readonly dependsOn: readonly string[]; - readonly blocks: readonly string[]; - readonly clearDependsOn: boolean; - readonly clearBlocks: boolean; - readonly owner?: string; - readonly source?: string; - readonly assignee?: string; - readonly clearAssignee: boolean; - readonly tags: readonly string[]; - readonly clearTags: boolean; - readonly externalSystem?: string; - readonly externalId?: string; - readonly externalKey?: string; - readonly externalUrl?: string; - readonly externalProjectId?: string; - readonly externalProjectName?: string; - readonly externalTeamId?: string; - readonly actor?: string; - readonly json: boolean; - readonly rest: readonly string[]; -}; - -type TicketsParseResult = - | { readonly ok: true; readonly value: TicketsArgs } - | { readonly ok: false; readonly error: string }; - -type TicketsSetupArgs = { - readonly agents: boolean; - readonly claude: boolean; - readonly all: boolean; - readonly global: boolean; - readonly check: boolean; - readonly remove: boolean; - readonly json: boolean; -}; - -type ResolveConflictArgs = { - readonly conflictId: string; - readonly resolution: TicketSyncConflictResolution; - readonly summary?: string; - readonly actor?: string; - readonly json: boolean; - readonly rest: readonly string[]; -}; - -type TicketsSetupParseResult = - | { readonly ok: true; readonly value: TicketsSetupArgs } - | { readonly ok: false; readonly error: string }; - -type MutableTicketsArgs = { - title?: string; - body?: string; - bodyFile?: string; - bodyStdin: boolean; - kind?: TicketDocumentKind; - role?: TicketDocumentRole; - dependsOn: string[]; - blocks: string[]; - clearDependsOn: boolean; - clearBlocks: boolean; - owner?: string; - source?: string; - assignee?: string; - clearAssignee: boolean; - tags: string[]; - clearTags: boolean; - externalSystem?: string; - externalId?: string; - externalKey?: string; - externalUrl?: string; - externalProjectId?: string; - externalProjectName?: string; - externalTeamId?: string; - actor?: string; - json: boolean; - rest: string[]; -}; - -type MutableResolveConflictArgs = { - conflictId?: string; - resolution?: TicketSyncConflictResolution; - summary?: string; - actor?: string; - json: boolean; - rest: string[]; -}; - -type MutableTicketsSetupArgs = { - agents: boolean; - claude: boolean; - all: boolean; - global: boolean; - check: boolean; - remove: boolean; - json: boolean; -}; - -type TicketsSetupNeeds = { - readonly needsGitignore: boolean; - readonly needsUntrack: boolean; -}; - -type TicketDetailResult = Awaited< - ReturnType<ReturnType<typeof createTicketsStore>["getTicketDetail"]> ->; - -type TicketsGitHealthSummary = { - readonly hasRefDivergence: boolean; - readonly hasLegacyRef: boolean; - readonly legacyRef?: string; - readonly hasNonTicketFiles: boolean; - readonly nonTicketPaths: readonly string[]; - readonly remoteRefOid?: string; - readonly legacyRefOid?: string; -}; - -type TicketsRepairSummary = { - readonly didCommit: boolean; - readonly didPush: boolean; - readonly pruneError?: string; -}; - -type ParseHandlerResult = { - readonly handled: boolean; - readonly nextIndex: number; - readonly error?: string; -}; - -type ConsumedOptionValue = - | { readonly matched: false } - | { - readonly matched: true; - readonly nextIndex: number; - readonly value?: string; - readonly error?: string; - }; - -type TicketsSetupTokenResult = - | { readonly ok: true } - | { readonly ok: false; readonly error: string }; - -function parseTicketsArgs(opts: { - readonly args: readonly string[]; -}): TicketsParseResult { - const state: MutableTicketsArgs = { - bodyStdin: false, - dependsOn: [], - blocks: [], - clearDependsOn: false, - clearBlocks: false, - clearAssignee: false, - tags: [], - clearTags: false, - json: false, - rest: [], - }; - - for (let i = 0; i < opts.args.length; i += 1) { - const token = opts.args[i] ?? ""; - - if (token === "--") { - state.rest.push(...opts.args.slice(i + 1)); - break; - } - - if (token === "--json") { - state.json = true; - continue; - } - const handlers = [ - parseTicketContentOption, - parseTicketDocumentOption, - parseTicketRelationshipOption, - parseTicketIdentityOption, - parseTicketTagOption, - parseTicketExternalOption, - ] as const; - let handled = false; - for (const handler of handlers) { - const result = handler({ - args: opts.args, - index: i, - state, - }); - if (result.error) { - return { ok: false, error: result.error }; - } - if (result.handled) { - i = result.nextIndex; - handled = true; - break; - } - } - if (handled) { - continue; - } - - if (token.startsWith("-")) { - return { ok: false, error: `Unknown option: ${token}` }; - } - - state.rest.push(token); - } - - return { - ok: true, - value: finalizeTicketsArgs(state), - }; -} - -function parseResolveConflictArgs(opts: { - readonly args: readonly string[]; -}): - | { readonly ok: true; readonly value: ResolveConflictArgs } - | { readonly ok: false; readonly error: string } { - const state: MutableResolveConflictArgs = { - json: false, - rest: [], - }; - - for (let i = 0; i < opts.args.length; i += 1) { - const token = opts.args[i] ?? ""; - if (token === "--") { - state.rest.push(...opts.args.slice(i + 1)); - break; - } - if (token === "--json") { - state.json = true; - continue; - } - - const result = parseResolveConflictOption({ - args: opts.args, - index: i, - state, - }); - if (result.error) { - return { ok: false, error: result.error }; - } - if (result.handled) { - i = result.nextIndex; - continue; - } - - if (token.startsWith("-")) { - return { ok: false, error: `Unknown option: ${token}` }; - } - state.rest.push(token); - } - - if (!state.conflictId) { - return { ok: false, error: "Missing --conflict-id <ID>." }; - } - if (!state.resolution) { - return { - ok: false, - error: "Missing --resolution <accept_local|accept_remote|merged|ignore>.", - }; - } - - return { - ok: true, - value: { - conflictId: state.conflictId, - resolution: state.resolution, - ...(state.summary !== undefined ? { summary: state.summary } : {}), - ...(state.actor ? { actor: state.actor } : {}), - json: state.json, - rest: state.rest, - }, - }; -} - -function finalizeTicketsArgs(state: MutableTicketsArgs): TicketsArgs { - return { - ...(state.title ? { title: state.title } : {}), - ...(state.body ? { body: state.body } : {}), - ...(state.bodyFile ? { bodyFile: state.bodyFile } : {}), - bodyStdin: state.bodyStdin, - ...(state.kind ? { kind: state.kind } : {}), - ...(state.role ? { role: state.role } : {}), - dependsOn: state.dependsOn, - blocks: state.blocks, - clearDependsOn: state.clearDependsOn, - clearBlocks: state.clearBlocks, - ...(state.owner ? { owner: state.owner } : {}), - ...(state.source ? { source: state.source } : {}), - ...(state.assignee !== undefined ? { assignee: state.assignee } : {}), - clearAssignee: state.clearAssignee, - tags: normalizeTags(state.tags), - clearTags: state.clearTags, - ...(state.externalSystem !== undefined - ? { externalSystem: state.externalSystem } - : {}), - ...(state.externalId !== undefined ? { externalId: state.externalId } : {}), - ...(state.externalKey !== undefined - ? { externalKey: state.externalKey } - : {}), - ...(state.externalUrl !== undefined - ? { externalUrl: state.externalUrl } - : {}), - ...(state.externalProjectId !== undefined - ? { externalProjectId: state.externalProjectId } - : {}), - ...(state.externalProjectName !== undefined - ? { externalProjectName: state.externalProjectName } - : {}), - ...(state.externalTeamId !== undefined - ? { externalTeamId: state.externalTeamId } - : {}), - ...(state.actor ? { actor: state.actor } : {}), - json: state.json, - rest: state.rest, - }; -} - -function consumeOptionValue(opts: { - readonly args: readonly string[]; - readonly index: number; - readonly flag: string; -}): ConsumedOptionValue { - const token = opts.args[opts.index] ?? ""; - if (token.startsWith(`${opts.flag}=`)) { - return { - matched: true, - nextIndex: opts.index, - value: token.slice(opts.flag.length + 1), - }; - } - if (token !== opts.flag) { - return { matched: false }; - } - const value = opts.args[opts.index + 1]; - if (!value || value.startsWith("-")) { - return { - matched: true, - nextIndex: opts.index, - error: `${opts.flag} requires a value.`, - }; - } - return { - matched: true, - nextIndex: opts.index + 1, - value, - }; -} - -function parseTicketContentOption(opts: { - readonly args: readonly string[]; - readonly index: number; - readonly state: MutableTicketsArgs; -}): ParseHandlerResult { - const bodyStdinToken = opts.args[opts.index]; - if (bodyStdinToken === "--body-stdin") { - opts.state.bodyStdin = true; - return { handled: true, nextIndex: opts.index }; - } - - for (const [flag, assign] of [ - ["--title", (value: string | undefined) => (opts.state.title = value)], - ["--body", (value: string | undefined) => (opts.state.body = value)], - [ - "--body-file", - (value: string | undefined) => (opts.state.bodyFile = value), - ], - ] as const) { - const consumed = consumeOptionValue({ - args: opts.args, - index: opts.index, - flag, - }); - if (!consumed.matched) { - continue; - } - if (consumed.error) { - return { - handled: true, - nextIndex: consumed.nextIndex, - error: consumed.error, - }; - } - assign(consumed.value); - return { handled: true, nextIndex: consumed.nextIndex }; - } - - return { handled: false, nextIndex: opts.index }; -} - -function parseTicketDocumentOption(opts: { - readonly args: readonly string[]; - readonly index: number; - readonly state: MutableTicketsArgs; -}): ParseHandlerResult { - const kindOption = consumeOptionValue({ - args: opts.args, - index: opts.index, - flag: "--kind", - }); - if (kindOption.matched) { - if (kindOption.error) { - return { - handled: true, - nextIndex: kindOption.nextIndex, - error: kindOption.error, - }; - } - const kind = kindOption.value ?? ""; - if (!isTicketDocumentKind(kind)) { - return { - handled: true, - nextIndex: kindOption.nextIndex, - error: "Invalid --kind value. Expected description|spec|notes.", - }; - } - opts.state.kind = kind; - return { handled: true, nextIndex: kindOption.nextIndex }; - } - - const roleOption = consumeOptionValue({ - args: opts.args, - index: opts.index, - flag: "--role", - }); - if (roleOption.matched) { - if (roleOption.error) { - return { - handled: true, - nextIndex: roleOption.nextIndex, - error: roleOption.error, - }; - } - const role = roleOption.value ?? ""; - if (!isTicketDocumentRole(role)) { - return { - handled: true, - nextIndex: roleOption.nextIndex, - error: "Invalid --role value. Expected description|spec|notes|handoff.", - }; - } - opts.state.role = role; - return { handled: true, nextIndex: roleOption.nextIndex }; - } - - return { handled: false, nextIndex: opts.index }; -} - -function parseTicketRelationshipOption(opts: { - readonly args: readonly string[]; - readonly index: number; - readonly state: MutableTicketsArgs; -}): ParseHandlerResult { - const token = opts.args[opts.index] ?? ""; - switch (token) { - case "--clear-depends-on": - opts.state.clearDependsOn = true; - return { handled: true, nextIndex: opts.index }; - case "--clear-blocks": - opts.state.clearBlocks = true; - return { handled: true, nextIndex: opts.index }; - case "--clear-tags": - opts.state.clearTags = true; - return { handled: true, nextIndex: opts.index }; - case "--clear-assignee": - opts.state.clearAssignee = true; - return { handled: true, nextIndex: opts.index }; - default: - break; - } - - for (const [flag, assign] of [ - [ - "--depends-on", - (value: string) => opts.state.dependsOn.push(...splitTicketRefs(value)), - ], - [ - "--blocks", - (value: string) => opts.state.blocks.push(...splitTicketRefs(value)), - ], - ] as const) { - const consumed = consumeOptionValue({ - args: opts.args, - index: opts.index, - flag, - }); - if (!consumed.matched) { - continue; - } - if (consumed.error) { - return { - handled: true, - nextIndex: consumed.nextIndex, - error: consumed.error, - }; - } - assign(consumed.value ?? ""); - return { handled: true, nextIndex: consumed.nextIndex }; - } - - return { handled: false, nextIndex: opts.index }; -} - -function parseTicketIdentityOption(opts: { - readonly args: readonly string[]; - readonly index: number; - readonly state: MutableTicketsArgs; -}): ParseHandlerResult { - for (const [flag, assign] of [ - ["--actor", (value: string | undefined) => (opts.state.actor = value)], - ["--owner", (value: string | undefined) => (opts.state.owner = value)], - ["--source", (value: string | undefined) => (opts.state.source = value)], - [ - "--assignee", - (value: string | undefined) => (opts.state.assignee = value), - ], - ] as const) { - const consumed = consumeOptionValue({ - args: opts.args, - index: opts.index, - flag, - }); - if (!consumed.matched) { - continue; - } - if (consumed.error) { - return { - handled: true, - nextIndex: consumed.nextIndex, - error: consumed.error, - }; - } - assign(consumed.value); - return { handled: true, nextIndex: consumed.nextIndex }; - } - - return { handled: false, nextIndex: opts.index }; -} - -function parseTicketTagOption(opts: { - readonly args: readonly string[]; - readonly index: number; - readonly state: MutableTicketsArgs; -}): ParseHandlerResult { - for (const [flag, split] of [ - ["--tags", splitTags], - ["--tag", (value: string) => [value]], - ] as const) { - const consumed = consumeOptionValue({ - args: opts.args, - index: opts.index, - flag, - }); - if (!consumed.matched) { - continue; - } - if (consumed.error) { - return { - handled: true, - nextIndex: consumed.nextIndex, - error: consumed.error, - }; - } - opts.state.tags.push(...split(consumed.value ?? "")); - return { handled: true, nextIndex: consumed.nextIndex }; - } - - return { handled: false, nextIndex: opts.index }; -} - -function parseTicketExternalOption(opts: { - readonly args: readonly string[]; - readonly index: number; - readonly state: MutableTicketsArgs; -}): ParseHandlerResult { - for (const [flag, assign] of [ - [ - "--external-system", - (value: string | undefined) => (opts.state.externalSystem = value), - ], - [ - "--external-id", - (value: string | undefined) => (opts.state.externalId = value), - ], - [ - "--external-key", - (value: string | undefined) => (opts.state.externalKey = value), - ], - [ - "--external-url", - (value: string | undefined) => (opts.state.externalUrl = value), - ], - [ - "--external-project-id", - (value: string | undefined) => (opts.state.externalProjectId = value), - ], - [ - "--external-project-name", - (value: string | undefined) => (opts.state.externalProjectName = value), - ], - [ - "--external-team-id", - (value: string | undefined) => (opts.state.externalTeamId = value), - ], - ] as const) { - const consumed = consumeOptionValue({ - args: opts.args, - index: opts.index, - flag, - }); - if (!consumed.matched) { - continue; - } - if (consumed.error) { - return { - handled: true, - nextIndex: consumed.nextIndex, - error: consumed.error, - }; - } - assign(consumed.value); - return { handled: true, nextIndex: consumed.nextIndex }; - } - - return { handled: false, nextIndex: opts.index }; -} - -function parseResolveConflictOption(opts: { - readonly args: readonly string[]; - readonly index: number; - readonly state: MutableResolveConflictArgs; -}): ParseHandlerResult { - for (const [flag, assign] of [ - [ - "--conflict-id", - (value: string | undefined) => (opts.state.conflictId = value), - ], - ["--summary", (value: string | undefined) => (opts.state.summary = value)], - ["--actor", (value: string | undefined) => (opts.state.actor = value)], - ] as const) { - const consumed = consumeOptionValue({ - args: opts.args, - index: opts.index, - flag, - }); - if (!consumed.matched) { - continue; - } - if (consumed.error) { - return { - handled: true, - nextIndex: consumed.nextIndex, - error: consumed.error, - }; - } - assign(consumed.value); - return { handled: true, nextIndex: consumed.nextIndex }; - } - - const resolution = consumeOptionValue({ - args: opts.args, - index: opts.index, - flag: "--resolution", - }); - if (!resolution.matched) { - return { handled: false, nextIndex: opts.index }; - } - if (resolution.error) { - return { - handled: true, - nextIndex: resolution.nextIndex, - error: resolution.error, - }; - } - const parsedResolution = parseConflictResolutionValue({ - value: resolution.value ?? "", - }); - if (!parsedResolution) { - return { - handled: true, - nextIndex: resolution.nextIndex, - error: - "Invalid --resolution value. Expected accept_local|accept_remote|merged|ignore.", - }; - } - opts.state.resolution = parsedResolution; - return { handled: true, nextIndex: resolution.nextIndex }; -} - -async function resolveTicketBody(opts: { - readonly body?: string; - readonly bodyFile?: string; - readonly bodyStdin: boolean; - readonly allowEmpty?: boolean; -}): Promise<string | undefined> { - const allowEmpty = opts.allowEmpty ?? false; - if (opts.bodyStdin) { - const text = await Bun.stdin.text(); - const trimmed = text.trimEnd(); - if (trimmed.length > 0) { - return trimmed; - } - return allowEmpty ? "" : undefined; - } - - const bodyFile = (opts.bodyFile ?? "").trim(); - if (bodyFile.length > 0) { - const text = await Bun.file(bodyFile).text(); - const trimmed = text.trimEnd(); - if (trimmed.length > 0) { - return trimmed; - } - return allowEmpty ? "" : undefined; - } - - const body = (opts.body ?? "").trimEnd(); - if (body.length > 0) { - return body; - } - return allowEmpty ? "" : undefined; -} - -function splitTicketRefs(value: string): string[] { - return value - .split(TICKET_REF_SEPARATOR_PATTERN) - .map((part) => part.trim()) - .filter((part) => part.length > 0); -} - -function parseConflictResolutionValue(input: { - readonly value: string; -}): TicketSyncConflictResolution | null { - const value = input.value.trim(); - if ( - value === "accept_local" || - value === "accept_remote" || - value === "merged" || - value === "ignore" - ) { - return value; - } - return null; -} - -function splitTags(value: string): string[] { - return value - .split(TICKET_REF_SEPARATOR_PATTERN) - .map((part) => part.trim()) - .filter((part) => part.length > 0); -} - -function normalizeTags(values: readonly string[]): string[] { - const seen = new Set<string>(); - const normalized: string[] = []; - for (const value of values) { - const tag = value.trim(); - if (!(tag && !seen.has(tag))) { - continue; - } - seen.add(tag); - normalized.push(tag); - } - normalized.sort((left, right) => left.localeCompare(right)); - return normalized; -} - -function resolveTicketRefs(opts: { - readonly values: readonly string[]; - readonly label: string; -}): - | { readonly ok: true; readonly refs: string[] } - | { readonly ok: false; readonly error: string } { - if (opts.values.length === 0) { - return { ok: true, refs: [] }; - } - - const invalid: string[] = []; - const normalized: string[] = []; - for (const value of opts.values) { - const parsed = normalizeTicketRef(value); - if (parsed) { - normalized.push(parsed); - } else { - invalid.push(value); - } - } - - if (invalid.length > 0) { - return { - ok: false, - error: `Invalid ${opts.label} ticket(s): ${invalid.join(", ")}`, - }; - } - - return { ok: true, refs: normalizeTicketRefs(normalized) }; -} - -async function renderTicketDetail(opts: { - readonly detail: TicketDetailResult; - readonly json: boolean; -}): Promise<void> { - if (opts.json) { - writeTicketDetailJson({ detail: opts.detail }); - return; - } - - await displayTicketDetailSections({ detail: opts.detail }); -} - -function writeTicketDetailJson(opts: { - readonly detail: TicketDetailResult; -}): void { - process.stdout.write( - `${JSON.stringify( - { - ticket: opts.detail.ticket, - documents: opts.detail.documents, - comments: opts.detail.comments, - reviewNotes: opts.detail.reviewNotes, - syncCheckpoints: opts.detail.syncCheckpoints, - conflicts: opts.detail.conflicts, - events: opts.detail.events, - }, - null, - 2 - )}\n` - ); -} - -async function displayTicketDetailSections(opts: { - readonly detail: TicketDetailResult; -}): Promise<void> { - const { detail } = opts; - const ticket = detail.ticket; - if (!ticket) { - return; - } - - await display.kv({ - title: `Ticket ${ticket.ticketId}`, - entries: [ - ["title", ticket.title], - ["status", ticket.status], - ["owner", ticket.owner], - ["source", ticket.source], - ["assignee", ticket.assignee ?? ""], - ["tags", ticket.tags.join(", ")], - ["external_system", ticket.externalSystem ?? ""], - ["external_id", ticket.externalId ?? ""], - ["external_key", ticket.externalKey ?? ""], - ["external_url", ticket.externalUrl ?? ""], - ["external_project_id", ticket.externalProjectId ?? ""], - ["external_project_name", ticket.externalProjectName ?? ""], - ["external_team_id", ticket.externalTeamId ?? ""], - ["depends_on", ticket.dependsOn.join(", ")], - ["blocks", ticket.blocks.join(", ")], - ["created_at", ticket.createdAt], - ["updated_at", ticket.updatedAt], - ["project_id", ticket.projectId ?? ""], - ["project_name", ticket.projectName ?? ""], - ], - }); - - if (ticket.body) { - await display.panel({ - title: "Body", - tone: "info", - lines: ticket.body.split("\n"), - }); - } - - if (detail.documents.length > 0) { - await display.table({ - columns: ["document_id", "kind", "role", "updated_at"], - rows: detail.documents.map((document) => [ - document.documentId, - document.kind, - document.role, - document.updatedAt, - ]), - }); - } - - if (detail.comments.length > 0) { - await display.table({ - columns: ["comment_id", "source", "actor", "created_at", "body"], - rows: detail.comments.map((comment) => [ - comment.commentId, - comment.source, - comment.actor, - comment.createdAt, - comment.body, - ]), - }); - } - - if (detail.reviewNotes.length > 0) { - await display.table({ - columns: ["note_id", "actor", "created_at", "context", "body"], - rows: detail.reviewNotes.map((reviewNote) => [ - reviewNote.noteId, - reviewNote.actor, - reviewNote.createdAt, - reviewNote.context ?? "", - reviewNote.body, - ]), - }); - } - - if (detail.syncCheckpoints.length > 0) { - await display.table({ - columns: ["checkpoint_id", "provider", "profile", "direction", "cursor"], - rows: detail.syncCheckpoints.map((checkpoint) => [ - checkpoint.checkpointId, - checkpoint.provider, - checkpoint.profileId ?? "", - checkpoint.direction ?? "", - checkpoint.remoteCursor ?? "", - ]), - }); - } - - if (detail.conflicts.length > 0) { - await display.table({ - columns: ["conflict_id", "field", "status", "provider", "resolution"], - rows: detail.conflicts.map((conflict) => [ - conflict.conflictId, - conflict.field, - conflict.status, - conflict.provider, - conflict.resolution ?? "", - ]), - }); - } - - await display.table({ - columns: ["ts", "type", "event_id"], - rows: detail.events.map((event) => [ - event.tsIso, - event.type, - event.eventId, - ]), - }); -} - -async function maybeEnsureTicketsSetup(opts: { - readonly ctx: ExtensionCommandContext; - readonly json: boolean; -}): Promise<void> { - if (!opts.ctx.project) { - return; - } - if (opts.json) { - return; - } - - if (!didWarnTicketsDeprecation) { - didWarnTicketsDeprecation = true; - opts.ctx.logger.warn({ - message: - "Hack Tickets is deprecated. Agent skills and instruction blocks are no longer installed; use this command only for compatibility or migration.", - }); - } - - const projectRoot = opts.ctx.project.projectRoot; - const repoState = await checkTicketsRepoState({ projectRoot }); - const needs = getTicketsSetupNeeds({ repoState }); - if (!hasIncompleteTicketsSetup({ needs })) { - return; - } - - if (!(isTty() && isGumAvailable())) { - const notices = buildTicketsSetupNotices({ needs }); - if (notices.length > 0) { - opts.ctx.logger.warn({ - message: `Tickets setup incomplete: ${notices.join("; ")}.`, - }); - } - return; - } - - const confirmed = await gumConfirm({ - prompt: "Tickets setup is incomplete. Fix now?", - default: true, - }); - if (!(confirmed.ok && confirmed.value)) { - return; - } - - const lines = await repairTicketsSetup({ projectRoot, needs }); - if (lines.length > 0) { - await display.panel({ - title: "Tickets setup", - tone: "success", - lines, - }); - } - - await maybeEnsureTicketsGitHealth({ ctx: opts.ctx, json: opts.json }); -} - -async function maybeEnsureTicketsGitHealth(opts: { - readonly ctx: ExtensionCommandContext; - readonly json: boolean; -}): Promise<void> { - if (!opts.ctx.project) { - return; - } - if (opts.json) { - return; - } - if (didPromptTicketsGitHealth) { - return; - } - didPromptTicketsGitHealth = true; - - const gitConfig = opts.ctx.controlPlaneConfig.tickets.git; - if (!gitConfig.enabled) { - return; - } - - const projectRoot = opts.ctx.project.projectRoot; - const channel = createGitTicketsChannel({ - projectRoot, - config: gitConfig, - logger: opts.ctx.logger, - }); - - const inspected = await channel.inspect(); - if (!inspected.ok) { - opts.ctx.logger.warn({ - message: `Tickets git health check failed: ${inspected.error}`, - }); - return; - } - - const health = inspected.health; - if ( - !( - health.hasLegacyRef || - health.hasRefDivergence || - health.hasNonTicketFiles - ) - ) { - return; - } - - const reasons = buildTicketsGitHealthReasons({ health }); - - if (!(isTty() && isGumAvailable())) { - opts.ctx.logger.warn({ - message: `Tickets git storage needs repair (${reasons.join("; ")}). Run: hack x tickets setup`, - }); - return; - } - - const confirmed = await gumConfirm({ - prompt: "Tickets git storage needs repair. Fix now?", - default: true, - }); - if (!(confirmed.ok && confirmed.value)) { - return; - } - - const pruneLegacyRef = await confirmLegacyRefPrune({ health }); - - const repaired = await channel.repair({ pruneLegacyRef }); - if (!repaired.ok) { - await display.panel({ - title: "Tickets repair", - tone: "warn", - lines: [`error: ${repaired.error}`], - }); - return; - } - - const lines = buildTicketsRepairLines({ - health, - repaired, - pruneLegacyRef, - }); - - await display.panel({ - title: "Tickets repair", - tone: repaired.pruneError ? "warn" : "success", - lines, - }); -} - -function getTicketsSetupNeeds(opts: { - readonly repoState: Awaited<ReturnType<typeof checkTicketsRepoState>>; -}): TicketsSetupNeeds { - return { - needsGitignore: opts.repoState.gitignore.status === "missing", - needsUntrack: opts.repoState.tracked.status === "tracked", - }; -} - -function hasIncompleteTicketsSetup(opts: { - readonly needs: TicketsSetupNeeds; -}): boolean { - return opts.needs.needsGitignore || opts.needs.needsUntrack; -} - -function buildTicketsSetupNotices(opts: { - readonly needs: TicketsSetupNeeds; -}): string[] { - const notices: string[] = []; - if (opts.needs.needsGitignore) { - notices.push("add .hack/tickets/ to .gitignore"); - } - if (opts.needs.needsUntrack) { - notices.push("untrack .hack/tickets from main branch"); - } - return notices; -} - -async function repairTicketsSetup(opts: { - readonly projectRoot: string; - readonly needs: TicketsSetupNeeds; -}): Promise<string[]> { - const lines: string[] = []; - - if (opts.needs.needsGitignore) { - const gitignore = await ensureTicketsGitignore({ - projectRoot: opts.projectRoot, - }); - lines.push(`repo.gitignore: ${gitignore.status} (${gitignore.path})`); - } - - if (opts.needs.needsUntrack) { - const untrack = await untrackTicketsRepo({ projectRoot: opts.projectRoot }); - lines.push( - `repo.tracking: ${untrack.status}${untrack.message ? ` (${untrack.message})` : ""}` - ); - } - - return lines; -} - -function buildTicketsGitHealthReasons(opts: { - readonly health: TicketsGitHealthSummary; -}): string[] { - const reasons: string[] = []; - if (opts.health.hasRefDivergence) { - reasons.push("hidden ref diverges from legacy branch"); - } - if (opts.health.hasLegacyRef && opts.health.legacyRef) { - reasons.push(`legacy ref ${opts.health.legacyRef}`); - } - if (opts.health.hasNonTicketFiles) { - reasons.push("non-ticket files in tickets ref"); - } - return reasons; -} - -async function confirmLegacyRefPrune(opts: { - readonly health: TicketsGitHealthSummary; -}): Promise<boolean> { - if (!(opts.health.hasLegacyRef && opts.health.legacyRef)) { - return false; - } - - const prune = await gumConfirm({ - prompt: `Remove legacy ref ${opts.health.legacyRef} from the remote?`, - default: true, - }); - return prune.ok && prune.value; -} - -function buildTicketsRepairLines(opts: { - readonly health: TicketsGitHealthSummary; - readonly repaired: TicketsRepairSummary; - readonly pruneLegacyRef: boolean; -}): string[] { - const lines: string[] = []; - if (opts.health.hasNonTicketFiles) { - const sample = opts.health.nonTicketPaths.slice(0, 5); - const extra = opts.health.nonTicketPaths.length - sample.length; - lines.push(`non-ticket files: ${opts.health.nonTicketPaths.length}`); - if (sample.length > 0) { - lines.push( - `sample: ${sample.join(", ")}${extra > 0 ? ` (+${extra} more)` : ""}` - ); - } - } - if (opts.health.hasLegacyRef && opts.health.legacyRef) { - lines.push( - `legacy ref: ${opts.health.legacyRef} ${ - opts.pruneLegacyRef ? "pruned" : "left intact" - }` - ); - } - if (opts.health.hasRefDivergence) { - lines.push( - `ref divergence: ${ - opts.health.remoteRefOid?.slice(0, 8) ?? "missing" - } vs ${opts.health.legacyRefOid?.slice(0, 8) ?? "missing"}` - ); - } - lines.push(`commit: ${opts.repaired.didCommit ? "created" : "noop"}`); - lines.push(`push: ${opts.repaired.didPush ? "pushed" : "skipped"}`); - if (opts.repaired.pruneError) { - lines.push(`legacy prune error: ${opts.repaired.pruneError}`); - } - return lines; -} - -function parseTicketsSetupArgs(opts: { - readonly args: readonly string[]; -}): TicketsSetupParseResult { - const state: MutableTicketsSetupArgs = { - agents: false, - claude: false, - all: false, - global: false, - check: false, - remove: false, - json: false, - }; - - for (const token of opts.args) { - const applied = applyTicketsSetupToken({ token, state }); - if (!applied.ok) { - return applied; - } - } - - if (state.check && state.remove) { - return { ok: false, error: "--check and --remove are mutually exclusive." }; - } - - return { - ok: true, - value: state, - }; -} - -function applyTicketsSetupToken(opts: { - readonly token: string; - readonly state: MutableTicketsSetupArgs; -}): TicketsSetupTokenResult { - if (opts.token === "--agents" || opts.token === "--agents-md") { - opts.state.agents = true; - return { ok: true }; - } - - if (opts.token === "--claude" || opts.token === "--claude-md") { - opts.state.claude = true; - return { ok: true }; - } - - if (opts.token === "--all") { - opts.state.all = true; - return { ok: true }; - } - - if (opts.token === "--global") { - opts.state.global = true; - return { ok: true }; - } - - if (opts.token === "--check") { - opts.state.check = true; - return { ok: true }; - } - - if (opts.token === "--remove") { - opts.state.remove = true; - return { ok: true }; - } - - if (opts.token === "--json") { - opts.state.json = true; - return { ok: true }; - } - - if (opts.token === "--help" || opts.token === "help") { - return { - ok: false, - error: - "Usage: hack x tickets setup [--agents|--claude|--all] [--global] [--check|--remove] [--json]", - }; - } - - return { ok: false, error: `Unknown option: ${opts.token}` }; -} diff --git a/src/control-plane/extensions/tickets/documents.ts b/src/control-plane/extensions/tickets/documents.ts deleted file mode 100644 index f43ee830..00000000 --- a/src/control-plane/extensions/tickets/documents.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { sha256Hex } from "./util.ts"; - -export type TicketDocumentKind = "description" | "spec" | "notes"; -export type TicketDocumentRole = TicketDocumentKind | "handoff"; - -export type TicketDocument = { - readonly documentId: string; - readonly ticketId: string; - readonly kind: TicketDocumentKind; - readonly role: TicketDocumentRole; - readonly content: string; - readonly contentSha256: string; - readonly createdAt: string; - readonly updatedAt: string; -}; - -export function isTicketDocumentKind( - value: string -): value is TicketDocumentKind { - return ["description", "spec", "notes"].includes(value); -} - -export function isTicketDocumentRole( - value: string -): value is TicketDocumentRole { - return ["description", "spec", "notes", "handoff"].includes(value); -} - -export function resolveTicketDocumentRole(input: { - readonly kind: TicketDocumentKind; - readonly role?: TicketDocumentRole; -}): TicketDocumentRole { - return input.role ?? input.kind; -} - -export function buildTicketDocument(input: { - readonly documentId: string; - readonly ticketId: string; - readonly kind: TicketDocumentKind; - readonly role?: TicketDocumentRole; - readonly content: string; - readonly createdAt: string; - readonly updatedAt: string; -}): TicketDocument { - return { - documentId: input.documentId, - ticketId: input.ticketId, - kind: input.kind, - role: resolveTicketDocumentRole({ - kind: input.kind, - role: input.role, - }), - content: input.content, - contentSha256: sha256Hex({ value: input.content }), - createdAt: input.createdAt, - updatedAt: input.updatedAt, - }; -} - -export function buildLegacyDescriptionDocument(input: { - readonly eventId: string; - readonly ticketId: string; - readonly content: string; - readonly createdAt: string; - readonly updatedAt: string; -}): TicketDocument { - return buildTicketDocument({ - documentId: `${input.ticketId}:description:${input.eventId}`, - ticketId: input.ticketId, - kind: "description", - content: input.content, - createdAt: input.createdAt, - updatedAt: input.updatedAt, - }); -} - -export function getActiveTicketDescription(input: { - readonly documents: readonly TicketDocument[]; -}): TicketDocument | undefined { - for (let index = input.documents.length - 1; index >= 0; index -= 1) { - const document = input.documents[index]; - if (document?.role === "description") { - return document; - } - } - return undefined; -} - -export function projectTicketBodyFromDocuments(input: { - readonly documents: readonly TicketDocument[]; - readonly fallbackBody?: string; -}): string | undefined { - return ( - getActiveTicketDescription({ documents: input.documents })?.content ?? - input.fallbackBody - ); -} diff --git a/src/control-plane/extensions/tickets/domain.ts b/src/control-plane/extensions/tickets/domain.ts deleted file mode 100644 index bd343431..00000000 --- a/src/control-plane/extensions/tickets/domain.ts +++ /dev/null @@ -1,265 +0,0 @@ -import type { TicketDocument } from "./documents.ts"; -import { - buildLegacyDescriptionDocument, - getActiveTicketDescription, -} from "./documents.ts"; -import { - buildTicketProvenance, - normalizeTicketFieldName, - projectRemoteLinkToCompatibilityFields, - type TicketFieldAuthority, - type TicketFieldAuthorityEntry, - type TicketFieldVersion, - type TicketOrigin, - type TicketRemoteLink, -} from "./provenance.ts"; - -export type { - TicketDocument, - TicketDocumentKind, - TicketDocumentRole, -} from "./documents.ts"; -export type { - TicketFieldAuthority, - TicketFieldAuthorityEntry, - TicketFieldVersion, - TicketOrigin, - TicketRemoteLink, -} from "./provenance.ts"; - -export type TicketStatus = "open" | "in_progress" | "blocked" | "done"; - -export type TicketSummaryCompatibility = { - readonly ticketId: string; - readonly title: string; - readonly body?: string; - readonly status: TicketStatus; - readonly createdAt: string; - readonly updatedAt: string; - readonly dependsOn: readonly string[]; - readonly blocks: readonly string[]; - readonly owner: string; - readonly source: string; - readonly assignee?: string; - readonly tags: readonly string[]; - readonly externalSystem?: string; - readonly externalId?: string; - readonly externalKey?: string; - readonly externalUrl?: string; - readonly externalProjectId?: string; - readonly externalProjectName?: string; - readonly externalTeamId?: string; - readonly projectId?: string; - readonly projectName?: string; -}; - -export type TicketMetadataValue = - | string - | number - | boolean - | null - | readonly TicketMetadataValue[] - | { readonly [key: string]: TicketMetadataValue }; - -export type TicketSyncCheckpointCompatibility = { - readonly checkpointId: string; - readonly ticketId: string; - readonly provider: string; - readonly profileId?: string; - readonly direction?: string; - readonly remoteCursor?: string; - readonly remoteUpdatedAt?: string; - readonly localUpdatedAt?: string; - readonly actor: string; - readonly createdAt: string; -}; - -export type TicketSyncConflictCompatibility = { - readonly conflictId: string; - readonly ticketId: string; - readonly provider: string; - readonly field: string; - readonly status: "open" | "resolved"; - readonly authority?: string; - readonly summary?: string; - readonly localValue?: TicketMetadataValue; - readonly remoteValue?: TicketMetadataValue; - readonly createdAt: string; - readonly updatedAt: string; - readonly resolution?: "accept_local" | "accept_remote" | "merged" | "ignore"; - readonly resolutionSummary?: string; - readonly resolvedAt?: string; - readonly resolvedBy?: string; -}; - -export type NormalizedTicketIdentity = { - readonly ticketId: string; - readonly projectId?: string; - readonly projectName?: string; -}; - -export type TicketFieldState = { - readonly field: string; - readonly authority: TicketFieldAuthority; - readonly conflictIds: readonly string[]; -}; - -export type NormalizedTicket = { - readonly identity: NormalizedTicketIdentity; - readonly title: string; - readonly status: TicketStatus; - readonly createdAt: string; - readonly updatedAt: string; - readonly dependsOn: readonly string[]; - readonly blocks: readonly string[]; - readonly assignee?: string; - readonly tags: readonly string[]; - readonly provenance: { - readonly origin: TicketOrigin; - readonly remotes: readonly TicketRemoteLink[]; - readonly fieldAuthorities: readonly TicketFieldAuthorityEntry[]; - readonly fieldVersions: readonly TicketFieldVersion[]; - }; - readonly documents: readonly TicketDocument[]; - readonly fieldStates: readonly TicketFieldState[]; - readonly sync: { - readonly checkpoints: readonly TicketSyncCheckpointCompatibility[]; - readonly conflicts: readonly TicketSyncConflictCompatibility[]; - }; -}; - -export function createNormalizedTicket(input: { - readonly ticket: TicketSummaryCompatibility; - readonly syncCheckpoints?: readonly TicketSyncCheckpointCompatibility[]; - readonly conflicts?: readonly TicketSyncConflictCompatibility[]; - readonly documents?: readonly TicketDocument[]; -}): NormalizedTicket { - const documents = buildDocuments({ - ticket: input.ticket, - documents: input.documents, - }); - const checkpoints = input.syncCheckpoints ?? []; - const conflicts = input.conflicts ?? []; - const provenance = buildTicketProvenance({ - ticket: input.ticket, - syncCheckpoints: checkpoints, - conflicts, - }); - - return { - identity: { - ticketId: input.ticket.ticketId, - ...(input.ticket.projectId ? { projectId: input.ticket.projectId } : {}), - ...(input.ticket.projectName - ? { projectName: input.ticket.projectName } - : {}), - }, - title: input.ticket.title, - status: input.ticket.status, - createdAt: input.ticket.createdAt, - updatedAt: input.ticket.updatedAt, - dependsOn: [...input.ticket.dependsOn], - blocks: [...input.ticket.blocks], - ...(input.ticket.assignee ? { assignee: input.ticket.assignee } : {}), - tags: [...input.ticket.tags], - provenance, - documents, - fieldStates: buildFieldStates({ - fieldAuthorities: provenance.fieldAuthorities, - conflicts, - }), - sync: { - checkpoints: [...checkpoints], - conflicts: [...conflicts], - }, - }; -} - -export function projectNormalizedTicketSummary(input: { - readonly ticket: NormalizedTicket; -}): TicketSummaryCompatibility { - const description = getActiveTicketDescription({ - documents: input.ticket.documents, - }); - const primaryRemote = input.ticket.provenance.remotes[0]; - - return { - ticketId: input.ticket.identity.ticketId, - title: input.ticket.title, - ...(description ? { body: description.content } : {}), - status: input.ticket.status, - createdAt: input.ticket.createdAt, - updatedAt: input.ticket.updatedAt, - dependsOn: [...input.ticket.dependsOn], - blocks: [...input.ticket.blocks], - owner: input.ticket.provenance.origin.owner, - source: input.ticket.provenance.origin.source, - ...(input.ticket.assignee ? { assignee: input.ticket.assignee } : {}), - tags: [...input.ticket.tags], - ...(primaryRemote - ? projectRemoteLinkToCompatibilityFields({ - remote: primaryRemote, - }) - : {}), - ...(input.ticket.identity.projectId - ? { projectId: input.ticket.identity.projectId } - : {}), - ...(input.ticket.identity.projectName - ? { projectName: input.ticket.identity.projectName } - : {}), - }; -} - -function buildDocuments(input: { - readonly ticket: TicketSummaryCompatibility; - readonly documents?: readonly TicketDocument[]; -}): TicketDocument[] { - if (input.documents && input.documents.length > 0) { - return [...input.documents]; - } - if (!input.ticket.body) { - return []; - } - return [ - buildLegacyDescriptionDocument({ - eventId: "compatibility-summary", - ticketId: input.ticket.ticketId, - content: input.ticket.body, - createdAt: input.ticket.createdAt, - updatedAt: input.ticket.updatedAt, - }), - ]; -} - -function buildFieldStates(input: { - readonly fieldAuthorities: readonly TicketFieldAuthorityEntry[]; - readonly conflicts: readonly TicketSyncConflictCompatibility[]; -}): TicketFieldState[] { - const trackedFields = new Map<string, TicketFieldState>(); - - for (const fieldAuthority of input.fieldAuthorities) { - const field = normalizeTicketFieldName(fieldAuthority.field); - if (!["title", "status", "assignee", "description"].includes(field)) { - continue; - } - trackedFields.set(field, { - field, - authority: fieldAuthority.authority, - conflictIds: [], - }); - } - - for (const conflict of input.conflicts) { - const field = normalizeTicketFieldName(conflict.field); - const current = trackedFields.get(field); - if (!current) { - continue; - } - trackedFields.set(field, { - ...current, - conflictIds: [...current.conflictIds, conflict.conflictId], - }); - } - - return [...trackedFields.values()]; -} diff --git a/src/control-plane/extensions/tickets/enablement.ts b/src/control-plane/extensions/tickets/enablement.ts deleted file mode 100644 index d3db207e..00000000 --- a/src/control-plane/extensions/tickets/enablement.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { resolve } from "node:path"; - -import { - HACK_PROJECT_DIR_LEGACY, - HACK_PROJECT_DIR_PRIMARY, - PROJECT_CONFIG_FILENAME, -} from "../../../constants.ts"; -import { pathExists } from "../../../lib/fs.ts"; -import { readControlPlaneConfig } from "../../sdk/config.ts"; - -/** - * Extension id for the optional git-backed tickets extension. - * - * Kept here (instead of importing `extension.ts`) so enablement checks stay - * lightweight and do not pull the full tickets command surface into the - * module graph of callers like `setup sync` and the integration guard. - */ -export const TICKETS_EXTENSION_ID = "dance.hack.tickets" as const; - -export type TicketsIntegrationEnablement = { - /** Enabled for the project (global config merged with project overrides). */ - readonly project: boolean; - /** Enabled in the global (user) config layer only. */ - readonly global: boolean; -}; - -/** - * Resolve whether the tickets extension is enabled per scope. - * - * Tickets is optional/legacy: agent integrations (codex skill, agent-doc - * snippets) are only installed/checked when the extension is explicitly - * enabled — project scope uses the merged global+project config, global - * scope uses the global config alone. - * - * @param opts.projectRoot - Project root for the project-scope answer; when - * omitted the project answer falls back to the global answer. - */ -export async function resolveTicketsIntegrationEnablement(opts: { - readonly projectRoot?: string; -}): Promise<TicketsIntegrationEnablement> { - const projectDir = opts.projectRoot - ? await resolveProjectConfigDir({ projectRoot: opts.projectRoot }) - : null; - - const [globalResult, projectResult] = await Promise.all([ - readControlPlaneConfig({}), - projectDir ? readControlPlaneConfig({ projectDir }) : Promise.resolve(null), - ]); - - const globalEnabled = - globalResult.config.extensions[TICKETS_EXTENSION_ID]?.enabled === true; - const projectEnabled = projectResult - ? projectResult.config.extensions[TICKETS_EXTENSION_ID]?.enabled === true - : globalEnabled; - - return { project: projectEnabled, global: globalEnabled }; -} - -/** - * Resolve the project config dir (`.hack`, legacy `.dev`) under a repo root. - * Falls back to the primary dir when neither carries a config file so the - * merged read degrades to global-only defaults. - */ -async function resolveProjectConfigDir(opts: { - readonly projectRoot: string; -}): Promise<string> { - const primary = resolve(opts.projectRoot, HACK_PROJECT_DIR_PRIMARY); - if (await pathExists(resolve(primary, PROJECT_CONFIG_FILENAME))) { - return primary; - } - const legacy = resolve(opts.projectRoot, HACK_PROJECT_DIR_LEGACY); - if (await pathExists(resolve(legacy, PROJECT_CONFIG_FILENAME))) { - return legacy; - } - return primary; -} diff --git a/src/control-plane/extensions/tickets/extension.ts b/src/control-plane/extensions/tickets/extension.ts deleted file mode 100644 index 896d632e..00000000 --- a/src/control-plane/extensions/tickets/extension.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { ExtensionDefinition } from "../types.ts"; -import { TICKETS_COMMANDS } from "./commands.ts"; - -export const TICKETS_EXTENSION: ExtensionDefinition = { - manifest: { - id: "dance.hack.tickets", - version: "0.1.0", - scopes: ["project"], - cliNamespace: "tickets", - summary: "Deprecated git-backed Tickets compatibility surface", - }, - commands: TICKETS_COMMANDS, -}; diff --git a/src/control-plane/extensions/tickets/provenance.ts b/src/control-plane/extensions/tickets/provenance.ts deleted file mode 100644 index 4a192c36..00000000 --- a/src/control-plane/extensions/tickets/provenance.ts +++ /dev/null @@ -1,459 +0,0 @@ -export type TicketMetadataValue = - | string - | number - | boolean - | null - | readonly TicketMetadataValue[] - | { readonly [key: string]: TicketMetadataValue }; - -export type TicketSyncCheckpointCompatibility = { - readonly checkpointId: string; - readonly ticketId: string; - readonly provider: string; - readonly profileId?: string; - readonly direction?: string; - readonly remoteCursor?: string; - readonly remoteUpdatedAt?: string; - readonly localUpdatedAt?: string; - readonly actor: string; - readonly createdAt: string; -}; - -export type TicketSyncConflictCompatibility = { - readonly conflictId: string; - readonly ticketId: string; - readonly provider: string; - readonly field: string; - readonly status: "open" | "resolved"; - readonly authority?: string; - readonly summary?: string; - readonly localValue?: TicketMetadataValue; - readonly remoteValue?: TicketMetadataValue; - readonly createdAt: string; - readonly updatedAt: string; - readonly resolution?: "accept_local" | "accept_remote" | "merged" | "ignore"; - readonly resolutionSummary?: string; - readonly resolvedAt?: string; - readonly resolvedBy?: string; -}; - -export type TicketProvenanceCompatibility = { - readonly owner: string; - readonly source: string; - readonly updatedAt: string; - readonly title: string; - readonly status: string; - readonly body?: string; - readonly assignee?: string; - readonly externalSystem?: string; - readonly externalId?: string; - readonly externalKey?: string; - readonly externalUrl?: string; - readonly externalProjectId?: string; - readonly externalProjectName?: string; - readonly externalTeamId?: string; -}; - -export type TicketOrigin = { - readonly owner: string; - readonly source: string; - readonly system: string; -}; - -export type TicketRemoteLink = { - readonly provider: string; - readonly remoteId?: string; - readonly remoteKey?: string; - readonly remoteUrl?: string; - readonly profileId?: string; - readonly projectId?: string; - readonly projectName?: string; - readonly teamId?: string; - readonly remoteCursor?: string; - readonly remoteUpdatedAt?: string; -}; - -export type TicketFieldAuthority = - | "local" - | "remote" - | "append_only" - | "derived" - | "review_required"; - -export type TicketFieldAuthorityEntry = { - readonly field: string; - readonly authority: TicketFieldAuthority; -}; - -export type TicketFieldVersion = { - readonly field: string; - readonly source: "local" | "remote"; - readonly provider?: string; - readonly recordedAt: string; - readonly value?: TicketMetadataValue; -}; - -export function normalizeTicketFieldName(field: string): string { - return field === "body" ? "description" : field; -} - -function inferTicketSourceSystem(input: { - readonly ticket: Pick< - TicketProvenanceCompatibility, - "owner" | "source" | "externalSystem" - >; -}): string { - if (input.ticket.externalSystem) { - return input.ticket.externalSystem; - } - if (input.ticket.source !== "hack") { - return input.ticket.source; - } - if (input.ticket.owner !== "hack") { - return input.ticket.owner; - } - return "hack"; -} - -function buildTicketRemoteLinks(input: { - readonly ticket: Pick< - TicketProvenanceCompatibility, - | "owner" - | "source" - | "externalSystem" - | "externalId" - | "externalKey" - | "externalUrl" - | "externalProjectId" - | "externalProjectName" - | "externalTeamId" - >; - readonly syncCheckpoints?: readonly TicketSyncCheckpointCompatibility[]; -}): TicketRemoteLink[] { - const sourceSystem = inferTicketSourceSystem({ - ticket: input.ticket, - }); - const remotes: TicketRemoteLink[] = []; - - if (ticketHasRemoteIdentity({ ticket: input.ticket })) { - remotes.push( - createPrimaryRemoteLink({ - ticket: input.ticket, - sourceSystem, - }) - ); - } - - for (const checkpoint of input.syncCheckpoints ?? []) { - const trackedIndex = remotes.findIndex((remote) => - remoteMatchesCheckpoint({ remote, checkpoint }) - ); - - if (trackedIndex >= 0) { - const trackedRemote = remotes[trackedIndex]; - if (!trackedRemote) { - continue; - } - remotes[trackedIndex] = mergeCheckpointRemote({ - remote: trackedRemote, - checkpoint, - }); - continue; - } - - remotes.push(createCheckpointRemoteLink({ checkpoint })); - } - - return remotes; -} - -function ticketHasRemoteIdentity(input: { - readonly ticket: Pick< - TicketProvenanceCompatibility, - | "externalSystem" - | "externalId" - | "externalKey" - | "externalUrl" - | "externalProjectId" - | "externalProjectName" - | "externalTeamId" - >; -}): boolean { - return Boolean( - input.ticket.externalSystem || - input.ticket.externalId || - input.ticket.externalKey || - input.ticket.externalUrl || - input.ticket.externalProjectId || - input.ticket.externalProjectName || - input.ticket.externalTeamId - ); -} - -function createPrimaryRemoteLink(input: { - readonly ticket: Pick< - TicketProvenanceCompatibility, - | "externalSystem" - | "externalId" - | "externalKey" - | "externalUrl" - | "externalProjectId" - | "externalProjectName" - | "externalTeamId" - >; - readonly sourceSystem: string; -}): TicketRemoteLink { - return { - provider: input.ticket.externalSystem ?? input.sourceSystem, - ...(input.ticket.externalId ? { remoteId: input.ticket.externalId } : {}), - ...(input.ticket.externalKey - ? { remoteKey: input.ticket.externalKey } - : {}), - ...(input.ticket.externalUrl - ? { remoteUrl: input.ticket.externalUrl } - : {}), - ...(input.ticket.externalProjectId - ? { projectId: input.ticket.externalProjectId } - : {}), - ...(input.ticket.externalProjectName - ? { projectName: input.ticket.externalProjectName } - : {}), - ...(input.ticket.externalTeamId - ? { teamId: input.ticket.externalTeamId } - : {}), - }; -} - -function remoteMatchesCheckpoint(input: { - readonly remote: TicketRemoteLink; - readonly checkpoint: TicketSyncCheckpointCompatibility; -}): boolean { - return ( - input.remote.provider === input.checkpoint.provider && - (input.remote.profileId === input.checkpoint.profileId || - input.remote.profileId === undefined || - input.checkpoint.profileId === undefined) - ); -} - -function mergeCheckpointRemote(input: { - readonly remote: TicketRemoteLink; - readonly checkpoint: TicketSyncCheckpointCompatibility; -}): TicketRemoteLink { - return { - ...input.remote, - ...(input.remote.profileId === undefined && input.checkpoint.profileId - ? { profileId: input.checkpoint.profileId } - : {}), - ...(input.checkpoint.remoteCursor - ? { remoteCursor: input.checkpoint.remoteCursor } - : {}), - ...(input.checkpoint.remoteUpdatedAt - ? { remoteUpdatedAt: input.checkpoint.remoteUpdatedAt } - : {}), - }; -} - -function createCheckpointRemoteLink(input: { - readonly checkpoint: TicketSyncCheckpointCompatibility; -}): TicketRemoteLink { - return { - provider: input.checkpoint.provider, - ...(input.checkpoint.profileId - ? { profileId: input.checkpoint.profileId } - : {}), - ...(input.checkpoint.remoteCursor - ? { remoteCursor: input.checkpoint.remoteCursor } - : {}), - ...(input.checkpoint.remoteUpdatedAt - ? { remoteUpdatedAt: input.checkpoint.remoteUpdatedAt } - : {}), - }; -} - -function buildTicketFieldAuthorities(input: { - readonly remotes: readonly TicketRemoteLink[]; - readonly conflicts?: readonly TicketSyncConflictCompatibility[]; -}): TicketFieldAuthorityEntry[] { - const defaultAuthority: TicketFieldAuthority = - input.remotes.length > 0 ? "remote" : "local"; - const byField = new Map<string, TicketFieldAuthorityEntry>(); - - for (const field of [ - "title", - "status", - "assignee", - "description", - "project", - ]) { - byField.set(field, { - field, - authority: defaultAuthority, - }); - } - byField.set("comment", { - field: "comment", - authority: "append_only", - }); - byField.set("review_note", { - field: "review_note", - authority: "local", - }); - byField.set("sync_checkpoint", { - field: "sync_checkpoint", - authority: "derived", - }); - byField.set("sync_conflict", { - field: "sync_conflict", - authority: "derived", - }); - - for (const conflict of input.conflicts ?? []) { - const field = normalizeTicketFieldName(conflict.field); - const current = byField.get(field); - const authority = normalizeConflictAuthority({ - authority: conflict.authority, - }); - byField.set(field, { - field, - authority: authority ?? current?.authority ?? defaultAuthority, - }); - } - - return [...byField.values()]; -} - -function normalizeConflictAuthority(input: { - readonly authority?: string; -}): TicketFieldAuthority | undefined { - if ( - input.authority === "local" || - input.authority === "remote" || - input.authority === "append_only" || - input.authority === "derived" || - input.authority === "review_required" - ) { - return input.authority; - } - return undefined; -} - -function buildTicketFieldVersions(input: { - readonly ticket: TicketProvenanceCompatibility; - readonly conflicts?: readonly TicketSyncConflictCompatibility[]; -}): TicketFieldVersion[] { - const versions: TicketFieldVersion[] = [ - { - field: "title", - source: "local", - recordedAt: input.ticket.updatedAt, - value: input.ticket.title, - }, - { - field: "status", - source: "local", - recordedAt: input.ticket.updatedAt, - value: input.ticket.status, - }, - { - field: "description", - source: "local", - recordedAt: input.ticket.updatedAt, - ...(input.ticket.body ? { value: input.ticket.body } : {}), - }, - ]; - - if (input.ticket.assignee) { - versions.push({ - field: "assignee", - source: "local", - recordedAt: input.ticket.updatedAt, - value: input.ticket.assignee, - }); - } - - for (const conflict of input.conflicts ?? []) { - const field = normalizeTicketFieldName(conflict.field); - if (conflict.localValue !== undefined) { - versions.push({ - field, - source: "local", - recordedAt: conflict.updatedAt, - value: conflict.localValue, - }); - } - if (conflict.remoteValue !== undefined) { - versions.push({ - field, - source: "remote", - provider: conflict.provider, - recordedAt: conflict.updatedAt, - value: conflict.remoteValue, - }); - } - } - - return versions; -} - -export function buildTicketProvenance(input: { - readonly ticket: TicketProvenanceCompatibility; - readonly syncCheckpoints?: readonly TicketSyncCheckpointCompatibility[]; - readonly conflicts?: readonly TicketSyncConflictCompatibility[]; -}): { - readonly origin: TicketOrigin; - readonly remotes: readonly TicketRemoteLink[]; - readonly fieldAuthorities: readonly TicketFieldAuthorityEntry[]; - readonly fieldVersions: readonly TicketFieldVersion[]; -} { - const system = inferTicketSourceSystem({ - ticket: input.ticket, - }); - const remotes = buildTicketRemoteLinks({ - ticket: input.ticket, - syncCheckpoints: input.syncCheckpoints, - }); - - return { - origin: { - owner: input.ticket.owner, - source: input.ticket.source, - system, - }, - remotes, - fieldAuthorities: buildTicketFieldAuthorities({ - remotes, - conflicts: input.conflicts, - }), - fieldVersions: buildTicketFieldVersions({ - ticket: input.ticket, - conflicts: input.conflicts, - }), - }; -} - -export function projectRemoteLinkToCompatibilityFields(input: { - readonly remote: TicketRemoteLink; -}): { - readonly externalSystem: string; - readonly externalId?: string; - readonly externalKey?: string; - readonly externalUrl?: string; - readonly externalProjectId?: string; - readonly externalProjectName?: string; - readonly externalTeamId?: string; -} { - return { - externalSystem: input.remote.provider, - ...(input.remote.remoteId ? { externalId: input.remote.remoteId } : {}), - ...(input.remote.remoteKey ? { externalKey: input.remote.remoteKey } : {}), - ...(input.remote.remoteUrl ? { externalUrl: input.remote.remoteUrl } : {}), - ...(input.remote.projectId - ? { externalProjectId: input.remote.projectId } - : {}), - ...(input.remote.projectName - ? { externalProjectName: input.remote.projectName } - : {}), - ...(input.remote.teamId ? { externalTeamId: input.remote.teamId } : {}), - }; -} diff --git a/src/control-plane/extensions/tickets/repo-state.ts b/src/control-plane/extensions/tickets/repo-state.ts deleted file mode 100644 index ec4f6753..00000000 --- a/src/control-plane/extensions/tickets/repo-state.ts +++ /dev/null @@ -1,216 +0,0 @@ -import { resolve } from "node:path"; - -import { readTextFile, writeTextFileIfChanged } from "../../../lib/fs.ts"; - -export type TicketsRepoGitignoreStatus = "present" | "missing" | "error"; -export type TicketsRepoTrackedStatus = - | "clean" - | "tracked" - | "unavailable" - | "error"; -export type TicketsRepoGitignoreFixStatus = - | "created" - | "updated" - | "noop" - | "error"; -export type TicketsRepoUntrackStatus = - | "removed" - | "noop" - | "skipped" - | "unavailable" - | "error"; - -export type TicketsRepoState = { - readonly gitignore: { - readonly status: TicketsRepoGitignoreStatus; - readonly path: string; - readonly message?: string; - }; - readonly tracked: { - readonly status: TicketsRepoTrackedStatus; - readonly message?: string; - }; -}; - -export type TicketsRepoFix = { - readonly gitignore: { - readonly status: TicketsRepoGitignoreFixStatus; - readonly path: string; - readonly message?: string; - }; - readonly untrack: { - readonly status: TicketsRepoUntrackStatus; - readonly message?: string; - }; -}; - -const GITIGNORE_ENTRY = ".hack/tickets/"; -const GITIGNORE_HEADER = "# hack tickets"; - -export async function checkTicketsRepoState(opts: { - readonly projectRoot: string; -}): Promise<TicketsRepoState> { - const gitignorePath = resolve(opts.projectRoot, ".gitignore"); - const gitignoreText = await readTextFile(gitignorePath); - - const gitignoreStatus: TicketsRepoState["gitignore"] = resolveGitignoreStatus( - { - gitignoreText, - gitignorePath, - } - ); - - const tracked = await runGit({ - cwd: opts.projectRoot, - args: ["ls-files", "-z", "--", ".hack/tickets"], - }); - - if (!tracked.ok) { - const message = formatGitError(tracked); - if (isNotGitRepo(message)) { - return { - gitignore: gitignoreStatus, - tracked: { status: "unavailable", message }, - }; - } - - return { - gitignore: gitignoreStatus, - tracked: { status: "error", message }, - }; - } - - const isTracked = tracked.stdout.length > 0; - return { - gitignore: gitignoreStatus, - tracked: { status: isTracked ? "tracked" : "clean" }, - }; -} - -export async function ensureTicketsGitignore(opts: { - readonly projectRoot: string; -}): Promise<TicketsRepoFix["gitignore"]> { - const path = resolve(opts.projectRoot, ".gitignore"); - const existing = await readTextFile(path); - - if (existing !== null && hasGitignoreEntry({ content: existing })) { - return { status: "noop", path }; - } - - const next = buildGitignoreContent({ existing }); - const result = await writeTextFileIfChanged(path, next); - if (!result.changed) { - return { status: "noop", path }; - } - - return { status: existing === null ? "created" : "updated", path }; -} - -export async function untrackTicketsRepo(opts: { - readonly projectRoot: string; -}): Promise<TicketsRepoFix["untrack"]> { - const listed = await runGit({ - cwd: opts.projectRoot, - args: ["ls-files", "-z", "--", ".hack/tickets"], - }); - - if (!listed.ok) { - const message = formatGitError(listed); - if (isNotGitRepo(message)) { - return { status: "unavailable", message }; - } - return { status: "error", message }; - } - - if (listed.stdout.length === 0) { - return { status: "noop" }; - } - - const removed = await runGit({ - cwd: opts.projectRoot, - args: ["rm", "-r", "--cached", "--", ".hack/tickets"], - }); - - if (!removed.ok) { - return { status: "error", message: formatGitError(removed) }; - } - - return { status: "removed" }; -} - -function resolveGitignoreStatus(opts: { - readonly gitignoreText: string | null; - readonly gitignorePath: string; -}): TicketsRepoState["gitignore"] { - if (opts.gitignoreText === null) { - return { status: "missing", path: opts.gitignorePath }; - } - if (hasGitignoreEntry({ content: opts.gitignoreText })) { - return { status: "present", path: opts.gitignorePath }; - } - return { status: "missing", path: opts.gitignorePath }; -} - -function hasGitignoreEntry(opts: { readonly content: string }): boolean { - return opts.content - .split("\n") - .map((line) => line.trim()) - .filter( - (line) => - line.length > 0 && !line.startsWith("#") && !line.startsWith("!") - ) - .some( - (line) => - line === ".hack/tickets" || - line === ".hack/tickets/" || - line.startsWith(".hack/tickets/") - ); -} - -function buildGitignoreContent(opts: { - readonly existing: string | null; -}): string { - const trimmed = (opts.existing ?? "").trimEnd(); - const addition = `${GITIGNORE_HEADER}\n${GITIGNORE_ENTRY}`; - if (!trimmed) { - return `${addition}\n`; - } - return `${trimmed}\n\n${addition}\n`; -} - -function isNotGitRepo(message: string): boolean { - return message.toLowerCase().includes("not a git repository"); -} - -function formatGitError(result: { - readonly stdout: string; - readonly stderr: string; -}): string { - return `${result.stderr}\n${result.stdout}`.trim() || "git command failed"; -} - -async function runGit(opts: { - readonly cwd: string; - readonly args: readonly string[]; -}): Promise<{ - readonly ok: boolean; - readonly stdout: string; - readonly stderr: string; -}> { - try { - const proc = Bun.spawn(["git", ...opts.args], { - cwd: opts.cwd, - stdout: "pipe", - stderr: "pipe", - stdin: "ignore", - }); - const stdout = await new Response(proc.stdout).text(); - const stderr = await new Response(proc.stderr).text(); - const exitCode = await proc.exited; - return { ok: exitCode === 0, stdout, stderr }; - } catch (error: unknown) { - const message = - error instanceof Error ? error.message : "Failed to run git"; - return { ok: false, stdout: "", stderr: message }; - } -} diff --git a/src/control-plane/extensions/tickets/runs-channel.ts b/src/control-plane/extensions/tickets/runs-channel.ts deleted file mode 100644 index 73513c60..00000000 --- a/src/control-plane/extensions/tickets/runs-channel.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { randomUUID } from "node:crypto"; -import { resolve } from "node:path"; -import type { DispatchRunRecord } from "../../../lib/dispatch-runs.ts"; -import { ensureDir, readTextFile } from "../../../lib/fs.ts"; -import type { Logger } from "../../../ui/logger.ts"; -import type { ControlPlaneConfig } from "../../sdk/config.ts"; -import { createGitTicketsChannel } from "./tickets-git-channel.ts"; -import { unixSeconds } from "./util.ts"; - -const MAX_LOG_BYTES = 200_000; - -const SECRET_PATTERNS = [ - { - pattern: /(authorization:\s*bearer\s+)[^\s]+/gi, - replacement: "$1***", - }, - { - pattern: /((?:token|api[_-]?key|password|secret)\s*[:=]\s*)[^\s]+/gi, - replacement: "$1***", - }, - { - pattern: /\bgh[pousr]_[A-Za-z0-9_]{20,}\b/g, - replacement: "gh_***", - }, -] as const; - -/** - * Mirror a dispatched run's durable artifacts to the canonical tickets git channel. - */ -export async function persistDispatchRunToTicketsChannel(input: { - readonly projectRoot: string; - readonly controlPlaneConfig: ControlPlaneConfig; - readonly run: DispatchRunRecord; - readonly actor: string; - readonly logger: Pick<Logger, "info" | "warn">; -}): Promise< - | { readonly ok: true; readonly didWrite: boolean } - | { readonly ok: false; readonly error: string } -> { - const channel = createGitTicketsChannel({ - projectRoot: input.projectRoot, - config: input.controlPlaneConfig.tickets.git, - logger: input.logger, - }); - - let worktreeRoot: string; - try { - worktreeRoot = await channel.ensureCheckedOut(); - } catch (error: unknown) { - const message = - error instanceof Error ? error.message : "Failed to checkout tickets ref"; - return { ok: false, error: message }; - } - - const runDir = resolve(worktreeRoot, ".hack/tickets/runs", input.run.runId); - await ensureDir(runDir); - - const artifacts = input.run.artifacts; - const [summary, patch, tests, logs, manifest, events] = await Promise.all([ - readArtifactText({ path: artifacts.summaryPath }), - readArtifactText({ path: artifacts.patchPath }), - readArtifactText({ path: artifacts.testsPath }), - readArtifactText({ path: artifacts.logPath }), - readArtifactText({ path: artifacts.manifestPath }), - readArtifactText({ path: artifacts.eventsPath }), - ]); - - const sanitizedLogs = sanitizeText({ - text: logs, - maxBytes: MAX_LOG_BYTES, - }); - - await Promise.all([ - Bun.write(resolve(runDir, "summary.md"), summary), - Bun.write(resolve(runDir, "patch.diff"), patch), - Bun.write(resolve(runDir, "tests.json"), tests), - Bun.write(resolve(runDir, "logs.txt"), sanitizedLogs), - Bun.write(resolve(runDir, "manifest.json"), manifest), - Bun.write(resolve(runDir, "events.jsonl"), sanitizeText({ text: events })), - Bun.write( - resolve(runDir, "run.json"), - `${JSON.stringify(input.run, null, 2)}\n` - ), - ]); - - const ts = unixSeconds(); - if (input.run.ticketId) { - const persisted = await channel.appendEvents({ - events: [ - { - eventId: randomUUID(), - ts, - actor: input.actor, - ticketId: input.run.ticketId, - type: "run.artifacts_persisted", - payload: { - runId: input.run.runId, - status: input.run.status, - nodeId: input.run.nodeId, - projectId: input.run.projectId ?? null, - projectName: input.run.projectName ?? null, - branch: input.run.branch ?? null, - path: `.hack/tickets/runs/${input.run.runId}`, - }, - }, - ], - }); - if (!persisted.ok) { - return persisted; - } - return { ok: true, didWrite: true }; - } - - const synced = await channel.sync(); - if (!synced.ok) { - return synced; - } - return { ok: true, didWrite: synced.didCommit || synced.didPush }; -} - -async function readArtifactText(input: { - readonly path: string; -}): Promise<string> { - return (await readTextFile(input.path)) ?? ""; -} - -function sanitizeText(input: { - readonly text: string; - readonly maxBytes?: number; -}): string { - let next = input.text; - for (const entry of SECRET_PATTERNS) { - next = next.replaceAll(entry.pattern, entry.replacement); - } - const maxBytes = input.maxBytes; - if (maxBytes && next.length > maxBytes) { - const suffix = "\n\n[truncated]\n"; - next = `${next.slice(next.length - maxBytes)}${suffix}`; - } - if (!next.endsWith("\n")) { - next = `${next}\n`; - } - return next; -} diff --git a/src/control-plane/extensions/tickets/sqlite-projection.ts b/src/control-plane/extensions/tickets/sqlite-projection.ts deleted file mode 100644 index bcbabfcd..00000000 --- a/src/control-plane/extensions/tickets/sqlite-projection.ts +++ /dev/null @@ -1,432 +0,0 @@ -import { Database } from "bun:sqlite"; -import { mkdir, readdir, stat } from "node:fs/promises"; -import { dirname, resolve } from "node:path"; - -import type { TicketDocument } from "./documents.ts"; -import type { - TicketComment, - TicketEvent, - TicketReviewNote, - TicketStoreSnapshot, - TicketSummary, - TicketSyncCheckpoint, - TicketSyncConflict, -} from "./store.ts"; -import { sha256Hex, stableStringify } from "./util.ts"; - -const PROJECTION_SCHEMA_VERSION = 1; - -type ProjectionMetaRow = { - readonly key: string; - readonly value: string; -}; - -type ProjectionJsonRow = { - readonly ticket_id: string; - readonly json_value: string; -}; - -type ProjectionEventRow = { - readonly ticket_id: string; - readonly json_value: string; - readonly ts: number; - readonly order_key: string | null; -}; - -export function createTicketsSqliteProjection(opts: { - readonly projectRoot: string; -}): { - readonly computeJournalSignature: (input: { - readonly ticketsRoot: string; - }) => Promise<string>; - readonly path: string; - readonly readSnapshot: (input: { - readonly journalSignature: string; - }) => Promise<TicketStoreSnapshot | null>; - readonly replaceSnapshot: (input: { - readonly journalSignature: string; - readonly snapshot: TicketStoreSnapshot; - }) => Promise<void>; -} { - const path = resolve(opts.projectRoot, ".hack/tickets/projection.sqlite"); - - const computeJournalSignature = async (input: { - readonly ticketsRoot: string; - }): Promise<string> => { - const eventsDir = resolve(input.ticketsRoot, ".hack/tickets/events"); - let entries: string[] = []; - try { - entries = (await readdir(eventsDir)) - .filter((entry) => entry.endsWith(".jsonl")) - .sort(); - } catch { - return sha256Hex({ value: "" }); - } - - const files = await Promise.all( - entries.map(async (entry) => { - const filePath = resolve(eventsDir, entry); - const metadata = await stat(filePath).catch(() => null); - return { - entry, - size: metadata?.size ?? -1, - mtimeMs: metadata?.mtimeMs ?? -1, - }; - }) - ); - - return sha256Hex({ - value: stableStringify( - files.map((file) => ({ - entry: file.entry, - size: file.size, - mtimeMs: file.mtimeMs, - })) - ), - }); - }; - - const replaceSnapshot = async (input: { - readonly journalSignature: string; - readonly snapshot: TicketStoreSnapshot; - }): Promise<void> => { - await mkdir(dirname(path), { recursive: true }); - const db = new Database(path); - try { - initializeProjectionSchema({ db }); - - const insertMeta = db.query( - "INSERT INTO projection_meta (key, value) VALUES (?, ?)" - ); - const insertTicket = db.query( - "INSERT INTO tickets (ticket_id, title, updated_at, json_value) VALUES (?, ?, ?, ?)" - ); - const insertEvent = db.query( - "INSERT INTO journal_events (event_id, ticket_id, ts, order_key, event_type, idempotency_key, json_value) VALUES (?, ?, ?, ?, ?, ?, ?)" - ); - const insertComment = db.query( - "INSERT INTO ticket_comments (comment_id, ticket_id, json_value) VALUES (?, ?, ?)" - ); - const insertDocument = db.query( - "INSERT INTO ticket_documents (document_id, ticket_id, json_value) VALUES (?, ?, ?)" - ); - const insertReviewNote = db.query( - "INSERT INTO ticket_review_notes (note_id, ticket_id, json_value) VALUES (?, ?, ?)" - ); - const insertCheckpoint = db.query( - "INSERT INTO ticket_sync_checkpoints (checkpoint_id, ticket_id, json_value) VALUES (?, ?, ?)" - ); - const insertConflict = db.query( - "INSERT INTO ticket_sync_conflicts (conflict_id, ticket_id, json_value) VALUES (?, ?, ?)" - ); - - const events = [...input.snapshot.eventsByTicket.values()].flat(); - - const transaction = db.transaction(() => { - db.exec(` - DELETE FROM projection_meta; - DELETE FROM tickets; - DELETE FROM journal_events; - DELETE FROM ticket_comments; - DELETE FROM ticket_documents; - DELETE FROM ticket_review_notes; - DELETE FROM ticket_sync_checkpoints; - DELETE FROM ticket_sync_conflicts; - `); - - insertMeta.run("schema_version", String(PROJECTION_SCHEMA_VERSION)); - insertMeta.run("journal_signature", input.journalSignature); - insertMeta.run("rebuilt_at", new Date().toISOString()); - - for (const ticket of input.snapshot.tickets) { - insertTicket.run( - ticket.ticketId, - ticket.title, - ticket.updatedAt, - JSON.stringify(ticket) - ); - } - - for (const event of events) { - insertEvent.run( - event.eventId, - event.ticketId, - event.ts, - event.orderKey ?? null, - event.eventType, - event.idempotencyKey, - JSON.stringify(event) - ); - } - - writeMapEntries({ - map: input.snapshot.commentsByTicket, - write: (comment: TicketComment) => { - insertComment.run( - comment.commentId, - comment.ticketId, - JSON.stringify(comment) - ); - }, - }); - - writeMapEntries({ - map: input.snapshot.documentsByTicket, - write: (document: TicketDocument) => { - insertDocument.run( - document.documentId, - document.ticketId, - JSON.stringify(document) - ); - }, - }); - - writeMapEntries({ - map: input.snapshot.reviewNotesByTicket, - write: (reviewNote: TicketReviewNote) => { - insertReviewNote.run( - reviewNote.noteId, - reviewNote.ticketId, - JSON.stringify(reviewNote) - ); - }, - }); - - writeMapEntries({ - map: input.snapshot.syncCheckpointsByTicket, - write: (checkpoint: TicketSyncCheckpoint) => { - insertCheckpoint.run( - checkpoint.checkpointId, - checkpoint.ticketId, - JSON.stringify(checkpoint) - ); - }, - }); - - writeMapEntries({ - map: input.snapshot.conflictsByTicket, - write: (conflict: TicketSyncConflict) => { - insertConflict.run( - conflict.conflictId, - conflict.ticketId, - JSON.stringify(conflict) - ); - }, - }); - }); - - transaction(); - } finally { - db.close(); - } - }; - - const readSnapshot = async (input: { - readonly journalSignature: string; - }): Promise<TicketStoreSnapshot | null> => { - if (!(await Bun.file(path).exists())) { - return null; - } - - const db = new Database(path); - try { - initializeProjectionSchema({ db }); - const metaRows = db - .query("SELECT key, value FROM projection_meta") - .all() as ProjectionMetaRow[]; - const meta = new Map( - metaRows.map((row) => [row.key, row.value] as const) - ); - if ( - meta.get("schema_version") !== String(PROJECTION_SCHEMA_VERSION) || - meta.get("journal_signature") !== input.journalSignature - ) { - return null; - } - - const tickets = ( - db - .query( - "SELECT ticket_id, json_value FROM tickets ORDER BY CAST(SUBSTR(ticket_id, 3) AS INTEGER), ticket_id" - ) - .all() as ProjectionJsonRow[] - ) - .map((row) => - parseProjectionJson<TicketSummary>({ json: row.json_value }) - ) - .filter((ticket): ticket is TicketSummary => ticket !== null); - - const eventsByTicket = readEventRows({ db }); - const commentsByTicket = readRowsByTicket<TicketComment>({ - db, - table: "ticket_comments", - }); - const documentsByTicket = readRowsByTicket<TicketDocument>({ - db, - table: "ticket_documents", - }); - const reviewNotesByTicket = readRowsByTicket<TicketReviewNote>({ - db, - table: "ticket_review_notes", - }); - const syncCheckpointsByTicket = readRowsByTicket<TicketSyncCheckpoint>({ - db, - table: "ticket_sync_checkpoints", - }); - const conflictsByTicket = readRowsByTicket<TicketSyncConflict>({ - db, - table: "ticket_sync_conflicts", - }); - - return { - tickets, - eventsByTicket, - documentsByTicket, - commentsByTicket, - reviewNotesByTicket, - syncCheckpointsByTicket, - conflictsByTicket, - }; - } finally { - db.close(); - } - }; - - return { - computeJournalSignature, - path, - readSnapshot, - replaceSnapshot, - }; -} - -function initializeProjectionSchema(input: { readonly db: Database }): void { - input.db.exec(` - PRAGMA journal_mode = WAL; - CREATE TABLE IF NOT EXISTS projection_meta ( - key TEXT PRIMARY KEY, - value TEXT NOT NULL - ); - CREATE TABLE IF NOT EXISTS tickets ( - ticket_id TEXT PRIMARY KEY, - title TEXT NOT NULL, - updated_at TEXT NOT NULL, - json_value TEXT NOT NULL - ); - CREATE TABLE IF NOT EXISTS journal_events ( - event_id TEXT PRIMARY KEY, - ticket_id TEXT NOT NULL, - ts INTEGER NOT NULL, - order_key TEXT, - event_type TEXT NOT NULL, - idempotency_key TEXT NOT NULL, - json_value TEXT NOT NULL - ); - CREATE TABLE IF NOT EXISTS ticket_comments ( - comment_id TEXT PRIMARY KEY, - ticket_id TEXT NOT NULL, - json_value TEXT NOT NULL - ); - CREATE TABLE IF NOT EXISTS ticket_documents ( - document_id TEXT PRIMARY KEY, - ticket_id TEXT NOT NULL, - json_value TEXT NOT NULL - ); - CREATE TABLE IF NOT EXISTS ticket_review_notes ( - note_id TEXT PRIMARY KEY, - ticket_id TEXT NOT NULL, - json_value TEXT NOT NULL - ); - CREATE TABLE IF NOT EXISTS ticket_sync_checkpoints ( - checkpoint_id TEXT PRIMARY KEY, - ticket_id TEXT NOT NULL, - json_value TEXT NOT NULL - ); - CREATE TABLE IF NOT EXISTS ticket_sync_conflicts ( - conflict_id TEXT PRIMARY KEY, - ticket_id TEXT NOT NULL, - json_value TEXT NOT NULL - ); - CREATE INDEX IF NOT EXISTS journal_events_ticket_id_idx - ON journal_events (ticket_id, ts, order_key); - CREATE INDEX IF NOT EXISTS ticket_comments_ticket_id_idx - ON ticket_comments (ticket_id); - CREATE INDEX IF NOT EXISTS ticket_documents_ticket_id_idx - ON ticket_documents (ticket_id); - CREATE INDEX IF NOT EXISTS ticket_review_notes_ticket_id_idx - ON ticket_review_notes (ticket_id); - CREATE INDEX IF NOT EXISTS ticket_sync_checkpoints_ticket_id_idx - ON ticket_sync_checkpoints (ticket_id); - CREATE INDEX IF NOT EXISTS ticket_sync_conflicts_ticket_id_idx - ON ticket_sync_conflicts (ticket_id); - `); -} - -function parseProjectionJson<T>(input: { readonly json: string }): T | null { - try { - return JSON.parse(input.json) as T; - } catch { - return null; - } -} - -function readEventRows(input: { - readonly db: Database; -}): Map<string, TicketEvent[]> { - const rows = input.db - .query( - "SELECT ticket_id, json_value, ts, order_key FROM journal_events ORDER BY ts, order_key, event_id" - ) - .all() as ProjectionEventRow[]; - - const grouped = new Map<string, TicketEvent[]>(); - for (const row of rows) { - const event = parseProjectionJson<TicketEvent>({ json: row.json_value }); - if (!event) { - continue; - } - const list = grouped.get(row.ticket_id) ?? []; - list.push(event); - grouped.set(row.ticket_id, list); - } - return grouped; -} - -function readRowsByTicket<T>(input: { - readonly db: Database; - readonly table: - | "ticket_comments" - | "ticket_documents" - | "ticket_review_notes" - | "ticket_sync_checkpoints" - | "ticket_sync_conflicts"; -}): Map<string, T[]> { - const rows = input.db - .query( - `SELECT ticket_id, json_value FROM ${input.table} ORDER BY ticket_id, rowid` - ) - .all() as ProjectionJsonRow[]; - - const grouped = new Map<string, T[]>(); - for (const row of rows) { - const value = parseProjectionJson<T>({ json: row.json_value }); - if (!value) { - continue; - } - const list = grouped.get(row.ticket_id) ?? []; - list.push(value); - grouped.set(row.ticket_id, list); - } - return grouped; -} - -function writeMapEntries<T>(input: { - readonly map: ReadonlyMap<string, readonly T[]>; - readonly write: (value: T) => void; -}): void { - for (const values of input.map.values()) { - for (const value of values) { - input.write(value); - } - } -} diff --git a/src/control-plane/extensions/tickets/store.ts b/src/control-plane/extensions/tickets/store.ts deleted file mode 100644 index 69b63fe1..00000000 --- a/src/control-plane/extensions/tickets/store.ts +++ /dev/null @@ -1,2743 +0,0 @@ -import { randomUUID } from "node:crypto"; -import { readdir } from "node:fs/promises"; -import { hostname } from "node:os"; -import { resolve } from "node:path"; - -import { isRecord } from "../../../lib/guards.ts"; -import type { ControlPlaneConfig } from "../../sdk/config.ts"; -import { - buildLegacyDescriptionDocument, - buildTicketDocument, - getActiveTicketDescription, - isTicketDocumentKind, - isTicketDocumentRole, - type TicketDocument, - type TicketDocumentKind, - type TicketDocumentRole, -} from "./documents.ts"; -import { - createNormalizedTicket, - projectNormalizedTicketSummary, -} from "./domain.ts"; -import { createTicketsSqliteProjection } from "./sqlite-projection.ts"; -import { - createGitTicketsChannel, - isTicketsGitRemoteConnectivityError, -} from "./tickets-git-channel.ts"; -import { - compareTicketIds, - generateTicketId, - normalizeTicketRefs, - unixSeconds, -} from "./util.ts"; - -export type TicketStatus = "open" | "in_progress" | "blocked" | "done"; - -export type TicketSummary = { - readonly ticketId: string; - readonly title: string; - readonly body?: string; - readonly status: TicketStatus; - readonly createdAt: string; - readonly updatedAt: string; - readonly dependsOn: readonly string[]; - readonly blocks: readonly string[]; - readonly owner: string; - readonly source: string; - readonly assignee?: string; - readonly tags: readonly string[]; - readonly externalSystem?: string; - readonly externalId?: string; - readonly externalKey?: string; - readonly externalUrl?: string; - readonly externalProjectId?: string; - readonly externalProjectName?: string; - readonly externalTeamId?: string; - readonly projectId?: string; - readonly projectName?: string; -}; - -export type TicketMetadataValue = - | string - | number - | boolean - | null - | readonly TicketMetadataValue[] - | { readonly [key: string]: TicketMetadataValue }; - -export type TicketComment = { - readonly commentId: string; - readonly ticketId: string; - readonly body: string; - readonly source: string; - readonly actor: string; - readonly createdAt: string; - readonly externalId?: string; - readonly externalUrl?: string; -}; - -export type TicketReviewNote = { - readonly noteId: string; - readonly ticketId: string; - readonly body: string; - readonly actor: string; - readonly createdAt: string; - readonly context?: string; -}; - -export type TicketSyncCheckpoint = { - readonly checkpointId: string; - readonly ticketId: string; - readonly provider: string; - readonly profileId?: string; - readonly direction?: string; - readonly remoteCursor?: string; - readonly remoteUpdatedAt?: string; - readonly localUpdatedAt?: string; - readonly actor: string; - readonly createdAt: string; -}; - -export type TicketSyncConflictResolution = - | "accept_local" - | "accept_remote" - | "merged" - | "ignore"; - -export type TicketSyncConflict = { - readonly conflictId: string; - readonly ticketId: string; - readonly provider: string; - readonly field: string; - readonly status: "open" | "resolved"; - readonly authority?: string; - readonly summary?: string; - readonly localValue?: TicketMetadataValue; - readonly remoteValue?: TicketMetadataValue; - readonly createdAt: string; - readonly updatedAt: string; - readonly resolution?: TicketSyncConflictResolution; - readonly resolutionSummary?: string; - readonly resolvedAt?: string; - readonly resolvedBy?: string; -}; - -export type TicketEvent = { - readonly eventId: string; - readonly schemaVersion: number; - readonly ts: number; - readonly tsIso: string; - readonly eventType: string; - readonly occurredAt: string; - readonly recordedAt: string; - readonly actor: string; - readonly sourceSystem: string; - readonly sourceOperation: string; - readonly idempotencyKey: string; - readonly causationId?: string; - readonly correlationId?: string; - readonly orderKey?: string; - readonly projectId?: string; - readonly projectName?: string; - readonly ticketId: string; - readonly type: string; - readonly payload: Record<string, unknown>; -}; - -export type TicketStoreSnapshot = { - readonly tickets: readonly TicketSummary[]; - readonly eventsByTicket: ReadonlyMap<string, readonly TicketEvent[]>; - readonly documentsByTicket: ReadonlyMap<string, readonly TicketDocument[]>; - readonly commentsByTicket: ReadonlyMap<string, readonly TicketComment[]>; - readonly reviewNotesByTicket: ReadonlyMap< - string, - readonly TicketReviewNote[] - >; - readonly syncCheckpointsByTicket: ReadonlyMap< - string, - readonly TicketSyncCheckpoint[] - >; - readonly conflictsByTicket: ReadonlyMap< - string, - readonly TicketSyncConflict[] - >; -}; - -type CreateTicketResult = - | { readonly ok: true; readonly ticket: TicketSummary } - | { readonly ok: false; readonly error: string }; - -type SyncResult = - | { - readonly ok: true; - readonly branch: string; - readonly remote?: string; - readonly didCommit: boolean; - readonly didPush: boolean; - } - | { readonly ok: false; readonly error: string }; - -type MaterializedTicketState = { - readonly tickets: Map<string, TicketSummary>; - readonly documentsByTicket: Map<string, TicketDocument[]>; - readonly commentsByTicket: Map<string, TicketComment[]>; - readonly reviewNotesByTicket: Map<string, TicketReviewNote[]>; - readonly syncCheckpointsByTicket: Map<string, TicketSyncCheckpoint[]>; - readonly conflictsByTicket: Map<string, TicketSyncConflict[]>; -}; - -type TicketStoreContext = { - readonly events: readonly TicketEvent[]; - readonly snapshot: TicketStoreSnapshot; -}; - -type NormalizedTicketMetadata = { - readonly dependsOn: readonly string[]; - readonly blocks: readonly string[]; - readonly owner: string; - readonly source: string; - readonly assignee?: string; - readonly tags: readonly string[]; - readonly externalSystem?: string; - readonly externalId?: string; - readonly externalKey?: string; - readonly externalUrl?: string; - readonly externalProjectId?: string; - readonly externalProjectName?: string; - readonly externalTeamId?: string; -}; - -type TicketUpdatePayloadResult = - | { - readonly ok: true; - readonly payload: Record<string, unknown>; - readonly documentContent?: string; - } - | { readonly ok: false; readonly error: string }; - -type SyncCheckpointBuildResult = - | { - readonly ok: true; - readonly checkpointId: string; - readonly provider: string; - readonly payload: Record<string, unknown>; - readonly checkpoint: TicketSyncCheckpoint; - } - | { readonly ok: false; readonly error: string }; - -type SyncConflictBuildResult = - | { - readonly ok: true; - readonly conflictId: string; - readonly provider: string; - readonly field: string; - readonly payload: Record<string, unknown>; - readonly conflict: Omit<TicketSyncConflict, "createdAt" | "updatedAt">; - } - | { readonly ok: false; readonly error: string }; - -const MAX_TICKET_ID_ALLOCATION_ATTEMPTS = 32; - -function normalizeTicketSummaryCompatibility(input: { - readonly ticket: TicketSummary; -}): TicketSummary { - return projectNormalizedTicketSummary({ - ticket: createNormalizedTicket({ - ticket: input.ticket, - }), - }); -} - -export function createTicketsStore(opts: { - readonly projectRoot: string; - readonly projectId?: string; - readonly projectName?: string; - readonly controlPlaneConfig: ControlPlaneConfig; - readonly generateTicketId?: () => string; - readonly logger: { - info: (input: { message: string }) => void; - warn: (input: { message: string }) => void; - }; -}): { - readonly createTicket: (input: { - readonly title: string; - readonly body?: string; - readonly dependsOn?: readonly string[]; - readonly blocks?: readonly string[]; - readonly owner?: string; - readonly source?: string; - readonly assignee?: string; - readonly tags?: readonly string[]; - readonly externalSystem?: string; - readonly externalId?: string; - readonly externalKey?: string; - readonly externalUrl?: string; - readonly externalProjectId?: string; - readonly externalProjectName?: string; - readonly externalTeamId?: string; - readonly actor?: string; - }) => Promise<CreateTicketResult>; - readonly updateTicket: (input: { - readonly ticketId: string; - readonly title?: string; - readonly body?: string; - readonly dependsOn?: readonly string[]; - readonly blocks?: readonly string[]; - readonly owner?: string; - readonly source?: string; - readonly assignee?: string; - readonly tags?: readonly string[]; - readonly externalSystem?: string; - readonly externalId?: string; - readonly externalKey?: string; - readonly externalUrl?: string; - readonly externalProjectId?: string; - readonly externalProjectName?: string; - readonly externalTeamId?: string; - readonly actor?: string; - }) => Promise< - | { readonly ok: true; readonly changed?: boolean } - | { readonly ok: false; readonly error: string } - >; - readonly listTickets: () => Promise<readonly TicketSummary[]>; - readonly getTicket: (input: { - readonly ticketId: string; - }) => Promise<TicketSummary | null>; - readonly listEvents: (input: { - readonly ticketId: string; - }) => Promise<readonly TicketEvent[]>; - readonly getTicketDetail: (input: { readonly ticketId: string }) => Promise<{ - readonly ticket: TicketSummary | null; - readonly events: readonly TicketEvent[]; - readonly documents: readonly TicketDocument[]; - readonly comments: readonly TicketComment[]; - readonly reviewNotes: readonly TicketReviewNote[]; - readonly syncCheckpoints: readonly TicketSyncCheckpoint[]; - readonly conflicts: readonly TicketSyncConflict[]; - }>; - readonly appendComment: (input: { - readonly ticketId: string; - readonly body: string; - readonly source?: string; - readonly externalId?: string; - readonly externalUrl?: string; - readonly actor?: string; - }) => Promise< - | { readonly ok: true; readonly comment: TicketComment } - | { readonly ok: false; readonly error: string } - >; - readonly appendReviewNote: (input: { - readonly ticketId: string; - readonly body: string; - readonly context?: string; - readonly actor?: string; - }) => Promise< - | { readonly ok: true; readonly reviewNote: TicketReviewNote } - | { readonly ok: false; readonly error: string } - >; - readonly appendDocument: (input: { - readonly ticketId: string; - readonly kind: TicketDocumentKind; - readonly role?: TicketDocumentRole; - readonly content: string; - readonly actor?: string; - }) => Promise< - | { readonly ok: true; readonly document: TicketDocument } - | { readonly ok: false; readonly error: string } - >; - readonly linkCommentExternalId: (input: { - readonly ticketId: string; - readonly commentId: string; - readonly externalId: string; - readonly externalUrl?: string; - readonly actor?: string; - }) => Promise< - { readonly ok: true } | { readonly ok: false; readonly error: string } - >; - readonly recordSyncCheckpoint: (input: { - readonly ticketId: string; - readonly provider: string; - readonly profileId?: string; - readonly direction?: string; - readonly remoteCursor?: string; - readonly remoteUpdatedAt?: string; - readonly localUpdatedAt?: string; - readonly idempotencyKey?: string; - readonly actor?: string; - }) => Promise< - | { - readonly ok: true; - readonly checkpoint: TicketSyncCheckpoint; - readonly recorded?: boolean; - } - | { readonly ok: false; readonly error: string } - >; - readonly recordSyncConflict: (input: { - readonly ticketId: string; - readonly provider: string; - readonly field: string; - readonly authority?: string; - readonly summary?: string; - readonly localValue?: TicketMetadataValue; - readonly remoteValue?: TicketMetadataValue; - readonly idempotencyKey?: string; - readonly actor?: string; - }) => Promise< - | { readonly ok: true; readonly conflict: TicketSyncConflict } - | { readonly ok: false; readonly error: string } - >; - readonly resolveSyncConflict: (input: { - readonly ticketId: string; - readonly conflictId: string; - readonly resolution: TicketSyncConflictResolution; - readonly summary?: string; - readonly actor?: string; - }) => Promise< - { readonly ok: true } | { readonly ok: false; readonly error: string } - >; - readonly readSnapshot: () => Promise<TicketStoreSnapshot>; - readonly setStatus: (input: { - readonly ticketId: string; - readonly status: TicketStatus; - readonly actor?: string; - }) => Promise< - | { readonly ok: true; readonly changed?: boolean } - | { readonly ok: false; readonly error: string } - >; - readonly sync: () => Promise<SyncResult>; -} { - const git = createGitTicketsChannel({ - projectRoot: opts.projectRoot, - config: opts.controlPlaneConfig.tickets.git, - logger: opts.logger, - }); - const projection = createTicketsSqliteProjection({ - projectRoot: opts.projectRoot, - }); - const allocateTicketId = opts.generateTicketId ?? generateTicketId; - let eventSequence = 0; - - const resolveActor = (override?: string): string => { - const trimmed = (override ?? "").trim(); - if (trimmed) { - return trimmed; - } - const user = (process.env.USER ?? "").trim() || "unknown"; - return `${user}@${hostname()}`; - }; - - const buildEvent = (input: { - readonly ticketId: string; - readonly type: string; - readonly payload: Record<string, unknown>; - readonly actor?: string; - readonly occurredAt?: string; - readonly sourceSystem?: string; - readonly sourceOperation?: string; - readonly idempotencyKey?: string; - readonly causationId?: string; - readonly correlationId?: string; - }): TicketEvent => { - const ts = unixSeconds(); - const recordedAt = new Date(ts * 1000).toISOString(); - const eventId = randomUUID(); - const orderKey = `${Date.now()}-${String(eventSequence).padStart(6, "0")}`; - eventSequence += 1; - return { - eventId, - schemaVersion: 1, - ts, - tsIso: recordedAt, - eventType: input.type, - occurredAt: input.occurredAt ?? recordedAt, - recordedAt, - actor: resolveActor(input.actor), - sourceSystem: input.sourceSystem ?? "hack", - sourceOperation: input.sourceOperation ?? "local_command", - idempotencyKey: input.idempotencyKey ?? eventId, - ...(input.causationId ? { causationId: input.causationId } : {}), - ...(input.correlationId ? { correlationId: input.correlationId } : {}), - orderKey, - ...(opts.projectId ? { projectId: opts.projectId } : {}), - ...(opts.projectName ? { projectName: opts.projectName } : {}), - ticketId: input.ticketId, - type: input.type, - payload: input.payload, - }; - }; - - const readAllEventsFromRoot = async (input: { - readonly root: string; - }): Promise<readonly TicketEvent[]> => { - const eventsDir = resolve(input.root, ".hack/tickets/events"); - - let entries: string[] = []; - try { - entries = (await readdir(eventsDir)).filter((f) => f.endsWith(".jsonl")); - } catch { - return []; - } - - const events: TicketEvent[] = []; - const seenEventIds = new Set<string>(); - const seenIdempotencyKeys = new Set<string>(); - for (const filename of entries.sort()) { - const path = resolve(eventsDir, filename); - const text = await Bun.file(path) - .text() - .catch(() => ""); - for (const line of text.split("\n")) { - const trimmed = line.trim(); - if (!trimmed) { - continue; - } - const parsed = safeJsonParse(trimmed); - const event = parseEvent(parsed); - if (!event || seenEventIds.has(event.eventId)) { - continue; - } - if (seenIdempotencyKeys.has(event.idempotencyKey)) { - continue; - } - seenEventIds.add(event.eventId); - seenIdempotencyKeys.add(event.idempotencyKey); - events.push(event); - } - } - - events.sort((a, b) => { - if (a.ticketId === b.ticketId && a.type !== b.type) { - if (a.type === "ticket.created") { - return -1; - } - if (b.type === "ticket.created") { - return 1; - } - } - if (a.ts !== b.ts) { - return a.ts - b.ts; - } - if (a.orderKey && b.orderKey && a.orderKey !== b.orderKey) { - return a.orderKey.localeCompare(b.orderKey); - } - return 0; - }); - return events; - }; - - const materializeSnapshotFromEvents = (input: { - readonly events: readonly TicketEvent[]; - }): MaterializedTicketState => { - const tickets = new Map<string, TicketSummary>(); - const documentsByTicket = new Map<string, TicketDocument[]>(); - const commentsByTicket = new Map<string, TicketComment[]>(); - const reviewNotesByTicket = new Map<string, TicketReviewNote[]>(); - const syncCheckpointsByTicket = new Map<string, TicketSyncCheckpoint[]>(); - const conflictsByTicket = new Map<string, TicketSyncConflict[]>(); - - for (const event of input.events) { - applyTicketEvent({ - tickets, - documentsByTicket, - commentsByTicket, - reviewNotesByTicket, - syncCheckpointsByTicket, - conflictsByTicket, - event, - }); - } - - return { - tickets: applyDerivedBlocks(tickets), - documentsByTicket, - commentsByTicket, - reviewNotesByTicket, - syncCheckpointsByTicket, - conflictsByTicket, - }; - }; - - const groupEventsByTicket = (input: { - readonly events: readonly TicketEvent[]; - }): Map<string, TicketEvent[]> => { - const grouped = new Map<string, TicketEvent[]>(); - for (const event of input.events) { - const list = grouped.get(event.ticketId) ?? []; - list.push(event); - grouped.set(event.ticketId, list); - } - return grouped; - }; - - const sortTickets = (input: { - readonly tickets: Iterable<TicketSummary>; - }): TicketSummary[] => { - const out = [...input.tickets]; - out.sort((left, right) => { - const createdAt = left.createdAt.localeCompare(right.createdAt); - if (createdAt !== 0) { - return createdAt; - } - return compareTicketIds(left.ticketId, right.ticketId); - }); - return out; - }; - - const buildStoreSnapshot = (input: { - readonly events: readonly TicketEvent[]; - readonly materialized: MaterializedTicketState; - }): TicketStoreSnapshot => { - return { - tickets: sortTickets({ tickets: input.materialized.tickets.values() }), - eventsByTicket: groupEventsByTicket({ events: input.events }), - documentsByTicket: input.materialized.documentsByTicket, - commentsByTicket: input.materialized.commentsByTicket, - reviewNotesByTicket: input.materialized.reviewNotesByTicket, - syncCheckpointsByTicket: input.materialized.syncCheckpointsByTicket, - conflictsByTicket: input.materialized.conflictsByTicket, - }; - }; - - const rebuildProjectionFromJournal = async (): Promise< - | { readonly ok: true; readonly context: TicketStoreContext } - | { - readonly ok: false; - readonly error: string; - } - > => { - try { - const root = await git.ensureCheckedOut({ refreshRemote: false }); - const events = await readAllEventsFromRoot({ root }); - const materialized = materializeSnapshotFromEvents({ events }); - const snapshot = buildStoreSnapshot({ events, materialized }); - const journalSignature = await projection.computeJournalSignature({ - ticketsRoot: root, - }); - await projection.replaceSnapshot({ - journalSignature, - snapshot, - }); - return { - ok: true, - context: { - events, - snapshot, - }, - }; - } catch (error: unknown) { - const message = - error instanceof Error - ? error.message - : "Failed to rebuild tickets sqlite projection"; - return { - ok: false, - error: message, - }; - } - }; - - const loadStoreContext = async (input?: { - readonly refreshRemote?: boolean; - }): Promise<TicketStoreContext> => { - const root = await git.ensureCheckedOut({ - refreshRemote: input?.refreshRemote, - }); - const journalSignature = await projection.computeJournalSignature({ - ticketsRoot: root, - }); - const persisted = await projection.readSnapshot({ - journalSignature, - }); - if (persisted) { - const events = [...persisted.eventsByTicket.values()].flat(); - return { - events, - snapshot: persisted, - }; - } - - const rebuilt = await rebuildProjectionFromJournal(); - if (rebuilt.ok) { - return rebuilt.context; - } - - const events = await readAllEventsFromRoot({ root }); - const materialized = materializeSnapshotFromEvents({ events }); - return { - events, - snapshot: buildStoreSnapshot({ events, materialized }), - }; - }; - - const loadStoreContextWithRemoteFallback = async (input?: { - readonly refreshRemote?: boolean; - }): Promise<TicketStoreContext> => { - try { - return await loadStoreContext(input); - } catch (error: unknown) { - const message = - error instanceof Error ? error.message : "Failed to load tickets state"; - if ( - input?.refreshRemote === false || - !isTicketsGitRemoteConnectivityError(message) - ) { - throw error; - } - opts.logger.warn({ - message: `Tickets remote refresh failed; using local ticket state: ${message}`, - }); - return await loadStoreContext({ refreshRemote: false }); - } - }; - - const materializeTickets = async (input?: { - readonly refreshRemote?: boolean; - }): Promise<Map<string, TicketSummary>> => { - const context = await loadStoreContext(input); - return new Map( - context.snapshot.tickets.map( - (ticket) => [ticket.ticketId, ticket] as const - ) - ); - }; - - const setStatus = async (input: { - readonly ticketId: string; - readonly status: TicketStatus; - readonly actor?: string; - }): Promise< - | { readonly ok: true; readonly changed?: boolean } - | { readonly ok: false; readonly error: string } - > => { - const tickets = await materializeTickets({ refreshRemote: true }); - const current = tickets.get(input.ticketId); - if (!current) { - return { ok: false, error: `Ticket not found: ${input.ticketId}` }; - } - if (current.status === input.status) { - return { ok: true, changed: false }; - } - - const event = buildEvent({ - ticketId: input.ticketId, - type: "ticket.status_changed", - payload: { status: input.status }, - actor: input.actor, - }); - - const wrote = await git.appendEvents({ events: [event] }); - if (!wrote.ok) { - return wrote; - } - - const rebuilt = await rebuildProjectionFromJournal(); - if (!rebuilt.ok) { - return rebuilt; - } - - return { ok: true, changed: true }; - }; - - const appendEventsAndRefresh = async (input: { - readonly events: readonly TicketEvent[]; - readonly refreshRemote?: boolean; - }): Promise< - | { readonly ok: true; readonly appendedCount: number } - | { readonly ok: false; readonly error: string } - > => { - const context = await loadStoreContext({ - refreshRemote: input.refreshRemote, - }); - const seenIdempotencyKeys = new Set( - context.events.map((event) => event.idempotencyKey) - ); - const pendingEvents = input.events.filter((event) => { - if (seenIdempotencyKeys.has(event.idempotencyKey)) { - return false; - } - seenIdempotencyKeys.add(event.idempotencyKey); - return true; - }); - if (pendingEvents.length === 0) { - return { ok: true, appendedCount: 0 }; - } - - const wrote = await git.appendEvents({ events: pendingEvents }); - if (!wrote.ok) { - return wrote; - } - - const rebuilt = await rebuildProjectionFromJournal(); - if (!rebuilt.ok) { - return rebuilt; - } - - return { ok: true, appendedCount: pendingEvents.length }; - }; - - return { - createTicket: async (input) => { - const metadata = normalizeTicketMetadata({ - dependsOn: input.dependsOn, - blocks: input.blocks, - owner: input.owner, - source: input.source, - assignee: input.assignee, - tags: input.tags, - externalSystem: input.externalSystem, - externalId: input.externalId, - externalKey: input.externalKey, - externalUrl: input.externalUrl, - externalProjectId: input.externalProjectId, - externalProjectName: input.externalProjectName, - externalTeamId: input.externalTeamId, - ownerFallback: "hack", - sourceFallback: "hack", - }); - const payload: Record<string, unknown> = { - title: input.title, - ...(input.body ? { body: input.body } : {}), - status: "open", - }; - appendTicketMetadataPayload({ - payload, - metadata, - }); - const wrote = await git.appendPreparedEvents({ - prepare: async (root) => { - const events = await readAllEventsFromRoot({ root }); - const snapshot = materializeSnapshotFromEvents({ events }); - - let ticketId: string | null = null; - for ( - let attempt = 0; - attempt < MAX_TICKET_ID_ALLOCATION_ATTEMPTS; - attempt += 1 - ) { - const candidate = allocateTicketId(); - if (!snapshot.tickets.has(candidate)) { - ticketId = candidate; - break; - } - } - - if (!ticketId) { - return { - ok: false, - error: "Failed to allocate a unique ticket id.", - }; - } - - const event = buildEvent({ - ticketId, - type: "ticket.created", - payload, - actor: input.actor, - }); - - return { - ok: true, - events: [event], - result: { - event, - ticketId, - }, - } as const; - }, - }); - if (!wrote.ok) { - return wrote; - } - - const rebuilt = await rebuildProjectionFromJournal(); - if (!rebuilt.ok) { - return rebuilt; - } - - return { - ok: true, - ticket: buildTicketSummary({ - ticketId: wrote.result.ticketId, - title: input.title, - ...(input.body ? { body: input.body } : {}), - status: "open", - createdAt: wrote.result.event.tsIso, - updatedAt: wrote.result.event.tsIso, - metadata, - projectId: opts.projectId, - projectName: opts.projectName, - }), - }; - }, - - updateTicket: async (input) => { - const tickets = await materializeTickets({ refreshRemote: true }); - const current = tickets.get(input.ticketId); - if (!current) { - return { ok: false, error: `Ticket not found: ${input.ticketId}` }; - } - - const updatePayload = buildTicketUpdatePayload(input); - if (!updatePayload.ok) { - return updatePayload; - } - const nextUpdate = filterTicketUpdatePayloadAgainstCurrent({ - current, - payload: updatePayload.payload, - documentContent: updatePayload.documentContent, - }); - - const events: TicketEvent[] = []; - if (nextUpdate.documentContent) { - events.push( - buildDocumentRecordedEvent({ - buildEvent, - ticketId: input.ticketId, - actor: input.actor, - documentId: randomUUID(), - kind: "description", - content: nextUpdate.documentContent, - }) - ); - } - - if (Object.keys(nextUpdate.payload).length > 0) { - events.push( - buildEvent({ - ticketId: input.ticketId, - type: "ticket.updated", - payload: nextUpdate.payload, - actor: input.actor, - }) - ); - } - - if (events.length === 0) { - return { ok: true, changed: false }; - } - - const wrote = await appendEventsAndRefresh({ - events, - refreshRemote: true, - }); - if (!wrote.ok) { - return wrote; - } - - return { ok: true, changed: wrote.appendedCount > 0 }; - }, - - listTickets: async () => { - const { snapshot } = await loadStoreContextWithRemoteFallback(); - return snapshot.tickets; - }, - - getTicket: async ({ ticketId }) => { - const { snapshot } = await loadStoreContextWithRemoteFallback(); - return ( - snapshot.tickets.find((ticket) => ticket.ticketId === ticketId) ?? null - ); - }, - - listEvents: async ({ ticketId }) => { - const { snapshot } = await loadStoreContextWithRemoteFallback(); - return snapshot.eventsByTicket.get(ticketId) ?? []; - }, - - getTicketDetail: async ({ ticketId }) => { - const { snapshot } = await loadStoreContextWithRemoteFallback(); - return { - ticket: - snapshot.tickets.find((ticket) => ticket.ticketId === ticketId) ?? - null, - events: snapshot.eventsByTicket.get(ticketId) ?? [], - documents: snapshot.documentsByTicket.get(ticketId) ?? [], - comments: snapshot.commentsByTicket.get(ticketId) ?? [], - reviewNotes: snapshot.reviewNotesByTicket.get(ticketId) ?? [], - syncCheckpoints: snapshot.syncCheckpointsByTicket.get(ticketId) ?? [], - conflicts: snapshot.conflictsByTicket.get(ticketId) ?? [], - }; - }, - - appendComment: async (input) => { - const tickets = await materializeTickets({ refreshRemote: true }); - const current = tickets.get(input.ticketId); - if (!current) { - return { ok: false, error: `Ticket not found: ${input.ticketId}` }; - } - - const body = input.body.trim(); - if (!body) { - return { ok: false, error: "Comment body cannot be empty." }; - } - - const source = normalizeOwnerOrSource({ - value: input.source, - fallback: current.source, - }); - const externalId = normalizeOptionalMetadataString({ - value: input.externalId, - }); - const externalUrl = normalizeOptionalMetadataString({ - value: input.externalUrl, - }); - const commentId = randomUUID(); - const event = buildEvent({ - ticketId: input.ticketId, - type: "ticket.comment_appended", - payload: { - commentId, - body, - source, - ...(externalId ? { externalId } : {}), - ...(externalUrl ? { externalUrl } : {}), - }, - actor: input.actor, - }); - - const wrote = await appendEventsAndRefresh({ - events: [event], - refreshRemote: true, - }); - if (!wrote.ok) { - return wrote; - } - - return { - ok: true, - comment: { - commentId, - ticketId: input.ticketId, - body, - recorded: wrote.appendedCount > 0, - source, - actor: event.actor, - createdAt: event.tsIso, - ...(externalId ? { externalId } : {}), - ...(externalUrl ? { externalUrl } : {}), - }, - }; - }, - - appendReviewNote: async (input) => { - const tickets = await materializeTickets({ refreshRemote: true }); - if (!tickets.has(input.ticketId)) { - return { ok: false, error: `Ticket not found: ${input.ticketId}` }; - } - - const body = input.body.trim(); - if (!body) { - return { ok: false, error: "Review note body cannot be empty." }; - } - - const noteId = randomUUID(); - const context = normalizeOptionalMetadataString({ - value: input.context, - }); - const event = buildEvent({ - ticketId: input.ticketId, - type: "ticket.review_note_appended", - payload: { - noteId, - body, - ...(context ? { context } : {}), - }, - actor: input.actor, - }); - - const wrote = await appendEventsAndRefresh({ - events: [event], - refreshRemote: true, - }); - if (!wrote.ok) { - return wrote; - } - - return { - ok: true, - reviewNote: { - noteId, - ticketId: input.ticketId, - body, - actor: event.actor, - createdAt: event.tsIso, - ...(context ? { context } : {}), - }, - }; - }, - - appendDocument: async (input) => { - const tickets = await materializeTickets({ refreshRemote: true }); - if (!tickets.has(input.ticketId)) { - return { ok: false, error: `Ticket not found: ${input.ticketId}` }; - } - - const content = input.content.trimEnd(); - if (!content.trim()) { - return { ok: false, error: "Document content cannot be empty." }; - } - - const documentId = randomUUID(); - const event = buildEvent({ - ticketId: input.ticketId, - type: "ticket.document_recorded", - payload: { - documentId, - kind: input.kind, - ...(input.role ? { role: input.role } : {}), - content, - }, - actor: input.actor, - }); - - const wrote = await appendEventsAndRefresh({ - events: [event], - refreshRemote: true, - }); - if (!wrote.ok) { - return wrote; - } - - return { - ok: true, - document: buildTicketDocument({ - documentId, - ticketId: input.ticketId, - kind: input.kind, - ...(input.role ? { role: input.role } : {}), - content, - createdAt: event.tsIso, - updatedAt: event.tsIso, - }), - }; - }, - - linkCommentExternalId: async (input) => { - const { snapshot } = await loadStoreContext({ refreshRemote: true }); - const comments = snapshot.commentsByTicket.get(input.ticketId) ?? []; - const current = comments.find( - (comment) => comment.commentId === input.commentId - ); - if (!current) { - return { ok: false, error: `Comment not found: ${input.commentId}` }; - } - - const externalId = normalizeOptionalMetadataString({ - value: input.externalId, - }); - if (!externalId) { - return { ok: false, error: "External comment id is required." }; - } - const externalUrl = normalizeOptionalMetadataString({ - value: input.externalUrl, - }); - const event = buildEvent({ - ticketId: input.ticketId, - type: "ticket.comment_linked", - payload: { - commentId: input.commentId, - externalId, - ...(externalUrl ? { externalUrl } : {}), - }, - actor: input.actor, - }); - - return await appendEventsAndRefresh({ - events: [event], - refreshRemote: true, - }); - }, - - recordSyncCheckpoint: async (input) => { - const tickets = await materializeTickets({ refreshRemote: true }); - if (!tickets.has(input.ticketId)) { - return { ok: false, error: `Ticket not found: ${input.ticketId}` }; - } - - const checkpoint = buildSyncCheckpointResult(input); - if (!checkpoint.ok) { - return checkpoint; - } - const event = buildEvent({ - ticketId: input.ticketId, - type: "ticket.sync_checkpoint_recorded", - payload: checkpoint.payload, - ...(input.idempotencyKey - ? { idempotencyKey: input.idempotencyKey } - : {}), - actor: input.actor, - }); - - const wrote = await appendEventsAndRefresh({ - events: [event], - refreshRemote: true, - }); - if (!wrote.ok) { - return wrote; - } - - return { - ok: true, - recorded: wrote.appendedCount > 0, - checkpoint: { - ...checkpoint.checkpoint, - actor: event.actor, - createdAt: event.tsIso, - }, - }; - }, - - recordSyncConflict: async (input) => { - const tickets = await materializeTickets({ refreshRemote: true }); - if (!tickets.has(input.ticketId)) { - return { ok: false, error: `Ticket not found: ${input.ticketId}` }; - } - - const conflict = buildSyncConflictResult(input); - if (!conflict.ok) { - return conflict; - } - const event = buildEvent({ - ticketId: input.ticketId, - type: "ticket.sync_conflict_recorded", - payload: conflict.payload, - ...(input.idempotencyKey - ? { idempotencyKey: input.idempotencyKey } - : {}), - actor: input.actor, - }); - - const wrote = await appendEventsAndRefresh({ - events: [event], - refreshRemote: true, - }); - if (!wrote.ok) { - return wrote; - } - - return { - ok: true, - conflict: { - ...conflict.conflict, - createdAt: event.tsIso, - updatedAt: event.tsIso, - }, - }; - }, - - resolveSyncConflict: async (input) => { - const { snapshot } = await loadStoreContext({ refreshRemote: true }); - const conflicts = snapshot.conflictsByTicket.get(input.ticketId) ?? []; - const current = conflicts.find( - (conflict) => conflict.conflictId === input.conflictId - ); - if (!current) { - return { ok: false, error: `Conflict not found: ${input.conflictId}` }; - } - - const summary = normalizeOptionalMetadataString({ - value: input.summary, - }); - const event = buildEvent({ - ticketId: input.ticketId, - type: "ticket.sync_conflict_resolved", - payload: { - conflictId: input.conflictId, - resolution: input.resolution, - ...(summary ? { summary } : {}), - }, - actor: input.actor, - }); - - return await appendEventsAndRefresh({ - events: [event], - refreshRemote: true, - }); - }, - - readSnapshot: async () => { - const { snapshot } = await loadStoreContextWithRemoteFallback(); - return snapshot; - }, - - sync: async () => { - return await git.sync(); - }, - - setStatus, - }; -} - -function applyTicketEvent(input: { - readonly tickets: Map<string, TicketSummary>; - readonly documentsByTicket: Map<string, TicketDocument[]>; - readonly commentsByTicket: Map<string, TicketComment[]>; - readonly reviewNotesByTicket: Map<string, TicketReviewNote[]>; - readonly syncCheckpointsByTicket: Map<string, TicketSyncCheckpoint[]>; - readonly conflictsByTicket: Map<string, TicketSyncConflict[]>; - readonly event: TicketEvent; -}): void { - switch (input.event.type) { - case "ticket.created": { - applyTicketCreatedEvent({ - tickets: input.tickets, - documentsByTicket: input.documentsByTicket, - event: input.event, - }); - break; - } - case "ticket.status_changed": { - applyTicketStatusChangedEvent({ - tickets: input.tickets, - event: input.event, - }); - break; - } - case "ticket.updated": { - applyTicketUpdatedEvent({ - tickets: input.tickets, - documentsByTicket: input.documentsByTicket, - event: input.event, - }); - break; - } - case "ticket.document_recorded": { - applyTicketDocumentRecordedEvent({ - tickets: input.tickets, - documentsByTicket: input.documentsByTicket, - event: input.event, - }); - break; - } - case "ticket.comment_appended": { - applyTicketCommentAppendedEvent({ - tickets: input.tickets, - commentsByTicket: input.commentsByTicket, - event: input.event, - }); - break; - } - case "ticket.comment_linked": { - applyTicketCommentLinkedEvent({ - commentsByTicket: input.commentsByTicket, - event: input.event, - }); - break; - } - case "ticket.review_note_appended": { - applyTicketReviewNoteAppendedEvent({ - tickets: input.tickets, - reviewNotesByTicket: input.reviewNotesByTicket, - event: input.event, - }); - break; - } - case "ticket.sync_checkpoint_recorded": { - applyTicketSyncCheckpointRecordedEvent({ - tickets: input.tickets, - syncCheckpointsByTicket: input.syncCheckpointsByTicket, - event: input.event, - }); - break; - } - case "ticket.sync_conflict_recorded": { - applyTicketSyncConflictRecordedEvent({ - tickets: input.tickets, - conflictsByTicket: input.conflictsByTicket, - event: input.event, - }); - break; - } - case "ticket.sync_conflict_resolved": { - applyTicketSyncConflictResolvedEvent({ - conflictsByTicket: input.conflictsByTicket, - event: input.event, - }); - break; - } - default: { - break; - } - } -} - -function applyTicketCreatedEvent(input: { - readonly tickets: Map<string, TicketSummary>; - readonly documentsByTicket: Map<string, TicketDocument[]>; - readonly event: TicketEvent; -}): void { - const title = - typeof input.event.payload.title === "string" - ? input.event.payload.title - : ""; - const body = - typeof input.event.payload.body === "string" - ? input.event.payload.body - : undefined; - const dependsOn = parseDependencyList({ - value: input.event.payload.dependsOn, - }); - const blocks = parseDependencyList({ - value: input.event.payload.blocks, - }); - const owner = normalizeOwnerOrSource({ - value: readOptionalStringPayload({ value: input.event.payload.owner }), - fallback: "hack", - }); - const source = normalizeOwnerOrSource({ - value: readOptionalStringPayload({ value: input.event.payload.source }), - fallback: "hack", - }); - const assignee = readOptionalStringPayload({ - value: input.event.payload.assignee, - }); - const tags = parseTags({ value: input.event.payload.tags }); - const externalSystem = readOptionalStringPayload({ - value: input.event.payload.externalSystem, - }); - const externalId = readOptionalStringPayload({ - value: input.event.payload.externalId, - }); - const externalKey = readOptionalStringPayload({ - value: input.event.payload.externalKey, - }); - const externalUrl = readOptionalStringPayload({ - value: input.event.payload.externalUrl, - }); - const externalProjectId = readOptionalStringPayload({ - value: input.event.payload.externalProjectId, - }); - const externalProjectName = readOptionalStringPayload({ - value: input.event.payload.externalProjectName, - }); - const externalTeamId = readOptionalStringPayload({ - value: input.event.payload.externalTeamId, - }); - - input.tickets.set( - input.event.ticketId, - normalizeTicketSummaryCompatibility({ - ticket: { - ticketId: input.event.ticketId, - title, - body, - status: "open", - createdAt: input.event.tsIso, - updatedAt: input.event.tsIso, - dependsOn, - blocks, - owner, - source, - ...(assignee ? { assignee } : {}), - tags, - ...(externalSystem ? { externalSystem } : {}), - ...(externalId ? { externalId } : {}), - ...(externalKey ? { externalKey } : {}), - ...(externalUrl ? { externalUrl } : {}), - ...(externalProjectId ? { externalProjectId } : {}), - ...(externalProjectName ? { externalProjectName } : {}), - ...(externalTeamId ? { externalTeamId } : {}), - ...(input.event.projectId ? { projectId: input.event.projectId } : {}), - ...(input.event.projectName - ? { projectName: input.event.projectName } - : {}), - }, - }) - ); - - if (body) { - appendMapValue({ - map: input.documentsByTicket, - key: input.event.ticketId, - value: buildLegacyDescriptionDocument({ - eventId: input.event.eventId, - ticketId: input.event.ticketId, - content: body, - createdAt: input.event.tsIso, - updatedAt: input.event.tsIso, - }), - }); - } -} - -function applyTicketStatusChangedEvent(input: { - readonly tickets: Map<string, TicketSummary>; - readonly event: TicketEvent; -}): void { - const current = input.tickets.get(input.event.ticketId); - if (!current) { - return; - } - - const status = parseTicketStatus({ value: input.event.payload.status }); - if (!status) { - return; - } - - input.tickets.set( - input.event.ticketId, - normalizeTicketSummaryCompatibility({ - ticket: { - ...current, - status, - updatedAt: input.event.tsIso, - }, - }) - ); -} - -function applyTicketUpdatedEvent(input: { - readonly tickets: Map<string, TicketSummary>; - readonly documentsByTicket: Map<string, TicketDocument[]>; - readonly event: TicketEvent; -}): void { - const current = input.tickets.get(input.event.ticketId); - if (!current) { - return; - } - - const title = - typeof input.event.payload.title === "string" - ? input.event.payload.title - : undefined; - const body = - typeof input.event.payload.body === "string" - ? input.event.payload.body - : undefined; - const dependsOn = readDependencyUpdate({ - payload: input.event.payload, - key: "dependsOn", - }); - const blocks = readDependencyUpdate({ - payload: input.event.payload, - key: "blocks", - }); - const owner = readOptionalStringUpdate({ - payload: input.event.payload, - key: "owner", - }); - const source = readOptionalStringUpdate({ - payload: input.event.payload, - key: "source", - }); - const assignee = readOptionalStringUpdate({ - payload: input.event.payload, - key: "assignee", - }); - const tags = readTagsUpdate({ - payload: input.event.payload, - key: "tags", - }); - const externalSystem = readOptionalStringUpdate({ - payload: input.event.payload, - key: "externalSystem", - }); - const externalId = readOptionalStringUpdate({ - payload: input.event.payload, - key: "externalId", - }); - const externalKey = readOptionalStringUpdate({ - payload: input.event.payload, - key: "externalKey", - }); - const externalUrl = readOptionalStringUpdate({ - payload: input.event.payload, - key: "externalUrl", - }); - const externalProjectId = readOptionalStringUpdate({ - payload: input.event.payload, - key: "externalProjectId", - }); - const externalProjectName = readOptionalStringUpdate({ - payload: input.event.payload, - key: "externalProjectName", - }); - const externalTeamId = readOptionalStringUpdate({ - payload: input.event.payload, - key: "externalTeamId", - }); - const next = { - ...current, - updatedAt: input.event.tsIso, - }; - - applyUpdateValue(title, (value) => { - next.title = value; - }); - applyUpdateValue(body, (value) => { - next.body = value; - }); - applyUpdateValue(dependsOn, (value) => { - next.dependsOn = value; - }); - applyUpdateValue(blocks, (value) => { - next.blocks = value; - }); - applyOptionalMetadataUpdate(owner, (value) => { - next.owner = normalizeOwnerOrSource({ - value, - fallback: current.owner, - }); - }); - applyOptionalMetadataUpdate(source, (value) => { - next.source = normalizeOwnerOrSource({ - value, - fallback: current.source, - }); - }); - applyOptionalMetadataUpdate(assignee, (value) => { - next.assignee = value; - }); - applyUpdateValue(tags, (value) => { - next.tags = value; - }); - applyOptionalMetadataUpdate(externalSystem, (value) => { - next.externalSystem = value; - }); - applyOptionalMetadataUpdate(externalId, (value) => { - next.externalId = value; - }); - applyOptionalMetadataUpdate(externalKey, (value) => { - next.externalKey = value; - }); - applyOptionalMetadataUpdate(externalUrl, (value) => { - next.externalUrl = value; - }); - applyOptionalMetadataUpdate(externalProjectId, (value) => { - next.externalProjectId = value; - }); - applyOptionalMetadataUpdate(externalProjectName, (value) => { - next.externalProjectName = value; - }); - applyOptionalMetadataUpdate(externalTeamId, (value) => { - next.externalTeamId = value; - }); - - input.tickets.set( - input.event.ticketId, - normalizeTicketSummaryCompatibility({ - ticket: next, - }) - ); - - if (body !== undefined) { - appendMapValue({ - map: input.documentsByTicket, - key: input.event.ticketId, - value: buildLegacyDescriptionDocument({ - eventId: input.event.eventId, - ticketId: input.event.ticketId, - content: body, - createdAt: input.event.tsIso, - updatedAt: input.event.tsIso, - }), - }); - } -} - -function applyTicketDocumentRecordedEvent(input: { - readonly tickets: Map<string, TicketSummary>; - readonly documentsByTicket: Map<string, TicketDocument[]>; - readonly event: TicketEvent; -}): void { - const current = input.tickets.get(input.event.ticketId); - if (!current) { - return; - } - - const kindValue = readOptionalStringPayload({ - value: input.event.payload.kind, - }); - const content = readOptionalTextPayload({ - value: input.event.payload.content, - }); - if (!(kindValue && content && isTicketDocumentKind(kindValue))) { - return; - } - - const roleValue = readOptionalStringPayload({ - value: input.event.payload.role, - }); - const role = - roleValue && isTicketDocumentRole(roleValue) ? roleValue : undefined; - - const document = buildTicketDocument({ - documentId: - readOptionalStringPayload({ value: input.event.payload.documentId }) ?? - input.event.eventId, - ticketId: input.event.ticketId, - kind: kindValue, - ...(role ? { role } : {}), - content, - createdAt: input.event.tsIso, - updatedAt: input.event.tsIso, - }); - - appendMapValue({ - map: input.documentsByTicket, - key: input.event.ticketId, - value: document, - }); - - const documents = input.documentsByTicket.get(input.event.ticketId) ?? []; - const activeDescription = getActiveTicketDescription({ documents }); - input.tickets.set( - input.event.ticketId, - normalizeTicketSummaryCompatibility({ - ticket: { - ...current, - ...(activeDescription ? { body: activeDescription.content } : {}), - updatedAt: input.event.tsIso, - }, - }) - ); -} - -function applyTicketCommentAppendedEvent(input: { - readonly tickets: Map<string, TicketSummary>; - readonly commentsByTicket: Map<string, TicketComment[]>; - readonly event: TicketEvent; -}): void { - if (!input.tickets.has(input.event.ticketId)) { - return; - } - - const body = - readOptionalTextPayload({ - value: input.event.payload.body, - }) ?? - readOptionalTextPayload({ - value: input.event.payload.markdown, - }); - if (!body) { - return; - } - - const source = normalizeOwnerOrSource({ - value: readOptionalStringPayload({ value: input.event.payload.source }), - fallback: "hack", - }); - const externalId = readOptionalStringPayload({ - value: input.event.payload.externalId, - }); - const externalUrl = readOptionalStringPayload({ - value: input.event.payload.externalUrl, - }); - - appendMapValue({ - map: input.commentsByTicket, - key: input.event.ticketId, - value: { - commentId: - readOptionalStringPayload({ value: input.event.payload.commentId }) ?? - input.event.eventId, - ticketId: input.event.ticketId, - body, - source, - actor: input.event.actor, - createdAt: input.event.tsIso, - ...(externalId ? { externalId } : {}), - ...(externalUrl ? { externalUrl } : {}), - }, - }); -} - -function applyTicketCommentLinkedEvent(input: { - readonly commentsByTicket: Map<string, TicketComment[]>; - readonly event: TicketEvent; -}): void { - const commentId = readOptionalStringPayload({ - value: input.event.payload.commentId, - }); - const externalId = readOptionalStringPayload({ - value: input.event.payload.externalId, - }); - if (!(commentId && externalId)) { - return; - } - const comments = input.commentsByTicket.get(input.event.ticketId); - if (!comments) { - return; - } - const externalUrl = readOptionalStringPayload({ - value: input.event.payload.externalUrl, - }); - input.commentsByTicket.set( - input.event.ticketId, - comments.map((comment) => - comment.commentId === commentId - ? { - ...comment, - externalId, - ...(externalUrl ? { externalUrl } : {}), - } - : comment - ) - ); -} - -function applyTicketReviewNoteAppendedEvent(input: { - readonly tickets: Map<string, TicketSummary>; - readonly reviewNotesByTicket: Map<string, TicketReviewNote[]>; - readonly event: TicketEvent; -}): void { - if (!input.tickets.has(input.event.ticketId)) { - return; - } - - const body = - readOptionalTextPayload({ value: input.event.payload.body }) ?? - readOptionalTextPayload({ value: input.event.payload.markdown }); - if (!body) { - return; - } - - const context = readOptionalStringPayload({ - value: input.event.payload.context, - }); - - appendMapValue({ - map: input.reviewNotesByTicket, - key: input.event.ticketId, - value: { - noteId: - readOptionalStringPayload({ value: input.event.payload.noteId }) ?? - input.event.eventId, - ticketId: input.event.ticketId, - body, - actor: input.event.actor, - createdAt: input.event.tsIso, - ...(context ? { context } : {}), - }, - }); -} - -function applyTicketSyncCheckpointRecordedEvent(input: { - readonly tickets: Map<string, TicketSummary>; - readonly syncCheckpointsByTicket: Map<string, TicketSyncCheckpoint[]>; - readonly event: TicketEvent; -}): void { - if (!input.tickets.has(input.event.ticketId)) { - return; - } - - const provider = readOptionalStringPayload({ - value: input.event.payload.provider, - }); - if (!provider) { - return; - } - - const profileId = readOptionalStringPayload({ - value: input.event.payload.profileId, - }); - const direction = readOptionalStringPayload({ - value: input.event.payload.direction, - }); - const remoteCursor = readOptionalStringPayload({ - value: input.event.payload.remoteCursor, - }); - const remoteUpdatedAt = readOptionalStringPayload({ - value: input.event.payload.remoteUpdatedAt, - }); - const localUpdatedAt = readOptionalStringPayload({ - value: input.event.payload.localUpdatedAt, - }); - - appendMapValue({ - map: input.syncCheckpointsByTicket, - key: input.event.ticketId, - value: { - checkpointId: - readOptionalStringPayload({ - value: input.event.payload.checkpointId, - }) ?? input.event.eventId, - ticketId: input.event.ticketId, - provider, - ...(profileId ? { profileId } : {}), - ...(direction ? { direction } : {}), - ...(remoteCursor ? { remoteCursor } : {}), - ...(remoteUpdatedAt ? { remoteUpdatedAt } : {}), - ...(localUpdatedAt ? { localUpdatedAt } : {}), - actor: input.event.actor, - createdAt: input.event.tsIso, - }, - }); -} - -function applyTicketSyncConflictRecordedEvent(input: { - readonly tickets: Map<string, TicketSummary>; - readonly conflictsByTicket: Map<string, TicketSyncConflict[]>; - readonly event: TicketEvent; -}): void { - if (!input.tickets.has(input.event.ticketId)) { - return; - } - - const provider = readOptionalStringPayload({ - value: input.event.payload.provider, - }); - const field = readOptionalStringPayload({ value: input.event.payload.field }); - if (!(provider && field)) { - return; - } - - const authority = readOptionalStringPayload({ - value: input.event.payload.authority, - }); - const summary = readOptionalStringPayload({ - value: input.event.payload.summary, - }); - const localValue = parseTicketMetadataValue(input.event.payload.localValue); - const remoteValue = parseTicketMetadataValue(input.event.payload.remoteValue); - - appendMapValue({ - map: input.conflictsByTicket, - key: input.event.ticketId, - value: { - conflictId: - readOptionalStringPayload({ value: input.event.payload.conflictId }) ?? - input.event.eventId, - ticketId: input.event.ticketId, - provider, - field, - status: "open", - ...(authority ? { authority } : {}), - ...(summary ? { summary } : {}), - ...(localValue !== undefined ? { localValue } : {}), - ...(remoteValue !== undefined ? { remoteValue } : {}), - createdAt: input.event.tsIso, - updatedAt: input.event.tsIso, - }, - }); -} - -function applyTicketSyncConflictResolvedEvent(input: { - readonly conflictsByTicket: Map<string, TicketSyncConflict[]>; - readonly event: TicketEvent; -}): void { - const conflictId = readOptionalStringPayload({ - value: input.event.payload.conflictId, - }); - const resolution = parseTicketSyncConflictResolution({ - value: input.event.payload.resolution, - }); - if (!(conflictId && resolution)) { - return; - } - - const conflicts = input.conflictsByTicket.get(input.event.ticketId); - if (!conflicts) { - return; - } - - const summary = readOptionalStringPayload({ - value: input.event.payload.summary, - }); - input.conflictsByTicket.set( - input.event.ticketId, - conflicts.map((conflict) => - conflict.conflictId === conflictId - ? { - ...conflict, - status: "resolved" as const, - resolution, - ...(summary ? { resolutionSummary: summary } : {}), - resolvedAt: input.event.tsIso, - resolvedBy: input.event.actor, - updatedAt: input.event.tsIso, - } - : conflict - ) - ); -} - -function parseTicketStatus(input: { - readonly value: unknown; -}): TicketStatus | null { - if ( - input.value === "open" || - input.value === "in_progress" || - input.value === "blocked" || - input.value === "done" - ) { - return input.value; - } - return null; -} - -function parseDependencyList(input: { readonly value: unknown }): string[] { - if (!Array.isArray(input.value)) { - return []; - } - const values = input.value.filter( - (item): item is string => typeof item === "string" - ); - return normalizeTicketRefs(values); -} - -function parseTags(input: { readonly value: unknown }): string[] { - if (!Array.isArray(input.value)) { - return []; - } - const tags = input.value.filter( - (item): item is string => typeof item === "string" - ); - return normalizeTags(tags); -} - -function readDependencyUpdate(input: { - readonly payload: Record<string, unknown>; - readonly key: "dependsOn" | "blocks"; -}): string[] | null { - if (!Object.hasOwn(input.payload, input.key)) { - return null; - } - return parseDependencyList({ value: input.payload[input.key] }); -} - -function readTagsUpdate(input: { - readonly payload: Record<string, unknown>; - readonly key: "tags"; -}): string[] | null { - if (!Object.hasOwn(input.payload, input.key)) { - return null; - } - return parseTags({ value: input.payload[input.key] }); -} - -function readOptionalStringUpdate(input: { - readonly payload: Record<string, unknown>; - readonly key: - | "assignee" - | "owner" - | "source" - | "externalSystem" - | "externalId" - | "externalKey" - | "externalUrl" - | "externalProjectId" - | "externalProjectName" - | "externalTeamId"; -}): string | null { - if (!Object.hasOwn(input.payload, input.key)) { - return null; - } - return readOptionalStringPayload({ value: input.payload[input.key] }) ?? ""; -} - -function appendMapValue<T>(input: { - readonly map: Map<string, T[]>; - readonly key: string; - readonly value: T; -}): void { - const current = input.map.get(input.key) ?? []; - current.push(input.value); - input.map.set(input.key, current); -} - -function parseTicketMetadataValue( - value: unknown -): TicketMetadataValue | undefined { - if ( - value === null || - typeof value === "string" || - typeof value === "number" || - typeof value === "boolean" - ) { - return value; - } - - if (Array.isArray(value)) { - const out: TicketMetadataValue[] = []; - for (const item of value) { - const parsed = parseTicketMetadataValue(item); - if (parsed === undefined) { - return undefined; - } - out.push(parsed); - } - return out; - } - - if (isRecord(value)) { - const out: Record<string, TicketMetadataValue> = {}; - for (const [key, item] of Object.entries(value)) { - const parsed = parseTicketMetadataValue(item); - if (parsed === undefined) { - return undefined; - } - out[key] = parsed; - } - return out; - } - - return undefined; -} - -function parseTicketSyncConflictResolution(input: { - readonly value: unknown; -}): TicketSyncConflictResolution | null { - if ( - input.value === "accept_local" || - input.value === "accept_remote" || - input.value === "merged" || - input.value === "ignore" - ) { - return input.value; - } - return null; -} - -function applyDerivedBlocks( - tickets: Map<string, TicketSummary> -): Map<string, TicketSummary> { - const derived = new Map<string, Set<string>>(); - for (const ticket of tickets.values()) { - for (const dep of ticket.dependsOn) { - const set = derived.get(dep) ?? new Set<string>(); - set.add(ticket.ticketId); - derived.set(dep, set); - } - } - - for (const [ticketId, blockedBy] of derived) { - const current = tickets.get(ticketId); - if (!current) { - continue; - } - const merged = normalizeTicketRefs([...current.blocks, ...blockedBy]); - tickets.set(ticketId, { - ...current, - blocks: merged, - }); - } - - return tickets; -} - -function readOptionalStringPayload(input: { - readonly value: unknown; -}): string | undefined { - if (typeof input.value !== "string") { - return undefined; - } - const trimmed = input.value.trim(); - return trimmed.length > 0 ? trimmed : undefined; -} - -function readOptionalTextPayload(input: { - readonly value: unknown; -}): string | undefined { - if (typeof input.value !== "string") { - return undefined; - } - return input.value.trim().length > 0 ? input.value : undefined; -} - -function normalizeOwnerOrSource(input: { - readonly value: string | undefined; - readonly fallback: string; -}): string { - const trimmed = (input.value ?? "").trim(); - return trimmed || input.fallback; -} - -function normalizeOptionalMetadataString(input: { - readonly value: string | undefined; -}): string | undefined { - const trimmed = (input.value ?? "").trim(); - return trimmed.length > 0 ? trimmed : undefined; -} - -function normalizeTags(tags: readonly string[]): string[] { - const seen = new Set<string>(); - const normalized: string[] = []; - for (const tag of tags) { - const trimmed = tag.trim(); - if (!(trimmed && !seen.has(trimmed))) { - continue; - } - seen.add(trimmed); - normalized.push(trimmed); - } - normalized.sort((left, right) => left.localeCompare(right)); - return normalized; -} - -function safeJsonParse(text: string): unknown { - try { - return JSON.parse(text); - } catch { - return null; - } -} - -function normalizeTicketMetadata(input: { - readonly dependsOn?: readonly string[]; - readonly blocks?: readonly string[]; - readonly owner?: string; - readonly source?: string; - readonly assignee?: string; - readonly tags?: readonly string[]; - readonly externalSystem?: string; - readonly externalId?: string; - readonly externalKey?: string; - readonly externalUrl?: string; - readonly externalProjectId?: string; - readonly externalProjectName?: string; - readonly externalTeamId?: string; - readonly ownerFallback: string; - readonly sourceFallback: string; -}): NormalizedTicketMetadata { - const assignee = normalizeOptionalMetadataString({ - value: input.assignee, - }); - return { - dependsOn: normalizeTicketRefs(input.dependsOn ?? []), - blocks: normalizeTicketRefs(input.blocks ?? []), - owner: normalizeOwnerOrSource({ - value: input.owner, - fallback: input.ownerFallback, - }), - source: normalizeOwnerOrSource({ - value: input.source, - fallback: input.sourceFallback, - }), - ...(assignee ? { assignee } : {}), - tags: normalizeTags(input.tags ?? []), - ...readOptionalMetadataFields(input), - }; -} - -function readOptionalMetadataFields(input: { - readonly externalSystem?: string; - readonly externalId?: string; - readonly externalKey?: string; - readonly externalUrl?: string; - readonly externalProjectId?: string; - readonly externalProjectName?: string; - readonly externalTeamId?: string; -}): Partial<NormalizedTicketMetadata> { - const externalSystem = normalizeOptionalMetadataString({ - value: input.externalSystem, - }); - const externalId = normalizeOptionalMetadataString({ - value: input.externalId, - }); - const externalKey = normalizeOptionalMetadataString({ - value: input.externalKey, - }); - const externalUrl = normalizeOptionalMetadataString({ - value: input.externalUrl, - }); - const externalProjectId = normalizeOptionalMetadataString({ - value: input.externalProjectId, - }); - const externalProjectName = normalizeOptionalMetadataString({ - value: input.externalProjectName, - }); - const externalTeamId = normalizeOptionalMetadataString({ - value: input.externalTeamId, - }); - - return { - ...(externalSystem ? { externalSystem } : {}), - ...(externalId ? { externalId } : {}), - ...(externalKey ? { externalKey } : {}), - ...(externalUrl ? { externalUrl } : {}), - ...(externalProjectId ? { externalProjectId } : {}), - ...(externalProjectName ? { externalProjectName } : {}), - ...(externalTeamId ? { externalTeamId } : {}), - }; -} - -function buildTicketSummary(input: { - readonly ticketId: string; - readonly title: string; - readonly body?: string; - readonly status: TicketStatus; - readonly createdAt: string; - readonly updatedAt: string; - readonly metadata: NormalizedTicketMetadata; - readonly projectId?: string; - readonly projectName?: string; -}): TicketSummary { - return normalizeTicketSummaryCompatibility({ - ticket: { - ticketId: input.ticketId, - title: input.title, - ...(input.body ? { body: input.body } : {}), - status: input.status, - createdAt: input.createdAt, - updatedAt: input.updatedAt, - dependsOn: input.metadata.dependsOn, - blocks: input.metadata.blocks, - owner: input.metadata.owner, - source: input.metadata.source, - ...(input.metadata.assignee ? { assignee: input.metadata.assignee } : {}), - tags: input.metadata.tags, - ...(input.metadata.externalSystem - ? { externalSystem: input.metadata.externalSystem } - : {}), - ...(input.metadata.externalId - ? { externalId: input.metadata.externalId } - : {}), - ...(input.metadata.externalKey - ? { externalKey: input.metadata.externalKey } - : {}), - ...(input.metadata.externalUrl - ? { externalUrl: input.metadata.externalUrl } - : {}), - ...(input.metadata.externalProjectId - ? { externalProjectId: input.metadata.externalProjectId } - : {}), - ...(input.metadata.externalProjectName - ? { externalProjectName: input.metadata.externalProjectName } - : {}), - ...(input.metadata.externalTeamId - ? { externalTeamId: input.metadata.externalTeamId } - : {}), - ...(input.projectId ? { projectId: input.projectId } : {}), - ...(input.projectName ? { projectName: input.projectName } : {}), - }, - }); -} - -function appendTicketMetadataPayload(input: { - readonly payload: Record<string, unknown>; - readonly metadata: NormalizedTicketMetadata; -}): void { - if (input.metadata.dependsOn.length > 0) { - input.payload.dependsOn = input.metadata.dependsOn; - } - if (input.metadata.blocks.length > 0) { - input.payload.blocks = input.metadata.blocks; - } - input.payload.owner = input.metadata.owner; - input.payload.source = input.metadata.source; - if (input.metadata.assignee) { - input.payload.assignee = input.metadata.assignee; - } - if (input.metadata.tags.length > 0) { - input.payload.tags = input.metadata.tags; - } - appendOptionalStringFields({ - target: input.payload, - entries: [ - ["externalSystem", input.metadata.externalSystem], - ["externalId", input.metadata.externalId], - ["externalKey", input.metadata.externalKey], - ["externalUrl", input.metadata.externalUrl], - ["externalProjectId", input.metadata.externalProjectId], - ["externalProjectName", input.metadata.externalProjectName], - ["externalTeamId", input.metadata.externalTeamId], - ], - }); -} - -function appendOptionalStringFields(input: { - readonly target: Record<string, unknown>; - readonly entries: readonly (readonly [string, string | undefined])[]; -}): void { - for (const [key, value] of input.entries) { - if (value) { - input.target[key] = value; - } - } -} - -function buildTicketUpdatePayload(input: { - readonly title?: string; - readonly body?: string; - readonly dependsOn?: readonly string[]; - readonly blocks?: readonly string[]; - readonly owner?: string; - readonly source?: string; - readonly assignee?: string; - readonly tags?: readonly string[]; - readonly externalSystem?: string; - readonly externalId?: string; - readonly externalKey?: string; - readonly externalUrl?: string; - readonly externalProjectId?: string; - readonly externalProjectName?: string; - readonly externalTeamId?: string; -}): TicketUpdatePayloadResult { - const payload: Record<string, unknown> = {}; - let documentContent: string | undefined; - - if (input.title !== undefined) { - const title = input.title.trim(); - if (!title) { - return { ok: false, error: "Title cannot be empty." }; - } - payload.title = title; - } - - if (input.body !== undefined) { - documentContent = input.body.trimEnd(); - if (!documentContent) { - return { ok: false, error: "Body cannot be empty." }; - } - } - - appendUpdateField(payload, "dependsOn", () => - input.dependsOn === undefined - ? undefined - : normalizeTicketRefs(input.dependsOn) - ); - appendUpdateField(payload, "blocks", () => - input.blocks === undefined ? undefined : normalizeTicketRefs(input.blocks) - ); - appendUpdateField(payload, "owner", () => - input.owner === undefined - ? undefined - : normalizeOwnerOrSource({ - value: input.owner, - fallback: "hack", - }) - ); - appendUpdateField(payload, "source", () => - input.source === undefined - ? undefined - : normalizeOwnerOrSource({ - value: input.source, - fallback: "hack", - }) - ); - appendUpdateField(payload, "assignee", () => - input.assignee === undefined - ? undefined - : (normalizeOptionalMetadataString({ - value: input.assignee, - }) ?? null) - ); - appendUpdateField(payload, "tags", () => - input.tags === undefined ? undefined : normalizeTags(input.tags) - ); - - for (const key of [ - "externalSystem", - "externalId", - "externalKey", - "externalUrl", - "externalProjectId", - "externalProjectName", - "externalTeamId", - ] as const) { - appendUpdateField(payload, key, () => { - const value = input[key]; - if (value === undefined) { - return undefined; - } - return normalizeOptionalMetadataString({ - value, - }); - }); - } - - return { ok: true, payload, ...(documentContent ? { documentContent } : {}) }; -} - -function filterTicketUpdatePayloadAgainstCurrent(input: { - readonly current: TicketSummary; - readonly payload: Record<string, unknown>; - readonly documentContent?: string; -}): { - readonly payload: Record<string, unknown>; - readonly documentContent?: string; -} { - const currentRecord = input.current as Record<string, unknown>; - const payload = Object.fromEntries( - Object.entries(input.payload).filter(([key, value]) => { - const currentValue = currentRecord[key]; - return ( - JSON.stringify(currentValue ?? null) !== JSON.stringify(value ?? null) - ); - }) - ); - const currentBody = - typeof input.current.body === "string" - ? input.current.body.trimEnd() - : undefined; - const documentContent = - input.documentContent !== undefined && currentBody === input.documentContent - ? undefined - : input.documentContent; - - return { - payload, - ...(documentContent ? { documentContent } : {}), - }; -} - -function appendUpdateField( - payload: Record<string, unknown>, - key: string, - resolveValue: () => unknown -): void { - const value = resolveValue(); - if (value !== undefined) { - payload[key] = value; - } -} - -function readStringField(value: unknown, fallback = ""): string { - return typeof value === "string" ? value : fallback; -} - -function readOptionalStringField(value: unknown): string | undefined { - return typeof value === "string" ? value : undefined; -} - -function applyUpdateValue<T>( - value: T | null | undefined, - apply: (value: T) => void -): void { - if (value !== null && value !== undefined) { - apply(value); - } -} - -function applyOptionalMetadataUpdate( - value: string | null, - apply: (value: string | undefined) => void -): void { - if (value !== null) { - apply(value || undefined); - } -} - -function buildDocumentRecordedEvent(input: { - readonly buildEvent: (input: { - readonly ticketId: string; - readonly type: string; - readonly payload: Record<string, unknown>; - readonly actor?: string; - readonly occurredAt?: string; - readonly sourceSystem?: string; - readonly sourceOperation?: string; - readonly idempotencyKey?: string; - readonly causationId?: string; - readonly correlationId?: string; - }) => TicketEvent; - readonly ticketId: string; - readonly actor?: string; - readonly documentId: string; - readonly kind: TicketDocumentKind; - readonly role?: TicketDocumentRole; - readonly content: string; -}): TicketEvent { - return input.buildEvent({ - ticketId: input.ticketId, - type: "ticket.document_recorded", - payload: { - documentId: input.documentId, - kind: input.kind, - ...(input.role ? { role: input.role } : {}), - content: input.content, - }, - actor: input.actor, - }); -} - -function buildSyncCheckpointResult(input: { - readonly ticketId: string; - readonly provider: string; - readonly profileId?: string; - readonly direction?: string; - readonly remoteCursor?: string; - readonly remoteUpdatedAt?: string; - readonly localUpdatedAt?: string; -}): SyncCheckpointBuildResult { - const provider = normalizeOptionalMetadataString({ - value: input.provider, - }); - if (!provider) { - return { ok: false, error: "Provider is required." }; - } - - const checkpointId = randomUUID(); - const profileId = normalizeOptionalMetadataString({ - value: input.profileId, - }); - const direction = normalizeOptionalMetadataString({ - value: input.direction, - }); - const remoteCursor = normalizeOptionalMetadataString({ - value: input.remoteCursor, - }); - const remoteUpdatedAt = normalizeOptionalMetadataString({ - value: input.remoteUpdatedAt, - }); - const localUpdatedAt = normalizeOptionalMetadataString({ - value: input.localUpdatedAt, - }); - const payload: Record<string, unknown> = { - checkpointId, - provider, - }; - appendOptionalStringFields({ - target: payload, - entries: [ - ["profileId", profileId], - ["direction", direction], - ["remoteCursor", remoteCursor], - ["remoteUpdatedAt", remoteUpdatedAt], - ["localUpdatedAt", localUpdatedAt], - ], - }); - - return { - ok: true, - checkpointId, - provider, - payload, - checkpoint: { - checkpointId, - ticketId: input.ticketId, - provider, - ...(profileId ? { profileId } : {}), - ...(direction ? { direction } : {}), - ...(remoteCursor ? { remoteCursor } : {}), - ...(remoteUpdatedAt ? { remoteUpdatedAt } : {}), - ...(localUpdatedAt ? { localUpdatedAt } : {}), - actor: "", - createdAt: "", - }, - }; -} - -function buildSyncConflictResult(input: { - readonly ticketId: string; - readonly provider: string; - readonly field: string; - readonly authority?: string; - readonly summary?: string; - readonly localValue?: TicketMetadataValue; - readonly remoteValue?: TicketMetadataValue; -}): SyncConflictBuildResult { - const provider = normalizeOptionalMetadataString({ - value: input.provider, - }); - if (!provider) { - return { ok: false, error: "Provider is required." }; - } - const field = normalizeOptionalMetadataString({ - value: input.field, - }); - if (!field) { - return { ok: false, error: "Field is required." }; - } - - const conflictId = randomUUID(); - const authority = normalizeOptionalMetadataString({ - value: input.authority, - }); - const summary = normalizeOptionalMetadataString({ - value: input.summary, - }); - const payload: Record<string, unknown> = { - conflictId, - provider, - field, - }; - appendOptionalStringFields({ - target: payload, - entries: [ - ["authority", authority], - ["summary", summary], - ], - }); - if (input.localValue !== undefined) { - payload.localValue = input.localValue; - } - if (input.remoteValue !== undefined) { - payload.remoteValue = input.remoteValue; - } - - return { - ok: true, - conflictId, - provider, - field, - payload, - conflict: { - conflictId, - ticketId: input.ticketId, - provider, - field, - status: "open", - ...(authority ? { authority } : {}), - ...(summary ? { summary } : {}), - ...(input.localValue !== undefined - ? { localValue: input.localValue } - : {}), - ...(input.remoteValue !== undefined - ? { remoteValue: input.remoteValue } - : {}), - }, - }; -} - -function parseEvent(value: unknown): TicketEvent | null { - if (!isRecord(value)) { - return null; - } - const eventId = readStringField(value.eventId); - const schemaVersion = - typeof value.schemaVersion === "number" ? value.schemaVersion : 0; - const ts = typeof value.ts === "number" ? value.ts : Number.NaN; - const tsIso = new Date(ts * 1000).toISOString(); - const actor = readStringField(value.actor); - const eventTypeCandidate = readOptionalStringField(value.eventType); - const typeCandidate = readOptionalStringField(value.type); - const eventType = eventTypeCandidate ?? typeCandidate ?? ""; - const occurredAt = readOptionalStringField(value.occurredAt) ?? tsIso; - const recordedAt = readOptionalStringField(value.recordedAt) ?? tsIso; - const orderKey = readOptionalStringField(value.orderKey); - const sourceSystem = readOptionalStringField(value.sourceSystem) ?? "hack"; - const sourceOperation = - readOptionalStringField(value.sourceOperation) ?? eventType; - const idempotencyKey = - readOptionalStringField(value.idempotencyKey) ?? eventId; - const causationId = readOptionalStringField(value.causationId); - const correlationId = readOptionalStringField(value.correlationId); - const ticketId = readStringField(value.ticketId); - const type = typeCandidate ?? eventType; - const payload = isRecord(value.payload) - ? (value.payload as Record<string, unknown>) - : null; - - if ( - !(eventId && Number.isFinite(ts) && actor && ticketId && type && payload) - ) { - return null; - } - - const projectId = readOptionalStringField(value.projectId); - const projectName = readOptionalStringField(value.projectName); - - return { - eventId, - schemaVersion, - ts, - tsIso, - eventType, - occurredAt, - recordedAt, - actor, - sourceSystem, - sourceOperation, - idempotencyKey, - ...(causationId ? { causationId } : {}), - ...(correlationId ? { correlationId } : {}), - ...(orderKey ? { orderKey } : {}), - ...(projectId ? { projectId } : {}), - ...(projectName ? { projectName } : {}), - ticketId, - type, - payload, - }; -} diff --git a/src/control-plane/extensions/tickets/tickets-git-channel.ts b/src/control-plane/extensions/tickets/tickets-git-channel.ts deleted file mode 100644 index d36eb32e..00000000 --- a/src/control-plane/extensions/tickets/tickets-git-channel.ts +++ /dev/null @@ -1,2031 +0,0 @@ -// biome-ignore-all lint/complexity/noExcessiveCognitiveComplexity: Local tickets git mutation paths intentionally keep sparse checkout and legacy-ref recovery explicit in one module. -import { randomUUID } from "node:crypto"; -import { mkdir, open, readdir, rm, stat, unlink } from "node:fs/promises"; -import { dirname, resolve } from "node:path"; - -import { isRecord } from "../../../lib/guards.ts"; -import type { TicketsGitConfig, TicketsGitRefMode } from "../../sdk/config.ts"; -import { stableStringify } from "./util.ts"; - -const REFS_HEADS_PREFIX_PATTERN = /^refs\/heads\//; -const REFS_PREFIX_PATTERN = /^refs\//; -const DEFAULT_MUTATION_LOCK_HEARTBEAT_MS = 1000; -const DEFAULT_MUTATION_LOCK_RETRY_MS = 100; -const DEFAULT_MUTATION_LOCK_STALE_MS = 30_000; -const DEFAULT_MUTATION_LOCK_TIMEOUT_MS = 30_000; -const DEFAULT_REMOTE_GIT_TIMEOUT_MS = 15_000; -const DEFAULT_REMOTE_SSH_COMMAND = "ssh -oBatchMode=yes -oConnectTimeout=5"; -const MAX_PUSH_ATTEMPTS = 3; - -export type TicketsGitChannel = { - readonly ensureCheckedOut: (input?: { - readonly forceFreshCheckout?: boolean; - readonly refreshRemote?: boolean; - }) => Promise<string>; - readonly appendEvents: (input: { - readonly events: readonly Record<string, unknown>[]; - }) => Promise< - { readonly ok: true } | { readonly ok: false; readonly error: string } - >; - readonly appendPreparedEvents: <T>(input: { - readonly prepare: (root: string) => Promise< - | { - readonly ok: true; - readonly events: readonly Record<string, unknown>[]; - readonly result: T; - } - | { readonly ok: false; readonly error: string } - >; - }) => Promise< - | { readonly ok: true; readonly result: T } - | { readonly ok: false; readonly error: string } - >; - readonly inspect: () => Promise<TicketsGitInspectResult>; - readonly repair: (input: { - readonly pruneLegacyRef: boolean; - }) => Promise<TicketsGitRepairResult>; - readonly sync: () => Promise< - | { - readonly ok: true; - readonly branch: string; - readonly remote?: string; - readonly didCommit: boolean; - readonly didPush: boolean; - } - | { readonly ok: false; readonly error: string } - >; -}; - -export type TicketsGitHealth = { - readonly branch: string; - readonly refMode: TicketsGitRefMode; - readonly remote?: string; - readonly remoteRef: string; - readonly legacyRef?: string; - readonly hasLegacyRef: boolean; - readonly hasRefDivergence: boolean; - readonly remoteRefOid?: string; - readonly legacyRefOid?: string; - readonly hasNonTicketFiles: boolean; - readonly nonTicketPaths: readonly string[]; -}; - -export type TicketsGitInspectResult = - | { readonly ok: true; readonly health: TicketsGitHealth } - | { readonly ok: false; readonly error: string }; - -export type TicketsGitRepairResult = - | { - readonly ok: true; - readonly didCommit: boolean; - readonly didPush: boolean; - readonly didPruneLegacy: boolean; - readonly pruneError?: string; - } - | { readonly ok: false; readonly error: string }; - -type ParsedJournalEvent = { - readonly eventId: string; - readonly idempotencyKey: string; - readonly ts: number; - readonly value: Record<string, unknown>; -}; - -type PushAttemptResult = - | { readonly ok: true; readonly didPush: boolean } - | { - readonly ok: false; - readonly error: string; - readonly hiddenRefRejected?: boolean; - }; - -export function createGitTicketsChannel(opts: { - readonly projectRoot: string; - readonly config: TicketsGitConfig; - readonly logger: { - info: (input: { message: string }) => void; - warn: (input: { message: string }) => void; - }; - readonly testOverrides?: { - readonly beforePushAttempt?: (input: { - readonly attempt: number; - readonly pushRef: string; - }) => Promise<void>; - readonly mutationLockHeartbeatMs?: number; - readonly mutationLockRetryMs?: number; - readonly mutationLockStaleMs?: number; - readonly mutationLockTimeoutMs?: number; - readonly remoteGitTimeoutMs?: number; - }; -}): TicketsGitChannel { - const ticketsDir = resolve(opts.projectRoot, ".hack/tickets"); - const gitDir = resolve(ticketsDir, "git"); - const bareDir = resolve(gitDir, "bare.git"); - const worktreeDir = resolve(gitDir, "worktree"); - - const gitEnabled = opts.config.enabled; - const refMode: TicketsGitRefMode = opts.config.refMode ?? "hidden"; - const branch = normalizeBranchName(opts.config.branch || "hack/tickets"); - const remoteRef = buildRemoteRef({ branch, refMode }); - const legacyRemoteRef = - refMode === "hidden" ? buildRemoteRef({ branch, refMode: "heads" }) : null; - const localBranchRef = `refs/heads/${branch}`; - const trackingRef = `refs/remotes/origin/${branch}`; - const legacyTrackingRef = legacyRemoteRef - ? `refs/remotes/origin/__legacy__/${branch}` - : null; - const remoteName = gitEnabled ? (opts.config.remote ?? "origin").trim() : ""; - const bareIndexLockPath = resolve(bareDir, "index.lock"); - const mutationLockPath = resolve(gitDir, ".mutation.lock"); - const mutationLockHeartbeatMs = - opts.testOverrides?.mutationLockHeartbeatMs ?? - DEFAULT_MUTATION_LOCK_HEARTBEAT_MS; - const mutationLockRetryMs = - opts.testOverrides?.mutationLockRetryMs ?? DEFAULT_MUTATION_LOCK_RETRY_MS; - const mutationLockStaleMs = - opts.testOverrides?.mutationLockStaleMs ?? DEFAULT_MUTATION_LOCK_STALE_MS; - const mutationLockTimeoutMs = - opts.testOverrides?.mutationLockTimeoutMs ?? - DEFAULT_MUTATION_LOCK_TIMEOUT_MS; - const remoteGitTimeoutMs = - opts.testOverrides?.remoteGitTimeoutMs ?? DEFAULT_REMOTE_GIT_TIMEOUT_MS; - let inProcessWorktreeAccess: Promise<void> = Promise.resolve(); - - const resolvePushRefForCheckout = (input: { - readonly checkoutRef: string; - }): string => - resolvePushRefForCheckoutRef({ - checkoutRef: input.checkoutRef, - remoteRef, - legacyTrackingRef, - legacyRemoteRef, - }); - - const runGitDir = async (input: { - readonly args: readonly string[]; - readonly remote?: boolean; - readonly timeoutMs?: number; - }): Promise<{ - readonly ok: boolean; - readonly stdout: string; - readonly stderr: string; - }> => { - for (let attempt = 0; attempt < 5; attempt += 1) { - const result = await runGit({ - cwd: opts.projectRoot, - args: [ - `--git-dir=${bareDir}`, - `--work-tree=${worktreeDir}`, - ...input.args, - ], - remote: input.remote, - timeoutMs: input.timeoutMs, - }); - if (result.ok) { - return result; - } - - const message = `${result.stderr}\n${result.stdout}`.trim(); - if (!isGitIndexLockError(message)) { - return result; - } - - if (attempt < 3) { - await Bun.sleep(150 * (attempt + 1)); - continue; - } - - opts.logger.warn({ - message: `tickets git index.lock blocked ${input.args.join(" ")}; removing stale lock and retrying`, - }); - try { - await rm(bareIndexLockPath, { force: true }); - } catch { - // Best-effort stale lock cleanup; retry will surface a real error if it still exists. - } - } - - return await runGit({ - cwd: opts.projectRoot, - args: [ - `--git-dir=${bareDir}`, - `--work-tree=${worktreeDir}`, - ...input.args, - ], - remote: input.remote, - timeoutMs: input.timeoutMs, - }); - }; - - const resolveRemoteUrl = async (): Promise<string | null> => { - if (!(gitEnabled && remoteName)) { - return null; - } - const result = await runGit({ - cwd: opts.projectRoot, - args: ["remote", "get-url", remoteName], - }); - if (!result.ok) { - return null; - } - const url = result.stdout.trim(); - return url.length > 0 ? url : null; - }; - - const ensureDirs = async () => { - await mkdir(gitDir, { recursive: true }); - await mkdir(worktreeDir, { recursive: true }); - }; - - type MutationLockHandle = { - readonly ownerToken: string; - readonly heartbeatTimer: ReturnType<typeof setInterval>; - }; - - const withWorktreeAccess = async <T>(fn: () => Promise<T>): Promise<T> => { - const prior = inProcessWorktreeAccess.catch(() => undefined); - let release!: () => void; - inProcessWorktreeAccess = new Promise<void>((resolve) => { - release = resolve; - }); - await prior; - try { - return await fn(); - } finally { - release(); - } - }; - - const withMutationLock = async <T>(fn: () => Promise<T>): Promise<T> => { - return await withWorktreeAccess(async () => { - const lockHandle = await acquireMutationLock(); - try { - return await fn(); - } finally { - await releaseMutationLock(lockHandle); - } - }); - }; - - const ensureCheckedOutUnlocked = async (input?: { - readonly forceFreshCheckout?: boolean; - readonly refreshRemote?: boolean; - }): Promise< - | { - readonly ok: true; - readonly remoteUrl: string | null; - readonly pushRef: string; - } - | { readonly ok: false; readonly error: string } - > => { - await ensureDirs(); - await ensureBareRepo(); - await ensureSparseCheckout(); - const refreshRemote = input?.refreshRemote !== false; - const remote = await ensureRemote(); - if (refreshRemote) { - const refreshed = await refreshRemoteTrackingRefs({ - remoteUrl: remote.remoteUrl, - }); - if (!refreshed.ok) { - return refreshed; - } - } - - const reusableCheckout = await resolveReusableCheckout({ - forceFreshCheckout: input?.forceFreshCheckout === true, - remoteUrl: remote.remoteUrl, - }); - if (reusableCheckout) { - return reusableCheckout; - } - - const hasLocalBranch = await hasLocalTicketsBranch(); - const checkoutRemoteUrl = - refreshRemote || !hasLocalBranch ? remote.remoteUrl : null; - - const checkedOut = await checkoutHead({ - allowRemoteFetchFailureFallback: !refreshRemote && hasLocalBranch, - remoteUrl: checkoutRemoteUrl, - }); - if (!checkedOut.ok) { - return checkedOut; - } - - const migratedLegacy = await mergeLegacyRefIntoCurrentBranch({ - remoteUrl: checkoutRemoteUrl, - }); - if (!migratedLegacy.ok) { - return migratedLegacy; - } - - return { - ok: true, - remoteUrl: remote.remoteUrl, - pushRef: checkedOut.pushRef, - }; - }; - - const ensureCheckedOut = async (input?: { - readonly forceFreshCheckout?: boolean; - readonly refreshRemote?: boolean; - }): Promise< - | { - readonly ok: true; - readonly remoteUrl: string | null; - readonly pushRef: string; - } - | { readonly ok: false; readonly error: string } - > => { - return await withWorktreeAccess(async () => { - return await ensureCheckedOutUnlocked(input); - }); - }; - - const readMutationLockOwner = async (): Promise<string | null> => { - const ownerToken = ( - await Bun.file(mutationLockPath) - .text() - .catch(() => "") - ) - .split("\n")[0] - ?.trim(); - return ownerToken ? ownerToken : null; - }; - - const writeMutationLockOwner = async (ownerToken: string): Promise<void> => { - await Bun.write(mutationLockPath, `${ownerToken}\n`); - }; - - const refreshMutationLock = async ( - input: Pick<MutationLockHandle, "ownerToken"> - ): Promise<void> => { - if ((await readMutationLockOwner()) !== input.ownerToken) { - return; - } - await writeMutationLockOwner(input.ownerToken); - }; - - const isMutationLockStale = async (): Promise<boolean> => { - try { - const info = await stat(mutationLockPath); - return Date.now() - info.mtimeMs > mutationLockStaleMs; - } catch { - return false; - } - }; - - const clearStaleMutationLock = async (): Promise<void> => { - const ownerToken = await readMutationLockOwner(); - if (!ownerToken) { - await unlink(mutationLockPath).catch(() => undefined); - return; - } - if (!(await isMutationLockStale())) { - return; - } - if ((await readMutationLockOwner()) !== ownerToken) { - return; - } - await unlink(mutationLockPath).catch(() => undefined); - }; - - const startMutationLockHeartbeat = ( - ownerToken: string - ): MutationLockHandle["heartbeatTimer"] => { - const heartbeatTimer = setInterval(() => { - void refreshMutationLock({ ownerToken }); - }, mutationLockHeartbeatMs); - heartbeatTimer.unref?.(); - return heartbeatTimer; - }; - - const tryAcquireMutationLock = - async (): Promise<MutationLockHandle | null> => { - const ownerToken = randomUUID(); - try { - const file = await open(mutationLockPath, "wx"); - await file.writeFile(`${ownerToken}\n`); - await file.close(); - return { - ownerToken, - heartbeatTimer: startMutationLockHeartbeat(ownerToken), - }; - } catch (error: unknown) { - const code = - typeof error === "object" && error !== null && "code" in error - ? (error as { code?: string }).code - : undefined; - if (code === "EEXIST") { - return null; - } - throw error; - } - }; - - const acquireMutationLock = async (): Promise<MutationLockHandle> => { - await mkdir(dirname(mutationLockPath), { recursive: true }); - const start = Date.now(); - - while (true) { - const lockHandle = await tryAcquireMutationLock(); - if (lockHandle) { - return lockHandle; - } - if (await isMutationLockStale()) { - await clearStaleMutationLock(); - continue; - } - if (Date.now() - start > mutationLockTimeoutMs) { - throw new Error("Timed out waiting for tickets git mutation lock"); - } - await Bun.sleep(mutationLockRetryMs); - } - }; - - const releaseMutationLock = async ( - input: MutationLockHandle - ): Promise<void> => { - clearInterval(input.heartbeatTimer); - if ((await readMutationLockOwner()) !== input.ownerToken) { - return; - } - await unlink(mutationLockPath).catch(() => undefined); - }; - - const ensureBareRepo = async () => { - try { - const st = await stat(bareDir); - if (st.isDirectory()) { - return; - } - } catch { - // missing, create - } - - await mkdir(dirname(bareDir), { recursive: true }); - - // Important: do NOT `clone --bare` the project. - // This channel is intended to store *only* `.hack/tickets/**` on a dedicated ref. - // Cloning the full project makes the tickets repo enormous and causes commits/pushes - // to include unrelated workspace files. - const init = await runGit({ - cwd: opts.projectRoot, - args: ["init", "--bare", bareDir], - }); - if (!init.ok) { - throw new Error( - `Failed to init bare repo: ${init.stderr.trim() || init.stdout.trim()}` - ); - } - }; - - const ensureSparseCheckout = async (): Promise<void> => { - // Kept for backward compatibility if the repo ever gains extra paths. - await mkdir(resolve(bareDir, "info"), { recursive: true }); - await Bun.write( - resolve(bareDir, "info/sparse-checkout"), - ".hack/tickets\n" - ); - await runGitDir({ args: ["config", "core.sparseCheckout", "true"] }); - }; - - const ensureRemote = async (): Promise<{ - readonly remoteUrl: string | null; - }> => { - if (!(gitEnabled && remoteName)) { - return { remoteUrl: null }; - } - - const remoteUrl = await resolveRemoteUrl(); - if (!remoteUrl) { - // biome-ignore lint/suspicious/noEmptyBlockStatements: best-effort cleanup - await runGitDir({ args: ["remote", "remove", "origin"] }).catch(() => {}); - return { remoteUrl: null }; - } - - const set = await runGitDir({ - args: ["remote", "set-url", "origin", remoteUrl], - }); - if (!set.ok) { - await runGitDir({ args: ["remote", "add", "origin", remoteUrl] }).catch( - // biome-ignore lint/suspicious/noEmptyBlockStatements: best-effort remote setup - () => {} - ); - } - - return { remoteUrl }; - }; - - const hasPendingWorktreeChanges = async (): Promise<boolean> => { - const currentBranch = await runGitDir({ - args: ["branch", "--show-current"], - }); - if (!currentBranch.ok) { - return false; - } - if (currentBranch.stdout.trim() !== branch) { - return false; - } - - const status = await runGitDir({ - args: ["status", "--short"], - }); - return status.ok && status.stdout.trim().length > 0; - }; - - const resolvePreferredTrackingRef = async (): Promise<string | null> => { - const tracking = await runGitDir({ - args: ["rev-parse", "--verify", trackingRef], - }); - if (tracking.ok) { - return trackingRef; - } - - if (legacyTrackingRef) { - const legacy = await runGitDir({ - args: ["rev-parse", "--verify", legacyTrackingRef], - }); - if (legacy.ok) { - return legacyTrackingRef; - } - } - - return null; - }; - - const hasLocalTicketsBranch = async (): Promise<boolean> => { - const localBranch = await runGitDir({ - args: ["rev-parse", "--verify", localBranchRef], - }); - return localBranch.ok; - }; - - const refreshRemoteTrackingRefs = async (input: { - readonly remoteUrl: string | null; - }): Promise< - { readonly ok: true } | { readonly ok: false; readonly error: string } - > => { - if (!input.remoteUrl) { - return { ok: true }; - } - - const hiddenFetch = await fetchRemoteRef(remoteRef); - if (!(hiddenFetch.ok || hiddenFetch.missing)) { - return { ok: false, error: `git fetch failed: ${hiddenFetch.error}` }; - } - - if (legacyRemoteRef && legacyTrackingRef) { - const legacyFetch = await fetchRemoteRefToTracking( - legacyRemoteRef, - legacyTrackingRef - ); - if (!(legacyFetch.ok || legacyFetch.missing)) { - return { ok: false, error: `git fetch failed: ${legacyFetch.error}` }; - } - } - - return { ok: true }; - }; - - const hasAheadLocalBranchCommits = async (input: { - readonly trackingRef: string | null; - }): Promise<boolean> => { - if (!input.trackingRef) { - return false; - } - - const currentBranch = await runGitDir({ - args: ["branch", "--show-current"], - }); - if (!currentBranch.ok || currentBranch.stdout.trim() !== branch) { - return false; - } - - const ahead = await runGitDir({ - args: ["rev-list", "--count", `${input.trackingRef}..${branch}`], - }); - if (!ahead.ok) { - return false; - } - - return Number.parseInt(ahead.stdout.trim(), 10) > 0; - }; - - const fetchRemoteRefToTracking = async ( - ref: string, - destinationRef: string - ): Promise< - | { readonly ok: true } - | { readonly ok: false; readonly error: string; readonly missing: boolean } - > => { - const fetchArgs = [ - "fetch", - "--prune", - "--refmap=", - "origin", - `+${ref}:${destinationRef}`, - ] as const; - let fetched = await runGitDir({ - args: fetchArgs, - remote: true, - timeoutMs: remoteGitTimeoutMs, - }); - if (!fetched.ok) { - const retryableLockFailure = isRetryableTrackingRefLockFailure({ - stderr: fetched.stderr, - trackingRef: destinationRef, - }); - if (retryableLockFailure) { - const deletedTrackingRef = await runGitDir({ - args: ["update-ref", "-d", destinationRef], - }); - void deletedTrackingRef; - fetched = await runGitDir({ - args: fetchArgs, - remote: true, - timeoutMs: remoteGitTimeoutMs, - }); - } - } - if (fetched.ok) { - return { ok: true }; - } - const message = `${fetched.stderr}\n${fetched.stdout}`.trim(); - if (isMissingRemoteRef(message)) { - return { ok: false, error: message, missing: true }; - } - return { - ok: false, - error: formatTicketsGitRemoteError({ - message, - operation: "fetch", - }), - missing: false, - }; - }; - - const fetchRemoteRef = async ( - ref: string - ): Promise< - | { readonly ok: true } - | { readonly ok: false; readonly error: string; readonly missing: boolean } - > => { - return await fetchRemoteRefToTracking(ref, trackingRef); - }; - - const buildHiddenRefPushError = (message: string): PushAttemptResult => { - return { - ok: false, - error: `git push failed: ${message}\nRemote rejected hidden refs. Set controlPlane.tickets.git.refMode to "heads" to use a branch ref.`, - hiddenRefRejected: true, - }; - }; - - const pushCurrentBranch = async (input: { - readonly pushRef: string; - readonly attempt: number; - }): Promise<PushAttemptResult> => { - if (opts.testOverrides?.beforePushAttempt) { - await opts.testOverrides.beforePushAttempt({ - attempt: input.attempt, - pushRef: input.pushRef, - }); - } - const push = await runGitDir({ - args: ["push", "origin", `${localBranchRef}:${input.pushRef}`], - remote: true, - timeoutMs: remoteGitTimeoutMs, - }); - if (push.ok) { - return { ok: true, didPush: true }; - } - - const message = `${push.stderr}\n${push.stdout}`.trim(); - if (refMode === "hidden" && isHiddenRefRejected(message)) { - return buildHiddenRefPushError(message); - } - return { ok: false, error: `git push failed: ${message}` }; - }; - - const rewritePendingEventsAfterCheckout = async (input: { - readonly pendingEvents?: readonly Record<string, unknown>[]; - }): Promise< - { readonly ok: true } | { readonly ok: false; readonly error: string } - > => { - if (!(input.pendingEvents && input.pendingEvents.length > 0)) { - return { ok: true }; - } - return await writeEvents({ events: input.pendingEvents }); - }; - - const pruneLegacyRemoteRefIfRequested = async (input: { - readonly pruneLegacyRef: boolean; - readonly remoteUrl: string | null; - }): Promise<{ - readonly didPruneLegacy: boolean; - readonly pruneError?: string; - }> => { - if (!(input.pruneLegacyRef && legacyRemoteRef && input.remoteUrl)) { - return { didPruneLegacy: false }; - } - - const prunedLegacy = await runGitDir({ - args: ["push", "origin", `:${legacyRemoteRef}`], - remote: true, - timeoutMs: remoteGitTimeoutMs, - }); - if (!prunedLegacy.ok) { - return { - didPruneLegacy: false, - pruneError: `${prunedLegacy.stderr}\n${prunedLegacy.stdout}`.trim(), - }; - } - - if (legacyTrackingRef) { - await runGitDir({ - args: ["update-ref", "-d", legacyTrackingRef], - }); - } - - return { didPruneLegacy: true }; - }; - - const checkoutHead = async (input: { - readonly allowRemoteFetchFailureFallback?: boolean; - readonly remoteUrl: string | null; - }): Promise< - | { readonly ok: true; readonly pushRef: string } - | { readonly ok: false; readonly error: string } - > => { - await rm(worktreeDir, { recursive: true, force: true }); - await mkdir(worktreeDir, { recursive: true }); - const allowLocalFallback = input.allowRemoteFetchFailureFallback === true; - - if (input.remoteUrl) { - let canCheckoutRemote = false; - let checkoutRef = `origin/${branch}`; - - const fetched = await fetchRemoteRef(remoteRef); - if (fetched.ok) { - canCheckoutRemote = true; - if (legacyRemoteRef && legacyTrackingRef) { - const legacyFetch = await fetchRemoteRefToTracking( - legacyRemoteRef, - legacyTrackingRef - ); - if (!(legacyFetch.ok || legacyFetch.missing)) { - return { - ok: false, - error: `git fetch failed: ${legacyFetch.error}`, - }; - } - } - } else if (fetched.missing && legacyRemoteRef) { - const legacyFetch = legacyTrackingRef - ? await fetchRemoteRefToTracking(legacyRemoteRef, legacyTrackingRef) - : await fetchRemoteRef(legacyRemoteRef); - if (legacyFetch.ok) { - canCheckoutRemote = true; - if (legacyTrackingRef) { - checkoutRef = legacyTrackingRef; - } - } else if (!legacyFetch.missing) { - return { ok: false, error: `git fetch failed: ${legacyFetch.error}` }; - } - } else if (!fetched.missing) { - if (allowLocalFallback) { - opts.logger.warn({ - message: `tickets git fetch failed during checkout, falling back to local branch initialization: ${fetched.error}`, - }); - } else { - return { ok: false, error: `git fetch failed: ${fetched.error}` }; - } - } - - if (canCheckoutRemote) { - const rev = await runGitDir({ - args: ["rev-parse", "--verify", checkoutRef], - }); - if (rev.ok) { - const checkout = await runGitDir({ - args: ["checkout", "-B", branch, rev.stdout.trim()], - }); - if (!checkout.ok) { - return { - ok: false, - error: `git checkout failed: ${checkout.stderr.trim()}`, - }; - } - - const reset = await runGitDir({ args: ["reset", "--hard"] }); - if (!reset.ok) { - return { - ok: false, - error: `git reset failed: ${reset.stderr.trim()}`, - }; - } - - return { - ok: true, - pushRef: resolvePushRefForCheckout({ checkoutRef }), - }; - } - } - } - - const localRef = await runGitDir({ - args: ["rev-parse", "--verify", localBranchRef], - }); - if (!localRef.ok) { - const orphan = await runGitDir({ - args: ["checkout", "--orphan", branch], - }); - if (!orphan.ok) { - return { - ok: false, - error: `git checkout --orphan failed: ${orphan.stderr.trim()}`, - }; - } - - await mkdir(resolve(worktreeDir, ".hack/tickets"), { recursive: true }); - await Bun.write( - resolve(worktreeDir, ".hack/tickets/README.md"), - "Tickets ref for hack-cli\n" - ); - - const added = await runGitDir({ args: ["add", "-A"] }); - if (!added.ok) { - return { ok: false, error: `git add failed: ${added.stderr.trim()}` }; - } - const committed = await runGitDir({ - args: ["commit", "-m", "init tickets"], - }); - if (!committed.ok) { - return { - ok: false, - error: `git commit failed: ${committed.stderr.trim()}`, - }; - } - - return { ok: true, pushRef: remoteRef }; - } - - const checkout = await runGitDir({ args: ["checkout", branch] }); - if (!checkout.ok) { - return { - ok: false, - error: `git checkout failed: ${checkout.stderr.trim()}`, - }; - } - - const reset = await runGitDir({ args: ["reset", "--hard"] }); - if (!reset.ok) { - return { ok: false, error: `git reset failed: ${reset.stderr.trim()}` }; - } - - return { ok: true, pushRef: remoteRef }; - }; - - const mergeLegacyRefIntoCurrentBranch = async (input: { - readonly remoteUrl: string | null; - }): Promise< - | { readonly ok: true; readonly imported: boolean } - | { readonly ok: false; readonly error: string } - > => { - if (!(input.remoteUrl && legacyRemoteRef && legacyTrackingRef)) { - return { ok: true, imported: false }; - } - const prepared = await prepareLegacyEventImport(); - if (!prepared.ok) { - return prepared; - } - if (prepared.paths.length === 0) { - return { ok: true, imported: false }; - } - - let imported = false; - for (const relativePath of prepared.paths) { - const importedPath = await importLegacyEventPath({ relativePath }); - if (!importedPath.ok) { - return importedPath; - } - imported ||= importedPath.imported; - } - - if (!imported) { - return { ok: true, imported: false }; - } - - const normalized = await normalizeLogs(); - if (!normalized.ok) { - return normalized; - } - - return { ok: true, imported: true }; - }; - - const prepareLegacyEventImport = async (): Promise< - | { readonly ok: true; readonly paths: readonly string[] } - | { readonly ok: false; readonly error: string } - > => { - if (!(legacyRemoteRef && legacyTrackingRef)) { - return { ok: true, paths: [] }; - } - - const fetched = await fetchRemoteRefToTracking( - legacyRemoteRef, - legacyTrackingRef - ); - if (!fetched.ok) { - if (fetched.missing) { - return { ok: true, paths: [] }; - } - return { ok: false, error: `git fetch failed: ${fetched.error}` }; - } - - const listed = await runGitDir({ - args: [ - "ls-tree", - "-r", - "--name-only", - legacyTrackingRef, - ".hack/tickets/events", - ], - }); - if (!listed.ok) { - return { - ok: false, - error: `git ls-tree failed: ${listed.stderr.trim() || listed.stdout.trim()}`, - }; - } - - return { - ok: true, - paths: listed.stdout - .split("\n") - .map((path) => path.trim()) - .filter((path) => path.startsWith(".hack/tickets/events/")), - }; - }; - - const importLegacyEventPath = async (input: { - readonly relativePath: string; - }): Promise< - | { readonly ok: true; readonly imported: boolean } - | { readonly ok: false; readonly error: string } - > => { - if (!legacyTrackingRef) { - return { ok: true, imported: false }; - } - - const shown = await runGitDir({ - args: ["show", `${legacyTrackingRef}:${input.relativePath}`], - }); - if (!shown.ok) { - return { - ok: false, - error: `git show failed: ${shown.stderr.trim() || shown.stdout.trim()}`, - }; - } - - const targetPath = resolve(worktreeDir, input.relativePath); - const existing = await Bun.file(targetPath) - .text() - .catch(() => ""); - const merged = mergeTicketEventLogs({ - existing, - incoming: shown.stdout, - }); - if (merged === existing) { - return { ok: true, imported: false }; - } - - await mkdir(dirname(targetPath), { recursive: true }); - await Bun.write(targetPath, merged); - return { ok: true, imported: true }; - }; - - const resolveReusableCheckout = async (input: { - readonly forceFreshCheckout: boolean; - readonly remoteUrl: string | null; - }): Promise<{ - readonly ok: true; - readonly remoteUrl: string | null; - readonly pushRef: string; - } | null> => { - if (input.forceFreshCheckout) { - return null; - } - if (await hasPendingWorktreeChanges()) { - return { - ok: true, - remoteUrl: input.remoteUrl, - pushRef: - refMode === "hidden" && legacyRemoteRef ? legacyRemoteRef : remoteRef, - }; - } - - const preferredTrackingRef = await resolvePreferredTrackingRef(); - if ( - !(await hasAheadLocalBranchCommits({ trackingRef: preferredTrackingRef })) - ) { - return null; - } - - return { - ok: true, - remoteUrl: input.remoteUrl, - pushRef: - preferredTrackingRef === legacyTrackingRef && legacyRemoteRef - ? legacyRemoteRef - : remoteRef, - }; - }; - - const resolveEventsPath = (tsSeconds: number): string => { - const d = new Date(tsSeconds * 1000); - const year = d.getUTCFullYear(); - const month = String(d.getUTCMonth() + 1).padStart(2, "0"); - return resolve( - worktreeDir, - `.hack/tickets/events/events-${year}-${month}.jsonl` - ); - }; - - const writeEvents = async (input: { - readonly events: readonly Record<string, unknown>[]; - }): Promise< - { readonly ok: true } | { readonly ok: false; readonly error: string } - > => { - await mkdir(resolve(worktreeDir, ".hack/tickets/events"), { - recursive: true, - }); - - const grouped = new Map<string, Record<string, unknown>[]>(); - for (const ev of input.events) { - const ts = - typeof ev.ts === "number" - ? (ev.ts as number) - : Math.floor(Date.now() / 1000); - const path = resolveEventsPath(ts); - const list = grouped.get(path) ?? []; - list.push(ev); - grouped.set(path, list); - } - - for (const [path, events] of grouped) { - const existing = await Bun.file(path) - .text() - .catch(() => ""); - const lines = collectPendingSerializedEvents({ - existingText: existing, - incomingEvents: events, - }); - - if (lines.length > 0) { - const prefix = - existing.length > 0 && !existing.endsWith("\n") ? "\n" : ""; - await Bun.write(path, `${existing}${prefix}${lines.join("\n")}\n`); - } - } - - const normalized = await normalizeLogs(); - if (!normalized.ok) { - return normalized; - } - - return { ok: true }; - }; - - const normalizeLogs = async (): Promise< - { readonly ok: true } | { readonly ok: false; readonly error: string } - > => { - const eventsDir = resolve(worktreeDir, ".hack/tickets/events"); - let files: string[] = []; - try { - files = (await readdir(eventsDir)).filter((f) => f.endsWith(".jsonl")); - } catch { - return { ok: true }; - } - - for (const file of files.sort()) { - const path = resolve(eventsDir, file); - const text = await Bun.file(path) - .text() - .catch(() => ""); - const next = collectUniqueJournalEvents({ - texts: [text], - }) - .map((event) => stableStringify(event.value)) - .join("\n"); - const normalized = next.length > 0 ? `${next}\n` : ""; - if (normalized !== text) { - await Bun.write(path, normalized); - } - } - - return { ok: true }; - }; - - const commitAll = async ( - message: string - ): Promise< - | { readonly ok: true; readonly didCommit: boolean } - | { readonly ok: false; readonly error: string } - > => { - const staged = await runGitDir({ args: ["add", "-A"] }); - if (!staged.ok) { - return { ok: false, error: `git add failed: ${staged.stderr.trim()}` }; - } - - const commit = await runGitDir({ args: ["commit", "-m", message] }); - if (!commit.ok) { - const msg = `${commit.stderr}\n${commit.stdout}`.trim(); - if ( - msg.includes("nothing to commit") || - msg.includes("nothing added to commit") - ) { - return { ok: true, didCommit: false }; - } - return { ok: false, error: `git commit failed: ${msg}` }; - } - - return { ok: true, didCommit: true }; - }; - - const pushWithRetry = async (input: { - readonly remoteUrl: string | null; - readonly pushRef: string; - readonly pendingEvents?: readonly Record<string, unknown>[]; - readonly replayPendingEvents?: () => Promise< - { readonly ok: true } | { readonly ok: false; readonly error: string } - >; - }): Promise< - | { readonly ok: true; readonly didPush: boolean } - | { readonly ok: false; readonly error: string } - > => { - if (!input.remoteUrl) { - return { ok: true, didPush: false }; - } - - let nextPushRef = input.pushRef; - for (let attempt = 1; attempt <= MAX_PUSH_ATTEMPTS; attempt += 1) { - const push = await pushCurrentBranch({ - pushRef: nextPushRef, - attempt, - }); - if (push.ok) { - return push; - } - if (push.hiddenRefRejected || attempt === MAX_PUSH_ATTEMPTS) { - return push; - } - - opts.logger.warn({ - message: `git push failed, retrying after fetch: ${push.error.replace("git push failed: ", "")}`, - }); - const recovered = await recoverRetryablePushFailure(input); - if (!recovered.ok) { - return recovered; - } - nextPushRef = recovered.pushRef; - } - - return { - ok: false, - error: "git push failed after exhausting retry attempts", - }; - }; - - const recoverRetryablePushFailure = async (input: { - readonly remoteUrl: string | null; - readonly pendingEvents?: readonly Record<string, unknown>[]; - readonly replayPendingEvents?: () => Promise< - { readonly ok: true } | { readonly ok: false; readonly error: string } - >; - }): Promise< - | { readonly ok: true; readonly pushRef: string } - | { readonly ok: false; readonly error: string } - > => { - const checkedOut = await checkoutHead({ remoteUrl: input.remoteUrl }); - if (!checkedOut.ok) { - return checkedOut; - } - - if (input.replayPendingEvents) { - const replayed = await input.replayPendingEvents(); - if (!replayed.ok) { - return replayed; - } - } else { - const rewrote = await rewritePendingEventsAfterCheckout({ - pendingEvents: input.pendingEvents, - }); - if (!rewrote.ok) { - return rewrote; - } - } - - const committed = await commitAll("tickets: retry"); - if (!committed.ok) { - return committed; - } - - return { ok: true, pushRef: checkedOut.pushRef }; - }; - - const listTrackedPaths = async (): Promise< - | { readonly ok: true; readonly paths: readonly string[] } - | { readonly ok: false; readonly error: string } - > => { - const listed = await runGitDir({ args: ["ls-files", "-z"] }); - if (!listed.ok) { - return { - ok: false, - error: `git ls-files failed: ${listed.stderr.trim()}`, - }; - } - - const paths = listed.stdout - .split("\u0000") - .map((path) => path.trim()) - .filter((path) => path.length > 0); - return { ok: true, paths }; - }; - - const hasRemoteRef = async (ref: string): Promise<boolean> => { - const remoteUrl = await resolveRemoteUrl(); - if (!remoteUrl) { - return false; - } - const listed = await runGitDir({ - args: ["ls-remote", "origin", ref], - remote: true, - timeoutMs: remoteGitTimeoutMs, - }); - if (!listed.ok) { - return false; - } - return listed.stdout.trim().length > 0; - }; - - const resolveRefOid = async (ref: string): Promise<string | null> => { - const resolved = await runGitDir({ - args: ["rev-parse", "--verify", ref], - }); - if (!resolved.ok) { - return null; - } - const oid = resolved.stdout.trim(); - return oid.length > 0 ? oid : null; - }; - - const pruneWorktreeToTickets = async (): Promise< - { readonly ok: true } | { readonly ok: false; readonly error: string } - > => { - let entries: string[] = []; - try { - entries = await readdir(worktreeDir); - } catch (error: unknown) { - const message = - error instanceof Error - ? error.message - : "Failed to read tickets worktree"; - return { ok: false, error: message }; - } - - for (const entry of entries) { - if (entry === ".hack" || entry === ".git") { - continue; - } - await rm(resolve(worktreeDir, entry), { recursive: true, force: true }); - } - - const hackDir = resolve(worktreeDir, ".hack"); - try { - const hackEntries = await readdir(hackDir); - for (const entry of hackEntries) { - if (entry === "tickets") { - continue; - } - await rm(resolve(hackDir, entry), { recursive: true, force: true }); - } - } catch { - // ignore missing .hack directory - } - - await mkdir(resolve(worktreeDir, ".hack/tickets"), { recursive: true }); - const readmePath = resolve(worktreeDir, ".hack/tickets/README.md"); - const hasReadme = await Bun.file(readmePath).exists(); - if (!hasReadme) { - await Bun.write(readmePath, "Tickets ref for hack-cli\n"); - } - - return { ok: true }; - }; - - const inspect = async (): Promise<TicketsGitInspectResult> => { - const checkedOut = await ensureCheckedOut(); - if (!checkedOut.ok) { - return checkedOut; - } - - const tracked = await listTrackedPaths(); - if (!tracked.ok) { - return tracked; - } - - const nonTicketPaths = tracked.paths.filter( - (path) => !path.startsWith(".hack/tickets/") && path !== ".hack/tickets" - ); - - const hasLegacyRef = legacyRemoteRef - ? await hasRemoteRef(legacyRemoteRef) - : false; - const remoteRefOid = await resolveRefOid(trackingRef); - const legacyRefOid = - hasLegacyRef && legacyTrackingRef - ? await resolveRefOid(legacyTrackingRef) - : null; - const hasRefDivergence = - hasLegacyRef && - remoteRefOid !== null && - legacyRefOid !== null && - remoteRefOid !== legacyRefOid; - - return { - ok: true, - health: { - branch, - refMode, - remoteRef, - legacyRef: legacyRemoteRef ?? undefined, - remote: checkedOut.remoteUrl ? remoteName : undefined, - hasLegacyRef, - hasRefDivergence, - remoteRefOid: remoteRefOid ?? undefined, - legacyRefOid: legacyRefOid ?? undefined, - hasNonTicketFiles: nonTicketPaths.length > 0, - nonTicketPaths, - }, - }; - }; - - const repair = async (input: { - readonly pruneLegacyRef: boolean; - }): Promise<TicketsGitRepairResult> => { - return await withMutationLock(async () => { - const checkedOut = await ensureCheckedOutUnlocked({ - forceFreshCheckout: true, - }); - if (!checkedOut.ok) { - return checkedOut; - } - - const pruned = await pruneWorktreeToTickets(); - if (!pruned.ok) { - return pruned; - } - - const committed = await commitAll("tickets: repair"); - if (!committed.ok) { - return committed; - } - - const pushed = await pushWithRetry({ - remoteUrl: checkedOut.remoteUrl, - pushRef: checkedOut.pushRef, - replayPendingEvents: async () => { - return await pruneWorktreeToTickets(); - }, - }); - if (!pushed.ok) { - return pushed; - } - - const prunedLegacy = await pruneLegacyRemoteRefIfRequested({ - pruneLegacyRef: input.pruneLegacyRef, - remoteUrl: checkedOut.remoteUrl, - }); - - return { - ok: true, - didCommit: committed.didCommit, - didPush: pushed.didPush, - didPruneLegacy: prunedLegacy.didPruneLegacy, - ...(prunedLegacy.pruneError - ? { pruneError: prunedLegacy.pruneError } - : {}), - }; - }); - }; - - const appendEvents = async (input: { - readonly events: readonly Record<string, unknown>[]; - }): Promise< - { readonly ok: true } | { readonly ok: false; readonly error: string } - > => { - return await withMutationLock(async () => { - const checkedOut = await ensureCheckedOutUnlocked(); - if (!checkedOut.ok) { - return checkedOut; - } - - const wrote = await writeEvents({ events: input.events }); - if (!wrote.ok) { - return wrote; - } - - const committed = await commitAll("tickets: append events"); - if (!committed.ok) { - return committed; - } - - const pushed = await pushWithRetry({ - remoteUrl: checkedOut.remoteUrl, - pushRef: checkedOut.pushRef, - pendingEvents: input.events, - }); - if (!pushed.ok) { - return pushed; - } - - return { ok: true }; - }); - }; - - const appendPreparedEvents = async <T>(input: { - readonly prepare: (root: string) => Promise< - | { - readonly ok: true; - readonly events: readonly Record<string, unknown>[]; - readonly result: T; - } - | { readonly ok: false; readonly error: string } - >; - }): Promise< - | { readonly ok: true; readonly result: T } - | { readonly ok: false; readonly error: string } - > => { - return await withMutationLock(async () => { - const checkedOut = await ensureCheckedOutUnlocked(); - if (!checkedOut.ok) { - return checkedOut; - } - - const prepared = await input.prepare(worktreeDir); - if (!prepared.ok) { - return prepared; - } - - let preparedResult = prepared.result; - - const wrote = await writeEvents({ events: prepared.events }); - if (!wrote.ok) { - return wrote; - } - - const committed = await commitAll("tickets: append events"); - if (!committed.ok) { - return committed; - } - - const pushed = await pushWithRetry({ - remoteUrl: checkedOut.remoteUrl, - pushRef: checkedOut.pushRef, - replayPendingEvents: async () => { - const replayed = await input.prepare(worktreeDir); - if (!replayed.ok) { - return replayed; - } - preparedResult = replayed.result; - return await writeEvents({ events: replayed.events }); - }, - }); - if (!pushed.ok) { - return pushed; - } - - return { ok: true, result: preparedResult }; - }); - }; - - const sync = async (): Promise< - | { - readonly ok: true; - readonly branch: string; - readonly remote?: string; - readonly didCommit: boolean; - readonly didPush: boolean; - } - | { readonly ok: false; readonly error: string } - > => { - return await withMutationLock(async () => { - const checkedOut = await ensureCheckedOutUnlocked(); - if (!checkedOut.ok) { - return checkedOut; - } - - const normalized = await normalizeLogs(); - if (!normalized.ok) { - return normalized; - } - - const committed = await commitAll("tickets: sync"); - if (!committed.ok) { - return committed; - } - - const pushed = await pushWithRetry({ - remoteUrl: checkedOut.remoteUrl, - pushRef: checkedOut.pushRef, - }); - if (!pushed.ok) { - return pushed; - } - - return { - ok: true, - branch, - ...(checkedOut.remoteUrl ? { remote: remoteName } : {}), - didCommit: committed.didCommit, - didPush: pushed.didPush, - }; - }); - }; - - return { - ensureCheckedOut: async (input) => { - const checkedOut = await ensureCheckedOut(input); - if (!checkedOut.ok) { - throw new Error(checkedOut.error); - } - return worktreeDir; - }, - appendEvents, - appendPreparedEvents, - inspect, - repair, - sync, - }; -} - -async function runGit(opts: { - readonly cwd: string; - readonly args: readonly string[]; - readonly remote?: boolean; - readonly timeoutMs?: number; -}): Promise<{ - readonly ok: boolean; - readonly stdout: string; - readonly stderr: string; -}> { - const proc = Bun.spawn(["git", ...opts.args], { - cwd: opts.cwd, - stdout: "pipe", - stderr: "pipe", - stdin: "ignore", - detached: opts.remote === true, - env: { - ...process.env, - ...resolveTicketGitIdentityEnv({ - remote: opts.remote === true, - }), - }, - }); - const stdoutPromise = new Response(proc.stdout).text(); - const stderrPromise = new Response(proc.stderr).text(); - const timeoutMs = - opts.remote === true - ? (opts.timeoutMs ?? DEFAULT_REMOTE_GIT_TIMEOUT_MS) - : opts.timeoutMs; - const exitCode = await waitForGitProcess({ - proc, - timeoutMs, - remote: opts.remote === true, - }); - const stdout = await stdoutPromise; - const rawStderr = await stderrPromise; - const stderr = - exitCode === 124 && rawStderr.trim().length === 0 - ? `git ${opts.args[0] ?? "command"} timed out after ${timeoutMs ?? DEFAULT_REMOTE_GIT_TIMEOUT_MS}ms` - : rawStderr; - return { - ok: exitCode === 0, - stdout, - stderr, - }; -} - -async function waitForGitProcess(input: { - readonly proc: Bun.Subprocess; - readonly timeoutMs?: number; - readonly remote?: boolean; -}): Promise<number> { - if ( - !(typeof input.timeoutMs === "number" && Number.isFinite(input.timeoutMs)) - ) { - return await input.proc.exited; - } - const timeoutMs = Math.max(1, Math.floor(input.timeoutMs)); - let timeoutHandle: ReturnType<typeof setTimeout> | null = null; - try { - return await Promise.race([ - input.proc.exited, - new Promise<number>((resolve) => { - timeoutHandle = setTimeout(() => { - terminateGitProcess({ - proc: input.proc, - remote: input.remote === true, - }); - resolve(124); - }, timeoutMs); - }), - ]); - } finally { - if (timeoutHandle) { - clearTimeout(timeoutHandle); - } - } -} - -function terminateGitProcess(input: { - readonly proc: Bun.Subprocess; - readonly remote: boolean; -}): void { - if (input.remote) { - const pid = typeof input.proc.pid === "number" ? input.proc.pid : null; - if (pid !== null) { - try { - process.kill(-pid, "SIGKILL"); - return; - } catch { - // Fall back to terminating the direct git process below. - } - } - } - input.proc.kill(); -} - -function resolveTicketGitIdentityEnv(input?: { - readonly remote?: boolean; -}): Record<string, string> { - const env: Record<string, string> = { - GIT_TERMINAL_PROMPT: "0", - }; - if ( - input?.remote && - !readOptionalEnv("GIT_SSH_COMMAND") && - !readOptionalEnv("GIT_SSH") - ) { - env.GIT_SSH_COMMAND = DEFAULT_REMOTE_SSH_COMMAND; - } - const authorName = - readOptionalEnv("GIT_AUTHOR_NAME") ?? - readOptionalEnv("GIT_COMMITTER_NAME") ?? - "hack tickets"; - const authorEmail = - readOptionalEnv("GIT_AUTHOR_EMAIL") ?? - readOptionalEnv("GIT_COMMITTER_EMAIL") ?? - "tickets@hack.local"; - const committerName = readOptionalEnv("GIT_COMMITTER_NAME") ?? authorName; - const committerEmail = readOptionalEnv("GIT_COMMITTER_EMAIL") ?? authorEmail; - return { - ...env, - GIT_AUTHOR_NAME: authorName, - GIT_AUTHOR_EMAIL: authorEmail, - GIT_COMMITTER_NAME: committerName, - GIT_COMMITTER_EMAIL: committerEmail, - }; -} - -export function isTicketsGitRemoteConnectivityError(message: string): boolean { - if (isTicketsGitMissingRepositoryError(message)) { - return false; - } - return ( - isTicketsGitRemoteAuthError(message) || - isTicketsGitRemoteTimeoutError(message) || - isTicketsGitRemoteTransportError(message) - ); -} - -function formatTicketsGitRemoteError(input: { - readonly message: string; - readonly operation: "fetch" | "push" | "ls-remote"; -}): string { - const message = input.message.trim(); - if (!message) { - return message; - } - if (isTicketsGitRemoteAuthError(message)) { - return `${message}\nTickets ${input.operation} could not authenticate to the git remote. Unlock your SSH agent or 1Password, then retry. Check with: ssh -T git@github.com`; - } - if (isTicketsGitRemoteTimeoutError(message)) { - return `${message}\nTickets ${input.operation} timed out talking to the git remote. Check SSH reachability, agent state, and network access, then retry. Check with: ssh -T git@github.com`; - } - return message; -} - -function isTicketsGitRemoteAuthError(message: string): boolean { - const normalized = message.toLowerCase(); - return ( - normalized.includes("agent refused operation") || - normalized.includes("permission denied (publickey)") || - normalized.includes( - "could not open a connection to your authentication agent" - ) || - normalized.includes("sign_and_send_pubkey") || - normalized.includes("no such identity") || - (normalized.includes("permission denied") && - normalized.includes("publickey")) - ); -} - -function isTicketsGitMissingRepositoryError(message: string): boolean { - const normalized = message.toLowerCase(); - return ( - normalized.includes("repository not found") || - (normalized.includes("fatal: repository") && - normalized.includes("not found")) - ); -} - -function isTicketsGitRemoteTimeoutError(message: string): boolean { - const normalized = message.toLowerCase(); - return ( - normalized.includes("operation timed out") || - normalized.includes("connection timed out") || - normalized.includes("timed out after") || - normalized.includes("connect timeout") - ); -} - -function isTicketsGitRemoteTransportError(message: string): boolean { - const normalized = message.toLowerCase(); - return ( - normalized.includes("connection refused") || - normalized.includes("connection reset by peer") || - normalized.includes("could not read from remote repository") || - normalized.includes("could not resolve hostname") || - normalized.includes("name or service not known") || - normalized.includes("network is unreachable") || - normalized.includes("no route to host") || - normalized.includes("ssh: connect to host") || - normalized.includes("temporary failure in name resolution") - ); -} - -function readOptionalEnv(key: string): string | null { - const value = process.env[key]; - if (typeof value !== "string") { - return null; - } - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : null; -} - -function safeJsonParse(text: string): unknown { - try { - return JSON.parse(text); - } catch { - return null; - } -} - -function mergeTicketEventLogs(input: { - readonly existing: string; - readonly incoming: string; -}): string { - const next = collectUniqueJournalEvents({ - texts: [input.existing, input.incoming], - }) - .map((event) => stableStringify(event.value)) - .join("\n"); - return next ? `${next}\n` : ""; -} - -function collectPendingSerializedEvents(input: { - readonly existingText: string; - readonly incomingEvents: readonly Record<string, unknown>[]; -}): string[] { - const existingEvents = collectUniqueJournalEvents({ - texts: [input.existingText], - }); - const existingIds = new Set(existingEvents.map((event) => event.eventId)); - const existingIdempotencyKeys = new Set( - existingEvents.map((event) => event.idempotencyKey) - ); - const pendingIdempotencyKeys = new Set<string>(); - const lines: string[] = []; - - for (const event of input.incomingEvents) { - const parsed = parseJournalEvent(event); - if (!parsed) { - continue; - } - if ( - existingIds.has(parsed.eventId) || - existingIdempotencyKeys.has(parsed.idempotencyKey) || - pendingIdempotencyKeys.has(parsed.idempotencyKey) - ) { - continue; - } - pendingIdempotencyKeys.add(parsed.idempotencyKey); - lines.push(stableStringify(event)); - } - - return lines; -} - -function collectUniqueJournalEvents(input: { - readonly texts: readonly string[]; -}): ParsedJournalEvent[] { - const parsed: ParsedJournalEvent[] = []; - const seen = new Set<string>(); - const seenIdempotencyKeys = new Set<string>(); - - for (const text of input.texts) { - for (const line of text.split("\n")) { - const event = parseJournalEventLine(line); - if (!event) { - continue; - } - if ( - seen.has(event.eventId) || - seenIdempotencyKeys.has(event.idempotencyKey) - ) { - continue; - } - seen.add(event.eventId); - seenIdempotencyKeys.add(event.idempotencyKey); - parsed.push(event); - } - } - - parsed.sort(compareParsedJournalEvents); - return parsed; -} - -function parseJournalEventLine(line: string): ParsedJournalEvent | null { - const trimmed = line.trim(); - if (!trimmed) { - return null; - } - return parseJournalEvent(safeJsonParse(trimmed)); -} - -function parseJournalEvent(value: unknown): ParsedJournalEvent | null { - if (!isRecord(value)) { - return null; - } - const eventId = typeof value.eventId === "string" ? value.eventId : ""; - const idempotencyKey = - typeof value.idempotencyKey === "string" ? value.idempotencyKey : eventId; - const ts = typeof value.ts === "number" ? value.ts : Number.NaN; - if (!(eventId && Number.isFinite(ts))) { - return null; - } - return { - eventId, - idempotencyKey, - ts, - value, - }; -} - -function compareParsedJournalEvents( - left: ParsedJournalEvent, - right: ParsedJournalEvent -): number { - if (left.ts !== right.ts) { - return left.ts - right.ts; - } - return left.eventId.localeCompare(right.eventId); -} - -function isRetryableTrackingRefLockFailure(input: { - readonly stderr: string; - readonly trackingRef: string; -}): boolean { - const message = input.stderr.toLowerCase(); - return ( - message.includes("cannot lock ref") && - message.includes(input.trackingRef.toLowerCase()) && - message.includes("expected") - ); -} - -function isMissingRemoteRef(message: string): boolean { - const normalized = message.toLowerCase(); - return ( - normalized.includes("couldn't find remote ref") || - (normalized.includes("remote ref") && normalized.includes("not found")) || - (normalized.includes("remote branch") && normalized.includes("not found")) - ); -} - -function normalizeBranchName(input: string): string { - const trimmed = input.trim(); - if (!trimmed) { - return "hack/tickets"; - } - return trimmed - .replace(REFS_HEADS_PREFIX_PATTERN, "") - .replace(REFS_PREFIX_PATTERN, ""); -} - -function buildRemoteRef(opts: { - readonly branch: string; - readonly refMode: TicketsGitRefMode; -}): string { - const branch = normalizeBranchName(opts.branch); - if (opts.refMode === "heads") { - return `refs/heads/${branch}`; - } - return `refs/${branch}`; -} - -function isHiddenRefRejected(message: string): boolean { - const normalized = message.toLowerCase(); - return ( - normalized.includes("deny updating a hidden ref") || - normalized.includes("deny updating hidden ref") || - normalized.includes("update is not allowed") || - normalized.includes("remote rejected") || - normalized.includes("not a valid ref") - ); -} - -function isGitIndexLockError(message: string): boolean { - const normalized = message.toLowerCase(); - return ( - (normalized.includes("index.lock") && normalized.includes("file exists")) || - normalized.includes("unable to write new index file") || - (normalized.includes("could not lock") && normalized.includes("index")) - ); -} - -export const __testOnly = { - createGitTicketsChannel, - formatTicketsGitRemoteError, - isTicketsGitRemoteConnectivityError, - mergeTicketEventLogs, - resolveTicketGitIdentityEnv, - resolvePushRefForCheckoutRef, - resolveLocalCheckoutFallback, - resolveLegacyImportFetchResult, -}; - -function resolvePushRefForCheckoutRef(input: { - readonly checkoutRef: string; - readonly remoteRef: string; - readonly legacyTrackingRef?: string | null; - readonly legacyRemoteRef?: string | null; -}): string { - if ( - input.legacyTrackingRef && - input.legacyRemoteRef && - input.checkoutRef === input.legacyTrackingRef - ) { - return input.legacyRemoteRef; - } - return input.remoteRef; -} - -function resolveLocalCheckoutFallback(input: { - readonly fetchFailure: string; - readonly allowFetchFailureFallback: boolean; - readonly preferredTrackingRef: string; - readonly remoteRef: string; - readonly legacyTrackingRef?: string | null; - readonly legacyRemoteRef?: string | null; -}): - | { readonly ok: true; readonly pushRef: string } - | { readonly ok: false; readonly error: string } { - if (!input.allowFetchFailureFallback) { - return { ok: false, error: input.fetchFailure }; - } - return { - ok: true, - pushRef: resolvePushRefForCheckoutRef({ - checkoutRef: input.preferredTrackingRef, - remoteRef: input.remoteRef, - legacyTrackingRef: input.legacyTrackingRef, - legacyRemoteRef: input.legacyRemoteRef, - }), - }; -} - -function resolveLegacyImportFetchResult(input: { - readonly missing: boolean; - readonly error: string; -}): - | { readonly ok: true; readonly imported: false } - | { readonly ok: false; readonly error: string } { - if (input.missing) { - return { ok: true, imported: false }; - } - return { ok: false, error: `git fetch failed: ${input.error}` }; -} diff --git a/src/control-plane/extensions/tickets/tickets-skill.ts b/src/control-plane/extensions/tickets/tickets-skill.ts deleted file mode 100644 index edb1b1c6..00000000 --- a/src/control-plane/extensions/tickets/tickets-skill.ts +++ /dev/null @@ -1,247 +0,0 @@ -import { rm } from "node:fs/promises"; -import { dirname, resolve } from "node:path"; - -import { - ensureDir, - pathExists, - readTextFile, - writeTextFileIfChanged, -} from "../../../lib/fs.ts"; - -const HACK_TICKETS_NAME_PATTERN = /name:\s*hack-tickets\b/i; - -export type TicketsSkillScope = "project" | "user"; - -export type TicketsSkillResult = { - readonly scope: TicketsSkillScope; - readonly status: - | "created" - | "updated" - | "noop" - | "absent" - | "deprecated" - | "removed" - | "missing" - | "error"; - readonly path: string; - readonly message?: string; -}; - -const SKILL_NAME = "hack-tickets"; -const SKILL_FILENAME = "SKILL.md"; -const SKILL_DIR = ".codex/skills"; - -export async function installTicketsSkill(opts: { - readonly scope: TicketsSkillScope; - readonly projectRoot?: string; -}): Promise<TicketsSkillResult> { - const resolved = resolveTicketsSkillPath(opts); - if (!resolved.ok) { - return { - scope: opts.scope, - status: "error", - path: resolved.path ?? SKILL_FILENAME, - message: resolved.message, - }; - } - - const path = resolved.path; - await ensureDir(dirname(path)); - const existed = await pathExists(path); - const result = await writeTextFileIfChanged(path, renderTicketsSkill()); - const status = resolveSkillInstallStatus({ - changed: result.changed, - existed, - }); - - return { - scope: opts.scope, - status, - path, - }; -} - -export async function checkTicketsSkill(opts: { - readonly scope: TicketsSkillScope; - readonly projectRoot?: string; -}): Promise<TicketsSkillResult> { - const resolved = resolveTicketsSkillPath(opts); - if (!resolved.ok) { - return { - scope: opts.scope, - status: "error", - path: resolved.path ?? SKILL_FILENAME, - message: resolved.message, - }; - } - - const path = resolved.path; - const content = await readTextFile(path); - if (!content) { - return { scope: opts.scope, status: "missing", path }; - } - - const hasMarker = HACK_TICKETS_NAME_PATTERN.test(content); - return { scope: opts.scope, status: hasMarker ? "noop" : "error", path }; -} - -/** Check that the deprecated tickets skill is absent from an agent scope. */ -export async function checkDeprecatedTicketsSkill(opts: { - readonly scope: TicketsSkillScope; - readonly projectRoot?: string; -}): Promise<TicketsSkillResult> { - const result = await checkTicketsSkill(opts); - if (result.status === "missing") { - return { ...result, status: "absent" }; - } - if (result.status === "noop") { - return { - ...result, - status: "deprecated", - message: `Deprecated hack-tickets skill is still installed at ${result.path}. Run: hack setup sync --all-scopes`, - }; - } - return result; -} - -export async function removeTicketsSkill(opts: { - readonly scope: TicketsSkillScope; - readonly projectRoot?: string; -}): Promise<TicketsSkillResult> { - const resolved = resolveTicketsSkillPath(opts); - if (!resolved.ok) { - return { - scope: opts.scope, - status: "error", - path: resolved.path ?? SKILL_FILENAME, - message: resolved.message, - }; - } - - const path = resolved.path; - const skillDir = resolve(path, ".."); - - if (!(await pathExists(path))) { - return { scope: opts.scope, status: "missing", path }; - } - - await rm(skillDir, { recursive: true, force: true }); - return { scope: opts.scope, status: "removed", path }; -} - -export function renderTicketsSkill(): string { - const lines = [ - "---", - "name: hack-tickets", - "description: >", - " Use the hack tickets extension (git-backed JSONL event log) to create/list/show/sync lightweight tickets.", - " Trigger when asked to track work items, decisions, or bugs inside a repo without external issue trackers.", - " Prefer `hack x tickets ...` commands; store lives on hidden ref `refs/hack/tickets` by default.", - "---", - "", - "# hack tickets", - "", - "This repo uses the hack tickets extension (`dance.hack.tickets`).", - "Prefer `hack tickets ...` (alias) or `hack x tickets ...` over manual edits in `.hack/tickets/`.", - "", - "## Enable", - "", - "Enable globally:", - "", - "- `hack config set --global 'controlPlane.extensions[\"dance.hack.tickets\"].enabled' true`", - "- `hack setup sync --all-scopes` (refresh generated agent instructions + skills + MCP config)", - "", - "Or per-project by adding `.hack/hack.config.json`:", - "", - "```json", - "{", - ' "controlPlane": {', - ' "extensions": {', - ' "dance.hack.tickets": { "enabled": true }', - " }", - " }", - "}", - "```", - "", - "## Commands", - "", - '- Create: `hack x tickets create --title "..." [--body "..."] [--body-file <path>] [--body-stdin] [--depends-on "..."] [--blocks "..."] [--actor "..."] [--json]`', - "- List: `hack x tickets list [--json]`", - "- Tui: `hack x tickets tui`", - "- Show: `hack x tickets show <ticket-id> [--json]`", - '- Update: `hack x tickets update <ticket-id> [--title "..."] [--body "..."] [--depends-on "..."] [--blocks "..."] [--clear-depends-on] [--clear-blocks] [--json]`', - "- Status: `hack x tickets status <ticket-id> <open|in_progress|blocked|done> [--json]`", - "- Sync: `hack x tickets sync [--json]`", - "", - "Recommended body template (Markdown):", - "", - "```md", - "## Context", - "## Goals", - "## Notes", - "## Links", - "```", - "", - "Tip: use `--body-stdin` for multi-line markdown.", - "", - "## Data model", - "", - "- Tickets are derived from an append-only event log (JSONL).", - "- Local state lives in `.hack/tickets/` (gitignored on the main branch).", - "- Sync writes commits to a dedicated ref (`refs/hack/tickets` hidden by default) and pushes to your remote.", - "- Set `controlPlane.tickets.git.refMode` to `heads` to use a normal branch ref (and protect it if desired).", - "", - "## Tips", - "", - "- Keep ticket titles short; put detail in `--body`.", - "- Use `--json` for agent workflows and piping.", - "- Run `hack x tickets sync` before opening PRs if you want tickets to travel with the repo.", - "- Update status continuously (`open` -> `in_progress` -> `blocked`/`done`) so handoffs are explicit.", - "- Use `--depends-on` / `--blocks` links to model execution order for parallel agent work.", - "", - ]; - - return lines.join("\n"); -} - -function resolveSkillInstallStatus(opts: { - readonly changed: boolean; - readonly existed: boolean; -}): "created" | "updated" | "noop" { - if (!opts.changed) { - return "noop"; - } - return opts.existed ? "updated" : "created"; -} - -function resolveTicketsSkillPath(opts: { - readonly scope: TicketsSkillScope; - readonly projectRoot?: string; -}): - | { readonly ok: true; readonly path: string } - | { readonly ok: false; readonly message: string; readonly path?: string } { - if (opts.scope === "project" && !opts.projectRoot) { - return { - ok: false, - message: "Missing project root for project-scoped tickets skill.", - }; - } - - const root = opts.scope === "user" ? resolveHomeDir() : opts.projectRoot; - if (!root) { - return { - ok: false, - message: "HOME is not set; cannot resolve tickets skill path.", - }; - } - - return { - ok: true, - path: resolve(root, SKILL_DIR, SKILL_NAME, SKILL_FILENAME), - }; -} - -function resolveHomeDir(): string | null { - const home = (process.env.HOME ?? "").trim(); - return home.length > 0 ? home : null; -} diff --git a/src/control-plane/extensions/tickets/util.ts b/src/control-plane/extensions/tickets/util.ts deleted file mode 100644 index 8fe5b6bb..00000000 --- a/src/control-plane/extensions/tickets/util.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { createHash, randomBytes } from "node:crypto"; - -const DIGITS_ONLY_PATTERN = /^\d+$/; -const LEGACY_TICKET_ID_PATTERN = /^T-(\d+)$/i; -const RANDOM_TICKET_ID_PATTERN = /^T-([0-9A-Z]{10})$/i; -const RANDOM_TICKET_ID_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"; -const RANDOM_TICKET_ID_PREFIX_ALPHABET = "ABCDEFGHJKMNPQRSTVWXYZ"; -const RANDOM_TICKET_ID_LENGTH = 10; - -export function unixSeconds(): number { - return Math.floor(Date.now() / 1000); -} - -export function getMonthStamp(tsSeconds: number): string { - const d = new Date(tsSeconds * 1000); - const year = d.getUTCFullYear(); - const month = String(d.getUTCMonth() + 1).padStart(2, "0"); - return `${year}-${month}`; -} - -export function formatTicketId(n: number): string { - const padded = String(n).padStart(5, "0"); - return `T-${padded}`; -} - -export function parseTicketNumber(ticketId: string): number | null { - const trimmed = ticketId.trim(); - const match = LEGACY_TICKET_ID_PATTERN.exec(trimmed); - if (!match) { - return null; - } - const n = Number(match[1]); - if (!Number.isFinite(n)) { - return null; - } - return Math.trunc(n); -} - -export function generateTicketId(): string { - const prefix = generateRandomTicketIdChunk({ - alphabet: RANDOM_TICKET_ID_PREFIX_ALPHABET, - length: 1, - }); - const suffix = generateRandomTicketIdChunk({ - alphabet: RANDOM_TICKET_ID_ALPHABET, - length: RANDOM_TICKET_ID_LENGTH - prefix.length, - }); - return `T-${prefix}${suffix}`; -} - -export function normalizeTicketRef(input: string): string | null { - const trimmed = input.trim(); - if (!trimmed) { - return null; - } - const raw = trimmed.startsWith("#") ? trimmed.slice(1) : trimmed; - if (!raw) { - return null; - } - const upper = raw.toUpperCase(); - if (upper.startsWith("T-")) { - const n = parseTicketNumber(upper); - if (n !== null) { - return formatTicketId(n); - } - return RANDOM_TICKET_ID_PATTERN.test(upper) ? upper : null; - } - if (DIGITS_ONLY_PATTERN.test(raw)) { - return formatTicketId(Number(raw)); - } - return null; -} - -export function compareTicketIds(left: string, right: string): number { - const leftNumber = parseTicketNumber(left); - const rightNumber = parseTicketNumber(right); - if (leftNumber !== null && rightNumber !== null) { - return leftNumber - rightNumber; - } - if (leftNumber !== null) { - return -1; - } - if (rightNumber !== null) { - return 1; - } - return left.localeCompare(right); -} - -export function normalizeTicketRefs(inputs: readonly string[]): string[] { - const seen = new Set<string>(); - const out: string[] = []; - for (const raw of inputs) { - const normalized = normalizeTicketRef(raw); - if (!normalized || seen.has(normalized)) { - continue; - } - seen.add(normalized); - out.push(normalized); - } - out.sort(compareTicketIds); - return out; -} - -export function stableStringify(value: unknown): string { - return JSON.stringify(stableSort(value)); -} - -export function sha256Hex(input: { readonly value: string }): string { - return createHash("sha256").update(input.value).digest("hex"); -} - -function stableSort(value: unknown): unknown { - if (Array.isArray(value)) { - return value.map(stableSort); - } - if (value && typeof value === "object") { - const rec = value as Record<string, unknown>; - const out: Record<string, unknown> = {}; - for (const key of Object.keys(rec).sort()) { - out[key] = stableSort(rec[key]); - } - return out; - } - return value; -} - -function generateRandomTicketIdChunk(input: { - readonly alphabet: string; - readonly length: number; -}): string { - let chunkText = ""; - while (chunkText.length < input.length) { - const chunk = randomBytes(input.length); - for (const byte of chunk) { - chunkText += input.alphabet[byte % input.alphabet.length] ?? ""; - if (chunkText.length === input.length) { - break; - } - } - } - return chunkText; -} diff --git a/src/control-plane/sdk/config.ts b/src/control-plane/sdk/config.ts index 34797ebe..cff7409e 100644 --- a/src/control-plane/sdk/config.ts +++ b/src/control-plane/sdk/config.ts @@ -21,24 +21,6 @@ const ExtensionEnablementSchema = z.object({ config: z.record(z.string(), z.unknown()).default({}), }); -const TicketsGitRefModeSchema = z.enum(["heads", "hidden"]); - -const TicketsGitConfigInputSchema = z.object({ - enabled: z.boolean().optional(), - branch: z.string().optional(), - remote: z.string().optional(), - forceBareClone: z.boolean().optional(), - refMode: TicketsGitRefModeSchema.optional(), -}); - -const TicketsGitConfigSchema = z.object({ - enabled: z.boolean().default(true), - branch: z.string().default("hack/tickets"), - remote: z.string().default("origin"), - forceBareClone: z.boolean().default(false), - refMode: TicketsGitRefModeSchema.default("hidden"), -}); - const SupervisorConfigInputSchema = z.object({ enabled: z.boolean().optional(), maxConcurrentJobs: z.number().int().positive().optional(), @@ -349,11 +331,6 @@ const PreferencesConfigSchema = z.object({ const ControlPlaneConfigInputSchema = z.object({ extensions: z.record(z.string(), ExtensionEnablementInputSchema).optional(), - tickets: z - .object({ - git: TicketsGitConfigInputSchema.optional(), - }) - .optional(), supervisor: SupervisorConfigInputSchema.optional(), tui: TuiConfigInputSchema.optional(), usage: UsageConfigInputSchema.optional(), @@ -370,11 +347,6 @@ const ControlPlaneConfigInputSchema = z.object({ const ControlPlaneConfigSchema = z.object({ extensions: z.record(z.string(), ExtensionEnablementSchema).default({}), - tickets: z - .object({ - git: TicketsGitConfigSchema, - }) - .default({ git: TicketsGitConfigSchema.parse({}) }), supervisor: SupervisorConfigSchema.default(SupervisorConfigSchema.parse({})), tui: TuiConfigSchema.default(TuiConfigSchema.parse({})), usage: UsageConfigSchema.default(UsageConfigSchema.parse({})), @@ -394,8 +366,6 @@ const ControlPlaneConfigSchema = z.object({ export type ControlPlaneConfig = z.infer<typeof ControlPlaneConfigSchema>; export type DaemonConfig = z.infer<typeof DaemonConfigSchema>; export type DaemonLaunchdConfig = z.infer<typeof DaemonLaunchdConfigSchema>; -export type TicketsGitConfig = z.infer<typeof TicketsGitConfigSchema>; -export type TicketsGitRefMode = z.infer<typeof TicketsGitRefModeSchema>; export type ClusterConfig = z.infer<typeof ClusterConfigSchema>; export type ProjectExecutionMode = z.infer<typeof ProjectExecutionModeSchema>; export type ExecutionSyncEngine = z.infer<typeof ExecutionSyncEngineSchema>; diff --git a/src/lib/doctor-generated-files.ts b/src/lib/doctor-generated-files.ts index c76610e2..12a2bc25 100644 --- a/src/lib/doctor-generated-files.ts +++ b/src/lib/doctor-generated-files.ts @@ -16,12 +16,6 @@ export type TrackedGeneratedFilesInspection = { * Intentionally excluded: `<dir>/hack.env.local.yaml` — older repos may track * it on purpose as the shared `--env local` overlay (legacy compatibility in * `project-env-config.ts`), so a tracked copy is not treated as a leak. - * - * `<dir>/tickets` is included because the tickets extension's local git - * cache (`<dir>/tickets/git/bare.git`, `.../worktree`) is machine-local - * working state that syncs via the hidden `refs/hack/tickets` ref — see - * `docs/guides/tickets.md`. No project is documented to intentionally commit - * it, so a tracked copy is always treated as a leak. */ export function buildGeneratedFilePathspecs(opts: { readonly projectDirName: string; @@ -33,7 +27,6 @@ export function buildGeneratedFilePathspecs(opts: { `${dir}/.env`, `${dir}/.env.state.json`, `${dir}/hack.env.*.local.yaml`, - `${dir}/tickets`, PROJECT_ENV_KEY_FILENAME, ]; } diff --git a/src/lib/project-views.ts b/src/lib/project-views.ts index 8f9e2651..e1617f55 100644 --- a/src/lib/project-views.ts +++ b/src/lib/project-views.ts @@ -897,8 +897,6 @@ async function resolveProjectExtensions(opts: { function mapExtensionFeature(id: string): string | null { switch (id) { - case "dance.hack.tickets": - return "tickets"; case "dance.hack.cloudflare": return "cloudflare"; case "dance.hack.railway": diff --git a/src/mcp/agent-docs.ts b/src/mcp/agent-docs.ts index a9770a17..eb3e24ca 100644 --- a/src/mcp/agent-docs.ts +++ b/src/mcp/agent-docs.ts @@ -92,7 +92,7 @@ export async function getExistingAgentDocs(opts: { * * Reports `stale` when the markers exist but the wrapped content no longer * matches the current render (normalized comparison), so `setup sync --check` - * can flag content drift and auto-sync can repair it. + * can flag content drift and an explicit sync can repair it. */ export async function checkAgentDocs(opts: { readonly projectRoot: string; diff --git a/src/templates.ts b/src/templates.ts index a40a8d25..5a652f5a 100644 --- a/src/templates.ts +++ b/src/templates.ts @@ -318,7 +318,6 @@ export const HACK_DIR_GITIGNORE_ENTRIES = [ ".env.state.json", "hack.env.local.yaml", "hack.env.*.local.yaml", - "tickets/", ] as const; /** @@ -687,23 +686,6 @@ export function renderProjectConfigSchemaJson(): string { }, }, }, - tickets: { - type: "object", - additionalProperties: true, - properties: { - git: { - type: "object", - additionalProperties: true, - properties: { - enabled: { type: "boolean" }, - branch: { type: "string" }, - remote: { type: "string" }, - forceBareClone: { type: "boolean" }, - refMode: { type: "string" }, - }, - }, - }, - }, supervisor: { type: "object", additionalProperties: true, diff --git a/src/tui/tickets-tui.ts b/src/tui/tickets-tui.ts deleted file mode 100644 index 7c353a10..00000000 --- a/src/tui/tickets-tui.ts +++ /dev/null @@ -1,1606 +0,0 @@ -// biome-ignore-all lint/complexity/noExcessiveCognitiveComplexity: The tickets TUI keeps overlay and keyboard interaction state inline with renderer wiring. -import { - BoxRenderable, - createCliRenderer, - createTextAttributes, - dim, - fg, - InputRenderable, - RenderableEvents, - RGBA, - ScrollBoxRenderable, - SelectRenderable, - SelectRenderableEvents, - StyledText, - type TextChunk, - TextRenderable, - t, -} from "@opentui/core"; -import type { - TicketEvent, - TicketSummary, -} from "../control-plane/extensions/tickets/store.ts"; -import { createTicketsStore } from "../control-plane/extensions/tickets/store.ts"; -import type { ControlPlaneConfig } from "../control-plane/sdk/config.ts"; -import { gumFormat, isGumAvailable } from "../ui/gum.ts"; -import type { Logger } from "../ui/logger.ts"; - -/** Matches markdown heading lines (e.g., "# Title", "## Section") */ -const MARKDOWN_HEADING_REGEX = /^#{1,6}\s+(.+)$/; - -/** Matches priority lines in ticket metadata (e.g., "priority: high") */ -const PRIORITY_LINE_REGEX = /^priority\s*:\s*(.+)$/i; - -/** Matches markdown checkbox list items (e.g., "- [x] Done", "- [ ] Todo") */ -const CHECKBOX_LIST_ITEM_REGEX = /^\s*[-*]\s*\[(?:x| )\]\s+(.+)$/i; - -/** Matches markdown bullet list items (e.g., "- item", "* item") */ -const BULLET_LIST_ITEM_REGEX = /^\s*[-*]\s+(.+)$/; - -/** Matches markdown ordered list items (e.g., "1. item") */ -const ORDERED_LIST_ITEM_REGEX = /^\s*\d+\.\s+(.+)$/; - -/** Matches markdown links to extract label and URL (e.g., "[label](url)") */ -const MARKDOWN_LINK_REGEX = /\[([^\]]+)\]\(([^)]+)\)/; - -/** Matches HTTP/HTTPS URLs */ -const URL_REGEX = /^https?:\/\//i; - -const STATUS_LABELS: Record<string, string> = { - open: "open", - in_progress: "in_progress", - blocked: "blocked", - done: "done", -}; - -const STATUS_OPTIONS = ["open", "in_progress", "blocked", "done"] as const; - -type StatusOption = (typeof STATUS_OPTIONS)[number]; - -type TicketsTuiOptions = { - readonly projectRoot: string; - readonly projectId?: string; - readonly projectName?: string; - readonly controlPlaneConfig: ControlPlaneConfig; - readonly logger: Logger; -}; - -type MarkdownCacheEntry = { - readonly updatedAt: string; - readonly content: StyledText | string; -}; - -class WrappedTextRenderable extends TextRenderable { - protected override onResize(width: number, height: number): void { - super.onResize(width, height); - if (this.wrapMode !== "none" && width > 0) { - this.textBufferView.setWrapWidth(width); - } - } - - public syncWrapWidth(): void { - const width = Math.floor(this.width); - if (this.wrapMode !== "none" && width > 0) { - this.textBufferView.setWrapWidth(width); - } - } -} - -export async function runTicketsTui({ - projectRoot, - projectId, - projectName, - controlPlaneConfig, - logger, -}: TicketsTuiOptions): Promise<number> { - if (!process.stdout.isTTY) { - logger.error({ - message: - "Tickets TUI requires a TTY. Run this from an interactive terminal.", - }); - return 1; - } - - let renderer: Awaited<ReturnType<typeof createCliRenderer>> | null = null; - let running = true; - let activePane: "list" | "body" | "history" = "list"; - let toastTimer: ReturnType<typeof setTimeout> | null = null; - let detailToken = 0; - let detailTimer: ReturnType<typeof setTimeout> | null = null; - - let ticketsCache: TicketSummary[] = []; - let ticketsById = new Map<string, TicketSummary>(); - let eventsByTicket = new Map<string, readonly TicketEvent[]>(); - - const markdownCache = new Map<string, MarkdownCacheEntry>(); - - const store = createTicketsStore({ - projectRoot, - projectId, - projectName, - controlPlaneConfig, - logger, - }); - - const shutdownRenderer = () => { - if (!renderer) { - return; - } - renderer.stop(); - renderer.destroy(); - renderer = null; - }; - - const handleFatal = (error: unknown) => { - const message = error instanceof Error ? error.message : "Unknown error"; - shutdownRenderer(); - process.stderr.write(`Tickets TUI failed: ${message}\n`); - }; - - const handleSignal = () => { - void shutdown(); - }; - - process.on("SIGINT", handleSignal); - process.on("SIGTERM", handleSignal); - - const shutdown = () => { - if (!running) { - return; - } - running = false; - if (toastTimer) { - clearTimeout(toastTimer); - toastTimer = null; - } - if (detailTimer) { - clearTimeout(detailTimer); - detailTimer = null; - } - process.off("SIGINT", handleSignal); - process.off("SIGTERM", handleSignal); - shutdownRenderer(); - }; - - try { - process.env.OTUI_USE_CONSOLE = "false"; - const activeRenderer = await createCliRenderer({ - targetFps: 30, - exitOnCtrlC: false, - useConsole: false, - openConsoleOnError: false, - useAlternateScreen: true, - useMouse: true, - }); - renderer = activeRenderer; - activeRenderer.setBackgroundColor("#0f111a"); - - const root = new BoxRenderable(activeRenderer, { - id: "tickets-tui-root", - width: "100%", - height: "100%", - flexDirection: "column", - backgroundColor: "#0f111a", - }); - - const header = new BoxRenderable(activeRenderer, { - id: "tickets-tui-header", - width: "100%", - height: 3, - minHeight: 3, - paddingLeft: 2, - paddingRight: 2, - paddingTop: 1, - paddingBottom: 1, - backgroundColor: "#141b2d", - }); - - const headerText = new TextRenderable(activeRenderer, { - id: "tickets-tui-header-text", - content: buildHeaderLabel({ projectName }), - }); - - header.add(headerText); - - const main = new BoxRenderable(activeRenderer, { - id: "tickets-tui-main", - width: "100%", - flexGrow: 1, - flexDirection: "row", - gap: 1, - paddingLeft: 1, - paddingRight: 1, - paddingTop: 1, - paddingBottom: 1, - }); - - const listBox = new BoxRenderable(activeRenderer, { - id: "tickets-tui-list", - width: "35%", - minWidth: 28, - border: true, - borderColor: "#4a5374", - backgroundColor: "#131829", - title: "Tickets", - titleAlignment: "left", - flexDirection: "column", - }); - - const ticketsSelect = new SelectRenderable(activeRenderer, { - id: "tickets-tui-list-select", - width: "100%", - height: "100%", - backgroundColor: "#131829", - focusedBackgroundColor: "#131829", - textColor: "#c7d0ff", - focusedTextColor: "#c7d0ff", - selectedBackgroundColor: "#1f2540", - selectedTextColor: "#9ad7ff", - descriptionColor: "#6b7390", - selectedDescriptionColor: "#7ea0d6", - showDescription: true, - showScrollIndicator: false, - wrapSelection: true, - options: [{ name: "Loading tickets...", description: "", value: null }], - }); - - listBox.add(ticketsSelect); - - const detailBox = new BoxRenderable(activeRenderer, { - id: "tickets-tui-detail", - flexGrow: 1, - border: false, - flexDirection: "column", - gap: 1, - }); - - const metaBox = new BoxRenderable(activeRenderer, { - id: "tickets-tui-meta", - width: "100%", - minHeight: 8, - height: 8, - border: true, - borderColor: "#4a5374", - backgroundColor: "#141b2d", - title: "Ticket", - titleAlignment: "left", - paddingLeft: 1, - paddingRight: 1, - paddingTop: 1, - paddingBottom: 1, - }); - - const metaText = new TextRenderable(activeRenderer, { - id: "tickets-tui-meta-text", - content: t`${dim("Select a ticket to view details.")}`, - }); - - metaBox.add(metaText); - - const bodyBox = new BoxRenderable(activeRenderer, { - id: "tickets-tui-body", - width: "100%", - flexGrow: 1, - border: true, - borderColor: "#4a5374", - backgroundColor: "#0f111a", - title: "Body", - titleAlignment: "left", - }); - - const bodyScroll = new ScrollBoxRenderable(activeRenderer, { - id: "tickets-tui-body-scroll", - flexGrow: 1, - rootOptions: { backgroundColor: "#0f111a" }, - wrapperOptions: { backgroundColor: "#0f111a" }, - viewportOptions: { backgroundColor: "#0f111a" }, - contentOptions: { backgroundColor: "#0f111a" }, - scrollbarOptions: { - trackOptions: { - foregroundColor: "#3b4160", - backgroundColor: "#151a2a", - }, - }, - }); - - const bodyText = new WrappedTextRenderable(activeRenderer, { - id: "tickets-tui-body-text", - width: "100%", - wrapMode: "word", - content: "", - selectable: true, - selectionBg: "#2b3355", - selectionFg: "#e6f1ff", - }); - - bodyScroll.add(bodyText); - bodyBox.add(bodyScroll); - - const historyBox = new BoxRenderable(activeRenderer, { - id: "tickets-tui-history", - width: "100%", - minHeight: 8, - height: 8, - border: true, - borderColor: "#4a5374", - backgroundColor: "#121520", - title: "History", - titleAlignment: "left", - }); - - const historyScroll = new ScrollBoxRenderable(activeRenderer, { - id: "tickets-tui-history-scroll", - flexGrow: 1, - rootOptions: { backgroundColor: "#121520" }, - wrapperOptions: { backgroundColor: "#121520" }, - viewportOptions: { backgroundColor: "#121520" }, - contentOptions: { backgroundColor: "#121520" }, - scrollbarOptions: { - trackOptions: { - foregroundColor: "#3b4160", - backgroundColor: "#151a2a", - }, - }, - }); - - const historyText = new WrappedTextRenderable(activeRenderer, { - id: "tickets-tui-history-text", - width: "100%", - wrapMode: "word", - content: "", - selectable: true, - selectionBg: "#2b3355", - selectionFg: "#e6f1ff", - }); - - historyScroll.add(historyText); - historyBox.add(historyScroll); - - detailBox.add(metaBox); - detailBox.add(bodyBox); - detailBox.add(historyBox); - - const footer = new BoxRenderable(activeRenderer, { - id: "tickets-tui-footer", - width: "100%", - height: 3, - minHeight: 3, - paddingLeft: 2, - paddingRight: 2, - paddingTop: 1, - paddingBottom: 1, - backgroundColor: "#141828", - flexDirection: "row", - alignItems: "center", - justifyContent: "space-between", - gap: 2, - }); - - const footerShortcutsText = new TextRenderable(activeRenderer, { - id: "tickets-tui-footer-shortcuts", - content: "", - }); - - const footerToastText = new TextRenderable(activeRenderer, { - id: "tickets-tui-footer-toast", - content: "", - }); - - footer.add(footerShortcutsText); - footer.add(footerToastText); - - const overlay = new BoxRenderable(activeRenderer, { - id: "tickets-tui-overlay", - position: "absolute", - top: 0, - left: 0, - width: "100%", - height: "100%", - backgroundColor: "#0b0f1a", - opacity: 1, - zIndex: 1000, - alignItems: "center", - justifyContent: "center", - visible: false, - shouldFill: true, - }); - - const overlayPanel = new BoxRenderable(activeRenderer, { - id: "tickets-tui-overlay-panel", - width: "70%", - maxWidth: 100, - border: true, - borderColor: "#2f344a", - backgroundColor: "#141828", - padding: 1, - flexDirection: "column", - gap: 1, - shouldFill: true, - }); - - const overlayTitle = new TextRenderable(activeRenderer, { - id: "tickets-tui-overlay-title", - content: "", - }); - - const overlayHint = new TextRenderable(activeRenderer, { - id: "tickets-tui-overlay-hint", - content: t`${dim("Enter to confirm | Esc to cancel | Tab to move")}`, - }); - - const overlayStatus = new TextRenderable(activeRenderer, { - id: "tickets-tui-overlay-status", - content: t`${dim("Ready")}`, - }); - - const titleLabel = new TextRenderable(activeRenderer, { - id: "tickets-tui-new-title-label", - content: t`${dim("Title")}`, - }); - - const titleInput = new InputRenderable(activeRenderer, { - id: "tickets-tui-new-title-input", - width: "100%", - height: 1, - backgroundColor: "#0f111a", - focusedBackgroundColor: "#141c2a", - textColor: "#c0caf5", - focusedTextColor: "#c0caf5", - placeholder: "Short title", - placeholderColor: "#5c637a", - }); - - const overlayFieldBorderColor = "#2f344a"; - const overlayFieldFocusBorderColor = "#7dcfff"; - - const wrapOverlayInput = (opts: { - readonly id: string; - readonly child: InputRenderable; - }) => { - const frame = new BoxRenderable(activeRenderer, { - id: opts.id, - width: "100%", - height: 3, - border: true, - borderColor: overlayFieldBorderColor, - backgroundColor: "#0f111a", - paddingLeft: 1, - paddingRight: 1, - paddingTop: 1, - paddingBottom: 1, - shouldFill: true, - }); - frame.add(opts.child); - return frame; - }; - - const bindOverlayFieldFocus = (opts: { - readonly field: InputRenderable; - readonly frame: BoxRenderable; - }) => { - opts.field.on(RenderableEvents.FOCUSED, () => { - opts.frame.borderColor = overlayFieldFocusBorderColor; - opts.frame.requestRender(); - }); - opts.field.on(RenderableEvents.BLURRED, () => { - opts.frame.borderColor = overlayFieldBorderColor; - opts.frame.requestRender(); - }); - }; - - const titleInputFrame = wrapOverlayInput({ - id: "tickets-tui-new-title-frame", - child: titleInput, - }); - - const bodyLabel = new TextRenderable(activeRenderer, { - id: "tickets-tui-new-body-label", - content: t`${dim("Body")}`, - }); - - const bodyInput = new InputRenderable(activeRenderer, { - id: "tickets-tui-new-body-input", - width: "100%", - height: 1, - backgroundColor: "#0f111a", - focusedBackgroundColor: "#141c2a", - textColor: "#c0caf5", - focusedTextColor: "#c0caf5", - placeholder: "Optional summary (one line)", - placeholderColor: "#5c637a", - }); - - const bodyInputFrame = wrapOverlayInput({ - id: "tickets-tui-new-body-frame", - child: bodyInput, - }); - - const statusSelectLabel = new TextRenderable(activeRenderer, { - id: "tickets-tui-status-label", - content: t`${dim("Status")}`, - }); - - const statusSelect = new SelectRenderable(activeRenderer, { - id: "tickets-tui-status-select", - width: "100%", - height: 4, - backgroundColor: "#0f111a", - focusedBackgroundColor: "#0f111a", - textColor: "#c7d0ff", - focusedTextColor: "#c7d0ff", - selectedBackgroundColor: "#1f2540", - selectedTextColor: "#9ad7ff", - descriptionColor: "#6b7390", - selectedDescriptionColor: "#7ea0d6", - showDescription: false, - showScrollIndicator: false, - wrapSelection: true, - options: STATUS_OPTIONS.map((value) => ({ - name: STATUS_LABELS[value] ?? value, - value, - description: "", - })), - }); - - const overlayState = { - mode: "none" as "none" | "new" | "status", - focusIndex: 0, - focusables: [] as Array<InputRenderable | SelectRenderable>, - }; - - overlayPanel.add(overlayTitle); - overlayPanel.add(overlayHint); - overlayPanel.add(titleLabel); - overlayPanel.add(titleInputFrame); - overlayPanel.add(bodyLabel); - overlayPanel.add(bodyInputFrame); - overlayPanel.add(statusSelectLabel); - overlayPanel.add(statusSelect); - overlayPanel.add(overlayStatus); - - overlay.add(overlayPanel); - - root.add(header); - root.add(main); - root.add(footer); - root.add(overlay); - - main.add(listBox); - main.add(detailBox); - - activeRenderer.root.add(root); - - bindOverlayFieldFocus({ field: titleInput, frame: titleInputFrame }); - bindOverlayFieldFocus({ field: bodyInput, frame: bodyInputFrame }); - - const setToast = (opts: { - readonly message: string; - readonly tone?: "info" | "warn"; - }) => { - if (toastTimer) { - clearTimeout(toastTimer); - } - const tone = opts.tone ?? "info"; - footerToastText.content = - tone === "warn" - ? t`${fg("#e0af68")(`${opts.message}`)}` - : t`${fg("#7dcfff")(`${opts.message}`)}`; - footer.requestRender(); - toastTimer = setTimeout(() => { - footerToastText.content = ""; - footer.requestRender(); - }, 3000); - }; - - const renderFooter = () => { - const parts = [ - t`${fg("#9ad7ff")("n")}${dim(":new")}`, - t`${fg("#9ad7ff")("s")}${dim(":status")}`, - t`${fg("#9ad7ff")("r")}${dim(":refresh")}`, - t`${fg("#9ad7ff")("tab")}${dim(":switch")}`, - t`${fg("#9ad7ff")("q")}${dim(":quit")}`, - ]; - footerShortcutsText.content = joinStyledText({ parts, separator: " " }); - footer.requestRender(); - }; - - const setActivePane = (pane: "list" | "body" | "history") => { - activePane = pane; - listBox.borderColor = pane === "list" ? "#7dcfff" : "#4a5374"; - bodyBox.borderColor = pane === "body" ? "#7dcfff" : "#4a5374"; - historyBox.borderColor = pane === "history" ? "#7dcfff" : "#4a5374"; - listBox.requestRender(); - bodyBox.requestRender(); - historyBox.requestRender(); - }; - - const focusPane = (pane: "list" | "body" | "history") => { - if (pane === "list") { - ticketsSelect.focus(); - } else if (pane === "body") { - bodyScroll.focus(); - } else { - historyScroll.focus(); - } - }; - - const focusNextPane = (direction: number) => { - const panes: Array<"list" | "body" | "history"> = [ - "list", - "body", - "history", - ]; - const idx = panes.indexOf(activePane); - const next = - (((idx + direction) % panes.length) + panes.length) % panes.length; - const nextPane = panes[next] ?? "list"; - setActivePane(nextPane); - focusPane(nextPane); - }; - - const selectedTicketId = () => { - return ticketsSelect.getSelectedOption()?.value ?? null; - }; - - const formatTicketRow = (ticket: TicketSummary) => { - const updated = formatTimestamp(ticket.updatedAt); - const label = STATUS_LABELS[ticket.status] ?? ticket.status; - const title = ticket.title || "(untitled)"; - return { - name: `${ticket.ticketId} [${label}] ${title}`, - description: updated, - value: ticket.ticketId, - }; - }; - - const updateTicketsList = (tickets: TicketSummary[]) => { - if (tickets.length === 0) { - ticketsSelect.options = [ - { name: "No tickets yet.", description: "", value: null }, - ]; - ticketsSelect.setSelectedIndex(0); - return; - } - - const options = tickets.map(formatTicketRow); - const current = selectedTicketId(); - ticketsSelect.options = options; - const idx = current - ? options.findIndex((option) => option.value === current) - : 0; - ticketsSelect.setSelectedIndex(idx >= 0 ? idx : 0); - }; - - const renderMeta = (ticket: TicketSummary | null) => { - if (!ticket) { - metaText.content = t`${dim("Select a ticket to view details.")}`; - return; - } - - const meta = ticket.body - ? parseTicketBodyMeta({ body: ticket.body }) - : emptyTicketBodyMeta(); - const created = formatTimestamp(ticket.createdAt); - const updated = formatTimestamp(ticket.updatedAt); - const status = STATUS_LABELS[ticket.status] ?? ticket.status; - const dependencies = renderDependencyMeta({ - dependsOn: ticket.dependsOn, - blocks: ticket.blocks, - }); - const lines = [ - t`${fg("#9ad7ff")(`${ticket.ticketId}`)} ${fg("#c0caf5")(`${ticket.title}`)}`, - t`${dim(`status: ${status}`)}`, - ...dependencies, - ...renderMetaExtras(meta), - t`${dim(`created: ${created}`)}`, - t`${dim(`updated: ${updated}`)}`, - ...(ticket.projectName - ? [t`${dim(`project: ${ticket.projectName}`)}`] - : []), - ...(ticket.projectId - ? [t`${dim(`project id: ${ticket.projectId}`)}`] - : []), - ]; - - metaText.content = joinStyledText({ parts: lines, separator: "\n" }); - }; - - const renderBody = async (opts: { - readonly ticket: TicketSummary | null; - readonly token: number; - }) => { - const ticket = opts.ticket; - if (!ticket?.body) { - bodyText.content = ticket - ? t`${dim("No body provided.")}` - : t`${dim("Select a ticket.")}`; - bodyText.syncWrapWidth(); - return; - } - - const cacheKey = ticket.ticketId; - const cached = markdownCache.get(cacheKey); - if (cached && cached.updatedAt === ticket.updatedAt) { - bodyText.content = cached.content; - bodyText.syncWrapWidth(); - return; - } - - const normalized = normalizeTicketBody({ body: ticket.body }); - const rendered = await renderMarkdown({ markdown: normalized }); - if (opts.token !== detailToken) { - return; - } - - markdownCache.set(cacheKey, { - updatedAt: ticket.updatedAt, - content: rendered, - }); - bodyText.content = rendered; - bodyText.syncWrapWidth(); - }; - - const renderHistory = (events: readonly TicketEvent[]) => { - if (events.length === 0) { - historyText.content = t`${dim("No history yet.")}`; - historyText.syncWrapWidth(); - return; - } - const lines = events.map(formatEventLine); - historyText.content = lines.join("\n"); - historyText.syncWrapWidth(); - }; - - const refreshDetails = async () => { - const token = ++detailToken; - const ticketId = selectedTicketId(); - if (!ticketId) { - renderMeta(null); - await renderBody({ ticket: null, token }); - renderHistory([]); - return; - } - - const ticket = ticketsById.get(ticketId) ?? null; - renderMeta(ticket); - await renderBody({ ticket, token }); - if (token !== detailToken) { - return; - } - const events = eventsByTicket.get(ticketId) ?? []; - renderHistory(events); - }; - - const refreshTickets = async () => { - const snapshot = await store.readSnapshot(); - ticketsCache = [...snapshot.tickets]; - ticketsById = new Map( - ticketsCache.map((ticket) => [ticket.ticketId, ticket]) - ); - eventsByTicket = new Map(snapshot.eventsByTicket); - updateTicketsList(ticketsCache); - await refreshDetails(); - }; - - const scheduleRefreshDetails = () => { - if (detailTimer) { - clearTimeout(detailTimer); - } - detailTimer = setTimeout(() => { - detailTimer = null; - void refreshDetails(); - }, 80); - }; - - const openOverlay = (mode: "new" | "status") => { - overlay.visible = true; - overlay.requestRender(); - overlayState.mode = mode; - overlayState.focusIndex = 0; - overlayStatus.content = t`${dim("Ready")}`; - - if (mode === "new") { - overlayTitle.content = t`${fg("#9ad7ff")("New ticket")}`; - titleLabel.visible = true; - titleInputFrame.visible = true; - bodyLabel.visible = true; - bodyInputFrame.visible = true; - statusSelectLabel.visible = false; - statusSelect.visible = false; - titleInput.value = ""; - bodyInput.value = ""; - overlayState.focusables = [titleInput, bodyInput]; - } else { - overlayTitle.content = t`${fg("#9ad7ff")("Update status")}`; - titleLabel.visible = false; - titleInputFrame.visible = false; - bodyLabel.visible = false; - bodyInputFrame.visible = false; - statusSelectLabel.visible = true; - statusSelect.visible = true; - const current = selectedTicketId(); - if (current) { - void store.getTicket({ ticketId: current }).then((ticket) => { - const idx = STATUS_OPTIONS.indexOf(ticket?.status ?? "open"); - statusSelect.setSelectedIndex(idx >= 0 ? idx : 0); - }); - } - overlayState.focusables = [statusSelect]; - } - - overlayState.focusables[0]?.focus(); - }; - - const closeOverlay = () => { - overlay.visible = false; - overlayState.mode = "none"; - overlayState.focusables = []; - overlay.requestRender(); - setActivePane(activePane); - focusPane(activePane); - }; - - const submitOverlay = async () => { - if (overlayState.mode === "new") { - const title = titleInput.value.trim(); - const body = bodyInput.value.trim(); - if (!title) { - overlayStatus.content = t`${fg("#e0af68")("Title is required.")}`; - return; - } - - overlayStatus.content = t`${fg("#7dcfff")("Creating ticket...")}`; - const created = await store.createTicket({ - title, - body: body.length > 0 ? body : undefined, - }); - - if (!created.ok) { - overlayStatus.content = t`${fg("#f7768e")(`${created.error}`)}`; - return; - } - - closeOverlay(); - await refreshTickets(); - const index = ticketsSelect.options.findIndex( - (option) => option.value === created.ticket.ticketId - ); - ticketsSelect.setSelectedIndex(index >= 0 ? index : 0); - setToast({ message: `Created ${created.ticket.ticketId}` }); - return; - } - - if (overlayState.mode === "status") { - const ticketId = selectedTicketId(); - if (!ticketId) { - overlayStatus.content = t`${fg("#e0af68")("Select a ticket first.")}`; - return; - } - - const next = statusSelect.getSelectedOption()?.value as - | StatusOption - | undefined; - if (!next) { - overlayStatus.content = t`${fg("#e0af68")("Select a status.")}`; - return; - } - - overlayStatus.content = t`${fg("#7dcfff")("Updating status...")}`; - const updated = await store.setStatus({ ticketId, status: next }); - if (!updated.ok) { - overlayStatus.content = t`${fg("#f7768e")(`${updated.error}`)}`; - return; - } - - closeOverlay(); - await refreshTickets(); - setToast({ message: `Updated ${ticketId} -> ${next}` }); - } - }; - - ticketsSelect.on(SelectRenderableEvents.SELECTION_CHANGED, () => { - scheduleRefreshDetails(); - }); - - ticketsSelect.on(RenderableEvents.FOCUSED, () => { - setActivePane("list"); - }); - - bodyScroll.on(RenderableEvents.FOCUSED, () => { - setActivePane("body"); - }); - - historyScroll.on(RenderableEvents.FOCUSED, () => { - setActivePane("history"); - }); - - renderFooter(); - setActivePane("list"); - - activeRenderer.keyInput.on("keypress", (key) => { - if (overlayState.mode !== "none") { - if (key.name === "escape") { - key.preventDefault(); - closeOverlay(); - return; - } - if (key.name === "tab") { - key.preventDefault(); - const total = overlayState.focusables.length; - if (total === 0) { - return; - } - const next = - (overlayState.focusIndex + (key.shift ? -1 : 1) + total) % total; - overlayState.focusIndex = next; - overlayState.focusables[next]?.focus(); - return; - } - if ( - key.name === "enter" || - key.name === "return" || - key.name === "linefeed" - ) { - key.preventDefault(); - void submitOverlay(); - return; - } - return; - } - - if (key.name === "q" || (key.ctrl && key.name === "c")) { - key.preventDefault(); - void shutdown(); - return; - } - - if (key.name === "tab") { - key.preventDefault(); - focusNextPane(key.shift ? -1 : 1); - return; - } - - if (key.name === "n" && !key.ctrl && !key.meta) { - key.preventDefault(); - openOverlay("new"); - return; - } - - if (key.name === "s" && !key.ctrl && !key.meta) { - key.preventDefault(); - openOverlay("status"); - return; - } - - if (key.name === "r" && !key.ctrl && !key.meta) { - key.preventDefault(); - void refreshTickets().then(() => - setToast({ message: "Refreshed tickets" }) - ); - } - }); - - await refreshTickets(); - setActivePane(activePane); - ticketsSelect.focus(); - activeRenderer.start(); - - return await new Promise<number>((resolve) => { - const interval = setInterval(() => { - if (!running) { - clearInterval(interval); - resolve(0); - } - }, 100); - }); - } catch (error: unknown) { - await handleFatal(error); - return 1; - } -} - -function buildHeaderLabel(opts: { readonly projectName?: string }): StyledText { - if (!opts.projectName) { - return t`${fg("#9ad7ff")("tickets")}${dim(" | hack")}`; - } - return t`${fg("#9ad7ff")("tickets")}${dim(` | ${opts.projectName}`)}`; -} - -function joinStyledText(opts: { - readonly parts: StyledText[]; - readonly separator?: string; -}): StyledText { - if (opts.parts.length === 0) { - return new StyledText([]); - } - const first = opts.parts[0]; - if (opts.parts.length === 1 && first) { - return first; - } - - const chunks: TextChunk[] = []; - const separator = opts.separator ?? " "; - for (const part of opts.parts) { - chunks.push(...part.chunks); - if (part !== opts.parts[opts.parts.length - 1]) { - chunks.push({ __isChunk: true, text: separator }); - } - } - return new StyledText(chunks); -} - -function formatTimestamp(iso: string): string { - const date = new Date(iso); - if (Number.isNaN(date.getTime())) { - return iso; - } - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, "0"); - const day = String(date.getDate()).padStart(2, "0"); - const hours = String(date.getHours()).padStart(2, "0"); - const mins = String(date.getMinutes()).padStart(2, "0"); - return `${year}-${month}-${day} ${hours}:${mins}`; -} - -function formatEventLine(event: TicketEvent): string { - const timestamp = formatTimestamp(event.tsIso); - const actor = event.actor ? ` ${event.actor}` : ""; - if (event.type === "ticket.status_changed") { - const next = - typeof event.payload.status === "string" ? event.payload.status : ""; - return `${timestamp} status -> ${next}${actor}`; - } - if (event.type === "ticket.updated") { - return `${timestamp} updated${actor}`; - } - if (event.type === "ticket.created") { - return `${timestamp} created${actor}`; - } - return `${timestamp} ${event.type}${actor}`; -} - -async function renderMarkdown(opts: { - readonly markdown: string; -}): Promise<StyledText | string> { - const trimmed = opts.markdown.trim(); - if (trimmed.length === 0) { - return ""; - } - - if (!isGumAvailable()) { - return trimmed; - } - - const formatted = await gumFormat({ - input: trimmed, - type: "markdown", - theme: "dark", - stripAnsi: false, - }); - - if (!formatted.ok) { - return trimmed; - } - - const parsed = parseAnsiStyledText(formatted.value); - return new StyledText(parsed.chunks); -} - -function normalizeTicketBody(opts: { readonly body: string }): string { - if (!opts.body.includes("\\n") || opts.body.includes("\n")) { - return opts.body; - } - return opts.body.replaceAll("\\n", "\n"); -} - -type TicketBodyMeta = { - readonly links: readonly string[]; - readonly acceptanceCriteria: readonly string[]; - readonly priority?: string; -}; - -type MutableTicketBodyMeta = { - links: string[]; - acceptanceCriteria: string[]; - priority?: string; -}; - -function emptyTicketBodyMeta(): TicketBodyMeta { - return { - links: [], - acceptanceCriteria: [], - priority: undefined, - }; -} - -function parseTicketBodyMeta(opts: { readonly body: string }): TicketBodyMeta { - const meta: MutableTicketBodyMeta = { - links: [], - acceptanceCriteria: [], - priority: undefined, - }; - - const normalized = normalizeTicketBody({ body: opts.body }); - const lines = normalized.split("\n"); - let inCodeBlock = false; - let section: "links" | "acceptance" | null = null; - - for (const rawLine of lines) { - const line = rawLine.trim(); - if (line.startsWith("```")) { - inCodeBlock = !inCodeBlock; - continue; - } - if (inCodeBlock) { - continue; - } - - const headingMatch = line.match(MARKDOWN_HEADING_REGEX); - if (headingMatch) { - const heading = normalizeHeading(headingMatch[1] ?? ""); - section = resolveMetaSection(heading); - continue; - } - - if (!meta.priority) { - const priorityMatch = line.match(PRIORITY_LINE_REGEX); - if (priorityMatch?.[1]) { - meta.priority = priorityMatch[1].trim(); - continue; - } - } - - if (!section || line.length === 0) { - continue; - } - - const listItem = extractListItem({ line }); - if (!listItem) { - continue; - } - - if (section === "links") { - const link = extractLinkLabel({ text: listItem }) ?? listItem; - meta.links.push(link); - } else { - meta.acceptanceCriteria.push(listItem); - } - } - - return { - links: uniqueNonEmpty(meta.links), - acceptanceCriteria: uniqueNonEmpty(meta.acceptanceCriteria), - priority: meta.priority, - }; -} - -function renderMetaExtras(meta: TicketBodyMeta): StyledText[] { - const extras: StyledText[] = []; - const priority = meta.priority ? meta.priority.trim() : ""; - if (priority) { - extras.push(t`${dim("priority: ")}${fg("#c0caf5")(`${priority}`)}`); - } - - const linksSummary = summarizeMetaItems({ items: meta.links, maxItems: 3 }); - if (linksSummary) { - extras.push(t`${dim("links: ")}${fg("#9ad7ff")(`${linksSummary}`)}`); - } - - const acceptanceSummary = summarizeMetaItems({ - items: meta.acceptanceCriteria, - maxItems: 3, - }); - if (acceptanceSummary) { - extras.push( - t`${dim("acceptance: ")}${fg("#c0caf5")(`${acceptanceSummary}`)}` - ); - } - - return extras; -} - -function renderDependencyMeta(opts: { - readonly dependsOn: readonly string[]; - readonly blocks: readonly string[]; -}): StyledText[] { - const extras: StyledText[] = []; - const dependsSummary = summarizeMetaItems({ - items: opts.dependsOn, - maxItems: 3, - }); - if (dependsSummary) { - extras.push(t`${dim("depends on: ")}${fg("#e0af68")(`${dependsSummary}`)}`); - } - const blocksSummary = summarizeMetaItems({ items: opts.blocks, maxItems: 3 }); - if (blocksSummary) { - extras.push(t`${dim("blocks: ")}${fg("#9ece6a")(`${blocksSummary}`)}`); - } - return extras; -} - -function resolveMetaSection(heading: string): "links" | "acceptance" | null { - if (!heading) { - return null; - } - if ( - heading === "links" || - heading === "link" || - heading === "references" || - heading === "reference" - ) { - return "links"; - } - if ( - heading === "acceptance criteria" || - heading === "acceptance-criteria" || - heading === "acceptance" || - heading === "criteria" || - heading === "ac" - ) { - return "acceptance"; - } - return null; -} - -function extractListItem(opts: { readonly line: string }): string | null { - const checkbox = opts.line.match(CHECKBOX_LIST_ITEM_REGEX); - if (checkbox?.[1]) { - return checkbox[1].trim(); - } - - const bullet = opts.line.match(BULLET_LIST_ITEM_REGEX); - if (bullet?.[1]) { - return bullet[1].trim(); - } - - const ordered = opts.line.match(ORDERED_LIST_ITEM_REGEX); - if (ordered?.[1]) { - return ordered[1].trim(); - } - - if (looksLikeUrl(opts.line)) { - return opts.line.trim(); - } - return null; -} - -function extractLinkLabel(opts: { readonly text: string }): string | null { - const match = opts.text.match(MARKDOWN_LINK_REGEX); - if (match?.[1]) { - return match[1].trim(); - } - if (match?.[2]) { - return match[2].trim(); - } - return null; -} - -function looksLikeUrl(value: string): boolean { - return URL_REGEX.test(value.trim()); -} - -function normalizeHeading(value: string): string { - return value.trim().toLowerCase().replaceAll(/\s+/g, " "); -} - -function summarizeMetaItems(opts: { - readonly items: readonly string[]; - readonly maxItems: number; -}): string { - const cleaned = uniqueNonEmpty(opts.items); - if (cleaned.length === 0) { - return ""; - } - const limited = cleaned.slice(0, Math.max(1, opts.maxItems)); - const suffix = - cleaned.length > limited.length - ? ` (+${cleaned.length - limited.length})` - : ""; - return `${limited.join(", ")}${suffix}`; -} - -function uniqueNonEmpty(items: readonly string[]): string[] { - const out: string[] = []; - const seen = new Set<string>(); - for (const raw of items) { - const value = raw.trim(); - if (!value) { - continue; - } - if (seen.has(value)) { - continue; - } - seen.add(value); - out.push(value); - } - return out; -} - -function parseAnsiStyledText(input: string): { - readonly chunks: TextChunk[]; - readonly plain: string; - readonly hasAnsi: boolean; -} { - const pattern = /\x1b\[([0-9;]*)m/g; - let lastIndex = 0; - let match: RegExpExecArray | null; - let hasAnsi = false; - const chunks: TextChunk[] = []; - let plain = ""; - let style = createAnsiStyle(); - - while ((match = pattern.exec(input))) { - const idx = match.index; - if (idx > lastIndex) { - const text = normalizePlainText(input.slice(lastIndex, idx)); - if (text.length > 0) { - chunks.push(buildAnsiChunk({ text, style })); - plain += text; - } - } - - hasAnsi = true; - const rawCodes = match[1] ?? ""; - const codes = - rawCodes.length === 0 - ? [0] - : rawCodes - .split(";") - .map((part) => Number(part)) - .filter((n) => Number.isFinite(n)); - style = applyAnsiCodes({ style, codes }); - lastIndex = idx + match[0].length; - } - - if (lastIndex < input.length) { - const text = normalizePlainText(input.slice(lastIndex)); - if (text.length > 0) { - chunks.push(buildAnsiChunk({ text, style })); - plain += text; - } - } - - if (plain.length === 0) { - plain = normalizePlainText(stripAnsi(input)); - } - - return { - chunks, - plain, - hasAnsi, - }; -} - -type AnsiStyle = { - readonly fg?: RGBA; - readonly bg?: RGBA; - readonly bold: boolean; - readonly dim: boolean; - readonly italic: boolean; - readonly underline: boolean; - readonly strikethrough: boolean; - readonly inverse: boolean; -}; - -function createAnsiStyle(): AnsiStyle { - return { - fg: undefined, - bg: undefined, - bold: false, - dim: false, - italic: false, - underline: false, - strikethrough: false, - inverse: false, - }; -} - -function buildAnsiChunk(opts: { - readonly text: string; - readonly style: AnsiStyle; -}): TextChunk { - const attributes = createTextAttributes({ - bold: opts.style.bold, - dim: opts.style.dim, - italic: opts.style.italic, - underline: opts.style.underline, - strikethrough: opts.style.strikethrough, - inverse: opts.style.inverse, - }); - const fg = opts.style.inverse ? opts.style.bg : opts.style.fg; - const bg = opts.style.inverse ? opts.style.fg : opts.style.bg; - return { - __isChunk: true, - text: opts.text, - ...(fg ? { fg } : {}), - ...(bg ? { bg } : {}), - attributes, - }; -} - -function applyAnsiCodes(opts: { - readonly style: AnsiStyle; - readonly codes: number[]; -}): AnsiStyle { - let style = { ...opts.style }; - let i = 0; - while (i < opts.codes.length) { - const code = opts.codes[i] ?? 0; - switch (code) { - case 0: - style = createAnsiStyle(); - i += 1; - break; - case 1: - style = { ...style, bold: true }; - i += 1; - break; - case 2: - style = { ...style, dim: true }; - i += 1; - break; - case 3: - style = { ...style, italic: true }; - i += 1; - break; - case 4: - style = { ...style, underline: true }; - i += 1; - break; - case 7: - style = { ...style, inverse: true }; - i += 1; - break; - case 9: - style = { ...style, strikethrough: true }; - i += 1; - break; - case 22: - style = { ...style, bold: false, dim: false }; - i += 1; - break; - case 23: - style = { ...style, italic: false }; - i += 1; - break; - case 24: - style = { ...style, underline: false }; - i += 1; - break; - case 27: - style = { ...style, inverse: false }; - i += 1; - break; - case 29: - style = { ...style, strikethrough: false }; - i += 1; - break; - case 39: - style = { ...style, fg: undefined }; - i += 1; - break; - case 49: - style = { ...style, bg: undefined }; - i += 1; - break; - default: { - if (code >= 30 && code <= 37) { - style = { ...style, fg: ansiToRgba(code - 30, false) }; - i += 1; - break; - } - if (code >= 90 && code <= 97) { - style = { ...style, fg: ansiToRgba(code - 90, true) }; - i += 1; - break; - } - if (code >= 40 && code <= 47) { - style = { ...style, bg: ansiToRgba(code - 40, false) }; - i += 1; - break; - } - if (code >= 100 && code <= 107) { - style = { ...style, bg: ansiToRgba(code - 100, true) }; - i += 1; - break; - } - if (code === 38 || code === 48) { - const isFg = code === 38; - const next = opts.codes[i + 1]; - if (next === 5) { - const colorIndex = opts.codes[i + 2]; - if (typeof colorIndex === "number") { - const rgba = xtermToRgba(colorIndex); - style = isFg ? { ...style, fg: rgba } : { ...style, bg: rgba }; - } - i += 3; - break; - } - if (next === 2) { - const r = opts.codes[i + 2]; - const g = opts.codes[i + 3]; - const b = opts.codes[i + 4]; - if ([r, g, b].every((v) => typeof v === "number")) { - const rgba = RGBA.fromInts( - clampColor(r ?? 0), - clampColor(g ?? 0), - clampColor(b ?? 0), - 255 - ); - style = isFg ? { ...style, fg: rgba } : { ...style, bg: rgba }; - } - i += 5; - break; - } - } - i += 1; - break; - } - } - } - - return style; -} - -function clampColor(value: number): number { - if (value < 0) { - return 0; - } - if (value > 255) { - return 255; - } - return Math.round(value); -} - -function ansiToRgba(code: number, bright: boolean): RGBA { - const palette = [ - [0, 0, 0], - [205, 49, 49], - [13, 188, 121], - [229, 229, 16], - [36, 114, 200], - [188, 63, 188], - [17, 168, 205], - [229, 229, 229], - [102, 102, 102], - [241, 76, 76], - [35, 209, 139], - [245, 245, 67], - [59, 142, 234], - [214, 112, 214], - [41, 184, 219], - [255, 255, 255], - ] as const; - const idx = bright ? code + 8 : code; - const rgb = palette[idx] ?? palette[7]; - return RGBA.fromInts(rgb[0], rgb[1], rgb[2], 255); -} - -function xtermToRgba(code: number): RGBA { - if (code < 0) { - return ansiToRgba(0, false); - } - if (code < 16) { - return ansiToRgba(code % 8, code >= 8); - } - if (code >= 232) { - const shade = 8 + (code - 232) * 10; - return RGBA.fromInts(shade, shade, shade, 255); - } - - const index = code - 16; - const r = Math.floor(index / 36); - const g = Math.floor((index % 36) / 6); - const b = index % 6; - const steps = [0, 95, 135, 175, 215, 255]; - return RGBA.fromInts(steps[r] ?? 0, steps[g] ?? 0, steps[b] ?? 0, 255); -} - -function stripAnsi(text: string): string { - return text.replaceAll(/\x1b\[[0-9;]*[A-Za-z]/g, ""); -} - -function normalizePlainText(text: string): string { - const normalized = text.replaceAll("\r\n", "\n").replaceAll("\r", "\n"); - return normalized.replaceAll(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, " "); -} diff --git a/tests/agent-instruction-source.test.ts b/tests/agent-instruction-source.test.ts index 201dd1bb..63ee9d83 100644 --- a/tests/agent-instruction-source.test.ts +++ b/tests/agent-instruction-source.test.ts @@ -101,11 +101,11 @@ test("no stale command patterns in any surface", () => { } }); -test("deprecated tickets do not appear in generated agent guidance", () => { +test("retired Tickets do not appear in generated agent guidance", () => { for (const [surface, rendered] of Object.entries(RENDERED_SURFACES)) { expect( rendered, - `surface "${surface}" mentions deprecated Tickets` + `surface "${surface}" mentions retired Tickets` ).not.toMatch(/hack[ -]?tickets|dance\.hack\.tickets/i); } }); diff --git a/tests/agent-onboard-command.test.ts b/tests/agent-onboard-command.test.ts index 88010b89..74d4a817 100644 --- a/tests/agent-onboard-command.test.ts +++ b/tests/agent-onboard-command.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, expect, test } from "bun:test"; +import { afterEach, expect, test } from "bun:test"; import { mkdir, mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -10,19 +10,7 @@ type CapturedRunResult = { }; let tempDir: string | null = null; -let originalSyncMode: string | undefined; - -beforeEach(() => { - originalSyncMode = process.env.HACK_SETUP_SYNC_MODE; - process.env.HACK_SETUP_SYNC_MODE = "off"; -}); - afterEach(async () => { - if (originalSyncMode === undefined) { - Reflect.deleteProperty(process.env, "HACK_SETUP_SYNC_MODE"); - } else { - process.env.HACK_SETUP_SYNC_MODE = originalSyncMode; - } if (tempDir) { await rm(tempDir, { recursive: true, force: true }); tempDir = null; diff --git a/tests/cli-command.test.ts b/tests/cli-command.test.ts index ccc36eff..325b5e1d 100644 --- a/tests/cli-command.test.ts +++ b/tests/cli-command.test.ts @@ -13,18 +13,14 @@ type CapturedRunResult = { readonly stderr: string; }; -let originalSetupSyncMode: string | undefined; let originalLogger: string | undefined; beforeEach(() => { - originalSetupSyncMode = process.env.HACK_SETUP_SYNC_MODE; originalLogger = process.env.HACK_LOGGER; - process.env.HACK_SETUP_SYNC_MODE = "off"; process.env.HACK_LOGGER = "console"; }); afterEach(() => { - process.env.HACK_SETUP_SYNC_MODE = originalSetupSyncMode; process.env.HACK_LOGGER = originalLogger; }); @@ -73,14 +69,10 @@ test("parseOptionsForCommand converts number options", () => { expect(parsed.follow).toBe(true); }); -test("removed surfaces still resolve as top-level migration stubs", () => { - const authResolved = resolveCommand(CLI_SPEC, ["auth"]); - const linearResolved = resolveCommand(CLI_SPEC, ["linear"]); - - expect(authResolved.command?.summary).toContain("Removed:"); - expect(linearResolved.command?.summary).toContain("Removed:"); - expect(authResolved.remainingPositionals).toEqual([]); - expect(linearResolved.remainingPositionals).toEqual([]); +test("retired product surfaces no longer resolve", () => { + for (const command of ["auth", "org", "team", "linear", "tickets"]) { + expect(resolveCommand(CLI_SPEC, [command]).command).toBeNull(); + } }); test("resolveCommand exposes host exec path", () => { @@ -93,16 +85,7 @@ test("resolveCommand exposes host exec path", () => { expect(resolved.remainingPositionals).toEqual(["bun", "test"]); }); -test("help shows usage for removed namespace-style commands", async () => { - const result = await runCliWithCapturedOutput(["help", "org"]); - - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("Usage:"); - expect(result.stdout).toContain("hack org [args...]"); - expect(result.stdout).toContain("Removed in v3:"); -}); - -test("dispatch rejects removed GitHub PR automation flags with migration guidance", async () => { +test("dispatch rejects retired GitHub PR automation flags as unknown", async () => { const result = await runCliWithCapturedOutput([ "dispatch", "run", @@ -115,23 +98,7 @@ test("dispatch rejects removed GitHub PR automation flags with migration guidanc ]); expect(result.exitCode).toBe(1); - expect(result.stderr).toContain( - "Built-in GitHub PR automation was removed in Hack v3." - ); - expect(result.stderr).toContain("gh pr create"); -}); - -test("removed linear stub still emits migration guidance for legacy flags", async () => { - const result = await runCliWithCapturedOutput([ - "linear", - "status", - "--profile", - "demo", - ]); - - expect(result.exitCode).toBe(1); - expect(result.stderr).toContain("`hack linear status` was removed in v3."); - expect(result.stderr).toContain("Use repo-local tickets"); + expect(result.stderr).toContain("Unknown option '--pr'"); }); async function runCliWithCapturedOutput( diff --git a/tests/cli-help.test.ts b/tests/cli-help.test.ts index b2b9c2af..ddf8d7e4 100644 --- a/tests/cli-help.test.ts +++ b/tests/cli-help.test.ts @@ -14,15 +14,7 @@ test("root help leads with the local-first offer and new command grouping", () = expect(help).toContain("Local helpers:"); expect(help).toContain("Unsupported experimental:"); expect(help).toContain("Extension commands:"); - expect(help).toMatch( - /hack tickets(?: \[args\.\.\.\])?\s+Deprecated: legacy repo-local Tickets compatibility commands/ - ); - expect(help).toMatch( - /hack auth(?: \[args\.\.\.\])?\s+Removed: Hack account sign-in no longer ships with the local-first CLI/ - ); - expect(help).toMatch( - /hack linear(?: \[args\.\.\.\])?\s+Removed: Linear integration is no longer part of Hack v3/ - ); + expect(help).not.toMatch(/hack (?:tickets|auth|org|team|linear)\b/); expect(help).toMatch( /hack remote\s+Beta: guided remote access and gateway helpers/ ); @@ -38,12 +30,7 @@ test("markdown help preserves the local-first grouping", () => { expect(help).toContain("### Core workflows"); expect(help).toContain("### Local helpers"); expect(help).toContain("### Unsupported experimental"); - expect(help).toContain("`hack tickets [args...]`"); - expect(help).toContain( - "Deprecated: legacy repo-local Tickets compatibility commands" - ); - expect(help).toContain("`hack auth [args...]`"); - expect(help).toContain("`hack linear [args...]`"); + expect(help).not.toMatch(/`hack (?:tickets|auth|org|team|linear)\b/); }); test("experimental subcommand help stays visibly labeled experimental", () => { @@ -60,5 +47,5 @@ test("dispatch help no longer advertises built-in GitHub PR automation", () => { expect(help).toContain("hack dispatch run"); expect(help).not.toContain("create/update GitHub PR"); - expect(help).toContain("Removed in v3: legacy GitHub PR automation flag"); + expect(help).not.toMatch(/--pr(?:-|\b)|--github-profile/); }); diff --git a/tests/config-command.test.ts b/tests/config-command.test.ts index 2f55ec5e..03c12f28 100644 --- a/tests/config-command.test.ts +++ b/tests/config-command.test.ts @@ -14,18 +14,15 @@ let tempDir: string | null = null; let originalHome: string | undefined; let originalLogger: string | undefined; let originalGlobalConfigPath: string | undefined; -let originalSetupSyncMode: string | undefined; beforeEach(async () => { originalHome = process.env.HOME; originalLogger = process.env.HACK_LOGGER; originalGlobalConfigPath = process.env.HACK_GLOBAL_CONFIG_PATH; - originalSetupSyncMode = process.env.HACK_SETUP_SYNC_MODE; tempDir = await mkdtemp(join(tmpdir(), "hack-config-command-")); process.env.HOME = tempDir; process.env.HACK_LOGGER = "console"; process.env.HACK_GLOBAL_CONFIG_PATH = join(tempDir, "hack.config.json"); - process.env.HACK_SETUP_SYNC_MODE = "off"; }); afterEach(async () => { @@ -36,7 +33,6 @@ afterEach(async () => { process.env.HOME = originalHome; process.env.HACK_LOGGER = originalLogger; process.env.HACK_GLOBAL_CONFIG_PATH = originalGlobalConfigPath; - process.env.HACK_SETUP_SYNC_MODE = originalSetupSyncMode; }); test("config set --global updates extension enabled using bracket path", async () => { diff --git a/tests/control-plane-config.test.ts b/tests/control-plane-config.test.ts index e0ebffb4..cb9c8947 100644 --- a/tests/control-plane-config.test.ts +++ b/tests/control-plane-config.test.ts @@ -41,7 +41,6 @@ test("readControlPlaneConfig returns defaults when config is missing", async () const result = await readControlPlaneConfig({ projectDir }); expect(result.parseError).toBeUndefined(); - expect(result.config.tickets.git.branch).toBe("hack/tickets"); expect(result.config.supervisor.enabled).toBe(true); expect(result.config.daemon.autoStart).toBe(true); expect(result.config.daemon.launchd.runAtLoad).toBe(true); diff --git a/tests/control-plane-extensions.test.ts b/tests/control-plane-extensions.test.ts index f0e8df8e..14b3a9d9 100644 --- a/tests/control-plane-extensions.test.ts +++ b/tests/control-plane-extensions.test.ts @@ -78,7 +78,7 @@ test("ExtensionManager warns and falls back on namespace collisions", async () = id: "ext.a", version: "0.1.0", scopes: ["global"], - cliNamespace: "tickets", + cliNamespace: "example", }, commands: [], }; @@ -88,7 +88,7 @@ test("ExtensionManager warns and falls back on namespace collisions", async () = id: "ext.b", version: "0.1.0", scopes: ["global"], - cliNamespace: "tickets", + cliNamespace: "example", }, commands: [], }; @@ -97,9 +97,9 @@ test("ExtensionManager warns and falls back on namespace collisions", async () = manager.registerExtension({ extension: extB }); const resolved = manager.listExtensions(); - expect(resolved[0]?.namespace).toBe("tickets"); - expect(resolved[1]?.namespace).not.toBe("tickets"); - expect(resolved[1]?.namespace?.startsWith("tickets.")).toBe(true); + expect(resolved[0]?.namespace).toBe("example"); + expect(resolved[1]?.namespace).not.toBe("example"); + expect(resolved[1]?.namespace?.startsWith("example.")).toBe(true); expect(manager.getWarnings().length).toBe(1); }); diff --git a/tests/dispatch-command.test.ts b/tests/dispatch-command.test.ts deleted file mode 100644 index cbf99dd4..00000000 --- a/tests/dispatch-command.test.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { expect, test } from "bun:test"; - -import { __testOnlyDispatch } from "../src/commands/dispatch.ts"; - -test("removed dispatch PR automation warns without changing the migration text", () => { - const message = __testOnlyDispatch.resolveRemovedDispatchPrAutomationMessage({ - pr: true, - prBase: "main", - }); - - expect(message).not.toBeNull(); - expect(message).toContain("removed in Hack v3"); - expect(message).toContain("Dispatch still runs the remote command"); - expect(message).toContain("gh pr create"); -}); diff --git a/tests/doctor-command.test.ts b/tests/doctor-command.test.ts index dea88802..6de23f0c 100644 --- a/tests/doctor-command.test.ts +++ b/tests/doctor-command.test.ts @@ -402,11 +402,6 @@ test("doctor summary groups detailed checks into concise sections", () => { message: "1 env input file changed since materialization (run: hack env materialize)", }, - { - name: "tickets git", - status: "ok", - message: "Healthy (refs/hack/tickets)", - }, ], }); diff --git a/tests/doctor-generated-files.test.ts b/tests/doctor-generated-files.test.ts index 96f17796..05ea66f4 100644 --- a/tests/doctor-generated-files.test.ts +++ b/tests/doctor-generated-files.test.ts @@ -125,55 +125,6 @@ test("tracked .hack/hack.env.local.yaml is not flagged (legacy shared overlay)", expect(inspection?.trackedPaths).toEqual([]); }); -test("buildGeneratedFilePathspecs covers the tickets extension's local git cache", () => { - expect(buildGeneratedFilePathspecs({ projectDirName: ".hack" })).toContain( - ".hack/tickets" - ); -}); - -test("tracked .hack/tickets/ (leaked tickets git cache) is flagged and can be untracked", async () => { - const dir = await mkdtemp(join(tmpdir(), "hack-doctor-generated-tickets-")); - tempDirs.add(dir); - const repoRoot = resolve(dir, "repo"); - await mkdir(resolve(repoRoot, ".hack", "tickets", "git"), { - recursive: true, - }); - await runGit(["init", "-b", "main"], repoRoot); - await runGit(["config", "user.name", "Hack Test"], repoRoot); - await runGit(["config", "user.email", "hack@example.com"], repoRoot); - await writeFile( - resolve(repoRoot, ".hack", "docker-compose.yml"), - "services:\n api: {}\n" - ); - await writeFile( - resolve(repoRoot, ".hack", "tickets", "git", "marker.txt"), - "leaked tickets cache\n" - ); - await runGit(["add", "-f", "."], repoRoot); - await runGit(["commit", "-m", "leak tickets cache"], repoRoot); - - const inspection = await inspectTrackedGeneratedFiles({ - projectRoot: repoRoot, - projectDirName: ".hack", - }); - expect(inspection?.trackedPaths).toEqual([".hack/tickets/git/marker.txt"]); - - const untracked = await untrackGeneratedFiles({ - projectRoot: repoRoot, - paths: inspection?.trackedPaths ?? [], - }); - expect(untracked).toEqual({ ok: true, error: null }); - - const second = await inspectTrackedGeneratedFiles({ - projectRoot: repoRoot, - projectDirName: ".hack", - }); - expect(second?.trackedPaths).toEqual([]); - expect( - await pathExists(resolve(repoRoot, ".hack", "tickets", "git", "marker.txt")) - ).toBe(true); -}); - test("untrackGeneratedFiles removes offenders from the index, keeps files on disk, and a rerun is clean", async () => { const repoRoot = await createLeakedRepo(); diff --git a/tests/doctor-tickets-dir-gating.test.ts b/tests/doctor-tickets-dir-gating.test.ts deleted file mode 100644 index 3480c5f3..00000000 --- a/tests/doctor-tickets-dir-gating.test.ts +++ /dev/null @@ -1,240 +0,0 @@ -import { - afterAll, - afterEach, - beforeAll, - beforeEach, - expect, - test, -} from "bun:test"; -import { mkdir, mkdtemp, rm, stat, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join, resolve } from "node:path"; - -import { ensureHackDirGitignore } from "../src/lib/project-env-config.ts"; -import { registerScopedModuleMock } from "./helpers/scoped-module-mock.ts"; - -/** - * Regression coverage for the field report: `hack doctor` (and `--fix`) must - * not create the tickets extension's local git cache (`.hack/tickets/`) - * unless the extension is actually enabled for the project — it previously - * checked the unrelated `tickets.git.enabled` flag (default `true`) instead - * of `controlPlane.extensions["dance.hack.tickets"].enabled` (default - * `false`), so every `hack doctor` run silently created the directory. - * - * Also covers the companion fix: once the committed `.hack/.gitignore` is - * generated, `tickets/` is covered so an enabled project's cache never shows - * up as untracked. - * - * Docker/shell/OS are mocked (as in tests/doctor-fix-noninteractive.test.ts) - * so `--fix` never touches this machine's real Docker/global infra state. - */ - -async function dirExists(path: string): Promise<boolean> { - try { - const info = await stat(path); - return info.isDirectory(); - } catch { - return false; - } -} - -const clackMock = await registerScopedModuleMock({ - importerPath: import.meta.path, - specifier: "@clack/prompts", - overrides: { - confirm: async () => { - throw new Error( - "confirm() must not be called under HACK_NO_INTERACTIVE=1" - ); - }, - isCancel: () => false, - note: () => {}, - spinner: () => ({ - start: () => {}, - stop: () => {}, - }), - }, -}); - -const shellMock = await registerScopedModuleMock({ - importerPath: import.meta.path, - specifier: "../src/lib/shell.ts", - overrides: { - exec: async (cmd: readonly string[]) => { - if (cmd[0] === "docker" && cmd[1] === "info") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (cmd[0] === "docker" && cmd[1] === "network" && cmd[2] === "inspect") { - return { exitCode: 0, stdout: "[]", stderr: "" }; - } - return { exitCode: 1, stdout: "", stderr: "" }; - }, - execOrThrow: async () => ({ exitCode: 0, stdout: "", stderr: "" }), - run: async () => 0, - findExecutableInPath: (name?: string) => { - if (name === "hack" || name === "bun" || name === "docker") { - return `/usr/local/bin/${name}`; - } - return null; - }, - CommandError: class CommandError extends Error {}, - }, -}); - -const osMock = await registerScopedModuleMock({ - importerPath: import.meta.path, - specifier: "../src/lib/os.ts", - overrides: { - isMac: () => true, - isLinux: () => false, - openUrl: async () => 0, - }, -}); - -let tempHome: string | null = null; -let originalHome: string | undefined; -let originalMutagenPath: string | undefined; - -beforeAll(() => { - clackMock.activate(); - shellMock.activate(); - osMock.activate(); -}); - -beforeEach(async () => { - originalHome = process.env.HOME; - originalMutagenPath = process.env.HACK_MUTAGEN_PATH; - tempHome = await mkdtemp(join(tmpdir(), "hack-doctor-tickets-home-")); - process.env.HOME = tempHome; - // Short-circuit mutagen path resolution so --fix never attempts a real - // network install. - process.env.HACK_MUTAGEN_PATH = "/usr/local/bin/mutagen"; -}); - -afterEach(async () => { - if (tempHome) { - await rm(tempHome, { recursive: true, force: true }); - tempHome = null; - } - process.env.HOME = originalHome; - if (originalMutagenPath === undefined) { - Reflect.deleteProperty(process.env, "HACK_MUTAGEN_PATH"); - } else { - process.env.HACK_MUTAGEN_PATH = originalMutagenPath; - } -}); - -afterAll(() => { - clackMock.deactivate(); - shellMock.deactivate(); - osMock.deactivate(); -}); - -const tempDirs = new Set<string>(); - -afterEach(async () => { - for (const dir of tempDirs) { - await rm(dir, { recursive: true, force: true }); - } - tempDirs.clear(); -}); - -async function createTempDir(): Promise<string> { - const dir = await mkdtemp(join(tmpdir(), "hack-doctor-tickets-repo-")); - tempDirs.add(dir); - return dir; -} - -async function runGit(args: readonly string[], cwd: string): Promise<string> { - const proc = Bun.spawn({ - cmd: ["git", ...args], - cwd, - stdout: "pipe", - stderr: "pipe", - }); - const [stdout, stderr, exitCode] = await Promise.all([ - new Response(proc.stdout).text(), - new Response(proc.stderr).text(), - proc.exited, - ]); - if (exitCode !== 0) { - throw new Error(stderr || stdout || `git ${args.join(" ")} failed`); - } - return stdout.trim(); -} - -async function createFixtureRepo(opts: { - readonly ticketsEnabled: boolean; -}): Promise<string> { - const dir = await createTempDir(); - const repoRoot = resolve(dir, "repo"); - await mkdir(resolve(repoRoot, ".hack"), { recursive: true }); - await runGit(["init", "-b", "main"], repoRoot); - await runGit(["config", "user.name", "Hack Test"], repoRoot); - await runGit(["config", "user.email", "hack@example.com"], repoRoot); - - await writeFile( - resolve(repoRoot, ".hack", "docker-compose.yml"), - "services:\n api:\n image: alpine:3.19\n" - ); - await writeFile( - resolve(repoRoot, ".hack", "hack.config.json"), - `${JSON.stringify( - { - name: "fixture", - controlPlane: { - extensions: { - "dance.hack.tickets": { enabled: opts.ticketsEnabled }, - }, - }, - }, - null, - 2 - )}\n` - ); - await ensureHackDirGitignore({ projectDir: resolve(repoRoot, ".hack") }); - await runGit(["add", "."], repoRoot); - await runGit(["commit", "-m", "init"], repoRoot); - return repoRoot; -} - -async function runDoctor(opts: { - readonly repoRoot: string; - readonly extraArgs?: readonly string[]; -}): Promise<number> { - const { runCli } = await import("../src/cli/run.ts"); - return await runCli([ - "doctor", - "--path", - opts.repoRoot, - "--no-interactive", - ...(opts.extraArgs ?? []), - ]); -} - -test("hack doctor does not create .hack/tickets/ when the extension is disabled", async () => { - const repoRoot = await createFixtureRepo({ ticketsEnabled: false }); - - await runDoctor({ repoRoot }); - - expect(await dirExists(resolve(repoRoot, ".hack", "tickets"))).toBe(false); -}); - -test("hack doctor --fix does not create .hack/tickets/ when the extension is disabled", async () => { - const repoRoot = await createFixtureRepo({ ticketsEnabled: false }); - - await runDoctor({ repoRoot, extraArgs: ["--fix"] }); - - expect(await dirExists(resolve(repoRoot, ".hack", "tickets"))).toBe(false); -}); - -test("hack doctor does not initialize deprecated Tickets storage when legacy enablement remains", async () => { - const repoRoot = await createFixtureRepo({ ticketsEnabled: true }); - - await runDoctor({ repoRoot }); - - expect(await dirExists(resolve(repoRoot, ".hack", "tickets"))).toBe(false); - - const status = await runGit(["status", "--porcelain"], repoRoot); - expect(status).toBe(""); -}); diff --git a/tests/e2e/README.md b/tests/e2e/README.md index ef5e00b6..95317c4c 100644 --- a/tests/e2e/README.md +++ b/tests/e2e/README.md @@ -23,7 +23,7 @@ failed (nothing ran). ## Isolation model (HACK_HOME) Every CLI invocation runs with `HACK_HOME=<fresh tempdir>` plus -`HACK_SETUP_SYNC_MODE=off`, `HACK_NO_INTERACTIVE=1`, `NO_COLOR=1`, +`HACK_NO_INTERACTIVE=1`, `NO_COLOR=1`, `TERM=dumb`, and stdin closed. Global state (projects registry, global config) must land under `HACK_HOME`, never under the real `~/.hack`. diff --git a/tests/e2e/harness.ts b/tests/e2e/harness.ts index 8bf8e573..57acbcc9 100644 --- a/tests/e2e/harness.ts +++ b/tests/e2e/harness.ts @@ -207,7 +207,6 @@ export function buildCliEnv(opts: { } } env.HACK_HOME = opts.hackHome; - env.HACK_SETUP_SYNC_MODE = "off"; env.HACK_NO_INTERACTIVE = "1"; env.NO_COLOR = "1"; env.CLICOLOR = "0"; diff --git a/tests/e2e/scenarios/agent-docs-sync.ts b/tests/e2e/scenarios/agent-docs-sync.ts index 2b778a53..4310ec2d 100644 --- a/tests/e2e/scenarios/agent-docs-sync.ts +++ b/tests/e2e/scenarios/agent-docs-sync.ts @@ -1,4 +1,3 @@ -import { mkdir } from "node:fs/promises"; import { join } from "node:path"; import { createMonorepoFixture } from "../fixture.ts"; @@ -12,8 +11,7 @@ const MARKER_END = "<!-- hack:agent-docs:end -->"; * upsert → check clean → corrupt the marker content → check reports STALE * with a non-zero exit → sync repairs → check clean again. * - * The scenario overrides HACK_SETUP_SYNC_MODE=off (the harness default) only - * via explicit commands so auto-sync cannot mask drift detection. + * Ordinary commands leave drift untouched; only explicit setup commands write. */ export const agentDocsSyncScenario: Scenario = { name: "agent-docs-sync", @@ -129,65 +127,6 @@ export const agentDocsSyncScenario: Scenario = { message: "check after repair should report clean (exit 0)", }); - const projectTicketsSkill = join( - fixture.root, - ".codex", - "skills", - "hack-tickets", - "SKILL.md" - ); - const globalTicketsSkill = join( - ctx.hackHome, - ".codex", - "skills", - "hack-tickets", - "SKILL.md" - ); - const sharedLegacyHackSkill = join( - ctx.hackHome, - ".ai", - "skills", - "hack", - "SKILL.md" - ); - const sharedTicketsSkill = join( - ctx.hackHome, - ".ai", - "skills", - "hack-tickets", - "SKILL.md" - ); - for (const path of [ - projectTicketsSkill, - globalTicketsSkill, - sharedLegacyHackSkill, - sharedTicketsSkill, - ]) { - await mkdir(join(path, ".."), { recursive: true }); - } - await Bun.write(projectTicketsSkill, "---\nname: hack-tickets\n---\n"); - await Bun.write(globalTicketsSkill, "---\nname: hack-tickets\n---\n"); - await Bun.write( - sharedLegacyHackSkill, - "---\nname: hack\nhomepage: https://github.com/hack-dance/hack-cli\n---\n" - ); - await Bun.write(sharedTicketsSkill, "---\nname: hack-tickets\n---\n"); - const agentDocsWithTickets = `${await Bun.file(agentsPath).text()}\n<!-- hack:tickets:start -->\nlegacy tickets guidance\n<!-- hack:tickets:end -->\n`; - await Bun.write(agentsPath, agentDocsWithTickets); - - const deprecatedCheck = await ctx.cli({ - args: ["setup", "sync", "--all-scopes", "--check"], - cwd: fixture.root, - env: isolatedUserEnv, - }); - expect({ - that: - deprecatedCheck.exitCode !== 0 && - deprecatedCheck.combined.toLowerCase().includes("deprecated"), - message: "sync check should expose legacy Tickets guidance as deprecated", - result: deprecatedCheck, - }); - const fullSync = await ctx.cli({ args: ["setup", "sync", "--all-scopes"], cwd: fixture.root, @@ -230,55 +169,34 @@ export const agentDocsSyncScenario: Scenario = { const syncedAgents = await Bun.file(agentsPath).text(); expect({ - that: - syncedAgents.includes("Integration freshness") && - !/hack[ -]?tickets|dance\.hack\.tickets/i.test(syncedAgents), - message: "synced agent docs should be freshness-stamped and ticket-free", + that: syncedAgents.includes("Integration freshness"), + message: "synced agent docs should be freshness-stamped", }); - for (const path of [ - projectTicketsSkill, - globalTicketsSkill, - sharedLegacyHackSkill, - sharedTicketsSkill, - ]) { - expect({ - that: !(await Bun.file(path).exists()), - message: `sync should remove deprecated skill at ${path}`, - }); - } await Bun.write( agentsPath, syncedAgents.replace( MARKER_START, - `${MARKER_START}\nSTALE-AUTO-SYNC-PROBE` + `${MARKER_START}\nSTALE-ORDINARY-COMMAND-PROBE` ) ); - const autoRepair = await ctx.cli({ + const ordinaryCommand = await ctx.cli({ args: ["config", "get", "name"], cwd: fixture.root, - env: { ...isolatedUserEnv, HACK_SETUP_SYNC_MODE: "auto" }, + env: isolatedUserEnv, }); expectExit({ - result: autoRepair, + result: ordinaryCommand, codes: [0], - message: "a normal project command should auto-repair integration drift", - }); - expect({ - that: - autoRepair.combined.includes( - "Detected stale Hack agent integrations" - ) && autoRepair.combined.includes("Reload the agent session"), message: - "auto-repair must announce stale guidance and reload requirement", - result: autoRepair, + "a normal project command should still succeed with stale guidance", }); expect({ - that: !(await Bun.file(agentsPath).text()).includes( - "STALE-AUTO-SYNC-PROBE" + that: (await Bun.file(agentsPath).text()).includes( + "STALE-ORDINARY-COMMAND-PROBE" ), - message: "auto-sync should repair the stale managed instruction block", - result: autoRepair, + message: "ordinary commands must not rewrite stale agent integrations", + result: ordinaryCommand, }); }, }; diff --git a/tests/env-command-modern.test.ts b/tests/env-command-modern.test.ts index 4bfcc40f..e644bd86 100644 --- a/tests/env-command-modern.test.ts +++ b/tests/env-command-modern.test.ts @@ -12,16 +12,13 @@ type CapturedRunResult = { }; let tempDir: string | null = null; -let originalSetupSyncMode: string | undefined; let originalLogger: string | undefined; let originalProjectEnvKey: string | undefined; beforeEach(async () => { tempDir = await mkdtemp(join(tmpdir(), "hack-env-modern-")); - originalSetupSyncMode = process.env.HACK_SETUP_SYNC_MODE; originalLogger = process.env.HACK_LOGGER; originalProjectEnvKey = process.env.HACK_ENV_SECRET_KEY; - process.env.HACK_SETUP_SYNC_MODE = "off"; process.env.HACK_LOGGER = "console"; process.env.HACK_ENV_SECRET_KEY = undefined; }); @@ -31,11 +28,6 @@ afterEach(async () => { await rm(tempDir, { recursive: true, force: true }); tempDir = null; } - if (originalSetupSyncMode === undefined) { - process.env.HACK_SETUP_SYNC_MODE = undefined; - } else { - process.env.HACK_SETUP_SYNC_MODE = originalSetupSyncMode; - } if (originalLogger === undefined) { process.env.HACK_LOGGER = undefined; } else { diff --git a/tests/experimental-gating.test.ts b/tests/experimental-gating.test.ts index 72e6e529..b2368f40 100644 --- a/tests/experimental-gating.test.ts +++ b/tests/experimental-gating.test.ts @@ -12,22 +12,14 @@ type CapturedRunResult = { readonly stderr: string; }; -let originalSyncMode: string | undefined; let originalAck: string | undefined; beforeEach(() => { - originalSyncMode = process.env.HACK_SETUP_SYNC_MODE; originalAck = process.env.HACK_EXPERIMENTAL_ACK; - process.env.HACK_SETUP_SYNC_MODE = "off"; Reflect.deleteProperty(process.env, "HACK_EXPERIMENTAL_ACK"); }); afterEach(() => { - if (originalSyncMode === undefined) { - Reflect.deleteProperty(process.env, "HACK_SETUP_SYNC_MODE"); - } else { - process.env.HACK_SETUP_SYNC_MODE = originalSyncMode; - } if (originalAck === undefined) { Reflect.deleteProperty(process.env, "HACK_EXPERIMENTAL_ACK"); } else { diff --git a/tests/hack-gitignore.test.ts b/tests/hack-gitignore.test.ts index e00af790..791b7a0f 100644 --- a/tests/hack-gitignore.test.ts +++ b/tests/hack-gitignore.test.ts @@ -186,7 +186,6 @@ test("nested .hack/.gitignore makes git ignore generated files, including in lin ".hack/.env.state.json", ".hack/hack.env.local.yaml", ".hack/hack.env.qa.local.yaml", - ".hack/tickets/git/bare.git", ]) { expect( await gitCheckIgnore({ repoRoot: sourceRoot, path }), @@ -203,7 +202,6 @@ test("nested .hack/.gitignore makes git ignore generated files, including in lin ".hack/.branch/compose.0b88.override.yml", ".hack/.env.state.json", ".hack/hack.env.qa.local.yaml", - ".hack/tickets/git/bare.git", ]) { expect( await gitCheckIgnore({ repoRoot: linkedRoot, path }), diff --git a/tests/init-with-command.test.ts b/tests/init-with-command.test.ts index b847a15b..16821d02 100644 --- a/tests/init-with-command.test.ts +++ b/tests/init-with-command.test.ts @@ -20,12 +20,7 @@ type CapturedRunResult = { type SavedEnv = Record<string, string | undefined>; -const ENV_KEYS = [ - "PATH", - "HACK_HOME", - "HACK_NO_INTERACTIVE", - "HACK_SETUP_SYNC_MODE", -] as const; +const ENV_KEYS = ["PATH", "HACK_HOME", "HACK_NO_INTERACTIVE"] as const; let tempDir: string | null = null; let savedEnv: SavedEnv = {}; @@ -42,7 +37,6 @@ beforeEach(async () => { process.env.PATH = join(tempDir, "empty-path"); process.env.HACK_HOME = join(tempDir, "hack-home"); process.env.HACK_NO_INTERACTIVE = "1"; - process.env.HACK_SETUP_SYNC_MODE = "off"; }); afterEach(async () => { diff --git a/tests/lifecycle-json.test.ts b/tests/lifecycle-json.test.ts index fe3b53cb..4e1a5b48 100644 --- a/tests/lifecycle-json.test.ts +++ b/tests/lifecycle-json.test.ts @@ -14,14 +14,11 @@ type CapturedRunResult = { let tempDir: string | null = null; let originalHome: string | undefined; -let originalSyncMode: string | undefined; beforeEach(async () => { tempDir = await mkdtemp(join(tmpdir(), "hack-lifecycle-json-")); originalHome = process.env.HOME; - originalSyncMode = process.env.HACK_SETUP_SYNC_MODE; process.env.HOME = tempDir; - process.env.HACK_SETUP_SYNC_MODE = "off"; }); afterEach(async () => { @@ -31,11 +28,6 @@ afterEach(async () => { tempDir = null; } process.env.HOME = originalHome; - if (originalSyncMode === undefined) { - Reflect.deleteProperty(process.env, "HACK_SETUP_SYNC_MODE"); - } else { - process.env.HACK_SETUP_SYNC_MODE = originalSyncMode; - } }); test("buildLifecycleJsonData shapes the envelope payload with sorted services", () => { diff --git a/tests/project-config.test.ts b/tests/project-config.test.ts index 52985e66..26269f51 100644 --- a/tests/project-config.test.ts +++ b/tests/project-config.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, expect, test } from "bun:test"; +import { afterEach, expect, test } from "bun:test"; import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -14,24 +14,11 @@ import { } from "../src/lib/project.ts"; let tempDir: string | null = null; -let originalSetupSyncMode: string | undefined; - -beforeEach(() => { - originalSetupSyncMode = process.env.HACK_SETUP_SYNC_MODE; - process.env.HACK_SETUP_SYNC_MODE = "off"; -}); - afterEach(async () => { if (tempDir) { await rm(tempDir, { recursive: true, force: true }); tempDir = null; } - - if (originalSetupSyncMode !== undefined) { - process.env.HACK_SETUP_SYNC_MODE = originalSetupSyncMode; - } else { - process.env.HACK_SETUP_SYNC_MODE = undefined; - } }); async function createProjectDir(): Promise<{ diff --git a/tests/project-down-worktree-safety.test.ts b/tests/project-down-worktree-safety.test.ts index abe453c0..b64920a0 100644 --- a/tests/project-down-worktree-safety.test.ts +++ b/tests/project-down-worktree-safety.test.ts @@ -31,17 +31,14 @@ type RuntimeFixture = { let tempDir: string | null = null; let originalHome: string | undefined; let originalPath: string | undefined; -let originalSyncMode: string | undefined; let originalLogger: string | undefined; beforeEach(async () => { tempDir = await mkdtemp(join(tmpdir(), "hack-down-worktree-")); originalHome = process.env.HOME; originalPath = process.env.PATH; - originalSyncMode = process.env.HACK_SETUP_SYNC_MODE; originalLogger = process.env.HACK_LOGGER; process.env.HOME = tempDir; - process.env.HACK_SETUP_SYNC_MODE = "off"; process.env.HACK_LOGGER = "console"; }); @@ -52,7 +49,6 @@ afterEach(async () => { } restoreEnv("HOME", originalHome); restoreEnv("PATH", originalPath); - restoreEnv("HACK_SETUP_SYNC_MODE", originalSyncMode); restoreEnv("HACK_LOGGER", originalLogger); }); diff --git a/tests/project-owner-command.test.ts b/tests/project-owner-command.test.ts index 6ee8e863..d2b18d4e 100644 --- a/tests/project-owner-command.test.ts +++ b/tests/project-owner-command.test.ts @@ -15,14 +15,11 @@ type CapturedRunResult = { }; let tempDir: string | null = null; -let originalSetupSyncMode: string | undefined; let originalLogger: string | undefined; beforeEach(async () => { tempDir = await mkdtemp(join(tmpdir(), "hack-project-owner-")); - originalSetupSyncMode = process.env.HACK_SETUP_SYNC_MODE; originalLogger = process.env.HACK_LOGGER; - process.env.HACK_SETUP_SYNC_MODE = "off"; process.env.HACK_LOGGER = "console"; }); @@ -31,11 +28,6 @@ afterEach(async () => { await rm(tempDir, { recursive: true, force: true }); tempDir = null; } - if (originalSetupSyncMode !== undefined) { - process.env.HACK_SETUP_SYNC_MODE = originalSetupSyncMode; - } else { - process.env.HACK_SETUP_SYNC_MODE = undefined; - } if (originalLogger !== undefined) { process.env.HACK_LOGGER = originalLogger; } else { diff --git a/tests/project-up-command.test.ts b/tests/project-up-command.test.ts index e462f3a9..80fada22 100644 --- a/tests/project-up-command.test.ts +++ b/tests/project-up-command.test.ts @@ -10,14 +10,11 @@ type CapturedRunResult = { }; let tempDir: string | null = null; -let originalSetupSyncMode: string | undefined; let originalLogger: string | undefined; beforeEach(async () => { tempDir = await mkdtemp(join(tmpdir(), "hack-up-missing-project-")); - originalSetupSyncMode = process.env.HACK_SETUP_SYNC_MODE; originalLogger = process.env.HACK_LOGGER; - process.env.HACK_SETUP_SYNC_MODE = "off"; process.env.HACK_LOGGER = "console"; }); @@ -26,11 +23,6 @@ afterEach(async () => { await rm(tempDir, { recursive: true, force: true }); tempDir = null; } - if (originalSetupSyncMode !== undefined) { - process.env.HACK_SETUP_SYNC_MODE = originalSetupSyncMode; - } else { - process.env.HACK_SETUP_SYNC_MODE = undefined; - } if (originalLogger !== undefined) { process.env.HACK_LOGGER = originalLogger; } else { diff --git a/tests/run-exec-branch-default.test.ts b/tests/run-exec-branch-default.test.ts index 54d60302..589dc3f2 100644 --- a/tests/run-exec-branch-default.test.ts +++ b/tests/run-exec-branch-default.test.ts @@ -19,17 +19,14 @@ type CapturedRunResult = { let tempDir: string | null = null; let originalHome: string | undefined; let originalPath: string | undefined; -let originalSyncMode: string | undefined; let originalLogger: string | undefined; beforeEach(async () => { tempDir = await mkdtemp(join(tmpdir(), "hack-run-branch-default-")); originalHome = process.env.HOME; originalPath = process.env.PATH; - originalSyncMode = process.env.HACK_SETUP_SYNC_MODE; originalLogger = process.env.HACK_LOGGER; process.env.HOME = tempDir; - process.env.HACK_SETUP_SYNC_MODE = "off"; process.env.HACK_LOGGER = "console"; }); @@ -40,11 +37,6 @@ afterEach(async () => { } process.env.HOME = originalHome; process.env.PATH = originalPath; - if (originalSyncMode === undefined) { - Reflect.deleteProperty(process.env, "HACK_SETUP_SYNC_MODE"); - } else { - process.env.HACK_SETUP_SYNC_MODE = originalSyncMode; - } if (originalLogger === undefined) { Reflect.deleteProperty(process.env, "HACK_LOGGER"); } else { diff --git a/tests/setup.test.ts b/tests/setup.test.ts index fd0c88b0..ceb95b4c 100644 --- a/tests/setup.test.ts +++ b/tests/setup.test.ts @@ -165,37 +165,6 @@ test("setup sync keeps failing artifact paths visible", () => { }); }); -test("setup sync treats installed deprecated artifacts as actionable", () => { - const result = buildSetupSyncScopeResult({ - action: "check", - scope: "Global", - groups: [ - { - label: "Deprecated shared Hack skills", - results: [ - { - status: "deprecated", - path: "<home>/.ai/skills/hack/SKILL.md", - message: "Deprecated Hack skill is still installed", - }, - { status: "absent", path: "<home>/.ai/skills/hack-tickets/SKILL.md" }, - ], - }, - ], - }); - - expect(result).toEqual({ - exitCode: 1, - item: { - label: "Global", - status: "warn", - meta: "1/2 current", - detail: - "Deprecated shared Hack skills: Deprecated Hack skill is still installed", - }, - }); -}); - test("buildInitAssistantReport captures repo signals", async () => { const repoRoot = await setupTempRepo(); await Bun.write( diff --git a/tests/shared-agent-skill.test.ts b/tests/shared-agent-skill.test.ts index e24ac97d..24d0c02a 100644 --- a/tests/shared-agent-skill.test.ts +++ b/tests/shared-agent-skill.test.ts @@ -1,13 +1,11 @@ import { afterEach, beforeEach, expect, test } from "bun:test"; -import { mkdir, mkdtemp, rm } from "node:fs/promises"; +import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { - checkDeprecatedSharedHackSkills, checkSharedHackSkill, installSharedHackSkill, - removeDeprecatedSharedHackSkills, } from "../src/agents/shared-skill.ts"; let tempHome: string | null = null; @@ -53,45 +51,3 @@ test("shared Hack skill installs current ticket-free guidance and detects drift" expect(stale.status).toBe("stale"); expect(stale.message).toContain("hack setup sync --all-scopes"); }); - -test("known legacy shared Hack skills are reported and removed", async () => { - const hackPath = join(tempHome ?? "", ".ai", "skills", "hack", "SKILL.md"); - const ticketsPath = join( - tempHome ?? "", - ".ai", - "skills", - "hack-tickets", - "SKILL.md" - ); - await mkdir(join(hackPath, ".."), { recursive: true }); - await mkdir(join(ticketsPath, ".."), { recursive: true }); - await Bun.write( - hackPath, - "---\nname: hack\nhomepage: https://github.com/hack-dance/hack-cli\n---\n" - ); - await Bun.write(ticketsPath, "---\nname: hack-tickets\n---\n"); - - const checked = await checkDeprecatedSharedHackSkills(); - expect(checked.map((result) => result.status)).toEqual([ - "deprecated", - "deprecated", - ]); - - const removed = await removeDeprecatedSharedHackSkills(); - expect(removed.map((result) => result.status)).toEqual([ - "removed", - "removed", - ]); - expect(await Bun.file(hackPath).exists()).toBe(false); - expect(await Bun.file(ticketsPath).exists()).toBe(false); -}); - -test("unrecognized shared hack alias is protected from cleanup", async () => { - const hackPath = join(tempHome ?? "", ".ai", "skills", "hack", "SKILL.md"); - await mkdir(join(hackPath, ".."), { recursive: true }); - await Bun.write(hackPath, "---\nname: hack\n---\nuser-owned\n"); - - const removed = await removeDeprecatedSharedHackSkills(); - expect(removed[0]?.status).toBe("error"); - expect(await Bun.file(hackPath).exists()).toBe(true); -}); diff --git a/tests/tickets-enablement.test.ts b/tests/tickets-enablement.test.ts deleted file mode 100644 index cbe3b54e..00000000 --- a/tests/tickets-enablement.test.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { afterEach, beforeEach, expect, test } from "bun:test"; -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { dirname, join } from "node:path"; - -import { resolveTicketsIntegrationEnablement } from "../src/control-plane/extensions/tickets/enablement.ts"; - -let tempDir: string | null = null; -let originalGlobalConfigPath: string | undefined; -let originalHackHome: string | undefined; - -beforeEach(async () => { - originalGlobalConfigPath = process.env.HACK_GLOBAL_CONFIG_PATH; - originalHackHome = process.env.HACK_HOME; - tempDir = await mkdtemp(join(tmpdir(), "hack-tickets-enablement-")); - process.env.HACK_HOME = join(tempDir, "hack-home"); - process.env.HACK_GLOBAL_CONFIG_PATH = join( - tempDir, - "global", - "hack.config.json" - ); -}); - -afterEach(async () => { - restoreEnv("HACK_GLOBAL_CONFIG_PATH", originalGlobalConfigPath); - restoreEnv("HACK_HOME", originalHackHome); - if (tempDir) { - await rm(tempDir, { recursive: true, force: true }); - tempDir = null; - } -}); - -function restoreEnv(key: string, value: string | undefined): void { - if (value === undefined) { - Reflect.deleteProperty(process.env, key); - } else { - process.env[key] = value; - } -} - -async function writeJson(path: string, value: unknown): Promise<void> { - await mkdir(dirname(path), { recursive: true }); - await writeFile(path, `${JSON.stringify(value, null, 2)}\n`); -} - -async function writeGlobalConfig(opts: { - readonly ticketsEnabled: boolean; -}): Promise<void> { - const path = process.env.HACK_GLOBAL_CONFIG_PATH; - if (!path) { - throw new Error("HACK_GLOBAL_CONFIG_PATH not set"); - } - await writeJson(path, { - controlPlane: { - extensions: { - "dance.hack.tickets": { enabled: opts.ticketsEnabled }, - }, - }, - }); -} - -async function createProjectRoot(opts: { - readonly ticketsEnabled?: boolean; -}): Promise<string> { - if (!tempDir) { - throw new Error("tempDir not set"); - } - const projectRoot = join(tempDir, "repo"); - const configPath = join(projectRoot, ".hack", "hack.config.json"); - if (opts.ticketsEnabled === undefined) { - await mkdir(join(projectRoot, ".hack"), { recursive: true }); - return projectRoot; - } - await writeJson(configPath, { - name: "demo", - controlPlane: { - extensions: { - "dance.hack.tickets": { enabled: opts.ticketsEnabled }, - }, - }, - }); - return projectRoot; -} - -test("tickets integration is disabled by default", async () => { - const projectRoot = await createProjectRoot({}); - const enablement = await resolveTicketsIntegrationEnablement({ - projectRoot, - }); - - expect(enablement.project).toBe(false); - expect(enablement.global).toBe(false); -}); - -test("global enablement applies to both scopes when project has no override", async () => { - await writeGlobalConfig({ ticketsEnabled: true }); - const projectRoot = await createProjectRoot({}); - - const enablement = await resolveTicketsIntegrationEnablement({ - projectRoot, - }); - - expect(enablement.project).toBe(true); - expect(enablement.global).toBe(true); -}); - -test("project enablement does not enable the global scope", async () => { - const projectRoot = await createProjectRoot({ ticketsEnabled: true }); - - const enablement = await resolveTicketsIntegrationEnablement({ - projectRoot, - }); - - expect(enablement.project).toBe(true); - expect(enablement.global).toBe(false); -}); - -test("project override can disable tickets while global stays enabled", async () => { - await writeGlobalConfig({ ticketsEnabled: true }); - const projectRoot = await createProjectRoot({ ticketsEnabled: false }); - - const enablement = await resolveTicketsIntegrationEnablement({ - projectRoot, - }); - - expect(enablement.project).toBe(false); - expect(enablement.global).toBe(true); -}); - -test("omitted project root falls back to the global answer", async () => { - await writeGlobalConfig({ ticketsEnabled: true }); - - const enablement = await resolveTicketsIntegrationEnablement({}); - - expect(enablement.project).toBe(true); - expect(enablement.global).toBe(true); -}); diff --git a/tests/tickets-extension.test.ts b/tests/tickets-extension.test.ts deleted file mode 100644 index 486cc77a..00000000 --- a/tests/tickets-extension.test.ts +++ /dev/null @@ -1,715 +0,0 @@ -import { afterEach, beforeEach, expect } from "bun:test"; -import { - mkdir, - mkdtemp, - readdir, - readFile, - rm, - writeFile, -} from "node:fs/promises"; -import { homedir, tmpdir } from "node:os"; -import { join, resolve } from "node:path"; - -import { createTicketsStore } from "../src/control-plane/extensions/tickets/store.ts"; -import { readControlPlaneConfig } from "../src/control-plane/sdk/config.ts"; -import { testIntegration } from "./helpers/ci.ts"; - -const originalGlobalConfigPath = process.env.HACK_GLOBAL_CONFIG_PATH; -const originalHome = process.env.HOME; -let tempHome: string | null = null; - -beforeEach(async () => { - tempHome = await mkdtemp(join(tmpdir(), "hack-tickets-home-")); - process.env.HOME = tempHome; - process.env.HACK_GLOBAL_CONFIG_PATH = join(tempHome, "hack.config.json"); -}); - -afterEach(async () => { - if (tempHome) { - await rm(tempHome, { recursive: true, force: true }); - } - tempHome = null; - - if (originalHome === undefined) { - process.env.HOME = undefined; - } else { - process.env.HOME = originalHome; - } - - if (originalGlobalConfigPath === undefined) { - process.env.HACK_GLOBAL_CONFIG_PATH = undefined; - } else { - process.env.HACK_GLOBAL_CONFIG_PATH = originalGlobalConfigPath; - } -}); - -testIntegration( - "tickets extension: create/list/show with isolated git ref stays local-first when broker auth is unavailable", - { timeout: 60_000 }, - async () => { - const previousAuthBrokerUrl = process.env.HACK_AUTH_BROKER_URL; - process.env.HACK_AUTH_BROKER_URL = "http://127.0.0.1:9"; - - const root = await mkdirTempDir({ prefix: "hack-cli-tickets-e2e-" }); - const projectDir = join(root, "project"); - const remoteDir = join(root, "remote.git"); - try { - await mkdir(projectDir, { recursive: true }); - await copyDir({ - from: resolve(import.meta.dir, "../examples/tickets"), - to: projectDir, - }); - - await run({ cwd: projectDir, cmd: ["git", "init"] }); - await run({ - cwd: projectDir, - cmd: ["git", "config", "user.email", "tests@hack"], - }); - await run({ - cwd: projectDir, - cmd: ["git", "config", "user.name", "hack-cli-tests"], - }); - await run({ cwd: projectDir, cmd: ["git", "add", "-A"] }); - await run({ cwd: projectDir, cmd: ["git", "commit", "-m", "init"] }); - - await run({ cwd: root, cmd: ["git", "init", "--bare", remoteDir] }); - await run({ - cwd: projectDir, - cmd: ["git", "remote", "add", "origin", remoteDir], - }); - await run({ - cwd: projectDir, - cmd: ["git", "push", "-u", "origin", "HEAD:main"], - }); - - const beforeHead = ( - await run({ - cwd: projectDir, - cmd: ["git", "rev-parse", "--abbrev-ref", "HEAD"], - }) - ).stdout.trim(); - - const created = await runHack({ - cwd: projectDir, - args: ["tickets", "create", "--title", "First ticket", "--json"], - }); - const createdJson = JSON.parse(created.stdout) as { - ticket: { ticketId: string }; - }; - expect(createdJson.ticket.ticketId).toMatch(/^T-[0-9A-Z]{10}$/); - - const updated = await runHack({ - cwd: projectDir, - args: [ - "tickets", - "update", - createdJson.ticket.ticketId, - "--title", - "Updated ticket title", - "--json", - ], - }); - const updatedJson = JSON.parse(updated.stdout) as { ok: boolean }; - expect(updatedJson.ok).toBe(true); - - const status = await runHack({ - cwd: projectDir, - args: [ - "tickets", - "status", - createdJson.ticket.ticketId, - "in_progress", - "--json", - ], - }); - const statusJson = JSON.parse(status.stdout) as { ok: boolean }; - expect(statusJson.ok).toBe(true); - - const afterHead = ( - await run({ - cwd: projectDir, - cmd: ["git", "rev-parse", "--abbrev-ref", "HEAD"], - }) - ).stdout.trim(); - expect(afterHead).toBe(beforeHead); - - const listed = await runHack({ - cwd: projectDir, - args: ["tickets", "list", "--json"], - }); - const listJson = JSON.parse(listed.stdout) as { - tickets: { ticketId: string; title: string; status: string }[]; - }; - expect(listJson.tickets.length).toBe(1); - expect(listJson.tickets[0]?.title).toBe("Updated ticket title"); - expect(listJson.tickets[0]?.status).toBe("in_progress"); - - const shown = await runHack({ - cwd: projectDir, - args: ["tickets", "show", createdJson.ticket.ticketId, "--json"], - }); - const showJson = JSON.parse(shown.stdout) as { - ticket: { ticketId: string; title: string; status: string }; - events: { type: string }[]; - }; - expect(showJson.ticket.ticketId).toBe(createdJson.ticket.ticketId); - expect(showJson.ticket.title).toBe("Updated ticket title"); - expect(showJson.ticket.status).toBe("in_progress"); - expect(showJson.events.some((e) => e.type === "ticket.created")).toBe( - true - ); - - const showRef = await runAllowFail({ - cwd: root, - cmd: [ - "git", - `--git-dir=${remoteDir}`, - "show-ref", - "--verify", - "refs/hack/tickets", - ], - }); - expect(showRef.exitCode).toBe(0); - } finally { - if (previousAuthBrokerUrl === undefined) { - Reflect.deleteProperty(process.env, "HACK_AUTH_BROKER_URL"); - } else { - process.env.HACK_AUTH_BROKER_URL = previousAuthBrokerUrl; - } - - await rm(root, { recursive: true, force: true }); - } - } -); - -testIntegration( - "tickets extension: assignee, review note, comment, and conflict resolution commands work end to end", - { timeout: 60_000 }, - async () => { - const root = await mkdirTempDir({ prefix: "hack-cli-tickets-sync-e2e-" }); - const projectDir = join(root, "project"); - - await mkdir(projectDir, { recursive: true }); - await copyDir({ - from: resolve(import.meta.dir, "../examples/tickets"), - to: projectDir, - }); - - await run({ cwd: projectDir, cmd: ["git", "init"] }); - await run({ - cwd: projectDir, - cmd: ["git", "config", "user.email", "tests@hack"], - }); - await run({ - cwd: projectDir, - cmd: ["git", "config", "user.name", "hack-cli-tests"], - }); - await run({ cwd: projectDir, cmd: ["git", "add", "-A"] }); - await run({ cwd: projectDir, cmd: ["git", "commit", "-m", "init"] }); - - const created = await runHack({ - cwd: projectDir, - args: [ - "tickets", - "create", - "--title", - "Sync lifecycle ticket", - "--assignee", - "alice@hack", - "--json", - ], - }); - const createdJson = JSON.parse(created.stdout) as { - ticket: { ticketId: string }; - }; - const ticketId = createdJson.ticket.ticketId; - - const commented = await runHack({ - cwd: projectDir, - args: [ - "tickets", - "comment", - ticketId, - "--body", - "Append only note", - "--source", - "hack", - "--json", - ], - }); - const commentJson = JSON.parse(commented.stdout) as { - comment: { body: string; source: string }; - }; - expect(commentJson.comment.body).toBe("Append only note"); - expect(commentJson.comment.source).toBe("hack"); - - const reviewed = await runHack({ - cwd: projectDir, - args: [ - "tickets", - "review-note", - ticketId, - "--body", - "Shared review note", - "--json", - ], - }); - const reviewJson = JSON.parse(reviewed.stdout) as { - reviewNote: { body: string }; - }; - expect(reviewJson.reviewNote.body).toBe("Shared review note"); - - const store = await createStore({ projectRoot: projectDir }); - const conflict = await store.recordSyncConflict({ - ticketId, - provider: "linear", - field: "title", - authority: "origin", - localValue: "Local title", - remoteValue: "Remote title", - summary: "Title diverged during sync.", - actor: "sync@app", - }); - expect(conflict.ok).toBe(true); - if (!conflict.ok) { - throw new Error(conflict.error); - } - - const resolved = await runHack({ - cwd: projectDir, - args: [ - "tickets", - "resolve-conflict", - ticketId, - "--conflict-id", - conflict.conflict.conflictId, - "--resolution", - "accept_remote", - "--summary", - "Remote remains authoritative.", - "--json", - ], - }); - const resolvedJson = JSON.parse(resolved.stdout) as { - ok: boolean; - resolution: string; - }; - expect(resolvedJson.ok).toBe(true); - expect(resolvedJson.resolution).toBe("accept_remote"); - - const shown = await runHack({ - cwd: projectDir, - args: ["tickets", "show", ticketId, "--json"], - }); - const showJson = JSON.parse(shown.stdout) as { - ticket: { assignee?: string }; - comments: Array<{ body: string }>; - reviewNotes: Array<{ body: string }>; - conflicts: Array<{ status: string; resolution?: string }>; - }; - expect(showJson.ticket.assignee).toBe("alice@hack"); - expect(showJson.comments.map((comment) => comment.body)).toContain( - "Append only note" - ); - expect(showJson.reviewNotes.map((reviewNote) => reviewNote.body)).toContain( - "Shared review note" - ); - expect(showJson.conflicts[0]?.status).toBe("resolved"); - expect(showJson.conflicts[0]?.resolution).toBe("accept_remote"); - - await rm(root, { recursive: true, force: true }); - } -); - -testIntegration( - "tickets extension: cli rebuilds sqlite projection after local deletion", - { timeout: 60_000 }, - async () => { - const root = await mkdirTempDir({ - prefix: "hack-cli-tickets-projection-e2e-", - }); - const projectDir = join(root, "project"); - - await mkdir(projectDir, { recursive: true }); - await copyDir({ - from: resolve(import.meta.dir, "../examples/tickets"), - to: projectDir, - }); - - await run({ cwd: projectDir, cmd: ["git", "init"] }); - await run({ - cwd: projectDir, - cmd: ["git", "config", "user.email", "tests@hack"], - }); - await run({ - cwd: projectDir, - cmd: ["git", "config", "user.name", "hack-cli-tests"], - }); - await run({ cwd: projectDir, cmd: ["git", "add", "-A"] }); - await run({ cwd: projectDir, cmd: ["git", "commit", "-m", "init"] }); - - const created = await runHack({ - cwd: projectDir, - args: ["tickets", "create", "--title", "Projection lifecycle", "--json"], - }); - const createdJson = JSON.parse(created.stdout) as { - ticket: { ticketId: string }; - }; - - const projectionPath = join(projectDir, ".hack/tickets/projection.sqlite"); - expect(await Bun.file(projectionPath).exists()).toBe(true); - - await rm(projectionPath, { force: true }); - expect(await Bun.file(projectionPath).exists()).toBe(false); - - const shown = await runHack({ - cwd: projectDir, - args: ["tickets", "show", createdJson.ticket.ticketId, "--json"], - }); - expect(shown.exitCode).toBe(0); - expect(await Bun.file(projectionPath).exists()).toBe(true); - - await rm(root, { recursive: true, force: true }); - } -); - -testIntegration( - "tickets extension: document command appends spec docs and updates the active description", - { timeout: 60_000 }, - async () => { - const root = await mkdirTempDir({ prefix: "hack-cli-tickets-docs-e2e-" }); - const projectDir = join(root, "project"); - - await mkdir(projectDir, { recursive: true }); - await copyDir({ - from: resolve(import.meta.dir, "../examples/tickets"), - to: projectDir, - }); - - await run({ cwd: projectDir, cmd: ["git", "init"] }); - await run({ - cwd: projectDir, - cmd: ["git", "config", "user.email", "tests@hack"], - }); - await run({ - cwd: projectDir, - cmd: ["git", "config", "user.name", "hack-cli-tests"], - }); - await run({ cwd: projectDir, cmd: ["git", "add", "-A"] }); - await run({ cwd: projectDir, cmd: ["git", "commit", "-m", "init"] }); - - const created = await runHack({ - cwd: projectDir, - args: [ - "tickets", - "create", - "--title", - "Document lifecycle", - "--body", - "## Context\nInitial description", - "--json", - ], - }); - const createdJson = JSON.parse(created.stdout) as { - ticket: { ticketId: string }; - }; - const ticketId = createdJson.ticket.ticketId; - - const spec = await runHack({ - cwd: projectDir, - args: [ - "tickets", - "document", - ticketId, - "--kind", - "spec", - "--body", - "## Goals\n- Add spec support", - "--json", - ], - }); - const specJson = JSON.parse(spec.stdout) as { - document: { kind: string; role: string }; - }; - expect(specJson.document.kind).toBe("spec"); - expect(specJson.document.role).toBe("spec"); - - const description = await runHack({ - cwd: projectDir, - args: [ - "tickets", - "document", - ticketId, - "--kind", - "description", - "--body", - "## Context\nUpdated description", - "--json", - ], - }); - const descriptionJson = JSON.parse(description.stdout) as { - document: { kind: string; content: string }; - }; - expect(descriptionJson.document.kind).toBe("description"); - expect(descriptionJson.document.content).toBe( - "## Context\nUpdated description" - ); - - const shown = await runHack({ - cwd: projectDir, - args: ["tickets", "show", ticketId, "--json"], - }); - const showJson = JSON.parse(shown.stdout) as { - ticket: { body?: string }; - documents: Array<{ kind: string; role: string; content: string }>; - }; - expect(showJson.ticket.body).toBe("## Context\nUpdated description"); - expect(showJson.documents).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - kind: "description", - role: "description", - content: "## Context\nInitial description", - }), - expect.objectContaining({ - kind: "spec", - role: "spec", - content: "## Goals\n- Add spec support", - }), - expect.objectContaining({ - kind: "description", - role: "description", - content: "## Context\nUpdated description", - }), - ]) - ); - - await rm(root, { recursive: true, force: true }); - } -); - -testIntegration( - "tickets extension: hidden ref sync transports only the journal and peers rebuild projection state locally", - { timeout: 60_000 }, - async () => { - const root = await mkdirTempDir({ - prefix: "hack-cli-tickets-portability-e2e-", - }); - const authorDir = join(root, "author"); - const peerDir = join(root, "peer"); - const remoteDir = join(root, "remote.git"); - - await mkdir(authorDir, { recursive: true }); - await copyDir({ - from: resolve(import.meta.dir, "../examples/tickets"), - to: authorDir, - }); - - await run({ cwd: authorDir, cmd: ["git", "init"] }); - await run({ - cwd: authorDir, - cmd: ["git", "config", "user.email", "tests@hack"], - }); - await run({ - cwd: authorDir, - cmd: ["git", "config", "user.name", "hack-cli-tests"], - }); - await run({ cwd: authorDir, cmd: ["git", "add", "-A"] }); - await run({ cwd: authorDir, cmd: ["git", "commit", "-m", "init"] }); - - await run({ cwd: root, cmd: ["git", "init", "--bare", remoteDir] }); - await run({ - cwd: authorDir, - cmd: ["git", "remote", "add", "origin", remoteDir], - }); - await run({ - cwd: authorDir, - cmd: ["git", "push", "-u", "origin", "HEAD:main"], - }); - - const created = await runHack({ - cwd: authorDir, - args: ["tickets", "create", "--title", "Portable journal", "--json"], - }); - const createdJson = JSON.parse(created.stdout) as { - ticket: { ticketId: string }; - }; - - const remoteTree = await run({ - cwd: root, - cmd: [ - "git", - `--git-dir=${remoteDir}`, - "ls-tree", - "-r", - "--name-only", - "refs/hack/tickets", - ], - }); - const remotePaths = remoteTree.stdout - .split("\n") - .map((line) => line.trim()) - .filter((line) => line.length > 0); - expect( - remotePaths.some((path) => - path.startsWith(".hack/tickets/events/events-") - ) - ).toBe(true); - expect(remotePaths).not.toContain(".hack/tickets/projection.sqlite"); - - await mkdir(peerDir, { recursive: true }); - await copyDir({ - from: resolve(import.meta.dir, "../examples/tickets"), - to: peerDir, - }); - await run({ cwd: peerDir, cmd: ["git", "init"] }); - await run({ - cwd: peerDir, - cmd: ["git", "config", "user.email", "tests@hack"], - }); - await run({ - cwd: peerDir, - cmd: ["git", "config", "user.name", "hack-cli-tests"], - }); - await run({ cwd: peerDir, cmd: ["git", "add", "-A"] }); - await run({ cwd: peerDir, cmd: ["git", "commit", "-m", "init"] }); - await run({ - cwd: peerDir, - cmd: ["git", "remote", "add", "origin", remoteDir], - }); - - const synced = await runHack({ - cwd: peerDir, - args: ["tickets", "sync", "--json"], - }); - expect(synced.exitCode).toBe(0); - - const shown = await runHack({ - cwd: peerDir, - args: ["tickets", "show", createdJson.ticket.ticketId, "--json"], - }); - expect(shown.exitCode).toBe(0); - - const eventsDir = join( - peerDir, - ".hack/tickets/git/worktree/.hack/tickets/events" - ); - const eventFiles = (await readdir(eventsDir)) - .filter((entry) => entry.endsWith(".jsonl")) - .sort(); - expect(eventFiles.length).toBeGreaterThan(0); - const journalBeforeDeletion = await readFile( - join(eventsDir, eventFiles[0] ?? ""), - "utf8" - ); - - const projectionPath = join(peerDir, ".hack/tickets/projection.sqlite"); - expect(await Bun.file(projectionPath).exists()).toBe(true); - await rm(projectionPath, { force: true }); - expect(await Bun.file(projectionPath).exists()).toBe(false); - - const rebuilt = await runHack({ - cwd: peerDir, - args: ["tickets", "list", "--json"], - }); - expect(rebuilt.exitCode).toBe(0); - expect(await Bun.file(projectionPath).exists()).toBe(true); - - const journalAfterDeletion = await readFile( - join(eventsDir, eventFiles[0] ?? ""), - "utf8" - ); - expect(journalAfterDeletion).toBe(journalBeforeDeletion); - - await rm(root, { recursive: true, force: true }); - } -); - -interface RunResult { - readonly stdout: string; - readonly stderr: string; - readonly exitCode: number; -} - -async function run(opts: { - readonly cwd: string; - readonly cmd: readonly string[]; -}): Promise<RunResult> { - const result = await runAllowFail(opts); - if (result.exitCode !== 0) { - throw new Error( - `Command failed (${result.exitCode}): ${opts.cmd.join(" ")}\n${result.stderr || result.stdout}` - ); - } - return result; -} - -async function runAllowFail(opts: { - readonly cwd: string; - readonly cmd: readonly string[]; -}): Promise<RunResult> { - const proc = Bun.spawn([...opts.cmd], { - cwd: opts.cwd, - stdout: "pipe", - stderr: "pipe", - stdin: "ignore", - env: { - ...process.env, - HOME: process.env.HOME ?? homedir(), - }, - }); - - const stdout = await new Response(proc.stdout).text(); - const stderr = await new Response(proc.stderr).text(); - const exitCode = await proc.exited; - - return { stdout, stderr, exitCode }; -} - -async function runHack(opts: { - readonly cwd: string; - readonly args: readonly string[]; -}): Promise<RunResult> { - return await run({ - cwd: opts.cwd, - cmd: ["bun", resolve(import.meta.dir, "../index.ts"), ...opts.args], - }); -} - -async function createStore(opts: { readonly projectRoot: string }) { - const result = await readControlPlaneConfig({}); - return createTicketsStore({ - projectRoot: opts.projectRoot, - controlPlaneConfig: result.config, - logger: { - info: () => {}, - warn: () => {}, - }, - }); -} - -async function mkdirTempDir(opts: { - readonly prefix: string; -}): Promise<string> { - const root = join(tmpdir(), `${opts.prefix}${Date.now()}-${Math.random()}`); - await mkdir(root, { recursive: true }); - return root; -} - -async function copyDir(opts: { - readonly from: string; - readonly to: string; -}): Promise<void> { - await mkdir(opts.to, { recursive: true }); - const entries = await readdir(opts.from, { withFileTypes: true }); - for (const entry of entries) { - const fromPath = join(opts.from, entry.name); - const toPath = join(opts.to, entry.name); - if (entry.isDirectory()) { - await copyDir({ from: fromPath, to: toPath }); - } else if (entry.isFile()) { - const data = await readFile(fromPath); - await writeFile(toPath, data); - } - } -} diff --git a/tests/tickets-git-channel.test.ts b/tests/tickets-git-channel.test.ts deleted file mode 100644 index faec3267..00000000 --- a/tests/tickets-git-channel.test.ts +++ /dev/null @@ -1,859 +0,0 @@ -import { afterEach, expect, test } from "bun:test"; -import { - chmod, - mkdir, - mkdtemp, - readdir, - readFile, - rm, - writeFile, -} from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join, resolve } from "node:path"; - -import { __testOnly } from "../src/control-plane/extensions/tickets/tickets-git-channel.ts"; - -const tempRoots: string[] = []; - -afterEach(async () => { - for (const root of tempRoots) { - await rm(root, { recursive: true, force: true }); - } - tempRoots.length = 0; -}); - -test("mergeTicketEventLogs dedupes by event id and preserves chronological order", () => { - const merged = __testOnly.mergeTicketEventLogs({ - existing: [ - JSON.stringify({ - eventId: "event-2", - schemaVersion: 1, - ts: 2, - occurredAt: "2026-03-13T00:00:02.000Z", - recordedAt: "2026-03-13T00:00:02.000Z", - sourceSystem: "hack", - sourceOperation: "ticket.created", - idempotencyKey: "event-2", - ticketId: "T-00002", - eventType: "ticket.created", - type: "ticket.created", - payload: {}, - }), - JSON.stringify({ - eventId: "event-3", - schemaVersion: 1, - ts: 3, - occurredAt: "2026-03-13T00:00:03.000Z", - recordedAt: "2026-03-13T00:00:03.000Z", - sourceSystem: "hack", - sourceOperation: "ticket.created", - idempotencyKey: "event-3", - ticketId: "T-00003", - eventType: "ticket.created", - type: "ticket.created", - payload: {}, - }), - "", - ].join("\n"), - incoming: [ - JSON.stringify({ - eventId: "event-1", - schemaVersion: 1, - ts: 1, - occurredAt: "2026-03-13T00:00:01.000Z", - recordedAt: "2026-03-13T00:00:01.000Z", - sourceSystem: "linear", - sourceOperation: "issue.import", - idempotencyKey: "linear:issue:1", - ticketId: "T-00001", - eventType: "ticket.created", - type: "ticket.created", - payload: {}, - }), - JSON.stringify({ - eventId: "event-2", - schemaVersion: 1, - ts: 2, - occurredAt: "2026-03-13T00:00:02.000Z", - recordedAt: "2026-03-13T00:00:02.000Z", - sourceSystem: "hack", - sourceOperation: "ticket.created", - idempotencyKey: "event-2", - ticketId: "T-00002", - eventType: "ticket.created", - type: "ticket.created", - payload: {}, - }), - "", - ].join("\n"), - }); - - const lines = merged - .trim() - .split("\n") - .map( - (line) => - JSON.parse(line) as { - readonly eventId: string; - readonly ts: number; - readonly schemaVersion: number; - readonly sourceSystem: string; - readonly sourceOperation: string; - readonly idempotencyKey: string; - readonly eventType: string; - } - ); - - expect(lines.map((line) => line.eventId)).toEqual([ - "event-1", - "event-2", - "event-3", - ]); - expect(lines.map((line) => line.ts)).toEqual([1, 2, 3]); - expect(lines[0]).toMatchObject({ - schemaVersion: 1, - sourceSystem: "linear", - sourceOperation: "issue.import", - idempotencyKey: "linear:issue:1", - eventType: "ticket.created", - }); -}); - -test("mergeTicketEventLogs preserves normalized journal envelope fields", () => { - const merged = __testOnly.mergeTicketEventLogs({ - existing: [ - JSON.stringify({ - eventId: "event-2", - schemaVersion: 1, - ts: 2, - occurredAt: "2026-03-13T10:00:02.000Z", - recordedAt: "2026-03-13T10:00:03.000Z", - ticketId: "T-00002", - type: "ticket.created", - sourceSystem: "linear", - sourceOperation: "webhook_pull", - idempotencyKey: "linear:event-2", - }), - "", - ].join("\n"), - incoming: [ - JSON.stringify({ - eventId: "event-1", - schemaVersion: 1, - ts: 1, - occurredAt: "2026-03-13T10:00:01.000Z", - recordedAt: "2026-03-13T10:00:01.500Z", - ticketId: "T-00001", - type: "ticket.created", - sourceSystem: "hack", - sourceOperation: "local_command", - idempotencyKey: "hack:event-1", - }), - "", - ].join("\n"), - }); - - const lines = merged - .trim() - .split("\n") - .map((line) => JSON.parse(line) as Record<string, unknown>); - - expect(lines).toEqual([ - expect.objectContaining({ - eventId: "event-1", - schemaVersion: 1, - sourceSystem: "hack", - sourceOperation: "local_command", - idempotencyKey: "hack:event-1", - }), - expect.objectContaining({ - eventId: "event-2", - schemaVersion: 1, - sourceSystem: "linear", - sourceOperation: "webhook_pull", - idempotencyKey: "linear:event-2", - }), - ]); -}); - -test("resolvePushRefForCheckoutRef prefers legacy branch when checkout came from legacy tracking ref", () => { - const pushRef = __testOnly.resolvePushRefForCheckoutRef({ - checkoutRef: "refs/remotes/origin/__legacy__/hack/tickets", - remoteRef: "refs/hack/tickets", - legacyTrackingRef: "refs/remotes/origin/__legacy__/hack/tickets", - legacyRemoteRef: "refs/heads/hack/tickets", - }); - - expect(pushRef).toBe("refs/heads/hack/tickets"); -}); - -test("resolvePushRefForCheckoutRef keeps hidden ref when checkout came from hidden tracking ref", () => { - const pushRef = __testOnly.resolvePushRefForCheckoutRef({ - checkoutRef: "origin/hack/tickets", - remoteRef: "refs/hack/tickets", - legacyTrackingRef: "refs/remotes/origin/__legacy__/hack/tickets", - legacyRemoteRef: "refs/heads/hack/tickets", - }); - - expect(pushRef).toBe("refs/hack/tickets"); -}); - -test("resolveTicketGitIdentityEnv leaves remote ssh defaults unset when GIT_SSH exists", () => { - const originalGitSsh = process.env.GIT_SSH; - const originalGitSshCommand = process.env.GIT_SSH_COMMAND; - process.env.GIT_SSH = "/tmp/custom-ssh-wrapper"; - process.env.GIT_SSH_COMMAND = undefined; - - try { - const env = __testOnly.resolveTicketGitIdentityEnv({ - remote: true, - }); - expect(env.GIT_SSH_COMMAND).toBeUndefined(); - } finally { - process.env.GIT_SSH = originalGitSsh; - process.env.GIT_SSH_COMMAND = originalGitSshCommand; - } -}); - -test("resolveTicketGitIdentityEnv sets remote ssh defaults when no ssh env is configured", () => { - const originalGitSsh = process.env.GIT_SSH; - const originalGitSshCommand = process.env.GIT_SSH_COMMAND; - process.env.GIT_SSH = undefined; - process.env.GIT_SSH_COMMAND = undefined; - - try { - const env = __testOnly.resolveTicketGitIdentityEnv({ - remote: true, - }); - expect(env.GIT_SSH_COMMAND).toBe("ssh -oBatchMode=yes -oConnectTimeout=5"); - } finally { - process.env.GIT_SSH = originalGitSsh; - process.env.GIT_SSH_COMMAND = originalGitSshCommand; - } -}); - -test("mutation lock heartbeat prevents overlapping prepared mutations past stale threshold", async () => { - const projectRoot = await createTempGitProject({ - prefix: "hack-cli-tickets-git-lock-", - }); - const channel = __testOnly.createGitTicketsChannel({ - projectRoot, - config: { - enabled: true, - branch: "hack/tickets", - refMode: "hidden", - remote: "", - forceBareClone: false, - }, - logger: { - info: (_input: { message: string }) => {}, - warn: (_input: { message: string }) => {}, - }, - testOverrides: { - mutationLockRetryMs: 5, - mutationLockStaleMs: 40, - mutationLockTimeoutMs: 2000, - mutationLockHeartbeatMs: 10, - }, - }); - - let activeCount = 0; - let maxActiveCount = 0; - - const firstMutation = channel.appendPreparedEvents({ - prepare: async () => { - activeCount += 1; - maxActiveCount = Math.max(maxActiveCount, activeCount); - await Bun.sleep(120); - activeCount -= 1; - return { - ok: true, - events: [ - { - actor: "creator-1@hack", - eventId: "event-1", - payload: { title: "first" }, - ticketId: "T-AAAAAAA111", - ts: 1, - tsIso: "2025-11-04T00:00:00.000Z", - type: "ticket.created", - }, - ], - result: "first", - } as const; - }, - }); - - await Bun.sleep(60); - - const secondMutation = channel.appendPreparedEvents({ - prepare: async () => { - activeCount += 1; - maxActiveCount = Math.max(maxActiveCount, activeCount); - activeCount -= 1; - return { - ok: true, - events: [ - { - actor: "creator-2@hack", - eventId: "event-2", - payload: { title: "second" }, - ticketId: "T-BBBBBBB222", - ts: 2, - tsIso: "2025-11-04T00:00:01.000Z", - type: "ticket.created", - }, - ], - result: "second", - } as const; - }, - }); - - const results = await Promise.all([firstMutation, secondMutation]); - - expect(results).toEqual([ - { ok: true, result: "first" }, - { ok: true, result: "second" }, - ]); - expect(maxActiveCount).toBe(1); -}); - -test("resolveLocalCheckoutFallback blocks stale local fallback when fetch failure must be surfaced", () => { - const result = __testOnly.resolveLocalCheckoutFallback({ - fetchFailure: "git fetch failed: origin unavailable", - allowFetchFailureFallback: false, - preferredTrackingRef: "refs/remotes/origin/__legacy__/hack/tickets", - remoteRef: "refs/hack/tickets", - legacyTrackingRef: "refs/remotes/origin/__legacy__/hack/tickets", - legacyRemoteRef: "refs/heads/hack/tickets", - }); - - expect(result).toEqual({ - ok: false, - error: "git fetch failed: origin unavailable", - }); -}); - -test("resolveLocalCheckoutFallback preserves the legacy push ref when local fallback is allowed", () => { - const result = __testOnly.resolveLocalCheckoutFallback({ - fetchFailure: "git fetch failed: origin unavailable", - allowFetchFailureFallback: true, - preferredTrackingRef: "refs/remotes/origin/__legacy__/hack/tickets", - remoteRef: "refs/hack/tickets", - legacyTrackingRef: "refs/remotes/origin/__legacy__/hack/tickets", - legacyRemoteRef: "refs/heads/hack/tickets", - }); - - expect(result).toEqual({ - ok: true, - pushRef: "refs/heads/hack/tickets", - }); -}); - -test("resolveLegacyImportFetchResult surfaces non-missing legacy fetch failures", () => { - const result = __testOnly.resolveLegacyImportFetchResult({ - missing: false, - error: "fatal: remote transport failed", - }); - - expect(result).toEqual({ - ok: false, - error: "git fetch failed: fatal: remote transport failed", - }); -}); - -test("resolveLegacyImportFetchResult ignores missing legacy refs", () => { - const result = __testOnly.resolveLegacyImportFetchResult({ - missing: true, - error: "fatal: couldn't find remote ref refs/heads/hack/tickets", - }); - - expect(result).toEqual({ - ok: true, - imported: false, - }); -}); - -test("formatTicketsGitRemoteError adds actionable SSH guidance", () => { - const message = __testOnly.formatTicketsGitRemoteError({ - message: - 'sign_and_send_pubkey: signing failed for ED25519 "<ssh-key-path>" from agent: agent refused operation\nPermission denied (publickey).', - operation: "fetch", - }); - - expect(message).toContain("Unlock your SSH agent or 1Password"); - expect(message).toContain("ssh -T git@github.com"); - expect(__testOnly.isTicketsGitRemoteConnectivityError(message)).toBe(true); -}); - -test("repository not found is not treated as a recoverable connectivity error", () => { - const message = [ - "fatal: repository 'git@github.com:hack-dance/missing.git' not found", - "fatal: Could not read from remote repository.", - ].join("\n"); - - expect(__testOnly.isTicketsGitRemoteConnectivityError(message)).toBe(false); -}); - -test("sync returns actionable SSH guidance when git remote auth fails", async () => { - const projectRoot = await createTempGitProject({ - prefix: "hack-cli-tickets-git-auth-failure-", - }); - const remoteScriptPath = join(projectRoot, "fake-ssh.sh"); - await writeFile( - remoteScriptPath, - [ - "#!/bin/sh", - "echo 'sign_and_send_pubkey: signing failed for ED25519 \"<ssh-key-path>\" from agent: agent refused operation' >&2", - 'echo "git@github.com: Permission denied (publickey)." >&2', - "exit 255", - "", - ].join("\n") - ); - await chmod(remoteScriptPath, 0o755); - await run({ - cwd: projectRoot, - cmd: [ - "git", - "remote", - "add", - "origin", - "ssh://git@example.invalid/does-not-exist", - ], - }); - - const originalGitSshCommand = process.env.GIT_SSH_COMMAND; - process.env.GIT_SSH_COMMAND = remoteScriptPath; - try { - const channel = __testOnly.createGitTicketsChannel({ - projectRoot, - config: { - enabled: true, - branch: "hack/tickets", - refMode: "hidden", - remote: "origin", - forceBareClone: false, - }, - logger: { - info: (_input: { message: string }) => {}, - warn: (_input: { message: string }) => {}, - }, - }); - - const synced = await channel.sync(); - expect(synced.ok).toBe(false); - if (synced.ok) { - throw new Error("Expected sync to fail"); - } - expect(synced.error).toContain("Unlock your SSH agent or 1Password"); - expect(synced.error).toContain("ssh -T git@github.com"); - } finally { - if (originalGitSshCommand === undefined) { - process.env.GIT_SSH_COMMAND = undefined; - } else { - process.env.GIT_SSH_COMMAND = originalGitSshCommand; - } - } -}); - -test("sync timeout kills remote git subprocess groups", async () => { - const projectRoot = await createTempGitProject({ - prefix: "hack-cli-tickets-git-timeout-", - }); - const remoteScriptPath = join(projectRoot, "fake-ssh-timeout.sh"); - await writeFile( - remoteScriptPath, - [ - "#!/bin/sh", - "sleep 30 &", - "child=$!", - "trap 'kill \"$child\" 2>/dev/null; exit 0' TERM INT", - 'wait "$child"', - "", - ].join("\n") - ); - await chmod(remoteScriptPath, 0o755); - await run({ - cwd: projectRoot, - cmd: [ - "git", - "remote", - "add", - "origin", - "ssh://git@example.invalid/does-not-exist", - ], - }); - - const originalGitSshCommand = process.env.GIT_SSH_COMMAND; - process.env.GIT_SSH_COMMAND = remoteScriptPath; - try { - const channel = __testOnly.createGitTicketsChannel({ - projectRoot, - config: { - enabled: true, - branch: "hack/tickets", - refMode: "hidden", - remote: "origin", - forceBareClone: false, - }, - logger: { - info: (_input: { message: string }) => {}, - warn: (_input: { message: string }) => {}, - }, - testOverrides: { - remoteGitTimeoutMs: 200, - }, - }); - - const startedAt = Date.now(); - const synced = await channel.sync(); - const elapsedMs = Date.now() - startedAt; - expect(synced.ok).toBe(false); - if (synced.ok) { - throw new Error("Expected sync to fail"); - } - expect(synced.error).toContain("timed out after"); - expect(elapsedMs).toBeLessThan(5000); - } finally { - if (originalGitSshCommand === undefined) { - process.env.GIT_SSH_COMMAND = undefined; - } else { - process.env.GIT_SSH_COMMAND = originalGitSshCommand; - } - } -}, 10_000); - -test("repair reapplies cleanup after a non-fast-forward push retry", async () => { - const projectRoot = await createTempGitProject({ - prefix: "hack-cli-tickets-git-repair-", - }); - const remoteRoot = await mkdtemp(join(tmpdir(), "hack-cli-tickets-remote-")); - tempRoots.push(remoteRoot); - await run({ cwd: remoteRoot, cmd: ["git", "init", "--bare"] }); - await run({ - cwd: projectRoot, - cmd: ["git", "remote", "add", "origin", remoteRoot], - }); - - const remoteClone = await createTempGitProject({ - prefix: "hack-cli-tickets-remote-clone-", - }); - await run({ - cwd: remoteClone, - cmd: ["git", "remote", "add", "origin", remoteRoot], - }); - - const channelWriter = __testOnly.createGitTicketsChannel({ - projectRoot, - config: { - enabled: true, - branch: "hack/tickets", - refMode: "hidden", - remote: "origin", - forceBareClone: false, - }, - logger: { - info: (_input: { message: string }) => {}, - warn: (_input: { message: string }) => {}, - }, - }); - const remoteWriter = __testOnly.createGitTicketsChannel({ - projectRoot: remoteClone, - config: { - enabled: true, - branch: "hack/tickets", - refMode: "hidden", - remote: "origin", - forceBareClone: false, - }, - logger: { - info: (_input: { message: string }) => {}, - warn: (_input: { message: string }) => {}, - }, - }); - - const appendResult = await channelWriter.appendEvents({ - events: [ - createTicketEvent({ - eventId: "event-1", - ticketId: "T-AAAAAAA111", - ts: 1, - }), - ], - }); - expect(appendResult).toEqual({ ok: true }); - - const worktree = await channelWriter.ensureCheckedOut(); - await writeFile(resolve(worktree, ".hack/notes.txt"), "legacy noise\n"); - - let remoteAdvanced = false; - const repairingChannel = __testOnly.createGitTicketsChannel({ - projectRoot, - config: { - enabled: true, - branch: "hack/tickets", - refMode: "hidden", - remote: "origin", - forceBareClone: false, - }, - logger: { - info: (_input: { message: string }) => {}, - warn: (_input: { message: string }) => {}, - }, - testOverrides: { - beforePushAttempt: async ({ attempt }) => { - if (attempt !== 1 || remoteAdvanced) { - return; - } - remoteAdvanced = true; - const result = await remoteWriter.appendEvents({ - events: [ - createTicketEvent({ - eventId: "event-2", - ticketId: "T-BBBBBBB222", - ts: 2, - }), - ], - }); - expect(result).toEqual({ ok: true }); - }, - }, - }); - - const repaired = await repairingChannel.repair({ - pruneLegacyRef: false, - }); - expect(repaired).toMatchObject({ - ok: true, - didPush: true, - }); - - const listed = await runCapture({ - cwd: remoteRoot, - cmd: ["git", "ls-tree", "-r", "--name-only", "refs/hack/tickets"], - }); - expect(listed.stdout).toContain(".hack/tickets/README.md"); - expect(listed.stdout).toContain(".hack/tickets/events/events-1970-01.jsonl"); - expect(listed.stdout).not.toContain(".hack/notes.txt"); - - const eventsText = await runCapture({ - cwd: remoteRoot, - cmd: [ - "git", - "show", - "refs/hack/tickets:.hack/tickets/events/events-1970-01.jsonl", - ], - }); - expect(eventsText.stdout).toContain('"eventId":"event-1"'); - expect(eventsText.stdout).toContain('"eventId":"event-2"'); -}); - -test("ensureCheckedOut can reuse the local tickets branch without refreshing remotes", async () => { - const projectRoot = await createTempGitProject({ - prefix: "hack-cli-tickets-git-local-checkout-", - }); - - const channel = __testOnly.createGitTicketsChannel({ - projectRoot, - config: { - enabled: true, - branch: "hack/tickets", - refMode: "hidden", - remote: "origin", - forceBareClone: false, - }, - logger: { - info: (_input: { message: string }) => {}, - warn: (_input: { message: string }) => {}, - }, - }); - - const initialWorktree = await channel.ensureCheckedOut({ - refreshRemote: false, - }); - expect( - await Bun.file(resolve(initialWorktree, ".hack/tickets/README.md")).text() - ).toContain("Tickets ref for hack-cli"); - - await run({ - cwd: projectRoot, - cmd: ["git", "remote", "add", "origin", "ssh://127.0.0.1:1/does-not-exist"], - }); - - const worktree = await channel.ensureCheckedOut({ refreshRemote: false }); - - expect( - await Bun.file(resolve(worktree, ".hack/tickets/README.md")).text() - ).toContain("Tickets ref for hack-cli"); -}); - -test("ensureCheckedOut does not poison a fresh clone after an unreachable first remote", async () => { - const remoteRoot = await mkdtemp(join(tmpdir(), "hack-cli-tickets-remote-")); - tempRoots.push(remoteRoot); - await run({ cwd: remoteRoot, cmd: ["git", "init", "--bare"] }); - - const writerRoot = await createTempGitProject({ - prefix: "hack-cli-tickets-git-writer-recovery-", - }); - await run({ - cwd: writerRoot, - cmd: ["git", "remote", "add", "origin", remoteRoot], - }); - - const writerChannel = __testOnly.createGitTicketsChannel({ - projectRoot: writerRoot, - config: { - enabled: true, - branch: "hack/tickets", - refMode: "hidden", - remote: "origin", - forceBareClone: false, - }, - logger: { - info: (_input: { message: string }) => {}, - warn: (_input: { message: string }) => {}, - }, - }); - expect( - await writerChannel.appendEvents({ - events: [ - createTicketEvent({ - eventId: "event-1", - ticketId: "T-AAAAAAA111", - ts: 1, - }), - ], - }) - ).toEqual({ ok: true }); - - const readerRoot = await createTempGitProject({ - prefix: "hack-cli-tickets-git-reader-recovery-", - }); - await run({ - cwd: readerRoot, - cmd: ["git", "remote", "add", "origin", "ssh://127.0.0.1:1/does-not-exist"], - }); - - const readerChannel = __testOnly.createGitTicketsChannel({ - projectRoot: readerRoot, - config: { - enabled: true, - branch: "hack/tickets", - refMode: "hidden", - remote: "origin", - forceBareClone: false, - }, - logger: { - info: (_input: { message: string }) => {}, - warn: (_input: { message: string }) => {}, - }, - }); - - await expect(readerChannel.ensureCheckedOut()).rejects.toThrow(); - - await run({ - cwd: readerRoot, - cmd: ["git", "remote", "set-url", "origin", remoteRoot], - }); - - const worktree = await readerChannel.ensureCheckedOut(); - expect( - await Bun.file( - resolve(worktree, ".hack/tickets/events/events-1970-01.jsonl") - ).text() - ).toContain('"eventId":"event-1"'); -}); - -async function createTempGitProject(input: { - readonly prefix: string; -}): Promise<string> { - const root = await mkdtemp(join(tmpdir(), input.prefix)); - tempRoots.push(root); - await copyDir({ - from: resolve(import.meta.dir, "../examples/tickets"), - to: root, - }); - await run({ cwd: root, cmd: ["git", "init"] }); - await run({ cwd: root, cmd: ["git", "config", "user.email", "tests@hack"] }); - await run({ - cwd: root, - cmd: ["git", "config", "user.name", "hack-cli-tests"], - }); - await run({ cwd: root, cmd: ["git", "add", "-A"] }); - await run({ cwd: root, cmd: ["git", "commit", "-m", "init"] }); - return root; -} - -async function run(input: { - readonly cwd: string; - readonly cmd: readonly string[]; -}) { - const { exitCode, stderr, stdout } = await runCapture(input); - if (exitCode !== 0) { - throw new Error( - `Command failed (${exitCode}): ${input.cmd.join(" ")}\n${stderr || stdout}` - ); - } -} - -async function runCapture(input: { - readonly cwd: string; - readonly cmd: readonly string[]; -}): Promise<{ - readonly exitCode: number; - readonly stdout: string; - readonly stderr: string; -}> { - const proc = Bun.spawn([...input.cmd], { - cwd: input.cwd, - env: process.env, - stderr: "pipe", - stdin: "ignore", - stdout: "pipe", - }); - - const stdout = await new Response(proc.stdout).text(); - const stderr = await new Response(proc.stderr).text(); - const exitCode = await proc.exited; - return { exitCode, stdout, stderr }; -} - -function createTicketEvent(input: { - readonly eventId: string; - readonly ticketId: string; - readonly ts: number; -}): Record<string, unknown> { - const iso = new Date(input.ts * 1000).toISOString(); - return { - actor: "tests@hack", - eventId: input.eventId, - idempotencyKey: input.eventId, - occurredAt: iso, - payload: { title: input.eventId }, - recordedAt: iso, - schemaVersion: 1, - sourceOperation: "test", - sourceSystem: "hack", - ticketId: input.ticketId, - ts: input.ts, - type: "ticket.created", - }; -} - -async function copyDir(input: { - readonly from: string; - readonly to: string; -}): Promise<void> { - await mkdir(input.to, { recursive: true }); - const entries = await readdir(input.from, { withFileTypes: true }); - for (const entry of entries) { - const fromPath = join(input.from, entry.name); - const toPath = join(input.to, entry.name); - if (entry.isDirectory()) { - await copyDir({ from: fromPath, to: toPath }); - } else if (entry.isFile()) { - const data = await readFile(fromPath); - await writeFile(toPath, data); - } - } -} diff --git a/tests/tickets-id-generation.test.ts b/tests/tickets-id-generation.test.ts deleted file mode 100644 index 9f4baab1..00000000 --- a/tests/tickets-id-generation.test.ts +++ /dev/null @@ -1,199 +0,0 @@ -import { afterEach, expect, test } from "bun:test"; -import { - mkdir, - mkdtemp, - readdir, - readFile, - rm, - writeFile, -} from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join, resolve } from "node:path"; - -import { createTicketsStore } from "../src/control-plane/extensions/tickets/store.ts"; -import { createDefaultControlPlaneConfig } from "../src/control-plane/sdk/config.ts"; - -const logger = { - info: (_input: { message: string }) => {}, - warn: (_input: { message: string }) => {}, -}; - -let tempRoots: string[] = []; - -afterEach(async () => { - for (const root of tempRoots) { - await rm(root, { recursive: true, force: true }); - } - tempRoots = []; -}); - -test("tickets store retries generated ids that collide with an existing local ticket", async () => { - const generatedTicketIds = ["T-AAAAAAAAAA", "T-AAAAAAAAAA", "T-BBBBBBBBBB"]; - - const projectRoot = await createTempGitProject({ - prefix: "hack-cli-tickets-id-collision-", - }); - const store = await createStore({ - projectRoot, - generateTicketId: () => generatedTicketIds.shift() ?? "T-ZZZZZZZZZZ", - }); - - const first = await store.createTicket({ - title: "First ticket", - owner: "hack", - source: "hack", - actor: "creator-1@hack", - }); - expect(first.ok).toBe(true); - - const second = await store.createTicket({ - title: "Second ticket", - owner: "hack", - source: "hack", - actor: "creator-2@hack", - }); - expect(second.ok).toBe(true); - - const tickets = await store.listTickets(); - expect(tickets.map((ticket) => ticket.ticketId)).toEqual([ - "T-AAAAAAAAAA", - "T-BBBBBBBBBB", - ]); -}, 20_000); - -test("tickets store reallocates ticket ids after a push retry sees a remote collision", async () => { - const remoteRoot = await mkdtemp(join(tmpdir(), "hack-cli-tickets-remote-")); - tempRoots.push(remoteRoot); - await run({ cwd: remoteRoot, cmd: ["git", "init", "--bare"] }); - - const projectRootA = await createTempGitProject({ - prefix: "hack-cli-tickets-remote-a-", - remoteRoot, - }); - const projectRootB = await createTempGitProject({ - prefix: "hack-cli-tickets-remote-b-", - remoteRoot, - }); - - const storeA = await createStore({ - projectRoot: projectRootA, - generateTicketId: () => "T-AAAAAAAAAA", - }); - const storeB = await createStore({ - projectRoot: projectRootB, - generateTicketId: (() => { - const generatedIds = ["T-AAAAAAAAAA", "T-BBBBBBBBBB"]; - return () => generatedIds.shift() ?? "T-CCCCCCCCCC"; - })(), - }); - - expect(await storeB.listTickets()).toEqual([]); - - const createdA = await storeA.createTicket({ - title: "First repo ticket", - owner: "hack", - source: "hack", - actor: "creator-a@hack", - }); - expect(createdA.ok).toBe(true); - - const createdB = await storeB.createTicket({ - title: "Second repo ticket", - owner: "hack", - source: "hack", - actor: "creator-b@hack", - }); - expect(createdB.ok).toBe(true); - if (!(createdA.ok && createdB.ok)) { - throw new Error("Expected both ticket creates to succeed"); - } - - expect(createdA.ticket.ticketId).toBe("T-AAAAAAAAAA"); - expect(createdB.ticket.ticketId).toBe("T-BBBBBBBBBB"); - - const tickets = await storeB.listTickets(); - expect(tickets.map((ticket) => ticket.ticketId)).toEqual([ - "T-AAAAAAAAAA", - "T-BBBBBBBBBB", - ]); -}, 20_000); - -async function createStore(opts: { - readonly projectRoot: string; - readonly generateTicketId?: () => string; -}) { - return createTicketsStore({ - projectRoot: opts.projectRoot, - controlPlaneConfig: createDefaultControlPlaneConfig(), - generateTicketId: opts.generateTicketId ?? (() => "T-ZZZZZZZZZZ"), - logger, - }); -} - -async function createTempGitProject(opts: { - readonly prefix: string; - readonly remoteRoot?: string; -}): Promise<string> { - const root = await mkdtemp(join(tmpdir(), opts.prefix)); - tempRoots.push(root); - await copyDir({ - from: resolve(import.meta.dir, "../examples/tickets"), - to: root, - }); - await run({ cwd: root, cmd: ["git", "init"] }); - await run({ cwd: root, cmd: ["git", "config", "user.email", "tests@hack"] }); - await run({ - cwd: root, - cmd: ["git", "config", "user.name", "hack-cli-tests"], - }); - await run({ cwd: root, cmd: ["git", "add", "-A"] }); - await run({ cwd: root, cmd: ["git", "commit", "-m", "init"] }); - if (opts.remoteRoot) { - await run({ - cwd: root, - cmd: ["git", "remote", "add", "origin", opts.remoteRoot], - }); - } - return root; -} - -async function run(opts: { - readonly cwd: string; - readonly cmd: readonly string[]; -}) { - const proc = Bun.spawn([...opts.cmd], { - cwd: opts.cwd, - env: process.env, - stderr: "pipe", - stdin: "ignore", - stdout: "pipe", - }); - - const stdout = await new Response(proc.stdout).text(); - const stderr = await new Response(proc.stderr).text(); - const exitCode = await proc.exited; - - if (exitCode !== 0) { - throw new Error( - `Command failed (${exitCode}): ${opts.cmd.join(" ")}\n${stderr || stdout}` - ); - } -} - -async function copyDir(opts: { - readonly from: string; - readonly to: string; -}): Promise<void> { - await mkdir(opts.to, { recursive: true }); - const entries = await readdir(opts.from, { withFileTypes: true }); - for (const entry of entries) { - const fromPath = join(opts.from, entry.name); - const toPath = join(opts.to, entry.name); - if (entry.isDirectory()) { - await copyDir({ from: fromPath, to: toPath }); - } else if (entry.isFile()) { - const data = await readFile(fromPath); - await writeFile(toPath, data); - } - } -} diff --git a/tests/tickets-store.test.ts b/tests/tickets-store.test.ts deleted file mode 100644 index e064122f..00000000 --- a/tests/tickets-store.test.ts +++ /dev/null @@ -1,1445 +0,0 @@ -import { afterEach, beforeEach, expect, test } from "bun:test"; -import { - chmod, - mkdir, - mkdtemp, - readdir, - readFile, - rm, - stat, - utimes, - writeFile, -} from "node:fs/promises"; -import { homedir, tmpdir } from "node:os"; -import { join, resolve } from "node:path"; -import { - createNormalizedTicket, - projectNormalizedTicketSummary, -} from "../src/control-plane/extensions/tickets/domain.ts"; -import { buildTicketProvenance } from "../src/control-plane/extensions/tickets/provenance.ts"; -import { createTicketsSqliteProjection } from "../src/control-plane/extensions/tickets/sqlite-projection.ts"; -import { createTicketsStore } from "../src/control-plane/extensions/tickets/store.ts"; -import { createGitTicketsChannel } from "../src/control-plane/extensions/tickets/tickets-git-channel.ts"; -import { createDefaultControlPlaneConfig } from "../src/control-plane/sdk/config.ts"; - -const logger = { - info: (_input: { message: string }) => {}, - warn: (_input: { message: string }) => {}, -}; - -const originalHome = process.env.HOME; -let tempHome: string | null = null; -let tempRoots: string[] = []; - -beforeEach(async () => { - tempHome = await mkdtemp(join(tmpdir(), "hack-tickets-store-home-")); - process.env.HOME = tempHome; -}); - -afterEach(async () => { - for (const root of tempRoots) { - await rm(root, { recursive: true, force: true }); - } - tempRoots = []; - - if (tempHome) { - await rm(tempHome, { recursive: true, force: true }); - } - tempHome = null; - - if (originalHome === undefined) { - process.env.HOME = undefined; - } else { - process.env.HOME = originalHome; - } -}); - -test("tickets store materializes assignee, review notes, comments, checkpoints, and conflicts", async () => { - const projectRoot = await createTempGitProject({ - prefix: "hack-cli-tickets-store-", - }); - const store = await createStore({ projectRoot }); - - const created = await store.createTicket({ - title: "Sync metadata ticket", - owner: "hack", - source: "hack", - actor: "creator@hack", - }); - expect(created.ok).toBe(true); - if (!created.ok) { - throw new Error(created.error); - } - - const ticketId = created.ticket.ticketId; - - const updated = await store.updateTicket({ - ticketId, - assignee: "alice@hack", - actor: "router@hack", - }); - expect(updated.ok).toBe(true); - - const firstComment = await store.appendComment({ - ticketId, - body: "Imported from Linear.", - source: "linear", - externalId: "comment-1", - actor: "linear@app", - }); - expect(firstComment.ok).toBe(true); - if (!firstComment.ok) { - throw new Error(firstComment.error); - } - - const secondComment = await store.appendComment({ - ticketId, - body: "Local follow-up.", - source: "hack", - actor: "alice@hack", - }); - expect(secondComment.ok).toBe(true); - if (!secondComment.ok) { - throw new Error(secondComment.error); - } - - const reviewNote = await store.appendReviewNote({ - ticketId, - body: "Investigate assignee before the next pull.", - context: "conflict_review", - actor: "alice@hack", - }); - expect(reviewNote.ok).toBe(true); - if (!reviewNote.ok) { - throw new Error(reviewNote.error); - } - - const linkedComment = await store.linkCommentExternalId({ - ticketId, - commentId: secondComment.comment.commentId, - externalId: "linear-comment-2", - externalUrl: "https://linear.app/issue/HACK-1#comment-2", - actor: "sync@app", - }); - expect(linkedComment.ok).toBe(true); - - const checkpoint = await store.recordSyncCheckpoint({ - ticketId, - provider: "linear", - profileId: "default", - direction: "pull", - remoteCursor: "issue/LIN-123#v2", - remoteUpdatedAt: "2026-03-05T18:15:00.000Z", - actor: "sync@app", - }); - expect(checkpoint.ok).toBe(true); - if (!checkpoint.ok) { - throw new Error(checkpoint.error); - } - - const conflict = await store.recordSyncConflict({ - ticketId, - provider: "linear", - field: "assignee", - localValue: "alice@hack", - remoteValue: "bob@linear", - authority: "origin", - summary: "Assignee diverged during pull.", - actor: "sync@app", - }); - expect(conflict.ok).toBe(true); - if (!conflict.ok) { - throw new Error(conflict.error); - } - - const resolved = await store.resolveSyncConflict({ - ticketId, - conflictId: conflict.conflict.conflictId, - resolution: "accept_remote", - summary: "Linear remains source of truth for this field.", - actor: "alice@hack", - }); - expect(resolved.ok).toBe(true); - - const snapshot = await store.readSnapshot(); - const ticket = snapshot.tickets.find((item) => item.ticketId === ticketId); - expect(ticket?.assignee).toBe("alice@hack"); - - const comments = snapshot.commentsByTicket.get(ticketId) ?? []; - expect(comments).toHaveLength(2); - expect(comments.map((item) => item.body)).toEqual([ - "Imported from Linear.", - "Local follow-up.", - ]); - expect(comments[0]).toMatchObject({ - source: "linear", - externalId: "comment-1", - actor: "linear@app", - }); - expect(comments[1]).toMatchObject({ - source: "hack", - externalId: "linear-comment-2", - externalUrl: "https://linear.app/issue/HACK-1#comment-2", - }); - - const reviewNotes = snapshot.reviewNotesByTicket.get(ticketId) ?? []; - expect(reviewNotes).toHaveLength(1); - expect(reviewNotes[0]).toMatchObject({ - actor: "alice@hack", - body: "Investigate assignee before the next pull.", - context: "conflict_review", - }); - - const checkpoints = snapshot.syncCheckpointsByTicket.get(ticketId) ?? []; - expect(checkpoints).toHaveLength(1); - expect(checkpoints[0]).toMatchObject({ - provider: "linear", - profileId: "default", - direction: "pull", - remoteCursor: "issue/LIN-123#v2", - remoteUpdatedAt: "2026-03-05T18:15:00.000Z", - }); - - const conflicts = snapshot.conflictsByTicket.get(ticketId) ?? []; - expect(conflicts).toHaveLength(1); - expect(conflicts[0]).toMatchObject({ - provider: "linear", - field: "assignee", - status: "resolved", - authority: "origin", - localValue: "alice@hack", - remoteValue: "bob@linear", - resolution: "accept_remote", - resolutionSummary: "Linear remains source of truth for this field.", - }); - - const events = await store.listEvents({ ticketId }); - expect(events.map((item) => item.type)).toEqual([ - "ticket.created", - "ticket.updated", - "ticket.comment_appended", - "ticket.comment_appended", - "ticket.review_note_appended", - "ticket.comment_linked", - "ticket.sync_checkpoint_recorded", - "ticket.sync_conflict_recorded", - "ticket.sync_conflict_resolved", - ]); - expect(events[0]).toMatchObject({ - schemaVersion: 1, - eventType: "ticket.created", - occurredAt: events[0]?.tsIso, - recordedAt: events[0]?.tsIso, - sourceSystem: "hack", - sourceOperation: "local_command", - }); - expect(events[0]?.idempotencyKey).toBe(events[0]?.eventId); -}, 60_000); - -test("tickets show json includes materialized sync metadata", async () => { - const projectRoot = await createTempGitProject({ - prefix: "hack-cli-tickets-show-", - }); - const store = await createStore({ projectRoot }); - - const created = await store.createTicket({ - title: "Show metadata ticket", - owner: "hack", - source: "hack", - actor: "creator@hack", - }); - expect(created.ok).toBe(true); - if (!created.ok) { - throw new Error(created.error); - } - - const ticketId = created.ticket.ticketId; - const updated = await store.updateTicket({ - ticketId, - assignee: "alice@hack", - actor: "router@hack", - }); - expect(updated.ok).toBe(true); - - const comment = await store.appendComment({ - ticketId, - body: "Needs review.", - source: "linear", - actor: "linear@app", - }); - expect(comment.ok).toBe(true); - - const reviewNote = await store.appendReviewNote({ - ticketId, - body: "Shared review note.", - actor: "alice@hack", - }); - expect(reviewNote.ok).toBe(true); - - const checkpoint = await store.recordSyncCheckpoint({ - ticketId, - provider: "linear", - profileId: "default", - direction: "pull", - remoteCursor: "issue/LIN-321#v3", - actor: "sync@app", - }); - expect(checkpoint.ok).toBe(true); - - const conflict = await store.recordSyncConflict({ - ticketId, - provider: "linear", - field: "status", - localValue: "in_progress", - remoteValue: "done", - authority: "origin", - summary: "Status diverged during sync.", - actor: "sync@app", - }); - expect(conflict.ok).toBe(true); - - const shown = await runHack({ - cwd: projectRoot, - args: ["tickets", "show", ticketId, "--json"], - }); - expect(shown.exitCode).toBe(0); - - const payload = JSON.parse(shown.stdout) as { - ticket: { ticketId: string; assignee?: string }; - comments: { body: string }[]; - reviewNotes: { body: string }[]; - syncCheckpoints: { provider: string; remoteCursor?: string }[]; - conflicts: { field: string; status: string }[]; - events: { type: string }[]; - }; - - expect(payload.ticket.ticketId).toBe(ticketId); - expect(payload.ticket.assignee).toBe("alice@hack"); - expect(payload.comments).toHaveLength(1); - expect(payload.comments[0]?.body).toBe("Needs review."); - expect(payload.reviewNotes).toHaveLength(1); - expect(payload.reviewNotes[0]?.body).toBe("Shared review note."); - expect(payload.syncCheckpoints[0]).toMatchObject({ - provider: "linear", - remoteCursor: "issue/LIN-321#v3", - }); - expect(payload.conflicts[0]).toMatchObject({ - field: "status", - status: "open", - }); - expect( - payload.events.some((event) => event.type === "ticket.comment_appended") - ).toBe(true); -}, 60_000); - -test("tickets store recovers from a stale tickets bare repo index.lock", async () => { - const projectRoot = await createTempGitProject({ - prefix: "hack-cli-tickets-stale-lock-", - }); - const store = await createStore({ projectRoot }); - - const created = await store.createTicket({ - title: "Stale lock ticket", - owner: "hack", - source: "hack", - actor: "creator@hack", - }); - expect(created.ok).toBe(true); - if (!created.ok) { - throw new Error(created.error); - } - - const lockPath = join(projectRoot, ".hack/tickets/git/bare.git/index.lock"); - await writeFile(lockPath, "stale lock\n"); - - const tickets = await store.listTickets(); - expect(tickets.map((ticket) => ticket.title)).toContain("Stale lock ticket"); -}, 20_000); - -test("tickets store creates non-sequential ids and keeps them unique under concurrent creates", async () => { - const projectRoot = await createTempGitProject({ - prefix: "hack-cli-tickets-concurrent-create-", - }); - const store = await createStore({ projectRoot }); - const allTicketIds: string[] = []; - - for (let round = 0; round < 3; round += 1) { - const results = await Promise.all( - Array.from({ length: 16 }, (_value, index) => - store.createTicket({ - title: `Concurrent ticket ${round + 1}-${index + 1}`, - owner: "hack", - source: "hack", - actor: `creator-${round}-${index}@hack`, - }) - ) - ); - - if (!results.every((result) => result.ok)) { - throw new Error(JSON.stringify(results, null, 2)); - } - - const ticketIds = results.flatMap((result) => - result.ok ? [result.ticket.ticketId] : [] - ); - expect(ticketIds).toHaveLength(16); - expect(new Set(ticketIds).size).toBe(ticketIds.length); - allTicketIds.push(...ticketIds); - } - - expect(new Set(allTicketIds).size).toBe(allTicketIds.length); - - for (const ticketId of allTicketIds) { - expect(ticketId).toMatch(/^T-[0-9A-Z]{10}$/); - expect(ticketId).not.toMatch(/^T-\d{5}$/); - } -}, 60_000); - -test("tickets store continues to read and update legacy sequential ids", async () => { - const projectRoot = await createTempGitProject({ - prefix: "hack-cli-tickets-legacy-id-", - }); - const store = await createStore({ projectRoot }); - const git = createGitTicketsChannel({ - projectRoot, - config: createDefaultControlPlaneConfig().tickets.git, - logger, - }); - - const legacyEvent = { - actor: "creator@hack", - eventId: "legacy-ticket-created", - payload: { - owner: "hack", - source: "hack", - title: "Legacy sequential ticket", - }, - ticketId: "T-00001", - ts: 1_762_000_000, - tsIso: "2025-11-04T00:00:00.000Z", - type: "ticket.created", - }; - const appended = await git.appendEvents({ events: [legacyEvent] }); - expect(appended.ok).toBe(true); - - const ticket = await store.getTicket({ ticketId: "T-00001" }); - expect(ticket?.title).toBe("Legacy sequential ticket"); - - const updated = await store.updateTicket({ - ticketId: "T-00001", - title: "Legacy sequential ticket updated", - actor: "updater@hack", - }); - expect(updated.ok).toBe(true); - - const updatedTicket = await store.getTicket({ ticketId: "T-00001" }); - expect(updatedTicket?.title).toBe("Legacy sequential ticket updated"); -}, 20_000); - -test("tickets store writes normalized journal envelope metadata and ignores duplicate idempotency keys", async () => { - const projectRoot = await createTempGitProject({ - prefix: "hack-cli-tickets-envelope-", - }); - const store = await createStore({ projectRoot }); - - const created = await store.createTicket({ - title: "Envelope ticket", - owner: "hack", - source: "hack", - actor: "creator@hack", - }); - expect(created.ok).toBe(true); - if (!created.ok) { - throw new Error(created.error); - } - - const eventsDir = join( - projectRoot, - ".hack/tickets/git/worktree/.hack/tickets/events" - ); - const [eventsFile] = (await readdir(eventsDir)).filter((entry) => - entry.endsWith(".jsonl") - ); - expect(eventsFile).toBeString(); - if (!eventsFile) { - throw new Error("Missing tickets event log"); - } - - const eventsPath = join(eventsDir, eventsFile); - const rawLines = (await readFile(eventsPath, "utf8")) - .trim() - .split("\n") - .map((line) => JSON.parse(line) as Record<string, unknown>); - - expect(rawLines[0]).toMatchObject({ - schemaVersion: 1, - ticketId: created.ticket.ticketId, - occurredAt: created.ticket.createdAt, - recordedAt: created.ticket.createdAt, - sourceSystem: "hack", - sourceOperation: "local_command", - }); - expect(rawLines[0]?.idempotencyKey).toBe(rawLines[0]?.eventId); - const baseTs = Number(rawLines[0]?.ts ?? 0) + 1; - - const duplicateBaseEvent = { - schemaVersion: 1, - ticketId: created.ticket.ticketId, - type: "ticket.comment_appended", - payload: { - commentId: "comment-1", - body: "Imported from Linear.", - source: "linear", - }, - actor: "linear@app", - sourceSystem: "linear", - sourceOperation: "webhook_pull", - idempotencyKey: "linear:comment:1", - occurredAt: "2026-03-13T11:00:00.000Z", - recordedAt: "2026-03-13T11:00:01.000Z", - }; - - await writeFile( - eventsPath, - [ - await readFile(eventsPath, "utf8"), - JSON.stringify({ - ...duplicateBaseEvent, - eventId: "event-comment-1", - ts: baseTs, - orderKey: `${baseTs}-000000`, - }), - JSON.stringify({ - ...duplicateBaseEvent, - eventId: "event-comment-2", - ts: baseTs + 1, - orderKey: `${baseTs + 1}-000000`, - }), - "", - ].join("\n") - ); - - const snapshot = await store.readSnapshot(); - const comments = snapshot.commentsByTicket.get(created.ticket.ticketId) ?? []; - expect(comments).toHaveLength(1); - expect(comments[0]).toMatchObject({ - commentId: "comment-1", - body: "Imported from Linear.", - source: "linear", - }); -}, 20_000); - -test("tickets store ignores duplicate sync checkpoints with the same idempotency key", async () => { - const projectRoot = await createTempGitProject({ - prefix: "hack-cli-tickets-checkpoint-idempotency-", - }); - const store = await createStore({ projectRoot }); - - const created = await store.createTicket({ - title: "Checkpoint idempotency", - owner: "hack", - source: "linear", - actor: "creator@hack", - }); - expect(created.ok).toBe(true); - if (!created.ok) { - throw new Error(created.error); - } - - const first = await store.recordSyncCheckpoint({ - ticketId: created.ticket.ticketId, - provider: "linear", - profileId: "default", - direction: "hack_to_linear", - remoteCursor: "ENG-321", - idempotencyKey: "linear:checkpoint:T-00001:ENG-321", - actor: "sync@app", - }); - expect(first.ok).toBe(true); - if (!first.ok) { - throw new Error(first.error); - } - expect(first.recorded).toBe(true); - - const second = await store.recordSyncCheckpoint({ - ticketId: created.ticket.ticketId, - provider: "linear", - profileId: "default", - direction: "hack_to_linear", - remoteCursor: "ENG-321", - idempotencyKey: "linear:checkpoint:T-00001:ENG-321", - actor: "sync@app", - }); - expect(second.ok).toBe(true); - if (!second.ok) { - throw new Error(second.error); - } - expect(second.recorded).toBe(false); - - const detail = await store.getTicketDetail({ - ticketId: created.ticket.ticketId, - }); - expect(detail.syncCheckpoints).toHaveLength(1); - expect( - detail.events.filter( - (event) => event.type === "ticket.sync_checkpoint_recorded" - ) - ).toHaveLength(1); -}, 20_000); - -test("tickets store records immutable documents and projects body from the active description", async () => { - const projectRoot = await createTempGitProject({ - prefix: "hack-cli-tickets-documents-", - }); - const store = await createStore({ projectRoot }); - - const created = await store.createTicket({ - title: "Document-backed ticket", - body: "## Context\nInitial description", - owner: "hack", - source: "hack", - actor: "creator@hack", - }); - expect(created.ok).toBe(true); - if (!created.ok) { - throw new Error(created.error); - } - - const spec = await store.appendDocument({ - ticketId: created.ticket.ticketId, - kind: "spec", - content: "\n## Goals\n- Ship immutable ticket documents", - actor: "author@hack", - }); - expect(spec.ok).toBe(true); - if (!spec.ok) { - throw new Error(spec.error); - } - - const description = await store.appendDocument({ - ticketId: created.ticket.ticketId, - kind: "description", - content: "## Context\nUpdated description", - actor: "author@hack", - }); - expect(description.ok).toBe(true); - if (!description.ok) { - throw new Error(description.error); - } - - const detail = await store.getTicketDetail({ - ticketId: created.ticket.ticketId, - }); - - expect(detail.ticket?.body).toBe("## Context\nUpdated description"); - expect(detail.documents).toEqual([ - expect.objectContaining({ - kind: "description", - role: "description", - content: "## Context\nInitial description", - }), - expect.objectContaining({ - kind: "spec", - role: "spec", - content: "\n## Goals\n- Ship immutable ticket documents", - }), - expect.objectContaining({ - kind: "description", - role: "description", - content: "## Context\nUpdated description", - }), - ]); - expect( - detail.events.filter((event) => event.type === "ticket.document_recorded") - ).toHaveLength(2); -}, 20_000); - -test("tickets store persists a sqlite projection and rebuilds it when deleted", async () => { - const projectRoot = await createTempGitProject({ - prefix: "hack-cli-tickets-projection-", - }); - const projectionPath = join(projectRoot, ".hack/tickets/projection.sqlite"); - - const firstStore = await createStore({ projectRoot }); - const created = await firstStore.createTicket({ - title: "Projection ticket", - body: "Persisted through sqlite projection.", - owner: "hack", - source: "hack", - actor: "creator@hack", - }); - expect(created.ok).toBe(true); - if (!created.ok) { - throw new Error(created.error); - } - - const initialTickets = await firstStore.listTickets(); - expect(initialTickets.map((ticket) => ticket.title)).toContain( - "Projection ticket" - ); - expect(await Bun.file(projectionPath).exists()).toBe(true); - - const secondStore = await createStore({ projectRoot }); - const persistedTickets = await secondStore.listTickets(); - expect(persistedTickets.map((ticket) => ticket.title)).toContain( - "Projection ticket" - ); - - await rm(projectionPath, { force: true }); - expect(await Bun.file(projectionPath).exists()).toBe(false); - - const rebuiltStore = await createStore({ projectRoot }); - const rebuiltTickets = await rebuiltStore.listTickets(); - expect(rebuiltTickets.map((ticket) => ticket.title)).toContain( - "Projection ticket" - ); - expect(await Bun.file(projectionPath).exists()).toBe(true); -}, 20_000); - -test("tickets store hydrates remote tickets on a fresh clone without an explicit sync", async () => { - const remoteRoot = await mkdtemp(join(tmpdir(), "hack-cli-tickets-remote-")); - tempRoots.push(remoteRoot); - await run({ cwd: remoteRoot, cmd: ["git", "init", "--bare"] }); - - const writerRoot = await createTempGitProject({ - prefix: "hack-cli-tickets-writer-", - }); - await run({ - cwd: writerRoot, - cmd: ["git", "remote", "add", "origin", remoteRoot], - }); - const writerStore = await createStore({ projectRoot: writerRoot }); - - const created = await writerStore.createTicket({ - title: "Remote hydration ticket", - body: "Should appear on a fresh clone read path.", - owner: "hack", - source: "hack", - actor: "creator@hack", - }); - expect(created.ok).toBe(true); - if (!created.ok) { - throw new Error(created.error); - } - - const readerRoot = await createTempGitProject({ - prefix: "hack-cli-tickets-reader-", - }); - await run({ - cwd: readerRoot, - cmd: ["git", "remote", "add", "origin", remoteRoot], - }); - const readerStore = await createStore({ projectRoot: readerRoot }); - - const tickets = await readerStore.listTickets(); - expect(tickets.map((ticket) => ticket.title)).toContain( - "Remote hydration ticket" - ); -}, 20_000); - -test("tickets store does not poison a fresh clone after an unreachable first remote", async () => { - const remoteRoot = await mkdtemp(join(tmpdir(), "hack-cli-tickets-remote-")); - tempRoots.push(remoteRoot); - await run({ cwd: remoteRoot, cmd: ["git", "init", "--bare"] }); - - const writerRoot = await createTempGitProject({ - prefix: "hack-cli-tickets-writer-recovery-", - }); - await run({ - cwd: writerRoot, - cmd: ["git", "remote", "add", "origin", remoteRoot], - }); - const writerStore = await createStore({ projectRoot: writerRoot }); - - const created = await writerStore.createTicket({ - title: "Recovered remote hydration ticket", - body: "Should still load after the first remote error is fixed.", - owner: "hack", - source: "hack", - actor: "creator@hack", - }); - expect(created.ok).toBe(true); - if (!created.ok) { - throw new Error(created.error); - } - - const readerRoot = await createTempGitProject({ - prefix: "hack-cli-tickets-reader-recovery-", - }); - await run({ - cwd: readerRoot, - cmd: ["git", "remote", "add", "origin", "ssh://127.0.0.1:1/does-not-exist"], - }); - const readerStore = await createStore({ projectRoot: readerRoot }); - - await expect(readerStore.listTickets()).rejects.toThrow(); - - await run({ - cwd: readerRoot, - cmd: ["git", "remote", "set-url", "origin", remoteRoot], - }); - - const tickets = await readerStore.listTickets(); - expect(tickets.map((ticket) => ticket.title)).toContain( - "Recovered remote hydration ticket" - ); -}, 20_000); - -test("tickets store falls back to local state when remote refresh auth is unavailable", async () => { - const remoteRoot = await mkdtemp(join(tmpdir(), "hack-cli-tickets-remote-")); - tempRoots.push(remoteRoot); - await run({ cwd: remoteRoot, cmd: ["git", "init", "--bare"] }); - - const writerRoot = await createTempGitProject({ - prefix: "hack-cli-tickets-writer-local-fallback-", - }); - await run({ - cwd: writerRoot, - cmd: ["git", "remote", "add", "origin", remoteRoot], - }); - const writerStore = await createStore({ projectRoot: writerRoot }); - - const created = await writerStore.createTicket({ - title: "Falls back to local tickets state", - body: "Should remain readable after SSH auth trouble.", - owner: "hack", - source: "hack", - actor: "creator@hack", - }); - expect(created.ok).toBe(true); - if (!created.ok) { - throw new Error(created.error); - } - - const readerRoot = await createTempGitProject({ - prefix: "hack-cli-tickets-reader-local-fallback-", - }); - await run({ - cwd: readerRoot, - cmd: ["git", "remote", "add", "origin", remoteRoot], - }); - const readerStore = await createStore({ projectRoot: readerRoot }); - - const hydrated = await readerStore.listTickets(); - expect(hydrated.map((ticket) => ticket.title)).toContain( - "Falls back to local tickets state" - ); - - await run({ - cwd: readerRoot, - cmd: [ - "git", - "remote", - "set-url", - "origin", - "ssh://127.0.0.1:1/does-not-exist", - ], - }); - - const fallbackTickets = await readerStore.listTickets(); - expect(fallbackTickets.map((ticket) => ticket.title)).toContain( - "Falls back to local tickets state" - ); -}, 20_000); - -test("tickets store refreshes remote state before validating mutation targets", async () => { - const remoteRoot = await mkdtemp(join(tmpdir(), "hack-cli-tickets-remote-")); - tempRoots.push(remoteRoot); - await run({ cwd: remoteRoot, cmd: ["git", "init", "--bare"] }); - - const writerRoot = await createTempGitProject({ - prefix: "hack-cli-tickets-writer-refresh-", - }); - await run({ - cwd: writerRoot, - cmd: ["git", "remote", "add", "origin", remoteRoot], - }); - const writerStore = await createStore({ projectRoot: writerRoot }); - - const baseTicket = await writerStore.createTicket({ - title: "Base ticket", - owner: "hack", - source: "hack", - actor: "creator@hack", - }); - expect(baseTicket.ok).toBe(true); - if (!baseTicket.ok) { - throw new Error(baseTicket.error); - } - - const readerRoot = await createTempGitProject({ - prefix: "hack-cli-tickets-reader-refresh-", - }); - await run({ - cwd: readerRoot, - cmd: ["git", "remote", "add", "origin", remoteRoot], - }); - const readerStore = await createStore({ projectRoot: readerRoot }); - - const initialTickets = await readerStore.listTickets(); - expect(initialTickets.map((ticket) => ticket.title)).toContain("Base ticket"); - - const created = await writerStore.createTicket({ - title: "Remote-only ticket", - owner: "hack", - source: "hack", - actor: "creator@hack", - }); - expect(created.ok).toBe(true); - if (!created.ok) { - throw new Error(created.error); - } - - const updated = await readerStore.setStatus({ - ticketId: created.ticket.ticketId, - status: "in_progress", - actor: "reader@hack", - }); - expect(updated).toEqual({ ok: true, changed: true }); - - const refreshed = await readerStore.getTicket({ - ticketId: created.ticket.ticketId, - }); - expect(refreshed?.status).toBe("in_progress"); -}, 20_000); - -test("tickets sqlite projection signature tracks journal file metadata instead of reading full contents", async () => { - const projectRoot = await createTempGitProject({ - prefix: "hack-cli-tickets-signature-", - }); - const projection = createTicketsSqliteProjection({ projectRoot }); - const eventsDir = resolve(projectRoot, ".hack/tickets/events"); - const journalPath = resolve(eventsDir, "events-2026-04.jsonl"); - - await mkdir(eventsDir, { recursive: true }); - await writeFile(journalPath, '{"ticketId":"T-ONE"}\n'); - const baseMtimeSeconds = 1_700_000_000; - await utimes(journalPath, baseMtimeSeconds + 0.123, baseMtimeSeconds + 0.123); - - const initial = await projection.computeJournalSignature({ - ticketsRoot: projectRoot, - }); - - await writeFile(journalPath, '{"ticketId":"T-TWO"}\n'); - await utimes(journalPath, baseMtimeSeconds + 0.789, baseMtimeSeconds + 0.789); - - const updatedMetadata = await stat(journalPath); - expect(Math.trunc(updatedMetadata.mtimeMs)).toBe( - Math.trunc((baseMtimeSeconds + 0.789) * 1000) - ); - expect(updatedMetadata.mtimeMs).not.toBe((baseMtimeSeconds + 0.123) * 1000); - - const updated = await projection.computeJournalSignature({ - ticketsRoot: projectRoot, - }); - - expect(updated).not.toBe(initial); -}, 20_000); - -test("normalized ticket adapter preserves compatibility while exposing provenance and documents", () => { - const summary = { - ticketId: "T-00042", - title: "Normalize ticket metadata", - body: "## Context\nDocument-backed description", - status: "in_progress" as const, - createdAt: "2026-03-13T10:00:00.000Z", - updatedAt: "2026-03-13T11:00:00.000Z", - dependsOn: ["T-00001"], - blocks: ["T-00009"], - owner: "hack", - source: "tracker", - assignee: "alice@hack", - tags: ["core", "tickets"], - externalSystem: "tracker", - externalId: "trk_123", - externalKey: "TRK-431", - externalUrl: "https://tracker.example/issues/TRK-431", - externalProjectId: "project-1", - externalProjectName: "Hack App", - externalTeamId: "team-1", - projectId: "hack-cli", - projectName: "hack-cli", - }; - - const normalized = createNormalizedTicket({ - ticket: summary, - syncCheckpoints: [ - { - checkpointId: "checkpoint-1", - ticketId: summary.ticketId, - provider: "tracker", - profileId: "default", - direction: "pull", - remoteCursor: "issue/trk_123#v2", - remoteUpdatedAt: "2026-03-13T10:59:00.000Z", - localUpdatedAt: "2026-03-13T11:00:00.000Z", - actor: "sync@app", - createdAt: "2026-03-13T11:00:00.000Z", - }, - ], - conflicts: [ - { - conflictId: "conflict-1", - ticketId: summary.ticketId, - provider: "tracker", - field: "title", - status: "open", - authority: "review_required", - summary: "Local title drifted from the remote tracker.", - localValue: summary.title, - remoteValue: "Normalize work model", - createdAt: "2026-03-13T11:00:00.000Z", - updatedAt: "2026-03-13T11:00:00.000Z", - }, - ], - }); - - expect(normalized.identity).toEqual({ - ticketId: "T-00042", - projectId: "hack-cli", - projectName: "hack-cli", - }); - expect(normalized.provenance.origin).toEqual({ - owner: "hack", - source: "tracker", - system: "tracker", - }); - expect(normalized.provenance.remotes).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - provider: "tracker", - remoteId: "trk_123", - remoteKey: "TRK-431", - remoteUrl: "https://tracker.example/issues/TRK-431", - projectId: "project-1", - projectName: "Hack App", - teamId: "team-1", - }), - expect.objectContaining({ - provider: "tracker", - profileId: "default", - remoteCursor: "issue/trk_123#v2", - }), - ]) - ); - expect(normalized.documents).toEqual([ - expect.objectContaining({ - kind: "description", - role: "description", - content: "## Context\nDocument-backed description", - }), - ]); - expect(normalized.fieldStates).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - field: "title", - authority: "review_required", - conflictIds: ["conflict-1"], - }), - ]) - ); - expect(projectNormalizedTicketSummary({ ticket: normalized })).toEqual( - summary - ); -}); - -test("normalized ticket provenance captures multiple remotes, field authority, and field versions", () => { - const normalized = createNormalizedTicket({ - ticket: { - ticketId: "T-00077", - title: "Normalize provenance", - body: "Track provenance explicitly.", - status: "open", - createdAt: "2026-03-13T09:00:00.000Z", - updatedAt: "2026-03-13T12:00:00.000Z", - dependsOn: [], - blocks: [], - owner: "hack", - source: "tracker", - assignee: "alice@hack", - tags: ["normalization"], - externalSystem: "tracker", - externalId: "trk-77", - externalKey: "TRK-77", - externalUrl: "https://tracker.example/issues/TRK-77", - externalProjectId: "proj-77", - externalProjectName: "Hack App", - externalTeamId: "team-77", - projectId: "hack-cli", - projectName: "hack-cli", - }, - syncCheckpoints: [ - { - checkpointId: "checkpoint-tracker", - ticketId: "T-00077", - provider: "tracker", - profileId: "default", - direction: "pull", - remoteCursor: "issue/trk-77#v3", - remoteUpdatedAt: "2026-03-13T11:59:00.000Z", - actor: "sync@app", - createdAt: "2026-03-13T12:00:00.000Z", - }, - { - checkpointId: "checkpoint-mirror", - ticketId: "T-00077", - provider: "mirror", - profileId: "mirror", - direction: "replicate", - remoteCursor: "issue/mirror-77#v1", - remoteUpdatedAt: "2026-03-13T11:45:00.000Z", - actor: "sync@app", - createdAt: "2026-03-13T12:00:00.000Z", - }, - ], - conflicts: [ - { - conflictId: "conflict-title", - ticketId: "T-00077", - provider: "tracker", - field: "title", - status: "open", - authority: "review_required", - summary: "Title changed in both places.", - localValue: "Normalize provenance", - remoteValue: "Normalize explicit provenance", - createdAt: "2026-03-13T12:00:00.000Z", - updatedAt: "2026-03-13T12:00:00.000Z", - }, - ], - }); - - expect(normalized.provenance.remotes).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - provider: "tracker", - remoteId: "trk-77", - remoteKey: "TRK-77", - }), - expect.objectContaining({ - provider: "mirror", - profileId: "mirror", - remoteCursor: "issue/mirror-77#v1", - }), - ]) - ); - expect(normalized.provenance.fieldAuthorities).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - field: "title", - authority: "review_required", - }), - expect.objectContaining({ - field: "comment", - authority: "append_only", - }), - ]) - ); - expect(normalized.provenance.fieldVersions).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - field: "title", - source: "local", - recordedAt: "2026-03-13T12:00:00.000Z", - }), - expect.objectContaining({ - field: "title", - source: "remote", - provider: "tracker", - value: "Normalize explicit provenance", - }), - ]) - ); -}); - -test("projectNormalizedTicketSummary uses the latest description document", () => { - const normalized = createNormalizedTicket({ - ticket: { - ticketId: "T-00089", - title: "Prefer the latest description", - body: "Legacy body", - status: "open", - createdAt: "2026-03-13T09:00:00.000Z", - updatedAt: "2026-03-13T12:00:00.000Z", - dependsOn: [], - blocks: [], - owner: "hack", - source: "hack", - tags: [], - }, - documents: [ - { - documentId: "T-00089:description:created", - ticketId: "T-00089", - kind: "description", - role: "description", - content: "Original description", - contentSha256: "sha-original", - createdAt: "2026-03-13T09:00:00.000Z", - updatedAt: "2026-03-13T09:00:00.000Z", - }, - { - documentId: "T-00089:description:updated", - ticketId: "T-00089", - kind: "description", - role: "description", - content: "Latest description", - contentSha256: "sha-latest", - createdAt: "2026-03-13T11:00:00.000Z", - updatedAt: "2026-03-13T12:00:00.000Z", - }, - ], - }); - - expect(projectNormalizedTicketSummary({ ticket: normalized }).body).toBe( - "Latest description" - ); -}); - -test("normalized ticket field states map legacy body conflicts to description", () => { - const normalized = createNormalizedTicket({ - ticket: { - ticketId: "T-00090", - title: "Normalize body conflicts", - body: "Local body", - status: "open", - createdAt: "2026-03-13T09:00:00.000Z", - updatedAt: "2026-03-13T12:00:00.000Z", - dependsOn: [], - blocks: [], - owner: "hack", - source: "linear", - tags: [], - }, - conflicts: [ - { - conflictId: "conflict-description", - ticketId: "T-00090", - provider: "linear", - field: "body", - status: "open", - authority: "review_required", - localValue: "Local body", - remoteValue: "Remote body", - createdAt: "2026-03-13T12:00:00.000Z", - updatedAt: "2026-03-13T12:00:00.000Z", - }, - ], - }); - - expect(normalized.fieldStates).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - field: "description", - authority: "review_required", - conflictIds: ["conflict-description"], - }), - ]) - ); -}); - -test("ticket provenance infers a remote provider from source metadata when externalSystem is absent", () => { - const ticket = { - ticketId: "T-00088", - title: "Infer remote provider", - body: "Link a remote without an explicit external system field.", - status: "open" as const, - createdAt: "2026-03-13T09:00:00.000Z", - updatedAt: "2026-03-13T12:00:00.000Z", - dependsOn: [], - blocks: [], - owner: "hack", - source: "tracker", - tags: ["normalization"], - externalId: "trk-88", - externalKey: "TRK-88", - externalUrl: "https://tracker.example/issues/TRK-88", - externalProjectId: "proj-88", - externalProjectName: "Hack App", - externalTeamId: "team-88", - }; - - const provenance = buildTicketProvenance({ - ticket, - syncCheckpoints: [ - { - checkpointId: "checkpoint-tracker", - ticketId: "T-00088", - provider: "tracker", - direction: "pull", - remoteCursor: "issue/trk-88#v1", - remoteUpdatedAt: "2026-03-13T11:59:00.000Z", - actor: "sync@app", - createdAt: "2026-03-13T12:00:00.000Z", - }, - ], - }); - - expect(provenance.remotes).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - provider: "tracker", - remoteId: "trk-88", - remoteKey: "TRK-88", - remoteUrl: "https://tracker.example/issues/TRK-88", - }), - ]) - ); - expect(provenance.fieldAuthorities).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - field: "title", - authority: "remote", - }), - expect.objectContaining({ - field: "comment", - authority: "append_only", - }), - ]) - ); -}); - -test("tickets store surfaces repository-not-found instead of falling back to stale local state", async () => { - const projectRoot = await createTempGitProject({ - prefix: "hack-cli-tickets-store-missing-remote-", - }); - const store = await createStore({ projectRoot }); - - const created = await store.createTicket({ - title: "Local snapshot", - owner: "hack", - source: "hack", - actor: "creator@hack", - }); - expect(created.ok).toBe(true); - - const remoteScriptPath = join(projectRoot, "fake-ssh.sh"); - await writeFile( - remoteScriptPath, - [ - "#!/bin/sh", - "echo \"fatal: repository 'git@github.com:hack-dance/missing.git' not found\" >&2", - "exit 128", - "", - ].join("\n") - ); - await chmod(remoteScriptPath, 0o755); - await run({ - cwd: projectRoot, - cmd: [ - "git", - "remote", - "add", - "origin", - "git@github.com:hack-dance/missing.git", - ], - }); - - const originalGitSshCommand = process.env.GIT_SSH_COMMAND; - process.env.GIT_SSH_COMMAND = remoteScriptPath; - - try { - await expect(store.readSnapshot()).rejects.toThrow("repository"); - } finally { - process.env.GIT_SSH_COMMAND = originalGitSshCommand; - } -}); - -async function createStore(opts: { readonly projectRoot: string }) { - return createTicketsStore({ - projectRoot: opts.projectRoot, - controlPlaneConfig: createDefaultControlPlaneConfig(), - logger, - }); -} - -async function createTempGitProject(opts: { - readonly prefix: string; -}): Promise<string> { - const root = await mkdtemp(join(tmpdir(), opts.prefix)); - tempRoots.push(root); - await copyDir({ - from: resolve(import.meta.dir, "../examples/tickets"), - to: root, - }); - await run({ cwd: root, cmd: ["git", "init"] }); - await run({ cwd: root, cmd: ["git", "config", "user.email", "tests@hack"] }); - await run({ - cwd: root, - cmd: ["git", "config", "user.name", "hack-cli-tests"], - }); - await run({ cwd: root, cmd: ["git", "add", "-A"] }); - await run({ cwd: root, cmd: ["git", "commit", "-m", "init"] }); - return root; -} - -type RunResult = { - readonly stdout: string; - readonly stderr: string; - readonly exitCode: number; -}; - -async function run(opts: { - readonly cwd: string; - readonly cmd: readonly string[]; -}): Promise<RunResult> { - const result = await runAllowFail(opts); - if (result.exitCode !== 0) { - throw new Error( - `Command failed (${result.exitCode}): ${opts.cmd.join(" ")}\n${result.stderr || result.stdout}` - ); - } - return result; -} - -async function runAllowFail(opts: { - readonly cwd: string; - readonly cmd: readonly string[]; -}): Promise<RunResult> { - const proc = Bun.spawn([...opts.cmd], { - cwd: opts.cwd, - stdout: "pipe", - stderr: "pipe", - stdin: "ignore", - env: { - ...process.env, - HOME: process.env.HOME ?? homedir(), - HACK_SETUP_SYNC_MODE: "off", - NO_COLOR: "1", - }, - }); - - const stdout = await new Response(proc.stdout).text(); - const stderr = await new Response(proc.stderr).text(); - const exitCode = await proc.exited; - - return { stdout, stderr, exitCode }; -} - -async function runHack(opts: { - readonly cwd: string; - readonly args: readonly string[]; -}): Promise<RunResult> { - return await runAllowFail({ - cwd: opts.cwd, - cmd: ["bun", resolve(import.meta.dir, "../index.ts"), ...opts.args], - }); -} - -async function copyDir(opts: { - readonly from: string; - readonly to: string; -}): Promise<void> { - await mkdir(opts.to, { recursive: true }); - const entries = await readdir(opts.from, { withFileTypes: true }); - for (const entry of entries) { - const fromPath = join(opts.from, entry.name); - const toPath = join(opts.to, entry.name); - if (entry.isDirectory()) { - await copyDir({ from: fromPath, to: toPath }); - } else if (entry.isFile()) { - const data = await readFile(fromPath); - await writeFile(toPath, data); - } - } -} diff --git a/tests/tickets-util-id-generation.test.ts b/tests/tickets-util-id-generation.test.ts deleted file mode 100644 index 0b45258c..00000000 --- a/tests/tickets-util-id-generation.test.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { expect, test } from "bun:test"; - -import { generateTicketId } from "../src/control-plane/extensions/tickets/util.ts"; - -test("generateTicketId never emits an all-digit suffix", () => { - const ticketId = generateTicketId(); - - expect(ticketId).toMatch(/^T-[0-9A-Z]{10}$/); - expect(ticketId).not.toMatch(/^T-\d{10}$/); -}); diff --git a/tests/tickets-util.test.ts b/tests/tickets-util.test.ts deleted file mode 100644 index 3565cb48..00000000 --- a/tests/tickets-util.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { expect, test } from "bun:test"; - -import { - normalizeTicketRef, - normalizeTicketRefs, -} from "../src/control-plane/extensions/tickets/util.ts"; - -test("normalizeTicketRef preserves legacy numeric shorthand", () => { - expect(normalizeTicketRef("7")).toBe("T-00007"); - expect(normalizeTicketRef("#42")).toBe("T-00042"); - expect(normalizeTicketRef("t-00009")).toBe("T-00009"); -}); - -test("normalizeTicketRef canonicalizes new-style ids with a T- prefix", () => { - expect(normalizeTicketRef("t-ab12cd34ef")).toBe("T-AB12CD34EF"); - expect(normalizeTicketRef("T-AB12CD34EF")).toBe("T-AB12CD34EF"); -}); - -test("normalizeTicketRef rejects unprefixed non-legacy ids", () => { - expect(normalizeTicketRef("ab12cd34ef")).toBeNull(); -}); - -test("normalizeTicketRefs dedupes mixed legacy and new-style ids", () => { - expect( - normalizeTicketRefs(["7", "T-00007", "t-ab12cd34ef", "T-AB12CD34EF", "#7"]) - ).toEqual(["T-00007", "T-AB12CD34EF"]); -}); From 8d9e2ce401182569d537ed88dc606b2210a2af93 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <dimitrikennedy@gmail.com> Date: Tue, 1 Sep 2026 21:05:32 -0400 Subject: [PATCH 02/10] fix(setup): clean retired agent artifacts safely --- .codex/skills/hack-cli/SKILL.md | 1 + .cursor/rules/hack.mdc | 1 + AGENTS.md | 1 + CLAUDE.md | 1 + src/agents/instruction-source.ts | 1 + src/agents/legacy-artifacts.ts | 209 +++++++++++++++++++++++++++ src/commands/setup.ts | 21 ++- src/templates.ts | 4 +- tests/experimental-gating.test.ts | 2 - tests/hack-gitignore.test.ts | 2 + tests/legacy-agent-artifacts.test.ts | 124 ++++++++++++++++ tests/setup.test.ts | 22 +++ 12 files changed, 384 insertions(+), 5 deletions(-) create mode 100644 src/agents/legacy-artifacts.ts create mode 100644 tests/legacy-agent-artifacts.test.ts diff --git a/.codex/skills/hack-cli/SKILL.md b/.codex/skills/hack-cli/SKILL.md index 92689982..8f77515e 100644 --- a/.codex/skills/hack-cli/SKILL.md +++ b/.codex/skills/hack-cli/SKILL.md @@ -194,6 +194,7 @@ Use `hack` as the primary interface for local-first development. - Use `hack setup sync` only when explicitly choosing to manage project or user docs, skills, rules, hooks, or MCP config. - Read-only freshness checks are available through `hack setup sync --all-scopes --check`, `hack doctor`, and `hack agent prime`. - Refresh project + user integrations: `hack setup sync --all-scopes` +- Explicit sync removes recognized Hack-owned artifacts from retired integrations and preserves unrecognized files for manual review. - Audit integration state only: `hack setup sync --all-scopes --check` - Remove generated integration artifacts: `hack setup sync --all-scopes --remove` - After upgrading CLI: `hack update` then `hack setup sync --all-scopes` diff --git a/.cursor/rules/hack.mdc b/.cursor/rules/hack.mdc index cdf2157d..4da30974 100644 --- a/.cursor/rules/hack.mdc +++ b/.cursor/rules/hack.mdc @@ -95,6 +95,7 @@ Prefer `hack` when shell access is available. Use MCP only when shell access is - Use `hack setup sync` only when explicitly choosing to manage project or user docs, skills, rules, hooks, or MCP config. - Read-only freshness checks are available through `hack setup sync --all-scopes --check`, `hack doctor`, and `hack agent prime`. - Refresh project + user integrations: `hack setup sync --all-scopes` +- Explicit sync removes recognized Hack-owned artifacts from retired integrations and preserves unrecognized files for manual review. - Audit integration state only: `hack setup sync --all-scopes --check` - Remove generated integration artifacts: `hack setup sync --all-scopes --remove` - After upgrading CLI: `hack update` then `hack setup sync --all-scopes` diff --git a/AGENTS.md b/AGENTS.md index bca3a2ea..4e50cfe5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -370,6 +370,7 @@ Agent integration maintenance: - Use `hack setup sync` only when explicitly choosing to manage project or user docs, skills, rules, hooks, or MCP config. - Read-only freshness checks are available through `hack setup sync --all-scopes --check`, `hack doctor`, and `hack agent prime`. - Refresh project + user integrations: `hack setup sync --all-scopes` +- Explicit sync removes recognized Hack-owned artifacts from retired integrations and preserves unrecognized files for manual review. - Audit integration state only: `hack setup sync --all-scopes --check` - Remove generated integration artifacts: `hack setup sync --all-scopes --remove` - After upgrading CLI: `hack update` then `hack setup sync --all-scopes` diff --git a/CLAUDE.md b/CLAUDE.md index 8f729cae..e6fe21ec 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -240,6 +240,7 @@ Agent integration maintenance: - Use `hack setup sync` only when explicitly choosing to manage project or user docs, skills, rules, hooks, or MCP config. - Read-only freshness checks are available through `hack setup sync --all-scopes --check`, `hack doctor`, and `hack agent prime`. - Refresh project + user integrations: `hack setup sync --all-scopes` +- Explicit sync removes recognized Hack-owned artifacts from retired integrations and preserves unrecognized files for manual review. - Audit integration state only: `hack setup sync --all-scopes --check` - Remove generated integration artifacts: `hack setup sync --all-scopes --remove` - After upgrading CLI: `hack update` then `hack setup sync --all-scopes` diff --git a/src/agents/instruction-source.ts b/src/agents/instruction-source.ts index 62c13db4..592d9b7d 100644 --- a/src/agents/instruction-source.ts +++ b/src/agents/instruction-source.ts @@ -311,6 +311,7 @@ export const INSTRUCTION_SECTIONS: readonly InstructionSection[] = [ "Use `hack setup sync` only when explicitly choosing to manage project or user docs, skills, rules, hooks, or MCP config.", "Read-only freshness checks are available through `hack setup sync --all-scopes --check`, `hack doctor`, and `hack agent prime`.", "Refresh project + user integrations: `hack setup sync --all-scopes`", + "Explicit sync removes recognized Hack-owned artifacts from retired integrations and preserves unrecognized files for manual review.", "Audit integration state only: `hack setup sync --all-scopes --check`", "Remove generated integration artifacts: `hack setup sync --all-scopes --remove`", "After upgrading CLI: `hack update` then `hack setup sync --all-scopes`", diff --git a/src/agents/legacy-artifacts.ts b/src/agents/legacy-artifacts.ts new file mode 100644 index 00000000..8209137e --- /dev/null +++ b/src/agents/legacy-artifacts.ts @@ -0,0 +1,209 @@ +import { rm } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; + +import { readTextFile, writeTextFileIfChanged } from "../lib/fs.ts"; + +export type LegacyAgentArtifactResult = { + readonly status: "absent" | "deprecated" | "removed" | "error"; + readonly path: string; + readonly message?: string; +}; + +type LegacySkillDefinition = { + readonly path: string; + readonly markers: readonly RegExp[]; +}; + +const LEGACY_TICKETS_SKILL_MARKER = /name:\s*hack-tickets\b/i; +const LEGACY_HACK_SKILL_MARKER = /name:\s*hack\b/i; +const LEGACY_HACK_HOMEPAGE_MARKER = + /homepage:\s*https:\/\/github\.com\/hack-dance\/hack-cli/i; +const LEGACY_TICKETS_DOC_START = "<!-- hack:tickets:start -->"; +const LEGACY_TICKETS_DOC_END = "<!-- hack:tickets:end -->"; + +/** Audit retired project artifacts without treating unrelated user files as Hack-owned. */ +export async function checkLegacyProjectAgentArtifacts({ + projectRoot, +}: { + readonly projectRoot: string; +}): Promise<LegacyAgentArtifactResult[]> { + return await Promise.all([ + checkLegacySkill({ + definition: { + path: resolve(projectRoot, ".codex/skills/hack-tickets/SKILL.md"), + markers: [LEGACY_TICKETS_SKILL_MARKER], + }, + }), + checkLegacyInstructionBlock({ path: resolve(projectRoot, "AGENTS.md") }), + checkLegacyInstructionBlock({ path: resolve(projectRoot, "CLAUDE.md") }), + ]); +} + +/** Remove only retired project artifacts with recognized Hack ownership markers. */ +export async function removeLegacyProjectAgentArtifacts({ + projectRoot, +}: { + readonly projectRoot: string; +}): Promise<LegacyAgentArtifactResult[]> { + return await Promise.all([ + removeLegacySkill({ + definition: { + path: resolve(projectRoot, ".codex/skills/hack-tickets/SKILL.md"), + markers: [LEGACY_TICKETS_SKILL_MARKER], + }, + }), + removeLegacyInstructionBlock({ path: resolve(projectRoot, "AGENTS.md") }), + removeLegacyInstructionBlock({ path: resolve(projectRoot, "CLAUDE.md") }), + ]); +} + +/** Audit retired user-scope skills without reading or removing arbitrary skill directories. */ +export async function checkLegacyUserAgentArtifacts({ + home = process.env.HOME, +}: { + readonly home?: string; +} = {}): Promise<LegacyAgentArtifactResult[]> { + const definitions = resolveLegacyUserSkillDefinitions({ home }); + if (!definitions.ok) { + return [ + { status: "error", path: "SKILL.md", message: definitions.message }, + ]; + } + return await Promise.all( + definitions.items.map( + async (definition) => await checkLegacySkill({ definition }) + ) + ); +} + +/** Remove only retired user-scope skills whose known ownership markers match. */ +export async function removeLegacyUserAgentArtifacts({ + home = process.env.HOME, +}: { + readonly home?: string; +} = {}): Promise<LegacyAgentArtifactResult[]> { + const definitions = resolveLegacyUserSkillDefinitions({ home }); + if (!definitions.ok) { + return [ + { status: "error", path: "SKILL.md", message: definitions.message }, + ]; + } + return await Promise.all( + definitions.items.map( + async (definition) => await removeLegacySkill({ definition }) + ) + ); +} + +function resolveLegacyUserSkillDefinitions({ + home, +}: { + readonly home?: string; +}): + | { readonly ok: true; readonly items: readonly LegacySkillDefinition[] } + | { readonly ok: false; readonly message: string } { + const resolvedHome = (home ?? "").trim(); + if (!resolvedHome) { + return { + ok: false, + message: "HOME is not set; cannot resolve legacy skills.", + }; + } + return { + ok: true, + items: [ + { + path: resolve(resolvedHome, ".codex/skills/hack-tickets/SKILL.md"), + markers: [LEGACY_TICKETS_SKILL_MARKER], + }, + { + path: resolve(resolvedHome, ".ai/skills/hack/SKILL.md"), + markers: [LEGACY_HACK_SKILL_MARKER, LEGACY_HACK_HOMEPAGE_MARKER], + }, + { + path: resolve(resolvedHome, ".ai/skills/hack-tickets/SKILL.md"), + markers: [LEGACY_TICKETS_SKILL_MARKER], + }, + ], + }; +} + +async function checkLegacySkill({ + definition, +}: { + readonly definition: LegacySkillDefinition; +}): Promise<LegacyAgentArtifactResult> { + const content = await readTextFile(definition.path); + if (!content) { + return { status: "absent", path: definition.path }; + } + const owned = definition.markers.every((marker) => marker.test(content)); + return { + status: owned ? "deprecated" : "error", + path: definition.path, + message: owned + ? `Retired Hack agent artifact remains at ${definition.path}. Run: hack setup sync --all-scopes` + : `Refusing to remove unrecognized skill at ${definition.path}`, + }; +} + +async function removeLegacySkill({ + definition, +}: { + readonly definition: LegacySkillDefinition; +}): Promise<LegacyAgentArtifactResult> { + const checked = await checkLegacySkill({ definition }); + if (checked.status !== "deprecated") { + return checked; + } + await rm(dirname(definition.path), { recursive: true, force: true }); + return { status: "removed", path: definition.path }; +} + +async function checkLegacyInstructionBlock({ + path, +}: { + readonly path: string; +}): Promise<LegacyAgentArtifactResult> { + const content = await readTextFile(path); + if (!content) { + return { status: "absent", path }; + } + const start = content.indexOf(LEGACY_TICKETS_DOC_START); + const end = content.indexOf(LEGACY_TICKETS_DOC_END); + if (start === -1 && end === -1) { + return { status: "absent", path }; + } + if (start === -1 || end === -1 || end < start) { + return { + status: "error", + path, + message: `Refusing to edit malformed retired Hack instruction markers at ${path}`, + }; + } + return { + status: "deprecated", + path, + message: `Retired Hack instruction block remains at ${path}. Run: hack setup sync --all-scopes`, + }; +} + +async function removeLegacyInstructionBlock({ + path, +}: { + readonly path: string; +}): Promise<LegacyAgentArtifactResult> { + const checked = await checkLegacyInstructionBlock({ path }); + if (checked.status !== "deprecated") { + return checked; + } + const content = (await readTextFile(path)) ?? ""; + const start = content.indexOf(LEGACY_TICKETS_DOC_START); + const afterEnd = + content.indexOf(LEGACY_TICKETS_DOC_END) + LEGACY_TICKETS_DOC_END.length; + const prefix = content.slice(0, start).trimEnd(); + const suffix = content.slice(afterEnd).trimStart(); + const next = [prefix, suffix].filter(Boolean).join("\n\n"); + await writeTextFileIfChanged(path, next ? `${next.trimEnd()}\n` : ""); + return { status: "removed", path }; +} diff --git a/src/commands/setup.ts b/src/commands/setup.ts index b9ed30e3..e091b74f 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -21,6 +21,12 @@ import { installCursorRules, removeCursorRules, } from "../agents/cursor.ts"; +import { + checkLegacyProjectAgentArtifacts, + checkLegacyUserAgentArtifacts, + removeLegacyProjectAgentArtifacts, + removeLegacyUserAgentArtifacts, +} from "../agents/legacy-artifacts.ts"; import { checkSharedHackSkill, installSharedHackSkill, @@ -675,7 +681,8 @@ export function buildSetupSyncScopeResult(input: { return true; } return ( - input.action === "check" && ["missing", "stale"].includes(entry.status) + input.action === "check" && + ["missing", "stale", "deprecated"].includes(entry.status) ); }); const errorCount = failures.filter( @@ -778,6 +785,7 @@ async function runProjectScopeSync(opts: { let cursorResult: Awaited<ReturnType<typeof checkCursorRules>>; let claudeResult: Awaited<ReturnType<typeof checkClaudeHooks>>; let codexResult: Awaited<ReturnType<typeof checkCodexSkill>>; + let legacyResults: SetupMultiLogResult[]; let mcpResults: SetupMultiLogResult[]; let docsResults: SetupMultiLogResult[]; @@ -785,6 +793,7 @@ async function runProjectScopeSync(opts: { cursorResult = await checkCursorRules({ scope: "project", projectRoot }); claudeResult = await checkClaudeHooks({ scope: "project", projectRoot }); codexResult = await checkCodexSkill({ scope: "project", projectRoot }); + legacyResults = await checkLegacyProjectAgentArtifacts({ projectRoot }); mcpResults = await checkMcpConfig({ scope: "project", targets: ["cursor", "claude", "codex"], @@ -798,6 +807,7 @@ async function runProjectScopeSync(opts: { cursorResult = await removeCursorRules({ scope: "project", projectRoot }); claudeResult = await removeClaudeHooks({ scope: "project", projectRoot }); codexResult = await removeCodexSkill({ scope: "project", projectRoot }); + legacyResults = await removeLegacyProjectAgentArtifacts({ projectRoot }); mcpResults = await removeMcpConfig({ scope: "project", targets: ["cursor", "claude", "codex"], @@ -811,6 +821,7 @@ async function runProjectScopeSync(opts: { cursorResult = await installCursorRules({ scope: "project", projectRoot }); claudeResult = await installClaudeHooks({ scope: "project", projectRoot }); codexResult = await installCodexSkill({ scope: "project", projectRoot }); + legacyResults = await removeLegacyProjectAgentArtifacts({ projectRoot }); mcpResults = await installMcpConfig({ scope: "project", targets: ["cursor", "claude", "codex"], @@ -829,6 +840,7 @@ async function runProjectScopeSync(opts: { { label: "Cursor", results: [cursorResult] }, { label: "Claude", results: [claudeResult] }, { label: "Codex", results: [codexResult] }, + { label: "Retired agent artifacts", results: legacyResults }, { label: "MCP config", results: mcpResults }, { label: "Agent docs", results: docsResults }, ], @@ -838,7 +850,7 @@ async function runProjectScopeSync(opts: { /** * Run one sync action across all global (user) scope integrations and log * results. Shared `~/.ai/skills` guidance is managed alongside client-specific - * integrations. + * integrations. Known retired Hack-owned skills are cleaned up safely. */ async function runUserScopeSync(opts: { readonly action: SetupSyncAction; @@ -848,6 +860,7 @@ async function runUserScopeSync(opts: { let claudeResult: Awaited<ReturnType<typeof checkClaudeHooks>>; let codexResult: Awaited<ReturnType<typeof checkCodexSkill>>; let sharedSkillResult: SetupMultiLogResult & { readonly path: string }; + let legacyResults: SetupMultiLogResult[]; let mcpResults: SetupMultiLogResult[]; if (action === "check") { @@ -855,6 +868,7 @@ async function runUserScopeSync(opts: { claudeResult = await checkClaudeHooks({ scope: "user" }); codexResult = await checkCodexSkill({ scope: "user" }); sharedSkillResult = await checkSharedHackSkill(); + legacyResults = await checkLegacyUserAgentArtifacts(); mcpResults = await checkMcpConfig({ scope: "user", targets: ["cursor", "claude", "codex"], @@ -864,6 +878,7 @@ async function runUserScopeSync(opts: { claudeResult = await removeClaudeHooks({ scope: "user" }); codexResult = await removeCodexSkill({ scope: "user" }); sharedSkillResult = await removeSharedHackSkill(); + legacyResults = await removeLegacyUserAgentArtifacts(); mcpResults = await removeMcpConfig({ scope: "user", targets: ["cursor", "claude", "codex"], @@ -873,6 +888,7 @@ async function runUserScopeSync(opts: { claudeResult = await installClaudeHooks({ scope: "user" }); codexResult = await installCodexSkill({ scope: "user" }); sharedSkillResult = await installSharedHackSkill(); + legacyResults = await removeLegacyUserAgentArtifacts(); mcpResults = await installMcpConfig({ scope: "user", targets: ["cursor", "claude", "codex"], @@ -887,6 +903,7 @@ async function runUserScopeSync(opts: { { label: "Claude", results: [claudeResult] }, { label: "Codex", results: [codexResult] }, { label: "Shared Hack skill", results: [sharedSkillResult] }, + { label: "Retired agent artifacts", results: legacyResults }, { label: "MCP config", results: mcpResults }, ], }); diff --git a/src/templates.ts b/src/templates.ts index 5a652f5a..cb9b16b5 100644 --- a/src/templates.ts +++ b/src/templates.ts @@ -309,7 +309,8 @@ export const HACK_DIR_GITIGNORE_END_MARKER = "# end managed by hack" as const; /** * Canonical ignore entries for the committed `.hack/.gitignore`, relative to * the `.hack/` directory. Root-level `.hack.secret.key` stays in the root - * `.gitignore` (see `ensureProjectEnvSecretKey`). + * `.gitignore` (see `ensureProjectEnvSecretKey`). The retired `tickets/` + * entry remains upgrade-safe so existing machine-local caches cannot be staged. */ export const HACK_DIR_GITIGNORE_ENTRIES = [ ".internal/", @@ -318,6 +319,7 @@ export const HACK_DIR_GITIGNORE_ENTRIES = [ ".env.state.json", "hack.env.local.yaml", "hack.env.*.local.yaml", + "tickets/", ] as const; /** diff --git a/tests/experimental-gating.test.ts b/tests/experimental-gating.test.ts index b2368f40..74d000db 100644 --- a/tests/experimental-gating.test.ts +++ b/tests/experimental-gating.test.ts @@ -63,7 +63,6 @@ test("invoking an experimental command warns once on stderr", async () => { "run", "--project", "demo", - "--pr", "--", "echo", "hi", @@ -84,7 +83,6 @@ test("HACK_EXPERIMENTAL_ACK=1 suppresses the experimental warning", async () => "run", "--project", "demo", - "--pr", "--", "echo", "hi", diff --git a/tests/hack-gitignore.test.ts b/tests/hack-gitignore.test.ts index 791b7a0f..92663caa 100644 --- a/tests/hack-gitignore.test.ts +++ b/tests/hack-gitignore.test.ts @@ -186,6 +186,7 @@ test("nested .hack/.gitignore makes git ignore generated files, including in lin ".hack/.env.state.json", ".hack/hack.env.local.yaml", ".hack/hack.env.qa.local.yaml", + ".hack/tickets/state/projection.sqlite", ]) { expect( await gitCheckIgnore({ repoRoot: sourceRoot, path }), @@ -202,6 +203,7 @@ test("nested .hack/.gitignore makes git ignore generated files, including in lin ".hack/.branch/compose.0b88.override.yml", ".hack/.env.state.json", ".hack/hack.env.qa.local.yaml", + ".hack/tickets/git/bare.git/HEAD", ]) { expect( await gitCheckIgnore({ repoRoot: linkedRoot, path }), diff --git a/tests/legacy-agent-artifacts.test.ts b/tests/legacy-agent-artifacts.test.ts new file mode 100644 index 00000000..b052c681 --- /dev/null +++ b/tests/legacy-agent-artifacts.test.ts @@ -0,0 +1,124 @@ +import { afterEach, expect, test } from "bun:test"; +import { mkdir, mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; + +import { + checkLegacyProjectAgentArtifacts, + checkLegacyUserAgentArtifacts, + removeLegacyProjectAgentArtifacts, + removeLegacyUserAgentArtifacts, +} from "../src/agents/legacy-artifacts.ts"; + +const tempRoots: string[] = []; + +afterEach(async () => { + for (const root of tempRoots.splice(0)) { + await rm(root, { recursive: true, force: true }); + } +}); + +test("explicit project sync can audit and remove retired owned artifacts", async () => { + const projectRoot = await createTempRoot(); + const skillPath = resolve(projectRoot, ".codex/skills/hack-tickets/SKILL.md"); + await mkdir(resolve(skillPath, ".."), { recursive: true }); + await Bun.write(skillPath, "---\nname: hack-tickets\n---\n"); + await Bun.write( + resolve(projectRoot, "AGENTS.md"), + [ + "keep before", + "<!-- hack:tickets:start -->", + "retired instructions", + "<!-- hack:tickets:end -->", + "keep after", + "", + ].join("\n") + ); + + const checked = await checkLegacyProjectAgentArtifacts({ projectRoot }); + expect(checked.map((result) => result.status)).toEqual([ + "deprecated", + "deprecated", + "absent", + ]); + + const removed = await removeLegacyProjectAgentArtifacts({ projectRoot }); + expect(removed.map((result) => result.status)).toEqual([ + "removed", + "removed", + "absent", + ]); + expect(await Bun.file(skillPath).exists()).toBe(false); + expect(await Bun.file(resolve(projectRoot, "AGENTS.md")).text()).toBe( + "keep before\n\nkeep after\n" + ); +}); + +test("legacy cleanup refuses an unrecognized skill directory", async () => { + const projectRoot = await createTempRoot(); + const skillPath = resolve(projectRoot, ".codex/skills/hack-tickets/SKILL.md"); + await mkdir(resolve(skillPath, ".."), { recursive: true }); + await Bun.write(skillPath, "---\nname: user-owned-skill\n---\n"); + + const results = await removeLegacyProjectAgentArtifacts({ projectRoot }); + expect(results[0]?.status).toBe("error"); + expect(results[0]?.message).toContain( + "Refusing to remove unrecognized skill" + ); + expect(await Bun.file(skillPath).exists()).toBe(true); +}); + +test("legacy cleanup preserves malformed instruction markers", async () => { + const projectRoot = await createTempRoot(); + const agentsPath = resolve(projectRoot, "AGENTS.md"); + const malformed = [ + "<!-- hack:tickets:end -->", + "user content", + "<!-- hack:tickets:start -->", + "", + ].join("\n"); + await Bun.write(agentsPath, malformed); + + const results = await removeLegacyProjectAgentArtifacts({ projectRoot }); + expect(results[1]?.status).toBe("error"); + expect(results[1]?.message).toContain("malformed retired Hack instruction"); + expect(await Bun.file(agentsPath).text()).toBe(malformed); +}); + +test("explicit user sync removes all recognized retired skill locations", async () => { + const home = await createTempRoot(); + const skillFixtures = [ + { + path: resolve(home, ".codex/skills/hack-tickets/SKILL.md"), + content: "---\nname: hack-tickets\n---\n", + }, + { + path: resolve(home, ".ai/skills/hack/SKILL.md"), + content: + "---\nname: hack\nhomepage: https://github.com/hack-dance/hack-cli\n---\n", + }, + { + path: resolve(home, ".ai/skills/hack-tickets/SKILL.md"), + content: "---\nname: hack-tickets\n---\n", + }, + ]; + for (const fixture of skillFixtures) { + await mkdir(resolve(fixture.path, ".."), { recursive: true }); + await Bun.write(fixture.path, fixture.content); + } + + const checked = await checkLegacyUserAgentArtifacts({ home }); + expect(checked.every((result) => result.status === "deprecated")).toBe(true); + + const removed = await removeLegacyUserAgentArtifacts({ home }); + expect(removed.every((result) => result.status === "removed")).toBe(true); + for (const fixture of skillFixtures) { + expect(await Bun.file(fixture.path).exists()).toBe(false); + } +}); + +async function createTempRoot(): Promise<string> { + const root = await mkdtemp(resolve(tmpdir(), "hack-legacy-agents-")); + tempRoots.push(root); + return root; +} diff --git a/tests/setup.test.ts b/tests/setup.test.ts index ceb95b4c..bee84e81 100644 --- a/tests/setup.test.ts +++ b/tests/setup.test.ts @@ -165,6 +165,28 @@ test("setup sync keeps failing artifact paths visible", () => { }); }); +test("setup sync check reports retired agent artifacts as stale", () => { + const result = buildSetupSyncScopeResult({ + action: "check", + scope: "Project", + groups: [ + { + label: "Retired agent artifacts", + results: [ + { + status: "deprecated", + path: "/repo/.codex/skills/hack-tickets/SKILL.md", + }, + ], + }, + ], + }); + + expect(result.exitCode).toBe(1); + expect(result.item.status).toBe("warn"); + expect(result.item.meta).toBe("0/1 current"); +}); + test("buildInitAssistantReport captures repo signals", async () => { const repoRoot = await setupTempRepo(); await Bun.write( From e8f97aef6c968bb424097d92f254600930f58030 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <dimitrikennedy@gmail.com> Date: Tue, 1 Sep 2026 21:10:35 -0400 Subject: [PATCH 03/10] fix(agents): refresh integration content revision --- .codex/skills/hack-cli/SKILL.md | 2 +- .cursor/rules/hack.mdc | 2 +- AGENTS.md | 2 +- CLAUDE.md | 2 +- src/agents/integration-revision.ts | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.codex/skills/hack-cli/SKILL.md b/.codex/skills/hack-cli/SKILL.md index 8f77515e..28f8a311 100644 --- a/.codex/skills/hack-cli/SKILL.md +++ b/.codex/skills/hack-cli/SKILL.md @@ -16,7 +16,7 @@ Use `hack` as the primary interface for local-first development. - At session start, audit project and global integrations with `hack setup sync --all-scopes --check`. - If anything is stale or missing, run `hack setup sync --all-scopes`, then reload the agent session so cached instructions are replaced. - Never copy or hand-edit generated Hack rules to refresh them; update the CLI and run the sync command. -- Content revision: `9e40aaab2d26` (version alone is not a freshness guarantee). +- Content revision: `844380b12a6e` (version alone is not a freshness guarantee). ## Product boundary diff --git a/.cursor/rules/hack.mdc b/.cursor/rules/hack.mdc index 4da30974..522ca6de 100644 --- a/.cursor/rules/hack.mdc +++ b/.cursor/rules/hack.mdc @@ -11,7 +11,7 @@ Prefer `hack` when shell access is available. Use MCP only when shell access is - At session start, audit project and global integrations with `hack setup sync --all-scopes --check`. - If anything is stale or missing, run `hack setup sync --all-scopes`, then reload the agent session so cached instructions are replaced. - Never copy or hand-edit generated Hack rules to refresh them; update the CLI and run the sync command. -- Content revision: `9e40aaab2d26` (version alone is not a freshness guarantee). +- Content revision: `844380b12a6e` (version alone is not a freshness guarantee). ## Product boundary diff --git a/AGENTS.md b/AGENTS.md index 4e50cfe5..979b3b66 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -214,7 +214,7 @@ Integration freshness: - At session start, audit project and global integrations with `hack setup sync --all-scopes --check`. - If anything is stale or missing, run `hack setup sync --all-scopes`, then reload the agent session so cached instructions are replaced. - Never copy or hand-edit generated Hack rules to refresh them; update the CLI and run the sync command. -- Content revision: `9e40aaab2d26` (version alone is not a freshness guarantee). +- Content revision: `844380b12a6e` (version alone is not a freshness guarantee). Product boundary: - Supported v3 surface: project init, up/down/restart, open, logs, env, host exec/shell, sessions, doctor, and daemon. diff --git a/CLAUDE.md b/CLAUDE.md index e6fe21ec..6f3b94bd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -84,7 +84,7 @@ Integration freshness: - At session start, audit project and global integrations with `hack setup sync --all-scopes --check`. - If anything is stale or missing, run `hack setup sync --all-scopes`, then reload the agent session so cached instructions are replaced. - Never copy or hand-edit generated Hack rules to refresh them; update the CLI and run the sync command. -- Content revision: `9e40aaab2d26` (version alone is not a freshness guarantee). +- Content revision: `844380b12a6e` (version alone is not a freshness guarantee). Product boundary: - Supported v3 surface: project init, up/down/restart, open, logs, env, host exec/shell, sessions, doctor, and daemon. diff --git a/src/agents/integration-revision.ts b/src/agents/integration-revision.ts index 5187e589..6b4bf471 100644 --- a/src/agents/integration-revision.ts +++ b/src/agents/integration-revision.ts @@ -3,4 +3,4 @@ * source test recomputes this value and fails whenever guidance changes * without a revision update. */ -export const HACK_AGENT_INTEGRATION_CONTENT_REVISION = "9e40aaab2d26"; +export const HACK_AGENT_INTEGRATION_CONTENT_REVISION = "844380b12a6e"; From 1807130d374ecb4150162f363c3a1f76ce6de742 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <dimitrikennedy@gmail.com> Date: Wed, 2 Sep 2026 00:03:47 -0400 Subject: [PATCH 04/10] fix(setup): complete retired artifact cleanup --- docs/architecture.md | 6 +++-- docs/cli.md | 12 +++++---- docs/env.md | 3 ++- src/cli/integration-sync.ts | 13 +++++++++- src/lib/doctor-generated-files.ts | 1 + tests/doctor-generated-files.test.ts | 9 ++++++- tests/e2e/scenarios/agent-docs-sync.ts | 36 +++++++++++++++++++++++++- 7 files changed, 69 insertions(+), 11 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index f79d5d61..74c8cf81 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -317,8 +317,10 @@ one, or set `worktree.auto_branch=false` to opt into the base instance explicitl - `hack.config.json` - `hack.branches.json` (optional) - `.gitignore` (committed, self-healing on `init`/`up`; covers machine-local generated files — - `.internal/`, `.branch/`, `.env`, `.env.state.json`, and `hack.env*.local.yaml`. If generated files - leaked into git, `hack doctor --fix` untracks them without deleting them from disk.) + `.internal/`, `.branch/`, `.env`, `.env.state.json`, and `hack.env*.local.yaml`. The retired + `tickets/` path stays ignored only to contain legacy machine-local caches during upgrades. If + generated files leaked into git, `hack doctor --fix` untracks them without deleting them from + disk.) - `hack.env.default.yaml` plus optional `hack.env.<overlay>.yaml` (committed env) - `hack.env.local.yaml` / `hack.env.<overlay>.local.yaml` (worktree-local overrides) - `.env.state.json` diff --git a/docs/cli.md b/docs/cli.md index f16600b2..0d4dbec7 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -300,11 +300,13 @@ materialized `.hack/.env` or `.hack/.env.state.json` is stale and should be rege ## Project files Hack owns a committed `.hack/.gitignore` (self-healing on `init`/`up`) that ignores machine-local -generated files (`.internal/`, `.branch/`, `.env`, `.env.state.json`, `hack.env*.local.yaml`). Keep -it committed. If generated files ever leak into git, `hack doctor --fix` untracks them (the files -stay on disk). Runtime metadata is written to `.internal/compose.runtime.override.yml` for the base -instance and `.branch/compose.<branch>.runtime.override.yml` for branch instances. See -[Architecture](architecture.md) for the full file map. +generated files (`.internal/`, `.branch/`, `.env`, `.env.state.json`, `hack.env*.local.yaml`). The +retired `tickets/` path remains ignored only so upgrades cannot recommit legacy machine-local +caches. Keep `.hack/.gitignore` committed. If generated files ever leak into git, `hack doctor +--fix` untracks them (the files stay on disk). Runtime metadata is written to +`.internal/compose.runtime.override.yml` for the base instance and +`.branch/compose.<branch>.runtime.override.yml` for branch instances. See [Architecture](architecture.md) +for the full file map. The global config root defaults to `~/.hack`; override it with `HACK_HOME`. diff --git a/docs/env.md b/docs/env.md index 014ac521..b89dfb79 100644 --- a/docs/env.md +++ b/docs/env.md @@ -51,6 +51,7 @@ worktrees inherit the rules with zero setup. It covers (patterns relative to - `.env.state.json` - `hack.env.local.yaml` - `hack.env.*.local.yaml` +- `tickets/` (retained only to contain retired machine-local ticket caches during upgrades) How it is maintained: @@ -66,7 +67,7 @@ How it is maintained: Leak detection and repair: - `hack doctor` runs a "generated files" check: it lists any of the paths - above (plus `.hack.secret.key`) that are tracked in git, except a tracked + above (including retired `.hack/tickets/` caches, plus `.hack.secret.key`) that are tracked in git, except a tracked `.hack/hack.env.local.yaml`, which is never flagged because older repos may intentionally track it as the shared `--env local` overlay (see the legacy compatibility note above) diff --git a/src/cli/integration-sync.ts b/src/cli/integration-sync.ts index 7530c7ab..8870c2aa 100644 --- a/src/cli/integration-sync.ts +++ b/src/cli/integration-sync.ts @@ -2,6 +2,10 @@ import { checkClaudeHooks } from "../agents/claude.ts"; import { checkCodexSkill } from "../agents/codex-skill.ts"; import { checkCursorRules } from "../agents/cursor.ts"; import { HACK_AGENT_INTEGRATION_CLI_VERSION } from "../agents/instruction-source.ts"; +import { + checkLegacyProjectAgentArtifacts, + checkLegacyUserAgentArtifacts, +} from "../agents/legacy-artifacts.ts"; import { checkSharedHackSkill } from "../agents/shared-skill.ts"; import { type AgentDocCheckResult, checkAgentDocs } from "../mcp/agent-docs.ts"; import { checkMcpConfig, type McpCheckResult } from "../mcp/install.ts"; @@ -59,6 +63,8 @@ async function detectIntegrationDrift(opts: { mcpProject, mcpUser, docs, + legacyProject, + legacyUser, ] = await Promise.all([ checkCursorRules({ scope: "project", projectRoot: opts.projectRoot }), checkCursorRules({ scope: "user" }), @@ -80,6 +86,8 @@ async function detectIntegrationDrift(opts: { projectRoot: opts.projectRoot, targets: ["agents", "claude"], }), + checkLegacyProjectAgentArtifacts({ projectRoot: opts.projectRoot }), + checkLegacyUserAgentArtifacts(), ]); const singleChecks = [ @@ -97,8 +105,11 @@ async function detectIntegrationDrift(opts: { ); const mcpDrift = hasMcpDrift({ checks: [...mcpProject, ...mcpUser] }); const docsDrift = hasDocDrift({ checks: docs }); + const legacyDrift = [...legacyProject, ...legacyUser].some( + (check) => check.status !== "absent" + ); return { - hasDrift: singleDrift || mcpDrift || docsDrift, + hasDrift: singleDrift || mcpDrift || docsDrift || legacyDrift, }; } diff --git a/src/lib/doctor-generated-files.ts b/src/lib/doctor-generated-files.ts index 12a2bc25..0728c784 100644 --- a/src/lib/doctor-generated-files.ts +++ b/src/lib/doctor-generated-files.ts @@ -26,6 +26,7 @@ export function buildGeneratedFilePathspecs(opts: { `${dir}/.branch`, `${dir}/.env`, `${dir}/.env.state.json`, + `${dir}/tickets`, `${dir}/hack.env.*.local.yaml`, PROJECT_ENV_KEY_FILENAME, ]; diff --git a/tests/doctor-generated-files.test.ts b/tests/doctor-generated-files.test.ts index 05ea66f4..2f130e43 100644 --- a/tests/doctor-generated-files.test.ts +++ b/tests/doctor-generated-files.test.ts @@ -48,6 +48,7 @@ async function createLeakedRepo(): Promise<string> { tempDirs.add(dir); const repoRoot = resolve(dir, "repo"); await mkdir(resolve(repoRoot, ".hack", ".branch"), { recursive: true }); + await mkdir(resolve(repoRoot, ".hack", "tickets"), { recursive: true }); await runGit(["init", "-b", "main"], repoRoot); await runGit(["config", "user.name", "Hack Test"], repoRoot); await runGit(["config", "user.email", "hack@example.com"], repoRoot); @@ -64,6 +65,10 @@ async function createLeakedRepo(): Promise<string> { resolve(repoRoot, ".hack", ".env.state.json"), '{"env":"default"}\n' ); + await writeFile( + resolve(repoRoot, ".hack", "tickets", "legacy-cache.json"), + "{}\n" + ); await writeFile( resolve(repoRoot, PROJECT_ENV_KEY_FILENAME), "super-secret-key\n" @@ -85,6 +90,7 @@ test("inspectTrackedGeneratedFiles lists tracked generated files and flags the s PROJECT_ENV_KEY_FILENAME, ".hack/.branch/compose.x.override.yml", ".hack/.env.state.json", + ".hack/tickets/legacy-cache.json", ]); expect(inspection?.secretKeyTracked).toBe(true); }); @@ -132,7 +138,7 @@ test("untrackGeneratedFiles removes offenders from the index, keeps files on dis projectRoot: repoRoot, projectDirName: ".hack", }); - expect(inspection?.trackedPaths.length).toBe(3); + expect(inspection?.trackedPaths.length).toBe(4); // Same flow as `hack doctor --fix`: untrack, then ensure the nested ignore. const untracked = await untrackGeneratedFiles({ @@ -146,6 +152,7 @@ test("untrackGeneratedFiles removes offenders from the index, keeps files on dis for (const path of [ ".hack/.branch/compose.x.override.yml", ".hack/.env.state.json", + ".hack/tickets/legacy-cache.json", PROJECT_ENV_KEY_FILENAME, ]) { expect( diff --git a/tests/e2e/scenarios/agent-docs-sync.ts b/tests/e2e/scenarios/agent-docs-sync.ts index 4310ec2d..fe6deead 100644 --- a/tests/e2e/scenarios/agent-docs-sync.ts +++ b/tests/e2e/scenarios/agent-docs-sync.ts @@ -175,7 +175,41 @@ export const agentDocsSyncScenario: Scenario = { await Bun.write( agentsPath, - syncedAgents.replace( + `${syncedAgents}\n<!-- hack:tickets:start -->\nRetired ticket guidance\n<!-- hack:tickets:end -->\n` + ); + const legacyPrime = await ctx.cli({ + args: ["agent", "prime"], + cwd: fixture.root, + env: isolatedUserEnv, + }); + expect({ + that: legacyPrime.stdout.includes( + "WARNING: Hack agent integrations are stale" + ), + message: "agent primer should report retained legacy artifacts as stale", + result: legacyPrime, + }); + + const legacyCleanup = await ctx.cli({ + args: ["setup", "sync", "--all-scopes"], + cwd: fixture.root, + env: isolatedUserEnv, + }); + expectExit({ + result: legacyCleanup, + codes: [0], + message: "explicit setup sync should remove retired instruction blocks", + }); + const cleanedAgents = await Bun.file(agentsPath).text(); + expect({ + that: !cleanedAgents.includes("hack:tickets"), + message: "explicit setup sync should remove retired ticket guidance", + result: legacyCleanup, + }); + + await Bun.write( + agentsPath, + cleanedAgents.replace( MARKER_START, `${MARKER_START}\nSTALE-ORDINARY-COMMAND-PROBE` ) From 5279022eb32a08ec960d975393c1cf43c33e0f06 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <dimitrikennedy@gmail.com> Date: Wed, 2 Sep 2026 00:09:39 -0400 Subject: [PATCH 05/10] fix(doctor): audit retired agent artifacts --- src/commands/doctor.ts | 23 +++++++++++++++++++---- tests/doctor-command.test.ts | 9 +++++++++ 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index cecb26a2..e0e64710 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -4,6 +4,10 @@ import { dirname, resolve } from "node:path"; import { note, spinner } from "@clack/prompts"; import { YAML } from "bun"; import { HACK_AGENT_INTEGRATION_CONTENT_REVISION } from "../agents/integration-revision.ts"; +import { + checkLegacyProjectAgentArtifacts, + checkLegacyUserAgentArtifacts, +} from "../agents/legacy-artifacts.ts"; import type { CommandHandlerFor } from "../cli/command.ts"; import { CliUsageError, @@ -3016,11 +3020,22 @@ export async function inspectDoctorAgentIntegrations(opts: { resolve(home, ".ai", "skills", "hack-cli", "SKILL.md"), ]; const marker = `Content revision: \`${HACK_AGENT_INTEGRATION_CONTENT_REVISION}\``; - const contents = await Promise.all(paths.map((path) => readTextFile(path))); + const [contents, legacyProject, legacyUser] = await Promise.all([ + Promise.all(paths.map((path) => readTextFile(path))), + opts.projectRoot + ? checkLegacyProjectAgentArtifacts({ projectRoot: opts.projectRoot }) + : Promise.resolve([]), + checkLegacyUserAgentArtifacts({ home }), + ]); + const hasLegacyArtifacts = [...legacyProject, ...legacyUser].some( + (check) => check.status !== "absent" + ); return { - status: contents.every((content) => content?.includes(marker)) - ? "current" - : "stale", + status: + contents.every((content) => content?.includes(marker)) && + !hasLegacyArtifacts + ? "current" + : "stale", }; } diff --git a/tests/doctor-command.test.ts b/tests/doctor-command.test.ts index 6de23f0c..4041ce95 100644 --- a/tests/doctor-command.test.ts +++ b/tests/doctor-command.test.ts @@ -209,6 +209,15 @@ test("doctor audits global agent guidance without a project", async () => { inspectDoctorAgentIntegrations({ projectRoot: null, homeDir: home }) ).resolves.toEqual({ status: "current" }); + const legacySkillDir = join(home, ".codex", "skills", "hack-tickets"); + const legacySkill = join(legacySkillDir, "SKILL.md"); + await mkdir(legacySkillDir, { recursive: true }); + await writeFile(legacySkill, "---\nname: hack-tickets\n---\n"); + await expect( + inspectDoctorAgentIntegrations({ projectRoot: null, homeDir: home }) + ).resolves.toEqual({ status: "stale" }); + await rm(legacySkillDir, { recursive: true, force: true }); + await writeFile(paths[0] ?? "", "stale\n"); await expect( inspectDoctorAgentIntegrations({ projectRoot: null, homeDir: home }) From c1672b2c9e9dbb68c4659cb3124c200f574f5b23 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <dimitrikennedy@gmail.com> Date: Wed, 2 Sep 2026 00:18:50 -0400 Subject: [PATCH 06/10] fix: remove remaining retired Tickets surfaces --- .factory/library/architecture.md | 10 ++----- .factory/library/environment.md | 3 +- .factory/library/user-testing.md | 5 ++-- .factory/services.yaml | 1 - .factory/skills/control-plane-worker/SKILL.md | 9 +++--- .gitignore | 2 +- .hack/README.md | 11 ------- .hack/hack.config.json | 22 -------------- WORKFLOW.md | 1 - .../DashboardFeature/ProjectDetailView.swift | 3 -- .../ProjectSummary+Capabilities.swift | 7 ----- .../ProjectListResponseTests.swift | 4 +-- examples/basic/.hack/hack.config.json | 5 ---- examples/tickets/.hack/README.md | 3 -- examples/tickets/.hack/docker-compose.yml | 5 ---- examples/tickets/.hack/hack.config.json | 10 ------- examples/tickets/README.md | 3 -- examples/tickets/app.txt | 1 - tests/agent-instruction-source.test.ts | 30 +++++++++++++++++++ 19 files changed, 42 insertions(+), 93 deletions(-) delete mode 100644 examples/tickets/.hack/README.md delete mode 100644 examples/tickets/.hack/docker-compose.yml delete mode 100644 examples/tickets/.hack/hack.config.json delete mode 100644 examples/tickets/README.md delete mode 100644 examples/tickets/app.txt diff --git a/.factory/library/architecture.md b/.factory/library/architecture.md index 25372a41..bf49dc0d 100644 --- a/.factory/library/architecture.md +++ b/.factory/library/architecture.md @@ -5,8 +5,8 @@ Durable architecture rules for current Hack work. ## Core Product Boundary - Hack v3 is CLI-first, local-first, and self-contained. -- Supported product surface: project init, local runtime orchestration, routing/TLS, env and secrets, lifecycle, sessions, diagnostics, MCP/agent setup, the slim macOS companion, and optional local tickets. -- Retired product surfaces: hosted auth, account/org/team admin, web dashboard, built-in GitHub workflows, and built-in Linear sync. +- Supported product surface: project init, local runtime orchestration, routing/TLS, env and secrets, lifecycle, sessions, diagnostics, MCP/agent setup, and the slim macOS companion. +- Retired product surfaces: hosted auth, account/org/team admin, web dashboard, Hack Tickets, built-in GitHub workflows, and built-in Linear sync. - Remote/gateway/node/dispatch code may remain source-available, but it is unsupported experimental and must stay out of first-run docs, release gates, and default agent paths. ## Runtime Ownership @@ -30,9 +30,3 @@ Durable architecture rules for current Hack work. - Use `onConflict: "adopt"` only when a complete existing listener set is equivalent and should be reused. - `singleton` adoption is listener-level reuse, not ownership transfer; Hack must leave adopted external processes running on `hack down`. - Stale mux state should be recovered through lifecycle metadata carefully enough to avoid orphaning Hack-owned processes while not broadening cleanup to unrelated process groups. - -## Tickets Ownership - -- Tickets are optional local helpers, not a headline hosted workflow. -- Durable ticket state is the git-backed JSONL journal under `refs/hack/tickets` or the configured branch ref. -- Local projection and checkout state under `.hack/tickets/` is rebuildable. diff --git a/.factory/library/environment.md b/.factory/library/environment.md index 4ec35a4b..76b9647e 100644 --- a/.factory/library/environment.md +++ b/.factory/library/environment.md @@ -22,7 +22,7 @@ Environment variables, external dependencies, and setup notes for current Hack w - Use `hackdance/hack:slim` for repo-local managed-agent containers when Docker Hub is available. - Inject `HACK_ENV_SECRET_KEY` from the runtime or secret manager; never bake `.hack.secret.key` into an image. -- Slim/codex mode should use repo-local commands such as `hack env list`, `hack host exec`, `hack host shell`, and `hack tickets`. +- Slim/codex mode should use repo-local commands such as `hack env list`, `hack host exec`, and `hack host shell`. - Machine-wide surfaces such as `hack global install`, Caddy/CoreDNS/Loki/Grafana, and local CA bootstrap are not expected in slim mode. ## Runtime and Lifecycle @@ -35,4 +35,3 @@ Environment variables, external dependencies, and setup notes for current Hack w - For stale env compatibility output, use `hack doctor` and `hack env materialize`. - For stale lifecycle state, use `hack doctor`, then `hack down`, then rerun `hack doctor`. -- For tickets remote auth failures, prefer explicit SSH guidance and bounded timeouts over interactive prompts. diff --git a/.factory/library/user-testing.md b/.factory/library/user-testing.md index 70030f97..70cb7c0b 100644 --- a/.factory/library/user-testing.md +++ b/.factory/library/user-testing.md @@ -7,7 +7,7 @@ Testing surfaces, tools, and validation concurrency for current Hack work. ### CLI and runtime - Primary tools: `./dist/hack`, repo-local Bun commands, and global `hack` only for installed-runtime orchestration checks. -- Use for: runtime lifecycle, env, tickets, sessions, doctor, daemon, project config, and agent setup. +- Use for: runtime lifecycle, env, sessions, doctor, daemon, project config, and agent setup. - Prefer `--json` when validating machine-readable behavior. - If validating current-branch command behavior, build first and run `./dist/hack` or `bun index.ts` from the repo root. @@ -32,7 +32,7 @@ Testing surfaces, tools, and validation concurrency for current Hack work. ### CLI validators - Max concurrent validators: `2`. -- Rationale: project state, tickets state, runtime metadata, and branch/worktree artifacts can race. +- Rationale: project state, runtime metadata, and branch/worktree artifacts can race. ### Runtime/lifecycle validators @@ -48,5 +48,4 @@ Testing surfaces, tools, and validation concurrency for current Hack work. - Env changes: cover overlay order, worktree-local overrides, linked-worktree key lookup, host-vs-compose target mode, and materialization drift. - Lifecycle changes: cover shell semantics, process groups, stale metadata, singleton full/partial listener conflicts, and doctor recovery guidance. -- Tickets changes: cover offline/stale-local fallback only for transient connectivity; hard remote misconfiguration should surface clearly. - Agent setup changes: update source renderers and checked-in generated examples, then run setup/MCP tests. diff --git a/.factory/services.yaml b/.factory/services.yaml index 364c18fd..d6e21179 100644 --- a/.factory/services.yaml +++ b/.factory/services.yaml @@ -13,7 +13,6 @@ commands: lifecycle_tests: bun test tests/project-lifecycle-processes.test.ts tests/project-lifecycle-singleton.test.ts tests/project-lifecycle-hygiene.test.ts env_list_json: ./dist/hack env list --json env_tests: bun test tests/project-env-config.test.ts tests/env-command.test.ts tests/project-run-command.test.ts - tickets_tests: bun test tests/tickets-git-channel.test.ts tests/tickets-store.test.ts setup_docs_tests: bun test tests/setup.test.ts tests/mcp.test.ts services: diff --git a/.factory/skills/control-plane-worker/SKILL.md b/.factory/skills/control-plane-worker/SKILL.md index abeb199a..a63f4279 100644 --- a/.factory/skills/control-plane-worker/SKILL.md +++ b/.factory/skills/control-plane-worker/SKILL.md @@ -1,6 +1,6 @@ --- name: control-plane-worker -description: Implements local-first CLI, runtime, env, lifecycle, tickets, and macOS companion features for Hack. +description: Implements local-first CLI, runtime, env, lifecycle, and macOS companion features for Hack. --- # Control Plane Worker @@ -12,7 +12,7 @@ NOTE: Startup and cleanup are handled by `worker-base`. This skill defines the W Use this skill for features that primarily touch: - `src/**` CLI and control-plane code - `.hack/docker-compose.yml`, `.hack/hack.config.json`, or other source-of-truth Hack runtime files -- local runtime orchestration, env/runtime hardening, lifecycle processes, tickets, sessions, MCP/agent setup, docs, or the slim macOS companion +- local runtime orchestration, env/runtime hardening, lifecycle processes, sessions, MCP/agent setup, docs, or the slim macOS companion Do not use this skill for retired v3 surfaces: - hosted auth/account/org/team management @@ -22,16 +22,15 @@ Do not use this skill for retired v3 surfaces: ## Required Skills -- `hack-cli` — invoke when the feature touches `.hack/**`, runtime orchestration, lifecycle/session flows, tickets, env, or any `hack up/ps/open/down` verification. +- `hack-cli` — invoke when the feature touches `.hack/**`, runtime orchestration, lifecycle/session flows, env, or any `hack up/ps/open/down` verification. ## Work Procedure 1. Read the assigned feature, `mission.md`, mission `AGENTS.md`, `.factory/services.yaml`, and relevant `.factory/library/*.md` files. Restate the exact assertions or outcomes the feature must complete. 2. Investigate existing code paths and add the failing test or regression harness first. Prefer the narrowest relevant suites under `tests/*.test.ts`. If the feature has no `fulfills` claims, still add characterization or regression coverage for the changed behavior. -3. Implement the smallest coherent change set in CLI, runtime config, tickets, env, lifecycle, macOS, or agent setup. Never hand-edit `.hack/.internal/**` or `.hack/.branch/**`; only change source-of-truth files. +3. Implement the smallest coherent change set in CLI, runtime config, env, lifecycle, macOS, or agent setup. Never hand-edit `.hack/.internal/**` or `.hack/.branch/**`; only change source-of-truth files. 4. Run focused validators first, then the smallest meaningful `typecheck`/`check` commands for the touched surfaces. For repo-bound CLI behavior, build and validate with `./dist/hack` or repo-local Bun entrypoints. When invoking `bun test` from the repo root against files outside `./tests`, use absolute paths or explicit `./`-prefixed paths that Bun actually honors in this repo so targeted commands do not silently skip files. - If the assigned feature is explicitly about fixing a known red baseline, capture the failing baseline evidence once, then continue the repair work and rerun the gate before handoff. - - If repo-bound GitHub CLI routes cannot reach the changed auth code because `dance.hack.github` is not enabled in project config yet, use a direct resolver or similarly narrow deterministic smoke and record why the repo-bound path was unavailable. - If no safe repo-bound hook exists to force a failure mode (for example local-sync failure injection), deterministic regression tests are acceptable proof as long as you explain why a live manual repro would mutate real project state. - For daemon/gateway request-target hardening, raw-socket regression coverage against the proxy transport is preferred. If you also need live proof without mutating shared user daemon state, an isolated temp-HOME `bun index.ts daemon start --foreground` smoke is an acceptable validation pattern; record the isolation setup in the handoff. - For lifecycle changes, verify shell semantics, process-group cleanup, stale pane/process metadata reconciliation, singleton listener behavior, and doctor recovery guidance. diff --git a/.gitignore b/.gitignore index 5a08cf5b..01ee730c 100644 --- a/.gitignore +++ b/.gitignore @@ -73,7 +73,7 @@ apps/macos/.ghostty/ zig-cache/ zig-out/ -# hack tickets +# Retired Hack Tickets cache retained for upgrade safety .hack/tickets/ .hack/.internal/ .hack/supervisor/ diff --git a/.hack/README.md b/.hack/README.md index 74032c7e..ad92a5a1 100644 --- a/.hack/README.md +++ b/.hack/README.md @@ -1,14 +1,3 @@ # hack-cli -This repo is dogfooding the tickets extension. - -- Enablement: `.hack/hack.config.json` -- Usage: - - `hack x tickets setup` - - `hack x tickets create --title "..." --body-stdin` - - `hack x tickets list` - - `hack x tickets show T-AB12CD34EF` - - `hack x tickets status T-AB12CD34EF in_progress` - - `hack x tickets sync` - No services are required for this repo; `docker-compose.yml` is intentionally empty. diff --git a/.hack/hack.config.json b/.hack/hack.config.json index 514ca61a..1b52be40 100644 --- a/.hack/hack.config.json +++ b/.hack/hack.config.json @@ -3,30 +3,8 @@ "name": "hack-cli", "dev_host": "hack-cli.hack", "controlPlane": { - "extensions": { - "dance.hack.github": { - "enabled": false - }, - "dance.hack.tickets": { - "enabled": false - }, - "dance.hack.linear": { - "enabled": false - } - }, "gateway": { "enabled": true - }, - "routing": { - "overrides": { - "linear": { - "profile": "default", - "projectId": "7a3c8adf-ede5-4d3a-8779-9c32695c76bf", - "projectName": "Hack", - "teamId": "e0aedec9-5273-446f-b975-aa4cd1525900", - "additionalProjects": [] - } - } } } } diff --git a/WORKFLOW.md b/WORKFLOW.md index 81e04752..b0018829 100644 --- a/WORKFLOW.md +++ b/WORKFLOW.md @@ -49,7 +49,6 @@ Supported: - env management - sessions - diagnostics -- optional local tickets - slim macOS companion Removed: diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectDetailView.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectDetailView.swift index 87a87912..b4146fe7 100644 --- a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectDetailView.swift +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectDetailView.swift @@ -88,9 +88,6 @@ struct ProjectDetailView: View { .onChange(of: lifecycleSummary.hasEntries) { _, _ in ensureSidebarSelection() } - .onChange(of: project.supportsTickets) { _, _ in - ensureSidebarSelection() - } .onChange(of: project.kind) { _, _ in ensureSidebarSelection() } diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectSummary+Capabilities.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectSummary+Capabilities.swift index 08795bcd..932c8b40 100644 --- a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectSummary+Capabilities.swift +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectSummary+Capabilities.swift @@ -29,11 +29,6 @@ extension ProjectSummary { !isRuntimeConfigured && !featureList.isEmpty } - var supportsTickets: Bool { - if featureList.contains("tickets") { return true } - return extensionsEnabled?.contains("dance.hack.tickets") == true - } - var runtimeStatusLabel: String { if let runtimeStatus { return displayRuntimeStatus(runtimeStatus) @@ -43,8 +38,6 @@ extension ProjectSummary { private func displayFeatureName(_ feature: String) -> String { switch feature { - case "tickets": - return "Tickets" case "cloudflare": return "Cloudflare" case "tailscale": diff --git a/apps/macos/Packages/Shared/Models/Tests/HackDesktopModelsTests/ProjectListResponseTests.swift b/apps/macos/Packages/Shared/Models/Tests/HackDesktopModelsTests/ProjectListResponseTests.swift index 38574ee0..ce44f94f 100644 --- a/apps/macos/Packages/Shared/Models/Tests/HackDesktopModelsTests/ProjectListResponseTests.swift +++ b/apps/macos/Packages/Shared/Models/Tests/HackDesktopModelsTests/ProjectListResponseTests.swift @@ -20,8 +20,8 @@ final class ProjectListResponseTests: XCTestCase { "repo_root": "/repo", "project_dir": "/repo", "defined_services": ["api"], - "extensions_enabled": ["dance.hack.tickets"], - "features": ["tickets"], + "extensions_enabled": ["dance.hack.cloudflare"], + "features": ["cloudflare"], "service_hosts": { "api": ["api.hack-cli.test", "api.hack-cli.test.gy"] }, diff --git a/examples/basic/.hack/hack.config.json b/examples/basic/.hack/hack.config.json index 399e87a2..a46c26d1 100644 --- a/examples/basic/.hack/hack.config.json +++ b/examples/basic/.hack/hack.config.json @@ -18,11 +18,6 @@ "controlPlane": { "gateway": { "enabled": true - }, - "extensions": { - "dance.hack.linear": { - "enabled": true - } } } } diff --git a/examples/tickets/.hack/README.md b/examples/tickets/.hack/README.md deleted file mode 100644 index f6c53e52..00000000 --- a/examples/tickets/.hack/README.md +++ /dev/null @@ -1,3 +0,0 @@ -Tickets example project for hack-cli. - -Includes a minimal `.hack/` so `findProjectContext()` works in tests. diff --git a/examples/tickets/.hack/docker-compose.yml b/examples/tickets/.hack/docker-compose.yml deleted file mode 100644 index bae9b57c..00000000 --- a/examples/tickets/.hack/docker-compose.yml +++ /dev/null @@ -1,5 +0,0 @@ -name: tickets-example -services: - noop: - image: alpine:3 - command: ["sh", "-c", "sleep 3600"] diff --git a/examples/tickets/.hack/hack.config.json b/examples/tickets/.hack/hack.config.json deleted file mode 100644 index 9fafb328..00000000 --- a/examples/tickets/.hack/hack.config.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "$schema": "https://schemas.hack/hack.config.schema.json", - "name": "tickets-example", - "dev_host": "tickets-example.hack", - "controlPlane": { - "extensions": { - "dance.hack.tickets": { "enabled": true } - } - } -} diff --git a/examples/tickets/README.md b/examples/tickets/README.md deleted file mode 100644 index 7a5d11b4..00000000 --- a/examples/tickets/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# Tickets example repo - -Used by `bun test` to validate the git-backed tickets extension end-to-end. diff --git a/examples/tickets/app.txt b/examples/tickets/app.txt deleted file mode 100644 index 5f1cfce2..00000000 --- a/examples/tickets/app.txt +++ /dev/null @@ -1 +0,0 @@ -example file diff --git a/tests/agent-instruction-source.test.ts b/tests/agent-instruction-source.test.ts index 63ee9d83..9f8d57c2 100644 --- a/tests/agent-instruction-source.test.ts +++ b/tests/agent-instruction-source.test.ts @@ -110,6 +110,36 @@ test("retired Tickets do not appear in generated agent guidance", () => { } }); +test("active contributor guidance and examples do not advertise retired Tickets", async () => { + const guidancePaths = [ + "WORKFLOW.md", + ".hack/README.md", + ".factory/library/architecture.md", + ".factory/library/environment.md", + ".factory/library/user-testing.md", + ".factory/skills/control-plane-worker/SKILL.md", + ]; + const retiredGuidancePattern = + /`hack(?: x)? tickets\b|optional local tickets|tickets extension|dance\.hack\.tickets/i; + for (const path of guidancePaths) { + const content = await Bun.file(path).text(); + expect(content, `${path} advertises retired Tickets`).not.toMatch( + retiredGuidancePattern + ); + } + + for (const path of [ + ".hack/hack.config.json", + "examples/basic/.hack/hack.config.json", + ]) { + const content = await Bun.file(path).text(); + expect(content, `${path} configures a retired extension`).not.toMatch( + /dance\.hack\.(?:github|linear|tickets)/ + ); + } + expect(await Bun.file("examples/tickets/README.md").exists()).toBe(false); +}); + test("all generated surfaces expose integration freshness and repair upfront", () => { for (const [surface, rendered] of Object.entries(RENDERED_SURFACES)) { expect(rendered, `surface "${surface}" lacks freshness status`).toContain( From b007cb47ad457dcf5566379b28a08b45ac781926 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <dimitrikennedy@gmail.com> Date: Wed, 2 Sep 2026 00:26:15 -0400 Subject: [PATCH 07/10] fix(agents): document retained upgrade cache --- .codex/skills/hack-cli/SKILL.md | 4 ++-- .cursor/rules/hack.mdc | 4 ++-- AGENTS.md | 4 ++-- CLAUDE.md | 4 ++-- src/agents/instruction-source.ts | 2 +- src/agents/integration-revision.ts | 2 +- 6 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.codex/skills/hack-cli/SKILL.md b/.codex/skills/hack-cli/SKILL.md index 28f8a311..d469242a 100644 --- a/.codex/skills/hack-cli/SKILL.md +++ b/.codex/skills/hack-cli/SKILL.md @@ -16,7 +16,7 @@ Use `hack` as the primary interface for local-first development. - At session start, audit project and global integrations with `hack setup sync --all-scopes --check`. - If anything is stale or missing, run `hack setup sync --all-scopes`, then reload the agent session so cached instructions are replaced. - Never copy or hand-edit generated Hack rules to refresh them; update the CLI and run the sync command. -- Content revision: `844380b12a6e` (version alone is not a freshness guarantee). +- Content revision: `ca2fd44ef13e` (version alone is not a freshness guarantee). ## Product boundary @@ -74,7 +74,7 @@ Use `hack` as the primary interface for local-first development. - Generated (do not hand-edit): `.hack/.internal/compose.override.yml`, `.hack/.internal/compose.env.override.yml`, `.hack/.internal/compose.runtime.override.yml`, `.hack/.branch/compose.<branch>.override.yml`, `.hack/.branch/compose.<branch>.runtime.override.yml`. - Managed via CLI: `.hack/.internal/extra-hosts.json` (use `hack internal extra-hosts ...` commands). - Lifecycle runtime files: `.hack/.internal/lifecycle/state.json`, `.hack/.internal/lifecycle/*.log`. -- Ignore rules: hack owns a committed `.hack/.gitignore` (self-healing on init/up) covering machine-local generated files (`.internal/`, `.branch/`, `.env`, `.env.state.json`, `hack.env*.local.yaml`); keep it committed, and if generated files leaked into git, `hack doctor --fix` untracks them (files stay on disk). +- Ignore rules: hack owns a committed `.hack/.gitignore` (self-healing on init/up) covering machine-local generated files (`.internal/`, `.branch/`, `.env`, `.env.state.json`, `hack.env*.local.yaml`) plus the retired `tickets/` cache path for upgrade safety; keep it committed, and if generated files leaked into git, `hack doctor --fix` untracks them (files stay on disk). ## Linked git worktrees diff --git a/.cursor/rules/hack.mdc b/.cursor/rules/hack.mdc index 522ca6de..738c860c 100644 --- a/.cursor/rules/hack.mdc +++ b/.cursor/rules/hack.mdc @@ -11,7 +11,7 @@ Prefer `hack` when shell access is available. Use MCP only when shell access is - At session start, audit project and global integrations with `hack setup sync --all-scopes --check`. - If anything is stale or missing, run `hack setup sync --all-scopes`, then reload the agent session so cached instructions are replaced. - Never copy or hand-edit generated Hack rules to refresh them; update the CLI and run the sync command. -- Content revision: `844380b12a6e` (version alone is not a freshness guarantee). +- Content revision: `ca2fd44ef13e` (version alone is not a freshness guarantee). ## Product boundary @@ -37,7 +37,7 @@ Prefer `hack` when shell access is available. Use MCP only when shell access is - Generated (do not hand-edit): `.hack/.internal/compose.override.yml`, `.hack/.internal/compose.env.override.yml`, `.hack/.internal/compose.runtime.override.yml`, `.hack/.branch/compose.<branch>.override.yml`, `.hack/.branch/compose.<branch>.runtime.override.yml`. - Managed via CLI: `.hack/.internal/extra-hosts.json` (use `hack internal extra-hosts ...` commands). - Lifecycle runtime files: `.hack/.internal/lifecycle/state.json`, `.hack/.internal/lifecycle/*.log`. -- Ignore rules: hack owns a committed `.hack/.gitignore` (self-healing on init/up) covering machine-local generated files (`.internal/`, `.branch/`, `.env`, `.env.state.json`, `hack.env*.local.yaml`); keep it committed, and if generated files leaked into git, `hack doctor --fix` untracks them (files stay on disk). +- Ignore rules: hack owns a committed `.hack/.gitignore` (self-healing on init/up) covering machine-local generated files (`.internal/`, `.branch/`, `.env`, `.env.state.json`, `hack.env*.local.yaml`) plus the retired `tickets/` cache path for upgrade safety; keep it committed, and if generated files leaked into git, `hack doctor --fix` untracks them (files stay on disk). ## Linked git worktrees diff --git a/AGENTS.md b/AGENTS.md index 979b3b66..b229a888 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -214,7 +214,7 @@ Integration freshness: - At session start, audit project and global integrations with `hack setup sync --all-scopes --check`. - If anything is stale or missing, run `hack setup sync --all-scopes`, then reload the agent session so cached instructions are replaced. - Never copy or hand-edit generated Hack rules to refresh them; update the CLI and run the sync command. -- Content revision: `844380b12a6e` (version alone is not a freshness guarantee). +- Content revision: `ca2fd44ef13e` (version alone is not a freshness guarantee). Product boundary: - Supported v3 surface: project init, up/down/restart, open, logs, env, host exec/shell, sessions, doctor, and daemon. @@ -265,7 +265,7 @@ Project files (managed vs generated): - Generated (do not hand-edit): `.hack/.internal/compose.override.yml`, `.hack/.internal/compose.env.override.yml`, `.hack/.internal/compose.runtime.override.yml`, `.hack/.branch/compose.<branch>.override.yml`, `.hack/.branch/compose.<branch>.runtime.override.yml`. - Managed via CLI: `.hack/.internal/extra-hosts.json` (use `hack internal extra-hosts ...` commands). - Lifecycle runtime files: `.hack/.internal/lifecycle/state.json`, `.hack/.internal/lifecycle/*.log`. -- Ignore rules: hack owns a committed `.hack/.gitignore` (self-healing on init/up) covering machine-local generated files (`.internal/`, `.branch/`, `.env`, `.env.state.json`, `hack.env*.local.yaml`); keep it committed, and if generated files leaked into git, `hack doctor --fix` untracks them (files stay on disk). +- Ignore rules: hack owns a committed `.hack/.gitignore` (self-healing on init/up) covering machine-local generated files (`.internal/`, `.branch/`, `.env`, `.env.state.json`, `hack.env*.local.yaml`) plus the retired `tickets/` cache path for upgrade safety; keep it committed, and if generated files leaked into git, `hack doctor --fix` untracks them (files stay on disk). Linked git worktrees: - Secret key inherits from the primary checkout automatically through the shared git common dir; set `HACK_ENV_SECRET_KEY` for CI or detached environments. diff --git a/CLAUDE.md b/CLAUDE.md index 6f3b94bd..8bf7a961 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -84,7 +84,7 @@ Integration freshness: - At session start, audit project and global integrations with `hack setup sync --all-scopes --check`. - If anything is stale or missing, run `hack setup sync --all-scopes`, then reload the agent session so cached instructions are replaced. - Never copy or hand-edit generated Hack rules to refresh them; update the CLI and run the sync command. -- Content revision: `844380b12a6e` (version alone is not a freshness guarantee). +- Content revision: `ca2fd44ef13e` (version alone is not a freshness guarantee). Product boundary: - Supported v3 surface: project init, up/down/restart, open, logs, env, host exec/shell, sessions, doctor, and daemon. @@ -135,7 +135,7 @@ Project files (managed vs generated): - Generated (do not hand-edit): `.hack/.internal/compose.override.yml`, `.hack/.internal/compose.env.override.yml`, `.hack/.internal/compose.runtime.override.yml`, `.hack/.branch/compose.<branch>.override.yml`, `.hack/.branch/compose.<branch>.runtime.override.yml`. - Managed via CLI: `.hack/.internal/extra-hosts.json` (use `hack internal extra-hosts ...` commands). - Lifecycle runtime files: `.hack/.internal/lifecycle/state.json`, `.hack/.internal/lifecycle/*.log`. -- Ignore rules: hack owns a committed `.hack/.gitignore` (self-healing on init/up) covering machine-local generated files (`.internal/`, `.branch/`, `.env`, `.env.state.json`, `hack.env*.local.yaml`); keep it committed, and if generated files leaked into git, `hack doctor --fix` untracks them (files stay on disk). +- Ignore rules: hack owns a committed `.hack/.gitignore` (self-healing on init/up) covering machine-local generated files (`.internal/`, `.branch/`, `.env`, `.env.state.json`, `hack.env*.local.yaml`) plus the retired `tickets/` cache path for upgrade safety; keep it committed, and if generated files leaked into git, `hack doctor --fix` untracks them (files stay on disk). Linked git worktrees: - Secret key inherits from the primary checkout automatically through the shared git common dir; set `HACK_ENV_SECRET_KEY` for CI or detached environments. diff --git a/src/agents/instruction-source.ts b/src/agents/instruction-source.ts index 592d9b7d..1c69d20d 100644 --- a/src/agents/instruction-source.ts +++ b/src/agents/instruction-source.ts @@ -131,7 +131,7 @@ export const INSTRUCTION_SECTIONS: readonly InstructionSection[] = [ "Generated (do not hand-edit): `.hack/.internal/compose.override.yml`, `.hack/.internal/compose.env.override.yml`, `.hack/.internal/compose.runtime.override.yml`, `.hack/.branch/compose.<branch>.override.yml`, `.hack/.branch/compose.<branch>.runtime.override.yml`.", "Managed via CLI: `.hack/.internal/extra-hosts.json` (use `hack internal extra-hosts ...` commands).", "Lifecycle runtime files: `.hack/.internal/lifecycle/state.json`, `.hack/.internal/lifecycle/*.log`.", - "Ignore rules: hack owns a committed `.hack/.gitignore` (self-healing on init/up) covering machine-local generated files (`.internal/`, `.branch/`, `.env`, `.env.state.json`, `hack.env*.local.yaml`); keep it committed, and if generated files leaked into git, `hack doctor --fix` untracks them (files stay on disk).", + "Ignore rules: hack owns a committed `.hack/.gitignore` (self-healing on init/up) covering machine-local generated files (`.internal/`, `.branch/`, `.env`, `.env.state.json`, `hack.env*.local.yaml`) plus the retired `tickets/` cache path for upgrade safety; keep it committed, and if generated files leaked into git, `hack doctor --fix` untracks them (files stay on disk).", ], }, { diff --git a/src/agents/integration-revision.ts b/src/agents/integration-revision.ts index 6b4bf471..4e6a21ab 100644 --- a/src/agents/integration-revision.ts +++ b/src/agents/integration-revision.ts @@ -3,4 +3,4 @@ * source test recomputes this value and fails whenever guidance changes * without a revision update. */ -export const HACK_AGENT_INTEGRATION_CONTENT_REVISION = "844380b12a6e"; +export const HACK_AGENT_INTEGRATION_CONTENT_REVISION = "ca2fd44ef13e"; From a9266db12ed7131542cf0c3afd7fd61dd4cf4af0 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <dimitrikennedy@gmail.com> Date: Wed, 2 Sep 2026 00:33:27 -0400 Subject: [PATCH 08/10] fix(projects): hide retired extension capabilities --- src/lib/project-views.ts | 7 +++++++ tests/project-views.test.ts | 29 +++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/src/lib/project-views.ts b/src/lib/project-views.ts index e1617f55..013a38bf 100644 --- a/src/lib/project-views.ts +++ b/src/lib/project-views.ts @@ -887,6 +887,7 @@ async function resolveProjectExtensions(opts: { const enabled = Object.entries(config.extensions) .filter(([, value]) => value.enabled) .map(([key]) => key) + .filter((id) => !RETIRED_EXTENSION_IDS.has(id)) .sort((a, b) => a.localeCompare(b)); const features = enabled .map((id) => mapExtensionFeature(id)) @@ -895,6 +896,12 @@ async function resolveProjectExtensions(opts: { return { enabled, features }; } +const RETIRED_EXTENSION_IDS: ReadonlySet<string> = new Set([ + "dance.hack.github", + "dance.hack.linear", + "dance.hack.tickets", +]); + function mapExtensionFeature(id: string): string | null { switch (id) { case "dance.hack.cloudflare": diff --git a/tests/project-views.test.ts b/tests/project-views.test.ts index e37287c1..4894667a 100644 --- a/tests/project-views.test.ts +++ b/tests/project-views.test.ts @@ -185,6 +185,35 @@ test("buildProjectViews includes defined services and runtime status", async () }); }); +test("buildProjectViews filters retired extensions from upgraded configs", async () => { + const project = await createProject({ + name: "legacy-extensions", + services: [], + configJson: JSON.stringify({ + controlPlane: { + extensions: { + "dance.example.custom": { enabled: true }, + "dance.hack.github": { enabled: true }, + "dance.hack.linear": { enabled: true }, + "dance.hack.tickets": { enabled: true }, + }, + }, + }), + }); + + const views = await buildProjectViews({ + registryProjects: [project], + runtime: [], + runtimeOk: true, + filter: null, + includeUnregistered: false, + muxSessions: [], + }); + + expect(views[0]?.extensionsEnabled).toEqual(["dance.example.custom"]); + expect(views[0]?.features).toEqual(["dance.example.custom"]); +}); + test("buildProjectViews includes explicit project ownership metadata", async () => { const alpha = await createProject({ name: "alpha", From 0cef615d9be69c8f5ac3c21b710b03ff3adb6032 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <dimitrikennedy@gmail.com> Date: Wed, 2 Sep 2026 00:40:50 -0400 Subject: [PATCH 09/10] fix(agents): clarify read-only freshness audits --- .codex/skills/hack-cli/SKILL.md | 4 ++-- .cursor/rules/hack.mdc | 4 ++-- AGENTS.md | 4 ++-- CLAUDE.md | 4 ++-- src/agents/instruction-source.ts | 2 +- src/agents/integration-revision.ts | 2 +- 6 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.codex/skills/hack-cli/SKILL.md b/.codex/skills/hack-cli/SKILL.md index d469242a..feb192be 100644 --- a/.codex/skills/hack-cli/SKILL.md +++ b/.codex/skills/hack-cli/SKILL.md @@ -16,7 +16,7 @@ Use `hack` as the primary interface for local-first development. - At session start, audit project and global integrations with `hack setup sync --all-scopes --check`. - If anything is stale or missing, run `hack setup sync --all-scopes`, then reload the agent session so cached instructions are replaced. - Never copy or hand-edit generated Hack rules to refresh them; update the CLI and run the sync command. -- Content revision: `ca2fd44ef13e` (version alone is not a freshness guarantee). +- Content revision: `994ef1552d14` (version alone is not a freshness guarantee). ## Product boundary @@ -190,7 +190,7 @@ Use `hack` as the primary interface for local-first development. ## Agent integration maintenance -- Ordinary Hack commands, `hack update`, and `hack doctor --fix` never inspect, render, repair, or remove agent integration files. +- Ordinary Hack commands, `hack update`, and `hack doctor --fix` may audit freshness but never render, repair, remove, or otherwise mutate agent integration files. - Use `hack setup sync` only when explicitly choosing to manage project or user docs, skills, rules, hooks, or MCP config. - Read-only freshness checks are available through `hack setup sync --all-scopes --check`, `hack doctor`, and `hack agent prime`. - Refresh project + user integrations: `hack setup sync --all-scopes` diff --git a/.cursor/rules/hack.mdc b/.cursor/rules/hack.mdc index 738c860c..2f6dcab4 100644 --- a/.cursor/rules/hack.mdc +++ b/.cursor/rules/hack.mdc @@ -11,7 +11,7 @@ Prefer `hack` when shell access is available. Use MCP only when shell access is - At session start, audit project and global integrations with `hack setup sync --all-scopes --check`. - If anything is stale or missing, run `hack setup sync --all-scopes`, then reload the agent session so cached instructions are replaced. - Never copy or hand-edit generated Hack rules to refresh them; update the CLI and run the sync command. -- Content revision: `ca2fd44ef13e` (version alone is not a freshness guarantee). +- Content revision: `994ef1552d14` (version alone is not a freshness guarantee). ## Product boundary @@ -91,7 +91,7 @@ Prefer `hack` when shell access is available. Use MCP only when shell access is ## Agent integration maintenance -- Ordinary Hack commands, `hack update`, and `hack doctor --fix` never inspect, render, repair, or remove agent integration files. +- Ordinary Hack commands, `hack update`, and `hack doctor --fix` may audit freshness but never render, repair, remove, or otherwise mutate agent integration files. - Use `hack setup sync` only when explicitly choosing to manage project or user docs, skills, rules, hooks, or MCP config. - Read-only freshness checks are available through `hack setup sync --all-scopes --check`, `hack doctor`, and `hack agent prime`. - Refresh project + user integrations: `hack setup sync --all-scopes` diff --git a/AGENTS.md b/AGENTS.md index b229a888..ecaffa79 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -214,7 +214,7 @@ Integration freshness: - At session start, audit project and global integrations with `hack setup sync --all-scopes --check`. - If anything is stale or missing, run `hack setup sync --all-scopes`, then reload the agent session so cached instructions are replaced. - Never copy or hand-edit generated Hack rules to refresh them; update the CLI and run the sync command. -- Content revision: `ca2fd44ef13e` (version alone is not a freshness guarantee). +- Content revision: `994ef1552d14` (version alone is not a freshness guarantee). Product boundary: - Supported v3 surface: project init, up/down/restart, open, logs, env, host exec/shell, sessions, doctor, and daemon. @@ -366,7 +366,7 @@ Docker compose notes: - Use `docker compose -f .hack/docker-compose.yml exec <service> <cmd>` only if you need exec into a running container. Agent integration maintenance: -- Ordinary Hack commands, `hack update`, and `hack doctor --fix` never inspect, render, repair, or remove agent integration files. +- Ordinary Hack commands, `hack update`, and `hack doctor --fix` may audit freshness but never render, repair, remove, or otherwise mutate agent integration files. - Use `hack setup sync` only when explicitly choosing to manage project or user docs, skills, rules, hooks, or MCP config. - Read-only freshness checks are available through `hack setup sync --all-scopes --check`, `hack doctor`, and `hack agent prime`. - Refresh project + user integrations: `hack setup sync --all-scopes` diff --git a/CLAUDE.md b/CLAUDE.md index 8bf7a961..da80ca0f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -84,7 +84,7 @@ Integration freshness: - At session start, audit project and global integrations with `hack setup sync --all-scopes --check`. - If anything is stale or missing, run `hack setup sync --all-scopes`, then reload the agent session so cached instructions are replaced. - Never copy or hand-edit generated Hack rules to refresh them; update the CLI and run the sync command. -- Content revision: `ca2fd44ef13e` (version alone is not a freshness guarantee). +- Content revision: `994ef1552d14` (version alone is not a freshness guarantee). Product boundary: - Supported v3 surface: project init, up/down/restart, open, logs, env, host exec/shell, sessions, doctor, and daemon. @@ -236,7 +236,7 @@ Docker compose notes: - Use `docker compose -f .hack/docker-compose.yml exec <service> <cmd>` only if you need exec into a running container. Agent integration maintenance: -- Ordinary Hack commands, `hack update`, and `hack doctor --fix` never inspect, render, repair, or remove agent integration files. +- Ordinary Hack commands, `hack update`, and `hack doctor --fix` may audit freshness but never render, repair, remove, or otherwise mutate agent integration files. - Use `hack setup sync` only when explicitly choosing to manage project or user docs, skills, rules, hooks, or MCP config. - Read-only freshness checks are available through `hack setup sync --all-scopes --check`, `hack doctor`, and `hack agent prime`. - Refresh project + user integrations: `hack setup sync --all-scopes` diff --git a/src/agents/instruction-source.ts b/src/agents/instruction-source.ts index 1c69d20d..cd7af368 100644 --- a/src/agents/instruction-source.ts +++ b/src/agents/instruction-source.ts @@ -307,7 +307,7 @@ export const INSTRUCTION_SECTIONS: readonly InstructionSection[] = [ title: "Agent integration maintenance", surfaces: ALL_SURFACES, bullets: [ - "Ordinary Hack commands, `hack update`, and `hack doctor --fix` never inspect, render, repair, or remove agent integration files.", + "Ordinary Hack commands, `hack update`, and `hack doctor --fix` may audit freshness but never render, repair, remove, or otherwise mutate agent integration files.", "Use `hack setup sync` only when explicitly choosing to manage project or user docs, skills, rules, hooks, or MCP config.", "Read-only freshness checks are available through `hack setup sync --all-scopes --check`, `hack doctor`, and `hack agent prime`.", "Refresh project + user integrations: `hack setup sync --all-scopes`", diff --git a/src/agents/integration-revision.ts b/src/agents/integration-revision.ts index 4e6a21ab..489b6b76 100644 --- a/src/agents/integration-revision.ts +++ b/src/agents/integration-revision.ts @@ -3,4 +3,4 @@ * source test recomputes this value and fails whenever guidance changes * without a revision update. */ -export const HACK_AGENT_INTEGRATION_CONTENT_REVISION = "ca2fd44ef13e"; +export const HACK_AGENT_INTEGRATION_CONTENT_REVISION = "994ef1552d14"; From 0d3c354f949f9e0783912008f361ad1e3dfa8d60 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <dimitrikennedy@gmail.com> Date: Wed, 2 Sep 2026 00:49:31 -0400 Subject: [PATCH 10/10] fix(docs): align checked-in agent guidance --- docs/cli.md | 3 ++- examples/basic/AGENTS.md | 19 ++++++++++++------- examples/basic/CLAUDE.md | 19 ++++++++++++------- tests/agent-instruction-source.test.ts | 24 ++++++++++++++++++++++++ 4 files changed, 50 insertions(+), 15 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index 0d4dbec7..48fabc6b 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -61,7 +61,8 @@ Generated agent docs, Cursor rules, Codex skills, and the shared `~/.ai/skills/h the Hack CLI version that generated them. Audit both project and global surfaces with `hack setup sync --all-scopes --check`; repair them with the explicit `hack setup sync --all-scopes`, then reload the agent session so it stops using cached guidance. -Ordinary commands, `hack update`, and `hack doctor --fix` never inspect or rewrite these files. +Ordinary commands, `hack update`, and `hack doctor --fix` may audit freshness but never render, +repair, remove, or otherwise mutate these files. ## First-run path diff --git a/examples/basic/AGENTS.md b/examples/basic/AGENTS.md index e7795766..be27c1e8 100644 --- a/examples/basic/AGENTS.md +++ b/examples/basic/AGENTS.md @@ -4,11 +4,11 @@ Use `hack` as the single interface for local-first runtime orchestration (compose, DNS/TLS, logs, env, and persistent project workspaces). Integration freshness: -- These instructions were generated by hack CLI v3.3.5; treat cached rules from another version as potentially stale. +- These instructions were generated by hack CLI v3.5.2; treat cached rules from another version as potentially stale. - At session start, audit project and global integrations with `hack setup sync --all-scopes --check`. - If anything is stale or missing, run `hack setup sync --all-scopes`, then reload the agent session so cached instructions are replaced. - Never copy or hand-edit generated Hack rules to refresh them; update the CLI and run the sync command. -- Content revision: `b8663fad3ef4` (version alone is not a freshness guarantee). +- Content revision: `994ef1552d14` (version alone is not a freshness guarantee). Product boundary: - Supported v3 surface: project init, up/down/restart, open, logs, env, host exec/shell, sessions, doctor, and daemon. @@ -39,6 +39,7 @@ Hostname routing + Caddy labels: - Primary host comes from `dev_host` (default: `<project>.hack`). - Subdomain pattern is `<sub>.<dev_host>` (for example: `api.myapp.hack`). - OAuth alias (when enabled) also routes `<dev_host>.<tld>` and `<sub>.<dev_host>.<tld>` (default tld: `gy`). +- Browser launches automatically prefer a routed OAuth alias when enabled; custom development hosts outside `.hack` stay on the primary host. Set `open.prefer` or pass `hack open --prefer <auto|alias|dev>` to override. - Not every compose service is routable: only services with Caddy labels and on `hack-dev` are exposed. - Required labels for HTTP services: `caddy`, `caddy.reverse_proxy`, `caddy.tls=internal`. - Quick checks: `hack open`, `hack open <sub>`, `hack open --json`. @@ -55,15 +56,16 @@ Project files (managed vs generated): - Source-of-truth files: `.hack/docker-compose.yml`, `.hack/hack.config.json`, `.hack/hack.env.default.yaml`, and optional `.hack/hack.env.<overlay>.yaml`. - Worktree-local env override files: `.hack/hack.env.local.yaml` and `.hack/hack.env.<overlay>.local.yaml`. - Local-only files: `.hack.secret.key`, optional `.hack/.env` compatibility output, `.hack/.env.state.json`, and `.hack/.internal/` (runtime/local machine state; keep gitignored). -- Generated (do not hand-edit): `.hack/.internal/compose.override.yml`, `.hack/.internal/compose.env.override.yml`, `.hack/.branch/compose.<branch>.override.yml`. +- Generated (do not hand-edit): `.hack/.internal/compose.override.yml`, `.hack/.internal/compose.env.override.yml`, `.hack/.internal/compose.runtime.override.yml`, `.hack/.branch/compose.<branch>.override.yml`, `.hack/.branch/compose.<branch>.runtime.override.yml`. - Managed via CLI: `.hack/.internal/extra-hosts.json` (use `hack internal extra-hosts ...` commands). - Lifecycle runtime files: `.hack/.internal/lifecycle/state.json`, `.hack/.internal/lifecycle/*.log`. -- Ignore rules: hack owns a committed `.hack/.gitignore` (self-healing on init/up) covering machine-local generated files (`.internal/`, `.branch/`, `.env`, `.env.state.json`, `hack.env*.local.yaml`); keep it committed, and if generated files leaked into git, `hack doctor --fix` untracks them (files stay on disk). +- Ignore rules: hack owns a committed `.hack/.gitignore` (self-healing on init/up) covering machine-local generated files (`.internal/`, `.branch/`, `.env`, `.env.state.json`, `hack.env*.local.yaml`) plus the retired `tickets/` cache path for upgrade safety; keep it committed, and if generated files leaked into git, `hack doctor --fix` untracks them (files stay on disk). Linked git worktrees: - Secret key inherits from the primary checkout automatically through the shared git common dir; set `HACK_ENV_SECRET_KEY` for CI or detached environments. - `hack up` in a linked worktree defaults to a branch instance named after the worktree's git branch; a detached linked worktree requires an explicit `--branch`, unless config `worktree.auto_branch=false` explicitly opts into the base instance. - Before `hack up` or `hack restart` auto-targets a new branch instance, Hack warns when the same worktree already owns a non-terminal instance; pass `--branch <name>` to make the target explicit. +- Implicit `hack down` retargets a uniquely owned same-checkout runtime after a Git branch rename, including Created and stopped containers; when multiple runtimes belong to the checkout, pass `--branch <name>` explicitly. - `hack doctor` flags divergent secret keys and dev_host collisions across checkouts. Advanced networking (extra_hosts + local proxies/tunnels): @@ -85,7 +87,7 @@ Running things (decision guide): - Command inside an already-running service container: `hack exec <service> -- <cmd...>`. - Host script that needs hack-stored env: `hack host exec --env <overlay> --scope <service> -- <cmd...>` — this is THE way to run repo scripts; never read .env files directly. - Interactive host shell with injected env: `hack host shell --env <overlay> --scope <service>`. -- Call a service over HTTP (from the host or between containers): use its Caddy hostname `https://<sub>.<dev_host>`; discover routable URLs with `hack open --json`. +- Browser/host URL: use `hack open <service> --json` (OAuth aliases are preferred when enabled). Container-to-container traffic should use Compose DNS. Logs (default is compose): - Fast tail: `hack logs --pretty` @@ -104,11 +106,12 @@ Lifecycle + startup: - Inspect lifecycle status via `hack projects --details` and stream via `hack logs <service-or-process>`. - Lifecycle session recovery is ownership-proven: Hack adopts healthy token-, definition-, and environment-matched sessions, replaces owned stale sessions, and refuses to kill same-name sessions without deterministic ownership proof. - `hack doctor --fix` reaps an orphan lifecycle session only when mux ownership is proven and its Compose instance is absent; unverified same-name sessions are never modified. -- After `hack up` or `hack restart`, running services and successful one-shot services (`exited` with code 0) count as successful; other states return `E_STARTUP_INCOMPLETE`, and `hack doctor` warns about containers stuck in `Created`. +- After `hack up` or `hack restart`, running services and successful one-shot services (`exited` with code 0) count as successful. Hack recognizes dependency installers, `hack.service.one-shot=true`, and services referenced by Compose `condition: service_completed_successfully`; other states return `E_STARTUP_INCOMPLETE`, and `hack doctor` warns about containers stuck in `Created`. - Detached startup is bounded; a hung Compose operation returns `E_STARTUP_TIMEOUT`, terminates its process group, and `hack doctor --fix` can start exact containers left in `Created`. - Target only affected services with `hack up <service...> --detach`, `hack restart <service...>`, or `hack env apply --service <service>`; scoped operations skip project lifecycle hooks and implicit dependency startup. - Use `hack env explain <KEY> --env <overlay> --service <service> --target <host|compose>` for redacted source, precedence, availability, and delivery diagnostics. - Dependency installer services are detected generically by command or `hack.dependencies.bootstrap=true`; registry env references are preflighted before container mutation. Optional `hack.dependencies.cache-volume`, `hack.dependencies.lockfiles`, and `hack.dependencies.runtime-files` labels enable lockfile/runtime-keyed volumes shared across compatible worktrees. +- `hack down --prune-caches` can remove only confirmed Compose-owned named volumes mounted exclusively at `.next` destinations or explicitly labeled `hack.cache.disposable=true`; it is confirmation-gated, requires `--yes` for JSON/scripted runs, and never performs broad volume pruning. Workspaces (mux-managed, tmux-first by default): - Picker: `hack session` for persistent project workspaces. @@ -130,6 +133,7 @@ Branch instances (parallel envs): - Use a branch instance when you need two versions running at once (PR review, experiments, migrations) or want to keep a stable environment while testing another branch. - Target one with `--branch <name>` on up/open/logs/down (for example: `hack up --branch <name> --detach`). - Linked worktrees pick a branch instance automatically (see Linked git worktrees). +- Containers receive `HACK_RUNTIME_METADATA` plus `HACK_DEV_URL`, `HACK_ALIAS_URL`, and current-service URL fields derived from effective Caddy routes. Use Compose DNS for internal traffic and this metadata for browser-facing links, OAuth callbacks, and webhooks. Run commands inside services: - One-off: `hack run <service> <cmd...>` (uses `docker compose run --rm`) @@ -156,10 +160,11 @@ Docker compose notes: - Use `docker compose -f .hack/docker-compose.yml exec <service> <cmd>` only if you need exec into a running container. Agent integration maintenance: -- Ordinary Hack commands, `hack update`, and `hack doctor --fix` never inspect, render, repair, or remove agent integration files. +- Ordinary Hack commands, `hack update`, and `hack doctor --fix` may audit freshness but never render, repair, remove, or otherwise mutate agent integration files. - Use `hack setup sync` only when explicitly choosing to manage project or user docs, skills, rules, hooks, or MCP config. - Read-only freshness checks are available through `hack setup sync --all-scopes --check`, `hack doctor`, and `hack agent prime`. - Refresh project + user integrations: `hack setup sync --all-scopes` +- Explicit sync removes recognized Hack-owned artifacts from retired integrations and preserves unrecognized files for manual review. - Audit integration state only: `hack setup sync --all-scopes --check` - Remove generated integration artifacts: `hack setup sync --all-scopes --remove` - After upgrading CLI: `hack update` then `hack setup sync --all-scopes` diff --git a/examples/basic/CLAUDE.md b/examples/basic/CLAUDE.md index e7795766..be27c1e8 100644 --- a/examples/basic/CLAUDE.md +++ b/examples/basic/CLAUDE.md @@ -4,11 +4,11 @@ Use `hack` as the single interface for local-first runtime orchestration (compose, DNS/TLS, logs, env, and persistent project workspaces). Integration freshness: -- These instructions were generated by hack CLI v3.3.5; treat cached rules from another version as potentially stale. +- These instructions were generated by hack CLI v3.5.2; treat cached rules from another version as potentially stale. - At session start, audit project and global integrations with `hack setup sync --all-scopes --check`. - If anything is stale or missing, run `hack setup sync --all-scopes`, then reload the agent session so cached instructions are replaced. - Never copy or hand-edit generated Hack rules to refresh them; update the CLI and run the sync command. -- Content revision: `b8663fad3ef4` (version alone is not a freshness guarantee). +- Content revision: `994ef1552d14` (version alone is not a freshness guarantee). Product boundary: - Supported v3 surface: project init, up/down/restart, open, logs, env, host exec/shell, sessions, doctor, and daemon. @@ -39,6 +39,7 @@ Hostname routing + Caddy labels: - Primary host comes from `dev_host` (default: `<project>.hack`). - Subdomain pattern is `<sub>.<dev_host>` (for example: `api.myapp.hack`). - OAuth alias (when enabled) also routes `<dev_host>.<tld>` and `<sub>.<dev_host>.<tld>` (default tld: `gy`). +- Browser launches automatically prefer a routed OAuth alias when enabled; custom development hosts outside `.hack` stay on the primary host. Set `open.prefer` or pass `hack open --prefer <auto|alias|dev>` to override. - Not every compose service is routable: only services with Caddy labels and on `hack-dev` are exposed. - Required labels for HTTP services: `caddy`, `caddy.reverse_proxy`, `caddy.tls=internal`. - Quick checks: `hack open`, `hack open <sub>`, `hack open --json`. @@ -55,15 +56,16 @@ Project files (managed vs generated): - Source-of-truth files: `.hack/docker-compose.yml`, `.hack/hack.config.json`, `.hack/hack.env.default.yaml`, and optional `.hack/hack.env.<overlay>.yaml`. - Worktree-local env override files: `.hack/hack.env.local.yaml` and `.hack/hack.env.<overlay>.local.yaml`. - Local-only files: `.hack.secret.key`, optional `.hack/.env` compatibility output, `.hack/.env.state.json`, and `.hack/.internal/` (runtime/local machine state; keep gitignored). -- Generated (do not hand-edit): `.hack/.internal/compose.override.yml`, `.hack/.internal/compose.env.override.yml`, `.hack/.branch/compose.<branch>.override.yml`. +- Generated (do not hand-edit): `.hack/.internal/compose.override.yml`, `.hack/.internal/compose.env.override.yml`, `.hack/.internal/compose.runtime.override.yml`, `.hack/.branch/compose.<branch>.override.yml`, `.hack/.branch/compose.<branch>.runtime.override.yml`. - Managed via CLI: `.hack/.internal/extra-hosts.json` (use `hack internal extra-hosts ...` commands). - Lifecycle runtime files: `.hack/.internal/lifecycle/state.json`, `.hack/.internal/lifecycle/*.log`. -- Ignore rules: hack owns a committed `.hack/.gitignore` (self-healing on init/up) covering machine-local generated files (`.internal/`, `.branch/`, `.env`, `.env.state.json`, `hack.env*.local.yaml`); keep it committed, and if generated files leaked into git, `hack doctor --fix` untracks them (files stay on disk). +- Ignore rules: hack owns a committed `.hack/.gitignore` (self-healing on init/up) covering machine-local generated files (`.internal/`, `.branch/`, `.env`, `.env.state.json`, `hack.env*.local.yaml`) plus the retired `tickets/` cache path for upgrade safety; keep it committed, and if generated files leaked into git, `hack doctor --fix` untracks them (files stay on disk). Linked git worktrees: - Secret key inherits from the primary checkout automatically through the shared git common dir; set `HACK_ENV_SECRET_KEY` for CI or detached environments. - `hack up` in a linked worktree defaults to a branch instance named after the worktree's git branch; a detached linked worktree requires an explicit `--branch`, unless config `worktree.auto_branch=false` explicitly opts into the base instance. - Before `hack up` or `hack restart` auto-targets a new branch instance, Hack warns when the same worktree already owns a non-terminal instance; pass `--branch <name>` to make the target explicit. +- Implicit `hack down` retargets a uniquely owned same-checkout runtime after a Git branch rename, including Created and stopped containers; when multiple runtimes belong to the checkout, pass `--branch <name>` explicitly. - `hack doctor` flags divergent secret keys and dev_host collisions across checkouts. Advanced networking (extra_hosts + local proxies/tunnels): @@ -85,7 +87,7 @@ Running things (decision guide): - Command inside an already-running service container: `hack exec <service> -- <cmd...>`. - Host script that needs hack-stored env: `hack host exec --env <overlay> --scope <service> -- <cmd...>` — this is THE way to run repo scripts; never read .env files directly. - Interactive host shell with injected env: `hack host shell --env <overlay> --scope <service>`. -- Call a service over HTTP (from the host or between containers): use its Caddy hostname `https://<sub>.<dev_host>`; discover routable URLs with `hack open --json`. +- Browser/host URL: use `hack open <service> --json` (OAuth aliases are preferred when enabled). Container-to-container traffic should use Compose DNS. Logs (default is compose): - Fast tail: `hack logs --pretty` @@ -104,11 +106,12 @@ Lifecycle + startup: - Inspect lifecycle status via `hack projects --details` and stream via `hack logs <service-or-process>`. - Lifecycle session recovery is ownership-proven: Hack adopts healthy token-, definition-, and environment-matched sessions, replaces owned stale sessions, and refuses to kill same-name sessions without deterministic ownership proof. - `hack doctor --fix` reaps an orphan lifecycle session only when mux ownership is proven and its Compose instance is absent; unverified same-name sessions are never modified. -- After `hack up` or `hack restart`, running services and successful one-shot services (`exited` with code 0) count as successful; other states return `E_STARTUP_INCOMPLETE`, and `hack doctor` warns about containers stuck in `Created`. +- After `hack up` or `hack restart`, running services and successful one-shot services (`exited` with code 0) count as successful. Hack recognizes dependency installers, `hack.service.one-shot=true`, and services referenced by Compose `condition: service_completed_successfully`; other states return `E_STARTUP_INCOMPLETE`, and `hack doctor` warns about containers stuck in `Created`. - Detached startup is bounded; a hung Compose operation returns `E_STARTUP_TIMEOUT`, terminates its process group, and `hack doctor --fix` can start exact containers left in `Created`. - Target only affected services with `hack up <service...> --detach`, `hack restart <service...>`, or `hack env apply --service <service>`; scoped operations skip project lifecycle hooks and implicit dependency startup. - Use `hack env explain <KEY> --env <overlay> --service <service> --target <host|compose>` for redacted source, precedence, availability, and delivery diagnostics. - Dependency installer services are detected generically by command or `hack.dependencies.bootstrap=true`; registry env references are preflighted before container mutation. Optional `hack.dependencies.cache-volume`, `hack.dependencies.lockfiles`, and `hack.dependencies.runtime-files` labels enable lockfile/runtime-keyed volumes shared across compatible worktrees. +- `hack down --prune-caches` can remove only confirmed Compose-owned named volumes mounted exclusively at `.next` destinations or explicitly labeled `hack.cache.disposable=true`; it is confirmation-gated, requires `--yes` for JSON/scripted runs, and never performs broad volume pruning. Workspaces (mux-managed, tmux-first by default): - Picker: `hack session` for persistent project workspaces. @@ -130,6 +133,7 @@ Branch instances (parallel envs): - Use a branch instance when you need two versions running at once (PR review, experiments, migrations) or want to keep a stable environment while testing another branch. - Target one with `--branch <name>` on up/open/logs/down (for example: `hack up --branch <name> --detach`). - Linked worktrees pick a branch instance automatically (see Linked git worktrees). +- Containers receive `HACK_RUNTIME_METADATA` plus `HACK_DEV_URL`, `HACK_ALIAS_URL`, and current-service URL fields derived from effective Caddy routes. Use Compose DNS for internal traffic and this metadata for browser-facing links, OAuth callbacks, and webhooks. Run commands inside services: - One-off: `hack run <service> <cmd...>` (uses `docker compose run --rm`) @@ -156,10 +160,11 @@ Docker compose notes: - Use `docker compose -f .hack/docker-compose.yml exec <service> <cmd>` only if you need exec into a running container. Agent integration maintenance: -- Ordinary Hack commands, `hack update`, and `hack doctor --fix` never inspect, render, repair, or remove agent integration files. +- Ordinary Hack commands, `hack update`, and `hack doctor --fix` may audit freshness but never render, repair, remove, or otherwise mutate agent integration files. - Use `hack setup sync` only when explicitly choosing to manage project or user docs, skills, rules, hooks, or MCP config. - Read-only freshness checks are available through `hack setup sync --all-scopes --check`, `hack doctor`, and `hack agent prime`. - Refresh project + user integrations: `hack setup sync --all-scopes` +- Explicit sync removes recognized Hack-owned artifacts from retired integrations and preserves unrecognized files for manual review. - Audit integration state only: `hack setup sync --all-scopes --check` - Remove generated integration artifacts: `hack setup sync --all-scopes --remove` - After upgrading CLI: `hack update` then `hack setup sync --all-scopes` diff --git a/tests/agent-instruction-source.test.ts b/tests/agent-instruction-source.test.ts index 9f8d57c2..2381af14 100644 --- a/tests/agent-instruction-source.test.ts +++ b/tests/agent-instruction-source.test.ts @@ -151,6 +151,30 @@ test("all generated surfaces expose integration freshness and repair upfront", ( } }); +test("checked-in agent examples use the current integration contract", async () => { + const maintenance = INSTRUCTION_SECTIONS.find( + (section) => section.id === "maintenance" + ); + expect(maintenance).toBeDefined(); + + for (const path of ["examples/basic/AGENTS.md", "examples/basic/CLAUDE.md"]) { + const content = await Bun.file(path).text(); + expect(content).toContain( + `Content revision: \`${HACK_AGENT_INTEGRATION_CONTENT_REVISION}\`` + ); + for (const bullet of maintenance?.bullets ?? []) { + expect(content, `${path} lacks current maintenance guidance`).toContain( + bullet + ); + } + } + + const cliGuide = await Bun.file("docs/cli.md").text(); + expect(cliGuide).not.toContain( + "`hack doctor --fix` never inspect or rewrite" + ); +}); + test("agent integration content revision changes with canonical guidance", () => { const revision = createHash("sha256") .update(