sillyspec 3.27.9 → 3.27.12

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/src/init.js CHANGED
@@ -8,7 +8,7 @@ import { getVersion } from './version.js';
8
8
  import { gitQuiet } from './git-helper.js';
9
9
  import { renderExample } from './config-schema.js';
10
10
  // 向后兼容:getVersion 已抽到轻量 version.js(避免 index.js 为 --version 静态加载 init.js 的 inquirer 税),
11
- // 此处 re-export 保持 init.js 既有导出 API 不破坏(如 test/init-claude-injection.test.mjs)。
11
+ // 此处 re-export 保持 init.js 既有导出 API 不破坏(如 test/init-agents-injection.test.mjs)。
12
12
  export { getVersion };
13
13
 
14
14
  const __filename = fileURLToPath(import.meta.url);
@@ -87,10 +87,12 @@ const TOOL_LABELS = {
87
87
  opencode: 'OpenCode (通过 INSTRUCTIONS.md)',
88
88
  };
89
89
 
90
- const INSTRUCTION_TOOLS = ['codex', 'gemini', 'opencode'];
90
+ // 小段追加工具(gemini/opencode)。codex 不在此列——AGENTS.md 是跨工具通用的标准内容源,
91
+ // codex 与 claude 共用完整模板注入(injectAgentsInstructions);gemini/opencode 改 @AGENTS.md
92
+ // 指针需先验证两家对 @ 导入语法的支持,留待后续变更。
93
+ const INSTRUCTION_TOOLS = ['gemini', 'opencode'];
91
94
 
92
95
  const INSTRUCTION_FILE_MAP = {
93
- codex: 'AGENTS.md',
94
96
  gemini: 'GEMINI.md',
95
97
  opencode: 'INSTRUCTIONS.md',
96
98
  };
@@ -130,21 +132,38 @@ function injectInstructions(tool, projectDir) {
130
132
  writeFileSync(filePath, content.trimEnd() + '\n\n' + INJECTION_CONTENT);
131
133
  }
132
134
 
133
- // ── 注入 CLAUDE.md(claude,版本感知幂等)──
134
- // 与 codex/gemini/opencode 不同:claude 需要完整 CLAUDE.md 模板 + 版本感知三态。
135
- // 故 claude 不进 INSTRUCTION_TOOLS,单独走此函数(design: 2026-08-02-init-claude-md)。
136
- const CLAUDE_TEMPLATE_PATH = join(__dirname, '..', 'templates', 'claude-instruction.md');
135
+ // ── 注入 AGENTS.md / CLAUDE.md(claude + codex,版本感知幂等)──
136
+ // AGENTS.md 是唯一承载完整指引的内容源(跨工具通用标准);CLAUDE.md 仅为 @AGENTS.md
137
+ // 导入指针(Claude Code 记忆导入语法)。取代 design: 2026-08-02-init-claude-md 的
138
+ // CLAUDE.md 单文件方案——其 D-004 预留的「marker 方案迁移」即本次变更。
139
+ const AGENTS_TEMPLATE_PATH = join(__dirname, '..', 'templates', 'agents-instruction.md');
137
140
 
138
141
  /**
139
- * 为 Claude Code 注入 CLAUDE.md(版本感知幂等)。
140
- * - 不存在:写完整模板(templates/claude-instruction.md)+ 顶部版本注释
141
- * - 存在无标记:追加受管段(INJECTION_CONTENT)+ 版本块标记
142
- * - 存在同版本标记:跳过
143
- * - 存在异版本标记:追加态刷新块 / 完整态仅 stderr 提示
142
+ * 移除 codex 老安装的旧版小段(injectInstructions 方案:`## SillySpec` 段,追加在 EOF)。
143
+ * 优先精确匹配旧文本;用户编辑过 / CRLF 漂移导致匹配失败时回退按标题截到 EOF。
144
+ * @param {string} content - AGENTS.md 原文
145
+ * @returns {string} 截除旧段后的内容(已 trimEnd)
146
+ */
147
+ function stripLegacyAgentsBlock(content) {
148
+ const exact = INJECTION_CONTENT.trimEnd();
149
+ const idx = content.indexOf(exact);
150
+ if (idx >= 0) return content.slice(0, idx).trimEnd();
151
+ const headingIdx = content.indexOf('## SillySpec');
152
+ if (headingIdx >= 0) return content.slice(0, headingIdx).trimEnd();
153
+ return content;
154
+ }
155
+
156
+ /**
157
+ * 为 AGENTS.md 注入完整指引(版本感知幂等,三态四分支 + 旧标记迁移)。
158
+ * - 不存在:写完整模板(templates/agents-instruction.md)+ 顶部版本注释
159
+ * - 存在无标记:追加受管段(INJECTION_CONTENT)+ 版本块标记;
160
+ * 含旧 `## SillySpec` 段(codex 老安装)先截除再追加(marker 迁移,防双段)
161
+ * - 存在同版本标记:跳过(不写文件)
162
+ * - 存在异版本标记:追加态刷新块 / 完整态仅 stderr 提示(保留用户改动)
144
163
  * @param {string} projectDir - 源码项目根
145
164
  */
