sillyspec 3.26.1 → 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/.sillyspec-platform.json +1 -1
- package/package.json +1 -1
- package/src/classify-change.js +41 -5
- package/src/config-schema.js +287 -0
- package/src/db.js +1 -1
- package/src/hooks/worktree-guard.js +3 -2
- package/src/index.js +41 -4
- package/src/init.js +13 -0
- package/src/run/command.js +4 -2
- package/src/run/prompt.js +9 -3
- package/src/run/quick-audit.js +12 -3
- package/src/run/shared.js +9 -4
- package/src/stages/plan.js +5 -0
- package/src/sync.js +102 -40
package/.sillyspec-platform.json
CHANGED
package/package.json
CHANGED
package/src/classify-change.js
CHANGED
|
@@ -5,6 +5,10 @@
|
|
|
5
5
|
* 供 auto 模式决定内部流程深度。
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import { existsSync, readFileSync } from 'fs'
|
|
9
|
+
import { join } from 'path'
|
|
10
|
+
import jsYaml from 'js-yaml'
|
|
11
|
+
|
|
8
12
|
/**
|
|
9
13
|
* 用户显式指定的关键词 → 强制模式
|
|
10
14
|
*/
|
|
@@ -24,12 +28,36 @@ const FORCE_QUICK_PATTERNS = [
|
|
|
24
28
|
/修复.*\s*\bbug\b.*\bfix\b/i,
|
|
25
29
|
]
|
|
26
30
|
|
|
31
|
+
/**
|
|
32
|
+
* 读 local.yaml 的 auto_mode 段(best-effort,绝不抛)。
|
|
33
|
+
* 供 runCommand 接线:传给 classifyChange 的 localConfig,让 force_*_patterns 真生效。
|
|
34
|
+
* @param {string} cwd - 主仓库根(local.yaml 在 <cwd>/.sillyspec/local.yaml)
|
|
35
|
+
* @returns {{ force_full_patterns?: string[], force_quick_patterns?: string[] } | null}
|
|
36
|
+
* auto_mode 段含至少一个 string pattern → 归一化数组;文件缺/解析失败/无段/空 → null(降级默认关键词)
|
|
37
|
+
*/
|
|
38
|
+
export function readAutoModeFromLocalYaml(cwd) {
|
|
39
|
+
try {
|
|
40
|
+
const p = join(cwd, '.sillyspec', 'local.yaml')
|
|
41
|
+
if (!existsSync(p)) return null
|
|
42
|
+
const doc = jsYaml.load(readFileSync(p, 'utf8'))
|
|
43
|
+
if (!doc || typeof doc !== 'object') return null
|
|
44
|
+
const am = doc.auto_mode
|
|
45
|
+
if (!am || typeof am !== 'object') return null
|
|
46
|
+
const ffp = Array.isArray(am.force_full_patterns) ? am.force_full_patterns.filter(s => typeof s === 'string') : []
|
|
47
|
+
const fqp = Array.isArray(am.force_quick_patterns) ? am.force_quick_patterns.filter(s => typeof s === 'string') : []
|
|
48
|
+
if (ffp.length === 0 && fqp.length === 0) return null
|
|
49
|
+
return { force_full_patterns: ffp, force_quick_patterns: fqp }
|
|
50
|
+
} catch {
|
|
51
|
+
return null
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
27
55
|
/**
|
|
28
56
|
* 分类变更规模
|
|
29
57
|
* @param {object} opts
|
|
30
58
|
* @param {string} opts.description - 用户需求描述
|
|
31
59
|
* @param {string} [opts.explicitMode] - 用户显式指定的模式(auto/quick/full)
|
|
32
|
-
* @param {object} [opts.localConfig] - local.yaml
|
|
60
|
+
* @param {object} [opts.localConfig] - local.yaml auto_mode 段(由 readAutoModeFromLocalYaml 读出)
|
|
33
61
|
* @returns {{ mode: 'quick'|'auto'|'full', reason: string }}
|
|
34
62
|
*/
|
|
35
63
|
export function classifyChange({ description = '', explicitMode, localConfig } = {}) {
|
|
@@ -44,13 +72,21 @@ export function classifyChange({ description = '', explicitMode, localConfig } =
|
|
|
44
72
|
const forceQuickPatterns = localConfig.force_quick_patterns || []
|
|
45
73
|
|
|
46
74
|
for (const pattern of forceFullPatterns) {
|
|
47
|
-
|
|
48
|
-
|
|
75
|
+
try {
|
|
76
|
+
if (new RegExp(pattern, 'i').test(description)) {
|
|
77
|
+
return { mode: 'full', reason: `local.yaml force_full_pattern 匹配: ${pattern}` }
|
|
78
|
+
}
|
|
79
|
+
} catch {
|
|
80
|
+
// 非法正则跳过,不崩 auto 分类(review-2026-08-09 #30)
|
|
49
81
|
}
|
|
50
82
|
}
|
|
51
83
|
for (const pattern of forceQuickPatterns) {
|
|
52
|
-
|
|
53
|
-
|
|
84
|
+
try {
|
|
85
|
+
if (new RegExp(pattern, 'i').test(description)) {
|
|
86
|
+
return { mode: 'quick', reason: `local.yaml force_quick_pattern 匹配: ${pattern}` }
|
|
87
|
+
}
|
|
88
|
+
} catch {
|
|
89
|
+
// 非法正则跳过
|
|
54
90
|
}
|
|
55
91
|
}
|
|
56
92
|
}
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* config-schema.js — local.yaml 配置键的单一数据源。
|
|
3
|
+
*
|
|
4
|
+
* 定位:`.sillyspec/local.yaml` 是 gitignored 的机器+人混写配置,键散落在 ~10 个 reader
|
|
5
|
+
* 里(detect / verify-postcheck / sync / dispatch / worktree-guard / classify-change …),
|
|
6
|
+
* 外部项目的 agent 无从得知有哪些键、哪些生效。本模块把「全部已知键 + 生效状态 + 读取点」
|
|
7
|
+
* 集中成一张表,供:
|
|
8
|
+
* - `sillyspec config schema`(人类可读树 / --json 机读)打印;
|
|
9
|
+
* - `sillyspec init` 调 renderExample() 落盘脱敏 local.yaml.example 给人/外部 agent 看。
|
|
10
|
+
*
|
|
11
|
+
* 单一真相 = LOCAL_YAML_SCHEMA。renderExample() 是策展模板(YAML 形态各异:嵌套 / inline
|
|
12
|
+
* flow / 顶层标量,自动生成不如策展可读),由测试「每个 live 键路径必出现于 example 文本」
|
|
13
|
+
* 耦合保证不漂移——加键忘 example 则测试红。
|
|
14
|
+
*
|
|
15
|
+
* reader 引用一律用符号名(readMcpConfig / extractModules),**不用行号**——行号随重构漂移,
|
|
16
|
+
* 符号名稳定可 grep。
|
|
17
|
+
*
|
|
18
|
+
* status 语义:
|
|
19
|
+
* - live 真有 reader 从 local.yaml 读并起效(配了即生效)。
|
|
20
|
+
* - declared 代码/JSDoc/prompt 声明过,但无 reader 真读 local.yaml(或调用方未传),配了不生效。
|
|
21
|
+
* 诚实标出,避免 agent 白配 + 暴露待接线债。
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @typedef {Object} SchemaKey
|
|
26
|
+
* @property {string} path 点分路径(mcp.url / worktree-hook.readonlyCommands)
|
|
27
|
+
* @property {string} type string|enum|integer|object|array|scalar
|
|
28
|
+
* @property {string[]} [values] enum 取值
|
|
29
|
+
* @property {boolean} [required] 凭据类必填(两键齐全才视为有效源)
|
|
30
|
+
* @property {boolean} [secret] 敏感凭据,example 用占位符
|
|
31
|
+
* @property {boolean} [optional] 可选键
|
|
32
|
+
* @property {string} status live|declared
|
|
33
|
+
* @property {string[]} readers 读取点符号名(稳定可 grep),declared 键留空数组
|
|
34
|
+
* @property {string} desc 一句话说明
|
|
35
|
+
* @property {string} example 脱敏示例值(secret 键用占位符)
|
|
36
|
+
*
|
|
37
|
+
* @typedef {Object} SchemaSection
|
|
38
|
+
* @property {string} id
|
|
39
|
+
* @property {string} title
|
|
40
|
+
* @property {string} note 段说明(写入来源 / 注意事项)
|
|
41
|
+
* @property {SchemaKey[]} keys
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
/** @type {{ file: string, note: string, sections: SchemaSection[] }} */
|
|
45
|
+
export const LOCAL_YAML_SCHEMA = {
|
|
46
|
+
file: '.sillyspec/local.yaml',
|
|
47
|
+
note: 'gitignored(含敏感凭据勿提交);脱敏示例 local.yaml.example 由 sillyspec init 生成(可提交)。',
|
|
48
|
+
sections: [
|
|
49
|
+
{
|
|
50
|
+
id: 'project',
|
|
51
|
+
title: '项目类型',
|
|
52
|
+
note: '由 sillyspec local detect 纯 fs 嗅探写入,一般无需手填。',
|
|
53
|
+
keys: [
|
|
54
|
+
{
|
|
55
|
+
path: 'project.type',
|
|
56
|
+
type: 'enum',
|
|
57
|
+
values: ['nodejs', 'maven', 'gradle', 'make', 'generic'],
|
|
58
|
+
status: 'live',
|
|
59
|
+
readers: ['detectLocalYaml (src/local-detect.js)', 'extractProjectType (src/worktree-deps.js)'],
|
|
60
|
+
desc: '项目类型,决定默认 commands 嗅探与 worktree 依赖策略。',
|
|
61
|
+
example: 'nodejs',
|
|
62
|
+
},
|
|
63
|
+
],
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
id: 'commands',
|
|
67
|
+
title: '构建/测试/lint 命令',
|
|
68
|
+
note: 'detect 核验 package.json(或对应构建文件) scripts 存在性后才写对应键;缺失则不写。agent 可改。',
|
|
69
|
+
keys: [
|
|
70
|
+
{ path: 'commands.build', type: 'string', optional: true, status: 'live', readers: ['detectLocalYaml (src/local-detect.js)', 'validateCommands (src/scan-postcheck.js)'], desc: '构建命令。', example: 'npm run build' },
|
|
71
|
+
{ path: 'commands.test', type: 'string', optional: true, status: 'live', readers: ['extractTestCommand (src/verify-postcheck.js)', 'validateCommands (src/scan-postcheck.js)'], desc: '测试命令——verify 阶段 CLI 亲自执行此命令与 verify-result.md 对账,实测失败即阻断。', example: 'npm test' },
|
|
72
|
+
{ path: 'commands.lint', type: 'string', optional: true, status: 'live', readers: ['detectLocalYaml (src/local-detect.js)', 'validateCommands (src/scan-postcheck.js)'], desc: 'lint 命令。', example: 'npm run lint' },
|
|
73
|
+
{ path: 'commands.install', type: 'string', optional: true, status: 'live', readers: ['extractInstallCommand (src/worktree-deps.js)'], desc: '依赖安装命令(worktree 依赖 provisioning 用)。', example: 'npm install' },
|
|
74
|
+
],
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
id: 'mcp',
|
|
78
|
+
title: 'SillyHub MCP 客户端凭据(派发用)',
|
|
79
|
+
note: 'agent 手填,或 sillyspec platform connect 在 mcp 段缺失时同源自动填(§7.4)。两键齐全才生效,否则回退 env SILLYHUB_MCP_URL/TOKEN。',
|
|
80
|
+
keys: [
|
|
81
|
+
{ path: 'mcp.url', type: 'string', required: true, secret: true, status: 'live', readers: ['readMcpConfig (src/sillyhub-mcp/config.js)'], desc: 'SillyHub MCP 服务地址(尾斜杠自动归一)。', example: 'https://your-sillyhub.example.com' },
|
|
82
|
+
{ path: 'mcp.token', type: 'string', required: true, secret: true, status: 'live', readers: ['readMcpConfig (src/sillyhub-mcp/config.js)'], desc: 'SillyHub MCP 认证 token。', example: '<your-mcp-token>' },
|
|
83
|
+
],
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
id: 'platform',
|
|
87
|
+
title: 'SillyHub 平台同步',
|
|
88
|
+
note: 'sillyspec platform connect 写入(含 ping 验活);手填亦可。user/last_connected 可空或自动写。',
|
|
89
|
+
keys: [
|
|
90
|
+
{ path: 'platform.url', type: 'string', required: true, secret: true, status: 'live', readers: ['SyncManager._getPlatform (src/sync.js)'], desc: 'SillyHub 平台地址。', example: 'https://your-sillyhub.example.com' },
|
|
91
|
+
{ path: 'platform.token', type: 'string', required: true, secret: true, status: 'live', readers: ['SyncManager._getPlatform (src/sync.js)'], desc: '平台认证 token(Bearer)。', example: '<your-platform-token>' },
|
|
92
|
+
{ path: 'platform.user', type: 'string', optional: true, status: 'live', readers: ['resolvePlatformUser / SyncManager.connect (src/sync.js)'], desc: '推送者身份;缺则回退 git user.name / env。推送时进 X-SillySpec-User header。', example: 'alice' },
|
|
93
|
+
{ path: 'platform.last_connected', type: 'string', optional: true, status: 'live', readers: ['SyncManager.connect (src/sync.js)'], desc: 'connect 成功时间戳(ISO),自动写,勿手填。', example: '2026-08-11T00:00:00.000Z' },
|
|
94
|
+
],
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
id: 'dispatch',
|
|
98
|
+
title: '派发调参',
|
|
99
|
+
note: '仅在确知调优值时填,否则留默认。',
|
|
100
|
+
keys: [
|
|
101
|
+
{ path: 'dispatch.probe_ttl_ms', type: 'integer', optional: true, status: 'live', readers: ['readProbeTtlFromLocalYaml (src/dispatch/probe.js)'], desc: '派发探测负面缓存 TTL(毫秒,默认 60000)。daemon 抖动期避免反复探测。', example: '60000' },
|
|
102
|
+
{ path: 'dispatch.poll_interval_ms', type: 'integer', optional: true, status: 'declared', readers: [], desc: '【路径A 预留·未落地】renderSillyHubInstruction 注入的轮询文本提及,但 isPathASupported()=false 该指令当前不注入;路径A 落地后接线,配了暂不生效。', example: '15000' },
|
|
103
|
+
{ path: 'dispatch.worker_timeout_ms', type: 'integer', optional: true, status: 'declared', readers: [], desc: '【路径A 预留·未落地】同 poll_interval_ms,路径A 落地后接线,配了暂不生效。', example: '60000' },
|
|
104
|
+
],
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
id: 'modules',
|
|
108
|
+
title: 'monorepo 子模块映射',
|
|
109
|
+
note: 'test_strategy: module 时,按 git diff 命中的模块子集收窄测试。只支持 inline flow 形态(extractModules 不解析嵌套展开式)。',
|
|
110
|
+
keys: [
|
|
111
|
+
{ path: 'modules.<name>.path', type: 'string', optional: true, status: 'live', readers: ['extractModules (src/verify-postcheck.js)', 'parseLocalYamlModules (src/plan-postcheck.js)', 'extractModulePaths (src/worktree-deps.js)'], desc: '子模块目录路径(相对仓库根)。', example: 'frontend/' },
|
|
112
|
+
{ path: 'modules.<name>.test', type: 'string', optional: true, status: 'live', readers: ['extractModules (src/verify-postcheck.js)'], desc: '该子模块的测试命令。', example: 'cd frontend && pnpm test' },
|
|
113
|
+
],
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
id: 'test_strategy',
|
|
117
|
+
title: '测试策略',
|
|
118
|
+
note: 'verify 阶段 CLI 对账的收窄策略。',
|
|
119
|
+
keys: [
|
|
120
|
+
{ path: 'test_strategy', type: 'enum', values: ['full', 'module'], optional: true, status: 'live', readers: ['extractTestStrategy (src/verify-postcheck.js)'], desc: 'full=全量 commands.test;module=按 git diff 命中 modules 子集收窄(需配 modules)。缺省=全量。', example: 'full' },
|
|
121
|
+
],
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
id: 'known_failures',
|
|
125
|
+
title: '预存失败豁免清单',
|
|
126
|
+
note: '变更前就失败的测试行,豁免后不计入 verify 阻断。支持块列表或 inline 数组。务必定期复核,避免误豁免本变更引入的真实失败。',
|
|
127
|
+
keys: [
|
|
128
|
+
{ path: 'known_failures', type: 'array', optional: true, status: 'live', readers: ['extractKnownFailures (src/verify-postcheck.js)'], desc: '失败行模式列表(匹配测试输出失败行)。', example: 'tests/some-old-test' },
|
|
129
|
+
],
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
id: 'worktree-hook',
|
|
133
|
+
title: 'worktree-guard 扩展',
|
|
134
|
+
note: 'execute worktree 隔离期内放行的额外只读命令。键名用 camelCase(parseSimpleYaml 原样保留)。',
|
|
135
|
+
keys: [
|
|
136
|
+
{ path: 'worktree-hook.readonlyCommands', type: 'array', optional: true, status: 'live', readers: ['loadLocalConfig (src/hooks/worktree-guard.js)'], desc: '额外放行的只读命令名列表(如 rg/fd),绕过 worktree-guard 写操作拦截。', example: 'rg' },
|
|
137
|
+
],
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
id: 'auto_mode',
|
|
141
|
+
title: '变更规模自动分类',
|
|
142
|
+
note: 'sillyspec run auto 时,readAutoModeFromLocalYaml 读本段传 classifyChange 的 localConfig,force_*_patterns 匹配需求描述则强制对应模式。',
|
|
143
|
+
keys: [
|
|
144
|
+
{ path: 'auto_mode.force_full_patterns', type: 'array', optional: true, status: 'live', readers: ['readAutoModeFromLocalYaml + classifyChange (src/classify-change.js)', 'runCommand (src/run/command.js)'], desc: '需求描述匹配任一正则(i 大小写无关)→ 强制 full 流程。非法正则跳过不崩。', example: '数据库|migration' },
|
|
145
|
+
{ path: 'auto_mode.force_quick_patterns', type: 'array', optional: true, status: 'live', readers: ['readAutoModeFromLocalYaml + classifyChange (src/classify-change.js)', 'runCommand (src/run/command.js)'], desc: '需求描述匹配任一正则 → 强制 quick。非法正则跳过不崩。', example: 'fix typo' },
|
|
146
|
+
],
|
|
147
|
+
},
|
|
148
|
+
],
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
/** 全部键(拍平),便于 JSON 输出与 example 耦合测试。 */
|
|
152
|
+
export function flatKeys() {
|
|
153
|
+
return LOCAL_YAML_SCHEMA.sections.flatMap((s) => s.keys.map((k) => ({ section: s.id, ...k })));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** 人类可读树形 schema 文本。live 在前、declared 在后,段内逐键列 path/type/约束/读取点/说明。 */
|
|
157
|
+
export function renderSchemaHuman() {
|
|
158
|
+
const L = [];
|
|
159
|
+
L.push('SillySpec local.yaml 配置清单');
|
|
160
|
+
L.push(`文件:${LOCAL_YAML_SCHEMA.file} — ${LOCAL_YAML_SCHEMA.note}`);
|
|
161
|
+
L.push('数据源:src/config-schema.js(本表唯一真相;reader 见各键「读取点」,符号名可 grep)');
|
|
162
|
+
L.push('');
|
|
163
|
+
|
|
164
|
+
const all = flatKeys();
|
|
165
|
+
const live = all.filter((k) => k.status === 'live');
|
|
166
|
+
const declared = all.filter((k) => k.status === 'declared');
|
|
167
|
+
|
|
168
|
+
L.push(`━━━ 生效(${live.length} 键,配了即生效) ━━━`);
|
|
169
|
+
L.push('');
|
|
170
|
+
for (const s of LOCAL_YAML_SCHEMA.sections) {
|
|
171
|
+
const keys = s.keys.filter((k) => k.status === 'live');
|
|
172
|
+
if (keys.length === 0) continue;
|
|
173
|
+
L.push(`[${s.id}] ${s.title}`);
|
|
174
|
+
L.push(` ${s.note}`);
|
|
175
|
+
for (const k of keys) {
|
|
176
|
+
L.push(` ${k.path}${formatConstraint(k)} ${k.desc}`);
|
|
177
|
+
if (k.readers.length) L.push(` 读取点:${k.readers.join(';')}`);
|
|
178
|
+
}
|
|
179
|
+
L.push('');
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
L.push(`━━━ 声明但未接线(${declared.length} 键,配了不生效 / 待接线债) ━━━`);
|
|
183
|
+
L.push('');
|
|
184
|
+
for (const s of LOCAL_YAML_SCHEMA.sections) {
|
|
185
|
+
const keys = s.keys.filter((k) => k.status === 'declared');
|
|
186
|
+
if (keys.length === 0) continue;
|
|
187
|
+
L.push(`[${s.id}] ${s.title}`);
|
|
188
|
+
L.push(` ${s.note}`);
|
|
189
|
+
for (const k of keys) {
|
|
190
|
+
L.push(` ${k.path}${formatConstraint(k)} ${k.desc}`);
|
|
191
|
+
}
|
|
192
|
+
L.push('');
|
|
193
|
+
}
|
|
194
|
+
return L.join('\n');
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function formatConstraint(k) {
|
|
198
|
+
const tags = [];
|
|
199
|
+
if (k.required) tags.push('必填');
|
|
200
|
+
if (k.optional) tags.push('可选');
|
|
201
|
+
if (k.secret) tags.push('凭据');
|
|
202
|
+
if (k.type === 'enum' && k.values) tags.push(k.values.join('|'));
|
|
203
|
+
else tags.push(k.type);
|
|
204
|
+
return tags.length ? ` [${tags.join(' · ')}]` : '';
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** 机读 JSON(程序化消费)。结构 = LOCAL_YAML_SCHEMA 本体 + flatKeys 视图。 */
|
|
208
|
+
export function renderSchemaJson() {
|
|
209
|
+
return JSON.stringify({
|
|
210
|
+
file: LOCAL_YAML_SCHEMA.file,
|
|
211
|
+
note: LOCAL_YAML_SCHEMA.note,
|
|
212
|
+
sections: LOCAL_YAML_SCHEMA.sections,
|
|
213
|
+
keys: flatKeys().map(({ section, path, type, values, required, optional, secret, status, readers, desc }) => ({
|
|
214
|
+
section, path, type, values, required, optional, secret, status, readers, desc,
|
|
215
|
+
})),
|
|
216
|
+
}, null, 2);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* 脱敏 local.yaml 示例文本(sillyspec init 落盘用)。
|
|
221
|
+
* 策展模板(非自动生成):YAML 形态各异(嵌套/inline flow/顶层标量),策展可读性远胜自动拼。
|
|
222
|
+
* 与 SCHEMA 的耦合由测试保证:每个 live 键路径必出现于本文本。
|
|
223
|
+
*/
|
|
224
|
+
export function renderExample() {
|
|
225
|
+
return `# SillySpec local.yaml — 脱敏示例(由 sillyspec init 生成,可提交)
|
|
226
|
+
# 真实配置文件 .sillyspec/local.yaml 是 gitignored(含敏感凭据)。
|
|
227
|
+
# 用法:复制本文件为 .sillyspec/local.yaml,填真实值;或直接 sillyspec platform connect 自动写凭据段。
|
|
228
|
+
# 全部键与生效状态:sillyspec config schema
|
|
229
|
+
|
|
230
|
+
# ── 项目类型(sillyspec local detect 自动写入,一般无需手填)──
|
|
231
|
+
project:
|
|
232
|
+
type: nodejs # nodejs | maven | gradle | make | generic
|
|
233
|
+
|
|
234
|
+
# ── 构建/测试/lint 命令(detect 核验 scripts 存在性后才写;缺失键省略)──
|
|
235
|
+
commands:
|
|
236
|
+
build: npm run build
|
|
237
|
+
test: npm test
|
|
238
|
+
lint: npm run lint
|
|
239
|
+
# install: npm install # worktree 依赖安装命令
|
|
240
|
+
|
|
241
|
+
# ── SillyHub MCP 客户端凭据(派发到 worker 用)──
|
|
242
|
+
# agent 手填,或 sillyspec platform connect 在 mcp 段缺失时同源自动填。
|
|
243
|
+
# 两键齐全才生效;缺则回退环境变量 SILLYHUB_MCP_URL / SILLYHUB_MCP_TOKEN。
|
|
244
|
+
mcp:
|
|
245
|
+
url: https://your-sillyhub.example.com
|
|
246
|
+
token: <your-mcp-token>
|
|
247
|
+
|
|
248
|
+
# ── SillyHub 平台同步(sillyspec platform connect 写入,含 ping 验活)──
|
|
249
|
+
platform:
|
|
250
|
+
url: https://your-sillyhub.example.com
|
|
251
|
+
token: <your-platform-token>
|
|
252
|
+
user: alice # 推送者身份(可空,回退 git user.name / env)
|
|
253
|
+
# last_connected: 2026-08-11T00:00:00.000Z # connect 自动写,勿手填
|
|
254
|
+
|
|
255
|
+
# ── 派发调参(仅确知调优值时填,否则留默认)──
|
|
256
|
+
dispatch:
|
|
257
|
+
probe_ttl_ms: 60000 # 派发探测负面缓存 TTL(默认 60000)
|
|
258
|
+
# poll_interval_ms: 15000 # ⚠ 路径A 预留·未落地,配了暂不生效
|
|
259
|
+
# worker_timeout_ms: 60000 # ⚠ 路径A 预留·未落地,配了暂不生效
|
|
260
|
+
|
|
261
|
+
# ── monorepo 子模块映射(test_strategy: module 时按 git diff 命中模块收窄测试)──
|
|
262
|
+
# 只支持 inline flow 形态(嵌套展开式解析不出):
|
|
263
|
+
modules:
|
|
264
|
+
frontend: { path: "frontend/", test: "cd frontend && pnpm test" }
|
|
265
|
+
backend: { path: "backend/", test: "cd backend && npm test" }
|
|
266
|
+
|
|
267
|
+
# ── 测试策略:full=全量 commands.test | module=按命中模块收窄(需配 modules)──
|
|
268
|
+
test_strategy: full
|
|
269
|
+
|
|
270
|
+
# ── 预存失败豁免清单(变更前就失败的测试行;务必定期复核,防误豁免真实失败)──
|
|
271
|
+
known_failures:
|
|
272
|
+
- tests/some-old-stable-failure
|
|
273
|
+
|
|
274
|
+
# ── worktree-guard 只读命令扩展(execute worktree 隔离期内放行额外只读命令)──
|
|
275
|
+
worktree-hook:
|
|
276
|
+
readonlyCommands:
|
|
277
|
+
- rg
|
|
278
|
+
- fd
|
|
279
|
+
|
|
280
|
+
# ── 变更规模自动分类(sillyspec run auto 时按需求描述强制 quick/full;正则数组,i 大小写无关)──
|
|
281
|
+
auto_mode:
|
|
282
|
+
force_full_patterns:
|
|
283
|
+
- 数据库|migration
|
|
284
|
+
force_quick_patterns:
|
|
285
|
+
- fix typo
|
|
286
|
+
`;
|
|
287
|
+
}
|
package/src/db.js
CHANGED
|
@@ -202,7 +202,7 @@ export class DB {
|
|
|
202
202
|
CREATE TABLE IF NOT EXISTS project (
|
|
203
203
|
id INTEGER PRIMARY KEY DEFAULT 1,
|
|
204
204
|
name TEXT NOT NULL,
|
|
205
|
-
schema_version INTEGER DEFAULT
|
|
205
|
+
schema_version INTEGER DEFAULT 5,
|
|
206
206
|
created_at TEXT NOT NULL,
|
|
207
207
|
updated_at TEXT NOT NULL
|
|
208
208
|
)
|
|
@@ -356,9 +356,10 @@ function matchFileWhitelist(filePath) {
|
|
|
356
356
|
}
|
|
357
357
|
|
|
358
358
|
/**
|
|
359
|
-
*
|
|
359
|
+
* 读取并解析 local.yaml(轻量手写解析,返回顶层对象)。
|
|
360
|
+
* 消费方读嵌套段 worktree-hook.readonlyCommands(见 isSingleCommandReadonly 的 extraReadonlyCommands)。
|
|
360
361
|
* @param {string} cwd
|
|
361
|
-
* @returns {
|
|
362
|
+
* @returns {Record<string, any>} 解析后的 local.yaml 顶层对象(文件缺/解析失败 → {})
|
|
362
363
|
*/
|
|
363
364
|
function loadLocalConfig(cwd) {
|
|
364
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('#
|
|
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) {
|
package/src/run/command.js
CHANGED
|
@@ -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
|
-
|
|
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 || '需求'}"`)
|
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 {
|
|
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 })
|
package/src/run/quick-audit.js
CHANGED
|
@@ -24,9 +24,18 @@ export function printQuickAuditReview(review) {
|
|
|
24
24
|
console.error(`\n quick 已停止:请恢复/拆分这些变更,或重新运行 quick 并显式声明范围。`)
|
|
25
25
|
// 解锁咒语须在 BLOCKED 分支也给出 —— 原只在 confirm/review 分支打印,
|
|
26
26
|
// 普通 --done 走这里只看到「恢复/拆分」反方向指引,正确解法(带 flag 重跑)根本不出现。
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
27
|
+
// 但删除是例外:deletedFiles.length>0 是裸判定(auditQuickCompletion 判定行不受任何 flag
|
|
28
|
+
// 门控),--force-baseline / --allow-new 都救不回。此时甩 flag 建议会误导调用者照加后继续
|
|
29
|
+
// 被拦——改提示走完整流程 execute(有意设计:删除是高危操作,须 review 把关)。
|
|
30
|
+
if (review.deletedFiles && review.deletedFiles.length > 0) {
|
|
31
|
+
console.error(`\n ⛔ 本次含删除文件 ${review.deletedFiles.length} 个:${review.deletedFiles.join(', ')}`)
|
|
32
|
+
console.error(` quick 不支持删除——--force-baseline / --allow-new 均无法解锁(有意设计,高危操作须走 review 把关)。`)
|
|
33
|
+
console.error(` 请改走完整流程:sillyspec run execute(在 plan 的 task allowed_paths 内删除),或先 git restore 撤回删除后再 --done。`)
|
|
34
|
+
} else {
|
|
35
|
+
console.error(` 如确认接受这些变更,重新运行 --done 时带上对应 flag 即可解锁:`)
|
|
36
|
+
console.error(` sillyspec run quick --done --force-baseline --allow-new --change <id> --output "..."`)
|
|
37
|
+
console.error(` (--force-baseline 覆盖受保护/危险文件如 src/run.js;--allow-new 允许新增文件)`)
|
|
38
|
+
}
|
|
30
39
|
} else if (review.status === 'warning') {
|
|
31
40
|
console.warn(`\n⚠️ quick 变更边界审计 — WARNING:`)
|
|
32
41
|
for (const r of review.reasons) {
|
package/src/run/shared.js
CHANGED
|
@@ -655,10 +655,15 @@ export async function auditQuickCompletion(cwd, guard, options = {}) {
|
|
|
655
655
|
console.log(` - ${r}`)
|
|
656
656
|
}
|
|
657
657
|
}
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
658
|
+
if (result.deletedFiles.length > 0) {
|
|
659
|
+
console.log(`\n ⛔ 本次含删除文件 ${result.deletedFiles.length} 个——quick 不支持删除,--force-baseline / --allow-new 均无法解锁(有意设计,高危操作须走 review 把关)。`)
|
|
660
|
+
console.log(` 请改走完整流程:sillyspec run execute(在 plan 的 task allowed_paths 内删除),或先 git restore 撤回删除后再 --done。`)
|
|
661
|
+
} else {
|
|
662
|
+
console.log(`\n 如确认接受这些变更,重新运行 --done 时带上对应 flag 即可解锁:`)
|
|
663
|
+
console.log(` sillyspec run quick --done --force-baseline --allow-new --change <id> --output "..."`)
|
|
664
|
+
console.log(` (--force-baseline 覆盖受保护/危险文件如 src/run.js;--allow-new 允许新增文件)`)
|
|
665
|
+
console.log(` 或在首个 sillyspec run quick 启动(step 1)时就声明这些 flag,持久化进 guard。`)
|
|
666
|
+
}
|
|
662
667
|
}
|
|
663
668
|
} catch (e) {
|
|
664
669
|
result.reasons.push(`审计失败: ${e.message}`)
|
package/src/stages/plan.js
CHANGED
|
@@ -425,6 +425,11 @@ related_tests: # 可选。当本 task 改动会导致
|
|
|
425
425
|
|
|
426
426
|
const prompt = `为 plan.md 中的每个任务生成紧凑 TaskCard。
|
|
427
427
|
|
|
428
|
+
⚠️ 生成卡片前先确认 plan.md 已满足(否则下一步 postcheck 会硬拦,导致返工重编号/重分 Wave):
|
|
429
|
+
- **共享文件须分 Wave**:若多个 task 的 allowed_path 含同一文件,plan.md 必须把它们分到不同「## Wave N」(同 Wave 共享文件会被 execute 强制并行,子代理互相覆盖;postcheck 拦同 Wave 共享)
|
|
430
|
+
- **task id 从 1 连续**:task-01、task-02、task-03… 不能跳号或重号(postcheck 校验 id 连续性,gap 会拦)
|
|
431
|
+
这两条是跨 task 全局约束,子代理只写单卡看不到全局——你(主 agent)分派子代理前必须先在 plan.md 里确认 Wave 划分与编号正确。
|
|
432
|
+
|
|
428
433
|
## 任务清单
|
|
429
434
|
${taskList}
|
|
430
435
|
|
package/src/sync.js
CHANGED
|
@@ -40,8 +40,13 @@ const DOCUMENT_FILES = ['proposal.md', 'design.md', 'requirements.md', 'tasks.md
|
|
|
40
40
|
// ── YAML 辅助 ──
|
|
41
41
|
|
|
42
42
|
/**
|
|
43
|
-
* 简易 YAML
|
|
43
|
+
* 简易 YAML 读取(解析为扁平 obj),供 _getPlatform / status 只读用。
|
|
44
44
|
* 与 worktree-guard.js 的 parseSimpleYaml 保持一致的轻量风格。
|
|
45
|
+
*
|
|
46
|
+
* 写入侧(connect/disconnect)**不再**走 parse→modify→write 有损往返——
|
|
47
|
+
* readLocalYaml/parseSimpleYaml 丢注释(:85 跳 # 行)、丢数组/深嵌套(只认一层 key:value),
|
|
48
|
+
* round-trip 会清空用户手填的注释与其他段。改为 readLocalYamlRaw + replaceTopLevelSection
|
|
49
|
+
* 文本级定向替换 platform 段,原文件注释/空行/其他段/数组/深嵌套/CRLF 字节级保留。
|
|
45
50
|
*/
|
|
46
51
|
function readLocalYaml(cwd) {
|
|
47
52
|
const p = join(cwd, LOCAL_YAML);
|
|
@@ -53,28 +58,81 @@ function readLocalYaml(cwd) {
|
|
|
53
58
|
}
|
|
54
59
|
}
|
|
55
60
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
61
|
+
/** 读 local.yaml 原始文本(保留注释/换行/结构),不存在返回 ''。供 connect/disconnect 文本级改写。 */
|
|
62
|
+
function readLocalYamlRaw(cwd) {
|
|
63
|
+
const p = join(cwd, LOCAL_YAML);
|
|
64
|
+
if (!existsSync(p)) return '';
|
|
65
|
+
try {
|
|
66
|
+
return readFileSync(p, 'utf8');
|
|
67
|
+
} catch {
|
|
68
|
+
return '';
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* 定位顶层 YAML 段(如 platform:/mcp:)的行范围 [start, end)。
|
|
74
|
+
* 段 = key 行(行首非空白 + 以 'name:' 开头)+ 后续连续缩进行(以空格/tab 开头)。
|
|
75
|
+
* 遇空行/注释/下一个顶层 key 即段结束——这些行不属于本段,保留不动。
|
|
76
|
+
* split('\n')/join('\n') 操作:CRLF 下 '\r' 留在行尾,重组原样还原(Windows 兼容,CLAUDE.md #13)。
|
|
77
|
+
* @param {string} text
|
|
78
|
+
* @param {string} name - 段名(不含冒号,如 'platform')
|
|
79
|
+
* @returns {{start: number, end: number} | null} 不存在返回 null
|
|
80
|
+
*/
|
|
81
|
+
function findTopLevelSectionRange(text, name) {
|
|
82
|
+
const lines = text.split('\n');
|
|
83
|
+
const prefix = `${name}:`;
|
|
84
|
+
let start = -1;
|
|
85
|
+
for (let i = 0; i < lines.length; i++) {
|
|
86
|
+
const line = lines[i];
|
|
87
|
+
// 顶层 key 行:行首非空白(排除缩进子段)+ 以 'name:' 开头
|
|
88
|
+
if (/^\S/.test(line) && line.startsWith(prefix)) {
|
|
89
|
+
start = i;
|
|
90
|
+
break;
|
|
75
91
|
}
|
|
76
92
|
}
|
|
77
|
-
|
|
93
|
+
if (start === -1) return null;
|
|
94
|
+
let end = start + 1;
|
|
95
|
+
while (end < lines.length && /^[ \t]/.test(lines[end])) {
|
|
96
|
+
end++;
|
|
97
|
+
}
|
|
98
|
+
return { start, end };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* 文本级定向替换/删除/追加一个顶层 YAML 段,保留文件其余所有字节
|
|
103
|
+
* (注释/空行/其他段/数组/深嵌套/CRLF 全保留)。
|
|
104
|
+
* @param {string} text - 原始文件文本
|
|
105
|
+
* @param {string} name - 段名(不含冒号)
|
|
106
|
+
* @param {string|null} body - 段体(不含 key 行);null=删除该段;string=替换或追加
|
|
107
|
+
* @returns {string} 新文本
|
|
108
|
+
*/
|
|
109
|
+
function replaceTopLevelSection(text, name, body) {
|
|
110
|
+
const lines = text.split('\n');
|
|
111
|
+
const range = findTopLevelSectionRange(text, name);
|
|
112
|
+
if (range) {
|
|
113
|
+
const before = lines.slice(0, range.start);
|
|
114
|
+
const after = lines.slice(range.end);
|
|
115
|
+
if (body === null) {
|
|
116
|
+
return [...before, ...after].join('\n');
|
|
117
|
+
}
|
|
118
|
+
const sectionLines = [`${name}:`, ...body.split('\n')];
|
|
119
|
+
return [...before, ...sectionLines, ...after].join('\n');
|
|
120
|
+
}
|
|
121
|
+
// 段不存在
|
|
122
|
+
if (body === null) return text; // 删不存在的段,原样返回
|
|
123
|
+
// 追加:去尾换行后加空行分隔 + 新段;空文件直接起段
|
|
124
|
+
const stripped = text.replace(/(\r?\n)+$/, '');
|
|
125
|
+
if (stripped === '') {
|
|
126
|
+
return `${name}:\n${body}\n`;
|
|
127
|
+
}
|
|
128
|
+
return `${stripped}\n\n${name}:\n${body}\n`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** 文本级写 local.yaml(保留传入 text 的注释/结构);确保 .sillyspec 目录存在。 */
|
|
132
|
+
function writeLocalYamlRaw(cwd, text) {
|
|
133
|
+
const dir = join(cwd, '.sillyspec');
|
|
134
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
135
|
+
writeFileSync(join(cwd, LOCAL_YAML), text, 'utf8');
|
|
78
136
|
}
|
|
79
137
|
|
|
80
138
|
function parseSimpleYaml(content) {
|
|
@@ -201,7 +259,8 @@ export class SyncManager {
|
|
|
201
259
|
*/
|
|
202
260
|
async connect(url, token, user) {
|
|
203
261
|
// 验证连接
|
|
204
|
-
const
|
|
262
|
+
const normalizedUrl = url.replace(/\/+$/, '');
|
|
263
|
+
const healthUrl = `${normalizedUrl}/api/health`;
|
|
205
264
|
const result = await fetchJson(healthUrl);
|
|
206
265
|
if (result === null) {
|
|
207
266
|
console.warn(`[sync] 平台连接验证失败: ${url}`);
|
|
@@ -212,21 +271,23 @@ export class SyncManager {
|
|
|
212
271
|
// 解析推送者身份(D-004):显式 > git user.name > env;全失败留空不写 user 字段
|
|
213
272
|
const resolvedUser = resolvePlatformUser(this.cwd, user);
|
|
214
273
|
|
|
215
|
-
//
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
url:
|
|
219
|
-
token
|
|
220
|
-
last_connected: new Date().toISOString()
|
|
221
|
-
|
|
274
|
+
// 文本级改写 local.yaml:定向替换 platform 段,原文件注释/其他段/数组/深嵌套/CRLF 字节级保留
|
|
275
|
+
// (旧 read-modify-write 经 parseSimpleYaml 丢注释+丢非扁平结构,round-trip 清空用户手填内容)。
|
|
276
|
+
const platformEntries = [
|
|
277
|
+
` url: ${normalizedUrl}`,
|
|
278
|
+
` token: ${token}`,
|
|
279
|
+
` last_connected: ${new Date().toISOString()}`,
|
|
280
|
+
];
|
|
222
281
|
if (resolvedUser) {
|
|
223
|
-
|
|
282
|
+
platformEntries.push(` user: ${resolvedUser}`);
|
|
224
283
|
}
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
284
|
+
let text = readLocalYamlRaw(this.cwd);
|
|
285
|
+
text = replaceTopLevelSection(text, 'platform', platformEntries.join('\n'));
|
|
286
|
+
// mcp 段同源假设(design §7.4):复用 platform 的 url/token;用户已手填 mcp 段则保留不覆盖(R-09,文本级检测)
|
|
287
|
+
if (findTopLevelSectionRange(text, 'mcp') === null) {
|
|
288
|
+
text = replaceTopLevelSection(text, 'mcp', ` url: ${normalizedUrl}\n token: ${token}`);
|
|
228
289
|
}
|
|
229
|
-
|
|
290
|
+
writeLocalYamlRaw(this.cwd, text);
|
|
230
291
|
}
|
|
231
292
|
|
|
232
293
|
/**
|
|
@@ -239,17 +300,18 @@ export class SyncManager {
|
|
|
239
300
|
console.log('[sync] 已断开连接(无配置文件)');
|
|
240
301
|
return;
|
|
241
302
|
}
|
|
242
|
-
const
|
|
243
|
-
if (
|
|
303
|
+
const text = readFileSync(p, 'utf8');
|
|
304
|
+
if (findTopLevelSectionRange(text, 'platform') === null) {
|
|
244
305
|
console.log('[sync] 已断开连接(未连接)');
|
|
245
306
|
return;
|
|
246
307
|
}
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
308
|
+
// 文本级删除 platform 段,保留注释/其他段/数组/深嵌套/CRLF
|
|
309
|
+
const newText = replaceTopLevelSection(text, 'platform', null);
|
|
310
|
+
if (newText.trim() === '') {
|
|
311
|
+
// 删段后纯空白(无任何段也无注释)→ 删除整个文件;注释算内容,有注释则保留
|
|
250
312
|
try { unlinkSync(p); } catch { /* best effort */ }
|
|
251
313
|
} else {
|
|
252
|
-
|
|
314
|
+
writeFileSync(p, newText, 'utf8');
|
|
253
315
|
}
|
|
254
316
|
console.log('[sync] 已断开连接');
|
|
255
317
|
}
|