@zhuan-ai/zhuanspec 2.17.0 → 2.17.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -14,6 +14,7 @@ interface UsageCounter {
14
14
  refCount: number;
15
15
  lastRefAt: string;
16
16
  }
17
+ type ProjectWikiRefSource = 'file' | 'llmwiki' | string;
17
18
  /** usage.json 落点结构:zhuanspec/knowledge/.metrics/usage.json */
18
19
  export interface KnowledgeUsageStats {
19
20
  schemaVersion: 1;
@@ -26,6 +27,10 @@ export interface KnowledgeUsageStats {
26
27
  byFile: Record<string, UsageCounter>;
27
28
  /** 键 = 相对 ZhuanSpec 根的 ProjectWiki 路径,如 service/.project-wiki/project.md */
28
29
  byProjectWikiFile: Record<string, UsageCounter>;
30
+ /** 键 = ProjectWiki 引用来源,如 file / llmwiki */
31
+ projectWikiRefsBySource: Record<string, UsageCounter>;
32
+ /** 键 = ProjectWiki 文件路径,值 = 该文件按来源拆分的引用计数 */
33
+ byProjectWikiFileSource: Record<string, Record<string, UsageCounter>>;
29
34
  lastUpdatedAt: string;
30
35
  }
31
36
  /** 单次上报输入(清晰可复用的上报接口契约) */
@@ -35,6 +40,22 @@ export interface KnowledgeRefEvent {
35
40
  /** 引用时间,缺省取 getBeijingTime() */
36
41
  refAt?: string;
37
42
  }
43
+ export interface ProjectWikiEffectiveRef {
44
+ /** llmwiki 返回的 ProjectWiki 文档路径,支持绝对路径或相对 ZhuanSpec 根路径 */
45
+ path: string;
46
+ /** llmwiki 召回分数,仅用于后续分析,不参与是否计数的判定 */
47
+ score?: number;
48
+ /** 调用方判定该文档为有效引用的简要原因 */
49
+ reason?: string;
50
+ }
51
+ export interface ProjectWikiRefEvent {
52
+ source?: ProjectWikiRefSource;
53
+ query?: string;
54
+ project?: string;
55
+ effectiveRefs: ProjectWikiEffectiveRef[];
56
+ /** 引用时间,缺省取 getBeijingTime() */
57
+ refAt?: string;
58
+ }
38
59
  interface KnowledgeUsageOptions {
39
60
  json?: boolean;
40
61
  file?: string;
@@ -43,6 +64,14 @@ interface KnowledgeUsageOptions {
43
64
  export declare function getUsagePath(knowledgeDir: string): string;
44
65
  /** 读取 usage.json,缺失/损坏均返回空结构(容错,不抛) */
45
66
  export declare function readUsageStats(knowledgeDir: string): Promise<KnowledgeUsageStats>;
67
+ export declare function extractLlmwikiEffectiveRefsFromHookInput(stdinData: Record<string, unknown>): ProjectWikiEffectiveRef[];
68
+ /**
69
+ * 上报 llmwiki 召回后被实际使用的 ProjectWiki 文档。
70
+ * 只统计调用方传入的 effectiveRefs,不统计全部召回结果;同一次事件内同一路径去重。
71
+ *
72
+ * @returns 实际累加的唯一文档数
73
+ */
74
+ export declare function reportProjectWikiRefs(knowledgeDir: string, event: ProjectWikiRefEvent): Promise<number>;
46
75
  /**
47
76
  * 清晰可复用的上报接口:累加一次引用。
48
77
  * - filePath 未命中 knowledge/ 三类目录 → no-op 返回 false。
@@ -28,6 +28,8 @@ function emptyUsage() {
28
28
  byId: {},
29
29
  byFile: {},
30
30
  byProjectWikiFile: {},
31
+ projectWikiRefsBySource: {},
32
+ byProjectWikiFileSource: {},
31
33
  lastUpdatedAt: '',
32
34
  };
33
35
  }
@@ -48,6 +50,12 @@ export async function readUsageStats(knowledgeDir) {
48
50
  byProjectWikiFile: parsed.byProjectWikiFile && typeof parsed.byProjectWikiFile === 'object'
49
51
  ? parsed.byProjectWikiFile
50
52
  : {},
53
+ projectWikiRefsBySource: parsed.projectWikiRefsBySource && typeof parsed.projectWikiRefsBySource === 'object'
54
+ ? parsed.projectWikiRefsBySource
55
+ : {},
56
+ byProjectWikiFileSource: parsed.byProjectWikiFileSource && typeof parsed.byProjectWikiFileSource === 'object'
57
+ ? parsed.byProjectWikiFileSource
58
+ : {},
51
59
  lastUpdatedAt: typeof parsed.lastUpdatedAt === 'string' ? parsed.lastUpdatedAt : '',
52
60
  };
53
61
  }
@@ -95,6 +103,143 @@ function matchProjectWikiFile(repoRoot, filePath) {
95
103
  return null;
96
104
  return segments.join('/');
97
105
  }
106
+ function normalizeProjectWikiFile(repoRoot, filePath) {
107
+ if (!filePath)
108
+ return null;
109
+ if (path.isAbsolute(filePath)) {
110
+ return matchProjectWikiFile(repoRoot, filePath);
111
+ }
112
+ return matchProjectWikiFile(repoRoot, path.join(repoRoot, filePath));
113
+ }
114
+ function bump(counter, refAt) {
115
+ return {
116
+ refCount: (counter?.refCount ?? 0) + 1,
117
+ lastRefAt: refAt,
118
+ };
119
+ }
120
+ function bumpProjectWikiRef(usage, relPath, source, refAt) {
121
+ usage.byProjectWikiFile[relPath] = bump(usage.byProjectWikiFile[relPath], refAt);
122
+ usage.projectWikiRefsBySource[source] = bump(usage.projectWikiRefsBySource[source], refAt);
123
+ usage.byProjectWikiFileSource[relPath] = usage.byProjectWikiFileSource[relPath] || {};
124
+ usage.byProjectWikiFileSource[relPath][source] = bump(usage.byProjectWikiFileSource[relPath][source], refAt);
125
+ usage.projectWikiTotalRefs += 1;
126
+ }
127
+ function isObject(value) {
128
+ return !!value && typeof value === 'object' && !Array.isArray(value);
129
+ }
130
+ function toEffectiveRefs(value) {
131
+ if (!Array.isArray(value))
132
+ return [];
133
+ const refs = [];
134
+ for (const item of value) {
135
+ if (typeof item === 'string') {
136
+ refs.push({ path: item });
137
+ continue;
138
+ }
139
+ if (!isObject(item))
140
+ continue;
141
+ const refPath = typeof item.path === 'string'
142
+ ? item.path
143
+ : typeof item.file_path === 'string'
144
+ ? item.file_path
145
+ : '';
146
+ if (!refPath)
147
+ continue;
148
+ refs.push({
149
+ path: refPath,
150
+ score: typeof item.score === 'number' ? item.score : undefined,
151
+ reason: typeof item.reason === 'string' ? item.reason : undefined,
152
+ });
153
+ }
154
+ return refs;
155
+ }
156
+ function extractProjectWikiRefEvent(stdinData, toolInput) {
157
+ const candidates = [stdinData, toolInput].filter(Boolean);
158
+ for (const candidate of candidates) {
159
+ const refs = toEffectiveRefs(candidate.projectWikiEffectiveRefs
160
+ ?? candidate.project_wiki_effective_refs
161
+ ?? candidate.effectiveRefs
162
+ ?? candidate.effective_refs);
163
+ if (refs.length === 0)
164
+ continue;
165
+ return {
166
+ source: typeof candidate.source === 'string' ? candidate.source : 'llmwiki',
167
+ query: typeof candidate.query === 'string' ? candidate.query : undefined,
168
+ project: typeof candidate.project === 'string' ? candidate.project : undefined,
169
+ effectiveRefs: refs,
170
+ };
171
+ }
172
+ return null;
173
+ }
174
+ function collectStringValues(value, output = []) {
175
+ if (typeof value === 'string') {
176
+ output.push(value);
177
+ return output;
178
+ }
179
+ if (Array.isArray(value)) {
180
+ for (const item of value)
181
+ collectStringValues(item, output);
182
+ return output;
183
+ }
184
+ if (isObject(value)) {
185
+ for (const item of Object.values(value))
186
+ collectStringValues(item, output);
187
+ }
188
+ return output;
189
+ }
190
+ function extractProjectWikiPaths(text) {
191
+ const matches = text.match(/[^\s"'`<>]*\.project-wiki\/[^\s"'`<>]*?\.md/g) || [];
192
+ return matches
193
+ .map((item) => item.replace(/[),.;:,。;:]+$/g, ''))
194
+ .filter(Boolean);
195
+ }
196
+ export function extractLlmwikiEffectiveRefsFromHookInput(stdinData) {
197
+ const toolInput = stdinData.tool_input;
198
+ const command = typeof toolInput?.command === 'string' ? toolInput.command : '';
199
+ if (!/\bllmwiki\b/.test(command))
200
+ return [];
201
+ const toolResult = stdinData.tool_result;
202
+ const searchText = [
203
+ command,
204
+ ...collectStringValues(toolResult),
205
+ ].join('\n');
206
+ return Array.from(new Set(extractProjectWikiPaths(searchText))).map((refPath) => ({
207
+ path: refPath,
208
+ reason: 'llmwiki query result referenced by Bash output',
209
+ }));
210
+ }
211
+ /**
212
+ * 上报 llmwiki 召回后被实际使用的 ProjectWiki 文档。
213
+ * 只统计调用方传入的 effectiveRefs,不统计全部召回结果;同一次事件内同一路径去重。
214
+ *
215
+ * @returns 实际累加的唯一文档数
216
+ */
217
+ export async function reportProjectWikiRefs(knowledgeDir, event) {
218
+ const repoRoot = path.dirname(path.dirname(path.resolve(knowledgeDir)));
219
+ const source = event.source || 'llmwiki';
220
+ const refAt = event.refAt || getBeijingTime();
221
+ const uniqueRefs = new Set();
222
+ for (const ref of event.effectiveRefs || []) {
223
+ const relPath = normalizeProjectWikiFile(repoRoot, ref.path);
224
+ if (relPath)
225
+ uniqueRefs.add(relPath);
226
+ }
227
+ if (uniqueRefs.size === 0)
228
+ return 0;
229
+ try {
230
+ const usage = await readUsageStats(knowledgeDir);
231
+ for (const relPath of uniqueRefs) {
232
+ bumpProjectWikiRef(usage, relPath, source, refAt);
233
+ }
234
+ usage.lastUpdatedAt = refAt;
235
+ await FileSystemUtils.createDirectory(path.dirname(getUsagePath(knowledgeDir)));
236
+ await atomicWriteJson(getUsagePath(knowledgeDir), usage);
237
+ return uniqueRefs.size;
238
+ }
239
+ catch {
240
+ return 0;
241
+ }
242
+ }
98
243
  /**
99
244
  * 清晰可复用的上报接口:累加一次引用。
100
245
  * - filePath 未命中 knowledge/ 三类目录 → no-op 返回 false。
@@ -106,7 +251,7 @@ function matchProjectWikiFile(repoRoot, filePath) {
106
251
  export async function reportKnowledgeRef(knowledgeDir, event) {
107
252
  const relPath = matchKnowledgeFile(knowledgeDir, event.filePath);
108
253
  const repoRoot = path.dirname(path.dirname(path.resolve(knowledgeDir)));
109
- const projectWikiRelPath = matchProjectWikiFile(repoRoot, event.filePath);
254
+ const projectWikiRelPath = normalizeProjectWikiFile(repoRoot, event.filePath);
110
255
  if (!relPath && !projectWikiRelPath)
111
256
  return false;
112
257
  const refAt = event.refAt || getBeijingTime();
@@ -124,19 +269,14 @@ export async function reportKnowledgeRef(knowledgeDir, event) {
124
269
  }
125
270
  try {
126
271
  const usage = await readUsageStats(knowledgeDir);
127
- const bump = (counter) => ({
128
- refCount: (counter?.refCount ?? 0) + 1,
129
- lastRefAt: refAt,
130
- });
131
272
  if (relPath) {
132
- usage.byFile[relPath] = bump(usage.byFile[relPath]);
273
+ usage.byFile[relPath] = bump(usage.byFile[relPath], refAt);
133
274
  if (id)
134
- usage.byId[id] = bump(usage.byId[id]);
275
+ usage.byId[id] = bump(usage.byId[id], refAt);
135
276
  usage.totalRefs += 1;
136
277
  }
137
278
  if (projectWikiRelPath) {
138
- usage.byProjectWikiFile[projectWikiRelPath] = bump(usage.byProjectWikiFile[projectWikiRelPath]);
139
- usage.projectWikiTotalRefs += 1;
279
+ bumpProjectWikiRef(usage, projectWikiRelPath, 'file', refAt);
140
280
  }
141
281
  usage.lastUpdatedAt = refAt;
142
282
  await FileSystemUtils.createDirectory(path.dirname(getUsagePath(knowledgeDir)));
@@ -173,11 +313,20 @@ export async function knowledgeUsageHook(options) {
173
313
  }
174
314
  const toolInput = stdinData.tool_input;
175
315
  const filePath = toolInput?.file_path || toolInput?.path || options.file || '';
176
- if (filePath) {
316
+ const projectWikiRefEvent = extractProjectWikiRefEvent(stdinData, toolInput) || {
317
+ source: 'llmwiki',
318
+ effectiveRefs: extractLlmwikiEffectiveRefsFromHookInput(stdinData),
319
+ };
320
+ if (filePath || projectWikiRefEvent.effectiveRefs.length > 0) {
177
321
  try {
178
322
  const repoRoot = resolveZhuanSpecRoot(process.cwd());
179
323
  const knowledgeDir = path.join(repoRoot, 'zhuanspec', 'knowledge');
180
- await reportKnowledgeRef(knowledgeDir, { filePath });
324
+ if (projectWikiRefEvent.effectiveRefs.length > 0) {
325
+ await reportProjectWikiRefs(knowledgeDir, projectWikiRefEvent);
326
+ }
327
+ if (filePath) {
328
+ await reportKnowledgeRef(knowledgeDir, { filePath });
329
+ }
181
330
  }
182
331
  catch {
183
332
  // 旁路 hook 永不抛错
package/dist/core/init.js CHANGED
@@ -1495,7 +1495,7 @@ export class InitCommand {
1495
1495
  ],
1496
1496
  },
1497
1497
  {
1498
- matcher: 'Read|Grep|Glob',
1498
+ matcher: 'Read|Grep|Glob|Bash',
1499
1499
  hooks: [
1500
1500
  {
1501
1501
  type: 'command',
@@ -30,6 +30,8 @@ export interface FunnelStats {
30
30
  projectWikiReferencedRefs: number;
31
31
  /** ProjectWiki 被引用数按 service 聚合 */
32
32
  projectWikiReferencedRefsByService: Record<string, number>;
33
+ /** ProjectWiki 被引用数按来源聚合,如 file / llmwiki */
34
+ projectWikiReferencedRefsBySource: Record<string, number>;
33
35
  /** 五跳转化率(0~1,分母为 0 记 0) */
34
36
  conversions: {
35
37
  signalToAsked: number;
@@ -104,6 +104,13 @@ function aggregateProjectWikiRefsByService(byProjectWikiFile) {
104
104
  }
105
105
  return Object.fromEntries(Object.entries(byService).sort(([a], [b]) => a.localeCompare(b)));
106
106
  }
107
+ function aggregateCounters(counters) {
108
+ const result = {};
109
+ for (const [key, counter] of Object.entries(counters)) {
110
+ result[key] = counter.refCount ?? 0;
111
+ }
112
+ return Object.fromEntries(Object.entries(result).sort(([a], [b]) => a.localeCompare(b)));
113
+ }
107
114
  /**
108
115
  * 顶层聚合:拼装 FunnelStats。
109
116
  * @param archivedEntries 落库数(由调用方传入 stats.total,避免重复 scanKnowledgeEntries)
@@ -114,6 +121,7 @@ export async function computeFunnelStats(zhuanspecDir, knowledgeDir, archivedEnt
114
121
  const referencedRefs = usage.totalRefs;
115
122
  const projectWikiReferencedRefs = usage.projectWikiTotalRefs;
116
123
  const projectWikiReferencedRefsByService = aggregateProjectWikiRefsByService(usage.byProjectWikiFile);
124
+ const projectWikiReferencedRefsBySource = aggregateCounters(usage.projectWikiRefsBySource);
117
125
  const { correctionSignals, askedPitfalls, scannedChanges } = changeScan;
118
126
  return {
119
127
  correctionSignals,
@@ -122,6 +130,7 @@ export async function computeFunnelStats(zhuanspecDir, knowledgeDir, archivedEnt
122
130
  referencedRefs,
123
131
  projectWikiReferencedRefs,
124
132
  projectWikiReferencedRefsByService,
133
+ projectWikiReferencedRefsBySource,
125
134
  conversions: {
126
135
  signalToAsked: rate(askedPitfalls, correctionSignals),
127
136
  askedToArchived: rate(archivedEntries, askedPitfalls),
@@ -62,7 +62,7 @@ const CODEX_DEFAULT_HOOKS = [
62
62
  },
63
63
  {
64
64
  event: 'PostToolUse',
65
- matcher: 'Read|Grep|Glob',
65
+ matcher: 'Read|Grep|Glob|Bash',
66
66
  command: 'ZHUANSPEC_HOST=codex zhuanspec-hook knowledge-usage --json',
67
67
  },
68
68
  {
@@ -893,9 +893,26 @@ const designSteps = `**步骤**
893
893
  * 在 progress.json 的 events 字段追加:\`{ "event": "test-case-skipped", "timestamp": "<ISO>" }\`
894
894
  * 直接进入步骤 8
895
895
 
896
+ 7.6. **涉及工程清单输出(阶段 6 定稿产物,必须生成)**:
897
+ - 在技术方案定稿时,从 Skill 生成的 \`tech-spec.md\`、\`matched_services\` 和改动点定位结果中提取涉及工程
898
+ - 输出到与技术方案同目录:
899
+ \`zhuanspec/changes/{change-id}/techDesign/affected-projects.md\`
900
+ - 只记录分析出的工程/服务名称,不展开改动原因、证据、置信度或实现细节
901
+ - 固定使用 Markdown 列表:
902
+ \`\`\`markdown
903
+ # 涉及工程清单
904
+
905
+ - user-service
906
+ - order-service
907
+ \`\`\`
908
+ - 工程/服务必须来自项目知识定位或实际代码检索证据,禁止凭空补全
909
+ - 如果无法定位任何工程,也必须生成 \`affected-projects.md\`,并写明"未定位到明确工程"
910
+ - 该文件供后续 proposal/tasks/apply 阶段快速判断工程边界,禁止创建 proposal.md 或 tasks.md 来替代它
911
+
896
912
  8. **输出摘要**:
897
913
  - 告知用户生成的文档路径和实际调用的 Skill 名称
898
- - **明确说明**:techDesign 阶段仅保存进度数据(progress.json)和技术方案(含外部依赖矩阵),不创建 proposal.md 和 tasks.md
914
+ - 告知用户涉及工程清单路径:\`zhuanspec/changes/{change-id}/techDesign/affected-projects.md\`
915
+ - **明确说明**:techDesign 阶段仅保存进度数据(progress.json)、技术方案(含外部依赖矩阵)和涉及工程清单,不创建 proposal.md 和 tasks.md
899
916
  - 如果步骤 7.5 生成了测试 case,额外提示:「✅ 已生成测试 case 源文件,后续 Propose 阶段将自动启用 TDD 模式并调用 tdd-testcase-generator 转换为研发 TDD testcase」
900
917
  - 提示 phase=techDesign
901
918
  - 提示下一步可以使用 \`/zhuanspec:proposal\` 创建变更提案(复用目录)`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhuan-ai/zhuanspec",
3
- "version": "2.17.0",
3
+ "version": "2.17.2",
4
4
  "description": "AI-native system for spec-driven development",
5
5
  "keywords": [
6
6
  "zhuanspec",
@@ -39,26 +39,6 @@
39
39
  "!dist/**/__tests__",
40
40
  "!dist/**/*.map"
41
41
  ],
42
- "scripts": {
43
- "lint": "eslint src/",
44
- "build": "node build.js",
45
- "dev": "tsc --watch",
46
- "dev:cli": "pnpm build && node bin/zhuanspec.js",
47
- "test": "vitest run",
48
- "test:watch": "vitest",
49
- "test:ui": "vitest --ui",
50
- "test:coverage": "vitest --coverage",
51
- "test:postinstall": "node scripts/postinstall.js",
52
- "prepare": "npm run build",
53
- "prepublishOnly": "npm run build",
54
- "postinstall": "node scripts/postinstall.js",
55
- "check:pack-version": "node scripts/pack-version-check.mjs",
56
- "diagnose:cursor": "node scripts/diagnose-cursor-commands.js",
57
- "release": "pnpm run release:ci",
58
- "release:ci": "pnpm run check:pack-version && pnpm exec changeset publish",
59
- "release:local": "pnpm exec changeset version && pnpm run check:pack-version && pnpm exec changeset publish",
60
- "changeset": "changeset"
61
- },
62
42
  "engines": {
63
43
  "node": ">=20.19.0"
64
44
  },
@@ -80,5 +60,23 @@
80
60
  "ora": "^8.2.0",
81
61
  "yaml": "^2.8.2",
82
62
  "zod": "^4.0.17"
63
+ },
64
+ "scripts": {
65
+ "lint": "eslint src/",
66
+ "build": "node build.js",
67
+ "dev": "tsc --watch",
68
+ "dev:cli": "pnpm build && node bin/zhuanspec.js",
69
+ "test": "vitest run",
70
+ "test:watch": "vitest",
71
+ "test:ui": "vitest --ui",
72
+ "test:coverage": "vitest --coverage",
73
+ "test:postinstall": "node scripts/postinstall.js",
74
+ "postinstall": "node scripts/postinstall.js",
75
+ "check:pack-version": "node scripts/pack-version-check.mjs",
76
+ "diagnose:cursor": "node scripts/diagnose-cursor-commands.js",
77
+ "release": "pnpm run release:ci",
78
+ "release:ci": "pnpm run check:pack-version && pnpm exec changeset publish",
79
+ "release:local": "pnpm exec changeset version && pnpm run check:pack-version && pnpm exec changeset publish",
80
+ "changeset": "changeset"
83
81
  }
84
- }
82
+ }