mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 14:06:50 +01:00
* fix(owner-brief): the Captain brief resolves rec through parseReply, so carried decisions match gstack-gate render (#3546) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(owner-brief): the Captain brief keeps answers already recorded at this gate, as answer --reply rec does Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(owner-brief): the do-it line says every choice when rec keeps earlier answers Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
124 lines
9.5 KiB
TypeScript
124 lines
9.5 KiB
TypeScript
/**
|
|
* captain-brief — the plan-approval ask in the shape Capy Captains relay to
|
|
* their person (capy/skill/gstack/captain.md): a two-line header with the
|
|
* one-word reply, then Before → after, Why it matters, What caused this work,
|
|
* Big decisions (each read from the reviewed plan through the shared heading
|
|
* parser) and What we need from you (built from gate.json: every choice `rec`
|
|
* takes, in words, closed by the "do it" reply: rec at full scope, follow-ons
|
|
* included, carried through with no further ask). Gate ids, gate_rev and rec_hash appear only on the last
|
|
* line, which is for the relaying agent. A plan missing a section yields a
|
|
* brief that says it is not ready for approval and has no reply invitation
|
|
* (CAPTAIN_BRIEF_INCOMPLETE). Called by bin/gstack-owner-brief render --captain.
|
|
*/
|
|
import { createHash } from 'node:crypto';
|
|
import * as fs from 'node:fs';
|
|
import * as path from 'node:path';
|
|
import { gateCost, gateCostSentence, priorChoices } from './autoplan-gate-cost';
|
|
import { parseReply, recHash, type GateItem } from './gate-list';
|
|
import { renderUrgentBlock } from './headless-artifacts';
|
|
import { findSection, firstTitle } from './markdown-sections';
|
|
import type { BriefInputs } from './owner-brief';
|
|
|
|
export const BRIEF_SECTIONS = [
|
|
{ title: 'Before → after', aliases: ['Before → after', 'Before -> after', 'Before and after', 'Before/after'] },
|
|
{ title: 'Why it matters', aliases: ['Why it matters', 'Why this matters'] },
|
|
{ title: 'What caused this work', aliases: ['What caused this work', 'What caused us to work on this', 'What caused this', 'Trigger'] },
|
|
{ title: 'Big decisions', aliases: ['Big decisions', 'Big decisions we made', 'Key decisions'] },
|
|
] as const;
|
|
const SECTION_LINES = 24;
|
|
const SECTION_CHARS = 3000;
|
|
|
|
/** The plan text the gate approves: the run's active (reviewed) plan unless an amendment is pending, else the export. */
|
|
export function reviewedPlan(outDir: string, run?: Record<string, any>): { file?: string; text: string; sha: string } {
|
|
const active = typeof run?.active_plan === 'string' && run?.status !== 'amend_pending' ? run.active_plan : undefined;
|
|
const candidates = [active, path.join(outDir, 'plan-active.md'), path.join(outDir, 'plan.md')].filter((f): f is string => !!f);
|
|
const file = candidates.find(f => fs.existsSync(f));
|
|
const text = file ? fs.readFileSync(file, 'utf8') : '';
|
|
const sha = typeof run?.plan_reality?.plan_sha256 === 'string' ? run.plan_reality.plan_sha256.slice(0, 12) : text ? createHash('sha256').update(text).digest('hex').slice(0, 12) : 'unknown';
|
|
return { file, text, sha };
|
|
}
|
|
|
|
const stripRef = (t: string) => t.replace(/\s*\[[^\]]*\]\s*$/, '').trim();
|
|
const OPTION_WORDS: Record<string, string> = {
|
|
'keep your direction': 'keep the plan as written (a reviewer suggested changing it)',
|
|
'accept the change': "make the reviewer's change",
|
|
};
|
|
const plainOption = (t: string) => {
|
|
const s = t.replace(/\s*\([^)]*gstack-autoplan[^)]*\)/g, '').trim();
|
|
if (/^approve with the recommended changes\b/.test(s)) return 'approve, with the plan changes marked below';
|
|
if (s === 'approve as-is') return 'approve the plan as reviewed';
|
|
return OPTION_WORDS[s] ?? s;
|
|
};
|
|
const P1_DONE: Record<string, string> = { a: 'approved as reviewed', b: 'approved with the recommended plan changes', c: 'questions asked, still open', d: 'sent back for another review round', e: 'rejected' };
|
|
const clip = (t: string, n: number) => (t.length <= n ? t : t.slice(0, n).replace(/\s+\S*$/, '') + '…');
|
|
|
|
function capped(body: string, planPath: string): string {
|
|
const lines = body.split('\n');
|
|
let out = lines.slice(0, SECTION_LINES).join('\n');
|
|
if (out.length > SECTION_CHARS) out = out.slice(0, SECTION_CHARS).replace(/\s+\S*$/, '') + ' …';
|
|
const hidden = lines.length - Math.min(lines.length, SECTION_LINES);
|
|
return hidden > 0 || out.length < body.length ? `${out}\n\n_+ more in the plan (${path.basename(planPath)})._` : out;
|
|
}
|
|
|
|
export interface CaptainBrief { text: string; missing: string[]; pending: boolean; recHash: string }
|
|
|
|
export function renderCaptainBrief(inputs: BriefInputs, o: { outDir: string }): CaptainBrief {
|
|
const plan = reviewedPlan(o.outDir, inputs.run);
|
|
const title = firstTitle(plan.text) ?? 'this plan';
|
|
const list = inputs.gate;
|
|
const hash = recHash(list);
|
|
const p1 = list.items.find(i => i.id === 'p1');
|
|
const rowOf = (id: string) => inputs.decisions.filter(d => d.gate_rev === list.gate_rev).find(d => (d.label ?? '').toLowerCase() === id);
|
|
const p1Row = p1 ? rowOf('p1') : undefined;
|
|
const pending = !p1Row || p1Row.status === 'pending';
|
|
const sections = BRIEF_SECTIONS.map(s => ({ title: s.title, found: findSection(plan.text, [...s.aliases]) }));
|
|
const missing = sections.filter(s => !s.found || !s.found.body.trim()).map(s => s.title);
|
|
const urgent = inputs.findings.some(r => r.urgent === true || r.outside_plan === true) ? [renderUrgentBlock(inputs.findings).trimEnd(), ''] : [];
|
|
const items = list.items.filter(i => i.id !== 'p1');
|
|
const prior = priorChoices(o.outDir, list.gate_rev);
|
|
const rec = parseReply('rec', list, undefined, prior).choices;
|
|
const recKey = (i: GateItem) => rec.find(c => c.id === i.id)?.chosen ?? i.recommended;
|
|
const recOption = (i: GateItem) => i.options.find(x => x.key === recKey(i));
|
|
const kept = items.filter(i => rec.find(c => c.id === i.id)?.prior);
|
|
const keptNote = (i: GateItem) => i.carried?.chosen === recKey(i) ? ` (your answer at gate_rev ${i.carried.gate_rev}, carried forward)` : ' (your answer at this gate)';
|
|
|
|
let header: string[];
|
|
if (!pending) {
|
|
header = [`**Nothing pending.** Already decided${p1Row?.answered_at ? ` on ${p1Row.answered_at.slice(0, 10)}` : ''}: ${P1_DONE[p1Row?.chosen ?? ''] ?? 'answered'}${p1Row?.owner_reply ?? p1Row?.reply ? ` (“${p1Row?.owner_reply ?? p1Row?.reply}”)` : ''}.`];
|
|
} else if (missing.length) {
|
|
header = [`**Not ready for approval:** the plan "${title}" is missing ${missing.join(', ')}. The thread will add them and send this again; do not approve from this draft.`];
|
|
} else {
|
|
header = [
|
|
`**Decision needed:** approve the plan "${title}". Reply **rec** to ${kept.length ? 'keep the answers you already gave and take every other recommendation' : 'take every recommendation'} (recommended), or say in words what to change.`,
|
|
`${items.length ? `${items.length} choice${items.length === 1 ? '' : 's'} below; rec ${kept.length ? `keeps the ${kept.length} you already answered and takes the recommended answer for the rest` : 'takes the recommended answer for each'}.` : 'No open choices besides the approval itself.'} Nothing is built until you answer.`,
|
|
];
|
|
}
|
|
|
|
const body = sections.flatMap(s => [`### ${s.title}`, '', s.found?.body.trim() ? capped(s.found.body.trim(), plan.file ?? 'plan.md') : '_Not written for this plan yet._', '']);
|
|
const need: string[] = ['### What we need from you', ''];
|
|
if (!pending) need.push('Nothing. This gate is answered.', '');
|
|
else {
|
|
need.push(`One decision: approve the plan.`, '');
|
|
need.push(`- **rec (recommended):** ${p1 ? plainOption(recOption(p1)?.text ?? 'approve') : 'approve'}${items.length ? `, taking these ${kept.length ? 'answers' : 'recommendations'}:` : '.'}`);
|
|
items.forEach((i, n) => need.push(` ${n + 1}. ${stripRef(i.title)}: ${plainOption(recOption(i)?.text ?? recKey(i))}${kept.includes(i) ? keptNote(i) : recOption(i)?.changes_plan ? ' (changes the plan)' : ''}.`));
|
|
const example = items.find(i => i.options.length > 1);
|
|
const alt = example?.options.find(x => x.key !== recKey(example));
|
|
need.push(`- **Change something:** say which choice and what you want instead, in words${example && alt ? ` (for example "${clip(stripRef(example.title), 60)}: ${plainOption(alt.text)}")` : ''}. A change that alters the plan reopens its review before anything is built.`);
|
|
need.push('- **Reject:** nothing is built.', '');
|
|
if (!missing.length) need.push(`- **do it:** rec at full scope: every ${kept.length ? 'choice' : 'recommendation'} above, the follow-ons the plan names (docs, trains, cleanups) included, carried through to done with no further ask.`, '', 'Reply: **rec**, or **do it**', '');
|
|
}
|
|
const tokens = rec.map(c => `${c.id}${c.chosen}`).join(' ');
|
|
const trailer = `_For the relaying agent, not the person:_ run ${list.run}, gate_rev=${list.gate_rev}, rec_hash=${hash}; rec resolves to ${tokens}; plan ${plan.file ?? '(missing)'} (sha256 ${plan.sha}). Answer with ` +
|
|
`\`gstack-autoplan answer --out ${o.outDir} --gate-rev ${list.gate_rev} --reply rec --rec-hash ${hash} --by agent:<id> --owner-reply "<their exact words>"\`, then \`gstack-approvals import --autoplan ${o.outDir} --by <name>\`.`;
|
|
// The price sits above the trailer: the relaying agent sends everything above that line, so the person approving sees it.
|
|
const cost = inputs.run ? ['### Cost', '', gateCostSentence(gateCost(o.outDir, list, { phase_order: inputs.run.phase_order ?? [], outside_enabled: inputs.run.outside_enabled !== false, spend_cap_usd: inputs.run.spend_cap_usd ?? null, scope_flags: inputs.run.scope_flags })), ''] : [];
|
|
const text = [...urgent, ...header, '', ...body, ...need, ...cost, '---', '', trailer, ''].join('\n');
|
|
return { text, missing, pending, recHash: hash };
|
|
}
|
|
|
|
/** The extra line a gate-producing command prints under Capy (GSTACK_CAPY=1): the owner-facing render to relay. */
|
|
export function capyRelayLine(outDir: string, env: NodeJS.ProcessEnv = process.env): string | undefined {
|
|
if (env.GSTACK_CAPY !== '1') return undefined;
|
|
return `RELAY: gstack-owner-brief render --captain --out ${outDir} (Capy: send it to your Captain, or to the person when you report to none; it carries the rec_hash to answer with)`;
|
|
}
|