@punk6529/playbook 0.2.1 → 0.2.3

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
@@ -1,6 +1,38 @@
1
1
  # @punk6529/playbook
2
2
 
3
- 结构化工程知识库 CLI —— 初始化、更新和查询项目 Playbook 资产。
3
+ 把团队踩过的坑,沉淀成 AI 可读、可查、可复用的工程记忆。
4
+
5
+ `@punk6529/playbook` 是一个结构化工程知识库 CLI,用于在项目中快速建立并维护 Playbook 资产(cases / patterns / checklists + INDEX)。
6
+
7
+ 它不只是“记笔记工具”,而是把“个人经验”变成“团队可复用资产”的最小基础设施:
8
+
9
+ - 解决问题后可立即沉淀为 case,避免同类问题反复排查
10
+ - AI 可以按 tag 精准检索历史经验,而不是每次从零猜测
11
+ - 支持项目级和全局级知识库,单项目沉淀可逐步升级为跨项目最佳实践
12
+ - 通过结构化索引 + 按需读取,控制上下文体积,降低 token 浪费
13
+
14
+ ## 这个工具适合谁
15
+
16
+ - 希望减少重复踩坑的个人开发者
17
+ - 需要把故障处置经验标准化的技术团队
18
+ - 在 Codex / Claude 工作流中,希望让 AI“带着历史经验工作”的团队
19
+
20
+ ## 你会获得什么
21
+
22
+ - 一套开箱即用的知识库目录结构(项目级 + 全局级)
23
+ - 三个可直接使用的 AI skills:
24
+ - `/playbook-advisor`:会话内主动顾问,自动按需查询知识库
25
+ - `/playbook-query`:手动按标签检索经验
26
+ - `/playbook-case`:引导式记录新 case,并更新索引
27
+ - 一套稳定的 CLI 工作流(init / global init / update / query / promote / hit),便于持续演进而不破坏现有内容
28
+
29
+ ## 典型工作流
30
+
31
+ 1. `playbook init` 初始化项目知识库与 AI skills (新项目一次即可)
32
+ 2. `playbook global init` 初始化全局知识库(一次即可)
33
+ 3. 新会话开始先调用 `/playbook-advisor`,AI 会列出现有 tags;后续遇到问题时,会按相关 tags 查询案例 summary,并选取最相关案例查看详情
34
+ 4. 问题解决后,用 `/playbook-case` 沉淀经验并更新索引
35
+ 5. 在多个项目间通过全局仓库复用高价值模式
4
36
 
5
37
  ## 安装
6
38
 
@@ -52,25 +84,6 @@ playbook update --force # 强制覆盖(含 INDEX.json)
52
84
 
53
85
  仅刷新 skill 模板,不修改用户业务内容(case/pattern/checklist 文件)。`INDEX.json` 默认受保护,需 `--force` 才会覆盖。
54
86
 
55
- ## 内部命令
56
-
57
- 以下命令主要由 AI skill 内部调用,用户一般不需要直接使用:
58
-
59
- ### `playbook query`
60
-
61
- 按 tag 搜索 INDEX.json 条目(OR 逻辑),同时搜索项目和全局范围。
62
-
63
- ```bash
64
- playbook query docker env # 匹配 "docker" 或 "env" 的条目
65
- playbook query # 所有条目
66
- playbook query --tags-only # 仅输出 tag 概览:docker(3) env(5) ci(2)
67
- playbook query --scope project # 仅项目范围
68
- playbook query --limit 0 # 不限数量
69
- playbook query docker --json # JSON 格式输出
70
- ```
71
-
72
- 这个命令是 AI skill 的底层工具。`/playbook-advisor` 和 `/playbook-query` skill 在内部调用它来搜索知识库,节省 token 开销。
73
-
74
87
  ## 在 AI 中使用
75
88
 
76
89
  `playbook init` 之后,AI 工具中有三个 skill 可用:
@@ -121,6 +134,48 @@ AI:(引导你填写 问题/原因/解决/教训 四个部分)
121
134
  AI:(生成 case 文件,更新 INDEX.json)
