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