lee-spec-kit 0.8.1 → 0.8.4

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,25 @@ 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. In standalone mode, do not hand-write \`git worktree add\`; run the exact \`nextAction.command\` from \`workflow-stage\` so the managed workspace path, stale directory cleanup, and \`.env\` / \`.env.*\` copy step stay consistent
1301
+ 11. Keep docs and code synchronized; if code changes materially, update the active feature docs in the same turn before stopping
1302
+ 12. 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
1303
 
1299
1304
  Approval and remote actions:
1300
1305
 
1301
1306
  - Ask the user for approval only at documented workflow approval boundaries or before remote/destructive actions
1302
1307
  - If \`workflow-stage --json\` reports \`approvalRequired === true\`, stop at that boundary and ask the user before proceeding
1308
+ - 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
1309
+ - 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
1310
  - Before \`git commit\`, prefer \`npx lee-spec-kit commit-audit --json\` when hooks or manual checks need commit-time docs path enforcement
1304
1311
  - Before remote GitHub actions, share the plan or artifact being sent
1305
1312
  - Respect repo policy from docs and config first; hooks only enforce guardrails and continuation checks
@@ -1431,6 +1438,61 @@ function resolveStandaloneProjectRoots(config, component) {
1431
1438
  function resolveGitTopLevelOrNull(cwd) {
1432
1439
  return runGitCapture(["rev-parse", "--show-toplevel"], cwd) || null;
1433
1440
  }
1441
+ function normalizeBranchNameForWorktree(branchName) {
1442
+ return branchName.trim().replace(/[\\/]/g, "-");
1443
+ }
1444
+ function resolveStandaloneManagedWorktreeRoot(config, projectRoot) {
1445
+ if (config.docsRepo !== "standalone") return null;
1446
+ const workspaceRoot = resolveConfiguredStandaloneWorkspaceRoot(config);
1447
+ if (!workspaceRoot) return null;
1448
+ return path8.resolve(
1449
+ workspaceRoot,
1450
+ ".worktrees",
1451
+ path8.basename(path8.resolve(projectRoot))
1452
+ );
1453
+ }
1454
+ function resolveManagedWorktreePath(config, projectRoot, branchName) {
1455
+ const standaloneRoot = resolveStandaloneManagedWorktreeRoot(config, projectRoot);
1456
+ if (standaloneRoot) {
1457
+ return path8.resolve(
1458
+ standaloneRoot,
1459
+ normalizeBranchNameForWorktree(branchName)
1460
+ );
1461
+ }
1462
+ return path8.resolve(
1463
+ path8.resolve(projectRoot),
1464
+ ".worktrees",
1465
+ normalizeBranchNameForWorktree(branchName)
1466
+ );
1467
+ }
1468
+ var registeredWorktreeCache = /* @__PURE__ */ new Map();
1469
+ function listRegisteredGitWorktrees(projectRoot) {
1470
+ const resolvedProjectRoot = path8.resolve(projectRoot);
1471
+ const cached = registeredWorktreeCache.get(resolvedProjectRoot);
1472
+ if (cached) return cached;
1473
+ const output = runGitCapture(
1474
+ ["worktree", "list", "--porcelain"],
1475
+ resolvedProjectRoot
1476
+ ) || "";
1477
+ const worktrees = /* @__PURE__ */ new Set();
1478
+ for (const line of output.split(/\r?\n/u)) {
1479
+ if (!line.startsWith("worktree ")) continue;
1480
+ const listedPath = line.slice("worktree ".length).trim();
1481
+ if (listedPath) worktrees.add(path8.resolve(listedPath));
1482
+ }
1483
+ registeredWorktreeCache.set(resolvedProjectRoot, worktrees);
1484
+ return worktrees;
1485
+ }
1486
+ function isRegisteredGitWorktree(projectRoot, worktreePath) {
1487
+ const resolvedTarget = path8.resolve(worktreePath);
1488
+ return listRegisteredGitWorktrees(projectRoot).has(resolvedTarget);
1489
+ }
1490
+ function buildManagedWorktreeStaleCleanupCommand(projectRoot, worktreePath) {
1491
+ return `if [ -d "${worktreePath}" ] && ! git -C "${projectRoot}" worktree list --porcelain | grep -Fxq "worktree ${worktreePath}"; then rm -rf "${worktreePath}"; fi`;
1492
+ }
1493
+ function buildManagedWorktreeEnvCopyCommand(projectRoot, worktreePath) {
1494
+ return `sh -c 'source_dir=$1; target_dir=$2; for source_env in "$source_dir"/.env "$source_dir"/.env.*; do [ -e "$source_env" ] || [ -L "$source_env" ] || continue; target_env="$target_dir/$(basename "$source_env")"; if [ ! -e "$target_env" ] && [ ! -L "$target_env" ]; then cp -p "$source_env" "$target_env"; fi; done' sh "${path8.resolve(projectRoot)}" "${path8.resolve(worktreePath)}"`;
1495
+ }
1434
1496
 
1435
1497
  // src/utils/init/options.ts
1436
1498
  function parseStandaloneMultiProjectRootJson(raw) {
@@ -2423,7 +2485,7 @@ async function runInit(options) {
2423
2485
  workflow: {
2424
2486
  preset: workflowMode,
2425
2487
  mode: workflowMode,
2426
- requireWorktree: false,
2488
+ requireWorktree: docsRepo === "standalone",
2427
2489
  codeDirtyScope: "auto",
2428
2490
  taskCommitGate: "warn",
2429
2491
  auto: {
@@ -2551,7 +2613,7 @@ async function initGit(cwd, targetDir, docsRepo, lang, pushDocs, docsRemote, ext
2551
2613
  }
2552
2614
  };
2553
2615
  const toGitPath = (input) => input.replace(/\\/g, "/").replace(/^\.\//, "");
2554
- const toRepoRelativePath = (workdir, relativePath2) => {
2616
+ const toRepoRelativePath2 = (workdir, relativePath2) => {
2555
2617
  if (relativePath2 === ".") return ".";
2556
2618
  try {
2557
2619
  const prefix = runGitOrThrow(["rev-parse", "--show-prefix"], workdir, {
@@ -2582,7 +2644,7 @@ async function initGit(cwd, targetDir, docsRepo, lang, pushDocs, docsRemote, ext
2582
2644
  return;
2583
2645
  }
2584
2646
  if (relativePath !== "." && isPathIgnored(gitWorkdir, relativePath)) {
2585
- const repoRelativePath = toRepoRelativePath(gitWorkdir, relativePath);
2647
+ const repoRelativePath = toRepoRelativePath2(gitWorkdir, relativePath);
2586
2648
  console.log(
2587
2649
  chalk.yellow(
2588
2650
  tr(lang, "cli", "init.warn.docsPathIgnoredSkipCommit", {
@@ -3258,7 +3320,9 @@ function hasOwnKey(value, key) {
3258
3320
  }
3259
3321
  function isLegacyGeneratedApprovalConfig(approval) {
3260
3322
  const mode = typeof approval.mode === "string" ? approval.mode : "";
3261
- if (mode && mode !== "category" && mode !== "steps") return false;
3323
+ if (mode && mode !== "category" && mode !== "steps" && mode !== "builtin") {
3324
+ return false;
3325
+ }
3262
3326
  const overrideKeys = [
3263
3327
  "default",
3264
3328
  "requireCheckSteps",
@@ -3514,7 +3578,16 @@ async function backfillMissingConfigDefaults(cwd, docsDir) {
3514
3578
  const inferredPreset = workflow.mode === "local" ? "local" : "github";
3515
3579
  setIfMissing(workflow, "preset", inferredPreset, "workflow.preset");
3516
3580
  setIfMissing(workflow, "mode", "github", "workflow.mode");
3517
- setIfMissing(workflow, "requireWorktree", false, "workflow.requireWorktree");
3581
+ setIfMissing(
3582
+ workflow,
3583
+ "requireWorktree",
3584
+ raw.docsRepo === "standalone",
3585
+ "workflow.requireWorktree"
3586
+ );
3587
+ if (raw.docsRepo === "standalone" && workflow.requireWorktree !== true) {
3588
+ workflow.requireWorktree = true;
3589
+ changedPaths.push("workflow.requireWorktree");
3590
+ }
3518
3591
  setIfMissing(workflow, "codeDirtyScope", "auto", "workflow.codeDirtyScope");
3519
3592
  setIfMissing(workflow, "taskCommitGate", "warn", "workflow.taskCommitGate");
3520
3593
  if (!isPlainObject(workflow.auto)) {
@@ -3927,6 +4000,7 @@ function runGhJson(args, cwd, messages) {
3927
4000
  );
3928
4001
  }
3929
4002
  }
4003
+ var BRANCH_LABELS = ["Branch", "\uBE0C\uB79C\uCE58"];
3930
4004
  function normalizeComponent(value) {
3931
4005
  const component = (value || "").trim().toLowerCase();
3932
4006
  return component || void 0;
@@ -3959,6 +4033,44 @@ function resolveProjectGitCwd(cwd, config, component) {
3959
4033
  }
3960
4034
  return resolveGitTopLevelOrNull(cwd) || resolveGitTopLevelOrNull(config.docsDir) || cwd;
3961
4035
  }
4036
+ function resolveProjectRootFromGitCwd(projectGitCwd) {
4037
+ return resolveGitTopLevelOrNull(projectGitCwd) || path8.resolve(projectGitCwd);
4038
+ }
4039
+ async function resolveExistingManagedWorktreePath(config, projectGitCwd, slug, folderName, issueNumber, branchName) {
4040
+ const projectRoot = resolveProjectRootFromGitCwd(projectGitCwd);
4041
+ const branchCandidates = [
4042
+ branchName,
4043
+ issueNumber ? `feat/${issueNumber}-${slug}` : null,
4044
+ issueNumber ? `feat/${issueNumber}-${folderName}` : null
4045
+ ].filter((candidate) => !!candidate);
4046
+ const candidates = [...new Set(branchCandidates)].map(
4047
+ (candidate) => resolveManagedWorktreePath(config, projectRoot, candidate)
4048
+ );
4049
+ for (const candidate of candidates) {
4050
+ if (await fs.pathExists(candidate) && isRegisteredGitWorktree(projectRoot, candidate)) {
4051
+ return candidate;
4052
+ }
4053
+ }
4054
+ return null;
4055
+ }
4056
+ function extractFieldValue(content, labels) {
4057
+ for (const label of Array.isArray(labels) ? labels : [labels]) {
4058
+ const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
4059
+ const match = content.match(
4060
+ new RegExp(`^\\s*-\\s*\\*\\*${escaped}\\*\\*:\\s*(.*?)\\s*$`, "mi")
4061
+ );
4062
+ if (!match) continue;
4063
+ const value = match[1].trim();
4064
+ if (value) return value;
4065
+ }
4066
+ return null;
4067
+ }
4068
+ function sanitizeMetadataValue(value) {
4069
+ if (!value) return null;
4070
+ const trimmed = value.trim().replace(/^`(.+)`$/, "$1");
4071
+ if (!trimmed || trimmed === "-") return null;
4072
+ return trimmed;
4073
+ }
3962
4074
  function toFeaturePathFromDocs(projectType, component, folderName) {
3963
4075
  return projectType === "multi" && component !== "single" ? path8.join("features", component, folderName) : path8.join("features", folderName);
3964
4076
  }
@@ -3971,6 +4083,12 @@ async function extractIssueNumber(featureDir) {
3971
4083
  const parsed = Number(match[1]);
3972
4084
  return Number.isFinite(parsed) ? parsed : void 0;
3973
4085
  }
4086
+ async function extractBranchName(featureDir) {
4087
+ const tasksPath = path8.join(featureDir, "tasks.md");
4088
+ if (!await fs.pathExists(tasksPath)) return null;
4089
+ const content = await fs.readFile(tasksPath, "utf-8");
4090
+ return sanitizeMetadataValue(extractFieldValue(content, BRANCH_LABELS));
4091
+ }
3974
4092
  async function listResolvedFeatures(cwd, config, component) {
3975
4093
  const refs = await listLeeSpecFeatures(cwd);
3976
4094
  const normalizedComponent = normalizeComponent(component);
@@ -3984,6 +4102,17 @@ async function listResolvedFeatures(cwd, config, component) {
3984
4102
  ref.folderName
3985
4103
  );
3986
4104
  const featureDir = path8.join(config.docsDir, featurePathFromDocs);
4105
+ const issueNumber = await extractIssueNumber(featureDir);
4106
+ const branchName = await extractBranchName(featureDir);
4107
+ const projectGitCwdBase = resolveProjectGitCwd(cwd, config, type);
4108
+ const worktreeProjectGitCwd = config.docsRepo === "standalone" && (issueNumber || branchName) ? await resolveExistingManagedWorktreePath(
4109
+ config,
4110
+ projectGitCwdBase,
4111
+ ref.slug,
4112
+ ref.folderName,
4113
+ issueNumber,
4114
+ branchName
4115
+ ) : null;
3987
4116
  return {
3988
4117
  id: ref.id || ref.folderName.split("-")[0] || "",
3989
4118
  slug: ref.slug,
@@ -3995,9 +4124,9 @@ async function listResolvedFeatures(cwd, config, component) {
3995
4124
  },
3996
4125
  git: {
3997
4126
  docsGitCwd: config.docsDir,
3998
- projectGitCwd: resolveProjectGitCwd(cwd, config, type)
4127
+ projectGitCwd: worktreeProjectGitCwd || projectGitCwdBase
3999
4128
  },
4000
- issueNumber: await extractIssueNumber(featureDir)
4129
+ issueNumber
4001
4130
  };
4002
4131
  })
4003
4132
  );
@@ -4237,6 +4366,15 @@ function sanitizeDraftTitleValue(raw) {
4237
4366
  const normalized = value.replace(/`/g, "").replace(/\*\*(.*?)\*\*/g, "$1").replace(/\[(.*?)\]\((.*?)\)/g, "$1").replace(/\s+/g, " ").trim();
4238
4367
  return normalized || void 0;
4239
4368
  }
