lee-spec-kit 0.9.0 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/README.en.md +1 -1
  2. package/README.md +2 -2
  3. package/dist/bootstrap-Q77MTW3Q.js +0 -0
  4. package/dist/chunk-3AFCPGGS.js +0 -0
  5. package/dist/chunk-7V7RMGEU.js +0 -0
  6. package/dist/chunk-GR7JQBWF.js +0 -0
  7. package/dist/hooks-373Z6JG2.js +0 -0
  8. package/dist/index.js +285 -70
  9. package/dist/index.js.map +1 -1
  10. package/package.json +13 -15
  11. package/templates/en/common/README.md +8 -8
  12. package/templates/en/common/agents/agents.md +8 -2
  13. package/templates/en/common/agents/git-workflow.md +27 -8
  14. package/templates/en/common/agents/skills/split-feature.md +2 -1
  15. package/templates/en/common/agents/ui-ux-design.md +128 -0
  16. package/templates/en/common/designs/README.md +39 -1
  17. package/templates/en/common/features/README.md +2 -2
  18. package/templates/en/common/features/feature-base/decisions.md +3 -1
  19. package/templates/en/common/features/feature-base/spec.md +3 -0
  20. package/templates/en/common/features/feature-base/tasks.md +1 -0
  21. package/templates/ko/common/README.md +8 -8
  22. package/templates/ko/common/agents/agents.md +8 -2
  23. package/templates/ko/common/agents/git-workflow.md +27 -8
  24. package/templates/ko/common/agents/skills/split-feature.md +4 -3
  25. package/templates/ko/common/agents/ui-ux-design.md +128 -0
  26. package/templates/ko/common/designs/README.md +39 -1
  27. package/templates/ko/common/features/README.md +2 -2
  28. package/templates/ko/common/features/feature-base/decisions.md +3 -1
  29. package/templates/ko/common/features/feature-base/spec.md +3 -0
  30. package/templates/ko/common/features/feature-base/tasks.md +1 -0
package/dist/index.js CHANGED
@@ -12,6 +12,7 @@ import { spawn, execFileSync, spawnSync } from 'child_process';
12
12
  import os from 'os';
13
13
  import crypto, { createHash } from 'crypto';
14
14
  import fs21 from 'fs';
15
+ import fs26 from 'fs/promises';
15
16
 
