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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .codex/skills/hack-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,18 @@ Use `hack` as the primary interface for local development.
- Use lifecycle processes for long-running host tasks, not ad-hoc terminals.
- Inspect via `hack projects --details` and `hack logs <service-or-process>`.

## Verification Loops

- For `hack run` / `hack exec` / env-resolution changes, verify the effective env transition matrix in
`tests/project-run-command.test.ts`.
- Cover omitted env, explicit overlay, explicit `base`, default-overlay resolution, cached runtime-state env,
and target-service running/not-running.
- For lifecycle or startup-process changes, verify `tests/project-lifecycle-processes.test.ts`.
- Preserve `sh -c` semantics, process-group cleanup, stale pane-metadata reconciliation, and interactive stdin
behavior.
- When semantics change, update `docs/env.md` or `docs/lifecycle.md` in the same patch so future agent work starts
from the current contract.

## Branch Instances

Use branch instances to run parallel environments:
Expand Down
25 changes: 25 additions & 0 deletions .github/PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
## Summary

-

## Verification

-

## Release Signal

- Commit / squash title: `<type>(<scope>): <summary>`
- Release intent: `feat` / `fix` / `docs` / `refactor` / `test` / `chore` / `build` / `ci` / `perf` / `revert`
- Should this PR trigger a release signal? `yes` / `no`
- If `no`, explain why:
- If `yes`, include the required changeset or equivalent release artifact in this PR.

## Semantic Surfaces

- Does this change affect `hack run`, `hack exec`, env resolution, runtime-state reconciliation, or lifecycle shell/process behavior? `yes` / `no`
- If `yes`, link the targeted tests you added or updated:
- If `yes`, link the matching docs or skill instructions you updated:

## Risks / Follow-up

-
15 changes: 15 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,21 @@ Never use any types and always default to leveraging generics and smart types to
- If a change is breaking, mark it explicitly with `!` in the type or scope position and include a `BREAKING CHANGE:` footer explaining the migration impact.
- If work lands through a squash merge, the PR title must also follow Conventional Commits so release automation can classify it correctly.
- Before pushing or merging, make sure the final commit history or squash title still preserves the intended release signal.
- Every PR must make the release decision explicit: should this change trigger a release signal, and if not, why not.
- If a changeset or equivalent release artifact is expected for that release signal, include it in the same patch instead of leaving the decision to bot feedback.

## Verification Guardrails

- If a change affects `hack run`, `hack exec`, env resolution, runtime-state reconciliation, or lifecycle shell/process semantics, the patch must include both targeted tests and matching docs updates.
- For env-sensitive command changes, verify the requested env, effective env, cached runtime-state env, and target-service-running matrix instead of a single happy path.
- For lifecycle changes, verify `sh -c` semantics, process-group cleanup, stale pane/process metadata reconciliation, and interactive stdin behavior.
- When a semantic contract changes, update the closest durable doc or skill instruction in the same patch so future work starts from the current rules.

## Command Complexity

- Treat `src/commands/project.ts` and `src/commands/global.ts` as complexity-sensitive surfaces.
- Before adding new branch-heavy logic there, prefer extracting a small helper with a narrow contract and direct tests.
- Do not grow top-level command handlers when the real change is a decision table, state transition, or reusable readiness check.

<!-- hack:tickets:start -->
## Tickets (git-backed)
Expand Down
266 changes: 93 additions & 173 deletions tests/project-run-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,9 +79,13 @@ function expectNoEnvScopeWarnings(): void {
).toEqual([]);
}

