# Commit-message CLI Parity Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Align host-agent `commitMessage.*` (protocol, pattern, structure, skill, hybridGenerate) with `smart-commit-cli` 0.1.21, without correction repair turns and without backward compatibility for `custom` or the old default `conventional`.

**Architecture:** Surgical port into existing `src/commitMessage/*` and `src/config/*`. Copy CLI `git-commit-message-skills` and `promptContext` (commit-message category only). Keep a single `purpose: "commit-message"` turn. Do not extract a shared package. Do not add CLI flag/env overlay.

**Tech Stack:** TypeScript 5.6, Node 20+, `node:test`, existing HostAgentClient / SessionStore.

**Spec:** `docs/superpowers/specs/2026-08-14-commit-message-cli-parity-design.md`  
**Reference CLI (read-only):** `/Users/nietao/VSCode-plugins/smart-commit-cli` @ package `peerReference.cliVersion`

**Global constraints:**
- Do not modify `smart-commit-cli`
- Zero LLM HTTP; no correction repair turns
- No `custom` protocol alias; no migration layer
- Do not add `--validation-protocol` / `SMART_COMMIT_*` commit-message env overlays
- Do not port `review.skill`

---

## File structure

| Path | Responsibility |
|------|----------------|
| `src/config/constants.ts` | Add `CommitMessageProtocol`, `CommitMessageValidationProtocol`, `CommitMessageStructure`, `builtinSkillIds` (git-commit-message only), parsers/formatters |
| `src/config/schema.ts` | Types, `parseCommitMessageValidationProtocol`, `parseCommitMessageStructure`, `assertValidRegexPattern`, `resolveBuiltinSkillId`, `validateHostAgentConfig` checks |
| `src/config/defaults.ts` | CLI-aligned `commitMessage` defaults |
| `src/config/load.ts` | Parse `maxDiffChars` / `structure` / `hybridGenerate` / `skill`; trim `pattern`; nested `skill` merge |
| `src/commitMessage/protocol.ts` | Replace with CLI protocol (HostAgentConfig), including structure + language + gitmoji/semantic |
| `src/commitMessage/prompt.ts` | Port CLI generate prompt (no repair); structure-aware `responseSchema` |
| `src/promptContext.ts` | Port CLI `buildPromptAugmentation` for git-commit-message |
| `src/git-commit-message-skills/**` | Copied bundled skills |
| `src/commitMessage/hostAgentCommitMessage.ts` | provided / hybrid / generated; inject skill prompt |
| `src/commands/commitMessageGenerate.ts` | Truncate with `commitMessage.maxDiffChars`; map hybrid `status` |
| `src/commands/bridge.ts` | Separate commit-message vs review truncation |
| `package.json` | Ship `src/git-commit-message-skills` |
| Tests | `configResolve`, `commitMessage`, `promptContext`, `commitMessageGenerate` |
| Docs | configuration, parity-matrix, README |

---

### Task 1: Config contract (types, defaults, parse, validate)

**Files:**
- Modify: `src/config/constants.ts`, `src/config/schema.ts`, `src/config/defaults.ts`, `src/config/load.ts`
- Test: `src/test/configResolve.test.ts`, `src/test/commitMessage.test.ts`

- [ ] **Step 1: Write failing config tests** in `src/test/configResolve.test.ts`

```ts
test("commitMessage defaults match CLI (protocol none, structure subjectOnly, skill conventional)", () => {
  const resolved = resolveHostAgentConfig({ env: {} });
  assert.equal(resolved.config.commitMessage.validation.protocol, "none");
  assert.equal(resolved.config.commitMessage.validation.pattern, "");
  assert.equal(resolved.config.commitMessage.structure, "subjectOnly");
  assert.equal(resolved.config.commitMessage.hybridGenerate, false);
  assert.equal(resolved.config.commitMessage.maxDiffChars, 150000);
  assert.equal(resolved.config.commitMessage.skill.id, "conventional");
  assert.equal(resolved.config.commitMessage.skill.path, "");
  assert.equal(resolved.config.commitMessage.skill.promptTuning, "");
});

test("commitMessage.validation.protocol accepts none, conventional, semantic, gitmoji and normalizes case", () => {
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), "scha-cfg-"));
  const configPath = path.join(dir, "cfg.json");
  fs.writeFileSync(
    configPath,
    JSON.stringify({
      smartCommitHostAgent: {
        commitMessage: { validation: { protocol: " Gitmoji " } }
      }
    }),
    "utf8"
  );
  const resolved = resolveHostAgentConfig({ configPath, env: {} });
  assert.equal(resolved.config.commitMessage.validation.protocol, "gitmoji");
});

test("commitMessage.validation.protocol empty string becomes none", () => {
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), "scha-cfg-"));
  const configPath = path.join(dir, "cfg.json");
  fs.writeFileSync(
    configPath,
    JSON.stringify({
      smartCommitHostAgent: {
        commitMessage: { validation: { protocol: "" } }
      }
    }),
    "utf8"
  );
  const resolved = resolveHostAgentConfig({ configPath, env: {} });
  assert.equal(resolved.config.commitMessage.validation.protocol, "none");
});

test("commitMessage.validation.protocol rejects custom", () => {
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), "scha-cfg-"));
  const configPath = path.join(dir, "cfg.json");
  fs.writeFileSync(
    configPath,
    JSON.stringify({
      smartCommitHostAgent: {
        commitMessage: { validation: { protocol: "custom" } }
      }
    }),
    "utf8"
  );
  assert.throws(
    () => resolveHostAgentConfig({ configPath, env: {} }),
    /must be one of: none, conventional, semantic, gitmoji/
  );
});

test("commitMessage.validation.pattern is trimmed and invalid regex is rejected", () => {
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), "scha-cfg-"));
  const okPath = path.join(dir, "ok.json");
  fs.writeFileSync(
    okPath,
    JSON.stringify({
      smartCommitHostAgent: {
        commitMessage: { validation: { pattern: "  ^feat:  " } }
      }
    }),
    "utf8"
  );
  assert.equal(
    resolveHostAgentConfig({ configPath: okPath, env: {} }).config.commitMessage.validation.pattern,
    "^feat:"
  );

  const badPath = path.join(dir, "bad.json");
  fs.writeFileSync(
    badPath,
    JSON.stringify({
      smartCommitHostAgent: {
        commitMessage: { validation: { pattern: "(" } }
      }
    }),
    "utf8"
  );
  assert.throws(
    () => resolveHostAgentConfig({ configPath: badPath, env: {} }),
    /must be a valid JavaScript RegExp pattern/
  );
});

test("commitMessage.skill.id must be builtin when path is empty", () => {
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), "scha-cfg-"));
  const configPath = path.join(dir, "cfg.json");
  fs.writeFileSync(
    configPath,
    JSON.stringify({
      smartCommitHostAgent: {
        commitMessage: { skill: { id: "not-a-skill" } }
      }
    }),
    "utf8"
  );
  assert.throws(
    () => resolveHostAgentConfig({ configPath, env: {} }),
    /commitMessage\.skill\.id must be one of: conventional, semantic, gitmoji/
  );
});
```

