sillyspec 3.26.0 → 3.26.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.
@@ -24,9 +24,18 @@ export function printQuickAuditReview(review) {
24
24
  console.error(`\n quick 已停止:请恢复/拆分这些变更,或重新运行 quick 并显式声明范围。`)
25
25
  // 解锁咒语须在 BLOCKED 分支也给出 —— 原只在 confirm/review 分支打印,
26
26
  // 普通 --done 走这里只看到「恢复/拆分」反方向指引,正确解法(带 flag 重跑)根本不出现。
27
- console.error(` 如确认接受这些变更,重新运行 --done 时带上对应 flag 即可解锁:`)
28
- console.error(` sillyspec run quick --done --force-baseline --allow-new --change <id> --output "..."`)
29
- console.error(` (--force-baseline 覆盖受保护/危险文件如 src/run.js;--allow-new 允许新增文件)`)
27
+ // 但删除是例外:deletedFiles.length>0 是裸判定(auditQuickCompletion 判定行不受任何 flag
28
+ // 门控),--force-baseline / --allow-new 都救不回。此时甩 flag 建议会误导调用者照加后继续
29
+ // 被拦——改提示走完整流程 execute(有意设计:删除是高危操作,须 review 把关)。
30
+ if (review.deletedFiles && review.deletedFiles.length > 0) {
31
+ console.error(`\n ⛔ 本次含删除文件 ${review.deletedFiles.length} 个:${review.deletedFiles.join(', ')}`)
32
+ console.error(` quick 不支持删除——--force-baseline / --allow-new 均无法解锁(有意设计,高危操作须走 review 把关)。`)
33
+ console.error(` 请改走完整流程:sillyspec run execute(在 plan 的 task allowed_paths 内删除),或先 git restore 撤回删除后再 --done。`)
34
+ } else {
35
+ console.error(` 如确认接受这些变更,重新运行 --done 时带上对应 flag 即可解锁:`)
36
+ console.error(` sillyspec run quick --done --force-baseline --allow-new --change <id> --output "..."`)
37
+ console.error(` (--force-baseline 覆盖受保护/危险文件如 src/run.js;--allow-new 允许新增文件)`)
38
+ }
30
39
  } else if (review.status === 'warning') {
31
40
  console.warn(`\n⚠️ quick 变更边界审计 — WARNING:`)
32
41
  for (const r of review.reasons) {
package/src/run/shared.js CHANGED
@@ -655,10 +655,15 @@ export async function auditQuickCompletion(cwd, guard, options = {}) {
655
655
  console.log(` - ${r}`)
656
656
  }
657
657
  }
658
- console.log(`\n 如确认接受这些变更,重新运行 --done 时带上对应 flag 即可解锁:`)
659
- console.log(` sillyspec run quick --done --force-baseline --allow-new --change <id> --output "..."`)
660
- console.log(` (--force-baseline 覆盖受保护/危险文件如 src/run.js;--allow-new 允许新增文件)`)
661
- console.log(` 或在首个 sillyspec run quick 启动(step 1)时就声明这些 flag,持久化进 guard。`)
658
+ if (result.deletedFiles.length > 0) {
659
+ console.log(`\n ⛔ 本次含删除文件 ${result.deletedFiles.length} 个——quick 不支持删除,--force-baseline / --allow-new 均无法解锁(有意设计,高危操作须走 review 把关)。`)
660
+ console.log(` 请改走完整流程:sillyspec run execute(在 plan 的 task allowed_paths 内删除),或先 git restore 撤回删除后再 --done。`)
661
+ } else {
662
+ console.log(`\n 如确认接受这些变更,重新运行 --done 时带上对应 flag 即可解锁:`)
663
+ console.log(` sillyspec run quick --done --force-baseline --allow-new --change <id> --output "..."`)
664
+ console.log(` (--force-baseline 覆盖受保护/危险文件如 src/run.js;--allow-new 允许新增文件)`)
665
+ console.log(` 或在首个 sillyspec run quick 启动(step 1)时就声明这些 flag,持久化进 guard。`)
666
+ }
662
667
  }
