@punk6529/playbook 0.2.5 → 0.2.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/README.md CHANGED
@@ -24,7 +24,7 @@
24
24
  - `/playbook-advisor`:会话内主动顾问,自动按需查询知识库
25
25
  - `/playbook-query`:手动按标签检索经验
26
26
  - `/playbook-case`:引导式记录新 case,并更新索引
27
- - 一套稳定的 CLI 工作流(init / global init / update / query / promote / hit),便于持续演进而不破坏现有内容
27
+ - 一套稳定的 CLI 工作流(init / global init / update / query / promote / show / hit / reindex),便于持续演进而不破坏现有内容
28
28
 
29
29
  ## 典型工作流
30
30
 
@@ -186,6 +186,17 @@ playbook hit case-001-docker-bind # 记录一次命中
186
186
 
187
187
  AI advisor 每次阅读案例详情后自动调用。命中次数在 `playbook query` 输出中以 `hits:N` 显示,帮助识别高价值案例。
188
188
 
189
+ ### `playbook reindex`
190
+
191
+ 从文件 front matter 重建 INDEX.json。当 INDEX 被清空、损坏或需要同步时使用。
192
+
193
+ ```bash
194
+ playbook reindex # 重建项目 INDEX
195
+ playbook reindex --scope global # 重建全局 INDEX
196
+ ```
197
+
198
+ 扫描 `cases/`、`patterns/`、`checklists/` 下的 `.md` 文件,解析 YAML front matter(title, tags, created),重建 INDEX.json。保留已有的 hits 统计。
199
+
189
200
  ## 错误处理
190
201
 
191
202
  - 未知命令或无效选项会给出可操作的提示信息
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@punk6529/playbook",
3
- "version": "0.2.5",
3
+ "version": "0.2.8",
4
4
  "description": "CLI for initializing and updating Playbook & Case workflows.",
5
5
  "main": "src/index.js",
