lee-spec-kit 0.8.0 → 0.8.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { hasLeeSpecKitCodexBootstrap } from './chunk-RYSDBL6X.js';
2
+ import { hasLeeSpecKitCodexBootstrap } from './chunk-LYFRLOFQ.js';
3
3
  import { runGitCapture, runGitOrThrow } from './chunk-GR7JQBWF.js';
4
4
  import { __dirname as __dirname$1 } from './chunk-7V7RMGEU.js';
5
5
  import { program } from 'commander';
@@ -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
 
@@ -1282,17 +1282,30 @@ On session start or after context compression/reset:
1282
1282
  3. Read any unread \`requiredDocs[*].command\` from that output
1283
1283
  4. Cache built-in docs per session and only re-read them when the user explicitly asks for a policy refresh, \`npx lee-spec-kit update\` changed the policy, or the session restarted
1284
1284
 
1285
- Before implementing or editing:
1285
+ Before taking the next workflow step:
1286
1286
 
1287
1287
  1. Confirm the active feature from the request, docs tree, issue/PR context, or the most recently active feature folder
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
- 4. Keep docs and code synchronized; if code changes materially, update the active feature docs in the same turn before stopping
1291
- 5. 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
1290
+ 4. Run \`npx lee-spec-kit workflow-stage <feature-ref> --json\` and follow only the returned \`nextAction\`
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
1295
+ - issue preparation / issue creation
1296
+ - branch creation
1297
+ - task commit checkpoints after each completed task
1298
+ 8. In standalone mode, keep the docs repo on its docs branch and do not create feature branches or worktrees there
1299
+ 9. In standalone mode, use the project repo through its managed feature worktree under the shared workspace \`.worktrees/\` root instead of checking the feature branch out in the main project repo
1300
+ 10. Keep docs and code synchronized; if code changes materially, update the active feature docs in the same turn before stopping
1301
+ 11. When docs are synced to code, refresh an explicit marker like \`<!-- lee-spec-kit:workflow-sync 2026-04-16T12:34:56.789Z -->\` in the active feature docs (prefer \`tasks.md\` or \`decisions.md\`) so \`workflow-audit\` can prove the sync happened after the latest code change
1292
1302
 
1293
1303
  Approval and remote actions:
1294
1304
 
1295
1305
  - Ask the user for approval only at documented workflow approval boundaries or before remote/destructive actions
1306
+ - If \`workflow-stage --json\` reports \`approvalRequired === true\`, stop at that boundary and ask the user before proceeding
1307
+ - If \`workflow-stage --json\` returns labeled \`actionOptions\` at any approval boundary, keep the same option labels and exact \`reply\` tokens in the user prompt and do not improvise different reply formats
1308
+ - If \`workflow-stage --json\` reports \`nextAction.category === "task_commit"\`, make the docs commit and project commit for the just-finished task before starting the next task or moving to the next stage
1296
1309
  - Before \`git commit\`, prefer \`npx lee-spec-kit commit-audit --json\` when hooks or manual checks need commit-time docs path enforcement
1297
1310
  - Before remote GitHub actions, share the plan or artifact being sent
1298
1311
  - Respect repo policy from docs and config first; hooks only enforce guardrails and continuation checks
@@ -1424,6 +1437,38 @@ function resolveStandaloneProjectRoots(config, component) {
1424
1437
  function resolveGitTopLevelOrNull(cwd) {
1425
1438
  return runGitCapture(["rev-parse", "--show-toplevel"], cwd) || null;
1426
1439
  }
1440
+ function normalizeBranchNameForWorktree(branchName) {
1441
+ return branchName.trim().replace(/[\\/]/g, "-");
1442
+ }
1443
+ function resolveStandaloneManagedWorktreeRoot(config, projectRoot) {
1444
+ if (config.docsRepo !== "standalone") return null;
1445
+ const workspaceRoot = resolveConfiguredStandaloneWorkspaceRoot(config);
1446
+ if (!workspaceRoot) return null;
1447
+ return path8.resolve(
1448
+ workspaceRoot,
1449
+ ".worktrees",
1450
+ path8.basename(path8.resolve(projectRoot))
1451
+ );
1452
+ }
1453
+ function resolveManagedWorktreePath(config, projectRoot, branchName) {
1454
+ const standaloneRoot = resolveStandaloneManagedWorktreeRoot(config, projectRoot);
1455
+ if (standaloneRoot) {
1456
+ return path8.resolve(
1457
+ standaloneRoot,
1458
+ normalizeBranchNameForWorktree(branchName)
1459
+ );
1460
+ }
1461
+ return path8.resolve(
1462
+ path8.resolve(projectRoot),
1463
+ ".worktrees",
1464
+ normalizeBranchNameForWorktree(branchName)
1465
+ );
1466
+ }
1467
+ function buildManagedWorktreeEnvLinkCommand(projectRoot, worktreePath) {
1468
+ const sourceEnvPath = path8.resolve(projectRoot, ".env");
1469
+ const targetEnvPath = path8.resolve(worktreePath, ".env");
1470
+ return `if [ -f "${sourceEnvPath}" ] && [ ! -e "${targetEnvPath}" ] && [ ! -L "${targetEnvPath}" ]; then ln -s "${sourceEnvPath}" "${targetEnvPath}"; fi`;
1471
+ }
1427
1472
 
1428
1473
  // src/utils/init/options.ts
1429
1474
  function parseStandaloneMultiProjectRootJson(raw) {
@@ -2416,7 +2461,7 @@ async function runInit(options) {
2416
2461
  workflow: {
2417
2462
  preset: workflowMode,
2418
2463
  mode: workflowMode,
2419
- requireWorktree: false,
2464
+ requireWorktree: docsRepo === "standalone",
2420
2465
  codeDirtyScope: "auto",
2421
2466
  taskCommitGate: "warn",
2422
2467
  auto: {
@@ -2476,16 +2521,10 @@ async function runInit(options) {
2476
2521
  "Standalone workspace root could not be resolved. Re-run init from the shared workspace root above the docs directory."
2477
2522
  );
2478
2523
  }
2479
- await upsertLeeSpecKitAgentsMd(path8.join(targetDir, "AGENTS.md"), {
2524
+ await upsertLeeSpecKitAgentsMd(path8.join(standaloneWorkspaceRoot, "AGENTS.md"), {
2480
2525
  lang,
2481
2526
  docsRepo
2482
2527
  });
2483
- if (standaloneWorkspaceRoot !== path8.resolve(targetDir)) {
2484
- await upsertLeeSpecKitAgentsMd(path8.join(standaloneWorkspaceRoot, "AGENTS.md"), {
2485
- lang,
2486
- docsRepo
2487
- });
2488
- }
2489
2528
  }
2490
2529
  } catch {
2491
2530
  }
@@ -2550,7 +2589,7 @@ async function initGit(cwd, targetDir, docsRepo, lang, pushDocs, docsRemote, ext
2550
2589
  }
2551
2590
  };
2552
2591
  const toGitPath = (input) => input.replace(/\\/g, "/").replace(/^\.\//, "");
2553
- const toRepoRelativePath = (workdir, relativePath2) => {
2592
+ const toRepoRelativePath2 = (workdir, relativePath2) => {
2554
2593
  if (relativePath2 === ".") return ".";
2555
2594
  try {
2556
2595
  const prefix = runGitOrThrow(["rev-parse", "--show-prefix"], workdir, {
@@ -2581,7 +2620,7 @@ async function initGit(cwd, targetDir, docsRepo, lang, pushDocs, docsRemote, ext
2581
2620
  return;
2582
2621
  }
2583
2622
  if (relativePath !== "." && isPathIgnored(gitWorkdir, relativePath)) {
2584
- const repoRelativePath = toRepoRelativePath(gitWorkdir, relativePath);
2623
+ const repoRelativePath = toRepoRelativePath2(gitWorkdir, relativePath);
2585
2624
  console.log(
2586
2625
  chalk.yellow(
2587
2626
  tr(lang, "cli", "init.warn.docsPathIgnoredSkipCommit", {
@@ -3257,7 +3296,9 @@ function hasOwnKey(value, key) {
3257
3296
  }
3258
3297
  function isLegacyGeneratedApprovalConfig(approval) {
3259
3298
  const mode = typeof approval.mode === "string" ? approval.mode : "";
3260
- if (mode && mode !== "category" && mode !== "steps") return false;
3299
+ if (mode && mode !== "category" && mode !== "steps" && mode !== "builtin") {
3300
+ return false;
3301
+ }
3261
3302
  const overrideKeys = [
3262
3303
  "default",
3263
3304
  "requireCheckSteps",
@@ -3438,7 +3479,6 @@ async function collectAgentsMdTargets(cwd, config) {
3438
3479
  targets.add(path8.join(repoRoot, "AGENTS.md"));
3439
3480
  return [...targets];
3440
3481
  }
3441
- targets.add(path8.join(config.docsDir, "AGENTS.md"));
3442
3482
  const workspaceRoot = resolveConfiguredStandaloneWorkspaceRoot(config);
3443
3483
  if (!workspaceRoot) {
3444
3484
  throw createCliError(
@@ -3446,9 +3486,7 @@ async function collectAgentsMdTargets(cwd, config) {
3446
3486
  "Standalone workspaceRoot is missing or invalid. Run `npx lee-spec-kit update --agents-md` from the shared workspace root to migrate this project."
3447
3487
  );
3448
3488
  }
3449
- if (workspaceRoot !== path8.resolve(config.docsDir)) {
3450
- targets.add(path8.join(workspaceRoot, "AGENTS.md"));
3451
- }
3489
+ targets.add(path8.join(workspaceRoot, "AGENTS.md"));
3452
3490
  return [...targets];
3453
3491
  }
3454
3492
  function isPlainObject(value) {
@@ -3516,7 +3554,16 @@ async function backfillMissingConfigDefaults(cwd, docsDir) {
3516
3554
  const inferredPreset = workflow.mode === "local" ? "local" : "github";
3517
3555
  setIfMissing(workflow, "preset", inferredPreset, "workflow.preset");
3518
3556
  setIfMissing(workflow, "mode", "github", "workflow.mode");
3519
- setIfMissing(workflow, "requireWorktree", false, "workflow.requireWorktree");
3557
+ setIfMissing(
3558
+ workflow,
3559
+ "requireWorktree",
3560
+ raw.docsRepo === "standalone",
3561
+ "workflow.requireWorktree"
3562
+ );
3563
+ if (raw.docsRepo === "standalone" && workflow.requireWorktree !== true) {
3564
+ workflow.requireWorktree = true;
3565
+ changedPaths.push("workflow.requireWorktree");
3566
+ }
3520
3567
  setIfMissing(workflow, "codeDirtyScope", "auto", "workflow.codeDirtyScope");
3521
3568
  setIfMissing(workflow, "taskCommitGate", "warn", "workflow.taskCommitGate");
3522
3569
  if (!isPlainObject(workflow.auto)) {
@@ -3929,6 +3976,7 @@ function runGhJson(args, cwd, messages) {
3929
3976
  );
3930
3977
  }
3931
3978
  }
3979
+ var BRANCH_LABELS = ["Branch", "\uBE0C\uB79C\uCE58"];
3932
3980
  function normalizeComponent(value) {
3933
3981
  const component = (value || "").trim().toLowerCase();
3934
3982
  return component || void 0;
@@ -3961,6 +4009,44 @@ function resolveProjectGitCwd(cwd, config, component) {
3961
4009
  }
3962
4010
  return resolveGitTopLevelOrNull(cwd) || resolveGitTopLevelOrNull(config.docsDir) || cwd;
3963
4011
  }
4012
+ function resolveProjectRootFromGitCwd(projectGitCwd) {
4013
+ return resolveGitTopLevelOrNull(projectGitCwd) || path8.resolve(projectGitCwd);
4014
+ }
4015
+ async function resolveExistingManagedWorktreePath(config, projectGitCwd, slug, folderName, issueNumber, branchName) {
4016
+ const projectRoot = resolveProjectRootFromGitCwd(projectGitCwd);
4017
+ const branchCandidates = [
4018
+ branchName,
4019
+ issueNumber ? `feat/${issueNumber}-${slug}` : null,
4020
+ issueNumber ? `feat/${issueNumber}-${folderName}` : null
4021
+ ].filter((candidate) => !!candidate);
4022
+ const candidates = [...new Set(branchCandidates)].map(
4023
+ (candidate) => resolveManagedWorktreePath(config, projectRoot, candidate)
4024
+ );
4025
+ for (const candidate of candidates) {
4026
+ if (await fs.pathExists(candidate)) {
4027
+ return candidate;
4028
+ }
4029
+ }
4030
+ return null;
4031
+ }
4032
+ function extractFieldValue(content, labels) {
4033
+ for (const label of Array.isArray(labels) ? labels : [labels]) {
4034
+ const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
4035
+ const match = content.match(
4036
+ new RegExp(`^\\s*-\\s*\\*\\*${escaped}\\*\\*:\\s*(.*?)\\s*$`, "mi")
4037
+ );
4038
+ if (!match) continue;
4039
+ const value = match[1].trim();
4040
+ if (value) return value;
4041
+ }
4042
+ return null;
4043
+ }
4044
+ function sanitizeMetadataValue(value) {
4045
+ if (!value) return null;
4046
+ const trimmed = value.trim().replace(/^`(.+)`$/, "$1");
4047
+ if (!trimmed || trimmed === "-") return null;
4048
+ return trimmed;
4049
+ }
3964
4050
  function toFeaturePathFromDocs(projectType, component, folderName) {
3965
4051
  return projectType === "multi" && component !== "single" ? path8.join("features", component, folderName) : path8.join("features", folderName);
3966
4052
  }
@@ -3973,6 +4059,12 @@ async function extractIssueNumber(featureDir) {
3973
4059
  const parsed = Number(match[1]);
3974
4060
  return Number.isFinite(parsed) ? parsed : void 0;
3975
4061
  }
4062
+ async function extractBranchName(featureDir) {
4063
+ const tasksPath = path8.join(featureDir, "tasks.md");
4064
+ if (!await fs.pathExists(tasksPath)) return null;
4065
+ const content = await fs.readFile(tasksPath, "utf-8");
4066
+ return sanitizeMetadataValue(extractFieldValue(content, BRANCH_LABELS));
4067
+ }
3976
4068
  async function listResolvedFeatures(cwd, config, component) {
3977
4069
  const refs = await listLeeSpecFeatures(cwd);
3978
4070
  const normalizedComponent = normalizeComponent(component);
@@ -3986,6 +4078,17 @@ async function listResolvedFeatures(cwd, config, component) {
3986
4078
  ref.folderName
3987
4079
  );
3988
4080
  const featureDir = path8.join(config.docsDir, featurePathFromDocs);
4081
+ const issueNumber = await extractIssueNumber(featureDir);
4082
+ const branchName = await extractBranchName(featureDir);
4083
+ const projectGitCwdBase = resolveProjectGitCwd(cwd, config, type);
4084
+ const worktreeProjectGitCwd = config.docsRepo === "standalone" && (issueNumber || branchName) ? await resolveExistingManagedWorktreePath(
4085
+ config,
4086
+ projectGitCwdBase,
4087
+ ref.slug,
4088
+ ref.folderName,
4089
+ issueNumber,
4090
+ branchName
4091
+ ) : null;
3989
4092
  return {
3990
4093
  id: ref.id || ref.folderName.split("-")[0] || "",
3991
4094
  slug: ref.slug,
@@ -3997,9 +4100,9 @@ async function listResolvedFeatures(cwd, config, component) {
3997
4100
  },
3998
4101
  git: {
3999
4102
  docsGitCwd: config.docsDir,
4000
- projectGitCwd: resolveProjectGitCwd(cwd, config, type)
4103
+ projectGitCwd: worktreeProjectGitCwd || projectGitCwdBase
4001
4104
  },
4002
- issueNumber: await extractIssueNumber(featureDir)
4105
+ issueNumber
4003
4106
  };
4004
4107
  })
4005
4108
  );
@@ -4239,6 +4342,15 @@ function sanitizeDraftTitleValue(raw) {
4239
4342
  const normalized = value.replace(/`/g, "").replace(/\*\*(.*?)\*\*/g, "$1").replace(/\[(.*?)\]\((.*?)\)/g, "$1").replace(/\s+/g, " ").trim();
4240
4343
  return normalized || void 0;
4241
4344
  }
