sillyspec 3.33.6 → 3.33.8
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/package.json +2 -2
- package/src/agent-session-log.js +4 -1
- package/src/change-delete.js +24 -1
- package/src/checkpoint.js +258 -0
- package/src/config-schema.js +14 -0
- package/src/flow.js +7 -0
- package/src/foreign-declared.js +2 -1
- package/src/index.js +96 -1
- package/src/knowledge-hits.js +80 -1
- package/src/overlap-detect.js +68 -0
- package/src/run/shared.js +7 -1
- package/src/task-tick.js +16 -0
- package/src/verify-postcheck.js +45 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sillyspec",
|
|
3
|
-
"version": "3.33.
|
|
3
|
+
"version": "3.33.8",
|
|
4
4
|
"description": "SillySpec CLI — 流程状态机,让 AI 严格按步骤来",
|
|
5
5
|
"icon": "logo.jpg",
|
|
6
6
|
"homepage": "https://sillyspec.ppdmq.top/",
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
],
|
|
25
25
|
"license": "MIT",
|
|
26
26
|
"scripts": {
|
|
27
|
-
"test:core": "node --test test/preview-migration.test.mjs test/preview-progress.test.mjs test/preview-outlet.test.mjs test/preview-gc.test.mjs test/preview-gate-isolation.test.mjs test/db-engine.test.mjs test/db-concurrency.test.mjs test/db-atomic-write.test.mjs test/platform-sync-schema.test.mjs test/platform-sync-serialization.test.mjs test/change-ownership-guards.test.mjs test/watcher.test.mjs test/watcher-alerts.test.mjs test/watcher-timeline.test.mjs test/sentinel-rules.test.mjs test/handoff.test.mjs test/execution-mode-render.test.mjs test/knowledge-inbox.test.mjs test/knowledge-baseline.test.mjs test/quick-laststep-fourfields-preview.test.mjs test/doctor-align-execute-progress.test.mjs test/test-ledger.test.mjs test/test-bindings.test.mjs test/flow-draft-binding-extract.test.mjs test/gate-snapshot-lifecycle.test.mjs test/gate-snapshot-cleanup.test.mjs test/verify-trace-residual.test.mjs test/platform-creds-fallback.test.mjs test/archive-timeline-bake.test.mjs test/doc-ref-check.test.mjs test/flow-status-json.test.mjs test/flow-status-title.test.mjs test/flow-resume-title.test.mjs test/datetime-timeago.test.mjs test/resume-domain-flip.test.mjs test/fr-regress-cap-drop.test.mjs test/fr-priority-overlap.test.mjs test/archive-commit-suggest-race.test.mjs test/flow-done-fail-brief.test.mjs test/verify-probes-refresh-backup.test.mjs test/taskcard-yaml-triage-validate.test.mjs test/gate-last-reader.test.mjs test/status-multi-active-list.test.mjs test/gate-docs-only.test.mjs test/probe7-fr-prefill.test.mjs test/plan-fill-batch-config.test.mjs test/step-guide-prune.test.mjs test/gate-stage-review-hint.test.mjs test/design-ref-suggest.test.mjs test/flow-friction-batch3.test.mjs test/wave-auto-adopt.test.mjs test/test-incremental-rerun.test.mjs test/knowledge-graph.test.mjs test/explore-graph-guidance.test.mjs test/git-helper-input.test.mjs test/push-plan.test.mjs test/push-cli.test.mjs test/push-skill.test.mjs test/flow-guidance.test.mjs",
|
|
27
|
+
"test:core": "node --test test/preview-migration.test.mjs test/preview-progress.test.mjs test/preview-outlet.test.mjs test/preview-gc.test.mjs test/preview-gate-isolation.test.mjs test/db-engine.test.mjs test/db-concurrency.test.mjs test/db-atomic-write.test.mjs test/platform-sync-schema.test.mjs test/platform-sync-serialization.test.mjs test/change-ownership-guards.test.mjs test/watcher.test.mjs test/watcher-alerts.test.mjs test/watcher-timeline.test.mjs test/sentinel-rules.test.mjs test/handoff.test.mjs test/execution-mode-render.test.mjs test/knowledge-inbox.test.mjs test/knowledge-baseline.test.mjs test/quick-laststep-fourfields-preview.test.mjs test/doctor-align-execute-progress.test.mjs test/test-ledger.test.mjs test/test-bindings.test.mjs test/flow-draft-binding-extract.test.mjs test/gate-snapshot-lifecycle.test.mjs test/gate-snapshot-cleanup.test.mjs test/verify-trace-residual.test.mjs test/platform-creds-fallback.test.mjs test/archive-timeline-bake.test.mjs test/doc-ref-check.test.mjs test/flow-status-json.test.mjs test/flow-status-title.test.mjs test/flow-resume-title.test.mjs test/datetime-timeago.test.mjs test/resume-domain-flip.test.mjs test/fr-regress-cap-drop.test.mjs test/fr-priority-overlap.test.mjs test/archive-commit-suggest-race.test.mjs test/flow-done-fail-brief.test.mjs test/verify-probes-refresh-backup.test.mjs test/taskcard-yaml-triage-validate.test.mjs test/gate-last-reader.test.mjs test/status-multi-active-list.test.mjs test/gate-docs-only.test.mjs test/probe7-fr-prefill.test.mjs test/plan-fill-batch-config.test.mjs test/step-guide-prune.test.mjs test/gate-stage-review-hint.test.mjs test/design-ref-suggest.test.mjs test/flow-friction-batch3.test.mjs test/wave-auto-adopt.test.mjs test/test-incremental-rerun.test.mjs test/target-files-main-docs-rescue.test.mjs test/knowledge-graph.test.mjs test/explore-graph-guidance.test.mjs test/git-helper-input.test.mjs test/push-plan.test.mjs test/push-cli.test.mjs test/push-skill.test.mjs test/flow-guidance.test.mjs",
|
|
28
28
|
"test": "node test/run-tests.mjs",
|
|
29
29
|
"lint": "node test/check-syntax.mjs",
|
|
30
30
|
"eval:dry": "node eval/run.mjs --dry-run",
|
package/src/agent-session-log.js
CHANGED
|
@@ -994,7 +994,10 @@ export function detectAgentLogEntries(opts) {
|
|
|
994
994
|
* specBase/local.yaml platform 段(url + token,与链路 A 同源)。
|
|
995
995
|
* @returns {{url:string, token:string}|null} 未配置返回 null(合法状态,静默跳过上报)
|
|
996
996
|
*/
|
|
997
|
-
function readPlatformPushConfig(specBase, env = process.env) {
|
|
997
|
+
export function readPlatformPushConfig(specBase, env = process.env) {
|
|
998
|
+
// 2026-10-28-knowledge-hits-cli-push:export 化(签名与行为零变化)——
|
|
999
|
+
// knowledge-hits.js 写后直报复用同一配置解析(单一源,不复制 local.yaml
|
|
1000
|
+
// 解析逻辑)。
|
|
998
1001
|
if (env.SILLYHUB_PLATFORM_URL && env.SILLYHUB_PLATFORM_TOKEN) {
|
|
999
1002
|
return { url: env.SILLYHUB_PLATFORM_URL, token: env.SILLYHUB_PLATFORM_TOKEN };
|
|
1000
1003
|
}
|
package/src/change-delete.js
CHANGED
|
@@ -106,12 +106,23 @@ export async function deleteChange({ cwd, specDir = null, changeName, confirm =
|
|
|
106
106
|
|
|
107
107
|
const dirFiles = dirExists ? countFiles(dirPath) : 0;
|
|
108
108
|
const worktreeInfo = await probeWorktree(cwd, changeName);
|
|
109
|
+
// 影子存档探测(2026-10-28-shadow-checkpoint):dry-run 也报存档链规模——「删了丢多少存档」
|
|
110
|
+
// 与「删了丢代码」同级的决策信息。非 git 仓/无 ref 静默 null。
|
|
111
|
+
let checkpointInfo = null;
|
|
112
|
+
try {
|
|
113
|
+
const { hasCheckpointRef, listCheckpoints } = await import('./checkpoint.js');
|
|
114
|
+
if (hasCheckpointRef(cwd, changeName)) {
|
|
115
|
+
const list = await listCheckpoints({ repoRoot: cwd, changeName });
|
|
116
|
+
checkpointInfo = { ref: `refs/sillyspec/checkpoints/${changeName}`, count: list.length };
|
|
117
|
+
}
|
|
118
|
+
} catch { /* 非 git 仓/探测失败:无存档面 */ }
|
|
109
119
|
|
|
110
120
|
if (!confirm) {
|
|
111
121
|
return {
|
|
112
122
|
ok: true, action: 'dry_run', change: changeName,
|
|
113
123
|
db_row: row, dir: dirExists ? { path: dirPath, files: dirFiles } : null,
|
|
114
124
|
worktree: worktreeInfo,
|
|
125
|
+
checkpoints: checkpointInfo,
|
|
115
126
|
already_deleted: alreadyDeleted,
|
|
116
127
|
};
|
|
117
128
|
}
|
|
@@ -149,6 +160,18 @@ export async function deleteChange({ cwd, specDir = null, changeName, confirm =
|
|
|
149
160
|
} catch (e) {
|
|
150
161
|
warnings.push(`worktree 清理失败(不阻断删除):${e.message}`);
|
|
151
162
|
}
|
|
163
|
+
// 3.5 影子存档 ref 清理(2026-10-28-shadow-checkpoint FR-05):删除是终态,存档链随变更
|
|
164
|
+
// 一并清 ref(对象交 git gc 自然回收);归档不删(审计面保留)。best-effort:失败仅警告。
|
|
165
|
+
let checkpointsRemoved = false;
|
|
166
|
+
try {
|
|
167
|
+
const { hasCheckpointRef, deleteCheckpointRef } = await import('./checkpoint.js');
|
|
168
|
+
if (hasCheckpointRef(cwd, changeName)) {
|
|
169
|
+
checkpointsRemoved = deleteCheckpointRef(cwd, changeName);
|
|
170
|
+
if (!checkpointsRemoved) warnings.push(`影子存档 ref 删除失败(不阻断,可手删 refs/sillyspec/checkpoints/${changeName})`);
|
|
171
|
+
}
|
|
172
|
+
} catch (e) {
|
|
173
|
+
warnings.push(`影子存档 ref 清理异常(不阻断删除):${e.message}`);
|
|
174
|
+
}
|
|
152
175
|
// 4. git 暂存目录删除(best-effort:非 git 仓 / specBase 在仓外时跳过,git status 照常可见)
|
|
153
176
|
try {
|
|
154
177
|
const relDir = relative(cwd, dirPath).split('\\').join('/');
|
|
@@ -178,6 +201,6 @@ export async function deleteChange({ cwd, specDir = null, changeName, confirm =
|
|
|
178
201
|
return {
|
|
179
202
|
ok: true, action: 'deleted', change: changeName,
|
|
180
203
|
db_updated: dbUpdated, dir_removed: dirExists, files_removed: dirFiles,
|
|
181
|
-
worktree: worktreeInfo, warnings,
|
|
204
|
+
worktree: worktreeInfo, checkpoints_removed: checkpointsRemoved, warnings,
|
|
182
205
|
};
|
|
183
206
|
}
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* checkpoint.js — 任务级影子提交存档(tick 即快照,2026-10-28-shadow-checkpoint)
|
|
3
|
+
*
|
|
4
|
+
* 定位:断点恢复从「进度级」(tasks.md 记做到哪)升级到「内容级」(工作区长什么样)——
|
|
5
|
+
* task tick 成功翻格后落一个 git 影子提交到 refs/sillyspec/checkpoints/<change>,agent
|
|
6
|
+
* 跑歪/改崩时 git restore --source 一键读档。git 对象库本身是内容寻址存储:增量成本=
|
|
7
|
+
* 只哈希变化文件,相同内容全局一份——零新依赖即得 CAS 快照性质。
|
|
8
|
+
*
|
|
9
|
+
* 机制(多 agent 共享仓安全是第一约束):
|
|
10
|
+
* - 临时索引 GIT_INDEX_FILE=.git/sillyspec-cp-index-<session>(会话隔离)——read-tree HEAD
|
|
11
|
+
* 起底 → add -A(硬排除清单)→ write-tree → commit-tree 成链(父=上一存档,首档父=
|
|
12
|
+
* 当时 HEAD)→ update-ref。共享 index 全程零读写(staged 面不可能被卷走,规则 11 纪律)。
|
|
13
|
+
* - 无变化 no-op:write-tree 结果与现 ref 树 SHA 相同即跳过(零对象零 ref 翻转)。
|
|
14
|
+
* - fail-open 铁律:本模块任何失败只返回 skipped(reason 留痕),绝不抛出阻断 tick。
|
|
15
|
+
*
|
|
16
|
+
* 边界:worktree 会话跳过(v1 主仓形态——git-dir ≠ common-dir 判定);未忽略 untracked
|
|
17
|
+
* 总量超阈值(缺省 200MB,checkpoint.maxUntrackedMB)早停跳过;.sillyspec/.runtime 与
|
|
18
|
+
* 常见产物目录/.env* 硬排除不进快照(gitignore 之外的第二道防线)。
|
|
19
|
+
*/
|
|
20
|
+
import { existsSync, readFileSync, rmSync, statSync } from 'fs'
|
|
21
|
+
import { join, resolve as resolvePath } from 'path'
|
|
22
|
+
import { git, gitQuiet } from './git-helper.js'
|
|
23
|
+
|
|
24
|
+
const REF_PREFIX = 'refs/sillyspec/checkpoints/'
|
|
25
|
+
const DEFAULT_MAX_UNTRACKED_MB = 200
|
|
26
|
+
const SUBJECT_PREFIX = 'sillyspec-cp: '
|
|
27
|
+
const NOOP_GUARD_TREE_TIMEOUT_MS = 60000
|
|
28
|
+
|
|
29
|
+
/** 硬排除清单(add 后在临时索引上 reset 的路径)。目录用 glob 双星任意深度形态
|
|
30
|
+
* (:(glob) 魔法 + 前置双星斜杠 + 尾部斜杠双星——实证 2.45:缺尾部 /** 时零匹配,
|
|
31
|
+
* 根级与嵌套 packages/x/node_modules 一并排除,2026-10-28-cp-review-debt FR-01);
|
|
32
|
+
* .env* 与 .sillyspec/.runtime 维持根级语义。 */
|
|
33
|
+
const EXCLUDE_DIRS = ['node_modules', 'dist', 'build', 'target', '.venv', 'venv', '__pycache__', '.next', '.cache', 'coverage']
|
|
34
|
+
const RESET_SPECS = [...EXCLUDE_DIRS.map((d) => `:(glob)**/${d}/**`), '.env', '.env.*', '.sillyspec/.runtime']
|
|
35
|
+
|
|
36
|
+
/** 与 EXCLUDE_SPECS 同口径的 JS 判定(体积护栏用——只计真会进快照的文件)。 */
|
|
37
|
+
function isHardExcluded(p) {
|
|
38
|
+
const segs = p.split('/')
|
|
39
|
+
if (segs.some((s) => EXCLUDE_DIRS.includes(s))) return true
|
|
40
|
+
const root = segs[0]
|
|
41
|
+
if (root === '.env' || /^\.env\..+$/.test(root)) return true
|
|
42
|
+
if (p === '.sillyspec/.runtime' || p.startsWith('.sillyspec/.runtime/')) return true
|
|
43
|
+
return false
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const SAFE_CHANGE_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 读 .sillyspec/local.yaml 的 checkpoint 块(手解析单一键路径,沿 worktree supplyFiles
|
|
50
|
+
* 先例零 yaml 依赖)。fail-open:文件缺失/解析异常一律返回缺省(enabled=true)。
|
|
51
|
+
* @returns {{enabled: boolean, maxUntrackedMB: number}}
|
|
52
|
+
*/
|
|
53
|
+
export function readCheckpointConfig(repoRoot) {
|
|
54
|
+
const defaults = { enabled: true, maxUntrackedMB: DEFAULT_MAX_UNTRACKED_MB }
|
|
55
|
+
try {
|
|
56
|
+
const p = join(repoRoot, '.sillyspec', 'local.yaml')
|
|
57
|
+
if (!existsSync(p)) return defaults
|
|
58
|
+
const vals = {}
|
|
59
|
+
let inBlock = false
|
|
60
|
+
for (const rawLine of readFileSync(p, 'utf8').split(/\r?\n/)) {
|
|
61
|
+
const line = rawLine.replace(/\t/g, ' ')
|
|
62
|
+
if (/^checkpoint:\s*(#.*)?$/.test(line)) { inBlock = true; continue }
|
|
63
|
+
if (!inBlock) continue
|
|
64
|
+
if (/^\S/.test(line)) break // 出块:下一个顶层键
|
|
65
|
+
const m = line.match(/^\s+([\w.-]+):\s*(.*?)\s*(#.*)?$/)
|
|
66
|
+
if (m && m[2] !== '') vals[m[1]] = m[2]
|
|
67
|
+
}
|
|
68
|
+
const enabled = vals.enabled === undefined ? true : !/^(false|off|no|0)$/i.test(String(vals.enabled))
|
|
69
|
+
const mb = Number(vals.maxUntrackedMB)
|
|
70
|
+
return { enabled, maxUntrackedMB: Number.isFinite(mb) && mb > 0 ? mb : DEFAULT_MAX_UNTRACKED_MB }
|
|
71
|
+
} catch {
|
|
72
|
+
return defaults
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** 主仓判定:worktree 会话(git-dir ≠ common-dir)v1 跳过存档。 */
|
|
77
|
+
function isWorktreeSession(repoRoot) {
|
|
78
|
+
const gitDir = gitQuiet(repoRoot, ['rev-parse', '--absolute-git-dir'])
|
|
79
|
+
const commonDir = gitQuiet(repoRoot, ['rev-parse', '--git-common-dir'])
|
|
80
|
+
if (!gitDir || !commonDir) return false // 探测失败不拦(留给后续 git 调用自然 fail-open)
|
|
81
|
+
if (commonDir === '.git') return false // 主仓:两者同指
|
|
82
|
+
try {
|
|
83
|
+
return resolvePath(repoRoot, gitDir) !== resolvePath(repoRoot, commonDir)
|
|
84
|
+
} catch {
|
|
85
|
+
return gitDir !== commonDir
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* 落一个影子提交(fail-open:任何失败返回 {kind:'skipped', reason},绝不抛出)。
|
|
91
|
+
* @param {{repoRoot: string, changeName: string, session?: string, taskId?: string}} opts
|
|
92
|
+
* @returns {Promise<{kind:'created'|'noop'|'skipped', sha?: string, treeSha?: string,
|
|
93
|
+
* parent?: string, files?: number, reason?: string, detail?: string}>}
|
|
94
|
+
*/
|
|
95
|
+
export async function createCheckpoint({ repoRoot, changeName, session, taskId }) {
|
|
96
|
+
const name = String(changeName || '')
|
|
97
|
+
if (!SAFE_CHANGE_NAME_RE.test(name)) return { kind: 'skipped', reason: 'invalid-change-name', detail: name }
|
|
98
|
+
const cfg = readCheckpointConfig(repoRoot)
|
|
99
|
+
if (!cfg.enabled) return { kind: 'skipped', reason: 'checkpoint-disabled' }
|
|
100
|
+
if (isWorktreeSession(repoRoot)) return { kind: 'skipped', reason: 'worktree-session' }
|
|
101
|
+
|
|
102
|
+
const ref = REF_PREFIX + name
|
|
103
|
+
const gitDir = gitQuiet(repoRoot, ['rev-parse', '--absolute-git-dir'])
|
|
104
|
+
if (!gitDir) return { kind: 'skipped', reason: 'not-a-git-repo' }
|
|
105
|
+
const sessionId = String(session || process.env.SILLYSPEC_SESSION_ID || 'anon').replace(/[^A-Za-z0-9._-]/g, '_')
|
|
106
|
+
const tmpIndex = join(gitDir, `sillyspec-cp-index-${sessionId}`)
|
|
107
|
+
// env 展开铁律(git-helper 终批-③):裸替换丢 SystemRoot/TEMP 在 Windows 必炸
|
|
108
|
+
const idxEnv = { ...process.env, GIT_INDEX_FILE: tmpIndex }
|
|
109
|
+
const head = gitQuiet(repoRoot, ['rev-parse', 'HEAD'])
|
|
110
|
+
if (!head) return { kind: 'skipped', reason: 'no-head-commit' }
|
|
111
|
+
|
|
112
|
+
try {
|
|
113
|
+
rmSync(tmpIndex, { force: true }) // 清上次中断残留(同会话同路径幂等)
|
|
114
|
+
git(repoRoot, ['read-tree', 'HEAD'], { env: idxEnv })
|
|
115
|
+
|
|
116
|
+
// 体积护栏:只计真会进快照的 untracked(硬排除同口径),累计超限早停
|
|
117
|
+
const maxBytes = cfg.maxUntrackedMB * 1024 * 1024
|
|
118
|
+
const othersRaw = gitQuiet(repoRoot, ['ls-files', '--others', '--exclude-standard', '-z'])
|
|
119
|
+
if (othersRaw) {
|
|
120
|
+
let bytes = 0
|
|
121
|
+
for (const p of othersRaw.split('\0')) {
|
|
122
|
+
if (!p || isHardExcluded(p)) continue
|
|
123
|
+
try {
|
|
124
|
+
bytes += statSync(join(repoRoot, p)).size
|
|
125
|
+
} catch { /* 文件瞬失:计 0 不拦 */}
|
|
126
|
+
if (bytes > maxBytes) return { kind: 'skipped', reason: 'untracked-size-over-limit', detail: `${Math.round(bytes / 1048576)}MB > ${cfg.maxUntrackedMB}MB` }
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// 裸 add -A(无 pathspec):ignored 文件静默跳过——显式 pathspec 形态遇 gitignored
|
|
131
|
+
// 候选会整体报错拒加("paths are ignored by .gitignore",.sillyspec/ 被忽略的仓必炸)。
|
|
132
|
+
// 硬排除随后以 reset 收窄:临时索引上把排除路径退回 HEAD 态(不在 HEAD 的即整条移出)。
|
|
133
|
+
git(repoRoot, ['add', '-A'], { env: idxEnv, timeout: NOOP_GUARD_TREE_TIMEOUT_MS })
|
|
134
|
+
git(repoRoot, ['reset', '-q', '--', ...RESET_SPECS], { env: idxEnv })
|
|
135
|
+
const treeSha = git(repoRoot, ['write-tree'], { env: idxEnv })
|
|
136
|
+
|
|
137
|
+
const current = gitQuiet(repoRoot, ['rev-parse', '--verify', ref])
|
|
138
|
+
if (current) {
|
|
139
|
+
const currentTree = gitQuiet(repoRoot, ['rev-parse', `${ref}^{tree}`])
|
|
140
|
+
if (currentTree === treeSha) return { kind: 'noop', treeSha } // 无变化:零对象零翻转
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const parent = current || head
|
|
144
|
+
const subject = `${SUBJECT_PREFIX}${name} ${taskId || '-'} ${new Date().toISOString()}`
|
|
145
|
+
const message = `${subject}\n\nshadow checkpoint for ${name}\ntask: ${taskId || '-'}\nsession: ${sessionId}\n`
|
|
146
|
+
// 身份兜底(沿 worktree baseline checkpoint 先例):未配置 user.email 时注入临时身份,
|
|
147
|
+
// 已配置则用真实身份——存档提交可归属
|
|
148
|
+
const cpEnv = { ...process.env }
|
|
149
|
+
if (!gitQuiet(repoRoot, ['config', 'user.email'])) {
|
|
150
|
+
cpEnv.GIT_AUTHOR_NAME = 'sillyspec-checkpoint'
|
|
151
|
+
cpEnv.GIT_AUTHOR_EMAIL = 'checkpoint@sillyspec.local'
|
|
152
|
+
cpEnv.GIT_COMMITTER_NAME = 'sillyspec-checkpoint'
|
|
153
|
+
cpEnv.GIT_COMMITTER_EMAIL = 'checkpoint@sillyspec.local'
|
|
154
|
+
}
|
|
155
|
+
const sha = git(repoRoot, ['commit-tree', treeSha, '-p', parent, '-F', '-'], { input: message, env: cpEnv })
|
|
156
|
+
git(repoRoot, ['update-ref', ref, sha])
|
|
157
|
+
|
|
158
|
+
let files = 0
|
|
159
|
+
const changed = gitQuiet(repoRoot, ['diff-tree', '--no-commit-id', '--name-only', '-r', sha])
|
|
160
|
+
if (changed) files = changed.split('\n').filter(Boolean).length
|
|
161
|
+
return { kind: 'created', sha, treeSha, parent, files }
|
|
162
|
+
} catch (e) {
|
|
163
|
+
return { kind: 'skipped', reason: 'git-error', detail: e && e.message ? e.message : String(e) }
|
|
164
|
+
} finally {
|
|
165
|
+
try { rmSync(tmpIndex, { force: true }) } catch { /* 清理失败:同会话下次覆盖 */ }
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* 列出变更的存档链(按时间倒序;链尾混入主历史,按 subject 前缀过滤)。
|
|
171
|
+
* @returns {Promise<Array<{sha: string, taskId: string|null, ts: number, files: number}>>}
|
|
172
|
+
*/
|
|
173
|
+
export async function listCheckpoints({ repoRoot, changeName }) {
|
|
174
|
+
const name = String(changeName || '')
|
|
175
|
+
if (!SAFE_CHANGE_NAME_RE.test(name)) return []
|
|
176
|
+
const ref = REF_PREFIX + name
|
|
177
|
+
const log = gitQuiet(repoRoot, ['log', '--format=%H%x09%at%x09%s', '-n', '200', ref])
|
|
178
|
+
if (!log) return []
|
|
179
|
+
const out = []
|
|
180
|
+
for (const line of log.split('\n')) {
|
|
181
|
+
if (!line) continue
|
|
182
|
+
const [sha, at, subject] = line.split('\t')
|
|
183
|
+
if (!subject || !subject.startsWith(SUBJECT_PREFIX)) continue // 链尾以下的主历史止步
|
|
184
|
+
const m = /^sillyspec-cp: \S+ (task-\d+|-) /.exec(subject)
|
|
185
|
+
const files = gitQuiet(repoRoot, ['diff-tree', '--no-commit-id', '--name-only', '-r', sha])
|
|
186
|
+
out.push({ sha, taskId: m && m[1] !== '-' ? m[1] : null, ts: Number(at) * 1000, files: files ? files.split('\n').filter(Boolean).length : 0 })
|
|
187
|
+
}
|
|
188
|
+
return out
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* 恢复预览:工作区 vs 存档点的 diff(patch 与 stat 各一份)。
|
|
193
|
+
* @returns {Promise<{stat: string, patch: string}>}
|
|
194
|
+
*/
|
|
195
|
+
export async function previewRestore({ repoRoot, sha, paths = [] }) {
|
|
196
|
+
const spec = ['--', ...(paths && paths.length ? paths : ['.'])]
|
|
197
|
+
const stat = git(repoRoot, ['diff', '--stat', sha, ...spec])
|
|
198
|
+
const patch = git(repoRoot, ['diff', sha, ...spec])
|
|
199
|
+
return { stat, patch }
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* 链内校验(无上限):subject 前缀过滤全链——不复用 listCheckpoints 的 -n 200 显示帽
|
|
204
|
+
* (截断解耦,2026-10-28-cp-review-debt FR-03:超帽旧档同样可读,主历史普通提交仍拒)。
|
|
205
|
+
*/
|
|
206
|
+
function isCheckpointInChain(repoRoot, ref, sha) {
|
|
207
|
+
const subjects = gitQuiet(repoRoot, ['log', '--format=%H %s', ref])
|
|
208
|
+
if (!subjects) return false
|
|
209
|
+
for (const line of subjects.split('\n')) {
|
|
210
|
+
if (!line) continue
|
|
211
|
+
const commitSha = line.slice(0, 40)
|
|
212
|
+
const subject = line.slice(41)
|
|
213
|
+
if (!subject.startsWith(SUBJECT_PREFIX)) break // 链尾以下为主历史,止步
|
|
214
|
+
if (commitSha === sha) return true
|
|
215
|
+
}
|
|
216
|
+
return false
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* 读档:git restore --source(仅写工作区,零触碰 index)。调用方负责 --yes 确认纪律。
|
|
221
|
+
* @param {{repoRoot: string, sha: string, paths?: string[], all?: boolean}} opts
|
|
222
|
+
* @returns {Promise<{ok: boolean, reason?: string, restored: string[]}>}
|
|
223
|
+
*/
|
|
224
|
+
export async function restoreCheckpoint({ repoRoot, sha, changeName, paths = [], all = false }) {
|
|
225
|
+
const name = String(changeName || '')
|
|
226
|
+
const ref = REF_PREFIX + name
|
|
227
|
+
if (!SAFE_CHANGE_NAME_RE.test(name) || !isCheckpointInChain(repoRoot, ref, sha)) {
|
|
228
|
+
return { ok: false, reason: 'sha 不在该变更存档链中(用 checkpoint --change 列出可用 SHA)', restored: [] }
|
|
229
|
+
}
|
|
230
|
+
const target = (all || !paths.length) ? ['.'] : paths
|
|
231
|
+
try {
|
|
232
|
+
git(repoRoot, ['restore', `--source=${sha}`, '--', ...target])
|
|
233
|
+
return { ok: true, restored: target }
|
|
234
|
+
} catch (e) {
|
|
235
|
+
return { ok: false, reason: e && e.message ? e.message : String(e), restored: [] }
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** 删除变更的存档 ref(change-delete 清理步用;best-effort 不抛出)。 */
|
|
240
|
+
export function deleteCheckpointRef(repoRoot, changeName) {
|
|
241
|
+
const name = String(changeName || '')
|
|
242
|
+
if (!SAFE_CHANGE_NAME_RE.test(name)) return false
|
|
243
|
+
try {
|
|
244
|
+
git(repoRoot, ['update-ref', '-d', REF_PREFIX + name])
|
|
245
|
+
return true
|
|
246
|
+
} catch {
|
|
247
|
+
return false
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** ref 存在性探测(dry-run 报告面用)。 */
|
|
252
|
+
export function hasCheckpointRef(repoRoot, changeName) {
|
|
253
|
+
const name = String(changeName || '')
|
|
254
|
+
if (!SAFE_CHANGE_NAME_RE.test(name)) return false
|
|
255
|
+
return Boolean(gitQuiet(repoRoot, ['rev-parse', '--verify', REF_PREFIX + name]))
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export default { createCheckpoint, listCheckpoints, previewRestore, restoreCheckpoint, deleteCheckpointRef, hasCheckpointRef, readCheckpointConfig }
|
package/src/config-schema.js
CHANGED
|
@@ -78,6 +78,15 @@ export const LOCAL_YAML_SCHEMA = {
|
|
|
78
78
|
{ path: 'commands.scan:check', type: 'string', optional: true, status: 'live', readers: ['scan 漂移检查(scripts/scan-drift-check.py 经 scan 流程调用)'], desc: '【2026-10-23-schema-registration-debt 平台仓 dogfood 补登】scan 产物漂移自检命令。', example: 'python scripts/scan-drift-check.py' },
|
|
79
79
|
],
|
|
80
80
|
},
|
|
81
|
+
{
|
|
82
|
+
id: 'checkpoint',
|
|
83
|
+
title: '影子存档(task tick 快照)',
|
|
84
|
+
note: '机制先落代码缺省(fail-open,缺省零回归)——键只用于仓主显式调优(2026-10-28-shadow-checkpoint)。',
|
|
85
|
+
keys: [
|
|
86
|
+
{ path: 'checkpoint.enabled', type: 'boolean', optional: true, status: 'live', readers: ['readCheckpointConfig (src/checkpoint.js)'], desc: '影子存档开关——task tick 成功翻格后落 git 影子提交到 refs/sillyspec/checkpoints/<change>(临时索引零触碰共享暂存区,断点恢复从进度级升到内容级)。缺省 true;false 关闭(tick 照常)。解析失败 fail-open 取缺省。', example: 'false' },
|
|
87
|
+
{ path: 'checkpoint.maxUntrackedMB', type: 'number', optional: true, status: 'live', readers: ['readCheckpointConfig (src/checkpoint.js)'], desc: '体积护栏——未忽略 untracked 总量超过该 MB 数时跳过本次存档并留痕(早停判定,不遍历全树;硬排除目录不计入)。缺省 200。', example: '50' },
|
|
88
|
+
],
|
|
89
|
+
},
|
|
81
90
|
{
|
|
82
91
|
id: 'knowledge',
|
|
83
92
|
title: '知识检索(平台向量语义召回)',
|
|
@@ -425,6 +434,11 @@ commands:
|
|
|
425
434
|
# lint_timeout_sec: 600 # commands.lint 超时秒数(缺省 180)
|
|
426
435
|
# knowledge-gate: false # 方案步 --done 知识检索命中回显开关(缺省 true 开;false 关回显)
|
|
427
436
|
|
|
437
|
+
# ── 影子存档(task tick 快照;缺省开启零配置,仅在显式调优时写)──
|
|
438
|
+
# checkpoint:
|
|
439
|
+
# enabled: true # 影子存档开关(false 关闭,tick 照常)
|
|
440
|
+
# maxUntrackedMB: 200 # 体积护栏——未忽略 untracked 总量超此 MB 数跳过存档留痕
|
|
441
|
+
|
|
428
442
|
# ── 知识检索(平台向量语义召回)──
|
|
429
443
|
# 缺省(未写整段)跟随平台能力自动探测:端点在场自动启用、未部署自动跳过(部署后自动
|
|
430
444
|
# 启用,零配置)。仅显式逃生阀时才需要写:
|
package/src/flow.js
CHANGED
|
@@ -2942,6 +2942,13 @@ async function cmdFlowVerifyDrafts({ change, cwd, specBase, runtimeRootOpt = nul
|
|
|
2942
2942
|
for (const a of r.advisories) console.warn(`⚠️ [门柱漂移 advisory] ${a}(不阻断批准)`)
|
|
2943
2943
|
if (r.degraded) console.log(`ℹ️ 自查降级:draft-ledger 不在此机(.runtime 机本位/存量变更)——空槽兜底校验通过(v2 纯 markdown 无法对锚校验,如实降级)`)
|
|
2944
2944
|
console.log(`✅ spec 面自查通过(0 violations)——通过≠批准:请把摘要给用户看,用户确认后 sillyspec flow approve --change ${change}(批准留痕后动码)`)
|
|
2945
|
+
// 重叠路由 advisory(2026-10-28-overlap-route FR-04):批准前最后一个高频触点——当前
|
|
2946
|
+
// 变更声明与他人活跃变更相交时一行分流提示。fail-open 静默:检测异常不拦批准不影响退出码。
|
|
2947
|
+
try {
|
|
2948
|
+
const { overlapAdvisoryFor } = await import('./overlap-detect.js')
|
|
2949
|
+
const line = overlapAdvisoryFor(change, { cwd, specBase })
|
|
2950
|
+
if (line) console.warn(line)
|
|
2951
|
+
} catch { /* advisory 静默:检测失败不拦批准 */ }
|
|
2945
2952
|
} else {
|
|
2946
2953
|
for (const a of r.advisories) console.warn(`⚠️ [门柱漂移 advisory] ${a}(不阻断批准)`)
|
|
2947
2954
|
if (hasViolations) {
|
package/src/foreign-declared.js
CHANGED
|
@@ -174,7 +174,8 @@ export function collectForeignDeclaredFiles(cwd, currentChangeName, opts = {}) {
|
|
|
174
174
|
* (退回旧 foreign-first 行为,fail-closed 不放大放行面)。
|
|
175
175
|
* @returns {Set<string>} 正斜杠归一后的声明路径集
|
|
176
176
|
*/
|
|
177
|
-
|
|
177
|
+
// export 补齐(2026-10-28-overlap-route):重叠检测复用声明加载单一源,防口径漂移
|
|
178
|
+
export function loadOwnDeclaredSet(specBase, runtimeRoot, currentChangeName) {
|
|
178
179
|
const set = new Set()
|
|
179
180
|
if (!currentChangeName) return set
|
|
180
181
|
const norm = (p) => String(p || '').replace(/\\/g, '/')
|
package/src/index.js
CHANGED
|
@@ -148,6 +148,11 @@ SillySpec CLI — 规范驱动开发工具包
|
|
|
148
148
|
sillyspec change-rename <旧变更名> <新变更名>
|
|
149
149
|
sillyspec change-delete <变更名> [--confirm] 删除变更(DB status='deleted' 与归档语义分离 +
|
|
150
150
|
移目录 + 清 worktree + 推平台墓碑;默认 dry-run)
|
|
151
|
+
sillyspec checkpoint --change <名> [--diff <sha> [--path <p>]] [--restore <sha>
|
|
152
|
+
[--path <p>|--all] --yes] [--delete-ref]
|
|
153
|
+
影子存档面:列出 task tick 自动落的存档链,预览/按路径读档
|
|
154
|
+
sillyspec overlap 查询活跃变更 target-file 声明重叠(advisory——检出即附
|
|
155
|
+
worktree 分流与落地序列指引)
|
|
151
156
|
sillyspec knowledge <search --query "..." --limit N
|
|
152
157
|
| inspect --id "..."
|
|
153
158
|
| validate | refresh
|
|
@@ -5055,6 +5060,96 @@ checkbox 行;depends_on 自动反填行内注解 "(depends_on: task-01,02)";
|
|
|
5055
5060
|
process.exitCode = r.ok ? 0 : 1;
|
|
5056
5061
|
break;
|
|
5057
5062
|
}
|
|
5063
|
+
case 'checkpoint': {
|
|
5064
|
+
// 2026-10-28-shadow-checkpoint:影子存档面——task tick 自动落档(refs/sillyspec/
|
|
5065
|
+
// checkpoints/<change>),本动词提供列出/预览/读档/清 ref 的人工面。恢复纪律(FR-03):
|
|
5066
|
+
// --restore 必须显式 --yes;先 --diff 同参预览再执行是推荐路径。
|
|
5067
|
+
const cpVal = (flag) => {
|
|
5068
|
+
const i = filteredArgs.indexOf(flag);
|
|
5069
|
+
return i >= 0 && filteredArgs[i + 1] && !String(filteredArgs[i + 1]).startsWith('--')
|
|
5070
|
+
? filteredArgs[i + 1] : null;
|
|
5071
|
+
};
|
|
5072
|
+
const cpChange = cpVal('--change');
|
|
5073
|
+
if (!cpChange) {
|
|
5074
|
+
console.error('❌ 用法: sillyspec checkpoint --change <名> [--diff <sha> [--path <p>]] [--restore <sha> [--path <p>|--all] --yes] [--delete-ref]');
|
|
5075
|
+
process.exitCode = 2;
|
|
5076
|
+
break;
|
|
5077
|
+
}
|
|
5078
|
+
const { listCheckpoints, previewRestore, restoreCheckpoint, deleteCheckpointRef } = await import('./checkpoint.js');
|
|
5079
|
+
const cpDiffSha = cpVal('--diff');
|
|
5080
|
+
const cpRestoreSha = cpVal('--restore');
|
|
5081
|
+
const pathArgs = [];
|
|
5082
|
+
for (let i = 0; i < filteredArgs.length - 1; i++) {
|
|
5083
|
+
if (filteredArgs[i] === '--path' && !String(filteredArgs[i + 1]).startsWith('--')) pathArgs.push(filteredArgs[i + 1]);
|
|
5084
|
+
}
|
|
5085
|
+
const cpAll = filteredArgs.includes('--all');
|
|
5086
|
+
const cpYes = filteredArgs.includes('--yes');
|
|
5087
|
+
const cpDeleteRef = filteredArgs.includes('--delete-ref');
|
|
5088
|
+
if (cpDeleteRef) {
|
|
5089
|
+
const okDel = deleteCheckpointRef(dir, cpChange);
|
|
5090
|
+
console.log(okDel ? `🗑️ 已删除 refs/sillyspec/checkpoints/${cpChange}` : `ℹ️ 无 refs/sillyspec/checkpoints/${cpChange}(无可删)`);
|
|
5091
|
+
break;
|
|
5092
|
+
}
|
|
5093
|
+
if (cpDiffSha) {
|
|
5094
|
+
try {
|
|
5095
|
+
const pv = await previewRestore({ repoRoot: dir, sha: cpDiffSha, paths: pathArgs });
|
|
5096
|
+
console.log(pv.stat || '(无差异)');
|
|
5097
|
+
if (pv.patch) console.log(pv.patch);
|
|
5098
|
+
} catch (e) {
|
|
5099
|
+
console.error(`❌ 预览失败:${e && e.message ? e.message : e}`);
|
|
5100
|
+
process.exitCode = 1;
|
|
5101
|
+
}
|
|
5102
|
+
break;
|
|
5103
|
+
}
|
|
5104
|
+
if (cpRestoreSha) {
|
|
5105
|
+
if (!cpYes) {
|
|
5106
|
+
console.error(`❌ 恢复需显式 --yes(建议先 --diff 同参预览):sillyspec checkpoint --change ${cpChange} --diff <sha> [--path p] 再 --restore <sha> --path <p> --yes`);
|
|
5107
|
+
process.exitCode = 2;
|
|
5108
|
+
break;
|
|
5109
|
+
}
|
|
5110
|
+
if (!cpAll && pathArgs.length === 0) {
|
|
5111
|
+
console.error('❌ 整树恢复需 --all(按路径恢复用 --path <p>,可多次)');
|
|
5112
|
+
process.exitCode = 2;
|
|
5113
|
+
break;
|
|
5114
|
+
}
|
|
5115
|
+
const rr = await restoreCheckpoint({ repoRoot: dir, sha: cpRestoreSha, changeName: cpChange, paths: pathArgs, all: cpAll });
|
|
5116
|
+
if (rr.ok) console.log(`🗂️ 已读档 ${cpRestoreSha.slice(0, 8)} → ${(cpAll ? ['.'] : pathArgs).join(' ')}(仅写工作区,共享暂存区未动)`);
|
|
5117
|
+
else { console.error(`❌ ${rr.reason}`); process.exitCode = 1; }
|
|
5118
|
+
break;
|
|
5119
|
+
}
|
|
5120
|
+
const list = await listCheckpoints({ repoRoot: dir, changeName: cpChange });
|
|
5121
|
+
if (!list.length) {
|
|
5122
|
+
console.log(`ℹ️ 变更 ${cpChange} 暂无影子存档(task tick 成功勾选时自动落档;checkpoint.enabled=false 时关闭)`);
|
|
5123
|
+
break;
|
|
5124
|
+
}
|
|
5125
|
+
console.log(`🗂️ 变更 ${cpChange} 影子存档链(${list.length} 档,新→旧):`);
|
|
5126
|
+
for (const c of list) {
|
|
5127
|
+
const when = new Date(c.ts).toISOString().replace('T', ' ').slice(0, 19);
|
|
5128
|
+
console.log(` ${c.sha.slice(0, 8)} ${when} ${c.taskId || '-'} ${c.files} 文件`);
|
|
5129
|
+
}
|
|
5130
|
+
console.log(`\n预览:sillyspec checkpoint --change ${cpChange} --diff <sha> [--path p]|读档:--restore <sha> --path <p> --yes${list.length >= 200 ? '\nℹ️ 列表显示截 200 档(仅展示面)——全量走 git:git log --format="%H %s" refs/sillyspec/checkpoints/' + cpChange : ''}`);
|
|
5131
|
+
break;
|
|
5132
|
+
}
|
|
5133
|
+
case 'overlap': {
|
|
5134
|
+
// 2026-10-28-overlap-route:target-file 声明重叠仓级查询(advisory 恒 exit 0)——
|
|
5135
|
+
// 事前路由信号:检出重叠附 worktree 分流与落地序列指引(不是禁止是分流)。
|
|
5136
|
+
const { detectDeclaredOverlaps } = await import('./overlap-detect.js');
|
|
5137
|
+
let overlaps = [];
|
|
5138
|
+
try { overlaps = detectDeclaredOverlaps({ cwd: dir, specBase: resolvePlatformSpecDir(dir, specDir) || join(dir, '.sillyspec') }); }
|
|
5139
|
+
catch (e) { console.log(`ℹ️ 重叠检测不可用(advisory 面静默退化):${e && e.message ? e.message : e}`); break; }
|
|
5140
|
+
if (!overlaps.length) {
|
|
5141
|
+
console.log(`✅ 无 target-file 声明重叠——各活跃变更声明面互不相交(落地零冲突预期)`);
|
|
5142
|
+
break;
|
|
5143
|
+
}
|
|
5144
|
+
console.log(`⚠️ ${overlaps.length} 个文件被多个活跃变更声明(并行改同一文件是冲突地带):`);
|
|
5145
|
+
for (const o of overlaps) {
|
|
5146
|
+
console.log(` ${o.file} ← ${o.changes.join('、')}`);
|
|
5147
|
+
}
|
|
5148
|
+
console.log(`\n🔀 分流建议:其一转 worktree 形态并行——sillyspec worktree apply <后到变更名>(隔离干活,互不可见互不干扰)。`);
|
|
5149
|
+
console.log(` 落地序列(既有机器):先落地者干净进主线;后落地者 apply --3way 三方合并(同文件不同区域自动过,同段冲突摆出裁决);并发落地由 main-repo.lock 串行。`);
|
|
5150
|
+
console.log(` 确认某方已死:归档后其声明自动退出重叠判定(归档件只读)。`);
|
|
5151
|
+
break;
|
|
5152
|
+
}
|
|
5058
5153
|
case 'flow': {
|
|
5059
5154
|
// R7 切片二:2-调用轻量协议命令族(start/done/amend-draft)——协议必需交互=2。
|
|
5060
5155
|
// --json 全局透传(2026-10-06-flow-status-json):该 flag 在上方全局解析段被剥出
|
|
@@ -5806,7 +5901,7 @@ SillySpec pull — 拉取服务器 spec 快照到本地(X2 / FR-07)
|
|
|
5806
5901
|
break;
|
|
5807
5902
|
}
|
|
5808
5903
|
default: {
|
|
5809
|
-
const topCommands = ['init', 'setup', 'run', 'progress', 'worktree', 'dispatch', 'agent-log', 'local', 'workflow', 'gate', 'derive', 'backfill-reviews', 'register-stage-review', 'modules', 'change-rename', 'change-delete', 'knowledge', 'platform', 'pull', 'scan', 'brainstorm', 'plan', 'execute', 'verify', 'archive', 'quick', 'explore', 'status', 'doctor', 'auto', 'runtime', 'watcher', 'commit', 'push'];
|
|
5904
|
+
const topCommands = ['init', 'setup', 'run', 'progress', 'worktree', 'dispatch', 'agent-log', 'local', 'workflow', 'gate', 'derive', 'backfill-reviews', 'register-stage-review', 'modules', 'change-rename', 'change-delete', 'checkpoint', 'overlap', 'knowledge', 'platform', 'pull', 'scan', 'brainstorm', 'plan', 'execute', 'verify', 'archive', 'quick', 'explore', 'status', 'doctor', 'auto', 'runtime', 'watcher', 'commit', 'push'];
|
|
5810
5905
|
const suggestion = didYouMean(command, topCommands);
|
|
5811
5906
|
console.error(`❌ 未知命令: ${command}`);
|
|
5812
5907
|
if (command === '--status') {
|
package/src/knowledge-hits.js
CHANGED
|
@@ -14,16 +14,92 @@
|
|
|
14
14
|
import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'fs'
|
|
15
15
|
import { runtimeStatePath } from './runtime-paths.js'
|
|
16
16
|
import { join } from 'path'
|
|
17
|
+
import { readPlatformPushConfig } from './agent-session-log.js'
|
|
17
18
|
|
|
18
19
|
const HITS_FILENAME = 'knowledge-hits.jsonl'
|
|
19
20
|
|
|
21
|
+
// 直报超时上限(2026-10-28-knowledge-hits-cli-push):对齐 agent-log PUSH_TIMEOUT_MS
|
|
22
|
+
// 量级——最坏情况下进程退出被活跃 socket 拖住的时长有界。
|
|
23
|
+
const HITS_PUSH_TIMEOUT_MS = 10_000
|
|
24
|
+
|
|
20
25
|
function hitsFilePath(runtimeDir) {
|
|
21
26
|
return runtimeStatePath(runtimeDir, HITS_FILENAME)
|
|
22
27
|
}
|
|
23
28
|
|
|
29
|
+
/**
|
|
30
|
+
* runtimeDir → specBase 反推(剥尾 `.runtime` 段;跨平台:正反斜杠与尾分隔符容忍)。
|
|
31
|
+
*
|
|
32
|
+
* 全部 appendKnowledgeHit 调用形态的 runtimeDir 最终都是 `<specBase>/.runtime`
|
|
33
|
+
* (join(specBase,'.runtime') 或 resolveRuntimeRoot 产物)——统一剥最后一段反推
|
|
34
|
+
* specBase 供平台配置读取(local.yaml 在 specBase 根)。结果保留输入的分隔符
|
|
35
|
+
* 形态(不做平台归一——Node fs 对 / 与 \ 双向兼容,归一反而扭曲 POSIX 形态输入)。
|
|
36
|
+
*
|
|
37
|
+
* @param {string} runtimeDir
|
|
38
|
+
* @returns {string} specBase(runtimeDir 末段不是 .runtime 时原样返回——容错面)
|
|
39
|
+
*/
|
|
40
|
+
export function specBaseFromRuntimeDir(runtimeDir) {
|
|
41
|
+
const raw = String(runtimeDir)
|
|
42
|
+
const norm = raw.replace(/\\/g, '/').replace(/\/+$/, '')
|
|
43
|
+
const idx = norm.lastIndexOf('/')
|
|
44
|
+
const last = idx === -1 ? norm : norm.slice(idx + 1)
|
|
45
|
+
if (last !== '.runtime') return runtimeDir
|
|
46
|
+
// \→/ 等长替换、去尾 / 只动末尾——norm 前 idx 字符与原串位置一一对应,
|
|
47
|
+
// 直接按剥点切片即保留原分隔符形态的 specBase。
|
|
48
|
+
return raw.slice(0, idx)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* 单行遥测直报平台(2026-10-28-knowledge-hits-cli-push FR-01)。
|
|
53
|
+
*
|
|
54
|
+
* POST {url}/api/changes/-/hits-sync(Bearer shpsync_ 令牌——workspace 由令牌派生),
|
|
55
|
+
* body {lines:[line], daemon_local_id:'cli'};接收端行级幂等(行 sha256 去重,
|
|
56
|
+
* 平台仓 2026-10-28-knowledge-hits-cli-direct)。best-effort 语义照
|
|
57
|
+
* pushAgentLogToPlatform 先例:无配置/被禁用静默跳过(返回 pushed:null);
|
|
58
|
+
* 网络/非 2xx/超时 warn 一行不抛(返回 pushed:false, reason)——本地行已落盘、
|
|
59
|
+
* daemon 周期上行与平台按钮 flush 为兜底通道。
|
|
60
|
+
*
|
|
61
|
+
* @param {object} p
|
|
62
|
+
* @param {string} p.specBase - local.yaml 所在 spec 基路径
|
|
63
|
+
* @param {object} [p.env] - env(默认 process.env;测试注入用)
|
|
64
|
+
* @param {string} p.line - 已落盘的遥测行(JSON 字符串)
|
|
65
|
+
* @returns {Promise<{pushed:boolean|null, reason?:string}>}
|
|
66
|
+
*/
|
|
67
|
+
export async function pushKnowledgeHitToPlatform({ specBase, env = process.env, line }) {
|
|
68
|
+
if (env.SILLYSPEC_HITS_PUSH === '0') {
|
|
69
|
+
return { pushed: null, reason: 'disabled' }
|
|
70
|
+
}
|
|
71
|
+
const cfg = readPlatformPushConfig(specBase, env)
|
|
72
|
+
if (!cfg) return { pushed: null, reason: 'no-config' }
|
|
73
|
+
const controller = new AbortController()
|
|
74
|
+
const timer = setTimeout(() => controller.abort(), HITS_PUSH_TIMEOUT_MS)
|
|
75
|
+
try {
|
|
76
|
+
const res = await fetch(`${cfg.url.replace(/\/+$/, '')}/api/changes/-/hits-sync`, {
|
|
77
|
+
method: 'POST',
|
|
78
|
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${cfg.token}` },
|
|
79
|
+
body: JSON.stringify({ lines: [line], daemon_local_id: 'cli' }),
|
|
80
|
+
signal: controller.signal,
|
|
81
|
+
})
|
|
82
|
+
if (!res.ok) {
|
|
83
|
+
console.warn(`[knowledge-hits-push] → HTTP ${res.status}(本地行已落盘,daemon 兜底)`)
|
|
84
|
+
return { pushed: false, reason: `http-${res.status}` }
|
|
85
|
+
}
|
|
86
|
+
return { pushed: true }
|
|
87
|
+
} catch (err) {
|
|
88
|
+
const msg = err && err.name === 'AbortError' ? '超时' : (err && err.message ? err.message : err)
|
|
89
|
+
console.warn(`[knowledge-hits-push] 直报失败: ${msg}(本地行已落盘,daemon 兜底)`)
|
|
90
|
+
return { pushed: false, reason: 'network' }
|
|
91
|
+
} finally {
|
|
92
|
+
clearTimeout(timer)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
24
96
|
/**
|
|
25
97
|
* 追加一条遥测记录(目录/文件缺失自动创建)。
|
|
26
98
|
*
|
|
99
|
+
* 2026-10-28-knowledge-hits-cli-push:落盘成功后 void fire 平台直报(FR-01/02)——
|
|
100
|
+
* 不 await 不抛(失败 warn 一行由 pushKnowledgeHitToPlatform 内部收口),主流程
|
|
101
|
+
* 同步语义与返回值不变;无配置/被禁用时零行为差异。
|
|
102
|
+
*
|
|
27
103
|
* @param {string} runtimeDir - .runtime 目录
|
|
28
104
|
* @param {object} hit - 记录对象,常用字段 { type:'inject'|'classify', change, query, matchedFiles, at }
|
|
29
105
|
* (at 缺省时补当前 ISO 时间;其余字段原样序列化,classify 的 qlId/targetFile 等照写)
|
|
@@ -35,7 +111,10 @@ export function appendKnowledgeHit(runtimeDir, hit) {
|
|
|
35
111
|
record.at = new Date().toISOString()
|
|
36
112
|
}
|
|
37
113
|
mkdirSync(runtimeDir, { recursive: true })
|
|
38
|
-
|
|
114
|
+
const line = JSON.stringify(record)
|
|
115
|
+
appendFileSync(hitsFilePath(runtimeDir), line + '\n', 'utf8')
|
|
116
|
+
// 写后直报(void fire):本地已落盘,直报成败不影响本函数语义(FR-02)。
|
|
117
|
+
void pushKnowledgeHitToPlatform({ specBase: specBaseFromRuntimeDir(runtimeDir), line }).catch(() => {})
|
|
39
118
|
return record
|
|
40
119
|
}
|
|
41
120
|
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* overlap-detect.js — 活跃变更 target-file 声明重叠检测(2026-10-28-overlap-route)
|
|
3
|
+
*
|
|
4
|
+
* 定位:同 target-file 并行的事前路由信号——落地侧机器已厚(worktree apply --3way 三方
|
|
5
|
+
* 合并、main-repo.lock 全仓串行、guard 相交预检、base 漂移 re-anchor),缺的是干活期的
|
|
6
|
+
* 「两个活跃变更声明了同一文件」检测:重叠拖到落地才撞见,错失把后到者分流进 worktree
|
|
7
|
+
* 的窗口。本模块只做检测与呈现(advisory),不做调度不做阻断——「不是禁止,是分流」。
|
|
8
|
+
*
|
|
9
|
+
* 机制:声明加载单一源复用 foreign-declared.loadOwnDeclaredSet(design §6 文件变更
|
|
10
|
+
* 清单 ∪ task 卡 allowed_paths/target_files,quick 会话 guard 形态),构建文件→变更集
|
|
11
|
+
* 反查表,声明同一文件的变更数 ≥2 即重叠条目。瞬时快照零持久状态;归档(目录移入
|
|
12
|
+
* changes/archive/)天然退出反查表。
|
|
13
|
+
*
|
|
14
|
+
* 消费面:sillyspec overlap 仓级查询动词;flow verify-drafts 通过分支的路由 advisory。
|
|
15
|
+
*/
|
|
16
|
+
import { readdirSync } from 'fs'
|
|
17
|
+
import { join } from 'path'
|
|
18
|
+
import { loadOwnDeclaredSet } from './foreign-declared.js'
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* 检测活跃变更间的 target-file 声明重叠。
|
|
22
|
+
* @param {{cwd?: string, specBase?: string}} opts 二选一(specBase 优先)
|
|
23
|
+
* @returns {Array<{file: string, changes: string[]}>} file 正斜杠归一、changes 字典序、
|
|
24
|
+
* 条目按 file 排序;无重叠返回 []。任何读取失败 fail-open(该变更空集),绝不抛出。
|
|
25
|
+
*/
|
|
26
|
+
export function detectDeclaredOverlaps({ cwd, specBase } = {}) {
|
|
27
|
+
const base = specBase || join(cwd || process.cwd(), '.sillyspec')
|
|
28
|
+
const runtimeRoot = join(base, '.runtime')
|
|
29
|
+
let names = []
|
|
30
|
+
try {
|
|
31
|
+
names = readdirSync(join(base, 'changes'), { withFileTypes: true })
|
|
32
|
+
.filter((e) => e.isDirectory() && e.name !== 'archive')
|
|
33
|
+
.map((e) => e.name)
|
|
34
|
+
} catch {
|
|
35
|
+
return [] // 无 changes/ 目录:零活跃变更
|
|
36
|
+
}
|
|
37
|
+
const byFile = new Map()
|
|
38
|
+
for (const name of names) {
|
|
39
|
+
const own = loadOwnDeclaredSet(base, runtimeRoot, name) // 内部 fail-open:解析失败空集
|
|
40
|
+
if (!own || own.size === 0) continue
|
|
41
|
+
for (const f of own) {
|
|
42
|
+
if (!byFile.has(f)) byFile.set(f, new Set())
|
|
43
|
+
byFile.get(f).add(name)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
const overlaps = []
|
|
47
|
+
for (const [file, changes] of byFile) {
|
|
48
|
+
if (changes.size >= 2) overlaps.push({ file, changes: [...changes].sort() })
|
|
49
|
+
}
|
|
50
|
+
return overlaps.sort((a, b) => a.file.localeCompare(b.file))
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* 指定变更的重叠路由提示文案(verify-drafts 通过分支钩子用)。
|
|
55
|
+
* @returns {string|null} null=该变更无重叠(调用方零输出);有则返回一行提示
|
|
56
|
+
* (分流建议 + 落地序列 + 详查指引)。纯函数:检测异常由上层 try/catch fail-open。
|
|
57
|
+
*/
|
|
58
|
+
export function overlapAdvisoryFor(changeName, { cwd, specBase } = {}) {
|
|
59
|
+
const name = String(changeName || '')
|
|
60
|
+
if (!name) return null
|
|
61
|
+
const mine = detectDeclaredOverlaps({ cwd, specBase }).filter((o) => o.changes.includes(name))
|
|
62
|
+
if (mine.length === 0) return null
|
|
63
|
+
const others = [...new Set(mine.flatMap((o) => o.changes).filter((c) => c !== name))].join('、')
|
|
64
|
+
const files = mine.slice(0, 3).map((o) => `${o.file}←${o.changes.join(',')}`).join(';') + (mine.length > 3 ? ` 等 ${mine.length} 个` : '')
|
|
65
|
+
return `⚠️ [重叠路由] 本变更与活跃变更 ${others} 声明了相同文件(${files})——并行改同一文件是冲突地带,建议其一转 worktree 形态(sillyspec worktree apply <名>);落地序列:先落地干净进、后落地 apply --3way 三方合并(并发由 main-repo.lock 串行)。详见 sillyspec overlap`
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export default { detectDeclaredOverlaps, overlapAdvisoryFor }
|
package/src/run/shared.js
CHANGED
|
@@ -109,7 +109,10 @@ export function assertDatedChangeName(name, label = '变更名') {
|
|
|
109
109
|
|
|
110
110
|
import { buildExecuteSteps } from '../stages/execute.js'
|
|
111
111
|
import { buildPlanSteps } from '../stages/plan.js'
|
|
112
|
-
|
|
112
|
+
// stageRegistry 不静态引 stages/index.js(2026-10-28-stages-cycle-root-fix):execute.js 子树
|
|
113
|
+
// (knowledge-hits → agent-session-log → run/shared)回边使 index.js 首次求值 stageRegistry
|
|
114
|
+
// 时 execute 绑定仍在 TDZ(execute.js:19-21 注释记载的坑形态再犯)——唯一使用点
|
|
115
|
+
// getStageSteps 已是 async,点用动态 import 断环(同函数 await import 惰性先例)。
|
|
113
116
|
import { parseRepo, parseRepoRegistry } from '../stages/plan-postcheck.js'
|
|
114
117
|
import { MultiRepoContext } from './multi-repo-context.js'
|
|
115
118
|
|
|
@@ -2505,6 +2508,9 @@ export async function getStageSteps(stageName, cwd, progress, specDir = null) {
|
|
|
2505
2508
|
return buildQuickScanSteps()
|
|
2506
2509
|
}
|
|
2507
2510
|
}
|
|
2511
|
+
// 动态取注册表(断环点,见文件头 import 区注释):首次求值时机后移到调用点,
|
|
2512
|
+
// 此处到达时 stages/index.js 及其全部阶段定义必已完成初始化
|
|
2513
|
+
const { stageRegistry } = await import('../stages/index.js')
|
|
2508
2514
|
const def = stageRegistry[stageName]
|
|
2509
2515
|
return def ? def.steps : null
|
|
2510
2516
|
}
|
package/src/task-tick.js
CHANGED
|
@@ -158,6 +158,22 @@ export async function runTaskTick({ changeName, cwd, taskId, specBase, evidence
|
|
|
158
158
|
} catch (e) {
|
|
159
159
|
console.warn(`⚠️ 勾选留痕写失败(task-ticks.jsonl:${e && e.code ? e.code : e})——翻格不受影响,timeline 勾选时刻退事件流推断`)
|
|
160
160
|
}
|
|
161
|
+
// 影子存档(2026-10-28-shadow-checkpoint):勾选即快照——tasks.md 翻格+侧车落盘后落一个
|
|
162
|
+
// git 影子提交到 refs/sillyspec/checkpoints/<change>,断点恢复从进度级升到内容级。
|
|
163
|
+
// best-effort fail-open(模块内零抛出,此处再兜一层):任何失败只回显一行,绝不影响翻格。
|
|
164
|
+
try {
|
|
165
|
+
const { createCheckpoint } = await import('./checkpoint.js')
|
|
166
|
+
const cp = await createCheckpoint({
|
|
167
|
+
repoRoot: cwd, changeName,
|
|
168
|
+
session: process.env.SILLYSPEC_SESSION_ID,
|
|
169
|
+
taskId: normalizeTaskId(taskId) || taskId,
|
|
170
|
+
})
|
|
171
|
+
if (cp.kind === 'created') console.log(`🗂️ 影子存档 ${String(cp.sha).slice(0, 8)}(${cp.files} 文件变更)→ refs/sillyspec/checkpoints/${changeName}`)
|
|
172
|
+
else if (cp.kind === 'noop') console.log(`🗂️ 影子存档 noop(工作区无变化,零对象)`)
|
|
173
|
+
else console.log(`🗂️ 影子存档跳过:${cp.reason}${cp.detail ? `(${cp.detail})` : ''}`)
|
|
174
|
+
} catch (e) {
|
|
175
|
+
console.warn(`⚠️ 影子存档失败(不阻断勾选):${e && e.message ? e.message : e}`)
|
|
176
|
+
}
|
|
161
177
|
// 工件即时重推(2026-10-07-allticked-gate-docs-resync):翻格即任务面变更——顺带触发
|
|
162
178
|
// spec-sync,平台不再停留 start 时点快照(真实会话实证:改写后平台恒显初稿)。best-effort。
|
|
163
179
|
try {
|
package/src/verify-postcheck.js
CHANGED
|
@@ -4806,6 +4806,51 @@ export function reconcileTargetFiles({ cwd, specBase = null, changeName = null,
|
|
|
4806
4806
|
if (rescuedCount > 0) {
|
|
4807
4807
|
notes.push(`对账救赎:${rescuedCount} 个声明文件经近窗提交触及面命中(isolated worktree per-task commit 形态,porcelain 恒空——R9/R11 四连拦根治)`)
|
|
4808
4808
|
}
|
|
4809
|
+
// 主仓治理文档救赎(坑 target-files-worktree-docs-mismatch,2026-10-26-session-stage-progress
|
|
4810
|
+
// verify 实证):模块卡等主仓 .sillyspec/ 文档按 CLI 铁律「一律写主仓,绝不写 worktree 副本」
|
|
4811
|
+
// ——worktree 形态 actual(wt diff+porcelain)结构性看不见主仓侧改动,声明主仓治理路径恒落
|
|
4812
|
+
// ②类假红(「写 worktree=产物蒸发,写主仓=对账 missing」两规则打架)。救赎面=主仓 .sillyspec/
|
|
4813
|
+
// 的 status(未提交)∪ baseline..HEAD diff(已提交),仅救赎「声明而 missing」的主仓治理路径
|
|
4814
|
+
// (不扩 undeclared 面——与 R9/R11 窗口救赎同语义)。fail-open:主仓读取失败走既有 missing 判定。
|
|
4815
|
+
if (actual.form === 'worktree') {
|
|
4816
|
+
const govDeclared = declaredPaths.filter((p) => {
|
|
4817
|
+
const clean = String(p).replace(/\\/g, '/').replace(/^NEW:/, '')
|
|
4818
|
+
return clean.startsWith('.sillyspec/')
|
|
4819
|
+
})
|
|
4820
|
+
if (govDeclared.length > 0) {
|
|
4821
|
+
let mainDocsRescued = 0
|
|
4822
|
+
try {
|
|
4823
|
+
const mainFace = new Set()
|
|
4824
|
+
// -uall:未跟踪目录缺省折叠成目录条目(?? .sillyspec/docs/<proj>/ 整目录一行)——
|
|
4825
|
+
// 模块卡常为新建文件,折叠形态对不上文件级声明,逐文件展开(scope-audit 同款口径)
|
|
4826
|
+
const st = gitQuiet(cwd, ['status', '--porcelain', '--untracked-files=all', '--', '.sillyspec/'], { timeout: 30000, trim: false })
|
|
4827
|
+
for (const line of String(st || '').split('\n')) {
|
|
4828
|
+
if (!line || line.length < 4) continue
|
|
4829
|
+
const p = line.slice(3).trim().replace(/^"|"$/g, '')
|
|
4830
|
+
const arrow = p.indexOf(' -> ')
|
|
4831
|
+
const f = (arrow !== -1 ? p.slice(arrow + 4) : p).replace(/\\/g, '/')
|
|
4832
|
+
if (f) mainFace.add(pathKey(f))
|
|
4833
|
+
}
|
|
4834
|
+
if (actual.baseAnchor) {
|
|
4835
|
+
const df = gitQuiet(cwd, ['diff', '--name-only', `${actual.baseAnchor}..HEAD`, '--', '.sillyspec/'], { timeout: 30000 })
|
|
4836
|
+
for (const f of String(df || '').split('\n')) {
|
|
4837
|
+
const t = f.trim().replace(/\\/g, '/')
|
|
4838
|
+
if (t) mainFace.add(pathKey(t))
|
|
4839
|
+
}
|
|
4840
|
+
}
|
|
4841
|
+
for (const p of govDeclared) {
|
|
4842
|
+
const clean = String(p).replace(/\\/g, '/').replace(/^NEW:/, '')
|
|
4843
|
+
if (!actualKeySet.has(pathKey(p)) && mainFace.has(pathKey(clean))) {
|
|
4844
|
+
actualKeySet.add(pathKey(p))
|
|
4845
|
+
mainDocsRescued++
|
|
4846
|
+
}
|
|
4847
|
+
}
|
|
4848
|
+
} catch { /* 主仓治理面读取失败 → 走既有 missing 判定(fail-open 不新造阻断) */ }
|
|
4849
|
+
if (mainDocsRescued > 0) {
|
|
4850
|
+
notes.push(`主仓治理文档救赎:${mainDocsRescued} 个 .sillyspec/ 声明经主仓实际改动面命中(worktree 对账面看不见主仓侧模块卡等治理文档——「治理文档一律写主仓」铁律与 worktree actual 的结构性错配,2026-10-26 实证)`)
|
|
4851
|
+
}
|
|
4852
|
+
}
|
|
4853
|
+
}
|
|
4809
4854
|
// ①交集(计数进 evidence):唯一路径口径(多卡声明同文件只计一次)
|
|
4810
4855
|
for (const path of declaredPaths) {
|
|
4811
4856
|
if (actualKeySet.has(pathKey(path))) matched.push(path)
|