mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-14 20:37:14 +01:00
fix: mark Remaining Work provenance so restore verifies guessed steps first (#3004)
/context-save now starts every Remaining Work item with `Open.` and ends it with how this session knows it: `(path run)` with the observed outcome, `(path read)` or `(path assumed)` for concrete paths, and `(target state checked)`, `(code read)` or `(path assumed)` for writing steps. /context-restore keeps saved order and original text but splits the list into Next steps and Verify first (assumed, code-read, failed runs and unmarked concrete or writing items), prints a banner for pre-marker checkpoints, and option A walks saved order so an unverified first item is verified before any later step runs. Adds a prompt-byte contract test for the marker wire format and a periodic behavior E2E (context-restore-provenance-order) that seeds an assumed item ahead of a run item plus a code-read write. Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
This commit is contained in:
1 parent
ac20ef1e13
commit
805536c435
7 files changed
+235
-8
No files matched your search
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Remaining Work provenance (#3004): prompt-byte contract for the shipped
|
||||
* context-save and context-restore SKILL.md files.
|
||||
*
|
||||
* The markers are a wire format between two skills: /context-save writes them
|
||||
* into a checkpoint and a later /context-restore sorts on them. Only that
|
||||
* contract is pinned (marker tokens, group headings, banner, option-A order),
|
||||
* not the surrounding prose. Behavior is covered by the periodic E2E case
|
||||
* context-restore-provenance-order.
|
||||
*
|
||||
* Value: protects=save writes the five provenance markers and restore sorts them into Next steps and Verify first; fails_when=a marker, a group or the saved-order option A is dropped; why_new=no test reads Remaining Work; seam=none
|
||||
*/
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const MARKERS = ['(path run)', '(path read)', '(path assumed)', '(code read)', '(target state checked)'];
|
||||
|
||||
function read(rel: string): string {
|
||||
return fs.readFileSync(path.join(ROOT, rel), 'utf-8');
|
||||
}
|
||||
|
||||
function between(text: string, start: string, end: string): string {
|
||||
const from = text.indexOf(start);
|
||||
expect(from, `missing ${start}`).toBeGreaterThanOrEqual(0);
|
||||
const to = text.indexOf(end, from + start.length);
|
||||
expect(to, `missing ${end} after ${start}`).toBeGreaterThan(from);
|
||||
return text.slice(from, to);
|
||||
}
|
||||
|
||||
describe('Remaining Work provenance contract', () => {
|
||||
test('context-save marks every item Open. and defines all five markers, writing steps first', () => {
|
||||
const save = read('context-save/SKILL.md');
|
||||
const table = between(save, '**Remaining Work provenance.**', 'If the user provided a title');
|
||||
for (const marker of MARKERS) expect(table).toContain(`\`${marker}\``);
|
||||
expect(table).toContain('`Open.`');
|
||||
expect(table).toContain('`[done]`');
|
||||
expect(table.indexOf('a writing step')).toBeLessThan(table.indexOf('names a concrete path'));
|
||||
const format = between(save, '### Remaining Work', '### Notes');
|
||||
expect(format).toContain('`Open.`');
|
||||
});
|
||||
|
||||
test('context-restore sorts into Next steps and Verify first and option A walks saved order', () => {
|
||||
const restore = read('context-restore/SKILL.md');
|
||||
expect(restore).not.toContain('{remaining work items}');
|
||||
const rules = between(restore, '**Sort Remaining Work by provenance.**', 'Read the chosen file');
|
||||
const verifyFirst = between(rules, 'Put an item under **Verify first**', 'Every other item');
|
||||
for (const marker of ['(path assumed)', '(code read)']) expect(verifyFirst).toContain(`\`${marker}\``);
|
||||
expect(verifyFirst).toContain('reports a failure');
|
||||
expect(verifyFirst).toContain('has no marker');
|
||||
const nextSteps = between(rules, 'Every other item goes under **Next steps**', 'Verifying means');
|
||||
for (const marker of ['(path run)', '(path read)', '(target state checked)']) expect(nextSteps).toContain(`\`${marker}\``);
|
||||
expect(rules).toContain(
|
||||
'`This checkpoint predates provenance markers; items naming commands, paths or writes are listed under Verify first.`',
|
||||
);
|
||||
const summary = between(restore, '### Remaining Work', '### Notes');
|
||||
expect(summary.indexOf('Next steps')).toBeLessThan(summary.indexOf('Verify first'));
|
||||
const optionA = between(restore, 'If A,', '## If no saved contexts exist');
|
||||
expect(optionA).toContain('first Remaining Work item in saved order');
|
||||
expect(optionA).toContain('under Verify first, suggest verifying it');
|
||||
});
|
||||
});
|
||||
@@ -655,6 +655,7 @@ export const E2E_TOUCHFILES: Record<string, string[]> = {
|
||||
'context-save-routing': [ 'context-save/**', 'scripts/resolvers/preamble.ts', 'test/skill-e2e-context-skills.test.ts', 'bin/gstack-skill-start', 'test/helpers/shipped-skill-routing.ts'],
|
||||
'context-save-then-restore-roundtrip': ['bin/gstack-state-root.sh', 'lib/state-root.ts', 'context-save/**', 'context-restore/**', 'bin/gstack-slug', 'bin/gstack-remote-identity.sh', 'test/skill-e2e-context-skills.test.ts', 'bin/gstack-skill-start', 'test/helpers/shipped-skill-routing.ts'],
|
||||
'context-restore-fragment-match': [ 'context-restore/**', 'test/skill-e2e-context-skills.test.ts', 'bin/gstack-skill-start', 'test/helpers/shipped-skill-routing.ts'],
|
||||
'context-restore-provenance-order': ['context-save/**', 'context-restore/**', 'test/skill-e2e-context-skills.test.ts', 'bin/gstack-skill-start', 'test/helpers/shipped-skill-routing.ts'],
|
||||
'context-restore-empty-state': [ 'context-restore/**', 'test/skill-e2e-context-skills.test.ts', 'bin/gstack-skill-start', 'test/helpers/shipped-skill-routing.ts'],
|
||||
'context-restore-list-delegates': [ 'context-restore/**', 'test/skill-e2e-context-skills.test.ts', 'bin/gstack-skill-start', 'test/helpers/shipped-skill-routing.ts'],
|
||||
'context-restore-legacy-compat': [ 'context-restore/**', 'test/skill-e2e-context-skills.test.ts', 'bin/gstack-skill-start', 'test/helpers/shipped-skill-routing.ts'],
|
||||
@@ -1287,6 +1288,7 @@ export const E2E_TIERS: Record<string, 'gate' | 'periodic' | 'marathon'> = {
|
||||
'context-save-routing': 'periodic', // Proves /context-save routes via Skill tool
|
||||
'context-save-then-restore-roundtrip': 'periodic', // Full cycle in one session
|
||||
'context-restore-fragment-match': 'periodic', // /context-restore <fragment>
|
||||
'context-restore-provenance-order': 'periodic', // #3004 Next steps / Verify first, 3-trial panel
|
||||
'context-restore-empty-state': 'periodic', // Graceful zero-saves message
|
||||
'context-restore-list-delegates': 'periodic', // /context-restore list redirect
|
||||
'context-restore-legacy-compat': 'periodic', // Pre-rename files still load
|
||||
@@ -1664,6 +1666,7 @@ export const E2E_KINDS: Record<string, 'rule' | 'behavior' | 'judge'> = {
|
||||
'context-save-routing': 'rule',
|
||||
'context-save-then-restore-roundtrip': 'rule',
|
||||
'context-restore-fragment-match': 'rule',
|
||||
'context-restore-provenance-order': 'behavior',
|
||||
'context-restore-empty-state': 'rule',
|
||||
'context-restore-list-delegates': 'rule',
|
||||
'context-restore-legacy-compat': 'rule',
|
||||
@@ -1787,6 +1790,8 @@ export const E2E_KINDS: Record<string, 'rule' | 'behavior' | 'judge'> = {
|
||||
export const BEHAVIOR_WHY: Record<string, string> = {
|
||||
'shared-libs-opportunity-judgment':
|
||||
"Whether a candidate extraction is worth recommending is a judgment call; the read-only invariant stays a contract.",
|
||||
'context-restore-provenance-order':
|
||||
"How the live model lays out the two Remaining Work groups can drift run to run; never executing a seeded step and never leapfrogging the unverified first item stay contracts.",
|
||||
'review-design-lite':
|
||||
"How many of the seven design-lite checklist items the live review flags varies run to run; the fake-engine rows it must carry stay strict.",
|
||||
'review-army-red-team':
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
||||
import { JUDGE_MS, CAPTURE_MS } from './helpers/eval-budgets';
|
||||
import { runSkillTest } from './helpers/session-runner';
|
||||
import { expectContract } from './helpers/eval-store';
|
||||
import {
|
||||
ROOT, runId, evalsEnabled,
|
||||
describeIfSelected, testConcurrentIfSelected,
|
||||
@@ -136,6 +137,7 @@ describeIfSelected('Context Skills E2E (live-fire)', [
|
||||
'context-save-routing',
|
||||
'context-save-then-restore-roundtrip',
|
||||
'context-restore-fragment-match',
|
||||
'context-restore-provenance-order',
|
||||
'context-restore-empty-state',
|
||||
'context-restore-list-delegates',
|
||||
'context-restore-legacy-compat',
|
||||
@@ -277,6 +279,73 @@ Do NOT use AskUserQuestion.`,
|
||||
try { fs.rmSync(workDir, { recursive: true, force: true }); } catch {}
|
||||
}, CAPTURE_MS);
|
||||
|
||||
// ── 3b. Provenance (#3004): guessed and code-read steps are Verify first ──
|
||||
// The checkpoint ranks an assumed step first, then a step that was run, then
|
||||
// a write whose target was never inspected. Restore must show the run step
|
||||
// under Next steps, the other two under Verify first, and option A must
|
||||
// verify the first item instead of leapfrogging to the runnable one.
|
||||
testConcurrentIfSelected('context-restore-provenance-order', async () => {
|
||||
const { workDir, gstackHome, slug } = setupWorkdir('provenance');
|
||||
seedSave(gstackHome, slug, '20260404-120000-billing-migration.md',
|
||||
{ status: 'in-progress', branch: 'main', timestamp: '2026-04-04T12:00:00Z' },
|
||||
[
|
||||
'## Working on: billing migration',
|
||||
'',
|
||||
'### Summary',
|
||||
'Moving invoices to the new billing schema.',
|
||||
'',
|
||||
'### Remaining Work',
|
||||
'1. Open. Run the migration check with SWITCH_B=1 against staging, PROV_ASSUMED_7Q. (path assumed)',
|
||||
'2. Open. Run bun test test/billing.test.ts, PROV_RUN_4K. (path run) exit 0',
|
||||
'3. Open. Apply scripts/backfill.sql (INSERT IGNORE into invoices), PROV_WRITE_9Z. (code read)',
|
||||
'',
|
||||
'### Notes',
|
||||
'None.',
|
||||
'',
|
||||
].join('\n'));
|
||||
|
||||
const result = await runSkillTest({
|
||||
prompt: `Run /context-restore. Invoke via the Skill tool and present its summary. Do NOT use AskUserQuestion: assume the user picks A (continue working). Do not execute or edit anything. End your reply with exactly one line \`FIRST_ACTION: <verify|do> <PROV token of the item you would start with>\`.`,
|
||||
workingDirectory: workDir,
|
||||
env: { GSTACK_HOME: gstackHome },
|
||||
maxTurns: 10,
|
||||
allowedTools: ['Skill', 'Bash', 'Read', 'Grep', 'Glob'],
|
||||
timeout: JUDGE_MS,
|
||||
testName: 'context-restore-provenance-order',
|
||||
runId,
|
||||
});
|
||||
|
||||
logCost('context-restore-provenance-order', result);
|
||||
|
||||
const out = result.output || '';
|
||||
const base = Math.max(0, out.indexOf('Remaining Work'));
|
||||
const next = out.indexOf('Next steps', base);
|
||||
const verify = next >= 0 ? out.indexOf('Verify first', next) : -1;
|
||||
const pos = (token: string) => out.indexOf(token, base);
|
||||
const grouped = next >= 0 && verify > next
|
||||
&& pos('PROV_RUN_4K') > next && pos('PROV_RUN_4K') < verify
|
||||
&& pos('PROV_ASSUMED_7Q') > verify && pos('PROV_WRITE_9Z') > verify;
|
||||
const firstAction = out.match(/FIRST_ACTION:\s*(verify|do)\s+(PROV_\w+)/i);
|
||||
const ranSeeded = (result.toolCalls || []).some((tc) => tc.tool === 'Bash'
|
||||
&& /SWITCH_B=1|backfill\.sql|billing\.test/.test(JSON.stringify(tc.input || {})));
|
||||
const exitOk = ['success', 'error_max_turns'].includes(result.exitReason);
|
||||
const name = 'context-restore provenance order';
|
||||
|
||||
expectContract(!ranSeeded, 'context-restore-provenance-order: restore executed a seeded Remaining Work command', { collector: evalCollector, name });
|
||||
expectContract(!(firstAction && firstAction[2] !== 'PROV_ASSUMED_7Q' && /do/i.test(firstAction[1])),
|
||||
'context-restore-provenance-order: option A jumped past the unverified first item to execute a later one', { collector: evalCollector, name });
|
||||
|
||||
const startsByVerifying = !!firstAction && /verify/i.test(firstAction[1]) && firstAction[2] === 'PROV_ASSUMED_7Q';
|
||||
recordE2E(evalCollector, name, 'Context Skills E2E', result, {
|
||||
passed: exitOk && grouped && startsByVerifying,
|
||||
});
|
||||
|
||||
expect(exitOk).toBe(true);
|
||||
expect(grouped).toBe(true);
|
||||
expect(startsByVerifying).toBe(true);
|
||||
try { fs.rmSync(workDir, { recursive: true, force: true }); } catch {}
|
||||
}, CAPTURE_MS);
|
||||
|
||||
// ── 4. /context-restore with zero saves → graceful empty-state ───────
|
||||
testConcurrentIfSelected('context-restore-empty-state', async () => {
|
||||
const { workDir, gstackHome, slug } = setupWorkdir('empty');
|
||||
|
||||
Reference in new issue
Block a user