#!/usr/bin/env node // Пакет задачи — производное представление, а не новый источник статуса (#496). // // Агент, берущий задачу, сегодня собирает одно и то же руками из четырёх мест: // метки и комментарии issue, ветку и её положение относительно dev, документы // ревью с их якорями, состояние Validate на вершине. Каждый раз — свои ходы, // свои ошибки («метка встала раньше push'а», «ревьюер читал не тот SHA»). // Скрипт делает эту сборку детерминированно и печатает один markdown-пакет: // // issue · статус и трек · что можно делать в этом статусе · решения владельца · // материал (ветка, SHA, база, Validate) · предыдущий вердикт · AC → свидетель · // непроверенное / следующий шаг // // Источник правды остаётся GitHub и git: пакет ничего не пишет и ничего не // решает. Все чтения инъектируемы — `buildPacket(inputs)` чист и покрыт тестами. // // node scripts/task-packet.mjs --issue 437 [--repo Matysh/houseplan-card] [--json] import { spawnSync } from 'node:child_process'; import { isMainModule } from './spawn-portable.mjs'; import { anchorTreeFrom, anchorVerdictFrom, verdictDeclaration } from './review-doc-guard.mjs'; import { classify } from './process-gate.mjs'; export const STATUS_LABELS = ['S1-new', 'S2-analysis', 'S3-spec', 'S4-spec-review', 'S5-ready', 'S6-in-progress', 'S7-code-review', 'S8-merged']; /** Что разрешено в статусе — по PROCESS.md, без домыслов. */ export function rightsFor(status, labels = [], { infrastructure = false, infrastructureHint = false } = {}) { const blocked = labels.includes('blocked'); const exhausted = labels.includes('review-4'); const code = !infrastructure && ['S5-ready', 'S6-in-progress', 'S7-code-review'].includes(status); const lines = []; if (exhausted) lines.push('review-4: лимит циклов исчерпан — решение владельца (разделить, отклонить, арбитраж); дальше не двигать'); if (blocked) lines.push('blocked: работа стоит, ждём внешнего решения — коммиты по задаче гейт не пропустит'); lines.push(code ? 'продуктовый код трогать МОЖНО (правило №1)' : infrastructure ? 'файлы класса A трогать НЕЛЬЗЯ; инфраструктурную реализацию МОЖНО вести сразу по issue (#562)' : 'продуктовый код трогать НЕЛЬЗЯ: статус не S5/S6/S7 (правило №1)'); if (infrastructureHint) { lines.push('метка infra — только подсказка, не доказательство и не право: до ветки проверь предполагаемые пути; без class A начинай сразу, при любом class A нужен продуктовый S-flow (#562)'); } switch (status) { case 'S1-new': lines.push('следующий шаг: аналитика (S2) — оценки метками, критерий лёгкого трека, затем ТЗ'); break; case 'S2-analysis': lines.push('следующий шаг: ТЗ (S3); трек по умолчанию small — отказ от него обосновать названным критерием §5'); break; case 'S3-spec': lines.push('следующий шаг: ТЗ готово → push ветки → метка S4-spec-review (метку после push)'); break; case 'S4-spec-review': lines.push('идёт ревью ТЗ: ждать вердикт (scripts/wait-verdict.mjs), не править материал'); break; case 'S5-ready': lines.push('следующий шаг: ветка issue/NN-slug от dev, код по ТЗ, метка S6-in-progress'); break; case 'S6-in-progress': lines.push('следующий шаг: gate:small + смоки по AC → push ветки → метка S7-code-review (метку после push)'); break; case 'S7-code-review': lines.push('идёт код-ревью: ждать вердикт, ничего не пушить в ветку — вердикт привязан к SHA (#312)'); break; case 'S8-merged': lines.push('код в dev, ждёт беты; ничего не делать; issue закроет владелец при выпуске'); break; default: lines.push(infrastructure ? 'инфраструктурный вход: реализовать и проверить → push ветки → S7-code-review; ТЗ и S1–S6 не нужны (#562)' : infrastructureHint ? 'предварительный инфраструктурный вход: после проверки отсутствия class A реализовать → push ветки → S7-code-review; diff станет окончательным доказательством' : 'статусной метки нет — продуктовая задача вне процесса; вход — первая S*-метка владельца'); } return lines; } /** AC из текста ТЗ или тела issue: строки таблицы `| ACn |` и маркеры `ACn` в начале. */ export function extractAcceptanceCriteria(text) { const out = new Map(); for (const raw of String(text ?? '').split('\n')) { const line = raw.trim(); let m = line.match(/^\|\s*\**(AC\d+)\**\s*\|\s*([^|]*)\|/); if (!m) m = line.match(/^(?:[-*]\s*)?\**(AC\d+)\**[.:)\s-]+(.*)$/); if (!m) continue; if (!out.has(m[1])) out.set(m[1], m[2].trim().slice(0, 160)); } return [...out.entries()].map(([id, text]) => ({ id, text })); } /** Где в документе ревью упомянут AC: «проверен» или «без записи». */ export function evidenceFor(acs, reviewDocText) { const text = String(reviewDocText ?? ''); return acs.map((ac) => { const re = new RegExp(`(^|[^A-Z0-9])(${ac.id})(?![0-9])`, 'm'); const m = re.exec(text); if (!m) return { ...ac, evidence: 'без записи в последнем документе ревью' }; const start = m.index + m[1].length; const line = text.slice(start).split('\n')[0].replace(/^\W+/, '').slice(0, 140); return { ...ac, evidence: line }; }); } /** Решения владельца: его комментарии с заголовками/словами решения, свежие первыми. */ export function ownerDecisions(comments, owner) { return (comments || []) .filter((c) => c.author === owner && /решени|ответ|принима|утвержда|делаем|не делаем|owner|decision/i.test(String(c.body))) .slice(-5) .map((c) => ({ at: c.createdAt, head: String(c.body).trim().split('\n')[0].replace(/^#+\s*/, '').slice(0, 140), url: c.url })); } /** Последний вердикт этапа по комментариям (страховка) и по документу (запись конвейера). */ export function lastVerdict(comments, docs, stage) { const marker = stage === 'spec' ? 'SPEC-REVIEW' : 'CODE-REVIEW'; const fromComments = (comments || []).map((c) => ({ ...c, line: verdictDeclaration(c.body) })) .filter((c) => c.line && new RegExp(`${marker}|заход r\\d+`).test(c.body)); const latestComment = fromComments.at(-1) || null; const numbered = (docs || []) .map((d) => ({ ...d, round: Number((d.name.match(/-r(\d+)\.md$/) || [])[1]) })) .filter((d) => d.name.startsWith(`${marker}-`) && Number.isFinite(d.round)) .sort((a, b) => b.round - a.round); const latestDoc = numbered[0] || null; return { comment: latestComment ? { at: latestComment.createdAt, line: latestComment.line.trim(), url: latestComment.url } : null, doc: latestDoc ? { name: latestDoc.name, round: latestDoc.round, tree: anchorTreeFrom(latestDoc.text), recorded: anchorVerdictFrom(latestDoc.text), } : null, }; } /** * Дифф ветки как доказательство ускоренного инфраструктурного трека (#562). * Документы ревью (`docs/reviews/**`, класс C) пишет конвейер и сама ветка * ревью ТЗ — они не материал задачи и классификацию не двигают (#632): ветка * продуктовой S6-задачи до первого кодового коммита содержит только * SPEC-REVIEW и не должна выглядеть инфраструктурной. */ export function branchIsInfrastructure(changedFiles = []) { const material = changedFiles.filter((name) => !name.startsWith('docs/reviews/')); return material.length > 0 && !material.some((name) => classify(name) === 'A'); } const PRE_CODE_STATUSES = ['S1-new', 'S2-analysis', 'S3-spec', 'S4-spec-review', 'S5-ready']; /** * Признаки продуктового S-flow (#632). Инфраструктурная задача входит в поток * сразу на S7 и никогда не несёт S1–S5, ТЗ и ревью ТЗ; поэтому любой из этих * признаков делает эвристику «дифф без класса A» неприменимой. S6/S7/S8 сами по * себе признаком не являются: их носит и инфраструктурная задача после ревью. * Метка `trivial` — признак сама по себе (r1 #632): короткий трек (PROCESS §5.1) * идёт S2 → S5 без ТЗ и без ревью ТЗ, и в S6/S7 никакого другого следа потока у * него нет. Ускоренный инфраструктурный вход понятия трека не имеет, поэтому * `trivial` на нём не бывает; `infra` рядом с ней — тематическая метка. */ export function productFlowEvidence({ status = null, labels = [], issue = {}, specs = [], reviewDocs = [], comments = [] } = {}) { const reasons = []; if (PRE_CODE_STATUSES.includes(status)) reasons.push(`статус ${status}`); if (labels.includes('trivial')) reasons.push('короткий трек trivial (ТЗ не пишется, §5.1)'); if (/^#{1,3}\s*ТЗ(?![\p{L}\p{N}_])/mu.test(String(issue?.body ?? ''))) reasons.push('раздел «## ТЗ» в теле issue'); if (specs.length) reasons.push('файл ТЗ в docs/specs'); if (reviewDocs.some((d) => String(d.name).startsWith('SPEC-REVIEW-'))) reasons.push('документ ревью ТЗ'); else if (comments.some((c) => verdictDeclaration(c.body) && /SPEC-REVIEW-\d+/.test(String(c.body)))) reasons.push('вердикт ревью ТЗ в комментариях'); return reasons; } export function buildPacket(inputs) { const { issue, labels = [], comments = [], owner = 'Matysh', branch = null, specs = [], reviewDocs = [], validate = null, } = inputs; const status = STATUS_LABELS.find((l) => labels.includes(l)) || null; // Трек сначала определяется статусом и историей issue (#632): прошедшая // S3/S4/S5 или несущая ТЗ задача — продуктовая, и её право на класс A в // S5–S7 не отнимается пустым пока диффом. const productFlow = productFlowEvidence({ status, labels, issue, specs, reviewDocs, comments }); // `infra` — тематическая метка и не даёт процессных прав. Ускоренный трек // доказывается тем же механическим признаком, что process-gate: в реальном // diff опубликованной ветки нет ни одного файла класса A — и только вне // продуктового потока. const infrastructure = branch?.infrastructure === true && productFlow.length === 0; const infrastructureHint = branch == null && status == null && labels.includes('infra') && productFlow.length === 0; const track = infrastructure ? 'инфраструктурный' : infrastructureHint ? 'инфраструктурный (предварительно; подтвердить путями/diff)' : labels.includes('trivial') ? 'trivial' : labels.includes('small') ? 'small' : 'полный'; const stage = status === 'S4-spec-review' || status === 'S3-spec' || status === 'S5-ready' ? 'spec' : 'code'; const verdict = lastVerdict(comments, reviewDocs, stage); // ТЗ живёт в теле issue (#517); архивный файл — источник только у задач до // перехода, у которых в теле AC нет. Порядок именно такой: тело правится и // после создания файла, и тогда файл описывает не тот текст, что читает // ревьюер. const fromBody = extractAcceptanceCriteria(issue.body); const acSource = fromBody.length || !specs.length ? issue.body : specs.map((s) => s.text).join('\n'); const acs = evidenceFor(extractAcceptanceCriteria(acSource), reviewDocs.length ? reviewDocs.at(-1).text : ''); const unverified = acs.filter((a) => a.evidence.startsWith('без записи')); const packet = { issue: { number: issue.number, title: issue.title, state: issue.state, url: issue.url }, status, track, labels, productFlow, rights: rightsFor(status, labels, { infrastructure, infrastructureHint }), decisions: ownerDecisions(comments, owner), material: branch ? { branch: branch.name, tip: branch.tip, base: branch.base, ahead: branch.ahead, behind: branch.behind, treeMatchesVerdict: verdict.doc?.tree ? branch.treeWithoutReviews === verdict.doc.tree : null, validate: validate || { status: 'неизвестно' }, } : null, verdict, acceptance: acs, unverified: unverified.map((a) => a.id), }; return packet; } export function renderPacket(p) { const L = []; L.push(`# Пакет задачи #${p.issue.number} — ${p.issue.title}`); L.push(''); L.push(`Статус: **${p.status || 'без S-метки'}** · трек: ${p.track} · метки: ${p.labels.join(', ') || '—'} · issue ${p.issue.state}`); if (p.productFlow?.length) L.push(`Продуктовый поток: ${p.productFlow.join(', ')} — дифф без класса A трек не меняет (#632)`); L.push(''); L.push('## Права и следующий шаг'); for (const r of p.rights) L.push(`- ${r}`); L.push(''); L.push('## Решения владельца (свежие)'); if (!p.decisions.length) L.push('- не найдены (комментарии владельца со словами решения отсутствуют)'); for (const d of p.decisions) L.push(`- ${d.at?.slice(0, 16) || ''} — ${d.head}${d.url ? ` (${d.url})` : ''}`); L.push(''); L.push('## Материал'); if (!p.material) L.push('- ветки issue/NN-* на origin нет — материал не запушен'); else { const m = p.material; L.push(`- ветка \`${m.branch}\`, вершина \`${m.tip.slice(0, 12)}\`, база dev \`${m.base.slice(0, 12)}\`: впереди ${m.ahead}, позади ${m.behind}${m.behind ? ' — перед S7 ребейз (rebase-on-dev.mjs при конфликте в бандле)' : ''}`); L.push(`- Validate на вершине: ${m.validate.status}${m.validate.url ? ` (${m.validate.url})` : ''}`); if (m.treeMatchesVerdict === true) L.push('- дерево вне docs/reviews совпадает с материалом последнего вердикта — повторный S7 применит его без модели (#499)'); if (m.treeMatchesVerdict === false) L.push('- дерево изменилось с последнего вердикта — будет полный разбор'); } L.push(''); L.push('## Предыдущий вердикт'); if (p.verdict.comment) L.push(`- комментарий: ${p.verdict.comment.line}${p.verdict.comment.url ? ` (${p.verdict.comment.url})` : ''}`); if (p.verdict.doc) L.push(`- документ: \`${p.verdict.doc.name}\`, дерево \`${p.verdict.doc.tree?.slice(0, 12) || '—'}\`, запись конвейера: ${p.verdict.doc.recorded ? `${p.verdict.doc.recorded.verdict} · High ${p.verdict.doc.recorded.high}` : 'нет (документ до #499)'}`); if (!p.verdict.comment && !p.verdict.doc) L.push('- вердиктов этапа ещё не было'); L.push(''); L.push('## AC → свидетель (по последнему документу ревью)'); if (!p.acceptance.length) L.push('- AC не распознаны: ни таблицы `| ACn |`, ни строк `ACn:` в ТЗ/теле issue'); for (const a of p.acceptance) L.push(`- **${a.id}** ${a.text} → ${a.evidence}`); L.push(''); L.push('## Непроверенное'); L.push(p.unverified.length ? `- ${p.unverified.join(', ')}: нет записи в последнем документе ревью — свидетель нужен до S7` : '- по документу ревью все распознанные AC имеют запись'); return `${L.join('\n')}\n`; } // ---- сбор входов: gh + git ------------------------------------------------- function sh(cmd, args, { cwd } = {}) { const r = spawnSync(cmd, args, { encoding: 'utf8', cwd, maxBuffer: 64 * 1024 * 1024 }); if (r.error) throw r.error; if (r.status !== 0) throw new Error(`${cmd} ${args.join(' ')} → ${(r.stderr || '').trim()}`); return (r.stdout || '').trim(); } export function collectInputs({ number, repo = 'Matysh/houseplan-card', cwd = process.cwd() }) { const view = JSON.parse(sh('gh', ['issue', 'view', String(number), '--repo', repo, '--json', 'number,title,body,state,url,labels,comments'])); const labels = (view.labels || []).map((l) => l.name); const comments = (view.comments || []).map((c) => ({ author: c.author?.login, body: c.body, createdAt: c.createdAt, url: c.url })); const owner = repo.split('/')[0]; try { sh('git', ['fetch', '-q', 'origin', 'dev', `+refs/heads/issue/${number}-*:refs/remotes/origin/issue/${number}-*`], { cwd }); } catch { /* офлайн — читаем что есть */ } const refs = sh('git', ['for-each-ref', '--sort=-committerdate', '--format=%(refname:lstrip=3)', `refs/remotes/origin/issue/${number}-*`], { cwd }).split('\n').filter(Boolean); let branch = null; let reviewDocs = []; let specs = []; if (refs[0]) { const name = refs[0]; const ref = `origin/${name}`; const tip = sh('git', ['rev-parse', ref], { cwd }); const base = sh('git', ['merge-base', 'origin/dev', ref], { cwd }); const ahead = Number(sh('git', ['rev-list', '--count', `origin/dev..${ref}`], { cwd })); const behind = Number(sh('git', ['rev-list', '--count', `${ref}..origin/dev`], { cwd })); const changedFiles = sh('git', ['diff', '--name-only', `${base}..${ref}`], { cwd }).split('\n').filter(Boolean); const infrastructure = branchIsInfrastructure(changedFiles); // Дерево без docs/reviews — для сравнения с якорем вердикта: git сам его не даёт, // поэтому сравнение делается diff'ом при известном якоре (см. ниже). const names = sh('git', ['ls-tree', '--name-only', `${ref}:docs/reviews`], { cwd }).split('\n').filter((n) => new RegExp(`-${number}-r\\d+\\.md$`).test(n)); reviewDocs = names.sort().map((n) => ({ name: n, text: sh('git', ['show', `${ref}:docs/reviews/${n}`], { cwd }) })); const specNames = sh('git', ['ls-tree', '--name-only', `${ref}:docs/specs`], { cwd }).split('\n').filter((n) => new RegExp(`^${number}-.*\\.md$`).test(n)); specs = specNames.map((n) => ({ name: n, text: sh('git', ['show', `${ref}:docs/specs/${n}`], { cwd }) })); const anchorTree = reviewDocs.length ? anchorTreeFrom(reviewDocs.at(-1).text) : null; let treeWithoutReviews = null; if (anchorTree) { const same = spawnSync('git', ['diff', '--quiet', anchorTree, tip, '--', '.', ':!docs/reviews', ':!legacy/reviews'], { cwd }); treeWithoutReviews = same.status === 0 ? anchorTree : `differs-from-${anchorTree}`; } branch = { name, tip, base, ahead, behind, treeWithoutReviews, infrastructure }; } let validate = null; if (branch) { try { const runs = JSON.parse(sh('gh', ['run', 'list', '--repo', repo, '--workflow', 'validate.yml', '--commit', branch.tip, '--limit', '5', '--json', 'status,conclusion,url'])); const green = runs.find((r) => r.status === 'completed' && r.conclusion === 'success'); const running = runs.find((r) => r.status !== 'completed'); validate = green ? { status: 'зелёный', url: green.url } : running ? { status: 'идёт', url: running.url } : runs[0] ? { status: `красный (${runs[0].conclusion})`, url: runs[0].url } : { status: 'прогона нет' }; } catch { validate = { status: 'неизвестно (gh run list недоступен)' }; } } return { issue: view, labels, comments, owner, branch, specs, reviewDocs, validate }; } if (isMainModule(import.meta.url)) { const argv = process.argv.slice(2); const value = (name) => { const eq = argv.find((a) => a.startsWith(`--${name}=`)); if (eq) return eq.slice(name.length + 3); const at = argv.indexOf(`--${name}`); return at >= 0 ? (argv[at + 1] || '') : ''; }; const number = Number(value('issue')); if (!Number.isInteger(number) || number <= 0) { console.error('нужен --issue <номер>'); process.exit(2); } const repo = value('repo') || 'Matysh/houseplan-card'; const packet = buildPacket(collectInputs({ number, repo })); process.stdout.write(argv.includes('--json') ? `${JSON.stringify(packet, null, 2)}\n` : renderPacket(packet)); }