fix(gate): a new answer replaces the earlier answer's authorship and words

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-11-01 11:30:36 +00:00
1 parent 033319e710
commit 95a8f335cf
2 files changed
+25 -1

No files matched your search

+6 -1
View File
@@ -244,14 +244,19 @@ export function decisionRows(list: GateList, extra: Partial<DecisionRow> = {}):
/** `--by` values: the owner typing tokens, or an agent relaying or deciding them. */
export const ANSWERED_BY_RE = /^(human|agent:[A-Za-z0-9_.:-]+)$/;
/** Who answered and in whose words: a new answer replaces all of it, so an earlier answerer's fields never survive under a later one. */
const PROVENANCE = ['answered_by', 'owner_reply', 'owner_reply_parsed', 'answer_words', 'answer_words_parsed', 'words_by', 'carried_gate_from', 'carried_gate_rev', 'carry_reason', 'carried_answered_by'] as const;
export function applyReply(rows: DecisionRow[], parsed: ParsedReply, answeredAt: string, answeredBy?: string, extra: Partial<DecisionRow> = {}): DecisionRow[] {
if (!parsed.valid) return rows;
const byId = new Map(parsed.choices.map(c => [c.id, c]));
return rows.map(row => {
const choice = byId.get(row.label?.toLowerCase() ?? '') ?? byId.get(row.id.slice(row.run.length + 1).replace(/^r\d+-/, ''));
if (!choice || choice.prior || row.gate_rev !== parsed.gate_rev) return row;
const fresh = { ...row };
for (const k of PROVENANCE) delete (fresh as Record<string, unknown>)[k];
return {
...row, chosen: choice.chosen, answered_at: answeredAt, reply: parsed.reply, ...(answeredBy ? { answered_by: answeredBy } : {}), ...extra,
...fresh, chosen: choice.chosen, answered_at: answeredAt, reply: parsed.reply, ...(answeredBy ? { answered_by: answeredBy } : {}), ...extra,
status: choice.how === 'override' ? 'overridden' : 'approved',
};
});
+19
View File
@@ -67,6 +67,25 @@ describe('whose words answered the gate (GEVA-2, P7)', () => {
expect(r.validate(s.out).status).toBe(0);
});
test('a later answer from someone else replaces the earlier answer\'s authorship and words; nothing stale survives', () => {
const dir = path.join(r.host.base, `carry${++n}`);
const out = path.join(dir, 'run');
expect(r.ap(['next', '--out', out, '--plan', writePlan(dir), '--no-ui']).status).toBe(0);
phaseVia(r, out, 'ceo', NONE);
phaseVia(r, out, 'eng', NONE);
const first = r.ap(['answer', '--out', out, '--gate-rev', '1', '--reply', 'p1c', '--by', 'agent:gstack-minder', '--words-by', 'agent:gstack-minder', '--owner-reply', 'p1c']);
expect(first.status, first.stderr).toBe(0);
const second = r.ap(['answer', '--out', out, '--gate-rev', '1', '--reply', 'p1a', '--by', 'human']);
expect(second.status, second.stderr).toBe(0);
expect(second.stdout).toMatch(/^APPROVED: gate_rev=1/m);
const p1 = decisions(out).find(d => d.gate_rev === 1 && d.label === 'P1');
expect(p1).toMatchObject({ chosen: 'a', answered_by: 'human' });
for (const stale of ['words_by', 'answer_words', 'answer_words_parsed', 'owner_reply', 'owner_reply_parsed']) expect(p1[stale]).toBeUndefined();
const rows = importAutoplan(out, 'feature/x', 'Garry');
if ('code' in rows) throw new Error(rows.message);
expect(rows[0]!.by).not.toContain('agent:gstack-minder');
});
test('--words-by names the owner or the answering agent; anything else is refused', () => {
const dir = path.join(r.host.base, `carry${++n}`);
const out = path.join(dir, 'run');