diff --git a/packages/cli/src/commands/conventions.integration.spec.ts b/packages/cli/src/commands/conventions.integration.spec.ts index 258d0f2..982509c 100644 --- a/packages/cli/src/commands/conventions.integration.spec.ts +++ b/packages/cli/src/commands/conventions.integration.spec.ts @@ -43,6 +43,7 @@ describe("architecture and practices facets integration", () => { .mockResolvedValueOnce("tanstack"); // architecture vi.mocked(clack.multiselect) .mockResolvedValueOnce([]) // practices: none + .mockResolvedValueOnce([]) // backpressure: none .mockResolvedValueOnce([]) // docsets: deselect all .mockResolvedValueOnce(["claude-code"]); // harnesses @@ -228,6 +229,7 @@ describe("architecture and practices facets integration", () => { .mockResolvedValueOnce("tanstack"); // architecture vi.mocked(clack.multiselect) .mockResolvedValueOnce(["tdd-london", "conventional-commits"]) // practices + .mockResolvedValueOnce([]) // backpressure: none .mockResolvedValueOnce([]) // docsets: deselect all .mockResolvedValueOnce(["claude-code"]); // harnesses diff --git a/packages/cli/src/commands/install.spec.ts b/packages/cli/src/commands/install.spec.ts index d0aeb74..0c27908 100644 --- a/packages/cli/src/commands/install.spec.ts +++ b/packages/cli/src/commands/install.spec.ts @@ -14,7 +14,9 @@ const mockLogical: LogicalConfig = { instructions: ["test instruction"], cli_actions: [], knowledge_sources: [], - skills: [] + skills: [], + git_hooks: [], + setup_notes: [] }; vi.mock("@ade/core", async (importOriginal) => { diff --git a/packages/cli/src/commands/knowledge.integration.spec.ts b/packages/cli/src/commands/knowledge.integration.spec.ts index a1a4680..7fd12a3 100644 --- a/packages/cli/src/commands/knowledge.integration.spec.ts +++ b/packages/cli/src/commands/knowledge.integration.spec.ts @@ -44,6 +44,7 @@ describe("knowledge integration", () => { vi.mocked(clack.multiselect) .mockResolvedValueOnce([]) // practices: none + .mockResolvedValueOnce([]) // backpressure: none .mockResolvedValueOnce([ "tanstack-router-docs", "tanstack-query-docs", @@ -94,6 +95,7 @@ describe("knowledge integration", () => { vi.mocked(clack.multiselect) .mockResolvedValueOnce([]) // practices: none + .mockResolvedValueOnce([]) // backpressure: none .mockResolvedValueOnce(["tanstack-router-docs", "tanstack-query-docs"]) .mockResolvedValueOnce(["claude-code"]); // harnesses diff --git a/packages/cli/src/commands/setup.spec.ts b/packages/cli/src/commands/setup.spec.ts index 1c3380d..f04b6d2 100644 --- a/packages/cli/src/commands/setup.spec.ts +++ b/packages/cli/src/commands/setup.spec.ts @@ -27,7 +27,9 @@ vi.mock("@ade/core", async (importOriginal) => { instructions: [], cli_actions: [], knowledge_sources: [], - skills: [] + skills: [], + git_hooks: [], + setup_notes: [] } satisfies LogicalConfig), collectDocsets: actual.collectDocsets }; @@ -176,7 +178,9 @@ describe("runSetup", () => { instructions: ["do stuff"], cli_actions: [], knowledge_sources: [], - skills: [] + skills: [], + git_hooks: [], + setup_notes: [] }; vi.mocked(resolve).mockResolvedValueOnce(mockLogical); vi.mocked(clack.select) @@ -311,6 +315,30 @@ describe("runSetup", () => { expect(clack.outro).toHaveBeenCalled(); }); + it("displays each setup note via clack.log.info", async () => { + const mockLogical: LogicalConfig = { + mcp_servers: [], + instructions: [], + cli_actions: [], + knowledge_sources: [], + skills: [], + git_hooks: [], + setup_notes: ["Add lint script to package.json", "Run npm install"] + }; + vi.mocked(resolve).mockResolvedValueOnce(mockLogical); + vi.mocked(clack.select) + .mockResolvedValueOnce("workflow-a") + .mockResolvedValueOnce("vitest"); + vi.mocked(clack.multiselect).mockResolvedValueOnce(["claude-code"]); + + await runSetup("/tmp/test-project", testCatalog); + + expect(clack.log.info).toHaveBeenCalledWith( + "Add lint script to package.json" + ); + expect(clack.log.info).toHaveBeenCalledWith("Run npm install"); + }); + describe("re-run with existing config", () => { it("passes existing single-select choice as initialValue", async () => { vi.mocked(readUserConfig).mockResolvedValueOnce({ diff --git a/packages/cli/src/commands/setup.ts b/packages/cli/src/commands/setup.ts index bd464e7..f381086 100644 --- a/packages/cli/src/commands/setup.ts +++ b/packages/cli/src/commands/setup.ts @@ -11,7 +11,9 @@ import { collectDocsets, createDefaultRegistry, getFacet, - getOption + getOption, + sortFacets, + getVisibleOptions } from "@ade/core"; import { allHarnessWriters, @@ -45,9 +47,16 @@ export async function runSetup( const choices: Record = {}; - for (const facet of catalog.facets) { + const sortedFacets = sortFacets(catalog); + + for (const facet of sortedFacets) { + const visibleOptions = getVisibleOptions(facet, choices, catalog); + if (visibleOptions.length === 0) continue; + + const visibleFacet = { ...facet, options: visibleOptions }; + if (facet.multiSelect) { - const selected = await promptMultiSelect(facet, existingChoices); + const selected = await promptMultiSelect(visibleFacet, existingChoices); if (typeof selected === "symbol") { clack.cancel("Setup cancelled."); return; @@ -56,7 +65,7 @@ export async function runSetup( choices[facet.id] = selected; } } else { - const selected = await promptSelect(facet, existingChoices); + const selected = await promptSelect(visibleFacet, existingChoices); if (typeof selected === "symbol") { clack.cancel("Setup cancelled."); return; @@ -161,6 +170,10 @@ export async function runSetup( ); } + for (const note of logicalConfig.setup_notes) { + clack.log.info(note); + } + clack.outro("Setup complete!"); } diff --git a/packages/core/src/catalog/catalog.spec.ts b/packages/core/src/catalog/catalog.spec.ts index 3c80d23..56b08be 100644 --- a/packages/core/src/catalog/catalog.spec.ts +++ b/packages/core/src/catalog/catalog.spec.ts @@ -1,5 +1,11 @@ import { describe, it, expect } from "vitest"; -import { getDefaultCatalog, getFacet, getOption } from "./index.js"; +import { + getDefaultCatalog, + getFacet, + getOption, + sortFacets, + getVisibleOptions +} from "./index.js"; import { createDefaultRegistry, getProvisionWriter } from "../registry.js"; describe("catalog", () => { @@ -272,6 +278,239 @@ describe("catalog", () => { }); }); + describe("backpressure facet", () => { + it("exists in the default catalog", () => { + const catalog = getDefaultCatalog(); + const backpressure = getFacet(catalog, "backpressure"); + expect(backpressure).toBeDefined(); + expect(backpressure!.required).toBe(false); + }); + + it("is multi-select", () => { + const catalog = getDefaultCatalog(); + const backpressure = getFacet(catalog, "backpressure")!; + expect(backpressure.multiSelect).toBe(true); + }); + + it("depends on architecture facet", () => { + const catalog = getDefaultCatalog(); + const backpressure = getFacet(catalog, "backpressure")!; + expect(backpressure.dependsOn).toContain("architecture"); + }); + + it("has per-architecture lint-build-precommit options with git-hooks provisions", () => { + const catalog = getDefaultCatalog(); + const backpressure = getFacet(catalog, "backpressure")!; + + for (const archId of ["tanstack", "nodejs-backend", "java-backend"]) { + const option = getOption( + backpressure, + `lint-build-precommit-${archId}` + ); + expect(option, `lint-build-precommit-${archId} missing`).toBeDefined(); + expect(option!.recipe.some((p) => p.writer === "git-hooks")).toBe(true); + + const gitHooksProvision = option!.recipe.find( + (p) => p.writer === "git-hooks" + )!; + const hooks = ( + gitHooksProvision.config as { hooks: { phase: string }[] } + ).hooks; + expect(hooks.some((h) => h.phase === "pre-commit")).toBe(true); + } + }); + + it("lint-build-precommit options have an instruction provision for WIP commits", () => { + const catalog = getDefaultCatalog(); + const backpressure = getFacet(catalog, "backpressure")!; + + for (const archId of ["tanstack", "nodejs-backend", "java-backend"]) { + const option = getOption( + backpressure, + `lint-build-precommit-${archId}` + )!; + expect(option.recipe.some((p) => p.writer === "instruction")).toBe( + true + ); + } + }); + + it("lint-build-precommit options have a setup-note provision", () => { + const catalog = getDefaultCatalog(); + const backpressure = getFacet(catalog, "backpressure")!; + + for (const archId of ["tanstack", "nodejs-backend", "java-backend"]) { + const option = getOption( + backpressure, + `lint-build-precommit-${archId}` + )!; + const note = option.recipe.find((p) => p.writer === "setup-note"); + expect( + note, + `lint-build-precommit-${archId} missing setup-note` + ).toBeDefined(); + expect((note!.config as { text: string }).text).toBeTruthy(); + } + }); + + it("has per-architecture unit-test-prepush options with git-hooks provisions", () => { + const catalog = getDefaultCatalog(); + const backpressure = getFacet(catalog, "backpressure")!; + + for (const archId of ["tanstack", "nodejs-backend", "java-backend"]) { + const option = getOption(backpressure, `unit-test-prepush-${archId}`); + expect(option, `unit-test-prepush-${archId} missing`).toBeDefined(); + expect(option!.recipe.some((p) => p.writer === "git-hooks")).toBe(true); + + const gitHooksProvision = option!.recipe.find( + (p) => p.writer === "git-hooks" + )!; + const hooks = ( + gitHooksProvision.config as { hooks: { phase: string }[] } + ).hooks; + expect(hooks.some((h) => h.phase === "pre-push")).toBe(true); + } + }); + + it("hook scripts contain the swallow-on-success pattern", () => { + const catalog = getDefaultCatalog(); + const backpressure = getFacet(catalog, "backpressure")!; + + for (const option of backpressure.options) { + const gitHooksProvision = option.recipe.find( + (p) => p.writer === "git-hooks" + )!; + const hooks = ( + gitHooksProvision.config as { hooks: { script: string }[] } + ).hooks; + for (const hook of hooks) { + expect(hook.script).toContain("✓"); + expect(hook.script).toContain("exit_code"); + } + } + }); + + it("all options have an available() function", () => { + const catalog = getDefaultCatalog(); + const backpressure = getFacet(catalog, "backpressure")!; + + for (const option of backpressure.options) { + expect( + typeof option.available, + `option ${option.id} missing available()` + ).toBe("function"); + } + }); + }); + + describe("backpressure facet — available()", () => { + it("tanstack options are visible when architecture=tanstack", () => { + const catalog = getDefaultCatalog(); + const backpressure = getFacet(catalog, "backpressure")!; + const architectureFacet = getFacet(catalog, "architecture")!; + const tanstackOption = getOption(architectureFacet, "tanstack")!; + + const visible = getVisibleOptions( + backpressure, + { architecture: "tanstack" }, + catalog + ); + const ids = visible.map((o) => o.id); + expect(ids).toContain("lint-build-precommit-tanstack"); + expect(ids).toContain("unit-test-prepush-tanstack"); + expect(ids).not.toContain("lint-build-precommit-java-backend"); + expect(tanstackOption).toBeDefined(); // guard + }); + + it("java-backend options are visible when architecture=java-backend", () => { + const catalog = getDefaultCatalog(); + const backpressure = getFacet(catalog, "backpressure")!; + + const visible = getVisibleOptions( + backpressure, + { architecture: "java-backend" }, + catalog + ); + const ids = visible.map((o) => o.id); + expect(ids).toContain("lint-build-precommit-java-backend"); + expect(ids).toContain("unit-test-prepush-java-backend"); + expect(ids).not.toContain("lint-build-precommit-tanstack"); + }); + + it("no options visible when architecture is not selected", () => { + const catalog = getDefaultCatalog(); + const backpressure = getFacet(catalog, "backpressure")!; + + const visible = getVisibleOptions(backpressure, {}, catalog); + expect(visible).toHaveLength(0); + }); + + it("only the two matching options are visible per architecture", () => { + const catalog = getDefaultCatalog(); + const backpressure = getFacet(catalog, "backpressure")!; + + for (const archId of ["tanstack", "nodejs-backend", "java-backend"]) { + const visible = getVisibleOptions( + backpressure, + { architecture: archId }, + catalog + ); + expect(visible, `expected 2 options for ${archId}`).toHaveLength(2); + expect(visible.every((o) => o.id.endsWith(`-${archId}`))).toBe(true); + } + }); + }); + + describe("sortFacets", () => { + it("returns all facets", () => { + const catalog = getDefaultCatalog(); + const sorted = sortFacets(catalog); + expect(sorted).toHaveLength(catalog.facets.length); + }); + + it("places backpressure after architecture", () => { + const catalog = getDefaultCatalog(); + const sorted = sortFacets(catalog); + const archIdx = sorted.findIndex((f) => f.id === "architecture"); + const bpIdx = sorted.findIndex((f) => f.id === "backpressure"); + expect(archIdx).toBeLessThan(bpIdx); + }); + + it("facets without dependsOn are not placed after their dependents", () => { + const catalog = getDefaultCatalog(); + const sorted = sortFacets(catalog); + for (const facet of sorted) { + const facetIdx = sorted.findIndex((f) => f.id === facet.id); + for (const depId of facet.dependsOn ?? []) { + const depIdx = sorted.findIndex((f) => f.id === depId); + expect(depIdx, `${depId} must come before ${facet.id}`).toBeLessThan( + facetIdx + ); + } + } + }); + }); + + describe("getVisibleOptions", () => { + it("returns all options when none have available()", () => { + const catalog = getDefaultCatalog(); + const architecture = getFacet(catalog, "architecture")!; + const visible = getVisibleOptions(architecture, {}, catalog); + expect(visible).toHaveLength(architecture.options.length); + }); + + it("returns all options when available() returns true for all", () => { + const catalog = getDefaultCatalog(); + const architecture = getFacet(catalog, "architecture")!; + const visible = getVisibleOptions( + architecture, + { architecture: "tanstack" }, + catalog + ); + expect(visible).toHaveLength(architecture.options.length); + }); + }); + describe("catalog + registry integration", () => { it("every recipe provision references a writer that exists in the default registry", () => { const catalog = getDefaultCatalog(); diff --git a/packages/core/src/catalog/facets/backpressure.ts b/packages/core/src/catalog/facets/backpressure.ts new file mode 100644 index 0000000..d03f4b3 --- /dev/null +++ b/packages/core/src/catalog/facets/backpressure.ts @@ -0,0 +1,143 @@ +import type { Facet, Provision } from "../../types.js"; + +const NODEJS_LINT_BUILD_SCRIPT = `#!/bin/sh +output=$(npm run lint 2>&1 && npm run build 2>&1); exit_code=$? +if [ $exit_code -eq 0 ]; then echo "✓"; else echo "$output"; exit $exit_code; fi +`; + +const JAVA_LINT_BUILD_SCRIPT = `#!/bin/sh +output=$(./gradlew compileJava checkstyleMain 2>&1); exit_code=$? +if [ $exit_code -eq 0 ]; then echo "✓"; else echo "$output"; exit $exit_code; fi +`; + +const NODEJS_UNIT_TEST_SCRIPT = `#!/bin/sh +output=$(npm test -- --bail 2>&1); exit_code=$? +if [ $exit_code -eq 0 ]; then echo "✓"; else echo "$output"; exit $exit_code; fi +`; + +const JAVA_UNIT_TEST_SCRIPT = `#!/bin/sh +output=$(./gradlew test 2>&1); exit_code=$? +if [ $exit_code -eq 0 ]; then echo "✓"; else echo "$output"; exit $exit_code; fi +`; + +const WIP_COMMIT_INSTRUCTION = + "Commit often using small WIP commits so pre-commit quality gates run frequently and catch issues early."; + +const NODEJS_LINT_BUILD_NOTE = + "Add lint and build scripts to package.json before committing:\n" + + ' "lint": "eslint .",\n' + + ' "build": "tsc --noEmit"'; + +const JAVA_LINT_BUILD_NOTE = + "Apply the Checkstyle Gradle plugin before committing:\n" + + " // build.gradle.kts\n" + + " plugins { checkstyle }"; + +const NODEJS_LINT_BUILD_RECIPE: Provision[] = [ + { + writer: "git-hooks", + config: { + hooks: [{ phase: "pre-commit", script: NODEJS_LINT_BUILD_SCRIPT }] + } + }, + { + writer: "instruction", + config: { text: WIP_COMMIT_INSTRUCTION } + }, + { + writer: "setup-note", + config: { text: NODEJS_LINT_BUILD_NOTE } + } +]; + +const JAVA_LINT_BUILD_RECIPE: Provision[] = [ + { + writer: "git-hooks", + config: { + hooks: [{ phase: "pre-commit", script: JAVA_LINT_BUILD_SCRIPT }] + } + }, + { + writer: "instruction", + config: { text: WIP_COMMIT_INSTRUCTION } + }, + { + writer: "setup-note", + config: { text: JAVA_LINT_BUILD_NOTE } + } +]; + +const NODEJS_UNIT_TEST_RECIPE: Provision[] = [ + { + writer: "git-hooks", + config: { + hooks: [{ phase: "pre-push", script: NODEJS_UNIT_TEST_SCRIPT }] + } + } +]; + +const JAVA_UNIT_TEST_RECIPE: Provision[] = [ + { + writer: "git-hooks", + config: { + hooks: [{ phase: "pre-push", script: JAVA_UNIT_TEST_SCRIPT }] + } + } +]; + +export const backpressureFacet: Facet = { + id: "backpressure", + label: "Backpressure", + description: + "Install git hooks that enforce quality gates — silent on success, surface only relevant failures", + required: false, + multiSelect: true, + dependsOn: ["architecture"], + options: [ + { + id: "lint-build-precommit-tanstack", + label: "Lint + Build (pre-commit)", + description: + "Block commits if lint or build fails; emit only ✓ on success", + available: (deps) => deps["architecture"]?.id === "tanstack", + recipe: NODEJS_LINT_BUILD_RECIPE + }, + { + id: "lint-build-precommit-nodejs-backend", + label: "Lint + Build (pre-commit)", + description: + "Block commits if lint or build fails; emit only ✓ on success", + available: (deps) => deps["architecture"]?.id === "nodejs-backend", + recipe: NODEJS_LINT_BUILD_RECIPE + }, + { + id: "lint-build-precommit-java-backend", + label: "Lint + Build (pre-commit)", + description: + "Block commits if lint or build fails; emit only ✓ on success", + available: (deps) => deps["architecture"]?.id === "java-backend", + recipe: JAVA_LINT_BUILD_RECIPE + }, + { + id: "unit-test-prepush-tanstack", + label: "Unit Tests (pre-push)", + description: "Block pushes if unit tests fail; emit only ✓ on success", + available: (deps) => deps["architecture"]?.id === "tanstack", + recipe: NODEJS_UNIT_TEST_RECIPE + }, + { + id: "unit-test-prepush-nodejs-backend", + label: "Unit Tests (pre-push)", + description: "Block pushes if unit tests fail; emit only ✓ on success", + available: (deps) => deps["architecture"]?.id === "nodejs-backend", + recipe: NODEJS_UNIT_TEST_RECIPE + }, + { + id: "unit-test-prepush-java-backend", + label: "Unit Tests (pre-push)", + description: "Block pushes if unit tests fail; emit only ✓ on success", + available: (deps) => deps["architecture"]?.id === "java-backend", + recipe: JAVA_UNIT_TEST_RECIPE + } + ] +}; diff --git a/packages/core/src/catalog/index.ts b/packages/core/src/catalog/index.ts index 6d17d81..c949deb 100644 --- a/packages/core/src/catalog/index.ts +++ b/packages/core/src/catalog/index.ts @@ -2,10 +2,11 @@ import type { Catalog, Facet, Option } from "../types.js"; import { processFacet } from "./facets/process.js"; import { architectureFacet } from "./facets/architecture.js"; import { practicesFacet } from "./facets/practices.js"; +import { backpressureFacet } from "./facets/backpressure.js"; export function getDefaultCatalog(): Catalog { return { - facets: [processFacet, architectureFacet, practicesFacet] + facets: [processFacet, architectureFacet, practicesFacet, backpressureFacet] }; } @@ -16,3 +17,70 @@ export function getFacet(catalog: Catalog, id: string): Facet | undefined { export function getOption(facet: Facet, id: string): Option | undefined { return facet.options.find((o) => o.id === id); } + +/** + * Topologically sort facets so that dependency facets come before dependent ones. + * Uses Kahn's algorithm. Throws if a cycle is detected. + */ +export function sortFacets(catalog: Catalog): Facet[] { + const facets = catalog.facets; + const idToFacet = new Map(facets.map((f) => [f.id, f])); + + // Build in-degree and adjacency (dependsOn edge: dep → dependent) + const inDegree = new Map(facets.map((f) => [f.id, 0])); + const dependents = new Map(facets.map((f) => [f.id, []])); + + for (const facet of facets) { + for (const dep of facet.dependsOn ?? []) { + if (idToFacet.has(dep)) { + inDegree.set(facet.id, (inDegree.get(facet.id) ?? 0) + 1); + dependents.get(dep)!.push(facet.id); + } + } + } + + const queue: Facet[] = facets.filter((f) => (inDegree.get(f.id) ?? 0) === 0); + const result: Facet[] = []; + + while (queue.length > 0) { + const facet = queue.shift()!; + result.push(facet); + for (const depId of dependents.get(facet.id) ?? []) { + const newDegree = (inDegree.get(depId) ?? 0) - 1; + inDegree.set(depId, newDegree); + if (newDegree === 0) { + queue.push(idToFacet.get(depId)!); + } + } + } + + if (result.length !== facets.length) { + throw new Error("Cycle detected in facet dependsOn graph"); + } + + return result; +} + +/** + * Returns only the options of a facet that are visible given the current choices. + * Options without an `available()` function are always visible. + */ +export function getVisibleOptions( + facet: Facet, + choices: Record, + catalog: Catalog +): Option[] { + return facet.options.filter((option) => { + if (!option.available) return true; + const deps: Record = {}; + for (const depFacetId of facet.dependsOn ?? []) { + const depFacet = getFacet(catalog, depFacetId); + const choiceVal = choices[depFacetId]; + deps[depFacetId] = + depFacet && typeof choiceVal === "string" + ? getOption(depFacet, choiceVal) + : undefined; + } + return option.available(deps); + }); +} diff --git a/packages/core/src/config.spec.ts b/packages/core/src/config.spec.ts index 771a9e6..fccd65d 100644 --- a/packages/core/src/config.spec.ts +++ b/packages/core/src/config.spec.ts @@ -124,7 +124,9 @@ describe("config", () => { description: "TypeScript documentation" } ], - skills: [] + skills: [], + git_hooks: [], + setup_notes: [] } }; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 29ccfea..0bc7268 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -12,7 +12,8 @@ export { type KnowledgeSource, type SkillDefinition, type InlineSkill, - type ExternalSkill + type ExternalSkill, + type GitHook } from "./types.js"; export { type ResolutionContext, type ResolvedFacet } from "./types.js"; export { type UserConfig, type LockFile } from "./types.js"; @@ -37,6 +38,12 @@ export { createDefaultRegistry } from "./registry.js"; export { resolve, collectDocsets } from "./resolver.js"; -export { getDefaultCatalog, getFacet, getOption } from "./catalog/index.js"; +export { + getDefaultCatalog, + getFacet, + getOption, + sortFacets, + getVisibleOptions +} from "./catalog/index.js"; export { skillsWriter } from "./writers/skills.js"; export { knowledgeWriter } from "./writers/knowledge.js"; diff --git a/packages/core/src/registry.spec.ts b/packages/core/src/registry.spec.ts index 4ff05bc..81384fb 100644 --- a/packages/core/src/registry.spec.ts +++ b/packages/core/src/registry.spec.ts @@ -99,7 +99,9 @@ describe("registry", () => { instructions: ["be helpful"], cli_actions: [], knowledge_sources: [], - skills: [] + skills: [], + git_hooks: [], + setup_notes: [] }; await found!.install(config, "/tmp/my-project"); expect(mockInstall).toHaveBeenCalledWith(config, "/tmp/my-project"); @@ -113,7 +115,7 @@ describe("registry", () => { }); describe("createDefaultRegistry", () => { - it("has all 6 built-in provision writer IDs registered", () => { + it("has all 8 built-in provision writer IDs registered", () => { const registry = createDefaultRegistry(); const expectedIds = [ "workflows", @@ -121,7 +123,9 @@ describe("registry", () => { "knowledge", "mcp-server", "instruction", - "installable" + "installable", + "git-hooks", + "setup-note" ]; for (const id of expectedIds) { expect( @@ -129,7 +133,7 @@ describe("registry", () => { `expected provision writer "${id}" to be registered` ).toBeDefined(); } - expect(registry.provisions.size).toBe(6); + expect(registry.provisions.size).toBe(8); }); it("has no agent writers by default (moved to @ade/harnesses)", () => { diff --git a/packages/core/src/registry.ts b/packages/core/src/registry.ts index 90d7ace..e7d247a 100644 --- a/packages/core/src/registry.ts +++ b/packages/core/src/registry.ts @@ -7,6 +7,8 @@ import { instructionWriter } from "./writers/instruction.js"; import { workflowsWriter } from "./writers/workflows.js"; import { skillsWriter } from "./writers/skills.js"; import { knowledgeWriter } from "./writers/knowledge.js"; +import { gitHooksWriter } from "./writers/git-hooks.js"; +import { setupNoteWriter } from "./writers/setup-note.js"; export function createRegistry(): WriterRegistry { return { @@ -51,6 +53,8 @@ export function createDefaultRegistry(): WriterRegistry { registerProvisionWriter(registry, skillsWriter); registerProvisionWriter(registry, knowledgeWriter); + registerProvisionWriter(registry, gitHooksWriter); + registerProvisionWriter(registry, setupNoteWriter); // Stub writers for types not yet implemented for (const id of ["mcp-server", "installable"]) { diff --git a/packages/core/src/resolver.spec.ts b/packages/core/src/resolver.spec.ts index 5970e85..607d04b 100644 --- a/packages/core/src/resolver.spec.ts +++ b/packages/core/src/resolver.spec.ts @@ -5,6 +5,7 @@ import { createRegistry, registerProvisionWriter } from "./registry.js"; import { instructionWriter } from "./writers/instruction.js"; import { workflowsWriter } from "./writers/workflows.js"; import { skillsWriter } from "./writers/skills.js"; +import { setupNoteWriter } from "./writers/setup-note.js"; import type { UserConfig, WriterRegistry, Catalog } from "./types.js"; function buildRegistry(): WriterRegistry { @@ -12,6 +13,7 @@ function buildRegistry(): WriterRegistry { registerProvisionWriter(registry, instructionWriter); registerProvisionWriter(registry, workflowsWriter); registerProvisionWriter(registry, skillsWriter); + registerProvisionWriter(registry, setupNoteWriter); return registry; } @@ -70,7 +72,9 @@ describe("resolve", () => { instructions: [], cli_actions: [], knowledge_sources: [], - skills: [] + skills: [], + git_hooks: [], + setup_notes: [] }); }); }); @@ -132,7 +136,9 @@ describe("resolve", () => { instructions: [], cli_actions: [], knowledge_sources: [], - skills: [] + skills: [], + git_hooks: [], + setup_notes: [] }); }); }); @@ -213,6 +219,41 @@ describe("resolve", () => { }); }); + describe("setup_notes merging", () => { + it("merges setup_notes from provision writers into the output", async () => { + const notesCatalog: Catalog = { + facets: [ + { + id: "quality", + label: "Quality", + description: "Quality tools", + required: false, + options: [ + { + id: "test-gate", + label: "Test Gate", + description: "A gate with a setup note", + recipe: [ + { + writer: "setup-note", + config: { text: "Run npm install before committing." } + } + ] + } + ] + } + ] + }; + + const userConfig: UserConfig = { choices: { quality: "test-gate" } }; + const result = await resolve(userConfig, notesCatalog, registry); + + expect(result.setup_notes).toEqual([ + "Run npm install before committing." + ]); + }); + }); + describe("docset collection", () => { it("collects docsets from selected options into knowledge_sources", async () => { const docsetCatalog: Catalog = { diff --git a/packages/core/src/resolver.ts b/packages/core/src/resolver.ts index 4f42ba4..818e87a 100644 --- a/packages/core/src/resolver.ts +++ b/packages/core/src/resolver.ts @@ -20,7 +20,9 @@ export async function resolve( instructions: [], cli_actions: [], knowledge_sources: [], - skills: [] + skills: [], + git_hooks: [], + setup_notes: [] }; const context: ResolutionContext = { resolved: {} }; @@ -64,6 +66,12 @@ export async function resolve( if (partial.skills) { result.skills.push(...partial.skills); } + if (partial.git_hooks) { + result.git_hooks.push(...partial.git_hooks); + } + if (partial.setup_notes) { + result.setup_notes.push(...partial.setup_notes); + } } } } diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index d79513f..49cb36d 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -20,6 +20,7 @@ export interface Option { description: string; recipe: Provision[]; docsets?: DocsetDef[]; + available?: (deps: Record) => boolean; } export interface DocsetDef { @@ -40,7 +41,9 @@ export type ProvisionWriter = | "knowledge" | "mcp-server" | "instruction" - | "installable"; + | "installable" + | "git-hooks" + | "setup-note"; // --- LogicalConfig types --- @@ -57,12 +60,19 @@ export interface ExternalSkill { export type SkillDefinition = InlineSkill | ExternalSkill; +export interface GitHook { + phase: "pre-commit" | "pre-push"; + script: string; +} + export interface LogicalConfig { mcp_servers: McpServerEntry[]; instructions: string[]; cli_actions: CliAction[]; knowledge_sources: KnowledgeSource[]; skills: SkillDefinition[]; + git_hooks: GitHook[]; + setup_notes: string[]; } export interface McpServerEntry { diff --git a/packages/core/src/writers/git-hooks.ts b/packages/core/src/writers/git-hooks.ts new file mode 100644 index 0000000..3859abb --- /dev/null +++ b/packages/core/src/writers/git-hooks.ts @@ -0,0 +1,9 @@ +import type { ProvisionWriterDef, GitHook } from "../types.js"; + +export const gitHooksWriter: ProvisionWriterDef = { + id: "git-hooks", + async write(config) { + const { hooks } = config as { hooks: GitHook[] }; + return { git_hooks: hooks }; + } +}; diff --git a/packages/core/src/writers/setup-note.ts b/packages/core/src/writers/setup-note.ts new file mode 100644 index 0000000..b106866 --- /dev/null +++ b/packages/core/src/writers/setup-note.ts @@ -0,0 +1,9 @@ +import type { ProvisionWriterDef } from "../types.js"; + +export const setupNoteWriter: ProvisionWriterDef = { + id: "setup-note", + async write(config) { + const { text } = config as { text: string }; + return { setup_notes: [text] }; + } +}; diff --git a/packages/harnesses/src/util.ts b/packages/harnesses/src/util.ts index 39a2ee3..0ddbb94 100644 --- a/packages/harnesses/src/util.ts +++ b/packages/harnesses/src/util.ts @@ -1,6 +1,6 @@ import { mkdir, readFile, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; -import type { LogicalConfig, McpServerEntry } from "@ade/core"; +import type { GitHook, LogicalConfig, McpServerEntry } from "@ade/core"; // --------------------------------------------------------------------------- // JSON helpers @@ -162,6 +162,26 @@ export async function writeAgentMd( // Inline skill SKILL.md writer (used by claude-code) // --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// Git hook installer +// --------------------------------------------------------------------------- + +/** + * Write git hook scripts to `.git/hooks/`. + * Files are created with executable permissions (0o755). + * No-op when the hooks array is empty. + */ +export async function writeGitHooks( + hooks: GitHook[] | undefined, + projectRoot: string +): Promise { + if (!hooks) return; + for (const hook of hooks) { + const hookPath = join(projectRoot, ".git", "hooks", hook.phase); + await writeFile(hookPath, hook.script, { mode: 0o755 }); + } +} + export async function writeInlineSkills( config: LogicalConfig, projectRoot: string diff --git a/packages/harnesses/src/writers/claude-code.spec.ts b/packages/harnesses/src/writers/claude-code.spec.ts index b2e0b6c..7262d68 100644 --- a/packages/harnesses/src/writers/claude-code.spec.ts +++ b/packages/harnesses/src/writers/claude-code.spec.ts @@ -35,7 +35,9 @@ describe("claudeCodeWriter", () => { instructions: ["Use workflow files.", "Follow conventions."], cli_actions: [], knowledge_sources: [], - skills: [] + skills: [], + git_hooks: [], + setup_notes: [] }; await claudeCodeWriter.install(config, dir); @@ -63,7 +65,9 @@ describe("claudeCodeWriter", () => { instructions: [], cli_actions: [], knowledge_sources: [], - skills: [] + skills: [], + git_hooks: [], + setup_notes: [] }; await claudeCodeWriter.install(config, dir); @@ -89,7 +93,9 @@ describe("claudeCodeWriter", () => { instructions: [], cli_actions: [], knowledge_sources: [], - skills: [] + skills: [], + git_hooks: [], + setup_notes: [] }; await claudeCodeWriter.install(config, dir); @@ -112,7 +118,9 @@ describe("claudeCodeWriter", () => { instructions: [], cli_actions: [], knowledge_sources: [], - skills: [{ name: "my-skill", description: "A skill", body: "Do stuff." }] + skills: [{ name: "my-skill", description: "A skill", body: "Do stuff." }], + git_hooks: [], + setup_notes: [] }; await claudeCodeWriter.install(config, dir); @@ -137,7 +145,9 @@ describe("claudeCodeWriter", () => { description: "TanStack architecture conventions", body: "# Architecture\n\nUse file-based routing." } - ] + ], + git_hooks: [], + setup_notes: [] }; await claudeCodeWriter.install(config, dir); diff --git a/packages/harnesses/src/writers/claude-code.ts b/packages/harnesses/src/writers/claude-code.ts index 0052734..4eed515 100644 --- a/packages/harnesses/src/writers/claude-code.ts +++ b/packages/harnesses/src/writers/claude-code.ts @@ -6,7 +6,8 @@ import { writeJson, writeMcpServers, writeAgentMd, - writeInlineSkills + writeInlineSkills, + writeGitHooks } from "../util.js"; export const claudeCodeWriter: HarnessWriter = { @@ -26,6 +27,7 @@ export const claudeCodeWriter: HarnessWriter = { await writeClaudeSettings(config, projectRoot); await writeInlineSkills(config, projectRoot); + await writeGitHooks(config.git_hooks, projectRoot); } }; diff --git a/packages/harnesses/src/writers/cline.spec.ts b/packages/harnesses/src/writers/cline.spec.ts index 8b1828c..fe67112 100644 --- a/packages/harnesses/src/writers/cline.spec.ts +++ b/packages/harnesses/src/writers/cline.spec.ts @@ -34,7 +34,9 @@ describe("clineWriter", () => { instructions: [], cli_actions: [], knowledge_sources: [], - skills: [] + skills: [], + git_hooks: [], + setup_notes: [] }; await clineWriter.install(config, dir); @@ -54,7 +56,9 @@ describe("clineWriter", () => { instructions: ["Follow TDD."], cli_actions: [], knowledge_sources: [], - skills: [] + skills: [], + git_hooks: [], + setup_notes: [] }; await clineWriter.install(config, dir); diff --git a/packages/harnesses/src/writers/cline.ts b/packages/harnesses/src/writers/cline.ts index 3c89388..980cce1 100644 --- a/packages/harnesses/src/writers/cline.ts +++ b/packages/harnesses/src/writers/cline.ts @@ -1,7 +1,12 @@ import { join } from "node:path"; import type { LogicalConfig } from "@ade/core"; import type { HarnessWriter } from "../types.js"; -import { writeMcpServers, alwaysAllowEntry, writeRulesFile } from "../util.js"; +import { + writeMcpServers, + alwaysAllowEntry, + writeRulesFile, + writeGitHooks +} from "../util.js"; export const clineWriter: HarnessWriter = { id: "cline", @@ -14,5 +19,6 @@ export const clineWriter: HarnessWriter = { }); await writeRulesFile(config.instructions, join(projectRoot, ".clinerules")); + await writeGitHooks(config.git_hooks, projectRoot); } }; diff --git a/packages/harnesses/src/writers/copilot.spec.ts b/packages/harnesses/src/writers/copilot.spec.ts index 1076e7a..653720c 100644 --- a/packages/harnesses/src/writers/copilot.spec.ts +++ b/packages/harnesses/src/writers/copilot.spec.ts @@ -34,7 +34,9 @@ describe("copilotWriter", () => { instructions: [], cli_actions: [], knowledge_sources: [], - skills: [] + skills: [], + git_hooks: [], + setup_notes: [] }; await copilotWriter.install(config, dir); @@ -55,7 +57,9 @@ describe("copilotWriter", () => { instructions: ["Follow TDD."], cli_actions: [], knowledge_sources: [], - skills: [] + skills: [], + git_hooks: [], + setup_notes: [] }; await copilotWriter.install(config, dir); @@ -78,7 +82,9 @@ describe("copilotWriter", () => { instructions: ["Follow TDD."], cli_actions: [], knowledge_sources: [], - skills: [] + skills: [], + git_hooks: [], + setup_notes: [] }; await copilotWriter.install(config, dir); diff --git a/packages/harnesses/src/writers/copilot.ts b/packages/harnesses/src/writers/copilot.ts index 17e61b6..8743ced 100644 --- a/packages/harnesses/src/writers/copilot.ts +++ b/packages/harnesses/src/writers/copilot.ts @@ -1,7 +1,12 @@ import { join } from "node:path"; import type { LogicalConfig } from "@ade/core"; import type { HarnessWriter } from "../types.js"; -import { writeMcpServers, stdioEntry, writeAgentMd } from "../util.js"; +import { + writeMcpServers, + stdioEntry, + writeAgentMd, + writeGitHooks +} from "../util.js"; export const copilotWriter: HarnessWriter = { id: "copilot", @@ -28,5 +33,6 @@ export const copilotWriter: HarnessWriter = { path: join(projectRoot, ".github", "agents", "ade.agent.md"), extraFrontmatter: ["tools:", ...tools.map((t) => ` - ${t}`)] }); + await writeGitHooks(config.git_hooks, projectRoot); } }; diff --git a/packages/harnesses/src/writers/cursor.spec.ts b/packages/harnesses/src/writers/cursor.spec.ts index 6e8995f..55f4781 100644 --- a/packages/harnesses/src/writers/cursor.spec.ts +++ b/packages/harnesses/src/writers/cursor.spec.ts @@ -34,7 +34,9 @@ describe("cursorWriter", () => { instructions: [], cli_actions: [], knowledge_sources: [], - skills: [] + skills: [], + git_hooks: [], + setup_notes: [] }; await cursorWriter.install(config, dir); @@ -53,7 +55,9 @@ describe("cursorWriter", () => { instructions: ["Follow TDD.", "Use conventional commits."], cli_actions: [], knowledge_sources: [], - skills: [] + skills: [], + git_hooks: [], + setup_notes: [] }; await cursorWriter.install(config, dir); @@ -80,7 +84,9 @@ describe("cursorWriter", () => { instructions: [], cli_actions: [], knowledge_sources: [], - skills: [{ name: "my-skill", description: "A skill", body: "content" }] + skills: [{ name: "my-skill", description: "A skill", body: "content" }], + git_hooks: [], + setup_notes: [] }; await cursorWriter.install(config, dir); @@ -96,7 +102,9 @@ describe("cursorWriter", () => { instructions: ["hello"], cli_actions: [], knowledge_sources: [], - skills: [] + skills: [], + git_hooks: [], + setup_notes: [] }; await cursorWriter.install(config, dir); diff --git a/packages/harnesses/src/writers/cursor.ts b/packages/harnesses/src/writers/cursor.ts index 321cbac..35d1449 100644 --- a/packages/harnesses/src/writers/cursor.ts +++ b/packages/harnesses/src/writers/cursor.ts @@ -2,7 +2,7 @@ import { mkdir, writeFile } from "node:fs/promises"; import { join } from "node:path"; import type { LogicalConfig } from "@ade/core"; import type { HarnessWriter } from "../types.js"; -import { writeMcpServers } from "../util.js"; +import { writeMcpServers, writeGitHooks } from "../util.js"; export const cursorWriter: HarnessWriter = { id: "cursor", @@ -28,5 +28,6 @@ export const cursorWriter: HarnessWriter = { await writeFile(join(rulesDir, "ade.mdc"), content, "utf-8"); } + await writeGitHooks(config.git_hooks, projectRoot); } }; diff --git a/packages/harnesses/src/writers/kiro.ts b/packages/harnesses/src/writers/kiro.ts index 8f00c73..912d5b5 100644 --- a/packages/harnesses/src/writers/kiro.ts +++ b/packages/harnesses/src/writers/kiro.ts @@ -1,7 +1,7 @@ import { join } from "node:path"; import type { LogicalConfig } from "@ade/core"; import type { HarnessWriter } from "../types.js"; -import { standardEntry, writeJson } from "../util.js"; +import { standardEntry, writeJson, writeGitHooks } from "../util.js"; export const kiroWriter: HarnessWriter = { id: "kiro", @@ -9,43 +9,44 @@ export const kiroWriter: HarnessWriter = { description: "AWS AI IDE — .kiro/agents/ade.json", async install(config: LogicalConfig, projectRoot: string) { const servers = config.mcp_servers; - if (servers.length === 0 && config.instructions.length === 0) return; - - const mcpServers: Record = {}; - for (const s of servers) { - mcpServers[s.ref] = standardEntry(s); - } + if (servers.length > 0 || config.instructions.length > 0) { + const mcpServers: Record = {}; + for (const s of servers) { + mcpServers[s.ref] = standardEntry(s); + } - const tools: string[] = [ - "execute_bash", - "fs_read", - "fs_write", - "knowledge", - "thinking", - ...Object.keys(mcpServers).map((n) => `@${n}`) - ]; + const tools: string[] = [ + "execute_bash", + "fs_read", + "fs_write", + "knowledge", + "thinking", + ...Object.keys(mcpServers).map((n) => `@${n}`) + ]; - const allowedTools: string[] = []; - for (const s of servers) { - const explicit = s.allowedTools; - if (explicit && !explicit.includes("*")) { - for (const tool of explicit) { - allowedTools.push(`@${s.ref}/${tool}`); + const allowedTools: string[] = []; + for (const s of servers) { + const explicit = s.allowedTools; + if (explicit && !explicit.includes("*")) { + for (const tool of explicit) { + allowedTools.push(`@${s.ref}/${tool}`); + } + } else { + allowedTools.push(`@${s.ref}/*`); } - } else { - allowedTools.push(`@${s.ref}/*`); } - } - await writeJson(join(projectRoot, ".kiro", "agents", "ade.json"), { - name: "ade", - prompt: - config.instructions.length > 0 - ? config.instructions.join("\n\n") - : "ADE — Agentic Development Environment agent", - mcpServers, - tools, - allowedTools - }); + await writeJson(join(projectRoot, ".kiro", "agents", "ade.json"), { + name: "ade", + prompt: + config.instructions.length > 0 + ? config.instructions.join("\n\n") + : "ADE — Agentic Development Environment agent", + mcpServers, + tools, + allowedTools + }); + } + await writeGitHooks(config.git_hooks, projectRoot); } }; diff --git a/packages/harnesses/src/writers/opencode.ts b/packages/harnesses/src/writers/opencode.ts index a25e311..d7fc028 100644 --- a/packages/harnesses/src/writers/opencode.ts +++ b/packages/harnesses/src/writers/opencode.ts @@ -1,7 +1,7 @@ import { join } from "node:path"; import type { LogicalConfig } from "@ade/core"; import type { HarnessWriter } from "../types.js"; -import { writeMcpServers, writeAgentMd } from "../util.js"; +import { writeMcpServers, writeAgentMd, writeGitHooks } from "../util.js"; export const opencodeWriter: HarnessWriter = { id: "opencode", @@ -49,5 +49,6 @@ export const opencodeWriter: HarnessWriter = { fallbackBody: "ADE — Agentic Development Environment agent with project conventions and tools." }); + await writeGitHooks(config.git_hooks, projectRoot); } }; diff --git a/packages/harnesses/src/writers/roo-code.spec.ts b/packages/harnesses/src/writers/roo-code.spec.ts index c9dee42..e9b420c 100644 --- a/packages/harnesses/src/writers/roo-code.spec.ts +++ b/packages/harnesses/src/writers/roo-code.spec.ts @@ -34,7 +34,9 @@ describe("rooCodeWriter", () => { instructions: [], cli_actions: [], knowledge_sources: [], - skills: [] + skills: [], + git_hooks: [], + setup_notes: [] }; await rooCodeWriter.install(config, dir); @@ -54,7 +56,9 @@ describe("rooCodeWriter", () => { instructions: ["Follow TDD."], cli_actions: [], knowledge_sources: [], - skills: [] + skills: [], + git_hooks: [], + setup_notes: [] }; await rooCodeWriter.install(config, dir); diff --git a/packages/harnesses/src/writers/roo-code.ts b/packages/harnesses/src/writers/roo-code.ts index a1d008d..f463124 100644 --- a/packages/harnesses/src/writers/roo-code.ts +++ b/packages/harnesses/src/writers/roo-code.ts @@ -1,7 +1,12 @@ import { join } from "node:path"; import type { LogicalConfig } from "@ade/core"; import type { HarnessWriter } from "../types.js"; -import { writeMcpServers, alwaysAllowEntry, writeRulesFile } from "../util.js"; +import { + writeMcpServers, + alwaysAllowEntry, + writeRulesFile, + writeGitHooks +} from "../util.js"; export const rooCodeWriter: HarnessWriter = { id: "roo-code", @@ -14,5 +19,6 @@ export const rooCodeWriter: HarnessWriter = { }); await writeRulesFile(config.instructions, join(projectRoot, ".roorules")); + await writeGitHooks(config.git_hooks, projectRoot); } }; diff --git a/packages/harnesses/src/writers/universal.ts b/packages/harnesses/src/writers/universal.ts index 29ab3d4..bcf0a0f 100644 --- a/packages/harnesses/src/writers/universal.ts +++ b/packages/harnesses/src/writers/universal.ts @@ -2,7 +2,7 @@ import { join } from "node:path"; import { writeFile } from "node:fs/promises"; import type { LogicalConfig } from "@ade/core"; import type { HarnessWriter } from "../types.js"; -import { writeMcpServers } from "../util.js"; +import { writeMcpServers, writeGitHooks } from "../util.js"; export const universalWriter: HarnessWriter = { id: "universal", @@ -26,5 +26,6 @@ export const universalWriter: HarnessWriter = { await writeMcpServers(config.mcp_servers, { path: join(projectRoot, ".mcp.json") }); + await writeGitHooks(config.git_hooks, projectRoot); } }; diff --git a/packages/harnesses/src/writers/windsurf.spec.ts b/packages/harnesses/src/writers/windsurf.spec.ts index 2c72620..01f976d 100644 --- a/packages/harnesses/src/writers/windsurf.spec.ts +++ b/packages/harnesses/src/writers/windsurf.spec.ts @@ -34,7 +34,9 @@ describe("windsurfWriter", () => { instructions: [], cli_actions: [], knowledge_sources: [], - skills: [] + skills: [], + git_hooks: [], + setup_notes: [] }; await windsurfWriter.install(config, dir); @@ -55,7 +57,9 @@ describe("windsurfWriter", () => { instructions: ["Follow TDD."], cli_actions: [], knowledge_sources: [], - skills: [] + skills: [], + git_hooks: [], + setup_notes: [] }; await windsurfWriter.install(config, dir); diff --git a/packages/harnesses/src/writers/windsurf.ts b/packages/harnesses/src/writers/windsurf.ts index e03d298..7198d53 100644 --- a/packages/harnesses/src/writers/windsurf.ts +++ b/packages/harnesses/src/writers/windsurf.ts @@ -1,7 +1,12 @@ import { join } from "node:path"; import type { LogicalConfig } from "@ade/core"; import type { HarnessWriter } from "../types.js"; -import { writeMcpServers, alwaysAllowEntry, writeRulesFile } from "../util.js"; +import { + writeMcpServers, + alwaysAllowEntry, + writeRulesFile, + writeGitHooks +} from "../util.js"; export const windsurfWriter: HarnessWriter = { id: "windsurf", @@ -17,5 +22,6 @@ export const windsurfWriter: HarnessWriter = { config.instructions, join(projectRoot, ".windsurfrules") ); + await writeGitHooks(config.git_hooks, projectRoot); } };