Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion PRODUCT-MAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ flowchart TD
- Local CPU plugins — ffmpeg and pyscenedetect off Modal (`local-cpu-plugins`)
- Measurement harness — Whisper-vi WER, blind TTS rating, per-node COGS (`measure-harness`)
- Mở hoá B01 — hạ cánh nhánh đổi định danh kho (ba README, NOTICE, SECURITY, CONTRIBUTING, CLAUDE.md, .github, docker-compose trỏ ảnh của fork, tắt trigger tag desktop-release) (`mo-hoa-b01`) — đã giao — không đo, khai ở Cổng Đáng
- slot đọc số/giá/ngày thành chữ tiếng Việt, bắt buộc đứng trước TTS (Phase 1.3) (`normalize-text-vi`)
- slot đọc số/giá/ngày thành chữ tiếng Việt, bắt buộc đứng trước TTS (Phase 1.3) (`normalize-text-vi`) — đã giao — đã nghỉ, giữ sử liệu
- Ô đo chạy 0 ca thử mà vẫn báo đạt — hàng rào ở chốt CI (`o-do-chay-0-ca-van-xanh`)
- Plugin directory prefix — accept oneflow-*, keep tongflow-* installable (`oneflow-plugin-prefix`)
- Per-plugin origin in the official manifest (`per-plugin-origin`)
Expand Down
9 changes: 9 additions & 0 deletions _acceptance/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -851,6 +851,15 @@ risk_tiers:
# workspace-record: vendor LẦN ĐẦU ở lượt này. Trước đây oneflow thiếu hẳn
# file này nên nhánh đọc workspace của cổng chưa từng chạy.
- "lib/workspace-record.cjs"
# Kit 2.18.x: sáu tệp vendor LẦN ĐẦU (lớp CI 9 → 15 theo INIT-CI-COPY-LIST).
# Cùng lớp thước với các dòng trên — thiếu miễn trừ thì chính PR nâng thước
# đỏ ở luật T1-escape (đo 23/09 trên chore/kit-2-18-1).
- "lib/nhan-canh-gay.cjs"
- "lib/nguong-o-co-hoi.cjs"
- "lib/out-of-contract.cjs"
- "scripts/product-map.mjs"
- "scripts/trang-thai-ho-so.cjs"
- "scripts/khong-can-nguoi.mjs"
# Lint configuration only — it constrains how source is written, never what
# the product does at runtime, so a change here cannot invalidate evidence.
- "biome.json"
Expand Down
6 changes: 6 additions & 0 deletions biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@
"!lib/workspace-record.cjs",
"!lib/eval-yaml.cjs",
"!lib/lop-nhin-thay.cjs",
"!lib/nhan-canh-gay.cjs",
"!lib/nguong-o-co-hoi.cjs",
"!lib/out-of-contract.cjs",
"!scripts/product-map.mjs",
"!scripts/trang-thai-ho-so.cjs",
"!scripts/khong-can-nguoi.mjs",
"!_acceptance"
]
},
Expand Down
91 changes: 91 additions & 0 deletions lib/nguong-o-co-hoi.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
// nguong-o-co-hoi.cjs — LUẬT DUY NHẤT trả lời «ô ngưỡng của hồ sơ cơ hội đang ở đâu».
//
// Vì sao một chỗ: bộ quét, thẻ quyết định và ca đo đều phải phân loại cùng một ô. Vòng 1
// của hồ sơ ra-co-ten viết luật này BA LẦN (start-scan · gate-card · ca đo) và bản thứ ba
// đã lệch ngay khi ra đời: nó chỉ đòi «mọi bullet ĐANG CÓ đều đã điền», không đòi ĐỦ nhãn
// của khuôn — nên một ô thiếu hẳn hai bullet bị hai bên kết luận trái nhau. Cùng doctrine
// với lib/workspace-record.cjs: ai thêm bên đọc thứ ba thì GỌI hàm này, đừng chép luật.
//
// Hàm THUẦN: nhận nội dung đã đọc, không tự đọc file — bên gọi quyết định đọc ở đâu.
'use strict';
const { section } = require('./md-section.cjs');

