sillyspec 3.25.9 → 3.26.0

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.
Files changed (76) hide show
  1. package/.claude/skills/sillyspec-auto/SKILL.md +8 -13
  2. package/.claude/skills/sillyspec-brainstorm/SKILL.md +8 -6
  3. package/.claude/skills/sillyspec-continue/SKILL.md +3 -3
  4. package/.claude/skills/sillyspec-doctor/SKILL.md +1 -1
  5. package/.claude/skills/sillyspec-execute/SKILL.md +10 -4
  6. package/.claude/skills/sillyspec-plan/SKILL.md +1 -1
  7. package/.claude/skills/sillyspec-quick/SKILL.md +3 -1
  8. package/.claude/skills/sillyspec-verify/SKILL.md +1 -1
  9. package/.sillyspec-platform-cleaned +1 -0
  10. package/.sillyspec-platform.json +7 -0
  11. package/README.md +9 -0
  12. package/package.json +2 -2
  13. package/src/change-list.js +11 -8
  14. package/src/change-risk-profile.js +22 -0
  15. package/src/db.js +170 -161
  16. package/src/dispatch/backends/local-agent.js +152 -0
  17. package/src/dispatch/backends/sillyhub-mcp.js +242 -0
  18. package/src/dispatch/probe.js +210 -0
  19. package/src/dispatch/strategy.js +121 -0
  20. package/src/doctor-diagnostics.js +19 -25
  21. package/src/fs-atomic.js +69 -69
  22. package/src/git-helper.js +66 -0
  23. package/src/hooks/worktree-guard.js +64 -55
  24. package/src/index.js +326 -36
  25. package/src/init.js +7 -14
  26. package/src/local-detect.js +120 -110
  27. package/src/machine-interface.js +54 -19
  28. package/src/migrate.js +14 -4
  29. package/src/modules.js +16 -5
  30. package/src/progress/change-registry.js +85 -70
  31. package/src/progress/consistency-doctor.js +71 -14
  32. package/src/progress/shared.js +8 -5
  33. package/src/progress/stage-machine.js +67 -65
  34. package/src/progress/step-store.js +72 -67
  35. package/src/progress.js +448 -236
  36. package/src/quicklog.js +59 -12
  37. package/src/run/command.js +78 -39
  38. package/src/run/complete-handlers.js +1028 -889
  39. package/src/run/complete.js +132 -212
  40. package/src/run/concurrent-detect.js +132 -0
  41. package/src/run/gates.js +302 -19
  42. package/src/run/prompt.js +22 -68
  43. package/src/run/quick-audit.js +10 -9
  44. package/src/run/shared.js +722 -600
  45. package/src/run/stage.js +43 -24
  46. package/src/scan-postcheck.js +9 -31
  47. package/src/sillyhub-mcp/client.js +394 -0
  48. package/src/sillyhub-mcp/config.js +62 -0
  49. package/src/spec-dir-typo.js +71 -0
  50. package/src/stage-contract-engine.js +22 -1
  51. package/src/stage-contract-spec.js +11 -4
  52. package/src/stage-contract.js +26 -3
  53. package/src/stage-review.js +118 -10
  54. package/src/stages/archive.js +4 -6
  55. package/src/stages/brainstorm-auto.js +26 -20
  56. package/src/stages/brainstorm.js +29 -5
  57. package/src/stages/cmd-existence.js +145 -0
  58. package/src/stages/doctor.js +10 -7
  59. package/src/stages/execute.js +64 -22
  60. package/src/stages/plan-postcheck.js +152 -0
  61. package/src/stages/plan.js +16 -31
  62. package/src/stages/quick.js +25 -9
  63. package/src/stages/scan.js +53 -8
  64. package/src/stages/status.js +0 -1
  65. package/src/stages/verify.js +14 -9
  66. package/src/sync.js +433 -16
  67. package/src/task-review.js +59 -1
  68. package/src/verify-postcheck.js +938 -862
  69. package/src/version.js +19 -0
  70. package/src/worktree-apply.js +218 -93
  71. package/src/worktree-deps.js +89 -5
  72. package/src/worktree.js +213 -111
  73. package/templates/prompts/taskcard-rules.md +27 -0
  74. package/templates/prompts/verify-probes.md +3 -3
  75. package/.claude/skills/sillyspec-propose/SKILL.md +0 -87
  76. package/src/stages/propose.js +0 -181
package/src/db.js CHANGED
@@ -1,11 +1,38 @@
1
- import initSqlJs from 'sql.js';
2
- import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'fs';
3
- import { basename, dirname, join } from 'path';
1
+ import Database from 'better-sqlite3';
2
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'fs';
3
+ import { dirname } from 'path';
4
4
 
5
5
  // DB schema 版本(与 project.schema_version DEFAULT 对齐)。_createSchema 改动(加表/列/migration)时 bump,
