lee-spec-kit 0.8.1 → 0.8.3

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.
package/dist/index.js CHANGED
@@ -8,7 +8,7 @@ import path8 from 'path';
8
8
  import prompts from 'prompts';
9
9
  import chalk from 'chalk';
10
10
  import { fileURLToPath } from 'url';
11
- import { spawn, spawnSync, execFileSync } from 'child_process';
11
+ import { spawn, execFileSync, spawnSync } from 'child_process';
12
12
  import os from 'os';
13
13
  import { createHash } from 'crypto';
14
14
 
@@ -1288,18 +1288,24 @@ Before taking the next workflow step:
1288
1288
  2. Read the active feature docs as the SSOT: \`spec.md\`, \`plan.md\`, \`tasks.md\`, and \`decisions.md\`
1289
1289
  3. When relevant, also read \`issue.md\` and \`pr.md\`
1290
1290
  4. Run \`npx lee-spec-kit workflow-stage <feature-ref> --json\` and follow only the returned \`nextAction\`
1291
- 5. Do not start implementation unless \`stage === "implementation"\` and \`implementationAllowed === true\`
1292
- 6. Treat stages before implementation as hard gates:
1293
- - spec / plan / tasks approvals
1291
+ 5. If \`workflow-stage --json\` returns \`primaryActionLabel\` and \`actionOptions\`, treat \`primaryActionLabel\` as the default option label and present the exact \`actionOptions[*].reply\` tokens to the user before continuing
1292
+ 6. Do not start implementation unless \`stage === "implementation"\` and \`implementationAllowed === true\`
1293
+ 7. Treat stages before implementation as hard gates:
1294
+ - spec approval plus plan / tasks readiness
1294
1295
  - issue preparation / issue creation
1295
1296
  - branch creation
1296
- 7. Keep docs and code synchronized; if code changes materially, update the active feature docs in the same turn before stopping
1297
- 8. When docs are synced to code, refresh an explicit marker like \`<!-- lee-spec-kit:workflow-sync 2026-04-16T12:34:56.789Z -->\` in the active feature docs (prefer \`tasks.md\` or \`decisions.md\`) so \`workflow-audit\` can prove the sync happened after the latest code change
1297
+ - task commit checkpoints after each completed task
1298
+ 8. In standalone mode, keep the docs repo on its docs branch and do not create feature branches or worktrees there
1299
+ 9. In standalone mode, use the project repo through its managed feature worktree under the shared workspace \`.worktrees/\` root instead of checking the feature branch out in the main project repo
1300
+ 10. Keep docs and code synchronized; if code changes materially, update the active feature docs in the same turn before stopping
1301
+ 11. When docs are synced to code, refresh an explicit marker like \`<!-- lee-spec-kit:workflow-sync 2026-04-16T12:34:56.789Z -->\` in the active feature docs (prefer \`tasks.md\` or \`decisions.md\`) so \`workflow-audit\` can prove the sync happened after the latest code change
1298
1302
 
1299
1303
  Approval and remote actions:
1300
1304
 
1301
1305
  - Ask the user for approval only at documented workflow approval boundaries or before remote/destructive actions
1302
1306
  - If \`workflow-stage --json\` reports \`approvalRequired === true\`, stop at that boundary and ask the user before proceeding
1307
+ - 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
1308
+ - 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
1303
1309
  - Before \`git commit\`, prefer \`npx lee-spec-kit commit-audit --json\` when hooks or manual checks need commit-time docs path enforcement
1304
1310
  - Before remote GitHub actions, share the plan or artifact being sent
1305
1311
  - Respect repo policy from docs and config first; hooks only enforce guardrails and continuation checks
@@ -1431,6 +1437,38 @@ function resolveStandaloneProjectRoots(config, component) {
1431
1437
  function resolveGitTopLevelOrNull(cwd) {
1432
1438
  return runGitCapture(["rev-parse", "--show-toplevel"], cwd) || null;
1433
1439
  }
1440
+ function normalizeBranchNameForWorktree(branchName) {
1441
+ return branchName.trim().replace(/[\\/]/g, "-");
1442
+ }
1443
+ function resolveStandaloneManagedWorktreeRoot(config, projectRoot) {
1444
+ if (config.docsRepo !== "standalone") return null;
1445
+ const workspaceRoot = resolveConfiguredStandaloneWorkspaceRoot(config);
1446
+ if (!workspaceRoot) return null;
1447
+ return path8.resolve(
1448
+ workspaceRoot,
1449
+ ".worktrees",
1450
+ path8.basename(path8.resolve(projectRoot))
1451
+ );
1452
+ }
1453
+ function resolveManagedWorktreePath(config, projectRoot, branchName) {
1454
+ const standaloneRoot = resolveStandaloneManagedWorktreeRoot(config, projectRoot);
1455
+ if (standaloneRoot) {
1456
+ return path8.resolve(
1457
+ standaloneRoot,
1458
+ normalizeBranchNameForWorktree(branchName)
1459
+ );
1460
+ }
1461
+ return path8.resolve(
1462
+ path8.resolve(projectRoot),
1463
+ ".worktrees",
1464
+ normalizeBranchNameForWorktree(branchName)
1465
+ );
1466
+ }
1467
+ function buildManagedWorktreeEnvLinkCommand(projectRoot, worktreePath) {
1468
+ const sourceEnvPath = path8.resolve(projectRoot, ".env");
1469
+ const targetEnvPath = path8.resolve(worktreePath, ".env");
1470
+ return `if [ -f "${sourceEnvPath}" ] && [ ! -e "${targetEnvPath}" ] && [ ! -L "${targetEnvPath}" ]; then ln -s "${sourceEnvPath}" "${targetEnvPath}"; fi`;
1471
+ }
1434
1472
 
1435
1473
  // src/utils/init/options.ts
1436
1474
  function parseStandaloneMultiProjectRootJson(raw) {
@@ -2423,7 +2461,7 @@ async function runInit(options) {
2423
2461
  workflow: {
2424
2462
  preset: workflowMode,
2425
2463
  mode: workflowMode,
2426
- requireWorktree: false,
2464
+ requireWorktree: docsRepo === "standalone",
2427
2465
  codeDirtyScope: "auto",
2428
2466
  taskCommitGate: "warn",
2429
2467
  auto: {
@@ -2551,7 +2589,7 @@ async function initGit(cwd, targetDir, docsRepo, lang, pushDocs, docsRemote, ext
2551
2589
  }
2552
2590
  };
2553
2591
  const toGitPath = (input) => input.replace(/\\/g, "/").replace(/^\.\//, "");
2554
- const toRepoRelativePath = (workdir, relativePath2) => {
2592
+ const toRepoRelativePath2 = (workdir, relativePath2) => {
2555
2593
  if (relativePath2 === ".") return ".";
2556
2594
  try {
2557
2595
  const prefix = runGitOrThrow(["rev-parse", "--show-prefix"], workdir, {
@@ -2582,7 +2620,7 @@ async function initGit(cwd, targetDir, docsRepo, lang, pushDocs, docsRemote, ext
2582
2620
  return;
2583
2621
  }
2584
2622
  if (relativePath !== "." && isPathIgnored(gitWorkdir, relativePath)) {
2585
- const repoRelativePath = toRepoRelativePath(gitWorkdir, relativePath);
2623
+ const repoRelativePath = toRepoRelativePath2(gitWorkdir, relativePath);
2586
2624
  console.log(
2587
2625
  chalk.yellow(
2588
2626
  tr(lang, "cli", "init.warn.docsPathIgnoredSkipCommit", {
@@ -3258,7 +3296,9 @@ function hasOwnKey(value, key) {
3258
3296
  }
3259
3297
  function isLegacyGeneratedApprovalConfig(approval) {
3260
3298
  const mode = typeof approval.mode === "string" ? approval.mode : "";
3261
- if (mode && mode !== "category" && mode !== "steps") return false;
3299
+ if (mode && mode !== "category" && mode !== "steps" && mode !== "builtin") {
3300
+ return false;
3301
+ }
3262
3302
  const overrideKeys = [
3263
3303
  "default",
3264
3304
  "requireCheckSteps",
@@ -3514,7 +3554,16 @@ async function backfillMissingConfigDefaults(cwd, docsDir) {
3514
3554
  const inferredPreset = workflow.mode === "local" ? "local" : "github";
3515
3555
  setIfMissing(workflow, "preset", inferredPreset, "workflow.preset");
3516
3556
  setIfMissing(workflow, "mode", "github", "workflow.mode");
3517
- setIfMissing(workflow, "requireWorktree", false, "workflow.requireWorktree");
3557
+ setIfMissing(
3558
+ workflow,
3559
+ "requireWorktree",
3560
+ raw.docsRepo === "standalone",
3561
+ "workflow.requireWorktree"
3562
+ );
3563
+ if (raw.docsRepo === "standalone" && workflow.requireWorktree !== true) {
3564
+ workflow.requireWorktree = true;
3565
+ changedPaths.push("workflow.requireWorktree");
3566
+ }
3518
3567
  setIfMissing(workflow, "codeDirtyScope", "auto", "workflow.codeDirtyScope");
3519
3568
  setIfMissing(workflow, "taskCommitGate", "warn", "workflow.taskCommitGate");
3520
3569
  if (!isPlainObject(workflow.auto)) {
@@ -3927,6 +3976,7 @@ function runGhJson(args, cwd, messages) {
3927
3976
  );
3928
3977
  }
3929
3978
  }
3979
+ var BRANCH_LABELS = ["Branch", "\uBE0C\uB79C\uCE58"];
3930
3980
  function normalizeComponent(value) {
3931
3981
  const component = (value || "").trim().toLowerCase();
3932
3982
  return component || void 0;
@@ -3959,6 +4009,44 @@ function resolveProjectGitCwd(cwd, config, component) {
3959
4009
  }
3960
4010
  return resolveGitTopLevelOrNull(cwd) || resolveGitTopLevelOrNull(config.docsDir) || cwd;
3961
4011
  }
4012
+ function resolveProjectRootFromGitCwd(projectGitCwd) {
4013
+ return resolveGitTopLevelOrNull(projectGitCwd) || path8.resolve(projectGitCwd);
4014
+ }
4015
+ async function resolveExistingManagedWorktreePath(config, projectGitCwd, slug, folderName, issueNumber, branchName) {
4016
+ const projectRoot = resolveProjectRootFromGitCwd(projectGitCwd);
4017
+ const branchCandidates = [
4018
+ branchName,
4019
+ issueNumber ? `feat/${issueNumber}-${slug}` : null,
4020
+ issueNumber ? `feat/${issueNumber}-${folderName}` : null
4021
+ ].filter((candidate) => !!candidate);
4022
+ const candidates = [...new Set(branchCandidates)].map(
4023
+ (candidate) => resolveManagedWorktreePath(config, projectRoot, candidate)
4024
+ );
4025
+ for (const candidate of candidates) {
4026
+ if (await fs.pathExists(candidate)) {
4027
+ return candidate;
4028
+ }
4029
+ }
4030
+ return null;
4031
+ }
4032
+ function extractFieldValue(content, labels) {
4033
+ for (const label of Array.isArray(labels) ? labels : [labels]) {
4034
+ const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
4035
+ const match = content.match(
4036
+ new RegExp(`^\\s*-\\s*\\*\\*${escaped}\\*\\*:\\s*(.*?)\\s*$`, "mi")
4037
+ );
4038
+ if (!match) continue;
4039
+ const value = match[1].trim();
4040
+ if (value) return value;
4041
+ }
4042
+ return null;
4043
+ }
4044
+ function sanitizeMetadataValue(value) {
4045
+ if (!value) return null;
4046
+ const trimmed = value.trim().replace(/^`(.+)`$/, "$1");
4047
+ if (!trimmed || trimmed === "-") return null;
4048
+ return trimmed;
4049
+ }
3962
4050
  function toFeaturePathFromDocs(projectType, component, folderName) {
3963
4051
  return projectType === "multi" && component !== "single" ? path8.join("features", component, folderName) : path8.join("features", folderName);
3964
4052
  }
@@ -3971,6 +4059,12 @@ async function extractIssueNumber(featureDir) {
3971
4059
  const parsed = Number(match[1]);
3972
4060
  return Number.isFinite(parsed) ? parsed : void 0;
3973
4061
  }
4062
+ async function extractBranchName(featureDir) {
4063
+ const tasksPath = path8.join(featureDir, "tasks.md");
4064
+ if (!await fs.pathExists(tasksPath)) return null;
4065
+ const content = await fs.readFile(tasksPath, "utf-8");
4066
+ return sanitizeMetadataValue(extractFieldValue(content, BRANCH_LABELS));
4067
+ }
3974
4068
  async function listResolvedFeatures(cwd, config, component) {
3975
4069
  const refs = await listLeeSpecFeatures(cwd);
3976
4070
  const normalizedComponent = normalizeComponent(component);
@@ -3984,6 +4078,17 @@ async function listResolvedFeatures(cwd, config, component) {
3984
4078
  ref.folderName
3985
4079
  );
3986
4080
  const featureDir = path8.join(config.docsDir, featurePathFromDocs);
4081
+ const issueNumber = await extractIssueNumber(featureDir);
4082
+ const branchName = await extractBranchName(featureDir);
4083
+ const projectGitCwdBase = resolveProjectGitCwd(cwd, config, type);
4084
+ const worktreeProjectGitCwd = config.docsRepo === "standalone" && (issueNumber || branchName) ? await resolveExistingManagedWorktreePath(
4085
+ config,
4086
+ projectGitCwdBase,
4087
+ ref.slug,
4088
+ ref.folderName,
4089
+ issueNumber,
4090
+ branchName
4091
+ ) : null;
3987
4092
  return {
3988
4093
  id: ref.id || ref.folderName.split("-")[0] || "",
3989
4094
  slug: ref.slug,
@@ -3995,9 +4100,9 @@ async function listResolvedFeatures(cwd, config, component) {
3995
4100
  },
3996
4101
  git: {
3997
4102
  docsGitCwd: config.docsDir,
3998
- projectGitCwd: resolveProjectGitCwd(cwd, config, type)
4103
+ projectGitCwd: worktreeProjectGitCwd || projectGitCwdBase
3999
4104
  },
4000
- issueNumber: await extractIssueNumber(featureDir)
4105
+ issueNumber
4001
4106
  };
4002
4107
  })
4003
4108
  );
@@ -4237,6 +4342,15 @@ function sanitizeDraftTitleValue(raw) {
4237
4342
  const normalized = value.replace(/`/g, "").replace(/\*\*(.*?)\*\*/g, "$1").replace(/\[(.*?)\]\((.*?)\)/g, "$1").replace(/\s+/g, " ").trim();
