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