sillyspec 3.24.7 → 3.24.8

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sillyspec",
3
- "version": "3.24.7",
3
+ "version": "3.24.8",
4
4
  "description": "SillySpec CLI — 流程状态机,让 AI 严格按步骤来",
5
5
  "icon": "logo.jpg",
6
6
  "homepage": "https://sillyspec.ppdmq.top/",
package/src/constants.js CHANGED
@@ -68,3 +68,15 @@ export function isPointerStale(pointer) {
68
68
  export function isPointerCorrupted(pointer) {
69
69
  return !pointer || !pointer.specRoot || !pointer.savedAt
70
70
  }
71
+
72
+ // ── scan 必需文档清单(stage-contract validator 与 scan-postcheck 共用单一真相源)──
73
+ // 曾分两处硬编码同一份清单,加新文档需同步改两处,易分叉。现收敛到此处一处。
74
+ export const SCAN_REQUIRED_DOCS = Object.freeze([
75
+ 'ARCHITECTURE.md',
76
+ 'CONVENTIONS.md',
77
+ 'STRUCTURE.md',
78
+ 'INTEGRATIONS.md',
79
+ 'TESTING.md',
80
+ 'CONCERNS.md',
81
+ 'PROJECT.md',
82
+ ])
@@ -0,0 +1,69 @@
1
+ /**
2
+ * fs-atomic.js — 原子文件写 + Windows 友好的 rename 重试
3
+ *
4
+ * 用于 .runtime/*.json、pointer、guard.json 等「会被其他进程 / hook 读取」的配置文件。
5
+ * 保证读者要么看到旧完整内容、要么看到新完整内容,不会读到半截 JSON。
6
+ *
7
+ * 注意:db.js 有自己的 _atomicWriteSync(含 .bak 备份),那是 DB 专属持久化逻辑,
8
+ * 已经过稳定性验证,此处不复用、不替换——保持 db.js 零改动。
9
+ */
10
+ import { writeFileSync, renameSync, unlinkSync } from 'fs';
11
+ import { dirname, basename, join } from 'path';
12
+
13
+ // Windows 上杀毒 / 索引 / IDE 占用文件时,rename 偶发这些错误,短退避重试即可
14
+ const RENAME_RETRY_CODES = ['EPERM', 'EBUSY', 'EACCES', 'ENOTEMPTY'];
15
+
16
+ /** 同步退避:优先 Atomics.wait,不可用时退化忙等(仅在 rename 冲突的极端情况触发)。 */
17
+ function sleepSync(ms) {
18
+ try {
19
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
20
+ } catch {
21
+ const end = Date.now() + ms;
22
+ while (Date.now() < end) { /* busy wait */ }
23
+ }
24
+ }
25
+
26
+ /**
27
+ * rename 带退避重试,覆盖 Windows 偶发的 EPERM/EBUSY/EACCES/ENOTEMPTY。
28
+ * 目录 rename 同样适用(归档移动目录时可复用)。
29
+ * @param {string} from
30
+ * @param {string} to
31
+ * @param {number} [retries=5]
32
+ */
33
+ export function renameSyncRetry(from, to, retries = 5) {
34
+ let lastErr;
35
+ for (let attempt = 0; attempt <= retries; attempt++) {
36
+ try {
37
+ renameSync(from, to);
38
+ return;
39
+ } catch (err) {
40
+ lastErr = err;
41
+ const code = err && err.code;
42
+ // 非可重试错误码立即抛出,不浪费时间
43
+ if (!code || !RENAME_RETRY_CODES.includes(code)) throw err;
44
+ if (attempt < retries) sleepSync(15 * (attempt + 1));
45
+ }
46
+ }
47
+ throw lastErr;
48
+ }
49
+
50
+ /**
51
+ * 原子写文本文件:同目录 tmp 写入 → rename 覆盖目标(带 Windows 退避重试)。
52
+ * - tmp 名含 pid,避免多进程并发写同一目标时 tmp 名碰撞(如多会话写 gate-status.json)。
53
+ * - rename 失败时清理 tmp,避免留下孤儿 .tmp 文件。
54
+ * - Node 的 rename 在 Windows 上用 MoveFileEx(REPLACE_EXISTING),可覆盖已存在目标;
55
+ * EPERM 仅在文件被其他进程锁定时出现,由 renameSyncRetry 退避重试。
56
+ * @param {string} filePath - 目标文件绝对路径
57
+ * @param {string} content - 文件内容
58
+ */
59
+ export function writeAtomicSync(filePath, content) {
60
+ const dir = dirname(filePath);
61
+ const tmpPath = join(dir, `.${basename(filePath)}.${process.pid}.tmp`);
62
+ writeFileSync(tmpPath, content);
63
+ try {
64
+ renameSyncRetry(tmpPath, filePath);
65
+ } catch (err) {
66
+ try { unlinkSync(tmpPath); } catch { /* tmp 可能已被 rename 走,忽略 */ }
67
+ throw err;
68
+ }
69
+ }
package/src/index.js CHANGED
@@ -7,6 +7,7 @@
7
7
  * 状态管理通过 sillyspec.db(SQLite)完成,使用 `sillyspec progress` 命令。
8
8
  */
9
9
  import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'fs';
10
+ import { writeAtomicSync } from './fs-atomic.js';
10
11
  import { join, resolve } from 'path';
11
12
  import { cmdInit, getVersion } from './init.js';
12
13
  import { ProgressManager, resolvePlatformSpecDir } from './progress.js';
@@ -280,7 +281,7 @@ async function main() {
280
281
  }
281
282
  case 'batch': {
282
283
  if (filteredArgs.includes('--status')) {
283
- const bp = pm.readBatchProgress(dir, progChangeName);
284
+ const bp = await pm.readBatchProgress(dir, progChangeName);
284
285
  if (!bp) { console.log('📭 无批量进度数据'); break; }
285
286
  const line = pm._renderBatchProgress(bp);
286
287
  console.log(line || '📭 无批量进度数据');
@@ -299,7 +300,7 @@ async function main() {
299
300
  console.log(' sillyspec progress batch --status');
300
301
  break;
301
302
  }
302
- pm.updateBatchProgress(dir, batchData, progChangeName);
303
+ await pm.updateBatchProgress(dir, batchData, progChangeName);
303
304
  console.log('✅ 批量进度已更新');
304
305
  }
305
306
  break;
@@ -1171,11 +1172,9 @@ SillySpec modules — 模块文档管理
1171
1172
  lines.push('# order-service: "order/"');
1172
1173
  const yamlText = lines.join('\n') + '\n';
1173
1174
 
1174
- // 原子写:mkdir -p specRoot → 写 tmp → rename
1175
+ // 原子写(tmp + rename + Windows EPERM 重试),避免半截 local.yaml
1175
1176
  mkdirSync(specRoot, { recursive: true });
1176
- const tmpPath = localYamlPath + '.tmp';
1177
- writeFileSync(tmpPath, yamlText, 'utf8');
1178
- renameSync(tmpPath, localYamlPath);
1177
+ writeAtomicSync(localYamlPath, yamlText);
1179
1178
 
1180
1179
  console.log(`✅ 已生成 local.yaml (type: ${detected.project.type})`);
1181
1180
  console.log(` 路径: ${localYamlPath}`);
package/src/modules.js CHANGED
@@ -79,7 +79,7 @@ export async function rebuildModuleMap(cwd) {
79
79
  const content = readFileSync(join(modulesDir, f), 'utf8');
80
80
  const fm = parseModuleCardFrontmatter(content);
81
81
  const moduleId = fm.module_id || parseModuleIdFromFilename(f);
82
- cards.push({ filename: f, moduleId });
82
+ cards.push({ filename: f, moduleId, content });
83
83
  }
84
84
  }
85
85
 
@@ -134,7 +134,7 @@ export async function rebuildModuleMap(cwd) {
134
134
 
135
135
  for (const moduleId of allModuleIds) {
136
136
  const card = cards.find(c => c.moduleId === moduleId);
137
- const cardContent = card ? readFileSync(join(modulesDir, card.filename), 'utf8') : '';
137
+ const cardContent = card?.content || '';
138
138
 
139
139
  // 从模块卡片提取 context index 字段
140
140
  const role = extractSection(cardContent, '定位') || '';
package/src/progress.js CHANGED
@@ -13,6 +13,7 @@
13
13
  import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, unlinkSync, appendFileSync } from 'fs';
14
14
  import { join, basename, dirname, resolve } from 'path';
15
15
  import { DB } from './db.js';
16
+ import { writeAtomicSync } from './fs-atomic.js';
16
17
 
17
18
  // 默认规范目录名(相对于 cwd)
18
19
  const SPEC_DIR_NAME = '.sillyspec';
@@ -156,7 +157,14 @@ export class ProgressManager {
156
157
  /** 获取 specDir(优先自定义,否则向上查找含 .sillyspec 的目录,fallback 到 cwd/.sillyspec) */
157
158
  _getSpecDir(cwd) {
158
159
  if (this._customSpecDir) return this._customSpecDir;
159
- return resolveSpecDir(cwd);
160
+ // specDir 对给定 cwd 在进程生命周期内不变(.sillyspec 目录不会被移动),
161
+ // 按 cwd 缓存,避免每次路径解析都向上 existsSync 遍历目录树(热路径)。
162
+ if (!this._specDirCache) this._specDirCache = new Map();
163
+ const cached = this._specDirCache.get(cwd);
164
+ if (cached) return cached;
165
+ const resolved = resolveSpecDir(cwd);
166
+ this._specDirCache.set(cwd, resolved);
167
+ return resolved;
160
168
  }
161
169
 
162
170
  _runtimePath(cwd, ...parts) {
@@ -1064,7 +1072,7 @@ export class ProgressManager {
1064
1072
  mkdirSync(historyDir, { recursive: true });
1065
1073
  const ts = now.replace(/[:.TZ-]/g, '');
1066
1074
  const stageData = data.stages[stage];
1067
- writeFileSync(
1075
+ writeAtomicSync(
1068
1076
  join(historyDir, `${cn}-${stage}-${ts}.json`),
1069
1077
  JSON.stringify({ change: cn, stage, data: stageData, completedAt: now }, null, 2) + '\n'
1070
1078
  );
@@ -1666,8 +1674,7 @@ export class ProgressManager {
1666
1674
  const changeId = changeRow[0].values[0][0];
1667
1675
  sqlDb.run('DELETE FROM steps WHERE stage_id IN (SELECT id FROM stages WHERE change_id = ?)', [changeId]);
1668
1676
  sqlDb.run('DELETE FROM stages WHERE change_id = ?', [changeId]);
1669
- sqlDb.run('UPDATE stages SET status = "pending", started_at = NULL, completed_at = NULL WHERE change_id = ?', [changeId]);
1670
- // 重新插入所有阶段
1677
+ // 重新插入所有阶段(注意:上方已 DELETE stages,无需再 UPDATE)
1671
1678
  for (const s of VALID_STAGES) {
1672
1679
  sqlDb.run('INSERT OR IGNORE INTO stages (change_id, stage, status) VALUES (?, ?, "pending")', [changeId, s]);
1673
1680
  }
@@ -1865,9 +1872,7 @@ export class ProgressManager {
1865
1872
  updatedAt: new Date().toISOString(),
1866
1873
  ...(hasNoWorktree ? { noWorktree: true } : {}),
1867
1874
  };
1868
- const tmpPath = gatePath + '.tmp';
1869
- writeFileSync(tmpPath, JSON.stringify(gateData, null, 2) + '\n');
1870
- renameSync(tmpPath, gatePath);
1875
+ writeAtomicSync(gatePath, JSON.stringify(gateData, null, 2) + '\n');
1871
1876
  } catch (err) {
1872
1877
  console.warn('⚠️ 写入 gate-status.json 失败:', err.message);
1873
1878
  }
package/src/run.js CHANGED
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import { basename, join, resolve, dirname, relative, isAbsolute } from 'path'
8
8
  import { existsSync, readdirSync, mkdirSync, writeFileSync, appendFileSync, readFileSync, rmSync, statSync } from 'fs'
9
+ import { writeAtomicSync, renameSyncRetry } from './fs-atomic.js'
9
10
  import { randomBytes, randomUUID } from 'crypto'
10
11
  import { createRequire } from 'module'
11
12
  const require = createRequire(import.meta.url)
@@ -1245,12 +1246,15 @@ async function executeScanPreflight(cwd, platformOpts, scanProfile) {
1245
1246
  const projectName = basename(cwd)
1246
1247
  console.log(` 📁 项目: ${projectName}`)
1247
1248
  console.log(` 📊 Profile: ${scanProfile.mode} (${scanProfile.reason})`)
1248
- // 快速列出顶层结构
1249
+ // 快速列出顶层结构(readdirSync 跨平台,避免 spawn shell + 5 个 grep 进程)
1249
1250
  try {
1250
- const { execSync } = await import('child_process')
1251
- const dirs = execSync(`ls -d */ 2>/dev/null | grep -v node_modules | grep -v '.git' | grep -v '.sillyspec' | grep -v '.claude' | head -20`, { cwd, encoding: 'utf8' }).trim()
1252
- if (dirs) {
1253
- console.log(` 📂 目录: ${dirs.split('\n').map(d => d.replace(/\/$/, '')).join(', ')}`)
1251
+ const skip = new Set(['node_modules', '.git', '.sillyspec', '.claude'])
1252
+ const dirNames = readdirSync(cwd, { withFileTypes: true })
1253
+ .filter(d => d.isDirectory() && !skip.has(d.name) && !d.name.startsWith('.'))
1254
+ .slice(0, 20)
1255
+ .map(d => d.name)
1256
+ if (dirNames.length) {
1257
+ console.log(` 📂 目录: ${dirNames.join(', ')}`)
1254
1258
  }
1255
1259
  } catch {}
1256
1260
  console.log(` ✅ Preflight 完成,准备生成核心文档\n`)
@@ -2199,7 +2203,7 @@ async function runStage(pm, progress, stageName, cwd, changeName, skipApproval =
2199
2203
  // 写入 .runtime/quick-sessions/<sessionId>/guard.json 供 worktree-guard hook 读取
2200
2204
  // (D-002:按 session 存,多会话各自 guard 不互覆盖。runStage 作用域内 sessionId == changeName == quick-<uuid8>,见 §4.4/4.5)
2201
2205
  mkdirSync(sessionGuardDir, { recursive: true })
2202
- writeFileSync(guardFile, JSON.stringify(progress.quickGuard, null, 2))
2206
+ writeAtomicSync(guardFile, JSON.stringify(progress.quickGuard, null, 2))
2203
2207
  const parts = [`${baselineFiles.length} 个已有脏文件`]
2204
2208
  if (allowedFiles.length > 0) parts.push(`${allowedFiles.length} 个 allowedFiles`)
2205
2209
  if (allowNew) parts.push('允许新增文件')
@@ -2383,7 +2387,13 @@ async function archiveChangeDirectory(pm, cwd, progress, specBase) {
2383
2387
  process.exit(1)
2384
2388
  }
2385
2389
  mkdirSync(archiveDir, { recursive: true })
2386
- renameSync(srcDir, destDir)
2390
+ try {
2391
+ renameSyncRetry(srcDir, destDir)
2392
+ } catch (e) {
2393
+ console.error(`❌ 归档失败:移动变更目录时出错(${e.message})`)
2394
+ console.error(` 常见原因:变更目录内文件被 IDE / 杀毒 / 索引占用,已重试 5 次仍失败。请关闭相关程序后重试。`)
2395
+ process.exit(1)
2396
+ }
2387
2397
 
2388
2398
  if (!existsSync(destDir) || existsSync(srcDir)) {
2389
2399
  console.error('❌ 归档校验失败:移动操作异常')
@@ -2749,7 +2759,11 @@ async function enforceDepsGate(stageName, cwd, changeName, step, steps, currentI
2749
2759
  const { WorktreeManager } = await import('./worktree.js')
2750
2760
  wm = new WorktreeManager({ cwd })
2751
2761
  meta = wm.getMeta(changeName)
2752
- } catch {}
2762
+ } catch (e) {
2763
+ // WorktreeManager 构造/读取失败(git 不可用 / worktree 元数据损坏)不阻断 deps gate;
2764
+ // 下游已有物理目录存在性判定兜底(G2/R3),这里 warn 留下根因线索,避免静默误诊。
2765
+ console.warn(`⚠️ worktree meta 读取失败(deps gate 将走物理目录判定): ${e.message}`)
2766
+ }
2753
2767
  const depsStatus = meta?.depsStatus
2754
2768
  if (['linked', 'installed', 'n/a'].includes(depsStatus)) return true
2755
2769
  const changeDir = changeName ? join(specBase, 'changes', changeName) : null
@@ -3402,8 +3416,12 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
3402
3416
  pointer.status = POINTER_STATUS.SCAN_COMPLETED
3403
3417
  pointer.completedAt = new Date().toISOString()
3404
3418
  pointer.scanStatus = postResult.status
3405
- writeFileSync(pointerPath, JSON.stringify(pointer, null, 2) + '\n')
3406
- } catch {}
3419
+ writeAtomicSync(pointerPath, JSON.stringify(pointer, null, 2) + '\n')
3420
+ } catch (e) {
3421
+ // 不阻断 scan 主流程,但暴露失败——pointer 写失败会让平台看不到 scan_completed,
3422
+ // 与项目 fail-loud 原则一致:宁可可见地 warn,也不静默吞错。
3423
+ console.warn(`⚠️ 更新平台指针状态失败(scan_completed 可能未落盘): ${e.message}`)
3424
+ }
3407
3425
 
3408
3426
  // failed_post_check 时强制阻止 clean success
3409
3427
  if (postResult.status === 'failed_post_check') {
@@ -7,17 +7,9 @@
7
7
 
8
8
  import { existsSync, readdirSync, readFileSync, mkdirSync, writeFileSync } from 'fs'
9
9
  import { join, basename } from 'path'
10
- import { SCAN_STATUS, CHECK_SEVERITY } from './constants.js'
10
+ import { SCAN_STATUS, CHECK_SEVERITY, SCAN_REQUIRED_DOCS } from './constants.js'
11
11
 
12
- const REQUIRED_SCAN_DOCS = [
13
- 'ARCHITECTURE.md',
14
- 'CONVENTIONS.md',
15
- 'STRUCTURE.md',
16
- 'INTEGRATIONS.md',
17
- 'TESTING.md',
18
- 'CONCERNS.md',
19
- 'PROJECT.md',
20
- ]
12
+ const REQUIRED_SCAN_DOCS = SCAN_REQUIRED_DOCS
21
13
 
22
14
  /**
23
15
  * @param {object} opts
@@ -9,6 +9,7 @@ import { existsSync, readdirSync, readFileSync } from 'fs'
9
9
  import { join, basename } from 'path'
10
10
  import { execFileSync } from 'child_process'
11
11
  import { detectChangeRisk, checkIntegrationEvidence } from './change-risk-profile.js'
12
+ import { SCAN_REQUIRED_DOCS } from './constants.js'
12
13
 
13
14
  /**
14
15
  * 校验结果
@@ -215,15 +216,7 @@ function validateScanOutputs(cwd, changeName, context = {}) {
215
216
  ? join(specBase, 'docs', projectName, 'scan')
216
217
  : join(specBase, 'docs', 'scan')
217
218
 
218
- const requiredDocs = [
219
- 'ARCHITECTURE.md',
220
- 'CONVENTIONS.md',
221
- 'STRUCTURE.md',
222
- 'INTEGRATIONS.md',
223
- 'TESTING.md',
224
- 'CONCERNS.md',
225
- 'PROJECT.md',
226
- ]
219
+ const requiredDocs = SCAN_REQUIRED_DOCS
227
220
 
228
221
  const errors = []
229
222
  const warnings = []
@@ -25,7 +25,7 @@ export const definition = {
25
25
  3. **检查变更名称**:如果当前变更名是自动生成的(如 \`2026-06-02-new-change-a3f2b7c1\`),直接重命名为有意义名称,然后运行 \`sillyspec change-rename <旧名> <新名>\`
26
26
 
27
27
  ### 加载上下文
28
- 4. 读取 CODEBASE-OVERVIEW.md + 共享规范 + 子项目上下文
28
+ 4. 读取项目总览 \`.sillyspec/docs/<project>/scan/PROJECT.md\`(如存在)+ 共享规范 + 子项目上下文
29
29
  5. 加载项目信息:\`cat .sillyspec/projects/*.yaml 2>/dev/null\`
30
30
  6. 加载本地配置:\`cat .sillyspec/local.yaml 2>/dev/null\`
31
31
  7. 棕地项目:读取 scan 下的 STRUCTURE.md、CONVENTIONS.md、ARCHITECTURE.md