diff --git a/lib/gate-list.ts b/lib/gate-list.ts index 60164a760..8baa3715d 100644 --- a/lib/gate-list.ts +++ b/lib/gate-list.ts @@ -244,14 +244,19 @@ export function decisionRows(list: GateList, extra: Partial = {}): /** `--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[] { 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)[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', }; }); diff --git a/test/autoplan-carry.test.ts b/test/autoplan-carry.test.ts index 0a8e9c4fe..3bbaf000c 100644 --- a/test/autoplan-carry.test.ts +++ b/test/autoplan-carry.test.ts @@ -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');