const UAT_THRESHOLD_HEADING = 'Ngưỡng chết / ngưỡng UAT';
const PLACEHOLDER_RE = /^(…|\.\.\.)?$/;
const bulletOf = l => { const m = l.match(/^\s*[-*]\s+([^:]+):(.*)$/); return m ? { label: m[1].trim(), value: m[2].trim() } : null; };
// «Chưa điền» nhận diện RỘNG hơn bulletOf: dấu đầu dòng TUỲ CHỌN. Ô cơ hội viết tay có dòng
// ngưỡng không gạch đầu dòng; đòi gạch thì placeholder lọt thành «đã khai» — thẻ Cổng Phạm vi
// mất cờ vàng và đòi thêm `## Đường đo` cho ô chưa có thước nào, trong khi thresholdState (đi
// qua bulletOf) vẫn gọi «chưa chốt»: hai bộ đọc nói hai chuyện về CÙNG hồ sơ, đúng lớp lỗi
// mà lib này sinh ra để giết (S4-r8). bulletOf giữ CHẶT — nhãn của nó round-trip với khuôn.
const CHUA_DIEN_RE = /^[-*]?\s*[^:]*:\s*(…|\.\.\.)?\s*$/;
const chuaDien = l => CHUA_DIEN_RE.test(String(l).trim());

// Rút chuỗi máy-đọc từ khối marker của khuôn. FAIL-CLOSED: khuôn mất khối thì NÉM, không
// trả null. Một răng chặn tắt im lặng vì thiếu khuôn là chiều hỏng tệ nhất cho một cổng.
function prefixFromTemplate(tplText, marker) {
const m = String(tplText).match(new RegExp(`<<<${marker} -->\\n([\\s\\S]*?)<!-- ${marker}>>>`));
if (!m) throw new Error(`khuôn ô cơ hội không có khối ${marker} — không phân loại ngưỡng được`);
return m[1].trim();
}
const prefixes = tplText => ({
deXuat: prefixFromTemplate(tplText, 'OPP-DE-XUAT-PREFIX'),
khongDo: prefixFromTemplate(tplText, 'OPP-KHONG-DO-DUOC-PREFIX'),
});

// Dòng «không đo được» = bắt đầu ĐÚNG tiền tố, ký tự kế là khoảng trắng hoặc hết dòng.
// Nhận cả dạng bullet `- `/`* ` trước tiền tố: section Ngưỡng toàn bullet nên đó là dạng
// tự nhiên nhất — bản cũ xếp nó im lặng thành «chưa chốt» (S4-r6 [5], phân loại sai câm).
// «Không đo được:» (hai chấm) KHÔNG phải lối ra — lối ra có tên thì tên phải khớp.
const isKhongDoLine = (line, khongDo) => {
const t = String(line).trim().replace(/^[-*]\s+/, '');
return t.startsWith(khongDo) && (t.length === khongDo.length || /\s/.test(t[khongDo.length]));
};

// Nhãn bullet CHUẨN = nhãn trong khuôn (không phải nhãn có mặt trong hồ sơ).
const thresholdLabels = tplText => section(tplText, UAT_THRESHOLD_HEADING).map(bulletOf).filter(Boolean).map(b => b.label);

