@nahisaho/shikigami 1.22.0 → 1.23.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.
- package/CHANGELOG.md +45 -0
- package/mcp-server/package.json +1 -1
- package/mcp-server/src/index.ts +257 -0
- package/mcp-server/src/tools/__tests__/project.test.ts +215 -0
- package/mcp-server/src/tools/__tests__/save.test.ts +233 -0
- package/mcp-server/src/tools/project.ts +211 -0
- package/mcp-server/src/tools/save.ts +297 -0
- package/package.json +1 -1
- package/scripts/cli.js +11 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,51 @@ All notable changes to SHIKIGAMI will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [1.23.0] - 2026-01-27
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- **MCPツール: プロジェクトコンテキスト管理**
|
|
13
|
+
- `set_project`: アクティブなプロジェクトディレクトリを設定
|
|
14
|
+
- `projectPath`: 手動でプロジェクトパスを指定
|
|
15
|
+
- `autoDetect`: projects/内の最新プロジェクトを自動検出
|
|
16
|
+
- `get_project`: 現在のプロジェクト情報を取得
|
|
17
|
+
|
|
18
|
+
- **MCPツール: ファイル永続化機能**
|
|
19
|
+
- `save_prompt`: プロンプトをprompts/に保存
|
|
20
|
+
- タイプ: original(元プロンプト), structured(構造化後), refinement(修正)
|
|
21
|
+
- 自動フロントマター付与(timestamp, project_id, type)
|
|
22
|
+
- `save_research`: 検索結果をresearch/に保存
|
|
23
|
+
- ソース種別: search(Web検索), visit(ページ訪問), manual(手動)
|
|
24
|
+
- Markdown/JSON両形式対応
|
|
25
|
+
- クエリ情報をメタデータとして保存
|
|
26
|
+
|
|
27
|
+
- **新規モジュール**
|
|
28
|
+
- `tools/project.ts`: プロジェクト管理機能(検証、パス解決、自動検出)
|
|
29
|
+
- `tools/save.ts`: ファイル保存機能(プロンプト、リサーチ)
|
|
30
|
+
|
|
31
|
+
- **CLIコマンド: upgrade/update**
|
|
32
|
+
- `npx shikigami upgrade` または `npx shikigami update` で最新版にアップグレード
|
|
33
|
+
- 内部的に `npx shikigami init --force` と同じ動作
|
|
34
|
+
- 既存ファイルを上書きしてSHIKIGAMIを更新
|
|
35
|
+
|
|
36
|
+
### Fixed
|
|
37
|
+
|
|
38
|
+
- **プロンプトがprompts/に保存されない問題を修正**
|
|
39
|
+
- 原因: MCPサーバーにファイル書き込みツールが存在しなかった
|
|
40
|
+
- 対策: `save_prompt`ツールを実装
|
|
41
|
+
|
|
42
|
+
- **検索結果がresearch/に保存されない問題を修正**
|
|
43
|
+
- 原因: searchツールは結果を返すだけで永続化機能がなかった
|
|
44
|
+
- 対策: `save_research`ツールを実装
|
|
45
|
+
|
|
46
|
+
### Changed
|
|
47
|
+
|
|
48
|
+
- **index.ts**: 新規ツール定義(4ツール)とハンドラー追加
|
|
49
|
+
- **MCPサーバー**: ツール総数 7→11 に増加
|
|
50
|
+
|
|
51
|
+
---
|
|
52
|
+
|
|
8
53
|
## [1.22.0] - 2026-01-27
|
|
9
54
|
|
|
10
55
|
### Added
|
package/mcp-server/package.json
CHANGED
package/mcp-server/src/index.ts
CHANGED
|
@@ -27,9 +27,131 @@ import {
|
|
|
27
27
|
type SimilarityResult,
|
|
28
28
|
type SearchResult as SemanticSearchResult,
|
|
29
29
|
} from './tools/embedding.js';
|
|
30
|
+
import {
|
|
31
|
+
setActiveProject,
|
|
32
|
+
getProjectInfo,
|
|
33
|
+
detectLatestProject,
|
|
34
|
+
} from './tools/project.js';
|
|
35
|
+
import {
|
|
36
|
+
savePrompt,
|
|
37
|
+
saveResearch,
|
|
38
|
+
saveResearchJson,
|
|
39
|
+
type SavePromptOptions,
|
|
40
|
+
type SaveResearchOptions,
|
|
41
|
+
} from './tools/save.js';
|
|
30
42
|
|
|
31
43
|
// Tool definitions
|
|
32
44
|
const TOOLS: Tool[] = [
|
|
45
|
+
// Project Management Tools (v1.23.0)
|
|
46
|
+
{
|
|
47
|
+
name: 'set_project',
|
|
48
|
+
description: `アクティブなプロジェクトディレクトリを設定。
|
|
49
|
+
save_prompt, save_researchの前に必ず実行。
|
|
50
|
+
プロジェクトパスはnpx shikigami newで作成されたフォルダを指定。`,
|
|
51
|
+
inputSchema: {
|
|
52
|
+
type: 'object',
|
|
53
|
+
properties: {
|
|
54
|
+
projectPath: {
|
|
55
|
+
type: 'string',
|
|
56
|
+
description: 'プロジェクトディレクトリの絶対パスまたは相対パス(例: projects/pj00001_MyProject_20260127)',
|
|
57
|
+
},
|
|
58
|
+
autoDetect: {
|
|
59
|
+
type: 'boolean',
|
|
60
|
+
default: false,
|
|
61
|
+
description: 'trueの場合、projects/内の最新プロジェクトを自動検出',
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
required: [],
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
name: 'get_project',
|
|
69
|
+
description: `現在のアクティブなプロジェクト情報を取得。
|
|
70
|
+
プロジェクトID、パス、各ディレクトリの情報を返却。`,
|
|
71
|
+
inputSchema: {
|
|
72
|
+
type: 'object',
|
|
73
|
+
properties: {},
|
|
74
|
+
required: [],
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
name: 'save_prompt',
|
|
79
|
+
description: `プロンプトをプロジェクトのprompts/ディレクトリに保存。
|
|
80
|
+
元のプロンプト、構造化プロンプト、修正履歴を記録。
|
|
81
|
+
set_projectでプロジェクトを設定してから使用。`,
|
|
82
|
+
inputSchema: {
|
|
83
|
+
type: 'object',
|
|
84
|
+
properties: {
|
|
85
|
+
content: {
|
|
86
|
+
type: 'string',
|
|
87
|
+
description: '保存するプロンプト内容',
|
|
88
|
+
},
|
|
89
|
+
type: {
|
|
90
|
+
type: 'string',
|
|
91
|
+
enum: ['original', 'structured', 'refinement'],
|
|
92
|
+
description: 'プロンプトの種類(original: 元のプロンプト, structured: 構造化後, refinement: 修正)',
|
|
93
|
+
},
|
|
94
|
+
filename: {
|
|
95
|
+
type: 'string',
|
|
96
|
+
description: 'ファイル名(省略時は自動生成)',
|
|
97
|
+
},
|
|
98
|
+
version: {
|
|
99
|
+
type: 'number',
|
|
100
|
+
description: '修正バージョン番号(refinementタイプの場合)',
|
|
101
|
+
},
|
|
102
|
+
metadata: {
|
|
103
|
+
type: 'object',
|
|
104
|
+
description: '追加のメタデータ',
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
required: ['content', 'type'],
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
name: 'save_research',
|
|
112
|
+
description: `検索結果や調査内容をプロジェクトのresearch/ディレクトリに保存。
|
|
113
|
+
Web検索結果、ページ訪問結果、手動メモを記録。
|
|
114
|
+
set_projectでプロジェクトを設定してから使用。`,
|
|
115
|
+
inputSchema: {
|
|
116
|
+
type: 'object',
|
|
117
|
+
properties: {
|
|
118
|
+
content: {
|
|
119
|
+
type: 'string',
|
|
120
|
+
description: '保存する調査内容(Markdown形式推奨)',
|
|
121
|
+
},
|
|
122
|
+
query: {
|
|
123
|
+
type: 'string',
|
|
124
|
+
description: '検索クエリ(ファイル名に使用)',
|
|
125
|
+
},
|
|
126
|
+
source: {
|
|
127
|
+
type: 'string',
|
|
128
|
+
enum: ['search', 'visit', 'manual'],
|
|
129
|
+
default: 'manual',
|
|
130
|
+
description: '情報ソース(search: Web検索, visit: ページ訪問, manual: 手動入力)',
|
|
131
|
+
},
|
|
132
|
+
filename: {
|
|
133
|
+
type: 'string',
|
|
134
|
+
description: 'ファイル名(省略時は自動生成)',
|
|
135
|
+
},
|
|
136
|
+
format: {
|
|
137
|
+
type: 'string',
|
|
138
|
+
enum: ['markdown', 'json'],
|
|
139
|
+
default: 'markdown',
|
|
140
|
+
description: '保存形式',
|
|
141
|
+
},
|
|
142
|
+
data: {
|
|
143
|
+
type: 'object',
|
|
144
|
+
description: 'JSON形式で保存する場合のデータ(format=jsonの場合)',
|
|
145
|
+
},
|
|
146
|
+
metadata: {
|
|
147
|
+
type: 'object',
|
|
148
|
+
description: '追加のメタデータ',
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
required: ['content'],
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
// Search & Visit Tools
|
|
33
155
|
{
|
|
34
156
|
name: 'search',
|
|
35
157
|
description: `バッチWeb検索を実行(DuckDuckGo使用)。
|
|
@@ -244,6 +366,141 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
|
|
|
244
366
|
|
|
245
367
|
try {
|
|
246
368
|
switch (name) {
|
|
369
|
+
// Project Management Handlers (v1.23.0)
|
|
370
|
+
case 'set_project': {
|
|
371
|
+
const projectPath = args?.projectPath as string | undefined;
|
|
372
|
+
const autoDetect = args?.autoDetect as boolean | undefined;
|
|
373
|
+
|
|
374
|
+
let targetPath: string;
|
|
375
|
+
|
|
376
|
+
if (autoDetect) {
|
|
377
|
+
const detected = detectLatestProject();
|
|
378
|
+
if (!detected) {
|
|
379
|
+
return {
|
|
380
|
+
content: [
|
|
381
|
+
{
|
|
382
|
+
type: 'text',
|
|
383
|
+
text: JSON.stringify({
|
|
384
|
+
success: false,
|
|
385
|
+
error: 'No project found in projects/ directory. Create one with: npx shikigami new <ProjectName>',
|
|
386
|
+
}, null, 2),
|
|
387
|
+
},
|
|
388
|
+
],
|
|
389
|
+
isError: true,
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
targetPath = detected;
|
|
393
|
+
} else if (projectPath) {
|
|
394
|
+
targetPath = projectPath;
|
|
395
|
+
} else {
|
|
396
|
+
return {
|
|
397
|
+
content: [
|
|
398
|
+
{
|
|
399
|
+
type: 'text',
|
|
400
|
+
text: JSON.stringify({
|
|
401
|
+
success: false,
|
|
402
|
+
error: 'Either projectPath or autoDetect=true is required',
|
|
403
|
+
}, null, 2),
|
|
404
|
+
},
|
|
405
|
+
],
|
|
406
|
+
isError: true,
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const project = setActiveProject(targetPath);
|
|
411
|
+
return {
|
|
412
|
+
content: [
|
|
413
|
+
{
|
|
414
|
+
type: 'text',
|
|
415
|
+
text: JSON.stringify({
|
|
416
|
+
success: true,
|
|
417
|
+
message: `Active project set to: ${project.projectPath}`,
|
|
418
|
+
project: getProjectInfo(),
|
|
419
|
+
}, null, 2),
|
|
420
|
+
},
|
|
421
|
+
],
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
case 'get_project': {
|
|
426
|
+
return {
|
|
427
|
+
content: [
|
|
428
|
+
{
|
|
429
|
+
type: 'text',
|
|
430
|
+
text: JSON.stringify(getProjectInfo(), null, 2),
|
|
431
|
+
},
|
|
432
|
+
],
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
case 'save_prompt': {
|
|
437
|
+
const content = args?.content as string;
|
|
438
|
+
const type = args?.type as 'original' | 'structured' | 'refinement';
|
|
439
|
+
const filename = args?.filename as string | undefined;
|
|
440
|
+
const version = args?.version as number | undefined;
|
|
441
|
+
const metadata = args?.metadata as Record<string, unknown> | undefined;
|
|
442
|
+
|
|
443
|
+
const result = await savePrompt(content, {
|
|
444
|
+
type,
|
|
445
|
+
filename,
|
|
446
|
+
version,
|
|
447
|
+
metadata,
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
return {
|
|
451
|
+
content: [
|
|
452
|
+
{
|
|
453
|
+
type: 'text',
|
|
454
|
+
text: JSON.stringify(result, null, 2),
|
|
455
|
+
},
|
|
456
|
+
],
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
case 'save_research': {
|
|
461
|
+
const content = args?.content as string;
|
|
462
|
+
const query = args?.query as string | undefined;
|
|
463
|
+
const source = args?.source as 'search' | 'visit' | 'manual' | undefined;
|
|
464
|
+
const filename = args?.filename as string | undefined;
|
|
465
|
+
const format = args?.format as 'markdown' | 'json' | undefined;
|
|
466
|
+
const data = args?.data as unknown;
|
|
467
|
+
const metadata = args?.metadata as Record<string, unknown> | undefined;
|
|
468
|
+
|
|
469
|
+
if (format === 'json' && data) {
|
|
470
|
+
const result = await saveResearchJson(data, {
|
|
471
|
+
query,
|
|
472
|
+
source,
|
|
473
|
+
filename,
|
|
474
|
+
metadata,
|
|
475
|
+
});
|
|
476
|
+
return {
|
|
477
|
+
content: [
|
|
478
|
+
{
|
|
479
|
+
type: 'text',
|
|
480
|
+
text: JSON.stringify(result, null, 2),
|
|
481
|
+
},
|
|
482
|
+
],
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
const result = await saveResearch(content, {
|
|
487
|
+
query,
|
|
488
|
+
source,
|
|
489
|
+
filename,
|
|
490
|
+
metadata,
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
return {
|
|
494
|
+
content: [
|
|
495
|
+
{
|
|
496
|
+
type: 'text',
|
|
497
|
+
text: JSON.stringify(result, null, 2),
|
|
498
|
+
},
|
|
499
|
+
],
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// Search & Visit Handlers
|
|
247
504
|
case 'search': {
|
|
248
505
|
const queryInput = args?.query as string | string[];
|
|
249
506
|
const maxResults = (args?.maxResults as number) ?? 10;
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project Management Module Tests
|
|
3
|
+
* v1.23.0 - REQ-SHIKIGAMI-016
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
7
|
+
import * as fs from 'fs';
|
|
8
|
+
import * as path from 'path';
|
|
9
|
+
import {
|
|
10
|
+
setActiveProject,
|
|
11
|
+
getActiveProject,
|
|
12
|
+
hasActiveProject,
|
|
13
|
+
clearActiveProject,
|
|
14
|
+
getProjectSubdirectory,
|
|
15
|
+
getProjectInfo,
|
|
16
|
+
isValidProjectDirectory,
|
|
17
|
+
detectLatestProject,
|
|
18
|
+
} from '../project.js';
|
|
19
|
+
|
|
20
|
+
// Mock fs module
|
|
21
|
+
vi.mock('fs');
|
|
22
|
+
|
|
23
|
+
describe('Project Management Module', () => {
|
|
24
|
+
const mockProjectPath = '/test/projects/pj00001_TestProject_20260127';
|
|
25
|
+
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
clearActiveProject();
|
|
28
|
+
vi.resetAllMocks();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
afterEach(() => {
|
|
32
|
+
clearActiveProject();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe('isValidProjectDirectory', () => {
|
|
36
|
+
it('should return true for valid project directory', () => {
|
|
37
|
+
vi.mocked(fs.existsSync).mockImplementation((p) => {
|
|
38
|
+
const pathStr = String(p);
|
|
39
|
+
return (
|
|
40
|
+
pathStr.includes('prompts') ||
|
|
41
|
+
pathStr.includes('research') ||
|
|
42
|
+
pathStr.includes('reports') ||
|
|
43
|
+
pathStr.includes('manifest.yaml')
|
|
44
|
+
);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
expect(isValidProjectDirectory(mockProjectPath)).toBe(true);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('should return false if prompts directory is missing', () => {
|
|
51
|
+
vi.mocked(fs.existsSync).mockImplementation((p) => {
|
|
52
|
+
const pathStr = String(p);
|
|
53
|
+
return !pathStr.includes('prompts');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
expect(isValidProjectDirectory(mockProjectPath)).toBe(false);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('should return false if manifest.yaml is missing', () => {
|
|
60
|
+
vi.mocked(fs.existsSync).mockImplementation((p) => {
|
|
61
|
+
const pathStr = String(p);
|
|
62
|
+
return (
|
|
63
|
+
pathStr.includes('prompts') ||
|
|
64
|
+
pathStr.includes('research') ||
|
|
65
|
+
pathStr.includes('reports')
|
|
66
|
+
);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
expect(isValidProjectDirectory(mockProjectPath)).toBe(false);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe('setActiveProject', () => {
|
|
74
|
+
it('should set active project successfully', () => {
|
|
75
|
+
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
76
|
+
|
|
77
|
+
const project = setActiveProject(mockProjectPath);
|
|
78
|
+
|
|
79
|
+
expect(project.projectPath).toBe(mockProjectPath);
|
|
80
|
+
expect(project.projectId).toBe('pj00001');
|
|
81
|
+
expect(project.projectName).toBe('TestProject');
|
|
82
|
+
expect(project.promptsDir).toBe(path.join(mockProjectPath, 'prompts'));
|
|
83
|
+
expect(project.researchDir).toBe(path.join(mockProjectPath, 'research'));
|
|
84
|
+
expect(project.reportsDir).toBe(path.join(mockProjectPath, 'reports'));
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('should throw error if directory does not exist', () => {
|
|
88
|
+
vi.mocked(fs.existsSync).mockReturnValue(false);
|
|
89
|
+
|
|
90
|
+
expect(() => setActiveProject('/nonexistent')).toThrow(
|
|
91
|
+
'Project directory not found'
|
|
92
|
+
);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('should throw error if directory is invalid', () => {
|
|
96
|
+
vi.mocked(fs.existsSync).mockImplementation((p) => {
|
|
97
|
+
const pathStr = String(p);
|
|
98
|
+
// Path exists but subdirectories don't
|
|
99
|
+
return pathStr === mockProjectPath;
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
expect(() => setActiveProject(mockProjectPath)).toThrow(
|
|
103
|
+
'Invalid project directory'
|
|
104
|
+
);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe('getActiveProject', () => {
|
|
109
|
+
it('should return null when no project is active', () => {
|
|
110
|
+
expect(getActiveProject()).toBeNull();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('should return active project when set', () => {
|
|
114
|
+
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
115
|
+
setActiveProject(mockProjectPath);
|
|
116
|
+
|
|
117
|
+
const project = getActiveProject();
|
|
118
|
+
expect(project).not.toBeNull();
|
|
119
|
+
expect(project?.projectId).toBe('pj00001');
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
describe('hasActiveProject', () => {
|
|
124
|
+
it('should return false when no project is active', () => {
|
|
125
|
+
expect(hasActiveProject()).toBe(false);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('should return true when project is active', () => {
|
|
129
|
+
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
130
|
+
setActiveProject(mockProjectPath);
|
|
131
|
+
|
|
132
|
+
expect(hasActiveProject()).toBe(true);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
describe('clearActiveProject', () => {
|
|
137
|
+
it('should clear active project', () => {
|
|
138
|
+
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
139
|
+
setActiveProject(mockProjectPath);
|
|
140
|
+
expect(hasActiveProject()).toBe(true);
|
|
141
|
+
|
|
142
|
+
clearActiveProject();
|
|
143
|
+
expect(hasActiveProject()).toBe(false);
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
describe('getProjectSubdirectory', () => {
|
|
148
|
+
it('should throw error when no project is active', () => {
|
|
149
|
+
expect(() => getProjectSubdirectory('prompts')).toThrow(
|
|
150
|
+
'No active project'
|
|
151
|
+
);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('should return correct subdirectory path', () => {
|
|
155
|
+
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
156
|
+
setActiveProject(mockProjectPath);
|
|
157
|
+
|
|
158
|
+
expect(getProjectSubdirectory('prompts')).toBe(
|
|
159
|
+
path.join(mockProjectPath, 'prompts')
|
|
160
|
+
);
|
|
161
|
+
expect(getProjectSubdirectory('research')).toBe(
|
|
162
|
+
path.join(mockProjectPath, 'research')
|
|
163
|
+
);
|
|
164
|
+
expect(getProjectSubdirectory('reports')).toBe(
|
|
165
|
+
path.join(mockProjectPath, 'reports')
|
|
166
|
+
);
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
describe('getProjectInfo', () => {
|
|
171
|
+
it('should return inactive status when no project is active', () => {
|
|
172
|
+
const info = getProjectInfo();
|
|
173
|
+
expect(info.active).toBe(false);
|
|
174
|
+
expect(info.message).toContain('No active project');
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('should return full project info when active', () => {
|
|
178
|
+
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
179
|
+
setActiveProject(mockProjectPath);
|
|
180
|
+
|
|
181
|
+
const info = getProjectInfo();
|
|
182
|
+
expect(info.active).toBe(true);
|
|
183
|
+
expect(info.projectId).toBe('pj00001');
|
|
184
|
+
expect(info.projectName).toBe('TestProject');
|
|
185
|
+
expect(info.directories).toBeDefined();
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
describe('detectLatestProject', () => {
|
|
190
|
+
it('should return null if projects directory does not exist', () => {
|
|
191
|
+
vi.mocked(fs.existsSync).mockReturnValue(false);
|
|
192
|
+
|
|
193
|
+
expect(detectLatestProject('/test')).toBeNull();
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('should return latest project path', () => {
|
|
197
|
+
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
198
|
+
vi.mocked(fs.readdirSync).mockReturnValue([
|
|
199
|
+
{ name: 'pj00001_Old_20260101', isDirectory: () => true },
|
|
200
|
+
{ name: 'pj00002_New_20260127', isDirectory: () => true },
|
|
201
|
+
{ name: '.hidden', isDirectory: () => true },
|
|
202
|
+
] as unknown as fs.Dirent[]);
|
|
203
|
+
vi.mocked(fs.statSync).mockImplementation((p) => {
|
|
204
|
+
const pathStr = String(p);
|
|
205
|
+
if (pathStr.includes('pj00002')) {
|
|
206
|
+
return { mtime: new Date('2026-01-27') } as fs.Stats;
|
|
207
|
+
}
|
|
208
|
+
return { mtime: new Date('2026-01-01') } as fs.Stats;
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
const result = detectLatestProject('/test');
|
|
212
|
+
expect(result).toContain('pj00002_New_20260127');
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
});
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Save Module Tests
|
|
3
|
+
* v1.23.0 - REQ-SHIKIGAMI-016
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
7
|
+
import * as fs from 'fs';
|
|
8
|
+
import * as path from 'path';
|
|
9
|
+
import {
|
|
10
|
+
savePrompt,
|
|
11
|
+
saveResearch,
|
|
12
|
+
saveResearchJson,
|
|
13
|
+
saveToProject,
|
|
14
|
+
} from '../save.js';
|
|
15
|
+
import {
|
|
16
|
+
setActiveProject,
|
|
17
|
+
clearActiveProject,
|
|
18
|
+
} from '../project.js';
|
|
19
|
+
|
|
20
|
+
// Mock fs module
|
|
21
|
+
vi.mock('fs');
|
|
22
|
+
|
|
23
|
+
// Mock project module to provide controlled active project
|
|
24
|
+
vi.mock('../project.js', async () => {
|
|
25
|
+
const actual = await vi.importActual<typeof import('../project.js')>('../project.js');
|
|
26
|
+
return {
|
|
27
|
+
...actual,
|
|
28
|
+
hasActiveProject: vi.fn(),
|
|
29
|
+
getActiveProject: vi.fn(),
|
|
30
|
+
};
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
import { hasActiveProject, getActiveProject } from '../project.js';
|
|
34
|
+
|
|
35
|
+
describe('Save Module', () => {
|
|
36
|
+
const mockProject = {
|
|
37
|
+
projectPath: '/test/projects/pj00001_Test_20260127',
|
|
38
|
+
projectId: 'pj00001',
|
|
39
|
+
projectName: 'Test',
|
|
40
|
+
promptsDir: '/test/projects/pj00001_Test_20260127/prompts',
|
|
41
|
+
researchDir: '/test/projects/pj00001_Test_20260127/research',
|
|
42
|
+
reportsDir: '/test/projects/pj00001_Test_20260127/reports',
|
|
43
|
+
manifestPath: '/test/projects/pj00001_Test_20260127/manifest.yaml',
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
beforeEach(() => {
|
|
47
|
+
vi.resetAllMocks();
|
|
48
|
+
vi.mocked(hasActiveProject).mockReturnValue(true);
|
|
49
|
+
vi.mocked(getActiveProject).mockReturnValue(mockProject);
|
|
50
|
+
vi.mocked(fs.writeFileSync).mockImplementation(() => undefined);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
afterEach(() => {
|
|
54
|
+
vi.resetAllMocks();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe('savePrompt', () => {
|
|
58
|
+
it('should throw error when no active project', async () => {
|
|
59
|
+
vi.mocked(hasActiveProject).mockReturnValue(false);
|
|
60
|
+
|
|
61
|
+
await expect(
|
|
62
|
+
savePrompt('test content', { type: 'original' })
|
|
63
|
+
).rejects.toThrow('No active project');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('should save original prompt with correct format', async () => {
|
|
67
|
+
const result = await savePrompt('My original prompt', {
|
|
68
|
+
type: 'original',
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
expect(result.success).toBe(true);
|
|
72
|
+
expect(result.filePath).toContain('prompts');
|
|
73
|
+
expect(result.filePath).toContain('original-prompt');
|
|
74
|
+
expect(fs.writeFileSync).toHaveBeenCalled();
|
|
75
|
+
|
|
76
|
+
// Check content includes frontmatter
|
|
77
|
+
const writeCall = vi.mocked(fs.writeFileSync).mock.calls[0];
|
|
78
|
+
const content = writeCall[1] as string;
|
|
79
|
+
expect(content).toContain('---');
|
|
80
|
+
expect(content).toContain('type: "original"');
|
|
81
|
+
expect(content).toContain('project_id: "pj00001"');
|
|
82
|
+
expect(content).toContain('My original prompt');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('should save structured prompt with correct format', async () => {
|
|
86
|
+
const result = await savePrompt('Structured prompt content', {
|
|
87
|
+
type: 'structured',
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
expect(result.success).toBe(true);
|
|
91
|
+
expect(result.filePath).toContain('structured-prompt');
|
|
92
|
+
|
|
93
|
+
const writeCall = vi.mocked(fs.writeFileSync).mock.calls[0];
|
|
94
|
+
const content = writeCall[1] as string;
|
|
95
|
+
expect(content).toContain('type: "structured"');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('should save refinement with version number', async () => {
|
|
99
|
+
const result = await savePrompt('Refined prompt', {
|
|
100
|
+
type: 'refinement',
|
|
101
|
+
version: 2,
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
expect(result.success).toBe(true);
|
|
105
|
+
expect(result.filePath).toContain('refinement-2');
|
|
106
|
+
|
|
107
|
+
const writeCall = vi.mocked(fs.writeFileSync).mock.calls[0];
|
|
108
|
+
const content = writeCall[1] as string;
|
|
109
|
+
expect(content).toContain('version: 2');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('should use custom filename when provided', async () => {
|
|
113
|
+
const result = await savePrompt('Content', {
|
|
114
|
+
type: 'original',
|
|
115
|
+
filename: 'custom-name.md',
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
expect(result.filePath).toContain('custom-name');
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe('saveResearch', () => {
|
|
123
|
+
it('should throw error when no active project', async () => {
|
|
124
|
+
vi.mocked(hasActiveProject).mockReturnValue(false);
|
|
125
|
+
|
|
126
|
+
await expect(saveResearch('test content', {})).rejects.toThrow(
|
|
127
|
+
'No active project'
|
|
128
|
+
);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('should save search result with query info', async () => {
|
|
132
|
+
const result = await saveResearch('Search results here', {
|
|
133
|
+
query: 'TypeScript best practices',
|
|
134
|
+
source: 'search',
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
expect(result.success).toBe(true);
|
|
138
|
+
expect(result.filePath).toContain('research');
|
|
139
|
+
expect(result.filePath).toContain('search');
|
|
140
|
+
|
|
141
|
+
const writeCall = vi.mocked(fs.writeFileSync).mock.calls[0];
|
|
142
|
+
const content = writeCall[1] as string;
|
|
143
|
+
expect(content).toContain('source: "search"');
|
|
144
|
+
expect(content).toContain('query: "TypeScript best practices"');
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('should save visit result', async () => {
|
|
148
|
+
const result = await saveResearch('Page content', {
|
|
149
|
+
source: 'visit',
|
|
150
|
+
query: 'example.com',
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
expect(result.success).toBe(true);
|
|
154
|
+
expect(result.filePath).toContain('visit');
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('should save manual notes', async () => {
|
|
158
|
+
const result = await saveResearch('Manual notes', {
|
|
159
|
+
source: 'manual',
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
expect(result.success).toBe(true);
|
|
163
|
+
expect(result.filePath).toContain('manual');
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
describe('saveResearchJson', () => {
|
|
168
|
+
it('should save JSON data with metadata', async () => {
|
|
169
|
+
const data = {
|
|
170
|
+
results: [
|
|
171
|
+
{ title: 'Result 1', url: 'https://example.com/1' },
|
|
172
|
+
{ title: 'Result 2', url: 'https://example.com/2' },
|
|
173
|
+
],
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
const result = await saveResearchJson(data, {
|
|
177
|
+
query: 'test query',
|
|
178
|
+
source: 'search',
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
expect(result.success).toBe(true);
|
|
182
|
+
expect(result.filePath).toContain('.json');
|
|
183
|
+
|
|
184
|
+
const writeCall = vi.mocked(fs.writeFileSync).mock.calls[0];
|
|
185
|
+
const content = writeCall[1] as string;
|
|
186
|
+
const parsed = JSON.parse(content);
|
|
187
|
+
expect(parsed.data).toEqual(data);
|
|
188
|
+
expect(parsed.query).toBe('test query');
|
|
189
|
+
expect(parsed.source).toBe('search');
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
describe('saveToProject', () => {
|
|
194
|
+
it('should save to prompts directory', async () => {
|
|
195
|
+
const result = await saveToProject('content', 'prompts', 'test.md');
|
|
196
|
+
|
|
197
|
+
expect(result.success).toBe(true);
|
|
198
|
+
expect(result.filePath).toBe(
|
|
199
|
+
'/test/projects/pj00001_Test_20260127/prompts/test.md'
|
|
200
|
+
);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('should save to research directory', async () => {
|
|
204
|
+
const result = await saveToProject('content', 'research', 'test.md');
|
|
205
|
+
|
|
206
|
+
expect(result.success).toBe(true);
|
|
207
|
+
expect(result.filePath).toBe(
|
|
208
|
+
'/test/projects/pj00001_Test_20260127/research/test.md'
|
|
209
|
+
);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('should save to reports directory', async () => {
|
|
213
|
+
const result = await saveToProject('content', 'reports', 'test.md');
|
|
214
|
+
|
|
215
|
+
expect(result.success).toBe(true);
|
|
216
|
+
expect(result.filePath).toBe(
|
|
217
|
+
'/test/projects/pj00001_Test_20260127/reports/test.md'
|
|
218
|
+
);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('should sanitize filename', async () => {
|
|
222
|
+
const result = await saveToProject(
|
|
223
|
+
'content',
|
|
224
|
+
'prompts',
|
|
225
|
+
'my file<>:"/\\|?*.md'
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
expect(result.filePath).not.toContain('<');
|
|
229
|
+
expect(result.filePath).not.toContain('>');
|
|
230
|
+
expect(result.filePath).not.toContain(':');
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
});
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project Management Module
|
|
3
|
+
* v1.23.0 - REQ-SHIKIGAMI-016: プロジェクトコンテキスト管理
|
|
4
|
+
*
|
|
5
|
+
* アクティブなプロジェクトディレクトリを管理し、
|
|
6
|
+
* 他のツール(save_prompt, save_research)が使用するパスを提供
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import * as fs from 'fs';
|
|
10
|
+
import * as path from 'path';
|
|
11
|
+
|
|
12
|
+
// アクティブなプロジェクト情報
|
|
13
|
+
interface ActiveProject {
|
|
14
|
+
projectPath: string;
|
|
15
|
+
projectId: string;
|
|
16
|
+
projectName: string;
|
|
17
|
+
promptsDir: string;
|
|
18
|
+
researchDir: string;
|
|
19
|
+
reportsDir: string;
|
|
20
|
+
manifestPath: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// グローバルな現在のプロジェクト
|
|
24
|
+
let currentProject: ActiveProject | null = null;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* プロジェクトディレクトリが有効かどうか検証
|
|
28
|
+
*/
|
|
29
|
+
export function isValidProjectDirectory(projectPath: string): boolean {
|
|
30
|
+
// 必須ディレクトリの存在チェック
|
|
31
|
+
const requiredDirs = ['prompts', 'research', 'reports'];
|
|
32
|
+
const requiredFiles = ['manifest.yaml'];
|
|
33
|
+
|
|
34
|
+
for (const dir of requiredDirs) {
|
|
35
|
+
const dirPath = path.join(projectPath, dir);
|
|
36
|
+
if (!fs.existsSync(dirPath)) {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// manifest.yamlまたはmanifest.ymlの存在チェック
|
|
42
|
+
const manifestYaml = path.join(projectPath, 'manifest.yaml');
|
|
43
|
+
const manifestYml = path.join(projectPath, 'manifest.yml');
|
|
44
|
+
if (!fs.existsSync(manifestYaml) && !fs.existsSync(manifestYml)) {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* プロジェクトパスからプロジェクト情報を抽出
|
|
53
|
+
*/
|
|
54
|
+
function extractProjectInfo(projectPath: string): { projectId: string; projectName: string } {
|
|
55
|
+
const dirName = path.basename(projectPath);
|
|
56
|
+
// pjXXXXX_ProjectName_YYYYMMDD 形式をパース
|
|
57
|
+
const match = dirName.match(/^(pj\d{5})_(.+?)_\d{8}$/);
|
|
58
|
+
|
|
59
|
+
if (match) {
|
|
60
|
+
return {
|
|
61
|
+
projectId: match[1],
|
|
62
|
+
projectName: match[2],
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// フォールバック
|
|
67
|
+
return {
|
|
68
|
+
projectId: dirName,
|
|
69
|
+
projectName: dirName,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* アクティブなプロジェクトを設定
|
|
75
|
+
*/
|
|
76
|
+
export function setActiveProject(projectPath: string): ActiveProject {
|
|
77
|
+
// 絶対パスに変換
|
|
78
|
+
const absolutePath = path.isAbsolute(projectPath)
|
|
79
|
+
? projectPath
|
|
80
|
+
: path.resolve(process.cwd(), projectPath);
|
|
81
|
+
|
|
82
|
+
// パスの存在チェック
|
|
83
|
+
if (!fs.existsSync(absolutePath)) {
|
|
84
|
+
throw new Error(`Project directory not found: ${absolutePath}`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// プロジェクトディレクトリの検証
|
|
88
|
+
if (!isValidProjectDirectory(absolutePath)) {
|
|
89
|
+
throw new Error(
|
|
90
|
+
`Invalid project directory: ${absolutePath}. ` +
|
|
91
|
+
'Expected prompts/, research/, reports/ directories and manifest.yaml'
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const { projectId, projectName } = extractProjectInfo(absolutePath);
|
|
96
|
+
|
|
97
|
+
// manifest.yamlのパスを解決
|
|
98
|
+
const manifestYaml = path.join(absolutePath, 'manifest.yaml');
|
|
99
|
+
const manifestYml = path.join(absolutePath, 'manifest.yml');
|
|
100
|
+
const manifestPath = fs.existsSync(manifestYaml) ? manifestYaml : manifestYml;
|
|
101
|
+
|
|
102
|
+
currentProject = {
|
|
103
|
+
projectPath: absolutePath,
|
|
104
|
+
projectId,
|
|
105
|
+
projectName,
|
|
106
|
+
promptsDir: path.join(absolutePath, 'prompts'),
|
|
107
|
+
researchDir: path.join(absolutePath, 'research'),
|
|
108
|
+
reportsDir: path.join(absolutePath, 'reports'),
|
|
109
|
+
manifestPath,
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
return currentProject;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* 現在のアクティブなプロジェクトを取得
|
|
117
|
+
*/
|
|
118
|
+
export function getActiveProject(): ActiveProject | null {
|
|
119
|
+
return currentProject;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* プロジェクトがアクティブかどうか確認
|
|
124
|
+
*/
|
|
125
|
+
export function hasActiveProject(): boolean {
|
|
126
|
+
return currentProject !== null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* アクティブなプロジェクトをクリア
|
|
131
|
+
*/
|
|
132
|
+
export function clearActiveProject(): void {
|
|
133
|
+
currentProject = null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* プロジェクトの特定ディレクトリパスを取得
|
|
138
|
+
*/
|
|
139
|
+
export function getProjectSubdirectory(
|
|
140
|
+
subdir: 'prompts' | 'research' | 'reports'
|
|
141
|
+
): string {
|
|
142
|
+
if (!currentProject) {
|
|
143
|
+
throw new Error('No active project. Use set_project tool first.');
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
switch (subdir) {
|
|
147
|
+
case 'prompts':
|
|
148
|
+
return currentProject.promptsDir;
|
|
149
|
+
case 'research':
|
|
150
|
+
return currentProject.researchDir;
|
|
151
|
+
case 'reports':
|
|
152
|
+
return currentProject.reportsDir;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* プロジェクト情報をJSON形式で返す(MCPレスポンス用)
|
|
158
|
+
*/
|
|
159
|
+
export function getProjectInfo(): Record<string, unknown> {
|
|
160
|
+
if (!currentProject) {
|
|
161
|
+
return {
|
|
162
|
+
active: false,
|
|
163
|
+
message: 'No active project. Use set_project tool to set one.',
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
active: true,
|
|
169
|
+
projectId: currentProject.projectId,
|
|
170
|
+
projectName: currentProject.projectName,
|
|
171
|
+
projectPath: currentProject.projectPath,
|
|
172
|
+
directories: {
|
|
173
|
+
prompts: currentProject.promptsDir,
|
|
174
|
+
research: currentProject.researchDir,
|
|
175
|
+
reports: currentProject.reportsDir,
|
|
176
|
+
},
|
|
177
|
+
manifestPath: currentProject.manifestPath,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* projects/ディレクトリから最新のプロジェクトを自動検出
|
|
183
|
+
*/
|
|
184
|
+
export function detectLatestProject(basePath?: string): string | null {
|
|
185
|
+
const searchPath = basePath || process.cwd();
|
|
186
|
+
const projectsDir = path.join(searchPath, 'projects');
|
|
187
|
+
|
|
188
|
+
if (!fs.existsSync(projectsDir)) {
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
try {
|
|
193
|
+
const entries = fs.readdirSync(projectsDir, { withFileTypes: true });
|
|
194
|
+
const projects = entries
|
|
195
|
+
.filter((e) => e.isDirectory() && e.name.startsWith('pj'))
|
|
196
|
+
.map((e) => ({
|
|
197
|
+
name: e.name,
|
|
198
|
+
path: path.join(projectsDir, e.name),
|
|
199
|
+
mtime: fs.statSync(path.join(projectsDir, e.name)).mtime,
|
|
200
|
+
}))
|
|
201
|
+
.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
|
|
202
|
+
|
|
203
|
+
if (projects.length > 0) {
|
|
204
|
+
return projects[0].path;
|
|
205
|
+
}
|
|
206
|
+
} catch {
|
|
207
|
+
// Ignore errors
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File Save Module
|
|
3
|
+
* v1.23.0 - REQ-SHIKIGAMI-016: プロンプト・検索結果の永続化
|
|
4
|
+
*
|
|
5
|
+
* プロンプトをprompts/に、検索結果をresearch/に保存する機能
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import * as fs from 'fs';
|
|
9
|
+
import * as path from 'path';
|
|
10
|
+
import { getActiveProject, hasActiveProject } from './project.js';
|
|
11
|
+
|
|
12
|
+
// 保存結果の型定義
|
|
13
|
+
export interface SaveResult {
|
|
14
|
+
success: boolean;
|
|
15
|
+
filePath: string;
|
|
16
|
+
message: string;
|
|
17
|
+
timestamp: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// プロンプト保存オプション
|
|
21
|
+
export interface SavePromptOptions {
|
|
22
|
+
filename?: string;
|
|
23
|
+
type: 'original' | 'structured' | 'refinement';
|
|
24
|
+
version?: number;
|
|
25
|
+
metadata?: Record<string, unknown>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// リサーチ保存オプション
|
|
29
|
+
export interface SaveResearchOptions {
|
|
30
|
+
filename?: string;
|
|
31
|
+
query?: string;
|
|
32
|
+
source?: 'search' | 'visit' | 'manual';
|
|
33
|
+
metadata?: Record<string, unknown>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 現在のタイムスタンプを取得
|
|
38
|
+
*/
|
|
39
|
+
function getTimestamp(): string {
|
|
40
|
+
return new Date().toISOString();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 日付文字列を取得(ファイル名用)
|
|
45
|
+
*/
|
|
46
|
+
function getDateString(): string {
|
|
47
|
+
return new Date().toISOString().split('T')[0].replace(/-/g, '');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 時刻文字列を取得(ファイル名用)
|
|
52
|
+
*/
|
|
53
|
+
function getTimeString(): string {
|
|
54
|
+
return new Date().toISOString().split('T')[1].split('.')[0].replace(/:/g, '');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* ファイル名をサニタイズ
|
|
59
|
+
*/
|
|
60
|
+
function sanitizeFilename(name: string): string {
|
|
61
|
+
return name
|
|
62
|
+
.replace(/[<>:"/\\|?*]/g, '_')
|
|
63
|
+
.replace(/\s+/g, '-')
|
|
64
|
+
.slice(0, 100);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* プロンプトを保存
|
|
69
|
+
*/
|
|
70
|
+
export async function savePrompt(
|
|
71
|
+
content: string,
|
|
72
|
+
options: SavePromptOptions
|
|
73
|
+
): Promise<SaveResult> {
|
|
74
|
+
if (!hasActiveProject()) {
|
|
75
|
+
throw new Error('No active project. Use set_project tool first.');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const project = getActiveProject()!;
|
|
79
|
+
const timestamp = getTimestamp();
|
|
80
|
+
const dateStr = getDateString();
|
|
81
|
+
const timeStr = getTimeString();
|
|
82
|
+
|
|
83
|
+
// ファイル名を決定
|
|
84
|
+
let filename: string;
|
|
85
|
+
if (options.filename) {
|
|
86
|
+
filename = sanitizeFilename(options.filename);
|
|
87
|
+
} else {
|
|
88
|
+
switch (options.type) {
|
|
89
|
+
case 'original':
|
|
90
|
+
filename = `original-prompt_${dateStr}_${timeStr}.md`;
|
|
91
|
+
break;
|
|
92
|
+
case 'structured':
|
|
93
|
+
filename = `structured-prompt_${dateStr}_${timeStr}.md`;
|
|
94
|
+
break;
|
|
95
|
+
case 'refinement':
|
|
96
|
+
filename = `refinement-${options.version || 1}_${dateStr}_${timeStr}.md`;
|
|
97
|
+
break;
|
|
98
|
+
default:
|
|
99
|
+
filename = `prompt_${dateStr}_${timeStr}.md`;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Markdownフロントマター付きのコンテンツを生成
|
|
104
|
+
const frontmatter = {
|
|
105
|
+
timestamp,
|
|
106
|
+
project_id: project.projectId,
|
|
107
|
+
type: options.type,
|
|
108
|
+
...(options.version && { version: options.version }),
|
|
109
|
+
...(options.metadata && { metadata: options.metadata }),
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const fullContent = `---
|
|
113
|
+
timestamp: "${frontmatter.timestamp}"
|
|
114
|
+
project_id: "${frontmatter.project_id}"
|
|
115
|
+
type: "${frontmatter.type}"${frontmatter.version ? `\nversion: ${frontmatter.version}` : ''}
|
|
116
|
+
---
|
|
117
|
+
|
|
118
|
+
${content}
|
|
119
|
+
`;
|
|
120
|
+
|
|
121
|
+
const filePath = path.join(project.promptsDir, filename);
|
|
122
|
+
|
|
123
|
+
try {
|
|
124
|
+
fs.writeFileSync(filePath, fullContent, 'utf-8');
|
|
125
|
+
return {
|
|
126
|
+
success: true,
|
|
127
|
+
filePath,
|
|
128
|
+
message: `Prompt saved successfully to ${filePath}`,
|
|
129
|
+
timestamp,
|
|
130
|
+
};
|
|
131
|
+
} catch (error) {
|
|
132
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
133
|
+
throw new Error(`Failed to save prompt: ${message}`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* 検索結果を保存
|
|
139
|
+
*/
|
|
140
|
+
export async function saveResearch(
|
|
141
|
+
content: string,
|
|
142
|
+
options: SaveResearchOptions
|
|
143
|
+
): Promise<SaveResult> {
|
|
144
|
+
if (!hasActiveProject()) {
|
|
145
|
+
throw new Error('No active project. Use set_project tool first.');
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const project = getActiveProject()!;
|
|
149
|
+
const timestamp = getTimestamp();
|
|
150
|
+
const dateStr = getDateString();
|
|
151
|
+
const timeStr = getTimeString();
|
|
152
|
+
|
|
153
|
+
// ファイル名を決定
|
|
154
|
+
let filename: string;
|
|
155
|
+
if (options.filename) {
|
|
156
|
+
filename = sanitizeFilename(options.filename);
|
|
157
|
+
} else {
|
|
158
|
+
const source = options.source || 'manual';
|
|
159
|
+
const queryPart = options.query
|
|
160
|
+
? `_${sanitizeFilename(options.query).slice(0, 30)}`
|
|
161
|
+
: '';
|
|
162
|
+
filename = `${source}${queryPart}_${dateStr}_${timeStr}.md`;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Markdownフロントマター付きのコンテンツを生成
|
|
166
|
+
const frontmatter = {
|
|
167
|
+
timestamp,
|
|
168
|
+
project_id: project.projectId,
|
|
169
|
+
source: options.source || 'manual',
|
|
170
|
+
...(options.query && { query: options.query }),
|
|
171
|
+
...(options.metadata && { metadata: options.metadata }),
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
const fullContent = `---
|
|
175
|
+
timestamp: "${frontmatter.timestamp}"
|
|
176
|
+
project_id: "${frontmatter.project_id}"
|
|
177
|
+
source: "${frontmatter.source}"${frontmatter.query ? `\nquery: "${frontmatter.query}"` : ''}
|
|
178
|
+
---
|
|
179
|
+
|
|
180
|
+
${content}
|
|
181
|
+
`;
|
|
182
|
+
|
|
183
|
+
const filePath = path.join(project.researchDir, filename);
|
|
184
|
+
|
|
185
|
+
try {
|
|
186
|
+
fs.writeFileSync(filePath, fullContent, 'utf-8');
|
|
187
|
+
return {
|
|
188
|
+
success: true,
|
|
189
|
+
filePath,
|
|
190
|
+
message: `Research saved successfully to ${filePath}`,
|
|
191
|
+
timestamp,
|
|
192
|
+
};
|
|
193
|
+
} catch (error) {
|
|
194
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
195
|
+
throw new Error(`Failed to save research: ${message}`);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* 検索結果をJSON形式で保存
|
|
201
|
+
*/
|
|
202
|
+
export async function saveResearchJson(
|
|
203
|
+
data: unknown,
|
|
204
|
+
options: SaveResearchOptions
|
|
205
|
+
): Promise<SaveResult> {
|
|
206
|
+
if (!hasActiveProject()) {
|
|
207
|
+
throw new Error('No active project. Use set_project tool first.');
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const project = getActiveProject()!;
|
|
211
|
+
const timestamp = getTimestamp();
|
|
212
|
+
const dateStr = getDateString();
|
|
213
|
+
const timeStr = getTimeString();
|
|
214
|
+
|
|
215
|
+
// ファイル名を決定
|
|
216
|
+
let filename: string;
|
|
217
|
+
if (options.filename) {
|
|
218
|
+
filename = sanitizeFilename(options.filename);
|
|
219
|
+
if (!filename.endsWith('.json')) {
|
|
220
|
+
filename += '.json';
|
|
221
|
+
}
|
|
222
|
+
} else {
|
|
223
|
+
const source = options.source || 'manual';
|
|
224
|
+
const queryPart = options.query
|
|
225
|
+
? `_${sanitizeFilename(options.query).slice(0, 30)}`
|
|
226
|
+
: '';
|
|
227
|
+
filename = `${source}${queryPart}_${dateStr}_${timeStr}.json`;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const jsonContent = {
|
|
231
|
+
timestamp,
|
|
232
|
+
project_id: project.projectId,
|
|
233
|
+
source: options.source || 'manual',
|
|
234
|
+
...(options.query && { query: options.query }),
|
|
235
|
+
...(options.metadata && { metadata: options.metadata }),
|
|
236
|
+
data,
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
const filePath = path.join(project.researchDir, filename);
|
|
240
|
+
|
|
241
|
+
try {
|
|
242
|
+
fs.writeFileSync(filePath, JSON.stringify(jsonContent, null, 2), 'utf-8');
|
|
243
|
+
return {
|
|
244
|
+
success: true,
|
|
245
|
+
filePath,
|
|
246
|
+
message: `Research JSON saved successfully to ${filePath}`,
|
|
247
|
+
timestamp,
|
|
248
|
+
};
|
|
249
|
+
} catch (error) {
|
|
250
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
251
|
+
throw new Error(`Failed to save research JSON: ${message}`);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* 汎用ファイル保存(指定ディレクトリに保存)
|
|
257
|
+
*/
|
|
258
|
+
export async function saveToProject(
|
|
259
|
+
content: string,
|
|
260
|
+
subdir: 'prompts' | 'research' | 'reports',
|
|
261
|
+
filename: string
|
|
262
|
+
): Promise<SaveResult> {
|
|
263
|
+
if (!hasActiveProject()) {
|
|
264
|
+
throw new Error('No active project. Use set_project tool first.');
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const project = getActiveProject()!;
|
|
268
|
+
const timestamp = getTimestamp();
|
|
269
|
+
|
|
270
|
+
let targetDir: string;
|
|
271
|
+
switch (subdir) {
|
|
272
|
+
case 'prompts':
|
|
273
|
+
targetDir = project.promptsDir;
|
|
274
|
+
break;
|
|
275
|
+
case 'research':
|
|
276
|
+
targetDir = project.researchDir;
|
|
277
|
+
break;
|
|
278
|
+
case 'reports':
|
|
279
|
+
targetDir = project.reportsDir;
|
|
280
|
+
break;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const filePath = path.join(targetDir, sanitizeFilename(filename));
|
|
284
|
+
|
|
285
|
+
try {
|
|
286
|
+
fs.writeFileSync(filePath, content, 'utf-8');
|
|
287
|
+
return {
|
|
288
|
+
success: true,
|
|
289
|
+
filePath,
|
|
290
|
+
message: `File saved successfully to ${filePath}`,
|
|
291
|
+
timestamp,
|
|
292
|
+
};
|
|
293
|
+
} catch (error) {
|
|
294
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
295
|
+
throw new Error(`Failed to save file: ${message}`);
|
|
296
|
+
}
|
|
297
|
+
}
|
package/package.json
CHANGED
package/scripts/cli.js
CHANGED
|
@@ -31,6 +31,7 @@ Usage: npx shikigami [command] [options]
|
|
|
31
31
|
|
|
32
32
|
Commands:
|
|
33
33
|
init Initialize SHIKIGAMI files in current directory
|
|
34
|
+
upgrade, update Upgrade SHIKIGAMI files (same as init --force) (v1.23.0)
|
|
34
35
|
new [name] Create a new research project
|
|
35
36
|
find-related [keyword] Find related projects by keywords (v1.7.0)
|
|
36
37
|
inherit [options] Prepare knowledge inheritance from past reports (v1.8.0)
|
|
@@ -39,6 +40,7 @@ Commands:
|
|
|
39
40
|
|
|
40
41
|
Examples:
|
|
41
42
|
npx shikigami init # Initialize SHIKIGAMI
|
|
43
|
+
npx shikigami upgrade # Upgrade to latest version
|
|
42
44
|
npx shikigami new my-research # Create new project "my-research"
|
|
43
45
|
npx shikigami new # Create new project with auto-numbered name
|
|
44
46
|
npx shikigami find-related レアアース # Find projects related to "レアアース"
|
|
@@ -58,6 +60,14 @@ if (command === 'init') {
|
|
|
58
60
|
process.exit(0);
|
|
59
61
|
}
|
|
60
62
|
|
|
63
|
+
// Upgrade/Update command (v1.23.0) - same as init --force
|
|
64
|
+
if (command === 'upgrade' || command === 'update') {
|
|
65
|
+
// Inject --force flag for init.js
|
|
66
|
+
process.argv.push('--force');
|
|
67
|
+
require('./init.js');
|
|
68
|
+
process.exit(0);
|
|
69
|
+
}
|
|
70
|
+
|
|
61
71
|
// New project command
|
|
62
72
|
if (command === 'new') {
|
|
63
73
|
const projectName = args[1];
|
|
@@ -79,7 +89,7 @@ if (command === 'inherit') {
|
|
|
79
89
|
}
|
|
80
90
|
|
|
81
91
|
// Unknown command
|
|
82
|
-
if (command !== 'find-related' && command !== 'inherit') {
|
|
92
|
+
if (command !== 'find-related' && command !== 'inherit' && command !== 'upgrade' && command !== 'update') {
|
|
83
93
|
console.error(`Unknown command: ${command}`);
|
|
84
94
|
console.log('Run "npx shikigami --help" for usage information.');
|
|
85
95
|
process.exit(1);
|