sillyspec 3.18.0 → 3.18.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sillyspec",
3
- "version": "3.18.0",
3
+ "version": "3.18.1",
4
4
  "description": "SillySpec CLI — 流程状态机,让 AI 严格按步骤来",
5
5
  "icon": "logo.jpg",
6
6
  "homepage": "https://sillyspec.ppdmq.top/",
package/src/db.js CHANGED
@@ -175,6 +175,10 @@ export class DB {
175
175
  this._migrateAddColumn('steps', 'wait_options', 'TEXT');
176
176
  this._migrateAddColumn('steps', 'wait_answer', 'TEXT');
177
177
  this._migrateAddColumn('steps', 'waited_at', 'TEXT');
178
+ // repeatableWait support
179
+ this._migrateAddColumn('steps', 'wait_answers', 'TEXT'); // JSON array
180
+ this._migrateAddColumn('steps', 'wait_round', 'INTEGER');
181
+ this._migrateAddColumn('steps', 'max_wait_rounds', 'INTEGER');
178
182
  }
179
183
 
180
184
  /**
@@ -126,6 +126,96 @@ function isPathInside(child, parent) {
126
126
  return absChild === absParent || absChild.startsWith(absParent + path.sep)
127
127
  }
128
128
 
129
+ function toPosixPath(filePath) {
130
+ return filePath.replace(/\\/g, '/')
131
+ }
132
+
133
+ function parseFrontmatter(content) {
134
+ if (!content.startsWith('---\n') && !content.startsWith('---\r\n')) return {}
135
+ const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n/)
136
+ if (!match) return {}
137
+ const result = {}
138
+ for (const line of match[1].split(/\r?\n/)) {
139
+ const m = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/)
140
+ if (!m) continue
141
+ result[m[1]] = m[2].replace(/^['"]|['"]$/g, '').trim()
142
+ }
143
+ return result
144
+ }
145
+
146
+ function parseTimestamp(value) {
147
+ if (!value) return null
148
+ const time = Date.parse(value)
149
+ return Number.isNaN(time) ? null : time
150
+ }
151
+
152
+ function getScanDocInfo(filePath) {
153
+ const normalized = toPosixPath(path.resolve(filePath))
154
+ const match = normalized.match(/^(.*)\/docs\/([^/]+)\/scan\/([^/]+\.md)$/)
155
+ if (!match) return null
156
+ return {
157
+ specRoot: path.resolve(match[1]),
158
+ projectName: match[2],
159
+ docName: match[3],
160
+ }
161
+ }
162
+
163
+ function readScanGuard(scanDocInfo, projectRoot) {
164
+ const candidates = [
165
+ path.join(scanDocInfo.specRoot, '.runtime', 'scan-guard.json'),
166
+ path.join(projectRoot, '.sillyspec', '.runtime', 'scan-guard.json'),
167
+ ]
168
+ for (const p of candidates) {
169
+ if (!existsSync(p)) continue
170
+ try {
171
+ return JSON.parse(readFileSync(p, 'utf8'))
172
+ } catch {
173
+ return null
174
+ }
175
+ }
176
+ return null
177
+ }
178
+
179
+ function shouldBlockScanDocOverwrite(filePath, projectRoot) {
180
+ const scanDocInfo = getScanDocInfo(filePath)
181
+ if (!scanDocInfo || !existsSync(filePath)) return { blocked: false }
182
+
183
+ const guard = readScanGuard(scanDocInfo, projectRoot)
184
+ if (!guard || guard.forceRescan) return { blocked: false }
185
+
186
+ let frontmatter = {}
187
+ try {
188
+ frontmatter = parseFrontmatter(readFileSync(filePath, 'utf8'))
189
+ } catch {
190
+ return { blocked: false }
191
+ }
192
+
193
+ const relPath = toPosixPath(path.relative(projectRoot, filePath))
194
+ if (frontmatter.source_commit && guard.sourceCommit && frontmatter.source_commit !== guard.sourceCommit) {
195
+ return {
196
+ blocked: true,
197
+ reason: [
198
+ `scan 覆盖保护:${relPath} 的 source_commit=${frontmatter.source_commit} 与当前 scan source_commit=${guard.sourceCommit} 不一致。`,
199
+ '如确认要重新生成,请重新运行 scan 并添加 --force-rescan。',
200
+ ].join('\n')
201
+ }
202
+ }
203
+
204
+ const existingUpdatedAt = parseTimestamp(frontmatter.updated_at)
205
+ const scanStartedAt = parseTimestamp(guard.startedAt)
206
+ if (existingUpdatedAt && scanStartedAt && existingUpdatedAt > scanStartedAt) {
207
+ return {
208
+ blocked: true,
209
+ reason: [
210
+ `scan 覆盖保护:${relPath} 的 updated_at 晚于本次 scan 开始时间,可能包含手工编辑。`,
211
+ '如确认要覆盖,请重新运行 scan 并添加 --force-rescan。',
212
+ ].join('\n')
213
+ }
214
+ }
215
+
216
+ return { blocked: false }
217
+ }
218
+
129
219
  function isInsideWorktreeStorage(filePath, cwd) {
130
220
  const absPath = path.isAbsolute(filePath) ? filePath : path.resolve(cwd || process.cwd(), filePath)
131
221
  return isPathInside(absPath, resolveWorktreeDir(cwd || process.cwd()))
@@ -489,12 +579,15 @@ export function shouldBlockWrite(filePath, cwd) {
489
579
  const projectRoot = findProjectRoot(callerCwd)
490
580
  const absPath = path.isAbsolute(filePath) ? filePath : path.resolve(callerCwd, filePath)
491
581
 
492
- // 1. 文件门禁:文档类/配置类始终放行,但 worktree 存储区内的源码必须继续走登记校验。
493
- if (!isInsideWorktreeStorage(absPath, projectRoot) && matchFileWhitelist(absPath)) return { blocked: false }
494
-
495
- // 2. 阶段门禁(使用 fallback 读取)
582
+ // 1. 阶段门禁(使用 fallback 读取)
496
583
  const stage = readCurrentStage(projectRoot) || '(none)'
497
584
 
585
+ const scanGuardResult = shouldBlockScanDocOverwrite(absPath, projectRoot)
586
+ if (scanGuardResult.blocked) return scanGuardResult
587
+
588
+ // 2. 文件门禁:文档类/配置类始终放行,但 worktree 存储区内的源码必须继续走登记校验。
589
+ if (!isInsideWorktreeStorage(absPath, projectRoot) && matchFileWhitelist(absPath)) return { blocked: false }
590
+
498
591
  if (!['execute', 'quick'].includes(stage)) {
499
592
  return {
500
593
  blocked: true,
package/src/index.js CHANGED
@@ -40,7 +40,7 @@ SillySpec CLI — 规范驱动开发工具包
40
40
  auto 连续推进 brainstorm→plan→execute→verify
41
41
 
42
42
  可选阶段:
43
- scan, brainstorm, propose, plan, execute, verify, archive
43
+ scan, brainstorm, plan, execute, verify, archive
44
44
  quick, explore, status, doctor
45
45
 
46
46
  sillyspec progress <cmd> 进度记录(轻量,不强制顺序)
package/src/progress.js CHANGED
@@ -12,16 +12,32 @@
12
12
  */
