icoa-cli 2.19.51 → 2.19.53
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 +40 -10
- 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
|
@@ -413,19 +413,43 @@ export async function startRepl(program, resumeMode) {
|
|
|
413
413
|
realExit(0);
|
|
414
414
|
return;
|
|
415
415
|
}
|
|
416
|
-
// "back" — return to main menu
|
|
416
|
+
// "back" — return to main menu.
|
|
417
|
+
// Real exam: show "Exam paused", preserve state (server timer is ticking).
|
|
418
|
+
// Active demo: show pause message, keep state so `exam q N` can resume.
|
|
419
|
+
// Stale demo: auto-clear and show menu (demo from a previous session the
|
|
420
|
+
// user long abandoned — hanging state makes the menu lie).
|
|
421
|
+
// Nothing: show selection menu.
|
|
422
|
+
// A demo is "active" if `startedAt` is within the last 30 minutes. That window
|
|
423
|
+
// covers an intentional "back to check something and come right back" case but
|
|
424
|
+
// clears anything left over from a prior session.
|
|
417
425
|
if (input === 'back') {
|
|
418
426
|
const state = getExamState();
|
|
419
|
-
|
|
427
|
+
const isRealExam = state && state.session.examId !== 'demo-free';
|
|
428
|
+
const isActiveDemo = state && state.session.examId === 'demo-free' && (() => {
|
|
429
|
+
const started = new Date(state.session.startedAt || 0).getTime();
|
|
430
|
+
return Date.now() - started < 30 * 60 * 1000;
|
|
431
|
+
})();
|
|
432
|
+
if (isRealExam) {
|
|
420
433
|
console.log();
|
|
421
434
|
console.log(chalk.gray(' Exam paused. Your progress is saved.'));
|
|
422
435
|
console.log(chalk.white(' Resume: exam q 1') + chalk.gray(' · ') + chalk.white('exam review') + chalk.gray(' · ') + chalk.white('exam submit'));
|
|
423
436
|
console.log();
|
|
424
437
|
}
|
|
438
|
+
else if (isActiveDemo) {
|
|
439
|
+
const answered = Object.keys(state.answers).length;
|
|
440
|
+
const total = state.session.questionCount;
|
|
441
|
+
console.log();
|
|
442
|
+
console.log(chalk.gray(` Demo paused (${answered}/${total} answered). Resume with: `) + chalk.white(`exam q 1`));
|
|
443
|
+
console.log(chalk.gray(' Or type ') + chalk.white('demo') + chalk.gray(' to restart.'));
|
|
444
|
+
console.log();
|
|
445
|
+
}
|
|
425
446
|
else {
|
|
426
|
-
//
|
|
427
|
-
//
|
|
428
|
-
|
|
447
|
+
// Stale demo state from a past session — clear it so the menu reflects
|
|
448
|
+
// reality. Demo is free-practice, user can always restart.
|
|
449
|
+
if (state && state.session.examId === 'demo-free') {
|
|
450
|
+
const { clearExamState } = await import('./lib/exam-state.js');
|
|
451
|
+
clearExamState('demo-free');
|
|
452
|
+
}
|
|
429
453
|
const cfg = getConfig();
|
|
430
454
|
fetch('https://practice.icoa2026.au/api/icoa/demo-stats', {
|
|
431
455
|
method: 'POST',
|
|
@@ -437,8 +461,6 @@ export async function startRepl(program, resumeMode) {
|
|
|
437
461
|
}),
|
|
438
462
|
signal: AbortSignal.timeout(5000),
|
|
439
463
|
}).catch(() => { });
|
|
440
|
-
// Show the National Selection menu so the user always knows what
|
|
441
|
-
// commands are available (demo / exam setup / exam <token>).
|
|
442
464
|
if (mode === 'selection') {
|
|
443
465
|
printSelectionMenu();
|
|
444
466
|
}
|
|
@@ -607,8 +629,8 @@ export async function startRepl(program, resumeMode) {
|
|
|
607
629
|
}
|
|
608
630
|
const cmd = input.split(/\s+/)[0].toLowerCase();
|
|
609
631
|
// ─── Mode-based command filtering ───
|
|
610
|
-
const selectionCommands = ['exam', 'demo', 'retry', 'nations', 'next', 'prev', 'continue', 'setup', 'lang', 'ref', 'ai4ctf', 'ctf4ai'];
|
|
611
|
-
const organizerCommands = ['join', 'exam', 'demo', 'retry', 'next', 'prev', 'logout', 'setup', 'lang', 'ref', 'ctf'];
|
|
632
|
+
const selectionCommands = ['exam', 'demo', 'retry', 'nations', 'next', 'prev', 'continue', 'setup', 'lang', 'ref', 'ai4ctf', 'ctf4ai', 'mark', 'unmark', 'review', 'submit'];
|
|
633
|
+
const organizerCommands = ['join', 'exam', 'demo', 'retry', 'next', 'prev', 'logout', 'setup', 'lang', 'ref', 'ctf', 'mark', 'unmark', 'review', 'submit'];
|
|
612
634
|
if (mode === 'selection' && !selectionCommands.includes(cmd)) {
|
|
613
635
|
console.log(chalk.gray(' Not available in Selection mode.'));
|
|
614
636
|
if (examState) {
|
|
@@ -715,7 +737,12 @@ export async function startRepl(program, resumeMode) {
|
|
|
715
737
|
return;
|
|
716
738
|
}
|
|
717
739
|
processing = true;
|
|
718
|
-
|
|
740
|
+
// During an exam, `submit` must mean "submit my exam", not "submit a CTF
|
|
741
|
+
// flag". The `mapCommand` shortcut table always points `submit` at CTF,
|
|
742
|
+
// which silently fails in Selection mode and leaves users stuck. Route
|
|
743
|
+
// to exam submit here instead.
|
|
744
|
+
const submitInExam = examState && (input.toLowerCase() === 'submit');
|
|
745
|
+
const args = submitInExam ? ['exam', 'submit'] : mapCommand(input);
|
|
719
746
|
// Pause REPL readline for commands that read stdin (join)
|
|
720
747
|
const needsPause = args[0] === 'ctf' && args[1] === 'join';
|
|
721
748
|
if (needsPause)
|
|
@@ -792,6 +819,9 @@ function mapCommand(input) {
|
|
|
792
819
|
'nations': ['exam', 'nations'],
|
|
793
820
|
'next': ['exam', 'next'],
|
|
794
821
|
'prev': ['exam', 'prev'],
|
|
822
|
+
'mark': ['exam', 'mark', ...rest],
|
|
823
|
+
'unmark': ['exam', 'unmark', ...rest],
|
|
824
|
+
'review': ['exam', 'review'],
|
|
795
825
|
'logout': ['ctf', 'logout'],
|
|
796
826
|
'join': ['ctf', 'join', ...rest],
|
|
797
827
|
'activate': ['ctf', 'activate', ...rest],
|
package/dist/types/index.d.ts
CHANGED