vibepro 0.1.0-alpha.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/LICENSE +201 -0
- package/NOTICE +9 -0
- package/README.ja.md +448 -0
- package/README.md +520 -0
- package/agent-instructions/codex/AGENTS.vibepro.md +45 -0
- package/bin/vibepro.js +9 -0
- package/docs/assets/vibepro-header.png +0 -0
- package/package.json +51 -0
- package/skills/vibepro-diagnosis-packages/SKILL.md +133 -0
- package/skills/vibepro-human-review/SKILL.md +73 -0
- package/skills/vibepro-story-refactor/SKILL.md +89 -0
- package/skills/vibepro-workflow/SKILL.md +139 -0
- package/src/agent-harness-map.js +230 -0
- package/src/agent-harness-scanner.js +337 -0
- package/src/agent-review.js +2180 -0
- package/src/api-boundary-scanner.js +452 -0
- package/src/architecture-profiler.js +423 -0
- package/src/authorization-scoring.js +149 -0
- package/src/brainbase-importer.js +534 -0
- package/src/change-risk-classifier.js +195 -0
- package/src/check-packs.js +605 -0
- package/src/checkpoint-manager.js +233 -0
- package/src/cli.js +2213 -0
- package/src/code-quality-scanner.js +310 -0
- package/src/codex-manager.js +143 -0
- package/src/component-style-scanner.js +336 -0
- package/src/coverage-report.js +99 -0
- package/src/database-access-scanner.js +163 -0
- package/src/decision-records.js +315 -0
- package/src/design-modernize.js +1435 -0
- package/src/design-system.js +1732 -0
- package/src/diagnostic-engine.js +1945 -0
- package/src/diagram-requirement-resolver.js +194 -0
- package/src/doctor.js +677 -0
- package/src/environment-graph.js +424 -0
- package/src/execution-state.js +849 -0
- package/src/explore-evidence.js +425 -0
- package/src/flow-design-scanner.js +896 -0
- package/src/flow-verifier.js +887 -0
- package/src/gesture-interaction-scanner.js +330 -0
- package/src/graph-context.js +263 -0
- package/src/graphify-adapter.js +189 -0
- package/src/html-report.js +1035 -0
- package/src/journey-map.js +1299 -0
- package/src/language.js +48 -0
- package/src/lazy-pattern-detector.js +182 -0
- package/src/local-dev-scanner.js +135 -0
- package/src/managed-worktree-gate.js +187 -0
- package/src/managed-worktree.js +766 -0
- package/src/merge-manager.js +501 -0
- package/src/network-contract-scanner.js +442 -0
- package/src/nocodb-story-sync.js +386 -0
- package/src/oss-readiness-scanner.js +417 -0
- package/src/performance-evidence.js +756 -0
- package/src/performance-measurer.js +591 -0
- package/src/pr-manager.js +8220 -0
- package/src/presets.js +682 -0
- package/src/public-discovery-scanner.js +519 -0
- package/src/refactoring-delta-reporter.js +367 -0
- package/src/refactoring-opportunity-generator.js +797 -0
- package/src/regression-risk-scanner.js +146 -0
- package/src/repo-status.js +266 -0
- package/src/report-fingerprint.js +188 -0
- package/src/report-pr-body-prompt-template.md +108 -0
- package/src/report-pr-body-schema.json +95 -0
- package/src/report-store.js +135 -0
- package/src/report-validator.js +192 -0
- package/src/requirement-consistency.js +1066 -0
- package/src/runtime-info.js +134 -0
- package/src/self-dogfood-scanner.js +476 -0
- package/src/session-learning.js +164 -0
- package/src/skills-manager.js +157 -0
- package/src/spec-drift.js +378 -0
- package/src/spec-fingerprint.js +445 -0
- package/src/spec-prompt-template.md +155 -0
- package/src/spec-schema.json +219 -0
- package/src/spec-store.js +258 -0
- package/src/spec-validator.js +459 -0
- package/src/static-site-scanner.js +316 -0
- package/src/story-candidate-generator.js +85 -0
- package/src/story-catalog-generator.js +2813 -0
- package/src/story-html.js +156 -0
- package/src/story-manager.js +2144 -0
- package/src/story-task-generator.js +522 -0
- package/src/task-manager.js +1029 -0
- package/src/terminal-link-scanner.js +238 -0
- package/src/usage-report.js +417 -0
- package/src/verification-evidence.js +284 -0
- package/src/workspace.js +126 -0
|
@@ -0,0 +1,1299 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
import { getWorkspaceDir, initWorkspace, readManifest, toWorkspaceRelative, writeManifest } from './workspace.js';
|
|
6
|
+
|
|
7
|
+
const JOURNEY_SCHEMA_VERSION = '0.1.0';
|
|
8
|
+
const DEFAULT_JOURNEY_ID = 'default-product-journey';
|
|
9
|
+
const STORY_DIRS = [
|
|
10
|
+
path.join('docs', 'management', 'stories', 'active'),
|
|
11
|
+
path.join('docs', 'user_stories', 'active'),
|
|
12
|
+
path.join('docs', 'stories')
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
const ACTIVITY_ORDER = [
|
|
16
|
+
'acquisition',
|
|
17
|
+
'activation',
|
|
18
|
+
'core_usage',
|
|
19
|
+
'monetization',
|
|
20
|
+
'retention',
|
|
21
|
+
'operations',
|
|
22
|
+
'risk_control',
|
|
23
|
+
'quality_gate',
|
|
24
|
+
'architecture',
|
|
25
|
+
'knowledge_recovery'
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
const ACTIVITY_LABELS = {
|
|
29
|
+
acquisition: '価値を知る',
|
|
30
|
+
activation: '利用開始する',
|
|
31
|
+
core_usage: '主要価値を得る',
|
|
32
|
+
monetization: '支払い・契約する',
|
|
33
|
+
retention: '継続利用する',
|
|
34
|
+
operations: '運用する',
|
|
35
|
+
risk_control: '信頼境界を守る',
|
|
36
|
+
quality_gate: '品質を保つ',
|
|
37
|
+
architecture: '構造を整える',
|
|
38
|
+
knowledge_recovery: '正本を復元する'
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const RELEASE_SLICE_ORDER = ['walking_skeleton', 'next_slice', 'hardening', 'custom'];
|
|
42
|
+
|
|
43
|
+
export async function deriveJourneyMap(repoRoot, options = {}) {
|
|
44
|
+
const root = path.resolve(repoRoot);
|
|
45
|
+
await initWorkspace(root);
|
|
46
|
+
const manifest = await readManifest(root);
|
|
47
|
+
const storyInputs = await readJourneyStoryInputs(root);
|
|
48
|
+
const sourceStories = storyInputs.filter((story) => story.status !== 'archived');
|
|
49
|
+
const placements = sourceStories.map((story) => placeStory(story));
|
|
50
|
+
const backbone = buildBackbone(placements);
|
|
51
|
+
const releaseSlices = buildReleaseSlices(placements, backbone);
|
|
52
|
+
const walkingSkeleton = buildWalkingSkeleton(backbone, releaseSlices);
|
|
53
|
+
const conflicts = buildJourneyConflicts(placements);
|
|
54
|
+
const unplacedStories = placements
|
|
55
|
+
.filter((placement) => placement.placement_status !== 'placed')
|
|
56
|
+
.map((placement) => ({
|
|
57
|
+
story_id: placement.story_id,
|
|
58
|
+
title: placement.title,
|
|
59
|
+
reason: placement.placement_reason,
|
|
60
|
+
confidence: placement.confidence
|
|
61
|
+
}));
|
|
62
|
+
const openQuestions = [
|
|
63
|
+
...walkingSkeleton.gaps.map((gap) => ({
|
|
64
|
+
id: `gap:${gap.step_id}`,
|
|
65
|
+
kind: 'walking_skeleton_gap',
|
|
66
|
+
question: `Walking skeleton に必要な ${gap.label} step をどの Story が満たすか確認する。`,
|
|
67
|
+
blocker: true,
|
|
68
|
+
step_id: gap.step_id
|
|
69
|
+
})),
|
|
70
|
+
...unplacedStories.map((story) => ({
|
|
71
|
+
id: `unplaced:${story.story_id}`,
|
|
72
|
+
kind: 'unplaced_story',
|
|
73
|
+
question: `${story.story_id} をJourney stepへ配置するか、補助Storyまたは横断関心として扱うか確認する。`,
|
|
74
|
+
blocker: false,
|
|
75
|
+
story_id: story.story_id
|
|
76
|
+
}))
|
|
77
|
+
];
|
|
78
|
+
const generatedAt = new Date().toISOString();
|
|
79
|
+
const journey = {
|
|
80
|
+
schema_version: JOURNEY_SCHEMA_VERSION,
|
|
81
|
+
journey_id: options.journeyId ?? DEFAULT_JOURNEY_ID,
|
|
82
|
+
generated_at: generatedAt,
|
|
83
|
+
source_story_ids: sourceStories.map((story) => story.story_id),
|
|
84
|
+
source_digest: buildSourceDigest(sourceStories),
|
|
85
|
+
source: {
|
|
86
|
+
story_count: sourceStories.length,
|
|
87
|
+
story_catalog_available: storyInputs.some((story) => story.source_types.includes('story_catalog')),
|
|
88
|
+
input_paths: sourceStories.flatMap((story) => story.source_paths).filter(Boolean).sort()
|
|
89
|
+
},
|
|
90
|
+
backbone,
|
|
91
|
+
release_slices: releaseSlices,
|
|
92
|
+
walking_skeleton: walkingSkeleton,
|
|
93
|
+
unplaced_stories: unplacedStories,
|
|
94
|
+
conflicts,
|
|
95
|
+
open_questions: openQuestions
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const journeyDir = path.join(getWorkspaceDir(root), 'journey');
|
|
99
|
+
const historyDir = path.join(journeyDir, 'history');
|
|
100
|
+
await mkdir(historyDir, { recursive: true });
|
|
101
|
+
const latestJsonPath = path.join(journeyDir, 'latest-journey.json');
|
|
102
|
+
const latestMarkdownPath = path.join(journeyDir, 'latest-journey.md');
|
|
103
|
+
const historyJsonPath = path.join(historyDir, `${generatedAt.replace(/\.\d{3}Z$/, 'Z').replace(/:/g, '')}.json`);
|
|
104
|
+
const markdown = renderJourneyMapMarkdown(journey);
|
|
105
|
+
await writeFile(latestJsonPath, `${JSON.stringify(journey, null, 2)}\n`);
|
|
106
|
+
await writeFile(latestMarkdownPath, markdown);
|
|
107
|
+
await writeFile(historyJsonPath, `${JSON.stringify(journey, null, 2)}\n`);
|
|
108
|
+
|
|
109
|
+
manifest.artifacts = {
|
|
110
|
+
...(manifest.artifacts ?? {}),
|
|
111
|
+
latest_journey: toWorkspaceRelative(root, latestJsonPath),
|
|
112
|
+
latest_journey_markdown: toWorkspaceRelative(root, latestMarkdownPath)
|
|
113
|
+
};
|
|
114
|
+
manifest.journey = {
|
|
115
|
+
schema_version: JOURNEY_SCHEMA_VERSION,
|
|
116
|
+
latest_journey: toWorkspaceRelative(root, latestJsonPath),
|
|
117
|
+
latest_journey_markdown: toWorkspaceRelative(root, latestMarkdownPath),
|
|
118
|
+
latest_history: toWorkspaceRelative(root, historyJsonPath),
|
|
119
|
+
generated_at: generatedAt,
|
|
120
|
+
source_story_count: sourceStories.length,
|
|
121
|
+
walking_skeleton_status: walkingSkeleton.status,
|
|
122
|
+
conflict_count: conflicts.length,
|
|
123
|
+
open_question_count: openQuestions.length
|
|
124
|
+
};
|
|
125
|
+
await writeManifest(root, manifest);
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
journey,
|
|
129
|
+
artifacts: {
|
|
130
|
+
json: latestJsonPath,
|
|
131
|
+
markdown: latestMarkdownPath,
|
|
132
|
+
history_json: historyJsonPath
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export async function readLatestJourneyMap(repoRoot) {
|
|
138
|
+
const journeyPath = path.join(getWorkspaceDir(repoRoot), 'journey', 'latest-journey.json');
|
|
139
|
+
try {
|
|
140
|
+
return JSON.parse(await readFile(journeyPath, 'utf8'));
|
|
141
|
+
} catch (error) {
|
|
142
|
+
if (error.code === 'ENOENT') return null;
|
|
143
|
+
throw error;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export async function getJourneyStatus(repoRoot) {
|
|
148
|
+
const journey = await readLatestJourneyMap(repoRoot);
|
|
149
|
+
if (!journey) {
|
|
150
|
+
return {
|
|
151
|
+
schema_version: JOURNEY_SCHEMA_VERSION,
|
|
152
|
+
status: 'missing',
|
|
153
|
+
reason: 'Journey Map is not generated. Run `vibepro journey derive <repo>`.',
|
|
154
|
+
journey: null
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
return {
|
|
158
|
+
schema_version: JOURNEY_SCHEMA_VERSION,
|
|
159
|
+
status: journey.conflicts?.length > 0
|
|
160
|
+
? 'conflict'
|
|
161
|
+
: journey.walking_skeleton?.status === 'needs_evidence'
|
|
162
|
+
? 'needs_evidence'
|
|
163
|
+
: 'available',
|
|
164
|
+
generated_at: journey.generated_at,
|
|
165
|
+
journey_id: journey.journey_id,
|
|
166
|
+
source_story_count: journey.source_story_ids?.length ?? 0,
|
|
167
|
+
activity_count: journey.backbone?.length ?? 0,
|
|
168
|
+
walking_skeleton_status: journey.walking_skeleton?.status ?? 'unknown',
|
|
169
|
+
conflict_count: journey.conflicts?.length ?? 0,
|
|
170
|
+
open_question_count: journey.open_questions?.length ?? 0,
|
|
171
|
+
journey
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function summarizeJourneyForPr(journey, storyId = null) {
|
|
176
|
+
if (!journey) {
|
|
177
|
+
return {
|
|
178
|
+
status: 'missing',
|
|
179
|
+
reason: 'Journey Map is not generated. Run `vibepro journey derive <repo>` to surface latest user Journey context.',
|
|
180
|
+
current_story: null
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
const storyPlacement = findStoryPlacement(journey, storyId);
|
|
184
|
+
return {
|
|
185
|
+
status: journey.conflicts?.length > 0
|
|
186
|
+
? 'conflict'
|
|
187
|
+
: journey.walking_skeleton?.status === 'needs_evidence'
|
|
188
|
+
? 'needs_evidence'
|
|
189
|
+
: 'available',
|
|
190
|
+
generated_at: journey.generated_at,
|
|
191
|
+
journey_id: journey.journey_id,
|
|
192
|
+
walking_skeleton_status: journey.walking_skeleton?.status ?? 'unknown',
|
|
193
|
+
conflict_count: journey.conflicts?.length ?? 0,
|
|
194
|
+
open_question_count: journey.open_questions?.length ?? 0,
|
|
195
|
+
current_story: storyPlacement,
|
|
196
|
+
affected_release_slices: storyPlacement
|
|
197
|
+
? (journey.release_slices ?? [])
|
|
198
|
+
.filter((slice) => (slice.story_ids ?? []).includes(storyId))
|
|
199
|
+
.map((slice) => ({ slice_id: slice.slice_id, kind: slice.kind, label: slice.label }))
|
|
200
|
+
: []
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function renderJourneyMap(result) {
|
|
205
|
+
return renderJourneyMapMarkdown(result.journey ?? result);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function renderJourneyStatus(status) {
|
|
209
|
+
if (status.status === 'missing') {
|
|
210
|
+
return `# Journey Status\n\n- status: missing\n- reason: ${status.reason}\n`;
|
|
211
|
+
}
|
|
212
|
+
return `# Journey Status
|
|
213
|
+
|
|
214
|
+
- status: ${status.status}
|
|
215
|
+
- generated_at: ${status.generated_at}
|
|
216
|
+
- journey_id: ${status.journey_id}
|
|
217
|
+
- source stories: ${status.source_story_count}
|
|
218
|
+
- activities: ${status.activity_count}
|
|
219
|
+
- walking skeleton: ${status.walking_skeleton_status}
|
|
220
|
+
- conflicts: ${status.conflict_count}
|
|
221
|
+
- open questions: ${status.open_question_count}
|
|
222
|
+
`;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export function renderJourneyPrSection(summary) {
|
|
226
|
+
if (!summary || summary.status === 'missing') {
|
|
227
|
+
return `## Journey Map
|
|
228
|
+
- Status: missing
|
|
229
|
+
- Action: ${summary?.reason ?? 'Run `vibepro journey derive <repo>` to generate latest Journey context.'}`;
|
|
230
|
+
}
|
|
231
|
+
const current = summary.current_story;
|
|
232
|
+
const slices = summary.affected_release_slices.length > 0
|
|
233
|
+
? summary.affected_release_slices.map((slice) => `${slice.slice_id} (${slice.kind})`).join(', ')
|
|
234
|
+
: '-';
|
|
235
|
+
return `## Journey Map
|
|
236
|
+
- Status: ${summary.status}
|
|
237
|
+
- Generated: ${summary.generated_at}
|
|
238
|
+
- Walking skeleton: ${summary.walking_skeleton_status}
|
|
239
|
+
- Current Story step: ${current ? `${current.activity_id}/${current.step_id} (${current.placement_kind})` : '-'}
|
|
240
|
+
- Affected release slices: ${slices}
|
|
241
|
+
- Conflicts: ${summary.conflict_count}
|
|
242
|
+
- Open questions: ${summary.open_question_count}`;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async function readJourneyStoryInputs(root) {
|
|
246
|
+
const [configStories, catalogStories, docStories, evidenceIndex] = await Promise.all([
|
|
247
|
+
readConfigStories(root),
|
|
248
|
+
readCatalogStories(root),
|
|
249
|
+
readStoryDocs(root),
|
|
250
|
+
readJourneyEvidenceIndex(root)
|
|
251
|
+
]);
|
|
252
|
+
const byId = new Map();
|
|
253
|
+
for (const story of [...configStories, ...catalogStories, ...docStories]) {
|
|
254
|
+
if (!story.story_id) continue;
|
|
255
|
+
const existing = byId.get(story.story_id) ?? emptyStoryInput(story.story_id);
|
|
256
|
+
byId.set(story.story_id, mergeStoryInput(existing, story));
|
|
257
|
+
}
|
|
258
|
+
for (const [storyId, evidence] of evidenceIndex.entries()) {
|
|
259
|
+
const existing = byId.get(storyId);
|
|
260
|
+
if (!existing) continue;
|
|
261
|
+
byId.set(storyId, mergeStoryInput(existing, evidence));
|
|
262
|
+
}
|
|
263
|
+
return [...byId.values()].sort((a, b) => {
|
|
264
|
+
const dateA = a.updated_at ?? a.created_at ?? '';
|
|
265
|
+
const dateB = b.updated_at ?? b.created_at ?? '';
|
|
266
|
+
return dateA.localeCompare(dateB) || a.story_id.localeCompare(b.story_id);
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async function readConfigStories(root) {
|
|
271
|
+
try {
|
|
272
|
+
const config = JSON.parse(await readFile(path.join(getWorkspaceDir(root), 'config.json'), 'utf8'));
|
|
273
|
+
return (config.brainbase?.stories ?? []).map((story) => normalizeStoryInput({
|
|
274
|
+
...story,
|
|
275
|
+
source_types: ['config'],
|
|
276
|
+
source_paths: []
|
|
277
|
+
}));
|
|
278
|
+
} catch (error) {
|
|
279
|
+
if (error.code === 'ENOENT') return [];
|
|
280
|
+
throw error;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async function readCatalogStories(root) {
|
|
285
|
+
try {
|
|
286
|
+
const catalogPath = path.join(getWorkspaceDir(root), 'stories', 'story-catalog.json');
|
|
287
|
+
const catalog = JSON.parse(await readFile(catalogPath, 'utf8'));
|
|
288
|
+
return (catalog.stories ?? []).map((story) => normalizeStoryInput({
|
|
289
|
+
...story,
|
|
290
|
+
story_id: story.story_id,
|
|
291
|
+
title: story.title,
|
|
292
|
+
status: story.status,
|
|
293
|
+
view: story.view,
|
|
294
|
+
category: story.category,
|
|
295
|
+
period: story.period,
|
|
296
|
+
source_types: ['story_catalog'],
|
|
297
|
+
source_paths: story.source?.paths ?? [],
|
|
298
|
+
surfaces: buildSurfaceEvidenceFromPaths(story.source?.paths ?? [], 'story_catalog'),
|
|
299
|
+
derived_definition: story.derived?.story_definition ?? null,
|
|
300
|
+
workflow_position: story.derived?.meaning?.workflow_position ?? null
|
|
301
|
+
}));
|
|
302
|
+
} catch (error) {
|
|
303
|
+
if (error.code === 'ENOENT') return [];
|
|
304
|
+
throw error;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async function readStoryDocs(root) {
|
|
309
|
+
const stories = [];
|
|
310
|
+
for (const dir of STORY_DIRS) {
|
|
311
|
+
const absoluteDir = path.join(root, dir);
|
|
312
|
+
for (const filePath of await listMarkdownFiles(absoluteDir)) {
|
|
313
|
+
const relativePath = path.relative(root, filePath).split(path.sep).join('/');
|
|
314
|
+
const content = await readFile(filePath, 'utf8');
|
|
315
|
+
stories.push(normalizeStoryInput(parseStoryDoc(relativePath, content)));
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
return stories;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
async function readJourneyEvidenceIndex(root) {
|
|
322
|
+
const [specEvidence, graphifyEvidence, gateEvidence] = await Promise.all([
|
|
323
|
+
readSpecEvidence(root),
|
|
324
|
+
readGraphifyEvidence(root),
|
|
325
|
+
readGateEvidence(root)
|
|
326
|
+
]);
|
|
327
|
+
const index = new Map();
|
|
328
|
+
for (const item of [...specEvidence, ...graphifyEvidence, ...gateEvidence]) {
|
|
329
|
+
if (!item.story_id) continue;
|
|
330
|
+
const existing = index.get(item.story_id) ?? normalizeStoryInput({
|
|
331
|
+
story_id: item.story_id,
|
|
332
|
+
source_types: ['journey_evidence']
|
|
333
|
+
});
|
|
334
|
+
index.set(item.story_id, mergeStoryInput(existing, normalizeStoryInput({
|
|
335
|
+
story_id: item.story_id,
|
|
336
|
+
spec_clauses: item.spec_clauses ?? [],
|
|
337
|
+
surfaces: item.surfaces ?? [],
|
|
338
|
+
gate_evidence: item.gate_evidence ?? [],
|
|
339
|
+
source_types: item.source_types ?? ['journey_evidence'],
|
|
340
|
+
source_paths: item.source_paths ?? []
|
|
341
|
+
})));
|
|
342
|
+
}
|
|
343
|
+
return index;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
async function readSpecEvidence(root) {
|
|
347
|
+
const specs = [];
|
|
348
|
+
const specDir = path.join(root, 'docs', 'specs');
|
|
349
|
+
for (const filePath of await listMarkdownFiles(specDir)) {
|
|
350
|
+
const relativePath = path.relative(root, filePath).split(path.sep).join('/');
|
|
351
|
+
const content = await readFile(filePath, 'utf8');
|
|
352
|
+
const { frontmatter, body } = parseFrontmatter(content);
|
|
353
|
+
const storyId = frontmatter.story_id ?? inferStoryIdFromText(body);
|
|
354
|
+
if (!storyId) continue;
|
|
355
|
+
const clauses = extractSpecClauses(body, relativePath);
|
|
356
|
+
if (clauses.length === 0) continue;
|
|
357
|
+
specs.push({
|
|
358
|
+
story_id: storyId,
|
|
359
|
+
spec_clauses: clauses,
|
|
360
|
+
source_types: ['spec'],
|
|
361
|
+
source_paths: [relativePath]
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
return specs;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
async function readGraphifyEvidence(root) {
|
|
368
|
+
const graphPath = path.join(getWorkspaceDir(root), 'graphify', 'graph.json');
|
|
369
|
+
let graph;
|
|
370
|
+
try {
|
|
371
|
+
graph = JSON.parse(await readFile(graphPath, 'utf8'));
|
|
372
|
+
} catch (error) {
|
|
373
|
+
if (error.code === 'ENOENT') return [];
|
|
374
|
+
throw error;
|
|
375
|
+
}
|
|
376
|
+
const relativeGraphPath = toWorkspaceRelative(root, graphPath);
|
|
377
|
+
const entries = [];
|
|
378
|
+
for (const story of graph.stories ?? []) {
|
|
379
|
+
const surfaces = normalizeSurfaceEvidence(story.surfaces ?? story.coverage ?? story.paths ?? [], relativeGraphPath, 'graphify');
|
|
380
|
+
if (story.story_id && surfaces.length > 0) {
|
|
381
|
+
entries.push({
|
|
382
|
+
story_id: story.story_id,
|
|
383
|
+
surfaces,
|
|
384
|
+
source_types: ['graphify'],
|
|
385
|
+
source_paths: [relativeGraphPath]
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
for (const node of graph.nodes ?? []) {
|
|
390
|
+
const storyIds = [
|
|
391
|
+
node.story_id,
|
|
392
|
+
...(Array.isArray(node.story_ids) ? node.story_ids : [])
|
|
393
|
+
].filter(Boolean);
|
|
394
|
+
if (storyIds.length === 0) continue;
|
|
395
|
+
const surfaces = normalizeSurfaceEvidence([node.path, node.file, node.route, node.api, node.component, node].filter(Boolean), relativeGraphPath, 'graphify');
|
|
396
|
+
if (surfaces.length === 0) continue;
|
|
397
|
+
for (const storyId of storyIds) {
|
|
398
|
+
entries.push({
|
|
399
|
+
story_id: storyId,
|
|
400
|
+
surfaces,
|
|
401
|
+
source_types: ['graphify'],
|
|
402
|
+
source_paths: [relativeGraphPath]
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
return entries;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
async function readGateEvidence(root) {
|
|
410
|
+
const prRoot = path.join(getWorkspaceDir(root), 'pr');
|
|
411
|
+
let entries;
|
|
412
|
+
try {
|
|
413
|
+
entries = await readdir(prRoot, { withFileTypes: true });
|
|
414
|
+
} catch (error) {
|
|
415
|
+
if (error.code === 'ENOENT') return [];
|
|
416
|
+
throw error;
|
|
417
|
+
}
|
|
418
|
+
const evidence = [];
|
|
419
|
+
for (const entry of entries) {
|
|
420
|
+
if (!entry.isDirectory()) continue;
|
|
421
|
+
const storyId = entry.name;
|
|
422
|
+
const prDir = path.join(prRoot, storyId);
|
|
423
|
+
evidence.push(...await readVerificationEvidence(prDir, root, storyId));
|
|
424
|
+
evidence.push(...await readGateDagEvidence(prDir, root, storyId));
|
|
425
|
+
}
|
|
426
|
+
return evidence;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
async function readVerificationEvidence(prDir, root, storyId) {
|
|
430
|
+
const evidencePath = path.join(prDir, 'verification-evidence.json');
|
|
431
|
+
let parsed;
|
|
432
|
+
try {
|
|
433
|
+
parsed = JSON.parse(await readFile(evidencePath, 'utf8'));
|
|
434
|
+
} catch (error) {
|
|
435
|
+
if (error.code === 'ENOENT') return [];
|
|
436
|
+
throw error;
|
|
437
|
+
}
|
|
438
|
+
const relativePath = toWorkspaceRelative(root, evidencePath);
|
|
439
|
+
const commands = (parsed.commands ?? []).map((command) => ({
|
|
440
|
+
kind: command.kind ?? 'verification',
|
|
441
|
+
ref: command.status ?? 'unknown',
|
|
442
|
+
source: relativePath,
|
|
443
|
+
command: command.command ?? null
|
|
444
|
+
}));
|
|
445
|
+
return commands.length > 0
|
|
446
|
+
? [{
|
|
447
|
+
story_id: parsed.story_id ?? storyId,
|
|
448
|
+
gate_evidence: commands,
|
|
449
|
+
source_types: ['gate_evidence'],
|
|
450
|
+
source_paths: [relativePath]
|
|
451
|
+
}]
|
|
452
|
+
: [];
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
async function readGateDagEvidence(prDir, root, storyId) {
|
|
456
|
+
const gateDagPath = path.join(prDir, 'gate-dag.json');
|
|
457
|
+
let parsed;
|
|
458
|
+
try {
|
|
459
|
+
parsed = JSON.parse(await readFile(gateDagPath, 'utf8'));
|
|
460
|
+
} catch (error) {
|
|
461
|
+
if (error.code === 'ENOENT') return [];
|
|
462
|
+
throw error;
|
|
463
|
+
}
|
|
464
|
+
const relativePath = toWorkspaceRelative(root, gateDagPath);
|
|
465
|
+
const nodes = Array.isArray(parsed.nodes)
|
|
466
|
+
? parsed.nodes
|
|
467
|
+
: Array.isArray(parsed.gate_dag?.nodes)
|
|
468
|
+
? parsed.gate_dag.nodes
|
|
469
|
+
: [];
|
|
470
|
+
const gateEvidence = nodes.map((node) => ({
|
|
471
|
+
kind: 'gate',
|
|
472
|
+
ref: `${node.id ?? node.gate ?? 'unknown'}:${node.status ?? 'unknown'}`,
|
|
473
|
+
source: relativePath
|
|
474
|
+
}));
|
|
475
|
+
if (parsed.overall_status) {
|
|
476
|
+
gateEvidence.unshift({ kind: 'gate_dag', ref: parsed.overall_status, source: relativePath });
|
|
477
|
+
}
|
|
478
|
+
return gateEvidence.length > 0
|
|
479
|
+
? [{
|
|
480
|
+
story_id: parsed.story_id ?? storyId,
|
|
481
|
+
gate_evidence: gateEvidence,
|
|
482
|
+
source_types: ['gate_evidence'],
|
|
483
|
+
source_paths: [relativePath]
|
|
484
|
+
}]
|
|
485
|
+
: [];
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
async function listMarkdownFiles(dir) {
|
|
489
|
+
let entries;
|
|
490
|
+
try {
|
|
491
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
492
|
+
} catch (error) {
|
|
493
|
+
if (error.code === 'ENOENT') return [];
|
|
494
|
+
throw error;
|
|
495
|
+
}
|
|
496
|
+
const files = [];
|
|
497
|
+
for (const entry of entries) {
|
|
498
|
+
const entryPath = path.join(dir, entry.name);
|
|
499
|
+
if (entry.isDirectory()) {
|
|
500
|
+
files.push(...await listMarkdownFiles(entryPath));
|
|
501
|
+
continue;
|
|
502
|
+
}
|
|
503
|
+
if (entry.isFile() && entry.name.endsWith('.md')) files.push(entryPath);
|
|
504
|
+
}
|
|
505
|
+
return files;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function parseStoryDoc(relativePath, content) {
|
|
509
|
+
const { frontmatter, body } = parseFrontmatter(content);
|
|
510
|
+
const title = frontmatter.title ?? body.match(/^#\s+(.+)$/m)?.[1]?.replace(/^Story:\s*/i, '').trim() ?? path.basename(relativePath, '.md');
|
|
511
|
+
return {
|
|
512
|
+
story_id: frontmatter.story_id ?? slugify(path.basename(relativePath, '.md')),
|
|
513
|
+
title,
|
|
514
|
+
status: frontmatter.status ?? 'active',
|
|
515
|
+
view: frontmatter.view ?? null,
|
|
516
|
+
category: frontmatter.category ?? null,
|
|
517
|
+
period: frontmatter.period ?? null,
|
|
518
|
+
created_at: frontmatter.created_at ?? null,
|
|
519
|
+
updated_at: frontmatter.updated_at ?? null,
|
|
520
|
+
journey_activity: frontmatter.journey_activity ?? null,
|
|
521
|
+
journey_step: frontmatter.journey_step ?? null,
|
|
522
|
+
journey_step_label: frontmatter.journey_step_label ?? null,
|
|
523
|
+
release_slice: frontmatter.release_slice ?? null,
|
|
524
|
+
enabler_kind: frontmatter.enabler_kind ?? null,
|
|
525
|
+
journey_to: frontmatter.journey_to ?? frontmatter.post_step_destination ?? null,
|
|
526
|
+
body,
|
|
527
|
+
acceptance_focus: extractAcceptanceCriteria(body),
|
|
528
|
+
source_types: ['story_doc'],
|
|
529
|
+
source_paths: [relativePath]
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function parseFrontmatter(content) {
|
|
534
|
+
const match = content.match(/^---\n([\s\S]*?)\n---\n?/);
|
|
535
|
+
if (!match) return { frontmatter: {}, body: content };
|
|
536
|
+
const frontmatter = {};
|
|
537
|
+
for (const line of match[1].split(/\r?\n/)) {
|
|
538
|
+
const item = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
|
|
539
|
+
if (!item) continue;
|
|
540
|
+
frontmatter[item[1]] = item[2].replace(/^["']|["']$/g, '').trim();
|
|
541
|
+
}
|
|
542
|
+
return { frontmatter, body: content.slice(match[0].length) };
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function extractAcceptanceCriteria(body) {
|
|
546
|
+
const section = body.split(/^##\s+(?:Acceptance Criteria|受け入れ基準|受入基準)\s*$/m)[1] ?? '';
|
|
547
|
+
return section
|
|
548
|
+
.split(/\r?\n/)
|
|
549
|
+
.map((line) => line.match(/^\s*-\s+\[[ xX]\]\s+(.+)$|^\s*-\s+(.+)$/))
|
|
550
|
+
.filter(Boolean)
|
|
551
|
+
.map((match) => (match[1] ?? match[2]).trim())
|
|
552
|
+
.slice(0, 12);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function inferStoryIdFromText(text) {
|
|
556
|
+
return text.match(/\bstory-[a-z0-9][a-z0-9-]+\b/i)?.[0] ?? null;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function extractSpecClauses(body, source) {
|
|
560
|
+
return body
|
|
561
|
+
.split(/\r?\n/)
|
|
562
|
+
.map((line) => line.match(/^\s*-\s+`?([A-Z][A-Z0-9]+-[A-Z0-9-]+)`?:\s+(.+)$/))
|
|
563
|
+
.filter(Boolean)
|
|
564
|
+
.map((match) => ({
|
|
565
|
+
id: match[1],
|
|
566
|
+
text: match[2].trim(),
|
|
567
|
+
source
|
|
568
|
+
}))
|
|
569
|
+
.slice(0, 40);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function buildSurfaceEvidenceFromPaths(paths, source) {
|
|
573
|
+
return normalizeSurfaceEvidence(paths, source, source);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function normalizeSurfaceEvidence(items, source, defaultKind = 'surface') {
|
|
577
|
+
const values = Array.isArray(items) ? items : [items];
|
|
578
|
+
return values
|
|
579
|
+
.flatMap((item) => normalizeSurfaceItem(item, source, defaultKind))
|
|
580
|
+
.filter(Boolean);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function normalizeSurfaceItem(item, source, defaultKind) {
|
|
584
|
+
if (!item) return [];
|
|
585
|
+
if (typeof item === 'string') {
|
|
586
|
+
const kind = classifySurfacePath(item) ?? defaultKind;
|
|
587
|
+
return [{ kind, ref: item, source }];
|
|
588
|
+
}
|
|
589
|
+
if (typeof item !== 'object') return [];
|
|
590
|
+
const ref = item.ref ?? item.path ?? item.file ?? item.route ?? item.api ?? item.component ?? item.id ?? null;
|
|
591
|
+
if (!ref) return [];
|
|
592
|
+
const kind = item.kind ?? item.type ?? classifySurfacePath(ref) ?? defaultKind;
|
|
593
|
+
return [{ kind, ref: String(ref), source }];
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
function classifySurfacePath(value) {
|
|
597
|
+
const text = String(value ?? '').toLowerCase();
|
|
598
|
+
if (!text) return null;
|
|
599
|
+
if (/\/api\/|(^|\/)api($|\/)|route\.(js|ts)$/.test(text)) return 'api';
|
|
600
|
+
if (/\/app\/|\/pages\/|page\.(jsx|tsx|js|ts)$|layout\.(jsx|tsx|js|ts)$/.test(text)) return 'route';
|
|
601
|
+
if (/\/components?\//.test(text) || /\.(jsx|tsx)$/.test(text)) return 'component';
|
|
602
|
+
if (/^docs\//.test(text) || /\.md$/.test(text)) return 'document';
|
|
603
|
+
if (/config|\.json$|\.ya?ml$/.test(text)) return 'config';
|
|
604
|
+
return null;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
function emptyStoryInput(storyId) {
|
|
608
|
+
return normalizeStoryInput({ story_id: storyId });
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function normalizeStoryInput(story) {
|
|
612
|
+
return {
|
|
613
|
+
story_id: story.story_id,
|
|
614
|
+
title: story.title ?? story.story_id,
|
|
615
|
+
status: story.status ?? 'active',
|
|
616
|
+
view: story.view ?? null,
|
|
617
|
+
category: story.category ?? null,
|
|
618
|
+
period: story.period ?? null,
|
|
619
|
+
created_at: story.created_at ?? null,
|
|
620
|
+
updated_at: story.updated_at ?? null,
|
|
621
|
+
journey_activity: story.journey_activity ?? null,
|
|
622
|
+
journey_step: story.journey_step ?? null,
|
|
623
|
+
journey_step_label: story.journey_step_label ?? null,
|
|
624
|
+
release_slice: story.release_slice ?? null,
|
|
625
|
+
enabler_kind: story.enabler_kind ?? null,
|
|
626
|
+
journey_to: story.journey_to ?? null,
|
|
627
|
+
body: story.body ?? '',
|
|
628
|
+
acceptance_focus: story.acceptance_focus ?? story.derived_definition?.acceptance_focus ?? [],
|
|
629
|
+
derived_definition: story.derived_definition ?? null,
|
|
630
|
+
workflow_position: story.workflow_position ?? null,
|
|
631
|
+
spec_clauses: story.spec_clauses ?? [],
|
|
632
|
+
surfaces: story.surfaces ?? buildSurfaceEvidenceFromPaths(story.source_paths ?? [], 'source_path'),
|
|
633
|
+
gate_evidence: story.gate_evidence ?? [],
|
|
634
|
+
source_types: story.source_types ?? [],
|
|
635
|
+
source_paths: story.source_paths ?? []
|
|
636
|
+
};
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
function mergeStoryInput(a, b) {
|
|
640
|
+
return {
|
|
641
|
+
...a,
|
|
642
|
+
...Object.fromEntries(Object.entries(b).filter(([key, value]) => {
|
|
643
|
+
if (value === null || value === undefined || value === '') return false;
|
|
644
|
+
if (key === 'title' && value === b.story_id && a.title && a.title !== a.story_id) return false;
|
|
645
|
+
return true;
|
|
646
|
+
})),
|
|
647
|
+
source_types: [...new Set([...(a.source_types ?? []), ...(b.source_types ?? [])])],
|
|
648
|
+
source_paths: [...new Set([...(a.source_paths ?? []), ...(b.source_paths ?? [])])],
|
|
649
|
+
acceptance_focus: [...new Set([...(a.acceptance_focus ?? []), ...(b.acceptance_focus ?? [])])],
|
|
650
|
+
spec_clauses: mergeEvidenceArray(a.spec_clauses, b.spec_clauses, (item) => item.id),
|
|
651
|
+
surfaces: mergeEvidenceArray(a.surfaces, b.surfaces, (item) => `${item.kind}:${item.ref}`),
|
|
652
|
+
gate_evidence: mergeEvidenceArray(a.gate_evidence, b.gate_evidence, (item) => `${item.kind}:${item.ref}`)
|
|
653
|
+
};
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
function mergeEvidenceArray(a = [], b = [], keyFn) {
|
|
657
|
+
const byKey = new Map();
|
|
658
|
+
for (const item of [...a, ...b]) {
|
|
659
|
+
if (!item) continue;
|
|
660
|
+
const key = keyFn(item);
|
|
661
|
+
if (!key) continue;
|
|
662
|
+
byKey.set(key, { ...(byKey.get(key) ?? {}), ...item });
|
|
663
|
+
}
|
|
664
|
+
return [...byKey.values()];
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
function placeStory(story) {
|
|
668
|
+
const explicitActivity = normalizeActivity(story.journey_activity);
|
|
669
|
+
const inferredActivity = explicitActivity ?? normalizeActivity(story.workflow_position?.stage) ?? inferActivity(story);
|
|
670
|
+
const placementKind = isEnablerStory(story, inferredActivity) ? 'enabler' : 'backbone_step';
|
|
671
|
+
const stepId = story.journey_step ? slugify(story.journey_step) : inferStepId(story, inferredActivity);
|
|
672
|
+
const confidence = story.journey_activity || story.journey_step
|
|
673
|
+
? 'high'
|
|
674
|
+
: story.workflow_position?.confidence ?? (inferredActivity === 'knowledge_recovery' ? 'low' : 'medium');
|
|
675
|
+
return {
|
|
676
|
+
...story,
|
|
677
|
+
activity_id: inferredActivity,
|
|
678
|
+
activity_label: ACTIVITY_LABELS[inferredActivity] ?? inferredActivity,
|
|
679
|
+
step_id: stepId,
|
|
680
|
+
step_label: story.journey_step_label ?? inferStepLabel(story, stepId),
|
|
681
|
+
release_slice: normalizeReleaseSlice(story.release_slice) ?? inferReleaseSlice(story, placementKind, inferredActivity),
|
|
682
|
+
placement_kind: placementKind,
|
|
683
|
+
placement_status: inferredActivity && stepId ? 'placed' : 'unplaced',
|
|
684
|
+
placement_reason: inferredActivity && stepId ? null : 'Journey activity or step could not be inferred.',
|
|
685
|
+
confidence,
|
|
686
|
+
evidence: buildPlacementEvidence(story)
|
|
687
|
+
};
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
function normalizeActivity(value) {
|
|
691
|
+
const normalized = slugify(value)?.replace(/-/g, '_');
|
|
692
|
+
const aliases = {
|
|
693
|
+
entry: 'activation',
|
|
694
|
+
personalization: 'core_usage',
|
|
695
|
+
usage: 'core_usage',
|
|
696
|
+
risk: 'risk_control',
|
|
697
|
+
security: 'risk_control',
|
|
698
|
+
quality: 'quality_gate',
|
|
699
|
+
docs: 'knowledge_recovery'
|
|
700
|
+
};
|
|
701
|
+
return ACTIVITY_ORDER.includes(normalized) ? normalized : aliases[normalized] ?? null;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
function inferActivity(story) {
|
|
705
|
+
const text = storyText(story);
|
|
706
|
+
if (/public-discovery|seo|article|content|cms|landing|contact|waiting|問い合わせ|検索|流入/.test(text)) return 'acquisition';
|
|
707
|
+
if (/auth|login|signup|account|onboarding|navigation|利用開始|登録|認証|ログイン/.test(text)) return 'activation';
|
|
708
|
+
if (/profile|personal|dashboard|home|workflow|sample|core|主要|編集|保存|確認/.test(text)) return 'core_usage';
|
|
709
|
+
if (/billing|premium|stripe|payment|subscription|課金|支払い/.test(text)) return 'monetization';
|
|
710
|
+
if (/notification|retention|email|push|通知|再訪|継続/.test(text)) return 'retention';
|
|
711
|
+
if (/security|auth-boundary|trust|permission|権限|信頼|境界/.test(text)) return 'risk_control';
|
|
712
|
+
if (/quality|test|ci|e2e|gate|coverage|検証|品質/.test(text)) return 'quality_gate';
|
|
713
|
+
if (/architecture|adr|spec|contract|journey|patton|構造|設計/.test(text)) return 'architecture';
|
|
714
|
+
if (/ops|deploy|runtime|observability|health|運用|デプロイ/.test(text)) return 'operations';
|
|
715
|
+
if (/docs|ssot|recovery|正本|復元/.test(text)) return 'knowledge_recovery';
|
|
716
|
+
return story.category === 'product' ? 'core_usage' : 'knowledge_recovery';
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
function isEnablerStory(story, activity) {
|
|
720
|
+
if (story.enabler_kind) return true;
|
|
721
|
+
if (['architecture', 'risk_control', 'operations', 'quality_gate', 'knowledge_recovery'].includes(activity)) return true;
|
|
722
|
+
return ['architecture', 'security', 'ops', 'quality', 'docs'].includes(story.category);
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
function inferStepId(story, activity) {
|
|
726
|
+
const text = storyText(story);
|
|
727
|
+
if (activity === 'acquisition') {
|
|
728
|
+
if (/contact|waiting|問い合わせ/.test(text)) return 'contact';
|
|
729
|
+
if (/cms|content|article|記事/.test(text)) return 'content';
|
|
730
|
+
return 'discover';
|
|
731
|
+
}
|
|
732
|
+
if (activity === 'activation') {
|
|
733
|
+
if (/onboarding|初回/.test(text)) return 'onboarding';
|
|
734
|
+
if (/navigation|home|shell|ナビ/.test(text)) return 'enter-app';
|
|
735
|
+
return 'signup';
|
|
736
|
+
}
|
|
737
|
+
if (activity === 'core_usage') {
|
|
738
|
+
if (/profile|personal|個人/.test(text)) return 'personalize';
|
|
739
|
+
if (/sample|review/.test(text)) return 'review-work';
|
|
740
|
+
return 'first-value';
|
|
741
|
+
}
|
|
742
|
+
if (activity === 'monetization') return 'pay';
|
|
743
|
+
if (activity === 'retention') return 'return';
|
|
744
|
+
if (activity === 'risk_control') return 'trust-boundary';
|
|
745
|
+
if (activity === 'quality_gate') return 'quality-evidence';
|
|
746
|
+
if (activity === 'architecture') return 'architecture-decision';
|
|
747
|
+
if (activity === 'operations') return 'operate';
|
|
748
|
+
return 'recover-source';
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
function inferStepLabel(story, stepId) {
|
|
752
|
+
const labels = {
|
|
753
|
+
discover: '価値を理解する',
|
|
754
|
+
contact: '問い合わせる',
|
|
755
|
+
content: 'コンテンツを見る',
|
|
756
|
+
signup: '登録・認証する',
|
|
757
|
+
onboarding: '初期設定する',
|
|
758
|
+
'enter-app': 'アプリに入る',
|
|
759
|
+
'first-value': '最初の価値を得る',
|
|
760
|
+
personalize: '体験を個人化する',
|
|
761
|
+
'review-work': '作業を確認する',
|
|
762
|
+
pay: '支払う',
|
|
763
|
+
return: '再訪する',
|
|
764
|
+
'trust-boundary': '信頼境界を守る',
|
|
765
|
+
'quality-evidence': '品質証跡を揃える',
|
|
766
|
+
'architecture-decision': '設計判断を確定する',
|
|
767
|
+
operate: '運用する',
|
|
768
|
+
'recover-source': '正本を復元する'
|
|
769
|
+
};
|
|
770
|
+
return labels[stepId] ?? story.title ?? stepId;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
function normalizeReleaseSlice(value) {
|
|
774
|
+
const normalized = slugify(value)?.replace(/-/g, '_');
|
|
775
|
+
if (RELEASE_SLICE_ORDER.includes(normalized)) return normalized;
|
|
776
|
+
return null;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
function inferReleaseSlice(story, placementKind, activity) {
|
|
780
|
+
if (placementKind === 'enabler') return 'hardening';
|
|
781
|
+
if (['acquisition', 'activation', 'core_usage'].includes(activity)) return 'walking_skeleton';
|
|
782
|
+
if (['monetization', 'retention'].includes(activity)) return 'next_slice';
|
|
783
|
+
return 'hardening';
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
function buildPlacementEvidence(story) {
|
|
787
|
+
return [
|
|
788
|
+
...(story.source_paths ?? []).map((file) => ({ type: 'source_path', ref: file })),
|
|
789
|
+
...(story.spec_clauses ?? []).map((clause) => ({ type: 'spec_clause', ref: clause.id, source: clause.source })),
|
|
790
|
+
...(story.surfaces ?? []).map((surface) => ({ type: 'surface', ref: `${surface.kind}:${surface.ref}`, source: surface.source })),
|
|
791
|
+
...(story.gate_evidence ?? []).map((evidence) => ({ type: 'gate_evidence', ref: `${evidence.kind}:${evidence.ref}`, source: evidence.source })),
|
|
792
|
+
story.workflow_position ? { type: 'workflow_position', ref: story.workflow_position.stage } : null,
|
|
793
|
+
story.journey_activity ? { type: 'frontmatter', ref: 'journey_activity' } : null,
|
|
794
|
+
story.journey_step ? { type: 'frontmatter', ref: 'journey_step' } : null
|
|
795
|
+
].filter(Boolean);
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
function buildBackbone(placements) {
|
|
799
|
+
const byActivity = new Map();
|
|
800
|
+
for (const placement of placements.filter((item) => item.placement_status === 'placed')) {
|
|
801
|
+
if (!byActivity.has(placement.activity_id)) {
|
|
802
|
+
byActivity.set(placement.activity_id, {
|
|
803
|
+
activity_id: placement.activity_id,
|
|
804
|
+
label: placement.activity_label,
|
|
805
|
+
order: ACTIVITY_ORDER.indexOf(placement.activity_id),
|
|
806
|
+
steps: new Map()
|
|
807
|
+
});
|
|
808
|
+
}
|
|
809
|
+
const activity = byActivity.get(placement.activity_id);
|
|
810
|
+
if (!activity.steps.has(placement.step_id)) {
|
|
811
|
+
activity.steps.set(placement.step_id, {
|
|
812
|
+
step_id: placement.step_id,
|
|
813
|
+
label: placement.step_label,
|
|
814
|
+
order: activity.steps.size,
|
|
815
|
+
story_ids: [],
|
|
816
|
+
story_labels: {},
|
|
817
|
+
enabler_story_ids: [],
|
|
818
|
+
enabler_story_labels: {},
|
|
819
|
+
evidence: [],
|
|
820
|
+
confidence: placement.confidence
|
|
821
|
+
});
|
|
822
|
+
}
|
|
823
|
+
const step = activity.steps.get(placement.step_id);
|
|
824
|
+
const target = placement.placement_kind === 'enabler' ? step.enabler_story_ids : step.story_ids;
|
|
825
|
+
const targetLabels = placement.placement_kind === 'enabler' ? step.enabler_story_labels : step.story_labels;
|
|
826
|
+
target.push(placement.story_id);
|
|
827
|
+
targetLabels[placement.story_id] = formatStoryTitleForHuman(placement.title, placement.story_id);
|
|
828
|
+
step.evidence.push(...placement.evidence);
|
|
829
|
+
step.confidence = combineConfidence(step.confidence, placement.confidence);
|
|
830
|
+
}
|
|
831
|
+
return [...byActivity.values()]
|
|
832
|
+
.sort((a, b) => a.order - b.order)
|
|
833
|
+
.map((activity) => ({
|
|
834
|
+
...activity,
|
|
835
|
+
steps: [...activity.steps.values()].map((step, index) => ({
|
|
836
|
+
...step,
|
|
837
|
+
order: index,
|
|
838
|
+
story_ids: [...new Set(step.story_ids)],
|
|
839
|
+
story_labels: selectStoryLabels(step.story_labels, step.story_ids),
|
|
840
|
+
enabler_story_ids: [...new Set(step.enabler_story_ids)],
|
|
841
|
+
enabler_story_labels: selectStoryLabels(step.enabler_story_labels, step.enabler_story_ids),
|
|
842
|
+
evidence: dedupeEvidence(step.evidence)
|
|
843
|
+
}))
|
|
844
|
+
}));
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
function buildReleaseSlices(placements, backbone) {
|
|
848
|
+
return RELEASE_SLICE_ORDER.slice(0, 3).map((sliceId) => {
|
|
849
|
+
const placed = placements.filter((placement) => placement.release_slice === sliceId);
|
|
850
|
+
return {
|
|
851
|
+
slice_id: sliceId,
|
|
852
|
+
label: formatReleaseSliceName({ slice_id: sliceId }),
|
|
853
|
+
kind: sliceId,
|
|
854
|
+
story_ids: placed.map((placement) => placement.story_id),
|
|
855
|
+
required_step_ids: sliceId === 'walking_skeleton' ? inferWalkingSkeletonRequiredSteps(backbone) : [],
|
|
856
|
+
status: placed.length > 0 ? 'present' : 'empty'
|
|
857
|
+
};
|
|
858
|
+
});
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
function buildWalkingSkeleton(backbone, releaseSlices) {
|
|
862
|
+
const requiredStepIds = releaseSlices.find((slice) => slice.slice_id === 'walking_skeleton')?.required_step_ids ?? [];
|
|
863
|
+
if (requiredStepIds.length === 0) {
|
|
864
|
+
return {
|
|
865
|
+
status: 'not_applicable',
|
|
866
|
+
required_step_ids: [],
|
|
867
|
+
covered_step_ids: [],
|
|
868
|
+
gaps: [],
|
|
869
|
+
story_ids: []
|
|
870
|
+
};
|
|
871
|
+
}
|
|
872
|
+
const steps = backbone.flatMap((activity) => activity.steps.map((step) => ({ ...step, activity_id: activity.activity_id })));
|
|
873
|
+
const coveredStepIds = steps
|
|
874
|
+
.filter((step) => requiredStepIds.includes(step.step_id) && step.story_ids.length > 0)
|
|
875
|
+
.map((step) => step.step_id);
|
|
876
|
+
const gaps = requiredStepIds
|
|
877
|
+
.filter((stepId) => !coveredStepIds.includes(stepId))
|
|
878
|
+
.map((stepId) => ({ step_id: stepId, label: inferStepLabel({}, stepId) }));
|
|
879
|
+
return {
|
|
880
|
+
status: gaps.length > 0 ? 'needs_evidence' : 'covered',
|
|
881
|
+
required_step_ids: requiredStepIds,
|
|
882
|
+
covered_step_ids: coveredStepIds,
|
|
883
|
+
gaps,
|
|
884
|
+
story_ids: steps
|
|
885
|
+
.filter((step) => coveredStepIds.includes(step.step_id))
|
|
886
|
+
.flatMap((step) => step.story_ids)
|
|
887
|
+
};
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
function inferWalkingSkeletonRequiredSteps(backbone) {
|
|
891
|
+
const activityIds = new Set(backbone.map((activity) => activity.activity_id));
|
|
892
|
+
if (!['acquisition', 'activation', 'core_usage'].some((activity) => activityIds.has(activity))) return [];
|
|
893
|
+
return ['discover', 'signup', 'first-value'];
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
function buildJourneyConflicts(placements) {
|
|
897
|
+
const byStep = new Map();
|
|
898
|
+
for (const placement of placements) {
|
|
899
|
+
if (!placement.journey_to) continue;
|
|
900
|
+
const key = `${placement.activity_id}:${placement.step_id}`;
|
|
901
|
+
if (!byStep.has(key)) byStep.set(key, []);
|
|
902
|
+
byStep.get(key).push(placement);
|
|
903
|
+
}
|
|
904
|
+
const conflicts = [];
|
|
905
|
+
for (const [key, items] of byStep.entries()) {
|
|
906
|
+
const destinations = [...new Set(items.map((item) => item.journey_to))];
|
|
907
|
+
if (destinations.length <= 1) continue;
|
|
908
|
+
conflicts.push({
|
|
909
|
+
id: `journey-conflict:${key}`,
|
|
910
|
+
type: 'step_destination_conflict',
|
|
911
|
+
severity: 'needs_review',
|
|
912
|
+
activity_id: items[0].activity_id,
|
|
913
|
+
step_id: items[0].step_id,
|
|
914
|
+
story_ids: items.map((item) => item.story_id),
|
|
915
|
+
destinations,
|
|
916
|
+
reason: 'Multiple active Stories define different next destinations for the same Journey step.'
|
|
917
|
+
});
|
|
918
|
+
}
|
|
919
|
+
return conflicts;
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
function findStoryPlacement(journey, storyId) {
|
|
923
|
+
if (!storyId) return null;
|
|
924
|
+
for (const activity of journey.backbone ?? []) {
|
|
925
|
+
for (const step of activity.steps ?? []) {
|
|
926
|
+
if ((step.story_ids ?? []).includes(storyId) || (step.enabler_story_ids ?? []).includes(storyId)) {
|
|
927
|
+
return {
|
|
928
|
+
activity_id: activity.activity_id,
|
|
929
|
+
activity_label: activity.label,
|
|
930
|
+
step_id: step.step_id,
|
|
931
|
+
step_label: step.label,
|
|
932
|
+
placement_kind: (step.enabler_story_ids ?? []).includes(storyId) ? 'enabler' : 'backbone_step',
|
|
933
|
+
confidence: step.confidence
|
|
934
|
+
};
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
return null;
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
function renderJourneyMapMarkdown(journey) {
|
|
942
|
+
const activities = journey.backbone ?? [];
|
|
943
|
+
const slices = journey.release_slices ?? [];
|
|
944
|
+
const generatedAt = journey.generated_at ?? '-';
|
|
945
|
+
const storyCount = journey.source_story_ids?.length ?? 0;
|
|
946
|
+
const walkingSkeletonStatus = journey.walking_skeleton?.status ?? 'unknown';
|
|
947
|
+
const conflictCount = journey.conflicts?.length ?? 0;
|
|
948
|
+
const openQuestionCount = journey.open_questions?.length ?? 0;
|
|
949
|
+
const unplacedCount = journey.unplaced_stories?.length ?? 0;
|
|
950
|
+
const headline = buildJourneyHeadline(journey);
|
|
951
|
+
const nextJudgments = buildJourneyNextJudgments(journey);
|
|
952
|
+
const flowRows = renderJourneyFlowRows(activities);
|
|
953
|
+
const sliceRows = renderReleaseSliceRows(slices, journey);
|
|
954
|
+
const storyLabels = buildStoryLabelIndex(activities);
|
|
955
|
+
const header = ['スライス', ...activities.map((activity) => activity.label)].join(' | ');
|
|
956
|
+
const divider = ['---', ...activities.map(() => '---')].join(' | ');
|
|
957
|
+
const rows = slices.map((slice) => [
|
|
958
|
+
`${formatReleaseSliceName(slice)}(${formatJourneyStatus(slice.status)})`,
|
|
959
|
+
...activities.map((activity) => renderJourneyCell(activity, slice))
|
|
960
|
+
].join(' | '));
|
|
961
|
+
const conflicts = (journey.conflicts ?? []).length === 0
|
|
962
|
+
? '-'
|
|
963
|
+
: journey.conflicts.map((conflict) => `- ${conflict.id}: ${conflict.story_ids.join(', ')} -> ${conflict.destinations.join(' / ')}`).join('\n');
|
|
964
|
+
const questions = (journey.open_questions ?? []).length === 0
|
|
965
|
+
? '-'
|
|
966
|
+
: journey.open_questions.map((question) => `- ${question.id}: ${question.question}`).join('\n');
|
|
967
|
+
const unplaced = (journey.unplaced_stories ?? []).length === 0
|
|
968
|
+
? '-'
|
|
969
|
+
: journey.unplaced_stories.map((story) => `- ${story.story_id}: ${story.reason}`).join('\n');
|
|
970
|
+
const evidenceBindings = renderEvidenceBindings(activities);
|
|
971
|
+
return `# VibePro Journey
|
|
972
|
+
|
|
973
|
+
| 項目 | 内容 |
|
|
974
|
+
|------|------|
|
|
975
|
+
| Journey | ${journey.journey_id} |
|
|
976
|
+
| 生成日時 | ${generatedAt} |
|
|
977
|
+
| 対象Story | ${storyCount} |
|
|
978
|
+
| 最小体験 | ${formatJourneyStatus(walkingSkeletonStatus)} |
|
|
979
|
+
| Journey衝突 | ${conflictCount} |
|
|
980
|
+
| 未配置Story | ${unplacedCount} |
|
|
981
|
+
| 未解決の問い | ${openQuestionCount} |
|
|
982
|
+
|
|
983
|
+
## いまの結論
|
|
984
|
+
|
|
985
|
+
${headline}
|
|
986
|
+
|
|
987
|
+
## 現在の体験フロー
|
|
988
|
+
|
|
989
|
+
| 順 | 体験段階 | 状態 | 主なステップ | 判断 |
|
|
990
|
+
|---:|---|---|---|---|
|
|
991
|
+
${flowRows}
|
|
992
|
+
|
|
993
|
+
## リリーススライス
|
|
994
|
+
|
|
995
|
+
| スライス | 状態 | Story数 | 判断 |
|
|
996
|
+
|---|---|---:|---|
|
|
997
|
+
${sliceRows}
|
|
998
|
+
|
|
999
|
+
## 次の判断
|
|
1000
|
+
|
|
1001
|
+
${nextJudgments}
|
|
1002
|
+
|
|
1003
|
+
## 監査ログ: Patton式マップ
|
|
1004
|
+
|
|
1005
|
+
| ${header} |
|
|
1006
|
+
| ${divider} |
|
|
1007
|
+
${rows.map((row) => `| ${row} |`).join('\n')}
|
|
1008
|
+
|
|
1009
|
+
## 監査ログ: 最小体験
|
|
1010
|
+
|
|
1011
|
+
- 状態: ${formatJourneyStatus(walkingSkeletonStatus)}
|
|
1012
|
+
- 必須ステップ: ${(journey.walking_skeleton?.required_step_ids ?? []).join(', ') || '-'}
|
|
1013
|
+
- カバー済み: ${(journey.walking_skeleton?.covered_step_ids ?? []).join(', ') || '-'}
|
|
1014
|
+
- 対象Story: ${summarizeStoryRefs(journey.walking_skeleton?.story_ids ?? [], { labels: storyLabels, limit: 12 })}
|
|
1015
|
+
|
|
1016
|
+
## 監査ログ: 証跡バインディング
|
|
1017
|
+
|
|
1018
|
+
${evidenceBindings}
|
|
1019
|
+
|
|
1020
|
+
## 監査ログ: Journey衝突
|
|
1021
|
+
|
|
1022
|
+
${conflicts}
|
|
1023
|
+
|
|
1024
|
+
## 監査ログ: 未配置Story
|
|
1025
|
+
|
|
1026
|
+
${unplaced}
|
|
1027
|
+
|
|
1028
|
+
## 監査ログ: 未解決の問い
|
|
1029
|
+
|
|
1030
|
+
${questions}
|
|
1031
|
+
`;
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
function buildJourneyHeadline(journey) {
|
|
1035
|
+
const walkingSkeletonStatus = journey.walking_skeleton?.status ?? 'unknown';
|
|
1036
|
+
const conflictCount = journey.conflicts?.length ?? 0;
|
|
1037
|
+
const openQuestionCount = journey.open_questions?.length ?? 0;
|
|
1038
|
+
const unplacedCount = journey.unplaced_stories?.length ?? 0;
|
|
1039
|
+
const nextSlice = (journey.release_slices ?? []).find((slice) => slice.slice_id === 'next_slice');
|
|
1040
|
+
const lines = [];
|
|
1041
|
+
if (walkingSkeletonStatus === 'covered' && conflictCount === 0 && openQuestionCount === 0 && unplacedCount === 0) {
|
|
1042
|
+
lines.push('- 現在のJourneyは、最小体験が成立しており、未解決の衝突や未配置Storyはありません。');
|
|
1043
|
+
} else if (walkingSkeletonStatus === 'needs_evidence') {
|
|
1044
|
+
lines.push('- 現在のJourneyは、最小体験に不足があります。まず不足ステップを埋めるStoryまたは証跡を確認してください。');
|
|
1045
|
+
} else {
|
|
1046
|
+
lines.push(`- 現在のJourney状態は ${formatJourneyStatus(walkingSkeletonStatus)} です。衝突、未配置Story、未解決の問いを確認してください。`);
|
|
1047
|
+
}
|
|
1048
|
+
if (nextSlice?.status === 'empty') {
|
|
1049
|
+
lines.push('- 次の成長領域はまだ空です。次に伸ばす体験を明示すると、Story追加とPR分割の判断がしやすくなります。');
|
|
1050
|
+
}
|
|
1051
|
+
if (conflictCount > 0) lines.push(`- Journey衝突が ${conflictCount} 件あります。ユーザー遷移の正本を決めるまで、関連PRでは判断材料として扱ってください。`);
|
|
1052
|
+
if (unplacedCount > 0) lines.push(`- 未配置Storyが ${unplacedCount} 件あります。Journey stepへ置くか、補助Storyとして扱うかを決める必要があります。`);
|
|
1053
|
+
return lines.join('\n');
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
function renderJourneyFlowRows(activities) {
|
|
1057
|
+
if (!Array.isArray(activities) || activities.length === 0) return '| 1 | - | 未生成 | - | Journeyがまだ生成されていません。 |';
|
|
1058
|
+
return activities.map((activity, index) => {
|
|
1059
|
+
const storyCount = sumActivityStories(activity);
|
|
1060
|
+
const enablerCount = sumActivityEnablers(activity);
|
|
1061
|
+
const state = storyCount > 0 ? '成立' : enablerCount > 0 ? '強化中' : '未着手';
|
|
1062
|
+
const steps = (activity.steps ?? [])
|
|
1063
|
+
.map((step) => `${step.label}(${step.story_ids?.length ?? 0} Story${(step.enabler_story_ids?.length ?? 0) > 0 ? ` / 補助 ${step.enabler_story_ids.length}` : ''})`)
|
|
1064
|
+
.join('<br>') || '-';
|
|
1065
|
+
return `| ${index + 1} | ${escapeMarkdownTableCell(activity.label)} | ${state} | ${escapeMarkdownTableCell(steps)} | ${escapeMarkdownTableCell(describeActivityJudgment(activity, state))} |`;
|
|
1066
|
+
}).join('\n');
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
function renderReleaseSliceRows(slices, journey) {
|
|
1070
|
+
if (!Array.isArray(slices) || slices.length === 0) return '| - | 未生成 | 0 | Journey deriveを実行してください。 |';
|
|
1071
|
+
return slices.map((slice) => {
|
|
1072
|
+
const storyCount = slice.story_ids?.length ?? 0;
|
|
1073
|
+
return `| ${escapeMarkdownTableCell(formatReleaseSliceName(slice))} | ${formatJourneyStatus(slice.status)} | ${storyCount} | ${escapeMarkdownTableCell(describeReleaseSliceJudgment(slice, journey))} |`;
|
|
1074
|
+
}).join('\n');
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
function buildJourneyNextJudgments(journey) {
|
|
1078
|
+
const actions = [];
|
|
1079
|
+
const nextSlice = (journey.release_slices ?? []).find((slice) => slice.slice_id === 'next_slice');
|
|
1080
|
+
if (journey.walking_skeleton?.status === 'needs_evidence') {
|
|
1081
|
+
const gaps = (journey.walking_skeleton.gaps ?? []).map((gap) => gap.label ?? gap.step_id).join(' / ');
|
|
1082
|
+
actions.push(`- 最優先: 最小体験の不足を埋める。対象: ${gaps || '-'}`);
|
|
1083
|
+
}
|
|
1084
|
+
if ((journey.conflicts ?? []).length > 0) {
|
|
1085
|
+
actions.push('- Journey衝突を解消する。特に同じstepで遷移先が割れているStoryは、どちらを正本にするか決める。');
|
|
1086
|
+
}
|
|
1087
|
+
if ((journey.unplaced_stories ?? []).length > 0) {
|
|
1088
|
+
actions.push('- 未配置Storyを整理する。ユーザー体験のstepに置くか、品質・信頼・構造の補助Storyとして扱うかを決める。');
|
|
1089
|
+
}
|
|
1090
|
+
if (nextSlice?.status === 'empty') {
|
|
1091
|
+
actions.push('- 次の成長領域を決める。今は次に強化するユーザー体験が空なので、対象体験をStoryとして切り出す。');
|
|
1092
|
+
}
|
|
1093
|
+
if (actions.length === 0) {
|
|
1094
|
+
actions.push('- 未解決のJourney判断はありません。新しいStoryを追加するときは、この体験フローのどこに置くかを確認してください。');
|
|
1095
|
+
}
|
|
1096
|
+
return actions.join('\n');
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
function describeActivityJudgment(activity, state) {
|
|
1100
|
+
if (state === '未着手') return 'まだ体験としては成立していません。';
|
|
1101
|
+
const storyCount = sumActivityStories(activity);
|
|
1102
|
+
const enablerCount = sumActivityEnablers(activity);
|
|
1103
|
+
if (storyCount > 0 && enablerCount > 0) return '体験は成立しており、補助Storyで品質や信頼性を強化しています。';
|
|
1104
|
+
if (storyCount > 0) return 'ユーザーが通る体験として成立しています。';
|
|
1105
|
+
return '主体験ではなく、補助Storyとして体験を支えています。';
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
function describeReleaseSliceJudgment(slice, journey) {
|
|
1109
|
+
if (slice.slice_id === 'walking_skeleton') {
|
|
1110
|
+
return journey.walking_skeleton?.status === 'covered'
|
|
1111
|
+
? '最小体験は成立しています。'
|
|
1112
|
+
: '最小体験に不足があります。';
|
|
1113
|
+
}
|
|
1114
|
+
if (slice.slice_id === 'next_slice') {
|
|
1115
|
+
return slice.status === 'empty'
|
|
1116
|
+
? '次に伸ばす体験が未定義です。'
|
|
1117
|
+
: '次の成長領域が定義されています。';
|
|
1118
|
+
}
|
|
1119
|
+
if (slice.slice_id === 'hardening') {
|
|
1120
|
+
return slice.status === 'present'
|
|
1121
|
+
? '品質、信頼、運用、構造の補強Storyがあります。'
|
|
1122
|
+
: '補強Storyはまだありません。';
|
|
1123
|
+
}
|
|
1124
|
+
return slice.status === 'present' ? 'Storyがあります。' : 'Storyはありません。';
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
function formatReleaseSliceName(slice) {
|
|
1128
|
+
const names = {
|
|
1129
|
+
walking_skeleton: '最小体験',
|
|
1130
|
+
next_slice: '次の成長領域',
|
|
1131
|
+
hardening: '信頼性・品質強化'
|
|
1132
|
+
};
|
|
1133
|
+
return names[slice.slice_id] ?? slice.label ?? slice.slice_id ?? '-';
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
function formatJourneyStatus(status) {
|
|
1137
|
+
const statuses = {
|
|
1138
|
+
covered: '成立',
|
|
1139
|
+
present: 'あり',
|
|
1140
|
+
empty: '空',
|
|
1141
|
+
needs_evidence: '証跡不足',
|
|
1142
|
+
not_applicable: '対象外',
|
|
1143
|
+
available: '利用可能',
|
|
1144
|
+
missing: '未生成',
|
|
1145
|
+
unknown: '不明'
|
|
1146
|
+
};
|
|
1147
|
+
return statuses[status] ?? status ?? '不明';
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
function sumActivityStories(activity) {
|
|
1151
|
+
return (activity.steps ?? []).reduce((sum, step) => sum + (step.story_ids?.length ?? 0), 0);
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
function sumActivityEnablers(activity) {
|
|
1155
|
+
return (activity.steps ?? []).reduce((sum, step) => sum + (step.enabler_story_ids?.length ?? 0), 0);
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
function buildStoryLabelIndex(activities) {
|
|
1159
|
+
const labels = {};
|
|
1160
|
+
for (const activity of activities ?? []) {
|
|
1161
|
+
for (const step of activity.steps ?? []) {
|
|
1162
|
+
Object.assign(labels, step.story_labels ?? {}, step.enabler_story_labels ?? {});
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
return labels;
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
function selectStoryLabels(labels = {}, storyIds = []) {
|
|
1169
|
+
return Object.fromEntries(
|
|
1170
|
+
[...new Set(storyIds)]
|
|
1171
|
+
.map((storyId) => [storyId, labels[storyId] ?? formatStoryIdForHuman(storyId)])
|
|
1172
|
+
);
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
function summarizeStoryRefs(storyIds, { limit = 8, labels = {} } = {}) {
|
|
1176
|
+
if (!Array.isArray(storyIds) || storyIds.length === 0) return '-';
|
|
1177
|
+
const visible = storyIds.slice(0, limit).map((storyId) => formatStoryRefForHuman(storyId, labels));
|
|
1178
|
+
const hidden = storyIds.length - visible.length;
|
|
1179
|
+
return hidden > 0 ? `${visible.join(', ')} ほか${hidden}件` : visible.join(', ');
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
function formatStoryRefForHuman(storyId, labels = {}) {
|
|
1183
|
+
const label = labels[storyId];
|
|
1184
|
+
if (label && label !== storyId) return label;
|
|
1185
|
+
return formatStoryIdForHuman(storyId);
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
function formatStoryTitleForHuman(title, storyId) {
|
|
1189
|
+
const value = String(title ?? '').trim();
|
|
1190
|
+
if (!value || value === storyId) return formatStoryIdForHuman(storyId);
|
|
1191
|
+
return value.replace(/^["']|["']$/g, '');
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
function formatStoryIdForHuman(storyId) {
|
|
1195
|
+
return String(storyId)
|
|
1196
|
+
.replace(/^story-/, '')
|
|
1197
|
+
.replace(/^vibepro-/, '')
|
|
1198
|
+
.replace(/^product-/, '')
|
|
1199
|
+
.replace(/-/g, ' ');
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
function renderEvidenceBindings(activities) {
|
|
1203
|
+
const rows = [];
|
|
1204
|
+
for (const activity of activities) {
|
|
1205
|
+
for (const step of activity.steps ?? []) {
|
|
1206
|
+
const evidenceByType = new Map();
|
|
1207
|
+
for (const evidence of step.evidence ?? []) {
|
|
1208
|
+
if (!evidenceByType.has(evidence.type)) evidenceByType.set(evidence.type, []);
|
|
1209
|
+
evidenceByType.get(evidence.type).push(evidence.ref);
|
|
1210
|
+
}
|
|
1211
|
+
const summary = [...evidenceByType.entries()]
|
|
1212
|
+
.map(([type, refs]) => `${formatEvidenceType(type)}: ${[...new Set(refs)].slice(0, 6).join(', ')}`)
|
|
1213
|
+
.join('; ');
|
|
1214
|
+
rows.push(`| ${escapeMarkdownTableCell(`${activity.label}/${step.label}`)} | ${escapeMarkdownTableCell(summarizeStoryRefs(step.story_ids ?? [], { labels: step.story_labels ?? {} }))} | ${escapeMarkdownTableCell(summarizeStoryRefs(step.enabler_story_ids ?? [], { labels: step.enabler_story_labels ?? {} }))} | ${escapeMarkdownTableCell(summary || '-')} |`);
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
if (rows.length === 0) return '-';
|
|
1218
|
+
return `| ステップ | Story | 補助Story | 証跡 |\n|------|-------|---------|------|\n${rows.join('\n')}`;
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
function formatEvidenceType(type) {
|
|
1222
|
+
const labels = {
|
|
1223
|
+
source_path: '正本',
|
|
1224
|
+
spec_clause: '仕様',
|
|
1225
|
+
surface: '対象面',
|
|
1226
|
+
gate_evidence: '検証',
|
|
1227
|
+
workflow_position: '工程',
|
|
1228
|
+
frontmatter: '明示設定'
|
|
1229
|
+
};
|
|
1230
|
+
return labels[type] ?? type;
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
function renderJourneyCell(activity, slice) {
|
|
1234
|
+
const sliceStoryIds = new Set(slice.story_ids ?? []);
|
|
1235
|
+
const items = activity.steps
|
|
1236
|
+
.map((step) => {
|
|
1237
|
+
const storyIds = (step.story_ids ?? []).filter((storyId) => sliceStoryIds.has(storyId));
|
|
1238
|
+
const enablerIds = (step.enabler_story_ids ?? []).filter((storyId) => sliceStoryIds.has(storyId));
|
|
1239
|
+
if (storyIds.length === 0 && enablerIds.length === 0) return null;
|
|
1240
|
+
const suffix = enablerIds.length > 0 ? ` / 補助: ${summarizeStoryRefs(enablerIds, { labels: step.enabler_story_labels ?? {}, limit: 3 })}` : '';
|
|
1241
|
+
return `${step.label}: ${summarizeStoryRefs(storyIds, { labels: step.story_labels ?? {}, limit: 3 })}${suffix}`;
|
|
1242
|
+
})
|
|
1243
|
+
.filter(Boolean);
|
|
1244
|
+
return items.length > 0 ? items.join('<br>') : '-';
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
function escapeMarkdownTableCell(value) {
|
|
1248
|
+
return String(value ?? '-').replace(/\|/g, '\\|').replace(/\n/g, '<br>');
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
function buildSourceDigest(stories) {
|
|
1252
|
+
const input = JSON.stringify(stories.map((story) => ({
|
|
1253
|
+
story_id: story.story_id,
|
|
1254
|
+
title: story.title,
|
|
1255
|
+
updated_at: story.updated_at,
|
|
1256
|
+
source_paths: story.source_paths,
|
|
1257
|
+
spec_clauses: story.spec_clauses,
|
|
1258
|
+
surfaces: story.surfaces,
|
|
1259
|
+
gate_evidence: story.gate_evidence
|
|
1260
|
+
})));
|
|
1261
|
+
return {
|
|
1262
|
+
algorithm: 'sha256',
|
|
1263
|
+
value: createHash('sha256').update(input).digest('hex')
|
|
1264
|
+
};
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
function storyText(story) {
|
|
1268
|
+
return [
|
|
1269
|
+
story.story_id,
|
|
1270
|
+
story.title,
|
|
1271
|
+
story.category,
|
|
1272
|
+
story.view,
|
|
1273
|
+
story.body,
|
|
1274
|
+
...(story.acceptance_focus ?? [])
|
|
1275
|
+
].filter(Boolean).join('\n').toLowerCase();
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
function combineConfidence(a, b) {
|
|
1279
|
+
const rank = { high: 3, medium: 2, low: 1, unknown: 0 };
|
|
1280
|
+
return (rank[b] ?? 0) < (rank[a] ?? 0) ? b : a;
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
function dedupeEvidence(items) {
|
|
1284
|
+
const seen = new Set();
|
|
1285
|
+
return items.filter((item) => {
|
|
1286
|
+
const key = `${item.type}:${item.ref}`;
|
|
1287
|
+
if (seen.has(key)) return false;
|
|
1288
|
+
seen.add(key);
|
|
1289
|
+
return true;
|
|
1290
|
+
});
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
function slugify(value) {
|
|
1294
|
+
return String(value ?? '')
|
|
1295
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
|
|
1296
|
+
.toLowerCase()
|
|
1297
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
1298
|
+
.replace(/^-+|-+$/g, '') || null;
|
|
1299
|
+
}
|