13
13
 
14
14
  import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, unlinkSync } from 'fs';
15
- import { join, basename } from 'path';
15
+ import { join, basename, dirname, resolve } from 'path';
16
16
  import { DB } from './db.js';
17
17
 
18
18
  // 默认规范目录名(相对于 cwd)
19
19
  const SPEC_DIR_NAME = '.sillyspec';
20
20
  const RUNTIME_SUBDIR = '.runtime';
21
+
22
+ /**
23
+ * 向上查找含 .sillyspec 目录的祖先目录,类似 git 找 .git 的逻辑。
24
+ * 找到则返回 <祖先>/.sillyspec,否则 fallback 到 <cwd>/.sillyspec。
25
+ */
26
+ export function resolveSpecDir(startDir) {
27
+ let dir = resolve(startDir);
28
+ while (true) {
29
+ const candidate = join(dir, SPEC_DIR_NAME);
30
+ if (existsSync(candidate)) return candidate;
31
+ const parent = dirname(dir);
32
+ if (parent === dir) break; // 到达根目录
33
+ dir = parent;
34
+ }
35
+ return join(resolve(startDir), SPEC_DIR_NAME);
36
+ }
21
37
  const CHANGES_SUBDIR = 'changes';
22
38
  const GLOBAL_FILE = 'global.json';
23
39
  const CURRENT_VERSION = 3;
24
- const VALID_STAGES = ['scan', 'brainstorm', 'propose', 'plan', 'execute', 'verify', 'archive', 'quick', 'explore'];
40
+ const VALID_STAGES = ['scan', 'brainstorm', 'plan', 'execute', 'verify', 'archive', 'quick', 'explore'];
25
41
  const VALID_STATUSES = ['pending', 'in-progress', 'completed', 'failed', 'blocked', 'waiting'];
26
42
 
