sillyspec 3.28.10 → 3.28.11
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-execute/SKILL.md +223 -222
- package/.claude/skills/sillyspec-plan/SKILL.md +156 -156
- package/.claude/skills/sillyspec-quick/SKILL.md +8 -10
- package/.claude/skills/sillyspec-verify/SKILL.md +91 -87
- package/package.json +1 -1
- package/src/change-risk-profile.js +51 -3
- package/src/config-schema.js +21 -0
- package/src/design-facts.js +46 -9
- package/src/docs-check.js +14 -10
- package/src/docs-debt.js +7 -2
- package/src/docs-gate.js +42 -15
- package/src/endpoint-extractor.js +50 -2
- package/src/foreign-declared.js +25 -16
- package/src/hooks/worktree-guard.js +75 -2
- package/src/index.js +129 -1
- package/src/machine-interface.js +26 -0
- package/src/probe7-anchor-check.js +20 -2
- package/src/progress/stage-machine.js +34 -5
- package/src/progress.js +1 -1
- package/src/quicklog.js +231 -1
- package/src/run/complete-handlers.js +172 -1
- package/src/run/complete.js +73 -3
- package/src/run/gate-snapshot.js +262 -0
- package/src/run/gates.js +41 -7
- package/src/run/prompt.js +79 -1
- package/src/run/shared.js +10 -0
- package/src/stage-contract-spec.js +14 -0
- package/src/stage-contract.js +314 -8
- package/src/stage-review-checklist.js +3 -1
- package/src/stage-review.js +137 -2
- package/src/stages/archive.js +9 -5
- package/src/stages/brainstorm.js +7 -1
- package/src/stages/execute.js +62 -8
- package/src/stages/plan-postcheck.js +42 -5
- package/src/stages/plan.js +8 -1
- package/src/stages/quick.js +5 -5
- package/src/stages/verify.js +3 -2
- package/src/sync.js +69 -0
- package/src/task-review.js +9 -3
- package/src/verify-facts-schema.js +78 -9
- package/src/verify-postcheck.js +260 -26
- package/src/verify-probes.js +780 -44
- package/src/worktree-apply.js +175 -3
- package/src/write-audit.js +55 -0
- package/.claude/full.json +0 -1
package/src/index.js
CHANGED
|
@@ -76,6 +76,9 @@ SillySpec CLI — 规范驱动开发工具包
|
|
|
76
76
|
sillyspec wt-commit [--change <名>] -m <信息> [--pathspec-from-file <f>] -- <path...>
|
|
77
77
|
worktree 内 per-task 串行化提交(文件锁排队 + 强制显式 pathspec,
|
|
78
78
|
防 add -A 卷入并行兄弟 WIP;worktree 内可省 --change,从 cwd 推断)
|
|
79
|
+
sillyspec quicklog commit [--change <quick会话ID>] -m <信息> [--ql <ql-id>]... [-- <额外pathspec...>]
|
|
80
|
+
一键收编本会话 QUICKLOG 条目(切片提交:HEAD 基线+本会话条目块,
|
|
81
|
+
patches sidecar 一并带上;并行会话条目恢复为工作区未提交态,不夹带)
|
|
79
82
|
sillyspec worktree <cmd> git worktree 隔离管理(execute 阶段相关)
|
|
80
83
|
create <change> [--base <branch>] 创建隔离 worktree
|
|
81
84
|
apply <change> [--check-only] 校验并应用变更到主工作区
|
|
@@ -167,6 +170,7 @@ SillySpec CLI — 规范驱动开发工具包
|
|
|
167
170
|
sillyspec run plan --reopen --from-step 2 # 修订 plan,从第 2 步重做
|
|
168
171
|
sillyspec run quick --non-interactive --done --output "CI 内的快修" # 脚本/CI
|
|
169
172
|
sillyspec progress show
|
|
173
|
+
sillyspec progress show --all # 多变更汇总时无信号变更全量单行列出(默认折叠超 8 个)
|
|
170
174
|
sillyspec progress show --json # 全局总览 envelope(面板/脚本消费)
|
|
171
175
|
sillyspec worktree apply 2026-07-03-add-login
|
|
172
176
|
`);
|
|
@@ -372,7 +376,7 @@ async function main() {
|
|
|
372
376
|
process.exitCode = exitCode;
|
|
373
377
|
break;
|
|
374
378
|
}
|
|
375
|
-
pm.show(progDir, progChangeName);
|
|
379
|
+
pm.show(progDir, progChangeName, { all: filteredArgs.includes('--all') });
|
|
376
380
|
break;
|
|
377
381
|
}
|
|
378
382
|
case 'check': {
|
|
@@ -606,6 +610,84 @@ async function main() {
|
|
|
606
610
|
// [--changed-files a,b](归属切片为空或纯验证任务时显式给出;""=空覆盖)
|
|
607
611
|
// [--base <rev> --head <rev>](无 worktree meta 时显式锡点)[--force]
|
|
608
612
|
const rwSub = filteredArgs[1];
|
|
613
|
+
// ── review status(C1 轻量硬机制,坑 review-liveness-opaque,2026-09-15 wp EHS 实证:
|
|
614
|
+
// 独立审查 94 分钟无收敛、用户三催——宿主/agent 无只读探针可查「审查到哪了、审完没有、
|
|
615
|
+
// 审完之后文档又改过几版」。agent-tool 通道的子代理进程归宿主管(CLI 无 kill 面),但
|
|
616
|
+
// 「状态可见」这一半可以闭环:各 stage review 的 verdict/通道/刷新序数/代落盘·降级标记/
|
|
617
|
+
// docHash 现势一行可见,供催问时一查即答、也供派发前查前序审查复用(C2 互补)。──
|
|
618
|
+
if (rwSub === 'status') {
|
|
619
|
+
const rsVal = (flag) => {
|
|
620
|
+
const i = filteredArgs.indexOf(flag);
|
|
621
|
+
return i >= 0 && filteredArgs[i + 1] && !String(filteredArgs[i + 1]).startsWith("--") ? filteredArgs[i + 1] : null;
|
|
622
|
+
};
|
|
623
|
+
const rsChange = rsVal('--change');
|
|
624
|
+
if (!rsChange) {
|
|
625
|
+
console.error('用法: sillyspec review status --change <名> [--json]\n 只读查询各阶段 stage review 状态:verdict/执行通道/刷新序数/代落盘·降级标记/docHash 现势(主文档改版未刷新时提示)');
|
|
626
|
+
process.exit(2);
|
|
627
|
+
}
|
|
628
|
+
assertSafeChangeName(rsChange, '--change 变更名');
|
|
629
|
+
const { getLatestStageReviewRunId, computeDocHash, isDegradedSelfReview, hasDelegatedWriteDisclosure, classifyReviewerChannel } = await import('./stage-review.js');
|
|
630
|
+
const { resolveRuntimeRoot } = await import('./run/shared.js');
|
|
631
|
+
const rsPlatformOpts = {};
|
|
632
|
+
const rsResolved = resolvePlatformOpts(dir, specDir);
|
|
633
|
+
if (rsResolved) {
|
|
634
|
+
rsPlatformOpts.specRoot = rsResolved.specRoot;
|
|
635
|
+
if (rsResolved.runtimeRoot) rsPlatformOpts.runtimeRoot = rsResolved.runtimeRoot;
|
|
636
|
+
}
|
|
637
|
+
const rsSpecBase = rsPlatformOpts.specRoot || join(dir, '.sillyspec');
|
|
638
|
+
const rsRuntimeRoot = resolveRuntimeRoot(rsPlatformOpts, rsSpecBase);
|
|
639
|
+
const rows = [];
|
|
640
|
+
for (const st of ['brainstorm', 'plan', 'execute']) {
|
|
641
|
+
let runId = null;
|
|
642
|
+
try { runId = getLatestStageReviewRunId(rsRuntimeRoot, st, rsChange) } catch {}
|
|
643
|
+
if (!runId) { rows.push({ stage: st, status: 'none' }); continue }
|
|
644
|
+
const rjPath = join(rsRuntimeRoot, 'stage-reviews', `${st}-${runId}`, 'review.json');
|
|
645
|
+
let rev = null;
|
|
646
|
+
try { rev = JSON.parse(readFileSync(rjPath, 'utf8')) } catch {
|
|
647
|
+
rows.push({ stage: st, status: 'unreadable', runId, reviewPath: rjPath }); continue
|
|
648
|
+
}
|
|
649
|
+
const notes = typeof rev.reviewerNotes === 'string' ? rev.reviewerNotes : '';
|
|
650
|
+
const refreshCount = (notes.match(/docHash auto-refreshed at /g) || []).length;
|
|
651
|
+
// docHash 现势:主文档在 [specBase, changeDir, cwd] 基准解析后重算对比
|
|
652
|
+
let docState = 'unknown';
|
|
653
|
+
const primaryRel = Array.isArray(rev.reviewedFiles) ? rev.reviewedFiles[0] : null;
|
|
654
|
+
if (primaryRel) {
|
|
655
|
+
for (const base of [rsSpecBase, join(rsSpecBase, 'changes', rsChange), dir]) {
|
|
656
|
+
const abs = join(base, primaryRel);
|
|
657
|
+
if (existsSync(abs)) {
|
|
658
|
+
docState = computeDocHash(abs) === rev.docHash ? 'match' : 'stale(改版未刷新——gate 将自动重算放行,verdict 续用需人工确认)';
|
|
659
|
+
break
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
if (docState === 'unknown') docState = '主文档缺失';
|
|
663
|
+
}
|
|
664
|
+
rows.push({
|
|
665
|
+
stage: st, status: 'present', runId, reviewPath: rjPath,
|
|
666
|
+
specVerdict: rev.specVerdict, qualityVerdict: rev.qualityVerdict,
|
|
667
|
+
channel: classifyReviewerChannel(rev),
|
|
668
|
+
degradedSelf: isDegradedSelfReview(rev), delegatedWrite: hasDelegatedWriteDisclosure(rev),
|
|
669
|
+
refreshCount, docState,
|
|
670
|
+
});
|
|
671
|
+
}
|
|
672
|
+
if (json) {
|
|
673
|
+
process.stdout.write(JSON.stringify({ ok: true, command: 'review status', change: rsChange, stages: rows }) + '\n');
|
|
674
|
+
} else {
|
|
675
|
+
console.log(`📋 stage review 状态 [${rsChange}]:`);
|
|
676
|
+
for (const r of rows) {
|
|
677
|
+
if (r.status === 'none') { console.log(` ⬜ ${r.stage}: 无 review 记录`); continue }
|
|
678
|
+
if (r.status === 'unreadable') { console.log(` ⚠️ ${r.stage}: review.json 不可读(${r.reviewPath})`); continue }
|
|
679
|
+
const flags = [
|
|
680
|
+
r.channel !== 'agent-tool' && r.channel !== 'platform' ? `通道=${r.channel}` : null,
|
|
681
|
+
r.degradedSelf ? '⚠️降级自审' : null,
|
|
682
|
+
r.delegatedWrite ? '⚠️代落盘' : null,
|
|
683
|
+
r.refreshCount > 0 ? `已刷新×${r.refreshCount}${r.refreshCount >= 2 ? '(反复改版,人工确认结论续用)' : ''}` : null,
|
|
684
|
+
r.docState !== 'match' && r.docState !== 'unknown' ? `docHash=${r.docState}` : null,
|
|
685
|
+
].filter(Boolean).join(',');
|
|
686
|
+
console.log(` ${r.specVerdict === 'pass' && r.qualityVerdict === 'pass' ? '✅' : '❌'} ${r.stage}: spec=${r.specVerdict} quality=${r.qualityVerdict}(${r.channel}${flags ? ';' + flags : ''})`);
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
break;
|
|
690
|
+
}
|
|
609
691
|
if (rwSub !== 'write') {
|
|
610
692
|
console.error('用法: sillyspec review write --change <名> --task task-NN --spec <pass|fail|cannot_verify> --quality <verdict>\n [--notes "评审备注"] [--evidence "cannot_verify 证据说明"] [--changed-files a,b] [--base <rev> --head <rev>] [--force] [--json]\n 单 task review.json 命令式写入:verdict/notes 由你给,executeRunId/base/head/changedFiles/diffPaths 由 CLI 从 git + task 卡代算;已存在默认拒覆盖(--force 越过)');
|
|
611
693
|
process.exit(2);
|
|
@@ -1979,6 +2061,8 @@ ${generated.length} 个骨架已就绪——逐节把 <!--TODO--> 替换为语
|
|
|
1979
2061
|
console.log(` marker → ${result.markerPath}`);
|
|
1980
2062
|
if (result.mode === 'refreshed') {
|
|
1981
2063
|
console.log(` docHash 已按当前 ${result.mainDoc} 重算(verdict 结论是否仍适用需人工确认)`);
|
|
2064
|
+
} else if (result.mode === 'noop-unchanged') {
|
|
2065
|
+
console.log(` docHash 已与当前 ${result.mainDoc} 一致,跳过重写(幂等 no-op,不动文件)`);
|
|
1982
2066
|
} else {
|
|
1983
2067
|
console.log(` 下一步:独立审查子代理对照 ${result.mainDoc} 填 verdict/checklist 后重跑 --done`);
|
|
1984
2068
|
}
|
|
@@ -2697,6 +2781,50 @@ ${generated.length} 个骨架已就绪——逐节把 <!--TODO--> 替换为语
|
|
|
2697
2781
|
}
|
|
2698
2782
|
break;
|
|
2699
2783
|
}
|
|
2784
|
+
case 'quicklog': {
|
|
2785
|
+
// known-issues ④ v1(2026-09-16):QUICKLOG 多会话条目交织——本会话提交 git add 整文件
|
|
2786
|
+
// 会夹带并行会话未完成条目,只能手工四步舞(备份→剥离→commit→恢复)。本命令一键机制化:
|
|
2787
|
+
// 切片(HEAD 基线+本会话条目块)→ 显式 pathspec 提交(QUICKLOG 文件+patches sidecar+额外
|
|
2788
|
+
// pathspec)→ 恢复工作区(并行条目留未提交态)。参数面照抄 wt-commit 先例(--change/-m/--
|
|
2789
|
+
// pathspec)+ 可重复 --ql(guard 缺失/损坏时的显式逃生门;含已取消条目)。
|
|
2790
|
+
if (filteredArgs[1] !== 'commit') {
|
|
2791
|
+
console.error(`❌ 未知 quicklog 子命令: ${filteredArgs[1] || '(空)'}——当前支持: sillyspec quicklog commit [--change <quick会话ID>] -m <信息> [--ql <ql-id>]... [-- <额外pathspec...>]`);
|
|
2792
|
+
process.exit(1);
|
|
2793
|
+
}
|
|
2794
|
+
const qlRest = filteredArgs.slice(2);
|
|
2795
|
+
const qlDd = qlRest.indexOf('--');
|
|
2796
|
+
const qlFlags = qlDd === -1 ? qlRest : qlRest.slice(0, qlDd);
|
|
2797
|
+
const qlExtra = qlDd === -1 ? [] : qlRest.slice(qlDd + 1);
|
|
2798
|
+
let qlChange = null;
|
|
2799
|
+
let qlMessage = null;
|
|
2800
|
+
const qlIds = [];
|
|
2801
|
+
for (let i = 0; i < qlFlags.length; i++) {
|
|
2802
|
+
if (qlFlags[i] === '--change' && qlFlags[i + 1]) qlChange = qlFlags[++i];
|
|
2803
|
+
else if ((qlFlags[i] === '-m' || qlFlags[i] === '--message') && qlFlags[i + 1]) qlMessage = qlFlags[++i];
|
|
2804
|
+
else if (qlFlags[i] === '--ql' && qlFlags[i + 1]) qlIds.push(qlFlags[++i]);
|
|
2805
|
+
}
|
|
2806
|
+
try {
|
|
2807
|
+
const { resolvePlatformSpecDir, resolvePlatformOpts } = await import('./progress.js');
|
|
2808
|
+
const qlSpecBase = resolvePlatformSpecDir(dir, specDir);
|
|
2809
|
+
const { resolveQuickSessionsDir } = await import('./run/shared.js');
|
|
2810
|
+
const qlSessionsDir = resolveQuickSessionsDir(resolvePlatformOpts(dir, specDir), qlSpecBase);
|
|
2811
|
+
let qlUser = 'unknown';
|
|
2812
|
+
try { qlUser = (await import('./git-helper.js')).git(dir, ['config', 'user.name']) || 'unknown'; } catch {}
|
|
2813
|
+
const { runQuicklogCommit } = await import('./quicklog.js');
|
|
2814
|
+
const r = await runQuicklogCommit({
|
|
2815
|
+
specBase: qlSpecBase, cwd: dir, gitUser: qlUser, qlIds,
|
|
2816
|
+
changeName: qlChange, sessionsDir: qlSessionsDir,
|
|
2817
|
+
message: qlMessage, extraPathspecs: qlExtra,
|
|
2818
|
+
});
|
|
2819
|
+
if (json) console.log(JSON.stringify({ command: 'quicklog commit', ...r }, null, 2));
|
|
2820
|
+
else if (r.skipped) console.log(`⏭️ quicklog commit 无新内容跳过(HEAD 不动):${r.shortHead}(条目均已收编:${(r.skippedQlIds || []).join(', ') || '(无)'})`);
|
|
2821
|
+
else console.log(`✅ quicklog commit 完成:${r.shortHead}(${r.files.length} 文件;收编 ${(r.committedQlIds || []).join(', ')})\n 并行会话条目已恢复为工作区未提交态;备份:${(r.backupPaths || []).join(' , ')}`);
|
|
2822
|
+
} catch (e) {
|
|
2823
|
+
console.error(`❌ quicklog commit 失败:${e.message}`);
|
|
2824
|
+
process.exit(1);
|
|
2825
|
+
}
|
|
2826
|
+
break;
|
|
2827
|
+
}
|
|
2700
2828
|
case 'worktree': {
|
|
2701
2829
|
const { WorktreeManager } = await import('./worktree.js');
|
|
2702
2830
|
const { ProgressManager, resolveSessionIdentity } = await import('./progress.js');
|
package/src/machine-interface.js
CHANGED
|
@@ -168,6 +168,32 @@ export async function runGate(stage, changeName, { cwd, specBase, runtimeRoot, s
|
|
|
168
168
|
warnings: r.warnings || [],
|
|
169
169
|
});
|
|
170
170
|
|
|
171
|
+
// ── a2. design 文件清单行级核验(brainstorm;坑 design-file-ref-late-feedback,2026-09-15
|
|
172
|
+
// wp EHS 会话实证:41 条 design_file_ref_invalid 只在 --done 末步爆出,gate 预检全绿——
|
|
173
|
+
// 前移进 gate 让写作期预检即可见,免「末步拦→改文档→重跑 --done」整轮往返。与 complete.js
|
|
174
|
+
// --done 硬门同源实现(design-facts.js validateDesignFileList),gate 只读不写)──
|
|
175
|
+
if (stage === 'brainstorm') {
|
|
176
|
+
try {
|
|
177
|
+
const { validateDesignFileList } = await import('./design-facts.js');
|
|
178
|
+
const dfl = validateDesignFileList({ changeDir: join(specRoot, 'changes', changeName), cwd });
|
|
179
|
+
checks.push({
|
|
180
|
+
id: 'design-file-list',
|
|
181
|
+
ok: (dfl.errors || []).length === 0,
|
|
182
|
+
// errors 元素是 {path, message} 对象(与 complete.js --done 打印侧同源),gate check 面拍平为 message 字符串
|
|
183
|
+
errors: (dfl.errors || []).map(e => e && e.message ? e.message : String(e)),
|
|
184
|
+
warnings: dfl.warnings || [],
|
|
185
|
+
});
|
|
186
|
+
} catch (e) {
|
|
187
|
+
// fail-open 与 complete.js 同口径:核验自身异常不误拦,warning 留痕
|
|
188
|
+
checks.push({
|
|
189
|
+
id: 'design-file-list',
|
|
190
|
+
ok: true,
|
|
191
|
+
errors: [],
|
|
192
|
+
warnings: [`design 清单核验自身异常,跳过(${e && e.message ? e.message : e})`],
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
171
197
|
// ── b. transition:状态转换合法性(参与综合 ok,与 completeStep 硬阻断一致)──
|
|
172
198
|
// 与 run.js runStage 同源:传 fromStageData 触发 failed_post_check 门控。
|
|
173
199
|
// transition 必须参与 ok:否则 gate 返回 ok=true、exit 0,Agent 据此 --done 却被
|
|
@@ -4,7 +4,18 @@
|
|
|
4
4
|
* 坑 probe7-covered-anchor-missing(2026-09-15 复盘实证:探针7 矩阵第一轮 7 行 covered 判定的
|
|
5
5
|
* 证据列缺 file:line 锚点被审查打回——预填说明写着「证据列给首命中 file:line 锚点」但模板要求
|
|
6
6
|
* 没细读就交,人工往返一轮)。本模块把该口径机器化:verify --done 时点解析正文探针7 段,
|
|
7
|
-
* 判定列=covered 的行必须含
|
|
7
|
+
* 判定列=covered 的行必须含 file:line / `.test.` 测试文件名 / 反引号包裹路径或测试名三形态
|
|
8
|
+
* 之一,缺则 advisory 提示回补。
|
|
9
|
+
*
|
|
10
|
+
* 口径对齐(2026-09-16-friction5-hardening FR-05 / D-005@v1,用户 2026-09-16 驾驭小结⑤):
|
|
11
|
+
* 锚点认 file:line 或 `.test.` 文件名两形态(对齐 stage-contract 硬门三形态中的两形态)——
|
|
12
|
+
* 行号随提交漂移场景 `.test.` 文件名锚过硬门仍被 advisory 提示回补是无效摩擦。file:line
|
|
13
|
+
* 侧本判定(/:\d+\b/)宽于 stage-contract 硬门形态集,advisory 宽于硬门是设计容差。
|
|
14
|
+
*
|
|
15
|
+
* 口径再对齐(2026-09-17-feedback-hardening FR-02 / D-005@v2,supersedes D-005@v1 的
|
|
16
|
+
* 「裸反引号不收」,用户 2026-09-17 驾驭小结②):反引号包裹的路径/测试名与硬门
|
|
17
|
+
* matrixEvidenceHasAnchor 同权收认——硬门已保证锚点存在,advisory 多要行号在行号漂移
|
|
18
|
+
* 场景零正确性收益纯摩擦。
|
|
8
19
|
*
|
|
9
20
|
* 定位与边界:
|
|
10
21
|
* - **advisory 不阻断**:covered 行缺锚点只 warn(证据可以是合法的人工核验形态,但 covered=
|
|
@@ -20,6 +31,10 @@
|
|
|
20
31
|
// file:line 锚点:冒号后跟数字(`test/x.test.mjs:42`、`src/a.py:12-18` 均命中)
|
|
21
32
|
const ANCHOR_RE = /:\d+\b/
|
|
22
33
|
|
|
34
|
+
// 反引号包裹的路径/测试名(`test/x.mjs`、`src/plain.js` 等——D-005@v2 第三形态,
|
|
35
|
+
// 与 stage-contract 硬门 matrixEvidenceHasAnchor 的 /`[^`]+`/ 同权)
|
|
36
|
+
const BACKTICK_RE = /`[^`]+`/
|
|
37
|
+
|
|
23
38
|
// 判定枚举纯值(骨架四枚举;容忍反引号包裹——门禁层 extractAcceptanceMatrixSlots 另有纯值校验)
|
|
24
39
|
function normalizeVerdict(cell) {
|
|
25
40
|
return String(cell || '').trim().replace(/^`|`$/g, '').toLowerCase()
|
|
@@ -57,7 +72,10 @@ export function checkProbe7AnchorCoverage(reportText) {
|
|
|
57
72
|
if (verdict !== 'covered') continue
|
|
58
73
|
out.coveredRows++
|
|
59
74
|
const evidence = cells[5] || ''
|
|
60
|
-
|
|
75
|
+
// 三形态锚点(FR-05 / D-005@v2):file:line(ANCHOR_RE,本体不动)、`.test.` 测试文件名、
|
|
76
|
+
// 反引号包裹路径/测试名(BACKTICK_RE)——三者对齐 stage-contract 硬门形态,消灭行号漂移
|
|
77
|
+
// 场景的无效回补提示
|
|
78
|
+
if (!ANCHOR_RE.test(evidence) && !/\.test\./.test(evidence) && !BACKTICK_RE.test(evidence)) {
|
|
61
79
|
out.missingAnchors.push({
|
|
62
80
|
task: task || '(未知 task)',
|
|
63
81
|
acceptance: (cells[1] || '').slice(0, 80),
|
|
@@ -185,7 +185,7 @@ export class StageMachine {
|
|
|
185
185
|
console.log(`✅ 阶段 ${stage} 已标记为完成(不自动推进,下一步由你决定)`);
|
|
186
186
|
}
|
|
187
187
|
|
|
188
|
-
show(cwd, changeName = null) {
|
|
188
|
+
show(cwd, changeName = null, opts = {}) {
|
|
189
189
|
// 如果指定了变更名,只显示该变更
|
|
190
190
|
if (changeName) {
|
|
191
191
|
return this._showChange(cwd, changeName);
|
|
@@ -218,20 +218,36 @@ export class StageMachine {
|
|
|
218
218
|
const changesRoot = join(this.pm._getSpecDir(cwd), 'changes');
|
|
219
219
|
// P2-2-①:未决同步冲突标红(overview 同源 _listPendingConflicts;该变更命中 → 🔴 行)
|
|
220
220
|
const pendingConflicts = this._listPendingConflicts(this.pm._getSpecDir(cwd));
|
|
221
|
+
// 折叠显示(2026-09-17 用户反馈⑥-③,ql-20260917-005):29 个滞留变更的全量逐条输出
|
|
222
|
+
// (每条 3-5 行 + 空行)把真正需要看的信号淹掉。口径:**有信号的变更**(未决冲突/滞留/
|
|
223
|
+
// 疑似完成/目录缺失/无法读取)保持原有详情渲染——它们就是该看的东西;**无信号变更**压成
|
|
224
|
+
// 单行,且超过 CLEAN_SHOW_CAP 个时折叠为计数行(--all 展开全量单行;单变更详情恒走
|
|
225
|
+
// --change <名>)。--json envelope(机器面)不受影响。
|
|
226
|
+
const CLEAN_SHOW_CAP = 8;
|
|
227
|
+
const withSignals = [];
|
|
228
|
+
const clean = [];
|
|
221
229
|
for (const cn of changes) {
|
|
222
230
|
const data = this.pm.read(cwd, cn);
|
|
223
231
|
const dirMissing = _isGhostChange(changesRoot, cn);
|
|
232
|
+
const cnConflicts = pendingConflicts.filter(c => c.change === cn);
|
|
233
|
+
const stall = data ? this._stallSignal(cwd, data, cn) : null;
|
|
234
|
+
if (!data || dirMissing || cnConflicts.length > 0 || stall) {
|
|
235
|
+
withSignals.push({ cn, data, dirMissing, cnConflicts, stall });
|
|
236
|
+
} else {
|
|
237
|
+
clean.push({ cn, data });
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
for (const { cn, data, dirMissing, cnConflicts, stall } of withSignals) {
|
|
224
242
|
if (!data) {
|
|
225
243
|
console.log(` 📂 ${cn} — (无法读取)${dirMissing ? ' ⚠️ 目录缺失(残留记录,sillyspec doctor --cleanup-ghosts --confirm 可归档清理)' : ''}`);
|
|
226
244
|
continue;
|
|
227
245
|
}
|
|
228
|
-
const
|
|
229
|
-
const stageLabel = STAGE_LABELS[data.currentStage] || currentStage;
|
|
246
|
+
const stageLabel = STAGE_LABELS[data.currentStage] || data.currentStage || '(无)';
|
|
230
247
|
const lastActive = data.lastActive ? this._timeAgo(data.lastActive) : '未知';
|
|
231
248
|
|
|
232
249
|
console.log(` 📂 ${cn}${dirMissing ? ' ⚠️ 目录缺失(残留记录,sillyspec doctor --cleanup-ghosts --confirm 可归档清理)' : ''}`);
|
|
233
250
|
// 变更级未决冲突标红(冲突可见性,2026-09-02 P2-2-①):不再靠 agent 自己撞上才发现
|
|
234
|
-
const cnConflicts = pendingConflicts.filter(c => c.change === cn);
|
|
235
251
|
if (cnConflicts.length > 0) {
|
|
236
252
|
for (const cf of cnConflicts) {
|
|
237
253
|
console.log(` 🔴 未决同步冲突(${cf.type === 'progress' ? '进度' : 'spec 树'}${cf.created_at ? `,${cf.created_at}` : ''})——请 sillyspec platform resolve 处理后再推进`);
|
|
@@ -239,7 +255,6 @@ export class StageMachine {
|
|
|
239
255
|
}
|
|
240
256
|
console.log(` 当前阶段: ${stageLabel} 最近活跃: ${lastActive}`);
|
|
241
257
|
// 滞留/疑似完成信号(2026-08-30 用户反馈②):多变更汇总逐行透出,无需逐个 --change 看详情
|
|
242
|
-
const stall = this._stallSignal(cwd, data, cn);
|
|
243
258
|
if (stall) {
|
|
244
259
|
console.log(` ⏳ ${stall.text}`);
|
|
245
260
|
console.log(stall.kind === 'likely-complete'
|
|
@@ -249,6 +264,20 @@ export class StageMachine {
|
|
|
249
264
|
console.log('');
|
|
250
265
|
}
|
|
251
266
|
|
|
267
|
+
// 无信号变更:单行压缩(名 + 阶段 + 最近活跃),超帽折叠(--all 展开)
|
|
268
|
+
clean.sort((a, b) => String(b.data?.lastActive || '').localeCompare(String(a.data?.lastActive || '')));
|
|
269
|
+
const shown = opts.all ? clean : clean.slice(0, CLEAN_SHOW_CAP);
|
|
270
|
+
for (const { cn, data } of shown) {
|
|
271
|
+
const stageLabel = STAGE_LABELS[data.currentStage] || data.currentStage || '(无)';
|
|
272
|
+
const lastActive = data.lastActive ? this._timeAgo(data.lastActive) : '未知';
|
|
273
|
+
console.log(` 📂 ${cn} — ${stageLabel} · 最近活跃 ${lastActive}`);
|
|
274
|
+
}
|
|
275
|
+
if (!opts.all && clean.length > shown.length) {
|
|
276
|
+
console.log(` … 另有 ${clean.length - shown.length} 个无信号活跃变更已折叠(progress show --change <名> 看单变更详情;全量单行列出加 --all)`);
|
|
277
|
+
} else if (clean.length > 0) {
|
|
278
|
+
console.log('');
|
|
279
|
+
}
|
|
280
|
+
|
|
252
281
|
// P2-2-① 补丁(2026-09-02 晚):全局未决冲突汇总段——逐变更行只显示「对上活跃变更」的冲突,
|
|
253
282
|
// 挂在 ghost/非活跃变更上的冲突会漏(huber 面板联调实证 11 条只显示 0)。此处按
|
|
254
283
|
// pending_conflicts 全量兜底显示,与 --json envelope 顶层字段同口径。
|
package/src/progress.js
CHANGED
|
@@ -1163,7 +1163,7 @@ export class ProgressManager {
|
|
|
1163
1163
|
|
|
1164
1164
|
completeStage(cwd, stage, changeName = null, opts = {}) { return this._stageMachine.completeStage(cwd, stage, changeName, opts); }
|
|
1165
1165
|
|
|
1166
|
-
show(cwd, changeName = null) { return this._stageMachine.show(cwd, changeName); }
|
|
1166
|
+
show(cwd, changeName = null, opts = {}) { return this._stageMachine.show(cwd, changeName, opts); }
|
|
1167
1167
|
|
|
1168
1168
|
// 只读全局总览(show 多变更汇总的机器版,2026-09-02 单一状态源):纯数据,machine-interface 包装
|
|
1169
1169
|
overview(cwd) { return this._stageMachine.overview(cwd); }
|
package/src/quicklog.js
CHANGED
|
@@ -10,13 +10,14 @@
|
|
|
10
10
|
* 本模块把分配与写入下沉到 CLI 进程内,O_EXCL lockfile 串行化,彻底消除上述问题。
|
|
11
11
|
* 无新 npm 依赖(仅 fs/path/crypto)——匹配项目零 FS 工具依赖的风格。
|
|
12
12
|
*/
|
|
13
|
-
import { join, dirname, basename } from 'path'
|
|
13
|
+
import { join, dirname, basename, resolve, relative, isAbsolute } from 'path'
|
|
14
14
|
import { getRule } from './stage-contract-spec.js'
|
|
15
15
|
import {
|
|
16
16
|
openSync, closeSync, unlinkSync, statSync, mkdirSync, existsSync,
|
|
17
17
|
readFileSync, writeFileSync, writeSync, readdirSync, renameSync, appendFileSync, rmSync,
|
|
18
18
|
} from 'fs'
|
|
19
19
|
import { randomBytes } from 'crypto'
|
|
20
|
+
import { safeGit } from './git-helper.js'
|
|
20
21
|
|
|
21
22
|
const sleep = (ms) => new Promise(r => setTimeout(r, ms))
|
|
22
23
|
|
|
@@ -1168,3 +1169,232 @@ export async function cancelQuickSession({ specBase, gitUser, qlId, sessionId =
|
|
|
1168
1169
|
|
|
1169
1170
|
return { ok: true, quicklogFile, removedTaskRows }
|
|
1170
1171
|
}
|
|
1172
|
+
|
|
1173
|
+
// ── quicklog commit(known-issues ④ v1:本会话条目切片提交,2026-09-16)──
|
|
1174
|
+
//
|
|
1175
|
+
// 坑 QUICKLOG 多会话条目交织:QUICKLOG 是多会话共享追加的单文件,某会话 `git add` 整文件
|
|
1176
|
+
// 会夹带并行会话未完成条目(违反显式 pathspec 隔离纪律,AGENTS.md 第 18 条),只能
|
|
1177
|
+
// 「备份 → 剥离并行条目 → commit pathspec → 恢复」四步舞(2026-09-15/16 单会话实证 6 次)。
|
|
1178
|
+
// 本命令把四步舞机制化:
|
|
1179
|
+
// 切片(工作区 = HEAD 基线 + 本会话条目块)→ git add/commit 显式 pathspec(含 patches
|
|
1180
|
+
// sidecar 与额外 pathspec)→ finally 恢复工作区全量(并行条目留「未提交」预期态)。
|
|
1181
|
+
// 任一步失败 fail-fast 打印人工兜底;恢复在 finally,commit 失败也不留半态。
|
|
1182
|
+
// 「完整文件化」(entries sidecar 权威 + 聚合渲染)不在 v1 范围,见 known-issues 条目。
|
|
1183
|
+
|
|
1184
|
+
// git 子操作退避重试:与 wt-commit.js gitWithLockRetry 同款(本文件锁只约束 quicklog 写方,
|
|
1185
|
+
// 锁队列外裸跑 git 的并行进程仍可能短暂持 index.lock/HEAD 锁,等待重试消化)。
|
|
1186
|
+
async function quicklogGitRetry(cwd, args, label, { retries = 6, waitMs = 1000 } = {}) {
|
|
1187
|
+
for (let i = 0; ; i++) {
|
|
1188
|
+
const { value, error } = safeGit(cwd, args, { timeout: 30000 })
|
|
1189
|
+
if (!error) return value
|
|
1190
|
+
const msg = String((error && error.message) || error)
|
|
1191
|
+
if (/index\.lock|HEAD\.lock|cannot lock ref/i.test(msg) && i < retries) {
|
|
1192
|
+
await sleep(waitMs)
|
|
1193
|
+
continue
|
|
1194
|
+
}
|
|
1195
|
+
throw new Error(`${label} 失败:${msg.split('\n')[0].trim()}`)
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
// 行级条目头匹配(容错口径与 countQuicklogEntries 一致:## 后空白可省、ID 与 | 间空白可变)
|
|
1200
|
+
function findQuicklogEntryLineIdx(lines, qlId) {
|
|
1201
|
+
const re = new RegExp(`^##[ \\t]*${escapeRe(qlId)}[ \\t]*\\|`)
|
|
1202
|
+
return lines.findIndex(l => re.test(l))
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
/**
|
|
1206
|
+
* 切片:工作区全量 → 「基线 + 指定条目块」。
|
|
1207
|
+
* 块界 = `## ql-<id>` 头到下一个 `## ` 头/文件尾(extractRawBlock 同口径);条目间补单空行
|
|
1208
|
+
* 分隔(对齐 allocateQuicklogEntry 条目模板形态)。EOL 随工作区文件——HEAD blob 归一到
|
|
1209
|
+
* 工作区 EOL 再拼(.gitattributes eol=lf + autocrlf=true 环境下 blob 是 LF、工作区 CRLF;
|
|
1210
|
+
* git add 时按配置归一回去,不产生整文件行尾假差异)。条目头已在基线(先前提交过)→
|
|
1211
|
+
* 跳过不重复(重跑幂等)。返回 null = 无任何目标条目;blocks 为空 = 全部已在基线
|
|
1212
|
+
* (调用方不落盘不动文件,避免基线归一化重写制造假提交)。
|
|
1213
|
+
*/
|
|
1214
|
+
function sliceQuicklogFileContent(fullContent, baselineContent, qlIds) {
|
|
1215
|
+
const eol = fullContent.includes('\r\n') ? '\r\n' : '\n'
|
|
1216
|
+
const norm = (s) => String(s || '').replace(/\r\n/g, '\n')
|
|
1217
|
+
const lines = norm(fullContent).split('\n')
|
|
1218
|
+
const baseLines = norm(baselineContent).split('\n')
|
|
1219
|
+
while (baseLines.length && baseLines[baseLines.length - 1].trim() === '') baseLines.pop()
|
|
1220
|
+
const blocks = []
|
|
1221
|
+
const blockIds = []
|
|
1222
|
+
const alreadyIncluded = []
|
|
1223
|
+
for (const qlId of qlIds) {
|
|
1224
|
+
const startIdx = findQuicklogEntryLineIdx(lines, qlId)
|
|
1225
|
+
if (startIdx === -1) continue
|
|
1226
|
+
if (findQuicklogEntryLineIdx(baseLines, qlId) !== -1) { alreadyIncluded.push(qlId); continue }
|
|
1227
|
+
let endIdx = lines.length
|
|
1228
|
+
for (let i = startIdx + 1; i < lines.length; i++) {
|
|
1229
|
+
if (/^##[ \t]*\S/.test(lines[i])) { endIdx = i; break }
|
|
1230
|
+
}
|
|
1231
|
+
const block = lines.slice(startIdx, endIdx)
|
|
1232
|
+
while (block.length && block[block.length - 1].trim() === '') block.pop()
|
|
1233
|
+
blocks.push(block)
|
|
1234
|
+
blockIds.push(qlId)
|
|
1235
|
+
}
|
|
1236
|
+
if (blocks.length === 0 && alreadyIncluded.length === 0) return null
|
|
1237
|
+
const outLines = [...baseLines]
|
|
1238
|
+
for (const block of blocks) {
|
|
1239
|
+
if (outLines.length > 0) outLines.push('') // 条目间空行分隔
|
|
1240
|
+
outLines.push(...block)
|
|
1241
|
+
}
|
|
1242
|
+
let sliced = outLines.join('\n') + '\n'
|
|
1243
|
+
if (eol === '\r\n') sliced = sliced.replace(/\n/g, '\r\n')
|
|
1244
|
+
return { content: sliced, blockIds, alreadyIncluded }
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
const QUICKLOG_COMMIT_MANUAL_FALLBACK = [
|
|
1248
|
+
'人工兜底(四步舞):',
|
|
1249
|
+
' ① 备份 QUICKLOG 文件:cp <文件> /tmp/quicklog-backup.md',
|
|
1250
|
+
' ② 从工作区文件剥离并行会话条目(只留本会话条目,条目块 = "## ql-…" 行到下一个 "## " 头)',
|
|
1251
|
+
' ③ git add -- <QUICKLOG文件> .sillyspec/quicklog/patches/<ql-id>.* && git commit -m "..."',
|
|
1252
|
+
' ④ 从备份恢复工作区全量(并行条目回到未提交态)',
|
|
1253
|
+
].join('\n')
|
|
1254
|
+
|
|
1255
|
+
/**
|
|
1256
|
+
* 一键收编本会话 QUICKLOG 条目(`sillyspec quicklog commit`,known-issues ④ v1)。
|
|
1257
|
+
*
|
|
1258
|
+
* 持用户 QUICKLOG 锁(与 allocate/complete/cancel 同锁)跨「定位 → 切片 → 提交 → 恢复」
|
|
1259
|
+
* 全程——锁内重读文件,并发追加的并行条目不丢(恢复用的就是锁内读到的最新全量)。
|
|
1260
|
+
*
|
|
1261
|
+
* @param {object} opts
|
|
1262
|
+
* @param {string} opts.specBase .sillyspec 根目录
|
|
1263
|
+
* @param {string} [opts.cwd] 调用方 cwd(git rev-parse --show-toplevel 的起点)
|
|
1264
|
+
* @param {string} opts.gitUser git user.name(定位本用户 QUICKLOG 文件与锁)
|
|
1265
|
+
* @param {string[]} [opts.qlIds] 显式 ql-ID(--ql,可含已取消条目——历史一并收编)
|
|
1266
|
+
* @param {string|null} [opts.changeName] quick 会话 ID(缺 --ql 时读其 guard.json 的 quicklogId)
|
|
1267
|
+
* @param {string|null} [opts.sessionsDir] quick-sessions 目录(平台模式异位时由 dispatch 传入)
|
|
1268
|
+
* @param {string} opts.message 提交信息
|
|
1269
|
+
* @param {string[]} [opts.extraPathspecs] 额外 pathspec(-- 后透传,随本次提交带上)
|
|
1270
|
+
* @returns {Promise<{ok:boolean,skipped:boolean,head:string,shortHead:string,files:string[],
|
|
1271
|
+
* quicklogFiles:string[],committedQlIds:string[],skippedQlIds:string[],backupPaths:string[]}>}
|
|
1272
|
+
*/
|
|
1273
|
+
export async function runQuicklogCommit({ specBase, cwd = process.cwd(), gitUser, qlIds = [], changeName = null, sessionsDir = null, message = '', extraPathspecs = [] }) {
|
|
1274
|
+
if (!message || !message.trim()) throw new Error('缺少 -m/--message <提交信息>(建议 "chore: 收编本会话 QUICKLOG 条目——<一句话>")')
|
|
1275
|
+
const ids = [...new Set((Array.isArray(qlIds) ? qlIds : []).filter(id => typeof id === 'string' && id.trim()).map(id => id.trim()))]
|
|
1276
|
+
|
|
1277
|
+
// ql-ID 解析优先级:--ql 显式 > 会话 guard.json(写侧字段 quicklogId,兼容旧 .qlId)
|
|
1278
|
+
if (ids.length === 0 && changeName) {
|
|
1279
|
+
const guardPath = join(sessionsDir || join(specBase, '.runtime', 'quick-sessions'), changeName, 'guard.json')
|
|
1280
|
+
try {
|
|
1281
|
+
if (existsSync(guardPath)) {
|
|
1282
|
+
const g = JSON.parse(readFileSync(guardPath, 'utf8'))
|
|
1283
|
+
const q = g.quicklogId || g.qlId
|
|
1284
|
+
if (typeof q === 'string' && q.trim()) ids.push(q.trim())
|
|
1285
|
+
}
|
|
1286
|
+
} catch { /* guard 损坏 → 按缺失走下方 fail-fast */ }
|
|
1287
|
+
}
|
|
1288
|
+
if (ids.length === 0) {
|
|
1289
|
+
throw new Error(`无法定位 ql-ID:--change 会话的 guard.json 缺失/损坏且未传 --ql——显式传 --ql <ql-xxx>(可重复传多个;含已取消条目,cancelled 也是历史一并收编;ID 即 QUICKLOG 条目头 "## ql-…" 的 ID)`)
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
const quicklogDir = join(specBase, 'quicklog')
|
|
1293
|
+
const user = sanitizeQuicklogUser(gitUser) || 'unknown'
|
|
1294
|
+
const lockPath = join(quicklogDir, `.QUICKLOG-${user}.md.lock`)
|
|
1295
|
+
const repoRootRaw = safeGit(cwd, ['rev-parse', '--show-toplevel'], { timeout: 10000 }).value
|
|
1296
|
+
if (!repoRootRaw) throw new Error(`git 仓库定位失败(cwd=${cwd} 的 git rev-parse --show-toplevel 无输出)`)
|
|
1297
|
+
const repoRoot = resolve(repoRootRaw)
|
|
1298
|
+
const toRel = (abs) => relative(repoRoot, abs).replace(/\\/g, '/')
|
|
1299
|
+
|
|
1300
|
+
const result = await withFileLock(lockPath, async () => {
|
|
1301
|
+
// 锁内重扫(并发追加不丢):主文件优先,恒扫全部 QUICKLOG 文件(含轮转归档——
|
|
1302
|
+
// 2026-09-16 实证 ql-013 落轮转新文件、ql-014 又落回旧文件,新旧都可能有本会话条目)
|
|
1303
|
+
const allFiles = listQuicklogFiles(quicklogDir)
|
|
1304
|
+
const mainName = `QUICKLOG-${user}.md`
|
|
1305
|
+
const ordered = existsSync(join(quicklogDir, mainName))
|
|
1306
|
+
? [mainName, ...allFiles.filter(f => f !== mainName)] : allFiles
|
|
1307
|
+
const plans = []
|
|
1308
|
+
const found = new Set()
|
|
1309
|
+
for (const f of ordered) {
|
|
1310
|
+
const p = join(quicklogDir, f)
|
|
1311
|
+
let content = ''
|
|
1312
|
+
try { content = readFileSync(p, 'utf8') } catch { continue }
|
|
1313
|
+
const contentLines = content.split(/\r?\n/)
|
|
1314
|
+
const hit = ids.filter(id => findQuicklogEntryLineIdx(contentLines, id) !== -1)
|
|
1315
|
+
for (const id of hit) found.add(id)
|
|
1316
|
+
if (hit.length > 0) plans.push({ file: p, rel: toRel(p), content, hitIds: hit })
|
|
1317
|
+
}
|
|
1318
|
+
// 工作区未命中者查 HEAD 基线:已在 = 幂等跳过;两边都无 = fail-fast(--ql 笔误防护)
|
|
1319
|
+
const alreadyCommitted = []
|
|
1320
|
+
for (const id of ids.filter(i => !found.has(i))) {
|
|
1321
|
+
let inHead = false
|
|
1322
|
+
for (const f of ordered) {
|
|
1323
|
+
const blob = safeGit(repoRoot, ['show', `HEAD:${toRel(join(quicklogDir, f))}`], { trim: false, timeout: 10000 }).value
|
|
1324
|
+
if (blob && findQuicklogEntryLineIdx(blob.replace(/\r\n/g, '\n').split('\n'), id) !== -1) { inHead = true; break }
|
|
1325
|
+
}
|
|
1326
|
+
if (inHead) alreadyCommitted.push(id)
|
|
1327
|
+
else throw new Error(`条目 ${id} 在所有 QUICKLOG-*.md(含轮转归档)与 HEAD 中均未找到——核对 --ql 的 ql-ID。\n${QUICKLOG_COMMIT_MANUAL_FALLBACK}`)
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
// 切片 + 备份(.runtime 不入库,恢复失败时的最后兜底)。无新块的文件不动不落盘——
|
|
1331
|
+
// 已收编条目重跑形态下按基线归一化重写会制造只差尾部空行的假提交。
|
|
1332
|
+
const now = new Date()
|
|
1333
|
+
const stamp = `${todayStamp(now)}-${String(now.getHours()).padStart(2, '0')}${String(now.getMinutes()).padStart(2, '0')}${String(now.getSeconds()).padStart(2, '0')}`
|
|
1334
|
+
mkdirSync(join(specBase, '.runtime'), { recursive: true })
|
|
1335
|
+
const backups = []
|
|
1336
|
+
const originals = []
|
|
1337
|
+
const slicePlans = []
|
|
1338
|
+
for (const plan of plans) {
|
|
1339
|
+
const baseline = safeGit(repoRoot, ['show', `HEAD:${plan.rel}`], { trim: false, timeout: 10000 }).value || ''
|
|
1340
|
+
const sliced = sliceQuicklogFileContent(plan.content, baseline, plan.hitIds)
|
|
1341
|
+
if (!sliced) continue // 不可能(plan.hitIds 非空才进 plans),防御
|
|
1342
|
+
for (const id of sliced.alreadyIncluded) alreadyCommitted.push(id)
|
|
1343
|
+
if (sliced.blockIds.length === 0) continue // 全部已在基线:文件不动、不进提交
|
|
1344
|
+
const backupPath = join(specBase, '.runtime', `quicklog-slice-${stamp}-${basename(plan.file)}`)
|
|
1345
|
+
writeFileSync(backupPath, plan.content)
|
|
1346
|
+
backups.push(backupPath)
|
|
1347
|
+
originals.push({ file: plan.file, content: plan.content })
|
|
1348
|
+
await writeAtomic(plan.file, sliced.content)
|
|
1349
|
+
slicePlans.push(plan)
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
// pathspec 组装:QUICKLOG 文件 + patches sidecar(--done 冻结件,存在才加)+ 额外透传
|
|
1353
|
+
const addPaths = slicePlans.map(pl => pl.rel)
|
|
1354
|
+
for (const id of ids) {
|
|
1355
|
+
for (const ext of ['.json', '.patch']) {
|
|
1356
|
+
const sp = join(quicklogDir, 'patches', id + ext)
|
|
1357
|
+
if (existsSync(sp)) addPaths.push(toRel(sp))
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
for (const extra of (Array.isArray(extraPathspecs) ? extraPathspecs : []).filter(Boolean)) {
|
|
1361
|
+
addPaths.push(toRel(isAbsolute(extra) ? extra : resolve(cwd, extra)))
|
|
1362
|
+
}
|
|
1363
|
+
if (addPaths.length === 0) {
|
|
1364
|
+
// 条目均已收编且无 sidecar/额外 pathspec → 无事可做(幂等重跑)
|
|
1365
|
+
return { skipped: true, head: safeGit(repoRoot, ['rev-parse', 'HEAD'], { timeout: 10000 }).value || '', addPaths: [], backups, alreadyCommitted, quicklogFiles: slicePlans.map(pl => basename(pl.file)) }
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
try {
|
|
1369
|
+
await quicklogGitRetry(repoRoot, ['add', '--', ...addPaths], 'git add')
|
|
1370
|
+
// pathspec 范围内无 staged 变更 = 条目均已收编(幂等重跑),HEAD 不动不算失败
|
|
1371
|
+
const staged = safeGit(repoRoot, ['diff', '--cached', '--name-only', '--', ...addPaths], { timeout: 10000 }).value || ''
|
|
1372
|
+
if (!staged.trim()) {
|
|
1373
|
+
return { skipped: true, head: safeGit(repoRoot, ['rev-parse', 'HEAD'], { timeout: 10000 }).value || '', addPaths, backups, alreadyCommitted, quicklogFiles: slicePlans.map(pl => basename(pl.file)) }
|
|
1374
|
+
}
|
|
1375
|
+
await quicklogGitRetry(repoRoot, ['commit', '-m', message, '--', ...addPaths], 'git commit')
|
|
1376
|
+
const head = safeGit(repoRoot, ['rev-parse', 'HEAD'], { timeout: 10000 }).value || ''
|
|
1377
|
+
return { skipped: false, head, addPaths, backups, alreadyCommitted, quicklogFiles: slicePlans.map(pl => basename(pl.file)) }
|
|
1378
|
+
} finally {
|
|
1379
|
+
// 恢复:切片前工作区全量写回(并行会话条目回到「未提交」预期态;原子写防轮询读半截)
|
|
1380
|
+
for (const o of originals) await writeAtomic(o.file, o.content)
|
|
1381
|
+
}
|
|
1382
|
+
}, {
|
|
1383
|
+
staleMs: 120000, // 临界区含 git add/commit(AV 扫描的 Windows 上秒级起步),30s 默认会被误偷锁
|
|
1384
|
+
timeoutMs: 120000,
|
|
1385
|
+
content: JSON.stringify({ pid: process.pid, purpose: 'quicklog-commit' }),
|
|
1386
|
+
})
|
|
1387
|
+
|
|
1388
|
+
const already = [...new Set(result.alreadyCommitted)]
|
|
1389
|
+
return {
|
|
1390
|
+
ok: true,
|
|
1391
|
+
skipped: !!result.skipped,
|
|
1392
|
+
head: result.head,
|
|
1393
|
+
shortHead: (result.head || '').slice(0, 8),
|
|
1394
|
+
files: result.addPaths,
|
|
1395
|
+
quicklogFiles: result.quicklogFiles,
|
|
1396
|
+
committedQlIds: ids.filter(id => !already.includes(id)),
|
|
1397
|
+
skippedQlIds: already,
|
|
1398
|
+
backupPaths: result.backups,
|
|
1399
|
+
}
|
|
1400
|
+
}
|