6
6
  "bin": {
package/src/cli.js CHANGED
@@ -1,5 +1,8 @@
1
+ const fs = require("fs");
2
+ const path = require("path");
3
+ const os = require("os");
1
4
  const { CliError } = require("./errors");
2
- const { parseProjectCommandArgs, parseGlobalInitArgs, parseQueryArgs, parsePromoteArgs, parseHitArgs, parseShowArgs } = require("./options");
5
+ const { parseProjectCommandArgs, parseGlobalInitArgs, parseQueryArgs, parsePromoteArgs, parseHitArgs, parseShowArgs, parseReindexArgs } = require("./options");
3
6
  const { initProject } = require("./commands/init-project");
4
7
  const { initGlobal } = require("./commands/global-init");
5
8
  const { updateProject } = require("./commands/update-project");
@@ -7,6 +10,7 @@ const { queryIndex, aggregateTags, formatPlainText, formatJson, formatTagsOnly }
7
10
  const { promoteEntries } = require("./commands/promote");
8
11
  const { hitEntry } = require("./commands/hit");
9
12
  const { showEntry } = require("./commands/show");
13
+ const { reindexPlaybook } = require("./commands/reindex");
10
14
  const { printSummary } = require("./reporting");
11
15
 
12
16
  function usageText() {
@@ -20,6 +24,8 @@ function usageText() {
20
24
  " playbook promote --all [--force]",
21
25
  " playbook hit <entry-id>",
22
26
  " playbook show <entry-id>",
27
+ " playbook reindex [--scope project|global]",
28
+ " playbook root [--scope project|global]",
23
29
  "",
24
30
  "Notes:",
25
31
  " - `init` and `update` are project-scoped commands.",
@@ -204,6 +210,59 @@ function runShow(args, io) {
204
210
  return 0;
205
211
  }
206
212
 
213
+ function reindexHelpText() {
214
+ return [
215
+ "Usage: playbook reindex [options]",
216
+ "",
217
+ "Rebuild INDEX.json by scanning playbook files and reading front matter.",
218
+ "",
219
+ "Options:",
220
+ " --scope X Scope: project or global (default: project)",
221
+ " -h, --help Show this help",
222
+ ].join("\n");
223
+ }
224
+
225
+ function runReindex(args, io) {
226
+ const options = parseReindexArgs(args);
227
+ if (options.help) {
228
+ writeStdout(io, reindexHelpText());
229
+ return 0;
230
+ }
231
+
232
+ const result = reindexPlaybook(options);
233
+ writeStdout(io, `Reindexed ${result.scope}: ${result.count} ${result.count === 1 ? "entry" : "entries"} found.`);
234
+ return 0;
235
+ }
236
+
237
+ function runRoot(args, io) {
238
+ let scope = "project";
239
+ for (const arg of args) {
240
+ if (arg === "-h" || arg === "--help") {
241
+ writeStdout(io, "Usage: playbook root [--scope project|global]\n\nOutput the absolute path of the playbook directory.");
242
+ return 0;
243
+ }
244
+ if (arg === "--scope" || arg.startsWith("--scope=")) {
245
+ const value = arg === "--scope" ? args[args.indexOf(arg) + 1] : arg.slice("--scope=".length);
246
+ if (value === "global" || value === "project") scope = value;
247
+ }
248
+ }
249
+
250
+ if (scope === "global") {
251
+ const globalRoot = path.join(os.homedir(), ".playbook/repo");
252
+ if (!fs.existsSync(globalRoot)) {
253
+ throw new CliError("Global playbook not found. Run 'playbook global init' first.");
254
+ }
255
+ writeStdout(io, globalRoot);
256
+ } else {
257
+ const projectRoot = path.resolve("docs/playbook");
258
+ if (!fs.existsSync(projectRoot)) {
259
+ throw new CliError("Project playbook not found. Run 'playbook init' first.");
260
+ }
261
+ writeStdout(io, projectRoot);
262
+ }
263
+ return 0;
264
+ }
265
+
207
266
  function runGlobal(args, io) {
208
267
  const subcommand = args[0];
209
268
  if (!subcommand) {
@@ -265,11 +324,19 @@ async function runCli(argv, io = process) {
265
324
  return runShow(argv.slice(1), io);
266
325
  }
267
326
 
327
+ if (command === "reindex") {
328
+ return runReindex(argv.slice(1), io);
329
+ }
330
+
331
+ if (command === "root") {
332
+ return runRoot(argv.slice(1), io);
333
+ }
334
+
268
335
  if (command === "global") {
269
336
  return runGlobal(argv.slice(1), io);
270
337
  }
271
338
 
272
- throw new CliError(`Unknown command: ${command}. Supported: init, global, update, query, promote, hit, show`);
339
+ throw new CliError(`Unknown command: ${command}. Supported: init, global, update, query, promote, hit, show, reindex, root`);
273
340
  } catch (error) {
274
341
  if (error instanceof CliError) {
275
342
  writeStderr(io, `Error: ${error.message}`);
@@ -92,11 +92,28 @@ function formatPlainText(result) {
92
92
  return lines.join("\n");
93
93
  }
94
94
 
95
+ function readTagsRegistry(tagsYmlPath) {
96
+ try {
97
+ const content = fs.readFileSync(tagsYmlPath, "utf8");
98
+ const ids = [];
99
+ for (const line of content.split("\n")) {
100
+ const match = line.match(/^\s*-\s*id:\s*(.+)/);
101
+ if (match) {
102
+ ids.push(match[1].trim());
103
+ }
104
+ }
105
+ return ids;
106
+ } catch {
107
+ return null;
108
+ }
109
+ }
110
+
95
111
  function aggregateTags(options) {
96
112
  const { scope = "both", targetPath = "." } = options;
97
113
 
98
114
  const projectIndexPath = path.resolve(targetPath, "docs/playbook/INDEX.json");
99
115
  const globalIndexPath = path.join(os.homedir(), ".playbook/repo/INDEX.json");
116
+ const tagsYmlPath = path.join(os.homedir(), ".playbook/repo/tags.yml");
100
117
 
101
118
  const tagCounts = {};
102
119
  let anyIndexFound = false;
@@ -125,6 +142,17 @@ function aggregateTags(options) {
125
142
  }
126
143
  }
127
144
 
145
+ // Merge tags.yml registry — ensures registered tags appear even with count 0
146
+ const registryTags = readTagsRegistry(tagsYmlPath);
147
+ if (registryTags) {
148
+ anyIndexFound = true;
149
+ for (const tag of registryTags) {
150
+ if (!(tag in tagCounts)) {
151
+ tagCounts[tag] = 0;
152
+ }
153
+ }
154
+ }
155
+
128
156
  if (!anyIndexFound) {
129
157
  return { tags: {}, found: false };
130
158
  }
@@ -0,0 +1,98 @@
1
+ const fs = require("fs");
2
+ const path = require("path");
3
+ const os = require("os");
4
+ const { CliError } = require("../errors");
5
+ const { parseFrontMatter } = require("../frontmatter");
6
+
7
+ const TYPE_DIRS = {
8
+ cases: "case",
9
+ patterns: "pattern",
10
+ checklists: "checklist",
11
+ };
12
+
13
+ function readIndex(indexPath) {
14
+ try {
15
+ const content = fs.readFileSync(indexPath, "utf8");
16
+ return JSON.parse(content);
17
+ } catch {
18
+ return null;
19
+ }
20
+ }
21
+
22
+ function scanDir(baseDir) {
23
+ const entries = {};
24
+
25
+ for (const [dirName, type] of Object.entries(TYPE_DIRS)) {
26
+ const dirPath = path.join(baseDir, dirName);
27
+ if (!fs.existsSync(dirPath)) continue;
28
+
29
+ const files = fs.readdirSync(dirPath).filter((f) => f.endsWith(".md"));
30
+ for (const file of files) {
31
+ const filePath = path.join(dirPath, file);
32
+ const content = fs.readFileSync(filePath, "utf8");
33
+ const fm = parseFrontMatter(content);
34
+
35
+ if (!fm.title) continue; // skip files without valid front matter
36
+
37
+ const id = file.replace(/\.md$/, "");
38
+ const relativePath = `${dirName}/${file}`;
39
+
40
+ entries[id] = {
41
+ type,
42
+ title: fm.title,
43
+ tags: fm.tags || [],
44
+ path: relativePath,
45
+ created: fm.created || new Date().toISOString().slice(0, 10),
46
+ };
47
+ }
48
+ }
49
+
50
+ return entries;
51
+ }
52
+
53
+ function reindexPlaybook(options) {
54
+ const { scope = "project", targetPath = "." } = options;
55
+
56
+ let baseDir;
57
+ let indexPath;
58
+
59
+ if (scope === "global") {
60
+ baseDir = path.join(os.homedir(), ".playbook/repo");
61
+ indexPath = path.join(baseDir, "INDEX.json");
62
+ } else {
63
+ baseDir = path.resolve(targetPath, "docs/playbook");
64
+ indexPath = path.join(baseDir, "INDEX.json");
65
+ }
66
+
67
+ if (!fs.existsSync(baseDir)) {
68
+ throw new CliError(
69
+ scope === "global"
70
+ ? "Global playbook not found. Run 'playbook global init' first."
71
+ : "Project playbook not found. Run 'playbook init' first."
72
+ );
73
+ }
74
+
75
+ // Read existing index to preserve hits
76
+ const oldIndex = readIndex(indexPath) || {};
77
+
78
+ // Scan files and build new index
79
+ const newEntries = scanDir(baseDir);
80
+
81
+ // Preserve hits from old index
82
+ for (const [id, entry] of Object.entries(newEntries)) {
83
+ if (oldIndex[id] && oldIndex[id].hits) {
84
+ entry.hits = oldIndex[id].hits;
85
+ }
86
+ }
87
+
88
+ // Write new index
89
+ fs.writeFileSync(indexPath, JSON.stringify(newEntries, null, 2) + "\n", "utf8");
90
+
91
+ return {
92
+ scope,
93
+ count: Object.keys(newEntries).length,
94
+ entries: newEntries,
95
+ };
96
+ }
97
+
98
+ module.exports = { reindexPlaybook, scanDir };
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Parse YAML front matter from markdown content.
3
+ * Expects format:
4
+ * ---
5
+ * title: Some title
6
+ * tags: [tag1, tag2]
7
+ * created: 2026-02-01
8
+ * ---
9
+ * body content here
10
+ */
11
+ function parseFrontMatter(content) {
12
+ const trimmed = content.trimStart();
13
+ if (!trimmed.startsWith("---")) {
14
+ return { title: null, tags: null, created: null, body: content };
15
+ }
16
+
17
+ const endIndex = trimmed.indexOf("\n---", 3);
18
+ if (endIndex === -1) {
19
+ return { title: null, tags: null, created: null, body: content };
20
+ }
21
+
22
+ const fmBlock = trimmed.slice(4, endIndex);
23
+ const body = trimmed.slice(endIndex + 4).replace(/^\n/, "");
24
+
25
+ let title = null;
26
+ let tags = null;
27
+ let created = null;
28
+
29
+ for (const line of fmBlock.split("\n")) {
30
+ const stripped = line.trim();
31
+ if (!stripped || stripped.startsWith("#")) continue;
32
+
33
+ const colonIndex = stripped.indexOf(":");
34
+ if (colonIndex === -1) continue;
35
+
36
+ const key = stripped.slice(0, colonIndex).trim();
37
+ const value = stripped.slice(colonIndex + 1).trim();
38
+
39
+ if (key === "title") {
40
+ title = value;
41
+ } else if (key === "tags") {
42
+ tags = parseTagsValue(value);
43
+ } else if (key === "created") {
44
+ created = value;
45
+ }
46
+ }
47
+
48
+ return { title, tags, created, body };
49
+ }
50
+
51
+ function parseTagsValue(value) {
52
+ // Handle [tag1, tag2] format
53
+ const match = value.match(/^\[(.*)?\]$/);
54
+ if (match) {
55
+ if (!match[1] || !match[1].trim()) return [];
56
+ return match[1]
57
+ .split(",")
58
+ .map((t) => t.trim())
59
+ .filter(Boolean);
60
+ }
61
+ // Handle bare value as single tag
62
+ if (value) return [value];
63
+ return [];
64
+ }
65
+
66
+ module.exports = { parseFrontMatter };
package/src/options.js CHANGED
@@ -259,6 +259,43 @@ function parseShowArgs(args) {
259
259
  return { help: false, entryId };
260
260
  }
261
261
 
262
+ function parseReindexArgs(args) {
263
+ let scope = "project";
264
+
265
+ for (let i = 0; i < args.length; i += 1) {
266
+ const arg = args[i];
267
+
268
+ if (arg === "-h" || arg === "--help") {
269
+ return { help: true };
270
+ }
271
+
272
+ if (arg === "--scope") {
273
+ const next = args[i + 1];
274
+ if (!next || !["project", "global"].includes(next)) {
275
+ throw new CliError("--scope must be one of: project, global");
276
+ }
277
+ scope = next;
278
+ i += 1;
279
+ continue;
280
+ }
281
+
282
+ if (arg.startsWith("--scope=")) {
283
+ const value = arg.slice("--scope=".length);
284
+ if (!["project", "global"].includes(value)) {
285
+ throw new CliError("--scope must be one of: project, global");
286
+ }
287
+ scope = value;
288
+ continue;
289
+ }
290
+
291
+ if (arg.startsWith("-")) {
292
+ throw new CliError(`Unknown option for playbook reindex: ${arg}`);
293
+ }
294
+ }
295
+
296
+ return { help: false, scope };
297
+ }
298
+
262
299
  module.exports = {
263
300
  parseProjectCommandArgs,
264
301
  parseGlobalInitArgs,
@@ -266,5 +303,6 @@ module.exports = {
266
303
  parsePromoteArgs,
267
304
  parseHitArgs,
268
305
  parseShowArgs,
306
+ parseReindexArgs,
269
307
  parseToolsValue,
270
308
  };
@@ -13,10 +13,11 @@ Use this skill when the user asks to create, review, or inspect case entries.
13
13
  - For new cases, enforce this guided flow:
14
14
  1. If the user has not provided a concrete problem statement, ask first:
15
15
  "你想把之前遇到的哪个问题沉淀为 case?请一句话描述。"
16
- 2. After problem statement is available, suggest tags from registry/index context first:
17
- - Prefer project index: `docs/playbook/INDEX.json`
18
- - Use global index only when relevant: `~/.playbook/repo/INDEX.json`
19
- - Ask user to choose existing tags or explicitly confirm a new tag
16
+ 2. After problem statement is available, get available tags by running:
17
+ ```bash
18
+ playbook query --tags-only
19
+ ```
20
+ Suggest relevant existing tags from the output. Ask user to choose existing tags or explicitly confirm a new tag.
20
21
  3. If scope is not confirmed, ask for scope confirmation:
21
22
  - default is `project`
22
23
  - use `global` only when user explicitly confirms
@@ -65,9 +66,51 @@ Format: `{type}-{NNN}-{slug}`
65
66
  - `NNN`: zero-padded 3-digit sequence number (check existing entries to determine next number)
66
67
  - `slug`: lowercase hyphenated descriptor derived from the title
67
68
 
69
+ ### Case File Format
70
+
71
+ Case files MUST include YAML front matter with title, tags, and created:
72
+
73
+ ```markdown
74
+ ---
75
+ title: OAuth token refresh race condition under concurrency
76
+ tags: [auth, api]
77
+ created: 2026-02-10
78
+ ---
79
+
80
+ ## Problem
81
+ ...
82
+
83
+ ## Context
84
+ ...
85
+
86
+ ## Solution
87
+ ...
88
+
89
+ ## Takeaway
90
+ ...
91
+ ```
92
+
93
+ The front matter is the source of truth for metadata. INDEX.json is rebuilt from front matter via `playbook reindex`.
94
+
95
+ ### File Paths
96
+
97
+ Write files to the correct location based on scope:
98
+
99
+ - **Project scope:** case file → `<project-root>/docs/playbook/<path>`, INDEX → `<project-root>/docs/playbook/INDEX.json`
100
+ - **Global scope:** case file → `~/.playbook/repo/<path>`, INDEX → `~/.playbook/repo/INDEX.json`
101
+
102
+ **IMPORTANT:** Always use absolute paths when writing files. Do NOT use relative paths — the current working directory may not be the project root.
103
+
104
+ Get the playbook directory first:
105
+ ```bash
106
+ playbook root # project scope
107
+ playbook root --scope global # global scope
108
+ ```
109
+ Then use the output to construct absolute paths, e.g. `<root>/cases/case-001-xxx.md` and `<root>/INDEX.json`.
110
+
68
111
  ### Draft Package Output
69
112
 
70
- When producing the draft package, output the INDEX entry as a ready-to-merge JSON snippet:
113
+ When producing the draft package, output:
71
114
 
72
115
  ```
73
116
  **Entry ID:** case-001-oauth-token-race
@@ -84,3 +127,11 @@ Add this entry to INDEX.json:
84
127
  }
85
128
  }
86
129
  ```
130
+
131
+ ### Write Procedure
132
+
133
+ After user confirms the draft:
134
+
135
+ 1. Write the case file to the scope-specific path
136
+ 2. Read the current INDEX.json, merge the new entry, write back
137
+ 3. Run `playbook hit <entry-id>` is NOT needed — hit is only for reads