sillyspec 3.14.1 → 3.15.2

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.
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  author: qinyi
3
3
  created_at: 2026-05-31 11:00:00
4
- updated_at: 2026-05-31 21:35:00
4
+ updated_at: 2026-06-03 16:00:00
5
5
  ---
6
6
 
7
7
  # SillySpec 文件生命周期描述
@@ -20,9 +20,11 @@ updated_at: 2026-05-31 21:35:00
20
20
  │ ├── history/ ← 已完成阶段的历史快照
21
21
  │ ├── logs/ ← 日志
22
22
  │ ├── templates/ ← 模板
23
+ │ ├── workflow-runs/ ← workflow check 结果归档(JSON)
24
+ │ │ └── <timestamp>-<workflow>-<project>-<status>.json
23
25
  │ └── worktrees/ ← git worktree 隔离环境
24
26
  │ └── <change-name>/
25
- │ └── meta.json ← worktree 元数据
27
+ │ └── meta.json ← worktree 元数据(含 baselineCommit/baselineHash)
26
28
  ├── changes/ ← 变更工作区(git tracked)
27
29
  │ ├── <change-name>/ ← 活跃变更目录
28
30
  │ │ ├── proposal.md ← 动机与变更范围
@@ -59,6 +61,9 @@ updated_at: 2026-05-31 21:35:00
59
61
  ├── knowledge/ ← 跨项目共享知识库
60
62
  │ ├── INDEX.md ← 知识索引(关键词匹配)
61
63
  │ └── uncategorized.md ← 待分类知识
64
+ ├── workflows/ ← workflow 定义(init 自动生成,可自定义)
65
+ │ ├── scan-docs.yaml ← scan 阶段产物检查 workflow
66
+ │ └── archive-impact.yaml ← archive 阶段影响分析 workflow
62
67
  ├── shared/ ← 共享目录
63
68
  ├── workspace/ ← 工作区
64
69
  ├── ROADMAP.md ← 路线图(可选)
@@ -529,6 +534,8 @@ PASS / PASS WITH NOTES / FAIL
529
534
 
530
535
  **断点续扫:** "检查已有扫描文档和子项目列表"步骤检查已有文档,**必须停下来问用户**选择「全部重新扫描」或「只补缺失文档」,不能自行决定跳过。
531
536
 
537
+ **Workflow 检查(post_check):** scan 阶段完成后(verify 步骤),`run.js` 自动加载 `workflows/scan-docs.yaml`,对每个项目的 7 份文档执行结构化产物校验。检查结果为结构化 JSON 对象,包含 `roles[].outputs[].checks[]` 和 `failures[]`。校验规则定义在 `scan-docs.yaml` 的各 role output checks 中(file_exists / min_lines / contains_sections / no_placeholder 等)。检查失败时自动生成 `retry_prompts`,精确到角色级别。
538
+
532
539
  **生命周期:** scan 阶段生成 → 被后续所有阶段(brainstorm/propose/plan/execute/verify)作为上下文读取
533
540
 
534
541
  ---
@@ -755,6 +762,51 @@ test_strategy: module
755
762
 
756
763
  ---
757
764
 
765
+ ### `workflow-runs/` — Workflow Check 结果归档
766
+
767
+ **创建时机:**
768
+ - `run.js` 在 scan/verify 步骤和 archive extract-module-impact 步骤执行 `runPostCheck()` 后自动归档
769
+ - CLI `sillyspec workflow check ... --save` 手动触发
770
+
771
+ **存储位置:** `.sillyspec/.runtime/workflow-runs/`
772
+
773
+ **命名格式:** `<YYYYMMDDHHmmss>-<workflow>-<project>-<status>.json`
774
+
775
+ **示例:** `20260603035731-scan-docs-dashboard-pass.json`
776
+
777
+ **内容结构(结构化结果对象):**
778
+ ```json
779
+ {
780
+ "run_id": "20260603035731-scan-docs-dashboard",
781
+ "created_at": "2026-06-03T03:57:31.000Z",
782
+ "source": "run.js",
783
+ "stage": "verify",
784
+ "workflow": "scan-docs",
785
+ "project": "dashboard",
786
+ "status": "pass",
787
+ "spec_version": 1,
788
+ "roles": [
789
+ { "id": "arch", "name": "技术架构", "status": "pass", "outputs": [...] }
790
+ ],
791
+ "workflow_checks": [...],
792
+ "failures": [],
793
+ "retry_prompts": []
794
+ }
795
+ ```
796
+
797
+ **写入方:**
798
+ - `run.js` — scan/verify 和 archive post_check 后自动调用 `saveWorkflowRun()`
799
+ - `index.js` — CLI `--save` 时调用 `saveWorkflowRun()`
800
+
801
+ **读取方:** 人工查阅 / 后续查询命令(待实现)
802
+
803
+ **注意:**
804
+ - 保存失败只输出 warning,不影响 workflow check 的 exit code
805
+ - CLI `--json --save` 时 stdout 仍为纯净 JSON,保存提示不混入
806
+ - 文件路径在 `.gitignore` 中(`.sillyspec/.runtime/`)
807
+
808
+ ---
809
+
758
810
  ## 8. 文件销毁与归档
