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.
- package/dist/commands/exam.js +343 -20
- package/dist/lib/i18n.d.ts +2 -0
- package/dist/lib/i18n.js +2 -0
- package/dist/repl.js +37 -9
- package/dist/types/index.d.ts +2 -0
- package/package.json +1 -1
package/dist/commands/exam.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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:
|
|
1072
|
+
// Demo: submit directly. Real exam: require typed confirmation.
|
|
869
1073
|
if (state.session.examId !== 'demo-free') {
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
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
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
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) {
|
package/dist/lib/i18n.d.ts
CHANGED
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
|
-
//
|
|
378
|
-
|
|
379
|
-
|
|
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(' ⚠
|
|
383
|
-
console.log(chalk.white(' To return to menu without
|
|
384
|
-
console.log(chalk.
|
|
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
|
-
|
|
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],
|
package/dist/types/index.d.ts
CHANGED