icoa-cli 2.19.50 → 2.19.52

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.
@@ -172,6 +172,115 @@ function printTimeRemaining() {
172
172
  }
173
173
  }
174
174
  }
175
+ // One-shot urgent warnings: show the first time we cross a threshold, then
176
+ // never again in the same session. Tracked on ExamState so it survives REPL
177
+ // restarts and resume. Called from printQuestion so it fires naturally as the
178
+ // user navigates (no background timer needed).
179
+ function printTimeWarningIfNeeded(state) {
180
+ const deadline = getExamDeadline();
181
+ if (!deadline)
182
+ return;
183
+ const remainingMs = deadline.getTime() - Date.now();
184
+ if (remainingMs <= 0)
185
+ return;
186
+ const minutes = remainingMs / 60000;
187
+ const shown = new Set(state.shownWarnings || []);
188
+ let warn = null;
189
+ if (minutes <= 1 && !shown.has('t1')) {
190
+ warn = {
191
+ key: 't1',
192
+ text: [
193
+ chalk.red.bold(' ⚠ LESS THAN 1 MINUTE LEFT'),
194
+ chalk.red(' Submit now with: ') + chalk.bold('exam submit'),
195
+ ],
196
+ };
197
+ }
198
+ else if (minutes <= 5 && !shown.has('t5')) {
199
+ warn = {
200
+ key: 't5',
201
+ text: [
202
+ chalk.red.bold(' ⚠ 5 minutes remaining'),
203
+ chalk.yellow(' Wrap up and submit: ') + chalk.bold('exam submit'),
204
+ ],
205
+ };
206
+ }
207
+ else if (minutes <= 10 && !shown.has('t10')) {
208
+ warn = {
209
+ key: 't10',
210
+ text: [
211
+ chalk.yellow.bold(' ⏰ 10 minutes remaining'),
212
+ chalk.gray(' Review unanswered questions: ') + chalk.white('exam review'),
213
+ ],
214
+ };
215
+ }
216
+ if (!warn)
217
+ return;
218
+ console.log();
219
+ console.log(chalk.red(' ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'));
220
+ for (const line of warn.text)
221
+ console.log(line);
222
+ console.log(chalk.red(' ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'));
223
+ shown.add(warn.key);
224
+ state.shownWarnings = Array.from(shown);
225
+ saveExamState(state);
226
+ }
227
+ // Pacing hints at Q10 (time check) and Q30 (section complete). Real exam only.
228
+ // Keyed by question number so they fire once per exam on any render.
229
+ function printPacingHint(state, currentQ) {
230
+ if (state.session.examId === 'demo-free')
231
+ return;
232
+ const shown = new Set(state.shownWarnings || []);
233
+ const total = Number(state.session.questionCount || 40);
234
+ const deadline = getExamDeadline();
235
+ // Q10: gentle time check (only if exam is 40+ questions, not 10 demo)
236
+ if (currentQ === 10 && total >= 40 && !shown.has('p10')) {
237
+ const remainingMin = deadline ? Math.max(0, Math.round((deadline.getTime() - Date.now()) / 60000)) : null;
238
+ console.log();
239
+ console.log(chalk.gray(' ┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄'));
240
+ console.log(chalk.cyan(' ⏱ Time check ') + chalk.gray(`— you're 1/4 through the exam`));
241
+ if (remainingMin !== null) {
242
+ console.log(chalk.gray(` ${remainingMin} minutes left. Keep the pace steady.`));
243
+ }
244
+ console.log(chalk.gray(' ┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄'));
245
+ shown.add('p10');
246
+ state.shownWarnings = Array.from(shown);
247
+ saveExamState(state);
248
+ }
249
+ // Q30: MCQ section complete milestone
250
+ if (currentQ === 30 && total >= 40 && !shown.has('p30')) {
251
+ const answered = Object.keys(state.answers || {}).length;
252
+ const mcqAnswered = Object.keys(state.answers || {}).filter((n) => Number(n) <= 30).length;
253
+ console.log();
254
+ console.log(chalk.green(' ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'));
255
+ console.log(chalk.bold.green(' ✓ MCQ section complete — well done!'));
256
+ console.log(chalk.gray(` ${mcqAnswered}/30 multiple-choice answered`));
257
+ console.log(chalk.white(' ↓ Practical section begins at Q31.'));
258
+ console.log(chalk.gray(' Budget ~2 min per practical question. You can come back with: next / prev.'));
259
+ console.log(chalk.green(' ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'));
260
+ shown.add('p30');
261
+ state.shownWarnings = Array.from(shown);
262
+ saveExamState(state);
263
+ }
264
+ }
265
+ // Bookmark / flag-for-review helpers
266
+ function isBookmarked(state, n) {
267
+ return Array.isArray(state.bookmarks) && state.bookmarks.includes(n);
268
+ }
269
+ function toggleBookmark(state, n) {
270
+ const arr = Array.isArray(state.bookmarks) ? [...state.bookmarks] : [];
271
+ const idx = arr.indexOf(n);
272
+ if (idx >= 0) {
273
+ arr.splice(idx, 1);
274
+ state.bookmarks = arr;
275
+ saveExamState(state);
276
+ return false;
277
+ }
278
+ arr.push(n);
279
+ arr.sort((a, b) => a - b);
280
+ state.bookmarks = arr;
281
+ saveExamState(state);
282
+ return true;
283
+ }
175
284
  function printHowToPlay() {
176
285
  console.log(chalk.gray(' ─────────────────────────────────────────'));
177
286
  console.log(chalk.white(` ${t('howToPlay')}`));
@@ -235,6 +344,9 @@ function printQuestion(q, answer) {
235
344
  const help = getHelpState(state);
236
345
  const eliminated = help.eliminated[q.number] || [];
237
346
  const isPractical = q.type === 'ai4ctf' || q.type === 'ctf4ai' || (q.options && !q.options.A && !q.options.B);
347
+ // Pacing hints (Q10 time check, Q30 section complete) — real exam only
348
+ if (state)
349
+ printPacingHint(state, q.number);
238
350
  // Show practical section intro once when first entering Q31+
239
351
  if (isPractical && !_practicalIntroShown && state?.session.examId !== 'demo-free') {
240
352
  _practicalIntroShown = true;
@@ -254,8 +366,15 @@ function printQuestion(q, answer) {
254
366
  console.log(chalk.cyan(' ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'));
255
367
  console.log();
256
368
  }
369
+ // Urgent countdown warnings (10 / 5 / 1 minutes remaining)
370
+ if (state)
371
+ printTimeWarningIfNeeded(state);
257
372
  // Progress bar
258
373
  printQuestionProgress(q.number, total, answered);
374
+ // Bookmark indicator — yellow ★ after the progress line if current q is marked
375
+ if (state && isBookmarked(state, q.number)) {
376
+ console.log(chalk.yellow(' ★ Marked for review'));
377
+ }
259
378
  // Easter egg (position calculated by percentage of total)
260
379
  const egg = getEasterEgg(q.number, total);
261
380
  if (egg) {
@@ -325,7 +444,9 @@ function printQuestion(q, answer) {
325
444
  }
326
445
  console.log(chalk.yellow(' next') + chalk.gray(' / ') + chalk.yellow('prev') + chalk.gray(` ${t('htpNav')}`));
327
446
  console.log(chalk.yellow(` exam q 1..${total}`) + chalk.gray(` ${t('htpJump')}`));
447
+ console.log(chalk.yellow(' mark') + chalk.gray(` ${t('htpMark')}`));
328
448
  console.log(chalk.yellow(' exam review') + chalk.gray(` ${t('htpReview')}`));
449
+ console.log(chalk.bold.yellow(' exam submit') + chalk.gray(` ${t('htpSubmit')}`));
329
450
  console.log(chalk.yellow(' back') + chalk.gray(` ${t('htpBack')}`));
330
451
  console.log(chalk.yellow(' lang') + chalk.gray(` ${t('htpLang')}`));
331
452
  console.log(chalk.gray(' ─────────────────────────────────────────'));
@@ -528,6 +649,65 @@ export function registerExamCommand(program) {
528
649
  console.log();
529
650
  }
530
651
  });
652
+ // ─── exam mark [n] ───
653
+ // Flag the current (or given) question for review later. Toggles on/off.
654
+ exam
655
+ .command('mark [n]')
656
+ .description('Flag a question to review later (toggles)')
657
+ .action((n) => {
658
+ logCommand(`exam mark ${n || ''}`);
659
+ const state = getExamState();
660
+ if (!state) {
661
+ printError('No exam in progress.');
662
+ return;
663
+ }
664
+ const total = state.questions.length;
665
+ const targetN = n ? parseInt(n, 10) : (state._lastQ || 1);
666
+ if (isNaN(targetN) || targetN < 1 || targetN > total) {
667
+ printError(`Invalid question number. Use 1..${total}.`);
668
+ return;
669
+ }
670
+ const nowMarked = toggleBookmark(state, targetN);
671
+ console.log();
672
+ if (nowMarked) {
673
+ console.log(chalk.yellow(` ★ Q${targetN} flagged for review.`));
674
+ }
675
+ else {
676
+ console.log(chalk.gray(` ☆ Q${targetN} unflagged.`));
677
+ }
678
+ const all = Array.isArray(state.bookmarks) ? state.bookmarks : [];
679
+ if (all.length > 0) {
680
+ console.log(chalk.gray(` Currently flagged: ${all.map((x) => `Q${x}`).join(', ')}`));
681
+ }
682
+ console.log(chalk.gray(' See all flagged questions in: ') + chalk.white('exam review'));
683
+ console.log();
684
+ });
685
+ // ─── exam unmark [n] ───
686
+ exam
687
+ .command('unmark [n]')
688
+ .description('Remove review flag from a question')
689
+ .action((n) => {
690
+ logCommand(`exam unmark ${n || ''}`);
691
+ const state = getExamState();
692
+ if (!state) {
693
+ printError('No exam in progress.');
694
+ return;
695
+ }
696
+ const total = state.questions.length;
697
+ const targetN = n ? parseInt(n, 10) : (state._lastQ || 1);
698
+ if (isNaN(targetN) || targetN < 1 || targetN > total) {
699
+ printError(`Invalid question number. Use 1..${total}.`);
700
+ return;
701
+ }
702
+ if (!isBookmarked(state, targetN)) {
703
+ console.log(chalk.gray(` Q${targetN} was not flagged.`));
704
+ return;
705
+ }
706
+ toggleBookmark(state, targetN);
707
+ console.log();
708
+ console.log(chalk.gray(` ☆ Q${targetN} unflagged.`));
709
+ console.log();
710
+ });
531
711
  // ─── exam next ───
532
712
  exam
533
713
  .command('next')
@@ -807,13 +987,27 @@ export function registerExamCommand(program) {
807
987
  console.log();
808
988
  const cols = 6;
809
989
  let line = ' ';
990
+ const marks = Array.isArray(state.bookmarks) ? state.bookmarks : [];
810
991
  for (const q of state.questions) {
811
992
  const ans = state.answers[q.number];
993
+ const isMarked = marks.includes(q.number);
994
+ const star = isMarked ? '★' : ' ';
812
995
  if (ans) {
813
- line += chalk.green(`Q${String(q.number).padStart(2)} [${ans}] `);
996
+ // Answered + marked → bold yellow; answered only → green; unanswered + marked → yellow; unanswered → dim yellow
997
+ if (isMarked) {
998
+ line += chalk.bold.yellow(`Q${String(q.number).padStart(2)}${star}[${ans}] `);
999
+ }
1000
+ else {
1001
+ line += chalk.green(`Q${String(q.number).padStart(2)} [${ans}] `);
1002
+ }
814
1003
  }
815
1004
  else {
816
- line += chalk.yellow(`Q${String(q.number).padStart(2)} [ ] `);
1005
+ if (isMarked) {
1006
+ line += chalk.bold.yellow(`Q${String(q.number).padStart(2)}${star}[ ] `);
1007
+ }
1008
+ else {
1009
+ line += chalk.yellow(`Q${String(q.number).padStart(2)} [ ] `);
1010
+ }
817
1011
  }
818
1012
  if (q.number % cols === 0) {
819
1013
  console.log(line);
@@ -834,16 +1028,26 @@ export function registerExamCommand(program) {
834
1028
  .join(', ');
835
1029
  printKeyValue('Unanswered', chalk.yellow(`${unanswered} (Q${missing})`));
836
1030
  }
1031
+ if (marks.length > 0) {
1032
+ printKeyValue('Flagged for review', chalk.bold.yellow(`★ ${marks.length}`) + chalk.gray(` (${marks.map((x) => `Q${x}`).join(', ')})`));
1033
+ }
837
1034
  console.log();
838
- console.log(chalk.gray(' Ready? Use "exam submit" to submit for grading.'));
1035
+ if (marks.length > 0) {
1036
+ console.log(chalk.gray(' Jump to a flagged question: ') + chalk.white(`exam q ${marks[0]}`));
1037
+ }
1038
+ console.log(chalk.gray(' Ready? Use ') + chalk.bold.cyan('exam submit') + chalk.gray(' to submit for grading.'));
839
1039
  });
840
- // ─── exam submit ───
1040
+ // ─── exam submit [confirm] ───
841
1041
  exam
842
- .command('submit')
843
- .description('Submit exam for grading')
844
- .action(async () => {
845
- logCommand('exam submit');
1042
+ .command('submit [confirmArg]')
1043
+ .description('Submit exam for grading (use: "exam submit confirm" for real exams)')
1044
+ .action(async (confirmArg) => {
1045
+ logCommand(`exam submit ${confirmArg || ''}`);
846
1046
  const state = getExamState();
1047
+ if (state && confirmArg === 'confirm') {
1048
+ state._submitConfirmed = true;
1049
+ saveExamState(state);
1050
+ }
847
1051
  if (!state) {
848
1052
  printError('No exam in progress.');
849
1053
  return;
@@ -865,16 +1069,46 @@ export function registerExamCommand(program) {
865
1069
  }
866
1070
  return;
867
1071
  }
868
- // Demo: submit directly. Real exam: show warning first.
1072
+ // Demo: submit directly. Real exam: require typed confirmation.
869
1073
  if (state.session.examId !== 'demo-free') {
870
- console.log();
871
- console.log(chalk.yellow(` Submitting ${answered}/${total} answers.`));
872
- if (unanswered > 0) {
873
- console.log(chalk.yellow(` ${unanswered} unanswered.`));
1074
+ const confirmedAlready = state._submitConfirmed === true;
1075
+ const deadline = getExamDeadline();
1076
+ const expired = !!(deadline && new Date() >= deadline);
1077
+ if (!confirmedAlready && !expired) {
1078
+ // First press of `exam submit` — show a clear preflight summary and
1079
+ // require the user to run `exam submit confirm` to proceed. Typing
1080
+ // out "confirm" is the deliberate action that prevents accidental
1081
+ // submissions while an exam is still in progress.
1082
+ console.log();
1083
+ console.log(chalk.red(' ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'));
1084
+ console.log(chalk.bold.red(' ⚠ FINAL SUBMISSION — please confirm'));
1085
+ console.log(chalk.red(' ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'));
1086
+ console.log();
1087
+ console.log(chalk.white(` You will submit ${chalk.bold.green(`${answered}/${total}`)} answers.`));
1088
+ if (unanswered > 0) {
1089
+ const missingList = state.questions
1090
+ .filter((q) => !state.answers[q.number])
1091
+ .map((q) => `Q${q.number}`)
1092
+ .join(', ');
1093
+ console.log(chalk.yellow(` ${unanswered} unanswered: ${missingList}`));
1094
+ }
1095
+ const marks = Array.isArray(state.bookmarks) ? state.bookmarks : [];
1096
+ if (marks.length > 0) {
1097
+ console.log(chalk.yellow(` ★ ${marks.length} still flagged for review: ${marks.map((x) => `Q${x}`).join(', ')}`));
1098
+ }
1099
+ console.log();
1100
+ console.log(chalk.white(' Answers are ') + chalk.bold.red('final') + chalk.white(' — you cannot change them after submit.'));
1101
+ console.log();
1102
+ console.log(chalk.bold.white(' To confirm: ') + chalk.bold.cyan('exam submit confirm'));
1103
+ console.log(chalk.white(' To go back: ') + chalk.cyan('exam review') + chalk.gray(' or ') + chalk.cyan('back'));
1104
+ console.log();
1105
+ return;
1106
+ }
1107
+ // Clear the flag so a subsequent re-entry requires re-confirmation.
1108
+ if (confirmedAlready) {
1109
+ delete state._submitConfirmed;
1110
+ saveExamState(state);
874
1111
  }
875
- console.log(chalk.gray(' You cannot change answers after this.'));
876
- console.log(chalk.gray(' Type "back" now to cancel, or wait...'));
877
- await sleep(2000);
878
1112
  }
879
1113
  console.log();
880
1114
  // Demo exam: grade locally
@@ -1054,6 +1288,10 @@ export function registerExamCommand(program) {
1054
1288
  answers: state.answers,
1055
1289
  interactions: state.interactions || [],
1056
1290
  aiUsage: state.aiUsage || { ai4ctf: 0, ctf4ai: 0 },
1291
+ // Flagged-for-review questions (display numbers). Server maps
1292
+ // these to original pool numbers for cross-session aggregation
1293
+ // so the exam center can spot high-flag-rate questions.
1294
+ bookmarks: state.bookmarks || [],
1057
1295
  }),
1058
1296
  signal: AbortSignal.timeout(15000),
1059
1297
  });
@@ -1074,12 +1312,97 @@ export function registerExamCommand(program) {
1074
1312
  await sleep(300);
1075
1313
  drawProgress(100, 'Complete!');
1076
1314
  console.log();
1315
+ // Capture stats from state BEFORE we clear it for the debrief display.
1316
+ const preSubmit = {
1317
+ answered: Object.keys(state.answers).length,
1318
+ total: state.session.questionCount,
1319
+ bookmarks: Array.isArray(state.bookmarks) ? state.bookmarks.length : 0,
1320
+ help: getHelpState(state),
1321
+ aiUsage: state.aiUsage || { ai4ctf: 0, ctf4ai: 0 },
1322
+ interactions: (state.interactions || []).length,
1323
+ durationMin: state.session.durationMinutes || 0,
1324
+ confirmedAt: state.session.confirmedAt || state.session.startedAt,
1325
+ };
1077
1326
  clearExamState();
1327
+ const elapsedSec = Math.max(0, Math.round((Date.now() - new Date(preSubmit.confirmedAt).getTime()) / 1000));
1328
+ const elapsedMin = Math.floor(elapsedSec / 60);
1329
+ const elapsedS = elapsedSec % 60;
1330
+ // ─── Student debrief — qualitative only ───
1331
+ // National Selection: student sees pass/fail and directional feedback.
1332
+ // Numeric scores go to the country's exam center who decides the real
1333
+ // selection bar. This respects national autonomy — each country picks
1334
+ // who advances based on their own criteria (top N, percentile, etc.).
1078
1335
  console.log();
1079
- printHeader('Exam Result');
1080
- printKeyValue('Score', chalk.bold(`${result.score}/${result.total}`));
1081
- printKeyValue('Percentage', chalk.bold(`${result.percentage}%`));
1082
- printKeyValue('Status', result.passed ? chalk.green.bold('PASSED') : chalk.red.bold('NOT PASSED'));
1336
+ console.log(chalk.cyan(' ═════════════════════════════════════════════'));
1337
+ console.log();
1338
+ console.log(chalk.bold.white(' ██╗ ██████╗ ██████╗ █████╗'));
1339
+ console.log(chalk.bold.white(' ██║██╔════╝██╔═══██╗██╔══██╗'));
1340
+ console.log(chalk.bold.white(' ██║██║ ██║ ██║███████║'));
1341
+ console.log(chalk.bold.white(' ██║██║ ██║ ██║██╔══██║'));
1342
+ console.log(chalk.bold.white(' ██║╚██████╗╚██████╔╝██║ ██║'));
1343
+ console.log(chalk.bold.white(' ╚═╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═╝'));
1344
+ console.log();
1345
+ const statusLine = result.passed
1346
+ ? chalk.green.bold(' ✓ Submission accepted — baseline met')
1347
+ : chalk.yellow.bold(' ○ Submission accepted');
1348
+ console.log(statusLine);
1349
+ console.log();
1350
+ console.log(chalk.white(' Your answers have been recorded and sent to your national'));
1351
+ console.log(chalk.white(' exam center. Final scoring and selection are decided by'));
1352
+ console.log(chalk.white(' the organizer in your country.'));
1353
+ console.log();
1354
+ console.log(chalk.cyan(' ─────────────────────────────────────────────'));
1355
+ console.log();
1356
+ // Your run — personal stats only, no score
1357
+ console.log(chalk.bold.white(' Your run'));
1358
+ const unanswered = preSubmit.total - preSubmit.answered;
1359
+ console.log(' ' + chalk.gray('Answered: ') + chalk.white(`${preSubmit.answered}/${preSubmit.total}`)
1360
+ + (unanswered > 0 ? chalk.gray(` · ${unanswered} skipped`) : ''));
1361
+ console.log(' ' + chalk.gray('Time used: ') + chalk.white(`${elapsedMin}m ${elapsedS}s`)
1362
+ + chalk.gray(` of ${preSubmit.durationMin}m total`));
1363
+ if (preSubmit.bookmarks > 0) {
1364
+ console.log(' ' + chalk.gray('Flagged: ') + chalk.yellow(`★ ${preSubmit.bookmarks}`));
1365
+ }
1366
+ console.log();
1367
+ // Qualitative category feedback: "stronger" vs "area to grow" based on
1368
+ // relative rank, not absolute percentages. Never reveals numbers.
1369
+ const catStats = result.categoryStats;
1370
+ if (catStats && Object.keys(catStats).length >= 2) {
1371
+ const ranked = Object.entries(catStats)
1372
+ .filter(([, s]) => s.total > 0)
1373
+ .map(([cat, s]) => ({ cat, pct: s.correct / s.total }))
1374
+ .sort((a, b) => b.pct - a.pct);
1375
+ const topCount = Math.min(2, Math.ceil(ranked.length / 3));
1376
+ const bottomCount = Math.min(2, Math.ceil(ranked.length / 3));
1377
+ const strong = ranked.slice(0, topCount).map((r) => r.cat);
1378
+ const grow = ranked.slice(-bottomCount).map((r) => r.cat);
1379
+ // Only show if there's actual variation (avoid "strong: X, grow: X")
1380
+ const overlap = strong.some((s) => grow.includes(s));
1381
+ if (!overlap) {
1382
+ console.log(chalk.bold.white(' Directional feedback'));
1383
+ console.log(' ' + chalk.green('Stronger here: ') + chalk.white(strong.join(', ')));
1384
+ console.log(' ' + chalk.yellow('Room to grow: ') + chalk.white(grow.join(', ')));
1385
+ console.log(chalk.gray(' (relative to other categories — numeric scores go to your exam center)'));
1386
+ console.log();
1387
+ }
1388
+ }
1389
+ // Assistance usage — personal, fine to show
1390
+ console.log(chalk.bold.white(' Assistance used'));
1391
+ console.log(' ' + chalk.gray('Help: ') + chalk.white(`${preSubmit.help.used}/${preSubmit.help.max}`));
1392
+ console.log(' ' + chalk.gray('AI tokens: ') + chalk.white(`${preSubmit.aiUsage.ai4ctf} AI4CTF`) + chalk.gray(' · ') + chalk.white(`${preSubmit.aiUsage.ctf4ai} CTF4AI`));
1393
+ console.log();
1394
+ console.log(chalk.cyan(' ─────────────────────────────────────────────'));
1395
+ console.log();
1396
+ // Next steps
1397
+ console.log(chalk.bold.white(' What next'));
1398
+ console.log(chalk.gray(' · Your national organizer will announce selection results.'));
1399
+ console.log(chalk.gray(' · Keep sharpening: ') + chalk.white('demo') + chalk.gray(' is free, unlimited practice.'));
1400
+ console.log(chalk.gray(' · Reference guides for 38 tools: ') + chalk.white('ref'));
1401
+ console.log();
1402
+ console.log(chalk.yellow(' ICOA 2026 · Sydney, Australia · Jun 27 – Jul 2'));
1403
+ console.log(chalk.cyan.underline(' https://icoa2026.au'));
1404
+ console.log();
1405
+ console.log(chalk.cyan(' ═════════════════════════════════════════════'));
1083
1406
  console.log();
1084
1407
  }
1085
1408
  catch (err) {
@@ -141,6 +141,8 @@ export declare const EN: {
141
141
  enterExam: string;
142
142
  htpJump: string;
143
143
  htpReview: string;
144
+ htpSubmit: string;
145
+ htpMark: string;
144
146
  nextLabel: string;
145
147
  reportRetryCta: string;
146
148
  reportRetryWrongN: string;
package/dist/lib/i18n.js CHANGED
@@ -155,6 +155,8 @@ export const EN = {
155
155
  // Exam help footer (v2.19.26)
156
156
  htpJump: 'jump to a specific question',
157
157
  htpReview: 'check progress',
158
+ htpSubmit: 'submit your answers for grading',
159
+ htpMark: 'flag this question to review later',
158
160
  // Stage 1 → Stage 2 handoff (v2.19.28)
159
161
  nextLabel: 'Next:',
160
162
  // Final report retry/back menu (v2.19.28)
package/dist/repl.js CHANGED
@@ -374,18 +374,38 @@ export async function startRepl(program, resumeMode) {
374
374
  }
375
375
  // Log ALL commands for audit trail
376
376
  logCommand(input);
377
- // Exitrecord, reset terminal colors, and quit
378
- if (input === 'exit' || input === 'quit' || input === 'q') {
379
- // During exam, warn and suggest back instead
377
+ // `exit`soft exit: acts like `back` and surfaces the menu. Only
378
+ // `quit` / `q` actually close the CLI. Rationale: in the demo flow the
379
+ // user bounces between main prompt and sub-flows (ai4ctf / ctf4ai /
380
+ // demo exam); typing `exit` at the main prompt to mean "leave the demo"
381
+ // is a very common mistake and should never nuke the whole session.
382
+ if (input === 'exit') {
380
383
  if (getExamState()) {
381
384
  console.log();
382
- console.log(chalk.yellow(' ⚠ "exit" will close ICOA CLI entirely.'));
383
- console.log(chalk.white(' To return to menu without quitting, type: ') + chalk.bold.cyan('back'));
384
- console.log(chalk.gray(' Your exam progress is auto-saved.'));
385
+ console.log(chalk.yellow(' ⚠ An exam is in progress.'));
386
+ console.log(chalk.white(' To return to menu without losing progress, type: ') + chalk.bold.cyan('back'));
387
+ console.log(chalk.white(' To fully close ICOA CLI, type: ') + chalk.bold.cyan('quit'));
388
+ console.log(chalk.gray(' Your progress is auto-saved either way.'));
385
389
  console.log();
386
390
  rl.prompt();
387
391
  return;
388
392
  }
393
+ console.log();
394
+ console.log(chalk.gray(' ') + chalk.white('exit') + chalk.gray(' returns to the main menu. To fully close ICOA CLI, type ') + chalk.bold.cyan('quit') + chalk.gray('.'));
395
+ if (mode === 'selection') {
396
+ printSelectionMenu();
397
+ }
398
+ rl.prompt();
399
+ return;
400
+ }
401
+ // Explicit quit — `quit` or `q` always closes the CLI.
402
+ if (input === 'quit' || input === 'q') {
403
+ if (getExamState()) {
404
+ console.log();
405
+ console.log(chalk.yellow(' ⚠ An exam is in progress — progress is auto-saved.'));
406
+ console.log(chalk.gray(' Closing anyway. Resume with: ') + chalk.white('icoa --resume'));
407
+ console.log();
408
+ }
389
409
  stopLogSync();
390
410
  recordExit();
391
411
  console.log(chalk.gray(' Session saved. Use ') + chalk.white('icoa --resume') + chalk.gray(' to continue.'));
@@ -587,8 +607,8 @@ export async function startRepl(program, resumeMode) {
587
607
  }
588
608
  const cmd = input.split(/\s+/)[0].toLowerCase();
589
609
  // ─── Mode-based command filtering ───
590
- const selectionCommands = ['exam', 'demo', 'retry', 'nations', 'next', 'prev', 'continue', 'setup', 'lang', 'ref', 'ai4ctf', 'ctf4ai'];
591
- const organizerCommands = ['join', 'exam', 'demo', 'retry', 'next', 'prev', 'logout', 'setup', 'lang', 'ref', 'ctf'];
610
+ const selectionCommands = ['exam', 'demo', 'retry', 'nations', 'next', 'prev', 'continue', 'setup', 'lang', 'ref', 'ai4ctf', 'ctf4ai', 'mark', 'unmark', 'review', 'submit'];
611
+ const organizerCommands = ['join', 'exam', 'demo', 'retry', 'next', 'prev', 'logout', 'setup', 'lang', 'ref', 'ctf', 'mark', 'unmark', 'review', 'submit'];
592
612
  if (mode === 'selection' && !selectionCommands.includes(cmd)) {
593
613
  console.log(chalk.gray(' Not available in Selection mode.'));
594
614
  if (examState) {
@@ -695,7 +715,12 @@ export async function startRepl(program, resumeMode) {
695
715
  return;
696
716
  }
697
717
  processing = true;
698
- const args = mapCommand(input);
718
+ // During an exam, `submit` must mean "submit my exam", not "submit a CTF
719
+ // flag". The `mapCommand` shortcut table always points `submit` at CTF,
720
+ // which silently fails in Selection mode and leaves users stuck. Route
721
+ // to exam submit here instead.
722
+ const submitInExam = examState && (input.toLowerCase() === 'submit');
723
+ const args = submitInExam ? ['exam', 'submit'] : mapCommand(input);
699
724
  // Pause REPL readline for commands that read stdin (join)
700
725
  const needsPause = args[0] === 'ctf' && args[1] === 'join';
701
726
  if (needsPause)
@@ -772,6 +797,9 @@ function mapCommand(input) {
772
797
  'nations': ['exam', 'nations'],
773
798
  'next': ['exam', 'next'],
774
799
  'prev': ['exam', 'prev'],
800
+ 'mark': ['exam', 'mark', ...rest],
801
+ 'unmark': ['exam', 'unmark', ...rest],
802
+ 'review': ['exam', 'review'],
775
803
  'logout': ['ctf', 'logout'],
776
804
  'join': ['ctf', 'join', ...rest],
777
805
  'activate': ['ctf', 'activate', ...rest],
@@ -184,6 +184,8 @@ export interface ExamState {
184
184
  ai4ctf: number;
185
185
  ctf4ai: number;
186
186
  };
187
+ bookmarks?: number[];
188
+ shownWarnings?: string[];
187
189
  }
188
190
  export interface ExamResult {
189
191
  examId: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "icoa-cli",
3
- "version": "2.19.50",
3
+ "version": "2.19.52",
4
4
  "description": "ICOA CLI — The world's first CLI-native CTF competition terminal",
5
5
  "type": "module",
6
6
  "bin": {