sillyspec 3.34.1 → 3.34.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/src/spec-sync.js CHANGED
@@ -17,7 +17,7 @@
17
17
 
18
18
  import { createHash } from 'crypto';
19
19
  import { timedAsync } from './timing.js';
20
- import { existsSync, readdirSync, readFileSync, statSync, mkdirSync, writeFileSync, unlinkSync } from 'fs';
20
+ import { existsSync, readdirSync, readFileSync, statSync, mkdirSync, writeFileSync, unlinkSync, rmSync } from 'fs';
21
21
  import { join, relative, sep } from 'path';
22
22
  import { bindSyncNoiseRoot, syncConnectionWarn, isConnectionClassStatus, noteSyncSuccess } from './sync-noise.js';
23
23
  import { runtimeStatePath } from './runtime-paths.js';
@@ -52,6 +52,60 @@ function toPosix(p) {
52
52
  // 小响应维持 10s 噪音闸门不变。
53
53
  const MANIFEST_FULL_TIMEOUT_MS = 120_000;
54
54
 
55
+ // 推送分批(2026-11-04-sync-push-chunking):大积压单批 POST 在单请求预算内必死——
56
+ // 生产实证 multi-agent-platform 工作区 15 天积压 2436 ops/45.7MB(base64 后 ~60MB),
57
+ // 上传+服务端 apply 双超(客户端 30s 掐线、服务器 16-34s 处理后 400/中断),37 连败
58
+ // 零落地。切块顺序推送:每块独立提交,已落地块推进服务器清单,失败即停由下轮 diff
59
+ // 断点续传(锚点刷新后已落地路径自动出 diff)。env 仅调优,缺省零回归(小批量一块直发)。
60
+ const PUSH_TIMEOUT_DEFAULT_MS = 30_000;
61
+ const CHUNK_OPS_DEFAULT = 128;
62
+ const CHUNK_BYTES_DEFAULT = 2 * 1024 * 1024;
63
+
64
+ function resolvePushTimeoutMs() {
65
+ const raw = Number(process.env.SILLYSPEC_SPEC_SYNC_PUSH_TIMEOUT_MS);
66
+ if (Number.isFinite(raw) && raw >= 1000) return Math.floor(raw);
67
+ return PUSH_TIMEOUT_DEFAULT_MS;
68
+ }
69
+
70
+ function resolveChunkOps() {
71
+ const raw = Number(process.env.SILLYSPEC_SPEC_SYNC_CHUNK_OPS);
72
+ if (Number.isInteger(raw) && raw >= 1) return raw;
73
+ return CHUNK_OPS_DEFAULT;
74
+ }
75
+
76
+ function resolveChunkBytes() {
77
+ const raw = Number(process.env.SILLYSPEC_SPEC_SYNC_CHUNK_BYTES);
78
+ if (Number.isFinite(raw) && raw >= 1024) return Math.floor(raw);
79
+ return CHUNK_BYTES_DEFAULT;
80
+ }
81
+
82
+ /**
83
+ * 按 op 数与 base64 字节数双约束切块(顺序保序;单 op 超任一上限独占一块——
84
+ * 4.7MB 级 change.patch 单文件不切不丢弃)。行宽为粗估(content + path×2 + 包裹),
85
+ * 只为分块比例,无需精确。
86
+ */
87
+ export function chunkOpsForPush(ops, opts = {}) {
88
+ const maxOps = opts.maxOps ?? resolveChunkOps();
89
+ const maxBytes = opts.maxBytes ?? resolveChunkBytes();
90
+ if (!Array.isArray(ops) || ops.length === 0) return [];
91
+ if (maxOps < 1 || maxBytes < 1) return [ops.slice()];
92
+ const chunks = [];
93
+ let cur = [];
94
+ let curBytes = 0;
95
+ for (const op of ops) {
96
+ const size = (op.content ? op.content.length : 0) + op.path.length * 2 + 96;
97
+ if (cur.length > 0 && (cur.length >= maxOps || curBytes + size > maxBytes)) {
98
+ chunks.push(cur);
99
+ cur = [];
100
+ curBytes = 0;
101
+ }
102
+ cur.push(op);
103
+ curBytes += size;
104
+ }
105
+ if (cur.length > 0) chunks.push(cur);
106
+ return chunks;
107
+ }
108
+
55
109
  function debugLog(msg) {
56
110
  if (process.env.SILLYSPEC_DEBUG_SYNC) console.warn(msg);
57
111
  }
@@ -68,7 +122,7 @@ export function describeSyncError(err) {
68
122
  return '平台响应慢,本轮同步被总预算熔断让路(best-effort,下一条命令自动重试)';
69
123
  }
70
124
  if (err && err.name === 'TimeoutError') {
71
- return '单请求超时(清单 10s / 推送 30s 口径,best-effort,下次自动重试)';
125
+ return '单请求超时(清单增量 10s/全量 120s;推送默认 30s,SILLYSPEC_SPEC_SYNC_PUSH_TIMEOUT_MS 可调;分批尺寸 SILLYSPEC_SPEC_SYNC_CHUNK_*;best-effort,下次自动重试)';
72
126
  }
73
127
  return err && err.message ? err.message : String(err);
74
128
  }
