project-knowledge 0.1.0 → 1.0.0

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 (34) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/README.md +184 -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/project-control-panel-task14-test.js +151 -0
  10. package/_site/_test/task15-20-integration-test.js +194 -0
  11. package/_site/_test/task15-20-ui-flow-test.js +144 -0
  12. package/_site/_test/ui-smoke-test.js +2 -2
  13. package/_site/index.html +1640 -90
  14. package/_site/lib/ai-adapter.js +3 -3
  15. package/_site/lib/ai-workspace.js +120 -0
  16. package/_site/lib/analysis-orchestrator.js +117 -32
  17. package/_site/lib/claude-cli-runner.js +862 -0
  18. package/_site/lib/context-pack-builder.js +19 -11
  19. package/_site/lib/draft-apply.js +80 -31
  20. package/_site/lib/index-builder.js +100 -0
  21. package/_site/lib/job-orchestrator.js +12 -9
  22. package/_site/lib/kb-v3.js +188 -0
  23. package/_site/lib/kb-validator.js +84 -0
  24. package/_site/lib/knowledge-store.js +141 -0
  25. package/_site/lib/llm-client.js +103 -56
  26. package/_site/lib/prompt-registry.js +102 -0
  27. package/_site/lib/structured-logger.js +120 -0
  28. package/_site/lib/supervision.js +103 -0
  29. package/_site/server.js +835 -19
  30. package/_site/vendor/tailwind-browser.js +947 -0
  31. package/_site/vendor/vue.global.prod.js +9 -0
  32. package/ai-profiles.json +12 -10
  33. package/docs/development-progress.md +141 -0
  34. package/package.json +7 -2
@@ -1,4 +1,4 @@
1
- // TASK-003: KB v2 templates test
1
+ // TASK-003/TASK-020: KB template compatibility test
2
2
  // Run: node _site/_test/kb-v2-templates-test.js
3
3
  const fs = require('fs');
4
4
  const path = require('path');
@@ -35,6 +35,9 @@ const REQUIRED_V2_FILES = [
35
35
  'references/source-map.md',
36
36
  ];
37
37
 
38
+ const REQUIRED_V3_TOP_LEVEL = ['ARCHITECTURE.md', 'GOAL.md', 'README.md', 'changes', 'modules'];
39
+ const REQUIRED_V3_FILES = ['README.md', 'GOAL.md', 'ARCHITECTURE.md', 'modules/00-index.md', 'changes/00-index.md'];
40
+
38
41
  function assert(cond, msg) { if (!cond) throw new Error(msg); }
39
42
 
