mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-10 18:50:18 +01:00
Merge origin/main (5225e35, v1.92.42.0) into gsta215-ceo-only-driver
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
commit
f4547e07cc
249 files changed
+15292
-6073
No files matched your search
+34
-12
@@ -1,10 +1,13 @@
|
||||
// gstack /ship review pass program for Capy (lib/capy-review-pass.js, GSTA-167 calls PR 2).
|
||||
// The body of an async function (tools, sleep, now, input), loaded verbatim by the loader in
|
||||
// ship/DRIVER.md from `gstack-review-dispatch program`; never retyped. One call runs one pass or Step 11's
|
||||
// voices from the manifest `gstack-review-dispatch pass` writes: render, chunked brief transport, `sent`,
|
||||
// ship/DRIVER.md from `gstack-review-dispatch program`; never retyped. One call runs one pass (decision 3.5's
|
||||
// fix-check with its plan's voices included) or Step 11's voices from the manifest `gstack-review-dispatch pass` writes: render, chunked brief transport, `sent`,
|
||||
// intent before create, create, poll, collect. It returns 200 s after the loader's first line
|
||||
// (input.startedAt) at most; the next call resumes from the manifest. A failed, stuck or ambiguous
|
||||
// reader, a brief mismatch or a failing bin returns `STOP:` for the parent; nothing is dropped.
|
||||
// Once Phase 2 has started <run dir>/parts (the parent of input.out), each poll or finished pass also
|
||||
// returns the next phase's section parts that fit its 32 KB (`gstack-ship-parts next`, GSTA-167 C5);
|
||||
// after a return that never reached the parent, rerun with input.resendParts to get that batch again.
|
||||
const T0 = input.startedAt ?? (await now());
|
||||
const deadline = T0 + (input.budgetMs ?? 200000);
|
||||
const tick = input.tickMs ?? 20000;
|
||||
@@ -22,10 +25,21 @@ const bin = 'gstack-review-dispatch';
|
||||
const finish = (tail) => {
|
||||
const all = [...lines, ...tail];
|
||||
let text = all.join('\n');
|
||||
if (text.length > RETURN_BYTES) text = text.slice(0, RETURN_BYTES) + '\n(return capped at 32 KB; the rest is in the files named above)';
|
||||
return text;
|
||||
if (text.length <= RETURN_BYTES) return text;
|
||||
const last = '\n(return capped at 32 KB; the rest is in the files named above)\n' + all[all.length - 1];
|
||||
return all.slice(0, -1).join('\n').slice(0, Math.max(0, RETURN_BYTES - last.length)) + last;
|
||||
};
|
||||
const stop = (why) => finish(['STOP: ' + why]);
|
||||
const runDir = input.out.replace(/\/+$/, '').replace(/\/[^/]+$/, '');
|
||||
const nextPhase = input.stage === 'voices' ? 5 : input.step === '14.5' ? 7 : 4;
|
||||
const withParts = async (tail) => {
|
||||
const s = await sh('test -f ' + q(runDir + '/parts/state.json') + ' && echo started');
|
||||
if (!s.out.startsWith('started')) return finish([...tail.slice(0, -1), 'PARTS: skipped (no ' + runDir + '/parts/state.json: Phase 2 started no parts here, or input.out is not <run dir>/<pass dir>)', tail[tail.length - 1]]);
|
||||
const room = RETURN_BYTES - [...lines, ...tail].join('\n').length - 1;
|
||||
const p = await sh('gstack-ship-parts next --run ' + q(runDir) + ' --phase ' + nextPhase + ' --room ' + Math.max(0, room) + (input.resendParts ? ' --resend' : ''));
|
||||
const body = p.out.trimEnd().split('\n');
|
||||
return finish(p.code === 0 ? [...tail.slice(0, -1), ...body, tail[tail.length - 1]] : [...tail.slice(0, -1), ...body]);
|
||||
};
|
||||
|
||||
const passCmd = [bin, 'pass', '--base', q(input.base), '--out', q(input.out), '--pass', q(input.pass)];
|
||||
if (input.ledger) passCmd.push('--ledger', q(input.ledger));
|
||||
@@ -35,6 +49,9 @@ if (input.extras && typeof input.extras !== 'string') {
|
||||
passCmd.push('--extras', q(file));
|
||||
} else if (input.extras) passCmd.push('--extras', q(input.extras));
|
||||
if (input.learnings) passCmd.push('--learnings-file', q(input.learnings));
|
||||
if (input.diffBase) passCmd.push('--diff-base', q(input.diffBase));
|
||||
if (input.step === '14.5') passCmd.push('--audit-only');
|
||||
for (const [k, v] of Object.entries(input.fixCheck || {})) passCmd.push('--' + k.replace('_', '-'), q(v));
|
||||
passCmd.push('--', ...(input.planFlags || []).map(q));
|
||||
const pr = await sh(passCmd.join(' '));
|
||||
if (pr.code !== 0) return stop('gstack-review-dispatch pass exited ' + pr.code + ': ' + pr.out.slice(-1500));
|
||||
@@ -78,19 +95,23 @@ const modelOf = (r) => r.model || (input.models || {})[r.role === 'specialist-ba
|
||||
|| (REASONING[r.effort] && input.laneModel ? { model_id: input.laneModel, reasoning_mode: REASONING[r.effort] } : undefined);
|
||||
const safe = (s) => s.replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||
const active = input.stage === 'voices' ? ['voices'] : ['first', 'specialists', 'red-team'];
|
||||
const beforeKey = input.stage === 'voices' ? 'before_voices' : 'before';
|
||||
|
||||
try {
|
||||
let m = await load();
|
||||
if (input.stage !== 'voices' && m.next === 'fix-check') active.push('voices');
|
||||
if (input.stage === 'voices' && !m.readers.some((r) => r.stage === 'voices')) return finish(['VOICES: none (the pass plan lists no Step 11 voice)', 'NEXT: judge']);
|
||||
const seen = {};
|
||||
for (const s of input.logStart || []) {
|
||||
const key = (input.stage === 'voices' ? 'voices:' : 'pass:') + s;
|
||||
const nth = (seen[s] = (seen[s] || 0) + 1);
|
||||
const key = (input.stage === 'voices' ? 'voices:' : 'pass:') + s + (nth > 1 ? '#' + nth : '');
|
||||
if ((m.log_starts || {})[key]) continue;
|
||||
const l = await sh('gstack-review-log --start ' + s.split(' ').map(q).join(' ') + (input.reservation ? ' --reservation ' + q(input.reservation) : ''));
|
||||
if (l.code !== 0) return stop('gstack-review-log --start ' + s + ' exited ' + l.code + ': ' + l.out.slice(-500));
|
||||
const token = l.out.trim();
|
||||
const k = await sh(bin + ' mark --manifest ' + q(mf) + ' --log-start ' + q(key) + ' --token ' + q(token));
|
||||
if (k.code !== 0) return stop('recording the ' + s + ' start token exited ' + k.code + ': ' + k.out.slice(-500));
|
||||
lines.push('REVIEW_LOG_START: ' + s + ' token=' + token);
|
||||
lines.push('REVIEW_LOG_START: ' + s + (nth > 1 ? ' #' + nth : '') + ' token=' + token);
|
||||
}
|
||||
for (;;) {
|
||||
const failed = m.readers.find((x) => active.includes(x.stage) && x.state === 'failed');
|
||||
@@ -159,9 +180,9 @@ try {
|
||||
if (s.code !== 0) return stop(s.out.trim());
|
||||
lines.push(s.out.trim());
|
||||
}
|
||||
if (!m.before) {
|
||||
if (!m[beforeKey]) {
|
||||
const b = await reading();
|
||||
if (b) { await sh(bin + ' mark --manifest ' + q(mf) + ' --before ' + b.tokens + ',' + b.credits); m.before = b; }
|
||||
if (b) { await sh(bin + ' mark --manifest ' + q(mf) + ' --before ' + b.tokens + ',' + b.credits + (beforeKey === 'before_voices' ? ' --voices' : '')); m[beforeKey] = b; }
|
||||
}
|
||||
await mark(r.reader, 'intent', {});
|
||||
const res = await tools.subagent.create({ tasks: [{ title: r.title, prompt: text }], start: { machine: 'shared', ...(model ? { model } : {}) } });
|
||||
@@ -173,15 +194,16 @@ try {
|
||||
m = await load();
|
||||
if ((await now()) + tick >= deadline) {
|
||||
const open = m.readers.filter((r) => active.includes(r.stage) && r.state !== 'collected');
|
||||
return finish([...open.map((r) => 'RUNNING: ' + r.reader + ' state=' + r.state + (r.path ? ' path=' + r.path : '')), 'NEXT: poll (run this same program again now; it resumes from the manifest)']);
|
||||
return await withParts([...open.map((r) => 'RUNNING: ' + r.reader + ' state=' + r.state + (r.path ? ' path=' + r.path : '')), 'NEXT: poll (run this same program again now; it resumes from the manifest)']);
|
||||
}
|
||||
await sleep(tick);
|
||||
}
|
||||
const done = m.readers.filter((r) => active.includes(r.stage));
|
||||
const after = await reading();
|
||||
let noteExit = 0;
|
||||
if (after && m.before && input.record && input.step) {
|
||||
const n = await sh('gstack-ship-budget note --step ' + q(input.step) + ' --reading ' + after.tokens + ' --credits ' + after.credits + ' --record ' + q(input.record) + ' --dispatched-at ' + m.before.tokens + ' --dispatched-credits ' + m.before.credits + ' --readers ' + done.length);
|
||||
const before = m[beforeKey];
|
||||
if (after && before && input.record && input.step) {
|
||||
const n = await sh('gstack-ship-budget note --step ' + q(input.step) + ' --reading ' + after.tokens + ' --credits ' + after.credits + ' --record ' + q(input.record) + ' --dispatched-at ' + before.tokens + ' --dispatched-credits ' + before.credits + ' --readers ' + done.length);
|
||||
lines.push(n.out.trim());
|
||||
noteExit = n.code;
|
||||
}
|
||||
@@ -191,7 +213,7 @@ try {
|
||||
outs.push('OUTPUT: ' + r.reader + ' file=' + r.output, o.out.trimEnd());
|
||||
}
|
||||
if (noteExit !== 0) return finish([...outs, 'STOP: gstack-ship-budget note exited ' + noteExit + ' (its lines above say why); judge nothing further until the budget allows it']);
|
||||
return finish([...outs, input.stage === 'voices' ? 'NEXT: judge the voices' : 'NEXT: judge (Phase 4)']);
|
||||
return await withParts([...outs, input.stage === 'voices' ? 'NEXT: judge the voices' : input.step === '14.5' ? 'NEXT: apply the docs audit (Step 14.5)' : 'NEXT: judge (Phase 4)']);
|
||||
} catch (e) {
|
||||
return stop('pass program error: ' + (e && e.message ? e.message : String(e)) + ' (the manifest keeps every recorded reader; rerun resumes)');
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
*/
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { headingVersion, parseChangelog } from './changelog-check';
|
||||
import { readHistoryFile } from './history-read';
|
||||
import type { Bump } from './version-source';
|
||||
|
||||
export const FRAGMENT_README = 'README.md';
|
||||
@@ -75,8 +76,8 @@ export function readFragments(cwd: string, treeish: string | null, dir: string,
|
||||
return out;
|
||||
};
|
||||
const excluded = new Map((exclude ? list(exclude) : []).map(e => [e.path, e.sha]));
|
||||
const shown = exclude && changelog ? git(cwd, ['show', `${exclude}:${changelog}`]) : null;
|
||||
const released = shown?.ok ? parseChangelog(shown.out.replace(/\r\n/g, '\n')).sections.filter(s => !s.unreleased).map(s => s.lines) : [];
|
||||
const shown = exclude && changelog ? readHistoryFile(cwd, exclude, changelog) : null;
|
||||
const released = shown !== null ? parseChangelog(shown.replace(/\r\n/g, '\n')).sections.filter(s => !s.unreleased).map(s => s.lines) : [];
|
||||
const isReleased = (body: string) => {
|
||||
const want = body.split('\n');
|
||||
return released.some(lines => lines.some((_, i) => want.every((w, k) => lines[i + k] === w)));
|
||||
|
||||
+85
-13
@@ -22,6 +22,7 @@ import { spawnSync } from 'node:child_process';
|
||||
import { bunIsolationArgs } from './bun-isolation';
|
||||
import { runBashSync } from './script-invocation';
|
||||
import { frontmatterList, routerLine } from './skill-triggers';
|
||||
import { activateHook, assertHookRoot, hookChoice, hookFiles, hookProfile, hookRoot, inspectHook, isHookTarget, profileSkills, type HookResult } from './harness-start-hook';
|
||||
|
||||
export type SkillsMode = 'router' | 'hybrid' | 'all';
|
||||
export type TargetName = 'openclaw' | 'hermes' | 'dots' | 'generic' | 'qm' | 'muse' | 'grok';
|
||||
@@ -134,6 +135,10 @@ export const HARNESS_CODES = {
|
||||
HARNESS_RENDER_FAILED: { anchor: 'harness-render-failed', fix: 'run bun install in the gstack checkout, then register again; the render output above names the failing skill' },
|
||||
HARNESS_BUDGET_EXCEEDED: { anchor: 'harness-budget-exceeded', fix: 'this is a gstack bug: report it with the byte count printed above' },
|
||||
HARNESS_NOT_REGISTERED: { anchor: 'harness-not-registered', fix: 'run gstack-harness status to see the registrations on this machine' },
|
||||
HARNESS_HOOK_UNSUPPORTED: { anchor: 'harness-hook-unsupported', fix: 'the start hook is for OpenClaw and Hermes registered in their own profile: drop --skills-dir (set OPENCLAW_STATE_DIR or HERMES_HOME to pick the profile), or drop --start-hook' },
|
||||
HARNESS_HOOK_CHOICE_INVALID: { anchor: 'harness-hook-choice-invalid', fix: 'set it to on or off (GSTACK_HARNESS_START_HOOK also takes 1 or 0), or unset it' },
|
||||
HARNESS_HOOK_OWNED_ELSEWHERE: { anchor: 'harness-hook-owned-elsewhere', fix: 'if another gstack install wrote it, run that install\'s gstack-harness register <harness> --no-start-hook; otherwise move the folder aside, then register again' },
|
||||
HARNESS_HOOK_INACTIVE: { anchor: 'harness-hook-inactive', fix: 'run the command printed above, then gstack-harness status; the skills themselves are registered' },
|
||||
HARNESS_QM_CREDENTIALS: { anchor: 'harness-qm-credentials', fix: 'set GSTACK_QM_URL and either GSTACK_QM_ADMIN_TOKEN, or GSTACK_QM_SIGNING_SECRET with GSTACK_QM_ADMIN_ACTOR, then rerun with --yes' },
|
||||
HARNESS_QM_API: { anchor: 'harness-qm-api', fix: 'check that the QM core URL is reachable and the credential is an org admin; gstack-harness status shows whether a pack was left half-registered' },
|
||||
HARNESS_QM_PACK_TOO_LARGE: { anchor: 'harness-qm-pack-too-large', fix: 'QM registration is not available yet; give a QM agent gstack with gstack-harness register generic --print pasted into its instructions' },
|
||||
@@ -342,10 +347,14 @@ export interface RegisterOptions {
|
||||
version: string;
|
||||
/** Publish an installs.tsv row for host targets (setup's own arm publishes it instead). */
|
||||
registry?: boolean;
|
||||
/** --start-hook (true) or --no-start-hook (false); otherwise GSTACK_HARNESS_START_HOOK, gstack-config harness_start_hook, then the ledger decide (lib/harness-start-hook.ts). */
|
||||
startHook?: boolean;
|
||||
}
|
||||
export interface RegisterResult {
|
||||
id: string; target: TargetName; dest: string; mode: SkillsMode;
|
||||
status: 'installed' | 'updated' | 'unchanged'; written: number; skills: number; router: string | null; ledger: string; kept: string[];
|
||||
/** The start hook, for OpenClaw and Hermes; required: this run turned it on or was asked to, so anything but active is a failure. */
|
||||
hook?: HookResult & { required?: boolean; retried?: boolean };
|
||||
}
|
||||
|
||||
const PLAIN = /^[^\t\n\r]+$/;
|
||||
@@ -424,9 +433,10 @@ function keepEdit(file: string, stamp: string): string {
|
||||
return to;
|
||||
}
|
||||
|
||||
/** Refuses a path outside dest, or one reached through a symlink below dest. */
|
||||
function assertOwnedPath(dest: string, p: string): void {
|
||||
if (!p.startsWith(dest + path.sep)) throw new HarnessError('HARNESS_UNSAFE_DESTINATION', p + ' is outside ' + dest);
|
||||
/** Refuses a path outside every owned root (the destination, and the start hook's folder), or one reached through a symlink below its root. */
|
||||
function assertOwnedPath(roots: string[], p: string): void {
|
||||
const dest = roots.find(r => r && p.startsWith(r + path.sep));
|
||||
if (!dest) throw new HarnessError('HARNESS_UNSAFE_DESTINATION', p + ' is outside ' + roots.filter(Boolean).join(' and '));
|
||||
for (let d = p; d !== dest; d = path.dirname(d)) {
|
||||
let st: fs.Stats;
|
||||
try { st = fs.lstatSync(d); } catch { continue; }
|
||||
@@ -515,6 +525,56 @@ function shellRegistry(opts: RegisterOptions, fn: string, args: string[]): boole
|
||||
return sourcedBash(opts.checkout, 'gstack-install-registry.sh', fn, args, opts.stateRoot, opts.env).status === 0;
|
||||
}
|
||||
|
||||
interface HookPlan { on: boolean; prevOn: boolean; explicit: boolean; retry: boolean; profile: string; root: string; files: Map<string, Buffer>; note?: string }
|
||||
|
||||
/** The start hook for this run: whether it is on, where it goes, and its files (null for targets without a hook). */
|
||||
function hookPlan(opts: RegisterOptions, dest: string, prev: Ledger | null): HookPlan | null {
|
||||
const t = opts.target;
|
||||
if (!isHookTarget(t.name)) {
|
||||
if (opts.startHook) throw new HarnessError('HARNESS_HOOK_UNSUPPORTED', t.displayName + ' has no start hook');
|
||||
return null;
|
||||
}
|
||||
const choice = hookChoice(opts.startHook, opts.env, opts.stateRoot, prev?.meta.start_hook);
|
||||
if ('invalid' in choice) throw new HarnessError('HARNESS_HOOK_CHOICE_INVALID', choice.invalid + ' is not on or off');
|
||||
const prevOn = prev?.meta.start_hook === 'on' && !!prev.meta.hook_root && !!prev.meta.hook_profile;
|
||||
const profile = prevOn ? prev!.meta.hook_profile : hookProfile(t.name, opts.env, opts.home);
|
||||
const root = hookRoot(t.name, profile);
|
||||
const plan = { on: choice.on, prevOn, explicit: choice.source === 'flag', retry: prevOn && prev!.meta.hook_active === 'no', profile, root, files: new Map<string, Buffer>() };
|
||||
const saved = prev?.meta.hook_root && prev.meta.hook_profile ? { profile: prev.meta.hook_profile, root: prev.meta.hook_root } : null;
|
||||
const name = t.name;
|
||||
const off = (p: HookPlan) => { if (saved) assertHookRoot(name, saved.profile, saved.root, saved.root, (code, msg) => { throw new HarnessError(code, msg); }); return p; };
|
||||
if (!choice.on) return off(plan);
|
||||
const why = dest !== profileSkills(t.name, profile) ? 'this registration uses another skills folder (' + dest + ')'
|
||||
: t.name === 'openclaw' && opts.env.OPENCLAW_CONFIG_PATH ? 'OPENCLAW_CONFIG_PATH points OpenClaw at a config outside that profile' : '';
|
||||
if (why && (plan.explicit || !prevOn)) {
|
||||
if (plan.explicit) throw new HarnessError('HARNESS_HOOK_UNSUPPORTED', 'the start hook goes into the ' + t.displayName + ' profile ' + profile + ', and ' + why);
|
||||
return off({ ...plan, on: false, note: 'off: ' + why + ' (' + choice.source + ' asked for the hook)' });
|
||||
}
|
||||
try {
|
||||
assertHookRoot(t.name, profile, root, prevOn ? prev!.meta.hook_root : undefined, (code, msg) => { throw new HarnessError(code, msg); });
|
||||
} catch (e) {
|
||||
if (plan.explicit || prevOn || !(e instanceof HarnessError)) throw e;
|
||||
return off({ ...plan, on: false, note: 'off: ' + e.message + ' (' + choice.source + ' asked for the hook; ' + HARNESS_CODES[e.code].fix + ')' });
|
||||
}
|
||||
return { ...plan, files: hookFiles(t.name, root) };
|
||||
}
|
||||
|
||||
/** Turns the hook on after its files are written (when this run turns it on, was asked to, or the last try did not end active), and reads it back. */
|
||||
function finishHook(opts: RegisterOptions, hook: HookPlan | null, released: string): RegisterResult['hook'] {
|
||||
if (!hook) return undefined;
|
||||
if (!hook.on) return { state: 'off', ...(hook.note ? { detail: hook.note } : {}), ...(released ? { fix: released } : {}) };
|
||||
const t = opts.target.name as 'openclaw' | 'hermes';
|
||||
const required = !hook.prevOn || hook.explicit;
|
||||
if (!required && !hook.retry) return { state: 'unchecked', profile: hook.profile, root: hook.root, fix: 'gstack-harness status shows whether it is active' };
|
||||
if (!required) {
|
||||
const now = inspectHook(t, hook.profile, opts.env, opts.home);
|
||||
if (now.state === 'active' || now.state === 'disabled') return { ...now, profile: hook.profile, root: hook.root, retried: true };
|
||||
}
|
||||
const failed = activateHook(t, hook.profile, opts.env, opts.home);
|
||||
const r = inspectHook(t, hook.profile, opts.env, opts.home);
|
||||
return { ...r, ...(failed && r.state !== 'active' && !r.detail?.startsWith(failed) ? { detail: failed + (r.detail ? '; ' + r.detail : '') } : {}), profile: hook.profile, root: hook.root, required, retried: !required };
|
||||
}
|
||||
|
||||
export function register(opts: RegisterOptions): RegisterResult {
|
||||
const t = opts.target;
|
||||
if (t.mechanism === 'verify-only' || t.mechanism === 'skill-pack-api') throw new HarnessError('HARNESS_UNKNOWN_TARGET', t.name + ' is not registered through files (use status for ' + t.name + ', or lib/harness-qm.ts for qm)');
|
||||
@@ -530,12 +590,13 @@ export function register(opts: RegisterOptions): RegisterResult {
|
||||
if (explicit) { assertSafeDest(explicit, opts); assertContextFile(t, explicit); }
|
||||
const lfile = ledgerPath(opts.stateRoot, id);
|
||||
const prev = readLedger(lfile);
|
||||
const hook = hookPlan(opts, dest, prev);
|
||||
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-harness-'));
|
||||
try {
|
||||
const { files, router, visible, direct, skills } = planPayload(opts, dest, mode, scratch);
|
||||
const prevFiles = new Map((prev?.files ?? []).map(f => [f.path, f.sha]));
|
||||
const topOf = (p: string) => path.join(dest, path.relative(dest, p).split(path.sep)[0]);
|
||||
const owned = new Set([...prevFiles.keys()].map(topOf));
|
||||
const owned = new Set([...prevFiles.keys()].filter(p => p.startsWith(dest + path.sep)).map(topOf));
|
||||
for (const top of new Set([...files.keys()].map(topOf))) {
|
||||
let st: fs.Stats;
|
||||
try { st = fs.lstatSync(top); } catch { continue; }
|
||||
@@ -549,7 +610,8 @@ export function register(opts: RegisterOptions): RegisterResult {
|
||||
const other = frontmatterField(fs.readFileSync(md, 'utf8'), 'name');
|
||||
if (visible.includes(other)) throw new HarnessError('HARNESS_UNOWNED_ENTRY', 'a skill named ' + other + ' already exists at ' + path.join(dest, e) + ' and gstack did not write it');
|
||||
}
|
||||
for (const p of new Set([...files.keys(), ...prevFiles.keys()])) assertOwnedPath(dest, p);
|
||||
for (const [p, b] of hook?.files ?? []) files.set(p, b);
|
||||
for (const p of new Set([...files.keys(), ...prevFiles.keys()])) assertOwnedPath([dest, prev?.meta.hook_root ?? '', hook?.on ? hook.root : ''], p);
|
||||
const want = [...files].map(([p, b]) => ({ path: p, sha: sha256(b) }));
|
||||
const context = opts.context ? path.resolve(opts.context) : prev?.meta.context ?? '';
|
||||
const block = contextBlock(router, direct);
|
||||
@@ -573,18 +635,21 @@ export function register(opts: RegisterOptions): RegisterResult {
|
||||
const same = prev && prev.meta.mode === mode && prev.meta.names === visible.join(',') && prev.files.length === want.length
|
||||
&& (prev.meta.context ?? '') === context && prev.blocks.length === blocks.length
|
||||
&& blocks.every(b => prev.blocks.some(p => p.path === b.path && p.sha === b.sha && p.pad === b.pad))
|
||||
&& want.every(w => prevFiles.get(w.path) === w.sha && onDiskSha(w.path) === w.sha);
|
||||
&& want.every(w => prevFiles.get(w.path) === w.sha && onDiskSha(w.path) === w.sha)
|
||||
&& (!hook || (prev.meta.start_hook ?? 'off') === (hook.on ? 'on' : 'off'));
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const kept: string[] = [];
|
||||
let written = 0;
|
||||
const released = hook?.prevOn && !hook.on ? sourcedBash(opts.checkout, 'gstack-harness-ledger.sh', 'gstack_harness_hook_release', [lfile], opts.stateRoot, opts.env).stdout.trim() : '';
|
||||
if (!same) {
|
||||
const dirs = new Set(prev?.dirs ?? []);
|
||||
for (const p of files.keys()) for (let d = path.dirname(p); !fs.existsSync(d) && path.dirname(d) !== d; d = path.dirname(d)) dirs.add(d);
|
||||
const record = (recorded: typeof want) => writeLedger(lfile, {
|
||||
meta: { id, target: t.name, dest, mode, checkout: opts.checkout, version: opts.version, names: visible.join(','), ...(router ? { router } : {}), ...(context ? { context } : {}) },
|
||||
const record = (recorded: typeof want, final: boolean) => writeLedger(lfile, {
|
||||
meta: { id, target: t.name, dest, mode, checkout: opts.checkout, version: opts.version, names: visible.join(','), ...(router ? { router } : {}), ...(context ? { context } : {}),
|
||||
...(hook ? { start_hook: hook.on ? 'on' : 'off' } : {}), ...(hook?.on ? { hook_profile: hook.profile, hook_root: hook.root, ...(!hook.prevOn || hook.explicit ? { hook_active: 'no' } : prev?.meta.hook_active ? { hook_active: prev.meta.hook_active } : {}) } : !final && prev?.meta.hook_root ? { hook_profile: prev.meta.hook_profile, hook_root: prev.meta.hook_root } : {}) },
|
||||
files: recorded, dirs: [...dirs].sort((a, b) => b.length - a.length), blocks,
|
||||
});
|
||||
record([...want, ...(prev?.files ?? []).filter(f => !files.has(f.path))]);
|
||||
record([...want, ...(prev?.files ?? []).filter(f => !files.has(f.path))], false);
|
||||
for (const [p, sha] of prevFiles) {
|
||||
if (files.has(p)) continue;
|
||||
const disk = onDiskSha(p);
|
||||
@@ -603,7 +668,7 @@ export function register(opts: RegisterOptions): RegisterResult {
|
||||
fs.renameSync(p + '.gstack-new', p);
|
||||
written++;
|
||||
}
|
||||
record(want);
|
||||
record(want, true);
|
||||
}
|
||||
for (const [file, text] of blockWrites) {
|
||||
if (text === (fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : null)) continue;
|
||||
@@ -611,7 +676,12 @@ export function register(opts: RegisterOptions): RegisterResult {
|
||||
fs.writeFileSync(file, text);
|
||||
}
|
||||
if (opts.registry && t.render === 'host') shellRegistry(opts, 'gstack_install_registry_upsert', [t.name, 'global', '-', dest, opts.checkout, opts.checkout, opts.version, '-', '-']);
|
||||
return { id, target: t.name, dest, mode, status: !prev ? 'installed' : same ? 'unchanged' : 'updated', written, skills, router, ledger: lfile, kept };
|
||||
if (same && hook?.on && hook.explicit && prev.meta.hook_active !== 'no') writeLedger(lfile, { ...prev, meta: { ...prev.meta, hook_active: 'no' } });
|
||||
const hookResult = finishHook(opts, hook, released);
|
||||
const active = hookResult?.required || hookResult?.retried ? ({ active: 'yes', disabled: 'disabled' } as Record<string, string>)[hookResult.state] ?? 'no' : '';
|
||||
const saved = active ? readLedger(lfile) : null;
|
||||
if (saved && saved.meta.hook_active !== active) writeLedger(lfile, { ...saved, meta: { ...saved.meta, hook_active: active } });
|
||||
return { id, target: t.name, dest, mode, status: !prev ? 'installed' : same ? 'unchanged' : 'updated', written, skills, router, ledger: lfile, kept, ...(hookResult ? { hook: hookResult } : {}) };
|
||||
} finally {
|
||||
fs.rmSync(scratch, { recursive: true, force: true });
|
||||
}
|
||||
@@ -647,7 +717,7 @@ export function refresh(base: Omit<RegisterOptions, 'target'>, skip: Set<string>
|
||||
}));
|
||||
}
|
||||
|
||||
export interface StatusRow { id: string; target: string; dest: string; mode: string; files: number; version: string; stale: boolean; visible: 'yes' | 'no' | 'unknown'; checked?: string; missing?: string[] }
|
||||
export interface StatusRow { id: string; target: string; dest: string; mode: string; files: number; version: string; stale: boolean; visible: 'yes' | 'no' | 'unknown'; checked?: string; missing?: string[]; hook?: HookResult }
|
||||
|
||||
/** Runs the harness's own skill list: 'yes' when every name shows (or, with any, at least one). */
|
||||
export function harnessSees(t: Target, dest: string, env: Env, names: string[], any = false): { visible: 'yes' | 'no' | 'unknown'; checked?: string; missing?: string[] } {
|
||||
@@ -664,6 +734,8 @@ export function status(stateRoot: string, version: string, env: Env, check = tru
|
||||
return listLedgers(stateRoot).map(({ ledger: l }) => {
|
||||
const t = (TARGETS as Record<string, Target>)[l.meta.target];
|
||||
const seen = check && t ? harnessSees(t, l.meta.dest, env, (l.meta.names ?? '').split(',').filter(Boolean)) : { visible: 'unknown' as const };
|
||||
return { id: l.meta.id, target: l.meta.target, dest: l.meta.dest, mode: l.meta.mode, files: l.files.length, version: l.meta.version, stale: l.meta.version !== version, ...seen };
|
||||
const on = l.meta.start_hook === 'on' && isHookTarget(l.meta.target) && !!l.meta.hook_profile;
|
||||
const hook = on ? (check ? inspectHook(l.meta.target as 'openclaw' | 'hermes', l.meta.hook_profile, env, os.homedir()) : { state: 'unchecked' as const }) : undefined;
|
||||
return { id: l.meta.id, target: l.meta.target, dest: l.meta.dest, mode: l.meta.mode, files: l.files.length, version: l.meta.version, stale: l.meta.version !== version, ...seen, ...(hook ? { hook } : {}) };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* The gstack-start hook (GST-718): an opt-in plugin that puts one short note on every user turn in OpenClaw
|
||||
* (before_prompt_build) and Hermes (pre_llm_call), telling the agent to run a gstack skill's start command as
|
||||
* written. Skill listings were followed on 1 of 29 chances (HARN-18); a per-turn note 97-100% (HARN-15).
|
||||
*
|
||||
* Off unless the user asks: `--start-hook`, GSTACK_HARNESS_START_HOOK or `gstack-config set harness_start_hook on`.
|
||||
* Turning it on writes a fixed plugin into the harness profile (never under a custom --skills-dir) and changes the
|
||||
* harness's settings only through its own commands: OpenClaw needs plugins.entries.gstack-start.hooks.
|
||||
* allowConversationAccess before a non-bundled plugin's before_prompt_build runs (2026.10.6 hook-policy-decisions:6);
|
||||
* Hermes loads a user plugin only once `hermes plugins enable` lists it. Activation runs only when the choice goes
|
||||
* from off to on, so a plugin the user disabled in the harness is never turned back on. Turning it off and
|
||||
* unregistering run gstack_harness_hook_release in bin/gstack-harness-ledger.sh, the same code gstack-uninstall runs.
|
||||
*/
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { readConfigKey } from './state-root';
|
||||
|
||||
export const HOOK_ID = 'gstack-start';
|
||||
export const START_NOTE_MAX = 300;
|
||||
/** Names the helper every rendered preamble runs (test/harness-start-hook.test.ts checks both). */
|
||||
export const START_NOTE = 'gstack: if you use a gstack skill this turn, first run the gstack-skill-start command in its Preamble section exactly as written, with no pipe, head, || true or ; echo. Read its output, then follow the skill.';
|
||||
|
||||
export type HookTarget = 'openclaw' | 'hermes';
|
||||
export type HookState = 'active' | 'written' | 'disabled' | 'unchecked' | 'off';
|
||||
export interface HookResult { state: HookState; fix?: string; detail?: string; profile?: string; root?: string }
|
||||
type Env = Record<string, string | undefined>;
|
||||
|
||||
const MANAGED = "Managed by gstack-harness: it rewrites this file. Turn the hook off: gstack-harness register %s --no-start-hook";
|
||||
const SPECS = {
|
||||
openclaw: {
|
||||
profileVar: 'OPENCLAW_STATE_DIR', home: '.openclaw', sub: 'extensions', skills: ['skills'],
|
||||
activate: [['config', 'set', 'plugins.entries.' + HOOK_ID + '.hooks.allowConversationAccess', 'true'], ['config', 'set', 'plugins.entries.' + HOOK_ID + '.enabled', 'true']],
|
||||
files: (): Record<string, string> => ({
|
||||
'index.js': [
|
||||
'// ' + MANAGED.replace('%s', 'openclaw'),
|
||||
"import { readFileSync } from 'node:fs';",
|
||||
"const NOTE = new URL('./note.txt', import.meta.url);",
|
||||
'export default {',
|
||||
" id: '" + HOOK_ID + "',",
|
||||
" name: 'gstack start hook',",
|
||||
' description: "Puts gstack\'s start-command note on each user turn (managed by gstack-harness).",',
|
||||
' register(api) {',
|
||||
" api.on('before_prompt_build', () => {",
|
||||
" let note = '';",
|
||||
" try { note = readFileSync(NOTE, 'utf8').trim(); } catch {}",
|
||||
' return note ? { prependContext: note } : undefined;',
|
||||
' });',
|
||||
' },',
|
||||
'};',
|
||||
'',
|
||||
].join('\n'),
|
||||
'openclaw.plugin.json': JSON.stringify({ id: HOOK_ID, name: 'gstack start hook', description: "Puts gstack's start-command note on each user turn (managed by gstack-harness).", configSchema: { type: 'object', additionalProperties: false } }, null, 2) + '\n',
|
||||
'package.json': JSON.stringify({ name: HOOK_ID, version: '1.0.0', private: true, type: 'module', openclaw: { extensions: ['./index.js'] } }, null, 2) + '\n',
|
||||
}),
|
||||
},
|
||||
hermes: {
|
||||
profileVar: 'HERMES_HOME', home: '.hermes', sub: 'plugins', skills: ['skills', 'gstack'],
|
||||
activate: [['plugins', 'enable', HOOK_ID]],
|
||||
files: (): Record<string, string> => ({
|
||||
'plugin.yaml': ['# ' + MANAGED.replace('%s', 'hermes'), 'name: ' + HOOK_ID, 'version: 1.0.0', "description: Puts gstack's start-command note on each user turn (managed by gstack-harness).", ''].join('\n'),
|
||||
'__init__.py': [
|
||||
'# ' + MANAGED.replace('%s', 'hermes'),
|
||||
'from pathlib import Path',
|
||||
'',
|
||||
'_NOTE = Path(__file__).with_name("note.txt")',
|
||||
'',
|
||||
'',
|
||||
'def _context(**_kwargs):',
|
||||
' try:',
|
||||
' note = _NOTE.read_text(encoding="utf-8").strip()',
|
||||
' except OSError:',
|
||||
' return None',
|
||||
' return {"context": note} if note else None',
|
||||
'',
|
||||
'',
|
||||
'def register(ctx):',
|
||||
' ctx.register_hook("pre_llm_call", _context)',
|
||||
'',
|
||||
].join('\n'),
|
||||
}),
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const isHookTarget = (t: string): t is HookTarget => t === 'openclaw' || t === 'hermes';
|
||||
export function hookProfile(t: HookTarget, env: Env, home: string): string {
|
||||
const v = env[SPECS[t].profileVar];
|
||||
return v && path.isAbsolute(v) ? path.resolve(v) : v || path.join(home, SPECS[t].home);
|
||||
}
|
||||
export const hookRoot = (t: HookTarget, profile: string) => path.join(profile, SPECS[t].sub, HOOK_ID);
|
||||
/** The skills folder a registration must use for the hook to go into that profile. */
|
||||
export const profileSkills = (t: HookTarget, profile: string) => path.join(profile, ...SPECS[t].skills);
|
||||
const activateCommands = (t: HookTarget, profile: string) => SPECS[t].activate.map(a => SPECS[t].profileVar + '=' + JSON.stringify(profile) + ' ' + t + ' ' + a.join(' ')).join(' && ');
|
||||
|
||||
/** The plugin's files (absolute path -> bytes): fixed code plus note.txt, which the code reads at each turn. */
|
||||
export function hookFiles(t: HookTarget, root: string): Map<string, Buffer> {
|
||||
const out = new Map<string, Buffer>();
|
||||
for (const [name, text] of Object.entries({ ...SPECS[t].files(), 'note.txt': START_NOTE + '\n' })) out.set(path.join(root, name), Buffer.from(text));
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The user's choice and where it came from: --start-hook/--no-start-hook, then the env var, then gstack-config, then the ledger. */
|
||||
export function hookChoice(flag: boolean | undefined, env: Env, stateRoot: string, recorded: string | undefined): { on: boolean; source: 'flag' | 'env' | 'config' | 'ledger' } | { invalid: string } {
|
||||
if (flag !== undefined) return { on: flag, source: 'flag' };
|
||||
const parse = (v: string | null | undefined) => v == null || v === '' ? null : /^(1|on|true|yes)$/i.test(v) ? true : /^(0|off|false|no)$/i.test(v) ? false : undefined;
|
||||
const fromEnv = parse(env.GSTACK_HARNESS_START_HOOK);
|
||||
if (fromEnv === undefined) return { invalid: 'GSTACK_HARNESS_START_HOOK=' + JSON.stringify(env.GSTACK_HARNESS_START_HOOK) };
|
||||
if (fromEnv !== null) return { on: fromEnv, source: 'env' };
|
||||
const stored = readConfigKey('harness_start_hook', { ...env, GSTACK_STATE_ROOT: stateRoot } as NodeJS.ProcessEnv);
|
||||
const fromConfig = parse(stored);
|
||||
if (fromConfig === undefined) return { invalid: 'gstack-config harness_start_hook=' + JSON.stringify(stored) };
|
||||
if (fromConfig !== null) return { on: fromConfig, source: 'config' };
|
||||
return { on: recorded === 'on', source: 'ledger' };
|
||||
}
|
||||
|
||||
/** Runs the harness's own CLI for one profile: argv only, stdin closed, a timeout, and no environment beyond PATH, HOME and the profile. */
|
||||
export function runHarness(t: HookTarget, profile: string, args: readonly string[], env: Env, home: string, timeout = 120_000) {
|
||||
const r = spawnSync(t, args, { env: { PATH: env.PATH ?? '', HOME: home, [SPECS[t].profileVar]: profile }, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout });
|
||||
const out = ((r.stdout ?? '') + (r.stderr ?? '')).trim();
|
||||
return { ok: r.status === 0, stdout: r.stdout ?? '', why: r.error ? String((r.error as NodeJS.ErrnoException).code ?? r.error.message) : r.status === 0 ? '' : 'exit ' + r.status + (out ? ': ' + out.split('\n').slice(-2).join(' | ').slice(0, 300) : '') };
|
||||
}
|
||||
const onPath = (bin: string, env: Env) => (env.PATH ?? '').split(path.delimiter).some(d => d && fs.existsSync(path.join(d, bin)));
|
||||
|
||||
/** Turns the plugin on in the harness. Returns the first failure, or ''. */
|
||||
export function activateHook(t: HookTarget, profile: string, env: Env, home: string): string {
|
||||
if (!onPath(t, env)) return t + ' is not on PATH';
|
||||
for (const a of SPECS[t].activate) {
|
||||
const r = runHarness(t, profile, a, env, home, 60_000);
|
||||
if (!r.ok) return t + ' ' + a.join(' ') + ' failed (' + r.why + ')';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function parseJson(s: string): any {
|
||||
for (const m of s.matchAll(/^[ \t]*[[{]/gm)) { try { return JSON.parse(s.slice(m.index)); } catch {} }
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Reads the hook back from the harness: active (configured for new sessions), disabled by the user, or written (with the fix). */
|
||||
const READ_TIMEOUT = 15_000;
|
||||
|
||||
export function inspectHook(t: HookTarget, profile: string, env: Env, home: string): HookResult {
|
||||
const fix = activateCommands(t, profile);
|
||||
if (!onPath(t, env)) return { state: 'written', detail: t + ' is not on PATH, so gstack could not turn the plugin on or check it', fix };
|
||||
if (t === 'hermes') {
|
||||
const r = runHarness(t, profile, ['plugins', 'list', '--json'], env, home, READ_TIMEOUT);
|
||||
const row = (parseJson(r.stdout) ?? []).find?.((p: any) => p?.name === HOOK_ID);
|
||||
if (row?.status === 'enabled') return { state: 'active' };
|
||||
if (row?.status === 'disabled') return { state: 'disabled', detail: 'turned off in Hermes (plugins.disabled); gstack leaves it off', fix: activateCommands(t, profile) };
|
||||
return { state: 'written', detail: row ? 'Hermes lists it as ' + row.status : 'hermes plugins list does not show ' + HOOK_ID + (r.why ? ' (' + r.why + ')' : ''), fix };
|
||||
}
|
||||
const cfg = parseJson(runHarness(t, profile, ['config', 'get', 'plugins', '--json'], env, home, READ_TIMEOUT).stdout) ?? {};
|
||||
const entry = cfg.entries?.[HOOK_ID] ?? {};
|
||||
if (entry.enabled === false) return { state: 'disabled', detail: 'turned off in openclaw.json (plugins.entries.' + HOOK_ID + '.enabled false); gstack leaves it off', fix };
|
||||
if (Array.isArray(cfg.deny) && cfg.deny.includes(HOOK_ID)) return { state: 'written', detail: 'plugins.deny lists ' + HOOK_ID, fix: 'remove ' + HOOK_ID + ' from plugins.deny in openclaw.json' };
|
||||
if (Array.isArray(cfg.allow) && cfg.allow.length && !cfg.allow.includes(HOOK_ID)) return { state: 'written', detail: 'plugins.allow is set and does not list ' + HOOK_ID, fix: 'OPENCLAW_STATE_DIR=' + JSON.stringify(profile) + " openclaw config set plugins.allow '" + JSON.stringify([...cfg.allow, HOOK_ID]) + "'" };
|
||||
if (entry.hooks?.allowPromptInjection === false) return { state: 'written', detail: 'plugins.entries.' + HOOK_ID + '.hooks.allowPromptInjection is false', fix };
|
||||
const r = runHarness(t, profile, ['plugins', 'inspect', HOOK_ID, '--runtime', '--json'], env, home, READ_TIMEOUT);
|
||||
const d = parseJson(r.stdout);
|
||||
if (d?.plugin?.status === 'loaded' && d.plugin.enabled && (d.typedHooks ?? []).some((h: any) => h?.name === 'before_prompt_build')) return { state: 'active' };
|
||||
const why = d?.plugin ? 'status ' + d.plugin.status + (d.plugin.enabled ? '' : ', not enabled') + ((d.typedHooks ?? []).length ? '' : ', before_prompt_build not registered') : 'openclaw plugins inspect failed' + (r.why ? ' (' + r.why + ')' : '');
|
||||
return { state: 'written', detail: why, fix };
|
||||
}
|
||||
|
||||
/** True when a plugin folder holds nothing but the .edited-<stamp> copies gstack kept when it turned the hook off. */
|
||||
function onlyKeptEdits(root: string): boolean {
|
||||
let entries: fs.Dirent[];
|
||||
try { entries = fs.readdirSync(root, { recursive: true, withFileTypes: true }); } catch { return false; }
|
||||
const files = entries.filter(e => !e.isDirectory());
|
||||
return files.length > 0 && files.every(e => e.isFile() && /\.edited-[0-9T-]+Z$/.test(e.name));
|
||||
}
|
||||
|
||||
/** Checks the plugin folder before anything is written: under the profile, no symlink from the profile down, and not someone else's. */
|
||||
export function assertHookRoot(t: HookTarget, profile: string, root: string, recordedRoot: string | undefined, fail: (code: 'HARNESS_UNSAFE_DESTINATION' | 'HARNESS_HOOK_OWNED_ELSEWHERE', msg: string) => never): void {
|
||||
if (!path.isAbsolute(profile) || /[\t\n\r]/.test(profile)) fail('HARNESS_UNSAFE_DESTINATION', 'the ' + t + ' profile ' + profile + ' is not a plain absolute path');
|
||||
const top = path.resolve(profile);
|
||||
for (let d = root; d !== top && d !== path.dirname(d); d = path.dirname(d)) {
|
||||
let st: fs.Stats;
|
||||
try { st = fs.lstatSync(d); } catch { continue; }
|
||||
if (st.isSymbolicLink()) fail('HARNESS_UNSAFE_DESTINATION', d + ' is a symlink');
|
||||
}
|
||||
if (fs.existsSync(root) && recordedRoot !== root && !onlyKeptEdits(root)) fail('HARNESS_HOOK_OWNED_ELSEWHERE', 'a ' + HOOK_ID + ' plugin already exists at ' + root + ' and this registration did not write it');
|
||||
}
|
||||
@@ -73,6 +73,8 @@ export interface PregateCheck {
|
||||
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[];
|
||||
/** `lanes` under `--from-run`: the run receipt and the selected local test files that run already passed on this tree (not run again here). */
|
||||
from_run?: { receipt: string; files: 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 }> }
|
||||
@@ -219,7 +221,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 } }, proven: strList } } },
|
||||
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, from_run: { type: 'object', required: ['receipt', 'files'], properties: { receipt: str, files: 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 } } } } } } } },
|
||||
@@ -549,9 +551,16 @@ export interface DerivedReviewStatus {
|
||||
findings_total: number; findings_open: number; findings_resolved: number;
|
||||
}
|
||||
export function isOpenFinding(row: Record<string, unknown>): boolean {
|
||||
if (row.action === 'open') return true;
|
||||
if (typeof row.disposition === 'string') return !RESOLVED.includes(row.disposition);
|
||||
if (typeof row.status === 'string') return !['resolved', 'fixed', 'closed'].includes(row.status);
|
||||
return row.action !== 'fixed';
|
||||
return isOpenByAction(row);
|
||||
}
|
||||
/** A finding's `action` alone: fixed and auto-fixed resolve it; deferred and noted resolve only one marked `blocking: false` that is not CRITICAL (review policy's floor). */
|
||||
export function isOpenByAction(row: Record<string, unknown>): boolean {
|
||||
if (row.action === 'fixed' || row.action === 'auto-fixed') return false;
|
||||
if (row.action === 'deferred' || row.action === 'noted') return row.blocking !== false || isCriticalFinding(row);
|
||||
return true;
|
||||
}
|
||||
export function isCriticalFinding(row: Record<string, unknown>): boolean {
|
||||
return typeof row.severity === 'string' && row.severity.toLowerCase() === 'critical';
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Read one file at a commit when the checkout may be a blobless clone
|
||||
* (`filter: blob:none`), where git fetches an old file's contents on demand
|
||||
* and that fetch can fail. The TypeScript twin of
|
||||
* .github/scripts/history-read.sh. A read that `git show` answers is the
|
||||
* file. When it fails, existence comes from the ref's tree (`git ls-tree
|
||||
* --full-tree`, local data only): a path the tree does not list is absent, and
|
||||
* a listed one is retried, 3 tries in all in a partial clone (only a fetch can
|
||||
* recover) and none otherwise, waiting HISTORY_READ_BACKOFF_SECONDS (default 2)
|
||||
* times the attempt between tries. Every other failure throws
|
||||
* HISTORY_READ_FAILED instead of reading as absent
|
||||
* (docs/troubleshooting.md#history-read-failed).
|
||||
*/
|
||||
import { spawnSync } from 'node:child_process';
|
||||
|
||||
export const HISTORY_READ_TRIES = 3;
|
||||
|
||||
export class HistoryReadError extends Error {
|
||||
readonly code = 'HISTORY_READ_FAILED';
|
||||
}
|
||||
|
||||
/** The file's text at `ref`, or null when the ref's tree does not list it. */
|
||||
export function readHistoryFile(repoRoot: string, ref: string, file: string): string | null {
|
||||
const git = (args: string[]) => spawnSync('git', args, { cwd: repoRoot, encoding: 'utf8', timeout: 60_000, maxBuffer: 64 * 1024 * 1024 });
|
||||
const fail = (cause: string) => {
|
||||
const version = git(['--version']).stdout?.trim() || 'git version unknown';
|
||||
return new HistoryReadError(`cannot read ${file} at ${ref}. ${cause} (${version}). Fix: see docs/troubleshooting.md#history-read-failed (HISTORY_READ_FAILED)`);
|
||||
};
|
||||
const firstLine = (r: ReturnType<typeof git>) => (r.stderr ?? '').split('\n')[0] || r.error?.message || `exit ${r.status}`;
|
||||
let shown = git(['show', `${ref}:${file}`]);
|
||||
if (shown.status === 0) return shown.stdout;
|
||||
const listed = git(['ls-tree', '--full-tree', ref, '--', file]);
|
||||
if (listed.status !== 0) throw fail(`git ls-tree failed: ${firstLine(listed)}`);
|
||||
if (!listed.stdout.trim()) return null;
|
||||
const promisors = (git(['config', '--get-regexp', '^(remote\\..+\\.promisor|extensions\\.partialclone)$']).stdout ?? '').split('\n');
|
||||
const tries = promisors.some(line => line && !/ false$/i.test(line)) ? HISTORY_READ_TRIES : 1;
|
||||
for (let attempt = 1; attempt < tries; attempt++) {
|
||||
Bun.sleepSync(attempt * 1000 * Number(process.env.HISTORY_READ_BACKOFF_SECONDS ?? 2));
|
||||
shown = git(['show', `${ref}:${file}`]);
|
||||
if (shown.status === 0) return shown.stdout;
|
||||
}
|
||||
throw fail(`git show failed ${tries === 1 ? 'once (not a partial clone, so no retry)' : `${tries} times`}: ${firstLine(shown)}`);
|
||||
}
|
||||
+65
-13
@@ -4,10 +4,12 @@
|
||||
* `set -o pipefail` with the exit code and the runner's own summary judged
|
||||
* (zero tests ran is a failure, exit 0 or not); platform lanes this machine
|
||||
* cannot run become persisted `requires-remote` obligations. A touched file
|
||||
* with no lane is `FAIL lanes — <file> has no test lane` once any policy
|
||||
* exists; with no policy anywhere (no base `.gstack/pregate.json`, no
|
||||
* user-owned file) it is the `warn` PREGATE_NO_POLICY with a starter printed
|
||||
* (lib/pregate/starter.ts), never a pass either way.
|
||||
* with no lane is `FAIL lanes — <file> has no test lane` once the policy
|
||||
* declares lanes (a `dependencies`, `empty_lanes`, `selection` or `local` key); with no policy
|
||||
* anywhere, or one that declares neither (a timeout-only file), it is the
|
||||
* `warn` PREGATE_NO_POLICY with a starter printed (lib/pregate/starter.ts),
|
||||
* never a pass either way. `warn: ["lanes"]` downgrades only that condition:
|
||||
* the selected tests still run and a red lane stays `fail` (GST-775).
|
||||
* Markdown no test depends on is the docs/no-tests empty lane instead
|
||||
* (lib/pregate/docs-lane.ts): it does not fail, and unmapped code still does.
|
||||
* A release manifest whose diff against the merge base changes only version
|
||||
@@ -19,6 +21,9 @@
|
||||
* and a deleted test runs its owning suite or names the files that lost their
|
||||
* last test (issue #3529). Any other removal owes what an edit owes.
|
||||
* A test that reads the file still selects that test.
|
||||
* Under `--from-run <run>` the selected local test files a verified Free Tests
|
||||
* run on this exact tree lists count as passed (recorded as `from_run` with the
|
||||
* run receipt) and only the rest run; a refused run runs everything.
|
||||
* Incidents: p0-4-pipe-hidden-failure (`false | tail` read as green),
|
||||
* p0-4-windows-lane-unrun (a Windows-only change merged with its lane unrun).
|
||||
*/
|
||||
@@ -34,6 +39,7 @@ import { selectLanes, type Selection } from '../lane-selection';
|
||||
import { launchRuntime } from '../runtime';
|
||||
import { cargoModel, type CargoModel } from '../rust';
|
||||
import { adapterFor, readRange, supervise, type Counter, type Supervised } from '../supervise';
|
||||
import { verifyFromRun } from '../../run-evidence';
|
||||
import { matchesAny } from '../../ship-policy';
|
||||
import type { CheckDef, CheckResult, PregateContext, RemoteObligation } from '../types';
|
||||
|
||||
@@ -347,8 +353,15 @@ function unfinished(ctx: PregateContext, r: Awaited<ReturnType<typeof runLocal>>
|
||||
return { code: 'PREGATE_INCOMPLETE', detail: `a runner process timed out after ${ctx.timeoutMs} ms (${ctx.timeoutSource ?? '--timeout'} applies to each runner process; ${total} files; last file started: ${timedOut.map(x => x.lastFile ?? `(none reported; ${x.files.length} file(s))`).join(', ')}${rest})`, fix: 'raise --timeout or split the selection; a timed-out lane never passes' };
|
||||
}
|
||||
|
||||
function describe(sel: Selection, noPolicy = false): string[] {
|
||||
const lines = noPolicy ? [`no policy: ${sel.noLane.join(' ')} have no test lane (a warning under no policy; the starter below maps them)`] : sel.noLane.map(f => `FAIL lanes — ${f} has no test lane`);
|
||||
/** How a touched file with no lane is judged: the no-policy warning, the `warn: ["lanes"]` downgrade, or the hard PREGATE_NO_LANE. */
|
||||
type NoLaneMode = 'no-policy' | 'warned' | 'strict';
|
||||
|
||||
function describe(ctx: PregateContext, sel: Selection, mode: NoLaneMode): string[] {
|
||||
const lines = !sel.noLane.length ? []
|
||||
: mode === 'warned' ? sel.noLane.map(f => `WARN lanes — ${f} has no test lane (warn: ["lanes"] in ${ctx.configLabel}; the selected tests are still judged)`)
|
||||
: mode === 'strict' ? sel.noLane.map(f => `FAIL lanes — ${f} has no test lane`)
|
||||
: ctx.configKind === 'none' ? [`no policy: ${sel.noLane.join(' ')} have no test lane (a warning under no policy; the starter below maps them)`]
|
||||
: [`no lane policy: ${sel.noLane.join(' ')} have no test lane (a warning while ${ctx.configLabel} declares no dependencies, empty_lanes, selection or local; the starter below maps them)`];
|
||||
for (const r of sel.removed) lines.push(`removed (no lane owed): ${r.file} — ${r.note}`);
|
||||
if (sel.emptyLane.length) lines.push(`docs/no-tests (empty lane): ${sel.emptyLane.join(' ')}`);
|
||||
if (sel.inert.length) lines.push(`no-tests (assets and tool config): ${sel.inert.join(' ')}`);
|
||||
@@ -500,9 +513,25 @@ async function runSelected(ctx: PregateContext, sel: Selection, base: CheckResul
|
||||
*/
|
||||
function withStarter(ctx: PregateContext, sel: Selection, result: CheckResult): CheckResult {
|
||||
const lines = [...(result.lines ?? []), ...starterPolicy(ctx, sel.noLane, detectRunner(ctx, sel.localTests.length ? sel.localTests : sel.tests))];
|
||||
const noted = `${sel.noLane.length} touched file(s) with no test lane under no policy (starter printed)`;
|
||||
const noted = `${sel.noLane.length} touched file(s) with no test lane under ${ctx.configKind === 'none' ? 'no policy' : 'a policy that declares no lanes'} (starter printed)`;
|
||||
if (result.status !== 'pass') return { ...result, lines, detail: `${result.detail}; ${noted}`, ...(result.status === 'requires-remote' && !result.code ? { code: 'PREGATE_NO_POLICY' } : {}) };
|
||||
return { ...result, status: 'warn', code: 'PREGATE_NO_POLICY', lines, detail: `${result.detail}; ${noted}`, fix: `review the starter above, write it with gstack-pregate init, edit each TODO, then commit ${PREGATE_CONFIG_PATH} on the base branch; from then on an unmapped file is PREGATE_NO_LANE` };
|
||||
const fix = ctx.configKind === 'none'
|
||||
? `review the starter above, write it with gstack-pregate init, edit each TODO, then commit ${PREGATE_CONFIG_PATH} on the base branch; from then on an unmapped file is PREGATE_NO_LANE`
|
||||
: `review the starter above and add its dependencies to ${ctx.configFile}${ctx.configKind === 'user' ? '' : ' on the base branch'}, editing each TODO; from then on an unmapped file is PREGATE_NO_LANE`;
|
||||
return { ...result, status: 'warn', code: 'PREGATE_NO_POLICY', lines, detail: `${result.detail}; ${noted}`, fix };
|
||||
}
|
||||
|
||||
/**
|
||||
* `warn: ["lanes"]` under a policy that declares lanes: the selected tests ran
|
||||
* and their verdict stands (a red lane is still `fail`, an unfinished one
|
||||
* `incomplete`); only a green run becomes `warn` PREGATE_NO_LANE naming the
|
||||
* files with no lane. Before GST-775 the downgraded failure returned before
|
||||
* any test ran, so a red test beside an unmapped file passed.
|
||||
*/
|
||||
function withNoLaneWarning(ctx: PregateContext, sel: Selection, result: CheckResult): CheckResult {
|
||||
const noted = `${sel.noLane.length} touched file(s) with no test lane: ${sel.noLane.join(' ')} (downgraded to warn by ${ctx.configLabel})`;
|
||||
if (result.status !== 'pass') return { ...result, detail: `${result.detail}; ${noted}`, ...(result.status === 'requires-remote' && !result.code ? { code: 'PREGATE_NO_LANE' } : {}) };
|
||||
return { ...result, status: 'warn', code: 'PREGATE_NO_LANE', detail: `${result.detail}; ${noted}`, fix: noLaneFix(ctx, sel.noLane) };
|
||||
}
|
||||
|
||||
async function run(ctx: PregateContext): Promise<CheckResult> {
|
||||
@@ -510,16 +539,39 @@ async function run(ctx: PregateContext): Promise<CheckResult> {
|
||||
ctx.explain.push(...sel.provenance);
|
||||
for (const [lane, set] of Object.entries(sel.laneSets)) ctx.explain.push(`lane ${lane}: ${set === '?' ? 'file set unknown' : `${set.length} files`}`);
|
||||
const inputs = baseInputs(ctx, { selection: sha256(sel.tests.join('\n')), localTests: sha256(sel.localTests.join('\n')), remote: sha256(sel.remote.map(r => `${r.lane}:${r.tests.join(',')}`).join('\n')) });
|
||||
const noPolicy = sel.noLane.length > 0 && ctx.configKind === 'none';
|
||||
const base: CheckResult = { id: 'lanes', stage: 'tests', status: 'pass', inputs, lines: describe(sel, noPolicy), remote: sel.remote };
|
||||
const declared = ctx.configKind !== 'none' && ctx.policyDeclaresLanes !== false;
|
||||
const mode: NoLaneMode = !declared ? 'no-policy' : ctx.config.warn.includes('lanes') ? 'warned' : 'strict';
|
||||
const base: CheckResult = { id: 'lanes', stage: 'tests', status: 'pass', inputs, lines: describe(ctx, sel, mode), remote: sel.remote };
|
||||
const lost = new Set(sel.removed.flatMap(r => r.lostCoverage)).size;
|
||||
const removedNote = sel.removed.length ? `${sel.removed.length} removed path(s)${lost ? `; ${lost} subject(s) lost their last test` : ''}` : '';
|
||||
const withRemoved = (r: CheckResult): CheckResult => (removedNote ? { ...r, detail: r.detail ? `${r.detail}; ${removedNote}` : removedNote } : r);
|
||||
if (sel.noLane.length && !noPolicy) {
|
||||
if (sel.noLane.length && mode === 'strict') {
|
||||
return withRemoved({ ...base, status: 'fail', cause: 'diff', code: 'PREGATE_NO_LANE', detail: `${sel.noLane.length} touched file(s) with no test lane: ${sel.noLane.join(' ')}`, fix: noLaneFix(ctx, sel.noLane) });
|
||||
}
|
||||
const result = withRemoved(await runSelected(ctx, sel, base));
|
||||
return noPolicy ? withStarter(ctx, sel, result) : result;
|
||||
const reuse = ctx.fromRun ? fromRun(ctx, sel) : null;
|
||||
if (reuse) ctx.progress(reuse.line);
|
||||
const rest = reuse?.passed.length ? { ...sel, localTests: sel.localTests.filter(t => !reuse.passed.includes(t)) } : sel;
|
||||
const ran = await runSelected(ctx, rest, base);
|
||||
const reused = !reuse ? ran : { ...ran, lines: [reuse.line, ...(ran.lines ?? [])], ...(reuse.receipt ? { from_run: { receipt: reuse.receipt, files: reuse.passed }, detail: `${reuse.passed.length} of ${sel.localTests.length} local test file(s) passed in the --from-run run; ${ran.detail ?? ran.status}` } : {}) };
|
||||
const result = withRemoved(reused);
|
||||
if (!sel.noLane.length) return result;
|
||||
return mode === 'no-policy' ? withStarter(ctx, sel, result) : withNoLaneWarning(ctx, sel, result);
|
||||
}
|
||||
|
||||
/**
|
||||
* `--from-run`: verify the run against the base branch's profiles for this machine's OS whose workflow holds a lane this
|
||||
* machine runs (not a platform lane), on exactly this tree; the selected local test files its tree.json lists are the
|
||||
* ones it already passed. Profiles are matched by workflow and OS, never by name, so none has to be named after a lane.
|
||||
*/
|
||||
function fromRun(ctx: PregateContext, sel: Selection): { line: string; passed: string[]; receipt?: string } {
|
||||
const { ref, check } = ctx.fromRun!;
|
||||
const os = ({ linux: 'Linux', darwin: 'macOS', win32: 'Windows' } as Record<string, string>)[ctx.platform];
|
||||
const names = 'error' in check ? [] : Object.entries(check.profiles).filter(([, p]) => p.os === os && ctx.lanes.some(l => l.tier !== 'platform' && l.id.startsWith(`${p.workflow}/`))).map(([n]) => n);
|
||||
const v = 'error' in check ? { ok: false as const, reason: 'profiles_unavailable', detail: check.error } : verifyFromRun(check, ref, names, ctx.wtree, ctx.head);
|
||||
if (!v.ok) return { line: `REUSE: refused reason=${v.reason} — ${v.detail}; every selected test runs here`, passed: [] };
|
||||
const ran = new Set(v.evidence.files);
|
||||
const passed = sel.localTests.filter(t => ran.has(t));
|
||||
return { line: `REUSE: accepted ${passed.length} of ${sel.localTests.length} selected local test file(s) from ${v.receipt}; ${sel.localTests.length - passed.length} still run`, passed, receipt: v.receipt };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+18
-8
@@ -13,8 +13,9 @@
|
||||
* depends on it; source code and test trees never do)
|
||||
* selection `<workflow.yml>/<job>` → the runner-owned command that lists
|
||||
* the lane's test files when the matrix is planner-computed
|
||||
* warn check ids downgraded to warn (never `secrets`; `lanes` keeps
|
||||
* its required-lane and requires-remote verdicts)
|
||||
* warn check ids downgraded to warn (never `secrets`; `lanes`
|
||||
* downgrades only a touched file with no lane, after the
|
||||
* selected tests ran: a red lane stays `fail`)
|
||||
* strays.allow root files or new top-level directories that are intentional
|
||||
* local `<workflow.yml>/<job>` lanes a local run satisfies (a
|
||||
* platform-agnostic toolchain, or a repo whose CI is off):
|
||||
@@ -49,6 +50,11 @@
|
||||
* `env.value` (a configured credential's real value) and never
|
||||
* the whole `secret` category
|
||||
*
|
||||
* A policy declares lanes when it has a `dependencies`, `empty_lanes`,
|
||||
* `selection` or `local` key (LANE_KEYS), even an empty one; only then is a touched file with no lane
|
||||
* the hard PREGATE_NO_LANE. A policy without either (a timeout-only file)
|
||||
* keeps the no-policy lane verdicts (lib/pregate/checks/lanes.ts).
|
||||
*
|
||||
* A repo that keeps `.gstack/` out of git can hold the same file at
|
||||
* `<state root>/projects/<slug>/pregate.json` (lib/state-root.ts), honoured
|
||||
* only when the base carries none. It must sit outside the repo, so a PR
|
||||
@@ -254,17 +260,20 @@ export function validateConfig(raw: unknown, knownChecks: readonly string[] = []
|
||||
return errors;
|
||||
}
|
||||
|
||||
export function parseConfig(text: string, knownChecks: readonly string[] = []): { config: PregateConfig; errors: ConfigError[] } {
|
||||
/** The keys that declare lanes: with either present, a touched file with no lane is PREGATE_NO_LANE. */
|
||||
export const LANE_KEYS = ['dependencies', 'empty_lanes', 'selection', 'local'] as const;
|
||||
|
||||
export function parseConfig(text: string, knownChecks: readonly string[] = []): { config: PregateConfig; errors: ConfigError[]; declaresLanes: boolean } {
|
||||
let raw: unknown;
|
||||
try { raw = JSON.parse(text); }
|
||||
catch (e: any) { return { config: DEFAULT_CONFIG, errors: [{ code: 'PREGATE_CONFIG_INVALID', path: PREGATE_CONFIG_PATH, message: `not valid JSON: ${e.message}` }] }; }
|
||||
catch (e: any) { return { config: DEFAULT_CONFIG, errors: [{ code: 'PREGATE_CONFIG_INVALID', path: PREGATE_CONFIG_PATH, message: `not valid JSON: ${e.message}` }], declaresLanes: false }; }
|
||||
const errors = validateConfig(raw, knownChecks);
|
||||
if (errors.length) return { config: DEFAULT_CONFIG, errors };
|
||||
if (errors.length) return { config: DEFAULT_CONFIG, errors, declaresLanes: false };
|
||||
const o = raw as Partial<PregateConfig>;
|
||||
return { config: { $schema: o.$schema, runner: o.runner ?? null, dependencies: o.dependencies ?? {}, empty_lanes: o.empty_lanes ?? [], selection: o.selection ?? {}, warn: o.warn ?? [], strays: { allow: o.strays?.allow ?? [] }, local: o.local ?? [], guards: o.guards ?? [], serial_patterns: o.serial_patterns ?? DEFAULT_SERIAL_PATTERNS, price_file: o.price_file ?? null, stall_timeout_s: o.stall_timeout_s ?? null, timeout_s: o.timeout_s ?? null, remote_gate: o.remote_gate ?? null, allowlist: o.allowlist ?? [], run_profiles: o.run_profiles ?? {}, remote_gate_runs: o.remote_gate_runs ?? null }, errors: [] };
|
||||
return { declaresLanes: LANE_KEYS.some(k => k in o), config: { $schema: o.$schema, runner: o.runner ?? null, dependencies: o.dependencies ?? {}, empty_lanes: o.empty_lanes ?? [], selection: o.selection ?? {}, warn: o.warn ?? [], strays: { allow: o.strays?.allow ?? [] }, local: o.local ?? [], guards: o.guards ?? [], serial_patterns: o.serial_patterns ?? DEFAULT_SERIAL_PATTERNS, price_file: o.price_file ?? null, stall_timeout_s: o.stall_timeout_s ?? null, timeout_s: o.timeout_s ?? null, remote_gate: o.remote_gate ?? null, allowlist: o.allowlist ?? [], run_profiles: o.run_profiles ?? {}, remote_gate_runs: o.remote_gate_runs ?? null }, errors: [] };
|
||||
}
|
||||
|
||||
export type ConfigLoad = { config: PregateConfig; errors: ConfigError[]; present: boolean; label: string; kind: 'base' | 'head' | 'user' | 'none'; /** sha256 of the user file's bytes (kind 'user' only). */ digest?: string } | { unavailable: true; base: string; detail: string };
|
||||
export type ConfigLoad = { config: PregateConfig; errors: ConfigError[]; present: boolean; label: string; kind: 'base' | 'head' | 'user' | 'none'; /** sha256 of the user file's bytes (kind 'user' only). */ digest?: string; /** The policy has a LANE_KEYS key; absent under kind 'none'. */ declaresLanes?: boolean } | { unavailable: true; base: string; detail: string };
|
||||
|
||||
function git(cwd: string, args: string[]): { ok: boolean; out: string } {
|
||||
const r = spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 30_000 });
|
||||
@@ -333,7 +342,8 @@ export function explainConfig(): string {
|
||||
'dependencies: glob → test files the import graph cannot see (subprocess targets, runtime reads, generated inputs); [] declares no lane needed',
|
||||
'empty_lanes: extra globs treated as docs/no-tests when nothing else maps them. Built-in, with or without this file: any Markdown file the import graph maps to no test (a quoted path, a join() of path segments, an import, or a dependencies entry), plus README/CHANGELOG/CONTRIBUTING/LICENSE/AGENTS/CLAUDE and licence text under any name; assets (images, fonts, media) and allowlisted tool config take their own empty lane. Source code and files under a test directory never qualify. A test that reads the file still selects that test',
|
||||
'selection: <workflow.yml>/<job> → the runner-owned command that lists that lane’s test files when its matrix is planner-computed',
|
||||
`warn: check ids downgraded to warn; never ${NON_DOWNGRADABLE.join(', ')}; lanes keeps its required-lane and requires-remote verdicts`,
|
||||
`warn: check ids downgraded to warn; never ${NON_DOWNGRADABLE.join(', ')}; lanes downgrades only a touched file with no lane, after the selected tests ran (a red lane stays fail)`,
|
||||
`lane requirements: a policy with any of ${LANE_KEYS.join(', ')} (even empty) makes a touched file with no lane PREGATE_NO_LANE; a policy with none of them (timeout_s only, say) keeps the no-policy warning (PREGATE_NO_POLICY) and starter`,
|
||||
'strays.allow: root files or new top-level directories that are intentional',
|
||||
'local: <workflow.yml>/<job> lanes a local run satisfies (platform-agnostic toolchain, or CI off); their tests run here, not requires-remote',
|
||||
'guards: [{ id, when: [globs], command, fix?, timeout_s? }]: each guard whose globs match a touched file runs in its own process (--allow-repo-commands); a failure fails pregate with the guard id and fix; never downgradable',
|
||||
|
||||
@@ -247,7 +247,7 @@ export function buildContext(o: BuildContextOptions): ContextLoad {
|
||||
return {
|
||||
ctx: {
|
||||
repoRoot: o.repoRoot, base, baseRef, baseSha: baseSha.out, mergeBase, head, wtree, touched: touchedFiles(o.repoRoot, mergeBase),
|
||||
config, configLabel: configLoad.label, configKind: configLoad.kind, scratch, configFile: configLoad.kind === 'user' && o.userConfigPath ? o.userConfigPath : PREGATE_CONFIG_PATH, ...(configLoad.digest ? { configDigest: configLoad.digest } : {}), ...(absentUserPolicy ? { absentUserPolicy } : {}), registry: registryLoad.registry as Registry, registryLabel: registryLoad.source.label,
|
||||
config, configLabel: configLoad.label, configKind: configLoad.kind, policyDeclaresLanes: configLoad.declaresLanes !== false, scratch, configFile: configLoad.kind === 'user' && o.userConfigPath ? o.userConfigPath : PREGATE_CONFIG_PATH, ...(configLoad.digest ? { configDigest: configLoad.digest } : {}), ...(absentUserPolicy ? { absentUserPolicy } : {}), registry: registryLoad.registry as Registry, registryLabel: registryLoad.source.label,
|
||||
lanes, platform, allowRepoCommands: o.allowRepoCommands, timeoutMs: o.timeoutMs ?? (config.timeout_s ?? 600) * 1000, timeoutSource: o.timeoutMs !== undefined ? '--timeout' : config.timeout_s ? `timeout_s from ${configLoad.label}` : 'the 600 s default',
|
||||
stallTimeoutMs: o.stallTimeoutMs ?? (config.stall_timeout_s ?? DEFAULT_STALL_TIMEOUT_S) * 1000,
|
||||
progress: o.progress ?? (line => process.stderr.write(`${line}\n`)),
|
||||
|
||||
@@ -66,7 +66,7 @@ export function headPolicy(repoRoot: string, trusted: Loaded, mergeBase: string,
|
||||
for (const [glob, tests] of Object.entries(adds)) dependencies[glob] = [...new Set([...(dependencies[glob] ?? []), ...tests])];
|
||||
const n = Object.keys(adds).length;
|
||||
return {
|
||||
load: { ...trusted, config: { ...trusted.config, dependencies }, kind: 'head', label: `head (unreviewed) ${PREGATE_CONFIG_PATH}: adds ${n} lane${n === 1 ? '' : 's'} over ${trusted.label}` },
|
||||
load: { ...trusted, config: { ...trusted.config, dependencies }, declaresLanes: trusted.declaresLanes === true || head.declaresLanes === true, kind: 'head', label: `head (unreviewed) ${PREGATE_CONFIG_PATH}: adds ${n} lane${n === 1 ? '' : 's'} over ${trusted.label}` },
|
||||
explain: Object.entries(adds).map(([glob, tests]) => `head policy adds: dependencies[${JSON.stringify(glob)}] → ${tests.join(', ')}`),
|
||||
};
|
||||
}
|
||||
@@ -37,12 +37,12 @@ export const PREGATE_TIER2 = ['ratchets', 'lint', 'links', 'hermetic', 'patches'
|
||||
|
||||
export interface RunOptions { stage: Stage | 'all'; only?: string[]; run?: string }
|
||||
|
||||
/** Apply the repo's `warn` downgrades: never `secrets`, and never a lane that could run here and failed. */
|
||||
/** Apply the repo's `warn` downgrades: never `secrets`. `lanes` applies its own (a touched file with no lane, after the selected tests ran), so nothing it fails is downgraded here. */
|
||||
export function applyDowngrades(ctx: PregateContext, result: CheckResult, def: CheckDef): CheckResult {
|
||||
if (result.status !== 'fail') return result;
|
||||
if (!def.downgradable || (NON_DOWNGRADABLE as readonly string[]).includes(def.id)) return result;
|
||||
if (!ctx.config.warn.includes(def.id)) return result;
|
||||
if (def.id === 'lanes' && result.code === 'PREGATE_LANE_FAILED') return { ...result, detail: `${result.detail} (required lane; warn downgrade does not apply)` };
|
||||
if (def.id === 'lanes') return result.code === 'PREGATE_LANE_FAILED' ? { ...result, detail: `${result.detail} (required lane; warn downgrade does not apply)` } : result;
|
||||
return { ...result, status: 'warn', detail: `${result.detail} (downgraded to warn by ${ctx.configLabel})` };
|
||||
}
|
||||
|
||||
|
||||
@@ -165,7 +165,7 @@ function removedPath(ctx: PregateContext, graph: Graph, gone: ReadonlySet<string
|
||||
const uncovered = namedBy(ctx, file).filter(f => !gone.has(f)).filter(n => {
|
||||
const m = testsFor(graph, n, ctx.config.dependencies);
|
||||
for (const t of m.tests) namerSuite.add(t);
|
||||
return !m.tests.length;
|
||||
return !m.tests.length && !m.declaredNone;
|
||||
});
|
||||
if (uncovered.length) return { refused: `removed, but still named by ${shown(uncovered)}, which no test covers` };
|
||||
const namerNote = namerSuite.size ? `files naming it run: ${shown([...namerSuite].sort())}` : '';
|
||||
@@ -211,6 +211,11 @@ export function selectLanes(ctx: PregateContext): Selection {
|
||||
const stampOnly: string[] = [];
|
||||
const seeds = new Map<string, string>();
|
||||
const touchedByPath = new Map(touched.map(t => [t.path, t]));
|
||||
const waive = (file: string, r: { removed: Removed; suite: string[] }) => {
|
||||
removed.push(r.removed);
|
||||
for (const t of r.suite) tests.add(t);
|
||||
provenance.push(`${file}: removed (no lane owed) — ${r.removed.note}`);
|
||||
};
|
||||
for (const t of touched) { seeds.set(t.path, t.status === 'D' ? 'deleted' : 'touched'); if (t.renamedFrom) seeds.set(t.renamedFrom, `renamed to ${t.path}`); }
|
||||
for (const e of ctx.registry.entries) {
|
||||
if (!touched.some(t => matchesAny(e.inputs, t.path) || matchesAny(e.outputs, t.path))) continue;
|
||||
@@ -221,8 +226,11 @@ export function selectLanes(ctx: PregateContext): Selection {
|
||||
for (const t of m.tests) tests.add(t);
|
||||
provenance.push(...m.provenance.map(p => `${p} [${why}]`));
|
||||
if (m.tests.length || why.startsWith('generated')) continue;
|
||||
// An explicit `[]` already cleared the file. Label it docs/no-tests only when it is that class.
|
||||
// An explicit `[]` already cleared the file. A removed test still runs its owning suite; label the rest docs/no-tests only when it is that class.
|
||||
if (m.declaredNone) {
|
||||
const r = gone.includes(file) ? removedPath(ctx, graph, goneSet, file) : null;
|
||||
if (r && 'removed' in r) { waive(file, r); continue; }
|
||||
if (r) provenance.push(`${file}: ${r.refused}; cleared by its [] declaration`);
|
||||
if (isDocsEmptyLane(file, ctx.config.empty_lanes ?? [])) emptyLane.push(file);
|
||||
continue;
|
||||
}
|
||||
@@ -259,12 +267,7 @@ export function selectLanes(ctx: PregateContext): Selection {
|
||||
}
|
||||
if (gone.includes(file)) {
|
||||
const r = removedPath(ctx, graph, goneSet, file);
|
||||
if ('removed' in r) {
|
||||
removed.push(r.removed);
|
||||
for (const t of r.suite) tests.add(t);
|
||||
provenance.push(`${file}: removed (no lane owed) — ${r.removed.note}`);
|
||||
continue;
|
||||
}
|
||||
if ('removed' in r) { waive(file, r); continue; }
|
||||
provenance.push(`${file}: ${r.refused}; no lane until declared`);
|
||||
}
|
||||
noLane.push(file);
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* The run-evidence inputs one consumer needs (GSTA-156): the base branch's run
|
||||
* profiles, this repository on GitHub, the verifier's gh- and git-backed
|
||||
* dependencies, and the remote gate's inventory command (a pin-file command,
|
||||
* so it runs only with --allow-repo-commands). Shared by `gstack-pregate
|
||||
* clear` and `gstack-pregate --from-run`, so both read profiles from the
|
||||
* base and nowhere else.
|
||||
*/
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { ghRunDeps, runInventory } from '../run-evidence';
|
||||
import { wtreeFingerprint } from '../script-invocation';
|
||||
import { resolveBaseBranch } from '../ship-policy';
|
||||
import { normalizeRemoteUrl } from '../ship-queue-train-record';
|
||||
import { REMOTE_GATE_LANE } from './checks/lanes';
|
||||
import { PREGATE_CONFIG_PATH, isConfigUnavailable, loadConfigFromBase } from './config';
|
||||
import type { RunCheck } from './index';
|
||||
|
||||
/**
|
||||
* `profiled` says whether `lane` may have a run profile: an unreadable base counts as profiled, so a lane is
|
||||
* recorded unverified only once the base shows it has none.
|
||||
*/
|
||||
export function buildRunCheck(root: string, lane: string | null, allowRepoCommands: boolean, base = resolveBaseBranch(root)): RunCheck | { error: string; profiled?: boolean } {
|
||||
const loaded = loadConfigFromBase(root, base);
|
||||
if (isConfigUnavailable(loaded)) return { error: `the base branch's ${PREGATE_CONFIG_PATH} is unreadable (${loaded.detail}); run profiles come only from the base`, profiled: true };
|
||||
if (loaded.errors.length) return { error: `the base branch's ${PREGATE_CONFIG_PATH} is invalid: ${loaded.errors.map(e => `${e.path}: ${e.message}`).join('; ')}`, profiled: true };
|
||||
const origin = spawnSync('git', ['config', '--get', 'remote.origin.url'], { cwd: root, encoding: 'utf8', timeout: 30_000 });
|
||||
const repo = /^https:\/\/github\.com\/(.+)$/.exec(normalizeRemoteUrl(origin.status === 0 ? origin.stdout.trim() : ''))?.[1];
|
||||
if (!repo) return { error: 'origin is not a github.com repository; run evidence is read from GitHub Actions', profiled: lane === null ? false : lane === REMOTE_GATE_LANE ? !!loaded.config.remote_gate_runs : !!loaded.config.run_profiles[lane] };
|
||||
return {
|
||||
repo, deps: ghRunDeps(repo, root), profiles: loaded.config.run_profiles, gateRuns: loaded.config.remote_gate_runs, now: new Date(),
|
||||
inventory: (command, tree) => {
|
||||
if (!allowRepoCommands) return { error: 'the inventory command is a pin-file command; pass --allow-repo-commands' };
|
||||
const at = () => { const fp = wtreeFingerprint({ cwd: root }); return 'tree' in fp ? fp.tree : null; };
|
||||
if (at() !== tree) return { error: `the working tree is not pregate.json's tree ${tree.slice(0, 12)}; the inventory must list that tree's gate files` };
|
||||
const listed = runInventory(command, root);
|
||||
if (at() !== tree) return { error: `the working tree changed while ${command} ran` };
|
||||
return listed;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -2,7 +2,8 @@
|
||||
* pregate/starter — the policy a repository with none would start from
|
||||
* (Lane D, GSTA-112; FOUN #292, harness-evals #81/#82). When no
|
||||
* `.gstack/pregate.json` exists on the base branch and no user-owned file is
|
||||
* present, the lanes check does not fail a touched file with no lane
|
||||
* present, or the active policy declares no lanes (no `dependencies` or
|
||||
* `empty_lanes`, `selection` or `local` key, GST-775), the lanes check does not fail a touched file with no lane
|
||||
* (PREGATE_NO_LANE); it warns (PREGATE_NO_POLICY) and prints this starter:
|
||||
* the detected runner, a `dependencies` entry for each unmapped touched file
|
||||
* (the tests nearest it, else the tests sharing its stem; never `[]`, which
|
||||
@@ -29,7 +30,7 @@ export function nearestTestsFor(tests: readonly string[], file: string): string[
|
||||
}
|
||||
|
||||
/** The starter for `noLane` (touched files with no lane) under `runner` (detected by the lanes check; null when none). */
|
||||
export function starterPolicy(ctx: Pick<PregateContext, 'repoRoot' | 'lanes' | 'config' | 'baseRef' | 'absentUserPolicy'>, noLane: readonly string[], runner: string | null): string[] {
|
||||
export function starterPolicy(ctx: Pick<PregateContext, 'repoRoot' | 'lanes' | 'config' | 'baseRef' | 'absentUserPolicy' | 'configKind' | 'configLabel' | 'configFile'>, noLane: readonly string[], runner: string | null): string[] {
|
||||
const tests = listRepoFiles(ctx.repoRoot).filter(isTestFile).sort();
|
||||
const dependencies: Record<string, string[]> = {};
|
||||
const todo: string[] = [];
|
||||
@@ -43,7 +44,9 @@ export function starterPolicy(ctx: Pick<PregateContext, 'repoRoot' | 'lanes' | '
|
||||
const dirs = [...dirCounts].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, MAX_DIRS).map(([d]) => d);
|
||||
const lanes = ctx.lanes.filter(l => l.runsTests && !l.manual).map(l => `${l.id} (${l.platform}${l.matrixExpression && l.testFiles.length === 0 && !ctx.config.selection[l.id] ? ', matrix computed; declare selection' : ''})`);
|
||||
return [
|
||||
`PREGATE_STARTER: no ${PREGATE_CONFIG_PATH} on ${ctx.baseRef}${ctx.absentUserPolicy ? ` and no user-owned policy at ${ctx.absentUserPolicy}` : ''}; a starter drafted from this repository follows. Review it, write it with gstack-pregate init, edit each TODO, then commit it on the base branch (from then on an unmapped file is PREGATE_NO_LANE)`,
|
||||
ctx.configKind === 'none'
|
||||
? `PREGATE_STARTER: no ${PREGATE_CONFIG_PATH} on ${ctx.baseRef}${ctx.absentUserPolicy ? ` and no user-owned policy at ${ctx.absentUserPolicy}` : ''}; a starter drafted from this repository follows. Review it, write it with gstack-pregate init, edit each TODO, then commit it on the base branch (from then on an unmapped file is PREGATE_NO_LANE)`
|
||||
: `PREGATE_STARTER: ${ctx.configLabel} declares no lanes (no dependencies, empty_lanes, selection or local key); a starter drafted from this repository follows. Review it, add its dependencies to ${ctx.configFile}${ctx.configKind === 'user' ? '' : ' on the base branch'} and edit each TODO (from then on an unmapped file is PREGATE_NO_LANE)`,
|
||||
`PREGATE_STARTER: runner ${runner ?? 'null (none detected; set runner with {files})'}`,
|
||||
`PREGATE_STARTER: test directories: ${dirs.join(', ') || '(no test files found)'}${dirCounts.size > MAX_DIRS ? ` (+${dirCounts.size - MAX_DIRS} more)` : ''}`,
|
||||
`PREGATE_STARTER: CI lanes: ${lanes.join(', ') || '(no test job in .github/workflows)'}`,
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
import type { PregateCheck } from '../headless-artifacts';
|
||||
import type { Registry } from '../regen';
|
||||
import type { RunSource } from '../run-evidence';
|
||||
import type { PregateConfig } from './config';
|
||||
import type { Lane } from './workflows';
|
||||
|
||||
@@ -35,6 +36,8 @@ export interface PregateContext {
|
||||
configLabel: string;
|
||||
/** Where the policy came from: the base branch, the unreviewed head, the user-owned file, or nowhere (defaults). */
|
||||
configKind: 'base' | 'head' | 'user' | 'none';
|
||||
/** The active policy has a `dependencies`, `empty_lanes`, `selection` or `local` key (config LANE_KEYS); false keeps the no-policy lane verdicts. Absent counts as declared. */
|
||||
policyDeclaresLanes?: boolean;
|
||||
/** This run's scratch directory under the state root (lib/pregate/run-scratch.ts): logs and scan files, never os.tmpdir(). */
|
||||
scratch: string;
|
||||
/** The policy file to edit: the user-owned path when it is the active policy, else `.gstack/pregate.json`. */
|
||||
@@ -59,6 +62,8 @@ export interface PregateContext {
|
||||
cores: number;
|
||||
testBackend: string;
|
||||
policyFrom: 'base' | 'head';
|
||||
/** `--from-run`: a Free Tests run whose tree.json files count as passed for this tree (lanes, lib/run-evidence.ts verifyFromRun), with the base branch's run check or why it could not be built. */
|
||||
fromRun?: { ref: string; check: RunSource | { error: string } };
|
||||
/** `--remote`: run the declared remote gate now, under this spend cap. */
|
||||
remote: { spendCapUsd: number } | null;
|
||||
env: NodeJS.ProcessEnv;
|
||||
|
||||
@@ -17,7 +17,12 @@ export const BUDGET_RESULT_CODES = {
|
||||
BUDGET_READING_MISSING: {
|
||||
anchor: 'budget-reading-missing',
|
||||
summary: 'the Capy meter needs a fresh counter reading and none was passed',
|
||||
fix: 'read cost.tokens from tools.thread.get({ thread_id: "<your own jam id>" }) (your own jam, its subagents included, never the root thread) inside the execute program that runs the command and pass it with --reading <n> (ship/sections/budget-handoff.md has the recipe)',
|
||||
fix: 'read cost.tokens from tools.thread.get({ thread_id: "<your own jam id>" }) (your own jam, its subagents included; a subagent that can read only the root thread starts with --subagent --reading-from this --meter-scope tree) inside the execute program that runs the command and pass it with --reading <n> (ship/sections/budget-handoff.md has the recipe)',
|
||||
},
|
||||
METER_SCOPE_PARENT: {
|
||||
anchor: 'meter-scope-parent',
|
||||
summary: 'a subagent asked for a self-scoped run while its reading comes from "this", the root thread\'s counter, which also holds every sibling lane',
|
||||
fix: 'start with --meter-scope tree (every cost line then prints METER: tree (root counter, upper bound)); exact per-run cost comes from a root replay thread',
|
||||
},
|
||||
BUDGET_READING_REJECTED: {
|
||||
anchor: 'budget-reading-rejected',
|
||||
|
||||
@@ -303,8 +303,8 @@ export const RELEASE_RESULT_CODES = {
|
||||
// Lane D (GSTA-112): the no-policy warning and the environment-cause verdict.
|
||||
PREGATE_NO_POLICY: {
|
||||
anchor: 'pregate-no-policy',
|
||||
summary: 'the repository has no pregate policy yet (no .gstack/pregate.json on the base branch and no user-owned file) and a touched file maps to no test lane; a warn, with a starter policy printed',
|
||||
fix: 'review the printed starter, write it with `gstack-pregate init`, edit the TODO entries, then commit .gstack/pregate.json on the base branch; from then on an unmapped file is PREGATE_NO_LANE',
|
||||
summary: 'the repository has no pregate policy yet (no .gstack/pregate.json on the base branch and no user-owned file), or its policy declares no lanes (no dependencies, empty_lanes, selection or local key), and a touched file maps to no test lane; a warn, with a starter policy printed',
|
||||
fix: 'review the printed starter, write it with `gstack-pregate init` (or add its dependencies to the existing policy), edit the TODO entries, then commit .gstack/pregate.json on the base branch; from then on an unmapped file is PREGATE_NO_LANE',
|
||||
},
|
||||
PREGATE_ENVIRONMENT: {
|
||||
anchor: 'pregate-environment',
|
||||
@@ -383,7 +383,7 @@ export const RELEASE_RESULT_CODES = {
|
||||
RECEIPT_CARRY_PRODUCT_CHANGED: {
|
||||
anchor: 'receipt-carry-product-changed',
|
||||
summary: 'the product diff against the base changed between the receipt\'s head and HEAD (a product file differs beyond a base merge, a test-only fix, release metadata or a regenerated output), so the reviewed verdict does not cover HEAD',
|
||||
fix: 'run /ship on the new head; the receipt carries only when every changed path is a test, release metadata, a registered output the pregate regen check proved, or the base merge, and the product patch-id is unchanged',
|
||||
fix: 'run /ship on the new head; the receipt carries only when every changed path is a test, release metadata, a registered output the pregate regen check proved, or the base merge, and the product patch-id is unchanged; base context next to unchanged reviewed hunks is RECEIPT_CARRY_SCOPED_REVIEW instead (one scoped fix-check, no /ship)',
|
||||
},
|
||||
RECEIPT_CARRY_SCOPED_REVIEW: {
|
||||
anchor: 'receipt-carry-scoped-review',
|
||||
|
||||
@@ -167,6 +167,11 @@ export const RESULT_CODES = {
|
||||
summary: 'an argument given to .\\setup.ps1 is an empty string, which its Git Bash hand-off cannot carry, so nothing ran',
|
||||
fix: 'drop the empty argument or give it a value, then run .\\setup.ps1 again',
|
||||
},
|
||||
SETUP_DEPS_MIGRATE_FAILED: {
|
||||
anchor: 'setup-deps-migrate-failed',
|
||||
summary: 'setup tried to move an install made before v1.91.231.0 to production-only dependencies, the production install failed, and setup put the full install back',
|
||||
fix: 'read the cause on the warning line (offline, proxy, disk full), fix it, then re-run ./setup; the full install keeps working meanwhile',
|
||||
},
|
||||
// C-stamp (plan C1, C2, C3, C5, C7): ship policy, restamp, tree receipt, ship receipt.
|
||||
POLICY_SOURCE_UNAVAILABLE: {
|
||||
anchor: 'policy-source-unavailable',
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* retro-friction — the "Friction by skill" numbers /retro shows, computed from
|
||||
* two per-project logs so the retro prompt reads finished lines instead of
|
||||
* aggregating JSONL itself.
|
||||
*
|
||||
* projects/<slug>/timeline.jsonl gstack-skill-start / gstack-skill-end,
|
||||
* written on every run: runs (started)
|
||||
* and explicit error / abort outcomes
|
||||
* projects/<slug>/question-log.jsonl gstack-question-log (question tuning
|
||||
* on): questions a person answered
|
||||
*
|
||||
* Only explicit outcomes count: the stop hook's `unknown` completions and
|
||||
* unmatched starts are not aborts. Machine answers (auto-decided, spawned-env
|
||||
* denies, Codex imports) are not questions. The Claude Code question hook logs
|
||||
* skill `unknown` (it cannot tell which skill asked), so those rows are not
|
||||
* counted; the agent's own rows carry the skill. Per skill and session,
|
||||
* hook-captured rows that do name a skill win over the agent's rows for the
|
||||
* same questions, so one question counts once. Skill names outside ^[a-z0-9-]{1,64}$ are dropped so log text
|
||||
* never reaches the prompt. Idea credit: @thesohamdatta (#3417).
|
||||
*
|
||||
* CLI (bin/gstack-retro-metrics):
|
||||
* --timeline <file> --questions <file> --since-epoch <s> [--until-epoch <s>]
|
||||
* prints FRICTION_SOURCES: and FRICTION: lines.
|
||||
*/
|
||||
import * as fs from 'node:fs';
|
||||
|
||||
export interface SkillFriction {
|
||||
skill: string;
|
||||
runs: number;
|
||||
questions: number;
|
||||
typed: number;
|
||||
errors: number;
|
||||
aborts: number;
|
||||
score: number;
|
||||
}
|
||||
|
||||
export interface FrictionInput {
|
||||
timelineLines: string[];
|
||||
questionLines: string[];
|
||||
sinceMs: number;
|
||||
untilMs?: number;
|
||||
}
|
||||
|
||||
const SKILL_RE = /^[a-z0-9-]{1,64}$/;
|
||||
const HUMAN_SOURCES = new Set(['agent', 'hook', 'auq-other']);
|
||||
export const FRICTION_TOP = 3;
|
||||
|
||||
function parse(line: string): Record<string, unknown> | null {
|
||||
if (!line.trim()) return null;
|
||||
try {
|
||||
const j = JSON.parse(line);
|
||||
return j && typeof j === 'object' && !Array.isArray(j) ? j : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function skillOf(j: Record<string, unknown>): string | null {
|
||||
if (typeof j.skill !== 'string') return null;
|
||||
const s = j.skill.replace(/^\//, '').toLowerCase();
|
||||
return SKILL_RE.test(s) ? s : null;
|
||||
}
|
||||
|
||||
function inWindow(j: Record<string, unknown>, sinceMs: number, untilMs?: number): boolean {
|
||||
if (typeof j.ts !== 'string') return false;
|
||||
const t = Date.parse(j.ts);
|
||||
if (Number.isNaN(t) || t < sinceMs) return false;
|
||||
return untilMs === undefined || t <= untilMs;
|
||||
}
|
||||
|
||||
export function frictionBySkill(input: FrictionInput): SkillFriction[] {
|
||||
const bySkill = new Map<string, SkillFriction>();
|
||||
const entry = (skill: string): SkillFriction => {
|
||||
let e = bySkill.get(skill);
|
||||
if (!e) {
|
||||
e = { skill, runs: 0, questions: 0, typed: 0, errors: 0, aborts: 0, score: 0 };
|
||||
bySkill.set(skill, e);
|
||||
}
|
||||
return e;
|
||||
};
|
||||
|
||||
for (const line of input.timelineLines) {
|
||||
const j = parse(line);
|
||||
if (!j || !inWindow(j, input.sinceMs, input.untilMs)) continue;
|
||||
const skill = skillOf(j);
|
||||
if (!skill) continue;
|
||||
if (j.event === 'started') entry(skill).runs++;
|
||||
else if (j.event === 'completed' && j.outcome === 'error') entry(skill).errors++;
|
||||
else if (j.event === 'completed' && j.outcome === 'abort') entry(skill).aborts++;
|
||||
}
|
||||
|
||||
const groups = new Map<string, { skill: string; hook: Record<string, unknown>[]; agent: Record<string, unknown>[] }>();
|
||||
const seenToolUse = new Set<string>();
|
||||
for (const line of input.questionLines) {
|
||||
const j = parse(line);
|
||||
if (!j || !inWindow(j, input.sinceMs, input.untilMs)) continue;
|
||||
const skill = skillOf(j);
|
||||
const source = typeof j.source === 'string' ? j.source : 'agent';
|
||||
if (!skill || skill === 'unknown' || !HUMAN_SOURCES.has(source)) continue;
|
||||
const toolUse = typeof j.tool_use_id === 'string' && j.tool_use_id ? j.tool_use_id : '';
|
||||
if (toolUse) {
|
||||
const key = `${source}:${toolUse}`;
|
||||
if (seenToolUse.has(key)) continue;
|
||||
seenToolUse.add(key);
|
||||
}
|
||||
const session = typeof j.session_id === 'string' ? j.session_id : '';
|
||||
const gkey = `${skill}\u0000${session}`;
|
||||
let g = groups.get(gkey);
|
||||
if (!g) {
|
||||
g = { skill, hook: [], agent: [] };
|
||||
groups.set(gkey, g);
|
||||
}
|
||||
const hookCaptured = source === 'hook' || (source === 'auq-other' && toolUse !== '');
|
||||
(hookCaptured ? g.hook : g.agent).push(j);
|
||||
}
|
||||
for (const g of groups.values()) {
|
||||
const rows = g.hook.length > 0 ? g.hook : g.agent;
|
||||
const e = entry(g.skill);
|
||||
e.questions += rows.length;
|
||||
e.typed += rows.filter((r) => r.source === 'auq-other').length;
|
||||
}
|
||||
|
||||
for (const e of bySkill.values()) e.score = e.questions + e.errors + e.aborts;
|
||||
return [...bySkill.values()]
|
||||
.filter((e) => e.score > 0)
|
||||
.sort((a, b) => b.score - a.score || (a.skill < b.skill ? -1 : a.skill > b.skill ? 1 : 0));
|
||||
}
|
||||
|
||||
export function formatFriction(
|
||||
rows: SkillFriction[],
|
||||
sources: { timeline: boolean; questions: boolean },
|
||||
): string[] {
|
||||
const out = [
|
||||
`FRICTION_SOURCES: timeline=${sources.timeline ? 'present' : 'absent'} questions=${sources.questions ? 'present' : 'absent'}`,
|
||||
];
|
||||
if (rows.length === 0) return [...out, 'FRICTION: none'];
|
||||
for (const e of rows.slice(0, FRICTION_TOP)) {
|
||||
out.push(
|
||||
`FRICTION: /${e.skill} score=${e.score} runs=${e.runs} questions=${e.questions} typed=${e.typed} errors=${e.errors} aborts=${e.aborts}`,
|
||||
);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function readLines(file: string | undefined): string[] | null {
|
||||
if (!file || !fs.existsSync(file)) return null;
|
||||
return fs.readFileSync(file, 'utf-8').split('\n');
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
const args = process.argv.slice(2);
|
||||
const arg = (name: string): string | undefined => {
|
||||
const i = args.indexOf(name);
|
||||
return i >= 0 ? args[i + 1] : undefined;
|
||||
};
|
||||
const since = Number(arg('--since-epoch'));
|
||||
const untilRaw = arg('--until-epoch');
|
||||
const until = untilRaw === undefined || untilRaw === '' ? undefined : Number(untilRaw);
|
||||
if (!Number.isFinite(since) || (until !== undefined && !Number.isFinite(until))) {
|
||||
console.log('FRICTION: unavailable (window bounds did not resolve)');
|
||||
process.exit(0);
|
||||
}
|
||||
const timeline = readLines(arg('--timeline'));
|
||||
const questions = readLines(arg('--questions'));
|
||||
const rows = frictionBySkill({
|
||||
timelineLines: timeline ?? [],
|
||||
questionLines: questions ?? [],
|
||||
sinceMs: since * 1000,
|
||||
untilMs: until === undefined ? undefined : until * 1000,
|
||||
});
|
||||
for (const line of formatFriction(rows, { timeline: timeline !== null, questions: questions !== null })) console.log(line);
|
||||
}
|
||||
+13
-4
@@ -15,7 +15,7 @@
|
||||
* targeted classes, brief (review/specialists/targeted.md's text), checklist (its path), diff_base, stack, test_fw, learnings
|
||||
*/
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { renderFixCheckPrompt } from '../scripts/resolvers/fix-check';
|
||||
@@ -83,12 +83,21 @@ const SPECIALIST_DIFF = 'run\n`DIFF_BASE=<diff-base> && git diff "$DIFF_BASE" <e
|
||||
|
||||
const PLACEHOLDERS = ['<diff-base>', '<exclude>', '<base>', '{checklist path}', '{STACK}', '{TEST_FW}', '{merged findings summary}', '{red-team checklist path}', '{learnings'];
|
||||
|
||||
/** Render one reader's brief. Throws BriefError on a missing slot, a section that no longer carries an anchor or placeholder, or a placeholder left unfilled. */
|
||||
/**
|
||||
* Render one reader's brief. Throws BriefError on a missing slot, a section that no longer carries an anchor or placeholder, or a placeholder left unfilled.
|
||||
* Findings and learnings are reviewer text that may quote a slot name such as origin/<base> (GST-797): they render as a token, so the
|
||||
* placeholder check and the slot fills never see them, and go in last, trimmed as renderRole trims them. The token carries a
|
||||
* per-render nonce, so no other slot's text (a diff, a hunk) can name it.
|
||||
*/
|
||||
export function renderBrief(role: BriefRole, slots: Slots, t: SectionTexts): string {
|
||||
const text = renderRole(role, slots, t);
|
||||
const nonce = randomUUID();
|
||||
const keys = ['findings', 'learnings'].filter(k => slots[k]?.trim());
|
||||
const held: Slots = { ...slots };
|
||||
for (const k of keys) held[k] = `\u0000${nonce}:${k}\u0000`;
|
||||
const text = renderRole(role, held, t);
|
||||
const left = role === 'fix-check' || role === 'outside-voice' ? [] : PLACEHOLDERS.filter(p => text.includes(p));
|
||||
if (left.length) throw new BriefError(`${role}: unfilled placeholder ${left.join(', ')}`);
|
||||
return text;
|
||||
return keys.reduce((out, k) => out.split(held[k]!).join(slots[k]!.trim()), text);
|
||||
}
|
||||
|
||||
function renderRole(role: BriefRole, slots: Slots, t: SectionTexts): string {
|
||||
|
||||
+21
-5
@@ -2,7 +2,7 @@ import { createHash } from 'node:crypto';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { deriveReviewStatus, readJsonl } from './headless-artifacts';
|
||||
import { deriveReviewStatus, isCriticalFinding, isOpenByAction, readJsonl } from './headless-artifacts';
|
||||
import { resultLine, type ResultCodeName } from './result-codes';
|
||||
|
||||
const DIFF_REVIEWS = new Set(['review', 'review-fix-check', 'adversarial-review', 'codex-review', 'design-review-lite', 'ship']);
|
||||
@@ -248,11 +248,12 @@ const DERIVED_KEYS = ['status', 'unresolved', 'issues_found', 'critical_gaps'] a
|
||||
* severity + disposition/action). A claimed value that disagrees rejects the
|
||||
* row with REVIEW_STATUS_MISMATCH; the file is the record, never the claim.
|
||||
*/
|
||||
export function deriveFromFindings(rec: Record<string, any>, findingsPath: string): Record<string, any> {
|
||||
export function deriveFromFindings(rec: Record<string, any>, findingsPath: string, sessionKind?: string): Record<string, any> {
|
||||
let read;
|
||||
try { read = readJsonl(findingsPath); }
|
||||
catch (e: any) { throw new ReviewLogError('ARTIFACT_MISSING', resultLine('review-log', 'ARTIFACT_MISSING', `${findingsPath}: ${e.message}`)); }
|
||||
if (read.errors.length) throw new ReviewLogError('ARTIFACT_MALFORMED_JSONL', resultLine('review-log', 'ARTIFACT_MALFORMED_JSONL', `${read.errors[0]!.path}: ${read.errors[0]!.message}`));
|
||||
stampGateFindings(read.rows, sessionKind);
|
||||
const derived = deriveReviewStatus(read.rows);
|
||||
const mismatches = DERIVED_KEYS.filter(key => rec[key] !== undefined && rec[key] !== derived[key])
|
||||
.map(key => `claimed ${key} ${JSON.stringify(rec[key])}, findings file has ${JSON.stringify(derived[key])}`);
|
||||
@@ -266,11 +267,20 @@ export function bindReview(rec: Record<string, any>, token: string, env = proces
|
||||
for (const key of ['commit_full', 'tree', 'wtree', 'dirty', 'review_binding', 'review_freshness', 'shared_libs_coverage_version', 'status_source', 'findings_total', 'findings_open', 'findings_resolved', 'findings_file', 'scope', 'fix_base']) delete rec[key];
|
||||
// The findings file is validated BEFORE the start token is consumed below:
|
||||
// a rejected row must leave the token for the retry.
|
||||
rec = findingsPath ? deriveFromFindings(rec, findingsPath) : { ...rec, status_source: 'claimed' };
|
||||
rec = findingsPath ? deriveFromFindings(rec, findingsPath, env.GSTACK_STAMP_SESSION_KIND) : { ...rec, status_source: 'claimed' };
|
||||
if (env.GSTACK_STAMP_COMMIT_FULL) rec.commit_full = env.GSTACK_STAMP_COMMIT_FULL;
|
||||
if (env.GSTACK_STAMP_TREE) rec.tree = env.GSTACK_STAMP_TREE;
|
||||
if (env.GSTACK_STAMP_DIRTY) rec.dirty = env.GSTACK_STAMP_DIRTY === 'true';
|
||||
stampGateFindings(rec.findings, env.GSTACK_STAMP_SESSION_KIND);
|
||||
// A /ship pass whose findings were all fixed, or deferred or noted below the floor, left no open defect (review-army
|
||||
// item 6), so a claimed issues_found is recorded clean with zero counts and Step 11.5 can bind it (GST-430). Only the
|
||||
// logger-stamped action counts (never a caller's status), and the findings must account for every claimed issue.
|
||||
if (!findingsPath && (rec.skill === 'review' || rec.skill === 'review-fix-check') && rec.status === 'issues_found' && rec.completed === true &&
|
||||
Array.isArray(rec.findings) && rec.findings.length > 0 && rec.findings.length >= Number(rec.issues_found ?? 0) &&
|
||||
rec.findings.filter(f => record(f) && isCriticalFinding(f)).length >= Number(rec.critical ?? 0) &&
|
||||
rec.findings.every(f => record(f) && typeof f.action === 'string' && !isOpenByAction(f))) {
|
||||
Object.assign(rec, { status: 'clean', issues_found: 0, critical: 0, informational: 0 });
|
||||
}
|
||||
if (!DIFF_REVIEWS.has(rec.skill)) {
|
||||
if (env.GSTACK_STAMP_WTREE) rec.wtree = env.GSTACK_STAMP_WTREE;
|
||||
return rec;
|
||||
@@ -326,7 +336,10 @@ export function reviewFreshness(rec: Record<string, any>, currentWtree: string):
|
||||
if (!DIFF_REVIEWS.has(rec.skill)) return;
|
||||
if (rec.skill === 'ship') return { status: 'UNVERIFIED', reason: 'ship telemetry is not a review pass' };
|
||||
const binding = rec.review_binding;
|
||||
if (binding?.state === 'changed') return { status: 'STALE', reason: 'content changed during review' };
|
||||
if (binding?.state === 'changed') {
|
||||
const short = (w: unknown) => typeof w === 'string' ? w.slice(0, 12) : 'unknown';
|
||||
return { status: 'STALE', reason: `content changed during review: the tree moved from ${short(binding.start_wtree)} to ${short(binding.end_wtree)} while ${rec.skill} ran (an edit, commit or merge landed before its row was logged), so the record cannot bind. Repair: freeze the checkout until every reviewer returns, then rerun the steps 11.5 names on the frozen tree; when the edit was your own fix, log it as a fix so gstack-review-plan picks the repeat depth (docs/troubleshooting.md#review-content-changed)` };
|
||||
}
|
||||
if (rec.scope === 'fix-range') return { status: 'UNVERIFIED', reason: 'fix-range pass; only a later full pass can clear review' };
|
||||
if (binding?.state !== 'verified' || rec.completed !== true || rec.converged !== true ||
|
||||
!rec.wtree || binding.start_wtree !== rec.wtree || binding.end_wtree !== rec.wtree) {
|
||||
@@ -340,7 +353,10 @@ export function reviewFreshness(rec: Record<string, any>, currentWtree: string):
|
||||
// issues_found, and the legacy `critical` and Codex counters stay as they were.
|
||||
const open = typeof rec.findings_open === 'number' ? rec.findings_open : rec.issues_found;
|
||||
if (rec.status !== 'clean' || open > 0 || rec.critical > 0 ||
|
||||
(rec.skill === 'codex-review' && rec.findings > (rec.findings_fixed ?? 0))) {
|
||||
(rec.skill === 'codex-review' && rec.findings > (rec.findings_fixed ?? 0)) ||
|
||||
(Array.isArray(rec.findings) && rec.findings.some(f => record(f) && (f.action === 'open' ||
|
||||
((f.action === 'deferred' || f.action === 'noted') && (f.blocking === true || isCriticalFinding(f))) ||
|
||||
(!f.gate && isCriticalFinding(f) && isOpenByAction(f)))))) {
|
||||
return { status: 'UNVERIFIED', reason: 'review has unresolved findings or did not finish clean' };
|
||||
}
|
||||
return { status: 'CURRENT', reason: 'completed clean pass on unchanged content' };
|
||||
|
||||
+16
-2
@@ -64,7 +64,18 @@ const LOCUS = /[\w./-]+\.\w+:\d+/;
|
||||
/** A line that is only a finding's `Severity` or `Blocks` field ("Severity: High", "- Blocks: yes", "Severity High"). */
|
||||
const META = new RegExp(`^(?:[-*+]\\s+)?\\**\\s*(Severity|Blocks)\\**\\s*(?::|\\s+\\**(?:${LABELS}|yes|no)(?![A-Za-z0-9]))`, 'i');
|
||||
|
||||
export interface MergedLine { id: string; reader: string; label: string; raw: string; output_sha256: string }
|
||||
/** The severity and Blocks a prose finding's own fields state (GST-766): recorded beside the collected label, never in its place. */
|
||||
export interface Stated { severity?: string; blocks?: string }
|
||||
const STATED_SEVERITY = new RegExp(`(?<![A-Za-z0-9])Severity\\**\\s*:?\\s*\\**\\s*(${LABELS})(?![A-Za-z0-9])`, 'gi');
|
||||
const STATED_BLOCKS = /(?<![A-Za-z0-9])Blocks\**\s*:?\s*\**\s*(yes|no)(?![A-Za-z0-9])/gi;
|
||||
function stated(raw: string): Stated | undefined {
|
||||
if (raw.startsWith('{')) return undefined;
|
||||
const severity = [...raw.matchAll(STATED_SEVERITY)].at(-1)?.[1];
|
||||
const blocks = [...raw.matchAll(STATED_BLOCKS)].at(-1)?.[1]?.toLowerCase();
|
||||
return severity || blocks ? { ...(severity ? { severity } : {}), ...(blocks ? { blocks } : {}) } : undefined;
|
||||
}
|
||||
|
||||
export interface MergedLine { id: string; reader: string; label: string; stated?: Stated; raw: string; output_sha256: string }
|
||||
|
||||
/**
|
||||
* One reader's findings as written: a JSON object per line (severity kept as written); else each prose line carrying a severity or
|
||||
@@ -164,7 +175,10 @@ export function collect(ledger: string, reader: string, output: string, mergedFi
|
||||
const sha = promptHash(output);
|
||||
const prior = readLedger(ledger).filter(e => e.event === 'output' && e.reader === reader).length;
|
||||
const wrongTree = !unavailable && tree !== undefined && !tree.ok;
|
||||
const lines = unavailable || wrongTree ? [] : parseFindings(reader, output).map((f, i) => ({ id: `${reader}#${prior ? `${prior + 1}.` : ''}${i + 1}`, reader, label: f.label, raw: f.raw, output_sha256: sha }));
|
||||
const lines = unavailable || wrongTree ? [] : parseFindings(reader, output).map((f, i) => {
|
||||
const s = stated(f.raw);
|
||||
return { id: `${reader}#${prior ? `${prior + 1}.` : ''}${i + 1}`, reader, label: f.label, ...(s ? { stated: s } : {}), raw: f.raw, output_sha256: sha };
|
||||
});
|
||||
const review = !unavailable && !wrongTree && (lines.length > 0 || hasCleanMarker(reader, output));
|
||||
fs.appendFileSync(mergedFile, lines.map(l => JSON.stringify(l) + '\n').join(''), { mode: 0o600 });
|
||||
appendEvent(ledger, { event: 'output', reader, sha256: sha, file: outputFile ? path.resolve(outputFile) : null, merged: path.resolve(mergedFile), ids: lines.map(l => l.id), review, ...(unavailable ? { unavailable } : {}),
|
||||
|
||||
+16
-4
@@ -39,8 +39,13 @@ export interface Manifest {
|
||||
version: 1; pass: string; out: string; ledger: string; merged: string; base: string; diff_base: string;
|
||||
stack: string; test_fw: string; learnings: string | null; checklist_root: string;
|
||||
next: string; plan_lines: string[]; readers: PassReader[]; before?: { tokens: number; credits: number };
|
||||
/** Step 11's own reading at its first dispatch, so its note never counts the pass's readers again. */
|
||||
before_voices?: { tokens: number; credits: number };
|
||||
log_starts?: Record<string, string>;
|
||||
/** Decision 3.5's files for a fix-check reader: the fix list, touched hunks, test results and callers. */
|
||||
fix_check?: FixCheckFiles;
|
||||
}
|
||||
export interface FixCheckFiles { fix_list: string; hunks: string; tests: string; callers?: string }
|
||||
export interface Extra { name: string; prompt_file: string; first?: boolean; model?: PassModel }
|
||||
|
||||
/** Brief characters per transport chunk: even with every character escaped, one chunk line stays far under 1 MiB. */
|
||||
@@ -68,18 +73,19 @@ export function detectTestFw(root: string): string {
|
||||
}
|
||||
|
||||
const safe = (reader: string) => reader.replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||
const STAGE_OF: Record<string, Stage> = { specialist: 'specialists', 'red-team': 'red-team', 'adversarial-native': 'voices', 'outside-voice': 'voices', targeted: 'voices' };
|
||||
const STAGE_OF: Record<string, Stage> = { specialist: 'specialists', 'fix-check': 'specialists', 'red-team': 'red-team', 'adversarial-native': 'voices', 'outside-voice': 'voices', targeted: 'voices' };
|
||||
|
||||
/** The manifest for one pass from the plan's dispatches. A fix-check is not part of a pass, so it is refused. */
|
||||
/** The manifest for one pass from the plan's dispatches. A fix-check reader needs decision 3.5's files. */
|
||||
export function buildManifest(o: {
|
||||
pass: string; out: string; ledger: string; base: string; diffBase: string; root: string; checklistRoot: string;
|
||||
dispatches: Dispatch[]; next: string; planLines: string[]; learnings?: string | null; extras?: Extra[];
|
||||
targetedClasses?: readonly string[]; mergeBase?: string; rangeBase?: string;
|
||||
targetedClasses?: readonly string[]; mergeBase?: string; rangeBase?: string; fixCheck?: FixCheckFiles;
|
||||
}): Manifest {
|
||||
const readers: PassReader[] = [];
|
||||
for (const d of o.dispatches) {
|
||||
const stage = STAGE_OF[d.role];
|
||||
if (!stage) throw new BriefError(`a pass dispatches no ${d.role} reader (fix-check runs on its own)`);
|
||||
if (!stage) throw new BriefError(`a pass dispatches no ${d.role} reader`);
|
||||
if (d.role === 'fix-check' && !o.fixCheck) throw new BriefError('the plan dispatches a fix-check: pass needs --fix-list, --hunks and --tests (decision 3.5\'s files; --callers optional)');
|
||||
const role: BriefRole = d.role === 'specialist' && d.checklists ? 'specialist-batched' : d.role as BriefRole;
|
||||
const name = role === 'specialist' ? d.name : undefined;
|
||||
const reader = readerKey(role, name);
|
||||
@@ -98,6 +104,7 @@ export function buildManifest(o: {
|
||||
version: 1, pass: o.pass, out, ledger: path.resolve(o.ledger), merged: path.join(out, 'merged.jsonl'), base: o.base, diff_base: o.diffBase,
|
||||
stack: detectStack(o.root), test_fw: detectTestFw(o.root), learnings: o.learnings ?? null, checklist_root: o.checklistRoot,
|
||||
next: o.next, plan_lines: o.planLines, readers,
|
||||
...(o.fixCheck ? { fix_check: Object.fromEntries(Object.entries(o.fixCheck).filter(([, v]) => v).map(([k, v]) => [k, path.resolve(v as string)])) as unknown as FixCheckFiles } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -155,6 +162,11 @@ export function renderStage(m: Manifest, stage: Stage, t: SectionTexts, diffFor:
|
||||
Object.assign(slots, { checklist: path.join(m.checklist_root, 'review/specialists/red-team.md'), findings, specialists: String(specialists) });
|
||||
}
|
||||
if (role === 'outside-voice') slots.diff = diffFor(m.diff_base);
|
||||
if (role === 'fix-check') {
|
||||
const read = (f: string | undefined, what: string) => { if (!f) return undefined; try { return fs.readFileSync(f, 'utf8'); } catch { throw new BriefError(`cannot read the fix-check ${what} ${f}`); } };
|
||||
const fc = m.fix_check!;
|
||||
Object.assign(slots, { fix_list: read(fc.fix_list, 'fix list'), hunks: read(fc.hunks, 'hunks'), tests: read(fc.tests, 'tests'), callers: read(fc.callers, 'callers') });
|
||||
}
|
||||
let targeted = {};
|
||||
if (role === 'targeted') {
|
||||
const checklist = path.join(m.checklist_root, 'review/specialists/targeted.md');
|
||||
|
||||
+4
-1
@@ -28,6 +28,8 @@ export const HUNK_CONTEXT_LINES = 3;
|
||||
export interface ScopeFinding {
|
||||
fingerprint?: string; path?: string; line?: number; severity?: string; category?: string; summary?: string;
|
||||
problem?: string; fix?: string; specialist?: string; advisory?: boolean; test_stub?: string;
|
||||
/** The severity and Blocks the reviewer's own text stated, from its merged.jsonl line (GST-766); shown, never used to classify. */
|
||||
stated?: { severity?: string; blocks?: string };
|
||||
/** The changed line that causes a defect reported elsewhere (a broken caller, a consumer of a removed export); the locus follows it. */
|
||||
cause?: { path?: string; line?: number };
|
||||
[key: string]: unknown;
|
||||
@@ -252,10 +254,11 @@ export function renderDeferredIssue(f: ScopedFinding, ctx: DeferContext): { titl
|
||||
const title = `Deferred review finding: ${findingWhere(f)} — ${text}`.slice(0, 120);
|
||||
const origin = ctx.pr ? `${ctx.repo ? `${ctx.repo}#${ctx.pr}` : `PR #${ctx.pr}`}` : ctx.branch ? `branch \`${ctx.branch}\`` : 'a /ship run';
|
||||
const marker = `<!-- ${DEFERRED_MARKER}: ${oneLine(findingIdentity(f))} -->`;
|
||||
const said = [typeof f.stated?.severity === 'string' ? `Severity: ${oneLine(f.stated.severity)}` : '', typeof f.stated?.blocks === 'string' ? `Blocks: ${oneLine(f.stated.blocks)}` : ''].filter(Boolean);
|
||||
const body = [
|
||||
`Filed by /ship's pre-landing review of ${origin}${ctx.head ? ` (head ${ctx.head.slice(0, 12)})` : ''}. The finding is in code that PR did not change, so it was deferred here instead of being fixed there (review scope frozen after pass 1).`,
|
||||
'',
|
||||
`- **Severity:** ${oneLine(f.severity ?? 'unrated')}`,
|
||||
`- **Severity:** ${oneLine(f.severity ?? 'unrated')}${said.length ? ` (the reviewer wrote ${said.join(', ')})` : ''}`,
|
||||
`- **Where:** \`${findingWhere(f)}\``,
|
||||
`- **Finding:** ${text}`,
|
||||
...(f.fix ? [`- **Suggested fix:** ${oneLine(f.fix)}`] : []),
|
||||
|
||||
+43
-5
@@ -22,7 +22,10 @@
|
||||
* not hide a failure in between); it finished within the profile's
|
||||
* max_age_hours (default 24); its runner OS matches when the profile names
|
||||
* one; and a second read of the run still shows the same successful attempt
|
||||
* (run_changed otherwise).
|
||||
* (run_changed otherwise). A pull_request run tested GitHub's merge commit,
|
||||
* not its head, so it counts only when its tree.json names that head and a
|
||||
* merge commit (sha) whose tree git computes equal to the head's tree: the
|
||||
* head already contained its base, so the merge tested the head tree.
|
||||
*
|
||||
* Profiles are the base branch's `.gstack/pregate.json` `run_profiles`, so a
|
||||
* pull request can tighten them but never relax them. The workflow a run
|
||||
@@ -65,6 +68,7 @@ export interface RunEvidence { run_id: number; run_attempt: number; head_sha: st
|
||||
export type RunVerdict = { ok: true; evidence: RunEvidence } | { ok: false; reason: RunRefusal; detail: string };
|
||||
|
||||
const SHA = /^[0-9a-f]{40}$/;
|
||||
const RUN_EVENTS = ['push', 'workflow_dispatch', 'pull_request'];
|
||||
const PROFILE_KEYS = ['workflow', 'events', 'jobs', 'artifact', 'max_age_hours', 'os'];
|
||||
const WORKFLOW_FILE = /^[A-Za-z0-9_.-]+\.ya?ml$/;
|
||||
const NAME = /^[A-Za-z0-9_.\/-]{1,128}$/;
|
||||
@@ -96,7 +100,7 @@ export function runProfileErrors(profiles: unknown, gateRuns: unknown): Array<{
|
||||
const v = p[k];
|
||||
if (!Array.isArray(v) || v.length === 0 || v.length > 32 || v.some(s => typeof s !== 'string' || !s.trim())) errors.push({ path: `${at}.${k}`, message: 'must be a non-empty array of up to 32 names' });
|
||||
}
|
||||
if (Array.isArray(p.events) && p.events.some(e => e !== 'push' && e !== 'workflow_dispatch')) errors.push({ path: `${at}.events`, message: 'events are push or workflow_dispatch (a pull_request run tests the merge commit, not the head tree)' });
|
||||
if (Array.isArray(p.events) && p.events.some(e => !RUN_EVENTS.includes(e))) errors.push({ path: `${at}.events`, message: 'events are push, workflow_dispatch or pull_request (a pull_request run counts only when its merge commit\'s tree is its head\'s tree)' });
|
||||
if (typeof p.artifact !== 'string' || !NAME.test(p.artifact)) errors.push({ path: `${at}.artifact`, message: 'must be the artifact name holding tree.json' });
|
||||
if ('max_age_hours' in p && (!Number.isInteger(p.max_age_hours) || (p.max_age_hours as number) < 1 || (p.max_age_hours as number) > 168)) errors.push({ path: `${at}.max_age_hours`, message: 'must be an integer from 1 to 168' });
|
||||
if ('os' in p && (typeof p.os !== 'string' || !/^(Linux|macOS|Windows)$/.test(p.os))) errors.push({ path: `${at}.os`, message: 'must be Linux, macOS or Windows' });
|
||||
@@ -164,13 +168,21 @@ export function verifyRun(deps: RunDeps, o: { repo: string; runRef: string; prof
|
||||
catch (e: any) { return fail('artifact_missing', `${at} has no readable ${p.artifact} tree.json: ${String(e?.message ?? e).slice(0, 200)}`); }
|
||||
if (!rec || typeof rec !== 'object') return fail('artifact_mismatch', `${at}'s ${p.artifact} tree.json is not a JSON object`);
|
||||
if (rec.reused === true || rec.reuse === true) return fail('artifact_reused', `${at} reused another run; a reuse is never evidence`);
|
||||
const recHeads = rec.head_sha === undefined && rec.sha === undefined ? [['head', undefined, run.head_sha] as const] : ([['sha', rec.sha, run.head_sha], ['head_sha', rec.head_sha, run.head_sha]] as const).filter(([, got]) => got !== undefined);
|
||||
const pr = run.event === 'pull_request';
|
||||
const recHeads = pr ? [['head_sha', rec.head_sha, run.head_sha] as const] : rec.head_sha === undefined && rec.sha === undefined ? [['head', undefined, run.head_sha] as const] : ([['sha', rec.sha, run.head_sha], ['head_sha', rec.head_sha, run.head_sha]] as const).filter(([, got]) => got !== undefined);
|
||||
const wrong = ([['run_id', rec.run_id, id], ['run_attempt', rec.run_attempt, run.run_attempt], ['event', rec.event, run.event], ...recHeads] as const).filter(([, got, want]) => got !== want);
|
||||
if (wrong.length) return fail('artifact_mismatch', `${at}'s ${p.artifact} records ${wrong.map(([k, got, want]) => `${k} ${String(got)} (expected ${want})`).join(', ')}`);
|
||||
const headTree = deps.commitTree(run.head_sha);
|
||||
if (!headTree) return fail('api_error', `git cannot read the tree of ${run.head_sha}`);
|
||||
if (rec.tree !== headTree) return fail('tree_mismatch', `${at} recorded tree ${String(rec.tree)}, but its head ${run.head_sha.slice(0, 12)} has tree ${headTree}`);
|
||||
if (p.os && rec.os !== p.os) return fail('os_mismatch', `${at} ran on ${String(rec.os ?? 'an unrecorded OS')}; ${o.profileName} requires ${p.os}`);
|
||||
if (pr) {
|
||||
if (typeof rec.sha !== 'string' || !SHA.test(rec.sha)) return fail('artifact_mismatch', `${at}'s ${p.artifact} names no merge commit (sha) for its pull_request run`);
|
||||
const mergeTree = deps.commitTree(rec.sha);
|
||||
if (!mergeTree) return fail('api_error', `git cannot read the tree of merge commit ${rec.sha}`);
|
||||
if (mergeTree !== headTree) return fail('tree_mismatch', `${at} tested merge commit ${rec.sha.slice(0, 12)} (tree ${mergeTree}), not its head ${run.head_sha.slice(0, 12)}'s tree ${headTree}: the pull request was behind its base`);
|
||||
}
|
||||
const oses = Array.isArray(rec.os) ? rec.os : [rec.os];
|
||||
if (p.os && !oses.includes(p.os)) return fail('os_mismatch', `${at} ran on ${oses.filter(Boolean).map(String).join(', ') || 'an unrecorded OS'}; ${o.profileName} requires ${p.os}`);
|
||||
if (rec.files !== undefined && (!Array.isArray(rec.files) || rec.files.some(f => typeof f !== 'string'))) return fail('artifact_mismatch', `${at}'s ${p.artifact} files is not a list of paths`);
|
||||
if (rec.tests !== undefined && (!Number.isInteger(rec.tests) || (rec.tests as number) < 0)) return fail('artifact_mismatch', `${at}'s ${p.artifact} tests is not a non-negative integer`);
|
||||
const files = ((rec.files ?? []) as string[]).map(normalPath);
|
||||
@@ -202,6 +214,29 @@ export function uncoveredFiles(inventory: string[], evidence: RunEvidence[]): st
|
||||
return inventory.filter(f => !ran.has(normalPath(f)));
|
||||
}
|
||||
|
||||
/** What a run check reads: the repository, the verifier's dependencies, the base branch's profiles and the clock. */
|
||||
export interface RunSource { repo: string; deps: RunDeps; profiles: Record<string, RunProfile>; now: Date }
|
||||
export type FromRunVerdict = { ok: true; evidence: RunEvidence; receipt: string } | { ok: false; reason: string; detail: string };
|
||||
|
||||
/**
|
||||
* `gstack-pregate --stage tests --from-run <run>`: the run as evidence for `tree` under the first profile in `names`
|
||||
* it verifies against (the base branch's profiles for this machine's OS whose workflow holds a local lane), with `head` as a further veto
|
||||
* head. The run must have tested exactly this tree (no stamp-only substitution) and list the files it ran.
|
||||
*/
|
||||
export function verifyFromRun(src: RunSource, ref: string, names: string[], tree: string, head: string): FromRunVerdict {
|
||||
if (!names.length) return { ok: false, reason: 'no_profile', detail: 'the base branch declares no run_profiles entry for this machine\'s OS whose workflow holds a lane this machine runs (gstack\'s ci-reuse/free-tests, for example)' };
|
||||
const refusals: Array<{ reason: string; detail: string }> = [];
|
||||
for (const name of names) {
|
||||
const v = verifyRun(src.deps, { repo: src.repo, runRef: ref, profileName: name, profile: src.profiles[name]!, vetoHeads: SHA.test(head) ? [head] : [], now: src.now });
|
||||
if (!v.ok) { refusals.push({ reason: v.reason, detail: `${name}: ${v.detail}` }); continue; }
|
||||
const e = v.evidence;
|
||||
if (e.tree !== tree) return { ok: false, reason: 'tree_mismatch', detail: `run ${e.run_id} tested tree ${e.tree.slice(0, 12)}, not this tree ${tree.slice(0, 12)}` };
|
||||
if (!e.files.length || e.tests === 0) return { ok: false, reason: 'no_tests', detail: `run ${e.run_id}'s tree.json ${e.files.length ? `records 0 tests for ${e.files.length} file(s)` : 'lists no test files'}` };
|
||||
return { ok: true, evidence: e, receipt: `run tree=${tree.slice(0, 12)} tests=${e.tests ?? e.files.length} ${runReceiptFields([e], src.now)}` };
|
||||
}
|
||||
return refusals.length === 1 ? { ok: false, ...refusals[0]! } : { ok: false, reason: 'no_verified_run', detail: refusals.map(r => `${r.detail} (${r.reason})`).join(' | ') };
|
||||
}
|
||||
|
||||
/** The receipt suffix a run clear records: runs, profiles and the verification time, read back by `parseRunReceipt`. */
|
||||
export function runReceiptFields(evidence: RunEvidence[], now: Date): string {
|
||||
return `runs=${evidence.map(e => `${e.run_id}/${e.run_attempt}`).join(',')} profiles=${evidence.map(e => e.profile).join(',')} heads=${[...new Set(evidence.map(e => e.head_sha.slice(0, 12)))].join(',')} verified=${now.toISOString()}`;
|
||||
@@ -215,7 +250,7 @@ export function parseRunReceipt(receipt: string): { runs: string[]; profiles: st
|
||||
}
|
||||
|
||||
/** Egress receipts for the reads below (fail-open, like contributor-mode): only this repository's name, run ids and a commit sha leave the machine. */
|
||||
const receiptFor = (host: string, payloadClass: string, request: string) => ({ sink: 'run-evidence', host, payloadClass, bytes: 0, sha256: sha256Hex(request), consent: 'user ran gstack-pregate clear --run (or --run-url on a profiled lane); reads its own repository\'s Actions runs and fetches a run head by sha' });
|
||||
const receiptFor = (host: string, payloadClass: string, request: string) => ({ sink: 'run-evidence', host, payloadClass, bytes: 0, sha256: sha256Hex(request), consent: 'a run verification the user or the repository\'s own workflow started (gstack-pregate clear --run or --from-run, the main-push reuse job); reads its own repository\'s Actions runs and fetches a run head by sha' });
|
||||
const receiptFailed = (err: unknown) => process.stderr.write(`[run-evidence] egress receipt could not be written (${(err as Error).message}); proceeding (fail-open)\n`);
|
||||
|
||||
function gh(args: string[], timeoutMs = 60_000): string {
|
||||
@@ -225,6 +260,9 @@ function gh(args: string[], timeoutMs = 60_000): string {
|
||||
return r.stdout;
|
||||
}
|
||||
|
||||
/** One `gh api` read on this repository's own data, receipted like every read here. */
|
||||
export function ghApiJson<T>(apiPath: string): T { return JSON.parse(gh(['api', apiPath])) as T; }
|
||||
|
||||
/** The gh- and git-backed dependencies for `repo`, reading trees in `repoRoot` (fetching a missing head from origin once). */
|
||||
export function ghRunDeps(repo: string, repoRoot: string): RunDeps {
|
||||
const json = <T>(p: string): T => JSON.parse(gh(['api', p]));
|
||||
|
||||
+20
-9
@@ -9,11 +9,12 @@
|
||||
*/
|
||||
import * as crypto from 'node:crypto';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { resultLine } from './result-codes';
|
||||
import {
|
||||
BudgetError, DEFAULT_TAIL, DELEGATE_SEGMENT_CALL, DELEGATE_START_CALL, MAX_CHAIN_DEPTH, METER_CONTRACTS, SEGMENTS, SHIP_STEPS,
|
||||
aboveFloor, admits, applyCredits, applyReading, budgetError, creditsUsed, parseCredits, budgetLine, keepsSmallRun, ledgerFileFor, loadCalibration, measureSettled, mintRunId, mutateLedger, newEpoch,
|
||||
aboveFloor, admits, applyCredits, applyReading, budgetError, creditsUsed, parseCredits, budgetLine, keepsSmallRun, labelScope, ledgerFileFor, loadCalibration, measureSettled, mintRunId, mutateLedger, newEpoch,
|
||||
parseNext, parseRecordBlock, parseStep, parseTokens, pointMarker, projector, readLedger, reserveIn, restoreRun, reviewPaths,
|
||||
runDirFor, settleIn, stepProjection, tally, unitConstants, unreservedDispatches, usageError, writeLedger,
|
||||
type Ledger, type MeterKind, type Next, type Note,
|
||||
@@ -236,9 +237,15 @@ function pickMeter(o: Opts): MeterKind | undefined {
|
||||
}
|
||||
|
||||
export function cmdStart(argv: string[], ctx: Ctx): Outcome {
|
||||
const o = parseArgs(argv, ['token-cap', 'record', 'meter', 'reading', 'credits', 'prior-reading', 'session', 'nonce', 'reserve', 'delegate', 'calibration', 'ledger', 'call-floor', 'mode']);
|
||||
const o = parseArgs(argv, ['token-cap', 'record', 'meter', 'reading', 'credits', 'prior-reading', 'session', 'nonce', 'reserve', 'delegate', 'calibration', 'ledger', 'call-floor', 'mode', 'meter-scope', 'reading-from'], ['subagent']);
|
||||
const mode = str(o, 'mode') ?? 'admit';
|
||||
if (mode !== 'admit' && mode !== 'observe') throw usageError(`--mode '${mode}' is not admit or observe`);
|
||||
const scope = str(o, 'meter-scope');
|
||||
if (scope !== undefined && scope !== 'self' && scope !== 'tree') throw usageError(`--meter-scope '${scope}' is not self or tree`);
|
||||
const from = str(o, 'reading-from');
|
||||
if (from !== undefined && from !== 'this' && from !== 'own') throw usageError(`--reading-from '${from}' is not this or own`);
|
||||
const parentCounter = (o.subagent === true || ctx.env.GSTACK_SHIP_SUBAGENT === '1') && from === 'this';
|
||||
if (parentCounter && scope === 'self') throw budgetError('METER_SCOPE_PARENT', 'start --subagent --reading-from this --meter-scope self');
|
||||
const record = str(o, 'record');
|
||||
if (!record) throw usageError('start needs --record <invocation record path>');
|
||||
const rawCap = str(o, 'token-cap') ?? ctx.env.GSTACK_SHIP_TOKEN_CAP;
|
||||
@@ -248,8 +255,10 @@ export function cmdStart(argv: string[], ctx: Ctx): Outcome {
|
||||
if (delegate !== 'auto' && delegate !== 'never') throw usageError(`--delegate '${delegate}' is not auto or never`);
|
||||
const existing = parseRecordBlock(record);
|
||||
if (existing?.ledger && fs.existsSync(existing.ledger) && !readLedger(existing.ledger).closed_at) {
|
||||
const l = readLedger(existing.ledger);
|
||||
return { stdout: [`BUDGET_RUN: id=${l.run} ledger=${l.file}`, budgetLine(l, { call: null, next: '1', projected: 0, verdict: l.stopped ? 'stop' : 'continue' })], exit: l.stopped ? 3 : 0 };
|
||||
return mutateLedger(existing.ledger, ctx.env, l => {
|
||||
if (scope === 'tree' || parentCounter) l.meter_scope = 'tree';
|
||||
return { stdout: [`BUDGET_RUN: id=${l.run} ledger=${l.file}`, budgetLine(l, { call: null, next: '1', projected: 0, verdict: l.stopped ? 'stop' : 'continue' })], exit: l.stopped ? 3 : 0 };
|
||||
});
|
||||
}
|
||||
const meter = pickMeter(o);
|
||||
if (!meter) {
|
||||
@@ -270,6 +279,7 @@ export function cmdStart(argv: string[], ctx: Ctx): Outcome {
|
||||
epochs: [], carried_used: 0, estimate_used: 0, unknown: 0, next_id: 1, reservations: [], handoffs: [], cap_raises: [],
|
||||
marker: paths.marker, review_log: paths.reviewLog, ...(str(o, 'calibration') ? { calibration: path.resolve(str(o, 'calibration')!) } : {}),
|
||||
...(callFloor !== undefined ? { call_floor: callFloor } : {}), ...(mode === 'observe' ? { mode } : {}),
|
||||
...(scope === 'tree' || parentCounter ? { meter_scope: 'tree' as const } : {}),
|
||||
};
|
||||
if (meter === 'capy' && tokensOpt(o, 'reading') === undefined) throw budgetError('BUDGET_READING_MISSING', 'start --meter capy');
|
||||
if (meter === 'capy') l.meter.baseline = tokensOpt(o, 'reading')!;
|
||||
@@ -278,7 +288,7 @@ export function cmdStart(argv: string[], ctx: Ctx): Outcome {
|
||||
const out = decide(l, '0.9', { kind: 'step', step: '1' }, call, true);
|
||||
writeLedger(l, ctx.env);
|
||||
pointMarker(l.marker, l);
|
||||
return { stdout: [`BUDGET_RUN: id=${run} ledger=${l.file}`, ...out.stdout], exit: out.exit };
|
||||
return labelScope(l, { stdout: [`BUDGET_RUN: id=${run} ledger=${l.file}`, ...out.stdout], exit: out.exit });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -400,10 +410,11 @@ export function cmdNote(argv: string[], ctx: Ctx): Outcome {
|
||||
}
|
||||
|
||||
/** calls: count parent calls from the host's transcript items (lib/ship-calls.ts). */
|
||||
export function cmdCalls(argv: string[]): Outcome {
|
||||
export function cmdCalls(argv: string[], ctx: Ctx): Outcome {
|
||||
const o = parseArgs(argv, ['events', 'since', 'until']);
|
||||
const file = str(o, 'events');
|
||||
if (!file) throw usageError('calls needs --events <file of the host\'s transcript items>');
|
||||
const raw = str(o, 'events');
|
||||
if (!raw) throw usageError('calls needs --events <file of the host\'s transcript items>');
|
||||
const file = raw === '~' || raw.startsWith('~/') ? path.join(ctx.env.HOME || os.homedir(), raw.slice(1)) : raw;
|
||||
let doc: unknown;
|
||||
try { doc = JSON.parse(fs.readFileSync(file, 'utf8')); } catch (e: any) { throw usageError(`calls --events ${file}: ${e.message}`); }
|
||||
const d = doc as { items?: unknown; cursor?: unknown };
|
||||
@@ -470,7 +481,7 @@ function rebuildLedger(file: string, record: string | undefined, ctx: Ctx, o: Op
|
||||
meter: { contract: METER_CONTRACTS[meter], epoch: num('epoch') + 1, baseline: meter === 'capy' ? reading! : 0, last: meter === 'capy' ? reading! : null },
|
||||
epochs: [], carried_used: num('used'), estimate_used: 0, unknown: num('unknown') + num('reserved'), next_id: 1, reservations: [], handoffs: [], cap_raises: [],
|
||||
...(() => { try { return { marker: reviewPaths(ctx.cwd, ctx.env).marker, review_log: reviewPaths(ctx.cwd, ctx.env).reviewLog }; } catch { return {}; } })(),
|
||||
...(b.mode === 'observe' ? { mode: 'observe' as const } : {}),
|
||||
...(b.mode === 'observe' ? { mode: 'observe' as const } : {}), ...(b.meter_scope === 'tree' ? { meter_scope: 'tree' as const } : {}),
|
||||
};
|
||||
if (meter === 'estimate') l.estimate_used = 0;
|
||||
fs.mkdirSync(l.dir, { recursive: true, mode: 0o700 });
|
||||
|
||||
+16
-2
@@ -91,6 +91,8 @@ export interface Ledger {
|
||||
/** Capy cost.credits readings beside the token meter: baseline is the first reading, last the newest. */
|
||||
credits?: { baseline: number; last: number };
|
||||
notes?: Note[];
|
||||
/** tree: the reading is the root thread's counter (a subagent cannot read its own jam id), so every cost line carries TREE_METER. */
|
||||
meter_scope?: 'tree';
|
||||
stopped?: { at: string; reason: string; step: string; next: string }; closed_at?: string;
|
||||
}
|
||||
|
||||
@@ -359,10 +361,22 @@ export function mutateLedger<T>(file: string, env: NodeJS.ProcessEnv, fn: (l: Le
|
||||
const ledger = readLedger(file);
|
||||
const out = fn(ledger);
|
||||
writeLedger(ledger, env);
|
||||
return out;
|
||||
return labelScope(ledger, out);
|
||||
});
|
||||
}
|
||||
|
||||
export const TREE_METER = 'METER: tree (root counter, upper bound)';
|
||||
|
||||
/** A tree-scoped run never prints a cost line without TREE_METER on the line after it. */
|
||||
export function labelScope<T>(l: Ledger, out: T): T {
|
||||
if (l.meter_scope !== 'tree') return out;
|
||||
const label = (lines: string[]) => lines.flatMap((line, i) => /^(BUDGET|BUDGET_FINAL|BUDGET_TIMELINE|BUDGET_DRIFT|NOTE|RECONCILED):/.test(line) && lines[i + 1] !== TREE_METER ? [line, TREE_METER] : [line]);
|
||||
if (Array.isArray(out)) return label(out) as T;
|
||||
const o = out as { stdout?: unknown };
|
||||
if (Array.isArray(o?.stdout)) o.stdout = label(o.stdout);
|
||||
return out;
|
||||
}
|
||||
|
||||
function childUsed(l: Ledger, r: Reservation): number {
|
||||
const h = l.handoffs.find(x => x.reservation === r.id);
|
||||
if (!h || h.reading_at === null || h.epoch !== l.meter.epoch || l.meter.last === null) return 0;
|
||||
@@ -463,7 +477,7 @@ export function recordBlock(l: Ledger): string {
|
||||
const t = tally(l);
|
||||
const open = l.handoffs.filter(h => !h.reconciled_at).map(h => h.sha).join(',') || 'none';
|
||||
const credits = creditsUsed(l);
|
||||
return `run=${l.run} ledger=${l.file} role=${l.role} cap=${l.cap ?? 'none'} tail=${l.tail} unit=${l.meter.contract.unit} meter=${l.meter.contract.meter} epoch=${l.meter.epoch} baseline=${l.meter.baseline} used=${t.used}${credits === null ? '' : ` credits=${credits}`} reserved=${t.reserved} unknown=${t.unknown} stop=${l.stopped?.reason ?? 'none'} handoff=${open}${l.mode === 'observe' ? ' mode=observe' : ''}`;
|
||||
return `run=${l.run} ledger=${l.file} role=${l.role} cap=${l.cap ?? 'none'} tail=${l.tail} unit=${l.meter.contract.unit} meter=${l.meter.contract.meter} epoch=${l.meter.epoch} baseline=${l.meter.baseline} used=${t.used}${credits === null ? '' : ` credits=${credits}`} reserved=${t.reserved} unknown=${t.unknown} stop=${l.stopped?.reason ?? 'none'} handoff=${open}${l.mode === 'observe' ? ' mode=observe' : ''}${l.meter_scope === 'tree' ? ' meter_scope=tree' : ''}`;
|
||||
}
|
||||
|
||||
/** The record's fenced gstack-budget block: written by the bin, never by the agent. */
|
||||
|
||||
+6
-6
@@ -25,7 +25,7 @@ import { resolveClaudeCommand } from './claude-bin';
|
||||
import { wtreeFingerprint } from './script-invocation';
|
||||
import { resultLine } from './result-codes';
|
||||
import {
|
||||
MAX_CHAIN_DEPTH, SHIP_STEPS, budgetError, budgetLine, ledgerFileFor, loadCalibration, mintRunId, mutateLedger, parseStep, pointMarker,
|
||||
MAX_CHAIN_DEPTH, SHIP_STEPS, budgetError, budgetLine, labelScope, ledgerFileFor, loadCalibration, mintRunId, mutateLedger, parseStep, pointMarker,
|
||||
projector, readLedger, reserveIn, runDirFor, segmentEstimate, settleIn, tally, usageError, writeLedger, type Handoff, type Ledger,
|
||||
} from './ship-budget';
|
||||
import { loadMeterState, saveMeterState } from './claude-usage';
|
||||
@@ -195,7 +195,7 @@ export function cmdVerifyHandoff(argv: string[], ctx: Ctx): Outcome {
|
||||
...(parent.meter.contract.meter === 'claude' ? { sessions: [str(o, 'session') ?? ''], config_dir: parent.meter.config_dir } : {}) },
|
||||
epochs: [], carried_used: 0, estimate_used: 0, unknown: 0, next_id: 1, reservations: [], handoffs: [], cap_raises: [],
|
||||
marker: parent.marker, review_log: parent.review_log, calibration: parent.calibration,
|
||||
...(parentMetered ? { parent_metered: true } : {}),
|
||||
...(parentMetered ? { parent_metered: true } : {}), ...(parent.meter_scope ? { meter_scope: parent.meter_scope } : {}),
|
||||
};
|
||||
if (parent.meter.contract.meter === 'claude' && !str(o, 'session')) throw usageError('verify-handoff on meter=claude needs --session <this child session id>');
|
||||
readMeter(child, o, !parentMetered);
|
||||
@@ -205,9 +205,9 @@ export function cmdVerifyHandoff(argv: string[], ctx: Ctx): Outcome {
|
||||
});
|
||||
writeLedger(child, ctx.env);
|
||||
pointMarker(child.marker, child);
|
||||
return { stdout: [`HANDOFF_OK: steps=${block.from}-${block.to} sha256=${sha}`, `BUDGET_RUN: id=${run} ledger=${child.file}`,
|
||||
return labelScope(child, { stdout: [`HANDOFF_OK: steps=${block.from}-${block.to} sha256=${sha}`, `BUDGET_RUN: id=${run} ledger=${child.file}`,
|
||||
...(parentMetered ? [`${PARENT_METERED} (no counter of its own: the parent's handoff and reconcile readings measure Steps ${block.from}-${block.to})`] : []),
|
||||
budgetLine(child, { call: null, next: block.from, projected: 0, verdict: 'continue' })], exit: 0 };
|
||||
budgetLine(child, { call: null, next: block.from, projected: 0, verdict: 'continue' })], exit: 0 });
|
||||
}
|
||||
|
||||
export function cmdReturn(argv: string[], ctx: Ctx): Outcome {
|
||||
@@ -229,11 +229,11 @@ export function cmdReturn(argv: string[], ctx: Ctx): Outcome {
|
||||
const unreconciled = l.handoffs.filter(h => !h.reconciled_at);
|
||||
const dropped = l.reservations.filter(r => r.remaining?.length && !accepted.has(r.id));
|
||||
const result = status === 'complete' && (unreconciled.length || dropped.length) ? 'incomplete' : status;
|
||||
const lines = [`RETURN: handoff=${l.parent.handoff_sha} status=${result} steps=${l.parent.from}-${to} head=${head} tree=${tree}`,
|
||||
const lines = labelScope(l, [`RETURN: handoff=${l.parent.handoff_sha} status=${result} steps=${l.parent.from}-${to} head=${head} tree=${tree}`,
|
||||
...unreconciled.map(h => `UNRECONCILED: handoff=${h.sha} steps=${h.from}-${h.to} (reconcile --run ${l.run} --return ${h.return_file} before returning)`),
|
||||
...dropped.map(r => `DROPPED: step=${r.step} reservation=${r.id} roles=${r.remaining!.join(',')} (ADMIT named them; no later check requested them)`),
|
||||
`RECORD_CHANGED: ${str(o, 'changed') ?? 'none'}`, ...(l.parent_metered ? [PARENT_METERED] : []), budgetLine(l, { call: null, next: '-', projected: 0, verdict: 'continue' }),
|
||||
`GSTACK_RESULT: skill=ship status=${result} run=${l.dir}`];
|
||||
`GSTACK_RESULT: skill=ship status=${result} run=${l.dir}`]);
|
||||
writeReturnFile(l.parent.return_file, lines);
|
||||
l.closed_at = new Date().toISOString();
|
||||
return { stdout: lines, exit: 0 };
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* /ship section parts (GSTA-167 calls plan C5, in code): the driver's phase programs end with
|
||||
* `gstack-ship-parts next`, which returns the section text the next phase's steps use, verbatim,
|
||||
* split at headings, each byte once per run, before its first use, within the room the program has
|
||||
* left under its 32 KB return. The parent never reads these sections in a call of its own.
|
||||
*
|
||||
* PARTS is in run order. A part runs from its heading line to the next start heading listed for the
|
||||
* same file, its `until` heading, or the end of the file; a part with no heading starts at byte 0,
|
||||
* so review-army.md, adversarial.md, documentation.md and review/checklist.md are returned whole.
|
||||
*/
|
||||
import { createHash } from 'node:crypto';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
|
||||
export const ROOT = path.resolve(import.meta.dir, '..');
|
||||
export const PART_MAX = 24 * 1024;
|
||||
export const ROOM_DEFAULT = 32000;
|
||||
|
||||
type PartSpec = { id: string; file: string; from: string | null; until?: string; phase: number };
|
||||
|
||||
const RA = 'ship/sections/review-army.md';
|
||||
const ADV = 'ship/sections/adversarial.md';
|
||||
const DOCS = 'ship/sections/documentation.md';
|
||||
export const WHOLE = [RA, ADV, DOCS, 'review/checklist.md'];
|
||||
|
||||
export const PARTS: PartSpec[] = [
|
||||
{ id: 'review-9', file: RA, from: null, phase: 3 },
|
||||
{ id: 'review-9.1', file: RA, from: '## Step 9.1: Review Army — Specialist Dispatch', phase: 3 },
|
||||
{ id: 'review-red-team', file: RA, from: '### Red Team dispatch (conditional)', phase: 3 },
|
||||
{ id: 'review-9.2.1', file: RA, from: '### Step 9.2.1: Exploratory QA (before Fix-First)', phase: 3 },
|
||||
{ id: 'plan-8.1-8.2', file: 'ship/sections/plan-completion.md', from: '## Step 8.1: Plan Verification', until: '## Prior Learnings', phase: 4 },
|
||||
{ id: 'review-calibration', file: RA, from: '## Confidence Calibration', phase: 4 },
|
||||
{ id: 'review-core', file: RA, from: '### Core checklist', phase: 4 },
|
||||
{ id: 'checklist', file: 'review/checklist.md', from: null, phase: 4 },
|
||||
{ id: 'review-design', file: RA, from: '## Design Review (conditional, diff-scoped)', phase: 4 },
|
||||
{ id: 'review-9.2', file: RA, from: '### Step 9.2: Collect and merge findings', phase: 4 },
|
||||
{ id: 'review-9.3', file: RA, from: '### Step 9.3: Cross-review finding dedup', phase: 4 },
|
||||
{ id: 'review-9.4', file: RA, from: '## Step 9.4: Fix-First and persistence', phase: 4 },
|
||||
{ id: 'adversarial-11', file: ADV, from: null, phase: 4 },
|
||||
{ id: 'adversarial-native', file: ADV, from: '### Claude adversarial subagent (on `DISPATCH: adversarial-native`)', phase: 4 },
|
||||
{ id: 'adversarial-codex', file: ADV, from: '### Codex adversarial challenge (runs whenever `CODEX_MODE` is `ready` or `unverified`)', phase: 4 },
|
||||
{ id: 'adversarial-structured', file: ADV, from: '### Codex structured review (large diffs only, 200+ lines)', phase: 4 },
|
||||
{ id: 'adversarial-finish', file: ADV, from: '### Persist the review result', phase: 4 },
|
||||
{ id: 'adversarial-learnings', file: ADV, from: '## Capture Learnings', phase: 4 },
|
||||
{ id: 'documentation', file: DOCS, from: null, phase: 6 },
|
||||
];
|
||||
|
||||
export type Part = { id: string; file: string; phase: number; start: number; end: number; text: string };
|
||||
|
||||
/** Byte offset of a heading line outside code fences, or -1. */
|
||||
function headingAt(buf: Buffer, heading: string): number {
|
||||
let fence = false;
|
||||
let at = 0;
|
||||
for (const line of buf.toString('utf8').split('\n')) {
|
||||
if (line.startsWith('```')) fence = !fence;
|
||||
else if (!fence && line === heading) return at;
|
||||
at += Buffer.byteLength(line) + 1;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
export function resolveParts(root = ROOT): Part[] {
|
||||
const bufs = new Map<string, Buffer>();
|
||||
const read = (f: string) => {
|
||||
if (!bufs.has(f)) bufs.set(f, fs.readFileSync(path.join(root, f)));
|
||||
return bufs.get(f)!;
|
||||
};
|
||||
const find = (f: string, h: string) => {
|
||||
const at = headingAt(read(f), h);
|
||||
if (at < 0) throw new Error(`PARTS_MANIFEST: heading "${h}" not found in ${f}; update lib/ship-parts.ts with the section`);
|
||||
return at;
|
||||
};
|
||||
const starts = PARTS.map(p => (p.from === null ? 0 : find(p.file, p.from)));
|
||||
return PARTS.map((p, i) => {
|
||||
const buf = read(p.file);
|
||||
const later = PARTS.map((q, j) => (q.file === p.file && starts[j] > starts[i] ? starts[j] : Infinity));
|
||||
const end = Math.min(buf.length, ...later, p.until ? find(p.file, p.until) : Infinity);
|
||||
return { id: p.id, file: p.file, phase: p.phase, start: starts[i], end, text: buf.subarray(starts[i], end).toString('utf8') };
|
||||
});
|
||||
}
|
||||
|
||||
const sha = (s: string) => createHash('sha256').update(s).digest('hex').slice(0, 12);
|
||||
const quote = (s: string) => "'" + s.replace(/'/g, "'\\''") + "'";
|
||||
export const block = (p: Part) =>
|
||||
`PART: ${p.id} file=${p.file} phase=${p.phase} bytes=${p.end - p.start} sha256=${sha(p.text)}\n${p.text}${p.text.endsWith('\n') ? '' : '\n'}PART_END: ${p.id}\n`;
|
||||
|
||||
/** The next parts in run order that fit `room` bytes, and the parts due by `phase` that did not fit. */
|
||||
export function nextParts(parts: Part[], returned: readonly string[], phase: number, room: number) {
|
||||
const out: Part[] = [];
|
||||
let used = 0;
|
||||
for (const p of parts) {
|
||||
if (returned.includes(p.id)) continue;
|
||||
const b = Buffer.byteLength(block(p));
|
||||
if (used + b > room) break;
|
||||
out.push(p);
|
||||
used += b;
|
||||
}
|
||||
const given = new Set([...returned, ...out.map(p => p.id)]);
|
||||
return {
|
||||
out,
|
||||
due: parts.filter(p => p.phase <= phase && !given.has(p.id)).map(p => p.id),
|
||||
left: parts.filter(p => !given.has(p.id)).length,
|
||||
};
|
||||
}
|
||||
|
||||
const HELP = `gstack-ship-parts — the section text /ship's next phase uses, verbatim, each byte once (GSTA-167 C5)
|
||||
|
||||
next --run <run dir> --phase <n> [--room <bytes>] [--resend] print the next parts that fit, in run order
|
||||
list every part with its phase and size
|
||||
|
||||
Each phase program ends with \`next --phase <its phase + 1>\` and --room set to 32000 less the
|
||||
program's other output; the room covers the whole output, the PARTS: and NEXT: lines included.
|
||||
\`NEXT: parts\` means a part due by that phase did not fit: run \`next\` alone before the phase.
|
||||
A batch is \`sent\` until the next \`next\` call promotes it to returned; after a return that never
|
||||
reached the parent, \`next --resend\` sends that batch again. State: <run dir>/parts/state.json.
|
||||
`;
|
||||
|
||||
export function shipParts(argv: readonly string[], root = ROOT): { text: string; exit: number } {
|
||||
const [cmd, ...rest] = argv;
|
||||
if (!cmd || cmd === '--help' || cmd === '-h') return { text: HELP, exit: cmd ? 0 : 2 };
|
||||
if (cmd !== 'next' && cmd !== 'list') return { text: `unknown command: ${cmd}\n${HELP}`, exit: 2 };
|
||||
let parts: Part[];
|
||||
try { parts = resolveParts(root); } catch (e) { return { text: `STOP: ${(e as Error).message}\n`, exit: 3 }; }
|
||||
if (cmd === 'list') return { text: parts.map(p => `${p.id} phase=${p.phase} bytes=${p.end - p.start} file=${p.file}`).join('\n') + '\n', exit: 0 };
|
||||
const flag = (n: string) => { const i = rest.indexOf(n); return i >= 0 ? rest[i + 1] : undefined; };
|
||||
const run = flag('--run');
|
||||
const phase = Number(flag('--phase'));
|
||||
const room = flag('--room') === undefined ? ROOM_DEFAULT : Number(flag('--room'));
|
||||
if (!run || !Number.isInteger(phase) || !Number.isFinite(room)) return { text: 'next needs --run <dir> and --phase <n>; --room is a byte count\n', exit: 2 };
|
||||
const state = path.join(run, 'parts', 'state.json');
|
||||
let saved: { returned: string[]; sent?: string[] } = { returned: [] };
|
||||
try {
|
||||
if (fs.existsSync(state)) saved = JSON.parse(fs.readFileSync(state, 'utf8'));
|
||||
if (!Array.isArray(saved.returned) || (saved.sent !== undefined && !Array.isArray(saved.sent))) throw new Error('returned and sent must be id lists');
|
||||
} catch (e) {
|
||||
return { text: `STOP: ${state} is not a parts state file (${(e as Error).message}); delete it and rerun to start the run's parts again\n`, exit: 3 };
|
||||
}
|
||||
const returned = rest.includes('--resend') ? saved.returned : [...saved.returned, ...(saved.sent ?? [])];
|
||||
const trailer = (ids: string[], due: string[], left: number) => {
|
||||
const lines = [`PARTS: phase=${phase} returned=${ids.join(',') || 'none'} due=${due.join(',') || 'none'} left=${left}`];
|
||||
if (due.length) lines.push(`NEXT: parts (run \`gstack-ship-parts next --run ${quote(run)} --phase ${phase}\` alone before Phase ${phase})`);
|
||||
return lines.join('\n') + '\n';
|
||||
};
|
||||
const open = parts.filter(p => !returned.includes(p.id)).map(p => p.id);
|
||||
const { out, due, left } = nextParts(parts, returned, phase, room - Buffer.byteLength(trailer(open, open, open.length)));
|
||||
fs.mkdirSync(path.dirname(state), { recursive: true });
|
||||
fs.writeFileSync(state, JSON.stringify({ returned, sent: out.map(p => p.id) }, null, 2) + '\n');
|
||||
return { text: out.map(block).join('') + trailer(out.map(p => p.id), due, left), exit: 0 };
|
||||
}
|
||||
+20
-3
@@ -53,6 +53,10 @@ function integrate(cwd: string, base: string, r: Report): boolean {
|
||||
r.next(`carry route (Step 1.4): fix what the base merge broke (CHANGELOG, size caps), gstack-pregate --allow-repo-commands, then gstack-ship-receipt carry --pr ${n} --base ${base} --pregate .gstack/tmp/pregate.json`);
|
||||
return false;
|
||||
}
|
||||
if (line === 'CARRY: refused (RECEIPT_CARRY_SCOPED_REVIEW)') {
|
||||
r.next(`carry route (Step 1.4): the base edited context next to reviewed hunks: fix what the base merge broke (CHANGELOG, size caps), commit and push; then gstack-ship-receipt carry --pr ${n} --base ${base} writes the packet for that head; one fix-check reviewer reads it; gstack-pregate --allow-repo-commands, then carry again with --scoped-review <record.json> --pregate .gstack/tmp/pregate.json; a blocking finding runs Steps 2-16`);
|
||||
return false;
|
||||
}
|
||||
} else r.add('PR: none open');
|
||||
const apple = git(cwd, ['ls-files', '--', '*.xcodeproj/*', '*.xcworkspace/*', 'Package.swift']).stdout.split('\n').filter(Boolean).map(p => p.replace(/(\.xcodeproj|\.xcworkspace)\/.*$/, '$1'));
|
||||
if (apple.length) r.add(`APPLE: ${short([...new Set(apple)], 3)}`).next('Step 0.9: for an App Store/TestFlight ask, confirm the app target and follow apple-release.md; otherwise ignore');
|
||||
@@ -67,9 +71,22 @@ const forkPoint = (cwd: string, base: string) => git(cwd, ['merge-base', `origin
|
||||
|
||||
function merge(cwd: string, base: string, r: Report): boolean {
|
||||
const policy = shipPolicy(cwd, base);
|
||||
if (policy?.queue_mode === 'stamp-at-merge') r.add('MERGE: skipped (queue_mode stamp-at-merge: the slot stamp merges the base; docs/ship-policy.md#ship-before-ready)');
|
||||
else if (!git(cwd, ['ls-files', '-u']).stdout.trim() && ['MERGE_HEAD', 'CHERRY_PICK_HEAD', 'REVERT_HEAD'].every(ref => git(cwd, ['rev-parse', '-q', '--verify', ref]).status !== 0)
|
||||
&& git(cwd, ['merge-base', '--is-ancestor', `origin/${base}`, 'HEAD']).status === 0) r.add('MERGE: up to date');
|
||||
const stampAtMerge = policy?.queue_mode === 'stamp-at-merge';
|
||||
const pending = stampAtMerge ? undefined : ['MERGE_HEAD', 'CHERRY_PICK_HEAD', 'REVERT_HEAD'].find(ref => {
|
||||
const p = git(cwd, ['rev-parse', '--git-path', ref]);
|
||||
return p.status === 0 && !!p.stdout.trim() && fs.existsSync(path.resolve(cwd, p.stdout.trim()));
|
||||
});
|
||||
const unmerged = stampAtMerge ? [] : git(cwd, ['diff', '--name-only', '--diff-filter=U']).stdout.split('\n').filter(Boolean);
|
||||
if (pending) {
|
||||
r.stop(`an unfinished ${pending.replace(/_HEAD$/, '').toLowerCase().replace('_', '-')} is in progress (${pending}${unmerged.length ? `, conflicts in ${short(unmerged, 8)}` : ''}): resolve and commit it, or abort it, then rerun (Step 3)`);
|
||||
return false;
|
||||
}
|
||||
if (unmerged.length) {
|
||||
r.stop(`unresolved conflicts in ${short(unmerged, 8)} with no merge, cherry-pick or revert in progress: resolve them (git add) or restore the files, then rerun (Step 3)`);
|
||||
return false;
|
||||
}
|
||||
if (stampAtMerge) r.add('MERGE: skipped (queue_mode stamp-at-merge: the slot stamp merges the base; docs/ship-policy.md#ship-before-ready)');
|
||||
else if (git(cwd, ['merge-base', '--is-ancestor', `origin/${base}`, 'HEAD']).status === 0) r.add('MERGE: up to date');
|
||||
else {
|
||||
const before = git(cwd, ['rev-parse', 'HEAD']).stdout.trim();
|
||||
const m = git(cwd, ['merge', `origin/${base}`, '--no-edit'], 300_000);
|
||||
|
||||
+36
-15
@@ -3,7 +3,8 @@
|
||||
* when the queue is long, 3-6 PRs of one functional area travel as one wave PR
|
||||
* that takes one train slot, one /ship, one READY and one version.
|
||||
*
|
||||
* wave plan read-only: live READY PRs, WAVE_ADVISED over the trigger, suggested area groups
|
||||
* wave plan read-only (git merge-tree leaves only unreferenced objects): live READY PRs with green
|
||||
* CI, WAVE_ADVISED over the trigger, suggested area groups
|
||||
* wave build the integrator branch wave/<nonce>: main plus each constituent's pinned head merged
|
||||
* unchanged with the train's merge rules (a merge commit whose second parent is the
|
||||
* pin); a conflict stops in a worktree under the state root (--continue resumes);
|
||||
@@ -24,7 +25,7 @@ import { EXIT, formatResult } from './headless-artifacts';
|
||||
import { RESULT_CODES } from './result-codes';
|
||||
import { versionAtRef } from './restamp';
|
||||
import { globToRegExp, type ShipPolicy } from './ship-policy';
|
||||
import type { QueueContext } from './ship-queue';
|
||||
import { ciAtHead, type QueueContext } from './ship-queue';
|
||||
import { git, sha256, type QueueFailure } from './ship-queue-gate';
|
||||
import { fetchBase, mergeCommit, outFailed, outOk, outRefused, parsePins, trainEnv, type TrainEnv, type TrainOut } from './ship-queue-train';
|
||||
import { prInfo, type PrInfo } from './ship-queue-train-github';
|
||||
@@ -79,20 +80,34 @@ export function dominantArea(files: string[], areaOf: (f: string) => string): st
|
||||
const largestFirst = (a: PlanPr, b: PlanPr) => b.lines - a.lines || Number(a.pr) - Number(b.pr);
|
||||
|
||||
/**
|
||||
* Group PRs into waves: PRs that change a common file always travel together (one integrator
|
||||
* resolves their conflicts once), each cluster takes its largest PR's area, clusters of one
|
||||
* area are packed in PR order into waves of at most max, and a wave below min rides solo.
|
||||
* The integrator is the PR with the most changed lines.
|
||||
* Hub files: prose (docs/**, Markdown, generated SKILL.md renders) and JSON ledgers and registries.
|
||||
* Most PRs append to them, so their conflicts are kept-both-sides edits that any train slot
|
||||
* resolves; linking PRs through them chained a 55-PR queue into one area (GST-770). They never
|
||||
* link PRs into a wave. Skill templates (.md.tmpl) are source and link on a real conflict.
|
||||
*/
|
||||
export function groupWaves(prs: PlanPr[], areaOf: (f: string) => string, min = WAVE_MIN, max = WAVE_MAX): { groups: WaveGroup[]; solo: string[] } {
|
||||
export const isHubFile = (file: string) => file.startsWith('docs/') || /\.(md|jsonl?)$/.test(file);
|
||||
|
||||
/**
|
||||
* Group PRs into waves: PRs whose changes to a common code file conflict always travel together
|
||||
* (one integrator resolves the conflict once); hub files never link. conflicts(a, b) names the
|
||||
* files a merge of the two heads cannot resolve, or null when unknown, which counts every shared
|
||||
* file as conflicting. Each cluster takes its largest PR's area, clusters of one area are packed
|
||||
* in PR order into waves of at most max, and a wave below min rides solo. The integrator is the
|
||||
* PR with the most changed lines.
|
||||
*/
|
||||
export function groupWaves(prs: PlanPr[], areaOf: (f: string) => string, min = WAVE_MIN, max = WAVE_MAX, conflicts: (a: PlanPr, b: PlanPr) => string[] | null = () => null): { groups: WaveGroup[]; solo: string[] } {
|
||||
const sorted = [...prs].sort((a, b) => Number(a.pr) - Number(b.pr));
|
||||
const root = new Map(sorted.map(p => [p.pr, p.pr]));
|
||||
const find = (x: string): string => { const r = root.get(x)!; return r === x ? x : find(r); };
|
||||
const owner = new Map<string, string>();
|
||||
for (const p of sorted) for (const f of p.files) {
|
||||
const o = owner.get(f);
|
||||
if (o && find(o) !== find(p.pr)) root.set(find(p.pr), find(o)); else if (!o) owner.set(f, p.pr);
|
||||
}
|
||||
const sharers = new Map<string, PlanPr[]>();
|
||||
for (const p of sorted) for (const f of p.files) if (!isHubFile(f)) sharers.set(f, [...(sharers.get(f) ?? []), p]);
|
||||
const clash = new Map<string, string[] | null>();
|
||||
for (const [f, list] of sharers) list.forEach((a, i) => list.slice(i + 1).forEach(b => {
|
||||
const key = `${a.pr} ${b.pr}`;
|
||||
if (!clash.has(key)) clash.set(key, conflicts(a, b));
|
||||
const hit = clash.get(key);
|
||||
if ((!hit || hit.includes(f)) && find(a.pr) !== find(b.pr)) root.set(find(b.pr), find(a.pr));
|
||||
}));
|
||||
const clusters = new Map<string, PlanPr[]>();
|
||||
for (const p of sorted) clusters.set(find(p.pr), [...(clusters.get(find(p.pr)) ?? []), p]);
|
||||
const byArea = new Map<string, PlanPr[][]>();
|
||||
@@ -212,7 +227,7 @@ function readyReceipts(env: TrainEnv): ReadyReceipt[] {
|
||||
|
||||
function changed(env: TrainEnv, head: string): { files: string[]; lines: number } | null {
|
||||
const fork = git(env.ctx.repoRoot, ['merge-base', `${env.remote}/${env.ctx.base}`, head]);
|
||||
const stat = fork.ok ? git(env.ctx.repoRoot, ['diff', '--numstat', fork.out, head]) : fork;
|
||||
const stat = fork.ok ? git(env.ctx.repoRoot, ['diff', '--numstat', '--no-renames', fork.out, head]) : fork;
|
||||
if (!stat.ok) return null;
|
||||
let lines = 0;
|
||||
const files: string[] = [];
|
||||
@@ -250,15 +265,21 @@ export function wavePlan(ctx: QueueContext, o: { trigger: number; min: number; m
|
||||
const info = prInfo(ctx, r.pr);
|
||||
if ('error' in info) { unreadable.push(`#${r.pr} (${info.error})`); continue; }
|
||||
if (info.state !== 'OPEN' || info.headRefOid !== r.head || info.isCrossRepository || info.headRefName.startsWith('wave/')) continue;
|
||||
const ci = ciAtHead(ctx, r.head);
|
||||
if (ci.line.startsWith('unknown')) { unreadable.push(`#${r.pr} (CI ${ci.line})`); continue; }
|
||||
if (!ci.green) { lines.push(`NOT_GREEN: #${r.pr} (${ci.line}; left out until CI is green at the READY head)`); continue; }
|
||||
if (!ensureCommit(env, r.head, r.pr)) { unreadable.push(`#${r.pr} (head ${short(r.head)} not fetchable)`); continue; }
|
||||
const c = changed(env, r.head);
|
||||
if (!c) { unreadable.push(`#${r.pr} (diff unreadable)`); continue; }
|
||||
live.push({ pr: r.pr, head: r.head, ...c });
|
||||
}
|
||||
if (unreadable.length) return outFailed(fail('QUEUE_PROOF_MISSING', `cannot read ${unreadable.length} READY PR(s): ${unreadable.join('; ')}; a plan from a partial queue would mislead`), lines, `${Q} wave plan (after gh and git can read every PR)`, 'none');
|
||||
lines.push(`QUEUE: ${live.length} live READY PR(s) (open, head equal to its READY receipt); wave trigger ${o.trigger}`);
|
||||
lines.push(`QUEUE: ${live.length} live READY PR(s) (open, head equal to its READY receipt, CI green there); wave trigger ${o.trigger}`);
|
||||
if (live.length <= o.trigger) return outOk([...lines, `WAVE: not needed (${live.length} <= ${o.trigger}); build trains of single PRs`], `${Q} train build --prs <pins>`, 'none');
|
||||
const plan = groupWaves(live, areaOf(env), o.min, o.max);
|
||||
const plan = groupWaves(live, areaOf(env), o.min, o.max, (a, b) => {
|
||||
const m = git(env.ctx.repoRoot, ['merge-tree', '--write-tree', '--name-only', '--no-messages', a.head, b.head]);
|
||||
return m.ok ? [] : m.status === 1 ? m.out.split('\n').slice(1).filter(Boolean) : null;
|
||||
});
|
||||
lines.push(`WAVE_ADVISED: ${live.length} READY PRs over the wave trigger ${o.trigger}; group by area into waves of ${o.min}-${o.max} (Garry R46)`);
|
||||
for (const g of plan.groups) {
|
||||
lines.push(`WAVE_GROUP: area=${g.area} prs=${g.prs.map(p => '#' + p.pr).join(',')} integrator=#${g.integrator} (most changed lines)`);
|
||||
|
||||
+23
-1
@@ -13,6 +13,7 @@ import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { classifyPath } from './review-depth';
|
||||
import { comparePath, contextPacket, gateEditedPaths, isTestCode, PACKET_SECTION_CAP, readScopedReview, touchedPaths, type PatchSides } from './ship-patch';
|
||||
import { parseReceiptDocs, type ReceiptDocs } from './ship-receipt-docs';
|
||||
import { resolveStateRoot } from './state-root';
|
||||
import { bumpVersion, cmpVersion, fmtVersion, parseVersion, versionWidth, type Bump } from './version-source';
|
||||
import { bin, evidenceExempt, firstLine, flagReader, fragmentPath, gh, git, hostedOrigin, laneLine, lanes, releasePaths, Report, runLanes, sh, shipPolicy, short, stampedFiles } from './ship-bin-common';
|
||||
@@ -223,6 +224,26 @@ function docsFresh(cwd: string, base: string, postFile: string, moved: readonly
|
||||
try { stale = JSON.parse(cmp.stdout).content_changed; } catch { stale = null; }
|
||||
if (stale === null) { r.stop(`gstack-docs-candidate compare failed: ${firstLine(cmp.stderr)}; documentation freshness is unknown (stage 3)`); return false; }
|
||||
const record = JSON.parse(fs.readFileSync(postFile, 'utf8'));
|
||||
const auditFile = path.join(cwd, '.gstack/tmp/docs-audit.json');
|
||||
let docsAudit: ReceiptDocs | null = null;
|
||||
if (fs.existsSync(auditFile)) {
|
||||
let raw: unknown;
|
||||
try { raw = JSON.parse(fs.readFileSync(auditFile, 'utf8')); } catch { raw = undefined; }
|
||||
const parsed = parseReceiptDocs(raw);
|
||||
if (!parsed.ok) { r.stop(`.gstack/tmp/docs-audit.json: ${parsed.message}; documentation status is unknown (stage 3)`); return false; }
|
||||
docsAudit = parsed.docs;
|
||||
}
|
||||
const accepted = docsAudit?.exception;
|
||||
if (accepted || record.exception) {
|
||||
const same = !!accepted && !!record.exception && docsAudit!.status === 'blocked' && record.exception.scope === docsAudit!.scope
|
||||
&& (['accepted_by', 'date', 'via', 'reason'] as const).every(k => record.exception[k] === accepted[k]);
|
||||
if (!same) {
|
||||
r.stop(accepted
|
||||
? `the Step 14.5 audit is blocked and ${postFile} names no matching owner-accepted exception: snapshot with --exception .gstack/tmp/docs-audit.json (documentation.md Blocked recovery step 3)`
|
||||
: `${postFile} names a documentation exception but .gstack/tmp/docs-audit.json is not the blocked record carrying it: rerun Step 14.5`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
const baseRef = `origin/${base.replace(/^origin\//, '')}`;
|
||||
const nul = (out: string) => out.split('\0').filter(Boolean);
|
||||
const auditBase = git(cwd, ['merge-base', baseRef, record.head]).stdout.trim();
|
||||
@@ -252,7 +273,8 @@ function docsFresh(cwd: string, base: string, postFile: string, moved: readonly
|
||||
if (carried.requested.length && bin(cwd, 'gstack-ship-receipt', ['docs-caps', '--base', base], 60_000).status !== 0) { r.stop('a --docs-requested fix breaks a doc cap (gstack-ship-receipt docs-caps): trim the file, then rerun finish'); return false; }
|
||||
const left = stale.filter(p => !carried.tests.includes(p) && !carried.base.includes(p) && !carried.requested.includes(p));
|
||||
const note = left.length || stale.length === 0 ? '' : ` (carried: tests=${carried.tests.length} base=${carried.base.length} requested=${carried.requested.length})`;
|
||||
r.add(`DOCS: ${left.length ? `stale (${short(left)})` : `fresh against the Step 14.5 post-audit snapshot${note}`}`);
|
||||
const exception = record.exception ? ` (blocked; documentation exception accepted by ${record.exception.accepted_by} ${record.exception.date} via ${record.exception.via})` : '';
|
||||
r.add(`DOCS: ${left.length ? `stale (${short(left)})` : `fresh against the Step 14.5 post-audit snapshot${note}${exception}`}`);
|
||||
if (left.length) { r.stop('documentation inputs changed since the accepted audit: use stage 3 recovery (the remaining re-audit, a fix the audit\'s result asked for via --docs-requested <path> --docs-result <audit-id>-result.json, or a named risk); never refresh hashes'); return false; }
|
||||
return true;
|
||||
}
|
||||
|
||||
+6
-1
@@ -170,7 +170,12 @@ export function buildReceipt(o: ReceiptOptions): TreeReceipt | { error: string;
|
||||
const versionRel = o.versionRel ?? ('rel' in cv ? cv.rel : 'rel' in gv ? gv.rel : 'VERSION');
|
||||
const oldV = 'version' in gv ? gv.version : null;
|
||||
const newV = 'version' in cv ? cv.version : null;
|
||||
const diffText = git(o.repoRoot, ['diff', '--unified=0', '--no-color', '--no-ext-diff', gatedHead, currentHead]) ?? '';
|
||||
const d = spawnSync('git', ['diff', '--unified=0', '--no-color', '--no-ext-diff', gatedHead, currentHead], { cwd: o.repoRoot, encoding: 'utf8', timeout: 60_000, maxBuffer: 256 * 1024 * 1024 });
|
||||
if (d.status !== 0) {
|
||||
const why = (d.error as NodeJS.ErrnoException | undefined)?.code ?? (d.signal ? `killed by ${d.signal}` : (d.stderr ?? '').trim().split('\n')[0]?.slice(0, 200) || `exit ${d.status}`);
|
||||
return { error: `git diff ${gatedHead.slice(0, 12)} ${currentHead.slice(0, 12)} failed (${why}), so the changed files cannot be classified (GST-448)`, code: 'EVIDENCE_IDENTITY_UNKNOWN' };
|
||||
}
|
||||
const diffText = d.stdout.replace(/\n$/, '');
|
||||
const files = classifyFiles(o.repoRoot, parseUnifiedDiff(diffText), gatedHead, currentHead, o.policy, versionRel, oldV, newV);
|
||||
const residual = files.filter(f => f.verdict === 'changed').map(f => f.path);
|
||||
const stampFiles = files.filter(f => f.verdict === 'stamp').map(f => f.path);
|
||||
|
||||
@@ -13,6 +13,7 @@ import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { parseChangelog } from './changelog-check';
|
||||
import { effectiveBump, readFragments } from './changelog-fragments';
|
||||
import { HistoryReadError, readHistoryFile } from './history-read';
|
||||
import { matchesAny } from './ship-policy';
|
||||
|
||||
export type TrainHead =
|
||||
@@ -90,19 +91,27 @@ export function regeneratedAtBase(o: { repoRoot: string; head: string; baseVersi
|
||||
}
|
||||
}
|
||||
|
||||
/** A history read that fails (not an absent file) classifies the head invalid with HISTORY_READ_FAILED. */
|
||||
export function classifyTrainHead(o: { repoRoot: string; baseRef: string; head: string; author?: string }): TrainHead {
|
||||
try { return classify(o); } catch (e) { if (e instanceof HistoryReadError) return { kind: 'invalid', reason: e.message }; throw e; }
|
||||
}
|
||||
|
||||
function classify(o: { repoRoot: string; baseRef: string; head: string; author?: string }): TrainHead {
|
||||
const base = `origin/${o.baseRef.replace(/^origin\//, '')}`;
|
||||
const show = (rev: string, file: string) => git(o.repoRoot, ['show', `${rev}:${file}`]);
|
||||
const show = (rev: string, file: string) => readHistoryFile(o.repoRoot, rev, file);
|
||||
const policyText = show(base, '.gstack/ship-policy.json');
|
||||
let policy: any = null;
|
||||
try { policy = JSON.parse(show(base, '.gstack/ship-policy.json') ?? 'null'); } catch { policy = null; }
|
||||
try { policy = JSON.parse(policyText ?? 'null'); } catch { policy = null; }
|
||||
const fragDir: string | null = typeof policy?.changelog_fragments?.dir === 'string' ? policy.changelog_fragments.dir : null;
|
||||
if (!policy || ((typeof policy.train !== 'object' || policy.train === null) && !fragDir && policy.bump !== 'never')) return { kind: 'off' };
|
||||
const mergeBase = git(o.repoRoot, ['merge-base', base, o.head])?.trim();
|
||||
if (!mergeBase) return { kind: 'invalid', reason: `no merge base between ${base} and ${o.head}` };
|
||||
|
||||
const npmVersion = (rev: string) => { try { return JSON.parse(show(rev, 'package.json') ?? '{}').version ?? null; } catch { return null; } };
|
||||
const npmVersion = (rev: string) => { const text = show(rev, 'package.json'); try { return JSON.parse(text ?? '{}').version ?? null; } catch { return null; } };
|
||||
if (policy.bump === 'never') return show(mergeBase, 'VERSION') === show(o.head, 'VERSION') && npmVersion(mergeBase) === npmVersion(o.head) ? { kind: 'unstamped', entry: 'kept' } : { kind: 'off' };
|
||||
const changed = (git(o.repoRoot, ['diff', '--name-only', mergeBase, o.head]) ?? '').split('\n').filter(Boolean);
|
||||
const diff = git(o.repoRoot, ['diff', '--no-renames', '--name-only', mergeBase, o.head]);
|
||||
if (diff === null) return { kind: 'invalid', reason: `cannot list the files ${o.head} changes since its merge base ${mergeBase.slice(0, 12)}. Fix: see docs/troubleshooting.md#history-read-failed (HISTORY_READ_FAILED)` };
|
||||
const changed = diff.split('\n').filter(Boolean);
|
||||
const releaseOutputs: string[] = Array.isArray(policy.release_outputs) ? policy.release_outputs : [];
|
||||
// touched: what marks the head as stamped (the labels only feed the length check below)
|
||||
const touched: string[] = changed.filter(f => f === 'VERSION');
|
||||
@@ -128,10 +137,7 @@ export function classifyTrainHead(o: { repoRoot: string; baseRef: string; head:
|
||||
}
|
||||
|
||||
const file = typeof policy.changelog === 'string' ? policy.changelog : 'CHANGELOG.md';
|
||||
const exists = (rev: string) => git(o.repoRoot, ['cat-file', '-e', `${rev}:${file}`]) !== null;
|
||||
const read = (rev: string) => (exists(rev) ? show(rev, file) : '');
|
||||
const [beforeText, afterText] = [read(mergeBase), read(o.head)];
|
||||
if (beforeText === null || afterText === null) return { kind: 'invalid', reason: `cannot read ${file} at ${beforeText === null ? `the merge base ${mergeBase.slice(0, 12)}` : o.head}` };
|
||||
const [beforeText, afterText] = [show(mergeBase, file) ?? '', show(o.head, file) ?? ''];
|
||||
const before = parseChangelog(beforeText);
|
||||
const after = parseChangelog(afterText);
|
||||
const unreleased = after.sections.filter(s => s.unreleased);
|
||||
|
||||
Reference in new issue
Block a user