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.
package/src/db.js CHANGED
@@ -1,13 +1,13 @@
1
- import Database from 'better-sqlite3';
1
+ import { openDatabase, applyPragmas, runTransaction } from './db-engine.js';
2
2
  import { copyFileSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'fs';
3
3
  import { dirname } from 'path';
4
4
 
5
5
  // DB schema 版本(与 project.schema_version DEFAULT 对齐)。_createSchema 改动(加表/列/migration)时 bump,
6
6
  // 触发 .schema-version 戳失效 → 下次 init 重跑 _createSchema(W4-H:高频读路径 gate/derive/status
7
7
  // 每次 new ProgressManager 都过 init,靠版本戳跳过建表省开销)。
8
- // better-sqlite3 是原生 SQLite 引擎,打开即持久化(不像 sql.js 纯内存需整库 export 落盘),
8
+ // node:sqlite(DatabaseSync)是原生 SQLite 引擎,打开即持久化(不像 sql.js 纯内存需整库 export 落盘),
9
9
  // _createSchema 内 DDL 直接落盘,无需额外 _save。
10
- const DB_SCHEMA_VERSION = 4;
10
+ const DB_SCHEMA_VERSION = 5;
11
11
 
12
12
  // SQLITE_BUSY 应用层有限重试(R-08 / NFR-03):WAL 单写者模型,并发写第二者在
13
13
  // busy_timeout=5000(init PRAGMA)后抛 SQLITE_BUSY。busy_timeout 已在引擎层处理大部分等待;
@@ -20,7 +20,7 @@ const MAX_BUSY_RETRIES = 3;
20
20
  const BUSY_BACKOFF_MS = [50, 100, 200];
21
21
 
22
22
  /**
23
- * 同步阻塞 sleep(better-sqlite3 同步 API,BUSY 退避不能用 await)。
23
+ * 同步阻塞 sleep(node:sqlite 同步 API,BUSY 退避不能用 await)。
24
24
  * Atomics.wait 阻塞当前线程不占 CPU;SharedArrayBuffer 不可用的环境(受限 CSP / 旧 Node)
25
25
  * 退化为 busy-wait(退避时长 50-200ms 短,CPU 占用可接受)。仅在 transaction() 的 BUSY
26
26
  * 重试路径调用。
@@ -41,13 +41,13 @@ export class DB {
41
41
  }
42
42
 
43
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 落地;
44
+ * 同步初始化(node:sqlite DatabaseSync 是同步 API,无 async/await,与 sql.js 的 initSqlJs() 异步加载不同):
45
+ * - openDatabase(path) 同步打开/创建库文件(node:sqlite 原生绑定,非 WASM)
46
+ * - PRAGMA:journal_mode=WAL 真生效(node:sqlite 直连原生 SQLite,WAL 落地;
47
47
  * sql.js 是纯内存库,WAL 对它无意义),busy_timeout=5000 让写锁竞争时自动等待,
48
48
  * foreign_keys=ON 启用外键级联,synchronous=NORMAL 在 WAL 下兼顾安全与性能
49
49
  * - schema 戳检查:内容匹配 DB_SCHEMA_VERSION 则跳过 _createSchema,省建表开销
50
- * - better-sqlite3 打开即持久化,DDL/事务提交直接落盘,不需要 sql.js 时代的 _save()
50
+ * - node:sqlite 打开即持久化,DDL/事务提交直接落盘,不需要 sql.js 时代的 _save()
51
51
  *
52
52
  * .bak 损坏回退由 _openWithFallback() 承担(task-04):主库 → .bak → 全新/报错 逐级回退,
53
53
  * 语义对齐原 sql.js 的 _loadDatabase。
@@ -60,14 +60,16 @@ export class DB {
60
60
  // 2. 同步打开/创建数据库(主库 → .bak → 全新/报错 逐级回退,归 task-04)
61
61
  this.db = this._openWithFallback();
62
62
 
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');
63
+ // 3. 设置 PRAGMA(node:sqlite 走真 SQLite,journal_mode=WAL 真生效;经 applyPragmas 逐条 exec)
64
+ applyPragmas(this.db, [
65
+ ['journal_mode', 'WAL'],
66
+ ['busy_timeout', '5000'],
67
+ ['foreign_keys', 'ON'],
68
+ ['synchronous', 'NORMAL'],
69
+ ]);
68
70
 
69
71
  // 4. 创建表结构(仅当 schema 版本戳不匹配时——W4-H):
70
- // schema 已最新则跳过建表,省 DDL 开销。better-sqlite3 打开即持久化,
72
+ // schema 已最新则跳过建表,省 DDL 开销。node:sqlite 打开即持久化,
71
73
  // _createSchema 内 DDL 直接落盘,无需 _save。
72
74
  // 戳在但 db 缺表属"手动改 db"边角(SillySpec 不支持),不在本层兜底。
73
75
  const versionPath = `${this.dbPath}.schema-version`;
@@ -85,13 +87,13 @@ export class DB {
85
87
 
86
88
  /**
87
89
  * 打开数据库(主库 → .bak → 全新/报错 逐级回退)。
88
- * 语义对齐原 sql.js 的 _loadDatabase,但 better-sqlite3 绑定文件路径无 _save,
90
+ * 语义对齐原 sql.js 的 _loadDatabase,但 node:sqlite 绑定文件路径无 _save,
89
91
  * .bak 回退时 copyFileSync 把 .bak 内容恢复到主库路径,统一用 dbPath。
90
92
  * - 主库存在且有内容且可打开:直接用。
91
93
  * - 主库缺失/为空/损坏:尝试 .bak;成功则 warn 原因后 copy 回主库使用。
92
94
  * - 都不可用:主库不存在→建全新空库;主库曾存在(空/损坏)→fail-loud 抛错(不静默建空库吞进度)。
93
- * better-sqlite3 打开 0 字节文件不抛错:主库须 statSync 显式判空(截断信号→走回退,防静默吞进度);
94
- * .bak 作为恢复源只要存在且能打开即用(含 0 字节空库——better-sqlite3 视其为合法空库,
95
+ * node:sqlite 打开 0 字节文件不抛错:主库须 statSync 显式判空(截断信号→走回退,防静默吞进度);
96
+ * .bak 作为恢复源只要存在且能打开即用(含 0 字节空库——node:sqlite 视其为合法空库,
95
97
  * 生产环境 .bak 由原子写改名上一完整主库得来,永不为 0 字节,0 字节态仅出现在测试构造)。
96
98
  */
97
99
  _openWithFallback() {
@@ -101,10 +103,10 @@ export class DB {
101
103
  if (!existsSync(p)) return null;
102
104
  return statSync(p).size === 0 ? 'empty' : 'ok';
103
105
  };
104
- // tryOpen: new Database(path) + prepare 探测双保险(防打开成功但内容非 SQLite)
106
+ // tryOpen: openDatabase(path) + prepare 探测双保险(防打开成功但内容非 SQLite)
105
107
  const tryOpen = (p) => {
106
108
  let db;
107
- try { db = new Database(p); }
109
+ try { db = openDatabase(p); }
108
110
  catch { return null; }
109
111
  try { db.prepare('SELECT count(*) FROM sqlite_master').get(); return db; }
110
112
  catch { try { db.close(); } catch { /* 关闭失败忽略 */ } return null; }
@@ -117,7 +119,7 @@ export class DB {
117
119
  }
118
120
 
119
121
  // 2. .bak 回退(.bak 是恢复源:存在且能打开即用,含 0 字节空库;门禁只挡"不存在",
120
- // 避免 tryOpen 对不存在的路径触发 new Database 的副作用——会凭空创建文件)
122
+ // 避免 tryOpen 对不存在的路径触发 openDatabase 的副作用——会凭空创建文件)
121
123
  if (readValid(bakPath) !== null) {
122
124
  const bakDb = tryOpen(bakPath);
123
125
  if (bakDb) {
@@ -129,23 +131,23 @@ export class DB {
129
131
  : 'sillyspec.db 损坏';
130
132
  console.warn(`⚠️ ${reason},已从 .bak 备份恢复。`);
131
133
  bakDb.close();
132
- // better-sqlite3 绑定路径,无 _save:把 .bak 内容 copy 回主库,统一用 dbPath
134
+ // node:sqlite 绑定路径,无 _save:把 .bak 内容 copy 回主库,统一用 dbPath
133
135
  copyFileSync(bakPath, this.dbPath);
134
- return new Database(this.dbPath);
136
+ return openDatabase(this.dbPath);
135
137
  }
136
138
  }
137
139
 
138
140
  // 3. 都不可用
139
141
  if (readValid(this.dbPath) === null) {
140
142
  // 主库与备份都不存在 → 全新项目,建空库
141
- return new Database(this.dbPath);
143
+ return openDatabase(this.dbPath);
142
144
  }
143
145
  // 主库曾存在(空/损坏)但无法恢复 → 数据丢失,必须 fail-loud(不静默建空库吞进度)
144
146
  throw new Error('sillyspec.db 损坏且 .bak 备份不可用,无法恢复。请从版本控制或其他备份恢复 .sillyspec/.runtime/ 后重试。');
145
147
  }
146
148
 
147
149
  /**
148
- * 关闭数据库。better-sqlite3 的 close() 自动做 WAL checkpoint(把 -wal/-shm 日志合并回主库),
150
+ * 关闭数据库。node:sqlite 的 close() 自动做 WAL checkpoint(把 -wal/-shm 日志合并回主库),
149
151
  * 不需要 sql.js 时代的 _save() 整库 export 落盘。
150
152
  */
151
153
  close() {
@@ -158,9 +160,10 @@ export class DB {
158
160
  /**
159
161
  * 原生事务 + SQLITE_BUSY 应用层有限重试(R-08 / NFR-03)。
160
162
  *
161
- * better-sqlite3 的 db.transaction(fn) 返回一个包装函数,调用时自动
162
- * BEGIN/COMMIT/ROLLBACK(fn 抛错自动 ROLLBACK 且不吞错),嵌套调用自动用 SAVEPOINT。
163
- * 无需手动 BEGIN/COMMIT/ROLLBACK,也无需 _save(better-sqlite3 提交即持久化)。
163
+ * node:sqlite 无 .transaction(),runTransaction(fn)(db-engine)用 SAVEPOINT/RELEASE/
164
+ * ROLLBACK TO 手写事务:fn 抛错自动回滚且不吞错,嵌套调用自动形成 savepoint 栈
165
+ * (对齐旧引擎嵌套自动 SAVEPOINT 的兼容实现)。无需手动 BEGIN/COMMIT/ROLLBACK,
166
+ * 也无需 _save(node:sqlite 提交即持久化)。
164
167
  *
165
168
  * SQLITE_BUSY 重试:WAL 单写者模型,并发写第二者在 busy_timeout=5000 后抛 BUSY。此处对
166
169
  * BUSY 加 MAX_BUSY_RETRIES 次递增退避(BUSY_BACKOFF_MS);仅 BUSY 重试(其它异常直接上抛),
@@ -169,14 +172,13 @@ export class DB {
169
172
  */
170
173
  transaction(fn) {
171
174
  if (!this.db) throw new Error('DB not initialized');
172
- const tx = this.db.transaction(fn);
173
175
  let attempt = 0;
174
176
  while (true) {
175
177
  try {
176
- return tx();
178
+ return runTransaction(this.db, fn);
177
179
  } catch (err) {
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
+ // 仅 SQLITE_BUSY 重试(node:sqlite err.errcode 为 5 / err.code 为 'ERR_SQLITE_ERROR',message 含 'database is locked')
181
+ const isBusy = err && (err.code === 'SQLITE_BUSY' || err.code === 5 || err.errcode === 5
180
182
  || (typeof err.message === 'string' && /database is locked|SQLITE_BUSY/i.test(err.message)));
181
183
  if (!isBusy) throw err;
182
184
  if (attempt >= MAX_BUSY_RETRIES) throw err; // 达上限 fail-loud(不静默吞,防死循环)
@@ -186,13 +188,13 @@ export class DB {
186
188
  }
187
189
  }
188
190
 
189
- /** 获取底层 db 对象(better-sqlite3 Database 实例,供 progress.js 直接使用) */
191
+ /** 获取底层 db 对象(node:sqlite DatabaseSync 实例,供 progress.js 直接使用) */
190
192
  getDb() {
191
193
  return this.db;
192
194
  }
193
195
 
194
196
  _createSchema() {
195
- // better-sqlite3:exec() 执行多条 SQL/DDL(无返回);prepare().run/get/all 用于 DML。
197
+ // node:sqlite:exec() 执行多条 SQL/DDL(无返回);prepare().run/get/all 用于 DML。
196
198
  // sql.js 的 .run(sql) 在此换为 .exec(sql)。
197
199
 
198
200
  // project 表
@@ -200,7 +202,7 @@ export class DB {
200
202
  CREATE TABLE IF NOT EXISTS project (
201
203
  id INTEGER PRIMARY KEY DEFAULT 1,
202
204
  name TEXT NOT NULL,
203
- schema_version INTEGER DEFAULT 4,
205
+ schema_version INTEGER DEFAULT 5,
204
206
  created_at TEXT NOT NULL,
205
207
  updated_at TEXT NOT NULL
206
208
  )
@@ -310,11 +312,19 @@ export class DB {
310
312
  // last_synced_platform_ts NULL=首次同步(base_ts NULL 平台接受首次 push)。
311
313
  this._migrateAddColumn('changes', 'last_synced_platform_ts', 'TEXT');
312
314
  this._migrateAddColumn('changes', 'last_local_modified_ts', 'TEXT');
315
+
316
+ // changes 表加 title(人类可读中文标题)+ quicklog_id(关联 QUICKLOG ql-ID)两列:
317
+ // 让 quick-<hex> 这种机器 hash sessionId 命名的 change 行可读,DB↔QUICKLOG 可对账。
318
+ // quick 启动时回填(stage.js:title 从任务描述提取、quicklog_id 用分配的 qlId),
319
+ // --done 时从 step3「需求:」刷新 title(agent 可改)。完整流程 change 的 name 本身
320
+ // 已语义化,title/quicklog_id 留空。不纳入平台同步脏度(本地展示用元信息)。
321
+ this._migrateAddColumn('changes', 'title', 'TEXT');
322
+ this._migrateAddColumn('changes', 'quicklog_id', 'TEXT');
313
323
  }
314
324
 
315
325
  /**
316
326
  * 幂等地给表添加列(列已存在则跳过)。
317
- * better-sqlite3:exec() 执行 ALTER TABLE DDL;列已存在抛错被 catch 静默跳过。
327
+ * node:sqlite:exec() 执行 ALTER TABLE DDL;列已存在抛错被 catch 静默跳过。
318
328
  * @private
319
329
  */
320
330
  _migrateAddColumn(table, column, type) {
@@ -11,7 +11,7 @@
11
11
  * - safe_actions:只描述建议动作与风险等级,绝不自动执行
12
12
  *
13
13
  * 安全约束(硬性):
14
- * - 所有检测只读。DB 以 better-sqlite3 只读连接打开(readonly + fileMustExist),不调用
14
+ * - 所有检测只读。DB 以 node:sqlite 只读连接打开(readOnly + existsSync 前置门),不调用
15
15
  * export/writeFileSync,不跑建表/迁移,close 后丢弃——绝不写回原 db 文件。
16
16
  * - 不删除/移动任何文件;orphan db 仅报告,处理交给后续 --dump-db / --confirm 流程。
17
17
  * - execute-progress-plan-mismatch 维度同样只读:仅读 plan.md checkbox + 只读查 stages 表,
@@ -20,7 +20,7 @@
20
20
  * 风格对齐 scan-postcheck.js:checks 用 CHECK_SEVERITY,formatter 产出 schema_version JSON,
21
21
  * writer 落盘到 <authoritySpecDir>/.runtime/。
22
22
  */
23
- import Database from 'better-sqlite3';
23
+ import { openDatabase, pluckGet, pluckAll } from './db-engine.js';
24
24
  import { existsSync, statSync, readFileSync, readdirSync, mkdirSync, writeFileSync, unlinkSync } from 'fs';
25
25
  import { join } from 'path';
26
26
  import { CHECK_SEVERITY } from './constants.js';
@@ -68,16 +68,16 @@ function probeDb(dbPath) {
68
68
  }
69
69
  let db = null;
70
70
  try {
71
- db = new Database(dbPath, { readonly: true, fileMustExist: true });
71
+ db = openDatabase(dbPath, { readOnly: true });
72
72
  const pick = (sql, fallback = null) => {
73
73
  try {
74
- const r = db.prepare(sql).pluck().get();
74
+ const r = pluckGet(db, sql);
75
75
  return r === undefined ? fallback : r;
76
76
  } catch { return fallback; }
77
77
  };
78
78
  const pickCol = (sql) => {
79
79
  try {
80
- return db.prepare(sql).pluck().all();
80
+ return pluckAll(db, sql);
81
81
  } catch { return []; }
82
82
  };
83
83
  // 每个 active change 的 execute stage status(只读查询;stages 表的 status 列)。
@@ -657,7 +657,7 @@ export async function dumpDb({ dbPath, cwd }) {
657
657
  }
658
658
  let db = null;
659
659
  try {
660
- db = new Database(dbPath, { readonly: true, fileMustExist: true });
660
+ db = openDatabase(dbPath, { readOnly: true });
661
661
  const rows = (sql) => {
662
662
  try { return db.prepare(sql).all(); } catch { return []; }
663
663
  };
@@ -12,7 +12,6 @@
12
12
  import { existsSync, readFileSync, readdirSync } from 'fs'
13
13
  import path from 'path'
14
14
  import { execFileSync } from 'child_process'
15
- import { createRequire } from 'module'
16
15
 
17
16
  // ── 常量 ──
18
17
 
@@ -225,20 +224,19 @@ function isInsideWorktreeStorage(filePath, cwd) {
225
224
  /**
226
225
  * 同步只读查询 sillyspec.db 第一行第一列(不依赖外部 sqlite3 CLI)。
227
226
  *
228
- * 用子进程跑 better-sqlite3 同步原生绑定(execFileSync 等待):
229
- * - 原生绑定经 hook 进程的 createRequire(import.meta.url).resolve('better-sqlite3') 解析出绝对路径,
230
- * JSON.stringify 嵌入子进程脚本——子进程 cwd 是项目目录,裸 require('better-sqlite3') 找不到包
231
- * (用户项目未必直接装了它);绝对路径 Windows/Linux/macOS 均 require 直接可用,无需 file:// URL。
232
- * - readonly + fileMustExist 双 true 打开只读连接:绝不写库;WAL 并发读不阻塞主进程写,
233
- * 且原生绑定可看到已 commit 但未 checkpoint 的过渡态(修复旧 WASM 内存库裸读 .db
234
- * 看不见 WAL 的过渡态可见性窗口)。
227
+ * 用子进程跑 node:sqlite 的 DatabaseSync(execFileSync 等待):
228
+ * - node:sqlite 是 Node 内置模块,子进程 require('node:sqlite') 直接可用,无需先 resolve 出
229
+ * 绝对路径再注入(旧方案需手动解析原生绑定路径,node:sqlite 无此步骤)。
230
+ * - readOnly 打开只读连接(替代旧方案 readonly/fileMustExist 语义):绝不写库;WAL 并发读不阻塞主进程写,
231
+ * 且只读连接可看到已 commit 但未 checkpoint 的过渡态(修复旧 WASM 内存库裸读 .db
232
+ * 看不见 WAL 的过渡态可见性窗口)。db 缺失/损坏由 open 异常自然触发 fail-closed(外层 catch)。
235
233
  *
236
234
  * 环境假设(R-09):WAL 模式下只读连接仍需在 .runtime/ 目录建/更新 -shm 索引文件,故 .runtime
237
235
  * 必须可写(与主进程一致)——“只读”指不改 DB 数据,不等于不对 -shm 加索引。
238
236
  *
239
- * fail-closed:better-sqlite3 resolve 失败 / db 文件不存在 / fileMustExist 打开失败 / 查询异常 /
240
- * 子进程超时或崩溃——一律 console.warn(含 e.stderr 详情)并返回 null,调用方
241
- *(readCurrentStage/isNoWorktreeMode)对 null 走 fail-closed,禁止 fail-open。
237
+ * fail-closed:db 文件不存在 / 子进程打开、查询异常 / 子进程超时或崩溃——一律 console.warn
238
+ * (含 e.stderr 详情)并返回 null,调用方(readCurrentStage/isNoWorktreeMode)对 null 走
239
+ * fail-closed,禁止 fail-open。
242
240
  */
243
241
  function queryDbFirstCell(cwd, sql) {
244
242
  const dbPath = path.join(cwd, '.sillyspec', '.runtime', 'sillyspec.db')
@@ -248,21 +246,15 @@ function queryDbFirstCell(cwd, sql) {
248
246
  console.warn('⚠️ sillyspec.db 不存在,hook 降级 fail-closed: ' + dbPath)
249
247
  return null
250
248
  }
251
- let libPath
252
- try {
253
- libPath = createRequire(import.meta.url).resolve('better-sqlite3')
254
- } catch (e) {
255
- console.warn('⚠️ sillyspec.db 查询失败(better-sqlite3 未解析),hook 降级 fail-closed: ' + ((e && e.message) || e))
256
- return null
257
- }
258
- // 子进程同步 require 绝对路径(better-sqlite3 是同步原生 API,无 async/import().then 包装)。
259
- // pluck().get() 取第一行第一列原值,无行返回 undefined(写空 stdout → 父侧 trim 后 null)。
249
+ // 子进程同步内置 require('node:sqlite')(DatabaseSync 是同步 API,无 async/import().then 包装)。
250
+ // prepare(sql).get() 取第一行整行后取首列值,无行返回 undefined(写空 stdout → 父侧 trim 后 null)。
260
251
  const script =
261
- "const D=require(" + JSON.stringify(libPath) + ")," +
252
+ "const {DatabaseSync}=require('node:sqlite')," +
262
253
  "dbPath=" + JSON.stringify(dbPath) + ",sql=" + JSON.stringify(sql) + ";" +
263
254
  "let db;" +
264
- "try{db=new D(dbPath,{readonly:true,fileMustExist:true});" +
265
- "const v=db.prepare(sql).pluck().get();" +
255
+ "try{db=new DatabaseSync(dbPath,{readOnly:true});" +
256
+ "const row=db.prepare(sql).get();" +
257
+ "const v=row===undefined?undefined:Object.values(row)[0];" +
266
258
  "if(v!==undefined)process.stdout.write(String(v??''));db.close()}" +
267
259
  "catch(e){process.stderr.write('hook db query failed: '+String(e&&e.message||e));process.exit(1)};"
268
260
  try {
@@ -270,7 +262,7 @@ function queryDbFirstCell(cwd, sql) {
270
262
  encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'pipe'],
271
263
  }).trim() || null
272
264
  } catch (e) {
273
- // DB 查询失败(db 损坏 / fileMustExist 打开失败 / 子进程超时或崩溃):降级返回 null,调用方
265
+ // DB 查询失败(db 损坏 / readOnly 打开失败 / 子进程超时或崩溃):降级返回 null,调用方
274
266
  //(readCurrentStage 等)对 null 走 fail-closed。补 warn 让 doctor/agent 能看到根因,
275
267
  // 而非完全静默(子进程把诊断写到了 stderr,execFileSync 抛错时挂在 e.stderr 上)。
276
268
  const detail = (e && e.stderr ? String(e.stderr).trim() : '') || (e && e.message) || 'timeout/crash'
@@ -291,7 +283,7 @@ export {
291
283
  * @returns {string|null} 阶段名,null 表示无法确定
292
284
  */
293
285
  function readCurrentStage(cwd) {
294
- // 直读 sillyspec.db(node + better-sqlite3 只读子进程,不依赖外部 sqlite3 CLI——Windows 默认没有)
286
+ // 直读 sillyspec.db(node + node:sqlite 只读子进程,不依赖外部 sqlite3 CLI——Windows 默认没有)
295
287
  const v = queryDbFirstCell(cwd, "SELECT current_stage FROM changes WHERE status='active' AND current_stage IN ('execute','quick') ORDER BY last_active DESC LIMIT 1")
296
288
  return v || null
297
289
  }
@@ -302,7 +294,7 @@ function readCurrentStage(cwd) {
302
294
  * @returns {boolean}
303
295
  */
304
296
  function isNoWorktreeMode(cwd) {
305
- // 直读 sillyspec.db(node + better-sqlite3,同 readCurrentStage)
297
+ // 直读 sillyspec.db(node + node:sqlite,同 readCurrentStage)
306
298
  return queryDbFirstCell(cwd, "SELECT no_worktree FROM changes WHERE status='active' AND current_stage IN ('execute','quick') LIMIT 1") === '1'
307
299
  }
308
300
 
@@ -364,9 +356,10 @@ function matchFileWhitelist(filePath) {
364
356
  }
365
357
 
366
358
  /**
367
- * 读取 local.yaml 中的扩展白名单配置(如果存在)
359
+ * 读取并解析 local.yaml(轻量手写解析,返回顶层对象)。
360
+ * 消费方读嵌套段 worktree-hook.readonlyCommands(见 isSingleCommandReadonly 的 extraReadonlyCommands)。
368
361
  * @param {string} cwd
369
- * @returns {{ fileWhitelist?: string[], readonlyCommands?: string[] }}
362
+ * @returns {Record<string, any>} 解析后的 local.yaml 顶层对象(文件缺/解析失败 → {})
370
363
  */
371
364
  function loadLocalConfig(cwd) {
372
365
  const candidates = [
package/src/index.js CHANGED
@@ -75,6 +75,7 @@ SillySpec CLI — 规范驱动开发工具包
75
75
  doctor [--fix] [--stale-hours N] 健康检查 + 修复
76
76
 
77
77
  sillyspec local detect [--dir <path>] 生成本地配置 local.yaml(纯 fs 嗅探,零 token、不跑 scan)
78
+ sillyspec config [schema] [--json] 打印 local.yaml 全部已知键 + 生效状态 + 读取点(堵外部 agent 配置发现缺口)
78
79
  sillyspec runtime list [--json] 枚举 .sillyspec/.runtime/ 运行时产物(只读,看手上有哪些证据/状态文件)
79
80
  sillyspec dispatch <probe | hint> SillyHub 派发能力探测 + 策略生成(agent 调用桥,仅渲染不执行 tool)
80
81
 
@@ -1543,10 +1544,10 @@ SillySpec modules — 模块文档管理
1543
1544
  lines.push('# 测试策略:full=全量测试, module=只测变更模块, skip=跳过测试');
1544
1545
  lines.push('test_strategy: module');
1545
1546
  lines.push('');
1546
- lines.push('# 模块测试路径映射(可选)');
1547
- lines.push('# module_paths:');
1548
- lines.push('# user-service: "user/"');
1549
- lines.push('# order-service: "order/"');
1547
+ lines.push('# monorepo 子模块映射(test_strategy: module 时按 git diff 命中模块收窄测试;inline flow 形态)');
1548
+ lines.push('# modules:');
1549
+ lines.push('# user-service: { path: "user/", test: "cd user && npm test" }');
1550
+ lines.push('# order-service: { path: "order/", test: "cd order && npm test" }');
1550
1551
  lines.push('# 已知预存失败(可选):verify 实测时这些失败被豁免、不阻断归档;fail-safe 仅豁免能检测到的失败行');
1551
1552
  lines.push('# known_failures:');
1552
1553
  lines.push('# - "tests/test_legacy.py::test_old"');
@@ -1560,6 +1561,42 @@ SillySpec modules — 模块文档管理
1560
1561
  console.log(` 路径: ${localYamlPath}`);
1561
1562
  break;
1562
1563
  }
1564
+ case 'config': {
1565
+ // local.yaml 键发现缺口(2026-08-11):键散落 ~10 个 reader,外部项目 agent 无从得知。
1566
+ // config schema 把全部已知键 + 生效状态 + 读取点打印出来,堵住发现缺口。
1567
+ // 数据源唯一 = src/config-schema.js(与 init 落盘的 local.yaml.example 同源)。
1568
+ const configSub = filteredArgs[1];
1569
+ const wantHelp = configSub === 'help' || configSub === '--help' || configSub === '-h';
1570
+ const isSchema = !configSub || configSub === 'schema';
1571
+ if (wantHelp) {
1572
+ console.log(`
1573
+ SillySpec config — local.yaml 配置键速查
1574
+
1575
+ 用法:
1576
+ sillyspec config 打印 local.yaml 全部已知键(人类可读树,默认)
1577
+ sillyspec config schema 同上(显式子命令)
1578
+ sillyspec config --json 机读 JSON(程序化消费)
1579
+ sillyspec config schema --json 同上
1580
+
1581
+ 说明:
1582
+ - 数据源:src/config-schema.js(唯一真相;reader 见各键「读取点」)
1583
+ - 键分两类:【生效】配了即生效;【声明但未接线】代码/JSDoc 提及但无 reader,配了不生效
1584
+ - 脱敏示例文件:sillyspec init 生成 local.yaml.example(可提交;真实 local.yaml 是 gitignored)
1585
+ `);
1586
+ break;
1587
+ }
1588
+ if (!isSchema) {
1589
+ const sug = didYouMean(configSub, ['schema']);
1590
+ console.error(`❌ 未知子命令: config ${configSub}`);
1591
+ if (sug) console.error(` 你是想输入「config ${sug}」吗?`);
1592
+ console.error('用法: sillyspec config [schema] [--json] 打印 local.yaml 配置键');
1593
+ process.exit(2);
1594
+ }
1595
+ const { renderSchemaHuman, renderSchemaJson } = await import('./config-schema.js');
1596
+ if (json) console.log(renderSchemaJson());
1597
+ else console.log(renderSchemaHuman());
1598
+ break;
1599
+ }
1563
1600
  case 'runtime': {
1564
1601
  // D2:.runtime/ 产物索引。CLI 往 .runtime/ 写各种证据/状态文件(db、doctor-diagnosis、
1565
1602
  // workflow-runs、user-inputs…),却没有命令让 agent 一眼看到「我手上有哪些产物」。
package/src/init.js CHANGED
@@ -5,6 +5,7 @@ import { checkbox, confirm, input } from '@inquirer/prompts';
5
5
  import { ProgressManager } from './progress.js';
6
6
  import chalk from 'chalk';
7
7
  import { getVersion } from './version.js';
8
+ import { renderExample } from './config-schema.js';
8
9
  // 向后兼容:getVersion 已抽到轻量 version.js(避免 index.js 为 --version 静态加载 init.js 的 inquirer 税),
9
10
  // 此处 re-export 保持 init.js 既有导出 API 不破坏(如 test/init-claude-injection.test.mjs)。
10
11
  export { getVersion };
@@ -313,6 +314,18 @@ function doInstall(projectDir, tools, subprojects = [], specDir = null) {
313
314
  const pm = new ProgressManager({ specDir: spec });
314
315
  pm.init(projectDir);
315
316
 
317
+ // 落盘脱敏 local.yaml.example(config-schema.js 单一数据源;不存在才写,仿 local detect skip-if-exists)
318
+ // 真实 local.yaml 是 gitignored 含凭据;example 可提交,是给人/外部 agent 看的配置发现物。
319
+ const examplePath = join(spec, 'local.yaml.example');
320
+ if (!existsSync(examplePath)) {
321
+ try {
322
+ writeFileSync(examplePath, renderExample());
323
+ console.log(chalk.green(' ✓ local.yaml.example 已生成(脱敏配置示例,可提交;真实 local.yaml 由 sillyspec local detect / platform connect 写入)'));
324
+ } catch (e) {
325
+ console.warn(chalk.yellow(` ⚠ 生成 local.yaml.example 失败: ${e.message}`));
326
+ }
327
+ }
328
+
316
329
  // .gitignore 只在 specDir 在项目内时才修改
317
330
  const isExternalSpec = specDir && resolve(spec) !== resolve(projectDir, '.sillyspec');
318
331
  if (!isExternalSpec) {
@@ -67,6 +67,34 @@ export class ChangeRegistry {
67
67
  }
68
68
  }
69
69
 
70
+ /**
71
+ * 更新变更的人类可读元信息(title / quicklog_id),让 quick-<hex> 行可读、DB↔QUICKLOG 可对账。
72
+ * quick 启动时回填(title 从任务描述、quicklog_id 用分配的 qlId);--done 时从 step3「需求:」刷新 title。
73
+ * 部分更新(只传 title 不动 quicklog_id,反之亦然)。不调 _touchLocalModified:title/quicklog_id 是
74
+ * 本地展示用元信息,纳入脏度会扰动平台同步(平台 changes 表无此列)。
75
+ * @param {string} cwd
76
+ * @param {string} changeName
77
+ * @param {{ title?: string, quicklogId?: string }} meta
78
+ */
79
+ updateChangeMeta(cwd, changeName, meta) {
80
+ if (!changeName || !meta) return;
81
+ const db = this.pm._ensureDB(cwd);
82
+ try {
83
+ db.transaction(() => {
84
+ const sqlDb = db.getDb();
85
+ const sets = [];
86
+ const params = [];
87
+ if (meta.title !== undefined) { sets.push('title = ?'); params.push(meta.title); }
88
+ if (meta.quicklogId !== undefined) { sets.push('quicklog_id = ?'); params.push(meta.quicklogId); }
89
+ if (sets.length === 0) return;
90
+ params.push(changeName);
91
+ sqlDb.prepare(`UPDATE changes SET ${sets.join(', ')} WHERE name = ?`).run(...params);
92
+ });
93
+ } catch (err) {
94
+ console.warn('⚠️ 更新 change 元信息失败:', err.message);
95
+ }
96
+ }
97
+
70
98
  /**
71
99
  * 读取变更的隔离状态
72
100
  * @param {string} cwd - 项目根目录
@@ -27,7 +27,7 @@ export const STAGE_ORDER = ['scan', 'brainstorm', 'plan', 'execute', 'verify', '
27
27
  export const MAIN_FLOW_ORDER = ['brainstorm', 'plan', 'execute', 'verify', 'archive'];
28
28
 
29
29
  // progress 数据版本(v3 = SQLite;v4 = changes 加 base_ts / 本地脏度两列,platform-sync §8)
30
- export const CURRENT_VERSION = 4;
30
+ export const CURRENT_VERSION = 5;
31
31
 
32
32
  // 空阶段骨架
33
33
  export function emptyStage() {
package/src/progress.js CHANGED
@@ -347,7 +347,7 @@ export class ProgressManager {
347
347
  }
348
348
 
349
349
  const result = {
350
- _version: 4,
350
+ _version: 5,
351
351
  project: projectName,
352
352
  currentChange: cName,
353
353
  currentStage: currentStage || '',
@@ -776,6 +776,8 @@ export class ProgressManager {
776
776
 
777
777
  unregisterChange(cwd, changeName) { return this._changeRegistry.unregisterChange(cwd, changeName); }
778
778
 
779
+ updateChangeMeta(cwd, changeName, meta) { return this._changeRegistry.updateChangeMeta(cwd, changeName, meta); }
780
+
779
781
  // ── CLI 命令 ──
780
782
 
781
783
  init(cwd) {
@@ -815,7 +817,7 @@ export class ProgressManager {
815
817
  * 初始化指定变更的 progress
816
818
  * SQL: INSERT changes + 批量 INSERT stages
817
819
  */
818
- initChange(cwd, changeName) {
820
+ initChange(cwd, changeName, meta = {}) {
819
821
  if (!changeName) {
820
822
  console.warn('⚠️ initChange: changeName 为空,跳过');
821
823
  return null;
@@ -836,9 +838,9 @@ export class ProgressManager {
836
838
  if (existing === undefined) {
837
839
  // 插入 changes 行
838
840
  sqlDb.prepare(
839
- `INSERT INTO changes (name, current_stage, status, created_at, last_active)
840
- VALUES (?, 'scan', 'active', ?, ?)`
841
- ).run(changeName, now, now);
841
+ `INSERT INTO changes (name, current_stage, status, created_at, last_active, title, quicklog_id)
842
+ VALUES (?, 'scan', 'active', ?, ?, ?, ?)`
843
+ ).run(changeName, now, now, meta.title || null, meta.quicklogId || null);
842
844
  }
843
845
 
844
846
  // 获取 change_id
package/src/quicklog.js CHANGED
@@ -75,7 +75,7 @@ function nowDatetime(d = new Date()) {
75
75
  }
76
76
 
77
77
  // 描述压成一行、限长,避免破坏 QUICKLOG 条目结构
78
- function sanitizeDesc(description) {
78
+ export function sanitizeDesc(description) {
79
79
  const s = String(description || '').replace(/[\r\n]+/g, ' ').trim()
80
80
  if (!s) return '(quick 任务)'
81
81
  return s.length > 120 ? s.slice(0, 120) + '…' : s
@@ -101,7 +101,7 @@ export function deriveTitleFromLinkedChange(specBase, change) {
101
101
 
102
102
  // 从 quick step3 --output 四字段提取「需求:」摘要,用于翻完成时刷新标题行
103
103
  // (覆盖启动时的占位/弱标题)。提取失败返回 ''(不刷新)。
104
- function extractTitleFromResult(result) {
104
+ export function extractTitleFromResult(result) {
105
105
  if (!result) return ''
106
106
  const m = String(result).match(/需求:([^\n\r]*?)(?:\s+根因:|$)/)
107
107
  if (!m) return ''
@@ -1004,8 +1004,10 @@ async function runAutoMode(pm, progress, cwd, flags, changeName, platformOpts =
1004
1004
 
1005
1005
  // ── Classify change on first entry ──
1006
1006
  if (!progress.stages?.brainstorm?.status && !progress.stages?.plan?.status) {
1007
- const { classifyChange } = await import('../classify-change.js')
1008
- const classification = classifyChange({ description: inputText || '', explicitMode })
1007
+ const { classifyChange, readAutoModeFromLocalYaml } = await import('../classify-change.js')
1008
+ // auto_mode 接线(2026-08-11):从 local.yaml 读 auto_mode 段传 localConfig,让 force_*_patterns 真生效。
1009
+ const localConfig = readAutoModeFromLocalYaml(cwd)
1010
+ const classification = classifyChange({ description: inputText || '', explicitMode, localConfig })
1009
1011
  if (classification.mode === 'quick') {
1010
1012
  console.log(`📊 auto 模式分类:${classification.mode}(${classification.reason})`)
1011
1013
  console.log(` 此变更建议使用 quick 模式,运行:sillyspec run quick "${inputText || '需求'}"`)
@@ -30,7 +30,7 @@ import { detectConcurrentChanges, formatConcurrentWarning } from './concurrent-d
30
30
  import { stageRegistry } from '../stages/index.js'
31
31
  import { SCAN_STATUS, POINTER_STATUS } from '../constants.js'
32
32
  import { printQuickAuditReview } from './quick-audit.js'
33
- import { validateQuickResult, allocateQuicklogEntry, findQuicklogEntry, completeQuicklogEntry } from '../quicklog.js'
33
+ import { validateQuickResult, allocateQuicklogEntry, findQuicklogEntry, completeQuicklogEntry, extractTitleFromResult } from '../quicklog.js'
34
34
  import { getRule } from '../stage-contract-spec.js'
35
35
  import { archiveDestDirName } from '../stage-contract.js'
36
36
 
@@ -774,6 +774,12 @@ export async function handleQuickStageCompletion({ stageName, steps, currentIdx,
774
774
  changedFiles: realFiles,
775
775
  })
776
776
  console.log(`📝 QUICKLOG 条目 ${qlId} 已标记完成`)
777
+ // 刷新 DB title:从 step3「需求:」提取(agent 可改 title 的途径),覆盖启动时的兜底快照。
778
+ // 与 QUICKLOG 标题刷新(flipEntryInContent 内 extractTitleFromResult)同源,保持 DB↔QUICKLOG 一致。
779
+ try {
780
+ const refinedTitle = extractTitleFromResult(outputText || '');
781
+ if (refinedTitle) pm.updateChangeMeta(cwd, changeName, { title: refinedTitle });
782
+ } catch { /* title 刷新失败不阻断完成 */ }
777
783
  } catch (e) {
778
784
  console.warn(`⚠️ QUICKLOG 完成态写入失败: ${e.message}`)
779
785
  }
package/src/run/prompt.js CHANGED
@@ -41,9 +41,9 @@ function loadModuleContextIndex(specBase, projectName) {
41
41
  // 提示 v1/v2 格式混用风险(旧格式字段解析会静默错位)。正常生成的文件不触发。
42
42
  const sv = content.match(/^schema_version:\s*(\d+)/m)
43
43
  if (!sv) {
44
- console.warn(`⚠️ _module-map.yaml 缺少 schema_version 声明(期望 2),模块解析可能错位:${mapPath}`)
44
+ console.warn(`⚠️ _module-map.yaml 缺少 schema_version 声明(期望 2),模块解析可能错位:${mapPath}(跑 \`sillyspec modules rebuild\` 升级到 schema_version: 2 可消除此警告)`)
45
45
  } else if (sv[1] !== '2') {
46
- console.warn(`⚠️ _module-map.yaml schema_version=${sv[1]}(期望 2),可能为旧格式,模块解析可能错位:${mapPath}`)
46
+ console.warn(`⚠️ _module-map.yaml schema_version=${sv[1]}(期望 2),可能为旧格式,模块解析可能错位:${mapPath}(跑 \`sillyspec modules rebuild\` 升级到 schema_version: 2 可消除此警告)`)
47
47
  }
48
48
  return parseModuleMapSimple(content)
49
49
  } catch {
@@ -452,7 +452,13 @@ export async function outputStep(stageName, stepIndex, steps, cwd, changeName, d
452
452
  } catch {}
453
453
  if (!reviewRunId) {
454
454
  reviewRunId = generateStageReviewRunId()
455
- try { mkdirSync(tierRuntimeRoot, { recursive: true }); writeFileSync(reviewRunIdMarker, reviewRunId + '\n') } catch {}
455
+ try {
456
+ mkdirSync(tierRuntimeRoot, { recursive: true })
457
+ writeFileSync(reviewRunIdMarker, reviewRunId + '\n')
458
+ // plan-c: echo 完整 review 目录路径,避免 agent 拿裸 runId 给子代理时漏连字符拼错路径
459
+ const reviewDir = `${tierRuntimeRoot}/stage-reviews/${stageName}-${reviewRunId}`
460
+ console.log(` 📁 Stage Review 写入目录(直接复制给 review 子代理,勿手拼 runId):${reviewDir}/`)
461
+ } catch {}
456
462
  }
457
463
  // review.json 产物契约(schema + 示例 + docHash 算法 + 重算提示)事前注入,与 validateStageReviewSchema 同源
458
464
  const reviewContractMd = renderReviewJsonContract({ stage: stageName, changeDir: tierChangeDir, reviewRunId, tier: tier.tier })