@ngockhoale/ukit 3.4.11 → 3.4.12

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/CHANGELOG.md CHANGED
@@ -2,6 +2,51 @@
2
2
 
3
3
  All notable changes to UKit are documented here.
4
4
 
5
+ ## 3.4.12 - 2026-10-02
6
+
7
+ **Handoff Stop gate is session-scoped — a stale `RUN.md` no longer hijacks unrelated sessions.**
8
+ Before, any session in a repo was bounced with "CONTINUE IMMEDIATELY" whenever
9
+ `docs/AI_HANDOFF/RUN.md` carried a live `Phase:` (or a `done` whose ExitPredicate
10
+ failed, e.g. `git:clean` on a dirty tree) — even a fresh session opened only to plan.
11
+ `evaluateHandoffCursor` now takes the session `transcriptPath` and enforces the
12
+ gate only when that session itself drives the run, proven by STRUCTURED transcript
13
+ evidence (a real user turn with the `/ukit:handoff-fullstack` tag, a `Skill` call for
14
+ it, or a SessionStart attachment carrying the resume banner). Marker strings in tool
15
+ output, bash commands or source files do not count. An unreadable/oversized
16
+ transcript keeps the legacy block (a real run is never released by a lost read).
17
+ The omp bridge passes the transcript path too.
18
+
19
+ - `handoff-resume.sh`: `startup`/`clear` sessions get an informational note (ignore
20
+ unless asked; `/ukit:handoff-fullstack` to continue, `/ukit:handoff-clear` to
21
+ discard) instead of a resume order; `compact`/`resume` still resume automatically.
22
+ - New config `handoff.fullstack.stopGateScope`: `"session"` (default) | `"project"`
23
+ (legacy repo-wide gate).
24
+ - `reset-compact-pressure.sh`: staged-payload read is async (was a sync
25
+ `readFileSync` inside the hook deadline window; fixes BUG-C22-03 test).
26
+ - `async-lock.mjs`: an `EEXIST` from the lock's PARENT-directory mkdir (a regular file
27
+ squatting on the store path) is a hard error, not lock contention — it used to spin
28
+ out the whole acquisition budget and hang crash-path hooks (`reinject-context`,
29
+ `output-compression`) for ~15 s instead of failing fast with a systemMessage.
30
+ - `skill-router.sh`: restored a missing `};` (v3.4.11 syntax error that silently
31
+ disabled the whole router).
32
+ - Hook-deadline conventions: no sync-fs mention in `verification-guard.sh`/
33
+ `observability-emit.mjs`, explicit timeout on the codex probe's `spawnSync`.
34
+ - Test/doc hygiene: tests follow the cycle-91 inventory archive move; baseline
35
+ reconciliation pins the major.minor line instead of exact patch; decision-coverage
36
+ allowlist covers `deriveTypedDelegationAdvice`.
37
+
38
+ ## 3.4.11 - 2026-10-01
39
+
40
+ **Gate posture is answer-first; only explicit change orders mint work debt.**
41
+ The router now stamps `routingContext.planOnly` (new `isPlanOnlyRequest` —
42
+ "chỉ lên plan", "plan thôi", "just plan it", "plan … không implement") next to
43
+ `mutationOrder`. The completion gate's `no-mutation-order` valve covers both:
44
+ when a prompt ordered no mutation or asked only for a plan/answer, missing
45
+ `write-evidence` AND `verification-evidence` release loud instead of forcing
46
+ an Edit loop. A prompt that does order a change — or a turn that already
47
+ attempted an edit — keeps the normal gate. To implement a reviewed plan:
48
+ "implement", "sửa", "làm luôn", or `execute this plan`.
49
+
5
50
  ## 3.4.10 - 2026-10-01
6
51
 
7
52
  **sensitive-data-guard reads .env placeholders.** `classifySecretFile` now
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ngockhoale/ukit",
3
- "version": "3.4.11",
3
+ "version": "3.4.12",
4
4
  "description": "Install/update an index-first AI workspace for Claude Code, OpenAI Codex and omp (Oh My Pi).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -34,7 +34,7 @@ const out = [];
34
34
  const say = (line = '') => { out.push(line); console.log(line); };
35
35
 