146
- export function injectClaudeInstructions(projectDir) {
147
- const filePath = join(projectDir, 'CLAUDE.md');
165
+ export function injectAgentsInstructions(projectDir) {
166
+ const filePath = join(projectDir, 'AGENTS.md');
148
167
  const version = getVersion();
149
168
  // 完整态顶部注释(新文件首行,轻量、不限制后续编辑)
150
169
  const fullHeader = `<!-- SillySpec v${version} — 由 sillyspec init 生成,可自由编辑;重跑 init 同版本不更新 -->`;
@@ -158,23 +177,27 @@ export function injectClaudeInstructions(projectDir) {
158
177
  if (!existsSync(filePath)) {
159
178
  let template = '';
160
179
  try {
161
- template = readFileSync(CLAUDE_TEMPLATE_PATH, 'utf8');
180
+ template = readFileSync(AGENTS_TEMPLATE_PATH, 'utf8');
162
181
  } catch {
163
- console.error(`❌ [sillyspec] 未找到 Claude 模板:${CLAUDE_TEMPLATE_PATH}`);
182
+ console.error(`❌ [sillyspec] 未找到 Agent 指引模板:${AGENTS_TEMPLATE_PATH}`);
164
183
  return;
165
184
  }
166
185
  writeFileSync(filePath, fullHeader + '\n' + template);
167
- console.log(chalk.green(` ✓ Claude Code CLAUDE.md 已生成(SillySpec v${version})`));
186
+ console.log(chalk.green(` ✓ AGENTS.md 已生成(SillySpec v${version},完整指引)`));
168
187
  return;
169
188
  }
170
189
 
171
- const content = readFileSync(filePath, 'utf8');
190
+ let content = readFileSync(filePath, 'utf8');
172
191
 
173
192
  // 无任何 `<!-- SillySpec v` 标记 → 状态 2:追加受管段(原文字节保留)
174
193
  const markMatch = content.match(/<!-- SillySpec v(\S+)/);
175
194
  if (!markMatch) {
176
- writeFileSync(filePath, content.trimEnd() + '\n\n' + appendBlock + '\n');
177
- console.log(chalk.green(` ✓ Claude Code CLAUDE.md 已追加 SillySpec 受管段(v${version})`));
195
+ if (content.includes('## SillySpec')) {
196
+ content = stripLegacyAgentsBlock(content);
197
+ console.log(chalk.green(' ✓ AGENTS.md 旧版 SillySpec 小段已迁移为新受管段'));
198
+ }
199
+ writeFileSync(filePath, content + '\n\n' + appendBlock + '\n');
200
+ console.log(chalk.green(` ✓ AGENTS.md 已追加 SillySpec 受管段(v${version})`));
178
201
  return;
179
202
  }
180
203
 
@@ -187,11 +210,60 @@ export function injectClaudeInstructions(projectDir) {
187
210
  if (startBlockRe.test(content)) {
188
211
  // 4a:追加态 → 用当前版本受管段替换该块(块外用户内容字节保留)
189
212
  writeFileSync(filePath, content.replace(startBlockRe, appendBlock));
190
- console.log(chalk.green(` ✓ Claude Code CLAUDE.md 受管段已升级(v${existingVersion} → v${version})`));
213
+ console.log(chalk.green(` ✓ AGENTS.md 受管段已升级(v${existingVersion} → v${version})`));
191
214
  } else {
192
215
  // 4b:完整态 → 不覆盖(保留用户改动),仅 stderr 打印升级提示
193
- console.error(`⚠️ [sillyspec] SillySpec 升级 v${existingVersion}→v${version},CLAUDE.md 未自动更新(保留你的改动)。如需采用新模板:备份后删除 CLAUDE.md 再跑 sillyspec init。`);
216
+ console.error(`⚠️ [sillyspec] SillySpec 升级 v${existingVersion}→v${version},AGENTS.md 未自动更新(保留你的改动)。如需采用新模板:备份后删除 AGENTS.md 再跑 sillyspec init。`);
217
+ }
218
+ }
219
+
220
+ /**
221
+ * 为 Claude Code 写 CLAUDE.md 指针(@AGENTS.md 导入,薄文件)。
222
+ * - 不存在:写指针文件(版本头注释 + @AGENTS.md)
223
+ * - 存在无标记(用户自有文件):文末追加受管指针块(原文字节保留)
224
+ * - 指针态:同版本跳过;异版本仅刷新标记行中的版本号
225
+ * - 旧完整态(2026-08-02 方案:标记在但无 @AGENTS.md):不动,仅 stderr 提示迁移路径
226
+ * @param {string} projectDir - 源码项目根
227
+ */
228
+ export function injectClaudePointer(projectDir) {
229
+ const filePath = join(projectDir, 'CLAUDE.md');
230
+ const version = getVersion();
231
+ const fullPointer = `<!-- SillySpec v${version} — CLAUDE.md 指针:内容统一维护在 AGENTS.md(@ 导入),可自由编辑 -->\n\n@AGENTS.md\n`;
232
+ const appendBlock =
233
+ `<!-- SillySpec v${version} START — 由 sillyspec init 注入,勿手动编辑此段 -->\n` +
234
+ `@AGENTS.md\n` +
235
+ `<!-- SillySpec END -->`;
236
+
237
+ // 状态 1:文件不存在 → 写指针文件
238
+ if (!existsSync(filePath)) {
239
+ writeFileSync(filePath, fullPointer);
240
+ console.log(chalk.green(` ✓ Claude Code CLAUDE.md 指针已生成(@AGENTS.md,SillySpec v${version})`));
241
+ return;
242
+ }
243
+
244
+ const content = readFileSync(filePath, 'utf8');
245
+ const markMatch = content.match(/<!-- SillySpec v(\S+)/);
246
+
247
+ // 状态 2:无标记(用户自有文件)→ 追加受管指针块
248
+ if (!markMatch) {
249
+ writeFileSync(filePath, content.trimEnd() + '\n\n' + appendBlock + '\n');
250
+ console.log(chalk.green(` ✓ Claude Code CLAUDE.md 已追加 @AGENTS.md 受管指针块(v${version})`));
251
+ return;
194
252
  }
253
+
254
+ const existingVersion = markMatch[1];
255
+ // 状态 3:同版本 → 跳过(幂等,不写)
256
+ if (existingVersion === version) return;
257
+
258
+ // 状态 4:异版本。区分指针态(含 @AGENTS.md)/ 旧完整态(无导入行)。
259
+ if (!content.includes('@AGENTS.md')) {
260
+ // 4b:旧完整态 → 不覆盖,提示迁移到 AGENTS.md 单源
261
+ console.error(`⚠️ [sillyspec] 检测到旧版完整 CLAUDE.md(v${existingVersion})。当前方案以 AGENTS.md 为内容源、CLAUDE.md 仅作 @AGENTS.md 指针;为保留你的改动未自动迁移。如需迁移:备份后删除 CLAUDE.md 再跑 sillyspec init。`);
262
+ return;
263
+ }
264
+ // 4a:指针态 → 仅刷新标记行中的版本号(指针无用户语义内容,不动其余字节)
265
+ writeFileSync(filePath, content.replace(`<!-- SillySpec v${existingVersion}`, `<!-- SillySpec v${version}`));
266
+ console.log(chalk.green(` ✓ Claude Code CLAUDE.md 指针标记已升级(v${existingVersion} → v${version})`));
195
267
  }
196
268
 
197
269
  // ── 检测工具 ──
@@ -363,7 +435,7 @@ function doInstall(projectDir, tools, subprojects = [], specDir = null, options
363
435
  }
364
436
  }
365
437
 
366
- // 注入指令文件(codex/gemini/opencode)
438
+ // 注入指令文件(gemini/opencode 小段追加,不进 AGENTS.md 注入器)
367
439
  for (let i = 0; i < tools.length; i++) {
368
440
  const toolName = tools[i];
369
441
  if (INSTRUCTION_TOOLS.includes(toolName)) {
@@ -371,9 +443,14 @@ function doInstall(projectDir, tools, subprojects = [], specDir = null, options
371
443
  }
372
444
  }
373
445
 
374
- // 注入 CLAUDE.md(claude,版本感知幂等三态四分支;claude 不进 INSTRUCTION_TOOLS)
446
+ // 注入 AGENTS.md 完整指引(claude/codex 共用:版本感知幂等三态四分支 + 旧 ## SillySpec 段迁移)
447
+ if (tools.includes('claude') || tools.includes('codex')) {
448
+ injectAgentsInstructions(projectDir);
449
+ }
450
+
451
+ // 注入 CLAUDE.md 指针(claude 专属:@AGENTS.md 导入薄文件,须在 AGENTS.md 之后)
375
452
  if (tools.includes('claude')) {
376
- injectClaudeInstructions(projectDir);
453
+ injectClaudePointer(projectDir);
377
454
  }
378
455
 
379
456
  // 复制 skills 到各工具目录(--no-skills 可跳过:platform init 等场景勿污染项目内工具目录)
@@ -473,8 +550,8 @@ export async function cmdInit(projectDir, options = {}) {
473
550
  const version = getVersion();
474
551
  const resolvedSpecDir = specDir ? resolve(specDir) : null;
475
552
 
476
- // C14c:绿地/棕地引导——必须在 doInstall 之前捕获目录状态(init 会创建 .sillyspec/CLAUDE.md/
477
- // .gitignore 等,事后检测会误判);棕地(已有非隐藏源码)建议 scan,绿地(空目录)建议 brainstorm。
553
+ // C14c:绿地/棕地引导——必须在 doInstall 之前捕获目录状态(init 会创建 .sillyspec/AGENTS.md/
554
+ // CLAUDE.md/.gitignore 等,事后检测会误判);棕地(已有非隐藏源码)建议 scan,绿地(空目录)建议 brainstorm。
478
555
  let brownfield = false;
479
556
  try {
480
557
  const entries = readdirSync(projectDir, { withFileTypes: true });
@@ -0,0 +1,116 @@
1
+ /**
2
+ * module-changelog.js — 模块卡「变更索引」解析(模块→变更历史只读索引)。
3
+ *
4
+ * 背景(2026-08-31 变更关联审计):模块卡的变更史是全仓变更名提名密度最高的活文档
5
+ * (12 张卡 + 6 个 sidecar,45+ 行),但格式是 agent 归档同步时自发形成的半结构化惯例,
6
+ * 从未被解析消费。本模块把它变成机器可读:供 prompt.js buildModuleContextInjection
7
+ * 注入「最近变更」,让 brainstorm/plan/execute 免扫描地知道该模块最近被谁动过。
8
+ *
9
+ * 格式实测变体(勿按单一格式写死):
10
+ * 标题:`## 变更索引(表格,初始为空)`(scan 模板)与 `## 变更索引`(手写)两种;
11
+ * 行体:表格 `| 日期 | 变更名 | 摘要 |` 与列表 `- <变更名> | <摘要>` 混用(同一卡可混合);
12
+ * 名字:日期前缀真实变更名(2026-08-13-foo)或 ql-* quicklog id,偶见 `(quick)`后缀;
13
+ * 噪音:表头/分隔行、纯日期遗留行(`- 2026-06-03 | 初始文档`)、sidecar 迁出指针行
14
+ * (`见 \`x.changelog.md\`——…`)——全部经「整格匹配变更名」自然排除,无需黑名单。
15
+ * sidecar(split-changelog 迁出产物):`<module>.changelog.md`,frontmatter +
16
+ * `# <module> 变更索引(changelog sidecar)` + 同款表格。卡内表是迁出前历史保留不动,
17
+ * 与 sidecar 同名条目按首次出现去重(卡优先)。
18
+ */
19
+ import { existsSync, readFileSync } from 'node:fs'
20
+ import { join } from 'node:path'
21
+
22
+ /** 变更名整格匹配(剥 `(quick)` 类括号后缀后校验):日期前缀真实名 / ql-* quicklog id */
23
+ const CHANGE_NAME_RE = /^(\d{4}-\d{2}-\d{2}-[a-z0-9][a-z0-9-]*|ql-\d{8}-\d{3}-[a-z0-9]+)$/i
24
+
25
+ /** 卡内小节标题:`## 变更索引` 前缀匹配(兼容「(表格,初始为空)」后缀变体) */
26
+ const SECTION_HEADING_RE = /^##\s*变更索引/
27
+
28
+ /**
29
+ * 单行清洗为变更名:去列表标记 / 表格竖线两侧、剥括号后缀,整格匹配不过返回 null。
30
+ * @param {string} raw 行内单元格或列表项原文
31
+ * @returns {string|null}
32
+ */
33
+ function toChangeName(raw) {
34
+ const s = String(raw || '').trim().replace(/^[-|]\s*/, '').replace(/\s*[((][^))]*[))]\s*$/, '').trim()
35
+ return CHANGE_NAME_RE.test(s) ? s : null
36
+ }
37
+
38
+ /**
39
+ * 从「变更索引」段文本解析条目(纯函数,卡内小节与 sidecar 正文共用)。
40
+ * 表格行取「第一个整格匹配变更名的 cell」为名、最后一个非名 cell 为摘要;
41
+ * 列表行 `- name | desc` 同理。表头/分隔行/纯日期遗留行/指针行均不匹配自然跳过。
42
+ * @param {string} text 已切出的段文本(CRLF 已归一)
43
+ * @returns {{ name: string, summary: string }[]}
44
+ */
45
+ export function parseChangelogEntries(text) {
46
+ const entries = []
47
+ const seen = new Set()
48
+ for (const line of String(text || '').replace(/\r\n/g, '\n').split('\n')) {
49
+ if (/^\|[-:\s|]+\|$/.test(line.trim())) continue // 表格分隔行
50
+ let cells
51
+ if (line.startsWith('|')) {
52
+ cells = line.split('|').slice(1, -1).map(c => c.trim())
53
+ } else {
54
+ const item = line.match(/^\s*-\s+(.+)/)
55
+ if (!item) continue
56
+ cells = item[1].split('|').map(c => c.trim())
57
+ }
58
+ const nameIdx = cells.findIndex(toChangeName)
59
+ if (nameIdx === -1) continue
60
+ const name = toChangeName(cells[nameIdx])
61
+ if (seen.has(name)) continue
62
+ // 摘要 = 名字 cell 之后最后一个非空 cell(容忍行尾悬空竖线与「只有名字」的列表行)
63
+ let summary = ''
64
+ for (let i = cells.length - 1; i > nameIdx; i--) {
65
+ if (cells[i]) { summary = cells[i]; break }
66
+ }
67
+ entries.push({ name, summary })
68
+ seen.add(name)
69
+ }
70
+ return entries
71
+ }
72
+
73
+ /** 切出卡内「## 变更索引…」段(到下一个 ## 标题或文末);无该段返回 '' */
74
+ function extractChangelogSection(cardText) {
75
+ const lines = String(cardText || '').replace(/\r\n/g, '\n').split('\n')
76
+ const start = lines.findIndex(l => SECTION_HEADING_RE.test(l))
77
+ if (start === -1) return ''
78
+ const end = lines.findIndex((l, i) => i > start && /^##\s/.test(l))
79
+ return lines.slice(start + 1, end === -1 ? undefined : end).join('\n')
80
+ }
81
+
82
+ /** sidecar 正文剥 frontmatter(--- … ---)后整文即变更索引段 */
83
+ function stripFrontmatter(text) {
84
+ const t = String(text || '').replace(/\r\n/g, '\n')
85
+ return t.startsWith('---') ? t.replace(/^---\n.*?\n---\n?/s, '') : t
86
+ }
87
+
88
+ /**
89
+ * 读单个模块的变更史(卡内段 + sidecar,同名去重,按名字内日期降序 = 最近在前)。
90
+ * 展示用途,宁可漏抓不可错抓:解析失败/文件缺失 → [](不抛,不阻断注入)。
91
+ * @param {string} modulesDir 模块文档目录(…/docs/<project>/modules)
92
+ * @param {string} moduleId
93
+ * @returns {{ name: string, summary: string }[]}
94
+ */
95
+ export function readModuleRecentChanges(modulesDir, moduleId) {
96
+ if (!modulesDir || !moduleId) return []
97
+ const entries = []
98
+ const seen = new Set()
99
+ const absorb = list => {
100
+ for (const e of list) {
101
+ if (seen.has(e.name)) continue
102
+ entries.push(e)
103
+ seen.add(e.name)
104
+ }
105
+ }
106
+ try {
107
+ const cardPath = join(modulesDir, `${moduleId}.md`)
108
+ if (existsSync(cardPath)) absorb(parseChangelogEntries(extractChangelogSection(readFileSync(cardPath, 'utf8'))))
109
+ const sidecarPath = join(modulesDir, `${moduleId}.changelog.md`)
110
+ if (existsSync(sidecarPath)) absorb(parseChangelogEntries(stripFrontmatter(readFileSync(sidecarPath, 'utf8'))))
111
+ } catch { /* 读失败按无变更史处理 */ }
112
+ // 排序键 = 名字内前 8 位数字(日期前缀名 2026-08-13 与 ql-20260804 都取到各自日期;
113
+ // 连续 \d{8} 匹配不到带横线的日期格式,按位收集)。无数字名垫底
114
+ const dateKey = n => Number((n.match(/\d/g) || []).slice(0, 8).join('') || '0')
115
+ return entries.sort((a, b) => dateKey(b.name) - dateKey(a.name))
116
+ }
@@ -273,6 +273,34 @@ export class ChangeRegistry {
273
273
  if (renamed) console.log(`✅ 变更已重命名:${oldName} → ${newName}`);
274
274
  }
275
275
 
276
+ /**
277
+ * 删除变更(change-delete 命令的 DB 侧,2026-08-30 用户反馈①)
278
+ * SQL: UPDATE changes SET status = 'deleted'
279
+ *
280
+ * 语义区分:此前删除靠 git rm + 借道 doctor 幽灵清理,而幽灵清理把删除行写成
281
+ * archived——DB 无法区分「流程正常收尾的归档」与「中途废弃的删除」,事后审计只能
282
+ * 回溯 git。现在两种终态在 DB 里显式分离:
283
+ * - archived = archive 阶段 --confirm / quick 轻量归档(流程收尾)
284
+ * - deleted = change-delete 显式废弃(行保留供审计,status 即语义载体)
285
+ * 与 unregisterChange 不同:不做 archive 终态一致化——删除不是「完成」,给删除行收尾
286
+ * archive 阶段属伪造终态(对齐 cleanupGhostChanges 对「目录真丢失」幽灵保持 status-only
287
+ * 的可逆语义);stages/steps 行不动,保留原审计价值。
288
+ */
289
+ deleteChange(cwd, changeName) {
290
+ if (!changeName) {
291
+ console.warn('⚠️ deleteChange: changeName 为空,跳过');
292
+ return;
293
+ }
294
+ const db = this.pm._ensureDB(cwd);
295
+ db.transaction(() => {
296
+ const sqlDb = db.getDb();
297
+ const now = new Date().toISOString();
298
+ sqlDb.prepare(`UPDATE changes SET status = 'deleted', last_active = ? WHERE name = ?`).run(now, changeName);
299
+ // 本地脏度(D-013 / task-04):删除也是本地状态推进(triggerSync 据此推终态/墓碑上行)
300
+ this.pm._touchLocalModified(cwd, changeName, now);
301
+ });
302
+ }
303
+
276
304
  /**
277
305
  * 从活跃列表移除变更(归档时调用,不物理删除)
278
306
  * SQL: UPDATE changes SET status = 'archived'
@@ -29,6 +29,12 @@ export const MAIN_FLOW_ORDER = ['brainstorm', 'plan', 'execute', 'verify', 'arch
29
29
  // progress 数据版本(v3 = SQLite;v4 = changes 加 base_ts / 本地脏度两列,platform-sync §8)
30
30
  export const CURRENT_VERSION = 5;
31
31
 
32
+ // 滞留提示阈值(天,2026-08-30 用户反馈②:7 个「代码全落地但流程没收口」的变更挂 38 天
33
+ // 无人发现):活跃变更 last_active 距今超该天数且流程未收口(archive 未完成)时,progress
34
+ // show 输出滞留提示行。与 doctor-diagnostics 空壳目录 7 天门槛(GHOST_EMPTY_DIR_STALE_MS)
35
+ // 同量级——「多久算滞留」全仓统一口径。
36
+ export const STALL_WARN_DAYS = 7;
37
+
32
38
  // 空阶段骨架
33
39
  export function emptyStage() {
34
40
  return { status: 'pending', steps: [], startedAt: null, completedAt: null };
@@ -7,7 +7,7 @@ import { mkdirSync, existsSync } from 'fs';
7
7
  import { join, resolve, basename } from 'path';
8
8
  import { writeAtomicSync } from '../fs-atomic.js';
9
9
  import { runValidators } from '../stage-contract.js';
10
- import { VALID_STAGES, STAGE_LABELS, STAGE_ORDER, MAIN_FLOW_ORDER, SPEC_DIR_NAME, CURRENT_VERSION, emptyStage } from './shared.js';
10
+ import { VALID_STAGES, STAGE_LABELS, STAGE_ORDER, MAIN_FLOW_ORDER, SPEC_DIR_NAME, CURRENT_VERSION, STALL_WARN_DAYS, emptyStage } from './shared.js';
11
11
 
12
12
  /**
13
13
  * 归一化步骤的已记录等待回答(show 渲染用,跨会话恢复数据源)。
@@ -23,6 +23,20 @@ function _stepWaitRounds(step) {
23
23
  return rounds.map((r, i) => ({ round: typeof r.round === 'number' ? r.round : i + 1, answer: r.answer }));
24
24
  }
25
25
 
26
+ /**
27
+ * 容错时间戳解析:ISO 8601(DB last_active 正常形态)优先,回退 zh-CN 本地格式
28
+ * (reopenStage/reset 等旧路径曾把 toLocaleString('zh-CN') 写进 lastActive)。解析失败 → null。
29
+ */
30
+ function _parseFlexibleTs(dateStr) {
31
+ if (!dateStr) return null;
32
+ let ts = Date.parse(dateStr);
33
+ if (isNaN(ts)) {
34
+ const m = dateStr.match(/(\d{4})[\/-](\d{1,2})[\/-](\d{1,2})[\s,]+(\d{1,2}):(\d{2})(?::(\d{2}))?/);
35
+ if (m) ts = new Date(+m[1], +m[2] - 1, +m[3], +m[4], +m[5], +(m[6] || 0)).getTime();
36
+ }
37
+ return isNaN(ts) ? null : ts;
38
+ }
39
+
26
40
  export class StageMachine {
27
41
  constructor(pm) {
28
42
  this.pm = pm;
@@ -204,6 +218,14 @@ export class StageMachine {
204
218
 
205
219
  console.log(` 📂 ${cn}${dirMissing ? ' ⚠️ 目录缺失(残留记录,sillyspec doctor --cleanup-ghosts --confirm 可归档清理)' : ''}`);
206
220
  console.log(` 当前阶段: ${stageLabel} 最近活跃: ${lastActive}`);
221
+ // 滞留/疑似完成信号(2026-08-30 用户反馈②):多变更汇总逐行透出,无需逐个 --change 看详情
222
+ const stall = this._stallSignal(cwd, data, cn);
223
+ if (stall) {
224
+ console.log(` ⏳ ${stall.text}`);
225
+ console.log(stall.kind === 'likely-complete'
226
+ ? ` → 建议:${stall.command ? `${stall.command} 收口后走 verify,或 ` : ''}sillyspec doctor --align-execute-progress 对齐派生戳`
227
+ : ` → 若已放弃:sillyspec change-delete --change ${cn}(默认 dry-run)`);
228
+ }
207
229
  console.log('');
208
230
  }
209
231
 
@@ -288,6 +310,16 @@ export class StageMachine {
288
310
  }
289
311
  }
290
312
 
313
+ // ── 滞留/疑似完成信号(2026-08-30 用户反馈②)──
314
+ const stall = this._stallSignal(cwd, data, changeName);
315
+ if (stall) {
316
+ console.log('');
317
+ console.log(` ⏳ ${stall.text}`);
318
+ console.log(stall.kind === 'likely-complete'
319
+ ? ` 建议:${stall.command ? `${stall.command} 收口后走 verify,或 ` : ''}sillyspec doctor --align-execute-progress 对齐派生戳`
320
+ : ` 若已放弃:sillyspec change-delete --change ${changeName}(默认 dry-run);若仍在推进请忽略`);
321
+ }
322
+
291
323
  // ── Next 建议 ──
292
324
  const suggestion = this._getNextSuggestion(data);
293
325
  if (suggestion) {
@@ -599,14 +631,59 @@ export class StageMachine {
599
631
  }
600
632
  }
601
633
 
602
- _timeAgo(dateStr) {
603
- if (!dateStr) return '未知';
604
- let ts = Date.parse(dateStr);
605
- if (isNaN(ts)) {
606
- const m = dateStr.match(/(\d{4})[\/-](\d{1,2})[\/-](\d{1,2})[\s,]+(\d{1,2}):(\d{2})(?::(\d{2}))?/);
607
- if (m) ts = new Date(+m[1], +m[2]-1, +m[3], +m[4], +m[5], +(m[6]||0)).getTime();
634
+ /**
635
+ * 滞留信号检测(2026-08-30 用户反馈②:7 个「代码全落地但流程没收口」的变更挂 38 天
636
+ * 无人发现——progress 视图缺中期滞留提示)。两类信号(advisory,只打印提示行不阻断):
637
+ * - likely-complete:tasks.md 全勾但 execute 阶段未完成(不限天数——全勾未收口本身
638
+ * 即异常态;判据与 doctor D5 execute-progress-plan-mismatch 同源,但 D5 藏在
639
+ * doctor --json 里,日常 progress show 看不见)
640
+ * - stalled:流程未收口(archive 未完成)且 last_active 超 STALL_WARN_DAYS 天无活跃
641
+ * 流程已收口(archive completed / currentStage=archive)不提示。
642
+ * @returns {{kind:'likely-complete'|'stalled', text:string, command?:string}|null}
643
+ */
644
+ _stallSignal(cwd, data, changeName) {
645
+ if (!data || !data.stages) return null;
646
+ if (data.stages.archive?.status === 'completed' || data.currentStage === 'archive') return null;
647
+ const stageLabel = STAGE_LABELS[data.currentStage] || data.currentStage || '(无)';
648
+ // ① 实现疑似完成:tasks 全勾 + execute 未完成(回 verify 收口的前置是 execute 收口)
649
+ if (data.stages.execute?.status !== 'completed') {
650
+ let plan = null;
651
+ try {
652
+ plan = this.pm.readPlanCheckboxStatus(join(this.pm._getSpecDir(cwd), 'changes', changeName));
653
+ } catch { plan = null; }
654
+ if (plan && plan.total > 0 && plan.checked >= plan.total) {
655
+ return {
656
+ kind: 'likely-complete',
657
+ text: `实现疑似完成未收口:tasks ${plan.checked}/${plan.total} 全勾,但流程停在「${stageLabel}」(execute 阶段 ${data.stages.execute?.status || '未开始'})`,
658
+ // execute 阶段才建议 run execute 收口;更早阶段(brainstorm/plan)tasks 全勾属
659
+ // 数据异常态,只提示事实 + doctor 对齐备选,不给当前状态下不可执行的命令
660
+ command: data.currentStage === 'execute'
661
+ ? `sillyspec run execute${data.currentChange ? ` --change ${data.currentChange}` : ''}`
662
+ : null,
663
+ };
664
+ }
665
+ }
666
+ // ② 滞留:超 N 天无活跃
667
+ const days = this._daysSince(data.lastActive);
668
+ if (days !== null && days >= STALL_WARN_DAYS) {
669
+ return {
670
+ kind: 'stalled',
671
+ text: `滞留:已 ${days} 天无活跃,流程停在「${stageLabel}」未收口`,
672
+ };
608
673
  }
609
- if (isNaN(ts)) return dateStr;
674
+ return null;
675
+ }
676
+
677
+ /** last_active 距今天数(向下取整);解析失败 → null(不提示,宁漏勿误报) */
678
+ _daysSince(dateStr) {
679
+ const ts = _parseFlexibleTs(dateStr);
680
+ if (ts === null) return null;
681
+ return Math.floor((Date.now() - ts) / 86400000);
682
+ }
683
+
684
+ _timeAgo(dateStr) {
685
+ const ts = _parseFlexibleTs(dateStr);
686
+ if (ts === null) return dateStr || '未知';
610
687
  const diff = Date.now() - ts;
611
688
  const minutes = Math.floor(diff / 60000);
612
689
  if (minutes < 1) return '刚刚';
package/src/progress.js CHANGED
@@ -850,6 +850,9 @@ export class ProgressManager {
850
850
 
851
851
  renameChange(cwd, oldName, newName) { return this._changeRegistry.renameChange(cwd, oldName, newName); }
852
852
 
853
+ // 删除语义与归档分离(status='deleted',见 change-registry.deleteChange 注释)
854
+ deleteChange(cwd, changeName) { return this._changeRegistry.deleteChange(cwd, changeName); }
855
+
853
856
  // opts.archiveStepNames 给定时做归档终态一致化收尾(见 change-registry.unregisterChange 注释,
854
857
  // 坑 manual-archive-desync-status-only);archiveStepNamesForArchive() 取 stageRegistry 单一真相
855
858
  //(stages/* 不反向依赖 progress.js,静态 import 无环)。
@@ -75,6 +75,38 @@ export function detectSpaceSeparatedFiles(flags) {
75
75
  return suspects
76
76
  }
77
77
 
78
+ /**
79
+ * 检测 prose 参数值是否疑似被 Git Bash(MSYS) 路径转换污染(坑 quick-req-msys-path-mangling)。
80
+ *
81
+ * MSYS2 对以 / 开头的命令行参数做 POSIX→Windows 自动转换:--req "/sessions 页修复" 到达
82
+ * CLI 时已是 "<Git 安装目录>/sessions 页修复"(如 E:/Software/Git/sessions 页修复),无感
83
+ * 写入 QUICKLOG 标题与「需求:」行(该标题也是平台「快速修复」列表展示标题)。启发式:
84
+ * 盘符绝对路径开头 + 紧随空白与中文正文——人写的 Windows 路径引用不会以「盘符路径+中文
85
+ * 句子」形态开头;纯英文正文检不出(v1 取零误报优先,本仓主流为中文文案)。
86
+ *
87
+ * 纯函数:无副作用,供 command.js 解析层告警 + 单测复用。只告警不阻断——无法确定性区分
88
+ * 误报(合法值确可能以盘符路径开头),由 agent 看到 stderr 告警后自查重发。
89
+ * @param {string|null} v 待检值
90
+ * @returns {boolean} 疑似污染返回 true
91
+ */
92
+ export function looksLikeMsysMangledPath(v) {
93
+ if (typeof v !== 'string' || v.length === 0) return false
94
+ const m = v.match(/^[A-Za-z]:[\\/]\S+/) // 盘符绝对路径 token(吃到首个空白)
95
+ if (!m) return false
96
+ const rest = v.slice(m[0].length)
97
+ return /^\s/.test(rest) && /[\u4e00-\u9fff]/.test(rest)
98
+ }
99
+
100
+ /**
101
+ * MSYS 污染告警出口:命中嗅探时向 stderr 打 flag 名 + 值前缀 + 修复指引(不阻断)。
102
+ */
103
+ function warnMsysMangledFlag(flag, value) {
104
+ if (!looksLikeMsysMangledPath(value)) return
105
+ console.error(`⚠️ ${flag} 的值疑似被 Git Bash(MSYS) 路径转换污染:「${value.slice(0, 60)}${value.length > 60 ? '…' : ''}」`)
106
+ console.error(' 以 / 开头的文案在 Git Bash 下会被展开成 <Git 安装目录>/… 绝对路径后才传入 CLI。')
107
+ console.error(' 非本意 → 去掉前导 / 或改写表述后重发本命令;确需原样 → 命令前加 MSYS_NO_PATHCONV=1。')
108
+ }
109
+
78
110
  /**
79
111
  * 从 progress 或变更目录推导变更名
80
112
  */
@@ -473,6 +505,7 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
473
505
  let outputText = null
474
506
  const outputValue = getFlagValue('--output')
475
507
  if (outputValue !== null) outputText = outputValue
508
+ warnMsysMangledFlag('--output', outputValue)
476
509
 
477
510
  // 解析 quick 末步四字段参数(2026-08-21 agent-手工产出审计第二批 F6):--req/--cause/
478
511
  // --solution/--result 各传一项,CLI 合成单行四字段 outputText(quicklog 落盘侧
@@ -492,6 +525,7 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
492
525
  console.error(`❌ quick 末步四字段参数缺项:${missing.join(' ')}(--req/--cause/--solution/--result 必须全给,或缺字段 --done 会被拒)`);
493
526
  process.exit(2)
494
527
  }
528
+ for (const [f] of FOUR) warnMsysMangledFlag(f, getFlagValue(f))
495
529
  outputText = FOUR.map(([f, label]) => `${label}${getFlagValue(f)}`).join(' ')
496
530
  }
497
531
  }
@@ -514,6 +548,7 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
514
548
  break
515
549
  }
516
550
  }
551
+ warnMsysMangledFlag('--input', inputText)
517
552
 
518
553
  // 解析 --linked-changes <a,b|none>(quick 专用:显式声明关联变更,CI/脚本友好)
519
554
  // 与 --change 解耦:--linked-changes 语义清晰(关联变更),不与「指定变更名」混淆。
@@ -1187,12 +1222,37 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
1187
1222
  }
1188
1223
 
1189
1224
  // 确保步骤已初始化
1225
+ // 坑 archive-batch-31-tool-notes ②(2026-08-30 实证):漂移重播种(ensureStageSteps 按名
1226
+ // 保留完成态)会改变「当前待完成步骤」的身份——旧表当前步「确认归档」、重播种后首个
1227
+ // pending 变为新插入的中间步骤,带 --confirm 的 --done 被静默记到非预期步骤上(确认
1228
+ // 标志被耗),操作者只见「步骤没完成、提示让补 --confirm」。fail-closed:mutating
1229
+ // 命令(--done/--skip/--wait)在当前步身份变化时中止本次执行(重播种已落盘),指引
1230
+ // 原样重跑——与 complete.js def↔DB 守卫「原样重跑即自愈」同哲学。首播(无旧表)与
1231
+ // 按名保留后当前步不变的重播种不受影响。
1232
+ const _preReseedPending = (progress.stages?.[stageName]?.steps || [])
1233
+ .find(s => s.status === 'pending' || s.status === 'in-progress' || s.status === 'blocked')?.name || null
1190
1234
  const changed = await ensureStageSteps(progress, stageName, cwd, specRoot)
1191
1235
  if (changed && effectiveChange) {
1192
1236
  pm._write(cwd, progress, effectiveChange)
1193
1237
  triggerSync(cwd, effectiveChange, platformOpts)
1194
1238
  progress = pm.read(cwd, effectiveChange) || progress
1195
1239
  }
1240
+ if (changed && _preReseedPending && (isDone || isSkip || isWait)
1241
+ && !['execute', 'plan', 'scan'].includes(stageName)) {
1242
+ // 仅静态定义阶段(brainstorm/verify/archive/quick…)适用:老表来自旧版 CLI 定义,
1243
+ // 重播种改变当前步身份属版本错位、应当显式中止。execute/plan/scan 的步骤表动态构建
1244
+ //(plan.md Wave / buildPlanSteps / scanProfile 档位),DB↔定义漂移是常态,「重播种后
1245
+ // 继续推进」是设计内自愈流(run-complete-step-validator-rollback 等测试锁住该行为),
1246
+ // 不能中止——其错位防护由 complete.js def↔DB 守卫承担。
1247
+ const _postReseedPending = (progress.stages?.[stageName]?.steps || [])
1248
+ .find(s => s.status === 'pending' || s.status === 'in-progress' || s.status === 'blocked')?.name || null
1249
+ if (_postReseedPending && _postReseedPending !== _preReseedPending) {
1250
+ const _action = isDone ? '--done' : isSkip ? '--skip' : '--wait'
1251
+ console.error(`⚠️ 步骤表漂移重播种后,当前步骤由「${_preReseedPending}」变为「${_postReseedPending}」——本次 ${_action} 未执行,防记到非预期步骤(如 --confirm 被耗在新插入的中间步骤)。`)
1252
+ console.error(` 重播种已落盘,原样重跑同一命令即可按新步骤表推进(原 flags 全部生效)。`)
1253
+ process.exit(1)
1254
+ }
1255
+ }
1196
1256
 
1197
1257
  // --file-notes 非末步静默忽略前置 warn(坑 quick-file-notes-nonfinal-ignored,2026-08-22 实证:
1198
1258
  // step2 --done 传 --file-notes 被静默丢——CLI 短进程,注入随进程结束即丢,白传一轮)。
@@ -797,7 +797,17 @@ export async function handleWorkflowPostCheck({ stageName, steps, currentIdx, cw
797
797
  console.log(` └─ ${f.message ?? JSON.stringify(f)}`)
798
798
  }
799
799
  }
800
- const saved = saveWorkflowRun(result, {
800
+ const saved = saveWorkflowRun(
801
+ // 坑 archive-batch-31-tool-notes ①(2026-08-30 实证):archive-impact workflow 的
802
+ // 整体 status 含下一步 doc-syncer 角色(sync-module-docs 步才执行,本步恒 fail)——
803
+ // 按整体 status 命名会把本步已通过的产物误标 -fail.json(与「✅ module-impact.md
804
+ // 检查通过」矛盾,存量 75 个实证)。落盘记录按本步最终校验(impact-analyzer)定
805
+ // status/文件名;doc-syncer 的角色明细保留在 roles/failures 字段(另记字段不丢信息),
806
+ // status_scope 标注口径。impact-analyzer 角色缺失时维持整体 status(行为不变)。
807
+ impactResult && impactResult.status !== result.status
808
+ ? { ...result, status: impactResult.status, status_scope: 'step:extract-module-impact' }
809
+ : result,
810
+ {
801
811
  cwd,
802
812
  source: 'run.js',
803
813
  stage: 'archive',
@@ -1365,6 +1375,20 @@ export async function handleExecuteWorktreeCleanup({ stageName, changeName, cwd
1365
1375
  console.log(` 下一步: sillyspec worktree apply ${changeName}`);
1366
1376
  } else {
1367
1377
  console.log(`🔗 Worktree: ${cleanResult.result.result}`);
1378
+ // 正当清理回执(坑 deps-gate-cleanup-order,2026-08-28 实证:cleanup 后重试 --done 被
1379
+ // deps 门拦「worktree 不可用」,逼 doctor --align-execute-progress 手工对齐):execute 完成时的
1380
+ // 正当清理(无未应用变更)落 durable 标记,enforceDepsGate 读它区分「正当收尾」与「意外丢失」。
1381
+ // apply 路径不写——apply-pathspec-<change>.txt 已是既定 apply 凭据,双标记冗余。
1382
+ if (['cleaned', 'force-cleaned', 'partial'].includes(cleanResult.result.result)) {
1383
+ try {
1384
+ const runtimeDir = join(cwd, '.sillyspec', '.runtime');
1385
+ mkdirSync(runtimeDir, { recursive: true });
1386
+ writeFileSync(join(runtimeDir, `execute-cleanup-${changeName}.json`), JSON.stringify({
1387
+ change: changeName, cleanedAt: new Date().toISOString(),
1388
+ reason: 'execute-completed-no-unapplied-changes',
1389
+ }, null, 2) + '\n', 'utf8');
1390
+ } catch { /* 回执失败只损失 deps 门的放行判据,不影响 cleanup 结果 */ }
1391
+ }
1368
1392
  if (cleanResult.result.residual?.length > 0) {
1369
1393
  console.warn(` ⚠️ 清理残留: ${cleanResult.result.residual.join('; ')}`);
1370
1394
  console.warn(` 手动处理: sillyspec worktree cleanup ${changeName} --force`);