feat(process): a track:ask draft may be written during the spec review (#729)

On track:ask every spec review round is 10-45 minutes of waiting, and
rule #1 kept the author idle for all of it. Rule 10 (#738) judges a
class A commit by its author date, so code written in the
S4-spec-review epoch was always refused: nothing told a draft written
against the reviewed text from a violation. The owner allowed changing
rule #1 for this (decision 2026-10-01).

A draft commit carries `Spec-Draft: sha256:<issueBodyDigest(body)>`,
the hash the pipeline already writes as "Тело issue" into the review
document anchor. Rule 10 accepts a class A commit written in S4 only
when its S4 epoch (a repeated S4 does not restart it) was closed by
S5-ready, the track at the author date was ask, and the trailer equals
the body of the green, High 0 SPEC-REVIEW added inside that epoch, read
from the range head or origin/dev. The first failing check is the one
finding: trailer format, track, how the epoch ended, the missing
document with a `git fetch origin dev` hint, or both hashes and the
document name. A trailer on a commit written in an allowed epoch is a
warn. Without the document reader rule 10 is exactly #738; main always
passes one, and it reads git only when the range holds a draft.

The task packet tells S4 on ask that a local draft is allowed while the
branch stays closed, prints the trailer line, and in S5/S6 names the
green spec review, whether the body changed since, and the --report
check before push. SPEC-REVIEW documents are a separate input
(specDocs) from the branch and origin/dev, so the previous verdict and
the AC witness keep their source.

PROCESS.md gets §11.8 and the points that refer to it (§1, §2.4-2.6,
§3 item 1, §7.2, §9, §10.2 item 10, §12); AUTHOR.md, REVIEWER.md and
AGENTS.md follow, with three new key rules in process-digests. The
pre-push hook fixture copies the modules process-gate now imports.

Issue: #729
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
This commit is contained in:
Claude
2026-10-01 11:47:01 +03:00
parent 7ff2b5aeaa
commit 0e44f8a69f
10 changed files with 882 additions and 34 deletions
+189 -5
View File
@@ -42,6 +42,11 @@ import { resolveValidationRange } from './validate-commit-provenance.mjs';
// Классы изменений (PROCESS.md §1) живут в change-classes.mjs (#701): их
// читает и хук commit-msg, который судит, нужен ли коммиту трейлер.
import { classify } from './change-classes.mjs';
// #729: черновик `track:ask` судится теми же функциями, что конвейер: зелёный
// документ и «Тело issue» — по якорям review-doc-guard, трек — по меткам
// process-track. Ни один из модулей process-gate.mjs не импортирует.
import { anchorIssueBodyFrom, anchorVerdictFrom } from './review-doc-guard.mjs';
import { hasTrackLabel, trackFromLabels } from './process-track.mjs';
export { classify };
@@ -111,6 +116,8 @@ export function makeCommit({
release: one('Release'),
baselineReviewed: one('Baseline-Reviewed'),
baselineReviewedLocal: one('Baseline-Reviewed-Local'),
// #729: все значения, а не первое — «ровно один» судит правило 10.
specDrafts: all('Spec-Draft'),
gates: one('Gates'),
// null = вызывающий не доказал содержимое diff. Для stable release это
// намеренно fail-closed: одного имени разрешённого version source мало.
@@ -520,6 +527,96 @@ function statusIndexAt(events, w) {
const iso = (ms) => new Date(ms).toISOString();
// #729: черновик кода во время ревью ТЗ (PROCESS.md §11.8). Статус, в котором
// он пишется, трейлер и его значение — `issueBodyDigest` тела issue, тот же
// хеш, что конвейер пишет в «Тело issue:» документа ревью.
export const DRAFT_STATUS = 'S4-spec-review';
export const SPEC_DRAFT_VALUE = /^sha256:[0-9a-f]{64}$/;
// Трек на момент `w`: последнее событие трековой метки (`track:*`, прежние
// `small`/`trivial`) с `at ≤ w`, трек — по одной этой метке. Событий нет —
// `ask`: продуктовая задача без метки (§5.1).
function trackAt(rawEvents, w) {
const marks = rawEvents
.filter((e) => e && e.at && hasTrackLabel([e.label]))
.map((e) => ({ label: e.label, at: Date.parse(e.at) }))
.filter((e) => Number.isFinite(e.at) && e.at <= w)
.sort((a, b) => a.at - b.at);
const last = marks.at(-1);
return last
? { track: trackFromLabels([last.label]), label: last.label, at: last.at }
: { track: 'ask', label: null, at: null };
}
// Эпоха `S4` вокруг события `at`: начало — первое `S4` непрерывной серии
// (повторная постановка, reconcile #555, эпоху заново не начинает), конец —
// первое следующее статусное событие с другой меткой, либо `null`.
function draftEpoch(events, at) {
let first = at;
while (first > 0 && events[first - 1].label === DRAFT_STATUS) first -= 1;
const close = events.slice(at + 1).find((e) => e.label !== DRAFT_STATUS) ?? null;
return { start: events[first].at, close };
}
/**
* Зелёный SPEC-REVIEW эпохи `[start, end]` (#729): запись конвейера `green` с
* High 0, есть «Тело issue:», документ добавлен (`addedAt`, дата автора
* добавившего коммита) внутри окна. Из подходящих — наибольший заход.
*/
export function greenSpecReviewOf(docs, nn, start, end) {
const name = new RegExp(`^SPEC-REVIEW-${nn}-r(\\d+)\\.md$`);
let best = null;
for (const doc of docs || []) {
const m = name.exec(String(doc?.name ?? ''));
if (!m) continue;
const recorded = anchorVerdictFrom(doc.text);
if (!recorded || recorded.verdict !== 'green' || recorded.high !== 0) continue;
const body = anchorIssueBodyFrom(doc.text);
if (!body) continue;
const added = Date.parse(doc.addedAt);
if (!Number.isFinite(added) || added < start || added > end) continue;
const round = Number(m[1]);
if (!best || round > best.round) best = { name: doc.name, round, body, addedAt: added };
}
return best;
}
// Проверки чернового коммита по порядку (К3 п.2 #729); находка одна — по первой
// невыполненной. `null` — коммит принят как написанный в разрешённой эпохе.
function judgeDraft({ c, nn, events, at, rawEvents, rangeHead, docsOf }) {
const wrote = Date.parse(c.authorDate);
const fail = (msg) => ({ level: 'fail', rule: 10, sha: c.short, msg });
const lead = `issue #${nn}: коммит класса A написан ${c.authorDate} в ${DRAFT_STATUS}`;
const drafts = c.specDrafts ?? [];
if (drafts.length !== 1 || !SPEC_DRAFT_VALUE.test(drafts[0])) {
return fail(`${lead}: трейлер \`Spec-Draft\` должен быть ровно один, вида \`sha256:<64 hex>\` `
+ `(трейлеров ${drafts.length}) (§11.8)`);
}
const track = trackAt(rawEvents, wrote);
if (track.track !== 'ask') {
return fail(`${lead} на треке ${track.track} (${track.label} с ${iso(track.at)}): `
+ 'черновик разрешён только на `track:ask` (§11.8)');
}
const epoch = draftEpoch(events, at);
if (!epoch.close || epoch.close.label !== 'S5-ready') {
const closed = epoch.close ? `закрыта ${epoch.close.label} ${iso(epoch.close.at)}` : 'ещё не закрыта';
return fail(`${lead}: эпоха ${DRAFT_STATUS} с ${iso(epoch.start)} ${closed} — `
+ 'черновик принимается только из эпохи, которую закрыл `S5-ready` (§11.8)');
}
const green = greenSpecReviewOf(docsOf(), nn, epoch.start, epoch.close.at);
if (!green) {
return fail(`${lead}: эпоха ${iso(epoch.start)}…${iso(epoch.close.at)} — зелёный \`SPEC-REVIEW-${nn}-r*\` `
+ `этой эпохи не найден ни в ${rangeHead}, ни в origin/dev — \`git fetch origin dev\` и повторить (§11.8)`);
}
const draft = drafts[0].slice('sha256:'.length);
if (draft !== green.body) {
return fail(`${lead}: трейлер \`Spec-Draft\` sha256:${draft.slice(0, 12)} не равен «Тело issue» `
+ `sha256:${green.body.slice(0, 12)} из ${green.name} — ТЗ правилось — работа переделывается после S5, `
+ 'дата автора не переписывается (§12)');
}
return null;
}
// 10. DoR по моменту коммита (#311, #738). Правило 8 читает ТЕКУЩУЮ метку
// issue: нарушение «код написан вне статуса разработки» становится невидимым,
// как только статус штатно продвигается. Здесь статус задачи сверяется с
@@ -532,8 +629,15 @@ const iso = (ms) => new Date(ms).toISOString();
// authorDate переживает ребейзы конвейера — окно нарушения не закрывается.
// Проверка вторичная к правилу 8, поэтому недоступный timeline — warn, а не
// fail: основная fail-closed проверка статуса остаётся за правилом 8.
//
// #729: одно исключение поверх эпох — черновик `track:ask` (§11.8). Коммит,
// написанный в `S4-spec-review` с трейлером `Spec-Draft`, принимается, если
// эпоху `S4` закрыл `S5-ready`, трек на момент написания — `ask`, а трейлер
// равен «Тело issue» зелёного SPEC-REVIEW этой эпохи. Документы читает
// `specReviews` (`{ head, read(nn) }`, см. gitSpecReviewReader); не передан —
// исключение выключено, и находки с текстами равны #738.
export function checkCommitEraStatuses(
commits, timelineRunner, { allowed = ALLOWED_STATUS } = {},
commits, timelineRunner, { allowed = ALLOWED_STATUS, specReviews = null } = {},
) {
const out = [];
const byIssue = new Map();
@@ -567,11 +671,40 @@ export function checkCommitEraStatuses(
});
continue;
}
// #729: документы issue читаются один раз и только для чернового коммита.
let issueDocs = null;
const docsOf = () => {
if (issueDocs === null) issueDocs = specReviews.read(nn) ?? [];
return issueDocs;
};
for (const c of list) {
const wrote = Date.parse(c.authorDate);
if (!Number.isFinite(wrote)) continue;
const at = statusIndexAt(events, wrote);
if (at >= 0 && events[at].ready) continue;
const drafts = c.specDrafts ?? [];
if (at >= 0 && events[at].ready) {
// #729: коммит законен по #738, трейлер — шум; вероятно, он переехал
// вместе с кодом при переносе черновика.
if (specReviews && drafts.length) {
out.push({
level: 'warn', rule: 10, sha: c.short,
msg: `issue #${nn}: трейлер \`Spec-Draft\` на коммите, написанном в ${events[at].label}: `
+ `черновиком считается только коммит из \`${DRAFT_STATUS}\` (§11.8)`,
});
}
continue;
}
const inDraftStatus = Boolean(specReviews) && at >= 0 && events[at].label === DRAFT_STATUS;
if (inDraftStatus && drafts.length) {
const finding = judgeDraft({
c, nn, events, at, rawEvents: r.events, rangeHead: specReviews.head ?? '<head>', docsOf,
});
if (finding) out.push(finding);
continue;
}
const tail = inDraftStatus
? `; написан в \`${DRAFT_STATUS}\` — черновик \`track:ask\` принимается только с трейлером \`Spec-Draft\` (§11.8)`
: '';
let lastReady = -1;
for (let i = 0; i < at; i += 1) if (events[i].ready) lastReady = i;
if (lastReady < 0) {
@@ -579,7 +712,7 @@ export function checkCommitEraStatuses(
level: 'fail', rule: 10, sha: c.short,
msg: `issue #${nn}: коммит класса A написан ${c.authorDate}, `
+ `до первого достижения задачей статуса из ${allowed.join('/')} `
+ `(${iso(firstReady.at)}) — код раньше «Готово к разработке» (§12)`,
+ `(${iso(firstReady.at)}) — код раньше «Готово к разработке» (§12)${tail}`,
});
continue;
}
@@ -591,7 +724,7 @@ export function checkCommitEraStatuses(
level: 'fail', rule: 10, sha: c.short,
msg: `issue #${nn}: коммит класса A написан ${c.authorDate} в ${events[at].label} — `
+ `после возврата ${iso(returned.at)} и до повторного «Готово к разработке» `
+ `(${nextReady ? iso(nextReady.at) : 'ещё не достигнут'}) — код вне статуса разработки (§12)`,
+ `(${nextReady ? iso(nextReady.at) : 'ещё не достигнут'}) — код вне статуса разработки (§12)${tail}`,
});
}
}
@@ -640,6 +773,54 @@ function ghTimelineRunner(nwo, bin) {
};
}
/**
* Документы SPEC-REVIEW из git для исключения #729 (§11.8): вершина
* проверяемого диапазона и `refs/remotes/origin/dev`, если она есть. На issue
* SPEC-REVIEW лежит либо в ветке задачи, либо прямо в `dev` (ветки до `S5` у
* `ask` обычно нет), поэтому нужны обе ссылки. `addedAt` — дата автора
* коммита, добавившего путь на той ссылке, где документ найден: она переживает
* ребейзы конвейера. Одно имя на обеих ссылках — одна запись. Чтение ленивое:
* ни одной команды git, пока правило 10 не спросило документы issue, и один
* раз на issue.
*/
export function gitSpecReviewReader({ repo = process.cwd(), head = 'HEAD', run = spawnSync } = {}) {
const git = (args) => {
const r = run('git', ['-C', repo, ...args], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
return r && r.status === 0 ? r.stdout : null;
};
let refs = null;
const cache = new Map();
return {
head,
read(nn) {
if (cache.has(nn)) return cache.get(nn);
if (!refs) {
refs = [head];
if (git(['rev-parse', '--verify', '-q', 'refs/remotes/origin/dev']) !== null) refs.push('refs/remotes/origin/dev');
}
const name = new RegExp(`^docs/reviews/(SPEC-REVIEW-${nn}-r\\d+\\.md)$`);
const byName = new Map();
for (const ref of refs) {
const listing = git(['ls-tree', '--name-only', ref, 'docs/reviews/']);
if (listing === null) continue;
for (const path of listing.split('\n').map((s) => s.trim())) {
const m = name.exec(path);
if (!m || byName.has(m[1])) continue;
byName.set(m[1], {
name: m[1],
ref,
text: git(['show', `${ref}:${path}`]) ?? '',
addedAt: (git(['log', '-1', '--diff-filter=A', '--format=%aI', ref, '--', path]) ?? '').trim(),
});
}
}
const docs = [...byName.values()];
cache.set(nn, docs);
return docs;
},
};
}
function ghRunner(nwo, bin) {
return (nn) => {
const r = spawnSync(bin, ['issue', 'view', String(nn), '--repo', nwo, '--json', 'number,state,labels,body'],
@@ -833,10 +1014,13 @@ function main(argv) {
process.env.HP_REPO ?? 'Matysh/houseplan-card', process.env.GH_BIN ?? 'gh',
);
const timelineCache = new Map();
// #729: читатель SPEC-REVIEW передаётся всегда; git он трогает, только
// когда в диапазоне есть черновой коммит. Вершина — правая часть --range.
const rangeHead = /^(.*?)\.\.\.?(.+)$/.exec(range)?.[2] ?? range;
findings.push(...checkCommitEraStatuses(statusCommits, (nn) => {
if (!timelineCache.has(nn)) timelineCache.set(nn, timelineRunner(nn));
return timelineCache.get(nn);
}, { allowed }));
}, { allowed, specReviews: gitSpecReviewReader({ repo, head: rangeHead }) }));
}
labelsOf = (nn) => {
const r = cached(nn);
+119 -13
View File
@@ -8,10 +8,10 @@
// Скрипт делает эту сборку детерминированно и печатает один markdown-пакет:
//
// issue · статус и трек · трек: основание, лимит, ребейз (#707) · что можно
// делать в этом статусе · решения владельца · материал (ветка, SHA, база,
// Validate) · следующий шаг по ветке · риск по участкам · обязательные
// проверки · changelog и визуальное свидетельство · предыдущий вердикт ·
// AC → свидетель · непроверенное
// делать в этом статусе · черновик во время ревью ТЗ (#729) · решения
// владельца · материал (ветка, SHA, база, Validate) · следующий шаг по
// ветке · риск по участкам · обязательные проверки · changelog и визуальное
// свидетельство · предыдущий вердикт · AC → свидетель · непроверенное
//
// Источник правды остаётся GitHub и git: пакет ничего не пишет и ничего не
// решает. Все чтения инъектируемы — `buildPacket(inputs)` чист и покрыт тестами.
@@ -20,7 +20,9 @@
import { spawnSync } from 'node:child_process';
import { isMainModule } from './spawn-portable.mjs';
import { anchorTreeFrom, anchorVerdictFrom, verdictDeclaration } from './review-doc-guard.mjs';
import {
anchorIssueBodyFrom, anchorTreeFrom, anchorVerdictFrom, issueBodyDigest, verdictDeclaration,
} from './review-doc-guard.mjs';
import { classify } from './process-gate.mjs';
import {
classifyRisk, cycleLimit, hasTrackLabel, rebaseBeforeReview, riskClassLine, trackFromLabels, trackOrigin,
@@ -30,18 +32,28 @@ import { selectSmokes } from './smoke-select.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 } = {}) {
export function rightsFor(status, labels = [], { infrastructure = false, infrastructureHint = false, track = null } = {}) {
const blocked = labels.includes('blocked');
const exhausted = labels.includes('review-4');
const code = !infrastructure && ['S5-ready', 'S6-in-progress', 'S7-code-review'].includes(status);
// #729: на `track:ask` в `S4-spec-review` локальный черновик разрешён (§11.8),
// в ветку код по-прежнему только после `S5`; под blocked/review-4 — нет.
const specReview = !infrastructure && status === 'S4-spec-review' && track === 'ask';
const draft = specReview && !blocked && !exhausted;
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 (draft) {
lines.push('продуктовый код в ветку — НЕЛЬЗЯ до `S5` (правило №1)');
lines.push('черновик локально — МОЖНО (§11.8): ветка `issue/NN-slug` не пушится, трейлер — в разделе «Черновик»');
} else {
lines.push(code
? 'продуктовый код трогать МОЖНО (правило №1)'
: infrastructure
? 'файлы класса A трогать НЕЛЬЗЯ; инфраструктурную реализацию МОЖНО вести сразу по issue (#562)'
: 'продуктовый код трогать НЕЛЬЗЯ: статус не S5/S6/S7 (правило №1)');
}
if (specReview && !draft) lines.push(`черновик не ведётся (§11.8): ${exhausted ? 'review-4' : 'blocked'}`);
if (infrastructureHint) {
lines.push('метка infra — только подсказка, не доказательство и не право: до ветки проверь предполагаемые пути; без class A начинай сразу, при любом class A нужен продуктовый S-flow (#562)');
}
@@ -130,12 +142,64 @@ export function branchIsInfrastructure(changedFiles = []) {
return material.length > 0 && !material.some((name) => classify(name) === 'A');
}
/**
* Последний зелёный SPEC-REVIEW задачи (#729): запись конвейера `green` с
* High 0 и строкой «Тело issue:» — те же якоря, что читает гейт. Из
* подходящих — наибольший заход; нет — `null`.
*/
export function latestGreenSpecReview(specDocs = [], number = null) {
const name = new RegExp(`^SPEC-REVIEW-${number ?? '\\d+'}-r(\\d+)\\.md$`);
let best = null;
for (const doc of specDocs) {
const m = name.exec(String(doc?.name ?? ''));
if (!m) continue;
const recorded = anchorVerdictFrom(doc.text);
if (!recorded || recorded.verdict !== 'green' || recorded.high !== 0) continue;
const body = anchorIssueBodyFrom(doc.text);
if (!body) continue;
const round = Number(m[1]);
if (!best || round > best.round) best = { doc: doc.name, round, body };
}
return best;
}
/**
* Черновик кода во время ревью ТЗ (#729, PROCESS.md §11.8): можно ли вести его
* сейчас, строка трейлера и зелёное ревью ТЗ, с которым гейт сверит черновые
* коммиты. Хеш — `issueBodyDigest`, та же функция, что пишет «Тело issue:» в
* якорь документа ревью.
*/
export function specDraftState({ status = null, labels = [], track = null, body = '', specDocs = [], number = null } = {}) {
const bodyNow = issueBodyDigest(body ?? '');
const stop = labels.includes('review-4') ? 'review-4' : labels.includes('blocked') ? 'blocked' : null;
let allowed = false;
let reason;
if (status !== 'S4-spec-review') reason = `статус ${status || 'без S-метки'}: черновик ведётся только в S4-spec-review (§11.8)`;
else if (track !== 'ask') reason = `трек ${track}: черновик только на track:ask (§11.8)`;
else if (stop) reason = `${stop}: черновик не ведётся (§11.8)`;
else {
allowed = true;
reason = 'track:ask в S4-spec-review: черновик можно вести локально (§11.8)';
}
const green = latestGreenSpecReview(specDocs, number);
return {
allowed,
reason,
trailer: allowed ? `Spec-Draft: sha256:${bodyNow}` : null,
green,
bodyNow,
bodyChanged: green ? green.body !== bodyNow : null,
};
}
const PRE_CODE_STATUSES = ['S1-new', 'S2-analysis', 'S3-spec', 'S4-spec-review', 'S5-ready'];
// Трек по меткам — одна функция на конвейер и пакет (#696): process-track.mjs.
// С #707 оттуда же основание трека, лимит циклов, политика ребейза и риск по
// изменённым участкам: пакет не держит своей копии правила.
export { classifyRisk, cycleLimit, hasTrackLabel, rebaseBeforeReview, trackFromLabels, trackOrigin };
// Хеш тела issue — одна функция с конвейером и гейтом (#729): review-doc-guard.mjs.
export { issueBodyDigest };
/** Зеркала junction limits: правка любого требует parity (§8, #548). */
export const JUNCTION_MIRRORS = Object.freeze([
@@ -254,6 +318,7 @@ export function productFlowEvidence({ status = null, labels = [], issue = {}, sp
export function buildPacket(inputs) {
const {
issue, labels = [], comments = [], owner = 'Matysh', branch = null, specs = [], reviewDocs = [], validate = null,
specDocs = [],
} = inputs;
const status = STATUS_LABELS.find((l) => labels.includes(l)) || null;
// Трек сначала определяется статусом и историей issue (#632): прошедшая
@@ -300,7 +365,13 @@ export function buildPacket(inputs) {
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, trackDetail, labels, productFlow, rights: rightsFor(status, labels, { infrastructure, infrastructureHint }),
status, track, trackDetail, labels, productFlow,
rights: rightsFor(status, labels, { infrastructure, infrastructureHint, track: infrastructure ? null : origin.track }),
// #729: свой вход `specDocs` — lastVerdict, AC → свидетель и признаки
// продуктового потока по-прежнему читают только reviewDocs.
specDraft: specDraftState({
status, labels, track: infrastructure ? null : origin.track, body: issue.body, specDocs, number: issue.number,
}),
decisions: ownerDecisions(comments, owner),
material: branch ? {
branch: branch.name, tip: branch.tip, base: branch.base, ahead: branch.ahead, behind: branch.behind,
@@ -337,6 +408,26 @@ export function renderPacket(p) {
L.push('## Права и следующий шаг');
for (const r of p.rights) L.push(`- ${r}`);
L.push('');
const draft = p.specDraft;
const askTrack = p.trackDetail?.track === 'ask';
if (draft && askTrack && p.status === 'S4-spec-review') {
L.push('## Черновик (#729)');
if (draft.allowed) {
L.push(`- трейлер каждого чернового коммита: \`${draft.trailer}\``);
L.push('- ветку не пушить до `S5`; `S6` — после зелёного ревью ТЗ; комментарий «Черновик:» (§7.2); черновик занимает слот WIP');
L.push('- жёлтый или красный вердикт — черновик остановить: коммиты по прежнему тексту гейт не примет');
} else L.push(`- ${draft.reason}`);
L.push('');
}
if (draft?.green && askTrack && (p.status === 'S5-ready' || p.status === 'S6-in-progress')) {
L.push('## Черновик (#729)');
L.push(`- зелёное ревью ТЗ: \`${draft.green.doc}\` · тело \`sha256:${draft.green.body.slice(0, 12)}\` — черновые коммиты принимаются с этим хешем`);
L.push(draft.bodyChanged
? '- тело issue сейчас: изменилось после зелёного ревью ТЗ (ревьюер кода получит находку, #517)'
: '- тело issue сейчас: совпадает');
L.push('- проверка до push: `node scripts/process-gate.mjs --range origin/dev..HEAD --issues --report`');
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})` : ''}`);
@@ -449,6 +540,21 @@ export function collectInputs({ number, repo = 'Matysh/houseplan-card', cwd = pr
changedFiles, diff, commits, smokes, mergeClean: merge.clean, conflicts: merge.conflicts,
};
}
// #729: SPEC-REVIEW — отдельный вход `specDocs` из ветки на origin и из
// `origin/dev`: на ask документ ревью ТЗ обычно ложится прямо в dev. Источник
// reviewDocs (lastVerdict, AC → свидетель) не меняется.
const specByName = new Map();
for (const ref of [...(refs[0] ? [`origin/${refs[0]}`] : []), 'origin/dev']) {
let names = [];
try {
names = sh('git', ['ls-tree', '--name-only', `${ref}:docs/reviews`], { cwd }).split('\n')
.filter((n) => new RegExp(`^SPEC-REVIEW-${number}-r\\d+\\.md$`).test(n));
} catch { continue; }
for (const n of names) {
if (!specByName.has(n)) specByName.set(n, { name: n, text: sh('git', ['show', `${ref}:docs/reviews/${n}`], { cwd }) });
}
}
const specDocs = [...specByName.values()].sort((a, b) => a.name.localeCompare(b.name));
let validate = null;
if (branch) {
try {
@@ -459,7 +565,7 @@ export function collectInputs({ number, repo = 'Matysh/houseplan-card', cwd = pr
: 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 };
return { issue: view, labels, comments, owner, branch, specs, reviewDocs, specDocs, validate };
}
if (isMainModule(import.meta.url)) {