6
- // 触发 .schema-version 戳失效 → 下次 init 重跑 _createSchema + _save(W4-H:高频读路径 gate/derive/status
7
- // 每次 new ProgressManager 都过 init,原无条件 _createSchema+_save 致每次整库 export+三段原子写)。
8
- const DB_SCHEMA_VERSION = 3;
6
+ // 触发 .schema-version 戳失效 → 下次 init 重跑 _createSchema(W4-H:高频读路径 gate/derive/status
7
+ // 每次 new ProgressManager 都过 init,靠版本戳跳过建表省开销)。
8
+ // better-sqlite3 是原生 SQLite 引擎,打开即持久化(不像 sql.js 纯内存需整库 export 落盘),
9
+ // _createSchema 内 DDL 直接落盘,无需额外 _save。
10
+ const DB_SCHEMA_VERSION = 4;
11
+
12
+ // SQLITE_BUSY 应用层有限重试(R-08 / NFR-03):WAL 单写者模型,并发写第二者在
13
+ // busy_timeout=5000(init PRAGMA)后抛 SQLITE_BUSY。busy_timeout 已在引擎层处理大部分等待;
14
+ // 此处常量给 transaction() 做超时后的应用层兜底重试。
15
+ // 仅 SQLITE_BUSY 重试(其它异常直接上抛);MAX_BUSY_RETRIES 有限次防死循环,达上限 fail-loud
16
+ // 不静默吞错。BUSY_BACKOFF_MS 递增退避(50→100→200ms),总等待上限 350ms。
17
+ // busy_timeout=5000 保持不变(design §7):引擎层 5s 等待已覆盖大部分锁竞争,应用层 3 次退避
18
+ // 足够消化极端并发超时尖刺,无需抬高 busy_timeout(过长会让单次写阻塞拖慢整体吞吐)。
19
+ const MAX_BUSY_RETRIES = 3;
20
+ const BUSY_BACKOFF_MS = [50, 100, 200];
21
+
22
+ /**
23
+ * 同步阻塞 sleep(better-sqlite3 同步 API,BUSY 退避不能用 await)。
24
+ * Atomics.wait 阻塞当前线程不占 CPU;SharedArrayBuffer 不可用的环境(受限 CSP / 旧 Node)
25
+ * 退化为 busy-wait(退避时长 50-200ms 短,CPU 占用可接受)。仅在 transaction() 的 BUSY
26
+ * 重试路径调用。
27
+ */
28
+ function _sleepSync(ms) {
29
+ try {
30
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
31
+ } catch {
32
+ const end = Date.now() + ms;
33
+ while (Date.now() < end) { /* busy-wait fallback */ }
34
+ }
35
+ }
9
36
 
