wogiflow 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.workflow/agents/reviewer.md +81 -0
- package/.workflow/agents/security.md +94 -0
- package/.workflow/agents/story-writer.md +58 -0
- package/.workflow/bridges/base-bridge.js +395 -0
- package/.workflow/bridges/claude-bridge.js +434 -0
- package/.workflow/bridges/index.js +130 -0
- package/.workflow/lib/assumption-detector.js +481 -0
- package/.workflow/lib/config-substitution.js +371 -0
- package/.workflow/lib/failure-categories.js +478 -0
- package/.workflow/state/app-map.md.template +15 -0
- package/.workflow/state/architecture.md.template +24 -0
- package/.workflow/state/component-index.json.template +5 -0
- package/.workflow/state/decisions.md.template +15 -0
- package/.workflow/state/feedback-patterns.md.template +9 -0
- package/.workflow/state/knowledge-sync.json.template +6 -0
- package/.workflow/state/progress.md.template +14 -0
- package/.workflow/state/ready.json.template +7 -0
- package/.workflow/state/request-log.md.template +14 -0
- package/.workflow/state/session-state.json.template +11 -0
- package/.workflow/state/stack.md.template +33 -0
- package/.workflow/state/testing.md.template +36 -0
- package/.workflow/templates/claude-md.hbs +257 -0
- package/.workflow/templates/correction-report.md +67 -0
- package/.workflow/templates/gemini-md.hbs +52 -0
- package/README.md +1802 -0
- package/bin/flow +205 -0
- package/lib/index.js +33 -0
- package/lib/installer.js +467 -0
- package/lib/release-channel.js +269 -0
- package/lib/skill-registry.js +526 -0
- package/lib/upgrader.js +401 -0
- package/lib/utils.js +305 -0
- package/package.json +64 -0
- package/scripts/flow +985 -0
- package/scripts/flow-adaptive-learning.js +1259 -0
- package/scripts/flow-aggregate.js +488 -0
- package/scripts/flow-archive +133 -0
- package/scripts/flow-auto-context.js +1015 -0
- package/scripts/flow-auto-learn.js +615 -0
- package/scripts/flow-bridge.js +223 -0
- package/scripts/flow-browser-suggest.js +316 -0
- package/scripts/flow-bug.js +247 -0
- package/scripts/flow-cascade.js +711 -0
- package/scripts/flow-changelog +85 -0
- package/scripts/flow-checkpoint.js +483 -0
- package/scripts/flow-cli.js +403 -0
- package/scripts/flow-code-intelligence.js +760 -0
- package/scripts/flow-complexity.js +502 -0
- package/scripts/flow-config-set.js +152 -0
- package/scripts/flow-constants.js +157 -0
- package/scripts/flow-context +152 -0
- package/scripts/flow-context-init.js +482 -0
- package/scripts/flow-context-monitor.js +384 -0
- package/scripts/flow-context-scoring.js +886 -0
- package/scripts/flow-correct.js +458 -0
- package/scripts/flow-damage-control.js +985 -0
- package/scripts/flow-deps +101 -0
- package/scripts/flow-diff.js +700 -0
- package/scripts/flow-done +151 -0
- package/scripts/flow-done.js +489 -0
- package/scripts/flow-durable-session.js +1541 -0
- package/scripts/flow-entropy-monitor.js +345 -0
- package/scripts/flow-export-profile +349 -0
- package/scripts/flow-export-scanner.js +1046 -0
- package/scripts/flow-figma-confirm.js +400 -0
- package/scripts/flow-figma-extract.js +496 -0
- package/scripts/flow-figma-generate.js +683 -0
- package/scripts/flow-figma-index.js +909 -0
- package/scripts/flow-figma-match.js +617 -0
- package/scripts/flow-figma-mcp-server.js +518 -0
- package/scripts/flow-figma-pipeline.js +414 -0
- package/scripts/flow-file-ops.js +301 -0
- package/scripts/flow-gate-confidence.js +825 -0
- package/scripts/flow-guided-edit.js +659 -0
- package/scripts/flow-health +185 -0
- package/scripts/flow-health.js +413 -0
- package/scripts/flow-hooks.js +556 -0
- package/scripts/flow-http-client.js +249 -0
- package/scripts/flow-hybrid-detect.js +167 -0
- package/scripts/flow-hybrid-interactive.js +591 -0
- package/scripts/flow-hybrid-test.js +152 -0
- package/scripts/flow-import-profile +439 -0
- package/scripts/flow-init +253 -0
- package/scripts/flow-instruction-richness.js +827 -0
- package/scripts/flow-jira-integration.js +579 -0
- package/scripts/flow-knowledge-router.js +522 -0
- package/scripts/flow-knowledge-sync.js +589 -0
- package/scripts/flow-linear-integration.js +631 -0
- package/scripts/flow-links.js +774 -0
- package/scripts/flow-log-manager.js +559 -0
- package/scripts/flow-loop-enforcer.js +1246 -0
- package/scripts/flow-loop-retry-learning.js +630 -0
- package/scripts/flow-lsp.js +923 -0
- package/scripts/flow-map-index +348 -0
- package/scripts/flow-map-sync +201 -0
- package/scripts/flow-memory-blocks.js +668 -0
- package/scripts/flow-memory-compactor.js +350 -0
- package/scripts/flow-memory-db.js +1110 -0
- package/scripts/flow-memory-sync.js +484 -0
- package/scripts/flow-metrics.js +353 -0
- package/scripts/flow-migrate-ids.js +370 -0
- package/scripts/flow-model-adapter.js +802 -0
- package/scripts/flow-model-router.js +884 -0
- package/scripts/flow-models.js +1231 -0
- package/scripts/flow-morning.js +517 -0
- package/scripts/flow-multi-approach.js +660 -0
- package/scripts/flow-new-feature +86 -0
- package/scripts/flow-onboard +1042 -0
- package/scripts/flow-orchestrate-llm.js +459 -0
- package/scripts/flow-orchestrate.js +3592 -0
- package/scripts/flow-output.js +123 -0
- package/scripts/flow-parallel-detector.js +399 -0
- package/scripts/flow-parallel-dispatch.js +987 -0
- package/scripts/flow-parallel.js +428 -0
- package/scripts/flow-pattern-enforcer.js +600 -0
- package/scripts/flow-prd-manager.js +282 -0
- package/scripts/flow-progress.js +323 -0
- package/scripts/flow-project-analyzer.js +975 -0
- package/scripts/flow-prompt-composer.js +487 -0
- package/scripts/flow-providers.js +1381 -0
- package/scripts/flow-queue.js +308 -0
- package/scripts/flow-ready +82 -0
- package/scripts/flow-ready.js +189 -0
- package/scripts/flow-regression.js +396 -0
- package/scripts/flow-response-parser.js +450 -0
- package/scripts/flow-resume.js +284 -0
- package/scripts/flow-rules-sync.js +439 -0
- package/scripts/flow-run-trace.js +718 -0
- package/scripts/flow-safety.js +587 -0
- package/scripts/flow-search +104 -0
- package/scripts/flow-security.js +481 -0
- package/scripts/flow-session-end +106 -0
- package/scripts/flow-session-end.js +437 -0
- package/scripts/flow-session-state.js +671 -0
- package/scripts/flow-setup-hooks +216 -0
- package/scripts/flow-setup-hooks.js +377 -0
- package/scripts/flow-skill-create.js +329 -0
- package/scripts/flow-skill-creator.js +572 -0
- package/scripts/flow-skill-generator.js +1046 -0
- package/scripts/flow-skill-learn.js +880 -0
- package/scripts/flow-skill-matcher.js +578 -0
- package/scripts/flow-spec-generator.js +820 -0
- package/scripts/flow-stack-wizard.js +895 -0
- package/scripts/flow-standup +162 -0
- package/scripts/flow-start +74 -0
- package/scripts/flow-start.js +235 -0
- package/scripts/flow-status +110 -0
- package/scripts/flow-status.js +301 -0
- package/scripts/flow-step-browser.js +83 -0
- package/scripts/flow-step-changelog.js +217 -0
- package/scripts/flow-step-comments.js +306 -0
- package/scripts/flow-step-complexity.js +234 -0
- package/scripts/flow-step-coverage.js +218 -0
- package/scripts/flow-step-knowledge.js +193 -0
- package/scripts/flow-step-pr-tests.js +364 -0
- package/scripts/flow-step-regression.js +89 -0
- package/scripts/flow-step-review.js +516 -0
- package/scripts/flow-step-security.js +162 -0
- package/scripts/flow-step-silent-failures.js +290 -0
- package/scripts/flow-step-simplifier.js +346 -0
- package/scripts/flow-story +105 -0
- package/scripts/flow-story.js +500 -0
- package/scripts/flow-suspend.js +252 -0
- package/scripts/flow-sync-daemon.js +654 -0
- package/scripts/flow-task-analyzer.js +606 -0
- package/scripts/flow-team-dashboard.js +748 -0
- package/scripts/flow-team-sync.js +752 -0
- package/scripts/flow-team.js +977 -0
- package/scripts/flow-tech-options.js +528 -0
- package/scripts/flow-templates.js +812 -0
- package/scripts/flow-tiered-learning.js +728 -0
- package/scripts/flow-trace +204 -0
- package/scripts/flow-transcript-chunking.js +1106 -0
- package/scripts/flow-transcript-digest.js +7918 -0
- package/scripts/flow-transcript-language.js +465 -0
- package/scripts/flow-transcript-parsing.js +1085 -0
- package/scripts/flow-transcript-stories.js +2194 -0
- package/scripts/flow-update-map +224 -0
- package/scripts/flow-utils.js +2242 -0
- package/scripts/flow-verification.js +644 -0
- package/scripts/flow-verify.js +1177 -0
- package/scripts/flow-voice-input.js +638 -0
- package/scripts/flow-watch +168 -0
- package/scripts/flow-workflow-steps.js +521 -0
- package/scripts/flow-workflow.js +1029 -0
- package/scripts/flow-worktree.js +489 -0
- package/scripts/hooks/adapters/base-adapter.js +102 -0
- package/scripts/hooks/adapters/claude-code.js +359 -0
- package/scripts/hooks/adapters/index.js +79 -0
- package/scripts/hooks/core/component-check.js +341 -0
- package/scripts/hooks/core/index.js +35 -0
- package/scripts/hooks/core/loop-check.js +241 -0
- package/scripts/hooks/core/session-context.js +294 -0
- package/scripts/hooks/core/task-gate.js +177 -0
- package/scripts/hooks/core/validation.js +230 -0
- package/scripts/hooks/entry/claude-code/post-tool-use.js +65 -0
- package/scripts/hooks/entry/claude-code/pre-tool-use.js +89 -0
- package/scripts/hooks/entry/claude-code/session-end.js +87 -0
- package/scripts/hooks/entry/claude-code/session-start.js +46 -0
- package/scripts/hooks/entry/claude-code/stop.js +43 -0
- package/scripts/postinstall.js +139 -0
- package/templates/browser-test-flow.json +56 -0
- package/templates/bug-report.md +43 -0
- package/templates/component-detail.md +42 -0
- package/templates/component.stories.tsx +49 -0
- package/templates/context/constraints.md +83 -0
- package/templates/context/conventions.md +177 -0
- package/templates/context/stack.md +60 -0
- package/templates/correction-report.md +90 -0
- package/templates/feature-proposal.md +35 -0
- package/templates/hybrid/_base.md +254 -0
- package/templates/hybrid/_patterns.md +45 -0
- package/templates/hybrid/create-component.md +127 -0
- package/templates/hybrid/create-file.md +56 -0
- package/templates/hybrid/create-hook.md +145 -0
- package/templates/hybrid/create-service.md +70 -0
- package/templates/hybrid/fix-bug.md +33 -0
- package/templates/hybrid/modify-file.md +55 -0
- package/templates/story.md +68 -0
- package/templates/task.json +56 -0
- package/templates/trace.md +69 -0
|
@@ -0,0 +1,2194 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Wogi Flow - Transcript Stories Module
|
|
5
|
+
*
|
|
6
|
+
* Extracted from flow-transcript-digest.js for maintainability.
|
|
7
|
+
* Handles story generation, presentation queue, editing, and workflow export.
|
|
8
|
+
*
|
|
9
|
+
* Part of E3-S2: Story Generation with Source Tracing
|
|
10
|
+
*
|
|
11
|
+
* Dependencies: Requires core functions from flow-transcript-digest.js
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const fs = require('fs');
|
|
15
|
+
const path = require('path');
|
|
16
|
+
const crypto = require('crypto');
|
|
17
|
+
|
|
18
|
+
// Core functions are injected via init() to avoid circular dependencies
|
|
19
|
+
let digestCore = null;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Initialize with core digest functions
|
|
23
|
+
* @param {object} core - Core functions from flow-transcript-digest.js
|
|
24
|
+
*/
|
|
25
|
+
function init(core) {
|
|
26
|
+
digestCore = core;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Helper to ensure init was called
|
|
30
|
+
function requireInit() {
|
|
31
|
+
if (!digestCore) {
|
|
32
|
+
throw new Error('flow-transcript-stories not initialized. Call init() first.');
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Proxy functions to core module
|
|
37
|
+
function loadActiveDigest() { requireInit(); return digestCore.loadActiveDigest(); }
|
|
38
|
+
function saveActiveDigest(d) { requireInit(); return digestCore.saveActiveDigest(d); }
|
|
39
|
+
function loadTopics() { requireInit(); return digestCore.loadTopics(); }
|
|
40
|
+
function saveTopics(t) { requireInit(); return digestCore.saveTopics(t); }
|
|
41
|
+
function loadStatementMap() { requireInit(); return digestCore.loadStatementMap(); }
|
|
42
|
+
function loadClarifications() { requireInit(); return digestCore.loadClarifications(); }
|
|
43
|
+
function isRequirement(s) { requireInit(); return digestCore.isRequirement(s); }
|
|
44
|
+
function isVagueStatement(s) { requireInit(); return digestCore.isVagueStatement(s); }
|
|
45
|
+
function analyzeComplexity() { requireInit(); return digestCore.analyzeComplexity(); }
|
|
46
|
+
|
|
47
|
+
// State directory
|
|
48
|
+
const STATE_DIR = path.join(process.cwd(), '.workflow', 'state', 'digests');
|
|
49
|
+
|
|
50
|
+
// ==========================================================================
|
|
51
|
+
// E3-S2: Story Generation with Source Tracing
|
|
52
|
+
// ==========================================================================
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* User type patterns for story generation
|
|
56
|
+
*/
|
|
57
|
+
const USER_TYPE_PATTERNS = [
|
|
58
|
+
{ pattern: /\b(admin|administrator)\b/i, type: 'admin' },
|
|
59
|
+
{ pattern: /\b(user|customer|client)\b/i, type: 'user' },
|
|
60
|
+
{ pattern: /\b(manager|supervisor)\b/i, type: 'manager' },
|
|
61
|
+
{ pattern: /\b(developer|dev)\b/i, type: 'developer' },
|
|
62
|
+
{ pattern: /\b(guest|visitor|anonymous)\b/i, type: 'guest' },
|
|
63
|
+
{ pattern: /\b(owner|creator)\b/i, type: 'owner' }
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Scenario name patterns
|
|
68
|
+
*/
|
|
69
|
+
const SCENARIO_PATTERNS = [
|
|
70
|
+
{ pattern: /\b(create|add|new)\b/i, prefix: 'Create' },
|
|
71
|
+
{ pattern: /\b(edit|update|modify|change)\b/i, prefix: 'Update' },
|
|
72
|
+
{ pattern: /\b(delete|remove|archive)\b/i, prefix: 'Delete' },
|
|
73
|
+
{ pattern: /\b(view|show|display|see|list)\b/i, prefix: 'View' },
|
|
74
|
+
{ pattern: /\b(search|find|filter)\b/i, prefix: 'Search' },
|
|
75
|
+
{ pattern: /\b(login|authenticate|sign in)\b/i, prefix: 'Login' },
|
|
76
|
+
{ pattern: /\b(logout|sign out)\b/i, prefix: 'Logout' },
|
|
77
|
+
{ pattern: /\b(validate|check|verify)\b/i, prefix: 'Validate' },
|
|
78
|
+
{ pattern: /\b(submit|save|confirm)\b/i, prefix: 'Submit' },
|
|
79
|
+
{ pattern: /\b(cancel|dismiss|close)\b/i, prefix: 'Cancel' },
|
|
80
|
+
{ pattern: /\b(select|choose|pick)\b/i, prefix: 'Select' },
|
|
81
|
+
{ pattern: /\b(upload|import)\b/i, prefix: 'Upload' },
|
|
82
|
+
{ pattern: /\b(download|export)\b/i, prefix: 'Download' }
|
|
83
|
+
];
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Generate unique story ID
|
|
87
|
+
*/
|
|
88
|
+
function generateStoryId() {
|
|
89
|
+
return 'story-' + crypto.randomBytes(4).toString('hex');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Detect user type from statements
|
|
94
|
+
*/
|
|
95
|
+
function detectUserType(statements) {
|
|
96
|
+
for (const statement of statements) {
|
|
97
|
+
if (!statement.text) continue;
|
|
98
|
+
for (const { pattern, type } of USER_TYPE_PATTERNS) {
|
|
99
|
+
if (pattern.test(statement.text)) {
|
|
100
|
+
return { value: type, source: statement.id };
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return { value: 'user', source: 'default' };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Extract main object/entity from text
|
|
109
|
+
*/
|
|
110
|
+
function extractObject(text) {
|
|
111
|
+
// Look for nouns after action verbs
|
|
112
|
+
const patterns = [
|
|
113
|
+
/(?:create|add|new|edit|update|delete|remove|view|show)\s+(?:a\s+|the\s+)?(\w+)/i,
|
|
114
|
+
/(\w+)\s+(?:table|form|list|page|modal|button)/i,
|
|
115
|
+
/\b(user|product|order|item|account|profile|setting|message)\b/i
|
|
116
|
+
];
|
|
117
|
+
|
|
118
|
+
for (const pattern of patterns) {
|
|
119
|
+
const match = text.match(pattern);
|
|
120
|
+
if (match) {
|
|
121
|
+
return match[1].toLowerCase();
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Default to first noun-like word
|
|
126
|
+
const words = text.split(/\s+/);
|
|
127
|
+
for (const word of words) {
|
|
128
|
+
if (word.length > 3 && /^[a-z]+$/i.test(word)) {
|
|
129
|
+
return word.toLowerCase();
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return 'item';
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Generate scenario name from requirement
|
|
138
|
+
*/
|
|
139
|
+
function generateScenarioName(requirement) {
|
|
140
|
+
const text = requirement.text || '';
|
|
141
|
+
|
|
142
|
+
for (const { pattern, prefix } of SCENARIO_PATTERNS) {
|
|
143
|
+
if (pattern.test(text)) {
|
|
144
|
+
const object = extractObject(text);
|
|
145
|
+
return `${prefix} ${object}`;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return `Handle ${extractObject(text)}`;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Extract action from requirement text
|
|
154
|
+
*/
|
|
155
|
+
function extractActionFromText(text) {
|
|
156
|
+
// Look for verb phrases
|
|
157
|
+
const patterns = [
|
|
158
|
+
/(?:should|can|will|must)\s+(be able to\s+)?(\w+(?:\s+\w+)?)/i,
|
|
159
|
+
/(?:want to|need to)\s+(\w+(?:\s+\w+)?)/i,
|
|
160
|
+
/(?:add|create|edit|delete|view|manage)\s+(\w+(?:\s+\w+)?)/i
|
|
161
|
+
];
|
|
162
|
+
|
|
163
|
+
for (const pattern of patterns) {
|
|
164
|
+
const match = text.match(pattern);
|
|
165
|
+
if (match) {
|
|
166
|
+
return match[match.length - 1];
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return 'perform the action';
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Extract outcome from requirement text
|
|
175
|
+
*/
|
|
176
|
+
function extractOutcomeFromText(text) {
|
|
177
|
+
// Look for outcome indicators
|
|
178
|
+
if (/\b(table|list|grid)\b/i.test(text)) {
|
|
179
|
+
return 'I should see the data displayed';
|
|
180
|
+
}
|
|
181
|
+
if (/\b(form|input)\b/i.test(text)) {
|
|
182
|
+
return 'I should see the form';
|
|
183
|
+
}
|
|
184
|
+
if (/\b(button)\b/i.test(text)) {
|
|
185
|
+
return 'the action should be performed';
|
|
186
|
+
}
|
|
187
|
+
if (/\b(modal|dialog|popup)\b/i.test(text)) {
|
|
188
|
+
return 'I should see the modal';
|
|
189
|
+
}
|
|
190
|
+
if (/\b(create|add|new)\b/i.test(text)) {
|
|
191
|
+
return 'a new item should be created';
|
|
192
|
+
}
|
|
193
|
+
if (/\b(delete|remove)\b/i.test(text)) {
|
|
194
|
+
return 'the item should be removed';
|
|
195
|
+
}
|
|
196
|
+
if (/\b(update|edit|modify)\b/i.test(text)) {
|
|
197
|
+
return 'the changes should be saved';
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return 'the expected result should occur';
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Convert statement to Given clause
|
|
205
|
+
*/
|
|
206
|
+
function convertToGiven(text) {
|
|
207
|
+
// Remove leading "when", "if", etc.
|
|
208
|
+
let given = text.replace(/^(when|if|after|once|assuming)\s+/i, '');
|
|
209
|
+
|
|
210
|
+
// Convert to first person if needed
|
|
211
|
+
given = given.replace(/\b(the user|users)\b/i, 'I');
|
|
212
|
+
|
|
213
|
+
return given;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Extract Given clause from context
|
|
218
|
+
*/
|
|
219
|
+
function extractGiven(requirement, contextStatements, topic) {
|
|
220
|
+
// Look for precondition statements
|
|
221
|
+
const preconditions = contextStatements.filter(s =>
|
|
222
|
+
/\b(when|if|after|once|assuming|logged in|on the)\b/i.test(s.text || '') &&
|
|
223
|
+
s.id !== requirement.id
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
if (preconditions.length > 0) {
|
|
227
|
+
return {
|
|
228
|
+
text: convertToGiven(preconditions[0].text),
|
|
229
|
+
source: preconditions[0].id
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Default context based on topic
|
|
234
|
+
const topicLower = (topic.title || '').toLowerCase();
|
|
235
|
+
if (topicLower.includes('dashboard') || topicLower.includes('management')) {
|
|
236
|
+
return { text: `I am on the ${topicLower} page`, source: 'context' };
|
|
237
|
+
}
|
|
238
|
+
if (topicLower.includes('form')) {
|
|
239
|
+
return { text: 'I am filling out the form', source: 'context' };
|
|
240
|
+
}
|
|
241
|
+
if (topicLower.includes('settings') || topicLower.includes('profile')) {
|
|
242
|
+
return { text: 'I am in the settings section', source: 'context' };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return { text: 'I am logged into the system', source: 'context' };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Extract When clause from requirement
|
|
250
|
+
*/
|
|
251
|
+
function extractWhen(requirement) {
|
|
252
|
+
const text = requirement.text || '';
|
|
253
|
+
const action = extractActionFromText(text);
|
|
254
|
+
|
|
255
|
+
return {
|
|
256
|
+
text: `I ${action}`,
|
|
257
|
+
source: requirement.id
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Extract Then clause from requirement
|
|
263
|
+
*/
|
|
264
|
+
function extractThen(requirement) {
|
|
265
|
+
const text = requirement.text || '';
|
|
266
|
+
const outcome = extractOutcomeFromText(text);
|
|
267
|
+
|
|
268
|
+
return {
|
|
269
|
+
text: outcome,
|
|
270
|
+
source: requirement.id
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Generate criteria from clarification answers
|
|
276
|
+
*/
|
|
277
|
+
function generateCriteriaFromClarification(clarification, topic) {
|
|
278
|
+
const criteria = [];
|
|
279
|
+
const question = (clarification.question || '').toLowerCase();
|
|
280
|
+
const answer = clarification.answer || '';
|
|
281
|
+
|
|
282
|
+
// Column-related questions
|
|
283
|
+
if (question.includes('column') || question.includes('display') || question.includes('show')) {
|
|
284
|
+
criteria.push({
|
|
285
|
+
scenario: 'Display correct columns',
|
|
286
|
+
given: { text: 'I am viewing the table', source: 'context' },
|
|
287
|
+
when: { text: 'the data loads', source: 'context' },
|
|
288
|
+
then: { text: `I should see columns: ${answer}`, source: clarification.id },
|
|
289
|
+
sources: [clarification.id],
|
|
290
|
+
originalText: `Q: ${clarification.question}\nA: ${answer}`,
|
|
291
|
+
type: 'clarification'
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Validation-related questions
|
|
296
|
+
if (question.includes('validation') || question.includes('required') || question.includes('rules')) {
|
|
297
|
+
criteria.push({
|
|
298
|
+
scenario: 'Validate form input',
|
|
299
|
+
given: { text: 'I am filling out the form', source: 'context' },
|
|
300
|
+
when: { text: 'I submit with invalid data', source: 'context' },
|
|
301
|
+
then: { text: `validation should enforce: ${answer}`, source: clarification.id },
|
|
302
|
+
sources: [clarification.id],
|
|
303
|
+
originalText: `Q: ${clarification.question}\nA: ${answer}`,
|
|
304
|
+
type: 'clarification'
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Action-related questions
|
|
309
|
+
if (question.includes('action') || question.includes('button') || question.includes('click')) {
|
|
310
|
+
criteria.push({
|
|
311
|
+
scenario: 'Handle user actions',
|
|
312
|
+
given: { text: 'I am on the page', source: 'context' },
|
|
313
|
+
when: { text: 'I perform an action', source: 'context' },
|
|
314
|
+
then: { text: `the available actions are: ${answer}`, source: clarification.id },
|
|
315
|
+
sources: [clarification.id],
|
|
316
|
+
originalText: `Q: ${clarification.question}\nA: ${answer}`,
|
|
317
|
+
type: 'clarification'
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Sort/filter questions
|
|
322
|
+
if (question.includes('sort') || question.includes('filter') || question.includes('order')) {
|
|
323
|
+
criteria.push({
|
|
324
|
+
scenario: 'Sort and filter data',
|
|
325
|
+
given: { text: 'I am viewing the data', source: 'context' },
|
|
326
|
+
when: { text: 'I apply sorting or filtering', source: 'context' },
|
|
327
|
+
then: { text: `sorting/filtering should support: ${answer}`, source: clarification.id },
|
|
328
|
+
sources: [clarification.id],
|
|
329
|
+
originalText: `Q: ${clarification.question}\nA: ${answer}`,
|
|
330
|
+
type: 'clarification'
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Generic fallback
|
|
335
|
+
if (criteria.length === 0) {
|
|
336
|
+
const keyword = question.split(' ').find(w => w.length > 4) || 'detail';
|
|
337
|
+
criteria.push({
|
|
338
|
+
scenario: `Handle ${keyword}`,
|
|
339
|
+
given: { text: 'the feature is active', source: 'context' },
|
|
340
|
+
when: { text: 'the user interacts', source: 'context' },
|
|
341
|
+
then: { text: answer, source: clarification.id },
|
|
342
|
+
sources: [clarification.id],
|
|
343
|
+
originalText: `Q: ${clarification.question}\nA: ${answer}`,
|
|
344
|
+
type: 'clarification'
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
return criteria;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Build traceability matrix for a story
|
|
353
|
+
*/
|
|
354
|
+
function buildTraceabilityMatrix(criteria) {
|
|
355
|
+
const matrix = [];
|
|
356
|
+
|
|
357
|
+
for (let i = 0; i < criteria.length; i++) {
|
|
358
|
+
const criterion = criteria[i];
|
|
359
|
+
const criterionId = `AC-${i + 1}`;
|
|
360
|
+
|
|
361
|
+
for (const sourceId of criterion.sources || []) {
|
|
362
|
+
matrix.push({
|
|
363
|
+
criterion_id: criterionId,
|
|
364
|
+
criterion_name: criterion.scenario,
|
|
365
|
+
source_id: sourceId,
|
|
366
|
+
source_text: (criterion.originalText || '').slice(0, 60) + '...',
|
|
367
|
+
source_type: sourceId.startsWith('s-') ? 'statement' :
|
|
368
|
+
sourceId.startsWith('q-') ? 'clarification' : 'context'
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
return matrix;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Validate story coverage
|
|
378
|
+
*/
|
|
379
|
+
function validateStoryCoverage(story, topicStatements) {
|
|
380
|
+
const warnings = [];
|
|
381
|
+
const coveredSources = new Set(
|
|
382
|
+
story.acceptance_criteria.flatMap(c => c.sources || [])
|
|
383
|
+
);
|
|
384
|
+
|
|
385
|
+
// Check all requirements are covered
|
|
386
|
+
const requirements = topicStatements.filter(s =>
|
|
387
|
+
isRequirement({ text: s.text })
|
|
388
|
+
);
|
|
389
|
+
|
|
390
|
+
for (const req of requirements) {
|
|
391
|
+
if (!coveredSources.has(req.id)) {
|
|
392
|
+
warnings.push({
|
|
393
|
+
type: 'uncovered_requirement',
|
|
394
|
+
statement_id: req.id,
|
|
395
|
+
text: req.text,
|
|
396
|
+
message: 'Requirement not covered by any acceptance criterion'
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// Check for assumptions
|
|
402
|
+
for (const criterion of story.acceptance_criteria) {
|
|
403
|
+
if (!criterion.sources || criterion.sources.length === 0 ||
|
|
404
|
+
criterion.sources.every(s => s === 'context' || s === 'default')) {
|
|
405
|
+
warnings.push({
|
|
406
|
+
type: 'assumption',
|
|
407
|
+
criterion: criterion.scenario,
|
|
408
|
+
message: 'Criterion has no direct source - may be an assumption'
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
return {
|
|
414
|
+
valid: warnings.filter(w => w.type === 'uncovered_requirement').length === 0,
|
|
415
|
+
coverage_percent: requirements.length > 0 ?
|
|
416
|
+
Math.round((coveredSources.size / requirements.length) * 100) : 100,
|
|
417
|
+
warnings
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Generate a story from a topic
|
|
423
|
+
*/
|
|
424
|
+
function generateStoryFromTopic(topicId) {
|
|
425
|
+
const topics = loadTopics();
|
|
426
|
+
const statementMap = loadStatementMap();
|
|
427
|
+
const clarifications = loadClarifications();
|
|
428
|
+
const complexityResult = analyzeComplexity();
|
|
429
|
+
|
|
430
|
+
if (!topics || !topics.topics) {
|
|
431
|
+
return { error: 'No topics found' };
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const topic = topics.topics.find(t => t.id === topicId);
|
|
435
|
+
if (!topic) {
|
|
436
|
+
return { error: `Topic ${topicId} not found` };
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
const statements = statementMap?.statements || [];
|
|
440
|
+
const topicStatements = statements.filter(s => s.topic_id === topicId);
|
|
441
|
+
const requirements = topicStatements.filter(s =>
|
|
442
|
+
isRequirement({ text: s.text })
|
|
443
|
+
);
|
|
444
|
+
|
|
445
|
+
// Get answered clarifications for this topic
|
|
446
|
+
const topicClarifications = (clarifications?.questions || [])
|
|
447
|
+
.filter(q => q.topic_id === topicId && q.status === 'answered');
|
|
448
|
+
|
|
449
|
+
// Detect user type
|
|
450
|
+
const userType = detectUserType(topicStatements);
|
|
451
|
+
|
|
452
|
+
// Generate acceptance criteria from requirements
|
|
453
|
+
const criteria = [];
|
|
454
|
+
|
|
455
|
+
for (const req of requirements) {
|
|
456
|
+
criteria.push({
|
|
457
|
+
scenario: generateScenarioName(req),
|
|
458
|
+
given: extractGiven(req, topicStatements, topic),
|
|
459
|
+
when: extractWhen(req),
|
|
460
|
+
then: extractThen(req),
|
|
461
|
+
sources: [req.id],
|
|
462
|
+
originalText: req.text,
|
|
463
|
+
type: 'requirement'
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// Add criteria from clarification answers
|
|
468
|
+
for (const clarification of topicClarifications) {
|
|
469
|
+
const derived = generateCriteriaFromClarification(clarification, topic);
|
|
470
|
+
criteria.push(...derived);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// Build traceability matrix
|
|
474
|
+
const traceability = buildTraceabilityMatrix(criteria);
|
|
475
|
+
|
|
476
|
+
// Get topic complexity
|
|
477
|
+
const topicComplexity = complexityResult.topic_analysis?.find(t => t.topic_id === topicId);
|
|
478
|
+
|
|
479
|
+
// Build story object
|
|
480
|
+
const story = {
|
|
481
|
+
id: generateStoryId(),
|
|
482
|
+
topic_id: topicId,
|
|
483
|
+
title: topic.title,
|
|
484
|
+
generated_at: now(),
|
|
485
|
+
user_story: {
|
|
486
|
+
user_type: userType.value,
|
|
487
|
+
user_type_source: userType.source,
|
|
488
|
+
action: requirements.length > 0 ? extractActionFromText(requirements[0].text) : 'use this feature',
|
|
489
|
+
action_source: requirements.length > 0 ? requirements[0].id : 'inferred',
|
|
490
|
+
benefit: 'accomplish their goals efficiently',
|
|
491
|
+
benefit_source: 'inferred'
|
|
492
|
+
},
|
|
493
|
+
description: {
|
|
494
|
+
text: `Feature for ${topic.title.toLowerCase()}. ` +
|
|
495
|
+
(topicStatements.length > 0 ? topicStatements[0].text : ''),
|
|
496
|
+
source_statements: topicStatements.slice(0, 3).map(s => s.id)
|
|
497
|
+
},
|
|
498
|
+
acceptance_criteria: criteria.map((c, i) => ({
|
|
499
|
+
id: `AC-${i + 1}`,
|
|
500
|
+
...c
|
|
501
|
+
})),
|
|
502
|
+
traceability,
|
|
503
|
+
coverage: {
|
|
504
|
+
statements_total: topicStatements.length,
|
|
505
|
+
statements_covered: new Set(criteria.flatMap(c => c.sources || [])).size,
|
|
506
|
+
requirements_total: requirements.length,
|
|
507
|
+
clarifications_used: topicClarifications.length,
|
|
508
|
+
coverage_percent: requirements.length > 0 ?
|
|
509
|
+
Math.round((new Set(criteria.filter(c => c.type === 'requirement').flatMap(c => c.sources)).size / requirements.length) * 100) : 100
|
|
510
|
+
},
|
|
511
|
+
complexity: topicComplexity || { score: 0, level: 'unknown' },
|
|
512
|
+
validation: validateStoryCoverage({ acceptance_criteria: criteria }, topicStatements)
|
|
513
|
+
};
|
|
514
|
+
|
|
515
|
+
return story;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* Generate stories for all active topics
|
|
520
|
+
*/
|
|
521
|
+
function generateAllStories() {
|
|
522
|
+
const topics = loadTopics();
|
|
523
|
+
if (!topics || !topics.topics) {
|
|
524
|
+
return { error: 'No topics found' };
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
const activeTopics = topics.topics.filter(t => t.status === 'active');
|
|
528
|
+
const stories = [];
|
|
529
|
+
const errors = [];
|
|
530
|
+
|
|
531
|
+
for (const topic of activeTopics) {
|
|
532
|
+
const story = generateStoryFromTopic(topic.id);
|
|
533
|
+
if (story.error) {
|
|
534
|
+
errors.push({ topic_id: topic.id, error: story.error });
|
|
535
|
+
} else {
|
|
536
|
+
stories.push(story);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
return {
|
|
541
|
+
stories,
|
|
542
|
+
summary: {
|
|
543
|
+
total_topics: activeTopics.length,
|
|
544
|
+
stories_generated: stories.length,
|
|
545
|
+
errors: errors.length,
|
|
546
|
+
total_criteria: stories.reduce((sum, s) => sum + s.acceptance_criteria.length, 0),
|
|
547
|
+
average_coverage: stories.length > 0 ?
|
|
548
|
+
Math.round(stories.reduce((sum, s) => sum + s.coverage.coverage_percent, 0) / stories.length) : 0
|
|
549
|
+
},
|
|
550
|
+
errors
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* Save story to digest
|
|
556
|
+
*/
|
|
557
|
+
function saveStory(story) {
|
|
558
|
+
const activeDigest = loadActiveDigest();
|
|
559
|
+
if (!activeDigest.session.digest_path) {
|
|
560
|
+
return { error: 'No active digest session' };
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
const storiesPath = path.join(activeDigest.session.digest_path, 'stories');
|
|
564
|
+
fs.mkdirSync(storiesPath, { recursive: true });
|
|
565
|
+
|
|
566
|
+
const storyPath = path.join(storiesPath, `${story.id}.json`);
|
|
567
|
+
fs.writeFileSync(storyPath, JSON.stringify(story, null, 2));
|
|
568
|
+
|
|
569
|
+
return { saved: true, path: storyPath };
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/**
|
|
573
|
+
* Load story from digest
|
|
574
|
+
*/
|
|
575
|
+
function loadStory(storyId) {
|
|
576
|
+
const activeDigest = loadActiveDigest();
|
|
577
|
+
if (!activeDigest.session.digest_path) {
|
|
578
|
+
return null;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
const storyPath = path.join(activeDigest.session.digest_path, 'stories', `${storyId}.json`);
|
|
582
|
+
try {
|
|
583
|
+
return JSON.parse(fs.readFileSync(storyPath, 'utf8'));
|
|
584
|
+
} catch (err) {
|
|
585
|
+
return null;
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/**
|
|
590
|
+
* Load all stories from digest
|
|
591
|
+
*/
|
|
592
|
+
function loadAllStories() {
|
|
593
|
+
const activeDigest = loadActiveDigest();
|
|
594
|
+
if (!activeDigest.session.digest_path) {
|
|
595
|
+
return [];
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
const storiesPath = path.join(activeDigest.session.digest_path, 'stories');
|
|
599
|
+
try {
|
|
600
|
+
const files = fs.readdirSync(storiesPath).filter(f => f.endsWith('.json'));
|
|
601
|
+
return files.map(f => {
|
|
602
|
+
try {
|
|
603
|
+
return JSON.parse(fs.readFileSync(path.join(storiesPath, f), 'utf8'));
|
|
604
|
+
} catch (err) {
|
|
605
|
+
return null;
|
|
606
|
+
}
|
|
607
|
+
}).filter(Boolean);
|
|
608
|
+
} catch (err) {
|
|
609
|
+
return [];
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/**
|
|
614
|
+
* Format story as markdown with source tracing
|
|
615
|
+
*/
|
|
616
|
+
function formatStoryAsMarkdown(story) {
|
|
617
|
+
let md = '';
|
|
618
|
+
|
|
619
|
+
md += `# [${story.id}] ${story.title}\n\n`;
|
|
620
|
+
|
|
621
|
+
// Source topic
|
|
622
|
+
md += `## Source Topic\n`;
|
|
623
|
+
md += `**Topic ID**: ${story.topic_id}\n`;
|
|
624
|
+
md += `**Statements**: ${story.coverage.statements_total} statements, ${story.coverage.requirements_total} requirements\n\n`;
|
|
625
|
+
|
|
626
|
+
// User story
|
|
627
|
+
md += `## User Story\n`;
|
|
628
|
+
md += `**As a** ${story.user_story.user_type} \`[${story.user_story.user_type_source}]\`\n`;
|
|
629
|
+
md += `**I want** to ${story.user_story.action} \`[${story.user_story.action_source}]\`\n`;
|
|
630
|
+
md += `**So that** I can ${story.user_story.benefit} \`[${story.user_story.benefit_source}]\`\n\n`;
|
|
631
|
+
|
|
632
|
+
// Description
|
|
633
|
+
md += `## Description\n`;
|
|
634
|
+
md += `${story.description.text}\n\n`;
|
|
635
|
+
if (story.description.source_statements.length > 0) {
|
|
636
|
+
md += `**Source statements:** ${story.description.source_statements.join(', ')}\n\n`;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// Acceptance criteria
|
|
640
|
+
md += `## Acceptance Criteria\n\n`;
|
|
641
|
+
for (const ac of story.acceptance_criteria) {
|
|
642
|
+
md += `### Scenario ${ac.id.replace('AC-', '')}: ${ac.scenario}\n`;
|
|
643
|
+
md += `**Given** ${ac.given.text} \`[${ac.given.source}]\`\n`;
|
|
644
|
+
md += `**When** ${ac.when.text} \`[${ac.when.source}]\`\n`;
|
|
645
|
+
md += `**Then** ${ac.then.text} \`[${ac.then.source}]\`\n\n`;
|
|
646
|
+
|
|
647
|
+
if (ac.originalText) {
|
|
648
|
+
md += `**Derived from:**\n`;
|
|
649
|
+
md += `> "${ac.originalText.slice(0, 100)}${ac.originalText.length > 100 ? '...' : ''}" — ${ac.sources.join(', ')}\n\n`;
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// Coverage
|
|
654
|
+
md += `## Coverage\n`;
|
|
655
|
+
md += `- **Statements covered**: ${story.coverage.statements_covered}/${story.coverage.statements_total}\n`;
|
|
656
|
+
md += `- **Requirements coverage**: ${story.coverage.coverage_percent}%\n`;
|
|
657
|
+
md += `- **Clarifications used**: ${story.coverage.clarifications_used}\n`;
|
|
658
|
+
if (story.validation.warnings.length === 0) {
|
|
659
|
+
md += `- **Assumptions**: NONE\n`;
|
|
660
|
+
} else {
|
|
661
|
+
const assumptions = story.validation.warnings.filter(w => w.type === 'assumption');
|
|
662
|
+
if (assumptions.length > 0) {
|
|
663
|
+
md += `- **Potential assumptions**: ${assumptions.length}\n`;
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
md += '\n';
|
|
667
|
+
|
|
668
|
+
// Traceability matrix
|
|
669
|
+
md += `## Traceability Matrix\n`;
|
|
670
|
+
md += `| Criterion | Source | Type |\n`;
|
|
671
|
+
md += `|-----------|--------|------|\n`;
|
|
672
|
+
for (const row of story.traceability) {
|
|
673
|
+
md += `| ${row.criterion_id} | ${row.source_id} | ${row.source_type} |\n`;
|
|
674
|
+
}
|
|
675
|
+
md += '\n';
|
|
676
|
+
|
|
677
|
+
// Complexity
|
|
678
|
+
md += `## Complexity\n`;
|
|
679
|
+
md += `**Score**: ${story.complexity.score || 'N/A'} (${story.complexity.level || 'unknown'})\n`;
|
|
680
|
+
if (story.complexity.estimated_stories) {
|
|
681
|
+
md += `**Estimated stories**: ${story.complexity.estimated_stories}\n`;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
return md;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// ==========================================================================
|
|
688
|
+
// E3-S3: One-by-One Presentation Flow
|
|
689
|
+
// ==========================================================================
|
|
690
|
+
|
|
691
|
+
/**
|
|
692
|
+
* Load presentation queue
|
|
693
|
+
*/
|
|
694
|
+
function loadQueue() {
|
|
695
|
+
const activeDigest = loadActiveDigest();
|
|
696
|
+
if (!activeDigest.session.digest_path) {
|
|
697
|
+
return null;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
const queuePath = path.join(activeDigest.session.digest_path, 'presentation-queue.json');
|
|
701
|
+
try {
|
|
702
|
+
return JSON.parse(fs.readFileSync(queuePath, 'utf8'));
|
|
703
|
+
} catch (err) {
|
|
704
|
+
return null;
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/**
|
|
709
|
+
* Save presentation queue
|
|
710
|
+
*/
|
|
711
|
+
function saveQueue(queue) {
|
|
712
|
+
const activeDigest = loadActiveDigest();
|
|
713
|
+
if (!activeDigest.session.digest_path) {
|
|
714
|
+
return { error: 'No active digest session' };
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
const queuePath = path.join(activeDigest.session.digest_path, 'presentation-queue.json');
|
|
718
|
+
fs.writeFileSync(queuePath, JSON.stringify(queue, null, 2));
|
|
719
|
+
return { saved: true };
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
/**
|
|
723
|
+
* Initialize presentation queue from generated stories
|
|
724
|
+
*/
|
|
725
|
+
function initializePresentation() {
|
|
726
|
+
const stories = loadAllStories();
|
|
727
|
+
if (stories.length === 0) {
|
|
728
|
+
return { error: 'No stories to present. Run generate-stories first.' };
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
const activeDigest = loadActiveDigest();
|
|
732
|
+
|
|
733
|
+
const queue = {
|
|
734
|
+
session_id: activeDigest.session.id,
|
|
735
|
+
presentation: {
|
|
736
|
+
status: 'in_progress',
|
|
737
|
+
started_at: now(),
|
|
738
|
+
current_index: 0,
|
|
739
|
+
current_story_id: null
|
|
740
|
+
},
|
|
741
|
+
stories: stories.map(s => ({
|
|
742
|
+
id: s.id,
|
|
743
|
+
topic_id: s.topic_id,
|
|
744
|
+
title: s.title,
|
|
745
|
+
criteria_count: s.acceptance_criteria.length,
|
|
746
|
+
coverage: s.coverage.coverage_percent,
|
|
747
|
+
status: 'pending'
|
|
748
|
+
})),
|
|
749
|
+
summary: {
|
|
750
|
+
total: stories.length,
|
|
751
|
+
approved: 0,
|
|
752
|
+
rejected: 0,
|
|
753
|
+
skipped: 0,
|
|
754
|
+
pending: stories.length,
|
|
755
|
+
presenting: 0
|
|
756
|
+
}
|
|
757
|
+
};
|
|
758
|
+
|
|
759
|
+
saveQueue(queue);
|
|
760
|
+
return queue;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
/**
|
|
764
|
+
* Get presentation status
|
|
765
|
+
*/
|
|
766
|
+
function getPresentationStatus() {
|
|
767
|
+
const queue = loadQueue();
|
|
768
|
+
if (!queue) {
|
|
769
|
+
return { active: false };
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
return {
|
|
773
|
+
active: true,
|
|
774
|
+
status: queue.presentation.status,
|
|
775
|
+
progress: {
|
|
776
|
+
reviewed: queue.summary.approved + queue.summary.rejected,
|
|
777
|
+
remaining: queue.summary.pending + queue.summary.skipped,
|
|
778
|
+
total: queue.summary.total
|
|
779
|
+
},
|
|
780
|
+
current: queue.presentation.current_story_id,
|
|
781
|
+
summary: queue.summary
|
|
782
|
+
};
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
/**
|
|
786
|
+
* Get the next story to present
|
|
787
|
+
*/
|
|
788
|
+
function getNextStory() {
|
|
789
|
+
let queue = loadQueue();
|
|
790
|
+
|
|
791
|
+
// Initialize if no queue exists
|
|
792
|
+
if (!queue) {
|
|
793
|
+
queue = initializePresentation();
|
|
794
|
+
if (queue.error) return queue;
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
// Mark any currently presenting story as skipped (interrupted)
|
|
798
|
+
const currentlyPresenting = queue.stories.find(s => s.status === 'presenting');
|
|
799
|
+
if (currentlyPresenting) {
|
|
800
|
+
currentlyPresenting.status = 'skipped';
|
|
801
|
+
currentlyPresenting.skipped_at = now();
|
|
802
|
+
queue.summary.presenting--;
|
|
803
|
+
queue.summary.skipped++;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
// Find first pending story (prefer pending over skipped)
|
|
807
|
+
let nextIndex = queue.stories.findIndex(s => s.status === 'pending');
|
|
808
|
+
|
|
809
|
+
// If no pending, try skipped
|
|
810
|
+
if (nextIndex === -1) {
|
|
811
|
+
nextIndex = queue.stories.findIndex(s => s.status === 'skipped');
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
// All done
|
|
815
|
+
if (nextIndex === -1) {
|
|
816
|
+
queue.presentation.status = 'completed';
|
|
817
|
+
queue.presentation.completed_at = now();
|
|
818
|
+
saveQueue(queue);
|
|
819
|
+
return { complete: true, summary: queue.summary };
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
// Mark as presenting
|
|
823
|
+
const entry = queue.stories[nextIndex];
|
|
824
|
+
const wasSkipped = entry.status === 'skipped';
|
|
825
|
+
entry.status = 'presenting';
|
|
826
|
+
entry.presented_at = now();
|
|
827
|
+
if (wasSkipped) {
|
|
828
|
+
queue.summary.skipped--;
|
|
829
|
+
} else {
|
|
830
|
+
queue.summary.pending--;
|
|
831
|
+
}
|
|
832
|
+
queue.summary.presenting++;
|
|
833
|
+
|
|
834
|
+
queue.presentation.current_index = nextIndex;
|
|
835
|
+
queue.presentation.current_story_id = entry.id;
|
|
836
|
+
|
|
837
|
+
saveQueue(queue);
|
|
838
|
+
|
|
839
|
+
// Load full story
|
|
840
|
+
const story = loadStory(entry.id);
|
|
841
|
+
|
|
842
|
+
return {
|
|
843
|
+
index: nextIndex + 1,
|
|
844
|
+
total: queue.stories.length,
|
|
845
|
+
story,
|
|
846
|
+
queue_entry: entry,
|
|
847
|
+
summary: queue.summary
|
|
848
|
+
};
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
/**
|
|
852
|
+
* Get current story being presented
|
|
853
|
+
*/
|
|
854
|
+
function getCurrentStory() {
|
|
855
|
+
const queue = loadQueue();
|
|
856
|
+
if (!queue) {
|
|
857
|
+
return { error: 'No presentation in progress' };
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
const currentEntry = queue.stories.find(s => s.status === 'presenting');
|
|
861
|
+
if (!currentEntry) {
|
|
862
|
+
return { error: 'No story currently being presented' };
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
const story = loadStory(currentEntry.id);
|
|
866
|
+
const index = queue.stories.indexOf(currentEntry);
|
|
867
|
+
|
|
868
|
+
return {
|
|
869
|
+
index: index + 1,
|
|
870
|
+
total: queue.stories.length,
|
|
871
|
+
story,
|
|
872
|
+
queue_entry: currentEntry,
|
|
873
|
+
summary: queue.summary
|
|
874
|
+
};
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
/**
|
|
878
|
+
* Approve current story
|
|
879
|
+
*/
|
|
880
|
+
function approveCurrentStory() {
|
|
881
|
+
const queue = loadQueue();
|
|
882
|
+
if (!queue) {
|
|
883
|
+
return { error: 'No presentation in progress' };
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
const entry = queue.stories.find(s => s.status === 'presenting');
|
|
887
|
+
if (!entry) {
|
|
888
|
+
return { error: 'No story currently being presented' };
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
entry.status = 'approved';
|
|
892
|
+
entry.decided_at = now();
|
|
893
|
+
|
|
894
|
+
queue.summary.approved++;
|
|
895
|
+
queue.summary.presenting--;
|
|
896
|
+
|
|
897
|
+
saveQueue(queue);
|
|
898
|
+
|
|
899
|
+
return { success: true, story_id: entry.id, title: entry.title };
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
/**
|
|
903
|
+
* Reject current story with reason
|
|
904
|
+
*/
|
|
905
|
+
function rejectCurrentStory(reason) {
|
|
906
|
+
const queue = loadQueue();
|
|
907
|
+
if (!queue) {
|
|
908
|
+
return { error: 'No presentation in progress' };
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
const entry = queue.stories.find(s => s.status === 'presenting');
|
|
912
|
+
if (!entry) {
|
|
913
|
+
return { error: 'No story currently being presented' };
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
entry.status = 'rejected';
|
|
917
|
+
entry.decided_at = now();
|
|
918
|
+
entry.rejection_reason = reason || 'No reason provided';
|
|
919
|
+
|
|
920
|
+
queue.summary.rejected++;
|
|
921
|
+
queue.summary.presenting--;
|
|
922
|
+
|
|
923
|
+
saveQueue(queue);
|
|
924
|
+
|
|
925
|
+
return { success: true, story_id: entry.id, title: entry.title, reason: entry.rejection_reason };
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
/**
|
|
929
|
+
* Skip current story for later
|
|
930
|
+
*/
|
|
931
|
+
function skipCurrentStory() {
|
|
932
|
+
const queue = loadQueue();
|
|
933
|
+
if (!queue) {
|
|
934
|
+
return { error: 'No presentation in progress' };
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
const entry = queue.stories.find(s => s.status === 'presenting');
|
|
938
|
+
if (!entry) {
|
|
939
|
+
return { error: 'No story currently being presented' };
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
entry.status = 'skipped';
|
|
943
|
+
entry.skipped_at = now();
|
|
944
|
+
|
|
945
|
+
queue.summary.skipped++;
|
|
946
|
+
queue.summary.presenting--;
|
|
947
|
+
|
|
948
|
+
saveQueue(queue);
|
|
949
|
+
|
|
950
|
+
return { success: true, story_id: entry.id, title: entry.title };
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
/**
|
|
954
|
+
* Format story summary for presentation (compact view)
|
|
955
|
+
*/
|
|
956
|
+
function formatStorySummary(storyData) {
|
|
957
|
+
const { index, total, story, summary } = storyData;
|
|
958
|
+
|
|
959
|
+
let output = '';
|
|
960
|
+
|
|
961
|
+
// Header box
|
|
962
|
+
output += `${'═'.repeat(64)}\n`;
|
|
963
|
+
output += ` Story ${index} of ${total}: ${story.title}\n`;
|
|
964
|
+
output += `${'═'.repeat(64)}\n\n`;
|
|
965
|
+
|
|
966
|
+
// User story
|
|
967
|
+
output += ` As a ${story.user_story.user_type.toUpperCase()},\n`;
|
|
968
|
+
output += ` I want to ${story.user_story.action.toUpperCase()}\n`;
|
|
969
|
+
output += ` So that I can ${story.user_story.benefit}\n\n`;
|
|
970
|
+
|
|
971
|
+
// Stats
|
|
972
|
+
output += ` Acceptance Criteria: ${story.acceptance_criteria.length}\n`;
|
|
973
|
+
output += ` Coverage: ${story.coverage.coverage_percent}%`;
|
|
974
|
+
if (story.validation.warnings.length === 0) {
|
|
975
|
+
output += ' (no assumptions)';
|
|
976
|
+
}
|
|
977
|
+
output += '\n';
|
|
978
|
+
|
|
979
|
+
if (story.complexity && story.complexity.score) {
|
|
980
|
+
output += ` Complexity: ${story.complexity.level} (${story.complexity.score})\n`;
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
output += `\n${'─'.repeat(64)}\n`;
|
|
984
|
+
|
|
985
|
+
// Progress
|
|
986
|
+
output += ` Progress: ${summary.approved} approved, ${summary.rejected} rejected, ${summary.pending + summary.skipped} remaining\n`;
|
|
987
|
+
|
|
988
|
+
output += `${'─'.repeat(64)}\n`;
|
|
989
|
+
|
|
990
|
+
return output;
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
/**
|
|
994
|
+
* Format presentation actions prompt
|
|
995
|
+
*/
|
|
996
|
+
function formatActionsPrompt() {
|
|
997
|
+
return `\nActions: [a]pprove [r]eject [s]kip [v]iew full [n]ext [q]uit\n`;
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
/**
|
|
1001
|
+
* Get completion summary
|
|
1002
|
+
*/
|
|
1003
|
+
function getCompletionSummary() {
|
|
1004
|
+
const queue = loadQueue();
|
|
1005
|
+
if (!queue) {
|
|
1006
|
+
return { error: 'No presentation data' };
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
const approved = queue.stories.filter(s => s.status === 'approved');
|
|
1010
|
+
const rejected = queue.stories.filter(s => s.status === 'rejected');
|
|
1011
|
+
const skipped = queue.stories.filter(s => s.status === 'skipped');
|
|
1012
|
+
const pending = queue.stories.filter(s => s.status === 'pending');
|
|
1013
|
+
|
|
1014
|
+
return {
|
|
1015
|
+
complete: queue.presentation.status === 'completed',
|
|
1016
|
+
summary: queue.summary,
|
|
1017
|
+
approved: approved.map(s => ({ id: s.id, title: s.title })),
|
|
1018
|
+
rejected: rejected.map(s => ({ id: s.id, title: s.title, reason: s.rejection_reason })),
|
|
1019
|
+
skipped: skipped.map(s => ({ id: s.id, title: s.title })),
|
|
1020
|
+
pending: pending.map(s => ({ id: s.id, title: s.title }))
|
|
1021
|
+
};
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
/**
|
|
1025
|
+
* Reset presentation (start over)
|
|
1026
|
+
*/
|
|
1027
|
+
function resetPresentation() {
|
|
1028
|
+
const queue = loadQueue();
|
|
1029
|
+
if (!queue) {
|
|
1030
|
+
return { error: 'No presentation to reset' };
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
// Reset all stories to pending
|
|
1034
|
+
for (const entry of queue.stories) {
|
|
1035
|
+
entry.status = 'pending';
|
|
1036
|
+
delete entry.presented_at;
|
|
1037
|
+
delete entry.decided_at;
|
|
1038
|
+
delete entry.skipped_at;
|
|
1039
|
+
delete entry.rejection_reason;
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
queue.presentation = {
|
|
1043
|
+
status: 'in_progress',
|
|
1044
|
+
started_at: now(),
|
|
1045
|
+
current_index: 0,
|
|
1046
|
+
current_story_id: null
|
|
1047
|
+
};
|
|
1048
|
+
|
|
1049
|
+
queue.summary = {
|
|
1050
|
+
total: queue.stories.length,
|
|
1051
|
+
approved: 0,
|
|
1052
|
+
rejected: 0,
|
|
1053
|
+
skipped: 0,
|
|
1054
|
+
pending: queue.stories.length,
|
|
1055
|
+
presenting: 0
|
|
1056
|
+
};
|
|
1057
|
+
|
|
1058
|
+
saveQueue(queue);
|
|
1059
|
+
|
|
1060
|
+
return { success: true, total: queue.stories.length };
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
// ============================================================================
|
|
1064
|
+
// E3-S4: Edit and Change Handling
|
|
1065
|
+
// ============================================================================
|
|
1066
|
+
|
|
1067
|
+
/**
|
|
1068
|
+
* Generate unique edit session ID
|
|
1069
|
+
*/
|
|
1070
|
+
function generateEditSessionId() {
|
|
1071
|
+
return 'edit-' + crypto.randomBytes(4).toString('hex');
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
/**
|
|
1075
|
+
* Generate unique change ID
|
|
1076
|
+
*/
|
|
1077
|
+
function generateChangeId() {
|
|
1078
|
+
return 'change-' + crypto.randomBytes(3).toString('hex');
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
/**
|
|
1082
|
+
* Load edit sessions data
|
|
1083
|
+
*/
|
|
1084
|
+
function loadEditSessions() {
|
|
1085
|
+
const activeDigest = loadActiveDigest();
|
|
1086
|
+
if (!activeDigest.session.digest_path) {
|
|
1087
|
+
return null;
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
const sessionsPath = path.join(activeDigest.session.digest_path, 'edit-sessions.json');
|
|
1091
|
+
try {
|
|
1092
|
+
return JSON.parse(fs.readFileSync(sessionsPath, 'utf8'));
|
|
1093
|
+
} catch (err) {
|
|
1094
|
+
return { active_session: null, sessions: [] };
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
/**
|
|
1099
|
+
* Save edit sessions data
|
|
1100
|
+
*/
|
|
1101
|
+
function saveEditSessions(data) {
|
|
1102
|
+
const activeDigest = loadActiveDigest();
|
|
1103
|
+
if (!activeDigest.session.digest_path) {
|
|
1104
|
+
return { error: 'No active digest session' };
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
const sessionsPath = path.join(activeDigest.session.digest_path, 'edit-sessions.json');
|
|
1108
|
+
writeJson(sessionsPath, data);
|
|
1109
|
+
return { saved: true };
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
/**
|
|
1113
|
+
* Start an edit session for a story
|
|
1114
|
+
*/
|
|
1115
|
+
function startEditSession(storyId, reason) {
|
|
1116
|
+
const story = loadStory(storyId);
|
|
1117
|
+
if (!story) {
|
|
1118
|
+
return { error: `Story ${storyId} not found` };
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
const sessionsData = loadEditSessions() || { active_session: null, sessions: [] };
|
|
1122
|
+
|
|
1123
|
+
// Check if there's already an active session
|
|
1124
|
+
if (sessionsData.active_session && sessionsData.active_session.active) {
|
|
1125
|
+
return {
|
|
1126
|
+
error: 'An edit session is already active',
|
|
1127
|
+
active_session: sessionsData.active_session
|
|
1128
|
+
};
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
// Get rejection reason if story was rejected
|
|
1132
|
+
const queue = loadQueue();
|
|
1133
|
+
const queueEntry = queue?.stories.find(s => s.id === storyId);
|
|
1134
|
+
const rejectionReason = queueEntry?.rejection_reason;
|
|
1135
|
+
|
|
1136
|
+
const session = {
|
|
1137
|
+
id: generateEditSessionId(),
|
|
1138
|
+
story_id: storyId,
|
|
1139
|
+
started_at: now(),
|
|
1140
|
+
trigger: reason || (queueEntry?.status === 'rejected' ? 'rejection' : 'manual'),
|
|
1141
|
+
rejection_reason: rejectionReason,
|
|
1142
|
+
original_status: queueEntry?.status || 'unknown',
|
|
1143
|
+
changes: [],
|
|
1144
|
+
active: true
|
|
1145
|
+
};
|
|
1146
|
+
|
|
1147
|
+
sessionsData.active_session = session;
|
|
1148
|
+
saveEditSessions(sessionsData);
|
|
1149
|
+
|
|
1150
|
+
return {
|
|
1151
|
+
session,
|
|
1152
|
+
story,
|
|
1153
|
+
rejection_reason: rejectionReason,
|
|
1154
|
+
editable_sections: [
|
|
1155
|
+
'user_story',
|
|
1156
|
+
'acceptance_criteria',
|
|
1157
|
+
'technical_notes',
|
|
1158
|
+
'description'
|
|
1159
|
+
]
|
|
1160
|
+
};
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
/**
|
|
1164
|
+
* Get active edit session
|
|
1165
|
+
*/
|
|
1166
|
+
function getActiveEditSession() {
|
|
1167
|
+
const sessionsData = loadEditSessions();
|
|
1168
|
+
if (!sessionsData || !sessionsData.active_session || !sessionsData.active_session.active) {
|
|
1169
|
+
return null;
|
|
1170
|
+
}
|
|
1171
|
+
return sessionsData.active_session;
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
/**
|
|
1175
|
+
* Record a change in the active edit session
|
|
1176
|
+
*/
|
|
1177
|
+
function recordChange(change) {
|
|
1178
|
+
const sessionsData = loadEditSessions();
|
|
1179
|
+
if (!sessionsData || !sessionsData.active_session || !sessionsData.active_session.active) {
|
|
1180
|
+
return { error: 'No active edit session' };
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
change.id = generateChangeId();
|
|
1184
|
+
change.timestamp = now();
|
|
1185
|
+
|
|
1186
|
+
sessionsData.active_session.changes.push(change);
|
|
1187
|
+
saveEditSessions(sessionsData);
|
|
1188
|
+
|
|
1189
|
+
return { recorded: true, change };
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
/**
|
|
1193
|
+
* Edit user story fields
|
|
1194
|
+
*/
|
|
1195
|
+
function editUserStory(storyId, updates) {
|
|
1196
|
+
const session = getActiveEditSession();
|
|
1197
|
+
if (!session || session.story_id !== storyId) {
|
|
1198
|
+
return { error: 'No active edit session for this story. Run edit-story first.' };
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
const story = loadStory(storyId);
|
|
1202
|
+
if (!story) {
|
|
1203
|
+
return { error: `Story ${storyId} not found` };
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
const changes = [];
|
|
1207
|
+
|
|
1208
|
+
if (updates.user_type && updates.user_type !== story.user_story.user_type) {
|
|
1209
|
+
changes.push({
|
|
1210
|
+
type: 'user_story_modified',
|
|
1211
|
+
section: 'user_story',
|
|
1212
|
+
field: 'user_type',
|
|
1213
|
+
before: story.user_story.user_type,
|
|
1214
|
+
after: updates.user_type
|
|
1215
|
+
});
|
|
1216
|
+
story.user_story.user_type = updates.user_type;
|
|
1217
|
+
story.user_story.user_type_source = 'manual';
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
if (updates.action && updates.action !== story.user_story.action) {
|
|
1221
|
+
changes.push({
|
|
1222
|
+
type: 'user_story_modified',
|
|
1223
|
+
section: 'user_story',
|
|
1224
|
+
field: 'action',
|
|
1225
|
+
before: story.user_story.action,
|
|
1226
|
+
after: updates.action
|
|
1227
|
+
});
|
|
1228
|
+
story.user_story.action = updates.action;
|
|
1229
|
+
story.user_story.action_source = 'manual';
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
if (updates.benefit && updates.benefit !== story.user_story.benefit) {
|
|
1233
|
+
changes.push({
|
|
1234
|
+
type: 'user_story_modified',
|
|
1235
|
+
section: 'user_story',
|
|
1236
|
+
field: 'benefit',
|
|
1237
|
+
before: story.user_story.benefit,
|
|
1238
|
+
after: updates.benefit
|
|
1239
|
+
});
|
|
1240
|
+
story.user_story.benefit = updates.benefit;
|
|
1241
|
+
story.user_story.benefit_source = 'manual';
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
// Record all changes
|
|
1245
|
+
for (const change of changes) {
|
|
1246
|
+
recordChange(change);
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
// Save the story (uncommitted until commit-edit)
|
|
1250
|
+
saveStory(story);
|
|
1251
|
+
|
|
1252
|
+
return { success: true, story, changes };
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
/**
|
|
1256
|
+
* Edit a specific acceptance criterion
|
|
1257
|
+
*/
|
|
1258
|
+
function editCriterion(storyId, criterionId, updates) {
|
|
1259
|
+
const session = getActiveEditSession();
|
|
1260
|
+
if (!session || session.story_id !== storyId) {
|
|
1261
|
+
return { error: 'No active edit session for this story. Run edit-story first.' };
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
const story = loadStory(storyId);
|
|
1265
|
+
if (!story) {
|
|
1266
|
+
return { error: `Story ${storyId} not found` };
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
const criterion = story.acceptance_criteria.find(ac => ac.id === criterionId);
|
|
1270
|
+
if (!criterion) {
|
|
1271
|
+
return { error: `Criterion ${criterionId} not found in story ${storyId}` };
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
const changes = [];
|
|
1275
|
+
|
|
1276
|
+
if (updates.scenario && updates.scenario !== criterion.scenario) {
|
|
1277
|
+
changes.push({
|
|
1278
|
+
type: 'criteria_modified',
|
|
1279
|
+
target: criterionId,
|
|
1280
|
+
field: 'scenario',
|
|
1281
|
+
before: criterion.scenario,
|
|
1282
|
+
after: updates.scenario
|
|
1283
|
+
});
|
|
1284
|
+
criterion.scenario = updates.scenario;
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
if (updates.given) {
|
|
1288
|
+
const beforeText = criterion.given?.text || '';
|
|
1289
|
+
if (updates.given !== beforeText) {
|
|
1290
|
+
changes.push({
|
|
1291
|
+
type: 'criteria_modified',
|
|
1292
|
+
target: criterionId,
|
|
1293
|
+
field: 'given',
|
|
1294
|
+
before: beforeText,
|
|
1295
|
+
after: updates.given
|
|
1296
|
+
});
|
|
1297
|
+
criterion.given = { text: updates.given, source: 'manual' };
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
if (updates.when) {
|
|
1302
|
+
const beforeText = criterion.when?.text || '';
|
|
1303
|
+
if (updates.when !== beforeText) {
|
|
1304
|
+
changes.push({
|
|
1305
|
+
type: 'criteria_modified',
|
|
1306
|
+
target: criterionId,
|
|
1307
|
+
field: 'when',
|
|
1308
|
+
before: beforeText,
|
|
1309
|
+
after: updates.when
|
|
1310
|
+
});
|
|
1311
|
+
criterion.when = { text: updates.when, source: 'manual' };
|
|
1312
|
+
}
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
if (updates.then) {
|
|
1316
|
+
const beforeText = criterion.then?.text || '';
|
|
1317
|
+
if (updates.then !== beforeText) {
|
|
1318
|
+
changes.push({
|
|
1319
|
+
type: 'criteria_modified',
|
|
1320
|
+
target: criterionId,
|
|
1321
|
+
field: 'then',
|
|
1322
|
+
before: beforeText,
|
|
1323
|
+
after: updates.then
|
|
1324
|
+
});
|
|
1325
|
+
criterion.then = { text: updates.then, source: 'manual' };
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
// Record all changes
|
|
1330
|
+
for (const change of changes) {
|
|
1331
|
+
recordChange(change);
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
// Save the story
|
|
1335
|
+
saveStory(story);
|
|
1336
|
+
|
|
1337
|
+
return { success: true, story, criterion, changes };
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
/**
|
|
1341
|
+
* Add a new acceptance criterion
|
|
1342
|
+
*/
|
|
1343
|
+
function addCriterion(storyId, criterion) {
|
|
1344
|
+
const session = getActiveEditSession();
|
|
1345
|
+
if (!session || session.story_id !== storyId) {
|
|
1346
|
+
return { error: 'No active edit session for this story. Run edit-story first.' };
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
const story = loadStory(storyId);
|
|
1350
|
+
if (!story) {
|
|
1351
|
+
return { error: `Story ${storyId} not found` };
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
// Generate new AC ID
|
|
1355
|
+
const existingIds = story.acceptance_criteria.map(ac =>
|
|
1356
|
+
parseInt(ac.id.replace('AC-', ''), 10) || 0
|
|
1357
|
+
);
|
|
1358
|
+
const maxId = existingIds.length > 0 ? Math.max(...existingIds) : 0;
|
|
1359
|
+
const newId = `AC-${maxId + 1}`;
|
|
1360
|
+
|
|
1361
|
+
const newCriterion = {
|
|
1362
|
+
id: newId,
|
|
1363
|
+
scenario: criterion.scenario || `Scenario ${maxId + 1}`,
|
|
1364
|
+
given: { text: criterion.given || 'the system is ready', source: 'manual' },
|
|
1365
|
+
when: { text: criterion.when || 'the user performs the action', source: 'manual' },
|
|
1366
|
+
then: { text: criterion.then || 'the expected result occurs', source: 'manual' },
|
|
1367
|
+
and: criterion.and?.map(a => ({ text: a, source: 'manual' })) || [],
|
|
1368
|
+
derived_from: [{ id: 'manual', text: 'Manually added criterion' }]
|
|
1369
|
+
};
|
|
1370
|
+
|
|
1371
|
+
story.acceptance_criteria.push(newCriterion);
|
|
1372
|
+
|
|
1373
|
+
// Record the change
|
|
1374
|
+
recordChange({
|
|
1375
|
+
type: 'criteria_added',
|
|
1376
|
+
target: newId,
|
|
1377
|
+
before: null,
|
|
1378
|
+
after: newCriterion
|
|
1379
|
+
});
|
|
1380
|
+
|
|
1381
|
+
// Save the story
|
|
1382
|
+
saveStory(story);
|
|
1383
|
+
|
|
1384
|
+
return { success: true, story, criterion: newCriterion };
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
/**
|
|
1388
|
+
* Remove an acceptance criterion
|
|
1389
|
+
*/
|
|
1390
|
+
function removeCriterion(storyId, criterionId, reason) {
|
|
1391
|
+
const session = getActiveEditSession();
|
|
1392
|
+
if (!session || session.story_id !== storyId) {
|
|
1393
|
+
return { error: 'No active edit session for this story. Run edit-story first.' };
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
const story = loadStory(storyId);
|
|
1397
|
+
if (!story) {
|
|
1398
|
+
return { error: `Story ${storyId} not found` };
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
const index = story.acceptance_criteria.findIndex(ac => ac.id === criterionId);
|
|
1402
|
+
if (index === -1) {
|
|
1403
|
+
return { error: `Criterion ${criterionId} not found in story ${storyId}` };
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
// Don't allow removing last criterion
|
|
1407
|
+
if (story.acceptance_criteria.length === 1) {
|
|
1408
|
+
return { error: 'Cannot remove the last acceptance criterion' };
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
const removed = story.acceptance_criteria.splice(index, 1)[0];
|
|
1412
|
+
|
|
1413
|
+
// Record the change
|
|
1414
|
+
recordChange({
|
|
1415
|
+
type: 'criteria_removed',
|
|
1416
|
+
target: criterionId,
|
|
1417
|
+
before: removed,
|
|
1418
|
+
after: null,
|
|
1419
|
+
reason: reason || 'Removed by user'
|
|
1420
|
+
});
|
|
1421
|
+
|
|
1422
|
+
// Save the story
|
|
1423
|
+
saveStory(story);
|
|
1424
|
+
|
|
1425
|
+
return { success: true, story, removed };
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
/**
|
|
1429
|
+
* Validate an edited story
|
|
1430
|
+
*/
|
|
1431
|
+
function validateEditedStory(story) {
|
|
1432
|
+
const warnings = [];
|
|
1433
|
+
const errors = [];
|
|
1434
|
+
|
|
1435
|
+
// Check user story completeness
|
|
1436
|
+
if (!story.user_story.user_type || story.user_story.user_type === '') {
|
|
1437
|
+
errors.push({ field: 'user_story.user_type', message: 'User type is required' });
|
|
1438
|
+
}
|
|
1439
|
+
if (!story.user_story.action || story.user_story.action === '') {
|
|
1440
|
+
errors.push({ field: 'user_story.action', message: 'Action is required' });
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
// Check acceptance criteria
|
|
1444
|
+
if (!story.acceptance_criteria || story.acceptance_criteria.length === 0) {
|
|
1445
|
+
errors.push({ field: 'acceptance_criteria', message: 'At least one acceptance criterion required' });
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
for (const ac of story.acceptance_criteria || []) {
|
|
1449
|
+
if (!ac.given?.text) {
|
|
1450
|
+
warnings.push({ field: `${ac.id}.given`, message: 'Given clause is empty' });
|
|
1451
|
+
}
|
|
1452
|
+
if (!ac.when?.text) {
|
|
1453
|
+
warnings.push({ field: `${ac.id}.when`, message: 'When clause is empty' });
|
|
1454
|
+
}
|
|
1455
|
+
if (!ac.then?.text) {
|
|
1456
|
+
warnings.push({ field: `${ac.id}.then`, message: 'Then clause is empty' });
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
// Check for manual-only coverage (all criteria manually added)
|
|
1461
|
+
const manualOnlyCriteria = (story.acceptance_criteria || []).filter(ac =>
|
|
1462
|
+
ac.given?.source === 'manual' &&
|
|
1463
|
+
ac.when?.source === 'manual' &&
|
|
1464
|
+
ac.then?.source === 'manual'
|
|
1465
|
+
);
|
|
1466
|
+
|
|
1467
|
+
if (manualOnlyCriteria.length === story.acceptance_criteria?.length) {
|
|
1468
|
+
warnings.push({
|
|
1469
|
+
field: 'coverage',
|
|
1470
|
+
message: 'All criteria are manually added - no traceability to original transcript'
|
|
1471
|
+
});
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
return {
|
|
1475
|
+
valid: errors.length === 0,
|
|
1476
|
+
errors,
|
|
1477
|
+
warnings
|
|
1478
|
+
};
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
/**
|
|
1482
|
+
* Recalculate coverage after edits
|
|
1483
|
+
*/
|
|
1484
|
+
function recalculateCoverage(story) {
|
|
1485
|
+
const totalCriteria = story.acceptance_criteria.length;
|
|
1486
|
+
let tracedCriteria = 0;
|
|
1487
|
+
|
|
1488
|
+
for (const ac of story.acceptance_criteria) {
|
|
1489
|
+
// Count criteria with at least one non-manual source
|
|
1490
|
+
const sources = [ac.given?.source, ac.when?.source, ac.then?.source];
|
|
1491
|
+
if (sources.some(s => s && s !== 'manual' && s !== 'context' && s !== 'inferred')) {
|
|
1492
|
+
tracedCriteria++;
|
|
1493
|
+
}
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
return {
|
|
1497
|
+
statements_total: story.coverage?.statements_total || 0,
|
|
1498
|
+
statements_covered: story.coverage?.statements_covered || 0,
|
|
1499
|
+
coverage_percent: totalCriteria > 0 ? Math.round((tracedCriteria / totalCriteria) * 100) : 0,
|
|
1500
|
+
clarifications_used: story.coverage?.clarifications_used || 0,
|
|
1501
|
+
manual_criteria: totalCriteria - tracedCriteria,
|
|
1502
|
+
assumptions: story.coverage?.assumptions || []
|
|
1503
|
+
};
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
/**
|
|
1507
|
+
* Update queue after edit is committed
|
|
1508
|
+
*/
|
|
1509
|
+
function updateQueueAfterEdit(storyId) {
|
|
1510
|
+
const queue = loadQueue();
|
|
1511
|
+
if (!queue) return { error: 'No queue found' };
|
|
1512
|
+
|
|
1513
|
+
const entry = queue.stories.find(s => s.id === storyId);
|
|
1514
|
+
if (!entry) return { error: 'Story not found in queue' };
|
|
1515
|
+
|
|
1516
|
+
// Track previous status
|
|
1517
|
+
const previousStatus = entry.status;
|
|
1518
|
+
|
|
1519
|
+
// Update summary counts
|
|
1520
|
+
if (previousStatus === 'rejected') {
|
|
1521
|
+
queue.summary.rejected--;
|
|
1522
|
+
queue.summary.pending++;
|
|
1523
|
+
} else if (previousStatus === 'approved') {
|
|
1524
|
+
queue.summary.approved--;
|
|
1525
|
+
queue.summary.pending++;
|
|
1526
|
+
} else if (previousStatus === 'skipped') {
|
|
1527
|
+
queue.summary.skipped--;
|
|
1528
|
+
queue.summary.pending++;
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
// Reset entry to pending
|
|
1532
|
+
entry.status = 'pending';
|
|
1533
|
+
entry.edited_at = now();
|
|
1534
|
+
delete entry.rejection_reason;
|
|
1535
|
+
delete entry.decided_at;
|
|
1536
|
+
|
|
1537
|
+
// Reset presentation status if complete
|
|
1538
|
+
if (queue.presentation.status === 'completed') {
|
|
1539
|
+
queue.presentation.status = 'in_progress';
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
saveQueue(queue);
|
|
1543
|
+
|
|
1544
|
+
return { previous_status: previousStatus, new_status: 'pending' };
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1547
|
+
/**
|
|
1548
|
+
* Commit edit session and return story to review queue
|
|
1549
|
+
*/
|
|
1550
|
+
function commitEditSession() {
|
|
1551
|
+
const sessionsData = loadEditSessions();
|
|
1552
|
+
if (!sessionsData || !sessionsData.active_session || !sessionsData.active_session.active) {
|
|
1553
|
+
return { error: 'No active edit session to commit' };
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
const session = sessionsData.active_session;
|
|
1557
|
+
const story = loadStory(session.story_id);
|
|
1558
|
+
|
|
1559
|
+
if (!story) {
|
|
1560
|
+
return { error: 'Story not found' };
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
// Validate the edited story
|
|
1564
|
+
const validation = validateEditedStory(story);
|
|
1565
|
+
if (!validation.valid) {
|
|
1566
|
+
return {
|
|
1567
|
+
error: 'Story validation failed',
|
|
1568
|
+
errors: validation.errors,
|
|
1569
|
+
warnings: validation.warnings
|
|
1570
|
+
};
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
// Mark session complete
|
|
1574
|
+
session.completed_at = now();
|
|
1575
|
+
session.active = false;
|
|
1576
|
+
session.changes_count = session.changes.length;
|
|
1577
|
+
|
|
1578
|
+
// Update story with edit history
|
|
1579
|
+
if (!story.edit_history) {
|
|
1580
|
+
story.edit_history = [];
|
|
1581
|
+
}
|
|
1582
|
+
story.edit_history.push({
|
|
1583
|
+
session_id: session.id,
|
|
1584
|
+
timestamp: session.completed_at,
|
|
1585
|
+
changes_count: session.changes_count,
|
|
1586
|
+
trigger: session.trigger
|
|
1587
|
+
});
|
|
1588
|
+
|
|
1589
|
+
// Recalculate coverage
|
|
1590
|
+
story.coverage = recalculateCoverage(story);
|
|
1591
|
+
story.last_edited = now();
|
|
1592
|
+
|
|
1593
|
+
// Save story
|
|
1594
|
+
saveStory(story);
|
|
1595
|
+
|
|
1596
|
+
// Move session to history
|
|
1597
|
+
sessionsData.sessions.push(session);
|
|
1598
|
+
sessionsData.active_session = null;
|
|
1599
|
+
saveEditSessions(sessionsData);
|
|
1600
|
+
|
|
1601
|
+
// Update presentation queue
|
|
1602
|
+
const queueUpdate = updateQueueAfterEdit(story.id);
|
|
1603
|
+
|
|
1604
|
+
return {
|
|
1605
|
+
success: true,
|
|
1606
|
+
story_id: story.id,
|
|
1607
|
+
changes_made: session.changes_count,
|
|
1608
|
+
previous_status: queueUpdate.previous_status,
|
|
1609
|
+
new_status: 'pending',
|
|
1610
|
+
validation_warnings: validation.warnings
|
|
1611
|
+
};
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
|
+
/**
|
|
1615
|
+
* Cancel edit session and discard changes
|
|
1616
|
+
*/
|
|
1617
|
+
function cancelEditSession() {
|
|
1618
|
+
const sessionsData = loadEditSessions();
|
|
1619
|
+
if (!sessionsData || !sessionsData.active_session || !sessionsData.active_session.active) {
|
|
1620
|
+
return { error: 'No active edit session to cancel' };
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
const session = sessionsData.active_session;
|
|
1624
|
+
const changesCount = session.changes.length;
|
|
1625
|
+
|
|
1626
|
+
// Mark session as cancelled
|
|
1627
|
+
session.cancelled_at = now();
|
|
1628
|
+
session.active = false;
|
|
1629
|
+
session.cancelled = true;
|
|
1630
|
+
|
|
1631
|
+
// Move to history
|
|
1632
|
+
sessionsData.sessions.push(session);
|
|
1633
|
+
sessionsData.active_session = null;
|
|
1634
|
+
saveEditSessions(sessionsData);
|
|
1635
|
+
|
|
1636
|
+
// Note: We don't revert the story file here. The changes were saved as they were made.
|
|
1637
|
+
// A proper implementation would need to store the original story state and restore it.
|
|
1638
|
+
// For simplicity, we just mark the session as cancelled.
|
|
1639
|
+
|
|
1640
|
+
return { success: true, discarded_changes: changesCount };
|
|
1641
|
+
}
|
|
1642
|
+
|
|
1643
|
+
/**
|
|
1644
|
+
* Get changes in current edit session
|
|
1645
|
+
*/
|
|
1646
|
+
function getEditChanges() {
|
|
1647
|
+
const session = getActiveEditSession();
|
|
1648
|
+
if (!session) {
|
|
1649
|
+
return { error: 'No active edit session' };
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
return {
|
|
1653
|
+
session_id: session.id,
|
|
1654
|
+
story_id: session.story_id,
|
|
1655
|
+
started_at: session.started_at,
|
|
1656
|
+
trigger: session.trigger,
|
|
1657
|
+
rejection_reason: session.rejection_reason,
|
|
1658
|
+
changes: session.changes,
|
|
1659
|
+
changes_count: session.changes.length
|
|
1660
|
+
};
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
/**
|
|
1664
|
+
* Get edit history for a story
|
|
1665
|
+
*/
|
|
1666
|
+
function getEditHistory(storyId) {
|
|
1667
|
+
const story = loadStory(storyId);
|
|
1668
|
+
if (!story) {
|
|
1669
|
+
return { error: `Story ${storyId} not found` };
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
const sessionsData = loadEditSessions() || { sessions: [] };
|
|
1673
|
+
|
|
1674
|
+
// Filter sessions for this story
|
|
1675
|
+
const storySessions = sessionsData.sessions.filter(s => s.story_id === storyId);
|
|
1676
|
+
|
|
1677
|
+
return {
|
|
1678
|
+
story_id: storyId,
|
|
1679
|
+
title: story.title,
|
|
1680
|
+
edit_count: storySessions.length,
|
|
1681
|
+
sessions: storySessions.map(s => ({
|
|
1682
|
+
session_id: s.id,
|
|
1683
|
+
timestamp: s.completed_at || s.cancelled_at,
|
|
1684
|
+
trigger: s.trigger,
|
|
1685
|
+
changes_count: s.changes_count || s.changes.length,
|
|
1686
|
+
cancelled: s.cancelled || false
|
|
1687
|
+
})),
|
|
1688
|
+
story_edit_history: story.edit_history || []
|
|
1689
|
+
};
|
|
1690
|
+
}
|
|
1691
|
+
|
|
1692
|
+
/**
|
|
1693
|
+
* List stories that can be edited (rejected or approved)
|
|
1694
|
+
*/
|
|
1695
|
+
function listEditableStories() {
|
|
1696
|
+
const queue = loadQueue();
|
|
1697
|
+
if (!queue) {
|
|
1698
|
+
return { error: 'No presentation queue found' };
|
|
1699
|
+
}
|
|
1700
|
+
|
|
1701
|
+
const editable = queue.stories.filter(s =>
|
|
1702
|
+
s.status === 'rejected' || s.status === 'approved' || s.status === 'skipped'
|
|
1703
|
+
);
|
|
1704
|
+
|
|
1705
|
+
return {
|
|
1706
|
+
total: editable.length,
|
|
1707
|
+
rejected: editable.filter(s => s.status === 'rejected').map(s => ({
|
|
1708
|
+
id: s.id,
|
|
1709
|
+
title: s.title,
|
|
1710
|
+
status: s.status,
|
|
1711
|
+
rejection_reason: s.rejection_reason
|
|
1712
|
+
})),
|
|
1713
|
+
approved: editable.filter(s => s.status === 'approved').map(s => ({
|
|
1714
|
+
id: s.id,
|
|
1715
|
+
title: s.title,
|
|
1716
|
+
status: s.status
|
|
1717
|
+
})),
|
|
1718
|
+
skipped: editable.filter(s => s.status === 'skipped').map(s => ({
|
|
1719
|
+
id: s.id,
|
|
1720
|
+
title: s.title,
|
|
1721
|
+
status: s.status
|
|
1722
|
+
}))
|
|
1723
|
+
};
|
|
1724
|
+
}
|
|
1725
|
+
|
|
1726
|
+
// ============================================================================
|
|
1727
|
+
// E3-S5: ready.json Integration
|
|
1728
|
+
// ============================================================================
|
|
1729
|
+
|
|
1730
|
+
/**
|
|
1731
|
+
* Generate a workflow ID for task tracking
|
|
1732
|
+
*/
|
|
1733
|
+
function generateWorkflowId() {
|
|
1734
|
+
return 'wf-' + crypto.randomBytes(4).toString('hex');
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1737
|
+
/**
|
|
1738
|
+
* Generate sub-task ID from parent
|
|
1739
|
+
*/
|
|
1740
|
+
function generateSubTaskId(parentId, index) {
|
|
1741
|
+
return `${parentId}-${String(index).padStart(2, '0')}`;
|
|
1742
|
+
}
|
|
1743
|
+
|
|
1744
|
+
/**
|
|
1745
|
+
* Map story complexity to task priority
|
|
1746
|
+
*/
|
|
1747
|
+
const COMPLEXITY_TO_PRIORITY = {
|
|
1748
|
+
'simple': 'P3',
|
|
1749
|
+
'low': 'P3',
|
|
1750
|
+
'medium': 'P2',
|
|
1751
|
+
'high': 'P1',
|
|
1752
|
+
'very_high': 'P0'
|
|
1753
|
+
};
|
|
1754
|
+
|
|
1755
|
+
function mapPriority(story) {
|
|
1756
|
+
const level = story.complexity?.level || 'medium';
|
|
1757
|
+
return COMPLEXITY_TO_PRIORITY[level] || 'P2';
|
|
1758
|
+
}
|
|
1759
|
+
|
|
1760
|
+
/**
|
|
1761
|
+
* Format user story description
|
|
1762
|
+
*/
|
|
1763
|
+
function formatUserStoryDescription(userStory) {
|
|
1764
|
+
if (!userStory) return '';
|
|
1765
|
+
const who = userStory.user_type || 'user';
|
|
1766
|
+
const what = userStory.action || 'perform an action';
|
|
1767
|
+
const why = userStory.benefit || 'achieve my goal';
|
|
1768
|
+
return `As a ${who}, I want to ${what}, so that ${why}.`;
|
|
1769
|
+
}
|
|
1770
|
+
|
|
1771
|
+
/**
|
|
1772
|
+
* Convert a story to a workflow task
|
|
1773
|
+
*/
|
|
1774
|
+
function convertStoryToTask(story, options = {}) {
|
|
1775
|
+
const taskId = options.taskId || generateWorkflowId();
|
|
1776
|
+
const activeDigest = loadActiveDigest();
|
|
1777
|
+
|
|
1778
|
+
return {
|
|
1779
|
+
id: taskId,
|
|
1780
|
+
title: story.title,
|
|
1781
|
+
type: options.type || 'story',
|
|
1782
|
+
parent: options.parent || null,
|
|
1783
|
+
epic: options.epic || null,
|
|
1784
|
+
status: 'ready',
|
|
1785
|
+
priority: mapPriority(story),
|
|
1786
|
+
dependencies: options.dependencies || [],
|
|
1787
|
+
createdAt: now(),
|
|
1788
|
+
source: {
|
|
1789
|
+
type: 'transcript-digestion',
|
|
1790
|
+
digest_id: activeDigest.session?.id || 'unknown',
|
|
1791
|
+
story_id: story.id,
|
|
1792
|
+
topic_id: story.topic_id
|
|
1793
|
+
},
|
|
1794
|
+
description: formatUserStoryDescription(story.user_story),
|
|
1795
|
+
acceptanceCriteria: (story.acceptance_criteria || []).map(ac => ({
|
|
1796
|
+
id: ac.id,
|
|
1797
|
+
scenario: ac.scenario,
|
|
1798
|
+
given: ac.given?.text || '',
|
|
1799
|
+
when: ac.when?.text || '',
|
|
1800
|
+
then: ac.then?.text || ''
|
|
1801
|
+
})),
|
|
1802
|
+
metadata: {
|
|
1803
|
+
coverage: story.coverage?.coverage_percent || 0,
|
|
1804
|
+
criteria_count: (story.acceptance_criteria || []).length,
|
|
1805
|
+
generated_at: story.generated_at
|
|
1806
|
+
}
|
|
1807
|
+
};
|
|
1808
|
+
}
|
|
1809
|
+
|
|
1810
|
+
/**
|
|
1811
|
+
* Validate stories before export
|
|
1812
|
+
*/
|
|
1813
|
+
function validateForExport(stories) {
|
|
1814
|
+
const warnings = [];
|
|
1815
|
+
const errors = [];
|
|
1816
|
+
|
|
1817
|
+
for (const story of stories) {
|
|
1818
|
+
// Check coverage threshold
|
|
1819
|
+
if (story.coverage && story.coverage.coverage_percent < 50) {
|
|
1820
|
+
warnings.push({
|
|
1821
|
+
story_id: story.id,
|
|
1822
|
+
message: `Low coverage: ${story.coverage.coverage_percent}%`
|
|
1823
|
+
});
|
|
1824
|
+
}
|
|
1825
|
+
|
|
1826
|
+
// Check for empty criteria
|
|
1827
|
+
if (!story.acceptance_criteria || story.acceptance_criteria.length === 0) {
|
|
1828
|
+
errors.push({
|
|
1829
|
+
story_id: story.id,
|
|
1830
|
+
message: 'No acceptance criteria'
|
|
1831
|
+
});
|
|
1832
|
+
}
|
|
1833
|
+
|
|
1834
|
+
// Check for manual-only criteria
|
|
1835
|
+
if (story.acceptance_criteria && story.acceptance_criteria.length > 0) {
|
|
1836
|
+
const manualOnly = story.acceptance_criteria.every(ac =>
|
|
1837
|
+
ac.given?.source === 'manual' &&
|
|
1838
|
+
ac.when?.source === 'manual' &&
|
|
1839
|
+
ac.then?.source === 'manual'
|
|
1840
|
+
);
|
|
1841
|
+
if (manualOnly) {
|
|
1842
|
+
warnings.push({
|
|
1843
|
+
story_id: story.id,
|
|
1844
|
+
message: 'All criteria manually added - no transcript traceability'
|
|
1845
|
+
});
|
|
1846
|
+
}
|
|
1847
|
+
}
|
|
1848
|
+
}
|
|
1849
|
+
|
|
1850
|
+
return {
|
|
1851
|
+
valid: errors.length === 0,
|
|
1852
|
+
errors,
|
|
1853
|
+
warnings
|
|
1854
|
+
};
|
|
1855
|
+
}
|
|
1856
|
+
|
|
1857
|
+
/**
|
|
1858
|
+
* Export approved stories from the presentation queue
|
|
1859
|
+
*/
|
|
1860
|
+
function exportApprovedStories(options = {}) {
|
|
1861
|
+
const queue = loadQueue();
|
|
1862
|
+
if (!queue) {
|
|
1863
|
+
return { error: 'No presentation queue found' };
|
|
1864
|
+
}
|
|
1865
|
+
|
|
1866
|
+
const approved = queue.stories.filter(s => s.status === 'approved');
|
|
1867
|
+
if (approved.length === 0) {
|
|
1868
|
+
return { error: 'No approved stories to export' };
|
|
1869
|
+
}
|
|
1870
|
+
|
|
1871
|
+
// Load and convert each story
|
|
1872
|
+
const tasks = [];
|
|
1873
|
+
const loadErrors = [];
|
|
1874
|
+
|
|
1875
|
+
for (const entry of approved) {
|
|
1876
|
+
const story = loadStory(entry.id);
|
|
1877
|
+
if (!story) {
|
|
1878
|
+
loadErrors.push({ id: entry.id, error: 'Story file not found' });
|
|
1879
|
+
continue;
|
|
1880
|
+
}
|
|
1881
|
+
|
|
1882
|
+
const task = convertStoryToTask(story, options);
|
|
1883
|
+
tasks.push(task);
|
|
1884
|
+
}
|
|
1885
|
+
|
|
1886
|
+
// Validate before export
|
|
1887
|
+
const stories = approved.map(e => loadStory(e.id)).filter(Boolean);
|
|
1888
|
+
const validation = validateForExport(stories);
|
|
1889
|
+
|
|
1890
|
+
return {
|
|
1891
|
+
tasks,
|
|
1892
|
+
loadErrors,
|
|
1893
|
+
validation,
|
|
1894
|
+
summary: {
|
|
1895
|
+
total_approved: approved.length,
|
|
1896
|
+
exported: tasks.length,
|
|
1897
|
+
failed: loadErrors.length
|
|
1898
|
+
}
|
|
1899
|
+
};
|
|
1900
|
+
}
|
|
1901
|
+
|
|
1902
|
+
/**
|
|
1903
|
+
* Create a feature task grouping multiple stories
|
|
1904
|
+
*/
|
|
1905
|
+
function createFeatureTask(stories, featureName) {
|
|
1906
|
+
const featureId = generateWorkflowId();
|
|
1907
|
+
|
|
1908
|
+
return {
|
|
1909
|
+
id: featureId,
|
|
1910
|
+
title: featureName || `Feature: ${stories[0]?.title || 'Untitled'}`,
|
|
1911
|
+
type: 'parent',
|
|
1912
|
+
subTasks: stories.map((s, i) => generateSubTaskId(featureId, i + 1)),
|
|
1913
|
+
status: 'ready',
|
|
1914
|
+
priority: 'P2',
|
|
1915
|
+
dependencies: [],
|
|
1916
|
+
createdAt: now(),
|
|
1917
|
+
source: {
|
|
1918
|
+
type: 'transcript-digestion',
|
|
1919
|
+
digest_id: loadActiveDigest().session?.id,
|
|
1920
|
+
story_count: stories.length
|
|
1921
|
+
}
|
|
1922
|
+
};
|
|
1923
|
+
}
|
|
1924
|
+
|
|
1925
|
+
/**
|
|
1926
|
+
* Add tasks to ready.json
|
|
1927
|
+
*/
|
|
1928
|
+
function addTasksToReadyJson(tasks, options = {}) {
|
|
1929
|
+
const readyPath = path.join(process.cwd(), '.workflow', 'state', 'ready.json');
|
|
1930
|
+
|
|
1931
|
+
let readyData;
|
|
1932
|
+
try {
|
|
1933
|
+
readyData = JSON.parse(fs.readFileSync(readyPath, 'utf8'));
|
|
1934
|
+
} catch (err) {
|
|
1935
|
+
readyData = {
|
|
1936
|
+
lastUpdated: now(),
|
|
1937
|
+
ready: [],
|
|
1938
|
+
inProgress: [],
|
|
1939
|
+
blocked: [],
|
|
1940
|
+
recentlyCompleted: []
|
|
1941
|
+
};
|
|
1942
|
+
}
|
|
1943
|
+
|
|
1944
|
+
// Check for duplicates by source story_id
|
|
1945
|
+
const existingStoryIds = new Set(
|
|
1946
|
+
readyData.ready
|
|
1947
|
+
.filter(t => t.source?.type === 'transcript-digestion')
|
|
1948
|
+
.map(t => t.source?.story_id)
|
|
1949
|
+
);
|
|
1950
|
+
|
|
1951
|
+
const newTasks = tasks.filter(t => !existingStoryIds.has(t.source?.story_id));
|
|
1952
|
+
|
|
1953
|
+
if (newTasks.length === 0) {
|
|
1954
|
+
return { error: 'All tasks already exist in ready.json', skipped: tasks.length };
|
|
1955
|
+
}
|
|
1956
|
+
|
|
1957
|
+
// Add new tasks
|
|
1958
|
+
readyData.ready.push(...newTasks);
|
|
1959
|
+
readyData.lastUpdated = now();
|
|
1960
|
+
|
|
1961
|
+
fs.writeFileSync(readyPath, JSON.stringify(readyData, null, 2));
|
|
1962
|
+
|
|
1963
|
+
return {
|
|
1964
|
+
success: true,
|
|
1965
|
+
added: newTasks.length,
|
|
1966
|
+
skipped: tasks.length - newTasks.length,
|
|
1967
|
+
total_ready: readyData.ready.length
|
|
1968
|
+
};
|
|
1969
|
+
}
|
|
1970
|
+
|
|
1971
|
+
/**
|
|
1972
|
+
* Format a task as markdown file
|
|
1973
|
+
*/
|
|
1974
|
+
function formatTaskAsMarkdown(task) {
|
|
1975
|
+
let md = `# ${task.id} ${task.title}\n\n`;
|
|
1976
|
+
|
|
1977
|
+
md += `## User Story\n`;
|
|
1978
|
+
md += `${task.description}\n\n`;
|
|
1979
|
+
|
|
1980
|
+
md += `## Acceptance Criteria\n\n`;
|
|
1981
|
+
for (const ac of task.acceptanceCriteria || []) {
|
|
1982
|
+
md += `### ${ac.id}: ${ac.scenario}\n`;
|
|
1983
|
+
md += `**Given** ${ac.given}\n`;
|
|
1984
|
+
md += `**When** ${ac.when}\n`;
|
|
1985
|
+
md += `**Then** ${ac.then}\n\n`;
|
|
1986
|
+
}
|
|
1987
|
+
|
|
1988
|
+
md += `## Metadata\n`;
|
|
1989
|
+
md += `- **Priority**: ${task.priority}\n`;
|
|
1990
|
+
md += `- **Coverage**: ${task.metadata?.coverage || 0}%\n`;
|
|
1991
|
+
md += `- **Criteria Count**: ${task.metadata?.criteria_count || 0}\n`;
|
|
1992
|
+
md += `- **Source**: Transcript Digestion (${task.source?.digest_id || 'unknown'})\n`;
|
|
1993
|
+
|
|
1994
|
+
return md;
|
|
1995
|
+
}
|
|
1996
|
+
|
|
1997
|
+
/**
|
|
1998
|
+
* Export story files to .workflow/changes/
|
|
1999
|
+
*/
|
|
2000
|
+
function exportStoryFiles(tasks, featureName = 'general') {
|
|
2001
|
+
const changesDir = path.join(process.cwd(), '.workflow', 'changes', featureName);
|
|
2002
|
+
fs.mkdirSync(changesDir, { recursive: true });
|
|
2003
|
+
|
|
2004
|
+
const exported = [];
|
|
2005
|
+
|
|
2006
|
+
for (const task of tasks) {
|
|
2007
|
+
const filename = `${task.id}.md`;
|
|
2008
|
+
const filepath = path.join(changesDir, filename);
|
|
2009
|
+
|
|
2010
|
+
const content = formatTaskAsMarkdown(task);
|
|
2011
|
+
fs.writeFileSync(filepath, content);
|
|
2012
|
+
|
|
2013
|
+
exported.push({ id: task.id, path: filepath });
|
|
2014
|
+
}
|
|
2015
|
+
|
|
2016
|
+
return { exported, directory: changesDir };
|
|
2017
|
+
}
|
|
2018
|
+
|
|
2019
|
+
/**
|
|
2020
|
+
* Preview what would be exported
|
|
2021
|
+
*/
|
|
2022
|
+
function previewExport() {
|
|
2023
|
+
const queue = loadQueue();
|
|
2024
|
+
if (!queue) {
|
|
2025
|
+
return { error: 'No presentation queue found' };
|
|
2026
|
+
}
|
|
2027
|
+
|
|
2028
|
+
const approved = queue.stories.filter(s => s.status === 'approved');
|
|
2029
|
+
const pending = queue.stories.filter(s => s.status === 'pending' || s.status === 'skipped');
|
|
2030
|
+
|
|
2031
|
+
const stories = approved.map(e => loadStory(e.id)).filter(Boolean);
|
|
2032
|
+
const validation = validateForExport(stories);
|
|
2033
|
+
|
|
2034
|
+
return {
|
|
2035
|
+
approved_count: approved.length,
|
|
2036
|
+
pending_count: pending.length,
|
|
2037
|
+
stories: approved.map(e => {
|
|
2038
|
+
const story = loadStory(e.id);
|
|
2039
|
+
return {
|
|
2040
|
+
id: e.id,
|
|
2041
|
+
title: e.title,
|
|
2042
|
+
priority: story ? mapPriority(story) : 'P2',
|
|
2043
|
+
criteria_count: story?.acceptance_criteria?.length || 0,
|
|
2044
|
+
coverage: story?.coverage?.coverage_percent || 0
|
|
2045
|
+
};
|
|
2046
|
+
}),
|
|
2047
|
+
validation,
|
|
2048
|
+
ready_to_export: validation.valid && approved.length > 0
|
|
2049
|
+
};
|
|
2050
|
+
}
|
|
2051
|
+
|
|
2052
|
+
/**
|
|
2053
|
+
* Finalize the digestion process and export to ready.json
|
|
2054
|
+
*/
|
|
2055
|
+
function finalizeDigestion(options = {}) {
|
|
2056
|
+
// 1. Check presentation status
|
|
2057
|
+
const queue = loadQueue();
|
|
2058
|
+
if (!queue) {
|
|
2059
|
+
return { error: 'No presentation queue found' };
|
|
2060
|
+
}
|
|
2061
|
+
|
|
2062
|
+
const pendingCount = queue.stories.filter(s =>
|
|
2063
|
+
s.status === 'pending' || s.status === 'skipped'
|
|
2064
|
+
).length;
|
|
2065
|
+
|
|
2066
|
+
if (pendingCount > 0 && !options.force) {
|
|
2067
|
+
return {
|
|
2068
|
+
error: `${pendingCount} stories not yet reviewed. Use --force to proceed anyway.`,
|
|
2069
|
+
pending: pendingCount
|
|
2070
|
+
};
|
|
2071
|
+
}
|
|
2072
|
+
|
|
2073
|
+
// 2. Export approved stories
|
|
2074
|
+
const exportResult = exportApprovedStories(options);
|
|
2075
|
+
if (exportResult.error) {
|
|
2076
|
+
return exportResult;
|
|
2077
|
+
}
|
|
2078
|
+
|
|
2079
|
+
// 3. Add to ready.json
|
|
2080
|
+
const addResult = addTasksToReadyJson(exportResult.tasks, options);
|
|
2081
|
+
if (addResult.error && addResult.skipped !== exportResult.tasks.length) {
|
|
2082
|
+
return addResult;
|
|
2083
|
+
}
|
|
2084
|
+
|
|
2085
|
+
// 4. Optionally export story files
|
|
2086
|
+
let fileExport = null;
|
|
2087
|
+
if (options.exportFiles) {
|
|
2088
|
+
fileExport = exportStoryFiles(exportResult.tasks, options.featureName || 'digest-export');
|
|
2089
|
+
}
|
|
2090
|
+
|
|
2091
|
+
// 5. Mark digest as complete
|
|
2092
|
+
const activeDigest = loadActiveDigest();
|
|
2093
|
+
activeDigest.session.status = 'completed';
|
|
2094
|
+
activeDigest.session.completed_at = now();
|
|
2095
|
+
activeDigest.session.exported = {
|
|
2096
|
+
task_count: addResult.added || 0,
|
|
2097
|
+
skipped_count: addResult.skipped || 0,
|
|
2098
|
+
timestamp: now()
|
|
2099
|
+
};
|
|
2100
|
+
saveActiveDigest(activeDigest);
|
|
2101
|
+
|
|
2102
|
+
return {
|
|
2103
|
+
success: true,
|
|
2104
|
+
approved_count: exportResult.summary.total_approved,
|
|
2105
|
+
tasks_added: addResult.added || 0,
|
|
2106
|
+
tasks_skipped: addResult.skipped || 0,
|
|
2107
|
+
files_exported: fileExport?.exported.length || 0,
|
|
2108
|
+
validation: exportResult.validation,
|
|
2109
|
+
digest_status: 'completed'
|
|
2110
|
+
};
|
|
2111
|
+
}
|
|
2112
|
+
|
|
2113
|
+
// ============================================================================
|
|
2114
|
+
// Module Exports
|
|
2115
|
+
// ============================================================================
|
|
2116
|
+
|
|
2117
|
+
module.exports = {
|
|
2118
|
+
// Initialization
|
|
2119
|
+
init,
|
|
2120
|
+
|
|
2121
|
+
// Story Generation (E3-S2)
|
|
2122
|
+
USER_TYPE_PATTERNS,
|
|
2123
|
+
SCENARIO_PATTERNS,
|
|
2124
|
+
generateStoryId,
|
|
2125
|
+
detectUserType,
|
|
2126
|
+
extractObject,
|
|
2127
|
+
generateScenarioName,
|
|
2128
|
+
extractActionFromText,
|
|
2129
|
+
extractOutcomeFromText,
|
|
2130
|
+
convertToGiven,
|
|
2131
|
+
extractGiven,
|
|
2132
|
+
extractWhen,
|
|
2133
|
+
extractThen,
|
|
2134
|
+
generateCriteriaFromClarification,
|
|
2135
|
+
buildTraceabilityMatrix,
|
|
2136
|
+
validateStoryCoverage,
|
|
2137
|
+
generateStoryFromTopic,
|
|
2138
|
+
generateAllStories,
|
|
2139
|
+
saveStory,
|
|
2140
|
+
loadStory,
|
|
2141
|
+
loadAllStories,
|
|
2142
|
+
formatStoryAsMarkdown,
|
|
2143
|
+
|
|
2144
|
+
// Story Presentation Queue
|
|
2145
|
+
loadQueue,
|
|
2146
|
+
saveQueue,
|
|
2147
|
+
initializePresentation,
|
|
2148
|
+
getPresentationStatus,
|
|
2149
|
+
getNextStory,
|
|
2150
|
+
getCurrentStory,
|
|
2151
|
+
approveCurrentStory,
|
|
2152
|
+
rejectCurrentStory,
|
|
2153
|
+
skipCurrentStory,
|
|
2154
|
+
formatStorySummary,
|
|
2155
|
+
formatActionsPrompt,
|
|
2156
|
+
getCompletionSummary,
|
|
2157
|
+
resetPresentation,
|
|
2158
|
+
|
|
2159
|
+
// Story Editing
|
|
2160
|
+
generateEditSessionId,
|
|
2161
|
+
generateChangeId,
|
|
2162
|
+
loadEditSessions,
|
|
2163
|
+
saveEditSessions,
|
|
2164
|
+
startEditSession,
|
|
2165
|
+
getActiveEditSession,
|
|
2166
|
+
recordChange,
|
|
2167
|
+
editUserStory,
|
|
2168
|
+
editCriterion,
|
|
2169
|
+
addCriterion,
|
|
2170
|
+
removeCriterion,
|
|
2171
|
+
validateEditedStory,
|
|
2172
|
+
recalculateCoverage,
|
|
2173
|
+
updateQueueAfterEdit,
|
|
2174
|
+
commitEditSession,
|
|
2175
|
+
cancelEditSession,
|
|
2176
|
+
getEditChanges,
|
|
2177
|
+
getEditHistory,
|
|
2178
|
+
listEditableStories,
|
|
2179
|
+
|
|
2180
|
+
// Workflow Export
|
|
2181
|
+
generateWorkflowId,
|
|
2182
|
+
generateSubTaskId,
|
|
2183
|
+
mapPriority,
|
|
2184
|
+
formatUserStoryDescription,
|
|
2185
|
+
convertStoryToTask,
|
|
2186
|
+
validateForExport,
|
|
2187
|
+
exportApprovedStories,
|
|
2188
|
+
createFeatureTask,
|
|
2189
|
+
addTasksToReadyJson,
|
|
2190
|
+
formatTaskAsMarkdown,
|
|
2191
|
+
exportStoryFiles,
|
|
2192
|
+
previewExport,
|
|
2193
|
+
finalizeDigestion
|
|
2194
|
+
};
|