@@ -310,6 +364,12 @@ export function computeSpecOps(serverManifest, localFiles) {
310
364
  for (const p of localMap.keys()) {
311
365
  if (renamedNew.has(p)) continue;
312
366
  if (!serverSet.has(p)) {
367
+ // 平台已审候选不复活(2026-11-07-sync-proposed-no-revive):平台知识页 merge/reject
368
+ // 待审候选后服务器软删(manifest 行 exists=false,不置墓碑)——此处若照常 add 会走
369
+ // 平台复活分支把已审条目推回待审区(2026-11-07 平台侧核查实证)。窄化判定:仅
370
+ // knowledge/proposed/ 前缀 + 软删行在场(平台处置的唯一可信证据;manifest 无行 =
371
+ // 从未同步的新提议,照常 add)。本地残留清理由 cleanupPlatformDisposedProposed 负责。
372
+ if (p.startsWith('knowledge/proposed/') && server[p]?.exists === false) continue;
313
373
  const localEntry = localMap.get(p);
314
374
  const content = (localEntry.buf ?? readFileSync(localEntry.absPath ?? join('.', p))).toString('base64');
315
375
  ops.push({
@@ -326,6 +386,39 @@ export function computeSpecOps(serverManifest, localFiles) {
326
386
  return ops;
327
387
  }
328
388
 
389
+ /**
390
+ * 平台已审候选的本地残留清理(2026-11-07-sync-proposed-no-revive FR-02):
391
+ * 「本地 knowledge/proposed/ 文件 ∩ 服务器 manifest exists=false 行」= 平台侧已
392
+ * merge/reject 处置过的候选在本地留下的残件(computeSpecOps 已拦其 add 复活)——
393
+ * 此处删除本地文件并提示,防止残留永久滞留(每次 sync 重算,幂等;无残留零输出)。
394
+ * 删除安全性:服务器侧该路径已无活文件(软删行),删本地不产生两端不一致;误删窗口
395
+ * 仅「同 slug 复写新内容 + 平台恰已处置同路径」(发生率极低,见变更 design R-02)。
396
+ * @returns {string[]} 已清理的 spec 相对路径列表
397
+ */
398
+ export function cleanupPlatformDisposedProposed(specRoot, serverManifest) {
399
+ const server = serverManifest || {};
400
+ const disposed = Object.keys(server).filter(
401
+ (p) => p.startsWith('knowledge/proposed/') && server[p]?.exists === false
402
+ );
403
+ if (disposed.length === 0) return [];
404
+ const removed = [];
405
+ for (const p of disposed) {
406
+ // spec 相对路径 → 本地绝对路径(walkSpecTree 同源根;路径来自服务器清单为 POSIX 形态)
407
+ const abs = join(specRoot, ...p.split('/'));
408
+ if (!existsSync(abs)) continue; // 本地不在场的处置行零动作零上报(只清真残件)
409
+ try {
410
+ rmSync(abs, { force: true });
411
+ removed.push(p);
412
+ } catch { /* 删除失败不阻断同步——下轮重算重试(幂等) */ }
413
+ }
414
+ if (removed.length > 0) {
415
+ console.log(
416
+ `[spec-sync] 已清理 ${removed.length} 个平台已审核(合并/拒绝)的本地待审候选残留(不再回推复活): ${removed.slice(0, 5).join(', ')}${removed.length > 5 ? ' 等' : ''}`
417
+ );
418
+ }
419
+ return removed;
420
+ }
421
+
329
422
  /**
330
423
  * 从 ops 提取本次涉及的变更目录名集合(change_dirs 标注,D-005@v1)。
331
424
  *
@@ -838,6 +931,11 @@ async function syncSpecTreeImpl(specRoot, platform, changeName, opts = {}) {
838
931
  // 2. walk/hash/diff
839
932
  const entries = walkSpecTree(specRoot);
840
933
  const localFiles = hashFiles(entries);
934
+ // 平台已审候选残留清理(2026-11-07-sync-proposed-no-revive FR-02):computeSpecOps 已拦
935
+ // 其 add 复活,此处删本地残件+提示(幂等;walk 已完成,删除不影响本轮 diff 结果)。
936
+ try {
937
+ cleanupPlatformDisposedProposed(specRoot, serverManifest);
938
+ } catch { /* 清理 fail-soft——不阻断文件树同步 */ }
841
939
  let ops = computeSpecOps(serverManifest, localFiles);
842
940
 
843
941
  // 旧副本回推防护(并行会话一致性):上次同步后本地从未改动的文件不回推覆盖服务器新内容。
@@ -877,30 +975,47 @@ async function syncSpecTreeImpl(specRoot, platform, changeName, opts = {}) {
877
975
  return { synced: 0, ok: true };
878
976
  }
879
977
 
880
- // 3. POST ops
978
+ // 3. POST ops(2026-11-04-sync-push-chunking:分批顺序推送——单批大积压在单请求
979
+ // 预算内上传+服务端 apply 双超零落地;切块后每块独立提交,失败即停下轮续传)
881
980
  try {
882
- const res = await fetch(syncUrl, {
883
- method: 'POST',
884
- headers: {
885
- 'Content-Type': 'application/json',
886
- Authorization: `Bearer ${platform.token}`,
887
- },
888
- body: JSON.stringify({ ops }),
889
- signal: withSignal(30000),
890
- });
891
- if (!res.ok) {
892
- const raw = await res.text().catch(() => '');
893
- const detail = describeErrorBody(res.status, raw);
894
- const failCount = bumpFailStreak(specRoot, logLabel, detail);
895
- const line = `[spec-sync] 同步请求失败 HTTP ${res.status}(文件树本次未同步,下次自动重试): ${logLabel}${detail ? ` —— ${detail}` : ''}`;
896
- if (isConnectionClassStatus(res.status)) syncConnectionWarn(line);
897
- else console.warn(line);
898
- if (failCount >= FAIL_STREAK_ESCALATE_AT) {
899
- console.warn(`🚨 [spec-sync] 已连续 ${failCount} 次同步失败——平台文件树可能持续落后(变更中心或仍显示「进行中」/旧内容): ${logLabel}`);
981
+ const chunks = chunkOpsForPush(ops);
982
+ if (chunks.length > 1) {
983
+ console.log(`[spec-sync] 推送分批:${ops.length} ops 切 ${chunks.length} 块顺序提交(块上限 ${resolveChunkOps()} ops / ${Math.round(resolveChunkBytes() / 1024)}KB 估算): ${logLabel}`);
984
+ }
985
+ let landedChunks = 0;
986
+ const body = { conflict: false, server_versions: {}, platform_deleted: [] };
987
+ for (let ci = 0; ci < chunks.length; ci++) {
988
+ const res = await fetch(syncUrl, {
989
+ method: 'POST',
990
+ headers: {
991
+ 'Content-Type': 'application/json',
992
+ Authorization: `Bearer ${platform.token}`,
993
+ },
994
+ body: JSON.stringify({ ops: chunks[ci] }),
995
+ signal: withSignal(resolvePushTimeoutMs()),
996
+ });
997
+ if (!res.ok) {
998
+ const raw = await res.text().catch(() => '');
999
+ const detail = describeErrorBody(res.status, raw);
1000
+ const failCount = bumpFailStreak(specRoot, logLabel, detail);
1001
+ const landedNote = landedChunks > 0 ? `(前 ${landedChunks}/${chunks.length} 块已落地,下轮自动续传未落地部分)` : '';
1002
+ const line = `[spec-sync] 同步请求失败 HTTP ${res.status}(文件树本次未同步,下次自动重试)${landedNote}: ${logLabel}${detail ? ` —— ${detail}` : ''}`;
1003
+ if (isConnectionClassStatus(res.status)) syncConnectionWarn(line);
1004
+ else console.warn(line);
1005
+ if (failCount >= FAIL_STREAK_ESCALATE_AT) {
1006
+ console.warn(`🚨 [spec-sync] 已连续 ${failCount} 次同步失败——平台文件树可能持续落后(变更中心或仍显示「进行中」/旧内容): ${logLabel}`);
1007
+ }
1008
+ return { synced: 0, ok: false };
900
1009
  }
901
- return { synced: 0, ok: false };
1010
+ const chunkBody = await res.json().catch(() => ({}));
1011
+ landedChunks += 1;
1012
+ // 块响应合并成单批等价形状:conflict 任一块命中即真;server_versions /
1013
+ // platform_deleted 逐块累积(块间路径互斥——computeSpecOps 每 path 至多一 op)。
1014
+ if (chunkBody.conflict) body.conflict = true;
1015
+ Object.assign(body.server_versions, chunkBody.server_versions || {});
1016
+ if (Array.isArray(chunkBody.platform_deleted)) body.platform_deleted.push(...chunkBody.platform_deleted);
1017
+ if (chunks.length > 1) debugLog(`[spec-sync] 分批 ${ci + 1}/${chunks.length} 落地(${chunks[ci].length} ops)`);
902
1018
  }
903
- const body = await res.json().catch(() => ({}));
904
1019
  if (body.conflict) {
905
1020
  // HUB-08 冲突闭环:落 spec-sync-conflict-<change>.json(与进度 sync-conflict-* 同目录),
906
1021
  // 供 platform status 列出 + platform resolve 三态处置。此前只 warn 返回,下次 sync 用
@@ -1037,5 +1152,6 @@ export default {
1037
1152
  filterStaleUpdates,
1038
1153
  dropFollowServerUpdates,
1039
1154
  partitionConflictPaths,
1155
+ chunkOpsForPush,
1040
1156
  syncSpecTree,
1041
1157
  };
@@ -21,7 +21,7 @@ import { collectExecuteChangedFiles, computeDecisionTouches, renderDecisionTouch
21
21
  // (test 直 import 本文件皆此形态)触发 stages/index.js 顶层 stageRegistry 对本文件 definition
22
22
  // const 的 TDZ 求值(ESM 环实证 2026-09-14)。此处为同格式本地孪生,漂移由
23
23
  // test/knowledge-inject.test.mjs 格式等价断言锁定——改 prompt.js 版必同步改此处。
24
- import { matchKnowledge } from '../knowledge-match.js'
24
+ import { matchKnowledge, resultChannel } from '../knowledge-match.js'
25
25
  import { matchKnowledgeWithScope, graphPathCandidates, scopeTelemetryRefs } from '../knowledge-graph.js'
26
26
  import { appendKnowledgeHit } from '../knowledge-hits.js'
27
27
 
@@ -136,10 +136,12 @@ function buildWaveKnowledgeSection(changeDir, wave) {
136
136
  }
137
137
  }
138
138
  try {
139
+ // channel 单源记录(FR-04,2026-11-05-knowledge-search-observability)——画像/调优数据地基
139
140
  appendKnowledgeHit(path.join(specBase, '.runtime'), {
140
141
  type: 'inject',
141
142
  change: path.basename(changeDir),
142
143
  query,
144
+ channel: resultChannel(result),
143
145
  matchedFiles: result.matched ? [...humanEntries, ...routeHits].map(e => (e.anchor ? `${e.file}#${e.anchor}` : e.file)) : [],
144
146
  ...(result.matched ? (() => {
145
147
  const scopeMatchedFiles = scopeTelemetryRefs(result, { includeRejected: false })
@@ -22,11 +22,13 @@ export const definition = {
22
22
  - 在途面(出方向建议前)→ \`sillyspec status\` 活跃变更一览——结论别跟在途工作撞车
23
23
  4. 输出 2-3 个有价值方向、关键风险和下一步建议
24
24
  5. 如果用户要求保存结论,先明确保存位置,再写入对应文档
25
- 6. 讨论 UI/交互方案且用户明确要求原型时,按下方「HTML 原型生成」执行
25
+ 6. 讨论 UI/交互方案或状态机/流程/时序类歧义且用户明确要求原型时,按下方「HTML 原型生成」执行
26
26
 
27
27
  ### HTML 原型生成(仅用户明确要求时)
28
- 探索保持只读姿态:不主动写原型文件;UI 讨论成形时可以提议("要不要出个原型看效果"),用户同意后再生成。探索期原型是**粗选对比件**(求快不求真,手绘单文件含内联 CSS+JS 在探索期合法,浏览器直接打开):
28
+ 探索保持只读姿态:不主动写原型文件;UI/交互讨论成形、或状态机/流程/时序类歧义文字多轮问答装不下时,可以提议("要不要出个原型看效果"),用户同意后再生成。探索期原型是**粗选对比件**(求快不求真,手绘单文件含内联 CSS+JS 在探索期合法,浏览器直接打开):
29
29
  - 呈现讨论中的布局/组件/交互流程——用户看到的是"这个方案做出来长什么样",不是示意图;能用项目真实组件库/设计 token 观感的尽量用(先读 scan 文档与现有前端代码),降低与真实效果的偏差
30
+ - 逻辑形(状态机/流转/时序类歧义用):把流转规则写成内联纯逻辑模块,配状态面板(每步操作后重渲染全量状态)+ 领域语言标注,让用户亲手把玩边界场景代替文字推演;非 JS 项目即「JS 复刻的模拟器」——验证的是复刻件非真码,提议时声明可能的翻译失真;逻辑形不进变更目录(prototype-*.html 命名是 UI 契约件专属),结论以 design/decisions 文字落盘
31
+ - 多方案对比时:各方案须在布局/信息层级(UI 形)或流转结构(逻辑形)上实质性分歧,颜色文案级差异不构成对比方案;mock 数据取真实形状与密度,不在空态页面上做判断
30
32
  - 保存到 \`{SPEC_ROOT}/explore/prototype-<名称>.html\`(目录不存在则先创建;用户指定了其他位置则从其指定)
31
33
  - 原型是探索讨论工具、不是交付物,也不是实现代码——不要顺手把原型接进项目源码或构建;话题后续进入 brainstorm 时分两路:已是可复跑真码的原型复制到 \`{SPEC_ROOT}/changes/<变更名>/\`(**保留 \`prototype-<名称>.html\` 命名**——execute 按此命名注入,改名=不注入)走登记复用;手绘粗选件只是方向输入,brainstorm 定稿须按其「生成要求」升级为真码(同方言/可复跑),不得把手绘件直接当定稿登记
32
34
 
@@ -17,10 +17,10 @@
17
17
  */
18
18
 
19
19
  import { existsSync, readFileSync, readdirSync, mkdirSync, statSync } from 'fs'
20
- import { join, basename } from 'path'
20
+ import { join, basename, dirname } from 'path'
21
21
  import { writeAtomicSync } from '../fs-atomic.js'
22
22
  import { parseKnowledgeIndex, keywordMatchesContext, normalizeAnchorForCompare } from '../knowledge-match.js'
23
- import { matchKnowledgeHybrid } from '../knowledge-vector.js'
23
+ import { matchKnowledgeHybrid, resultChannel } from '../knowledge-vector.js'
24
24
 
25
25
  // ── 工具函数 ──
26
26
 
@@ -132,6 +132,21 @@ export async function cmdSearch(dir, args, opts = {}) {
132
132
  ? result.json.parkingFiltered : []
133
133
  const payload = buildSearchPayload(result, knowledgeDir, limit, query)
134
134
 
135
+ // search 面遥测(FR-05,2026-11-05-knowledge-search-observability):显式检索此前零落盘
136
+ // ——查询形态/命中率/通道分布是向量层调优(地板值/词表收录)的唯一数据源。fail-soft:
137
+ // 落盘失败不影响检索输出(R-04 纪律);stats 按 type 严格过滤,type=search 不进既有视图。
138
+ try {
139
+ const { appendKnowledgeHit } = await import('../knowledge-hits.js')
140
+ appendKnowledgeHit(join(dirname(knowledgeDir), '.runtime'), {
141
+ type: 'search',
142
+ query,
143
+ matched: Boolean(result && result.matched),
144
+ channel: resultChannel(result),
145
+ entry_count: (payload.matches || []).length,
146
+ matchedFiles: (payload.matches || []).map((m) => m.id).slice(0, 12),
147
+ })
148
+ } catch { /* 遥测 fail-soft(R-04) */ }
149
+
135
150
  output(true, {
136
151
  query,
137
152
  ...payload,
@@ -176,11 +191,8 @@ export function buildSearchPayload(result, knowledgeDir, limit, query) {
176
191
  */
177
192
  export function buildSearchMatches(result, knowledgeDir, limit, query) {
178
193
  const ctx = String(query || '').toLowerCase()
179
- const channel = result && result.json && result.json.fallback ? 'fallback'
180
- : result && result.json && result.json.vector ? 'vector'
181
- : result && result.json && result.json.scope ? 'scope'
182
- : 'routing'
183
- return result.entries.slice(0, limit).map(entry => {
194
+ const channel = resultChannel(result)
195
+ return result.entries.slice(0, limit).map((entry) => {
184
196
  const filePath = join(knowledgeDir, entry.file)
185
197
  let title = entry.display
186
198
  let summary = ''
@@ -192,12 +204,16 @@ export function buildSearchMatches(result, knowledgeDir, limit, query) {
192
204
  }
193
205
 
194
206
  const tags = entry.keywords
207
+ // 向量通道透出真实余弦分(FR-03,2026-11-05-knowledge-search-observability):
208
+ // keywords∩query 对 vector 通道恒 0(keywords=['vector'] 非查询词)——错值不只是缺值;
209
+ // vectorScore 由 buildResultFromPlatform 挂文件级最高分。其余通道 score 口径不变。
210
+ const vectorScore = channel === 'vector' && typeof entry.vectorScore === 'number' ? entry.vectorScore : null
195
211
  return {
196
212
  id: pathToId(entry.file, knowledgeDir),
197
213
  path: join('knowledge', entry.file),
198
214
  title,
199
215
  summary,
200
- score: tags.filter(k => keywordMatchesContext(k, ctx)).length,
216
+ score: vectorScore !== null ? Math.round(vectorScore * 1000) / 1000 : tags.filter((k) => keywordMatchesContext(k, ctx)).length,
201
217
  tags,
202
218
  channel,
203
219
  category: entry.category,
@@ -399,120 +415,17 @@ export async function cmdValidate(dir, args, opts = {}) {
399
415
  // ── refresh ──
400
416
 
401
417
  export async function cmdRefresh(dir, args, opts = {}) {
418
+ // generated 切块退役(2026-11-07-knowledge-generated-retire):双仓审计实证切块镜像为
419
+ // 零消费死区(INDEX 零引用、检索四层零进语料、自带 INDEX 零读取方)且是双源漂移源
420
+ // (平台仓同事实矛盾实证)——scan 文档本体在 docs 下项目 scan 目录,保留为唯一真相源,
421
+ // 切块产物退出知识库。子命令入口保留防脚本断链,行为=废弃提示零写盘。
402
422
  const knowledgeDir = resolveKnowledgeDir(dir, opts.specDir)
403
- const generatedDir = join(knowledgeDir, 'generated')
404
-
405
- if (!existsSync(knowledgeDir)) {
406
- output(false, {}, { code: 'knowledge_dir_missing', path: knowledgeDir })
407
- return
408
- }
409
-
410
- // refresh 只写 generated/ 区
411
- mkdirSync(generatedDir, { recursive: true })
412
-
413
- // 扫描 scan 文档。项目名用 DB 注册名优先(坑 knowledge-basename-project-mismatch):
414
- // scan 写侧 docs/<project> 的 project 来自 dbProjectName || basename(cwd),注册名 ≠ 目录名
415
- // (projects yaml name/path 分离、平台模式 specRoot≠sourceRoot)时,用 basename(dir) 拼
416
- // scanDir 永远找不到 → refresh 恒报 scan_docs_missing。DB 读失败回退 basename(与写侧
417
- // default 同口径)。查不到注册名对应目录时再回退 basename 试一次(双形态兼容)。
418
- const base = opts.specDir || join(dir, '.sillyspec')
419
- let projectName = null
420
- try {
421
- const { DatabaseSync } = await import('node:sqlite')
422
- const db = new DatabaseSync(join(base, '.runtime', 'sillyspec.db'), { readOnly: true })
423
- const row = db.prepare('SELECT name FROM project LIMIT 1').get()
424
- db.close()
425
- if (row && row.name) projectName = row.name
426
- } catch { /* db 不可读 → 走 basename 兜底 */ }
427
- if (!projectName) projectName = basename(dir)
428
- let scanDir = join(base, 'docs', projectName, 'scan')
429
- if (!existsSync(scanDir) && projectName !== basename(dir)) {
430
- scanDir = join(base, 'docs', basename(dir), 'scan')
431
- }
432
-
433
- if (!existsSync(scanDir)) {
434
- output(false, {}, { code: 'scan_docs_missing', path: scanDir })
435
- return
436
- }
437
-
438
- const scanFiles = readdirSync(scanDir).filter(f => f.endsWith('.md'))
439
- const generated = []
440
- const overwritten = []
441
- // 同 run 文件名去重(坑 knowledge-slug-collision):slug 只由章节标题生成,不同 scan 文件
442
- // 的同名章节(如各自都有「## 概述」)后者 writeFileSync 静默覆盖前者,INDEX 却登记两条
443
- // 指向同一路径的条目——知识条目无声丢失。碰撞时追加 -2/-3 后缀保双份(唯一命名零扰动,
444
- // 每次 refresh 按相同文件顺序重跑,后缀分配稳定)
445
- const usedNames = new Set()
446
-
447
- for (const scanFile of scanFiles) {
448
- const scanPath = join(scanDir, scanFile)
449
- const content = readFileSync(scanPath, 'utf8')
450
- const title = extractTitle(content) || scanFile.replace('.md', '')
451
-
452
- // 简单提取:取每个 ## 章节作为一个知识条目候选
453
- const sections = extractSections(content)
454
- for (const section of sections) {
455
- if (section.lines < 3) continue // 太短的章节跳过
456
-
457
- const slug = section.heading.toLowerCase()
458
- .replace(/[^\w\u4e00-\u9fff]+/g, '-')
459
- .replace(/^-|-$/g, '')
460
- .slice(0, 60)
461
-
462
- let fileName = `${slug}.md`
463
- if (usedNames.has(fileName)) {
464
- let n = 2
465
- while (usedNames.has(`${slug}-${n}.md`)) n++
466
- fileName = `${slug}-${n}.md`
467
- }
468
- usedNames.add(fileName)
469
- const genPath = join(generatedDir, fileName)
470
- const isNew = !existsSync(genPath)
471
-
472
- const entryContent = [
473
- `---`,
474
- `source: scan/${scanFile}`,
475
- `section: ${section.heading}`,
476
- `generated_at: ${new Date().toISOString()}`,
477
- `---`,
478
- ``,
479
- `# ${title} — ${section.heading}`,
480
- ``,
481
- section.body,
482
- ].join('\n')
483
-
484
- // INDEX/generated 是 search/validate 的解析源(跨进程读),走原子写防半截
485
- writeAtomicSync(genPath, entryContent)
486
- generated.push({ file: `generated/${fileName}`, title: `${title} — ${section.heading}`, new: isNew })
487
- if (!isNew) overwritten.push(`generated/${fileName}`)
488
- }
489
- }
490
-
491
- // 更新 generated/INDEX.md
492
- const genIndexPath = join(generatedDir, 'INDEX.md')
493
- const indexLines = [
494
- '---',
495
- `generated_at: ${new Date().toISOString()}`,
496
- '---',
497
- '',
498
- '# Generated Knowledge Index',
499
- '',
500
- '> Auto-generated from scan documents. Do not edit manually.',
501
- '',
502
- ]
503
- for (const g of generated) {
504
- indexLines.push(`- ${g.title} → [${g.file}](${g.file})`)
505
- }
506
- writeAtomicSync(genIndexPath, indexLines.join('\n'))
507
-
508
423
  output(true, {
509
- generated_count: generated.length,
510
- new_count: generated.filter(g => g.new).length,
511
- overwritten_count: overwritten.length,
512
- files: generated,
424
+ deprecated: true,
425
+ message: 'knowledge refresh 已退役(2026-11-07-knowledge-generated-retire):generated 切块为死区已退库。scan 文档本体在 docs 下的项目 scan 目录,由 scan 流程维护;知识沉淀走 propose → 用户确认后 accept 合并。',
426
+ knowledge_dir: knowledgeDir,
513
427
  })
514
428
  }
515
-
516
429
  // ── propose ──
517
430
 
518
431
  export async function cmdPropose(dir, args, opts = {}) {
@@ -525,6 +438,13 @@ export async function cmdPropose(dir, args, opts = {}) {
525
438
  const category = categoryIdx >= 0 && args[categoryIdx + 1] ? args[categoryIdx + 1] : 'uncategorized'
526
439
  const bodyIdx = args.indexOf('--body')
527
440
  const body = bodyIdx >= 0 && args[bodyIdx + 1] ? args[bodyIdx + 1] : ''
441
+ // --body-file 文件正文通道(2026-11-06-knowledge-propose-body-file):中文长正文
442
+ // 经命令行 argv 传递会被策略层分词误杀(「斜杠+中文标点」序列误判路径,2026-11-06
443
+ // 平台会话双 shell 实证),文件通道彻底绕开 argv。相对路径按进程 cwd 解析
444
+ // (readFileSync 直读,不做 specDir 拼接)。
445
+ const bodyFileIdx = args.indexOf('--body-file')
446
+ const bodyFile = bodyFileIdx >= 0 && args[bodyFileIdx + 1] ? args[bodyFileIdx + 1] : ''
447
+ let bodyFromFile = ''
528
448
  const fromIdx = args.indexOf('--from')
529
449
  const from = fromIdx >= 0 && args[fromIdx + 1] ? args[fromIdx + 1] : ''
530
450
 
@@ -533,6 +453,40 @@ export async function cmdPropose(dir, args, opts = {}) {
533
453
  return
534
454
  }
535
455
 
456
+ // 三类参数错误先于 mkdirSync/writeAtomicSync——错误路径零目录创建零落盘。
457
+ // 互斥按 flag 在场判定(值缺省同算同给,报冲突优于静默择一)。
458
+ if (bodyFileIdx >= 0 && bodyIdx >= 0) {
459
+ output(false, {}, {
460
+ code: 'body_source_conflict',
461
+ message: '--body 与 --body-file 互斥,只能提供一个正文来源',
462
+ })
463
+ return
464
+ }
465
+ if (bodyFileIdx >= 0) {
466
+ if (!bodyFile || bodyFile.startsWith('--')) {
467
+ output(false, {}, '--body-file requires a path')
468
+ return
469
+ }
470
+ if (!existsSync(bodyFile)) {
471
+ output(false, {}, { code: 'body_file_missing', path: bodyFile })
472
+ return
473
+ }
474
+ try {
475
+ // 文件通道逐字快照读:空文件落空正文(不走 argv 通道的 '(no body provided)'
476
+ // 兜底——FR-01 落盘与源文件一致)
477
+ bodyFromFile = readFileSync(bodyFile, 'utf8')
478
+ } catch (err) {
479
+ output(false, {}, {
480
+ code: 'body_file_read_failed',
481
+ path: bodyFile,
482
+ reason: String((err && err.message) || err),
483
+ })
484
+ return
485
+ }
486
+ }
487
+ const hasBodyFile = bodyFileIdx >= 0
488
+ const bodyText = hasBodyFile ? bodyFromFile : body
489
+
536
490
  mkdirSync(proposedDir, { recursive: true })
537
491
 
538
492
  // 生成 slug
@@ -555,7 +509,8 @@ export async function cmdPropose(dir, args, opts = {}) {
555
509
  '',
556
510
  `# ${title}`,
557
511
  '',
558
- body || '(no body provided)',
512
+ // argv 通道保持既有空兜底;文件通道逐字(空即空,见上方读取处注释)
513
+ hasBodyFile ? bodyText : (bodyText || '(no body provided)'),
559
514
  '',
560
515
  '---',
561
516
  '> This is a proposed knowledge entry. Review and merge into manual/ or generated/.',
@@ -673,15 +628,16 @@ export async function cmdKnowledge(args, dir, opts = {}) {
673
628
  ' digest 治理信号卡(rot/伪域/坏绑定——纯读)',
674
629
  ' scope 知识注入作用域现状与标记语法(always/repo/module/关键词;纯读)',
675
630
  ' stats 覆盖率/死条目统计(纯读)',
676
- ' refresh 重扫 scan 文档重写 generated/ 区(**写盘**——先 validate 再用)',
677
- ' propose / classify / inbox / graph / stats / renumber',
631
+ ' refresh (已退役 2026-11-07)generated 切块死区退库——零写盘废弃提示;scan 本体由 scan 流程维护',
632
+ ' propose --title "..." [--body "<文本>" | --body-file <路径>] [--category <名>] [--from <来源>] 提议新知识(长中文正文用 --body-file 从文件读正文,绕开命令行传参)',
633
+ ' accept --id proposed/<slug> [--file <目标>] [--dry-run] 合并待审提议为正式知识(用户确认后执行;category 有效时可省 --file 自动路由)',
678
634
  ' renumber [--dry-run|--apply] 存量决策号迁移为全局号(dry-run 先行;apply 写盘幂等)',
679
635
  ' redomain-unmapped [--face decisions|fr] [--apply] unmapped 停车场存量批量清偿(文件锚/覆盖面投票目标域;dry-run 先行)',
680
636
  '',
681
637
  '写盘面子命令(refresh/propose/classify)先跑同名只读命令确认现状;探索用法先看本帮助。',
682
638
  ]
683
639
  for (const l of lines) console.log(l)
684
- output(true, { help: true, subcommands: ['search', 'inspect', 'validate', 'digest', 'refresh', 'propose', 'classify', 'inbox', 'graph', 'stats', 'scope', 'redomain-unmapped'] })
640
+ output(true, { help: true, subcommands: ['search', 'inspect', 'validate', 'digest', 'refresh', 'propose', 'accept', 'classify', 'inbox', 'graph', 'stats', 'scope', 'redomain-unmapped'] })
685
641
  return
686
642
  }
687
643
  const subCommand = args[0] || ''
@@ -697,6 +653,20 @@ export async function cmdKnowledge(args, dir, opts = {}) {
697
653
  return cmdRefresh(dir, args.slice(1), opts)
698
654
  case 'propose':
699
655
  return cmdPropose(dir, args.slice(1), opts)
656
+ case 'accept': {
657
+ // 待审知识收口(2026-11-07-knowledge-accept-channel):合并 proposed 条目到正式知识
658
+ // 文件——动态 import 同 classify 先例(实现缺席返回友好错误)。
659
+ const mod = await tryImportSubcommandImpl('../knowledge-classify.js', 'knowledge-classify.js')
660
+ if (!mod || typeof mod.cmdKnowledgeAccept !== 'function') {
661
+ output(false, {}, {
662
+ code: 'not_implemented',
663
+ subcommand: 'accept',
664
+ message: '实现未落地(src/knowledge-classify.js cmdKnowledgeAccept)',
665
+ })
666
+ return
667
+ }
668
+ return mod.cmdKnowledgeAccept(dir, args.slice(1), opts)
669
+ }
700
670
  case 'redomain-unmapped': {
701
671
  // unmapped 停车场存量批量清偿(FR-04,2026-10-21-knowledge-tier-denoise):decisions 按文件锚
702
672
  // / fr 按来源变更覆盖面投票目标域;缺省 dry-run 报告先行,--apply 幂等迁移(不裸删、冲突跳过)。
@@ -857,7 +827,7 @@ export async function cmdKnowledge(args, dir, opts = {}) {
857
827
  output(false, {}, {
858
828
  code: 'unknown_subcommand',
859
829
  subCommand: subCommand,
860
- available: ['search', 'inspect', 'validate', 'refresh', 'propose', 'classify', 'inbox', 'stats', 'digest', 'graph', 'renumber', 'redomain-unmapped'], // dump 随 graph 路由面(2026-10-09-graph-dump-layout)
830
+ available: ['search', 'inspect', 'validate', 'refresh', 'propose', 'accept', 'classify', 'inbox', 'stats', 'digest', 'graph', 'renumber', 'redomain-unmapped'], // dump 随 graph 路由面(2026-10-09-graph-dump-layout)
861
831
  })
862
832
  }
863
833
  }