4345
+ function isPlaceholderWorkflowDraftTitle(raw, feature) {
4346
+ const normalized = sanitizeDraftTitleValue(raw);
4347
+ if (!normalized) return true;
4348
+ const lowered = normalized.trim().toLowerCase();
4349
+ const placeholders = new Set(
4350
+ [feature.slug, feature.folderName].map((value) => (value || "").trim().toLowerCase()).filter(Boolean)
4351
+ );
4352
+ return placeholders.has(lowered);
4353
+ }
4242
4354
  function parseWorkflowDraftStatus(raw) {
4243
4355
  const value = (raw || "").trim();
4244
4356
  if (!value) return void 0;
@@ -4946,8 +5058,36 @@ function isOverviewMetadataLine(line, lang) {
4946
5058
  return keys.some((key) => cleaned.startsWith(`${key}:`));
4947
5059
  }
4948
5060
  function truncateIssueTitleSummary(input, maxLength = 72) {
4949
- if (input.length <= maxLength) return input;
4950
- return `${input.slice(0, maxLength - 3).trimEnd()}...`;
5061
+ const normalized = input.trim().replace(/\s+/g, " ");
5062
+ if (normalized.length <= maxLength) return normalized;
5063
+ const stripTrailingPunctuation = (value) => value.trim().replace(/[.!?。]+$/u, "").trim();
5064
+ const rewriteVerboseEnding = (value) => stripTrailingPunctuation(value).replace(/공통화하고/gu, "\uACF5\uD1B5\uD654\uC640").replace(/줄인다$/u, "\uAC10\uC18C").replace(/강화한다$/u, "\uAC15\uD654").replace(/개선한다$/u, "\uAC1C\uC120").replace(/복구한다$/u, "\uBCF5\uAD6C").replace(/정리한다$/u, "\uC815\uB9AC").replace(/도입한다$/u, "\uB3C4\uC785").replace(/구현한다$/u, "\uAD6C\uD604");
5065
+ const addCandidate = (bucket, seen2, value) => {
5066
+ const cleaned = stripTrailingPunctuation(value);
5067
+ if (!cleaned || seen2.has(cleaned)) return;
5068
+ seen2.add(cleaned);
5069
+ bucket.push(cleaned);
5070
+ };
5071
+ const candidates = [];
5072
+ const seen = /* @__PURE__ */ new Set();
5073
+ addCandidate(candidates, seen, normalized);
5074
+ const commaStripped = normalized.replace(/^[^,,]+[,,]\s*/u, "");
5075
+ addCandidate(candidates, seen, commaStripped);
5076
+ const purposeLeadStripped = normalized.replace(/^.+?(?:하도록|하기 위해),\s*/u, "");
5077
+ addCandidate(candidates, seen, purposeLeadStripped);
5078
+ for (const base of [...candidates]) {
5079
+ addCandidate(candidates, seen, rewriteVerboseEnding(base));
5080
+ }
5081
+ for (const base of [...candidates]) {
5082
+ for (const part of base.split(/[,:;,;]/u)) {
5083
+ addCandidate(candidates, seen, part);
5084
+ }
5085
+ }
5086
+ const fitting = candidates.filter((candidate) => candidate.length <= maxLength);
5087
+ if (fitting.length > 0) {
5088
+ return fitting.sort((left, right) => right.length - left.length)[0];
5089
+ }
5090
+ return stripTrailingPunctuation(normalized.slice(0, maxLength));
4951
5091
  }
4952
5092
  function resolveIssueTitleSummary(overview, feature, lang) {
4953
5093
  const candidates = overview.split("\n").map((line) => normalizeIssueTitleSummaryLine(line)).filter((line) => !!line).filter((line) => !isOverviewMetadataLine(line, lang));
@@ -5394,7 +5534,7 @@ function syncTasksIssueMetadata(tasksPath, issueNumber, lang) {
5394
5534
  }
5395
5535
  return { changed, path: tasksPath };
5396
5536
  }
5397
- function syncIssueDraftMetadata(issueDocPath, issueNumber) {
5537
+ function syncIssueDraftMetadata(issueDocPath, issueNumber, title) {
5398
5538
  if (!fs.existsSync(issueDocPath)) {
5399
5539
  return { changed: false, path: issueDocPath };
5400
5540
  }
@@ -5414,6 +5554,16 @@ function syncIssueDraftMetadata(issueDocPath, issueNumber) {
5414
5554
  next = inserted.content;
5415
5555
  changed = changed || inserted.changed;
5416
5556
  }
5557
+ const normalizedTitle = sanitizeDraftTitleValue(title);
5558
+ if (normalizedTitle) {
5559
+ const titleReplaced = replaceListField(
5560
+ next,
5561
+ ["Title", "\uC81C\uBAA9"],
5562
+ normalizedTitle
5563
+ );
5564
+ next = titleReplaced.content;
5565
+ changed = changed || titleReplaced.changed;
5566
+ }
5417
5567
  if (changed) {
5418
5568
  fs.writeFileSync(issueDocPath, next, "utf-8");
5419
5569
  }
@@ -5458,7 +5608,7 @@ function syncTasksPrMetadata(tasksPath, prUrl, nextStatus, lang) {
5458
5608
  }
5459
5609
  return { changed, path: tasksPath };
5460
5610
  }
5461
- function syncPrDraftMetadata(prDocPath, prUrl, nextStatus) {
5611
+ function syncPrDraftMetadata(prDocPath, prUrl, nextStatus, title) {
5462
5612
  if (!fs.existsSync(prDocPath)) {
5463
5613
  return { changed: false, path: prDocPath };
5464
5614
  }
@@ -5485,6 +5635,17 @@ function syncPrDraftMetadata(prDocPath, prUrl, nextStatus) {
5485
5635
  next = inserted.content;
5486
5636
  changed = changed || inserted.changed;
5487
5637
  }
5638
+ const normalizedTitle = sanitizeDraftTitleValue(title);
5639
+ if (normalizedTitle) {
5640
+ const titleReplaced = replaceListField(next, ["Title", "\uC81C\uBAA9"], normalizedTitle);
5641
+ next = titleReplaced.content;
5642
+ changed = changed || titleReplaced.changed;
5643
+ if (!titleReplaced.found) {
5644
+ const inserted = insertFieldInMetadataSection(next, "Title", normalizedTitle);
5645
+ next = inserted.content;
5646
+ changed = changed || inserted.changed;
5647
+ }
5648
+ }
5488
5649
  if (changed) {
5489
5650
  fs.writeFileSync(prDocPath, next, "utf-8");
5490
5651
  }
@@ -5779,7 +5940,10 @@ function githubCommand(program2) {
5779
5940
  await fs.writeFile(sanitizedBodyFile, body, "utf-8");
5780
5941
  bodyFile = sanitizedBodyFile;
5781
5942
  }
5782
- const title = options.title?.trim() || (preparedBody.source === "workflow-ready" ? preparedBody.draftMetadata?.title : void 0) || defaultTitle;
5943
+ const title = options.title?.trim() || (preparedBody.source === "workflow-ready" && !isPlaceholderWorkflowDraftTitle(
5944
+ preparedBody.draftMetadata?.title,
5945
+ feature
5946
+ ) ? preparedBody.draftMetadata?.title : void 0) || defaultTitle;
5783
5947
  const labels = parseLabels(
5784
5948
  optionLabels || (preparedBody.source === "workflow-ready" ? preparedBody.draftMetadata?.labels : void 0),
5785
5949
  config.lang
@@ -5830,7 +5994,8 @@ function githubCommand(program2) {
5830
5994
  );
5831
5995
  const draftSynced = syncIssueDraftMetadata(
5832
5996
  path8.join(config.docsDir, paths.issuePath),
5833
- syncedIssueNumber
5997
+ syncedIssueNumber,
5998
+ title
5834
5999
  );
5835
6000
  syncChanged = synced.changed || draftSynced.changed;
5836
6001
  }
@@ -6015,7 +6180,8 @@ function githubCommand(program2) {
6015
6180
  await fs.writeFile(sanitizedBodyFile, body, "utf-8");
6016
6181
  bodyFile = sanitizedBodyFile;
6017
6182
  }
6018
- const title = options.title?.trim() || (preparedBody.source === "workflow-ready" ? preparedBody.draftMetadata?.title : void 0) || defaultTitle;
6183
+ const requestedTitle = options.title?.trim() || (preparedBody.source === "workflow-ready" ? preparedBody.draftMetadata?.title : void 0) || "";
6184
+ let title = requestedTitle || defaultTitle;
6019
6185
  const labels = parseLabels(
6020
6186
  optionLabels || (preparedBody.source === "workflow-ready" ? preparedBody.draftMetadata?.labels : void 0),
6021
6187
  config.lang
@@ -6025,7 +6191,6 @@ function githubCommand(program2) {
6025
6191
  let prUrl = options.pr?.trim() || "";
6026
6192
  let mergedAttempts;
6027
6193
  let mergeAlreadyMerged;
6028
- const postMergeWarnings = [];
6029
6194
  let syncChanged = false;
6030
6195
  const pushDocsSync = shouldPushDocsSync(config);
6031
6196
  if (options.create) {
@@ -6038,11 +6203,18 @@ function githubCommand(program2) {
6038
6203
  feature.issueNumber ? String(feature.issueNumber) : void 0,
6039
6204
  config.lang
6040
6205
  );
6206
+ title = closingIssueNumber && closingIssueNumber.trim() ? defaultTitle : requestedTitle || defaultTitle;
6041
6207
  assertRemoteIssueExists(
6042
6208
  closingIssueNumber,
6043
6209
  projectGitCwd,
6044
6210
  config.lang
6045
6211
  );
6212
+ if (closingIssueNumber && options.title?.trim() && options.title.trim() !== defaultTitle) {
6213
+ throw createCliError(
6214
+ "PRECONDITION_FAILED",
6215
+ `PR title must follow the existing convention: "${defaultTitle}".`
6216
+ );
6217
+ }
6046
6218
  const normalizedBody = ensureIssueClosingLine(
6047
6219
  body,
6048
6220
  closingIssueNumber
@@ -6118,7 +6290,8 @@ function githubCommand(program2) {
6118
6290
  const syncedDraft = syncPrDraftMetadata(
6119
6291
  path8.join(config.docsDir, paths.prPath),
6120
6292
  prUrl,
6121
- "Review"
6293
+ "Review",
6294
+ title
6122
6295
  );
6123
6296
  syncChanged = syncedTasks.changed || syncedDraft.changed;
6124
6297
  const shouldCommitSync = !!options.commitSync || !!options.merge;
@@ -6188,34 +6361,6 @@ function githubCommand(program2) {
6188
6361
  );
6189
6362
  }
6190
6363
  }
6191
- const mergeBaseBranch = (merged.baseRefName || baseBranch || "main").trim();
6192
- const checkoutResult = runProcess(
6193
- "git",
6194
- ["checkout", mergeBaseBranch],
6195
- projectGitCwd
6196
- );
6197
- if (checkoutResult.code !== 0) {
6198
- postMergeWarnings.push(
6199
- tg(config.lang, "postMergeCheckoutWarning", {
6200
- base: mergeBaseBranch,
6201
- detail: (checkoutResult.stderr || checkoutResult.stdout || "").trim()
6202
- })
6203
- );
6204
- } else {
6205
- const pullResult = runProcess(
6206
- "git",
6207
- ["pull", "--rebase", "origin", mergeBaseBranch],
6208
- projectGitCwd
6209
- );
6210
- if (pullResult.code !== 0) {
6211
- postMergeWarnings.push(
6212
- tg(config.lang, "postMergePullWarning", {
6213
- base: mergeBaseBranch,
6214
- detail: (pullResult.stderr || pullResult.stdout || "").trim()
6215
- })
6216
- );
6217
- }
6218
- }
6219
6364
  }
6220
6365
  if (options.json) {
6221
6366
  console.log(
@@ -6237,8 +6382,7 @@ function githubCommand(program2) {
6237
6382
  syncChanged,
6238
6383
  merged: !!options.merge,
6239
6384
  mergeAttempts: mergedAttempts,
6240
- mergeAlreadyMerged,
6241
- postMergeWarnings: postMergeWarnings.length > 0 ? postMergeWarnings : void 0
6385
+ mergeAlreadyMerged
6242
6386
  },
6243
6387
  null,
6244
6388
  2
@@ -6286,9 +6430,6 @@ function githubCommand(program2) {
6286
6430
  chalk.yellow(tg(config.lang, "prAlreadyMergedNotice"))
6287
6431
  );
6288
6432
  }
6289
- for (const warning of postMergeWarnings) {
6290
- console.log(chalk.yellow(`\u26A0\uFE0F ${warning}`));
6291
- }
6292
6433
  } else if (!options.create) {
6293
6434
  console.log(
6294
6435
  chalk.blue(tg(config.lang, "prTemplateGenerated"))
@@ -6738,7 +6879,7 @@ function registerCodexIntegration(parent) {
6738
6879
  getCodexConfigPath,
6739
6880
  removeLeeSpecKitCodexBootstrap,
6740
6881
  upsertLeeSpecKitCodexBootstrap
6741
- } = await import('./bootstrap-ZIJP7O72.js');
6882
+ } = await import('./bootstrap-G37N6RGB.js');
6742
6883
  const filePath = getCodexConfigPath();
6743
6884
  if (options.remove) {
6744
6885
  const result2 = await removeLeeSpecKitCodexBootstrap(filePath);
@@ -6783,7 +6924,7 @@ function registerCodexHooksIntegration(parent) {
6783
6924
  removeLeeSpecKitCodexHooks,
6784
6925
  resolveCodexHooksRepoRoot,
6785
6926
  upsertLeeSpecKitCodexHooks
6786
- } = await import('./hooks-IP6FICAV.js');
6927
+ } = await import('./hooks-B5UIIZYN.js');
6787
6928
  const repoRoot = config.docsRepo === "standalone" ? resolveConfiguredStandaloneWorkspaceRoot(config) : resolveCodexHooksRepoRoot(process.cwd());
6788
6929
  if (!repoRoot) {
6789
6930
  throw createCliError(
@@ -6818,146 +6959,1788 @@ function integrationsCommand(program2) {
6818
6959
  registerCodexIntegration(integrations);
6819
6960
  registerCodexHooksIntegration(integrations);
6820
6961
  }
6821
- var FEATURE_DOC_FILE_PATTERN = /^features\/(?:[^/]+\/)?F\d{3,}[^/]*\/(spec|plan|tasks|decisions|issue|pr)\.md$/i;
6822
- var CODE_FILE_PATTERN = /(^|\/)(Dockerfile|Makefile)$|\.(c|cc|cjs|cpp|cs|css|cts|go|h|hpp|html|java|js|json|jsx|kt|mjs|mts|php|py|rb|rs|scss|sh|sql|swift|ts|tsx|vue|yaml|yml|zsh)$/i;
6823
- var WORKFLOW_SYNC_MARKER_PATTERN = /<!--\s*lee-spec-kit:workflow-sync\s+([0-9]{4}-[0-9]{2}-[0-9]{2}T[^ ]+?)\s*-->/gi;
6824
- function workflowAuditCommand(program2) {
6825
- program2.command("workflow-audit").description("Validate whether code changes have been synchronized back into feature docs").option("--json", "Output JSON for hooks and agents").action(async (options) => {
6826
- try {
6827
- const payload = await collectWorkflowAudit(process.cwd());
6828
- if (options.json) {
6829
- console.log(JSON.stringify(payload, null, 2));
6830
- return;
6831
- }
6832
- console.log(`${payload.status}: ${payload.reasonCode}`);
6833
- } catch (error) {
6834
- const cliError = toCliError(error);
6835
- const payload = {
6836
- status: "error",
6837
- reasonCode: "UNEXPECTED_ERROR",
6838
- docsDir: null,
6839
- activeFeatureRef: null,
6840
- changedCodePaths: [],
6841
- changedFeatureDocPaths: [],
6842
- latestCodeChangeAt: null,
6843
- latestFeatureDocSyncAt: null
6844
- };
6845
- if (options.json) {
6846
- console.log(
6847
- JSON.stringify(
6848
- {
6849
- ...payload,
6850
- error: cliError.message
6851
- },
6852
- null,
6853
- 2
6854
- )
6855
- );
6856
- return;
6857
- }
6858
- process.stderr.write(`[${cliError.code}] ${cliError.message}
6859
- `);
6860
- process.exitCode = 1;
6962
+ var LEGACY_STEP_BY_ACTION = {
6963
+ spec_write: 2,
6964
+ spec_approve: 3,
6965
+ plan_write: 4,
6966
+ plan_approve: 5,
6967
+ tasks_write: 6,
6968
+ tasks_approve: 6,
6969
+ issue_prepare: 8,
6970
+ issue_create: 8,
6971
+ branch_create: 9,
6972
+ task_execute: 10,
6973
+ implementation_approve: 10,
6974
+ pre_pr_review: 12,
6975
+ pr_prepare: 13,
6976
+ pr_create: 13,
6977
+ code_review: 14,
6978
+ pr_merge: 14
6979
+ };
6980
+ var DOC_STATUS_LABELS = ["Doc Status", "\uBB38\uC11C \uC0C1\uD0DC"];
6981
+ var ISSUE_LABELS = ["Issue", "Issue Number", "\uC774\uC288", "\uC774\uC288 \uBC88\uD638"];
6982
+ var BRANCH_LABELS2 = ["Branch", "\uBE0C\uB79C\uCE58"];
6983
+ var PR_LABELS = ["PR", "Pull Request"];
6984
+ var PR_STATUS_LABELS = ["PR Status", "PR \uC0C1\uD0DC"];
6985
+ var PRE_PR_REVIEW_LABELS = ["Pre-PR Review", "PR \uC804 \uB9AC\uBDF0"];
6986
+ var PRE_PR_EVIDENCE_LABELS = ["Pre-PR Evidence", "PR \uC804 \uB9AC\uBDF0 Evidence"];
6987
+ var PRE_PR_DECISION_LABELS = ["Pre-PR Decision", "PR \uC804 \uB9AC\uBDF0 Decision"];
6988
+ function resolveWorkflowRequirements(config) {
6989
+ const workflow = config.workflow || {};
6990
+ const workflowMode = workflow.mode || workflow.preset || "github";
6991
+ const isLocalWorkflow = workflowMode === "local";
6992
+ return {
6993
+ requireIssue: workflow.requireIssue ?? !isLocalWorkflow,
6994
+ requireBranch: workflow.requireBranch ?? true,
6995
+ requireWorktree: config.docsRepo === "standalone" ? true : workflow.requireWorktree ?? false,
6996
+ requirePr: workflow.requirePr ?? !isLocalWorkflow,
6997
+ requireReview: workflow.requireReview ?? !isLocalWorkflow,
6998
+ requireMerge: workflow.requireMerge ?? !isLocalWorkflow,
6999
+ prePrReviewEnabled: workflow.prePrReview?.enabled ?? !isLocalWorkflow
7000
+ };
7001
+ }
7002
+ function parseApprovalStatus(raw) {
7003
+ const value = (raw || "").trim().toLowerCase();
7004
+ if (value === "draft") return "draft";
7005
+ if (value === "review") return "review";
7006
+ if (value === "approved") return "approved";
7007
+ return null;
7008
+ }
7009
+ function extractFieldValue2(content, labels) {
7010
+ for (const label of Array.isArray(labels) ? labels : [labels]) {
7011
+ const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
7012
+ const match = content.match(
7013
+ new RegExp(`^\\s*-\\s*\\*\\*${escaped}\\*\\*:\\s*(.*?)\\s*$`, "mi")
7014
+ );
7015
+ if (!match) continue;
7016
+ const value = match[1].trim();
7017
+ if (value) return value;
7018
+ }
7019
+ return null;
7020
+ }
7021
+ function parseMarkdownCheckbox(line) {
7022
+ const match = line.match(/^\s*-\s*\[([ xX])\]\s+/);
7023
+ if (!match) return null;
7024
+ return match[1].toLowerCase() === "x";
7025
+ }
7026
+ function withoutFencedCodeBlocks(content) {
7027
+ const lines = [];
7028
+ let inFence = false;
7029
+ for (const line of content.split("\n")) {
7030
+ if (/^\s*```/.test(line)) {
7031
+ inFence = !inFence;
7032
+ continue;
6861
7033
  }
6862
- });
7034
+ if (!inFence) {
7035
+ lines.push(line);
7036
+ }
7037
+ }
7038
+ return lines;
6863
7039
  }
6864
- async function collectWorkflowAudit(cwd) {
6865
- const config = await getConfig(cwd);
6866
- if (!config) {
6867
- throw createCliError("CONFIG_NOT_FOUND", "Config file not found. Run `init` first.");
7040
+ function parseTasksDoc(content) {
7041
+ const issueRaw = extractFieldValue2(content, ISSUE_LABELS);
7042
+ const issueNumberMatch = issueRaw?.match(/^#(\d+)$/);
7043
+ const issueNumber = issueNumberMatch ? Number(issueNumberMatch[1]) : null;
7044
+ const branchRaw = extractFieldValue2(content, BRANCH_LABELS2);
7045
+ const prRaw = extractFieldValue2(content, PR_LABELS);
7046
+ const prePrDecision = extractFieldValue2(content, PRE_PR_DECISION_LABELS);
7047
+ const tasks = [];
7048
+ const nonCodeLines = withoutFencedCodeBlocks(content);
7049
+ for (const line of nonCodeLines) {
7050
+ const match = line.match(
7051
+ /^\s*-\s*\[(TODO|DOING|DONE|REVIEW)\](?:\[[^\]]+\])*\s+(.+?)\s*$/i
7052
+ );
7053
+ if (!match) continue;
7054
+ tasks.push({
7055
+ raw: line,
7056
+ status: match[1].toUpperCase(),
7057
+ title: match[2].trim()
7058
+ });
6868
7059
  }
6869
- const activeFeature = await resolveActiveFeature(cwd);
6870
- const activeFeatureRef = activeFeature?.folderName ?? null;
6871
- const codeRootResolution = resolveCodeRepoRoots(cwd, config, activeFeature);
6872
- const codeRoots = codeRootResolution.codeRoots;
6873
- if (codeRootResolution.errorReasonCode) {
7060
+ const allTasksChecked = nonCodeLines.some(
7061
+ (line) => /(All tasks are|모든 태스크가)/i.test(line) && parseMarkdownCheckbox(line) === true
7062
+ );
7063
+ const testsChecked = nonCodeLines.some(
7064
+ (line) => /(Tests executed and passing|테스트 실행 및 통과)/i.test(line) && parseMarkdownCheckbox(line) === true
7065
+ );
7066
+ const finalOutcomeChecked = nonCodeLines.some(
7067
+ (line) => /(Final outcome shared and any required user confirmation recorded|Final user approval|최종 결과를 공유했고, 필요한 사용자 확인을 문서화된 workflow checkpoint 기준으로 기록함)/i.test(
7068
+ line
7069
+ ) && parseMarkdownCheckbox(line) === true
7070
+ );
7071
+ const prStatus = (() => {
7072
+ const value = (extractFieldValue2(content, PR_STATUS_LABELS) || "").trim().toLowerCase();
7073
+ if (value === "review") return "review";
7074
+ if (value === "approved") return "approved";
7075
+ return null;
7076
+ })();
7077
+ const prePrReviewStatus = (() => {
7078
+ const value = (extractFieldValue2(content, PRE_PR_REVIEW_LABELS) || "").trim().toLowerCase();
7079
+ if (value === "pending") return "pending";
7080
+ if (value === "running") return "running";
7081
+ if (value === "done") return "done";
7082
+ return null;
7083
+ })();
7084
+ const prePrDecisionOutcome = (() => {
7085
+ const value = (prePrDecision || "").trim().toLowerCase();
7086
+ const match = value.match(/\b(approve|changes_requested|blocked)\b/);
7087
+ return match?.[1] || null;
7088
+ })();
7089
+ return {
7090
+ docStatus: parseApprovalStatus(
7091
+ extractFieldValue2(content, DOC_STATUS_LABELS) || void 0
7092
+ ),
7093
+ issueNumber,
7094
+ branch: sanitizeMetadataValue2(branchRaw),
7095
+ prLink: sanitizeMetadataValue2(prRaw),
7096
+ prStatus,
7097
+ prePrReviewStatus,
7098
+ prePrEvidence: sanitizeMetadataValue2(
7099
+ extractFieldValue2(content, PRE_PR_EVIDENCE_LABELS)
7100
+ ),
7101
+ prePrDecision: sanitizeMetadataValue2(prePrDecision),
7102
+ prePrDecisionOutcome,
7103
+ tasks,
7104
+ completion: {
7105
+ allTasksChecked,
7106
+ testsChecked,
7107
+ finalOutcomeChecked
7108
+ }
7109
+ };
7110
+ }
7111
+ function sanitizeMetadataValue2(value) {
7112
+ if (!value) return null;
7113
+ const trimmed = value.trim().replace(/^`(.+)`$/, "$1");
7114
+ if (!trimmed || trimmed === "-") return null;
7115
+ return trimmed;
7116
+ }
7117
+ function normalizeCommitTopicText(value) {
7118
+ return value.replace(/\s+/g, " ").trim();
7119
+ }
7120
+ function normalizeTaskTopic(value) {
7121
+ return normalizeCommitTopicText(value).replace(/^T-[A-Za-z0-9-]+\s+/, "");
7122
+ }
7123
+ function normalizeCommitSubjectForGate(value) {
7124
+ return normalizeCommitTopicText(value).replace(/^[a-z]+(?:\([^)]*\))?!?:\s*/i, "").toLowerCase();
7125
+ }
7126
+ function toTaskKey(rawTitle) {
7127
+ const trimmed = normalizeCommitTopicText(rawTitle);
7128
+ if (!trimmed) return "";
7129
+ const idMatch = trimmed.match(/^(T-[A-Za-z0-9-]+)/i);
7130
+ if (idMatch) return idMatch[1].toUpperCase();
7131
+ return normalizeTaskTopic(trimmed).toLowerCase();
7132
+ }
7133
+ function normalizeGitRelativePath(value) {
7134
+ return value.replace(/\\/g, "/").replace(/^\.\/+/, "").replace(/\/+$/, "");
7135
+ }
7136
+ function toRepoRelativePath(cwd, relativePathFromCwd) {
7137
+ const prefix = (runGitCapture(["rev-parse", "--show-prefix"], cwd) || "").trim().replace(/\/+$/, "");
7138
+ if (!prefix) return normalizeGitRelativePath(relativePathFromCwd);
7139
+ return normalizeGitRelativePath(`${prefix}/${relativePathFromCwd}`);
7140
+ }
7141
+ function parseDoneTransitionsFromDiff(diff) {
7142
+ const removedByTask = /* @__PURE__ */ new Map();
7143
+ const addedByTask = /* @__PURE__ */ new Map();
7144
+ const parseTaskDiffLine = (line) => {
7145
+ const match = line.match(
7146
+ /^\s*-\s*\[(TODO|DOING|DONE|REVIEW)\](?:\[[^\]]+\])*\s+(.+?)\s*$/i
7147
+ );
7148
+ if (!match) return null;
7149
+ const key = toTaskKey(match[2]);
7150
+ if (!key) return null;
6874
7151
  return {
6875
- status: "error",
6876
- reasonCode: codeRootResolution.errorReasonCode,
6877
- docsDir: config.docsDir,
6878
- activeFeatureRef,
6879
- changedCodePaths: [],
6880
- changedFeatureDocPaths: [],
6881
- latestCodeChangeAt: null,
6882
- latestFeatureDocSyncAt: null
7152
+ key,
7153
+ status: match[1].toUpperCase()
6883
7154
  };
7155
+ };
7156
+ for (const line of diff.split("\n")) {
7157
+ if (line.startsWith("---") || line.startsWith("+++")) continue;
7158
+ if (line.startsWith("-")) {
7159
+ const parsed = parseTaskDiffLine(line.slice(1));
7160
+ if (!parsed) continue;
7161
+ const existing = removedByTask.get(parsed.key) || /* @__PURE__ */ new Set();
7162
+ existing.add(parsed.status);
7163
+ removedByTask.set(parsed.key, existing);
7164
+ continue;
7165
+ }
7166
+ if (line.startsWith("+")) {
7167
+ const parsed = parseTaskDiffLine(line.slice(1));
7168
+ if (!parsed) continue;
7169
+ const existing = addedByTask.get(parsed.key) || /* @__PURE__ */ new Set();
7170
+ existing.add(parsed.status);
7171
+ addedByTask.set(parsed.key, existing);
7172
+ }
6884
7173
  }
6885
- if (codeRoots.length === 0) {
6886
- return {
6887
- status: "skipped",
6888
- reasonCode: "NO_GIT_REPOSITORY",
6889
- docsDir: config.docsDir,
6890
- activeFeatureRef,
6891
- changedCodePaths: [],
6892
- changedFeatureDocPaths: [],
6893
- latestCodeChangeAt: null,
6894
- latestFeatureDocSyncAt: null
6895
- };
7174
+ let doneTransitions = 0;
7175
+ for (const [taskKey, addedStatuses] of addedByTask.entries()) {
7176
+ if (!addedStatuses.has("DONE")) continue;
7177
+ const removedStatuses = removedByTask.get(taskKey);
7178
+ if (!removedStatuses) continue;
7179
+ if (removedStatuses.has("TODO") || removedStatuses.has("DOING") || removedStatuses.has("REVIEW")) {
7180
+ doneTransitions += 1;
7181
+ }
6896
7182
  }
6897
- const changedCodePaths = collectChangedRecords(codeRoots, config.docsDir).filter(
6898
- isCodeChange
7183
+ return doneTransitions;
7184
+ }
7185
+ function parseDoneTaskTopicCounts(content) {
7186
+ const counts = /* @__PURE__ */ new Map();
7187
+ for (const line of withoutFencedCodeBlocks(content)) {
7188
+ const match = line.match(
7189
+ /^\s*-\s*\[(DONE)\](?:\[[^\]]+\])*\s+(.+?)\s*$/i
7190
+ );
7191
+ if (!match) continue;
7192
+ const topic = normalizeTaskTopic(match[2] || "");
7193
+ if (!topic) continue;
7194
+ counts.set(topic, (counts.get(topic) || 0) + 1);
7195
+ }
7196
+ return counts;
7197
+ }
7198
+ function countDoneTransitionsInLatestTasksCommit(feature) {
7199
+ const docsGitCwd = feature.git.docsGitCwd;
7200
+ const tasksRelativePathFromDocs = normalizeGitRelativePath(
7201
+ path8.join(feature.docs.featurePathFromDocs, "tasks.md")
6899
7202
  );
6900
- const outOfScopeStandaloneCodePaths = collectOutOfScopeStandaloneCodeChanges(
6901
- config,
6902
- activeFeature,
6903
- codeRoots
7203
+ const latestTasksCommit = (runGitCapture(
7204
+ ["rev-list", "-n", "1", "HEAD", "--", tasksRelativePathFromDocs],
7205
+ docsGitCwd
7206
+ ) || "").trim();
7207
+ if (!latestTasksCommit) return void 0;
7208
+ const repoTasksPath = toRepoRelativePath(docsGitCwd, tasksRelativePathFromDocs);
7209
+ const currentContent = runGitCapture(
7210
+ ["show", `${latestTasksCommit}:${repoTasksPath}`],
7211
+ docsGitCwd
6904
7212
  );
6905
- const combinedChangedCodePaths = [
6906
- ...changedCodePaths,
6907
- ...outOfScopeStandaloneCodePaths
6908
- ];
6909
- const docsRepoRoot = resolveDocsRepoRoot(config.docsDir);
6910
- const changedFeatureDocPaths = docsRepoRoot ? collectChangedRecords([docsRepoRoot], config.docsDir).filter(isFeatureDocChange) : [];
6911
- const meaningfulChangedFeatureDocPaths = await filterMeaningfulFeatureDocRecords(
6912
- config,
6913
- activeFeature,
6914
- changedFeatureDocPaths
7213
+ if (currentContent === void 0) return void 0;
7214
+ const previousContent = runGitCapture(["show", `${latestTasksCommit}^:${repoTasksPath}`], docsGitCwd) || "";
7215
+ const currentDone = parseDoneTaskTopicCounts(currentContent);
7216
+ const previousDone = parseDoneTaskTopicCounts(previousContent);
7217
+ let doneTransitions = 0;
7218
+ for (const [topic, currentCount] of currentDone.entries()) {
7219
+ const previousCount = previousDone.get(topic) || 0;
7220
+ if (currentCount > previousCount) {
7221
+ doneTransitions += currentCount - previousCount;
7222
+ }
7223
+ }
7224
+ return doneTransitions;
7225
+ }
7226
+ function countPendingDoneTransitions(feature) {
7227
+ const docsGitCwd = feature.git.docsGitCwd;
7228
+ const tasksRelativePath = normalizeGitRelativePath(
7229
+ path8.join(feature.docs.featurePathFromDocs, "tasks.md")
6915
7230
  );
6916
- const scopedFeatureDocPaths = activeFeatureRef ? meaningfulChangedFeatureDocPaths.filter(
6917
- (record) => featureRefFromDocPath(record.relativeToDocs) === activeFeatureRef
6918
- ) : [];
6919
- const allMeaningfulFeatureDocPaths = meaningfulChangedFeatureDocPaths;
6920
- const latestCodeChangeAt = await getLatestMtimeIso(combinedChangedCodePaths);
6921
- const latestFeatureDocSyncAt = await getLatestWorkflowSyncMarkerAt(activeFeature);
6922
- if (combinedChangedCodePaths.length === 0) {
6923
- return {
6924
- status: "ok",
6925
- reasonCode: "WORKFLOW_IN_SYNC",
6926
- docsDir: config.docsDir,
6927
- activeFeatureRef,
6928
- changedCodePaths: [],
6929
- changedFeatureDocPaths: allMeaningfulFeatureDocPaths.map((item) => item.relativeToRepo),
6930
- latestCodeChangeAt: null,
6931
- latestFeatureDocSyncAt
6932
- };
7231
+ const diff = runGitCapture(
7232
+ ["diff", "--unified=0", "--no-color", "HEAD", "--", tasksRelativePath],
7233
+ docsGitCwd
7234
+ ) || "";
7235
+ if (!diff.trim()) return 0;
7236
+ return parseDoneTransitionsFromDiff(diff);
7237
+ }
7238
+ function getLastDoneTask(tasks) {
7239
+ for (let index = tasks.tasks.length - 1; index >= 0; index -= 1) {
7240
+ if (tasks.tasks[index].status === "DONE") return tasks.tasks[index];
6933
7241
  }
6934
- if (!activeFeatureRef) {
6935
- return {
6936
- status: "needs_sync",
6937
- reasonCode: "ACTIVE_FEATURE_SCOPE_UNCLEAR",
6938
- docsDir: config.docsDir,
6939
- activeFeatureRef: null,
6940
- changedCodePaths: combinedChangedCodePaths.map((item) => item.relativeToRepo),
6941
- changedFeatureDocPaths: allMeaningfulFeatureDocPaths.map((item) => item.relativeToRepo),
6942
- latestCodeChangeAt,
6943
- latestFeatureDocSyncAt
6944
- };
7242
+ return null;
7243
+ }
7244
+ function hasOpenTask(tasks) {
7245
+ return tasks.tasks.some(
7246
+ (task) => task.status === "DOING" || task.status === "REVIEW"
7247
+ );
7248
+ }
7249
+ function hasUncommittedChanges(gitCwd) {
7250
+ if (!gitCwd) return false;
7251
+ const status = runGitCapture(
7252
+ ["status", "--porcelain", "--untracked-files=no"],
7253
+ gitCwd
7254
+ ) || "";
7255
+ return status.trim().length > 0;
7256
+ }
7257
+ function resolveTaskCommitGatePolicy(config) {
7258
+ const raw = config.workflow?.taskCommitGate;
7259
+ return raw === "off" || raw === "strict" ? raw : "warn";
7260
+ }
7261
+ function checkTaskCommitGate(feature, effectiveProjectGitCwd, lastDoneTask) {
7262
+ const doneTransitions = countDoneTransitionsInLatestTasksCommit(feature);
7263
+ if (doneTransitions === 0) {
7264
+ return { pass: true, doneTransitions };
6945
7265
  }
6946
- if (outOfScopeStandaloneCodePaths.length > 0) {
7266
+ if (typeof doneTransitions === "number" && doneTransitions > 1) {
6947
7267
  return {
6948
- status: "needs_sync",
6949
- reasonCode: "ACTIVE_FEATURE_SCOPE_UNCLEAR",
6950
- docsDir: config.docsDir,
6951
- activeFeatureRef,
6952
- changedCodePaths: combinedChangedCodePaths.map((item) => item.relativeToRepo),
6953
- changedFeatureDocPaths: allMeaningfulFeatureDocPaths.map((item) => item.relativeToRepo),
6954
- latestCodeChangeAt,
6955
- latestFeatureDocSyncAt
7268
+ pass: false,
7269
+ reason: "DONE_TRANSITIONS_COUNT",
7270
+ doneTransitions
6956
7271
  };
6957
7272
  }
6958
- const needsSync = scopedFeatureDocPaths.length === 0 || !latestFeatureDocSyncAt || !latestCodeChangeAt || latestCodeChangeAt > latestFeatureDocSyncAt;
7273
+ const lastDoneTopic = normalizeTaskTopic(lastDoneTask?.title || "");
7274
+ if (!effectiveProjectGitCwd || !lastDoneTopic) {
7275
+ return { pass: true };
7276
+ }
7277
+ const args = ["log", "-n", "1", "--pretty=%s", "--", "."];
7278
+ const relativeDocsDir = path8.relative(
7279
+ effectiveProjectGitCwd,
7280
+ feature.git.docsGitCwd
7281
+ );
7282
+ const normalizedDocsDir = normalizeGitRelativePath(relativeDocsDir);
7283
+ if (normalizedDocsDir && normalizedDocsDir !== "." && normalizedDocsDir !== ".." && !normalizedDocsDir.startsWith("../")) {
7284
+ args.push(`:(exclude)${normalizedDocsDir}/**`);
7285
+ }
7286
+ const latestProjectSubject = runGitCapture(args, effectiveProjectGitCwd);
7287
+ if (latestProjectSubject === void 0) {
7288
+ return { pass: false, reason: "PROJECT_LOG_UNAVAILABLE" };
7289
+ }
7290
+ const normalizedSubject = normalizeCommitSubjectForGate(latestProjectSubject);
7291
+ if (!normalizedSubject) {
7292
+ return { pass: false, reason: "NO_PROJECT_COMMIT" };
7293
+ }
7294
+ if (!normalizedSubject.includes(normalizeTaskTopic(lastDoneTopic).toLowerCase())) {
7295
+ return { pass: false, reason: "MISMATCH_LAST_DONE" };
7296
+ }
7297
+ return { pass: true };
7298
+ }
7299
+ function describeTaskCommitGateFailure(check) {
7300
+ switch (check.reason) {
7301
+ case "DONE_TRANSITIONS_COUNT":
7302
+ return `latest tasks.md commit includes ${check.doneTransitions || 0} DONE transitions`;
7303
+ case "NO_PROJECT_COMMIT":
7304
+ return "no recent project code commit was found for the just-finished task";
7305
+ case "PROJECT_LOG_UNAVAILABLE":
7306
+ return "the latest project commit subject could not be inspected";
7307
+ case "MISMATCH_LAST_DONE":
7308
+ default:
7309
+ return "the latest project commit subject does not match the just-finished task";
7310
+ }
7311
+ }
7312
+ function resolveProjectCommitTopic(feature, tasks) {
7313
+ const activeTask = tasks.tasks.find(
7314
+ (task) => task.status === "DOING" || task.status === "REVIEW"
7315
+ );
7316
+ const raw = activeTask?.title || getLastDoneTask(tasks)?.title || nextTodoTask(tasks)?.title || feature.folderName;
7317
+ const withoutTaskId = normalizeCommitTopicText(raw || "").replace(
7318
+ /^T-[A-Za-z0-9-]+\s+/,
7319
+ ""
7320
+ );
7321
+ return withoutTaskId || feature.folderName;
7322
+ }
7323
+ function buildTaskCommitSummary(input) {
7324
+ const { feature, tasks, effectiveProjectGitCwd, docsDirty, projectDirty, gateFailureReason } = input;
7325
+ const docsMessage = tasks.issueNumber ? `git -C "${feature.git.docsGitCwd}" add "${feature.docs.featurePathFromDocs}" && git -C "${feature.git.docsGitCwd}" commit -m "docs(#${tasks.issueNumber}): ${feature.folderName} \uBB38\uC11C \uC5C5\uB370\uC774\uD2B8"` : `git -C "${feature.git.docsGitCwd}" add "${feature.docs.featurePathFromDocs}" && git -C "${feature.git.docsGitCwd}" commit -m "docs: ${feature.folderName} \uBB38\uC11C \uC5C5\uB370\uC774\uD2B8"`;
7326
+ const projectMessage = tasks.issueNumber ? `Stage only the files touched by the just-finished task in "${effectiveProjectGitCwd}", then commit with: git -C "${effectiveProjectGitCwd}" commit -m "feat(#${tasks.issueNumber}): ${resolveProjectCommitTopic(feature, tasks)}"` : `Stage only the files touched by the just-finished task in "${effectiveProjectGitCwd}", then commit with: git -C "${effectiveProjectGitCwd}" commit -m "feat(${feature.folderName}): ${resolveProjectCommitTopic(feature, tasks)}"`;
7327
+ const lines = ["Finish the task-level commit checkpoint before continuing."];
7328
+ if (gateFailureReason) {
7329
+ lines.push(`Current gate failure: ${gateFailureReason}`);
7330
+ }
7331
+ if (docsDirty) {
7332
+ lines.push(`Docs commit: ${docsMessage}`);
7333
+ }
7334
+ if (projectDirty) {
7335
+ lines.push(`Project commit: ${projectMessage}`);
7336
+ }
7337
+ if (!docsDirty && !projectDirty) {
7338
+ lines.push(`Re-check the last task commits. Docs commit should contain exactly one DONE transition, and the latest project commit should match "${normalizeTaskTopic(getLastDoneTask(tasks)?.title || "")}".`);
7339
+ }
7340
+ return lines.join("\n");
7341
+ }
7342
+ function parseWorkflowDraftMetadataExtended(content) {
7343
+ const metadata = parseWorkflowDraftMetadata(content);
7344
+ const prStatusRaw = extractFieldValue2(content, PR_STATUS_LABELS);
7345
+ const normalizedPrStatus = (prStatusRaw || "").trim().toLowerCase();
6959
7346
  return {
6960
- status: needsSync ? "needs_sync" : "ok",
7347
+ ...metadata,
7348
+ issueRef: sanitizeMetadataValue2(extractFieldValue2(content, ISSUE_LABELS)),
7349
+ prRef: sanitizeMetadataValue2(extractFieldValue2(content, PR_LABELS)),
7350
+ prStatus: normalizedPrStatus === "review" ? "review" : normalizedPrStatus === "approved" ? "approved" : null
7351
+ };
7352
+ }
7353
+ async function readFileIfExists(filePath) {
7354
+ if (!await fs.pathExists(filePath)) return null;
7355
+ return fs.readFile(filePath, "utf-8");
7356
+ }
7357
+ function buildFeatureRef(feature) {
7358
+ return feature.folderName;
7359
+ }
7360
+ function buildFeatureArgs(feature) {
7361
+ return feature.type && feature.type !== "single" ? `${buildFeatureRef(feature)} --component ${feature.type}` : buildFeatureRef(feature);
7362
+ }
7363
+ function resolveExpectedBranch(feature, tasks) {
7364
+ if (tasks.branch) return tasks.branch;
7365
+ if (!tasks.issueNumber) return null;
7366
+ return `feat/${tasks.issueNumber}-${feature.slug}`;
7367
+ }
7368
+ function resolveProjectRootFromGitCwd2(projectGitCwd) {
7369
+ return runGitCapture(["rev-parse", "--show-toplevel"], projectGitCwd) || path8.resolve(projectGitCwd);
7370
+ }
7371
+ function resolveProjectRootGitCwd(config, feature) {
7372
+ if (config.docsRepo === "standalone") {
7373
+ const roots = resolveStandaloneProjectRoots(
7374
+ config,
7375
+ feature.type === "single" ? void 0 : feature.type
7376
+ );
7377
+ if (roots.length > 0) {
7378
+ return roots[0];
7379
+ }
7380
+ }
7381
+ return resolveProjectRootFromGitCwd2(feature.git.projectGitCwd);
7382
+ }
7383
+ function getExpectedWorktreePath(config, projectGitCwd, branchName) {
7384
+ const projectRoot = resolveProjectRootFromGitCwd2(projectGitCwd);
7385
+ return resolveManagedWorktreePath(config, projectRoot, branchName);
7386
+ }
7387
+ async function resolveExistingExpectedWorktreePath(config, projectGitCwd, branchName) {
7388
+ const candidate = getExpectedWorktreePath(config, projectGitCwd, branchName);
7389
+ return await fs.pathExists(candidate) ? candidate : null;
7390
+ }
7391
+ function buildManagedWorktreeCreateCommand(config, projectGitCwd, branchName) {
7392
+ const projectRoot = resolveProjectRootFromGitCwd2(projectGitCwd);
7393
+ const worktreePath = getExpectedWorktreePath(config, projectGitCwd, branchName);
7394
+ const worktreeParent = path8.dirname(worktreePath);
7395
+ const envLinkCommand = buildManagedWorktreeEnvLinkCommand(projectRoot, worktreePath);
7396
+ return `mkdir -p "${worktreeParent}" && (git -C "${projectRoot}" worktree add "${worktreePath}" "${branchName}" || git -C "${projectRoot}" worktree add -b "${branchName}" "${worktreePath}") && ${envLinkCommand}`;
7397
+ }
7398
+ function resolveRemotePrMergeMeta(prRef, projectGitCwd) {
7399
+ if (!prRef) return null;
7400
+ const result = runProcess(
7401
+ "gh",
7402
+ ["pr", "view", prRef, "--json", "headRefName,baseRefName"],
7403
+ projectGitCwd
7404
+ );
7405
+ if (result.code !== 0) {
7406
+ return null;
7407
+ }
7408
+ try {
7409
+ const parsed = JSON.parse(String(result.stdout || "{}"));
7410
+ return {
7411
+ headRefName: sanitizeMetadataValue2(String(parsed.headRefName || "")),
7412
+ baseRefName: sanitizeMetadataValue2(String(parsed.baseRefName || ""))
7413
+ };
7414
+ } catch {
7415
+ return null;
7416
+ }
7417
+ }
7418
+ function localBranchExists(cwd, branchName) {
7419
+ if (!branchName) return false;
7420
+ return runProcess(
7421
+ "git",
7422
+ ["show-ref", "--verify", "--quiet", `refs/heads/${branchName}`],
7423
+ cwd
7424
+ ).code === 0;
7425
+ }
7426
+ function remoteBranchExists(cwd, branchName) {
7427
+ if (!branchName) return false;
7428
+ return runProcess(
7429
+ "git",
7430
+ ["show-ref", "--verify", "--quiet", `refs/remotes/origin/${branchName}`],
7431
+ cwd
7432
+ ).code === 0;
7433
+ }
7434
+ function resolvePostMergeCleanupState(config, feature, tasks) {
7435
+ const projectRootGitCwd = resolveProjectRootGitCwd(config, feature);
7436
+ const prMeta = resolveRemotePrMergeMeta(tasks.prLink, projectRootGitCwd);
7437
+ const baseBranch = (prMeta?.baseRefName || "main").trim() || "main";
7438
+ const headBranch = (prMeta?.headRefName || resolveExpectedBranch(feature, tasks))?.trim() || null;
7439
+ const hasOriginRemote = runProcess(
7440
+ "git",
7441
+ ["remote", "get-url", "origin"],
7442
+ projectRootGitCwd
7443
+ ).code === 0;
7444
+ if (hasOriginRemote) {
7445
+ runProcess("git", ["fetch", "--prune", "origin"], projectRootGitCwd);
7446
+ }
7447
+ const currentBranch = runGitCapture(["branch", "--show-current"], projectRootGitCwd) || runGitCapture(["rev-parse", "--abbrev-ref", "HEAD"], projectRootGitCwd) || "";
7448
+ const localBaseSha = runGitCapture(["rev-parse", baseBranch], projectRootGitCwd) || "";
7449
+ const remoteBaseSha = hasOriginRemote ? runGitCapture(["rev-parse", `origin/${baseBranch}`], projectRootGitCwd) || "" : "";
7450
+ const worktreePath = config.docsRepo === "standalone" && headBranch ? resolveManagedWorktreePath(config, projectRootGitCwd, headBranch) : null;
7451
+ const managedWorktreeExists = !!worktreePath && fs.existsSync(worktreePath);
7452
+ const localFeatureBranchExists = localBranchExists(projectRootGitCwd, headBranch);
7453
+ const remoteFeatureBranchExists = hasOriginRemote && remoteBranchExists(projectRootGitCwd, headBranch);
7454
+ const localBaseCheckedOut = currentBranch === baseBranch;
7455
+ const baseSyncedWithOrigin = !hasOriginRemote || localBaseSha.length > 0 && remoteBaseSha.length > 0 && localBaseSha === remoteBaseSha;
7456
+ return {
7457
+ complete: localBaseCheckedOut && baseSyncedWithOrigin && !localFeatureBranchExists && !remoteFeatureBranchExists && !managedWorktreeExists,
7458
+ projectRootGitCwd,
7459
+ baseBranch,
7460
+ headBranch,
7461
+ worktreePath,
7462
+ hasOriginRemote,
7463
+ localBaseCheckedOut,
7464
+ baseSyncedWithOrigin,
7465
+ localFeatureBranchExists,
7466
+ remoteFeatureBranchExists,
7467
+ managedWorktreeExists
7468
+ };
7469
+ }
7470
+ function buildPostMergeCleanupCommand(state) {
7471
+ const commandParts = [];
7472
+ if (state.hasOriginRemote) {
7473
+ commandParts.push(
7474
+ `git -C "${state.projectRootGitCwd}" fetch --prune origin`
7475
+ );
7476
+ }
7477
+ commandParts.push(
7478
+ `git -C "${state.projectRootGitCwd}" checkout "${state.baseBranch}"`
7479
+ );
7480
+ if (state.hasOriginRemote) {
7481
+ commandParts.push(
7482
+ `git -C "${state.projectRootGitCwd}" pull --ff-only origin "${state.baseBranch}"`
7483
+ );
7484
+ }
7485
+ if (state.worktreePath) {
7486
+ commandParts.push(
7487
+ `if [ -d "${state.worktreePath}" ]; then git -C "${state.projectRootGitCwd}" worktree remove "${state.worktreePath}"; fi`
7488
+ );
7489
+ }
7490
+ if (state.headBranch) {
7491
+ commandParts.push(
7492
+ `if git -C "${state.projectRootGitCwd}" show-ref --verify --quiet "refs/heads/${state.headBranch}"; then git -C "${state.projectRootGitCwd}" branch -D "${state.headBranch}"; fi`
7493
+ );
7494
+ if (state.hasOriginRemote) {
7495
+ commandParts.push(
7496
+ `if git -C "${state.projectRootGitCwd}" show-ref --verify --quiet "refs/remotes/origin/${state.headBranch}"; then git -C "${state.projectRootGitCwd}" push origin --delete "${state.headBranch}"; fi`
7497
+ );
7498
+ commandParts.push(
7499
+ `git -C "${state.projectRootGitCwd}" fetch --prune origin`
7500
+ );
7501
+ }
7502
+ }
7503
+ return commandParts.join(" && ");
7504
+ }
7505
+ function buildPostMergeCleanupSummary(state) {
7506
+ const remaining = [];
7507
+ if (!state.localBaseCheckedOut) {
7508
+ remaining.push(`check out ${state.baseBranch}`);
7509
+ }
7510
+ if (!state.baseSyncedWithOrigin) {
7511
+ remaining.push(`sync ${state.baseBranch} with origin/${state.baseBranch}`);
7512
+ }
7513
+ if (state.managedWorktreeExists) {
7514
+ remaining.push("remove the managed feature worktree");
7515
+ }
7516
+ if (state.localFeatureBranchExists) {
7517
+ remaining.push("delete the local feature branch");
7518
+ }
7519
+ if (state.remoteFeatureBranchExists) {
7520
+ remaining.push("delete the remote feature branch");
7521
+ }
7522
+ if (remaining.length === 0) {
7523
+ return "Finish the post-merge cleanup before closing the feature.";
7524
+ }
7525
+ return `Finish the post-merge cleanup before closing the feature: ${remaining.join(", ")}.`;
7526
+ }
7527
+ function nextTodoTask(tasks) {
7528
+ return tasks.tasks.find((task) => task.status === "DOING") || tasks.tasks.find((task) => task.status === "TODO") || null;
7529
+ }
7530
+ function allTasksDone(tasks) {
7531
+ return tasks.tasks.length > 0 && tasks.tasks.every((task) => task.status === "DONE");
7532
+ }
7533
+ function prePrSatisfied(tasks) {
7534
+ return tasks.prePrReviewStatus === "done" && !!tasks.prePrEvidence && !!tasks.prePrDecision && tasks.prePrDecisionOutcome === "approve";
7535
+ }
7536
+ function issueExistsRemotely(issueNumber, feature) {
7537
+ if (!issueNumber) return false;
7538
+ const result = runProcess(
7539
+ "gh",
7540
+ ["issue", "view", String(issueNumber), "--json", "number"],
7541
+ feature.git.projectGitCwd
7542
+ );
7543
+ return result.code === 0;
7544
+ }
7545
+ function prExistsRemotely(prRef, feature) {
7546
+ if (!prRef) return false;
7547
+ const result = runProcess(
7548
+ "gh",
7549
+ ["pr", "view", prRef, "--json", "url"],
7550
+ feature.git.projectGitCwd
7551
+ );
7552
+ return result.code === 0;
7553
+ }
7554
+ function buildAction(category, summary, approvalRequired, command = null) {
7555
+ return {
7556
+ category,
7557
+ summary,
7558
+ approvalRequired,
7559
+ command
7560
+ };
7561
+ }
7562
+ function buildStageOption(label, reply, category, summary, command = null) {
7563
+ return {
7564
+ label,
7565
+ reply,
7566
+ category,
7567
+ summary,
7568
+ command
7569
+ };
7570
+ }
7571
+ function normalizeApprovalToken(value) {
7572
+ return (value ?? "").trim().toLowerCase();
7573
+ }
7574
+ function resolveActionApprovalRequired(config, category, builtinRequiresUserCheck) {
7575
+ const approval = config.approval?.mode === "builtin" ? createDefaultApprovalConfig() : config.approval ?? createDefaultApprovalConfig();
7576
+ const mode = approval.mode ?? "category";
7577
+ if (mode === "steps") {
7578
+ const requiredSteps = new Set(
7579
+ (approval.requireCheckSteps ?? []).map((value) => typeof value === "number" ? value : Number(value)).filter((value) => Number.isFinite(value))
7580
+ );
7581
+ const legacyStep = LEGACY_STEP_BY_ACTION[category];
7582
+ return typeof legacyStep === "number" ? requiredSteps.has(legacyStep) : builtinRequiresUserCheck;
7583
+ }
7584
+ const requiredCategories = new Set(
7585
+ (approval.requireCheckCategories ?? []).map((value) => normalizeApprovalToken(value)).filter(Boolean)
7586
+ );
7587
+ const skippedCategories = new Set(
7588
+ (approval.skipCheckCategories ?? []).map((value) => normalizeApprovalToken(value)).filter(Boolean)
7589
+ );
7590
+ const defaultPolicy = approval.default ?? createDefaultApprovalConfig().default ?? "skip";
7591
+ const normalizedCategory = normalizeApprovalToken(category);
7592
+ const explicitlyRequired = requiredCategories.has("*") || requiredCategories.has(normalizedCategory);
7593
+ if (explicitlyRequired) return true;
7594
+ if (skippedCategories.has("*") || skippedCategories.has(normalizedCategory)) {
7595
+ return false;
7596
+ }
7597
+ if (defaultPolicy === "require") return true;
7598
+ if (defaultPolicy === "skip") return false;
7599
+ return builtinRequiresUserCheck;
7600
+ }
7601
+ function resolveRemotePrReviewState(prRef, feature) {
7602
+ if (!prRef) return "unknown";
7603
+ const result = runProcess(
7604
+ "gh",
7605
+ [
7606
+ "pr",
7607
+ "view",
7608
+ prRef,
7609
+ "--json",
7610
+ "reviewDecision,state,mergedAt,mergeStateStatus,isDraft,headRefOid,latestReviews,comments,statusCheckRollup"
7611
+ ],
7612
+ feature.git.projectGitCwd
7613
+ );
7614
+ if (result.code !== 0) {
7615
+ return "unknown";
7616
+ }
7617
+ try {
7618
+ const parsed = JSON.parse(String(result.stdout || "{}"));
7619
+ const reviewDecision = String(parsed.reviewDecision || "").trim().toUpperCase();
7620
+ const state = String(parsed.state || "").trim().toUpperCase();
7621
+ const mergeStateStatus = String(parsed.mergeStateStatus || "").trim().toUpperCase();
7622
+ const isDraft = parsed.isDraft === true;
7623
+ const headRefOid = String(parsed.headRefOid || "").trim().toLowerCase();
7624
+ const mergedAt = typeof parsed.mergedAt === "string" ? parsed.mergedAt.trim() : "";
7625
+ const codeRabbitThreadState = reviewDecision.length === 0 ? resolveCodeRabbitReviewThreadsState(prRef, feature) : "unknown";
7626
+ const codeRabbitCheckSucceeded = hasSuccessfulCodeRabbitStatusCheck(
7627
+ parsed.statusCheckRollup
7628
+ );
7629
+ if (state === "MERGED" || mergedAt.length > 0) {
7630
+ return "merged";
7631
+ }
7632
+ if (isDraft) {
7633
+ return "draft";
7634
+ }
7635
+ if (reviewDecision === "CHANGES_REQUESTED") {
7636
+ return "changes_requested";
7637
+ }
7638
+ if (reviewDecision === "APPROVED") {
7639
+ return mergeStateStatus === "CLEAN" || mergeStateStatus === "HAS_HOOKS" ? "approved" : "merge_blocked";
7640
+ }
7641
+ if (reviewDecision.length === 0 && codeRabbitThreadState === "open") {
7642
+ return "changes_requested";
7643
+ }
7644
+ if (reviewDecision.length === 0 && hasLatestHeadRateLimitSignal(parsed, headRefOid)) {
7645
+ return "review_rate_limited";
7646
+ }
7647
+ if (reviewDecision.length === 0 && hasStaleLatestCommitReviewSignal(parsed, headRefOid) && !(codeRabbitThreadState === "resolved" && codeRabbitCheckSucceeded)) {
7648
+ return "review_pending_latest_commit";
7649
+ }
7650
+ if (reviewDecision.length === 0 && hasCodeRabbitActionableReview(parsed.latestReviews)) {
7651
+ if (codeRabbitThreadState === "resolved" && codeRabbitCheckSucceeded) {
7652
+ return mergeStateStatus === "CLEAN" || mergeStateStatus === "HAS_HOOKS" ? "approved" : "merge_blocked";
7653
+ }
7654
+ return "changes_requested";
7655
+ }
7656
+ if (reviewDecision.length === 0 && codeRabbitThreadState === "resolved" && codeRabbitCheckSucceeded) {
7657
+ return mergeStateStatus === "CLEAN" || mergeStateStatus === "HAS_HOOKS" ? "approved" : "merge_blocked";
7658
+ }
7659
+ if (reviewDecision === "REVIEW_REQUIRED" || reviewDecision.length === 0) {
7660
+ return "waiting_review";
7661
+ }
7662
+ return "unknown";
7663
+ } catch {
7664
+ return "unknown";
7665
+ }
7666
+ }
7667
+ function resolveCurrentReviewState(tasks, prDraft, remoteReviewState) {
7668
+ if (remoteReviewState === "merged") {
7669
+ return "merged";
7670
+ }
7671
+ if (remoteReviewState === "draft") {
7672
+ return "draft";
7673
+ }
7674
+ if (remoteReviewState === "merge_blocked") {
7675
+ return "merge_blocked";
7676
+ }
7677
+ if (remoteReviewState === "changes_requested") {
7678
+ return "changes_requested";
7679
+ }
7680
+ if (remoteReviewState === "review_rate_limited") {
7681
+ return "review_rate_limited";
7682
+ }
7683
+ if (remoteReviewState === "review_pending_latest_commit") {
7684
+ return "review_pending_latest_commit";
7685
+ }
7686
+ if (remoteReviewState === "waiting_review") {
7687
+ return "waiting_review";
7688
+ }
7689
+ if (remoteReviewState === "approved") {
7690
+ return "approved";
7691
+ }
7692
+ if (remoteReviewState === "unknown") {
7693
+ return "unknown";
7694
+ }
7695
+ if (tasks.prStatus === "approved" || prDraft.prStatus === "approved") {
7696
+ return "approved";
7697
+ }
7698
+ return "unknown";
7699
+ }
7700
+ function buildCodeReviewActionOptions(reviewState) {
7701
+ if (reviewState === "merged") {
7702
+ return [
7703
+ buildStageOption(
7704
+ "A",
7705
+ "A",
7706
+ "review_sync_approved",
7707
+ "Sync the already-merged PR state into tasks.md and pr.md before closing the feature."
7708
+ ),
7709
+ buildStageOption(
7710
+ "B",
7711
+ "B",
7712
+ "hold",
7713
+ "Stop here and leave the merged-state sync for later."
7714
+ )
7715
+ ];
7716
+ }
7717
+ if (reviewState === "approved") {
7718
+ return [
7719
+ buildStageOption(
7720
+ "A",
7721
+ "A",
7722
+ "review_sync_approved",
7723
+ "Sync the approved PR review state into tasks.md and pr.md, then continue to the merge gate."
7724
+ ),
7725
+ buildStageOption(
7726
+ "B",
7727
+ "B",
7728
+ "hold",
7729
+ "Hold the merge boundary for now and leave the PR open."
7730
+ )
7731
+ ];
7732
+ }
7733
+ if (reviewState === "draft" || reviewState === "merge_blocked") {
7734
+ return [
7735
+ buildStageOption(
7736
+ "A",
7737
+ "A",
7738
+ "review_wait",
7739
+ "Inspect the current PR state, resolve the draft/merge blocker, and sync the review fields before proceeding."
7740
+ ),
7741
+ buildStageOption(
7742
+ "B",
7743
+ "B",
7744
+ "hold",
7745
+ "Stop here and keep the PR open until the blocker is resolved."
7746
+ )
7747
+ ];
7748
+ }
7749
+ if (reviewState === "changes_requested") {
7750
+ return [
7751
+ buildStageOption(
7752
+ "A",
7753
+ "A",
7754
+ "review_fix",
7755
+ "Address the requested review changes, update review evidence/decision, and continue the feature."
7756
+ ),
7757
+ buildStageOption(
7758
+ "B",
7759
+ "B",
7760
+ "hold",
7761
+ "Stop here and wait before taking another review-fix pass."
7762
+ )
7763
+ ];
7764
+ }
7765
+ if (reviewState === "review_rate_limited") {
7766
+ return [
7767
+ buildStageOption(
7768
+ "A",
7769
+ "A",
7770
+ "review_wait",
7771
+ "Re-check the PR review state after the CodeRabbit rate limit window resets, then sync tasks.md when a fresh review arrives."
7772
+ ),
7773
+ buildStageOption(
7774
+ "B",
7775
+ "B",
7776
+ "hold",
7777
+ "Stop here and wait for the review rate limit window to clear."
7778
+ )
7779
+ ];
7780
+ }
7781
+ if (reviewState === "review_pending_latest_commit") {
7782
+ return [
7783
+ buildStageOption(
7784
+ "A",
7785
+ "A",
7786
+ "review_wait",
7787
+ "Re-check the PR review state after a reviewer processes the latest commit, then sync tasks.md when fresh review feedback arrives."
7788
+ ),
7789
+ buildStageOption(
7790
+ "B",
7791
+ "B",
7792
+ "hold",
7793
+ "Stop here and wait for a fresh review on the latest commit."
7794
+ )
7795
+ ];
7796
+ }
7797
+ return [
7798
+ buildStageOption(
7799
+ "A",
7800
+ "A",
7801
+ "review_wait",
7802
+ "Check the PR review state again and sync tasks.md when reviewer feedback or approval arrives."
7803
+ ),
7804
+ buildStageOption(
7805
+ "B",
7806
+ "B",
7807
+ "hold",
7808
+ "Stop here and wait for external reviewer feedback."
7809
+ )
7810
+ ];
7811
+ }
7812
+ function buildMergeActionOptions(command) {
7813
+ return [
7814
+ buildStageOption(
7815
+ "A",
7816
+ "A OK",
7817
+ "pr_merge",
7818
+ "Merge the PR now and sync the merged state back into tasks.md.",
7819
+ command
7820
+ ),
7821
+ buildStageOption(
7822
+ "B",
7823
+ "B",
7824
+ "hold",
7825
+ "Keep the PR open and do not merge yet."
7826
+ )
7827
+ ];
7828
+ }
7829
+ function buildApprovalActionOptions(params) {
7830
+ const remoteCommand = params.remoteCommand?.trim() || null;
7831
+ if (remoteCommand && remoteCommand.includes("--confirm OK")) {
7832
+ return [
7833
+ buildStageOption(
7834
+ "A",
7835
+ "A OK",
7836
+ "remote_execute",
7837
+ params.approveSummary,
7838
+ remoteCommand
7839
+ ),
7840
+ buildStageOption("B", "B", "hold", params.holdSummary)
7841
+ ];
7842
+ }
7843
+ return [
7844
+ buildStageOption("A", "A", "approve_continue", params.approveSummary),
7845
+ buildStageOption("B", "B", "request_changes", params.holdSummary)
7846
+ ];
7847
+ }
7848
+ function resolveFeatureSelectionError(selection) {
7849
+ const reasonCode = selection.status === "no_features" ? "NO_FEATURES" : "FEATURE_SELECTION_REQUIRED";
7850
+ return {
7851
+ status: "error",
7852
+ reasonCode,
7853
+ docsDir: selection.config.docsDir,
7854
+ featureRef: null,
7855
+ stage: null,
7856
+ nextAction: null,
7857
+ approvalRequired: false,
7858
+ implementationAllowed: false,
7859
+ blockedReasonCode: null
7860
+ };
7861
+ }
7862
+ async function collectWorkflowStage(cwd, selector, component) {
7863
+ const config = await getConfig(cwd);
7864
+ if (!config) {
7865
+ return {
7866
+ status: "error",
7867
+ reasonCode: "CONFIG_NOT_FOUND",
7868
+ docsDir: null,
7869
+ featureRef: null,
7870
+ stage: null,
7871
+ nextAction: null,
7872
+ approvalRequired: false,
7873
+ implementationAllowed: false,
7874
+ blockedReasonCode: null
7875
+ };
7876
+ }
7877
+ const selection = await resolveFeatureSelection(cwd, selector, component);
7878
+ if (selection.status !== "selected" || !selection.matchedFeature) {
7879
+ return resolveFeatureSelectionError(selection);
7880
+ }
7881
+ const feature = selection.matchedFeature;
7882
+ const requirements = resolveWorkflowRequirements(config);
7883
+ const taskCommitGatePolicy = resolveTaskCommitGatePolicy(config);
7884
+ const paths = getFeatureDocPaths(feature);
7885
+ const specContent = await readFileIfExists(path8.join(config.docsDir, paths.specPath));
7886
+ const planContent = await readFileIfExists(path8.join(config.docsDir, paths.planPath));
7887
+ const tasksContent = await readFileIfExists(path8.join(config.docsDir, paths.tasksPath));
7888
+ const issueContent = await readFileIfExists(path8.join(config.docsDir, paths.issuePath));
7889
+ const prContent = await readFileIfExists(path8.join(config.docsDir, paths.prPath));
7890
+ const specStatus = parseApprovalStatus(
7891
+ extractFieldValue2(specContent || "", ["Status", "\uC0C1\uD0DC"]) || void 0
7892
+ );
7893
+ const planStatus = parseApprovalStatus(
7894
+ extractFieldValue2(planContent || "", ["Status", "\uC0C1\uD0DC"]) || void 0
7895
+ );
7896
+ const tasks = parseTasksDoc(tasksContent || "");
7897
+ const issueDraft = parseWorkflowDraftMetadataExtended(issueContent || "");
7898
+ const prDraft = parseWorkflowDraftMetadataExtended(prContent || "");
7899
+ const remoteReviewState = requirements.requireReview && tasks.prLink ? resolveRemotePrReviewState(tasks.prLink, feature) : "unknown";
7900
+ const currentReviewState = resolveCurrentReviewState(
7901
+ tasks,
7902
+ prDraft,
7903
+ remoteReviewState
7904
+ );
7905
+ if (specStatus !== "approved") {
7906
+ const isReviewStage = specStatus === "review";
7907
+ const approvalRequired = isReviewStage ? resolveActionApprovalRequired(config, "spec_approve", true) : false;
7908
+ const actionOptions = approvalRequired ? buildApprovalActionOptions({
7909
+ approveSummary: "Approve spec.md and continue to the plan stage.",
7910
+ holdSummary: "Request spec changes before continuing."
7911
+ }) : void 0;
7912
+ return {
7913
+ status: "ok",
7914
+ reasonCode: "WORKFLOW_STAGE_RESOLVED",
7915
+ docsDir: config.docsDir,
7916
+ featureRef: buildFeatureRef(feature),
7917
+ stage: "spec",
7918
+ nextAction: buildAction(
7919
+ isReviewStage ? "spec_approve" : "spec_write",
7920
+ isReviewStage ? approvalRequired ? "Get user approval and update spec.md status to Approved." : "Promote spec.md from Review to Approved and continue automatically." : "Write or refine spec.md until it is ready for approval.",
7921
+ approvalRequired
7922
+ ),
7923
+ approvalRequired,
7924
+ implementationAllowed: false,
7925
+ primaryActionLabel: actionOptions ? "A" : void 0,
7926
+ actionOptions,
7927
+ blockedReasonCode: "SPEC_NOT_APPROVED"
7928
+ };
7929
+ }
7930
+ if (planStatus !== "approved") {
7931
+ const isReviewStage = planStatus === "review";
7932
+ const approvalRequired = isReviewStage ? resolveActionApprovalRequired(config, "plan_approve", true) : false;
7933
+ const actionOptions = approvalRequired ? buildApprovalActionOptions({
7934
+ approveSummary: "Approve plan.md and continue to the tasks stage.",
7935
+ holdSummary: "Request plan changes before continuing."
7936
+ }) : void 0;
7937
+ return {
7938
+ status: "ok",
7939
+ reasonCode: "WORKFLOW_STAGE_RESOLVED",
7940
+ docsDir: config.docsDir,
7941
+ featureRef: buildFeatureRef(feature),
7942
+ stage: "plan",
7943
+ nextAction: buildAction(
7944
+ isReviewStage ? "plan_approve" : "plan_write",
7945
+ isReviewStage ? approvalRequired ? "Get user approval and update plan.md status to Approved." : "Promote plan.md from Review to Approved and continue automatically." : "Write or refine plan.md until it is ready for approval.",
7946
+ approvalRequired
7947
+ ),
7948
+ approvalRequired,
7949
+ implementationAllowed: false,
7950
+ primaryActionLabel: actionOptions ? "A" : void 0,
7951
+ actionOptions,
7952
+ blockedReasonCode: "PLAN_NOT_APPROVED"
7953
+ };
7954
+ }
7955
+ if (tasks.tasks.length === 0 || tasks.docStatus !== "approved") {
7956
+ const isReviewStage = tasks.docStatus === "review";
7957
+ const approvalRequired = isReviewStage ? resolveActionApprovalRequired(config, "tasks_approve", true) : false;
7958
+ const actionOptions = approvalRequired ? buildApprovalActionOptions({
7959
+ approveSummary: "Approve tasks.md and continue to issue preparation.",
7960
+ holdSummary: "Request task-list changes before continuing."
7961
+ }) : void 0;
7962
+ return {
7963
+ status: "ok",
7964
+ reasonCode: "WORKFLOW_STAGE_RESOLVED",
7965
+ docsDir: config.docsDir,
7966
+ featureRef: buildFeatureRef(feature),
7967
+ stage: "tasks",
7968
+ nextAction: buildAction(
7969
+ isReviewStage ? "tasks_approve" : "tasks_write",
7970
+ isReviewStage ? approvalRequired ? "Get user approval and update tasks.md Doc Status to Approved." : "Promote tasks.md Doc Status from Review to Approved and continue automatically." : "Add and refine tasks until tasks.md is execution-ready and Approved.",
7971
+ approvalRequired
7972
+ ),
7973
+ approvalRequired,
7974
+ implementationAllowed: false,
7975
+ primaryActionLabel: actionOptions ? "A" : void 0,
7976
+ actionOptions,
7977
+ blockedReasonCode: "TASKS_NOT_READY"
7978
+ };
7979
+ }
7980
+ if (requirements.requireIssue) {
7981
+ const issueReady = issueDraft.status === "ready";
7982
+ const issueCreated = tasks.issueNumber !== null && issueExistsRemotely(tasks.issueNumber, feature);
7983
+ if (!issueCreated || !issueReady) {
7984
+ const issueCreateApprovalRequired = issueReady && !issueCreated;
7985
+ const issueCreateCommand = `npx lee-spec-kit github issue ${buildFeatureArgs(feature)} --create --confirm OK`;
7986
+ const issueCreateOptions = issueCreateApprovalRequired ? buildApprovalActionOptions({
7987
+ approveSummary: "Create the GitHub issue now and sync the issue number back into tasks.md.",
7988
+ holdSummary: "Keep the issue in Ready state but do not create it yet.",
7989
+ remoteCommand: issueCreateCommand
7990
+ }) : void 0;
7991
+ return {
7992
+ status: "ok",
7993
+ reasonCode: "WORKFLOW_STAGE_RESOLVED",
7994
+ docsDir: config.docsDir,
7995
+ featureRef: buildFeatureRef(feature),
7996
+ stage: "issue",
7997
+ nextAction: issueReady && !issueCreated ? buildAction(
7998
+ "issue_create",
7999
+ "Create the GitHub issue from issue.md and sync the issue number into tasks.md.",
8000
+ issueCreateApprovalRequired,
8001
+ issueCreateCommand
8002
+ ) : buildAction(
8003
+ "issue_prepare",
8004
+ "Prepare issue.md and set its Status to Ready before issue creation.",
8005
+ false
8006
+ ),
8007
+ approvalRequired: issueCreateApprovalRequired,
8008
+ implementationAllowed: false,
8009
+ primaryActionLabel: issueCreateOptions ? "A" : void 0,
8010
+ actionOptions: issueCreateOptions,
8011
+ blockedReasonCode: "ISSUE_NOT_CREATED"
8012
+ };
8013
+ }
8014
+ }
8015
+ let effectiveProjectGitCwd = feature.git.projectGitCwd;
8016
+ if (requirements.requireWorktree) {
8017
+ const expectedBranch = resolveExpectedBranch(feature, tasks);
8018
+ if (expectedBranch) {
8019
+ const existingWorktreePath = await resolveExistingExpectedWorktreePath(
8020
+ config,
8021
+ feature.git.projectGitCwd,
8022
+ expectedBranch
8023
+ );
8024
+ if (existingWorktreePath) {
8025
+ effectiveProjectGitCwd = existingWorktreePath;
8026
+ }
8027
+ }
8028
+ }
8029
+ if (requirements.requireBranch && !allTasksDone(tasks)) {
8030
+ const expectedBranch = resolveExpectedBranch(feature, tasks);
8031
+ const currentBranch = runGitCapture(["branch", "--show-current"], effectiveProjectGitCwd) || runGitCapture(["rev-parse", "--abbrev-ref", "HEAD"], effectiveProjectGitCwd) || null;
8032
+ if (expectedBranch && currentBranch !== expectedBranch) {
8033
+ const branchCommand = requirements.requireWorktree ? buildManagedWorktreeCreateCommand(config, feature.git.projectGitCwd, expectedBranch) : `git checkout -b ${expectedBranch}`;
8034
+ return {
8035
+ status: "ok",
8036
+ reasonCode: "WORKFLOW_STAGE_RESOLVED",
8037
+ docsDir: config.docsDir,
8038
+ featureRef: buildFeatureRef(feature),
8039
+ stage: "branch",
8040
+ nextAction: buildAction(
8041
+ "branch_create",
8042
+ requirements.requireWorktree ? `Create or reuse the managed worktree for ${expectedBranch} before implementation starts.` : `Switch the project repo to ${expectedBranch} before implementation starts.`,
8043
+ false,
8044
+ branchCommand
8045
+ ),
8046
+ approvalRequired: false,
8047
+ implementationAllowed: false,
8048
+ blockedReasonCode: "BRANCH_NOT_READY"
8049
+ };
8050
+ }
8051
+ }
8052
+ const activeTaskOpen = hasOpenTask(tasks);
8053
+ const lastDoneTask = getLastDoneTask(tasks);
8054
+ const docsDirty = hasUncommittedChanges(feature.git.docsGitCwd);
8055
+ const projectDirty = hasUncommittedChanges(effectiveProjectGitCwd);
8056
+ const pendingDoneTransitions = countPendingDoneTransitions(feature) || 0;
8057
+ const taskCommitCheckpointRequired = !activeTaskOpen && !!lastDoneTask && (projectDirty || pendingDoneTransitions > 0);
8058
+ if (taskCommitCheckpointRequired) {
8059
+ const pendingReason = pendingDoneTransitions > 1 ? `working tree currently contains ${pendingDoneTransitions} uncommitted DONE transitions` : null;
8060
+ return {
8061
+ status: "ok",
8062
+ reasonCode: "WORKFLOW_STAGE_RESOLVED",
8063
+ docsDir: config.docsDir,
8064
+ featureRef: buildFeatureRef(feature),
8065
+ stage: "task_commit",
8066
+ nextAction: buildAction(
8067
+ "task_commit",
8068
+ buildTaskCommitSummary({
8069
+ feature,
8070
+ tasks,
8071
+ effectiveProjectGitCwd,
8072
+ docsDirty,
8073
+ projectDirty,
8074
+ gateFailureReason: pendingReason
8075
+ }),
8076
+ false
8077
+ ),
8078
+ approvalRequired: false,
8079
+ implementationAllowed: false,
8080
+ blockedReasonCode: "TASK_COMMIT_REQUIRED"
8081
+ };
8082
+ }
8083
+ const committedTaskGate = taskCommitGatePolicy !== "off" && lastDoneTask ? checkTaskCommitGate(feature, effectiveProjectGitCwd, lastDoneTask) : { pass: true };
8084
+ if (!allTasksDone(tasks)) {
8085
+ const currentTask = nextTodoTask(tasks);
8086
+ if (taskCommitGatePolicy === "strict" && !committedTaskGate.pass) {
8087
+ return {
8088
+ status: "ok",
8089
+ reasonCode: "WORKFLOW_STAGE_RESOLVED",
8090
+ docsDir: config.docsDir,
8091
+ featureRef: buildFeatureRef(feature),
8092
+ stage: "task_commit",
8093
+ nextAction: buildAction(
8094
+ "task_commit",
8095
+ buildTaskCommitSummary({
8096
+ feature,
8097
+ tasks,
8098
+ effectiveProjectGitCwd,
8099
+ docsDirty,
8100
+ projectDirty,
8101
+ gateFailureReason: describeTaskCommitGateFailure(committedTaskGate)
8102
+ }),
8103
+ false
8104
+ ),
8105
+ approvalRequired: false,
8106
+ implementationAllowed: false,
8107
+ blockedReasonCode: "TASK_COMMIT_REQUIRED"
8108
+ };
8109
+ }
8110
+ const commitWarning = taskCommitGatePolicy === "warn" && !committedTaskGate.pass ? `
8111
+ Task commit boundary warning: ${describeTaskCommitGateFailure(committedTaskGate)}` : "";
8112
+ return {
8113
+ status: "ok",
8114
+ reasonCode: "WORKFLOW_STAGE_RESOLVED",
8115
+ docsDir: config.docsDir,
8116
+ featureRef: buildFeatureRef(feature),
8117
+ stage: "implementation",
8118
+ nextAction: buildAction(
8119
+ "task_execute",
8120
+ currentTask ? `Continue the next implementation task: ${currentTask.title}${commitWarning}` : "Continue the active implementation task.",
8121
+ false
8122
+ ),
8123
+ approvalRequired: false,
8124
+ implementationAllowed: true,
8125
+ blockedReasonCode: null
8126
+ };
8127
+ }
8128
+ if (!tasks.completion.allTasksChecked || !tasks.completion.testsChecked || !tasks.completion.finalOutcomeChecked) {
8129
+ if (taskCommitGatePolicy === "strict" && !committedTaskGate.pass) {
8130
+ return {
8131
+ status: "ok",
8132
+ reasonCode: "WORKFLOW_STAGE_RESOLVED",
8133
+ docsDir: config.docsDir,
8134
+ featureRef: buildFeatureRef(feature),
8135
+ stage: "task_commit",
8136
+ nextAction: buildAction(
8137
+ "task_commit",
8138
+ buildTaskCommitSummary({
8139
+ feature,
8140
+ tasks,
8141
+ effectiveProjectGitCwd,
8142
+ docsDirty,
8143
+ projectDirty,
8144
+ gateFailureReason: describeTaskCommitGateFailure(committedTaskGate)
8145
+ }),
8146
+ false
8147
+ ),
8148
+ approvalRequired: false,
8149
+ implementationAllowed: false,
8150
+ blockedReasonCode: "TASK_COMMIT_REQUIRED"
8151
+ };
8152
+ }
8153
+ const approvalRequired = resolveActionApprovalRequired(
8154
+ config,
8155
+ "implementation_approve",
8156
+ true
8157
+ );
8158
+ const actionOptions = approvalRequired ? buildApprovalActionOptions({
8159
+ approveSummary: "Approve the completed implementation and continue to the pre-PR or PR preparation stage.",
8160
+ holdSummary: "Request implementation changes before the workflow continues."
8161
+ }) : void 0;
8162
+ return {
8163
+ status: "ok",
8164
+ reasonCode: "WORKFLOW_STAGE_RESOLVED",
8165
+ docsDir: config.docsDir,
8166
+ featureRef: buildFeatureRef(feature),
8167
+ stage: "implementation_approve",
8168
+ nextAction: buildAction(
8169
+ "implementation_approve",
8170
+ "Share the completed implementation, get user approval, and record the completion checkpoint in tasks.md.",
8171
+ approvalRequired
8172
+ ),
8173
+ approvalRequired,
8174
+ implementationAllowed: false,
8175
+ primaryActionLabel: actionOptions ? "A" : void 0,
8176
+ actionOptions,
8177
+ blockedReasonCode: "IMPLEMENTATION_APPROVAL_REQUIRED"
8178
+ };
8179
+ }
8180
+ if (requirements.prePrReviewEnabled && !prePrSatisfied(tasks)) {
8181
+ return {
8182
+ status: "ok",
8183
+ reasonCode: "WORKFLOW_STAGE_RESOLVED",
8184
+ docsDir: config.docsDir,
8185
+ featureRef: buildFeatureRef(feature),
8186
+ stage: "pre_pr_review",
8187
+ nextAction: buildAction(
8188
+ "pre_pr_review",
8189
+ "Run and record the Pre-PR review until tasks.md shows an approve decision with evidence.",
8190
+ false
8191
+ ),
8192
+ approvalRequired: false,
8193
+ implementationAllowed: false,
8194
+ blockedReasonCode: "PRE_PR_REVIEW_NOT_APPROVED"
8195
+ };
8196
+ }
8197
+ if (requirements.requirePr) {
8198
+ const prReady = prDraft.status === "ready";
8199
+ const prCreated = !!tasks.prLink && prExistsRemotely(tasks.prLink, feature);
8200
+ if (!prCreated || !prReady) {
8201
+ const prCreateApprovalRequired = prReady && !prCreated;
8202
+ const prCreateCommand = `npx lee-spec-kit github pr ${buildFeatureArgs(feature)} --create --confirm OK`;
8203
+ const prCreateOptions = prCreateApprovalRequired ? buildApprovalActionOptions({
8204
+ approveSummary: "Create the GitHub PR now and sync the PR metadata back into tasks.md.",
8205
+ holdSummary: "Keep the PR in Ready state but do not create it yet.",
8206
+ remoteCommand: prCreateCommand
8207
+ }) : void 0;
8208
+ return {
8209
+ status: "ok",
8210
+ reasonCode: "WORKFLOW_STAGE_RESOLVED",
8211
+ docsDir: config.docsDir,
8212
+ featureRef: buildFeatureRef(feature),
8213
+ stage: "pr",
8214
+ nextAction: prReady && !prCreated ? buildAction(
8215
+ "pr_create",
8216
+ "Create the GitHub PR from pr.md and sync the PR metadata into tasks.md.",
8217
+ prCreateApprovalRequired,
8218
+ prCreateCommand
8219
+ ) : buildAction(
8220
+ "pr_prepare",
8221
+ "Prepare pr.md and set its Status to Ready before PR creation.",
8222
+ false
8223
+ ),
8224
+ approvalRequired: prCreateApprovalRequired,
8225
+ implementationAllowed: false,
8226
+ primaryActionLabel: prCreateOptions ? "A" : void 0,
8227
+ actionOptions: prCreateOptions,
8228
+ blockedReasonCode: "PR_NOT_CREATED"
8229
+ };
8230
+ }
8231
+ }
8232
+ const reviewApprovedInDocs = tasks.prStatus === "approved" && prDraft.prStatus === "approved";
8233
+ if (requirements.requireReview && currentReviewState === "merged" && reviewApprovedInDocs) {
8234
+ const cleanupState = resolvePostMergeCleanupState(config, feature, tasks);
8235
+ if (!cleanupState.complete) {
8236
+ return {
8237
+ status: "ok",
8238
+ reasonCode: "WORKFLOW_STAGE_RESOLVED",
8239
+ docsDir: config.docsDir,
8240
+ featureRef: buildFeatureRef(feature),
8241
+ stage: "cleanup",
8242
+ nextAction: buildAction(
8243
+ "merge_cleanup",
8244
+ buildPostMergeCleanupSummary(cleanupState),
8245
+ false,
8246
+ buildPostMergeCleanupCommand(cleanupState)
8247
+ ),
8248
+ approvalRequired: false,
8249
+ implementationAllowed: false,
8250
+ reviewState: "merged",
8251
+ blockedReasonCode: "POST_MERGE_CLEANUP_REQUIRED"
8252
+ };
8253
+ }
8254
+ return {
8255
+ status: "ok",
8256
+ reasonCode: "WORKFLOW_STAGE_RESOLVED",
8257
+ docsDir: config.docsDir,
8258
+ featureRef: buildFeatureRef(feature),
8259
+ stage: "done",
8260
+ nextAction: null,
8261
+ approvalRequired: false,
8262
+ implementationAllowed: false,
8263
+ reviewState: "merged",
8264
+ primaryActionLabel: null,
8265
+ actionOptions: [],
8266
+ blockedReasonCode: null
8267
+ };
8268
+ }
8269
+ if (requirements.requireReview && (!reviewApprovedInDocs || currentReviewState !== "approved")) {
8270
+ const reviewFixAllowed = currentReviewState === "changes_requested";
8271
+ const reviewApprovalRequired = !reviewFixAllowed;
8272
+ const reviewActionOptions = reviewApprovalRequired ? buildCodeReviewActionOptions(currentReviewState) : void 0;
8273
+ const reviewSummary = currentReviewState === "approved" ? "Record the approved PR review state in tasks.md and pr.md before proceeding to merge." : currentReviewState === "merged" ? "Sync the already-merged PR state into tasks.md and pr.md before marking the workflow as complete." : currentReviewState === "changes_requested" ? "Address the requested review changes and update the PR review evidence/decision before continuing." : currentReviewState === "review_pending_latest_commit" ? "Wait for a fresh review on the latest PR commit before taking the next review action." : currentReviewState === "review_rate_limited" ? "Wait for the current CodeRabbit review rate limit to clear, then re-check the latest PR review state before continuing." : currentReviewState === "draft" ? "Resolve the draft PR state before continuing to the merge boundary." : currentReviewState === "merge_blocked" ? "Resolve the current PR merge blocker before continuing to merge." : "Wait for PR review or inspect the current review state before taking the next review action.";
8274
+ return {
8275
+ status: "ok",
8276
+ reasonCode: "WORKFLOW_STAGE_RESOLVED",
8277
+ docsDir: config.docsDir,
8278
+ featureRef: buildFeatureRef(feature),
8279
+ stage: "code_review",
8280
+ nextAction: buildAction(
8281
+ "code_review",
8282
+ reviewSummary,
8283
+ reviewApprovalRequired
8284
+ ),
8285
+ approvalRequired: reviewApprovalRequired,
8286
+ implementationAllowed: reviewFixAllowed,
8287
+ reviewState: currentReviewState,
8288
+ primaryActionLabel: reviewActionOptions ? "A" : void 0,
8289
+ actionOptions: reviewActionOptions,
8290
+ blockedReasonCode: "PR_REVIEW_NOT_APPROVED"
8291
+ };
8292
+ }
8293
+ if (requirements.requireMerge) {
8294
+ const mergeCommand = `npx lee-spec-kit github pr ${buildFeatureArgs(feature)} --merge --confirm OK`;
8295
+ return {
8296
+ status: "ok",
8297
+ reasonCode: "WORKFLOW_STAGE_RESOLVED",
8298
+ docsDir: config.docsDir,
8299
+ featureRef: buildFeatureRef(feature),
8300
+ stage: "merge",
8301
+ nextAction: buildAction(
8302
+ "pr_merge",
8303
+ "Merge the PR and sync the merged state back into tasks.md.",
8304
+ true,
8305
+ mergeCommand
8306
+ ),
8307
+ approvalRequired: true,
8308
+ implementationAllowed: false,
8309
+ reviewState: "approved",
8310
+ primaryActionLabel: "A",
8311
+ actionOptions: buildMergeActionOptions(mergeCommand),
8312
+ blockedReasonCode: null
8313
+ };
8314
+ }
8315
+ return {
8316
+ status: "ok",
8317
+ reasonCode: "WORKFLOW_STAGE_RESOLVED",
8318
+ docsDir: config.docsDir,
8319
+ featureRef: buildFeatureRef(feature),
8320
+ stage: "done",
8321
+ nextAction: null,
8322
+ approvalRequired: false,
8323
+ implementationAllowed: false,
8324
+ blockedReasonCode: null
8325
+ };
8326
+ }
8327
+ function hasLatestHeadRateLimitSignal(parsed, headRefOid) {
8328
+ const latestRateLimitCommentAt = findLatestCodeRabbitRateLimitCommentAt(
8329
+ parsed.comments,
8330
+ headRefOid
8331
+ );
8332
+ if (!latestRateLimitCommentAt) {
8333
+ return false;
8334
+ }
8335
+ const latestReviewAt = findLatestCodeRabbitReviewAt(parsed.latestReviews);
8336
+ return !latestReviewAt || latestReviewAt <= latestRateLimitCommentAt;
8337
+ }
8338
+ function hasStaleLatestCommitReviewSignal(parsed, headRefOid) {
8339
+ if (!headRefOid) {
8340
+ return false;
8341
+ }
8342
+ const latestReviewHead = findLatestCodeRabbitReviewedHead(parsed.latestReviews);
8343
+ if (!latestReviewHead) {
8344
+ return false;
8345
+ }
8346
+ return !matchesCommitReference(headRefOid, latestReviewHead);
8347
+ }
8348
+ function resolveCodeRabbitReviewThreadsState(prRef, feature) {
8349
+ const coordinates = parseGithubPullRequestRef(prRef);
8350
+ if (!coordinates) {
8351
+ return "unknown";
8352
+ }
8353
+ const result = runProcess(
8354
+ "gh",
8355
+ [
8356
+ "api",
8357
+ "graphql",
8358
+ "-f",
8359
+ `owner=${coordinates.owner}`,
8360
+ "-f",
8361
+ `name=${coordinates.name}`,
8362
+ "-F",
8363
+ `number=${coordinates.number}`,
8364
+ "-f",
8365
+ "query=query($owner:String!, $name:String!, $number:Int!) { repository(owner:$owner, name:$name) { pullRequest(number:$number) { headRefOid reviewThreads(first:100) { nodes { isResolved isOutdated comments(first:20) { nodes { author { login } body } } } } } } }"
8366
+ ],
8367
+ feature.git.projectGitCwd
8368
+ );
8369
+ if (result.code !== 0) {
8370
+ return "unknown";
8371
+ }
8372
+ try {
8373
+ const parsed = JSON.parse(String(result.stdout || "{}"));
8374
+ const nodes = extractNestedArray(parsed, [
8375
+ "data",
8376
+ "repository",
8377
+ "pullRequest",
8378
+ "reviewThreads",
8379
+ "nodes"
8380
+ ]);
8381
+ if (!nodes) {
8382
+ return "unknown";
8383
+ }
8384
+ const codeRabbitThreads = nodes.filter(isCodeRabbitReviewThread);
8385
+ if (codeRabbitThreads.length === 0) {
8386
+ return "none";
8387
+ }
8388
+ return codeRabbitThreads.some((thread) => !isReviewThreadResolved(thread)) ? "open" : "resolved";
8389
+ } catch {
8390
+ return "unknown";
8391
+ }
8392
+ }
8393
+ function parseGithubPullRequestRef(prRef) {
8394
+ const value = prRef?.trim();
8395
+ if (!value) {
8396
+ return null;
8397
+ }
8398
+ const urlMatch = value.match(
8399
+ /^https?:\/\/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)(?:[/?#].*)?$/i
8400
+ );
8401
+ if (!urlMatch?.[1] || !urlMatch[2] || !urlMatch[3]) {
8402
+ return null;
8403
+ }
8404
+ return {
8405
+ owner: urlMatch[1],
8406
+ name: urlMatch[2],
8407
+ number: Number(urlMatch[3])
8408
+ };
8409
+ }
8410
+ function hasSuccessfulCodeRabbitStatusCheck(statusChecksValue) {
8411
+ if (!Array.isArray(statusChecksValue)) {
8412
+ return false;
8413
+ }
8414
+ return statusChecksValue.some((entry) => {
8415
+ if (!entry || typeof entry !== "object") return false;
8416
+ const record = entry;
8417
+ const label = [
8418
+ record.context,
8419
+ record.name,
8420
+ extractNestedString(record, ["app", "name"]),
8421
+ extractNestedString(record, ["checkSuite", "app", "name"])
8422
+ ].filter((value) => typeof value === "string" && value.trim().length > 0).join(" ").toLowerCase();
8423
+ if (!label.includes("coderabbit")) return false;
8424
+ const state = String(record.state || record.conclusion || "").trim().toUpperCase();
8425
+ return state === "SUCCESS";
8426
+ });
8427
+ }
8428
+ function isCodeRabbitReviewThread(threadValue) {
8429
+ const comments = extractNestedArray(threadValue, ["comments", "nodes"]);
8430
+ if (!comments) {
8431
+ return false;
8432
+ }
8433
+ return comments.some(
8434
+ (comment) => extractNestedString(comment, ["author", "login"]).toLowerCase().startsWith("coderabbitai")
8435
+ );
8436
+ }
8437
+ function isReviewThreadResolved(threadValue) {
8438
+ if (!threadValue || typeof threadValue !== "object") {
8439
+ return false;
8440
+ }
8441
+ const record = threadValue;
8442
+ return record.isResolved === true || record.isOutdated === true;
8443
+ }
8444
+ function hasCodeRabbitActionableReview(reviewsValue) {
8445
+ if (!Array.isArray(reviewsValue)) {
8446
+ return false;
8447
+ }
8448
+ return reviewsValue.some((entry) => {
8449
+ if (!entry || typeof entry !== "object") return false;
8450
+ const authorLogin = extractNestedString(entry, ["author", "login"]).toLowerCase();
8451
+ if (authorLogin !== "coderabbitai") return false;
8452
+ const state = String(entry.state || "").trim().toUpperCase();
8453
+ if (state === "CHANGES_REQUESTED") return true;
8454
+ if (state !== "COMMENTED") return false;
8455
+ const body = String(entry.body || "");
8456
+ const actionableMatch = body.match(/Actionable comments posted:\s*(\d+)/i);
8457
+ return actionableMatch ? Number(actionableMatch[1]) > 0 : false;
8458
+ });
8459
+ }
8460
+ function extractNestedArray(value, pathSegments) {
8461
+ let current = value;
8462
+ for (const segment of pathSegments) {
8463
+ if (!current || typeof current !== "object") {
8464
+ return null;
8465
+ }
8466
+ current = current[segment];
8467
+ }
8468
+ return Array.isArray(current) ? current : null;
8469
+ }
8470
+ function findLatestCodeRabbitRateLimitCommentAt(commentsValue, headRefOid) {
8471
+ if (!Array.isArray(commentsValue)) {
8472
+ return null;
8473
+ }
8474
+ let latest = null;
8475
+ for (const entry of commentsValue) {
8476
+ if (!entry || typeof entry !== "object") continue;
8477
+ const authorLogin = extractNestedString(entry, ["author", "login"]).toLowerCase();
8478
+ if (authorLogin !== "coderabbitai") continue;
8479
+ const body = String(entry.body || "");
8480
+ if (!isCodeRabbitRateLimitBody(body, headRefOid)) continue;
8481
+ const createdAt = String(entry.createdAt || "").trim();
8482
+ if (!createdAt) continue;
8483
+ if (!latest || createdAt > latest) {
8484
+ latest = createdAt;
8485
+ }
8486
+ }
8487
+ return latest;
8488
+ }
8489
+ function findLatestCodeRabbitReviewAt(reviewsValue) {
8490
+ if (!Array.isArray(reviewsValue)) {
8491
+ return null;
8492
+ }
8493
+ let latest = null;
8494
+ for (const entry of reviewsValue) {
8495
+ if (!entry || typeof entry !== "object") continue;
8496
+ const authorLogin = extractNestedString(entry, ["author", "login"]).toLowerCase();
8497
+ if (authorLogin !== "coderabbitai") continue;
8498
+ const submittedAt = String(entry.submittedAt || "").trim();
8499
+ if (!submittedAt) continue;
8500
+ if (!latest || submittedAt > latest) {
8501
+ latest = submittedAt;
8502
+ }
8503
+ }
8504
+ return latest;
8505
+ }
8506
+ function findLatestCodeRabbitReviewedHead(reviewsValue) {
8507
+ if (!Array.isArray(reviewsValue)) {
8508
+ return null;
8509
+ }
8510
+ let latestReview = null;
8511
+ for (const entry of reviewsValue) {
8512
+ if (!entry || typeof entry !== "object") continue;
8513
+ const authorLogin = extractNestedString(entry, ["author", "login"]).toLowerCase();
8514
+ if (authorLogin !== "coderabbitai") continue;
8515
+ const submittedAt = String(entry.submittedAt || "").trim();
8516
+ if (!submittedAt) continue;
8517
+ const body = String(entry.body || "");
8518
+ const reviewedHead = extractReviewedHeadFromReviewBody(body);
8519
+ if (!latestReview || submittedAt > latestReview.submittedAt) {
8520
+ latestReview = { submittedAt, reviewedHead };
8521
+ }
8522
+ }
8523
+ return latestReview?.reviewedHead ?? null;
8524
+ }
8525
+ function isCodeRabbitRateLimitBody(body, headRefOid) {
8526
+ const normalized = body.toLowerCase();
8527
+ if (!normalized.includes("rate limited by coderabbit.ai") && !normalized.includes("rate limit exceeded")) {
8528
+ return false;
8529
+ }
8530
+ if (!headRefOid) {
8531
+ return true;
8532
+ }
8533
+ const shortHead = headRefOid.slice(0, 7);
8534
+ return normalized.includes(headRefOid) || normalized.includes(shortHead);
8535
+ }
8536
+ function extractReviewedHeadFromReviewBody(body) {
8537
+ const match = body.match(/between\s+[0-9a-f]{7,40}\s+and\s+([0-9a-f]{7,40})/i);
8538
+ if (!match) {
8539
+ return null;
8540
+ }
8541
+ return match[1].trim().toLowerCase();
8542
+ }
8543
+ function matchesCommitReference(headRefOid, reviewedHead) {
8544
+ const normalizedHead = headRefOid.trim().toLowerCase();
8545
+ const normalizedReviewedHead = reviewedHead.trim().toLowerCase();
8546
+ return normalizedHead === normalizedReviewedHead || normalizedHead.startsWith(normalizedReviewedHead) || normalizedReviewedHead.startsWith(normalizedHead);
8547
+ }
8548
+ function extractNestedString(value, pathSegments) {
8549
+ let current = value;
8550
+ for (const segment of pathSegments) {
8551
+ if (!current || typeof current !== "object") {
8552
+ return "";
8553
+ }
8554
+ current = current[segment];
8555
+ }
8556
+ return typeof current === "string" ? current.trim() : "";
8557
+ }
8558
+
8559
+ // src/commands/workflow-stage.ts
8560
+ function workflowStageCommand(program2) {
8561
+ program2.command("workflow-stage [feature-name]").description("Resolve the current high-level workflow stage for the active feature").option("--json", "Output JSON for agents and hooks").option("--component <component>", "Component name for multi projects").action(async (featureName, options) => {
8562
+ try {
8563
+ const payload = await collectWorkflowStage(
8564
+ process.cwd(),
8565
+ featureName,
8566
+ options.component
8567
+ );
8568
+ if (options.json) {
8569
+ console.log(JSON.stringify(payload, null, 2));
8570
+ return;
8571
+ }
8572
+ if (payload.status !== "ok") {
8573
+ console.log(`${payload.status}: ${payload.reasonCode}`);
8574
+ process.exitCode = 1;
8575
+ return;
8576
+ }
8577
+ console.log(`stage: ${payload.stage}`);
8578
+ if (payload.nextAction) {
8579
+ console.log(`next: ${payload.nextAction.category}`);
8580
+ }
8581
+ } catch (error) {
8582
+ const cliError = toCliError(error);
8583
+ if (options.json) {
8584
+ console.log(
8585
+ JSON.stringify(
8586
+ {
8587
+ status: "error",
8588
+ reasonCode: cliError.code,
8589
+ error: cliError.message
8590
+ },
8591
+ null,
8592
+ 2
8593
+ )
8594
+ );
8595
+ process.exitCode = 1;
8596
+ return;
8597
+ }
8598
+ process.stderr.write(`[${cliError.code}] ${cliError.message}
8599
+ `);
8600
+ process.exitCode = 1;
8601
+ }
8602
+ });
8603
+ }
8604
+ var FEATURE_DOC_FILE_PATTERN = /^features\/(?:[^/]+\/)?F\d{3,}[^/]*\/(spec|plan|tasks|decisions|issue|pr)\.md$/i;
8605
+ var CODE_FILE_PATTERN = /(^|\/)(Dockerfile|Makefile)$|\.(c|cc|cjs|cpp|cs|css|cts|go|h|hpp|html|java|js|json|jsx|kt|mjs|mts|php|py|rb|rs|scss|sh|sql|swift|ts|tsx|vue|yaml|yml|zsh)$/i;
8606
+ var WORKFLOW_SYNC_MARKER_PATTERN = /<!--\s*lee-spec-kit:workflow-sync\s+([0-9]{4}-[0-9]{2}-[0-9]{2}T[^ ]+?)\s*-->/gi;
8607
+ function workflowAuditCommand(program2) {
8608
+ program2.command("workflow-audit").description("Validate whether code changes have been synchronized back into feature docs").option("--json", "Output JSON for hooks and agents").action(async (options) => {
8609
+ try {
8610
+ const payload = await collectWorkflowAudit(process.cwd());
8611
+ if (options.json) {
8612
+ console.log(JSON.stringify(payload, null, 2));
8613
+ return;
8614
+ }
8615
+ console.log(`${payload.status}: ${payload.reasonCode}`);
8616
+ } catch (error) {
8617
+ const cliError = toCliError(error);
8618
+ const payload = {
8619
+ status: "error",
8620
+ reasonCode: "UNEXPECTED_ERROR",
8621
+ docsDir: null,
8622
+ activeFeatureRef: null,
8623
+ changedCodePaths: [],
8624
+ changedFeatureDocPaths: [],
8625
+ latestCodeChangeAt: null,
8626
+ latestFeatureDocSyncAt: null
8627
+ };
8628
+ if (options.json) {
8629
+ console.log(
8630
+ JSON.stringify(
8631
+ {
8632
+ ...payload,
8633
+ error: cliError.message
8634
+ },
8635
+ null,
8636
+ 2
8637
+ )
8638
+ );
8639
+ return;
8640
+ }
8641
+ process.stderr.write(`[${cliError.code}] ${cliError.message}
8642
+ `);
8643
+ process.exitCode = 1;
8644
+ }
8645
+ });
8646
+ }
8647
+ async function collectWorkflowAudit(cwd) {
8648
+ const config = await getConfig(cwd);
8649
+ if (!config) {
8650
+ throw createCliError("CONFIG_NOT_FOUND", "Config file not found. Run `init` first.");
8651
+ }
8652
+ const activeFeature = await resolveActiveFeature(cwd);
8653
+ const activeFeatureRef = activeFeature?.folderName ?? null;
8654
+ const codeRootResolution = resolveCodeRepoRoots(cwd, config, activeFeature);
8655
+ const codeRoots = codeRootResolution.codeRoots;
8656
+ if (codeRootResolution.errorReasonCode) {
8657
+ return {
8658
+ status: "error",
8659
+ reasonCode: codeRootResolution.errorReasonCode,
8660
+ docsDir: config.docsDir,
8661
+ activeFeatureRef,
8662
+ changedCodePaths: [],
8663
+ changedFeatureDocPaths: [],
8664
+ latestCodeChangeAt: null,
8665
+ latestFeatureDocSyncAt: null
8666
+ };
8667
+ }
8668
+ if (codeRoots.length === 0) {
8669
+ return {
8670
+ status: "skipped",
8671
+ reasonCode: "NO_GIT_REPOSITORY",
8672
+ docsDir: config.docsDir,
8673
+ activeFeatureRef,
8674
+ changedCodePaths: [],
8675
+ changedFeatureDocPaths: [],
8676
+ latestCodeChangeAt: null,
8677
+ latestFeatureDocSyncAt: null
8678
+ };
8679
+ }
8680
+ const changedCodePaths = collectChangedRecords(codeRoots, config.docsDir).filter(
8681
+ isCodeChange
8682
+ );
8683
+ const outOfScopeStandaloneCodePaths = collectOutOfScopeStandaloneCodeChanges(
8684
+ config,
8685
+ activeFeature,
8686
+ codeRoots
8687
+ );
8688
+ const combinedChangedCodePaths = [
8689
+ ...changedCodePaths,
8690
+ ...outOfScopeStandaloneCodePaths
8691
+ ];
8692
+ const docsRepoRoot = resolveDocsRepoRoot(config.docsDir);
8693
+ const changedFeatureDocPaths = docsRepoRoot ? collectChangedRecords([docsRepoRoot], config.docsDir).filter(isFeatureDocChange) : [];
8694
+ const meaningfulChangedFeatureDocPaths = await filterMeaningfulFeatureDocRecords(
8695
+ config,
8696
+ activeFeature,
8697
+ changedFeatureDocPaths
8698
+ );
8699
+ const scopedFeatureDocPaths = activeFeatureRef ? meaningfulChangedFeatureDocPaths.filter(
8700
+ (record) => featureRefFromDocPath(record.relativeToDocs) === activeFeatureRef
8701
+ ) : [];
8702
+ const allMeaningfulFeatureDocPaths = meaningfulChangedFeatureDocPaths;
8703
+ const latestCodeChangeAt = await getLatestMtimeIso(combinedChangedCodePaths);
8704
+ const latestFeatureDocSyncAt = await getLatestWorkflowSyncMarkerAt(activeFeature);
8705
+ if (combinedChangedCodePaths.length === 0) {
8706
+ return {
8707
+ status: "ok",
8708
+ reasonCode: "WORKFLOW_IN_SYNC",
8709
+ docsDir: config.docsDir,
8710
+ activeFeatureRef,
8711
+ changedCodePaths: [],
8712
+ changedFeatureDocPaths: allMeaningfulFeatureDocPaths.map((item) => item.relativeToRepo),
8713
+ latestCodeChangeAt: null,
8714
+ latestFeatureDocSyncAt
8715
+ };
8716
+ }
8717
+ if (!activeFeatureRef) {
8718
+ return {
8719
+ status: "needs_sync",
8720
+ reasonCode: "ACTIVE_FEATURE_SCOPE_UNCLEAR",
8721
+ docsDir: config.docsDir,
8722
+ activeFeatureRef: null,
8723
+ changedCodePaths: combinedChangedCodePaths.map((item) => item.relativeToRepo),
8724
+ changedFeatureDocPaths: allMeaningfulFeatureDocPaths.map((item) => item.relativeToRepo),
8725
+ latestCodeChangeAt,
8726
+ latestFeatureDocSyncAt
8727
+ };
8728
+ }
8729
+ if (outOfScopeStandaloneCodePaths.length > 0) {
8730
+ return {
8731
+ status: "needs_sync",
8732
+ reasonCode: "ACTIVE_FEATURE_SCOPE_UNCLEAR",
8733
+ docsDir: config.docsDir,
8734
+ activeFeatureRef,
8735
+ changedCodePaths: combinedChangedCodePaths.map((item) => item.relativeToRepo),
8736
+ changedFeatureDocPaths: allMeaningfulFeatureDocPaths.map((item) => item.relativeToRepo),
8737
+ latestCodeChangeAt,
8738
+ latestFeatureDocSyncAt
8739
+ };
8740
+ }
8741
+ const needsSync = scopedFeatureDocPaths.length === 0 || !latestFeatureDocSyncAt || !latestCodeChangeAt || latestCodeChangeAt > latestFeatureDocSyncAt;
8742
+ return {
8743
+ status: needsSync ? "needs_sync" : "ok",
6961
8744
  reasonCode: needsSync ? "CODE_WITHOUT_DOCS_SYNC" : "WORKFLOW_IN_SYNC",
6962
8745
  docsDir: config.docsDir,
6963
8746
  activeFeatureRef,
@@ -7233,13 +9016,35 @@ var DEFAULT_MANAGED_DOC_FILES = [
7233
9016
  ".gitignore"
7234
9017
  ];
7235
9018
 
9019
+ // src/utils/commit-conventions.ts
9020
+ var PROJECT_COMMIT_PREFIX_PATTERN = /^(feat|fix|refactor|test|chore)\(#(\d+)\):\s+\S.+$/i;
9021
+ var DOCS_COMMIT_PREFIX_PATTERN = /^docs\(#(\d+)\):\s+\S.+$/i;
9022
+ function matchesProjectCommitConvention(message, issueNumber) {
9023
+ const normalized = String(message || "").trim();
9024
+ if (!normalized) return false;
9025
+ const match = normalized.match(PROJECT_COMMIT_PREFIX_PATTERN);
9026
+ if (!match) return false;
9027
+ return Number(match[2]) === issueNumber;
9028
+ }
9029
+ function matchesDocsCommitConvention(message, issueNumber) {
9030
+ const normalized = String(message || "").trim();
9031
+ if (!normalized) return false;
9032
+ const match = normalized.match(DOCS_COMMIT_PREFIX_PATTERN);
9033
+ if (!match) return false;
9034
+ return Number(match[1]) === issueNumber;
9035
+ }
9036
+
7236
9037
  // src/commands/commit-audit.ts
7237
9038
  var CANONICAL_FEATURE_DOC_PATTERN = /^features\/(?:[^/]+\/)?F\d{3,}[^/]*\/(spec|plan|tasks|decisions|issue|pr)\.md$/i;
7238
9039
  var FEATURE_DOC_CANDIDATE_PATTERN = /^features\/(?:[^/]+\/)?F\d{3,}[^/]*\/(.+)$/i;
7239
9040
  function commitAuditCommand(program2) {
7240
- program2.command("commit-audit").description("Validate staged docs paths before commit").option("--json", "Output JSON for hooks and agents").option("--git-root <path>", "Override the git root used for staged-path inspection").action(async (options) => {
9041
+ program2.command("commit-audit").description("Validate staged docs paths before commit").option("--json", "Output JSON for hooks and agents").option("--git-root <path>", "Override the git root used for staged-path inspection").option("--message <message>", "Validate a commit subject against the current workflow convention").action(async (options) => {
7241
9042
  try {
7242
- const payload = await collectCommitAudit(process.cwd(), options.gitRoot);
9043
+ const payload = await collectCommitAudit(
9044
+ process.cwd(),
9045
+ options.gitRoot,
9046
+ options.message
9047
+ );
7243
9048
  if (options.json) {
7244
9049
  console.log(JSON.stringify(payload, null, 2));
7245
9050
  return;
@@ -7274,7 +9079,7 @@ function commitAuditCommand(program2) {
7274
9079
  }
7275
9080
  });
7276
9081
  }
7277
- async function collectCommitAudit(cwd, gitRootOverride) {
9082
+ async function collectCommitAudit(cwd, gitRootOverride, commitMessage) {
7278
9083
  const config = await getConfig(cwd);
7279
9084
  if (!config) {
7280
9085
  throw createCliError("CONFIG_NOT_FOUND", "Config file not found. Run `init` first.");
@@ -7315,6 +9120,16 @@ async function collectCommitAudit(cwd, gitRootOverride) {
7315
9120
  stagedEntries,
7316
9121
  config.allowedDocsEntries
7317
9122
  );
9123
+ const commitMessageViolation = await collectCommitMessageViolation(
9124
+ cwd,
9125
+ config,
9126
+ repoRoot,
9127
+ stagedEntries,
9128
+ commitMessage
9129
+ );
9130
+ if (commitMessageViolation) {
9131
+ violations.push(commitMessageViolation);
9132
+ }
7318
9133
  if (violations.length === 0) {
7319
9134
  return {
7320
9135
  status: "ok",
@@ -7359,6 +9174,9 @@ function collectAllowedCommitRepoRoots(config, cwd) {
7359
9174
  if (projectRepoRoot) {
7360
9175
  allowed.add(path8.resolve(projectRepoRoot));
7361
9176
  }
9177
+ for (const worktreeRepoRoot of collectManagedWorktreeRepoRoots(config, projectRoot)) {
9178
+ allowed.add(path8.resolve(worktreeRepoRoot));
9179
+ }
7362
9180
  }
7363
9181
  return allowed;
7364
9182
  }
@@ -7368,6 +9186,28 @@ function collectAllowedCommitRepoRoots(config, cwd) {
7368
9186
  }
7369
9187
  return allowed;
7370
9188
  }
9189
+ function collectManagedWorktreeRepoRoots(config, projectRoot) {
9190
+ const managedRoot = resolveStandaloneManagedWorktreeRoot(config, projectRoot);
9191
+ if (!managedRoot) {
9192
+ return [];
9193
+ }
9194
+ const output = runGitCapture(["worktree", "list", "--porcelain"], projectRoot) || "";
9195
+ const roots = /* @__PURE__ */ new Set();
9196
+ for (const rawLine of output.split("\n")) {
9197
+ const line = rawLine.trim();
9198
+ if (!line.startsWith("worktree ")) continue;
9199
+ const worktreePath = path8.resolve(line.slice("worktree ".length).trim());
9200
+ if (isSameOrWithin2(path8.resolve(managedRoot), worktreePath)) {
9201
+ roots.add(worktreePath);
9202
+ }
9203
+ }
9204
+ return [...roots];
9205
+ }
9206
+ function isSameOrWithin2(parentDir, candidateDir) {
9207
+ const resolvedParent = path8.resolve(parentDir);
9208
+ const resolvedCandidate = path8.resolve(candidateDir);
9209
+ return resolvedParent === resolvedCandidate || resolvedCandidate.startsWith(`${resolvedParent}${path8.sep}`);
9210
+ }
7371
9211
  function parseStagedPaths(output) {
7372
9212
  const staged = /* @__PURE__ */ new Map();
7373
9213
  for (const rawLine of output.split("\n")) {
@@ -7384,10 +9224,10 @@ function parseStagedPaths(output) {
7384
9224
  staged.set(`path:${normalizeSlashes3(parts[1])}`, `${status}:path`);
7385
9225
  }
7386
9226
  return [...staged.entries()].map(([encodedPath, encodedStatus]) => {
7387
- const [role, path25] = encodedPath.split(":", 2);
9227
+ const [role, path26] = encodedPath.split(":", 2);
7388
9228
  const [status, entryRole] = encodedStatus.split(":", 2);
7389
9229
  return {
7390
- path: path25,
9230
+ path: path26,
7391
9231
  status,
7392
9232
  role: entryRole || role || "path"
7393
9233
  };
@@ -7446,6 +9286,9 @@ function collectCommitViolations(repoRoot, docsDir, stagedEntries, allowed) {
7446
9286
  function resolveReasonCode(violations) {
7447
9287
  const kinds = new Set(violations.map((entry) => entry.kind));
7448
9288
  if (kinds.size > 1) return "DOCS_COMMIT_POLICY_VIOLATION";
9289
+ if (kinds.has("commit_message_policy")) {
9290
+ return "COMMIT_MESSAGE_POLICY_VIOLATION";
9291
+ }
7449
9292
  if (kinds.has("unsupported_git_target")) return "UNSUPPORTED_GIT_TARGET";
7450
9293
  if (kinds.has("unmanaged_docs_entry")) return "UNMANAGED_DOCS_COMMIT";
7451
9294
  if (kinds.has("canonical_feature_doc_deletion")) {
@@ -7453,6 +9296,36 @@ function resolveReasonCode(violations) {
7453
9296
  }
7454
9297
  return "NON_CANONICAL_FEATURE_DOC_COMMIT";
7455
9298
  }
9299
+ async function collectCommitMessageViolation(cwd, config, repoRoot, stagedEntries, commitMessage) {
9300
+ const normalizedMessage = String(commitMessage || "").trim();
9301
+ if (!normalizedMessage) {
9302
+ return null;
9303
+ }
9304
+ const selection = await resolveFeatureSelection(cwd);
9305
+ if (selection.status !== "selected" || !selection.matchedFeature?.issueNumber) {
9306
+ return null;
9307
+ }
9308
+ const issueNumber = selection.matchedFeature.issueNumber;
9309
+ const docsRepoRoot = runGitCapture(["rev-parse", "--show-toplevel"], config.docsDir);
9310
+ const normalizedRepoRoot = path8.resolve(repoRoot);
9311
+ const normalizedDocsRepoRoot = docsRepoRoot ? path8.resolve(docsRepoRoot) : null;
9312
+ const docsOnlyCommit = stagedEntries.length > 0 && stagedEntries.every((entry) => {
9313
+ const absolutePath = path8.resolve(repoRoot, entry.path);
9314
+ const relativeToDocs = normalizeSlashes3(path8.relative(config.docsDir, absolutePath));
9315
+ return !!relativeToDocs && relativeToDocs !== "" && !relativeToDocs.startsWith("..");
9316
+ });
9317
+ const isDocsCommit = !!normalizedDocsRepoRoot && normalizedDocsRepoRoot === normalizedRepoRoot && (config.docsRepo === "standalone" || docsOnlyCommit);
9318
+ const valid = isDocsCommit ? matchesDocsCommitConvention(normalizedMessage, issueNumber) : matchesProjectCommitConvention(normalizedMessage, issueNumber);
9319
+ if (valid) {
9320
+ return null;
9321
+ }
9322
+ const expected = isDocsCommit ? `docs(#${issueNumber}): ...` : `type(#${issueNumber}): ... (feat/fix/refactor/test/chore)`;
9323
+ return {
9324
+ path: "(commit message)",
9325
+ kind: "commit_message_policy",
9326
+ detail: `Commit subject must follow the issue-scoped convention for this feature. Expected ${expected}, received "${normalizedMessage}".`
9327
+ };
9328
+ }
7456
9329
  function normalizeEntryName(value) {
7457
9330
  return value.trim().toLowerCase();
7458
9331
  }
@@ -7632,6 +9505,7 @@ function configureRootCommandSurface() {
7632
9505
  ["docs", "Workflow Policy Commands:"],
7633
9506
  ["detect", "Workflow Policy Commands:"],
7634
9507
  ["github", "Workflow Policy Commands:"],
9508
+ ["workflow-stage", "Workflow Policy Commands:"],
7635
9509
  ["integrations", "Codex Integration Commands:"],
7636
9510
  ["commit-audit", "Codex Integration Commands:"],
7637
9511
  ["workflow-audit", "Codex Integration Commands:"]
@@ -7658,6 +9532,7 @@ configCommand(program);
7658
9532
  githubCommand(program);
7659
9533
  docsCommand(program);
7660
9534
  detectCommand(program);
9535
+ workflowStageCommand(program);
7661
9536
  integrationsCommand(program);
7662
9537
  workflowAuditCommand(program);
7663
9538
  commitAuditCommand(program);