10
37
  export class DB {
11
38
  constructor(dbPath) {
@@ -13,26 +40,36 @@ export class DB {
13
40
  this.db = null;
14
41
  }
15
42
 
16
- async init() {
43
+ /**
44
+ * 同步初始化(better-sqlite3 是同步 API,无 async/await,与 sql.js 的 initSqlJs() 异步加载不同):
45
+ * - new Database(path) 同步打开/创建库文件(better-sqlite3 原生绑定,非 WASM)
46
+ * - PRAGMA:journal_mode=WAL 真生效(better-sqlite3 直连原生 SQLite,WAL 落地;
47
+ * sql.js 是纯内存库,WAL 对它无意义),busy_timeout=5000 让写锁竞争时自动等待,
48
+ * foreign_keys=ON 启用外键级联,synchronous=NORMAL 在 WAL 下兼顾安全与性能
49
+ * - schema 戳检查:内容匹配 DB_SCHEMA_VERSION 则跳过 _createSchema,省建表开销
50
+ * - better-sqlite3 打开即持久化,DDL/事务提交直接落盘,不需要 sql.js 时代的 _save()
51
+ *
52
+ * .bak 损坏回退由 _openWithFallback() 承担(task-04):主库 → .bak → 全新/报错 逐级回退,
53
+ * 语义对齐原 sql.js 的 _loadDatabase。
54
+ */
55
+ init() {
17
56
  // 1. 确保父目录存在
18
57
  const dir = dirname(this.dbPath);
19
58
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
20
59
 
21
- // 2. 初始化 sql.js
22
- const SQL = await initSqlJs();
60
+ // 2. 同步打开/创建数据库(主库 → .bak → 全新/报错 逐级回退,归 task-04)
61
+ this.db = this._openWithFallback();
23
62
 
24
- // 3. 加载已有数据库(含损坏检测 + .bak 回滚)或创建新库
25
- this.db = this._loadDatabase(SQL);
63
+ // 3. 设置 PRAGMA(better-sqlite3 走真 SQLite,journal_mode=WAL 真生效)
64
+ this.db.pragma('journal_mode = WAL');
65
+ this.db.pragma('busy_timeout = 5000');
66
+ this.db.pragma('foreign_keys = ON');
67
+ this.db.pragma('synchronous = NORMAL');
26
68
 
27
- // 4. 设置 PRAGMA
28
- this.db.run('PRAGMA journal_mode = WAL');
29
- this.db.run('PRAGMA busy_timeout = 5000');
30
- this.db.run('PRAGMA foreign_keys = ON');
31
- this.db.run('PRAGMA synchronous = NORMAL');
32
-
33
- // 5. 创建表结构(仅当 schema 版本戳不匹配时——W4-H):
34
- // schema 已最新则只 load 不写,省掉 _createSchema + _save 的整库 export + 三段原子写。
35
- // 戳在但 db 缺表属"手动改 db"边角(SillySpec 不支持),_loadDatabase 的损坏检测兜底正常路径。
69
+ // 4. 创建表结构(仅当 schema 版本戳不匹配时——W4-H):
70
+ // schema 已最新则跳过建表,省 DDL 开销。better-sqlite3 打开即持久化,
71
+ // _createSchema 内 DDL 直接落盘,无需 _save。
72
+ // 戳在但 db 缺表属"手动改 db"边角(SillySpec 不支持),不在本层兜底。
36
73
  const versionPath = `${this.dbPath}.schema-version`;
37
74
  let schemaCurrent = false;
38
75
  try {
@@ -42,172 +79,135 @@ export class DB {
42
79
 
43
80
  if (!schemaCurrent) {
44
81
  this._createSchema();
45
- this._save();
46
82
  try { writeFileSync(versionPath, String(DB_SCHEMA_VERSION)); } catch { /* 戳写入失败不阻断,下次重跑 */ }
47
83
  }
48
84
  }
49
85
 
50
- close() {
51
- if (this.db) {
52
- this._save();
53
- this.db.close();
54
- this.db = null;
86
+ /**
87
+ * 打开数据库(主库 → .bak → 全新/报错 逐级回退)。
88
+ * 语义对齐原 sql.js 的 _loadDatabase,但 better-sqlite3 绑定文件路径无 _save,
89
+ * .bak 回退时 copyFileSync 把 .bak 内容恢复到主库路径,统一用 dbPath。
90
+ * - 主库存在且有内容且可打开:直接用。
91
+ * - 主库缺失/为空/损坏:尝试 .bak;成功则 warn 原因后 copy 回主库使用。
92
+ * - 都不可用:主库不存在→建全新空库;主库曾存在(空/损坏)→fail-loud 抛错(不静默建空库吞进度)。
93
+ * better-sqlite3 打开 0 字节文件不抛错:主库须 statSync 显式判空(截断信号→走回退,防静默吞进度);
94
+ * .bak 作为恢复源只要存在且能打开即用(含 0 字节空库——better-sqlite3 视其为合法空库,
95
+ * 生产环境 .bak 由原子写改名上一完整主库得来,永不为 0 字节,0 字节态仅出现在测试构造)。
96
+ */
97
+ _openWithFallback() {
98
+ const bakPath = `${this.dbPath}.bak`;
99
+ // readValid: null=文件不存在;'empty'=存在但 0 字节;'ok'=有内容
100
+ const readValid = (p) => {
101
+ if (!existsSync(p)) return null;
102
+ return statSync(p).size === 0 ? 'empty' : 'ok';
103
+ };
104
+ // tryOpen: new Database(path) + prepare 探测双保险(防打开成功但内容非 SQLite)
105
+ const tryOpen = (p) => {
106
+ let db;
107
+ try { db = new Database(p); }
108
+ catch { return null; }
109
+ try { db.prepare('SELECT count(*) FROM sqlite_master').get(); return db; }
110
+ catch { try { db.close(); } catch { /* 关闭失败忽略 */ } return null; }
111
+ };
112
+
113
+ // 1. 主库(0 字节=截断信号,必须走回退,故门禁要求 'ok' 有内容)
114
+ if (readValid(this.dbPath) === 'ok') {
115
+ const primaryDb = tryOpen(this.dbPath);
116
+ if (primaryDb) return primaryDb;
55
117
  }
56
- }
57
118
 
58
- transaction(fn) {
59
- if (!this.db) throw new Error('DB not initialized');
60
- this.db.run('BEGIN');
61
- let result;
62
- try {
63
- result = fn(this.db);
64
- this.db.run('COMMIT');
65
- } catch (err) {
66
- // fn 抛错或 COMMIT 失败:事务级回滚(此时事务确实活跃,ROLLBACK 有效)
67
- try { this.db.run('ROLLBACK'); } catch { /* 事务可能已被 fn 内的错自动回滚 */ }
68
- throw err;
119
+ // 2. .bak 回退(.bak 是恢复源:存在且能打开即用,含 0 字节空库;门禁只挡"不存在",
120
+ // 避免 tryOpen 对不存在的路径触发 new Database 的副作用——会凭空创建文件)
121
+ if (readValid(bakPath) !== null) {
122
+ const bakDb = tryOpen(bakPath);
123
+ if (bakDb) {
124
+ const primaryRaw = readValid(this.dbPath);
125
+ const reason = primaryRaw === 'empty'
126
+ ? 'sillyspec.db 为空(可能被截断)'
127
+ : primaryRaw === null
128
+ ? 'sillyspec.db 不存在'
129
+ : 'sillyspec.db 损坏';
130
+ console.warn(`⚠️ ${reason},已从 .bak 备份恢复。`);
131
+ bakDb.close();
132
+ // better-sqlite3 绑定路径,无 _save:把 .bak 内容 copy 回主库,统一用 dbPath
133
+ copyFileSync(bakPath, this.dbPath);
134
+ return new Database(this.dbPath);
135
+ }
69
136
  }
70
- // COMMIT 成功后才持久化。_save 失败时抛原生错(磁盘满/EPERM),不再被 catch 里
71
- // 无效的 ROLLBACK 二级异常("no transaction active")掩盖——内存库已 commit,
72
- // 下次 _save / close() 会落盘,调用方拿到的是准确的持久化错误而非被替换的误报。
73
- this._save();
74
- return result;
75
- }
76
137
 
77
- /** 获取底层 db 对象(供 progress.js 直接使用) */
78
- getDb() {
79
- return this.db;
138
+ // 3. 都不可用
139
+ if (readValid(this.dbPath) === null) {
140
+ // 主库与备份都不存在 → 全新项目,建空库
141
+ return new Database(this.dbPath);
142
+ }
143
+ // 主库曾存在(空/损坏)但无法恢复 → 数据丢失,必须 fail-loud(不静默建空库吞进度)
144
+ throw new Error('sillyspec.db 损坏且 .bak 备份不可用,无法恢复。请从版本控制或其他备份恢复 .sillyspec/.runtime/ 后重试。');
80
145
  }
81
146
 
82
147
  /**
83
- * 将内存中的数据库持久化到磁盘(原子写 + 保留上一版 .bak 备份)。
84
- * 任何时刻磁盘上的主库要么是完整的上一版、要么是完整的新版,
85
- * 不会出现写入中途崩溃导致的截断态。sql.js 是纯内存库,
86
- * PRAGMA journal_mode=WAL 对它无意义——真正的持久化原子性在这里保证。
148
+ * 关闭数据库。better-sqlite3 的 close() 自动做 WAL checkpoint(把 -wal/-shm 日志合并回主库),
149
+ * 不需要 sql.js 时代的 _save() 整库 export 落盘。
87
150
  */
88
- _save() {
89
- if (!this.db) return;
90
- const data = this.db.export();
91
- const buffer = Buffer.from(data);
92
- this._atomicWriteSync(this.dbPath, buffer);
93
- // sql.js 的 export() 会重置 PRAGMA 状态,需要重新设置
94
- this.db.run('PRAGMA journal_mode = WAL');
95
- this.db.run('PRAGMA busy_timeout = 5000');
96
- this.db.run('PRAGMA foreign_keys = ON');
97
- this.db.run('PRAGMA synchronous = NORMAL');
151
+ close() {
152
+ if (this.db) {
153
+ this.db.close();
154
+ this.db = null;
155
+ }
98
156
  }
99
157
 
100
158
  /**
101
- * 原子落盘:同目录 tmp 写入 → 旧主库改名 .bak → tmp 改名为主库。
102
- * 三步保证主库永不处于半写状态;.bak 始终保留上一完整版本。
103
- * Windows 上 rename 偶发 EPERM/EBUSY(杀毒/索引扫描占文件),带短退避重试。
159
+ * 原生事务 + SQLITE_BUSY 应用层有限重试(R-08 / NFR-03)。
160
+ *
161
+ * better-sqlite3 的 db.transaction(fn) 返回一个包装函数,调用时自动
162
+ * BEGIN/COMMIT/ROLLBACK(fn 抛错自动 ROLLBACK 且不吞错),嵌套调用自动用 SAVEPOINT。
163
+ * 无需手动 BEGIN/COMMIT/ROLLBACK,也无需 _save(better-sqlite3 提交即持久化)。
164
+ *
165
+ * SQLITE_BUSY 重试:WAL 单写者模型,并发写第二者在 busy_timeout=5000 后抛 BUSY。此处对
166
+ * BUSY 加 MAX_BUSY_RETRIES 次递增退避(BUSY_BACKOFF_MS);仅 BUSY 重试(其它异常直接上抛),
167
+ * 达上限 fail-loud 抛错不静默吞(防死循环)。BUSY 重试收敛在 DB 层 transaction 封装,
168
+ * 所有写者(progress.js _write / initChange / setStage 等)统一受益,不散落调用点。
104
169
  */
105
- _atomicWriteSync(filePath, buffer) {
106
- const dir = dirname(filePath);
107
- const tmpPath = join(dir, `${basename(filePath)}.tmp`);
108
- const bakPath = `${filePath}.bak`;
109
- // 1. 完整内容先落 tmp(同目录,保证 rename 不跨卷)
110
- writeFileSync(tmpPath, buffer);
111
- // 2. 旧主库 → .bak(保留上一版;不存在则跳过;失败不阻断主流程)
112
- if (existsSync(filePath)) {
113
- try { this._renameSyncRetry(filePath, bakPath); }
114
- catch { /* 备份失败最坏只是少一个 .bak,主库仍会被新内容替换 */ }
115
- }
116
- // 3. tmp → 主库(成功后主库即新版完整态)
117
- this._renameSyncRetry(tmpPath, filePath);
118
- }
119
-
120
- /** rename 带退避重试,覆盖 Windows 偶发的 EPERM/EBUSY/EACCES/ENOTEMPTY。 */
121
- _renameSyncRetry(from, to, retries = 5) {
122
- let lastErr;
123
- for (let attempt = 0; attempt <= retries; attempt++) {
170
+ transaction(fn) {
171
+ if (!this.db) throw new Error('DB not initialized');
172
+ const tx = this.db.transaction(fn);
173
+ let attempt = 0;
174
+ while (true) {
124
175
  try {
125
- renameSync(from, to);
126
- return;
176
+ return tx();
127
177
  } catch (err) {
128
- lastErr = err;
129
- const code = err && err.code;
130
- if (!code || !['EPERM', 'EBUSY', 'EACCES', 'ENOTEMPTY'].includes(code)) throw err;
131
- if (attempt < retries) this._sleepSync(15 * (attempt + 1));
178
+ // 仅 SQLITE_BUSY 重试(better-sqlite3 err.code 为 'SQLITE_BUSY'/5,message 含 'database is locked')
179
+ const isBusy = err && (err.code === 'SQLITE_BUSY' || err.code === 5
180
+ || (typeof err.message === 'string' && /database is locked|SQLITE_BUSY/i.test(err.message)));
181
+ if (!isBusy) throw err;
182
+ if (attempt >= MAX_BUSY_RETRIES) throw err; // 达上限 fail-loud(不静默吞,防死循环)
183
+ _sleepSync(BUSY_BACKOFF_MS[attempt]);
184
+ attempt++;
132
185
  }
133
186
  }
134
- throw lastErr;
135
187
  }
136
188
 
137
- /** 同步退避:优先 Atomics.wait,不可用时退化为忙等(仅在 rename 冲突的极端情况触发)。 */
138
- _sleepSync(ms) {
139
- try {
140
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
141
- } catch {
142
- const end = Date.now() + ms;
143
- while (Date.now() < end) { /* busy wait */ }
144
- }
145
- }
146
-
147
- /**
148
- * 加载数据库:主库 → .bak → 报错/空库,逐级回退。
149
- * - 主库可正常打开:直接用。
150
- * - 主库缺失/为空/损坏:尝试 .bak;成功则 warn 后用 .bak。
151
- * - 主库与 .bak 都不可用:
152
- * · 全新项目(两者都不存在):创建空库;
153
- * · 主库曾存在却无法恢复(截断/损坏且无可用备份):fail-loud 抛错,
154
- * 不静默建空库——那会无声吞掉用户所有进度。
155
- */
156
- _loadDatabase(SQL) {
157
- const bakPath = `${this.dbPath}.bak`;
158
- // readValid: null=文件不存在;'empty'=存在但 0 字节;Buffer=有内容
159
- const readValid = (p) => {
160
- if (!existsSync(p)) return null;
161
- const b = readFileSync(p);
162
- return (b && b.length > 0) ? b : 'empty';
163
- };
164
- const tryOpen = (raw) => {
165
- if (!raw || raw === 'empty') return null;
166
- let instance;
167
- try { instance = new SQL.Database(raw); }
168
- catch { return null; }
169
- // 双保险:损坏 buffer 偶尔被 sql.js 静默打开为空库,用一次查询确认它真能用
170
- try { instance.exec('SELECT count(*) FROM sqlite_master'); return instance; }
171
- catch { try { instance.close(); } catch {} return null; }
172
- };
173
-
174
- const primaryRaw = readValid(this.dbPath);
175
- const primaryDb = tryOpen(primaryRaw);
176
- if (primaryDb) return primaryDb;
177
-
178
- const bakDb = tryOpen(readValid(bakPath));
179
- if (bakDb) {
180
- const reason = primaryRaw === 'empty'
181
- ? 'sillyspec.db 为空(可能被截断)'
182
- : primaryRaw === null
183
- ? 'sillyspec.db 不存在'
184
- : 'sillyspec.db 损坏';
185
- console.warn(`⚠️ ${reason},已从 .bak 备份恢复。`);
186
- return bakDb;
187
- }
188
-
189
- if (primaryRaw === null) {
190
- // 主库与备份都不存在 → 全新项目
191
- return new SQL.Database();
192
- }
193
- // 主库曾存在(空/损坏)但无法恢复 → 数据丢失,必须 fail-loud
194
- throw new Error('sillyspec.db 损坏且 .bak 备份不可用,无法恢复。请从版本控制或其他备份恢复 .sillyspec/.runtime/ 后重试。');
189
+ /** 获取底层 db 对象(better-sqlite3 Database 实例,供 progress.js 直接使用) */
190
+ getDb() {
191
+ return this.db;
195
192
  }
196
193
 
197
194
  _createSchema() {
195
+ // better-sqlite3:exec() 执行多条 SQL/DDL(无返回);prepare().run/get/all 用于 DML。
196
+ // sql.js 的 .run(sql) 在此换为 .exec(sql)。
197
+
198
198
  // project 表
199
- this.db.run(`
199
+ this.db.exec(`
200
200
  CREATE TABLE IF NOT EXISTS project (
201
201
  id INTEGER PRIMARY KEY DEFAULT 1,
202
202
  name TEXT NOT NULL,
203
- schema_version INTEGER DEFAULT 3,
203
+ schema_version INTEGER DEFAULT 4,
204
204
  created_at TEXT NOT NULL,
205
205
  updated_at TEXT NOT NULL
206
206
  )
207
207
  `);
208
208
 
209
209
  // changes 表
210
- this.db.run(`
210
+ this.db.exec(`
211
211
  CREATE TABLE IF NOT EXISTS changes (
212
212
  id INTEGER PRIMARY KEY AUTOINCREMENT,
213
213
  name TEXT UNIQUE NOT NULL,
@@ -224,7 +224,7 @@ export class DB {
224
224
  `);
225
225
 
226
226
  // stages 表
227
- this.db.run(`
227
+ this.db.exec(`
228
228
  CREATE TABLE IF NOT EXISTS stages (
229
229
  id INTEGER PRIMARY KEY AUTOINCREMENT,
230
230
  change_id INTEGER NOT NULL REFERENCES changes(id) ON DELETE CASCADE,
@@ -237,7 +237,7 @@ export class DB {
237
237
  `);
238
238
 
239
239
  // steps 表
240
- this.db.run(`
240
+ this.db.exec(`
241
241
  CREATE TABLE IF NOT EXISTS steps (
242
242
  id INTEGER PRIMARY KEY AUTOINCREMENT,
243
243
  stage_id INTEGER NOT NULL REFERENCES stages(id) ON DELETE CASCADE,
@@ -250,7 +250,7 @@ export class DB {
250
250
  `);
251
251
 
252
252
  // batch_progress 表
253
- this.db.run(`
253
+ this.db.exec(`
254
254
  CREATE TABLE IF NOT EXISTS batch_progress (
255
255
  id INTEGER PRIMARY KEY AUTOINCREMENT,
256
256
  change_id INTEGER NOT NULL REFERENCES changes(id) ON DELETE CASCADE,
@@ -263,7 +263,7 @@ export class DB {
263
263
  `);
264
264
 
265
265
  // approvals 表
266
- this.db.run(`
266
+ this.db.exec(`
267
267
  CREATE TABLE IF NOT EXISTS approvals (
268
268
  id INTEGER PRIMARY KEY AUTOINCREMENT,
269
269
  change_id INTEGER NOT NULL REFERENCES changes(id) ON DELETE CASCADE,
@@ -277,10 +277,10 @@ export class DB {
277
277
  `);
278
278
 
279
279
  // 索引
280
- this.db.run('CREATE INDEX IF NOT EXISTS idx_changes_current_stage ON changes(current_stage)');
281
- this.db.run('CREATE INDEX IF NOT EXISTS idx_changes_status ON changes(status)');
282
- this.db.run('CREATE INDEX IF NOT EXISTS idx_stages_change ON stages(change_id)');
283
- this.db.run('CREATE INDEX IF NOT EXISTS idx_steps_stage ON steps(stage_id)');
280
+ this.db.exec('CREATE INDEX IF NOT EXISTS idx_changes_current_stage ON changes(current_stage)');
281
+ this.db.exec('CREATE INDEX IF NOT EXISTS idx_changes_status ON changes(status)');
282
+ this.db.exec('CREATE INDEX IF NOT EXISTS idx_stages_change ON stages(change_id)');
283
+ this.db.exec('CREATE INDEX IF NOT EXISTS idx_steps_stage ON steps(stage_id)');
284
284
 
285
285
  // Migration: add isolation columns to changes table (idempotent)
286
286
  this._migrateAddColumn('changes', 'isolation_status', 'TEXT');
@@ -302,15 +302,24 @@ export class DB {
302
302
  this._migrateAddColumn('stages', 'reopened_from_step', 'TEXT');
303
303
  this._migrateAddColumn('stages', 'reopened_at', 'TEXT');
304
304
  this._migrateAddColumn('stages', 'stale_reason', 'TEXT');
305
+
306
+ // Platform sync support(2026-08-10-platform-progress-sync §8 / D-012):
307
+ // changes 表加 base_ts(last_synced_platform_ts)与本地脏度(last_local_modified_ts)两列,
308
+ // 为 serializeForSync()/import() 序列化与 base_ts 乐观锁冲突检测提供数据载体。
309
+ // NULL 语义(gap8):last_local_modified_ts NULL=本地无脏度(pull 不判冲突直接 import);
310
+ // last_synced_platform_ts NULL=首次同步(base_ts NULL 平台接受首次 push)。
311
+ this._migrateAddColumn('changes', 'last_synced_platform_ts', 'TEXT');
312
+ this._migrateAddColumn('changes', 'last_local_modified_ts', 'TEXT');
305
313
  }
306
314
 
307
315
  /**
308
- * 幂等地给表添加列(列已存在则跳过)
316
+ * 幂等地给表添加列(列已存在则跳过)。
317
+ * better-sqlite3:exec() 执行 ALTER TABLE DDL;列已存在抛错被 catch 静默跳过。
309
318
  * @private
310
319
  */
311
320
  _migrateAddColumn(table, column, type) {
312
321
  try {
313
- this.db.run(`ALTER TABLE ${table} ADD COLUMN ${column} ${type}`);
322
+ this.db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${type}`);
314
323
  } catch {
315
324
  // 列已存在,静默跳过
316
325
  }
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Local 后端派发指令模板(task-dispatcher 抽象层)— task-03
3
+ *
4
+ * 定位(D-007):dispatcher **不是 JS 执行体**。本机 Agent tool(Claude Code Task 工具)
5
+ * 只有 agent 能调,CLI(Node)进程调不了。所以本文件 = **派发指令模板生成器**:
6
+ * 生成注入 execute prompt 的「派发指令文本」,告诉 agent 如何用本机 Agent tool 启动
7
+ * 子代理;实际 Agent tool 调用由 agent 执行,本模块不调任何 tool。
8
+ *
9
+ * Local 后端 = **默认与降级路径**(D-005),保留现有 buildWavePrompt(src/stages/execute.js)
10
+ * 的本机 Agent tool 派发行为:每任务一个独立子代理、workdir 强制 = worktreePath、
11
+ * worker 终态后 SillySpec 对 worktree 工作区 git diff 写 review.json(复用既有
12
+ * task-review 契约)。SillyHub MCP 不可用 / 未配置 / 路径 A 未落地时全程走此路径,零回归。
13
+ *
14
+ * Local 后端**忽略** contract 中的 modelHint / agentProfileHint:本机 Agent tool 不做
15
+ * 异构模型 / agent 分配,这两字段仅 SillyHub 后端消费。
16
+ */
17
+
18
+ /**
19
+ * Local 后端结果回收约定(worker 终态 → review.json)。
20
+ *
21
+ * 抽成独立常量供 strategy.js 在拼接「兜底 / 降级 / SillyHub 回收」段时复用,避免 Local
22
+ * 与 SillyHub 两份回收文案重复维护(R-07:回收统一走 review.json,屏蔽后端差异)。
23
+ *
24
+ * 含 `<runId>` 占位符,使用方按实际 contract.runId 替换(见 renderLocalInstruction)。
25
+ */
26
+ export const LOCAL_RECYCLE_RULE = `### 回收约定(worker 终态 → review.json)
27
+
28
+ worker 子代理结束后,**回收不依赖 worker 自报结果,也不依赖 worker git commit**:
29
+ SillySpec 主体(你,调度 agent)对 worktree **工作区**做 git diff,按既有 task-review
30
+ 契约写 review.json。
31
+
32
+ 1. 读取该 task 在 worktree 工作区的 git diff(task 开始 → 完成的变更)
33
+ 2. 对照 plan.md 与 tasks/task-XX.md 检查实现(不信任 implementer 自报,只看当前 task 的 diff)
34
+ 3. 写入 review.json:\`.runtime/execute-runs/<runId>/tasks/task-XX/review.json\`
35
+ 4. review.json 写入成功后,该 task 的派发回收完成
36
+
37
+ review.json 复用既有 task-review 契约(必填字段:schemaVersion / task / base / head /
38
+ changedFiles / specVerdict / qualityVerdict / reviewerNotes / requiredEvidence),不发明
39
+ 新契约。回收屏蔽后端差异:无论 Local 还是 SillyHub worker,SillySpec 都自己 diff worktree
40
+ 工作区写 review.json。`
41
+
42
+ /**
43
+ * @typedef {object} DispatchContract
44
+ * @property {string} brief - 任务目标 + 蓝图路径(注入子代理 prompt)
45
+ * @property {string} worktreePath - SillySpec worktree 绝对路径(子代理 cwd)
46
+ * @property {string} branch - worktree 当前分支
47
+ * @property {string[]} allowedPaths - task 边界(子代理 prompt 里声明,SillySpec 侧 assess/apply 校验)
48
+ * @property {boolean} readOnly - execute 写模式=false;true=只读 QA(仅读/diff/写 review.json)
49
+ * @property {string} [modelHint] - tasks.md [model:xxx](Local 忽略,仅 SillyHub 消费)
50
+ * @property {string} [agentProfileHint] - agent profile(Local 忽略,仅 SillyHub 消费)
51
+ * @property {string} runId - SillySpec execute-run id(关联 review.json 路径)
52
+ * @property {string} [missionId] - Wave 级 mission id(D-008,SillyHub 专属,Local 忽略)
53
+ */
54
+
55
+ /**
56
+ * 渲染 Local 后端派发指令文本。
57
+ *
58
+ * 纯模板生成器:不校验 contract 完整性、不调任何 tool,只把 contract 字段拼成可注入
59
+ * execute prompt 的中文指令字符串。contract 缺字段时回退到醒目占位符(`<未提供 ...>`),
60
+ * 让 agent 在 prompt 里一眼看到派发前必须补齐的参数,而非静默产出错位指令。
61
+ *
62
+ * @param {DispatchContract} [contract] - 派发契约(见 design.md DispatchContract 接口定义)
63
+ * @returns {string} 注入 execute prompt 的 Local 后端派发指令文本
64
+ */
65
+ export function renderLocalInstruction(contract) {
66
+ const c = contract || {}
67
+ const worktreePath = c.worktreePath
68
+ const branch = c.branch
69
+ const allowedPaths = Array.isArray(c.allowedPaths) ? c.allowedPaths : null
70
+ const readOnly = !!c.readOnly
71
+ const brief = c.brief
72
+ const runId = c.runId
73
+
74
+ // 回收约定:替换 <runId> 占位符;runId 缺失保留占位符让 agent 警觉
75
+ const recycleRule = LOCAL_RECYCLE_RULE.replaceAll(
76
+ '<runId>',
77
+ runId ? String(runId) : '<未提供 runId>'
78
+ )
79
+
80
+ const workdirDisplay = worktreePath
81
+ ? String(worktreePath)
82
+ : '<未提供 contract.worktreePath — 派发前必须补齐,否则破坏 worktree 隔离>'
83
+
84
+ const branchLine = branch
85
+ ? `- worktree 当前分支:\`${branch}\``
86
+ : '- worktree 当前分支:未提供(沿用 worktree 检出时的既有分支)'
87
+
88
+ const allowedLine = allowedPaths && allowedPaths.length
89
+ ? `- 本次任务边界(allowedPaths):${allowedPaths.map(p => `\`${p}\``).join(' / ')}`
90
+ : '- 本次任务边界(allowedPaths):未提供,按蓝图 tasks/task-XX.md 的 allowed_paths 为准'
91
+
92
+ const briefLine = brief
93
+ ? String(brief)
94
+ : '<未提供 contract.brief — 派发前必须补齐任务目标与蓝图路径>'
95
+
96
+ const modeLine = readOnly
97
+ ? '5. **只读模式(readOnly=true)**:本子代理只读 / 审查 / 写 review.json,**不改源码**'
98
+ : '5. **写模式(readOnly=false)**:按蓝图实现源码(TDD:先读后写 → 写测试 → 写实现)'
99
+
100
+ return `### 派发后端:Local(本机 Agent tool)— 默认与降级路径
101
+
102
+ 本指令用**本机 Agent tool**(Claude Code Task 工具)启动子代理执行任务。这是默认与降级
103
+ 路径:SillyHub MCP 不可用 / 未配置 / 路径 A 未落地时全程走此路径,派发行为与既有 execute
104
+ 波次派发等价(零回归)。
105
+
106
+ **你的角色是调度者 + 审查者,不要自己写代码。** 每个任务由一个独立子代理执行,同 Wave 内
107
+ 可并行启动多个子代理。
108
+
109
+ ### 启动子代理(必须严格遵守)
110
+
111
+ 调用本机 Agent tool(Task 工具)为该 task 启动一个独立子代理,参数结构范例:
112
+
113
+ \`\`\`json
114
+ {
115
+ "subagent_type": "general",
116
+ "workdir": "${workdirDisplay}",
117
+ "prompt": "在此编写任务描述:任务目标 + 蓝图路径 + 编码铁律(见下方要点)"
118
+ }
119
+ \`\`\`
120
+
121
+ ### workdir 强制必传 = contract.worktreePath(关键)
122
+
123
+ **workdir 参数是强制必传的,值必须为 \`${workdirDisplay}\`(contract.worktreePath)。**
124
+
125
+ - **不传 workdir,子代理会在主工作区(主仓库根)而非 worktree 里读写文件**,把改动写到
126
+ 主工作区、**破坏 worktree 隔离**,后续 assess / apply / review.json 全部错位,并可能
127
+ 撞其他并行 agent 的改动。
128
+ ${branchLine}
129
+ - 蓝图文件(tasks.md / design.md / tasks/task-XX.md)在主工作区 \`.sillyspec/changes/<change>/\`
130
+ 下,可能不在 worktree 中——**读蓝图用主工作区绝对路径,不要拼接到 worktree 路径下**;
131
+ 写代码必须落在 workdir(worktree)内。
132
+
133
+ ### 子代理 prompt 要点
134
+
135
+ 在子代理 prompt 里写明:
136
+ 1. 任务目标(brief):${briefLine}
137
+ 2. 蓝图文件路径(让子代理按需读取详情,不要内联整份蓝图污染上下文)
138
+ 3. 编码铁律:先读后写、TDD、不编造方法、只做蓝图里写的事、遵守边界处理规则、不超出 allowedPaths
139
+ 4. 如存在模块文档(\`.sillyspec/docs/*/modules/\`),按需读取涉及模块的 <module>.md 参考接口约定与数据流
140
+ ${modeLine}
141
+ ${allowedLine}
142
+
143
+ ### Local 后端忽略的字段(不消费)
144
+
145
+ 本机 Agent tool 不做异构模型 / agent 分配,故 Local 后端**忽略** contract 中的以下字段
146
+ (仅 SillyHub 后端消费,此处列出仅为避免 agent 误把它们塞进 Agent tool 参数):
147
+ - \`modelHint\` → SillyHub model 映射,Local 不传给本机 Agent tool
148
+ - \`agentProfileHint\` → SillyHub agent_profile_id 映射,Local 不传给本机 Agent tool
149
+ - \`missionId\` → SillyHub「一 Wave 一 mission」概念(D-008),Local 无 mission
150
+
151
+ ${recycleRule}`
152
+ }