sillyspec 3.26.1 → 3.26.3

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.
@@ -9,12 +9,13 @@
9
9
  *
10
10
  * 这些都是 noAI 步骤,不需要 LLM 参与。
11
11
  */
12
- import { existsSync, readFileSync as _readFileSync, readdirSync } from 'fs'
12
+ import { existsSync, readFileSync as _readFileSync, readdirSync, mkdtempSync, writeFileSync, rmSync } from 'fs'
13
13
  // 归一化行尾为 LF:Windows 下 python/编辑器文本模式写 .md 会产生 CRLF,致本模块多处
14
14
  // frontmatter/字段正则(`^---\n`、`allowed_paths:\s*\n…`、`^goal:` 等)失配,报「缺 frontmatter
15
15
  // /缺字段」假错误(见缺陷 windows-python-crlf-taskcard)。读取时统一转 LF,一处覆盖全部正则。
16
16
  const readFileSync = (filePath, encoding) => _readFileSync(filePath, encoding).replace(/\r\n/g, '\n')
17
17
  import { join as pJoin } from 'path'
18
+ import { tmpdir } from 'os'
18
19
  import jsYaml from 'js-yaml'
19
20
  import { parseFileChangeList, pathMatches } from '../change-list.js'
20
21
  import { getRule } from '../stage-contract-spec.js'
@@ -80,6 +81,111 @@ export function parseAllowedPaths(content) {
80
81
  return []
81
82
  }
82
83
 
