lee-spec-kit 0.8.3 → 0.8.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +2 -0
- package/README.md +2 -0
- package/dist/{hooks-B5UIIZYN.js → hooks-43P4YKHY.js} +3 -4
- package/dist/hooks-43P4YKHY.js.map +1 -0
- package/dist/index.js +474 -21
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/templates/en/common/README.md +1 -4
- package/templates/en/common/agents/agents.md +1 -0
- package/templates/en/common/agents/git-workflow.md +14 -2
- package/templates/en/common/agents/skills/execute-task.md +1 -1
- package/templates/en/common/features/README.md +4 -8
- package/templates/en/common/features/feature-base/decisions.md +1 -0
- package/templates/en/common/features/feature-base/tasks.md +4 -3
- package/templates/ko/common/README.md +1 -4
- package/templates/ko/common/agents/agents.md +1 -0
- package/templates/ko/common/agents/git-workflow.md +13 -2
- package/templates/ko/common/agents/skills/execute-task.md +1 -1
- package/templates/ko/common/features/README.md +4 -8
- package/templates/ko/common/features/feature-base/decisions.md +1 -0
- package/templates/ko/common/features/feature-base/tasks.md +4 -3
- package/dist/hooks-B5UIIZYN.js.map +0 -1
package/dist/index.js
CHANGED
|
@@ -1297,8 +1297,9 @@ Before taking the next workflow step:
|
|
|
1297
1297
|
- task commit checkpoints after each completed task
|
|
1298
1298
|
8. In standalone mode, keep the docs repo on its docs branch and do not create feature branches or worktrees there
|
|
1299
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.
|
|
1301
|
-
11.
|
|
1300
|
+
10. In standalone mode, do not hand-write \`git worktree add\`; run the exact \`nextAction.command\` from \`workflow-stage\` so the managed workspace path, stale directory cleanup, and \`.env\` / \`.env.*\` copy step stay consistent
|
|
1301
|
+
11. Keep docs and code synchronized; if code changes materially, update the active feature docs in the same turn before stopping
|
|
1302
|
+
12. When docs are synced to code, keep exactly one explicit marker like \`<!-- lee-spec-kit:workflow-sync 2026-04-16T12:34:56.789Z -->\` in a single active feature doc (prefer \`tasks.md\` or \`decisions.md\`): replace an existing marker timestamp or remove duplicates instead of appending another marker, so \`workflow-audit\` can prove the sync happened after the latest code change
|
|
1302
1303
|
|
|
1303
1304
|
Approval and remote actions:
|
|
1304
1305
|
|
|
@@ -1313,7 +1314,7 @@ Approval and remote actions:
|
|
|
1313
1314
|
Validation:
|
|
1314
1315
|
|
|
1315
1316
|
- Prefer \`npx lee-spec-kit commit-audit --json\` for commit-time staged docs path validation
|
|
1316
|
-
- Prefer \`npx lee-spec-kit workflow-audit --json\` as the default docs-sync validator for Codex hooks and end-of-turn checks; it expects the active feature docs to carry
|
|
1317
|
+
- Prefer \`npx lee-spec-kit workflow-audit --json\` as the default docs-sync validator for Codex hooks and end-of-turn checks; it expects the active feature docs to carry one fresh \`lee-spec-kit:workflow-sync\` marker after meaningful code/doc sync
|
|
1317
1318
|
`;
|
|
1318
1319
|
function renderManagedSegment(lang, docsRepo) {
|
|
1319
1320
|
return `${LEE_SPEC_KIT_AGENTS_BEGIN}
|
|
@@ -1464,10 +1465,33 @@ function resolveManagedWorktreePath(config, projectRoot, branchName) {
|
|
|
1464
1465
|
normalizeBranchNameForWorktree(branchName)
|
|
1465
1466
|
);
|
|
1466
1467
|
}
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
const
|
|
1470
|
-
|
|
1468
|
+
var registeredWorktreeCache = /* @__PURE__ */ new Map();
|
|
1469
|
+
function listRegisteredGitWorktrees(projectRoot) {
|
|
1470
|
+
const resolvedProjectRoot = path8.resolve(projectRoot);
|
|
1471
|
+
const cached = registeredWorktreeCache.get(resolvedProjectRoot);
|
|
1472
|
+
if (cached) return cached;
|
|
1473
|
+
const output = runGitCapture(
|
|
1474
|
+
["worktree", "list", "--porcelain"],
|
|
1475
|
+
resolvedProjectRoot
|
|
1476
|
+
) || "";
|
|
1477
|
+
const worktrees = /* @__PURE__ */ new Set();
|
|
1478
|
+
for (const line of output.split(/\r?\n/u)) {
|
|
1479
|
+
if (!line.startsWith("worktree ")) continue;
|
|
1480
|
+
const listedPath = line.slice("worktree ".length).trim();
|
|
1481
|
+
if (listedPath) worktrees.add(path8.resolve(listedPath));
|
|
1482
|
+
}
|
|
1483
|
+
registeredWorktreeCache.set(resolvedProjectRoot, worktrees);
|
|
1484
|
+
return worktrees;
|
|
1485
|
+
}
|
|
1486
|
+
function isRegisteredGitWorktree(projectRoot, worktreePath) {
|
|
1487
|
+
const resolvedTarget = path8.resolve(worktreePath);
|
|
1488
|
+
return listRegisteredGitWorktrees(projectRoot).has(resolvedTarget);
|
|
1489
|
+
}
|
|
1490
|
+
function buildManagedWorktreeStaleCleanupCommand(projectRoot, worktreePath) {
|
|
1491
|
+
return `if [ -d "${worktreePath}" ] && ! git -C "${projectRoot}" worktree list --porcelain | grep -Fxq "worktree ${worktreePath}"; then rm -rf "${worktreePath}"; fi`;
|
|
1492
|
+
}
|
|
1493
|
+
function buildManagedWorktreeEnvCopyCommand(projectRoot, worktreePath) {
|
|
1494
|
+
return `sh -c 'source_dir=$1; target_dir=$2; for source_env in "$source_dir"/.env "$source_dir"/.env.*; do [ -e "$source_env" ] || [ -L "$source_env" ] || continue; target_env="$target_dir/$(basename "$source_env")"; if [ ! -e "$target_env" ] && [ ! -L "$target_env" ]; then cp -p "$source_env" "$target_env"; fi; done' sh "${path8.resolve(projectRoot)}" "${path8.resolve(worktreePath)}"`;
|
|
1471
1495
|
}
|
|
1472
1496
|
|
|
1473
1497
|
// src/utils/init/options.ts
|
|
@@ -4023,7 +4047,7 @@ async function resolveExistingManagedWorktreePath(config, projectGitCwd, slug, f
|
|
|
4023
4047
|
(candidate) => resolveManagedWorktreePath(config, projectRoot, candidate)
|
|
4024
4048
|
);
|
|
4025
4049
|
for (const candidate of candidates) {
|
|
4026
|
-
if (await fs.pathExists(candidate)) {
|
|
4050
|
+
if (await fs.pathExists(candidate) && isRegisteredGitWorktree(projectRoot, candidate)) {
|
|
4027
4051
|
return candidate;
|
|
4028
4052
|
}
|
|
4029
4053
|
}
|
|
@@ -6744,6 +6768,380 @@ function docsCommand(program2) {
|
|
|
6744
6768
|
}
|
|
6745
6769
|
});
|
|
6746
6770
|
}
|
|
6771
|
+
|
|
6772
|
+
// src/utils/task-lines.ts
|
|
6773
|
+
function parseTaskLine(line, index = -1) {
|
|
6774
|
+
const match = line.match(
|
|
6775
|
+
/^\s*-\s*\[(TODO|DOING|DONE|REVIEW)\]((?:\[[^\]]+\])*)\s+(T-[A-Za-z0-9-]+)\s+(.+?)\s*$/
|
|
6776
|
+
);
|
|
6777
|
+
if (!match) return null;
|
|
6778
|
+
const tags = [...(match[2] || "").matchAll(/\[([^\]]+)\]/g)].map((entry) => (entry[1] || "").trim()).filter(Boolean);
|
|
6779
|
+
return {
|
|
6780
|
+
index,
|
|
6781
|
+
raw: line,
|
|
6782
|
+
status: match[1],
|
|
6783
|
+
tags,
|
|
6784
|
+
taskId: match[3],
|
|
6785
|
+
title: match[4]
|
|
6786
|
+
};
|
|
6787
|
+
}
|
|
6788
|
+
|
|
6789
|
+
// src/utils/doc-mutation.ts
|
|
6790
|
+
function collectRepeatableOption(value, previous = []) {
|
|
6791
|
+
return [...previous, value];
|
|
6792
|
+
}
|
|
6793
|
+
function normalizeRequiredText(value, label) {
|
|
6794
|
+
const normalized = (value || "").trim();
|
|
6795
|
+
if (!normalized || normalized === "-" || /^todo$/i.test(normalized)) {
|
|
6796
|
+
throw createCliError(
|
|
6797
|
+
"INVALID_ARGUMENT",
|
|
6798
|
+
`${label} must contain concrete text.`
|
|
6799
|
+
);
|
|
6800
|
+
}
|
|
6801
|
+
return normalized;
|
|
6802
|
+
}
|
|
6803
|
+
function normalizeRequiredItems(values, label) {
|
|
6804
|
+
const normalized = (values || []).map((value) => value.trim()).filter(Boolean);
|
|
6805
|
+
if (normalized.length === 0) {
|
|
6806
|
+
throw createCliError(
|
|
6807
|
+
"INVALID_ARGUMENT",
|
|
6808
|
+
`${label} must be provided at least once.`
|
|
6809
|
+
);
|
|
6810
|
+
}
|
|
6811
|
+
for (const value of normalized) {
|
|
6812
|
+
normalizeRequiredText(value, label);
|
|
6813
|
+
}
|
|
6814
|
+
return normalized;
|
|
6815
|
+
}
|
|
6816
|
+
async function resolveFeatureDocTarget(input) {
|
|
6817
|
+
const state = await resolveFeatureSelection(
|
|
6818
|
+
input.cwd,
|
|
6819
|
+
input.selector,
|
|
6820
|
+
input.component
|
|
6821
|
+
);
|
|
6822
|
+
if (state.status !== "selected" || !state.matchedFeature) {
|
|
6823
|
+
throw createCliError(
|
|
6824
|
+
"CONTEXT_SELECTION_REQUIRED",
|
|
6825
|
+
`A single feature is required. Pass <feature-name> explicitly.`
|
|
6826
|
+
);
|
|
6827
|
+
}
|
|
6828
|
+
const targetPath = path8.join(state.matchedFeature.path, input.fileName);
|
|
6829
|
+
if (!await fs.pathExists(targetPath)) {
|
|
6830
|
+
throw createCliError(
|
|
6831
|
+
"PRECONDITION_FAILED",
|
|
6832
|
+
`${input.fileName} not found for feature: ${state.matchedFeature.folderName}`
|
|
6833
|
+
);
|
|
6834
|
+
}
|
|
6835
|
+
return {
|
|
6836
|
+
feature: state.matchedFeature,
|
|
6837
|
+
path: targetPath
|
|
6838
|
+
};
|
|
6839
|
+
}
|
|
6840
|
+
function findSecondLevelHeadingIndex(lines, names) {
|
|
6841
|
+
const alternatives = names.map((name) => name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
|
|
6842
|
+
const pattern = new RegExp(`^\\s*##\\s+(${alternatives.join("|")})\\s*$`);
|
|
6843
|
+
return lines.findIndex((line) => pattern.test(line));
|
|
6844
|
+
}
|
|
6845
|
+
function findNextSecondLevelHeadingIndex(lines, start) {
|
|
6846
|
+
for (let index = start + 1; index < lines.length; index += 1) {
|
|
6847
|
+
if (/^\s*##\s+/.test(lines[index] || "")) return index;
|
|
6848
|
+
}
|
|
6849
|
+
return lines.length;
|
|
6850
|
+
}
|
|
6851
|
+
function normalizeMarkdownEnd(content) {
|
|
6852
|
+
return content.replace(/\s+$/g, "") + "\n";
|
|
6853
|
+
}
|
|
6854
|
+
function localDate() {
|
|
6855
|
+
return getLocalDateString();
|
|
6856
|
+
}
|
|
6857
|
+
function nextTaskSequence(content, featureFolderName) {
|
|
6858
|
+
const escaped = featureFolderName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
6859
|
+
const taskIdPattern = new RegExp(`\\bT-${escaped}-(\\d+)\\b`, "g");
|
|
6860
|
+
let max = 0;
|
|
6861
|
+
for (const match of content.matchAll(taskIdPattern)) {
|
|
6862
|
+
const parsed = Number(match[1] || "0");
|
|
6863
|
+
if (Number.isFinite(parsed) && parsed > max) max = parsed;
|
|
6864
|
+
}
|
|
6865
|
+
return max + 1;
|
|
6866
|
+
}
|
|
6867
|
+
function findTaskInsertIndex(lines, sectionStart, sectionEnd) {
|
|
6868
|
+
let lastTaskIndex = -1;
|
|
6869
|
+
for (let index = sectionStart; index < sectionEnd; index += 1) {
|
|
6870
|
+
if (parseTaskLine(lines[index] || "", index)) lastTaskIndex = index;
|
|
6871
|
+
}
|
|
6872
|
+
if (lastTaskIndex < 0) return sectionEnd;
|
|
6873
|
+
let insertIndex = lastTaskIndex + 1;
|
|
6874
|
+
while (insertIndex < sectionEnd) {
|
|
6875
|
+
const line = lines[insertIndex] || "";
|
|
6876
|
+
if (parseTaskLine(line, insertIndex)) break;
|
|
6877
|
+
if (/^\s{2,}\S/.test(line) || /^\s*$/.test(line)) {
|
|
6878
|
+
insertIndex += 1;
|
|
6879
|
+
continue;
|
|
6880
|
+
}
|
|
6881
|
+
break;
|
|
6882
|
+
}
|
|
6883
|
+
return insertIndex;
|
|
6884
|
+
}
|
|
6885
|
+
|
|
6886
|
+
// src/commands/task.ts
|
|
6887
|
+
function normalizeTaskRef(value) {
|
|
6888
|
+
const ref = normalizeRequiredText(value, "--ref").toUpperCase();
|
|
6889
|
+
if (ref === "NON-PRD") return ref;
|
|
6890
|
+
if (!/^PRD-[A-Z0-9][A-Z0-9-]*$/.test(ref)) {
|
|
6891
|
+
throw createCliError(
|
|
6892
|
+
"INVALID_ARGUMENT",
|
|
6893
|
+
"`--ref` must be `NON-PRD` or a `PRD-*` requirement key."
|
|
6894
|
+
);
|
|
6895
|
+
}
|
|
6896
|
+
return ref;
|
|
6897
|
+
}
|
|
6898
|
+
function formatTaskBlock(input) {
|
|
6899
|
+
return [
|
|
6900
|
+
`- [TODO][${input.ref}] ${input.taskId} ${input.title}`,
|
|
6901
|
+
` - Date: ${input.date}`,
|
|
6902
|
+
" - Acceptance:",
|
|
6903
|
+
...input.acceptanceItems.map((item) => ` - ${item}`),
|
|
6904
|
+
" - Checklist:",
|
|
6905
|
+
...input.checklistItems.map((item) => ` - [ ] ${item}`)
|
|
6906
|
+
];
|
|
6907
|
+
}
|
|
6908
|
+
async function runTaskAdd(featureName, options) {
|
|
6909
|
+
const target = await resolveFeatureDocTarget({
|
|
6910
|
+
cwd: process.cwd(),
|
|
6911
|
+
selector: featureName,
|
|
6912
|
+
component: options.component,
|
|
6913
|
+
fileName: "tasks.md"
|
|
6914
|
+
});
|
|
6915
|
+
const title = normalizeRequiredText(options.title, "--title");
|
|
6916
|
+
const ref = normalizeTaskRef(options.ref);
|
|
6917
|
+
const acceptanceItems = normalizeRequiredItems(options.acceptance, "--acceptance");
|
|
6918
|
+
const checklistItems = normalizeRequiredItems(options.check, "--check");
|
|
6919
|
+
const content = await fs.readFile(target.path, "utf-8");
|
|
6920
|
+
const lines = content.split("\n");
|
|
6921
|
+
const taskListIndex = findSecondLevelHeadingIndex(lines, ["Task List", "\uD0DC\uC2A4\uD06C \uBAA9\uB85D"]);
|
|
6922
|
+
if (taskListIndex < 0) {
|
|
6923
|
+
throw createCliError(
|
|
6924
|
+
"PRECONDITION_FAILED",
|
|
6925
|
+
"tasks.md is missing a `Task List` section."
|
|
6926
|
+
);
|
|
6927
|
+
}
|
|
6928
|
+
const sectionEnd = findNextSecondLevelHeadingIndex(lines, taskListIndex);
|
|
6929
|
+
const insertIndex = findTaskInsertIndex(lines, taskListIndex + 1, sectionEnd);
|
|
6930
|
+
const taskId = `T-${target.feature.folderName}-${String(
|
|
6931
|
+
nextTaskSequence(content, target.feature.folderName)
|
|
6932
|
+
).padStart(2, "0")}`;
|
|
6933
|
+
const recordedAt = localDate();
|
|
6934
|
+
const block = formatTaskBlock({
|
|
6935
|
+
ref,
|
|
6936
|
+
taskId,
|
|
6937
|
+
title,
|
|
6938
|
+
date: recordedAt,
|
|
6939
|
+
acceptanceItems,
|
|
6940
|
+
checklistItems
|
|
6941
|
+
});
|
|
6942
|
+
const shouldPrefixBlank = insertIndex > taskListIndex + 1 && (lines[insertIndex - 1] || "").trim() !== "";
|
|
6943
|
+
const shouldSuffixBlank = insertIndex < lines.length && (lines[insertIndex] || "").trim() !== "";
|
|
6944
|
+
lines.splice(
|
|
6945
|
+
insertIndex,
|
|
6946
|
+
0,
|
|
6947
|
+
...shouldPrefixBlank ? [""] : [],
|
|
6948
|
+
...block,
|
|
6949
|
+
...shouldSuffixBlank ? [""] : []
|
|
6950
|
+
);
|
|
6951
|
+
await fs.writeFile(target.path, normalizeMarkdownEnd(lines.join("\n")), "utf-8");
|
|
6952
|
+
const payload = {
|
|
6953
|
+
status: "ok",
|
|
6954
|
+
reasonCode: "TASK_ADDED",
|
|
6955
|
+
feature: target.feature.folderName,
|
|
6956
|
+
taskId,
|
|
6957
|
+
title,
|
|
6958
|
+
ref,
|
|
6959
|
+
tasksUpdated: true,
|
|
6960
|
+
tasksPath: target.path,
|
|
6961
|
+
recordedAt
|
|
6962
|
+
};
|
|
6963
|
+
if (options.json) {
|
|
6964
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
6965
|
+
return;
|
|
6966
|
+
}
|
|
6967
|
+
console.log(chalk.green(`Added task ${taskId} to ${target.feature.folderName}.`));
|
|
6968
|
+
console.log(chalk.gray(`- tasks.md updated: ${target.path}`));
|
|
6969
|
+
}
|
|
6970
|
+
function taskCommand(program2) {
|
|
6971
|
+
const task = program2.command("task").description("Patch feature task docs");
|
|
6972
|
+
task.command("add [feature-name]").description("Append a docs-only task block to tasks.md").requiredOption("--title <title>", "Task title").requiredOption("--ref <ref>", "Requirement ref: NON-PRD or PRD-* key").option(
|
|
6973
|
+
"--acceptance <text>",
|
|
6974
|
+
"Concrete acceptance item. Repeat to add more than one.",
|
|
6975
|
+
collectRepeatableOption,
|
|
6976
|
+
[]
|
|
6977
|
+
).option(
|
|
6978
|
+
"--check <text>",
|
|
6979
|
+
"Concrete checklist item. Repeat to add more than one.",
|
|
6980
|
+
collectRepeatableOption,
|
|
6981
|
+
[]
|
|
6982
|
+
).option("--component <component>", "Component name for multi projects").option("--json", "Output JSON").action(async (featureName, options) => {
|
|
6983
|
+
try {
|
|
6984
|
+
await runTaskAdd(featureName, options);
|
|
6985
|
+
} catch (error) {
|
|
6986
|
+
const cliError = toCliError(error);
|
|
6987
|
+
const suggestions = getCliErrorSuggestions(cliError.code, DEFAULT_LANG);
|
|
6988
|
+
if (options.json) {
|
|
6989
|
+
console.log(
|
|
6990
|
+
JSON.stringify({
|
|
6991
|
+
status: "error",
|
|
6992
|
+
reasonCode: cliError.code,
|
|
6993
|
+
error: cliError.message,
|
|
6994
|
+
suggestions
|
|
6995
|
+
})
|
|
6996
|
+
);
|
|
6997
|
+
process.exitCode = 1;
|
|
6998
|
+
return;
|
|
6999
|
+
}
|
|
7000
|
+
console.error(chalk.red(`[${cliError.code}] ${cliError.message}`));
|
|
7001
|
+
printCliErrorSuggestions(suggestions, DEFAULT_LANG);
|
|
7002
|
+
process.exitCode = 1;
|
|
7003
|
+
}
|
|
7004
|
+
});
|
|
7005
|
+
}
|
|
7006
|
+
function getNextDecisionSequence(content) {
|
|
7007
|
+
let max = 0;
|
|
7008
|
+
for (const match of content.matchAll(/^##\s+D(\d+):\s+/gm)) {
|
|
7009
|
+
if (/\{(?:Decision Title|결정 제목)\}/.test(match[0])) continue;
|
|
7010
|
+
const parsed = Number(match[1] || "0");
|
|
7011
|
+
if (Number.isFinite(parsed) && parsed > max) max = parsed;
|
|
7012
|
+
}
|
|
7013
|
+
return max + 1;
|
|
7014
|
+
}
|
|
7015
|
+
function findPlaceholderDecisionRange(content) {
|
|
7016
|
+
const match = /^##\s+D(\d+):\s+.*$/m.exec(content);
|
|
7017
|
+
if (!match || match.index === void 0) return null;
|
|
7018
|
+
const afterHeadingIndex = match.index + match[0].length;
|
|
7019
|
+
const nextHeadingMatch = /^##\s+D\d+:\s+/m.exec(content.slice(afterHeadingIndex));
|
|
7020
|
+
const end = nextHeadingMatch ? afterHeadingIndex + nextHeadingMatch.index : content.length;
|
|
7021
|
+
const block = content.slice(match.index, end);
|
|
7022
|
+
const isPlaceholder = /\{(?:Decision Title|결정 제목)\}/.test(match[0]) || /-\s+\*\*Decision\*\*:\s*(Final choice|최종 선택)/.test(block);
|
|
7023
|
+
if (!isPlaceholder) return null;
|
|
7024
|
+
const sequence = Number(match[1] || "1");
|
|
7025
|
+
return {
|
|
7026
|
+
start: match.index,
|
|
7027
|
+
end,
|
|
7028
|
+
sequence: Number.isFinite(sequence) ? sequence : 1
|
|
7029
|
+
};
|
|
7030
|
+
}
|
|
7031
|
+
function formatOptions(options) {
|
|
7032
|
+
return options.length > 0 ? options.join("; ") : "-";
|
|
7033
|
+
}
|
|
7034
|
+
function formatDecisionBlock(input) {
|
|
7035
|
+
return [
|
|
7036
|
+
`## ${input.decisionId}: ${input.title} (${input.date})`,
|
|
7037
|
+
"",
|
|
7038
|
+
`- **Context**: ${input.context}`,
|
|
7039
|
+
`- **Constraints**: ${input.constraints}`,
|
|
7040
|
+
`- **Options**: ${formatOptions(input.options)}`,
|
|
7041
|
+
`- **Decision**: ${input.decision}`,
|
|
7042
|
+
`- **Rationale**: ${input.rationale}`,
|
|
7043
|
+
"- **Trace**:",
|
|
7044
|
+
" - **At DOING start**: Recorded by `decision add` when the decision was created.",
|
|
7045
|
+
" - **Before DONE**: Update this line when the related task is completed.",
|
|
7046
|
+
" - **Post-merge check**: Update this line after merge when applicable.",
|
|
7047
|
+
"- **Evidence**:",
|
|
7048
|
+
...input.evidence.map((item) => ` - **Test/Log**: ${item}`),
|
|
7049
|
+
`- **Consequences**: ${input.consequence}`
|
|
7050
|
+
].join("\n");
|
|
7051
|
+
}
|
|
7052
|
+
async function runDecisionAdd(featureName, options) {
|
|
7053
|
+
const target = await resolveFeatureDocTarget({
|
|
7054
|
+
cwd: process.cwd(),
|
|
7055
|
+
selector: featureName,
|
|
7056
|
+
component: options.component,
|
|
7057
|
+
fileName: "decisions.md"
|
|
7058
|
+
});
|
|
7059
|
+
const title = normalizeRequiredText(options.title, "--title");
|
|
7060
|
+
const context = normalizeRequiredText(options.context, "--context");
|
|
7061
|
+
const decision = normalizeRequiredText(options.decision, "--decision");
|
|
7062
|
+
const rationale = normalizeRequiredText(options.rationale, "--rationale");
|
|
7063
|
+
const evidence = normalizeRequiredItems(options.evidence, "--evidence");
|
|
7064
|
+
const constraints = (options.constraints || "").trim() || "-";
|
|
7065
|
+
const consequence = (options.consequence || "").trim() || "-";
|
|
7066
|
+
const optionItems = (options.option || []).map((value) => value.trim()).filter(Boolean);
|
|
7067
|
+
const content = await fs.readFile(target.path, "utf-8");
|
|
7068
|
+
const placeholderRange = findPlaceholderDecisionRange(content);
|
|
7069
|
+
const decisionSequence = placeholderRange?.sequence ?? getNextDecisionSequence(content);
|
|
7070
|
+
const decisionId = `D${String(decisionSequence).padStart(3, "0")}`;
|
|
7071
|
+
const recordedAt = localDate();
|
|
7072
|
+
const block = formatDecisionBlock({
|
|
7073
|
+
decisionId,
|
|
7074
|
+
title,
|
|
7075
|
+
date: recordedAt,
|
|
7076
|
+
context,
|
|
7077
|
+
constraints,
|
|
7078
|
+
options: optionItems,
|
|
7079
|
+
decision,
|
|
7080
|
+
rationale,
|
|
7081
|
+
evidence,
|
|
7082
|
+
consequence
|
|
7083
|
+
});
|
|
7084
|
+
const nextContent = placeholderRange ? normalizeMarkdownEnd(
|
|
7085
|
+
`${content.slice(0, placeholderRange.start)}${block}${content.slice(
|
|
7086
|
+
placeholderRange.end
|
|
7087
|
+
)}`
|
|
7088
|
+
) : `${normalizeMarkdownEnd(content)}
|
|
7089
|
+
${block}
|
|
7090
|
+
`;
|
|
7091
|
+
await fs.writeFile(target.path, nextContent, "utf-8");
|
|
7092
|
+
const payload = {
|
|
7093
|
+
status: "ok",
|
|
7094
|
+
reasonCode: "DECISION_ADDED",
|
|
7095
|
+
feature: target.feature.folderName,
|
|
7096
|
+
decisionId,
|
|
7097
|
+
title,
|
|
7098
|
+
decisionsUpdated: true,
|
|
7099
|
+
decisionsPath: target.path,
|
|
7100
|
+
recordedAt
|
|
7101
|
+
};
|
|
7102
|
+
if (options.json) {
|
|
7103
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
7104
|
+
return;
|
|
7105
|
+
}
|
|
7106
|
+
console.log(chalk.green(`Added decision ${decisionId} to ${target.feature.folderName}.`));
|
|
7107
|
+
console.log(chalk.gray(`- decisions.md updated: ${target.path}`));
|
|
7108
|
+
}
|
|
7109
|
+
function decisionCommand(program2) {
|
|
7110
|
+
const decision = program2.command("decision").description("Patch feature decision docs");
|
|
7111
|
+
decision.command("add [feature-name]").description("Append a docs-only ADR block to decisions.md").requiredOption("--title <title>", "Decision title").requiredOption("--context <text>", "Decision context").option("--constraints <text>", "Decision constraints").option(
|
|
7112
|
+
"--option <text>",
|
|
7113
|
+
"Alternative considered. Repeat to add more than one.",
|
|
7114
|
+
collectRepeatableOption,
|
|
7115
|
+
[]
|
|
7116
|
+
).requiredOption("--decision <text>", "Final decision").requiredOption("--rationale <text>", "Decision rationale").option(
|
|
7117
|
+
"--evidence <text>",
|
|
7118
|
+
"Evidence link or test/log note. Repeat to add more than one.",
|
|
7119
|
+
collectRepeatableOption,
|
|
7120
|
+
[]
|
|
7121
|
+
).option("--consequence <text>", "Decision consequence").option("--component <component>", "Component name for multi projects").option("--json", "Output JSON").action(async (featureName, options) => {
|
|
7122
|
+
try {
|
|
7123
|
+
await runDecisionAdd(featureName, options);
|
|
7124
|
+
} catch (error) {
|
|
7125
|
+
const cliError = toCliError(error);
|
|
7126
|
+
const suggestions = getCliErrorSuggestions(cliError.code, DEFAULT_LANG);
|
|
7127
|
+
if (options.json) {
|
|
7128
|
+
console.log(
|
|
7129
|
+
JSON.stringify({
|
|
7130
|
+
status: "error",
|
|
7131
|
+
reasonCode: cliError.code,
|
|
7132
|
+
error: cliError.message,
|
|
7133
|
+
suggestions
|
|
7134
|
+
})
|
|
7135
|
+
);
|
|
7136
|
+
process.exitCode = 1;
|
|
7137
|
+
return;
|
|
7138
|
+
}
|
|
7139
|
+
console.error(chalk.red(`[${cliError.code}] ${cliError.message}`));
|
|
7140
|
+
printCliErrorSuggestions(suggestions, DEFAULT_LANG);
|
|
7141
|
+
process.exitCode = 1;
|
|
7142
|
+
}
|
|
7143
|
+
});
|
|
7144
|
+
}
|
|
6747
7145
|
function detectCommand(program2) {
|
|
6748
7146
|
program2.command("detect").description(tr(DEFAULT_LANG, "cli", "detect.cmdDescription")).option("--dir <dir>", tr(DEFAULT_LANG, "cli", "detect.optDir")).option("--json", tr(DEFAULT_LANG, "cli", "detect.optJson")).action(async (options) => {
|
|
6749
7147
|
try {
|
|
@@ -6924,7 +7322,7 @@ function registerCodexHooksIntegration(parent) {
|
|
|
6924
7322
|
removeLeeSpecKitCodexHooks,
|
|
6925
7323
|
resolveCodexHooksRepoRoot,
|
|
6926
7324
|
upsertLeeSpecKitCodexHooks
|
|
6927
|
-
} = await import('./hooks-
|
|
7325
|
+
} = await import('./hooks-43P4YKHY.js');
|
|
6928
7326
|
const repoRoot = config.docsRepo === "standalone" ? resolveConfiguredStandaloneWorkspaceRoot(config) : resolveCodexHooksRepoRoot(process.cwd());
|
|
6929
7327
|
if (!repoRoot) {
|
|
6930
7328
|
throw createCliError(
|
|
@@ -7385,15 +7783,20 @@ function getExpectedWorktreePath(config, projectGitCwd, branchName) {
|
|
|
7385
7783
|
return resolveManagedWorktreePath(config, projectRoot, branchName);
|
|
7386
7784
|
}
|
|
7387
7785
|
async function resolveExistingExpectedWorktreePath(config, projectGitCwd, branchName) {
|
|
7786
|
+
const projectRoot = resolveProjectRootFromGitCwd2(projectGitCwd);
|
|
7388
7787
|
const candidate = getExpectedWorktreePath(config, projectGitCwd, branchName);
|
|
7389
|
-
return await fs.pathExists(candidate) ? candidate : null;
|
|
7788
|
+
return await fs.pathExists(candidate) && isRegisteredGitWorktree(projectRoot, candidate) ? candidate : null;
|
|
7390
7789
|
}
|
|
7391
7790
|
function buildManagedWorktreeCreateCommand(config, projectGitCwd, branchName) {
|
|
7392
7791
|
const projectRoot = resolveProjectRootFromGitCwd2(projectGitCwd);
|
|
7393
7792
|
const worktreePath = getExpectedWorktreePath(config, projectGitCwd, branchName);
|
|
7394
7793
|
const worktreeParent = path8.dirname(worktreePath);
|
|
7395
|
-
const
|
|
7396
|
-
|
|
7794
|
+
const staleCleanupCommand = buildManagedWorktreeStaleCleanupCommand(
|
|
7795
|
+
projectRoot,
|
|
7796
|
+
worktreePath
|
|
7797
|
+
);
|
|
7798
|
+
const envCopyCommand = buildManagedWorktreeEnvCopyCommand(projectRoot, worktreePath);
|
|
7799
|
+
return `${staleCleanupCommand} && mkdir -p "${worktreeParent}" && (git -C "${projectRoot}" worktree add "${worktreePath}" "${branchName}" || git -C "${projectRoot}" worktree add -b "${branchName}" "${worktreePath}") && ${envCopyCommand}`;
|
|
7397
7800
|
}
|
|
7398
7801
|
function resolveRemotePrMergeMeta(prRef, projectGitCwd) {
|
|
7399
7802
|
if (!prRef) return null;
|
|
@@ -7484,7 +7887,7 @@ function buildPostMergeCleanupCommand(state) {
|
|
|
7484
7887
|
}
|
|
7485
7888
|
if (state.worktreePath) {
|
|
7486
7889
|
commandParts.push(
|
|
7487
|
-
`if [ -d "${state.worktreePath}" ]; then git -C "${state.projectRootGitCwd}" worktree remove "${state.worktreePath}"; fi`
|
|
7890
|
+
`if [ -d "${state.worktreePath}" ]; then if git -C "${state.worktreePath}" rev-parse --is-inside-work-tree >/dev/null 2>&1; then meaningful_changes=$(git -C "${state.worktreePath}" status --porcelain --untracked-files=normal 2>/dev/null || true); if [ -n "$meaningful_changes" ]; then printf '%s\\n' "Managed worktree has tracked or meaningful untracked changes; refusing cleanup: ${state.worktreePath}" >&2; exit 1; fi; git -C "${state.projectRootGitCwd}" worktree remove --force "${state.worktreePath}" || { git -C "${state.projectRootGitCwd}" worktree prune; rm -rf "${state.worktreePath}"; }; else leftover_meaningful=$(find "${state.worktreePath}" -mindepth 1 \\( -name ".next" -o -name "node_modules" -o -name "storybook-static" -o -name "dist" -o -name "build" -o -name "coverage" -o -name ".turbo" -o -name ".cache" \\) -prune -o -print -quit); if [ -n "$leftover_meaningful" ]; then printf '%s\\n' "Managed worktree leftover has files outside generated artifact directories; refusing cleanup: ${state.worktreePath}" >&2; exit 1; fi; git -C "${state.projectRootGitCwd}" worktree prune; rm -rf "${state.worktreePath}"; fi; fi`
|
|
7488
7891
|
);
|
|
7489
7892
|
}
|
|
7490
7893
|
if (state.headBranch) {
|
|
@@ -7493,7 +7896,7 @@ function buildPostMergeCleanupCommand(state) {
|
|
|
7493
7896
|
);
|
|
7494
7897
|
if (state.hasOriginRemote) {
|
|
7495
7898
|
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`
|
|
7899
|
+
`if git -C "${state.projectRootGitCwd}" show-ref --verify --quiet "refs/remotes/origin/${state.headBranch}"; then HUSKY=0 git -C "${state.projectRootGitCwd}" push origin --delete "${state.headBranch}"; fi`
|
|
7497
7900
|
);
|
|
7498
7901
|
commandParts.push(
|
|
7499
7902
|
`git -C "${state.projectRootGitCwd}" fetch --prune origin`
|
|
@@ -7697,14 +8100,15 @@ function resolveCurrentReviewState(tasks, prDraft, remoteReviewState) {
|
|
|
7697
8100
|
}
|
|
7698
8101
|
return "unknown";
|
|
7699
8102
|
}
|
|
7700
|
-
function buildCodeReviewActionOptions(reviewState) {
|
|
8103
|
+
function buildCodeReviewActionOptions(reviewState, reviewSyncCommand = null) {
|
|
7701
8104
|
if (reviewState === "merged") {
|
|
7702
8105
|
return [
|
|
7703
8106
|
buildStageOption(
|
|
7704
8107
|
"A",
|
|
7705
8108
|
"A",
|
|
7706
8109
|
"review_sync_approved",
|
|
7707
|
-
"Sync the already-merged PR state into tasks.md and pr.md before closing the feature."
|
|
8110
|
+
"Sync the already-merged PR state into tasks.md and pr.md before closing the feature.",
|
|
8111
|
+
reviewSyncCommand
|
|
7708
8112
|
),
|
|
7709
8113
|
buildStageOption(
|
|
7710
8114
|
"B",
|
|
@@ -7720,7 +8124,8 @@ function buildCodeReviewActionOptions(reviewState) {
|
|
|
7720
8124
|
"A",
|
|
7721
8125
|
"A",
|
|
7722
8126
|
"review_sync_approved",
|
|
7723
|
-
"Sync the approved PR review state into tasks.md and pr.md, then continue to the merge gate."
|
|
8127
|
+
"Sync the approved PR review state into tasks.md and pr.md, then continue to the merge gate.",
|
|
8128
|
+
reviewSyncCommand
|
|
7724
8129
|
),
|
|
7725
8130
|
buildStageOption(
|
|
7726
8131
|
"B",
|
|
@@ -8269,7 +8674,8 @@ Task commit boundary warning: ${describeTaskCommitGateFailure(committedTaskGate)
|
|
|
8269
8674
|
if (requirements.requireReview && (!reviewApprovedInDocs || currentReviewState !== "approved")) {
|
|
8270
8675
|
const reviewFixAllowed = currentReviewState === "changes_requested";
|
|
8271
8676
|
const reviewApprovalRequired = !reviewFixAllowed;
|
|
8272
|
-
const
|
|
8677
|
+
const reviewSyncCommand = currentReviewState === "merged" ? `npx lee-spec-kit github pr ${buildFeatureArgs(feature)} --merge --confirm OK` : null;
|
|
8678
|
+
const reviewActionOptions = reviewApprovalRequired ? buildCodeReviewActionOptions(currentReviewState, reviewSyncCommand) : void 0;
|
|
8273
8679
|
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
8680
|
return {
|
|
8275
8681
|
status: "ok",
|
|
@@ -8280,7 +8686,8 @@ Task commit boundary warning: ${describeTaskCommitGateFailure(committedTaskGate)
|
|
|
8280
8686
|
nextAction: buildAction(
|
|
8281
8687
|
"code_review",
|
|
8282
8688
|
reviewSummary,
|
|
8283
|
-
reviewApprovalRequired
|
|
8689
|
+
reviewApprovalRequired,
|
|
8690
|
+
reviewSyncCommand
|
|
8284
8691
|
),
|
|
8285
8692
|
approvalRequired: reviewApprovalRequired,
|
|
8286
8693
|
implementationAllowed: reviewFixAllowed,
|
|
@@ -8702,7 +9109,20 @@ async function collectWorkflowAudit(cwd) {
|
|
|
8702
9109
|
const allMeaningfulFeatureDocPaths = meaningfulChangedFeatureDocPaths;
|
|
8703
9110
|
const latestCodeChangeAt = await getLatestMtimeIso(combinedChangedCodePaths);
|
|
8704
9111
|
const latestFeatureDocSyncAt = await getLatestWorkflowSyncMarkerAt(activeFeature);
|
|
9112
|
+
const duplicateWorkflowSyncMarkerPaths = await collectDuplicateWorkflowSyncMarkerPaths(activeFeature);
|
|
8705
9113
|
if (combinedChangedCodePaths.length === 0) {
|
|
9114
|
+
if (duplicateWorkflowSyncMarkerPaths.length > 0) {
|
|
9115
|
+
return {
|
|
9116
|
+
status: "needs_sync",
|
|
9117
|
+
reasonCode: "DUPLICATE_WORKFLOW_SYNC_MARKERS",
|
|
9118
|
+
docsDir: config.docsDir,
|
|
9119
|
+
activeFeatureRef,
|
|
9120
|
+
changedCodePaths: [],
|
|
9121
|
+
changedFeatureDocPaths: duplicateWorkflowSyncMarkerPaths,
|
|
9122
|
+
latestCodeChangeAt: null,
|
|
9123
|
+
latestFeatureDocSyncAt
|
|
9124
|
+
};
|
|
9125
|
+
}
|
|
8706
9126
|
return {
|
|
8707
9127
|
status: "ok",
|
|
8708
9128
|
reasonCode: "WORKFLOW_IN_SYNC",
|
|
@@ -8714,6 +9134,18 @@ async function collectWorkflowAudit(cwd) {
|
|
|
8714
9134
|
latestFeatureDocSyncAt
|
|
8715
9135
|
};
|
|
8716
9136
|
}
|
|
9137
|
+
if (duplicateWorkflowSyncMarkerPaths.length > 0) {
|
|
9138
|
+
return {
|
|
9139
|
+
status: "needs_sync",
|
|
9140
|
+
reasonCode: "DUPLICATE_WORKFLOW_SYNC_MARKERS",
|
|
9141
|
+
docsDir: config.docsDir,
|
|
9142
|
+
activeFeatureRef,
|
|
9143
|
+
changedCodePaths: combinedChangedCodePaths.map((item) => item.relativeToRepo),
|
|
9144
|
+
changedFeatureDocPaths: duplicateWorkflowSyncMarkerPaths,
|
|
9145
|
+
latestCodeChangeAt,
|
|
9146
|
+
latestFeatureDocSyncAt
|
|
9147
|
+
};
|
|
9148
|
+
}
|
|
8717
9149
|
if (!activeFeatureRef) {
|
|
8718
9150
|
return {
|
|
8719
9151
|
status: "needs_sync",
|
|
@@ -8839,6 +9271,23 @@ async function getLatestWorkflowSyncMarkerAt(activeFeature) {
|
|
|
8839
9271
|
}
|
|
8840
9272
|
return latest > 0 ? new Date(latest).toISOString() : null;
|
|
8841
9273
|
}
|
|
9274
|
+
async function collectDuplicateWorkflowSyncMarkerPaths(activeFeature) {
|
|
9275
|
+
if (!activeFeature) return [];
|
|
9276
|
+
const canonicalFiles = ["spec.md", "plan.md", "tasks.md", "decisions.md", "issue.md", "pr.md"];
|
|
9277
|
+
const markerPaths = [];
|
|
9278
|
+
let markerCount = 0;
|
|
9279
|
+
for (const fileName of canonicalFiles) {
|
|
9280
|
+
const absolutePath = path8.join(activeFeature.path, fileName);
|
|
9281
|
+
if (!await fs.pathExists(absolutePath)) continue;
|
|
9282
|
+
const content = await fs.readFile(absolutePath, "utf-8");
|
|
9283
|
+
const matches = [...content.matchAll(WORKFLOW_SYNC_MARKER_PATTERN)];
|
|
9284
|
+
if (matches.length > 0) {
|
|
9285
|
+
markerCount += matches.length;
|
|
9286
|
+
markerPaths.push(normalizeSlashes2(path8.relative(activeFeature.path, absolutePath)));
|
|
9287
|
+
}
|
|
9288
|
+
}
|
|
9289
|
+
return markerCount > 1 ? markerPaths : [];
|
|
9290
|
+
}
|
|
8842
9291
|
function extractWorkflowSyncMarkerTimes(content, nowMs, fileMtimeMs) {
|
|
8843
9292
|
const values = [];
|
|
8844
9293
|
for (const match of content.matchAll(WORKFLOW_SYNC_MARKER_PATTERN)) {
|
|
@@ -9224,10 +9673,10 @@ function parseStagedPaths(output) {
|
|
|
9224
9673
|
staged.set(`path:${normalizeSlashes3(parts[1])}`, `${status}:path`);
|
|
9225
9674
|
}
|
|
9226
9675
|
return [...staged.entries()].map(([encodedPath, encodedStatus]) => {
|
|
9227
|
-
const [role,
|
|
9676
|
+
const [role, path27] = encodedPath.split(":", 2);
|
|
9228
9677
|
const [status, entryRole] = encodedStatus.split(":", 2);
|
|
9229
9678
|
return {
|
|
9230
|
-
path:
|
|
9679
|
+
path: path27,
|
|
9231
9680
|
status,
|
|
9232
9681
|
role: entryRole || role || "path"
|
|
9233
9682
|
};
|
|
@@ -9502,6 +9951,8 @@ function configureRootCommandSurface() {
|
|
|
9502
9951
|
["init", "Docs Schema Commands:"],
|
|
9503
9952
|
["idea", "Docs Schema Commands:"],
|
|
9504
9953
|
["feature", "Docs Schema Commands:"],
|
|
9954
|
+
["task", "Docs Schema Commands:"],
|
|
9955
|
+
["decision", "Docs Schema Commands:"],
|
|
9505
9956
|
["docs", "Workflow Policy Commands:"],
|
|
9506
9957
|
["detect", "Workflow Policy Commands:"],
|
|
9507
9958
|
["github", "Workflow Policy Commands:"],
|
|
@@ -9531,6 +9982,8 @@ updateCommand(program);
|
|
|
9531
9982
|
configCommand(program);
|
|
9532
9983
|
githubCommand(program);
|
|
9533
9984
|
docsCommand(program);
|
|
9985
|
+
taskCommand(program);
|
|
9986
|
+
decisionCommand(program);
|
|
9534
9987
|
detectCommand(program);
|
|
9535
9988
|
workflowStageCommand(program);
|
|
9536
9989
|
integrationsCommand(program);
|