// BỐN trạng thái. Thứ tự hỏi: lối ra có tên trước, rồi mới xét bullet — ô đã khai «không
// đo được» thì bullet còn lại không có nghĩa.
function thresholdState(oppText, tplText) {
const { deXuat, khongDo } = prefixes(tplText);
const lines = section(oppText, UAT_THRESHOLD_HEADING);
if (lines.some(l => isKhongDoLine(l, khongDo))) return 'khong-do-duoc';
const got = new Map();
for (const l of lines) { const b = bulletOf(l); if (b) got.set(b.label, b.value); }
const labels = thresholdLabels(tplText);
// Thông điệp giữ NGUYÊN VĂN bản cũ của start-scan — có ca ghim nó (VC1 chiều đỏ b).
if (!labels.length) throw new Error(`khuôn không có section Ngưỡng «${UAT_THRESHOLD_HEADING}» (hoặc section không có bullet)`);
if (!labels.every(lb => got.has(lb) && !PLACEHOLDER_RE.test(got.get(lb)))) return 'chua-chot';
return [...got.values()].some(v => v.startsWith(deXuat)) ? 'de-xuat' : 'chot';
}

// Timebox: CHỈ nhận hai dạng ngày. Hạn viết «muộn nhất <ngày>» BAO GỒM ngày đó, nên quá
// hạn tính từ 00:00 hôm SAU — cắm cờ đúng ngày hạn là giục người sớm một ngày.
function timeboxDate(oppText) {
for (const l of section(oppText, UAT_THRESHOLD_HEADING)) {
const b = bulletOf(l); if (!b || b.label !== 'Timebox') continue;
const iso = b.value.match(/\b(\d{4})-(\d{2})-(\d{2})\b/);
if (iso) return Date.UTC(+iso[1], +iso[2] - 1, +iso[3]);
const vn = b.value.match(/\b(\d{2})\/(\d{2})\/(\d{4})\b/);
if (vn) return Date.UTC(+vn[3], +vn[2] - 1, +vn[1]);
}
return null;
}
const quaTimebox = (oppText, now = Date.now()) => { const d = timeboxDate(oppText); return d != null && d + 86400000 <= now; };

// Mặt có NGƯỜI DÙNG CUỐI — vị từ của răng chống lách «không đo được». Sống ở đây vì hai
// bộ đọc (bộ quét + thẻ) cùng hỏi; hai bản regex rời là đúng lớp lib này sinh ra để giết.
const SURFACE_NGUOI_DUNG = /\b(ui|mobile)\b/i; // giữ tên xuất khẩu cho bên đọc cũ
// Mặt người nhìn (ui + alias web/web-ui) RÚT từ lib/lop-nhin-thay.cjs — một nguồn cho bốn bộ đọc
// (hồ sơ lop-bang-chung-nhin-thay, D4); mobile vẫn là người dùng cuối nhưng đi làn test.
// Đường đọc-cũ: bản vendored/bản sao chỉ chép từng file lib (repo tiêu thụ trước 2.10, fixture
// chép tay) không có lop-nhin-thay.cjs → quay về regex cũ (ui|mobile, KHÔNG alias web) thay vì
// ném lỗi làm bộ quét/bản đồ chết (đọc-cũ: bản sao/vendored chép từng file vẫn chạy).
let LNT = null;
try { LNT = require('./lop-nhin-thay.cjs'); } catch (_) { LNT = null; }
const coNguoiDungCuoi = surfaces => LNT
? (LNT.laMatNguoiNhin(surfaces) || LNT.tokensOf(surfaces).includes('mobile'))
: SURFACE_NGUOI_DUNG.test(String(surfaces || ''));

