mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-09 02:08:19 +01:00
fix(ship-budget): per-reader drift measured at each reader's return; stale prior readings ignored; segment-only handoff brief (GSTA-167)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
ae0b348bcf
commit
0b49d5619f
12 files changed
+356
-55
No files matched your search
+4
-1
@@ -8,12 +8,15 @@
|
||||
- **Thresholds sit above the host call floor.** `lib/ship-budget-calibration.json` gives each meter a `call_floor` (Capy 150k, Claude Code 40k, estimate 0); `start --call-floor <n>` overrides it and children inherit it. `BUDGET:` and `STOP:` lines print `call_floor=` and `above_floor=`.
|
||||
- **A child never stops on the context rule.** After a completed step it hands its remaining steps to a grandchild through the same handoff, verify, return and reconcile commands (chain depth 3 at most). Before its first completed step, at the maximum depth, or past its segment it continues with one `CONTEXT_HIGH:` line. The admission cap still stops it.
|
||||
- **A delegated child cannot return complete with work left over.** `return` reports `status=incomplete` with `UNRECONCILED:` or `DROPPED:` lines while one of its own grandchildren is unreconciled or a batch remainder was never re-checked.
|
||||
- **Reviewer drift is measured to each reader's return, per reader.** A dispatch is measured only to the reading it was settled with when it returned (`check --returned`, `settle --reading`); `--finish` and a step change settle without one and print `UNMEASURED:`. A step is measured at the step change minus the dispatches reserved inside it, so the parent's own calls stay in the step. On #34 a Red Team interval that covered the child's own review and QA taught a 1.818 factor; on #36 a six-reader batch reserved as one role taught 2.041 and priced the Red Team at 15.17M, a false STOP. The budget section now asks for one role per `DISPATCH:` line.
|
||||
- **A `--prior-reading` older than the run's last reading is ignored** (`CALL_UNKNOWN:`, `call=unknown`). On #34 a reading six calls old produced `call=1068386` and a false `CONTEXT_HIGH`. The meter read now happens inside the same program as the budget command, never as its own call (the #34 child made 11 meter-only calls at about 286k each).
|
||||
- **A handoff brief names only its segment's sections** (Steps 7-11.5: test-coverage, plan-completion, review-army, adversarial; Steps 12-22: changelog, documentation, pr-body, capy-pr; budget-handoff.md always). It never sends the child to preamble.md in full or docs/ship-budget.md.
|
||||
|
||||
### Changed
|
||||
- **Parallel reviewer batches near the cap admit the largest prefix that fits** (`ADMIT: k of n (remaining=<roles>)`) instead of refusing the whole batch; each reader is priced at the larger of its projection and the run's measured mean for that role. `close` flags remaining roles nobody re-checked (`DROPPED:`, `status=incomplete` until the owner accepts).
|
||||
|
||||
### Tests
|
||||
- `test/ship-budget-chain.test.ts` replays #36 (a child at its first boundary continues) and covers the Capy floor, grandchild handoff at depth 2, the depth-3 cap, charging a chained return once on all three meters, and the incomplete return. `test/ship-budget-batch.test.ts` replays #35's 7-reader batch near the cap.
|
||||
- `test/ship-budget-chain.test.ts` replays #36 (a child at its first boundary continues) and covers the Capy floor, grandchild handoff at depth 2, the depth-3 cap, charging a chained return once on all three meters, and the incomplete return. `test/ship-budget-batch.test.ts` replays #35's 7-reader batch near the cap. `test/ship-budget-reader-drift.test.ts` replays #36's per-reader Red Team price and #34's Red Team interval and stale prior reading; `test/ship-handoff-brief.test.ts` checks the brief's section list.
|
||||
|
||||
## [1.91.324.0] - 2026-10-29
|
||||
|
||||
|
||||
+24
-10
@@ -23,19 +23,24 @@ you set stays as given); the stop overhead never scales.
|
||||
|
||||
## Quickstart
|
||||
|
||||
**Capy.** Read the root counter in one execute program that returns only that number:
|
||||
**Capy.** Read the root counter inside the execute program that runs the command, never as a
|
||||
call of its own:
|
||||
|
||||
```js
|
||||
return (await tools.thread.get({ thread_id: "this" })).cost.tokens;
|
||||
const n = (await tools.thread.get({ thread_id: "this" })).cost.tokens;
|
||||
```
|
||||
|
||||
then
|
||||
then pass it to
|
||||
|
||||
```bash
|
||||
gstack-ship-budget start --record <record> --token-cap 25M --reading <n>
|
||||
gstack-ship-budget check --run <id> --step 6 --next 7 --reading <n> [--prior-reading <n from your previous call>]
|
||||
```
|
||||
|
||||
`--prior-reading` must come from the call just before this one; a prior older than the run's last
|
||||
reading spans several calls, so it is ignored (`CALL_UNKNOWN:`, `call=unknown`). Two readings in
|
||||
one program measure nothing: Capy books a call after its program ends.
|
||||
|
||||
The counter rolls up every subagent of the thread, outside voices included; Capy exposes no
|
||||
per-subagent counter, so other work in the same thread counts too (it only stops earlier).
|
||||
|
||||
@@ -60,7 +65,7 @@ With a cap set and no readable meter, start refuses
|
||||
| `admission_cap` | The cap, or `none` |
|
||||
| `tail` | Tail reserve (scaled by the step factor once measured) plus stop overhead, held back from every admission |
|
||||
| `unit`, `meter`, `epoch` | The meter contract: `capy-tokens` (root counter), `claude-usage-tokens` (journal usage), `estimate-tokens`; a meter change opens a new epoch |
|
||||
| `call` | The per-call charge: two Capy readings in consecutive calls, or the last Claude Code call's usage |
|
||||
| `call` | The per-call charge: two Capy readings in consecutive calls (a prior older than the run's last reading is ignored), or the last Claude Code call's usage |
|
||||
| `call_floor`, `above_floor` | The host's per-call floor (see Delegation) and the charge above it, max(0, call − floor); the delegate rule compares `above_floor` |
|
||||
| `next`, `projected` | What the check was for and its projection: the prior times the run's factor for that kind |
|
||||
| `calibration` | The run's step and dispatch factors (see Calibration), or `prior` before the first measurement |
|
||||
@@ -80,8 +85,9 @@ gstack-ship-budget check --run <id> --step 9 --next dispatch:specialist,red-team
|
||||
gstack-review-log --start review --reservation <id>
|
||||
```
|
||||
|
||||
Each reader is priced at max(its projection, the run's measured mean for that role over settled
|
||||
dispatches). When the whole batch does not fit, `check` admits the largest prefix that does and
|
||||
Name one role per `DISPATCH:` line (six specialists are `specialist` six times): drift and the
|
||||
measured means are per reader. Each reader is priced at max(its projection, the run's measured
|
||||
mean per reader for that role over measured dispatches). When the whole batch does not fit, `check` admits the largest prefix that does and
|
||||
prints `ADMIT: k of n (remaining=<roles>)`: dispatch those k, then re-check the remaining roles
|
||||
once they settle. Only a batch whose first reader does not fit stops, and `close` flags remaining
|
||||
roles no later check requested (`DROPPED:`, status=incomplete until the owner accepts).
|
||||
@@ -91,7 +97,10 @@ every other dispatch reservation started in the same step, so reviewers admitted
|
||||
do not stay reserved. Name a reviewer that came back in your next check
|
||||
(`check ... --returned <id>[,<id>...]`), or, when other work comes first, `settle --reservation <id>
|
||||
--reading <n>` as it returns so its cost is measured alone. A check that moves to the next step
|
||||
settles every dispatch a reviewer started in the step before.
|
||||
settles every dispatch a reviewer started in the step before. Only a dispatch settled with a
|
||||
reading at its return is measured: `--finish` and a step change settle without one, so their
|
||||
dispatches print `UNMEASURED:` and leave the factor alone (their interval also holds the parent's
|
||||
own work).
|
||||
`close` audits after the fact: each review-log row of the run with no reservation gets one
|
||||
BUDGET_CHECK_SKIPPED line and the run ends status=incomplete for the owner to accept
|
||||
(`close --approved-by <who> --words "<their words>"`); a reservation is never created after the fact.
|
||||
@@ -177,12 +186,17 @@ Claude-metered /ship completes. Each unit's `call_floor` is calibrated separatel
|
||||
evidence in `call_floor_why`; a floor is the largest 10,000 multiple at or below its smallest
|
||||
observed fresh call.
|
||||
|
||||
These constants are the prior. Each settled step or dispatch is measured on the next fresh
|
||||
reading (a step from its check's reading to the next one; dispatches settled together as one
|
||||
group, split by role), and prints
|
||||
These constants are the prior. A dispatch is measured from its check's reading to the reading it
|
||||
was settled with on return (dispatches settled at one reading split it per reader). A step is
|
||||
measured at the next step change, from its check's reading minus the dispatches reserved inside
|
||||
it, so the parent's interleaved calls stay in the step; a step holding an unmeasured dispatch is
|
||||
not measured. Each measurement prints
|
||||
|
||||
DRIFT: id=<reservation> kind=step|dispatch next=<id> projected=<n> actual=<n> ratio=<actual/projected>
|
||||
|
||||
and each reservation that cannot be measured prints
|
||||
`UNMEASURED: id=<reservation> kind=step|dispatch next=<id> reason=settled_without_reading|dispatch_unmeasured`.
|
||||
|
||||
From then on the run projects steps and dispatches with its own factor: the largest
|
||||
actual/prior ratio among its last three measurements of that kind, times 1.5. The step factor
|
||||
also scales the default tail reserve. Dispatches keep the prior until the first
|
||||
|
||||
+23
-9
@@ -44,8 +44,13 @@ const meterFile = (l: Ledger) => path.join(l.dir, 'claude-meter.json');
|
||||
/** Whether this command took a fresh reading (Claude Code reads its journals on every command). */
|
||||
const freshReading = (l: Ledger, o: Opts) => l.meter.contract.meter === 'claude' || str(o, 'reading') !== undefined;
|
||||
|
||||
/** Take this command's reading under the run's meter contract; returns the per-call charge when known. */
|
||||
export function readMeter(l: Ledger, o: Opts, requireReading: boolean): number | null {
|
||||
/**
|
||||
* Take this command's reading under the run's meter contract; returns the
|
||||
* per-call charge when known. On Capy that charge is --reading minus a
|
||||
* --prior-reading taken in the previous call; a prior older than the run's last
|
||||
* reading spans several calls, so it is ignored (call unknown) with a note.
|
||||
*/
|
||||
export function readMeter(l: Ledger, o: Opts, requireReading: boolean, notes: string[] = []): number | null {
|
||||
const reading = tokensOpt(o, 'reading');
|
||||
const prior = tokensOpt(o, 'prior-reading');
|
||||
const meter = l.meter.contract.meter;
|
||||
@@ -63,8 +68,14 @@ export function readMeter(l: Ledger, o: Opts, requireReading: boolean): number |
|
||||
return null;
|
||||
}
|
||||
if (prior !== undefined && prior > reading) throw budgetError('BUDGET_READING_REJECTED', `--prior-reading ${prior} is above --reading ${reading}`);
|
||||
const last = l.meter.last;
|
||||
applyReading(l, reading, 'flag');
|
||||
return prior === undefined ? null : reading - prior;
|
||||
if (prior === undefined) return null;
|
||||
if (last !== null && prior < last) {
|
||||
notes.push(`CALL_UNKNOWN: --prior-reading ${prior} is older than the run's last reading ${last}; it spans several calls, so the per-call charge is unknown (pass the reading your previous call returned)`);
|
||||
return null;
|
||||
}
|
||||
return reading - prior;
|
||||
}
|
||||
|
||||
/** The stop block: STOP, the resumable RECOVER line and GSTACK_RESULT incomplete; a child also writes its return. */
|
||||
@@ -249,15 +260,17 @@ export function cmdCheck(argv: string[], ctx: Ctx): Outcome {
|
||||
const back = returned.map(id => l.reservations.find(r => r.id === id && r.kind === 'dispatch'));
|
||||
const bad = returned.filter((_, i) => !back[i]);
|
||||
if (bad.length) throw usageError(`--returned ${bad.join(',')} is not a dispatch reservation of run ${l.run}; fix: pass the RESERVATION ids check printed for the reviewers that came back`);
|
||||
const call = readMeter(l, o, true);
|
||||
const notes: string[] = [];
|
||||
const call = readMeter(l, o, true, notes);
|
||||
const fresh = freshReading(l, o);
|
||||
for (const r of l.reservations) {
|
||||
const done = r.kind === 'step' ? r.state === 'open' : r.kind === 'dispatch' && (back.includes(r) || (next.kind === 'step' && r.state === 'consumed'));
|
||||
if (done) settleIn(l, r);
|
||||
if (done) settleIn(l, r, { reading: fresh && back.includes(r) ? l.meter.last ?? undefined : undefined });
|
||||
}
|
||||
const drift = freshReading(l, o) ? measureSettled(l) : [];
|
||||
const drift = fresh ? measureSettled(l, next.kind === 'step') : [];
|
||||
if (l.parent && next.kind === 'step' && SHIP_STEPS.indexOf(step as any) >= SHIP_STEPS.indexOf(l.parent.from as any)) l.steps_done = [...new Set([...(l.steps_done ?? []), step])];
|
||||
const out = decide(l, step, next, call);
|
||||
return { stdout: [...drift, ...out.stdout], exit: out.exit };
|
||||
return { stdout: [...notes, ...drift, ...out.stdout], exit: out.exit };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -270,8 +283,9 @@ export function cmdSettle(argv: string[], ctx: Ctx, release = false): Outcome {
|
||||
if (!r) throw usageError(`reservation ${id} is not in run ${l.run}`);
|
||||
if (release) { if (r.state === 'open') r.state = 'released'; return { stdout: [`RELEASED: id=${id} state=${r.state}`], exit: 0 }; }
|
||||
readMeter(l, o, false);
|
||||
settleIn(l, r, { unknown: o.unknown === true, cal: loadCalibration(l.calibration) });
|
||||
return { stdout: [`SETTLED: id=${id} state=${r.state}`, ...(freshReading(l, o) ? measureSettled(l) : [])], exit: 0 };
|
||||
const fresh = freshReading(l, o);
|
||||
settleIn(l, r, { unknown: o.unknown === true, cal: loadCalibration(l.calibration), reading: fresh ? l.meter.last ?? undefined : undefined });
|
||||
return { stdout: [`SETTLED: id=${id} state=${r.state}`, ...(fresh ? measureSettled(l, false) : [])], exit: 0 };
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+39
-15
@@ -57,6 +57,7 @@ export interface Reservation {
|
||||
id: string; kind: 'step' | 'dispatch' | 'child'; step: string; next: string; roles?: string[]; tokens: number; at: string;
|
||||
state: 'open' | 'consumed' | 'settled' | 'released'; token?: string; started_at?: string; settled_at?: string;
|
||||
prior?: number; reading_at?: number | null; epoch?: number; actual?: number; unseen?: boolean; remaining?: string[];
|
||||
settled_reading?: number; unmeasured?: boolean;
|
||||
}
|
||||
export interface Sample { id: string; kind: 'step' | 'dispatch'; epoch: number; prior: number; projected: number; actual: number }
|
||||
export interface Handoff {
|
||||
@@ -189,26 +190,48 @@ export function projector(l: Ledger, cal: Calibration = loadCalibration(l.calibr
|
||||
}
|
||||
|
||||
/**
|
||||
* Measure settled reservations against the current (fresh) reading: a step
|
||||
* from its reading to now; settled dispatches as one group from the earliest
|
||||
* of their readings, split by role. A charge the meter cannot see (settled
|
||||
* --unknown, or a Claude-metered outside voice) is never measured. Returns one
|
||||
* DRIFT line per measurement.
|
||||
* Measure settled reservations against the current (fresh) reading (GSTA-167).
|
||||
* A dispatch is measured only to the reading it was settled with when it
|
||||
* returned (settle --reading, check --returned): dispatches settled at the same
|
||||
* reading form one group from the earliest of their readings, split per reader
|
||||
* (one role per reader). One settled without a reading (review-log --finish, a
|
||||
* step change) is UNMEASURED: its interval also holds the parent's own work.
|
||||
* A step is measured on a step change, from its reading to now minus the
|
||||
* dispatches reserved after it, so the parent's interleaved calls stay in the
|
||||
* step; a window holding an unmeasured dispatch leaves the step unmeasured. A
|
||||
* charge the meter cannot see (settled --unknown, or a Claude-metered outside
|
||||
* voice) is never measured. Returns one DRIFT or UNMEASURED line each.
|
||||
*/
|
||||
export function measureSettled(l: Ledger): string[] {
|
||||
export function measureSettled(l: Ledger, stepChange = true): string[] {
|
||||
const last = l.meter.last;
|
||||
if (last === null) return [];
|
||||
const pending = l.reservations.filter(r => r.state === 'settled' && r.kind !== 'child' && r.actual === undefined && !r.unseen
|
||||
const pending = l.reservations.filter(r => r.state === 'settled' && r.kind !== 'child' && r.actual === undefined && !r.unseen && !r.unmeasured
|
||||
&& typeof r.reading_at === 'number' && r.epoch === l.meter.epoch && last >= r.reading_at);
|
||||
const dispatches = pending.filter(r => r.kind === 'dispatch');
|
||||
const from = Math.min(...dispatches.map(r => r.reading_at!));
|
||||
const roles = dispatches.reduce((n, r) => n + (r.roles?.length ?? 1), 0);
|
||||
return pending.map(r => {
|
||||
const actual = r.kind === 'dispatch' ? Math.round(((last - from) * (r.roles?.length ?? 1)) / roles) : last - r.reading_at!;
|
||||
const lines: string[] = [];
|
||||
const measure = (r: Reservation, actual: number) => {
|
||||
r.actual = actual;
|
||||
(l.samples ??= []).push({ id: r.id, kind: r.kind as Sample['kind'], epoch: l.meter.epoch, prior: r.prior ?? r.tokens, projected: r.tokens, actual });
|
||||
return `DRIFT: id=${r.id} kind=${r.kind} next=${r.next} projected=${r.tokens} actual=${actual} ratio=${(actual / Math.max(1, r.tokens)).toFixed(2)}`;
|
||||
});
|
||||
lines.push(`DRIFT: id=${r.id} kind=${r.kind} next=${r.next} projected=${r.tokens} actual=${actual} ratio=${(actual / Math.max(1, r.tokens)).toFixed(2)}`);
|
||||
};
|
||||
const unmeasured = (r: Reservation, reason: string) => {
|
||||
r.unmeasured = true;
|
||||
lines.push(`UNMEASURED: id=${r.id} kind=${r.kind} next=${r.next} reason=${reason}`);
|
||||
};
|
||||
const dispatches = pending.filter(r => r.kind === 'dispatch');
|
||||
for (const r of dispatches) if (r.settled_reading === undefined) unmeasured(r, 'settled_without_reading');
|
||||
for (const end of new Set(dispatches.filter(r => !r.unmeasured).map(r => r.settled_reading!))) {
|
||||
const group = dispatches.filter(r => r.settled_reading === end);
|
||||
const from = Math.min(...group.map(r => r.reading_at!));
|
||||
const readers = group.reduce((n, r) => n + (r.roles?.length ?? 1), 0);
|
||||
for (const r of group) measure(r, Math.round(((end - from) * (r.roles?.length ?? 1)) / readers));
|
||||
}
|
||||
if (!stepChange) return lines;
|
||||
for (const r of pending.filter(x => x.kind === 'step')) {
|
||||
const inside = l.reservations.slice(l.reservations.indexOf(r) + 1).filter(d => d.kind === 'dispatch' && d.state !== 'released' && !d.unseen && d.epoch === l.meter.epoch);
|
||||
if (inside.some(d => d.actual === undefined)) { unmeasured(r, 'dispatch_unmeasured'); continue; }
|
||||
measure(r, Math.max(0, last - r.reading_at! - inside.reduce((n, d) => n + d.actual!, 0)));
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
/** Steps from..to inclusive plus their usual reviewer batches at the prior: a child's segment estimate. */
|
||||
@@ -297,10 +320,11 @@ export function reserveIn(l: Ledger, r: Omit<Reservation, 'id' | 'at' | 'state'>
|
||||
}
|
||||
|
||||
/** Settle at completion: the meter already holds the spend; estimate and unseen charges are booked here. */
|
||||
export function settleIn(l: Ledger, r: Reservation, opts: { unknown?: boolean; cal?: Calibration } = {}): void {
|
||||
export function settleIn(l: Ledger, r: Reservation, opts: { unknown?: boolean; cal?: Calibration; reading?: number } = {}): void {
|
||||
if (r.state === 'settled' || r.state === 'released') return;
|
||||
r.state = 'settled';
|
||||
r.settled_at = new Date().toISOString();
|
||||
if (opts.reading !== undefined) r.settled_reading = opts.reading;
|
||||
if (opts.unknown) { l.unknown += r.tokens; r.unseen = true; return; }
|
||||
if (l.meter.contract.meter === 'estimate') { l.estimate_used += r.tokens; return; }
|
||||
const unseen = l.meter.contract.meter === 'claude' ? (r.roles ?? []).filter(x => x === 'outside-voice').length : 0;
|
||||
|
||||
@@ -51,6 +51,17 @@ export function recordSection(text: string, name: string): string[] | undefined
|
||||
return lines.slice(at, end < 0 ? lines.length : end);
|
||||
}
|
||||
|
||||
/** The ship sections each step reads (ship/sections/manifest.json); conditional ones (apple-release, measure, greptile, shared-code-reuse, handoff) load only when their trigger fires. */
|
||||
const STEP_SECTIONS: Record<string, string[]> = {
|
||||
'4': ['tests'], '5': ['tests'], '6': ['tests'], '6.5': ['greptile-early'], '7': ['test-coverage'], '8': ['plan-completion'], '9': ['review-army'],
|
||||
'11': ['adversarial'], '13': ['changelog'], '14.5': ['documentation'], '18': ['pr-body', 'capy-pr'], '19': ['pr-body', 'capy-pr'],
|
||||
};
|
||||
|
||||
/** The sections a child running Steps from..to reads (GSTA-167): its own steps' only. */
|
||||
export function segmentSections(from: string, to: string): string[] {
|
||||
return [...new Set(SHIP_STEPS.slice(SHIP_STEPS.indexOf(from as any), SHIP_STEPS.indexOf(to as any) + 1).flatMap(s => STEP_SECTIONS[s] ?? []))];
|
||||
}
|
||||
|
||||
function brief(l: Ledger, h: Omit<Handoff, 'sha'>, pins: Record<string, unknown>, recordText: string, o: Record<string, string | undefined>): string {
|
||||
const reviews = recordSection(recordText, 'Reviews') ?? [];
|
||||
const tokens = [...new Set(reviews.join('\n').match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/g) ?? [])];
|
||||
@@ -65,6 +76,8 @@ function brief(l: Ledger, h: Omit<Handoff, 'sha'>, pins: Record<string, unknown>
|
||||
block.trail ? `2. Run \`gstack-decision-trail start ${block.trail} <your run id>\` before any trail write.` : '2. No decision trail was named.',
|
||||
`3. Run /ship --continue <this file> for Steps ${h.from}-${h.to} only, under the printed child run: \`check\` at every boundary and before every dispatch batch, obeying \`verdict=\`. The context rule never stops you: \`verdict=delegate\` hands your remaining steps to a fresh child the same way (ship/sections/budget-handoff.md), and a \`CONTEXT_HIGH:\` note means continue. This run is at chain depth ${h.depth} of ${MAX_CHAIN_DEPTH}.`,
|
||||
block.launch_file ? `4. Source the launch file \`${block.launch_file}\` in every shell that runs a gstack command (its path only; never print its values).` : '4. No GSTACK_LAUNCH_FILE was named.', '',
|
||||
'## Read', '',
|
||||
`Only ship/SKILL.md's step list and, under ship/sections/, ${[...segmentSections(h.from, h.to), 'budget-handoff'].map(s => s + '.md').join(', ')}; a conditional section only when its manifest.json trigger fires. Never read ship/sections/preamble.md in full or docs/ship-budget.md: budget-handoff.md is the whole budget procedure.`, '',
|
||||
'## Record', '', `You are the only writer of \`${l.record}\` during this segment. It keeps its six headings (${RECORD_HEADINGS.join(', ')}). Original start tokens: ${tokens.join(', ') || 'none'}.`, '',
|
||||
'## Decisions and approvals in scope (verbatim from the record)', '', ...(recordSection(recordText, 'Decisions') ?? ['(none recorded)']), '',
|
||||
'## Budget', '', `Sub-cap ${h.sub_cap} ${block.unit} (meter=${block.meter}); the parent keeps a ${l.tail} tail reserve. Ledger: ${l.file}.`, '',
|
||||
|
||||
@@ -9,15 +9,15 @@ Field-by-field reference: docs/ship-budget.md. Exit 0 continue or delegate, 3 st
|
||||
2 usage; any non-zero exit means the next step does not start.
|
||||
|
||||
**Meter.** Read one per command:
|
||||
- Capy: in one execute program return only `(await tools.thread.get({ thread_id: "this" })).cost.tokens`
|
||||
and pass it as `--reading <n>`. For the per-call charge, read it again in your next
|
||||
call and pass the earlier value as `--prior-reading <n>`.
|
||||
- Capy: read `(await tools.thread.get({ thread_id: "this" })).cost.tokens` in the same program as the
|
||||
`gstack-ship-budget` command, never its own call: `--reading <n>`. `--prior-reading <n>` (per-call
|
||||
charge) is your previous call's reading; one older than the run's last reading is ignored.
|
||||
- Claude Code: `start --session <id>`, or echo the nonce the refusal prints once and
|
||||
rerun `start --nonce <value>`; the helper reads the journals itself (never `--reading`).
|
||||
- Elsewhere: `--meter estimate` (printed `meter=estimate`), only when the owner accepts it.
|
||||
|
||||
**Every boundary.** `gstack-ship-budget check --run <id> --step <done> --next <step> --reading <n>`.
|
||||
Before each dispatch batch, reserve the batch as one sum, then pass the printed id once
|
||||
Before each dispatch batch, reserve the batch as one sum, one role per `DISPATCH:` line, then pass the printed id once
|
||||
(`READING` is the current counter reading; `RESERVATION_ID` is the id the check prints):
|
||||
|
||||
```bash
|
||||
@@ -26,10 +26,10 @@ Before each dispatch batch, reserve the batch as one sum, then pass the printed
|
||||
```
|
||||
|
||||
On `ADMIT: k of n (remaining=<roles>)`, dispatch only those k and re-check the remaining roles
|
||||
after they settle; `close` flags roles never re-checked. `--finish` settles the reservation and the pass's other dispatch reservations. Admitting reviewers one at
|
||||
a time, name each one that came back in the next check's `--returned <id>`. A dispatch with no `review-log --start` settles with
|
||||
`gstack-ship-budget settle --run <id> --reservation <id>` when it returns
|
||||
(`--unknown` when the meter cannot see it), or `release` if it never ran.
|
||||
after they settle; `close` flags roles never re-checked. `--finish` settles the reservation and the pass's other dispatch reservations.
|
||||
Settle each reader when it returns: the next check's `--returned <id>`, or
|
||||
`gstack-ship-budget settle --run <id> --reservation <id> --reading <n>` (`--unknown` when the meter
|
||||
cannot see it); `release` one that never ran. Only these are measured: `--finish` or a step change settles without a reading.
|
||||
|
||||
**verdict=stop.** Print the STOP, RECOVER and GSTACK_RESULT lines as your last output and
|
||||
end status=incomplete. Raising the cap is the owner's call: `/ship --resume <record>
|
||||
|
||||
@@ -7,15 +7,15 @@ Field-by-field reference: docs/ship-budget.md. Exit 0 continue or delegate, 3 st
|
||||
2 usage; any non-zero exit means the next step does not start.
|
||||
|
||||
**Meter.** Read one per command:
|
||||
- Capy: in one execute program return only `(await tools.thread.get({ thread_id: "this" })).cost.tokens`
|
||||
and pass it as `--reading <n>`. For the per-call charge, read it again in your next
|
||||
call and pass the earlier value as `--prior-reading <n>`.
|
||||
- Capy: read `(await tools.thread.get({ thread_id: "this" })).cost.tokens` in the same program as the
|
||||
`gstack-ship-budget` command, never its own call: `--reading <n>`. `--prior-reading <n>` (per-call
|
||||
charge) is your previous call's reading; one older than the run's last reading is ignored.
|
||||
- Claude Code: `start --session <id>`, or echo the nonce the refusal prints once and
|
||||
rerun `start --nonce <value>`; the helper reads the journals itself (never `--reading`).
|
||||
- Elsewhere: `--meter estimate` (printed `meter=estimate`), only when the owner accepts it.
|
||||
|
||||
**Every boundary.** `gstack-ship-budget check --run <id> --step <done> --next <step> --reading <n>`.
|
||||
Before each dispatch batch, reserve the batch as one sum, then pass the printed id once
|
||||
Before each dispatch batch, reserve the batch as one sum, one role per `DISPATCH:` line, then pass the printed id once
|
||||
(`READING` is the current counter reading; `RESERVATION_ID` is the id the check prints):
|
||||
|
||||
```bash
|
||||
@@ -24,10 +24,10 @@ Before each dispatch batch, reserve the batch as one sum, then pass the printed
|
||||
```
|
||||
|
||||
On `ADMIT: k of n (remaining=<roles>)`, dispatch only those k and re-check the remaining roles
|
||||
after they settle; `close` flags roles never re-checked. `--finish` settles the reservation and the pass's other dispatch reservations. Admitting reviewers one at
|
||||
a time, name each one that came back in the next check's `--returned <id>`. A dispatch with no `review-log --start` settles with
|
||||
`gstack-ship-budget settle --run <id> --reservation <id>` when it returns
|
||||
(`--unknown` when the meter cannot see it), or `release` if it never ran.
|
||||
after they settle; `close` flags roles never re-checked. `--finish` settles the reservation and the pass's other dispatch reservations.
|
||||
Settle each reader when it returns: the next check's `--returned <id>`, or
|
||||
`gstack-ship-budget settle --run <id> --reservation <id> --reading <n>` (`--unknown` when the meter
|
||||
cannot see it); `release` one that never ran. Only these are measured: `--finish` or a step change settles without a reading.
|
||||
|
||||
**verdict=stop.** Print the STOP, RECOVER and GSTACK_RESULT lines as your last output and
|
||||
end status=incomplete. Raising the cap is the owner's call: `/ship --resume <record>
|
||||
|
||||
@@ -158,7 +158,7 @@ describe('dispatch reservations settle when the reviewer returns (GST-101)', ()
|
||||
expect(bad.err).toContain('--returned');
|
||||
});
|
||||
|
||||
test('--finish settles every consumed dispatch reservation of the pass, and the next reading measures them as one group', () => {
|
||||
test('--finish settles every consumed dispatch reservation of the pass; settled without a reading, they stay unmeasured (GSTA-167)', () => {
|
||||
const h = fresh();
|
||||
const s = started(h, '80M');
|
||||
const tokens: string[] = [];
|
||||
@@ -175,7 +175,8 @@ describe('dispatch reservations settle when the reviewer returns (GST-101)', ()
|
||||
expect(dispatches().map((r: any) => r.state)).toEqual(['settled', 'settled', 'settled']);
|
||||
const next = h.sb(['check', '--run', s.run, '--step', '9', '--next', '10', '--reading', String(reading)]);
|
||||
expect(h.field(next.out, 'BUDGET', 'reserved')).toBe('0');
|
||||
expect(dispatches().map((r: any) => r.actual)).toEqual([500_000, 500_000, 500_000]);
|
||||
expect(dispatches().map((r: any) => [r.actual, r.unmeasured])).toEqual([[undefined, true], [undefined, true], [undefined, true]]);
|
||||
expect(next.out.split('\n').filter(l => l.startsWith('UNMEASURED:') && l.includes('kind=dispatch'))).toHaveLength(3);
|
||||
});
|
||||
|
||||
test('moving to the next step settles dispatch reservations a reviewer consumed in the step before', () => {
|
||||
@@ -185,7 +186,8 @@ describe('dispatch reservations settle when the reviewer returns (GST-101)', ()
|
||||
expect(h.reviewLog(['--start', 'adversarial-review', '--reservation', id]).code).toBe(0);
|
||||
const c = h.sb(['check', '--run', s.run, '--step', '11', '--next', '11.5', '--reading', '600000']);
|
||||
expect(c.code).toBe(0);
|
||||
expect(h.ledger(s.ledger).reservations.find((r: any) => r.id === id)).toMatchObject({ state: 'settled', actual: 600_000 });
|
||||
expect(h.ledger(s.ledger).reservations.find((r: any) => r.id === id)).toMatchObject({ state: 'settled', unmeasured: true });
|
||||
expect(c.out).toContain(`UNMEASURED: id=${id} kind=dispatch next=dispatch:outside-voice reason=settled_without_reading`);
|
||||
});
|
||||
|
||||
test('close prints projected against actual for the run', () => {
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* GSTA-167 PR 3a: dispatch drift is measured per reader and only to the reading
|
||||
* a dispatch was settled with when it returned, never across the parent's own
|
||||
* work; the parent's interleaved calls stay in the step's sample. The per-call
|
||||
* charge takes a prior reading from the previous call only. Replays
|
||||
* gstack-evals #36 (a six-reader batch taught a 2.041 factor and priced the Red
|
||||
* Team at 15.17M, a false STOP with 14.4M of sub-cap unused) and #34 (the Red
|
||||
* Team's reservation interval covered the child's own review and QA, and a
|
||||
* prior reading six calls old fired CONTEXT_HIGH at call=1068386); evidence in
|
||||
* handoffs/GSTA-167/replay-36.md and replay-34.md in the GStack Minder drive.
|
||||
*/
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { loadCalibration, unitConstants } from '../lib/ship-budget';
|
||||
import { budgetHarness, type Harness } from './helpers/ship-budget-harness';
|
||||
|
||||
const cal = loadCalibration();
|
||||
const K = unitConstants(cal, 'capy-tokens');
|
||||
const harnesses: Harness[] = [];
|
||||
const fresh = () => { const h = budgetHarness(); harnesses.push(h); return h; };
|
||||
afterEach(() => { while (harnesses.length) harnesses.pop()!.cleanup(); });
|
||||
|
||||
/** Opus medium readers on #34/#36 cost about 1.7M each (replay-36: ~1.69M per specialist). */
|
||||
const READER = 1_690_000;
|
||||
const SIX = Array(6).fill('specialist');
|
||||
const roundUp = (n: number) => Math.ceil(n / cal.round_to) * cal.round_to;
|
||||
/** The per-reader price after a measured batch: the prior times actual/(prior x readers) times the headroom. */
|
||||
const priced = (actual: number, readers: number) => roundUp(K.per_dispatch * ((actual / (K.per_dispatch * readers)) * cal.headroom));
|
||||
|
||||
function start(h: Harness, cap: number, reading = 0) {
|
||||
const r = h.sb(['start', '--record', h.record(), '--token-cap', String(cap), '--reading', String(reading), '--reserve', '0']);
|
||||
expect(r.code, r.out + r.err).toBe(0);
|
||||
return { run: h.field(r.out, 'BUDGET_RUN', 'id'), ledger: h.field(r.out, 'BUDGET_RUN', 'ledger') };
|
||||
}
|
||||
function ok(h: Harness, args: string[]) {
|
||||
const r = h.sb(args);
|
||||
expect(r.code, r.out + r.err).toBe(0);
|
||||
return r;
|
||||
}
|
||||
const check = (h: Harness, run: string, step: string, next: string, reading: number, extra: string[] = []) =>
|
||||
ok(h, ['check', '--run', run, '--step', step, '--next', next, '--reading', String(reading), ...extra]);
|
||||
|
||||
describe('per-reader dispatch drift (#36)', () => {
|
||||
test('#36 replay: six readers measured at their return price the Red Team per reader, so it is admitted instead of a 15.17M STOP', () => {
|
||||
const h = fresh();
|
||||
const cap = 35_244_164;
|
||||
const s = start(h, cap);
|
||||
let reading = 10_710_000;
|
||||
const first = check(h, s.run, '9', 'dispatch:' + SIX.join(','), reading);
|
||||
expect(first.out).toMatch(/^ADMIT: 2 of 6 /m);
|
||||
const r1 = h.field(first.out, 'RESERVATION', 'id');
|
||||
expect(h.reviewLog(['--start', 'review', '--reservation', r1]).code).toBe(0);
|
||||
reading += 2 * READER;
|
||||
const rest = check(h, s.run, '9', 'dispatch:' + SIX.slice(2).join(','), reading, ['--returned', r1]);
|
||||
expect(rest.out).toMatch(new RegExp(`^DRIFT: id=${r1} kind=dispatch next=dispatch:specialist,specialist projected=${2 * K.per_dispatch} actual=${2 * READER} ratio=0\\.23$`, 'm'));
|
||||
expect(rest.out).not.toContain('ADMIT:');
|
||||
expect(Number(h.field(rest.out, 'BUDGET', 'projected'))).toBe(4 * priced(2 * READER, 2));
|
||||
const r2 = h.field(rest.out, 'RESERVATION', 'id');
|
||||
reading += 4 * READER;
|
||||
const red = check(h, s.run, '9', 'dispatch:red-team', reading, ['--returned', r2]);
|
||||
expect(h.field(red.out, 'BUDGET', 'verdict')).toBe('continue');
|
||||
expect(Number(h.field(red.out, 'BUDGET', 'projected'))).toBe(priced(2 * READER, 2));
|
||||
expect(Number(h.field(red.out, 'BUDGET', 'projected'))).toBeLessThan(3_000_000);
|
||||
expect(red.out).not.toContain('STOP:');
|
||||
});
|
||||
|
||||
test('a measured role prices its next reader at the larger of the per-reader projection and its per-reader mean', () => {
|
||||
const h = fresh();
|
||||
const s = start(h, 200_000_000);
|
||||
const a = h.field(check(h, s.run, '9', 'dispatch:' + SIX.join(','), 0).out, 'RESERVATION', 'id');
|
||||
const b = check(h, s.run, '9', 'dispatch:red-team', 6 * READER, ['--returned', a]);
|
||||
const r = h.ledger(s.ledger).reservations.find((x: any) => x.id === a);
|
||||
expect(r.actual).toBe(6 * READER);
|
||||
expect(Number(h.field(b.out, 'BUDGET', 'projected'))).toBe(priced(6 * READER, 6));
|
||||
const red = h.field(b.out, 'RESERVATION', 'id');
|
||||
const heavy = 3_910_000;
|
||||
const c = check(h, s.run, '9', 'dispatch:red-team,specialist', 6 * READER + heavy, ['--returned', red]);
|
||||
const factor = Math.max(READER / K.per_dispatch, heavy / K.per_dispatch) * cal.headroom;
|
||||
expect(Number(h.field(c.out, 'BUDGET', 'projected'))).toBe(Math.max(roundUp(K.per_dispatch * factor), heavy) + Math.max(roundUp(K.per_dispatch * factor), READER));
|
||||
});
|
||||
});
|
||||
|
||||
describe('a dispatch is measured to its return, never across the parent\'s own work (#34)', () => {
|
||||
test('#34 replay: a Red Team settled by the step change after the child\'s review and QA is UNMEASURED, so the voices keep the per-reader factor', () => {
|
||||
const h = fresh();
|
||||
const s = start(h, 200_000_000);
|
||||
check(h, s.run, '8', '9', 3_500_000);
|
||||
const specialists = h.field(check(h, s.run, '9', 'dispatch:' + SIX.join(','), 4_000_000).out, 'RESERVATION', 'id');
|
||||
expect(h.reviewLog(['--start', 'review', '--reservation', specialists]).code).toBe(0);
|
||||
let reading = 4_000_000 + 6 * READER;
|
||||
const redCheck = check(h, s.run, '9', 'dispatch:red-team', reading, ['--returned', specialists]);
|
||||
const red = h.field(redCheck.out, 'RESERVATION', 'id');
|
||||
expect(h.reviewLog(['--start', 'review', '--reservation', red]).code).toBe(0);
|
||||
reading += READER + 3_000_000;
|
||||
const step = check(h, s.run, '9', '10', reading);
|
||||
expect(step.out).toMatch(new RegExp(`^UNMEASURED: id=${red} kind=dispatch next=dispatch:red-team reason=settled_without_reading$`, 'm'));
|
||||
expect(step.out).not.toMatch(new RegExp(`^DRIFT: id=${red} `, 'm'));
|
||||
expect(step.out).toMatch(/^UNMEASURED: id=\S+ kind=step next=9 reason=dispatch_unmeasured$/m);
|
||||
expect(h.ledger(s.ledger).reservations.find((x: any) => x.id === red)).toMatchObject({ state: 'settled', unmeasured: true });
|
||||
check(h, s.run, '10', '11', reading + 400_000);
|
||||
const voices = check(h, s.run, '11', 'dispatch:adversarial-native,outside-voice', reading + 800_000);
|
||||
expect(Number(h.field(voices.out, 'BUDGET', 'projected'))).toBe(2 * priced(6 * READER, 6));
|
||||
});
|
||||
|
||||
test('settled with a reading at its return, the Red Team measures only its own interval; the parent\'s QA after it lands in Step 9\'s sample', () => {
|
||||
const h = fresh();
|
||||
const s = start(h, 200_000_000);
|
||||
check(h, s.run, '8', '9', 3_500_000);
|
||||
const red = h.field(check(h, s.run, '9', 'dispatch:red-team', 4_000_000).out, 'RESERVATION', 'id');
|
||||
expect(h.reviewLog(['--start', 'review', '--reservation', red]).code).toBe(0);
|
||||
const settled = ok(h, ['settle', '--run', s.run, '--reservation', red, '--reading', String(4_000_000 + READER)]);
|
||||
expect(settled.out).toMatch(new RegExp(`^DRIFT: id=${red} kind=dispatch next=dispatch:red-team projected=${K.per_dispatch} actual=${READER} ratio=0\\.23$`, 'm'));
|
||||
const step = check(h, s.run, '9', '10', 4_000_000 + READER + 3_000_000);
|
||||
expect(step.out).toMatch(/^DRIFT: id=\S+ kind=step next=9 projected=\d+ actual=3500000 ratio=\d+\.\d\d$/m);
|
||||
expect(step.out).not.toContain('UNMEASURED:');
|
||||
});
|
||||
|
||||
test('--finish settles the pass without a reading, so its dispatches are UNMEASURED and the factor stays at the prior', () => {
|
||||
const h = fresh();
|
||||
const s = start(h, 200_000_000);
|
||||
const id = h.field(check(h, s.run, '9', 'dispatch:' + SIX.join(','), 0).out, 'RESERVATION', 'id');
|
||||
const token = h.reviewLog(['--start', 'review', '--reservation', id]).out.trim();
|
||||
const row = JSON.stringify({ skill: 'review', timestamp: new Date().toISOString(), status: 'clean', issues_found: 0, critical: 0, informational: 0 });
|
||||
expect(h.reviewLog([row, '--finish', token]).code).toBe(0);
|
||||
const next = check(h, s.run, '9', 'dispatch:red-team', 6 * READER + 5_000_000);
|
||||
expect(next.out).toMatch(new RegExp(`^UNMEASURED: id=${id} kind=dispatch `, 'm'));
|
||||
expect(h.field(next.out, 'BUDGET', 'calibration')).toEndWith('dispatch:prior');
|
||||
expect(Number(h.field(next.out, 'BUDGET', 'projected'))).toBe(K.per_dispatch);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the per-call charge takes a prior reading from the previous call only (#34)', () => {
|
||||
test('a prior reading older than the run\'s last reading is ignored: call=unknown, never a six-call span', () => {
|
||||
const h = fresh();
|
||||
const s = start(h, 60_000_000, 1_000_000);
|
||||
check(h, s.run, '4', '5', 8_900_000);
|
||||
const stale = check(h, s.run, '5', '6', 9_162_515, ['--prior-reading', '8094129']);
|
||||
expect(h.field(stale.out, 'BUDGET', 'call')).toBe('unknown');
|
||||
expect(stale.out).toMatch(/^CALL_UNKNOWN: --prior-reading 8094129 is older than the run's last reading 8900000; /m);
|
||||
expect(stale.out).not.toContain('1068386');
|
||||
const fine = check(h, s.run, '6', '6.5', 9_450_000, ['--prior-reading', '9162515']);
|
||||
expect(h.field(fine.out, 'BUDGET', 'call')).toBe('287485');
|
||||
expect(fine.out).not.toContain('CALL_UNKNOWN');
|
||||
});
|
||||
|
||||
test('#34 replay: a child passing the parent\'s handoff reading as its prior gets call=unknown, not 1068386', () => {
|
||||
const h = fresh();
|
||||
const s = start(h, 400_000_000, 2_110_477);
|
||||
const ho = ok(h, ['handoff', '--run', s.run, '--from', '7', '--to', '11.5', '--base', 'origin/main', '--reading', '8094129']);
|
||||
const brief = ho.out.match(/^HANDOFF: (\S+) /m)![1]!;
|
||||
const v = ok(h, ['verify-handoff', '--in', brief, '--reading', '8800000']);
|
||||
const child = h.field(v.out, 'BUDGET_RUN', 'id');
|
||||
const c = check(h, child, '6.5', '7', 9_162_515, ['--prior-reading', '8094129']);
|
||||
expect(h.field(c.out, 'BUDGET', 'call')).toBe('unknown');
|
||||
expect(c.out).not.toContain('call=1068386');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the budget section states both contracts', () => {
|
||||
const tmpl = fs.readFileSync(path.join(import.meta.dir, '..', 'ship', 'sections', 'budget-handoff.md.tmpl'), 'utf8');
|
||||
test('the meter read happens inside the same program as the gstack-ship-budget command, never as its own call', () => {
|
||||
expect(tmpl).not.toContain('pass the earlier value');
|
||||
expect(tmpl).toMatch(/same (?:execute )?program as the\s+(?:`)?gstack-ship-budget(?:`)? command, never (?:as )?its own call/);
|
||||
expect(tmpl).toMatch(/--prior-reading[\s\S]{0,200}previous call/);
|
||||
expect(tmpl).toMatch(/older than the run's last reading is ignored/);
|
||||
});
|
||||
test('a batch names one role per reader and each reader is settled with a reading when it returns', () => {
|
||||
expect(tmpl).toMatch(/one role per (?:reader|`DISPATCH:` line)/);
|
||||
expect(tmpl).toMatch(/Settle each reader when it returns[\s\S]{0,200}--reservation <id> --reading <n>/);
|
||||
expect(tmpl).toMatch(/Only these are measured: `--finish` or a step change settles without a reading/);
|
||||
});
|
||||
});
|
||||
@@ -92,8 +92,8 @@ describe('reservations', () => {
|
||||
const c = h.sb(['check', '--run', s.run, '--step', '9', '--next', 'dispatch:specialist', '--reading', '0']);
|
||||
const id = h.field(c.out, 'RESERVATION', 'id');
|
||||
expect(Number(h.field(c.out, 'RESERVATION', 'tokens'))).toBe(K.per_dispatch);
|
||||
h.sb(['settle', '--run', s.run, '--reservation', id]);
|
||||
const spent = 2 * K.per_dispatch;
|
||||
h.sb(['settle', '--run', s.run, '--reservation', id, '--reading', String(spent)]);
|
||||
const next = h.sb(['check', '--run', s.run, '--step', '9', '--next', 'dispatch:red-team', '--reading', String(spent)]);
|
||||
expect(Number(h.field(next.out, 'BUDGET', 'used'))).toBe(spent);
|
||||
const projected = Number(h.field(next.out, 'BUDGET', 'projected'));
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* GSTA-167 PR 3a: a handoff child reads only its segment's sections. On #34
|
||||
* and #36 the Steps 7-11.5 child booted at 1.29M by reading the whole ship
|
||||
* skill, preamble.md and docs/ship-budget.md before its first step; the brief
|
||||
* now names ship/SKILL.md's step list, the segment's sections and
|
||||
* budget-handoff.md, and nothing else.
|
||||
*/
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import * as fs from 'node:fs';
|
||||
import { segmentSections } from '../lib/ship-handoff';
|
||||
import { budgetHarness, type Harness } from './helpers/ship-budget-harness';
|
||||
|
||||
const harnesses: Harness[] = [];
|
||||
const fresh = () => { const h = budgetHarness(); harnesses.push(h); return h; };
|
||||
afterEach(() => { while (harnesses.length) harnesses.pop()!.cleanup(); });
|
||||
|
||||
function briefFor(from: string, to: string): string {
|
||||
const h = fresh();
|
||||
const s = h.sb(['start', '--record', h.record(), '--token-cap', '400M', '--reading', '98000']);
|
||||
expect(s.code, s.out + s.err).toBe(0);
|
||||
const ho = h.sb(['handoff', '--run', h.field(s.out, 'BUDGET_RUN', 'id'), '--from', from, '--to', to, '--base', 'origin/main', '--reading', '1000000']);
|
||||
expect(ho.code, ho.out + ho.err).toBe(0);
|
||||
return fs.readFileSync(ho.out.match(/^HANDOFF: (\S+) /m)![1]!, 'utf8');
|
||||
}
|
||||
const readSection = (text: string) => text.slice(text.indexOf('## Read'), text.indexOf('\n## ', text.indexOf('## Read') + 1));
|
||||
|
||||
describe('the handoff brief names only the segment\'s sections', () => {
|
||||
test('Steps 7-11.5: the step list plus test-coverage, plan-completion, review-army and adversarial', () => {
|
||||
const read = readSection(briefFor('7', '11.5'));
|
||||
expect(read).toContain("ship/SKILL.md's step list");
|
||||
for (const s of ['test-coverage.md', 'plan-completion.md', 'review-army.md', 'adversarial.md', 'budget-handoff.md']) expect(read).toContain(s);
|
||||
for (const s of ['changelog.md', 'documentation.md', 'pr-body.md', 'capy-pr.md', 'tests.md']) expect(read).not.toContain(s);
|
||||
expect(read).toMatch(/Never read ship\/sections\/preamble\.md in full or docs\/ship-budget\.md/);
|
||||
});
|
||||
|
||||
test('Steps 12-22: the step list plus changelog, documentation, pr-body and capy-pr', () => {
|
||||
const read = readSection(briefFor('12', '22'));
|
||||
for (const s of ['changelog.md', 'documentation.md', 'pr-body.md', 'capy-pr.md', 'budget-handoff.md']) expect(read).toContain(s);
|
||||
for (const s of ['test-coverage.md', 'plan-completion.md', 'review-army.md', 'adversarial.md']) expect(read).not.toContain(s);
|
||||
expect(read).toMatch(/Never read ship\/sections\/preamble\.md in full or docs\/ship-budget\.md/);
|
||||
});
|
||||
|
||||
test('a grandchild segment names only the sections of its own steps', () => {
|
||||
expect(segmentSections('9', '11.5')).toEqual(['review-army', 'adversarial']);
|
||||
expect(segmentSections('19', '22')).toEqual(['pr-body', 'capy-pr']);
|
||||
expect(segmentSections('1', '22')).toEqual(['tests', 'greptile-early', 'test-coverage', 'plan-completion', 'review-army', 'adversarial', 'changelog', 'documentation', 'pr-body', 'capy-pr']);
|
||||
});
|
||||
|
||||
test('every section the brief can name is a registered ship section', () => {
|
||||
const manifest = JSON.parse(fs.readFileSync(`${import.meta.dir}/../ship/sections/manifest.json`, 'utf8'));
|
||||
const ids = new Set(manifest.sections.map((s: any) => s.id));
|
||||
for (const id of segmentSections('1', '22')) expect(ids.has(id)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"bytes": 73,
|
||||
"why": "GSTA-167 PR 3a: budget-handoff meter read inside the budget command's program, one role per DISPATCH line, settle readers with a reading at return"
|
||||
}
|
||||
Reference in new issue
Block a user