- [ ] **Step 2: Run the new tests and confirm they fail**

Run: `npm test -- --test-name-pattern="commitMessage defaults match CLI|protocol accepts none|protocol empty string|rejects custom|pattern is trimmed|skill.id must be builtin"`

Expected: FAIL (missing fields / old enum / old default `conventional`).

- [ ] **Step 3: Extend `src/config/constants.ts`**

Add after `OutputLanguage`:

```ts
export type CommitMessageLanguage = OutputLanguage;
export type CommitMessageProtocol = "conventional" | "gitmoji" | "semantic";
export type CommitMessageValidationProtocol = "none" | CommitMessageProtocol;
export type CommitMessageStructure = "subjectOnly" | "subjectBody" | "subjectBodyFooter";
export type BuiltinSkillCategory = "git-commit-message";

export const builtinSkillIds: Record<BuiltinSkillCategory, readonly string[]> = {
  "git-commit-message": ["conventional", "semantic", "gitmoji"]
};

export function isCommitMessageProtocol(value: string): value is CommitMessageProtocol {
  return value === "conventional" || value === "semantic" || value === "gitmoji";
}

export function isCommitMessageStructure(value: string): value is CommitMessageStructure {
  return value === "subjectOnly" || value === "subjectBody" || value === "subjectBodyFooter";
}

export function formatSupportedCommitMessageStructures(): string {
  return "subjectOnly, subjectBody, subjectBodyFooter";
}
```

Do not add CLI's `code-review` skill ids.

- [ ] **Step 4: Update `src/config/schema.ts`**

1. Import the new types/helpers from `./constants`. Re-export `CommitMessageValidationProtocol`, `CommitMessageStructure`, `CommitMessageProtocol`.
2. Change `HostAgentConfig.commitMessage` to:

```ts
commitMessage: {
  language: OutputLanguage;
  input: string;
  maxDiffChars: number;
  structure: CommitMessageStructure;
  autoGenerate: boolean;
  hybridGenerate: boolean;
  skill: {
    id: string;
    path: string;
    promptTuning: string;
  };
  validation: {
    protocol: CommitMessageValidationProtocol;
    pattern: string;
    extractTicketIdFromBranch: boolean;
    requireTicketIdInMessage: boolean;
  };
};
```

3. Replace `parseCommitMessageValidationProtocol` with the CLI version:

```ts
export function parseCommitMessageValidationProtocol(
  value: string,
  sourceLabel: string
): CommitMessageValidationProtocol {
  const normalized = value.trim().toLowerCase();
  if (!normalized || normalized === "none") {
    return "none";
  }
  if (!isCommitMessageProtocol(normalized)) {
    throw new Error(`${sourceLabel} must be one of: none, conventional, semantic, gitmoji.`);
  }
  return normalized;
}
```

4. Add `parseCommitMessageStructure`, `assertValidRegexPattern`, and `resolveBuiltinSkillId` by copying the CLI functions from `/Users/nietao/VSCode-plugins/smart-commit-cli/src/config/schema.ts` (`parseCommitMessageStructure`, `assertValidRegexPattern`, `resolveBuiltinSkillId`). `resolveBuiltinSkillId` uses `builtinSkillIds` from constants (git-commit-message only).

5. In `validateHostAgentConfig`, add:

