sillyspec 3.34.0 → 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.
@@ -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
 
@@ -75,13 +75,28 @@ function extractTitle(content) {
75
75
 
76
76
  // ── search ──
77
77
 
78
+ // 位置参数取词(2026-11-04-knowledge-search-positional):--query 缺失时取首个非 flag
79
+ // 且非 flag 值的 token 作查询词(AGENTS.md 模板形态 search "<关键词>")。跳过 '--' 开头
80
+ // token 与 --limit 的值 token——防 ['--limit','2'] 的 '2' 被误拾;flag 字面量永不入查询词
81
+ // (flag-as-value 坑语义保持:--query 后跟 flag 仍按缺参,不从残参里捡词)。
82
+ function positionalQuery(args) {
83
+ for (let i = 0; i < args.length; i++) {
84
+ const a = args[i]
85
+ if (a === '--limit') { i += 1; continue }
86
+ if (a.startsWith('--')) continue
87
+ return a
88
+ }
89
+ return ''
90
+ }
91
+
78
92
  export async function cmdSearch(dir, args, opts = {}) {
79
93
  const knowledgeDir = resolveKnowledgeDir(dir, opts.specDir)
80
94
  // flag-as-value 防护(坑 knowledge-flag-as-value):值以 '--' 开头视为漏传(只读命令,
81
95
  // 按缺参处理落入既有 required 校验/默认值,不静默拿 flag 字面量当查询词)
82
96
  const queryIdx = args.indexOf('--query')
83
97
  const queryRaw = queryIdx >= 0 ? args[queryIdx + 1] : ''
84
- const query = queryRaw && !queryRaw.startsWith('--') ? queryRaw : ''
98
+ // --query 优先(显式压过位置推断);缺失时回退位置参数形态
99
+ const query = (queryRaw && !queryRaw.startsWith('--') ? queryRaw : '') || positionalQuery(args)
85
100
  const limitIdx = args.indexOf('--limit')
86
101
  const limitRaw = limitIdx >= 0 ? args[limitIdx + 1] : ''
87
102
  const parsedLimit = limitRaw && !limitRaw.startsWith('--') ? parseInt(limitRaw, 10) : NaN
@@ -117,6 +132,21 @@ export async function cmdSearch(dir, args, opts = {}) {
117
132
  ? result.json.parkingFiltered : []
118
133
  const payload = buildSearchPayload(result, knowledgeDir, limit, query)
119
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
+
120
150
  output(true, {
121
151
  query,
122
152
  ...payload,
@@ -161,11 +191,8 @@ export function buildSearchPayload(result, knowledgeDir, limit, query) {
161
191
  */
162
192
  export function buildSearchMatches(result, knowledgeDir, limit, query) {
163
193
  const ctx = String(query || '').toLowerCase()
164
- const channel = result && result.json && result.json.fallback ? 'fallback'
165
- : result && result.json && result.json.vector ? 'vector'
166
- : result && result.json && result.json.scope ? 'scope'
167
- : 'routing'
168
- return result.entries.slice(0, limit).map(entry => {
194
+ const channel = resultChannel(result)
195
+ return result.entries.slice(0, limit).map((entry) => {
169
196
  const filePath = join(knowledgeDir, entry.file)
170
197
  let title = entry.display
171
198
  let summary = ''
@@ -177,12 +204,16 @@ export function buildSearchMatches(result, knowledgeDir, limit, query) {
177
204
  }
178
205
 
179
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
180
211
  return {
181
212
  id: pathToId(entry.file, knowledgeDir),
182
213
  path: join('knowledge', entry.file),
183
214
  title,
184
215
  summary,
185
- score: tags.filter(k => keywordMatchesContext(k, ctx)).length,
216
+ score: vectorScore !== null ? Math.round(vectorScore * 1000) / 1000 : tags.filter((k) => keywordMatchesContext(k, ctx)).length,
186
217
  tags,
187
218
  channel,
188
219
  category: entry.category,
@@ -384,120 +415,17 @@ export async function cmdValidate(dir, args, opts = {}) {
384
415
  // ── refresh ──
385
416
 
386
417
  export async function cmdRefresh(dir, args, opts = {}) {
418
+ // generated 切块退役(2026-11-07-knowledge-generated-retire):双仓审计实证切块镜像为
419
+ // 零消费死区(INDEX 零引用、检索四层零进语料、自带 INDEX 零读取方)且是双源漂移源
420
+ // (平台仓同事实矛盾实证)——scan 文档本体在 docs 下项目 scan 目录,保留为唯一真相源,
421
+ // 切块产物退出知识库。子命令入口保留防脚本断链,行为=废弃提示零写盘。
387
422
  const knowledgeDir = resolveKnowledgeDir(dir, opts.specDir)
388
- const generatedDir = join(knowledgeDir, 'generated')
389
-
390
- if (!existsSync(knowledgeDir)) {
391
- output(false, {}, { code: 'knowledge_dir_missing', path: knowledgeDir })
392
- return
393
- }
394
-
395
- // refresh 只写 generated/ 区
396
- mkdirSync(generatedDir, { recursive: true })
397
-
398
- // 扫描 scan 文档。项目名用 DB 注册名优先(坑 knowledge-basename-project-mismatch):
399
- // scan 写侧 docs/<project> 的 project 来自 dbProjectName || basename(cwd),注册名 ≠ 目录名
400
- // (projects yaml name/path 分离、平台模式 specRoot≠sourceRoot)时,用 basename(dir) 拼
401
- // scanDir 永远找不到 → refresh 恒报 scan_docs_missing。DB 读失败回退 basename(与写侧
402
- // default 同口径)。查不到注册名对应目录时再回退 basename 试一次(双形态兼容)。
403
- const base = opts.specDir || join(dir, '.sillyspec')
404
- let projectName = null
405
- try {
406
- const { DatabaseSync } = await import('node:sqlite')
407
- const db = new DatabaseSync(join(base, '.runtime', 'sillyspec.db'), { readOnly: true })
408
- const row = db.prepare('SELECT name FROM project LIMIT 1').get()
409
- db.close()
410
- if (row && row.name) projectName = row.name
411
- } catch { /* db 不可读 → 走 basename 兜底 */ }
412
- if (!projectName) projectName = basename(dir)
413
- let scanDir = join(base, 'docs', projectName, 'scan')
414
- if (!existsSync(scanDir) && projectName !== basename(dir)) {
415
- scanDir = join(base, 'docs', basename(dir), 'scan')
416
- }
417
-
418
- if (!existsSync(scanDir)) {
419
- output(false, {}, { code: 'scan_docs_missing', path: scanDir })
420
- return
421
- }
422
-
423
- const scanFiles = readdirSync(scanDir).filter(f => f.endsWith('.md'))
424
- const generated = []
425
- const overwritten = []
426
- // 同 run 文件名去重(坑 knowledge-slug-collision):slug 只由章节标题生成,不同 scan 文件
427
- // 的同名章节(如各自都有「## 概述」)后者 writeFileSync 静默覆盖前者,INDEX 却登记两条
428
- // 指向同一路径的条目——知识条目无声丢失。碰撞时追加 -2/-3 后缀保双份(唯一命名零扰动,
429
- // 每次 refresh 按相同文件顺序重跑,后缀分配稳定)
430
- const usedNames = new Set()
431
-
432
- for (const scanFile of scanFiles) {
433
- const scanPath = join(scanDir, scanFile)
434
- const content = readFileSync(scanPath, 'utf8')
435
- const title = extractTitle(content) || scanFile.replace('.md', '')
436
-
437
- // 简单提取:取每个 ## 章节作为一个知识条目候选
438
- const sections = extractSections(content)
439
- for (const section of sections) {
440
- if (section.lines < 3) continue // 太短的章节跳过
441
-
442
- const slug = section.heading.toLowerCase()
443
- .replace(/[^\w\u4e00-\u9fff]+/g, '-')
444
- .replace(/^-|-$/g, '')
445
- .slice(0, 60)
446
-
447
- let fileName = `${slug}.md`
448
- if (usedNames.has(fileName)) {
449
- let n = 2
450
- while (usedNames.has(`${slug}-${n}.md`)) n++
451
- fileName = `${slug}-${n}.md`
452
- }
453
- usedNames.add(fileName)
454
- const genPath = join(generatedDir, fileName)
455
- const isNew = !existsSync(genPath)
456
-
457
- const entryContent = [
458
- `---`,
459
- `source: scan/${scanFile}`,
460
- `section: ${section.heading}`,
461
- `generated_at: ${new Date().toISOString()}`,
462
- `---`,
463
- ``,
464
- `# ${title} — ${section.heading}`,
465
- ``,
466
- section.body,
467
- ].join('\n')
468
-
469
- // INDEX/generated 是 search/validate 的解析源(跨进程读),走原子写防半截
470
- writeAtomicSync(genPath, entryContent)
471
- generated.push({ file: `generated/${fileName}`, title: `${title} — ${section.heading}`, new: isNew })
472
- if (!isNew) overwritten.push(`generated/${fileName}`)
473
- }
474
- }
475
-
476
- // 更新 generated/INDEX.md
477
- const genIndexPath = join(generatedDir, 'INDEX.md')
478
- const indexLines = [
479
- '---',
480
- `generated_at: ${new Date().toISOString()}`,
481
- '---',
482
- '',
483
- '# Generated Knowledge Index',
484
- '',
485
- '> Auto-generated from scan documents. Do not edit manually.',
486
- '',
487
- ]
488
- for (const g of generated) {
489
- indexLines.push(`- ${g.title} → [${g.file}](${g.file})`)
490
- }
491
- writeAtomicSync(genIndexPath, indexLines.join('\n'))
492
-
493
423
  output(true, {
494
- generated_count: generated.length,
495
- new_count: generated.filter(g => g.new).length,
496
- overwritten_count: overwritten.length,
497
- 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,
498
427
  })
499
428
  }
500
-
501
429
  // ── propose ──
502
430
 
503
431
  export async function cmdPropose(dir, args, opts = {}) {
@@ -510,6 +438,13 @@ export async function cmdPropose(dir, args, opts = {}) {
510
438
  const category = categoryIdx >= 0 && args[categoryIdx + 1] ? args[categoryIdx + 1] : 'uncategorized'
511
439
  const bodyIdx = args.indexOf('--body')
512
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 = ''
513
448
  const fromIdx = args.indexOf('--from')
514
449
  const from = fromIdx >= 0 && args[fromIdx + 1] ? args[fromIdx + 1] : ''
515
450
 
@@ -518,6 +453,40 @@ export async function cmdPropose(dir, args, opts = {}) {
518
453
  return
519
454
  }
520
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
+
521
490
  mkdirSync(proposedDir, { recursive: true })
522
491
 
523
492
  // 生成 slug
@@ -540,7 +509,8 @@ export async function cmdPropose(dir, args, opts = {}) {
540
509
  '',
541
510
  `# ${title}`,
542
511
  '',
543
- body || '(no body provided)',
512
+ // argv 通道保持既有空兜底;文件通道逐字(空即空,见上方读取处注释)
513
+ hasBodyFile ? bodyText : (bodyText || '(no body provided)'),
544
514
  '',
545
515
  '---',
546
516
  '> This is a proposed knowledge entry. Review and merge into manual/ or generated/.',
@@ -652,21 +622,22 @@ export async function cmdKnowledge(args, dir, opts = {}) {
652
622
  '用法: sillyspec knowledge <子命令> [参数]',
653
623
  '',
654
624
  '子命令:',
655
- ' search "<关键词>" 检索知识库(命中自动注入 prompt 的同源面)',
625
+ ' search "<关键词>" 检索知识库(--query 等效;命中自动注入 prompt 的同源面)',
656
626
  ' inspect <文件> 查看单个知识文件条目结构',
657
627
  ' validate 校验知识库完整性(INDEX 锚点/注册/幽灵条目——纯读)',
658
628
  ' digest 治理信号卡(rot/伪域/坏绑定——纯读)',
659
629
  ' scope 知识注入作用域现状与标记语法(always/repo/module/关键词;纯读)',
660
630
  ' stats 覆盖率/死条目统计(纯读)',
661
- ' refresh 重扫 scan 文档重写 generated/ 区(**写盘**——先 validate 再用)',
662
- ' 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 自动路由)',
663
634
  ' renumber [--dry-run|--apply] 存量决策号迁移为全局号(dry-run 先行;apply 写盘幂等)',
664
635
  ' redomain-unmapped [--face decisions|fr] [--apply] unmapped 停车场存量批量清偿(文件锚/覆盖面投票目标域;dry-run 先行)',
665
636
  '',
666
637
  '写盘面子命令(refresh/propose/classify)先跑同名只读命令确认现状;探索用法先看本帮助。',
667
638
  ]
668
639
  for (const l of lines) console.log(l)
669
- 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'] })
670
641
  return
671
642
  }
672
643
  const subCommand = args[0] || ''
@@ -682,6 +653,20 @@ export async function cmdKnowledge(args, dir, opts = {}) {
682
653
  return cmdRefresh(dir, args.slice(1), opts)
683
654
  case 'propose':
684
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
+ }
685
670
  case 'redomain-unmapped': {
686
671
  // unmapped 停车场存量批量清偿(FR-04,2026-10-21-knowledge-tier-denoise):decisions 按文件锚
687
672
  // / fr 按来源变更覆盖面投票目标域;缺省 dry-run 报告先行,--apply 幂等迁移(不裸删、冲突跳过)。
@@ -842,7 +827,7 @@ export async function cmdKnowledge(args, dir, opts = {}) {
842
827
  output(false, {}, {
843
828
  code: 'unknown_subcommand',
844
829
  subCommand: subCommand,
845
- 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)
846
831
  })
847
832
  }
848
833
  }
package/src/task-tick.js CHANGED
@@ -20,6 +20,7 @@
20
20
  */
21
21
  import { existsSync, readFileSync, appendFileSync } from 'node:fs'
22
22
  import { runtimeStatePath } from './runtime-paths.js'
23
+ import { fenceScan } from './md-fence.js'
23
24
  import { join } from 'node:path'
24
25
 
25
26
  /** 任务行(宽容缩进/星号有序标记,与哨兵判集行锚同口径 + 缩进容差)。 */
@@ -31,11 +32,23 @@ export function normalizeTaskId(id) {
31
32
  return m ? `task-${String(Number(m[1])).padStart(2, '0')}` : null
32
33
  }
33
34
 
34
- /** 任务行清单(行序即文档序):[{id, checked, desc}]。 */
35
+ /**
36
+ * 任务行清单(行序即文档序):[{id, checked, desc}]。
37
+ * 围栏感知(2026-11-04-fence-aware-task-rows):代码围栏内的示例任务行不计——
38
+ * 文档围栏常含 `- [ ] task-N:` 形态示例,逐行正则会把它们当真实任务(幻影行污染
39
+ * 进度 N/M 与下一任务指针)。按 fenceScan 行标记过滤,围栏语法口径见 md-fence.js。
40
+ */
35
41
  function collectRows(md) {
42
+ const text = String(md || '')
43
+ const { inFence } = fenceScan(text)
36
44
  const rows = []
37
- for (const line of String(md || '').split(/\r?\n/)) {
38
- const m = line.match(TASK_ROW_RE)
45
+ // 行切分口径:split(/\r?\n/) 与 fenceScan 的 split('\n') 行数恒等(\r 只附着前行段),
46
+ // inFence 行索引直接对齐;行内容去 \r 是硬需求——JS 正则的 . 不匹配 \r,TASK_ROW_RE
47
+ // 的 (.*)$ 咬不到带 \r 的行尾(CRLF 全漏检)。
48
+ const lines = text.split(/\r?\n/)
49
+ for (let i = 0; i < lines.length; i++) {
50
+ if (inFence[i]) continue
51
+ const m = lines[i].match(TASK_ROW_RE)
39
52
  if (m) rows.push({ id: `task-${String(Number(m[4])).padStart(2, '0')}`, checked: m[2].toLowerCase() === 'x', desc: (m[5] || '').trim() })
40
53
  }
41
54
  return rows
@@ -44,19 +57,34 @@ function collectRows(md) {
44
57
  /**
45
58
  * 纯函数:翻格 + 进度回显材料。
46
59
  * @param {{tasksMd:string, taskId:string}} opts
47
- * @returns {{kind:'ticked'|'already', tasksMd:string, checked:number, total:number, next:{id:string,desc:string}|null}
48
- * |{kind:'unknown', available:string[]}}
60
+ * @returns {{kind:'ticked'|'already', tasksMd:string, checked:number, total:number, next:{id:string,desc:string}|null, unclosedFence:boolean, unclosedFenceLine:number|null}
61
+ * |{kind:'unknown', available:string[], unclosedFence:boolean, unclosedFenceLine:number|null}}
62
+ * unclosedFence/unclosedFenceLine(2026-11-04-fence-aware-task-rows):tasks.md 存在未闭合
63
+ * 代码围栏及其 1 基起始行号——围栏内任务行按开栏即入栏不计,advisory 面(不阻断翻格)。
49
64
  */
50
65
  export function tickTasksMd({ tasksMd, taskId }) {
51
66
  const id = normalizeTaskId(taskId)
52
- if (!id) return { kind: 'unknown', available: [] }
67
+ if (!id) return { kind: 'unknown', available: [], unclosedFence: false, unclosedFenceLine: null }
68
+ const scanInfo = fenceScan(String(tasksMd || ''))
53
69
  const rows = collectRows(tasksMd)
54
70
  const target = rows.find((r) => r.id === id)
55
- if (!target) return { kind: 'unknown', available: rows.map((r) => r.id) }
71
+ if (!target) {
72
+ return { kind: 'unknown', available: rows.map((r) => r.id), unclosedFence: scanInfo.unclosed, unclosedFenceLine: scanInfo.fenceStartLine }
73
+ }
56
74
  let out = String(tasksMd || '')
57
75
  if (!target.checked) {
58
- const re = new RegExp(`^(\\s*[-*] \\[) (\\] ${id}:)`, 'm')
59
- out = out.replace(re, '$1x$2')
76
+ // 行内替换(围栏感知):只动目标行 checkbox 态字符,其余字节逐字保留(\r 留在行内,
77
+ // 按 \n split 重 join 对 LF/CRLF 透明);围栏内同 id 示例行不命中(inFence 行跳过)。
78
+ const re = new RegExp(`^(\\s*[-*] \\[) (\\] ${id}:)`)
79
+ const lines = out.split('\n')
80
+ for (let i = 0; i < lines.length; i++) {
81
+ if (scanInfo.inFence[i]) continue
82
+ if (re.test(lines[i])) {
83
+ lines[i] = lines[i].replace(re, '$1x$2')
84
+ break
85
+ }
86
+ }
87
+ out = lines.join('\n')
60
88
  }
61
89
  const after = collectRows(out)
62
90
  const checked = after.filter((r) => r.checked).length
@@ -67,6 +95,8 @@ export function tickTasksMd({ tasksMd, taskId }) {
67
95
  checked,
68
96
  total: after.length,
69
97
  next: pending ? { id: pending.id, desc: pending.desc } : null,
98
+ unclosedFence: scanInfo.unclosed,
99
+ unclosedFenceLine: scanInfo.fenceStartLine,
70
100
  }
71
101
  }
72
102
 
@@ -111,6 +141,9 @@ export async function runTaskTick({ changeName, cwd, taskId, specBase, evidence
111
141
  process.exit(2)
112
142
  }
113
143
  const r = tickTasksMd({ tasksMd: readFileSync(tasksPath, 'utf8'), taskId })
144
+ if (r.unclosedFence) {
145
+ console.warn(`⚠️ tasks.md 存在未闭合代码围栏(第 ${r.unclosedFenceLine} 行起)——围栏内任务行不计入任务集(检查围栏开合)`)
146
+ }
114
147
  if (r.kind === 'unknown') {
115
148
  const ids = (r.available || []).join('、') || '(tasks.md 无任务行)'
116
149
  console.error(`❌ tasks.md 中无 ${taskId}——可选任务:${ids}`)
@@ -3,6 +3,9 @@
3
3
  ## 项目说明
4
4
  本项目使用 **SillySpec** 管理变更,采用文档驱动开发。SillySpec 是给 Agent 调用的 CLI 流程控制器:你通过 CLI 告诉它"我在哪",它告诉你"下一步做什么";你执行步骤,它校验产出、推进状态,人类只在关键决策点介入审批。要考虑多 agent 同时操作代码,代码随时可能变化。所有变更以稳定、可用、可维护为目标,按生产级标准处理。
5
5
 
6
+ ## 子代理豁免
7
+ 被派发/派生的子代理(subagent)不执行本流程:不运行 sillyspec 流程命令(立项/批准/收口/归档/推送等),只完成派单任务并回传结果——变更状态所有权归派单的主会话。子代理发现流程前提不满足时在回传中报告,由主会话处理。
8
+
6
9
  ## 选道(动手前先对号入座)
7
10
  | 需求形态 | 走法 |
8
11
  |---|---|