759
811
 
760
812
  ### 变更归档流程
@@ -978,17 +1030,19 @@ graph LR
978
1030
 
979
1031
  **大体结构:**
980
1032
  ```markdown
981
- ## 2026-05-28 14:30:00 — 修复用户登录超时问题
1033
+ ## ql-20260603-001-a3f2 | 2026-06-03 14:30:00 — 修复用户登录超时问题
982
1034
  状态:进行中
983
1035
  文件:src/auth/login.js, src/config/timeout.yaml
984
1036
 
985
- ## 2026-05-28 15:00:00 — 修复用户登录超时问题
1037
+ ## ql-20260603-002-7b1c | 2026-06-03 15:00:00 — 修复用户登录超时问题
986
1038
  状态:已完成
987
1039
  文件:src/auth/login.js
988
1040
  结果:调整超时配置为 30s,新增重试逻辑
989
1041
  ```
990
1042
 
991
- **生命周期:** quick 步骤 1 创建("进行中")→ 步骤 5 更新为"已完成"并补充实际改动。超过 500 行轮转重命名为 `QUICKLOG-<USER>-YYYY-MM-DD.md`。
1043
+ **ID 规则:** 每条记录以 `ql-YYYYMMDD-NNN-XXXX` 开头作为唯一 ID。`XXXX` 为 4 位随机十六进制,防止多文件或并发冲突。追加前扫描文件中已有 `ql-<当天日期>-` 前缀的最大序号 +1,每天从 001 重新开始。此 ID 可被 design.md / plan.md / archive / 模块变更索引引用。
1044
+
1045
+ **生命周期:** quick 步骤 1 创建("进行中")→ 步骤 5 按 ql-ID 找到条目更新为"已完成"并补充实际改动。超过 500 行轮转重命名为 `QUICKLOG-<USER>-YYYY-MM-DD.md`(日期取最后一条记录),新文件需扫描同目录所有 QUICKLOG 文件中当天最大序号 +1 以继承 ql-ID。
992
1046
 
993
1047
  ---
994
1048
 
@@ -1039,6 +1093,7 @@ graph LR
1039
1093
  | `modules/_module-map.yaml` | scan 可选步骤 | scan | archive/plan/execute |
1040
1094
  | `modules/<module>.md` | scan 可选步骤(全量生成)+ archive sync-module-docs | scan/archive | propose/plan/execute/verify/quick |
1041
1095
  | `verify-result.md` | verify 阶段输出 | verify | 验证报告存档 |
1096
+ | `workflows/*.yaml` | init 自动生成(scan-docs.yaml, archive-impact.yaml) | init | run.js post_check + CLI workflow check |
1042
1097
 
1043
1098
 
