diff --git a/CHANGELOG.md b/CHANGELOG.md
index f9e7f26e2..77ba60dc3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,7 +5,8 @@
**A /ship receipt now survives a base merge that only regenerates outputs, and a rebase onto the tree it reviewed.** On gstack-evals, two full /ship reruns came from a merge of main that changed a generated file's diff (U1 paid about 28M tokens for its rerun), and one came from a rebase whose tree was byte-identical to the reviewed one (about 60M). Each re-reviewed code the receipt already covered. For crews the rule is now a single line. Merge main into a reviewed branch, and never rebase it.
### Changed
-- **`gstack-ship-receipt carry`**: an output of an entry with a `command` in origin/'s `.gstack/generated.json` counts as `generated`, not product, when the `--pregate` result for HEAD shows the `regen` check passing with those entries fresh. Without that evidence, and for an entry with no command or one only the branch registers, the output stays product.
+- **`gstack-ship-receipt carry`**: an output of an entry with a `command` in origin/'s `.gstack/generated.json` counts as `generated`, not product, when the `--pregate` result for HEAD lists it under the `regen` check's new `proven` paths and that check ran the same base registry. Without that evidence, for a file that matches an output glob but that the command never writes, and for an entry with no command or one only the branch registers, the output stays product.
+- **`gstack-pregate` `regen` check**: each touched output is deleted in the scratch worktree before its entry's command runs, and the ones the command recreates byte-identically are recorded as `proven`. Pass and fail are unchanged.
- **Carry across rewritten history**: `write` records `head_tree` and `product_patch_id`, and each carry rebinds both and keeps earlier trees in `carry.trees`. A head that is no longer an ancestor carries only when its tree is one the receipt covers and its product patch-id against the current base matches; it records `carry.ancestry: rewritten` and needs no old commit, so a fresh clone carries too. A different tree is the new `RECEIPT_CARRY_TREE_MISMATCH`. The reviewed tree on a base that moved under it is `RECEIPT_CARRY_PRODUCT_CHANGED`.
- **Docs**: docs/ship-policy.md#receipt-carry opens with a four-row table: merge with the product unchanged, merge that changes product, rebase onto a covered tree, and any other rebase. docs/captain.md and captain/templates/CREW.md say to merge main in and never rebase a reviewed branch, and the troubleshooting entries for the carry refusals match the new rules.
diff --git a/bin/gstack-ship-receipt b/bin/gstack-ship-receipt
index e6d89eaf9..53391b011 100755
--- a/bin/gstack-ship-receipt
+++ b/bin/gstack-ship-receipt
@@ -63,7 +63,7 @@ const HELP = `gstack-ship-receipt — the \`\`\`gstack-ship-receipt\`\`\` block
the P7 adoption number over merged PRs (deduplicated by run and thread)
carry --pr [--repo ] | --body [--base [] [--head ] [--pregate ] [--dry-run] [--out ]
carry the receipt to HEAD when only a base merge, tests, release metadata or registered
- outputs the --pregate regen check proved fresh changed since its head (product patch-id
+ outputs the --pregate regen check proved (recreated them) changed since its head (product patch-id
unchanged), or after a rebase onto a tree the receipt covers with the recorded product
patch-id: rewrites the receipt with a \`carry\` record, posts the body (gh pr edit) and
prints GSTACK_RESULT: skill=ship status=complete; RECEIPT_CARRY_PRODUCT_CHANGED /
@@ -385,18 +385,20 @@ function pregateFieldFor(repoRoot: string, file: string, head: string): { ok: tr
}
/**
- * Generated outputs the carry may set aside as metadata: the outputs of every
- * entry with a `command` in origin/'s `.gstack/generated.json`, and only
- * when the pregate passed for HEAD shows the regen check regenerating them
- * fresh. Without that evidence an output is product like any other file.
+ * Generated outputs the carry may set aside as metadata: the paths the pregate
+ * passed for HEAD proved (its regen check deleted each touched output and the
+ * entry's command recreated it byte-identically), when that check ran
+ * origin/'s `.gstack/generated.json` at the commit this carry reads and the
+ * path is an output of an entry with a `command` there. Anything else is product.
*/
function verifiedOutputs(repoRoot: string, base: string, pregateFile: string | undefined): string[] {
if (!pregateFile) return [];
const regen = readPregateFile(pregateFile).file?.checks.find(c => c.id === 'regen');
- if (!regen || regen.status !== 'pass' || !/\bfresh\b/.test(regen.detail ?? '')) return [];
+ if (!regen || regen.status !== 'pass' || !regen.proven?.length) return [];
const load = loadRegistryFromBase(repoRoot, base);
- if (isRegistryUnavailable(load) || load.errors.length) return [];
- return load.registry.entries.filter(e => e.command).flatMap(e => e.outputs);
+ if (isRegistryUnavailable(load) || load.errors.length || regen.inputs?.registry !== load.source.label) return [];
+ const owned = load.registry.entries.filter(e => e.command).flatMap(e => e.outputs);
+ return regen.proven.filter(p => matchesAny(owned, p));
}
interface ProductRules { extra: { metadata: string[]; outputs: readonly string[] }; metadata: string[]; templates: readonly string[] }
@@ -445,7 +447,9 @@ function productPatchAgainstBase(repoRoot: string, rules: ProductRules, baseRef:
* reran a full /ship on identical product code after a base merge or a
* test-only fix; the carry compares the product diff against the base at the
* receipt's head and at HEAD by patch-id, classifies every changed path, and
- * records the range-diff as evidence in the receipt it rewrites.
+ * records the range-diff as evidence in the receipt it rewrites. After a rebase
+ * or force-push it carries only onto a tree the receipt covers whose product
+ * patch-id against the current base is the recorded one (planTreeCarry).
*/
function cmdCarry(rest: string[], json: boolean): number {
const bodyFile = flag(rest, '--body');
diff --git a/docs/pregate.md b/docs/pregate.md
index a42b37831..2677f75b7 100644
--- a/docs/pregate.md
+++ b/docs/pregate.md
@@ -146,7 +146,11 @@ Two stages with separate clocks, both printed:
~2.5 s for `regen` and under 200 ms for each of the others):
- `regen`: every registry entry whose inputs or outputs are touched is fresh
- (`gstack-regen check` in a scratch worktree).
+ (`gstack-regen check` in a scratch worktree). Each touched output is deleted
+ before its entry's command runs; the ones the command recreates
+ byte-identically are recorded as the check's `proven` paths, which
+ `gstack-ship-receipt carry` reads. An output it does not recreate is neither
+ stale nor proven.
- `secrets`: `gstack-redact` over the branch's added lines (merge-base to the
working tree, untracked files included). HIGH, or MEDIUM in the `secret`
category, fails; other MEDIUM findings warn. Cannot be downgraded.
diff --git a/docs/ship-policy.md b/docs/ship-policy.md
index 8890a2f4a..e767904bb 100644
--- a/docs/ship-policy.md
+++ b/docs/ship-policy.md
@@ -933,9 +933,13 @@ edit). A logic change in a
stamped source file is product. Lockfiles are product unless the policy names
them. An output of an entry with a `command` in origin/'s
`.gstack/generated.json` is `generated`, not product, only when the
-`--pregate` result passed for HEAD shows the `regen` check passing with those
-entries fresh; without that evidence, or for an entry with no command or one
-only the branch registers, the output is product (U1 on gstack-evals paid a
+`--pregate` result passed for HEAD lists it under the `regen` check's
+`proven` paths (the check deleted the touched output in its scratch worktree
+and the command recreated it byte-identically) and that check ran the same
+registry, origin/ at the commit the carry reads. A file that matches an
+output glob but that the command never writes is not proven. Without that
+evidence, or for an entry with no command or one only the branch registers,
+the output is product (U1 on gstack-evals paid a
full rerun because a merge changed its generated index's diff). A base merge
contributes no product change of its own. A receipt carried twice keeps the reviewed head as
`carry.from` and adds up the merges and paths. The carried receipt records
diff --git a/lib/headless-artifacts.ts b/lib/headless-artifacts.ts
index 2c37e3dcc..a42338611 100644
--- a/lib/headless-artifacts.ts
+++ b/lib/headless-artifacts.ts
@@ -69,6 +69,8 @@ export interface PregateCheck {
stage?: 'preflight' | 'tests'; inputs?: Record; fix?: string; code?: string; lines?: string[];
// Lane D (GSTA-112): a non-pass names its cause; an environment cause carries its kind and evidence.
cause?: 'diff' | 'environment'; environment?: { kind: string; evidence: string; proof?: 'launch' | 'output' };
+ /** `regen`: the touched outputs the generator recreated byte-identically from a deleted copy (what a receipt carry may set aside as generated). */
+ proven?: string[];
}
/** A remote_gate receipt accepted from a stamp-only predecessor tree (lib/pregate/remote-receipt.ts): the gated tree, the tree it was accepted for, the policy, and every differing path with its stamp rule. */
export interface PregateReceiptSubstitution { gated_tree: string; accepted_for: string; policy: string; files: Array<{ path: string; rule: string }> }
@@ -205,7 +207,7 @@ export const ARTIFACT_SCHEMAS = {
type: 'object', required: ['schema_version', 'tree', 'checks'],
properties: {
schema_version: { const: 1 }, run: str, tree: str, stage1_ms: num, stage2_ms: num,
- checks: { type: 'array', items: { type: 'object', required: ['id', 'status'], properties: { id: str, status: { enum: ['pass', 'fail', 'warn', 'incomplete', 'requires-remote', 'not_installed'] }, ms: num, detail: str, stage: { enum: ['preflight', 'tests'] }, inputs: { type: 'object' }, fix: str, code: str, lines: strList, cause: { enum: ['diff', 'environment'] }, environment: { type: 'object', required: ['kind', 'evidence'], properties: { kind: str, evidence: str } } } } },
+ checks: { type: 'array', items: { type: 'object', required: ['id', 'status'], properties: { id: str, status: { enum: ['pass', 'fail', 'warn', 'incomplete', 'requires-remote', 'not_installed'] }, ms: num, detail: str, stage: { enum: ['preflight', 'tests'] }, inputs: { type: 'object' }, fix: str, code: str, lines: strList, cause: { enum: ['diff', 'environment'] }, environment: { type: 'object', required: ['kind', 'evidence'], properties: { kind: str, evidence: str } }, proven: strList } } },
head: str, base: str, generated_at: str, policy: str, policy_digest: str, policy_user_path: str, registry: str, verdict: str, scratch: str,
requires_remote: { type: 'array', items: { type: 'object', required: ['lane', 'platform', 'tests', 'receipt'], properties: { lane: str, platform: str, workflow: str, job: str, tests: strList, receipt: nullableStr, cleared_by: str, command: str,
substitution: { type: 'object', required: ['gated_tree', 'accepted_for', 'policy', 'files'], properties: { gated_tree: str, accepted_for: str, policy: str, files: { type: 'array', items: { type: 'object', required: ['path', 'rule'], properties: { path: str, rule: str } } } } } } } },
diff --git a/lib/pregate/checks/regen.ts b/lib/pregate/checks/regen.ts
index dd63dd003..4aef9c75e 100644
--- a/lib/pregate/checks/regen.ts
+++ b/lib/pregate/checks/regen.ts
@@ -23,7 +23,7 @@ function run(ctx: PregateContext): CheckResult {
fix: 'rerun with --allow-repo-commands (what /ship passes)', lines: commands.map(c => `COMMAND: ${c.id}=${JSON.stringify(c.command)} outputs=${c.outputs.join(',')}`),
};
}
- const { results } = runRegen({ repoRoot: ctx.repoRoot, registry: ctx.registry, mode: 'check', only: selected.map(e => e.id), env: ctx.env, timeoutMs: ctx.timeoutMs });
+ const { results } = runRegen({ repoRoot: ctx.repoRoot, registry: ctx.registry, mode: 'check', only: selected.map(e => e.id), prove: ctx.touched.map(t => t.path), env: ctx.env, timeoutMs: ctx.timeoutMs });
const lines = results.map(renderEntry);
const stale = results.filter(r => r.status === 'stale');
const failed = results.filter(r => r.status === 'failed');
@@ -32,7 +32,8 @@ function run(ctx: PregateContext): CheckResult {
if (depsMissing.length) return { id: 'regen', stage: 'preflight', status: 'incomplete', inputs, lines, code: 'REGEN_DEPS_MISSING', detail: `dependencies missing; ${depsMissing[0]!.fix} (${depsMissing.length} entr${depsMissing.length === 1 ? 'y' : 'ies'} unverified: ${depsMissing.map(d => d.id).join(', ')})`, fix: depsMissing[0]!.fix };
if (failed.length) return { id: 'regen', stage: 'preflight', status: 'incomplete', inputs, lines, code: 'REGEN_COMMAND_FAILED', detail: `${failed.length} regen command(s) failed: ${failed.map(f => f.id).join(', ')}`, fix: failed.map(f => f.fix).join('; ') };
if (stale.length) return { id: 'regen', stage: 'preflight', status: 'fail', inputs, lines, code: 'REGEN_STALE', detail: `${stale.length} stale: ${stale.flatMap(s => s.changed.concat(s.outside)).join(' ')}`, fix: stale.map(s => s.fix).join('; ') };
- return { id: 'regen', stage: 'preflight', status: 'pass', inputs, lines, detail: `${results.length} entr${results.length === 1 ? 'y' : 'ies'} fresh${results.some(r => r.status === 'listed') ? ' (hand-regenerated outputs listed)' : ''}` };
+ const proven = results.flatMap(r => r.proven ?? []).sort();
+ return { id: 'regen', stage: 'preflight', status: 'pass', inputs, lines, ...(proven.length ? { proven } : {}), detail: `${results.length} entr${results.length === 1 ? 'y' : 'ies'} fresh${results.some(r => r.status === 'listed') ? ' (hand-regenerated outputs listed)' : ''}` };
}
export const regenCheck: CheckDef = { id: 'regen', stage: 'preflight', tier: 1, downgradable: true, needsRepoCommands: true, run };
diff --git a/lib/regen.ts b/lib/regen.ts
index 2a8538647..6654c854e 100644
--- a/lib/regen.ts
+++ b/lib/regen.ts
@@ -164,6 +164,8 @@ export interface EntryResult {
exit?: number | null; stderr?: string; ms: number;
/** The exact command an author runs to refresh the entry. */
fix: string;
+ /** `check` with `prove`, on a fresh entry: the deleted outputs the command recreated byte-identically (an output it never writes is not proven). */
+ proven?: string[];
}
/** Working tree vs the seeded index in a scratch worktree: modified tracked files plus new untracked ones. */
@@ -214,6 +216,8 @@ export interface RunRegenOptions {
only?: string[];
/** Restrict to entries whose inputs or outputs intersect these paths. */
touched?: string[];
+ /** `check`: outputs to delete from the scratch before their entry's command runs; the ones it recreates byte-identically come back as `proven`. */
+ prove?: readonly string[];
env?: NodeJS.ProcessEnv; timeoutMs?: number;
}
@@ -240,16 +244,19 @@ export function runRegen(o: RunRegenOptions): { results: EntryResult[]; wtree: s
if (!e.command) { results.push({ id: e.id, command: null, status: 'listed', changed: [], outside: [], ms: 0, fix: fixFor(e) }); continue; }
const cwd = o.mode === 'check' ? scratch!.dir : o.repoRoot;
const before = o.mode === 'write' ? snapshotFiles(o.repoRoot) : null;
+ const probe = o.mode === 'check' ? (o.prove ?? []).filter(p => matchesAny(e.outputs, p) && fs.existsSync(path.join(cwd, p))) : [];
+ for (const p of probe) fs.rmSync(path.join(cwd, p), { force: true });
const run = runCommand(cwd, e.command, o.env ?? process.env, timeout);
const after = before ? diffSnapshots(before, snapshotFiles(o.repoRoot)) : changedPaths(cwd);
- const changed = after.filter(p => matchesAny(e.outputs, p));
+ const unwritten = probe.filter(p => !fs.existsSync(path.join(cwd, p)));
+ const changed = after.filter(p => matchesAny(e.outputs, p) && !unwritten.includes(p));
const outside = after.filter(p => !matchesAny(e.outputs, p) && !matchesAny(o.registry.entries.flatMap(x => x.outputs), p));
const ms = Date.now() - started;
if (run.exit !== 0) { results.push({ id: e.id, command: e.command, status: 'failed', changed, outside, exit: run.exit, stderr: run.stderr, ms, fix: fixFor(e) }); continue; }
if (o.mode === 'check') {
const differs = changed.length > 0 || outside.length > 0;
if (differs && install) results.push({ id: e.id, command: e.command, status: 'deps-missing', changed, outside, exit: 0, ms, fix: `run ${install}, then rerun the check` });
- else results.push({ id: e.id, command: e.command, status: differs ? 'stale' : 'fresh', changed, outside, exit: 0, ms, fix: fixFor(e) });
+ else results.push({ id: e.id, command: e.command, status: differs ? 'stale' : 'fresh', changed, outside, exit: 0, ms, fix: fixFor(e), ...(probe.length && !differs ? { proven: probe.filter(p => !unwritten.includes(p)) } : {}) });
// Reset the scratch so the next entry's diff is its own.
git(cwd, ['checkout', '-q', '--', '.']); git(cwd, ['clean', '-fdq']);
} else results.push({ id: e.id, command: e.command, status: changed.length ? 'written' : 'fresh', changed, outside, exit: 0, ms, fix: fixFor(e) });
diff --git a/test/ship-receipt-carry-rewrite.test.ts b/test/ship-receipt-carry-rewrite.test.ts
index 41c038d1c..83009c4a0 100644
--- a/test/ship-receipt-carry-rewrite.test.ts
+++ b/test/ship-receipt-carry-rewrite.test.ts
@@ -120,9 +120,37 @@ describe('carry across a base merge that changes a registered output', () => {
expect(runBin(repo, 'gstack-pregate', ['--base', 'main', '--allow-repo-commands']).status).toBe(EXIT.ok);
const pregateJson = path.join(repo.work, '.gstack/tmp/pregate.json');
- expect(JSON.parse(fs.readFileSync(pregateJson, 'utf8')).checks.find((c: { id: string }) => c.id === 'regen')).toMatchObject({ status: 'pass' });
+ const pregate = JSON.parse(fs.readFileSync(pregateJson, 'utf8'));
+ const regen = pregate.checks.find((c: { id: string }) => c.id === 'regen');
+ expect(regen).toMatchObject({ status: 'pass', proven: ['generated/digest.md'] });
+ const headRegistry = path.join(repo.root, 'pregate-head-registry.json');
+ regen.inputs.registry = 'head (unreviewed)';
+ fs.writeFileSync(headRegistry, JSON.stringify(pregate));
+ const foreign = carry(repo, body, ['--pregate', headRegistry]);
+ expect(foreign.out).toContain('RECEIPT_CARRY_PRODUCT_CHANGED');
+ expect(foreign.err).toContain('generated/digest.md');
const out = carry(repo, body, ['--pregate', pregateJson]);
expect(out.status).toBe(EXIT.ok);
expect(receiptIn(body)).toMatchObject({ head: repo.git('rev-parse', 'HEAD'), carry: { from: reviewed } });
}, 120_000);
+
+ // Value: protects=a hand-written file under an output glob is reviewed code (red team, #3564 pass 1); fails_when=a
+ // regen pass that never wrote the path sets it aside as generated; seam=none; no_claim=-
+ test('a file that matches an output glob but that the generator never writes stays product', () => {
+ const repo = makeFixtureRepo(); repos.push(repo);
+ const reviewed = commit(repo, { 'lib/util.js': "exports.add = (a, b) => a + b + 0;\n" });
+ const body = path.join(repo.root, 'body.md');
+ expect(writeReceipt(repo, body).status).toBe(EXIT.ok);
+ commit(repo, { 'generated/extra.md': '# hand-written after review\n' });
+ expect(runBin(repo, 'gstack-pregate', ['--base', 'main', '--allow-repo-commands']).status).toBe(EXIT.ok);
+ const pregateJson = path.join(repo.work, '.gstack/tmp/pregate.json');
+ const regen = JSON.parse(fs.readFileSync(pregateJson, 'utf8')).checks.find((c: { id: string }) => c.id === 'regen');
+ expect(regen.status).toBe('pass');
+ expect(regen.proven ?? []).not.toContain('generated/extra.md');
+ const out = carry(repo, body, ['--pregate', pregateJson]);
+ expect(out.status).toBe(EXIT.refused);
+ expect(out.out).toContain('RECEIPT_CARRY_PRODUCT_CHANGED');
+ expect(out.err).toContain('generated/extra.md');
+ expect(receiptIn(body).head).toBe(reviewed);
+ }, 120_000);
});
]