```ts
if (config.commitMessage.maxDiffChars < 1000) {
  throw new Error("commitMessage.maxDiffChars must be greater than or equal to 1000.");
}
parseOutputLanguage(config.commitMessage.language, "commitMessage.language");
parseCommitMessageStructure(config.commitMessage.structure, "commitMessage.structure");
parseCommitMessageValidationProtocol(
  config.commitMessage.validation.protocol,
  "commitMessage.validation.protocol"
);
assertValidRegexPattern(config.commitMessage.validation.pattern, "commitMessage.validation.pattern");
if (!config.commitMessage.skill.path) {
  resolveBuiltinSkillId(config.commitMessage.skill.id, "git-commit-message", "commitMessage.skill.id");
}
```

- [ ] **Step 5: Update defaults and load/merge**

`src/config/defaults.ts` `commitMessage`:

```ts
commitMessage: {
  language: "zh-cn",
  input: "",
  maxDiffChars: 150_000,
  structure: "subjectOnly",
  autoGenerate: true,
  hybridGenerate: false,
  skill: {
    id: "conventional",
    path: "",
    promptTuning: ""
  },
  validation: {
    protocol: "none",
    pattern: "",
    extractTicketIdFromBranch: true,
    requireTicketIdInMessage: false
  }
}
```

In `src/config/load.ts` `parseCanonicalHostAgentConfig` `commitMessage` block, parse the new fields like CLI `file.ts`:

- `language` via `parseOutputLanguage(parseString(...), ...)`
- `maxDiffChars` via `parseFiniteNumber`
- `structure` via `parseCommitMessageStructure`
- `hybridGenerate` via `parseBoolean`
- `skill` via a local `parseSkillConfig` copied from CLI `file.ts` (`id`/`path`/`promptTuning`, each trimmed)
- `validation.protocol` via `parseCommitMessageValidationProtocol(parseString(...), ...)` — do **not** pre-trim; the parser trims
- `validation.pattern` via `parseString(...).trim()`

In `mergeHostAgentConfig`, nest-merge `skill` the same way as `validation`:

```ts
commitMessage: {
  ...base.commitMessage,
  ...override.commitMessage,
  skill: {
    ...base.commitMessage.skill,
    ...override.commitMessage?.skill
  },
  validation: {
    ...base.commitMessage.validation,
    ...override.commitMessage?.validation
  }
}
```

- [ ] **Step 6: Keep `npm test` green after the default change**

In `src/test/commitMessage.test.ts`, the test `validateAndFinalizeCommitMessage rejects unknown conventional type` currently uses default config. After this task, default protocol is `none`, so that test would stop throwing. Update it to set protocol explicitly:

```ts
const config = createDefaultHostAgentConfig();
config.commitMessage.validation.protocol = "conventional";
config.commitMessage.language = "en";
```

Leave protocol.ts behavior otherwise unchanged until Task 2. After the protocol type drops `custom`, `src/commitMessage/prompt.ts` will not typecheck. Update the label mapping now so `tsc` stays green:

```ts
const protocolLabel =
  input.protocol === "conventional"
    ? "Conventional Commits"
    : input.protocol === "semantic"
      ? "Semantic Commits"
      : input.protocol === "gitmoji"
        ? "Gitmoji"
        : "none";
```

Run: `npm test`  
Expected: PASS (new config tests pass; existing tests pass).

- [ ] **Step 7: Commit**

```bash
git add src/config/constants.ts src/config/schema.ts src/config/defaults.ts src/config/load.ts src/commitMessage/prompt.ts src/test/configResolve.test.ts src/test/commitMessage.test.ts
git commit -m "feat: align commitMessage config contract with CLI"
```

---

### Task 2: Port commit-message protocol validation

**Files:**
- Modify: `src/commitMessage/protocol.ts`
- Test: `src/test/commitMessage.test.ts`

- [ ] **Step 1: Add failing protocol tests** to `src/test/commitMessage.test.ts`

Keep existing ticket-injection and empty-message tests. Add/replace:

