sillyspec 3.29.1 → 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.1",
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
  ];
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 调用**——
@@ -1325,6 +1325,38 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
1325
1325
  } catch { /* claim 失败不阻断启动 */ }
1326
1326
  }
1327
1327
 
1328
+ // ── 所有权写路径断言(2026-09-20 双写碰撞实证修复):claim 只「不抢不拒」,写操作还须
1329
+ // assert——今晚实证两会话并行驱动同一 change(owner=zcode-fr-l2 他人活跃中,另一会话的
1330
+ // 三条 --done 直通落库,步骤槽混写)。拦截面=写 flag(--done/--reset/--reopen/--skip/
1331
+ // --answer/--confirm/--files:quick 边界追加也是写);读路径(prompt 显示/--status)不拦
1332
+ // (index.js apply/cleanup 只读不接同款)。语义同接管类:self/no-owner 放行;
1333
+ // takeover-stale(活跃窗外)放行并自动接管重写 owner;blocked-active-owner 拒绝(结构化
1334
+ // 错误 + 指引);--takeover 显式强制(逃生阀,重写留痕)。quick 会话身份=changeName 恒
1335
+ // self 零影响;default 容器跳过(同上方 claim)。fail-open:断言异常不拦(护栏退 no-owner)。
1336
+ if (effectiveChange && effectiveChange !== 'default') {
1337
+ const _isWriteOp = isDone || isReset || isReopen || isSkip
1338
+ || flags.includes('--answer') || flags.includes('--confirm') || flags.includes('--files');
1339
+ if (_isWriteOp) {
1340
+ try {
1341
+ const ident = resolveSessionIdentity({
1342
+ flagSession: getFlagValue('--session'),
1343
+ quickChangeName: stageName === 'quick' ? effectiveChange : null,
1344
+ cwd: dirname(specRoot),
1345
+ })
1346
+ const check = pm.assertChangeOwnership(cwd, effectiveChange, {
1347
+ selfSession: ident.session, nowMs: Date.now(), forced: flags.includes('--takeover'),
1348
+ })
1349
+ if (check.allowed) {
1350
+ if (check.action !== 'self') pm.setChangeOwner(cwd, effectiveChange, ident.session)
1351
+ } else {
1352
+ console.error(`\n🚫 变更 ${effectiveChange} 正被其他会话持有(owner: ${check.owner},最后活跃: ${check.lastActive || '未知'}),活跃窗口 ${Math.round(check.heartbeatMs / 60000)} 分钟内拒绝写操作(--done/--reset/--reopen/--skip/--answer/--confirm/--files)`)
1353
+ console.error(` 两个会话同时驱动同一 change 会步骤槽混写(2026-09-20 实证)。确认对方已放弃:等活跃窗过期后重试(自动接管),或本条命令加 --takeover 显式强制接管(重写 owner 留痕);或与对方会话协调归属。`)
1354
+ process.exit(1)
1355
+ }
1356
+ } catch { /* 断言异常不拦(fail-open,同 claim) */ }
1357
+ }
1358
+ }
1359
+
1328
1360
  // --reset