36
36
  function commandOnPath(cmd) {
37
- const r = spawnSync('sh', ['-c', `command -v ${cmd}`], { encoding: 'utf8' });
37
+ const r = spawnSync('sh', ['-c', `command -v ${cmd}`], { encoding: 'utf8', timeout: 5000 });
38
38
  return r.status === 0 ? r.stdout.trim() : null;
39
39
  }
40
40
 
@@ -170,6 +170,20 @@ async function emitOrdinaryResume(payload, source) {
170
170
  const cursor = field('Cursor');
171
171
  const next = field('Next');
172
172
 
173
+ // A brand-new session (startup / clear) did not start this run and may be about
174
+ // something else entirely (e.g. planning). Tell it the run exists, but do NOT
175
+ // order a resume and do NOT use the resume marker the Stop gate treats as proof
176
+ // that the session owns the run. Resume stays automatic for compact / resume.
177
+ if (source === 'startup' || source === 'clear') {
178
+ process.stdout.write([
179
+ 'UKit note: docs/AI_HANDOFF/RUN.md has an unfinished handoff-fullstack run '
180
+ + `(Phase: ${phase}; Next: ${next || 'not recorded'}).`,
181
+ 'It belongs to an earlier session. Ignore it unless the user asks to continue it; their current request comes first.',
182
+ 'To pick it up the user can run /ukit:handoff-fullstack; to discard it, /ukit:handoff-clear.',
183
+ ].join('\n') + '\n');
184
+ process.exit(0);
185
+ }
186
+
173
187
  const out = [
174
188
  'UKIT HANDOFF RESUME — an unfinished handoff-fullstack run was found on disk.',
175
189
  ` Goal: ${goal || '(not recorded)'}`,
@@ -10,5 +10,9 @@
10
10
  // so the `execution.started` record lands before any slow .sh member can
11
11
  // exhaust the chain budget (a timeout further down would silently skip a
12
12
  // trailing emit step).
13
+ //
14
+ // Deadline contract: the chain runner passes `deadlineMs` into runHook and the
15
+ // emit module bounds every write by it (HOOK_DEADLINE_MS fallback), so this
16
+ // thin entry needs no watchdog of its own.
13
17
 
14
18
  export { runHook } from '../ukit/runtime/observability-emit.mjs';
@@ -195,9 +195,10 @@ async function withLock(lockPath, fn) {
195
195
  // this hook waiting on EOF.
196
196
  const inputFile = process.argv[2];
197
197
  (async () => {
198
- const payload = (() => {
198
+ // Async on purpose — a sync read on a stalled mount would park the deadline timer.
199
+ const payload = await (async () => {
199
200
  try {
200
- const raw = require("fs").readFileSync(inputFile, "utf8");
201
+ const raw = await fsp.readFile(inputFile, "utf8");
201
202
  return JSON.parse(raw || "{}");
202
203
  } catch {
203
204
  return {};
@@ -2873,6 +2873,7 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
2873
2873
  ...(typeof routingContext.planOnly === 'boolean'
2874
2874
  ? { planOnly: routingContext.planOnly }
2875
2875
  : {}),
2876
+ };
2876
2877
  }
2877
2878
 
2878
2879
  function compactHelpers({
@@ -433,7 +433,7 @@ const NEW_SOURCE_FILE_RE = /\.(jsx?|tsx?|mjs|cjs|vue|svelte|css|scss|sass|less|h
433
433
  // `git status --porcelain` positions: untracked entries start '??', index-staged
434
434
  // adds start 'A'. Both are "component-create" candidates for the wiring check.
435
435
  // W2-D06: async execFile — a wedged git (slow index lock, network fs) must not
436
- // park the event loop inside the armed deadline block the way execFileSync did.
436
+ // park the event loop inside the armed deadline block the way a synchronous exec did.
437
437
  // SIGKILL keeps the child bound hard even for a TERM-ignoring process.
438
438
  async function readGitChangedPaths(projectRoot) {
439
439
  return new Promise((resolve) => {
@@ -451,6 +451,7 @@ export async function withAsyncLock(filePath, { signal, deadlineMs = LOCK_MAX_SL
451
451
  // they get the fixed cleanup reserve instead of the acquisition slice.
452
452
  const releaseRetry = (op) => withTransientFsRetry(op, { deadlineMs: LOCK_RESERVE_MS });
453
453
 
454
+ let parentFailed = false;
454
455
  while (true) {
455
456
  // Abort wins over every other condition, checked once per iteration: an abort
456
457
  // landing mid-sweep/mid-op must still surface the typed aborted outcome — the
@@ -467,7 +468,9 @@ export async function withAsyncLock(filePath, { signal, deadlineMs = LOCK_MAX_SL
467
468
  try {
468
469
  // The lock parent must exist before the atomic acquire — a first-ever run in a
469
470
  // fresh project would otherwise fail mkdir with ENOENT.
471
+ parentFailed = true;
470
472
  await retry(() => fs.mkdir(path.dirname(lockPath), { recursive: true }));
473
+ parentFailed = false;
471
474
  await retry(() => fs.mkdir(lockPath)); // atomic acquire — EEXIST means another holder exists
472
475
  inProcessLockHolders.set(lockPath, ownerToken);
473
476
  try {
@@ -503,7 +506,10 @@ export async function withAsyncLock(filePath, { signal, deadlineMs = LOCK_MAX_SL
503
506
  if (signal?.aborted) {
504
507
  return { ok: false, reason: 'aborted', waitedMs: Date.now() - startedAt };
505
508
  }
506
- if (error?.code !== 'EEXIST') throw error;
509
+ // EEXIST from the PARENT mkdir means a regular file squats on the directory path —
510
+ // a broken store, not a lock holder. Waiting out the budget for it would burn the
511
+ // whole acquisition slice and then report 'busy'; surface the real error instead.
512
+ if (error?.code !== 'EEXIST' || parentFailed) throw error;
507
513
  }
508
514
 
509
515
  // Someone holds the lock. Reclaim it only after the stale threshold AND only when
@@ -201,6 +201,12 @@ export const HANDOFF_CURSOR_DEFAULTS = Object.freeze({
201
201
  // gate releases instead of trapping the session in an unstoppable loop — same
202
202
  // liveness shape as the ledger's noProgressCount breaker.
203
203
  maxStalledBlocks: 12,
204
+ // 'session' (default): the gate only bounces a stop when THIS session is the one
205
+ // driving the run (its transcript shows a handoff-fullstack invocation or a
206
+ // resume injection). A fresh session that merely shares the repo with a stale
207
+ // RUN.md — e.g. one opened just to plan something else — is never hijacked.
208
+ // 'project': legacy behaviour, any session in the repo is bounced.
209
+ scope: 'session',
204
210
  });
205
211
 
206
212
  // ─── Redaction ────────────────────────────────────────────────────────────
@@ -636,7 +642,17 @@ export async function evaluateStopReviewPolicy({ projectRoot, completionKind = n
636
642
  : 0;
637
643
 
638
644
  const ledgerModule = await loadLedgerReceiptModule();
639
- const appendDecisionReceipt = ledgerModule?.appendDecisionReceipt;
645
+ // The policy fires its calibration receipt without awaiting it; collect the promises so
646
+ // the receipt is on disk before this evaluator returns (a loaded host otherwise leaves
647
+ // the write racing the next reader).
648
+ const pendingReceipts = [];
649
+ const appendDecisionReceipt = typeof ledgerModule?.appendDecisionReceipt === 'function'
650
+ ? (...args) => {
651
+ const written = ledgerModule.appendDecisionReceipt(...args);
652
+ pendingReceipts.push(Promise.resolve(written).catch(() => {}));
653
+ return written;
654
+ }
655
+ : undefined;
640
656
 
641
657
  const decision = policyModule.evaluateReviewPolicy({
642
658
  signals: routeSummary.suspicionSignals ?? {},
@@ -648,6 +664,7 @@ export async function evaluateStopReviewPolicy({ projectRoot, completionKind = n
648
664
  projectRoot,
649
665
  appendDecisionReceipt,
650
666
  });
667
+ await Promise.all(pendingReceipts);
651
668
  if (!decision || typeof decision !== 'object') return null;
652
669
 
653
670
  // Persist the round counter + last verdict for the next stop (same atomic
@@ -686,6 +703,9 @@ async function loadHandoffGateConfig(projectRoot) {
686
703
  maxStalledBlocks: Number.isFinite(gate?.stopGateMaxStalledBlocks)
687
704
  ? Math.max(1, gate.stopGateMaxStalledBlocks)
688
705
  : HANDOFF_CURSOR_DEFAULTS.maxStalledBlocks,
706
+ scope: gate?.stopGateScope === 'project' || gate?.stopGateScope === 'session'
707
+ ? gate.stopGateScope
708
+ : HANDOFF_CURSOR_DEFAULTS.scope,
689
709
  };
690
710
  } catch {
691
711
  return { ...HANDOFF_CURSOR_DEFAULTS };
@@ -835,6 +855,81 @@ function formatPredicateState(result) {
835
855
  return result.terms.map((t) => `${t.term}=${t.pass ? 'pass' : `FAIL (${t.detail})`}`).join(', ');
836
856
  }
837
857
 
858
+ // ─── Session ownership (does THIS session drive the run?) ─────────────────
859
+ // Structured evidence that a session itself started or resumed a handoff-fullstack
860
+ // run. Raw-text matching is NOT enough: the marker strings appear in tool output,
861
+ // bash commands and source files any session may read, so only these shapes count:
862
+ // - a real user turn carrying the slash-command tag for handoff-fullstack
863
+ // - a Skill tool call naming handoff-fullstack
864
+ // - a SessionStart hook attachment carrying the resume injection
865
+ const HANDOFF_COMMAND_TAG = /<command-name>\/?(?:ukit:)?handoff-fullstack\b/;
866
+ const HANDOFF_RESUME_BANNER = 'UKIT HANDOFF RESUME \u2014 an unfinished handoff-fullstack run';
867
+ const OWNERSHIP_SCAN_BUDGET_MS = 1200;
868
+ const OWNERSHIP_SCAN_MAX_BYTES = 64 * 1024 * 1024;
869
+
870
+ function textOfUserTurn(content) {
871
+ if (typeof content === 'string') return content;
872
+ if (!Array.isArray(content)) return '';
873
+ // tool_result blocks are tool output, never the user's own words.
874
+ return content.filter((b) => b && b.type === 'text' && typeof b.text === 'string').map((b) => b.text).join('\n');
875
+ }
876
+
877
+ function entryShowsHandoffRun(entry) {
878
+ if (!entry || typeof entry !== 'object') return false;
879
+ if (entry.type === 'user' && entry.message && entry.message.role === 'user') {
880
+ return HANDOFF_COMMAND_TAG.test(textOfUserTurn(entry.message.content));
881
+ }
882
+ if (entry.type === 'assistant' && Array.isArray(entry.message?.content)) {
883
+ return entry.message.content.some((b) => b && b.type === 'tool_use' && b.name === 'Skill'
884
+ && /^(?:ukit:)?handoff-fullstack$/.test(String(b.input?.skill ?? '')));
885
+ }
886
+ if (entry.type === 'attachment' && entry.attachment && entry.attachment.hookEvent === 'SessionStart') {
887
+ const a = entry.attachment;
888
+ return [a.content, a.stdout, a.text].some((v) => typeof v === 'string' && v.includes(HANDOFF_RESUME_BANNER));
889
+ }
890
+ return false;
891
+ }
892
+
893
+ /**
894
+ * Stream the session transcript (JSONL) for structured handoff-run evidence.
895
+ * Returns true (found), false (whole transcript scanned, none found) or null
896
+ * (unknown: no path, unreadable, or scan budget/size cap hit). Callers treat
897
+ * null as "cannot prove this session is not the owner" and keep the legacy
898
+ * blocking behaviour — a real run must never be released by a lost read.
899
+ */
900
+ export async function transcriptShowsHandoffRun(transcriptPath, {
901
+ budgetMs = OWNERSHIP_SCAN_BUDGET_MS,
902
+ maxBytes = OWNERSHIP_SCAN_MAX_BYTES,
903
+ } = {}) {
904
+ if (typeof transcriptPath !== 'string' || !transcriptPath.trim()) return null;
905
+ let handle;
906
+ try {
907
+ handle = await fs.open(transcriptPath, 'r');
908
+ const deadline = Date.now() + budgetMs;
909
+ const chunk = Buffer.allocUnsafe(1024 * 1024);
910
+ let carry = '';
911
+ let total = 0;
912
+ const scanLine = (line) => {
913
+ // Cheap prefilter: skip JSON.parse for lines that cannot be evidence.
914
+ if (!line.includes('handoff-fullstack') && !line.includes('HANDOFF RESUME')) return false;
915
+ try { return entryShowsHandoffRun(JSON.parse(line)); } catch { return false; }
916
+ };
917
+ for (;;) {
918
+ const { bytesRead } = await handle.read(chunk, 0, chunk.length, null);
919
+ if (!bytesRead) return scanLine(carry);
920
+ total += bytesRead;
921
+ const lines = (carry + chunk.toString('utf8', 0, bytesRead)).split('\n');
922
+ carry = lines.pop() ?? '';
923
+ for (const line of lines) if (scanLine(line)) return true;
924
+ if (total >= maxBytes || Date.now() > deadline) return null;
925
+ }
926
+ } catch {
927
+ return null;
928
+ } finally {
929
+ try { await handle?.close(); } catch {}
930
+ }
931
+ }
932
+
838
933
  /**
839
934
  * Mutate `.ukit/storage/cache/stop-coordinator/state.json`'s `handoff` slot:
840
935
  * { signature, count }. Same signature as last time → count+1; a moved cursor
@@ -900,7 +995,7 @@ async function bumpHandoffStallCount({ projectRoot, signature, lockBudgetMs }) {
900
995
  *
901
996
  * Advisory lane: EVERY failure path returns { kind: 'none' }, never throws.
902
997
  */
903
- export async function evaluateHandoffCursor({ projectRoot, now = Date.now(), lockBudgetMs = LOCK_BUDGET_MS } = {}) {
998
+ export async function evaluateHandoffCursor({ projectRoot, now = Date.now(), lockBudgetMs = LOCK_BUDGET_MS, transcriptPath = null } = {}) {
904
999
  const gate = await loadHandoffGateConfig(projectRoot);
905
1000
  if (!gate.enabled) return { kind: 'none' };
906
1001
 
@@ -917,8 +1012,16 @@ export async function evaluateHandoffCursor({ projectRoot, now = Date.now(), loc
917
1012
  if (!phase || terminal === 'blocked') {
918
1013
  return { kind: 'none' };
919
1014
  }
1015
+ if (terminal === 'done' && !exitPredicate) return { kind: 'none' }; // legacy phase-only release
1016
+
1017
+ // Session scope: a run owned by another session (or abandoned) must not
1018
+ // hijack an unrelated session. Only a positive "scanned the whole transcript,
1019
+ // no handoff evidence" releases; unknown (null) keeps the block.
1020
+ if (gate.scope === 'session' && await transcriptShowsHandoffRun(transcriptPath) === false) {
1021
+ return { kind: 'none' };
1022
+ }
1023
+
920
1024
  if (terminal === 'done') {
921
- if (!exitPredicate) return { kind: 'none' }; // legacy phase-only release
922
1025
  let predicate;
923
1026
  try {
924
1027
  predicate = await evaluateExitPredicate(projectRoot, exitPredicate);
@@ -1064,7 +1167,12 @@ export async function runStopCoordinator({
1064
1167
  }
1065
1168
  let handoff = null;
1066
1169
  try {
1067
- handoff = await evaluateHandoffCursor({ projectRoot, now, lockBudgetMs });
1170
+ handoff = await evaluateHandoffCursor({
1171
+ projectRoot,
1172
+ now,
1173
+ lockBudgetMs,
1174
+ transcriptPath: typeof payload?.transcript_path === 'string' ? payload.transcript_path : null,
1175
+ });
1068
1176
  } catch (error) {
1069
1177
  failures.handoff = error?.message || String(error);
1070
1178
  process.stderr.write(`[ukit-stop-coordinator] handoff-cursor evaluator failed: ${failures.handoff}\n`);
@@ -1056,7 +1056,12 @@ export async function runSessionStop(
1056
1056
  let handoff = null;
1057
1057
  if (coordinator) {
1058
1058
  try {
1059
- handoff = await coordinator.evaluateHandoffCursor({ projectRoot, now, lockBudgetMs });
1059
+ handoff = await coordinator.evaluateHandoffCursor({
1060
+ projectRoot,
1061
+ now,
1062
+ lockBudgetMs,
1063
+ transcriptPath: typeof payload.transcript_path === 'string' ? payload.transcript_path : null,
1064
+ });
1060
1065
  } catch (error) {
1061
1066
  pi.logger?.warn?.(`[UKit] handoff-cursor evaluator failed (advisory lane): ${error?.message || error}`);
1062
1067
  }
@@ -143,6 +143,9 @@ chạy **đến khi không còn gì để làm**:
143
143
  - Stop gate (Claude Code): `RUN.md` `Phase:` ≠ `done`/`blocked` → Stop hook từ chối
144
144
  stop kèm `Next:` step. Breaker `stopGateMaxStalledBlocks` (mặc định 12) thả ra nếu
145
145
  cursor không nhích — lối thoát, không phải đường thường.
146
+ - Gate theo session (`stopGateScope: session`, mặc định): chỉ ép session đã chạy `/ukit:handoff-fullstack`
147
+ hoặc nhận resume marker (compact/resume). Session mới (startup/clear) chỉ nhận ghi chú, không bị ép;
148
+ muốn nối run cũ thì chạy `/ukit:handoff-fullstack`. Không đọc được transcript → giữ hành vi chặn cũ.
146
149
  - `handoff-clear` PHẢI đặt `Phase: done` (hoặc xóa RUN.md) — cursor sống sẽ giữ gate
147
150
  chặn stop của session sau.
148
151
  - Phase F: docs sync (WORKLOG luôn; PROJECT/CODE_MAP/CHANGELOG khi đổi surface) →
@@ -525,6 +525,7 @@
525
525
  "fullstack": {
526
526
  "stopGateEnabled": true,
527
527
  "stopGateMaxStalledBlocks": 12,
528
+ "stopGateScope": "session",
528
529
  "stopGateMaxCrashStreaks": 3,
529
530
  "quietScansRequired": 2,
530
531
  "idleWatchdogMin": 5,
@@ -836,6 +837,7 @@
836
837
  "fullstack": {
837
838
  "stopGateEnabled": "B\u1eadt Stop gate handoff: RUN.md Phase != done/blocked \u2192 Stop hook t\u1eeb ch\u1ed1i stop k\u00e8m Next step, gi\u1eef handoff-fullstack ch\u1ea1y ti\u1ebfp thay v\u00ec \u0111\u1ee9ng sau recap. T\u1eaft ch\u1ec9 khi debug.",
838
839
  "stopGateMaxStalledBlocks": "Liveness breaker: cursor kh\u00f4ng nh\u00edch qua N l\u1ea7n block li\u00ean ti\u1ebfp th\u00ec gate th\u1ea3 stop (tr\u00e1nh v\u00f2ng l\u1eb7p v\u00f4 t\u1eadn). M\u1eb7c \u0111\u1ecbnh 12.",
840
+ "stopGateScope": "session = Stop gate chỉ ép khi chính session này đang chạy handoff-fullstack (transcript có /ukit:handoff-fullstack hoặc resume marker); session mới mở chỉ để plan/việc khác không bị ép. project = cũ: mọi session trong repo đều bị ép khi RUN.md còn sống.",
839
841
  "stopGateMaxCrashStreaks": "Liveness breaker cho coordinator crash: sau N l\u1ea7n block li\u00ean ti\u1ebfp do infrastructure failure, gate th\u1ea3 stop k\u00e8m c\u1ea3nh b\u00e1o l\u1edbn. M\u1eb7c \u0111\u1ecbnh 3.",
840
842
  "quietScansRequired": "S\u1ed1 scan li\u00ean ti\u1ebfp kh\u00f4ng th\u1ea5y vi\u1ec7c m\u1edbi tr\u01b0\u1edbc khi handoff-fullstack \u0111\u01b0\u1ee3c ph\u00e9p \u0111\u00f3ng run. M\u1eb7c \u0111\u1ecbnh 2.",
841
843
  "idleWatchdogMin": "Kho\u1ea3ng ph\u00fat cho scheduled wakeup n\u1ebfu harness h\u1ed7 tr\u1ee3 (cron/loop). M\u1eb7c \u0111\u1ecbnh 5.",