lee-spec-kit 0.9.0 → 0.9.2

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 +2 -1
  2. package/README.md +3 -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 +600 -89
  9. package/dist/index.js.map +1 -1
  10. package/package.json +13 -15
  11. package/templates/en/common/README.md +11 -9
  12. package/templates/en/common/agents/agents.md +9 -3
  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 +6 -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 +4 -3
  21. package/templates/ko/common/README.md +11 -9
  22. package/templates/ko/common/agents/agents.md +9 -3
  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 +6 -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 +4 -3
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(
@@ -1306,8 +1308,8 @@ Before taking the next workflow step:
1306
1308
  12. When docs are synced to code, keep exactly one explicit marker like \`<!-- lee-spec-kit:workflow-sync 2026-04-16T12:34:56.789Z -->\` in a single active feature doc (prefer \`tasks.md\` or \`decisions.md\`): replace an existing marker timestamp or remove duplicates instead of appending another marker, so \`workflow-audit\` can prove the sync happened after the latest code change
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
- 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
1311
+ 15. For a local workflow, do not report completion directly after implementation approval; follow the exact returned \`local verify\` / \`local merge\` / \`local cleanup\` commands until \`workflow-stage\` proves verification, integration, and cleanup and returns \`done\`; \`feature_remediation\` explicitly permits Feature worktree fixes
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",
@@ -2534,6 +2543,7 @@ async function runInit(options) {
2534
2543
  baseBranch: resolveInitialBaseBranch(cwd, docsRepo, projectRoot),
2535
2544
  completionStrategy: "local-ff",
2536
2545
  deleteFeatureBranchAfterMerge: true,
2546
+ featureChecks: [],
2537
2547
  postMergeChecks: []
2538
2548
  } : {},
2539
2549
  prePrReview: {
@@ -3481,6 +3491,11 @@ function isLegacyGeneratedApprovalConfig(approval) {
3481
3491
  ];
3482
3492
  return !overrideKeys.some((key) => hasOwnKey2(approval, key));
3483
3493
  }
3494
+ function isPreviousDefaultApprovalConfig(approval) {
3495
+ const keys = Object.keys(approval).sort();
3496
+ const categories = Array.isArray(approval.requireCheckCategories) ? approval.requireCheckCategories : [];
3497
+ 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";
3498
+ }
3484
3499
  function updateCommand(program2) {
3485
3500
  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
3501
  "-f, --force",
@@ -3720,7 +3735,7 @@ async function backfillMissingConfigDefaults(cwd, docsDir) {
3720
3735
  "none",
3721
3736
  "workflow.completionStrategy"
3722
3737
  );
3723
- if (workflow.completionStrategy !== "local-ff" && workflow.completionStrategy !== "none") {
3738
+ if (workflow.completionStrategy !== "local-ff" && workflow.completionStrategy !== "local-squash" && workflow.completionStrategy !== "none") {
3724
3739
  workflow.completionStrategy = "none";
3725
3740
  changedPaths.push("workflow.completionStrategy");
3726
3741
  }
@@ -3734,6 +3749,33 @@ async function backfillMissingConfigDefaults(cwd, docsDir) {
3734
3749
  workflow.deleteFeatureBranchAfterMerge = true;
3735
3750
  changedPaths.push("workflow.deleteFeatureBranchAfterMerge");
3736
3751
  }
3752
+ if (!Object.prototype.hasOwnProperty.call(workflow, "featureChecks")) {
3753
+ workflow.featureChecks = Array.isArray(workflow.postMergeChecks) ? workflow.postMergeChecks : [];
3754
+ workflow.postMergeChecks = [];
3755
+ changedPaths.push("workflow.featureChecks");
3756
+ changedPaths.push("workflow.postMergeChecks");
3757
+ }
3758
+ setIfMissing(workflow, "featureChecks", [], "workflow.featureChecks");
3759
+ if (!Array.isArray(workflow.featureChecks)) {
3760
+ workflow.featureChecks = [];
3761
+ changedPaths.push("workflow.featureChecks");
3762
+ } else {
3763
+ const normalizedFeatureChecks = workflow.featureChecks.flatMap(
3764
+ (value) => {
3765
+ if (!isPlainObject(value) || typeof value.command !== "string") {
3766
+ return [];
3767
+ }
3768
+ const command = value.command.trim();
3769
+ if (!command) return [];
3770
+ const args = Array.isArray(value.args) ? value.args.filter((arg) => typeof arg === "string") : [];
3771
+ return [{ command, ...args.length > 0 ? { args } : {} }];
3772
+ }
3773
+ );
3774
+ if (JSON.stringify(normalizedFeatureChecks) !== JSON.stringify(workflow.featureChecks)) {
3775
+ workflow.featureChecks = normalizedFeatureChecks;
3776
+ changedPaths.push("workflow.featureChecks");
3777
+ }
3778
+ }
3737
3779
  setIfMissing(workflow, "postMergeChecks", [], "workflow.postMergeChecks");
3738
3780
  if (!Array.isArray(workflow.postMergeChecks)) {
3739
3781
  workflow.postMergeChecks = [];
@@ -3822,7 +3864,7 @@ async function backfillMissingConfigDefaults(cwd, docsDir) {
3822
3864
  const migration = migrateLegacyApprovalSettings(approval);
3823
3865
  raw.approval = migration.approval;
3824
3866
  changedPaths.push(...migration.changedPaths);
3825
- if (isPlainObject(raw.approval) && isLegacyGeneratedApprovalConfig(raw.approval)) {
3867
+ if (isPlainObject(raw.approval) && (isLegacyGeneratedApprovalConfig(raw.approval) || isPreviousDefaultApprovalConfig(raw.approval))) {
3826
3868
  raw.approval = createDefaultApprovalConfig();
3827
3869
  changedPaths.push("approval");
3828
3870
  }
@@ -6697,6 +6739,14 @@ var BUILTIN_DOC_DEFINITIONS = [
6697
6739
  id: "split-feature",
6698
6740
  title: { ko: "feature \uBD84\uD560 \uAC00\uC774\uB4DC", en: "feature split guide" },
6699
6741
  relativePath: (_, lang) => path8.join(lang, "common", "agents", "skills", "split-feature.md")
6742
+ },
6743
+ {
6744
+ id: "ui-ux-design",
6745
+ title: {
6746
+ ko: "\uC120\uD0DD\uC801 UI/UX \uB514\uC790\uC778 \uBB38\uC11C \uC815\uCC45",
6747
+ en: "Optional UI/UX Design Documentation Policy"
6748
+ },
6749
+ relativePath: (_, lang) => path8.join(lang, "common", "agents", "ui-ux-design.md")
6700
6750
  }
6701
6751
  ];
6702
6752
  var DOC_FOLLOWUPS = {
@@ -6717,7 +6767,8 @@ var DOC_FOLLOWUPS = {
6717
6767
  "execute-task": ["git-workflow", "split-feature"],
6718
6768
  "create-issue": ["issue-doc"],
6719
6769
  "create-pr": ["pr-doc"],
6720
- "split-feature": []
6770
+ "split-feature": [],
6771
+ "ui-ux-design": []
6721
6772
  };
6722
6773
  function getBuiltinDocIds() {
6723
6774
  return BUILTIN_DOC_DEFINITIONS.map((doc) => doc.id);
@@ -6736,6 +6787,7 @@ function normalizeBuiltinDocId(input) {
6736
6787
  if (normalized === "split-feature") {
6737
6788
  return "split-feature";
6738
6789
  }
6790
+ if (normalized === "ui-ux-design") return "ui-ux-design";
6739
6791
  if (normalized === "agents") return "agents";
6740
6792
  return null;
6741
6793
  }
@@ -7197,11 +7249,9 @@ function findPlaceholderDecisionRange(content) {
7197
7249
  const block = content.slice(match.index, end);
7198
7250
  const isPlaceholder = /\{(?:Decision Title|결정 제목)\}/.test(match[0]) || /-\s+\*\*Decision\*\*:\s*(Final choice|최종 선택)/.test(block);
7199
7251
  if (!isPlaceholder) return null;
7200
- const sequence = Number(match[1] || "1");
7201
7252
  return {
7202
7253
  start: match.index,
7203
- end,
7204
- sequence: Number.isFinite(sequence) ? sequence : 1
7254
+ end
7205
7255
  };
7206
7256
  }
7207
7257
  function formatOptions(options) {
@@ -7242,7 +7292,7 @@ async function runDecisionAdd(featureName, options) {
7242
7292
  const optionItems = (options.option || []).map((value) => value.trim()).filter(Boolean);
7243
7293
  const content = await fs.readFile(target.path, "utf-8");
7244
7294
  const placeholderRange = findPlaceholderDecisionRange(content);
7245
- const decisionSequence = placeholderRange?.sequence ?? getNextDecisionSequence(content);
7295
+ const decisionSequence = placeholderRange ? 1 : getNextDecisionSequence(content);
7246
7296
  const decisionId = `D${String(decisionSequence).padStart(3, "0")}`;
7247
7297
  const recordedAt = localDate();
7248
7298
  const block = formatDecisionBlock({
@@ -7551,6 +7601,44 @@ function integrationsCommand(program2) {
7551
7601
  registerCodexIntegration(integrations);
7552
7602
  registerCodexHooksIntegration(integrations);
7553
7603
  }
7604
+
7605
+ // src/utils/commit-conventions.ts
7606
+ var PROJECT_COMMIT_TYPES = [
7607
+ "feat",
7608
+ "fix",
7609
+ "refactor",
7610
+ "test",
7611
+ "style",
7612
+ "chore"
7613
+ ];
7614
+ function resolveFeatureCommitScope(input) {
7615
+ const issueNumber = Number(input.issueNumber || 0);
7616
+ if (Number.isInteger(issueNumber) && issueNumber > 0) {
7617
+ return `#${issueNumber}`;
7618
+ }
7619
+ if (input.workflowMode !== "local") {
7620
+ return null;
7621
+ }
7622
+ const featureId = String(input.featureId || "").trim().toUpperCase();
7623
+ return /^F\d{3,}$/.test(featureId) ? featureId : null;
7624
+ }
7625
+ function matchesProjectCommitConvention(message, scope) {
7626
+ return matchesCommitConvention(message, scope, PROJECT_COMMIT_TYPES);
7627
+ }
7628
+ function matchesDocsCommitConvention(message, scope) {
7629
+ return matchesCommitConvention(message, scope, ["docs"]);
7630
+ }
7631
+ function matchesCommitConvention(message, scope, types) {
7632
+ const normalized = String(message || "").trim();
7633
+ const normalizedScope = String(scope || "").trim();
7634
+ if (!normalized || !normalizedScope) return false;
7635
+ const typePattern = types.join("|");
7636
+ const match = normalized.match(
7637
+ new RegExp(`^(${typePattern})\\((#[0-9]+|F[0-9]{3,})\\):\\s+\\S.+$`, "i")
7638
+ );
7639
+ if (!match) return false;
7640
+ return match[2].toLowerCase() === normalizedScope.toLowerCase();
7641
+ }
7554
7642
  function isSameOrWithin2(parentDir, candidatePath) {
7555
7643
  const relative = path8.relative(parentDir, candidatePath);
7556
7644
  return relative === "" || relative !== ".." && !relative.startsWith(`..${path8.sep}`) && !path8.isAbsolute(relative);
@@ -7591,7 +7679,8 @@ function isPrePrEvidenceSatisfied(check) {
7591
7679
  }
7592
7680
  var BRANCH_LABELS2 = ["Branch", "\uBE0C\uB79C\uCE58"];
7593
7681
  function resolveLocalCompletionStrategy(config) {
7594
- return config.workflow?.completionStrategy === "local-ff" ? "local-ff" : "none";
7682
+ const strategy = config.workflow?.completionStrategy;
7683
+ return strategy === "local-ff" || strategy === "local-squash" ? strategy : "none";
7595
7684
  }
7596
7685
  async function resolveLocalIntegrationContext(config, feature) {
7597
7686
  const projectRoot = resolveProjectRoot(config, feature);
@@ -7611,6 +7700,9 @@ async function resolveLocalIntegrationContext(config, feature) {
7611
7700
  );
7612
7701
  }
7613
7702
  const state = await readLocalIntegrationState(projectRoot, feature);
7703
+ const configuredStrategy = resolveLocalCompletionStrategy(config);
7704
+ const completionStrategy = configuredStrategy === "local-squash" ? "local-squash" : "local-ff";
7705
+ const evidenceRef = resolveEvidenceRef(feature);
7614
7706
  const featureBranchTip = resolveRef(
7615
7707
  projectRoot,
7616
7708
  `refs/heads/${featureBranch}`
@@ -7624,6 +7716,14 @@ async function resolveLocalIntegrationContext(config, feature) {
7624
7716
  );
7625
7717
  const managedFeatureWorktree = !!registeredWorktree && path8.resolve(registeredWorktree) !== path8.resolve(projectRoot);
7626
7718
  const effectiveFeatureWorktree = registeredWorktree || (fs.existsSync(featureWorktree) ? featureWorktree : projectRoot);
7719
+ const baseContainsFeature = !!featureTip && !!baseTip && isAncestor(projectRoot, featureTip, `refs/heads/${baseBranch}`);
7720
+ const integratedCommit = state?.integratedCommit || state?.mergedBaseTip;
7721
+ const evidenceTip = resolveRef(projectRoot, evidenceRef);
7722
+ const featureTree = featureTip ? resolveTree(projectRoot, featureTip) : null;
7723
+ const integratedTree = integratedCommit ? resolveTree(projectRoot, integratedCommit) : null;
7724
+ const squashCommitMatchesSource = completionStrategy === "local-squash" && state?.strategy === "local-squash" && !!featureTip && state.featureTip === featureTip && !!integratedCommit && !!baseTip && integratedCommit === baseTip && !!featureTree && integratedTree === featureTree && state.integratedTree === featureTree;
7725
+ const squashEvidencePresent = !!featureTip && evidenceTip === featureTip;
7726
+ const squashIntegrationComplete = squashCommitMatchesSource && squashEvidencePresent;
7627
7727
  return {
7628
7728
  projectRoot,
7629
7729
  featureWorktree: effectiveFeatureWorktree,
@@ -7631,16 +7731,25 @@ async function resolveLocalIntegrationContext(config, feature) {
7631
7731
  baseBranch,
7632
7732
  featureBranch,
7633
7733
  featureTip,
7734
+ featureTree,
7634
7735
  baseTip,
7635
7736
  currentBranch,
7636
7737
  state,
7637
- baseContainsFeature: !!featureTip && !!baseTip && isAncestor(projectRoot, featureTip, `refs/heads/${baseBranch}`),
7738
+ completionStrategy,
7739
+ baseContainsFeature,
7740
+ integrationComplete: completionStrategy === "local-squash" ? squashIntegrationComplete : !!featureTip && baseTip === featureTip,
7741
+ evidenceRef,
7742
+ squashCommitMatchesSource,
7743
+ squashEvidencePresent,
7638
7744
  projectRootClean: isClean(projectRoot),
7639
7745
  featureWorktreeClean: isClean(effectiveFeatureWorktree),
7640
7746
  docsClean: isClean(feature.git.docsGitCwd),
7641
7747
  managedFeatureWorktree,
7642
7748
  featureBranchExists: !!featureBranchTip,
7643
- postMergeChecks: normalizePostMergeChecks(config.workflow?.postMergeChecks),
7749
+ featureChecks: Array.isArray(config.workflow?.featureChecks) ? normalizeWorkflowChecks(config.workflow.featureChecks) : normalizeWorkflowChecks(config.workflow?.postMergeChecks),
7750
+ // Compatibility: pre-0.9.2 postMergeChecks are treated as Feature checks.
7751
+ // Projects can opt into true post-integration checks by defining featureChecks.
7752
+ postMergeChecks: Array.isArray(config.workflow?.featureChecks) ? normalizeWorkflowChecks(config.workflow?.postMergeChecks) : [],
7644
7753
  deleteFeatureBranchAfterMerge: config.workflow?.deleteFeatureBranchAfterMerge !== false
7645
7754
  };
7646
7755
  }
@@ -7663,22 +7772,71 @@ async function readLocalIntegrationState(projectRoot, feature) {
7663
7772
  }
7664
7773
  }
7665
7774
  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;
7775
+ 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
7776
  }
