sillyspec 3.26.6 → 3.26.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.
Files changed (42) hide show
  1. package/.claude/skills/sillyspec-doctor/SKILL.md +2 -0
  2. package/.claude/skills/sillyspec-execute/SKILL.md +1 -1
  3. package/.claude/skills/sillyspec-knowledge/SKILL.md +1 -1
  4. package/.claude/skills/sillyspec-plan/SKILL.md +1 -1
  5. package/.husky/pre-push +9 -2
  6. package/bin/sillyspec.js +1 -1
  7. package/package.json +3 -3
  8. package/packages/dashboard/server/index.js +8 -1
  9. package/src/config-schema.js +305 -287
  10. package/src/docs-check.js +333 -0
  11. package/src/docs-debt.js +226 -0
  12. package/src/docs-gate.js +106 -0
  13. package/src/doctor-diagnostics.js +23 -2
  14. package/src/index.js +1880 -1750
  15. package/src/init.js +81 -14
  16. package/src/modules.js +490 -486
  17. package/src/progress.js +37 -13
  18. package/src/quicklog.js +19 -4
  19. package/src/run/command.js +54 -24
  20. package/src/run/complete-handlers.js +1254 -1128
  21. package/src/run/complete.js +8 -34
  22. package/src/run/gates.js +32 -3
  23. package/src/run/prompt.js +713 -633
  24. package/src/run/quick-audit.js +137 -123
  25. package/src/run/shared.js +1109 -929
  26. package/src/run/stage.js +8 -3
  27. package/src/scan-staleness.js +90 -0
  28. package/src/setup.js +27 -9
  29. package/src/sillyhub-mcp/client.js +149 -14
  30. package/src/stage-contract-spec.js +15 -0
  31. package/src/stage-contract.js +8 -5
  32. package/src/stages/brainstorm-auto.js +0 -1
  33. package/src/stages/brainstorm.js +1 -1
  34. package/src/stages/execute.js +985 -967
  35. package/src/stages/plan-postcheck.js +93 -36
  36. package/src/stages/verify.js +1 -1
  37. package/src/sync.js +60 -32
  38. package/src/task-review.js +38 -6
  39. package/src/verify-postcheck.js +22 -5
  40. package/src/worktree-apply.js +6 -3
  41. package/src/worktree-deps.js +5 -3
  42. package/src/worktree.js +4 -2
package/src/progress.js CHANGED
@@ -20,26 +20,21 @@ import { ChangeRegistry } from './progress/change-registry.js';
20
20
  import { StepStore } from './progress/step-store.js';
21
21
  import { StageMachine } from './progress/stage-machine.js';
22
22
  import { STAGE_ORDER, MAIN_FLOW_ORDER, VALID_STAGES, STAGE_LABELS, SPEC_DIR_NAME, CURRENT_VERSION, emptyStage } from './progress/shared.js';
23
+ // resolveSpecDir 单一真相源在 src/run/shared.js(含 home 拒绝守卫,坑 cwd-correction-home-collision
24
+ // 根治:home 下 .sillyspec 恒不命中,防 smoke/临时目录污染自我延续)。此处 re-export 保持
25
+ // 既有 import 路径兼容;run/shared.js 对 progress.js 只有动态 import,无静态循环。
26
+ import { resolveSpecDir } from './run/shared.js';
27
+ import { checkPlatformManaged, PLATFORM_MANAGED_FILENAME } from './run/shared.js';
28
+ export { resolveSpecDir };
23
29
 
24
30
  // 默认规范目录名(相对于 cwd)
25
31
  // SPEC_DIR_NAME → ./progress/shared.js(W6 Step9d)
26
32
  const RUNTIME_SUBDIR = '.runtime';
27
33
 
28
34
  /**
29
- * 向上查找含 .sillyspec 目录的祖先目录,类似 git 找 .git 的逻辑。
30
- * 找到则返回 <祖先>/.sillyspec,否则 fallback 到 <cwd>/.sillyspec。
35
+ * 向上查找含 .sillyspec 目录的祖先目录——实现已收敛至 src/run/shared.js(单一真相源,
36
+ * 含 home 拒绝守卫),此处 re-export。见顶部 import 说明。
31
37
  */
