Cursor擦り倒すシリーズ
- Cursorで要件定義がエラいスムーズになった話
- (続)Cursorで「詳細設計→ガントチャート草稿」作成がめっちゃ楽になった話
- 「Cursor」×「A5:SQL Mk-2」でテーブル定義書をリッチにする
- 「Cursor」×「Obsidian」内部リンク生成&最適化プロンプト
- 「Cursor」で「難解コード」のリーディングがめちゃ楽になった話
- 「Cursorで要件定義をめっちゃ簡単に」を「rules」にしてさらに簡単にした
- 「Cursor」で「素の議事録」を「要件定義書」に高速でまとめなおした話 ←本稿こちら
はじめに
引き続き「Cursor」というAIエディタを文字どおり“擦り倒す”と意気込んで業務プロセスに組み込み、要件定義や設計ドキュメントの作成フローを最適化しています。動機は単純です。――エンジニアリングマネージャーという立場において、“要件が曖昧なまま走り出すプロジェクト”を放置すると、後の障害対応で自分たちの首を絞めるのは目に見えているからです。
しかし、要件定義フェーズに十分な手間と時間を割くのは容易ではありません。議事録、Backlog、チャットログ、メール。情報は四方八方に散在し、対面の「暗黙知」まで含めれば、“正式な一次情報”だけでも膨大です。
そこで私は、「情報の一元化」と「AIの力による構造化」 を同時に実現するワークフローを模索してきました。本稿は、その試行錯誤の“第7チャプター”としてまとめたものです。
ここから先は、スクリプトとプロンプトを掲載しつつ、私の経験と思索を交えながら工程ごとに掘り下げていきます。読み物というより、“実務の現場ノート” として活用いただければ幸いです。
概要
プロジェクトマネジメントにおいて、議事録・Backlogから効率的に要件定義書を作成するために実施した5つの工程とその詳細について解説します。
工程1:Backlogチケット情報の取得
解説
プロジェクト管理ツール「Backlog」から、APIを使用してチケット情報を自動取得し、Markdown形式でローカルに保存しました。これにより、手動でのコピー&ペーストを避け、最新の情報を効率的に収集できました。
使用技術
- Node.jsスクリプト
- Backlog API
スクリプト
const https = require('https');
const fs = require('fs').promises;
const path = require('path');
// .envファイルを読み込む関数
function loadEnv() {
try {
const envPath = path.join(__dirname, '.env');
const envFile = require('fs').readFileSync(envPath, 'utf-8');
envFile.split('\n').forEach(line => {
const trimmedLine = line.trim();
if (trimmedLine && !trimmedLine.startsWith('#')) {
const [key, ...valueParts] = trimmedLine.split('=');
const value = valueParts.join('=');
if (key && value) {
process.env[key.trim()] = value.trim();
}
}
});
console.log('.envファイルを読み込みました');
} catch (error) {
console.log('.envファイルが見つからないため、環境変数を使用します');
}
}
// .envファイルを読み込み
loadEnv();
// 設定
const CONFIG = {
SPACE_ID: process.env.BACKLOG_SPACE_ID || 'your-space-id', // スペースID
API_KEY: process.env.BACKLOG_API_KEY || 'your-api-key', // APIキー
PROJECT_KEY: process.env.BACKLOG_PROJECT_KEY || 'PROJECT', // プロジェクトキー
OUTPUT_DIR: './backlog-issues' // 出力ディレクトリ
};
// Backlog API基本設定
const API_BASE_URL = `https://${CONFIG.SPACE_ID}.backlog.com/api/v2`;
/**
* HTTPS GETリクエストを実行
*/
function httpsGet(url) {
return new Promise((resolve, reject) => {
https.get(url, (res) => {
let data = '';
res.on('data', (chunk) => data += chunk);
res.on('end', () => {
if (res.statusCode >= 200 && res.statusCode < 300) {
resolve(JSON.parse(data));
} else {
reject(new Error(`HTTP ${res.statusCode}: ${data}`));
}
});
}).on('error', reject);
});
}
/**
* プロジェクト情報を取得
*/
async function getProject(projectKey) {
const url = `${API_BASE_URL}/projects/${projectKey}?apiKey=${CONFIG.API_KEY}`;
return await httpsGet(url);
}
/**
* 課題一覧を取得(ページネーション対応)
*/
async function getAllIssues(projectId) {
const issues = [];
let offset = 0;
const count = 100; // 1ページあたりの取得数(最大100)
while (true) {
const url = `${API_BASE_URL}/issues?apiKey=${CONFIG.API_KEY}&projectId[]=${projectId}&count=${count}&offset=${offset}`;
const batch = await httpsGet(url);
if (batch.length === 0) break;
issues.push(...batch);
console.log(`取得済み課題数: ${issues.length}`);
if (batch.length < count) break;
offset += count;
}
return issues;
}
/**
* 課題のコメントを取得
*/
async function getIssueComments(issueId) {
const url = `${API_BASE_URL}/issues/${issueId}/comments?apiKey=${CONFIG.API_KEY}&order=asc`;
return await httpsGet(url);
}
/**
* 添付ファイル情報を取得
*/
async function getAttachmentInfo(issueId, attachmentId) {
const url = `${API_BASE_URL}/issues/${issueId}/attachments/${attachmentId}?apiKey=${CONFIG.API_KEY}`;
return await httpsGet(url);
}
/**
* 課題の種別名を取得
*/
function getIssueTypeName(issue) {
return issue.issueType?.name || '不明';
}
/**
* 課題の状態名を取得
*/
function getStatusName(issue) {
return issue.status?.name || '不明';
}
/**
* 優先度名を取得
*/
function getPriorityName(issue) {
return issue.priority?.name || '不明';
}
/**
* 日付をフォーマット
*/
function formatDate(dateString) {
if (!dateString) return '-';
const date = new Date(dateString);
return date.toLocaleString('ja-JP', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
}
/**
* Markdownエスケープ
*/
function escapeMarkdown(text) {
if (!text) return '';
return text
.replace(/\\/g, '\\\\')
.replace(/\*/g, '\\*')
.replace(/_/g, '\\_')
.replace(/\[/g, '\\[')
.replace(/\]/g, '\\]')
.replace(/</g, '<')
.replace(/>/g, '>');
}
/**
* カスタムフィールドの値を取得
*/
function getCustomFieldValue(customField) {
if (!customField.value) return '-';
// 複数選択の場合
if (Array.isArray(customField.value)) {
return customField.value.map(v => v.name || v).join(', ');
}
// 単一選択の場合
if (typeof customField.value === 'object') {
return customField.value.name || JSON.stringify(customField.value);
}
return String(customField.value);
}
/**
* 課題をMarkdown形式に変換
*/
async function convertIssueToMarkdown(issue, comments) {
let markdown = `# ${issue.issueKey}: ${issue.summary}\n\n`;
// 基本情報
markdown += `## 基本情報\n\n`;
markdown += `| 項目 | 内容 |\n`;
markdown += `|------|------|\n`;
markdown += `| 課題キー | ${issue.issueKey} |\n`;
markdown += `| 種別 | ${getIssueTypeName(issue)} |\n`;
markdown += `| 状態 | ${getStatusName(issue)} |\n`;
markdown += `| 優先度 | ${getPriorityName(issue)} |\n`;
markdown += `| 担当者 | ${issue.assignee?.name || '-'} |\n`;
markdown += `| 作成者 | ${issue.createdUser?.name || '-'} |\n`;
markdown += `| 作成日時 | ${formatDate(issue.created)} |\n`;
markdown += `| 更新日時 | ${formatDate(issue.updated)} |\n`;
markdown += `| 期限日 | ${issue.dueDate || '-'} |\n`;
if (issue.startDate) {
markdown += `| 開始日 | ${issue.startDate} |\n`;
}
if (issue.estimatedHours !== null) {
markdown += `| 予定時間 | ${issue.estimatedHours}時間 |\n`;
}
if (issue.actualHours !== null) {
markdown += `| 実績時間 | ${issue.actualHours}時間 |\n`;
}
markdown += `\n`;
// カテゴリー
if (issue.category && issue.category.length > 0) {
markdown += `## カテゴリー\n\n`;
issue.category.forEach(cat => {
markdown += `- ${cat.name}\n`;
});
markdown += `\n`;
}
// マイルストーン
if (issue.milestone && issue.milestone.length > 0) {
markdown += `## マイルストーン\n\n`;
issue.milestone.forEach(ms => {
markdown += `- ${ms.name}`;
if (ms.releaseDueDate) {
markdown += ` (期限: ${ms.releaseDueDate})`;
}
markdown += `\n`;
});
markdown += `\n`;
}
// カスタムフィールド
if (issue.customFields && issue.customFields.length > 0) {
markdown += `## カスタムフィールド\n\n`;
markdown += `| フィールド名 | 値 |\n`;
markdown += `|-------------|----|\n`;
issue.customFields.forEach(field => {
markdown += `| ${field.name} | ${getCustomFieldValue(field)} |\n`;
});
markdown += `\n`;
}
// 詳細
markdown += `## 詳細\n\n`;
markdown += issue.description || '(詳細なし)';
markdown += `\n\n`;
// 添付ファイル
if (issue.attachments && issue.attachments.length > 0) {
markdown += `## 添付ファイル\n\n`;
issue.attachments.forEach(att => {
markdown += `- ${att.name} (${(att.size / 1024).toFixed(2)} KB)\n`;
});
markdown += `\n`;
}
// コメント
if (comments && comments.length > 0) {
markdown += `## コメント\n\n`;
for (const comment of comments) {
markdown += `### ${comment.createdUser?.name || '不明'} - ${formatDate(comment.created)}\n\n`;
markdown += comment.content || '(コメント内容なし)';
markdown += `\n\n`;
// コメントの添付ファイル
if (comment.attachments && comment.attachments.length > 0) {
markdown += `**添付ファイル:**\n`;
comment.attachments.forEach(att => {
markdown += `- ${att.name}\n`;
});
markdown += `\n`;
}
// 通知先
if (comment.notifications && comment.notifications.length > 0) {
markdown += `**通知先:** `;
markdown += comment.notifications.map(n => n.user?.name || '不明').join(', ');
markdown += `\n\n`;
}
markdown += `---\n\n`;
}
}
return markdown;
}
/**
* ファイル名として使用できない文字を置換
*/
function sanitizeFileName(fileName) {
return fileName.replace(/[<>:"/\\|?*]/g, '_');
}
/**
* メイン処理
*/
async function main() {
try {
console.log('Backlog課題エクスポートツール');
console.log('================================\n');
// 設定確認
if (CONFIG.SPACE_ID === 'your-space-id' || CONFIG.API_KEY === 'your-api-key') {
console.error('エラー: SPACE_IDとAPI_KEYを設定してください。');
console.error('環境変数または直接CONFIG内の値を設定してください。');
process.exit(1);
}
// 出力ディレクトリ作成
await fs.mkdir(CONFIG.OUTPUT_DIR, { recursive: true });
console.log(`出力ディレクトリ: ${CONFIG.OUTPUT_DIR}\n`);
// プロジェクト情報取得
console.log(`プロジェクト情報を取得中: ${CONFIG.PROJECT_KEY}...`);
const project = await getProject(CONFIG.PROJECT_KEY);
console.log(`プロジェクト名: ${project.name}\n`);
// 全課題取得
console.log('課題一覧を取得中...');
const issues = await getAllIssues(project.id);
console.log(`\n取得完了: 全${issues.length}件の課題\n`);
// 各課題の詳細とコメントを取得してMarkdownファイルとして保存
console.log('各課題の詳細情報を取得してファイルに保存中...\n');
for (let i = 0; i < issues.length; i++) {
const issue = issues[i];
const progress = `[${i + 1}/${issues.length}]`;
try {
console.log(`${progress} ${issue.issueKey}: ${issue.summary}`);
// コメント取得
const comments = await getIssueComments(issue.id);
console.log(` └ コメント数: ${comments.length}`);
// Markdown変換
const markdown = await convertIssueToMarkdown(issue, comments);
// ファイル名生成
const fileName = sanitizeFileName(`${issue.issueKey}_${issue.summary}`);
const filePath = path.join(CONFIG.OUTPUT_DIR, `${fileName}.md`);
// ファイル保存
await fs.writeFile(filePath, markdown, 'utf-8');
console.log(` └ 保存完了: ${fileName}.md`);
} catch (error) {
console.error(` └ エラー: ${error.message}`);
}
// API制限対策(1秒待機)
if (i < issues.length - 1) {
await new Promise(resolve => setTimeout(resolve, 1000));
}
}
console.log('\n================================');
console.log('エクスポート完了!');
console.log(`出力先: ${path.resolve(CONFIG.OUTPUT_DIR)}`);
} catch (error) {
console.error('\nエラーが発生しました:');
console.error(error.message);
process.exit(1);
}
}
// 実行
if (require.main === module) {
main();
}
工程2:議事録をテンプレートで要点抽出
解説
PDFやExcel形式で保存されていた議事録から、重要な決定事項やTodo項目を抽出し、統一されたテンプレートに沿ってMarkdown形式で整理しました。これにより、複数回の打ち合わせ内容を横断的に把握できるようになりました。
使用ツール
- Cursor(AIエディタ)
プロンプト例
# 議事録重要事項抽出プロンプト
## プロンプト
以下の議事録を詳細に分析し、4つのカテゴリーに分けて情報を抽出してください。各項目は議事録の記載内容を正確に反映し、解釈や推測を加えずに抽出してください。特に発言者の特定には細心の注意を払ってください。
### 発言者の特定ルール
1. **明示的な発言者表記を優先**
- 「○○:」「○○様:」の形式で記載されている発言者を正確に記録
- 「(○○様からの質問に対し)」などの補足情報も活用
2. **発言者が不明確な場合の対応**
- 議事録に発言者が明記されていない場合は「発言者不明」と記載
- 文脈から推測可能でも、明記されていなければ推測しない
- 「担当者」「貴社」「弊社」などの表現は、可能な限り具体的な個人名・社名に置き換える
3. **複数の発言者が関与する場合**
- 提案者と承認者を区別して記載
- 議論の流れで複数人が関わる場合は、各人の発言を時系列で整理
### 1. 提案事項
議事録中で「〜したい」「〜を提案」「〜を活用したい」「〜を期待」などの表現で示された、いずれかの参加者から提示された案や意向を全て抽出してください。
**抽出形式:**
- 【提案者:会社名・氏名】提案内容
- 提案に対する反応者がいる場合:【反応者:会社名・氏名】反応内容
- 関連する議事録の該当箇所(ページ番号含む)
- 原文引用:「」で該当部分を引用
### 2. 決定事項
議事録中で「〜で進める」「〜とする」「問題ない」「承知した」などの合意形成が明確に示された事項を全て抽出してください。
**抽出形式:**
- 決定内容
- 【意思決定者:会社名・氏名】※複数の場合は全員記載
- 【提案者:会社名・氏名】(決定事項の元となった提案者)
- 具体的な内容(日時、担当者、方法など)
- 関連する議事録の該当箇所(ページ番号含む)
- 決定時の発言:「」で引用
### 3. 決定の背景・理由
決定事項に至った理由や背景として議事録に記載されている内容を抽出してください。「〜のため」「〜を考慮して」「〜という理由で」などの表現に注目してください。
**抽出形式:**
- 関連する決定事項
- 【説明者:会社名・氏名】背景・理由の内容
- 理由に対する他者の反応がある場合:【反応者:会社名・氏名】反応内容
- 関連する議事録の該当箇所(ページ番号含む)
- 原文引用:「」で該当部分を引用
### 4. 継続検討事項
議事録中で「〜は要検討」「〜は状況に応じて」「必要に応じて〜」「〜は今後調整」など、明確に決定されず今後の検討課題として残された事項を全て抽出してください。
**抽出形式:**
- 検討事項の内容
- 【提起者:会社名・氏名】(検討事項を提起した人物)
- 【関係者:会社名・氏名】(検討に関わる予定の人物)
- 検討の条件やタイミング(記載がある場合)
- 関連する議事録の該当箇所(ページ番号含む)
- 原文引用:「」で該当部分を引用
### 5. システム・ツール情報
議事録に記載されているシステム、ツール、URLなどの情報を抽出してください。
**抽出形式:**
- ツール/システム名
- URL(完全な形で記載)
- 用途・目的
- 【提案者/説明者:会社名・氏名】
- 関連する議事録の該当箇所(ページ番号含む)
**特に注意すべきURL:**
- backlog.com を含むURL
- xxxxx.com などのプロジェクト固有URL
- その他の業務システムURL
### 6. アクションアイテム(参考)
議事録に明記されているTo-Doリストも併せて整理してください。
**抽出形式:**
- 【担当者:会社名・氏名】実施内容
- 【依頼者:会社名・氏名】(アクションを依頼した人物、明記されている場合)
- 期限(記載がある場合)
- ステータス(議事録内で完了・進行中などの記載がある場合)
---
## 注意事項
1. **URL・システム情報の完全抽出**
- 議事録内のすべてのURLを完全な形(https://から)で抽出
- 特にbacklog.comドメインのURLは必ず抽出
- メールアドレス、電話番号なども併せて抽出
- システム名、ツール名は略称と正式名称の両方を記載
2. **発言者特定の正確性**
- 議事録に「○○:」「○○様:」と明記されている発言者のみを記載
- 「弊社」「貴社」は出席者リストと照合して具体的な会社名に置き換え
- 発言者が不明な場合は必ず「発言者不明」と記載
3. **会話の文脈を保持**
- 提案→反応→決定などの会話の流れを崩さない
- 誰が誰に対して発言したかが分かるように記載
4. **網羅性と正確性の確保**
- 議事録の文言を可能な限り原文のまま引用
- 推測や解釈を加えない
- 小さな決定事項も見逃さない
5. **構造的な整理**
- 同一議題に関する複数人の発言はまとめて記載
- 時系列や論理的な順序で整理
- 各カテゴリー内で重要度順に並べる
6. **ページ参照と引用の明記**
- 各項目について議事録のページ番号を明記
- 重要な発言は「」で原文を引用
- 複数ページにまたがる場合は全て記載
工程3:議事録を元にバックログ記載内容テキストに追記
解説
抽出した議事録の要点を、バックログのチケット情報と統合しました。議事録で決定された内容がバックログにどのように反映されているかを追跡し、不足している情報を補完しました。
使用ツール
- Cursor
プロンプト例
@{議事録ファイルパス} を解析し、@{バックログディレクトリパス} 内のどのファイルに追記すべきか一覧化し、以下フォーマットで出力してください(書き込み禁止)。
# 出力フォーマット
- 追記先: <relative/path/to/file.md>
- カテゴリ: <サイトマップ確認 | スケジュール確認 | 議事録確認 | Backlog活用方針 | 新機能・技術提案 | 契約・法務関連>
- 引用ヘッダー: "## <カテゴリ> – <議事録日付> 議事録より\n(引用元: <${MINUTES_PATH}>)"
- 内容区分: <提案事項 / 決定事項 / 継続検討事項 / アクションアイテム>
- 要約: "<ここに最大140文字で議事録該当部分を要約>"
- 原文: |
<引用ブロック(改変禁止)>
工程4:バックログ記載内容テキストを元に、要件定義書アジェンダを作成
解説
統合された情報を基に、要件定義書の構成(アジェンダ)を作成しました。機能別、画面別に整理し、開発チームが理解しやすい構造にしました。
使用ツール
- Cursor
プロンプト例
要件定義書の作成を行います。
まず、アジェンダを作成するので、「Backlogチケット」フォルダの各記載内容から「要件定義書作成に必要なファイル」の一覧をピックアップして「アジェンダ.md」ファイルに記載してください。
工程5:要件定義書アジェンダ×バックログ記載内容テキストで、要件定義書の各ファイルを作成
解説
作成したアジェンダに沿って、各セクションの詳細な要件定義書を作成しました。バックログの記載内容と議事録の決定事項を組み合わせ、開発に必要な仕様を明確に文書化しました。
使用ツール
- Cursor
プロンプト例
アジェンダの「2.1 xxxxxxxxx」について、
バックログディレクトリのチケットから要件定義書を作成してください。
以下、指示です:
---
# バックログチケット要件定義書作成プロンプト
## 指示内容
バックログディレクトリのチケットの内容を、要件定義書の1節として整理してください。
### 作成ルール
1. **構成**:以下の項目を含めて構成すること
- 概要(基本情報、目的)
- デザイン要件(確定デザイン、レスポンシブ対応)
- 機能要件(ページ構成要素、各機能の詳細)
- 表示ルール(条件別表示、データ0件時の処理)
- 運用要件(更新対応、管理機能との連携)
- 技術仕様(実装方法、データ連携)
- 承認事項(決定事項と日付)
2. **情報の抽出方法**
- チケットの基本情報から要件ID、担当者、優先度を記載
- 詳細欄、コメント欄から要件を抽出
- 議事録からの引用がある場合は、決定事項・提案事項を整理
- デザインURLがある場合は明記
3. **不足情報の扱い**
- 明確でない仕様は「**(要追加確認事項)**」として記載
- 各セクションで考えられる追加確認事項を提示
- 実装に必要だが記載がない項目を洗い出す
4. **記載の詳細度**
- 決定事項は具体的に記載(数値、文言等)
- 曖昧な表現は避け、確認が必要な箇所として明示
- 関連する他チケットがある場合は参照を記載
5. **非機能要件の扱い**
- セキュリティ、パフォーマンス、アクセシビリティ等の非機能要件は項目のみ挙げ、詳細は別途まとめる旨を記載
# 関連する議事録情報があれば貼り付け
[議事録の内容]
### 出力形式
マークダウン形式で、以下の見出しレベルを使用:
- # ページ名 + 要件定義
- ## 大項目(1. 概要、2. デザイン要件 等)
- ### 中項目(1.1 基本情報 等)
- #### 小項目(必要に応じて)
### 特に注意すべき点
- 決定事項には決定日を併記
- URLは正確に転記
- 「~と思われる」等の推測は避け、不明な点は確認事項として明記
- 運用に関わる事項(更新頻度、更新方法等)は漏れなく記載
まとめ
プロセスの効果
- 自動化による効率化:APIによるデータ取得で、繰り返し作業を“ゼロ距離”に近づけた。
- 情報の一元化:議事録とBacklogを統合し、“誰が何を決めたか”を迷わず追跡できるようになった。
- AIの活用:Cursorが自然言語を構造化し、私たちの“読む・書く負担”を劇的に軽減した。
- 構造化:段階的なプロセスでドキュメントを生成することで、漏れや重複を抑えた。
改善ポイント
- Backlog APIスクリプトの定期実行化――“最新情報が正だ”という前提を守り抜くために。
- 議事録テンプレートの標準化――抽出精度は“良質な入力”に依存する。
- 要件定義書のバージョン管理――*“ドキュメントもコードと同じく育てるもの”*という姿勢をチームで共有したい。
おわり
SREの現場であれ、プロジェクトマネジメントであれ、私たちは常に不確実性と隣り合わせです。議事録を読み返し、バックログを最新化し、要件定義書を磨く――それ自体は地味な作業かもしれません。しかし、「向き合わなかったときに発生するコスト」 を想像すれば、その地味さは決して軽視できない。
「よいエンジニア」とは、派手な技術スタックを扱う者ではなく、
“不確実性と泥臭く向き合い続ける者” のことだろう。
そう考えると、私たちの毎日は——たとえ小さな一手であっても——未来の障害を静かに防いでいるのかもしれません。