sillyspec 3.26.11 → 3.26.13
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/.claude/skills/sillyspec-archive/SKILL.md +1 -1
- package/.claude/skills/sillyspec-execute/SKILL.md +1 -1
- package/.claude/skills/sillyspec-knowledge/SKILL.md +1 -1
- package/.claude/skills/sillyspec-plan/SKILL.md +5 -5
- package/.claude/skills/sillyspec-quick/SKILL.md +5 -1
- package/README.md +7 -5
- package/SKILL.md +5 -4
- package/package.json +1 -1
- package/packages/dashboard/server/executor.js +10 -1
- package/packages/dashboard/server/index.js +108 -46
- package/src/config-schema.js +3 -0
- package/src/contract-matrix.js +22 -13
- package/src/db.js +24 -4
- package/src/dispatch/probe.js +99 -63
- package/src/docs-check.js +58 -13
- package/src/docs-debt.js +6 -2
- package/src/docs-gate.js +1 -0
- package/src/doctor-diagnostics.js +208 -21
- package/src/fs-atomic.js +3 -1
- package/src/git-helper.js +7 -4
- package/src/hooks/worktree-guard.js +8 -2
- package/src/index.js +161 -12
- package/src/init.js +3 -2
- package/src/local-detect.js +9 -1
- package/src/modules.js +7 -5
- package/src/progress/stage-machine.js +589 -578
- package/src/progress/step-store.js +1 -1
- package/src/progress.js +184 -30
- package/src/quicklog.js +47 -16
- package/src/run/command.js +31 -8
- package/src/run/complete-handlers.js +21 -14
- package/src/run/complete.js +1181 -1121
- package/src/run/gates.js +148 -25
- package/src/run/prompt.js +9 -5
- package/src/run/shared.js +138 -58
- package/src/run/stage.js +41 -1
- package/src/setup.js +8 -10
- package/src/sillyhub-mcp/client.js +74 -6
- package/src/spec-dir-typo.js +3 -24
- package/src/spec-sync.js +43 -8
- package/src/stage-contract.js +12 -38
- package/src/stages/archive.js +4 -3
- package/src/stages/brainstorm-auto.js +1 -1
- package/src/stages/brainstorm.js +2 -1
- package/src/stages/doctor.js +28 -13
- package/src/stages/execute.js +164 -153
- package/src/stages/plan-postcheck.js +49 -28
- package/src/stages/plan.js +57 -40
- package/src/stages/quick.js +3 -1
- package/src/stages/verify.js +1 -1
- package/src/sync.js +323 -221
- package/src/task-review.js +246 -61
- package/src/taskcard.js +178 -0
- package/src/verify-postcheck.js +48 -35
- package/src/worktree-apply.js +58 -34
- package/src/worktree-deps.js +120 -20
- package/src/worktree.js +55 -9
- package/.npmrc-publish +0 -2
package/src/task-review.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
import { existsSync, readFileSync, mkdirSync, writeFileSync, readdirSync, statSync } from 'fs'
|
|
12
12
|
import { join, resolve } from 'path'
|
|
13
|
-
import {
|
|
13
|
+
import { git } from './git-helper.js'
|
|
14
14
|
import { pathMatches } from './change-list.js'
|
|
15
15
|
import { parseAllowedPaths, parseRepo, parseBaseCommit, parseHeadCommit } from './stages/plan-postcheck.js'
|
|
16
16
|
import { resolveVerifyChangedFiles } from './verify-postcheck.js'
|
|
@@ -105,64 +105,63 @@ export function summarizeTaskCompletion({ changeDir, runtimeRoot, changeName })
|
|
|
105
105
|
}
|
|
106
106
|
if (!changeDir) return noPlan
|
|
107
107
|
|
|
108
|
+
// 2026-08-20-task-truth-unify:任务清单唯一真相在 tasks.md,优先读注册表;plan.md 回退
|
|
109
|
+
// (旧归档变更兼容读侧——新契约 plan.md 为纯 ID 引用行,无 checkbox 可解析)
|
|
108
110
|
const planPath = join(changeDir, 'plan.md')
|
|
109
111
|
const tasksPath = join(changeDir, 'tasks.md')
|
|
110
112
|
let planContent = null
|
|
111
|
-
if (existsSync(
|
|
112
|
-
else if (existsSync(
|
|
113
|
+
if (existsSync(tasksPath)) planContent = readFileSync(tasksPath, 'utf8')
|
|
114
|
+
else if (existsSync(planPath)) planContent = readFileSync(planPath, 'utf8')
|
|
113
115
|
else return noPlan
|
|
114
116
|
|
|
115
117
|
const taskIds = parseTaskIdsFromPlan(planContent)
|
|
116
118
|
if (taskIds.length === 0) {
|
|
117
119
|
return { source: 'no-tasks', total: 0, completed: 0, pending: [],
|
|
118
|
-
report: '⚠️
|
|
120
|
+
report: '⚠️ tasks.md 未解析出任何 task-NN 条目(任务注册表可能未按规范写 checkbox 列表)。' }
|
|
119
121
|
}
|
|
120
122
|
|
|
121
|
-
// runId
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
123
|
+
// runId 解析(坑 worktree-cleanup-marker-chain 根治):归属化解析 resolveExecuteRunForChange——
|
|
124
|
+
// marker 优先但校验覆盖度;marker 断裂(worktree cleanup / 归档清理 / 并行误删)时按 change 戳
|
|
125
|
+
// 精确归属,旧 run 退覆盖度启发。不再盲目 mtime 最新(曾把其他变更的 run 错配给本变更 →
|
|
126
|
+
// 全部 task 误报「review.json 缺失」→ 手工回填 7 份实际存在于正确 run 里的 review)。
|
|
127
|
+
const resolved = resolveExecuteRunForChange({ runtimeRoot, changeName, taskIds })
|
|
128
|
+
const runId = resolved ? resolved.runId : null
|
|
129
|
+
if (resolved && resolved.relocated) {
|
|
130
|
+
console.warn(`[sillyspec] execute run 归属修正:marker 指向的 run 对变更 ${changeName} 零覆盖,改用 ${resolved.origin === 'stamp' ? 'change 戳' : '覆盖度'}命中的 run ${runId}(真实 review 所在)`)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// 无归属 run → 降级 plan.md checkbox 统计;报告区分「无法归属」与「没跑过 execute」,
|
|
134
|
+
// 避免把定位失败误导成「review 全缺、需手工回填」
|
|
131
135
|
if (!runId) {
|
|
136
|
+
const { total, checked } = countPlanCheckboxes(planContent)
|
|
137
|
+
let runsNote = '无 execute-runs 目录(本变更未跑过 execute?)'
|
|
132
138
|
try {
|
|
133
139
|
const runsDir = join(runtimeRoot, 'execute-runs')
|
|
134
140
|
if (existsSync(runsDir)) {
|
|
135
|
-
const
|
|
136
|
-
|
|
137
|
-
.filter(x => { try { return statSync(x.p).isDirectory() } catch { return false } })
|
|
138
|
-
.map(x => ({ e: x.e, mtime: statSync(x.p).mtimeMs }))
|
|
139
|
-
.sort((a, b) => b.mtime - a.mtime)
|
|
140
|
-
if (entries.length > 0) runId = entries[0].e
|
|
141
|
+
const n = readdirSync(runsDir).filter(e => { try { return statSync(join(runsDir, e)).isDirectory() } catch { return false } }).length
|
|
142
|
+
if (n > 0) runsNote = `execute-runs/ 有 ${n} 个 run 但无一能归属到本变更(无 change 戳且零覆盖——marker 链断裂且 review 可能从未生成)`
|
|
141
143
|
}
|
|
142
144
|
} catch {}
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
// 无 runId → 降级 plan.md checkbox 统计
|
|
146
|
-
if (!runId) {
|
|
147
|
-
const { total, checked } = countPlanCheckboxes(planContent)
|
|
148
145
|
return {
|
|
149
146
|
source: 'plan-checkbox-fallback', total, completed: checked,
|
|
150
147
|
pending: taskIds.slice(0, Math.max(0, total - checked)).map(id => ({ id, reason: 'checkbox 未勾(降级口径)' })),
|
|
151
148
|
report:
|
|
152
|
-
'⚠️
|
|
149
|
+
'⚠️ 客观源不可用(execute run 无法归属到本变更:' + runsNote + ')→ 降级用 plan.md checkbox。\n' +
|
|
153
150
|
`- 总任务:${total}\n- plan.md 已勾选:${checked}\n` +
|
|
154
151
|
'⚠️ checkbox 可能因 autoCheckPlanFromReviews 回填断裂而失真,务必交叉核对 .runtime/execute-runs/*/tasks/task-NN/review.json 的 verdict 再下结论。'
|
|
155
152
|
}
|
|
156
153
|
}
|
|
157
154
|
|
|
158
|
-
// 有 runId → 读 review.json verdict
|
|
155
|
+
// 有 runId → 读 review.json verdict;cannot_verify 草稿单列(不是真正复核,归档前应可见)
|
|
159
156
|
const completed = []
|
|
160
157
|
const pending = []
|
|
158
|
+
let cannotVerifyCount = 0
|
|
161
159
|
for (const id of taskIds) {
|
|
162
160
|
const reviewPath = join(runtimeRoot, 'execute-runs', runId, 'tasks', id, 'review.json')
|
|
163
161
|
const r = readReview(reviewPath)
|
|
164
162
|
if (r.ok && r.review && r.review.specVerdict !== 'fail' && r.review.qualityVerdict !== 'fail') {
|
|
165
163
|
completed.push(id)
|
|
164
|
+
if (r.review.specVerdict === 'cannot_verify' || r.review.qualityVerdict === 'cannot_verify') cannotVerifyCount++
|
|
166
165
|
} else if (r.ok && r.review) {
|
|
167
166
|
pending.push({ id, reason: `verdict 未通过(spec=${r.review.specVerdict}, quality=${r.review.qualityVerdict})` })
|
|
168
167
|
} else {
|
|
@@ -173,12 +172,17 @@ export function summarizeTaskCompletion({ changeDir, runtimeRoot, changeName })
|
|
|
173
172
|
const pendingLines = pending.length > 0
|
|
174
173
|
? pending.map(p => ` - ${p.id}: ${p.reason}`).join('\n')
|
|
175
174
|
: ' (无)'
|
|
175
|
+
const draftLines = cannotVerifyCount > 0
|
|
176
|
+
? `- cannot_verify 草稿(未真正复核): ${cannotVerifyCount}\n` +
|
|
177
|
+
' ⚠️ 这些 task 的 review 是 cannot_verify(含自动草稿兜底)而非 pass——归档前应确认 verify 阶段已兑现其 requiredEvidence,否则派独立子代理对照 task brief + git diff 补真实复核,勿静默放行\n'
|
|
178
|
+
: ''
|
|
176
179
|
return {
|
|
177
|
-
source: 'review.json', total: taskIds.length, completed: completed.length, pending,
|
|
180
|
+
source: 'review.json', total: taskIds.length, completed: completed.length, pending, cannotVerify: cannotVerifyCount, runId,
|
|
178
181
|
report:
|
|
179
|
-
`客观任务完成度(真相源 = review.json verdict,runId=${runId}):\n` +
|
|
182
|
+
`客观任务完成度(真相源 = review.json verdict,runId=${runId}${resolved.origin !== 'marker' ? `,归属=${resolved.origin}` : ''}):\n` +
|
|
180
183
|
`- 总任务:${taskIds.length}\n` +
|
|
181
184
|
`- 已通过(spec + quality verdict 均非 fail):${completed.length}\n` +
|
|
185
|
+
draftLines +
|
|
182
186
|
`- 未通过 / 缺失:${pending.length}\n` +
|
|
183
187
|
`- 未完成列表:\n${pendingLines}\n` +
|
|
184
188
|
'注:以 review.json verdict 为准;plan.md checkbox 仅作显示态(回填断裂时会与客观 verdict 不一致,以下方客观点为准)。'
|
|
@@ -387,6 +391,9 @@ export function validateTaskReviews(opts) {
|
|
|
387
391
|
const errors = []
|
|
388
392
|
const warnings = []
|
|
389
393
|
const requiredEvidence = []
|
|
394
|
+
// PERF-01:整个循环共享一份 git 结果缓存(同 gitDir 的探测/status、同 (base,head) 的
|
|
395
|
+
// diff 在多 task 间高度重复;N task 从 5N 个 git 子进程降到 ~2+去重后数量)
|
|
396
|
+
const evidenceCache = {}
|
|
390
397
|
|
|
391
398
|
for (const taskId of taskIds) {
|
|
392
399
|
const reviewDir = join(runtimeRoot, 'execute-runs', executeRunId, 'tasks', taskId)
|
|
@@ -447,7 +454,7 @@ export function validateTaskReviews(opts) {
|
|
|
447
454
|
}
|
|
448
455
|
}
|
|
449
456
|
if (reviewGitDir) {
|
|
450
|
-
const evidence = verifyReviewGitEvidence(review, reviewGitDir)
|
|
457
|
+
const evidence = verifyReviewGitEvidence(review, reviewGitDir, evidenceCache)
|
|
451
458
|
for (const w of evidence.warnings) warnings.push(`${taskId}: ${w}`)
|
|
452
459
|
if (!evidence.ok) {
|
|
453
460
|
for (const err of evidence.errors) errors.push(`${taskId}: ${err}`)
|
|
@@ -516,13 +523,10 @@ export function validateTaskReviews(opts) {
|
|
|
516
523
|
|
|
517
524
|
// ── Git 真实性交叉校验 ──
|
|
518
525
|
|
|
526
|
+
// QUAL-01 收口:原为本地 execFileSync('git')(无 safe.directory、与 git-helper 口径分裂),
|
|
527
|
+
// 现走统一入口;抛错/trim 语义不变,timeout 15000 保留(rev-parse/diff 大仓余量)
|
|
519
528
|
function runGit(gitDir, args) {
|
|
520
|
-
return
|
|
521
|
-
cwd: gitDir,
|
|
522
|
-
encoding: 'utf8',
|
|
523
|
-
timeout: 15000,
|
|
524
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
525
|
-
}).trim()
|
|
529
|
+
return git(gitDir, args, { timeout: 15000 })
|
|
526
530
|
}
|
|
527
531
|
|
|
528
532
|
// porcelain 状态行 → 变更文件路径(对齐 shared.js:343 parsePorcelainPath:`XY path`、引号包裹、
|
|
@@ -552,31 +556,57 @@ function parsePorcelainFiles(statusOut) {
|
|
|
552
556
|
* git 环境不可用(非 git 仓库 / git 缺失)时返回 unavailable,由调用方降级为
|
|
553
557
|
* warning——不因环境问题误杀,但记录在输出中。
|
|
554
558
|
*
|
|
559
|
+
/**
|
|
555
560
|
* @param {object} review - 已通过 schema 校验的 review 对象
|
|
556
561
|
* @param {string} gitDir - 执行 git 命令的目录(worktree 优先,回退主仓库)
|
|
562
|
+
* @param {object} [cache] - 进程内单次校验批的共享缓存(体检 PERF-01:N task × 5 个 git
|
|
563
|
+
* 子进程去重——gitDir 探测与 status --porcelain 结果按 gitDir 恒同,commit 校验按
|
|
564
|
+
* (gitDir,hash)、diff 按 (gitDir,base,head) 在多 task 间高度重复。validateTaskReviews
|
|
565
|
+
* 整个循环传同一份;不传则退化为逐次执行(单测/独立调用零回归)。缓存生命周期 =
|
|
566
|
+
* 一次校验批:working-tree status 是快照语义,跨批复用会读到陈旧状态。
|
|
557
567
|
* @returns {{ ok: boolean, emptyDiff: boolean, errors: string[], warnings: string[], unavailable: boolean }}
|
|
558
568
|
*/
|
|
559
|
-
export function verifyReviewGitEvidence(review, gitDir) {
|
|
569
|
+
export function verifyReviewGitEvidence(review, gitDir, cache = null) {
|
|
560
570
|
const errors = []
|
|
561
571
|
const warnings = []
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
572
|
+
const c = cache || (cache = {})
|
|
573
|
+
c.gitOk = c.gitOk || new Map()
|
|
574
|
+
c.verified = c.verified || new Set()
|
|
575
|
+
c.diffs = c.diffs || new Map()
|
|
576
|
+
c.wtStatus = c.wtStatus || new Map()
|
|
577
|
+
|
|
578
|
+
// git 环境探测:失败即 unavailable,交由调用方降级(同 gitDir 结果恒同,批内缓存)
|
|
579
|
+
if (!c.gitOk.has(gitDir)) {
|
|
580
|
+
try {
|
|
581
|
+
runGit(gitDir, ['rev-parse', '--git-dir'])
|
|
582
|
+
c.gitOk.set(gitDir, true)
|
|
583
|
+
} catch (e) {
|
|
584
|
+
c.gitOk.set(gitDir, false)
|
|
585
|
+
return {
|
|
586
|
+
ok: true,
|
|
587
|
+
emptyDiff: false,
|
|
588
|
+
errors: [],
|
|
589
|
+
warnings: [`git 环境不可用(${gitDir}),跳过 review 真实性交叉校验: ${e.message?.split('\n')[0] || e.message}`],
|
|
590
|
+
unavailable: true,
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
} else if (c.gitOk.get(gitDir) === false) {
|
|
567
594
|
return {
|
|
568
595
|
ok: true,
|
|
569
596
|
emptyDiff: false,
|
|
570
597
|
errors: [],
|
|
571
|
-
warnings: [`git 环境不可用(${gitDir}),跳过 review
|
|
598
|
+
warnings: [`git 环境不可用(${gitDir}),跳过 review 真实性交叉校验(批内缓存判定)`],
|
|
572
599
|
unavailable: true,
|
|
573
600
|
}
|
|
574
601
|
}
|
|
575
602
|
|
|
576
603
|
for (const field of ['base', 'head']) {
|
|
577
604
|
const hash = review[field]
|
|
605
|
+
const vkey = `${gitDir}\u0000${hash}`
|
|
606
|
+
if (c.verified.has(vkey)) continue
|
|
578
607
|
try {
|
|
579
608
|
runGit(gitDir, ['rev-parse', '--verify', '--quiet', `${hash}^{commit}`])
|
|
609
|
+
c.verified.add(vkey)
|
|
580
610
|
} catch {
|
|
581
611
|
errors.push(`${field} "${hash}" 不是仓库中的真实 commit — review.json 疑似伪造`)
|
|
582
612
|
}
|
|
@@ -586,21 +616,32 @@ export function verifyReviewGitEvidence(review, gitDir) {
|
|
|
586
616
|
}
|
|
587
617
|
|
|
588
618
|
let diffFiles = []
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
diffFiles =
|
|
592
|
-
}
|
|
593
|
-
|
|
594
|
-
|
|
619
|
+
const dkey = `${gitDir}\u0000${review.base}\u0000${review.head}`
|
|
620
|
+
if (c.diffs.has(dkey)) {
|
|
621
|
+
diffFiles = c.diffs.get(dkey)
|
|
622
|
+
} else {
|
|
623
|
+
try {
|
|
624
|
+
const out = runGit(gitDir, ['diff', '--name-only', `${review.base}..${review.head}`])
|
|
625
|
+
diffFiles = out ? out.split('\n').filter(Boolean) : []
|
|
626
|
+
c.diffs.set(dkey, diffFiles)
|
|
627
|
+
} catch (e) {
|
|
628
|
+
warnings.push(`git diff ${review.base}..${review.head} 执行失败,跳过 diff 校验: ${e.message?.split('\n')[0] || e.message}`)
|
|
629
|
+
return { ok: true, emptyDiff: false, errors, warnings, unavailable: false }
|
|
630
|
+
}
|
|
595
631
|
}
|
|
596
632
|
|
|
597
633
|
// 并入 working-tree 未提交改动(execute 复盘 a):子代理默认不 commit,base..head 无 commit diff 时
|
|
598
634
|
// diffFiles 留空 → 下方交叉比对拿空集对非空 changedFiles 必判「完全不相交」伪造,逼 agent 强制 commit
|
|
599
635
|
// + 改 7 个 review head。对齐 checkExecuteCodeEvidence(stage-contract.js 同时查 working-tree)语义,
|
|
600
636
|
// 未提交改动也算有效对账源。working-tree 有改动时无条件并入(覆盖「部分 commit + 未提交」)。
|
|
637
|
+
// PERF-01:同 gitDir 的 status 快照按批缓存(单次校验批内恒同,跨批复用会读到陈旧状态)
|
|
601
638
|
const commitDiffFiles = diffFiles.slice()
|
|
602
639
|
try {
|
|
603
|
-
|
|
640
|
+
let wtStatus = c.wtStatus.get(gitDir)
|
|
641
|
+
if (wtStatus === undefined) {
|
|
642
|
+
wtStatus = runGit(gitDir, ['status', '--porcelain'])
|
|
643
|
+
c.wtStatus.set(gitDir, wtStatus)
|
|
644
|
+
}
|
|
604
645
|
if (wtStatus && wtStatus.trim().length > 0) {
|
|
605
646
|
const wtFiles = parsePorcelainFiles(wtStatus)
|
|
606
647
|
if (wtFiles.length > 0) diffFiles = diffFiles.concat(wtFiles)
|
|
@@ -700,7 +741,7 @@ export function isValidExecuteRunId(runId) {
|
|
|
700
741
|
* @param {{ runtimeRoot: string }} opts
|
|
701
742
|
* @returns {string|null} 含 tasks/ 的最新 runId,无则 null
|
|
702
743
|
*/
|
|
703
|
-
export function resolveLatestExecuteRunIdWithTasks({ runtimeRoot }) {
|
|
744
|
+
export function resolveLatestExecuteRunIdWithTasks({ runtimeRoot, changeName = null }) {
|
|
704
745
|
try {
|
|
705
746
|
const runsDir = join(runtimeRoot, 'execute-runs')
|
|
706
747
|
if (!existsSync(runsDir)) return null
|
|
@@ -710,7 +751,19 @@ export function resolveLatestExecuteRunIdWithTasks({ runtimeRoot }) {
|
|
|
710
751
|
.filter(x => { try { return statSync(join(x.p, 'tasks')).isDirectory() } catch { return false } })
|
|
711
752
|
.map(x => ({ e: x.e, mtime: statSync(x.p).mtimeMs }))
|
|
712
753
|
.sort((a, b) => b.mtime - a.mtime)
|
|
713
|
-
|
|
754
|
+
if (entries.length === 0) return null
|
|
755
|
+
// changeName 给定时优先 change 戳等值的 run(坑 worktree-cleanup-marker-chain:mtime 最新
|
|
756
|
+
// 会错拿其他变更的 run);无戳命中退回 mtime 最新(向后兼容,调用方多为 marker 漂移兜底,
|
|
757
|
+
// 宁可拿最新 run 也不空手而归——覆盖度由调用方 validateTaskReviews 复校验)
|
|
758
|
+
if (changeName) {
|
|
759
|
+
for (const x of entries) {
|
|
760
|
+
try {
|
|
761
|
+
const c = readFileSync(join(runsDir, x.e, 'change'), 'utf8').trim()
|
|
762
|
+
if (c === changeName) return x.e
|
|
763
|
+
} catch {}
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
return entries[0].e
|
|
714
767
|
} catch {
|
|
715
768
|
return null
|
|
716
769
|
}
|
|
@@ -725,20 +778,145 @@ export function resolveLatestExecuteRunId({ runtimeRoot, changeName }) {
|
|
|
725
778
|
if (isValidExecuteRunId(c)) return c
|
|
726
779
|
}
|
|
727
780
|
} catch {}
|
|
781
|
+
// marker 缺失(worktree cleanup / 归档清理 / 并行会话误删——坑 worktree-cleanup-marker-chain)
|
|
782
|
+
// 时不再盲目取 mtime 最新(会拿到其他变更的 run):先按 change 归属戳过滤,命中才返回;
|
|
783
|
+
// 无戳(旧 run)退回 mtime 最新保持向后兼容。
|
|
728
784
|
try {
|
|
729
|
-
const
|
|
730
|
-
if (
|
|
731
|
-
|
|
732
|
-
.map(e => ({ e, p: join(runsDir, e) }))
|
|
733
|
-
.filter(x => { try { return statSync(x.p).isDirectory() } catch { return false } })
|
|
734
|
-
.map(x => ({ e: x.e, mtime: statSync(x.p).mtimeMs }))
|
|
735
|
-
.sort((a, b) => b.mtime - a.mtime)
|
|
736
|
-
if (entries.length > 0) return entries[0].e
|
|
737
|
-
}
|
|
785
|
+
const candidates = listExecuteRunCandidates(runtimeRoot, changeName)
|
|
786
|
+
if (candidates.stamped.length > 0) return candidates.stamped[0]
|
|
787
|
+
if (candidates.all.length > 0) return candidates.all[0]
|
|
738
788
|
} catch {}
|
|
739
789
|
return null
|
|
740
790
|
}
|
|
741
791
|
|
|
792
|
+
// ── execute run 的 change 归属戳(坑 worktree-cleanup-marker-chain 根治)──
|
|
793
|
+
//
|
|
794
|
+
// 背景:run 目录 execute-runs/<runId>/ 本身不带变更身份,change→run 的唯一链接是
|
|
795
|
+
// current-execute-run-id-<change> marker;worktree cleanup / 归档 marker 清理 / 并行会话
|
|
796
|
+
// 误删后链即断,各 fallback(mtime 最新)会错拿其他变更的 run → archive 完成度报告把
|
|
797
|
+
// 已实现 task 全报「review.json 缺失」,只能手工回填。
|
|
798
|
+
// 修法:marker 写入点同步在 run 目录落 `change` 戳;marker 断裂后按戳精确归属,
|
|
799
|
+
// 旧 run(无戳)退回覆盖度启发式(run 的 tasks/ 含本变更 task-NN review 才算命中)。
|
|
800
|
+
|
|
801
|
+
/**
|
|
802
|
+
* 在 run 目录写入 change 归属戳(execute-runs/<runId>/change,内容为 changeName)。
|
|
803
|
+
* 与 marker 写入点同步调用(stage.js 主点 + gates.js/task-review.js 补写点)。
|
|
804
|
+
* best-effort:失败仅 warn 不抛(戳是归属优化的载体,缺了退回覆盖度启发式,不阻断主流程)。
|
|
805
|
+
* @param {string} runtimeRoot
|
|
806
|
+
* @param {string} runId
|
|
807
|
+
* @param {string} changeName
|
|
808
|
+
*/
|
|
809
|
+
export function stampExecuteRunChange(runtimeRoot, runId, changeName) {
|
|
810
|
+
if (!runtimeRoot || !runId || !changeName) return
|
|
811
|
+
try {
|
|
812
|
+
// run 目录可能尚未创建(本 helper 先于 review 落盘调用的路径)——mkdir 保证自洽
|
|
813
|
+
mkdirSync(join(runtimeRoot, 'execute-runs', runId), { recursive: true })
|
|
814
|
+
writeFileSync(join(runtimeRoot, 'execute-runs', runId, 'change'), changeName + '\n')
|
|
815
|
+
} catch (e) {
|
|
816
|
+
console.warn(`[sillyspec] execute run change 戳写入失败(归属 fallback 退回覆盖度启发式,不阻断): ${e.message}`)
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
/**
|
|
821
|
+
* 读 run 目录的 change 归属戳。
|
|
822
|
+
* @returns {string|null} 戳内容(trim 后非空),无戳/读失败返回 null
|
|
823
|
+
*/
|
|
824
|
+
function readExecuteRunChangeStamp(runtimeRoot, runId) {
|
|
825
|
+
try {
|
|
826
|
+
const c = readFileSync(join(runtimeRoot, 'execute-runs', runId, 'change'), 'utf8').trim()
|
|
827
|
+
return c || null
|
|
828
|
+
} catch { return null }
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
/**
|
|
832
|
+
* 扫描 execute-runs/ 候选 run(mtime 最新优先),按 change 归属戳分组。
|
|
833
|
+
* @returns {{ stamped: string[], all: string[] }} stamped=戳等 changeName 的 runId(新→旧);
|
|
834
|
+
* all=全部 runId(新→旧,含无戳旧 run,供覆盖度启发式与向后兼容 fallback)
|
|
835
|
+
*/
|
|
836
|
+
function listExecuteRunCandidates(runtimeRoot, changeName) {
|
|
837
|
+
const runsDir = join(runtimeRoot, 'execute-runs')
|
|
838
|
+
const out = { stamped: [], all: [] }
|
|
839
|
+
if (!existsSync(runsDir)) return out
|
|
840
|
+
const entries = readdirSync(runsDir)
|
|
841
|
+
.map(e => ({ e, p: join(runsDir, e) }))
|
|
842
|
+
.filter(x => { try { return statSync(x.p).isDirectory() } catch { return false } })
|
|
843
|
+
.map(x => ({ e: x.e, mtime: statSync(x.p).mtimeMs }))
|
|
844
|
+
.sort((a, b) => b.mtime - a.mtime)
|
|
845
|
+
for (const x of entries) {
|
|
846
|
+
out.all.push(x.e)
|
|
847
|
+
if (changeName && readExecuteRunChangeStamp(runtimeRoot, x.e) === changeName) out.stamped.push(x.e)
|
|
848
|
+
}
|
|
849
|
+
return out
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
/**
|
|
853
|
+
* 判定 run 对本变更 taskIds 的覆盖度:tasks/ 下存在多少个 task-NN/review.json ∈ taskIds。
|
|
854
|
+
* taskIds 为空时退化为「tasks/ 下存在任意 task-NN/review.json」(保守启发)。
|
|
855
|
+
* @returns {number} 命中的 review 数
|
|
856
|
+
*/
|
|
857
|
+
function executeRunCoverage(runtimeRoot, runId, taskIds) {
|
|
858
|
+
try {
|
|
859
|
+
const tasksDir = join(runtimeRoot, 'execute-runs', runId, 'tasks')
|
|
860
|
+
if (!existsSync(tasksDir)) return 0
|
|
861
|
+
const entries = readdirSync(tasksDir).filter(e => /^task-\d+$/.test(e))
|
|
862
|
+
if (taskIds.length > 0) return entries.filter(e => taskIds.includes(e) && existsSync(join(tasksDir, e, 'review.json'))).length
|
|
863
|
+
return entries.filter(e => existsSync(join(tasksDir, e, 'review.json'))).length
|
|
864
|
+
} catch { return 0 }
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
/**
|
|
868
|
+
* 归属化解析本变更的 execute run(marker 链断裂的根治入口)。
|
|
869
|
+
*
|
|
870
|
+
* 解析顺序:
|
|
871
|
+
* 1. marker(格式校验)指向的 run,若其对本变更有覆盖(tasks/ 有本变更 review)或戳匹配 → 用它;
|
|
872
|
+
* 2. marker 缺失/非法/零覆盖(坑10 型漂移:marker 指向空 run)→ 扫描候选:
|
|
873
|
+
* a. change 戳等值的最新的 run;
|
|
874
|
+
* b.(旧 run 无戳)覆盖度 ≥1 的最新 run;
|
|
875
|
+
* 3. 都不命中 → null(调用方降级,报告明确「run 无法归属」而非「review 全缺」)。
|
|
876
|
+
*
|
|
877
|
+
* fail-safe:任何异常 → null,绝不抛。
|
|
878
|
+
* @param {{ runtimeRoot: string, changeName: string, taskIds?: string[] }} opts
|
|
879
|
+
* @returns {{ runId: string, origin: 'marker'|'stamp'|'coverage', relocated?: boolean }|null}
|
|
880
|
+
*/
|
|
881
|
+
export function resolveExecuteRunForChange({ runtimeRoot, changeName, taskIds = [] }) {
|
|
882
|
+
try {
|
|
883
|
+
// 1. marker
|
|
884
|
+
let markerRunId = null
|
|
885
|
+
try {
|
|
886
|
+
const marker = join(runtimeRoot, `current-execute-run-id-${changeName}`)
|
|
887
|
+
if (existsSync(marker)) {
|
|
888
|
+
const c = readFileSync(marker, 'utf8').trim()
|
|
889
|
+
if (c && !isValidExecuteRunId(c)) console.warn(`[sillyspec] execute run marker 内容非法(期望 exec-YYYY-MM-DD-HHMMSS,实得 ${JSON.stringify(c.slice(0, 60))}),忽略并回退归属扫描`)
|
|
890
|
+
if (isValidExecuteRunId(c)) markerRunId = c
|
|
891
|
+
}
|
|
892
|
+
} catch {}
|
|
893
|
+
if (markerRunId) {
|
|
894
|
+
const stamped = readExecuteRunChangeStamp(runtimeRoot, markerRunId) === changeName
|
|
895
|
+
const covered = executeRunCoverage(runtimeRoot, markerRunId, taskIds)
|
|
896
|
+
if (stamped || covered > 0) return { runId: markerRunId, origin: 'marker' }
|
|
897
|
+
// marker 零覆盖且无戳:疑似漂移(坑10:marker 在、真实 review 在别的 run),下探扫描
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
// 2. 扫描:戳优先 → 覆盖度启发(旧 run)。
|
|
901
|
+
// 覆盖度启发跳过「戳属他变更」的 run:task-NN 命名跨变更同构(都从 task-01 起),只看覆盖度
|
|
902
|
+
// 会把他变更 run 的 task-01 review 误配给本变更;戳存在且不等值 = 该 run 有明确主人,排除。
|
|
903
|
+
const candidates = listExecuteRunCandidates(runtimeRoot, changeName)
|
|
904
|
+
if (candidates.stamped.length > 0) {
|
|
905
|
+
return { runId: candidates.stamped[0], origin: 'stamp', relocated: markerRunId ? markerRunId !== candidates.stamped[0] : true }
|
|
906
|
+
}
|
|
907
|
+
for (const runId of candidates.all) {
|
|
908
|
+
const stamp = readExecuteRunChangeStamp(runtimeRoot, runId)
|
|
909
|
+
if (stamp && stamp !== changeName) continue
|
|
910
|
+
if (executeRunCoverage(runtimeRoot, runId, taskIds) > 0) {
|
|
911
|
+
return { runId, origin: 'coverage', relocated: markerRunId ? markerRunId !== runId : true }
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
return null
|
|
915
|
+
} catch {
|
|
916
|
+
return null
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
|
|
742
920
|
/**
|
|
743
921
|
* worktree execute「主 agent 直接实现」模式收尾兜底:per-task review.json 缺失时,
|
|
744
922
|
* 据 git diff base..head 按 task allowed_paths 归属,自动落盘 cannot_verify 草稿。
|
|
@@ -798,6 +976,7 @@ export async function generateTaskReviewDrafts({ changeName, cwd, platformOpts =
|
|
|
798
976
|
try {
|
|
799
977
|
mkdirSync(join(runtimeRoot, 'execute-runs', executeRunId, 'tasks'), { recursive: true })
|
|
800
978
|
writeFileSync(runIdFile, executeRunId + '\n')
|
|
979
|
+
stampExecuteRunChange(runtimeRoot, executeRunId, changeName)
|
|
801
980
|
} catch (e) {
|
|
802
981
|
console.error(`[sillyspec] execute run marker/目录写入失败(降级继续,草稿落盘仍尝试): ${e.message}`)
|
|
803
982
|
}
|
|
@@ -907,6 +1086,12 @@ export async function generateTaskReviewDrafts({ changeName, cwd, platformOpts =
|
|
|
907
1086
|
skipped++
|
|
908
1087
|
continue
|
|
909
1088
|
}
|
|
1089
|
+
// 主仓 task 缺 base/head(meta 缺失 / HEAD 解析失败,见上方 :968-981 注释承诺的"后续跳过"):
|
|
1090
|
+
// 跳过留给 agent 手补。此前 null.slice(0,8) 直接 TypeError 崩掉整个草稿生成(体检 BUG-06)
|
|
1091
|
+
if (!crossEntry && (!taskBase || !taskHead)) {
|
|
1092
|
+
skipped++
|
|
1093
|
+
continue
|
|
1094
|
+
}
|
|
910
1095
|
// unattributed 只统计主仓 diff(跨仓 diff 路径相对跨仓仓根,与主仓 unattributed 语义无关)
|
|
911
1096
|
if (!crossEntry) {
|
|
912
1097
|
taskChangedFiles.forEach(f => attributed.add(f))
|
package/src/taskcard.js
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sillyspec taskcard — 生成 Windows 安全的 TaskCard 骨架
|
|
3
|
+
*
|
|
4
|
+
* 背景:plan postcheck 任务卡被拒的根因集中在「LLM 子代理手写 frontmatter 不可靠」——
|
|
5
|
+
* CRLF 行尾、漏闭合 ---、缺 constraints 等硬校验字段(校验侧已对 CRLF 容错,但闭合/字段
|
|
6
|
+
* 缺失仍拦)。本命令从源头消灭这类格式错误:骨架由 CLI 用 writeFileSync 直写(字符串
|
|
7
|
+
* 只含 \n,天然 LF),frontmatter 闭合与硬校验 9 字段由代码保证,子代理只需 Edit 填充
|
|
8
|
+
* 占位符,不再手写整卡。
|
|
9
|
+
*
|
|
10
|
+
* 任务编号/标题从 plan.md checkbox 行自动带出(与 plan.js parseTaskNames 同源,
|
|
11
|
+
* 保编号不跳号、名称与 plan.md 一致)。
|
|
12
|
+
*/
|
|
13
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
|
|
14
|
+
import { join } from 'path'
|
|
15
|
+
import { git } from './git-helper.js'
|
|
16
|
+
import { assertSafeChangeName, resolveSpecDir } from './run/shared.js'
|
|
17
|
+
import { parseTaskNames } from './stages/plan.js'
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* 任务号归一:接受 task-01 / task-1 / 01 / 1,统一为 task-NN(两位补零,与任务卡文件名一致)。
|
|
21
|
+
* @param {string} raw
|
|
22
|
+
* @returns {string} 形如 task-01
|
|
23
|
+
*/
|
|
24
|
+
export function normalizeTaskId(raw) {
|
|
25
|
+
const s = String(raw || '').trim()
|
|
26
|
+
const m = s.match(/^(?:task-)?(\d+)$/i)
|
|
27
|
+
if (!m) {
|
|
28
|
+
throw new Error(`任务号格式非法: "${s}"(应为 task-NN 或 NN,可逗号分隔多个)`)
|
|
29
|
+
}
|
|
30
|
+
return `task-${String(m[1]).padStart(2, '0')}`
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* YAML 标量安全序列化(坑 taskcard-title-backtick-yaml,2026-08-20 实证):plan checkbox 行名
|
|
35
|
+
* 是自由文本,带反引号(如 "`-p` 参数支持")时裸插值进 frontmatter——`` ` `` 与 @ 是 YAML 保留
|
|
36
|
+
* 指示符,js-yaml 直接抛错,契约字段(allowed_paths/constraints 等)随整个 frontmatter 静默丢失,
|
|
37
|
+
* 靠 plan postcheck 才兜住。统一单引号包裹 + 内部 ' 双写(YAML 标准转义),无边界字符要判。
|
|
38
|
+
* @param {string} v
|
|
39
|
+
* @returns {string}
|
|
40
|
+
*/
|
|
41
|
+
function yamlScalar(v) {
|
|
42
|
+
const s = String(v ?? '')
|
|
43
|
+
return `'${s.replace(/'/g, "''")}'`
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* 生成 TaskCard 骨架字符串(纯函数)。
|
|
48
|
+
*
|
|
49
|
+
* 硬校验 9 字段(plan-postcheck feasibility)+ 规范字段全部就位,占位符由子代理 Edit 填充;
|
|
50
|
+
* 可选字段(provides/expects_from/related_tests)默认不生成——缩小 YAML 出错面,何时加的
|
|
51
|
+
* 判据写在文件尾注释(规则详情见 templates/prompts/taskcard-rules.md)。
|
|
52
|
+
* 字符串只含 \n:writeFileSync 直写不经任何行尾转换,Windows 下天然 LF 安全。
|
|
53
|
+
* title/title_zh/author 经 yamlScalar 转义(自由文本防 YAML 指示符炸解析)。
|
|
54
|
+
*
|
|
55
|
+
* @param {{ taskId: string, title: string, titleZh: string, author: string, now: string }} p
|
|
56
|
+
* @returns {string}
|
|
57
|
+
*/
|
|
58
|
+
export function buildTaskcardSkeleton({ taskId, title, titleZh, author, now }) {
|
|
59
|
+
return `---
|
|
60
|
+
id: ${taskId}
|
|
61
|
+
title: ${yamlScalar(title)}
|
|
62
|
+
title_zh: ${yamlScalar(titleZh)}
|
|
63
|
+
author: ${yamlScalar(author)}
|
|
64
|
+
created_at: ${now}
|
|
65
|
+
priority: P0
|
|
66
|
+
depends_on: []
|
|
67
|
+
blocks: []
|
|
68
|
+
requirement_ids: [FR-XX]
|
|
69
|
+
decision_ids: [D-XXX@vN]
|
|
70
|
+
allowed_paths:
|
|
71
|
+
- src/example/file.ts
|
|
72
|
+
goal: >
|
|
73
|
+
一句话说明这个 task 要做什么、为什么。
|
|
74
|
+
implementation:
|
|
75
|
+
- 具体步骤 1
|
|
76
|
+
- 具体步骤 2
|
|
77
|
+
acceptance:
|
|
78
|
+
- 可验证的验收条件 1
|
|
79
|
+
- 可验证的验收条件 2
|
|
80
|
+
verify:
|
|
81
|
+
- cd frontend && pnpm exec tsc --noEmit
|
|
82
|
+
constraints:
|
|
83
|
+
- 边界约束 1(如:不加测试)
|
|
84
|
+
- 边界约束 2(如:不修改传入参数)
|
|
85
|
+
---
|
|
86
|
+
|
|
87
|
+
<!-- 骨架由 sillyspec taskcard 生成(LF 行尾 + frontmatter 已闭合 + 硬校验 9 字段齐全)。
|
|
88
|
+
用 Edit tool 填充上方占位符(allowed_paths/goal/implementation/acceptance/verify/constraints 等),
|
|
89
|
+
勿用 Write 整文件重写——会引入 CRLF 行尾/漏闭合 ---/漏字段回归。
|
|
90
|
+
可选字段按需插进上方 frontmatter(规则见 taskcard-rules):
|
|
91
|
+
provides: 仅当本 task 给其他 task 提供接口/DTO/响应时填
|
|
92
|
+
expects_from: 仅当本 task 消费其他 task 的契约时填
|
|
93
|
+
related_tests: 仅当本 task 改动导致既有测试断言失效时填(测试路径须同时进 allowed_paths) -->
|
|
94
|
+
`
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* taskcard 命令主逻辑:定位变更目录 → 解析任务清单(--task 显式列表 / 'all' 从 plan.md 取)→
|
|
99
|
+
* 逐个写骨架。
|
|
100
|
+
*
|
|
101
|
+
* @param {string} changeName 变更名(.sillyspec/changes/ 下目录名)
|
|
102
|
+
* @param {{ cwd: string, specDir?: string|null, taskIds: string[]|'all', title?: string|null,
|
|
103
|
+
* titleZh?: string|null, force?: boolean }} opts
|
|
104
|
+
* taskIds:normalizeTaskId 归一后的数组;或字面 'all' 表示从 plan.md 取全部 checkbox 任务
|
|
105
|
+
* @returns {{ created: string[], skipped: string[], tasksDir: string }}
|
|
106
|
+
*/
|
|
107
|
+
export function cmdTaskcard(changeName, opts = {}) {
|
|
108
|
+
const { cwd, specDir = null, taskIds, title = null, titleZh = null, force = false } = opts
|
|
109
|
+
assertSafeChangeName(changeName, '变更名')
|
|
110
|
+
|
|
111
|
+
const changeDir = join(resolveSpecDir(cwd, { specDir }), 'changes', changeName)
|
|
112
|
+
if (!existsSync(changeDir)) {
|
|
113
|
+
throw new Error(`变更目录不存在: ${changeDir}(先完成 plan 前置步骤,或检查 --change 名)`)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// 任务注册表 tasks.md(2026-08-20-task-truth-unify 唯一真相;CRLF 归一后解析,
|
|
117
|
+
// --all 时强依赖)。tasks.md 缺失回退 plan.md(旧归档变更兼容读侧)
|
|
118
|
+
let planTasks = []
|
|
119
|
+
const tasksMdPath = join(changeDir, 'tasks.md')
|
|
120
|
+
const planPath = join(changeDir, 'plan.md')
|
|
121
|
+
const registryPath = existsSync(tasksMdPath) ? tasksMdPath : planPath
|
|
122
|
+
if (existsSync(registryPath)) {
|
|
123
|
+
const registryContent = readFileSync(registryPath, 'utf8').replace(/\r\n/g, '\n')
|
|
124
|
+
planTasks = parseTaskNames(registryContent)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
let ids
|
|
128
|
+
if (taskIds === 'all') {
|
|
129
|
+
if (planTasks.length === 0) {
|
|
130
|
+
throw new Error(
|
|
131
|
+
`--all 需要 tasks.md 中存在 checkbox 任务行(格式 "- [ ] task-XX: 任务名")` +
|
|
132
|
+
(existsSync(registryPath) ? ',当前任务清单未解析到任何任务行' : ',且变更目录下未找到 tasks.md/plan.md')
|
|
133
|
+
)
|
|
134
|
+
}
|
|
135
|
+
ids = planTasks.map(t => `task-${t.num}`)
|
|
136
|
+
} else if (Array.isArray(taskIds) && taskIds.length > 0) {
|
|
137
|
+
ids = taskIds
|
|
138
|
+
} else {
|
|
139
|
+
throw new Error('缺少任务清单:需 --task task-01[,task-02...] 或 --all')
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// 标题优先级:--title/--title-zh 参数 > plan.md checkbox 行名称 > 占位符。
|
|
143
|
+
// 参数仅单任务时可用(多任务各卡标题不同,统一参数必错)。
|
|
144
|
+
if ((title || titleZh) && ids.length > 1) {
|
|
145
|
+
throw new Error('--title/--title-zh 仅在 --task 指定单个任务时可用(多任务标题自动取自 plan.md checkbox 行)')
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const author = (() => {
|
|
149
|
+
try {
|
|
150
|
+
// QUAL-01 收口:原 execSync 字符串拼接(经 shell,违反自家注入规约)→ git-helper 数组形式
|
|
151
|
+
return git(cwd, ['config', 'user.name']) || 'unknown'
|
|
152
|
+
} catch {
|
|
153
|
+
return 'unknown'
|
|
154
|
+
}
|
|
155
|
+
})()
|
|
156
|
+
const now = new Date().toISOString().slice(0, 19).replace('T', ' ')
|
|
157
|
+
|
|
158
|
+
const tasksDir = join(changeDir, 'tasks')
|
|
159
|
+
mkdirSync(tasksDir, { recursive: true })
|
|
160
|
+
|
|
161
|
+
const created = []
|
|
162
|
+
const skipped = []
|
|
163
|
+
for (const id of ids) {
|
|
164
|
+
const planMatch = planTasks.find(t => `task-${t.num}` === id)
|
|
165
|
+
const finalTitle = title || (planMatch ? planMatch.name : null) || `${id} fill-english-title`
|
|
166
|
+
const finalTitleZh = titleZh || (planMatch ? planMatch.name : null) || finalTitle
|
|
167
|
+
const filePath = join(tasksDir, `${id}.md`)
|
|
168
|
+
if (existsSync(filePath) && !force) {
|
|
169
|
+
skipped.push(filePath)
|
|
170
|
+
continue
|
|
171
|
+
}
|
|
172
|
+
writeFileSync(filePath, buildTaskcardSkeleton({
|
|
173
|
+
taskId: id, title: finalTitle, titleZh: finalTitleZh, author, now,
|
|
174
|
+
}), 'utf8')
|
|
175
|
+
created.push(filePath)
|
|
176
|
+
}
|
|
177
|
+
return { created, skipped, tasksDir }
|
|
178
|
+
}
|