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