@codewalla_india/openspec 1.3.2 → 1.3.3

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.
@@ -7,7 +7,8 @@
7
7
  *
8
8
  * @module comprehension-quiz/quiz-executor
9
9
  */
10
- import type { Question, QuizResult } from './types.js';
10
+ import type { QuizResult } from './types.js';
11
+ import type { QuizQuestionStream } from './question-generator.js';
11
12
  /**
12
13
  * Quiz executor class
13
14
  *
@@ -17,54 +18,58 @@ import type { Question, QuizResult } from './types.js';
17
18
  */
18
19
  export declare class QuizExecutor {
19
20
  private state;
20
- private currentQuestionIndex;
21
- private questions;
22
- private answers;
21
+ private totalQuestions;
22
+ private correctCount;
23
23
  private questionDisplayedAt;
24
- private answerReceivedAt;
25
24
  private changeName;
26
25
  private attemptNumber;
26
+ /** Minimum ms the question must be visible before ANY input is accepted (FR-4, NFR-4). */
27
+ private static readonly DISPLAY_LOCK_MS;
28
+ /** Minimum ms between question display and a valid answer (FR-9). */
29
+ private static readonly MIN_ANSWER_MS;
27
30
  /**
28
- * Execute quiz with given questions
31
+ * Execute quiz using an on-demand question stream.
29
32
  *
30
- * @param questions - Array of questions to ask
33
+ * Questions are fetched one at a time from the stream — the next question is
34
+ * NOT fetched until the user has answered the current one. This means at most
35
+ * one Question object is in memory at any point, and its correctOption never
36
+ * appears in stdout.
37
+ *
38
+ * @param stream - QuizQuestionStream supplying one question per call
31
39
  * @param changeName - Name of the change being tested
32
40
  * @returns Quiz result with score and pass/fail status
33
41
  */
34
- executeQuiz(questions: Question[], changeName: string): Promise<QuizResult>;
35
- /**
36
- * Transition to a new state
37
- *
38
- * @param newState - New state to transition to
39
- */
42
+ executeQuiz(stream: QuizQuestionStream, changeName: string): Promise<QuizResult>;
40
43
  private transitionTo;
41
44
  /**
42
- * Ask a question to the user
43
- *
44
- * @param questionIndex - Index of the question to ask
45
+ * Display a question to the user.
46
+ * Records the display timestamp for timing enforcement.
47
+ * correctOption is NOT printed it stays in process memory only.
45
48
  */
46
- private askQuestion;
49
+ private displayQuestion;
47
50
  /**
48
- * Wait for user answer with timing constraints
51
+ * Collect a valid answer from stdin, looping until one is received.
49
52
  *
50
- * Enforces a minimum 2-second wait time after question display before
51
- * accepting input to prevent automated answering.
53
+ * Enforces:
54
+ * - 4-second display lock: any input before 4s is silently discarded (FR-4 / NFR-4)
55
+ * - Batch input rejection: multiple newline-separated values in one write → re-prompt
56
+ * - Skip/show-all rejection: any non-option text → "Answer this question to continue."
57
+ * - 2-second minimum read time: answers faster than 2s after display → re-prompt (FR-9)
52
58
  *
53
- * @returns User's answer (1, 2, or 3), or -1 if invalid/too early
59
+ * @returns The user's selected option number (1, 2, or 3)
54
60
  */
55
- private waitForAnswer;
61
+ private collectValidAnswer;
56
62
  /**
57
- * Show feedback on the answer
58
- *
59
- * @param questionIndex - Index of the question
60
- * @param answer - User's answer
63
+ * Read a single line from stdin without echoing the prompt.
64
+ * Uses raw mode to capture the full write in one shot so batch detection works.
61
65
  */
62
- private showFeedback;
66
+ private readLine;
63
67
  /**
64
- * Calculate quiz result
65
- *
66
- * @returns Quiz result with score and pass/fail status
68
+ * Show pass/fail feedback for a single answer.
69
+ * Does NOT reveal the correctOption number to stdout if the user was correct —
70
+ * only shows "Correct" or "Incorrect" with the correct option disclosed on wrong answers.
67
71
  */
68
- private calculateResult;
72
+ private showFeedback;
73
+ private sleep;
69
74
  }
70
75
  //# sourceMappingURL=quiz-executor.d.ts.map
@@ -19,147 +19,163 @@ import { getPassRecord } from './pass-record.js';
19
19
  */