27
43
  const STAGE_LABELS = {
@@ -62,9 +78,10 @@ export class ProgressManager {
62
78
 
63
79
  // ── 路径工具 ──
64
80
 
65
- /** 获取 specDir(优先自定义,否则 cwd/.sillyspec) */
81
+ /** 获取 specDir(优先自定义,否则向上查找含 .sillyspec 的目录,fallback 到 cwd/.sillyspec) */
66
82
  _getSpecDir(cwd) {
67
- return this._customSpecDir || join(cwd, SPEC_DIR_NAME);
83
+ if (this._customSpecDir) return this._customSpecDir;
84
+ return resolveSpecDir(cwd);
68
85
  }
69
86
 
70
87
  _runtimePath(cwd, ...parts) {
@@ -207,21 +224,29 @@ export class ProgressManager {
207
224
  if (stageIds.length > 0) {
208
225
  const placeholders = stageIds.map(() => '?').join(',');
209
226
  stepRows = sqlDb.exec(
210
- `SELECT stage_id, name, status, output, completed_at, ordering, wait_reason, wait_options, wait_answer, waited_at FROM steps WHERE stage_id IN (${placeholders}) ORDER BY stage_id, ordering`,
227
+ `SELECT stage_id, name, status, output, completed_at, ordering, wait_reason, wait_options, wait_answer, waited_at, wait_answers, wait_round, max_wait_rounds FROM steps WHERE stage_id IN (${placeholders}) ORDER BY stage_id, ordering`,
211
228
  stageIds
212
229
  );
213
230
  }
214
231
  // 按阶段分组步骤
215
232
  const stepsByStage = {};
216
233
  if (stepRows && stepRows.length > 0) {
217
- for (const [stageId, name, status, output, completedAt, ordering, waitReason, waitOptions, waitAnswer, waitedAt] of stepRows[0].values) {
234
+ for (const row of stepRows[0].values) {
235
+ const [stageId, name, status, output, completedAt, ordering, waitReason, waitOptions, waitAnswer, waitedAt, waitAnswersJson, waitRound, maxWaitRounds] = row;
218
236
  if (!stepsByStage[stageId]) stepsByStage[stageId] = [];
237
+ let waitAnswers = null;
238
+ if (waitAnswersJson) {
239
+ try { waitAnswers = JSON.parse(waitAnswersJson); } catch {}
240
+ }
219
241
  stepsByStage[stageId].push({
220
242
  name, status, output, completedAt,
221
243
  ...(waitReason ? { waitReason } : {}),
222
244
  ...(waitOptions ? { waitOptions } : {}),
223
245
  ...(waitAnswer ? { waitAnswer } : {}),
224
246
  ...(waitedAt ? { waitedAt } : {}),
247
+ ...(waitAnswers ? { waitAnswers } : {}),
248
+ ...(waitRound != null ? { waitRound } : {}),
249
+ ...(maxWaitRounds != null ? { maxWaitRounds } : {}),
225
250
  });
226
251
  }
227
252
  }
@@ -257,6 +282,9 @@ export class ProgressManager {
257
282
  ...(s.waitOptions ? { waitOptions: s.waitOptions } : {}),
258
283
  ...(s.waitAnswer ? { waitAnswer: s.waitAnswer } : {}),
259
284
  ...(s.waitedAt ? { waitedAt: s.waitedAt } : {}),
285
+ ...(s.waitAnswers ? { waitAnswers: s.waitAnswers } : {}),
286
+ ...(s.waitRound != null ? { waitRound: s.waitRound } : {}),
287
+ ...(s.maxWaitRounds != null ? { maxWaitRounds: s.maxWaitRounds } : {}),
260
288
  }));
261
289
  stages[stage] = {
262
290
  status: info.status,
@@ -340,9 +368,11 @@ export class ProgressManager {
340
368
  // UPSERT 步骤(先删再插,steps 表无 UNIQUE 约束)
341
369
  sqlDb.run('DELETE FROM steps WHERE stage_id = ? AND name = ?', [stageId, step.name]);
342
370
  sqlDb.run(
343
- 'INSERT INTO steps (stage_id, name, status, output, completed_at, ordering, wait_reason, wait_options, wait_answer, waited_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
371
+ 'INSERT INTO steps (stage_id, name, status, output, completed_at, ordering, wait_reason, wait_options, wait_answer, waited_at, wait_answers, wait_round, max_wait_rounds) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
344
372
  [stageId, step.name, step.status || 'pending', step.output || null, step.completedAt || null, i,
345
- step.waitReason || null, step.waitOptions || null, step.waitAnswer || null, step.waitedAt || null]
373
+ step.waitReason ?? null, step.waitOptions ?? null, step.waitAnswer ?? null, step.waitedAt ?? null,
374
+ Array.isArray(step.waitAnswers) ? JSON.stringify(step.waitAnswers) : null,
375
+ step.waitRound ?? null, step.maxWaitRounds ?? null]
346
376
  );
347
377
  }
348
378
  }
@@ -411,11 +441,8 @@ export class ProgressManager {
411
441
  VALUES (?, ?, ?)`,
412
442
  [changeName, now, now]
413
443
  );
414
- // 如果已存在但为 archived,恢复为 active
415
- sqlDb.run(
416
- `UPDATE changes SET status = 'active', last_active = ? WHERE name = ? AND status = 'archived'`,
417
- [now, changeName]
418
- );
444
+ // 注意:不复活已归档的变更——归档是不可逆操作
445
+ // 如果变更已存在且为 archived,保持 archived 状态不变
419
446
  });
420
447
  }
421
448
 
@@ -634,7 +661,7 @@ export class ProgressManager {
634
661
  const changeId = changeRow[0].values[0][0];
635
662
 
636
663
  // 批量插入 9 个阶段(INSERT OR IGNORE 跳过已存在的)
637
- const allStages = ['scan', 'brainstorm', 'plan', 'execute', 'verify', 'archive', 'quick', 'explore', 'propose'];
664
+ const allStages = ['scan', 'brainstorm', 'plan', 'execute', 'verify', 'archive', 'quick', 'explore'];
638
665
  for (const stage of allStages) {
639
666
  sqlDb.run(
640
667
  `INSERT OR IGNORE INTO stages (change_id, stage, status)