diff --git a/.claude/skills/hack-init/SKILL.md b/.claude/skills/hack-init/SKILL.md index 1b186c461..74638c097 100644 --- a/.claude/skills/hack-init/SKILL.md +++ b/.claude/skills/hack-init/SKILL.md @@ -12,6 +12,6 @@ Fetch the current onboarding guidance from the installed Hack CLI. 1. Fetch the canonical onboarding prompt: - With shell access: run `hack agent onboard` (works for new and existing projects). - - Without shell access: only if the `hack-mcp` plugin or another Hack MCP server is installed, fetch the `hack-init` MCP prompt from it; otherwise report that onboarding needs shell access or an explicitly selected Hack MCP server. + - Without shell access: only if the `hack-mcp` plugin or another Hack MCP server is installed, fetch the `hack-init` MCP prompt from it and pass `repoRoot` (the repository root path) so the prompt includes Hack's repo inventory; otherwise report that onboarding needs shell access or an explicitly selected Hack MCP server. 2. Apply the relevant phases to the authorized workflow; preserve native approvals, credential safety, and runtime ownership. 3. Verify the requested services and host workflows. Report remaining blockers; unrelated optional integration warnings do not prevent completion. diff --git a/.codex/skills/hack-cli/SKILL.md b/.codex/skills/hack-cli/SKILL.md index 6088d8a6d..cfe610f0b 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: `8f9bff2d631a` (version alone is not a freshness guarantee). +- Content revision: `bd15ee2d251b` (version alone is not a freshness guarantee). ## Product boundary @@ -192,6 +192,6 @@ Use `hack` as the primary interface for local-first development. - MCP is opt-in, only for clients without shell access and only when explicitly selected: add the separate `hack-mcp` plugin, or use `hack setup mcp` (client and scope via `hack mcp install --help`). Do not configure other clients as a side effect. - Standalone export is an opt-in alternative to the plugin: `hack setup cursor` (rules), `hack setup claude` (hooks and skill), `hack setup codex` (skills) or `hack setup agents` (AGENTS.md/CLAUDE.md snippets). Use one integration path per client. - Setup and onboarding never write AGENTS.md, CLAUDE.md, rule files, or repository skill copies unless the user asks for them. -- Agent-assisted onboarding: `hack init --with claude|codex` (new repos) or `hack agent onboard` (existing projects) print/hand off the full setup prompt; the `/hack-init` skill and the `hack-init` MCP prompt return the same content. +- Agent-assisted onboarding: `hack init --with claude|codex` (new repos) or `hack agent onboard` (existing projects) print/hand off the full setup prompt; the `/hack-init` skill and the `hack-init` MCP prompt return the same content. No-shell clients pass `repoRoot` (the repository root path) to the `hack-init` MCP prompt to include Hack's repo inventory. - Init prompt: `hack agent init` (use --client cursor|claude|codex to open) - Init patterns: `hack agent patterns` diff --git a/.codex/skills/hack-init/SKILL.md b/.codex/skills/hack-init/SKILL.md index 1b186c461..74638c097 100644 --- a/.codex/skills/hack-init/SKILL.md +++ b/.codex/skills/hack-init/SKILL.md @@ -12,6 +12,6 @@ Fetch the current onboarding guidance from the installed Hack CLI. 1. Fetch the canonical onboarding prompt: - With shell access: run `hack agent onboard` (works for new and existing projects). - - Without shell access: only if the `hack-mcp` plugin or another Hack MCP server is installed, fetch the `hack-init` MCP prompt from it; otherwise report that onboarding needs shell access or an explicitly selected Hack MCP server. + - Without shell access: only if the `hack-mcp` plugin or another Hack MCP server is installed, fetch the `hack-init` MCP prompt from it and pass `repoRoot` (the repository root path) so the prompt includes Hack's repo inventory; otherwise report that onboarding needs shell access or an explicitly selected Hack MCP server. 2. Apply the relevant phases to the authorized workflow; preserve native approvals, credential safety, and runtime ownership. 3. Verify the requested services and host workflows. Report remaining blockers; unrelated optional integration warnings do not prevent completion. diff --git a/.cursor/rules/hack.mdc b/.cursor/rules/hack.mdc index ac63cee5f..2e0c431c3 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: `8f9bff2d631a` (version alone is not a freshness guarantee). +- Content revision: `bd15ee2d251b` (version alone is not a freshness guarantee). ## Product boundary diff --git a/docs/agent-guidance.md b/docs/agent-guidance.md index aaf6d11b0..9e87f3b16 100644 --- a/docs/agent-guidance.md +++ b/docs/agent-guidance.md @@ -17,9 +17,9 @@ run only `hack agent prime`. See [model-policy ownership](reference/agent-client | `apps/macos/AGENTS.md` | Explicit maintenance of the unsupported native app | | `.ai/skills/` | Contributor quality, verification, work units, performance and TLA+ workflows | | `src/agents/instruction-source.ts` | Project AGENTS/CLAUDE snippets, standalone/shared Hack skill, Cursor rules, session primer, native plugin skill/rule | -| `src/agents/onboarding-prompt.ts` | `hack agent onboard`, init handoff, and MCP onboarding prompt | +| `src/agents/onboarding-prompt.ts` | The one onboarding builder: `hack agent onboard`, `hack agent init`, init handoff, and MCP onboarding prompt (same core, per-entry framing only) | | `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/init-patterns.ts`, `init-assistant.ts` | Repo inventory embedded by the onboarding builder, and pattern hints | | `src/agents/install-skill.ts`, `remote-bootstrap-skill.ts` | Plugin installation/channel selection and fresh-host/cloud setup skills | | `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` | diff --git a/docs/guides/agent-first-setup.md b/docs/guides/agent-first-setup.md index cba4ead33..321cf259b 100644 --- a/docs/guides/agent-first-setup.md +++ b/docs/guides/agent-first-setup.md @@ -5,6 +5,13 @@ existing setup — instead of doing it by hand. One canonical onboarding prompt every entry point below; the content lives in the CLI, so it is always current for your installed version. +Every entry point calls the same builder with the same repo inventory. For the same +repo, `hack init --with`, `hack agent onboard`, `hack agent init`, and the +`hack-init` MCP prompt (with `repoRoot`) produce byte-identical prompt content; each +adds at most a fixed framing line. The mode follows the repo: `existing-project` +when `.hack/` exists (project name and dev host come from its config), +`new-project` otherwise. + ## Entry points (same prompt, four ways in) | You have | Use | @@ -25,8 +32,9 @@ hack init --auto --with claude # or codex, or both onboarding prompt (`claude ""` / `codex ""`). - If the agent CLI is not on PATH, the prompt is printed with copy-paste instructions instead. -- If `.hack/` already exists, init is skipped and the handoff proceeds in - existing-project mode. +- The handoff always runs after `.hack/` exists, so the prompt is in + existing-project mode. A framing line says whether this run scaffolded `.hack/` + or found it already present and skipped init. - With `--no-interactive` (or `HACK_NO_INTERACTIVE=1`, or no TTY) nothing is spawned — the prompt is always printed. @@ -37,8 +45,15 @@ hack agent onboard # prints the onboarding prompt for this project hack agent onboard | pbcopy # copy it for any agent session ``` -The prompt picks up the project name and dev host from `.hack/hack.config.json` -when present. +The prompt starts with a pre-computed repo inventory and picks up the project name +and dev host from `.hack/hack.config.json` when present. + +The `hack-init` MCP prompt takes an optional `repoRoot` argument (relative paths +resolve against the MCP server's working directory). With `repoRoot` it returns the +same content as `hack agent onboard` for that repo and ignores the `mode`, +`projectName`, and `devHost` arguments. Without it the server scans nothing, uses +those arguments (default `existing-project`), and the prompt states that no +inventory was run. ## Agent-side skill: `/hack-init` @@ -47,7 +62,8 @@ through your client's plugin manager (see [Optional native plugins](../integrations.md#optional-native-plugins)). The skill tells the agent to run `hack agent onboard` and follow the printed prompt. Without shell access it fetches the `hack-init` MCP prompt only when a Hack MCP server, such -as the opt-in `hack-mcp` plugin, is installed; otherwise it reports that blocker. +as the opt-in `hack-mcp` plugin, is installed, and passes `repoRoot` so the prompt +includes the repo inventory; otherwise it reports that blocker. The content stays in the CLI, so the skill fetches guidance matching the installed CLI. @@ -117,10 +133,11 @@ The ground rules tell the agent not to create or modify `AGENTS.md`, `CLAUDE.md` rule files, or repository skill copies unless asked. Onboarding configures the runtime; it does not install agent instructions. The agent also reports the discovery limits it hit: a missing agent client (the prompt was printed instead of -launched), a non-interactive run, or the no-shell `hack-init` MCP prompt, which -defaults to existing-project mode without a repo inventory. +launched), a non-interactive run, the inventory's remaining discovery limitations, +or a `hack-init` MCP prompt request without `repoRoot`, which defaults to +existing-project mode without a repo inventory. -`hack agent init` adds a pre-computed inventory before the same prompt. It reads +The pre-computed inventory comes before the phases in every entry point. It reads file names and manifest keys only, never env-file contents, lockfile contents or pin values. It ends with a list of discovery limitations, such as conflicting package managers or non-JS services whose entrypoints were not inferred. diff --git a/examples/basic/AGENTS.md b/examples/basic/AGENTS.md index 74509c9de..7fb916746 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: `8f9bff2d631a` (version alone is not a freshness guarantee). +- Content revision: `bd15ee2d251b` (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. @@ -163,7 +163,7 @@ Agent setup (plugin-first): - MCP is opt-in, only for clients without shell access and only when explicitly selected: add the separate `hack-mcp` plugin, or use `hack setup mcp` (client and scope via `hack mcp install --help`). Do not configure other clients as a side effect. - Standalone export is an opt-in alternative to the plugin: `hack setup cursor` (rules), `hack setup claude` (hooks and skill), `hack setup codex` (skills) or `hack setup agents` (AGENTS.md/CLAUDE.md snippets). Use one integration path per client. - Setup and onboarding never write AGENTS.md, CLAUDE.md, rule files, or repository skill copies unless the user asks for them. -- Agent-assisted onboarding: `hack init --with claude|codex` (new repos) or `hack agent onboard` (existing projects) print/hand off the full setup prompt; the `/hack-init` skill and the `hack-init` MCP prompt return the same content. +- Agent-assisted onboarding: `hack init --with claude|codex` (new repos) or `hack agent onboard` (existing projects) print/hand off the full setup prompt; the `/hack-init` skill and the `hack-init` MCP prompt return the same content. No-shell clients pass `repoRoot` (the repository root path) to the `hack-init` MCP prompt to include Hack's repo inventory. - Init prompt: `hack agent init` (use --client cursor|claude|codex to open) - Init patterns: `hack agent patterns` diff --git a/examples/basic/CLAUDE.md b/examples/basic/CLAUDE.md index 74509c9de..7fb916746 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: `8f9bff2d631a` (version alone is not a freshness guarantee). +- Content revision: `bd15ee2d251b` (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. @@ -163,7 +163,7 @@ Agent setup (plugin-first): - MCP is opt-in, only for clients without shell access and only when explicitly selected: add the separate `hack-mcp` plugin, or use `hack setup mcp` (client and scope via `hack mcp install --help`). Do not configure other clients as a side effect. - Standalone export is an opt-in alternative to the plugin: `hack setup cursor` (rules), `hack setup claude` (hooks and skill), `hack setup codex` (skills) or `hack setup agents` (AGENTS.md/CLAUDE.md snippets). Use one integration path per client. - Setup and onboarding never write AGENTS.md, CLAUDE.md, rule files, or repository skill copies unless the user asks for them. -- Agent-assisted onboarding: `hack init --with claude|codex` (new repos) or `hack agent onboard` (existing projects) print/hand off the full setup prompt; the `/hack-init` skill and the `hack-init` MCP prompt return the same content. +- Agent-assisted onboarding: `hack init --with claude|codex` (new repos) or `hack agent onboard` (existing projects) print/hand off the full setup prompt; the `/hack-init` skill and the `hack-init` MCP prompt return the same content. No-shell clients pass `repoRoot` (the repository root path) to the `hack-init` MCP prompt to include Hack's repo inventory. - Init prompt: `hack agent init` (use --client cursor|claude|codex to open) - Init patterns: `hack agent patterns` diff --git a/plugins/hack/rules/hack.mdc b/plugins/hack/rules/hack.mdc index 4d221a373..607ba5aa8 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: `8f9bff2d631a` (version alone is not a freshness guarantee). +- Content revision: `bd15ee2d251b` (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 index 9223b872a..580f7b174 100644 --- a/plugins/hack/skills/hack-branches/SKILL.md +++ b/plugins/hack/skills/hack-branches/SKILL.md @@ -35,4 +35,4 @@ description: > - 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: `8f9bff2d631a` (version alone is not a freshness guarantee). +- Content revision: `bd15ee2d251b` (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 77243c01e..a98351c5a 100644 --- a/plugins/hack/skills/hack-cli/SKILL.md +++ b/plugins/hack/skills/hack-cli/SKILL.md @@ -16,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: `8f9bff2d631a` (version alone is not a freshness guarantee). +- Content revision: `bd15ee2d251b` (version alone is not a freshness guarantee). ## Product boundary @@ -194,7 +194,7 @@ Use `hack` as the primary interface for local-first development. - MCP is opt-in, only for clients without shell access and only when explicitly selected: add the separate `hack-mcp` plugin, or use `hack setup mcp` (client and scope via `hack mcp install --help`). Do not configure other clients as a side effect. - Standalone export is an opt-in alternative to the plugin: `hack setup cursor` (rules), `hack setup claude` (hooks and skill), `hack setup codex` (skills) or `hack setup agents` (AGENTS.md/CLAUDE.md snippets). Use one integration path per client. - Setup and onboarding never write AGENTS.md, CLAUDE.md, rule files, or repository skill copies unless the user asks for them. -- Agent-assisted onboarding: `hack init --with claude|codex` (new repos) or `hack agent onboard` (existing projects) print/hand off the full setup prompt; the `/hack-init` skill and the `hack-init` MCP prompt return the same content. +- Agent-assisted onboarding: `hack init --with claude|codex` (new repos) or `hack agent onboard` (existing projects) print/hand off the full setup prompt; the `/hack-init` skill and the `hack-init` MCP prompt return the same content. No-shell clients pass `repoRoot` (the repository root path) to the `hack-init` MCP prompt to include Hack's repo inventory. - Init prompt: `hack agent init` (use --client cursor|claude|codex to open) - Init patterns: `hack agent patterns` diff --git a/plugins/hack/skills/hack-diagnose/SKILL.md b/plugins/hack/skills/hack-diagnose/SKILL.md index 1cc9297ec..2ef54a686 100644 --- a/plugins/hack/skills/hack-diagnose/SKILL.md +++ b/plugins/hack/skills/hack-diagnose/SKILL.md @@ -37,7 +37,7 @@ description: > - 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: `8f9bff2d631a` (version alone is not a freshness guarantee). +- Content revision: `bd15ee2d251b` (version alone is not a freshness guarantee). ## References diff --git a/plugins/hack/skills/hack-env/SKILL.md b/plugins/hack/skills/hack-env/SKILL.md index db99abd99..0cb15e0a4 100644 --- a/plugins/hack/skills/hack-env/SKILL.md +++ b/plugins/hack/skills/hack-env/SKILL.md @@ -34,4 +34,4 @@ description: > - 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: `8f9bff2d631a` (version alone is not a freshness guarantee). +- Content revision: `bd15ee2d251b` (version alone is not a freshness guarantee). diff --git a/plugins/hack/skills/hack-init/SKILL.md b/plugins/hack/skills/hack-init/SKILL.md index 1b186c461..74638c097 100644 --- a/plugins/hack/skills/hack-init/SKILL.md +++ b/plugins/hack/skills/hack-init/SKILL.md @@ -12,6 +12,6 @@ Fetch the current onboarding guidance from the installed Hack CLI. 1. Fetch the canonical onboarding prompt: - With shell access: run `hack agent onboard` (works for new and existing projects). - - Without shell access: only if the `hack-mcp` plugin or another Hack MCP server is installed, fetch the `hack-init` MCP prompt from it; otherwise report that onboarding needs shell access or an explicitly selected Hack MCP server. + - Without shell access: only if the `hack-mcp` plugin or another Hack MCP server is installed, fetch the `hack-init` MCP prompt from it and pass `repoRoot` (the repository root path) so the prompt includes Hack's repo inventory; otherwise report that onboarding needs shell access or an explicitly selected Hack MCP server. 2. Apply the relevant phases to the authorized workflow; preserve native approvals, credential safety, and runtime ownership. 3. Verify the requested services and host workflows. Report remaining blockers; unrelated optional integration warnings do not prevent completion. diff --git a/src/agents/hack-init-skill.ts b/src/agents/hack-init-skill.ts index f69062811..777156464 100644 --- a/src/agents/hack-init-skill.ts +++ b/src/agents/hack-init-skill.ts @@ -74,7 +74,7 @@ export function renderHackInitSkill(): string { "", "1. Fetch the canonical onboarding prompt:", " - With shell access: run `hack agent onboard` (works for new and existing projects).", - " - Without shell access: only if the `hack-mcp` plugin or another Hack MCP server is installed, fetch the `hack-init` MCP prompt from it; otherwise report that onboarding needs shell access or an explicitly selected Hack MCP server.", + " - Without shell access: only if the `hack-mcp` plugin or another Hack MCP server is installed, fetch the `hack-init` MCP prompt from it and pass `repoRoot` (the repository root path) so the prompt includes Hack's repo inventory; otherwise report that onboarding needs shell access or an explicitly selected Hack MCP server.", "2. Apply the relevant phases to the authorized workflow; preserve native approvals, credential safety, and runtime ownership.", "3. Verify the requested services and host workflows. Report remaining blockers; unrelated optional integration warnings do not prevent completion.", "", diff --git a/src/agents/init-assistant.ts b/src/agents/init-assistant.ts index 0b9c5a515..d70f7c42e 100644 --- a/src/agents/init-assistant.ts +++ b/src/agents/init-assistant.ts @@ -6,7 +6,6 @@ import { pathExists, readTextFile } from "../lib/fs.ts"; import { isRecord } from "../lib/guards.ts"; import type { ProjectContext } from "../lib/project.ts"; import { findProjectContext } from "../lib/project.ts"; -import { renderOnboardingPrompt } from "./onboarding-prompt.ts"; export type ComposeSignal = { readonly path: string; @@ -159,28 +158,19 @@ export async function buildInitAssistantReport(opts: { } /** - * Render the init assistant prompt based on repo signals. + * Render the pre-computed repo inventory section of the onboarding prompt. * - * The dynamic repo-signal report is rendered here; the canonical onboarding - * guidance (phases, deps/ops container patterns, verification loop) comes - * from the shared onboarding prompt module so content never forks between - * `hack agent init`, `hack agent onboard`, and `hack init --with`. + * This is the only renderer of {@link InitAssistantReport}. Every onboarding + * entry point (`hack init --with`, `hack agent onboard`, `hack agent init`, and + * the `hack-init` MCP prompt) reaches it through the shared core renderer in + * `onboarding-prompt.ts`, so the inventory text cannot fork between them. + * The returned lines carry no heading and no trailing blank line. */ -export function renderInitAssistantPrompt(opts: { +export function renderInitAssistantInventory(opts: { readonly report: InitAssistantReport; -}): string { +}): string[] { const lines: string[] = []; - lines.push("# hack agent init"); - lines.push(""); - lines.push( - [ - "You are helping a user bootstrap hack in this repo.", - "Prefer the Hack CLI and infer routine details from the repo; ask only when missing information materially affects correctness or scope.", - "A pre-computed repo inventory follows — confirm and extend it while working through the onboarding phases below.", - ].join(" ") - ); - lines.push(""); lines.push("Repo context:"); lines.push(`- Root: ${opts.report.repoRoot}`); lines.push(`- Hack config: ${formatHackContext({ report: opts.report })}`); @@ -267,16 +257,8 @@ export function renderInitAssistantPrompt(opts: { for (const question of buildSuggestedQuestions({ report: opts.report })) { lines.push(`- ${question}`); } - lines.push(""); - lines.push( - renderOnboardingPrompt({ - mode: opts.report.projectContext ? "existing-project" : "new-project", - }) - ); - lines.push("Need dependency/ops patterns? Run `hack agent patterns`."); - lines.push(""); - return lines.join("\n"); + return lines; } const DOCKERFILE_PATTERNS = ["**/Dockerfile", "**/Dockerfile.*"] as const; diff --git a/src/agents/instruction-source.ts b/src/agents/instruction-source.ts index beb584031..5c936e666 100644 --- a/src/agents/instruction-source.ts +++ b/src/agents/instruction-source.ts @@ -358,7 +358,7 @@ export const INSTRUCTION_SECTIONS: readonly InstructionSection[] = [ "MCP is opt-in, only for clients without shell access and only when explicitly selected: add the separate `hack-mcp` plugin, or use `hack setup mcp` (client and scope via `hack mcp install --help`). Do not configure other clients as a side effect.", "Standalone export is an opt-in alternative to the plugin: `hack setup cursor` (rules), `hack setup claude` (hooks and skill), `hack setup codex` (skills) or `hack setup agents` (AGENTS.md/CLAUDE.md snippets). Use one integration path per client.", "Setup and onboarding never write AGENTS.md, CLAUDE.md, rule files, or repository skill copies unless the user asks for them.", - "Agent-assisted onboarding: `hack init --with claude|codex` (new repos) or `hack agent onboard` (existing projects) print/hand off the full setup prompt; the `/hack-init` skill and the `hack-init` MCP prompt return the same content.", + "Agent-assisted onboarding: `hack init --with claude|codex` (new repos) or `hack agent onboard` (existing projects) print/hand off the full setup prompt; the `/hack-init` skill and the `hack-init` MCP prompt return the same content. No-shell clients pass `repoRoot` (the repository root path) to the `hack-init` MCP prompt to include Hack's repo inventory.", "Init prompt: `hack agent init` (use --client cursor|claude|codex to open)", "Init patterns: `hack agent patterns`", ], diff --git a/src/agents/integration-ownership.ts b/src/agents/integration-ownership.ts index c6f9b3559..9b3eba20e 100644 --- a/src/agents/integration-ownership.ts +++ b/src/agents/integration-ownership.ts @@ -116,12 +116,15 @@ const KNOWN_RENDER_FINGERPRINTS: Readonly< "eba974f66328605864507beb", // next: focused plugin skills + plugin-first agent setup "cf91ca873a00ad4743198e11", // next: plugin-first agent setup + refresh-only setup sync "0e99791aa0edfd1895e31748", // next: runtime metadata backend scope + refresh-only setup sync + "c6cd8f369e902624f9a31d2c", // next: hack-init MCP prompt takes repoRoot (HACK-1137) "124ca278acd4be97747bac9b", // next: runtime metadata for authored projects + "88900097f7eb18a752de31bb", // next: hack-init MCP prompt repoRoot + authored runtime metadata ]), "hack-init-skill": new Set([ "cd15fb6d8ed8e25071d05e75", // v3.1.0..v4.0.3 "f8e3018bcb8546e802dfa19c", // v4.1.0..next "dde7ffb2975e3257395f9506", // next: conditional hack-mcp fallback + "d8b5426211851f068d9a587b", // next: hack-init MCP prompt takes repoRoot (HACK-1137) ]), "cursor-rules": new Set([ "6acabe0f41a9785374970d6c", // v1.1.0..v1.10.2 @@ -155,7 +158,9 @@ const KNOWN_RENDER_FINGERPRINTS: Readonly< "270cd2b3fdcc325e04c76d43", // next: focused plugin skills + plugin-first agent setup "92ca4aa8f28ba1c93093c04a", // next: plugin-first agent setup + refresh-only setup sync "0b82281933af514de8a1b8b8", // next: runtime metadata backend scope + refresh-only setup sync + "5dcd202fc7bc640895bd876c", // next: hack-init MCP prompt takes repoRoot (HACK-1137) "05f095e17267519ad3660da5", // next: runtime metadata for authored projects + "07e49d3c2b05b93b6c49adfd", // next: hack-init MCP prompt repoRoot + authored runtime metadata ]), }; diff --git a/src/agents/integration-revision.ts b/src/agents/integration-revision.ts index fde8a0dce..b434029d7 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 = "8f9bff2d631a"; +export const HACK_AGENT_INTEGRATION_CONTENT_REVISION = "bd15ee2d251b"; diff --git a/src/agents/onboarding-prompt.ts b/src/agents/onboarding-prompt.ts index bee6260a4..8eef76028 100644 --- a/src/agents/onboarding-prompt.ts +++ b/src/agents/onboarding-prompt.ts @@ -1,15 +1,36 @@ +import { + findProjectContext, + findRepoRootForInit, + readProjectConfig, + readProjectDevHost, +} from "../lib/project.ts"; +import { + buildInitAssistantReport, + type InitAssistantReport, + renderInitAssistantInventory, +} from "./init-assistant.ts"; import { INSTRUCTION_SECTIONS } from "./instruction-source.ts"; /** * Single source of truth for the agent-assisted onboarding prompt. * - * Consumers (keep this list current — the content must never fork): - * - `hack init --with claude|codex` (CLI handoff for new repos), - * - `hack agent onboard` (CLI print for existing projects), - * - the `/hack-init` agent-side skill (thin pointer shipped by the default - * `hack` plugin, or exported by opt-in standalone `hack setup claude|codex`), - * - the `hack-init` MCP prompt (no-shell clients), - * - the copy-paste bootstrap block in `docs/guides/agent-first-setup.md`. + * Every entry point builds its prompt here, from one repo inventory, so the + * content can never fork (keep this list current): + * - `hack init --with claude|codex` (CLI handoff after init), + * - `hack agent onboard` (CLI print), + * - `hack agent init` (CLI print or client handoff), + * - the `hack-init` MCP prompt (no-shell clients). + * + * Contract: an entry point calls {@link buildOnboardingPrompt} (or, only for + * the MCP prompt without a `repoRoot`, {@link onboardingContextWithoutInventory} + * plus {@link renderOnboardingPrompt}). The rendered prompt is exactly + * `frame.before + core + frame.after`: the core comes from + * {@link renderOnboardingCore} and is byte-identical for every entry point on + * the same repo; {@link renderOnboardingFrame} owns the only per-entry-point + * text, and that text never repeats repo values. + * + * The `/hack-init` agent-side skill and the copy-paste bootstrap block in + * `docs/guides/agent-first-setup.md` are thin pointers to these entry points. */ export type OnboardingMode = "new-project" | "existing-project"; @@ -42,43 +63,178 @@ const DEPS_CONTAINER_SNIPPET = [ " node_modules:", ].join("\n"); +/** Why no pre-computed inventory is in the prompt. Closed set; value-free. */ +export type OnboardingInventoryGap = "no-repo-root"; + +/** The repo inventory every entry point embeds, or the closed reason it is absent. */ +export type OnboardingInventory = + | { readonly status: "collected"; readonly report: InitAssistantReport } + | { readonly status: "not-run"; readonly reason: OnboardingInventoryGap }; + +/** Everything the core prompt depends on. Nothing else may vary it. */ +export type OnboardingContext = { + readonly mode: OnboardingMode; + readonly projectName?: string; + readonly devHost?: string; + readonly inventory: OnboardingInventory; +}; + /** - * Render the canonical project-onboarding prompt for a coding agent. + * The entry point that asked for the prompt. It selects the framing only; + * it never changes the core. * - * The prompt walks an agent through inventorying a repo, standing hack up - * (or resuming/adopting an existing `.hack/` setup), platform nuances - * (locked deps installs, cache labels, ops containers), the running-things - * decision guide, and a verification loop that includes data persistence. - * Ground rules forbid injecting agent instruction files and require the - * agent to report discovery limits (missing client, non-interactive run, - * no-shell MCP prompt). + * `init-with.scaffold` says whether this `hack init` run wrote `.hack/` + * (`created`) or found it already present and skipped init (`existing`). + */ +export type OnboardingEntryPoint = + | { readonly kind: "init-with"; readonly scaffold: "created" | "existing" } + | { readonly kind: "agent-onboard" } + | { readonly kind: "agent-init" } + | { readonly kind: "mcp-hack-init" }; + +/** Per-entry-point text placed around the core. */ +export type OnboardingFrame = { + readonly before: string; + readonly after: string; +}; + +const INVENTORY_GAP_TEXT: Readonly> = { + "no-repo-root": "this `hack-init` MCP prompt request named no `repoRoot`", +}; + +/** + * Inventory the repo at or above `startDir` and derive the onboarding context. * - * @param opts.mode - `new-project` when `.hack/` does not exist yet; - * `existing-project` when hack is already configured and should be - * adopted/refined. - * @param opts.projectName - Known project slug (from config), when available. - * @param opts.devHost - Known dev host (from config), when available. + * The repo root is the enclosing hack project root when one exists, otherwise + * the `hack init` repo root. Mode follows repo state alone: `existing-project` + * when a `.hack/` project is found (project name and dev host then come from + * its config), `new-project` otherwise. Errors from project discovery (for + * example a native project marker) propagate unchanged. */ -export function renderOnboardingPrompt(opts: { +export async function collectOnboardingContext(opts: { + readonly startDir: string; +}): Promise { + const project = await findProjectContext(opts.startDir); + const repoRoot = project + ? project.projectRoot + : await findRepoRootForInit(opts.startDir); + const report = await buildInitAssistantReport({ repoRoot }); + const inventory: OnboardingInventory = { status: "collected", report }; + if (!report.projectContext) { + return { mode: "new-project", inventory }; + } + + const config = await readProjectConfig(report.projectContext); + const devHost = await readProjectDevHost(report.projectContext); + return { + mode: "existing-project", + ...(config.name ? { projectName: config.name } : {}), + ...(devHost ? { devHost } : {}), + inventory, + }; +} + +/** + * Context for a request that names no repo (the `hack-init` MCP prompt + * without `repoRoot`). The core then states that no inventory was run. + */ +export function onboardingContextWithoutInventory(opts: { readonly mode: OnboardingMode; readonly projectName?: string; readonly devHost?: string; + readonly reason: OnboardingInventoryGap; +}): OnboardingContext { + return { + mode: opts.mode, + ...(opts.projectName ? { projectName: opts.projectName } : {}), + ...(opts.devHost ? { devHost: opts.devHost } : {}), + inventory: { status: "not-run", reason: opts.reason }, + }; +} + +/** + * Collect the repo inventory and render the framed prompt for one entry point. + * This is the builder every CLI entry point calls. + */ +export async function buildOnboardingPrompt(opts: { + readonly startDir: string; + readonly entryPoint: OnboardingEntryPoint; +}): Promise { + const context = await collectOnboardingContext({ startDir: opts.startDir }); + return renderOnboardingPrompt({ context, entryPoint: opts.entryPoint }); +} + +/** Render `frame.before + core + frame.after` for one entry point. */ +export function renderOnboardingPrompt(opts: { + readonly context: OnboardingContext; + readonly entryPoint: OnboardingEntryPoint; +}): string { + const frame = renderOnboardingFrame({ entryPoint: opts.entryPoint }); + return `${frame.before}${renderOnboardingCore({ context: opts.context })}${frame.after}`; +} + +/** + * The only per-entry-point text. It is fixed per entry point and never + * repeats repo values, so it cannot carry inventory drift. + */ +export function renderOnboardingFrame(opts: { + readonly entryPoint: OnboardingEntryPoint; +}): OnboardingFrame { + const entryPoint = opts.entryPoint; + switch (entryPoint.kind) { + case "init-with": + return { + before: + entryPoint.scaffold === "created" + ? "Handoff from `hack init --with`: this run scaffolded `.hack/`. Treat the generated config as a starting point and refine it instead of re-running `hack init`.\n\n" + : "Handoff from `hack init --with`: `.hack/` already existed, so init was skipped and nothing was scaffolded.\n\n", + after: "", + }; + case "agent-init": + return { + before: "", + after: "Need dependency/ops patterns? Run `hack agent patterns`.\n", + }; + case "agent-onboard": + case "mcp-hack-init": + return { before: "", after: "" }; + default: + return assertNeverEntryPoint(entryPoint); + } +} + +/** + * Render the canonical, entry-point-independent onboarding prompt. + * + * Discovery first: the pre-computed repo inventory (or the closed reason it + * is absent) precedes the phases, which walk an agent through confirming the + * inventory, standing hack up (or resuming/adopting an existing `.hack/` + * setup), platform nuances (locked deps installs, cache labels, ops + * containers), the running-things decision guide, and a verification loop + * that includes data persistence. Ground rules forbid injecting agent + * instruction files and require the agent to report discovery limits. + */ +export function renderOnboardingCore(opts: { + readonly context: OnboardingContext; }): string { + const { context } = opts; const lines: string[] = []; - lines.push(renderHeader(opts)); + lines.push(renderHeader(context)); lines.push(""); - lines.push(...renderIntro(opts)); + lines.push(...renderIntro(context)); + lines.push(""); + lines.push(...renderInventorySection(context.inventory)); lines.push(""); lines.push(...renderInventoryPhase()); lines.push(""); - lines.push(...renderSetupPhase(opts)); + lines.push(...renderSetupPhase(context)); lines.push(""); lines.push(...renderPlatformPhase()); lines.push(""); lines.push(...renderRunningThingsPhase()); lines.push(""); - lines.push(...renderVerifyPhase(opts)); + lines.push(...renderVerifyPhase(context)); lines.push(""); lines.push(...renderGroundRules()); lines.push(""); @@ -86,6 +242,26 @@ export function renderOnboardingPrompt(opts: { return lines.join("\n"); } +function assertNeverEntryPoint(value: never): never { + throw new Error(`Unknown onboarding entry point: ${String(value)}`); +} + +function renderInventorySection(inventory: OnboardingInventory): string[] { + const heading = ["## Repo inventory (pre-computed by Hack)", ""]; + if (inventory.status === "not-run") { + return [ + ...heading, + `- Not run: ${INVENTORY_GAP_TEXT[inventory.reason]}. Inventory the repo yourself in Phase 1 and report that no pre-computed inventory was available.`, + ]; + } + return [ + ...heading, + "Hack read file names and manifest keys to build this inventory. Confirm and extend it in Phase 1; an absent signal is not an absent requirement.", + "", + ...renderInitAssistantInventory({ report: inventory.report }), + ]; +} + function renderHeader(opts: { readonly mode: OnboardingMode; readonly projectName?: string; @@ -114,6 +290,7 @@ function renderInventoryPhase(): string[] { return [ "## Phase 1 — Inventory the repo", "", + "- Start from the pre-computed inventory above: confirm each detected signal, check every listed discovery limitation yourself, and report the ones that remain. When no inventory was run, do the full inventory below.", "- Read the root package.json (including its `packageManager` and `engines` keys), workspace globs, and lockfiles (`bun.lock` or legacy `bun.lockb`, `pnpm-lock.yaml`, `yarn.lock`, `package-lock.json`) at the root and in each workspace package to identify the package manager and monorepo layout. Lockfiles from more than one package manager, or a nested lockfile inside a workspace package, are a conflict to resolve with the human — do not delete or regenerate a lockfile to pick one.", "- Note non-JS manifests and lockfiles (Cargo.toml/Cargo.lock, go.mod, pyproject.toml/uv.lock/poetry.lock/requirements.txt, Gemfile.lock, mix.exs/mix.lock, *.csproj/*.sln) and runtime pins (`.nvmrc`, `.tool-versions`, `mise.toml`, `engines`, `packageManager`) by name. Find those services' entrypoints, ports, and setup steps from their build files and docs; pinned versions decide the container images and tools you use.", "- Identify runnable services (web, api, workers): dev/start scripts, framework configs, and the ports they listen on.", @@ -128,7 +305,11 @@ function renderSetupPhase(opts: { readonly mode: OnboardingMode; readonly devHost?: string; }): string[] { - const devHostExample = opts.devHost ?? ".hack.local"; + // An existing setup without a configured dev_host must not be described + // with the new-project default host. + const devHostExample = + opts.devHost ?? + (opts.mode === "new-project" ? ".hack.local" : ""); const bootstrap = opts.mode === "new-project" ? "- Scaffold config: run `hack init --auto` (add `--name ` / `--dev-host ` when the defaults are wrong). If `.hack/` already exists, edit the config instead of re-running init." @@ -199,7 +380,7 @@ function renderGroundRules(): string[] { "- Treat `.hack/.internal/**` and `.hack/.branch/**` as generated artifacts — never hand-edit them.", "- Inspect unexpected state with `hack doctor` and apply only authorized repairs. Preserve runtime ownership, native approval, and destructive-cleanup gates.", "- Never create or modify AGENTS.md, CLAUDE.md, editor or agent rule files, or repository copies of agent skills unless the user asks. Onboarding configures the runtime; it does not install agent instructions.", - "- Report discovery limits you hit: the agent client CLI was missing (Hack printed this prompt instead of launching a session), a non-interactive run (nothing was spawned or prompted), or the no-shell `hack-init` MCP prompt (defaults to existing-project mode with no repo inventory — confirm whether `.hack/` exists and inventory the repo yourself).", + "- Report discovery limits you hit: the agent client CLI was missing (Hack printed this prompt instead of launching a session), a non-interactive run (nothing was spawned or prompted), the inventory's remaining discovery limitations, or a `hack-init` MCP prompt request without `repoRoot` (it defaults to existing-project mode with no repo inventory — confirm whether `.hack/` exists and inventory the repo yourself).", "- Summarize what you configured (services, hostnames, env keys added, remaining gaps) when you finish.", ]; } diff --git a/src/commands/agent.ts b/src/commands/agent.ts index be9e465bc..780a6a938 100644 --- a/src/commands/agent.ts +++ b/src/commands/agent.ts @@ -2,12 +2,8 @@ import { resolve } from "node:path"; import { isCancel, select } from "@clack/prompts"; -import { - buildInitAssistantReport, - renderInitAssistantPrompt, -} from "../agents/init-assistant.ts"; import { renderAgentInitPatterns } from "../agents/init-patterns.ts"; -import { renderOnboardingPrompt } from "../agents/onboarding-prompt.ts"; +import { buildOnboardingPrompt } from "../agents/onboarding-prompt.ts"; import { renderAgentPrimer } from "../agents/primer.ts"; import type { CliContext, CommandArgs } from "../cli/command.ts"; import { @@ -22,12 +18,7 @@ import { } from "../cli/integration-sync.ts"; import { optPath } from "../cli/options.ts"; import { openUrl } from "../lib/os.ts"; -import { - findProjectContext, - findRepoRootForInit, - readProjectConfig, - readProjectDevHost, -} from "../lib/project.ts"; +import { findProjectContext, findRepoRootForInit } from "../lib/project.ts"; import { findExecutableInPath, run } from "../lib/shell.ts"; import { logger } from "../ui/logger.ts"; @@ -149,8 +140,10 @@ async function handleAgentInit({ }): Promise { const startDir = resolveStartDir(ctx, args.options.path); const repoRoot = await findRepoRootForInit(startDir); - const report = await buildInitAssistantReport({ repoRoot }); - const prompt = renderInitAssistantPrompt({ report }); + const prompt = await buildOnboardingPrompt({ + startDir, + entryPoint: { kind: "agent-init" }, + }); const selection = await resolveInitClient({ clientRaw: args.options.client, @@ -179,10 +172,10 @@ async function handleAgentInit({ /** * `hack agent onboard` — print the canonical onboarding prompt. * - * Uses `existing-project` mode (with project name/dev_host from config) when - * a `.hack/` project is found; falls back to `new-project` mode otherwise. - * The same content backs `hack init --with`, the `/hack-init` skill, and the - * `hack-init` MCP prompt. + * The shared builder inventories the repo and picks `existing-project` mode + * (with project name/dev_host from config) when a `.hack/` project is found, + * `new-project` mode otherwise. `hack init --with`, `hack agent init`, and the + * `hack-init` MCP prompt render the same core content. */ async function handleAgentOnboard({ ctx, @@ -192,21 +185,10 @@ async function handleAgentOnboard({ readonly args: OnboardArgs; }): Promise { const startDir = resolveStartDir(ctx, args.options.path); - const projectContext = await findProjectContext(startDir); - - if (!projectContext) { - process.stdout.write(renderOnboardingPrompt({ mode: "new-project" })); - return 0; - } - - const config = await readProjectConfig(projectContext); - const devHost = await readProjectDevHost(projectContext); - process.stdout.write( - renderOnboardingPrompt({ - mode: "existing-project", - projectName: config.name, - devHost: devHost ?? undefined, + await buildOnboardingPrompt({ + startDir, + entryPoint: { kind: "agent-onboard" }, }) ); return 0; diff --git a/src/commands/project.ts b/src/commands/project.ts index 1fe605145..2900fd414 100644 --- a/src/commands/project.ts +++ b/src/commands/project.ts @@ -27,16 +27,16 @@ import { text, } from "@clack/prompts"; import { YAML } from "bun"; -import { runInitAgentIntegrations } from "../agents/init-agent-integrations.ts"; +import { + type InitAgentIntegrationPrompts, + runInitAgentIntegrations, +} from "../agents/init-agent-integrations.ts"; import { type OnboardingWith, parseOnboardingWith, runOnboardingHandoff, } from "../agents/onboarding-handoff.ts"; -import { - type OnboardingMode, - renderOnboardingPrompt, -} from "../agents/onboarding-prompt.ts"; +import { buildOnboardingPrompt } from "../agents/onboarding-prompt.ts"; import { composeLogBackend, lokiLogBackend } from "../backends/log-backend.ts"; import { parseNativePreparedBase } from "../backends/native-prepared-base.ts"; import { @@ -2777,13 +2777,51 @@ function validateInitProjectSlug( return undefined; } +/** + * Terminal seams for interactive `hack init`: the TTY check and every prompt + * the interactive path asks. Production uses {@link CLACK_INIT_IO}; tests + * inject scripted answers so the path, including the `--with` handoff, runs + * without a terminal. + */ +export type InitInteractiveIo = { + readonly canPrompt: () => boolean; + readonly text: (opts: { + readonly message: string; + readonly initialValue: string; + readonly validate: (value: string | undefined) => string | undefined; + }) => Promise; + readonly confirm: (opts: { + readonly message: string; + readonly initialValue: boolean; + }) => Promise; + /** + * Service wizard that renders `.hack/docker-compose.yml`: the discovered + * wizard when `useDiscovery`, otherwise the manual one. + */ + readonly composeWizard: (opts: { + readonly useDiscovery: boolean; + readonly input: ComposeWizardInput; + }) => Promise; + /** Coding-agent step prompts; omitted means the clack prompts. */ + readonly agentPrompts?: InitAgentIntegrationPrompts; +}; + +const CLACK_INIT_IO: InitInteractiveIo = { + canPrompt, + text: (opts) => text({ ...opts }), + confirm: (opts) => confirm({ ...opts }), + composeWizard: ({ useDiscovery, input }) => + useDiscovery ? buildDiscoveredCompose(input) : buildManualCompose(input), +}; + async function promptInitProjectSlug(opts: { + readonly io: InitInteractiveIo; readonly repoRoot: string; readonly nameOption: string | undefined; }): Promise { const defaultSlug = defaultProjectSlugFromPath(opts.repoRoot); const initialSlug = sanitizeProjectSlug(opts.nameOption ?? defaultSlug); - const name = await text({ + const name = await opts.io.text({ message: "Project name (slug):", initialValue: initialSlug, validate: validateInitProjectSlug, @@ -2846,13 +2884,14 @@ function validateInitDevHost(value: string | undefined): string | undefined { } async function promptInitDevHost(opts: { + readonly io: InitInteractiveIo; readonly slug: string; readonly devHostOption: string | undefined; readonly defaultDomain: string; }): Promise { const defaultHost = `${opts.slug}.${opts.defaultDomain}`; const initialHost = (opts.devHostOption ?? defaultHost).trim(); - const devHost = await text({ + const devHost = await opts.io.text({ message: "DEV_HOST:", initialValue: initialHost, validate: validateInitDevHost, @@ -2863,16 +2902,6 @@ async function promptInitDevHost(opts: { return devHost.trim(); } -/** A skipped init must describe the existing setup, not the new-project default. */ -async function resolveExistingInitHost(repoRoot: string): Promise { - const project = await findProjectContext(repoRoot); - const configured = project ? await readProjectDevHost(project) : null; - return ( - configured ?? - `${defaultProjectSlugFromPath(repoRoot)}.${DEFAULT_PROJECT_TLD}` - ); -} - function validateInitOauthTld(value: string | undefined): string | undefined { const v = value?.trim().toLowerCase(); if (!v) { @@ -2885,10 +2914,11 @@ function validateInitOauthTld(value: string | undefined): string | undefined { } async function promptInitOauthSettings(opts: { + readonly io: InitInteractiveIo; readonly oauthEnabledDefault: boolean; readonly oauthTldOption: string | undefined; }): Promise<{ readonly enabled: boolean; readonly tld: string } | null> { - const enableOauthHost = await confirm({ + const enableOauthHost = await opts.io.confirm({ message: `Enable OAuth-safe alias host (https://.${DEFAULT_PROJECT_TLD}.${DEFAULT_OAUTH_ALIAS_TLD})?`, initialValue: opts.oauthEnabledDefault, }); @@ -2900,7 +2930,7 @@ async function promptInitOauthSettings(opts: { return { enabled: false, tld: DEFAULT_OAUTH_ALIAS_TLD }; } - const oauthTld = await text({ + const oauthTld = await opts.io.text({ message: "OAuth alias TLD (optional):", initialValue: opts.oauthTldOption ?? DEFAULT_OAUTH_ALIAS_TLD, validate: validateInitOauthTld, @@ -2931,6 +2961,7 @@ function renderInitDiscoveryNote(opts: { } async function promptInitUseDiscovery(opts: { + readonly io: InitInteractiveIo; readonly canDiscover: boolean; readonly forceManual: boolean; }): Promise { @@ -2938,7 +2969,7 @@ async function promptInitUseDiscovery(opts: { return false; } - const useDiscovery = await confirm({ + const useDiscovery = await opts.io.confirm({ message: "Auto-discover dev scripts and generate services?", initialValue: true, }); @@ -2949,11 +2980,12 @@ async function promptInitUseDiscovery(opts: { } async function ensureInitHackDir(opts: { + readonly io: InitInteractiveIo; readonly hackDir: string; }): Promise<"proceed" | "skip" | null> { await assertLegacyProjectInputFamily({ projectRoot: dirname(opts.hackDir) }); if (await pathExists(opts.hackDir)) { - const ok = await confirm({ + const ok = await opts.io.confirm({ message: `${HACK_PROJECT_DIR_PRIMARY}/ already exists. Overwrite scaffold files?`, initialValue: false, }); @@ -2972,12 +3004,18 @@ async function ensureInitHackDir(opts: { return "proceed"; } -async function handleInit({ +/** + * `hack init`. `io` is the interactive path's terminal seam; the command + * handler omits it and gets the real TTY check and clack prompts. + */ +export async function handleInit({ ctx, args, + io = CLACK_INIT_IO, }: { readonly ctx: CliContext; readonly args: InitArgs; + readonly io?: InitInteractiveIo; }): Promise { const withValue = resolveInitWithOption({ withRaw: args.options.with }); const startDir = resolveStartDir(ctx, args.options.path); @@ -2990,7 +3028,7 @@ async function handleInit({ const repoRoot = await findRepoRootForInit(startDir); await assertLegacyProjectInputFamily({ projectRoot: repoRoot }); - if (!canPrompt()) { + if (!io.canPrompt()) { requireInteractive({ what: "hack init asks for project name, dev host, and discovery choices", hint: "Use `hack init --auto` (optionally with --name, --dev-host, --oauth, --oauth-tld, --manual) for scripted setup.", @@ -2998,6 +3036,7 @@ async function handleInit({ } const slug = await promptInitProjectSlug({ + io, repoRoot, nameOption: args.options.name, }); @@ -3008,6 +3047,7 @@ async function handleInit({ await ensureInitProjectSlugUnique({ repoRoot, slug }); const devHost = await promptInitDevHost({ + io, slug, devHostOption: args.options.devHost, defaultDomain: args.options.devHost @@ -3019,6 +3059,7 @@ async function handleInit({ } const oauth = await promptInitOauthSettings({ + io, oauthEnabledDefault: args.options.oauth === true || Boolean(args.options.oauthTld), oauthTldOption: args.options.oauthTld, @@ -3037,6 +3078,7 @@ async function handleInit({ } const useDiscovery = await promptInitUseDiscovery({ + io, canDiscover, forceManual, }); @@ -3048,7 +3090,7 @@ async function handleInit({ const composeFile = resolve(hackDir, PROJECT_COMPOSE_FILENAME); const configFile = resolve(hackDir, PROJECT_CONFIG_FILENAME); - const hackDirAction = await ensureInitHackDir({ hackDir }); + const hackDirAction = await ensureInitHackDir({ io, hackDir }); if (!hackDirAction) { return 1; } @@ -3061,29 +3103,24 @@ async function handleInit({ await runInitOnboardingHandoff({ repoRoot, withValue, - mode: "existing-project", - projectName: slug, - devHost: await resolveExistingInitHost(repoRoot), + scaffold: "existing", + interactive: io.canPrompt(), }); } return 0; } await assertLegacyProjectInputFamily({ projectRoot: repoRoot }); - const compose = useDiscovery - ? await buildDiscoveredCompose({ - repoRoot, - devHost, - projectSlug: slug, - candidates: discovery.candidates, - oauth: { enabled: oauth.enabled, tld: oauth.tld }, - }) - : await buildManualCompose({ - repoRoot, - devHost, - projectSlug: slug, - oauth: { enabled: oauth.enabled, tld: oauth.tld }, - }); + const compose = await io.composeWizard({ + useDiscovery, + input: { + repoRoot, + devHost, + projectSlug: slug, + candidates: discovery.candidates, + oauth: { enabled: oauth.enabled, tld: oauth.tld }, + }, + }); await assertLegacyProjectInputFamily({ projectRoot: repoRoot }); await ensureDir(hackDir); @@ -3145,8 +3182,9 @@ async function handleInit({ await runInitAgentIntegrations({ repoRoot, - interactive: canPrompt(), + interactive: io.canPrompt(), guard: assertLegacyProjectInputFamily, + ...(io.agentPrompts ? { prompts: io.agentPrompts } : {}), }); note( @@ -3169,9 +3207,8 @@ async function handleInit({ await runInitOnboardingHandoff({ repoRoot, withValue, - mode: "new-project", - projectName: slug, - devHost, + scaffold: "created", + interactive: io.canPrompt(), }); } @@ -3234,9 +3271,7 @@ async function handleInitAuto({ await runInitOnboardingHandoff({ repoRoot, withValue, - mode: "existing-project", - projectName: slug, - devHost: await resolveExistingInitHost(repoRoot), + scaffold: "existing", }); return 0; } @@ -3332,9 +3367,7 @@ async function handleInitAuto({ await runInitOnboardingHandoff({ repoRoot, withValue, - mode: "new-project", - projectName: slug, - devHost, + scaffold: "created", }); } @@ -3373,28 +3406,34 @@ function resolveInitWithOption(opts: { /** * Hand the onboarding prompt to the requested agent CLI(s) after init. * + * The prompt comes from the shared onboarding builder, which inventories the + * repo and reads mode, project name and dev host from the `.hack/` config, so + * the core matches `hack agent onboard`, `hack agent init` and the `hack-init` + * MCP prompt for the same repo. `scaffold` only selects the handoff framing. + * * Never spawns on non-interactive runs (`--no-interactive`, * `HACK_NO_INTERACTIVE`, or no TTY) — the prompt is printed for copy-paste * instead. Init success is never rolled back by handoff problems. + * + * @param opts.interactive - Defaults to the real TTY check; interactive init + * passes its injected {@link InitInteractiveIo.canPrompt} result. */ async function runInitOnboardingHandoff(opts: { readonly repoRoot: string; readonly withValue: OnboardingWith; - readonly mode: OnboardingMode; - readonly projectName: string; - readonly devHost: string; + readonly scaffold: "created" | "existing"; + readonly interactive?: boolean; }): Promise { await assertLegacyProjectInputFamily({ projectRoot: opts.repoRoot }); - const prompt = renderOnboardingPrompt({ - mode: opts.mode, - projectName: opts.projectName, - devHost: opts.devHost, + const prompt = await buildOnboardingPrompt({ + startDir: opts.repoRoot, + entryPoint: { kind: "init-with", scaffold: opts.scaffold }, }); await runOnboardingHandoff({ prompt, withValue: opts.withValue, - interactive: canPrompt(), + interactive: opts.interactive ?? canPrompt(), }); } diff --git a/src/mcp/server.ts b/src/mcp/server.ts index cc6c00ce5..2e6727362 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -7,6 +7,7 @@ import { CallToolRequestSchema, type CallToolResult, ErrorCode, + McpError, type ServerNotification, type ServerRequest, type ToolAnnotations, @@ -14,10 +15,12 @@ import { import * as z from "zod/v4"; import pkg from "../../package.json"; import { + buildOnboardingPrompt, type OnboardingMode, + onboardingContextWithoutInventory, renderOnboardingPrompt, } from "../agents/onboarding-prompt.ts"; -import type { HackErrorCode } from "../lib/cli-result.ts"; +import { HackCliError, type HackErrorCode } from "../lib/cli-result.ts"; import { ensureDir, pathExists } from "../lib/fs.ts"; import { isRecord } from "../lib/guards.ts"; import { resolveHackInvocation } from "../lib/hack-cli.ts"; @@ -310,7 +313,7 @@ export function createMcpServer(opts: { ); registerTools({ server, context }); - registerPrompts({ server }); + registerPrompts({ server, context }); return server; } @@ -350,43 +353,71 @@ const ONBOARDING_MODES: ReadonlySet = new Set([ "existing-project", ]); +const MCP_ONBOARDING_ENTRY_POINT = { kind: "mcp-hack-init" } as const; + /** * Register MCP prompts for no-shell clients. * - * `hack-init` returns the canonical project-onboarding prompt (same content - * as `hack agent onboard`, `hack init --with`, and the `/hack-init` skill). + * `hack-init` returns the canonical project-onboarding prompt from the shared + * builder (same core as `hack agent onboard`, `hack agent init`, and + * `hack init --with`). With `repoRoot` it inventories that repo, and mode, + * project name and dev host come from the repo; without it no filesystem + * scan runs (the server's working directory may be a home or root + * directory) and the core states that no inventory was run. */ -function registerPrompts(opts: { readonly server: McpServer }): void { +function registerPrompts(opts: { + readonly server: McpServer; + readonly context: McpSessionContext; +}): void { opts.server.registerPrompt( "hack-init", { title: "hack project onboarding", description: - "Agent-assisted onboarding prompt: stand up or adopt hack in a project (inventory, compose + env setup, deps/ops container patterns, verification loop).", + "Agent-assisted onboarding prompt: stand up or adopt hack in a project (repo inventory, compose + env setup, deps/ops container patterns, verification loop).", argsSchema: { + repoRoot: z + .string() + .describe( + "Repository root (or a directory inside it) to inventory; relative paths resolve against the server's working directory. When set, mode, projectName and devHost come from the repo and those arguments are ignored." + ) + .optional(), mode: z .string() .describe( - 'Onboarding mode: "new-project" (no .hack/ yet) or "existing-project" (adopt/refine an existing setup). Default: existing-project.' + 'Onboarding mode without repoRoot: "new-project" (no .hack/ yet) or "existing-project" (adopt/refine an existing setup). Default: existing-project.' ) .optional(), - projectName: z.string().describe("Project slug, when known").optional(), + projectName: z + .string() + .describe("Project slug, when known (without repoRoot)") + .optional(), devHost: z .string() - .describe("Project dev host (e.g. myapp.hack), when known") + .describe( + "Project dev host (e.g. myapp.hack.local), when known (without repoRoot)" + ) .optional(), }, }, - ({ mode, projectName, devHost }) => { - const resolvedMode: OnboardingMode = ONBOARDING_MODES.has(mode ?? "") - ? ((mode ?? "existing-project") as OnboardingMode) - : "existing-project"; - - const prompt = renderOnboardingPrompt({ - mode: resolvedMode, - projectName, - devHost, - }); + async ({ repoRoot, mode, projectName, devHost }) => { + // Trim once so the presence check and the resolved path agree. + const requestedRoot = repoRoot?.trim(); + const prompt = requestedRoot + ? await buildMcpOnboardingPrompt({ + startDir: resolve(opts.context.cwd, requestedRoot), + }) + : renderOnboardingPrompt({ + context: onboardingContextWithoutInventory({ + mode: ONBOARDING_MODES.has(mode ?? "") + ? (mode as OnboardingMode) + : "existing-project", + ...(projectName ? { projectName } : {}), + ...(devHost ? { devHost } : {}), + reason: "no-repo-root", + }), + entryPoint: MCP_ONBOARDING_ENTRY_POINT, + }); return { messages: [ @@ -400,6 +431,44 @@ function registerPrompts(opts: { readonly server: McpServer }): void { ); } +/** + * Build the `hack-init` prompt for a caller-named repo. Failures map to a + * closed, value-free protocol error: only the Hack error code survives; the + * caller's path, config values and the underlying message are never echoed. + * + * @param opts.build - Test seam for the shared builder; production omits it. + */ +export async function buildMcpOnboardingPrompt(opts: { + readonly startDir: string; + readonly build?: typeof buildOnboardingPrompt; +}): Promise { + if (!(await pathExists(opts.startDir))) { + const refusal = MCP_REFUSALS.path_not_found; + throw new McpError( + ErrorCode.InvalidParams, + `${refusal.code}: ${refusal.message}` + ); + } + const request = { + startDir: opts.startDir, + entryPoint: MCP_ONBOARDING_ENTRY_POINT, + }; + try { + return opts.build + ? await opts.build(request) + : await buildOnboardingPrompt(request); + } catch (error) { + const code: HackErrorCode = + error instanceof HackCliError ? error.code : "E_UNEXPECTED"; + throw new McpError( + code === "E_UNEXPECTED" + ? ErrorCode.InternalError + : ErrorCode.InvalidParams, + `${code}: hack-init could not inventory the repo.` + ); + } +} + function registerTools(opts: { readonly server: McpServer; readonly context: McpSessionContext; diff --git a/tests/hack-init-skill.test.ts b/tests/hack-init-skill.test.ts index 10173d550..b11a5c7d2 100644 --- a/tests/hack-init-skill.test.ts +++ b/tests/hack-init-skill.test.ts @@ -77,6 +77,10 @@ test("renderHackInitSkill is a thin pointer to the CLI prompt source", () => { "only if the `hack-mcp` plugin or another Hack MCP server is installed" ); expect(skill).not.toContain("MCP prompt from the hack MCP server."); + // Without repoRoot the MCP prompt runs no inventory, so the skill asks for it. + expect(skill).toContain( + "fetch the `hack-init` MCP prompt from it and pass `repoRoot`" + ); // Thin: the full onboarding content must NOT be duplicated into the skill. expect(skill).not.toContain("node_modules:/app/node_modules"); expect(skill).not.toContain("## Phase 1"); diff --git a/tests/helpers/interactive-init-runner.ts b/tests/helpers/interactive-init-runner.ts new file mode 100644 index 000000000..1d6d61fad --- /dev/null +++ b/tests/helpers/interactive-init-runner.ts @@ -0,0 +1,62 @@ +import { CLI_SPEC } from "../../src/cli/spec.ts"; +import { + handleInit, + type InitInteractiveIo, +} from "../../src/commands/project.ts"; + +/** + * Child-process entry for the interactive `hack init` path with scripted + * terminal seams. The TTY check passes and every prompt takes its default: + * a fresh repo gets `.hack/` scaffolded (with a fixed, service-free compose + * in place of the service wizard), and an existing `.hack/` is kept because + * the overwrite confirm defaults to "no". No agent files are exported. Spawn it with `privateCliEnv` and a PATH without agent CLIs, so + * the interactive handoff prints its copy-paste fallback instead of launching. + * + * Usage: bun tests/helpers/interactive-init-runner.ts + */ + +const [repo, withValue] = process.argv.slice(2); +if (!(repo && withValue)) { + throw new Error("usage: interactive-init-runner.ts "); +} + +const io: InitInteractiveIo = { + canPrompt: () => true, + text: async ({ initialValue }) => initialValue, + confirm: async ({ initialValue }) => initialValue, + // The service wizard is out of scope here; a fixed compose keeps it inert. + composeWizard: async ({ input }) => + `name: ${input.projectSlug}\nservices: {}\n`, + agentPrompts: { + note: () => undefined, + confirm: async ({ initialValue }) => initialValue, + multiselect: async () => [], + }, +}; + +type InitInput = Parameters[0]; + +const args = { + options: { + path: repo, + manual: false, + auto: false, + name: undefined, + devHost: undefined, + oauth: false, + oauthTld: undefined, + noDiscovery: false, + with: withValue, + }, + positionals: {}, + raw: { + argv: ["init", "--path", repo, "--with", withValue], + positionals: [], + }, +} as unknown as InitInput["args"]; + +process.exitCode = await handleInit({ + ctx: { cwd: repo, cli: CLI_SPEC }, + args, + io, +}); diff --git a/tests/init-assistant.test.ts b/tests/init-assistant.test.ts index 33d74c87f..640a80b46 100644 --- a/tests/init-assistant.test.ts +++ b/tests/init-assistant.test.ts @@ -6,8 +6,8 @@ import { dirname, join } from "node:path"; import { buildInitAssistantReport, type InitAssistantReport, - renderInitAssistantPrompt, } from "../src/agents/init-assistant.ts"; +import { buildOnboardingPrompt } from "../src/agents/onboarding-prompt.ts"; const ALWAYS_PRESENT_LIMITATION = "Inventory reads file names and manifest keys only; env files, secrets, lockfile contents, and pin values were not read."; @@ -41,7 +41,11 @@ async function inventory(files: Record): Promise<{ }> { const repoRoot = await createRepo(files); const report = await buildInitAssistantReport({ repoRoot }); - return { report, prompt: renderInitAssistantPrompt({ report }) }; + const prompt = await buildOnboardingPrompt({ + startDir: repoRoot, + entryPoint: { kind: "agent-init" }, + }); + return { report, prompt }; } test("bun.lock-only repo is detected as a Bun repo without conflicts", async () => { diff --git a/tests/init-with-command.test.ts b/tests/init-with-command.test.ts index c9b321ef5..5053cd2bd 100644 --- a/tests/init-with-command.test.ts +++ b/tests/init-with-command.test.ts @@ -134,7 +134,13 @@ test("hack init --auto --with claude initializes and prints the prompt when no b expect(result.stdout).toContain("hack onboarding"); expect(result.stdout).toContain("## Phase 1 — Inventory the repo"); expect(result.stdout).toContain("node_modules:/app/node_modules"); - expect(result.stdout).toContain("stand up hack in this repo"); + // The shared builder derives mode from repo state: init just wrote + // `.hack/`, so the core adopts it and only the framing says it is new. + expect(result.stdout).toContain("this run scaffolded `.hack/`"); + expect(result.stdout).toContain("adopt the existing hack setup in this repo"); + expect(result.stdout).toContain("(project: repo)"); + expect(result.stdout).toContain("## Repo inventory (pre-computed by Hack)"); + expect(result.stdout).toContain("- root (demo): dev -> bun run dev.ts"); }); test("hack init --auto --with proceeds to handoff when .hack already exists", async () => { @@ -162,6 +168,9 @@ test("hack init --auto --with proceeds to handoff when .hack already exists", as expect(second.exitCode).toBe(0); expect(`${second.stdout}${second.stderr}`).toContain("already exists"); expect(second.stdout).toContain("adopt the existing hack setup in this repo"); + expect(second.stdout).toContain( + "`.hack/` already existed, so init was skipped" + ); expect(second.stdout).toContain("existing.hack"); expect( (await Bun.file(join(repoRoot, ".hack", "hack.config.json")).json()) diff --git a/tests/onboarding-entry-parity.test.ts b/tests/onboarding-entry-parity.test.ts new file mode 100644 index 000000000..bdb1efa62 --- /dev/null +++ b/tests/onboarding-entry-parity.test.ts @@ -0,0 +1,622 @@ +import { afterEach, expect, test } from "bun:test"; +import { mkdir, mkdtemp, readFile, realpath, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join, relative, resolve } from "node:path"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { ErrorCode, McpError } from "@modelcontextprotocol/sdk/types.js"; + +import { + collectOnboardingContext, + type OnboardingEntryPoint, + onboardingContextWithoutInventory, + renderOnboardingCore, + renderOnboardingFrame, +} from "../src/agents/onboarding-prompt.ts"; +import { HackCliError } from "../src/lib/cli-result.ts"; +import { + buildMcpOnboardingPrompt, + createMcpServer, +} from "../src/mcp/server.ts"; +import { privateCliEnv } from "./helpers/private-cli-env.ts"; + +/** + * HACK-1137: every onboarding entry point (`hack init --with`, + * `hack agent onboard`, `hack agent init`, and the `hack-init` MCP prompt) + * renders `frame.before + core + frame.after`, where the core is byte-identical + * for the same repo. CLI entry points run as source CLI children with a + * private environment; the MCP prompt runs in-process over an in-memory + * transport. No Docker, real HOME, keychain or client config is reached. + */ + +const REPO_ROOT = resolve(import.meta.dir, ".."); +const INTERACTIVE_INIT_RUNNER = resolve( + import.meta.dir, + "helpers", + "interactive-init-runner.ts" +); +const INIT_PROMPT_START = "----- hack onboarding prompt -----\n"; +const INIT_PROMPT_END = "\n----- end prompt -----\n"; + +const roots: string[] = []; +const clients: Client[] = []; + +afterEach(async () => { + for (const client of clients.splice(0)) { + await client.close().catch(() => undefined); + } + for (const root of roots.splice(0)) { + await rm(root, { recursive: true, force: true }); + } +}); + +type Fixture = { + readonly root: string; + readonly repo: string; + readonly home: string; + readonly emptyPath: string; +}; + +/** + * A repo that exercises every corrected inventory signal: `bun.lock` at the + * root, nested workspaces with their own (conflicting) lockfile, a non-JS + * runtime two levels down, runtime pins, env files, compose, and DB signals. + */ +async function createFixture(): Promise { + const root = await realpath( + await mkdtemp(join(tmpdir(), "hack-onboarding-parity-")) + ); + roots.push(root); + const repo = join(root, "repo"); + const files: Record = { + "package.json": JSON.stringify( + { + name: "parity", + private: true, + workspaces: ["apps/*", "packages/*"], + packageManager: "bun@1.3.9", + engines: { node: ">=22" }, + scripts: { "db:migrate": "prisma migrate dev" }, + }, + null, + 2 + ), + "bun.lock": "{}\n", + ".nvmrc": "22\n", + ".env.example": "DATABASE_URL=\n", + "docker-compose.yml": "services:\n db:\n image: postgres:16\n", + "apps/web/package.json": JSON.stringify({ + name: "web", + scripts: { dev: "vite --port 5173" }, + }), + "apps/legacy/package.json": JSON.stringify({ + name: "legacy", + scripts: { start: "node server.js" }, + }), + "apps/legacy/package-lock.json": "{}\n", + "packages/ui/package.json": JSON.stringify({ name: "ui" }), + "services/worker/Cargo.toml": '[package]\nname = "worker"\n', + "prisma/schema.prisma": "datasource db {}\n", + }; + for (const [rel, content] of Object.entries(files)) { + const abs = join(repo, rel); + await mkdir(dirname(abs), { recursive: true }); + await Bun.write(abs, content); + } + const emptyPath = join(root, "empty-path"); + await mkdir(emptyPath, { recursive: true }); + return { root, repo, home: join(root, "home"), emptyPath }; +} + +async function runCli( + fixture: Fixture, + args: readonly string[] +): Promise { + return await runSourceEntry(fixture, ["index.ts", ...args], { + HACK_NO_INTERACTIVE: "1", + }); +} + +/** + * The interactive `hack init` path, run in a child with scripted terminal + * seams (tests/helpers/interactive-init-runner.ts) and a private environment. + */ +async function runInteractiveInit( + fixture: Fixture, + withValue: "claude" | "codex" +): Promise { + return await runSourceEntry(fixture, [ + INTERACTIVE_INIT_RUNNER, + fixture.repo, + withValue, + ]); +} + +async function runSourceEntry( + fixture: Fixture, + argv: readonly string[], + env: Readonly> = {} +): Promise { + const child = Bun.spawn([process.execPath, ...argv], { + cwd: REPO_ROOT, + env: privateCliEnv({ + root: fixture.root, + home: fixture.home, + env: { + ...env, + // No agent client binary can be found or launched. + PATH: fixture.emptyPath, + }, + }), + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + const [exitCode, stdout, stderr] = await Promise.all([ + child.exited, + new Response(child.stdout).text(), + new Response(child.stderr).text(), + ]); + expect(exitCode, stderr).toBe(0); + return stdout; +} + +async function connectMcp(cwd: string): Promise { + const server = createMcpServer({ cwd, env: { HOME: cwd } }); + const client = new Client({ name: "onboarding-parity", version: "0.0.0" }); + clients.push(client); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + await client.connect(clientTransport); + return client; +} + +async function mcpPrompt( + client: Client, + args: Record +): Promise { + const result = await client.getPrompt({ name: "hack-init", arguments: args }); + expect(result.messages).toHaveLength(1); + const content = result.messages[0]?.content; + if (content?.type !== "text") { + throw new Error("Expected text prompt content"); + } + return content.text; +} + +/** The prompt `hack init --with` printed between its fallback markers. */ +function extractInitPrompt(stdout: string): string { + const start = stdout.indexOf(INIT_PROMPT_START); + const end = stdout.lastIndexOf(INIT_PROMPT_END); + if (start < 0 || end < start) { + throw new Error("init --with printed no onboarding prompt"); + } + return stdout.slice(start + INIT_PROMPT_START.length, end); +} + +type EntryOutput = { + readonly entryPoint: OnboardingEntryPoint; + readonly text: string; +}; + +/** + * Return one issue per entry point whose output is not exactly its own frame + * around `core`. An empty list is parity. + */ +function parityIssues(opts: { + readonly core: string; + readonly outputs: Readonly>; +}): string[] { + const issues: string[] = []; + for (const [label, output] of Object.entries(opts.outputs)) { + const frame = renderOnboardingFrame({ entryPoint: output.entryPoint }); + if (!output.text.startsWith(frame.before)) { + issues.push(`${label}: framing prefix differs`); + continue; + } + if (!output.text.endsWith(frame.after)) { + issues.push(`${label}: framing suffix differs`); + continue; + } + const core = output.text.slice( + frame.before.length, + output.text.length - frame.after.length + ); + if (core !== opts.core) { + issues.push(`${label}: core differs from the shared builder`); + } + } + return issues; +} + +async function expectedCore(repo: string): Promise { + return renderOnboardingCore({ + context: await collectOnboardingContext({ startDir: repo }), + }); +} + +test("every entry point renders the same discovery-first core for the same repo", async () => { + const fixture = await createFixture(); + const mcp = await connectMcp(fixture.root); + + // Before init: no `.hack/`, so the shared builder picks new-project mode. + const freshCore = await expectedCore(fixture.repo); + expect(freshCore).toContain("stand up hack in this repo"); + const fresh: Record = { + "agent onboard": { + entryPoint: { kind: "agent-onboard" }, + text: await runCli(fixture, ["agent", "onboard", "--path", fixture.repo]), + }, + "agent init": { + entryPoint: { kind: "agent-init" }, + text: await runCli(fixture, [ + "agent", + "init", + "--path", + fixture.repo, + "--client", + "print", + ]), + }, + "mcp hack-init (absolute repoRoot)": { + entryPoint: { kind: "mcp-hack-init" }, + text: await mcpPrompt(mcp, { repoRoot: fixture.repo }), + }, + "mcp hack-init (relative repoRoot)": { + entryPoint: { kind: "mcp-hack-init" }, + text: await mcpPrompt(mcp, { + repoRoot: relative(fixture.root, fixture.repo), + }), + }, + // The trimmed value is both the presence check and the resolved path. + "mcp hack-init (padded relative repoRoot)": { + entryPoint: { kind: "mcp-hack-init" }, + text: await mcpPrompt(mcp, { + repoRoot: ` ${relative(fixture.root, fixture.repo)}\n`, + }), + }, + }; + expect(parityIssues({ core: freshCore, outputs: fresh })).toEqual([]); + + // `hack init --with` always runs after the scaffold exists. + const created = extractInitPrompt( + await runCli(fixture, [ + "init", + "--auto", + "--with", + "claude", + "--path", + fixture.repo, + ]) + ); + const existing = extractInitPrompt( + await runCli(fixture, [ + "init", + "--auto", + "--with", + "codex", + "--path", + fixture.repo, + ]) + ); + + const adoptedCore = await expectedCore(fixture.repo); + expect(adoptedCore).toContain("adopt the existing hack setup in this repo"); + expect(adoptedCore).not.toBe(freshCore); + const adopted: Record = { + "init --with (scaffolded)": { + entryPoint: { kind: "init-with", scaffold: "created" }, + text: created, + }, + "init --with (existing)": { + entryPoint: { kind: "init-with", scaffold: "existing" }, + text: existing, + }, + "agent onboard": { + entryPoint: { kind: "agent-onboard" }, + text: await runCli(fixture, ["agent", "onboard", "--path", fixture.repo]), + }, + "agent init": { + entryPoint: { kind: "agent-init" }, + text: await runCli(fixture, [ + "agent", + "init", + "--path", + join(fixture.repo, "apps", "web"), + "--client", + "print", + ]), + }, + "mcp hack-init": { + entryPoint: { kind: "mcp-hack-init" }, + text: await mcpPrompt(mcp, { + repoRoot: fixture.repo, + // Ignored with repoRoot: the repo decides mode, name and dev host. + mode: "new-project", + projectName: "caller-supplied", + devHost: "caller.example.test", + }), + }, + }; + expect(parityIssues({ core: adoptedCore, outputs: adopted })).toEqual([]); +}, 60_000); + +test("the shared core carries the corrected inventory and explicit limits", async () => { + const fixture = await createFixture(); + const core = await expectedCore(fixture.repo); + + const inventoryAt = core.indexOf("## Repo inventory (pre-computed by Hack)"); + const phaseOneAt = core.indexOf("## Phase 1 — Inventory the repo"); + expect(inventoryAt).toBeGreaterThan(0); + expect(phaseOneAt).toBeGreaterThan(inventoryAt); + for (const line of [ + `- Root: ${fixture.repo}`, + "- Workspaces: apps/*, packages/*", + "- Package managers: bun.lock", + "- Nested lockfiles: apps/legacy/package-lock.json", + "- Package manager conflicts: bun, npm", + "- Non-JS runtimes: rust (services/worker/Cargo.toml)", + "- Runtime pins: .nvmrc, package.json#engines, package.json#packageManager", + "- Env files: .env.example", + "- apps/web (web): dev -> vite --port 5173", + "Discovery limitations (check these gaps yourself and report the ones that remain):", + "- Inventory reads file names and manifest keys only; env files, secrets, lockfile contents, and pin values were not read.", + ]) { + expect(core).toContain(line); + } + // Name-only inventory: pin values and env-file contents never appear. + expect(core).not.toContain("bun@1.3.9"); + expect(core).not.toContain(">=22"); + expect(core).not.toContain("DATABASE_URL="); +}); + +test("the hack-init MCP prompt without repoRoot states the missing inventory and keeps its framing", async () => { + const fixture = await createFixture(); + const mcp = await connectMcp(fixture.repo); + + const text = await mcpPrompt(mcp, { + mode: "new-project", + projectName: "demo", + }); + expect(text).toBe( + renderOnboardingCore({ + context: onboardingContextWithoutInventory({ + mode: "new-project", + projectName: "demo", + reason: "no-repo-root", + }), + }) + ); + expect(text).toContain( + "- Not run: this `hack-init` MCP prompt request named no `repoRoot`." + ); + // The session cwd is a repo, but without repoRoot nothing is scanned. + expect(text).not.toContain("Repo context:"); + expect(text).not.toContain(fixture.repo); +}); + +test("hack-init MCP refusals are closed and never echo the caller's path", async () => { + const fixture = await createFixture(); + const mcp = await connectMcp(fixture.root); + const missing = join(fixture.root, "missing-secret-path"); + + const error = await mcpPrompt(mcp, { repoRoot: missing }).then( + () => null, + (caught: unknown) => caught + ); + expect(error).toBeInstanceOf(Error); + const message = error instanceof Error ? error.message : ""; + expect(message).toContain("E_PROJECT_NOT_FOUND: Path not found."); + expect(message).not.toContain("missing-secret-path"); + expect(message).not.toContain(fixture.root); + + await Bun.write(join(fixture.repo, ".hack", "hack.project.json"), "{}\n"); + const native = await mcpPrompt(mcp, { repoRoot: fixture.repo }).then( + () => null, + (caught: unknown) => caught + ); + const nativeMessage = native instanceof Error ? native.message : ""; + expect(nativeMessage).toContain( + "E_NATIVE_PROJECT_UNSUPPORTED: hack-init could not inventory the repo." + ); + expect(nativeMessage).not.toContain(fixture.repo); +}); + +test("hack-init MCP inventory failures drop the underlying message, path and values", async () => { + const fixture = await createFixture(); + const leakedPath = join( + fixture.root, + "leaked-secret-dir", + "hack.config.json" + ); + const leakedValue = "DATABASE_URL=postgres://app:hunter2@db.internal/app"; + const cases = [ + { + label: "unexpected error", + error: new Error( + `EACCES: permission denied, open '${leakedPath}' (${leakedValue})` + ), + code: ErrorCode.InternalError, + text: "E_UNEXPECTED: hack-init could not inventory the repo.", + }, + { + label: "Hack error", + error: new HackCliError({ + code: "E_NATIVE_PROJECT_UNSUPPORTED", + message: `Native project at ${leakedPath} uses ${leakedValue}`, + }), + code: ErrorCode.InvalidParams, + text: "E_NATIVE_PROJECT_UNSUPPORTED: hack-init could not inventory the repo.", + }, + ] as const; + + for (const testCase of cases) { + const requests: unknown[] = []; + const caught = await buildMcpOnboardingPrompt({ + startDir: fixture.repo, + build: (request) => { + requests.push(request); + return Promise.reject(testCase.error); + }, + }).then( + () => null, + (error: unknown) => error + ); + expect(requests, testCase.label).toEqual([ + { startDir: fixture.repo, entryPoint: { kind: "mcp-hack-init" } }, + ]); + expect(caught, testCase.label).toBeInstanceOf(McpError); + const error = caught as McpError; + expect(error.code, testCase.label).toBe(testCase.code); + // Exact text: any echo of the underlying message, path or value fails. + expect(error.message, testCase.label).toBe( + `MCP error ${testCase.code}: ${testCase.text}` + ); + expect(error.message).not.toContain("leaked-secret-dir"); + expect(error.message).not.toContain("hunter2"); + expect(error.data, testCase.label).toBeUndefined(); + } +}); + +test("an existing setup without a dev_host keeps the placeholder", async () => { + const fixture = await createFixture(); + await Bun.write( + join(fixture.repo, ".hack", "hack.config.json"), + `${JSON.stringify({ name: "parity" }, null, 2)}\n` + ); + await Bun.write( + join(fixture.repo, ".hack", "docker-compose.yml"), + "services: {}\n" + ); + + const context = await collectOnboardingContext({ startDir: fixture.repo }); + expect(context.mode).toBe("existing-project"); + expect(context.projectName).toBe("parity"); + expect(context.devHost).toBeUndefined(); + + const core = renderOnboardingCore({ context }); + expect(core).toContain("the primary app answers on dev_host ()"); + expect(core).toContain("`api.`"); + // The new-project default must not describe an adopted setup. + expect(core).not.toContain(".hack.local"); +}); + +test("interactive hack init frames the handoff by whether this run scaffolded .hack/", async () => { + const fixture = await createFixture(); + const created = await runInteractiveInit(fixture, "claude"); + const existing = await runInteractiveInit(fixture, "codex"); + + // The scripted TTY seam took the interactive handoff branch, which falls + // back to printing only because no agent CLI is on PATH. + expect(created).toContain("claude not found on PATH"); + expect(existing).toContain("codex not found on PATH"); + + const core = await expectedCore(fixture.repo); + expect(core).toContain("adopt the existing hack setup in this repo"); + const scaffolded = renderOnboardingFrame({ + entryPoint: { kind: "init-with", scaffold: "created" }, + }); + const kept = renderOnboardingFrame({ + entryPoint: { kind: "init-with", scaffold: "existing" }, + }); + expect(scaffolded.before).not.toBe(kept.before); + expect(extractInitPrompt(created)).toBe( + `${scaffolded.before}${core}${scaffolded.after}` + ); + expect(extractInitPrompt(existing)).toBe( + `${kept.before}${core}${kept.after}` + ); +}, 60_000); + +test("negative control: a drifted entry point fails parity", async () => { + const fixture = await createFixture(); + const core = await expectedCore(fixture.repo); + const onboard: OnboardingEntryPoint = { kind: "agent-onboard" }; + const init: OnboardingEntryPoint = { kind: "agent-init" }; + const initFrame = renderOnboardingFrame({ entryPoint: init }); + + // The pre-HACK-1137 shape: an entry point that renders the phases without + // the repo inventory. + const withoutInventory = renderOnboardingCore({ + context: onboardingContextWithoutInventory({ + mode: "new-project", + reason: "no-repo-root", + }), + }); + // One inventory signal dropped in one path only. + const droppedSignal = core.replace( + "- Nested lockfiles: apps/legacy/package-lock.json", + "- Nested lockfiles: none detected" + ); + expect(droppedSignal).not.toBe(core); + + expect( + parityIssues({ + core, + outputs: { + aligned: { entryPoint: onboard, text: core }, + "no inventory": { entryPoint: onboard, text: withoutInventory }, + "dropped signal": { + entryPoint: init, + text: `${initFrame.before}${droppedSignal}${initFrame.after}`, + }, + "frame leaked into core": { entryPoint: onboard, text: `x${core}` }, + "frame missing": { entryPoint: init, text: core }, + }, + }) + ).toEqual([ + "no inventory: core differs from the shared builder", + "dropped signal: core differs from the shared builder", + "frame leaked into core: core differs from the shared builder", + "frame missing: framing suffix differs", + ]); +}); + +test("entry points reach onboarding text only through the shared builder", async () => { + // Static drift guard: only the builder module renders inventory or cores, + // and the MCP prompt alone may render without an inventory. + const allowed: Readonly> = { + "src/agents/onboarding-prompt.ts": [ + "renderInitAssistantInventory", + "renderOnboardingCore", + "onboardingContextWithoutInventory", + "renderOnboardingPrompt", + ], + "src/mcp/server.ts": [ + "onboardingContextWithoutInventory", + "renderOnboardingPrompt", + ], + "src/agents/init-assistant.ts": ["renderInitAssistantInventory"], + }; + const guarded = [ + "renderInitAssistantInventory", + "renderOnboardingCore", + "onboardingContextWithoutInventory", + "renderOnboardingPrompt", + ]; + const violations: string[] = []; + for await (const path of new Bun.Glob("src/**/*.ts").scan({ + cwd: REPO_ROOT, + })) { + const source = await readFile(join(REPO_ROOT, path), "utf8"); + for (const name of guarded) { + if ( + new RegExp(`\\b${name}\\b`).test(source) && + !allowed[path]?.includes(name) + ) { + violations.push(`${path}: ${name}`); + } + } + } + expect(violations).toEqual([]); + + for (const path of [ + "src/commands/agent.ts", + "src/commands/project.ts", + "src/mcp/server.ts", + ]) { + const source = await readFile(join(REPO_ROOT, path), "utf8"); + expect(source, path).toContain("buildOnboardingPrompt("); + } +}); diff --git a/tests/onboarding-prompt.test.ts b/tests/onboarding-prompt.test.ts index aee1c1422..820fb8616 100644 --- a/tests/onboarding-prompt.test.ts +++ b/tests/onboarding-prompt.test.ts @@ -1,18 +1,32 @@ import { expect, test } from "bun:test"; import { INSTRUCTION_SECTIONS } from "../src/agents/instruction-source.ts"; -import { renderOnboardingPrompt } from "../src/agents/onboarding-prompt.ts"; +import { + onboardingContextWithoutInventory, + renderOnboardingCore, +} from "../src/agents/onboarding-prompt.ts"; import { CLI_SPEC } from "../src/cli/spec.ts"; import { extractHackInvocations, findGuidanceCommandIssues, } from "./helpers/cli-guidance-commands.ts"; -const NEW_PROJECT_PROMPT = renderOnboardingPrompt({ mode: "new-project" }); -const EXISTING_PROJECT_PROMPT = renderOnboardingPrompt({ - mode: "existing-project", - projectName: "myapp", - devHost: "myapp.hack", +// Repo-free cores: these assertions cover the static guidance only. The +// inventory section and entry-point parity are covered by +// tests/onboarding-entry-parity.test.ts. +const NEW_PROJECT_PROMPT = renderOnboardingCore({ + context: onboardingContextWithoutInventory({ + mode: "new-project", + reason: "no-repo-root", + }), +}); +const EXISTING_PROJECT_PROMPT = renderOnboardingCore({ + context: onboardingContextWithoutInventory({ + mode: "existing-project", + projectName: "myapp", + devHost: "myapp.hack", + reason: "no-repo-root", + }), }); const ALL_PROMPTS = { @@ -222,7 +236,12 @@ test("ground rules forbid instruction-file injection and require reporting disco expect(groundRules).toContain("Report discovery limits you hit"); expect(groundRules).toContain("the agent client CLI was missing"); expect(groundRules).toContain("a non-interactive run"); - expect(groundRules).toContain("no-shell `hack-init` MCP prompt"); + expect(groundRules).toContain( + "the inventory's remaining discovery limitations" + ); + expect(groundRules).toContain( + "`hack-init` MCP prompt request without `repoRoot`" + ); expect(groundRules).toContain("defaults to existing-project mode"); } }); diff --git a/tests/setup.test.ts b/tests/setup.test.ts index 66edc6a71..4e5c82c3b 100644 --- a/tests/setup.test.ts +++ b/tests/setup.test.ts @@ -12,11 +12,9 @@ import { removeCursorRules, renderCursorRules, } from "../src/agents/cursor.ts"; -import { - buildInitAssistantReport, - renderInitAssistantPrompt, -} from "../src/agents/init-assistant.ts"; +import { buildInitAssistantReport } from "../src/agents/init-assistant.ts"; import { renderAgentInitPatterns } from "../src/agents/init-patterns.ts"; +import { buildOnboardingPrompt } from "../src/agents/onboarding-prompt.ts"; import { renderAgentPrimer } from "../src/agents/primer.ts"; import { buildSetupSyncScopeResult } from "../src/commands/setup.ts"; import { renderAgentDocsSnippet } from "../src/mcp/agent-docs.ts"; @@ -305,7 +303,10 @@ test("buildInitAssistantReport captures repo signals", async () => { ).toBe(true); expect(report.envFiles).toContain(".env.example"); - const prompt = renderInitAssistantPrompt({ report }); + const prompt = await buildOnboardingPrompt({ + startDir: repoRoot, + entryPoint: { kind: "agent-init" }, + }); expect(prompt).toContain("hack init"); expect(prompt).toContain("docker-compose.yml"); });