84
+ /**
85
+ * 从 task-NN.md frontmatter 解析标量字段(repo / base_commit / head_commit)。
86
+ * 复用 parseTaskContracts 的 frontmatter 提取 + js-yaml load 模式(标量可选字段,正则对引号/空值脆弱)。
87
+ *
88
+ * design §7.2 跨仓 task 卡 frontmatter 协议:
89
+ * repo: sillyspec # 可选,缺省='main'(调用方视 null 为 main)
90
+ * base_commit: <sha> # 可选,CLI 锡点写入(派发前落盘 base)
91
+ * head_commit: <sha> # 可选,CLI 锡点写入(回收 review 前落盘 head)
92
+ *
93
+ * 缺省 / 空值 / 无 frontmatter → null(向后兼容旧 task 卡)。
94
+ * @param {string} content - task 文件内容
95
+ * @param {string} field - frontmatter 字段名
96
+ * @returns {string|null}
97
+ */
98
+ function parseFrontmatterScalar(content, field) {
99
+ const fmMatch = content.match(/^---\n([\s\S]*?)\n---/)
100
+ if (!fmMatch) return null
101
+ let fm
102
+ try {
103
+ fm = jsYaml.load(fmMatch[1]) || {}
104
+ } catch {
105
+ return null
106
+ }
107
+ const v = fm[field]
108
+ if (v == null) return null
109
+ const s = String(v).trim()
110
+ return s === '' ? null : s
111
+ }
112
+
113
+ /**
114
+ * 解析 task-NN.md frontmatter 的 repo 字段(跨仓 task 声明)。
115
+ * 缺省=null(=main,调用方按主仓处理)。design §7.2 / D-001。
116
+ * @param {string} content
117
+ * @returns {string|null}
118
+ */
119
+ export function parseRepo(content) {
120
+ return parseFrontmatterScalar(content, 'repo')
121
+ }
122
+
123
+ /**
124
+ * 解析 task-NN.md frontmatter 的 base_commit 字段(CLI 派发跨仓 task 前落盘的 base 锡点)。
125
+ * 缺省=null(未锡点 / 单仓 task)。design §7.2 / D-010。
126
+ * @param {string} content
127
+ * @returns {string|null}
128
+ */
129
+ export function parseBaseCommit(content) {
130
+ return parseFrontmatterScalar(content, 'base_commit')
131
+ }
132
+
133
+ /**
134
+ * 解析 task-NN.md frontmatter 的 head_commit 字段(CLI 回收 review 前落盘的 head 锡点)。
135
+ * 缺省=null(未锡点 / 单仓 task)。design §7.2 / D-010。
136
+ * @param {string} content
137
+ * @returns {string|null}
138
+ */
139
+ export function parseHeadCommit(content) {
140
+ return parseFrontmatterScalar(content, 'head_commit')
141
+ }
142
+
143
+ /**
144
+ * 从 local.yaml 文本解析 repos: 段(跨仓 workspace 注册表)。
145
+ * 与 parseLocalYamlModules 同风格(轻量行扫描,不引 yaml 依赖),结构 Map<repoKey, absolutePath>。
146
+ *
147
+ * design §7.3 local.yaml repos schema:
148
+ * repos:
149
+ * sillyspec: C:/Users/qinyi/IdeaProjects/sillyspec
150
+ * # main 不用注册(隐式 = cwd / specRoot 父目录)
151
+ *
152
+ * 无 repos 段 / 空段 / 入参空 → 空 Map(单仓 change 不读,向后兼容)。
153
+ *
154
+ * **职责边界**:本函数只 parse local.yaml 文本,不读 local.yaml 文件(文件读取入口归
155
+ * execute 启动 / task-09)。本函数不校验 declaredRepos ⊆ registry(D-007 fail-closed
156
+ * 校验在 MultiRepoContext 构造时做)。
157
+ * @param {string} yamlText
158
+ * @returns {Map<string, string>} repoKey → absolutePath
159
+ */
160
+ export function parseRepoRegistry(yamlText) {
161
+ const reg = new Map()
162
+ if (!yamlText) return reg
163
+ const lines = yamlText.split('\n')
164
+ let startIdx = -1
165
+ for (let i = 0; i < lines.length; i++) {
166
+ if (/^repos:\s*(?:#.*)?$/.test(lines[i])) { startIdx = i; break }
167
+ }
168
+ if (startIdx === -1) return reg
169
+ for (let i = startIdx + 1; i < lines.length; i++) {
170
+ const line = lines[i]
171
+ // 遇到新的顶层 key(行首非空格且非注释)→ repos 段结束
172
+ if (line.length > 0 && !line.startsWith(' ') && !line.startsWith('\t') && !line.startsWith('#') && line.trim() !== '') break
173
+ // 条目格式: <key>: <value>(key 限 [A-Za-z0-9_.\-],与 parseLocalYamlModules 对齐)
174
+ const entry = line.match(/^\s+([A-Za-z0-9_.\-]+):\s*(.*)$/)
175
+ if (!entry) continue
176
+ const value = (entry[2] || '').trim()
177
+ if (value === '' || value.startsWith('#')) continue
178
+ // 去可选行内注释(`value # comment`)—— 注意路径不含 ` #`,注释前必带空格
179
+ const cleaned = value.replace(/\s+#.*$/, '').trim()
180
+ if (cleaned === '') continue
181
+ // 去首尾可选引号(YAML 字符串引用),保留路径内反斜杠原样
182
+ const path = cleaned.replace(/^['"]|['"]$/g, '')
183
+ if (path === '') continue
184
+ reg.set(entry[1], path)
185
+ }
186
+ return reg
187
+ }
188
+
83
189
  /**
84
190
  * 检查 task-NN.md 是否包含验收标准章节
85
191
  * @param {string} content
@@ -253,6 +359,11 @@ export function validateBlueprintConsistency(changeDir) {
253
359
  // task 卡片基础字段文案从 manifest 同源(plan.task-card-structure);${id} 由下方 `${taskId} (${file})` 替换。
254
360
  const bsRule = getRule('plan.task-card-structure')
255
361
  const taskInfo = new Map()
362
+ // pathOwners 按 (repo, path) 二元组聚合(design §5.3 约束③ / D-008):键 = `${repo}|${path}`,
363
+ // repo 缺省='main'(parseRepo 返 null → main)。跨仓 task 与主仓 task 同名物理路径分属不同 repo
364
+ // → 二元组键不同 → 不误判同 Wave 冲突(如主仓 src/task-review.js vs sillyspec src/task-review.js)。
365
+ // ⚠️ 键分隔符 `|` 假设 repo 名与 path 均不含 `|`(repo 名约束 [A-Za-z0-9_.\-]、path 含 / \,极低概率含 |)。
366
+ // value 结构 { repo, path, owners }:冲突文案对外仍显示纯 path(用户体验),内部按二元组判冲突。
256
367
  const pathOwners = new Map()
257
368
 
258
369
  for (const file of taskFiles) {
@@ -268,6 +379,8 @@ export function validateBlueprintConsistency(changeDir) {
268
379
  const allowedPaths = parseAllowedPaths(content)
269
380
  const hasAcceptance = hasAcceptanceCriteria(content)
270
381
  const hasTdd = hasTddOrVerify(content)
382
+ // repo 缺省='main'(parseRepo 返 null → 单仓 task,与既有行为零回归)
383
+ const repo = parseRepo(content) || 'main'
271
384
 
272
385
  taskInfo.set(taskId, { dependsOn, allowedPaths, hasAcceptance, hasTdd, file })
273
386
 
@@ -282,17 +395,19 @@ export function validateBlueprintConsistency(changeDir) {
282
395
  }
283
396
 
284
397
  for (const p of allowedPaths) {
285
- if (!pathOwners.has(p)) pathOwners.set(p, [])
286
- pathOwners.get(p).push(taskId)
398
+ const key = `${repo}|${p}`
399
+ if (!pathOwners.has(key)) pathOwners.set(key, { repo, path: p, owners: [] })
400
+ pathOwners.get(key).owners.push(taskId)
287
401
  }
288
402
  }
289
403
 
290
- // 路径冲突(Wave 感知):同 Wave 内 >1 task 共享 allowed_path → execute 强制并行(execute.js:603)
404
+ // 路径冲突(Wave 感知):同 repo 同 Wave 内 >1 task 共享 allowed_path → execute 强制并行(execute.js:603)
291
405
  // 子代理会互相覆盖该文件 → error。跨 Wave 同文件 → 串行执行安全 → warning。
406
+ // 按 (repo, path) 二元组判冲突(约束③):跨仓 task 与主仓 task 同名路径不误判(不同 repo → 不同键)。
292
407
  // Wave 口径 = plan.md 显式 `## Wave N`(parseTaskWavesFromPlan,与 execute 同源,非 topoSort 建议值);
293
- // plan.md 无显式 Wave → execute 全并行(隐式单 Wave,execute.js:402-408)→ 同文件即冲突。
408
+ // plan.md 无显式 Wave → execute 全并行(隐式单 Wave,execute.js:402-408)→ 同 repo 同文件即冲突。
294
409
  const waveOfTask = parseTaskWavesFromPlan(pJoin(changeDir, 'plan.md'))
295
- for (const [p, owners] of pathOwners) {
410
+ for (const [, { repo, path: p, owners }] of pathOwners) {
296
411
  if (owners.length < 2) continue
297
412
  if (waveOfTask === null) {
298
413
  errors.push(
@@ -515,6 +630,120 @@ export function validateTaskCommands(changeDir, projectRoot, modules = null) {
515
630
  // 路径容差匹配(normalizePath / globMatch / pathMatches)复用自 change-list.js,
516
631
  // design 清单解析与 allowed_paths 对账共用同一套匹配语义,避免两处逻辑漂移。
517
632
 
633
+ /**
634
+ * design §6 按仓分段段头识别(D-014):`## <repo> 仓变更`(如 `## sillyspec 仓变更`)。
635
+ * 段头为 h2,repo 名取段头首 token(去空白/编号前缀)。容忍可选编号前缀与尾随空白/冒号。
636
+ * 不命中(非仓变更段头)→ null。
637
+ */
638
+ const REPO_SECTION_HEADER_RE = /^#{2,3}\s+(?:\d+[.)]\s*)?([A-Za-z0-9_.\-]+)\s*仓变更\s*[::]?\s*$/
639
+
640
+ /**
641
+ * design §6 文件清单章节标题(与 change-list.js FILE_LIST_SECTION_RE 同源,避免 import 私有常量)。
642
+ * 容忍可选编号前缀(`## 6. 文件变更清单`),与 change-list 解析口径一致。
643
+ */
644
+ const FILE_LIST_SECTION_RE = /^#{2,3}\s*(?:\d+[.)]\s*)?(文件变更清单|变更文件清单|文件清单|File Changes|Files to Change)/im
645
+
646
+ /**
647
+ * 按 repo 解析 design §6 文件清单(约束③ / D-014)。
648
+ *
649
+ * 支持 design §6 按仓分段:`## <repo> 仓变更` 段头下的路径归该 repo;无段头时整章节归 'main'
650
+ * (向后兼容单仓 design,与原 parseFileChangeList 行为等价)。段内路径解析复用 parseFileChangeList
651
+ * (表格列定位 / exclude 子段 / 占位符过滤 / CRLF 容错全部继承,零漂移)——方法是:把每段内容包成
652
+ * 临时 design 文件(前置标准 `## 文件变更清单` 标题),写 OS tmp 调 parseFileChangeList,立即清理。
653
+ *
654
+ * 为什么不复用单次 parseFileChangeList:change-list.js 的通用 parser 把段头 `## <repo> 仓变更`
655
+ * 当成「下一个 ## 章节」截断主章节(line 173 `^##\s`),跨仓段路径会丢——这正是 task-03 要修的。
656
+ *
657
+ * 返回值结构(与 pathOwners 二元组对齐):
658
+ * Map<repo, Set<path>> —— 每个 repo 的 design 清单路径集
659
+ * 外加 _hasSegmentHeader 标记(调用方据此决定是否走分段对账路径)
660
+ *
661
+ * @param {string} designPath - design.md 绝对路径
662
+ * @returns {{ byRepo: Map<string, Set<string>>, hasSegmentHeader: boolean, allFiles: string[] }}
663
+ */
664
+ function parseDesignCoverageByRepo(designPath) {
665
+ const byRepo = new Map()
666
+ const allFiles = []
667
+ if (!designPath || !existsSync(designPath)) {
668
+ return { byRepo, hasSegmentHeader: false, allFiles }
669
+ }
670
+ const content = readFileSync(designPath, 'utf8')
671
+
672
+ const sectionMatch = content.match(FILE_LIST_SECTION_RE)
673
+ if (!sectionMatch) {
674
+ return { byRepo, hasSegmentHeader: false, allFiles }
675
+ }
676
+
677
+ // 主章节起点(match index)→ 扫描到下一个非段头的 `## ` 标题或文件末尾。
678
+ // 段头 `## <repo> 仓变更` 是 h2 但属本章节的子分段,不应触发章节结束。
679
+ const lines = content.slice(sectionMatch.index).split('\n')
680
+ // 先扫一遍:本章节内是否含至少一个段头(决定 hasSegmentHeader / 走分段路径 vs 退化路径)
681
+ let hasSegmentHeader = false
682
+ const sectionLines = []
683
+ for (let i = 0; i < lines.length; i++) {
684
+ const line = lines[i]
685
+ if (i > 0 && /^##\s/.test(line) && !REPO_SECTION_HEADER_RE.test(line)) {
686
+ // 遇到非段头的 h2 标题(如 `## 7. 接口定义`)→ 主章节结束
687
+ break
688
+ }
689
+ if (REPO_SECTION_HEADER_RE.test(line)) hasSegmentHeader = true
690
+ sectionLines.push(line)
691
+ }
692
+
693
+ if (!hasSegmentHeader) {
694
+ // 无段头 → 整章节归 main(等价原 parseFileChangeList 行为)
695
+ const main = new Set(parseFileChangeList(designPath))
696
+ if (main.size > 0) byRepo.set('main', main)
697
+ for (const p of main) allFiles.push(p)
698
+ return { byRepo, hasSegmentHeader: false, allFiles }
699
+ }
700
+
701
+ // 有段头 → 按 repo 切片,每段构造临时 design 调 parseFileChangeList
702
+ // 当前 repo:段头之前的内容(主章节标题到第一个段头之间)归 'main'
703
+ let currentRepo = 'main'
704
+ const segments = new Map() // repo -> 行数组(含表格/列表,不含段头本身)
705
+ let beforeFirstHeader = [] // 段头之前的 main 段内容
706
+ let seenHeader = false
707
+ for (const line of sectionLines.slice(1)) { // 跳过主章节标题行(sectionLines[0])
708
+ const hdr = line.match(REPO_SECTION_HEADER_RE)
709
+ if (hdr) {
710
+ currentRepo = hdr[1]
711
+ seenHeader = true
712
+ if (!segments.has(currentRepo)) segments.set(currentRepo, [])
713
+ continue
714
+ }
715
+ if (!seenHeader) {
716
+ beforeFirstHeader.push(line)
717
+ } else {
718
+ segments.get(currentRepo).push(line)
719
+ }
720
+ }
721
+
722
+ // 段头之前的 main 段(若有内容)也要对账
723
+ if (beforeFirstHeader.filter(l => l.trim()).length > 0) {
724
+ segments.set('main', beforeFirstHeader)
725
+ }
726
+
727
+ const tmpDir = mkdtempSync(pJoin(tmpdir(), 'design-cov-seg-'))
728
+ try {
729
+ for (const [repo, segLines] of segments) {
730
+ // 把段内容包成完整 design md(前置主章节标题,让 parseFileChangeList 识别章节起点)
731
+ const virtualDesign = ['## 文件变更清单', '', ...segLines, ''].join('\n')
732
+ const tmpPath = pJoin(tmpDir, `${repo}.md`)
733
+ writeFileSync(tmpPath, virtualDesign)
734
+ const segFiles = [...parseFileChangeList(tmpPath)]
735
+ if (segFiles.length === 0) continue
736
+ const set = byRepo.get(repo) || new Set()
737
+ for (const p of segFiles) { set.add(p); allFiles.push(p) }
738
+ byRepo.set(repo, set)
739
+ }
740
+ } finally {
741
+ rmSync(tmpDir, { recursive: true, force: true })
742
+ }
743
+
744
+ return { byRepo, hasSegmentHeader: true, allFiles }
745
+ }
746
+
518
747
  /**
519
748
  * design.md 文件变更清单 → tasks allowed_paths 覆盖对账
520
749
  *
@@ -525,6 +754,10 @@ export function validateTaskCommands(changeDir, projectRoot, modules = null) {
525
754
  * 在 plan-postcheck(execute 前)确定性拦截:design 清单中每个源码文件必须被
526
755
  * 至少一个 task 的 allowed_paths 覆盖(前缀 / glob 容差匹配)。
527
756
  *
757
+ * 跨仓分段对账(约束③ / D-014):design §6 支持 `## <repo> 仓变更` 段头按仓分段,
758
+ * task allowed_paths 按 (repo, path) 二元组归属——跨仓 task 的 allowed_paths 只覆盖
759
+ * 对应 repo 段的 design 清单,不与主仓段交叉误判。无段头时整章节归 main(零回归)。
760
+ *
528
761
  * fail-open 边界(不阻断):design.md 不存在、无 task 卡片(none 级别)、
529
762
  * task 卡片均无 allowed_paths(由 validatePlanFeasibility 把关)。
530
763
  * 阻断边界:有 task 卡片但 design 缺清单章节(light/full 已生成 task → design 必有清单)、
@@ -552,7 +785,8 @@ export function validateDesignFileCoverage(changeDir) {
552
785
  return { ok: true, errors, warnings, designFiles: [], uncovered: [] }
553
786
  }
554
787
 
555
- const designFiles = [...parseFileChangeList(designPath)]
788
+ // 按仓分段解析 design §6(约束③):段头 `## <repo> 仓变更` → 段内路径归该 repo;无段头 → 全 main
789
+ const { byRepo: designByRepo, hasSegmentHeader, allFiles: designFiles } = parseDesignCoverageByRepo(designPath)
556
790
  // 两种断裂文案(缺清单章节 / 文件未覆盖)从 manifest 同源(plan.design-file-coverage.data)。
557
791
  const dcRule = getRule('plan.design-file-coverage')
558
792
  if (designFiles.length === 0) {
@@ -561,16 +795,37 @@ export function validateDesignFileCoverage(changeDir) {
561
795
  errors.push(dcRule.data.messageMissingList)
562
796
  return { ok: false, errors, warnings, designFiles: [], uncovered: [] }
563
797
  }
564
- const allAllowed = []
798
+
799
+ // task allowed_paths 也按 (repo, path) 二元组收集(与 pathOwners 同口径,约束③):
800
+ // 跨仓 task 的 allowed_paths 只覆盖对应 repo 的 design 段,不与主仓段交叉误判。
801
+ const taskAllowedByRepo = new Map() // repo -> string[]
802
+ let totalAllowed = 0
565
803
  for (const file of taskFiles) {
566
804
  const content = readFileSync(pJoin(tasksDir, file), 'utf8')
567
- allAllowed.push(...parseAllowedPaths(content))
805
+ const repo = parseRepo(content) || 'main'
806
+ const allowed = parseAllowedPaths(content)
807
+ if (allowed.length === 0) continue
808
+ totalAllowed += allowed.length
809
+ const arr = taskAllowedByRepo.get(repo) || []
810
+ arr.push(...allowed)
811
+ taskAllowedByRepo.set(repo, arr)
568
812
  }
569
- if (allAllowed.length === 0) {
813
+ if (totalAllowed === 0) {
570
814
  return { ok: true, errors, warnings, designFiles, uncovered: [] }
571
815
  }
572
816
 
573
- const uncovered = designFiles.filter(df => !allAllowed.some(ap => pathMatches(df, ap)))
817
+ // 对账:design 的每条 (repo, path) 必须被同 repo 的 task allowed_paths 覆盖。
818
+ // 无分段(单仓)时 byRepo 只含 main,taskAllowedByRepo 也只含 main → 退化原扁平对账(零回归)。
819
+ const uncovered = []
820
+ for (const [repo, paths] of designByRepo) {
821
+ const allowed = taskAllowedByRepo.get(repo) || []
822
+ for (const df of paths) {
823
+ // 同 repo 无 task 声明 → 整段无覆盖;或有 task 但无 pathMatches → 未覆盖
824
+ if (!allowed.some(ap => pathMatches(df, ap))) {
825
+ uncovered.push(hasSegmentHeader ? `[${repo}] ${df}` : df)
826
+ }
827
+ }
828
+ }
574
829
  if (uncovered.length > 0) {
575
830
  errors.push(
576
831
  dcRule.data.messageUncovered
@@ -425,6 +425,11 @@ related_tests: # 可选。当本 task 改动会导致
425
425
 
426
426
  const prompt = `为 plan.md 中的每个任务生成紧凑 TaskCard。
427
427
 
428
+ ⚠️ 生成卡片前先确认 plan.md 已满足(否则下一步 postcheck 会硬拦,导致返工重编号/重分 Wave):
429
+ - **共享文件须分 Wave**:若多个 task 的 allowed_path 含同一文件,plan.md 必须把它们分到不同「## Wave N」(同 Wave 共享文件会被 execute 强制并行,子代理互相覆盖;postcheck 拦同 Wave 共享)
430
+ - **task id 从 1 连续**:task-01、task-02、task-03… 不能跳号或重号(postcheck 校验 id 连续性,gap 会拦)
431
+ 这两条是跨 task 全局约束,子代理只写单卡看不到全局——你(主 agent)分派子代理前必须先在 plan.md 里确认 Wave 划分与编号正确。
432
+
428
433
  ## 任务清单
429
434
  ${taskList}
430
435
 
package/src/sync.js CHANGED
@@ -40,8 +40,13 @@ const DOCUMENT_FILES = ['proposal.md', 'design.md', 'requirements.md', 'tasks.md
40
40
  // ── YAML 辅助 ──
41
41
 
42
42
  /**
43
- * 简易 YAML 读写,只处理 project 段的扁平结构。
43
+ * 简易 YAML 读取(解析为扁平 obj),供 _getPlatform / status 只读用。
44
44
  * 与 worktree-guard.js 的 parseSimpleYaml 保持一致的轻量风格。
45
+ *
46
+ * 写入侧(connect/disconnect)**不再**走 parse→modify→write 有损往返——
47
+ * readLocalYaml/parseSimpleYaml 丢注释(:85 跳 # 行)、丢数组/深嵌套(只认一层 key:value),
48
+ * round-trip 会清空用户手填的注释与其他段。改为 readLocalYamlRaw + replaceTopLevelSection
49
+ * 文本级定向替换 platform 段,原文件注释/空行/其他段/数组/深嵌套/CRLF 字节级保留。
45
50
  */
46
51
  function readLocalYaml(cwd) {
47
52
  const p = join(cwd, LOCAL_YAML);
@@ -53,28 +58,81 @@ function readLocalYaml(cwd) {
53
58
  }
54
59
  }
55
60
 
56
- function writeLocalYaml(cwd, obj) {
57
- const dir = join(cwd, '.sillyspec');
58
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
59
- const lines = [];
60
- const rootKeys = Object.keys(obj);
61
- for (const key of rootKeys) {
62
- const val = obj[key];
63
- if (val === null || val === undefined) continue;
64
- if (typeof val === 'object' && !Array.isArray(val)) {
65
- lines.push(`${key}:`);
66
- for (const [k, v] of Object.entries(val)) {
67
- if (typeof v === 'string') {
68
- lines.push(` ${k}: ${v}`);
69
- } else {
70
- lines.push(` ${k}: ${JSON.stringify(v)}`);
71
- }
72
- }
73
- } else {
74
- lines.push(`${key}: ${typeof val === 'string' ? val : JSON.stringify(val)}`);
61
+ /** 读 local.yaml 原始文本(保留注释/换行/结构),不存在返回 ''。供 connect/disconnect 文本级改写。 */
62
+ function readLocalYamlRaw(cwd) {
63
+ const p = join(cwd, LOCAL_YAML);
64
+ if (!existsSync(p)) return '';
65
+ try {
66
+ return readFileSync(p, 'utf8');
67
+ } catch {
68
+ return '';
69
+ }
70
+ }
71
+
72
+ /**
73
+ * 定位顶层 YAML 段(如 platform:/mcp:)的行范围 [start, end)。
74
+ * 段 = key 行(行首非空白 + 以 'name:' 开头)+ 后续连续缩进行(以空格/tab 开头)。
75
+ * 遇空行/注释/下一个顶层 key 即段结束——这些行不属于本段,保留不动。
76
+ * split('\n')/join('\n') 操作:CRLF 下 '\r' 留在行尾,重组原样还原(Windows 兼容,CLAUDE.md #13)。
77
+ * @param {string} text
78
+ * @param {string} name - 段名(不含冒号,如 'platform')
79
+ * @returns {{start: number, end: number} | null} 不存在返回 null
80
+ */
81
+ function findTopLevelSectionRange(text, name) {
82
+ const lines = text.split('\n');
83
+ const prefix = `${name}:`;
84
+ let start = -1;
85
+ for (let i = 0; i < lines.length; i++) {
86
+ const line = lines[i];
87
+ // 顶层 key 行:行首非空白(排除缩进子段)+ 以 'name:' 开头
88
+ if (/^\S/.test(line) && line.startsWith(prefix)) {
89
+ start = i;
90
+ break;
91
+ }
92
+ }
93
+ if (start === -1) return null;
94
+ let end = start + 1;
95
+ while (end < lines.length && /^[ \t]/.test(lines[end])) {
96
+ end++;
97
+ }
98
+ return { start, end };
99
+ }
100
+
101
+ /**
102
+ * 文本级定向替换/删除/追加一个顶层 YAML 段,保留文件其余所有字节
103
+ * (注释/空行/其他段/数组/深嵌套/CRLF 全保留)。
104
+ * @param {string} text - 原始文件文本
105
+ * @param {string} name - 段名(不含冒号)
106
+ * @param {string|null} body - 段体(不含 key 行);null=删除该段;string=替换或追加
107
+ * @returns {string} 新文本
108
+ */
109
+ function replaceTopLevelSection(text, name, body) {
110
+ const lines = text.split('\n');
111
+ const range = findTopLevelSectionRange(text, name);
112
+ if (range) {
113
+ const before = lines.slice(0, range.start);
114
+ const after = lines.slice(range.end);
115
+ if (body === null) {
116
+ return [...before, ...after].join('\n');
75
117
  }
118
+ const sectionLines = [`${name}:`, ...body.split('\n')];
119
+ return [...before, ...sectionLines, ...after].join('\n');
76
120
  }
77
- writeFileSync(join(cwd, LOCAL_YAML), lines.join('\n') + '\n', 'utf8');
121
+ // 段不存在
122
+ if (body === null) return text; // 删不存在的段,原样返回
123
+ // 追加:去尾换行后加空行分隔 + 新段;空文件直接起段
124
+ const stripped = text.replace(/(\r?\n)+$/, '');
125
+ if (stripped === '') {
126
+ return `${name}:\n${body}\n`;
127
+ }
128
+ return `${stripped}\n\n${name}:\n${body}\n`;
129
+ }
130
+
131
+ /** 文本级写 local.yaml(保留传入 text 的注释/结构);确保 .sillyspec 目录存在。 */
132
+ function writeLocalYamlRaw(cwd, text) {
133
+ const dir = join(cwd, '.sillyspec');
134
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
135
+ writeFileSync(join(cwd, LOCAL_YAML), text, 'utf8');
78
136
  }
79
137
 
80
138
  function parseSimpleYaml(content) {
@@ -201,7 +259,8 @@ export class SyncManager {
201
259
  */
202
260
  async connect(url, token, user) {
203
261
  // 验证连接
204
- const healthUrl = `${url.replace(/\/+$/, '')}/api/health`;
262
+ const normalizedUrl = url.replace(/\/+$/, '');
263
+ const healthUrl = `${normalizedUrl}/api/health`;
205
264
  const result = await fetchJson(healthUrl);
206
265
  if (result === null) {
207
266
  console.warn(`[sync] 平台连接验证失败: ${url}`);
@@ -209,24 +268,49 @@ export class SyncManager {
209
268
  }
210
269
  console.log(`[sync] 平台连接成功: ${url}`);
211
270
 
271
+ // 换发 workspace-scoped 同步 token(task-09 / D-005@v1):用传入 user 级 token
272
+ // (shk_live_ / JWT)作 Bearer + 本地 root_path(connect 的 cwd,与平台
273
+ // Workspace.root_path 绑定值等值匹配)调 resolve-by-root-path。成功取 shpsync_
274
+ // 覆盖 token 写入 platform 段;404(root_path 未绑)/403(无 WORKSPACE_WRITE)/断网
275
+ // 均降级沿用原 token 继续(best-effort 不阻断 connect)。
276
+ let effectiveToken = token;
277
+ const resolveUrl = `${normalizedUrl}/api/workspaces/resolve-by-root-path`;
278
+ const resolved = await fetchJson(resolveUrl, {
279
+ method: 'POST',
280
+ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
281
+ body: JSON.stringify({ root_path: this.cwd }),
282
+ });
283
+ if (resolved && resolved.token && resolved.token.startsWith('shpsync_')) {
284
+ effectiveToken = resolved.token;
285
+ console.log(
286
+ `[sync] 已换发 workspace-scoped 同步 token (workspace: ${resolved.workspace_id})`
287
+ );
288
+ } else {
289
+ console.warn(
290
+ '[sync] 换发 workspace-scoped token 失败(404/403/断网),沿用原 token 继续'
291
+ );
292
+ }
293
+
212
294
  // 解析推送者身份(D-004):显式 > git user.name > env;全失败留空不写 user 字段
213
295
  const resolvedUser = resolvePlatformUser(this.cwd, user);
214
296
 
215
- // 写入 local.yaml
216
- const config = readLocalYaml(this.cwd);
217
- config.platform = {
218
- url: url.replace(/\/+$/, ''),
219
- token,
220
- last_connected: new Date().toISOString(),
221
- };
297
+ // 文本级改写 local.yaml:定向替换 platform 段,原文件注释/其他段/数组/深嵌套/CRLF 字节级保留
298
+ // (旧 read-modify-write 经 parseSimpleYaml 丢注释+丢非扁平结构,round-trip 清空用户手填内容)。
299
+ const platformEntries = [
300
+ ` url: ${normalizedUrl}`,
301
+ ` token: ${effectiveToken}`,
302
+ ` last_connected: ${new Date().toISOString()}`,
303
+ ];
222
304
  if (resolvedUser) {
223
- config.platform.user = resolvedUser;
305
+ platformEntries.push(` user: ${resolvedUser}`);
224
306
  }
225
- // mcp 段同源假设(design §7.4):复用 platform 的 url/token;用户已手填 mcp 段则保留不覆盖(R-09)
226
- if (!config.mcp) {
227
- config.mcp = { url: url.replace(/\/+$/, ''), token };
307
+ let text = readLocalYamlRaw(this.cwd);
308
+ text = replaceTopLevelSection(text, 'platform', platformEntries.join('\n'));
309
+ // mcp 段同源假设(design §7.4):复用 platform 的 url/token;用户已手填 mcp 段则保留不覆盖(R-09,文本级检测)
310
+ if (findTopLevelSectionRange(text, 'mcp') === null) {
311
+ text = replaceTopLevelSection(text, 'mcp', ` url: ${normalizedUrl}\n token: ${token}`);
228
312
  }
229
- writeLocalYaml(this.cwd, config);
313
+ writeLocalYamlRaw(this.cwd, text);
230
314
  }
231
315
 
232
316
  /**
@@ -239,17 +323,18 @@ export class SyncManager {
239
323
  console.log('[sync] 已断开连接(无配置文件)');
240
324
  return;
241
325
  }
242
- const config = readLocalYaml(this.cwd);
243
- if (!config.platform) {
326
+ const text = readFileSync(p, 'utf8');
327
+ if (findTopLevelSectionRange(text, 'platform') === null) {
244
328
  console.log('[sync] 已断开连接(未连接)');
245
329
  return;
246
330
  }
247
- delete config.platform;
248
- if (Object.keys(config).length === 0) {
249
- // 配置为空,删除整个文件
331
+ // 文本级删除 platform 段,保留注释/其他段/数组/深嵌套/CRLF
332
+ const newText = replaceTopLevelSection(text, 'platform', null);
333
+ if (newText.trim() === '') {
334
+ // 删段后纯空白(无任何段也无注释)→ 删除整个文件;注释算内容,有注释则保留
250
335
  try { unlinkSync(p); } catch { /* best effort */ }
251
336
  } else {
252
- writeLocalYaml(this.cwd, config);
337
+ writeFileSync(p, newText, 'utf8');
253
338
  }
254
339
  console.log('[sync] 已断开连接');
255
340
  }