init git
This commit is contained in:
225
.claude/workflows/ba-pipeline.js
Normal file
225
.claude/workflows/ba-pipeline.js
Normal file
@@ -0,0 +1,225 @@
|
||||
export const meta = {
|
||||
name: 'ba-pipeline',
|
||||
description: 'Điều phối quy trình BA (ba-1…ba-5) từng bước có người duyệt: mỗi lần gọi chạy đúng MỘT stage/activity, hoặc audit (chấm gate, chỉ đọc), sign (ghi chữ ký duyệt của con người vào header), sync (INDEX). Không có chế độ chạy liền.',
|
||||
whenToUse: 'Gọi từ skill ba-pipeline. Bắt buộc args.project, args.stage, args.date.',
|
||||
phases: [
|
||||
{ title: 'Init', detail: 'ba-lifecycle: PROFILE + INDEX' },
|
||||
{ title: 'Audit', detail: 'ba-gate-auditor: chấm G1–G5 + coverage RTM (chỉ đọc)' },
|
||||
{ title: 'Discovery', detail: 'ba-1: stakeholder, elicitation, RQ, BRIEF, risk' },
|
||||
{ title: 'Analysis', detail: 'ba-2: process, backlog US, BR, RBAC, impact' },
|
||||
{ title: 'Specification', detail: 'ba-3: SRS, PART 2, AC, mã lỗi, NFR, API (≤3 US)' },
|
||||
{ title: 'Delivery', detail: 'ba-4: clarification, CR, review test case, UAT' },
|
||||
{ title: 'Post-release', detail: 'ba-5: release note, manual, feedback, benefit' },
|
||||
{ title: 'Sign', detail: 'Ghi quyết định duyệt/baseline/revise của con người' },
|
||||
{ title: 'Sync', detail: 'Cập nhật INDEX từ header thật' },
|
||||
],
|
||||
}
|
||||
|
||||
const TRACK = {
|
||||
label: 'BA',
|
||||
outputRoot: 'ba-output',
|
||||
runner: 'ba-stage-runner',
|
||||
auditor: 'ba-gate-auditor',
|
||||
lifecycleSkill: 'ba-lifecycle',
|
||||
traceSkill: 'ba-traceability',
|
||||
indexDir: '00-index',
|
||||
stages: {
|
||||
discovery: { phase: 'Discovery', skill: 'ba-1-discovery', dir: '01-discovery', gate: 'G1', prevGate: null,
|
||||
activities: ['stakeholder', 'elicitation', 'requirements', 'goals-scope', 'risks'] },
|
||||
analysis: { phase: 'Analysis', skill: 'ba-2-analysis', dir: '02-analysis', gate: 'G2', prevGate: 'G1',
|
||||
activities: ['process', 'backlog', 'rules', 'rbac', 'impact'] },
|
||||
specification: { phase: 'Specification', skill: 'ba-3-specification', dir: '03-specification', gate: 'G3', prevGate: 'G2',
|
||||
activities: ['srs', 'part2', 'ac', 'errors', 'nfr', 'api'], scopeHint: 'danh sách US (tối đa 3)' },
|
||||
delivery: { phase: 'Delivery', skill: 'ba-4-delivery-support', dir: '04-delivery', gate: 'G4', prevGate: 'G3',
|
||||
activities: ['clarification', 'cr', 'testcase-review', 'uat'], scopeHint: 'US / câu hỏi / CR-nnn cụ thể' },
|
||||
'post-release': { phase: 'Post-release', skill: 'ba-5-post-release', dir: '05-post-release', gate: 'G5', prevGate: 'G4',
|
||||
activities: ['release-note', 'manual', 'feedback', 'benefit'] },
|
||||
},
|
||||
gates: {
|
||||
G1: 'PO', G2: 'PO + Tech Lead', G3: 'PO + Tech Lead + QA', G4: 'PO', G5: 'PO + BA Lead',
|
||||
},
|
||||
profileFields: ['product', 'lifecycle', 'rigor'],
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// ENGINE (dùng chung cho ba-pipeline / sa-pipeline) — một stage mỗi lần gọi
|
||||
// project (bắt buộc) · stage (bắt buộc) · date YYYY-MM-DD (bắt buộc)
|
||||
// activity : một hoạt động trong stage (mặc định: cả stage, theo thứ tự)
|
||||
// scope : phạm vi (VD US-012,US-013 · CR-004 · --focus)
|
||||
// inputs[] : file/nguồn người dùng cung cấp (ưu tiên cao nhất)
|
||||
// answers : trả lời của người dùng cho OQ / humanInputNeeded
|
||||
// notes : ghi chú người duyệt (Revise)
|
||||
// override : true = người dùng khẳng định chạy dù gate trước chưa qua
|
||||
// profile : {product, lifecycle, rigor} (init, hoặc khi đã xác nhận)
|
||||
// approvals[] : (sign) {artifact, decision: approve|baseline|revise, approver, role, note}
|
||||
// gate, decisions[] : (sign) gate được ký, DEC cần ghi
|
||||
// outputRoot : mặc định TRACK.outputRoot
|
||||
// ===========================================================================
|
||||
const a = args && typeof args === 'object' ? args : {}
|
||||
const STAGE_NAMES = ['init', 'audit', ...Object.keys(TRACK.stages), 'sign', 'sync']
|
||||
if (!a.project) throw new Error('Thiếu args.project — tên project (một project mỗi lần chạy)')
|
||||
if (!STAGE_NAMES.includes(a.stage)) throw new Error(`args.stage không hợp lệ: "${a.stage}". Hợp lệ: ${STAGE_NAMES.join(' | ')}`)
|
||||
if (!a.date || !/^\d{4}-\d{2}-\d{2}$/.test(a.date)) throw new Error('Thiếu args.date (YYYY-MM-DD) — header artifact cần ngày thật')
|
||||
|
||||
const project = a.project
|
||||
const stage = a.stage
|
||||
const root = `${a.outputRoot || TRACK.outputRoot}/${project}`
|
||||
const skillsDir = '.claude/skills'
|
||||
const def = TRACK.stages[stage] || null
|
||||
|
||||
if (def && a.activity && !def.activities.includes(a.activity)) {
|
||||
throw new Error(`args.activity "${a.activity}" không thuộc stage ${stage}. Hợp lệ: ${def.activities.join(' | ')}`)
|
||||
}
|
||||
if (stage === 'init' && !a.profile && TRACK.profileFields.length) {
|
||||
throw new Error(`Stage init cần args.profile {${TRACK.profileFields.join(', ')}} đã được người dùng xác nhận`)
|
||||
}
|
||||
if (stage === 'sign' && !(Array.isArray(a.approvals) && a.approvals.length)) {
|
||||
throw new Error('Stage sign cần args.approvals[] {artifact, decision, approver, role, note?}')
|
||||
}
|
||||
|
||||
// ---------------- Schema ----------------
|
||||
const STR_ARR = { type: 'array', items: { type: 'string' } }
|
||||
const obj = (props, required) => ({ type: 'object', properties: props, required })
|
||||
const arr = (item) => ({ type: 'array', items: item })
|
||||
const S = { type: 'string' }
|
||||
const B = { type: 'boolean' }
|
||||
const N = { type: 'number' }
|
||||
|
||||
const STAGE_SCHEMA = obj({
|
||||
filesWritten: STR_ARR,
|
||||
artifacts: arr(obj({ code: S, path: S, version: S, status: S, confidence: S }, ['code', 'path', 'status'])),
|
||||
blocked: B,
|
||||
gateWarning: S,
|
||||
gateSelfCheck: arr(obj({ item: S, ok: B, note: S }, ['item', 'ok'])),
|
||||
openQuestions: arr(obj({ id: S, question: S, askWho: S, blocks: S, ifReversed: S }, ['id', 'question'])),
|
||||
humanInputNeeded: arr(obj({ topic: S, question: S, suggestedDefault: S }, ['topic', 'question'])),
|
||||
assumptions: STR_ARR,
|
||||
decisions: arr(obj({ id: S, text: S }, ['text'])),
|
||||
adrs: arr(obj({ id: S, title: S, status: S, radar: N }, ['id', 'title'])),
|
||||
baSyncIssues: arr(obj({ baArtifact: S, saArtifact: S, issue: S, action: S }, ['issue'])),
|
||||
tbdCount: N,
|
||||
ambiguousCount: N,
|
||||
confidence: { type: 'string', enum: ['high', 'medium', 'low'] },
|
||||
summary: S,
|
||||
}, ['filesWritten', 'blocked', 'gateSelfCheck', 'openQuestions', 'humanInputNeeded', 'confidence', 'summary'])
|
||||
|
||||
const AUDIT_SCHEMA = obj({
|
||||
profile: obj({ product: S, lifecycle: S, rigor: S, confirmed: B }, []),
|
||||
gates: arr(obj({ gate: S, status: { type: 'string', enum: ['✅', '🟠', '☐'] }, artifacts: STR_ARR, missing: STR_ARR, signersRequired: S, lowConfidence: STR_ARR }, ['gate', 'status', 'missing'])),
|
||||
currentPosition: S,
|
||||
blockersHard: STR_ARR,
|
||||
blockersSoft: STR_ARR,
|
||||
coverage: arr(obj({ check: S, value: S, pass: B, details: STR_ARR }, ['check', 'pass'])),
|
||||
baSync: arr(obj({ pair: S, issue: S, action: S }, ['pair', 'issue'])),
|
||||
overdueOQ: arr(obj({ id: S, askWho: S, sinceDate: S, blocks: S, ifReversed: S }, ['id'])),
|
||||
warnings: STR_ARR,
|
||||
nextActions: arr(obj({ action: S, skill: S }, ['action'])),
|
||||
summary: S,
|
||||
}, ['gates', 'currentPosition', 'nextActions', 'summary'])
|
||||
|
||||
const SIGN_SCHEMA = obj({
|
||||
filesEdited: STR_ARR,
|
||||
applied: arr(obj({ artifact: S, decision: S, newStatus: S, newVersion: S }, ['artifact', 'decision'])),
|
||||
refused: arr(obj({ artifact: S, reason: S }, ['artifact', 'reason'])),
|
||||
indexUpdated: B,
|
||||
summary: S,
|
||||
}, ['filesEdited', 'applied', 'refused', 'summary'])
|
||||
|
||||
// ---------------- Khối prompt dùng chung ----------------
|
||||
const lines = []
|
||||
lines.push(`Project: ${project}. Thư mục output: ${root}/. Ngày hôm nay (dùng cho header Date/Approved by): ${a.date}.`)
|
||||
lines.push(`Skill điều phối: ${skillsDir}/${TRACK.lifecycleSkill}/ (references bắt buộc) · skill truy vết: ${skillsDir}/${TRACK.traceSkill}/.`)
|
||||
if (a.profile) lines.push(`Profile đã được người dùng xác nhận: ${Object.keys(a.profile).map((k) => `${k}=${a.profile[k]}`).join(' · ')}.`)
|
||||
else if (TRACK.profileFields.length) lines.push(`Profile: đọc ${root}/${TRACK.indexDir}/PROFILE_${project}.md; chưa có ⇒ dùng mặc định standard và NÓI RÕ điều đó.`)
|
||||
if (Array.isArray(a.inputs) && a.inputs.length) lines.push(`Input bổ sung do người dùng cung cấp (ưu tiên cao nhất):\n${a.inputs.map((i) => `- ${i}`).join('\n')}`)
|
||||
if (a.answers) lines.push(`## Câu trả lời của người dùng cho OQ / humanInputNeeded vòng trước\n${a.answers}`)
|
||||
if (a.notes) lines.push(`## Ghi chú từ người duyệt (bắt buộc xử lý trước, version +0.1 và Change Log)\n${a.notes}`)
|
||||
if (a.override) lines.push(`## Ngoại lệ gate\nNgười dùng đã được cảnh báo gate trước chưa ✅ Baselined và KHẲNG ĐỊNH LẠI muốn tiếp tục. Làm tiếp, ghi ngoại lệ vào Open Questions + DEC-nn của artifact.`)
|
||||
const common = lines.join('\n\n')
|
||||
|
||||
const gateOf = (name) => (TRACK.stages[name] ? TRACK.stages[name].gate : null)
|
||||
const signersOf = (g) => (g && TRACK.gates[g]) || null
|
||||
|
||||
// ---------------- Kết quả trả về cho người duyệt ----------------
|
||||
function wrap(kind, res, extra) {
|
||||
const r = res || {}
|
||||
const out = {
|
||||
track: TRACK.label, project, stage, kind, ok: !!res,
|
||||
date: a.date, activity: a.activity || null, scope: a.scope || null,
|
||||
gate: def ? def.gate : (a.gate || null),
|
||||
signersRequired: signersOf(def ? def.gate : a.gate),
|
||||
result: r,
|
||||
}
|
||||
return extra ? Object.assign(out, extra) : out
|
||||
}
|
||||
|
||||
// ---------------- Stage: init ----------------
|
||||
if (stage === 'init') {
|
||||
phase('Init')
|
||||
const r = await agent(
|
||||
`${common}\n\nNhiệm vụ "init": làm đúng ${skillsDir}/${TRACK.lifecycleSkill}/SKILL.md (khởi tạo project mới). ` +
|
||||
`Tạo cấu trúc thư mục trong ${root}/, file PROFILE (nếu bộ này có profile) và INDEX theo mẫu artifact-map. ` +
|
||||
`Không tạo file rỗng cho giai đoạn sau. Nếu ${root}/ đã có nội dung ⇒ không ghi đè, trả blocked=true và nêu rõ.`,
|
||||
{ agentType: TRACK.runner, label: `${TRACK.label}:init`, schema: STAGE_SCHEMA },
|
||||
)
|
||||
return wrap('init', r)
|
||||
}
|
||||
|
||||
// ---------------- Stage: audit (chỉ đọc) ----------------
|
||||
if (stage === 'audit') {
|
||||
phase('Audit')
|
||||
const r = await agent(
|
||||
`${common}\n\nNhiệm vụ "audit" (CHỈ ĐỌC): chấm toàn bộ gate theo checklist của ${skillsDir}/${TRACK.lifecycleSkill}/references/workflow.md ` +
|
||||
`(điều chỉnh theo RIGOR nếu có), chạy các phép kiểm coverage/nhất quán của ${skillsDir}/${TRACK.traceSkill}/, ` +
|
||||
`liệt kê blocker cứng/mềm, OQ quá hạn (so với ngày ${a.date}), cảnh báo bắt buộc và tối đa 3 việc tiếp theo. ` +
|
||||
`✅ chỉ khi có chữ ký thật trong header. Không ghi file.${a.scope ? `\nPhạm vi cần soi kỹ: ${a.scope}` : ''}`,
|
||||
{ agentType: TRACK.auditor, label: `${TRACK.label}:audit`, schema: AUDIT_SCHEMA },
|
||||
)
|
||||
return wrap('audit', r)
|
||||
}
|
||||
|
||||
// ---------------- Stage: sign (ghi quyết định của con người) ----------------
|
||||
if (stage === 'sign') {
|
||||
phase('Sign')
|
||||
const list = a.approvals.map((p, i) =>
|
||||
`${i + 1}. ${p.artifact} — quyết định: ${p.decision}; người duyệt: ${p.approver}${p.role ? ` (${p.role})` : ''}${p.note ? `; ghi chú: ${p.note}` : ''}`).join('\n')
|
||||
const decs = Array.isArray(a.decisions) && a.decisions.length ? `\nDEC cần ghi vào sổ quyết định:\n${a.decisions.map((d) => `- ${d}`).join('\n')}` : ''
|
||||
const gateLine = a.gate ? `\nGate được ký: ${a.gate} — người ký yêu cầu theo quy trình: ${signersOf(a.gate) || 'xem workflow.md'}. Nếu danh sách người duyệt bên dưới không đủ vai trò ⇒ vẫn ghi Approved by nhưng KHÔNG baseline, và nêu trong refused.` : ''
|
||||
const r = await agent(
|
||||
`${common}\n\nNhiệm vụ "sign": ghi quyết định duyệt của CON NGƯỜI vào header/Change Log/INDEX theo đúng quy tắc trong system prompt của bạn. ` +
|
||||
`Không đổi nội dung chuyên môn. Từ chối baseline khi artifact chưa đủ điều kiện (còn TBD, thiếu header, checklist gate chưa đủ) và nêu lý do.${gateLine}\n\nDanh sách quyết định:\n${list}${decs}`,
|
||||
{ agentType: TRACK.runner, label: `${TRACK.label}:sign`, schema: SIGN_SCHEMA },
|
||||
)
|
||||
return wrap('sign', r, { gate: a.gate || null, signersRequired: signersOf(a.gate) })
|
||||
}
|
||||
|
||||
// ---------------- Stage: sync (INDEX) ----------------
|
||||
if (stage === 'sync') {
|
||||
phase('Sync')
|
||||
const r = await agent(
|
||||
`${common}\n\nNhiệm vụ "sync": cập nhật ${root}/${TRACK.indexDir}/INDEX_${project}.md (và sổ ADL/DTM/OQ/DECISION nếu bộ này có) ` +
|
||||
`từ header THẬT của các artifact theo ${skillsDir}/${TRACK.lifecycleSkill}/references/artifact-map.md. Không sửa artifact.`,
|
||||
{ agentType: TRACK.runner, label: `${TRACK.label}:sync`, schema: STAGE_SCHEMA },
|
||||
)
|
||||
return wrap('sync', r)
|
||||
}
|
||||
|
||||
// ---------------- Stage giai đoạn (ba-1…/sa-1…) ----------------
|
||||
phase(def.phase)
|
||||
const acts = a.activity ? [a.activity] : def.activities
|
||||
const prereq = [
|
||||
def.prevGate ? `Gate trước cần ✅ Baselined: ${def.prevGate}.` : 'Đây là giai đoạn đầu, không có gate trước trong bộ này.',
|
||||
def.externalPrereq ? `Điều kiện ngoài bộ: ${def.externalPrereq}.` : '',
|
||||
def.orderedPrefix ? `Thứ tự bắt buộc trong giai đoạn: ${def.orderedPrefix.join(' → ')} phải có trước các hoạt động khác.` : '',
|
||||
].filter(Boolean).join(' ')
|
||||
if (def.scopeHint && !a.scope) log(`Cảnh báo: stage ${stage} thường cần args.scope (${def.scopeHint}) — agent sẽ tự chọn và báo trong summary.`)
|
||||
|
||||
const r = await agent(
|
||||
`${common}\n\nNhiệm vụ: thực thi skill ${skillsDir}/${def.skill}/SKILL.md ở chế độ go, ` +
|
||||
`chỉ các hoạt động: ${acts.join(', ')}${a.scope ? ` · phạm vi: ${a.scope}` : ''}. Output vào ${root}/${def.dir}/. ` +
|
||||
`${prereq} Preflight gate trước khi ghi: chưa qua và không có "Ngoại lệ gate" ⇒ blocked=true, không ghi file. ` +
|
||||
`Gate của giai đoạn này: ${def.gate} (người ký: ${signersOf(def.gate)}) — bạn chỉ TỰ CHẤM checklist, không được ghi ✅ vào header.`,
|
||||
{ agentType: TRACK.runner, label: `${TRACK.label}:${stage}${a.activity ? ':' + a.activity : ''}`, schema: STAGE_SCHEMA },
|
||||
)
|
||||
if (r && r.blocked) log(`${TRACK.label}/${stage}: bị chặn bởi gate — ${r.gateWarning || 'xem result.gateWarning'}`)
|
||||
return wrap('stage', r, { activities: acts, nextGate: def.gate, signersRequired: signersOf(def.gate) })
|
||||
460
.claude/workflows/generate-bid.js
Normal file
460
.claude/workflows/generate-bid.js
Normal file
@@ -0,0 +1,460 @@
|
||||
export const meta = {
|
||||
name: 'generate-bid',
|
||||
description: 'Lập hồ sơ dự thầu từ SAD theo từng stage có người duyệt: intake (HSMT, ma trận đáp ứng, checklist) → technical (Phần B) → estimate (WBS + UCP; MM/chi phí/timeline tính bằng code) → plan (B7/B8) → financial (Phần C) → assemble (HO-SO-THAU.md + HTML) → deck (slide 16:9) → export (PDF bằng Edge/Chrome headless) → review. Một stage mỗi lần gọi.',
|
||||
whenToUse: 'Gọi từ skill sad-bid. Bắt buộc args.stage và args.date; stage estimate cần args.pricing (từ bid/bid-config.md).',
|
||||
phases: [
|
||||
{ title: 'Intake', detail: 'bid-analyst: bid-brief, compliance matrix, document checklist' },
|
||||
{ title: 'Technical', detail: 'bid-technical-writer: Phần B (B1–B6, B9, B10)' },
|
||||
{ title: 'Estimate', detail: 'bid-estimator → code tính MD/MM/UCP/chi phí/timeline → persist computed.json' },
|
||||
{ title: 'Plan', detail: 'bid-planner: B7 kế hoạch, B8 nhân sự' },
|
||||
{ title: 'Financial', detail: 'bid-financial-writer: Phần C' },
|
||||
{ title: 'Assemble', detail: 'bid-builder: HO-SO-THAU.md + index.html + artifact.html' },
|
||||
{ title: 'Deck', detail: 'bid-deck-builder: deck.html 15–25 slide 16:9 tự chứa' },
|
||||
{ title: 'Export', detail: 'bid-exporter: Edge/Chrome headless → HO-SO-THAU.pdf, deck.pdf' },
|
||||
{ title: 'Review', detail: 'bid-reviewer: đủ mục, đáp ứng, số liệu, rò rỉ, khả thi, PDF/deck' },
|
||||
],
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tham số
|
||||
// ---------------------------------------------------------------------------
|
||||
const a = args && typeof args === 'object' ? args : {}
|
||||
const STAGES = ['intake', 'technical', 'estimate', 'plan', 'financial', 'assemble', 'deck', 'export', 'review']
|
||||
if (!STAGES.includes(a.stage)) throw new Error(`args.stage không hợp lệ: "${a.stage}". Hợp lệ: ${STAGES.join(' | ')}`)
|
||||
if (!a.date || !/^\d{4}-\d{2}-\d{2}$/.test(a.date)) throw new Error('Thiếu args.date (YYYY-MM-DD)')
|
||||
if (a.stage === 'estimate' && (!a.pricing || typeof a.pricing !== 'object')) {
|
||||
throw new Error('Stage estimate cần args.pricing (object lấy từ frontmatter bid/bid-config.md: roles, mdPerMM, rateCard, vatPct, contingencyPct, …)')
|
||||
}
|
||||
|
||||
const stage = a.stage
|
||||
const BID = a.bidDir || 'bid'
|
||||
const SAD = a.sadFile || 'docs/SAD.md'
|
||||
const S = a.sectionsDir || 'docs/sections'
|
||||
const BRIEF = a.briefFile || 'docs/00-project-brief.md'
|
||||
const CFG = `${BID}/bid-config.md`
|
||||
const REF = '.claude/skills/sad-bid/references/dossier-structure.md'
|
||||
const F = {
|
||||
brief: `${BID}/00-bid-brief.md`, compliance: `${BID}/01-compliance-matrix.md`, checklist: `${BID}/02-document-checklist.md`,
|
||||
technical: `${BID}/10-technical-proposal.md`, estimate: `${BID}/estimate.json`, estimationMd: `${BID}/20-estimation.md`,
|
||||
computed: `${BID}/estimate.computed.json`, plan: `${BID}/30-implementation-plan.md`, financial: `${BID}/40-financial-proposal.md`,
|
||||
dossier: `${BID}/HO-SO-THAU.md`, html: `${BID}/index.html`, artifact: `${BID}/artifact.html`,
|
||||
deck: `${BID}/deck.html`, deckArtifact: `${BID}/deck-artifact.html`,
|
||||
pdf: `${BID}/HO-SO-THAU.pdf`, deckPdf: `${BID}/deck.pdf`,
|
||||
}
|
||||
const notes = a.notes ? `\n\n## Ghi chú từ người duyệt (bắt buộc xử lý trước khi làm gì khác)\n${a.notes}\n` : ''
|
||||
const sources = `Nguồn: ${REF} (cấu trúc chuẩn), ${CFG} (thông tin thương mại), ${SAD} (nếu chưa có thì ${BRIEF} + ${S}/01–09), HSMT/RFP trong ${BID}/inputs/ nếu có. Ngày hôm nay: ${a.date}.`
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Schema
|
||||
// ---------------------------------------------------------------------------
|
||||
const SA = { type: 'array', items: { type: 'string' } }
|
||||
const obj = (p, r) => ({ type: 'object', properties: p, required: r })
|
||||
const arr = (i) => ({ type: 'array', items: i })
|
||||
const T = { type: 'string' }, Bo = { type: 'boolean' }, Nu = { type: 'number' }
|
||||
const NuN = { type: ['number', 'null'] }
|
||||
const CONF = { type: 'string', enum: ['high', 'medium', 'low'] }
|
||||
|
||||
const INTAKE_SCHEMA = obj({
|
||||
filesWritten: SA, hasRfp: Bo,
|
||||
evaluationCriteria: arr(obj({ criterion: T, weight: T, source: T }, ['criterion'])),
|
||||
mandatoryRequirements: arr(obj({ id: T, text: T, met: { type: 'string', enum: ['yes', 'partial', 'no', 'unclear'] } }, ['id', 'text', 'met'])),
|
||||
complianceSummary: obj({ total: Nu, met: Nu, partial: Nu, no: Nu, unclear: Nu }, []),
|
||||
gaps: arr(obj({ reqId: T, issue: T, suggestion: T }, ['reqId', 'issue'])),
|
||||
documentChecklist: arr(obj({ id: T, name: T, mandatory: Bo, status: T }, ['id', 'name', 'status'])),
|
||||
keyDates: arr(obj({ event: T, date: T }, ['event'])),
|
||||
dossierStructureOverride: T, risks: SA, confidence: CONF, summary: T,
|
||||
}, ['filesWritten', 'hasRfp', 'mandatoryRequirements', 'gaps', 'documentChecklist', 'confidence', 'summary'])
|
||||
|
||||
const TECH_SCHEMA = obj({
|
||||
filesWritten: SA,
|
||||
sections: arr(obj({ id: T, title: T, sadSources: SA, mandatoryReqsCovered: SA }, ['id', 'title'])),
|
||||
diagrams: arr(obj({ title: T, type: T, sadSource: T }, ['title', 'type'])),
|
||||
techStack: arr(obj({ layer: T, technology: T, license: T }, ['layer', 'technology'])),
|
||||
placeholders: arr(obj({ description: T, section: T }, ['description'])),
|
||||
uncoveredMandatory: SA, keyFacts: arr(obj({ fact: T, source: T }, ['fact', 'source'])), confidence: CONF, summary: T,
|
||||
}, ['filesWritten', 'sections', 'placeholders', 'uncoveredMandatory', 'confidence', 'summary'])
|
||||
|
||||
const ITEM = obj({
|
||||
id: T, name: T, group: T, sources: SA,
|
||||
complexity: { type: 'string', enum: ['S', 'M', 'L', 'XL'] },
|
||||
risk: { type: 'string', enum: ['low', 'medium', 'high'] },
|
||||
effortMD: { type: 'object', additionalProperties: Nu },
|
||||
rationale: T, assumptions: SA,
|
||||
}, ['id', 'name', 'complexity', 'risk', 'effortMD'])
|
||||
const FACTOR = obj({ id: T, name: T, score: Nu, why: T }, ['id', 'score'])
|
||||
const ESTIMATE_SCHEMA = obj({
|
||||
filesWritten: SA,
|
||||
items: arr(ITEM),
|
||||
ucp: obj({
|
||||
actors: obj({ simple: Nu, average: Nu, complex: Nu }, ['simple', 'average', 'complex']),
|
||||
useCases: obj({ simple: Nu, average: Nu, complex: Nu }, ['simple', 'average', 'complex']),
|
||||
tcf: arr(FACTOR), ef: arr(FACTOR), notes: T,
|
||||
}, ['actors', 'useCases', 'tcf', 'ef']),
|
||||
nonLabor: arr(obj({ id: T, name: T, basis: T, amount: NuN, currency: T, recurring: T, note: T }, ['id', 'name'])),
|
||||
configEcho: { type: 'object' },
|
||||
uncovered: SA, assumptions: SA, openQuestions: SA, nonLaborMissingAmounts: SA, confidence: CONF, summary: T,
|
||||
}, ['filesWritten', 'items', 'ucp', 'nonLabor', 'uncovered', 'confidence', 'summary'])
|
||||
const PERSIST_SCHEMA = obj({ filesWritten: SA, summary: T }, ['filesWritten', 'summary'])
|
||||
|
||||
const PLAN_SCHEMA = obj({
|
||||
filesWritten: SA, durationMonths: Nu,
|
||||
phases: arr(obj({ name: T, start: T, end: T, deliverables: SA }, ['name'])),
|
||||
milestones: arr(obj({ name: T, date: T, paymentLinked: Bo }, ['name'])),
|
||||
peakHeadcount: Nu, deadlineFits: { type: ['boolean', 'null'] }, accelerationOptions: SA,
|
||||
placeholders: arr(obj({ description: T, section: T }, ['description'])), numbersUsed: SA, confidence: CONF, summary: T,
|
||||
}, ['filesWritten', 'phases', 'milestones', 'placeholders', 'confidence', 'summary'])
|
||||
|
||||
const FIN_SCHEMA = obj({
|
||||
filesWritten: SA, grandMM: Nu, totalBeforeVat: NuN, totalAfterVat: NuN, currency: T,
|
||||
missingRates: SA, missingAmounts: SA, paymentMilestones: arr(obj({ milestone: T, pct: NuN }, ['milestone'])),
|
||||
placeholders: arr(obj({ description: T, section: T }, ['description'])), numbersUsed: SA, confidence: CONF, summary: T,
|
||||
}, ['filesWritten', 'placeholders', 'confidence', 'summary'])
|
||||
|
||||
const BUILD_SCHEMA = obj({
|
||||
filesWritten: SA, sectionsPresent: SA, sectionsMissing: SA, structureOverrideUsed: Bo,
|
||||
placeholdersCount: Nu, mermaidBlocks: Nu, approxSizeKB: Nu,
|
||||
checks: obj({ standaloneDoc: Bo, artifactVariant: Bo, toc: Bo, themeTokens: Bo, printCss: Bo, responsiveTables: Bo, priceConsistentA1C5: Bo }, []),
|
||||
confidence: CONF, summary: T,
|
||||
}, ['filesWritten', 'sectionsMissing', 'checks', 'confidence', 'summary'])
|
||||
|
||||
const DECK_SCHEMA = obj({
|
||||
filesWritten: SA, slideCount: Nu,
|
||||
slides: arr(obj({ id: T, title: T, source: T }, ['id', 'title'])),
|
||||
numbersUsed: SA, placeholdersCount: Nu, mermaidBlocks: Nu,
|
||||
checks: obj({ selfContained: Bo, printOnePerSlide: Bo, navigation: Bo, mermaidGuarded: Bo, artifactVariant: Bo, themeTokens: Bo }, []),
|
||||
confidence: CONF, summary: T,
|
||||
}, ['filesWritten', 'slideCount', 'slides', 'checks', 'confidence', 'summary'])
|
||||
|
||||
const EXPORT_SCHEMA = obj({
|
||||
blocked: Bo, browser: T,
|
||||
pdfs: arr(obj({ source: T, output: T, pages: Nu, sizeKB: Nu, mermaidExpected: Nu, mermaidRendered: Nu, outline: Bo, ok: Bo }, ['source', 'output', 'ok'])),
|
||||
commands: SA, warnings: SA,
|
||||
findings: arr(obj({ target: { type: 'string', enum: ['assemble', 'deck'] }, issue: T, suggestion: T, severity: { type: 'string', enum: ['high', 'medium', 'low'] } }, ['target', 'issue', 'severity'])),
|
||||
manualInstructions: T, confidence: CONF, summary: T,
|
||||
}, ['blocked', 'pdfs', 'warnings', 'confidence', 'summary'])
|
||||
|
||||
const REVIEW_SCHEMA = obj({
|
||||
verdict: { type: 'string', enum: ['pass', 'revise'] }, canSubmit: Bo,
|
||||
missingSections: SA,
|
||||
uncoveredMandatory: arr(obj({ reqId: T, text: T }, ['reqId'])),
|
||||
numberMismatches: arr(obj({ where: T, found: T, expected: T }, ['where', 'found', 'expected'])),
|
||||
leaks: arr(obj({ file: T, snippet: T, why: T }, ['file', 'snippet'])),
|
||||
placeholders: SA,
|
||||
exportChecks: obj({ dossierPdf: Bo, deckPdf: Bo, deckNumbersMatch: Bo }, []),
|
||||
findings: arr(obj({ target: { type: 'string', enum: ['intake', 'technical', 'estimate', 'plan', 'financial', 'assemble', 'deck', 'export'] }, issue: T, suggestion: T, severity: { type: 'string', enum: ['high', 'medium', 'low'] } }, ['target', 'issue', 'severity'])),
|
||||
factChecks: arr(obj({ claim: T, evidence: T, ok: Bo }, ['claim', 'ok'])),
|
||||
summary: T,
|
||||
}, ['verdict', 'canSubmit', 'missingSections', 'uncoveredMandatory', 'numberMismatches', 'leaks', 'placeholders', 'findings', 'summary'])
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tính toán ước lượng — thuần code, không LLM
|
||||
// ---------------------------------------------------------------------------
|
||||
const r2 = (x) => Math.round((Number(x) || 0) * 100) / 100
|
||||
const r0 = (x) => Math.round(Number(x) || 0)
|
||||
const TCF_W = { T1: 2, T2: 1, T3: 1, T4: 1, T5: 1, T6: 0.5, T7: 0.5, T8: 2, T9: 1, T10: 1, T11: 1, T12: 1, T13: 1 }
|
||||
const EF_W = { E1: 1.5, E2: 0.5, E3: 1, E4: 0.5, E5: 1, E6: 2, E7: -1, E8: -1 }
|
||||
const DEFAULT_PHASES = [
|
||||
{ name: 'Khởi động & chuẩn bị', pct: 5 },
|
||||
{ name: 'Phân tích & thiết kế chi tiết', pct: 15 },
|
||||
{ name: 'Phát triển', pct: 45 },
|
||||
{ name: 'Kiểm thử hệ thống, hiệu năng, bảo mật', pct: 15 },
|
||||
{ name: 'UAT & đào tạo', pct: 12 },
|
||||
{ name: 'Go-live & hỗ trợ ổn định', pct: 8 },
|
||||
]
|
||||
// phân bổ % MM của từng vai trò theo 6 giai đoạn (tổng 100)
|
||||
const ROLE_PHASE = {
|
||||
PM: [10, 15, 40, 15, 12, 8], BA: [15, 45, 20, 5, 15, 0], SA: [20, 45, 25, 10, 0, 0], UIUX: [10, 60, 30, 0, 0, 0],
|
||||
BE: [0, 10, 75, 10, 3, 2], FE: [0, 10, 75, 10, 3, 2], MOBILE: [0, 10, 75, 10, 3, 2],
|
||||
QA: [0, 10, 35, 40, 15, 0], DEVOPS: [30, 10, 25, 15, 5, 15], DEFAULT: [5, 15, 45, 15, 12, 8],
|
||||
}
|
||||
|
||||
function addMonths(iso, months) {
|
||||
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso || '')
|
||||
if (!m) return null
|
||||
const y = Number(m[1]), mo = Number(m[2]) - 1, d = Number(m[3])
|
||||
const whole = Math.floor(months), frac = months - whole
|
||||
const dt = new Date(Date.UTC(y, mo + whole, d))
|
||||
if (frac > 0) dt.setUTCDate(dt.getUTCDate() + Math.round(frac * 30))
|
||||
return dt.toISOString().slice(0, 10)
|
||||
}
|
||||
function monthsBetween(startIso, endIso) {
|
||||
const s = /^(\d{4})-(\d{2})-(\d{2})$/.exec(startIso || ''), e = /^(\d{4})-(\d{2})-(\d{2})$/.exec(endIso || '')
|
||||
if (!s || !e) return null
|
||||
const ms = Date.UTC(Number(e[1]), Number(e[2]) - 1, Number(e[3])) - Date.UTC(Number(s[1]), Number(s[2]) - 1, Number(s[3]))
|
||||
return r2(ms / (1000 * 60 * 60 * 24 * 30.4375))
|
||||
}
|
||||
|
||||
function computeEstimate(est, cfg) {
|
||||
const warnings = []
|
||||
const mdPerMM = Number(cfg.mdPerMM) > 0 ? Number(cfg.mdPerMM) : (warnings.push('mdPerMM không có trong config — dùng 21'), 21)
|
||||
const hoursPerDay = Number(cfg.hoursPerDay) > 0 ? Number(cfg.hoursPerDay) : 8
|
||||
const hoursPerUCP = Number(cfg.hoursPerUCP) > 0 ? Number(cfg.hoursPerUCP) : (warnings.push('hoursPerUCP không có — dùng 20'), 20)
|
||||
const cont = Object.assign({ low: 10, medium: 20, high: 35 }, cfg.contingencyPct || {})
|
||||
const overheadMode = cfg.overheadMode || 'itemized'
|
||||
const overheadPct = overheadMode === 'percent' ? Number(cfg.overheadPct) || 0 : 0
|
||||
const overheadRole = cfg.overheadRole || 'PM'
|
||||
const rateCard = cfg.rateCard || {}
|
||||
const vatPct = cfg.vatPct === undefined || cfg.vatPct === null ? (warnings.push('vatPct không có — dùng 10'), 10) : Number(cfg.vatPct)
|
||||
const currency = cfg.currency || 'VND'
|
||||
const roleSet = new Set(Array.isArray(cfg.roles) ? cfg.roles : [])
|
||||
|
||||
// --- WBS bottom-up ---
|
||||
const items = []
|
||||
const baseByRole = {}, contByRole = {}
|
||||
let baseMD = 0, contMD = 0
|
||||
for (const it of est.items || []) {
|
||||
const eff = it.effortMD || {}
|
||||
let itemMD = 0
|
||||
for (const role of Object.keys(eff)) {
|
||||
const v = Number(eff[role]) || 0
|
||||
if (v < 0) warnings.push(`${it.id}: effort âm cho ${role} — bỏ qua`)
|
||||
if (v <= 0) continue
|
||||
roleSet.add(role)
|
||||
itemMD += v
|
||||
baseByRole[role] = r2((baseByRole[role] || 0) + v)
|
||||
}
|
||||
const pct = cont[it.risk] !== undefined ? Number(cont[it.risk]) : cont.medium
|
||||
const itemCont = r2(itemMD * pct / 100)
|
||||
for (const role of Object.keys(eff)) {
|
||||
const v = Number(eff[role]) || 0
|
||||
if (v <= 0 || itemMD === 0) continue
|
||||
contByRole[role] = r2((contByRole[role] || 0) + itemCont * (v / itemMD))
|
||||
}
|
||||
baseMD += itemMD; contMD += itemCont
|
||||
items.push({ id: it.id, name: it.name, group: it.group || '', sources: it.sources || [], complexity: it.complexity, risk: it.risk, effortMD: eff, itemMD: r2(itemMD), contingencyPct: pct, contingencyMD: itemCont })
|
||||
}
|
||||
baseMD = r2(baseMD); contMD = r2(contMD)
|
||||
const overheadMD = r2(baseMD * overheadPct / 100)
|
||||
if (overheadMD > 0) { roleSet.add(overheadRole) }
|
||||
const roles = Array.from(roleSet)
|
||||
const totalByRole = {}, mmByRole = {}
|
||||
let grandMD = 0
|
||||
for (const role of roles) {
|
||||
const t = r2((baseByRole[role] || 0) + (contByRole[role] || 0) + (role === overheadRole ? overheadMD : 0))
|
||||
totalByRole[role] = t; mmByRole[role] = r2(t / mdPerMM); grandMD += t
|
||||
}
|
||||
grandMD = r2(grandMD)
|
||||
const grandMM = r2(grandMD / mdPerMM)
|
||||
const baseMM = r2(baseMD / mdPerMM)
|
||||
if (!items.length) warnings.push('Không có hạng mục WBS — ước lượng rỗng')
|
||||
const qa = (baseByRole.QA || 0), dev = (baseByRole.BE || 0) + (baseByRole.FE || 0) + (baseByRole.MOBILE || 0)
|
||||
if (dev > 0 && qa / dev < 0.15) warnings.push(`QA chỉ bằng ${r0(qa / dev * 100)}% effort dev — thấp bất thường (thường 25–40%)`)
|
||||
if (!(baseByRole.PM > 0) && overheadMD === 0) warnings.push('Không có effort PM và không có overhead quản lý')
|
||||
|
||||
// --- Chi phí ---
|
||||
const laborByRole = {}, missingRates = []
|
||||
let laborTotal = 0
|
||||
for (const role of roles) {
|
||||
const rate = Number(rateCard[role])
|
||||
if (!(rate > 0)) { missingRates.push(role); laborByRole[role] = null; continue }
|
||||
const c = r0(mmByRole[role] * rate)
|
||||
laborByRole[role] = c; laborTotal += c
|
||||
}
|
||||
const nonLaborItems = [], missingAmounts = []
|
||||
let nonLaborTotal = 0
|
||||
const cfgNL = Array.isArray(cfg.nonLabor) ? cfg.nonLabor : []
|
||||
const merged = [...(est.nonLabor || [])]
|
||||
for (const c of cfgNL) if (!merged.some((x) => x.id === c.id || x.name === c.name)) merged.push(c)
|
||||
for (const nl of merged) {
|
||||
const cfgMatch = cfgNL.find((x) => x.id === nl.id || x.name === nl.name)
|
||||
const amount = cfgMatch && Number(cfgMatch.amount) > 0 ? Number(cfgMatch.amount) : (Number(nl.amount) > 0 ? Number(nl.amount) : null)
|
||||
if (amount === null) missingAmounts.push(nl.name || nl.id)
|
||||
else nonLaborTotal += amount
|
||||
nonLaborItems.push({ id: nl.id, name: nl.name, basis: nl.basis || (cfgMatch && cfgMatch.basis) || '', recurring: nl.recurring || (cfgMatch && cfgMatch.recurring) || 'one-off', amount, currency })
|
||||
}
|
||||
const subtotal = r0(laborTotal + nonLaborTotal)
|
||||
const vat = r0(subtotal * vatPct / 100)
|
||||
const total = r0(subtotal + vat)
|
||||
const priceComplete = missingRates.length === 0 && missingAmounts.length === 0
|
||||
if (!priceComplete) warnings.push(`Giá tạm tính: thiếu đơn giá [${missingRates.join(', ')}]${missingAmounts.length ? `, thiếu số tiền [${missingAmounts.join(', ')}]` : ''}`)
|
||||
|
||||
// --- UCP đối chiếu ---
|
||||
const u = est.ucp || {}
|
||||
const ac = u.actors || {}, uc = u.useCases || {}
|
||||
const UAW = (Number(ac.simple) || 0) * 1 + (Number(ac.average) || 0) * 2 + (Number(ac.complex) || 0) * 3
|
||||
const UUCW = (Number(uc.simple) || 0) * 5 + (Number(uc.average) || 0) * 10 + (Number(uc.complex) || 0) * 15
|
||||
let tSum = 0
|
||||
for (const f of u.tcf || []) tSum += (TCF_W[f.id] || 0) * Math.min(5, Math.max(0, Number(f.score) || 0))
|
||||
let eSum = 0
|
||||
for (const f of u.ef || []) eSum += (EF_W[f.id] || 0) * Math.min(5, Math.max(0, Number(f.score) || 0))
|
||||
const TCF = r2(0.6 + 0.01 * tSum), EF = r2(1.4 - 0.03 * eSum)
|
||||
const UCP = r2((UAW + UUCW) * TCF * EF)
|
||||
const ucpHours = r2(UCP * hoursPerUCP)
|
||||
const ucpMD = r2(ucpHours / hoursPerDay)
|
||||
const ucpMM = r2(ucpMD / mdPerMM)
|
||||
const variancePct = ucpMM > 0 ? r2((baseMM - ucpMM) / ucpMM * 100) : null
|
||||
const threshold = Number(cfg.ucpVarianceThresholdPct) > 0 ? Number(cfg.ucpVarianceThresholdPct) : 25
|
||||
const varianceFlag = variancePct !== null && Math.abs(variancePct) > threshold
|
||||
if (varianceFlag) warnings.push(`WBS (${baseMM} MM) lệch ${variancePct}% so với UCP (${ucpMM} MM) — vượt ngưỡng ${threshold}%, cần giải thích ở C1`)
|
||||
if ((u.tcf || []).length !== 13 || (u.ef || []).length !== 8) warnings.push('UCP: thiếu yếu tố TCF (13) hoặc EF (8) — kết quả đối chiếu kém tin cậy')
|
||||
|
||||
// --- Timeline & staffing ---
|
||||
const eff = Number(cfg.parallelEfficiency) > 0 && Number(cfg.parallelEfficiency) <= 1 ? Number(cfg.parallelEfficiency) : 0.85
|
||||
let teamSize = Number(cfg.teamSize) > 0 ? Number(cfg.teamSize) : 0
|
||||
let teamDerived = false
|
||||
if (!teamSize && Number(cfg.targetMonths) > 0) { teamSize = Math.max(1, Math.ceil(grandMM / (Number(cfg.targetMonths) * eff))); teamDerived = true }
|
||||
if (!teamSize) { teamSize = Math.min(12, Math.max(3, Math.ceil(grandMM / 6))); teamDerived = true }
|
||||
const durationMonths = grandMM > 0 ? Math.max(1, Math.ceil(grandMM / (teamSize * eff))) : 0
|
||||
const startDate = /^\d{4}-\d{2}-\d{2}$/.test(cfg.projectStartDate || '') ? cfg.projectStartDate : null
|
||||
if (!startDate) warnings.push('projectStartDate chưa có — Gantt sẽ dùng tháng tương đối (M1, M2…)')
|
||||
const phaseDefs = Array.isArray(cfg.phases) && cfg.phases.length ? cfg.phases : DEFAULT_PHASES
|
||||
const phases = []
|
||||
let cursor = 0
|
||||
for (const p of phaseDefs) {
|
||||
const len = durationMonths > 0 ? Math.max(0.5, Math.round(durationMonths * p.pct / 100 * 2) / 2) : 0
|
||||
phases.push({ name: p.name, pct: p.pct, startMonth: r2(cursor), endMonth: r2(cursor + len), startDate: startDate ? addMonths(startDate, cursor) : null, endDate: startDate ? addMonths(startDate, cursor + len) : null, effortMM: r2(grandMM * p.pct / 100) })
|
||||
cursor += len
|
||||
}
|
||||
const scheduleMonths = r2(cursor)
|
||||
const endDate = startDate ? addMonths(startDate, scheduleMonths) : null
|
||||
let deadlineFit = { deadline: cfg.projectDeadline || null, fits: null, availableMonths: null, suggestedTeamSize: null }
|
||||
if (startDate && /^\d{4}-\d{2}-\d{2}$/.test(cfg.projectDeadline || '')) {
|
||||
const avail = monthsBetween(startDate, cfg.projectDeadline)
|
||||
const fits = avail !== null && scheduleMonths <= avail
|
||||
deadlineFit = { deadline: cfg.projectDeadline, fits, availableMonths: avail, suggestedTeamSize: fits ? teamSize : Math.ceil(grandMM / (Math.max(0.5, avail) * eff)) }
|
||||
if (!fits) warnings.push(`Deadline ${cfg.projectDeadline}: cần ${scheduleMonths} tháng nhưng chỉ có ${avail} — cân nhắc team ${deadlineFit.suggestedTeamSize} người hoặc cắt phạm vi`)
|
||||
}
|
||||
const nMonths = Math.max(1, Math.ceil(scheduleMonths))
|
||||
const byMonth = []
|
||||
for (let m = 1; m <= nMonths; m++) byMonth.push({ month: m, label: startDate ? addMonths(startDate, m - 1).slice(0, 7) : `M${m}`, fte: {}, total: 0 })
|
||||
for (const role of roles) {
|
||||
const dist = ROLE_PHASE[role] || ROLE_PHASE.DEFAULT
|
||||
phases.forEach((p, i) => {
|
||||
const mm = mmByRole[role] * (dist[i] || 0) / 100
|
||||
const len = p.endMonth - p.startMonth
|
||||
if (mm <= 0 || len <= 0) return
|
||||
for (let m = 1; m <= nMonths; m++) {
|
||||
const ms = m - 1, me = m
|
||||
const overlap = Math.max(0, Math.min(me, p.endMonth) - Math.max(ms, p.startMonth))
|
||||
if (overlap <= 0) continue
|
||||
byMonth[m - 1].fte[role] = r2((byMonth[m - 1].fte[role] || 0) + mm * (overlap / len))
|
||||
}
|
||||
})
|
||||
}
|
||||
let peak = 0
|
||||
for (const row of byMonth) { row.total = r2(Object.values(row.fte).reduce((s, v) => s + v, 0)); if (row.total > peak) peak = row.total }
|
||||
|
||||
return {
|
||||
generatedAt: cfg.date || null, unit: 'MD', mdPerMM, hoursPerDay, roles,
|
||||
items,
|
||||
totals: { baseMDByRole: baseByRole, contingencyMDByRole: contByRole, overheadMD, overheadRole: overheadMD > 0 ? overheadRole : null, totalMDByRole: totalByRole, baseMD, contingencyMD: contMD, grandMD, baseMM, mmByRole, grandMM },
|
||||
cost: { currency, rateCard, laborByRole, laborTotal: r0(laborTotal), nonLaborItems, nonLaborTotal: r0(nonLaborTotal), subtotal, vatPct, vat, total, priceComplete, missingRates, missingAmounts },
|
||||
ucp: { UAW, UUCW, TCF, EF, UCP, hoursPerUCP, hours: ucpHours, md: ucpMD, mm: ucpMM, tcfSum: r2(tSum), efSum: r2(eSum) },
|
||||
crosscheck: { wbsBaseMM: baseMM, ucpMM, variancePct, thresholdPct: threshold, flag: varianceFlag },
|
||||
timeline: { teamSize, teamDerived, parallelEfficiency: eff, durationMonths, scheduleMonths, startDate, endDate, phases, deadlineFit },
|
||||
staffing: { unit: 'FTE (MM/tháng)', byMonth, peak: r2(peak), peakHeadcount: Math.ceil(peak) },
|
||||
warnings,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stages
|
||||
// ---------------------------------------------------------------------------
|
||||
const wrap = (kind, res, extra) => Object.assign({ stage, kind, ok: !!res, date: a.date, files: F, result: res || {} }, extra || {})
|
||||
|
||||
if (stage === 'intake') {
|
||||
phase('Intake')
|
||||
const r = await agent(
|
||||
`${sources}\nNhiệm vụ: lập ${F.brief}, ${F.compliance}, ${F.checklist} theo đúng system prompt của bạn. ` +
|
||||
`Nếu ${BID}/inputs/ không có HSMT/RFP ⇒ dùng cấu trúc mặc định và ma trận tự đối chiếu theo FR của SAD.${notes}`,
|
||||
{ agentType: 'bid-analyst', label: 'bid-analyst', schema: INTAKE_SCHEMA },
|
||||
)
|
||||
return wrap('intake', r)
|
||||
}
|
||||
|
||||
if (stage === 'technical') {
|
||||
phase('Technical')
|
||||
const r = await agent(
|
||||
`${sources}\nĐọc ${F.brief} và ${F.compliance}. Viết ${F.technical} (Phần B: B1–B6, B9, B10; B2.1 từ ma trận đáp ứng). ` +
|
||||
`Không có con số MM/chi phí/số tháng trong Phần B. Không rò rỉ nội bộ.${notes}`,
|
||||
{ agentType: 'bid-technical-writer', label: 'bid-technical-writer', schema: TECH_SCHEMA },
|
||||
)
|
||||
return wrap('technical', r)
|
||||
}
|
||||
|
||||
if (stage === 'estimate') {
|
||||
phase('Estimate')
|
||||
const est = await agent(
|
||||
`${sources}\nĐọc ${F.compliance} và ${F.technical} (nếu có). Lập ước lượng theo system prompt: ghi ${F.estimate} và ${F.estimationMd}. ` +
|
||||
`TRẢ VỀ đầy đủ items[], ucp{actors,useCases,tcf(13),ef(8)}, nonLabor[] trong structured output (workflow sẽ tính toán bằng code từ dữ liệu này). ` +
|
||||
`Vai trò được phép: ${Array.isArray(a.pricing.roles) ? a.pricing.roles.join(', ') : 'đọc từ bid-config'}. Không cộng tổng, không quy đổi MM, không ghi tiền.${notes}`,
|
||||
{ agentType: 'bid-estimator', label: 'bid-estimator', schema: ESTIMATE_SCHEMA },
|
||||
)
|
||||
if (!est) return wrap('estimate', null, { computed: null })
|
||||
const cfg = Object.assign({}, est.configEcho || {}, a.pricing, { date: a.date })
|
||||
const computed = computeEstimate(est, cfg)
|
||||
log(`Ước lượng: ${computed.items.length} hạng mục · ${computed.totals.grandMM} MM (WBS cơ sở ${computed.totals.baseMM} MM, UCP ${computed.ucp.mm} MM) · ${computed.timeline.durationMonths} tháng với team ${computed.timeline.teamSize}${computed.timeline.teamDerived ? ' (suy ra)' : ''} · ${computed.cost.priceComplete ? 'giá đầy đủ' : 'giá tạm tính'}`)
|
||||
for (const w of computed.warnings) log(`Cảnh báo: ${w}`)
|
||||
const persisted = await agent(
|
||||
`Nhiệm vụ "persist": ghi NGUYÊN VĂN JSON dưới đây vào ${F.computed} (không sửa số, không thêm trường). Trả filesWritten.\n\n` +
|
||||
'```json\n' + JSON.stringify(computed, null, 2) + '\n```',
|
||||
{ agentType: 'bid-estimator', label: 'persist-computed', schema: PERSIST_SCHEMA, effort: 'low' },
|
||||
)
|
||||
return wrap('estimate', est, { computed, persisted: !!persisted && (persisted.filesWritten || []).length > 0, uncovered: est.uncovered || [], warnings: computed.warnings })
|
||||
}
|
||||
|
||||
if (stage === 'plan') {
|
||||
phase('Plan')
|
||||
const r = await agent(
|
||||
`${sources}\nĐọc ${F.computed} (timeline, staffing, totals — KHÔNG đổi số), ${F.estimate}, ${F.brief}, ${F.technical}. ` +
|
||||
`Viết ${F.plan} (B7 kế hoạch triển khai với Gantt ngày thật, B8 tổ chức nhân sự & staffing plan). ` +
|
||||
`deadlineFit.fits=false ⇒ trình bày kế hoạch cơ sở + phương án tăng tốc, không ép số.${notes}`,
|
||||
{ agentType: 'bid-planner', label: 'bid-planner', schema: PLAN_SCHEMA },
|
||||
)
|
||||
return wrap('plan', r)
|
||||
}
|
||||
|
||||
if (stage === 'financial') {
|
||||
phase('Financial')
|
||||
const r = await agent(
|
||||
`${sources}\nĐọc ${F.computed} (nguồn số liệu duy nhất), ${F.estimate}, ${F.estimationMd}, ${F.plan} (mốc thanh toán), ${F.brief} (mẫu biểu giá). ` +
|
||||
`Viết ${F.financial} (Phần C: C1–C7). Mọi con số phải trùng computed; thiếu đơn giá/số tiền ⇒ placeholder và ghi "giá tạm tính".${notes}`,
|
||||
{ agentType: 'bid-financial-writer', label: 'bid-financial-writer', schema: FIN_SCHEMA },
|
||||
)
|
||||
return wrap('financial', r)
|
||||
}
|
||||
|
||||
if (stage === 'assemble') {
|
||||
phase('Assemble')
|
||||
const r = await agent(
|
||||
`${sources}\nRáp ${F.dossier} từ ${F.checklist}, ${F.compliance}, ${F.technical}, ${F.plan}, ${F.financial}, ${F.estimate}, ${F.computed} theo cấu trúc chuẩn ` +
|
||||
`(hoặc cấu trúc HSMT ghi trong ${F.brief}); dựng ${F.html} (độc lập, in được) và ${F.artifact} (biến thể Artifact). Không viết nội dung mới, không đổi số; giá A1 = C5.${notes}`,
|
||||
{ agentType: 'bid-builder', label: 'bid-builder', schema: BUILD_SCHEMA },
|
||||
)
|
||||
return wrap('assemble', r)
|
||||
}
|
||||
|
||||
if (stage === 'deck') {
|
||||
phase('Deck')
|
||||
const r = await agent(
|
||||
`${sources}\nĐọc ${F.dossier}, ${F.computed}, ${F.brief}. Dựng ${F.deck} (bộ slide tự chứa 16:9, 15–25 slide, mỗi slide in đúng 1 trang) ` +
|
||||
`và ${F.deckArtifact} theo storyboard & design spec trong system prompt. Không viết nội dung mới; mọi số liệu trùng computed; không rò rỉ nội bộ.${notes}`,
|
||||
{ agentType: 'bid-deck-builder', label: 'bid-deck-builder', schema: DECK_SCHEMA },
|
||||
)
|
||||
return wrap('deck', r)
|
||||
}
|
||||
|
||||
if (stage === 'export') {
|
||||
phase('Export')
|
||||
const targets = Array.isArray(a.targets) && a.targets.length ? a.targets : [
|
||||
{ html: F.html, pdf: F.pdf, kind: 'dossier', optional: false },
|
||||
{ html: F.deck, pdf: F.deckPdf, kind: 'deck', optional: true },
|
||||
]
|
||||
const r = await agent(
|
||||
`${sources}\nNhiệm vụ "export": với từng target dưới đây, kiểm Mermaid đã render (--dump-dom), in PDF bằng Edge/Chrome headless theo đúng quy trình ` +
|
||||
`trong system prompt, rồi kiểm file/số trang/kích thước/outline. Target optional mà HTML chưa tồn tại ⇒ bỏ qua và ghi warning. Không sửa HTML.\n` +
|
||||
targets.map((t, i) => `${i + 1}. [${t.kind}] ${t.html} → ${t.pdf}${t.optional ? ' (optional)' : ''}`).join('\n') + notes,
|
||||
{ agentType: 'bid-exporter', label: 'bid-exporter', schema: EXPORT_SCHEMA },
|
||||
)
|
||||
return wrap('export', r, { blocked: !!(r && r.blocked), pdfs: r ? r.pdfs || [] : [] })
|
||||
}
|
||||
|
||||
// review
|
||||
phase('Review')
|
||||
const r = await agent(
|
||||
`${sources}\nRà soát toàn bộ ${BID}/ (00–02, 10, 20, 30, 40, estimate.json, estimate.computed.json, HO-SO-THAU.md, index.html, artifact.html, ` +
|
||||
`deck.html, deck-artifact.html, HO-SO-THAU.pdf, deck.pdf nếu có) theo 6 lăng kính trong system prompt. ` +
|
||||
`Nguồn sự thật số liệu: ${F.computed}. Không sửa file — chỉ trả findings.${notes}`,
|
||||
{ agentType: 'bid-reviewer', label: 'bid-reviewer', schema: REVIEW_SCHEMA },
|
||||
)
|
||||
return wrap('review', r, { canSubmit: !!(r && r.canSubmit) })
|
||||
284
.claude/workflows/generate-proposal.js
Normal file
284
.claude/workflows/generate-proposal.js
Normal file
@@ -0,0 +1,284 @@
|
||||
export const meta = {
|
||||
name: 'generate-proposal',
|
||||
description: 'Tổng hợp tài liệu SAD thành proposal khách hàng dạng HTML/CSS: content (nội dung thương mại) → build (HTML) → review (rò rỉ nội bộ, trung thực, chất lượng HTML). Mỗi lần gọi chạy 1 stage (args.stage) có gate; "all" chạy liền kèm 1 vòng tự sửa theo review.',
|
||||
whenToUse: 'Gọi từ skill sad-proposal sau khi SAD đã hoàn thiện (docs/SAD.md) và đã có docs/proposal/proposal-config.md.',
|
||||
phases: [
|
||||
{ title: 'Content', detail: 'proposal-writer: SAD + config → proposal-content.md' },
|
||||
{ title: 'Build', detail: 'proposal-builder: content → index.html + artifact.html' },
|
||||
{ title: 'Review', detail: 'proposal-reviewer: rò rỉ nội bộ, trung thực, chất lượng HTML' },
|
||||
],
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tham số
|
||||
// stage: content | build | review | all (mặc định: all)
|
||||
// notes: ghi chú người duyệt khi chạy lại 1 stage (Revise)
|
||||
// keyFacts: danh sách {fact, source} từ gate content, truyền cho stage review
|
||||
// autoFix: (all) tự sửa 1 vòng theo review — mặc định true
|
||||
// sadFile / sectionsDir / briefFile / configFile / outDir: đường dẫn
|
||||
// ---------------------------------------------------------------------------
|
||||
const a = args && typeof args === 'object' ? args : {}
|
||||
const STAGES = ['content', 'build', 'review']
|
||||
const stage = a.stage || 'all'
|
||||
if (stage !== 'all' && !STAGES.includes(stage)) {
|
||||
throw new Error(`args.stage không hợp lệ: "${stage}". Hợp lệ: all | ${STAGES.join(' | ')}`)
|
||||
}
|
||||
|
||||
const SAD = a.sadFile || 'docs/SAD.md'
|
||||
const S = a.sectionsDir || 'docs/sections'
|
||||
const BRIEF = a.briefFile || 'docs/00-project-brief.md'
|
||||
const CFG = a.configFile || 'docs/proposal/proposal-config.md'
|
||||
const OUT = a.outDir || 'docs/proposal'
|
||||
const CONTENT = `${OUT}/proposal-content.md`
|
||||
const HTML = `${OUT}/index.html`
|
||||
const ART = `${OUT}/artifact.html`
|
||||
const autoFix = a.autoFix !== false
|
||||
const notes = stage !== 'all' && a.notes
|
||||
? `\n\n## Ghi chú từ người duyệt (bắt buộc xử lý trước khi làm gì khác)\n${a.notes}\n`
|
||||
: ''
|
||||
const run = (s) => stage === 'all' || stage === s
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Schema — mọi agent trả structured output để script rẽ nhánh bằng code
|
||||
// ---------------------------------------------------------------------------
|
||||
const CONTENT_SCHEMA = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
filesWritten: { type: 'array', items: { type: 'string' } },
|
||||
sections: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: { id: { type: 'string' }, title: { type: 'string' }, sourceSadSections: { type: 'array', items: { type: 'string' } } },
|
||||
required: ['id', 'title'],
|
||||
},
|
||||
},
|
||||
placeholders: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: { id: { type: 'string' }, description: { type: 'string' }, section: { type: 'string' } },
|
||||
required: ['description'],
|
||||
},
|
||||
},
|
||||
excludedInternal: { type: 'array', items: { type: 'string' } },
|
||||
keyFacts: {
|
||||
type: 'array',
|
||||
items: { type: 'object', properties: { fact: { type: 'string' }, source: { type: 'string' } }, required: ['fact', 'source'] },
|
||||
},
|
||||
confidence: { type: 'string', enum: ['high', 'medium', 'low'] },
|
||||
summary: { type: 'string' },
|
||||
},
|
||||
required: ['filesWritten', 'sections', 'placeholders', 'confidence', 'summary'],
|
||||
}
|
||||
const BUILD_SCHEMA = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
filesWritten: { type: 'array', items: { type: 'string' } },
|
||||
sectionsRendered: { type: 'array', items: { type: 'string' } },
|
||||
placeholdersCount: { type: 'number' },
|
||||
mermaidBlocks: { type: 'number' },
|
||||
approxSizeKB: { type: 'number' },
|
||||
checks: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
standaloneDoc: { type: 'boolean' },
|
||||
artifactVariant: { type: 'boolean' },
|
||||
title: { type: 'boolean' },
|
||||
tocAnchors: { type: 'boolean' },
|
||||
themeTokens: { type: 'boolean' },
|
||||
printCss: { type: 'boolean' },
|
||||
responsiveTables: { type: 'boolean' },
|
||||
mermaidLoaderGuarded: { type: 'boolean' },
|
||||
},
|
||||
},
|
||||
confidence: { type: 'string', enum: ['high', 'medium', 'low'] },
|
||||
summary: { type: 'string' },
|
||||
},
|
||||
required: ['filesWritten', 'checks', 'confidence', 'summary'],
|
||||
}
|
||||
const REVIEW_SCHEMA = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
verdict: { type: 'string', enum: ['pass', 'revise'] },
|
||||
leaks: {
|
||||
type: 'array',
|
||||
items: { type: 'object', properties: { file: { type: 'string' }, snippet: { type: 'string' }, why: { type: 'string' } }, required: ['file', 'snippet'] },
|
||||
},
|
||||
placeholders: { type: 'array', items: { type: 'string' } },
|
||||
findings: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
target: { type: 'string', enum: ['content', 'html'] },
|
||||
issue: { type: 'string' },
|
||||
suggestion: { type: 'string' },
|
||||
severity: { type: 'string', enum: ['high', 'medium', 'low'] },
|
||||
},
|
||||
required: ['target', 'issue', 'severity'],
|
||||
},
|
||||
},
|
||||
factChecks: {
|
||||
type: 'array',
|
||||
items: { type: 'object', properties: { claim: { type: 'string' }, sadEvidence: { type: 'string' }, ok: { type: 'boolean' } }, required: ['claim', 'ok'] },
|
||||
},
|
||||
summary: { type: 'string' },
|
||||
},
|
||||
required: ['verdict', 'leaks', 'findings', 'summary'],
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
const sources =
|
||||
`Nguồn: ${CFG} (thông tin thương mại — nguồn duy nhất cho giá/ngày/tên người), ` +
|
||||
`${SAD} (nếu chưa có thì đọc ${BRIEF} + toàn bộ ${S}/01–09).`
|
||||
|
||||
const fmtFindings = (list) =>
|
||||
list.map((f, i) => `${i + 1}. [${f.severity}] ${f.issue}${f.suggestion ? ` → ${f.suggestion}` : ''}`).join('\n')
|
||||
|
||||
function writeContent(extra) {
|
||||
return agent(
|
||||
`${sources}\nSoạn nội dung proposal khách hàng theo đúng cấu trúc và marker trong system prompt của bạn, ghi ra ${CONTENT}. ` +
|
||||
`Tuyệt đối không đưa nội dung nội bộ (ghi chú rà soát, trạng thái duyệt, câu hỏi mở, findings, mã FR ngoài Phụ lục) vào proposal. ` +
|
||||
`Không bịa giá/ngày/số liệu — dùng [[CẦN ĐIỀN: ...]] và liệt kê trong placeholders.${extra || ''}`,
|
||||
{ agentType: 'proposal-writer', label: 'proposal-writer', phase: 'Content', schema: CONTENT_SCHEMA },
|
||||
)
|
||||
}
|
||||
function buildHtml(extra) {
|
||||
return agent(
|
||||
`Đọc ${CONTENT} và ${CFG} (brandColor, language, logo nếu có). Dựng ${HTML} (tài liệu HTML độc lập hoàn chỉnh) ` +
|
||||
`và ${ART} (biến thể Artifact: không doctype/html/head/body, bắt đầu bằng <title> rồi <style>) theo đúng design spec trong system prompt. ` +
|
||||
`Không đổi câu chữ của content; mọi [[CẦN ĐIỀN]] phải thành <mark class="todo">. Tự kiểm checklist trước khi trả kết quả.${extra || ''}`,
|
||||
{ agentType: 'proposal-builder', label: 'proposal-builder', phase: 'Build', schema: BUILD_SCHEMA },
|
||||
)
|
||||
}
|
||||
function reviewAll(keyFacts) {
|
||||
const facts = Array.isArray(keyFacts) && keyFacts.length
|
||||
? `\nkeyFacts từ proposal-writer cần đối chiếu từng mục:\n${keyFacts.map((k) => `- ${k.fact} (nguồn: ${k.source})`).join('\n')}`
|
||||
: ''
|
||||
return agent(
|
||||
`Rà soát ${CONTENT}, ${HTML}, ${ART} đối chiếu với ${SAD} (hoặc ${BRIEF} + ${S}/) và ${CFG}. ` +
|
||||
`Ba lăng kính: (1) rò rỉ nội dung nội bộ; (2) độ trung thực số liệu/cam kết; (3) placeholder còn lại và chất lượng HTML theo checklist. ` +
|
||||
`Không sửa file — chỉ trả findings.${facts}`,
|
||||
{ agentType: 'proposal-reviewer', label: 'proposal-reviewer', phase: 'Review', schema: REVIEW_SCHEMA },
|
||||
)
|
||||
}
|
||||
|
||||
function contentGate(c) {
|
||||
const r = c || {}
|
||||
return {
|
||||
stage: 'content', ok: !!c,
|
||||
filesWritten: r.filesWritten || [],
|
||||
sections: r.sections || [],
|
||||
placeholders: r.placeholders || [],
|
||||
excludedInternal: r.excludedInternal || [],
|
||||
keyFacts: r.keyFacts || [],
|
||||
confidence: r.confidence || 'unknown',
|
||||
summary: r.summary || '',
|
||||
}
|
||||
}
|
||||
function buildGate(b) {
|
||||
const r = b || {}
|
||||
const checks = r.checks || {}
|
||||
const failed = Object.keys(checks).filter((k) => checks[k] === false)
|
||||
return {
|
||||
stage: 'build', ok: !!b,
|
||||
filesWritten: r.filesWritten || [],
|
||||
sectionsRendered: r.sectionsRendered || [],
|
||||
placeholdersCount: typeof r.placeholdersCount === 'number' ? r.placeholdersCount : null,
|
||||
mermaidBlocks: r.mermaidBlocks,
|
||||
approxSizeKB: r.approxSizeKB,
|
||||
checks, failedChecks: failed,
|
||||
confidence: r.confidence || 'unknown',
|
||||
summary: r.summary || '',
|
||||
files: { html: HTML, artifact: ART },
|
||||
}
|
||||
}
|
||||
function reviewGate(v) {
|
||||
const r = v || {}
|
||||
const leaks = r.leaks || []
|
||||
const placeholders = r.placeholders || []
|
||||
const findings = r.findings || []
|
||||
return {
|
||||
stage: 'review', ok: !!v,
|
||||
verdict: r.verdict || 'unknown',
|
||||
leaks, placeholders, findings,
|
||||
factChecks: r.factChecks || [],
|
||||
failedFacts: (r.factChecks || []).filter((f) => f.ok === false),
|
||||
canSend: r.verdict === 'pass' && leaks.length === 0 && placeholders.length === 0,
|
||||
summary: r.summary || '',
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pipeline
|
||||
// ---------------------------------------------------------------------------
|
||||
const report = { stage, autoFixed: false, files: { content: CONTENT, html: HTML, artifact: ART } }
|
||||
if (stage === 'all') log('Chế độ "all": content → build → review, tự sửa tối đa 1 vòng theo review. Không có cổng phê duyệt giữa các bước.')
|
||||
|
||||
if (run('content')) {
|
||||
phase('Content')
|
||||
const c = await writeContent(notes)
|
||||
report.content = contentGate(c)
|
||||
if (stage === 'content') return report.content
|
||||
}
|
||||
|
||||
if (run('build')) {
|
||||
phase('Build')
|
||||
const b = await buildHtml(notes)
|
||||
report.build = buildGate(b)
|
||||
if (stage === 'build') return report.build
|
||||
}
|
||||
|
||||
if (run('review')) {
|
||||
phase('Review')
|
||||
let keyFacts = report.content ? report.content.keyFacts : (Array.isArray(a.keyFacts) ? a.keyFacts : [])
|
||||
const v = await reviewAll(keyFacts)
|
||||
report.review = reviewGate(v)
|
||||
if (stage === 'review') return report.review
|
||||
|
||||
// --- all: 1 vòng tự sửa ---------------------------------------------------
|
||||
if (autoFix && v && v.verdict === 'revise') {
|
||||
const fixable = (v.findings || []).filter((f) => f.severity !== 'low')
|
||||
const leakNotes = (v.leaks || []).map((l) => ({
|
||||
severity: 'high', target: 'content',
|
||||
issue: `Rò rỉ nội dung nội bộ trong ${l.file}: "${l.snippet}"`,
|
||||
suggestion: l.why || 'Loại bỏ hoặc viết lại theo góc nhìn khách hàng',
|
||||
}))
|
||||
const contentNotes = fixable.filter((f) => f.target === 'content').concat(leakNotes)
|
||||
const htmlNotes = fixable.filter((f) => f.target === 'html')
|
||||
|
||||
if (contentNotes.length) {
|
||||
log(`Tự sửa: ${contentNotes.length} finding nội dung → chạy lại content, build, review.`)
|
||||
phase('Content')
|
||||
const c2 = await writeContent(`\n\n## Findings từ reviewer (bắt buộc xử lý)\n${fmtFindings(contentNotes)}\n`)
|
||||
report.content = contentGate(c2)
|
||||
keyFacts = report.content.keyFacts
|
||||
phase('Build')
|
||||
const b2 = await buildHtml(
|
||||
htmlNotes.length
|
||||
? `\n\n## Findings từ reviewer (bắt buộc xử lý)\n${fmtFindings(htmlNotes)}\n\nNội dung cũng đã được sửa — dựng lại từ content mới.`
|
||||
: '\n\nNội dung đã được sửa theo review — dựng lại HTML từ content mới.',
|
||||
)
|
||||
report.build = buildGate(b2)
|
||||
} else if (htmlNotes.length) {
|
||||
log(`Tự sửa: ${htmlNotes.length} finding HTML → chạy lại build, review.`)
|
||||
phase('Build')
|
||||
const b2 = await buildHtml(`\n\n## Findings từ reviewer (bắt buộc xử lý)\n${fmtFindings(htmlNotes)}\n`)
|
||||
report.build = buildGate(b2)
|
||||
}
|
||||
|
||||
if (contentNotes.length || htmlNotes.length) {
|
||||
phase('Review')
|
||||
const v2 = await reviewAll(keyFacts)
|
||||
report.review = reviewGate(v2)
|
||||
report.autoFixed = true
|
||||
} else {
|
||||
log('Review yêu cầu sửa nhưng chỉ có finding mức low — bỏ qua tự sửa, để người duyệt quyết định.')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return report
|
||||
336
.claude/workflows/generate-sad.js
Normal file
336
.claude/workflows/generate-sad.js
Normal file
@@ -0,0 +1,336 @@
|
||||
export const meta = {
|
||||
name: 'generate-sad',
|
||||
description: 'Sinh tài liệu SAD theo từng stage có cổng phê duyệt: intake làm rõ brief → requirements → architecture → API/Data/UI-UX → detailed → security → test&ops → consolidate. Mỗi lần gọi chạy 1 stage (args.stage) rồi trả summary để người dùng duyệt.',
|
||||
whenToUse: 'Gọi từ skill sad-pipeline. Dùng args.stage để chạy từng bước; stage "all" chạy liền không có gate.',
|
||||
phases: [
|
||||
{ title: 'Intake', detail: 'Đánh giá độ đủ thông tin của brief, sinh câu hỏi làm rõ' },
|
||||
{ title: 'Requirements', detail: 'Mục 1-2: Tổng quan & phân tích yêu cầu' },
|
||||
{ title: 'Architecture', detail: 'Mục 3: Kiến trúc hệ thống' },
|
||||
{ title: 'Design Fanout', detail: 'Mục 4, 5, 7: API / Data / UI-UX song song' },
|
||||
{ title: 'Detailed Design', detail: 'Mục 6: Luồng xử lý chi tiết' },
|
||||
{ title: 'Security', detail: 'Mục 8: Rà soát bảo mật' },
|
||||
{ title: 'Test & Ops', detail: 'Mục 9: Kế hoạch kiểm thử & vận hành' },
|
||||
{ title: 'Consolidate', detail: 'Mục 0 + ráp tài liệu + kiểm tra nhất quán' },
|
||||
],
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tham số
|
||||
// stage: intake | requirements | architecture | fanout | detailed |
|
||||
// security | testops | consolidate | all (mặc định: all)
|
||||
// brief: mô tả dự án (stage intake, lần đầu)
|
||||
// answers: câu trả lời của người dùng cho câu hỏi làm rõ (stage intake, vòng sau)
|
||||
// round: số vòng làm rõ hiện tại (stage intake; >=3 sẽ tự chốt mặc định)
|
||||
// notes: ghi chú của người duyệt khi chạy lại một stage (Revise)
|
||||
// only: ['api','data','uiux'] — chỉ chạy một phần fanout
|
||||
// requirementIds: danh sách FR-xx đã duyệt, để tính coverage bằng code
|
||||
// sectionsDir / briefFile / outFile: đường dẫn (mặc định docs/...)
|
||||
// ---------------------------------------------------------------------------
|
||||
const a = args && typeof args === 'object' ? args : { brief: args }
|
||||
const STAGES = ['intake', 'requirements', 'architecture', 'fanout', 'detailed', 'security', 'testops', 'consolidate']
|
||||
const stage = a.stage || 'all'
|
||||
if (stage !== 'all' && !STAGES.includes(stage)) {
|
||||
throw new Error(`args.stage không hợp lệ: "${stage}". Hợp lệ: all | ${STAGES.join(' | ')}`)
|
||||
}
|
||||
|
||||
const S = a.sectionsDir || 'docs/sections'
|
||||
const BRIEF = a.briefFile || 'docs/00-project-brief.md'
|
||||
const OUT = a.outFile || 'docs/SAD.md'
|
||||
const only = Array.isArray(a.only) ? a.only : null
|
||||
const round = Number(a.round) || 1
|
||||
const notes = stage !== 'all' && a.notes
|
||||
? `\n\n## Ghi chú từ người duyệt (bắt buộc xử lý trước khi làm gì khác)\n${a.notes}\n`
|
||||
: ''
|
||||
let requirementIds = Array.isArray(a.requirementIds) ? a.requirementIds.slice() : []
|
||||
const run = (s) => stage === 'all' || stage === s
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Schema — mọi agent trả structured output để script rẽ nhánh bằng code
|
||||
// ---------------------------------------------------------------------------
|
||||
const GAP = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
field: { type: 'string' },
|
||||
severity: { type: 'string', enum: ['Critical', 'Important', 'Nice-to-have'] },
|
||||
question: { type: 'string' },
|
||||
proposedDefault: { type: 'string' },
|
||||
riskIfAssumed: { type: 'string' },
|
||||
relatedSections: { type: 'array', items: { type: 'string' } },
|
||||
},
|
||||
required: ['field', 'severity', 'question', 'proposedDefault'],
|
||||
}
|
||||
const INTAKE_SCHEMA = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
ready: { type: 'boolean' },
|
||||
completenessScore: { type: 'number' },
|
||||
briefVersion: { type: 'number' },
|
||||
profile: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
scale: { type: 'string' },
|
||||
hasPayment: { type: 'boolean' },
|
||||
hasPII: { type: 'boolean' },
|
||||
platforms: { type: 'array', items: { type: 'string' } },
|
||||
integrations: { type: 'array', items: { type: 'string' } },
|
||||
notApplicableSections: { type: 'array', items: { type: 'string' } },
|
||||
},
|
||||
},
|
||||
gaps: { type: 'array', items: GAP },
|
||||
adoptedDefaults: { type: 'array', items: { type: 'string' } },
|
||||
referenceModelSummary: { type: 'string' },
|
||||
summary: { type: 'string' },
|
||||
},
|
||||
required: ['ready', 'gaps', 'profile', 'summary'],
|
||||
}
|
||||
const FINDING = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
targetSection: { type: 'string' },
|
||||
issue: { type: 'string' },
|
||||
suggestion: { type: 'string' },
|
||||
severity: { type: 'string', enum: ['high', 'medium', 'low'] },
|
||||
},
|
||||
required: ['targetSection', 'issue', 'severity'],
|
||||
}
|
||||
const SECTION_PROPS = {
|
||||
filesWritten: { type: 'array', items: { type: 'string' } },
|
||||
coveredRequirements: { type: 'array', items: { type: 'string' } },
|
||||
knownRequirementIds: { type: 'array', items: { type: 'string' } },
|
||||
assumptions: { type: 'array', items: { type: 'string' } },
|
||||
openQuestions: { type: 'array', items: { type: 'string' } },
|
||||
findings: { type: 'array', items: FINDING },
|
||||
confidence: { type: 'string', enum: ['high', 'medium', 'low'] },
|
||||
summary: { type: 'string' },
|
||||
}
|
||||
const SECTION_REQUIRED = ['filesWritten', 'coveredRequirements', 'assumptions', 'openQuestions', 'confidence', 'summary']
|
||||
const SECTION_SCHEMA = { type: 'object', properties: SECTION_PROPS, required: SECTION_REQUIRED }
|
||||
const REQ_SCHEMA = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
...SECTION_PROPS,
|
||||
requirements: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: { id: { type: 'string' }, title: { type: 'string' }, priority: { type: 'string' } },
|
||||
required: ['id', 'title'],
|
||||
},
|
||||
},
|
||||
entities: { type: 'array', items: { type: 'string' } },
|
||||
},
|
||||
required: [...SECTION_REQUIRED, 'requirements'],
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
function coverage(res) {
|
||||
if (!res) return null
|
||||
const known = requirementIds.length ? requirementIds : (res.knownRequirementIds || [])
|
||||
const covered = new Set(res.coveredRequirements || [])
|
||||
return {
|
||||
known: known.length,
|
||||
covered: known.filter((id) => covered.has(id)).length,
|
||||
uncovered: known.filter((id) => !covered.has(id)),
|
||||
}
|
||||
}
|
||||
function gate(name, res, extra) {
|
||||
const r = res || {}
|
||||
const out = {
|
||||
stage: name,
|
||||
ok: !!res,
|
||||
filesWritten: r.filesWritten || [],
|
||||
coverage: coverage(res),
|
||||
confidence: r.confidence || 'unknown',
|
||||
assumptions: r.assumptions || [],
|
||||
openQuestions: r.openQuestions || [],
|
||||
findings: r.findings || [],
|
||||
summary: r.summary || '',
|
||||
requirementIds,
|
||||
}
|
||||
return extra ? Object.assign(out, extra) : out
|
||||
}
|
||||
const common =
|
||||
`Tuân thủ đầy đủ "Quy ước chung của pipeline" trong system prompt của bạn: đọc ${BRIEF} trước tiên, ` +
|
||||
`ghi frontmatter (section/title/status/version/reviewer_notes) đầu file output, right-size theo profile, ` +
|
||||
`và xử lý ghi chú người duyệt nếu có.`
|
||||
const reqHint = () =>
|
||||
requirementIds.length
|
||||
? `\nDanh sách FR đã duyệt cần phủ: ${requirementIds.join(', ')}. Trong coveredRequirements chỉ ghi mã bạn thực sự đã đề cập.`
|
||||
: `\nĐọc mã FR-xx từ ${S}/02-phan-tich-yeu-cau.md; trả knownRequirementIds = toàn bộ mã tìm thấy và coveredRequirements = mã bạn đã đề cập.`
|
||||
|
||||
const report = { stage, gates: [] }
|
||||
if (stage === 'all') log('Chế độ "all": chạy liền toàn bộ pipeline, KHÔNG có cổng phê duyệt giữa các bước.')
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stage 0 — Intake
|
||||
// ---------------------------------------------------------------------------
|
||||
if (run('intake')) {
|
||||
phase('Intake')
|
||||
const parts = [
|
||||
`Bạn đang ở vòng làm rõ số ${round}. File brief: ${BRIEF} (tạo mới nếu chưa có; nếu đã có thì đọc, gộp thông tin mới và tăng version).`,
|
||||
a.brief
|
||||
? `Mô tả dự án do người dùng cung cấp:\n"""\n${a.brief}\n"""`
|
||||
: `Không có brief mới trong prompt — đọc ${BRIEF} hiện có để đánh giá lại.`,
|
||||
a.answers
|
||||
? `Câu trả lời của người dùng cho các câu hỏi vòng trước (ghép vào Q&A log; câu nào người dùng chọn "dùng mặc định" thì đưa vào "Giả định đã chốt"):\n"""\n${a.answers}\n"""`
|
||||
: '',
|
||||
round >= 3
|
||||
? 'ĐÂY LÀ VÒNG CUỐI: với mọi khoảng trống còn lại, áp dụng proposedDefault, ghi vào "Giả định đã chốt" kèm rủi ro, và trả ready=true (trừ khi hoàn toàn không rõ hệ thống làm gì).'
|
||||
: '',
|
||||
].filter(Boolean).join('\n\n')
|
||||
|
||||
const intake = await agent(parts, { agentType: 'intake-analyst', label: 'intake-analyst', schema: INTAKE_SCHEMA })
|
||||
report.intake = intake
|
||||
if (!intake || !intake.ready) {
|
||||
log(`Brief chưa đủ thông tin (${intake ? intake.gaps.length : '?'} khoảng trống) — dừng để hỏi người dùng.`)
|
||||
return { stage: 'intake', ready: false, round, briefFile: BRIEF, intake }
|
||||
}
|
||||
log('Brief đã đủ thông tin để phân tích.')
|
||||
if (stage === 'intake') return { stage: 'intake', ready: true, round, briefFile: BRIEF, intake }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stage 1 — Requirements (mục 1, 2)
|
||||
// ---------------------------------------------------------------------------
|
||||
if (run('requirements')) {
|
||||
phase('Requirements')
|
||||
const req = await agent(
|
||||
`${common}\nSoạn mục 1 và mục 2 dựa trên ${BRIEF} (bao gồm mô hình tham chiếu đã xác nhận và giả định đã chốt). ` +
|
||||
`Ghi ra ${S}/01-tong-quan.md và ${S}/02-phan-tich-yeu-cau.md. ` +
|
||||
`Trong kết quả trả về, liệt kê đầy đủ requirements (id dạng FR-xx, title, priority) và entities (danh từ nghiệp vụ chuẩn hoá từ Glossary) để các mục sau dùng nhất quán.${notes}`,
|
||||
{ agentType: 'requirements-analyst', label: 'requirements-analyst', schema: REQ_SCHEMA },
|
||||
)
|
||||
if (req && Array.isArray(req.requirements)) requirementIds = req.requirements.map((r) => r.id)
|
||||
const g = gate('requirements', req, {
|
||||
requirements: req ? req.requirements || [] : [],
|
||||
entities: req ? req.entities || [] : [],
|
||||
})
|
||||
report.gates.push(g)
|
||||
if (stage === 'requirements') return g
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stage 2 — Architecture (mục 3)
|
||||
// ---------------------------------------------------------------------------
|
||||
if (run('architecture')) {
|
||||
phase('Architecture')
|
||||
const arch = await agent(
|
||||
`${common}\nĐọc ${S}/01-tong-quan.md và ${S}/02-phan-tich-yeu-cau.md, sau đó soạn mục 3. ` +
|
||||
`Ghi ra ${S}/03-kien-truc.md.${reqHint()}${notes}`,
|
||||
{ agentType: 'architecture-designer', label: 'architecture-designer', schema: SECTION_SCHEMA },
|
||||
)
|
||||
const g = gate('architecture', arch)
|
||||
report.gates.push(g)
|
||||
if (stage === 'architecture') return g
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stage 3 — Design fanout (mục 4, 5, 7 song song)
|
||||
// ---------------------------------------------------------------------------
|
||||
if (run('fanout')) {
|
||||
phase('Design Fanout')
|
||||
const want = (k) => !only || only.includes(k)
|
||||
const tasks = []
|
||||
if (want('api')) {
|
||||
tasks.push(() =>
|
||||
agent(
|
||||
`${common}\nĐọc ${S}/01-tong-quan.md (Glossary), ${S}/02-phan-tich-yeu-cau.md và ${S}/03-kien-truc.md, sau đó soạn mục 4. ` +
|
||||
`Ghi ra ${S}/04-api-design.md.${reqHint()}${notes}`,
|
||||
{ agentType: 'api-designer', label: 'api-designer', phase: 'Design Fanout', schema: SECTION_SCHEMA },
|
||||
).then((r) => ['api', r]),
|
||||
)
|
||||
}
|
||||
if (want('data')) {
|
||||
tasks.push(() =>
|
||||
agent(
|
||||
`${common}\nĐọc ${S}/01-tong-quan.md (Glossary), ${S}/02-phan-tich-yeu-cau.md và ${S}/03-kien-truc.md, sau đó soạn mục 5. ` +
|
||||
`Ghi ra ${S}/05-thiet-ke-du-lieu.md.${reqHint()}${notes}`,
|
||||
{ agentType: 'data-modeler', label: 'data-modeler', phase: 'Design Fanout', schema: SECTION_SCHEMA },
|
||||
).then((r) => ['data', r]),
|
||||
)
|
||||
}
|
||||
if (want('uiux')) {
|
||||
tasks.push(() =>
|
||||
agent(
|
||||
`${common}\nĐọc ${S}/01-tong-quan.md và ${S}/02-phan-tich-yeu-cau.md, sau đó soạn mục 7. ` +
|
||||
`Ghi ra ${S}/07-giao-dien.md.${reqHint()}${notes}`,
|
||||
{ agentType: 'uiux-designer', label: 'uiux-designer', phase: 'Design Fanout', schema: SECTION_SCHEMA },
|
||||
).then((r) => ['uiux', r]),
|
||||
)
|
||||
}
|
||||
if (!tasks.length) throw new Error('args.only không khớp: dùng các giá trị api | data | uiux')
|
||||
|
||||
const done = (await parallel(tasks)).filter(Boolean)
|
||||
const results = {}
|
||||
for (const [k, r] of done) results[k] = gate(k, r)
|
||||
const g = { stage: 'fanout', ok: done.length === tasks.length && done.every(([, r]) => !!r), results, requirementIds }
|
||||
report.gates.push(g)
|
||||
if (stage === 'fanout') return g
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stage 4 — Detailed design (mục 6)
|
||||
// ---------------------------------------------------------------------------
|
||||
if (run('detailed')) {
|
||||
phase('Detailed Design')
|
||||
const det = await agent(
|
||||
`${common}\nĐọc ${S}/02-phan-tich-yeu-cau.md, ${S}/04-api-design.md và ${S}/05-thiet-ke-du-lieu.md, sau đó soạn mục 6. ` +
|
||||
`Ghi ra ${S}/06-luong-xu-ly.md.${reqHint()}${notes}`,
|
||||
{ agentType: 'detailed-designer', label: 'detailed-designer', schema: SECTION_SCHEMA },
|
||||
)
|
||||
const g = gate('detailed', det)
|
||||
report.gates.push(g)
|
||||
if (stage === 'detailed') return g
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stage 5 — Security (mục 8, rà soát chéo)
|
||||
// ---------------------------------------------------------------------------
|
||||
if (run('security')) {
|
||||
phase('Security')
|
||||
const sec = await agent(
|
||||
`${common}\nĐọc ${S}/02-phan-tich-yeu-cau.md, ${S}/03-kien-truc.md, ${S}/04-api-design.md, ${S}/05-thiet-ke-du-lieu.md ` +
|
||||
`và ${S}/06-luong-xu-ly.md, sau đó soạn mục 8. Ghi ra ${S}/08-bao-mat.md. ` +
|
||||
`Mọi thiếu sót bảo mật phát hiện ở mục khác phải trả về trong findings (targetSection = số mục, VD "04").${reqHint()}${notes}`,
|
||||
{ agentType: 'security-architect', label: 'security-architect', schema: SECTION_SCHEMA },
|
||||
)
|
||||
const g = gate('security', sec)
|
||||
report.gates.push(g)
|
||||
if (stage === 'security') return g
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stage 6 — Test & Ops (mục 9)
|
||||
// ---------------------------------------------------------------------------
|
||||
if (run('testops')) {
|
||||
phase('Test & Ops')
|
||||
const ops = await agent(
|
||||
`${common}\nĐọc toàn bộ file 01–08 trong ${S}/, sau đó soạn mục 9. Ghi ra ${S}/09-van-hanh-kiem-thu.md. ` +
|
||||
`Mỗi Test Case phải gắn đúng 1 mã FR-xx; coveredRequirements = các FR đã có test case.${reqHint()}${notes}`,
|
||||
{ agentType: 'test-ops-planner', label: 'test-ops-planner', schema: SECTION_SCHEMA },
|
||||
)
|
||||
const g = gate('testops', ops)
|
||||
report.gates.push(g)
|
||||
if (stage === 'testops') return g
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stage 7 — Consolidate (mục 0 + ráp + rà soát)
|
||||
// ---------------------------------------------------------------------------
|
||||
if (run('consolidate')) {
|
||||
phase('Consolidate')
|
||||
const fin = await agent(
|
||||
`${common}\nĐọc ${BRIEF} và toàn bộ file 01–09 trong ${S}/ (file có thể có hoặc không có frontmatter — nếu không có, coi status là "unknown"). ` +
|
||||
`Soạn mục 0 (Document Control, kèm bảng trạng thái duyệt của từng mục), rà soát nhất quán/traceability xuyên suốt, ` +
|
||||
`và ráp toàn bộ thành 1 file hoàn chỉnh tại ${OUT} đúng cấu trúc introduction.md. ` +
|
||||
`Mọi mâu thuẫn/thiếu sót phát hiện trả về trong findings (targetSection = số mục); summary = "Ghi chú rà soát" ngắn gọn.${reqHint()}${notes}`,
|
||||
{ agentType: 'doc-consolidator', label: 'doc-consolidator', schema: SECTION_SCHEMA },
|
||||
)
|
||||
const g = gate('consolidate', fin, { outFile: OUT })
|
||||
report.gates.push(g)
|
||||
if (stage === 'consolidate') return g
|
||||
}
|
||||
|
||||
return report
|
||||
222
.claude/workflows/sa-pipeline.js
Normal file
222
.claude/workflows/sa-pipeline.js
Normal file
@@ -0,0 +1,222 @@
|
||||
export const meta = {
|
||||
name: 'sa-pipeline',
|
||||
description: 'Điều phối quy trình Solution Architect (sa-1…sa-4) từng bước có người duyệt: mỗi lần gọi chạy đúng MỘT stage/activity (--focus), hoặc audit (chấm AG1–AG4 + DTM/ADL, chỉ đọc), sign (ghi chữ ký duyệt của con người), sync (INDEX/ADL). Không có chế độ chạy liền.',
|
||||
whenToUse: 'Gọi từ skill sa-pipeline. Bắt buộc args.project, args.stage, args.date.',
|
||||
phases: [
|
||||
{ title: 'Init', detail: 'sa-lifecycle: cấu trúc sa-output + INDEX' },
|
||||
{ title: 'Audit', detail: 'sa-gate-auditor: chấm AG1–AG4, Confidence, DTM/ADL, đồng bộ BA (chỉ đọc)' },
|
||||
{ title: 'Context', detail: 'sa-1: driver, ràng buộc, as-is, phương án, TCO, rủi ro' },
|
||||
{ title: 'Architecture', detail: 'sa-2: QAS, ASR, SAD, ICD, DAT, SEC, INF, FAIL, ADR' },
|
||||
{ title: 'Enablement', detail: 'sa-3: guideline, fitness function, design review, tech debt' },
|
||||
{ title: 'Evolution', detail: 'sa-4: conformance, chi phí, drift, post-mortem, roadmap' },
|
||||
{ title: 'Sign', detail: 'Ghi quyết định duyệt/baseline/revise của con người' },
|
||||
{ title: 'Sync', detail: 'Cập nhật INDEX/ADL từ header thật' },
|
||||
],
|
||||
}
|
||||
|
||||
const TRACK = {
|
||||
label: 'SA',
|
||||
outputRoot: 'sa-output',
|
||||
runner: 'sa-stage-runner',
|
||||
auditor: 'sa-gate-auditor',
|
||||
lifecycleSkill: 'sa-lifecycle',
|
||||
traceSkill: 'sa-conformance',
|
||||
indexDir: '00-index',
|
||||
stages: {
|
||||
context: { phase: 'Context', skill: 'sa-1-context', dir: '01-context', gate: 'AG1', prevGate: null,
|
||||
activities: ['drivers', 'constraints', 'as-is', 'options', 'cost-risk'], externalPrereq: 'BA đã qua G1 (có BRIEF/GOAL/RQ)' },
|
||||
architecture: { phase: 'Architecture', skill: 'sa-2-architecture', dir: '02-architecture', gate: 'AG2', prevGate: 'AG1',
|
||||
activities: ['qas', 'asr', 'sad', 'icd', 'dat', 'sec', 'inf', 'fail', 'adr'], orderedPrefix: ['qas', 'asr', 'sad'] },
|
||||
enablement: { phase: 'Enablement', skill: 'sa-3-enablement', dir: '03-enablement', gate: 'AG3', prevGate: 'AG2',
|
||||
activities: ['agd', 'fit', 'review', 'debt'] },
|
||||
evolution: { phase: 'Evolution', skill: 'sa-4-evolution', dir: '04-evolution', gate: 'AG4', prevGate: 'AG3',
|
||||
activities: ['conformance', 'cost', 'drift', 'postmortem', 'review', 'roadmap'] },
|
||||
},
|
||||
gates: {
|
||||
AG1: 'PO + Tech Lead', AG2: 'Tech Lead + Security + Ops/SRE', AG3: 'Tech Lead + QA + SRE', AG4: 'PO + SRE + Enterprise Architect',
|
||||
},
|
||||
profileFields: [],
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// ENGINE (dùng chung cho ba-pipeline / sa-pipeline) — một stage mỗi lần gọi
|
||||
// project (bắt buộc) · stage (bắt buộc) · date YYYY-MM-DD (bắt buộc)
|
||||
// activity : một hoạt động trong stage (mặc định: cả stage, theo thứ tự)
|
||||
// scope : phạm vi (VD US-012,US-013 · CR-004 · --focus)
|
||||
// inputs[] : file/nguồn người dùng cung cấp (ưu tiên cao nhất)
|
||||
// answers : trả lời của người dùng cho OQ / humanInputNeeded
|
||||
// notes : ghi chú người duyệt (Revise)
|
||||
// override : true = người dùng khẳng định chạy dù gate trước chưa qua
|
||||
// profile : {product, lifecycle, rigor} (init, hoặc khi đã xác nhận)
|
||||
// approvals[] : (sign) {artifact, decision: approve|baseline|revise, approver, role, note}
|
||||
// gate, decisions[] : (sign) gate được ký, DEC cần ghi
|
||||
// outputRoot : mặc định TRACK.outputRoot
|
||||
// ===========================================================================
|
||||
const a = args && typeof args === 'object' ? args : {}
|
||||
const STAGE_NAMES = ['init', 'audit', ...Object.keys(TRACK.stages), 'sign', 'sync']
|
||||
if (!a.project) throw new Error('Thiếu args.project — tên project (một project mỗi lần chạy)')
|
||||
if (!STAGE_NAMES.includes(a.stage)) throw new Error(`args.stage không hợp lệ: "${a.stage}". Hợp lệ: ${STAGE_NAMES.join(' | ')}`)
|
||||
if (!a.date || !/^\d{4}-\d{2}-\d{2}$/.test(a.date)) throw new Error('Thiếu args.date (YYYY-MM-DD) — header artifact cần ngày thật')
|
||||
|
||||
const project = a.project
|
||||
const stage = a.stage
|
||||
const root = `${a.outputRoot || TRACK.outputRoot}/${project}`
|
||||
const skillsDir = '.claude/skills'
|
||||
const def = TRACK.stages[stage] || null
|
||||
|
||||
if (def && a.activity && !def.activities.includes(a.activity)) {
|
||||
throw new Error(`args.activity "${a.activity}" không thuộc stage ${stage}. Hợp lệ: ${def.activities.join(' | ')}`)
|
||||
}
|
||||
if (stage === 'init' && !a.profile && TRACK.profileFields.length) {
|
||||
throw new Error(`Stage init cần args.profile {${TRACK.profileFields.join(', ')}} đã được người dùng xác nhận`)
|
||||
}
|
||||
if (stage === 'sign' && !(Array.isArray(a.approvals) && a.approvals.length)) {
|
||||
throw new Error('Stage sign cần args.approvals[] {artifact, decision, approver, role, note?}')
|
||||
}
|
||||
|
||||
// ---------------- Schema ----------------
|
||||
const STR_ARR = { type: 'array', items: { type: 'string' } }
|
||||
const obj = (props, required) => ({ type: 'object', properties: props, required })
|
||||
const arr = (item) => ({ type: 'array', items: item })
|
||||
const S = { type: 'string' }
|
||||
const B = { type: 'boolean' }
|
||||
const N = { type: 'number' }
|
||||
|
||||
const STAGE_SCHEMA = obj({
|
||||
filesWritten: STR_ARR,
|
||||
artifacts: arr(obj({ code: S, path: S, version: S, status: S, confidence: S }, ['code', 'path', 'status'])),
|
||||
blocked: B,
|
||||
gateWarning: S,
|
||||
gateSelfCheck: arr(obj({ item: S, ok: B, note: S }, ['item', 'ok'])),
|
||||
openQuestions: arr(obj({ id: S, question: S, askWho: S, blocks: S, ifReversed: S }, ['id', 'question'])),
|
||||
humanInputNeeded: arr(obj({ topic: S, question: S, suggestedDefault: S }, ['topic', 'question'])),
|
||||
assumptions: STR_ARR,
|
||||
decisions: arr(obj({ id: S, text: S }, ['text'])),
|
||||
adrs: arr(obj({ id: S, title: S, status: S, radar: N }, ['id', 'title'])),
|
||||
baSyncIssues: arr(obj({ baArtifact: S, saArtifact: S, issue: S, action: S }, ['issue'])),
|
||||
tbdCount: N,
|
||||
ambiguousCount: N,
|
||||
confidence: { type: 'string', enum: ['high', 'medium', 'low'] },
|
||||
summary: S,
|
||||
}, ['filesWritten', 'blocked', 'gateSelfCheck', 'openQuestions', 'humanInputNeeded', 'confidence', 'summary'])
|
||||
|
||||
const AUDIT_SCHEMA = obj({
|
||||
profile: obj({ product: S, lifecycle: S, rigor: S, confirmed: B }, []),
|
||||
gates: arr(obj({ gate: S, status: { type: 'string', enum: ['✅', '🟠', '☐'] }, artifacts: STR_ARR, missing: STR_ARR, signersRequired: S, lowConfidence: STR_ARR }, ['gate', 'status', 'missing'])),
|
||||
currentPosition: S,
|
||||
blockersHard: STR_ARR,
|
||||
blockersSoft: STR_ARR,
|
||||
coverage: arr(obj({ check: S, value: S, pass: B, details: STR_ARR }, ['check', 'pass'])),
|
||||
baSync: arr(obj({ pair: S, issue: S, action: S }, ['pair', 'issue'])),
|
||||
overdueOQ: arr(obj({ id: S, askWho: S, sinceDate: S, blocks: S, ifReversed: S }, ['id'])),
|
||||
warnings: STR_ARR,
|
||||
nextActions: arr(obj({ action: S, skill: S }, ['action'])),
|
||||
summary: S,
|
||||
}, ['gates', 'currentPosition', 'nextActions', 'summary'])
|
||||
|
||||
const SIGN_SCHEMA = obj({
|
||||
filesEdited: STR_ARR,
|
||||
applied: arr(obj({ artifact: S, decision: S, newStatus: S, newVersion: S }, ['artifact', 'decision'])),
|
||||
refused: arr(obj({ artifact: S, reason: S }, ['artifact', 'reason'])),
|
||||
indexUpdated: B,
|
||||
summary: S,
|
||||
}, ['filesEdited', 'applied', 'refused', 'summary'])
|
||||
|
||||
// ---------------- Khối prompt dùng chung ----------------
|
||||
const lines = []
|
||||
lines.push(`Project: ${project}. Thư mục output: ${root}/. Ngày hôm nay (dùng cho header Date/Approved by): ${a.date}.`)
|
||||
lines.push(`Skill điều phối: ${skillsDir}/${TRACK.lifecycleSkill}/ (references bắt buộc) · skill truy vết: ${skillsDir}/${TRACK.traceSkill}/.`)
|
||||
if (a.profile) lines.push(`Profile đã được người dùng xác nhận: ${Object.keys(a.profile).map((k) => `${k}=${a.profile[k]}`).join(' · ')}.`)
|
||||
else if (TRACK.profileFields.length) lines.push(`Profile: đọc ${root}/${TRACK.indexDir}/PROFILE_${project}.md; chưa có ⇒ dùng mặc định standard và NÓI RÕ điều đó.`)
|
||||
if (Array.isArray(a.inputs) && a.inputs.length) lines.push(`Input bổ sung do người dùng cung cấp (ưu tiên cao nhất):\n${a.inputs.map((i) => `- ${i}`).join('\n')}`)
|
||||
if (a.answers) lines.push(`## Câu trả lời của người dùng cho OQ / humanInputNeeded vòng trước\n${a.answers}`)
|
||||
if (a.notes) lines.push(`## Ghi chú từ người duyệt (bắt buộc xử lý trước, version +0.1 và Change Log)\n${a.notes}`)
|
||||
if (a.override) lines.push(`## Ngoại lệ gate\nNgười dùng đã được cảnh báo gate trước chưa ✅ Baselined và KHẲNG ĐỊNH LẠI muốn tiếp tục. Làm tiếp, ghi ngoại lệ vào Open Questions + DEC-nn của artifact.`)
|
||||
const common = lines.join('\n\n')
|
||||
|
||||
const gateOf = (name) => (TRACK.stages[name] ? TRACK.stages[name].gate : null)
|
||||
const signersOf = (g) => (g && TRACK.gates[g]) || null
|
||||
|
||||
// ---------------- Kết quả trả về cho người duyệt ----------------
|
||||
function wrap(kind, res, extra) {
|
||||
const r = res || {}
|
||||
const out = {
|
||||
track: TRACK.label, project, stage, kind, ok: !!res,
|
||||
date: a.date, activity: a.activity || null, scope: a.scope || null,
|
||||
gate: def ? def.gate : (a.gate || null),
|
||||
signersRequired: signersOf(def ? def.gate : a.gate),
|
||||
result: r,
|
||||
}
|
||||
return extra ? Object.assign(out, extra) : out
|
||||
}
|
||||
|
||||
// ---------------- Stage: init ----------------
|
||||
if (stage === 'init') {
|
||||
phase('Init')
|
||||
const r = await agent(
|
||||
`${common}\n\nNhiệm vụ "init": làm đúng ${skillsDir}/${TRACK.lifecycleSkill}/SKILL.md (khởi tạo project mới). ` +
|
||||
`Tạo cấu trúc thư mục trong ${root}/, file PROFILE (nếu bộ này có profile) và INDEX theo mẫu artifact-map. ` +
|
||||
`Không tạo file rỗng cho giai đoạn sau. Nếu ${root}/ đã có nội dung ⇒ không ghi đè, trả blocked=true và nêu rõ.`,
|
||||
{ agentType: TRACK.runner, label: `${TRACK.label}:init`, schema: STAGE_SCHEMA },
|
||||
)
|
||||
return wrap('init', r)
|
||||
}
|
||||
|
||||
// ---------------- Stage: audit (chỉ đọc) ----------------
|
||||
if (stage === 'audit') {
|
||||
phase('Audit')
|
||||
const r = await agent(
|
||||
`${common}\n\nNhiệm vụ "audit" (CHỈ ĐỌC): chấm toàn bộ gate theo checklist của ${skillsDir}/${TRACK.lifecycleSkill}/references/workflow.md ` +
|
||||
`(điều chỉnh theo RIGOR nếu có), chạy các phép kiểm coverage/nhất quán của ${skillsDir}/${TRACK.traceSkill}/, ` +
|
||||
`liệt kê blocker cứng/mềm, OQ quá hạn (so với ngày ${a.date}), cảnh báo bắt buộc và tối đa 3 việc tiếp theo. ` +
|
||||
`✅ chỉ khi có chữ ký thật trong header. Không ghi file.${a.scope ? `\nPhạm vi cần soi kỹ: ${a.scope}` : ''}`,
|
||||
{ agentType: TRACK.auditor, label: `${TRACK.label}:audit`, schema: AUDIT_SCHEMA },
|
||||
)
|
||||
return wrap('audit', r)
|
||||
}
|
||||
|
||||
// ---------------- Stage: sign (ghi quyết định của con người) ----------------
|
||||
if (stage === 'sign') {
|
||||
phase('Sign')
|
||||
const list = a.approvals.map((p, i) =>
|
||||
`${i + 1}. ${p.artifact} — quyết định: ${p.decision}; người duyệt: ${p.approver}${p.role ? ` (${p.role})` : ''}${p.note ? `; ghi chú: ${p.note}` : ''}`).join('\n')
|
||||
const decs = Array.isArray(a.decisions) && a.decisions.length ? `\nDEC cần ghi vào sổ quyết định:\n${a.decisions.map((d) => `- ${d}`).join('\n')}` : ''
|
||||
const gateLine = a.gate ? `\nGate được ký: ${a.gate} — người ký yêu cầu theo quy trình: ${signersOf(a.gate) || 'xem workflow.md'}. Nếu danh sách người duyệt bên dưới không đủ vai trò ⇒ vẫn ghi Approved by nhưng KHÔNG baseline, và nêu trong refused.` : ''
|
||||
const r = await agent(
|
||||
`${common}\n\nNhiệm vụ "sign": ghi quyết định duyệt của CON NGƯỜI vào header/Change Log/INDEX theo đúng quy tắc trong system prompt của bạn. ` +
|
||||
`Không đổi nội dung chuyên môn. Từ chối baseline khi artifact chưa đủ điều kiện (còn TBD, thiếu header, checklist gate chưa đủ) và nêu lý do.${gateLine}\n\nDanh sách quyết định:\n${list}${decs}`,
|
||||
{ agentType: TRACK.runner, label: `${TRACK.label}:sign`, schema: SIGN_SCHEMA },
|
||||
)
|
||||
return wrap('sign', r, { gate: a.gate || null, signersRequired: signersOf(a.gate) })
|
||||
}
|
||||
|
||||
// ---------------- Stage: sync (INDEX) ----------------
|
||||
if (stage === 'sync') {
|
||||
phase('Sync')
|
||||
const r = await agent(
|
||||
`${common}\n\nNhiệm vụ "sync": cập nhật ${root}/${TRACK.indexDir}/INDEX_${project}.md (và sổ ADL/DTM/OQ/DECISION nếu bộ này có) ` +
|
||||
`từ header THẬT của các artifact theo ${skillsDir}/${TRACK.lifecycleSkill}/references/artifact-map.md. Không sửa artifact.`,
|
||||
{ agentType: TRACK.runner, label: `${TRACK.label}:sync`, schema: STAGE_SCHEMA },
|
||||
)
|
||||
return wrap('sync', r)
|
||||
}
|
||||
|
||||
// ---------------- Stage giai đoạn (ba-1…/sa-1…) ----------------
|
||||
phase(def.phase)
|
||||
const acts = a.activity ? [a.activity] : def.activities
|
||||
const prereq = [
|
||||
def.prevGate ? `Gate trước cần ✅ Baselined: ${def.prevGate}.` : 'Đây là giai đoạn đầu, không có gate trước trong bộ này.',
|
||||
def.externalPrereq ? `Điều kiện ngoài bộ: ${def.externalPrereq}.` : '',
|
||||
def.orderedPrefix ? `Thứ tự bắt buộc trong giai đoạn: ${def.orderedPrefix.join(' → ')} phải có trước các hoạt động khác.` : '',
|
||||
].filter(Boolean).join(' ')
|
||||
if (def.scopeHint && !a.scope) log(`Cảnh báo: stage ${stage} thường cần args.scope (${def.scopeHint}) — agent sẽ tự chọn và báo trong summary.`)
|
||||
|
||||
const r = await agent(
|
||||
`${common}\n\nNhiệm vụ: thực thi skill ${skillsDir}/${def.skill}/SKILL.md ở chế độ go, ` +
|
||||
`chỉ các hoạt động: ${acts.join(', ')}${a.scope ? ` · phạm vi: ${a.scope}` : ''}. Output vào ${root}/${def.dir}/. ` +
|
||||
`${prereq} Preflight gate trước khi ghi: chưa qua và không có "Ngoại lệ gate" ⇒ blocked=true, không ghi file. ` +
|
||||
`Gate của giai đoạn này: ${def.gate} (người ký: ${signersOf(def.gate)}) — bạn chỉ TỰ CHẤM checklist, không được ghi ✅ vào header.`,
|
||||
{ agentType: TRACK.runner, label: `${TRACK.label}:${stage}${a.activity ? ':' + a.activity : ''}`, schema: STAGE_SCHEMA },
|
||||
)
|
||||
if (r && r.blocked) log(`${TRACK.label}/${stage}: bị chặn bởi gate — ${r.gateWarning || 'xem result.gateWarning'}`)
|
||||
return wrap('stage', r, { activities: acts, nextGate: def.gate, signersRequired: signersOf(def.gate) })
|
||||
Reference in New Issue
Block a user