1044
1099
  ```
@@ -1048,6 +1103,7 @@ sillyspec init
1048
1103
  ├─→ .sillyspec/.runtime/user-inputs.md
1049
1104
  ├─→ .sillyspec/projects/<name>.yaml
1050
1105
  ├─→ .sillyspec/docs/<name>/scan/ (骨架)
1106
+ ├─→ .sillyspec/workflows/ (scan-docs.yaml, archive-impact.yaml 模板)
1051
1107
  ├─→ .sillyspec/knowledge/
1052
1108
  └─→ .gitignore (追加 .sillyspec/.runtime/)
1053
1109
 
@@ -1064,6 +1120,9 @@ scan 阶段(12 步,完成后重置)
1064
1120
  ├─→ docs/<name>/modules/<module>.md (可选,需用户确认)
1065
1121
  └─→ .sillyspec/local.yaml
1066
1122
 
1123
+ [scan 完成后 run.js 自动执行 workflow check]
1124
+ └─→ .sillyspec/.runtime/workflow-runs/<ts>-scan-docs-<project>-<status>.json
1125
+
1067
1126
  brainstorm 阶段
1068
1127
 
1069
1128
  ├─→ sillyspec.db: changes/stages 记录自动创建
@@ -1112,4 +1171,7 @@ archive 阶段
1112
1171
  ├─→ changes/<name>/module-impact.md
1113
1172
  ├─→ docs/<name>/modules/<module>.md (通过 module-impact.md 同步)
1114
1173
  └─→ changes/<name>/ → changes/archive/YYYY-MM-DD-<name>/
1174
+
1175
+ [archive extract-module-impact 后 run.js 自动执行 workflow check]
1176
+ └─→ .sillyspec/.runtime/workflow-runs/<ts>-archive-impact-<project>-<status>.json
1115
1177
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sillyspec",
3
- "version": "3.14.1",
3
+ "version": "3.15.2",
4
4
  "description": "SillySpec CLI — 流程状态机,让 AI 严格按步骤来",
5
5
  "icon": "logo.jpg",
6
6
  "homepage": "https://sillyspec.ppdmq.top/",
package/src/run.js CHANGED
@@ -10,6 +10,7 @@ import { ProgressManager } from './progress.js'
10
10
  import { stageRegistry, auxiliaryStages } from './stages/index.js'
11
11
  import { buildExecuteSteps } from './stages/execute.js'
12
12
  import { buildPlanSteps } from './stages/plan.js'
13
+ import { formatExecuteSummary } from './worktree-apply.js'
13
14
 
14
15
  /**
15
16
  * 同步触发辅助函数:_write 后 best-effort 同步到平台
@@ -745,7 +746,21 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
745
746
  console.log(`✅ ${stageName} 阶段已完成(${total}/${total} 步)`)
746
747
 
747
748
  if (stageName === 'execute') {
748
- console.log('\n👉 下一步:sillyspec run verify(验证通过后才能归档)')
749
+ // execute run summary:展示真实可得的结构化信息
750
+ try {
751
+ const lastOutput = steps[steps.length - 1]?.output || ''
752
+ const summary = formatExecuteSummary({
753
+ changeName,
754
+ stepsCompleted: total,
755
+ stepsTotal: total,
756
+ agentSummary: lastOutput,
757
+ cwd,
758
+ })
759
+ console.log(`\n${summary}`)
760
+ } catch (e) {
761
+ // summary 失败不影响主流程
762
+ console.log('\n👉 下一步:sillyspec run verify(验证通过后才能归档)')
763
+ }
749
764
  } else if (stageName === 'verify') {
750
765
  console.log('\n👉 下一步:sillyspec run archive(验证通过,可以归档了)')
751
766
  } else if (stageName === 'archive') {
@@ -300,6 +300,9 @@ function buildWavePrompt(wave, waveIndex, changeDir, worktreePath) {
300
300
  \`${worktreePath}\`
301
301
 
302
302
  不要在主工作区修改源码文件。所有代码变更只在 worktree 中进行。
303
+
304
+ ### 注意
305
+ 蓝图文件(tasks.md / design.md / proposal.md / requirements.md)在主工作区 .sillyspec/changes/<change>/ 下,它们可能不在 worktree 中。读取蓝图时使用主工作区路径,不要拼接到 worktree 路径下。
303
306
  子代理的 cwd 参数设为 \`${worktreePath}\`。
304
307
  `
305
308
  : ''