16
17
  async function walkFiles(fsAdapter, rootDir, options = {}) {
17
18
  const out = [];
@@ -1231,7 +1232,8 @@ var ENGINE_MANAGED_AGENT_FILES = [
1231
1232
  "agents.md",
1232
1233
  "git-workflow.md",
1233
1234
  "issue-template.md",
1234
- "pr-template.md"
1235
+ "pr-template.md",
1236
+ "ui-ux-design.md"
1235
1237
  ];
1236
1238
  var ENGINE_MANAGED_AGENT_DIRS = ["skills"];
1237
1239
  var ENGINE_MANAGED_FEATURE_PATH = path8.join(
@@ -1307,7 +1309,7 @@ Before taking the next workflow step:
1307
1309
  13. When \`workflow-stage --json\` returns \`nextAction.category === "pre_pr_review"\` and \`nextAction.executor === "subagent"\`, delegate a fresh read-only review using the returned \`model\`, \`reasoningEffort\`, and \`onUnavailable\` policy; do not select or require a named review skill
1308
1310
  14. The Pre-PR review subagent returns findings without modifying code; the main agent remediates findings and records the actual reviewer metadata, reviewed diff scope, evidence, and final decision
1309
1311
  15. For a local workflow, do not report completion directly after implementation approval; follow the exact returned \`local merge\` / \`local cleanup\` commands until \`workflow-stage\` proves integration, verification, and cleanup and returns \`done\`
1310
- 16. In a \`local-ff\` workflow, present the implementation approval summary exactly as returned because that checkpoint authorizes the remaining fast-forward integration, post-merge checks, and configured local cleanup; ask again at \`local_merge\` only when that category is explicitly configured as required
1312
+ 16. In a \`local-ff\` or \`local-squash\` workflow, keep implementation approval and local merge approval distinct when \`local_merge\` is required: the first accepts the implementation, and the second authorizes the configured integration strategy, post-merge checks, and local cleanup
1311
1313
 
1312
1314
  Approval and remote actions:
1313
1315
 
@@ -1315,14 +1317,20 @@ Approval and remote actions:
1315
1317
  - If \`workflow-stage --json\` reports \`approvalRequired === true\`, stop at that boundary and ask the user before proceeding
1316
1318
  - If \`workflow-stage --json\` returns labeled \`actionOptions\` at any approval boundary, keep the same option labels and exact \`reply\` tokens in the user prompt and do not improvise different reply formats
1317
1319
  - If \`workflow-stage --json\` reports \`nextAction.category === "task_commit"\`, make the docs commit and project commit for the just-finished task before starting the next task or moving to the next stage
1318
- - Before \`git commit\`, prefer \`npx lee-spec-kit commit-audit --json\` when hooks or manual checks need commit-time docs path enforcement
1320
+ - Before \`git commit\`, prefer \`npx lee-spec-kit commit-audit --json\`; Feature-scoped commits use \`#123\` when an Issue is linked and the stable Feature ID such as \`F027\` for issue-less local workflows
1319
1321
  - Before remote GitHub actions, share the plan or artifact being sent
1320
1322
  - Respect repo policy from docs and config first; hooks only enforce guardrails and continuation checks
1321
1323
 
1322
1324
  Validation:
1323
1325
 
1324
- - Prefer \`npx lee-spec-kit commit-audit --json\` for commit-time staged docs path validation
1326
+ - Prefer \`npx lee-spec-kit commit-audit --json\` for commit-time staged docs path validation and canonical commit-subject validation
1325
1327
  - Prefer \`npx lee-spec-kit workflow-audit --json\` as the default docs-sync validator for Codex hooks and end-of-turn checks; it expects the active feature docs to carry one fresh \`lee-spec-kit:workflow-sync\` marker after meaningful code/doc sync
1328
+
1329
+ Optional UI/UX design policy:
1330
+
1331
+ - Only when the user request explicitly mentions a design system, UI/visual redesign, design consistency, shared UI/component-library consolidation, branding/theme/token redesign, or implementation from Figma/design images, read and apply \`npx lee-spec-kit docs get ui-ux-design --json\`
1332
+ - Do not apply that policy merely because the target is web/frontend, to a non-UI/backend Feature, or to a simple bug fix unrelated to durable design rules
1333
+ - Treat it as optional guidance, not a \`requiredDocs\` entry or workflow approval gate
1326
1334
  `;
1327
1335
  function renderManagedSegment(lang, docsRepo) {
1328
1336
  return `${LEE_SPEC_KIT_AGENTS_BEGIN}
@@ -1887,7 +1895,8 @@ async function getConfig(cwd) {
1887
1895
  // src/config/types.ts
1888
1896
  var DEFAULT_APPROVAL_REQUIRE_CHECK_CATEGORIES = [
1889
1897
  "spec_approve",
1890
- "implementation_approve"
1898
+ "implementation_approve",
1899
+ "local_merge"
1891
1900
  ];
1892
1901
  var PRE_PR_REVIEW_REASONING_EFFORTS = [
1893
1902
  "low",
@@ -3481,6 +3490,11 @@ function isLegacyGeneratedApprovalConfig(approval) {
3481
3490
  ];
3482
3491
  return !overrideKeys.some((key) => hasOwnKey2(approval, key));
3483
3492
  }
3493
+ function isPreviousDefaultApprovalConfig(approval) {
3494
+ const keys = Object.keys(approval).sort();
3495
+ const categories = Array.isArray(approval.requireCheckCategories) ? approval.requireCheckCategories : [];
3496
+ return JSON.stringify(keys) === JSON.stringify(["default", "mode", "requireCheckCategories"]) && approval.mode === "category" && approval.default === "skip" && categories.length === 2 && categories[0] === "spec_approve" && categories[1] === "implementation_approve";
3497
+ }
3484
3498
  function updateCommand(program2) {
3485
3499
  program2.command("update").description("Update docs templates to the latest version").option("--agents", "Update agents/ folder only").option("--agents-md", "Sync project-scoped AGENTS.md entrypoint").option(
3486
3500
  "-f, --force",
@@ -3720,7 +3734,7 @@ async function backfillMissingConfigDefaults(cwd, docsDir) {
3720
3734
  "none",
3721
3735
  "workflow.completionStrategy"
3722
3736
  );
3723
- if (workflow.completionStrategy !== "local-ff" && workflow.completionStrategy !== "none") {
3737
+ if (workflow.completionStrategy !== "local-ff" && workflow.completionStrategy !== "local-squash" && workflow.completionStrategy !== "none") {
3724
3738
  workflow.completionStrategy = "none";
3725
3739
  changedPaths.push("workflow.completionStrategy");
3726
3740
  }
@@ -3822,7 +3836,7 @@ async function backfillMissingConfigDefaults(cwd, docsDir) {
3822
3836
  const migration = migrateLegacyApprovalSettings(approval);
3823
3837
  raw.approval = migration.approval;
3824
3838
  changedPaths.push(...migration.changedPaths);
3825
- if (isPlainObject(raw.approval) && isLegacyGeneratedApprovalConfig(raw.approval)) {
3839
+ if (isPlainObject(raw.approval) && (isLegacyGeneratedApprovalConfig(raw.approval) || isPreviousDefaultApprovalConfig(raw.approval))) {
3826
3840
  raw.approval = createDefaultApprovalConfig();
3827
3841
  changedPaths.push("approval");
3828
3842
  }
@@ -6697,6 +6711,14 @@ var BUILTIN_DOC_DEFINITIONS = [
6697
6711
  id: "split-feature",
6698
6712
  title: { ko: "feature \uBD84\uD560 \uAC00\uC774\uB4DC", en: "feature split guide" },
6699
6713
  relativePath: (_, lang) => path8.join(lang, "common", "agents", "skills", "split-feature.md")
6714
+ },
6715
+ {
6716
+ id: "ui-ux-design",
6717
+ title: {
6718
+ ko: "\uC120\uD0DD\uC801 UI/UX \uB514\uC790\uC778 \uBB38\uC11C \uC815\uCC45",
6719
+ en: "Optional UI/UX Design Documentation Policy"
6720
+ },
6721
+ relativePath: (_, lang) => path8.join(lang, "common", "agents", "ui-ux-design.md")
6700
6722
  }
6701
6723
  ];
6702
6724
  var DOC_FOLLOWUPS = {
@@ -6717,7 +6739,8 @@ var DOC_FOLLOWUPS = {
6717
6739
  "execute-task": ["git-workflow", "split-feature"],
6718
6740
  "create-issue": ["issue-doc"],
6719
6741
  "create-pr": ["pr-doc"],
6720
- "split-feature": []
6742
+ "split-feature": [],
6743
+ "ui-ux-design": []
6721
6744
  };
6722
6745
  function getBuiltinDocIds() {
6723
6746
  return BUILTIN_DOC_DEFINITIONS.map((doc) => doc.id);
@@ -6736,6 +6759,7 @@ function normalizeBuiltinDocId(input) {
6736
6759
  if (normalized === "split-feature") {
6737
6760
  return "split-feature";
6738
6761
  }
6762
+ if (normalized === "ui-ux-design") return "ui-ux-design";
6739
6763
  if (normalized === "agents") return "agents";
6740
6764
  return null;
6741
6765
  }
@@ -7197,11 +7221,9 @@ function findPlaceholderDecisionRange(content) {
7197
7221
  const block = content.slice(match.index, end);
7198
7222
  const isPlaceholder = /\{(?:Decision Title|결정 제목)\}/.test(match[0]) || /-\s+\*\*Decision\*\*:\s*(Final choice|최종 선택)/.test(block);
7199
7223
  if (!isPlaceholder) return null;
7200
- const sequence = Number(match[1] || "1");
7201
7224
  return {
7202
7225
  start: match.index,
7203
- end,
7204
- sequence: Number.isFinite(sequence) ? sequence : 1
7226
+ end
7205
7227
  };
7206
7228
  }
7207
7229
  function formatOptions(options) {
@@ -7242,7 +7264,7 @@ async function runDecisionAdd(featureName, options) {
7242
7264
  const optionItems = (options.option || []).map((value) => value.trim()).filter(Boolean);
7243
7265
  const content = await fs.readFile(target.path, "utf-8");
7244
7266
  const placeholderRange = findPlaceholderDecisionRange(content);
7245
- const decisionSequence = placeholderRange?.sequence ?? getNextDecisionSequence(content);
7267
+ const decisionSequence = placeholderRange ? 1 : getNextDecisionSequence(content);
7246
7268
  const decisionId = `D${String(decisionSequence).padStart(3, "0")}`;
7247
7269
  const recordedAt = localDate();
7248
7270
  const block = formatDecisionBlock({
@@ -7551,6 +7573,44 @@ function integrationsCommand(program2) {
7551
7573
  registerCodexIntegration(integrations);
7552
7574
  registerCodexHooksIntegration(integrations);
7553
7575
  }
7576
+
7577
+ // src/utils/commit-conventions.ts
7578
+ var PROJECT_COMMIT_TYPES = [
7579
+ "feat",
7580
+ "fix",
7581
+ "refactor",
7582
+ "test",
7583
+ "style",
7584
+ "chore"
7585
+ ];
7586
+ function resolveFeatureCommitScope(input) {
7587
+ const issueNumber = Number(input.issueNumber || 0);
7588
+ if (Number.isInteger(issueNumber) && issueNumber > 0) {
7589
+ return `#${issueNumber}`;
7590
+ }
7591
+ if (input.workflowMode !== "local") {
7592
+ return null;
7593
+ }
7594
+ const featureId = String(input.featureId || "").trim().toUpperCase();
7595
+ return /^F\d{3,}$/.test(featureId) ? featureId : null;
7596
+ }
7597
+ function matchesProjectCommitConvention(message, scope) {
7598
+ return matchesCommitConvention(message, scope, PROJECT_COMMIT_TYPES);
7599
+ }
7600
+ function matchesDocsCommitConvention(message, scope) {
7601
+ return matchesCommitConvention(message, scope, ["docs"]);
7602
+ }
7603
+ function matchesCommitConvention(message, scope, types) {
7604
+ const normalized = String(message || "").trim();
7605
+ const normalizedScope = String(scope || "").trim();
7606
+ if (!normalized || !normalizedScope) return false;
7607
+ const typePattern = types.join("|");
7608
+ const match = normalized.match(
7609
+ new RegExp(`^(${typePattern})\\((#[0-9]+|F[0-9]{3,})\\):\\s+\\S.+$`, "i")
7610
+ );
7611
+ if (!match) return false;
7612
+ return match[2].toLowerCase() === normalizedScope.toLowerCase();
7613
+ }
7554
7614
  function isSameOrWithin2(parentDir, candidatePath) {
7555
7615
  const relative = path8.relative(parentDir, candidatePath);
7556
7616
  return relative === "" || relative !== ".." && !relative.startsWith(`..${path8.sep}`) && !path8.isAbsolute(relative);
@@ -7591,7 +7651,8 @@ function isPrePrEvidenceSatisfied(check) {
7591
7651
  }
7592
7652
  var BRANCH_LABELS2 = ["Branch", "\uBE0C\uB79C\uCE58"];
7593
7653
  function resolveLocalCompletionStrategy(config) {
7594
- return config.workflow?.completionStrategy === "local-ff" ? "local-ff" : "none";
7654
+ const strategy = config.workflow?.completionStrategy;
7655
+ return strategy === "local-ff" || strategy === "local-squash" ? strategy : "none";
7595
7656
  }
7596
7657
  async function resolveLocalIntegrationContext(config, feature) {
7597
7658
  const projectRoot = resolveProjectRoot(config, feature);
@@ -7611,6 +7672,9 @@ async function resolveLocalIntegrationContext(config, feature) {
7611
7672
  );
7612
7673
  }
7613
7674
  const state = await readLocalIntegrationState(projectRoot, feature);
7675
+ const configuredStrategy = resolveLocalCompletionStrategy(config);
7676
+ const completionStrategy = configuredStrategy === "local-squash" ? "local-squash" : "local-ff";
7677
+ const evidenceRef = resolveEvidenceRef(feature);
7614
7678
  const featureBranchTip = resolveRef(
7615
7679
  projectRoot,
7616
7680
  `refs/heads/${featureBranch}`
@@ -7624,6 +7688,14 @@ async function resolveLocalIntegrationContext(config, feature) {
7624
7688
  );
7625
7689
  const managedFeatureWorktree = !!registeredWorktree && path8.resolve(registeredWorktree) !== path8.resolve(projectRoot);
7626
7690
  const effectiveFeatureWorktree = registeredWorktree || (fs.existsSync(featureWorktree) ? featureWorktree : projectRoot);
7691
+ const baseContainsFeature = !!featureTip && !!baseTip && isAncestor(projectRoot, featureTip, `refs/heads/${baseBranch}`);
7692
+ const integratedCommit = state?.integratedCommit || state?.mergedBaseTip;
7693
+ const evidenceTip = resolveRef(projectRoot, evidenceRef);
7694
+ const featureTree = featureTip ? resolveTree(projectRoot, featureTip) : null;
7695
+ const integratedTree = integratedCommit ? resolveTree(projectRoot, integratedCommit) : null;
7696
+ const squashCommitMatchesSource = completionStrategy === "local-squash" && state?.strategy === "local-squash" && !!featureTip && state.featureTip === featureTip && !!integratedCommit && !!baseTip && isAncestor(projectRoot, integratedCommit, `refs/heads/${baseBranch}`) && !!featureTree && integratedTree === featureTree && state.integratedTree === featureTree;
7697
+ const squashEvidencePresent = !!featureTip && evidenceTip === featureTip;
7698
+ const squashIntegrationComplete = squashCommitMatchesSource && squashEvidencePresent;
7627
7699
  return {
7628
7700
  projectRoot,
7629
7701
  featureWorktree: effectiveFeatureWorktree,
@@ -7634,7 +7706,12 @@ async function resolveLocalIntegrationContext(config, feature) {
7634
7706
  baseTip,
7635
7707
  currentBranch,
7636
7708
  state,
7637
- baseContainsFeature: !!featureTip && !!baseTip && isAncestor(projectRoot, featureTip, `refs/heads/${baseBranch}`),
7709
+ completionStrategy,
7710
+ baseContainsFeature,
7711
+ integrationComplete: completionStrategy === "local-squash" ? squashIntegrationComplete : baseContainsFeature,
7712
+ evidenceRef,
7713
+ squashCommitMatchesSource,
7714
+ squashEvidencePresent,
7638
7715
  projectRootClean: isClean(projectRoot),
7639
7716
  featureWorktreeClean: isClean(effectiveFeatureWorktree),
7640
7717
  docsClean: isClean(feature.git.docsGitCwd),
@@ -7663,7 +7740,7 @@ async function readLocalIntegrationState(projectRoot, feature) {
7663
7740
  }
7664
7741
  }
7665
7742
  function localCleanupComplete(context) {
7666
- return context.baseContainsFeature && context.currentBranch === context.baseBranch && !context.managedFeatureWorktree && (!context.deleteFeatureBranchAfterMerge || !context.featureBranchExists) && context.projectRootClean && context.docsClean && context.state?.status === "cleaned" && context.state.featureTip === context.featureTip && context.state.mergedBaseTip === context.baseTip;
7743
+ return context.integrationComplete && context.currentBranch === context.baseBranch && !context.managedFeatureWorktree && (!context.deleteFeatureBranchAfterMerge || !context.featureBranchExists) && context.projectRootClean && context.docsClean && context.state?.status === "cleaned" && context.state.featureTip === context.featureTip && context.state.mergedBaseTip === context.baseTip;
7667
7744
  }
7668
7745
  function runPostMergeChecks(projectRoot, checks) {
7669
7746
  const results = [];
@@ -7688,6 +7765,12 @@ function currentGitBranch(cwd) {
7688
7765
  function resolveRef(cwd, ref) {
7689
7766
  return runGitCapture(["rev-parse", "--verify", ref], cwd) || null;
7690
7767
  }
7768
+ function resolveTree(cwd, commit) {
7769
+ return resolveRef(cwd, `${commit}^{tree}`);
7770
+ }
7771
+ function preserveLocalIntegrationEvidence(projectRoot, evidenceRef, featureTip) {
7772
+ return gitRun(projectRoot, ["update-ref", evidenceRef, featureTip]);
7773
+ }
7691
7774
  function isAncestor(cwd, ancestor, descendant) {
7692
7775
  return runProcess(
7693
7776
  "git",
@@ -7711,7 +7794,7 @@ function resolveProjectRoot(config, feature) {
7711
7794
  function resolveStatePath(projectRoot, feature) {
7712
7795
  const commonDirRaw = runGitCapture(["rev-parse", "--git-common-dir"], projectRoot) || ".git";
7713
7796
  const commonDir = path8.isAbsolute(commonDirRaw) ? commonDirRaw : path8.resolve(projectRoot, commonDirRaw);
7714
- const key = crypto.createHash("sha256").update(`${feature.type}:${feature.folderName}`).digest("hex").slice(0, 24);
7797
+ const key = resolveFeatureStateKey(feature);
7715
7798
  return path8.join(
7716
7799
  commonDir,
7717
7800
  "lee-spec-kit",
@@ -7719,6 +7802,12 @@ function resolveStatePath(projectRoot, feature) {
7719
7802
  `${key}.json`
7720
7803
  );
7721
7804
  }
7805
+ function resolveEvidenceRef(feature) {
7806
+ return `refs/lee-spec-kit/integrations/${resolveFeatureStateKey(feature)}`;
7807
+ }
7808
+ function resolveFeatureStateKey(feature) {
7809
+ return crypto.createHash("sha256").update(`${feature.type}:${feature.folderName}`).digest("hex").slice(0, 24);
7810
+ }
7722
7811
  async function readTasks(featureDir) {
7723
7812
  const tasksPath = path8.join(featureDir, "tasks.md");
7724
7813
  return await fs.pathExists(tasksPath) ? fs.readFile(tasksPath, "utf-8") : "";
@@ -8116,8 +8205,13 @@ function resolveProjectCommitTopic(feature, tasks) {
8116
8205
  }
8117
8206
  function buildTaskCommitSummary(input) {
8118
8207
  const { feature, tasks, effectiveProjectGitCwd, docsDirty, projectDirty, gateFailureReason } = input;
8119
- const docsMessage = tasks.issueNumber ? `git -C "${feature.git.docsGitCwd}" add "${feature.docs.featurePathFromDocs}" && git -C "${feature.git.docsGitCwd}" commit -m "docs(#${tasks.issueNumber}): ${feature.folderName} \uBB38\uC11C \uC5C5\uB370\uC774\uD2B8"` : `git -C "${feature.git.docsGitCwd}" add "${feature.docs.featurePathFromDocs}" && git -C "${feature.git.docsGitCwd}" commit -m "docs: ${feature.folderName} \uBB38\uC11C \uC5C5\uB370\uC774\uD2B8"`;
8120
- const projectMessage = tasks.issueNumber ? `Stage only the files touched by the just-finished task in "${effectiveProjectGitCwd}", then commit with: git -C "${effectiveProjectGitCwd}" commit -m "feat(#${tasks.issueNumber}): ${resolveProjectCommitTopic(feature, tasks)}"` : `Stage only the files touched by the just-finished task in "${effectiveProjectGitCwd}", then commit with: git -C "${effectiveProjectGitCwd}" commit -m "feat(${feature.folderName}): ${resolveProjectCommitTopic(feature, tasks)}"`;
8208
+ const scope = resolveFeatureCommitScope({
8209
+ issueNumber: tasks.issueNumber,
8210
+ featureId: feature.id,
8211
+ workflowMode: tasks.issueNumber ? "github" : "local"
8212
+ }) || feature.id;
8213
+ const docsMessage = `git -C "${feature.git.docsGitCwd}" add "${feature.docs.featurePathFromDocs}" && git -C "${feature.git.docsGitCwd}" commit -m "docs(${scope}): ${feature.folderName} \uBB38\uC11C \uC5C5\uB370\uC774\uD2B8"`;
8214
+ const projectMessage = `Stage only the files touched by the just-finished task in "${effectiveProjectGitCwd}", then commit with: git -C "${effectiveProjectGitCwd}" commit -m "feat(${scope}): ${resolveProjectCommitTopic(feature, tasks)}"`;
8121
8215
  const lines = ["Finish the task-level commit checkpoint before continuing."];
8122
8216
  if (gateFailureReason) {
8123
8217
  lines.push(`Current gate failure: ${gateFailureReason}`);
@@ -8987,11 +9081,13 @@ Task commit boundary warning: ${describeTaskCommitGateFailure(committedTaskGate)
8987
9081
  "implementation_approve",
8988
9082
  true
8989
9083
  );
8990
- const localIntegrationApproval = config.workflow?.mode === "local" && resolveLocalCompletionStrategy(config) === "local-ff";
9084
+ const localIntegrationApproval = config.workflow?.mode === "local" && resolveLocalCompletionStrategy(config) !== "none";
9085
+ const separateLocalMergeApproval = localIntegrationApproval && resolveActionApprovalRequired(config, "local_merge", false);
8991
9086
  const localBaseBranch = config.workflow?.baseBranch?.trim() || "main";
9087
+ const localIntegrationLabel = resolveLocalCompletionStrategy(config) === "local-squash" ? `squash integration into ${localBaseBranch}` : `fast-forward integration into ${localBaseBranch}`;
8992
9088
  const localCleanupSummary = config.workflow?.deleteFeatureBranchAfterMerge === false ? "remove any managed Feature worktree" : "remove any managed Feature worktree and delete the integrated local Feature branch";
8993
9089
  const actionOptions = approvalRequired ? buildApprovalActionOptions({
8994
- approveSummary: localIntegrationApproval ? `Approve the completed implementation and authorize fast-forward integration into ${localBaseBranch}, post-merge verification, and cleanup that will ${localCleanupSummary}.` : "Approve the completed implementation and continue to the pre-PR or PR preparation stage.",
9090
+ approveSummary: separateLocalMergeApproval ? `Approve the completed implementation and continue to the separate local merge approval before ${localIntegrationLabel}.` : localIntegrationApproval ? `Approve the completed implementation and authorize ${localIntegrationLabel}, post-merge verification, and cleanup that will ${localCleanupSummary}.` : "Approve the completed implementation and continue to the pre-PR or PR preparation stage.",
8995
9091
  holdSummary: "Request implementation changes before the workflow continues."
8996
9092
  }) : void 0;
8997
9093
  return {
@@ -9002,7 +9098,7 @@ Task commit boundary warning: ${describeTaskCommitGateFailure(committedTaskGate)
9002
9098
  stage: "implementation_approve",
9003
9099
  nextAction: buildAction(
9004
9100
  "implementation_approve",
9005
- localIntegrationApproval ? `Share the completed implementation and get user approval for the remaining local completion flow: fast-forward into ${localBaseBranch}, run post-merge checks, then ${localCleanupSummary}. Record that approval in tasks.md.` : "Share the completed implementation, get user approval, and record the completion checkpoint in tasks.md.",
9101
+ separateLocalMergeApproval ? `Share the completed implementation and get user approval for the implementation itself. Record it in tasks.md; ${localIntegrationLabel} will require a separate local_merge approval.` : localIntegrationApproval ? `Share the completed implementation and get user approval for the remaining local completion flow: ${localIntegrationLabel}, run post-merge checks, then ${localCleanupSummary}. Record that approval in tasks.md.` : "Share the completed implementation, get user approval, and record the completion checkpoint in tasks.md.",
9006
9102
  approvalRequired
9007
9103
  ),
9008
9104
  approvalRequired,
@@ -9031,10 +9127,10 @@ Task commit boundary warning: ${describeTaskCommitGateFailure(committedTaskGate)
9031
9127
  blockedReasonCode: "PRE_PR_REVIEW_NOT_APPROVED"
9032
9128
  };
9033
9129
  }
9034
- if (config.workflow?.mode === "local" && resolveLocalCompletionStrategy(config) === "local-ff") {
9130
+ if (config.workflow?.mode === "local" && resolveLocalCompletionStrategy(config) !== "none") {
9035
9131
  const localState = await resolveLocalIntegrationContext(config, feature);
9036
9132
  const localMergeBaseCommand = `npx lee-spec-kit local merge ${buildFeatureArgs(feature)} --json`;
9037
- if (!localState.baseContainsFeature) {
9133
+ if (!localState.integrationComplete) {
9038
9134
  const approvalRequired = resolveActionApprovalRequired(
9039
9135
  config,
9040
9136
  "local_merge",
@@ -9042,7 +9138,7 @@ Task commit boundary warning: ${describeTaskCommitGateFailure(committedTaskGate)
9042
9138
  );
9043
9139
  const command = approvalRequired ? `${localMergeBaseCommand} --confirm OK` : localMergeBaseCommand;
9044
9140
  const actionOptions = approvalRequired ? buildApprovalActionOptions({
9045
- approveSummary: `Fast-forward ${localState.featureBranch} into ${localState.baseBranch} and run the configured post-merge checks.`,
9141
+ approveSummary: localState.completionStrategy === "local-squash" ? `Squash ${localState.featureBranch} into ${localState.baseBranch}, preserve the source Feature tip, and run the configured post-merge checks.` : `Fast-forward ${localState.featureBranch} into ${localState.baseBranch} and run the configured post-merge checks.`,
9046
9142
  holdSummary: "Keep the completed Feature branch unmerged for now."
9047
9143
  }) : void 0;
9048
9144
  return {
@@ -9053,7 +9149,7 @@ Task commit boundary warning: ${describeTaskCommitGateFailure(committedTaskGate)
9053
9149
  stage: "local_merge",
9054
9150
  nextAction: buildAction(
9055
9151
  "local_merge",
9056
- `Fast-forward ${localState.featureBranch} into ${localState.baseBranch}; do not create a merge commit.`,
9152
+ localState.completionStrategy === "local-squash" ? `Create one squash commit from ${localState.featureBranch} on ${localState.baseBranch} and preserve the source Feature tip as internal integration evidence.` : `Fast-forward ${localState.featureBranch} into ${localState.baseBranch}; do not create a merge commit.`,
9057
9153
  approvalRequired,
9058
9154
  command
9059
9155
  ),
@@ -9991,35 +10087,21 @@ var DEFAULT_MANAGED_DOC_FILES = [
9991
10087
  ".gitignore"
9992
10088
  ];
9993
10089
 
9994
- // src/utils/commit-conventions.ts
9995
- var PROJECT_COMMIT_PREFIX_PATTERN = /^(feat|fix|refactor|test|chore)\(#(\d+)\):\s+\S.+$/i;
9996
- var DOCS_COMMIT_PREFIX_PATTERN = /^docs\(#(\d+)\):\s+\S.+$/i;
9997
- function matchesProjectCommitConvention(message, issueNumber) {
9998
- const normalized = String(message || "").trim();
9999
- if (!normalized) return false;
10000
- const match = normalized.match(PROJECT_COMMIT_PREFIX_PATTERN);
10001
- if (!match) return false;
10002
- return Number(match[2]) === issueNumber;
10003
- }
10004
- function matchesDocsCommitConvention(message, issueNumber) {
10005
- const normalized = String(message || "").trim();
10006
- if (!normalized) return false;
10007
- const match = normalized.match(DOCS_COMMIT_PREFIX_PATTERN);
10008
- if (!match) return false;
10009
- return Number(match[1]) === issueNumber;
10010
- }
10011
-
10012
10090
  // src/commands/commit-audit.ts
10013
10091
  var CANONICAL_FEATURE_DOC_PATTERN = /^features\/(?:[^/]+\/)?F\d{3,}[^/]*\/(spec|plan|tasks|decisions|issue|pr)\.md$/i;
10014
10092
  var FEATURE_DOC_CANDIDATE_PATTERN = /^features\/(?:[^/]+\/)?F\d{3,}[^/]*\/(.+)$/i;
10015
10093
  function commitAuditCommand(program2) {
10016
- program2.command("commit-audit").description("Validate staged docs paths before commit").option("--json", "Output JSON for hooks and agents").option("--git-root <path>", "Override the git root used for staged-path inspection").option("--message <message>", "Validate a commit subject against the current workflow convention").action(async (options) => {
10094
+ program2.command("commit-audit").description("Validate staged docs paths and canonical commit subjects before commit").option("--json", "Output JSON for hooks and agents").option("--git-root <path>", "Override the git root used for staged-path inspection").option("--message <message>", "Validate a commit subject against the current workflow convention").option("--message-file <path>", "Read and validate the commit subject from a commit message file").option("--enforce", "Exit non-zero when commit-audit blocks the commit").action(async (options) => {
10017
10095
  try {
10096
+ const commitMessage = await resolveCommitMessageInput(process.cwd(), options);
10018
10097
  const payload = await collectCommitAudit(
10019
10098
  process.cwd(),
10020
10099
  options.gitRoot,
10021
- options.message
10100
+ commitMessage
10022
10101
  );
10102
+ if (options.enforce && payload.status === "blocked") {
10103
+ process.exitCode = 1;
10104
+ }
10023
10105
  if (options.json) {
10024
10106
  console.log(JSON.stringify(payload, null, 2));
10025
10107
  return;
@@ -10046,6 +10128,7 @@ function commitAuditCommand(program2) {
10046
10128
  2
10047
10129
  )
10048
10130
  );
10131
+ if (options.enforce) process.exitCode = 1;
10049
10132
  return;
10050
10133
  }
10051
10134
  process.stderr.write(`[${cliError.code}] ${cliError.message}
@@ -10054,6 +10137,18 @@ function commitAuditCommand(program2) {
10054
10137
  }
10055
10138
  });
10056
10139
  }
10140
+ async function resolveCommitMessageInput(cwd, options) {
10141
+ if (options.message && options.messageFile) {
10142
+ throw createCliError(
10143
+ "INVALID_ARGUMENT",
10144
+ "Use either --message or --message-file, not both."
10145
+ );
10146
+ }
10147
+ if (options.message) return options.message;
10148
+ if (!options.messageFile) return void 0;
10149
+ const content = await fs26.readFile(path8.resolve(cwd, options.messageFile), "utf-8");
10150
+ return content.split(/\r?\n/u).map((line) => line.trim()).find((line) => line.length > 0 && !line.startsWith("#"));
10151
+ }
10057
10152
  async function collectCommitAudit(cwd, gitRootOverride, commitMessage) {
10058
10153
  const config = await getConfig(cwd);
10059
10154
  if (!config) {
@@ -10277,10 +10372,17 @@ async function collectCommitMessageViolation(cwd, config, repoRoot, stagedEntrie
10277
10372
  return null;
10278
10373
  }
10279
10374
  const selection = await resolveFeatureSelection(cwd);
10280
- if (selection.status !== "selected" || !selection.matchedFeature?.issueNumber) {
10375
+ if (selection.status !== "selected" || !selection.matchedFeature) {
10376
+ return null;
10377
+ }
10378
+ const scope = resolveFeatureCommitScope({
10379
+ issueNumber: selection.matchedFeature.issueNumber,
10380
+ featureId: selection.matchedFeature.id,
10381
+ workflowMode: config.workflow?.mode
10382
+ });
10383
+ if (!scope) {
10281
10384
  return null;
10282
10385
  }
10283
- const issueNumber = selection.matchedFeature.issueNumber;
10284
10386
  const docsRepoRoot = runGitCapture(["rev-parse", "--show-toplevel"], config.docsDir);
10285
10387
  const normalizedRepoRoot = path8.resolve(repoRoot);
10286
10388
  const normalizedDocsRepoRoot = docsRepoRoot ? path8.resolve(docsRepoRoot) : null;
@@ -10290,15 +10392,15 @@ async function collectCommitMessageViolation(cwd, config, repoRoot, stagedEntrie
10290
10392
  return !!relativeToDocs && relativeToDocs !== "" && !relativeToDocs.startsWith("..");
10291
10393
  });
10292
10394
  const isDocsCommit = !!normalizedDocsRepoRoot && normalizedDocsRepoRoot === normalizedRepoRoot && (config.docsRepo === "standalone" || docsOnlyCommit);
10293
- const valid = isDocsCommit ? matchesDocsCommitConvention(normalizedMessage, issueNumber) : matchesProjectCommitConvention(normalizedMessage, issueNumber);
10395
+ const valid = isDocsCommit ? matchesDocsCommitConvention(normalizedMessage, scope) : matchesProjectCommitConvention(normalizedMessage, scope);
10294
10396
  if (valid) {
10295
10397
  return null;
10296
10398
  }
10297
- const expected = isDocsCommit ? `docs(#${issueNumber}): ...` : `type(#${issueNumber}): ... (feat/fix/refactor/test/chore)`;
10399
+ const expected = isDocsCommit ? `docs(${scope}): ...` : `type(${scope}): ... (feat/fix/refactor/test/style/chore)`;
10298
10400
  return {
10299
10401
  path: "(commit message)",
10300
10402
  kind: "commit_message_policy",
10301
- detail: `Commit subject must follow the issue-scoped convention for this feature. Expected ${expected}, received "${normalizedMessage}".`
10403
+ detail: `Commit subject must follow the canonical feature-scoped convention. Expected ${expected}, received "${normalizedMessage}".`
10302
10404
  };
10303
10405
  }
10304
10406
  function normalizeEntryName(value) {
@@ -10609,7 +10711,7 @@ async function collectDocsAudit(cwd) {
10609
10711
  // src/commands/local.ts
10610
10712
  function localCommand(program2) {
10611
10713
  const local = program2.command("local").description("Integrate and clean up local workflow feature branches");
10612
- local.command("merge [feature-name]").description("Fast-forward a completed local feature and verify the result").option("--component <component>", "Component name for multi projects").option(
10714
+ local.command("merge [feature-name]").description("Integrate a completed local feature and verify the result").option("--component <component>", "Component name for multi projects").option(
10613
10715
  "--confirm <token>",
10614
10716
  "Approval token when local_merge requires approval"
10615
10717
  ).option("--json", "Output JSON for agents and hooks").action(
@@ -10708,7 +10810,8 @@ async function runLocalMerge(featureName, options) {
10708
10810
  );
10709
10811
  }
10710
10812
  const now = (/* @__PURE__ */ new Date()).toISOString();
10711
- let state = context.state || {
10813
+ const previousStateStrategy = context.state?.strategy || "local-ff";
10814
+ let state = context.state && context.state.featureTip === context.featureTip && previousStateStrategy === context.completionStrategy ? context.state : {
10712
10815
  version: 1,
10713
10816
  featureRef: feature.folderName,
10714
10817
  component: feature.type,
@@ -10716,20 +10819,39 @@ async function runLocalMerge(featureName, options) {
10716
10819
  featureBranch: context.featureBranch,
10717
10820
  featureTip: context.featureTip,
10718
10821
  mergedBaseTip: context.baseTip,
10822
+ strategy: context.completionStrategy,
10719
10823
  status: "merged",
10720
10824
  mergedAt: now,
10721
10825
  verifiedAt: null,
10722
10826
  cleanedAt: null,
10723
10827
  verification: []
10724
10828
  };
10725
- if (!context.baseContainsFeature) {
10829
+ if (context.completionStrategy === "local-squash" && context.squashCommitMatchesSource && !context.squashEvidencePresent) {
10830
+ const evidence = preserveLocalIntegrationEvidence(
10831
+ context.projectRoot,
10832
+ context.evidenceRef,
10833
+ context.featureTip
10834
+ );
10835
+ if (evidence.code !== 0) {
10836
+ return blocked(
10837
+ "LOCAL_SQUASH_EVIDENCE_FAILED",
10838
+ feature.folderName,
10839
+ evidence.stderr
10840
+ );
10841
+ }
10842
+ context = await resolveLocalIntegrationContext(config, feature);
10843
+ }
10844
+ if (!context.featureTip || !context.baseTip) {
10845
+ return blocked("LOCAL_MERGE_REF_NOT_FOUND", feature.folderName);
10846
+ }
10847
+ if (!context.integrationComplete) {
10726
10848
  if (!isAncestor(
10727
10849
  context.projectRoot,
10728
10850
  `refs/heads/${context.baseBranch}`,
10729
10851
  context.featureTip
10730
10852
  )) {
10731
10853
  return blocked(
10732
- "LOCAL_MERGE_NOT_FAST_FORWARD",
10854
+ context.completionStrategy === "local-squash" ? "LOCAL_SQUASH_BASE_NOT_ANCESTOR" : "LOCAL_MERGE_NOT_FAST_FORWARD",
10733
10855
  feature.folderName,
10734
10856
  `${context.baseBranch} is not an ancestor of ${context.featureBranch}.`
10735
10857
  );
@@ -10747,26 +10869,21 @@ async function runLocalMerge(featureName, options) {
10747
10869
  );
10748
10870
  }
10749
10871
  }
10750
- const merge = gitRun(context.projectRoot, [
10751
- "merge",
10752
- "--ff-only",
10753
- context.featureBranch
10754
- ]);
10755
- if (merge.code !== 0) {
10756
- return blocked(
10757
- "LOCAL_MERGE_NOT_FAST_FORWARD",
10758
- feature.folderName,
10759
- merge.stderr
10760
- );
10761
- }
10872
+ const integration = context.completionStrategy === "local-squash" ? integrateLocalSquash(context, feature.folderName, feature.id, feature.slug) : integrateLocalFastForward(context, feature.folderName);
10873
+ if (integration.status !== "ok") return integration.payload;
10762
10874
  const mergedBaseTip = resolveRef(
10763
10875
  context.projectRoot,
10764
10876
  `refs/heads/${context.baseBranch}`
10765
10877
  );
10878
+ const integratedTree = mergedBaseTip ? resolveTree(context.projectRoot, mergedBaseTip) : null;
10766
10879
  state = {
10767
10880
  ...state,
10768
10881
  featureTip: context.featureTip,
10769
10882
  mergedBaseTip: mergedBaseTip || context.featureTip,
10883
+ strategy: context.completionStrategy,
10884
+ integratedCommit: mergedBaseTip || context.featureTip,
10885
+ ...integratedTree ? { integratedTree } : {},
10886
+ ...context.completionStrategy === "local-squash" ? { evidenceRef: context.evidenceRef } : {},
10770
10887
  status: "merged",
10771
10888
  mergedAt: now,
10772
10889
  verifiedAt: null,
@@ -10776,7 +10893,7 @@ async function runLocalMerge(featureName, options) {
10776
10893
  await writeLocalIntegrationState(context.projectRoot, feature, state);
10777
10894
  context = await resolveLocalIntegrationContext(config, feature);
10778
10895
  }
10779
- if (!context.baseContainsFeature) {
10896
+ if (!context.integrationComplete) {
10780
10897
  return blocked("LOCAL_MERGE_VERIFICATION_FAILED", feature.folderName);
10781
10898
  }
10782
10899
  const verification = runPostMergeChecks(
@@ -10825,6 +10942,7 @@ async function runLocalMerge(featureName, options) {
10825
10942
  featureBranch: context.featureBranch,
10826
10943
  featureTip: context.featureTip,
10827
10944
  baseTip,
10945
+ completionStrategy: context.completionStrategy,
10828
10946
  verification
10829
10947
  };
10830
10948
  }
@@ -10855,7 +10973,7 @@ async function runLocalCleanup(featureName, options) {
10855
10973
  }
10856
10974
  const feature = selection.matchedFeature;
10857
10975
  let context = await resolveLocalIntegrationContext(config, feature);
10858
- if (!context.baseContainsFeature || !context.state || !["verified", "cleaned"].includes(context.state.status) || context.state.featureTip !== context.featureTip || context.state.mergedBaseTip !== context.baseTip) {
10976
+ if (!context.integrationComplete || !context.state || !["verified", "cleaned"].includes(context.state.status) || context.state.featureTip !== context.featureTip || context.state.mergedBaseTip !== context.baseTip) {
10859
10977
  return blocked("LOCAL_MERGE_NOT_VERIFIED", feature.folderName);
10860
10978
  }
10861
10979
  if (!context.featureWorktreeClean || !context.projectRootClean || !context.docsClean) {
@@ -10881,7 +10999,7 @@ async function runLocalCleanup(featureName, options) {
10881
10999
  if (context.deleteFeatureBranchAfterMerge && context.featureBranchExists) {
10882
11000
  const removeBranch = gitRun(context.projectRoot, [
10883
11001
  "branch",
10884
- "-d",
11002
+ context.completionStrategy === "local-squash" ? "-D" : "-d",
10885
11003
  context.featureBranch
10886
11004
  ]);
10887
11005
  if (removeBranch.code !== 0) {
@@ -10905,9 +11023,106 @@ async function runLocalCleanup(featureName, options) {
10905
11023
  baseBranch: context.baseBranch,
10906
11024
  featureBranch: context.featureBranch,
10907
11025
  featureTip: context.featureTip,
10908
- baseTip: context.baseTip
11026
+ baseTip: context.baseTip,
11027
+ completionStrategy: context.completionStrategy
10909
11028
  };
10910
11029
  }
11030
+ function integrateLocalFastForward(context, featureRef) {
11031
+ const merge = gitRun(context.projectRoot, [
11032
+ "merge",
11033
+ "--ff-only",
11034
+ context.featureBranch
11035
+ ]);
11036
+ if (merge.code !== 0) {
11037
+ return {
11038
+ status: "blocked",
11039
+ payload: blocked(
11040
+ "LOCAL_MERGE_NOT_FAST_FORWARD",
11041
+ featureRef,
11042
+ merge.stderr
11043
+ )
11044
+ };
11045
+ }
11046
+ return { status: "ok" };
11047
+ }
11048
+ function integrateLocalSquash(context, featureRef, featureId, featureSlug) {
11049
+ const originalBaseTip = context.baseTip;
11050
+ if (!originalBaseTip || !context.featureTip) {
11051
+ return {
11052
+ status: "blocked",
11053
+ payload: blocked("LOCAL_MERGE_REF_NOT_FOUND", featureRef)
11054
+ };
11055
+ }
11056
+ const squash = gitRun(context.projectRoot, [
11057
+ "merge",
11058
+ "--squash",
11059
+ context.featureBranch
11060
+ ]);
11061
+ if (squash.code !== 0) {
11062
+ rollbackSquash(context.projectRoot, originalBaseTip);
11063
+ return {
11064
+ status: "blocked",
11065
+ payload: blocked("LOCAL_SQUASH_FAILED", featureRef, squash.stderr)
11066
+ };
11067
+ }
11068
+ const scope = resolveFeatureCommitScope({
11069
+ featureId,
11070
+ workflowMode: "local"
11071
+ });
11072
+ if (!scope) {
11073
+ rollbackSquash(context.projectRoot, originalBaseTip);
11074
+ return {
11075
+ status: "blocked",
11076
+ payload: blocked(
11077
+ "LOCAL_SQUASH_COMMIT_SCOPE_INVALID",
11078
+ featureRef,
11079
+ `Could not resolve a canonical local commit scope from ${featureId}.`
11080
+ )
11081
+ };
11082
+ }
11083
+ const commit = gitRun(context.projectRoot, [
11084
+ "commit",
11085
+ "-m",
11086
+ `feat(${scope}): integrate ${featureSlug}`
11087
+ ]);
11088
+ if (commit.code !== 0) {
11089
+ rollbackSquash(context.projectRoot, originalBaseTip);
11090
+ return {
11091
+ status: "blocked",
11092
+ payload: blocked("LOCAL_SQUASH_COMMIT_FAILED", featureRef, commit.stderr)
11093
+ };
11094
+ }
11095
+ const integratedCommit = resolveRef(context.projectRoot, "HEAD");
11096
+ const sourceTree = resolveTree(context.projectRoot, context.featureTip);
11097
+ const integratedTree = integratedCommit ? resolveTree(context.projectRoot, integratedCommit) : null;
11098
+ if (!integratedCommit || !sourceTree || integratedTree !== sourceTree) {
11099
+ rollbackSquash(context.projectRoot, originalBaseTip);
11100
+ return {
11101
+ status: "blocked",
11102
+ payload: blocked("LOCAL_SQUASH_TREE_MISMATCH", featureRef)
11103
+ };
11104
+ }
11105
+ const evidence = preserveLocalIntegrationEvidence(
11106
+ context.projectRoot,
11107
+ context.evidenceRef,
11108
+ context.featureTip
11109
+ );
11110
+ if (evidence.code !== 0) {
11111
+ rollbackSquash(context.projectRoot, originalBaseTip);
11112
+ return {
11113
+ status: "blocked",
11114
+ payload: blocked(
11115
+ "LOCAL_SQUASH_EVIDENCE_FAILED",
11116
+ featureRef,
11117
+ evidence.stderr
11118
+ )
11119
+ };
11120
+ }
11121
+ return { status: "ok" };
11122
+ }
11123
+ function rollbackSquash(projectRoot, originalBaseTip) {
11124
+ gitRun(projectRoot, ["reset", "--hard", originalBaseTip]);
11125
+ }
10911
11126
  function blocked(reasonCode, featureRef, error) {
10912
11127
  return {
10913
11128
  status: "blocked",