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.
- package/.claude/skills/sillyspec-auto/SKILL.md +8 -13
- package/.claude/skills/sillyspec-brainstorm/SKILL.md +8 -6
- package/.claude/skills/sillyspec-continue/SKILL.md +3 -3
- package/.claude/skills/sillyspec-doctor/SKILL.md +1 -1
- package/.claude/skills/sillyspec-execute/SKILL.md +10 -4
- package/.claude/skills/sillyspec-plan/SKILL.md +1 -1
- package/.claude/skills/sillyspec-quick/SKILL.md +3 -1
- package/.claude/skills/sillyspec-verify/SKILL.md +1 -1
- package/.sillyspec-platform-cleaned +1 -0
- package/.sillyspec-platform.json +7 -0
- package/README.md +9 -0
- package/package.json +2 -2
- package/src/change-list.js +11 -8
- package/src/change-risk-profile.js +22 -0
- package/src/db.js +170 -161
- package/src/dispatch/backends/local-agent.js +152 -0
- package/src/dispatch/backends/sillyhub-mcp.js +242 -0
- package/src/dispatch/probe.js +210 -0
- package/src/dispatch/strategy.js +121 -0
- package/src/doctor-diagnostics.js +19 -25
- package/src/fs-atomic.js +69 -69
- package/src/git-helper.js +66 -0
- package/src/hooks/worktree-guard.js +64 -55
- package/src/index.js +326 -36
- package/src/init.js +7 -14
- package/src/local-detect.js +120 -110
- package/src/machine-interface.js +54 -19
- package/src/migrate.js +14 -4
- package/src/modules.js +16 -5
- package/src/progress/change-registry.js +85 -70
- package/src/progress/consistency-doctor.js +71 -14
- package/src/progress/shared.js +8 -5
- package/src/progress/stage-machine.js +67 -65
- package/src/progress/step-store.js +72 -67
- package/src/progress.js +448 -236
- package/src/quicklog.js +59 -12
- package/src/run/command.js +78 -39
- package/src/run/complete-handlers.js +1028 -889
- package/src/run/complete.js +132 -212
- package/src/run/concurrent-detect.js +132 -0
- package/src/run/gates.js +302 -19
- package/src/run/prompt.js +22 -68
- package/src/run/quick-audit.js +10 -9
- package/src/run/shared.js +722 -600
- package/src/run/stage.js +43 -24
- package/src/scan-postcheck.js +9 -31
- package/src/sillyhub-mcp/client.js +394 -0
- package/src/sillyhub-mcp/config.js +62 -0
- package/src/spec-dir-typo.js +71 -0
- package/src/stage-contract-engine.js +22 -1
- package/src/stage-contract-spec.js +11 -4
- package/src/stage-contract.js +26 -3
- package/src/stage-review.js +118 -10
- package/src/stages/archive.js +4 -6
- package/src/stages/brainstorm-auto.js +26 -20
- package/src/stages/brainstorm.js +29 -5
- package/src/stages/cmd-existence.js +145 -0
- package/src/stages/doctor.js +10 -7
- package/src/stages/execute.js +64 -22
- package/src/stages/plan-postcheck.js +152 -0
- package/src/stages/plan.js +16 -31
- package/src/stages/quick.js +25 -9
- package/src/stages/scan.js +53 -8
- package/src/stages/status.js +0 -1
- package/src/stages/verify.js +14 -9
- package/src/sync.js +433 -16
- package/src/task-review.js +59 -1
- package/src/verify-postcheck.js +938 -862
- package/src/version.js +19 -0
- package/src/worktree-apply.js +218 -93
- package/src/worktree-deps.js +89 -5
- package/src/worktree.js +213 -111
- package/templates/prompts/taskcard-rules.md +27 -0
- package/templates/prompts/verify-probes.md +3 -3
- package/.claude/skills/sillyspec-propose/SKILL.md +0 -87
- package/src/stages/propose.js +0 -181
|
@@ -11,8 +11,8 @@
|
|
|
11
11
|
* - safe_actions:只描述建议动作与风险等级,绝不自动执行
|
|
12
12
|
*
|
|
13
13
|
* 安全约束(硬性):
|
|
14
|
-
* - 所有检测只读。DB
|
|
15
|
-
* close 后丢弃——绝不写回原 db 文件。
|
|
14
|
+
* - 所有检测只读。DB 以 better-sqlite3 只读连接打开(readonly + fileMustExist),不调用
|
|
15
|
+
* export/writeFileSync,不跑建表/迁移,close 后丢弃——绝不写回原 db 文件。
|
|
16
16
|
* - 不删除/移动任何文件;orphan db 仅报告,处理交给后续 --dump-db / --confirm 流程。
|
|
17
17
|
* - execute-progress-plan-mismatch 维度同样只读:仅读 plan.md checkbox + 只读查 stages 表,
|
|
18
18
|
* 绝不调用 ProgressManager 写方法(写操作是 progress.alignExecuteToPlan 的职责,D-001@v2 诊断/写分离)。
|
|
@@ -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
|
|
23
|
+
import Database from 'better-sqlite3';
|
|
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';
|
|
@@ -59,7 +59,7 @@ function resolvePointer(cwd) {
|
|
|
59
59
|
* 只读打开一个 db 文件,提取诊断信号。绝不写回。
|
|
60
60
|
* 返回 null 表示文件不存在。
|
|
61
61
|
*/
|
|
62
|
-
|
|
62
|
+
function probeDb(dbPath) {
|
|
63
63
|
if (!existsSync(dbPath)) return null;
|
|
64
64
|
const st = statSync(dbPath);
|
|
65
65
|
const base = { path: dbPath, exists: true, size: st.size, mtime: st.mtime.toISOString() };
|
|
@@ -68,18 +68,16 @@ async function probeDb(dbPath, SQL) {
|
|
|
68
68
|
}
|
|
69
69
|
let db = null;
|
|
70
70
|
try {
|
|
71
|
-
|
|
72
|
-
db = new SQL.Database(buf); // 内存副本
|
|
71
|
+
db = new Database(dbPath, { readonly: true, fileMustExist: true });
|
|
73
72
|
const pick = (sql, fallback = null) => {
|
|
74
73
|
try {
|
|
75
|
-
const r = db.
|
|
76
|
-
return r
|
|
74
|
+
const r = db.prepare(sql).pluck().get();
|
|
75
|
+
return r === undefined ? fallback : r;
|
|
77
76
|
} catch { return fallback; }
|
|
78
77
|
};
|
|
79
78
|
const pickCol = (sql) => {
|
|
80
79
|
try {
|
|
81
|
-
|
|
82
|
-
return r.length ? r[0].values.map((row) => row[0]) : [];
|
|
80
|
+
return db.prepare(sql).pluck().all();
|
|
83
81
|
} catch { return []; }
|
|
84
82
|
};
|
|
85
83
|
// 每个 active change 的 execute stage status(只读查询;stages 表的 status 列)。
|
|
@@ -87,14 +85,12 @@ async function probeDb(dbPath, SQL) {
|
|
|
87
85
|
// change_name → execute stage status(无 execute 行则 absent)。
|
|
88
86
|
const pickExecuteStatusByChange = () => {
|
|
89
87
|
try {
|
|
90
|
-
const
|
|
88
|
+
const rows = db.prepare(
|
|
91
89
|
`SELECT c.name, s.status FROM changes c
|
|
92
90
|
LEFT JOIN stages s ON s.change_id = c.id AND s.stage = 'execute'`
|
|
93
|
-
);
|
|
91
|
+
).all();
|
|
94
92
|
const out = {};
|
|
95
|
-
|
|
96
|
-
for (const [name, status] of r[0].values) out[name] = status || null;
|
|
97
|
-
}
|
|
93
|
+
for (const row of rows) out[row.name] = row.status || null;
|
|
98
94
|
return out;
|
|
99
95
|
} catch { return {}; }
|
|
100
96
|
};
|
|
@@ -123,7 +119,7 @@ function summarizeActive(changes) {
|
|
|
123
119
|
|
|
124
120
|
// ── D1 多 db 探测与权威判定 ────────────────────────────────────────────
|
|
125
121
|
|
|
126
|
-
|
|
122
|
+
function detectMultiDb(cwd, pointer) {
|
|
127
123
|
const localSpec = join(cwd, '.sillyspec');
|
|
128
124
|
const candidates = [
|
|
129
125
|
{ path: join(localSpec, 'sillyspec.db'), kind: 'local_root' },
|
|
@@ -136,7 +132,7 @@ async function detectMultiDb(cwd, pointer, SQL) {
|
|
|
136
132
|
|
|
137
133
|
const dbs = [];
|
|
138
134
|
for (const c of candidates) {
|
|
139
|
-
const probed =
|
|
135
|
+
const probed = probeDb(c.path);
|
|
140
136
|
if (!probed) continue;
|
|
141
137
|
dbs.push({ kind: c.kind, ...probed });
|
|
142
138
|
}
|
|
@@ -492,8 +488,7 @@ function detectExecuteProgressPlanMismatch(authoritySpecRoot, authDb) {
|
|
|
492
488
|
|
|
493
489
|
export async function runDoctorDiagnostics({ cwd }) {
|
|
494
490
|
const pointer = resolvePointer(cwd);
|
|
495
|
-
const
|
|
496
|
-
const multiDb = await detectMultiDb(cwd, pointer, SQL);
|
|
491
|
+
const multiDb = detectMultiDb(cwd, pointer);
|
|
497
492
|
const pointerHealth = detectPointerHealth(pointer);
|
|
498
493
|
const changesSplit = detectChangesSplit(cwd, pointer);
|
|
499
494
|
const changeDb = detectChangeDbConsistency(cwd, pointer, multiDb);
|
|
@@ -662,17 +657,16 @@ export async function dumpDb({ dbPath, cwd }) {
|
|
|
662
657
|
}
|
|
663
658
|
let db = null;
|
|
664
659
|
try {
|
|
665
|
-
|
|
666
|
-
db = new SQL.Database(readFileSync(dbPath));
|
|
660
|
+
db = new Database(dbPath, { readonly: true, fileMustExist: true });
|
|
667
661
|
const rows = (sql) => {
|
|
668
|
-
try {
|
|
662
|
+
try { return db.prepare(sql).all(); } catch { return []; }
|
|
669
663
|
};
|
|
670
664
|
const sv = rows('SELECT schema_version FROM project LIMIT 1');
|
|
671
|
-
const schemaVersion = sv.length ? sv[0]
|
|
665
|
+
const schemaVersion = sv.length ? sv[0].schema_version : null;
|
|
672
666
|
const changes = rows('SELECT name, current_stage, status, created_at, last_active FROM changes ORDER BY last_active DESC')
|
|
673
|
-
.map((r) => ({ name: r
|
|
667
|
+
.map((r) => ({ name: r.name, current_stage: r.current_stage, status: r.status, created_at: r.created_at, last_active: r.last_active }));
|
|
674
668
|
const stages = rows('SELECT c.name, s.stage, s.status, s.started_at, s.completed_at FROM stages s JOIN changes c ON s.change_id = c.id ORDER BY c.name, s.stage')
|
|
675
|
-
.map((r) => ({ change: r
|
|
669
|
+
.map((r) => ({ change: r.name, stage: r.stage, status: r.status, started_at: r.started_at, completed_at: r.completed_at }));
|
|
676
670
|
return writeDump({ ok: true, meta, schema_version: schemaVersion, changes, stages }, authoritySpecDir);
|
|
677
671
|
} catch (e) {
|
|
678
672
|
return writeDump({ ok: false, meta, error: `读取失败: ${e.message}` }, authoritySpecDir);
|
package/src/fs-atomic.js
CHANGED
|
@@ -1,69 +1,69 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* fs-atomic.js — 原子文件写 + Windows 友好的 rename 重试
|
|
3
|
-
*
|
|
4
|
-
* 用于 .runtime/*.json、pointer、guard.json 等「会被其他进程 / hook 读取」的配置文件。
|
|
5
|
-
* 保证读者要么看到旧完整内容、要么看到新完整内容,不会读到半截 JSON。
|
|
6
|
-
*
|
|
7
|
-
* 注意:db.js 有自己的 _atomicWriteSync(含 .bak 备份),那是 DB 专属持久化逻辑,
|
|
8
|
-
* 已经过稳定性验证,此处不复用、不替换——保持 db.js 零改动。
|
|
9
|
-
*/
|
|
10
|
-
import { writeFileSync, renameSync, unlinkSync } from 'fs';
|
|
11
|
-
import { dirname, basename, join } from 'path';
|
|
12
|
-
|
|
13
|
-
// Windows 上杀毒 / 索引 / IDE 占用文件时,rename 偶发这些错误,短退避重试即可
|
|
14
|
-
const RENAME_RETRY_CODES = ['EPERM', 'EBUSY', 'EACCES', 'ENOTEMPTY'];
|
|
15
|
-
|
|
16
|
-
/** 同步退避:优先 Atomics.wait,不可用时退化忙等(仅在 rename 冲突的极端情况触发)。 */
|
|
17
|
-
function sleepSync(ms) {
|
|
18
|
-
try {
|
|
19
|
-
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
20
|
-
} catch {
|
|
21
|
-
const end = Date.now() + ms;
|
|
22
|
-
while (Date.now() < end) { /* busy wait */ }
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* rename 带退避重试,覆盖 Windows 偶发的 EPERM/EBUSY/EACCES/ENOTEMPTY。
|
|
28
|
-
* 目录 rename 同样适用(归档移动目录时可复用)。
|
|
29
|
-
* @param {string} from
|
|
30
|
-
* @param {string} to
|
|
31
|
-
* @param {number} [retries=5]
|
|
32
|
-
*/
|
|
33
|
-
export function renameSyncRetry(from, to, retries = 5) {
|
|
34
|
-
let lastErr;
|
|
35
|
-
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
36
|
-
try {
|
|
37
|
-
renameSync(from, to);
|
|
38
|
-
return;
|
|
39
|
-
} catch (err) {
|
|
40
|
-
lastErr = err;
|
|
41
|
-
const code = err && err.code;
|
|
42
|
-
// 非可重试错误码立即抛出,不浪费时间
|
|
43
|
-
if (!code || !RENAME_RETRY_CODES.includes(code)) throw err;
|
|
44
|
-
if (attempt < retries) sleepSync(15 * (attempt + 1));
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
throw lastErr;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* 原子写文本文件:同目录 tmp 写入 → rename 覆盖目标(带 Windows 退避重试)。
|
|
52
|
-
* - tmp 名含 pid,避免多进程并发写同一目标时 tmp 名碰撞(如多会话写
|
|
53
|
-
* - rename 失败时清理 tmp,避免留下孤儿 .tmp 文件。
|
|
54
|
-
* - Node 的 rename 在 Windows 上用 MoveFileEx(REPLACE_EXISTING),可覆盖已存在目标;
|
|
55
|
-
* EPERM 仅在文件被其他进程锁定时出现,由 renameSyncRetry 退避重试。
|
|
56
|
-
* @param {string} filePath - 目标文件绝对路径
|
|
57
|
-
* @param {string} content - 文件内容
|
|
58
|
-
*/
|
|
59
|
-
export function writeAtomicSync(filePath, content) {
|
|
60
|
-
const dir = dirname(filePath);
|
|
61
|
-
const tmpPath = join(dir, `.${basename(filePath)}.${process.pid}.tmp`);
|
|
62
|
-
writeFileSync(tmpPath, content);
|
|
63
|
-
try {
|
|
64
|
-
renameSyncRetry(tmpPath, filePath);
|
|
65
|
-
} catch (err) {
|
|
66
|
-
try { unlinkSync(tmpPath); } catch { /* tmp 可能已被 rename 走,忽略 */ }
|
|
67
|
-
throw err;
|
|
68
|
-
}
|
|
69
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* fs-atomic.js — 原子文件写 + Windows 友好的 rename 重试
|
|
3
|
+
*
|
|
4
|
+
* 用于 .runtime/*.json、pointer、guard.json 等「会被其他进程 / hook 读取」的配置文件。
|
|
5
|
+
* 保证读者要么看到旧完整内容、要么看到新完整内容,不会读到半截 JSON。
|
|
6
|
+
*
|
|
7
|
+
* 注意:db.js 有自己的 _atomicWriteSync(含 .bak 备份),那是 DB 专属持久化逻辑,
|
|
8
|
+
* 已经过稳定性验证,此处不复用、不替换——保持 db.js 零改动。
|
|
9
|
+
*/
|
|
10
|
+
import { writeFileSync, renameSync, unlinkSync } from 'fs';
|
|
11
|
+
import { dirname, basename, join } from 'path';
|
|
12
|
+
|
|
13
|
+
// Windows 上杀毒 / 索引 / IDE 占用文件时,rename 偶发这些错误,短退避重试即可
|
|
14
|
+
const RENAME_RETRY_CODES = ['EPERM', 'EBUSY', 'EACCES', 'ENOTEMPTY'];
|
|
15
|
+
|
|
16
|
+
/** 同步退避:优先 Atomics.wait,不可用时退化忙等(仅在 rename 冲突的极端情况触发)。 */
|
|
17
|
+
function sleepSync(ms) {
|
|
18
|
+
try {
|
|
19
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
20
|
+
} catch {
|
|
21
|
+
const end = Date.now() + ms;
|
|
22
|
+
while (Date.now() < end) { /* busy wait */ }
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* rename 带退避重试,覆盖 Windows 偶发的 EPERM/EBUSY/EACCES/ENOTEMPTY。
|
|
28
|
+
* 目录 rename 同样适用(归档移动目录时可复用)。
|
|
29
|
+
* @param {string} from
|
|
30
|
+
* @param {string} to
|
|
31
|
+
* @param {number} [retries=5]
|
|
32
|
+
*/
|
|
33
|
+
export function renameSyncRetry(from, to, retries = 5) {
|
|
34
|
+
let lastErr;
|
|
35
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
36
|
+
try {
|
|
37
|
+
renameSync(from, to);
|
|
38
|
+
return;
|
|
39
|
+
} catch (err) {
|
|
40
|
+
lastErr = err;
|
|
41
|
+
const code = err && err.code;
|
|
42
|
+
// 非可重试错误码立即抛出,不浪费时间
|
|
43
|
+
if (!code || !RENAME_RETRY_CODES.includes(code)) throw err;
|
|
44
|
+
if (attempt < retries) sleepSync(15 * (attempt + 1));
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
throw lastErr;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 原子写文本文件:同目录 tmp 写入 → rename 覆盖目标(带 Windows 退避重试)。
|
|
52
|
+
* - tmp 名含 pid,避免多进程并发写同一目标时 tmp 名碰撞(如多会话写 local.yaml)。
|
|
53
|
+
* - rename 失败时清理 tmp,避免留下孤儿 .tmp 文件。
|
|
54
|
+
* - Node 的 rename 在 Windows 上用 MoveFileEx(REPLACE_EXISTING),可覆盖已存在目标;
|
|
55
|
+
* EPERM 仅在文件被其他进程锁定时出现,由 renameSyncRetry 退避重试。
|
|
56
|
+
* @param {string} filePath - 目标文件绝对路径
|
|
57
|
+
* @param {string} content - 文件内容
|
|
58
|
+
*/
|
|
59
|
+
export function writeAtomicSync(filePath, content) {
|
|
60
|
+
const dir = dirname(filePath);
|
|
61
|
+
const tmpPath = join(dir, `.${basename(filePath)}.${process.pid}.tmp`);
|
|
62
|
+
writeFileSync(tmpPath, content);
|
|
63
|
+
try {
|
|
64
|
+
renameSyncRetry(tmpPath, filePath);
|
|
65
|
+
} catch (err) {
|
|
66
|
+
try { unlinkSync(tmpPath); } catch { /* tmp 可能已被 rename 走,忽略 */ }
|
|
67
|
+
throw err;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 统一公共 git 调用入口(数组形式 execFileSync,不经 shell)。
|
|
3
|
+
*
|
|
4
|
+
* 背景:worktree 链路原用 `execSync(\`git ${args}\`)` 字符串拼接(经 shell),
|
|
5
|
+
* 文件名/路径/用户输入插值进 shell 存在空格拆词 + 命令注入面;
|
|
6
|
+
* run/shared.js 的 safeGit 已用 execFileSync 数组形式(注释明示避免拆词)。
|
|
7
|
+
* 本模块把 safeGit 收口为单一实现,并补抛错版 git / 静默版 gitQuiet,
|
|
8
|
+
* 供 worktree 链与 run 层共用,消除口径分裂。
|
|
9
|
+
*
|
|
10
|
+
* 三者统一带 `-c safe.directory=<cwd>` per-command(不污染全局 config)+ `-C cwd` 前缀;
|
|
11
|
+
* 默认 timeout 5000ms(opts.timeout 可覆盖),默认 trim(opts.trim:false 保留原样输出)。
|
|
12
|
+
* 数组形式天然规避 shell 拆词,路径含空格 / 元字符安全。
|
|
13
|
+
*/
|
|
14
|
+
import { execFileSync } from 'node:child_process'
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* 安全执行 git:失败不抛,返回 { value, error } 结构。
|
|
18
|
+
* @param {string} cwd
|
|
19
|
+
* @param {string[]} args
|
|
20
|
+
* @param {{ trim?: boolean, timeout?: number }} [opts]
|
|
21
|
+
* - trim:false 保留原样输出(git status --porcelain 首行前导空格
|
|
22
|
+
* 是状态码的一部分,trim 会削掉致 parsePorcelainPath 丢首字符,坑见 auditQuickCompletion 注释)
|
|
23
|
+
* - timeout 默认 5000ms,长操作(大 diff / worktree list)可传更大值
|
|
24
|
+
* @returns {{ value: string|null, error: string|null }}
|
|
25
|
+
*/
|
|
26
|
+
export function safeGit(cwd, args, opts = {}) {
|
|
27
|
+
const { trim = true, timeout = 5000 } = opts
|
|
28
|
+
const fullArgs = ['-c', `safe.directory=${cwd}`, '-C', cwd, ...args]
|
|
29
|
+
try {
|
|
30
|
+
let value = execFileSync('git', fullArgs, { encoding: 'utf8', timeout })
|
|
31
|
+
if (trim) value = value.trim()
|
|
32
|
+
return { value, error: null }
|
|
33
|
+
} catch (e) {
|
|
34
|
+
return { value: null, error: e.message.split('\n')[0] }
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* 抛错版 git:失败抛异常,成功返回 trim 后 string(对齐 worktree 本地 git() 语义)。
|
|
40
|
+
* 调用方自行 catch;不经 shell,参数以数组元素传递。
|
|
41
|
+
* @param {string} cwd
|
|
42
|
+
* @param {string[]} args
|
|
43
|
+
* @param {{ trim?: boolean, timeout?: number }} [opts]
|
|
44
|
+
* @returns {string}
|
|
45
|
+
*/
|
|
46
|
+
export function git(cwd, args, opts = {}) {
|
|
47
|
+
const { trim = true, timeout = 5000 } = opts
|
|
48
|
+
const fullArgs = ['-c', `safe.directory=${cwd}`, '-C', cwd, ...args]
|
|
49
|
+
const value = execFileSync('git', fullArgs, { encoding: 'utf8', timeout })
|
|
50
|
+
return trim ? value.trim() : value
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* 静默版 git:内部调 git,失败 catch 返回 null(对齐 worktree 本地 gitQuiet() 语义)。
|
|
55
|
+
* @param {string} cwd
|
|
56
|
+
* @param {string[]} args
|
|
57
|
+
* @param {{ trim?: boolean, timeout?: number }} [opts]
|
|
58
|
+
* @returns {string|null}
|
|
59
|
+
*/
|
|
60
|
+
export function gitQuiet(cwd, args, opts = {}) {
|
|
61
|
+
try {
|
|
62
|
+
return git(cwd, args, opts)
|
|
63
|
+
} catch {
|
|
64
|
+
return null
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* 纯判断模块,不做实际的 hook 注入。
|
|
6
6
|
*
|
|
7
7
|
* P0 优化:
|
|
8
|
-
* -
|
|
8
|
+
* - 阶段检测:直读 sillyspec.db currentStage(task-10 废 gate-status.json 缓存双源)
|
|
9
9
|
* - 拦截提示针对每个阶段给出具体修复建议
|
|
10
10
|
*/
|
|
11
11
|
|
|
@@ -13,7 +13,6 @@ import { existsSync, readFileSync, readdirSync } from 'fs'
|
|
|
13
13
|
import path from 'path'
|
|
14
14
|
import { execFileSync } from 'child_process'
|
|
15
15
|
import { createRequire } from 'module'
|
|
16
|
-
import { pathToFileURL } from 'url'
|
|
17
16
|
|
|
18
17
|
// ── 常量 ──
|
|
19
18
|
|
|
@@ -89,7 +88,6 @@ function findProjectRoot(cwd) {
|
|
|
89
88
|
let dir = path.resolve(cwd || process.cwd())
|
|
90
89
|
while (true) {
|
|
91
90
|
if (
|
|
92
|
-
existsSync(path.join(dir, '.sillyspec', '.runtime', 'gate-status.json')) ||
|
|
93
91
|
existsSync(path.join(dir, '.sillyspec', '.runtime', 'sillyspec.db')) ||
|
|
94
92
|
existsSync(path.join(dir, '.sillyspec', 'local.yaml')) ||
|
|
95
93
|
existsSync(path.join(dir, '.sillyspec', 'local.yml')) ||
|
|
@@ -224,47 +222,55 @@ function isInsideWorktreeStorage(filePath, cwd) {
|
|
|
224
222
|
return isPathInside(absPath, resolveWorktreeDir(cwd || process.cwd()))
|
|
225
223
|
}
|
|
226
224
|
|
|
227
|
-
/**
|
|
228
|
-
* 读取 gate-status.json
|
|
229
|
-
* @param {string} cwd
|
|
230
|
-
* @returns {{ stage: string, changes?: string[], updatedAt?: string } | null}
|
|
231
|
-
*/
|
|
232
|
-
function readGateStatus(cwd) {
|
|
233
|
-
const p = path.join(cwd, '.sillyspec', '.runtime', 'gate-status.json')
|
|
234
|
-
if (!existsSync(p)) return null
|
|
235
|
-
try {
|
|
236
|
-
return JSON.parse(readFileSync(p, 'utf8'))
|
|
237
|
-
} catch {
|
|
238
|
-
return null
|
|
239
|
-
}
|
|
240
|
-
}
|
|
241
|
-
|
|
242
225
|
/**
|
|
243
226
|
* 同步只读查询 sillyspec.db 第一行第一列(不依赖外部 sqlite3 CLI)。
|
|
244
|
-
*
|
|
245
|
-
*
|
|
246
|
-
*
|
|
227
|
+
*
|
|
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 的过渡态可见性窗口)。
|
|
235
|
+
*
|
|
236
|
+
* 环境假设(R-09):WAL 模式下只读连接仍需在 .runtime/ 目录建/更新 -shm 索引文件,故 .runtime
|
|
237
|
+
* 必须可写(与主进程一致)——“只读”指不改 DB 数据,不等于不对 -shm 加索引。
|
|
238
|
+
*
|
|
239
|
+
* fail-closed:better-sqlite3 resolve 失败 / db 文件不存在 / fileMustExist 打开失败 / 查询异常 /
|
|
240
|
+
* 子进程超时或崩溃——一律 console.warn(含 e.stderr 详情)并返回 null,调用方
|
|
241
|
+
*(readCurrentStage/isNoWorktreeMode)对 null 走 fail-closed,禁止 fail-open。
|
|
247
242
|
*/
|
|
248
243
|
function queryDbFirstCell(cwd, sql) {
|
|
249
244
|
const dbPath = path.join(cwd, '.sillyspec', '.runtime', 'sillyspec.db')
|
|
250
|
-
if (!existsSync(dbPath))
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
245
|
+
if (!existsSync(dbPath)) {
|
|
246
|
+
// db 文件不存在(findProjectRoot 命中 local.yaml/projects 但无 DB,或 DB 被删):fail-closed。
|
|
247
|
+
// warn 让异常(如 DB 误删)可见;正常空态在 findProjectRoot 未命中时根本不会进到这里。
|
|
248
|
+
console.warn('⚠️ sillyspec.db 不存在,hook 降级 fail-closed: ' + dbPath)
|
|
249
|
+
return null
|
|
250
|
+
}
|
|
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)。
|
|
255
260
|
const script =
|
|
256
|
-
"const
|
|
257
|
-
"
|
|
258
|
-
"
|
|
259
|
-
"
|
|
260
|
-
"const db
|
|
261
|
-
"if(
|
|
261
|
+
"const D=require(" + JSON.stringify(libPath) + ")," +
|
|
262
|
+
"dbPath=" + JSON.stringify(dbPath) + ",sql=" + JSON.stringify(sql) + ";" +
|
|
263
|
+
"let db;" +
|
|
264
|
+
"try{db=new D(dbPath,{readonly:true,fileMustExist:true});" +
|
|
265
|
+
"const v=db.prepare(sql).pluck().get();" +
|
|
266
|
+
"if(v!==undefined)process.stdout.write(String(v??''));db.close()}" +
|
|
267
|
+
"catch(e){process.stderr.write('hook db query failed: '+String(e&&e.message||e));process.exit(1)};"
|
|
262
268
|
try {
|
|
263
269
|
return execFileSync(process.execPath, ['-e', script], {
|
|
264
270
|
encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'pipe'],
|
|
265
271
|
}).trim() || null
|
|
266
272
|
} catch (e) {
|
|
267
|
-
// DB 查询失败(db 损坏 /
|
|
273
|
+
// DB 查询失败(db 损坏 / fileMustExist 打开失败 / 子进程超时或崩溃):降级返回 null,调用方
|
|
268
274
|
//(readCurrentStage 等)对 null 走 fail-closed。补 warn 让 doctor/agent 能看到根因,
|
|
269
275
|
// 而非完全静默(子进程把诊断写到了 stderr,execFileSync 抛错时挂在 e.stderr 上)。
|
|
270
276
|
const detail = (e && e.stderr ? String(e.stderr).trim() : '') || (e && e.message) || 'timeout/crash'
|
|
@@ -280,47 +286,48 @@ export {
|
|
|
280
286
|
isSingleCommandReadonly as _isSingleCommandReadonlyForTest,
|
|
281
287
|
};
|
|
282
288
|
/**
|
|
283
|
-
* 从 sillyspec.db 读取 currentStage
|
|
284
|
-
* 优先级:gate-status.json > sillyspec.db
|
|
289
|
+
* 从 sillyspec.db 读取 currentStage(直读 DB,task-10 废 gate-status.json 后为唯一来源)
|
|
285
290
|
* @param {string} cwd
|
|
286
291
|
* @returns {string|null} 阶段名,null 表示无法确定
|
|
287
292
|
*/
|
|
288
293
|
function readCurrentStage(cwd) {
|
|
289
|
-
//
|
|
290
|
-
const gateStatus = readGateStatus(cwd)
|
|
291
|
-
if (gateStatus && gateStatus.stage) return gateStatus.stage
|
|
292
|
-
|
|
293
|
-
// 2. 从 sillyspec.db 读取(node + sql.js 子进程,不依赖外部 sqlite3 CLI——Windows 默认没有)
|
|
294
|
+
// 直读 sillyspec.db(node + better-sqlite3 只读子进程,不依赖外部 sqlite3 CLI——Windows 默认没有)
|
|
294
295
|
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")
|
|
295
296
|
return v || null
|
|
296
297
|
}
|
|
297
298
|
|
|
298
299
|
/**
|
|
299
|
-
* 检查当前变更是否处于 noWorktree
|
|
300
|
+
* 检查当前变更是否处于 noWorktree 模式(直读 sillyspec.db)
|
|
300
301
|
* @param {string} cwd
|
|
301
302
|
* @returns {boolean}
|
|
302
303
|
*/
|
|
303
304
|
function isNoWorktreeMode(cwd) {
|
|
304
|
-
//
|
|
305
|
-
const gateStatus = readGateStatus(cwd)
|
|
306
|
-
if (gateStatus && gateStatus.noWorktree) return true
|
|
307
|
-
|
|
308
|
-
// 2. 从 sillyspec.db 读取(node + sql.js,同 readCurrentStage)
|
|
305
|
+
// 直读 sillyspec.db(node + better-sqlite3,同 readCurrentStage)
|
|
309
306
|
return queryDbFirstCell(cwd, "SELECT no_worktree FROM changes WHERE status='active' AND current_stage IN ('execute','quick') LIMIT 1") === '1'
|
|
310
307
|
}
|
|
311
308
|
|
|
312
309
|
/**
|
|
313
|
-
*
|
|
310
|
+
* 判断路径是否在某已注册 worktree 内
|
|
314
311
|
* @param {string} filePath - 绝对路径
|
|
315
312
|
* @returns {boolean}
|
|
316
313
|
*/
|
|
317
314
|
function isInsideRegisteredWorktree(filePath, cwd) {
|
|
318
315
|
const absPath = path.isAbsolute(filePath) ? filePath : path.resolve(cwd || process.cwd(), filePath)
|
|
319
316
|
const effectiveCwd = cwd || process.cwd()
|
|
320
|
-
const gateStatus = readGateStatus(effectiveCwd)
|
|
321
|
-
const changes = Array.isArray(gateStatus?.changes) ? gateStatus.changes : []
|
|
322
317
|
|
|
323
|
-
|
|
318
|
+
// 已注册 worktree 名单来源:扫描 .runtime/worktrees/ 子目录(task-10 废 gate-status.json 后的权威来源)。
|
|
319
|
+
// queryDbFirstCell 是单行单列,读不了多行 change 名单;目录扫描天然支持多 change 并发,
|
|
320
|
+
// 且 worktree 目录存在性 == 注册态,比 DB changes 表更贴合“已注册 worktree”语义
|
|
321
|
+
// (changes 表可能含 no_worktree 变更,它们没有 worktree 目录)。
|
|
322
|
+
// 跨平台:isPathInside 用 path.sep 判定包含关系,Windows backslash / POSIX slash 均正确。
|
|
323
|
+
const worktreeDir = resolveWorktreeDir(effectiveCwd)
|
|
324
|
+
let changeNames = []
|
|
325
|
+
try {
|
|
326
|
+
const entries = readdirSync(worktreeDir, { withFileTypes: true })
|
|
327
|
+
changeNames = entries.filter(e => e.isDirectory()).map(e => e.name)
|
|
328
|
+
} catch { /* 目录不存在或不可读 → 无注册 worktree */ }
|
|
329
|
+
|
|
330
|
+
for (const changeName of changeNames) {
|
|
324
331
|
const meta = readWorktreeMeta(effectiveCwd, changeName)
|
|
325
332
|
if (meta?.worktreePath && isPathInside(absPath, meta.worktreePath)) return true
|
|
326
333
|
}
|
|
@@ -661,7 +668,7 @@ function readAllQuickGuards(projectRoot) {
|
|
|
661
668
|
* - 除非同时设置 SILLYSPEC_DISABLE_HOOKS=1
|
|
662
669
|
*
|
|
663
670
|
* P0 优化:
|
|
664
|
-
* - 使用 readCurrentStage()
|
|
671
|
+
* - 使用 readCurrentStage() 直读 sillyspec.db 获取阶段
|
|
665
672
|
* - 拦截提示按阶段给出具体修复建议
|
|
666
673
|
*
|
|
667
674
|
* @param {string} filePath - 目标文件绝对路径
|
|
@@ -675,7 +682,7 @@ export function shouldBlockWrite(filePath, cwd) {
|
|
|
675
682
|
const projectRoot = findProjectRoot(callerCwd)
|
|
676
683
|
const absPath = path.isAbsolute(filePath) ? filePath : path.resolve(callerCwd, filePath)
|
|
677
684
|
|
|
678
|
-
// 1.
|
|
685
|
+
// 1. 阶段门禁(直读 sillyspec.db)
|
|
679
686
|
const stage = readCurrentStage(projectRoot) || '(none)'
|
|
680
687
|
|
|
681
688
|
const scanGuardResult = shouldBlockScanDocOverwrite(absPath, projectRoot)
|
|
@@ -694,9 +701,11 @@ export function shouldBlockWrite(filePath, cwd) {
|
|
|
694
701
|
// quick 阶段:检查 baselineFiles(合并所有活跃 session guard 并集,D-002@v1)
|
|
695
702
|
if (stage === 'quick') {
|
|
696
703
|
const { baselineFiles } = readAllQuickGuards(projectRoot)
|
|
697
|
-
|
|
704
|
+
// 归一为 forward-slash:baselineFiles 取自 guard.json(经 parsePorcelainPath 已是 /),
|
|
705
|
+
// Windows 下 path.relative 产 backslash 会与 baseline 不匹配,导致实时防护被静默绕过
|
|
706
|
+
const relTarget = path.relative(projectRoot, absPath).split(path.sep).join('/')
|
|
698
707
|
// 如果目标是 baseline protected file,阻止写入(并集 = 保护所有 session 的 baseline)
|
|
699
|
-
if (baselineFiles.some(f => relTarget === f || relTarget.startsWith(f +
|
|
708
|
+
if (baselineFiles.some(f => relTarget === f || relTarget.startsWith(f + '/'))) {
|
|
700
709
|
return {
|
|
701
710
|
blocked: true,
|
|
702
711
|
reason: [
|
|
@@ -755,7 +764,7 @@ export function shouldBlockBash(command, cwd) {
|
|
|
755
764
|
// cwd 在 worktree 内 → 全部放行
|
|
756
765
|
if (isInsideRegisteredWorktree(callerCwd, projectRoot)) return { blocked: false }
|
|
757
766
|
|
|
758
|
-
//
|
|
767
|
+
// 阶段门禁(直读 sillyspec.db)
|
|
759
768
|
const stage = readCurrentStage(projectRoot) || '(none)'
|
|
760
769
|
|
|
761
770
|
if (!['execute', 'quick'].includes(stage)) {
|