4369
+ function isPlaceholderWorkflowDraftTitle(raw, feature) {
4370
+ const normalized = sanitizeDraftTitleValue(raw);
4371
+ if (!normalized) return true;
4372
+ const lowered = normalized.trim().toLowerCase();
4373
+ const placeholders = new Set(
4374
+ [feature.slug, feature.folderName].map((value) => (value || "").trim().toLowerCase()).filter(Boolean)
4375
+ );
4376
+ return placeholders.has(lowered);
4377
+ }
4240
4378
  function parseWorkflowDraftStatus(raw) {
4241
4379
  const value = (raw || "").trim();
4242
4380
  if (!value) return void 0;
@@ -4944,8 +5082,36 @@ function isOverviewMetadataLine(line, lang) {
4944
5082
  return keys.some((key) => cleaned.startsWith(`${key}:`));
4945
5083
  }
4946
5084
  function truncateIssueTitleSummary(input, maxLength = 72) {
4947
- if (input.length <= maxLength) return input;
4948
- return `${input.slice(0, maxLength - 3).trimEnd()}...`;
5085
+ const normalized = input.trim().replace(/\s+/g, " ");
5086
+ if (normalized.length <= maxLength) return normalized;
5087
+ const stripTrailingPunctuation = (value) => value.trim().replace(/[.!?。]+$/u, "").trim();
5088
+ 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");
5089
+ const addCandidate = (bucket, seen2, value) => {
5090
+ const cleaned = stripTrailingPunctuation(value);
5091
+ if (!cleaned || seen2.has(cleaned)) return;
5092
+ seen2.add(cleaned);
5093
+ bucket.push(cleaned);
5094
+ };
5095
+ const candidates = [];
5096
+ const seen = /* @__PURE__ */ new Set();
5097
+ addCandidate(candidates, seen, normalized);
5098
+ const commaStripped = normalized.replace(/^[^,,]+[,,]\s*/u, "");
5099
+ addCandidate(candidates, seen, commaStripped);
5100
+ const purposeLeadStripped = normalized.replace(/^.+?(?:하도록|하기 위해),\s*/u, "");
5101
+ addCandidate(candidates, seen, purposeLeadStripped);
5102
+ for (const base of [...candidates]) {
5103
+ addCandidate(candidates, seen, rewriteVerboseEnding(base));
5104
+ }
5105
+ for (const base of [...candidates]) {
5106
+ for (const part of base.split(/[,:;,;]/u)) {
5107
+ addCandidate(candidates, seen, part);
5108
+ }
5109
+ }
5110
+ const fitting = candidates.filter((candidate) => candidate.length <= maxLength);
5111
+ if (fitting.length > 0) {
5112
+ return fitting.sort((left, right) => right.length - left.length)[0];
5113
+ }
5114
+ return stripTrailingPunctuation(normalized.slice(0, maxLength));
4949
5115
  }
4950
5116
  function resolveIssueTitleSummary(overview, feature, lang) {
4951
5117
  const candidates = overview.split("\n").map((line) => normalizeIssueTitleSummaryLine(line)).filter((line) => !!line).filter((line) => !isOverviewMetadataLine(line, lang));
@@ -5392,7 +5558,7 @@ function syncTasksIssueMetadata(tasksPath, issueNumber, lang) {
5392
5558
  }
5393
5559
  return { changed, path: tasksPath };
5394
5560
  }
5395
- function syncIssueDraftMetadata(issueDocPath, issueNumber) {
5561
+ function syncIssueDraftMetadata(issueDocPath, issueNumber, title) {
5396
5562
  if (!fs.existsSync(issueDocPath)) {
5397
5563
  return { changed: false, path: issueDocPath };
5398
5564
  }
@@ -5412,6 +5578,16 @@ function syncIssueDraftMetadata(issueDocPath, issueNumber) {
5412
5578
  next = inserted.content;
5413
5579
  changed = changed || inserted.changed;
5414
5580
  }
5581
+ const normalizedTitle = sanitizeDraftTitleValue(title);
5582
+ if (normalizedTitle) {
5583
+ const titleReplaced = replaceListField(
5584
+ next,
5585
+ ["Title", "\uC81C\uBAA9"],
5586
+ normalizedTitle
5587
+ );
5588
+ next = titleReplaced.content;
5589
+ changed = changed || titleReplaced.changed;
5590
+ }
5415
5591
  if (changed) {
5416
5592
  fs.writeFileSync(issueDocPath, next, "utf-8");
5417
5593
  }
@@ -5456,7 +5632,7 @@ function syncTasksPrMetadata(tasksPath, prUrl, nextStatus, lang) {
5456
5632
  }
5457
5633
  return { changed, path: tasksPath };
5458
5634
  }
5459
- function syncPrDraftMetadata(prDocPath, prUrl, nextStatus) {
5635
+ function syncPrDraftMetadata(prDocPath, prUrl, nextStatus, title) {
5460
5636
  if (!fs.existsSync(prDocPath)) {
5461
5637
  return { changed: false, path: prDocPath };
5462
5638
  }
@@ -5483,6 +5659,17 @@ function syncPrDraftMetadata(prDocPath, prUrl, nextStatus) {
5483
5659
  next = inserted.content;
5484
5660
  changed = changed || inserted.changed;
5485
5661
  }
5662
+ const normalizedTitle = sanitizeDraftTitleValue(title);
5663
+ if (normalizedTitle) {
5664
+ const titleReplaced = replaceListField(next, ["Title", "\uC81C\uBAA9"], normalizedTitle);
5665
+ next = titleReplaced.content;
5666
+ changed = changed || titleReplaced.changed;
5667
+ if (!titleReplaced.found) {
5668
+ const inserted = insertFieldInMetadataSection(next, "Title", normalizedTitle);
5669
+ next = inserted.content;
5670
+ changed = changed || inserted.changed;
5671
+ }
5672
+ }
5486
5673
  if (changed) {
5487
5674
  fs.writeFileSync(prDocPath, next, "utf-8");
5488
5675
  }
@@ -5777,7 +5964,10 @@ function githubCommand(program2) {
5777
5964
  await fs.writeFile(sanitizedBodyFile, body, "utf-8");
5778
5965
  bodyFile = sanitizedBodyFile;
5779
5966
  }
5780
- const title = options.title?.trim() || (preparedBody.source === "workflow-ready" ? preparedBody.draftMetadata?.title : void 0) || defaultTitle;
5967
+ const title = options.title?.trim() || (preparedBody.source === "workflow-ready" && !isPlaceholderWorkflowDraftTitle(
5968
+ preparedBody.draftMetadata?.title,
5969
+ feature
5970
+ ) ? preparedBody.draftMetadata?.title : void 0) || defaultTitle;
5781
5971
  const labels = parseLabels(
5782
5972
  optionLabels || (preparedBody.source === "workflow-ready" ? preparedBody.draftMetadata?.labels : void 0),
5783
5973
  config.lang
@@ -5828,7 +6018,8 @@ function githubCommand(program2) {
5828
6018
  );
5829
6019
  const draftSynced = syncIssueDraftMetadata(
5830
6020
  path8.join(config.docsDir, paths.issuePath),
5831
- syncedIssueNumber
6021
+ syncedIssueNumber,
6022
+ title
5832
6023
  );
5833
6024
  syncChanged = synced.changed || draftSynced.changed;
5834
6025
  }
@@ -6013,7 +6204,8 @@ function githubCommand(program2) {
6013
6204
  await fs.writeFile(sanitizedBodyFile, body, "utf-8");
6014
6205
  bodyFile = sanitizedBodyFile;
6015
6206
  }
6016
- const title = options.title?.trim() || (preparedBody.source === "workflow-ready" ? preparedBody.draftMetadata?.title : void 0) || defaultTitle;
6207
+ const requestedTitle = options.title?.trim() || (preparedBody.source === "workflow-ready" ? preparedBody.draftMetadata?.title : void 0) || "";
6208
+ let title = requestedTitle || defaultTitle;
6017
6209
  const labels = parseLabels(
6018
6210
  optionLabels || (preparedBody.source === "workflow-ready" ? preparedBody.draftMetadata?.labels : void 0),
6019
6211
  config.lang
@@ -6023,7 +6215,6 @@ function githubCommand(program2) {
6023
6215
  let prUrl = options.pr?.trim() || "";
6024
6216
  let mergedAttempts;
6025
6217
  let mergeAlreadyMerged;
6026
- const postMergeWarnings = [];
6027
6218
  let syncChanged = false;
6028
6219
  const pushDocsSync = shouldPushDocsSync(config);
6029
6220
  if (options.create) {
@@ -6036,11 +6227,18 @@ function githubCommand(program2) {
6036
6227
  feature.issueNumber ? String(feature.issueNumber) : void 0,
6037
6228
  config.lang
6038
6229
  );
6230
+ title = closingIssueNumber && closingIssueNumber.trim() ? defaultTitle : requestedTitle || defaultTitle;
6039
6231
  assertRemoteIssueExists(
6040
6232
  closingIssueNumber,
6041
6233
  projectGitCwd,
6042
6234
  config.lang
6043
6235
  );
6236
+ if (closingIssueNumber && options.title?.trim() && options.title.trim() !== defaultTitle) {
6237
+ throw createCliError(
6238
+ "PRECONDITION_FAILED",
6239
+ `PR title must follow the existing convention: "${defaultTitle}".`
6240
+ );
6241
+ }
6044
6242
  const normalizedBody = ensureIssueClosingLine(
6045
6243
  body,
6046
6244
  closingIssueNumber
@@ -6116,7 +6314,8 @@ function githubCommand(program2) {
6116
6314
  const syncedDraft = syncPrDraftMetadata(
6117
6315
  path8.join(config.docsDir, paths.prPath),
6118
6316
  prUrl,
6119
- "Review"
6317
+ "Review",
6318
+ title
6120
6319
  );
6121
6320
  syncChanged = syncedTasks.changed || syncedDraft.changed;
6122
6321
  const shouldCommitSync = !!options.commitSync || !!options.merge;
@@ -6186,34 +6385,6 @@ function githubCommand(program2) {
6186
6385
  );
6187
6386
  }
6188
6387
  }
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
6388
  }
6218
6389
  if (options.json) {
6219
6390
  console.log(
@@ -6235,8 +6406,7 @@ function githubCommand(program2) {
6235
6406
  syncChanged,
6236
6407
  merged: !!options.merge,
6237
6408
  mergeAttempts: mergedAttempts,
6238
- mergeAlreadyMerged,
6239
- postMergeWarnings: postMergeWarnings.length > 0 ? postMergeWarnings : void 0
6409
+ mergeAlreadyMerged
6240
6410
  },
6241
6411
  null,
6242
6412
  2
@@ -6284,9 +6454,6 @@ function githubCommand(program2) {
6284
6454
  chalk.yellow(tg(config.lang, "prAlreadyMergedNotice"))
6285
6455
  );
6286
6456
  }
6287
- for (const warning of postMergeWarnings) {
6288
- console.log(chalk.yellow(`\u26A0\uFE0F ${warning}`));
6289
- }
6290
6457
  } else if (!options.create) {
6291
6458
  console.log(
6292
6459
  chalk.blue(tg(config.lang, "prTemplateGenerated"))
@@ -6601,6 +6768,380 @@ function docsCommand(program2) {
6601
6768
  }
6602
6769
  });
6603
6770
  }
6771
+
6772
+ // src/utils/task-lines.ts
6773
+ function parseTaskLine(line, index = -1) {
6774
+ const match = line.match(
6775
+ /^\s*-\s*\[(TODO|DOING|DONE|REVIEW)\]((?:\[[^\]]+\])*)\s+(T-[A-Za-z0-9-]+)\s+(.+?)\s*$/
6776
+ );
6777
+ if (!match) return null;
6778
+ const tags = [...(match[2] || "").matchAll(/\[([^\]]+)\]/g)].map((entry) => (entry[1] || "").trim()).filter(Boolean);
6779
+ return {
6780
+ index,
6781
+ raw: line,
6782
+ status: match[1],
6783
+ tags,
6784
+ taskId: match[3],
6785
+ title: match[4]
6786
+ };
6787
+ }
6788
+
6789
+ // src/utils/doc-mutation.ts
6790
+ function collectRepeatableOption(value, previous = []) {
6791
+ return [...previous, value];
6792
+ }
6793
+ function normalizeRequiredText(value, label) {
6794
+ const normalized = (value || "").trim();
6795
+ if (!normalized || normalized === "-" || /^todo$/i.test(normalized)) {
6796
+ throw createCliError(
6797
+ "INVALID_ARGUMENT",
6798
+ `${label} must contain concrete text.`
6799
+ );
6800
+ }
6801
+ return normalized;
6802
+ }
6803
+ function normalizeRequiredItems(values, label) {
6804
+ const normalized = (values || []).map((value) => value.trim()).filter(Boolean);
6805
+ if (normalized.length === 0) {
6806
+ throw createCliError(
6807
+ "INVALID_ARGUMENT",
6808
+ `${label} must be provided at least once.`
6809
+ );
6810
+ }
6811
+ for (const value of normalized) {
6812
+ normalizeRequiredText(value, label);
6813
+ }
6814
+ return normalized;
6815
+ }
6816
+ async function resolveFeatureDocTarget(input) {
6817
+ const state = await resolveFeatureSelection(
6818
+ input.cwd,
6819
+ input.selector,
6820
+ input.component
6821
+ );
6822
+ if (state.status !== "selected" || !state.matchedFeature) {
6823
+ throw createCliError(
6824
+ "CONTEXT_SELECTION_REQUIRED",
6825
+ `A single feature is required. Pass <feature-name> explicitly.`
6826
+ );
6827
+ }
6828
+ const targetPath = path8.join(state.matchedFeature.path, input.fileName);
6829
+ if (!await fs.pathExists(targetPath)) {
6830
+ throw createCliError(
6831
+ "PRECONDITION_FAILED",
6832
+ `${input.fileName} not found for feature: ${state.matchedFeature.folderName}`
6833
+ );
6834
+ }
6835
+ return {
6836
+ feature: state.matchedFeature,
6837
+ path: targetPath
6838
+ };
6839
+ }
6840
+ function findSecondLevelHeadingIndex(lines, names) {
6841
+ const alternatives = names.map((name) => name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
6842
+ const pattern = new RegExp(`^\\s*##\\s+(${alternatives.join("|")})\\s*$`);
6843
+ return lines.findIndex((line) => pattern.test(line));
6844
+ }
6845
+ function findNextSecondLevelHeadingIndex(lines, start) {
6846
+ for (let index = start + 1; index < lines.length; index += 1) {
6847
+ if (/^\s*##\s+/.test(lines[index] || "")) return index;
6848
+ }
6849
+ return lines.length;
6850
+ }
6851
+ function normalizeMarkdownEnd(content) {
6852
+ return content.replace(/\s+$/g, "") + "\n";
6853
+ }
6854
+ function localDate() {
6855
+ return getLocalDateString();
6856
+ }
6857
+ function nextTaskSequence(content, featureFolderName) {
6858
+ const escaped = featureFolderName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
6859
+ const taskIdPattern = new RegExp(`\\bT-${escaped}-(\\d+)\\b`, "g");
6860
+ let max = 0;
6861
+ for (const match of content.matchAll(taskIdPattern)) {
6862
+ const parsed = Number(match[1] || "0");
6863
+ if (Number.isFinite(parsed) && parsed > max) max = parsed;
6864
+ }
6865
+ return max + 1;
6866
+ }
6867
+ function findTaskInsertIndex(lines, sectionStart, sectionEnd) {
6868
+ let lastTaskIndex = -1;
6869
+ for (let index = sectionStart; index < sectionEnd; index += 1) {
6870
+ if (parseTaskLine(lines[index] || "", index)) lastTaskIndex = index;
6871
+ }
6872
+ if (lastTaskIndex < 0) return sectionEnd;
6873
+ let insertIndex = lastTaskIndex + 1;
6874
+ while (insertIndex < sectionEnd) {
6875
+ const line = lines[insertIndex] || "";
6876
+ if (parseTaskLine(line, insertIndex)) break;
6877
+ if (/^\s{2,}\S/.test(line) || /^\s*$/.test(line)) {
6878
+ insertIndex += 1;
6879
+ continue;
6880
+ }
6881
+ break;
6882
+ }
6883
+ return insertIndex;
6884
+ }
6885
+
6886
+ // src/commands/task.ts
6887
+ function normalizeTaskRef(value) {
6888
+ const ref = normalizeRequiredText(value, "--ref").toUpperCase();
6889
+ if (ref === "NON-PRD") return ref;
6890
+ if (!/^PRD-[A-Z0-9][A-Z0-9-]*$/.test(ref)) {
6891
+ throw createCliError(
6892
+ "INVALID_ARGUMENT",
6893
+ "`--ref` must be `NON-PRD` or a `PRD-*` requirement key."
6894
+ );
6895
+ }
6896
+ return ref;
6897
+ }
6898
+ function formatTaskBlock(input) {
6899
+ return [
6900
+ `- [TODO][${input.ref}] ${input.taskId} ${input.title}`,
6901
+ ` - Date: ${input.date}`,
6902
+ " - Acceptance:",
6903
+ ...input.acceptanceItems.map((item) => ` - ${item}`),
6904
+ " - Checklist:",
6905
+ ...input.checklistItems.map((item) => ` - [ ] ${item}`)
6906
+ ];
6907
+ }
6908
+ async function runTaskAdd(featureName, options) {
6909
+ const target = await resolveFeatureDocTarget({
6910
+ cwd: process.cwd(),
6911
+ selector: featureName,
6912
+ component: options.component,
6913
+ fileName: "tasks.md"
6914
+ });
6915
+ const title = normalizeRequiredText(options.title, "--title");
6916
+ const ref = normalizeTaskRef(options.ref);
6917
+ const acceptanceItems = normalizeRequiredItems(options.acceptance, "--acceptance");
6918
+ const checklistItems = normalizeRequiredItems(options.check, "--check");
6919
+ const content = await fs.readFile(target.path, "utf-8");
6920
+ const lines = content.split("\n");
6921
+ const taskListIndex = findSecondLevelHeadingIndex(lines, ["Task List", "\uD0DC\uC2A4\uD06C \uBAA9\uB85D"]);
6922
+ if (taskListIndex < 0) {
6923
+ throw createCliError(
6924
+ "PRECONDITION_FAILED",
6925
+ "tasks.md is missing a `Task List` section."
6926
+ );
6927
+ }
6928
+ const sectionEnd = findNextSecondLevelHeadingIndex(lines, taskListIndex);
6929
+ const insertIndex = findTaskInsertIndex(lines, taskListIndex + 1, sectionEnd);
6930
+ const taskId = `T-${target.feature.folderName}-${String(
6931
+ nextTaskSequence(content, target.feature.folderName)
6932
+ ).padStart(2, "0")}`;
6933
+ const recordedAt = localDate();
6934
+ const block = formatTaskBlock({
6935
+ ref,
6936
+ taskId,
6937
+ title,
6938
+ date: recordedAt,
6939
+ acceptanceItems,
6940
+ checklistItems
6941
+ });
6942
+ const shouldPrefixBlank = insertIndex > taskListIndex + 1 && (lines[insertIndex - 1] || "").trim() !== "";
6943
+ const shouldSuffixBlank = insertIndex < lines.length && (lines[insertIndex] || "").trim() !== "";
6944
+ lines.splice(
6945
+ insertIndex,
6946
+ 0,
6947
+ ...shouldPrefixBlank ? [""] : [],
6948
+ ...block,
6949
+ ...shouldSuffixBlank ? [""] : []
6950
+ );
6951
+ await fs.writeFile(target.path, normalizeMarkdownEnd(lines.join("\n")), "utf-8");
6952
+ const payload = {
6953
+ status: "ok",
6954
+ reasonCode: "TASK_ADDED",
6955
+ feature: target.feature.folderName,
6956
+ taskId,
6957
+ title,
6958
+ ref,
6959
+ tasksUpdated: true,
6960
+ tasksPath: target.path,
6961
+ recordedAt
6962
+ };
6963
+ if (options.json) {
6964
+ console.log(JSON.stringify(payload, null, 2));
6965
+ return;
6966
+ }
6967
+ console.log(chalk.green(`Added task ${taskId} to ${target.feature.folderName}.`));
6968
+ console.log(chalk.gray(`- tasks.md updated: ${target.path}`));
6969
+ }
6970
+ function taskCommand(program2) {
6971
+ const task = program2.command("task").description("Patch feature task docs");
6972
+ task.command("add [feature-name]").description("Append a docs-only task block to tasks.md").requiredOption("--title <title>", "Task title").requiredOption("--ref <ref>", "Requirement ref: NON-PRD or PRD-* key").option(
6973
+ "--acceptance <text>",
6974
+ "Concrete acceptance item. Repeat to add more than one.",
6975
+ collectRepeatableOption,
6976
+ []
6977
+ ).option(
6978
+ "--check <text>",
6979
+ "Concrete checklist item. Repeat to add more than one.",
6980
+ collectRepeatableOption,
6981
+ []
6982
+ ).option("--component <component>", "Component name for multi projects").option("--json", "Output JSON").action(async (featureName, options) => {
6983
+ try {
6984
+ await runTaskAdd(featureName, options);
6985
+ } catch (error) {
6986
+ const cliError = toCliError(error);
6987
+ const suggestions = getCliErrorSuggestions(cliError.code, DEFAULT_LANG);
6988
+ if (options.json) {
6989
+ console.log(
6990
+ JSON.stringify({
6991
+ status: "error",
6992
+ reasonCode: cliError.code,
6993
+ error: cliError.message,
6994
+ suggestions
6995
+ })
6996
+ );
6997
+ process.exitCode = 1;
6998
+ return;
6999
+ }
7000
+ console.error(chalk.red(`[${cliError.code}] ${cliError.message}`));
7001
+ printCliErrorSuggestions(suggestions, DEFAULT_LANG);
7002
+ process.exitCode = 1;
7003
+ }
7004
+ });
7005
+ }
7006
+ function getNextDecisionSequence(content) {
7007
+ let max = 0;
7008
+ for (const match of content.matchAll(/^##\s+D(\d+):\s+/gm)) {
7009
+ if (/\{(?:Decision Title|결정 제목)\}/.test(match[0])) continue;
7010
+ const parsed = Number(match[1] || "0");
7011
+ if (Number.isFinite(parsed) && parsed > max) max = parsed;
7012
+ }
7013
+ return max + 1;
7014
+ }
7015
+ function findPlaceholderDecisionRange(content) {
7016
+ const match = /^##\s+D(\d+):\s+.*$/m.exec(content);
7017
+ if (!match || match.index === void 0) return null;
7018
+ const afterHeadingIndex = match.index + match[0].length;
7019
+ const nextHeadingMatch = /^##\s+D\d+:\s+/m.exec(content.slice(afterHeadingIndex));
7020
+ const end = nextHeadingMatch ? afterHeadingIndex + nextHeadingMatch.index : content.length;
7021
+ const block = content.slice(match.index, end);
7022
+ const isPlaceholder = /\{(?:Decision Title|결정 제목)\}/.test(match[0]) || /-\s+\*\*Decision\*\*:\s*(Final choice|최종 선택)/.test(block);
7023
+ if (!isPlaceholder) return null;
7024
+ const sequence = Number(match[1] || "1");
7025
+ return {
7026
+ start: match.index,
7027
+ end,
7028
+ sequence: Number.isFinite(sequence) ? sequence : 1
7029
+ };
7030
+ }
7031
+ function formatOptions(options) {
7032
+ return options.length > 0 ? options.join("; ") : "-";
7033
+ }
7034
+ function formatDecisionBlock(input) {
7035
+ return [
7036
+ `## ${input.decisionId}: ${input.title} (${input.date})`,
7037
+ "",
7038
+ `- **Context**: ${input.context}`,
7039
+ `- **Constraints**: ${input.constraints}`,
7040
+ `- **Options**: ${formatOptions(input.options)}`,
7041
+ `- **Decision**: ${input.decision}`,
7042
+ `- **Rationale**: ${input.rationale}`,
7043
+ "- **Trace**:",
7044
+ " - **At DOING start**: Recorded by `decision add` when the decision was created.",
7045
+ " - **Before DONE**: Update this line when the related task is completed.",
7046
+ " - **Post-merge check**: Update this line after merge when applicable.",
7047
+ "- **Evidence**:",
7048
+ ...input.evidence.map((item) => ` - **Test/Log**: ${item}`),
7049
+ `- **Consequences**: ${input.consequence}`
7050
+ ].join("\n");
7051
+ }
7052
+ async function runDecisionAdd(featureName, options) {
7053
+ const target = await resolveFeatureDocTarget({
7054
+ cwd: process.cwd(),
7055
+ selector: featureName,
7056
+ component: options.component,
7057
+ fileName: "decisions.md"
7058
+ });
7059
+ const title = normalizeRequiredText(options.title, "--title");
7060
+ const context = normalizeRequiredText(options.context, "--context");
7061
+ const decision = normalizeRequiredText(options.decision, "--decision");
7062
+ const rationale = normalizeRequiredText(options.rationale, "--rationale");
7063
+ const evidence = normalizeRequiredItems(options.evidence, "--evidence");
7064
+ const constraints = (options.constraints || "").trim() || "-";
7065
+ const consequence = (options.consequence || "").trim() || "-";
7066
+ const optionItems = (options.option || []).map((value) => value.trim()).filter(Boolean);
7067
+ const content = await fs.readFile(target.path, "utf-8");
7068
+ const placeholderRange = findPlaceholderDecisionRange(content);
7069
+ const decisionSequence = placeholderRange?.sequence ?? getNextDecisionSequence(content);
7070
+ const decisionId = `D${String(decisionSequence).padStart(3, "0")}`;
7071
+ const recordedAt = localDate();
7072
+ const block = formatDecisionBlock({
7073
+ decisionId,
7074
+ title,
7075
+ date: recordedAt,
7076
+ context,
7077
+ constraints,
7078
+ options: optionItems,
7079
+ decision,
7080
+ rationale,
7081
+ evidence,
7082
+ consequence
7083
+ });
7084
+ const nextContent = placeholderRange ? normalizeMarkdownEnd(
7085
+ `${content.slice(0, placeholderRange.start)}${block}${content.slice(
7086
+ placeholderRange.end
7087
+ )}`
7088
+ ) : `${normalizeMarkdownEnd(content)}
7089
+ ${block}
7090
+ `;
7091
+ await fs.writeFile(target.path, nextContent, "utf-8");
7092
+ const payload = {
7093
+ status: "ok",
7094
+ reasonCode: "DECISION_ADDED",
7095
+ feature: target.feature.folderName,
7096
+ decisionId,
7097
+ title,
7098
+ decisionsUpdated: true,
7099
+ decisionsPath: target.path,
7100
+ recordedAt
7101
+ };
7102
+ if (options.json) {
7103
+ console.log(JSON.stringify(payload, null, 2));
7104
+ return;
7105
+ }
7106
+ console.log(chalk.green(`Added decision ${decisionId} to ${target.feature.folderName}.`));
7107
+ console.log(chalk.gray(`- decisions.md updated: ${target.path}`));
7108
+ }
7109
+ function decisionCommand(program2) {
7110
+ const decision = program2.command("decision").description("Patch feature decision docs");
7111
+ decision.command("add [feature-name]").description("Append a docs-only ADR block to decisions.md").requiredOption("--title <title>", "Decision title").requiredOption("--context <text>", "Decision context").option("--constraints <text>", "Decision constraints").option(
7112
+ "--option <text>",
7113
+ "Alternative considered. Repeat to add more than one.",
7114
+ collectRepeatableOption,
7115
+ []
7116
+ ).requiredOption("--decision <text>", "Final decision").requiredOption("--rationale <text>", "Decision rationale").option(
7117
+ "--evidence <text>",
7118
+ "Evidence link or test/log note. Repeat to add more than one.",
7119
+ collectRepeatableOption,
7120
+ []
7121
+ ).option("--consequence <text>", "Decision consequence").option("--component <component>", "Component name for multi projects").option("--json", "Output JSON").action(async (featureName, options) => {
7122
+ try {
7123
+ await runDecisionAdd(featureName, options);
7124
+ } catch (error) {
7125
+ const cliError = toCliError(error);
7126
+ const suggestions = getCliErrorSuggestions(cliError.code, DEFAULT_LANG);
7127
+ if (options.json) {
7128
+ console.log(
7129
+ JSON.stringify({
7130
+ status: "error",
7131
+ reasonCode: cliError.code,
7132
+ error: cliError.message,
7133
+ suggestions
7134
+ })
7135
+ );
7136
+ process.exitCode = 1;
7137
+ return;
7138
+ }
7139
+ console.error(chalk.red(`[${cliError.code}] ${cliError.message}`));
7140
+ printCliErrorSuggestions(suggestions, DEFAULT_LANG);
7141
+ process.exitCode = 1;
7142
+ }
7143
+ });
7144
+ }
6604
7145
  function detectCommand(program2) {
6605
7146
  program2.command("detect").description(tr(DEFAULT_LANG, "cli", "detect.cmdDescription")).option("--dir <dir>", tr(DEFAULT_LANG, "cli", "detect.optDir")).option("--json", tr(DEFAULT_LANG, "cli", "detect.optJson")).action(async (options) => {
6606
7147
  try {
@@ -6781,7 +7322,7 @@ function registerCodexHooksIntegration(parent) {
6781
7322
  removeLeeSpecKitCodexHooks,
6782
7323
  resolveCodexHooksRepoRoot,
6783
7324
  upsertLeeSpecKitCodexHooks
6784
- } = await import('./hooks-4S33YUIB.js');
7325
+ } = await import('./hooks-43P4YKHY.js');
6785
7326
  const repoRoot = config.docsRepo === "standalone" ? resolveConfiguredStandaloneWorkspaceRoot(config) : resolveCodexHooksRepoRoot(process.cwd());
6786
7327
  if (!repoRoot) {
6787
7328
  throw createCliError(
@@ -6816,9 +7357,27 @@ function integrationsCommand(program2) {
6816
7357
  registerCodexIntegration(integrations);
6817
7358
  registerCodexHooksIntegration(integrations);
6818
7359
  }
7360
+ var LEGACY_STEP_BY_ACTION = {
7361
+ spec_write: 2,
7362
+ spec_approve: 3,
7363
+ plan_write: 4,
7364
+ plan_approve: 5,
7365
+ tasks_write: 6,
7366
+ tasks_approve: 6,
7367
+ issue_prepare: 8,
7368
+ issue_create: 8,
7369
+ branch_create: 9,
7370
+ task_execute: 10,
7371
+ implementation_approve: 10,
7372
+ pre_pr_review: 12,
7373
+ pr_prepare: 13,
7374
+ pr_create: 13,
7375
+ code_review: 14,
7376
+ pr_merge: 14
7377
+ };
6819
7378
  var DOC_STATUS_LABELS = ["Doc Status", "\uBB38\uC11C \uC0C1\uD0DC"];
6820
7379
  var ISSUE_LABELS = ["Issue", "Issue Number", "\uC774\uC288", "\uC774\uC288 \uBC88\uD638"];
6821
- var BRANCH_LABELS = ["Branch", "\uBE0C\uB79C\uCE58"];
7380
+ var BRANCH_LABELS2 = ["Branch", "\uBE0C\uB79C\uCE58"];
6822
7381
  var PR_LABELS = ["PR", "Pull Request"];
6823
7382
  var PR_STATUS_LABELS = ["PR Status", "PR \uC0C1\uD0DC"];
6824
7383
  var PRE_PR_REVIEW_LABELS = ["Pre-PR Review", "PR \uC804 \uB9AC\uBDF0"];
@@ -6831,6 +7390,7 @@ function resolveWorkflowRequirements(config) {
6831
7390
  return {
6832
7391
  requireIssue: workflow.requireIssue ?? !isLocalWorkflow,
6833
7392
  requireBranch: workflow.requireBranch ?? true,
7393
+ requireWorktree: config.docsRepo === "standalone" ? true : workflow.requireWorktree ?? false,
6834
7394
  requirePr: workflow.requirePr ?? !isLocalWorkflow,
6835
7395
  requireReview: workflow.requireReview ?? !isLocalWorkflow,
6836
7396
  requireMerge: workflow.requireMerge ?? !isLocalWorkflow,
@@ -6844,7 +7404,7 @@ function parseApprovalStatus(raw) {
6844
7404
  if (value === "approved") return "approved";
6845
7405
  return null;
6846
7406
  }
6847
- function extractFieldValue(content, labels) {
7407
+ function extractFieldValue2(content, labels) {
6848
7408
  for (const label of Array.isArray(labels) ? labels : [labels]) {
6849
7409
  const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
6850
7410
  const match = content.match(
@@ -6876,12 +7436,12 @@ function withoutFencedCodeBlocks(content) {
6876
7436
  return lines;
6877
7437
  }
6878
7438
  function parseTasksDoc(content) {
6879
- const issueRaw = extractFieldValue(content, ISSUE_LABELS);
7439
+ const issueRaw = extractFieldValue2(content, ISSUE_LABELS);
6880
7440
  const issueNumberMatch = issueRaw?.match(/^#(\d+)$/);
6881
7441
  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);
7442
+ const branchRaw = extractFieldValue2(content, BRANCH_LABELS2);
7443
+ const prRaw = extractFieldValue2(content, PR_LABELS);
7444
+ const prePrDecision = extractFieldValue2(content, PRE_PR_DECISION_LABELS);
6885
7445
  const tasks = [];
6886
7446
  const nonCodeLines = withoutFencedCodeBlocks(content);
6887
7447
  for (const line of nonCodeLines) {
@@ -6907,13 +7467,13 @@ function parseTasksDoc(content) {
6907
7467
  ) && parseMarkdownCheckbox(line) === true
6908
7468
  );
6909
7469
  const prStatus = (() => {
6910
- const value = (extractFieldValue(content, PR_STATUS_LABELS) || "").trim().toLowerCase();
7470
+ const value = (extractFieldValue2(content, PR_STATUS_LABELS) || "").trim().toLowerCase();
6911
7471
  if (value === "review") return "review";
6912
7472
  if (value === "approved") return "approved";
6913
7473
  return null;
6914
7474
  })();
6915
7475
  const prePrReviewStatus = (() => {
6916
- const value = (extractFieldValue(content, PRE_PR_REVIEW_LABELS) || "").trim().toLowerCase();
7476
+ const value = (extractFieldValue2(content, PRE_PR_REVIEW_LABELS) || "").trim().toLowerCase();
6917
7477
  if (value === "pending") return "pending";
6918
7478
  if (value === "running") return "running";
6919
7479
  if (value === "done") return "done";
@@ -6926,17 +7486,17 @@ function parseTasksDoc(content) {
6926
7486
  })();
6927
7487
  return {
6928
7488
  docStatus: parseApprovalStatus(
6929
- extractFieldValue(content, DOC_STATUS_LABELS) || void 0
7489
+ extractFieldValue2(content, DOC_STATUS_LABELS) || void 0
6930
7490
  ),
6931
7491
  issueNumber,
6932
- branch: sanitizeMetadataValue(branchRaw),
6933
- prLink: sanitizeMetadataValue(prRaw),
7492
+ branch: sanitizeMetadataValue2(branchRaw),
7493
+ prLink: sanitizeMetadataValue2(prRaw),
6934
7494
  prStatus,
6935
7495
  prePrReviewStatus,
6936
- prePrEvidence: sanitizeMetadataValue(
6937
- extractFieldValue(content, PRE_PR_EVIDENCE_LABELS)
7496
+ prePrEvidence: sanitizeMetadataValue2(
7497
+ extractFieldValue2(content, PRE_PR_EVIDENCE_LABELS)
6938
7498
  ),
6939
- prePrDecision: sanitizeMetadataValue(prePrDecision),
7499
+ prePrDecision: sanitizeMetadataValue2(prePrDecision),
6940
7500
  prePrDecisionOutcome,
6941
7501
  tasks,
6942
7502
  completion: {
@@ -6946,20 +7506,245 @@ function parseTasksDoc(content) {
6946
7506
  }
6947
7507
  };
6948
7508
  }
6949
- function sanitizeMetadataValue(value) {
7509
+ function sanitizeMetadataValue2(value) {
6950
7510
  if (!value) return null;
6951
7511
  const trimmed = value.trim().replace(/^`(.+)`$/, "$1");
6952
7512
  if (!trimmed || trimmed === "-") return null;
6953
7513
  return trimmed;
6954
7514
  }
7515
+ function normalizeCommitTopicText(value) {
7516
+ return value.replace(/\s+/g, " ").trim();
7517
+ }
7518
+ function normalizeTaskTopic(value) {
7519
+ return normalizeCommitTopicText(value).replace(/^T-[A-Za-z0-9-]+\s+/, "");
7520
+ }
7521
+ function normalizeCommitSubjectForGate(value) {
7522
+ return normalizeCommitTopicText(value).replace(/^[a-z]+(?:\([^)]*\))?!?:\s*/i, "").toLowerCase();
7523
+ }
7524
+ function toTaskKey(rawTitle) {
7525
+ const trimmed = normalizeCommitTopicText(rawTitle);
7526
+ if (!trimmed) return "";
7527
+ const idMatch = trimmed.match(/^(T-[A-Za-z0-9-]+)/i);
7528
+ if (idMatch) return idMatch[1].toUpperCase();
7529
+ return normalizeTaskTopic(trimmed).toLowerCase();
7530
+ }
7531
+ function normalizeGitRelativePath(value) {
7532
+ return value.replace(/\\/g, "/").replace(/^\.\/+/, "").replace(/\/+$/, "");
7533
+ }
7534
+ function toRepoRelativePath(cwd, relativePathFromCwd) {
7535
+ const prefix = (runGitCapture(["rev-parse", "--show-prefix"], cwd) || "").trim().replace(/\/+$/, "");
7536
+ if (!prefix) return normalizeGitRelativePath(relativePathFromCwd);
7537
+ return normalizeGitRelativePath(`${prefix}/${relativePathFromCwd}`);
7538
+ }
7539
+ function parseDoneTransitionsFromDiff(diff) {
7540
+ const removedByTask = /* @__PURE__ */ new Map();
7541
+ const addedByTask = /* @__PURE__ */ new Map();
7542
+ const parseTaskDiffLine = (line) => {
7543
+ const match = line.match(
7544
+ /^\s*-\s*\[(TODO|DOING|DONE|REVIEW)\](?:\[[^\]]+\])*\s+(.+?)\s*$/i
7545
+ );
7546
+ if (!match) return null;
7547
+ const key = toTaskKey(match[2]);
7548
+ if (!key) return null;
7549
+ return {
7550
+ key,
7551
+ status: match[1].toUpperCase()
7552
+ };
7553
+ };
7554
+ for (const line of diff.split("\n")) {
7555
+ if (line.startsWith("---") || line.startsWith("+++")) continue;
7556
+ if (line.startsWith("-")) {
7557
+ const parsed = parseTaskDiffLine(line.slice(1));
7558
+ if (!parsed) continue;
7559
+ const existing = removedByTask.get(parsed.key) || /* @__PURE__ */ new Set();
7560
+ existing.add(parsed.status);
7561
+ removedByTask.set(parsed.key, existing);
7562
+ continue;
7563
+ }
7564
+ if (line.startsWith("+")) {
7565
+ const parsed = parseTaskDiffLine(line.slice(1));
7566
+ if (!parsed) continue;
7567
+ const existing = addedByTask.get(parsed.key) || /* @__PURE__ */ new Set();
7568
+ existing.add(parsed.status);
7569
+ addedByTask.set(parsed.key, existing);
7570
+ }
7571
+ }
7572
+ let doneTransitions = 0;
7573
+ for (const [taskKey, addedStatuses] of addedByTask.entries()) {
7574
+ if (!addedStatuses.has("DONE")) continue;
7575
+ const removedStatuses = removedByTask.get(taskKey);
7576
+ if (!removedStatuses) continue;
7577
+ if (removedStatuses.has("TODO") || removedStatuses.has("DOING") || removedStatuses.has("REVIEW")) {
7578
+ doneTransitions += 1;
7579
+ }
7580
+ }
7581
+ return doneTransitions;
7582
+ }
7583
+ function parseDoneTaskTopicCounts(content) {
7584
+ const counts = /* @__PURE__ */ new Map();
7585
+ for (const line of withoutFencedCodeBlocks(content)) {
7586
+ const match = line.match(
7587
+ /^\s*-\s*\[(DONE)\](?:\[[^\]]+\])*\s+(.+?)\s*$/i
7588
+ );
7589
+ if (!match) continue;
7590
+ const topic = normalizeTaskTopic(match[2] || "");
7591
+ if (!topic) continue;
7592
+ counts.set(topic, (counts.get(topic) || 0) + 1);
7593
+ }
7594
+ return counts;
7595
+ }
7596
+ function countDoneTransitionsInLatestTasksCommit(feature) {
7597
+ const docsGitCwd = feature.git.docsGitCwd;
7598
+ const tasksRelativePathFromDocs = normalizeGitRelativePath(
7599
+ path8.join(feature.docs.featurePathFromDocs, "tasks.md")
7600
+ );
7601
+ const latestTasksCommit = (runGitCapture(
7602
+ ["rev-list", "-n", "1", "HEAD", "--", tasksRelativePathFromDocs],
7603
+ docsGitCwd
7604
+ ) || "").trim();
7605
+ if (!latestTasksCommit) return void 0;
7606
+ const repoTasksPath = toRepoRelativePath(docsGitCwd, tasksRelativePathFromDocs);
7607
+ const currentContent = runGitCapture(
7608
+ ["show", `${latestTasksCommit}:${repoTasksPath}`],
7609
+ docsGitCwd
7610
+ );
7611
+ if (currentContent === void 0) return void 0;
7612
+ const previousContent = runGitCapture(["show", `${latestTasksCommit}^:${repoTasksPath}`], docsGitCwd) || "";
7613
+ const currentDone = parseDoneTaskTopicCounts(currentContent);
7614
+ const previousDone = parseDoneTaskTopicCounts(previousContent);
7615
+ let doneTransitions = 0;
7616
+ for (const [topic, currentCount] of currentDone.entries()) {
7617
+ const previousCount = previousDone.get(topic) || 0;
7618
+ if (currentCount > previousCount) {
7619
+ doneTransitions += currentCount - previousCount;
7620
+ }
7621
+ }
7622
+ return doneTransitions;
7623
+ }
7624
+ function countPendingDoneTransitions(feature) {
7625
+ const docsGitCwd = feature.git.docsGitCwd;
7626
+ const tasksRelativePath = normalizeGitRelativePath(
7627
+ path8.join(feature.docs.featurePathFromDocs, "tasks.md")
7628
+ );
7629
+ const diff = runGitCapture(
7630
+ ["diff", "--unified=0", "--no-color", "HEAD", "--", tasksRelativePath],
7631
+ docsGitCwd
7632
+ ) || "";
7633
+ if (!diff.trim()) return 0;
7634
+ return parseDoneTransitionsFromDiff(diff);
7635
+ }
7636
+ function getLastDoneTask(tasks) {
7637
+ for (let index = tasks.tasks.length - 1; index >= 0; index -= 1) {
7638
+ if (tasks.tasks[index].status === "DONE") return tasks.tasks[index];
7639
+ }
7640
+ return null;
7641
+ }
7642
+ function hasOpenTask(tasks) {
7643
+ return tasks.tasks.some(
7644
+ (task) => task.status === "DOING" || task.status === "REVIEW"
7645
+ );
7646
+ }
7647
+ function hasUncommittedChanges(gitCwd) {
7648
+ if (!gitCwd) return false;
7649
+ const status = runGitCapture(
7650
+ ["status", "--porcelain", "--untracked-files=no"],
7651
+ gitCwd
7652
+ ) || "";
7653
+ return status.trim().length > 0;
7654
+ }
7655
+ function resolveTaskCommitGatePolicy(config) {
7656
+ const raw = config.workflow?.taskCommitGate;
7657
+ return raw === "off" || raw === "strict" ? raw : "warn";
7658
+ }
7659
+ function checkTaskCommitGate(feature, effectiveProjectGitCwd, lastDoneTask) {
7660
+ const doneTransitions = countDoneTransitionsInLatestTasksCommit(feature);
7661
+ if (doneTransitions === 0) {
7662
+ return { pass: true, doneTransitions };
7663
+ }
7664
+ if (typeof doneTransitions === "number" && doneTransitions > 1) {
7665
+ return {
7666
+ pass: false,
7667
+ reason: "DONE_TRANSITIONS_COUNT",
7668
+ doneTransitions
7669
+ };
7670
+ }
7671
+ const lastDoneTopic = normalizeTaskTopic(lastDoneTask?.title || "");
7672
+ if (!effectiveProjectGitCwd || !lastDoneTopic) {
7673
+ return { pass: true };
7674
+ }
7675
+ const args = ["log", "-n", "1", "--pretty=%s", "--", "."];
7676
+ const relativeDocsDir = path8.relative(
7677
+ effectiveProjectGitCwd,
7678
+ feature.git.docsGitCwd
7679
+ );
7680
+ const normalizedDocsDir = normalizeGitRelativePath(relativeDocsDir);
7681
+ if (normalizedDocsDir && normalizedDocsDir !== "." && normalizedDocsDir !== ".." && !normalizedDocsDir.startsWith("../")) {
7682
+ args.push(`:(exclude)${normalizedDocsDir}/**`);
7683
+ }
7684
+ const latestProjectSubject = runGitCapture(args, effectiveProjectGitCwd);
7685
+ if (latestProjectSubject === void 0) {
7686
+ return { pass: false, reason: "PROJECT_LOG_UNAVAILABLE" };
7687
+ }
7688
+ const normalizedSubject = normalizeCommitSubjectForGate(latestProjectSubject);
7689
+ if (!normalizedSubject) {
7690
+ return { pass: false, reason: "NO_PROJECT_COMMIT" };
7691
+ }
7692
+ if (!normalizedSubject.includes(normalizeTaskTopic(lastDoneTopic).toLowerCase())) {
7693
+ return { pass: false, reason: "MISMATCH_LAST_DONE" };
7694
+ }
7695
+ return { pass: true };
7696
+ }
7697
+ function describeTaskCommitGateFailure(check) {
7698
+ switch (check.reason) {
7699
+ case "DONE_TRANSITIONS_COUNT":
7700
+ return `latest tasks.md commit includes ${check.doneTransitions || 0} DONE transitions`;
7701
+ case "NO_PROJECT_COMMIT":
7702
+ return "no recent project code commit was found for the just-finished task";
7703
+ case "PROJECT_LOG_UNAVAILABLE":
7704
+ return "the latest project commit subject could not be inspected";
7705
+ case "MISMATCH_LAST_DONE":
7706
+ default:
7707
+ return "the latest project commit subject does not match the just-finished task";
7708
+ }
7709
+ }
7710
+ function resolveProjectCommitTopic(feature, tasks) {
7711
+ const activeTask = tasks.tasks.find(
7712
+ (task) => task.status === "DOING" || task.status === "REVIEW"
7713
+ );
7714
+ const raw = activeTask?.title || getLastDoneTask(tasks)?.title || nextTodoTask(tasks)?.title || feature.folderName;
7715
+ const withoutTaskId = normalizeCommitTopicText(raw || "").replace(
7716
+ /^T-[A-Za-z0-9-]+\s+/,
7717
+ ""
7718
+ );
7719
+ return withoutTaskId || feature.folderName;
7720
+ }
7721
+ function buildTaskCommitSummary(input) {
7722
+ const { feature, tasks, effectiveProjectGitCwd, docsDirty, projectDirty, gateFailureReason } = input;
7723
+ 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"`;
7724
+ 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)}"`;
7725
+ const lines = ["Finish the task-level commit checkpoint before continuing."];
7726
+ if (gateFailureReason) {
7727
+ lines.push(`Current gate failure: ${gateFailureReason}`);
7728
+ }
7729
+ if (docsDirty) {
7730
+ lines.push(`Docs commit: ${docsMessage}`);
7731
+ }
7732
+ if (projectDirty) {
7733
+ lines.push(`Project commit: ${projectMessage}`);
7734
+ }
7735
+ if (!docsDirty && !projectDirty) {
7736
+ 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 || "")}".`);
7737
+ }
7738
+ return lines.join("\n");
7739
+ }
6955
7740
  function parseWorkflowDraftMetadataExtended(content) {
6956
7741
  const metadata = parseWorkflowDraftMetadata(content);
6957
- const prStatusRaw = extractFieldValue(content, PR_STATUS_LABELS);
7742
+ const prStatusRaw = extractFieldValue2(content, PR_STATUS_LABELS);
6958
7743
  const normalizedPrStatus = (prStatusRaw || "").trim().toLowerCase();
6959
7744
  return {
6960
7745
  ...metadata,
6961
- issueRef: sanitizeMetadataValue(extractFieldValue(content, ISSUE_LABELS)),
6962
- prRef: sanitizeMetadataValue(extractFieldValue(content, PR_LABELS)),
7746
+ issueRef: sanitizeMetadataValue2(extractFieldValue2(content, ISSUE_LABELS)),
7747
+ prRef: sanitizeMetadataValue2(extractFieldValue2(content, PR_LABELS)),
6963
7748
  prStatus: normalizedPrStatus === "review" ? "review" : normalizedPrStatus === "approved" ? "approved" : null
6964
7749
  };
6965
7750
  }
@@ -6978,17 +7763,178 @@ function resolveExpectedBranch(feature, tasks) {
6978
7763
  if (!tasks.issueNumber) return null;
6979
7764
  return `feat/${tasks.issueNumber}-${feature.slug}`;
6980
7765
  }
6981
- function getCurrentProjectBranch(feature) {
6982
- return runGitCapture(["branch", "--show-current"], feature.git.projectGitCwd) || runGitCapture(["rev-parse", "--abbrev-ref", "HEAD"], feature.git.projectGitCwd) || null;
7766
+ function resolveProjectRootFromGitCwd2(projectGitCwd) {
7767
+ return runGitCapture(["rev-parse", "--show-toplevel"], projectGitCwd) || path8.resolve(projectGitCwd);
6983
7768
  }
6984
- function nextTodoTask(tasks) {
6985
- return tasks.tasks.find((task) => task.status === "DOING") || tasks.tasks.find((task) => task.status === "TODO") || null;
7769
+ function resolveProjectRootGitCwd(config, feature) {
7770
+ if (config.docsRepo === "standalone") {
7771
+ const roots = resolveStandaloneProjectRoots(
7772
+ config,
7773
+ feature.type === "single" ? void 0 : feature.type
7774
+ );
7775
+ if (roots.length > 0) {
7776
+ return roots[0];
7777
+ }
7778
+ }
7779
+ return resolveProjectRootFromGitCwd2(feature.git.projectGitCwd);
6986
7780
  }
6987
- function allTasksDone(tasks) {
6988
- return tasks.tasks.length > 0 && tasks.tasks.every((task) => task.status === "DONE");
7781
+ function getExpectedWorktreePath(config, projectGitCwd, branchName) {
7782
+ const projectRoot = resolveProjectRootFromGitCwd2(projectGitCwd);
7783
+ return resolveManagedWorktreePath(config, projectRoot, branchName);
6989
7784
  }
6990
- function prePrSatisfied(tasks) {
6991
- return tasks.prePrReviewStatus === "done" && !!tasks.prePrEvidence && !!tasks.prePrDecision && tasks.prePrDecisionOutcome === "approve";
7785
+ async function resolveExistingExpectedWorktreePath(config, projectGitCwd, branchName) {
7786
+ const projectRoot = resolveProjectRootFromGitCwd2(projectGitCwd);
7787
+ const candidate = getExpectedWorktreePath(config, projectGitCwd, branchName);
7788
+ return await fs.pathExists(candidate) && isRegisteredGitWorktree(projectRoot, candidate) ? candidate : null;
7789
+ }
7790
+ function buildManagedWorktreeCreateCommand(config, projectGitCwd, branchName) {
7791
+ const projectRoot = resolveProjectRootFromGitCwd2(projectGitCwd);
7792
+ const worktreePath = getExpectedWorktreePath(config, projectGitCwd, branchName);
7793
+ const worktreeParent = path8.dirname(worktreePath);
7794
+ const staleCleanupCommand = buildManagedWorktreeStaleCleanupCommand(
7795
+ projectRoot,
7796
+ worktreePath
7797
+ );
7798
+ const envCopyCommand = buildManagedWorktreeEnvCopyCommand(projectRoot, worktreePath);
7799
+ return `${staleCleanupCommand} && mkdir -p "${worktreeParent}" && (git -C "${projectRoot}" worktree add "${worktreePath}" "${branchName}" || git -C "${projectRoot}" worktree add -b "${branchName}" "${worktreePath}") && ${envCopyCommand}`;
7800
+ }
7801
+ function resolveRemotePrMergeMeta(prRef, projectGitCwd) {
7802
+ if (!prRef) return null;
7803
+ const result = runProcess(
7804
+ "gh",
7805
+ ["pr", "view", prRef, "--json", "headRefName,baseRefName"],
7806
+ projectGitCwd
7807
+ );
7808
+ if (result.code !== 0) {
7809
+ return null;
7810
+ }
7811
+ try {
7812
+ const parsed = JSON.parse(String(result.stdout || "{}"));
7813
+ return {
7814
+ headRefName: sanitizeMetadataValue2(String(parsed.headRefName || "")),
7815
+ baseRefName: sanitizeMetadataValue2(String(parsed.baseRefName || ""))
7816
+ };
7817
+ } catch {
7818
+ return null;
7819
+ }
7820
+ }
7821
+ function localBranchExists(cwd, branchName) {
7822
+ if (!branchName) return false;
7823
+ return runProcess(
7824
+ "git",
7825
+ ["show-ref", "--verify", "--quiet", `refs/heads/${branchName}`],
7826
+ cwd
7827
+ ).code === 0;
7828
+ }
7829
+ function remoteBranchExists(cwd, branchName) {
7830
+ if (!branchName) return false;
7831
+ return runProcess(
7832
+ "git",
7833
+ ["show-ref", "--verify", "--quiet", `refs/remotes/origin/${branchName}`],
7834
+ cwd
7835
+ ).code === 0;
7836
+ }
7837
+ function resolvePostMergeCleanupState(config, feature, tasks) {
7838
+ const projectRootGitCwd = resolveProjectRootGitCwd(config, feature);
7839
+ const prMeta = resolveRemotePrMergeMeta(tasks.prLink, projectRootGitCwd);
7840
+ const baseBranch = (prMeta?.baseRefName || "main").trim() || "main";
7841
+ const headBranch = (prMeta?.headRefName || resolveExpectedBranch(feature, tasks))?.trim() || null;
7842
+ const hasOriginRemote = runProcess(
7843
+ "git",
7844
+ ["remote", "get-url", "origin"],
7845
+ projectRootGitCwd
7846
+ ).code === 0;
7847
+ if (hasOriginRemote) {
7848
+ runProcess("git", ["fetch", "--prune", "origin"], projectRootGitCwd);
7849
+ }
7850
+ const currentBranch = runGitCapture(["branch", "--show-current"], projectRootGitCwd) || runGitCapture(["rev-parse", "--abbrev-ref", "HEAD"], projectRootGitCwd) || "";
7851
+ const localBaseSha = runGitCapture(["rev-parse", baseBranch], projectRootGitCwd) || "";
7852
+ const remoteBaseSha = hasOriginRemote ? runGitCapture(["rev-parse", `origin/${baseBranch}`], projectRootGitCwd) || "" : "";
7853
+ const worktreePath = config.docsRepo === "standalone" && headBranch ? resolveManagedWorktreePath(config, projectRootGitCwd, headBranch) : null;
7854
+ const managedWorktreeExists = !!worktreePath && fs.existsSync(worktreePath);
7855
+ const localFeatureBranchExists = localBranchExists(projectRootGitCwd, headBranch);
7856
+ const remoteFeatureBranchExists = hasOriginRemote && remoteBranchExists(projectRootGitCwd, headBranch);
7857
+ const localBaseCheckedOut = currentBranch === baseBranch;
7858
+ const baseSyncedWithOrigin = !hasOriginRemote || localBaseSha.length > 0 && remoteBaseSha.length > 0 && localBaseSha === remoteBaseSha;
7859
+ return {
7860
+ complete: localBaseCheckedOut && baseSyncedWithOrigin && !localFeatureBranchExists && !remoteFeatureBranchExists && !managedWorktreeExists,
7861
+ projectRootGitCwd,
7862
+ baseBranch,
7863
+ headBranch,
7864
+ worktreePath,
7865
+ hasOriginRemote,
7866
+ localBaseCheckedOut,
7867
+ baseSyncedWithOrigin,
7868
+ localFeatureBranchExists,
7869
+ remoteFeatureBranchExists,
7870
+ managedWorktreeExists
7871
+ };
7872
+ }
7873
+ function buildPostMergeCleanupCommand(state) {
7874
+ const commandParts = [];
7875
+ if (state.hasOriginRemote) {
7876
+ commandParts.push(
7877
+ `git -C "${state.projectRootGitCwd}" fetch --prune origin`
7878
+ );
7879
+ }
7880
+ commandParts.push(
7881
+ `git -C "${state.projectRootGitCwd}" checkout "${state.baseBranch}"`
7882
+ );
7883
+ if (state.hasOriginRemote) {
7884
+ commandParts.push(
7885
+ `git -C "${state.projectRootGitCwd}" pull --ff-only origin "${state.baseBranch}"`
7886
+ );
7887
+ }
7888
+ if (state.worktreePath) {
7889
+ commandParts.push(
7890
+ `if [ -d "${state.worktreePath}" ]; then git -C "${state.projectRootGitCwd}" worktree remove "${state.worktreePath}"; fi`
7891
+ );
7892
+ }
7893
+ if (state.headBranch) {
7894
+ commandParts.push(
7895
+ `if git -C "${state.projectRootGitCwd}" show-ref --verify --quiet "refs/heads/${state.headBranch}"; then git -C "${state.projectRootGitCwd}" branch -D "${state.headBranch}"; fi`
7896
+ );
7897
+ if (state.hasOriginRemote) {
7898
+ commandParts.push(
7899
+ `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`
7900
+ );
7901
+ commandParts.push(
7902
+ `git -C "${state.projectRootGitCwd}" fetch --prune origin`
7903
+ );
7904
+ }
7905
+ }
7906
+ return commandParts.join(" && ");
7907
+ }
7908
+ function buildPostMergeCleanupSummary(state) {
7909
+ const remaining = [];
7910
+ if (!state.localBaseCheckedOut) {
7911
+ remaining.push(`check out ${state.baseBranch}`);
7912
+ }
7913
+ if (!state.baseSyncedWithOrigin) {
7914
+ remaining.push(`sync ${state.baseBranch} with origin/${state.baseBranch}`);
7915
+ }
7916
+ if (state.managedWorktreeExists) {
7917
+ remaining.push("remove the managed feature worktree");
7918
+ }
7919
+ if (state.localFeatureBranchExists) {
7920
+ remaining.push("delete the local feature branch");
7921
+ }
7922
+ if (state.remoteFeatureBranchExists) {
7923
+ remaining.push("delete the remote feature branch");
7924
+ }
7925
+ if (remaining.length === 0) {
7926
+ return "Finish the post-merge cleanup before closing the feature.";
7927
+ }
7928
+ return `Finish the post-merge cleanup before closing the feature: ${remaining.join(", ")}.`;
7929
+ }
7930
+ function nextTodoTask(tasks) {
7931
+ return tasks.tasks.find((task) => task.status === "DOING") || tasks.tasks.find((task) => task.status === "TODO") || null;
7932
+ }
7933
+ function allTasksDone(tasks) {
7934
+ return tasks.tasks.length > 0 && tasks.tasks.every((task) => task.status === "DONE");
7935
+ }
7936
+ function prePrSatisfied(tasks) {
7937
+ return tasks.prePrReviewStatus === "done" && !!tasks.prePrEvidence && !!tasks.prePrDecision && tasks.prePrDecisionOutcome === "approve";
6992
7938
  }
6993
7939
  function issueExistsRemotely(issueNumber, feature) {
6994
7940
  if (!issueNumber) return false;
@@ -7016,6 +7962,292 @@ function buildAction(category, summary, approvalRequired, command = null) {
7016
7962
  command
7017
7963
  };
7018
7964
  }
7965
+ function buildStageOption(label, reply, category, summary, command = null) {
7966
+ return {
7967
+ label,
7968
+ reply,
7969
+ category,
7970
+ summary,
7971
+ command
7972
+ };
7973
+ }
7974
+ function normalizeApprovalToken(value) {
7975
+ return (value ?? "").trim().toLowerCase();
7976
+ }
7977
+ function resolveActionApprovalRequired(config, category, builtinRequiresUserCheck) {
7978
+ const approval = config.approval?.mode === "builtin" ? createDefaultApprovalConfig() : config.approval ?? createDefaultApprovalConfig();
7979
+ const mode = approval.mode ?? "category";
7980
+ if (mode === "steps") {
7981
+ const requiredSteps = new Set(
7982
+ (approval.requireCheckSteps ?? []).map((value) => typeof value === "number" ? value : Number(value)).filter((value) => Number.isFinite(value))
7983
+ );
7984
+ const legacyStep = LEGACY_STEP_BY_ACTION[category];
7985
+ return typeof legacyStep === "number" ? requiredSteps.has(legacyStep) : builtinRequiresUserCheck;
7986
+ }
7987
+ const requiredCategories = new Set(
7988
+ (approval.requireCheckCategories ?? []).map((value) => normalizeApprovalToken(value)).filter(Boolean)
7989
+ );
7990
+ const skippedCategories = new Set(
7991
+ (approval.skipCheckCategories ?? []).map((value) => normalizeApprovalToken(value)).filter(Boolean)
7992
+ );
7993
+ const defaultPolicy = approval.default ?? createDefaultApprovalConfig().default ?? "skip";
7994
+ const normalizedCategory = normalizeApprovalToken(category);
7995
+ const explicitlyRequired = requiredCategories.has("*") || requiredCategories.has(normalizedCategory);
7996
+ if (explicitlyRequired) return true;
7997
+ if (skippedCategories.has("*") || skippedCategories.has(normalizedCategory)) {
7998
+ return false;
7999
+ }
8000
+ if (defaultPolicy === "require") return true;
8001
+ if (defaultPolicy === "skip") return false;
8002
+ return builtinRequiresUserCheck;
8003
+ }
8004
+ function resolveRemotePrReviewState(prRef, feature) {
8005
+ if (!prRef) return "unknown";
8006
+ const result = runProcess(
8007
+ "gh",
8008
+ [
8009
+ "pr",
8010
+ "view",
8011
+ prRef,
8012
+ "--json",
8013
+ "reviewDecision,state,mergedAt,mergeStateStatus,isDraft,headRefOid,latestReviews,comments,statusCheckRollup"
8014
+ ],
8015
+ feature.git.projectGitCwd
8016
+ );
8017
+ if (result.code !== 0) {
8018
+ return "unknown";
8019
+ }
8020
+ try {
8021
+ const parsed = JSON.parse(String(result.stdout || "{}"));
8022
+ const reviewDecision = String(parsed.reviewDecision || "").trim().toUpperCase();
8023
+ const state = String(parsed.state || "").trim().toUpperCase();
8024
+ const mergeStateStatus = String(parsed.mergeStateStatus || "").trim().toUpperCase();
8025
+ const isDraft = parsed.isDraft === true;
8026
+ const headRefOid = String(parsed.headRefOid || "").trim().toLowerCase();
8027
+ const mergedAt = typeof parsed.mergedAt === "string" ? parsed.mergedAt.trim() : "";
8028
+ const codeRabbitThreadState = reviewDecision.length === 0 ? resolveCodeRabbitReviewThreadsState(prRef, feature) : "unknown";
8029
+ const codeRabbitCheckSucceeded = hasSuccessfulCodeRabbitStatusCheck(
8030
+ parsed.statusCheckRollup
8031
+ );
8032
+ if (state === "MERGED" || mergedAt.length > 0) {
8033
+ return "merged";
8034
+ }
8035
+ if (isDraft) {
8036
+ return "draft";
8037
+ }
8038
+ if (reviewDecision === "CHANGES_REQUESTED") {
8039
+ return "changes_requested";
8040
+ }
8041
+ if (reviewDecision === "APPROVED") {
8042
+ return mergeStateStatus === "CLEAN" || mergeStateStatus === "HAS_HOOKS" ? "approved" : "merge_blocked";
8043
+ }
8044
+ if (reviewDecision.length === 0 && codeRabbitThreadState === "open") {
8045
+ return "changes_requested";
8046
+ }
8047
+ if (reviewDecision.length === 0 && hasLatestHeadRateLimitSignal(parsed, headRefOid)) {
8048
+ return "review_rate_limited";
8049
+ }
8050
+ if (reviewDecision.length === 0 && hasStaleLatestCommitReviewSignal(parsed, headRefOid) && !(codeRabbitThreadState === "resolved" && codeRabbitCheckSucceeded)) {
8051
+ return "review_pending_latest_commit";
8052
+ }
8053
+ if (reviewDecision.length === 0 && hasCodeRabbitActionableReview(parsed.latestReviews)) {
8054
+ if (codeRabbitThreadState === "resolved" && codeRabbitCheckSucceeded) {
8055
+ return mergeStateStatus === "CLEAN" || mergeStateStatus === "HAS_HOOKS" ? "approved" : "merge_blocked";
8056
+ }
8057
+ return "changes_requested";
8058
+ }
8059
+ if (reviewDecision.length === 0 && codeRabbitThreadState === "resolved" && codeRabbitCheckSucceeded) {
8060
+ return mergeStateStatus === "CLEAN" || mergeStateStatus === "HAS_HOOKS" ? "approved" : "merge_blocked";
8061
+ }
8062
+ if (reviewDecision === "REVIEW_REQUIRED" || reviewDecision.length === 0) {
8063
+ return "waiting_review";
8064
+ }
8065
+ return "unknown";
8066
+ } catch {
8067
+ return "unknown";
8068
+ }
8069
+ }
8070
+ function resolveCurrentReviewState(tasks, prDraft, remoteReviewState) {
8071
+ if (remoteReviewState === "merged") {
8072
+ return "merged";
8073
+ }
8074
+ if (remoteReviewState === "draft") {
8075
+ return "draft";
8076
+ }
8077
+ if (remoteReviewState === "merge_blocked") {
8078
+ return "merge_blocked";
8079
+ }
8080
+ if (remoteReviewState === "changes_requested") {
8081
+ return "changes_requested";
8082
+ }
8083
+ if (remoteReviewState === "review_rate_limited") {
8084
+ return "review_rate_limited";
8085
+ }
8086
+ if (remoteReviewState === "review_pending_latest_commit") {
8087
+ return "review_pending_latest_commit";
8088
+ }
8089
+ if (remoteReviewState === "waiting_review") {
8090
+ return "waiting_review";
8091
+ }
8092
+ if (remoteReviewState === "approved") {
8093
+ return "approved";
8094
+ }
8095
+ if (remoteReviewState === "unknown") {
8096
+ return "unknown";
8097
+ }
8098
+ if (tasks.prStatus === "approved" || prDraft.prStatus === "approved") {
8099
+ return "approved";
8100
+ }
8101
+ return "unknown";
8102
+ }
8103
+ function buildCodeReviewActionOptions(reviewState) {
8104
+ if (reviewState === "merged") {
8105
+ return [
8106
+ buildStageOption(
8107
+ "A",
8108
+ "A",
8109
+ "review_sync_approved",
8110
+ "Sync the already-merged PR state into tasks.md and pr.md before closing the feature."
8111
+ ),
8112
+ buildStageOption(
8113
+ "B",
8114
+ "B",
8115
+ "hold",
8116
+ "Stop here and leave the merged-state sync for later."
8117
+ )
8118
+ ];
8119
+ }
8120
+ if (reviewState === "approved") {
8121
+ return [
8122
+ buildStageOption(
8123
+ "A",
8124
+ "A",
8125
+ "review_sync_approved",
8126
+ "Sync the approved PR review state into tasks.md and pr.md, then continue to the merge gate."
8127
+ ),
8128
+ buildStageOption(
8129
+ "B",
8130
+ "B",
8131
+ "hold",
8132
+ "Hold the merge boundary for now and leave the PR open."
8133
+ )
8134
+ ];
8135
+ }
8136
+ if (reviewState === "draft" || reviewState === "merge_blocked") {
8137
+ return [
8138
+ buildStageOption(
8139
+ "A",
8140
+ "A",
8141
+ "review_wait",
8142
+ "Inspect the current PR state, resolve the draft/merge blocker, and sync the review fields before proceeding."
8143
+ ),
8144
+ buildStageOption(
8145
+ "B",
8146
+ "B",
8147
+ "hold",
8148
+ "Stop here and keep the PR open until the blocker is resolved."
8149
+ )
8150
+ ];
8151
+ }
8152
+ if (reviewState === "changes_requested") {
8153
+ return [
8154
+ buildStageOption(
8155
+ "A",
8156
+ "A",
8157
+ "review_fix",
8158
+ "Address the requested review changes, update review evidence/decision, and continue the feature."
8159
+ ),
8160
+ buildStageOption(
8161
+ "B",
8162
+ "B",
8163
+ "hold",
8164
+ "Stop here and wait before taking another review-fix pass."
8165
+ )
8166
+ ];
8167
+ }
8168
+ if (reviewState === "review_rate_limited") {
8169
+ return [
8170
+ buildStageOption(
8171
+ "A",
8172
+ "A",
8173
+ "review_wait",
8174
+ "Re-check the PR review state after the CodeRabbit rate limit window resets, then sync tasks.md when a fresh review arrives."
8175
+ ),
8176
+ buildStageOption(
8177
+ "B",
8178
+ "B",
8179
+ "hold",
8180
+ "Stop here and wait for the review rate limit window to clear."
8181
+ )
8182
+ ];
8183
+ }
8184
+ if (reviewState === "review_pending_latest_commit") {
8185
+ return [
8186
+ buildStageOption(
8187
+ "A",
8188
+ "A",
8189
+ "review_wait",
8190
+ "Re-check the PR review state after a reviewer processes the latest commit, then sync tasks.md when fresh review feedback arrives."
8191
+ ),
8192
+ buildStageOption(
8193
+ "B",
8194
+ "B",
8195
+ "hold",
8196
+ "Stop here and wait for a fresh review on the latest commit."
8197
+ )
8198
+ ];
8199
+ }
8200
+ return [
8201
+ buildStageOption(
8202
+ "A",
8203
+ "A",
8204
+ "review_wait",
8205
+ "Check the PR review state again and sync tasks.md when reviewer feedback or approval arrives."
8206
+ ),
8207
+ buildStageOption(
8208
+ "B",
8209
+ "B",
8210
+ "hold",
8211
+ "Stop here and wait for external reviewer feedback."
8212
+ )
8213
+ ];
8214
+ }
8215
+ function buildMergeActionOptions(command) {
8216
+ return [
8217
+ buildStageOption(
8218
+ "A",
8219
+ "A OK",
8220
+ "pr_merge",
8221
+ "Merge the PR now and sync the merged state back into tasks.md.",
8222
+ command
8223
+ ),
8224
+ buildStageOption(
8225
+ "B",
8226
+ "B",
8227
+ "hold",
8228
+ "Keep the PR open and do not merge yet."
8229
+ )
8230
+ ];
8231
+ }
8232
+ function buildApprovalActionOptions(params) {
8233
+ const remoteCommand = params.remoteCommand?.trim() || null;
8234
+ if (remoteCommand && remoteCommand.includes("--confirm OK")) {
8235
+ return [
8236
+ buildStageOption(
8237
+ "A",
8238
+ "A OK",
8239
+ "remote_execute",
8240
+ params.approveSummary,
8241
+ remoteCommand
8242
+ ),
8243
+ buildStageOption("B", "B", "hold", params.holdSummary)
8244
+ ];
8245
+ }
8246
+ return [
8247
+ buildStageOption("A", "A", "approve_continue", params.approveSummary),
8248
+ buildStageOption("B", "B", "request_changes", params.holdSummary)
8249
+ ];
8250
+ }
7019
8251
  function resolveFeatureSelectionError(selection) {
7020
8252
  const reasonCode = selection.status === "no_features" ? "NO_FEATURES" : "FEATURE_SELECTION_REQUIRED";
7021
8253
  return {
@@ -7051,6 +8283,7 @@ async function collectWorkflowStage(cwd, selector, component) {
7051
8283
  }
7052
8284
  const feature = selection.matchedFeature;
7053
8285
  const requirements = resolveWorkflowRequirements(config);
8286
+ const taskCommitGatePolicy = resolveTaskCommitGatePolicy(config);
7054
8287
  const paths = getFeatureDocPaths(feature);
7055
8288
  const specContent = await readFileIfExists(path8.join(config.docsDir, paths.specPath));
7056
8289
  const planContent = await readFileIfExists(path8.join(config.docsDir, paths.planPath));
@@ -7058,16 +8291,27 @@ async function collectWorkflowStage(cwd, selector, component) {
7058
8291
  const issueContent = await readFileIfExists(path8.join(config.docsDir, paths.issuePath));
7059
8292
  const prContent = await readFileIfExists(path8.join(config.docsDir, paths.prPath));
7060
8293
  const specStatus = parseApprovalStatus(
7061
- extractFieldValue(specContent || "", ["Status", "\uC0C1\uD0DC"]) || void 0
8294
+ extractFieldValue2(specContent || "", ["Status", "\uC0C1\uD0DC"]) || void 0
7062
8295
  );
7063
8296
  const planStatus = parseApprovalStatus(
7064
- extractFieldValue(planContent || "", ["Status", "\uC0C1\uD0DC"]) || void 0
8297
+ extractFieldValue2(planContent || "", ["Status", "\uC0C1\uD0DC"]) || void 0
7065
8298
  );
7066
8299
  const tasks = parseTasksDoc(tasksContent || "");
7067
8300
  const issueDraft = parseWorkflowDraftMetadataExtended(issueContent || "");
7068
8301
  const prDraft = parseWorkflowDraftMetadataExtended(prContent || "");
8302
+ const remoteReviewState = requirements.requireReview && tasks.prLink ? resolveRemotePrReviewState(tasks.prLink, feature) : "unknown";
8303
+ const currentReviewState = resolveCurrentReviewState(
8304
+ tasks,
8305
+ prDraft,
8306
+ remoteReviewState
8307
+ );
7069
8308
  if (specStatus !== "approved") {
7070
- const approvalRequired = specStatus === "review";
8309
+ const isReviewStage = specStatus === "review";
8310
+ const approvalRequired = isReviewStage ? resolveActionApprovalRequired(config, "spec_approve", true) : false;
8311
+ const actionOptions = approvalRequired ? buildApprovalActionOptions({
8312
+ approveSummary: "Approve spec.md and continue to the plan stage.",
8313
+ holdSummary: "Request spec changes before continuing."
8314
+ }) : void 0;
7071
8315
  return {
7072
8316
  status: "ok",
7073
8317
  reasonCode: "WORKFLOW_STAGE_RESOLVED",
@@ -7075,17 +8319,24 @@ async function collectWorkflowStage(cwd, selector, component) {
7075
8319
  featureRef: buildFeatureRef(feature),
7076
8320
  stage: "spec",
7077
8321
  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.",
8322
+ isReviewStage ? "spec_approve" : "spec_write",
8323
+ 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
8324
  approvalRequired
7081
8325
  ),
7082
8326
  approvalRequired,
7083
8327
  implementationAllowed: false,
8328
+ primaryActionLabel: actionOptions ? "A" : void 0,
8329
+ actionOptions,
7084
8330
  blockedReasonCode: "SPEC_NOT_APPROVED"
7085
8331
  };
7086
8332
  }
7087
8333
  if (planStatus !== "approved") {
7088
- const approvalRequired = planStatus === "review";
8334
+ const isReviewStage = planStatus === "review";
8335
+ const approvalRequired = isReviewStage ? resolveActionApprovalRequired(config, "plan_approve", true) : false;
8336
+ const actionOptions = approvalRequired ? buildApprovalActionOptions({
8337
+ approveSummary: "Approve plan.md and continue to the tasks stage.",
8338
+ holdSummary: "Request plan changes before continuing."
8339
+ }) : void 0;
7089
8340
  return {
7090
8341
  status: "ok",
7091
8342
  reasonCode: "WORKFLOW_STAGE_RESOLVED",
@@ -7093,17 +8344,24 @@ async function collectWorkflowStage(cwd, selector, component) {
7093
8344
  featureRef: buildFeatureRef(feature),
7094
8345
  stage: "plan",
7095
8346
  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.",
8347
+ isReviewStage ? "plan_approve" : "plan_write",
8348
+ 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
8349
  approvalRequired
7099
8350
  ),
7100
8351
  approvalRequired,
7101
8352
  implementationAllowed: false,
8353
+ primaryActionLabel: actionOptions ? "A" : void 0,
8354
+ actionOptions,
7102
8355
  blockedReasonCode: "PLAN_NOT_APPROVED"
7103
8356
  };
7104
8357
  }
7105
8358
  if (tasks.tasks.length === 0 || tasks.docStatus !== "approved") {
7106
- const approvalRequired = tasks.docStatus === "review";
8359
+ const isReviewStage = tasks.docStatus === "review";
8360
+ const approvalRequired = isReviewStage ? resolveActionApprovalRequired(config, "tasks_approve", true) : false;
8361
+ const actionOptions = approvalRequired ? buildApprovalActionOptions({
8362
+ approveSummary: "Approve tasks.md and continue to issue preparation.",
8363
+ holdSummary: "Request task-list changes before continuing."
8364
+ }) : void 0;
7107
8365
  return {
7108
8366
  status: "ok",
7109
8367
  reasonCode: "WORKFLOW_STAGE_RESOLVED",
@@ -7111,12 +8369,14 @@ async function collectWorkflowStage(cwd, selector, component) {
7111
8369
  featureRef: buildFeatureRef(feature),
7112
8370
  stage: "tasks",
7113
8371
  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.",
8372
+ isReviewStage ? "tasks_approve" : "tasks_write",
8373
+ 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
8374
  approvalRequired
7117
8375
  ),
7118
8376
  approvalRequired,
7119
8377
  implementationAllowed: false,
8378
+ primaryActionLabel: actionOptions ? "A" : void 0,
8379
+ actionOptions,
7120
8380
  blockedReasonCode: "TASKS_NOT_READY"
7121
8381
  };
7122
8382
  }
@@ -7124,6 +8384,13 @@ async function collectWorkflowStage(cwd, selector, component) {
7124
8384
  const issueReady = issueDraft.status === "ready";
7125
8385
  const issueCreated = tasks.issueNumber !== null && issueExistsRemotely(tasks.issueNumber, feature);
7126
8386
  if (!issueCreated || !issueReady) {
8387
+ const issueCreateApprovalRequired = issueReady && !issueCreated;
8388
+ const issueCreateCommand = `npx lee-spec-kit github issue ${buildFeatureArgs(feature)} --create --confirm OK`;
8389
+ const issueCreateOptions = issueCreateApprovalRequired ? buildApprovalActionOptions({
8390
+ approveSummary: "Create the GitHub issue now and sync the issue number back into tasks.md.",
8391
+ holdSummary: "Keep the issue in Ready state but do not create it yet.",
8392
+ remoteCommand: issueCreateCommand
8393
+ }) : void 0;
7127
8394
  return {
7128
8395
  status: "ok",
7129
8396
  reasonCode: "WORKFLOW_STAGE_RESOLVED",
@@ -7133,23 +8400,40 @@ async function collectWorkflowStage(cwd, selector, component) {
7133
8400
  nextAction: issueReady && !issueCreated ? buildAction(
7134
8401
  "issue_create",
7135
8402
  "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`
8403
+ issueCreateApprovalRequired,
8404
+ issueCreateCommand
7138
8405
  ) : buildAction(
7139
8406
  "issue_prepare",
7140
8407
  "Prepare issue.md and set its Status to Ready before issue creation.",
7141
8408
  false
7142
8409
  ),
7143
- approvalRequired: issueReady && !issueCreated,
8410
+ approvalRequired: issueCreateApprovalRequired,
7144
8411
  implementationAllowed: false,
8412
+ primaryActionLabel: issueCreateOptions ? "A" : void 0,
8413
+ actionOptions: issueCreateOptions,
7145
8414
  blockedReasonCode: "ISSUE_NOT_CREATED"
7146
8415
  };
7147
8416
  }
7148
8417
  }
8418
+ let effectiveProjectGitCwd = feature.git.projectGitCwd;
8419
+ if (requirements.requireWorktree) {
8420
+ const expectedBranch = resolveExpectedBranch(feature, tasks);
8421
+ if (expectedBranch) {
8422
+ const existingWorktreePath = await resolveExistingExpectedWorktreePath(
8423
+ config,
8424
+ feature.git.projectGitCwd,
8425
+ expectedBranch
8426
+ );
8427
+ if (existingWorktreePath) {
8428
+ effectiveProjectGitCwd = existingWorktreePath;
8429
+ }
8430
+ }
8431
+ }
7149
8432
  if (requirements.requireBranch && !allTasksDone(tasks)) {
7150
8433
  const expectedBranch = resolveExpectedBranch(feature, tasks);
7151
- const currentBranch = getCurrentProjectBranch(feature);
8434
+ const currentBranch = runGitCapture(["branch", "--show-current"], effectiveProjectGitCwd) || runGitCapture(["rev-parse", "--abbrev-ref", "HEAD"], effectiveProjectGitCwd) || null;
7152
8435
  if (expectedBranch && currentBranch !== expectedBranch) {
8436
+ const branchCommand = requirements.requireWorktree ? buildManagedWorktreeCreateCommand(config, feature.git.projectGitCwd, expectedBranch) : `git checkout -b ${expectedBranch}`;
7153
8437
  return {
7154
8438
  status: "ok",
7155
8439
  reasonCode: "WORKFLOW_STAGE_RESOLVED",
@@ -7158,9 +8442,9 @@ async function collectWorkflowStage(cwd, selector, component) {
7158
8442
  stage: "branch",
7159
8443
  nextAction: buildAction(
7160
8444
  "branch_create",
7161
- `Switch the project repo to ${expectedBranch} before implementation starts.`,
8445
+ requirements.requireWorktree ? `Create or reuse the managed worktree for ${expectedBranch} before implementation starts.` : `Switch the project repo to ${expectedBranch} before implementation starts.`,
7162
8446
  false,
7163
- `git checkout -b ${expectedBranch}`
8447
+ branchCommand
7164
8448
  ),
7165
8449
  approvalRequired: false,
7166
8450
  implementationAllowed: false,
@@ -7168,8 +8452,66 @@ async function collectWorkflowStage(cwd, selector, component) {
7168
8452
  };
7169
8453
  }
7170
8454
  }
8455
+ const activeTaskOpen = hasOpenTask(tasks);
8456
+ const lastDoneTask = getLastDoneTask(tasks);
8457
+ const docsDirty = hasUncommittedChanges(feature.git.docsGitCwd);
8458
+ const projectDirty = hasUncommittedChanges(effectiveProjectGitCwd);
8459
+ const pendingDoneTransitions = countPendingDoneTransitions(feature) || 0;
8460
+ const taskCommitCheckpointRequired = !activeTaskOpen && !!lastDoneTask && (projectDirty || pendingDoneTransitions > 0);
8461
+ if (taskCommitCheckpointRequired) {
8462
+ const pendingReason = pendingDoneTransitions > 1 ? `working tree currently contains ${pendingDoneTransitions} uncommitted DONE transitions` : null;
8463
+ return {
8464
+ status: "ok",
8465
+ reasonCode: "WORKFLOW_STAGE_RESOLVED",
8466
+ docsDir: config.docsDir,
8467
+ featureRef: buildFeatureRef(feature),
8468
+ stage: "task_commit",
8469
+ nextAction: buildAction(
8470
+ "task_commit",
8471
+ buildTaskCommitSummary({
8472
+ feature,
8473
+ tasks,
8474
+ effectiveProjectGitCwd,
8475
+ docsDirty,
8476
+ projectDirty,
8477
+ gateFailureReason: pendingReason
8478
+ }),
8479
+ false
8480
+ ),
8481
+ approvalRequired: false,
8482
+ implementationAllowed: false,
8483
+ blockedReasonCode: "TASK_COMMIT_REQUIRED"
8484
+ };
8485
+ }
8486
+ const committedTaskGate = taskCommitGatePolicy !== "off" && lastDoneTask ? checkTaskCommitGate(feature, effectiveProjectGitCwd, lastDoneTask) : { pass: true };
7171
8487
  if (!allTasksDone(tasks)) {
7172
8488
  const currentTask = nextTodoTask(tasks);
8489
+ if (taskCommitGatePolicy === "strict" && !committedTaskGate.pass) {
8490
+ return {
8491
+ status: "ok",
8492
+ reasonCode: "WORKFLOW_STAGE_RESOLVED",
8493
+ docsDir: config.docsDir,
8494
+ featureRef: buildFeatureRef(feature),
8495
+ stage: "task_commit",
8496
+ nextAction: buildAction(
8497
+ "task_commit",
8498
+ buildTaskCommitSummary({
8499
+ feature,
8500
+ tasks,
8501
+ effectiveProjectGitCwd,
8502
+ docsDirty,
8503
+ projectDirty,
8504
+ gateFailureReason: describeTaskCommitGateFailure(committedTaskGate)
8505
+ }),
8506
+ false
8507
+ ),
8508
+ approvalRequired: false,
8509
+ implementationAllowed: false,
8510
+ blockedReasonCode: "TASK_COMMIT_REQUIRED"
8511
+ };
8512
+ }
8513
+ const commitWarning = taskCommitGatePolicy === "warn" && !committedTaskGate.pass ? `
8514
+ Task commit boundary warning: ${describeTaskCommitGateFailure(committedTaskGate)}` : "";
7173
8515
  return {
7174
8516
  status: "ok",
7175
8517
  reasonCode: "WORKFLOW_STAGE_RESOLVED",
@@ -7178,7 +8520,7 @@ async function collectWorkflowStage(cwd, selector, component) {
7178
8520
  stage: "implementation",
7179
8521
  nextAction: buildAction(
7180
8522
  "task_execute",
7181
- currentTask ? `Continue the next implementation task: ${currentTask.title}` : "Continue the active implementation task.",
8523
+ currentTask ? `Continue the next implementation task: ${currentTask.title}${commitWarning}` : "Continue the active implementation task.",
7182
8524
  false
7183
8525
  ),
7184
8526
  approvalRequired: false,
@@ -7187,6 +8529,39 @@ async function collectWorkflowStage(cwd, selector, component) {
7187
8529
  };
7188
8530
  }
7189
8531
  if (!tasks.completion.allTasksChecked || !tasks.completion.testsChecked || !tasks.completion.finalOutcomeChecked) {
8532
+ if (taskCommitGatePolicy === "strict" && !committedTaskGate.pass) {
8533
+ return {
8534
+ status: "ok",
8535
+ reasonCode: "WORKFLOW_STAGE_RESOLVED",
8536
+ docsDir: config.docsDir,
8537
+ featureRef: buildFeatureRef(feature),
8538
+ stage: "task_commit",
8539
+ nextAction: buildAction(
8540
+ "task_commit",
8541
+ buildTaskCommitSummary({
8542
+ feature,
8543
+ tasks,
8544
+ effectiveProjectGitCwd,
8545
+ docsDirty,
8546
+ projectDirty,
8547
+ gateFailureReason: describeTaskCommitGateFailure(committedTaskGate)
8548
+ }),
8549
+ false
8550
+ ),
8551
+ approvalRequired: false,
8552
+ implementationAllowed: false,
8553
+ blockedReasonCode: "TASK_COMMIT_REQUIRED"
8554
+ };
8555
+ }
8556
+ const approvalRequired = resolveActionApprovalRequired(
8557
+ config,
8558
+ "implementation_approve",
8559
+ true
8560
+ );
8561
+ const actionOptions = approvalRequired ? buildApprovalActionOptions({
8562
+ approveSummary: "Approve the completed implementation and continue to the pre-PR or PR preparation stage.",
8563
+ holdSummary: "Request implementation changes before the workflow continues."
8564
+ }) : void 0;
7190
8565
  return {
7191
8566
  status: "ok",
7192
8567
  reasonCode: "WORKFLOW_STAGE_RESOLVED",
@@ -7196,10 +8571,12 @@ async function collectWorkflowStage(cwd, selector, component) {
7196
8571
  nextAction: buildAction(
7197
8572
  "implementation_approve",
7198
8573
  "Share the completed implementation, get user approval, and record the completion checkpoint in tasks.md.",
7199
- true
8574
+ approvalRequired
7200
8575
  ),
7201
- approvalRequired: true,
8576
+ approvalRequired,
7202
8577
  implementationAllowed: false,
8578
+ primaryActionLabel: actionOptions ? "A" : void 0,
8579
+ actionOptions,
7203
8580
  blockedReasonCode: "IMPLEMENTATION_APPROVAL_REQUIRED"
7204
8581
  };
7205
8582
  }
@@ -7224,6 +8601,13 @@ async function collectWorkflowStage(cwd, selector, component) {
7224
8601
  const prReady = prDraft.status === "ready";
7225
8602
  const prCreated = !!tasks.prLink && prExistsRemotely(tasks.prLink, feature);
7226
8603
  if (!prCreated || !prReady) {
8604
+ const prCreateApprovalRequired = prReady && !prCreated;
8605
+ const prCreateCommand = `npx lee-spec-kit github pr ${buildFeatureArgs(feature)} --create --confirm OK`;
8606
+ const prCreateOptions = prCreateApprovalRequired ? buildApprovalActionOptions({
8607
+ approveSummary: "Create the GitHub PR now and sync the PR metadata back into tasks.md.",
8608
+ holdSummary: "Keep the PR in Ready state but do not create it yet.",
8609
+ remoteCommand: prCreateCommand
8610
+ }) : void 0;
7227
8611
  return {
7228
8612
  status: "ok",
7229
8613
  reasonCode: "WORKFLOW_STAGE_RESOLVED",
@@ -7233,20 +8617,63 @@ async function collectWorkflowStage(cwd, selector, component) {
7233
8617
  nextAction: prReady && !prCreated ? buildAction(
7234
8618
  "pr_create",
7235
8619
  "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`
8620
+ prCreateApprovalRequired,
8621
+ prCreateCommand
7238
8622
  ) : buildAction(
7239
8623
  "pr_prepare",
7240
8624
  "Prepare pr.md and set its Status to Ready before PR creation.",
7241
8625
  false
7242
8626
  ),
7243
- approvalRequired: prReady && !prCreated,
8627
+ approvalRequired: prCreateApprovalRequired,
7244
8628
  implementationAllowed: false,
8629
+ primaryActionLabel: prCreateOptions ? "A" : void 0,
8630
+ actionOptions: prCreateOptions,
7245
8631
  blockedReasonCode: "PR_NOT_CREATED"
7246
8632
  };
7247
8633
  }
7248
8634
  }
7249
- if (requirements.requireReview && (tasks.prStatus !== "approved" || prDraft.prStatus !== "approved")) {
8635
+ const reviewApprovedInDocs = tasks.prStatus === "approved" && prDraft.prStatus === "approved";
8636
+ if (requirements.requireReview && currentReviewState === "merged" && reviewApprovedInDocs) {
8637
+ const cleanupState = resolvePostMergeCleanupState(config, feature, tasks);
8638
+ if (!cleanupState.complete) {
8639
+ return {
8640
+ status: "ok",
8641
+ reasonCode: "WORKFLOW_STAGE_RESOLVED",
8642
+ docsDir: config.docsDir,
8643
+ featureRef: buildFeatureRef(feature),
8644
+ stage: "cleanup",
8645
+ nextAction: buildAction(
8646
+ "merge_cleanup",
8647
+ buildPostMergeCleanupSummary(cleanupState),
8648
+ false,
8649
+ buildPostMergeCleanupCommand(cleanupState)
8650
+ ),
8651
+ approvalRequired: false,
8652
+ implementationAllowed: false,
8653
+ reviewState: "merged",
8654
+ blockedReasonCode: "POST_MERGE_CLEANUP_REQUIRED"
8655
+ };
8656
+ }
8657
+ return {
8658
+ status: "ok",
8659
+ reasonCode: "WORKFLOW_STAGE_RESOLVED",
8660
+ docsDir: config.docsDir,
8661
+ featureRef: buildFeatureRef(feature),
8662
+ stage: "done",
8663
+ nextAction: null,
8664
+ approvalRequired: false,
8665
+ implementationAllowed: false,
8666
+ reviewState: "merged",
8667
+ primaryActionLabel: null,
8668
+ actionOptions: [],
8669
+ blockedReasonCode: null
8670
+ };
8671
+ }
8672
+ if (requirements.requireReview && (!reviewApprovedInDocs || currentReviewState !== "approved")) {
8673
+ const reviewFixAllowed = currentReviewState === "changes_requested";
8674
+ const reviewApprovalRequired = !reviewFixAllowed;
8675
+ const reviewActionOptions = reviewApprovalRequired ? buildCodeReviewActionOptions(currentReviewState) : void 0;
8676
+ 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
8677
  return {
7251
8678
  status: "ok",
7252
8679
  reasonCode: "WORKFLOW_STAGE_RESOLVED",
@@ -7255,15 +8682,19 @@ async function collectWorkflowStage(cwd, selector, component) {
7255
8682
  stage: "code_review",
7256
8683
  nextAction: buildAction(
7257
8684
  "code_review",
7258
- "Complete PR review and record the final approved review state in tasks.md.",
7259
- false
8685
+ reviewSummary,
8686
+ reviewApprovalRequired
7260
8687
  ),
7261
- approvalRequired: false,
7262
- implementationAllowed: false,
8688
+ approvalRequired: reviewApprovalRequired,
8689
+ implementationAllowed: reviewFixAllowed,
8690
+ reviewState: currentReviewState,
8691
+ primaryActionLabel: reviewActionOptions ? "A" : void 0,
8692
+ actionOptions: reviewActionOptions,
7263
8693
  blockedReasonCode: "PR_REVIEW_NOT_APPROVED"
7264
8694
  };
7265
8695
  }
7266
8696
  if (requirements.requireMerge) {
8697
+ const mergeCommand = `npx lee-spec-kit github pr ${buildFeatureArgs(feature)} --merge --confirm OK`;
7267
8698
  return {
7268
8699
  status: "ok",
7269
8700
  reasonCode: "WORKFLOW_STAGE_RESOLVED",
@@ -7274,10 +8705,13 @@ async function collectWorkflowStage(cwd, selector, component) {
7274
8705
  "pr_merge",
7275
8706
  "Merge the PR and sync the merged state back into tasks.md.",
7276
8707
  true,
7277
- `npx lee-spec-kit github pr ${buildFeatureArgs(feature)} --merge --confirm OK`
8708
+ mergeCommand
7278
8709
  ),
7279
8710
  approvalRequired: true,
7280
8711
  implementationAllowed: false,
8712
+ reviewState: "approved",
8713
+ primaryActionLabel: "A",
8714
+ actionOptions: buildMergeActionOptions(mergeCommand),
7281
8715
  blockedReasonCode: null
7282
8716
  };
7283
8717
  }
@@ -7293,6 +8727,237 @@ async function collectWorkflowStage(cwd, selector, component) {
7293
8727
  blockedReasonCode: null
7294
8728
  };
7295
8729
  }
8730
+ function hasLatestHeadRateLimitSignal(parsed, headRefOid) {
8731
+ const latestRateLimitCommentAt = findLatestCodeRabbitRateLimitCommentAt(
8732
+ parsed.comments,
8733
+ headRefOid
8734
+ );
8735
+ if (!latestRateLimitCommentAt) {
8736
+ return false;
8737
+ }
8738
+ const latestReviewAt = findLatestCodeRabbitReviewAt(parsed.latestReviews);
8739
+ return !latestReviewAt || latestReviewAt <= latestRateLimitCommentAt;
8740
+ }
8741
+ function hasStaleLatestCommitReviewSignal(parsed, headRefOid) {
8742
+ if (!headRefOid) {
8743
+ return false;
8744
+ }
8745
+ const latestReviewHead = findLatestCodeRabbitReviewedHead(parsed.latestReviews);
8746
+ if (!latestReviewHead) {
8747
+ return false;
8748
+ }
8749
+ return !matchesCommitReference(headRefOid, latestReviewHead);
8750
+ }
8751
+ function resolveCodeRabbitReviewThreadsState(prRef, feature) {
8752
+ const coordinates = parseGithubPullRequestRef(prRef);
8753
+ if (!coordinates) {
8754
+ return "unknown";
8755
+ }
8756
+ const result = runProcess(
8757
+ "gh",
8758
+ [
8759
+ "api",
8760
+ "graphql",
8761
+ "-f",
8762
+ `owner=${coordinates.owner}`,
8763
+ "-f",
8764
+ `name=${coordinates.name}`,
8765
+ "-F",
8766
+ `number=${coordinates.number}`,
8767
+ "-f",
8768
+ "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 } } } } } } }"
8769
+ ],
8770
+ feature.git.projectGitCwd
8771
+ );
8772
+ if (result.code !== 0) {
8773
+ return "unknown";
8774
+ }
8775
+ try {
8776
+ const parsed = JSON.parse(String(result.stdout || "{}"));
8777
+ const nodes = extractNestedArray(parsed, [
8778
+ "data",
8779
+ "repository",
8780
+ "pullRequest",
8781
+ "reviewThreads",
8782
+ "nodes"
8783
+ ]);
8784
+ if (!nodes) {
8785
+ return "unknown";
8786
+ }
8787
+ const codeRabbitThreads = nodes.filter(isCodeRabbitReviewThread);
8788
+ if (codeRabbitThreads.length === 0) {
8789
+ return "none";
8790
+ }
8791
+ return codeRabbitThreads.some((thread) => !isReviewThreadResolved(thread)) ? "open" : "resolved";
8792
+ } catch {
8793
+ return "unknown";
8794
+ }
8795
+ }
8796
+ function parseGithubPullRequestRef(prRef) {
8797
+ const value = prRef?.trim();
8798
+ if (!value) {
8799
+ return null;
8800
+ }
8801
+ const urlMatch = value.match(
8802
+ /^https?:\/\/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)(?:[/?#].*)?$/i
8803
+ );
8804
+ if (!urlMatch?.[1] || !urlMatch[2] || !urlMatch[3]) {
8805
+ return null;
8806
+ }
8807
+ return {
8808
+ owner: urlMatch[1],
8809
+ name: urlMatch[2],
8810
+ number: Number(urlMatch[3])
8811
+ };
8812
+ }
8813
+ function hasSuccessfulCodeRabbitStatusCheck(statusChecksValue) {
8814
+ if (!Array.isArray(statusChecksValue)) {
8815
+ return false;
8816
+ }
8817
+ return statusChecksValue.some((entry) => {
8818
+ if (!entry || typeof entry !== "object") return false;
8819
+ const record = entry;
8820
+ const label = [
8821
+ record.context,
8822
+ record.name,
8823
+ extractNestedString(record, ["app", "name"]),
8824
+ extractNestedString(record, ["checkSuite", "app", "name"])
8825
+ ].filter((value) => typeof value === "string" && value.trim().length > 0).join(" ").toLowerCase();
8826
+ if (!label.includes("coderabbit")) return false;
8827
+ const state = String(record.state || record.conclusion || "").trim().toUpperCase();
8828
+ return state === "SUCCESS";
8829
+ });
8830
+ }
8831
+ function isCodeRabbitReviewThread(threadValue) {
8832
+ const comments = extractNestedArray(threadValue, ["comments", "nodes"]);
8833
+ if (!comments) {
8834
+ return false;
8835
+ }
8836
+ return comments.some(
8837
+ (comment) => extractNestedString(comment, ["author", "login"]).toLowerCase().startsWith("coderabbitai")
8838
+ );
8839
+ }
8840
+ function isReviewThreadResolved(threadValue) {
8841
+ if (!threadValue || typeof threadValue !== "object") {
8842
+ return false;
8843
+ }
8844
+ const record = threadValue;
8845
+ return record.isResolved === true || record.isOutdated === true;
8846
+ }
8847
+ function hasCodeRabbitActionableReview(reviewsValue) {
8848
+ if (!Array.isArray(reviewsValue)) {
8849
+ return false;
8850
+ }
8851
+ return reviewsValue.some((entry) => {
8852
+ if (!entry || typeof entry !== "object") return false;
8853
+ const authorLogin = extractNestedString(entry, ["author", "login"]).toLowerCase();
8854
+ if (authorLogin !== "coderabbitai") return false;
8855
+ const state = String(entry.state || "").trim().toUpperCase();
8856
+ if (state === "CHANGES_REQUESTED") return true;
8857
+ if (state !== "COMMENTED") return false;
8858
+ const body = String(entry.body || "");
8859
+ const actionableMatch = body.match(/Actionable comments posted:\s*(\d+)/i);
8860
+ return actionableMatch ? Number(actionableMatch[1]) > 0 : false;
8861
+ });
8862
+ }
8863
+ function extractNestedArray(value, pathSegments) {
8864
+ let current = value;
8865
+ for (const segment of pathSegments) {
8866
+ if (!current || typeof current !== "object") {
8867
+ return null;
8868
+ }
8869
+ current = current[segment];
8870
+ }
8871
+ return Array.isArray(current) ? current : null;
8872
+ }
8873
+ function findLatestCodeRabbitRateLimitCommentAt(commentsValue, headRefOid) {
8874
+ if (!Array.isArray(commentsValue)) {
8875
+ return null;
8876
+ }
8877
+ let latest = null;
8878
+ for (const entry of commentsValue) {
8879
+ if (!entry || typeof entry !== "object") continue;
8880
+ const authorLogin = extractNestedString(entry, ["author", "login"]).toLowerCase();
8881
+ if (authorLogin !== "coderabbitai") continue;
8882
+ const body = String(entry.body || "");
8883
+ if (!isCodeRabbitRateLimitBody(body, headRefOid)) continue;
8884
+ const createdAt = String(entry.createdAt || "").trim();
8885
+ if (!createdAt) continue;
8886
+ if (!latest || createdAt > latest) {
8887
+ latest = createdAt;
8888
+ }
8889
+ }
8890
+ return latest;
8891
+ }
8892
+ function findLatestCodeRabbitReviewAt(reviewsValue) {
8893
+ if (!Array.isArray(reviewsValue)) {
8894
+ return null;
8895
+ }
8896
+ let latest = null;
8897
+ for (const entry of reviewsValue) {
8898
+ if (!entry || typeof entry !== "object") continue;
8899
+ const authorLogin = extractNestedString(entry, ["author", "login"]).toLowerCase();
8900
+ if (authorLogin !== "coderabbitai") continue;
8901
+ const submittedAt = String(entry.submittedAt || "").trim();
8902
+ if (!submittedAt) continue;
8903
+ if (!latest || submittedAt > latest) {
8904
+ latest = submittedAt;
8905
+ }
8906
+ }
8907
+ return latest;
8908
+ }
8909
+ function findLatestCodeRabbitReviewedHead(reviewsValue) {
8910
+ if (!Array.isArray(reviewsValue)) {
8911
+ return null;
8912
+ }
8913
+ let latestReview = null;
8914
+ for (const entry of reviewsValue) {
8915
+ if (!entry || typeof entry !== "object") continue;
8916
+ const authorLogin = extractNestedString(entry, ["author", "login"]).toLowerCase();
8917
+ if (authorLogin !== "coderabbitai") continue;
8918
+ const submittedAt = String(entry.submittedAt || "").trim();
8919
+ if (!submittedAt) continue;
8920
+ const body = String(entry.body || "");
8921
+ const reviewedHead = extractReviewedHeadFromReviewBody(body);
8922
+ if (!latestReview || submittedAt > latestReview.submittedAt) {
8923
+ latestReview = { submittedAt, reviewedHead };
8924
+ }
8925
+ }
8926
+ return latestReview?.reviewedHead ?? null;
8927
+ }
8928
+ function isCodeRabbitRateLimitBody(body, headRefOid) {
8929
+ const normalized = body.toLowerCase();
8930
+ if (!normalized.includes("rate limited by coderabbit.ai") && !normalized.includes("rate limit exceeded")) {
8931
+ return false;
8932
+ }
8933
+ if (!headRefOid) {
8934
+ return true;
8935
+ }
8936
+ const shortHead = headRefOid.slice(0, 7);
8937
+ return normalized.includes(headRefOid) || normalized.includes(shortHead);
8938
+ }
8939
+ function extractReviewedHeadFromReviewBody(body) {
8940
+ const match = body.match(/between\s+[0-9a-f]{7,40}\s+and\s+([0-9a-f]{7,40})/i);
8941
+ if (!match) {
8942
+ return null;
8943
+ }
8944
+ return match[1].trim().toLowerCase();
8945
+ }
8946
+ function matchesCommitReference(headRefOid, reviewedHead) {
8947
+ const normalizedHead = headRefOid.trim().toLowerCase();
8948
+ const normalizedReviewedHead = reviewedHead.trim().toLowerCase();
8949
+ return normalizedHead === normalizedReviewedHead || normalizedHead.startsWith(normalizedReviewedHead) || normalizedReviewedHead.startsWith(normalizedHead);
8950
+ }
8951
+ function extractNestedString(value, pathSegments) {
8952
+ let current = value;
8953
+ for (const segment of pathSegments) {
8954
+ if (!current || typeof current !== "object") {
8955
+ return "";
8956
+ }
8957
+ current = current[segment];
8958
+ }
8959
+ return typeof current === "string" ? current.trim() : "";
8960
+ }
7296
8961
 
7297
8962
  // src/commands/workflow-stage.ts
7298
8963
  function workflowStageCommand(program2) {
@@ -7754,13 +9419,35 @@ var DEFAULT_MANAGED_DOC_FILES = [
7754
9419
  ".gitignore"
7755
9420
  ];
7756
9421
 
9422
+ // src/utils/commit-conventions.ts
9423
+ var PROJECT_COMMIT_PREFIX_PATTERN = /^(feat|fix|refactor|test|chore)\(#(\d+)\):\s+\S.+$/i;
9424
+ var DOCS_COMMIT_PREFIX_PATTERN = /^docs\(#(\d+)\):\s+\S.+$/i;
9425
+ function matchesProjectCommitConvention(message, issueNumber) {
9426
+ const normalized = String(message || "").trim();
9427
+ if (!normalized) return false;
9428
+ const match = normalized.match(PROJECT_COMMIT_PREFIX_PATTERN);
9429
+ if (!match) return false;
9430
+ return Number(match[2]) === issueNumber;
9431
+ }
9432
+ function matchesDocsCommitConvention(message, issueNumber) {
9433
+ const normalized = String(message || "").trim();
9434
+ if (!normalized) return false;
9435
+ const match = normalized.match(DOCS_COMMIT_PREFIX_PATTERN);
9436
+ if (!match) return false;
9437
+ return Number(match[1]) === issueNumber;
9438
+ }
9439
+
7757
9440
  // src/commands/commit-audit.ts
7758
9441
  var CANONICAL_FEATURE_DOC_PATTERN = /^features\/(?:[^/]+\/)?F\d{3,}[^/]*\/(spec|plan|tasks|decisions|issue|pr)\.md$/i;
7759
9442
  var FEATURE_DOC_CANDIDATE_PATTERN = /^features\/(?:[^/]+\/)?F\d{3,}[^/]*\/(.+)$/i;
7760
9443
  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) => {
9444
+ 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
9445
  try {
7763
- const payload = await collectCommitAudit(process.cwd(), options.gitRoot);
9446
+ const payload = await collectCommitAudit(
9447
+ process.cwd(),
9448
+ options.gitRoot,
9449
+ options.message
9450
+ );
7764
9451
  if (options.json) {
7765
9452
  console.log(JSON.stringify(payload, null, 2));
7766
9453
  return;
@@ -7795,7 +9482,7 @@ function commitAuditCommand(program2) {
7795
9482
  }
7796
9483
  });
7797
9484
  }
7798
- async function collectCommitAudit(cwd, gitRootOverride) {
9485
+ async function collectCommitAudit(cwd, gitRootOverride, commitMessage) {
7799
9486
  const config = await getConfig(cwd);
7800
9487
  if (!config) {
7801
9488
  throw createCliError("CONFIG_NOT_FOUND", "Config file not found. Run `init` first.");
@@ -7836,6 +9523,16 @@ async function collectCommitAudit(cwd, gitRootOverride) {
7836
9523
  stagedEntries,
7837
9524
  config.allowedDocsEntries
7838
9525
  );
9526
+ const commitMessageViolation = await collectCommitMessageViolation(
9527
+ cwd,
9528
+ config,
9529
+ repoRoot,
9530
+ stagedEntries,
9531
+ commitMessage
9532
+ );
9533
+ if (commitMessageViolation) {
9534
+ violations.push(commitMessageViolation);
9535
+ }
7839
9536
  if (violations.length === 0) {
7840
9537
  return {
7841
9538
  status: "ok",
@@ -7880,6 +9577,9 @@ function collectAllowedCommitRepoRoots(config, cwd) {
7880
9577
  if (projectRepoRoot) {
7881
9578
  allowed.add(path8.resolve(projectRepoRoot));
7882
9579
  }
9580
+ for (const worktreeRepoRoot of collectManagedWorktreeRepoRoots(config, projectRoot)) {
9581
+ allowed.add(path8.resolve(worktreeRepoRoot));
9582
+ }
7883
9583
  }
7884
9584
  return allowed;
7885
9585
  }
@@ -7889,6 +9589,28 @@ function collectAllowedCommitRepoRoots(config, cwd) {
7889
9589
  }
7890
9590
  return allowed;
7891
9591
  }
9592
+ function collectManagedWorktreeRepoRoots(config, projectRoot) {
9593
+ const managedRoot = resolveStandaloneManagedWorktreeRoot(config, projectRoot);
9594
+ if (!managedRoot) {
9595
+ return [];
9596
+ }
9597
+ const output = runGitCapture(["worktree", "list", "--porcelain"], projectRoot) || "";
9598
+ const roots = /* @__PURE__ */ new Set();
9599
+ for (const rawLine of output.split("\n")) {
9600
+ const line = rawLine.trim();
9601
+ if (!line.startsWith("worktree ")) continue;
9602
+ const worktreePath = path8.resolve(line.slice("worktree ".length).trim());
9603
+ if (isSameOrWithin2(path8.resolve(managedRoot), worktreePath)) {
9604
+ roots.add(worktreePath);
9605
+ }
9606
+ }
9607
+ return [...roots];
9608
+ }
9609
+ function isSameOrWithin2(parentDir, candidateDir) {
9610
+ const resolvedParent = path8.resolve(parentDir);
9611
+ const resolvedCandidate = path8.resolve(candidateDir);
9612
+ return resolvedParent === resolvedCandidate || resolvedCandidate.startsWith(`${resolvedParent}${path8.sep}`);
9613
+ }
7892
9614
  function parseStagedPaths(output) {
7893
9615
  const staged = /* @__PURE__ */ new Map();
7894
9616
  for (const rawLine of output.split("\n")) {
@@ -7905,10 +9627,10 @@ function parseStagedPaths(output) {
7905
9627
  staged.set(`path:${normalizeSlashes3(parts[1])}`, `${status}:path`);
7906
9628
  }
7907
9629
  return [...staged.entries()].map(([encodedPath, encodedStatus]) => {
7908
- const [role, path26] = encodedPath.split(":", 2);
9630
+ const [role, path27] = encodedPath.split(":", 2);
7909
9631
  const [status, entryRole] = encodedStatus.split(":", 2);
7910
9632
  return {
7911
- path: path26,
9633
+ path: path27,
7912
9634
  status,
7913
9635
  role: entryRole || role || "path"
7914
9636
  };
@@ -7967,6 +9689,9 @@ function collectCommitViolations(repoRoot, docsDir, stagedEntries, allowed) {
7967
9689
  function resolveReasonCode(violations) {
7968
9690
  const kinds = new Set(violations.map((entry) => entry.kind));
7969
9691
  if (kinds.size > 1) return "DOCS_COMMIT_POLICY_VIOLATION";
9692
+ if (kinds.has("commit_message_policy")) {
9693
+ return "COMMIT_MESSAGE_POLICY_VIOLATION";
9694
+ }
7970
9695
  if (kinds.has("unsupported_git_target")) return "UNSUPPORTED_GIT_TARGET";
7971
9696
  if (kinds.has("unmanaged_docs_entry")) return "UNMANAGED_DOCS_COMMIT";
7972
9697
  if (kinds.has("canonical_feature_doc_deletion")) {
@@ -7974,6 +9699,36 @@ function resolveReasonCode(violations) {
7974
9699
  }
7975
9700
  return "NON_CANONICAL_FEATURE_DOC_COMMIT";
7976
9701
  }
9702
+ async function collectCommitMessageViolation(cwd, config, repoRoot, stagedEntries, commitMessage) {
9703
+ const normalizedMessage = String(commitMessage || "").trim();
9704
+ if (!normalizedMessage) {
9705
+ return null;
9706
+ }
9707
+ const selection = await resolveFeatureSelection(cwd);
9708
+ if (selection.status !== "selected" || !selection.matchedFeature?.issueNumber) {
9709
+ return null;
9710
+ }
9711
+ const issueNumber = selection.matchedFeature.issueNumber;
9712
+ const docsRepoRoot = runGitCapture(["rev-parse", "--show-toplevel"], config.docsDir);
9713
+ const normalizedRepoRoot = path8.resolve(repoRoot);
9714
+ const normalizedDocsRepoRoot = docsRepoRoot ? path8.resolve(docsRepoRoot) : null;
9715
+ const docsOnlyCommit = stagedEntries.length > 0 && stagedEntries.every((entry) => {
9716
+ const absolutePath = path8.resolve(repoRoot, entry.path);
9717
+ const relativeToDocs = normalizeSlashes3(path8.relative(config.docsDir, absolutePath));
9718
+ return !!relativeToDocs && relativeToDocs !== "" && !relativeToDocs.startsWith("..");
9719
+ });
9720
+ const isDocsCommit = !!normalizedDocsRepoRoot && normalizedDocsRepoRoot === normalizedRepoRoot && (config.docsRepo === "standalone" || docsOnlyCommit);
9721
+ const valid = isDocsCommit ? matchesDocsCommitConvention(normalizedMessage, issueNumber) : matchesProjectCommitConvention(normalizedMessage, issueNumber);
9722
+ if (valid) {
9723
+ return null;
9724
+ }
9725
+ const expected = isDocsCommit ? `docs(#${issueNumber}): ...` : `type(#${issueNumber}): ... (feat/fix/refactor/test/chore)`;
9726
+ return {
9727
+ path: "(commit message)",
9728
+ kind: "commit_message_policy",
9729
+ detail: `Commit subject must follow the issue-scoped convention for this feature. Expected ${expected}, received "${normalizedMessage}".`
9730
+ };
9731
+ }
7977
9732
  function normalizeEntryName(value) {
7978
9733
  return value.trim().toLowerCase();
7979
9734
  }
@@ -8150,6 +9905,8 @@ function configureRootCommandSurface() {
8150
9905
  ["init", "Docs Schema Commands:"],
8151
9906
  ["idea", "Docs Schema Commands:"],
8152
9907
  ["feature", "Docs Schema Commands:"],
9908
+ ["task", "Docs Schema Commands:"],
9909
+ ["decision", "Docs Schema Commands:"],
8153
9910
  ["docs", "Workflow Policy Commands:"],
8154
9911
  ["detect", "Workflow Policy Commands:"],
8155
9912
  ["github", "Workflow Policy Commands:"],
@@ -8179,6 +9936,8 @@ updateCommand(program);
8179
9936
  configCommand(program);
8180
9937
  githubCommand(program);
8181
9938
  docsCommand(program);
9939
+ taskCommand(program);
9940
+ decisionCommand(program);
8182
9941
  detectCommand(program);
8183
9942
  workflowStageCommand(program);
8184
9943
  integrationsCommand(program);