project-knowledge 0.1.0 → 1.0.1

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 (39) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/README.md +201 -58
  3. package/_site/_test/ai-profile-test.js +59 -1
  4. package/_site/_test/baseline-schema-test.js +4 -3
  5. package/_site/_test/claude-workbench-test.js +72 -0
  6. package/_site/_test/draft-apply-test.js +12 -6
  7. package/_site/_test/kb-v2-templates-test.js +31 -43
  8. package/_site/_test/knowledge-store-logs-supervision-test.js +143 -0
  9. package/_site/_test/package-startup-test.js +108 -0
  10. package/_site/_test/project-control-panel-task14-test.js +151 -0
  11. package/_site/_test/task15-20-integration-test.js +194 -0
  12. package/_site/_test/task15-20-ui-flow-test.js +144 -0
  13. package/_site/_test/ui-smoke-test.js +2 -2
  14. package/_site/index.html +1640 -90
  15. package/_site/lib/ai-adapter.js +3 -3
  16. package/_site/lib/ai-workspace.js +120 -0
  17. package/_site/lib/analysis-orchestrator.js +117 -32
  18. package/_site/lib/claude-cli-runner.js +862 -0
  19. package/_site/lib/context-pack-builder.js +19 -11
  20. package/_site/lib/draft-apply.js +80 -31
  21. package/_site/lib/index-builder.js +100 -0
  22. package/_site/lib/job-orchestrator.js +15 -11
  23. package/_site/lib/kb-v3.js +188 -0
  24. package/_site/lib/kb-validator.js +84 -0
  25. package/_site/lib/knowledge-store.js +141 -0
  26. package/_site/lib/llm-client.js +103 -56
  27. package/_site/lib/prompt-registry.js +102 -0
  28. package/_site/lib/structured-logger.js +120 -0
  29. package/_site/lib/supervision.js +103 -0
  30. package/_site/server.js +887 -30
  31. package/_site/vendor/tailwind-browser.js +947 -0
  32. package/_site/vendor/vue.global.prod.js +9 -0
  33. package/ai-profiles.json +13 -3
  34. package/bin/project-knowledge.js +51 -0
  35. package/docs/development-progress.md +141 -0
  36. package/package.json +11 -2
  37. package/scripts/gen-commit-doc.ps1 +1 -1
  38. package/scripts/list-features.ps1 +1 -1
  39. package/scripts/register-scheduled-task.bat +3 -1
@@ -37,6 +37,7 @@
37
37
 
38
38
  const fs = require('fs');
39
39
  const path = require('path');
40
+ const { isV3Kb, V3_TOP_LEVEL } = require('./kb-v3');
40
41
 
41
42
  const MANIFEST_SCHEMA = 'kb-manifest/v1';
42
43
  const REQUIRED_DRAFT_AREAS = ['_ai/drafts/', '_ai/runs/', '_ai/context-packs/'];
@@ -57,6 +58,65 @@ function isInsideDraftArea(rel) {
57
58
  return norm === '_ai' || norm.startsWith('_ai/');
58
59
  }
59
60
 
