@productbrain/cli 0.1.0-beta.1 → 0.1.0-beta.14

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.
Files changed (278) hide show
  1. package/README.md +167 -0
  2. package/dist/__tests__/audit.test.d.ts +2 -0
  3. package/dist/__tests__/audit.test.d.ts.map +1 -0
  4. package/dist/__tests__/audit.test.js +394 -0
  5. package/dist/__tests__/audit.test.js.map +1 -0
  6. package/dist/__tests__/capture.test.d.ts +2 -0
  7. package/dist/__tests__/capture.test.d.ts.map +1 -0
  8. package/dist/__tests__/capture.test.js +86 -0
  9. package/dist/__tests__/capture.test.js.map +1 -0
  10. package/dist/__tests__/constellation.test.d.ts +2 -0
  11. package/dist/__tests__/constellation.test.d.ts.map +1 -0
  12. package/dist/__tests__/constellation.test.js +260 -0
  13. package/dist/__tests__/constellation.test.js.map +1 -0
  14. package/dist/__tests__/context-strategy.test.d.ts +2 -0
  15. package/dist/__tests__/context-strategy.test.d.ts.map +1 -0
  16. package/dist/__tests__/context-strategy.test.js +79 -0
  17. package/dist/__tests__/context-strategy.test.js.map +1 -0
  18. package/dist/__tests__/fields.test.d.ts +2 -0
  19. package/dist/__tests__/fields.test.d.ts.map +1 -0
  20. package/dist/__tests__/fields.test.js +238 -0
  21. package/dist/__tests__/fields.test.js.map +1 -0
  22. package/dist/__tests__/handshake.test.d.ts +2 -0
  23. package/dist/__tests__/handshake.test.d.ts.map +1 -0
  24. package/dist/__tests__/handshake.test.js +187 -0
  25. package/dist/__tests__/handshake.test.js.map +1 -0
  26. package/dist/__tests__/ingest.test.d.ts +2 -0
  27. package/dist/__tests__/ingest.test.d.ts.map +1 -0
  28. package/dist/__tests__/ingest.test.js +185 -0
  29. package/dist/__tests__/ingest.test.js.map +1 -0
  30. package/dist/__tests__/promote.test.d.ts +2 -0
  31. package/dist/__tests__/promote.test.d.ts.map +1 -0
  32. package/dist/__tests__/promote.test.js +139 -0
  33. package/dist/__tests__/promote.test.js.map +1 -0
  34. package/dist/__tests__/proposals.test.d.ts +2 -0
  35. package/dist/__tests__/proposals.test.d.ts.map +1 -0
  36. package/dist/__tests__/proposals.test.js +190 -0
  37. package/dist/__tests__/proposals.test.js.map +1 -0
  38. package/dist/__tests__/relate.test.d.ts +2 -0
  39. package/dist/__tests__/relate.test.d.ts.map +1 -0
  40. package/dist/__tests__/relate.test.js +105 -0
  41. package/dist/__tests__/relate.test.js.map +1 -0
  42. package/dist/__tests__/repo-detect.test.d.ts +2 -0
  43. package/dist/__tests__/repo-detect.test.d.ts.map +1 -0
  44. package/dist/__tests__/repo-detect.test.js +119 -0
  45. package/dist/__tests__/repo-detect.test.js.map +1 -0
  46. package/dist/__tests__/runner.test.d.ts +2 -0
  47. package/dist/__tests__/runner.test.d.ts.map +1 -0
  48. package/dist/__tests__/runner.test.js +215 -0
  49. package/dist/__tests__/runner.test.js.map +1 -0
  50. package/dist/__tests__/session-touch.test.d.ts +2 -0
  51. package/dist/__tests__/session-touch.test.d.ts.map +1 -0
  52. package/dist/__tests__/session-touch.test.js +134 -0
  53. package/dist/__tests__/session-touch.test.js.map +1 -0
  54. package/dist/__tests__/session.test.d.ts +2 -0
  55. package/dist/__tests__/session.test.d.ts.map +1 -0
  56. package/dist/__tests__/session.test.js +52 -0
  57. package/dist/__tests__/session.test.js.map +1 -0
  58. package/dist/__tests__/strip.test.d.ts +2 -0
  59. package/dist/__tests__/strip.test.d.ts.map +1 -0
  60. package/dist/__tests__/strip.test.js +136 -0
  61. package/dist/__tests__/strip.test.js.map +1 -0
  62. package/dist/__tests__/update.test.d.ts +2 -0
  63. package/dist/__tests__/update.test.d.ts.map +1 -0
  64. package/dist/__tests__/update.test.js +237 -0
  65. package/dist/__tests__/update.test.js.map +1 -0
  66. package/dist/commands/accept.d.ts +18 -0
  67. package/dist/commands/accept.d.ts.map +1 -0
  68. package/dist/commands/accept.js +72 -0
  69. package/dist/commands/accept.js.map +1 -0
  70. package/dist/commands/audit.d.ts +25 -0
  71. package/dist/commands/audit.d.ts.map +1 -0
  72. package/dist/commands/audit.js +188 -0
  73. package/dist/commands/audit.js.map +1 -0
  74. package/dist/commands/brand-pack.d.ts +2 -0
  75. package/dist/commands/brand-pack.d.ts.map +1 -0
  76. package/dist/commands/brand-pack.js +25 -0
  77. package/dist/commands/brand-pack.js.map +1 -0
  78. package/dist/commands/brief.d.ts +28 -0
  79. package/dist/commands/brief.d.ts.map +1 -0
  80. package/dist/commands/brief.js +70 -0
  81. package/dist/commands/brief.js.map +1 -0
  82. package/dist/commands/capture.d.ts +21 -0
  83. package/dist/commands/capture.d.ts.map +1 -0
  84. package/dist/commands/capture.js +100 -0
  85. package/dist/commands/capture.js.map +1 -0
  86. package/dist/commands/chain-walk.d.ts +14 -0
  87. package/dist/commands/chain-walk.d.ts.map +1 -0
  88. package/dist/commands/chain-walk.js +33 -0
  89. package/dist/commands/chain-walk.js.map +1 -0
  90. package/dist/commands/changes.d.ts +11 -0
  91. package/dist/commands/changes.d.ts.map +1 -0
  92. package/dist/commands/changes.js +41 -0
  93. package/dist/commands/changes.js.map +1 -0
  94. package/dist/commands/constellation.d.ts +11 -0
  95. package/dist/commands/constellation.d.ts.map +1 -0
  96. package/dist/commands/constellation.js +28 -0
  97. package/dist/commands/constellation.js.map +1 -0
  98. package/dist/commands/context.d.ts +2 -1
  99. package/dist/commands/context.d.ts.map +1 -1
  100. package/dist/commands/context.js +19 -9
  101. package/dist/commands/context.js.map +1 -1
  102. package/dist/commands/cross-cut.d.ts +11 -0
  103. package/dist/commands/cross-cut.d.ts.map +1 -0
  104. package/dist/commands/cross-cut.js +23 -0
  105. package/dist/commands/cross-cut.js.map +1 -0
  106. package/dist/commands/fields.d.ts +9 -0
  107. package/dist/commands/fields.d.ts.map +1 -0
  108. package/dist/commands/fields.js +26 -0
  109. package/dist/commands/fields.js.map +1 -0
  110. package/dist/commands/get.d.ts +8 -1
  111. package/dist/commands/get.d.ts.map +1 -1
  112. package/dist/commands/get.js +55 -6
  113. package/dist/commands/get.js.map +1 -1
  114. package/dist/commands/handshake.d.ts +18 -0
  115. package/dist/commands/handshake.d.ts.map +1 -0
  116. package/dist/commands/handshake.js +378 -0
  117. package/dist/commands/handshake.js.map +1 -0
  118. package/dist/commands/ingest.d.ts +14 -0
  119. package/dist/commands/ingest.d.ts.map +1 -0
  120. package/dist/commands/ingest.js +181 -0
  121. package/dist/commands/ingest.js.map +1 -0
  122. package/dist/commands/login.d.ts +5 -0
  123. package/dist/commands/login.d.ts.map +1 -0
  124. package/dist/commands/login.js +53 -0
  125. package/dist/commands/login.js.map +1 -0
  126. package/dist/commands/orient.d.ts +2 -0
  127. package/dist/commands/orient.d.ts.map +1 -1
  128. package/dist/commands/orient.js +17 -8
  129. package/dist/commands/orient.js.map +1 -1
  130. package/dist/commands/promote.d.ts +12 -0
  131. package/dist/commands/promote.d.ts.map +1 -0
  132. package/dist/commands/promote.js +48 -0
  133. package/dist/commands/promote.js.map +1 -0
  134. package/dist/commands/proposals.d.ts +9 -0
  135. package/dist/commands/proposals.d.ts.map +1 -0
  136. package/dist/commands/proposals.js +24 -0
  137. package/dist/commands/proposals.js.map +1 -0
  138. package/dist/commands/reject.d.ts +14 -0
  139. package/dist/commands/reject.d.ts.map +1 -0
  140. package/dist/commands/reject.js +37 -0
  141. package/dist/commands/reject.js.map +1 -0
  142. package/dist/commands/relate.d.ts +16 -0
  143. package/dist/commands/relate.d.ts.map +1 -0
  144. package/dist/commands/relate.js +80 -0
  145. package/dist/commands/relate.js.map +1 -0
  146. package/dist/commands/search.d.ts +1 -0
  147. package/dist/commands/search.d.ts.map +1 -1
  148. package/dist/commands/search.js +9 -3
  149. package/dist/commands/search.js.map +1 -1
  150. package/dist/commands/session.d.ts +20 -0
  151. package/dist/commands/session.d.ts.map +1 -0
  152. package/dist/commands/session.js +134 -0
  153. package/dist/commands/session.js.map +1 -0
  154. package/dist/commands/update.d.ts +16 -0
  155. package/dist/commands/update.d.ts.map +1 -0
  156. package/dist/commands/update.js +139 -0
  157. package/dist/commands/update.js.map +1 -0
  158. package/dist/commands/verify.d.ts +13 -0
  159. package/dist/commands/verify.d.ts.map +1 -0
  160. package/dist/commands/verify.js +43 -0
  161. package/dist/commands/verify.js.map +1 -0
  162. package/dist/formatters/audit.d.ts +46 -0
  163. package/dist/formatters/audit.d.ts.map +1 -0
  164. package/dist/formatters/audit.js +81 -0
  165. package/dist/formatters/audit.js.map +1 -0
  166. package/dist/formatters/brief.d.ts +112 -0
  167. package/dist/formatters/brief.d.ts.map +1 -0
  168. package/dist/formatters/brief.js +179 -0
  169. package/dist/formatters/brief.js.map +1 -0
  170. package/dist/formatters/capture.d.ts +30 -0
  171. package/dist/formatters/capture.d.ts.map +1 -0
  172. package/dist/formatters/capture.js +58 -0
  173. package/dist/formatters/capture.js.map +1 -0
  174. package/dist/formatters/chain-walk.d.ts +33 -0
  175. package/dist/formatters/chain-walk.d.ts.map +1 -0
  176. package/dist/formatters/chain-walk.js +54 -0
  177. package/dist/formatters/chain-walk.js.map +1 -0
  178. package/dist/formatters/changes.d.ts +25 -0
  179. package/dist/formatters/changes.d.ts.map +1 -0
  180. package/dist/formatters/changes.js +60 -0
  181. package/dist/formatters/changes.js.map +1 -0
  182. package/dist/formatters/constellation.d.ts +34 -0
  183. package/dist/formatters/constellation.d.ts.map +1 -0
  184. package/dist/formatters/constellation.js +38 -0
  185. package/dist/formatters/constellation.js.map +1 -0
  186. package/dist/formatters/cross-cut.d.ts +21 -0
  187. package/dist/formatters/cross-cut.d.ts.map +1 -0
  188. package/dist/formatters/cross-cut.js +32 -0
  189. package/dist/formatters/cross-cut.js.map +1 -0
  190. package/dist/formatters/entry.d.ts +5 -0
  191. package/dist/formatters/entry.d.ts.map +1 -1
  192. package/dist/formatters/entry.js +5 -1
  193. package/dist/formatters/entry.js.map +1 -1
  194. package/dist/formatters/fields.d.ts +32 -0
  195. package/dist/formatters/fields.d.ts.map +1 -0
  196. package/dist/formatters/fields.js +49 -0
  197. package/dist/formatters/fields.js.map +1 -0
  198. package/dist/formatters/handshake.d.ts +17 -0
  199. package/dist/formatters/handshake.d.ts.map +1 -0
  200. package/dist/formatters/handshake.js +51 -0
  201. package/dist/formatters/handshake.js.map +1 -0
  202. package/dist/formatters/orient.d.ts +1 -0
  203. package/dist/formatters/orient.d.ts.map +1 -1
  204. package/dist/formatters/orient.js +4 -2
  205. package/dist/formatters/orient.js.map +1 -1
  206. package/dist/formatters/promote.d.ts +29 -0
  207. package/dist/formatters/promote.d.ts.map +1 -0
  208. package/dist/formatters/promote.js +38 -0
  209. package/dist/formatters/promote.js.map +1 -0
  210. package/dist/formatters/proposals.d.ts +45 -0
  211. package/dist/formatters/proposals.d.ts.map +1 -0
  212. package/dist/formatters/proposals.js +62 -0
  213. package/dist/formatters/proposals.js.map +1 -0
  214. package/dist/formatters/relate.d.ts +12 -0
  215. package/dist/formatters/relate.d.ts.map +1 -0
  216. package/dist/formatters/relate.js +13 -0
  217. package/dist/formatters/relate.js.map +1 -0
  218. package/dist/formatters/session.d.ts +11 -0
  219. package/dist/formatters/session.d.ts.map +1 -0
  220. package/dist/formatters/session.js +51 -0
  221. package/dist/formatters/session.js.map +1 -0
  222. package/dist/formatters/update.d.ts +17 -0
  223. package/dist/formatters/update.d.ts.map +1 -0
  224. package/dist/formatters/update.js +43 -0
  225. package/dist/formatters/update.js.map +1 -0
  226. package/dist/formatters/verify.d.ts +11 -0
  227. package/dist/formatters/verify.d.ts.map +1 -0
  228. package/dist/formatters/verify.js +11 -0
  229. package/dist/formatters/verify.js.map +1 -0
  230. package/dist/generators/adapters.d.ts +10 -0
  231. package/dist/generators/adapters.d.ts.map +1 -0
  232. package/dist/generators/adapters.js +102 -0
  233. package/dist/generators/adapters.js.map +1 -0
  234. package/dist/generators/briefing-md.d.ts +8 -0
  235. package/dist/generators/briefing-md.d.ts.map +1 -0
  236. package/dist/generators/briefing-md.js +51 -0
  237. package/dist/generators/briefing-md.js.map +1 -0
  238. package/dist/generators/context-md.d.ts +8 -0
  239. package/dist/generators/context-md.d.ts.map +1 -0
  240. package/dist/generators/context-md.js +123 -0
  241. package/dist/generators/context-md.js.map +1 -0
  242. package/dist/generators/portable-knowledge.d.ts +72 -0
  243. package/dist/generators/portable-knowledge.d.ts.map +1 -0
  244. package/dist/generators/portable-knowledge.js +246 -0
  245. package/dist/generators/portable-knowledge.js.map +1 -0
  246. package/dist/generators/portable-knowledge.test.d.ts +2 -0
  247. package/dist/generators/portable-knowledge.test.d.ts.map +1 -0
  248. package/dist/generators/portable-knowledge.test.js +399 -0
  249. package/dist/generators/portable-knowledge.test.js.map +1 -0
  250. package/dist/index.d.ts +3 -2
  251. package/dist/index.d.ts.map +1 -1
  252. package/dist/index.js +462 -6
  253. package/dist/index.js.map +1 -1
  254. package/dist/lib/client.d.ts +34 -0
  255. package/dist/lib/client.d.ts.map +1 -1
  256. package/dist/lib/client.js +114 -9
  257. package/dist/lib/client.js.map +1 -1
  258. package/dist/lib/config.d.ts +19 -2
  259. package/dist/lib/config.d.ts.map +1 -1
  260. package/dist/lib/config.js +95 -14
  261. package/dist/lib/config.js.map +1 -1
  262. package/dist/lib/repo-detect.d.ts +14 -0
  263. package/dist/lib/repo-detect.d.ts.map +1 -0
  264. package/dist/lib/repo-detect.js +58 -0
  265. package/dist/lib/repo-detect.js.map +1 -0
  266. package/dist/lib/runner.d.ts +31 -0
  267. package/dist/lib/runner.d.ts.map +1 -0
  268. package/dist/lib/runner.js +65 -0
  269. package/dist/lib/runner.js.map +1 -0
  270. package/dist/lib/session.d.ts +17 -0
  271. package/dist/lib/session.d.ts.map +1 -0
  272. package/dist/lib/session.js +43 -0
  273. package/dist/lib/session.js.map +1 -0
  274. package/dist/lib/strip.d.ts +11 -0
  275. package/dist/lib/strip.d.ts.map +1 -0
  276. package/dist/lib/strip.js +26 -0
  277. package/dist/lib/strip.js.map +1 -0
  278. package/package.json +8 -4