```ts
function withCommitMessage(
  config: ReturnType<typeof createDefaultHostAgentConfig>,
  patch: Partial<ReturnType<typeof createDefaultHostAgentConfig>["commitMessage"]> & {
    validation?: Partial<ReturnType<typeof createDefaultHostAgentConfig>["commitMessage"]["validation"]>;
    structure?: ReturnType<typeof createDefaultHostAgentConfig>["commitMessage"]["structure"];
  }
) {
  return {
    ...config,
    commitMessage: {
      ...config.commitMessage,
      ...patch,
      validation: {
        ...config.commitMessage.validation,
        ...patch.validation
      }
    }
  };
}

test("default protocol none accepts non-conventional subject", () => {
  const message = validateAndFinalizeCommitMessage({
    rawCommitMessage: "add login without type",
    config: createDefaultHostAgentConfig(),
    branchName: undefined
  });
  assert.equal(message, "add login without type");
});

test("conventional rejects unknown type and English-only summary when language is zh-cn", () => {
  const config = withCommitMessage(createDefaultHostAgentConfig(), {
    language: "zh-cn",
    validation: { protocol: "conventional" }
  });
  assert.throws(
    () =>
      validateAndFinalizeCommitMessage({
        rawCommitMessage: "banana: nope",
        config,
        branchName: undefined
      }),
    (error: unknown) => error instanceof CommitMessageFlowError && error.code === "COMMIT_MESSAGE_INVALID"
  );
  assert.throws(
    () =>
      validateAndFinalizeCommitMessage({
        rawCommitMessage: "feat: add login",
        config,
        branchName: undefined
      }),
    /must contain Chinese text/
  );
});

test("semantic accepts non-whitelist type", () => {
  const config = withCommitMessage(createDefaultHostAgentConfig(), {
    language: "en",
    validation: { protocol: "semantic" }
  });
  assert.equal(
    validateAndFinalizeCommitMessage({
      rawCommitMessage: "update: refresh cache",
      config,
      branchName: undefined
    }),
    "update: refresh cache"
  );
});

test("gitmoji validates emoji subject and language", () => {
  const enConfig = withCommitMessage(createDefaultHostAgentConfig(), {
    language: "en",
    validation: { protocol: "gitmoji" }
  });
  assert.equal(
    validateAndFinalizeCommitMessage({
      rawCommitMessage: "✨ add login",
      config: enConfig,
      branchName: undefined
    }),
    "✨ add login"
  );
  const zhConfig = withCommitMessage(createDefaultHostAgentConfig(), {
    language: "zh-cn",
    validation: { protocol: "gitmoji" }
  });
  assert.throws(
    () =>
      validateAndFinalizeCommitMessage({
        rawCommitMessage: "✨ add login",
        config: zhConfig,
        branchName: undefined
      }),
    /must contain Chinese text/
  );
});

test("subjectOnly rejects extra lines; subjectBody keeps body; ticket injection preserves body", () => {
  const subjectOnly = createDefaultHostAgentConfig();
  assert.throws(
    () =>
      validateAndFinalizeCommitMessage({
        rawCommitMessage: "feat: add login\n\nextra body",
        config: subjectOnly,
        branchName: undefined
      }),
    /exactly one non-empty line/
  );

  const subjectBody = withCommitMessage(createDefaultHostAgentConfig(), {
    language: "zh-cn",
    structure: "subjectBody",
    validation: { protocol: "conventional", requireTicketIdInMessage: true }
  });
  assert.equal(
    validateAndFinalizeCommitMessage({
      rawCommitMessage: "feat: 新增登录流程\n\n补充登录状态同步的提交背景",
      config: subjectBody,
      branchName: "feature/PROJ-12"
    }),
    "feat: PROJ-12 新增登录流程\n\n补充登录状态同步的提交背景"
  );
});

test("pattern is applied independently of protocol", () => {
  const config = withCommitMessage(createDefaultHostAgentConfig(), {
    language: "en",
    validation: { protocol: "conventional", pattern: "^feat:\\s.+$" }
  });
  assert.equal(
    validateAndFinalizeCommitMessage({
      rawCommitMessage: "feat: add login flow",
      config,
      branchName: undefined
    }),
    "feat: add login flow"
  );
  assert.throws(
    () =>
      validateAndFinalizeCommitMessage({
        rawCommitMessage: "fix: add login flow",
        config,
        branchName: undefined
      }),
    /does not match the configured validation pattern/
  );
});
```

- [ ] **Step 2: Run tests; expect FAIL**

Run: `npm test -- --test-name-pattern="default protocol none|conventional rejects unknown|semantic accepts|gitmoji validates|subjectOnly rejects|pattern is applied"`

Expected: FAIL (current protocol.ts has no semantic/gitmoji/language/structure).

- [ ] **Step 3: Replace `src/commitMessage/protocol.ts` from CLI**

Copy `/Users/nietao/VSCode-plugins/smart-commit-cli/src/commitMessage/protocol.ts` over `src/commitMessage/protocol.ts`.

Then adapt imports only:

- Change `import { CliConfig, CommitMessageLanguage, CommitMessageStructure, OutputLanguage } from "../config";` to:

```ts
import type { HostAgentConfig } from "../config/schema";
import type { CommitMessageLanguage, CommitMessageStructure, OutputLanguage } from "../config/constants";
```

- Change `config: CliConfig` to `config: HostAgentConfig` in `validateAndFinalizeCommitMessage`.
- Change `protocol: CliConfig["commitMessage"]["validation"]["protocol"]` to `HostAgentConfig["commitMessage"]["validation"]["protocol"]`.

Keep every validator (`validateCommitMessageStructure`, `validateCommitMessageProtocol`, `validateTypedCommitSubject`, `validateGitmojiSubject`, `validateLanguageText`, section parsing, gitmoji ticket injection). Do not add repair logic.

Update `ensureTicketIdFromBranchIfMissing` tests: CLI version preserves body/footer. Existing test `ensureTicketIdFromBranchIfMissing inserts ticket into conventional subject` should still pass for a single-line subject.

- [ ] **Step 4: Run tests**