@@ -27,10 +27,15 @@ export const definition = {
27
27
  1. 使用预注入的 git 用户名:\`<git-user>\`
28
28
  2. 无 \`--change\`:创建 .sillyspec/quicklog/QUICKLOG-\`<git-user>\`.md\`(已存在则追加),写入:
29
29
  \`\`\`
30
- ## <now-datetime> <一句话任务描述>
30
+ ## ql-<YYYYMMDD>-<NNN>-<short4> | <now-datetime> | <一句话任务描述>
31
31
  状态:进行中
32
32
  文件:<预估要改的文件>
33
33
  \`\`\`
34
+ - ID 格式:\`ql-YYYYMMDD-NNN-XXXX\`(如 ql-20260603-001-a3f2)
35
+ - \`XXXX\` 是 4 位随机十六进制(防多文件/并发冲突)
36
+ - 追加前扫描文件中已有的 \`ql-<当天日期>-\` 前缀的最大序号,+1 作为新序号
37
+ - 每天从 001 开始,跨日重新计数
38
+ - 此 ID 可被 design.md / plan.md / archive / module 变更索引引用
34
39
  3. 有 \`--change\`:在 \`.sillyspec/changes/<change-name>/tasks.md\` 追加未勾选的 task
35
40
 
36
41
  这样 Gate 检测到 .sillyspec/\` 下有变更,就不会拦截后续的代码修改。
@@ -67,9 +72,9 @@ export const definition = {
67
72
  ### 操作
68
73
  1. \`git add -A\` — 暂存改动文件(不要 commit,由用户通过统一提交工具处理)
69
74
  2. 更新 Step 1 创建的记录:
70
- - 无 \`--change\`:更新 QUICKLOG 条目,将「状态:进行中」改为「状态:已完成」,补充实际改动文件和结果摘要
75
+ - 无 \`--change\`:找到对应 ql-ID 的条目,将「状态:进行中」改为「状态:已完成」,补充实际改动文件和结果摘要
71
76
  - 有 \`--change\`:勾选 tasks.md 中对应的 task checkbox
72
- 3. QUICKLOG 轮转:超过 500 行则重命名为 \`QUICKLOG-<USER>-YYYY-MM-DD.md\`
77
+ 3. QUICKLOG 轮转:超过 500 行则重命名为 \`QUICKLOG-<USER>-YYYY-MM-DD.md\`(日期取最后一条记录的日期)。新文件从空开始,ql-ID 需扫描同目录所有 QUICKLOG 文件中当天最大序号 +1
73
78
  4. 如果发现项目特有的坑,追加到 \`.sillyspec/knowledge/uncategorized.md\`
74
79
  5. 任务比预期复杂 → 建议用完整流程
75
80
 
@@ -78,7 +83,8 @@ export const definition = {
78
83
  7. 对比本次修改的文件(\`git diff --name-only\`)与模块映射
79
84
  8. 如果命中模块 → 直接同步模块文档:
80
85
  - 读取对应的 \`.sillyspec/docs/<project>/modules/<module>.md\`(如不存在则新建)
81
- - 根据本次改动内容更新模块文档(正文描述当前状态,底部追加变更索引)
86
+ - 根据本次改动内容更新模块文档(正文描述当前状态,底部变更索引追加本次 ql-ID)
87
+ - 变更索引格式:\`- ql-YYYYMMDD-NNN-XXXX | <一句话描述>\`
82
88
  - 写入模块文档
83
89
  - 将更新的模块文件加入 \`git add\`
84
90
  9. 未命中任何模块 → 跳过,不做额外操作
@@ -16,6 +16,7 @@ import { execSync } from 'child_process';
16
16
  import { existsSync, unlinkSync, writeFileSync, mkdtempSync, rmSync } from 'fs';
17
17
  import { join, resolve } from 'path';
18
18
  import { tmpdir } from 'os';
19
+ import { createHash } from 'crypto';
19
20
  import { WorktreeManager } from './worktree.js';
20
21
  import { parseFileChangeList } from './change-list.js';
21
22
 
@@ -77,7 +78,9 @@ export function applyWorktree(changeName, { cwd, checkOnly = false } = {}) {
77
78
  return result;
78
79
  }
79
80
 
80
- const { worktreePath, baseHash } = meta;
81
+ const { worktreePath, baseHash, baselineCommit } = meta;
82
+ // diff 起始点:有 baseline checkpoint 用它(只合子代理改动),否则 fallback 到 baseHash
83
+ const diffBase = baselineCommit || baseHash;
81
84
 
82
85
  if (!existsSync(worktreePath)) {
83
86
  result.errors.push(`worktree 目录不存在: ${worktreePath}`);
@@ -89,17 +92,25 @@ export function applyWorktree(changeName, { cwd, checkOnly = false } = {}) {
89
92
  // 同时检测 untracked 新文件(git diff 不包含 untracked)
90
93
  let changedFiles;
91
94
  try {
92
- // tracked 文件的变更(modified/deleted)
93
- const trackedRaw = git(worktreePath, `diff --name-only ${baseHash}`);
94
- const trackedFiles = trackedRaw ? trackedRaw.split('\n').filter(Boolean) : [];
95
+ // --name-status 捕获 rename/delete(--name-only 会丢失 rename 源文件)
96
+ const statusRaw = git(worktreePath, `diff --name-status ${diffBase}`);
97
+ const statusFiles = new Set();
98
+ if (statusRaw) {
99
+ for (const line of statusRaw.split('\n').filter(Boolean)) {
100
+ const parts = line.split('\t');
101
+ // R100 old.txt new.txt → 提取两个文件
102
+ if (parts.length >= 2) statusFiles.add(parts[parts.length - 1]);
103
+ if (parts.length >= 3) statusFiles.add(parts[parts.length - 2]);
104
+ }
105
+ }
95
106
 
96
- // untracked 新文件(baseHash 中不存在的文件)
107
+ // untracked 新文件(diffBase 中不存在的文件)
97
108
  const untrackedRaw = gitQuiet(worktreePath, `ls-files --others --exclude-standard`);
98
109
  const untrackedFiles = untrackedRaw
99
110
  ? untrackedRaw.split('\n').filter(Boolean).filter(f => !f.startsWith('.sillyspec/') && f !== 'meta.json')
100
111
  : [];
101
112
 
102
- changedFiles = [...new Set([...trackedFiles, ...untrackedFiles])];
113
+ changedFiles = [...new Set([...statusFiles, ...untrackedFiles])];
103
114
  } catch (e) {
104
115
  result.errors.push(`获取变更文件列表失败: ${e.message}`);
105
116
  return result;
@@ -136,6 +147,24 @@ export function applyWorktree(changeName, { cwd, checkOnly = false } = {}) {
136
147
  }
137
148
  }
138
149
 
150
+ // --- 4.5 校验:主工作区 baseline 是否变化(防 execute 期间主工作区被修改)---
151
+ if (meta.baselineHash) {
152
+ const staged = gitQuiet(projectRoot, 'diff --cached') || '';
153
+ const unstaged = gitQuiet(projectRoot, 'diff') || '';
154
+ const untracked = gitQuiet(projectRoot, 'ls-files --others --exclude-standard') || '';
155
+ const raw = `staged:${staged}\nunstaged:${unstaged}\nuntracked:${untracked}`;
156
+ const currentHash = createHash('sha256').update(raw).digest('hex').slice(0, 16);
157
+ if (currentHash !== meta.baselineHash) {
158
+ result.errors.push(
159
+ `主工作区 baseline 已变化(execute 前后不一致),不能直接 apply task.patch。\n` +
160
+ `建议:重新创建 worktree 或手动检查冲突。\n` +
161
+ `execute 前 baseline: ${meta.baselineHash}\n` +
162
+ `当前 baseline: ${currentHash}`
163
+ );
164
+ return result;
165
+ }
166
+ }
167
+
139
168
  // --- 5. 校验:主工作区文件 base hash 一致 ---
140
169
  // 5a. 检查主工作区是否有未 commit 的脏文件(会影响 apply)
141
170
  const mainDirtyRaw = gitQuiet(projectRoot, 'diff --name-only HEAD');
@@ -195,8 +224,11 @@ export function applyWorktree(changeName, { cwd, checkOnly = false } = {}) {
195
224
 
196
225
  // 分 tracked 变更和 untracked 新文件生成 patch
197
226
  const trackedFiles = patchFiles.filter(f => {
198
- // untracked 文件在 baseHash 的 tree 中不存在
199
- return gitQuiet(worktreePath, `cat-file -e ${baseHash}:${f}`) !== null;
227
+ // 文件在 diffBase 的 tree 中存在 → tracked(包括 rename 目标可能的情况)
228
+ if (gitQuiet(worktreePath, `cat-file -e ${diffBase}:${f}`) !== null) return true;
229
+ // 文件在工作区 index 中已存在(比如被 git mv 处理过)→ 也视为 tracked
230
+ if (gitQuiet(worktreePath, `ls-files --error-unmatch ${f}`) !== null) return true;
231
+ return false;
200
232
  });
201
233
  const untrackedPatchFiles = patchFiles.filter(f => !trackedFiles.includes(f));
202
234
 
@@ -204,7 +236,7 @@ export function applyWorktree(changeName, { cwd, checkOnly = false } = {}) {
204
236
  if (trackedFiles.length > 0) {
205
237
  const trackedArgs = trackedFiles.map(f => `-- ${f}`).join(' ');
206
238
  patchContent += execSync(
207
- `git diff --binary ${baseHash} ${trackedArgs}`,
239
+ `git diff --binary ${diffBase} ${trackedArgs}`,
208
240
  { cwd: worktreePath, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }
209
241
  );
210
242
  }
@@ -269,3 +301,91 @@ export function applyWorktree(changeName, { cwd, checkOnly = false } = {}) {
269
301
 
270
302
  return result;
271
303
  }
304
+
305
+ /**
306
+ * 格式化 execute run summary(人类可读)
307
+ *
308
+ * 只展示 CLI 真实掌握的信息,不声称知道 per-task 状态。
309
+ * @param {object} opts
310
+ * @param {string} opts.changeName - 变更名
311
+ * @param {number} opts.stepsCompleted - 已完成步骤数
312
+ * @param {number} opts.stepsTotal - 总步骤数
313
+ * @param {string} opts.agentSummary - Agent 最终输出摘要
314
+ * @param {string} [opts.cwd] - 项目根目录(默认 process.cwd())
315
+ * @returns {string} 格式化的 summary 文本
316
+ */
317
+ export function formatExecuteSummary({ changeName, stepsCompleted, stepsTotal, agentSummary, cwd }) {
318
+ const wm = new WorktreeManager({ cwd });
319
+ const meta = wm.getMeta(changeName);
320
+ const lines = [];
321
+
322
+ const SEPARATOR = '─'.repeat(32);
323
+
324
+ // --- Header ---
325
+ lines.push(`Execute Summary`);
326
+ lines.push(SEPARATOR);
327
+
328
+ // --- Status ---
329
+ if (!meta) {
330
+ // worktree 不存在(可能已 cleanup 或没有用过 worktree)
331
+ lines.push(`Status: COMPLETED`);
332
+ lines.push(`Steps: ${stepsCompleted} / ${stepsTotal}`);
333
+ lines.push(`Apply: N/A`);
334
+ } else {
335
+ const hasBaseline = meta.baselineCommit != null;
336
+ const wtExists = existsSync(meta.worktreePath);
337
+
338
+ const applyStatus = wtExists ? 'pending' : 'applied';
339
+ const baselineCount = meta.baselineFiles?.length || 0;
340
+ const baselineStatus = hasBaseline
341
+ ? `dirty (${baselineCount} baseline file${baselineCount === 1 ? '' : 's'} protected)`
342
+ : 'clean';
343
+
344
+ lines.push(`Status: COMPLETED`);
345
+ lines.push(`Steps: ${stepsCompleted} / ${stepsTotal}`);
346
+ lines.push(`Baseline: ${baselineStatus}`);
347
+ lines.push(`Apply: ${applyStatus}`);
348
+ }
349
+
350
+ // --- Changed files ---
351
+ // 从主工作区 diff 获取(worktree 已 apply)或从 worktree diff 获取
352
+ if (meta && existsSync(meta.worktreePath)) {
353
+ // worktree 还在,用 baselineCommit 或 baseHash 做 diff
354
+ try {
355
+ const diffBase = meta.baselineCommit || meta.baseHash;
356
+ const { execSync: es } = require('child_process');
357
+ const filesRaw = es(`git -C ${meta.worktreePath} diff --name-only ${diffBase} 2>/dev/null`, { encoding: 'utf8' });
358
+ const files = filesRaw ? filesRaw.trim().split('\n').filter(Boolean) : [];
359
+ if (files.length > 0) {
360
+ lines.push(``);
361
+ const maxShow = 10;
362
+ const showFiles = files.slice(0, maxShow);
363
+ const remain = files.length - maxShow;
364
+ lines.push(`Changed Files (${files.length})`);
365
+ showFiles.forEach(f => lines.push(` ${f}`));
366
+ if (remain > 0) {
367
+ lines.push(` ... ${remain} more`);
368
+ }
369
+ }
370
+ } catch {}
371
+ }
372
+
373
+ // --- Agent Summary ---
374
+ if (agentSummary) {
375
+ lines.push(``);
376
+ lines.push(`Agent Summary`);
377
+ // 缩进每行,截断过长内容
378
+ const maxLen = 200;
379
+ const summary = agentSummary.length > maxLen
380
+ ? agentSummary.slice(0, maxLen) + '...'
381
+ : agentSummary;
382
+ summary.split('\n').forEach(l => lines.push(` ${l}`));
383
+ }
384
+
385
+ // --- Next ---
386
+ lines.push(``);
387
+ lines.push(`Next`);
388
+ lines.push(` → sillyspec run verify`);
389
+
390
+ return lines.join('\n');
391
+ }
package/src/worktree.js CHANGED
@@ -10,7 +10,8 @@
10
10
 
11
11
  import { execSync } from 'child_process';
12
12
  import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync, readdirSync } from 'fs';
13
- import { join, resolve } from 'path';
13
+ import { join, resolve, dirname } from 'path';
14
+ import { createHash } from 'crypto';
14
15
 
15
16
  const WORKTREES_REL = '.sillyspec/.runtime/worktrees';
16
17
  const BRANCH_PREFIX = 'sillyspec/';
@@ -32,6 +33,16 @@ function parseJSON(raw) {
32
33
  try { return JSON.parse(raw); } catch { return null; }
33
34
  }
34
35
 
36
+ function computeBaselineHash(cwd) {
37
+ const staged = gitQuiet(cwd, 'diff --cached') || '';
38
+ const unstaged = gitQuiet(cwd, 'diff') || '';
39
+ const untracked = gitQuiet(cwd, 'ls-files --others --exclude-standard') || '';
40
+ const raw = `staged:${staged}
41
+ unstaged:${unstaged}
42
+ untracked:${untracked}`;
43
+ return createHash('sha256').update(raw).digest('hex').slice(0, 16);
44
+ }
45
+
35
46
  function validateChangeName(changeName) {
36
47
  if (!changeName || typeof changeName !== 'string' || changeName.trim() === '') {
37
48
  throw new Error('changeName 不能为空');
@@ -175,16 +186,29 @@ export class WorktreeManager {
175
186
  // fetch/merge 失败不影响 worktree 创建,只记录警告
176
187
  }
177
188
 
189
+ // 5.6 Dirty baseline overlay:将主工作区未提交变更同步到 worktree
190
+ const baselineResult = this._overlayBaseline(this.cwd, worktreePath);
191
+ const baselineFiles = baselineResult.files;
192
+ const baselineHash = baselineResult.baselineHash;
193
+
194
+ // 5.7 创建 baseline checkpoint(有 dirty baseline 时才创建)
195
+ let baselineCommit = null;
196
+ if (baselineFiles.length > 0) {
197
+ baselineCommit = this._createBaselineCheckpoint(worktreePath, name);
198
+ }
199
+
178
200
  // 6. 写入 meta.json
179
201
  const meta = {
180
202
  changeName: name,
181
203
  branch,
182
204
  baseBranch,
183
205
  baseHash,
184
- // actualBaseHash 记录 fetch+merge 后的实际 HEAD(可能与 baseHash 不同)
185
206
  actualBaseHash: gitQuiet(worktreePath, 'rev-parse HEAD') || baseHash,
186
207
  createdAt: new Date().toISOString(),
187
208
  worktreePath,
209
+ baselineFiles,
210
+ baselineCommit,
211
+ baselineHash, // 有 dirty baseline 时指向 checkpoint commit
188
212
  };
189
213
 
190
214
  const metaPath = join(worktreePath, META_FILE);
@@ -260,4 +284,120 @@ export class WorktreeManager {
260
284
  rmSync(worktreePath, { recursive: true, force: true });
261
285
  }
262
286
  }
287
+
288
+ /**
289
+ * 将主工作区未提交变更同步到 worktree(dirty baseline overlay)
290
+ * 覆盖 staged + unstaged 的文件变更,以及 untracked 文件。
291
+ * 使用 git diff + git apply 确保正确处理删除/rename/binary。
292
+ * @param {string} mainCwd - 主工作区路径
293
+ * @param {string} worktreePath - worktree 路径
294
+ * @returns {Array<string>} overlay 的文件列表
295
+ */
296
+ _overlayBaseline(mainCwd, worktreePath) {
297
+ const files = [];
298
+ const errors = [];
299
+
300
+ try {
301
+ // staged 变更
302
+ const staged = gitQuiet(mainCwd, 'diff --cached --name-only') || '';
303
+ if (staged) {
304
+ try {
305
+ // 用 Buffer 模式读取,避免二进制 patch 被 UTF-8 解码损坏
306
+ const patchBuf = execSync(`git diff --cached --binary`, { cwd: mainCwd, stdio: ['pipe','pipe','pipe'] });
307
+ if (patchBuf && patchBuf.length > 0) {
308
+ const patchFile = join(worktreePath, '.sillyspec-baseline-staged.patch');
309
+ writeFileSync(patchFile, patchBuf);
310
+ git(worktreePath, `apply --binary ${patchFile}`);
311
+ rmSync(patchFile, { force: true });
312
+ }
313
+ } catch (e) {
314
+ errors.push(`staged: ${e.message}`);
315
+ }
316
+ files.push(...staged.split('\n').filter(Boolean));
317
+ }
318
+
319
+ // unstaged 变更
320
+ const unstaged = gitQuiet(mainCwd, 'diff --name-only') || '';
321
+ if (unstaged) {
322
+ try {
323
+ // 用 Buffer 模式读取,避免二进制 patch 被 UTF-8 解码损坏
324
+ const patchBuf = execSync(`git diff --binary`, { cwd: mainCwd, stdio: ['pipe','pipe','pipe'] });
325
+ if (patchBuf && patchBuf.length > 0) {
326
+ const patchFile = join(worktreePath, '.sillyspec-baseline-unstaged.patch');
327
+ writeFileSync(patchFile, patchBuf);
328
+ git(worktreePath, `apply --binary ${patchFile}`);
329
+ rmSync(patchFile, { force: true });
330
+ }
331
+ } catch (e) {
332
+ errors.push(`unstaged: ${e.message}`);
333
+ }
334
+ files.push(...unstaged.split('\n').filter(Boolean));
335
+ }
336
+
337
+ // untracked 文件(排除 .sillyspec/.runtime 等)
338
+ const untracked = gitQuiet(mainCwd, 'ls-files --others --exclude-standard') || '';
339
+ if (untracked) {
340
+ for (const f of untracked.split('\n').filter(Boolean)) {
341
+ const src = join(mainCwd, f);
342
+ const dst = join(worktreePath, f);
343
+ if (existsSync(src)) {
344
+ mkdirSync(dirname(dst), { recursive: true });
345
+ try { writeFileSync(dst, readFileSync(src)); files.push(f); } catch {}
346
+ }
347
+ }
348
+ }
349
+
350
+ if (files.length > 0) {
351
+ console.log(`📁 baseline overlay: ${files.length} 个未提交文件已同步到 worktree`);
352
+ }
353
+ } catch (e) {
354
+ errors.push(`unexpected: ${e.message}`);
355
+ }
356
+
357
+ // 有 pending 文件但 overlay 部分失败 → fail-fast
358
+ if (errors.length > 0) {
359
+ throw new Error(`baseline overlay 失败 (${errors.length} 个错误): ${errors.join('; ')}`);
360
+ }
361
+
362
+ const uniqueFiles = [...new Set(files)];
363
+
364
+ // 计算 baseline hash(用于 merge 前校验主工作区是否变化)
365
+ const baselineHash = uniqueFiles.length > 0 ? computeBaselineHash(mainCwd) : null;
366
+
367
+ return { files: uniqueFiles, baselineHash };
368
+ }
369
+
370
+ /**
371
+ * 在 worktree 内创建 baseline checkpoint commit
372
+ * 用于区分 "前置 dirty baseline" 和 "子代理新增改动"
373
+ * @param {string} worktreePath
374
+ * @param {string} changeName
375
+ * @returns {string} commit hash
376
+ */
377
+ _createBaselineCheckpoint(worktreePath, changeName) {
378
+ // 使用临时 git identity,避免用户未配置 user.name/user.email 导致失败
379
+ const env = {
380
+ GIT_AUTHOR_NAME: 'sillyspec',
381
+ GIT_AUTHOR_EMAIL: 'sillyspec@baseline',
382
+ GIT_COMMITTER_NAME: 'sillyspec',
383
+ GIT_COMMITTER_EMAIL: 'sillyspec@baseline',
384
+ };
385
+ try {
386
+ execSync('git add -A', { cwd: worktreePath, encoding: 'utf8', stdio: ['pipe','pipe','pipe'], env });
387
+ // 检查是否有实际变更(可能 overlay 后和 HEAD 完全一致)
388
+ const status = gitQuiet(worktreePath, 'status --porcelain');
389
+ if (!status) {
390
+ return gitQuiet(worktreePath, 'rev-parse HEAD');
391
+ }
392
+ execSync(
393
+ `git commit -m "sillyspec: baseline checkpoint for ${changeName}"`,
394
+ { cwd: worktreePath, encoding: 'utf8', stdio: ['pipe','pipe','pipe'], env }
395
+ );
396
+ const hash = git(worktreePath, 'rev-parse HEAD');
397
+ console.log(`📌 baseline checkpoint: ${hash}`);
398
+ return hash;
399
+ } catch (e) {
400
+ throw new Error(`baseline checkpoint 创建失败: ${e.message}`);
401
+ }
402
+ }
263
403
  }