663
668
  } catch (e) {
664
669
  result.reasons.push(`审计失败: ${e.message}`)
package/src/run/stage.js CHANGED
@@ -22,7 +22,7 @@ import { writeAtomicSync } from '../fs-atomic.js'
22
22
  import { resolveSpecDir, resolveChangeDir, resolveRuntimeRoot, resolveQuickSessionsDir, triggerSync, safeGit, parsePorcelainPath, formatWaitOptions, checkApproval, getStageSteps } from './shared.js'
23
23
  import { computeScanProfile, applyScanProfileSteps, executeScanPreflight, executeScanPostcheck } from './scan-profile.js'
24
24
  import { outputStep } from './prompt.js'
25
- import { allocateQuicklogEntry, deriveTitleFromLinkedChange } from '../quicklog.js'
25
+ import { allocateQuicklogEntry, deriveTitleFromLinkedChange, sanitizeDesc } from '../quicklog.js'
26
26
  import { checkTransition } from '../stage-contract.js'
27
27
  import { completeStageGates } from './gates.js'
28
28
 
@@ -288,6 +288,11 @@ export async function runStage(pm, progress, stageName, cwd, changeName, skipApp
288
288
  if (allowNew) parts.push('允许新增文件')
289
289
  console.log(`🛡️ quick 变更边界已记录: ${parts.join(', ')}`)
290
290
  console.log(`📝 QUICKLOG 条目已创建: ${qlId}`)
291
+ // 回填 DB changes 行的 title + quicklog_id,让 quick-<hex> 可读、DB↔QUICKLOG 可对账。
292
+ // title 用 quickDesc(任务描述或关联变更标题)经 sanitizeDesc 压一行限长,与 QUICKLOG 条目标题同源。
293
+ try {
294
+ pm.updateChangeMeta(cwd, changeName, { title: sanitizeDesc(quickDesc), quicklogId: qlId });
295
+ } catch { /* 回填失败不阻断 quick 启动 */ }
291
296
  pm._write(cwd, progress, changeName)
292
297
  } catch (e) {
293
298
  console.warn(`⚠️ baseline 记录失败: ${e.message}`)
@@ -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;
75
91
  }
76
92
  }
77
- writeFileSync(join(cwd, LOCAL_YAML), lines.join('\n') + '\n', 'utf8');
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');
117
+ }
118
+ const sectionLines = [`${name}:`, ...body.split('\n')];
119
+ return [...before, ...sectionLines, ...after].join('\n');
120
+ }
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}`);
@@ -212,21 +271,23 @@ export class SyncManager {
212
271
  // 解析推送者身份(D-004):显式 > git user.name > env;全失败留空不写 user 字段
213
272
  const resolvedUser = resolvePlatformUser(this.cwd, user);
214
273
 
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
- };
274
+ // 文本级改写 local.yaml:定向替换 platform 段,原文件注释/其他段/数组/深嵌套/CRLF 字节级保留
275
+ // (旧 read-modify-write 经 parseSimpleYaml 丢注释+丢非扁平结构,round-trip 清空用户手填内容)。
276
+ const platformEntries = [
277
+ ` url: ${normalizedUrl}`,
278
+ ` token: ${token}`,
279
+ ` last_connected: ${new Date().toISOString()}`,
280
+ ];
222
281
  if (resolvedUser) {
223
- config.platform.user = resolvedUser;
282
+ platformEntries.push(` user: ${resolvedUser}`);
224
283
  }
225
- // mcp 段同源假设(design §7.4):复用 platform 的 url/token;用户已手填 mcp 段则保留不覆盖(R-09)
226
- if (!config.mcp) {
227
- config.mcp = { url: url.replace(/\/+$/, ''), token };
284
+ let text = readLocalYamlRaw(this.cwd);
285
+ text = replaceTopLevelSection(text, 'platform', platformEntries.join('\n'));
286
+ // mcp 段同源假设(design §7.4):复用 platform 的 url/token;用户已手填 mcp 段则保留不覆盖(R-09,文本级检测)
287
+ if (findTopLevelSectionRange(text, 'mcp') === null) {
288
+ text = replaceTopLevelSection(text, 'mcp', ` url: ${normalizedUrl}\n token: ${token}`);
228
289
  }
229
- writeLocalYaml(this.cwd, config);
290
+ writeLocalYamlRaw(this.cwd, text);
230
291
  }
231
292
 
232
293
  /**
@@ -239,17 +300,18 @@ export class SyncManager {
239
300
  console.log('[sync] 已断开连接(无配置文件)');
240
301
  return;
241
302
  }
242
- const config = readLocalYaml(this.cwd);
243
- if (!config.platform) {
303
+ const text = readFileSync(p, 'utf8');
304
+ if (findTopLevelSectionRange(text, 'platform') === null) {
244
305
  console.log('[sync] 已断开连接(未连接)');
245
306
  return;
246
307
  }
247
- delete config.platform;
248
- if (Object.keys(config).length === 0) {
249
- // 配置为空,删除整个文件
308
+ // 文本级删除 platform 段,保留注释/其他段/数组/深嵌套/CRLF
309
+ const newText = replaceTopLevelSection(text, 'platform', null);
310
+ if (newText.trim() === '') {
311
+ // 删段后纯空白(无任何段也无注释)→ 删除整个文件;注释算内容,有注释则保留
250
312
  try { unlinkSync(p); } catch { /* best effort */ }
251
313
  } else {
252
- writeLocalYaml(this.cwd, config);
314
+ writeFileSync(p, newText, 'utf8');
253
315
  }
254
316
  console.log('[sync] 已断开连接');
255
317
  }