32
- export function resolveSpecDir(startDir) {
33
- let dir = resolve(startDir);
34
- while (true) {
35
- const candidate = join(dir, SPEC_DIR_NAME);
36
- if (existsSync(candidate)) return candidate;
37
- const parent = dirname(dir);
38
- if (parent === dir) break; // 到达根目录
39
- dir = parent;
40
- }
41
- return join(resolve(startDir), SPEC_DIR_NAME);
42
- }
43
38
 
44
39
  /**
45
40
  * 平台指针不可达错误。pointer 存在但失效时抛出,阻止静默回退到本地孤儿 db。
@@ -59,6 +54,26 @@ export class PointerUnreachableError extends Error {
59
54
  }
60
55
  }
61
56
 
57
+ /**
58
+ * 平台接管声明生效错误(指针缺失但声明存在)。fail-closed 入口一:
59
+ * 防指针被 cleanup/STALE 清理或项目挪动后裸调静默建本地进度库(状态分裂根因)。
60
+ *
61
+ * ⚠️ 不覆写 this.name(保持父类的 'PointerUnreachableError')——CLI 顶层 catch 按
62
+ * err?.name === 'PointerUnreachableError' 严格字符串匹配(index.js),子类改名会
63
+ * 落通用错误分支打 stack noise。靠 message 首行"平台接管声明生效"区分场景。
64
+ */
65
+ export class PlatformManagedError extends PointerUnreachableError {
66
+ constructor({ declarationPath, specRoot }) {
67
+ super({
68
+ pointerPath: declarationPath,
69
+ specRoot,
70
+ reason: `平台接管声明生效——本项目已由平台托管,但恢复指针缺失(可能被 platform pointer --cleanup 清理或项目目录被移动),拒绝静默回退本地模式`,
71
+ hint: `① 重跑平台 scan/init(带 --spec-root)重建指针;② 确认不再使用平台:sillyspec platform disconnect(删除接管声明);③ 显式 --spec-dir <路径> 临时指定目录`,
72
+ });
73
+ this.declarationPath = declarationPath;
74
+ }
75
+ }
76
+
62
77
  /**
63
78
  * 平台感知的 specDir 解析(fail-closed,所有 CLI 子命令统一入口)。
64
79
  *
@@ -75,6 +90,15 @@ export function resolvePlatformSpecDir(cwd, explicitSpecDir = null) {
75
90
  if (explicitSpecDir) return resolve(explicitSpecDir);
76
91
  const pointerPath = join(resolve(cwd), '.sillyspec-platform.json');
77
92
  if (!existsSync(pointerPath)) {
93
+ // 指针缺失:查平台接管声明(fail-closed 入口一)。声明存在 = 项目进过平台模式,
94
+ // 指针丢失不该静默当纯本地项目处理(会建本地进度库 → 状态分裂)。
95
+ const decl = checkPlatformManaged(cwd);
96
+ if (decl) {
97
+ throw new PlatformManagedError({
98
+ declarationPath: join(resolve(cwd), PLATFORM_MANAGED_FILENAME),
99
+ specRoot: decl.specRoot,
100
+ });
101
+ }
78
102
  return resolveSpecDir(cwd);
79
103
  }
80
104
  // pointer 存在 = 进过平台模式,严格校验,不静默回退
package/src/quicklog.js CHANGED
@@ -81,6 +81,14 @@ export function sanitizeDesc(description) {
81
81
  return s.length > 120 ? s.slice(0, 120) + '…' : s
82
82
  }
83
83
 
84
+ // git user.name → 文件名安全形式:白名单保留字母数字._- 与中文等 Unicode 字,
85
+ // 剥路径元字符(/ \ ..)与控制字符。防 QUICKLOG-<user>.md / 锁文件 / 轮转归档穿越写。
86
+ export function sanitizeQuicklogUser(user) {
87
+ const s = String(user || '').replace(/[\\/\r\n\0]/g, '').replace(/\.{2,}/g, '.').trim()
88
+ if (!s) return ''
89
+ return s.length > 64 ? s.slice(0, 64) : s
90
+ }
91
+
84
92
  // 从关联变更的 proposal/design 提取首个 # 标题(去「提案书(Proposal)— / 设计文档(Design)—」固定前缀)。
85
93
  // 让 quick --linked-changes 启动时(用户常不带 --input)也能拿到语义标题,而非 (quick 任务) 占位。
86
94
  // 读不到任何标题返回 ''(调用方再回退占位符,保持向后兼容)。
@@ -134,6 +142,9 @@ export function getQuickFileNotes() { return _pendingFileNotes }
134
142
  */