module.exports = { UAT_THRESHOLD_HEADING, SURFACE_NGUOI_DUNG, coNguoiDungCuoi, thresholdState, thresholdLabels, prefixes, prefixFromTemplate, isKhongDoLine, timeboxDate, quaTimebox, bulletOf, chuaDien, PLACEHOLDER_RE };
150 changes: 150 additions & 0 deletions lib/nhan-canh-gay.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
// nhan-canh-gay.cjs — MỘT nguồn trả lời «lượt chấm cuối kẹt vì đâu, và người ký được không»
// (hồ sơ nhan-trang-thai-va-reality, Đ2 · AC-4…AC-8). Bên đọc: thẻ Cổng 2 (gate-card.js),
// lưới trước-merge (pre-merge-check.sh qua CLI dưới), recheck-evidence.cjs. Ai thêm bên đọc
// thứ tư thì GỌI hàm này — hai bản luật là hai khuôn sẽ trôi.
//
// Nhãn lấy đúng chữ mục «Nhãn trạng thái» của CONTEXT.md. Phân loại CHỈ từ dữ liệu đã ghi
// (mã thoát, câu cố định của engine, cannot_run trên dòng eval) — không LLM đoán (giả định
// sinh tử 2 của ô). Dòng đời cũ thiếu `reason` → không phân loại → khoá như trước vòng.
'use strict';
const fs = require('fs');
const path = require('path');

const NHAN = { MU: 'không đọc được ở đây', CHET: 'hệ thống chết', LECH: 'thước lệch', VAT: 'sai hợp đồng' };
const TEN = { mu: NHAN.MU, chet: NHAN.CHET, vat: NHAN.VAT };

// ── nguồn chuỗi máy-đọc ─────────────────────────────────────────────────────────
// Lib này được CHÉP sang kho tiêu thụ cùng lưới trước-merge (INIT-CI-COPY-LIST), nơi KHÔNG có
// bộ chấm — nên chuỗi sống ở ĐÂY, trong khối marker, và ca NS/NC của kit so khối này với nguồn
// bộ chấm (feature-loop/workflows/acceptance-verify.js): lệch là ĐỎ, không trôi âm thầm.
// <<<NHAN-CANH-GAY-NGUON
const NGUON = {
infraExits: [97, 127],
toolKillReason: 'bi cong cu giet (timeout tool/output cat) — exit code khong phai cua lenh',
deadReason: 'agent bi skip/chet',
};
// NHAN-CANH-GAY-NGUON>>>
function nguonNhan() { return NGUON; }

// So khối trên với nguồn bộ chấm — dùng ở ca đo của kit (không dùng lúc chạy CI).
function soKhopEngine(avText) {
const loi = [];
const khoi = String(avText).match(/<<<INFRA-EXIT-CODES\n([\s\S]*?)INFRA-EXIT-CODES>>>/);
const ma = khoi ? [...khoi[1].matchAll(/^\s*(\d+):\s*'/gm)].map(m => Number(m[1])) : null;
if (!ma) loi.push('bộ chấm mất khối INFRA-EXIT-CODES');
else if (JSON.stringify(ma) !== JSON.stringify(NGUON.infraExits)) loi.push(`mã hạ tầng lệch: bộ chấm ${JSON.stringify(ma)} ≠ lib ${JSON.stringify(NGUON.infraExits)}`);
const tk = String(avText).match(/const TOOL_KILL_REASON = '([^']+)'/);
if (!tk || tk[1] !== NGUON.toolKillReason) loi.push('câu tool-kill lệch bộ chấm');
if (!String(avText).includes(`blocked.push({ cmd, reason: '${NGUON.deadReason}`)) loi.push('câu agent chết lệch bộ chấm');
return loi;
}

// Khuôn dòng sổ «ký trên cạnh gãy» — bên VIẾT là /acceptance-gate:signoff (thân lệnh mang
// CÙNG khối này); bên ĐỌC là kyTrenCanhGay dưới. Ca đo rút khuôn từ commands/signoff.md,
// điền eval, đưa vào lưới: đổi chữ ở một bên là đỏ (round-trip).
// <<<CANH-GAY-REVISIT-LINE -->
// "type":"revisit","stage":"gate2","at":"<ISO>","decision":"<nhãn> — <E> (<AC>): ghi hạn chế rồi ship","impact":"<giá>","serves":["<AC>"]
// <!-- CANH-GAY-REVISIT-LINE>>>
const KHUON_DECISION = (() => {
const m = fs.readFileSync(__filename, 'utf8').match(/"decision":"(<nhãn> — <E> \(<AC>\)[^"]*)"/);
if (!m) throw new Error('nhan-canh-gay: mất khuôn decision trong khối CANH-GAY-REVISIT-LINE');
return m[1];
})();
const tienToKy = (ten, evalId) => KHUON_DECISION.split(' (<AC>)')[0].replace('<nhãn>', ten).replace('<E>', evalId) + ' (';

