sillyspec 3.27.9 → 3.27.12

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.
@@ -0,0 +1,170 @@
1
+ /**
2
+ * facade-hint.js — plan 门「透传必经文件」候选探测(advisory)。
3
+ *
4
+ * 坑 plan-facade-files-manual-backfill(2026-08-28 用户实证 ×2):facade 文件(如
5
+ * daemon/service.py——新端点/新模块必须在它里面挂载/转发才对外生效)不进 design §6 /
6
+ * TaskCard allowed_paths,执行期子代理按现实改它 → apply Gate1 拦截 → 人工回补
7
+ * allowed_paths 两轮。本模块在 plan 完成时做静态引用扫描,把候选透传/聚合文件提前
8
+ * 亮给 agent(warning 不阻断):判定是语义,agent 拍板补不补。
9
+ *
10
+ * 两个信号(语言覆盖 py/js/ts/mjs/cjs/jsx/tsx/java 的 import/require 静态文本匹配):
11
+ * - direct:仓内文件 import 了 allowed_paths 里的模块(改被 facade 引用的模块,
12
+ * facade 常需同步改——转发签名/挂载点);
13
+ * - aggregate:仓内文件 import 了该模块同目录下 ≥2 个模块(聚合器/注册表形态,
14
+ * 新增同目录模块几乎必然要在此登记——service.py 挂 router 即此形态)。
15
+ *
16
+ * 启发式 → 恒 advisory:候选只列不改,由 agent 判定补 allowed_paths / design §6,
17
+ * 或确认无关忽略。性能护栏:allowed 条目 > 100 或仓内源码文件 > 5000 时静默跳过。
18
+ */
19
+ import { existsSync, readdirSync, readFileSync, statSync } from 'fs'
20
+ import { join, dirname, basename, extname, resolve } from 'path'
21
+
22
+ const SOURCE_EXTS = new Set(['.py', '.js', '.ts', '.jsx', '.tsx', '.mjs', '.cjs', '.java'])
23
+ const SKIP_DIRS = new Set([
24
+ '.git', 'node_modules', 'dist', 'build', 'out', '.next', '__pycache__',
25
+ '.venv', 'venv', 'env', '.sillyspec', '.worktrees', '.claude', '.runtime',
26
+ 'coverage', 'target', '.idea', '.vscode',
27
+ ])
28
+ const MAX_ALLOWED_ENTRIES = 100
29
+ const MAX_REPO_FILES = 5000
30
+ const MAX_FILE_BYTES = 512 * 1024
31
+
32
+ const toPosix = (p) => String(p).replace(/\\/g, '/')
33
+
34
+ /** 递归收集仓内源码文件(posix 相对路径),带总量护栏。 */
35
+ function walkSourceFiles(root, out = []) {
36
+ if (out.length > MAX_REPO_FILES) return out
37
+ let names
38
+ try { names = readdirSync(root) } catch { return out }
39
+ for (const name of names) {
40
+ if (SKIP_DIRS.has(name)) continue
41
+ const abs = join(root, name)
42
+ let st
43
+ try { st = statSync(abs) } catch { continue }
44
+ if (st.isDirectory()) walkSourceFiles(abs, out)
45
+ else if (st.isFile() && SOURCE_EXTS.has(extname(name).toLowerCase()) && st.size <= MAX_FILE_BYTES) {
46
+ out.push(toPosix(join(root, name)))
47
+ if (out.length > MAX_REPO_FILES) return out
48
+ }
49
+ }
50
+ return out
51
+ }
52
+
53
+ /** 从源码文本提取 import/require 说明符(py dotted + js/ts path-ish)。 */
54
+ export function extractImportSpecifiers(text, ext) {
55
+ const specs = new Set()
56
+ if (ext === '.py') {
57
+ // from X import a, b → 记 pkg 本身 + 逐名拼 'X.a'/'X.b'(前者保 from X.y import sym 的
58
+ // 直接引用形态,后者保 service.py 聚合挂载形态:from daemon.routers import users, orders)
59
+ for (const m of text.matchAll(/\bfrom\s+([.\w]+)\s+import\s+([^\n#]+)/g)) {
60
+ specs.add(m[1])
61
+ const names = m[2].split(',')
62
+ .map(s => s.trim().split(/\s+as\s+/i)[0].trim())
63
+ .filter(n => /^\w+$/.test(n))
64
+ for (const n of names) specs.add(`${m[1]}.${n}`)
65
+ }
66
+ for (const m of text.matchAll(/(?:^|\n)\s*import\s+([\w.]+)/g)) specs.add(m[1])
67
+ } else if (ext === '.java') {
68
+ for (const m of text.matchAll(/\bimport\s+([\w.]+)\s*;/g)) specs.add(m[1])
69
+ } else {
70
+ for (const m of text.matchAll(/(?:from\s+|require\(\s*|import\(\s*)['"]([^'"]+)['"]/g)) specs.add(m[1])
71
+ }
72
+ return [...specs]
73
+ }
74
+
75
+ /** 说明符是否指向模块名 mod(`m` / `pkg.m` / `./m` / `../d/m` 等尾部命中形态)。 */
76
+ export function specRefsModule(spec, mod) {
77
+ if (!spec || !mod) return false
78
+ if (spec === mod) return true
79
+ return spec.endsWith('.' + mod) || spec.endsWith('/' + mod)
80
+ }
81
+
82
+ /**
83
+ * 探测 allowed_paths 之外的 facade/聚合候选文件。
84
+ *
85
+ * @param {{ cwd: string, allowedPaths: string[] }} opts
86
+ * @returns {{ skipped: boolean, reason: string|null, candidates: Array<{file: string, via: string[]}> }}
87
+ */
88
+ export function findFacadeCandidates({ cwd, allowedPaths }) {
89
+ const root = cwd || process.cwd()
90
+ const allowed = (allowedPaths || []).map(toPosix).filter(p => !p.includes('*'))
91
+ if (allowed.length === 0) return { skipped: true, reason: 'no-allowed-paths', candidates: [] }
92
+ if (allowed.length > MAX_ALLOWED_ENTRIES) return { skipped: true, reason: 'allowed-too-many', candidates: [] }
93
+
94
+ const files = walkSourceFiles(root)
95
+ if (files.length > MAX_REPO_FILES) return { skipped: true, reason: 'repo-too-large', candidates: [] }
96
+
97
+ const allowedSet = new Set(allowed)
98
+ const isAllowed = (abs) => allowed.some(a => abs === a || abs.endsWith('/' + a) || a.endsWith('/' + abs) || abs.replace(/\.[^.]+$/, '') === a.replace(/\.[^.]+$/, ''))
99
+
100
+ // 目录 → 该目录下模块名索引(聚合信号用:R import 同目录 ≥2 模块 = 聚合器)
101
+ const dirModules = new Map()
102
+ for (const f of files) {
103
+ const d = dirname(f)
104
+ if (!dirModules.has(d)) dirModules.set(d, new Set())
105
+ dirModules.get(d).add(basename(f, extname(f)))
106
+ }
107
+
108
+ // 预读一遍源码 import(file → specs),两信号共用
109
+ const fileSpecs = []
110
+ for (const f of files) {
111
+ if (isAllowed(f)) continue
112
+ let text
113
+ try { text = readFileSync(f, 'utf8') } catch { continue }
114
+ fileSpecs.push({ file: f, specs: extractImportSpecifiers(text, extname(f).toLowerCase()) })
115
+ }
116
+
117
+ // 逐 allowed 文件探测 direct + aggregate 命中
118
+ const byFile = new Map()
119
+ for (const p of allowed) {
120
+ const mod = basename(p, extname(p))
121
+ // allowed 是相对仓根路径,目录索引键是绝对路径(walk 产物)——resolve 对齐再查
122
+ const dirKey = toPosix(resolve(root, dirname(p)))
123
+ const dirLabel = toPosix(dirname(p))
124
+ const dirModSet = dirModules.get(dirKey) || new Set()
125
+ for (const { file, specs } of fileSpecs) {
126
+ const via = []
127
+ if (specs.some(s => specRefsModule(s, mod))) via.push(`direct import of ${mod}`)
128
+ // aggregate:R 引用 mod 同目录 ≥2 个不同仓内模块(mod 自身可不在——新增模块形态)
129
+ const hitMods = new Set()
130
+ for (const s of specs) {
131
+ for (const b of dirModSet) {
132
+ if (specRefsModule(s, b)) { hitMods.add(b); break }
133
+ }
134
+ }
135
+ if (hitMods.size >= 2) via.push(`aggregates ≥2 modules of ${dirLabel === '.' ? './' : dirLabel + '/'}`)
136
+ if (via.length === 0) continue
137
+ const existing = byFile.get(file) || []
138
+ for (const v of via) if (!existing.includes(v)) existing.push(v)
139
+ byFile.set(file, existing)
140
+ }
141
+ }
142
+ const candidates = [...byFile.entries()]
143
+ .map(([file, via]) => ({ file: toPosix(file), via }))
144
+ .sort((a, b) => a.file.localeCompare(b.file))
145
+ return { skipped: false, reason: null, candidates }
146
+ }
147
+
148
+ /**
149
+ * plan 门消费入口:探测 + 打印(advisory,不阻断)。allowed 集与 apply Gate1 同源
150
+ * (resolveApplyAllowSet 的主仓切片),确保「预检亮出的候选」与「执行期会拦的门」
151
+ * 用同一份清单口径。
152
+ */
153
+ export function warnFacadeCandidateFiles({ cwd, changeName, allowSet }) {
154
+ try {
155
+ const r = findFacadeCandidates({ cwd, allowedPaths: [...(allowSet || [])] })
156
+ if (r.skipped || r.candidates.length === 0) return r
157
+ console.warn(`\n⚠️ plan 预检:${r.candidates.length} 个 allowed_paths 外的「透传/聚合」候选文件(advisory,不阻断):`)
158
+ for (const c of r.candidates.slice(0, 10)) {
159
+ console.warn(` - ${c.file}(${[...new Set(c.via)].join('; ')})`)
160
+ }
161
+ if (r.candidates.length > 10) console.warn(` …还有 ${r.candidates.length - 10} 个`)
162
+ console.warn(' 若属「透传必经文件」(facade 转发/路由挂载/注册登记——改 allowed 模块必须同步改它才生效):')
163
+ console.warn(' 现在补进对应 TaskCard allowed_paths 或 design §6 清单,避免执行期 apply Gate1 拦截后手工回补;')
164
+ console.warn(' 确认无关则忽略。执行期有据越界另有出口(review.json 声明 changedFiles,apply 有审计放行)。')
165
+ return r
166
+ } catch (e) {
167
+ console.warn(`⚠️ facade 候选探测失败(advisory 跳过): ${e.message}`)
168
+ return { skipped: true, reason: 'error', candidates: [] }
169
+ }
170
+ }
package/src/index.js CHANGED
@@ -8,7 +8,7 @@
8
8
  */
9
9
  import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'fs';
10
10
  import { writeAtomicSync } from './fs-atomic.js';
11
- import { basename, extname, join, resolve } from 'path';
11
+ import { basename, extname, join, resolve, isAbsolute } from 'path';
12
12
  import { safeGit, git } from './git-helper.js';
13
13
  import { getVersion } from './version.js';
14
14
 
@@ -98,7 +98,7 @@ SillySpec CLI — 规范驱动开发工具包
98
98
  sillyspec commit [--json] 智能提交建议:收集 QUICKLOG/已勾 task/阶段产出语义,生成建议 message(只建议不执行)
99
99
  sillyspec verify-probes --change <name> [--init] verify 机械探针(TODO 标记/测试覆盖/API 对账/删除对账);--init 生成 verify-result.md 骨架
100
100
  sillyspec module-impact --change <name> 生成 module-impact.md 骨架(文件×模块归属按 module-map 预填 + 未匹配清单)
101
- sillyspec endpoints extract --change <name> [--task task-NN] [--dir <dir>|--files <a.py,b.js>] 静态扫描路由装饰器生成 endpoints.json
101
+ sillyspec endpoints extract --change <name> [--task task-NN | --all-tasks] [--dir <dir>|--files <a.py,b.js>] 静态扫描路由装饰器生成 endpoints.json
102
102
  sillyspec scan-fix-headers [--project <名>] scan 文档补 author/created_at header(幂等)
103
103
  sillyspec plan-adopt-waves --change <名> [--dry-run] plan.md Wave 段一键重排为 depends_on 拓扑分组(同步任务总表 W 列,幂等)
104
104
  sillyspec workspace add <名> <路径> [--role] [--repo] | remove <名> | status 多项目工作区登记与状态探测
@@ -118,6 +118,8 @@ SillySpec CLI — 规范驱动开发工具包
118
118
  生成 Windows 安全 TaskCard 骨架(LF 行尾 + frontmatter 闭合 +
119
119
  硬校验 9 字段齐全,标题自动取自 plan.md,Edit 填占位符即可)
120
120
  sillyspec change-rename <旧变更名> <新变更名>
121
+ sillyspec change-delete <变更名> [--confirm] 删除变更(DB status='deleted' 与归档语义分离 +
122
+ 移目录 + 清 worktree + 推平台墓碑;默认 dry-run)
121
123
  sillyspec knowledge <search --query "..." --limit N
122
124
  | inspect --id "..."
123
125
  | validate | refresh
@@ -127,6 +129,8 @@ SillySpec CLI — 规范驱动开发工具包
127
129
  | sync [--change <name>] | sync-docs [--change <name>]
128
130
  | status | pointer [--cleanup]
129
131
  | approve <change> | reject <change> [--reason <r>]>
132
+ sillyspec pull --spec [--force] 拉取服务器 spec 快照到本地 .sillyspec(打包时刻
133
+ 快照非实时、无自动同步;进度六表拉取用 platform pull)
130
134
  sillyspec docs migrate
131
135
  sillyspec dashboard [--port <N>] [--no-open]
132
136
 
@@ -743,6 +747,20 @@ async function main() {
743
747
  }
744
748
  console.log(`📦 待提交变更(${cc.changedCount} 个文件${cc.untrackedCount > 0 ? `,其中未跟踪 ${cc.untrackedCount} 个` : ''};上方 stat 仅列已跟踪文件的改动)`);
745
749
  if (cc.stat) console.log(cc.stat.split('\n').map(l => ' ' + l).join('\n'));
750
+ // 暂存区快照(坑 same-main-staging-pollution,2026-08-28 实证:同 main 双会话共享暂存区,
751
+ // A 的 commit 扫入 B 暂存的文件):git commit 只含暂存内容——提交前把快照亮出来供核对。
752
+ {
753
+ const { collectStagedArea } = await import('./commit-suggest.js');
754
+ const staged = collectStagedArea({ cwd: dir });
755
+ if (staged.available && staged.staged.length > 0) {
756
+ console.log(`\n📌 暂存区快照(git diff --cached --name-only,你的下一次 git commit 将恰好含这 ${staged.staged.length} 个文件):`);
757
+ for (const f of staged.staged.slice(0, 15)) console.log(` - ${f}`);
758
+ if (staged.staged.length > 15) console.log(` … 共 ${staged.staged.length} 个`);
759
+ console.log(` ⚠️ 同 main 并行会话共享同一暂存区——快照里非本会话的文件请先 git restore --staged <file> 剔除,否则会被你的 commit 扫入`);
760
+ } else if (staged.available) {
761
+ console.log(`\n📌 暂存区为空(git commit 前需先 git add;用 pathspec 级 add 防扫入无关文件)`);
762
+ }
763
+ }
746
764
  if (cc.quickEntries.length > 0) {
747
765
  console.log(`\n📝 QUICKLOG(上次提交 ${cc.lastCommitTime || '(无)'} 之后 ${cc.quickEntries.length} 条):`);
748
766
  for (const q of cc.quickEntries) {
@@ -892,15 +910,19 @@ async function main() {
892
910
  // 此前靠 agent 手扫装饰器手写(易漏 endpoint)。CLI 复用 endpoint-extractor 全套提取器
893
911
  // (FastAPI @router.* / Express router.* / Spring @*Mapping)静态扫描生成,
894
912
  // verify 探针 5(verifyApiParity)直接消费该产物。
913
+ // --all-tasks(坑 probe5-single-task-artifact-scope,2026-08-28 实证:单 task 产物对账
914
+ // 产生大量假 missing):聚合模式——逐 task 卡提取,各自落 contract-artifacts/<change>/<task>/,
915
+ // 与 verifyApiParity 的聚合读侧(contract-artifacts/<change>/*/endpoints.json 全量并集)对齐。
895
916
  const epSub = filteredArgs[1];
896
917
  if (epSub !== 'extract') {
897
- console.error('用法: sillyspec endpoints extract --change <name> [--task task-NN] [--dir <backendRoot> | --files <a.py,b.js...>] [--spec-dir <path>]\n 静态扫描后端路由装饰器生成 endpoints.json(FastAPI/Express/Spring);verify 探针 5 消费');
918
+ console.error('用法: sillyspec endpoints extract --change <name> [--task task-NN | --all-tasks] [--dir <backendRoot> | --files <a.py,b.js...>] [--spec-dir <path>]\n 静态扫描后端路由装饰器生成 endpoints.json(FastAPI/Express/Spring);verify 探针 5 消费。\n --all-tasks:聚合逐 task 卡 allowed_paths 各自提取(对账口径与探针 5 聚合读侧一致,推荐)。\n execute worktree 模式:allowed_paths 自动按 worktree 根解析,产物恒落主仓 spec(坑 endpoints-extract-worktree-pitfalls)');
898
919
  process.exit(2);
899
920
  }
900
921
  const epChangeIdx = args.indexOf('--change');
901
922
  const epChange = epChangeIdx >= 0 && args[epChangeIdx + 1] ? args[epChangeIdx + 1] : null;
902
923
  const epTaskIdx = args.indexOf('--task');
903
924
  const epTask = epTaskIdx >= 0 && args[epTaskIdx + 1] ? args[epTaskIdx + 1] : null;
925
+ const epAllTasks = args.includes('--all-tasks');
904
926
  const epDirIdx = args.indexOf('--dir');
905
927
  const epDir = epDirIdx >= 0 && args[epDirIdx + 1] ? args[epDirIdx + 1] : null;
906
928
  const epFilesIdx = args.indexOf('--files');
@@ -909,17 +931,51 @@ async function main() {
909
931
  console.error('❌ 缺 --change <变更名>');
910
932
  process.exit(2);
911
933
  }
934
+ if (epAllTasks && (epTask || epDir || epFilesRaw)) {
935
+ console.error('❌ --all-tasks 与 --task/--dir/--files 互斥(聚合模式逐 task 卡自动取 allowed_paths)');
936
+ process.exit(2);
937
+ }
912
938
  assertSafeChangeName(epChange, '--change 变更名');
913
939
  const { extractFastApiEndpoints, extractExpressEndpoints, extractSpringEndpoints, scanBackendEndpoints } = await import('./endpoint-extractor.js');
914
940
  const endpoints = [];
915
- const epSpecBase = resolvePlatformSpecDir(dir, specDir) || join(dir, '.sillyspec');
941
+ let epSpecBase = resolvePlatformSpecDir(dir, specDir) || join(dir, '.sillyspec');
942
+ // 坑 endpoints-extract-worktree-pitfalls ②(2026-08-29 实测):CLI 在 execute worktree 内跑时
943
+ // spec 命中副本(worktree 内 checkout 出来的 .sillyspec)→ 产物落 worktree 自己的
944
+ // contract-artifacts/,主仓 verify 探针 5 读不到。复用 detectWorktreeSpecDrift 判据锚回
945
+ // 主仓 spec:产物落点恒主仓,扫描面(--dir/--files 绝对路径)不受影响。
946
+ const epDrift = (await import('./run/shared.js')).detectWorktreeSpecDrift(epSpecBase);
947
+ if (epDrift) {
948
+ console.log(`⚠️ 已自动锚定主仓 spec:${epDrift.mainSpecBase}(endpoints 产物落主仓;当前 cwd 命中 worktree 副本 ${epDrift.changeName},流程继续)`);
949
+ epSpecBase = epDrift.mainSpecBase;
950
+ }
916
951
  const epChangeDir = join(epSpecBase, 'changes', epChange);
917
952
  if (!existsSync(epChangeDir)) {
918
953
  console.error(`❌ 变更目录不存在: ${epChangeDir}`);
919
954
  process.exit(1);
920
955
  }
956
+ // 坑 endpoints-extract-worktree-pitfalls ①(2026-08-29 实测 13 task 全 0 端点):
957
+ // execute worktree 模式下变更代码只在 worktree 里,allowed_paths 按主仓(cwd)相对
958
+ // 解析 0 命中。活跃 worktree meta 存在时相对路径优先按 worktree 根解析——worktree =
959
+ // baseline 检出 + 本变更改动,扫描结果即交付态;不在 worktree 的文件回落主仓(显式
960
+ // --files 绝对路径原样通过)。WorktreeManager 从 worktree 内跑也会经 git-common-dir
961
+ // 锚定主仓 meta(构造器注释),meta 读取失败按无 worktree 处理不阻断。
962
+ let epWorktreeRoot = null;
963
+ try {
964
+ const { WorktreeManager } = await import('./worktree.js');
965
+ const epWtMeta = new WorktreeManager(dir).getMeta(epChange);
966
+ if (epWtMeta?.worktreePath && existsSync(epWtMeta.worktreePath)) epWorktreeRoot = epWtMeta.worktreePath;
967
+ } catch { /* meta 读取失败按无 worktree 处理 */ }
968
+ const resolveScoped = (p) => {
969
+ if (isAbsolute(p)) return p;
970
+ if (epWorktreeRoot) {
971
+ const inWt = join(epWorktreeRoot, p);
972
+ if (existsSync(inWt)) return inWt;
973
+ }
974
+ return resolve(p);
975
+ };
976
+ if (epWorktreeRoot) console.log(`ℹ️ 变更有活跃 worktree,扫描面优先按其根解析:${epWorktreeRoot}`);
921
977
  const collectFile = (f) => {
922
- const abs = resolve(f)
978
+ const abs = resolveScoped(f)
923
979
  if (!existsSync(abs)) return
924
980
  const ext = extname(abs).toLowerCase()
925
981
  try {
@@ -928,10 +984,56 @@ async function main() {
928
984
  else if (ext === '.java') endpoints.push(...extractSpringEndpoints(abs))
929
985
  } catch { /* 单文件解析失败不阻断整体 */ }
930
986
  }
987
+ // 单 task 产物写盘(taskName 归属目录);返回提取数
988
+ const epRuntimeRoot = (await import('./run/shared.js')).resolveRuntimeRoot({ specRoot: specDir }, epSpecBase);
989
+ const writeArtifact = (taskName, eps) => {
990
+ const artifact = {
991
+ generated_at: new Date().toISOString().slice(0, 19).replace('T', ' '),
992
+ generator: 'sillyspec endpoints extract',
993
+ endpoints: eps.map(e => ({ method: e.method, path: e.path, source: e.source })),
994
+ }
995
+ const epOutDir = join(epRuntimeRoot, 'contract-artifacts', epChange, taskName);
996
+ mkdirSync(epOutDir, { recursive: true });
997
+ const epOutPath = join(epOutDir, 'endpoints.json');
998
+ writeFileSync(epOutPath, JSON.stringify(artifact, null, 2) + '\n');
999
+ return epOutPath;
1000
+ }
931
1001
  if (epDir) {
932
- endpoints.push(...scanBackendEndpoints(resolve(epDir)))
1002
+ endpoints.push(...scanBackendEndpoints(resolve(epDir)));
933
1003
  } else if (epFilesRaw) {
934
1004
  for (const f of epFilesRaw.split(',').map(s => s.trim()).filter(Boolean)) collectFile(f)
1005
+ } else if (epAllTasks) {
1006
+ // 聚合模式:逐 task 卡 allowed_paths 提取,各自落产物目录(对齐 verifyApiParity 聚合读侧)
1007
+ const { parseAllowedPaths } = await import('./stages/plan-postcheck.js')
1008
+ const tasksDir = join(epChangeDir, 'tasks')
1009
+ const taskCards = existsSync(tasksDir)
1010
+ ? readdirSync(tasksDir).filter(f => /^task-[\w.-]+\.md$/.test(f)).sort()
1011
+ : []
1012
+ if (taskCards.length === 0) {
1013
+ console.error(`❌ --all-tasks 无 task 卡(${tasksDir} 不存在或无 task-*.md)——先跑 plan 生成 TaskCard`);
1014
+ process.exit(1);
1015
+ }
1016
+ let total = 0
1017
+ for (const card of taskCards) {
1018
+ const taskName = card.replace(/\.md$/, '')
1019
+ const eps = []
1020
+ const perFile = (f) => {
1021
+ const abs = resolveScoped(f)
1022
+ if (!existsSync(abs)) return
1023
+ const ext = extname(abs).toLowerCase()
1024
+ try {
1025
+ if (ext === '.py') eps.push(...extractFastApiEndpoints(abs))
1026
+ else if (ext === '.js' || ext === '.ts') eps.push(...extractExpressEndpoints(abs))
1027
+ else if (ext === '.java') eps.push(...extractSpringEndpoints(abs))
1028
+ } catch { /* 单文件解析失败不阻断整体 */ }
1029
+ }
1030
+ for (const p of parseAllowedPaths(readFileSync(join(tasksDir, card), 'utf8'))) perFile(p)
1031
+ const outPath = writeArtifact(taskName, eps)
1032
+ total += eps.length
1033
+ console.log(`ℹ️ ${taskName}: ${eps.length} 个端点 → ${outPath}`)
1034
+ }
1035
+ console.log(`✅ 聚合提取完成:${taskCards.length} 个 task 卡共 ${total} 个端点(探针 5 对账时全量并集消费)`);
1036
+ break;
935
1037
  } else {
936
1038
  // 默认扫描面:task 卡 allowed_paths ∪ design §6 清单里的具体源码文件
937
1039
  const { parseFileChangeListDetailed } = await import('./change-list.js')
@@ -951,28 +1053,19 @@ async function main() {
951
1053
  }
952
1054
  let scanned = 0
953
1055
  for (const p of candidates) {
954
- const abs = join(dir, p)
1056
+ const abs = resolveScoped(p)
955
1057
  if (!existsSync(abs)) continue
956
1058
  scanned++
957
- collectFile(p)
1059
+ collectFile(abs)
958
1060
  }
959
1061
  if (candidates.size === 0) {
960
- console.error('❌ 无扫描面:--task 卡无 allowed_paths 且 design.md 无具体文件清单——显式传 --dir <backendRoot> 或 --files <a.py,b.js>');
1062
+ console.error('❌ 无扫描面:--task 卡无 allowed_paths 且 design.md 无具体文件清单——显式传 --dir <backendRoot> 或 --files <a.py,b.js>,或用 --all-tasks 聚合逐卡提取');
961
1063
  process.exit(1);
962
1064
  }
963
1065
  console.log(`ℹ️ 扫描 ${scanned}/${candidates.size} 个清单文件(不存在的跳过)`);
964
1066
  }
965
- const artifact = {
966
- generated_at: new Date().toISOString().slice(0, 19).replace('T', ' '),
967
- generator: 'sillyspec endpoints extract',
968
- endpoints: endpoints.map(e => ({ method: e.method, path: e.path, source: e.source })),
969
- }
970
- const epRuntimeRoot = (await import('./run/shared.js')).resolveRuntimeRoot({ specRoot: specDir }, epSpecBase);
971
- const epOutDir = join(epRuntimeRoot, 'contract-artifacts', epChange, epTask || 'scan');
972
- mkdirSync(epOutDir, { recursive: true });
973
- const epOutPath = join(epOutDir, 'endpoints.json');
974
- writeFileSync(epOutPath, JSON.stringify(artifact, null, 2) + '\n');
975
- console.log(`✅ 提取 ${endpoints.length} 个端点 → ${epOutPath}`);
1067
+ const outPath = writeArtifact(epTask || 'scan', endpoints);
1068
+ console.log(`✅ 提取 ${endpoints.length} 个端点 → ${outPath}`);
976
1069
  console.log(` verify 阶段探针 5(API 契约对账)自动消费该产物;FastAPI/Express/Spring 装饰器均支持。`);
977
1070
  break;
978
1071
  }
@@ -1146,7 +1239,7 @@ async function main() {
1146
1239
  docsCheckFlags.push(a);
1147
1240
  }
1148
1241
  }
1149
- const { runDocsCheck, readDocsCheckConfig, DocsCheckConfigError, applyFixes } = await import('./docs-check.js');
1242
+ const { runDocsCheck, readDocsCheckConfig, DocsCheckConfigError, applyFixes, runChangeNameAdvisory } = await import('./docs-check.js');
1150
1243
  try {
1151
1244
  // 配置优先级:--paths > local.yaml docs-check.paths > 缺省 docs/**/*.md
1152
1245
  const cfg = readDocsCheckConfig(dir);
@@ -1177,10 +1270,20 @@ async function main() {
1177
1270
  }
1178
1271
  }
1179
1272
  const fixResult = fixActive ? applyFixes(dir, fixes, dryRun ? { dryRun: true } : {}) : null;
1273
+ // 变更名提名 advisory(2026-08-31 变更关联审计 P2):铁律同决策规则族——只 warn,
1274
+ // 不进 invalid、不影响下方 exit code 与 docs gate。降级容错:advisory 自身异常
1275
+ // 不拖垮主检查(try-catch 吞并提示)。
1276
+ let changeNameReport = null;
1277
+ try {
1278
+ changeNameReport = runChangeNameAdvisory({ projectRoot: dir });
1279
+ } catch (e) {
1280
+ console.warn(`⚠️ 变更名提名检查执行失败(advisory 降级跳过):${e.message}`);
1281
+ }
1180
1282
  if (json) {
1181
1283
  // --fix 与 --json 组合保持 stdout 纯 JSON 可解析(constraints):修复统计作 result.fixReport
1182
1284
  // 附加后整体 stringify,主体仍是 runDocsCheck 返回(invalid[].fix 是 task-01 设计内增量)
1183
1285
  if (fixResult) result.fixReport = { applied: fixResult.applied, skipped: fixResult.skipped.length, dryRun };
1286
+ if (changeNameReport) result.changeNameReport = changeNameReport;
1184
1287
  console.log(JSON.stringify(result, null, 2));
1185
1288
  } else {
1186
1289
  for (const w of result.warnings) console.warn(`⚠️ ${w}`);
@@ -1219,6 +1322,16 @@ async function main() {
1219
1322
  console.error(`\n修复指引:行号漂移 → 更新文档行号到当前源码;文件删改名 → 更新引用路径;`);
1220
1323
  console.error(`关键词缺失但行号正确 → 确认符号是否改名,改文档 token 或行号。`);
1221
1324
  }
1325
+ // 变更名提名 advisory 段(主结果之后、exit 之前;不参与 ok/invalid 与 exit code)。
1326
+ // 零输出原则(docs-check-fix 契约:无新 flag 时 stdout 逐字节一致;决策规则族同款
1327
+ // 「无信号零输出」):仅 findings 非空才输出——悬空即信号,无悬空不打扰。
1328
+ if (changeNameReport && changeNameReport.findings.length > 0) {
1329
+ console.warn(`⚠️ 变更名提名悬空 ${changeNameReport.findings.length}/${changeNameReport.mentions} 处(advisory 不阻断、不进 docs gate):`);
1330
+ for (const f of changeNameReport.findings) console.warn(` - ${f.message}`);
1331
+ if (changeNameReport.exempted.length > 0) {
1332
+ console.log(`ℹ️ 另有 ${changeNameReport.exempted.length} 处经 known_failures change-name.* 键豁免`);
1333
+ }
1334
+ }
1222
1335
  }
1223
1336
  // exit code(design §5.2 行为矩阵逐字对齐):全绿 → 0(--fix 无操作);--fix 后全部
1224
1337
  // fixable 修复完且无 needs-manual 残留 → 0;仍有 needs-manual 或写回 skipped → 1;
@@ -1719,6 +1832,20 @@ SillySpec worktree — git worktree 隔离管理
1719
1832
  console.log(` git add --pathspec-from-file="${result.pathspecFile}"`);
1720
1833
  }
1721
1834
  console.log(` (完整清单 ${result.commitPathspec.length} 个文件已存 ${result.pathspecFile},可 --pathspec-from-file 复用)`);
1835
+ // 他者暂存差集(坑 same-main-staging-pollution):暂存区已有非本变更文件时,
1836
+ // 普通 git commit 会把它们一并扫入——提交前亮出来。pathspec 级 commit
1837
+ // (git commit -m <msg> -- <paths>)只提交指定路径、不动他者暂存,双出口都给。
1838
+ try {
1839
+ const { collectStagedArea } = await import('./commit-suggest.js');
1840
+ const stagedNow = collectStagedArea({ cwd: dir, ownFiles: result.commitPathspec });
1841
+ if (stagedNow.available && stagedNow.foreign.length > 0) {
1842
+ console.warn(`\n⚠️ 暂存区已有 ${stagedNow.foreign.length} 个非本变更文件(很可能为他者并行会话暂存,普通 git commit 会扫入):`);
1843
+ for (const f of stagedNow.foreign.slice(0, 10)) console.warn(` - ${f}`);
1844
+ if (stagedNow.foreign.length > 10) console.warn(` … 共 ${stagedNow.foreign.length} 个`);
1845
+ console.warn(` 推荐用 pathspec 级 commit(不动他者暂存):git commit -m "<msg>" --pathspec-from-file="${result.pathspecFile}"`);
1846
+ console.warn(` 或先剔除:git restore --staged -- <文件>(工作区内容不丢,对方可重新 add)`);
1847
+ }
1848
+ } catch { /* 暂存区快照失败不影响 apply 结果展示 */ }
1722
1849
  }
1723
1850
  }
1724
1851
  if (result.warnings && result.warnings.length > 0) {
@@ -2069,6 +2196,9 @@ SillySpec platform — SillyHub 平台同步
2069
2196
  sillyspec platform pointer [--cleanup]
2070
2197
  sillyspec platform approve <change-name>
2071
2198
  sillyspec platform reject <change-name> [--reason <reason>]
2199
+
2200
+ 注: platform pull 拉的是进度六表(多机进度同步);拉取 spec 文件整树快照请用
2201
+ sillyspec pull --spec(X2,打包时刻快照非实时)。
2072
2202
  `);
2073
2203
  break;
2074
2204
  }
@@ -2431,6 +2561,53 @@ checkbox 行;depends_on 自动反填行内注解 "(depends_on: task-01,02)";
2431
2561
  pm.renameChange(dir, oldName, newName);
2432
2562
  break;
2433
2563
  }
2564
+ case 'change-delete': {
2565
+ // 2026-08-30 用户反馈①:一等删除路径——此前删变更靠 git rm + 借道幽灵清理,
2566
+ // 且幽灵清理把删除行写成 archived,DB 无法区分归档与删除。两段式(默认 dry-run)。
2567
+ // 变更名:位置参数(对齐 change-rename)或 --change(对齐 gate/derive)二选一。
2568
+ let delName = filteredArgs[1];
2569
+ if (!delName || delName.startsWith('-')) {
2570
+ const idx = filteredArgs.indexOf('--change');
2571
+ if (idx >= 0 && filteredArgs[idx + 1]) delName = filteredArgs[idx + 1];
2572
+ }
2573
+ if (!delName || delName.startsWith('-')) {
2574
+ console.error('❌ 用法: sillyspec change-delete <变更名> [--confirm](或 --change <变更名>)');
2575
+ process.exitCode = 2;
2576
+ break;
2577
+ }
2578
+ const delConfirm = filteredArgs.includes('--confirm');
2579
+ const { deleteChange } = await import('./change-delete.js');
2580
+ const r = await deleteChange({
2581
+ cwd: dir,
2582
+ specDir: resolvePlatformSpecDir(dir, specDir),
2583
+ changeName: delName,
2584
+ confirm: delConfirm,
2585
+ });
2586
+ if (json) {
2587
+ console.log(JSON.stringify(r, null, 2));
2588
+ } else if (!r.ok) {
2589
+ console.error(`❌ ${r.reason}`);
2590
+ } else if (r.action === 'dry_run') {
2591
+ console.log(`🗑️ 变更 ${delName} 待删除(dry-run,未执行):`);
2592
+ if (r.db_row) console.log(` DB 行:status=${r.db_row.status}${r.already_deleted ? '(已删除,本次仅清残留)' : ` → deleted(与 archived 语义分离,审计可直接区分)`}`);
2593
+ else console.log(' DB 行:无(孤儿目录,仅删目录)');
2594
+ if (r.dir) console.log(` 目录:${r.dir.path}(${r.dir.files} 个文件,git tracked 文件可从历史回溯)`);
2595
+ else console.log(' 目录:不存在(仅 DB 行翻 deleted——取代 git rm 后借道幽灵清理的老路径)');
2596
+ if (r.worktree) {
2597
+ console.log(` worktree:${r.worktree.mode}${r.worktree.unappliedChanges > 0
2598
+ ? `(⚠️ ${r.worktree.unappliedChanges} 个未 apply 变更——删除时保留 worktree 不丢代码,确认废弃后手动 cleanup)`
2599
+ : '(将一并清理)'}`);
2600
+ }
2601
+ console.log(`\n加 --confirm 执行删除(DB status='deleted' + 移除目录 + 清理 worktree + 暂存 git 删除 + 推平台墓碑)。`);
2602
+ } else {
2603
+ console.log(`🗑️ 变更 ${delName} 已删除:`);
2604
+ if (r.db_updated) console.log(` ✅ DB status='deleted'(与 archived 语义分离,审计可直接区分)`);
2605
+ if (r.dir_removed) console.log(` ✅ 目录已移除(${r.files_removed} 个文件,git 历史可回溯)`);
2606
+ for (const w of r.warnings || []) console.log(` ⚠️ ${w}`);
2607
+ }
2608
+ process.exitCode = r.ok ? 0 : 1;
2609
+ break;
2610
+ }
2434
2611
  case 'workflow': {
2435
2612
  const wfSub = filteredArgs[1];
2436
2613
  if (!wfSub || wfSub === 'help' || wfSub === '--help') {
@@ -2863,8 +3040,68 @@ SillySpec config — local.yaml 配置键速查
2863
3040
  }
2864
3041
  break;
2865
3042
  }
3043
+ case 'pull': {
3044
+ // X2(task-14 / design §7.1 §7.4 / FR-07 / FR-08):拉取服务器 spec 整树快照。
3045
+ // 注意与 platform pull(进度六表 DB import,多机进度同步)语义正交——本命令操作
3046
+ // .sillyspec 文件树;命名 pull --spec 显式区分,防误用。
3047
+ const pullSpecArgs = filteredArgs.slice(1);
3048
+ const pullWantsHelp = pullSpecArgs.length === 0
3049
+ || pullSpecArgs.includes('help') || pullSpecArgs.includes('--help') || pullSpecArgs.includes('-h');
3050
+ const pullWantsSpec = pullSpecArgs.includes('--spec');
3051
+ const printPullSpecHelp = () => console.log(`
3052
+ SillySpec pull — 拉取服务器 spec 快照到本地(X2 / FR-07)
3053
+
3054
+ 用法:
3055
+ sillyspec pull --spec [--force]
3056
+
3057
+ 说明:
3058
+ 从 SillyHub 平台拉取当前工作区的 .sillyspec 整树快照(tar 解压到 specDir,
3059
+ 含 tar 顶层 PLATFORM-BUNDLE.json 快照元数据,可离线辨认快照新旧)。
3060
+ 覆盖语义:specDir 为空 → 直接解压;非空 → 必须加 --force 整树替换
3061
+ (先删除本地整棵 .sillyspec 再解包——.runtime(进度 DB)等本地文件会一并丢失;
3062
+ --force 保留连接凭据 local.yaml(服务端 bundle 恒排除它),覆盖后无需重新 connect;
3063
+ 崩溃安全:覆盖前凭据先备份为 .sillyspec.local.yaml.bak,成功后自动清理,
3064
+ 中途失败可从该备份文件恢复)。
3065
+
3066
+ ⚠️ 快照语义:拉取的是服务器「打包时刻」的快照,非实时镜像——
3067
+ 本命令不自动同步、不在会话中自动刷新;拉取后本地的改动仍需正常推送。
3068
+ daemon 模式下任务/会话开始时会按 spec_version 自动取新,无需手动跑本命令。
3069
+
3070
+ 拉取进度六表(多机进度同步)请用:sillyspec platform pull [--change <name>]
3071
+ `);
3072
+ if (pullWantsHelp || !pullWantsSpec) {
3073
+ printPullSpecHelp();
3074
+ if (!pullWantsHelp) {
3075
+ console.error('❌ 缺少 --spec(本命令拉取 spec 整树快照;进度六表拉取用 sillyspec platform pull)');
3076
+ process.exit(1);
3077
+ }
3078
+ break;
3079
+ }
3080
+
3081
+ const pullSpecForce = pullSpecArgs.includes('--force');
3082
+ const syncModule = await import('./sync.js');
3083
+ // 未连接明确提示不崩(对齐 platform pull :2289 先例)
3084
+ const smForSpecPull = new syncModule.SyncManager(dir);
3085
+ if (!smForSpecPull._getPlatform()) {
3086
+ console.error('❌ 未连接平台,请先 sillyspec platform connect');
3087
+ process.exit(1);
3088
+ }
3089
+ const specR = await syncModule.pullSpecBundle(dir, { force: pullSpecForce, specDir });
3090
+ if (specR.ok && specR.pulled) {
3091
+ console.log(`✅ 已拉取 spec 快照 → ${specR.specDir}${specR.specVersion ? `(平台 spec_version: ${specR.specVersion})` : ''}`);
3092
+ console.log(' 快照语义:打包时刻快照非实时,无自动同步;本地后续改动仍需正常推送。');
3093
+ } else if (/非空/.test(specR.reason || '')) {
3094
+ console.error(`❌ ${specR.reason}`);
3095
+ console.error(' 确认整树替换请加 --force(先删除本地 .sillyspec 整树再解包,进度 DB 等本地文件一并丢失;连接凭据 local.yaml 保留,无需重新 connect)');
3096
+ process.exit(1);
3097
+ } else {
3098
+ console.error(`❌ 拉取 spec 快照失败: ${specR.reason || '未知'}`);
3099
+ process.exit(1);
3100
+ }
3101
+ break;
3102
+ }
2866
3103
  default: {
2867
- const topCommands = ['init', 'setup', 'run', 'progress', 'worktree', 'dispatch', 'agent-log', 'local', 'workflow', 'gate', 'derive', 'backfill-reviews', 'register-stage-review', 'modules', 'change-rename', 'knowledge', 'platform', 'scan', 'brainstorm', 'plan', 'execute', 'verify', 'archive', 'quick', 'explore', 'status', 'doctor', 'auto', 'runtime'];
3104
+ 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'];
2868
3105
  const suggestion = didYouMean(command, topCommands);
2869
3106
  console.error(`❌ 未知命令: ${command}`);
2870
3107
  if (command === '--status') {