122
135
  ```
123
136
 
137
+ ### `playbook promote`
138
+
139
+ 将项目级案例提升到全局知识库,让经验跨项目复用。
140
+
141
+ ```bash
142
+ playbook promote case-001-docker-bind # 提升单个条目
143
+ playbook promote case-001 case-002 # 提升多个条目
144
+ playbook promote --all # 提升所有项目条目
145
+ playbook promote case-001 --force # 强制覆盖已存在的全局条目
146
+ ```
147
+
148
+ 将项目 `docs/playbook/` 下的条目移动到全局 `~/.playbook/repo/`,同时更新两端的 INDEX.json。
149
+
150
+ ## 内部命令
151
+
152
+ 以下命令主要由 AI skill 内部调用,用户一般不需要直接使用:
153
+
154
+ ### `playbook query`
155
+
156
+ 按 tag 搜索 INDEX.json 条目(OR 逻辑),同时搜索项目和全局范围。
157
+
158
+ ```bash
159
+ playbook query docker env # 匹配 "docker" 或 "env" 的条目
160
+ playbook query # 所有条目
161
+ playbook query --tags-only # 仅输出 tag 概览:docker(3) env(5) ci(2)
162
+ playbook query --scope project # 仅项目范围
163
+ playbook query --limit 0 # 不限数量
164
+ playbook query docker --json # JSON 格式输出
165
+ ```
166
+
167
+ 这个命令是 AI skill 的底层工具。`/playbook-advisor` 和 `/playbook-query` skill 在内部调用它来搜索知识库,节省 token 开销。
168
+
169
+ ### `playbook hit`
170
+
171
+ 记录一次案例阅读命中,用于追踪案例的实际使用频率。
172
+
173
+ ```bash
174
+ playbook hit case-001-docker-bind # 记录一次命中
175
+ ```
176
+
177
+ AI advisor 每次阅读案例详情后自动调用。命中次数在 `playbook query` 输出中以 `hits:N` 显示,帮助识别高价值案例。
178
+
124
179
  ## 错误处理
125
180
 
126
181
  - 未知命令或无效选项会给出可操作的提示信息
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@punk6529/playbook",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
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,9 +1,11 @@
1
1
  const { CliError } = require("./errors");
2
- const { parseProjectCommandArgs, parseGlobalInitArgs, parseQueryArgs } = require("./options");
2
+ const { parseProjectCommandArgs, parseGlobalInitArgs, parseQueryArgs, parsePromoteArgs, parseHitArgs } = require("./options");
3
3
  const { initProject } = require("./commands/init-project");
4
4
  const { initGlobal } = require("./commands/global-init");
5
5
  const { updateProject } = require("./commands/update-project");
6
6
  const { queryIndex, aggregateTags, formatPlainText, formatJson, formatTagsOnly } = require("./commands/query");
7
+ const { promoteEntries } = require("./commands/promote");
8
+ const { hitEntry } = require("./commands/hit");
7
9
  const { printSummary } = require("./reporting");
8
10
 
9
11
  function usageText() {
@@ -13,6 +15,9 @@ function usageText() {
13
15
  " playbook global init [--force]",
14
16
  " playbook update [path] [--tools all|none|codex,claude] [--force]",
15
17
  " playbook query [tags...] [--limit N] [--scope project|global|both] [--json] [--tags-only]",
18
+ " playbook promote <entry-id...> [--force]",
19
+ " playbook promote --all [--force]",
20
+ " playbook hit <entry-id>",
16
21
  "",
17
22
  "Notes:",
18
23
  " - `init` and `update` are project-scoped commands.",
@@ -100,6 +105,79 @@ function runQuery(args, io) {
100
105
  return 0;
101
106
  }
102
107
 
108
+ function promoteHelpText() {
109
+ return [
110
+ "Usage: playbook promote <entry-id...> [options]",
111
+ " playbook promote --all [options]",
112
+ "",
113
+ "Move project entries to global knowledge base.",
114
+ "",
115
+ "Options:",
116
+ " --all Promote all project entries",
117
+ " --force Overwrite if entry already exists in global",
118
+ " -h, --help Show this help",
119
+ ].join("\n");
120
+ }
121
+
122
+ function runPromote(args, io) {
123
+ const options = parsePromoteArgs(args);
124
+ if (options.help) {
125
+ writeStdout(io, promoteHelpText());
126
+ return 0;
127
+ }
128
+
129
+ const result = promoteEntries(options);
130
+
131
+ if (result.message) {
132
+ writeStdout(io, result.message);
133
+ return 0;
134
+ }
135
+
136
+ let hasError = false;
137
+ for (const r of result.results) {
138
+ if (r.action === "promoted") {
139
+ writeStdout(io, `[promoted] ${r.entryId}\t${r.title}`);
140
+ } else if (r.action === "conflict") {
141
+ writeStderr(io, `[conflict] ${r.entryId}\t${r.reason}`);
142
+ hasError = true;
143
+ } else if (r.action === "error") {
144
+ writeStderr(io, `[error] ${r.entryId}\t${r.reason}`);
145
+ hasError = true;
146
+ }
147
+ }
148
+
149
+ const promoted = result.results.filter((r) => r.action === "promoted").length;
150
+ if (promoted > 0) {
151
+ writeStdout(io, `(${promoted} ${promoted === 1 ? "entry" : "entries"} promoted to global)`);
152
+ }
153
+
154
+ return hasError ? 1 : 0;
155
+ }
156
+
157
+ function hitHelpText() {
158
+ return [
159
+ "Usage: playbook hit <entry-id>",
160
+ "",
161
+ "Record a read hit on a playbook entry.",
162
+ "Searches project INDEX first, then global INDEX.",
163
+ "",
164
+ "Options:",
165
+ " -h, --help Show this help",
166
+ ].join("\n");
167
+ }
168
+
169
+ function runHit(args, io) {
170
+ const options = parseHitArgs(args);
171
+ if (options.help) {
172
+ writeStdout(io, hitHelpText());
173
+ return 0;
174
+ }
175
+
176
+ const result = hitEntry(options);
177
+ writeStdout(io, `[${result.scope}] ${result.entryId} hits:${result.hits}`);
178
+ return 0;
179
+ }
180
+
103
181
  function runGlobal(args, io) {
104
182
  const subcommand = args[0];
105
183
  if (!subcommand) {
@@ -149,11 +227,19 @@ async function runCli(argv, io = process) {
149
227
  return runQuery(argv.slice(1), io);
150
228
  }
151
229
 
230
+ if (command === "promote") {
231
+ return runPromote(argv.slice(1), io);
232
+ }
233
+
234
+ if (command === "hit") {
235
+ return runHit(argv.slice(1), io);
236
+ }
237
+
152
238
  if (command === "global") {
153
239
  return runGlobal(argv.slice(1), io);
154
240
  }
155
241
 
156
- throw new CliError(`Unknown command: ${command}. Supported: init, global, update, query`);
242
+ throw new CliError(`Unknown command: ${command}. Supported: init, global, update, query, promote, hit`);
157
243
  } catch (error) {
158
244
  if (error instanceof CliError) {
159
245
  writeStderr(io, `Error: ${error.message}`);
@@ -0,0 +1,51 @@
1
+ const fs = require("fs");
2
+ const path = require("path");
3
+ const os = require("os");
4
+ const { CliError } = require("../errors");
5
+
6
+ function readIndex(indexPath) {
7
+ try {
8
+ const content = fs.readFileSync(indexPath, "utf8");
9
+ return JSON.parse(content);
10
+ } catch {
11
+ return null;
12
+ }
13
+ }
14
+
15
+ function writeIndex(indexPath, data) {
16
+ fs.writeFileSync(indexPath, JSON.stringify(data, null, 2) + "\n", "utf8");
17
+ }
18
+
19
+ function hitEntry(options) {
20
+ const { entryId, targetPath = "." } = options;
21
+
22
+ const projectIndexPath = path.resolve(targetPath, "docs/playbook/INDEX.json");
23
+ const globalIndexPath = path.join(os.homedir(), ".playbook/repo/INDEX.json");
24
+
25
+ // Try project first
26
+ const projectIndex = readIndex(projectIndexPath);
27
+ if (projectIndex && projectIndex[entryId]) {
28
+ projectIndex[entryId].hits = (projectIndex[entryId].hits || 0) + 1;
29
+ writeIndex(projectIndexPath, projectIndex);
30
+ return { entryId, scope: "project", hits: projectIndex[entryId].hits };
31
+ }
32
+
33
+ // Fall back to global
34
+ const globalIndex = readIndex(globalIndexPath);
35
+ if (globalIndex && globalIndex[entryId]) {
36
+ globalIndex[entryId].hits = (globalIndex[entryId].hits || 0) + 1;
37
+ writeIndex(globalIndexPath, globalIndex);
38
+ return { entryId, scope: "global", hits: globalIndex[entryId].hits };
39
+ }
40
+
41
+ // Not found
42
+ if (!projectIndex && !globalIndex) {
43
+ throw new CliError("No INDEX.json found. Run 'playbook init' or 'playbook global init' first.");
44
+ }
45
+
46
+ throw new CliError(`Entry '${entryId}' not found in project or global INDEX.`);
47
+ }
48
+
49
+ module.exports = {
50
+ hitEntry,
51
+ };
@@ -0,0 +1,96 @@
1
+ const fs = require("fs");
2
+ const path = require("path");
3
+ const os = require("os");
4
+ const { CliError } = require("../errors");
5
+
6
+ function readIndex(indexPath) {
7
+ try {
8
+ const content = fs.readFileSync(indexPath, "utf8");
9
+ return JSON.parse(content);
10
+ } catch {
11
+ return null;
12
+ }
13
+ }
14
+
15
+ function writeIndex(indexPath, data) {
16
+ fs.writeFileSync(indexPath, JSON.stringify(data, null, 2) + "\n", "utf8");
17
+ }
18
+
19
+ function promoteEntry(entryId, projectIndex, globalIndex, projectBase, globalBase, force) {
20
+ const entry = projectIndex[entryId];
21
+ if (!entry) {
22
+ return { action: "error", entryId, reason: `Entry '${entryId}' not found in project INDEX` };
23
+ }
24
+
25
+ if (globalIndex[entryId] && !force) {
26
+ return {
27
+ action: "conflict",
28
+ entryId,
29
+ reason: `Entry '${entryId}' already exists in global INDEX (use --force to overwrite)`,
30
+ };
31
+ }
32
+
33
+ const srcFile = path.join(projectBase, entry.path);
34
+ const destFile = path.join(globalBase, entry.path);
35
+
36
+ // Write to global first (safety: write before delete)
37
+ try {
38
+ fs.mkdirSync(path.dirname(destFile), { recursive: true });
39
+ fs.copyFileSync(srcFile, destFile);
40
+ } catch (err) {
41
+ return { action: "error", entryId, reason: `Failed to copy file to global: ${err.message}` };
42
+ }
43
+
44
+ globalIndex[entryId] = entry;
45
+
46
+ // Now safe to remove from project
47
+ delete projectIndex[entryId];
48
+ try {
49
+ fs.unlinkSync(srcFile);
50
+ } catch {
51
+ // File may not exist, that's ok — INDEX entry already removed
52
+ }
53
+
54
+ return { action: "promoted", entryId, title: entry.title };
55
+ }
56
+
57
+ function promoteEntries(options) {
58
+ const { entryIds = [], all = false, force = false, targetPath = "." } = options;
59
+
60
+ const projectBase = path.resolve(targetPath, "docs/playbook");
61
+ const globalBase = path.join(os.homedir(), ".playbook/repo");
62
+
63
+ const projectIndexPath = path.join(projectBase, "INDEX.json");
64
+ const globalIndexPath = path.join(globalBase, "INDEX.json");
65
+
66
+ const projectIndex = readIndex(projectIndexPath);
67
+ if (!projectIndex) {
68
+ throw new CliError("Project INDEX.json not found. Run 'playbook init' first.");
69
+ }
70
+
71
+ const globalIndex = readIndex(globalIndexPath);
72
+ if (!globalIndex) {
73
+ throw new CliError("Global INDEX.json not found. Run 'playbook global init' first.");
74
+ }
75
+
76
+ const ids = all ? Object.keys(projectIndex) : entryIds;
77
+
78
+ if (all && ids.length === 0) {
79
+ return { results: [], message: "No entries in project INDEX to promote." };
80
+ }
81
+
82
+ const results = [];
83
+ for (const id of ids) {
84
+ results.push(promoteEntry(id, projectIndex, globalIndex, projectBase, globalBase, force));
85
+ }
86
+
87
+ // Write updated indices
88
+ writeIndex(projectIndexPath, projectIndex);
89
+ writeIndex(globalIndexPath, globalIndex);
90
+
91
+ return { results };
92
+ }
93
+
94
+ module.exports = {
95
+ promoteEntries,
96
+ };
@@ -70,7 +70,7 @@ function formatPlainText(result) {
70
70
  }
71
71
 
72
72
  const lines = result.entries.map(
73
- (e) => `[${e.scope}]\t${e.id}\t${e.title}\t${e.tags.join(",")}`
73
+ (e) => `[${e.scope}]\t${e.id}\t${e.title}\t${e.tags.join(",")}\thits:${e.hits || 0}`
74
74
  );
75
75
 
76
76
  const displayed = result.entries.length;
@@ -157,6 +157,7 @@ function formatJson(result) {
157
157
  title: e.title,
158
158
  tags: e.tags,
159
159
  path: e.path,
160
+ hits: e.hits || 0,
160
161
  }));
161
162
 
162
163
  return JSON.stringify(output, null, 2);
package/src/manifest.js CHANGED
@@ -57,6 +57,18 @@ const PROJECT_MANAGED_FILES = [
57
57
  tool: "claude",
58
58
  overwriteOnUpdate: true,
59
59
  },
60
+ {
61
+ relativePath: ".codex/skills/playbook-promote/SKILL.md",
62
+ templatePath: "project/skills/playbook-promote/SKILL.md",
63
+ tool: "codex",
64
+ overwriteOnUpdate: true,
65
+ },
66
+ {
67
+ relativePath: ".claude/skills/playbook-promote/SKILL.md",
68
+ templatePath: "project/skills/playbook-promote/SKILL.md",
69
+ tool: "claude",
70
+ overwriteOnUpdate: true,
71
+ },
60
72
  ];
61
73
 
62
74
  const GLOBAL_SKELETON_DIRS = ["cases", "patterns", "checklists"];
package/src/options.js CHANGED
@@ -181,9 +181,70 @@ function parseQueryArgs(args) {
181
181
  return { help: false, tags, limit, scope, json, tagsOnly };
182
182
  }
183
183
 
184
+ function parsePromoteArgs(args) {
185
+ let force = false;
186
+ let all = false;
187
+ const entryIds = [];
188
+
189
+ for (let i = 0; i < args.length; i += 1) {
190
+ const arg = args[i];
191
+
192
+ if (arg === "-h" || arg === "--help") {
193
+ return { help: true };
194
+ }
195
+
196
+ if (arg === "--force") {
197
+ force = true;
198
+ continue;
199
+ }
200
+
201
+ if (arg === "--all") {
202
+ all = true;
203
+ continue;
204
+ }
205
+
206
+ if (arg.startsWith("-")) {
207
+ throw new CliError(`Unknown option for playbook promote: ${arg}`);
208
+ }
209
+
210
+ entryIds.push(arg);
211
+ }
212
+
213
+ if (!all && entryIds.length === 0) {
214
+ throw new CliError("playbook promote requires entry IDs or --all");
215
+ }
216
+
217
+ if (all && entryIds.length > 0) {
218
+ throw new CliError("Cannot use --all with specific entry IDs");
219
+ }
220
+
221
+ return { help: false, force, all, entryIds };
222
+ }
223
+
224
+ function parseHitArgs(args) {
225
+ for (const arg of args) {
226
+ if (arg === "-h" || arg === "--help") {
227
+ return { help: true };
228
+ }
229
+ }
230
+
231
+ const entryId = args[0];
232
+ if (!entryId || entryId.startsWith("-")) {
233
+ throw new CliError("playbook hit requires an entry ID");
234
+ }
235
+
236
+ if (args.length > 1) {
237
+ throw new CliError("playbook hit accepts exactly one entry ID");
238
+ }
239
+
240
+ return { help: false, entryId };
241
+ }
242
+
184
243
  module.exports = {
185
244
  parseProjectCommandArgs,
186
245
  parseGlobalInitArgs,
187
246
  parseQueryArgs,
247
+ parsePromoteArgs,
248
+ parseHitArgs,
188
249
  parseToolsValue,
189
250
  };
@@ -49,6 +49,17 @@ For relevant entries only:
49
49
 
50
50
  Do NOT read all matched entries. Only read the ones that look relevant based on title.
51
51
 
52
+ When multiple entries have similar titles, prefer entries with higher `hits` count — they have been used more often and are likely more effective.
53
+
54
+ ### Step 3 — Record hit
55
+
56
+ After reading a case, run:
57
+ ```bash
58
+ playbook hit <entry-id>
59
+ ```
60
+
61
+ This records usage and helps prioritize effective cases in future queries.
62
+
52
63
  ## Guidelines
53
64
 
54
65
  - Keep queries focused — use specific tags, not broad searches
@@ -0,0 +1,32 @@
1
+ ---
2
+ name: playbook-promote
3
+ description: Guide when to promote project-level playbook entries to global knowledge base.
4
+ license: MIT
5
+ ---
6
+
7
+ Use this skill to suggest promoting valuable project experiences to the global knowledge base for cross-project reuse.
8
+
9
+ ## When to Suggest Promote
10
+
11
+ Suggest `playbook promote <entry-id>` to the user when:
12
+ - A project case was successfully used to resolve a problem and the lesson is not project-specific
13
+ - A pattern or checklist has proven useful and would benefit other projects
14
+ - The user explicitly asks about sharing knowledge across projects
15
+
16
+ ## When NOT to Suggest
17
+
18
+ Do not suggest promote when:
19
+ - The entry references project-specific configuration, paths, or dependencies
20
+ - The entry is still being refined or hasn't been validated
21
+ - The user is focused on other work (don't interrupt)
22
+
23
+ ## How to Promote
24
+
25
+ ```bash
26
+ playbook promote <entry-id> # promote a single entry
27
+ playbook promote <id1> <id2> # promote multiple entries
28
+ playbook promote --all # promote all project entries
29
+ playbook promote <entry-id> --force # overwrite if already exists in global
30
+ ```
31
+
32
+ The command moves entries from project to global: copies the file, updates both INDEX.json files, and removes the project copy.