61
+ function walkTrustedFiles(root, dir = root, prefix = '') {
62
+ const out = [];
63
+ let entries = [];
64
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return out; }
65
+ for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
66
+ if (entry.name.startsWith('.')) continue;
67
+ if (entry.name === '_ai' || entry.name === '_meta' || entry.name === 'node_modules') continue;
68
+ const rel = (prefix ? `${prefix}/` : '') + entry.name;
69
+ const full = path.join(dir, entry.name);
70
+ if (entry.isDirectory()) out.push(...walkTrustedFiles(root, full, rel));
71
+ else if (entry.isFile() && entry.name.endsWith('.md')) {
72
+ out.push({ path: rel, content: fs.readFileSync(full, 'utf-8'), size: fs.statSync(full).size });
73
+ }
74
+ }
75
+ return out;
76
+ }
77
+
78
+ function validateV3Kb(kbPath, info) {
79
+ const errors = [];
80
+ const warnings = [];
81
+ const required = [
82
+ ['README.md', 'file'],
83
+ ['GOAL.md', 'file'],
84
+ ['ARCHITECTURE.md', 'file'],
85
+ ['modules', 'dir'],
86
+ ['changes', 'dir'],
87
+ ['modules/00-index.md', 'file'],
88
+ ['changes/00-index.md', 'file'],
89
+ ];
90
+ for (const [rel, kind] of required) {
91
+ const abs = path.join(kbPath, rel);
92
+ if (!fs.existsSync(abs)) {
93
+ errors.push(`${rel} missing`);
94
+ continue;
95
+ }
96
+ const stat = fs.statSync(abs);
97
+ if (kind === 'file' && !stat.isFile()) errors.push(`${rel} must be a file`);
98
+ if (kind === 'dir' && !stat.isDirectory()) errors.push(`${rel} must be a directory`);
99
+ }
100
+ const top = fs.readdirSync(kbPath).filter(name => !name.startsWith('.')).sort();
101
+ const unexpected = top.filter(name => !V3_TOP_LEVEL.includes(name));
102
+ if (unexpected.length) errors.push(`unexpected top-level KB v3 items: ${unexpected.join(', ')}`);
103
+ if (fs.existsSync(path.join(kbPath, '_ai'))) errors.push('_ai must not live inside a KB v3 directory');
104
+ if (fs.existsSync(path.join(kbPath, 'kb-manifest.json'))) errors.push('kb-manifest.json must not exist in KB v3');
105
+ info.schemaVersion = 'v3';
106
+ info.manifestExists = false;
107
+ info.goalStatus = fs.existsSync(path.join(kbPath, 'GOAL.md')) ? 'accepted-or-draft' : 'missing';
108
+ info.analysisStatus = fs.existsSync(path.join(kbPath, 'ARCHITECTURE.md')) ? 'present' : 'missing';
109
+ info.trustedKnowledgeEntries = walkTrustedFiles(kbPath).length;
110
+ return {
111
+ ok: errors.length === 0,
112
+ status: errors.length ? 422 : 200,
113
+ info,
114
+ errors,
115
+ warnings,
116
+ manifest: null,
117
+ };
118
+ }
119
+
60
120
  function validateKb(kbPath) {
61
121
  const errors = [];
62
122
  const warnings = [];
@@ -77,6 +137,10 @@ function validateKb(kbPath) {
77
137
  return { ok: false, status: 400, error: `kbPath is not a directory: ${kbPath}` };
78
138
  }
79
139
 
140
+ if (isV3Kb(kbPath)) {
141
+ return validateV3Kb(kbPath, info);
142
+ }
143
+
80
144
  const manifestPath = path.join(kbPath, 'kb-manifest.json');
81
145
  if (!fs.existsSync(manifestPath)) {
82
146
  return { ok: false, status: 422, error: 'kb-manifest.json missing', info, errors: ['manifest missing'] };
@@ -186,6 +250,25 @@ function buildPrContextPack(kbPath) {
186
250
  const v = validateKb(kbPath);
187
251
  if (!v.ok) return { ok: false, status: v.status || 422, error: 'kb invalid', validation: v };
188
252
  const manifest = v.manifest;
253
+ if (v.info && v.info.schemaVersion === 'v3') {
254
+ const files = walkTrustedFiles(kbPath);
255
+ const readFile = rel => files.find(item => item.path === rel) || null;
256
+ return {
257
+ ok: true,
258
+ pack: {
259
+ schema: 'pr-context-pack/v1',
260
+ generatedAt: new Date().toISOString(),
261
+ project: path.basename(kbPath),
262
+ goal: readFile('GOAL.md'),
263
+ analysis: readFile('ARCHITECTURE.md'),
264
+ indexes: {
265
+ modules: readFile('modules/00-index.md'),
266
+ changes: readFile('changes/00-index.md'),
267
+ },
268
+ trustedKnowledge: files,
269
+ },
270
+ };
271
+ }
189
272
  const read = rel => {
190
273
  if (!rel || !existsAny(kbPath, rel)) return null;
191
274
  const abs = path.resolve(kbPath, rel);
@@ -221,4 +304,5 @@ module.exports = {
221
304
  REQUIRED_DRAFT_AREAS,
222
305
  validateKb,
223
306
  buildPrContextPack,
307
+ walkTrustedFiles,
224
308
  };
@@ -0,0 +1,141 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+
4
+ const SCHEMA = 'knowledge-store/v1';
5
+
6
+ function defaultConfig(appRoot) {
7
+ return {
8
+ schema: SCHEMA,
9
+ rootPath: path.join(appRoot, 'projects'),
10
+ git: {
11
+ enabled: false,
12
+ remoteUrl: '',
13
+ branch: 'main',
14
+ autoCommit: false,
15
+ autoPush: false,
16
+ },
17
+ configured: false,
18
+ };
19
+ }
20
+
21
+ function normalizeConfig(input, appRoot) {
22
+ const base = defaultConfig(appRoot);
23
+ const source = input && typeof input === 'object' ? input : {};
24
+ const git = source.git && typeof source.git === 'object' ? source.git : {};
25
+ return {
26
+ schema: SCHEMA,
27
+ rootPath: path.resolve(source.rootPath || base.rootPath),
28
+ git: {
29
+ enabled: git.enabled === true,
30
+ remoteUrl: typeof git.remoteUrl === 'string' ? git.remoteUrl : '',
31
+ branch: typeof git.branch === 'string' && git.branch.trim() ? git.branch.trim() : 'main',
32
+ autoCommit: git.autoCommit === true,
33
+ autoPush: git.autoPush === true,
34
+ },
35
+ configured: source.configured === true,
36
+ };
37
+ }
38
+
39
+ function readConfig(configPath, appRoot) {
40
+ if (!fs.existsSync(configPath)) return defaultConfig(appRoot);
41
+ try {
42
+ return normalizeConfig(JSON.parse(fs.readFileSync(configPath, 'utf-8')), appRoot);
43
+ } catch {
44
+ return defaultConfig(appRoot);
45
+ }
46
+ }
47
+
48
+ function writeConfig(configPath, appRoot, config) {
49
+ const normalized = normalizeConfig({ ...config, configured: true }, appRoot);
50
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
51
+ fs.writeFileSync(configPath, JSON.stringify(normalized, null, 2) + '\n', 'utf-8');
52
+ return normalized;
53
+ }
54
+
55
+ function validateRoot(rootPath) {
56
+ if (!rootPath || typeof rootPath !== 'string') {
57
+ return { ok: false, error: 'rootPath is required' };
58
+ }
59
+ const resolved = path.resolve(rootPath);
60
+ try {
61
+ fs.mkdirSync(resolved, { recursive: true });
62
+ const stat = fs.statSync(resolved);
63
+ if (!stat.isDirectory()) return { ok: false, rootPath: resolved, error: 'rootPath is not a directory' };
64
+ return { ok: true, rootPath: resolved };
65
+ } catch (e) {
66
+ return { ok: false, rootPath: resolved, error: e.message };
67
+ }
68
+ }
69
+
70
+ function defaultProjectKbPath(slug, configPath, appRoot) {
71
+ const cfg = readConfig(configPath, appRoot);
72
+ return path.join(cfg.rootPath, slug);
73
+ }
74
+
75
+ function isInside(child, parent) {
76
+ const rel = path.relative(path.resolve(parent), path.resolve(child));
77
+ return rel === '' || (!!rel && !rel.startsWith('..') && !path.isAbsolute(rel));
78
+ }
79
+
80
+ function copyDir(src, dest) {
81
+ fs.mkdirSync(dest, { recursive: true });
82
+ for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
83
+ const from = path.join(src, entry.name);
84
+ const to = path.join(dest, entry.name);
85
+ if (entry.isDirectory()) copyDir(from, to);
86
+ else if (entry.isFile()) fs.copyFileSync(from, to);
87
+ }
88
+ }
89
+
90
+ function buildMigrationPlan({ projects, appRoot, storeConfig }) {
91
+ const legacyRoot = path.join(appRoot, 'projects');
92
+ const items = [];
93
+ for (const [slug, cfg] of Object.entries(projects || {})) {
94
+ const current = path.resolve(cfg.kbPath || path.join(legacyRoot, slug));
95
+ if (!isInside(current, legacyRoot)) continue;
96
+ const target = path.join(storeConfig.rootPath, slug);
97
+ if (path.resolve(current) === path.resolve(target)) continue;
98
+ items.push({
99
+ slug,
100
+ from: current,
101
+ to: target,
102
+ exists: fs.existsSync(current),
103
+ targetExists: fs.existsSync(target),
104
+ });
105
+ }
106
+ return items;
107
+ }
108
+
109
+ function migrateProjects({ projects, appRoot, storeConfig, overwrite = false, move = false }) {
110
+ const plan = buildMigrationPlan({ projects, appRoot, storeConfig });
111
+ const migrated = [];
112
+ for (const item of plan) {
113
+ if (!item.exists) {
114
+ projects[item.slug].kbPath = item.to;
115
+ migrated.push({ ...item, copied: false, reason: 'source missing; path updated only' });
116
+ continue;
117
+ }
118
+ if (item.targetExists && !overwrite) {
119
+ migrated.push({ ...item, copied: false, skipped: true, reason: 'target exists' });
120
+ continue;
121
+ }
122
+ if (item.targetExists && overwrite) fs.rmSync(item.to, { recursive: true, force: true });
123
+ copyDir(item.from, item.to);
124
+ if (move) fs.rmSync(item.from, { recursive: true, force: true });
125
+ projects[item.slug].kbPath = item.to;
126
+ migrated.push({ ...item, copied: true, moved: !!move });
127
+ }
128
+ return { plan, migrated };
129
+ }
130
+
131
+ module.exports = {
132
+ SCHEMA,
133
+ defaultConfig,
134
+ readConfig,
135
+ writeConfig,
136
+ validateRoot,
137
+ defaultProjectKbPath,
138
+ buildMigrationPlan,
139
+ migrateProjects,
140
+ isInside,
141
+ };
@@ -1,56 +1,76 @@
1
1
  // _site/lib/llm-client.js
2
2
  //
3
- // Tiny Anthropic Messages API client. Zero npm deps; uses node:https.
4
- // Reads connection settings from env (so the same adapter works against
5
- // the real Anthropic API, the system-level proxy at api.minimaxi.com,
6
- // or any other Anthropic-compatible endpoint):
3
+ // Tiny Anthropic Messages API client. Zero npm deps; supports HTTPS and HTTP
4
+ // Anthropic-compatible endpoints. Connection settings are resolved from an AI
5
+ // profile first, then environment variables as fallback:
7
6
  //
8
- // ANTHROPIC_BASE_URL default "https://api.anthropic.com"
9
- // ANTHROPIC_AUTH_TOKEN required
10
- // ANTHROPIC_MODEL default "claude-haiku-4-5"
11
- // ANTHROPIC_VERSION default "2023-06-01"
7
+ // profile.baseUrl or ANTHROPIC_BASE_URL
8
+ // profile.apiKey or ANTHROPIC_AUTH_TOKEN
9
+ // profile.model or ANTHROPIC_MODEL
10
+ // profile.version or ANTHROPIC_VERSION
11
+ // profile.timeoutMs or API_TIMEOUT_MS
12
12
  //
13
- // The client returns { text, usage, raw }. Callers are responsible for
14
- // parsing the model output (usually as JSON) and for any retries.
13
+ // This lets the local UI persist model settings in ai-profiles.json while still
14
+ // keeping env vars useful for scripts and temporary overrides.
15
15
 
16
+ const fs = require('fs');
17
+ const path = require('path');
18
+ const http = require('http');
16
19
  const https = require('https');
17
20
  const { URL } = require('url');
18
21
 
19
22
  const DEFAULT_BASE = 'https://api.anthropic.com';
20
23
  const DEFAULT_VERSION = '2023-06-01';
21
24
  const DEFAULT_MODEL = 'claude-haiku-4-5';
25
+ const AI_PROFILES_PATH = path.resolve(__dirname, '..', '..', 'ai-profiles.json');
22
26
 
23
- function readConfig() {
27
+ function readProfilesFile() {
28
+ try {
29
+ const cfg = JSON.parse(fs.readFileSync(AI_PROFILES_PATH, 'utf-8'));
30
+ return Array.isArray(cfg.profiles) ? cfg.profiles : [];
31
+ } catch {
32
+ return [];
33
+ }
34
+ }
35
+
36
+ function findProfile(profileId) {
37
+ if (!profileId) return null;
38
+ return readProfilesFile().find(profile => profile && profile.id === profileId) || null;
39
+ }
40
+
41
+ function readConfig(options = {}) {
42
+ const profileId = typeof options === 'string' ? options : options.profileId;
43
+ const profile = findProfile(profileId) || {};
44
+ const apiKeyFromEnvName = profile.apiKeyEnv ? process.env[profile.apiKeyEnv] : '';
45
+ const timeoutMs = Number(profile.timeoutMs || process.env.API_TIMEOUT_MS || 60_000);
24
46
  return {
25
- baseUrl: process.env.ANTHROPIC_BASE_URL || DEFAULT_BASE,
26
- apiKey: process.env.ANTHROPIC_AUTH_TOKEN || '',
27
- model: process.env.ANTHROPIC_MODEL || DEFAULT_MODEL,
28
- version: process.env.ANTHROPIC_VERSION || DEFAULT_VERSION,
47
+ profileId: profile.id || profileId || null,
48
+ baseUrl: profile.baseUrl || profile.apiBaseUrl || profile.anthropicBaseUrl || process.env.ANTHROPIC_BASE_URL || DEFAULT_BASE,
49
+ apiKey: profile.apiKey || profile.authToken || profile.anthropicAuthToken || apiKeyFromEnvName || process.env.ANTHROPIC_AUTH_TOKEN || '',
50
+ model: profile.model || process.env.ANTHROPIC_MODEL || DEFAULT_MODEL,
51
+ version: profile.version || profile.anthropicVersion || process.env.ANTHROPIC_VERSION || DEFAULT_VERSION,
52
+ temperature: profile.temperature,
53
+ maxTokens: profile.maxTokens,
54
+ timeoutMs: Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : 60_000,
29
55
  };
30
56
  }
31
57
 
58
+ function buildRequestPath(base, p) {
59
+ if (!p.startsWith('/')) return p;
60
+ const basePath = base.pathname.endsWith('/') ? base.pathname.slice(0, -1) : base.pathname;
61
+ return basePath + p;
62
+ }
63
+
32
64
  function postJson({ baseUrl, path: p, body, apiKey, version, timeoutMs = 60_000 }) {
33
65
  return new Promise((resolve, reject) => {
34
- let url;
35
66
  try {
36
- // Allow baseUrl to contain a path prefix (e.g. "https://api.x.com/anthropic").
37
- // We resolve `p` relative to that base, but the result must be treated
38
- // as the final URL — no further prefix insertion.
39
67
  const base = new URL(baseUrl);
40
- // base.pathname is already a valid path; we replace naively if p starts with /
41
- let fullPath;
42
- if (p.startsWith('/')) {
43
- // Strip a trailing slash from base.pathname, then concatenate.
44
- const basePath = base.pathname.endsWith('/') ? base.pathname.slice(0, -1) : base.pathname;
45
- fullPath = basePath + p;
46
- } else {
47
- fullPath = p;
48
- }
68
+ const transport = base.protocol === 'http:' ? http : https;
49
69
  const data = JSON.stringify(body);
50
- const req = https.request({
51
- host: base.host,
52
- port: base.port || 443,
53
- path: fullPath,
70
+ const req = transport.request({
71
+ host: base.hostname,
72
+ port: base.port || (base.protocol === 'http:' ? 80 : 443),
73
+ path: buildRequestPath(base, p),
54
74
  method: 'POST',
55
75
  headers: {
56
76
  'Content-Type': 'application/json',
@@ -75,52 +95,79 @@ function postJson({ baseUrl, path: p, body, apiKey, version, timeoutMs = 60_000
75
95
  req.on('timeout', () => req.destroy(new Error(`timeout after ${timeoutMs}ms`)));
76
96
  req.write(data);
77
97
  req.end();
78
- } catch (e) { return reject(new Error(`bad baseUrl: ${baseUrl}: ${e.message}`)); }
98
+ } catch (e) {
99
+ reject(new Error(`bad baseUrl: ${baseUrl}: ${e.message}`));
100
+ }
79
101
  });
80
102
  }
81
103
 
82
- async function completeJson({ system, user, schema, maxTokens = 2048, temperature = 0.2, model }) {
83
- const cfg = readConfig();
84
- if (!cfg.apiKey) throw new Error('ANTHROPIC_AUTH_TOKEN not set');
85
- const useModel = model || cfg.model;
86
- // Wrap the user content in a directive that asks for strict JSON. The
87
- // adapter is also told the schema; it must return JSON that the KB
88
- // orchestrator's validateOutput can check.
89
- const schemaHint = schema ? `\n\nThe output MUST be a JSON object matching this schema:\n${schema}\n` : '';
90
- const finalUser = `${user}${schemaHint}\n\nRespond with ONLY the JSON object, no prose, no markdown fences.`;
91
- const messages = [{ role: 'user', content: finalUser }];
104
+ async function completeText({ system, user, maxTokens = 512, temperature = 0.2, model, profileId }) {
105
+ const cfg = readConfig({ profileId });
106
+ if (!cfg.apiKey) {
107
+ const id = profileId ? ` for profile ${profileId}` : '';
108
+ throw new Error(`API key not set${id}`);
109
+ }
92
110
  const body = {
93
- model: useModel,
94
- max_tokens: maxTokens,
95
- temperature,
96
- messages,
111
+ model: model || cfg.model,
112
+ max_tokens: maxTokens || cfg.maxTokens || 512,
113
+ temperature: temperature != null ? temperature : (cfg.temperature != null ? cfg.temperature : 0.2),
114
+ messages: [{ role: 'user', content: user }],
97
115
  };
98
116
  if (system) body.system = system;
117
+
99
118
  const raw = await postJson({
100
119
  baseUrl: cfg.baseUrl,
101
120
  path: '/v1/messages',
102
121
  body,
103
122
  apiKey: cfg.apiKey,
104
123
  version: cfg.version,
124
+ timeoutMs: cfg.timeoutMs,
105
125
  });
106
- // The Messages API returns content as an array of blocks; we expect one text block.
126
+
107
127
  const text = (raw.content || [])
108
- .filter(b => b && b.type === 'text' && typeof b.text === 'string')
109
- .map(b => b.text)
128
+ .filter(block => block && block.type === 'text' && typeof block.text === 'string')
129
+ .map(block => block.text)
110
130
  .join('\n')
111
131
  .trim();
132
+
133
+ return {
134
+ text,
135
+ raw,
136
+ usage: raw.usage || null,
137
+ model: raw.model || body.model,
138
+ profileId: cfg.profileId,
139
+ };
140
+ }
141
+
142
+ async function completeJson({ system, user, schema, maxTokens = 2048, temperature = 0.2, model, profileId }) {
143
+ const schemaHint = schema ? `\n\nThe output MUST be a JSON object matching this schema:\n${schema}\n` : '';
144
+ const finalUser = `${user}${schemaHint}\n\nRespond with ONLY the JSON object, no prose, no markdown fences.`;
145
+ const result = await completeText({ system, user: finalUser, maxTokens, temperature, model, profileId });
146
+ const text = result.text;
147
+
112
148
  let parsed = null;
113
149
  let parseError = null;
114
- // First try direct parse; if that fails, strip a leading ```json fence.
115
150
  const candidates = [
116
151
  text,
117
152
  text.replace(/^```(?:json)?\s*/i, '').replace(/```\s*$/, ''),
118
153
  ];
119
- for (const c of candidates) {
120
- if (!c) continue;
121
- try { parsed = JSON.parse(c); parseError = null; break; } catch (e) { parseError = e; }
154
+ for (const candidate of candidates) {
155
+ if (!candidate) continue;
156
+ try {
157
+ parsed = JSON.parse(candidate);
158
+ parseError = null;
159
+ break;
160
+ } catch (e) {
161
+ parseError = e;
162
+ }
122
163
  }
123
- return { text, parsed, parseError, raw };
164
+
165
+ return { ...result, parsed, parseError };
124
166
  }
125
167
 
126
- module.exports = { completeJson, readConfig };
168
+ module.exports = {
169
+ completeJson,
170
+ completeText,
171
+ readConfig,
172
+ findProfile,
173
+ };
@@ -0,0 +1,102 @@
1
+ // Prompt Registry — reads/writes claude-prompts.json and renders templated prompts.
2
+ //
3
+ // File location: <KB_ROOT>/claude-prompts.json
4
+ // Schema: { schema: 'claude-prompts/v1', prompts: { <key>: { description, model, permissionMode,
5
+ // allowedTools, systemPrompt, userPrompt } } }
6
+ //
7
+ // Variable interpolation: {{KEY}} placeholders in systemPrompt/userPrompt are replaced
8
+ // with values from the `vars` argument. Unknown placeholders are left untouched (so
9
+ // incomplete prompts don't crash — they show the raw token for diagnosis).
10
+
11
+ const fs = require('fs');
12
+ const path = require('path');
13
+
14
+ const KB_ROOT = path.resolve(__dirname, '..', '..');
15
+ const PROMPTS_PATH = path.join(KB_ROOT, 'claude-prompts.json');
16
+ const SCHEMA = 'claude-prompts/v1';
17
+
18
+ const PLACEHOLDER_RE = /\{\{\s*([A-Z_][A-Z0-9_]*)\s*\}\}/g;
19
+
20
+ function readPrompts() {
21
+ if (!fs.existsSync(PROMPTS_PATH)) {
22
+ return { schema: SCHEMA, prompts: {} };
23
+ }
24
+ try {
25
+ const cfg = JSON.parse(fs.readFileSync(PROMPTS_PATH, 'utf-8'));
26
+ if (!cfg || typeof cfg !== 'object') return { schema: SCHEMA, prompts: {} };
27
+ if (!cfg.prompts || typeof cfg.prompts !== 'object') cfg.prompts = {};
28
+ if (!cfg.schema) cfg.schema = SCHEMA;
29
+ return cfg;
30
+ } catch (e) {
31
+ throw new Error(`failed to parse ${PROMPTS_PATH}: ${e.message}`);
32
+ }
33
+ }
34
+
35
+ function writePrompts(cfg) {
36
+ if (!cfg || typeof cfg !== 'object') throw new Error('prompts config must be an object');
37
+ if (cfg.prompts && typeof cfg.prompts !== 'object') throw new Error('prompts field must be an object');
38
+ const normalized = {
39
+ schema: cfg.schema || SCHEMA,
40
+ prompts: cfg.prompts || {},
41
+ };
42
+ for (const [key, val] of Object.entries(normalized.prompts)) {
43
+ if (!key || typeof key !== 'string') throw new Error(`invalid prompt key: ${key}`);
44
+ if (!val || typeof val !== 'object') throw new Error(`prompt "${key}" must be an object`);
45
+ if (typeof val.userPrompt !== 'string' || !val.userPrompt) {
46
+ throw new Error(`prompt "${key}" must have a non-empty userPrompt`);
47
+ }
48
+ }
49
+ fs.writeFileSync(PROMPTS_PATH, JSON.stringify(normalized, null, 2) + '\n', 'utf-8');
50
+ return normalized;
51
+ }
52
+
53
+ function listPromptKeys() {
54
+ return Object.keys(readPrompts().prompts);
55
+ }
56
+
57
+ function getPrompt(key) {
58
+ const cfg = readPrompts();
59
+ const p = cfg.prompts[key];
60
+ if (!p) return null;
61
+ return p;
62
+ }
63
+
64
+ function interpolate(text, vars) {
65
+ if (typeof text !== 'string') return text;
66
+ if (!vars || typeof vars !== 'object') return text;
67
+ return text.replace(PLACEHOLDER_RE, (_, name) => {
68
+ if (Object.prototype.hasOwnProperty.call(vars, name)) {
69
+ const v = vars[name];
70
+ return v == null ? '' : String(v);
71
+ }
72
+ return `{{${name}}}`;
73
+ });
74
+ }
75
+
76
+ // Render the prompt for a given key, returning all fields needed to spawn claude.
77
+ // Returns null if key doesn't exist.
78
+ function renderPrompt(key, vars) {
79
+ const p = getPrompt(key);
80
+ if (!p) return null;
81
+ const safeVars = vars || {};
82
+ return {
83
+ key,
84
+ description: p.description || '',
85
+ model: p.model || 'sonnet',
86
+ permissionMode: p.permissionMode || 'bypassPermissions',
87
+ allowedTools: Array.isArray(p.allowedTools) ? p.allowedTools.slice() : ['Read', 'Grep', 'Glob'],
88
+ systemPrompt: interpolate(p.systemPrompt || '', safeVars),
89
+ userPrompt: interpolate(p.userPrompt, safeVars),
90
+ };
91
+ }
92
+
93
+ module.exports = {
94
+ PROMPTS_PATH,
95
+ SCHEMA,
96
+ readPrompts,
97
+ writePrompts,
98
+ listPromptKeys,
99
+ getPrompt,
100
+ renderPrompt,
101
+ interpolate,
102
+ };