1329
1361
  if (isReset) {
1330
1362
  return await resetStage(pm, progress, stageName, cwd, effectiveChange, platformOpts)
@@ -714,6 +714,13 @@ export async function completeStep(pm, progress, stageName, cwd, outputText, inp
714
714
  const total = steps.length
715
715
  console.log(`✅ ${stageName} 阶段已完成(${total}/${total} 步)`)
716
716
 
717
+ // 瘦会话交接提示(P1-5 v1,2026-09-20 对撞实验驱动):主流程阶段完成时点提示
718
+ // 「下一阶段可新会话续跑」——CLI prompt 自足(进度/上下文全由进度库与注入提供),
719
+ // 每阶段一个瘦会话省掉肥上下文 × N 请求的重发税。advisory 一行,不强制。
720
+ if (['brainstorm', 'plan', 'execute', 'verify'].includes(stageName) && changeName) {
721
+ console.log(`💡 瘦会话模式:下一阶段可在新会话续跑(sillyspec handoff --change ${changeName} 生成交接块;保持 SILLYSPEC_SESSION_ID 不变)——省肥上下文重发税`)
722
+ }
723
+
717
724
  // 平台模式产物落点指针(platform-docs-dual-location,2026-09-15 EHS 实证):完成时点在
718
725
  // 工作树 .sillyspec/ 刷新人类可读指针(本地模式零行为,fail-soft 内部全兜)。
719
726
  writePlatformDocsPointer(cwd, changeName, platformOpts, stageName)
@@ -352,7 +352,7 @@ export function applyGateSnapshotCopy(cwd, snapshotRoot) {
352
352
  * @param {{ cwd: string, files: string[] }} opts cwd=主仓根;files=本会话变更文件(仓库根相对 POSIX)
353
353
  * @returns {{ snapshotRoot: string, cleanup: () => void, reason?: string }|null} 失败返回 null(调用方回退主仓)
354
354
  */
355
- export function createGateSnapshot({ cwd, files, sourceRoot = null, skipImportSmoke = false }) {
355
+ export function createGateSnapshot({ cwd, files, sourceRoot = null, skipImportSmoke = false, mergeBase = null }) {
356
356
  let snapshotRoot = null
357
357
  try {
358
358
  // 前置:主仓须是 git 仓且有 HEAD(无 git 环境回退主仓现行为)
@@ -383,12 +383,20 @@ export function createGateSnapshot({ cwd, files, sourceRoot = null, skipImportSm
383
383
  let overlaid = 0
384
384
  // 双写一致性(2026-09-20 红线机检变更实证:主 agent 在主仓直写而 execute 已建 worktree 时,
385
385
  // overlay 源=worktree 拿陈旧内容盖掉快照 HEAD 的新版——快照内 import 找不到新导出→门禁假红)。
386
- // 三方取新:sourceRoot 与 cwd 同文件内容不同时,选「与快照 HEAD 基线不同」的那份;
387
- // 两份都变了且互不相等(真分叉)→ 显式警告 + 取 cwd(主仓直写=本会话最新编辑位约定)。
386
+ // 祖先判据(fr-index-l2 二次实证升级):基线优先取 merge-base 的每文件祖先内容
387
+ //(git show mb:file——worktree 有自家 checkpoint 提交时,快照 HEAD 不是公共祖先,
388
+ // wtDiff/cwdDiff 同谱不可分;祖先内容可精确分「worktree 停在分叉点=陈旧」与「真有修改」)。
389
+ // 两份都变于祖先且互不相等(真双写分叉)→ 显式警告 + 取 cwd(主仓直写=本会话最新编辑位约定)。
388
390
  // 行尾归一(坑 autocrlf-byte-misjudge):本机 autocrlf=true 时快照 checkout 是 CRLF、
389
391
  // 工作区文件常是 LF——字节直比会误判「两边都改」。比较一律 CRLF→LF 归一后做。
390
392
  const normalizeEol = (buf) => String(buf).replace(/\r\n/g, '\n')
391
393
  const headFileContent = (rel) => {
394
+ if (mergeBase) {
395
+ try {
396
+ const anc = git(cwd, ['show', `${mergeBase}:${rel}`])
397
+ if (typeof anc === 'string') return normalizeEol(anc)
398
+ } catch { /* 祖先无此文件(新增文件)→ 落到快照 HEAD 口径 */ }
399
+ }
392
400
  try { return normalizeEol(readFileSync(join(snapshotRoot, rel))) } catch { return null }
393
401
  }
394
402
  for (const f of files) {
@@ -633,16 +641,30 @@ export async function createVerifyGateSnapshot({ cwd, changeName, specBase, plat
633
641
 
634
642
  // overlay 源:worktree meta 感知(native worktree 在则从 worktree 根取「本变更分支内容」)
635
643
  let sourceRoot = null
644
+ let snapMergeBase = null
636
645
  try {
637
646
  const { WorktreeManager } = await import('../worktree.js')
638
647
  const wm = new WorktreeManager({ cwd })
639
648
  const meta = wm.getMeta(changeName)
640
649
  if (meta && meta.worktreePath && meta.mode !== 'in-place-fallback' && existsSync(meta.worktreePath)) {
641
650
  sourceRoot = meta.worktreePath
651
+ // 陈旧 worktree 判定(2026-09-20 fr-index-l2 实证):主 agent 在主仓直写并提交后,
652
+ // worktree 停在基线——内容三方(worktree/cwd/快照HEAD)判不出「领先」与「落后」
653
+ // (同谱)。精确判据 = merge-base 的每文件祖先内容(git show mb:file):
654
+ // worktree 内容==祖先 → 陈旧(主仓取新);≠ → worktree 有真修改(正常流保 worktree)。
655
+ // 此处只算 mergeBase 一次(拓扑级),每文件祖先内容由 createGateSnapshot 按需取。
656
+ try {
657
+ const wtTip = git(sourceRoot, ['rev-parse', 'HEAD'])
658
+ const mainHead = git(cwd, ['rev-parse', 'HEAD'])
659
+ const mb = (wtTip && mainHead && wtTip !== mainHead) ? git(cwd, ['merge-base', wtTip, mainHead]) : null
660
+ if (wtTip && mainHead && wtTip !== mainHead && mb) {
661
+ snapMergeBase = String(mb).trim()
662
+ }
663
+ } catch { /* 拓扑判定失败退快照HEAD口径(既有兜底) */ }
642
664
  }
643
665
  } catch { /* meta 读取失败 → 主仓 cwd 源(files 已含本变更 working-tree 改动) */ }
644
666
 
645
- const snap = createGateSnapshot({ cwd, files: changeFiles, sourceRoot })
667
+ const snap = createGateSnapshot({ cwd, files: changeFiles, sourceRoot, mergeBase: snapMergeBase })
646
668
  if (!snap) return null
647
669
 
648
670
  // 变更文档随快照:module-impact.md(test_strategy=evidence-auto 消费)/tasks.md 等
package/src/run/prompt.js CHANGED
@@ -1151,9 +1151,9 @@ export async function outputStep(stageName, stepIndex, steps, cwd, changeName, d
1151
1151
  if (entries.length === 0) {
1152
1152
  promptText = promptText.replace(/\{FR_INDEX_DIGEST\}/g, '(触达域暂无 active FR 索引条目——本变更大概率是这些域的首批需求,照常写作)')
1153
1153
  } else {
1154
- const lines = entries.map((e) => `- ${e.id} ${e.title}(来源 ${e.change}${e.scenarios.length ? ';场景:' + e.scenarios.slice(0, 3).join(',') : ''})`)
1154
+ const lines = entries.map((e) => `- ${e.id} ${e.title}(来源 ${e.change}${e.scenarios.length ? ';场景:' + e.scenarios.slice(0, 3).join(',') : ''}${(e.decisions || []).length ? ';依据:' + e.decisions.slice(0, 3).join('、') : ''})`)
1155
1155
  lines.push('')
1156
- lines.push('> 域解析自本变更 design.md 文件清单(漏域先核对清单)。改写/取代已有行为 → 对应 FR 块加承接行;新行为 → 新 FR 块。superseded 条目默认不列(历史回溯自行读 knowledge/fr/)。')
1156
+ lines.push('> 域解析自本变更 design.md 文件清单(漏域先核对清单)。改写/取代已有行为 → 对应 FR 块加承接行;新行为 → 新 FR 块。superseded 条目默认不列(历史回溯自行读 knowledge/fr/)。依据决策(L2)= 当年取舍锚——翻案须先读 knowledge/decisions/<域>.md 的否决理由,满足复潮条件走 D-xxx@vN+1,不得静默改行为。承接行可带退役理由:`承接: FR-<域>-NNN(退役理由:一句话)`——归档时写进被取代条目(理由内禁逗号);条目正文是截断摘要,全文锚(全文:<归档路径>#FR-NN)由 CLI 自动落。')
1157
1157
  promptText = promptText.replace(/\{FR_INDEX_DIGEST\}/g, lines.join('\n'))
1158
1158
  }
1159
1159
  try {