4238
4343
  return normalized || void 0;
4239
4344
  }
4345
+ function isPlaceholderWorkflowDraftTitle(raw, feature) {
4346
+ const normalized = sanitizeDraftTitleValue(raw);
4347
+ if (!normalized) return true;
4348
+ const lowered = normalized.trim().toLowerCase();
4349
+ const placeholders = new Set(
4350
+ [feature.slug, feature.folderName].map((value) => (value || "").trim().toLowerCase()).filter(Boolean)
4351
+ );
4352
+ return placeholders.has(lowered);
4353
+ }
4240
4354
  function parseWorkflowDraftStatus(raw) {
4241
4355
  const value = (raw || "").trim();
4242
4356
  if (!value) return void 0;
@@ -4944,8 +5058,36 @@ function isOverviewMetadataLine(line, lang) {
4944
5058
  return keys.some((key) => cleaned.startsWith(`${key}:`));
4945
5059
  }
4946
5060
  function truncateIssueTitleSummary(input, maxLength = 72) {
4947
- if (input.length <= maxLength) return input;
4948
- return `${input.slice(0, maxLength - 3).trimEnd()}...`;
5061
+ const normalized = input.trim().replace(/\s+/g, " ");
5062
+ if (normalized.length <= maxLength) return normalized;
5063
+ const stripTrailingPunctuation = (value) => value.trim().replace(/[.!?。]+$/u, "").trim();
5064
+ const rewriteVerboseEnding = (value) => stripTrailingPunctuation(value).replace(/공통화하고/gu, "\uACF5\uD1B5\uD654\uC640").replace(/줄인다$/u, "\uAC10\uC18C").replace(/강화한다$/u, "\uAC15\uD654").replace(/개선한다$/u, "\uAC1C\uC120").replace(/복구한다$/u, "\uBCF5\uAD6C").replace(/정리한다$/u, "\uC815\uB9AC").replace(/도입한다$/u, "\uB3C4\uC785").replace(/구현한다$/u, "\uAD6C\uD604");
5065
+ const addCandidate = (bucket, seen2, value) => {
5066
+ const cleaned = stripTrailingPunctuation(value);
5067
+ if (!cleaned || seen2.has(cleaned)) return;
5068
+ seen2.add(cleaned);
5069
+ bucket.push(cleaned);
5070
+ };
5071
+ const candidates = [];
5072
+ const seen = /* @__PURE__ */ new Set();
5073
+ addCandidate(candidates, seen, normalized);
5074
+ const commaStripped = normalized.replace(/^[^,,]+[,,]\s*/u, "");
5075
+ addCandidate(candidates, seen, commaStripped);
5076
+ const purposeLeadStripped = normalized.replace(/^.+?(?:하도록|하기 위해),\s*/u, "");
5077
+ addCandidate(candidates, seen, purposeLeadStripped);
5078
+ for (const base of [...candidates]) {
5079
+ addCandidate(candidates, seen, rewriteVerboseEnding(base));
5080
+ }
5081
+ for (const base of [...candidates]) {
5082
+ for (const part of base.split(/[,:;,;]/u)) {
5083
+ addCandidate(candidates, seen, part);
5084
+ }
5085
+ }
5086
+ const fitting = candidates.filter((candidate) => candidate.length <= maxLength);
5087
+ if (fitting.length > 0) {
5088
+ return fitting.sort((left, right) => right.length - left.length)[0];
5089
+ }
5090
+ return stripTrailingPunctuation(normalized.slice(0, maxLength));
4949
5091
  }
4950
5092
  function resolveIssueTitleSummary(overview, feature, lang) {
4951
5093
  const candidates = overview.split("\n").map((line) => normalizeIssueTitleSummaryLine(line)).filter((line) => !!line).filter((line) => !isOverviewMetadataLine(line, lang));
@@ -5392,7 +5534,7 @@ function syncTasksIssueMetadata(tasksPath, issueNumber, lang) {
5392
5534
  }
5393
5535
  return { changed, path: tasksPath };
5394
5536
  }
5395
- function syncIssueDraftMetadata(issueDocPath, issueNumber) {
5537
+ function syncIssueDraftMetadata(issueDocPath, issueNumber, title) {
5396
5538
  if (!fs.existsSync(issueDocPath)) {
5397
5539
  return { changed: false, path: issueDocPath };
5398
5540
  }
@@ -5412,6 +5554,16 @@ function syncIssueDraftMetadata(issueDocPath, issueNumber) {
5412
5554
  next = inserted.content;
5413
5555
  changed = changed || inserted.changed;
5414
5556
  }
5557
+ const normalizedTitle = sanitizeDraftTitleValue(title);
5558
+ if (normalizedTitle) {
5559
+ const titleReplaced = replaceListField(
5560
+ next,
5561
+ ["Title", "\uC81C\uBAA9"],
5562
+ normalizedTitle
5563
+ );
5564
+ next = titleReplaced.content;
5565
+ changed = changed || titleReplaced.changed;
5566
+ }
5415
5567
  if (changed) {
5416
5568
  fs.writeFileSync(issueDocPath, next, "utf-8");
5417
5569
  }
@@ -5456,7 +5608,7 @@ function syncTasksPrMetadata(tasksPath, prUrl, nextStatus, lang) {
5456
5608
  }
5457
5609
  return { changed, path: tasksPath };
5458
5610
  }
5459
- function syncPrDraftMetadata(prDocPath, prUrl, nextStatus) {
5611
+ function syncPrDraftMetadata(prDocPath, prUrl, nextStatus, title) {
5460
5612
  if (!fs.existsSync(prDocPath)) {
5461
5613
  return { changed: false, path: prDocPath };
5462
5614
  }
@@ -5483,6 +5635,17 @@ function syncPrDraftMetadata(prDocPath, prUrl, nextStatus) {
5483
5635
  next = inserted.content;
5484
5636
  changed = changed || inserted.changed;
5485
5637
  }
5638
+ const normalizedTitle = sanitizeDraftTitleValue(title);
5639
+ if (normalizedTitle) {
5640
+ const titleReplaced = replaceListField(next, ["Title", "\uC81C\uBAA9"], normalizedTitle);
5641
+ next = titleReplaced.content;
5642
+ changed = changed || titleReplaced.changed;
5643
+ if (!titleReplaced.found) {
5644
+ const inserted = insertFieldInMetadataSection(next, "Title", normalizedTitle);
5645
+ next = inserted.content;
5646
+ changed = changed || inserted.changed;
5647
+ }
5648
+ }
5486
5649
  if (changed) {
5487
5650
  fs.writeFileSync(prDocPath, next, "utf-8");
5488
5651
  }
@@ -5777,7 +5940,10 @@ function githubCommand(program2) {
5777
5940
  await fs.writeFile(sanitizedBodyFile, body, "utf-8");
5778
5941
  bodyFile = sanitizedBodyFile;
5779
5942
  }
5780
- const title = options.title?.trim() || (preparedBody.source === "workflow-ready" ? preparedBody.draftMetadata?.title : void 0) || defaultTitle;
5943
+ const title = options.title?.trim() || (preparedBody.source === "workflow-ready" && !isPlaceholderWorkflowDraftTitle(
5944
+ preparedBody.draftMetadata?.title,
5945
+ feature
5946
+ ) ? preparedBody.draftMetadata?.title : void 0) || defaultTitle;
5781
5947
  const labels = parseLabels(
5782
5948
  optionLabels || (preparedBody.source === "workflow-ready" ? preparedBody.draftMetadata?.labels : void 0),
5783
5949
  config.lang
@@ -5828,7 +5994,8 @@ function githubCommand(program2) {
5828
5994
  );
5829
5995
  const draftSynced = syncIssueDraftMetadata(
5830
5996
  path8.join(config.docsDir, paths.issuePath),
5831
- syncedIssueNumber
5997
+ syncedIssueNumber,
5998
+ title
5832
5999
  );
5833
6000
  syncChanged = synced.changed || draftSynced.changed;
5834
6001
  }
@@ -6013,7 +6180,8 @@ function githubCommand(program2) {
6013
6180
  await fs.writeFile(sanitizedBodyFile, body, "utf-8");
6014
6181
  bodyFile = sanitizedBodyFile;
6015
6182
  }
6016
- const title = options.title?.trim() || (preparedBody.source === "workflow-ready" ? preparedBody.draftMetadata?.title : void 0) || defaultTitle;
6183
+ const requestedTitle = options.title?.trim() || (preparedBody.source === "workflow-ready" ? preparedBody.draftMetadata?.title : void 0) || "";
6184
+ let title = requestedTitle || defaultTitle;
6017
6185
  const labels = parseLabels(
6018
6186
  optionLabels || (preparedBody.source === "workflow-ready" ? preparedBody.draftMetadata?.labels : void 0),
6019
6187
  config.lang
@@ -6023,7 +6191,6 @@ function githubCommand(program2) {
6023
6191
  let prUrl = options.pr?.trim() || "";
6024
6192
  let mergedAttempts;
6025
6193
  let mergeAlreadyMerged;
6026
- const postMergeWarnings = [];
6027
6194
  let syncChanged = false;
6028
6195
  const pushDocsSync = shouldPushDocsSync(config);
6029
6196
  if (options.create) {
@@ -6036,11 +6203,18 @@ function githubCommand(program2) {
6036
6203
  feature.issueNumber ? String(feature.issueNumber) : void 0,
6037
6204
  config.lang
6038
6205
  );
6206
+ title = closingIssueNumber && closingIssueNumber.trim() ? defaultTitle : requestedTitle || defaultTitle;
6039
6207
  assertRemoteIssueExists(
6040
6208
  closingIssueNumber,
6041
6209
  projectGitCwd,
6042
6210
  config.lang
6043
6211
  );
6212
+ if (closingIssueNumber && options.title?.trim() && options.title.trim() !== defaultTitle) {
6213
+ throw createCliError(
6214
+ "PRECONDITION_FAILED",
6215
+ `PR title must follow the existing convention: "${defaultTitle}".`
6216
+ );
6217
+ }
6044
6218
  const normalizedBody = ensureIssueClosingLine(
6045
6219
  body,
6046
6220
  closingIssueNumber
@@ -6116,7 +6290,8 @@ function githubCommand(program2) {
6116
6290
  const syncedDraft = syncPrDraftMetadata(
6117
6291
  path8.join(config.docsDir, paths.prPath),
6118
6292
  prUrl,
6119
- "Review"
6293
+ "Review",
6294
+ title
6120
6295
  );
6121
6296
  syncChanged = syncedTasks.changed || syncedDraft.changed;
6122
6297
  const shouldCommitSync = !!options.commitSync || !!options.merge;
@@ -6186,34 +6361,6 @@ function githubCommand(program2) {
6186
6361
  );
6187
6362
  }
6188
6363
  }
6189
- const mergeBaseBranch = (merged.baseRefName || baseBranch || "main").trim();
6190
- const checkoutResult = runProcess(
6191
- "git",
6192
- ["checkout", mergeBaseBranch],
6193
- projectGitCwd
6194
- );
6195
- if (checkoutResult.code !== 0) {
6196
- postMergeWarnings.push(
6197
- tg(config.lang, "postMergeCheckoutWarning", {
6198
- base: mergeBaseBranch,
6199
- detail: (checkoutResult.stderr || checkoutResult.stdout || "").trim()
6200
- })
6201
- );
6202
- } else {
6203
- const pullResult = runProcess(
6204
- "git",
6205
- ["pull", "--rebase", "origin", mergeBaseBranch],
6206
- projectGitCwd
6207
- );
6208
- if (pullResult.code !== 0) {
6209
- postMergeWarnings.push(
6210
- tg(config.lang, "postMergePullWarning", {
6211
- base: mergeBaseBranch,
6212
- detail: (pullResult.stderr || pullResult.stdout || "").trim()
6213
- })
6214
- );
6215
- }
6216
- }
6217
6364
  }
6218
6365
  if (options.json) {
6219
6366
  console.log(
@@ -6235,8 +6382,7 @@ function githubCommand(program2) {
6235
6382
  syncChanged,
6236
6383
  merged: !!options.merge,
6237
6384
  mergeAttempts: mergedAttempts,
6238
- mergeAlreadyMerged,
6239
- postMergeWarnings: postMergeWarnings.length > 0 ? postMergeWarnings : void 0
6385
+ mergeAlreadyMerged
6240
6386
  },
6241
6387
  null,
6242
6388
  2
@@ -6284,9 +6430,6 @@ function githubCommand(program2) {
6284
6430
  chalk.yellow(tg(config.lang, "prAlreadyMergedNotice"))
6285
6431
  );
6286
6432
  }
6287
- for (const warning of postMergeWarnings) {
6288
- console.log(chalk.yellow(`\u26A0\uFE0F ${warning}`));
6289
- }
6290
6433
  } else if (!options.create) {
6291
6434
  console.log(
6292
6435
  chalk.blue(tg(config.lang, "prTemplateGenerated"))
@@ -6781,7 +6924,7 @@ function registerCodexHooksIntegration(parent) {
6781
6924
  removeLeeSpecKitCodexHooks,
6782
6925
  resolveCodexHooksRepoRoot,
6783
6926
  upsertLeeSpecKitCodexHooks
6784
- } = await import('./hooks-4S33YUIB.js');
6927
+ } = await import('./hooks-B5UIIZYN.js');
6785
6928
  const repoRoot = config.docsRepo === "standalone" ? resolveConfiguredStandaloneWorkspaceRoot(config) : resolveCodexHooksRepoRoot(process.cwd());
6786
6929
  if (!repoRoot) {
6787
6930
  throw createCliError(
@@ -6816,9 +6959,27 @@ function integrationsCommand(program2) {
6816
6959
  registerCodexIntegration(integrations);
6817
6960
  registerCodexHooksIntegration(integrations);
6818
6961
  }
6962
+ var LEGACY_STEP_BY_ACTION = {
6963
+ spec_write: 2,
6964
+ spec_approve: 3,
6965
+ plan_write: 4,
6966
+ plan_approve: 5,
6967
+ tasks_write: 6,
6968
+ tasks_approve: 6,
6969
+ issue_prepare: 8,
6970
+ issue_create: 8,
6971
+ branch_create: 9,
6972
+ task_execute: 10,
6973
+ implementation_approve: 10,
6974
+ pre_pr_review: 12,
6975
+ pr_prepare: 13,
6976
+ pr_create: 13,
6977
+ code_review: 14,
6978
+ pr_merge: 14
6979
+ };
6819
6980
  var DOC_STATUS_LABELS = ["Doc Status", "\uBB38\uC11C \uC0C1\uD0DC"];
6820
6981
  var ISSUE_LABELS = ["Issue", "Issue Number", "\uC774\uC288", "\uC774\uC288 \uBC88\uD638"];
6821
- var BRANCH_LABELS = ["Branch", "\uBE0C\uB79C\uCE58"];
6982
+ var BRANCH_LABELS2 = ["Branch", "\uBE0C\uB79C\uCE58"];
6822
6983
  var PR_LABELS = ["PR", "Pull Request"];
6823
6984
  var PR_STATUS_LABELS = ["PR Status", "PR \uC0C1\uD0DC"];
6824
6985
  var PRE_PR_REVIEW_LABELS = ["Pre-PR Review", "PR \uC804 \uB9AC\uBDF0"];
@@ -6831,6 +6992,7 @@ function resolveWorkflowRequirements(config) {
6831
6992
  return {
6832
6993
  requireIssue: workflow.requireIssue ?? !isLocalWorkflow,
6833
6994
  requireBranch: workflow.requireBranch ?? true,
6995
+ requireWorktree: config.docsRepo === "standalone" ? true : workflow.requireWorktree ?? false,
6834
6996
  requirePr: workflow.requirePr ?? !isLocalWorkflow,
6835
6997
  requireReview: workflow.requireReview ?? !isLocalWorkflow,
6836
6998
  requireMerge: workflow.requireMerge ?? !isLocalWorkflow,
@@ -6844,7 +7006,7 @@ function parseApprovalStatus(raw) {
6844
7006
  if (value === "approved") return "approved";
6845
7007
  return null;
6846
7008
  }
6847
- function extractFieldValue(content, labels) {
7009
+ function extractFieldValue2(content, labels) {
6848
7010
  for (const label of Array.isArray(labels) ? labels : [labels]) {
6849
7011
  const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
6850
7012
  const match = content.match(
@@ -6876,12 +7038,12 @@ function withoutFencedCodeBlocks(content) {
6876
7038
  return lines;
6877
7039
  }
6878
7040
  function parseTasksDoc(content) {
6879
- const issueRaw = extractFieldValue(content, ISSUE_LABELS);
7041
+ const issueRaw = extractFieldValue2(content, ISSUE_LABELS);
6880
7042
  const issueNumberMatch = issueRaw?.match(/^#(\d+)$/);
6881
7043
  const issueNumber = issueNumberMatch ? Number(issueNumberMatch[1]) : null;
6882
- const branchRaw = extractFieldValue(content, BRANCH_LABELS);
6883
- const prRaw = extractFieldValue(content, PR_LABELS);
6884
- const prePrDecision = extractFieldValue(content, PRE_PR_DECISION_LABELS);
7044
+ const branchRaw = extractFieldValue2(content, BRANCH_LABELS2);
7045
+ const prRaw = extractFieldValue2(content, PR_LABELS);
7046
+ const prePrDecision = extractFieldValue2(content, PRE_PR_DECISION_LABELS);
6885
7047
  const tasks = [];
6886
7048
  const nonCodeLines = withoutFencedCodeBlocks(content);
6887
7049
  for (const line of nonCodeLines) {
@@ -6907,13 +7069,13 @@ function parseTasksDoc(content) {
6907
7069
  ) && parseMarkdownCheckbox(line) === true
6908
7070
  );
6909
7071
  const prStatus = (() => {
6910
- const value = (extractFieldValue(content, PR_STATUS_LABELS) || "").trim().toLowerCase();
7072
+ const value = (extractFieldValue2(content, PR_STATUS_LABELS) || "").trim().toLowerCase();
6911
7073
  if (value === "review") return "review";
6912
7074
  if (value === "approved") return "approved";
6913
7075
  return null;
6914
7076
  })();
6915
7077
  const prePrReviewStatus = (() => {
6916
- const value = (extractFieldValue(content, PRE_PR_REVIEW_LABELS) || "").trim().toLowerCase();
7078
+ const value = (extractFieldValue2(content, PRE_PR_REVIEW_LABELS) || "").trim().toLowerCase();
6917
7079
  if (value === "pending") return "pending";
6918
7080
  if (value === "running") return "running";
6919
7081
  if (value === "done") return "done";
@@ -6926,17 +7088,17 @@ function parseTasksDoc(content) {
6926
7088
  })();
6927
7089
  return {
6928
7090
  docStatus: parseApprovalStatus(
6929
- extractFieldValue(content, DOC_STATUS_LABELS) || void 0
7091
+ extractFieldValue2(content, DOC_STATUS_LABELS) || void 0
6930
7092
  ),
6931
7093
  issueNumber,
6932
- branch: sanitizeMetadataValue(branchRaw),
6933
- prLink: sanitizeMetadataValue(prRaw),
7094
+ branch: sanitizeMetadataValue2(branchRaw),
7095
+ prLink: sanitizeMetadataValue2(prRaw),
6934
7096
  prStatus,
6935
7097
  prePrReviewStatus,
6936
- prePrEvidence: sanitizeMetadataValue(
6937
- extractFieldValue(content, PRE_PR_EVIDENCE_LABELS)
7098
+ prePrEvidence: sanitizeMetadataValue2(
7099
+ extractFieldValue2(content, PRE_PR_EVIDENCE_LABELS)
6938
7100
  ),
6939
- prePrDecision: sanitizeMetadataValue(prePrDecision),
7101
+ prePrDecision: sanitizeMetadataValue2(prePrDecision),
6940
7102
  prePrDecisionOutcome,
6941
7103
  tasks,
6942
7104
  completion: {
@@ -6946,20 +7108,245 @@ function parseTasksDoc(content) {
6946
7108
  }
6947
7109
  };
6948
7110
  }
6949
- function sanitizeMetadataValue(value) {
7111
+ function sanitizeMetadataValue2(value) {
6950
7112
  if (!value) return null;
6951
7113
  const trimmed = value.trim().replace(/^`(.+)`$/, "$1");
6952
7114
  if (!trimmed || trimmed === "-") return null;
6953
7115
  return trimmed;
6954
7116
  }
7117
+ function normalizeCommitTopicText(value) {
7118
+ return value.replace(/\s+/g, " ").trim();
7119
+ }
7120
+ function normalizeTaskTopic(value) {
7121
+ return normalizeCommitTopicText(value).replace(/^T-[A-Za-z0-9-]+\s+/, "");
7122
+ }
7123
+ function normalizeCommitSubjectForGate(value) {
7124
+ return normalizeCommitTopicText(value).replace(/^[a-z]+(?:\([^)]*\))?!?:\s*/i, "").toLowerCase();
7125
+ }
7126
+ function toTaskKey(rawTitle) {
7127
+ const trimmed = normalizeCommitTopicText(rawTitle);
7128
+ if (!trimmed) return "";
7129
+ const idMatch = trimmed.match(/^(T-[A-Za-z0-9-]+)/i);
7130
+ if (idMatch) return idMatch[1].toUpperCase();
7131
+ return normalizeTaskTopic(trimmed).toLowerCase();
7132
+ }
7133
+ function normalizeGitRelativePath(value) {
7134
+ return value.replace(/\\/g, "/").replace(/^\.\/+/, "").replace(/\/+$/, "");
7135
+ }
7136
+ function toRepoRelativePath(cwd, relativePathFromCwd) {
7137
+ const prefix = (runGitCapture(["rev-parse", "--show-prefix"], cwd) || "").trim().replace(/\/+$/, "");
7138
+ if (!prefix) return normalizeGitRelativePath(relativePathFromCwd);
7139
+ return normalizeGitRelativePath(`${prefix}/${relativePathFromCwd}`);
7140
+ }
7141
+ function parseDoneTransitionsFromDiff(diff) {
7142
+ const removedByTask = /* @__PURE__ */ new Map();
7143
+ const addedByTask = /* @__PURE__ */ new Map();
7144
+ const parseTaskDiffLine = (line) => {
7145
+ const match = line.match(
7146
+ /^\s*-\s*\[(TODO|DOING|DONE|REVIEW)\](?:\[[^\]]+\])*\s+(.+?)\s*$/i
7147
+ );
7148
+ if (!match) return null;
7149
+ const key = toTaskKey(match[2]);
7150
+ if (!key) return null;
7151
+ return {
7152
+ key,
7153
+ status: match[1].toUpperCase()
7154
+ };
7155
+ };
7156
+ for (const line of diff.split("\n")) {
7157
+ if (line.startsWith("---") || line.startsWith("+++")) continue;
7158
+ if (line.startsWith("-")) {
7159
+ const parsed = parseTaskDiffLine(line.slice(1));
7160
+ if (!parsed) continue;
7161
+ const existing = removedByTask.get(parsed.key) || /* @__PURE__ */ new Set();
7162
+ existing.add(parsed.status);
7163
+ removedByTask.set(parsed.key, existing);
7164
+ continue;
7165
+ }
7166
+ if (line.startsWith("+")) {
7167
+ const parsed = parseTaskDiffLine(line.slice(1));
7168
+ if (!parsed) continue;
7169
+ const existing = addedByTask.get(parsed.key) || /* @__PURE__ */ new Set();
7170
+ existing.add(parsed.status);
7171
+ addedByTask.set(parsed.key, existing);
7172
+ }
7173
+ }
7174
+ let doneTransitions = 0;
7175
+ for (const [taskKey, addedStatuses] of addedByTask.entries()) {
7176
+ if (!addedStatuses.has("DONE")) continue;
7177
+ const removedStatuses = removedByTask.get(taskKey);
7178
+ if (!removedStatuses) continue;
7179
+ if (removedStatuses.has("TODO") || removedStatuses.has("DOING") || removedStatuses.has("REVIEW")) {
7180
+ doneTransitions += 1;
7181
+ }
7182
+ }
7183
+ return doneTransitions;
7184
+ }
7185
+ function parseDoneTaskTopicCounts(content) {
7186
+ const counts = /* @__PURE__ */ new Map();
7187
+ for (const line of withoutFencedCodeBlocks(content)) {
7188
+ const match = line.match(
7189
+ /^\s*-\s*\[(DONE)\](?:\[[^\]]+\])*\s+(.+?)\s*$/i
7190
+ );
7191
+ if (!match) continue;
7192
+ const topic = normalizeTaskTopic(match[2] || "");
7193
+ if (!topic) continue;
7194
+ counts.set(topic, (counts.get(topic) || 0) + 1);
7195
+ }
7196
+ return counts;
7197
+ }
7198
+ function countDoneTransitionsInLatestTasksCommit(feature) {
7199
+ const docsGitCwd = feature.git.docsGitCwd;
7200
+ const tasksRelativePathFromDocs = normalizeGitRelativePath(
7201
+ path8.join(feature.docs.featurePathFromDocs, "tasks.md")
7202
+ );
7203
+ const latestTasksCommit = (runGitCapture(
7204
+ ["rev-list", "-n", "1", "HEAD", "--", tasksRelativePathFromDocs],
7205
+ docsGitCwd
7206
+ ) || "").trim();
7207
+ if (!latestTasksCommit) return void 0;
7208
+ const repoTasksPath = toRepoRelativePath(docsGitCwd, tasksRelativePathFromDocs);
7209
+ const currentContent = runGitCapture(
7210
+ ["show", `${latestTasksCommit}:${repoTasksPath}`],
7211
+ docsGitCwd
7212
+ );
7213
+ if (currentContent === void 0) return void 0;
7214
+ const previousContent = runGitCapture(["show", `${latestTasksCommit}^:${repoTasksPath}`], docsGitCwd) || "";
7215
+ const currentDone = parseDoneTaskTopicCounts(currentContent);
7216
+ const previousDone = parseDoneTaskTopicCounts(previousContent);
7217
+ let doneTransitions = 0;
7218
+ for (const [topic, currentCount] of currentDone.entries()) {
7219
+ const previousCount = previousDone.get(topic) || 0;
7220
+ if (currentCount > previousCount) {
7221
+ doneTransitions += currentCount - previousCount;
7222
+ }
7223
+ }
7224
+ return doneTransitions;
7225
+ }
7226
+ function countPendingDoneTransitions(feature) {
7227
+ const docsGitCwd = feature.git.docsGitCwd;
7228
+ const tasksRelativePath = normalizeGitRelativePath(
7229
+ path8.join(feature.docs.featurePathFromDocs, "tasks.md")
7230
+ );
7231
+ const diff = runGitCapture(
7232
+ ["diff", "--unified=0", "--no-color", "HEAD", "--", tasksRelativePath],
7233
+ docsGitCwd
7234
+ ) || "";
7235
+ if (!diff.trim()) return 0;
7236
+ return parseDoneTransitionsFromDiff(diff);
7237
+ }
7238
+ function getLastDoneTask(tasks) {
7239
+ for (let index = tasks.tasks.length - 1; index >= 0; index -= 1) {
7240
+ if (tasks.tasks[index].status === "DONE") return tasks.tasks[index];
7241
+ }
7242
+ return null;
7243
+ }
7244
+ function hasOpenTask(tasks) {
7245
+ return tasks.tasks.some(
7246
+ (task) => task.status === "DOING" || task.status === "REVIEW"
7247
+ );
7248
+ }
7249
+ function hasUncommittedChanges(gitCwd) {
7250
+ if (!gitCwd) return false;
7251
+ const status = runGitCapture(
7252
+ ["status", "--porcelain", "--untracked-files=no"],
7253
+ gitCwd
7254
+ ) || "";
7255
+ return status.trim().length > 0;
7256
+ }
7257
+ function resolveTaskCommitGatePolicy(config) {
7258
+ const raw = config.workflow?.taskCommitGate;
7259
+ return raw === "off" || raw === "strict" ? raw : "warn";
7260
+ }
7261
+ function checkTaskCommitGate(feature, effectiveProjectGitCwd, lastDoneTask) {
7262
+ const doneTransitions = countDoneTransitionsInLatestTasksCommit(feature);
7263
+ if (doneTransitions === 0) {
7264
+ return { pass: true, doneTransitions };
7265
+ }
7266
+ if (typeof doneTransitions === "number" && doneTransitions > 1) {
7267
+ return {
7268
+ pass: false,
7269
+ reason: "DONE_TRANSITIONS_COUNT",
7270
+ doneTransitions
7271
+ };
7272
+ }
7273
+ const lastDoneTopic = normalizeTaskTopic(lastDoneTask?.title || "");
7274
+ if (!effectiveProjectGitCwd || !lastDoneTopic) {
7275
+ return { pass: true };
7276
+ }
7277
+ const args = ["log", "-n", "1", "--pretty=%s", "--", "."];
7278
+ const relativeDocsDir = path8.relative(
7279
+ effectiveProjectGitCwd,
7280
+ feature.git.docsGitCwd
7281
+ );
7282
+ const normalizedDocsDir = normalizeGitRelativePath(relativeDocsDir);
7283
+ if (normalizedDocsDir && normalizedDocsDir !== "." && normalizedDocsDir !== ".." && !normalizedDocsDir.startsWith("../")) {
7284
+ args.push(`:(exclude)${normalizedDocsDir}/**`);
7285
+ }
7286
+ const latestProjectSubject = runGitCapture(args, effectiveProjectGitCwd);
7287
+ if (latestProjectSubject === void 0) {
7288
+ return { pass: false, reason: "PROJECT_LOG_UNAVAILABLE" };
7289
+ }
7290
+ const normalizedSubject = normalizeCommitSubjectForGate(latestProjectSubject);
7291
+ if (!normalizedSubject) {
7292
+ return { pass: false, reason: "NO_PROJECT_COMMIT" };
7293
+ }
7294
+ if (!normalizedSubject.includes(normalizeTaskTopic(lastDoneTopic).toLowerCase())) {
7295
+ return { pass: false, reason: "MISMATCH_LAST_DONE" };
7296
+ }
7297
+ return { pass: true };
7298
+ }
7299
+ function describeTaskCommitGateFailure(check) {
7300
+ switch (check.reason) {
7301
+ case "DONE_TRANSITIONS_COUNT":
7302
+ return `latest tasks.md commit includes ${check.doneTransitions || 0} DONE transitions`;
7303
+ case "NO_PROJECT_COMMIT":
7304
+ return "no recent project code commit was found for the just-finished task";
7305
+ case "PROJECT_LOG_UNAVAILABLE":
7306
+ return "the latest project commit subject could not be inspected";
7307
+ case "MISMATCH_LAST_DONE":
7308
+ default:
7309
+ return "the latest project commit subject does not match the just-finished task";
7310
+ }
7311
+ }
7312
+ function resolveProjectCommitTopic(feature, tasks) {
7313
+ const activeTask = tasks.tasks.find(
7314
+ (task) => task.status === "DOING" || task.status === "REVIEW"
7315
+ );
7316
+ const raw = activeTask?.title || getLastDoneTask(tasks)?.title || nextTodoTask(tasks)?.title || feature.folderName;
7317
+ const withoutTaskId = normalizeCommitTopicText(raw || "").replace(
7318
+ /^T-[A-Za-z0-9-]+\s+/,
7319
+ ""
7320
+ );
7321
+ return withoutTaskId || feature.folderName;
7322
+ }
7323
+ function buildTaskCommitSummary(input) {
7324
+ const { feature, tasks, effectiveProjectGitCwd, docsDirty, projectDirty, gateFailureReason } = input;
7325
+ 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"`;
7326
+ 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)}"`;
7327
+ const lines = ["Finish the task-level commit checkpoint before continuing."];
7328
+ if (gateFailureReason) {
7329
+ lines.push(`Current gate failure: ${gateFailureReason}`);
7330
+ }
7331
+ if (docsDirty) {
7332
+ lines.push(`Docs commit: ${docsMessage}`);
7333
+ }
7334
+ if (projectDirty) {
7335
+ lines.push(`Project commit: ${projectMessage}`);
7336
+ }
7337
+ if (!docsDirty && !projectDirty) {
7338
+ lines.push(`Re-check the last task commits. Docs commit should contain exactly one DONE transition, and the latest project commit should match "${normalizeTaskTopic(getLastDoneTask(tasks)?.title || "")}".`);
7339
+ }
7340
+ return lines.join("\n");
7341
+ }
6955
7342
  function parseWorkflowDraftMetadataExtended(content) {
6956
7343
  const metadata = parseWorkflowDraftMetadata(content);
6957
- const prStatusRaw = extractFieldValue(content, PR_STATUS_LABELS);
7344
+ const prStatusRaw = extractFieldValue2(content, PR_STATUS_LABELS);
6958
7345
  const normalizedPrStatus = (prStatusRaw || "").trim().toLowerCase();
6959
7346
  return {
6960
7347
  ...metadata,
6961
- issueRef: sanitizeMetadataValue(extractFieldValue(content, ISSUE_LABELS)),
6962
- prRef: sanitizeMetadataValue(extractFieldValue(content, PR_LABELS)),
7348
+ issueRef: sanitizeMetadataValue2(extractFieldValue2(content, ISSUE_LABELS)),
7349
+ prRef: sanitizeMetadataValue2(extractFieldValue2(content, PR_LABELS)),
6963
7350
  prStatus: normalizedPrStatus === "review" ? "review" : normalizedPrStatus === "approved" ? "approved" : null
6964
7351
  };
6965
7352
  }
@@ -6978,8 +7365,164 @@ function resolveExpectedBranch(feature, tasks) {
6978
7365
  if (!tasks.issueNumber) return null;
6979
7366
  return `feat/${tasks.issueNumber}-${feature.slug}`;
6980
7367
  }
6981
- function getCurrentProjectBranch(feature) {
6982
- return runGitCapture(["branch", "--show-current"], feature.git.projectGitCwd) || runGitCapture(["rev-parse", "--abbrev-ref", "HEAD"], feature.git.projectGitCwd) || null;
7368
+ function resolveProjectRootFromGitCwd2(projectGitCwd) {
7369
+ return runGitCapture(["rev-parse", "--show-toplevel"], projectGitCwd) || path8.resolve(projectGitCwd);
7370
+ }
7371
+ function resolveProjectRootGitCwd(config, feature) {
7372
+ if (config.docsRepo === "standalone") {
7373
+ const roots = resolveStandaloneProjectRoots(
7374
+ config,
7375
+ feature.type === "single" ? void 0 : feature.type
7376
+ );
7377
+ if (roots.length > 0) {
7378
+ return roots[0];
7379
+ }
7380
+ }
7381
+ return resolveProjectRootFromGitCwd2(feature.git.projectGitCwd);
7382
+ }
7383
+ function getExpectedWorktreePath(config, projectGitCwd, branchName) {
7384
+ const projectRoot = resolveProjectRootFromGitCwd2(projectGitCwd);
7385
+ return resolveManagedWorktreePath(config, projectRoot, branchName);
7386
+ }
7387
+ async function resolveExistingExpectedWorktreePath(config, projectGitCwd, branchName) {
7388
+ const candidate = getExpectedWorktreePath(config, projectGitCwd, branchName);
7389
+ return await fs.pathExists(candidate) ? candidate : null;
7390
+ }
7391
+ function buildManagedWorktreeCreateCommand(config, projectGitCwd, branchName) {
7392
+ const projectRoot = resolveProjectRootFromGitCwd2(projectGitCwd);
7393
+ const worktreePath = getExpectedWorktreePath(config, projectGitCwd, branchName);
7394
+ const worktreeParent = path8.dirname(worktreePath);
7395
+ const envLinkCommand = buildManagedWorktreeEnvLinkCommand(projectRoot, worktreePath);
7396
+ return `mkdir -p "${worktreeParent}" && (git -C "${projectRoot}" worktree add "${worktreePath}" "${branchName}" || git -C "${projectRoot}" worktree add -b "${branchName}" "${worktreePath}") && ${envLinkCommand}`;
7397
+ }
7398
+ function resolveRemotePrMergeMeta(prRef, projectGitCwd) {
7399
+ if (!prRef) return null;
7400
+ const result = runProcess(
7401
+ "gh",
7402
+ ["pr", "view", prRef, "--json", "headRefName,baseRefName"],
7403
+ projectGitCwd
7404
+ );
7405
+ if (result.code !== 0) {
7406
+ return null;
7407
+ }
7408
+ try {
7409
+ const parsed = JSON.parse(String(result.stdout || "{}"));
7410
+ return {
7411
+ headRefName: sanitizeMetadataValue2(String(parsed.headRefName || "")),
7412
+ baseRefName: sanitizeMetadataValue2(String(parsed.baseRefName || ""))
7413
+ };
7414
+ } catch {
7415
+ return null;
7416
+ }
7417
+ }
7418
+ function localBranchExists(cwd, branchName) {
7419
+ if (!branchName) return false;
7420
+ return runProcess(
7421
+ "git",
7422
+ ["show-ref", "--verify", "--quiet", `refs/heads/${branchName}`],
7423
+ cwd
7424
+ ).code === 0;
7425
+ }
7426
+ function remoteBranchExists(cwd, branchName) {
7427
+ if (!branchName) return false;
7428
+ return runProcess(
7429
+ "git",
7430
+ ["show-ref", "--verify", "--quiet", `refs/remotes/origin/${branchName}`],
7431
+ cwd
7432
+ ).code === 0;
7433
+ }
7434
+ function resolvePostMergeCleanupState(config, feature, tasks) {
7435
+ const projectRootGitCwd = resolveProjectRootGitCwd(config, feature);
7436
+ const prMeta = resolveRemotePrMergeMeta(tasks.prLink, projectRootGitCwd);
7437
+ const baseBranch = (prMeta?.baseRefName || "main").trim() || "main";
7438
+ const headBranch = (prMeta?.headRefName || resolveExpectedBranch(feature, tasks))?.trim() || null;
7439
+ const hasOriginRemote = runProcess(
7440
+ "git",
7441
+ ["remote", "get-url", "origin"],
7442
+ projectRootGitCwd
7443
+ ).code === 0;
7444
+ if (hasOriginRemote) {
7445
+ runProcess("git", ["fetch", "--prune", "origin"], projectRootGitCwd);
7446
+ }
7447
+ const currentBranch = runGitCapture(["branch", "--show-current"], projectRootGitCwd) || runGitCapture(["rev-parse", "--abbrev-ref", "HEAD"], projectRootGitCwd) || "";
7448
+ const localBaseSha = runGitCapture(["rev-parse", baseBranch], projectRootGitCwd) || "";
7449
+ const remoteBaseSha = hasOriginRemote ? runGitCapture(["rev-parse", `origin/${baseBranch}`], projectRootGitCwd) || "" : "";
7450
+ const worktreePath = config.docsRepo === "standalone" && headBranch ? resolveManagedWorktreePath(config, projectRootGitCwd, headBranch) : null;
7451
+ const managedWorktreeExists = !!worktreePath && fs.existsSync(worktreePath);
7452
+ const localFeatureBranchExists = localBranchExists(projectRootGitCwd, headBranch);
7453
+ const remoteFeatureBranchExists = hasOriginRemote && remoteBranchExists(projectRootGitCwd, headBranch);
7454
+ const localBaseCheckedOut = currentBranch === baseBranch;
7455
+ const baseSyncedWithOrigin = !hasOriginRemote || localBaseSha.length > 0 && remoteBaseSha.length > 0 && localBaseSha === remoteBaseSha;
7456
+ return {
7457
+ complete: localBaseCheckedOut && baseSyncedWithOrigin && !localFeatureBranchExists && !remoteFeatureBranchExists && !managedWorktreeExists,
7458
+ projectRootGitCwd,
7459
+ baseBranch,
7460
+ headBranch,
7461
+ worktreePath,
7462
+ hasOriginRemote,
7463
+ localBaseCheckedOut,
7464
+ baseSyncedWithOrigin,
7465
+ localFeatureBranchExists,
7466
+ remoteFeatureBranchExists,
7467
+ managedWorktreeExists
7468
+ };
7469
+ }
7470
+ function buildPostMergeCleanupCommand(state) {
7471
+ const commandParts = [];
7472
+ if (state.hasOriginRemote) {
7473
+ commandParts.push(
7474
+ `git -C "${state.projectRootGitCwd}" fetch --prune origin`
7475
+ );
7476
+ }
7477
+ commandParts.push(
7478
+ `git -C "${state.projectRootGitCwd}" checkout "${state.baseBranch}"`
7479
+ );
7480
+ if (state.hasOriginRemote) {
7481
+ commandParts.push(
7482
+ `git -C "${state.projectRootGitCwd}" pull --ff-only origin "${state.baseBranch}"`
7483
+ );
7484
+ }
7485
+ if (state.worktreePath) {
7486
+ commandParts.push(
7487
+ `if [ -d "${state.worktreePath}" ]; then git -C "${state.projectRootGitCwd}" worktree remove "${state.worktreePath}"; fi`
7488
+ );
7489
+ }
7490
+ if (state.headBranch) {
7491
+ commandParts.push(
7492
+ `if git -C "${state.projectRootGitCwd}" show-ref --verify --quiet "refs/heads/${state.headBranch}"; then git -C "${state.projectRootGitCwd}" branch -D "${state.headBranch}"; fi`
7493
+ );
7494
+ if (state.hasOriginRemote) {
7495
+ commandParts.push(
7496
+ `if git -C "${state.projectRootGitCwd}" show-ref --verify --quiet "refs/remotes/origin/${state.headBranch}"; then git -C "${state.projectRootGitCwd}" push origin --delete "${state.headBranch}"; fi`
7497
+ );
7498
+ commandParts.push(
7499
+ `git -C "${state.projectRootGitCwd}" fetch --prune origin`
7500
+ );
7501
+ }
7502
+ }
7503
+ return commandParts.join(" && ");
7504
+ }
7505
+ function buildPostMergeCleanupSummary(state) {
7506
+ const remaining = [];
7507
+ if (!state.localBaseCheckedOut) {
7508
+ remaining.push(`check out ${state.baseBranch}`);
7509
+ }
7510
+ if (!state.baseSyncedWithOrigin) {
7511
+ remaining.push(`sync ${state.baseBranch} with origin/${state.baseBranch}`);
7512
+ }
7513
+ if (state.managedWorktreeExists) {
7514
+ remaining.push("remove the managed feature worktree");
7515
+ }
7516
+ if (state.localFeatureBranchExists) {
7517
+ remaining.push("delete the local feature branch");
7518
+ }
7519
+ if (state.remoteFeatureBranchExists) {
7520
+ remaining.push("delete the remote feature branch");
7521
+ }
7522
+ if (remaining.length === 0) {
7523
+ return "Finish the post-merge cleanup before closing the feature.";
7524
+ }
7525
+ return `Finish the post-merge cleanup before closing the feature: ${remaining.join(", ")}.`;
6983
7526
  }
6984
7527
  function nextTodoTask(tasks) {
6985
7528
  return tasks.tasks.find((task) => task.status === "DOING") || tasks.tasks.find((task) => task.status === "TODO") || null;
@@ -7016,6 +7559,292 @@ function buildAction(category, summary, approvalRequired, command = null) {
7016
7559
  command
7017
7560
  };
7018
7561
  }
7562
+ function buildStageOption(label, reply, category, summary, command = null) {
7563
+ return {
7564
+ label,
7565
+ reply,
7566
+ category,
7567
+ summary,
7568
+ command
7569
+ };
7570
+ }
7571
+ function normalizeApprovalToken(value) {
7572
+ return (value ?? "").trim().toLowerCase();
7573
+ }
7574
+ function resolveActionApprovalRequired(config, category, builtinRequiresUserCheck) {
7575
+ const approval = config.approval?.mode === "builtin" ? createDefaultApprovalConfig() : config.approval ?? createDefaultApprovalConfig();
7576
+ const mode = approval.mode ?? "category";
7577
+ if (mode === "steps") {
7578
+ const requiredSteps = new Set(
7579
+ (approval.requireCheckSteps ?? []).map((value) => typeof value === "number" ? value : Number(value)).filter((value) => Number.isFinite(value))
7580
+ );
7581
+ const legacyStep = LEGACY_STEP_BY_ACTION[category];
7582
+ return typeof legacyStep === "number" ? requiredSteps.has(legacyStep) : builtinRequiresUserCheck;
7583
+ }
7584
+ const requiredCategories = new Set(
7585
+ (approval.requireCheckCategories ?? []).map((value) => normalizeApprovalToken(value)).filter(Boolean)
7586
+ );
7587
+ const skippedCategories = new Set(
7588
+ (approval.skipCheckCategories ?? []).map((value) => normalizeApprovalToken(value)).filter(Boolean)
7589
+ );
7590
+ const defaultPolicy = approval.default ?? createDefaultApprovalConfig().default ?? "skip";
7591
+ const normalizedCategory = normalizeApprovalToken(category);
7592
+ const explicitlyRequired = requiredCategories.has("*") || requiredCategories.has(normalizedCategory);
7593
+ if (explicitlyRequired) return true;
7594
+ if (skippedCategories.has("*") || skippedCategories.has(normalizedCategory)) {
7595
+ return false;
7596
+ }
7597
+ if (defaultPolicy === "require") return true;
7598
+ if (defaultPolicy === "skip") return false;
7599
+ return builtinRequiresUserCheck;
7600
+ }
7601
+ function resolveRemotePrReviewState(prRef, feature) {
7602
+ if (!prRef) return "unknown";
7603
+ const result = runProcess(
7604
+ "gh",
7605
+ [
7606
+ "pr",
7607
+ "view",
7608
+ prRef,
7609
+ "--json",
7610
+ "reviewDecision,state,mergedAt,mergeStateStatus,isDraft,headRefOid,latestReviews,comments,statusCheckRollup"
7611
+ ],
7612
+ feature.git.projectGitCwd
7613
+ );
7614
+ if (result.code !== 0) {
7615
+ return "unknown";
7616
+ }
7617
+ try {
7618
+ const parsed = JSON.parse(String(result.stdout || "{}"));
7619
+ const reviewDecision = String(parsed.reviewDecision || "").trim().toUpperCase();
7620
+ const state = String(parsed.state || "").trim().toUpperCase();
7621
+ const mergeStateStatus = String(parsed.mergeStateStatus || "").trim().toUpperCase();
7622
+ const isDraft = parsed.isDraft === true;
7623
+ const headRefOid = String(parsed.headRefOid || "").trim().toLowerCase();
7624
+ const mergedAt = typeof parsed.mergedAt === "string" ? parsed.mergedAt.trim() : "";
7625
+ const codeRabbitThreadState = reviewDecision.length === 0 ? resolveCodeRabbitReviewThreadsState(prRef, feature) : "unknown";
7626
+ const codeRabbitCheckSucceeded = hasSuccessfulCodeRabbitStatusCheck(
7627
+ parsed.statusCheckRollup
7628
+ );
7629
+ if (state === "MERGED" || mergedAt.length > 0) {
7630
+ return "merged";
7631
+ }
7632
+ if (isDraft) {
7633
+ return "draft";
7634
+ }
7635
+ if (reviewDecision === "CHANGES_REQUESTED") {
7636
+ return "changes_requested";
7637
+ }
7638
+ if (reviewDecision === "APPROVED") {
7639
+ return mergeStateStatus === "CLEAN" || mergeStateStatus === "HAS_HOOKS" ? "approved" : "merge_blocked";
7640
+ }
7641
+ if (reviewDecision.length === 0 && codeRabbitThreadState === "open") {
7642
+ return "changes_requested";
7643
+ }
7644
+ if (reviewDecision.length === 0 && hasLatestHeadRateLimitSignal(parsed, headRefOid)) {
7645
+ return "review_rate_limited";
7646
+ }
7647
+ if (reviewDecision.length === 0 && hasStaleLatestCommitReviewSignal(parsed, headRefOid) && !(codeRabbitThreadState === "resolved" && codeRabbitCheckSucceeded)) {
7648
+ return "review_pending_latest_commit";
7649
+ }
7650
+ if (reviewDecision.length === 0 && hasCodeRabbitActionableReview(parsed.latestReviews)) {
7651
+ if (codeRabbitThreadState === "resolved" && codeRabbitCheckSucceeded) {
7652
+ return mergeStateStatus === "CLEAN" || mergeStateStatus === "HAS_HOOKS" ? "approved" : "merge_blocked";
7653
+ }
7654
+ return "changes_requested";
7655
+ }
7656
+ if (reviewDecision.length === 0 && codeRabbitThreadState === "resolved" && codeRabbitCheckSucceeded) {
7657
+ return mergeStateStatus === "CLEAN" || mergeStateStatus === "HAS_HOOKS" ? "approved" : "merge_blocked";
7658
+ }
7659
+ if (reviewDecision === "REVIEW_REQUIRED" || reviewDecision.length === 0) {
7660
+ return "waiting_review";
7661
+ }
7662
+ return "unknown";
7663
+ } catch {
7664
+ return "unknown";
7665
+ }
7666
+ }
7667
+ function resolveCurrentReviewState(tasks, prDraft, remoteReviewState) {
7668
+ if (remoteReviewState === "merged") {
7669
+ return "merged";
7670
+ }
7671
+ if (remoteReviewState === "draft") {
7672
+ return "draft";
7673
+ }
7674
+ if (remoteReviewState === "merge_blocked") {
7675
+ return "merge_blocked";
7676
+ }
7677
+ if (remoteReviewState === "changes_requested") {
7678
+ return "changes_requested";
7679
+ }
7680
+ if (remoteReviewState === "review_rate_limited") {
7681
+ return "review_rate_limited";
7682
+ }
7683
+ if (remoteReviewState === "review_pending_latest_commit") {
7684
+ return "review_pending_latest_commit";
7685
+ }
7686
+ if (remoteReviewState === "waiting_review") {
7687
+ return "waiting_review";
7688
+ }
7689
+ if (remoteReviewState === "approved") {
7690
+ return "approved";
7691
+ }
7692
+ if (remoteReviewState === "unknown") {
7693
+ return "unknown";
7694
+ }
7695
+ if (tasks.prStatus === "approved" || prDraft.prStatus === "approved") {
7696
+ return "approved";
7697
+ }
7698
+ return "unknown";
7699
+ }
7700
+ function buildCodeReviewActionOptions(reviewState) {
7701
+ if (reviewState === "merged") {
7702
+ return [
7703
+ buildStageOption(
7704
+ "A",
7705
+ "A",
7706
+ "review_sync_approved",
7707
+ "Sync the already-merged PR state into tasks.md and pr.md before closing the feature."
7708
+ ),
7709
+ buildStageOption(
7710
+ "B",
7711
+ "B",
7712
+ "hold",
7713
+ "Stop here and leave the merged-state sync for later."
7714
+ )
7715
+ ];
7716
+ }
7717
+ if (reviewState === "approved") {
7718
+ return [
7719
+ buildStageOption(
7720
+ "A",
7721
+ "A",
7722
+ "review_sync_approved",
7723
+ "Sync the approved PR review state into tasks.md and pr.md, then continue to the merge gate."
7724
+ ),
7725
+ buildStageOption(
7726
+ "B",
7727
+ "B",
7728
+ "hold",
7729
+ "Hold the merge boundary for now and leave the PR open."
7730
+ )
7731
+ ];
7732
+ }
7733
+ if (reviewState === "draft" || reviewState === "merge_blocked") {
7734
+ return [
7735
+ buildStageOption(
7736
+ "A",
7737
+ "A",
7738
+ "review_wait",
7739
+ "Inspect the current PR state, resolve the draft/merge blocker, and sync the review fields before proceeding."
7740
+ ),
7741
+ buildStageOption(
7742
+ "B",
7743
+ "B",
7744
+ "hold",
7745
+ "Stop here and keep the PR open until the blocker is resolved."
7746
+ )
7747
+ ];
7748
+ }
7749
+ if (reviewState === "changes_requested") {
7750
+ return [
7751
+ buildStageOption(
7752
+ "A",
7753
+ "A",
7754
+ "review_fix",
7755
+ "Address the requested review changes, update review evidence/decision, and continue the feature."
7756
+ ),
7757
+ buildStageOption(
7758
+ "B",
7759
+ "B",
7760
+ "hold",
7761
+ "Stop here and wait before taking another review-fix pass."
7762
+ )
7763
+ ];
7764
+ }
7765
+ if (reviewState === "review_rate_limited") {
7766
+ return [
7767
+ buildStageOption(
7768
+ "A",
7769
+ "A",
7770
+ "review_wait",
7771
+ "Re-check the PR review state after the CodeRabbit rate limit window resets, then sync tasks.md when a fresh review arrives."
7772
+ ),
7773
+ buildStageOption(
7774
+ "B",
7775
+ "B",
7776
+ "hold",
7777
+ "Stop here and wait for the review rate limit window to clear."
7778
+ )
7779
+ ];
7780
+ }
7781
+ if (reviewState === "review_pending_latest_commit") {
7782
+ return [
7783
+ buildStageOption(
7784
+ "A",
7785
+ "A",
7786
+ "review_wait",
7787
+ "Re-check the PR review state after a reviewer processes the latest commit, then sync tasks.md when fresh review feedback arrives."
7788
+ ),
7789
+ buildStageOption(
7790
+ "B",
7791
+ "B",
7792
+ "hold",
7793
+ "Stop here and wait for a fresh review on the latest commit."
7794
+ )
7795
+ ];
7796
+ }
7797
+ return [
7798
+ buildStageOption(
7799
+ "A",
7800
+ "A",
7801
+ "review_wait",
7802
+ "Check the PR review state again and sync tasks.md when reviewer feedback or approval arrives."
7803
+ ),
7804
+ buildStageOption(
7805
+ "B",
7806
+ "B",
7807
+ "hold",
7808
+ "Stop here and wait for external reviewer feedback."
7809
+ )
7810
+ ];
7811
+ }
7812
+ function buildMergeActionOptions(command) {
7813
+ return [
7814
+ buildStageOption(
7815
+ "A",
7816
+ "A OK",
7817
+ "pr_merge",
7818
+ "Merge the PR now and sync the merged state back into tasks.md.",
7819
+ command
7820
+ ),
7821
+ buildStageOption(
7822
+ "B",
7823
+ "B",
7824
+ "hold",
7825
+ "Keep the PR open and do not merge yet."
7826
+ )
7827
+ ];
7828
+ }
7829
+ function buildApprovalActionOptions(params) {
7830
+ const remoteCommand = params.remoteCommand?.trim() || null;
7831
+ if (remoteCommand && remoteCommand.includes("--confirm OK")) {
7832
+ return [
7833
+ buildStageOption(
7834
+ "A",
7835
+ "A OK",
7836
+ "remote_execute",
7837
+ params.approveSummary,
7838
+ remoteCommand
7839
+ ),
7840
+ buildStageOption("B", "B", "hold", params.holdSummary)
7841
+ ];
7842
+ }
7843
+ return [
7844
+ buildStageOption("A", "A", "approve_continue", params.approveSummary),
7845
+ buildStageOption("B", "B", "request_changes", params.holdSummary)
7846
+ ];
7847
+ }
7019
7848
  function resolveFeatureSelectionError(selection) {
7020
7849
  const reasonCode = selection.status === "no_features" ? "NO_FEATURES" : "FEATURE_SELECTION_REQUIRED";
7021
7850
  return {
@@ -7051,6 +7880,7 @@ async function collectWorkflowStage(cwd, selector, component) {
7051
7880
  }
7052
7881
  const feature = selection.matchedFeature;
7053
7882
  const requirements = resolveWorkflowRequirements(config);
7883
+ const taskCommitGatePolicy = resolveTaskCommitGatePolicy(config);
7054
7884
  const paths = getFeatureDocPaths(feature);
7055
7885
  const specContent = await readFileIfExists(path8.join(config.docsDir, paths.specPath));
7056
7886
  const planContent = await readFileIfExists(path8.join(config.docsDir, paths.planPath));
@@ -7058,16 +7888,27 @@ async function collectWorkflowStage(cwd, selector, component) {
7058
7888
  const issueContent = await readFileIfExists(path8.join(config.docsDir, paths.issuePath));
7059
7889
  const prContent = await readFileIfExists(path8.join(config.docsDir, paths.prPath));
7060
7890
  const specStatus = parseApprovalStatus(
7061
- extractFieldValue(specContent || "", ["Status", "\uC0C1\uD0DC"]) || void 0
7891
+ extractFieldValue2(specContent || "", ["Status", "\uC0C1\uD0DC"]) || void 0
7062
7892
  );
7063
7893
  const planStatus = parseApprovalStatus(
7064
- extractFieldValue(planContent || "", ["Status", "\uC0C1\uD0DC"]) || void 0
7894
+ extractFieldValue2(planContent || "", ["Status", "\uC0C1\uD0DC"]) || void 0
7065
7895
  );
7066
7896
  const tasks = parseTasksDoc(tasksContent || "");
7067
7897
  const issueDraft = parseWorkflowDraftMetadataExtended(issueContent || "");
7068
7898
  const prDraft = parseWorkflowDraftMetadataExtended(prContent || "");
7899
+ const remoteReviewState = requirements.requireReview && tasks.prLink ? resolveRemotePrReviewState(tasks.prLink, feature) : "unknown";
7900
+ const currentReviewState = resolveCurrentReviewState(
7901
+ tasks,
7902
+ prDraft,
7903
+ remoteReviewState
7904
+ );
7069
7905
  if (specStatus !== "approved") {
7070
- const approvalRequired = specStatus === "review";
7906
+ const isReviewStage = specStatus === "review";
7907
+ const approvalRequired = isReviewStage ? resolveActionApprovalRequired(config, "spec_approve", true) : false;
7908
+ const actionOptions = approvalRequired ? buildApprovalActionOptions({
7909
+ approveSummary: "Approve spec.md and continue to the plan stage.",
7910
+ holdSummary: "Request spec changes before continuing."
7911
+ }) : void 0;
7071
7912
  return {
7072
7913
  status: "ok",
7073
7914
  reasonCode: "WORKFLOW_STAGE_RESOLVED",
@@ -7075,17 +7916,24 @@ async function collectWorkflowStage(cwd, selector, component) {
7075
7916
  featureRef: buildFeatureRef(feature),
7076
7917
  stage: "spec",
7077
7918
  nextAction: buildAction(
7078
- approvalRequired ? "spec_approve" : "spec_write",
7079
- approvalRequired ? "Get user approval and update spec.md status to Approved." : "Write or refine spec.md until it is ready for approval.",
7919
+ isReviewStage ? "spec_approve" : "spec_write",
7920
+ isReviewStage ? approvalRequired ? "Get user approval and update spec.md status to Approved." : "Promote spec.md from Review to Approved and continue automatically." : "Write or refine spec.md until it is ready for approval.",
7080
7921
  approvalRequired
7081
7922
  ),
7082
7923
  approvalRequired,
7083
7924
  implementationAllowed: false,
7925
+ primaryActionLabel: actionOptions ? "A" : void 0,
7926
+ actionOptions,
7084
7927
  blockedReasonCode: "SPEC_NOT_APPROVED"
7085
7928
  };
7086
7929
  }
7087
7930
  if (planStatus !== "approved") {
7088
- const approvalRequired = planStatus === "review";
7931
+ const isReviewStage = planStatus === "review";
7932
+ const approvalRequired = isReviewStage ? resolveActionApprovalRequired(config, "plan_approve", true) : false;
7933
+ const actionOptions = approvalRequired ? buildApprovalActionOptions({
7934
+ approveSummary: "Approve plan.md and continue to the tasks stage.",
7935
+ holdSummary: "Request plan changes before continuing."
7936
+ }) : void 0;
7089
7937
  return {
7090
7938
  status: "ok",
7091
7939
  reasonCode: "WORKFLOW_STAGE_RESOLVED",
@@ -7093,17 +7941,24 @@ async function collectWorkflowStage(cwd, selector, component) {
7093
7941
  featureRef: buildFeatureRef(feature),
7094
7942
  stage: "plan",
7095
7943
  nextAction: buildAction(
7096
- approvalRequired ? "plan_approve" : "plan_write",
7097
- approvalRequired ? "Get user approval and update plan.md status to Approved." : "Write or refine plan.md until it is ready for approval.",
7944
+ isReviewStage ? "plan_approve" : "plan_write",
7945
+ isReviewStage ? approvalRequired ? "Get user approval and update plan.md status to Approved." : "Promote plan.md from Review to Approved and continue automatically." : "Write or refine plan.md until it is ready for approval.",
7098
7946
  approvalRequired
7099
7947
  ),
7100
7948
  approvalRequired,
7101
7949
  implementationAllowed: false,
7950
+ primaryActionLabel: actionOptions ? "A" : void 0,
7951
+ actionOptions,
7102
7952
  blockedReasonCode: "PLAN_NOT_APPROVED"
7103
7953
  };
7104
7954
  }
7105
7955
  if (tasks.tasks.length === 0 || tasks.docStatus !== "approved") {
7106
- const approvalRequired = tasks.docStatus === "review";
7956
+ const isReviewStage = tasks.docStatus === "review";
7957
+ const approvalRequired = isReviewStage ? resolveActionApprovalRequired(config, "tasks_approve", true) : false;
7958
+ const actionOptions = approvalRequired ? buildApprovalActionOptions({
7959
+ approveSummary: "Approve tasks.md and continue to issue preparation.",
7960
+ holdSummary: "Request task-list changes before continuing."
7961
+ }) : void 0;
7107
7962
  return {
7108
7963
  status: "ok",
7109
7964
  reasonCode: "WORKFLOW_STAGE_RESOLVED",
@@ -7111,12 +7966,14 @@ async function collectWorkflowStage(cwd, selector, component) {
7111
7966
  featureRef: buildFeatureRef(feature),
7112
7967
  stage: "tasks",
7113
7968
  nextAction: buildAction(
7114
- approvalRequired ? "tasks_approve" : "tasks_write",
7115
- approvalRequired ? "Get user approval and update tasks.md Doc Status to Approved." : "Add and refine tasks until tasks.md is execution-ready and Approved.",
7969
+ isReviewStage ? "tasks_approve" : "tasks_write",
7970
+ isReviewStage ? approvalRequired ? "Get user approval and update tasks.md Doc Status to Approved." : "Promote tasks.md Doc Status from Review to Approved and continue automatically." : "Add and refine tasks until tasks.md is execution-ready and Approved.",
7116
7971
  approvalRequired
7117
7972
  ),
7118
7973
  approvalRequired,
7119
7974
  implementationAllowed: false,
7975
+ primaryActionLabel: actionOptions ? "A" : void 0,
7976
+ actionOptions,
7120
7977
  blockedReasonCode: "TASKS_NOT_READY"
7121
7978
  };
7122
7979
  }
@@ -7124,6 +7981,13 @@ async function collectWorkflowStage(cwd, selector, component) {
7124
7981
  const issueReady = issueDraft.status === "ready";
7125
7982
  const issueCreated = tasks.issueNumber !== null && issueExistsRemotely(tasks.issueNumber, feature);
7126
7983
  if (!issueCreated || !issueReady) {
7984
+ const issueCreateApprovalRequired = issueReady && !issueCreated;
7985
+ const issueCreateCommand = `npx lee-spec-kit github issue ${buildFeatureArgs(feature)} --create --confirm OK`;
7986
+ const issueCreateOptions = issueCreateApprovalRequired ? buildApprovalActionOptions({
7987
+ approveSummary: "Create the GitHub issue now and sync the issue number back into tasks.md.",
7988
+ holdSummary: "Keep the issue in Ready state but do not create it yet.",
7989
+ remoteCommand: issueCreateCommand
7990
+ }) : void 0;
7127
7991
  return {
7128
7992
  status: "ok",
7129
7993
  reasonCode: "WORKFLOW_STAGE_RESOLVED",
@@ -7133,23 +7997,40 @@ async function collectWorkflowStage(cwd, selector, component) {
7133
7997
  nextAction: issueReady && !issueCreated ? buildAction(
7134
7998
  "issue_create",
7135
7999
  "Create the GitHub issue from issue.md and sync the issue number into tasks.md.",
7136
- true,
7137
- `npx lee-spec-kit github issue ${buildFeatureArgs(feature)} --create --confirm OK`
8000
+ issueCreateApprovalRequired,
8001
+ issueCreateCommand
7138
8002
  ) : buildAction(
7139
8003
  "issue_prepare",
7140
8004
  "Prepare issue.md and set its Status to Ready before issue creation.",
7141
8005
  false
7142
8006
  ),
7143
- approvalRequired: issueReady && !issueCreated,
8007
+ approvalRequired: issueCreateApprovalRequired,
7144
8008
  implementationAllowed: false,
8009
+ primaryActionLabel: issueCreateOptions ? "A" : void 0,
8010
+ actionOptions: issueCreateOptions,
7145
8011
  blockedReasonCode: "ISSUE_NOT_CREATED"
7146
8012
  };
7147
8013
  }
7148
8014
  }
8015
+ let effectiveProjectGitCwd = feature.git.projectGitCwd;
8016
+ if (requirements.requireWorktree) {
8017
+ const expectedBranch = resolveExpectedBranch(feature, tasks);
8018
+ if (expectedBranch) {
8019
+ const existingWorktreePath = await resolveExistingExpectedWorktreePath(
8020
+ config,
8021
+ feature.git.projectGitCwd,
8022
+ expectedBranch
8023
+ );
8024
+ if (existingWorktreePath) {
8025
+ effectiveProjectGitCwd = existingWorktreePath;
8026
+ }
8027
+ }
8028
+ }
7149
8029
  if (requirements.requireBranch && !allTasksDone(tasks)) {
7150
8030
  const expectedBranch = resolveExpectedBranch(feature, tasks);
7151
- const currentBranch = getCurrentProjectBranch(feature);
8031
+ const currentBranch = runGitCapture(["branch", "--show-current"], effectiveProjectGitCwd) || runGitCapture(["rev-parse", "--abbrev-ref", "HEAD"], effectiveProjectGitCwd) || null;
7152
8032
  if (expectedBranch && currentBranch !== expectedBranch) {
8033
+ const branchCommand = requirements.requireWorktree ? buildManagedWorktreeCreateCommand(config, feature.git.projectGitCwd, expectedBranch) : `git checkout -b ${expectedBranch}`;
7153
8034
  return {
7154
8035
  status: "ok",
7155
8036
  reasonCode: "WORKFLOW_STAGE_RESOLVED",
@@ -7158,9 +8039,9 @@ async function collectWorkflowStage(cwd, selector, component) {
7158
8039
  stage: "branch",
7159
8040
  nextAction: buildAction(
7160
8041
  "branch_create",
7161
- `Switch the project repo to ${expectedBranch} before implementation starts.`,
8042
+ requirements.requireWorktree ? `Create or reuse the managed worktree for ${expectedBranch} before implementation starts.` : `Switch the project repo to ${expectedBranch} before implementation starts.`,
7162
8043
  false,
7163
- `git checkout -b ${expectedBranch}`
8044
+ branchCommand
7164
8045
  ),
7165
8046
  approvalRequired: false,
7166
8047
  implementationAllowed: false,
@@ -7168,8 +8049,66 @@ async function collectWorkflowStage(cwd, selector, component) {
7168
8049
  };
7169
8050
  }
7170
8051
  }
8052
+ const activeTaskOpen = hasOpenTask(tasks);
8053
+ const lastDoneTask = getLastDoneTask(tasks);
8054
+ const docsDirty = hasUncommittedChanges(feature.git.docsGitCwd);
8055
+ const projectDirty = hasUncommittedChanges(effectiveProjectGitCwd);
8056
+ const pendingDoneTransitions = countPendingDoneTransitions(feature) || 0;
8057
+ const taskCommitCheckpointRequired = !activeTaskOpen && !!lastDoneTask && (projectDirty || pendingDoneTransitions > 0);
8058
+ if (taskCommitCheckpointRequired) {
8059
+ const pendingReason = pendingDoneTransitions > 1 ? `working tree currently contains ${pendingDoneTransitions} uncommitted DONE transitions` : null;
8060
+ return {
8061
+ status: "ok",
8062
+ reasonCode: "WORKFLOW_STAGE_RESOLVED",
8063
+ docsDir: config.docsDir,
8064
+ featureRef: buildFeatureRef(feature),
8065
+ stage: "task_commit",
8066
+ nextAction: buildAction(
8067
+ "task_commit",
8068
+ buildTaskCommitSummary({
8069
+ feature,
8070
+ tasks,
8071
+ effectiveProjectGitCwd,
8072
+ docsDirty,
8073
+ projectDirty,
8074
+ gateFailureReason: pendingReason
8075
+ }),
8076
+ false
8077
+ ),
8078
+ approvalRequired: false,
8079
+ implementationAllowed: false,
8080
+ blockedReasonCode: "TASK_COMMIT_REQUIRED"
8081
+ };
8082
+ }
8083
+ const committedTaskGate = taskCommitGatePolicy !== "off" && lastDoneTask ? checkTaskCommitGate(feature, effectiveProjectGitCwd, lastDoneTask) : { pass: true };
7171
8084
  if (!allTasksDone(tasks)) {
7172
8085
  const currentTask = nextTodoTask(tasks);
8086
+ if (taskCommitGatePolicy === "strict" && !committedTaskGate.pass) {
8087
+ return {
8088
+ status: "ok",
8089
+ reasonCode: "WORKFLOW_STAGE_RESOLVED",
8090
+ docsDir: config.docsDir,
8091
+ featureRef: buildFeatureRef(feature),
8092
+ stage: "task_commit",
8093
+ nextAction: buildAction(
8094
+ "task_commit",
8095
+ buildTaskCommitSummary({
8096
+ feature,
8097
+ tasks,
8098
+ effectiveProjectGitCwd,
8099
+ docsDirty,
8100
+ projectDirty,
8101
+ gateFailureReason: describeTaskCommitGateFailure(committedTaskGate)
8102
+ }),
8103
+ false
8104
+ ),
8105
+ approvalRequired: false,
8106
+ implementationAllowed: false,
8107
+ blockedReasonCode: "TASK_COMMIT_REQUIRED"
8108
+ };
8109
+ }
8110
+ const commitWarning = taskCommitGatePolicy === "warn" && !committedTaskGate.pass ? `
8111
+ Task commit boundary warning: ${describeTaskCommitGateFailure(committedTaskGate)}` : "";
7173
8112
  return {
7174
8113
  status: "ok",
7175
8114
  reasonCode: "WORKFLOW_STAGE_RESOLVED",
@@ -7178,7 +8117,7 @@ async function collectWorkflowStage(cwd, selector, component) {
7178
8117
  stage: "implementation",
7179
8118
  nextAction: buildAction(
7180
8119
  "task_execute",
7181
- currentTask ? `Continue the next implementation task: ${currentTask.title}` : "Continue the active implementation task.",
8120
+ currentTask ? `Continue the next implementation task: ${currentTask.title}${commitWarning}` : "Continue the active implementation task.",
7182
8121
  false
7183
8122
  ),
7184
8123
  approvalRequired: false,
@@ -7187,6 +8126,39 @@ async function collectWorkflowStage(cwd, selector, component) {
7187
8126
  };
7188
8127
  }
7189
8128
  if (!tasks.completion.allTasksChecked || !tasks.completion.testsChecked || !tasks.completion.finalOutcomeChecked) {
8129
+ if (taskCommitGatePolicy === "strict" && !committedTaskGate.pass) {
8130
+ return {
8131
+ status: "ok",
8132
+ reasonCode: "WORKFLOW_STAGE_RESOLVED",
8133
+ docsDir: config.docsDir,
8134
+ featureRef: buildFeatureRef(feature),
8135
+ stage: "task_commit",
8136
+ nextAction: buildAction(
8137
+ "task_commit",
8138
+ buildTaskCommitSummary({
8139
+ feature,
8140
+ tasks,
8141
+ effectiveProjectGitCwd,
8142
+ docsDirty,
8143
+ projectDirty,
8144
+ gateFailureReason: describeTaskCommitGateFailure(committedTaskGate)
8145
+ }),
8146
+ false
8147
+ ),
8148
+ approvalRequired: false,
8149
+ implementationAllowed: false,
8150
+ blockedReasonCode: "TASK_COMMIT_REQUIRED"
8151
+ };
8152
+ }
8153
+ const approvalRequired = resolveActionApprovalRequired(
8154
+ config,
8155
+ "implementation_approve",
8156
+ true
8157
+ );
8158
+ const actionOptions = approvalRequired ? buildApprovalActionOptions({
8159
+ approveSummary: "Approve the completed implementation and continue to the pre-PR or PR preparation stage.",
8160
+ holdSummary: "Request implementation changes before the workflow continues."
8161
+ }) : void 0;
7190
8162
  return {
7191
8163
  status: "ok",
7192
8164
  reasonCode: "WORKFLOW_STAGE_RESOLVED",
@@ -7196,10 +8168,12 @@ async function collectWorkflowStage(cwd, selector, component) {
7196
8168
  nextAction: buildAction(
7197
8169
  "implementation_approve",
7198
8170
  "Share the completed implementation, get user approval, and record the completion checkpoint in tasks.md.",
7199
- true
8171
+ approvalRequired
7200
8172
  ),
7201
- approvalRequired: true,
8173
+ approvalRequired,
7202
8174
  implementationAllowed: false,
8175
+ primaryActionLabel: actionOptions ? "A" : void 0,
8176
+ actionOptions,
7203
8177
  blockedReasonCode: "IMPLEMENTATION_APPROVAL_REQUIRED"
7204
8178
  };
7205
8179
  }
@@ -7224,6 +8198,13 @@ async function collectWorkflowStage(cwd, selector, component) {
7224
8198
  const prReady = prDraft.status === "ready";
7225
8199
  const prCreated = !!tasks.prLink && prExistsRemotely(tasks.prLink, feature);
7226
8200
  if (!prCreated || !prReady) {
8201
+ const prCreateApprovalRequired = prReady && !prCreated;
8202
+ const prCreateCommand = `npx lee-spec-kit github pr ${buildFeatureArgs(feature)} --create --confirm OK`;
8203
+ const prCreateOptions = prCreateApprovalRequired ? buildApprovalActionOptions({
8204
+ approveSummary: "Create the GitHub PR now and sync the PR metadata back into tasks.md.",
8205
+ holdSummary: "Keep the PR in Ready state but do not create it yet.",
8206
+ remoteCommand: prCreateCommand
8207
+ }) : void 0;
7227
8208
  return {
7228
8209
  status: "ok",
7229
8210
  reasonCode: "WORKFLOW_STAGE_RESOLVED",
@@ -7233,20 +8214,63 @@ async function collectWorkflowStage(cwd, selector, component) {
7233
8214
  nextAction: prReady && !prCreated ? buildAction(
7234
8215
  "pr_create",
7235
8216
  "Create the GitHub PR from pr.md and sync the PR metadata into tasks.md.",
7236
- true,
7237
- `npx lee-spec-kit github pr ${buildFeatureArgs(feature)} --create --confirm OK`
8217
+ prCreateApprovalRequired,
8218
+ prCreateCommand
7238
8219
  ) : buildAction(
7239
8220
  "pr_prepare",
7240
8221
  "Prepare pr.md and set its Status to Ready before PR creation.",
7241
8222
  false
7242
8223
  ),
7243
- approvalRequired: prReady && !prCreated,
8224
+ approvalRequired: prCreateApprovalRequired,
7244
8225
  implementationAllowed: false,
8226
+ primaryActionLabel: prCreateOptions ? "A" : void 0,
8227
+ actionOptions: prCreateOptions,
7245
8228
  blockedReasonCode: "PR_NOT_CREATED"
7246
8229
  };
7247
8230
  }
7248
8231
  }
7249
- if (requirements.requireReview && (tasks.prStatus !== "approved" || prDraft.prStatus !== "approved")) {
8232
+ const reviewApprovedInDocs = tasks.prStatus === "approved" && prDraft.prStatus === "approved";
8233
+ if (requirements.requireReview && currentReviewState === "merged" && reviewApprovedInDocs) {
8234
+ const cleanupState = resolvePostMergeCleanupState(config, feature, tasks);
8235
+ if (!cleanupState.complete) {
8236
+ return {
8237
+ status: "ok",
8238
+ reasonCode: "WORKFLOW_STAGE_RESOLVED",
8239
+ docsDir: config.docsDir,
8240
+ featureRef: buildFeatureRef(feature),
8241
+ stage: "cleanup",
8242
+ nextAction: buildAction(
8243
+ "merge_cleanup",
8244
+ buildPostMergeCleanupSummary(cleanupState),
8245
+ false,
8246
+ buildPostMergeCleanupCommand(cleanupState)
8247
+ ),
8248
+ approvalRequired: false,
8249
+ implementationAllowed: false,
8250
+ reviewState: "merged",
8251
+ blockedReasonCode: "POST_MERGE_CLEANUP_REQUIRED"
8252
+ };
8253
+ }
8254
+ return {
8255
+ status: "ok",
8256
+ reasonCode: "WORKFLOW_STAGE_RESOLVED",
8257
+ docsDir: config.docsDir,
8258
+ featureRef: buildFeatureRef(feature),
8259
+ stage: "done",
8260
+ nextAction: null,
8261
+ approvalRequired: false,
8262
+ implementationAllowed: false,
8263
+ reviewState: "merged",
8264
+ primaryActionLabel: null,
8265
+ actionOptions: [],
8266
+ blockedReasonCode: null
8267
+ };
8268
+ }
8269
+ if (requirements.requireReview && (!reviewApprovedInDocs || currentReviewState !== "approved")) {
8270
+ const reviewFixAllowed = currentReviewState === "changes_requested";
8271
+ const reviewApprovalRequired = !reviewFixAllowed;
8272
+ const reviewActionOptions = reviewApprovalRequired ? buildCodeReviewActionOptions(currentReviewState) : void 0;
8273
+ const reviewSummary = currentReviewState === "approved" ? "Record the approved PR review state in tasks.md and pr.md before proceeding to merge." : currentReviewState === "merged" ? "Sync the already-merged PR state into tasks.md and pr.md before marking the workflow as complete." : currentReviewState === "changes_requested" ? "Address the requested review changes and update the PR review evidence/decision before continuing." : currentReviewState === "review_pending_latest_commit" ? "Wait for a fresh review on the latest PR commit before taking the next review action." : currentReviewState === "review_rate_limited" ? "Wait for the current CodeRabbit review rate limit to clear, then re-check the latest PR review state before continuing." : currentReviewState === "draft" ? "Resolve the draft PR state before continuing to the merge boundary." : currentReviewState === "merge_blocked" ? "Resolve the current PR merge blocker before continuing to merge." : "Wait for PR review or inspect the current review state before taking the next review action.";
7250
8274
  return {
7251
8275
  status: "ok",
7252
8276
  reasonCode: "WORKFLOW_STAGE_RESOLVED",
@@ -7255,15 +8279,19 @@ async function collectWorkflowStage(cwd, selector, component) {
7255
8279
  stage: "code_review",
7256
8280
  nextAction: buildAction(
7257
8281
  "code_review",
7258
- "Complete PR review and record the final approved review state in tasks.md.",
7259
- false
8282
+ reviewSummary,
8283
+ reviewApprovalRequired
7260
8284
  ),
7261
- approvalRequired: false,
7262
- implementationAllowed: false,
8285
+ approvalRequired: reviewApprovalRequired,
8286
+ implementationAllowed: reviewFixAllowed,
8287
+ reviewState: currentReviewState,
8288
+ primaryActionLabel: reviewActionOptions ? "A" : void 0,
8289
+ actionOptions: reviewActionOptions,
7263
8290
  blockedReasonCode: "PR_REVIEW_NOT_APPROVED"
7264
8291
  };
7265
8292
  }
7266
8293
  if (requirements.requireMerge) {
8294
+ const mergeCommand = `npx lee-spec-kit github pr ${buildFeatureArgs(feature)} --merge --confirm OK`;
7267
8295
  return {
7268
8296
  status: "ok",
7269
8297
  reasonCode: "WORKFLOW_STAGE_RESOLVED",
@@ -7274,10 +8302,13 @@ async function collectWorkflowStage(cwd, selector, component) {
7274
8302
  "pr_merge",
7275
8303
  "Merge the PR and sync the merged state back into tasks.md.",
7276
8304
  true,
7277
- `npx lee-spec-kit github pr ${buildFeatureArgs(feature)} --merge --confirm OK`
8305
+ mergeCommand
7278
8306
  ),
7279
8307
  approvalRequired: true,
7280
8308
  implementationAllowed: false,
8309
+ reviewState: "approved",
8310
+ primaryActionLabel: "A",
8311
+ actionOptions: buildMergeActionOptions(mergeCommand),
7281
8312
  blockedReasonCode: null
7282
8313
  };
7283
8314
  }
@@ -7293,6 +8324,237 @@ async function collectWorkflowStage(cwd, selector, component) {
7293
8324
  blockedReasonCode: null
7294
8325
  };
7295
8326
  }
8327
+ function hasLatestHeadRateLimitSignal(parsed, headRefOid) {
8328
+ const latestRateLimitCommentAt = findLatestCodeRabbitRateLimitCommentAt(
8329
+ parsed.comments,
8330
+ headRefOid
8331
+ );
8332
+ if (!latestRateLimitCommentAt) {
8333
+ return false;
8334
+ }
8335
+ const latestReviewAt = findLatestCodeRabbitReviewAt(parsed.latestReviews);
8336
+ return !latestReviewAt || latestReviewAt <= latestRateLimitCommentAt;
8337
+ }
8338
+ function hasStaleLatestCommitReviewSignal(parsed, headRefOid) {
8339
+ if (!headRefOid) {
8340
+ return false;
8341
+ }
8342
+ const latestReviewHead = findLatestCodeRabbitReviewedHead(parsed.latestReviews);
8343
+ if (!latestReviewHead) {
8344
+ return false;
8345
+ }
8346
+ return !matchesCommitReference(headRefOid, latestReviewHead);
8347
+ }
8348
+ function resolveCodeRabbitReviewThreadsState(prRef, feature) {
8349
+ const coordinates = parseGithubPullRequestRef(prRef);
8350
+ if (!coordinates) {
8351
+ return "unknown";
8352
+ }
8353
+ const result = runProcess(
8354
+ "gh",
8355
+ [
8356
+ "api",
8357
+ "graphql",
8358
+ "-f",
8359
+ `owner=${coordinates.owner}`,
8360
+ "-f",
8361
+ `name=${coordinates.name}`,
8362
+ "-F",
8363
+ `number=${coordinates.number}`,
8364
+ "-f",
8365
+ "query=query($owner:String!, $name:String!, $number:Int!) { repository(owner:$owner, name:$name) { pullRequest(number:$number) { headRefOid reviewThreads(first:100) { nodes { isResolved isOutdated comments(first:20) { nodes { author { login } body } } } } } } }"
8366
+ ],
8367
+ feature.git.projectGitCwd
8368
+ );
8369
+ if (result.code !== 0) {
8370
+ return "unknown";
8371
+ }
8372
+ try {
8373
+ const parsed = JSON.parse(String(result.stdout || "{}"));
8374
+ const nodes = extractNestedArray(parsed, [
8375
+ "data",
8376
+ "repository",
8377
+ "pullRequest",
8378
+ "reviewThreads",
8379
+ "nodes"
8380
+ ]);
8381
+ if (!nodes) {
8382
+ return "unknown";
8383
+ }
8384
+ const codeRabbitThreads = nodes.filter(isCodeRabbitReviewThread);
8385
+ if (codeRabbitThreads.length === 0) {
8386
+ return "none";
8387
+ }
8388
+ return codeRabbitThreads.some((thread) => !isReviewThreadResolved(thread)) ? "open" : "resolved";
8389
+ } catch {
8390
+ return "unknown";
8391
+ }
8392
+ }
8393
+ function parseGithubPullRequestRef(prRef) {
8394
+ const value = prRef?.trim();
8395
+ if (!value) {
8396
+ return null;
8397
+ }
8398
+ const urlMatch = value.match(
8399
+ /^https?:\/\/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)(?:[/?#].*)?$/i
8400
+ );
8401
+ if (!urlMatch?.[1] || !urlMatch[2] || !urlMatch[3]) {
8402
+ return null;
8403
+ }
8404
+ return {
8405
+ owner: urlMatch[1],
8406
+ name: urlMatch[2],
8407
+ number: Number(urlMatch[3])
8408
+ };
8409
+ }
8410
+ function hasSuccessfulCodeRabbitStatusCheck(statusChecksValue) {
8411
+ if (!Array.isArray(statusChecksValue)) {
8412
+ return false;
8413
+ }
8414
+ return statusChecksValue.some((entry) => {
8415
+ if (!entry || typeof entry !== "object") return false;
8416
+ const record = entry;
8417
+ const label = [
8418
+ record.context,
8419
+ record.name,
8420
+ extractNestedString(record, ["app", "name"]),
8421
+ extractNestedString(record, ["checkSuite", "app", "name"])
8422
+ ].filter((value) => typeof value === "string" && value.trim().length > 0).join(" ").toLowerCase();
8423
+ if (!label.includes("coderabbit")) return false;
8424
+ const state = String(record.state || record.conclusion || "").trim().toUpperCase();
8425
+ return state === "SUCCESS";
8426
+ });
8427
+ }
8428
+ function isCodeRabbitReviewThread(threadValue) {
8429
+ const comments = extractNestedArray(threadValue, ["comments", "nodes"]);
8430
+ if (!comments) {
8431
+ return false;
8432
+ }
8433
+ return comments.some(
8434
+ (comment) => extractNestedString(comment, ["author", "login"]).toLowerCase().startsWith("coderabbitai")
8435
+ );
8436
+ }
8437
+ function isReviewThreadResolved(threadValue) {
8438
+ if (!threadValue || typeof threadValue !== "object") {
8439
+ return false;
8440
+ }
8441
+ const record = threadValue;
8442
+ return record.isResolved === true || record.isOutdated === true;
8443
+ }
8444
+ function hasCodeRabbitActionableReview(reviewsValue) {
8445
+ if (!Array.isArray(reviewsValue)) {
8446
+ return false;
8447
+ }
8448
+ return reviewsValue.some((entry) => {
8449
+ if (!entry || typeof entry !== "object") return false;
8450
+ const authorLogin = extractNestedString(entry, ["author", "login"]).toLowerCase();
8451
+ if (authorLogin !== "coderabbitai") return false;
8452
+ const state = String(entry.state || "").trim().toUpperCase();
8453
+ if (state === "CHANGES_REQUESTED") return true;
8454
+ if (state !== "COMMENTED") return false;
8455
+ const body = String(entry.body || "");
8456
+ const actionableMatch = body.match(/Actionable comments posted:\s*(\d+)/i);
8457
+ return actionableMatch ? Number(actionableMatch[1]) > 0 : false;
8458
+ });
8459
+ }
8460
+ function extractNestedArray(value, pathSegments) {
8461
+ let current = value;
8462
+ for (const segment of pathSegments) {
8463
+ if (!current || typeof current !== "object") {
8464
+ return null;
8465
+ }
8466
+ current = current[segment];
8467
+ }
8468
+ return Array.isArray(current) ? current : null;
8469
+ }
8470
+ function findLatestCodeRabbitRateLimitCommentAt(commentsValue, headRefOid) {
8471
+ if (!Array.isArray(commentsValue)) {
8472
+ return null;
8473
+ }
8474
+ let latest = null;
8475
+ for (const entry of commentsValue) {
8476
+ if (!entry || typeof entry !== "object") continue;
8477
+ const authorLogin = extractNestedString(entry, ["author", "login"]).toLowerCase();
8478
+ if (authorLogin !== "coderabbitai") continue;
8479
+ const body = String(entry.body || "");
8480
+ if (!isCodeRabbitRateLimitBody(body, headRefOid)) continue;
8481
+ const createdAt = String(entry.createdAt || "").trim();
8482
+ if (!createdAt) continue;
8483
+ if (!latest || createdAt > latest) {
8484
+ latest = createdAt;
8485
+ }
8486
+ }
8487
+ return latest;
8488
+ }
8489
+ function findLatestCodeRabbitReviewAt(reviewsValue) {
8490
+ if (!Array.isArray(reviewsValue)) {
8491
+ return null;
8492
+ }
8493
+ let latest = null;
8494
+ for (const entry of reviewsValue) {
8495
+ if (!entry || typeof entry !== "object") continue;
8496
+ const authorLogin = extractNestedString(entry, ["author", "login"]).toLowerCase();
8497
+ if (authorLogin !== "coderabbitai") continue;
8498
+ const submittedAt = String(entry.submittedAt || "").trim();
8499
+ if (!submittedAt) continue;
8500
+ if (!latest || submittedAt > latest) {
8501
+ latest = submittedAt;
8502
+ }
8503
+ }
8504
+ return latest;
8505
+ }
8506
+ function findLatestCodeRabbitReviewedHead(reviewsValue) {
8507
+ if (!Array.isArray(reviewsValue)) {
8508
+ return null;
8509
+ }
8510
+ let latestReview = null;
8511
+ for (const entry of reviewsValue) {
8512
+ if (!entry || typeof entry !== "object") continue;
8513
+ const authorLogin = extractNestedString(entry, ["author", "login"]).toLowerCase();
8514
+ if (authorLogin !== "coderabbitai") continue;
8515
+ const submittedAt = String(entry.submittedAt || "").trim();
8516
+ if (!submittedAt) continue;
8517
+ const body = String(entry.body || "");
8518
+ const reviewedHead = extractReviewedHeadFromReviewBody(body);
8519
+ if (!latestReview || submittedAt > latestReview.submittedAt) {
8520
+ latestReview = { submittedAt, reviewedHead };
8521
+ }
8522
+ }
8523
+ return latestReview?.reviewedHead ?? null;
8524
+ }
8525
+ function isCodeRabbitRateLimitBody(body, headRefOid) {
8526
+ const normalized = body.toLowerCase();
8527
+ if (!normalized.includes("rate limited by coderabbit.ai") && !normalized.includes("rate limit exceeded")) {
8528
+ return false;
8529
+ }
8530
+ if (!headRefOid) {
8531
+ return true;
8532
+ }
8533
+ const shortHead = headRefOid.slice(0, 7);
8534
+ return normalized.includes(headRefOid) || normalized.includes(shortHead);
8535
+ }
8536
+ function extractReviewedHeadFromReviewBody(body) {
8537
+ const match = body.match(/between\s+[0-9a-f]{7,40}\s+and\s+([0-9a-f]{7,40})/i);
8538
+ if (!match) {
8539
+ return null;
8540
+ }
8541
+ return match[1].trim().toLowerCase();
8542
+ }
8543
+ function matchesCommitReference(headRefOid, reviewedHead) {
8544
+ const normalizedHead = headRefOid.trim().toLowerCase();
8545
+ const normalizedReviewedHead = reviewedHead.trim().toLowerCase();
8546
+ return normalizedHead === normalizedReviewedHead || normalizedHead.startsWith(normalizedReviewedHead) || normalizedReviewedHead.startsWith(normalizedHead);
8547
+ }
8548
+ function extractNestedString(value, pathSegments) {
8549
+ let current = value;
8550
+ for (const segment of pathSegments) {
8551
+ if (!current || typeof current !== "object") {
8552
+ return "";
8553
+ }
8554
+ current = current[segment];
8555
+ }
8556
+ return typeof current === "string" ? current.trim() : "";
8557
+ }
7296
8558
 
7297
8559
  // src/commands/workflow-stage.ts
7298
8560
  function workflowStageCommand(program2) {
@@ -7754,13 +9016,35 @@ var DEFAULT_MANAGED_DOC_FILES = [
7754
9016
  ".gitignore"
7755
9017
  ];
7756
9018
 
9019
+ // src/utils/commit-conventions.ts
9020
+ var PROJECT_COMMIT_PREFIX_PATTERN = /^(feat|fix|refactor|test|chore)\(#(\d+)\):\s+\S.+$/i;
9021
+ var DOCS_COMMIT_PREFIX_PATTERN = /^docs\(#(\d+)\):\s+\S.+$/i;
9022
+ function matchesProjectCommitConvention(message, issueNumber) {
9023
+ const normalized = String(message || "").trim();
9024
+ if (!normalized) return false;
9025
+ const match = normalized.match(PROJECT_COMMIT_PREFIX_PATTERN);
9026
+ if (!match) return false;
9027
+ return Number(match[2]) === issueNumber;
9028
+ }
9029
+ function matchesDocsCommitConvention(message, issueNumber) {
9030
+ const normalized = String(message || "").trim();
9031
+ if (!normalized) return false;
9032
+ const match = normalized.match(DOCS_COMMIT_PREFIX_PATTERN);
9033
+ if (!match) return false;
9034
+ return Number(match[1]) === issueNumber;
9035
+ }
9036
+
7757
9037
  // src/commands/commit-audit.ts
7758
9038
  var CANONICAL_FEATURE_DOC_PATTERN = /^features\/(?:[^/]+\/)?F\d{3,}[^/]*\/(spec|plan|tasks|decisions|issue|pr)\.md$/i;
7759
9039
  var FEATURE_DOC_CANDIDATE_PATTERN = /^features\/(?:[^/]+\/)?F\d{3,}[^/]*\/(.+)$/i;
7760
9040
  function commitAuditCommand(program2) {
7761
- 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").action(async (options) => {
9041
+ 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) => {
7762
9042
  try {
7763
- const payload = await collectCommitAudit(process.cwd(), options.gitRoot);
9043
+ const payload = await collectCommitAudit(
9044
+ process.cwd(),
9045
+ options.gitRoot,
9046
+ options.message
9047
+ );
7764
9048
  if (options.json) {
7765
9049
  console.log(JSON.stringify(payload, null, 2));
7766
9050
  return;
@@ -7795,7 +9079,7 @@ function commitAuditCommand(program2) {
7795
9079
  }
7796
9080
  });
7797
9081
  }
7798
- async function collectCommitAudit(cwd, gitRootOverride) {
9082
+ async function collectCommitAudit(cwd, gitRootOverride, commitMessage) {
7799
9083
  const config = await getConfig(cwd);
7800
9084
  if (!config) {
7801
9085
  throw createCliError("CONFIG_NOT_FOUND", "Config file not found. Run `init` first.");
@@ -7836,6 +9120,16 @@ async function collectCommitAudit(cwd, gitRootOverride) {
7836
9120
  stagedEntries,
7837
9121
  config.allowedDocsEntries
7838
9122
  );
9123
+ const commitMessageViolation = await collectCommitMessageViolation(
9124
+ cwd,
9125
+ config,
9126
+ repoRoot,
9127
+ stagedEntries,
9128
+ commitMessage
9129
+ );
9130
+ if (commitMessageViolation) {
9131
+ violations.push(commitMessageViolation);
9132
+ }
7839
9133
  if (violations.length === 0) {
7840
9134
  return {
7841
9135
  status: "ok",
@@ -7880,6 +9174,9 @@ function collectAllowedCommitRepoRoots(config, cwd) {
7880
9174
  if (projectRepoRoot) {
7881
9175
  allowed.add(path8.resolve(projectRepoRoot));
7882
9176
  }
9177
+ for (const worktreeRepoRoot of collectManagedWorktreeRepoRoots(config, projectRoot)) {
9178
+ allowed.add(path8.resolve(worktreeRepoRoot));
9179
+ }
7883
9180
  }
7884
9181
  return allowed;
7885
9182
  }
@@ -7889,6 +9186,28 @@ function collectAllowedCommitRepoRoots(config, cwd) {
7889
9186
  }
7890
9187
  return allowed;
7891
9188
  }
9189
+ function collectManagedWorktreeRepoRoots(config, projectRoot) {
9190
+ const managedRoot = resolveStandaloneManagedWorktreeRoot(config, projectRoot);
9191
+ if (!managedRoot) {
9192
+ return [];
9193
+ }
9194
+ const output = runGitCapture(["worktree", "list", "--porcelain"], projectRoot) || "";
9195
+ const roots = /* @__PURE__ */ new Set();
9196
+ for (const rawLine of output.split("\n")) {
9197
+ const line = rawLine.trim();
9198
+ if (!line.startsWith("worktree ")) continue;
9199
+ const worktreePath = path8.resolve(line.slice("worktree ".length).trim());
9200
+ if (isSameOrWithin2(path8.resolve(managedRoot), worktreePath)) {
9201
+ roots.add(worktreePath);
9202
+ }
9203
+ }
9204
+ return [...roots];
9205
+ }
9206
+ function isSameOrWithin2(parentDir, candidateDir) {
9207
+ const resolvedParent = path8.resolve(parentDir);
9208
+ const resolvedCandidate = path8.resolve(candidateDir);
9209
+ return resolvedParent === resolvedCandidate || resolvedCandidate.startsWith(`${resolvedParent}${path8.sep}`);
9210
+ }
7892
9211
  function parseStagedPaths(output) {
7893
9212
  const staged = /* @__PURE__ */ new Map();
7894
9213
  for (const rawLine of output.split("\n")) {
@@ -7967,6 +9286,9 @@ function collectCommitViolations(repoRoot, docsDir, stagedEntries, allowed) {
7967
9286
  function resolveReasonCode(violations) {
7968
9287
  const kinds = new Set(violations.map((entry) => entry.kind));
7969
9288
  if (kinds.size > 1) return "DOCS_COMMIT_POLICY_VIOLATION";
9289
+ if (kinds.has("commit_message_policy")) {
9290
+ return "COMMIT_MESSAGE_POLICY_VIOLATION";
9291
+ }
7970
9292
  if (kinds.has("unsupported_git_target")) return "UNSUPPORTED_GIT_TARGET";
7971
9293
  if (kinds.has("unmanaged_docs_entry")) return "UNMANAGED_DOCS_COMMIT";
7972
9294
  if (kinds.has("canonical_feature_doc_deletion")) {
@@ -7974,6 +9296,36 @@ function resolveReasonCode(violations) {
7974
9296
  }
7975
9297
  return "NON_CANONICAL_FEATURE_DOC_COMMIT";
7976
9298
  }
9299
+ async function collectCommitMessageViolation(cwd, config, repoRoot, stagedEntries, commitMessage) {
9300
+ const normalizedMessage = String(commitMessage || "").trim();
9301
+ if (!normalizedMessage) {
9302
+ return null;
9303
+ }
9304
+ const selection = await resolveFeatureSelection(cwd);
9305
+ if (selection.status !== "selected" || !selection.matchedFeature?.issueNumber) {
9306
+ return null;
9307
+ }
9308
+ const issueNumber = selection.matchedFeature.issueNumber;
9309
+ const docsRepoRoot = runGitCapture(["rev-parse", "--show-toplevel"], config.docsDir);
9310
+ const normalizedRepoRoot = path8.resolve(repoRoot);
9311
+ const normalizedDocsRepoRoot = docsRepoRoot ? path8.resolve(docsRepoRoot) : null;
9312
+ const docsOnlyCommit = stagedEntries.length > 0 && stagedEntries.every((entry) => {
9313
+ const absolutePath = path8.resolve(repoRoot, entry.path);
9314
+ const relativeToDocs = normalizeSlashes3(path8.relative(config.docsDir, absolutePath));
9315
+ return !!relativeToDocs && relativeToDocs !== "" && !relativeToDocs.startsWith("..");
9316
+ });
9317
+ const isDocsCommit = !!normalizedDocsRepoRoot && normalizedDocsRepoRoot === normalizedRepoRoot && (config.docsRepo === "standalone" || docsOnlyCommit);
9318
+ const valid = isDocsCommit ? matchesDocsCommitConvention(normalizedMessage, issueNumber) : matchesProjectCommitConvention(normalizedMessage, issueNumber);
9319
+ if (valid) {
9320
+ return null;
9321
+ }
9322
+ const expected = isDocsCommit ? `docs(#${issueNumber}): ...` : `type(#${issueNumber}): ... (feat/fix/refactor/test/chore)`;
9323
+ return {
9324
+ path: "(commit message)",
9325
+ kind: "commit_message_policy",
9326
+ detail: `Commit subject must follow the issue-scoped convention for this feature. Expected ${expected}, received "${normalizedMessage}".`
9327
+ };
9328
+ }
7977
9329
  function normalizeEntryName(value) {
7978
9330
  return value.trim().toLowerCase();
7979
9331
  }