40
43
  async function waitForServer() {
@@ -64,9 +67,9 @@ async function json(method, url, body) {
64
67
  }
65
68
 
66
69
  async function cleanupTemp() {
67
- const base = path.join(ROOT, 'projects', TEMP_SLUG);
68
- fs.rmSync(base, { recursive: true, force: true });
69
70
  const cur = JSON.parse(fs.readFileSync(PROJECTS_JSON, 'utf-8'));
71
+ const base = cur[TEMP_SLUG] && cur[TEMP_SLUG].kbPath || path.join(ROOT, 'projects', TEMP_SLUG);
72
+ fs.rmSync(base, { recursive: true, force: true });
70
73
  if (cur[TEMP_SLUG]) {
71
74
  delete cur[TEMP_SLUG];
72
75
  fs.writeFileSync(PROJECTS_JSON, JSON.stringify(cur, null, 2) + '\n', 'utf-8');
@@ -104,51 +107,36 @@ async function cleanupTemp() {
104
107
 
105
108
  r = await json('POST', `/api/projects/${TEMP_SLUG}/init`);
106
109
  assert(r.res.ok, 'init should succeed');
107
- assert(r.data.kbSchemaVersion === 'v2', `init should set schema v2, got ${r.data.kbSchemaVersion}`);
110
+ assert(r.data.kbSchemaVersion === 'v3', `init should set schema v3, got ${r.data.kbSchemaVersion}`);
108
111
 
109
- const base = path.join(ROOT, 'projects', TEMP_SLUG);
110
- for (const sub of REQUIRED_V2_DIRS) {
111
- const p = path.join(base, sub);
112
- assert(fs.existsSync(p) && fs.statSync(p).isDirectory(), `missing dir: ${sub}`);
113
- }
114
- for (const rel of REQUIRED_V2_FILES) {
112
+ const savedProjects = JSON.parse(fs.readFileSync(PROJECTS_JSON, 'utf-8'));
113
+ const base = savedProjects[TEMP_SLUG].kbPath;
114
+ const topLevel = fs.readdirSync(base).filter(name => !name.startsWith('.')).sort();
115
+ assert(JSON.stringify(topLevel) === JSON.stringify(REQUIRED_V3_TOP_LEVEL), `v3 top-level mismatch: ${topLevel.join(', ')}`);
116
+ for (const rel of REQUIRED_V3_FILES) {
115
117
  const p = path.join(base, rel);
116
- assert(fs.existsSync(p), `missing file: ${rel}`);
118
+ assert(fs.existsSync(p), `missing v3 file: ${rel}`);
117
119
  }
118
-
119
- // 2. Manifest parses and points to existing files
120
- const manifestPath = path.join(base, 'kb-manifest.json');
121
- const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
122
- assert(manifest.schema === 'kb-manifest/v1', 'manifest schema should be v1');
123
- assert(manifest.project === TEMP_SLUG, 'manifest project should match');
124
- assert(manifest.goal.path === 'project-goal.md', 'manifest should point at project-goal.md');
125
- assert(Array.isArray(manifest.trustedKnowledge) && manifest.trustedKnowledge.length > 0, 'trustedKnowledge should be non-empty');
126
- for (const tpath of [manifest.goal.path, manifest.analysis.path, ...manifest.indexes ? Object.values(manifest.indexes) : []]) {
127
- const full = path.join(base, tpath);
128
- assert(fs.existsSync(full), `manifest should not point to missing file: ${tpath}`);
129
- }
130
- for (const trusted of manifest.trustedKnowledge) {
131
- const full = path.join(base, trusted);
132
- assert(fs.existsSync(full), `trusted entry should exist: ${trusted}`);
133
- }
134
-
135
- // 3. Templates rendered with project name
136
- const goal = fs.readFileSync(path.join(base, 'project-goal.md'), 'utf-8');
137
- assert(goal.includes(TEMP_SLUG), 'project-goal should contain project name');
138
- assert(goal.includes('TODO'), 'project-goal should still have TODO placeholders');
139
- const analysis = fs.readFileSync(path.join(base, 'project-analysis.md'), 'utf-8');
140
- assert(analysis.includes(TEMP_SLUG), 'project-analysis should contain project name');
141
- const feature = fs.readFileSync(path.join(base, 'features/00-index.md'), 'utf-8');
142
- assert(feature.includes('Features'), 'features index should be created');
143
-
144
- // 4. Idempotent re-init
145
- const before = fs.readFileSync(manifestPath, 'utf-8');
120
+ assert(!fs.existsSync(path.join(base, 'kb-manifest.json')), 'v3 init should not create manifest');
121
+ assert(!fs.existsSync(path.join(base, '_ai')), 'v3 init should not create _ai inside KB');
122
+
123
+ // 2. Templates rendered with project name
124
+ const goal = fs.readFileSync(path.join(base, 'GOAL.md'), 'utf-8');
125
+ assert(goal.includes(TEMP_SLUG), 'GOAL should contain project name');
126
+ assert(goal.includes('TODO'), 'GOAL should still have TODO placeholders');
127
+ const analysis = fs.readFileSync(path.join(base, 'ARCHITECTURE.md'), 'utf-8');
128
+ assert(analysis.includes(TEMP_SLUG), 'ARCHITECTURE should contain project name');
129
+ const modulesIndex = fs.readFileSync(path.join(base, 'modules', '00-index.md'), 'utf-8');
130
+ assert(modulesIndex.includes('Modules'), 'modules index should be created');
131
+
132
+ // 3. Idempotent re-init
133
+ const before = fs.readFileSync(path.join(base, 'GOAL.md'), 'utf-8');
146
134
  r = await json('POST', `/api/projects/${TEMP_SLUG}/init`);
147
135
  assert(r.res.ok, 're-init should succeed');
148
- const after = fs.readFileSync(manifestPath, 'utf-8');
149
- assert(before === after, 're-init should not rewrite manifest');
136
+ const after = fs.readFileSync(path.join(base, 'GOAL.md'), 'utf-8');
137
+ assert(before === after, 're-init should not rewrite GOAL');
150
138
 
151
- // 5. Legacy commits/ is preserved when migrating an existing v1 project
139
+ // 4. Legacy commits/ is preserved when migrating an existing v1 project to v2
152
140
  const legacySlug = 'task-003-legacy';
153
141
  const legacyBase = path.join(ROOT, 'projects', legacySlug);
154
142
  fs.mkdirSync(path.join(legacyBase, 'commits'), { recursive: true });
@@ -158,7 +146,7 @@ async function cleanupTemp() {
158
146
  fs.mkdirSync(path.join(legacyBase, 'modules'), { recursive: true });
159
147
  fs.writeFileSync(path.join(legacyBase, 'modules', 'foo.md'), 'legacy module\n');
160
148
 
161
- r = await json('PUT', '/api/projects', { slug: legacySlug, config: { displayName: 'legacy' } });
149
+ r = await json('PUT', '/api/projects', { slug: legacySlug, config: { displayName: 'legacy', kbPath: legacyBase } });
162
150
  assert(r.res.ok, 'legacy upsert should succeed');
163
151
  r = await json('POST', `/api/projects/${legacySlug}/migrate-v2`);
164
152
  assert(r.res.ok, 'migrate-v2 should succeed');
@@ -0,0 +1,143 @@
1
+ // Run: node _site/_test/knowledge-store-logs-supervision-test.js
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+ const { spawn } = require('child_process');
5
+ const { makeRepo } = require('./fixtures/make-git-repos');
6
+
7
+ const ROOT = path.resolve(__dirname, '..', '..');
8
+ const SERVER = path.join(ROOT, '_site', 'server.js');
9
+ const PROJECTS_JSON = path.join(ROOT, 'projects.json');
10
+ const KNOWLEDGE_STORE_JSON = path.join(ROOT, 'knowledge-store.json');
11
+ const LOGGING_JSON = path.join(ROOT, 'logging.json');
12
+ const PORT = process.env.KB_TASK_012_013_TEST_PORT || '7814';
13
+ const BASE_URL = `http://127.0.0.1:${PORT}`;
14
+ const TEMP_SLUG = 'task-012-013-temp';
15
+ const TEMP_ROOT = path.join(ROOT, '.tmp-task-012-013');
16
+
17
+ function assert(cond, msg) { if (!cond) throw new Error(msg); }
18
+
19
+ function backup(file) {
20
+ return fs.existsSync(file) ? fs.readFileSync(file, 'utf-8') : null;
21
+ }
22
+
23
+ function restore(file, content) {
24
+ if (content == null) fs.rmSync(file, { force: true });
25
+ else fs.writeFileSync(file, content, 'utf-8');
26
+ }
27
+
28
+ async function waitForServer() {
29
+ const deadline = Date.now() + 15000;
30
+ let lastError;
31
+ while (Date.now() < deadline) {
32
+ try {
33
+ const res = await fetch(`${BASE_URL}/api/state`);
34
+ if (res.ok) return;
35
+ lastError = new Error(`HTTP ${res.status}`);
36
+ } catch (e) { lastError = e; }
37
+ await new Promise(resolve => setTimeout(resolve, 250));
38
+ }
39
+ throw lastError || new Error('server did not start');
40
+ }
41
+
42
+ async function json(method, url, body) {
43
+ const res = await fetch(`${BASE_URL}${url}`, {
44
+ method,
45
+ headers: body ? { 'Content-Type': 'application/json' } : undefined,
46
+ body: body ? JSON.stringify(body) : undefined,
47
+ });
48
+ const text = await res.text();
49
+ let data = {};
50
+ if (text) { try { data = JSON.parse(text); } catch { data = { raw: text }; } }
51
+ return { res, data };
52
+ }
53
+
54
+ (async () => {
55
+ const projectsBackup = backup(PROJECTS_JSON);
56
+ const storeBackup = backup(KNOWLEDGE_STORE_JSON);
57
+ const loggingBackup = backup(LOGGING_JSON);
58
+ fs.rmSync(TEMP_ROOT, { recursive: true, force: true });
59
+ fs.mkdirSync(TEMP_ROOT, { recursive: true });
60
+
61
+ const child = spawn(process.execPath, [SERVER], {
62
+ cwd: ROOT,
63
+ env: { ...process.env, KB_SITE_PORT: PORT, KB_SITE_HOST: '127.0.0.1' },
64
+ stdio: ['ignore', 'pipe', 'pipe'],
65
+ windowsHide: true,
66
+ });
67
+
68
+ let serverOutput = '';
69
+ let repo = null;
70
+ child.stdout.on('data', d => { serverOutput += d.toString(); });
71
+ child.stderr.on('data', d => { serverOutput += d.toString(); });
72
+
73
+ try {
74
+ await waitForServer();
75
+
76
+ const storeRoot = path.join(TEMP_ROOT, 'kb-store');
77
+ let r = await json('PUT', '/api/knowledge-store/config', {
78
+ rootPath: storeRoot,
79
+ git: { enabled: true, remoteUrl: 'https://example.invalid/kb.git', branch: 'main' },
80
+ });
81
+ assert(r.res.ok, 'knowledge store config save should succeed');
82
+ assert(r.data.config.rootPath === path.resolve(storeRoot), 'knowledge store root should persist');
83
+
84
+ repo = makeRepo({ kind: 'multi-commit' });
85
+ r = await json('PUT', '/api/projects', {
86
+ slug: TEMP_SLUG,
87
+ config: {
88
+ displayName: 'TASK 012 013 Temp',
89
+ localPath: repo.path,
90
+ gitPath: repo.path,
91
+ enabled: true,
92
+ },
93
+ });
94
+ assert(r.res.ok, 'project import should succeed');
95
+
96
+ r = await json('GET', '/api/projects');
97
+ assert(r.data[TEMP_SLUG].kbPath === path.join(storeRoot, TEMP_SLUG), 'new project should use configured knowledge store root');
98
+
99
+ r = await json('POST', `/api/projects/${TEMP_SLUG}/init`);
100
+ assert(r.res.ok, 'project init should succeed');
101
+ assert(fs.existsSync(path.join(storeRoot, TEMP_SLUG, 'README.md')), 'KB README should be created in external store');
102
+
103
+ const logRoot = path.join(TEMP_ROOT, 'logs');
104
+ r = await json('PUT', '/api/logging/config', {
105
+ rootPath: logRoot,
106
+ retentionDays: 7,
107
+ levels: ['info', 'warn', 'error'],
108
+ });
109
+ assert(r.res.ok, 'logging config save should succeed');
110
+ assert(fs.readdirSync(logRoot).some(file => file.endsWith('.log')), 'logging config update should write a daily .log file');
111
+
112
+ r = await json('GET', '/api/logs?level=info&q=logging_config_updated');
113
+ assert(r.res.ok, 'log query should succeed');
114
+ assert((r.data.logs || []).some(log => log.event === 'logging_config_updated'), 'log query should find logging_config_updated');
115
+
116
+ r = await json('GET', '/api/supervision/pending-commits');
117
+ assert(r.res.ok, 'pending commits endpoint should succeed');
118
+ const pendingItem = (r.data.items || []).find(item => item.slug === TEMP_SLUG);
119
+ assert(pendingItem, 'pending commits should include temp project');
120
+ assert(pendingItem.pendingCount >= 1, 'temp project should have pending commits before analysis');
121
+
122
+ r = await json('GET', '/api/supervision/issues');
123
+ assert(r.res.ok, 'issues endpoint should succeed');
124
+ assert(Array.isArray(r.data.issues), 'issues should be an array');
125
+
126
+ r = await json('POST', '/api/knowledge-store/migrate', { execute: false });
127
+ assert(r.res.ok, 'migration preview should succeed');
128
+ assert(Array.isArray(r.data.plan), 'migration preview should return plan');
129
+
130
+ console.log('TASK-012/TASK-013 knowledge store, logs, supervision test passed');
131
+ } catch (e) {
132
+ console.error('TASK-012/TASK-013 test failed:', e.message);
133
+ if (serverOutput) console.error(serverOutput);
134
+ process.exitCode = 1;
135
+ } finally {
136
+ child.kill();
137
+ try { if (typeof repo !== 'undefined' && repo.cleanup) repo.cleanup(); } catch {}
138
+ restore(PROJECTS_JSON, projectsBackup);
139
+ restore(KNOWLEDGE_STORE_JSON, storeBackup);
140
+ restore(LOGGING_JSON, loggingBackup);
141
+ fs.rmSync(TEMP_ROOT, { recursive: true, force: true });
142
+ }
143
+ })();
@@ -0,0 +1,151 @@
1
+ // Run: node _site/_test/project-control-panel-task14-test.js
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+ const { spawn } = require('child_process');
5
+ const { makeRepo, git } = require('./fixtures/make-git-repos');
6
+
7
+ const ROOT = path.resolve(__dirname, '..', '..');
8
+ const SERVER = path.join(ROOT, '_site', 'server.js');
9
+ const PROJECTS_JSON = path.join(ROOT, 'projects.json');
10
+ const KNOWLEDGE_STORE_JSON = path.join(ROOT, 'knowledge-store.json');
11
+ const PORT = process.env.KB_TASK14_TEST_PORT || '7815';
12
+ const BASE_URL = `http://127.0.0.1:${PORT}`;
13
+ const TEMP_SLUG = 'task-014-temp';
14
+ const TEMP_ROOT = path.join(ROOT, '.tmp-task-014');
15
+
16
+ function assert(cond, msg) { if (!cond) throw new Error(msg); }
17
+ function backup(file) { return fs.existsSync(file) ? fs.readFileSync(file, 'utf-8') : null; }
18
+ function restore(file, content) {
19
+ if (content == null) fs.rmSync(file, { force: true });
20
+ else fs.writeFileSync(file, content, 'utf-8');
21
+ }
22
+
23
+ async function rmWithRetry(target) {
24
+ for (let i = 0; i < 8; i++) {
25
+ try {
26
+ fs.rmSync(target, { recursive: true, force: true });
27
+ return;
28
+ } catch (e) {
29
+ if (i === 7) throw e;
30
+ await new Promise(resolve => setTimeout(resolve, 250));
31
+ }
32
+ }
33
+ }
34
+
35
+ async function waitForServer() {
36
+ const deadline = Date.now() + 15000;
37
+ let lastError;
38
+ while (Date.now() < deadline) {
39
+ try {
40
+ const res = await fetch(`${BASE_URL}/api/state`);
41
+ if (res.ok) return;
42
+ lastError = new Error(`HTTP ${res.status}`);
43
+ } catch (e) { lastError = e; }
44
+ await new Promise(resolve => setTimeout(resolve, 250));
45
+ }
46
+ throw lastError || new Error('server did not start');
47
+ }
48
+
49
+ async function json(method, url, body) {
50
+ const res = await fetch(`${BASE_URL}${url}`, {
51
+ method,
52
+ headers: body ? { 'Content-Type': 'application/json' } : undefined,
53
+ body: body ? JSON.stringify(body) : undefined,
54
+ });
55
+ const text = await res.text();
56
+ let data = {};
57
+ if (text) { try { data = JSON.parse(text); } catch { data = { raw: text }; } }
58
+ return { res, data };
59
+ }
60
+
61
+ (async () => {
62
+ const projectsBackup = backup(PROJECTS_JSON);
63
+ const storeBackup = backup(KNOWLEDGE_STORE_JSON);
64
+ fs.rmSync(TEMP_ROOT, { recursive: true, force: true });
65
+ fs.mkdirSync(TEMP_ROOT, { recursive: true });
66
+ let repo = null;
67
+ let nonGit = null;
68
+
69
+ const child = spawn(process.execPath, [SERVER], {
70
+ cwd: ROOT,
71
+ env: { ...process.env, KB_SITE_PORT: PORT, KB_SITE_HOST: '127.0.0.1' },
72
+ stdio: ['ignore', 'pipe', 'pipe'],
73
+ windowsHide: true,
74
+ });
75
+ let serverOutput = '';
76
+ child.stdout.on('data', d => { serverOutput += d.toString(); });
77
+ child.stderr.on('data', d => { serverOutput += d.toString(); });
78
+
79
+ try {
80
+ await waitForServer();
81
+
82
+ const storeRoot = path.join(TEMP_ROOT, 'kb-store');
83
+ let r = await json('PUT', '/api/knowledge-store/config', { rootPath: storeRoot });
84
+ assert(r.res.ok, 'knowledge store config should save');
85
+
86
+ repo = makeRepo({ kind: 'multi-commit' });
87
+ r = await json('POST', '/api/projects/import-preflight', { localPath: repo.path });
88
+ assert(r.res.ok, 'preflight existing git should succeed');
89
+ assert(r.data.inspection.repoStatus === 'ok', 'existing git repo should be ok');
90
+ assert(r.data.needsGitInit === false, 'existing git should not need init');
91
+
92
+ nonGit = makeRepo({ kind: 'not-git' });
93
+ r = await json('POST', '/api/projects/import-preflight', { localPath: nonGit.path });
94
+ assert(r.res.ok, 'preflight non-git should return actionable result');
95
+ assert(r.data.needsGitInit === true, 'non-git should need git init');
96
+
97
+ r = await json('POST', '/api/projects/import-preflight', { localPath: path.join(TEMP_ROOT, 'missing') });
98
+ assert(!r.res.ok, 'preflight missing path should fail');
99
+
100
+ r = await json('POST', '/api/git/init', {
101
+ path: nonGit.path,
102
+ createInitialCommit: true,
103
+ remoteUrl: 'https://example.invalid/task-014.git',
104
+ });
105
+ assert(r.res.ok, 'git init should succeed');
106
+ assert(r.data.initialCommit === true, 'initial commit should be created');
107
+ assert(r.data.remoteConfigured === true, 'remote should be configured');
108
+ assert(git(nonGit.path, 'rev-parse --is-inside-work-tree') === 'true', 'non-git dir should now be git');
109
+ assert(git(nonGit.path, 'remote get-url origin') === 'https://example.invalid/task-014.git', 'remote origin should be set');
110
+
111
+ const firstCommit = repo.commits[repo.commits.length - 1].hash;
112
+ r = await json('PUT', '/api/projects', {
113
+ slug: TEMP_SLUG,
114
+ config: {
115
+ displayName: 'TASK 014 Temp',
116
+ localPath: repo.path,
117
+ gitPath: repo.path,
118
+ enabled: true,
119
+ aiProfileId: 'mock-agent',
120
+ goalStatus: 'accepted',
121
+ lastAnalyzedCommit: firstCommit,
122
+ },
123
+ });
124
+ assert(r.res.ok, 'project import should succeed');
125
+ r = await json('POST', `/api/projects/${TEMP_SLUG}/init`);
126
+ assert(r.res.ok, 'KB init should succeed');
127
+
128
+ r = await json('POST', `/api/projects/${TEMP_SLUG}/knowledge-update`, {});
129
+ assert(r.res.ok, `knowledge update should succeed: ${r.data.error || ''}`);
130
+ assert(r.data.scan && r.data.scan.pendingCount >= 1, 'knowledge update should scan pending commits');
131
+ assert(r.data.analysis && r.data.analysis.ok, 'knowledge update should run analysis');
132
+ assert(r.data.applyResult && r.data.applyResult.ok, 'safe commit drafts should auto-apply');
133
+
134
+ r = await json('GET', '/api/projects');
135
+ assert(r.data[TEMP_SLUG].lastAnalyzedCommit === repo.headCommit, 'lastAnalyzedCommit should advance to HEAD');
136
+
137
+ console.log('TASK-014 project control panel simplification test passed');
138
+ } catch (e) {
139
+ console.error('TASK-014 test failed:', e.message);
140
+ if (serverOutput) console.error(serverOutput);
141
+ process.exitCode = 1;
142
+ } finally {
143
+ child.kill();
144
+ await new Promise(r => setTimeout(r, 500));
145
+ try { if (repo && repo.cleanup) repo.cleanup(); } catch {}
146
+ try { if (nonGit && nonGit.cleanup) nonGit.cleanup(); } catch {}
147
+ restore(PROJECTS_JSON, projectsBackup);
148
+ restore(KNOWLEDGE_STORE_JSON, storeBackup);
149
+ await rmWithRetry(TEMP_ROOT);
150
+ }
151
+ })();
@@ -0,0 +1,194 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const { spawn } = require('child_process');
4
+ const { makeRepo } = require('./fixtures/make-git-repos');
5
+
6
+ const ROOT = path.resolve(__dirname, '..', '..');
7
+ const SERVER = path.join(ROOT, '_site', 'server.js');
8
+ const PROJECTS_JSON = path.join(ROOT, 'projects.json');
9
+ const KNOWLEDGE_STORE_JSON = path.join(ROOT, 'knowledge-store.json');
10
+ const LOGGING_JSON = path.join(ROOT, 'logging.json');
11
+ const PORT = process.env.KB_TASK_15_20_PORT || '7820';
12
+ const BASE_URL = `http://127.0.0.1:${PORT}`;
13
+ const TEMP_ROOT = path.join(ROOT, '.tmp-task-15-20');
14
+ const SLUG = 'task-015-020-temp';
15
+ const LEGACY_SLUG = 'task-020-legacy-temp';
16
+
17
+ function assert(cond, msg) { if (!cond) throw new Error(msg); }
18
+ function backup(file) { return fs.existsSync(file) ? fs.readFileSync(file, 'utf-8') : null; }
19
+ function restore(file, content) {
20
+ if (content == null) fs.rmSync(file, { force: true });
21
+ else fs.writeFileSync(file, content, 'utf-8');
22
+ }
23
+ async function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); }
24
+ async function rmWithRetry(target) {
25
+ for (let i = 0; i < 8; i++) {
26
+ try { fs.rmSync(target, { recursive: true, force: true }); return; }
27
+ catch (e) { if (i === 7) throw e; await sleep(250); }
28
+ }
29
+ }
30
+ async function waitForServer() {
31
+ const deadline = Date.now() + 15000;
32
+ let lastError;
33
+ while (Date.now() < deadline) {
34
+ try {
35
+ const res = await fetch(`${BASE_URL}/api/state`);
36
+ if (res.ok) return;
37
+ lastError = new Error(`HTTP ${res.status}`);
38
+ } catch (e) { lastError = e; }
39
+ await sleep(250);
40
+ }
41
+ throw lastError || new Error('server did not start');
42
+ }
43
+ async function json(method, url, body) {
44
+ const res = await fetch(`${BASE_URL}${url}`, {
45
+ method,
46
+ headers: body ? { 'Content-Type': 'application/json' } : undefined,
47
+ body: body ? JSON.stringify(body) : undefined,
48
+ });
49
+ const text = await res.text();
50
+ let data = {};
51
+ if (text) { try { data = JSON.parse(text); } catch { data = { raw: text }; } }
52
+ return { res, data };
53
+ }
54
+
55
+ (async () => {
56
+ const projectsBackup = backup(PROJECTS_JSON);
57
+ const storeBackup = backup(KNOWLEDGE_STORE_JSON);
58
+ const loggingBackup = backup(LOGGING_JSON);
59
+ fs.rmSync(TEMP_ROOT, { recursive: true, force: true });
60
+ fs.mkdirSync(TEMP_ROOT, { recursive: true });
61
+ const kbRoot = path.join(TEMP_ROOT, 'knowledge');
62
+ const logsRoot = path.join(TEMP_ROOT, 'logs');
63
+ const aiRoot = path.join(ROOT, '_site', '_ai');
64
+ fs.rmSync(path.join(aiRoot, SLUG), { recursive: true, force: true });
65
+ fs.rmSync(path.join(aiRoot, LEGACY_SLUG), { recursive: true, force: true });
66
+
67
+ const child = spawn(process.execPath, [SERVER], {
68
+ cwd: ROOT,
69
+ env: { ...process.env, KB_SITE_PORT: PORT, KB_SITE_HOST: '127.0.0.1' },
70
+ stdio: ['ignore', 'pipe', 'pipe'],
71
+ windowsHide: true,
72
+ });
73
+ let serverOutput = '';
74
+ child.stdout.on('data', d => { serverOutput += d.toString(); });
75
+ child.stderr.on('data', d => { serverOutput += d.toString(); });
76
+
77
+ let repo = null;
78
+ try {
79
+ await waitForServer();
80
+ let r = await json('PUT', '/api/knowledge-store/config', { rootPath: kbRoot, git: { enabled: false } });
81
+ assert(r.res.ok, 'knowledge-store config should save');
82
+ r = await json('PUT', '/api/logging/config', { rootPath: logsRoot, retentionDays: 14 });
83
+ assert(r.res.ok, 'logging config should save');
84
+
85
+ repo = makeRepo({ kind: 'feature-commit' });
86
+ r = await json('PUT', '/api/projects', {
87
+ slug: SLUG,
88
+ config: {
89
+ displayName: 'TASK 015-020 Temp',
90
+ localPath: repo.path,
91
+ gitPath: repo.path,
92
+ aiProfileId: 'mock-agent',
93
+ goalStatus: 'accepted',
94
+ kbSchemaVersion: 'v3',
95
+ },
96
+ });
97
+ assert(r.res.ok, 'project import should succeed');
98
+
99
+ r = await json('POST', `/api/projects/${SLUG}/init`);
100
+ assert(r.res.ok && r.data.kbSchemaVersion === 'v3', 'init should create v3 KB');
101
+ const projects = JSON.parse(fs.readFileSync(PROJECTS_JSON, 'utf-8'));
102
+ const kbPath = projects[SLUG].kbPath;
103
+ const top = fs.readdirSync(kbPath).filter(name => !name.startsWith('.')).sort();
104
+ assert(JSON.stringify(top) === JSON.stringify(['ARCHITECTURE.md', 'GOAL.md', 'README.md', 'changes', 'modules']), `unexpected v3 top-level: ${top.join(', ')}`);
105
+ assert(!fs.existsSync(path.join(kbPath, '_ai')), 'v3 KB must not contain _ai');
106
+ assert(!fs.existsSync(path.join(kbPath, 'kb-manifest.json')), 'v3 KB must not contain manifest');
107
+
108
+ r = await json('POST', `/api/projects/${SLUG}/validate-kb`);
109
+ assert(r.res.ok && r.data.ok, `v3 validate should pass: ${JSON.stringify(r.data)}`);
110
+ r = await json('GET', `/api/projects/${SLUG}/pr-context`);
111
+ assert(r.res.ok && r.data.pack && r.data.pack.goal.path === 'GOAL.md', 'PR context should read V3 GOAL');
112
+
113
+ r = await json('POST', `/api/projects/${SLUG}/analyze/commits`);
114
+ assert(r.res.ok, `commit analysis should pass: ${JSON.stringify(r.data)}`);
115
+ const runId = r.data.runId;
116
+ assert(r.data.run.sourceBranch !== undefined, 'run should include sourceBranch key');
117
+ assert(r.data.run.sourceDefaultBranch !== undefined, 'run should include sourceDefaultBranch key');
118
+ assert(r.data.run.sourceRemote !== undefined, 'run should include sourceRemote key');
119
+ assert(fs.existsSync(path.join(aiRoot, SLUG, 'runs', `${runId}.json`)), 'run should live in _site/_ai/<slug>');
120
+ assert(fs.existsSync(path.join(aiRoot, SLUG, 'context-packs', runId, 'context-pack.json')), 'context pack should live in _site/_ai/<slug>');
121
+
122
+ const branch = r.data.run.sourceBranch || 'unknown (pre-TASK-016)';
123
+ r = await json('GET', `/api/projects/${SLUG}/drafts-by-branch?branch=${encodeURIComponent(branch)}`);
124
+ assert(r.res.ok && r.data.drafts.length > 0, 'drafts-by-branch should return branch drafts');
125
+ assert(r.data.drafts.every(d => d.sourceBranch === branch), 'drafts-by-branch should filter by branch');
126
+
127
+ const payloads = [];
128
+ for (const d of r.data.drafts.filter(d => d.path.startsWith('changes/'))) {
129
+ const raw = await json('GET', `/api/projects/${SLUG}/drafts/${d.runId}/raw?path=${encodeURIComponent(d.path)}`);
130
+ assert(raw.res.ok, `raw draft should load: ${d.path}`);
131
+ payloads.push({ path: d.path, content: raw.data.content, sourceBranch: d.sourceBranch, sourceHeadCommit: d.sourceHeadCommit });
132
+ }
133
+ assert(payloads.length > 0, 'should have changes draft payloads');
134
+ r = await json('POST', `/api/projects/${SLUG}/drafts/${runId}/apply`, { drafts: payloads, allowGoalEdit: false });
135
+ assert(r.res.ok && r.data.applied.length === payloads.length, 'V3 changes drafts should auto-apply');
136
+ const applied = fs.readFileSync(path.join(kbPath, payloads[0].path), 'utf-8');
137
+ assert(applied.includes('sourceBranch:'), 'applied V3 doc should include sourceBranch');
138
+ assert(fs.existsSync(path.join(kbPath, 'changes', '00-index.md')), 'changes index should regenerate');
139
+
140
+ r = await json('POST', `/api/projects/${SLUG}/drafts/${runId}/apply`, {
141
+ drafts: [{ path: 'GOAL.md', content: '# bad silent goal edit' }],
142
+ allowGoalEdit: false,
143
+ });
144
+ assert(!r.res.ok && r.res.status === 409, 'GOAL.md apply should require review');
145
+
146
+ const legacyKb = path.join(kbRoot, LEGACY_SLUG);
147
+ fs.mkdirSync(path.join(legacyKb, '_ai', 'runs'), { recursive: true });
148
+ fs.mkdirSync(path.join(legacyKb, 'commits'), { recursive: true });
149
+ fs.writeFileSync(path.join(legacyKb, 'README.md'), '# legacy\n');
150
+ fs.writeFileSync(path.join(legacyKb, 'project-goal.md'), '# legacy goal\n');
151
+ fs.writeFileSync(path.join(legacyKb, '_ai', 'runs', 'old.json'), '{"schema":"ai-run/v1"}\n');
152
+ for (let i = 0; i < 6; i++) fs.writeFileSync(path.join(legacyKb, 'commits', `2026-01-0${i + 1}_aaaaaa${i}_test.md`), `# commit ${i}\n`);
153
+ r = await json('PUT', '/api/projects', { slug: LEGACY_SLUG, config: { displayName: 'legacy', localPath: repo.path, gitPath: repo.path, kbPath: legacyKb, kbSchemaVersion: 'v2' } });
154
+ assert(r.res.ok, 'legacy project import should succeed');
155
+ r = await json('POST', `/api/projects/${LEGACY_SLUG}/migrate-v3`);
156
+ assert(r.res.ok, `migrate-v3 should pass: ${JSON.stringify(r.data)}`);
157
+ assert(!fs.existsSync(path.join(legacyKb, '_ai')), 'migrate-v3 should remove KB-local _ai');
158
+ assert(!fs.existsSync(path.join(legacyKb, 'commits')), 'migrate-v3 should remove legacy commits dir');
159
+ assert(fs.existsSync(path.join(aiRoot, LEGACY_SLUG, 'legacy-commits')), 'legacy commits should be backed up to app AI workspace');
160
+ assert(fs.readdirSync(path.join(legacyKb, 'changes')).some(name => /^legacy-change-/.test(name)), 'legacy commits should consolidate into changes');
161
+
162
+ r = await json('GET', `/api/projects/${SLUG}/remove-preview`);
163
+ assert(r.res.ok && r.data.preview.fileCount > 0, 'remove preview should include file count');
164
+ r = await json('POST', `/api/projects/${SLUG}/remove`, { deleteKb: false, reason: 'integration soft remove' });
165
+ assert(r.res.ok && r.data.removedKb === false, 'soft remove should not delete KB');
166
+ assert(fs.existsSync(kbPath), 'soft remove should keep KB on disk');
167
+ r = await json('PUT', '/api/projects', { slug: SLUG, config: { displayName: 'reimport', localPath: repo.path, gitPath: repo.path, kbPath, kbSchemaVersion: 'v3' } });
168
+ assert(r.res.ok, 'reimport after soft remove should work');
169
+ r = await json('POST', `/api/projects/${SLUG}/remove`, { deleteKb: true, reason: 'integration hard remove' });
170
+ assert(r.res.ok && r.data.removedKb === true, 'hard remove should delete KB');
171
+ assert(!fs.existsSync(kbPath), 'hard remove should remove KB path');
172
+
173
+ const logFiles = fs.readdirSync(logsRoot).filter(name => name.endsWith('.log'));
174
+ assert(logFiles.length > 0, 'structured log should be written');
175
+ const logText = logFiles.map(file => fs.readFileSync(path.join(logsRoot, file), 'utf-8')).join('\n');
176
+ assert(logText.includes('project_removed'), 'project removal should be logged');
177
+
178
+ console.log('TASK-015..020 integration test passed');
179
+ } catch (e) {
180
+ console.error('TASK-015..020 integration test failed:', e.message);
181
+ if (serverOutput) console.error(serverOutput);
182
+ process.exitCode = 1;
183
+ } finally {
184
+ child.kill();
185
+ await sleep(500);
186
+ try { if (repo && repo.cleanup) repo.cleanup(); } catch {}
187
+ restore(PROJECTS_JSON, projectsBackup);
188
+ restore(KNOWLEDGE_STORE_JSON, storeBackup);
189
+ restore(LOGGING_JSON, loggingBackup);
190
+ fs.rmSync(path.join(aiRoot, SLUG), { recursive: true, force: true });
191
+ fs.rmSync(path.join(aiRoot, LEGACY_SLUG), { recursive: true, force: true });
192
+ await rmWithRetry(TEMP_ROOT).catch(() => {});
193
+ }
194
+ })();