sillyspec 3.26.7 → 3.26.9
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-archive/SKILL.md +1 -2
- package/.claude/skills/sillyspec-brainstorm/SKILL.md +1 -2
- package/.claude/skills/sillyspec-doctor/SKILL.md +2 -0
- package/.claude/skills/sillyspec-execute/SKILL.md +10 -2
- package/.claude/skills/sillyspec-explore/SKILL.md +0 -1
- package/.claude/skills/sillyspec-plan/SKILL.md +1 -2
- package/.claude/skills/sillyspec-quick/SKILL.md +1 -2
- package/.claude/skills/sillyspec-scan/SKILL.md +2 -2
- package/.claude/skills/sillyspec-status/SKILL.md +0 -1
- package/.claude/skills/sillyspec-verify/SKILL.md +1 -2
- package/.husky/pre-push +9 -2
- package/README.md +5 -9
- package/bin/sillyspec.js +1 -1
- package/package.json +2 -2
- package/packages/dashboard/server/index.js +14 -8
- package/src/change-risk-profile.js +5 -207
- package/src/config-schema.js +310 -287
- package/src/constants.js +97 -106
- package/src/db-engine.js +1 -1
- package/src/docs-check.js +362 -0
- package/src/docs-debt.js +226 -0
- package/src/docs-gate.js +106 -0
- package/src/doctor-diagnostics.js +23 -2
- package/src/git-helper.js +2 -2
- package/src/hooks/worktree-guard.js +1 -1
- package/src/index.js +1963 -1787
- package/src/init.js +95 -17
- package/src/machine-interface.js +7 -4
- package/src/modules.js +490 -486
- package/src/progress/stage-machine.js +3 -0
- package/src/progress.js +37 -13
- package/src/quicklog.js +182 -1
- package/src/run/command.js +154 -26
- package/src/run/complete-handlers.js +1254 -1128
- package/src/run/complete.js +9 -33
- package/src/run/gates.js +85 -1
- package/src/run/prompt.js +82 -1
- package/src/run/quick-audit.js +23 -0
- package/src/run/shared.js +273 -5
- package/src/run/stage.js +17 -1
- package/src/scan-diff.js +388 -0
- package/src/scan-staleness.js +95 -0
- package/src/stage-contract-spec.js +16 -1
- package/src/stage-contract.js +8 -5
- package/src/stage-review.js +1 -1
- package/src/stages/brainstorm-auto.js +0 -1
- package/src/stages/brainstorm.js +3 -3
- package/src/stages/execute.js +35 -11
- package/src/stages/plan-postcheck.js +93 -36
- package/src/stages/plan.js +22 -7
- package/src/stages/quick.js +1 -1
- package/src/stages/scan.js +2 -2
- package/src/stages/verify.js +11 -12
- package/src/sync.js +39 -17
- package/src/task-review.js +14 -5
- package/src/workflow.js +8 -185
- package/src/worktree-apply.js +128 -3
- package/src/worktree.js +2 -2
- package/templates/claude-instruction.md +1 -1
- package/templates/prompts/taskcard-rules.md +1 -0
- package/templates/prompts/verify-probes.md +2 -2
- package/templates/skills/sillyspec-onboard/SKILL.md +0 -92
|
@@ -275,7 +275,10 @@ export class StageMachine {
|
|
|
275
275
|
}
|
|
276
276
|
|
|
277
277
|
// 找到第一个有 pending/waiting/failed 步骤的 in-progress 阶段
|
|
278
|
+
// C14b:排除 scan(同下方主流程推荐循环)——scan 是 auxiliary,中途未完成时恒处
|
|
279
|
+
// STAGE_ORDER 首位且 in-progress,会把「下一步」劫持为 scan,掩盖主流程真实待办。
|
|
278
280
|
for (const s of STAGE_ORDER) {
|
|
281
|
+
if (s === 'scan') continue
|
|
279
282
|
const sd = data.stages[s];
|
|
280
283
|
if (!sd) continue;
|
|
281
284
|
if (sd.status === 'in-progress' && sd.steps) {
|
package/src/progress.js
CHANGED
|
@@ -20,26 +20,21 @@ import { ChangeRegistry } from './progress/change-registry.js';
|
|
|
20
20
|
import { StepStore } from './progress/step-store.js';
|
|
21
21
|
import { StageMachine } from './progress/stage-machine.js';
|
|
22
22
|
import { STAGE_ORDER, MAIN_FLOW_ORDER, VALID_STAGES, STAGE_LABELS, SPEC_DIR_NAME, CURRENT_VERSION, emptyStage } from './progress/shared.js';
|
|
23
|
+
// resolveSpecDir 单一真相源在 src/run/shared.js(含 home 拒绝守卫,坑 cwd-correction-home-collision
|
|
24
|
+
// 根治:home 下 .sillyspec 恒不命中,防 smoke/临时目录污染自我延续)。此处 re-export 保持
|
|
25
|
+
// 既有 import 路径兼容;run/shared.js 对 progress.js 只有动态 import,无静态循环。
|
|
26
|
+
import { resolveSpecDir } from './run/shared.js';
|
|
27
|
+
import { checkPlatformManaged, PLATFORM_MANAGED_FILENAME } from './run/shared.js';
|
|
28
|
+
export { resolveSpecDir };
|
|
23
29
|
|
|
24
30
|
// 默认规范目录名(相对于 cwd)
|
|
25
31
|
// SPEC_DIR_NAME → ./progress/shared.js(W6 Step9d)
|
|
26
32
|
const RUNTIME_SUBDIR = '.runtime';
|
|
27
33
|
|
|
28
34
|
/**
|
|
29
|
-
* 向上查找含 .sillyspec
|
|
30
|
-
*
|
|
35
|
+
* 向上查找含 .sillyspec 目录的祖先目录——实现已收敛至 src/run/shared.js(单一真相源,
|
|
36
|
+
* 含 home 拒绝守卫),此处 re-export。见顶部 import 说明。
|
|
31
37
|
*/
|
|
32
|
-
export function resolveSpecDir(startDir) {
|
|
33
|
-
let dir = resolve(startDir);
|
|
34
|
-
while (true) {
|
|
35
|
-
const candidate = join(dir, SPEC_DIR_NAME);
|
|
36
|
-
if (existsSync(candidate)) return candidate;
|
|
37
|
-
const parent = dirname(dir);
|
|
38
|
-
if (parent === dir) break; // 到达根目录
|
|
39
|
-
dir = parent;
|
|
40
|
-
}
|
|
41
|
-
return join(resolve(startDir), SPEC_DIR_NAME);
|
|
42
|
-
}
|
|
43
38
|
|
|
44
39
|
/**
|
|
45
40
|
* 平台指针不可达错误。pointer 存在但失效时抛出,阻止静默回退到本地孤儿 db。
|
|
@@ -59,6 +54,26 @@ export class PointerUnreachableError extends Error {
|
|
|
59
54
|
}
|
|
60
55
|
}
|
|
61
56
|
|
|
57
|
+
/**
|
|
58
|
+
* 平台接管声明生效错误(指针缺失但声明存在)。fail-closed 入口一:
|
|
59
|
+
* 防指针被 cleanup/STALE 清理或项目挪动后裸调静默建本地进度库(状态分裂根因)。
|
|
60
|
+
*
|
|
61
|
+
* ⚠️ 不覆写 this.name(保持父类的 'PointerUnreachableError')——CLI 顶层 catch 按
|
|
62
|
+
* err?.name === 'PointerUnreachableError' 严格字符串匹配(index.js),子类改名会
|
|
63
|
+
* 落通用错误分支打 stack noise。靠 message 首行"平台接管声明生效"区分场景。
|
|
64
|
+
*/
|
|
65
|
+
export class PlatformManagedError extends PointerUnreachableError {
|
|
66
|
+
constructor({ declarationPath, specRoot }) {
|
|
67
|
+
super({
|
|
68
|
+
pointerPath: declarationPath,
|
|
69
|
+
specRoot,
|
|
70
|
+
reason: `平台接管声明生效——本项目已由平台托管,但恢复指针缺失(可能被 platform pointer --cleanup 清理或项目目录被移动),拒绝静默回退本地模式`,
|
|
71
|
+
hint: `① 重跑平台 scan/init(带 --spec-root)重建指针;② 确认不再使用平台:sillyspec platform disconnect(删除接管声明);③ 显式 --spec-dir <路径> 临时指定目录`,
|
|
72
|
+
});
|
|
73
|
+
this.declarationPath = declarationPath;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
62
77
|
/**
|
|
63
78
|
* 平台感知的 specDir 解析(fail-closed,所有 CLI 子命令统一入口)。
|
|
64
79
|
*
|
|
@@ -75,6 +90,15 @@ export function resolvePlatformSpecDir(cwd, explicitSpecDir = null) {
|
|
|
75
90
|
if (explicitSpecDir) return resolve(explicitSpecDir);
|
|
76
91
|
const pointerPath = join(resolve(cwd), '.sillyspec-platform.json');
|
|
77
92
|
if (!existsSync(pointerPath)) {
|
|
93
|
+
// 指针缺失:查平台接管声明(fail-closed 入口一)。声明存在 = 项目进过平台模式,
|
|
94
|
+
// 指针丢失不该静默当纯本地项目处理(会建本地进度库 → 状态分裂)。
|
|
95
|
+
const decl = checkPlatformManaged(cwd);
|
|
96
|
+
if (decl) {
|
|
97
|
+
throw new PlatformManagedError({
|
|
98
|
+
declarationPath: join(resolve(cwd), PLATFORM_MANAGED_FILENAME),
|
|
99
|
+
specRoot: decl.specRoot,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
78
102
|
return resolveSpecDir(cwd);
|
|
79
103
|
}
|
|
80
104
|
// pointer 存在 = 进过平台模式,严格校验,不静默回退
|
package/src/quicklog.js
CHANGED
|
@@ -81,9 +81,153 @@ export function sanitizeDesc(description) {
|
|
|
81
81
|
return s.length > 120 ? s.slice(0, 120) + '…' : s
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
|
|
85
|
+
// ── 平台推送(best-effort,2026-08-16-change-center-quick-tab task-06 / D-003)──
|
|
86
|
+
|
|
87
|
+
// 读 local.yaml platform 段(url+token)。quicklog 不 import sync.js(会拖 ProgressManager
|
|
88
|
+
// 整链),自带同款轻量解析:只取 platform.url / platform.token 两键,与 parseSimpleYaml
|
|
89
|
+
// 同风格(丢注释可接受——只读)。未连接返回 null(合法本地状态,静默跳过)。
|
|
90
|
+
function _readPlatformConfig(specBase) {
|
|
91
|
+
try {
|
|
92
|
+
// specBase 即 <cwd>/.sillyspec(或平台 specRoot);local.yaml 就在 spec 根下
|
|
93
|
+
const yamlPath = join(specBase, 'local.yaml')
|
|
94
|
+
if (!existsSync(yamlPath)) return null
|
|
95
|
+
const lines = readFileSync(yamlPath, 'utf8').split(/\r?\n/)
|
|
96
|
+
let inPlatform = false
|
|
97
|
+
const cfg = {}
|
|
98
|
+
for (const line of lines) {
|
|
99
|
+
if (!line.trim() || line.trim().startsWith('#')) continue
|
|
100
|
+
if (!/^\s/.test(line)) inPlatform = line.startsWith('platform:')
|
|
101
|
+
else if (inPlatform) {
|
|
102
|
+
const m = line.match(/^\s+(url|token)\s*:\s*(.*)$/)
|
|
103
|
+
if (m) {
|
|
104
|
+
let v = m[2].trim()
|
|
105
|
+
if (v.length >= 2 && ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'")))) v = v.slice(1, -1)
|
|
106
|
+
if (v) cfg[m[1]] = v
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (!cfg.url || !cfg.token) return null
|
|
111
|
+
return cfg
|
|
112
|
+
} catch {
|
|
113
|
+
return null
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const PUSH_TIMEOUT_MS = 5_000
|
|
118
|
+
|
|
119
|
+
// 从文件全文提取指定 qlId 的条目块(## 头到下一个 ## 头/文件尾)。null=未命中。
|
|
120
|
+
// 与平台 quicklog_parser.py 同款切分口径(剥 \r),保证 CLI 推送的 raw_block 与平台解析一致。
|
|
121
|
+
function extractRawBlock(content, qlId) {
|
|
122
|
+
if (!content) return null
|
|
123
|
+
const lines = content.split('\n')
|
|
124
|
+
const start = lines.findIndex(l => l.replace(/\r$/, '').startsWith(`## ${qlId} |`))
|
|
125
|
+
if (start === -1) return null
|
|
126
|
+
let end = lines.length
|
|
127
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
128
|
+
if (lines[i].replace(/\r$/, '').startsWith('## ')) { end = i; break }
|
|
129
|
+
}
|
|
130
|
+
return lines.slice(start, end).map(l => l.replace(/\r$/, '')).join('\n').trimEnd()
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// 落盘条目块 → 推送 payload(design §5.3:以落盘终态为准,不从入参拼)。
|
|
134
|
+
// 宽松标签解析对齐平台:全半角冒号、多条状态行取最后、文件行单行/多行 bullet。
|
|
135
|
+
function buildPushPayloadFromRaw(rawBlock, { ql_id, author_raw, status, linked_changes, fallback_title }) {
|
|
136
|
+
const payload = {
|
|
137
|
+
ql_id,
|
|
138
|
+
timestamp: null,
|
|
139
|
+
title: fallback_title || '(quick 任务)',
|
|
140
|
+
status,
|
|
141
|
+
status_note: null,
|
|
142
|
+
author_raw,
|
|
143
|
+
linked_changes: Array.isArray(linked_changes) ? linked_changes : [],
|
|
144
|
+
files: [],
|
|
145
|
+
body_sections: {},
|
|
146
|
+
raw_block: rawBlock,
|
|
147
|
+
}
|
|
148
|
+
if (!rawBlock) return payload
|
|
149
|
+
const lines = rawBlock.split('\n')
|
|
150
|
+
const header = lines[0] || ''
|
|
151
|
+
const parts = header.split('|').map(s => s.trim())
|
|
152
|
+
if (parts.length >= 3) {
|
|
153
|
+
payload.timestamp = parts[1] || null
|
|
154
|
+
payload.title = parts.slice(2).join('|').trim() || payload.title
|
|
155
|
+
}
|
|
156
|
+
const labelRe = /^(状态|关联变更|文件|需求|根因|方案|结果)\s*[::]\s*(.*)$/
|
|
157
|
+
const bulletRe = /^-\s+(.*)$/
|
|
158
|
+
let inFiles = false
|
|
159
|
+
let inLinked = false
|
|
160
|
+
let lastLabel = null
|
|
161
|
+
let lastStatus = null
|
|
162
|
+
for (const line of lines.slice(1)) {
|
|
163
|
+
const stripped = line.trim()
|
|
164
|
+
if (!stripped) { inFiles = false; inLinked = false; continue }
|
|
165
|
+
const m = stripped.match(labelRe)
|
|
166
|
+
if (m) {
|
|
167
|
+
const [, label, value] = m
|
|
168
|
+
if (label === '状态') lastStatus = value
|
|
169
|
+
else if (label === '关联变更') { payload.linked_changes = value.split(/[,,、+;;]/).map(s => s.trim()).filter(Boolean); inLinked = true; inFiles = false }
|
|
170
|
+
else if (label === '文件') { if (value) payload.files.push(...value.split(/[,,、+;;]/).map(s => s.trim()).filter(Boolean).map(p => ({ path: p, note: null }))); inFiles = true; inLinked = false }
|
|
171
|
+
else { payload.body_sections[label] = value; lastLabel = label }
|
|
172
|
+
continue
|
|
173
|
+
}
|
|
174
|
+
if (bulletRe.test(stripped) && inFiles) {
|
|
175
|
+
payload.files.push({ path: stripped.replace(/^-\s+/, ''), note: null })
|
|
176
|
+
continue
|
|
177
|
+
}
|
|
178
|
+
if (bulletRe.test(stripped) && inLinked) {
|
|
179
|
+
payload.linked_changes.push(...stripped.replace(/^-\s+/, '').split(/[,,、+;;]/).map(s => s.trim()).filter(Boolean))
|
|
180
|
+
continue
|
|
181
|
+
}
|
|
182
|
+
if (lastLabel) payload.body_sections[lastLabel] += '\n' + stripped
|
|
183
|
+
}
|
|
184
|
+
// 状态以落盘为准(flip 后是「已完成」或「已完成(括注)」)——多条取最后
|
|
185
|
+
if (lastStatus) {
|
|
186
|
+
payload.status = lastStatus.startsWith('已完成') ? 'completed'
|
|
187
|
+
: lastStatus.startsWith('已暂存') ? 'partial_done'
|
|
188
|
+
: lastStatus.startsWith('进行中') ? 'in_progress'
|
|
189
|
+
: status
|
|
190
|
+
const note = lastStatus.match(/((.+))$/)
|
|
191
|
+
payload.status_note = note ? note[1] : null
|
|
192
|
+
}
|
|
193
|
+
// 白名单正则对齐平台(^\d{4}-\d{2}-\d{2}- 才进列表,防自由文本进反向区块)
|
|
194
|
+
payload.linked_changes = payload.linked_changes.filter(c => /^\d{4}-\d{2}-\d{2}-/.test(c))
|
|
195
|
+
return payload
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// 单条推送:payload 字段对齐平台 QuicklogEntryPushRequest(snake_case)。测试经 allocate/complete 间接触发。
|
|
199
|
+
// 任何失败(无配置/网络/非 2xx/超时)静默 warn 一行不抛(FR-02 best-effort,quick 主流程零阻断)。
|
|
200
|
+
async function pushQuicklogEntryToPlatform(specBase, entry) {
|
|
201
|
+
const cfg = _readPlatformConfig(specBase)
|
|
202
|
+
if (!cfg) {
|
|
203
|
+
if (process.env.SILLYSPEC_DEBUG_SYNC) console.warn('[quicklog-push] 未配置 platform 段,跳过推送')
|
|
204
|
+
return false
|
|
205
|
+
}
|
|
206
|
+
const controller = new AbortController()
|
|
207
|
+
const timer = setTimeout(() => controller.abort(), PUSH_TIMEOUT_MS)
|
|
208
|
+
try {
|
|
209
|
+
const res = await fetch(`${cfg.url.replace(/\/$/, '')}/api/quicklog-entries`, {
|
|
210
|
+
method: 'POST',
|
|
211
|
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${cfg.token}` },
|
|
212
|
+
body: JSON.stringify(entry),
|
|
213
|
+
signal: controller.signal,
|
|
214
|
+
})
|
|
215
|
+
if (!res.ok) {
|
|
216
|
+
console.warn(`[quicklog-push] ${entry.ql_id} → ${res.status}(文件链路兜底,不影响本地记录)`)
|
|
217
|
+
return false
|
|
218
|
+
}
|
|
219
|
+
return true
|
|
220
|
+
} catch (err) {
|
|
221
|
+
console.warn(`[quicklog-push] ${entry.ql_id} 推送失败: ${err && err.name === 'AbortError' ? '超时' : (err.message || err)}(文件链路兜底)`)
|
|
222
|
+
return false
|
|
223
|
+
} finally {
|
|
224
|
+
clearTimeout(timer)
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
84
228
|
// git user.name → 文件名安全形式:白名单保留字母数字._- 与中文等 Unicode 字,
|
|
85
229
|
// 剥路径元字符(/ \ ..)与控制字符。防 QUICKLOG-<user>.md / 锁文件 / 轮转归档穿越写。
|
|
86
|
-
|
|
230
|
+
function sanitizeQuicklogUser(user) {
|
|
87
231
|
const s = String(user || '').replace(/[\\/\r\n\0]/g, '').replace(/\.{2,}/g, '.').trim()
|
|
88
232
|
if (!s) return ''
|
|
89
233
|
return s.length > 64 ? s.slice(0, 64) : s
|
|
@@ -216,7 +360,13 @@ function scanExisting(quicklogDir, today) {
|
|
|
216
360
|
let maxSeq = 0
|
|
217
361
|
const usedSuffix = new Set()
|
|
218
362
|
const re = /^## ql-(\d{8})-(\d{3})-([0-9a-fA-F]{4})\b/gm
|
|
363
|
+
// E22c:轮转归档按文件名日期过滤——归档名 = QUICKLOG-<user>-<YYYY-MM-DD>.md(最后条目日期),
|
|
364
|
+
// 归档内全部条目 ≤ 名内日期;名内日期早于今天的归档不可能含当日条目,跳过读取
|
|
365
|
+
// (O(全历史归档) → O(当日文件),consumer 已 10 归档文件 756KB 时免全量扫描)。
|
|
366
|
+
const todayDashed = today.length === 8 ? `${today.slice(0, 4)}-${today.slice(4, 6)}-${today.slice(6, 8)}` : today
|
|
219
367
|
for (const f of listQuicklogFiles(quicklogDir)) {
|
|
368
|
+
const dm = f.match(/-(\d{4}-\d{2}-\d{2})\.md$/)
|
|
369
|
+
if (dm && dm[1] < todayDashed) continue // 早于今天的归档,必无当日条目
|
|
220
370
|
let content = ''
|
|
221
371
|
try { content = readFileSync(join(quicklogDir, f), 'utf8') } catch { continue }
|
|
222
372
|
let m
|
|
@@ -441,6 +591,22 @@ export async function allocateQuicklogEntry(specBase, gitUser, { description, li
|
|
|
441
591
|
|
|
442
592
|
for (const c of linked) appendTaskCheckbox(specBase, c, qlId, desc)
|
|
443
593
|
|
|
594
|
+
// 平台推送(task-06 / FR-02 / D-003):锁外语义等同(推送不回写本地),但放锁内
|
|
595
|
+
// 保证「分配即推送」顺序一致;best-effort 失败不影响返回。
|
|
596
|
+
await pushQuicklogEntryToPlatform(specBase, {
|
|
597
|
+
ql_id: qlId,
|
|
598
|
+
timestamp: nowDatetime(),
|
|
599
|
+
title: desc,
|
|
600
|
+
status: 'in_progress',
|
|
601
|
+
status_note: null,
|
|
602
|
+
author_raw: user,
|
|
603
|
+
// 白名单正则对齐平台(^\d{4}-\d{2}-\d{2}- 才进列表;原文在 raw_block 不丢)
|
|
604
|
+
linked_changes: linked.filter(c => /^\d{4}-\d{2}-\d{2}-/.test(c)),
|
|
605
|
+
files: files.map(f => ({ path: f, note: null })),
|
|
606
|
+
body_sections: {},
|
|
607
|
+
raw_block: entry,
|
|
608
|
+
}).catch(() => {}) // pushQuicklogEntryToPlatform 自身已吞错;双保险防未来改动破坏 best-effort 契约
|
|
609
|
+
|
|
444
610
|
return { qlId }
|
|
445
611
|
})
|
|
446
612
|
}
|
|
@@ -464,6 +630,8 @@ export async function completeQuicklogEntry(specBase, gitUser, qlId, { resultTex
|
|
|
464
630
|
|
|
465
631
|
await withFileLock(lockPath, async () => {
|
|
466
632
|
// 条目可能在主文件或轮转归档中
|
|
633
|
+
let updatedContent = null
|
|
634
|
+
let updatedFile = null
|
|
467
635
|
for (const f of listQuicklogFiles(quicklogDir)) {
|
|
468
636
|
const filePath = join(quicklogDir, f)
|
|
469
637
|
let content = ''
|
|
@@ -471,10 +639,23 @@ export async function completeQuicklogEntry(specBase, gitUser, qlId, { resultTex
|
|
|
471
639
|
const updated = flipEntryInContent(content, qlId, result, realFiles, fileNotes)
|
|
472
640
|
if (updated !== null) {
|
|
473
641
|
await writeAtomic(filePath, updated) // 命中处原子落盘(只改含目标条目的那一个文件)
|
|
642
|
+
updatedContent = updated
|
|
643
|
+
updatedFile = filePath
|
|
474
644
|
break
|
|
475
645
|
}
|
|
476
646
|
}
|
|
477
647
|
for (const c of linked) await checkTaskCheckbox(specBase, c, qlId)
|
|
648
|
+
|
|
649
|
+
// 平台推送(task-06 / FR-02 / D-003):**以落盘终态为准**读回条目组装 payload
|
|
650
|
+
// (design §5.3:翻完成时标题行被 extractTitleFromResult 刷新,推送须与落盘一致,
|
|
651
|
+
// 不能用入参拼——标题/结果块/文件行都会在 flipEntryInContent 中重写)。
|
|
652
|
+
const rawBlock = extractRawBlock(updatedContent, qlId)
|
|
653
|
+
await pushQuicklogEntryToPlatform(specBase, buildPushPayloadFromRaw(rawBlock, {
|
|
654
|
+
ql_id: qlId,
|
|
655
|
+
author_raw: user,
|
|
656
|
+
status: 'completed',
|
|
657
|
+
linked_changes: linked,
|
|
658
|
+
})).catch(() => {})
|
|
478
659
|
})
|
|
479
660
|
}
|
|
480
661
|
|
package/src/run/command.js
CHANGED
|
@@ -19,18 +19,19 @@
|
|
|
19
19
|
* './run/shared.js' / './run/quick-audit.js' / './run/prompt.js' / './run/complete.js' /
|
|
20
20
|
* './run/stage.js' → './shared.js' 等同级
|
|
21
21
|
*/
|
|
22
|
-
import { join, resolve, dirname } from 'node:path'
|
|
22
|
+
import { basename, join, resolve, dirname } from 'node:path'
|
|
23
23
|
import { existsSync, readdirSync, mkdirSync, writeFileSync, readFileSync, rmSync } from 'node:fs'
|
|
24
24
|
import { randomBytes, randomUUID } from 'node:crypto'
|
|
25
25
|
import { writeAtomicSync } from '../fs-atomic.js'
|
|
26
|
-
import { resolveSpecDir, countAncestorSpecDirs, resolveChangeDir, triggerSync, getStageSteps, formatWaitOptions, checkApproval, didYouMean, assertSafeChangeName, detectQuickSessionDrift, detectWorktreeSpecDrift, resolveRuntimeRoot } from './shared.js'
|
|
26
|
+
import { resolveSpecDir, countAncestorSpecDirs, resolveChangeDir, triggerSync, getStageSteps, formatWaitOptions, checkApproval, didYouMean, assertSafeChangeName, detectQuickSessionDrift, detectWorktreeSpecDrift, resolveRuntimeRoot, writePlatformPointer, checkPlatformManaged, PLATFORM_MANAGED_FILENAME } from './shared.js'
|
|
27
27
|
import { resolveQuickLinkedChanges } from './quick-audit.js'
|
|
28
28
|
import { outputStep } from './prompt.js'
|
|
29
29
|
import { completeStep, skipStep, waitStep, continueStep } from './complete.js'
|
|
30
30
|
import { runStage } from './stage.js'
|
|
31
31
|
import { sanitizeDesc } from '../quicklog.js'
|
|
32
32
|
import { ProgressManager } from '../progress.js'
|
|
33
|
-
import { validateChangeExists } from '../stage-contract.js'
|
|
33
|
+
import { validateChangeExists, checkTransition } from '../stage-contract.js'
|
|
34
|
+
import { READONLY_AUXILIARY_STAGES } from '../constants.js'
|
|
34
35
|
import { stageRegistry, auxiliaryStages } from '../stages/index.js'
|
|
35
36
|
import { definition as brainstormAutoDef } from '../stages/brainstorm-auto.js'
|
|
36
37
|
import { setQuickFileNotes } from '../quicklog.js'
|
|
@@ -43,6 +44,7 @@ const VALUE_FLAGS = new Set([
|
|
|
43
44
|
'--output', '--input', '--change', '--linked-changes',
|
|
44
45
|
'--spec-dir', '--spec-root', '--runtime-root', '--workspace-id', '--scan-run-id',
|
|
45
46
|
'--files', '--file-notes', '--from-step', '--mode', '--dir', '--confirm-mode',
|
|
47
|
+
'--base', // scan diff 基线 commit(吃值;只在 run scan --diff 转发路径消费)
|
|
46
48
|
])
|
|
47
49
|
|
|
48
50
|
/**
|
|
@@ -125,6 +127,31 @@ export async function ensureStageSteps(progress, stageName, cwd, specDir = null)
|
|
|
125
127
|
return false
|
|
126
128
|
}
|
|
127
129
|
|
|
130
|
+
/**
|
|
131
|
+
* run <stage> --help/-h 的用法帮助(runCommand 内 --help 短路调用)。
|
|
132
|
+
* 只打印不落盘:帮助查询不该有副作用(建会话/写 QUICKLOG)。
|
|
133
|
+
*/
|
|
134
|
+
function printStageUsage(stageName) {
|
|
135
|
+
const stage = stageRegistry[stageName]
|
|
136
|
+
console.log(`
|
|
137
|
+
sillyspec run ${stageName}${stage && stage.title ? ` — ${stage.title}` : ''}${stage && stage.description ? `\n ${stage.description}` : ''}
|
|
138
|
+
|
|
139
|
+
用法:
|
|
140
|
+
sillyspec run ${stageName} 开始/继续该阶段(输出下一步 prompt)
|
|
141
|
+
sillyspec run ${stageName} --status 查看阶段进度
|
|
142
|
+
sillyspec run ${stageName} --done --output "摘要" [--input "用户原话"] 完成当前步骤
|
|
143
|
+
sillyspec run ${stageName} --change <名> 指定变更(多活跃变更必填)
|
|
144
|
+
|
|
145
|
+
通用参数: --done --output --input --status --skip --reset --reopen --from-step
|
|
146
|
+
--wait --continue --answer --change --spec-dir --non-interactive
|
|
147
|
+
--interactive --skip-approval --json(不支持)
|
|
148
|
+
quick 专属: --linked-changes none|a,b --files a.js,b.js --allow-new
|
|
149
|
+
--allow-delete --force-baseline --confirm --file-notes
|
|
150
|
+
scan 专属: --quick --standard --deep --force-rescan --diff [--base <commit>] [--full] [--report]
|
|
151
|
+
archive专属: --confirm
|
|
152
|
+
`)
|
|
153
|
+
}
|
|
154
|
+
|
|
128
155
|
/**
|
|
129
156
|
* sillyspec run <stage> 主命令
|
|
130
157
|
*/
|
|
@@ -289,31 +316,27 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
|
|
|
289
316
|
}
|
|
290
317
|
}
|
|
291
318
|
}
|
|
319
|
+
// ── 平台接管声明 fail-closed(入口二,D-B@v2)──
|
|
320
|
+
// runCommand 有独立指针恢复链(不经 resolvePlatformSpecDir),指针缺失时若不在此
|
|
321
|
+
// 封堵,run/quick/scan 等全部 stage 命令会静默落本地库(与入口一行为分裂)。
|
|
322
|
+
// 触发条件:命令行没传平台参数 + 指针与 platform-scan.json 皆未命中 + 接管声明存在。
|
|
323
|
+
// 显式 --spec-dir/--spec-root 传参时 platformOpts.specRoot 已赋值,天然不进此分支(逃生口)。
|
|
324
|
+
if (!platformOpts.specRoot && !platformOpts.runtimeRoot && !platformFileExists) {
|
|
325
|
+
const decl = checkPlatformManaged(cwd)
|
|
326
|
+
if (decl) {
|
|
327
|
+
console.error(`❌ 平台接管声明生效:本项目已由平台托管(原 specRoot: ${decl.specRoot || '(未记录)'}),但恢复指针缺失,拒绝静默回退本地模式。`)
|
|
328
|
+
console.error(` 声明文件: ${join(cwd, PLATFORM_MANAGED_FILENAME)}`)
|
|
329
|
+
console.error(' 恢复:① 重跑平台 scan/init(带 --spec-root)重建指针;② 确认不再使用平台:sillyspec platform disconnect(删除接管声明);③ 显式 --spec-dir <路径> 临时指定目录。')
|
|
330
|
+
// exit(1) 而非邻近环境错的 exit(2):这是"状态保护阻断"(对齐 PointerUnreachableError
|
|
331
|
+
// 顶层 catch 语义),非"用法/环境错"——见 design.md §5.3 v2 复审观察 (a)
|
|
332
|
+
process.exit(1)
|
|
333
|
+
}
|
|
334
|
+
}
|
|
292
335
|
// 持久化 platformOpts
|
|
293
|
-
// 在 specRoot/.runtime/ 写主文件,同时在 cwd
|
|
336
|
+
// 在 specRoot/.runtime/ 写主文件,同时在 cwd 下写恢复指针
|
|
337
|
+
// (writePlatformPointer 收敛双写逻辑,cmdInit 平台模式共用,防两处字段漂移)
|
|
294
338
|
if (platformOpts.specRoot || platformOpts.runtimeRoot) {
|
|
295
|
-
|
|
296
|
-
const { mkdirSync, writeFileSync } = await import('fs')
|
|
297
|
-
mkdirSync(join(specRoot, '.runtime'), { recursive: true })
|
|
298
|
-
writeFileSync(platformOptsFile, JSON.stringify({
|
|
299
|
-
specRoot: platformOpts.specRoot,
|
|
300
|
-
runtimeRoot: platformOpts.runtimeRoot,
|
|
301
|
-
workspaceId: platformOpts.workspaceId,
|
|
302
|
-
scanRunId: platformOpts.scanRunId,
|
|
303
|
-
savedAt: new Date().toISOString(),
|
|
304
|
-
}, null, 2) + '\n')
|
|
305
|
-
// 恢复指针:在 cwd 下写 .sillyspec-platform.json(不在 .sillyspec 内,不污染源码结构)
|
|
306
|
-
// 供后续 --done(不带 --spec-root)找到 specDir
|
|
307
|
-
writeFileSync(join(cwd, '.sillyspec-platform.json'), JSON.stringify({
|
|
308
|
-
specRoot: platformOpts.specRoot,
|
|
309
|
-
runtimeRoot: platformOpts.runtimeRoot,
|
|
310
|
-
workspaceId: platformOpts.workspaceId,
|
|
311
|
-
scanRunId: platformOpts.scanRunId,
|
|
312
|
-
savedAt: new Date().toISOString(),
|
|
313
|
-
}, null, 2) + '\n')
|
|
314
|
-
} catch {
|
|
315
|
-
// 静默失败,不影响主流程
|
|
316
|
-
}
|
|
339
|
+
writePlatformPointer(cwd, platformOpts)
|
|
317
340
|
}
|
|
318
341
|
|
|
319
342
|
// 统一规范基路径:平台模式用 specRoot,本地模式用 cwd/.sillyspec
|
|
@@ -332,6 +355,24 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
|
|
|
332
355
|
}
|
|
333
356
|
}
|
|
334
357
|
|
|
358
|
+
// scan diff 转发(D-001@v1:command.js 只认 --diff flag,等价 `sillyspec scan diff` 子命令;
|
|
359
|
+
// 裸 token `run scan diff` 不在此解析——那是 index.js 子命令拦截的职责,防 command.js 静默吞)。
|
|
360
|
+
// 纯只读比较:零会话/QUICKLOG 副作用,仅解析 --base/--full/--report 后转发 scan-diff 退出码。
|
|
361
|
+
if (stageName === 'scan' && flags.includes('--diff')) {
|
|
362
|
+
const { runScanDiff } = await import('../scan-diff.js')
|
|
363
|
+
const diffBaseIdx = flags.indexOf('--base')
|
|
364
|
+
const diffBase = diffBaseIdx >= 0 && flags[diffBaseIdx + 1] ? flags[diffBaseIdx + 1] : null
|
|
365
|
+
process.exitCode = runScanDiff({
|
|
366
|
+
projectRoot: cwd,
|
|
367
|
+
specBase,
|
|
368
|
+
projectName: basename(cwd),
|
|
369
|
+
base: diffBase,
|
|
370
|
+
full: flags.includes('--full'),
|
|
371
|
+
report: flags.includes('--report'),
|
|
372
|
+
})
|
|
373
|
+
return
|
|
374
|
+
}
|
|
375
|
+
|
|
335
376
|
// 平台模式:首次接入时清理旧版本残留的 cwd/.sillyspec/(防止源码污染)。
|
|
336
377
|
// ⚠️ 同 init.js:必须保护真实资产(changes/、projects/、sillyspec.db)。
|
|
337
378
|
// 只在「首次」执行一次——用 cwd 下的 .sillyspec-platform-cleaned 标记文件记录已处理,
|
|
@@ -470,6 +511,24 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
|
|
|
470
511
|
}
|
|
471
512
|
}
|
|
472
513
|
|
|
514
|
+
// quick 关联变更存在性守卫(坑 quick-change-phantom-linked):quick 的 --change/--linked-changes
|
|
515
|
+
// 被解析为关联变更后静默接受不存在的名字(quick 不建变更,链接幻影名必是笔误/语义误用——想给
|
|
516
|
+
// 会话起名却传了 --change),后果是 QUICKLOG 挂悬空关联污染 change 关联图谱 + --done 时 sessionId
|
|
517
|
+
// 走 fallback 可能命中他者会话。f70c9c3 只修了「建幻影目录」后果(appendTaskCheckbox 加
|
|
518
|
+
// existsSync 守卫),本守卫把误用拦在 flag 装载层。只校验 CLI 显式装载的 linkedChanges;
|
|
519
|
+
// 持久化 guard.json 复用(下方 run↔--done 之间变更可能被归档)与交互式选择(列表只列存在项)不检。
|
|
520
|
+
if (stageName === 'quick' && linkedChanges.length > 0) {
|
|
521
|
+
const missing = linkedChanges.filter(lc => lc !== 'none' && !existsSync(join(specRoot, 'changes', lc)))
|
|
522
|
+
if (missing.length > 0) {
|
|
523
|
+
console.error(`❌ quick 的 --change/--linked-changes 被当作「关联变更」处理,但以下变更不存在:${missing.join('、')}`)
|
|
524
|
+
console.error(' (quick 不创建变更,关联的变更必须预先存在)')
|
|
525
|
+
console.error(' - 想给会话起名:不要传 --change,sessionId 由 CLI 自动生成(后续 --done 用它)')
|
|
526
|
+
console.error(' - 想关联变更:修正拼写(.sillyspec/changes/ 下已存在的目录名),显式写法 --linked-changes <名>')
|
|
527
|
+
console.error(' - 想新建变更:quick 不建变更,走 brainstorm 完整流程')
|
|
528
|
+
process.exit(2)
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
473
532
|
// 解析 --files a.js,b.js(quick 专用:显式声明 allowedFiles)
|
|
474
533
|
// --files 是单值 flag,空格分隔的多文件只取首个致其余静默丢失(坑 quick-files-space-separated-silently-drops)。
|
|
475
534
|
// detectSpaceSeparatedFiles 检测误用 → fail-loud 报错指明逗号用法(同 run --json :109 风格)。
|
|
@@ -513,6 +572,8 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
|
|
|
513
572
|
'--json', '--dir', '--help',
|
|
514
573
|
'--reopen', '--from-step', '--mode',
|
|
515
574
|
'--deep', '--quick', '--standard', // scan profile 三档显式选择(scan-profile.js 从 argv 读;互斥见下方 PROFILE_FLAGS 检测)
|
|
575
|
+
'--diff', '--base', '--full', '--report', // scan diff(D-001:command.js 只补 flag,裸 token 解析归 index.js 子命令拦截)
|
|
576
|
+
'-h',
|
|
516
577
|
])
|
|
517
578
|
for (let i = 0; i < flags.length; i++) {
|
|
518
579
|
const f = flags[i]
|
|
@@ -531,6 +592,14 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
|
|
|
531
592
|
}
|
|
532
593
|
}
|
|
533
594
|
|
|
595
|
+
// ── --help/-h 短路:flag 校验通过后、任何副作用(cwd 纠正/会话创建/QUICKLOG 落盘)之前 ──
|
|
596
|
+
// --help 此前在 knownFlags 白名单里被静默吞掉,run quick --help 会误开 quick 会话+写骨架
|
|
597
|
+
// 条目(查询意图不该有副作用);-h 更是被当未知参数 exit 2。帮助查询 = 用法展示 = 退出 0。
|
|
598
|
+
if (flags.includes('--help') || flags.includes('-h')) {
|
|
599
|
+
printStageUsage(stageName)
|
|
600
|
+
process.exit(0)
|
|
601
|
+
}
|
|
602
|
+
|
|
534
603
|
const isAuxiliary = auxiliaryStages.includes(stageName)
|
|
535
604
|
// scan 元数据追踪(存储在 stageData.scanMeta 中,completeStep 通过 progress 访问)
|
|
536
605
|
|
|
@@ -628,6 +697,39 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
|
|
|
628
697
|
|
|
629
698
|
let progress = pm.read(cwd, changeName)
|
|
630
699
|
|
|
700
|
+
// task-03 (D-005@v2 / FR-04): READONLY_AUXILIARY_STAGES(status/doctor)查询只读短路。
|
|
701
|
+
// 无显式写 flag 时在 registerChange / ensureStageSteps 之前拦截,零副作用:
|
|
702
|
+
// - 目标 progress 不存在 → 提示「只读查询不建变更」exit 0(不 initChange、不建 default 行,治 8b)
|
|
703
|
+
// - 目标 progress 存在 → 只读展示,不 seed steps、不刷新 lastActive、不 registerChange
|
|
704
|
+
// (治 B7 多 agent 并发 lastActive 互相覆盖)
|
|
705
|
+
// 显式写意图不短路:doctor 写操作 flag(--cleanup-remnant / --align-execute-progress)与
|
|
706
|
+
// 步骤动作 flag(--done/--skip/--reset/--reopen/--wait/--continue)走原写路径;--status 本身
|
|
707
|
+
// 是只读查询,纳入短路。--fix 是 worktree doctor 的 flag 不在此列(design.md Phase 4)。
|
|
708
|
+
const READONLY_WRITE_ACTIONS = ['--cleanup-remnant', '--align-execute-progress', '--done', '--skip', '--reset', '--reopen', '--wait', '--continue']
|
|
709
|
+
if (READONLY_AUXILIARY_STAGES.includes(stageName) && !flags.some(f => READONLY_WRITE_ACTIONS.includes(f))) {
|
|
710
|
+
if (!progress) {
|
|
711
|
+
console.log('ℹ️ 未找到进度数据(只读查询不建变更)')
|
|
712
|
+
process.exit(0)
|
|
713
|
+
}
|
|
714
|
+
// 只读展示路径:复用阶段定义渲染当前步骤 prompt(outputStep 对 status/doctor 纯只读),
|
|
715
|
+
// 不落盘任何 progress 改动。--status 走现有 showStatus 展示。
|
|
716
|
+
const readOnlyChange = changeName || progress.currentChange || resolveChangeName(cwd, progress, specRoot)
|
|
717
|
+
if (isStatus) {
|
|
718
|
+
let nextSuggestion = null
|
|
719
|
+
try { nextSuggestion = pm._getNextSuggestion(progress) } catch { /* 读建议失败不阻断展示 */ }
|
|
720
|
+
return showStatus(progress, stageName, nextSuggestion)
|
|
721
|
+
}
|
|
722
|
+
const readonlySteps = await getStageSteps(stageName, cwd, progress, specRoot)
|
|
723
|
+
if (readonlySteps && readonlySteps.length > 0) {
|
|
724
|
+
// 已有进度(历史 seed 过)则渲染当前待办步;无进度从首步开始
|
|
725
|
+
const existingSteps = progress.stages?.[stageName]?.steps
|
|
726
|
+
const pendingIdx = existingSteps ? existingSteps.findIndex(s => s.status === 'pending' || s.status === 'in-progress') : -1
|
|
727
|
+
const stepIdx = pendingIdx !== -1 ? pendingIdx : 0
|
|
728
|
+
await outputStep(stageName, stepIdx, readonlySteps, cwd, readOnlyChange, progress.project || null, platformOpts)
|
|
729
|
+
}
|
|
730
|
+
return
|
|
731
|
+
}
|
|
732
|
+
|
|
631
733
|
if (!progress) {
|
|
632
734
|
// 如果指定了变更名或有变更目录,自动初始化变更的 progress
|
|
633
735
|
const autoChange = changeName || resolveChangeNameAuto(cwd, specRoot)
|
|
@@ -673,6 +775,17 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
|
|
|
673
775
|
console.error(' 或先运行 sillyspec run brainstorm --change <变更名> 初始化。')
|
|
674
776
|
process.exit(2) // 用法错(--done 找不到变更,未传 --change)→ exit 2
|
|
675
777
|
}
|
|
778
|
+
// task-03 (D-006@v1 / FR-05): 无 --change 仅当无已存在活跃变更时 auto-create;
|
|
779
|
+
// 多活跃变更仓强制 --change(exit 2 引导),防 B8 幽灵变更。活跃变更数用 pm.listChanges
|
|
780
|
+
// (与 progress show 同源,DB changes 表 status='active')。
|
|
781
|
+
const activeChanges = pm.listChanges(cwd)
|
|
782
|
+
if (activeChanges.length > 0) {
|
|
783
|
+
console.error(`❌ 已存在 ${activeChanges.length} 个活跃变更,run brainstorm 无 --change 不会自动创建新变更。`)
|
|
784
|
+
console.error(` 活跃变更:${activeChanges.join('、')}`)
|
|
785
|
+
console.error(' 请用 --change <变更名> 指定要操作的变更,')
|
|
786
|
+
console.error(' 或先归档/收尾现有活跃变更后重试。')
|
|
787
|
+
process.exit(2) // 用法错(多活跃变更未指定 --change)→ exit 2
|
|
788
|
+
}
|
|
676
789
|
const date = new Date().toISOString().slice(0, 10)
|
|
677
790
|
const autoName = `${date}-new-change-${randomBytes(4).toString('hex')}`
|
|
678
791
|
console.log(`🔄 自动创建变更:${autoName}`)
|
|
@@ -853,6 +966,21 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
|
|
|
853
966
|
|
|
854
967
|
// --done
|
|
855
968
|
if (isDone) {
|
|
969
|
+
// task-03 (D-004@v1 / FR-02): --done 与 runStage(stage.js:35-45)同源状态转换守卫,
|
|
970
|
+
// 含 fromStageData 透传(让 scan failed_post_check 门控对 --done 同样生效)。
|
|
971
|
+
// auxiliary 的 --done 不受影响(checkTransition 对 auxiliary toStage 放行,见 stage-contract.js)。
|
|
972
|
+
// --skip-approval 绕过语义对齐 runStage(打错但放行)。
|
|
973
|
+
const prevStage = progress.currentStage || ''
|
|
974
|
+
const fromStageData = (progress.stages && prevStage && progress.stages[prevStage]) || undefined
|
|
975
|
+
const transition = checkTransition(prevStage, stageName, fromStageData ? { fromStageData } : {})
|
|
976
|
+
if (!transition.allowed) {
|
|
977
|
+
console.error(`❌ 阶段转换不允许: ${prevStage || '(起始)'} → ${stageName}`)
|
|
978
|
+
console.error(` 原因: ${transition.reason}`)
|
|
979
|
+
console.error(` 提示: 使用 --skip-approval 绕过(需明确意图)`)
|
|
980
|
+
if (!isSkipApproval) {
|
|
981
|
+
process.exit(1)
|
|
982
|
+
}
|
|
983
|
+
}
|
|
856
984
|
const doneAnswer = getFlagValue('--answer')
|
|
857
985
|
return await completeStep(pm, progress, stageName, cwd, outputText, inputText, { confirm: isConfirm, changeName: effectiveChange, nonInteractive: isNonInteractive && !isInteractive, platformOpts, doneAnswer, isForceBaseline, isAllowNew, isAllowDelete })
|
|
858
986
|
}
|