135
143
  export function parseFileNotes(raw) {
136
144
  if (!raw) return []
145
+ // note 单行化 + 限长:bullet 是「数组元素内嵌 \n」落盘(flipEntryInContent),note 带换行会伪造
146
+ // QUICKLOG 条目结构(\n## ql-… 可伪造后续条目头)。与 sanitizeDesc 同风格。
147
+ const sanitizeNote = (n) => n.replace(/[\r\n]+/g, ' ').trim().slice(0, 200)
137
148
  return String(raw)
138
149
  .split('||')
139
150
  .map((seg) => {
@@ -141,7 +152,7 @@ export function parseFileNotes(raw) {
141
152
  if (!s) return null
142
153
  const idx = s.indexOf('::')
143
154
  const path = (idx === -1 ? s : s.slice(0, idx)).replace(/\\/g, '/').trim()
144
- const note = idx === -1 ? '' : s.slice(idx + 2).trim()
155
+ const note = idx === -1 ? '' : sanitizeNote(s.slice(idx + 2))
145
156
  return path ? { path, note } : null
146
157
  })
147
158
  .filter(Boolean)
@@ -234,7 +245,7 @@ async function rotateIfNeeded(userFile, gitUser) {
234
245
  const dateStr = lastDate
235
246
  ? `${lastDate.slice(0, 4)}-${lastDate.slice(4, 6)}-${lastDate.slice(6, 8)}`
236
247
  : nowDatetime().slice(0, 10)
237
- const archiveFile = join(dirname(userFile), `QUICKLOG-${gitUser}-${dateStr}.md`)
248
+ const archiveFile = join(dirname(userFile), `QUICKLOG-${sanitizeQuicklogUser(gitUser) || 'unknown'}-${dateStr}.md`)
238
249
  // 同日已轮转过:两步各自原子化(append+clear 无法单原子,reader 容忍中间态——见 writeAtomic 注释)
239
250
  if (existsSync(archiveFile)) {
240
251
  const existing = readFileSync(archiveFile, 'utf8')
@@ -394,7 +405,9 @@ function flipEntryInContent(content, qlId, result, changedFiles = [], fileNotes
394
405
  export async function allocateQuicklogEntry(specBase, gitUser, { description, linkedChanges = [], allowedFiles = [] } = {}) {
395
406
  const quicklogDir = join(specBase, 'quicklog')
396
407
  mkdirSync(quicklogDir, { recursive: true })
397
- const user = gitUser || 'unknown'
408
+ // git user.name 无字符限制,可含 / \ .. 等路径元字符(git config 或 .git/config 可控)——
409
+ // 白名单消毒防穿越写(QUICKLOG-<user>.md / 锁文件 / 轮转归档三处拼接),与 assertSafeChangeName 同风格
410
+ const user = sanitizeQuicklogUser(gitUser) || 'unknown'
398
411
  const userFile = join(quicklogDir, `QUICKLOG-${user}.md`)
399
412
  const lockPath = join(quicklogDir, `.QUICKLOG-${user}.md.lock`)
400
413
  const desc = sanitizeDesc(description)
@@ -437,7 +450,9 @@ export async function allocateQuicklogEntry(specBase, gitUser, { description, li
437
450
  */
438
451
  export async function completeQuicklogEntry(specBase, gitUser, qlId, { resultText = '', linkedChanges = [], changedFiles = [] } = {}) {
439
452
  const quicklogDir = join(specBase, 'quicklog')
440
- const lockPath = join(quicklogDir, `.QUICKLOG-${(gitUser || 'unknown')}.md.lock`)
453
+ // 与 allocateQuicklogEntry 同源消毒:锁文件路径含 user(防穿越写,两入口必须一致否则锁不上同一文件)
454
+ const user = sanitizeQuicklogUser(gitUser) || 'unknown'
455
+ const lockPath = join(quicklogDir, `.QUICKLOG-${user}.md.lock`)
441
456
  const result = sanitizeResult(resultText)
442
457
  const linked = Array.isArray(linkedChanges) ? linkedChanges : []
443
458
  // 实际改动文件(调用方 complete-handlers 已用 isQuickMetadata 过滤 quick 自身元数据)。空 → 不动文件行。
@@ -23,7 +23,7 @@ import { join, resolve, dirname } from 'node:path'
23
23
  import { existsSync, readdirSync, mkdirSync, writeFileSync, readFileSync, rmSync } from 'node:fs'
24
24
  import { randomBytes, randomUUID } from 'node:crypto'
25
25
  import { writeAtomicSync } from '../fs-atomic.js'
26
- import { resolveSpecDir, countAncestorSpecDirs, resolveChangeDir, triggerSync, getStageSteps, formatWaitOptions, checkApproval, didYouMean, assertSafeChangeName, detectQuickSessionDrift, detectWorktreeSpecDrift, resolveRuntimeRoot } from './shared.js'
26
+ import { resolveSpecDir, countAncestorSpecDirs, resolveChangeDir, triggerSync, getStageSteps, formatWaitOptions, checkApproval, didYouMean, assertSafeChangeName, detectQuickSessionDrift, detectWorktreeSpecDrift, resolveRuntimeRoot, writePlatformPointer, checkPlatformManaged, PLATFORM_MANAGED_FILENAME } from './shared.js'
27
27
  import { resolveQuickLinkedChanges } from './quick-audit.js'
28
28
  import { outputStep } from './prompt.js'
29
29
  import { completeStep, skipStep, waitStep, continueStep } from './complete.js'
@@ -125,6 +125,31 @@ export async function ensureStageSteps(progress, stageName, cwd, specDir = null)
125
125
  return false
126
126
  }
127
127
 
128
+ /**
129
+ * run <stage> --help/-h 的用法帮助(runCommand 内 --help 短路调用)。
130
+ * 只打印不落盘:帮助查询不该有副作用(建会话/写 QUICKLOG)。
131
+ */
132
+ function printStageUsage(stageName) {
133
+ const stage = stageRegistry[stageName]
134
+ console.log(`
135
+ sillyspec run ${stageName}${stage && stage.title ? ` — ${stage.title}` : ''}${stage && stage.description ? `\n ${stage.description}` : ''}
136
+
137
+ 用法:
138
+ sillyspec run ${stageName} 开始/继续该阶段(输出下一步 prompt)
139
+ sillyspec run ${stageName} --status 查看阶段进度
140
+ sillyspec run ${stageName} --done --output "摘要" [--input "用户原话"] 完成当前步骤
141
+ sillyspec run ${stageName} --change <名> 指定变更(多活跃变更必填)
142
+
143
+ 通用参数: --done --output --input --status --skip --reset --reopen --from-step
144
+ --wait --continue --answer --change --spec-dir --non-interactive
145
+ --interactive --skip-approval --json(不支持)
146
+ quick 专属: --linked-changes none|a,b --files a.js,b.js --allow-new
147
+ --allow-delete --force-baseline --confirm --file-notes
148
+ scan 专属: --quick --standard --deep --force-rescan
149
+ archive专属: --confirm
150
+ `)
151
+ }
152
+
128
153
  /**
129
154
  * sillyspec run <stage> 主命令
130
155
  */
@@ -289,31 +314,27 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
289
314
  }
290
315
  }
291
316
  }
317
+ // ── 平台接管声明 fail-closed(入口二,D-B@v2)──
318
+ // runCommand 有独立指针恢复链(不经 resolvePlatformSpecDir),指针缺失时若不在此
319
+ // 封堵,run/quick/scan 等全部 stage 命令会静默落本地库(与入口一行为分裂)。
320
+ // 触发条件:命令行没传平台参数 + 指针与 platform-scan.json 皆未命中 + 接管声明存在。
321
+ // 显式 --spec-dir/--spec-root 传参时 platformOpts.specRoot 已赋值,天然不进此分支(逃生口)。
322
+ if (!platformOpts.specRoot && !platformOpts.runtimeRoot && !platformFileExists) {
323
+ const decl = checkPlatformManaged(cwd)
324
+ if (decl) {
325
+ console.error(`❌ 平台接管声明生效:本项目已由平台托管(原 specRoot: ${decl.specRoot || '(未记录)'}),但恢复指针缺失,拒绝静默回退本地模式。`)
326
+ console.error(` 声明文件: ${join(cwd, PLATFORM_MANAGED_FILENAME)}`)
327
+ console.error(' 恢复:① 重跑平台 scan/init(带 --spec-root)重建指针;② 确认不再使用平台:sillyspec platform disconnect(删除接管声明);③ 显式 --spec-dir <路径> 临时指定目录。')
328
+ // exit(1) 而非邻近环境错的 exit(2):这是"状态保护阻断"(对齐 PointerUnreachableError
329
+ // 顶层 catch 语义),非"用法/环境错"——见 design.md §5.3 v2 复审观察 (a)
330
+ process.exit(1)
331
+ }
332
+ }
292
333
  // 持久化 platformOpts
293
- // 在 specRoot/.runtime/ 写主文件,同时在 cwd/.sillyspec/.runtime/ 写恢复指针
334
+ // 在 specRoot/.runtime/ 写主文件,同时在 cwd 下写恢复指针
335
+ // (writePlatformPointer 收敛双写逻辑,cmdInit 平台模式共用,防两处字段漂移)
294
336
  if (platformOpts.specRoot || platformOpts.runtimeRoot) {
295
- try {
296
- const { mkdirSync, writeFileSync } = await import('fs')
297
- mkdirSync(join(specRoot, '.runtime'), { recursive: true })
298
- writeFileSync(platformOptsFile, JSON.stringify({
299
- specRoot: platformOpts.specRoot,
300
- runtimeRoot: platformOpts.runtimeRoot,
301
- workspaceId: platformOpts.workspaceId,
302
- scanRunId: platformOpts.scanRunId,
303
- savedAt: new Date().toISOString(),
304
- }, null, 2) + '\n')
305
- // 恢复指针:在 cwd 下写 .sillyspec-platform.json(不在 .sillyspec 内,不污染源码结构)
306
- // 供后续 --done(不带 --spec-root)找到 specDir
307
- writeFileSync(join(cwd, '.sillyspec-platform.json'), JSON.stringify({
308
- specRoot: platformOpts.specRoot,
309
- runtimeRoot: platformOpts.runtimeRoot,
310
- workspaceId: platformOpts.workspaceId,
311
- scanRunId: platformOpts.scanRunId,
312
- savedAt: new Date().toISOString(),
313
- }, null, 2) + '\n')
314
- } catch {
315
- // 静默失败,不影响主流程
316
- }
337
+ writePlatformPointer(cwd, platformOpts)
317
338
  }
318
339
 
319
340
  // 统一规范基路径:平台模式用 specRoot,本地模式用 cwd/.sillyspec
@@ -513,6 +534,7 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
513
534
  '--json', '--dir', '--help',
514
535
  '--reopen', '--from-step', '--mode',
515
536
  '--deep', '--quick', '--standard', // scan profile 三档显式选择(scan-profile.js 从 argv 读;互斥见下方 PROFILE_FLAGS 检测)
537
+ '-h',
516
538
  ])
517
539
  for (let i = 0; i < flags.length; i++) {
518
540
  const f = flags[i]
@@ -531,6 +553,14 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
531
553
  }
532
554
  }
533
555
 
556
+ // ── --help/-h 短路:flag 校验通过后、任何副作用(cwd 纠正/会话创建/QUICKLOG 落盘)之前 ──
557
+ // --help 此前在 knownFlags 白名单里被静默吞掉,run quick --help 会误开 quick 会话+写骨架
558
+ // 条目(查询意图不该有副作用);-h 更是被当未知参数 exit 2。帮助查询 = 用法展示 = 退出 0。
559
+ if (flags.includes('--help') || flags.includes('-h')) {
560
+ printStageUsage(stageName)
561
+ process.exit(0)
562
+ }
563
+
534
564
  const isAuxiliary = auxiliaryStages.includes(stageName)
535
565
  // scan 元数据追踪(存储在 stageData.scanMeta 中,completeStep 通过 progress 访问)
536
566