test("run applies modern env overlays to service-specific compose overrides", async () => {
const projectRoot = await createProject();

async function runPrintenv({
projectRoot,
envName,
}: {
readonly projectRoot: string;
readonly envName?: string;
}): Promise<number> {
const input = {
ctx: {
cwd: projectRoot,
Expand All @@ -91,7 +95,7 @@ test("run applies modern env overlays to service-specific compose overrides", as
options: {
path: projectRoot,
project: undefined,
env: "qa",
env: envName,
branch: undefined,
workdir: undefined,
profile: undefined,
Expand All @@ -101,13 +105,20 @@ test("run applies modern env overlays to service-specific compose overrides", as
cmd: ["printenv"],
},
raw: {
argv: ["--path", projectRoot, "--env", "qa", "api", "printenv"],
argv: envName
? ["--path", projectRoot, "--env", envName, "api", "printenv"]
: ["--path", projectRoot, "api", "printenv"],
positionals: ["api", "printenv"],
},
},
} as unknown as Parameters<typeof runCommand.handler>[0];

const exitCode = await runCommand.handler(input);
return await runCommand.handler(input);
}

test("run applies modern env overlays to service-specific compose overrides", async () => {
const projectRoot = await createProject();
const exitCode = await runPrintenv({ projectRoot, envName: "qa" });

expect(exitCode).toBe(0);
expect(runCalls).toHaveLength(1);
Expand Down Expand Up @@ -158,189 +169,98 @@ test("run does not warn about sibling service scopes in modern env configs", asy
].join("\n"),
});

const input = {
ctx: {
cwd: projectRoot,
cli: CLI_SPEC,
},
args: {
options: {
path: projectRoot,
project: undefined,
env: undefined,
branch: undefined,
workdir: undefined,
profile: undefined,
},
positionals: {
service: "api",
cmd: ["printenv"],
},
raw: {
argv: ["--path", projectRoot, "api", "printenv"],
positionals: ["api", "printenv"],
},
},
} as unknown as Parameters<typeof runCommand.handler>[0];

const exitCode = await runCommand.handler(input);
const exitCode = await runPrintenv({ projectRoot });

expect(exitCode).toBe(0);
expect(runCalls[0]?.noDeps).toBe(false);
expectNoEnvScopeWarnings();
});

test("run skips dependency startup when the local stack is already up", async () => {
const projectRoot = await createProject({
runningServices: ["api"],
});

const input = {
ctx: {
cwd: projectRoot,
cli: CLI_SPEC,
const runDependencyScenarios = [
{
name: "skips dependency startup when the local base stack is already up",
createProjectInput: {
runningServices: ["api"],
},
args: {
options: {
path: projectRoot,
project: undefined,
env: undefined,
branch: undefined,
workdir: undefined,
profile: undefined,
},
positionals: {
service: "api",
cmd: ["printenv"],
},
raw: {
argv: ["--path", projectRoot, "api", "printenv"],
positionals: ["api", "printenv"],
},
envName: undefined,
expectedNoDeps: true,
},
{
name: "keeps dependency startup when runtime state exists but the target service is not running",
createProjectInput: {
runtimeComposeProject: "project-run-env-test",
},
} as unknown as Parameters<typeof runCommand.handler>[0];

const exitCode = await runCommand.handler(input);

expect(exitCode).toBe(0);
expect(runCalls[0]?.noDeps).toBe(true);
});

test("run keeps dependency startup when cached runtime state is stale", async () => {
const projectRoot = await createProject({
runtimeComposeProject: "project-run-env-test",
});

const input = {
ctx: {
cwd: projectRoot,
cli: CLI_SPEC,
envName: undefined,
expectedNoDeps: false,
},
{
name: "keeps dependency startup when the requested overlay differs from the live stack env",
createProjectInput: {
runtimeComposeProject: "project-run-env-test",
runningServices: ["api"],
},
args: {
options: {
path: projectRoot,
project: undefined,
env: undefined,
branch: undefined,
workdir: undefined,
profile: undefined,
},
positionals: {
service: "api",
cmd: ["printenv"],
},
raw: {
argv: ["--path", projectRoot, "api", "printenv"],
positionals: ["api", "printenv"],
envName: "qa",
expectedNoDeps: false,
},
{
name: "skips dependency startup when omitted env resolves to the live default overlay",
createProjectInput: {
config: {
env: {
defaultOverlay: "qa",
},
},
runtimeComposeProject: "project-run-env-test",
runtimeEnvName: "qa",
runningServices: ["api"],
},
} as unknown as Parameters<typeof runCommand.handler>[0];

const exitCode = await runCommand.handler(input);

expect(exitCode).toBe(0);
expect(runCalls[0]?.noDeps).toBe(false);
});

test("run keeps dependency startup when the requested env differs from the live stack env", async () => {
const projectRoot = await createProject({
runtimeComposeProject: "project-run-env-test",
runningServices: ["api"],
});

const input = {
ctx: {
cwd: projectRoot,
cli: CLI_SPEC,
},
args: {
options: {
path: projectRoot,
project: undefined,
env: "qa",
branch: undefined,
workdir: undefined,
profile: undefined,
},
positionals: {
service: "api",
cmd: ["printenv"],
},
raw: {
argv: ["--path", projectRoot, "--env", "qa", "api", "printenv"],
positionals: ["api", "printenv"],
envName: undefined,
expectedNoDeps: true,
},
{
name: "keeps dependency startup when explicit base bypasses a matching default overlay stack",
createProjectInput: {
config: {
env: {
defaultOverlay: "qa",
},
},
runtimeComposeProject: "project-run-env-test",
runtimeEnvName: "qa",
runningServices: ["api"],
},
} as unknown as Parameters<typeof runCommand.handler>[0];

const exitCode = await runCommand.handler(input);

expect(exitCode).toBe(0);
expect(runCalls[0]?.noDeps).toBe(false);
});

test("run skips dependency startup when omitted env resolves to the live default overlay", async () => {
const projectRoot = await createProject({
config: {
env: {
defaultOverlay: "qa",
envName: "base",
expectedNoDeps: false,
},
{
name: "keeps dependency startup when omitted env resolves to qa but the live stack recorded base",
createProjectInput: {
config: {
env: {
defaultOverlay: "qa",
},
},
runtimeComposeProject: "project-run-env-test",
runtimeEnvName: null,
runningServices: ["api"],
},
runtimeComposeProject: "project-run-env-test",
runtimeEnvName: "qa",
runningServices: ["api"],
envName: undefined,
expectedNoDeps: false,
},
] as const;

for (const scenario of runDependencyScenarios) {
test(scenario.name, async () => {
const projectRoot = await createProject(scenario.createProjectInput);
const exitCode = await runPrintenv({
projectRoot,
envName: scenario.envName,
});

expect(exitCode).toBe(0);
expect(runCalls[0]?.noDeps).toBe(scenario.expectedNoDeps);
});

const input = {
ctx: {
cwd: projectRoot,
cli: CLI_SPEC,
},
args: {
options: {
path: projectRoot,
project: undefined,
env: undefined,
branch: undefined,
workdir: undefined,
profile: undefined,
},
positionals: {
service: "api",
cmd: ["printenv"],
},
raw: {
argv: ["--path", projectRoot, "api", "printenv"],
positionals: ["api", "printenv"],
},
},
} as unknown as Parameters<typeof runCommand.handler>[0];

const exitCode = await runCommand.handler(input);

expect(exitCode).toBe(0);
expect(runCalls[0]?.noDeps).toBe(true);
});
}

async function createProject(input?: {
readonly services?: readonly string[];
Expand Down
Loading