diff --git a/.codex/skills/hack-cli/SKILL.md b/.codex/skills/hack-cli/SKILL.md index 232d368e6..1b204191f 100644 --- a/.codex/skills/hack-cli/SKILL.md +++ b/.codex/skills/hack-cli/SKILL.md @@ -14,7 +14,7 @@ Use `hack` as the primary interface for local-first development. - These instructions were generated by hack CLI v4.2.1; use the content revision and command behavior to assess freshness. - Check guidance only when Hack is relevant and there is evidence of drift; use targeted `hack setup` checks for a read-only inventory. Routine tasks do not require a startup audit. -- Content revision: `c3c646a036aa` (version alone is not a freshness guarantee). +- Content revision: `9f26e5752230` (version alone is not a freshness guarantee). ## Product boundary diff --git a/.cursor/rules/hack.mdc b/.cursor/rules/hack.mdc index c682bd513..249e2925f 100644 --- a/.cursor/rules/hack.mdc +++ b/.cursor/rules/hack.mdc @@ -9,7 +9,7 @@ Prefer `hack` when shell access is available. Use MCP only when shell access is - These instructions were generated by hack CLI v4.2.1; use the content revision and command behavior to assess freshness. - Check guidance only when Hack is relevant and there is evidence of drift; use targeted `hack setup` checks for a read-only inventory. Routine tasks do not require a startup audit. -- Content revision: `c3c646a036aa` (version alone is not a freshness guarantee). +- Content revision: `9f26e5752230` (version alone is not a freshness guarantee). ## Product boundary diff --git a/docs/agent-guidance.md b/docs/agent-guidance.md index 74d7c5f35..e43752e50 100644 --- a/docs/agent-guidance.md +++ b/docs/agent-guidance.md @@ -21,7 +21,9 @@ run only `hack agent prime`. See [model-policy ownership](reference/agent-client | `src/agents/hack-init-skill.ts` | Thin Claude/Codex/plugin adapter that fetches onboarding guidance | | `src/agents/init-patterns.ts`, `init-assistant.ts` | Optional init inventory and pattern hints | | `src/agents/install-skill.ts`, `remote-bootstrap-skill.ts` | Plugin installation/channel selection and fresh-host/cloud setup skills | -| `scripts/generate-agent-plugins.ts` | Portable plugin/MCP manifests, three client compatibility manifests, four skills, and Cursor rule | +| `src/agents/focused-skills.ts` | Plugin `hack-branches`, `hack-env` and `hack-diagnose` skills, composed from canonical section ids, and the plugin `hack-cli` routing description | +| `src/agents/error-code-reference.ts` | `hack-diagnose` error-code reference, rendered from the exported `HACK_ERROR_CODES` registry in `src/lib/cli-result.ts` | +| `scripts/generate-agent-plugins.ts` | Portable plugin/MCP manifests, three client compatibility manifests, seven skills, and Cursor rule | The portable plugin manifest derives identity and effective OpenAI presentation from `.codex-plugin/plugin.json`; portable MCP derives from `.mcp.json` with an @@ -104,6 +106,17 @@ toolchain service against its card. Every `hack` command in a fixture README must resolve in CLI_SPEC and name a declared service and profile. These checks are static: they do not start containers or qualify the runtime gates. +The focused plugin skills `hack-branches`, `hack-env` and `hack-diagnose` list +canonical section ids in `src/agents/focused-skills.ts` and carry no prose of +their own; composition fails on an unknown id. A section with no surface tags +renders only in the focused skills that name it. Each trigger phrase belongs to +one plugin skill, and the plugin `hack-cli` description hands branch, env and +diagnosis intents to them; the standalone `hack-cli` keeps broad routing because +those installs ship no focused skills. The `hack-diagnose` error-code reference +renders from `HACK_ERROR_CODES` in `src/lib/cli-result.ts`. +`tests/focused-skills.test.ts` checks section ids, CLI_SPEC resolution, +error-code parity and trigger overlap. + Skill candidates and where their patterns live now: | Candidate | Decision | Patterns live in | Promotion owner and missing contract | diff --git a/docs/integrations.md b/docs/integrations.md index 9519e9e70..5618bcce7 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -68,9 +68,12 @@ trust and approval rules. ## Optional native plugins Hack also ships an optional native plugin for Codex, Claude Code, and Cursor in -`plugins/hack`. It bundles four skills (`hack-cli`, `hack-init`, `hack-install`, and -`hack-remote`), the `hack mcp serve` server, Claude primer hooks, and a Cursor rule -that the agent can select when relevant. The +`plugins/hack`. It bundles seven skills (`hack-cli`, the focused `hack-branches`, +`hack-env` and `hack-diagnose`, `hack-init`, `hack-install`, and `hack-remote`), the +`hack mcp serve` server, Claude primer hooks, and a Cursor rule that the agent can +select when relevant. The plugin's `hack-cli` routes branch-instance, env and +diagnosis requests to the focused skills; standalone `hack setup` installs keep the +broad `hack-cli` skill only. The [agent client capability matrix](reference/agent-client-capabilities.md) records which of these components have been observed loading in each client. Install the Hack CLI first and make `hack` available on the client's PATH. @@ -105,7 +108,7 @@ outside its local-plugin root; use a copy rather than weakening that boundary. S [Cursor's plugin documentation](https://cursor.com/docs/plugins) for marketplace and local development support in the installed client version. -After installing, verify in a fresh session that all four skills are discoverable, +After installing, verify in a fresh session that all seven skills are discoverable, `hack mcp serve` can initialize, and the client's hooks or rule load. A successful marketplace registration alone does not prove those components loaded. The plugin's MCP fallback is for clients without shell access; use the Hack CLI when shell is available. diff --git a/docs/reference/agent-client-capabilities.md b/docs/reference/agent-client-capabilities.md index 8c408b2c1..d19bb313d 100644 --- a/docs/reference/agent-client-capabilities.md +++ b/docs/reference/agent-client-capabilities.md @@ -56,7 +56,7 @@ Matrix version 1, observed 2026-10-11. | Capability | Meaning | | --- | --- | | Packaging | The client accepts, installs and enables the Hack plugin package in a session. | -| Skill discovery | A fresh session discovers the plugin's bundled skills (hack-cli, hack-init, hack-install, hack-remote). | +| Skill discovery | A fresh session discovers the plugin's bundled skills (hack-cli, hack-branches, hack-env, hack-diagnose, hack-init, hack-install, hack-remote). | | Hooks | The client runs plugin-shipped hooks; Hack hooks may only run `hack agent prime`. | | Plugin MCP | The client starts the plugin-declared `hack mcp serve` server and lists its tools. | | Standalone MCP | The client loads an MCP entry written by `hack mcp install` (stdio or a selected MCP bundle) without the plugin. | @@ -117,7 +117,7 @@ owner, and none of them replaces the others. Users choose one per client. | Layout | What it contains | Authoritative owner | Installed and updated by | | --- | --- | --- | --- | -| Native plugin (`plugins/hack`) | Client manifests, four skills, Claude primer hooks, Cursor rule, `hack mcp serve` declaration | `scripts/generate-agent-plugins.ts`, rendering from `src/agents/instruction-source.ts` and the skill renderers in `src/agents/` | The client's plugin manager. Hack commands never install or update it. | +| Native plugin (`plugins/hack`) | Client manifests, seven skills, Claude primer hooks, Cursor rule, `hack mcp serve` declaration | `scripts/generate-agent-plugins.ts`, rendering from `src/agents/instruction-source.ts` and the skill renderers in `src/agents/` | The client's plugin manager. Hack commands never install or update it. | | Standalone setup | Project AGENTS.md/CLAUDE.md snippets, Cursor rules, shared skills, Claude primer hooks, MCP entries | `src/agents/instruction-source.ts` and its renderers, written by `hack setup` and `hack mcp install` | Explicit `hack setup` and `hack mcp install` commands in a chosen scope | | MCP bundle | Native MCP adapter, owner and backend executables with a content-derived identity | `src/mcp/bundle.ts` (packaging and verification) | `hack mcp install --bundle ... --cli ...` writes the client entry; the bundle is never modified after selection | diff --git a/examples/basic/AGENTS.md b/examples/basic/AGENTS.md index c430447b4..07bfc5c71 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 v4.2.1; use the content revision and command behavior to assess freshness. - Check guidance only when Hack is relevant and there is evidence of drift; use targeted `hack setup` checks for a read-only inventory. Routine tasks do not require a startup audit. -- Content revision: `c3c646a036aa` (version alone is not a freshness guarantee). +- Content revision: `9f26e5752230` (version alone is not a freshness guarantee). Product boundary: - Supported CLI surface: project init, up/down/restart, open, logs, env, host exec/shell, sessions, doctor, and daemon. diff --git a/examples/basic/CLAUDE.md b/examples/basic/CLAUDE.md index c430447b4..07bfc5c71 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 v4.2.1; use the content revision and command behavior to assess freshness. - Check guidance only when Hack is relevant and there is evidence of drift; use targeted `hack setup` checks for a read-only inventory. Routine tasks do not require a startup audit. -- Content revision: `c3c646a036aa` (version alone is not a freshness guarantee). +- Content revision: `9f26e5752230` (version alone is not a freshness guarantee). Product boundary: - Supported CLI surface: project init, up/down/restart, open, logs, env, host exec/shell, sessions, doctor, and daemon. diff --git a/plugins/hack/README.md b/plugins/hack/README.md index 7533f828d..1d408c86b 100644 --- a/plugins/hack/README.md +++ b/plugins/hack/README.md @@ -3,9 +3,12 @@ Optional Hack integration for Codex, Claude Code, and Cursor. Install the `hack` executable first; the plugin uses it from PATH. -The bundle contains four skills: +The bundle contains seven skills: -- `hack-cli`: services, logs, environments, worktrees and diagnostics. +- `hack-cli`: services, logs, routed URLs, lifecycle and workspaces. +- `hack-branches`: parallel branch instances (`--branch `) and branch aliases. +- `hack-env`: env values, overlays and worktree-local overrides without exposing secrets. +- `hack-diagnose`: read-first diagnosis and the `--json` error-code reference. - `hack-init`: configure a new or existing project for Hack. - `hack-install`: installation, stable/prerelease selection, upgrade and rollback. - `hack-remote`: prepare an existing project on another machine or in Codex Cloud. @@ -26,7 +29,7 @@ that selection and disable the bundle's MCP entry in the client configuration: enabled = false ``` -This keeps the four plugin skills available without launching a second bundled +This keeps the seven plugin skills available without launching a second bundled server. A standalone registration remains independently configured. Preserve existing customized skills; resolve overlapping installation paths deliberately. @@ -38,7 +41,7 @@ codex plugin add hack@hack-dance codex plugin list --json --marketplace hack-dance ``` -Start a fresh client, confirm all four plugin skills load, and call a harmless +Start a fresh client, confirm all seven plugin skills load, and call a harmless MCP tool such as `hack.projects.list` against an isolated registry. Inspect actual discovered tool names first. The server and Claude hooks still resolve `hack` from the client's PATH; installing diff --git a/plugins/hack/rules/hack.mdc b/plugins/hack/rules/hack.mdc index 7e8faaf7c..88ab43282 100644 --- a/plugins/hack/rules/hack.mdc +++ b/plugins/hack/rules/hack.mdc @@ -14,7 +14,7 @@ Prefer `hack` when shell access is available. Use MCP only when shell access is - These instructions were generated by hack CLI v4.2.1; use the content revision and command behavior to assess freshness. - Check guidance only when Hack is relevant and there is evidence of drift; use targeted `hack setup` checks for a read-only inventory. Routine tasks do not require a startup audit. -- Content revision: `c3c646a036aa` (version alone is not a freshness guarantee). +- Content revision: `9f26e5752230` (version alone is not a freshness guarantee). ## Product boundary diff --git a/plugins/hack/skills/hack-branches/SKILL.md b/plugins/hack/skills/hack-branches/SKILL.md new file mode 100644 index 000000000..4acd9bc76 --- /dev/null +++ b/plugins/hack/skills/hack-branches/SKILL.md @@ -0,0 +1,38 @@ +--- +name: hack-branches +description: > + Run parallel branch instances of a Hack project with `--branch ` and manage their aliases + with `hack branch`. Trigger when asked to run two versions of a project side by side; start, + open, tail or stop a branch instance; review a pull request in its own instance; or add, list, + open or remove a branch alias. +--- + +# Hack branch instances + +## 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 ` on up/open/logs/down (for example: `hack up --branch --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. + +## Branch aliases + +- Aliases are entries in `.hack/hack.branches.json`; `hack up --branch ` records or refreshes its entry automatically. +- `hack branch add --note ` registers one ahead of time, and `hack branch list` shows each alias with its note and last use. +- `hack branch open ` opens that branch's routed host; `hack branch open --prefer ` selects which host. +- `hack branch remove ` deletes only the alias entry and leaves any running instance alone; stop the instance with `hack down --branch `. + +## Linked git worktrees (optional) + +- 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 ` 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 ` explicitly. +- `hack doctor` flags divergent secret keys and dev_host collisions across checkouts. + +## Integration freshness + +- These instructions were generated by hack CLI v4.2.1; use the content revision and command behavior to assess freshness. +- Check guidance only when Hack is relevant and there is evidence of drift; use targeted `hack setup` checks for a read-only inventory. Routine tasks do not require a startup audit. +- Content revision: `9f26e5752230` (version alone is not a freshness guarantee). diff --git a/plugins/hack/skills/hack-cli/SKILL.md b/plugins/hack/skills/hack-cli/SKILL.md index 315545a30..05b6ac9b9 100644 --- a/plugins/hack/skills/hack-cli/SKILL.md +++ b/plugins/hack/skills/hack-cli/SKILL.md @@ -1,9 +1,11 @@ --- name: hack-cli description: > - Use the hack CLI for local runtime orchestration (compose, DNS/TLS, logs, env, persistent project workspaces) and agent setup. - Trigger when asked to run/start/stop services, inspect logs, manage lifecycle/workspace workflows, or update - agent integrations. Prefer CLI over MCP when shell access is available. + Use the hack CLI for local runtime orchestration (compose, DNS/TLS, routed URLs, persistent + project workspaces) and agent setup. Trigger when asked to run, start or stop services; tail + service logs; open a routed app URL; manage lifecycle or workspace workflows; or update agent + integrations. Use hack-branches for branch instances, hack-env for env values and hack-diagnose + for a failing project. Prefer CLI over MCP when shell access is available. --- # hack CLI @@ -14,7 +16,7 @@ Use `hack` as the primary interface for local-first development. - These instructions were generated by hack CLI v4.2.1; use the content revision and command behavior to assess freshness. - Check guidance only when Hack is relevant and there is evidence of drift; use targeted `hack setup` checks for a read-only inventory. Routine tasks do not require a startup audit. -- Content revision: `c3c646a036aa` (version alone is not a freshness guarantee). +- Content revision: `9f26e5752230` (version alone is not a freshness guarantee). ## Product boundary diff --git a/plugins/hack/skills/hack-diagnose/SKILL.md b/plugins/hack/skills/hack-diagnose/SKILL.md new file mode 100644 index 000000000..5a6c94b38 --- /dev/null +++ b/plugins/hack/skills/hack-diagnose/SKILL.md @@ -0,0 +1,44 @@ +--- +name: hack-diagnose +description: > + Diagnose a Hack-managed project by reading its state first and repairing only within the + request. Trigger when asked to find out why a service is unhealthy or did not start; interpret + an E_* error code from a JSON envelope; review hack doctor findings; or investigate routing or + global infrastructure failures. +--- + +# Diagnose a Hack project + +## Read-first diagnosis + +- Read before changing anything, in this order, and stop once the cause is clear: +- Step 1, `hack ps --json`: which services exist and their state. +- Step 2, `hack doctor --json`: findings and stable E_* codes. +- Step 3, `hack logs --json --no-follow --tail 200`: a finite snapshot; add a service name to narrow it. +- Step 4, `hack env explain --service `: only when a value is suspect; it reports provenance without the value. +- Step 5, `hack global status`: shared proxy, DNS and logging infrastructure. +- Branch on `error.code`, not on message text; the code is the stable contract and the message may change. +- Report what each read showed before proposing a repair. + +## Operating rules + +- Prefer `hack` over raw `docker` / `docker compose` for project workflows. +- Do not start/stop services from Docker Desktop UI for `hack`-managed projects. +- Treat `.hack/.internal` and `.hack/.branch` as hack-managed artifacts; do not hand-edit generated files there. +- Use `--json` for machine-readable output when available; `hack up/down/restart/doctor --json` emit an `{ok, data | error: {code, message}}` envelope with stable E_* error codes. +- When the exit status matters, do not pipe `hack` output through `| tail` or `| grep`: the pipeline reports the filter's status, and filtered text can hide failures. Check the `hack` exit status or `--json` envelope first, and use finite reads such as `hack logs --no-follow --tail 100`. +- Scripted/agent runs: pass `--no-interactive` (or set `HACK_NO_INTERACTIVE=1`) so commands never block on prompts — they apply documented defaults or fail fast with E_INTERACTIVE_REQUIRED. +- Use MCP only when shell access is unavailable. +- A task request authorizes its stated scope, not unrelated service, global config, or production changes. Preserve native credential/trust approvals and destructive-cleanup gates; do not ask again for the same authorized action. +- Cleanup must prove resource ownership. Successful one-shot containers may exit with code 0; adopted external processes must remain running when Hack stops its own services. +- If runtime state looks wrong, inspect with `hack doctor`; apply `hack doctor --fix` only for repairs covered by the request. Warnings do not authorize broader changes. + +## Integration freshness + +- These instructions were generated by hack CLI v4.2.1; use the content revision and command behavior to assess freshness. +- Check guidance only when Hack is relevant and there is evidence of drift; use targeted `hack setup` checks for a read-only inventory. Routine tasks do not require a startup audit. +- Content revision: `9f26e5752230` (version alone is not a freshness guarantee). + +## References + +- [Error codes](references/error-codes.md): every stable `--json` error code with its meaning and the first read-only step. diff --git a/plugins/hack/skills/hack-diagnose/references/error-codes.md b/plugins/hack/skills/hack-diagnose/references/error-codes.md new file mode 100644 index 000000000..041c17ec1 --- /dev/null +++ b/plugins/hack/skills/hack-diagnose/references/error-codes.md @@ -0,0 +1,32 @@ +# Hack error codes + +Commands run with `--json` print one envelope. A failure has the shape +`{ok: false, error: {code, message}}`; branch on `code`, not on `message`. +Each read-first step only inspects state. Repairs such as `hack doctor --fix` +stay within the request. + +| Code | Meaning | Read first | +| --- | --- | --- | +| `E_DOCKER_UNAVAILABLE` | The container runtime CLI or engine cannot be reached. | `hack doctor --json` | +| `E_GLOBAL_INFRA_DOWN` | Shared global infrastructure the command needs is offline. | `hack global status` | +| `E_CONFIG_PARSE` | A Hack configuration file could not be parsed. | `hack doctor --json` | +| `E_CONFIG_INVALID` | Project configuration or saved runtime input did not pass validation. | `hack doctor --json` | +| `E_PROJECT_NOT_FOUND` | No Hack project resolved from the working directory, `--project` or `--path`. | `hack projects --json` | +| `E_NATIVE_PROJECT_CONFLICT` | Native project input and legacy Hack inputs coexist; project commands refuse until one is reconciled. | `hack doctor --json` | +| `E_NATIVE_PROJECT_UNSUPPORTED` | The project or the requested operation needs native runtime support that this CLI does not provide. | `hack doctor --json` | +| `E_NATIVE_COMPOSE_PROXY_ACCESS` | Native Compose routing could not read the live proxy state. | `hack global status` | +| `E_SERVICE_NOT_FOUND` | The named service is not part of the selected project runtime. | `hack ps --json` | +| `E_COMPOSE_FAILED` | A Compose operation failed or left a stop incomplete. | `hack logs --json --no-follow --tail 200` | +| `E_STARTUP_INCOMPLETE` | After start or restart, a service is neither running nor a successful one-shot. | `hack ps --json` | +| `E_DEPENDENCY_BOOTSTRAP_FAILED` | A dependency installer service did not complete successfully. | `hack logs --json --no-follow --tail 200` | +| `E_STARTUP_TIMEOUT` | Bounded startup ran out of time before services settled. | `hack ps --json` | +| `E_LIFECYCLE_FAILED` | A lifecycle hook or host process failed. | `hack projects --details` | +| `E_ENV_KEY_MISSING` | The selected env overlay cannot supply keys the command requires; the detail lists names only. | `hack env explain --service ` | +| `E_PERMISSION` | A filesystem or runtime permission was denied. | `hack doctor --json` | +| `E_INTERACTIVE_REQUIRED` | The command needs an answer at a prompt but is running without one. | `hack --help` | +| `E_USAGE` | Arguments or flags were invalid for the command. | `hack --help` | +| `E_UNEXPECTED` | An unclassified failure; the message is the only detail. | `hack doctor --json` | + +--- + +Content revision: `33e546b5dc60`. Generated by Hack; edit the canonical source and regenerate the plugin. diff --git a/plugins/hack/skills/hack-env/SKILL.md b/plugins/hack/skills/hack-env/SKILL.md new file mode 100644 index 000000000..9a59dc257 --- /dev/null +++ b/plugins/hack/skills/hack-env/SKILL.md @@ -0,0 +1,37 @@ +--- +name: hack-env +description: > + Inspect and change Hack-managed env values, overlays and worktree-local overrides without + exposing secret values. Trigger when asked to explain where an env value comes from; list, get + or set an env value; select or compare env overlays; write a worktree-local env override; run a + host command with managed env; or refresh a service after an env change. +--- + +# Hack env + +## Env composition + +- Layer order: `.hack/hack.env.default.yaml`, the selected `.hack/hack.env..yaml`, then worktree-local `.hack/hack.env.local.yaml` and `.hack/hack.env..local.yaml`; service values override `global`, and host commands apply `host` values last. +- Omit `--env` to use the configured default overlay; `--env base` (or `default`/`none`) selects base layers only, the same on every command. An overlay name grants no production authority and no network or instance isolation. +- Use `hack env explain --env --service ` for provenance without the value; use `hack env get ` only when a plaintext value is explicitly needed, and treat its output as secret. +- Store a secret by piping it: ` | hack env add --service --secret --value-stdin` (or the hidden prompt in a terminal); never pass secret values as command arguments, where shell history and process listings expose them. +- Running containers keep the env they were created with; after an env change, refresh with `hack env apply --service ` or `hack restart`. +- Accepted flags, injection timing and refresh path per command: the command and delivery matrix in Hack's env guide (`docs/env.md` in the Hack repository). + +## Env commands + +- `hack env list --env --service ` shows the resolved values for one overlay and scope with secret values masked; add `--json` for a machine-readable view. +- `hack env set --local` writes a non-secret value to the worktree-local override file for the selected overlay; drop `--local` only when the request covers the shared, committed overlay. +- `hack env exec --env --service -- ` runs one host command with the resolved env injected; use `hack env exec --env --shell ''` when `$VAR` must expand inside the child shell, and add `--timeout ` to bound a non-TTY job. +- `hack env apply --env --service ` recreates that one service so it picks up changed values. + +## Env safety + +- A secret value never goes in command arguments or agent prompts. Store one with `hack env set --secret` and no value, which the user runs in their own terminal and answers at its hidden prompt. +- `hack env get` and `hack env list --show-secrets` are plaintext sinks: run them only when the user asks for the value itself, never to check that a key exists, and do not repeat their output in replies, logs or files. + +## Integration freshness + +- These instructions were generated by hack CLI v4.2.1; use the content revision and command behavior to assess freshness. +- Check guidance only when Hack is relevant and there is evidence of drift; use targeted `hack setup` checks for a read-only inventory. Routine tasks do not require a startup audit. +- Content revision: `9f26e5752230` (version alone is not a freshness guarantee). diff --git a/scripts/generate-agent-plugins.ts b/scripts/generate-agent-plugins.ts index 1a8e85205..83b952fb2 100644 --- a/scripts/generate-agent-plugins.ts +++ b/scripts/generate-agent-plugins.ts @@ -1,8 +1,16 @@ import { readdir, rm } from "node:fs/promises"; import { dirname, resolve } from "node:path"; -import { renderCodexSkill } from "../src/agents/codex-skill.ts"; +import { renderHackCliSkill } from "../src/agents/codex-skill.ts"; import { renderCursorRules } from "../src/agents/cursor.ts"; +import { + FOCUSED_SKILLS, + type FocusedSkillName, + HACK_CLI_PLUGIN_ROUTING, + renderDescriptionFrontmatter, + renderFocusedSkill, + renderSkillDescription, +} from "../src/agents/focused-skills.ts"; import { renderHackInitSkill } from "../src/agents/hack-init-skill.ts"; import { renderHackInstallSkill } from "../src/agents/install-skill.ts"; import { renderHackRemoteSkill } from "../src/agents/remote-bootstrap-skill.ts"; @@ -29,6 +37,10 @@ export async function generateAgentPlugins({ path: resolve(pluginRoot, "skills", "hack-cli", "SKILL.md"), content: renderPluginSkill("hack-cli"), }, + ...FOCUSED_SKILLS.map((skill) => ({ + path: resolve(pluginRoot, "skills", skill.name, "SKILL.md"), + content: renderPluginSkill(skill.name), + })), { path: resolve(pluginRoot, "skills", "hack-init", "SKILL.md"), content: renderHackInitSkill(), @@ -147,16 +159,29 @@ export function portableMcpManifest( } /** - * Render a plugin skill's SKILL.md. The plugin variant of `hack-cli` adds - * links to its packaged references; standalone installs keep - * {@link renderCodexSkill} because they ship no references. + * Render a plugin skill's SKILL.md with links to its packaged references. + * + * The plugin `hack-cli` routes branch, env and diagnosis intents to the + * focused skills; standalone installs keep `renderCodexSkill` (broad routing, + * no references) because they ship neither. */ -export function renderPluginSkill(skill: "hack-cli"): string { - return withSkillReferenceLinks({ skill, content: renderCodexSkill() }); +export function renderPluginSkill( + skill: "hack-cli" | FocusedSkillName +): string { + const content = + skill === "hack-cli" + ? renderHackCliSkill({ + descriptionLines: renderDescriptionFrontmatter( + renderSkillDescription(HACK_CLI_PLUGIN_ROUTING) + ), + }) + : renderFocusedSkill(skill); + return withSkillReferenceLinks({ skill, content }); } const PLUGIN_SKILLS: readonly PluginSkillName[] = [ "hack-cli", + ...FOCUSED_SKILLS.map((skill) => skill.name), "hack-init", "hack-install", "hack-remote", diff --git a/src/agents/client-capabilities.ts b/src/agents/client-capabilities.ts index 657ee65f2..f1140a8af 100644 --- a/src/agents/client-capabilities.ts +++ b/src/agents/client-capabilities.ts @@ -124,7 +124,7 @@ export const AGENT_CLIENT_CAPABILITY_DEFINITIONS: readonly AgentClientCapability capability: "skillDiscovery", label: "Skill discovery", meaning: - "A fresh session discovers the plugin's bundled skills (hack-cli, hack-init, hack-install, hack-remote).", + "A fresh session discovers the plugin's bundled skills (hack-cli, hack-branches, hack-env, hack-diagnose, hack-init, hack-install, hack-remote).", }, { capability: "hooks", diff --git a/src/agents/codex-skill.ts b/src/agents/codex-skill.ts index 4621d83df..ff89c1ba5 100644 --- a/src/agents/codex-skill.ts +++ b/src/agents/codex-skill.ts @@ -251,16 +251,32 @@ export async function removeCodexSkill(opts: { * Render the Codex skill template for hack CLI usage. * * Frontmatter and top-level framing live here; the instructional content is - * the `skill`-tagged sections from the shared instruction source. + * the `skill`-tagged sections from the shared instruction source. Standalone + * installs ship no focused skills, so this variant keeps broad routing. */ export function renderCodexSkill(): string { + return renderHackCliSkill({ + descriptionLines: [ + "description: >", + " Use the hack CLI for local runtime orchestration (compose, DNS/TLS, logs, env, persistent project workspaces) and agent setup.", + " Trigger when asked to run/start/stop services, inspect logs, manage lifecycle/workspace workflows, or update", + " agent integrations. Prefer CLI over MCP when shell access is available.", + ], + }); +} + +/** + * Render the `hack-cli` SKILL.md with the given frontmatter description + * lines. The standalone and plugin variants share the body; only routing + * differs. + */ +export function renderHackCliSkill(opts: { + readonly descriptionLines: readonly string[]; +}): string { const lines = [ "---", "name: hack-cli", - "description: >", - " Use the hack CLI for local runtime orchestration (compose, DNS/TLS, logs, env, persistent project workspaces) and agent setup.", - " Trigger when asked to run/start/stop services, inspect logs, manage lifecycle/workspace workflows, or update", - " agent integrations. Prefer CLI over MCP when shell access is available.", + ...opts.descriptionLines, "---", "", "# hack CLI", diff --git a/src/agents/error-code-reference.ts b/src/agents/error-code-reference.ts new file mode 100644 index 000000000..841c38f5f --- /dev/null +++ b/src/agents/error-code-reference.ts @@ -0,0 +1,126 @@ +import { HACK_ERROR_CODES, type HackErrorCode } from "../lib/cli-result.ts"; + +/** + * Agent-facing meaning of each stable `--json` error code. + * + * Keyed by {@link HackErrorCode}, so adding a code to the registry without a + * row here fails typecheck; the packaged reference renders in registry order + * from {@link HACK_ERROR_CODES}, and `tests/focused-skills.test.ts` checks the + * rendered code set against the registry. + */ +type ErrorCodeGuide = { + /** What the failure means, without caller values. */ + readonly meaning: string; + /** The first read-only step; never a repair. */ + readonly readFirst: string; +}; + +const ERROR_CODE_GUIDES: Readonly> = { + E_DOCKER_UNAVAILABLE: { + meaning: "The container runtime CLI or engine cannot be reached.", + readFirst: "`hack doctor --json`", + }, + E_GLOBAL_INFRA_DOWN: { + meaning: "Shared global infrastructure the command needs is offline.", + readFirst: "`hack global status`", + }, + E_CONFIG_PARSE: { + meaning: "A Hack configuration file could not be parsed.", + readFirst: "`hack doctor --json`", + }, + E_CONFIG_INVALID: { + meaning: + "Project configuration or saved runtime input did not pass validation.", + readFirst: "`hack doctor --json`", + }, + E_PROJECT_NOT_FOUND: { + meaning: + "No Hack project resolved from the working directory, `--project` or `--path`.", + readFirst: "`hack projects --json`", + }, + E_NATIVE_PROJECT_CONFLICT: { + meaning: + "Native project input and legacy Hack inputs coexist; project commands refuse until one is reconciled.", + readFirst: "`hack doctor --json`", + }, + E_NATIVE_PROJECT_UNSUPPORTED: { + meaning: + "The project or the requested operation needs native runtime support that this CLI does not provide.", + readFirst: "`hack doctor --json`", + }, + E_NATIVE_COMPOSE_PROXY_ACCESS: { + meaning: "Native Compose routing could not read the live proxy state.", + readFirst: "`hack global status`", + }, + E_SERVICE_NOT_FOUND: { + meaning: "The named service is not part of the selected project runtime.", + readFirst: "`hack ps --json`", + }, + E_COMPOSE_FAILED: { + meaning: "A Compose operation failed or left a stop incomplete.", + readFirst: "`hack logs --json --no-follow --tail 200`", + }, + E_STARTUP_INCOMPLETE: { + meaning: + "After start or restart, a service is neither running nor a successful one-shot.", + readFirst: "`hack ps --json`", + }, + E_DEPENDENCY_BOOTSTRAP_FAILED: { + meaning: "A dependency installer service did not complete successfully.", + readFirst: "`hack logs --json --no-follow --tail 200`", + }, + E_STARTUP_TIMEOUT: { + meaning: "Bounded startup ran out of time before services settled.", + readFirst: "`hack ps --json`", + }, + E_LIFECYCLE_FAILED: { + meaning: "A lifecycle hook or host process failed.", + readFirst: "`hack projects --details`", + }, + E_ENV_KEY_MISSING: { + meaning: + "The selected env overlay cannot supply keys the command requires; the detail lists names only.", + readFirst: "`hack env explain --service `", + }, + E_PERMISSION: { + meaning: "A filesystem or runtime permission was denied.", + readFirst: "`hack doctor --json`", + }, + E_INTERACTIVE_REQUIRED: { + meaning: + "The command needs an answer at a prompt but is running without one.", + readFirst: "`hack --help`", + }, + E_USAGE: { + meaning: "Arguments or flags were invalid for the command.", + readFirst: "`hack --help`", + }, + E_UNEXPECTED: { + meaning: "An unclassified failure; the message is the only detail.", + readFirst: "`hack doctor --json`", + }, +}; + +/** + * Render the `hack-diagnose` error-code reference from the exported registry. + * One table row per code, in registry order. + */ +export function renderErrorCodeReference(): string { + const rows = HACK_ERROR_CODES.map((code) => { + const guide = ERROR_CODE_GUIDES[code]; + return `| \`${code}\` | ${guide.meaning} | ${guide.readFirst} |`; + }); + return [ + "# Hack error codes", + "", + "Commands run with `--json` print one envelope. A failure has the shape", + "`{ok: false, error: {code, message}}`; branch on `code`, not on `message`.", + "Each read-first step only inspects state. Repairs such as `hack doctor --fix`", + "stay within the request.", + "", + "| Code | Meaning | Read first |", + "| --- | --- | --- |", + ...rows, + "", + ].join("\n"); +} diff --git a/src/agents/focused-skills.ts b/src/agents/focused-skills.ts new file mode 100644 index 000000000..39bd7a6bc --- /dev/null +++ b/src/agents/focused-skills.ts @@ -0,0 +1,224 @@ +/** + * Focused plugin skills composed from canonical instruction sections. + * + * Each focused skill names the {@link INSTRUCTION_SECTIONS} ids it renders, in + * order; no guidance prose lives here. Only the frontmatter description (the + * routing contract) is owned by this module, built from a summary and a list + * of trigger phrases. A trigger phrase belongs to exactly one plugin skill, + * which `tests/focused-skills.test.ts` enforces across every description. + */ + +import { + INSTRUCTION_SECTIONS, + type InstructionSection, + renderInstructionSection, +} from "./instruction-source.ts"; + +export type FocusedSkillName = "hack-branches" | "hack-env" | "hack-diagnose"; + +/** One canonical section in a focused skill, optionally marked as optional. */ +export type FocusedSkillSection = { + readonly id: string; + /** A secondary entry, rendered with an "(optional)" title suffix. */ + readonly optional?: boolean; +}; + +/** The routing contract a skill's frontmatter description renders from. */ +export type SkillRouting = { + readonly summary: string; + /** Intents that select this skill; each phrase is unique to one skill. */ + readonly triggers: readonly string[]; + /** Optional closing sentences, e.g. where neighbouring intents go. */ + readonly closing?: string; +}; + +export type FocusedSkillSpec = { + readonly name: FocusedSkillName; + readonly heading: string; + readonly routing: SkillRouting; + readonly sections: readonly FocusedSkillSection[]; +}; + +/** + * Routing for the plugin's `hack-cli` skill. It keeps general runtime work + * and hands branch, env and diagnosis intents to the focused skills. The + * standalone `hack setup` render keeps its broad description because those + * installs ship no focused skills. + */ +export const HACK_CLI_PLUGIN_ROUTING: SkillRouting = { + summary: + "Use the hack CLI for local runtime orchestration (compose, DNS/TLS, routed URLs, persistent project workspaces) and agent setup.", + triggers: [ + "run, start or stop services", + "tail service logs", + "open a routed app URL", + "manage lifecycle or workspace workflows", + "update agent integrations", + ], + closing: + "Use hack-branches for branch instances, hack-env for env values and hack-diagnose for a failing project. Prefer CLI over MCP when shell access is available.", +}; + +/** Focused plugin skills, in generation order. */ +export const FOCUSED_SKILLS: readonly FocusedSkillSpec[] = [ + { + name: "hack-branches", + heading: "Hack branch instances", + routing: { + summary: + "Run parallel branch instances of a Hack project with `--branch ` and manage their aliases with `hack branch`.", + triggers: [ + "run two versions of a project side by side", + "start, open, tail or stop a branch instance", + "review a pull request in its own instance", + "add, list, open or remove a branch alias", + ], + }, + sections: [ + { id: "branch-instances" }, + { id: "branch-aliases" }, + { id: "worktrees", optional: true }, + { id: "freshness" }, + ], + }, + { + name: "hack-env", + heading: "Hack env", + routing: { + summary: + "Inspect and change Hack-managed env values, overlays and worktree-local overrides without exposing secret values.", + triggers: [ + "explain where an env value comes from", + "list, get or set an env value", + "select or compare env overlays", + "write a worktree-local env override", + "run a host command with managed env", + "refresh a service after an env change", + ], + }, + sections: [ + { id: "env-composition" }, + { id: "env-commands" }, + { id: "env-safety" }, + { id: "freshness" }, + ], + }, + { + name: "hack-diagnose", + heading: "Diagnose a Hack project", + routing: { + summary: + "Diagnose a Hack-managed project by reading its state first and repairing only within the request.", + triggers: [ + "find out why a service is unhealthy or did not start", + "interpret an E_* error code from a JSON envelope", + "review hack doctor findings", + "investigate routing or global infrastructure failures", + ], + }, + sections: [ + { id: "diagnose" }, + { id: "operating-rules" }, + { id: "freshness" }, + ], + }, +]; + +/** Look up a focused skill by name. */ +export function focusedSkill(name: FocusedSkillName): FocusedSkillSpec { + const spec = FOCUSED_SKILLS.find((candidate) => candidate.name === name); + if (!spec) { + throw new Error("Unknown focused skill"); + } + return spec; +} + +/** + * Render a skill description sentence: the summary, the trigger phrases, and + * the optional closing sentences. + */ +export function renderSkillDescription(routing: SkillRouting): string { + const triggers = routing.triggers; + const last = triggers.at(-1); + if (last === undefined) { + throw new Error("A skill description needs at least one trigger phrase"); + } + const joined = + triggers.length === 1 + ? last + : `${triggers.slice(0, -1).join("; ")}; or ${last}`; + return [ + routing.summary, + `Trigger when asked to ${joined}.`, + ...(routing.closing ? [routing.closing] : []), + ].join(" "); +} + +/** + * Render a description as a YAML folded block (`description: >`) wrapped at + * a readable width. Folding joins the lines back into one sentence. + */ +export function renderDescriptionFrontmatter(description: string): string[] { + const lines: string[] = []; + let current = ""; + for (const word of description.split(" ")) { + if (current.length > 0 && current.length + word.length + 1 > 96) { + lines.push(` ${current}`); + current = word; + } else { + current = current.length > 0 ? `${current} ${word}` : word; + } + } + if (current.length > 0) { + lines.push(` ${current}`); + } + return ["description: >", ...lines]; +} + +/** + * Compose canonical sections by id, in the given order. + * + * Throws on an unknown or repeated id so a renamed canonical section cannot + * silently drop out of a focused skill. Errors are closed and do not echo the + * id. + */ +export function composeCanonicalSections(opts: { + readonly sections: readonly FocusedSkillSection[]; + readonly source?: readonly InstructionSection[]; +}): string { + const source = opts.source ?? INSTRUCTION_SECTIONS; + const seen = new Set(); + const blocks = opts.sections.map((entry) => { + if (seen.has(entry.id)) { + throw new Error("Duplicate instruction section id in focused skill"); + } + seen.add(entry.id); + const section = source.find((candidate) => candidate.id === entry.id); + if (!section) { + throw new Error("Unknown instruction section id in focused skill"); + } + return renderInstructionSection({ + section: entry.optional + ? { ...section, title: `${section.title} (optional)` } + : section, + headingStyle: "markdown", + }); + }); + return blocks.join("\n\n"); +} + +/** Render a focused skill's SKILL.md (without packaged reference links). */ +export function renderFocusedSkill(name: FocusedSkillName): string { + const spec = focusedSkill(name); + return [ + "---", + `name: ${spec.name}`, + ...renderDescriptionFrontmatter(renderSkillDescription(spec.routing)), + "---", + "", + `# ${spec.heading}`, + "", + composeCanonicalSections({ sections: spec.sections }), + "", + ].join("\n"); +} diff --git a/src/agents/instruction-source.ts b/src/agents/instruction-source.ts index 13f09d3ec..95c7808e1 100644 --- a/src/agents/instruction-source.ts +++ b/src/agents/instruction-source.ts @@ -35,6 +35,10 @@ export const HACK_AGENT_INTEGRATION_CLI_VERSION = ( * - `skill`: Codex SKILL.md (full content). * - `rules`: Cursor `.mdc` rules (lean operational subset). * - `primer`: `hack agent prime` session primer (concise subset). + * + * A section with no surface tags renders only where a focused plugin skill + * names its id (see `src/agents/focused-skills.ts`); the broad surfaces never + * include it, but it still counts toward the content revision. */ export const INSTRUCTION_SECTIONS: readonly InstructionSection[] = [ { @@ -196,6 +200,21 @@ export const INSTRUCTION_SECTIONS: readonly InstructionSection[] = [ "Global infra logs: `hack global logs caddy --no-follow --tail 200`", ], }, + { + id: "diagnose", + title: "Read-first diagnosis", + surfaces: [], + bullets: [ + "Read before changing anything, in this order, and stop once the cause is clear:", + "Step 1, `hack ps --json`: which services exist and their state.", + "Step 2, `hack doctor --json`: findings and stable E_* codes.", + "Step 3, `hack logs --json --no-follow --tail 200`: a finite snapshot; add a service name to narrow it.", + "Step 4, `hack env explain --service `: only when a value is suspect; it reports provenance without the value.", + "Step 5, `hack global status`: shared proxy, DNS and logging infrastructure.", + "Branch on `error.code`, not on message text; the code is the stable contract and the message may change.", + "Report what each read showed before proposing a repair.", + ], + }, { id: "lifecycle", title: "Lifecycle + startup", @@ -245,6 +264,26 @@ export const INSTRUCTION_SECTIONS: readonly InstructionSection[] = [ "Accepted flags, injection timing and refresh path per command: the command and delivery matrix in Hack's env guide (`docs/env.md` in the Hack repository).", ], }, + { + id: "env-commands", + title: "Env commands", + surfaces: [], + bullets: [ + "`hack env list --env --service ` shows the resolved values for one overlay and scope with secret values masked; add `--json` for a machine-readable view.", + "`hack env set --local` writes a non-secret value to the worktree-local override file for the selected overlay; drop `--local` only when the request covers the shared, committed overlay.", + "`hack env exec --env --service -- ` runs one host command with the resolved env injected; use `hack env exec --env --shell ''` when `$VAR` must expand inside the child shell, and add `--timeout ` to bound a non-TTY job.", + "`hack env apply --env --service ` recreates that one service so it picks up changed values.", + ], + }, + { + id: "env-safety", + title: "Env safety", + surfaces: [], + bullets: [ + "A secret value never goes in command arguments or agent prompts. Store one with `hack env set --secret` and no value, which the user runs in their own terminal and answers at its hidden prompt.", + "`hack env get` and `hack env list --show-secrets` are plaintext sinks: run them only when the user asks for the value itself, never to check that a key exists, and do not repeat their output in replies, logs or files.", + ], + }, { id: "branch-instances", @@ -257,6 +296,17 @@ export const INSTRUCTION_SECTIONS: readonly InstructionSection[] = [ "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.", ], }, + { + id: "branch-aliases", + title: "Branch aliases", + surfaces: [], + bullets: [ + "Aliases are entries in `.hack/hack.branches.json`; `hack up --branch ` records or refreshes its entry automatically.", + "`hack branch add --note ` registers one ahead of time, and `hack branch list` shows each alias with its note and last use.", + "`hack branch open ` opens that branch's routed host; `hack branch open --prefer ` selects which host.", + "`hack branch remove ` deletes only the alias entry and leaves any running instance alone; stop the instance with `hack down --branch `.", + ], + }, { id: "targeting", @@ -341,7 +391,7 @@ export function renderInstructionSections(opts: { readonly headingStyle: InstructionHeadingStyle; }): string { const blocks = sectionsForSurface({ surface: opts.surface }).map((section) => - renderSection({ section, headingStyle: opts.headingStyle }) + renderInstructionSection({ section, headingStyle: opts.headingStyle }) ); return blocks.join("\n\n"); } @@ -362,7 +412,12 @@ export function normalizeInstructionText(opts: { .trim(); } -function renderSection(opts: { +/** + * Render one canonical section. The `freshness` section also carries the + * content revision line, so any render that includes it can be checked for + * drift against the canonical source. + */ +export function renderInstructionSection(opts: { readonly section: InstructionSection; readonly headingStyle: InstructionHeadingStyle; }): string { diff --git a/src/agents/integration-ownership.ts b/src/agents/integration-ownership.ts index cc5da86a9..a11233768 100644 --- a/src/agents/integration-ownership.ts +++ b/src/agents/integration-ownership.ts @@ -106,7 +106,9 @@ const KNOWN_RENDER_FINGERPRINTS: Readonly< "121d8f26374d24f2a09890a3", // v5.0.0-next.3..next "6449442a8f230412d4e5f9e7", // v5.0.0-next.4..next "d83da046cf7541ba30950880", // v5.0.0-next.4..next (offline env-matrix pointer) + "e620e980660f0443df7732fe", // next: focused plugin skills (HACK-1131) "c75c5f5eb9fdcf6842ed1eda", // v5.0.0-next.4..next (--value-stdin secret entry) + "680a52ef6737d979ed2d384b", // next: focused plugin skills + --value-stdin ]), "hack-init-skill": new Set([ "cd15fb6d8ed8e25071d05e75", // v3.1.0..v4.0.3 @@ -134,7 +136,9 @@ const KNOWN_RENDER_FINGERPRINTS: Readonly< "06cf6d0920afc1780b9946ba", // v5.0.0-next.3..next "cf0a31e197f9034485413674", // v5.0.0-next.4..next "3a99c9097855f93808e482cb", // v5.0.0-next.4..next (offline env-matrix pointer) + "ea46c0d85989de79dba95fd4", // next: focused plugin skills (HACK-1131) "29499060cad876931810b458", // v5.0.0-next.4..next (--value-stdin secret entry) + "afa91b84e719bd416b9dfea3", // next: focused plugin skills + --value-stdin ]), }; diff --git a/src/agents/integration-revision.ts b/src/agents/integration-revision.ts index 2b49586e7..1b0602445 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 = "c3c646a036aa"; +export const HACK_AGENT_INTEGRATION_CONTENT_REVISION = "9f26e5752230"; diff --git a/src/agents/skill-references.ts b/src/agents/skill-references.ts index 57792bea0..d678ee719 100644 --- a/src/agents/skill-references.ts +++ b/src/agents/skill-references.ts @@ -1,10 +1,14 @@ import { createHash } from "node:crypto"; +import { renderErrorCodeReference } from "./error-code-reference.ts"; import { renderPatternCatalogue } from "./pattern-catalogue.ts"; /** Plugin skills that can carry packaged references. */ export type PluginSkillName = | "hack-cli" + | "hack-branches" + | "hack-env" + | "hack-diagnose" | "hack-init" | "hack-install" | "hack-remote"; @@ -39,6 +43,14 @@ export const SKILL_REFERENCES: readonly SkillReference[] = [ "reusable project patterns with what Hack supports today, the missing contract and its owner.", render: renderPatternCatalogue, }, + { + skill: "hack-diagnose", + file: "error-codes.md", + title: "Error codes", + summary: + "every stable `--json` error code with its meaning and the first read-only step.", + render: renderErrorCodeReference, + }, ]; const REFERENCE_FILE_PATTERN = /^[a-z0-9][a-z0-9-]*\.md$/; diff --git a/src/lib/cli-result.ts b/src/lib/cli-result.ts index be1d66301..799d88cbd 100644 --- a/src/lib/cli-result.ts +++ b/src/lib/cli-result.ts @@ -8,31 +8,37 @@ */ /** - * Stable machine-readable error codes for `--json` output. + * Stable machine-readable error codes for `--json` output, in registry order. * * Codes are part of the CLI contract: add new ones as failure sites are - * wired up, never repurpose existing ones. + * wired up, never repurpose existing ones. Generated agent references (the + * `hack-diagnose` error-code reference) render from this list, and a drift + * test fails when the two diverge. */ -export type HackErrorCode = - | "E_DOCKER_UNAVAILABLE" - | "E_GLOBAL_INFRA_DOWN" - | "E_CONFIG_PARSE" - | "E_CONFIG_INVALID" - | "E_PROJECT_NOT_FOUND" - | "E_NATIVE_PROJECT_CONFLICT" - | "E_NATIVE_PROJECT_UNSUPPORTED" - | "E_NATIVE_COMPOSE_PROXY_ACCESS" - | "E_SERVICE_NOT_FOUND" - | "E_COMPOSE_FAILED" - | "E_STARTUP_INCOMPLETE" - | "E_DEPENDENCY_BOOTSTRAP_FAILED" - | "E_STARTUP_TIMEOUT" - | "E_LIFECYCLE_FAILED" - | "E_ENV_KEY_MISSING" - | "E_PERMISSION" - | "E_INTERACTIVE_REQUIRED" - | "E_USAGE" - | "E_UNEXPECTED"; +export const HACK_ERROR_CODES = [ + "E_DOCKER_UNAVAILABLE", + "E_GLOBAL_INFRA_DOWN", + "E_CONFIG_PARSE", + "E_CONFIG_INVALID", + "E_PROJECT_NOT_FOUND", + "E_NATIVE_PROJECT_CONFLICT", + "E_NATIVE_PROJECT_UNSUPPORTED", + "E_NATIVE_COMPOSE_PROXY_ACCESS", + "E_SERVICE_NOT_FOUND", + "E_COMPOSE_FAILED", + "E_STARTUP_INCOMPLETE", + "E_DEPENDENCY_BOOTSTRAP_FAILED", + "E_STARTUP_TIMEOUT", + "E_LIFECYCLE_FAILED", + "E_ENV_KEY_MISSING", + "E_PERMISSION", + "E_INTERACTIVE_REQUIRED", + "E_USAGE", + "E_UNEXPECTED", +] as const; + +/** One stable `--json` error code from {@link HACK_ERROR_CODES}. */ +export type HackErrorCode = (typeof HACK_ERROR_CODES)[number]; export type CliResultError = { readonly code: HackErrorCode; diff --git a/tests/agent-plugin-generation.test.ts b/tests/agent-plugin-generation.test.ts index 8a20add72..ecaafc6b4 100644 --- a/tests/agent-plugin-generation.test.ts +++ b/tests/agent-plugin-generation.test.ts @@ -12,6 +12,7 @@ import { renderPluginRule, renderPluginSkill, } from "../scripts/generate-agent-plugins.ts"; +import { FOCUSED_SKILLS } from "../src/agents/focused-skills.ts"; import { renderHackInitSkill } from "../src/agents/hack-init-skill.ts"; import { renderHackInstallSkill } from "../src/agents/install-skill.ts"; import { renderHackRemoteSkill } from "../src/agents/remote-bootstrap-skill.ts"; @@ -139,6 +140,12 @@ test("bundled skills and rule match current canonical guidance", async () => { expect(await Bun.file(join(root, "skills/hack-cli/SKILL.md")).text()).toBe( renderPluginSkill("hack-cli") ); + expect(FOCUSED_SKILLS.length).toBeGreaterThan(0); + for (const skill of FOCUSED_SKILLS) { + expect( + await Bun.file(join(root, "skills", skill.name, "SKILL.md")).text() + ).toBe(renderPluginSkill(skill.name)); + } const references = renderPluginReferences(); expect(references.size).toBeGreaterThan(0); for (const [path, content] of references) { diff --git a/tests/focused-skills.test.ts b/tests/focused-skills.test.ts new file mode 100644 index 000000000..580929f49 --- /dev/null +++ b/tests/focused-skills.test.ts @@ -0,0 +1,417 @@ +import { expect, test } from "bun:test"; +import { join } from "node:path"; +import { parse as parseYaml } from "yaml"; + +import { renderPluginSkill } from "../scripts/generate-agent-plugins.ts"; +import { renderErrorCodeReference } from "../src/agents/error-code-reference.ts"; +import { + composeCanonicalSections, + FOCUSED_SKILLS, + type FocusedSkillName, + focusedSkill, + HACK_CLI_PLUGIN_ROUTING, + renderSkillDescription, +} from "../src/agents/focused-skills.ts"; +import { renderHackInitSkill } from "../src/agents/hack-init-skill.ts"; +import { renderHackInstallSkill } from "../src/agents/install-skill.ts"; +import { + INSTRUCTION_SECTIONS, + type InstructionSection, +} from "../src/agents/instruction-source.ts"; +import { renderHackRemoteSkill } from "../src/agents/remote-bootstrap-skill.ts"; +import { CLI_SPEC } from "../src/cli/spec.ts"; +import { HACK_ERROR_CODES } from "../src/lib/cli-result.ts"; +import { isRecord } from "../src/lib/guards.ts"; +import { + extractHackInvocations, + findGuidanceCommandIssues, +} from "./helpers/cli-guidance-commands.ts"; + +const pluginRoot = join(import.meta.dir, "..", "plugins", "hack"); + +const FRONTMATTER = /^---\n([\s\S]*?)\n---\n/; + +/** The frontmatter `description` of a rendered skill, as YAML parses it. */ +function descriptionOf(rendered: string): string { + const parsed: unknown = parseYaml(FRONTMATTER.exec(rendered)?.[1] ?? ""); + if (!(isRecord(parsed) && typeof parsed.description === "string")) { + throw new Error("skill render lacks a string description"); + } + return parsed.description.trim(); +} + +function sectionById(id: string): InstructionSection { + const section = INSTRUCTION_SECTIONS.find((candidate) => candidate.id === id); + if (!section) { + throw new Error(`missing canonical section ${id}`); + } + return section; +} + +/** Rendered SKILL.md body of a focused skill, without its frontmatter. */ +function bodyOf(name: FocusedSkillName): string { + const rendered = renderPluginSkill(name); + return rendered.slice(rendered.indexOf("\n---\n") + 5); +} + +type RoutedSkill = { + readonly name: string; + readonly description: string; + readonly triggers: readonly string[]; +}; + +/** Every plugin skill's description, with the trigger phrases it owns. */ +function pluginRouting(): RoutedSkill[] { + return [ + { + name: "hack-cli", + description: descriptionOf(renderPluginSkill("hack-cli")), + triggers: HACK_CLI_PLUGIN_ROUTING.triggers, + }, + ...FOCUSED_SKILLS.map((skill) => ({ + name: skill.name, + description: descriptionOf(renderPluginSkill(skill.name)), + triggers: skill.routing.triggers, + })), + ...[ + ["hack-init", renderHackInitSkill()], + ["hack-install", renderHackInstallSkill()], + ["hack-remote", renderHackRemoteSkill()], + ].map(([name, rendered]) => ({ + name: name ?? "", + description: descriptionOf(rendered ?? ""), + triggers: [], + })), + ]; +} + +/** + * Closed overlap findings: a trigger phrase owned by one skill that also + * appears (case-insensitively) in another skill's description. + */ +function triggerOverlaps(skills: readonly RoutedSkill[]): string[] { + const findings: string[] = []; + for (const owner of skills) { + for (const trigger of owner.triggers) { + for (const other of skills) { + if ( + other !== owner && + other.description.toLowerCase().includes(trigger.toLowerCase()) + ) { + findings.push(`${owner.name} -> ${other.name}`); + } + } + } + } + return findings; +} + +/** Codes listed in the first column of the reference table, in order. */ +function referenceCodes(rendered: string): string[] { + return [...rendered.matchAll(/^\| `(E_[A-Z0-9_]+)` \|/gm)].map( + (match) => match[1] ?? "" + ); +} + +/** Parity between the reference rows and the exported registry. */ +function errorCodeParityIssues(opts: { + readonly rendered: string; + readonly registry: readonly string[]; +}): string[] { + const listed = referenceCodes(opts.rendered); + return [ + ...opts.registry + .filter((code) => !listed.includes(code)) + .map((code) => `missing ${code}`), + ...listed + .filter((code) => !opts.registry.includes(code)) + .map((code) => `unexpected ${code}`), + ...listed + .filter((code, index) => listed.indexOf(code) !== index) + .map((code) => `duplicate ${code}`), + ]; +} + +test("every focused skill section id exists in the canonical source", () => { + const ids = new Set(INSTRUCTION_SECTIONS.map((section) => section.id)); + expect(FOCUSED_SKILLS.map((skill) => skill.name)).toEqual([ + "hack-branches", + "hack-env", + "hack-diagnose", + ]); + for (const skill of FOCUSED_SKILLS) { + expect(skill.sections.length, skill.name).toBeGreaterThan(0); + for (const entry of skill.sections) { + expect(ids.has(entry.id), `${skill.name}: ${entry.id}`).toBe(true); + } + } +}); + +test("focused skills render canonical sections verbatim, in order", () => { + for (const skill of FOCUSED_SKILLS) { + const body = bodyOf(skill.name); + let previous = -1; + for (const entry of skill.sections) { + const section = sectionById(entry.id); + const title = entry.optional + ? `## ${section.title} (optional)` + : `## ${section.title}`; + const at = body.indexOf(title); + expect(at, `${skill.name}: ${entry.id} heading order`).toBeGreaterThan( + previous + ); + for (const bullet of section.bullets) { + expect(body, `${skill.name}: ${entry.id}`).toContain(`- ${bullet}`); + } + previous = at; + } + // Only canonical headings appear: no skill-local prose sections. + const headings = [...body.matchAll(/^## (.+)$/gm)].map( + (match) => match[1] ?? "" + ); + expect(headings.filter((heading) => heading !== "References")).toEqual( + skill.sections.map((entry) => { + const title = sectionById(entry.id).title; + return entry.optional ? `${title} (optional)` : title; + }) + ); + } +}); + +test("negative control: an unknown or repeated section id fails composition", () => { + expect(() => + composeCanonicalSections({ sections: [{ id: "no-such-section" }] }) + ).toThrow("Unknown instruction section id in focused skill"); + expect(() => + composeCanonicalSections({ + sections: [{ id: "env-composition" }, { id: "env-composition" }], + }) + ).toThrow("Duplicate instruction section id in focused skill"); + // The refusal is closed: it never echoes the caller's id. + let message = ""; + try { + composeCanonicalSections({ sections: [{ id: "secret-looking-id" }] }); + } catch (error) { + message = error instanceof Error ? error.message : String(error); + } + expect(message).toBe("Unknown instruction section id in focused skill"); +}); + +test("focused-only canonical sections stay out of the broad surfaces", () => { + for (const id of [ + "branch-aliases", + "env-commands", + "env-safety", + "diagnose", + ]) { + expect(sectionById(id).surfaces, id).toEqual([]); + } +}); + +test("hack-branches leads with --branch and the branch subcommands; worktrees are optional", () => { + const spec = focusedSkill("hack-branches"); + expect(spec.sections[0]?.id).toBe("branch-instances"); + expect(spec.routing.summary).toContain("`--branch `"); + const body = bodyOf("hack-branches"); + const first = extractHackInvocations(body)[0] ?? ""; + expect(first).toContain("--branch "); + for (const subcommand of ["add", "list", "open", "remove"]) { + expect(body).toContain(`\`hack branch ${subcommand}`); + } + const worktrees = spec.sections.find((entry) => entry.id === "worktrees"); + expect(worktrees?.optional).toBe(true); + expect(spec.sections.filter((entry) => entry.optional)).toEqual([ + { id: "worktrees", optional: true }, + ]); + expect(body.indexOf("## Linked git worktrees (optional)")).toBeGreaterThan( + body.indexOf("## Branch aliases") + ); +}); + +test("hack-env covers its commands and states precedence only from the canonical env section", () => { + const spec = focusedSkill("hack-env"); + expect(spec.sections[0]?.id).toBe("env-composition"); + const body = bodyOf("hack-env"); + for (const command of [ + "`hack env list", + "`hack env get", + "`hack env explain", + "`hack env apply", + "`hack env set --local`", + "`hack env set --secret`", + "`hack env exec", + ".hack/hack.env..yaml", + ".hack/hack.env.local.yaml", + ]) { + expect(body, command).toContain(command); + } + // Precedence wording lives in `env-composition` alone; skill-specific + // sections must not restate (and so cannot contradict) it. + const precedence = + /\b(?:layer order|precedence|override[sn]?\s+`global`|values last)\b/i; + for (const entry of spec.sections) { + if (entry.id === "env-composition") { + continue; + } + for (const bullet of sectionById(entry.id).bullets) { + expect(precedence.test(bullet), `${entry.id}: ${bullet}`).toBe(false); + } + } + expect( + sectionById("env-composition").bullets.some((bullet) => + precedence.test(bullet) + ) + ).toBe(true); +}); + +test("hack-env keeps secret values out of argv and treats get as a plaintext sink", () => { + const body = bodyOf("hack-env"); + expect(body).toContain( + "A secret value never goes in command arguments or agent prompts" + ); + expect(body).toContain("are plaintext sinks"); + expect(body).toContain("`hack env explain "); + // No rendered example passes a secret-looking value as an argument or + // assignment. + for (const pattern of [ + /--(?:password|passwd|token|secret|api-key|apikey)(?:=|\s+)(?!-)[^\s`]/i, + /\b[A-Z][A-Z0-9_]*(?:TOKEN|SECRET|PASSWORD|PASSWD|API_KEY)=\S/, + /`hack env (?:set|add) --secret/, + ]) { + expect(pattern.test(body), String(pattern)).toBe(false); + } +}); + +test("hack-diagnose reads first in the documented order and repairs only within the request", () => { + const spec = focusedSkill("hack-diagnose"); + expect(spec.sections[0]?.id).toBe("diagnose"); + const body = bodyOf("hack-diagnose"); + const order = [ + "`hack ps --json`", + "`hack doctor --json`", + "`hack logs --json --no-follow --tail 200`", + "`hack env explain --service `", + "`hack global status`", + ].map((step) => body.indexOf(step)); + expect(order.every((index) => index >= 0)).toBe(true); + expect([...order].sort((a, b) => a - b)).toEqual(order); + expect(body).toContain( + "apply `hack doctor --fix` only for repairs covered by the request" + ); + // Every `--fix` mention comes after the read-first steps. + expect(body.indexOf("--fix")).toBeGreaterThan(order.at(-1) ?? 0); + expect(body).toContain("](references/error-codes.md)"); +}); + +test("every hack command and flag in the focused skills resolves in CLI_SPEC", () => { + const rendered: Record = { + "hack-cli": renderPluginSkill("hack-cli"), + "error-codes": renderErrorCodeReference(), + }; + for (const skill of FOCUSED_SKILLS) { + rendered[skill.name] = renderPluginSkill(skill.name); + } + for (const [name, text] of Object.entries(rendered)) { + expect(extractHackInvocations(text).length, name).toBeGreaterThan(0); + expect( + findGuidanceCommandIssues({ cli: CLI_SPEC, rendered: text }) + ).toEqual([]); + } + // The walker still rejects a bad branch or env invocation. + for (const fixture of [ + "`hack branch rename `", + "`hack env set --shared`", + "`hack logs --json --no-follow --last 200`", + ]) { + expect( + findGuidanceCommandIssues({ cli: CLI_SPEC, rendered: fixture }), + fixture + ).not.toEqual([]); + } +}); + +test("the error-code reference lists exactly the exported registry", async () => { + const rendered = renderErrorCodeReference(); + expect(HACK_ERROR_CODES.length).toBeGreaterThan(0); + expect(referenceCodes(rendered)).toEqual([...HACK_ERROR_CODES]); + expect( + errorCodeParityIssues({ rendered, registry: HACK_ERROR_CODES }) + ).toEqual([]); + const packaged = await Bun.file( + join(pluginRoot, "skills/hack-diagnose/references/error-codes.md") + ).text(); + expect(packaged.startsWith(rendered.trimEnd())).toBe(true); + expect(referenceCodes(packaged)).toEqual([...HACK_ERROR_CODES]); +}); + +test("negative control: a fake or missing E_* code fails registry parity", () => { + const rendered = renderErrorCodeReference(); + expect( + errorCodeParityIssues({ + rendered: `${rendered}| \`E_FAKE_CODE\` | Not a real code. | \`hack doctor --json\` |\n`, + registry: HACK_ERROR_CODES, + }) + ).toEqual(["unexpected E_FAKE_CODE"]); + expect( + errorCodeParityIssues({ + rendered: rendered.replace(/^\| `E_USAGE` \|.*\n/m, ""), + registry: HACK_ERROR_CODES, + }) + ).toEqual(["missing E_USAGE"]); + expect( + errorCodeParityIssues({ + rendered, + registry: [...HACK_ERROR_CODES, "E_NOT_RENDERED"], + }) + ).toEqual(["missing E_NOT_RENDERED"]); +}); + +test("rendered descriptions are the routing sentences and parse as YAML", () => { + expect(descriptionOf(renderPluginSkill("hack-cli"))).toBe( + renderSkillDescription(HACK_CLI_PLUGIN_ROUTING) + ); + for (const skill of FOCUSED_SKILLS) { + expect(descriptionOf(renderPluginSkill(skill.name))).toBe( + renderSkillDescription(skill.routing) + ); + } + const cli = descriptionOf(renderPluginSkill("hack-cli")); + for (const name of ["hack-branches", "hack-env", "hack-diagnose"]) { + expect(cli).toContain(name); + } +}); + +test("no trigger phrase is shared between plugin skill descriptions", () => { + const skills = pluginRouting(); + expect(skills.map((skill) => skill.name)).toHaveLength(7); + const owned = skills.flatMap((skill) => skill.triggers); + expect(owned.length).toBeGreaterThan(0); + expect(new Set(owned.map((trigger) => trigger.toLowerCase())).size).toBe( + owned.length + ); + expect(triggerOverlaps(skills)).toEqual([]); +}); + +test("negative control: a duplicated trigger phrase fails the overlap test", () => { + const skills = pluginRouting(); + const env = skills.find((skill) => skill.name === "hack-env"); + const borrowed = env?.triggers[0]; + if (!borrowed) { + throw new Error("hack-env has no trigger phrases"); + } + const withDuplicate = skills.map((skill) => + skill.name === "hack-diagnose" + ? { + ...skill, + description: renderSkillDescription({ + ...focusedSkill("hack-diagnose").routing, + triggers: [ + ...focusedSkill("hack-diagnose").routing.triggers, + borrowed, + ], + }), + } + : skill + ); + expect(triggerOverlaps(withDuplicate)).toEqual(["hack-env -> hack-diagnose"]); +}); diff --git a/tests/skill-references.test.ts b/tests/skill-references.test.ts index 35622f3f7..951fc4594 100644 --- a/tests/skill-references.test.ts +++ b/tests/skill-references.test.ts @@ -63,8 +63,12 @@ test("every packaged reference is linked and carries a recomputable revision", a test("only the plugin skill links references; standalone renders stay unchanged", () => { expect(renderCodexSkill()).not.toContain("references/"); const plugin = renderPluginSkill("hack-cli"); - expect(plugin.startsWith(renderCodexSkill().trimEnd())).toBe(true); - expect(plugin.slice(renderCodexSkill().trimEnd().length)).toBe( + // The plugin variant differs only in its routing description and the + // appended references; the instruction body is the standalone body. + const body = (text: string) => text.slice(text.indexOf("\n---\n") + 5); + const standaloneBody = body(renderCodexSkill()).trimEnd(); + expect(body(plugin).startsWith(standaloneBody)).toBe(true); + expect(body(plugin).slice(standaloneBody.length)).toBe( "\n\n## References\n\n- [Pattern catalogue](references/patterns.md): reusable project patterns with what Hack supports today, the missing contract and its owner.\n" ); const unchanged = "---\nname: hack-init\n---\n";