sillyspec 3.32.7 → 3.32.9

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.
@@ -1329,7 +1329,7 @@ async function computeFullFlowAudit({ cwd, specBase, changeName, platformOpts, c
1329
1329
  * { key, repoPath, anchor, totals, degraded, degradedReason, patch?, patchSha256? };
1330
1330
  * notes 为调用方 note 链的增量行(跨仓计数行 + 降级行)。
1331
1331
  */
1332
- export function reconcileCrossRepoPlan({ cwd, specBase, runtimeRoot, changeName, planEntries, collectPatch = false }) {
1332
+ export function reconcileCrossRepoPlan({ cwd, specBase, runtimeRoot, changeName, planEntries, collectPatch = false, crossBaselines = null }) {
1333
1333
  const rows = []
1334
1334
  const repos = []
1335
1335
  const notes = []
@@ -1344,7 +1344,7 @@ export function reconcileCrossRepoPlan({ cwd, specBase, runtimeRoot, changeName,
1344
1344
  let repoActual = null
1345
1345
  let repoFailure = null
1346
1346
  try {
1347
- repoActual = collectRepoActual({ repoKey, specBase, cwd, runtimeRoot, changeName })
1347
+ repoActual = collectRepoActual({ repoKey, specBase, cwd, runtimeRoot, changeName, crossBaselines })
1348
1348
  } catch (e) {
1349
1349
  repoFailure = e && e.message ? String(e.message).split('\n')[0] : String(e)
1350
1350
  }
@@ -168,21 +168,27 @@ export function detectBatchCheckCadence(events) {
168
168
  /**
169
169
  * 单拍勾选门决策(2026-09-29-batch-tick-gate,纯函数):检出单拍跳(batchTick 非 null)
170
170
  * 时的收口动作裁决。镜像豁免分支退役(2026-10-07-thin-tasks-v3——任务面统一为 agent
171
- * 工作分解,无镜像面)。四态(决策顺序即豁免优先序):
171
+ * 工作分解,无镜像面)。五态(决策顺序即豁免优先序):
172
172
  * 'silent' 无单拍跳;
173
173
  * 'bypass' --allow-batch-tick 显式旁路——留痕放行(同意门先例);
174
- * 'advisory' 机器代勾可解释整跳(autopilotTicked >= 跳幅——governance-autopilot 代勾是
175
- * 单拍多格机械写,非 agent 纪律面;代勾数不可解释最大跳时落到 reject——防「留
176
- * 一格给代勾补、自身大跳蹭豁免」的对抗绕过,二轮评审 P3-E);
177
- * 'reject' 非旁路、代勾解释不了整跳——agent 一把勾,拒收。
178
- * @param {{batchTick:object|null, nonMirrorCount?:number|null, allowBatchTick?:boolean, autopilotTicked?:number}} args
174
+ * 'advisory' 三路:哨兵面未知(fail-open 防误拒);哨兵证据完备(status=complete——
175
+ * 2026-10-13-batchtick-evidence-domain-hint:同形态 git 时点检查「一把勾模式」
176
+ * 因 token 证据齐放行,事件流门对齐该语义——证据是提交哈希锚定的客观面,
177
+ * 损害只剩 per-task 时间戳信号面;证据缺失形态不走此态,维持 reject);机器代勾
178
+ * 可解释整跳(autopilotTicked >= 跳幅——governance-autopilot 代勾是单拍多格机械写,
179
+ * 非 agent 纪律面;代勾数不可解释最大跳时落到 reject——防「留一格给代勾补、自身
180
+ * 大跳蹭豁免」的对抗绕过,二轮评审 P3-E);
181
+ * 'reject' 非旁路、证据不完备、代勾解释不了整跳——agent 一把勾,拒收。
182
+ * @param {{batchTick:object|null, nonMirrorCount?:number|null, allowBatchTick?:boolean, autopilotTicked?:number, evidenceComplete?:boolean}} args
179
183
  * nonMirrorCount 保留为哨兵面未知信号(null → fail-open advisory);镜像-only 语义已退役。
184
+ * evidenceComplete 缺省 false(additive——既有调用方零感知)。
180
185
  * @returns {{action:'silent'|'advisory'|'bypass'|'reject', reason:string}}
181
186
  */
182
- export function resolveBatchTickAction({ batchTick, nonMirrorCount = null, allowBatchTick = false, autopilotTicked = 0 } = {}) {
187
+ export function resolveBatchTickAction({ batchTick, nonMirrorCount = null, allowBatchTick = false, autopilotTicked = 0, evidenceComplete = false } = {}) {
183
188
  if (!batchTick) return { action: 'silent', reason: 'no-batch-tick' };
184
189
  if (allowBatchTick === true) return { action: 'bypass', reason: 'flag' };
185
190
  if (nonMirrorCount === null) return { action: 'advisory', reason: 'sentinel-unknown' };
191
+ if (evidenceComplete === true) return { action: 'advisory', reason: 'evidence-complete' };
186
192
  if (autopilotTicked >= batchTick.to - batchTick.from) return { action: 'advisory', reason: 'autopilot-ticked' };
187
193
  return { action: 'reject', reason: 'agent-batch-tick' };
188
194
  }
@@ -138,6 +138,9 @@ export function renderReviewJsonContract({ stage, changeDir, reviewRunId, tier,
138
138
  '## review.json 产物契约(CLI Stage Review Gate 将硬校验,以下为精确 schema —— 事前给的 == 事后查的)',
139
139
  '',
140
140
  '> 路径:`{SPEC_ROOT}/.runtime/stage-reviews/' + stageLbl + '-' + runIdLbl + '/review.json`(本次 reviewType=' + reviewType + ',主审查文档=' + mainDocRel + ')',
141
+ ...(changeDir
142
+ ? ['> **本次完整绝对路径(直接复制给审查子代理,勿手拼 runId/漏阶段前缀——坑 stage-review-path-prefix 2026-10-10 实证:marker 只有裸 runId,子代理按 runId 直写漏「<stage>-」前缀即错位)**: `' + join(dirname(dirname(changeDir)), '.runtime', 'stage-reviews', stageLbl + '-' + runIdLbl, 'review.json') + '`']
143
+ : []),
141
144
  '',
142
145
  '### 必填字段(validateStageReviewSchema 硬校验,缺任一即 schema 失败阻断)',
143
146
  '- `schemaVersion`: ' + REVIEW_SCHEMA_VERSION,
@@ -67,7 +67,7 @@ sillyspec flow start --change <YYYY-MM-DD-名> --input "<动机与背景>
67
67
  (2 调用协议收口)。用户同意则本阶段可在此收尾(\`--done\` 并提示转轻量变更),不必继续走完整设计流程。
68
68
  - **明显 large**(需要 Wave 并行编排/上下文分片/多阶段治理,或用户显式要求完整流程)→ 不要提示轻量变更,继续进入下一步「对话式探索与需求澄清」。
69
69
  - **拿不准 → 继续探索**(这正是头脑风暴的职责;澄清完成后 Step 8 按同轴精判——注意:经过头脑风暴澄清的需求通常已可单上下文吞吐,出口默认收编轻量道)。
70
- - 这是**粗判**,只为让明显的纯执行小变更免走设计流程;后续「生成规范文件」步骤做精判兜底。
70
+ - 这是**粗判**,只为让明显的纯执行小变更免走设计流程;后续「生成规范文件」步骤做精判兜底(方案在澄清中缩水后按定稿重判,本步印象不作数)。
71
71
 
72
72
  ### 输出
73
73
  项目现状理解摘要(3-5 句话,关键约定和架构决策)+ 可能涉及的模块列表 + 本次需求所属子项目 + (如命中)quick 建议
@@ -248,11 +248,15 @@ sillyspec flow start --change <YYYY-MM-DD-名> --input "<动机与背景>
248
248
  3. 展示完整设计方案(不要逐段停顿,一次性展示)
249
249
  4. 分段设计含新机制时同上检索(固定动作):设计的分段内容引入新机制词(例词为举例非机制,不构成封闭清单)时,先跑 \`sillyspec knowledge search --query "<机制词>"\`,命中条目必读(尤其 status=rejected 的防复潮条目)再定稿设计
250
250
  5. 确认变更名(格式:\`YYYY-MM-DD-<简短描述>\`,例如 \`2026-05-13-user-auth\`——CLI 已强制:新建/改名不带日期前缀直接 exit 2)
251
- 6. 按「HTML 原型分级」生成原型或写出跳过原因(二选一必填,见下)
251
+ 6. 按「HTML 原型分级」三选一必填:生成原型 / 登记复用既有原型 / 写出跳过原因(见下)
252
252
  7. 暂停等待用户确认或修改意见
253
253
 
254
254
  ### HTML 原型分级(按改动程度,不是"是否碰前端文件")
255
255
  判定对象是**改造后的界面相对现状的变化程度**:
256
+ - **免生成·登记复用**(用户已提供**可复跑**的 HTML 原型且覆盖本次 UI 面——先判本档再判改动程度):
257
+ - 不重画。把原型文件**复制**进 \`{SPEC_ROOT}/changes/<change-name>/\` 并**必须命名为 \`prototype-<名称>.html\`**——execute 阶段按此命名扫描注入原型引用,保留中文名/原名=没登记=白复用
258
+ - 输出复用声明:原型来源路径 + 覆盖面(覆盖哪些页面/端);只覆盖部分 UI 面时如实声明,未覆盖部分仍按下两档生成(变更目录允许多个 prototype-*.html 共存)
259
+ - 截图/静态设计稿**不适用本档**(不可复跑)——仍按「必须生成」产出可跑 HTML
256
260
  - **必须生成**(布局/结构/流程级变化):
257
261
  - 新增页面、视图、路由
258
262
  - 页面布局变化:区域增删、骨架重排、信息层级调整(如单栏改多栏、列表改看板)
@@ -265,19 +269,20 @@ sillyspec flow start --change <YYYY-MM-DD-名> --input "<动机与背景>
265
269
  - 文案、颜色、间距、字号、图标等样式微调
266
270
  - 纯后端逻辑/CLI/配置/文档,无任何界面变化
267
271
 
268
- ### 生成要求
269
- 生成一个独立的 HTML 文件(内联 CSS + JS),保存到:
270
- \`{SPEC_ROOT}/changes/<change-name>/prototype-<名称>.html\`(目录不存在则先创建)
271
- - 单文件,浏览器直接打开
272
- - 高保真:布局/组件/交互流程按真实效果呈现,后续 execute 阶段可直接对照复用
273
- - 与项目代码风格一致:优先复用项目现有技术栈/组件库/设计 token(先查 scan 文档与现有前端代码),不另起风格
272
+ ### 生成要求(定稿原型=真码产物,不是手绘图)
273
+ 入口文件保存到 \`{SPEC_ROOT}/changes/<change-name>/prototype-<名称>.html\`(目录不存在则先创建;该命名是 execute 注入契约,不改)。真码五要素:
274
+ - **与实现同方言**:用项目真实前端栈构建——组件库/设计 token/样式从项目源码或 node_modules 真实引入(例:React 项目挂真实组件而非仿制标签、Vue 项目用真实 SFC;例词非封闭清单),不手搓仿制样式
275
+ - **可复跑**:能被本项目工具链跑起来(dev server / 组件文档页 / 构建产物均可,入口 HTML 从本地 node_modules 引真实库也行)——先查 scan 文档与现有前端代码就近发现项目原型管线,已有则复用,没有则搭最小入口并留在项目惯例位置
276
+ - **高保真=组件即实现原料**:布局/组件/交互按真实效果呈现,execute 阶段直接对照搬组件,不经过"看图重新翻译"
277
+ - **手绘单文件(自造样式)仅限一次性粗选对比,不得作为定稿原型**(frontend-apple-style 实证:手绘定稿后每条意见都要反向翻译回真实代码);粗选定了方向,升级真码再收口
278
+ - **例外与降级**:项目本身即单文件 HTML 方言(纯静态站)时单文件即真码,不适用手绘限定;无法同方言真码化(如目标平台无 HTML 运行面)时显式声明降级与理由请用户裁决留痕——静默降级会被收口「UI 视觉证据」探针拦下
274
279
 
275
280
  ### 铁律 — 必须等待用户确认设计
276
281
  - 展示完整设计方案后必须暂停等用户确认,**不要自己说"设计已充分确认"然后推进**(命令由 CLI 在下方注入)
277
- - 原型生成或跳过声明必须在暂停**之前**完成——暂停是本步骤最后动作,用户确认后直接进入写 design.md,不会回头补
282
+ - 原型生成、复用登记或跳过声明必须在暂停**之前**完成(复用登记=原型文件已按 \`prototype-<名称>.html\` 落在变更目录)——暂停是本步骤最后动作,用户确认后直接进入写 design.md,不会回头补
278
283
 
279
284
  ### 输出
280
- 完整设计方案 + 变更名 + 原型文件路径 或 跳过原因(二选一必填;跳过时写明分级依据,用户可当场否决要求补)
285
+ 完整设计方案 + 变更名 + 原型文件路径 或 复用声明(来源+覆盖面+登记后路径)或 跳过原因(三选一必填;跳过时写明分级依据,用户可当场否决要求补)
281
286
 
282
287
  ### 注意
283
288
  - 不要一次输出大段文字,按模块/Phase 分段
@@ -531,9 +536,12 @@ status: passed | needs-user-input | blocked | skipped
531
536
  按**复杂度/上下文轴**判断本次变更规模(2026-09-29-brainstorm-exit-thin-default:此前文件数轴「≤2 文件才 small」与选道表第 3 行的复杂度措辞不一致,且模糊需求探索完很少只碰 2 文件——出口几乎必然五阶段,实测坐实):
532
537
  - **small(收编轻量道)**:单上下文可吞吐——预计无需 Wave 并行编排、无需上下文分片(单会话吃得下全部任务)、无多阶段治理/设计期人机对抗需求
533
538
  - **large(完整五阶段)**:需要 Wave 并行编排、或单任务规模大需上下文分片、或多阶段治理、或用户显式要求完整流程
539
+ - **反判据(2026-10-14-scale-large-evidence,deploy-release-notes 实证:零建表定稿被早期印象判 large 走了五阶段)**:跨多端/多仓/多文件**不是** large 依据——轻量道支持多文件与跨仓交付;任务间**顺序依赖也不是**(B 等 A 的契约/产物正是轻量道任务循环的天然形态,一波等一波不构成 Wave)。Wave 证据=存在**可真并行**的任务簇(互相不等)**且单会话上下文吃不下**,两条同时成立才够 large
540
+ - **Wave 证据落盘**:判 large 时必须在 design.md(总体方案/Phase-Wave 处)写明——哪些任务簇可真并行、单会话上下文为何吃不下;**写不出 Wave 证据 = 改判 small**
541
+ - **按定稿方案重判**:Step 2 早期筛查只是路由粗判,方案在澄清中缩水(砍建表/砍迁移/砍端点)后必须按**定稿实质**重判——沿用早期筛查印象会把已缩水的方案误判厚(实证:plan 阶段自判单会话执行〔execution_mode=main〕反证 Wave 并非真需求)
534
542
  - **拿不准 → small**(与 thin-default-flip 同哲学:预判反诱发误升厚——R16 实证;升厚留运行时证据:实测失败自动升厚+--upgrade-thick 用户决策)
535
543
  在 design.md frontmatter 写入 \`scale: small\` 或 \`scale: large\`(frontmatter 不存在则补 \`author\`/\`created_at\`/\`scale\`)。规模决定下面的产物范围和实现路径。
536
- (注:早期「加载项目上下文」步骤已做过一次粗判并可能建议过走 quick;此处基于 design 方案实质做精判兜底——判据同轴。)
544
+ (注:早期「加载项目上下文」步骤已做过一次粗判并可能建议过走 quick;此处基于 design 方案实质做精判兜底——判据同轴,方案缩水后按定稿重判、早期印象不作数。)
537
545
 
538
546
  ### 操作
539
547
  1. **按规模生成规范文件**:
@@ -1425,7 +1425,7 @@ ${worktreeNoticeSingle}
1425
1425
 
1426
1426
  **主代理直写(execution_mode: main)——你直接实现,不派子代理。**
1427
1427
 
1428
- 逐任务闭环(串行,完成一个再下一个):读 task 卡(路径见下方任务摘要)→ 在 worktree 内实现(TDD:先写测试再实现)→ 跑该 task 卡 verify 命令 → \`sillyspec wt-commit --change <change-name> -- <task-files>\` 逐任务提交 → 手动勾选 tasks.md 对应 checkbox(完成=实现+测试绿+wt-commit 即勾,2026-09-26 Task Review 退役起勾选回归 agent 手动,同 thin 工作单元语义)→ 下一任务。
1428
+ 逐任务闭环(串行,完成一个再下一个):读 task 卡(路径见下方任务摘要)→ 在 worktree 内实现(TDD 按层适用:纯逻辑先写测试再实现,行为契约断言用户可见行为,样式/纯配置不写——判据见「测试用例设计」)→ 跑该 task 卡 verify 命令 → \`sillyspec wt-commit --change <change-name> -- <task-files>\` 逐任务提交 → 手动勾选 tasks.md 对应 checkbox(完成=实现+测试绿+wt-commit 即勾,2026-09-26 Task Review 退役起勾选回归 agent 手动,同 thin 工作单元语义)→ 下一任务。
1429
1429
 
1430
1430
  worktree 隔离 / 写入守卫(allowed_paths)/ verify 门禁全部保留——只换执行宿主,不换任何防线。
1431
1431
 
@@ -25,12 +25,10 @@ export const definition = {
25
25
  6. 讨论 UI/交互方案且用户明确要求原型时,按下方「HTML 原型生成」执行
26
26
 
27
27
  ### HTML 原型生成(仅用户明确要求时)
28
- 探索保持只读姿态:不主动写原型文件;UI 讨论成形时可以提议("要不要出个原型看效果"),用户同意后再生成。生成要求与 brainstorm 阶段的原型对齐:
29
- - 单文件 HTML(内联 CSS + JS),浏览器直接打开
30
- - 高保真呈现讨论中的布局/组件/交互流程——用户看到的是"这个方案做出来长什么样",不是示意图
31
- - 与项目现有界面风格一致:先读 scan 文档与现有前端代码,复用现有组件库/设计 token 的观感,不另起风格
28
+ 探索保持只读姿态:不主动写原型文件;UI 讨论成形时可以提议("要不要出个原型看效果"),用户同意后再生成。探索期原型是**粗选对比件**(求快不求真,手绘单文件含内联 CSS+JS 在探索期合法,浏览器直接打开):
29
+ - 呈现讨论中的布局/组件/交互流程——用户看到的是"这个方案做出来长什么样",不是示意图;能用项目真实组件库/设计 token 观感的尽量用(先读 scan 文档与现有前端代码),降低与真实效果的偏差
32
30
  - 保存到 \`{SPEC_ROOT}/explore/prototype-<名称>.html\`(目录不存在则先创建;用户指定了其他位置则从其指定)
33
- - 原型是探索讨论工具、不是交付物,也不是实现代码——不要顺手把原型接进项目源码或构建;话题后续进入 brainstorm 时,把原型文件复制到 \`{SPEC_ROOT}/changes/<变更名>/\` 可直接复用为该变更的原型
31
+ - 原型是探索讨论工具、不是交付物,也不是实现代码——不要顺手把原型接进项目源码或构建;话题后续进入 brainstorm 时分两路:已是可复跑真码的原型复制到 \`{SPEC_ROOT}/changes/<变更名>/\`(**保留 \`prototype-<名称>.html\` 命名**——execute 按此命名注入,改名=不注入)走登记复用;手绘粗选件只是方向输入,brainstorm 定稿须按其「生成要求」升级为真码(同方言/可复跑),不得把手绘件直接当定稿登记
34
32
 
35
33
  ### 输出
36
34
  探索结论、选项对比、风险清单或 ASCII 图;如生成了原型,附原型文件路径
@@ -596,6 +596,28 @@ async function tryImportSubcommandImpl(importPath, moduleName) {
596
596
  * @param {object} opts - { specDir }
597
597
  */
598
598
  export async function cmdKnowledge(args, dir, opts = {}) {
599
+ // --help/-h 全局拦截(坑:探索型调用 knowledge refresh --help 被当默认参数直接执行
600
+ // ——cmdRefresh 无参即全量重写 generated/,副作用写 34 文件。帮助请求在任何子命令
601
+ // 前消化,危险面子命令不再被误触发)。
602
+ if (args.includes('--help') || args.includes('-h')) {
603
+ const lines = [
604
+ '用法: sillyspec knowledge <子命令> [参数]',
605
+ '',
606
+ '子命令:',
607
+ ' search "<关键词>" 检索知识库(命中自动注入 prompt 的同源面)',
608
+ ' inspect <文件> 查看单个知识文件条目结构',
609
+ ' validate 校验知识库完整性(INDEX 锚点/注册/幽灵条目——纯读)',
610
+ ' digest 治理信号卡(rot/伪域/坏绑定——纯读)',
611
+ ' stats 覆盖率/死条目统计(纯读)',
612
+ ' refresh 重扫 scan 文档重写 generated/ 区(**写盘**——先 validate 再用)',
613
+ ' propose / classify / inbox / graph / stats',
614
+ '',
615
+ '写盘面子命令(refresh/propose/classify)先跑同名只读命令确认现状;探索用法先看本帮助。',
616
+ ]
617
+ for (const l of lines) console.log(l)
618
+ output(true, { help: true, subcommands: ['search', 'inspect', 'validate', 'digest', 'refresh', 'propose', 'classify', 'inbox', 'graph', 'stats'] })
619
+ return
620
+ }
599
621
  const subCommand = args[0] || ''
600
622
 
601
623
  switch (subCommand) {
@@ -17,7 +17,7 @@ const readFileSync = (filePath, encoding) => _readFileSync(filePath, encoding).r
17
17
  import { join as pJoin, basename } from 'path'
18
18
  import { tmpdir } from 'os'
19
19
  import jsYaml from 'js-yaml'
20
- import { parseFileChangeList, pathMatches } from '../change-list.js'
20
+ import { parseFileChangeList, pathMatches, REPO_SECTION_HEADER_RE } from '../change-list.js'
21
21
  import { getRule } from '../stage-contract-spec.js'
22
22
  import { TASKCARD_PLACEHOLDERS } from '../taskcard-placeholders.js'
23
23
  import { validateScriptCommands } from './cmd-existence.js'
@@ -942,13 +942,10 @@ export function validateTaskCommands(changeDir, projectRoot, modules = null, rep
942
942
  // design 清单解析与 allowed_paths 对账共用同一套匹配语义,避免两处逻辑漂移。
943
943
 
944
944
  /**
945
- * design §6 按仓分段段头识别(D-014):`## <repo> 仓变更`(如 `## sillyspec 仓变更`)。
946
- * 段头为 h2/h3,repo 名取段头首 token(去空白/编号前缀)。容忍可选编号前缀、(…)备注后缀
947
- * (`(跨仓,X1-X4)` 等,全/半角括号均可)与尾随空白/冒号——后缀不容忍时(用户实证
948
- * 2026-08-29)`### sillyspec 仓变更(跨仓,X1-X4)` 整段判 main、跨仓文件全量误报未覆盖。
949
- * 不命中(非仓变更段头)→ null。
945
+ * 「<repo> 仓变更」段头识别(D-014):`## <repo> 仓变更`(如 `## sillyspec 仓变更`)。
946
+ * 正则已上移 change-list.js 导出(单一源,2026-10-13-h2-repo-heading-parse——thin 收口
947
+ * 对账解析器同步消费该段头形态),此处 import 使用;容忍语义见 change-list.js 处注释。
950
948
  */
951
- const REPO_SECTION_HEADER_RE = /^#{2,3}\s+(?:\d+[.)]\s*)?([A-Za-z0-9_.\-]+)\s*仓变更(?:\s*[((][^()()]*[))])?\s*[::]?\s*$/
952
949
 
953
950
  /**
954
951
  * 疑似段头宽匹配:标题行含「<token> 仓变更」字样(token 与仓变更间有空白)。
@@ -1566,6 +1563,44 @@ export function validateTaskcardsCli({ changeDir, projectRoot = null }) {
1566
1563
  errors.push(`${id}: target_files 形态非法——${bad.map(b => `${b.raw}(${b.invalid})`).join(';')}`)
1567
1564
  }
1568
1565
  }
1566
+ // provides/expects_from 结构校验(坑 taskcard-validate-vs-postcheck-gap,2026-10-10
1567
+ // 实证:散文行 `- contract Foo fields [a, b]` 是合法 YAML 字符串数组,--validate 过、
1568
+ // 契约解析器静默过滤(无 contract 键即丢)→ plan --done 才报「provider 未声明」。
1569
+ // 在填卡自检轮暴露:字符串元素/缺 contract/fields·needs 非数组 = 形态错,给出映射式模板。
1570
+ const id2 = (content.match(/^id:\s*(.+)/m)?.[1] || '').trim() || f
1571
+ const fm = fmParse.fm || {}
1572
+ const _tpl = '映射式:- contract: <名> / fields: [a, b](expects_from 同构,needs 替 fields)'
1573
+ if (Array.isArray(fm.provides)) {
1574
+ for (const pe of fm.provides) {
1575
+ const bad = typeof pe === 'string'
1576
+ || !pe || typeof pe !== 'object'
1577
+ || typeof pe.contract !== 'string' || !pe.contract.trim()
1578
+ || (pe.fields !== undefined && !Array.isArray(pe.fields))
1579
+ if (bad) {
1580
+ errors.push(`${id2}: provides 条目形态非法(${typeof pe === 'string' ? `散文行「${pe.slice(0, 40)}」` : '缺 contract 键或 fields 非数组'})——${_tpl}`)
1581
+ break
1582
+ }
1583
+ }
1584
+ }
1585
+ if (fm.expects_from && typeof fm.expects_from === 'object' && !Array.isArray(fm.expects_from)) {
1586
+ for (const [prov, contracts] of Object.entries(fm.expects_from)) {
1587
+ if (!Array.isArray(contracts)) {
1588
+ errors.push(`${id2}: expects_from.${prov} 应为契约数组(映射式:- contract: <名>
1589
+ needs: [a, b])`)
1590
+ continue
1591
+ }
1592
+ for (const ce of contracts) {
1593
+ const bad = typeof ce === 'string'
1594
+ || !ce || typeof ce !== 'object'
1595
+ || typeof ce.contract !== 'string' || !ce.contract.trim()
1596
+ || (ce.needs !== undefined && !Array.isArray(ce.needs))
1597
+ if (bad) {
1598
+ errors.push(`${id2}: expects_from.${prov} 条目形态非法(${typeof ce === 'string' ? `散文行「${ce.slice(0, 40)}」` : '缺 contract 键或 needs 非数组'})——${_tpl}`)
1599
+ break
1600
+ }
1601
+ }
1602
+ }
1603
+ }
1569
1604
  }
1570
1605
  }
1571
1606
  } catch {
package/src/sync.js CHANGED
@@ -18,6 +18,7 @@ import { safeGit } from './git-helper.js';
18
18
  import { openDatabase } from './db-engine.js';
19
19
  import { PLATFORM_MANAGED_FILENAME, QUICK_SID_RE } from './run/shared.js';
20
20
  import { syncSpecTree } from './spec-sync.js';
21
+ import { lookupDeletedLedger } from './deleted-ledger.js';
21
22
  import { bindSyncNoiseFromCwd, syncConnectionWarn, isConnectionClassStatus, noteSyncSuccess, syncChangeDeletedLog, syncChangeDeletedWarn, noteChangeDeletedResolved, syncSelfHealWarn } from './sync-noise.js';
22
23
 
23
24
  // sync 是 best-effort(网络失败只 warn):平台指针失效时不抛,跳过平台、回退本地。
@@ -747,6 +748,18 @@ export class SyncManager {
747
748
  return { synced: 0, errors: [`读取 progress 失败: ${err.message}`] };
748
749
  }
749
750
  if (progressData === null) {
751
+ // 删除台账兜底(2026-10-13-sync-ledger-tombstone,事故 2026-10-13-new-change-e5a27813):
752
+ // DB 行被外部物理清除(rm + 裸 SQL)后 serializeForSync 恒 null、X1 墓碑链失源,
753
+ // 平台行永挂 active。台账里「存在过且已达终态」时合成最小载荷补推墓碑,让平台
754
+ // _apply_cli_tombstone 收敛 location。台账无此名(从未存在,如 --change 打错字)
755
+ // 不发——误发会把平台同名真变更软删。quick 会话豁免(与 tombstoneDue 同口径)。
756
+ if (!QUICK_SID_RE.test(changeName)) {
757
+ const ledger = lookupDeletedLedger(safePlatformSpecDir(this.cwd) || join(this.cwd, '.sillyspec'), changeName);
758
+ if (ledger && (ledger.status === 'deleted' || ledger.status === 'archived')) {
759
+ const pushed = await this._pushLedgerTombstone(changeName, ledger, { signal: opts.signal });
760
+ return { synced: 0, errors: [], ledgerTombstone: pushed ? 'pushed' : 'best-effort-failed' };
761
+ }
762
+ }
750
763
  console.warn(`[sync] 变更无进度数据 (无活跃进度): ${changeName}`);
751
764
  return { synced: 0, errors: [`变更无进度数据: ${changeName}`] };
752
765
  }
@@ -1098,6 +1111,63 @@ export class SyncManager {
1098
1111
  return { synced: 0 };
1099
1112
  }
1100
1113
 
1114
+ /**
1115
+ * 台账墓碑(2026-10-13-sync-ledger-tombstone):DB 行已被外部物理清除、终态凭证只剩
1116
+ * 删除台账时的合成墓碑上行。与 _pushTombstone 的差异:不读 DB(无行可读),载荷由
1117
+ * 台账条目合成——changes[0] 携带台账 status(平台 _apply_cli_tombstone 据此收敛
1118
+ * location)+ current_stage='archive',其余五表空数组(平台端解析容错,字段缺失即空集)。
1119
+ * base_ts 不设:平台契约「base_ts 缺失无条件接受」(platform_sync/service.py 分支 1),
1120
+ * 不与既有 last_pushed_at 比较,无 409 面。best-effort:失败 debugLog 不抛(与 X1 链
1121
+ * 语义一致——平台 7 天 reparse 兜底仍在)。
1122
+ * @param {string} changeName
1123
+ * @param {{name: string, status: string, at: string}} ledger 台账终态条目
1124
+ * @param {{signal?: AbortSignal}} [opts]
1125
+ * @returns {Promise<boolean>} 是否推送成功
1126
+ */
1127
+ async _pushLedgerTombstone(changeName, ledger, opts = {}) {
1128
+ const platform = this._getPlatform();
1129
+ if (!platform) return false; // 未连接平台:台账墓碑无从上行,行缺失也无处可回填,静默
1130
+ const payload = {
1131
+ changes: [{
1132
+ name: changeName,
1133
+ status: ledger.status,
1134
+ current_stage: 'archive',
1135
+ created_at: ledger.at,
1136
+ last_active: ledger.at,
1137
+ title: changeName,
1138
+ }],
1139
+ stages: [],
1140
+ steps: [],
1141
+ approvals: [],
1142
+ batch_progress: [],
1143
+ };
1144
+ const headers = {
1145
+ 'Content-Type': 'application/json',
1146
+ Authorization: `Bearer ${platform.token}`,
1147
+ 'X-SillySpec-Pushed-At': new Date().toISOString(),
1148
+ };
1149
+ const pushUser = platform.user || resolvePlatformUser(this.cwd) || null;
1150
+ if (pushUser) headers['X-SillySpec-User'] = pushUser;
1151
+ const syncUrl = `${platform.url}/api/changes/${encodeURIComponent(changeName)}/progress`;
1152
+ try {
1153
+ const res = await fetchJsonWithStatus(syncUrl, {
1154
+ method: 'POST',
1155
+ headers,
1156
+ body: JSON.stringify(payload),
1157
+ signal: opts.signal,
1158
+ });
1159
+ if (res.ok) {
1160
+ console.log(`[sync] 已上行台账墓碑(行已清除,status=${ledger.status}): ${changeName}`);
1161
+ return true;
1162
+ }
1163
+ debugLog(`[sync] 台账墓碑上行未成功: ${changeName} (status=${res.status},平台 reparse 兜底)`);
1164
+ return false;
1165
+ } catch (err) {
1166
+ debugLog(`[sync] 台账墓碑上行失败: ${changeName}: ${(err && err.message) || err}`);
1167
+ return false;
1168
+ }
1169
+ }
1170
+
1101
1171
  /**
1102
1172
  * 自竞态判定辅助:fresh 读本地 DB 的 last_synced_platform_ts(base_ts),判断它是否已覆盖
1103
1173
  * 平台 409 回执的 last_pushed_at(字符串字典序比较,契约 §4.2——ISO 8601 字典序 == 时间序)。
@@ -2867,17 +2867,34 @@ function buildDepsBatches({ deps, changedFiles = [], hits = [], cwd = null, prio
2867
2867
  // cd 前缀保留(2026-09-25-deps-cwd-prefix,R15/R16 实证):剥前缀后从 worktree 根跑 uv,根上无
2868
2868
  // pyproject.toml → uv 解析到无 dev extras 的错环境 → aiobotocore 假红(两轮 known_failures 顶着)。
2869
2869
  // 前缀在则批次文件路径按该 dir 重定基(模块命令的文件参即 dir 相对口径,如 app/modules/...)。
2870
+ // py 按包根分组(坑 flow-dynamic-subset-root-scripts-path,2026-10-13 实证:主仓根
2871
+ // scripts/test_x.py 被拼进 backend 的 cd 批——相对路径在 backend cwd 下必然 not found,
2872
+ // 整批 exit 2 假败)。每个 .py 文件按最近 pyproject/uv.lock 祖先归组(与 runner 推断
2873
+ // 同款就近原则——monorepo 子项目各自独立环境);组内共用该包根的 runner 命令与重定基。
2874
+ // 显式命中命令串(hits)优先且整批共用(用户显式配置语义优先,不拆组)。
2870
2875
  let pyRunner = null
2871
2876
  for (const h of hits || []) {
2872
2877
  const cmd = String(h.test || '')
2873
2878
  const m = /^((?:cd\s+[^&|;]+&&\s*)*[^&|;]*?pytest)/.exec(cmd) || /(?:^|&&|;|\|\|)\s*([^&|;]*pytest)/.exec(cmd)
2874
2879
  if (m) { pyRunner = m[1].trim(); break }
2875
2880
  }
2876
- if (!pyRunner && cwd && pyRun.length > 0) {
2877
- const inferred = inferPyRunner(cwd, pyRun[0])
2878
- pyRunner = inferred.command
2881
+ // pyGroups: Map<dirKey, { files, command }>;dirKey null = 主仓根(无 cd 前缀)
2882
+ const pyGroups = new Map()
2883
+ const pyGroupOf = (f) => {
2884
+ if (pyRunner) return null // 显式命中命令:整批一组(既有语义)
2885
+ if (!cwd) return null
2886
+ const inferred = inferPyRunner(cwd, f)
2887
+ return inferred.dir || null
2888
+ }
2889
+ for (const f of pyRun) {
2890
+ const key = pyGroupOf(f)
2891
+ if (!pyGroups.has(key)) {
2892
+ const command = pyRunner || (cwd ? inferPyRunner(cwd, f).command : 'python -m pytest')
2893
+ pyGroups.set(key, { files: [], command })
2894
+ }
2895
+ pyGroups.get(key).files.push(f)
2879
2896
  }
2880
- if (!pyRunner) pyRunner = 'python -m pytest'
2897
+ if (!pyRunner && pyGroups.size > 0) pyRunner = pyGroups.values().next().value.command
2881
2898
  // JSX 运行器推断(2026-09-26-residual-runner-parity 起 + 结构推断源):.tsx/.jsx 是 node
2882
2899
  // 原生 type stripping 跑不了的(node --test 直跑恒败——R18 verify 段 ~11 次尝试的坑),
2883
2900
  // ① 命中命令串提取 vitest/jest(旧路径)② 无命中命令时最近含 vitest/jest 的 package.json;
@@ -2928,7 +2945,19 @@ function buildDepsBatches({ deps, changedFiles = [], hits = [], cwd = null, prio
2928
2945
  // 批计数分列(2026-10-06-fr-regress-cap-drop):count=实跑总数、prioCount=实跑中优先面数、
2929
2946
  // dropped 只计普通依赖弃置、prioDropped=防御路径计数(构造上恒 0——quotaFill 保证优先面零弃置,
2930
2947
  // >0 即组卷逻辑被破坏,消费面 loud 披露)。js 批经 jsNative/jsProject 内容分流后计数随分流面。
2931
- if (pyRun.length > 0) batches.push({ name: 'deps(auto-py)', short: 'py', command: `${pyRunner} ${pyRun.map(rebase).join(' ')}`, count: pyRun.length, prioCount: pyQ.prioCount, dropped: pyQ.dropped, prioDropped: pyQ.prioDropped, files: pyRun })
2948
+ // py 多批(坑 flow-dynamic-subset-root-scripts-path):按包根分组逐批 push——每组
2949
+ // 重定基按本组 dir(跨组文件不再共享单一 cdDir);组序稳定(Map 插入序=pyRun 排序序)。
2950
+ {
2951
+ let gi = 0
2952
+ for (const [dirKey, grp] of pyGroups) {
2953
+ // hits 命中形态 dirKey=null——从本组命令的 cd 前缀提取重定基目录(坑 deps-cwd-prefix
2954
+ // ① 用例:显式配置 'cd backend && uv run pytest …' 时路径须按 backend/ 重定基)
2955
+ const cmdDir = dirKey || /(?:^|\s)cd\s+(\S+)\s*&&/.exec(grp.command)?.[1] || null
2956
+ const rebasePy = (f) => (cmdDir && f.startsWith(cmdDir + '/')) ? f.slice(cmdDir.length + 1) : f
2957
+ batches.push({ name: pyGroups.size > 1 ? `deps(auto-py:${dirKey || 'root'})` : 'deps(auto-py)', short: 'py', command: `${grp.command} ${grp.files.map(rebasePy).join(' ')}`, count: grp.files.length, prioCount: grp.files.filter(f => prio(f) === 0).length, dropped: pyQ.dropped, prioDropped: pyQ.prioDropped, tap: true, files: grp.files, pyRoot: dirKey || null })
2958
+ gi++
2959
+ }
2960
+ }
2932
2961
  if (jsNative.length > 0) batches.push({ name: 'deps(auto-js)', short: 'js', command: `node --test --test-reporter=spec --test-reporter-destination=stderr --test-reporter=tap --test-reporter-destination=stdout ${jsNative.join(' ')}`, count: jsNative.length, prioCount: jsNative.filter(f => prio(f) === 0).length, dropped: jsTest.length - jsRun.length, prioDropped: jsQ.prioDropped, tap: true, files: jsNative })
2933
2962
  if (jsMeta.length > 0) batches.push({ name: 'deps(auto-js-meta-skip)', short: 'js-meta-skip', command: null, count: jsMeta.length, dropped: 0, skip: true, files: jsMeta, reason: '套件编排器(run-tests.mjs=npm test 组卷入口)非单测文件——内嵌执行=递归全量套件(嵌套 runner 污染假败);全量门由收口实测/CI 的 npm test 承担' })
2934
2963
 
@@ -6,8 +6,9 @@
6
6
  ## 选道(动手前先对号入座)
7
7
  | 需求形态 | 走法 |
8
8
  |---|---|
9
- | 需求已含决策(改什么、成功标准说得清) | **轻量变更(默认快道)**:`flow start` → 直接干活 → `flow done`(2 次协议调用;操作细节见 `/sillyspec:flow` skill 与 flow start 输出) |
10
- | 需求不清晰 / 需要方案探索 | 头脑风暴预段:`run brainstorm --change <名>` → 完成后 `flow start --change <名>` 收编续跑(design 以头脑风暴版为准) |
9
+ | 需求已含决策(改什么、成功标准说得清——成功标准须来自**用户原话或既有文档转写**;要靠你推断拟出的标准=需求不清晰) | **轻量变更(默认快道)**:`flow start` → 直接干活 → `flow done`(2 次协议调用;操作细节见 `/sillyspec:flow` skill 与 flow start 输出) |
10
+ | 用户显式要求极速(「极速/别验证/只要 spec 和代码」类原话)且需求已含决策 | **轻量变更极速版**:同上行加 `--turbo`(`flow start --change <名> --input "<...>" --turbo`)——**仅用户显式触发,禁止你自行启用**;收口跳过实测与重仪式门(hunk/dirty/ui 探针/评审),spec 结构校验/tasks 全勾/ledger/patch 冻结/distill/归档照常(跳的是验证不是证据);升厚即失效恢复全量仪式;收口输出显式标注「不含测试客观核验」。预段组合极速:头脑风暴预段走轻量跑判定+单轮合并问询+burst 一次 --done,收编时按用户要求带 --turbo |
11
+ | 需求不清晰 / 需要方案探索(含「成功标准要你替用户拟」的形态——自己拟标准再自答是把默认共识伪装成共识) | 头脑风暴预段:`run brainstorm --change <名>` → 完成后 `flow start --change <名>` 收编续跑(design 以头脑风暴版为准)。上个轻量变更被 route-hindsight 标记时全新 start 会被同意门拦下要求显式选道:确认已含决策带 `--no-prestage` 声明留痕,否则先走本行预段 |
11
12
  | 大改动:跨模块取舍 / Wave 计划编排 / 多阶段治理 / 设计期人机对抗 | 完整流程五阶段:`run brainstorm → plan → execute → verify → archive`(每阶段一次渲染 + 一次 --done 收口;细节见各阶段 skill 与 CLI 输出) |
12
13
  | 代码已先写好(倒推收尾) | 不回头补流程装样子:`flow start --change <YYYY-MM-DD-名> --input` 按下方过门格式写(动机=已做改动的描述,成功标准=已验证达标的行为)→ `flow done` 一步收口 |
13
14
 
@@ -30,7 +31,7 @@ sillyspec flow start --change <YYYY-MM-DD-名> --input "<动机与背景>
30
31
  ## 核心规则
31
32
  1. **禁止绕过本文件规则和 SillySpec 流程**。所有变更走 sillyspec 流程,不裸改裸提交。
32
33
  2. **改代码前必须先说明依据**——依据的文档路径(design.md / 模块文档 / file-lifecycle.md(如有))或现有代码依据,无依据不改。
33
- 3. **执行顺序**:文档 → 读代码 → 写测试 → 写实现 → 跑测试 → 验收 → 更新文档。
34
+ 3. **执行顺序**:文档 → 读代码 → 写测试 → 写实现 → 跑测试 → 验收 → 更新文档。测试按层适用:纯逻辑先写测试,行为契约断言用户可见行为,视觉样式与纯配置/文档不写测试(记「不适用:理由」,验收靠规范与人工)。
34
35
  4. **实证核验再收口**:触及 `src`/`test` 的改动,CLI 会在收口时亲自实测(实测失败阻断收口;纯 doc/配置自动跳过)。测试面按变更动态推断(本变更测试 ∪ FR 关联回归 ∪ import 依赖,runner 自项目结构推断——无需配置);lint 走 `.sillyspec/local.yaml` 的 `commands.lint`。以落盘文件与测试结果为准,不信口头"已完成"。
35
36
  5. **非测试逻辑本身有误时,禁止改测试来"通过"**——修逻辑,不修测试。
36
37
  6. **git hook 拦截提交时禁止跳过**(如 `.husky/pre-push`),修复问题后再提交。
@@ -3,6 +3,11 @@
3
3
  写测试是为了拦住回归,不是"有测试文件"。
4
4
  **基础习惯照做、不展开**:FIRST(快/独立/可重复/自校验/及时)、金字塔分层(单元为主/集成少量/E2E 只冒烟)、AAA 结构、测试名描述场景、测试数据轻量构造(Object Mother/Builder)、覆盖率不追数字。
5
5
 
6
+ **先判该不该写:按层适用**(一句话判据——能写成「当 X 发生时,用户应看到/发生 Y」→ 写测试;只能描述成「看起来更精致/更美观」→ 不写,靠规范与人工验收):
7
+ - **纯逻辑**(校验规则/数据变换/状态规则/重试策略)→ 严格 test-first:先写测试再实现。
8
+ - **行为契约**(用户做 X 后应看到/发生 Y 的关键交互,组件/API 皆然)→ 断言用户可见行为;主干断言可先行,mock 细节允许实现后补;不为样式类/className 写断言。
9
+ - **视觉样式与纯配置**(颜色/布局/间距/动效;配置/文档/CI)→ 不写测试——断言样式是负资产,改一次样式碎一片;测试绑定记「不适用:<理由>」。
10
+
6
11
  逐条对照检查:
7
12
  1. **覆盖**:正常路径 + 边界 + 异常/非法输入,不只写业务正例;单测全绿 ≠ 集成正确。
8
13
  2. **断言**:断言真实输出/副作用,禁止只"不抛错"的空断言;不 mock 被测方法自身。