20
20
  export class QuizExecutor {
21
21
  state = 'START';
22
- currentQuestionIndex = 0;
23
- questions = [];
24
- answers = [];
22
+ totalQuestions = 0;
23
+ correctCount = 0;
25
24
  questionDisplayedAt = 0;
26
- answerReceivedAt = 0;
27
25
  changeName = '';
28
26
  attemptNumber = 1;
27
+ /** Minimum ms the question must be visible before ANY input is accepted (FR-4, NFR-4). */
28
+ static DISPLAY_LOCK_MS = 4000;
29
+ /** Minimum ms between question display and a valid answer (FR-9). */
30
+ static MIN_ANSWER_MS = 2000;
29
31
  /**
30
- * Execute quiz with given questions
32
+ * Execute quiz using an on-demand question stream.
31
33
  *
32
- * @param questions - Array of questions to ask
34
+ * Questions are fetched one at a time from the stream — the next question is
35
+ * NOT fetched until the user has answered the current one. This means at most
36
+ * one Question object is in memory at any point, and its correctOption never
37
+ * appears in stdout.
38
+ *
39
+ * @param stream - QuizQuestionStream supplying one question per call
33
40
  * @param changeName - Name of the change being tested
34
41
  * @returns Quiz result with score and pass/fail status
35
42
  */
36
- async executeQuiz(questions, changeName) {
37
- this.questions = questions;
43
+ async executeQuiz(stream, changeName) {
38
44
  this.changeName = changeName;
39
- this.currentQuestionIndex = 0;
40
- this.answers = [];
45
+ this.totalQuestions = stream.total;
46
+ this.correctCount = 0;
41
47
  this.state = 'START';
42
- // Track attempt number from existing pass record
43
48
  const existingRecord = await getPassRecord(changeName);
44
49
  this.attemptNumber = existingRecord ? existingRecord.attemptCount + 1 : 1;
45
- // Track attempt start
46
50
  await trackComprehensionAttempt(changeName, this.attemptNumber);
47
- // Start the quiz
48
- await this.transitionTo('WAITING_FOR_QUESTION_1');
49
- for (let i = 0; i < questions.length; i++) {
50
- await this.askQuestion(i);
51
- const answer = await this.waitForAnswer();
52
- this.answers.push(answer);
53
- await this.showFeedback(i, answer);
54
- if (i < questions.length - 1) {
55
- await this.transitionTo(`WAITING_FOR_QUESTION_${i + 2}`);
51
+ let questionIndex = 0;
52
+ while (stream.hasNext) {
53
+ await this.transitionTo(`WAITING_FOR_QUESTION_${questionIndex + 1}`);
54
+ // Fetch exactly one question — only now, not before
55
+ const question = await stream.next();
56
+ if (!question)
57
+ break;
58
+ this.displayQuestion(question, questionIndex);
59
+ // Loop until a valid answer is received (re-prompt on any invalid/early/batch/skip input)
60
+ const userAnswer = await this.collectValidAnswer(question);
61
+ await this.transitionTo('ACCEPTING_ANSWER');
62
+ if (userAnswer === question.correctOption + 1) {
63
+ this.correctCount += 1;
56
64
  }
65
+ await this.transitionTo('FEEDBACK');
66
+ this.showFeedback(question, userAnswer);
67
+ questionIndex += 1;
57
68
  }
58
69
  await this.transitionTo('DONE');
59
- const result = this.calculateResult();
60
- // Track completion
61
- await trackComprehensionCompletion(changeName, result.scorePercent, result.passed ? 'pass' : 'fail', this.attemptNumber);
62
- // If passed, emit apply_ready milestone
63
- if (result.passed) {
70
+ const scorePercent = this.totalQuestions > 0
71
+ ? Math.round((this.correctCount / this.totalQuestions) * 100)
72
+ : 0;
73
+ const passed = scorePercent >= 80;
74
+ console.log(`\n${'─'.repeat(50)}`);
75
+ console.log(`Result: ${this.correctCount}/${this.totalQuestions} correct (${scorePercent}%)`);
76
+ console.log(passed ? '✓ PASS — apply is now unblocked.' : '✗ FAIL — retry to proceed.');
77
+ console.log('─'.repeat(50));
78
+ await trackComprehensionCompletion(changeName, scorePercent, passed ? 'pass' : 'fail', this.attemptNumber);
79
+ if (passed) {
64
80
  await trackApplyReady(changeName);
65
81
  }
66
- return result;
82
+ return {
83
+ correct: this.correctCount,
84
+ total: this.totalQuestions,
85
+ scorePercent,
86
+ passed,
87
+ attemptNumber: this.attemptNumber,
88
+ };
67
89
  }
68
- /**
69
- * Transition to a new state
70
- *
71
- * @param newState - New state to transition to
72
- */
73
90
  async transitionTo(newState) {
74
91
  this.state = newState;
75
92
  }
76
93
  /**
77
- * Ask a question to the user
78
- *
79
- * @param questionIndex - Index of the question to ask
94
+ * Display a question to the user.
95
+ * Records the display timestamp for timing enforcement.
96
+ * correctOption is NOT printed it stays in process memory only.
80
97
  */
81
- async askQuestion(questionIndex) {
82
- const question = this.questions[questionIndex];
83
- console.log(`\nQuestion ${questionIndex + 1}/${this.questions.length}:`);
98
+ displayQuestion(question, index) {
99
+ console.clear();
100
+ console.log(`Question ${index + 1} of ${this.totalQuestions}\n`);
84
101
  console.log(question.text);
85
102
  console.log('');
86
- console.log('1)', question.options[0]);
87
- console.log('2)', question.options[1]);
88
- console.log('3)', question.options[2]);
103
+ console.log(` 1) ${question.options[0]}`);
104
+ console.log(` 2) ${question.options[1]}`);
105
+ console.log(` 3) ${question.options[2]}`);
89
106
  console.log('');
90
107
  this.questionDisplayedAt = Date.now();
91
108
  }
92
109
  /**
93
- * Wait for user answer with timing constraints
110
+ * Collect a valid answer from stdin, looping until one is received.
94
111
  *
95
- * Enforces a minimum 2-second wait time after question display before
96
- * accepting input to prevent automated answering.
112
+ * Enforces:
113
+ * - 4-second display lock: any input before 4s is silently discarded (FR-4 / NFR-4)
114
+ * - Batch input rejection: multiple newline-separated values in one write → re-prompt
115
+ * - Skip/show-all rejection: any non-option text → "Answer this question to continue."
116
+ * - 2-second minimum read time: answers faster than 2s after display → re-prompt (FR-9)
97
117
  *
98
- * @returns User's answer (1, 2, or 3), or -1 if invalid/too early
118
+ * @returns The user's selected option number (1, 2, or 3)
99
119
  */
100
- async waitForAnswer() {
101
- const rl = readline.createInterface({
102
- input: process.stdin,
103
- output: process.stdout,
104
- });
105
- const answer = await new Promise((resolve) => {
106
- rl.question('', (answer) => {
120
+ async collectValidAnswer(question) {
121
+ while (true) {
122
+ const elapsed = Date.now() - this.questionDisplayedAt;
123
+ if (elapsed < QuizExecutor.DISPLAY_LOCK_MS) {
124
+ // Still within the 4-second display lock — wait out the remainder silently
125
+ await this.sleep(QuizExecutor.DISPLAY_LOCK_MS - elapsed);
126
+ }
127
+ const raw = await this.readLine();
128
+ const answerReceivedAt = Date.now();
129
+ // Reject batch input: more than one non-empty line in a single write
130
+ if (raw.includes('\n') && raw.split('\n').filter((l) => l.trim()).length > 1) {
131
+ console.log('Answer this question to continue.');
132
+ continue;
133
+ }
134
+ const trimmed = raw.trim();
135
+ // Reject empty, skip commands, show-all, or anything not "1" / "2" / "3"
136
+ if (trimmed === '' || !['1', '2', '3'].includes(trimmed)) {
137
+ console.log('Answer this question to continue.');
138
+ continue;
139
+ }
140
+ // Enforce minimum 2-second read time after question was displayed
141
+ const timeSinceDisplay = answerReceivedAt - this.questionDisplayedAt;
142
+ if (timeSinceDisplay < QuizExecutor.MIN_ANSWER_MS) {
143
+ console.log('Answer this question to continue.');
144
+ continue;
145
+ }
146
+ return parseInt(trimmed, 10);
147
+ }
148
+ }
149
+ /**
150
+ * Read a single line from stdin without echoing the prompt.
151
+ * Uses raw mode to capture the full write in one shot so batch detection works.
152
+ */
153
+ readLine() {
154
+ return new Promise((resolve) => {
155
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
156
+ rl.question('Enter 1, 2, or 3: ', (answer) => {
107
157
  rl.close();
108
- const trimmed = answer.trim();
109
- // Check if answer was submitted too early (less than 2 seconds after question display)
110
- const timeSinceDisplay = Date.now() - this.questionDisplayedAt;
111
- if (timeSinceDisplay < 2000) {
112
- console.log('Answer submitted too early. Please wait at least 2 seconds before answering.');
113
- resolve(-1);
114
- return;
115
- }
116
- // Validate input
117
- if (trimmed === '1' || trimmed === '2' || trimmed === '3') {
118
- resolve(parseInt(trimmed, 10));
119
- }
120
- else {
121
- console.log('Answer this question to continue.');
122
- resolve(-1);
123
- }
158
+ resolve(answer);
124
159
  });
125
160
  });
126
- return answer;
127
161
  }
128
162
  /**
129
- * Show feedback on the answer
130
- *
131
- * @param questionIndex - Index of the question
132
- * @param answer - User's answer
163
+ * Show pass/fail feedback for a single answer.
164
+ * Does NOT reveal the correctOption number to stdout if the user was correct —
165
+ * only shows "Correct" or "Incorrect" with the correct option disclosed on wrong answers.
133
166
  */
134
- async showFeedback(questionIndex, answer) {
135
- const question = this.questions[questionIndex];
136
- const isCorrect = answer === question.correctOption + 1;
167
+ showFeedback(question, userAnswer) {
168
+ const isCorrect = userAnswer === question.correctOption + 1;
137
169
  if (isCorrect) {
138
- console.log('✓ Correct');
170
+ console.log('\n✓ Correct\n');
139
171
  }
140
172
  else {
141
- console.log('✗ Incorrect');
142
- console.log(`The correct answer was ${question.correctOption + 1}`);
173
+ console.log('\n✗ Incorrect');
174
+ console.log(` The correct answer was option ${question.correctOption + 1}.\n`);
143
175
  }
144
- // Clear terminal screen
145
- console.clear();
146
176
  }
147
- /**
148
- * Calculate quiz result
149
- *
150
- * @returns Quiz result with score and pass/fail status
151
- */
152
- calculateResult() {
153
- const correct = this.answers.filter((answer, index) => answer === this.questions[index].correctOption + 1).length;
154
- const total = this.questions.length;
155
- const scorePercent = Math.round((correct / total) * 100);
156
- return {
157
- correct,
158
- total,
159
- scorePercent,
160
- passed: scorePercent >= 80,
161
- attemptNumber: 1, // TODO: Track attempt count
162
- };
177
+ sleep(ms) {
178
+ return new Promise((resolve) => setTimeout(resolve, ms));
163
179
  }
164
180
  }
165
181
  //# sourceMappingURL=quiz-executor.js.map
@@ -72,6 +72,28 @@ export interface LLMProvider {
72
72
  * @returns Array of generated questions
73
73
  */
74
74
  generateQuestions(artifactContent: string, questionCount: number, artifactType: 'proposal' | 'design' | 'specs' | 'plan' | 'tasks'): Promise<Question[]>;
75
+ /**
76
+ * Generate a single quiz question on-demand from artifact content.
77
+ *
78
+ * This method MUST be used during quiz execution to fetch one question at a time.
79
+ * The correct answer index is included in the returned Question object so the CLI
80
+ * process can validate the user's answer locally. The correct answer MUST NOT be
81
+ * included in any conversational context or stdout — it stays in process memory only.
82
+ *
83
+ * The LLM prompt used by implementors MUST:
84
+ * 1. Ask for exactly ONE question, not a list or array.
85
+ * 2. Return a JSON object with: text, options (3 strings), correctOption (0-2), artifactSource.
86
+ * 3. NEVER ask the LLM to explain the answer or include the answer text in any prose.
87
+ * 4. Use a system message that forbids the model from listing multiple questions.
88
+ *
89
+ * @param artifactContent - The content of the artifact to generate from
90
+ * @param artifactType - Type of artifact (proposal, design, specs, plan, tasks)
91
+ * @param questionIndex - 0-based index of this question in the quiz (for diversity)
92
+ * @param totalQuestions - Total number of questions in the quiz (for context)
93
+ * @param previousQuestionTexts - Texts of already-asked questions to avoid duplicates
94
+ * @returns A single generated question (text + options + correctOption + artifactSource)
95
+ */
96
+ generateSingleQuestion(artifactContent: string, artifactType: 'proposal' | 'design' | 'specs' | 'plan' | 'tasks', questionIndex: number, totalQuestions: number, previousQuestionTexts: string[]): Promise<Question>;
75
97
  /**
76
98
  * Validate provider configuration
77
99
  * @throws Error if configuration is invalid