sillyspec 3.33.0 → 3.33.1

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 (62) hide show
  1. package/.claude/skills/sillyspec-flow/SKILL.md +1 -1
  2. package/.github/workflows/ci.yml +11 -3
  3. package/AGENTS.md +1 -1
  4. package/assets/command-cards/flow.md +1 -1
  5. package/package.json +1 -1
  6. package/src/archive-delta.js +4 -3
  7. package/src/change-delete.js +6 -5
  8. package/src/contract-matrix.js +2 -1
  9. package/src/cross-placement.js +5 -1
  10. package/src/db.js +8 -2
  11. package/src/deleted-ledger.js +2 -1
  12. package/src/doctor-diagnostics.js +8 -3
  13. package/src/flow-draft.js +2 -1
  14. package/src/flow.js +111 -62
  15. package/src/friction-ledger.js +2 -1
  16. package/src/friction-tally.js +8 -4
  17. package/src/handoff.js +2 -1
  18. package/src/hooks/worktree-guard.js +5 -5
  19. package/src/index.js +41 -7
  20. package/src/knowledge-hits.js +2 -1
  21. package/src/knowledge-vector.js +2 -1
  22. package/src/progress/consistency-doctor.js +5 -2
  23. package/src/progress/stage-machine.js +6 -2
  24. package/src/progress.js +19 -8
  25. package/src/quicklog.js +194 -9
  26. package/src/review-dispatch.js +4 -3
  27. package/src/review-tier.js +2 -1
  28. package/src/route-hindsight.js +4 -3
  29. package/src/run/bg-sync.js +10 -3
  30. package/src/run/command.js +13 -11
  31. package/src/run/complete-handlers.js +40 -37
  32. package/src/run/complete.js +12 -8
  33. package/src/run/gate-snapshot-ledger.js +2 -1
  34. package/src/run/gate-snapshot.js +57 -11
  35. package/src/run/gates.js +31 -7
  36. package/src/run/prompt.js +6 -5
  37. package/src/run/scan-profile.js +2 -1
  38. package/src/run/shared.js +109 -5
  39. package/src/run/stage.js +5 -4
  40. package/src/run/test-ledger.js +2 -1
  41. package/src/run/verify-quality-scan.js +11 -5
  42. package/src/runtime-paths.js +189 -0
  43. package/src/scan-refresh.js +4 -3
  44. package/src/scope-audit.js +7 -6
  45. package/src/sentinel-assertions.js +37 -0
  46. package/src/spec-sync.js +38 -22
  47. package/src/stage-contract.js +24 -2
  48. package/src/stage-review.js +3 -2
  49. package/src/stages/brainstorm.js +1 -1
  50. package/src/sync-noise.js +3 -2
  51. package/src/sync.js +71 -15
  52. package/src/task-done.js +2 -1
  53. package/src/task-review.js +7 -5
  54. package/src/task-tick.js +23 -1
  55. package/src/validate-artifacts.js +2 -1
  56. package/src/verify-draft.js +2 -1
  57. package/src/verify-postcheck.js +11 -10
  58. package/src/verify-probes.js +157 -52
  59. package/src/watcher.js +15 -9
  60. package/src/worktree-apply.js +2 -1
  61. package/src/write-audit.js +2 -1
  62. package/templates/agents-instruction.md +1 -1
@@ -31,6 +31,7 @@
31
31
  * 本模块自身不打印任何输出。
32
32
  */
33
33
  import { readFileSync, mkdirSync } from 'fs'
34
+ import { runtimeStatePath } from './runtime-paths.js'
34
35
  import { dirname, join } from 'path'
35
36
  import { writeAtomicSync } from './fs-atomic.js'
36
37
  import { withFileLock } from './quicklog.js'
@@ -45,7 +46,7 @@ const LEDGER_CAP = 200
45
46
  const LEDGER_FILENAME = 'friction-ledger.json'
46
47
 
47
48
  function ledgerFilePath(runtimeRoot) {
48
- return join(runtimeRoot, LEDGER_FILENAME)
49
+ return runtimeStatePath(runtimeRoot, LEDGER_FILENAME)
49
50
  }
50
51
 
51
52
  /**
@@ -24,6 +24,7 @@
24
24
  * 不打印任何输出,提示何时打印由调用方决定。
25
25
  */
26
26
  import { existsSync, mkdirSync, readFileSync, rmSync } from 'fs'
27
+ import { runtimeStatePath } from './runtime-paths.js'
27
28
  import { dirname, join } from 'path'
28
29
  import { writeAtomicSync } from './fs-atomic.js'
29
30
  import { withFileLock } from './quicklog.js'
@@ -76,7 +77,7 @@ function frictionTallyPath({ cwd, platformOpts, changeName }) {
76
77
  if (QUICK_SESSION_ID_RE.test(changeName)) {
77
78
  return join(resolveQuickSessionsDir(platformOpts, specBase), changeName, 'friction-tally.json')
78
79
  }
79
- return join(resolveRuntimeRoot(platformOpts, specBase), `friction-tally-${changeName}.json`)
80
+ return runtimeStatePath(resolveRuntimeRoot(platformOpts, specBase), `friction-tally-${changeName}.json`)
80
81
  }
81
82
 
82
83
  /** YAML 布尔值宽松解析:仅显式 false(含引号形态)为关,其余值一律默认开(R-05) */