Run: `npm test -- --test-name-pattern="commitMessage|ensureTicketId|validateAndFinalize"`

Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add src/commitMessage/protocol.ts src/test/commitMessage.test.ts
git commit -m "feat: port CLI commit-message protocol validation"
```

---

### Task 3: Port generate prompt (no repair)

**Files:**
- Modify: `src/commitMessage/prompt.ts`
- Test: `src/test/commitMessage.test.ts`

- [ ] **Step 1: Failing prompt tests**

Replace `buildCommitMessageMessages includes diff and protocol` and add:

```ts
test("buildCommitMessageMessages includes diff, structure, protocol, and optional draft", () => {
  const messages = buildCommitMessageMessages({
    repositoryPath: "/tmp/repo",
    branchName: "feature/x",
    diff: "diff --git a/a.ts",
    changedFiles: ["a.ts"],
    language: "zh-cn",
    structure: "subjectOnly",
    protocol: "conventional"
  });
  assert.equal(messages.length, 2);
  assert.match(messages[1]!.content, /a\.ts/);
  assert.match(messages[0]!.content, /Conventional Commits/);
  assert.match(messages[1]!.content, /Commit message structure: subjectOnly/);
  assert.match(messages[1]!.content, /single-line Conventional Commits subject/);
});

test("buildCommitMessageMessages uses Gitmoji and Semantic labels and includes userDraft", () => {
  const gitmoji = buildCommitMessageMessages({
    repositoryPath: "/tmp/repo",
    diff: "diff",
    changedFiles: ["a.ts"],
    language: "en",
    structure: "subjectBody",
    protocol: "gitmoji",
    userDraft: "wip login"
  });
  assert.match(gitmoji[0]!.content, /Gitmoji/);
  assert.match(gitmoji[1]!.content, /User draft:/);
  assert.match(gitmoji[1]!.content, /wip login/);
  assert.match(gitmoji[1]!.content, /subject plus optional body/);

  const semantic = buildCommitMessageMessages({
    repositoryPath: "/tmp/repo",
    diff: "diff",
    changedFiles: ["a.ts"],
    language: "en",
    structure: "subjectBodyFooter",
    protocol: "semantic"
  });
  assert.match(semantic[0]!.content, /Semantic Commits/);
  assert.match(buildCommitMessageResponseSchema("subjectOnly"), /single-line git commit subject/);
  assert.doesNotMatch(buildCommitMessageResponseSchema("subjectOnly"), /Conventional Commits/);
});

