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:
garrytanandMatt Van Horn committed 2026-10-03 19:49:05 +00:00
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');
});
});
+5
View File
@@ -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':
+69
View File
@@ -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');