7668
- function runPostMergeChecks(projectRoot, checks) {
7777
+ function runLocalChecks(cwd, checks, targetSha, logDir, phase) {
7669
7778
  const results = [];
7779
+ fs.ensureDirSync(logDir);
7670
7780
  for (const check of checks) {
7671
7781
  const args = Array.isArray(check.args) ? check.args.map(String) : [];
7672
- const result = runProcess(check.command, args, projectRoot);
7782
+ const startedAt = /* @__PURE__ */ new Date();
7783
+ const result = runProcess(check.command, args, cwd);
7784
+ const finishedAt = /* @__PURE__ */ new Date();
7785
+ const runtime = {
7786
+ node: process.version,
7787
+ platform: process.platform,
7788
+ arch: process.arch,
7789
+ path: process.env.PATH || ""
7790
+ };
7791
+ const index = results.length + 1;
7792
+ const logPath = path8.join(logDir, `${phase}-${String(index).padStart(2, "0")}.log`);
7793
+ const log = [
7794
+ `phase: ${phase}`,
7795
+ `targetSha: ${targetSha}`,
7796
+ `cwd: ${cwd}`,
7797
+ `command: ${JSON.stringify([check.command, ...args])}`,
7798
+ `startedAt: ${startedAt.toISOString()}`,
7799
+ `finishedAt: ${finishedAt.toISOString()}`,
7800
+ `exitCode: ${result.code}`,
7801
+ `runtime: ${JSON.stringify(runtime)}`,
7802
+ "",
7803
+ "--- stdout ---",
7804
+ result.stdout,
7805
+ "--- stderr ---",
7806
+ result.stderr
7807
+ ].join("\n");
7808
+ fs.writeFileSync(logPath, log, { encoding: "utf-8", mode: 384 });
7673
7809
  results.push({
7674
7810
  command: check.command,
7675
7811
  args,
7676
- exitCode: result.code
7812
+ cwd,
7813
+ targetSha,
7814
+ startedAt: startedAt.toISOString(),
7815
+ finishedAt: finishedAt.toISOString(),
7816
+ durationMs: finishedAt.getTime() - startedAt.getTime(),
7817
+ exitCode: result.code,
7818
+ runtime,
7819
+ ...preview(result.stdout) ? { stdoutPreview: preview(result.stdout) } : {},
7820
+ ...preview(result.stderr) ? { stderrPreview: preview(result.stderr) } : {},
7821
+ logPath
7677
7822
  });
7678
7823
  if (result.code !== 0) break;
7679
7824
  }
7680
7825
  return results;
7681
7826
  }
7827
+ function resolveLocalIntegrationLogDir(projectRoot, feature, targetSha) {
7828
+ return path8.join(
7829
+ path8.dirname(resolveStatePath(projectRoot, feature)),
7830
+ "logs",
7831
+ resolveFeatureStateKey(feature),
7832
+ targetSha.slice(0, 12)
7833
+ );
7834
+ }
7835
+ function preview(value) {
7836
+ const trimmed = value.trim();
7837
+ return trimmed.length > 2e3 ? `${trimmed.slice(0, 2e3)}
7838
+ \u2026[truncated]` : trimmed;
7839
+ }
7682
7840
  function gitRun(cwd, args) {
7683
7841
  return runProcess("git", args, cwd);
7684
7842
  }
@@ -7688,6 +7846,12 @@ function currentGitBranch(cwd) {
7688
7846
  function resolveRef(cwd, ref) {
7689
7847
  return runGitCapture(["rev-parse", "--verify", ref], cwd) || null;
7690
7848
  }
7849
+ function resolveTree(cwd, commit) {
7850
+ return resolveRef(cwd, `${commit}^{tree}`);
7851
+ }
7852
+ function preserveLocalIntegrationEvidence(projectRoot, evidenceRef, featureTip) {
7853
+ return gitRun(projectRoot, ["update-ref", evidenceRef, featureTip]);
7854
+ }
7691
7855
  function isAncestor(cwd, ancestor, descendant) {
7692
7856
  return runProcess(
7693
7857
  "git",
@@ -7711,7 +7875,7 @@ function resolveProjectRoot(config, feature) {
7711
7875
  function resolveStatePath(projectRoot, feature) {
7712
7876
  const commonDirRaw = runGitCapture(["rev-parse", "--git-common-dir"], projectRoot) || ".git";
7713
7877
  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);
7878
+ const key = resolveFeatureStateKey(feature);
7715
7879
  return path8.join(
7716
7880
  commonDir,
7717
7881
  "lee-spec-kit",
@@ -7719,6 +7883,12 @@ function resolveStatePath(projectRoot, feature) {
7719
7883
  `${key}.json`
7720
7884
  );
7721
7885
  }
7886
+ function resolveEvidenceRef(feature) {
7887
+ return `refs/lee-spec-kit/integrations/${resolveFeatureStateKey(feature)}`;
7888
+ }
7889
+ function resolveFeatureStateKey(feature) {
7890
+ return crypto.createHash("sha256").update(`${feature.type}:${feature.folderName}`).digest("hex").slice(0, 24);
7891
+ }
7722
7892
  async function readTasks(featureDir) {
7723
7893
  const tasksPath = path8.join(featureDir, "tasks.md");
7724
7894
  return await fs.pathExists(tasksPath) ? fs.readFile(tasksPath, "utf-8") : "";
@@ -7758,7 +7928,7 @@ function resolveRegisteredBranchWorktree(projectRoot, branch) {
7758
7928
  }
7759
7929
  return null;
7760
7930
  }
7761
- function normalizePostMergeChecks(value) {
7931
+ function normalizeWorkflowChecks(value) {
7762
7932
  if (!Array.isArray(value)) return [];
7763
7933
  return value.filter(
7764
7934
  (entry) => !!entry && typeof entry === "object" && typeof entry.command === "string" && !!entry.command.trim()
@@ -7777,6 +7947,11 @@ var PR_STATUS_LABELS = ["PR Status", "PR \uC0C1\uD0DC"];
7777
7947
  var PRE_PR_REVIEW_LABELS = ["Pre-PR Review", "PR \uC804 \uB9AC\uBDF0"];
7778
7948
  var PRE_PR_EVIDENCE_LABELS = ["Pre-PR Evidence", "PR \uC804 \uB9AC\uBDF0 Evidence"];
7779
7949
  var PRE_PR_DECISION_LABELS = ["Pre-PR Decision", "PR \uC804 \uB9AC\uBDF0 Decision"];
7950
+ var COMPLETION_MARKERS = {
7951
+ allTasks: "lee-spec-kit:completion:all-tasks",
7952
+ tests: "lee-spec-kit:completion:tests",
7953
+ finalOutcome: "lee-spec-kit:completion:final-outcome"
7954
+ };
7780
7955
  function resolveWorkflowRequirements(config) {
7781
7956
  const workflow = config.workflow || {};
7782
7957
  const hasCanonicalMode = workflow.mode === "github" || workflow.mode === "local";
@@ -7831,6 +8006,16 @@ function withoutFencedCodeBlocks(content) {
7831
8006
  }
7832
8007
  return lines;
7833
8008
  }
8009
+ function parseCompletionCheckbox(lines, marker, legacyPattern) {
8010
+ const markerToken = `<!-- ${marker} -->`;
8011
+ const markedLines = lines.filter((line) => line.includes(markerToken));
8012
+ if (markedLines.length > 0) {
8013
+ return markedLines.length === 1 && parseMarkdownCheckbox(markedLines[0]) === true;
8014
+ }
8015
+ return lines.some(
8016
+ (line) => legacyPattern.test(line) && parseMarkdownCheckbox(line) === true
8017
+ );
8018
+ }
7834
8019
  function parseTasksDoc(content) {
7835
8020
  const issueRaw = extractFieldValue2(content, ISSUE_LABELS);
7836
8021
  const issueNumberMatch = issueRaw?.match(/^#(\d+)$/);
@@ -7851,16 +8036,20 @@ function parseTasksDoc(content) {
7851
8036
  title: match[2].trim()
7852
8037
  });
7853
8038
  }
7854
- const allTasksChecked = nonCodeLines.some(
7855
- (line) => /(All tasks are|모든 태스크가)/i.test(line) && parseMarkdownCheckbox(line) === true
8039
+ const allTasksChecked = parseCompletionCheckbox(
8040
+ nonCodeLines,
8041
+ COMPLETION_MARKERS.allTasks,
8042
+ /(All tasks are|모든 태스크가)/i
7856
8043
  );
7857
- const testsChecked = nonCodeLines.some(
7858
- (line) => /(Tests executed and passing|테스트 실행 및 통과)/i.test(line) && parseMarkdownCheckbox(line) === true
8044
+ const testsChecked = parseCompletionCheckbox(
8045
+ nonCodeLines,
8046
+ COMPLETION_MARKERS.tests,
8047
+ /(Tests executed and passing|테스트 실행 및 통과)/i
7859
8048
  );
7860
- const finalOutcomeChecked = nonCodeLines.some(
7861
- (line) => /(Final outcome shared and any required user confirmation recorded|Final user approval|최종 결과를 공유했고, 필요한 사용자 확인을 문서화된 workflow checkpoint 기준으로 기록함)/i.test(
7862
- line
7863
- ) && parseMarkdownCheckbox(line) === true
8049
+ const finalOutcomeChecked = parseCompletionCheckbox(
8050
+ nonCodeLines,
8051
+ COMPLETION_MARKERS.finalOutcome,
8052
+ /(Final outcome shared and any required user confirmation recorded|Final user approval|최종 결과를 공유했고, 필요한 사용자 확인을 문서화된 workflow checkpoint 기준으로 기록함)/i
7864
8053
  );
7865
8054
  const prStatus = (() => {
7866
8055
  const value = (extractFieldValue2(content, PR_STATUS_LABELS) || "").trim().toLowerCase();
@@ -8116,8 +8305,13 @@ function resolveProjectCommitTopic(feature, tasks) {
8116
8305
  }
8117
8306
  function buildTaskCommitSummary(input) {
8118
8307
  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)}"`;
8308
+ const scope = resolveFeatureCommitScope({
8309
+ issueNumber: tasks.issueNumber,
8310
+ featureId: feature.id,
8311
+ workflowMode: tasks.issueNumber ? "github" : "local"
8312
+ }) || feature.id;
8313
+ 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"`;
8314
+ 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
8315
  const lines = ["Finish the task-level commit checkpoint before continuing."];
8122
8316
  if (gateFailureReason) {
8123
8317
  lines.push(`Current gate failure: ${gateFailureReason}`);
@@ -8987,11 +9181,13 @@ Task commit boundary warning: ${describeTaskCommitGateFailure(committedTaskGate)
8987
9181
  "implementation_approve",
8988
9182
  true
8989
9183
  );
8990
- const localIntegrationApproval = config.workflow?.mode === "local" && resolveLocalCompletionStrategy(config) === "local-ff";
9184
+ const localIntegrationApproval = config.workflow?.mode === "local" && resolveLocalCompletionStrategy(config) !== "none";
9185
+ const separateLocalMergeApproval = localIntegrationApproval && resolveActionApprovalRequired(config, "local_merge", false);
8991
9186
  const localBaseBranch = config.workflow?.baseBranch?.trim() || "main";
9187
+ const localIntegrationLabel = resolveLocalCompletionStrategy(config) === "local-squash" ? `squash integration into ${localBaseBranch}` : `fast-forward integration into ${localBaseBranch}`;
8992
9188
  const localCleanupSummary = config.workflow?.deleteFeatureBranchAfterMerge === false ? "remove any managed Feature worktree" : "remove any managed Feature worktree and delete the integrated local Feature branch";
8993
9189
  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.",
9190
+ 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
9191
  holdSummary: "Request implementation changes before the workflow continues."
8996
9192
  }) : void 0;
8997
9193
  return {
@@ -9002,7 +9198,7 @@ Task commit boundary warning: ${describeTaskCommitGateFailure(committedTaskGate)
9002
9198
  stage: "implementation_approve",
9003
9199
  nextAction: buildAction(
9004
9200
  "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.",
9201
+ 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
9202
  approvalRequired
9007
9203
  ),
9008
9204
  approvalRequired,
@@ -9031,10 +9227,51 @@ Task commit boundary warning: ${describeTaskCommitGateFailure(committedTaskGate)
9031
9227
  blockedReasonCode: "PRE_PR_REVIEW_NOT_APPROVED"
9032
9228
  };
9033
9229
  }
9034
- if (config.workflow?.mode === "local" && resolveLocalCompletionStrategy(config) === "local-ff") {
9230
+ if (config.workflow?.mode === "local" && resolveLocalCompletionStrategy(config) !== "none") {
9035
9231
  const localState = await resolveLocalIntegrationContext(config, feature);
9232
+ const localVerifyCommand = `npx lee-spec-kit local verify ${buildFeatureArgs(feature)} --json`;
9036
9233
  const localMergeBaseCommand = `npx lee-spec-kit local merge ${buildFeatureArgs(feature)} --json`;
9037
- if (!localState.baseContainsFeature) {
9234
+ if (!localState.integrationComplete) {
9235
+ const featureVerified = !!localState.state && ["feature_verified", "merged", "verified", "cleaned"].includes(
9236
+ localState.state.status
9237
+ ) && localState.state.verifiedFeatureTip === localState.featureTip && localState.state.verifiedFeatureTree === localState.featureTree;
9238
+ const featureVerificationFailed = localState.state?.status === "feature_failed" && localState.state.featureTip === localState.featureTip;
9239
+ if (featureVerificationFailed) {
9240
+ return {
9241
+ status: "ok",
9242
+ reasonCode: "WORKFLOW_STAGE_RESOLVED",
9243
+ docsDir: config.docsDir,
9244
+ featureRef: buildFeatureRef(feature),
9245
+ stage: "feature_remediation",
9246
+ nextAction: buildAction(
9247
+ "feature_remediation",
9248
+ `Fix the failed checks in ${localState.featureBranch} at ${localState.featureWorktree}, commit the remediation, then verify the new Feature tip. Re-run the command without code changes to retry an environmental failure.`,
9249
+ false,
9250
+ localVerifyCommand
9251
+ ),
9252
+ approvalRequired: false,
9253
+ implementationAllowed: true,
9254
+ blockedReasonCode: "FEATURE_REMEDIATION_REQUIRED"
9255
+ };
9256
+ }
9257
+ if (!featureVerified) {
9258
+ return {
9259
+ status: "ok",
9260
+ reasonCode: "WORKFLOW_STAGE_RESOLVED",
9261
+ docsDir: config.docsDir,
9262
+ featureRef: buildFeatureRef(feature),
9263
+ stage: "feature_verify",
9264
+ nextAction: buildAction(
9265
+ "feature_verify",
9266
+ `Run the configured Feature checks in ${localState.featureBranch} before integration and bind the result to its exact commit and tree.`,
9267
+ false,
9268
+ localVerifyCommand
9269
+ ),
9270
+ approvalRequired: false,
9271
+ implementationAllowed: false,
9272
+ blockedReasonCode: "FEATURE_VERIFICATION_REQUIRED"
9273
+ };
9274
+ }
9038
9275
  const approvalRequired = resolveActionApprovalRequired(
9039
9276
  config,
9040
9277
  "local_merge",
@@ -9042,7 +9279,7 @@ Task commit boundary warning: ${describeTaskCommitGateFailure(committedTaskGate)
9042
9279
  );
9043
9280
  const command = approvalRequired ? `${localMergeBaseCommand} --confirm OK` : localMergeBaseCommand;
9044
9281
  const actionOptions = approvalRequired ? buildApprovalActionOptions({
9045
- approveSummary: `Fast-forward ${localState.featureBranch} into ${localState.baseBranch} and run the configured post-merge checks.`,
9282
+ 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
9283
  holdSummary: "Keep the completed Feature branch unmerged for now."
9047
9284
  }) : void 0;
9048
9285
  return {
@@ -9053,7 +9290,7 @@ Task commit boundary warning: ${describeTaskCommitGateFailure(committedTaskGate)
9053
9290
  stage: "local_merge",
9054
9291
  nextAction: buildAction(
9055
9292
  "local_merge",
9056
- `Fast-forward ${localState.featureBranch} into ${localState.baseBranch}; do not create a merge commit.`,
9293
+ 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
9294
  approvalRequired,
9058
9295
  command
9059
9296
  ),
@@ -9991,35 +10228,21 @@ var DEFAULT_MANAGED_DOC_FILES = [
9991
10228
  ".gitignore"
9992
10229
  ];
9993
10230
 
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
10231
  // src/commands/commit-audit.ts
10013
10232
  var CANONICAL_FEATURE_DOC_PATTERN = /^features\/(?:[^/]+\/)?F\d{3,}[^/]*\/(spec|plan|tasks|decisions|issue|pr)\.md$/i;
10014
10233
  var FEATURE_DOC_CANDIDATE_PATTERN = /^features\/(?:[^/]+\/)?F\d{3,}[^/]*\/(.+)$/i;
10015
10234
  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) => {
10235
+ 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
10236
  try {
10237
+ const commitMessage = await resolveCommitMessageInput(process.cwd(), options);
10018
10238
  const payload = await collectCommitAudit(
10019
10239
  process.cwd(),
10020
10240
  options.gitRoot,
10021
- options.message
10241
+ commitMessage
10022
10242
  );
10243
+ if (options.enforce && payload.status === "blocked") {
10244
+ process.exitCode = 1;
10245
+ }
10023
10246
  if (options.json) {
10024
10247
  console.log(JSON.stringify(payload, null, 2));
10025
10248
  return;
@@ -10046,6 +10269,7 @@ function commitAuditCommand(program2) {
10046
10269
  2
10047
10270
  )
10048
10271
  );
10272
+ if (options.enforce) process.exitCode = 1;
10049
10273
  return;
10050
10274
  }
10051
10275
  process.stderr.write(`[${cliError.code}] ${cliError.message}
@@ -10054,6 +10278,18 @@ function commitAuditCommand(program2) {
10054
10278
  }
10055
10279
  });
10056
10280
  }
10281
+ async function resolveCommitMessageInput(cwd, options) {
10282
+ if (options.message && options.messageFile) {
10283
+ throw createCliError(
10284
+ "INVALID_ARGUMENT",
10285
+ "Use either --message or --message-file, not both."
10286
+ );
10287
+ }
10288
+ if (options.message) return options.message;
10289
+ if (!options.messageFile) return void 0;
10290
+ const content = await fs26.readFile(path8.resolve(cwd, options.messageFile), "utf-8");
10291
+ return content.split(/\r?\n/u).map((line) => line.trim()).find((line) => line.length > 0 && !line.startsWith("#"));
10292
+ }
10057
10293
  async function collectCommitAudit(cwd, gitRootOverride, commitMessage) {
10058
10294
  const config = await getConfig(cwd);
10059
10295
  if (!config) {
@@ -10277,10 +10513,17 @@ async function collectCommitMessageViolation(cwd, config, repoRoot, stagedEntrie
10277
10513
  return null;
10278
10514
  }
10279
10515
  const selection = await resolveFeatureSelection(cwd);
10280
- if (selection.status !== "selected" || !selection.matchedFeature?.issueNumber) {
10516
+ if (selection.status !== "selected" || !selection.matchedFeature) {
10517
+ return null;
10518
+ }
10519
+ const scope = resolveFeatureCommitScope({
10520
+ issueNumber: selection.matchedFeature.issueNumber,
10521
+ featureId: selection.matchedFeature.id,
10522
+ workflowMode: config.workflow?.mode
10523
+ });
10524
+ if (!scope) {
10281
10525
  return null;
10282
10526
  }
10283
- const issueNumber = selection.matchedFeature.issueNumber;
10284
10527
  const docsRepoRoot = runGitCapture(["rev-parse", "--show-toplevel"], config.docsDir);
10285
10528
  const normalizedRepoRoot = path8.resolve(repoRoot);
10286
10529
  const normalizedDocsRepoRoot = docsRepoRoot ? path8.resolve(docsRepoRoot) : null;
@@ -10290,15 +10533,15 @@ async function collectCommitMessageViolation(cwd, config, repoRoot, stagedEntrie
10290
10533
  return !!relativeToDocs && relativeToDocs !== "" && !relativeToDocs.startsWith("..");
10291
10534
  });
10292
10535
  const isDocsCommit = !!normalizedDocsRepoRoot && normalizedDocsRepoRoot === normalizedRepoRoot && (config.docsRepo === "standalone" || docsOnlyCommit);
10293
- const valid = isDocsCommit ? matchesDocsCommitConvention(normalizedMessage, issueNumber) : matchesProjectCommitConvention(normalizedMessage, issueNumber);
10536
+ const valid = isDocsCommit ? matchesDocsCommitConvention(normalizedMessage, scope) : matchesProjectCommitConvention(normalizedMessage, scope);
10294
10537
  if (valid) {
10295
10538
  return null;
10296
10539
  }
10297
- const expected = isDocsCommit ? `docs(#${issueNumber}): ...` : `type(#${issueNumber}): ... (feat/fix/refactor/test/chore)`;
10540
+ const expected = isDocsCommit ? `docs(${scope}): ...` : `type(${scope}): ... (feat/fix/refactor/test/style/chore)`;
10298
10541
  return {
10299
10542
  path: "(commit message)",
10300
10543
  kind: "commit_message_policy",
10301
- detail: `Commit subject must follow the issue-scoped convention for this feature. Expected ${expected}, received "${normalizedMessage}".`
10544
+ detail: `Commit subject must follow the canonical feature-scoped convention. Expected ${expected}, received "${normalizedMessage}".`
10302
10545
  };
10303
10546
  }
10304
10547
  function normalizeEntryName(value) {
@@ -10609,7 +10852,15 @@ async function collectDocsAudit(cwd) {
10609
10852
  // src/commands/local.ts
10610
10853
  function localCommand(program2) {
10611
10854
  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(
10855
+ local.command("verify [feature-name]").description("Verify the exact Feature tip before local integration").option("--component <component>", "Component name for multi projects").option("--json", "Output JSON for agents and hooks").action(
10856
+ async (featureName, options) => {
10857
+ await runLocalAction(
10858
+ options,
10859
+ () => runLocalVerify(featureName, options)
10860
+ );
10861
+ }
10862
+ );
10863
+ 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
10864
  "--confirm <token>",
10614
10865
  "Approval token when local_merge requires approval"
10615
10866
  ).option("--json", "Output JSON for agents and hooks").action(
@@ -10629,6 +10880,111 @@ function localCommand(program2) {
10629
10880
  }
10630
10881
  );
10631
10882
  }
10883
+ async function runLocalVerify(featureName, options) {
10884
+ const cwd = process.cwd();
10885
+ const stage = await collectWorkflowStage(cwd, featureName, options.component);
10886
+ if (stage.status !== "ok" || !stage.nextAction || !["feature_verify", "feature_remediation"].includes(stage.nextAction.category)) {
10887
+ return blocked(
10888
+ "LOCAL_FEATURE_VERIFY_STAGE_REQUIRED",
10889
+ stage.featureRef,
10890
+ "The active workflow is not at feature_verify or feature_remediation."
10891
+ );
10892
+ }
10893
+ const selection = await resolveFeatureSelection(
10894
+ cwd,
10895
+ featureName,
10896
+ options.component
10897
+ );
10898
+ if (selection.status !== "selected" || !selection.matchedFeature) {
10899
+ return blocked("FEATURE_SELECTION_REQUIRED", null);
10900
+ }
10901
+ const config = await getConfig(cwd);
10902
+ if (!config || config.workflow?.mode !== "local") {
10903
+ return blocked("LOCAL_WORKFLOW_REQUIRED", selection.matchedFeature.folderName);
10904
+ }
10905
+ const feature = selection.matchedFeature;
10906
+ const context = await resolveLocalIntegrationContext(config, feature);
10907
+ if (!context.featureTip || !context.featureTree) {
10908
+ return blocked("LOCAL_MERGE_REF_NOT_FOUND", feature.folderName);
10909
+ }
10910
+ if (!context.featureWorktreeClean || !context.docsClean) {
10911
+ return blocked(
10912
+ "LOCAL_FEATURE_VERIFY_DIRTY_WORKTREE",
10913
+ feature.folderName,
10914
+ "Feature worktree and docs repo must be clean before verification."
10915
+ );
10916
+ }
10917
+ const targetTip = context.featureTip;
10918
+ const targetTree = context.featureTree;
10919
+ const now = (/* @__PURE__ */ new Date()).toISOString();
10920
+ const logDir = resolveLocalIntegrationLogDir(
10921
+ context.projectRoot,
10922
+ feature,
10923
+ targetTip
10924
+ );
10925
+ const verification = runLocalChecks(
10926
+ context.featureWorktree,
10927
+ context.featureChecks,
10928
+ targetTip,
10929
+ logDir,
10930
+ "feature"
10931
+ );
10932
+ const refreshed = await resolveLocalIntegrationContext(config, feature);
10933
+ const changedDuringVerification = refreshed.featureTip !== targetTip || refreshed.featureTree !== targetTree || !refreshed.featureWorktreeClean;
10934
+ const failed = verification.some((entry) => entry.exitCode !== 0);
10935
+ const baseState = {
10936
+ version: 1,
10937
+ featureRef: feature.folderName,
10938
+ component: feature.type,
10939
+ baseBranch: context.baseBranch,
10940
+ featureBranch: context.featureBranch,
10941
+ featureTip: targetTip,
10942
+ mergedBaseTip: context.baseTip || targetTip,
10943
+ strategy: context.completionStrategy,
10944
+ status: failed || changedDuringVerification ? "feature_failed" : "feature_verified",
10945
+ mergedAt: context.state?.mergedAt || now,
10946
+ featureVerifiedAt: failed || changedDuringVerification ? null : now,
10947
+ verifiedAt: null,
10948
+ cleanedAt: null,
10949
+ ...failed || changedDuringVerification ? {} : {
10950
+ verifiedFeatureTip: targetTip,
10951
+ verifiedFeatureTree: targetTree
10952
+ },
10953
+ featureVerification: verification,
10954
+ postMergeVerification: [],
10955
+ verification
10956
+ };
10957
+ await writeLocalIntegrationState(context.projectRoot, feature, baseState);
10958
+ if (changedDuringVerification) {
10959
+ return {
10960
+ ...blocked(
10961
+ "LOCAL_FEATURE_CHANGED_DURING_VERIFICATION",
10962
+ feature.folderName,
10963
+ "The Feature tip, tree, or worktree changed while checks were running."
10964
+ ),
10965
+ featureTip: refreshed.featureTip,
10966
+ verification
10967
+ };
10968
+ }
10969
+ if (failed) {
10970
+ return {
10971
+ ...blocked("LOCAL_FEATURE_CHECK_FAILED", feature.folderName),
10972
+ featureTip: targetTip,
10973
+ verification
10974
+ };
10975
+ }
10976
+ return {
10977
+ status: "ok",
10978
+ reasonCode: "LOCAL_FEATURE_VERIFIED",
10979
+ featureRef: feature.folderName,
10980
+ baseBranch: context.baseBranch,
10981
+ featureBranch: context.featureBranch,
10982
+ featureTip: targetTip,
10983
+ baseTip: context.baseTip,
10984
+ completionStrategy: context.completionStrategy,
10985
+ verification
10986
+ };
10987
+ }
10632
10988
  async function runLocalAction(options, action) {
10633
10989
  try {
10634
10990
  const payload = await action();
@@ -10700,6 +11056,15 @@ async function runLocalMerge(featureName, options) {
10700
11056
  "Both the configured base branch and Feature tip must exist."
10701
11057
  );
10702
11058
  }
11059
+ if (!context.integrationComplete && (!context.state || !["feature_verified", "merged", "verified", "cleaned"].includes(
11060
+ context.state.status
11061
+ ) || context.state.verifiedFeatureTip !== context.featureTip || context.state.verifiedFeatureTree !== context.featureTree)) {
11062
+ return blocked(
11063
+ "LOCAL_FEATURE_VERIFICATION_REQUIRED",
11064
+ feature.folderName,
11065
+ "Verify the exact Feature tip before local integration."
11066
+ );
11067
+ }
10703
11068
  if (!context.featureWorktreeClean || !context.projectRootClean || !context.docsClean) {
10704
11069
  return blocked(
10705
11070
  "LOCAL_MERGE_DIRTY_WORKTREE",
@@ -10708,7 +11073,9 @@ async function runLocalMerge(featureName, options) {
10708
11073
  );
10709
11074
  }
10710
11075
  const now = (/* @__PURE__ */ new Date()).toISOString();
10711
- let state = context.state || {
11076
+ const originalBaseTip = context.baseTip;
11077
+ const previousStateStrategy = context.state?.strategy || "local-ff";
11078
+ let state = context.state && context.state.featureTip === context.featureTip && previousStateStrategy === context.completionStrategy ? context.state : {
10712
11079
  version: 1,
10713
11080
  featureRef: feature.folderName,
10714
11081
  component: feature.type,
@@ -10716,20 +11083,39 @@ async function runLocalMerge(featureName, options) {
10716
11083
  featureBranch: context.featureBranch,
10717
11084
  featureTip: context.featureTip,
10718
11085
  mergedBaseTip: context.baseTip,
11086
+ strategy: context.completionStrategy,
10719
11087
  status: "merged",
10720
11088
  mergedAt: now,
10721
11089
  verifiedAt: null,
10722
11090
  cleanedAt: null,
10723
11091
  verification: []
10724
11092
  };
10725
- if (!context.baseContainsFeature) {
11093
+ if (context.completionStrategy === "local-squash" && context.squashCommitMatchesSource && !context.squashEvidencePresent) {
11094
+ const evidence = preserveLocalIntegrationEvidence(
11095
+ context.projectRoot,
11096
+ context.evidenceRef,
11097
+ context.featureTip
11098
+ );
11099
+ if (evidence.code !== 0) {
11100
+ return blocked(
11101
+ "LOCAL_SQUASH_EVIDENCE_FAILED",
11102
+ feature.folderName,
11103
+ evidence.stderr
11104
+ );
11105
+ }
11106
+ context = await resolveLocalIntegrationContext(config, feature);
11107
+ }
11108
+ if (!context.featureTip || !context.baseTip) {
11109
+ return blocked("LOCAL_MERGE_REF_NOT_FOUND", feature.folderName);
11110
+ }
11111
+ if (!context.integrationComplete) {
10726
11112
  if (!isAncestor(
10727
11113
  context.projectRoot,
10728
11114
  `refs/heads/${context.baseBranch}`,
10729
11115
  context.featureTip
10730
11116
  )) {
10731
11117
  return blocked(
10732
- "LOCAL_MERGE_NOT_FAST_FORWARD",
11118
+ context.completionStrategy === "local-squash" ? "LOCAL_SQUASH_BASE_NOT_ANCESTOR" : "LOCAL_MERGE_NOT_FAST_FORWARD",
10733
11119
  feature.folderName,
10734
11120
  `${context.baseBranch} is not an ancestor of ${context.featureBranch}.`
10735
11121
  );
@@ -10747,26 +11133,22 @@ async function runLocalMerge(featureName, options) {
10747
11133
  );
10748
11134
  }
10749
11135
  }
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
- }
11136
+ const integration = context.completionStrategy === "local-squash" ? integrateLocalSquash(context, feature.folderName, feature.id, feature.slug) : integrateLocalFastForward(context, feature.folderName);
11137
+ if (integration.status !== "ok") return integration.payload;
10762
11138
  const mergedBaseTip = resolveRef(
10763
11139
  context.projectRoot,
10764
11140
  `refs/heads/${context.baseBranch}`
10765
11141
  );
11142
+ const integratedTree = mergedBaseTip ? resolveTree(context.projectRoot, mergedBaseTip) : null;
10766
11143
  state = {
10767
11144
  ...state,
10768
11145
  featureTip: context.featureTip,
10769
11146
  mergedBaseTip: mergedBaseTip || context.featureTip,
11147
+ strategy: context.completionStrategy,
11148
+ integratedCommit: mergedBaseTip || context.featureTip,
11149
+ originalBaseTip,
11150
+ ...integratedTree ? { integratedTree } : {},
11151
+ ...context.completionStrategy === "local-squash" ? { evidenceRef: context.evidenceRef } : {},
10770
11152
  status: "merged",
10771
11153
  mergedAt: now,
10772
11154
  verifiedAt: null,
@@ -10776,19 +11158,39 @@ async function runLocalMerge(featureName, options) {
10776
11158
  await writeLocalIntegrationState(context.projectRoot, feature, state);
10777
11159
  context = await resolveLocalIntegrationContext(config, feature);
10778
11160
  }
10779
- if (!context.baseContainsFeature) {
11161
+ if (!context.integrationComplete) {
10780
11162
  return blocked("LOCAL_MERGE_VERIFICATION_FAILED", feature.folderName);
10781
11163
  }
10782
- const verification = runPostMergeChecks(
11164
+ const integratedTip = resolveRef(
10783
11165
  context.projectRoot,
10784
- context.postMergeChecks
11166
+ `refs/heads/${context.baseBranch}`
10785
11167
  );
10786
- const failedCheck = verification.find((entry) => entry.exitCode !== 0);
11168
+ const verificationTarget = integratedTip || state.featureTip;
11169
+ const postMergeVerification = runLocalChecks(
11170
+ context.projectRoot,
11171
+ context.postMergeChecks,
11172
+ verificationTarget,
11173
+ resolveLocalIntegrationLogDir(
11174
+ context.projectRoot,
11175
+ feature,
11176
+ verificationTarget
11177
+ ),
11178
+ "post-merge"
11179
+ );
11180
+ const featureVerification = state.featureVerification || context.state?.featureVerification || [];
11181
+ const verification = [...featureVerification, ...postMergeVerification];
11182
+ const failedCheck = postMergeVerification.find((entry) => entry.exitCode !== 0);
10787
11183
  if (failedCheck) {
11184
+ rollbackLocalIntegration(context, originalBaseTip);
10788
11185
  await writeLocalIntegrationState(context.projectRoot, feature, {
10789
11186
  ...state,
10790
- status: "merged",
11187
+ status: "feature_failed",
11188
+ mergedBaseTip: originalBaseTip,
11189
+ integratedCommit: void 0,
11190
+ integratedTree: void 0,
10791
11191
  verification,
11192
+ featureVerification,
11193
+ postMergeVerification,
10792
11194
  verifiedAt: null
10793
11195
  });
10794
11196
  return {
@@ -10814,7 +11216,9 @@ async function runLocalMerge(featureName, options) {
10814
11216
  mergedBaseTip: baseTip || verifiedFeatureTip,
10815
11217
  status: "verified",
10816
11218
  verifiedAt: (/* @__PURE__ */ new Date()).toISOString(),
10817
- verification
11219
+ verification,
11220
+ featureVerification,
11221
+ postMergeVerification
10818
11222
  };
10819
11223
  await writeLocalIntegrationState(context.projectRoot, feature, verifiedState);
10820
11224
  return {
@@ -10825,9 +11229,19 @@ async function runLocalMerge(featureName, options) {
10825
11229
  featureBranch: context.featureBranch,
10826
11230
  featureTip: context.featureTip,
10827
11231
  baseTip,
11232
+ completionStrategy: context.completionStrategy,
10828
11233
  verification
10829
11234
  };
10830
11235
  }
11236
+ function rollbackLocalIntegration(context, originalBaseTip) {
11237
+ gitRun(context.projectRoot, ["reset", "--hard", originalBaseTip]);
11238
+ if (context.completionStrategy === "local-squash") {
11239
+ gitRun(context.projectRoot, ["update-ref", "-d", context.evidenceRef]);
11240
+ }
11241
+ if (!context.managedFeatureWorktree) {
11242
+ gitRun(context.projectRoot, ["checkout", context.featureBranch]);
11243
+ }
11244
+ }
10831
11245
  async function runLocalCleanup(featureName, options) {
10832
11246
  const cwd = process.cwd();
10833
11247
  const stage = await collectWorkflowStage(cwd, featureName, options.component);
@@ -10855,7 +11269,7 @@ async function runLocalCleanup(featureName, options) {
10855
11269
  }
10856
11270
  const feature = selection.matchedFeature;
10857
11271
  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) {
11272
+ if (!context.integrationComplete || !context.state || !["verified", "cleaned"].includes(context.state.status) || context.state.featureTip !== context.featureTip || context.state.mergedBaseTip !== context.baseTip) {
10859
11273
  return blocked("LOCAL_MERGE_NOT_VERIFIED", feature.folderName);
10860
11274
  }
10861
11275
  if (!context.featureWorktreeClean || !context.projectRootClean || !context.docsClean) {
@@ -10881,7 +11295,7 @@ async function runLocalCleanup(featureName, options) {
10881
11295
  if (context.deleteFeatureBranchAfterMerge && context.featureBranchExists) {
10882
11296
  const removeBranch = gitRun(context.projectRoot, [
10883
11297
  "branch",
10884
- "-d",
11298
+ context.completionStrategy === "local-squash" ? "-D" : "-d",
10885
11299
  context.featureBranch
10886
11300
  ]);
10887
11301
  if (removeBranch.code !== 0) {
@@ -10905,9 +11319,106 @@ async function runLocalCleanup(featureName, options) {
10905
11319
  baseBranch: context.baseBranch,
10906
11320
  featureBranch: context.featureBranch,
10907
11321
  featureTip: context.featureTip,
10908
- baseTip: context.baseTip
11322
+ baseTip: context.baseTip,
11323
+ completionStrategy: context.completionStrategy
10909
11324
  };
10910
11325
  }
11326
+ function integrateLocalFastForward(context, featureRef) {
11327
+ const merge = gitRun(context.projectRoot, [
11328
+ "merge",
11329
+ "--ff-only",
11330
+ context.featureBranch
11331
+ ]);
11332
+ if (merge.code !== 0) {
11333
+ return {
11334
+ status: "blocked",
11335
+ payload: blocked(
11336
+ "LOCAL_MERGE_NOT_FAST_FORWARD",
11337
+ featureRef,
11338
+ merge.stderr
11339
+ )
11340
+ };
11341
+ }
11342
+ return { status: "ok" };
11343
+ }
11344
+ function integrateLocalSquash(context, featureRef, featureId, featureSlug) {
11345
+ const originalBaseTip = context.baseTip;
11346
+ if (!originalBaseTip || !context.featureTip) {
11347
+ return {
11348
+ status: "blocked",
11349
+ payload: blocked("LOCAL_MERGE_REF_NOT_FOUND", featureRef)
11350
+ };
11351
+ }
11352
+ const squash = gitRun(context.projectRoot, [
11353
+ "merge",
11354
+ "--squash",
11355
+ context.featureBranch
11356
+ ]);
11357
+ if (squash.code !== 0) {
11358
+ rollbackSquash(context.projectRoot, originalBaseTip);
11359
+ return {
11360
+ status: "blocked",
11361
+ payload: blocked("LOCAL_SQUASH_FAILED", featureRef, squash.stderr)
11362
+ };
11363
+ }
11364
+ const scope = resolveFeatureCommitScope({
11365
+ featureId,
11366
+ workflowMode: "local"
11367
+ });
11368
+ if (!scope) {
11369
+ rollbackSquash(context.projectRoot, originalBaseTip);
11370
+ return {
11371
+ status: "blocked",
11372
+ payload: blocked(
11373
+ "LOCAL_SQUASH_COMMIT_SCOPE_INVALID",
11374
+ featureRef,
11375
+ `Could not resolve a canonical local commit scope from ${featureId}.`
11376
+ )
11377
+ };
11378
+ }
11379
+ const commit = gitRun(context.projectRoot, [
11380
+ "commit",
11381
+ "-m",
11382
+ `feat(${scope}): integrate ${featureSlug}`
11383
+ ]);
11384
+ if (commit.code !== 0) {
11385
+ rollbackSquash(context.projectRoot, originalBaseTip);
11386
+ return {
11387
+ status: "blocked",
11388
+ payload: blocked("LOCAL_SQUASH_COMMIT_FAILED", featureRef, commit.stderr)
11389
+ };
11390
+ }
11391
+ const integratedCommit = resolveRef(context.projectRoot, "HEAD");
11392
+ const sourceTree = resolveTree(context.projectRoot, context.featureTip);
11393
+ const integratedTree = integratedCommit ? resolveTree(context.projectRoot, integratedCommit) : null;
11394
+ if (!integratedCommit || !sourceTree || integratedTree !== sourceTree) {
11395
+ rollbackSquash(context.projectRoot, originalBaseTip);
11396
+ return {
11397
+ status: "blocked",
11398
+ payload: blocked("LOCAL_SQUASH_TREE_MISMATCH", featureRef)
11399
+ };
11400
+ }
11401
+ const evidence = preserveLocalIntegrationEvidence(
11402
+ context.projectRoot,
11403
+ context.evidenceRef,
11404
+ context.featureTip
11405
+ );
11406
+ if (evidence.code !== 0) {
11407
+ rollbackSquash(context.projectRoot, originalBaseTip);
11408
+ return {
11409
+ status: "blocked",
11410
+ payload: blocked(
11411
+ "LOCAL_SQUASH_EVIDENCE_FAILED",
11412
+ featureRef,
11413
+ evidence.stderr
11414
+ )
11415
+ };
11416
+ }
11417
+ return { status: "ok" };
11418
+ }
11419
+ function rollbackSquash(projectRoot, originalBaseTip) {
11420
+ gitRun(projectRoot, ["reset", "--hard", originalBaseTip]);
11421
+ }
10911
11422
  function blocked(reasonCode, featureRef, error) {
10912
11423
  return {
10913
11424
  status: "blocked",