@@ -0,0 +1,246 @@
1
+ /**
2
+ * Portable knowledge — reads canonical skills/rules from .productbrain/
3
+ * and generates tool-specific copies for Cursor, Claude Code, etc.
4
+ */
5
+ import { readdirSync, readFileSync, existsSync } from 'fs';
6
+ import { join, basename } from 'path';
7
+ import { MARKER } from './adapters.js';
8
+ /** Returns true if the entry should be emitted to the given target. */
9
+ export function shouldEmitToTarget(entry, target) {
10
+ if (!entry.targets)
11
+ return true;
12
+ return entry.targets.includes(target);
13
+ }
14
+ const LEVEL_HIERARCHY = {
15
+ beginner: ['core'],
16
+ intermediate: ['core', 'intermediate'],
17
+ expert: ['core', 'intermediate', 'expert'],
18
+ };
19
+ const VALID_LEVELS = new Set(Object.keys(LEVEL_HIERARCHY));
20
+ /**
21
+ * Filter items by graduated level.
22
+ * - If requestedLevel is undefined/null: return ALL items (backward compat).
23
+ * - Items with no `level` field: always included (backward compat).
24
+ * - Otherwise: include items whose level is within the hierarchy for requestedLevel.
25
+ */
26
+ export function filterByLevel(items, requestedLevel) {
27
+ if (!requestedLevel)
28
+ return items;
29
+ if (!VALID_LEVELS.has(requestedLevel)) {
30
+ throw new Error(`Unknown level "${requestedLevel}". Valid levels: ${[...VALID_LEVELS].join(', ')}`);
31
+ }
32
+ const allowedLevels = LEVEL_HIERARCHY[requestedLevel];
33
+ return items.filter((item) => !item.level || allowedLevels.includes(item.level));
34
+ }
35
+ // ── Frontmatter parser ─────────────────────────────────────────────────
36
+ function parseFrontmatter(raw) {
37
+ const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
38
+ if (!match)
39
+ return { fields: new Map(), arrayFields: new Map(), body: raw };
40
+ const yaml = match[1];
41
+ const body = match[2];
42
+ const fields = new Map();
43
+ const arrayFields = new Map();
44
+ const lines = yaml.split('\n');
45
+ let currentKey = null;
46
+ let collectingMultiline = false;
47
+ let collectingArray = false;
48
+ for (const line of lines) {
49
+ // Array item: " - value"
50
+ if (collectingArray && currentKey && /^\s+-\s/.test(line)) {
51
+ const items = arrayFields.get(currentKey) ?? [];
52
+ items.push(line.replace(/^\s+-\s+/, '').trim().replace(/^["']|["']$/g, ''));
53
+ arrayFields.set(currentKey, items);
54
+ continue;
55
+ }
56
+ // Multiline continuation: indented text after >- or >
57
+ if (collectingMultiline && currentKey && /^\s+\S/.test(line)) {
58
+ const existing = fields.get(currentKey) ?? '';
59
+ fields.set(currentKey, (existing ? existing + ' ' : '') + line.trim());
60
+ continue;
61
+ }
62
+ // New key — reset collection state
63
+ collectingMultiline = false;
64
+ collectingArray = false;
65
+ const kv = line.match(/^([\w-]+):\s*(.*)/);
66
+ if (!kv)
67
+ continue;
68
+ currentKey = kv[1];
69
+ const val = kv[2].trim();
70
+ if (val === '' || val === '>-' || val === '>') {
71
+ // Could be array or multiline — peek ahead via next iterations
72
+ // If next line starts with " -", it's an array; if indented text, it's multiline
73
+ collectingMultiline = val === '>-' || val === '>';
74
+ collectingArray = val === '';
75
+ continue;
76
+ }
77
+ fields.set(currentKey, val.replace(/^["']|["']$/g, ''));
78
+ }
79
+ return { fields, arrayFields, body };
80
+ }
81
+ // ── Readers ────────────────────────────────────────────────────────────
82
+ export function readCanonicalSkills(productbrainDir) {
83
+ const skillsDir = join(productbrainDir, 'skills');
84
+ if (!existsSync(skillsDir))
85
+ return [];
86
+ const files = readdirSync(skillsDir).filter((f) => f.endsWith('.md'));
87
+ const skills = [];
88
+ for (const file of files) {
89
+ const filePath = join(skillsDir, file);
90
+ const raw = readFileSync(filePath, 'utf8');
91
+ const { fields, arrayFields, body } = parseFrontmatter(raw);
92
+ const name = fields.get('name') ?? basename(file, '.md');
93
+ const description = fields.get('description') ?? '';
94
+ const triggers = arrayFields.get('triggers') ?? [];
95
+ const targets = arrayFields.get('targets');
96
+ const level = fields.get('level');
97
+ skills.push({ name, description, triggers, body, sourcePath: filePath, targets: targets?.length ? targets : undefined, level: level || undefined });
98
+ }
99
+ return skills.sort((a, b) => a.name.localeCompare(b.name));
100
+ }
101
+ export function readCanonicalRules(productbrainDir) {
102
+ const rulesDir = join(productbrainDir, 'rules');
103
+ if (!existsSync(rulesDir))
104
+ return [];
105
+ const files = readdirSync(rulesDir).filter((f) => f.endsWith('.md'));
106
+ const rules = [];
107
+ for (const file of files) {
108
+ const filePath = join(rulesDir, file);
109
+ const raw = readFileSync(filePath, 'utf8');
110
+ const { fields, arrayFields, body } = parseFrontmatter(raw);
111
+ const name = fields.get('name') ?? basename(file, '.md');
112
+ const description = fields.get('description') ?? '';
113
+ const scope = fields.get('scope');
114
+ const autoApply = fields.get('autoApply') === 'true';
115
+ const targets = arrayFields.get('targets');
116
+ const level = fields.get('level');
117
+ rules.push({ name, description, scope, autoApply, body, sourcePath: filePath, targets: targets?.length ? targets : undefined, level: level || undefined });
118
+ }
119
+ return rules.sort((a, b) => a.name.localeCompare(b.name));
120
+ }
121
+ // ── Transport section stripping ───────────────────────────────────────
122
+ /**
123
+ * Strip transport-conditional sections from a skill body.
124
+ *
125
+ * Sections are delimited by HTML comments:
126
+ * <!-- transport:TARGET_NAME -->
127
+ * ...content...
128
+ * <!-- /transport -->
129
+ *
130
+ * - Blocks matching the current target: keep the content, remove the delimiter comments.
131
+ * - Blocks for OTHER targets: remove entirely (delimiters + content).
132
+ * - Content outside any transport block: keep as-is (universal content).
133
+ */
134
+ export function stripTransportSections(body, target) {
135
+ // Match transport blocks: <!-- transport:NAME -->...<!-- /transport -->
136
+ // Using a non-greedy match between the opening and the NEXT closing tag.
137
+ const transportBlockRe = /<!-- transport:(\w+) -->\n?([\s\S]*?)<!-- \/transport -->\n?/g;
138
+ return body.replace(transportBlockRe, (_match, blockTarget, content) => {
139
+ if (blockTarget === target) {
140
+ // Keep the content, remove the delimiter comments
141
+ return content;
142
+ }
143
+ // Remove the entire block for other targets
144
+ return '';
145
+ });
146
+ }
147
+ // ── Cursor generators ──────────────────────────────────────────────────
148
+ /**
149
+ * Generate a Cursor SKILL.md from a canonical skill.
150
+ * Cursor expects: name, description (with triggers embedded).
151
+ */
152
+ export function generateCursorSkill(skill) {
153
+ // Cursor puts trigger phrases in the description field
154
+ const triggerLine = skill.triggers.length > 0
155
+ ? ` Use when the user says ${skill.triggers.map((t) => `"${t}"`).join(', ')}.`
156
+ : '';
157
+ const description = skill.description + triggerLine;
158
+ // Strip transport sections BEFORE path replacement
159
+ const stripped = stripTransportSections(skill.body, 'cursor');
160
+ // Replace canonical paths with Cursor paths in body
161
+ const body = stripped
162
+ .replace(/\.productbrain\/rules\/(\S+)\.md/g, '.cursor/rules/$1.mdc')
163
+ .replace(/\.productbrain\/skills\/(\S+)\.md/g, '.cursor/skills/$1/SKILL.md');
164
+ return `---
165
+ name: ${skill.name}
166
+ description: >-
167
+ ${description.replace(/\n/g, '\n ')}
168
+ ---
169
+ <!-- ${MARKER} — source: .productbrain/skills/${skill.name}.md -->
170
+
171
+ ${body}`;
172
+ }
173
+ /**
174
+ * Generate a Cursor .mdc rule from a canonical rule.
175
+ * Cursor expects: description, globs, alwaysApply.
176
+ */
177
+ export function generateCursorRule(rule) {
178
+ // Replace canonical paths with Cursor paths in body
179
+ const body = rule.body
180
+ .replace(/\.productbrain\/rules\/(\S+)\.md/g, '.cursor/rules/$1.mdc')
181
+ .replace(/\.productbrain\/skills\/(\S+)\.md/g, '.cursor/skills/$1/SKILL.md');
182
+ return `---
183
+ description: ${rule.description}
184
+ globs: ${rule.scope ?? ''}
185
+ alwaysApply: ${rule.autoApply}
186
+ ---
187
+ <!-- ${MARKER} — source: .productbrain/rules/${rule.name}.md -->
188
+
189
+ ${body}`;
190
+ }
191
+ // ── Claude Code generators ────────────────────────────────────────────
192
+ /**
193
+ * Generate a Claude Code .md rule from a canonical rule.
194
+ * Claude Code expects: description, optional paths (array).
195
+ */
196
+ export function generateClaudeRule(rule) {
197
+ // Replace canonical paths with Claude Code paths in body
198
+ const body = rule.body
199
+ .replace(/\.productbrain\/rules\/(\S+)\.md/g, '.claude/rules/$1.md')
200
+ .replace(/\.productbrain\/skills\/(\S+)\.md/g, '.productbrain/skills/$1.md');
201
+ // Build frontmatter
202
+ const fmLines = ['---'];
203
+ fmLines.push(`description: "${rule.description.replace(/"/g, '\\"')}"`);
204
+ if (rule.scope) {
205
+ fmLines.push('paths:');
206
+ fmLines.push(` - "${rule.scope}"`);
207
+ }
208
+ fmLines.push('---');
209
+ return `${fmLines.join('\n')}
210
+ <!-- ${MARKER} — source: .productbrain/rules/${rule.name}.md -->
211
+
212
+ ${body}`;
213
+ }
214
+ /**
215
+ * Generate a Claude Code skill-router.md — imperative skill activation rule.
216
+ * Loaded automatically by Claude Code via .claude/rules/ with no paths (always active).
217
+ */
218
+ export function generateClaudeSkillRouter(skills) {
219
+ if (skills.length === 0)
220
+ return '';
221
+ const triggerBlocks = skills.map((skill) => {
222
+ const triggerList = skill.triggers.map((t) => `"${t}"`).join(' / ');
223
+ return `- User says ${triggerList}
224
+ → READ \`.productbrain/skills/${skill.name}.md\` — follow its full protocol`;
225
+ });
226
+ return `---
227
+ description: "Skill activation router — MUST read skill file before responding when triggers match"
228
+ ---
229
+ <!-- ${MARKER} — source: generated from .productbrain/skills/*.md -->
230
+
231
+ # Skill Router — Mandatory Activation
232
+
233
+ BEFORE responding to ANY user message, scan for these triggers.
234
+ If ANY trigger matches: READ the skill file FIRST, then follow its full protocol.
235
+ Do NOT respond with your own approach. The skill IS the approach.
236
+
237
+ ## Triggers
238
+
239
+ ${triggerBlocks.join('\n\n')}
240
+
241
+ ## Why This Matters
242
+
243
+ Skipping the skill file produces a shallow response that misses governance, blast radius, and Chain context. This is the #1 quality failure in this workspace. The skill files encode the full protocol — including Chain checks, domain identification, and review gates — that a direct response will miss.
244
+ `;
245
+ }
246
+ //# sourceMappingURL=portable-knowledge.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"portable-knowledge.js","sourceRoot":"","sources":["../../src/generators/portable-knowledge.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=portable-knowledge.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"portable-knowledge.test.d.ts","sourceRoot":"","sources":["../../src/generators/portable-knowledge.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,399 @@
1
+ /**
2
+ * portable-knowledge — unit tests.
3
+ * BET-169: transport-aware skill dispatch, target filtering, and transport section stripping.
4
+ */
5
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
6
+ import { join } from 'path';
7
+ // vi.mock calls are hoisted — use vi.hoisted() for constants referenced inside factories.
8
+ const { vfs } = vi.hoisted(() => ({
9
+ vfs: {},
10
+ }));
11
+ vi.mock('fs', () => ({
12
+ mkdirSync: vi.fn(),
13
+ writeFileSync: vi.fn((path, content) => {
14
+ vfs[path] = content;
15
+ }),
16
+ existsSync: vi.fn((path) => {
17
+ // Check if the path itself or any child key exists (for directory checks)
18
+ if (path in vfs)
19
+ return true;
20
+ // For directory checks: return true if any key starts with path + '/'
21
+ return Object.keys(vfs).some((k) => k.startsWith(path + '/'));
22
+ }),
23
+ readFileSync: vi.fn((path, _enc) => {
24
+ if (path in vfs)
25
+ return vfs[path];
26
+ throw Object.assign(new Error(`ENOENT: no such file '${path}'`), { code: 'ENOENT' });
27
+ }),
28
+ readdirSync: vi.fn((dir) => {
29
+ const prefix = dir.endsWith('/') ? dir : dir + '/';
30
+ const files = new Set();
31
+ for (const key of Object.keys(vfs)) {
32
+ if (key.startsWith(prefix)) {
33
+ const rest = key.slice(prefix.length);
34
+ const parts = rest.split('/');
35
+ if (parts.length === 1)
36
+ files.add(parts[0]);
37
+ }
38
+ }
39
+ return [...files];
40
+ }),
41
+ }));
42
+ import { readCanonicalSkills, shouldEmitToTarget, stripTransportSections, filterByLevel, generateCursorSkill, generateClaudeSkillRouter, } from './portable-knowledge.js';
43
+ const PB_DIR = '/tmp/pb-test/.productbrain';
44
+ describe('readCanonicalSkills', () => {
45
+ beforeEach(() => {
46
+ Object.keys(vfs).forEach((k) => delete vfs[k]);
47
+ });
48
+ it('parses targets from frontmatter', () => {
49
+ vfs[join(PB_DIR, 'skills', 'test-skill.md')] = `---
50
+ name: test-skill
51
+ description: A test skill
52
+ triggers:
53
+ - test
54
+ targets:
55
+ - claude
56
+ ---
57
+
58
+ # Test Skill Body
59
+ `;
60
+ const skills = readCanonicalSkills(PB_DIR);
61
+ expect(skills).toHaveLength(1);
62
+ expect(skills[0].targets).toEqual(['claude']);
63
+ });
64
+ it('returns undefined targets when not specified in frontmatter', () => {
65
+ vfs[join(PB_DIR, 'skills', 'universal-skill.md')] = `---
66
+ name: universal-skill
67
+ description: A universal skill
68
+ triggers:
69
+ - universal
70
+ ---
71
+
72
+ # Universal Skill Body
73
+ `;
74
+ const skills = readCanonicalSkills(PB_DIR);
75
+ expect(skills).toHaveLength(1);
76
+ expect(skills[0].targets).toBeUndefined();
77
+ });
78
+ it('parses multiple targets', () => {
79
+ vfs[join(PB_DIR, 'skills', 'multi-target.md')] = `---
80
+ name: multi-target
81
+ description: Multi-target skill
82
+ triggers:
83
+ - multi
84
+ targets:
85
+ - claude
86
+ - cursor
87
+ ---
88
+
89
+ # Multi-Target Body
90
+ `;
91
+ const skills = readCanonicalSkills(PB_DIR);
92
+ expect(skills).toHaveLength(1);
93
+ expect(skills[0].targets).toEqual(['claude', 'cursor']);
94
+ });
95
+ it('parses level from frontmatter', () => {
96
+ vfs[join(PB_DIR, 'skills', 'leveled-skill.md')] = `---
97
+ name: leveled-skill
98
+ description: A leveled skill
99
+ level: core
100
+ triggers:
101
+ - leveled
102
+ ---
103
+
104
+ # Leveled Skill Body
105
+ `;
106
+ const skills = readCanonicalSkills(PB_DIR);
107
+ expect(skills).toHaveLength(1);
108
+ expect(skills[0].level).toBe('core');
109
+ });
110
+ it('returns undefined level when not specified in frontmatter', () => {
111
+ vfs[join(PB_DIR, 'skills', 'no-level-skill.md')] = `---
112
+ name: no-level-skill
113
+ description: A skill without level
114
+ triggers:
115
+ - nolevel
116
+ ---
117
+
118
+ # No Level Skill Body
119
+ `;
120
+ const skills = readCanonicalSkills(PB_DIR);
121
+ expect(skills).toHaveLength(1);
122
+ expect(skills[0].level).toBeUndefined();
123
+ });
124
+ });
125
+ describe('shouldEmitToTarget', () => {
126
+ it('returns true when targets is undefined (emit to all)', () => {
127
+ const skill = {
128
+ name: 'test',
129
+ description: '',
130
+ triggers: [],
131
+ body: '',
132
+ sourcePath: '',
133
+ };
134
+ expect(shouldEmitToTarget(skill, 'claude')).toBe(true);
135
+ expect(shouldEmitToTarget(skill, 'cursor')).toBe(true);
136
+ expect(shouldEmitToTarget(skill, 'copilot')).toBe(true);
137
+ });
138
+ it('returns true when target is in the list', () => {
139
+ const skill = {
140
+ name: 'test',
141
+ description: '',
142
+ triggers: [],
143
+ body: '',
144
+ sourcePath: '',
145
+ targets: ['claude'],
146
+ };
147
+ expect(shouldEmitToTarget(skill, 'claude')).toBe(true);
148
+ });
149
+ it('returns false when target is not in the list', () => {
150
+ const skill = {
151
+ name: 'test',
152
+ description: '',
153
+ triggers: [],
154
+ body: '',
155
+ sourcePath: '',
156
+ targets: ['claude'],
157
+ };
158
+ expect(shouldEmitToTarget(skill, 'cursor')).toBe(false);
159
+ expect(shouldEmitToTarget(skill, 'copilot')).toBe(false);
160
+ });
161
+ it('works for CanonicalRule as well', () => {
162
+ const rule = {
163
+ name: 'test-rule',
164
+ description: '',
165
+ autoApply: true,
166
+ body: '',
167
+ sourcePath: '',
168
+ targets: ['cursor'],
169
+ };
170
+ expect(shouldEmitToTarget(rule, 'cursor')).toBe(true);
171
+ expect(shouldEmitToTarget(rule, 'claude')).toBe(false);
172
+ });
173
+ });
174
+ describe('stripTransportSections', () => {
175
+ it('keeps content for the matching target and removes delimiters', () => {
176
+ const body = `Universal content above.
177
+
178
+ <!-- transport:claude -->
179
+ Claude-specific instructions here.
180
+ <!-- /transport -->
181
+
182
+ Universal content below.`;
183
+ const result = stripTransportSections(body, 'claude');
184
+ expect(result).toContain('Claude-specific instructions here.');
185
+ expect(result).toContain('Universal content above.');
186
+ expect(result).toContain('Universal content below.');
187
+ expect(result).not.toContain('<!-- transport:claude -->');
188
+ expect(result).not.toContain('<!-- /transport -->');
189
+ });
190
+ it('removes blocks for other targets entirely', () => {
191
+ const body = `Universal content.
192
+
193
+ <!-- transport:cursor -->
194
+ Cursor-only instructions.
195
+ <!-- /transport -->
196
+
197
+ More universal.`;
198
+ const result = stripTransportSections(body, 'claude');
199
+ expect(result).toContain('Universal content.');
200
+ expect(result).toContain('More universal.');
201
+ expect(result).not.toContain('Cursor-only instructions.');
202
+ expect(result).not.toContain('<!-- transport:cursor -->');
203
+ });
204
+ it('handles multiple transport blocks for different targets', () => {
205
+ const body = `# Heading
206
+
207
+ <!-- transport:claude -->
208
+ Claude dispatch: use Agent tool.
209
+ <!-- /transport -->
210
+
211
+ <!-- transport:cursor -->
212
+ Cursor dispatch: use fresh conversation.
213
+ <!-- /transport -->
214
+
215
+ ## Footer`;
216
+ const claudeResult = stripTransportSections(body, 'claude');
217
+ expect(claudeResult).toContain('Claude dispatch: use Agent tool.');
218
+ expect(claudeResult).not.toContain('Cursor dispatch: use fresh conversation.');
219
+ expect(claudeResult).toContain('# Heading');
220
+ expect(claudeResult).toContain('## Footer');
221
+ const cursorResult = stripTransportSections(body, 'cursor');
222
+ expect(cursorResult).toContain('Cursor dispatch: use fresh conversation.');
223
+ expect(cursorResult).not.toContain('Claude dispatch: use Agent tool.');
224
+ expect(cursorResult).toContain('# Heading');
225
+ expect(cursorResult).toContain('## Footer');
226
+ });
227
+ it('keeps universal content untouched when no transport blocks exist', () => {
228
+ const body = `# Just a normal skill
229
+
230
+ No transport sections here.
231
+
232
+ ## Section 2
233
+
234
+ More content.`;
235
+ const result = stripTransportSections(body, 'claude');
236
+ expect(result).toBe(body);
237
+ });
238
+ it('handles markdown and code blocks inside transport sections', () => {
239
+ const body = `Universal.
240
+
241
+ <!-- transport:claude -->
242
+ **Bold text** and \`inline code\`.
243
+
244
+ \`\`\`bash
245
+ git diff main..HEAD
246
+ \`\`\`
247
+
248
+ - List item 1
249
+ - List item 2
250
+ <!-- /transport -->
251
+
252
+ End.`;
253
+ const result = stripTransportSections(body, 'claude');
254
+ expect(result).toContain('**Bold text** and `inline code`.');
255
+ expect(result).toContain('git diff main..HEAD');
256
+ expect(result).toContain('- List item 1');
257
+ expect(result).toContain('End.');
258
+ });
259
+ it('handles transport sections with no trailing newline after closing tag', () => {
260
+ const body = `Before.
261
+ <!-- transport:claude -->
262
+ Content.
263
+ <!-- /transport -->After.`;
264
+ const result = stripTransportSections(body, 'claude');
265
+ expect(result).toContain('Content.');
266
+ expect(result).toContain('After.');
267
+ });
268
+ });
269
+ describe('generateCursorSkill (transport stripping)', () => {
270
+ it('strips non-cursor transport sections from generated output', () => {
271
+ const skill = {
272
+ name: 'test-skill',
273
+ description: 'Test skill',
274
+ triggers: ['test'],
275
+ body: `# Heading
276
+
277
+ <!-- transport:claude -->
278
+ Claude-only content.
279
+ <!-- /transport -->
280
+
281
+ <!-- transport:cursor -->
282
+ Cursor-only content.
283
+ <!-- /transport -->
284
+
285
+ Universal content.`,
286
+ sourcePath: '/tmp/.productbrain/skills/test-skill.md',
287
+ };
288
+ const output = generateCursorSkill(skill);
289
+ expect(output).toContain('Cursor-only content.');
290
+ expect(output).not.toContain('Claude-only content.');
291
+ expect(output).toContain('Universal content.');
292
+ expect(output).not.toContain('<!-- transport:');
293
+ });
294
+ });
295
+ describe('generateClaudeSkillRouter (target filtering)', () => {
296
+ it('includes only skills with matching or no targets', () => {
297
+ const claudeOnly = {
298
+ name: 'claude-skill',
299
+ description: 'Claude only',
300
+ triggers: ['test-claude'],
301
+ body: '# Claude',
302
+ sourcePath: '/tmp/skills/claude-skill.md',
303
+ targets: ['claude'],
304
+ };
305
+ const cursorOnly = {
306
+ name: 'cursor-skill',
307
+ description: 'Cursor only',
308
+ triggers: ['test-cursor'],
309
+ body: '# Cursor',
310
+ sourcePath: '/tmp/skills/cursor-skill.md',
311
+ targets: ['cursor'],
312
+ };
313
+ const universal = {
314
+ name: 'universal-skill',
315
+ description: 'Universal',
316
+ triggers: ['test-universal'],
317
+ body: '# Universal',
318
+ sourcePath: '/tmp/skills/universal-skill.md',
319
+ };
320
+ // Filter skills for Claude (as handshake.ts would do)
321
+ const claudeSkills = [claudeOnly, cursorOnly, universal].filter((s) => shouldEmitToTarget(s, 'claude'));
322
+ const router = generateClaudeSkillRouter(claudeSkills);
323
+ expect(router).toContain('claude-skill');
324
+ expect(router).toContain('universal-skill');
325
+ expect(router).not.toContain('cursor-skill');
326
+ });
327
+ it('skill with targets: [claude] does not appear in Cursor output', () => {
328
+ const claudeOnly = {
329
+ name: 'claude-exclusive',
330
+ description: 'Claude exclusive skill',
331
+ triggers: ['claude-trigger'],
332
+ body: '# Claude Exclusive',
333
+ sourcePath: '/tmp/skills/claude-exclusive.md',
334
+ targets: ['claude'],
335
+ };
336
+ // Filter for Cursor (as handshake.ts would do)
337
+ const cursorSkills = [claudeOnly].filter((s) => shouldEmitToTarget(s, 'cursor'));
338
+ expect(cursorSkills).toHaveLength(0);
339
+ });
340
+ it('skill with no targets appears in both Claude and Cursor output', () => {
341
+ const universal = {
342
+ name: 'universal',
343
+ description: 'Universal skill',
344
+ triggers: ['uni'],
345
+ body: '# Universal',
346
+ sourcePath: '/tmp/skills/universal.md',
347
+ };
348
+ const claudeSkills = [universal].filter((s) => shouldEmitToTarget(s, 'claude'));
349
+ const cursorSkills = [universal].filter((s) => shouldEmitToTarget(s, 'cursor'));
350
+ expect(claudeSkills).toHaveLength(1);
351
+ expect(cursorSkills).toHaveLength(1);
352
+ const router = generateClaudeSkillRouter(claudeSkills);
353
+ expect(router).toContain('universal');
354
+ const cursorOutput = generateCursorSkill(cursorSkills[0]);
355
+ expect(cursorOutput).toContain('universal');
356
+ });
357
+ });
358
+ describe('filterByLevel', () => {
359
+ const items = [
360
+ { name: 'core-item', level: 'core' },
361
+ { name: 'intermediate-item', level: 'intermediate' },
362
+ { name: 'expert-item', level: 'expert' },
363
+ { name: 'no-level-item' },
364
+ ];
365
+ it('filterByLevel("beginner") returns only level:core items + items with no level', () => {
366
+ const result = filterByLevel(items, 'beginner');
367
+ const names = result.map((i) => i.name);
368
+ expect(names).toContain('core-item');
369
+ expect(names).toContain('no-level-item');
370
+ expect(names).not.toContain('intermediate-item');
371
+ expect(names).not.toContain('expert-item');
372
+ expect(result).toHaveLength(2);
373
+ });
374
+ it('filterByLevel("intermediate") returns core + intermediate + no level', () => {
375
+ const result = filterByLevel(items, 'intermediate');
376
+ const names = result.map((i) => i.name);
377
+ expect(names).toContain('core-item');
378
+ expect(names).toContain('intermediate-item');
379
+ expect(names).toContain('no-level-item');
380
+ expect(names).not.toContain('expert-item');
381
+ expect(result).toHaveLength(3);
382
+ });
383
+ it('filterByLevel("expert") returns all items', () => {
384
+ const result = filterByLevel(items, 'expert');
385
+ expect(result).toHaveLength(4);
386
+ });
387
+ it('filterByLevel(undefined) returns all items (backward compat)', () => {
388
+ const result = filterByLevel(items, undefined);
389
+ expect(result).toHaveLength(4);
390
+ });
391
+ it('filterByLevel(null-ish) returns all items (backward compat)', () => {
392
+ const result = filterByLevel(items);
393
+ expect(result).toHaveLength(4);
394
+ });
395
+ it('unknown level throws an error', () => {
396
+ expect(() => filterByLevel(items, 'unknown')).toThrow('Unknown level "unknown"');
397
+ });
398
+ });
399
+ //# sourceMappingURL=portable-knowledge.test.js.map