sillyspec 3.29.0 → 3.29.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sillyspec",
3
- "version": "3.29.0",
3
+ "version": "3.29.2",
4
4
  "description": "SillySpec CLI — 流程状态机,让 AI 严格按步骤来",
5
5
  "icon": "logo.jpg",
6
6
  "homepage": "https://sillyspec.ppdmq.top/",
@@ -497,12 +497,66 @@ function detectOpencode(ctx) {
497
497
  * 防「Cursor IDE 在别的项目聊天 / 其他项目 opencode 会话活跃」被误报进当前登记。
498
498
  * 新增 CLI = 加一个探测器对象 + protocol 文档补布局与格式说明。
499
499
  */
500
+ /**
501
+ * cursor-agent(CLI 子代理,非 Cursor IDE):transcript = <home>/.cursor/projects/
502
+ * <encoded-workspace-path>/agent-transcripts/<uuid>/<uuid>.jsonl({role,message} 行 +
503
+ * turn_ended 事件)。workspace 目录名是 cwd 的连字符编码(C:/Users/qinyi → C-Users-qinyi,
504
+ * 路径内合法连字符保留原样——编码不可逆,匹配方向为 cwd→编码名正向比对)→ **precise** 探测:
505
+ * 逐 cwdCandidate 编码后与目录名精确相等才登记(数字目录名无从解码,跳过不误报)。
506
+ * 归属规则与 zcode/claude-code 同款(本会话 cwd 即 workspace 根);token 不落盘属数据源
507
+ * 缺失(usage 未知,平台侧 FR-03 口径不伪造)。
508
+ * 坑 cursor-agent-transcript-report-pipeline(2026-09-19 平台侧就绪声明):daemon 解析器
509
+ * 注册键 `cursor-agent-transcript-jsonl` 必须与此处 format 串逐字一致。
510
+ */
511
+ function detectCursorAgentTranscripts(ctx) {
512
+ const { cwdCandidates, homeDir, now, windowMs } = ctx;
513
+ const projectsRoot = join(homeDir, '.cursor', 'projects');
514
+ let projectDirs;
515
+ try {
516
+ projectDirs = readdirSync(projectsRoot, { withFileTypes: true }).filter(d => d.isDirectory());
517
+ } catch { return []; }
518
+ // cwd → 编码名(正向,避免解码歧义):盘符冒号与路径分隔符全部 → '-'
519
+ const encodeCwd = (p) => toPosix(String(p)).replace(/^([a-zA-Z]):/, '$1').replace(/[/:]/g, '-');
520
+ const wanted = new Set(cwdCandidates.filter(Boolean).map(encodeCwd));
521
+ if (wanted.size === 0) return [];
522
+ const entries = [];
523
+ for (const proj of projectDirs) {
524
+ if (!wanted.has(proj.name)) continue;
525
+ const tsRoot = join(projectsRoot, proj.name, 'agent-transcripts');
526
+ let sessionDirs;
527
+ try {
528
+ sessionDirs = readdirSync(tsRoot, { withFileTypes: true }).filter(d => d.isDirectory());
529
+ } catch { continue; }
530
+ for (const sess of sessionDirs) {
531
+ let files;
532
+ try {
533
+ files = readdirSync(join(tsRoot, sess.name)).filter(f => f.endsWith('.jsonl'));
534
+ } catch { continue; }
535
+ for (const f of files) {
536
+ const full = join(tsRoot, sess.name, f);
537
+ const st = statSafe(full);
538
+ if (!st || now - st.mtimeMs > windowMs) continue;
539
+ entries.push({
540
+ harness: 'cursor-agent', format: 'cursor-agent-transcript-jsonl',
541
+ detected_via: 'cursor-agent-transcript-scan',
542
+ log_path: toPosix(full), agent_cwd: toPosix(cwdCandidates[0] || ''),
543
+ session_id: sess.name, originator: null,
544
+ mtime_ms: st.mtimeMs,
545
+ });
546
+ }
547
+ }
548
+ }
549
+ entries.sort((a, b) => b.mtime_ms - a.mtime_ms);
550
+ return entries.slice(0, 3);
551
+ }
552
+
500
553
  const HARNESS_DETECTORS = [
501
554
  { name: 'claude-code', tier: 'precise', detect: detectClaudeCode },
502
555
  { name: 'codex', tier: 'precise', detect: detectCodex },
503
556
  { name: 'zcode', tier: 'precise', detect: detectZcode },
504
557
  { name: 'pi', tier: 'precise', detect: detectPi },
505
558
  { name: 'deepseek-dsh', tier: 'precise', detect: detectDsh },
559
+ { name: 'cursor-agent', tier: 'precise', detect: detectCursorAgentTranscripts },
506
560
  { name: 'cursor', tier: 'loose', detect: detectCursor },
507
561
  { name: 'opencode', tier: 'loose', detect: detectOpencode },
508
562
  ];
@@ -156,7 +156,7 @@ export const LOCAL_YAML_SCHEMA = {
156
156
  {
157
157
  id: 'gate_snapshot',
158
158
  title: '门禁隔离快照 copy 面',
159
- note: '门禁快照(gate-snapshot)= HEAD worktree + 会话文件 overlay + 环境目录 junction——gitignored 生成物(api-types/generated 类)不进 HEAD 也不在会话集,快照内 lint/test 环境性假败(2026-09-16 驾驭小结④,friction5-hardening R4 / D-002@v1)。按本清单在快照构建期从主仓 junction 链接补齐(失败回退复制);快照内 overlay 已覆盖的同名路径跳过(本变更最新态优先)。⚠️ junction 是活链接:快照内再跑生成命令会写穿到主仓该目录。',
159
+ note: '门禁快照(gate-snapshot)= HEAD worktree + 会话文件 overlay + 环境目录 junction——gitignored 生成物(类型生成/代码生成产物类)不进 HEAD 也不在会话集,快照内 lint/test 环境性假败(2026-09-16 驾驭小结④,friction5-hardening R4 / D-002@v1)。按本清单在快照构建期从主仓 junction 链接补齐(失败回退复制);快照内 overlay 已覆盖的同名路径跳过(本变更最新态优先)。⚠️ junction 是活链接:快照内再跑生成命令会写穿到主仓该目录。',
160
160
  keys: [
161
161
  { path: 'gate_snapshot.copy', type: 'array', optional: true, status: 'live', readers: ['createGateSnapshot (src/run/gate-snapshot.js)'], desc: '门禁隔离快照的生成物/额外路径 copy 面(元素 string,相对仓根):HEAD 快照缺 gitignored 生成物导致 lint/test 环境性假败;声明后快照构建期从主仓 junction 链接(失败回退复制)。⚠️ junction 是活链接,快照内再跑生成命令会写穿到主仓该目录。未配置/空清单 = 全段空转零行为变化。', example: 'gate_snapshot:\n copy:\n - src/generated' },
162
162
  { path: 'gate_snapshot.commands', type: 'array', optional: true, status: 'live', readers: ['parseGateSnapshotCommands + runGateSnapshotCommands (src/run/gate-snapshot.js — createGateSnapshot 环境预检后 copy 面前)'], desc: '门禁隔离快照的命令面(元素 string):快照构建期在环境目录链接后、copy 面前于快照根逐条执行(cwd=快照根、300s/条超时帽、非零退出/超时只 warn 继续——fail-open 不作废快照),产出「本仓应然态」生成物,收口主仓生成物缺失/过期(fresh clone 未跑 postinstall / build-id 类产物)时快照内全量 lint/test 必挂的坑(D-002@v2,2026-09-17-feedback-hardening FR-01)。⚠️ 环境目录(node_modules/venv)是活链接,命令写它们会穿透主仓——只放生成物命令(npm run gen:build-id 类),不放 install/build 全家桶。未配置 = 全段空转零行为。', example: 'npm run gen:build-id' },
package/src/fr-index.js CHANGED
@@ -54,32 +54,79 @@ export function parseChangeRequirements(changeDir) {
54
54
  const frs = [];
55
55
  const malformed = [];
56
56
  const lines = content.replace(/\r\n/g, '\n').split('\n');
57
+ // L2(2026-09-20-fr-index-l2):决策覆盖矩阵解析——requirements.md 尾表行
58
+ // `| D-xxx@vN | FR-01, FR-02 |` 提取 D→FR-NN 映射(local 号),归档时随条目落「依据决策:」行。
59
+ // 格式漂移/缺矩阵 → decisions 恒 [](省略行降级,不阻断归档)。
60
+ const decisionMap = new Map();
61
+ for (const line of lines) {
62
+ const m = line.match(/^\|\s*(D-\d+@v\d+)\s*\|([^|]+)\|/);
63
+ if (!m) continue;
64
+ for (const tok of m[2].split(/[,,、]/)) {
65
+ const fr = tok.trim().match(/^(FR-\d+)$/);
66
+ if (!fr) continue;
67
+ if (!decisionMap.has(fr[1])) decisionMap.set(fr[1], []);
68
+ decisionMap.get(fr[1]).push(m[1]);
69
+ }
70
+ }
57
71
  let cur = null;
72
+ // L2 厚版(D-003@v2):GWT 行捕获——Given/When/Then 归最近场景名下(无名归默认场景)。
73
+ // scenarioBodies: [{name, given, when, then}];scenarios(名字数组)保留 L1 契约不动。
58
74
  for (const line of lines) {
59
75
  const h = line.match(/^###\s+(FR-\d+)\s*[::]\s*(.*)$/);
60
76
  if (h) {
61
77
  if (cur) frs.push(cur);
62
- cur = { local: h[1], title: (h[2] || '').trim(), supersedes: [], scenarios: [] };
78
+ cur = { local: h[1], title: (h[2] || '').trim(), supersedes: [], scenarios: [], decisions: decisionMap.get(h[1]) || [], __bodies: [{ name: null, given: '', when: '', then: '' }] };
63
79
  continue;
64
80
  }
65
81
  if (!cur) continue;
66
82
  const s = line.match(/^\s*承接\s*[::]\s*(.+)$/);
67
83
  if (s) {
68
84
  for (const tok of s[1].split(/[,,、]/)) {
69
- const id = tok.trim();
70
- if (!id) continue;
71
- if (FR_GLOBAL_ID_RE.test(id)) cur.supersedes.push(id);
72
- else malformed.push(`${cur.local} 的承接行含非全局 id 形态「${id}」(应为 FR-<域>-NNN)`);
85
+ const t = tok.trim();
86
+ if (!t) continue;
87
+ // 退役理由语法(追平刀②,对标 OpenSpec REMOVED 语义):`FR-域-NNN(退役理由:一句话)`
88
+ //——理由随承接令牌走,翻链时写进被取代条目。理由内禁逗号(token 切分边界)。
89
+ const rm = t.match(/^(FR-[A-Za-z0-9-]+)[((]\s*退役理由[::]\s*([^))]+)[))]$/);
90
+ if (rm) {
91
+ if (FR_GLOBAL_ID_RE.test(rm[1])) {
92
+ cur.supersedes.push(rm[1]);
93
+ if (!cur.supersedeReasons) cur.supersedeReasons = {};
94
+ cur.supersedeReasons[rm[1]] = rm[2].trim();
95
+ } else {
96
+ malformed.push(`${cur.local} 的承接行退役理由令牌含非全局 id 形态「${rm[1]}」(应为 FR-<域>-NNN)`);
97
+ }
98
+ continue;
99
+ }
100
+ if (FR_GLOBAL_ID_RE.test(t)) cur.supersedes.push(t);
101
+ else malformed.push(`${cur.local} 的承接行含非全局 id 形态「${t}」(应为 FR-<域>-NNN)`);
73
102
  }
74
103
  continue;
75
104
  }
76
105
  const sc = line.match(/^\s*(?:#{2,4}\s*场景\s*[::]\s*(.+)|\*\*场景\s*[::]\s*([^*]+)\*\*)\s*$/);
77
106
  if (sc) {
78
- const name = ((sc[1] || sc[2] || '')).trim();
79
- cur.scenarios.push(name || `场景${cur.scenarios.length + 1}`);
107
+ const name = ((sc[1] || sc[2] || '')).trim() || `场景${cur.scenarios.length + 1}`;
108
+ cur.scenarios.push(name);
109
+ cur.__bodies.push({ name, given: '', when: '', then: '' });
110
+ continue;
111
+ }
112
+ const gwt = line.match(/^\s*(Given|When|Then)\s+(.+)$/i);
113
+ if (gwt) {
114
+ const body = cur.__bodies[cur.__bodies.length - 1];
115
+ const key = gwt[1].toLowerCase();
116
+ body[key] = (body[key] ? body[key] + ' ' : '') + gwt[2].trim();
117
+ // 无名默认场景已收 GWT → 命名并同步进 scenarios 名字数组(L1 摘要契约兼容)
118
+ if (body.name === null && cur.scenarios.length === 0 && (body.given || body.when || body.then)) {
119
+ cur.scenarios.push('默认场景');
120
+ body.name = '默认场景';
121
+ }
80
122
  }
81
123
  }
82
124
  if (cur) frs.push(cur);
125
+ // 剥离内部键,暴露 scenarioBodies(有 GWT 内容的体块才进)
126
+ for (const fr of frs) {
127
+ fr.scenarioBodies = (fr.__bodies || []).filter((b) => b.name !== null || b.given || b.when || b.then).map((b) => ({ name: b.name, given: b.given, when: b.when, then: b.then }));
128
+ delete fr.__bodies;
129
+ }
83
130
  return { missing: false, frs, malformed };
84
131
  }
85
132
 
@@ -129,6 +176,7 @@ function loadDomainSections(knowledgeRoot, domain) {
129
176
  '',
130
177
  '> fr-index 从归档变更 requirements.md 幂等提炼(「最近确认」= 归档时 HEAD)。条目字段行为机械解析契约,勿手改。',
131
178
  '> superseded 条目保留供取代链回溯;brainstorm 注入默认只给 active。',
179
+ `> 模块卡:modules/${domain}.md(域=模块 id 同构;行为条目↔模块契约互跳)`,
132
180
  '',
133
181
  ],
134
182
  sections: [],
@@ -153,6 +201,26 @@ function renderFrLines(entry, headHash) {
153
201
  if (entry.supersededBy) lines.push(`superseded_by:${entry.supersededBy}`);
154
202
  if (entry.supersededOf) lines.push(`取代链:${entry.supersededOf} ← 本条目(${entry.change} 承接)`);
155
203
  lines.push(`摘要:${(entry.scenarios || []).slice(0, 5).join(';') || '(无场景名)'}`);
204
+ // L2:依据决策行(决策覆盖矩阵提取;空省略——旧条目无此行照常解析)。
205
+ if (Array.isArray(entry.decisions) && entry.decisions.length > 0) {
206
+ lines.push(`依据决策:${entry.decisions.join('、')}`);
207
+ }
208
+ // L2 厚版(D-003@v2):场景正文块——每场景一行(各段截 80 字,≤5 场景)。
209
+ // 空体块省略整块;回填幂等锚=「场景正文:」行在场即跳过。
210
+ const bodies = (entry.scenarioBodies || []).filter((b) => b.given || b.when || b.then).slice(0, 5);
211
+ if (bodies.length > 0) {
212
+ lines.push('场景正文:');
213
+ const cut = (s) => String(s || '').slice(0, 80);
214
+ for (const b of bodies) {
215
+ const parts = [`Given ${cut(b.given)}`, `When ${cut(b.when)}`, `Then ${cut(b.then)}`].filter((p) => !p.endsWith(' '));
216
+ lines.push(`- 场景:${b.name || '默认场景'} — ${parts.join(';')}`);
217
+ }
218
+ }
219
+ // 全文锚(追平刀①):正文是截断摘要,全文活在来源归档——引用行不复制正文零体积税,
220
+ // 锚=归档 requirements.md 内的 FR 局部号标题(### FR-NN:),docs-check 层1 可校验存在性。
221
+ if (entry.local) {
222
+ lines.push(`全文:.sillyspec/changes/archive/${entry.change}/requirements.md#${entry.local}`);
223
+ }
156
224
  lines.push(`最近确认:${headHash || ''}`);
157
225
  return lines;
158
226
  }
@@ -241,7 +309,7 @@ export function indexRequirements({ changeDir, knowledgeRoot, headHash = '' }) {
241
309
  id,
242
310
  title: fr.title,
243
311
  change: changeName,
244
- lines: renderFrLines({ id, title: fr.title, change: changeName, scenarios: fr.scenarios }, headHash),
312
+ lines: renderFrLines({ id, title: fr.title, change: changeName, scenarios: fr.scenarios, decisions: fr.decisions, scenarioBodies: fr.scenarioBodies, local: fr.local }, headHash),
245
313
  });
246
314
  dirtyDomains.add(primaryDomain);
247
315
  written.push({ file: `${FR_DIR}/${primaryDomain}.md`, id, action: 'added' });
@@ -254,9 +322,15 @@ export function indexRequirements({ changeDir, knowledgeRoot, headHash = '' }) {
254
322
  // 就地补丁:状态翻 superseded + 插 superseded_by + 追取代链注记 + 刷新最近确认(摘要/标题保留)
255
323
  target.lines = target.lines
256
324
  .map((l) => (l.startsWith('状态:') ? `状态:superseded` : l.startsWith('最近确认:') ? `最近确认:${headHash || ''}` : l))
257
- .filter((l) => !l.startsWith('superseded_by:') && !l.startsWith('取代链:'));
325
+ .filter((l) => !l.startsWith('superseded_by:') && !l.startsWith('取代链:') && !l.startsWith('退役理由:'));
258
326
  const stateIdx = target.lines.findIndex((l) => l.startsWith('状态:'));
259
- target.lines.splice(stateIdx + 1, 0, `superseded_by:${id}`, `取代链:${refId} ← ${id}(${changeName} 承接)`);
327
+ // 退役理由(追平刀②):承接令牌带(退役理由:…)时写进被取代条目——对标 OpenSpec REMOVED
328
+ // 的 Migration 语义;无理由则省略行(不伪造)。
329
+ const reason = fr.supersedeReasons && fr.supersedeReasons[refId];
330
+ target.lines.splice(stateIdx + 1, 0,
331
+ `superseded_by:${id}`,
332
+ `取代链:${refId} ← ${id}(${changeName} 承接)`,
333
+ ...(reason ? [`退役理由:${reason}`] : []));
260
334
  dirtyDomains.add(st2Domain);
261
335
  superseded.push({ from: refId, to: id, change: changeName });
262
336
  }
@@ -322,6 +396,138 @@ export function scanFrIndex(knowledgeRoot) {
322
396
  return out;
323
397
  }
324
398
 
399
+ /**
400
+ * L2 厚版(D-003@v2 / FR-04):存量回填——active 条目缺「场景正文:」块 → 从其来源
401
+ * 变更的归档 requirements.md 按标题匹配补齐(fallback 按序)。幂等:已有正文块的条目
402
+ * 跳过;来源归档缺失/标题不匹配 → 警告不阻断。回填只动正文块,其余行(摘要/依据决策/
403
+ * 状态链/最近确认)原样保留。
404
+ * @param {object} opts
405
+ * @param {string} opts.knowledgeRoot - knowledge/ 目录
406
+ * @param {string} opts.archiveRoot - changes/archive/ 目录
407
+ * @returns {{backfilled: Array<{file,id,title}>, skipped: number, warnings: string[]}}
408
+ */
409
+ export function backfillScenarioBodies({ knowledgeRoot, archiveRoot }) {
410
+ const backfilled = [];
411
+ const warnings = [];
412
+ let skipped = 0;
413
+ // 归档 requirements 缓存:changeName → parse 结果(多条目共享一次读盘)
414
+ const parsedCache = new Map();
415
+ const parseArchive = (changeName) => {
416
+ if (parsedCache.has(changeName)) return parsedCache.get(changeName);
417
+ const dir = join(archiveRoot, changeName);
418
+ let result = null;
419
+ try { result = parseChangeRequirements(dir); } catch { result = { missing: true, frs: [] }; }
420
+ if (!result || result.missing) {
421
+ warnings.push(`来源归档缺 requirements.md:${changeName}(该条目正文不回填)`);
422
+ }
423
+ parsedCache.set(changeName, result);
424
+ return result;
425
+ };
426
+ let files;
427
+ try { files = readdirSync(frDirPath(knowledgeRoot)).filter((f) => f.endsWith('.md')); }
428
+ catch { return { backfilled: [], skipped: 0, warnings: ['knowledge/fr 目录不可读'] }; }
429
+ for (const f of files) {
430
+ const p = join(frDirPath(knowledgeRoot), f);
431
+ let content;
432
+ try { content = readFileSync(p, 'utf8'); } catch (e) { warnings.push(`读 ${f} 失败(${e && e.code ? e.code : 'error'})跳过`); continue; }
433
+ const lines = content.replace(/\r\n/g, '\n').split('\n');
434
+ // 按条目分段:## 开头为新段;段内判定「场景正文:」在场性
435
+ const out = [];
436
+ let sectionLines = [];
437
+ let sectionMeta = null; // {title, change, hasBody, backfilled}
438
+ const flushSection = () => {
439
+ if (sectionMeta === null) return;
440
+ // 全文锚回填(追平刀①):独立于正文块——已有正文(或 superseded)的条目缺锚同样补
441
+ //(正文块早退分支之前的必经路径;锚插入位=场景正文/摘要行后)。
442
+ let anchoredThisSection = false;
443
+ if (sectionMeta.change && !sectionLines.some((l) => l.startsWith('全文:'))) {
444
+ const parsedA = parseArchive(sectionMeta.change);
445
+ if (parsedA && !parsedA.missing) {
446
+ let srcA = parsedA.frs.find((fr) => (fr.title || '').trim() === sectionMeta.title);
447
+ if (!srcA) srcA = parsedA.frs[sectionMeta.ordInChange - 1] || null;
448
+ if (srcA) {
449
+ const isSceneLine = (l) => l.startsWith('场景正文:') || l.startsWith('- 场景:');
450
+ const aIdx = sectionLines.findLastIndex ? sectionLines.findLastIndex(isSceneLine) : -1;
451
+ const fallbackIdx = sectionLines.findLastIndex ? sectionLines.findLastIndex((l) => l.startsWith('摘要:')) : -1;
452
+ sectionLines.splice(Math.max(aIdx, fallbackIdx) + 1, 0, `全文:.sillyspec/changes/archive/${sectionMeta.change}/requirements.md#${srcA.local}`);
453
+ backfilled.push({ file: `fr/${f}`, id: sectionMeta.id, title: sectionMeta.title, what: '全文锚' });
454
+ anchoredThisSection = true;
455
+ }
456
+ }
457
+ }
458
+ if (sectionMeta.hasBody || !sectionMeta.change) {
459
+ if (!sectionMeta.hasBody) skipped++;
460
+ out.push(...sectionLines);
461
+ return;
462
+ }
463
+ const parsed = parseArchive(sectionMeta.change);
464
+ if (!parsed || parsed.missing) { skipped++; out.push(...sectionLines); return; }
465
+ // 标题匹配(剥 FR-x-NNN 前缀后的标题全等;fallback 按序:本域文件中该来源变更的第 n 条)
466
+ const bareTitle = sectionMeta.title;
467
+ let src = parsed.frs.find((fr) => (fr.title || '').trim() === bareTitle);
468
+ if (!src) {
469
+ const sameChange = parsed.frs;
470
+ src = sameChange[sectionMeta.ordInChange - 1] || null;
471
+ if (src) warnings.push(`${f} 的「${sectionMeta.title}」按序匹配回填(标题不精确匹配——建议核对)`);
472
+ }
473
+ const bodies = src && Array.isArray(src.scenarioBodies) ? src.scenarioBodies.filter((b) => b.given || b.when || b.then).slice(0, 5) : [];
474
+ if (!src || bodies.length === 0) {
475
+ if (!anchoredThisSection) {
476
+ warnings.push(`${f} 的「${sectionMeta.title}」来源无场景正文可回填(跳过)`);
477
+ skipped++;
478
+ }
479
+ out.push(...sectionLines);
480
+ return;
481
+ }
482
+ // 就地插入:状态行后(依据决策行若有则其后)
483
+ const insertAfter = Math.max(
484
+ sectionLines.findLastIndex ? sectionLines.findLastIndex((l) => l.startsWith('依据决策:')) : -1,
485
+ sectionLines.findLastIndex ? sectionLines.findLastIndex((l) => l.startsWith('摘要:')) : -1,
486
+ );
487
+ const block = ['场景正文:'];
488
+ const cut = (s) => String(s || '').slice(0, 80);
489
+ for (const b of bodies) {
490
+ const parts = [`Given ${cut(b.given)}`, `When ${cut(b.when)}`, `Then ${cut(b.then)}`].filter((x) => !x.endsWith(' '));
491
+ block.push(`- 场景:${b.name || '默认场景'} — ${parts.join(';')}`);
492
+ }
493
+ sectionLines.splice(insertAfter + 1, 0, ...block);
494
+ backfilled.push({ file: `fr/${f}`, id: sectionMeta.id, title: sectionMeta.title });
495
+ out.push(...sectionLines);
496
+ };
497
+ let ordCounter = new Map(); // changeName → 该来源在当前文件已见条目数
498
+ for (const line of lines) {
499
+ const hm = line.match(/^##\s+(FR-[A-Za-z0-9-]+)\s+(.*)$/);
500
+ if (hm) {
501
+ flushSection();
502
+ sectionLines = [line];
503
+ const changeLine = null; // 变更行在下文读取;先记标题
504
+ sectionMeta = { title: hm[2].trim(), change: null, hasBody: false, id: hm[1], ordInChange: 0 };
505
+ continue;
506
+ }
507
+ sectionLines.push(line);
508
+ if (sectionMeta) {
509
+ if (line.startsWith('变更:')) {
510
+ sectionMeta.change = line.replace(/^变更:\s*/, '').trim();
511
+ const n = (ordCounter.get(sectionMeta.change) || 0) + 1;
512
+ ordCounter.set(sectionMeta.change, n);
513
+ sectionMeta.ordInChange = n;
514
+ }
515
+ if (line.startsWith('状态:superseded')) sectionMeta.hasBody = true; // superseded 条目不回填(历史回溯面)
516
+ if (line.startsWith('场景正文:')) sectionMeta.hasBody = true;
517
+ }
518
+ }
519
+ flushSection();
520
+ if (backfilled.length > 0 || content !== out.join('\n')) {
521
+ // 仅当本文件确有回填才写盘(无回填保持字节不变)
522
+ const orig = content.replace(/\r\n/g, '\n');
523
+ if (out.join('\n') !== orig) {
524
+ try { writeFileSync(p, out.join('\n')); } catch (e) { warnings.push(`写 ${f} 失败(${e && e.message ? e.message : e})`); }
525
+ }
526
+ }
527
+ }
528
+ return { backfilled, skipped, warnings };
529
+ }
530
+
325
531
  /**
326
532
  * 注入源:触达域的 active 条目(superseded 默认藏——D-004)。
327
533
  * @returns {Array<{domain,id,title,change,scenarios:string[]}>}
@@ -333,8 +539,10 @@ export function readActiveFrDigest(knowledgeRoot, domains) {
333
539
  for (const s of st.sections) {
334
540
  if (s.lines.some((l) => l.startsWith('superseded_by:'))) continue;
335
541
  const scenarioLine = s.lines.find((l) => l.startsWith('摘要:'));
542
+ const decisionLine = s.lines.find((l) => l.startsWith('依据决策:'));
543
+ const decisions = decisionLine ? decisionLine.replace(/^依据决策:s*/, '').split('、').map((x) => x.trim()).filter(Boolean) : [];
336
544
  const scenarios = scenarioLine ? scenarioLine.replace(/^摘要:\s*/, '').split(';').map((x) => x.trim()).filter(Boolean) : [];
337
- out.push({ domain, id: s.number, title: s.title || '', change: s.change || '', scenarios });
545
+ out.push({ domain, id: s.number, title: s.title || '', change: s.change || '', scenarios, decisions });
338
546
  }
339
547
  }
340
548
  return out;
package/src/handoff.js ADDED
@@ -0,0 +1,113 @@
1
+ /**
2
+ * handoff.js —— 阶段跑者瘦会话交接块生成器(P1-5 v1,2026-09-20 对撞实验驱动)
3
+ *
4
+ * 为什么需要:单会话跑全流程时主上下文 ~300K 底座 × 每 CLI 往返重发一次——对撞实验
5
+ * 实测 sillyspec 侧主会话 203 请求烧 63M(肥上下文编排税是最大浪费源)。而 CLI prompt
6
+ * 本就自足(进度快照/模块命中/知识注入全由进度库与 prompt 渲染提供,新会话零背景可
7
+ * 续跑)——「每阶段一个瘦会话」是零机制改动的省法,缺的只是把交接信息打成一块可粘贴。
8
+ *
9
+ * 定位:纯只读(ProgressManager 读路径 + 环境探测),不写盘不动状态。输出三件:
10
+ * ① 新会话启动块(保持 SILLYSPEC_SESSION_ID 不变——变更所有权按此判定);
11
+ * ② 下一阶段建议(当前阶段完成 → MAIN_FLOW_ORDER 后继;进行中 → 同阶段续跑);
12
+ * ③ 当前状态摘要(阶段进度 + 停留步骤)。
13
+ */
14
+ import { ProgressManager, resolveSpecDir } from './progress.js';
15
+ import { MAIN_FLOW_ORDER, STAGE_LABELS } from './progress/shared.js';
16
+
17
+ /**
18
+ * 解析下一阶段建议。
19
+ * @param {string} currentStage - DB 当前阶段
20
+ * @param {boolean} stageCompleted - 当前阶段是否已完成(进度库 stageData.status === 'completed')
21
+ * @returns {{ next: string, mode: 'advance' | 'resume', reason: string }}
22
+ */
23
+ export function nextStageSuggestion(currentStage, stageCompleted) {
24
+ if (!currentStage) return { next: 'brainstorm', mode: 'resume', reason: '无进度记录,从头脑风暴起步' };
25
+ if (!stageCompleted) {
26
+ return { next: currentStage, mode: 'resume', reason: `${STAGE_LABELS[currentStage] || currentStage} 进行中——新会话续跑本阶段(步骤态由进度库恢复)` };
27
+ }
28
+ const idx = MAIN_FLOW_ORDER.indexOf(currentStage);
29
+ if (idx >= 0 && idx < MAIN_FLOW_ORDER.length - 1) {
30
+ const next = MAIN_FLOW_ORDER[idx + 1];
31
+ return { next, mode: 'advance', reason: `${STAGE_LABELS[currentStage] || currentStage} 已完成——新会话开跑下一阶段 ${STAGE_LABELS[next] || next}` };
32
+ }
33
+ if (currentStage === 'archive') {
34
+ return { next: '', mode: 'done', reason: '流程已归档终态——无下一阶段(可 sillyspec progress show 复核或开新变更)' };
35
+ }
36
+ // 辅助阶段(quick/scan/explore/status/doctor)完成:建议回主流程或开新
37
+ return { next: 'brainstorm', mode: 'resume', reason: `辅助阶段 ${STAGE_LABELS[currentStage] || currentStage} 完成——主流程变更请带 --change 续跑,或开新变更` };
38
+ }
39
+
40
+ /**
41
+ * 生成交接块(human 模式行数组 / --json 由调用方取结构)。
42
+ * @param {object} opts
43
+ * @param {string} opts.cwd
44
+ * @param {string} [opts.specBase] - 平台模式 specRoot;缺省 resolveSpecDir
45
+ * @param {string} [opts.changeName] - 变更名;缺省取唯一活跃主流程变更
46
+ * @returns {Promise<{ok:true, change, currentStage, stageCompleted, suggestion, sessionId, lines:string[]}|{ok:false, error:string}>}
47
+ */
48
+ export async function buildHandoff({ cwd, specBase, changeName } = {}) {
49
+ const specRoot = specBase || resolveSpecDir(cwd);
50
+ const pm = new ProgressManager({ specDir: specRoot });
51
+
52
+ let target = changeName;
53
+ let picked = false;
54
+ if (!target) {
55
+ // 唯一活跃主流程变更自动选中(listChanges=目录+DB 联合口径,read 取 currentStage)
56
+ const names = pm.listChanges(cwd) || [];
57
+ const mains = [];
58
+ for (const cn of names) {
59
+ const p = pm.read(cwd, cn);
60
+ if (p && MAIN_FLOW_ORDER.includes(p.currentStage || '')) mains.push({ name: cn, stage: p.currentStage });
61
+ }
62
+ if (mains.length === 1) {
63
+ target = mains[0].name;
64
+ picked = true;
65
+ } else if (mains.length > 1) {
66
+ return { ok: false, error: `多活跃主流程变更(${mains.length} 个:${mains.map((m) => m.name).join('、')})——请带 --change <名> 指定` };
67
+ } else {
68
+ return { ok: false, error: '无活跃主流程变更(quick/scan 会话或全部归档)——handoff 面向主流程阶段交接' };
69
+ }
70
+ }
71
+
72
+ const progress = pm.read(cwd, target);
73
+ if (!progress) return { ok: false, error: `变更不存在: ${target}` };
74
+
75
+ const currentStage = progress.currentStage || '';
76
+ const stageData = (progress.stages && progress.stages[currentStage]) || null;
77
+ const stageCompleted = !!(stageData && stageData.status === 'completed');
78
+ const suggestion = nextStageSuggestion(currentStage, stageCompleted);
79
+ const sessionId = process.env.SILLYSPEC_SESSION_ID || null;
80
+
81
+ const steps = Array.isArray(stageData && stageData.steps) ? stageData.steps : [];
82
+ const doneSteps = steps.filter((s) => s && s.status === 'completed').length;
83
+
84
+ const lines = [];
85
+ lines.push('🔗 交接块(瘦会话模式——下一阶段/续跑在新会话执行,主会话收尾省上下文)');
86
+ lines.push('');
87
+ lines.push('# ── 在新会话粘贴以下内容 ──');
88
+ if (sessionId) lines.push(`export SILLYSPEC_SESSION_ID=${sessionId}`);
89
+ else lines.push('export SILLYSPEC_SESSION_ID=<本变更的会话标识——旧会话 echo $SILLYSPEC_SESSION_ID 取>');
90
+ lines.push(`cd ${cwd}`);
91
+ if (suggestion.next) {
92
+ lines.push(`sillyspec run ${suggestion.next} --change ${target}`);
93
+ } else {
94
+ lines.push('# (流程终态,无续跑命令)');
95
+ }
96
+ lines.push('# ── 交接块结束 ──');
97
+ lines.push('');
98
+ lines.push(`📋 ${target}:当前 ${STAGE_LABELS[currentStage] || currentStage}${steps.length > 0 ? `(步骤 ${doneSteps}/${steps.length})` : ''}${stageCompleted ? ' ✅ 已完成' : ''}`);
99
+ lines.push(`➡️ ${suggestion.reason}`);
100
+ lines.push('💡 为什么可行:CLI prompt 自足——进度快照/模块命中/知识注入由进度库与渲染提供,新会话零背景可续跑。');
101
+ lines.push('⚠️ SILLYSPEC_SESSION_ID 必须保持不变(变更所有权/接管判定按此标识);缺省降级机器级标识只拦他机。');
102
+
103
+ return {
104
+ ok: true,
105
+ change: target,
106
+ autoPicked: picked,
107
+ currentStage,
108
+ stageCompleted,
109
+ suggestion,
110
+ sessionId,
111
+ lines,
112
+ };
113
+ }
package/src/index.js CHANGED
@@ -3402,6 +3402,58 @@ SillySpec worktree — git worktree 隔离管理
3402
3402
  await cmdAgentLog(filteredArgs.slice(1), { json, cwd: dir, specDir });
3403
3403
  break;
3404
3404
  }
3405
+ case 'handoff': {
3406
+ // 阶段跑者瘦会话交接块(P1-5 v1,2026-09-20 对撞实验驱动):打印下一阶段新会话
3407
+ // 启动块(保持 SILLYSPEC_SESSION_ID)+ 状态摘要。纯只读。CLI prompt 自足——
3408
+ // 新会话零背景可续跑,主会话按阶段收尾省肥上下文重发税。
3409
+ const hoChangeIdx = filteredArgs.indexOf('--change');
3410
+ const hoChange = hoChangeIdx >= 0 ? filteredArgs[hoChangeIdx + 1] : undefined;
3411
+ const { buildHandoff } = await import('./handoff.js');
3412
+ const specRootHo = resolvePlatformSpecDir(dir, specDir) || join(dir, '.sillyspec');
3413
+ const ho = await buildHandoff({ cwd: dir, specBase: specRootHo, changeName: hoChange });
3414
+ if (!ho.ok) {
3415
+ console.error(`❌ ${ho.error}`);
3416
+ process.exit(2);
3417
+ }
3418
+ if (json) {
3419
+ console.log(JSON.stringify({
3420
+ schema_version: 1,
3421
+ change: ho.change,
3422
+ auto_picked: ho.autoPicked,
3423
+ current_stage: ho.currentStage,
3424
+ stage_completed: ho.stageCompleted,
3425
+ next_stage: ho.suggestion.next,
3426
+ mode: ho.suggestion.mode,
3427
+ session_id: ho.sessionId,
3428
+ }, null, 2));
3429
+ } else {
3430
+ for (const l of ho.lines) console.log(l);
3431
+ }
3432
+ break;
3433
+ }
3434
+ case 'fr-backfill': {
3435
+ // L2 厚版(2026-09-20-fr-index-l2 / D-003@v2 / FR-04):存量 FR 条目场景正文回填——
3436
+ // active 条目缺「场景正文:」块 → 从来源变更归档 requirements.md 按标题匹配补齐
3437
+ //(幂等;superseded 不回填;来源缺失警告不阻断)。一次性/手动命令,归档管线不挂载。
3438
+ const specRootFb = resolvePlatformSpecDir(dir, specDir) || join(dir, '.sillyspec');
3439
+ const { backfillScenarioBodies } = await import('./fr-index.js');
3440
+ const r = backfillScenarioBodies({
3441
+ knowledgeRoot: join(specRootFb, 'knowledge'),
3442
+ archiveRoot: join(specRootFb, 'changes', 'archive'),
3443
+ });
3444
+ if (json) {
3445
+ console.log(JSON.stringify({ schema_version: 1, backfilled: r.backfilled, skipped: r.skipped, warnings: r.warnings }, null, 2));
3446
+ } else {
3447
+ if (r.backfilled.length === 0) {
3448
+ console.log(`✅ 无待回填条目(已有正文或无来源可回填 ${r.skipped} 条跳过)`);
3449
+ } else {
3450
+ console.log(`📦 回填 ${r.backfilled.length} 条:`);
3451
+ for (const b of r.backfilled) console.log(` - ${b.id} ${b.title}(${b.file})`);
3452
+ }
3453
+ for (const w of r.warnings) console.warn(` ⚠️ ${w}`);
3454
+ }
3455
+ break;
3456
+ }
3405
3457
  case 'dispatch': {
3406
3458
  // SillyHub 派发抽象层的 agent 调用桥(design.md §Phase2 / D-007@v1):
3407
3459
  // 仅做能力探测(probe)与派发策略生成(hint),**不执行任何 tool 调用**——