@@ -174,10 +175,12 @@ export function renderFrictionHintLine(counts) {
174
175
  export async function recordFrictionEvent({ cwd, platformOpts, changeName, type, detail }) {
175
176
  try {
176
177
  if (!FRICTION_TYPES.has(type)) return null
178
+ // enabled=false 直通:record/consume 双 no-op,.runtime 零写入(D-003 一键全关)。
179
+ // 必须先于 frictionTallyPath(2026-10-18 起路径助手带 mkdir-p 目录保证,关闭态
180
+ // 构造路径即建 state/ 目录=破坏零写入契约)
181
+ if (!readFrictionHintEnabled(deriveSpecBase(cwd, platformOpts))) return null
177
182
  const tallyPath = frictionTallyPath({ cwd, platformOpts, changeName })
178
183
  if (!tallyPath) return null
179
- // enabled=false 直通:record/consume 双 no-op,.runtime 零写入(D-003 一键全关)
180
- if (!readFrictionHintEnabled(deriveSpecBase(cwd, platformOpts))) return null
181
184
  return await withFileLock(tallyPath + ".lock", async () => {
182
185
  const tally = readFrictionTally(tallyPath)
183
186
  const at = new Date().toISOString()
@@ -207,9 +210,10 @@ export async function recordFrictionEvent({ cwd, platformOpts, changeName, type,
207
210
  */
208
211
  export async function consumeFrictionHint({ cwd, platformOpts, changeName }) {
209
212
  try {
213
+ // enabled 检查先于路径构造(同 record:关闭态零写入,路径助手 mkdir-p 不触发)
214
+ if (!readFrictionHintEnabled(deriveSpecBase(cwd, platformOpts))) return { hint: null, counts: {} }
210
215
  const tallyPath = frictionTallyPath({ cwd, platformOpts, changeName })
211
216
  if (!tallyPath || !existsSync(tallyPath)) return { hint: null, counts: {} }
212
- if (!readFrictionHintEnabled(deriveSpecBase(cwd, platformOpts))) return { hint: null, counts: {} }
213
217
  return await withFileLock(tallyPath + ".lock", async () => {
214
218
  const tally = readFrictionTally(tallyPath)
215
219
  const counts = {}
package/src/handoff.js CHANGED
@@ -15,6 +15,7 @@
15
15
  * 整块 80 行帽(HANDOFF_LINE_CAP),超限从上下文段尾截,粘贴块与警示行永不动。
16
16
  */
17
17
  import { ProgressManager, resolveSpecDir } from './progress.js';
18
+ import { runtimeStatePath } from './runtime-paths.js'
18
19
  import { MAIN_FLOW_ORDER, STAGE_LABELS } from './progress/shared.js';
19
20
  import { readFileSync, existsSync } from 'node:fs';
20
21
  import { join } from 'node:path';
@@ -199,7 +200,7 @@ export async function buildHandoff({ cwd, specBase, changeName } = {}) {
199
200
  // 保持不变——SESSION_ID 相同不能当硬续证据)。本命令执行即切割:best-effort 给
200
201
  // stage-session ledger 盖 handoffAt 章,墙据此放行新会话的 execute/verify 首入。
201
202
  try {
202
- const ledgerPath = join(specRoot, '.runtime', `stage-session-ledger-${target}.json`);
203
+ const ledgerPath = runtimeStatePath(join(specRoot, '.runtime'), `stage-session-ledger-${target}.json`);
203
204
  let ledger = null;
204
205
  try { ledger = JSON.parse(readFileSync(ledgerPath, 'utf8')); } catch { /* 无账本(阶段未收口)→ 无墙可放行,零动作 */ }
205
206
  if (ledger && typeof ledger === 'object') {
@@ -61,7 +61,7 @@ const STAGE_HINTS = {
61
61
  '你需要先启动一个任务流程才能修改源码(调用对应的 sillyspec skill):',
62
62
  '',
63
63
  ' 轻量变更/BUG修复(默认快道,skill sillyspec-flow)——--input 引号内换行合法,下例可照抄:',
64
- ' sillyspec flow start --change <YYYY-MM-DD-名> --input "<一句中文概括(首行即变更标题);随后动机与背景',
64
+ ' sillyspec flow start --change <YYYY-MM-DD-名> --input "<一句 10-30 字中文概括(首行即变更标题,硬区间 10-30 必须、建议 ~20 字);随后动机与背景',
65
65
  '',
66
66
  ' 成功标准:',
67
67
  ' - <可验证标准>"',
@@ -80,7 +80,7 @@ const STAGE_HINTS = {
80
80
  'verify': [
81
81
  '当前在 verify(验证)阶段,只做代码审查和测试验证,不修改源码。',
82
82
  '如需修改,请先回到 execute 阶段,或收口后开轻量变更(--input 引号内换行合法,可照抄):',
83
- ' sillyspec flow start --change <YYYY-MM-DD-名> --input "<一句中文概括(首行即变更标题);随后动机与背景',
83
+ ' sillyspec flow start --change <YYYY-MM-DD-名> --input "<一句 10-30 字中文概括(首行即变更标题,硬区间 10-30 必须、建议 ~20 字);随后动机与背景',
84
84
  '',
85
85
  ' 成功标准:',
86
86
  ' - <可验证标准>"',
@@ -88,7 +88,7 @@ const STAGE_HINTS = {
88
88
  'archive': [
89
89
  '当前在 archive(归档)阶段,不修改源码。',
90
90
  '如需修改,请开启新变更(--input 引号内换行合法,可照抄):',
91
- ' sillyspec flow start --change <YYYY-MM-DD-名> --input "<一句中文概括(首行即变更标题);随后动机与背景',
91
+ ' sillyspec flow start --change <YYYY-MM-DD-名> --input "<一句 10-30 字中文概括(首行即变更标题,硬区间 10-30 必须、建议 ~20 字);随后动机与背景',
92
92
  '',
93
93
  ' 成功标准:',
94
94
  ' - <可验证标准>"',
@@ -190,8 +190,8 @@ function getScanDocInfo(filePath) {
190
190
 
191
191
  function readScanGuard(scanDocInfo, projectRoot) {
192
192
  const candidates = [
193
- path.join(scanDocInfo.specRoot, '.runtime', 'scan-guard.json'),
194
- path.join(projectRoot, '.sillyspec', '.runtime', 'scan-guard.json'),
193
+ path.join(scanDocInfo.specRoot, '.runtime', 'state', 'scan-guard.json'),
194
+ path.join(projectRoot, '.sillyspec', '.runtime', 'state', 'scan-guard.json'),
195
195
  ]
196
196
  for (const p of candidates) {
197
197
  if (!existsSync(p)) continue
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, unlinkSync } from 'fs';
10
+ import { runtimeStatePath } from './runtime-paths.js'
10
11
  import { writeAtomicSync } from './fs-atomic.js';
11
12
  import { basename, dirname, extname, join, resolve, isAbsolute, sep, relative } from 'path';
12
13
  import { safeGit, git, gitQuiet } from './git-helper.js';
@@ -173,7 +174,7 @@ SillySpec CLI — 规范驱动开发工具包
173
174
  示例:
174
175
  sillyspec init
175
176
  sillyspec run brainstorm --change 2026-07-03-add-login
176
- sillyspec flow start --change 2026-07-03-my-fix --input "<一句中文概括(首行即变更标题)+动机与背景详述+独立一行『成功标准:』+每行一条『- 可验证标准』>" # 轻量变更(默认快道,2 次调用;变更名须 YYYY-MM-DD-<简短描述> 日期前缀)
177
+ sillyspec flow start --change 2026-07-03-my-fix --input "<一句 10-30 字中文概括(首行即变更标题,硬区间 10-30 必须、建议 ~20 字)+动机与背景详述+独立一行『成功标准:』+每行一条『- 可验证标准』>" # 轻量变更(默认快道,2 次调用;变更名须 YYYY-MM-DD-<简短描述> 日期前缀)
177
178
  sillyspec run verify --done --output "验证通过,测试全绿"
178
179
  sillyspec run archive --done --confirm --output "归档完成"
179
180
  sillyspec run plan --reopen --from-step 2 # 修订 plan,从第 2 步重做
@@ -228,7 +229,7 @@ function refuseRetiredQuickFreshStart(stageArgs) {
228
229
  console.error('❌ quick 通道已退役,不再接受新会话(v3.30.0 起)。')
229
230
  // 可照抄实例(2026-10-05-input-teach-copyable):引号内换行合法,行级 trim 容忍缩进
230
231
  console.error(' 新工作请走轻量变更(--input 引号内换行合法,下例可照抄):')
231
- console.error(' sillyspec flow start --change <YYYY-MM-DD-名> --input "<一句中文概括(首行即变更标题);随后动机与背景')
232
+ console.error(' sillyspec flow start --change <YYYY-MM-DD-名> --input "<一句 10-30 字中文概括(首行即变更标题,硬区间 10-30 必须、建议 ~20 字);随后动机与背景')
232
233
  console.error('')
233
234
  console.error(' 成功标准:')
234
235
  console.error(' - <可验证标准>"')
@@ -323,6 +324,26 @@ async function main() {
323
324
  process.exit(2);
324
325
  }
325
326
 
327
+ // ── 旧布局迁移(2026-10-18 runtime-relayout):per-change 散文件入 state/、可删产物入 temp/。
328
+ // 每次入口跑一遍(幂等:已收敛后 readdir 一轮零写入);fail-open 绝不阻断命令。
329
+ // init 除外——目标仓尚无 .sillyspec 可迁。resolveEffectiveDir 是函数声明(提升后可调)。
330
+ if (filteredArgs[0] !== 'init') {
331
+ try {
332
+ const { migrateRuntimeLayout } = await import('./runtime-paths.js')
333
+ const roots = new Set()
334
+ const direct = join(specDir || join(targetDir, '.sillyspec'), '.runtime')
335
+ roots.add(direct)
336
+ if (!existsSync(direct)) {
337
+ try { roots.add(join(resolveEffectiveDir(targetDir), '.sillyspec', '.runtime')) } catch { /* cwd 无仓 = 无可迁 */ }
338
+ }
339
+ try {
340
+ const po = resolvePlatformOpts(targetDir)
341
+ if (po && po.runtimeRoot) roots.add(po.runtimeRoot)
342
+ } catch { /* 平台指针不可达 = 无平台侧可迁 */ }
343
+ for (const rt of roots) migrateRuntimeLayout(rt)
344
+ } catch { /* 迁移失败不阻断任何命令 */ }
345
+ }
346
+
326
347
  const command = filteredArgs[0];
327
348
  // 支持 sillyspec init /path/to/project 语法:如果第二个参数看起来像路径,当作 targetDir
328
349
  if (command === 'init' && filteredArgs[1] && !filteredArgs[1].startsWith('-')) {
@@ -5371,10 +5392,10 @@ SillySpec config — local.yaml 配置键速查
5371
5392
  'sillyspec.db': '进度库 SQLite(stages/steps/changes 表)',
5372
5393
  'sillyspec.db.bak': '进度库备份(sql.js 时代写前快照遗留,node:sqlite 不再写入,仅 _openWithFallback 向后兼容恢复用)',
5373
5394
  'sillyspec.db.schema-version': '进度库 schema 版本标记',
5374
- 'doctor-diagnosis.json': 'doctor --json 结构化诊断快照',
5395
+ 'doctor-diagnosis.json': 'doctor --json 结构化诊断快照(可再生,落 temp/ 子目录)',
5375
5396
  'doctor-dumps': 'doctor --dump-db 取证输出目录',
5376
5397
  'workflow-runs': 'workflow check --save 归档目录',
5377
- 'scan-guard.json': 'scan 覆盖保护记录',
5398
+ 'scan-guard.json': 'scan 覆盖保护记录(2026-10-18 起落 state/ 子目录)',
5378
5399
  'quick-sessions': 'quick 会话记录目录',
5379
5400
  'quick-guard.json': 'quick baseline 守卫(旧位置)',
5380
5401
  'worktrees': 'worktree meta(hooks 侧)',
@@ -5388,6 +5409,8 @@ SillySpec config — local.yaml 配置键速查
5388
5409
  'history': '历史记录目录(init 预建)',
5389
5410
  'logs': '日志目录(init 预建)',
5390
5411
  'templates': '工作流模板目录(init 预建)',
5412
+ 'state': '按 change 命名的派生状态目录(watcher 事件流/账本/冲突标记等,归档时随变更精确回收)',
5413
+ 'temp': '可随时整体删除的临时产物(日志/db 备份/锁残留 tmp——删后自动重建,无状态语义)',
5391
5414
  };
5392
5415
  let entries;
5393
5416
  try {
@@ -5429,7 +5452,7 @@ SillySpec config — local.yaml 配置键速查
5429
5452
  SillySpec pull — 拉取服务器 spec 快照到本地(X2 / FR-07)
5430
5453
 
5431
5454
  用法:
5432
- sillyspec pull --spec [--force]
5455
+ sillyspec pull --spec [--force] [--restore-deleted]
5433
5456
 
5434
5457
  说明:
5435
5458
  从 SillyHub 平台拉取当前工作区的 .sillyspec 整树快照(tar 解压到 specDir,
@@ -5444,6 +5467,10 @@ SillySpec pull — 拉取服务器 spec 快照到本地(X2 / FR-07)
5444
5467
  本命令不自动同步、不在会话中自动刷新;拉取后本地的改动仍需正常推送。
5445
5468
  daemon 模式下任务/会话开始时会按 spec_version 自动取新,无需手动跑本命令。
5446
5469
 
5470
+ 防复活抑制(默认开启):本地已删除、但删除尚未同步到服务器的文件(基线快照有、
5471
+ 盘面无),不会从快照写回本地(防「同步失败窗口内删除的任务卡被服务器旧文件复活」);
5472
+ 确要从快照恢复这类文件用 --restore-deleted(纯快照回放语义)。
5473
+
5447
5474
  拉取进度六表(多机进度同步)请用:sillyspec platform pull [--change <name>]
5448
5475
  `);
5449
5476
  if (pullWantsHelp || !pullWantsSpec) {
@@ -5456,6 +5483,10 @@ SillySpec pull — 拉取服务器 spec 快照到本地(X2 / FR-07)
5456
5483
  }
5457
5484
 
5458
5485
  const pullSpecForce = pullSpecArgs.includes('--force');
5486
+ // 防复活抑制(坑 spec-tree-file-resurrect)的显式恢复通道:默认把「基线有、盘面无」的
5487
+ // 本地已删文件挡在快照回放外(删除 op 未上行成功时不被服务器旧文件复活);
5488
+ // --restore-deleted = 用户显式意志优先,恢复纯快照回放语义。
5489
+ const pullSpecRestoreDeleted = pullSpecArgs.includes('--restore-deleted');
5459
5490
  const syncModule = await import('./sync.js');
5460
5491
  // 未连接明确提示不崩(对齐 platform pull :2289 先例)
5461
5492
  const smForSpecPull = new syncModule.SyncManager(dir);
@@ -5463,10 +5494,13 @@ SillySpec pull — 拉取服务器 spec 快照到本地(X2 / FR-07)
5463
5494
  console.error('❌ 未连接平台,请先 sillyspec platform connect');
5464
5495
  process.exit(1);
5465
5496
  }
5466
- const specR = await syncModule.pullSpecBundle(dir, { force: pullSpecForce, specDir });
5497
+ const specR = await syncModule.pullSpecBundle(dir, { force: pullSpecForce, specDir, restoreDeleted: pullSpecRestoreDeleted });
5467
5498
  if (specR.ok && specR.pulled) {
5468
5499
  console.log(`✅ 已拉取 spec 快照 → ${specR.specDir}${specR.specVersion ? `(平台 spec_version: ${specR.specVersion})` : ''}`);
5469
5500
  console.log(' 快照语义:打包时刻快照非实时,无自动同步;本地后续改动仍需正常推送。');
5501
+ if (Array.isArray(specR.resurrectSkipped) && specR.resurrectSkipped.length > 0) {
5502
+ console.log(' 防复活抑制已生效:本地已删除文件未从快照恢复(基线有、盘面无=本地删除尚未上行成功,抑制防被服务器旧文件复活);确要从快照恢复用 --restore-deleted。');
5503
+ }
5470
5504
  } else if (/非空/.test(specR.reason || '')) {
5471
5505
  console.error(`❌ ${specR.reason}`);
5472
5506
  console.error(' 确认整树替换请加 --force(先删除本地 .sillyspec 整树再解包,进度 DB 等本地文件一并丢失;连接凭据 local.yaml 保留,无需重新 connect)');
@@ -5529,7 +5563,7 @@ SillySpec pull — 拉取服务器 spec 快照到本地(X2 / FR-07)
5529
5563
  }
5530
5564
  if (!tRes.exists) {
5531
5565
  console.error(`📭 ${tChange} 无事件流(未跑过 watcher),无从合成时间线`);
5532
- console.error(` 事件流路径:${join(tRuntimeRoot, `watcher-events-${tChange}.jsonl`)};活跃变更跑任意 flow/run 命令会拉起 watcher`);
5566
+ console.error(` 事件流路径:${runtimeStatePath(tRuntimeRoot, `watcher-events-${tChange}.jsonl`)};活跃变更跑任意 flow/run 命令会拉起 watcher`);
5533
5567
  process.exit(2);
5534
5568
  }
5535
5569
  const { changeDir, tasksMd, tickLog } = loadChangeTasks(tSpecBase, tChange);
@@ -12,12 +12,13 @@
12
12
  */
13
13
 
14
14
  import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'fs'
15
+ import { runtimeStatePath } from './runtime-paths.js'
15
16
  import { join } from 'path'
16
17
 
17
18
  const HITS_FILENAME = 'knowledge-hits.jsonl'
18
19
 
19
20
  function hitsFilePath(runtimeDir) {
20
- return join(runtimeDir, HITS_FILENAME)
21
+ return runtimeStatePath(runtimeDir, HITS_FILENAME)
21
22
  }
22
23
 
23
24
  /**
@@ -35,6 +35,7 @@
35
35
  * 404 另有进程级死信短路(端点死信不再空转探测)。
36
36
  */
37
37
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
38
+ import { runtimeStatePath } from './runtime-paths.js'
38
39
  import { join, dirname } from 'path'
39
40
  import { readPlatformConfig } from './sync.js'
40
41
  import { matchByRouting, fallbackByQueryShingles, parseDecisionEntries } from './knowledge-match.js'
@@ -49,7 +50,7 @@ const CAPABILITY_RETRY_MS = 60 * 60 * 1000
49
50
  // 机器态的标准落点(knowledge-hits 同款)。写盘全 fail-soft;按平台 URL 匹配(换平台即失效)。
50
51
 
51
52
  function capabilityCachePath(cwd) {
52
- return join(cwd, '.sillyspec', '.runtime', 'vector-capability.json')
53
+ return runtimeStatePath(join(cwd, '.sillyspec', '.runtime'), 'vector-capability.json')
53
54
  }
54
55
 
55
56
  function readCapabilityCache(cwd) {
@@ -1,7 +1,9 @@
1
1
  // W6 Step9a: Revision v1 状态一致性检查 + 修复 + --force 审计日志。
2
2
  // 从 src/progress.js 单体 ProgressManager 抽出。持有 pm 引用(构造注入),调 pm.read / pm._write /
3
3
  // pm._ensureRuntimeDir / pm._runtimePath(persistence-core 留 facade 本体)。
4
- import { appendFileSync, existsSync, readFileSync, readdirSync } from 'fs';
4
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync } from 'fs';
5
+ import { dirname } from 'path';
6
+ import { RUNTIME_TEMP_DIRNAME } from '../runtime-paths.js'
5
7
  import { join, resolve } from 'path';
6
8
  import { DB } from '../db.js';
7
9
  import { openDatabase } from '../db-engine.js';
@@ -122,8 +124,9 @@ export class ConsistencyDoctor {
122
124
  _appendAuditLog(cwd, entry) {
123
125
  try {
124
126
  this.pm._ensureRuntimeDir(cwd);
125
- const auditPath = this.pm._runtimePath(cwd, 'audit.log');
127
+ const auditPath = this.pm._runtimePath(cwd, RUNTIME_TEMP_DIRNAME, 'audit.log');
126
128
  const line = JSON.stringify({ at: new Date().toISOString(), ...entry });
129
+ try { mkdirSync(dirname(auditPath), { recursive: true }); } catch { /* fail-open */ }
127
130
  appendFileSync(auditPath, line + '\n');
128
131
  } catch (e) {
129
132
  console.warn(`⚠️ 审计日志写入失败: ${e.message}`);
@@ -361,8 +361,12 @@ export class StageMachine {
361
361
  /** 未决同步冲突列表(overview 数据源;与 sync.js listConflictFiles 同前缀同字段,fs-only) */
362
362
  _listPendingConflicts(specDir) {
363
363
  const runtimeDir = join(specDir, '.runtime');
364
+ // 2026-10-18 布局分层:冲突文件落 state/;根目录扫描保留旧布局兜底
364
365
  let files = [];
365
- try { files = readdirSync(runtimeDir); } catch { return []; }
366
+ for (const dir of [join(runtimeDir, 'state'), runtimeDir]) {
367
+ try { files.push(...readdirSync(dir)); } catch { /* 可选层缺失跳过 */ }
368
+ }
369
+ const fileDir = (f) => (existsSync(join(runtimeDir, 'state', f)) ? join(runtimeDir, 'state') : runtimeDir)
366
370
  const conflicts = [];
367
371
  for (const f of files) {
368
372
  const isProgress = f.startsWith('sync-conflict-');
@@ -371,7 +375,7 @@ export class StageMachine {
371
375
  let createdAt = null;
372
376
  let kind = null;
373
377
  try {
374
- const cf = JSON.parse(readFileSync(join(runtimeDir, f), 'utf8'));
378
+ const cf = JSON.parse(readFileSync(join(fileDir(f), f), 'utf8'));
375
379
  change = cf.change || change;
376
380
  createdAt = cf.created_at || null;
377
381
  kind = typeof cf.kind === 'string' ? cf.kind : null;
package/src/progress.js CHANGED
@@ -11,6 +11,7 @@
11
11
  */
12
12
 
13
13
  import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, unlinkSync, appendFileSync, copyFileSync, readdirSync, statSync, realpathSync } from 'fs';
14
+ import { RUNTIME_TEMP_DIRNAME, runtimeTempPath } from './runtime-paths.js'
14
15
  import { join, basename, dirname, resolve, sep } from 'path';
15
16
  import { tmpdir, hostname } from 'os';
16
17
  import { writeAtomicSync } from './fs-atomic.js';
@@ -122,7 +123,7 @@ export function resolveSessionIdentity({ flagSession = null, quickChangeName = n
122
123
  try {
123
124
  const markerDir = join(cwd, SPEC_DIR_NAME, RUNTIME_SUBDIR);
124
125
  mkdirSync(markerDir, { recursive: true });
125
- const markerPath = join(markerDir, ANON_SESSION_WARN_MARKER);
126
+ const markerPath = runtimeTempPath(markerDir, ANON_SESSION_WARN_MARKER);
126
127
  const now = Date.now();
127
128
  try {
128
129
  const m = JSON.parse(readFileSync(markerPath, 'utf8'));
@@ -348,7 +349,7 @@ export class ProgressManager {
348
349
  const runtimeDir = this._runtimePath(cwd);
349
350
  if (!existsSync(runtimeDir)) {
350
351
  mkdirSync(runtimeDir, { recursive: true });
351
- for (const d of ['artifacts', 'history', 'logs', 'templates']) {
352
+ for (const d of ['artifacts', 'history', 'logs', 'templates', 'state', 'temp']) {
352
353
  mkdirSync(join(runtimeDir, d), { recursive: true });
353
354
  }
354
355
  }
@@ -746,7 +747,8 @@ export class ProgressManager {
746
747
  throw new Error(`本地进度库不存在,无法 import:${dbPath}`);
747
748
  }
748
749
  const bakTs = new Date().toISOString().replace(/[:.]/g, '-');
749
- const bakPath = this._runtimePath(cwd, `sillyspec.db.pre-import-${bakTs}.bak`);
750
+ const bakPath = this._runtimePath(cwd, RUNTIME_TEMP_DIRNAME, `sillyspec.db.pre-import-${bakTs}.bak`);
751
+ try { mkdirSync(dirname(bakPath), { recursive: true }); } catch { /* import 主流程 fail-open */ }
750
752
  // 体检 BUG-18:WAL 模式下最近已提交事务可能仍在 -wal 侧车,直接 copy 主库文件会拿到
751
753
  // 缺尾部提交的快照(恢复时静默回退进度)。先尽力 checkpoint(TRUNCATE) 把 WAL 合并回主库;
752
754
  // 他进程持连接致 checkpoint 不完全时,把 -wal 侧车一并备份,恢复侧才有完整提交
@@ -880,17 +882,25 @@ export class ProgressManager {
880
882
  let keep = Number(process.env.SILLYSPEC_PREIMPORT_BAK_KEEP);
881
883
  if (!Number.isInteger(keep) || keep < 1) keep = PRE_IMPORT_BAK_KEEP_DEFAULT;
882
884
 
883
- const runtimeDir = this._runtimePath(cwd);
884
- const baks = readdirSync(runtimeDir).filter(f => PRE_IMPORT_BAK_RE.test(f)).sort();
885
+ // 快照 2026-10-18 起落 temp/;根目录扫描保留旧布局兜底(迁移幂等收敛后清零)。
886
+ // 契约:根 runtime 目录缺失仍如实抛 ENOENT(fail-loud 不静默假成功);temp/ 是可选
887
+ // 层(迁移前不存在),缺失按空集处理。
888
+ const rootDir = this._runtimePath(cwd)
889
+ const scanDirs = [this._runtimePath(cwd, RUNTIME_TEMP_DIRNAME), rootDir]
890
+ const baks = [...new Set([
891
+ ...(() => { try { return readdirSync(scanDirs[0]) } catch { return [] } })(),
892
+ ...readdirSync(rootDir),
893
+ ])].filter(f => PRE_IMPORT_BAK_RE.test(f)).sort();
885
894
  const keepName = keepPath ? basename(keepPath) : null;
886
895
  const doomed = baks.slice(0, Math.max(0, baks.length - keep)).filter(f => f !== keepName);
887
896
 
888
897
  let removed = 0;
889
898
  for (const f of doomed) {
890
899
  // 单份删除失败(他进程持句柄 / 权限)不连坐其余份:跳过即可,下次 import 再收。
891
- try { unlinkSync(join(runtimeDir, f)); } catch { continue; }
900
+ const fDir = scanDirs.find(d => { try { return existsSync(join(d, f)) } catch { return false } }) || scanDirs[0]
901
+ try { unlinkSync(join(fDir, f)); } catch { continue; }
892
902
  removed++;
893
- const wal = join(runtimeDir, `${f}-wal`);
903
+ const wal = join(fDir, `${f}-wal`);
894
904
  if (existsSync(wal)) {
895
905
  try { unlinkSync(wal); } catch { /* 侧车残留无害:不参与恢复链,下次裁剪再收 */ }
896
906
  }
@@ -937,7 +947,8 @@ export class ProgressManager {
937
947
  _maybeSnapshotBak(cwd) {
938
948
  try {
939
949
  const dbPath = this._runtimePath(cwd, 'sillyspec.db');
940
- const bakPath = dbPath + '.bak';
950
+ // 2026-10-18 布局分层:可删备份归 temp/(db.js _openWithFallback 双路径回读)
951
+ const bakPath = runtimeTempPath(dirname(dbPath), 'sillyspec.db.bak');
941
952
  const st = existsSync(bakPath) ? statSync(bakPath) : null;
942
953
  if (st && Date.now() - st.mtimeMs < ProgressManager.SNAPSHOT_INTERVAL_MS) return;
943
954
  const tmpPath = bakPath + '.tmp';
package/src/quicklog.js CHANGED
@@ -118,18 +118,74 @@ export function sanitizeDesc(description) {
118
118
  return s.length > 120 ? s.slice(0, 120) + '…' : s
119
119
  }
120
120
 
121
+ // ── 短标题提炼(2026-10-16-short-change-title,变更中心标题治理·工具侧)──
122
+
123
+ // 提炼参数:目标 20 字 / 上限 30 字 / 截点下限 6 字(更短的截点宁可不切——残词标题比
124
+ // 硬切长句更难读,如「修复,…」切出「修复」)。平台侧回填脚本须与此处口径对齐。
125
+ const SHORT_TITLE_DEFAULTS = Object.freeze({ target: 20, max: 30, floor: 6 })
126
+ // 全角/语义截点:无条件可切。ASCII 标点仅在后随空白/CJK/串尾时才算截点(防 3.5 / 1,000 /
127
+ // e.g. 误切——小数点后随数字是最常见误切源,rpc-budget 标题实证)。
128
+ const SHORT_TITLE_CJK_CUT = new Set([...'。!?;:、,…—~·'])
129
+ const SHORT_TITLE_ASCII_CUT = new Set([...'.,;:!?'])
130
+ const SHORT_TITLE_CJK_RE = /[\u3400-\u4dbf\u4e00-\u9fff]/
131
+ // 截断后尾部清理:剥句读尾标点(引号括号不入此集——「xxx」收尾是平衡结构不能剥);
132
+ // 再剥截点切进引号体时留下的悬空开口引号/括号。
133
+ const SHORT_TITLE_TRAIL_RE = /[\s。..!!??;;::,,、…—~~·]+$/u
134
+ const SHORT_TITLE_DANGLING_RE = /[「『(《"']+$/u
135
+
121
136
  /**
122
- * 变更标题推导(用户需求 2026-09-29:changes.title 应为中文描述,≤50 字、建议 ~20 字)。
123
- * 取 --input 首个非空行(「成功标准:」引导行即止——那是验收面不是标题素材),剥
124
- * 「需求:/动机:」类标签前缀,空白折叠单行,超长截断加 …;空输入返回 ''(调用方兜底
125
- * change 名)。agent 在 --input 首行写一句中文概括即成标题(flow start 横幅有指引),
126
- * --title 显式覆盖一切推导。
137
+ * 短标题提炼(2026-10-16-short-change-title)。病根:deriveChangeTitle 旧口径对超长首行
138
+ * 50 字硬切加 …(残句难读),纯靠 agent 自觉写短首行(本地 20 条实证 7 条变更名兜底 +
139
+ * 8 条硬切残句)。提炼口径:超 max 长句在自然标点处截断——截点取「距 target 最近、
140
+ * 落在 [floor, max] 区间」者(同距取更短),截后剥尾部标点;无可用截点才硬切
141
+ * slice(0,max)+'…' 并标 hardCut=true(调用方可据此打 --title 软提示)。
142
+ * 返回 { title, hardCut };空输入 { title:'', hardCut:false }。
143
+ */
144
+ export function distillShortTitleEx(raw, opts = {}) {
145
+ const { target, max, floor } = { ...SHORT_TITLE_DEFAULTS, ...opts }
146
+ const s = String(raw || '').replace(/\s+/g, ' ').trim()
147
+ if (!s) return { title: '', hardCut: false }
148
+ if (s.length <= max) {
149
+ return { title: s.replace(SHORT_TITLE_TRAIL_RE, ''), hardCut: false }
150
+ }
151
+ // 截点收集:候选标题=标点前缀(去重后按距 target 最近择优)
152
+ const seen = new Set()
153
+ const cands = []
154
+ for (let i = 0; i < s.length; i++) {
155
+ const ch = s[i]
156
+ let cut = SHORT_TITLE_CJK_CUT.has(ch)
157
+ if (!cut && SHORT_TITLE_ASCII_CUT.has(ch)) {
158
+ const nx = s[i + 1]
159
+ cut = nx === undefined || /\s/.test(nx) || SHORT_TITLE_CJK_RE.test(nx)
160
+ }
161
+ if (!cut) continue
162
+ const t = s.slice(0, i).replace(SHORT_TITLE_TRAIL_RE, '').replace(SHORT_TITLE_DANGLING_RE, '').trim()
163
+ if (t && !seen.has(t)) { seen.add(t); cands.push(t) }
164
+ }
165
+ const valid = cands.filter((t) => t.length >= floor && t.length <= max)
166
+ if (valid.length > 0) {
167
+ valid.sort((a, b) => (Math.abs(a.length - target) - Math.abs(b.length - target)) || (a.length - b.length))
168
+ return { title: valid[0], hardCut: false }
169
+ }
170
+ return { title: s.slice(0, max) + '…', hardCut: true }
171
+ }
172
+
173
+ /** distillShortTitleEx 的字符串简版(不需要 hardCut 观测的调用方)。 */
174
+ export function distillShortTitle(raw, opts) {
175
+ return distillShortTitleEx(raw, opts).title
176
+ }
177
+
178
+ /**
179
+ * 变更标题首行提取(不截断):取 --input 首个非空行(「成功标准:」引导行即止——那是验收
180
+ * 面不是标题素材),剥「需求:/动机:」类标签前缀,空白折叠单行。截断口径由此分治:
181
+ * deriveChangeTitle(≤50 硬切,模块摘要用)与 deriveShortChangeTitle(≤30 提炼,标题面用)
182
+ * 共享本提取,两个口径互不牵连。空输入返回 ''(调用方兜底变更名)。
127
183
  * 段头行防御(2026-10-15-title-label-skip):过门格式示例曾被读成段头形态——agent 把
128
184
  * 「动机与背景:」独立成行照抄,白名单无复合词致整行成标题(进度库 6 变更实证)。两道
129
185
  * 防御:白名单补复合词(动机与背景/背景与动机);任意短段头行(≤12 字非冒号内容+行尾
130
186
  * 冒号,如「变更背景:」「详细说明:」)整行跳过取次非空行——冒号后无内容不成标题。
131
187
  */
132
- export function deriveChangeTitle(input, max = 50) {
188
+ function deriveChangeTitleRaw(input) {
133
189
  for (const l of String(input || '').replace(/\r\n/g, '\n').split('\n')) {
134
190
  const t = l.trim()
135
191
  if (!t) continue
@@ -137,12 +193,105 @@ export function deriveChangeTitle(input, max = 50) {
137
193
  if (/^[^::]{1,12}[::]$/.test(t)) continue
138
194
  const stripped = t.replace(/^(?:需求|动机|背景|任务|标题|动机与背景|背景与动机)\s*[::]\s*/, '').trim()
139
195
  if (!stripped) continue
140
- const one = stripped.replace(/\s+/g, ' ')
141
- return one.length > max ? one.slice(0, max) + '…' : one
196
+ return stripped.replace(/\s+/g, ' ')
142
197
  }
143
198
  return ''
144
199
  }
145
200
 
201
+ /**
202
+ * 变更标题推导(用户需求 2026-09-29:changes.title 应为中文描述,≤50 字、建议 ~20 字)。
203
+ * --title 显式覆盖一切推导。agent 在 --input 首行写一句中文概括即成标题(flow start 横幅
204
+ * 有指引)。**2026-10-16-short-change-title 起标题面改走 deriveShortChangeTitle 提炼口径
205
+ * (目标 20/上限 30,50 字硬切残句实证淘汰)**;本函数 ≤50 硬切口径保持不变——
206
+ * motiveFromInput 模块变更索引摘要与其同源(flow-parity),勿在此改截断行为。
207
+ */
208
+ export function deriveChangeTitle(input, max = 50) {
209
+ const one = deriveChangeTitleRaw(input)
210
+ if (!one) return ''
211
+ return one.length > max ? one.slice(0, max) + '…' : one
212
+ }
213
+
214
+ /**
215
+ * 变更标题推导·提炼口径(2026-10-16-short-change-title):deriveChangeTitleRaw 提取首行后
216
+ * 过 distillShortTitle——目标 20 字上限 30 字、自然标点截断、无截点才硬切。changes.title
217
+ * 落库面统一入口(flow start/resume/adopt、run initChange);--title 显式值由调用方直传,
218
+ * 不经本函数(用户原话不做二次加工)。
219
+ */
220
+ export function deriveShortChangeTitleEx(input, opts) {
221
+ const raw = deriveChangeTitleRaw(input)
222
+ if (!raw) return { title: '', hardCut: false }
223
+ return distillShortTitleEx(raw, opts)
224
+ }
225
+
226
+ export function deriveShortChangeTitle(input, opts) {
227
+ return deriveShortChangeTitleEx(input, opts).title
228
+ }
229
+
230
+ // ── 标题硬区间门(2026-10-16-title-range-gate,用户硬性要求:10-30 字必须、建议 ~20、可强制命名)──
231
+
232
+ /** 标题是否落在硬区间(去首尾空白后按字符数计 10..30)。变更名兜底/短句/超长均 false。 */
233
+ export function isValidChangeTitle(title) {
234
+ const n = [...String(title || '').trim()].length
235
+ return n >= 10 && n <= 30
236
+ }
237
+
238
+ /**
239
+ * fresh 启动标题解析:--title 显式 > --input 首行提炼 > 变更名兜底。
240
+ * enforce=true(agent 命名时刻,flow start fresh 非平台 writer):不在区间/兜底 → ok=false,
241
+ * 调用方必须在任何状态写入前拒绝启动(强制 agent 命名)。
242
+ * enforce=false(平台 writer 预建空目录自动派发,无 agent 对话面可强制):恒 ok=true,
243
+ * 超界/兜底由调用方 ⚠️ 留痕(best-effort 标题优于阻断派发)。
244
+ */
245
+ export function resolveStartTitle({ titleFlag, input, change, enforce = true }) {
246
+ if (titleFlag) {
247
+ return { title: titleFlag, ok: !enforce || isValidChangeTitle(titleFlag), kind: 'flag', hardCut: false }
248
+ }
249
+ const derived = deriveShortChangeTitleEx(input)
250
+ // enforce 下 hardCut(30 字+… 残句)与超区间同样拒绝:残句正是「让人认不出变更做什么」
251
+ // 的形态,CLI 提炼救不了就让 agent 自己命名(补标点/写短句/--title 三选一)。
252
+ // enforce=false(平台豁免)任何非空提炼值照用,超界/残句由调用方 ⚠️ 留痕。
253
+ if (derived.title && (!enforce || (!derived.hardCut && isValidChangeTitle(derived.title)))) {
254
+ return { title: derived.title, ok: true, kind: 'derived', hardCut: false }
255
+ }
256
+ return {
257
+ title: change,
258
+ ok: !enforce,
259
+ kind: 'fallback',
260
+ hardCut: Boolean(derived && derived.hardCut),
261
+ rejectedTitle: (derived && derived.title) || null,
262
+ }
263
+ }
264
+
265
+ /** hardCut 软提示(fresh/resume 共用单源)。文案口径=「6..30 字内无可用截点」——hardCut 判定
266
+ * 是候选截点经 [floor,max] 过滤后为空(标点存在但全在区间外也硬切),禁用「无自然断点」旧
267
+ * 措辞(2026-10-16-short-change-title 评审 P3 清偿)。 */
268
+ export const SHORT_TITLE_HARDCUT_HINT = '💡 标题超 30 字且 6..30 字内无可用自然标点截点,已硬切——重入 flow start --title "<10-30 字中文概括>" 可给更短标题(不阻断)'
269
+
270
+ /** 超界不阻断面的强提示行(adopt 收编/resume 补写/平台 writer 豁免共用单源)。 */
271
+ export function shortTitleRangeWarnLine(title) {
272
+ const n = [...String(title || '').trim()].length
273
+ return `⚠️ 标题「${title}」${n} 字不在 10-30 硬区间(建议 ~20 字)——重入 flow start --title "<10-30 字中文概括>" 可改(不阻断)`
274
+ }
275
+
276
+ /** 硬门拒绝报错(flow start fresh exit 2 用)。状态/原因/修复一次给全(cli-copy-selfsufficient)。 */
277
+ export function shortTitleRejectMessage(title) {
278
+ const fix = [
279
+ ' ① 重跑时把 --input 首行写成一句 10-30 字中文概括(首行即变更标题)',
280
+ ' ② 或追加参数 --title "<10-30 字中文概括>"',
281
+ ]
282
+ if (title == null || title === '') {
283
+ return [
284
+ '❌ 变更标题未命名——flow start 全新启动必须带 10-30 字标题(必须,建议 ~20 字):标题是变更中心展示面,须让人一眼认出本变更做什么。两条修复:',
285
+ ...fix,
286
+ ].join('\n')
287
+ }
288
+ const n = [...String(title).trim()].length
289
+ return [
290
+ `❌ 变更标题必须为 10-30 字(当前:${JSON.stringify(title)},${n} 字;必须,建议 ~20 字)——标题是变更中心展示面,须让人一眼认出本变更做什么。两条修复:`,
291
+ ...fix,
292
+ ].join('\n')
293
+ }
294
+
146
295
 
147
296
  // ── 平台推送(best-effort,2026-08-16-change-center-quick-tab task-06 / D-003)──
148
297
 
@@ -337,6 +486,10 @@ function sanitizeQuicklogUser(user) {
337
486
  // 从关联变更的 proposal/design 提取首个 # 标题(去「提案书(Proposal)— / 设计文档(Design)—」固定前缀)。
338
487
  // 让 quick --linked-changes 启动时(用户常不带 --input)也能拿到语义标题,而非 (quick 任务) 占位。
339
488
  // 读不到任何标题返回 ''(调用方再回退占位符,保持向后兼容)。
489
+ // 2026-10-16-short-change-title:① 返回前过提炼封顶 ≤30 字(超长 dash-desc 不直落标题);
490
+ // ② 两文档皆无语义标题(纯模板 H1,brainstorm 模板正文即「# 提案书(Proposal)」形态)时
491
+ // 回退读 proposal「## 动机」节首行过提炼——动机首句本是一句概括素材,旧口径放弃致标题
492
+ // 恒变更名兜底(平台变更中心 7/20 实证)。无动机节/无内容行仍返回 ''(空串不刷新契约不变)。
340
493
  export function deriveTitleFromLinkedChange(specBase, change) {
341
494
  if (!change) return ''
342
495
  for (const f of ['proposal.md', 'design.md']) {
@@ -355,7 +508,39 @@ export function deriveTitleFromLinkedChange(specBase, change) {
355
508
  if (!raw) continue
356
509
  const dash = raw.match(/[—-]\s*(.+)$/)
357
510
  const title = dash ? dash[1].trim() : raw
358
- if (title) return title
511
+ if (title) return distillShortTitle(title)
512
+ }
513
+ const motive = extractMotivationFirstLine(specBase, change)
514
+ return motive ? distillShortTitle(motive) : ''
515
+ }
516
+
517
+ // proposal「## 动机」节首个内容行(动机回退素材,2026-10-16-short-change-title)。
518
+ // 节标题形态对齐 stage-templates(## 动机)与 flow-draft SECTION_HEAD_RE:2-6 级标题行含
519
+ // 「动机」即命中(H1 本身是标题不参与)。内容行跳过空行/表格分隔行/HTML 注释(AGENT 槽)/
520
+ // 模板占位行(「为什么做、解决什么核心问题」),剥 markdown bullet 与「任务原话转写:」
521
+ // 前缀(flow-draft 机器稿动机段固定前缀,剥后即 input 首行,与 deriveShortChangeTitle 同源)。
522
+ // 读不到文件/无节/无合格内容行返回 ''。
523
+ function extractMotivationFirstLine(specBase, change) {
524
+ let content
525
+ try { content = readFileSync(join(specBase, 'changes', change, 'proposal.md'), 'utf8') } catch { return '' }
526
+ let inMotivation = false
527
+ for (const line of String(content).replace(/\r\n/g, '\n').split('\n')) {
528
+ const t = line.trim()
529
+ if (/^#{2,6}\s/.test(t)) {
530
+ if (inMotivation) return '' // 动机节内直到下一节标题都无合格内容行
531
+ inMotivation = /^#{2,6}\s*(?:\d+(?:[.、)]\s*)?)?[^\n]*动机/.test(t)
532
+ continue
533
+ }
534
+ if (!inMotivation || !t) continue
535
+ if (/^[\s|:-]+$/.test(t)) continue // 表格分隔/装饰行
536
+ if (/^<!--/.test(t)) continue // AGENT 槽等 HTML 注释
537
+ const stripped = t
538
+ .replace(/^(?:[-*+]|\d+[.、)])\s+/, '')
539
+ .replace(/^>\s*/, '')
540
+ .replace(/^任务原话转写[::]\s*/, '')
541
+ .trim()
542
+ if (!stripped || stripped === '(未提供 --input)' || stripped === '为什么做、解决什么核心问题') continue
543
+ return stripped
359
544
  }
360
545
  return ''
361
546
  }