mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-02 06:54:50 +01:00
feat(autoplan): carry an item-by-item answer when the later gate asks nothing new (GBRA-223 claim 7)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
e79db1f7a3
commit
b00450e312
3 files changed
+40
-15
No files matched your search
+9
-3
@@ -524,9 +524,8 @@ A gate revision after the first (`gate_rev` 2 and later, written after a
|
||||
it when nothing new needs their decision. `gstack-autoplan` prints
|
||||
`CARRIED: gate_rev=<n> <- gate_rev=<m> words="<their words>" by=<relayer> reason=<why>`,
|
||||
then the usual `APPROVED:` lines, and journals `gate_carried`. The carry needs
|
||||
all of: the earlier gate approved (`p1a` or `p1b`) with `rec` or "do it"
|
||||
(not item tokens), no item there left pending, and the owner's words
|
||||
recorded; that gate was not itself carried; every accepted change incorporated
|
||||
all of: the earlier gate approved (`p1a` or `p1b`), no item there left
|
||||
pending, and the owner's words recorded; that gate was not itself carried; every accepted change incorporated
|
||||
or settled; the plan file's bytes equal those recorded at the gate or at the
|
||||
`revise` that took the changes (any later edit refuses, an ELI5-only one
|
||||
included); no urgent or
|
||||
@@ -535,6 +534,13 @@ Anything else prints `CARRY: no (<why>)` (journal `carry_refused`) and the gate
|
||||
goes to the owner as before. `--no-carry-forward` on the first `next` turns it
|
||||
off for the run.
|
||||
|
||||
An earlier answer given item by item (`<id><option>` tokens, not `rec` or "do
|
||||
it") carries only what the owner already answered: every item on the new gate
|
||||
must be carried by origin (the same proposal, so the same choice), and only an
|
||||
unreviewed post-cap delta (below) may be new. The carry replays the owner's own
|
||||
`p1` choice and records their tokens verbatim as its basis; any new or changed
|
||||
item, "do it" cases included, goes to the owner.
|
||||
|
||||
- **"do it".** When the owner's words were "do it" (their words, not a
|
||||
`captain:` relay of them), a new `auto` item that changes no scope, cost or
|
||||
direction, whose recommended option changes no plan text, and is no round
|
||||
|
||||
+11
-8
@@ -7,11 +7,13 @@
|
||||
* carried answer is the owner's own earlier words, and every refusal says which condition failed.
|
||||
*
|
||||
* A carry needs all of: carry not turned off (`--no-carry-forward`); gate_rev N approved (p1 a or b)
|
||||
* with the owner's words recorded, and not itself a carry; every change accepted at N
|
||||
* with the owner's words recorded, and not itself a carry; nothing left pending there; every change accepted at N
|
||||
* incorporated or settled; the plan file unchanged since the gate or the revise that took those
|
||||
* changes, and the reviewed plan equal to it; no open urgent finding; and every new item already
|
||||
* carried. Two kinds of new item do not block: items a "do it" covers (the owner's words are
|
||||
* "do it": an auto item that changes no scope, cost or direction and is no round grant), and a
|
||||
* carried. An answer given item by item (tokens, not `rec`) carries only that: every item on
|
||||
* N+1 already carried by origin, its p1 choice replayed and its tokens recorded verbatim as the
|
||||
* basis; any new or changed item goes to the owner. After `rec` or "do it", two kinds of new
|
||||
* item do not block: items a "do it" covers (the owner's words are "do it": an auto item that changes no scope, cost or direction and is no round grant), and a
|
||||
* capped-delta item recommending "accept", which first gets one reviewer on just the delta
|
||||
* (`CARRY_CHECK:`, answered with `gstack-autoplan check`); a High or Critical finding there
|
||||
* means no carry, and the gate goes to the owner as before.
|
||||
@@ -92,7 +94,8 @@ function deltaSince(state: RunState, rev: number): { diff: string; sha: string }
|
||||
return { diff, sha: sha256(diff) };
|
||||
}
|
||||
|
||||
interface Source { id: string; gate_rev: number; words: string; by?: string }
|
||||
/** `p1`: an item-token answer's own p1 choice, replayed as given. */
|
||||
interface Source { id: string; gate_rev: number; words: string; by?: string; p1?: string }
|
||||
type Plan = { source: Source; doIt: GateItem[]; deltas: GateItem[] } | { reason: string; from?: number };
|
||||
|
||||
/** Every condition but the delta check; returns why not, or what the carry takes. */
|
||||
@@ -105,7 +108,7 @@ function assess(state: RunState, prev: GateList, list: GateList): Plan {
|
||||
if (unanswered) return { reason: `gate_rev ${from} left ${unanswered.label ?? unanswered.id} unanswered`, from };
|
||||
if (p1.chosen !== 'a' && p1.chosen !== 'b') return { reason: `gate_rev ${from} answer was p1${p1.chosen}, not an approval`, from };
|
||||
if (p1.carried_gate_from) return { reason: `gate_rev ${from} was itself carried; the owner answers this one`, from };
|
||||
if (parseReply(p1.reply ?? '', prev).mode !== 'rec') return { reason: `gate_rev ${from} was answered with item tokens, not rec or "do it"`, from };
|
||||
const tokens = parseReply(p1.reply ?? '', prev).mode !== 'rec';
|
||||
const words = p1.owner_reply ?? (p1.answered_by === 'human' ? p1.reply : undefined);
|
||||
if (!words?.trim()) return { reason: `no owner words recorded at gate_rev ${from}`, from };
|
||||
const amendments = (state.amendments ?? []).filter(a => a.gate_rev === from);
|
||||
@@ -125,10 +128,10 @@ function assess(state: RunState, prev: GateList, list: GateList): Plan {
|
||||
if (item.id === 'p1' || item.carried) continue;
|
||||
const option = item.options.find(o => o.key === item.recommended);
|
||||
if (item.cap?.kind === 'delta' && !option?.changes_plan && item.recommended === 'a') { deltas.push(item); continue; }
|
||||
if (isDoIt(words) && item.kind === 'auto' && !item.cap && !option?.changes_plan && !['scope', 'cost', 'direction'].includes(item.changes ?? '')) { doIt.push(item); continue; }
|
||||
if (!tokens && isDoIt(words) && item.kind === 'auto' && !item.cap && !option?.changes_plan && !['scope', 'cost', 'direction'].includes(item.changes ?? '')) { doIt.push(item); continue; }
|
||||
return { reason: `new item ${item.id}: ${item.title}`, from };
|
||||
}
|
||||
return { source: { id: p1.id, gate_rev: from, words, by: p1.answered_by }, doIt, deltas };
|
||||
return { source: { id: p1.id, gate_rev: from, words, by: p1.answered_by, ...(tokens ? { p1: p1.chosen } : {}) }, doIt, deltas };
|
||||
}
|
||||
|
||||
/** Called by finishGate once gate_rev N+1 is written (status gate_pending): carry, set up the delta check, or say why not. */
|
||||
@@ -178,7 +181,7 @@ function dispatchCheck(state: RunState, prev: GateList, list: GateList, plan: Ex
|
||||
function carry(state: RunState, list: GateList, source: Source, doIt: GateItem[], deltas: GateItem[], check?: CarryCheck): CarryRecord {
|
||||
const rev = list.gate_rev;
|
||||
const p1 = list.items.find(i => i.id === 'p1')!;
|
||||
const reply = list.items.map(i => `${i.id}${i.id === 'p1' ? p1.recommended : i.carried?.chosen ?? i.recommended}`).join(' ');
|
||||
const reply = list.items.map(i => `${i.id}${i.id === 'p1' ? source.p1 ?? p1.recommended : i.carried?.chosen ?? i.recommended}`).join(' ');
|
||||
const reason = [doIt.length || deltas.length ? '' : 'no new choice', doIt.length ? `"do it" covers ${doIt.map(i => i.id).join(',')}` : '', deltas.length ? `delta check passed on ${deltas.map(i => i.id).join(',')}` : '',
|
||||
'plan unchanged since the accepted changes'].filter(Boolean).join('; ');
|
||||
const answered = answerGate(state, rev, reply, 'agent:gstack-autoplan', { carry: { from: source.id, gate_rev: source.gate_rev, words: source.words, by: source.by, reason } });
|
||||
|
||||
@@ -123,20 +123,36 @@ describe('carry-forward', () => {
|
||||
expect(phaseVia(r, rec.out, 'eng', PKG).outside.stdout).toMatch(/^CARRY: no \(new item \w+: Split the backoff/m);
|
||||
});
|
||||
|
||||
test('an earlier answer in item tokens, or one that left an item unanswered, is not carried', () => {
|
||||
/** Gate 1 answered item by item (every item's recommendation as tokens) by the owner, then revised to take uc1. */
|
||||
function tokensAndRevised() {
|
||||
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', AGREE);
|
||||
phaseVia(r, out, 'eng', NONE);
|
||||
const g = gateOf(out);
|
||||
const tokens = g.items.map((i: any) => `${i.id}${i.recommended}`).join(' ');
|
||||
const tokens = gateOf(out).items.map((i: any) => `${i.id}${i.recommended}`).join(' ');
|
||||
expect(r.ap(['answer', '--out', out, '--gate-rev', '1', '--reply', tokens, '--by', 'human']).status).toBe(0);
|
||||
const v2 = path.join(dir, 'plan-v2.md');
|
||||
fs.writeFileSync(v2, V2);
|
||||
expect(r.ap(['revise', '--out', out, '--plan', v2, '--incorporates', 'uc1']).status).toBe(0);
|
||||
phaseVia(r, out, 'ceo', NONE);
|
||||
expect(phaseVia(r, out, 'eng', NONE).outside.stdout).toMatch(/^CARRY: no \(gate_rev 1 was answered with item tokens, not rec or "do it"\)$/m);
|
||||
return { out, tokens, p1: decisions(out).find(x => x.gate_rev === 1 && x.label === 'P1') };
|
||||
}
|
||||
|
||||
test('an item-token answer carries when the later gate asks nothing new: the owner\'s p1 choice and verbatim tokens are the basis', () => {
|
||||
const s = tokensAndRevised();
|
||||
const eng = phaseVia(r, s.out, 'eng', NONE).outside;
|
||||
expect(eng.status, eng.stderr).toBe(0);
|
||||
expect(eng.stdout).toContain(`CARRIED: gate_rev=2 <- gate_rev=1 words=${JSON.stringify(s.tokens)} by=human reason=no new choice; plan unchanged since the accepted changes`);
|
||||
expect(eng.stdout).toMatch(/^APPROVED: gate_rev=2/m);
|
||||
expect(decisions(s.out).find(x => x.gate_rev === 2 && x.label === 'P1')).toMatchObject({ chosen: s.p1.chosen, carried_gate_from: s.p1.id });
|
||||
});
|
||||
|
||||
test('an item-token answer is not carried past a new item', () => {
|
||||
const s = tokensAndRevised();
|
||||
const PKG = { native: [{ id: 'F7', severity: 'Medium', title: 'Split the backoff into its own PR', user_challenge: true, changes: 'packaging', fix: 'own PR' }], outside: [] };
|
||||
expect(phaseVia(r, s.out, 'eng', PKG).outside.stdout).toMatch(/^CARRY: no \(new item \w+: Split the backoff/m);
|
||||
expect(readRunJson(s.out).status).toBe('gate_pending');
|
||||
});
|
||||
|
||||
test('an answer that left an approval item pending is not carried', () => {
|
||||
|
||||
Reference in new issue
Block a user