// ── phân một lý do ──────────────────────────────────────────────────────────────
// → 'mu' | 'chet' | null. `laEval` = dòng mang evalId của hợp đồng (không phải SUITE-*).
function nhanLyDo(reason, laEval, nguon) {
if (!nguon) return null; // không rút được nguồn → không phân loại (khoá), không đoán
const { infraExits, toolKillReason, deadReason } = nguon;
const r = typeof reason === 'string' ? reason : '';
if (!r) return null;
if (r.includes(deadReason)) return 'chet';
if (infraExits.some(c => r.startsWith(`exit ${c} — `))) return 'mu';
if (r === toolKillReason) return 'mu';
return laEval ? 'mu' : null;
}

function docDong(runLogText) {
return String(runLogText || '').split('\n').filter(l => l.trim())
.map(l => { try { return JSON.parse(l); } catch { return null; } })
.filter(o => o && typeof o === 'object');
}

// ── cạnh gãy của lượt chấm CUỐI ────────────────────────────────────────────────
// expectedExit: {evalId: mã đạt} (vắng = 0). Trả:
// { round, lech, muc:[{evalId,cmd,nhan,reason}], daThuLai, trangThai }
// trangThai ∈ 'lech' | 'mo' | 'chet-lan-dau' | 'khoa' | 'khong-ap' (verdict không phải BLOCKED)
function canhGay({ runLogText, verdict, expectedExit = {}, nguon }) {
const dong = docDong(runLogText);
const tallies = dong.filter(o => o.kind === 'round-tally');
const cuoi = tallies[tallies.length - 1] || null;
const round = cuoi && typeof cuoi.round === 'number' ? cuoi.round : null;
const lechDong = [...dong].reverse().find(o => o.kind === 'thuoc-lech' && (round == null || (typeof o.round === 'number' && o.round >= round)));
const lech = lechDong && Array.isArray(lechDong.tep) ? lechDong.tep : null;
const out = { round, lech, muc: [], daThuLai: false, trangThai: 'khong-ap' };
if (lech) { out.trangThai = 'lech'; return out; }
if (String(verdict || '').toUpperCase() !== 'BLOCKED') return out;
if (!cuoi || round == null) { out.trangThai = 'khoa'; return out; }
// Chỉ dòng của LẦN CHẠY CUỐI: cùng round và cùng mốc ts với dòng tổng kết cuối — lần thử
// lại ghi thêm dòng cho CÙNG round, đọc lẫn là đọc kết quả của lần đã bị thay.
const cungLan = o => o.round === round && (cuoi.ts ? o.ts === cuoi.ts : true);
for (const o of dong.filter(cungLan)) {
if (o.kind === 'vang-mat') out.muc.push({ evalId: o.evalId || null, cmd: null, nhan: nhanLyDo(o.reason, true, nguon), reason: o.reason || '' });
else if (!o.kind && o.evalId) {
const laEval = !String(o.evalId).startsWith('SUITE-');
if (o.cannot_run) out.muc.push({ evalId: o.evalId, cmd: o.cmd || null, nhan: nhanLyDo(o.reason, laEval, nguon), reason: o.reason || '' });
else if (o.exit_code != null && laEval && o.exit_code !== (expectedExit[o.evalId] ?? 0))
out.muc.push({ evalId: o.evalId, cmd: o.cmd || null, nhan: 'vat', reason: `exit ${o.exit_code}` });
else if (o.exit_code != null && !laEval && o.exit_code !== 0)
out.muc.push({ evalId: o.evalId, cmd: o.cmd || null, nhan: 'vat', reason: `exit ${o.exit_code}` });
}
}
const soChanKhai = Number.isInteger(cuoi.blocked) ? cuoi.blocked : null;
const soChanThay = out.muc.filter(m => m.nhan === 'mu' || m.nhan === 'chet' || m.nhan === null).length;
out.daThuLai = tallies.filter(o => o.round === round && String(o.verdict).toUpperCase() === 'BLOCKED').length >= 2;
if (!out.muc.length) out.trangThai = 'khoa'; // BLOCKED mà không thấy mục nào: không đoán
else if (soChanKhai != null && soChanThay < soChanKhai) out.trangThai = 'khoa'; // mục chặn không để lại dòng (suite chết)
else if (out.muc.some(m => m.nhan === null || m.nhan === 'vat')) out.trangThai = 'khoa';
else if (out.muc.some(m => m.nhan === 'chet')) out.trangThai = out.daThuLai ? 'mo' : 'chet-lan-dau';
else out.trangThai = 'mo';
return out;
}