test("buildCommitMessageMessages appends promptAugmentation to system", () => {
  const messages = buildCommitMessageMessages(
    {
      repositoryPath: "/repo",
      diff: "diff",
      changedFiles: ["file"],
      language: "zh-cn",
      structure: "subjectOnly",
      protocol: "none"
    },
    { promptAugmentation: ["Additional commit guidance."] }
  );
  assert.match(messages[0]!.content, /Additional commit guidance/);
});
```

- [ ] **Step 2: Run tests; expect FAIL**

Run: `npm test -- --test-name-pattern="buildCommitMessageMessages"`

Expected: FAIL (`structure` / `userDraft` / `promptAugmentation` missing).

- [ ] **Step 3: Port prompt helpers from CLI**

Copy generate-side helpers from `/Users/nietao/VSCode-plugins/smart-commit-cli/src/commitMessage/prompt.ts`:

- `CommitMessageGenerationInput` — add `structure` (required in host-agent, do not leave optional) and `userDraft?`
- `buildCommitMessageMessages(input, options?: { promptAugmentation?: readonly string[] })` returning `HostAgentChatMessage[]`
- `formatCommitMessageProtocolLabel`, `buildCommitMessageOutputRules`, `buildCommitMessageSystemIntro`, `buildCommitMessageStructureRules`

Do **not** copy `buildCommitMessageRepairMessages` or repair intros.

Replace the constant `COMMIT_MESSAGE_RESPONSE_SCHEMA` with:

```ts
export function buildCommitMessageResponseSchema(
  structure: HostAgentConfig["commitMessage"]["structure"]
): string {
  if (structure === "subjectOnly") {
    return "Plain text only: a single-line git commit subject. No markdown, JSON, or explanations.";
  }
  if (structure === "subjectBody") {
    return "Plain text only: a git commit message with a subject and optional body. No markdown fences, JSON, or explanations.";
  }
  return "Plain text only: a git commit message with a subject, optional body, and optional footer. No markdown fences, JSON, or explanations.";
}
```

Temporarily keep `COMMIT_MESSAGE_RESPONSE_SCHEMA = buildCommitMessageResponseSchema("subjectOnly")` only if another file still imports the constant; Task 5 will switch call sites to the builder. Prefer updating `hostAgentCommitMessage.ts` in this task to compile:

```ts
responseSchema: buildCommitMessageResponseSchema(input.config.commitMessage.structure)
```

and pass `structure: input.config.commitMessage.structure` into `buildCommitMessageMessages`. Hybrid/userDraft/skill injection wait for Task 5.

- [ ] **Step 4: `npm test` green; commit**

```bash
git add src/commitMessage/prompt.ts src/commitMessage/hostAgentCommitMessage.ts src/test/commitMessage.test.ts
git commit -m "feat: port CLI commit-message generate prompts"
```

---

### Task 4: Bundled git-commit-message skills + promptContext

**Files:**
- Create: `src/promptContext.ts`
- Create: `src/git-commit-message-skills/**` (copy from CLI)
- Modify: `package.json` (`files` array)
- Test: `src/test/promptContext.test.ts`

- [ ] **Step 1: Copy skill files**

```bash
mkdir -p src/git-commit-message-skills
cp -R /Users/nietao/VSCode-plugins/smart-commit-cli/src/git-commit-message-skills/. src/git-commit-message-skills/
```

Confirm these exist:

- `src/git-commit-message-skills/conventional/SKILL.md`
- `src/git-commit-message-skills/conventional/references/examples.md`
- `src/git-commit-message-skills/semantic/SKILL.md`
- `src/git-commit-message-skills/semantic/references/examples.md`
- `src/git-commit-message-skills/gitmoji/SKILL.md`
- `src/git-commit-message-skills/gitmoji/references/examples.md`

Add `"src/git-commit-message-skills"` to `package.json` `files` (tsc does not copy markdown).

- [ ] **Step 2: Write failing tests** in `src/test/promptContext.test.ts`

```ts
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import { buildPromptAugmentation } from "../promptContext";

test("buildPromptAugmentation loads bundled conventional commit-message skill", () => {
  const lines = buildPromptAugmentation({
    repositoryPath: "/repo",
    skill: { id: "conventional", path: "", promptTuning: "" },
    categoryLabel: "commit-message skill",
    builtinCategory: "git-commit-message"
  });
  const rendered = lines.join("\n\n");
  assert.match(rendered, /Selected commit-message skill profile: conventional\./);
  assert.match(rendered, /Bundled commit-message skill instructions:/);
  assert.match(rendered, /Subject template:/);
  assert.match(rendered, /Bundled commit-message skill reference \(examples\.md\):/);
});

test("buildPromptAugmentation loads custom skill file and promptTuning", () => {
  const repositoryPath = fs.mkdtempSync(path.join(os.tmpdir(), "scha-skill-"));
  fs.writeFileSync(path.join(repositoryPath, "commit-skill.txt"), "Prefer ticket ids.", "utf8");
  const lines = buildPromptAugmentation({
    repositoryPath,
    skill: { id: "ignored", path: "commit-skill.txt", promptTuning: "Keep it short." },
    categoryLabel: "commit-message skill",
    builtinCategory: "git-commit-message"
  });
  assert.match(lines[0] ?? "", /Prefer ticket ids/);
  assert.match(lines[1] ?? "", /Keep it short/);
});
```

- [ ] **Step 3: Run tests; expect FAIL**

Run: `npm test -- --test-name-pattern="buildPromptAugmentation"`

Expected: FAIL (module not found).

- [ ] **Step 4: Port `src/promptContext.ts` from CLI**

Copy `/Users/nietao/VSCode-plugins/smart-commit-cli/src/promptContext.ts`.

Adapt:

- `BuiltinSkillCategory` import from `./config/constants` (only `"git-commit-message"` exists).
- In `loadBuiltinSkillPromptBundle` / `resolveBundledSkillDirectory`, keep the same three candidate paths CLI uses (`__dirname/<root>/<id>`, `__dirname/../src/<root>/<id>`, `cwd/src/<root>/<id>`).
- Because host-agent has no `code-review-skills`, only resolve `git-commit-message-skills`. Do not keep a code path that looks for `code-review-skills`.

- [ ] **Step 5: `npm test` green; commit**

```bash
git add src/promptContext.ts src/git-commit-message-skills package.json src/test/promptContext.test.ts
git commit -m "feat: add bundled git-commit-message skills and promptContext"
```

---

### Task 5: Resolve flow (hybrid) + command wiring

**Files:**
- Modify: `src/commitMessage/hostAgentCommitMessage.ts`, `src/commands/commitMessageGenerate.ts`, `src/commands/bridge.ts`
- Test: `src/test/commitMessageGenerate.test.ts` (and a unit test in `src/test/commitMessage.test.ts` if you mock the client)

- [ ] **Step 1: Failing tests**

In `src/test/commitMessage.test.ts`, add a mock-client unit test (do not go through git):

```ts
test("resolveHostAgentCommitMessage uses provided input without a turn", async () => {
  const config = createDefaultHostAgentConfig();
  config.commitMessage.input = "feat: from config";
  const result = await resolveHostAgentCommitMessage({
    client: {
      complete: async () => {
        throw new Error("complete must not be called");
      },
      review: async () => {
        throw new Error("review must not be called");
      }
    },
    config,
    repositoryPath: "/repo",
    branchName: "main",
    diff: "diff",
    changedFiles: ["a.ts"]
  });
  assert.equal(result.source, "provided");
  assert.equal(result.message, "feat: from config");
});

test("resolveHostAgentCommitMessage hybrid sends userDraft and returns source hybrid", async () => {
  const config = createDefaultHostAgentConfig();
  config.commitMessage.input = "wip login";
  config.commitMessage.hybridGenerate = true;
  config.commitMessage.language = "en";
  let seen = "";
  const result = await resolveHostAgentCommitMessage({
    client: {
      complete: async (messages) => {
        seen = messages.map((message) => message.content).join("\n");
        return "feat: add login";
      },
      review: async () => {
        throw new Error("review must not be called");
      }
    },
    config,
    repositoryPath: "/repo",
    branchName: "main",
    diff: "diff",
    changedFiles: ["a.ts"]
  });
  assert.equal(result.source, "hybrid");
  assert.equal(result.message, "feat: add login");
  assert.match(seen, /User draft:/);
  assert.match(seen, /wip login/);
  assert.match(seen, /Bundled commit-message skill instructions:/);
});
```

In `src/test/commitMessageGenerate.test.ts`, add:

```ts
test("commit-message generate hybrid needs host agent then source hybrid", async () => {
  const repo = initRepoWithStagedChange();
  const sessionBase = fs.mkdtempSync(path.join(os.tmpdir(), "scha-cm-hyb-"));
  const configPath = path.join(sessionBase, "cfg.json");
  fs.writeFileSync(
    configPath,
    JSON.stringify({
      smartCommitHostAgent: {
        commitMessage: {
          autoGenerate: true,
          hybridGenerate: true,
          input: "wip login",
          language: "en"
        }
      }
    }),
    "utf8"
  );

  const first = await runCommitMessageGenerateCommand(
    ["--repo", repo, "--config", configPath, "--session-base", sessionBase, "--output", "json"],
    {}
  );
  assert.equal(first.exitCode, EXIT_CODE_NEEDS_HOST_AGENT);
  assert.equal(first.payload.purpose, "commit-message");

  writeTurnResponse(first.payload.sessionPath!, first.payload.turnId!, "feat: add login from draft");

  const second = await runCommitMessageGenerateCommand(
    ["--repo", repo, "--config", configPath, "--session", first.payload.sessionPath!, "--output", "json"],
    {}
  );
  assert.equal(second.exitCode, EXIT_CODE_SUCCESS);
  assert.equal(second.payload.status, "generated");
  assert.equal(second.payload.commitMessageSource, "hybrid");
  assert.equal(second.payload.commitMessage, "feat: add login from draft");
});
```

- [ ] **Step 2: Run tests; expect FAIL**

Run: `npm test -- --test-name-pattern="hybrid|uses provided input without a turn"`

Expected: FAIL (hybrid currently treated as provided; no userDraft).

- [ ] **Step 3: Implement `resolveHostAgentCommitMessage`**

```ts
import { buildPromptAugmentation } from "../promptContext";
import { buildCommitMessageResponseSchema, buildCommitMessageMessages } from "./prompt";

export interface CommitMessageResolutionResult {
  message: string;
  source: "provided" | "generated" | "hybrid";
}

function buildGenerationMessages(input: {
  config: HostAgentConfig;
  repositoryPath: string;
  branchName?: string;
  diff: string;
  changedFiles: string[];
  userDraft?: string;
}) {
  return buildCommitMessageMessages(
    {
      repositoryPath: input.repositoryPath,
      branchName: input.branchName,
      diff: input.diff,
      changedFiles: input.changedFiles,
      language: input.config.commitMessage.language,
      structure: input.config.commitMessage.structure,
      protocol: input.config.commitMessage.validation.protocol,
      ...(input.userDraft ? { userDraft: input.userDraft } : {})
    },
    {
      promptAugmentation: buildPromptAugmentation({
        repositoryPath: input.repositoryPath,
        skill: input.config.commitMessage.skill,
        categoryLabel: "commit-message skill",
        builtinCategory: "git-commit-message"
      })
    }
  );
}

async function generateOnce(
  input: Parameters<typeof resolveHostAgentCommitMessage>[0],
  userDraft: string | undefined,
  source: "generated" | "hybrid"
): Promise<CommitMessageResolutionResult> {
  const messages = buildGenerationMessages({ ...input, userDraft });
  const raw = await input.client.complete(messages, {
    purpose: "commit-message",
    responseSchema: buildCommitMessageResponseSchema(input.config.commitMessage.structure),
    attempt: 0
  });
  return {
    message: validateAndFinalizeCommitMessage({
      rawCommitMessage: raw,
      config: input.config,
      branchName: input.branchName
    }),
    source
  };
}

export async function resolveHostAgentCommitMessage(...): Promise<CommitMessageResolutionResult> {
  const initial = (input.providedInput ?? input.config.commitMessage.input).trim();

  if (initial && !input.config.commitMessage.hybridGenerate) {
    return {
      message: validateAndFinalizeCommitMessage({
        rawCommitMessage: initial,
        config: input.config,
        branchName: input.branchName
      }),
      source: "provided"
    };
  }

  if (initial && input.config.commitMessage.hybridGenerate) {
    return generateOnce(input, initial, "hybrid");
  }

  if (!input.config.commitMessage.autoGenerate) {
    throw new CommitMessageFlowError(
      "COMMIT_MESSAGE_REQUIRED",
      "Commit message is empty and auto-generation is disabled."
    );
  }

  return generateOnce(input, undefined, "generated");
}
```

On validation failure of a generated/hybrid response, throw `COMMIT_MESSAGE_INVALID` immediately. Do not write a second turn.

- [ ] **Step 4: Wire commands**

In `src/commands/commitMessageGenerate.ts`:

- Truncate with `config.commitMessage.maxDiffChars` (not `review.maxDiffChars`).
- Map status:

```ts
status: resolved.source === "provided" ? "provided" : "generated",
commitMessageSource: resolved.source,
summary: `Commit message ${resolved.source === "provided" ? "resolved" : "generated"} successfully.`
```

Do not add `"hybrid"` to the `status` union.

In `src/commands/bridge.ts` `runFullBridge`, after `prepareStagedDiff`:

```ts
const commitDiff = truncateDiffForHostAgent(prepared.diff, config.commitMessage.maxDiffChars);
reviewDiff = truncateDiffForHostAgent(prepared.diff, config.review.maxDiffChars);
```

Pass `diff: commitDiff` into `resolveHostAgentCommitMessage`. Keep `reviewDiff` for the review turn.

`mergeBridgeState` already accepts `commitMessageSource: "hybrid"`. No schema change required there.

- [ ] **Step 5: `npm test` green; commit**

Run: `npm test`  
Expected: PASS.

```bash
git add src/commitMessage/hostAgentCommitMessage.ts src/commands/commitMessageGenerate.ts src/commands/bridge.ts src/test/commitMessage.test.ts src/test/commitMessageGenerate.test.ts
git commit -m "feat: add hybrid commit-message generation without repair turns"
```

---

### Task 6: Docs and examples

**Files:**
- Modify: `docs/configuration.md`, `docs/parity-matrix.md`, `README.md`
- Modify `docs/contracts.md` only if it lists protocol values (it currently does not; skip if unchanged)

- [ ] **Step 1: Update `docs/configuration.md`**

Replace the `commitMessage.*` table so it matches CLI semantics:

| Field | Built-in default | Recommended first value | When to change it | Common mistake |
| --- | --- | --- | --- | --- |
| `commitMessage.input` | empty string | Leave empty first | Set it when you already know the exact message | Setting it and expecting auto-generation to replace it |
| `commitMessage.language` | `zh-cn` | `zh-cn` or `en` | Match the team's commit language | Mixing English convention with a non-English expectation |
| `commitMessage.maxDiffChars` | `150000` | Leave default first | Lower it if commit-message turns are too large | Setting it below `1000` |
| `commitMessage.structure` | `subjectOnly` | `subjectOnly` | Change when the team wants body/footer | Expecting extra lines while `subjectOnly` is active |
| `commitMessage.autoGenerate` | `true` | `true` | Set `false` only if users always provide a message | Turning it off with no provided message |
| `commitMessage.hybridGenerate` | `false` | `false` | Set `true` when a draft should be refined by a turn | Expecting hybrid without `commitMessage.input` / `--commit-message` |
| `commitMessage.skill.id` | `conventional` | `conventional` | Change when you prefer semantic or gitmoji guidance | Assuming skill id validates the subject; `validation.protocol` does |
| `commitMessage.skill.path` | empty string | Leave empty first | Set a custom policy file | Empty custom file |
| `commitMessage.skill.promptTuning` | empty string | Leave empty first | Short extra guidance | Putting a handbook into one string |
| `commitMessage.validation.protocol` | `none` | `none` first, then `conventional` if the team standardizes on it | When the team enforces a commit style | Enabling validation before generation matches that style |
| `commitMessage.validation.pattern` | empty string | Leave empty first | Extra JavaScript regex on the subject | Invalid regex |
| `commitMessage.validation.extractTicketIdFromBranch` | `true` | `true` if branches contain ticket ids | Set `false` if they do not | Expecting extraction from branches with no ticket ids |
| `commitMessage.validation.requireTicketIdInMessage` | `false` | `false` first | Set `true` only if process requires ticket ids | Enforcing it before branch naming is aligned |

Add the hybrid explanation (same four bullets as CLI). List structures and protocols:

- structures: `subjectOnly`, `subjectBody`, `subjectBodyFooter`
- protocols: `none`, `conventional`, `semantic`, `gitmoji`
- builtin skill ids: `conventional`, `semantic`, `gitmoji`

Remove every mention of protocol `custom`.

Update the later "Commit-message validation protocol" bullet list the same way.

- [ ] **Step 2: Update `docs/parity-matrix.md`**

Change:

- `bridge`（完整）备注: keep existing capabilities; replace `无 hybrid / correction` with `commit-message 支持 hybridGenerate；无 correction repair turns`
- `commit-message generate` 备注: `host-agent turns；支持 structure/skill/hybridGenerate；无 correction repair`

Keep the intentional-gap sentence: 无 chunked / hybrid **review**、无 correction repair turns.

- [ ] **Step 3: Update `README.md` safer-team example**

In the JSON example that currently has `"protocol": "conventional"` as if it were the default, either omit `validation` or set `"protocol": "none"`. If the example is meant to **enforce** Conventional Commits, keep `"protocol": "conventional"` and add `"skill": { "id": "conventional", "path": "", "promptTuning": "" }` so it is explicit, not implied as default.

- [ ] **Step 4: Commit**

```bash
git add docs/configuration.md docs/parity-matrix.md README.md
git commit -m "docs: align commitMessage configuration with CLI"
```

---

## Spec coverage

| Spec section | Task |
|--------------|------|
| §3 config types/defaults/parse/pattern trim/skill merge | Task 1 |
| §3.3 maxDiffChars truncation | Task 5 |
| §4 protocol validation + language + structure + pattern | Task 2 |
| §5.1 generate prompt + responseSchema | Task 3 |
| §5.2 skills + promptContext + package files | Task 4 |
| §6 provided/hybrid/generated, no repair, status mapping | Task 5 |
| §7 docs | Task 6 |
| §8 tests | Tasks 1–5 |
| §9 no compat for `custom` / old default | Task 1 (reject `custom`; default `none`) |
| Non-goal: no CLI flag/env overlay | all tasks (do not add) |
| Non-goal: no repair turns | Task 5 (`attempt: 0` only) |
)