// ── ký trên cạnh gãy: mọi mục chặn của lượt cuối có dòng sổ khớp khuôn ──────────
function kyTrenCanhGay({ canh, ledgerText }) {
const dong = docDong(ledgerText);
const thieu = [];
for (const m of canh.muc) {
const tt = tienToKy(TEN[m.nhan] || '', m.evalId);
if (!dong.some(o => o.type === 'revisit' && typeof o.decision === 'string' && o.decision.startsWith(tt))) thieu.push(m.evalId);
}
return { ok: canh.trangThai === 'mo' && !thieu.length, thieu };
}

// ── CLI cho lưới trước-merge (bash không gọi được hàm) ─────────────────────────
// node lib/nhan-canh-gay.cjs --check --root <repo> --slug <slug>
// → stdout «OK <E…>» thoát 0 · «THIEU <E…>» thoát 1 · «KHONG <lý do>» thoát 2
function checkHoSo(root, slug) {
const ws = path.join(root, '_acceptance', slug);
const doc = f => { try { return fs.readFileSync(path.join(ws, f), 'utf8'); } catch { return ''; } };
const rep = doc('evidence-report.md');
const vm = rep.match(/^verdict:\s*([^\s#]+)/m);
const verdict = vm ? vm[1].replace(/["']/g, '').toUpperCase() : '';
let expectedExit = {};
try { const ey = require('./eval-yaml.cjs'); const r = ey.expectedExits(doc('evals.yaml')); if (!r.errs.length) expectedExit = Object.fromEntries(r.byId); } catch { /* không đọc được → mặc định 0 */ }
const canh = canhGay({ runLogText: doc('run-log.jsonl'), verdict, expectedExit, nguon: NGUON });
if (canh.trangThai !== 'mo') return { code: 2, out: `KHONG ${canh.trangThai}` };
const k = kyTrenCanhGay({ canh, ledgerText: doc('decisions.jsonl') });
if (!k.ok) return { code: 1, out: `THIEU ${k.thieu.join(' ')}` };
return { code: 0, out: `OK ${canh.muc.map(m => m.evalId).join(' ')}` };
}
if (require.main === module) {
const a = process.argv.slice(2);
const v = k => { const i = a.indexOf(k); return i >= 0 ? a[i + 1] : null; };
if (!a.includes('--check') || !v('--root') || !v('--slug')) { console.error('usage: nhan-canh-gay.cjs --check --root <repo> --slug <slug>'); process.exit(3); }
const r = checkHoSo(v('--root'), v('--slug'));
console.log(r.out); process.exit(r.code);
}

module.exports = { NHAN, TEN, NGUON, nguonNhan, soKhopEngine, nhanLyDo, canhGay, kyTrenCanhGay, checkHoSo, tienToKy, KHUON_DECISION, docDong };
Loading
Loading