mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-09 02:08:19 +01:00
Merge commit '0c84f4ee14c32734313e68b700417e0748ad1d55' into capy/gst620-sealed-identifiers
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> # Conflicts: # test/helpers/free-fixtures-data.ts
This commit is contained in:
commit
c0d1482b05
568 files changed
+36850
-8414
No files matched your search
+6
-3
@@ -22,9 +22,12 @@ if [ ! -f "$REPO_ROOT/.env" ]; then
|
||||
fi
|
||||
fi
|
||||
|
||||
# 2. Install dependencies, devDependencies included. Always run: a checkout
|
||||
# first installed by ./setup has production dependencies only, and an
|
||||
# up-to-date install is a no-op.
|
||||
# 2. Install dependencies, devDependencies included, and mark this clone as a
|
||||
# contributor's (#3447) so ./setup keeps the dev toolchain instead of moving it
|
||||
# to the production-only install. Always run: a checkout first installed by
|
||||
# ./setup has production dependencies only, and an up-to-date install is a no-op.
|
||||
git -C "$REPO_ROOT" config --local gstack.contributor true 2>/dev/null \
|
||||
|| echo "Warning: could not set git config gstack.contributor in $REPO_ROOT; export GSTACK_CONTRIBUTOR=1 before ./setup to keep the dev toolchain." >&2
|
||||
echo "Installing dependencies..."
|
||||
(cd "$REPO_ROOT" && bun install)
|
||||
|
||||
|
||||
+64
-23
@@ -2,15 +2,15 @@
|
||||
/**
|
||||
* gstack-autoplan — the unattended /autoplan runner as an explicit, durable state machine (plan B2).
|
||||
*
|
||||
* gstack-autoplan next --out <dir> [--plan <file>] [--plan-heading <text>] [--ui|--no-ui] [--developer-tool] [--agent-primary] [--docs-only]
|
||||
* gstack-autoplan next --out <dir> [--plan <file>] [--plan-heading <text>] [--ui|--no-ui] [--developer-tool] [--agent-primary] [--docs-only] [--ceo-only]
|
||||
* [--no-outside] [--light] [--spend-cap <usd>] [--estimate-usd <n>] [--deadline <min>] [--redispatch <attempt>] [--json]
|
||||
* gstack-autoplan submit --out <dir> --phase <p> --voice native|outside --result <file> | --record <file> [--attest <reason>]
|
||||
* --model <id> [--attempt <id>] [--runner <r>] [--usage-usd <n>] [--reconcile <jsonl>] [--json]
|
||||
* gstack-autoplan submit --out <dir> --phase <p> --voice outside --unavailable <reason> | --failed <reason> [--json]
|
||||
* gstack-autoplan revise --out <dir> --plan <file> [--incorporates <id,...>] [--withdraw <id>] [--settle <id>] [--json] review a revised plan in this run
|
||||
* gstack-autoplan run --out <dir> --plan <file> --native-model <id> --outside-model <id> [--runner codex-cli|api] [--native-runner api]
|
||||
* [--deadline <min>] [--spend-cap <usd>] [--estimate-usd <n>] [--json] next in a loop; needs runners for both voices
|
||||
* gstack-autoplan answer --out <dir> --gate-rev <rev> --reply "<grammar>" --by <human|agent:<id>> [--owner-reply <text>|-] [--rec-hash <h>] [--json]
|
||||
* [--deadline <min>] [--spend-cap <usd>] [--estimate-usd <n>] [--max-tokens <n>] [--json] next in a loop; needs runners for both voices
|
||||
* gstack-autoplan answer --out <dir> --gate-rev <rev> --reply "<grammar>" --by <human|agent:<id>> [--owner-reply <text>|-] [--words-by owner|agent:<id>] [--rec-hash <h>] [--json]
|
||||
* gstack-autoplan recommend --out <dir> --gate-rev <n> --item <id> --option <k> --basis "<why>" [--by <who>]
|
||||
* gstack-autoplan resume --out <dir> [--from <checkpoint>] [--json] take ownership; intent without a terminal record becomes EXECUTION_UNKNOWN
|
||||
* gstack-autoplan status --out <dir> [--json] query: the manifest, no GSTACK_RESULT line
|
||||
@@ -31,10 +31,11 @@
|
||||
*/
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { amendmentSteps, answerGate, closeDeferred, finishGate, recommendGate } from '../lib/autoplan-gate';
|
||||
import { amendmentSteps, answerGate, closeDeferred, recommendGate } from '../lib/autoplan-gate';
|
||||
import { checkCeoOnlyFlags, finishRun, refuseCeoOnly } from '../lib/autoplan-ceo-only';
|
||||
import { gateCost, gateCostPayload, renderGateCost, type GateCost } from '../lib/autoplan-gate-cost';
|
||||
import { attemptEstimate, changeBudget, escalateLine, liftStop } from '../lib/autoplan-budget';
|
||||
import { DEFAULT_CARRY_CHECK_CAP_USD, carryLines, submitCarryCheck } from '../lib/autoplan-carry';
|
||||
import { DEFAULT_CARRY_CHECK_CAP_USD, submitCarryCheck } from '../lib/autoplan-carry';
|
||||
import { ANSWERED_BY_RE } from '../lib/gate-list';
|
||||
import { checkpointLines, checkpointRun, restoreCheckpoint, type CheckpointMoment } from '../lib/autoplan-checkpoint';
|
||||
import { admit, touchInstallLease } from '../lib/autoplan-admission';
|
||||
@@ -43,11 +44,13 @@ import { capyRelayLine } from '../lib/captain-brief';
|
||||
import { exportPublic } from '../lib/autoplan-public';
|
||||
import { migrateRun } from '../lib/autoplan-migrate';
|
||||
import { reviseRun } from '../lib/autoplan-revise';
|
||||
import { pendingAmendments, type RunState } from '../lib/autoplan-state';
|
||||
import { FILES, RunError, currentPhase, initRun, markOutside, nextStep, readState, resumeRun, statusLines, submitResult, withRunLock, writeState } from '../lib/autoplan-run';
|
||||
import { pendingAmendments, type RunState, type Voice } from '../lib/autoplan-state';
|
||||
import { FILES, RunError, currentPhase, fitReservation, initRun, markOutside, markSent, nextStep, readState, resumeRun, retireAttempt, statusLines, submitResult, withRunLock, writeState, type NextOptions } from '../lib/autoplan-run';
|
||||
import { driverFile, type Phase } from '../lib/autoplan-prompts';
|
||||
import { EXIT, exitTableHelp } from '../lib/headless-artifacts';
|
||||
import { createRunner, familyConflict, type RunnerId } from '../lib/outside-voice-runner';
|
||||
import { DEFAULT_MAX_TOKENS, apiTimeoutMs } from '../lib/outside-voice-api';
|
||||
import { estimateCostUsd, pricingRow } from '../lib/pricing';
|
||||
import { resultLine } from '../lib/result-codes';
|
||||
import { release } from '../lib/spend-ledger';
|
||||
import { COORDINATOR_CONTRACT } from '../lib/coordinator-contract';
|
||||
@@ -55,11 +58,12 @@ import { COORDINATOR_CONTRACT } from '../lib/coordinator-contract';
|
||||
const HELP = `gstack-autoplan — unattended /autoplan: next/submit loop, answer, resume (docs/unattended.md)
|
||||
DRIVER: ${driverFile()}
|
||||
|
||||
next --out <dir> [--plan <file>] [--plan-heading <text>] [--scaffold] [--ui|--no-ui] [--developer-tool] [--agent-primary] [--no-dx] [--docs-only]
|
||||
next --out <dir> [--plan <file>] [--plan-heading <text>] [--scaffold] [--ui|--no-ui] [--developer-tool] [--agent-primary] [--no-dx] [--docs-only] [--ceo-only]
|
||||
[--no-outside] [--light] [--spend-cap <usd>] [--max-rounds <n>] [--estimate-usd <n>] [--deadline <min>] [--redispatch <attempt>] [--json]
|
||||
--max-rounds (first next): review rounds per phase (default 2 unattended); at the cap a phase closes accepted-with-findings
|
||||
--docs-only (first next; also detected when every Implementation-plan path is docs-class): CEO (native voice only) + Eng
|
||||
--estimate-usd: reserved per new attempt; unattended Capy runs reserve a default for host attempts so --spend-cap binds
|
||||
--ceo-only (first next, command line only): the CEO phase alone, no gate; ends status=reviewed with <out>/ceo-review.md, never approved
|
||||
--estimate-usd: reserved per new attempt, kept for the rest of the run; with --spend-cap any run (or an unattended run on Capy) reserves a default so the cap binds
|
||||
--scaffold (first next): a plan missing one of its two sections is reviewed from <out>/plan-source.md with the heading added
|
||||
--light loads each phase's checklist plus the sections its scope needs (the unattended default); interactive kinds load everything
|
||||
--no-carry-forward (first next): every later gate revision goes to the owner, even one that asks nothing new
|
||||
@@ -80,9 +84,12 @@ DRIVER: ${driverFile()}
|
||||
comes into scope, the edit applies without re-review (APPLIED:); --withdraw (alone or with --plan) drops one;
|
||||
--settle records a packaging/ordering change that needs no plan edit and reopens nothing (ids: decision id or d4)
|
||||
run --out <dir> --plan <file> --native-model <id> --outside-model <id> [--runner codex-cli|api] [--native-runner api] ...
|
||||
next in a loop, only when a runner executes both voices here; --deadline cancels the children it started
|
||||
next in a loop, only when a runner executes both voices here (hosts without subagents: --native-runner api --runner api);
|
||||
--deadline cancels the children it started; --max-tokens caps each api call's output (default 16384; OpenAI counts reasoning tokens in it), and a
|
||||
response cut off at that limit is reported truncated; submit's result checks judge every completed response
|
||||
answer --out <dir> --gate-rev <rev> --reply "<rec | rec except d3a | d3b uc1a | as-is>" --by <human|agent:<id>> [--json]
|
||||
--by agent:<id> needs --owner-reply "<owner's words>" (- reads stdin; --owner-reply-file <path>), stored verbatim;
|
||||
--words-by agent:<id> (the same agent) when those words are its own: recorded as its words, never the owner's, never carried;
|
||||
--rec-hash <h> binds a rec to the render the owner saw (required once recommend ran); accepted changes leave amend_pending
|
||||
recommend --out <dir> --gate-rev <n> --item <id> --option <k> --basis "<why>" [--by <who>]
|
||||
set one item's recommendation before relay (provenance recorded, p1 follows); re-render and relay afterwards
|
||||
@@ -178,24 +185,26 @@ function emit(json: boolean, lines: string[], payload: Record<string, unknown>):
|
||||
else process.stdout.write(lines.join('\n') + '\n');
|
||||
}
|
||||
|
||||
function doNext(out: string, rest: string[], json: boolean): number {
|
||||
function doNext(out: string, rest: string[], json: boolean, price?: NextOptions['price']): number {
|
||||
const cwd = process.cwd();
|
||||
const initLines: string[] = [];
|
||||
const fresh = !fs.existsSync(path.join(out, FILES.run));
|
||||
if (!fresh) checkCeoOnlyFlags(rest, undefined, readState(out));
|
||||
if (fresh) {
|
||||
const plan = flag(rest, '--plan');
|
||||
if (!plan) throw new RunError('RUN_NOT_INITIALIZED', `${out} has no run.json; pass --plan <file> on the first next`, 3);
|
||||
const ceoOnly = checkCeoOnlyFlags(rest, path.resolve(plan), undefined, out);
|
||||
const ui = rest.includes('--ui') ? true : rest.includes('--no-ui') ? false : undefined;
|
||||
const maxRounds = num(rest, '--max-rounds');
|
||||
if (maxRounds !== undefined && (!Number.isInteger(maxRounds) || maxRounds < 1)) throw new RunError('ARTIFACT_SCHEMA', '--max-rounds needs an integer >= 1', 1);
|
||||
const carryCheckCapUsd = num(rest, '--carry-check-cap');
|
||||
if (carryCheckCapUsd !== undefined && !(carryCheckCapUsd >= 0)) throw new RunError('ARTIFACT_SCHEMA', '--carry-check-cap needs a number >= 0', 1);
|
||||
initRun({ plan, out, cwd, planHeading: flag(rest, '--plan-heading'), outsideEnabled: !rest.includes('--no-outside'), spendCap: num(rest, '--spend-cap') ?? null, maxRounds,
|
||||
carryForward: !rest.includes('--no-carry-forward'), carryCheckCapUsd,
|
||||
carryForward: !rest.includes('--no-carry-forward'), carryCheckCapUsd, ceoOnly,
|
||||
deadlineMin: num(rest, '--deadline'), light: rest.includes('--light'), scaffold: rest.includes('--scaffold'), scope: { ui, developerTool: rest.includes('--developer-tool'), agentPrimary: rest.includes('--agent-primary'), dx: rest.includes('--no-dx') ? false : undefined, ...(rest.includes('--docs-only') ? { docsOnly: true } : {}) } }, initLines);
|
||||
}
|
||||
const redispatch = rest.flatMap((a, i) => (a === '--redispatch' && rest[i + 1] ? [rest[i + 1]!] : []));
|
||||
const r = nextStep({ out, cwd, estimateUsd: num(rest, '--estimate-usd'), redispatch });
|
||||
const r = nextStep({ out, cwd, estimateUsd: num(rest, '--estimate-usd'), redispatch, price });
|
||||
moment = fresh ? 'init' : r.attempts.some(a => a.new) || r.lines.some(l => l.startsWith('PHASE_DEFERRED: ')) ? 'transition' : undefined;
|
||||
const status = r.state.status;
|
||||
const lines = [...initLines, ...r.lines];
|
||||
@@ -248,6 +257,7 @@ function doRevise(out: string, rest: string[], json: boolean): number {
|
||||
const plan = flag(rest, '--plan'); const incorporates = listFlag(rest, '--incorporates'); const withdraw = listFlag(rest, '--withdraw'); const settle = listFlag(rest, '--settle');
|
||||
if (!plan && !withdraw.length && !settle.length) return usage('revise needs --plan <file> (and optionally --incorporates <id,...>), --withdraw <decision id> or --settle <id>');
|
||||
if (!plan && incorporates.length) return usage('--incorporates binds accepted changes to a revised plan: pass --plan <file>');
|
||||
refuseCeoOnly(readState(out), 'revise');
|
||||
const r = reviseRun({ out, plan: plan ? path.resolve(plan) : undefined, incorporates, withdraw, settle, cwd: process.cwd() });
|
||||
const lines = [...r.lines];
|
||||
if (r.state.status === 'running' || r.state.status === 'amend_pending') lines.push(nextLine(r.state, out));
|
||||
@@ -268,7 +278,7 @@ function doClose(out: string, rest: string[], json: boolean): number {
|
||||
const r = closeDeferred(state, phase as Phase, { reason: reason.trim(), by, cause: 'owner' });
|
||||
moment = 'transition';
|
||||
const lines = [...r.lines, ...(stopped ? liftStop(state, `${phase} closed deferred by ${by}`) : [])];
|
||||
if (!currentPhase(state)) { finishGate(state); lines.push(...carryLines(state), ...(state.status === 'gate_pending' ? gateLines(state, out) : [])); }
|
||||
if (!currentPhase(state)) lines.push(...finishRun(state), ...(state.status === 'gate_pending' ? gateLines(state, out) : []));
|
||||
else { state.status = 'running'; lines.push(`NEXT: gstack-autoplan next --out ${out}`); }
|
||||
writeState(state);
|
||||
lines.push(resultLineFor(state.status, out));
|
||||
@@ -305,10 +315,25 @@ async function doRun(out: string, rest: string[], json: boolean): Promise<number
|
||||
if (!['codex-cli', 'api'].includes(outsideRunner)) return usage('--runner must be codex-cli or api for run (host-subagent cannot execute here; use next/submit)');
|
||||
const conflict = familyConflict(outsideModel, nativeModel);
|
||||
if (conflict) throw new RunError('MODEL_FAMILY_CONFLICT', conflict, 3);
|
||||
const maxTokens = num(rest, '--max-tokens');
|
||||
if (maxTokens !== undefined && (!Number.isInteger(maxTokens) || maxTokens <= 0)) return usage('--max-tokens needs a positive integer');
|
||||
const deadline = num(rest, '--deadline');
|
||||
const deadlineAt = deadline ? Date.now() + deadline * 60_000 : undefined;
|
||||
const modelOf = (voice: Voice) => (voice === 'native' ? nativeModel : outsideModel);
|
||||
const isApi = (voice: Voice) => (voice === 'native' ? nativeRunner : outsideRunner) === 'api';
|
||||
// An api call reserves its worst case (prompt bytes / 3 input tokens plus the output limit it is sent) and settles at its usage.
|
||||
const price = (voice: Voice, promptFile: string) => {
|
||||
const row = isApi(voice) ? pricingRow(modelOf(voice)) : undefined;
|
||||
return row ? +((fs.statSync(promptFile).size / 3 * row.input_per_mtok + (maxTokens ?? DEFAULT_MAX_TOKENS) * row.output_per_mtok) / 1e6).toFixed(6) : undefined;
|
||||
};
|
||||
const usdOf = (voice: Voice, used: { input_tokens?: number; output_tokens?: number } | undefined) =>
|
||||
isApi(voice) && pricingRow(modelOf(voice)) && typeof used?.input_tokens === 'number' && typeof used?.output_tokens === 'number' ? estimateCostUsd({ input: used.input_tokens, output: used.output_tokens }, modelOf(voice)) : undefined;
|
||||
// Every exit after a paid call went out settles its attempt: a failure, a cancel, a refused result, or a process that died
|
||||
// mid-call (sent_at, found on the next run) settles at its usage, or its reservation when the usage is unknown. Only a call
|
||||
// refused before any request left this machine bills nothing, so its attempt stays open for the rerun.
|
||||
const NOT_SENT = new Set(['runner_unavailable', 'cli_missing', 'model_unselected']);
|
||||
for (let guard = 0; guard < 40; guard++) {
|
||||
const code = withRunLock(out, () => doNext(out, rest, true));
|
||||
const code = withRunLock(out, () => doNext(out, rest, true, price));
|
||||
if (code !== EXIT.ok) return code;
|
||||
const state = readState(out);
|
||||
if (state.status !== 'awaiting_result') {
|
||||
@@ -316,27 +341,39 @@ async function doRun(out: string, rest: string[], json: boolean): Promise<number
|
||||
process.stdout.write(resultLineFor(state.status, out) + '\n'); return EXIT.ok;
|
||||
}
|
||||
const phase = state.phase_order.find(p => state.phases[p]?.snapshot && !state.phases[p]?.closed_at)!;
|
||||
const lost = (['native', 'outside'] as const).flatMap(voice => (state.phases[phase]![voice]!.attempts ?? []).filter(a => a.state === 'dispatched' && a.sent_at).map(a => [voice, a.attempt] as const));
|
||||
if (lost.length) { withRunLock(out, () => { for (const [voice, attempt] of lost) retireAttempt(out, phase as Phase, voice, attempt, 'unknown'); }); continue; }
|
||||
for (const voice of ['native', 'outside'] as const) {
|
||||
const v = state.phases[phase]![voice]!;
|
||||
const open = v.attempts?.find(a => a.state === 'dispatched');
|
||||
if (!open) continue;
|
||||
const runnerId = voice === 'native' ? nativeRunner : outsideRunner;
|
||||
const cost = price(voice, open.prompt);
|
||||
if (cost !== undefined) withRunLock(out, () => fitReservation(out, phase as Phase, voice, open.attempt, cost));
|
||||
const runner = createRunner(runnerId);
|
||||
const remaining = deadlineAt ? deadlineAt - Date.now() : 300_000;
|
||||
const remaining = deadlineAt ? deadlineAt - Date.now() : runnerId === 'api' ? apiTimeoutMs(maxTokens) : 300_000;
|
||||
if (remaining <= 0) { withRunLock(out, () => { const s = readState(out); s.status = 'interrupted'; writeState(s); }); process.stdout.write(resultLineFor('interrupted', out) + '\n'); return EXIT.fail; }
|
||||
const timer = deadlineAt ? setTimeout(() => runner.cancel(), remaining) : undefined;
|
||||
const res = await runner.run({ promptFile: open.prompt, outFile: `${open.result}.raw`, model: voice === 'native' ? nativeModel : outsideModel, timeoutMs: Math.min(remaining, 600_000), cwd: process.cwd(), gate: 'review' });
|
||||
withRunLock(out, () => markSent(out, phase as Phase, voice, open.attempt, true));
|
||||
const res = await runner.run({ promptFile: open.prompt, outFile: `${open.result}.raw`, model: voice === 'native' ? nativeModel : outsideModel, timeoutMs: Math.min(remaining, runnerId === 'api' ? apiTimeoutMs(maxTokens) : 600_000), cwd: process.cwd(), gate: 'execution', maxTokens: runnerId === 'api' ? maxTokens ?? DEFAULT_MAX_TOKENS : maxTokens });
|
||||
if (timer) clearTimeout(timer);
|
||||
if (!json) process.stdout.write(`VOICE: ${phase} ${voice} ${res.status} runner=${res.runner} model=${res.model ?? 'unknown'}\n`);
|
||||
if (res.status === 'cancelled') { withRunLock(out, () => { const s = readState(out); s.status = 'interrupted'; writeState(s); }); process.stdout.write(resultLineFor('interrupted', out) + '\n'); return EXIT.fail; }
|
||||
const retire = () => { if (NOT_SENT.has(res.reason ?? '')) markSent(out, phase as Phase, voice, open.attempt, false); else retireAttempt(out, phase as Phase, voice, open.attempt, usdOf(voice, res.usage) ?? 'unknown'); };
|
||||
if (res.status === 'cancelled') { withRunLock(out, () => { retire(); const s = readState(out); s.status = 'interrupted'; writeState(s); }); process.stdout.write(resultLineFor('interrupted', out) + '\n'); return EXIT.fail; }
|
||||
if (res.status !== 'completed' && res.status !== 'unverified') {
|
||||
if (voice === 'outside') { withRunLock(out, () => markOutside(out, phase as Phase, 'unavailable', `${res.runner} ${res.status}${res.detail ? `: ${res.detail}` : ''}`)); continue; }
|
||||
process.stderr.write(`gstack-autoplan run: native voice ${res.status}${res.detail ? `: ${res.detail}` : ''}\n`);
|
||||
withRunLock(out, () => { const s = readState(out); s.status = 'interrupted'; writeState(s); });
|
||||
process.stderr.write(`gstack-autoplan run: native voice ${res.status}${res.reason ? `: ${res.reason}` : ''}${res.detail ? `: ${res.detail}` : ''}\n`);
|
||||
withRunLock(out, () => { retire(); const s = readState(out); s.status = 'interrupted'; writeState(s); });
|
||||
process.stdout.write(resultLineFor('interrupted', out) + '\n');
|
||||
return EXIT.fail;
|
||||
}
|
||||
withRunLock(out, () => doSubmit(out, ['--phase', phase, '--voice', voice, '--result', `${open.result}.raw`, '--model', res.model ?? (voice === 'native' ? nativeModel : outsideModel), '--attempt', open.attempt, '--runner', res.runner], true));
|
||||
const usd = usdOf(voice, res.usage);
|
||||
const usage = usd === undefined ? [] : ['--usage-usd', String(usd)];
|
||||
try { withRunLock(out, () => doSubmit(out, ['--phase', phase, '--voice', voice, '--result', `${open.result}.raw`, '--model', res.model ?? modelOf(voice), '--attempt', open.attempt, '--runner', res.runner, ...usage], true)); }
|
||||
catch (e) {
|
||||
if (voice !== 'outside' || !(e instanceof RunError)) { withRunLock(out, () => { retire(); const s = readState(out); s.status = 'interrupted'; writeState(s); }); throw e; }
|
||||
withRunLock(out, () => markOutside(out, phase as Phase, 'unavailable', `${res.runner} result refused: ${e.code}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
return EXIT.fail;
|
||||
@@ -383,13 +420,16 @@ async function main(argv: string[]): Promise<number> {
|
||||
if (cmd === 'answer') {
|
||||
const rev = flag(rest, '--gate-rev'); const reply = flag(rest, '--reply'); const by = flag(rest, '--by');
|
||||
if (reply === undefined) return usage('answer needs --reply "<text>"');
|
||||
if (rev === undefined) { const s = readState(out); process.stderr.write(resultLine('gstack-autoplan answer', 'GATE_REV_STALE', `no --gate-rev given; current gate_rev=${s.gate_rev ?? 0}`) + '\n'); return EXIT.refused; }
|
||||
if (rev === undefined) { const s = readState(out); refuseCeoOnly(s, 'answer'); process.stderr.write(resultLine('gstack-autoplan answer', 'GATE_REV_STALE', `no --gate-rev given; current gate_rev=${s.gate_rev ?? 0}`) + '\n'); return EXIT.refused; }
|
||||
const replyFile = flag(rest, '--owner-reply-file'); const ownerFlag = flag(rest, '--owner-reply');
|
||||
if (replyFile !== undefined && ownerFlag !== undefined) return usage('--owner-reply and --owner-reply-file are exclusive');
|
||||
const wordsBy = flag(rest, '--words-by') ?? 'owner';
|
||||
if (wordsBy !== 'owner' && (wordsBy !== by || !by.startsWith('agent:'))) return usage(`--words-by must be owner or the answering agent (--by ${by ?? '<unset>'}), got ${wordsBy}`);
|
||||
const ownerReply = replyFile !== undefined ? fs.readFileSync(replyFile, 'utf8') : ownerFlag === '-' ? fs.readFileSync(0, 'utf8') : ownerFlag;
|
||||
return mutating(out, json, () => {
|
||||
const state = readState(out);
|
||||
const r = answerGate(state, Number(rev), reply, by, { recHash: flag(rest, '--rec-hash'), ownerReply });
|
||||
refuseCeoOnly(state, 'answer');
|
||||
const r = answerGate(state, Number(rev), reply, by, { recHash: flag(rest, '--rec-hash'), ownerReply, ...(wordsBy === 'owner' ? {} : { wordsBy }) });
|
||||
writeState(state);
|
||||
const lines = [...r.lines];
|
||||
if (r.outcome === 'reopened' || r.outcome === 'amend_pending') lines.push(nextLine(state, out));
|
||||
@@ -403,6 +443,7 @@ async function main(argv: string[]): Promise<number> {
|
||||
if (rev === undefined || !item || !option || !basis?.trim()) return usage('recommend needs --gate-rev <n> --item <id> --option <k> --basis "<why>"');
|
||||
return mutating(out, json, () => {
|
||||
const state = readState(out);
|
||||
refuseCeoOnly(state, 'recommend');
|
||||
const lines = recommendGate(state, Number(rev), item.toLowerCase(), option.toLowerCase(), basis.trim(), flag(rest, '--by') ?? 'parent');
|
||||
writeState(state);
|
||||
emit(json, [...lines, resultLineFor(state.status, out)], { run: state.run, status: state.status, gate_rev: state.gate_rev, out });
|
||||
|
||||
@@ -55,7 +55,7 @@ For each finding: what's wrong, severity (critical/high/medium), and the fix.`,
|
||||
reviewing this plan. You have NOT seen any prior review. Evaluate:
|
||||
1. Getting started: how many steps from zero to hello world? What's the TTHW?
|
||||
2. API/CLI ergonomics: naming consistency, sensible defaults, progressive disclosure?
|
||||
3. Error handling: Score every error path against the agent-ready error contract (https://github.com/garrytan/gstack/blob/main/docs/agent-ready-errors.md): what (operation and actual values), fix (a safe, authorized command or edit on the first or last line, or the precise thing a human must provide), recovery (retry, fix and retry, do X first, or ask a human; side effects none/partial/complete/unknown, reconcile before retrying an unknown outcome, a retry bound), code (stable, with a doc anchor), context (versions, config, env var names, the underlying cause; caller-safe only for remote services), trace (unexpected failures only: a compact trace or its file path locally, a correlation id remotely), redaction (no secret values anywhere), channel (the surface's native failure signal: non-zero exit and stderr for a CLI, status plus RFC 9457 body for an API, a typed error for an SDK, isError for an MCP tool). Usage and validation errors rejected before any work need only what, fix, code, redaction and channel; items apply per surface, N/A needs a reason, an item the plan never specifies for a path it introduces is a gap, and unknown is reserved for existing behavior you could not inspect. Report a leaked secret, a failure signalled as success, an unsafe fix, or a block on a terminal (a hang, or a no-terminal failure that names no non-interactive alternative such as --yes) as a Critical finding titled "CAP4:<path>:<item>" (caps the Error Messages score at 4/10); report a missing applicable fix, recovery, context or trace as a High finding titled "CAP6:<path>:<item>" (caps it at 6/10).
|
||||
3. Error handling: Score every error path against the agent-ready error contract (https://github.com/garrytan/gstack/blob/main/docs/agent-ready-errors.md): what (operation and actual values), fix (a safe, authorized command or edit on the first or last line, or what a human must provide), recovery (retry, fix and retry, do X first, or ask a human; side effects none/partial/complete/unknown, reconcile before retrying an unknown outcome, a retry bound), code (stable, with a doc anchor), context (versions, config, env var names, the underlying cause; caller-safe only for remote services), trace (unexpected failures only: a compact trace or its file path locally, a correlation id remotely), redaction (no secret values anywhere), channel (the surface's native failure signal: non-zero exit and stderr for a CLI, status plus RFC 9457 body for an API, a typed error for an SDK, isError for an MCP tool). Usage and validation errors rejected before any work need only what, fix, code, redaction and channel; items apply per surface, N/A needs a reason, an item the plan never specifies for a path it introduces is a gap, and unknown is reserved for existing behavior you could not inspect. Report a leaked secret, a failure signalled as success, an unsafe fix, or a block on a terminal (a hang, or a no-terminal failure naming no non-interactive alternative such as --yes) as a Critical finding titled "CAP4:<path>:<item> <what fails>" (caps Error Messages at 4/10); report a missing applicable fix, recovery, context or trace as a High finding titled "CAP6:<path>:<item> <what fails>" (caps it at 6/10).
|
||||
4. Documentation: copy-paste examples? Information architecture? Interactive elements?
|
||||
5. Escape hatches: can developers override every opinionated default?
|
||||
For each finding: what's wrong, severity (critical/high/medium), and the fix.`,
|
||||
|
||||
+128
-12
@@ -38,11 +38,11 @@
|
||||
# applies; `ok` and `idle` print none. <bin> is this script's absolute path
|
||||
# and <N> the held count. Rows are read by test/gstack-skill-start.test.ts.
|
||||
#
|
||||
# held trigger: status=held: the secret scan held back flagged files and synced the rest
|
||||
# line: ARTIFACTS_SYNC: attention: status=held held=<N>. The secret scan is holding back <N> file(s); everything else still syncs. See which files and how to fix them: <bin> --status
|
||||
# fix: edit the file so the scan no longer matches, or <bin> --skip-file <path> (permanent; undo with <bin> --unskip-file <path>)
|
||||
# held trigger: status=held: the secret scan or the JSON check held back flagged files and synced the rest
|
||||
# line: ARTIFACTS_SYNC: attention: status=held held=<N>. Sync is holding back <N> file(s) that the secret scan flagged or that are not valid JSON; everything else still syncs. See which files and how to fix them: <bin> --status
|
||||
# fix: edit the file so the scan no longer matches (or repair a file that is not valid JSON), or <bin> --skip-file <path> (permanent; undo with <bin> --unskip-file <path>)
|
||||
# blocked trigger: status=blocked: a re-scan still flagged after the hold, so nothing was committed
|
||||
# line: ARTIFACTS_SYNC: attention: status=blocked. The secret scan flagged a file it could not hold back on its own, so nothing is syncing. See the fix: <bin> --status
|
||||
# line: ARTIFACTS_SYNC: attention: status=blocked. Sync committed nothing: a flagged file could not be held back on its own, or held files are part of an unfinished git merge. See the fix: <bin> --status
|
||||
# fix: edit or skip the files named by <bin> --status, or clear the batch with <bin> --drop-queue --yes
|
||||
# push_failed trigger: status=push_failed: a commit is saved locally but the push failed
|
||||
# line: ARTIFACTS_SYNC: attention: status=push_failed. Synced files are committed locally but the push failed; it retries at skill start. See the cause: <bin> --status
|
||||
@@ -144,7 +144,7 @@ remote_auth_hint() {
|
||||
# Status file schema (one JSON object; readers: --status, --attention, doctor):
|
||||
# status ok | idle | held | collision | blocked | push_failed | error
|
||||
# (collision: files were held only for case-colliding paths)
|
||||
# held_count files the secret scan held back this drain (hits + dependents)
|
||||
# held_count files the secret scan or the JSON check held back this drain (hits + dependents)
|
||||
# case_held queued files held because their path differs only by letter
|
||||
# case from another path in the repo or the batch
|
||||
# case_names the colliding path component names, comma-separated and
|
||||
@@ -585,6 +585,60 @@ transcripts_consented() {
|
||||
transcript_policy --check 2>/dev/null
|
||||
}
|
||||
|
||||
# After a rejected push: merge origin/<branch> into HEAD (#3704). git refuses
|
||||
# a merge that would overwrite uncommitted edits to tracked files, and that
|
||||
# refusal used to repeat on every run, so local edits to the paths the merge
|
||||
# changes are set aside first and put back after; other local edits stay in
|
||||
# place, and only a stash entry this call created is put back. Paths are
|
||||
# literal, never patterns. A failed merge is aborted, so no half-finished merge
|
||||
# or conflict markers block the next commit; git's reason lands in MERGE_ERR.
|
||||
# When putting an edit back conflicts with what the merge brought in, the
|
||||
# merged version stays in the working tree, so a later sync never publishes
|
||||
# the stale local side over the other machine's change, and the local edit
|
||||
# stays in the kept stash entry, which the status names with the command
|
||||
# that restores it.
|
||||
MERGE_ERR=""
|
||||
merge_origin_keeping_local_edits() {
|
||||
local branch="$1" out rc=0 p stash_before="" stash_sha=""
|
||||
local -a lit=(git --literal-pathspecs -C "$GSTACK_HOME") blocking=()
|
||||
while IFS= read -r -d '' p; do
|
||||
"${lit[@]}" diff --quiet --no-renames HEAD "origin/$branch" -- "$p" 2>/dev/null || blocking+=("$p")
|
||||
done < <("${lit[@]}" diff -z --no-renames --name-only HEAD 2>/dev/null)
|
||||
if [ "${#blocking[@]}" -gt 0 ]; then
|
||||
stash_before=$("${lit[@]}" rev-parse -q --verify refs/stash 2>/dev/null)
|
||||
if ! out=$("${lit[@]}" stash push -q -m "gstack-brain-sync: local edits before merging origin/$branch" -- "${blocking[@]}" 2>&1); then
|
||||
MERGE_ERR="could not set local edits aside: $(printf '%s\n' "$out" | head -1)"
|
||||
return 1
|
||||
fi
|
||||
stash_sha=$("${lit[@]}" rev-parse -q --verify refs/stash 2>/dev/null)
|
||||
[ "$stash_sha" != "$stash_before" ] || stash_sha=""
|
||||
fi
|
||||
out=$("${lit[@]}" merge --no-edit "origin/$branch" 2>&1) || rc=$?
|
||||
if [ "$rc" -ne 0 ]; then
|
||||
MERGE_ERR=$(printf '%s\n' "$out" | grep -m1 -E '^(CONFLICT|error|fatal)' || printf '%s\n' "$out" | head -1)
|
||||
"${lit[@]}" merge --abort >/dev/null 2>&1 || true
|
||||
fi
|
||||
[ -n "$stash_sha" ] || return "$rc"
|
||||
out=$("${lit[@]}" stash pop -q 2>&1) && return "$rc"
|
||||
local paths="" args="" failed=0
|
||||
while IFS= read -r -d '' p; do
|
||||
if "${lit[@]}" cat-file -e "HEAD:$p" 2>/dev/null; then
|
||||
"${lit[@]}" checkout -q HEAD -- "$p" >/dev/null 2>&1 || failed=1
|
||||
else
|
||||
{ "${lit[@]}" rm -q --cached -- "$p" >/dev/null 2>&1 && rm -f -- "$GSTACK_HOME/$p"; } || failed=1
|
||||
fi
|
||||
paths="$paths ${p}"
|
||||
args="$args $(printf '%q' "$p")"
|
||||
done < <("${lit[@]}" diff -z --name-only --diff-filter=U 2>/dev/null)
|
||||
"${lit[@]}" reset -q >/dev/null 2>&1 || failed=1
|
||||
if [ -z "$paths" ] || [ "$failed" = 1 ]; then
|
||||
MERGE_ERR="local edits could not be put back after merging origin/$branch ($(printf '%s\n' "$out" | head -1)); they are saved in git -C $GSTACK_HOME stash list"
|
||||
return 1
|
||||
fi
|
||||
ST_NOTE="$ST_NOTE; local edits to$paths conflicted with the merged remote version, which is kept; the local edits are saved in stash $stash_sha (get them back: git -C $GSTACK_HOME --literal-pathspecs restore --source=$stash_sha --$args)"
|
||||
return "$rc"
|
||||
}
|
||||
|
||||
# Before every push: scan the unpublished range for transcript pages outside
|
||||
# the current consent; when some exist, the helper rewrites gstack-brain-sync's
|
||||
# own unpublished commits without them, or refuses (exit 3) when it cannot.
|
||||
@@ -899,6 +953,8 @@ for rule, path in hits:
|
||||
"rule": rule,
|
||||
"dependents": deps,
|
||||
"fixes": [
|
||||
f"{path} is not valid JSON (often git conflict markers from a merge); repair it and the next sync re-checks it"
|
||||
] if rule == "invalid-json" else [
|
||||
f"edit {path} so the secret scan no longer matches ({rule}); the next sync re-scans it",
|
||||
f"{self_bin} --skip-file {path} (permanently excludes it{also} from future syncs; undo with {self_bin} --unskip-file {path})",
|
||||
],
|
||||
@@ -928,6 +984,52 @@ for path, rows in ((unstage_out, unstage), (wait_out, waiting)):
|
||||
PYEOF
|
||||
}
|
||||
|
||||
# Append "invalid-json<TAB><path>" to the hits file $2 for every staged *.json
|
||||
# path in the NUL-separated names file $1 whose index blob does not parse
|
||||
# (#3763): a git merge can leave conflict markers in a synced JSON file, and
|
||||
# committing it spreads an unreadable file to every machine. A path the
|
||||
# secret scan already flagged is held for that reason alone. One
|
||||
# `git cat-file --batch` reads the index blobs; a staged deletion has none.
|
||||
append_invalid_json_hits() {
|
||||
python3 - "$1" "$2" "$1.json" <<'PYEOF' || return 1
|
||||
import sys
|
||||
names_path, hits_path, out_path = sys.argv[1:4]
|
||||
with open(names_path, "rb") as f:
|
||||
names = [p for p in f.read().decode("utf-8", "surrogateescape").split("\0") if p.endswith(".json") and "\n" not in p]
|
||||
with open(hits_path, encoding="utf-8", errors="surrogateescape") as f:
|
||||
flagged = {l.rstrip("\r\n").split("\t", 1)[-1] for l in f if "\t" in l}
|
||||
with open(out_path, "w", encoding="utf-8", errors="surrogateescape", newline="\n") as out:
|
||||
for p in names:
|
||||
if p not in flagged:
|
||||
out.write(":" + p + "\n")
|
||||
PYEOF
|
||||
[ -s "$1.json" ] || return 0
|
||||
git -C "$GSTACK_HOME" cat-file --batch < "$1.json" > "$1.json.blobs" 2>/dev/null || return 1
|
||||
python3 - "$1.json" "$1.json.blobs" "$2" <<'PYEOF'
|
||||
import json, sys
|
||||
batch_path, blobs_path, hits_path = sys.argv[1:4]
|
||||
with open(batch_path, encoding="utf-8", errors="surrogateescape") as f:
|
||||
names = [l[1:].rstrip("\n") for l in f if l.startswith(":")]
|
||||
with open(blobs_path, "rb") as f:
|
||||
data = f.read()
|
||||
pos = 0
|
||||
with open(hits_path, "a", encoding="utf-8", errors="surrogateescape", newline="\n") as out:
|
||||
for p in names:
|
||||
nl = data.index(b"\n", pos)
|
||||
header = data[pos:nl].split(b" ")
|
||||
pos = nl + 1
|
||||
if header[-1] == b"missing":
|
||||
continue
|
||||
size = int(header[2])
|
||||
blob = data[pos:pos + size]
|
||||
pos += size + 1
|
||||
try:
|
||||
json.loads(blob.decode("utf-8"))
|
||||
except ValueError:
|
||||
out.write("invalid-json\t" + p + "\n")
|
||||
PYEOF
|
||||
}
|
||||
|
||||
# Fail closed: unstage everything, keep the queue, status blocked.
|
||||
block_drain() {
|
||||
git -C "$GSTACK_HOME" reset -q HEAD -- . >/dev/null 2>&1 || true
|
||||
@@ -940,7 +1042,9 @@ block_drain() {
|
||||
# one scanner pass attributes findings to staged paths, plan_holds decides
|
||||
# what is held or waits, those paths are unstaged and leave $1 (their records
|
||||
# stay queued), then the whole remaining staged diff must re-scan clean.
|
||||
# Sets ST_HELD and WAITING. Returns 1 after falling back to block_drain.
|
||||
# Sets ST_HELD and WAITING. Returns 1 after falling back to block_drain, or
|
||||
# when a path would be held during an unfinished merge (the merge commit
|
||||
# would silently keep only this side of it).
|
||||
hold_flagged_paths() {
|
||||
local paths_file="$1" groups_file="$2" scan="$1.scan" rc=0 gate_rc=0 gate_out
|
||||
WAITING=0
|
||||
@@ -949,6 +1053,7 @@ hold_flagged_paths() {
|
||||
git -C "$GSTACK_HOME" diff --cached --no-renames > "$scan.diff" 2>/dev/null || rc=1
|
||||
git -C "$GSTACK_HOME" diff --cached --no-renames -z --name-only > "$scan.names" 2>/dev/null || rc=1
|
||||
[ "$rc" -eq 0 ] && { secret_scan attribute "$scan.diff" "$scan.names" "$scan.hits" || rc=1; }
|
||||
[ "$rc" -eq 0 ] && { append_invalid_json_hits "$scan.names" "$scan.hits" || rc=1; }
|
||||
# A successful add need not stage the requested spelling or current bytes
|
||||
# (case-insensitive indexes and assume-unchanged can both report success).
|
||||
# Reuse the scanner's name snapshot; refusal preserves the index and queue.
|
||||
@@ -969,6 +1074,12 @@ hold_flagged_paths() {
|
||||
ST_HELD=$(cat "$scan.held")
|
||||
WAITING=$(grep -c . "$scan.wait" 2>/dev/null || true)
|
||||
WAITING=${WAITING:-0}
|
||||
if [ -s "$scan.unstage" ] && git -C "$GSTACK_HOME" rev-parse -q --verify MERGE_HEAD >/dev/null 2>&1; then
|
||||
ST_DRAINABLE=0
|
||||
write_status "blocked" "a git merge in $GSTACK_HOME is unfinished and $(grep -c . "$scan.unstage") file(s) are held back or wait for their coupled group; committing the merge without them would drop the other side's changes. Repair the files held[] names (they stay queued and sync on the next run), or finish the merge yourself; --skip-file and --drop-queue do not clear this. Nothing was committed; the merge and the queue are kept"
|
||||
echo "BRAIN_SYNC: blocked: unfinished merge with held files" >&2
|
||||
return 1
|
||||
fi
|
||||
if [ -s "$scan.unstage" ]; then
|
||||
if ! tr -d '\r' < "$scan.unstage" | git --literal-pathspecs -C "$GSTACK_HOME" reset -q HEAD --pathspec-from-file=- >/dev/null 2>&1; then
|
||||
block_drain "git could not unstage the held files"
|
||||
@@ -1293,11 +1404,13 @@ subcmd_once() {
|
||||
hold_flagged_paths "$paths_file" "$groups_file" || exit 0
|
||||
ST_DRAINABLE=$(( ADD_FAILED + WAITING ))
|
||||
[ "$WAITING" -gt 0 ] && ST_NOTE="$ST_NOTE; $WAITING file(s) of a coupled group wait for a consistent generation (still queued)"
|
||||
local held_n ok_code="ok"
|
||||
local held_n json_n ok_code="ok"
|
||||
held_n=$(printf '%s' "$ST_HELD" | grep -o '"rule"' | wc -l | tr -d ' ')
|
||||
json_n=$(printf '%s' "$ST_HELD" | grep -o '"rule": "invalid-json"' | wc -l | tr -d ' ')
|
||||
if [ "$held_n" -gt 0 ]; then
|
||||
ok_code="held"
|
||||
ST_NOTE="$ST_NOTE; the secret scan held back $held_n flagged file(s) (and any coupled files), NOT synced; see held[] for each file's fixes"
|
||||
[ "$held_n" -gt "$json_n" ] && ST_NOTE="$ST_NOTE; the secret scan held back $(( held_n - json_n )) flagged file(s) (and any coupled files), NOT synced; see held[] for each file's fixes"
|
||||
[ "$json_n" -gt 0 ] && ST_NOTE="$ST_NOTE; $json_n synced file(s) are not valid JSON (often git conflict markers) and were held back (and any coupled files), NOT synced and still queued; see held[] for each file's fixes"
|
||||
echo "BRAIN_SYNC: held $held_n flagged file(s); see $SELF_BIN --status" >&2
|
||||
elif [ "$ST_CASE_HELD" -gt 0 ]; then
|
||||
ok_code="collision"
|
||||
@@ -1379,7 +1492,7 @@ subcmd_once() {
|
||||
bash -c 'git -C "$1" fetch origin 2>/dev/null' _ "$GSTACK_HOME"; then
|
||||
local branch
|
||||
branch=$(git -C "$GSTACK_HOME" rev-parse --abbrev-ref HEAD 2>/dev/null || echo main)
|
||||
if git -C "$GSTACK_HOME" merge --no-edit "origin/$branch" >/dev/null 2>&1 && guard_unpublished_range; then
|
||||
if merge_origin_keeping_local_edits "$branch" && guard_unpublished_range; then
|
||||
validate_outgoing_paths || exit 0
|
||||
if GSTACK_HOME="$GSTACK_HOME" _receipted_git closed brain-sync "$push_host" curated-memory-git-push "artifacts_sync_mode!=off" \
|
||||
bash -c 'git -C "$1" push origin HEAD 2>/dev/null' _ "$GSTACK_HOME"; then
|
||||
@@ -1391,7 +1504,10 @@ subcmd_once() {
|
||||
fi
|
||||
fi
|
||||
# Commit exists locally; the run-start detector re-pushes it next time.
|
||||
write_status "push_failed" "push failed: $(printf '%s' "$push_err" | head -1); commit retained locally, will retry next run"
|
||||
local push_reason
|
||||
push_reason=$(printf '%s\n' "$push_err" | grep -v -E '^(To |hint:)' | head -1)
|
||||
[ -n "$push_reason" ] || push_reason=$(printf '%s\n' "$push_err" | head -1)
|
||||
write_status "push_failed" "push failed: $push_reason${MERGE_ERR:+; merging origin failed: $MERGE_ERR}; commit retained locally, will retry next run"
|
||||
finalize_queue "$snapshot_file" "$class_file" "$paths_file"
|
||||
exit 0
|
||||
}
|
||||
@@ -1495,8 +1611,8 @@ subcmd_attention() {
|
||||
case "$last_push" in $iso) ;; *) last_push="" ;; esac
|
||||
|
||||
case "$code" in
|
||||
held) echo "ARTIFACTS_SYNC: attention: status=held held=$held. The secret scan is holding back $held file(s); everything else still syncs. See which files and how to fix them: $bin --status" ;;
|
||||
blocked) echo "ARTIFACTS_SYNC: attention: status=blocked. The secret scan flagged a file it could not hold back on its own, so nothing is syncing. See the fix: $bin --status" ;;
|
||||
held) echo "ARTIFACTS_SYNC: attention: status=held held=$held. Sync is holding back $held file(s) that the secret scan flagged or that are not valid JSON; everything else still syncs. See which files and how to fix them: $bin --status" ;;
|
||||
blocked) echo "ARTIFACTS_SYNC: attention: status=blocked. Sync committed nothing: a flagged file could not be held back on its own, or held files are part of an unfinished git merge. See the fix: $bin --status" ;;
|
||||
push_failed) echo "ARTIFACTS_SYNC: attention: status=push_failed. Synced files are committed locally but the push failed; it retries at skill start. See the cause: $bin --status" ;;
|
||||
error) echo "ARTIFACTS_SYNC: attention: status=error. The last sync stopped before committing; the queue is kept and retried. See the cause: $bin --status" ;;
|
||||
unknown) echo "ARTIFACTS_SYNC: attention: status=unknown. The sync status file is unreadable. See it: $bin --status" ;;
|
||||
|
||||
@@ -70,6 +70,7 @@ while [ $# -gt 0 ]; do
|
||||
done
|
||||
[ -n "$FOR" ] && [ -n "$REQUIRE" ] && { echo "gstack-capy-install: --for and --require are exclusive" >&2; exit 2; }
|
||||
[ -z "$FOR" ] && [ -z "$REQUIRE" ] && REQUIRE="claude,codex,patch"
|
||||
[ -z "$PROJECT" ] || { _given=$PROJECT; PROJECT=$(CDPATH= cd -- "$_given" 2>/dev/null && { git rev-parse --show-toplevel 2>/dev/null || pwd -P; }) || { echo "gstack-capy-install: --project takes a directory path; '$_given' is not a directory (pass the project checkout's path, not a repo slug)" >&2; exit 2; }; }
|
||||
|
||||
say() { echo "gstack-capy-install: $*"; }
|
||||
stop() { # CODE MESSAGE FIX — print and exit 3 before ./setup
|
||||
@@ -148,7 +149,7 @@ STORE=$(gstack_bun_store "$GSTACK_STATE_ROOT")
|
||||
done
|
||||
INSTALL_VERSION=$(gstack_bun_install_version) || stop BUN_INSTALL_VERSION_INVALID "GSTACK_BUN_INSTALL_VERSION=$INSTALL_VERSION is not a verified Bun at or above gstack's supported minimum $GSTACK_BUN_TESTED (verified archives: $GSTACK_BUN_VERIFIED)" "unset GSTACK_BUN_INSTALL_VERSION, or set it to one of: $GSTACK_BUN_VERIFIED"
|
||||
[ -n "$PROJECT" ] || { PROJECT=$(cd "$ORIG_PWD" && git rev-parse --show-toplevel 2>/dev/null || true); [ "$(cd "${PROJECT:-/nonexistent}" 2>/dev/null && pwd -P)" = "$ROOT_REAL" ] && PROJECT=""; }
|
||||
PROJECT_REAL=""; [ -n "$PROJECT" ] && PROJECT_REAL=$(cd "$PROJECT" 2>/dev/null && pwd -P)
|
||||
PROJECT_REAL=""; [ -n "$PROJECT" ] && PROJECT_REAL=$(cd "$PROJECT" && pwd -P) && [ "$PROJECT_REAL" = "$ROOT_REAL" ] && PROJECT_REAL=""
|
||||
VERDICT=$(gstack_install_verdict_path "$GSTACK_STATE_ROOT" "$ROOT_REAL" "$PROJECT_REAL")
|
||||
REC_BUN="" REC_VER="" REC_RULE="" REC_BUN_INSTALL="" REC_FOR=""
|
||||
if [ -f "$VERDICT" ]; then
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
#!/usr/bin/env bash
|
||||
# gstack-ceo-mode-handoff — print /plan-ceo-review's Step 0E mode handoff line.
|
||||
#
|
||||
# Usage: gstack-ceo-mode-handoff <MODE> [--auto] [--decisions "<rows>"]
|
||||
# Usage: gstack-ceo-mode-handoff <MODE> [--auto | --ceo-only] [--decisions "<rows>"]
|
||||
# MODE: HOLD SCOPE | SCOPE EXPANSION | SELECTIVE EXPANSION | SCOPE REDUCTION
|
||||
# (underscores accepted for spaces)
|
||||
# --auto the mode came from a saved AUTO_DECIDE preference
|
||||
# --ceo-only the mode came from 0E's rule in an /autoplan --ceo-only run
|
||||
# --decisions governing approved rows; defaults to "none"
|
||||
#
|
||||
# The handoff used to be chat-only, and the model often composed it in its
|
||||
@@ -15,10 +16,12 @@ set -euo pipefail
|
||||
mode="${1:-}"
|
||||
[ "$#" -gt 0 ] && shift
|
||||
auto=0
|
||||
ceo_only=0
|
||||
decisions="none"
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--auto) auto=1; shift ;;
|
||||
--ceo-only) ceo_only=1; shift ;;
|
||||
--decisions)
|
||||
[ "$#" -ge 2 ] || { echo "gstack-ceo-mode-handoff: --decisions needs a value" >&2; exit 2; }
|
||||
decisions="$2"; shift 2 ;;
|
||||
@@ -31,11 +34,14 @@ case "$mode" in
|
||||
"HOLD SCOPE"|"SCOPE EXPANSION"|"SELECTIVE EXPANSION"|"SCOPE REDUCTION") ;;
|
||||
*) echo "gstack-ceo-mode-handoff: unknown mode: ${mode:-<empty>}" >&2; exit 2 ;;
|
||||
esac
|
||||
[ "$auto$ceo_only" != 11 ] || { echo "gstack-ceo-mode-handoff: --auto and --ceo-only conflict" >&2; exit 2; }
|
||||
decisions="${decisions//$'\n'/ }"
|
||||
decisions="${decisions%.}"
|
||||
[ -n "${decisions// /}" ] || decisions="none"
|
||||
|
||||
if [ "$auto" = 1 ]; then
|
||||
if [ "$ceo_only" = 1 ]; then
|
||||
echo "Auto-selected review mode → $mode (CEO-only run: Step 0E's rule). Approved decisions: $decisions."
|
||||
elif [ "$auto" = 1 ]; then
|
||||
echo "Auto-decided review mode → $mode (your preference). Change with /plan-tune. Approved decisions: $decisions."
|
||||
else
|
||||
echo "Mode: $mode; approved decisions: $decisions."
|
||||
|
||||
+15
-2
@@ -208,6 +208,14 @@ CONFIG_HEADER='# gstack configuration — edit freely, changes take effect on ne
|
||||
# # the same model; every reviewer keeps full.
|
||||
# Docs: docs/model-policy.md
|
||||
#
|
||||
# harness_start_hook: # on | off. on: ./setup and gstack-harness add the
|
||||
# # gstack-start plugin to OpenClaw and Hermes registrations
|
||||
# # (a one-line note on every turn to run the start
|
||||
# # command of a skill; edits openclaw.json or config.yaml via the
|
||||
# # harness CLI). off: takes it out. Absent = each
|
||||
# # registration keeps its saved choice (default off).
|
||||
# # GSTACK_HARNESS_START_HOOK=1|0 overrides it. Docs: docs/hosts.md
|
||||
#
|
||||
# ─── Advanced ────────────────────────────────────────────────────────
|
||||
# codex_reviews: enabled # Outside review (Codex or Claude Code by harness): sends
|
||||
# # the review prompt and repository content to that model.
|
||||
@@ -368,6 +376,7 @@ lookup_default() {
|
||||
agent_model) echo "" ;; # empty = host (agents keep their own model); gstack-models resolve --role agent
|
||||
codex_effort) echo "" ;; # empty = each gate's own effort; /codex --effort, then GSTACK_CODEX_EFFORT, win (gstack-codex-probe run-with-timeout --effort)
|
||||
review_effort_tiers) echo "on" ;; # on | off — gstack-review-plan effort tiers (docs/model-policy.md#reasoning-effort)
|
||||
harness_start_hook) echo "" ;; # on | off — empty = each harness registration keeps its saved choice (docs/hosts.md#start-hook)
|
||||
ship_greptile_early) echo "true" ;; # true | false — /ship's early Greptile PR
|
||||
pr_wait_gating_bots) echo "greptile,macroscope" ;; # review bots gstack-pr-wait --until ready waits for
|
||||
pr_wait_advisory_bots) echo "coderabbit" ;; # review bots gstack-pr-wait reports but never waits for
|
||||
@@ -833,6 +842,10 @@ $_gstack_legacy_root"
|
||||
echo "Error: review_effort_tiers '$VALUE' not recognized. Valid values: on, off. Existing value left unchanged." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "$KEY" = "harness_start_hook" ] && [ "$VALUE" != "on" ] && [ "$VALUE" != "off" ]; then
|
||||
echo "Error: harness_start_hook '$VALUE' not recognized. Valid values: on, off. Existing value left unchanged. fix: gstack-config unset harness_start_hook keeps each registration's saved choice (docs/hosts.md#start-hook)" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "$KEY" = "codex_effort" ]; then
|
||||
case "$VALUE" in
|
||||
minimal|low|medium|high|xhigh|max|ultra) ;;
|
||||
@@ -1100,7 +1113,7 @@ $_gstack_legacy_root"
|
||||
ship_measure_judge_outputs ship_measure_ask_per_trial_usd ship_measure_budget_usd \
|
||||
ship_measure_max_rounds ship_measure_sweep_cases ship_measure_sweep_budget_usd ship_rerun_backend test_backend \
|
||||
plan_review_tier implementation_tier model_frontier_claude model_frontier_openai \
|
||||
model_smart_claude model_smart_openai codex_effort review_effort_tiers agent_model; do
|
||||
model_smart_claude model_smart_openai codex_effort review_effort_tiers agent_model harness_start_hook; do
|
||||
gstack_config_select "$KEY"
|
||||
VALUE="$_gstack_cfg_value"
|
||||
SOURCE="default"
|
||||
@@ -1125,7 +1138,7 @@ $_gstack_legacy_root"
|
||||
ship_measure_judge_outputs ship_measure_ask_per_trial_usd ship_measure_budget_usd \
|
||||
ship_measure_max_rounds ship_measure_sweep_cases ship_measure_sweep_budget_usd ship_rerun_backend test_backend \
|
||||
plan_review_tier implementation_tier model_frontier_claude model_frontier_openai \
|
||||
model_smart_claude model_smart_openai codex_effort review_effort_tiers agent_model; do
|
||||
model_smart_claude model_smart_openai codex_effort review_effort_tiers agent_model harness_start_hook; do
|
||||
printf ' %-24s %s\n' "$KEY:" "$(lookup_default "$KEY")"
|
||||
done
|
||||
;;
|
||||
|
||||
@@ -394,6 +394,18 @@ do_read() {
|
||||
# -----------------------------------------------------------------------
|
||||
do_profile() {
|
||||
ensure_profile
|
||||
local PARSE_ERR
|
||||
if ! PARSE_ERR=$(PROFILE_FILE_PATH="$PROFILE_FILE" bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e "
|
||||
const text = require('fs').readFileSync(process.env.PROFILE_FILE_PATH, 'utf-8');
|
||||
try { JSON.parse(text); } catch (e) {
|
||||
const markers = /^(<{7}|={7}|>{7})( |\$)/m.test(text) ? ' (it holds git conflict markers from a merge)' : '';
|
||||
console.log(e.message + markers);
|
||||
process.exit(1);
|
||||
}
|
||||
" 2>&1); then
|
||||
echo "gstack-developer-profile: $PROFILE_FILE is not valid JSON: $PARSE_ERR; repair the file or restore it from git -C $GSTACK_HOME log -- developer-profile.json" >&2
|
||||
return 1
|
||||
fi
|
||||
cat "$PROFILE_FILE"
|
||||
}
|
||||
|
||||
|
||||
@@ -4,8 +4,9 @@
|
||||
*
|
||||
* Usage (run inside the repository):
|
||||
* gstack-docs-candidate snapshot --out <candidate.json> --audit-id <id> --mode edit|read-only
|
||||
* --base <rev> [--select <path>]... [--docs <path>]...
|
||||
* --base <rev> [--select <path>]... [--docs <path>]... [--exception <docs-audit.json>]
|
||||
* gstack-docs-candidate compare <candidate.json>
|
||||
* gstack-docs-candidate attempt <child-output> --state <attempts.json>
|
||||
* gstack-docs-candidate [snapshot|compare] --help
|
||||
*
|
||||
* snapshot saves, NUL-safely and without writing to the repository: base SHA,
|
||||
@@ -15,15 +16,26 @@
|
||||
* selected paths are the committed, staged, unstaged and untracked paths.
|
||||
* --docs takes files or directories (directories expand to their tracked and
|
||||
* untracked files). It prints a short summary; the JSON file is the record.
|
||||
* --exception names the blocked .gstack/tmp/docs-audit.json an owner accepted
|
||||
* (Step 14.5 Blocked recovery): the record then carries that exception, so
|
||||
* Step 16 can tell an accepted risk from a skipped audit.
|
||||
*
|
||||
* compare re-reads the same state and prints one JSON object: whether HEAD or
|
||||
* the index changed, which recorded paths' content changed or disappeared, and
|
||||
* which paths are newly dirty or untracked. It never decides whether a change
|
||||
* was permitted; the caller checks those paths against the child's report.
|
||||
*
|
||||
* attempt reads one audit child's output and says whether the launch counts
|
||||
* against Step 14.5's attempt budget. A launch the child's install check refused
|
||||
* (an `INSTALL: fail` row, its last GSTACK_RESULT line a document-release
|
||||
* refusal, and no audit JSON) never audited, so it is not counted, once per
|
||||
* invocation (--state, the invocation's record); a second refusal is counted
|
||||
* and stops for the install fix (GST-391, gbrain #8084).
|
||||
*/
|
||||
import { spawnSync } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { parseReceiptDocs, type ReceiptDocs } from '../lib/ship-receipt-docs';
|
||||
|
||||
function git(args: string[]): string {
|
||||
const r = spawnSync('git', args, { encoding: 'utf8', env: { ...process.env, GIT_OPTIONAL_LOCKS: '0' }, maxBuffer: 256 * 1024 * 1024 });
|
||||
@@ -82,8 +94,19 @@ function one(args: string[], name: string): string {
|
||||
return values[0]!;
|
||||
}
|
||||
|
||||
const USAGE = `usage: gstack-docs-candidate snapshot --out <candidate.json> --audit-id <id> --mode edit|read-only --base <rev> [--select <path>]... [--docs <path>]...
|
||||
gstack-docs-candidate compare <candidate.json>`;
|
||||
const USAGE = `usage: gstack-docs-candidate snapshot --out <candidate.json> --audit-id <id> --mode edit|read-only --base <rev> [--select <path>]... [--docs <path>]... [--exception <docs-audit.json>]
|
||||
gstack-docs-candidate compare <candidate.json>
|
||||
gstack-docs-candidate attempt <child-output> --state <attempts.json>`;
|
||||
|
||||
/** The owner's accepted Step 14.5 exception from the blocked .gstack/tmp/docs-audit.json record, read by the receipt's own parser; throws when the record is not blocked or names no complete exception. */
|
||||
function acceptedException(file: string): NonNullable<ReceiptDocs['exception']> & { scope: string } {
|
||||
let audit: unknown;
|
||||
try { audit = JSON.parse(fs.readFileSync(file, 'utf8')); } catch (error) { throw new Error(`--exception ${file} is not readable JSON: ${(error as Error).message}`); }
|
||||
const parsed = parseReceiptDocs(audit);
|
||||
const problem = !parsed.ok ? parsed.message : parsed.docs.status !== 'blocked' ? `its status is ${parsed.docs.status}, not blocked` : !parsed.docs.exception ? 'it names no accepted exception' : null;
|
||||
if (problem || !parsed.ok || !parsed.docs.exception) throw new Error(`--exception ${file}: ${problem}. Only an owner-accepted documentation risk writes this snapshot (ship/sections/documentation.md, Blocked recovery step 3)`);
|
||||
return { ...parsed.docs.exception, scope: parsed.docs.scope };
|
||||
}
|
||||
|
||||
/** Each field compare needs that the record lacks or types wrongly; empty for a valid record. */
|
||||
function recordProblems(record: any): string[] {
|
||||
@@ -98,13 +121,13 @@ function recordProblems(record: any): string[] {
|
||||
function main(argv: string[]): void {
|
||||
const [command, ...args] = argv;
|
||||
const topHelp = (command === '--help' || command === '-h') && !args.length;
|
||||
const commandHelp = (command === 'snapshot' || command === 'compare') && args.length === 1 && args[0] === '--help';
|
||||
const commandHelp = (command === 'snapshot' || command === 'compare' || command === 'attempt') && args.length === 1 && args[0] === '--help';
|
||||
if (topHelp || commandHelp) {
|
||||
console.log(USAGE);
|
||||
return;
|
||||
}
|
||||
if (command === 'snapshot') {
|
||||
const known = new Set(['--out', '--audit-id', '--mode', '--base', '--select', '--docs']);
|
||||
const known = new Set(['--out', '--audit-id', '--mode', '--base', '--select', '--docs', '--exception']);
|
||||
for (let i = 0; i < args.length; i += 2) if (!known.has(args[i]!)) throw new Error(`unknown argument: ${args[i]}`);
|
||||
const out = path.resolve(one(args, '--out'));
|
||||
const top = fs.realpathSync(git(['rev-parse', '--show-toplevel']).trim());
|
||||
@@ -112,6 +135,8 @@ function main(argv: string[]): void {
|
||||
const mode = one(args, '--mode');
|
||||
if (mode !== 'edit' && mode !== 'read-only') throw new Error('--mode must be edit or read-only');
|
||||
const base = git(['rev-parse', '--verify', one(args, '--base') + '^{commit}']).trim();
|
||||
const exceptionFile = flag(args, '--exception')[0];
|
||||
const exception = exceptionFile === undefined ? undefined : acceptedException(exceptionFile);
|
||||
process.chdir(top);
|
||||
const now = state(base);
|
||||
const listed = nul(git(['ls-files', '-z', '--cached', '--others', '--exclude-standard']));
|
||||
@@ -119,10 +144,11 @@ function main(argv: string[]): void {
|
||||
: [...new Set([...now.committed, ...now.staged, ...now.unstaged, ...now.untracked])].sort();
|
||||
const docs = expand(listed, flag(args, '--docs'));
|
||||
const tracked = [...new Set([...selected, ...docs, ...now.staged, ...now.unstaged, ...now.untracked])].sort();
|
||||
const record = { schema_version: 1, audit_id: one(args, '--audit-id'), mode, base, ...now, selected, docs, hashes: hashes(tracked) };
|
||||
const record = { schema_version: 1, audit_id: one(args, '--audit-id'), mode, base, ...now, selected, docs, hashes: hashes(tracked), ...(exception ? { exception } : {}) };
|
||||
fs.writeFileSync(out, JSON.stringify(record, null, 2) + '\n', { mode: 0o600 });
|
||||
console.log(`CANDIDATE: ${out}`);
|
||||
console.log(`base=${base} head=${now.head} branch=${now.branch || '(detached)'} mode=${mode}`);
|
||||
if (exception) console.log(`EXCEPTION: accepted by ${exception.accepted_by} ${exception.date} via ${exception.via}`);
|
||||
console.log(`committed=${now.committed.length} staged=${now.staged.length} unstaged=${now.unstaged.length} untracked=${now.untracked.length} selected=${selected.length} docs=${docs.length} hashed=${tracked.length}`);
|
||||
for (const [label, list] of [['committed', now.committed], ['staged', now.staged], ['unstaged', now.unstaged], ['untracked', now.untracked], ['docs', docs]] as const) {
|
||||
if (list.length) console.log(`${label}: ${list.map(p => JSON.stringify(p)).join(' ')}`);
|
||||
@@ -160,6 +186,33 @@ function main(argv: string[]): void {
|
||||
}));
|
||||
return;
|
||||
}
|
||||
if (command === 'attempt') {
|
||||
const INSTALL_FIX = 'run the Capy skill installer (scripts/install.sh --for document-release), then launch with its GSTACK_LAUNCH line';
|
||||
const [file, ...rest] = args;
|
||||
if (!file || file.startsWith('--')) throw new Error('attempt takes the child output file, then --state <attempts.json>');
|
||||
const state = one(rest, '--state');
|
||||
const lines = fs.readFileSync(file, 'utf8').split('\n').map(l => l.trim()).filter(Boolean);
|
||||
const last = lines.filter(l => l.startsWith('GSTACK_RESULT: ')).pop();
|
||||
const install = lines.find(l => /^INSTALL: fail\b/.test(l));
|
||||
const audited = lines.some(l => { try { const v = JSON.parse(l); return !!v && typeof v === 'object' && 'schema_version' in v; } catch { return false; } });
|
||||
if (!last || !/^GSTACK_RESULT: skill=document-release status=refused\b/.test(last) || !install || audited) { console.log('ATTEMPT: counted'); return; }
|
||||
let seen = 0;
|
||||
try { seen = Number(JSON.parse(fs.readFileSync(state, 'utf8')).not_counted) || 0; } catch { seen = fs.existsSync(state) ? 1 : 0; }
|
||||
if (seen >= 1) {
|
||||
console.log('ATTEMPT: counted (a second install refusal in this invocation)');
|
||||
console.log(`STOP: ${INSTALL_FIX} and confirm its gstack: ok trailer before another launch`);
|
||||
process.exit(3);
|
||||
}
|
||||
fs.mkdirSync(path.dirname(path.resolve(state)), { recursive: true });
|
||||
try { fs.writeFileSync(state, JSON.stringify({ not_counted: 1 }) + '\n', { mode: 0o600, flag: 'wx' }); } catch (e) {
|
||||
if ((e as NodeJS.ErrnoException).code !== 'EEXIST') throw e;
|
||||
console.log('ATTEMPT: counted (another launch in this invocation already used the allowance)');
|
||||
console.log(`STOP: ${INSTALL_FIX} and confirm its gstack: ok trailer before another launch`);
|
||||
process.exit(3);
|
||||
}
|
||||
console.log('ATTEMPT: not-counted (the audit child\'s install check refused document-release)');
|
||||
return;
|
||||
}
|
||||
throw new Error(USAGE);
|
||||
}
|
||||
|
||||
|
||||
+15
-3
@@ -275,7 +275,7 @@ check_codex_probe() {
|
||||
}
|
||||
|
||||
check_sync() {
|
||||
local mode file json status held drainable drain push queue=0 now f detail case_names fix="$BIN/gstack-brain-sync --status"
|
||||
local mode file json json_held status held drainable drain push queue=0 now f detail case_names fix="$BIN/gstack-brain-sync --status"
|
||||
cfg artifacts_sync_mode off; mode=$_cfg
|
||||
if [ ! -d "$STATE_ROOT/.git" ]; then
|
||||
row "not configured" "artifacts sync" "off; cross-machine artifacts sync is unavailable" "$BIN/gstack-artifacts-init"
|
||||
@@ -285,6 +285,11 @@ check_sync() {
|
||||
row "not configured" "artifacts sync" "artifacts_sync_mode off; cross-machine artifacts sync is unavailable" "$BIN/gstack-config set artifacts_sync_mode artifacts-only"
|
||||
return
|
||||
fi
|
||||
for f in "$STATE_ROOT/developer-profile.json" "$STATE_ROOT"/projects/*/decisions.active.json; do
|
||||
[ -f "$f" ] && grep -qE '^(<{7}|>{7})( |$)' "$f" 2>/dev/null || continue
|
||||
row warn "artifacts sync" "${f#"$STATE_ROOT"/} holds git conflict markers and does not parse" "repair the file, or restore it from git -C $STATE_ROOT log -- ${f#"$STATE_ROOT"/}"
|
||||
return
|
||||
done
|
||||
for f in "$STATE_ROOT/.brain-queue.d"/*.json; do [ -f "$f" ] && queue=$((queue + 1)); done
|
||||
for f in "$STATE_ROOT/.brain-queue.jsonl" "$STATE_ROOT/.brain-queue.jsonl.migrating"; do
|
||||
[ -f "$f" ] && queue=$((queue + $(wc -l < "$f" | tr -d ' ')))
|
||||
@@ -306,8 +311,15 @@ check_sync() {
|
||||
[ -n "$push" ] || push=$(epoch_of "$(head -1 "$STATE_ROOT/.brain-last-push" 2>/dev/null)")
|
||||
now=$(date +%s)
|
||||
case "$status" in
|
||||
held) row warn "artifacts sync" "status held${held:+ ($held file(s) held by the secret scanner)}" "$fix"; return ;;
|
||||
blocked) row warn "artifacts sync" "status blocked (a re-scan still flags staged content)" "$fix"; return ;;
|
||||
held)
|
||||
json_held=$(printf '%s' "$json" | grep -o '"rule": *"invalid-json"' | wc -l | tr -d ' ')
|
||||
if [ "${json_held:-0}" -gt 0 ]; then
|
||||
row warn "artifacts sync" "status held ($json_held synced file(s) are not valid JSON${held:+; $held held in all})" "$fix"
|
||||
else
|
||||
row warn "artifacts sync" "status held${held:+ ($held file(s) held by the secret scanner)}" "$fix"
|
||||
fi
|
||||
return ;;
|
||||
blocked) row warn "artifacts sync" "status blocked (nothing was committed; --status names the cause)" "$fix"; return ;;
|
||||
push_failed|error|unknown) row warn "artifacts sync" "status $status" "$fix"; return ;;
|
||||
esac
|
||||
if [ -n "$case_names" ]; then
|
||||
|
||||
@@ -141,6 +141,7 @@ gstack_result_anchor() {
|
||||
BROWSER_UNAVAILABLE) printf '%s' 'browser-ensure-failed' ;;
|
||||
INSTALL_PREFLIGHT_FAILED) printf '%s' 'capy-install-preflight-failed' ;;
|
||||
SETUP_PS1_EMPTY_ARGUMENT) printf '%s' 'setup-ps1-empty-argument' ;;
|
||||
SETUP_DEPS_MIGRATE_FAILED) printf '%s' 'setup-deps-migrate-failed' ;;
|
||||
POLICY_SOURCE_UNAVAILABLE) printf '%s' 'policy-source-unavailable' ;;
|
||||
POLICY_INVALID) printf '%s' 'policy-invalid' ;;
|
||||
POLICY_CONTAINMENT) printf '%s' 'policy-containment' ;;
|
||||
@@ -282,7 +283,21 @@ gstack_result_anchor() {
|
||||
QUEUE_TRAIN_STAMP_MISMATCH) printf '%s' 'queue-train-stamp-mismatch' ;;
|
||||
QUEUE_TRAIN_TREE_MISMATCH) printf '%s' 'queue-train-tree-mismatch' ;;
|
||||
QUEUE_TRAIN_STALE) printf '%s' 'queue-train-stale' ;;
|
||||
QUEUE_TRAIN_BASE_RED) printf '%s' 'queue-train-base-red' ;;
|
||||
QUEUE_TRAIN_SPEND_CAP) printf '%s' 'queue-train-spend-cap' ;;
|
||||
QUEUE_TRAIN_LAND_METHOD) printf '%s' 'queue-train-land-method' ;;
|
||||
QUEUE_TRAIN_INIT_REFUSED) printf '%s' 'queue-train-init-refused' ;;
|
||||
QUEUE_TRAIN_DOCTOR_FINDINGS) printf '%s' 'queue-train-doctor-findings' ;;
|
||||
QUEUE_BASE_RED_NO_TRAIN) printf '%s' 'queue-base-red-no-train' ;;
|
||||
SHIP_BASE_RED_FIXER) printf '%s' 'ship-base-red-fixer' ;;
|
||||
QUEUE_TRAIN_FROZEN) printf '%s' 'queue-train-frozen' ;;
|
||||
QUEUE_WAVE_CONFLICT) printf '%s' 'queue-wave-conflict' ;;
|
||||
QUEUE_WAVE_MOVED) printf '%s' 'queue-wave-moved' ;;
|
||||
QUEUE_WAVE_DUPLICATE) printf '%s' 'queue-wave-duplicate' ;;
|
||||
QUEUE_WAVE_SIZE) printf '%s' 'queue-wave-size' ;;
|
||||
QUEUE_WAVE_UNKNOWN) printf '%s' 'queue-wave-unknown' ;;
|
||||
QUEUE_WAVE_DEPENDENT) printf '%s' 'queue-wave-dependent' ;;
|
||||
QUEUE_WAVE_NOT_MERGED) printf '%s' 'queue-wave-not-merged' ;;
|
||||
QUEUE_RECEIPT_MISSING) printf '%s' 'queue-receipt-missing' ;;
|
||||
QUEUE_RECEIPT_STALE) printf '%s' 'queue-receipt-stale' ;;
|
||||
QUEUE_RECEIPT_CONDITIONAL) printf '%s' 'queue-receipt-conditional' ;;
|
||||
@@ -316,12 +331,16 @@ gstack_result_anchor() {
|
||||
REVIEW_LOOP_EXCEPTION_INVALID) printf '%s' 'review-loop-exception-invalid' ;;
|
||||
RECEIPT_CARRY_TREE_MISMATCH) printf '%s' 'receipt-carry-tree-mismatch' ;;
|
||||
RECEIPT_CARRY_PRODUCT_CHANGED) printf '%s' 'receipt-carry-product-changed' ;;
|
||||
RECEIPT_CARRY_SCOPED_REVIEW) printf '%s' 'receipt-carry-scoped-review' ;;
|
||||
DOCS_AUDIT_BLOCKED) printf '%s' 'docs-audit-blocked' ;;
|
||||
RECEIPT_PROSE_MISMATCH) printf '%s' 'receipt-prose-mismatch' ;;
|
||||
RECEIPT_CARRY_PREGATE_STALE) printf '%s' 'receipt-carry-pregate-stale' ;;
|
||||
RECEIPT_GIT_TOO_OLD) printf '%s' 'receipt-git-too-old' ;;
|
||||
CHANGELOG_FRAGMENT_INVALID) printf '%s' 'changelog-fragment-invalid' ;;
|
||||
QUEUE_LAND_LOCKED) printf '%s' 'queue-land-locked' ;;
|
||||
QUEUE_TRAIN_UNSTAMPED) printf '%s' 'queue-train-unstamped' ;;
|
||||
RESTAMP_NOT_STAGED) printf '%s' 'restamp-not-staged' ;;
|
||||
RESTAMP_UNSTAGED_EDITS) printf '%s' 'restamp-unstaged-edits' ;;
|
||||
QUEUE_LAND_CONFLICT) printf '%s' 'queue-land-conflict' ;;
|
||||
QUEUE_LAND_DRIFT) printf '%s' 'queue-land-drift' ;;
|
||||
QUEUE_LAND_BASE_BUSY) printf '%s' 'queue-land-base-busy' ;;
|
||||
@@ -345,6 +364,7 @@ gstack_result_anchor() {
|
||||
UPGRADE_SKIPPED) printf '%s' 'upgrade-skipped' ;;
|
||||
BUDGET_CAP_REACHED) printf '%s' 'budget-cap-reached' ;;
|
||||
BUDGET_READING_MISSING) printf '%s' 'budget-reading-missing' ;;
|
||||
METER_SCOPE_PARENT) printf '%s' 'meter-scope-parent' ;;
|
||||
BUDGET_READING_REJECTED) printf '%s' 'budget-reading-rejected' ;;
|
||||
BUDGET_METER_UNAVAILABLE) printf '%s' 'budget-meter-unavailable' ;;
|
||||
BUDGET_LEDGER_INVALID) printf '%s' 'budget-ledger-invalid' ;;
|
||||
@@ -353,7 +373,14 @@ gstack_result_anchor() {
|
||||
PREREG_INCOMPLETE) printf '%s' 'prereg-incomplete' ;;
|
||||
PREREG_DIAGNOSTIC_UNTESTED) printf '%s' 'prereg-diagnostic-untested' ;;
|
||||
PREREG_NO_GSTACK_ARM) printf '%s' 'prereg-no-gstack-arm' ;;
|
||||
EVAL_REPORT_NO_GSTACK_ROW) printf '%s' 'eval-report-no-gstack-row' ;;
|
||||
EVAL_ARM_DUPLICATE) printf '%s' 'eval-arm-duplicate' ;;
|
||||
OFFICE_HOURS_PROBE_REFUSED) printf '%s' 'office-hours-probe-refused' ;;
|
||||
TASKS_SUPERSEDE_REFUSED) printf '%s' 'tasks-supersede-refused' ;;
|
||||
REALITY_RECEIPT_UNRESOLVED) printf '%s' 'reality-receipt-unresolved' ;;
|
||||
CEO_ONLY_CONFLICT) printf '%s' 'ceo-only-conflict' ;;
|
||||
CEO_ONLY_AFTER_INIT) printf '%s' 'ceo-only-after-init' ;;
|
||||
CEO_ONLY_NO_GATE) printf '%s' 'ceo-only-no-gate' ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
+26
-6
@@ -6,10 +6,11 @@
|
||||
* gstack-eval-plan arms [--arms <model,model>] [--policy-doc <file>] [--input-tokens N] [--output-tokens N] [--json]
|
||||
* gstack-eval-plan dry-run --input <rows.jsonl> --arms <model,model> [--fields a,b] [--output-budget N]
|
||||
* [--retry-prompt <file>] [--max-rows N] [--out <dir>] [--json]
|
||||
* gstack-eval-plan pilot start --out <dir> --cap <usd> --arms <model[#loadout],...> --dry-run <dry-run.json> [--n 3]
|
||||
* gstack-eval-plan pilot start --out <dir> --cap <usd> --arms <model[#loadout],...> --dry-run <dry-run.json> [--n 3] [--seat-metered <arm,...>]
|
||||
* gstack-eval-plan pilot settle --out <dir> --attempt <id> --usd <n|unknown>
|
||||
* gstack-eval-plan pilot release --out <dir> --attempt <id>
|
||||
* gstack-eval-plan pilot report --out <dir> --total <items> [--workers W] [--json]
|
||||
* gstack-eval-plan results --check <prereg> <results.md> [--json]
|
||||
* gstack-eval-plan price-gate --gate-file <file> | --gate "<exact gate command>" [--plan-file <file>] [--base <branch>] [--cap-usd N]
|
||||
* [--smoke-cap-usd N] [--allow-unpriced "<reason>"] [--out <dir>] [--json]
|
||||
*
|
||||
@@ -26,12 +27,13 @@
|
||||
* price-gate lint every arm's model against the base branch's
|
||||
* .gstack/eval-models.json (docs/eval-models.md; lib/eval-models.ts). Exit 0 ok, 1 fail (incomplete
|
||||
* prereg, failed check, exceeded cap, unpriced or stale gate), 2 usage, 3 refused.
|
||||
* Logic: lib/eval-plan.ts.
|
||||
* Logic: lib/eval-plan.ts; `results --check` in lib/eval-plan-report.ts.
|
||||
*/
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { armModel, checkPrereg, DEFAULT_TOKENS, PREREG_SECTIONS, dryRun, pilotReport, preregTemplate, priceGate, releasePilot, renderArms, renderDryRun, renderGatePrice, renderPilotReport, renderPreregDiagnostics, resolveArms, resolvePlanSource, runPreregDiagnostics, settlePilot, startPilot } from '../lib/eval-plan';
|
||||
import { checkResults } from '../lib/eval-plan-report';
|
||||
import { checkModelRules, EVAL_SCOPES, type EvalScope, type ModelRulesCheck } from '../lib/eval-models';
|
||||
import { EXIT, exitTableHelp } from '../lib/headless-artifacts';
|
||||
import { resultLine } from '../lib/result-codes';
|
||||
@@ -41,7 +43,8 @@ import { SpendCapExceeded } from '../lib/spend-ledger';
|
||||
const HELP = `gstack-eval-plan — preregistration, priced arms, $0 dry run, priced pilot (eval-plan/SKILL.md)
|
||||
|
||||
prereg --check <file> [--allow-repo-commands] [--json]
|
||||
the five sections exist and are filled: Bars, Arms, Decoys, Stop rules, Held-out exposure;
|
||||
the required sections exist and are filled: Bars, Arms, Comparison, Decoys, Stop rules,
|
||||
Held-out exposure, and No-gstack arm when the Comparison says stack: loaded;
|
||||
an optional Diagnostics section's summarizer runs on its synthetic fixture only with
|
||||
--allow-repo-commands (else it is listed and exit 3) and every count must match
|
||||
prereg --init <file> [--title <t>] write the template
|
||||
@@ -50,9 +53,14 @@ const HELP = `gstack-eval-plan — preregistration, priced arms, $0 dry run, pri
|
||||
dry-run --input rows.jsonl --arms m,m [--fields a,b] [--output-budget N] [--retry-prompt f] [--max-rows N] [--cap usd] [--out dir] [--json]
|
||||
stub model over the real rows; checklist: price table, output budget, retry prompt text,
|
||||
and with --cap reservation-fits-cap (one call: largest row + output budget, priced)
|
||||
pilot start --out dir --cap usd --arms m,m#no-gstack --dry-run dry-run.json [--n 3]
|
||||
pilot start --out dir --cap usd --arms m,m#no-gstack --dry-run dry-run.json [--n 3] [--seat-metered arm,...]
|
||||
--seat-metered: arms on a subscription with no incremental charge; they reserve $0,
|
||||
settle unknown, and sit outside the cap and the projection until one settles a charge
|
||||
pilot settle --out dir --attempt id --usd <n|unknown> pilot release --out dir --attempt id
|
||||
pilot report --out dir --total items [--workers W] [--json] spend table + extrapolation with a 95% band, then the approval ask
|
||||
pilot report --out dir --total items [--workers W] [--json] spend table + per-arm extrapolation with a 95% band, then the approval ask
|
||||
results --check <prereg> <results.md> [--json]
|
||||
one table in the results file has a filled row for every Arms identity, each no-gstack arm
|
||||
beside its variants; reads text only (docs/TESTING_INTERNALS.md#no-gstack-arm)
|
||||
price-gate --gate-file <file> | --gate "<exact gate command>" [--plan-file f] [--base b] [--cap-usd N] [--smoke-cap-usd N] [--allow-unpriced "<reason>"] [--out dir] [--json]
|
||||
price a paid gate from its GSTACK_EVAL_CALLS v1 call-plan file (--plan-file, else the base
|
||||
policy's price_file at HEAD); checks the gate/head/input binding, executes nothing, writes a
|
||||
@@ -129,6 +137,17 @@ function doPrereg(rest: string[]): number {
|
||||
return EXIT.ok;
|
||||
}
|
||||
|
||||
function doResults(rest: string[]): number {
|
||||
const prereg = flag(rest, '--check');
|
||||
const results = rest.filter(a => !a.startsWith('--') && a !== prereg)[0];
|
||||
if (!prereg || !results || !fs.existsSync(prereg) || !fs.existsSync(results)) return usage('results needs --check <existing prereg> <existing results file>');
|
||||
const r = checkResults(fs.readFileSync(prereg, 'utf8'), fs.readFileSync(results, 'utf8'));
|
||||
if (rest.includes('--json')) process.stdout.write(JSON.stringify({ prereg, results, ...r }) + '\n');
|
||||
else process.stdout.write(`RESULTS: ${r.ok ? 'ok' : 'incomplete'} no_gstack_rows=${r.no_gstack.filter(id => r.found.includes(id)).length}/${r.no_gstack.length} rows=${r.found.length}/${r.required.length} file=${results}\n`);
|
||||
if (!r.ok) { process.stderr.write(resultLine('gstack-eval-plan results', 'EVAL_REPORT_NO_GSTACK_ROW', r.problems.join('; ')) + '\n'); return EXIT.fail; }
|
||||
return EXIT.ok;
|
||||
}
|
||||
|
||||
function doArms(rest: string[]): number {
|
||||
const tokens = { input: num(rest, '--input-tokens', DEFAULT_TOKENS.input)!, output: num(rest, '--output-tokens', DEFAULT_TOKENS.output)! };
|
||||
const scope = scopeOf(rest);
|
||||
@@ -168,7 +187,7 @@ function doPilot(rest: string[]): number {
|
||||
if (cap === undefined || !arms.length || !dry || !fs.existsSync(dry)) return usage('pilot start needs --cap <usd> --arms <m,m> --dry-run <existing dry-run.json>');
|
||||
const rules = lintModels(args, arms.map(model => ({ model, scope: scopeOf(args), where: 'pilot start --arms' })), 'pilot start');
|
||||
if (!rules.ok) return EXIT.fail;
|
||||
const r = startPilot({ out, cap, n: num(args, '--n', 3)!, arms, dryRun: dry });
|
||||
const r = startPilot({ out, cap, n: num(args, '--n', 3)!, arms, seatMetered: csv(flag(args, '--seat-metered')), dryRun: dry });
|
||||
if (r.error && !r.record) { process.stderr.write(resultLine('gstack-eval-plan pilot start', r.error.code, r.error.message) + '\n'); return EXIT.refused; }
|
||||
for (const a of r.record.attempts) process.stdout.write(`PILOT_ATTEMPT: ${a.attempt} model=${a.model} estimate=$${a.estimate_usd.toFixed(4)}\n`);
|
||||
process.stdout.write(`PILOT: status=${r.record.status} reserved=${r.record.attempts.length} cap=$${cap.toFixed(2)} ledger=${path.join(out, 'spend.json')}\n`);
|
||||
@@ -242,6 +261,7 @@ function main(argv: string[]): number {
|
||||
if (cmd === 'dry-run') return doDryRun(rest);
|
||||
if (cmd === 'pilot') return doPilot(rest);
|
||||
if (cmd === 'price-gate') return doPriceGate(rest);
|
||||
if (cmd === 'results') return doResults(rest);
|
||||
return usage(`unknown command ${cmd}`);
|
||||
} catch (e: any) {
|
||||
if (e instanceof SpendCapExceeded) { process.stderr.write(resultLine(`gstack-eval-plan ${cmd}`, 'EVAL_BUDGET_EXHAUSTED', e.message) + '\n'); return EXIT.fail; }
|
||||
|
||||
+47
-3
@@ -36,7 +36,14 @@
|
||||
# exit 3 autostash-unrecoverable: (edits kept where they are)
|
||||
# exit 4 not-fast-forward:
|
||||
# exit 5 index-unreadable:
|
||||
# exit 6 tracked-by-project: / version-reverted: (#1022, the preflight)
|
||||
# exit 75 ff-busy: (another update holds this install's lock)
|
||||
#
|
||||
# gstack-ff-update --preflight <dir> runs only the exit-6 checks (exit 0 or 6);
|
||||
# bin/gstack-upgrade calls it before any fetch. A project that tracks the
|
||||
# install's files as plain blobs puts old ones back on every checkout of its
|
||||
# own, so VERSION freezes behind HEAD while updates seem to succeed; a
|
||||
# submodule gitlink is the supported shape and passes.
|
||||
# A rescue also prints the inspect, export and delete commands on stderr.
|
||||
|
||||
set +e
|
||||
@@ -49,15 +56,51 @@ ff_flatten() {
|
||||
printf '%s' "$1" | tr '\n\r\t' ' ' | sed "s/${_ff_esc}\[[0-9;?]*[ -\/]*[@-~]//g" | tr -d '\000-\037\177' | head -c "${2:-200}"
|
||||
}
|
||||
ff_quote() { printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")"; }
|
||||
refuse() { printf '%s: %s\n' "$2" "$(ff_flatten "$3" 300)"; exit "$1"; }
|
||||
refuse() { printf '%s: %s\n' "$2" "$(ff_flatten "$3" "${4:-300}")"; exit "$1"; }
|
||||
|
||||
[ $# -eq 2 ] && [ -n "$1" ] && [ -n "$2" ] || refuse 2 usage "gstack-ff-update <install dir> <revision>"
|
||||
DIR=$1 REV=$2
|
||||
[ $# -eq 2 ] && [ -n "$1" ] && [ -n "$2" ] || refuse 2 usage "gstack-ff-update <install dir> <revision> | --preflight <install dir>"
|
||||
PREFLIGHT=0
|
||||
if [ "$1" = --preflight ]; then PREFLIGHT=1 DIR=$2 REV=HEAD; else DIR=$1 REV=$2; fi
|
||||
cd -- "$DIR" 2>/dev/null || refuse 2 not-a-checkout "cannot enter $DIR"
|
||||
DIR=$(pwd -P)
|
||||
g() { git -c core.fsmonitor=false -c core.splitIndex=false "$@"; }
|
||||
[ "$(g rev-parse --is-inside-work-tree 2>/dev/null)" = true ] && [ -z "$(g rev-parse --show-prefix 2>/dev/null)" ] \
|
||||
|| refuse 2 not-a-checkout "$DIR is not the top of a git checkout"
|
||||
|
||||
# ── Preflight (#1022) ──
|
||||
# The repository enclosing the install's parent, probed with the caller's git
|
||||
# environment cleared; any git error there means not tracked.
|
||||
_PF=$(
|
||||
unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_COMMON_DIR GIT_OBJECT_DIRECTORY
|
||||
cd -- "$DIR/.." 2>/dev/null || exit 0 # best-effort: no parent directory means no project tracks the install
|
||||
pg() { git -c core.fsmonitor=false -c core.splitIndex=false "$@"; }
|
||||
_top=$(pg rev-parse --show-toplevel 2>/dev/null) || exit 0
|
||||
_rel="$(pg rev-parse --show-prefix 2>/dev/null)${DIR##*/}" || exit 0
|
||||
_n=0
|
||||
while IFS= read -r _l; do
|
||||
case "$_l" in (''|'160000 '*) ;; (*) _n=$((_n + 1)) ;; esac
|
||||
done <<EOF_PF
|
||||
$(pg -C "$_top" --literal-pathspecs ls-files -s -- "$_rel" 2>/dev/null)
|
||||
EOF_PF
|
||||
[ "$_n" -gt 0 ] && printf '%s\n%s\n%s\n' "$_top" "$_rel" "$_n"
|
||||
)
|
||||
if [ -n "$_PF" ]; then
|
||||
_top=$(printf '%s\n' "$_PF" | sed -n 1p) _rel=$(printf '%s\n' "$_PF" | sed -n 2p) _n=$(printf '%s\n' "$_PF" | sed -n 3p)
|
||||
_ign=$(printf '%s' "$_rel" | sed 's/[][*?\\]/\\&/g')
|
||||
refuse 6 tracked-by-project "$_top tracks $_n files of the gstack install at $_rel, so every checkout there puts old gstack files back (VERSION frozen, #1022); nothing was changed; fix: git -C $(ff_quote "$_top") --literal-pathspecs rm -r -q --cached -- $(ff_quote "$_rel"), add /$_ign/ to $(ff_quote "$_top/.gitignore") and commit both on every branch you check out (or vendor gstack as a git submodule), then git -C $(ff_quote "$DIR") stash push -m 'files reverted by the project' (keeps every edit) and re-run" 4000
|
||||
fi
|
||||
# A VERSION whose first line differs from HEAD's. Any unmerged file is left to
|
||||
# the rescue below; the full update runs this inside the lock, where a VERSION
|
||||
# already at the target's (an interrupted move) is finished, not held.
|
||||
version_check() {
|
||||
g cat-file -e HEAD:VERSION 2>/dev/null && [ -z "$(g ls-files -u 2>/dev/null)" ] || return 0
|
||||
_have=$(head -1 VERSION 2>/dev/null | tr -d '\r') _want=$(g show HEAD:VERSION 2>/dev/null | head -1 | tr -d '\r')
|
||||
[ "$_have" = "$_want" ] && return 0
|
||||
[ -n "$1" ] && [ "$_have" = "$(g show "$1:VERSION" 2>/dev/null | head -1 | tr -d '\r')" ] && return 0
|
||||
refuse 6 version-reverted "VERSION says ${_have:-nothing} but the checkout is at $_want: files changed outside git (an enclosing project's checkout puts old ones back, #1022, or a hand edit); nothing was changed; fix: git -C $(ff_quote "$DIR") stash push -m 'files reverted by the project' (keeps every edit), then re-run" 4000
|
||||
}
|
||||
[ "$PREFLIGHT" -eq 1 ] && { version_check; exit 0; }
|
||||
|
||||
TARGET=$(g rev-parse -q --verify "$REV^{commit}" 2>/dev/null) || refuse 2 target-unresolvable "$REV does not name a commit in $DIR"
|
||||
COMMON=$(g rev-parse --path-format=absolute --git-common-dir 2>/dev/null) \
|
||||
|| COMMON=$(cd -- "$(g rev-parse --git-common-dir 2>/dev/null)" 2>/dev/null && pwd -P) \
|
||||
@@ -128,6 +171,7 @@ case "$_beat" in ''|*[!0-9]*|0) _beat=300 ;; esac
|
||||
HB_PID=$!
|
||||
|
||||
# ── Inside the lock ──
|
||||
version_check "$TARGET"
|
||||
HEAD_OID=$(g rev-parse -q --verify 'HEAD^{commit}' 2>/dev/null) || refuse 2 not-a-checkout "$DIR has no HEAD commit"
|
||||
TMP=$(mktemp -d "${TMPDIR:-/tmp}/gstack-ff-XXXXXX" 2>/dev/null) || refuse 3 autostash-unrecoverable "cannot create a temp directory under ${TMPDIR:-/tmp}; nothing was changed"
|
||||
g ls-files -u -z > "$TMP/unmerged.raw" 2>"$TMP/err" || refuse 5 index-unreadable "cannot read the index of $DIR ($(head -c 200 "$TMP/err")); nothing was changed"
|
||||
|
||||
+167
-42
@@ -10,7 +10,7 @@
|
||||
*/
|
||||
|
||||
import { existsSync, readdirSync, statSync, readFileSync, openSync, readSync, closeSync } from "fs";
|
||||
import { join, basename, dirname, resolve } from "path";
|
||||
import { join, basename, dirname, resolve, isAbsolute } from "path";
|
||||
import { spawnSync } from "child_process";
|
||||
import { homedir } from "os";
|
||||
import { canonicalRemote } from "../lib/remote-identity";
|
||||
@@ -20,8 +20,11 @@ import { canonicalRemote } from "../lib/remote-identity";
|
||||
type CodexOriginator = "cli" | "desktop" | "exec" | "claude_code" | "other";
|
||||
type CodexOriginatorCounts = Record<CodexOriginator, number>;
|
||||
|
||||
type ToolId = "claude_code" | "codex" | "gemini" | "kimi_code" | "omp";
|
||||
type ToolCounts = Record<ToolId, number>;
|
||||
|
||||
interface Session {
|
||||
tool: "claude_code" | "codex" | "gemini";
|
||||
tool: ToolId;
|
||||
cwd: string;
|
||||
codexOriginator?: CodexOriginator;
|
||||
}
|
||||
@@ -30,19 +33,24 @@ interface Repo {
|
||||
name: string;
|
||||
remote: string;
|
||||
paths: string[];
|
||||
sessions: { claude_code: number; codex: number; gemini: number };
|
||||
sessions: ToolCounts;
|
||||
codex_originators: CodexOriginatorCounts;
|
||||
}
|
||||
|
||||
interface ToolSummary {
|
||||
label: string;
|
||||
total_sessions: number;
|
||||
repos: number;
|
||||
originators?: CodexOriginatorCounts;
|
||||
/** Sessions counted without a recorded cwd: they count in total_sessions, like sessions outside any repo, but belong to no repo. */
|
||||
unattributed?: true;
|
||||
}
|
||||
|
||||
interface DiscoveryResult {
|
||||
window: string;
|
||||
start_date: string;
|
||||
repos: Repo[];
|
||||
tools: {
|
||||
claude_code: { total_sessions: number; repos: number };
|
||||
codex: { total_sessions: number; repos: number; originators: CodexOriginatorCounts };
|
||||
gemini: { total_sessions: number; repos: number };
|
||||
};
|
||||
tools: Record<ToolId | "agy", ToolSummary>;
|
||||
total_sessions: number;
|
||||
total_repos: number;
|
||||
}
|
||||
@@ -521,6 +529,128 @@ function scanGemini(since: Date): Session[] {
|
||||
return sessions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kimi Code: ~/.kimi-code/session_index.jsonl lists { sessionId, sessionDir, workDir };
|
||||
* the session's state.json carries updatedAt (epoch ms). workDir is the absolute cwd.
|
||||
*/
|
||||
function scanKimiCode(since: Date): Session[] {
|
||||
const root = join(homedir(), ".kimi-code");
|
||||
let index: string;
|
||||
try {
|
||||
index = readFileSync(join(root, "session_index.jsonl"), { encoding: "utf-8" });
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const sessions: Session[] = [];
|
||||
for (const line of index.split(/\r?\n/)) {
|
||||
if (!line.trim()) continue;
|
||||
let entry: any;
|
||||
try {
|
||||
entry = JSON.parse(line);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (typeof entry?.sessionDir !== "string" || !entry.sessionDir) continue;
|
||||
const dir = isAbsolute(entry.sessionDir) ? entry.sessionDir : join(root, entry.sessionDir);
|
||||
let state: any = {};
|
||||
try {
|
||||
state = JSON.parse(readFileSync(join(dir, "state.json"), { encoding: "utf-8" }));
|
||||
} catch {}
|
||||
let updated = typeof state?.updatedAt === "number" ? state.updatedAt : NaN;
|
||||
if (Number.isNaN(updated)) {
|
||||
try {
|
||||
updated = statSync(dir).mtimeMs;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (updated < since.getTime()) continue;
|
||||
const cwd = typeof entry.workDir === "string" && entry.workDir ? entry.workDir : typeof state?.cwd === "string" ? state.cwd : null;
|
||||
if (cwd) sessions.push({ tool: "kimi_code", cwd });
|
||||
}
|
||||
return sessions;
|
||||
}
|
||||
|
||||
/**
|
||||
* OMP: one <timestamp>_<uuid>.jsonl per session under ~/.omp/agent/sessions/<workspace>/.
|
||||
* The workspace directory name is a lossy encoding of the path, so the cwd comes from
|
||||
* the session file itself. A session counts when its file was written inside the
|
||||
* window, like the other scanners; ~/.omp/logs holds per-launch logs and is never read.
|
||||
*/
|
||||
function scanOmp(since: Date, stats: CwdScanStats): Session[] {
|
||||
const sessionsDir = join(homedir(), ".omp", "agent", "sessions");
|
||||
let workspaces: string[];
|
||||
try {
|
||||
workspaces = readdirSync(sessionsDir);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const sessions: Session[] = [];
|
||||
for (const workspace of workspaces) {
|
||||
const dir = join(sessionsDir, workspace);
|
||||
let files: string[];
|
||||
try {
|
||||
files = readdirSync(dir).filter((f) => f.endsWith(".jsonl"));
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const file of files) {
|
||||
const path = join(dir, file);
|
||||
try {
|
||||
if (statSync(path).mtime < since) continue;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const cwd = extractCwdFromJsonl(path, stats);
|
||||
if (cwd) sessions.push({ tool: "omp", cwd });
|
||||
}
|
||||
}
|
||||
return sessions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Antigravity CLI (agy) keeps each conversation as SQLite under
|
||||
* ~/.gemini/antigravity-cli/conversations/*.db, with the cwd inside an undocumented
|
||||
* blob (#1977). Sessions are counted by the later of the .db and its -wal file's
|
||||
* mtime and are not attributed to a repo.
|
||||
*/
|
||||
function countAntigravity(since: Date): number {
|
||||
const dir = join(homedir(), ".gemini", "antigravity-cli", "conversations");
|
||||
let files: string[];
|
||||
try {
|
||||
files = readdirSync(dir).filter((f) => f.endsWith(".db"));
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
let count = 0;
|
||||
for (const file of files) {
|
||||
let latest = 0;
|
||||
for (const path of [join(dir, file), join(dir, `${file}-wal`)]) {
|
||||
try {
|
||||
latest = Math.max(latest, statSync(path).mtimeMs);
|
||||
} catch {}
|
||||
}
|
||||
if (latest >= since.getTime()) count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
const SCANNERS: ReadonlyArray<{ id: ToolId; label: string; scan: (since: Date, stats: CwdScanStats) => Session[] }> = [
|
||||
{ id: "claude_code", label: "CC", scan: scanClaudeCode },
|
||||
{ id: "codex", label: "Codex", scan: (since) => scanCodex(since) },
|
||||
{ id: "gemini", label: "Gemini", scan: (since) => scanGemini(since) },
|
||||
{ id: "kimi_code", label: "Kimi Code", scan: (since) => scanKimiCode(since) },
|
||||
{ id: "omp", label: "OMP", scan: scanOmp },
|
||||
];
|
||||
|
||||
function emptyCounts(): ToolCounts {
|
||||
return Object.fromEntries(SCANNERS.map((s) => [s.id, 0])) as ToolCounts;
|
||||
}
|
||||
|
||||
function countTotal(counts: ToolCounts): number {
|
||||
return SCANNERS.reduce((n, s) => n + counts[s.id], 0);
|
||||
}
|
||||
|
||||
// ── Deduplication ──────────────────────────────────────────────────────────
|
||||
|
||||
async function resolveAndDeduplicate(sessions: Session[]): Promise<Repo[]> {
|
||||
@@ -555,7 +685,7 @@ async function resolveAndDeduplicate(sessions: Session[]): Promise<Repo[]> {
|
||||
}
|
||||
}
|
||||
|
||||
const sessionCounts = { claude_code: 0, codex: 0, gemini: 0 };
|
||||
const sessionCounts = emptyCounts();
|
||||
for (const s of data.sessions) {
|
||||
sessionCounts[s.tool]++;
|
||||
}
|
||||
@@ -570,11 +700,7 @@ async function resolveAndDeduplicate(sessions: Session[]): Promise<Repo[]> {
|
||||
}
|
||||
|
||||
// Sort by total sessions descending
|
||||
repos.sort(
|
||||
(a, b) =>
|
||||
b.sessions.claude_code + b.sessions.codex + b.sessions.gemini -
|
||||
(a.sessions.claude_code + a.sessions.codex + a.sessions.gemini)
|
||||
);
|
||||
repos.sort((a, b) => countTotal(b.sessions) - countTotal(a.sessions));
|
||||
|
||||
return repos;
|
||||
}
|
||||
@@ -588,21 +714,19 @@ async function main() {
|
||||
|
||||
// Run all scanners
|
||||
const cwdStats: CwdScanStats = { oversizedLines: 0 };
|
||||
const ccSessions = scanClaudeCode(sinceDate, cwdStats);
|
||||
const codexSessions = scanCodex(sinceDate);
|
||||
const geminiSessions = scanGemini(sinceDate);
|
||||
|
||||
const allSessions = [...ccSessions, ...codexSessions, ...geminiSessions];
|
||||
const byTool = SCANNERS.map((s) => ({ ...s, sessions: s.scan(sinceDate, cwdStats) }));
|
||||
const agySessions = countAntigravity(sinceDate);
|
||||
const allSessions = byTool.flatMap((t) => t.sessions);
|
||||
const codexSessions = byTool.find((t) => t.id === "codex")!.sessions;
|
||||
const toolCounts = byTool.map((t) => `${t.label}: ${t.sessions.length}`).join(", ");
|
||||
|
||||
// Summary to stderr
|
||||
console.error(
|
||||
`Discovered: ${ccSessions.length} CC sessions, ${codexSessions.length} Codex sessions, ${geminiSessions.length} Gemini sessions`
|
||||
);
|
||||
console.error(`Discovered: ${toolCounts}; Antigravity: ${agySessions} (repo unattributed)`);
|
||||
|
||||
if (cwdStats.oversizedLines > 0) {
|
||||
console.error(
|
||||
`Skipped ${cwdStats.oversizedLines} Claude Code JSONL line(s) over 1 MiB while reading session cwds; ` +
|
||||
`a session whose cwd appears only after such lines counts with its project directory's other sessions.`,
|
||||
`Skipped ${cwdStats.oversizedLines} session JSONL line(s) over 1 MiB while reading session cwds; ` +
|
||||
`a Claude Code session whose cwd appears only after such lines counts with its project directory's other sessions.`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -611,23 +735,25 @@ async function main() {
|
||||
|
||||
console.error(`→ ${repos.length} unique repos`);
|
||||
|
||||
// Count per-tool repo counts
|
||||
const ccRepos = new Set(repos.filter((r) => r.sessions.claude_code > 0).map((r) => r.remote)).size;
|
||||
const codexRepos = new Set(repos.filter((r) => r.sessions.codex > 0).map((r) => r.remote)).size;
|
||||
const geminiRepos = new Set(repos.filter((r) => r.sessions.gemini > 0).map((r) => r.remote)).size;
|
||||
|
||||
const codexOriginators = countCodexOriginators(codexSessions);
|
||||
const tools = Object.fromEntries(
|
||||
byTool.map((t) => [
|
||||
t.id,
|
||||
{
|
||||
label: t.label,
|
||||
total_sessions: t.sessions.length,
|
||||
repos: repos.filter((r) => r.sessions[t.id] > 0).length,
|
||||
...(t.id === "codex" ? { originators: codexOriginators } : {}),
|
||||
},
|
||||
]),
|
||||
) as Record<ToolId, ToolSummary>;
|
||||
|
||||
const result: DiscoveryResult = {
|
||||
window: since,
|
||||
start_date: startDate,
|
||||
repos,
|
||||
tools: {
|
||||
claude_code: { total_sessions: ccSessions.length, repos: ccRepos },
|
||||
codex: { total_sessions: codexSessions.length, repos: codexRepos, originators: codexOriginators },
|
||||
gemini: { total_sessions: geminiSessions.length, repos: geminiRepos },
|
||||
},
|
||||
total_sessions: allSessions.length,
|
||||
tools: { ...tools, agy: { label: "Antigravity", total_sessions: agySessions, repos: 0, unattributed: true } },
|
||||
total_sessions: allSessions.length + agySessions,
|
||||
total_repos: repos.length,
|
||||
};
|
||||
|
||||
@@ -636,17 +762,16 @@ async function main() {
|
||||
} else {
|
||||
// Summary format
|
||||
console.log(`Window: ${since} (since ${startDate})`);
|
||||
console.log(`Sessions: ${allSessions.length} total (CC: ${ccSessions.length}, Codex: ${codexSessions.length}, Gemini: ${geminiSessions.length})`);
|
||||
console.log(`Sessions: ${allSessions.length + agySessions} total (${toolCounts}, Antigravity: ${agySessions})`);
|
||||
if (codexSessions.length > 0) console.log(` Codex originators: ${formatCodexOriginators(codexOriginators)}`);
|
||||
if (agySessions > 0) console.log(` Antigravity sessions are not attributed to a repo`);
|
||||
console.log(`Repos: ${repos.length} unique`);
|
||||
console.log("");
|
||||
for (const repo of repos) {
|
||||
const total = repo.sessions.claude_code + repo.sessions.codex + repo.sessions.gemini;
|
||||
const tools = [];
|
||||
if (repo.sessions.claude_code > 0) tools.push(`CC:${repo.sessions.claude_code}`);
|
||||
if (repo.sessions.codex > 0) tools.push(`Codex:${repo.sessions.codex} (${formatCodexOriginators(repo.codex_originators)})`);
|
||||
if (repo.sessions.gemini > 0) tools.push(`Gemini:${repo.sessions.gemini}`);
|
||||
console.log(` ${repo.name} (${total} sessions) — ${tools.join(", ")}`);
|
||||
const parts = byTool
|
||||
.filter((t) => repo.sessions[t.id] > 0)
|
||||
.map((t) => (t.id === "codex" ? `Codex:${repo.sessions.codex} (${formatCodexOriginators(repo.codex_originators)})` : `${t.label}:${repo.sessions[t.id]}`));
|
||||
console.log(` ${repo.name} (${countTotal(repo.sessions)} sessions) — ${parts.join(", ")}`);
|
||||
console.log(` Remote: ${repo.remote}`);
|
||||
console.log(` Paths: ${repo.paths.join(", ")}`);
|
||||
}
|
||||
|
||||
+24
-8
@@ -8,10 +8,16 @@
|
||||
*
|
||||
* Usage:
|
||||
* gstack-harness detect [--json]
|
||||
* gstack-harness register <target> [--skills router|hybrid|all] [--skills-dir <dir>] [--context <file>] [--print] [--yes] [--no-registry]
|
||||
* gstack-harness register <target> [--skills router|hybrid|all] [--skills-dir <dir>] [--context <file>] [--start-hook|--no-start-hook] [--print] [--yes] [--no-registry]
|
||||
* --context <file> writes gstack's marked routing block into that file only
|
||||
* (opt-in: setup, upgrades and refresh never add one, and a block you
|
||||
* delete stays deleted); it prints the file and its scope before writing
|
||||
* --start-hook (OpenClaw, Hermes; opt-in) adds the gstack-start plugin, which
|
||||
* puts a one-line note on every user turn to run a gstack skill's start
|
||||
* command, and turns it on through the harness's own commands (this edits
|
||||
* openclaw.json or Hermes's config.yaml); --no-start-hook takes it out. The
|
||||
* choice is saved; GSTACK_HARNESS_START_HOOK=1|0 or gstack-config
|
||||
* harness_start_hook on|off make it too. Exit 4: skills registered, hook not active
|
||||
* gstack-harness unregister <target|registration id> [--remote]
|
||||
* gstack-harness status [--json] [--no-check]
|
||||
* gstack-harness refresh
|
||||
@@ -46,11 +52,18 @@ function receiptLines(r: RegisterResult): string[] {
|
||||
'gstack-harness: ' + t.displayName + ': ' + r.skills + ' gstack skills ' + how + ' at ' + r.dest + ' (' + r.status + (r.written ? ', ' + r.written + ' files written' : '') + ')',
|
||||
...(r.router ? [' router: ' + r.router + ' (register with --skills all to list every skill natively)'] : []),
|
||||
...r.kept.map(k => ' kept your edit: ' + k),
|
||||
...(r.hook ? [' start hook: ' + r.hook.state + (r.hook.state === 'active' ? ' (new sessions get the note; a running gateway picks it up when it reloads)' : '') + (r.hook.detail ? ' - ' + r.hook.detail : '') + (r.hook.fix ? '; ' + r.hook.fix : '')] : []),
|
||||
' ledger: ' + r.ledger,
|
||||
' remove: ' + self + ' unregister ' + r.id,
|
||||
];
|
||||
}
|
||||
const receipt = (r: RegisterResult) => console.log(receiptLines(r).join('\n'));
|
||||
/** A start hook this run turned on, or was asked for, that did not end up active: exit 4 after the receipt. */
|
||||
function hookFailed(r: RegisterResult): number {
|
||||
if (!r.hook?.required || r.hook.state === 'active') return 0;
|
||||
console.error(new HarnessError('HARNESS_HOOK_INACTIVE', TARGETS[r.target].displayName + ' start hook is ' + r.hook.state + (r.hook.detail ? ': ' + r.hook.detail : '') + (r.hook.fix ? '. Turn it on: ' + r.hook.fix : '')).report());
|
||||
return 4;
|
||||
}
|
||||
|
||||
function usage(code: number): never {
|
||||
const text = fs.readFileSync(process.argv[1], 'utf8').split('\n').slice(1).filter(l => l.startsWith(' *')).map(l => l.slice(3)).join('\n');
|
||||
@@ -73,6 +86,7 @@ async function main(): Promise<number> {
|
||||
const mode = flag('--skills') as SkillsMode | undefined;
|
||||
const skillsDir = flag('--skills-dir'); const context = flag('--context');
|
||||
const print = bool('--print'); const yes = bool('--yes'); const noRegistry = bool('--no-registry');
|
||||
const startHook = bool('--start-hook') ? true : bool('--no-start-hook') ? false : undefined;
|
||||
const t = getTarget(argv.shift() ?? '');
|
||||
if (print) { console.log(printPayload(checkout)); return 0; }
|
||||
if (t.name === 'qm') {
|
||||
@@ -88,10 +102,10 @@ async function main(): Promise<number> {
|
||||
return 3;
|
||||
}
|
||||
if (context) (json ? console.error : console.log)('gstack-harness: context block: ' + path.resolve(context) + ' (' + contextScope(t, context, env, home) + ' scope)');
|
||||
const r = register({ ...base, target: t, mode, skillsDir, context, registry: !noRegistry });
|
||||
const r = register({ ...base, target: t, mode, skillsDir, context, registry: !noRegistry, startHook });
|
||||
if (json) console.log(JSON.stringify({ ...r, receipt: receiptLines(r).map(l => l.trim()).join('; ') }));
|
||||
else receipt(r);
|
||||
return 0;
|
||||
return hookFailed(r);
|
||||
}
|
||||
case 'unregister': {
|
||||
const remote = bool('--remote');
|
||||
@@ -113,23 +127,25 @@ async function main(): Promise<number> {
|
||||
const extra = (['muse', 'grok'] as TargetName[]).filter(n => TARGETS[n].present(env, home)).map(n => ({ target: n, ...(check ? harnessSees(TARGETS[n], '', env, ['review', 'gstack-review'], true) : { visible: 'unknown' as const }) }));
|
||||
if (json) { console.log(JSON.stringify({ registrations: rows, detected: extra })); return 0; }
|
||||
if (!rows.length && !extra.length) console.log('gstack-harness: no harness registrations on this machine');
|
||||
for (const r of rows) console.log(r.target + '\t' + r.mode + '\t' + r.dest + '\t' + r.files + ' files\t' + (r.stale ? 'stale (' + r.version + '; refresh: ' + self + ' refresh)' : r.version) + '\tvisible to harness: ' + r.visible + (r.checked ? ' (' + r.checked + ')' : '') + (r.missing?.length ? ' missing: ' + r.missing.slice(0, 5).join(', ') : '') + '\t' + r.id);
|
||||
for (const r of rows) console.log(r.target + '\t' + r.mode + '\t' + r.dest + '\t' + r.files + ' files\t' + (r.stale ? 'stale (' + r.version + '; refresh: ' + self + ' refresh)' : r.version) + '\tvisible to harness: ' + r.visible + (r.checked ? ' (' + r.checked + ')' : '') + (r.missing?.length ? ' missing: ' + r.missing.slice(0, 5).join(', ') : '') + '\t' + r.id
|
||||
+ (r.hook ? '\n start hook: ' + r.hook.state + (r.hook.detail ? ' - ' + r.hook.detail : '') + (r.hook.state !== 'active' && r.hook.fix ? '; ' + r.hook.fix : '') : ''));
|
||||
for (const x of extra) console.log(x.target + '\tdetect\t~/.claude/skills\t' + (x.visible === 'yes' ? 'discovered (Claude-rendered, unverified)' : 'unverified') + (x.checked ? ' (' + x.checked + ')' : ''));
|
||||
return 0;
|
||||
}
|
||||
case 'refresh': {
|
||||
for (const r of refresh(base)) if (r.status !== 'unchanged') receipt(r);
|
||||
return 0;
|
||||
let code = 0;
|
||||
for (const r of refresh(base)) { if (r.status !== 'unchanged' || r.hook?.required || r.hook?.retried || r.hook?.detail) receipt(r); code ||= hookFailed(r); }
|
||||
return code;
|
||||
}
|
||||
case 'auto': {
|
||||
const d = detect(env, home);
|
||||
let failed = 0;
|
||||
const done = new Set<string>();
|
||||
for (const n of d.inside.filter(n => TARGETS[n].mechanism === 'skill-folder')) {
|
||||
try { const r = register({ ...base, target: TARGETS[n], registry: true }); done.add(r.id); receipt(r); }
|
||||
try { const r = register({ ...base, target: TARGETS[n], registry: true }); done.add(r.id); receipt(r); hookFailed(r); }
|
||||
catch (e) { if (e instanceof HarnessError) { console.error(e.report()); failed = 1; } else throw e; }
|
||||
}
|
||||
try { for (const r of refresh(base, done)) if (r.status !== 'unchanged') receipt(r); }
|
||||
try { for (const r of refresh(base, done)) { if (r.status !== 'unchanged' || r.hook?.required || r.hook?.retried || r.hook?.detail) receipt(r); hookFailed(r); } }
|
||||
catch (e) { if (e instanceof HarnessError) { console.error(e.report()); failed = 1; } else throw e; }
|
||||
for (const n of d.present.filter(n => !d.inside.includes(n) && TARGETS[n].mechanism === 'skill-folder' && !listLedgers(stateRoot).some(l => l.ledger.meta.target === n))) {
|
||||
console.log('gstack-harness: ' + TARGETS[n].displayName + ' is installed here; to give it gstack skills: ' + self + ' register ' + n);
|
||||
|
||||
@@ -20,12 +20,25 @@
|
||||
# (ledgers without the row: when only whitespace is left). Folders gstack
|
||||
# created are removed only when empty. A QM pack is never touched here: detaching it from
|
||||
# the org is `gstack-harness unregister qm --remote`.
|
||||
#
|
||||
# A registration with the start hook on (meta start_hook on) owns a second root, the
|
||||
# gstack-start plugin folder in the harness profile (meta hook_root). Before its files go,
|
||||
# gstack_harness_hook_release turns the plugin off through the harness's own CLI
|
||||
# (lib/harness-start-hook.ts), the same step `register --no-start-hook` runs.
|
||||
|
||||
_gstack_harness_sha() {
|
||||
if command -v sha256sum >/dev/null 2>&1; then sha256sum ${1:+"$1"} | cut -d' ' -f1
|
||||
else shasum -a 256 ${1:+"$1"} | cut -d' ' -f1; fi
|
||||
}
|
||||
|
||||
# True when $1 is below $2 and nothing between them is a symlink ($2 itself may be one).
|
||||
_gstack_harness_below() {
|
||||
local p="$1" top="$2"
|
||||
[ -n "$top" ] || return 1
|
||||
case "$p" in "$top"/*) ;; *) return 1 ;; esac
|
||||
while [ "$p" != "$top" ]; do [ -L "$p" ] && return 1; p="$(dirname "$p")"; done
|
||||
}
|
||||
|
||||
# True when $1 is below $2 and no path from $2 down to $1 is a symlink.
|
||||
_gstack_harness_owned() {
|
||||
local p="$1" dest="$2"
|
||||
@@ -57,16 +70,53 @@ _gstack_harness_cut_block() {
|
||||
rm -f "$tmp"
|
||||
}
|
||||
|
||||
# Turns a registration's start hook off in its harness, from the ledger: OpenClaw drops
|
||||
# plugins.entries.gstack-start; Hermes removes the plugin and its config entries when its
|
||||
# folder holds exactly the files gstack wrote, unedited, and is reached without a symlink
|
||||
# below the profile; otherwise it only disables it so an edit or a kept copy survives. The harness runs with PATH, HOME and the profile only. Without the harness on
|
||||
# PATH it prints the command to run instead.
|
||||
gstack_harness_hook_release() {
|
||||
local ledger="$1" kind a b target="" on="" profile="" root="" edited=0 to="" sub cmd n=0 safe=1 total
|
||||
[ -f "$ledger" ] || return 0
|
||||
while IFS=$'\t' read -r kind a b; do
|
||||
case "$kind" in
|
||||
meta) case "$a" in target) target="$b" ;; start_hook) on="$b" ;; hook_profile) profile="$b" ;; hook_root) root="$b" ;; esac ;;
|
||||
file) if [ -n "$root" ]; then case "$b" in "$root"/*) if [ -f "$b" ]; then n=$((n + 1)); [ "$(_gstack_harness_sha "$b")" != "$a" ] && edited=1; fi ;; esac; fi ;;
|
||||
esac
|
||||
done < "$ledger"
|
||||
{ [ "$on" = on ] && [ -n "$profile" ] && [ -n "$root" ]; } || return 0
|
||||
if [ -d "$root" ]; then
|
||||
_gstack_harness_below "$root" "$profile" || { safe=0; edited=1; }
|
||||
total="$(find "$root" -type f ! -path "$root/__pycache__/*" 2>/dev/null | wc -l | tr -d ' ')"
|
||||
[ "${total:-0}" -gt "$n" ] && edited=1
|
||||
fi
|
||||
command -v timeout >/dev/null 2>&1 && to="timeout 60"
|
||||
case "$target" in
|
||||
openclaw) cmd="config unset plugins.entries.gstack-start"
|
||||
if command -v openclaw >/dev/null 2>&1 && env -i PATH="$PATH" HOME="$HOME" OPENCLAW_STATE_DIR="$profile" $to openclaw $cmd </dev/null >/dev/null 2>&1; then :
|
||||
else echo " start hook: turn it off in OpenClaw yourself: OPENCLAW_STATE_DIR=\"$profile\" openclaw $cmd"; fi ;;
|
||||
hermes) sub=remove; [ "$edited" = 1 ] && sub=disable
|
||||
if command -v hermes >/dev/null 2>&1 && env -i PATH="$PATH" HOME="$HOME" HERMES_HOME="$profile" $to hermes plugins "$sub" gstack-start </dev/null >/dev/null 2>&1; then :
|
||||
else echo " start hook: turn it off in Hermes yourself: HERMES_HOME=\"$profile\" hermes plugins disable gstack-start"; fi ;;
|
||||
esac
|
||||
if [ "$safe" = 1 ] && [ -d "$root/__pycache__" ] && _gstack_harness_owned "$root/__pycache__" "$root"; then
|
||||
rm -f "$root"/__pycache__/*.pyc
|
||||
rmdir "$root/__pycache__" 2>/dev/null
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
gstack_harness_unregister_ledger() {
|
||||
local ledger="$1" stamp kind a b target="" dest="" pack="" pad="" padpath=""
|
||||
local ledger="$1" stamp kind a b target="" dest="" pack="" pad="" padpath="" hroot="" hprof=""
|
||||
: "${GSTACK_STATE_ROOT:?gstack-harness-ledger: GSTACK_STATE_ROOT is not set}"
|
||||
[ -f "$ledger" ] || return 0
|
||||
stamp="$(date -u +%Y-%m-%dT%H-%M-%SZ)"
|
||||
gstack_harness_hook_release "$ledger"
|
||||
while IFS=$'\t' read -r kind a b; do
|
||||
case "$kind" in
|
||||
meta) case "$a" in target) target="$b" ;; dest) dest="$b" ;; qm_pack) pack="$b" ;; esac ;;
|
||||
meta) case "$a" in target) target="$b" ;; dest) dest="$b" ;; qm_pack) pack="$b" ;; hook_root) hroot="$b" ;; hook_profile) hprof="$b" ;; esac ;;
|
||||
file)
|
||||
{ [ -f "$b" ] && _gstack_harness_owned "$b" "$dest"; } || continue
|
||||
{ [ -f "$b" ] && { _gstack_harness_owned "$b" "$dest" || { _gstack_harness_owned "$b" "$hroot" && _gstack_harness_below "$hroot" "$hprof"; }; }; } || continue
|
||||
if [ "$(_gstack_harness_sha "$b")" = "$a" ]; then rm -f "$b"
|
||||
else mv "$b" "$b.edited-$stamp" && echo " kept your edit: $b.edited-$stamp"; fi ;;
|
||||
blockpad) pad="$a"; padpath="$b" ;;
|
||||
@@ -75,7 +125,10 @@ gstack_harness_unregister_ledger() {
|
||||
done < "$ledger"
|
||||
while IFS=$'\t' read -r kind a b; do
|
||||
[ "$kind" = dir ] && [ -d "$b" ] && [ ! -L "$b" ] || continue
|
||||
case "$b" in "$dest"/*) _gstack_harness_owned "$b" "$dest" || continue ;; esac
|
||||
case "$b" in
|
||||
"$dest"/*) _gstack_harness_owned "$b" "$dest" || continue ;;
|
||||
*) if [ -n "$hprof" ]; then case "$b" in "$hprof"/*) _gstack_harness_below "$b" "$hprof" || continue ;; esac; fi ;;
|
||||
esac
|
||||
rmdir "$b" 2>/dev/null
|
||||
done < "$ledger"
|
||||
case "$target" in
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
# host:render-dir for every host whose render lives in its own checkout dir.
|
||||
# Claude renders into the tracked tree. test/host-renders.test.ts keeps this
|
||||
# equal to hostSubdir in hosts/*.ts.
|
||||
GSTACK_HOST_RENDER_DIRS="codex:.agents kiro:.kiro factory:.factory opencode:.opencode cursor:.cursor copilot:.copilot agy:.agy slate:.slate openclaw:.openclaw hermes:.hermes gbrain:.gbrain"
|
||||
GSTACK_HOST_RENDER_DIRS="codex:.agents kiro:.kiro factory:.factory opencode:.opencode cursor:.cursor copilot:.copilot agy:.agy gemini:.gemini-cli slate:.slate openclaw:.openclaw hermes:.hermes gbrain:.gbrain"
|
||||
|
||||
gstack_render_hosts_file() { printf '%s/.gstack-installed-hosts\n' "$1"; }
|
||||
|
||||
|
||||
@@ -184,7 +184,7 @@ gstack_install_render_for() {
|
||||
gstack_host_tier() {
|
||||
case "$1" in
|
||||
claude) echo full ;;
|
||||
codex|kiro|factory|opencode|cursor|copilot|agy|openclaw|hermes) echo experimental ;;
|
||||
codex|kiro|factory|opencode|cursor|copilot|agy|gemini|openclaw|hermes) echo experimental ;;
|
||||
slate|gbrain) echo instruction-only ;;
|
||||
*) echo unknown ;;
|
||||
esac
|
||||
@@ -256,6 +256,7 @@ gstack_install_known_roots() {
|
||||
printf 'cursor\tglobal\t%s\t%s\n' "$HOME/.cursor/skills" "$HOME/.cursor/skills/gstack"
|
||||
printf 'copilot\tglobal\t%s\t%s\n' "$HOME/.copilot/skills" "$HOME/.copilot/skills/gstack"
|
||||
printf 'agy\tglobal\t%s\t%s\n' "$HOME/.gemini/antigravity-cli/skills" "$HOME/.gemini/antigravity-cli/skills/gstack"
|
||||
printf 'gemini\tglobal\t%s\t%s\n' "$HOME/.gemini/skills" "$HOME/.gemini/skills/gstack"
|
||||
proj="$(git rev-parse --show-toplevel 2>/dev/null || true)"
|
||||
if [ -n "$proj" ] && [ "$proj" != "$HOME" ]; then
|
||||
printf 'claude\tproject\t%s\t%s\n' "$proj/.claude/skills" "$proj/.claude/skills/gstack"
|
||||
|
||||
@@ -91,8 +91,12 @@ for path in paths:
|
||||
sys.exit(1)
|
||||
|
||||
# Timestamp-ordered entries first (group 0), then hash-ordered (group 1).
|
||||
# UTF-8 bytes and LF on every platform: print() encodes with the stdout code
|
||||
# page and writes CRLF on Windows (#3659).
|
||||
out = sys.stdout.buffer
|
||||
for line, _ in sorted(seen.items(), key=lambda item: item[1]):
|
||||
print(line)
|
||||
out.write(line.encode('utf-8') + b'\n')
|
||||
out.flush()
|
||||
PYEOF
|
||||
|
||||
_PYEXIT=$?
|
||||
|
||||
@@ -4,12 +4,32 @@ import * as path from 'node:path';
|
||||
import { atomicWriteSync } from '../lib/fs-atomic.ts';
|
||||
import { assessOfficeHoursReviews, extractOfficeHoursReviewBlock, loadOfficeHoursReviews, officeHoursDesignChanges, officeHoursMaxRounds, officeHoursSnapshotChanges, officeHoursVerdictReceipt, parseOfficeHoursVerdictReceipt, renderOfficeHoursReview, renderOfficeHoursReviewerPrompt, replaceOfficeHoursReviewBlock, verifyOfficeHoursCitations, verifyOfficeHoursVerdictReceipt } from '../lib/office-hours-review.ts';
|
||||
import { parseSpecFixes, verifySpecFixesAtCap } from '../lib/spec-review-cap.ts';
|
||||
import { repoRootOf, resolveProbe, runProbe } from '../lib/office-hours-probe.ts';
|
||||
import { resultLine } from '../lib/result-codes.ts';
|
||||
|
||||
const shellQuote = value => `'${value.replaceAll("'", `'\\''`)}'`;
|
||||
|
||||
// The reviewer's one execution right: --help for a gstack bin or a listed read-only CLI (lib/office-hours-probe.ts).
|
||||
function probe(args) {
|
||||
if (args.length < 2 || args.length > 4 || args[0] !== '--help') throw new Error('usage: gstack-office-hours-review probe --help <command> [<subcommand> [<subcommand>]]');
|
||||
const named = args.slice(1).join(' ');
|
||||
const where = { pathEnv: process.env.PATH ?? '', repoRoot: repoRootOf(process.cwd()) };
|
||||
const target = resolveProbe(args[1], { ...where, gstackBin: path.dirname(fs.realpathSync(process.argv[1])) }, args.slice(2));
|
||||
if ('refused' in target) {
|
||||
console.error(resultLine('gstack-office-hours-review probe', 'OFFICE_HOURS_PROBE_REFUSED', `${named}: ${target.refused}`));
|
||||
process.exitCode = 3;
|
||||
return;
|
||||
}
|
||||
const r = runProbe(target.exe, args.slice(2), where);
|
||||
console.log(`PROBE: ${named} ${r.timedOut ? 'timeout' : `exit=${r.status}`}`);
|
||||
if (r.output) console.log(r.output);
|
||||
if (r.timedOut) process.exitCode = 1;
|
||||
}
|
||||
|
||||
function main(args) {
|
||||
const command = args.shift();
|
||||
if (!['check', 'prepare', 'seal', 'finalize'].includes(command)) throw new Error('usage: gstack-office-hours-review check --receipt <reviewer response> <round.json...> | prepare --design <doc> --out-dir <reviewdir> [round.json...] | seal --design <doc> --verdict <round-N.json> [preceding round.json...] | finalize --design <doc> [--report <report>] [--unreviewed <reason>] [--fixes <fixes.json>] <round.json...> (each also takes --max-rounds 1|2|3, default 3)');
|
||||
if (command === 'probe') return probe(args);
|
||||
if (!['check', 'prepare', 'seal', 'finalize'].includes(command)) throw new Error('usage: gstack-office-hours-review probe --help <command> [<subcommand>] | check --receipt <reviewer response> <round.json...> | prepare --design <doc> --out-dir <reviewdir> [round.json...] | seal --design <doc> --verdict <round-N.json> [preceding round.json...] | finalize --design <doc> [--report <report>] [--unreviewed <reason>] [--fixes <fixes.json>] <round.json...> (each also takes --max-rounds 1|2|3, default 3)');
|
||||
const options = {};
|
||||
const files = [];
|
||||
while (args.length) {
|
||||
@@ -82,7 +102,7 @@ function main(args) {
|
||||
if (!captured) atomicWriteSync(snapshotPath, design, { mode: 0o600 });
|
||||
if (!existing) atomicWriteSync(promptPath, prompt, { mode: 0o600 });
|
||||
const seal = [shellQuote(fs.realpathSync(process.argv[1])), 'seal', ...(maxRounds === 3 ? [] : [`--max-rounds ${maxRounds}`]), '--design', shellQuote(designPath), '--verdict', shellQuote(verdictPath), ...files.map(file => shellQuote(fs.realpathSync(file)))].join(' ');
|
||||
const dispatch = `Read ${JSON.stringify(promptPath)} and execute the independent spec review as instructed.\nDocument: ${designPath}\nPrompt: ${promptPath}\nVerdict: ${verdictPath}\nSeal: ${seal}`;
|
||||
const dispatch = `Read ${JSON.stringify(promptPath)} and execute the independent spec review as instructed.\nDocument: ${designPath}\nPrompt: ${promptPath}\nVerdict: ${verdictPath}\nProbe: ${shellQuote(fs.realpathSync(process.argv[1]))} probe --help <command> [<subcommand>]\nSeal: ${seal}`;
|
||||
console.log(JSON.stringify({ round, promptPath, verdictPath, dispatch }));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*
|
||||
* gstack-outside-voice run --runner codex-cli|api|host-subagent --prompt <file> --out <file>
|
||||
* [--model <id>] [--result <file>] [--native-model <id>] [--fallback <runner>]
|
||||
* [--gate review|structured|spec|proposal] [--timeout-ms N] [--cwd <dir>] [--json]
|
||||
* [--gate review|structured|spec|proposal] [--timeout-ms N] [--max-tokens N] [--cwd <dir>] [--json]
|
||||
* gstack-outside-voice runners [--model <id>] [--json] the runner table: capabilities and availability here
|
||||
* gstack-outside-voice family <model-id> the model's family (same-family rule)
|
||||
*
|
||||
@@ -20,18 +20,21 @@ import { EXIT, exitTableHelp } from '../lib/headless-artifacts';
|
||||
import type { OutsideGate } from '../lib/outside-review-result';
|
||||
import { RUNNER_IDS, createRunner, familyConflict, modelFamily, outsideStatusLine, runnerTable, selectRunner, type RunnerId } from '../lib/outside-voice-runner';
|
||||
import { outsideConsent } from '../lib/outside-consent';
|
||||
import { apiTimeoutMs } from '../lib/outside-voice-api';
|
||||
import { resultLine } from '../lib/result-codes';
|
||||
|
||||
const HELP = `gstack-outside-voice — one outside voice, three runners (docs/unattended.md)
|
||||
|
||||
run --runner <r> --prompt <file> --out <file> [--model <id>] [--result <file>] [--native-model <id>]
|
||||
[--fallback <r>] [--gate review|structured|spec|proposal] [--timeout-ms N] [--cwd <dir>] [--json]
|
||||
[--fallback <r>] [--gate review|structured|spec|proposal] [--timeout-ms N] [--max-tokens N] [--cwd <dir>] [--json]
|
||||
runners [--model <id>] [--json] the runner table: id, capabilities, available here
|
||||
family <model-id> print the model family (anthropic, openai, google, ...)
|
||||
|
||||
Runners: codex-cli (repository review via codex exec), api (supplied-input review via
|
||||
OPENAI_API_KEY/ANTHROPIC_API_KEY; needs --model), host-subagent (binds --result from a
|
||||
cross-family subagent the host ran; needs --model). --native-model enforces the same-family
|
||||
cross-family subagent the host ran; needs --model). --max-tokens caps the api runner's output
|
||||
(Anthropic default 16384; its default time limit grows with it); a response cut off at the limit
|
||||
is unavailable (truncated). --native-model enforces the same-family
|
||||
rule. GSTACK_OUTSIDE_RUNNER names the fallback when --fallback is absent.
|
||||
Prints OUTSIDE_STATUS: <status> provider=<runner> model=<id> family=<f> and writes <out>.meta.json.
|
||||
codex_reviews=disabled (or an unreadable config) refuses every runner before it starts:
|
||||
@@ -75,9 +78,12 @@ async function main(argv: string[]): Promise<number> {
|
||||
if (!fs.existsSync(promptFile)) return usage(`prompt file not found: ${promptFile}`);
|
||||
const gate = (flag(rest, '--gate') ?? 'review') as OutsideGate;
|
||||
if (!['review', 'structured', 'spec', 'proposal'].includes(gate)) return usage('--gate must be review, structured, spec or proposal');
|
||||
const maxTokensText = flag(rest, '--max-tokens');
|
||||
const maxTokens = maxTokensText === undefined ? undefined : Number(maxTokensText);
|
||||
if (maxTokens !== undefined && (!Number.isInteger(maxTokens) || maxTokens <= 0)) return usage('--max-tokens needs a positive integer');
|
||||
const timeoutText = flag(rest, '--timeout-ms');
|
||||
const timeoutMs = timeoutText === undefined ? 300_000 : Number(timeoutText);
|
||||
if (!Number.isInteger(timeoutMs) || timeoutMs <= 0) return usage('--timeout-ms needs a positive integer');
|
||||
const timeoutMs = timeoutText === undefined ? undefined : Number(timeoutText);
|
||||
if (timeoutMs !== undefined && (!Number.isInteger(timeoutMs) || timeoutMs <= 0)) return usage('--timeout-ms needs a positive integer');
|
||||
const fallbackText = flag(rest, '--fallback') ?? process.env.GSTACK_OUTSIDE_RUNNER;
|
||||
if (fallbackText !== undefined && !isRunner(fallbackText)) return usage(`--fallback must be one of ${RUNNER_IDS.join(', ')}`);
|
||||
const model = flag(rest, '--model');
|
||||
@@ -110,7 +116,7 @@ async function main(argv: string[]): Promise<number> {
|
||||
const stop = () => runner.cancel();
|
||||
process.on('SIGTERM', stop);
|
||||
process.on('SIGINT', stop);
|
||||
const result = await runner.run({ promptFile: path.resolve(promptFile), outFile: path.resolve(outFile), model, timeoutMs, cwd, gate, resultFile: resultFile ? path.resolve(resultFile) : undefined });
|
||||
const result = await runner.run({ promptFile: path.resolve(promptFile), outFile: path.resolve(outFile), model, timeoutMs: timeoutMs ?? (choice.runner === 'api' ? apiTimeoutMs(maxTokens) : 300_000), cwd, gate, maxTokens, resultFile: resultFile ? path.resolve(resultFile) : undefined });
|
||||
fs.mkdirSync(path.dirname(path.resolve(outFile)), { recursive: true });
|
||||
fs.writeFileSync(`${path.resolve(outFile)}.meta.json`, JSON.stringify(result, null, 2) + '\n');
|
||||
if (json) process.stdout.write(JSON.stringify(result) + '\n');
|
||||
|
||||
+23
-10
@@ -3,26 +3,30 @@
|
||||
* gstack-plan-reality — the reality rows of a plan review (plan D1).
|
||||
*
|
||||
* gstack-plan-reality rows --phase <ceo|eng|dx|design> [--scope <csv>] [--deep] [--json]
|
||||
* gstack-plan-reality check --phase <p> <review-file|-> [--scope <csv>] [--session-kind <kind>] [--plan <reviewed plan>] [--json]
|
||||
* gstack-plan-reality check --phase <p> <review-file|-> [--scope <csv>] [--session-kind <kind>] [--plan <reviewed plan>] [--root <dir>]... [--json]
|
||||
*
|
||||
* Logic: lib/plan-reality.ts. Codes: lib/result-codes.ts (REALITY_ROW_MISSING, REALITY_RECEIPT_MISSING).
|
||||
* Logic: lib/plan-reality.ts, lib/plan-reality-receipts.ts. Codes: lib/result-codes.ts (REALITY_ROW_MISSING, REALITY_RECEIPT_MISSING, REALITY_RECEIPT_UNRESOLVED).
|
||||
*/
|
||||
import * as fs from 'node:fs';
|
||||
import { EXIT, exitTableHelp } from '../lib/headless-artifacts';
|
||||
import { checkRealityRows, parseScopes, REALITY_LINE_SHAPE, REALITY_SCOPES, renderRows, requiredRows } from '../lib/plan-reality';
|
||||
import { checkRealityRows, parseRealityLines, parseScopes, REALITY_LINE_SHAPE, REALITY_SCOPES, renderRows, requiredRows } from '../lib/plan-reality';
|
||||
import { defaultRoots, resolveReceipts } from '../lib/plan-reality-receipts';
|
||||
import { resultLine } from '../lib/result-codes';
|
||||
|
||||
const HELP = `gstack-plan-reality — reality rows for plan reviews
|
||||
|
||||
rows --phase <p> [--scope <csv>] [--deep] [--json] the rows this review must carry (tier 1 always, tier 2 by scope)
|
||||
check --phase <p> <file|-> [--scope <csv>] [--session-kind <kind>] [--plan <reviewed plan>] [--json]
|
||||
check --phase <p> <file|-> [--scope <csv>] [--session-kind <kind>] [--plan <reviewed plan>] [--root <dir>]... [--json]
|
||||
parse the review's REALITY: lines; exit 1 with the missing rows
|
||||
--plan: a number inside a "quoted" span copied verbatim from it needs no receipt
|
||||
--root: where file:line receipts must exist (repeatable; default the git top level)
|
||||
|
||||
Row line: \`${REALITY_LINE_SHAPE}\` (receipt: path:line, path:line-line or a commit sha).
|
||||
Scopes: ${REALITY_SCOPES.filter(s => s !== 'always').join(', ')}. Session kind unattended → verdict=incomplete; else verdict=repair.
|
||||
Prints \`PLAN_REALITY: phase=<p> rows=<n>/<m> missing=<ids|none> [unreceipted=<ids>] [unreceipted_numbers=<n>] [unquoted=<n>] [unverified=<n>] verdict=complete|incomplete|repair\`,
|
||||
then one \`NUMBER_UNRECEIPTED: <clause>\` per measured number (unit or measured/costs/took) without its own receipt.
|
||||
Prints \`PLAN_REALITY: phase=<p> rows=<n>/<m> missing=<ids|none> [unreceipted=<ids>] [unreceipted_numbers=<n>] [unquoted=<n>] [unverified=<n>] [unresolved=<ids>] verdict=complete|incomplete|repair\`,
|
||||
then one \`NUMBER_UNRECEIPTED: <clause>\` per measured number (unit or measured/costs/took) without its own receipt,
|
||||
one \`RECEIPT_UNRESOLVED: <row> <receipt> <reason>\` per receipt that is false in the roots (missing file, line past the end; blocks),
|
||||
and, on stderr, one \`RECEIPT_UNVERIFIABLE: <row> <receipt> <reason>\` per receipt the roots cannot judge (reported, never blocks).
|
||||
A line claiming a decision rule must quote it (\`"..."\`, a fenced or > block after it) or link it (file:line).
|
||||
${exitTableHelp()}`;
|
||||
|
||||
@@ -50,7 +54,7 @@ function main(argv: string[]): number {
|
||||
return EXIT.ok;
|
||||
}
|
||||
if (cmd === 'check') {
|
||||
const valueFlags = new Set(['--phase', '--scope', '--session-kind', '--plan']);
|
||||
const valueFlags = new Set(['--phase', '--scope', '--session-kind', '--plan', '--root']);
|
||||
const file = rest.find((a, i) => !a.startsWith('--') && !(i > 0 && valueFlags.has(rest[i - 1]!)));
|
||||
if (!file) return usage('a review file (or - for stdin) is required');
|
||||
let text: string;
|
||||
@@ -60,14 +64,23 @@ function main(argv: string[]): number {
|
||||
let planText: string | undefined;
|
||||
try { planText = planFile ? fs.readFileSync(planFile, 'utf8') : undefined; } catch (e: any) { process.stderr.write(`gstack-plan-reality: ${planFile}: ${e.message}\n`); return EXIT.fail; }
|
||||
const result = checkRealityRows(text, { phase, scopes, sessionKind, planText });
|
||||
if (json) process.stdout.write(JSON.stringify(result) + '\n');
|
||||
else process.stdout.write([result.line, ...result.numberLines].join('\n') + '\n');
|
||||
const rootFlags = rest.flatMap((a, i) => (a === '--root' && rest[i + 1] ? [rest[i + 1]!] : []));
|
||||
const receipts = resolveReceipts(parseRealityLines(text), { roots: rootFlags.length ? rootFlags : defaultRoots(process.cwd()) });
|
||||
const unresolved = receipts.filter(r => r.outcome === 'unresolved');
|
||||
const unverifiable = receipts.filter(r => r.outcome === 'unverifiable');
|
||||
const unresolvedRows = [...new Set(unresolved.map(r => r.row))];
|
||||
const verdict = result.verdict === 'complete' && unresolved.length ? (sessionKind === 'unattended' ? 'incomplete' : 'repair') : result.verdict;
|
||||
const line = unresolvedRows.length ? result.line.replace(/ verdict=\S+$/, ` unresolved=${unresolvedRows.join(',')} verdict=${verdict}`) : result.line;
|
||||
if (json) process.stdout.write(JSON.stringify({ ...result, verdict, line, receipts }) + '\n');
|
||||
else process.stdout.write([line, ...result.numberLines, ...unresolved.map(r => `RECEIPT_UNRESOLVED: ${r.row} ${r.receipt} ${r.reason}`)].join('\n') + '\n');
|
||||
for (const r of unverifiable) process.stderr.write(`RECEIPT_UNVERIFIABLE: ${r.row} ${r.receipt} ${r.reason}\n`);
|
||||
for (const id of result.unknownRows) process.stderr.write(`gstack-plan-reality: ${file}: REALITY row ${JSON.stringify(id)} is not a known row; it was ignored\n`);
|
||||
if (result.missing.length) process.stderr.write(resultLine('gstack-plan-reality check', 'REALITY_ROW_MISSING', `phase ${phase}: ${result.missing.join(', ')}`) + '\n');
|
||||
if (result.unreceipted.length) process.stderr.write(resultLine('gstack-plan-reality check', 'REALITY_RECEIPT_MISSING', `phase ${phase}: ${result.unreceipted.join(', ')}`) + '\n');
|
||||
if (result.unreceipted_numbers.length) process.stderr.write(resultLine('gstack-plan-reality check', 'REALITY_RECEIPT_MISSING', `phase ${phase}: ${result.unreceipted_numbers.length} measured number(s) share or lack a receipt; give each clause its own file:line, script path or command`) + '\n');
|
||||
for (const claim of result.unquoted) process.stderr.write(resultLine('gstack-plan-reality check', 'REALITY_RECEIPT_MISSING', `phase ${phase}: a decision rule is claimed without its text; quote it or link its file:line: ${claim}`) + '\n');
|
||||
return result.verdict === 'complete' ? EXIT.ok : EXIT.fail;
|
||||
if (unresolved.length) process.stderr.write(resultLine('gstack-plan-reality check', 'REALITY_RECEIPT_UNRESOLVED', `phase ${phase}: ${unresolved.map(r => `${r.row} ${r.receipt} (${r.reason})`).join('; ')}`) + '\n');
|
||||
return verdict === 'complete' ? EXIT.ok : EXIT.fail;
|
||||
}
|
||||
return usage(`unknown command ${cmd}`);
|
||||
}
|
||||
|
||||
+12
-30
@@ -4,7 +4,7 @@
|
||||
*
|
||||
* gstack-pregate [run] [--stage preflight|tests|all] [--only <check,..>] [--base <branch>]
|
||||
* [--allow-repo-commands] [--policy-from base|head] [--out <pregate.json>]
|
||||
* [--timeout <s>] [--stall-timeout <s>] [--remote --spend-cap <usd>] [--run <id>] [--explain] [--json]
|
||||
* [--timeout <s>] [--stall-timeout <s>] [--remote --spend-cap <usd>] [--run <id>] [--from-run <id|url>] [--explain] [--json]
|
||||
* gstack-pregate verify [--out <pregate.json>] [--json] publication check: current tree, no fail/incomplete, obligations cleared
|
||||
* gstack-pregate clear --remote <workflow.yml/job> --run-url <url> | --run <id|url> [--run …] [--allow-repo-commands] | --bundle-lane <label> | --receipt <file> [--out <pregate.json>]
|
||||
* gstack-pregate lanes [--explain] the lane list and platform tiers read from .github/workflows
|
||||
@@ -27,17 +27,15 @@ import { spawnSync } from 'node:child_process';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { EXIT, exitTableHelp, formatResult, type PregateFile } from '../lib/headless-artifacts';
|
||||
import { PREGATE_CONFIG_PATH, explainConfig, isConfigUnavailable, loadConfigFromBase, loadConfigFromHead } from '../lib/pregate/config';
|
||||
import { PREGATE_CONFIG_PATH, explainConfig, loadConfigFromHead } from '../lib/pregate/config';
|
||||
import { describeDraft, draftConfig } from '../lib/pregate/init-draft';
|
||||
import { buildRunCheck } from '../lib/pregate/run-check';
|
||||
import { buildContext, defaultUserConfigPath, describeRefusal, loadedDotenvFiles } from '../lib/pregate/context';
|
||||
import { CHECKS, CHECK_IDS, PREGATE_OUT_REL, PREGATE_TIER2, clearDecision, clearObligation, readPregateFile, renderTable, runPregate, verifyPregate, writePregateFile, type RunCheck } from '../lib/pregate/index';
|
||||
import { ghRunDeps, runInventory } from '../lib/run-evidence';
|
||||
import { wtreeFingerprint } from '../lib/script-invocation';
|
||||
import { CHECKS, CHECK_IDS, PREGATE_OUT_REL, PREGATE_TIER2, clearDecision, clearObligation, readPregateFile, renderTable, runPregate, verifyPregate, writePregateFile } from '../lib/pregate/index';
|
||||
import { describeLane, discoverLanes } from '../lib/pregate/workflows';
|
||||
import { REMOTE_GATE_LANE } from '../lib/pregate/checks/lanes';
|
||||
import { describeSubstitution } from '../lib/pregate/remote-receipt';
|
||||
import { ensureTmpExcluded } from '../lib/restamp';
|
||||
import { normalizeRemoteUrl } from '../lib/ship-queue-train-record';
|
||||
import { RESULT_CODES, resultCodeSuffix, resultLine } from '../lib/result-codes';
|
||||
import { DEFAULT_STALL_TIMEOUT_S } from '../lib/pregate/context';
|
||||
import { cancelActive } from '../lib/pregate/supervise';
|
||||
@@ -90,6 +88,9 @@ const HELP = `gstack-pregate — cheap checks before anything paid or remote (do
|
||||
spent, reserved and unknown
|
||||
--run <id> the run label recorded in pregate.json and the name of this run's scratch directory (a taken name gets a numbered sibling, <id>-2 … -9)
|
||||
(<state root>/projects/<slug>/pregate/runs/<id>: lane and guard logs, scan files; default a unique id)
|
||||
--from-run <id|url> a Free Tests run on this exact tree, verified against the base branch's run profile for this OS
|
||||
whose workflow holds a local lane (docs/pregate.md#from-run): the selected test files its tree.json lists count as passed and
|
||||
are recorded with its receipt; the rest still run. A refused run prints REUSE: refused and runs everything
|
||||
--explain print each selected test's provenance and each uncertain mapping
|
||||
--json one JSON object on stdout; a refusal before any check runs (unavailable base, invalid pin
|
||||
file, WTREE_UNAVAILABLE) is {ok:false,status:'refused',code,detail,fix,docs} with exit 3,
|
||||
@@ -108,7 +109,7 @@ function flag(args: string[], name: string): string | undefined { const i = args
|
||||
|
||||
/** Flags each verb accepts: `[flags that take a value, bare flags]`. Anything else is usage (exit 2), never a silent default run. */
|
||||
const VERB_FLAGS: Record<string, [string[], string[]]> = {
|
||||
run: [['--stage', '--only', '--base', '--policy-from', '--out', '--timeout', '--stall-timeout', '--spend-cap', '--run'], ['--allow-repo-commands', '--explain', '--json', '--remote']],
|
||||
run: [['--stage', '--only', '--base', '--policy-from', '--out', '--timeout', '--stall-timeout', '--spend-cap', '--run', '--from-run'], ['--allow-repo-commands', '--explain', '--json', '--remote']],
|
||||
verify: [['--out'], ['--json']],
|
||||
clear: [['--remote', '--run-url', '--run', '--bundle-lane', '--receipt', '--out'], ['--allow-repo-commands']],
|
||||
lanes: [[], ['--explain', '--json']],
|
||||
@@ -141,6 +142,8 @@ function outPath(root: string, rest: string[]): string { const o = flag(rest, '-
|
||||
async function cmdRun(root: string, rest: string[], json: boolean): Promise<number> {
|
||||
const stage = (flag(rest, '--stage') ?? 'all') as 'preflight' | 'tests' | 'all';
|
||||
if (!['preflight', 'tests', 'all'].includes(stage)) { process.stderr.write('gstack-pregate: --stage needs preflight, tests or all\n'); return EXIT.usage; }
|
||||
const fromRun = flag(rest, '--from-run');
|
||||
if (fromRun !== undefined && stage === 'preflight') { process.stderr.write('gstack-pregate: --from-run applies to the tests stage (--stage tests or all)\n'); return EXIT.usage; }
|
||||
const only = flag(rest, '--only')?.split(',').map(s => s.trim()).filter(Boolean);
|
||||
const unknown = (only ?? []).filter(id => !CHECK_IDS.includes(id));
|
||||
if (unknown.length) { process.stderr.write(`gstack-pregate: unknown check(s) ${unknown.join(', ')}${unknown.some(u => (PREGATE_TIER2 as readonly string[]).includes(u)) ? ' (tier 2, not in this gstack)' : ''}; known: ${CHECK_IDS.join(', ')}\n`); return EXIT.usage; }
|
||||
@@ -172,6 +175,7 @@ async function cmdRun(root: string, rest: string[], json: boolean): Promise<numb
|
||||
return EXIT.refused;
|
||||
}
|
||||
const ctx = loaded.ctx;
|
||||
if (fromRun !== undefined) ctx.fromRun = { ref: fromRun, check: buildRunCheck(root, null, allow, base) };
|
||||
ensureTmpExcluded(root);
|
||||
for (const signal of ['SIGINT', 'SIGTERM'] as const) {
|
||||
process.once(signal, () => {
|
||||
@@ -228,7 +232,7 @@ function cmdClear(root: string, rest: string[]): number {
|
||||
const runs = rest.flatMap((a, i) => a === '--run' ? [rest[i + 1]!] : []);
|
||||
const runUrl = flag(rest, '--run-url');
|
||||
const bundleLane = flag(rest, '--bundle-lane');
|
||||
const checked = runs.length || runUrl || bundleLane ? runCheck(root, lane, rest.includes('--allow-repo-commands')) : undefined;
|
||||
const checked = runs.length || runUrl || bundleLane ? buildRunCheck(root, lane, rest.includes('--allow-repo-commands')) : undefined;
|
||||
if (checked && 'error' in checked && (runs.length || checked.profiled)) { process.stderr.write(`gstack-pregate clear: ${checked.error}${checked.profiled ? `; ${lane} may have a run profile on the base branch, so it clears only with a verified --run` : ''}\n`); return EXIT.fail; }
|
||||
const evidence = checked && !('error' in checked) ? checked : undefined;
|
||||
const decision = clearDecision(read.file, lane, { runUrl, runs, bundleLane, receiptPath: flag(rest, '--receipt'), evidence }, root);
|
||||
@@ -248,28 +252,6 @@ function cmdClear(root: string, rest: string[]): number {
|
||||
return EXIT.ok;
|
||||
}
|
||||
|
||||
/** The run-evidence inputs for `clear --run`: the base branch's run profiles, this repository on GitHub, and the gate's inventory command (a pin-file command, so only with --allow-repo-commands). */
|
||||
function runCheck(root: string, lane: string, allowRepoCommands: boolean): RunCheck | { error: string; profiled?: boolean } {
|
||||
const base = resolveBaseBranch(root);
|
||||
const loaded = loadConfigFromBase(root, base);
|
||||
// Unknown profiles are treated as present: a lane is cleared unverified only once the base shows it has no profile.
|
||||
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 repo = /^https:\/\/github\.com\/(.+)$/.exec(normalizeRemoteUrl(gitOut(root, ['config', '--get', 'remote.origin.url']) ?? ''))?.[1];
|
||||
if (!repo) return { error: 'origin is not a github.com repository; run evidence is read from GitHub Actions', profiled: 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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function gitOut(root: string, args: string[]): string | null {
|
||||
const r = spawnSync('git', args, { cwd: root, encoding: 'utf8', timeout: 30_000 });
|
||||
return r.status === 0 ? r.stdout.trim() : null;
|
||||
|
||||
@@ -686,13 +686,15 @@ function main() {
|
||||
// as public-strict (HIGH blocks regardless either way).
|
||||
// Sliced (see scanAddedLines) so a large-but-legitimate diff is actually
|
||||
// scanned rather than blocked unscanned on the engine's size cap.
|
||||
// Scanned per file so a pattern can use the file's type (#3048).
|
||||
// Scanned per file so a pattern can use the file's type (#3048). A Dart or
|
||||
// Python file's added lines go without their path: their source-syntax
|
||||
// exemption needs the whole file to see a triple-quoted string (#3630).
|
||||
// An allowlisted span is found by scanning with and without the entries,
|
||||
// so the report names exactly what they suppressed.
|
||||
const allowed: string[] = [];
|
||||
const findingKey = (f: Finding) => `${f.id}:${f.line}:${f.col}`;
|
||||
for (const { part, allowlist } of parts) {
|
||||
const opts = { repoVisibility: "private" as const, ...emails.options, ...(part.file ? { sourcePath: part.file } : {}) };
|
||||
const opts = { repoVisibility: "private" as const, ...emails.options, ...(part.file && !/\.(?:dart|pyi?)$/i.test(part.file) ? { sourcePath: part.file } : {}) };
|
||||
const findings = scanAddedLines(part.text, opts);
|
||||
const kept = allowlist.length
|
||||
? new Set(scanAddedLines(part.text, { ...opts, allowlist }).map(findingKey))
|
||||
|
||||
@@ -25,7 +25,7 @@ fi
|
||||
# PWD-keyed slug cache matches other gstack project-state writers.
|
||||
REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || true)
|
||||
[ -z "${REPO_ROOT:-}" ] && { echo "REPO_MODE=unknown"; exit 0; }
|
||||
SLUG=$(cd "$REPO_ROOT" && "$SCRIPT_DIR/gstack-slug" | sed -n 's/^SLUG=//p' | head -1)
|
||||
SLUG=$(cd "$REPO_ROOT" && "$SCRIPT_DIR/gstack-slug" --get REPO_SLUG | head -1)
|
||||
[ -z "${SLUG:-}" ] && { echo "REPO_MODE=unknown"; exit 0; }
|
||||
|
||||
# Validate: only allow known values (prevent shell injection via source <(...))
|
||||
|
||||
+71
-10
@@ -10,11 +10,18 @@
|
||||
* Runs gstack-version-bump write, the policy's release_tool, the CHANGELOG
|
||||
* re-heading and the stamp_paths rewrites in an isolated staging worktree,
|
||||
* shows the diff limited to stamp files, and writes the branch only after
|
||||
* every stage validated (journal: .gstack/tmp/restamp-journal.json).
|
||||
* Idempotent; --dry-run writes nothing. Workflow command: ends with the
|
||||
* every stage validated (journal: .gstack/tmp/restamp-journal.json), then
|
||||
* stages what it wrote (`git add -A -- <FILES>`, STAGED:), so a bare commit after
|
||||
* a --no-commit base merge cannot drop the stamp (#3531). A stamp file holding
|
||||
* unstaged edits of the caller's own refuses before anything is written
|
||||
* (RESTAMP_UNSTAGED_EDITS); a staging failure leaves the files written and
|
||||
* .gstack/tmp/restamp-staging.json, which the noop rerun stages, or drops when a
|
||||
* file changed since (RESTAMP_NOT_STAGED). Idempotent; --dry-run writes nothing. Workflow command: ends with the
|
||||
* GSTACK_RESULT line. Logic: lib/restamp.ts; policy: lib/ship-policy.ts.
|
||||
*/
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { EXIT, exitTableHelp, formatResult } from '../lib/headless-artifacts';
|
||||
import { GATE_AHEAD_REL, JOURNAL_REL, restamp, type RestampResult } from '../lib/restamp';
|
||||
import { RESULT_CODES, type ResultCodeName } from '../lib/result-codes';
|
||||
@@ -39,7 +46,7 @@ const HELP = `gstack-restamp — stamp the version, release tool, CHANGELOG and
|
||||
--dry-run print the full diff, write nothing
|
||||
--json JSON only on stdout
|
||||
|
||||
Prints STAMP_VERSION: <X>, the stage table, the stamp diff and
|
||||
Prints STAMP_VERSION: <X>, the stage table, the stamp diff, STAGED: <files it staged> and
|
||||
GSTACK_RESULT: skill=restamp status=complete|refused|incomplete run=${JOURNAL_REL}.
|
||||
${exitTableHelp()}`;
|
||||
|
||||
@@ -48,6 +55,49 @@ function flag(args: string[], name: string): string | undefined {
|
||||
return i >= 0 ? args[i + 1] : undefined;
|
||||
}
|
||||
|
||||
const STAGING_REL = '.gstack/tmp/restamp-staging.json';
|
||||
interface Pending { files: string[]; blobs: Record<string, string | null> }
|
||||
interface Staging { staged?: string[]; dropped?: string[]; failure?: string }
|
||||
|
||||
const gitIn = (root: string, args: string[]) => spawnSync('git', args, { cwd: root, encoding: 'utf8', timeout: 60_000 });
|
||||
/** Each file's blob sha in the worktree (one git call), null for a file that does not exist. */
|
||||
function blobsOf(root: string, files: string[]): Record<string, string | null> {
|
||||
const present = files.filter(f => fs.existsSync(path.join(root, f)));
|
||||
const shas = present.length ? gitIn(root, ['hash-object', '--', ...present]).stdout.trim().split('\n') : [];
|
||||
return Object.fromEntries(files.map(f => [f, present.includes(f) ? shas[present.indexOf(f)] ?? '' : null]));
|
||||
}
|
||||
function readPending(root: string): Pending | null {
|
||||
try { return JSON.parse(fs.readFileSync(path.join(root, STAGING_REL), 'utf8')) as Pending; } catch { return null; }
|
||||
}
|
||||
|
||||
/** Files with unstaged edits that are the caller's own (not bytes a pending restamp wrote and could not stage). */
|
||||
function ownUnstaged(root: string): string[] {
|
||||
const pending = readPending(root);
|
||||
const unstaged = gitIn(root, ['diff', '--name-only', '-z']).stdout.split('\0').filter(Boolean);
|
||||
const now = pending ? blobsOf(root, unstaged.filter(f => f in pending.blobs)) : {};
|
||||
return unstaged.filter(f => !(pending && f in now && pending.blobs[f] === now[f]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Stage what restamp wrote (`written`), or what a pending marker says is still unstaged; a marker file whose bytes
|
||||
* changed since (a hand commit, another branch, a base merge) is stale for that file and dropped, never a failure.
|
||||
* Null when there is nothing to stage.
|
||||
*/
|
||||
function stage(root: string, written: string[] | null): Staging | null {
|
||||
const marker = path.join(root, STAGING_REL);
|
||||
const pending = written ? { files: written, blobs: blobsOf(root, written) } : readPending(root);
|
||||
if (!pending?.files.length) return null;
|
||||
if (written) { fs.mkdirSync(path.dirname(marker), { recursive: true }); fs.writeFileSync(marker, JSON.stringify(pending, null, 2) + '\n'); }
|
||||
const now = written ? pending.blobs : blobsOf(root, pending.files);
|
||||
const dropped = pending.files.filter(f => now[f] !== pending.blobs[f]);
|
||||
const files = pending.files.filter(f => !dropped.includes(f));
|
||||
if (!files.length) { fs.rmSync(marker, { force: true }); return { staged: [], dropped }; }
|
||||
const add = gitIn(root, ['add', '-A', '--', ...files]);
|
||||
if (add.status !== 0) return { failure: `git add of ${files.join(' ')} failed (${(add.stderr || add.stdout).trim().split('\n')[0]}); the stamp stays written and ${STAGING_REL} names it` };
|
||||
fs.rmSync(marker, { force: true });
|
||||
return { staged: gitIn(root, ['diff', '--cached', '--name-only', '-z', '--', ...files]).stdout.split('\0').filter(Boolean), dropped };
|
||||
}
|
||||
|
||||
function main(argv: string[]): number {
|
||||
if (argv.includes('--help') || argv.includes('-h')) { process.stdout.write(HELP + '\n'); return EXIT.ok; }
|
||||
const json = argv.includes('--json');
|
||||
@@ -65,23 +115,32 @@ function main(argv: string[]): number {
|
||||
const after = flag(argv, '--after');
|
||||
const base = resolveBaseBranch(repoRoot, flag(argv, '--base'));
|
||||
|
||||
const result = restamp({
|
||||
const options = {
|
||||
repoRoot, base, version: flag(argv, '--version'), next: argv.includes('--next'), bump, queueMode,
|
||||
after: after ? { pr: after, ref: flag(argv, '--predecessor-ref') } : undefined, pr: flag(argv, '--pr'),
|
||||
expectBase: flag(argv, '--expect-base'), was: flag(argv, '--was'), expectOrder: flag(argv, '--expect-order')?.split(',').map(s => s.trim()).filter(Boolean),
|
||||
policyFrom, allowRepoCommands: argv.includes('--allow-repo-commands'), dryRun: argv.includes('--dry-run'),
|
||||
failAfter: process.env.GSTACK_RESTAMP_FAIL_AFTER,
|
||||
});
|
||||
return report(result, json);
|
||||
};
|
||||
const writes = !after && !options.dryRun;
|
||||
const own = writes ? ownUnstaged(repoRoot) : [];
|
||||
const dry = own.length ? restamp({ ...options, dryRun: true }) : null;
|
||||
if (dry && (dry.status === 'refused' || dry.status === 'failed')) return report(dry, json);
|
||||
const clash = (dry?.files ?? []).filter(f => own.includes(f));
|
||||
if (clash.length) return report({ status: 'refused', code: 'RESTAMP_UNSTAGED_EDITS', message: `${clash.join(', ')} ${clash.length === 1 ? 'holds' : 'hold'} unstaged edits of your own, which staging the stamp would sweep into the stamp commit; nothing written`, stages: [] }, json);
|
||||
const result = restamp(options);
|
||||
const staging = writes && (result.status === 'stamped' || result.status === 'noop') ? stage(repoRoot, result.status === 'stamped' ? result.files ?? [] : null) : null;
|
||||
return report(result, json, staging);
|
||||
}
|
||||
|
||||
function report(r: RestampResult, json: boolean): number {
|
||||
const status = r.status === 'refused' ? 'refused' : r.status === 'failed' ? 'incomplete' : 'complete';
|
||||
const code = r.status === 'refused' ? EXIT.refused : r.status === 'failed' ? EXIT.fail : EXIT.ok;
|
||||
function report(r: RestampResult, json: boolean, staging: Staging | null = null): number {
|
||||
const status = r.status === 'refused' ? 'refused' : r.status === 'failed' || staging?.failure ? 'incomplete' : 'complete';
|
||||
const code = r.status === 'refused' ? EXIT.refused : r.status === 'failed' || staging?.failure ? EXIT.fail : EXIT.ok;
|
||||
if (json) {
|
||||
process.stdout.write(JSON.stringify({ ...r, result: status }) + '\n');
|
||||
process.stdout.write(JSON.stringify({ ...r, ...(staging ?? {}), ...(staging?.failure && !r.code ? { code: 'RESTAMP_NOT_STAGED' } : {}), result: status }) + '\n');
|
||||
return code;
|
||||
}
|
||||
if (staging?.failure) process.stderr.write(`gstack-restamp: ${staging.failure}; fix: ${RESULT_CODES.RESTAMP_NOT_STAGED.fix} (RESTAMP_NOT_STAGED)\n`);
|
||||
if (r.code) {
|
||||
const row = RESULT_CODES[r.code as ResultCodeName];
|
||||
process.stderr.write(`gstack-restamp: ${r.message}; fix: ${r.fix ?? row.fix} (${r.code})\n`);
|
||||
@@ -95,6 +154,8 @@ function report(r: RestampResult, json: boolean): number {
|
||||
if (r.gateAhead) process.stdout.write(`GATE_AHEAD: head=${r.gateAhead.gated_head} tree=${r.gateAhead.gated_tree} predecessor=#${r.gateAhead.predecessor_pr}@${r.gateAhead.predecessor_head.slice(0, 12)} worktree=${r.gateAhead.worktree} evidence_branch=${r.gateAhead.evidence_branch_key}\n`);
|
||||
if (r.diff) process.stdout.write(r.diff + (r.diff.endsWith('\n') ? '' : '\n'));
|
||||
process.stdout.write(`FILES: ${(r.files ?? []).join(' ') || '(none)'}\n`);
|
||||
if (staging?.staged) process.stdout.write(`STAGED: ${staging.staged.join(' ') || '(none)'}\n`);
|
||||
if (staging?.dropped?.length) process.stdout.write(`NOTE: ${staging.dropped.join(' ')} changed since an earlier restamp wrote ${staging.dropped.length === 1 ? 'it' : 'them'}; not staged, and that run's staging marker is dropped\n`);
|
||||
if (r.mergeCondition) process.stdout.write(`MERGE_CONDITION: sha=${r.mergeCondition}\n`);
|
||||
if (r.status === 'gate-ahead' && r.gateAhead) process.stdout.write(`next: run the verification gate in ${r.gateAhead.worktree}, then \`gstack-evidence bundle --out <file>\` there; the branch is untouched. At this PR's turn rerun \`gstack-restamp --next\` here and read \`gstack-tree-receipt --from ${GATE_AHEAD_REL}\` before deciding what to rerun.\n`);
|
||||
else if ((r.status === 'stamped' || r.status === 'noop') && r.queueMode === 'stamp-at-merge') process.stdout.write(`next: NEW_VERSION=${r.newVersion}; commit and push only the stamp diff above (FILES), with no edit of your own: the train's verify accepts the CHANGELOG only as the base's plus this PR's entry re-headed; merge with the expected head ${r.mergeCondition ?? 'sha'} and recompute on a stale queue.\n`);
|
||||
|
||||
@@ -29,6 +29,8 @@
|
||||
# expression ("24 hours ago"). Default: "7 days ago".
|
||||
# --until optional window end (compare mode's prior window).
|
||||
|
||||
GSTACK_BUN_CONFIG=/dev/null; case "${OSTYPE:-}" in msys*|cygwin*) if command -v cygpath >/dev/null 2>&1; then GSTACK_BUN_CONFIG=NUL; fi ;; esac
|
||||
|
||||
BASE=""
|
||||
SINCE="7 days ago"
|
||||
UNTIL=""
|
||||
@@ -379,4 +381,35 @@ fi
|
||||
[ -f "$_GH/analytics/skill-usage.jsonl" ] && echo "SKILL_USAGE_LOG: present ($_GH/analytics/skill-usage.jsonl)" || echo "SKILL_USAGE_LOG: absent"
|
||||
[ -f "$_GH/analytics/eureka.jsonl" ] && echo "EUREKA_LOG: present ($_GH/analytics/eureka.jsonl)" || echo "EUREKA_LOG: absent"
|
||||
|
||||
# ── Friction by skill (this project's timeline + question log) ──────────────
|
||||
# lib/retro-friction.ts does the counting; the window bounds come from git's own
|
||||
# date parser so they match the commit queries above. Any failure prints
|
||||
# FRICTION: unavailable and the script still reaches RETRO_METRICS_END.
|
||||
_FR_SLUG=$("$_BIN/gstack-slug" --get SLUG 2>/dev/null || true)
|
||||
_FR_SINCE=$(git rev-parse "--since=$SINCE" 2>/dev/null | sed -n 's/^--max-age=//p' | head -1)
|
||||
_FR_UNTIL=""
|
||||
[ -n "$UNTIL" ] && _FR_UNTIL=$(git rev-parse "--until=$UNTIL" 2>/dev/null | sed -n 's/^--min-age=//p' | head -1)
|
||||
_FR_QLOG=""
|
||||
[ "$("$_BIN/gstack-config" get question_tuning 2>/dev/null)" = "true" ] && _FR_QLOG="$_GH/projects/$_FR_SLUG/question-log.jsonl"
|
||||
_FR_LIB="$_SCRIPT_DIR/../lib/retro-friction.ts"
|
||||
case "$(uname -s)" in
|
||||
MINGW*|MSYS*|CYGWIN*) command -v cygpath >/dev/null 2>&1 && _FR_LIB="$(cygpath -m "$_FR_LIB")" ;;
|
||||
esac
|
||||
if [ -z "$_FR_SLUG" ]; then
|
||||
echo "FRICTION: unavailable (no project slug)"
|
||||
elif [ -z "$_FR_SINCE" ] || { [ -n "$UNTIL" ] && [ -z "$_FR_UNTIL" ]; }; then
|
||||
echo "FRICTION: unavailable (window bounds did not resolve)"
|
||||
elif ! command -v bun >/dev/null 2>&1; then
|
||||
echo "FRICTION: unavailable (bun not found)"
|
||||
else
|
||||
_FR_OUT=$(bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" "$_FR_LIB" \
|
||||
--timeline "$_GH/projects/$_FR_SLUG/timeline.jsonl" \
|
||||
--questions "$_FR_QLOG" \
|
||||
--since-epoch "$_FR_SINCE" --until-epoch "$_FR_UNTIL" 2>/dev/null) || _FR_OUT=""
|
||||
case "$_FR_OUT" in
|
||||
*FRICTION:*) printf '%s\n' "$_FR_OUT" ;;
|
||||
*) echo "FRICTION: unavailable (friction helper failed)" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
echo "RETRO_METRICS_END: ok"
|
||||
+40
-17
@@ -59,12 +59,15 @@ const HELP = `gstack-review-dispatch — verbatim reviewer briefs and loss-free
|
||||
BRIEF, OUTPUT and JUDGE rows from the ledger alone, then VERIFY: clean (exit 0), VERIFY: dirty (exit 1) or
|
||||
VERIFY: no brief records (exit 3: this run's ledger kept no prompts). A ledger with a targeted brief also prints
|
||||
TARGETED_RECORD: and --targeted-out writes it as the JSON gstack-ship-receipt write --review-targeted reads
|
||||
pass --base <ref> --out <dir> --pass <id> [--ledger <f>] [--diff-base <sha>] [--learnings-file <f>] [--extras <json>]
|
||||
[--checklist-root <dir>] [-- <gstack-review-plan flags>]
|
||||
pass --base <ref> --out <dir> --pass <id> [--ledger <f>] [--diff-base <sha>] [--learnings-file <f>] [--extras <json>] [--audit-only]
|
||||
[--checklist-root <dir>] [--fix-list <f> --hunks <f> --tests <f> [--callers <f>]] [-- <gstack-review-plan flags>]
|
||||
runs gstack-review-plan and writes <out>/pass-manifest.json: stage first (audit readers marked first in --extras),
|
||||
specialists (with the other --extras readers), red-team, voices (Step 11). Renders first and specialists now; prints
|
||||
the plan, MANIFEST: and one READER: line per reader. A plan whose NEXT is not full or scoped prints PASS: none.
|
||||
the plan, MANIFEST: and one READER: line per reader. A plan whose NEXT is not full, scoped or fix-check prints PASS: none.
|
||||
A fix-check reader (stage specialists) renders from decision 3.5's --fix-list, --hunks, --tests and --callers files.
|
||||
--diff-base sets every reader's base, a scoped pass's Red Team included (the targeted reader keeps the merge base).
|
||||
An existing manifest for the same --pass is reused (resumed). --extras: [{"name","prompt_file","first"?,"model"?}]
|
||||
--audit-only skips the plan: the --extras readers are the whole pass (Step 14.5's docs audit)
|
||||
show --manifest <f> MANIFEST_JSON: <the manifest on one line>
|
||||
stage --manifest <f> --stage <first|specialists|red-team|voices>
|
||||
renders a stage; the Red Team only after every earlier reader is collected, its findings file being the
|
||||
@@ -73,7 +76,7 @@ const HELP = `gstack-review-dispatch — verbatim reviewer briefs and loss-free
|
||||
BRIEF_CHUNK: reader=… index=i of=n sha256=…, then one JSON string line of at most 65,536 brief characters
|
||||
mark --manifest <f> --reader <r> --state <intent|created|queued|collected|failed> [--path <p>] [--output <f>] [--error <e>]
|
||||
an audit reader is collected with --output, whose REVIEWED_TREE line must match its render (else failed, COLLECT_WRONG_TREE)
|
||||
mark --manifest <f> --before <tokens>,<credits>
|
||||
mark --manifest <f> --before <tokens>,<credits> [--voices] the pass's reading at its first dispatch; --voices keeps Step 11's own
|
||||
mark --manifest <f> --log-start <key> --token <t> records a review-log start token once, so a rerun starts only what is missing
|
||||
reader states move forward only; intent is recorded before the subagent is created, its path right after
|
||||
program [--ledger <f>]
|
||||
@@ -99,7 +102,7 @@ function render(): number {
|
||||
const brief = role === 'targeted' ? readFile(checklist, 'targeted brief') : undefined;
|
||||
if (role === 'targeted') {
|
||||
const base = resolveBaseBranch(process.cwd(), flag('--base'));
|
||||
const mb = [`origin/${base}`, base].map(ref => spawnSync('git', ['merge-base', ref, 'HEAD'], { encoding: 'utf8', timeout: 30_000 })).find(r => r.status === 0)?.stdout.trim();
|
||||
const mb = mergeBaseWith(base);
|
||||
const full = diffBase ? spawnSync('git', ['rev-parse', '--verify', '--quiet', `${diffBase}^{commit}`], { encoding: 'utf8', timeout: 30_000 }).stdout?.trim() : undefined;
|
||||
if (!full || full !== mb) throw new BriefError(`the targeted reader reads the whole diff on every pass: --diff-base must be the merge base with ${base} (${mb ?? 'unresolved'}), got ${diffBase ?? 'none'}`);
|
||||
}
|
||||
@@ -138,6 +141,7 @@ const wrongTree = (reader: string, t: TreeCheck) => `${t.moved ? 'COLLECT_TREE_M
|
||||
const PROGRAM_FILE = path.join(ROOT, 'lib', 'capy-review-pass.js');
|
||||
const PROGRAM_VERSION = 1;
|
||||
const git = (a: string[]) => { const r = spawnSync('git', a, { encoding: 'utf8', timeout: 60_000, maxBuffer: 256 * 1024 * 1024 }); return r.status === 0 ? r.stdout : null; };
|
||||
const mergeBaseWith = (base: string) => [`origin/${base}`, base].map(ref => git(['merge-base', ref, 'HEAD'])?.trim()).find(Boolean);
|
||||
const readerLine = (m: Manifest, r: PassReader) => `READER: ${r.reader} stage=${r.stage} state=${r.state} title="${r.title}"${r.sha256 ? ` sha256=${r.sha256} bytes=${r.bytes} chunks=${r.chunks}` : ''}${r.path ? ` path=${r.path}` : ''}`;
|
||||
const branchDiff = (base: string) => {
|
||||
const d = git(['-c', 'core.quotePath=false', 'diff', '--no-color', '--no-ext-diff', base]);
|
||||
@@ -156,23 +160,42 @@ function pass(): number {
|
||||
return EXIT.ok;
|
||||
}
|
||||
const planArgs = args.includes('--') ? args.slice(args.indexOf('--') + 1) : [];
|
||||
if (args.includes('--audit-only')) {
|
||||
const extrasFile = flag('--extras');
|
||||
if (!extrasFile) { process.stderr.write('gstack-review-dispatch pass --audit-only: needs --extras (the audit readers, e.g. Step 14.5\'s docs reader)\n'); return EXIT.usage; }
|
||||
const extras = JSON.parse(readFile(extrasFile, 'extras')!) as Extra[];
|
||||
const m = buildManifest({ pass: id, out, ledger: flag('--ledger') ?? path.join(out, 'review-ledger.jsonl'), base, diffBase: mergeBaseWith(base) ?? 'HEAD', root: git(['rev-parse', '--show-toplevel'])?.trim() ?? process.cwd(),
|
||||
checklistRoot: flag('--checklist-root') ?? ROOT, dispatches: [], next: 'audit', planLines: ['PASS: audit-only (no reviewer plan; its extras run as one batch)'], extras });
|
||||
fs.mkdirSync(m.out, { recursive: true, mode: 0o700 });
|
||||
const t = sectionTexts(ROOT);
|
||||
renderStage(m, 'first', t, branchDiff);
|
||||
renderStage(m, 'specialists', t, branchDiff);
|
||||
writeManifest(file, m);
|
||||
process.stdout.write([...m.plan_lines, `MANIFEST: file=${file} pass=${id} readers=${m.readers.length} audit-only`, ...m.readers.map(r => readerLine(m, r))].join('\n') + '\n');
|
||||
return EXIT.ok;
|
||||
}
|
||||
const plan = (json: boolean) => runScriptSync(path.join(ROOT, 'bin', 'gstack-review-plan'), ['--base', base, ...planArgs, ...(json ? ['--json'] : [])], { encoding: 'utf8', timeout: 180_000, maxBuffer: 64 * 1024 * 1024 });
|
||||
const text = plan(false);
|
||||
if (text.status !== 0) { process.stdout.write(text.stdout ?? ''); process.stderr.write(text.stderr ?? ''); return EXIT.fail; }
|
||||
const j = plan(true);
|
||||
if (j.status !== 0) { process.stderr.write(j.stderr ?? ''); return EXIT.fail; }
|
||||
const p = JSON.parse(j.stdout) as { next: { step: string }; dispatches: Dispatch[]; targeted?: { classes: string[] } };
|
||||
const planLines = text.stdout.trimEnd().split('\n');
|
||||
const p = JSON.parse(j.stdout) as { next: { step: string }; dispatches: Dispatch[]; targeted?: { classes: string[] }; fix_range?: { base: string } | null };
|
||||
const step = p.next.step;
|
||||
if (step !== 'full' && step !== 'scoped') { process.stdout.write([...planLines, `PASS: none next=${step} (no reviewer pass; follow NEXT)`].join('\n') + '\n'); return EXIT.ok; }
|
||||
const diffBase = flag('--diff-base') ?? git(['merge-base', base, 'HEAD'])?.trim();
|
||||
const fixChecks = step === 'fix-check' ? [] : p.dispatches.filter(d => d.role === 'fix-check');
|
||||
const planLines = [...text.stdout.trimEnd().split('\n'), ...fixChecks.map(d => `FIX_CHECK: planned effort=${d.effort} (Step 3.5 runs it on its own; not in this manifest)`)];
|
||||
if (step !== 'full' && step !== 'scoped' && step !== 'fix-check') { process.stdout.write([...planLines, `PASS: none next=${step} (no reviewer pass; follow NEXT)`].join('\n') + '\n'); return EXIT.ok; }
|
||||
const rangeBase = step === 'scoped' ? git(['rev-parse', '--verify', '--quiet', `${p.fix_range?.base ?? ''}^{commit}`])?.trim() : undefined;
|
||||
if (step === 'scoped' && !rangeBase) throw new BriefError('a scoped plan names no readable fix range base');
|
||||
const mergeBase = mergeBaseWith(base);
|
||||
const diffBase = flag('--diff-base') ?? (planArgs.includes('--adversarial-ran') ? rangeBase : undefined) ?? mergeBase;
|
||||
if (!diffBase) throw new BriefError(`no merge base between ${base} and HEAD`);
|
||||
const root = git(['rev-parse', '--show-toplevel'])?.trim() ?? process.cwd();
|
||||
const extrasFile = flag('--extras');
|
||||
const extras = extrasFile ? JSON.parse(readFile(extrasFile, 'extras')!) as Extra[] : [];
|
||||
const m = buildManifest({ pass: id, out, ledger: flag('--ledger') ?? path.join(out, 'review-ledger.jsonl'), base, diffBase, root, checklistRoot: flag('--checklist-root') ?? ROOT,
|
||||
dispatches: p.dispatches, next: step, planLines, learnings: readFile(flag('--learnings-file'), 'learnings') ?? null, extras,
|
||||
targetedClasses: p.targeted?.classes, mergeBase: git(['merge-base', base, 'HEAD'])?.trim() });
|
||||
dispatches: step === 'fix-check' ? p.dispatches : p.dispatches.filter(d => d.role !== 'fix-check'), next: step, planLines, learnings: readFile(flag('--learnings-file'), 'learnings') ?? null, extras,
|
||||
targetedClasses: p.targeted?.classes, mergeBase, rangeBase: flag('--diff-base') ? undefined : rangeBase,
|
||||
fixCheck: step === 'fix-check' && (flag('--fix-list') || flag('--hunks') || flag('--tests')) ? { fix_list: flag('--fix-list')!, hunks: flag('--hunks')!, tests: flag('--tests')!, callers: flag('--callers') } : undefined });
|
||||
fs.mkdirSync(m.out, { recursive: true, mode: 0o700 });
|
||||
const t = sectionTexts(ROOT);
|
||||
renderStage(m, 'first', t, branchDiff);
|
||||
@@ -219,10 +242,11 @@ function passStep(cmd: string): number {
|
||||
const before = flag('--before');
|
||||
if (before) {
|
||||
const [tokens, credits] = before.split(',').map(Number);
|
||||
if (!Number.isFinite(tokens) || !Number.isFinite(credits)) { process.stderr.write('gstack-review-dispatch mark: --before <tokens>,<credits>\n'); return EXIT.usage; }
|
||||
m.before ??= { tokens: tokens!, credits: credits! };
|
||||
if (!Number.isFinite(tokens) || !Number.isFinite(credits)) { process.stderr.write('gstack-review-dispatch mark: --before <tokens>,<credits> [--voices]\n'); return EXIT.usage; }
|
||||
const key = args.includes('--voices') ? 'before_voices' : 'before';
|
||||
m[key] ??= { tokens: tokens!, credits: credits! };
|
||||
writeManifest(file, m);
|
||||
process.stdout.write(`BEFORE: tokens=${m.before.tokens} credits=${m.before.credits}\n`);
|
||||
process.stdout.write(`BEFORE: ${key === 'before_voices' ? 'stage=voices ' : ''}tokens=${m[key]!.tokens} credits=${m[key]!.credits}\n`);
|
||||
return EXIT.ok;
|
||||
}
|
||||
const audit = m.readers.find(x => x.reader === flag('--reader'));
|
||||
@@ -345,7 +369,6 @@ function main(): number {
|
||||
return EXIT.usage;
|
||||
}
|
||||
|
||||
try { process.exit(main()); } catch (e) {
|
||||
if (e instanceof BriefError) { process.stderr.write(`BRIEF_RENDER_FAILED: ${e.message}\n`); process.exit(EXIT.fail); }
|
||||
throw e;
|
||||
try { process.exitCode = main(); } catch (e) {
|
||||
if (e instanceof BriefError) { process.stderr.write(`BRIEF_RENDER_FAILED: ${e.message}\n`); process.exitCode = EXIT.fail; } else throw e;
|
||||
}
|
||||
@@ -27,6 +27,7 @@ export GIT_OPTIONAL_LOCKS=0
|
||||
export GIT_CONFIG_PARAMETERS="${GIT_CONFIG_PARAMETERS:+$GIT_CONFIG_PARAMETERS }'core.fsmonitor=false' 'core.untrackedCache=false'"
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null)"
|
||||
SLUG=$("$SCRIPT_DIR/gstack-slug" --get REPO_SLUG 2>/dev/null || true)
|
||||
. "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; }
|
||||
gstack_state_root_select
|
||||
GSTACK_HOME="$_gstack_sr_root"
|
||||
|
||||
@@ -56,7 +56,8 @@ const HELP = `gstack-review-plan — the review depth of the next /ship step (do
|
||||
reader last read it: the same patch skips it; any other id, none, or uncommitted edits dispatch it again
|
||||
--fix-check-prompt print the fix-check reviewer's prompt from --fix-list, --hunks, --tests and --callers files
|
||||
|
||||
Depth: light (runtime lines <= 50), standard (above 50), full (gate class or any DEPTH_ERROR). TARGETED: the money,
|
||||
Depth (R51, risk not size): full on gate class, a risk class (money, auth, secret, security, data) or any DEPTH_ERROR;
|
||||
standard only by --depth-floor or a raise; otherwise light. TARGETED: the money,
|
||||
gate, auth and secret classes the diff touches; any class dispatches the targeted reader at every depth. NEXT: full, scoped
|
||||
(a full pass on the range: fix-check, Red Team, native adversarial), fix-check, stop or done. A full plan
|
||||
from an error exits 0; exit 1 means no plan could be built (git could not list the diff).
|
||||
@@ -167,4 +168,4 @@ function main(): number {
|
||||
return EXIT.ok;
|
||||
}
|
||||
|
||||
process.exit(main());
|
||||
process.exitCode = main();
|
||||
@@ -15,6 +15,7 @@ case "$(uname -s)" in
|
||||
MINGW*|MSYS*|CYGWIN*) command -v cygpath >/dev/null 2>&1 && SCRIPT_DIR="$(cygpath -m "$SCRIPT_DIR")" ;;
|
||||
esac
|
||||
eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null)"
|
||||
SLUG=$("$SCRIPT_DIR/gstack-slug" --get REPO_SLUG 2>/dev/null || true)
|
||||
. "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; }
|
||||
gstack_state_root_select
|
||||
GSTACK_HOME="$_gstack_sr_root"
|
||||
|
||||
@@ -23,6 +23,7 @@ import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { EXIT, exitTableHelp } from '../lib/headless-artifacts';
|
||||
import { resultLineWithDocs } from '../lib/result-codes';
|
||||
import { runScriptSync } from '../lib/script-invocation';
|
||||
import { isUnavailable, loadPolicyFromBase, policyUnavailableLine, resolveBaseBranch } from '../lib/ship-policy';
|
||||
import { EXIT as POST_EXIT, runPost } from '../lib/gstack-post';
|
||||
import {
|
||||
@@ -237,8 +238,8 @@ function cmdRender(rest: string[]): number {
|
||||
function cmdSummary(rest: string[], json: boolean): number {
|
||||
const since = flag(rest, '--since');
|
||||
if (!since || !Number.isFinite(Date.parse(since))) return refuse('summary', 'REVIEW_SCOPE_INPUT', '--since <ISO-8601 timestamp> (the invocation\'s first Step 9 entry) is required');
|
||||
const read = spawnSync(path.join(path.dirname(Bun.fileURLToPath(import.meta.url)), 'gstack-review-read'), [], { encoding: 'utf8', timeout: 60_000, maxBuffer: 64 * 1024 * 1024 });
|
||||
if (read.status !== 0) return refuse('summary', 'REVIEW_SCOPE_INPUT', `gstack-review-read failed: ${(read.stderr ?? '').trim().split('\n')[0] ?? ''}`);
|
||||
const read = runScriptSync(path.join(path.dirname(Bun.fileURLToPath(import.meta.url)), 'gstack-review-read'), [], { encoding: 'utf8', timeout: 60_000, maxBuffer: 64 * 1024 * 1024 });
|
||||
if (read.status !== 0) return refuse('summary', 'REVIEW_SCOPE_INPUT', `gstack-review-read failed: ${read.error?.message ?? (read.stderr ?? '').trim().split('\n')[0] ?? ''}`);
|
||||
const rows: Record<string, unknown>[] = [];
|
||||
for (const line of (read.stdout ?? '').split('---CONFIG---')[0]!.split('\n')) {
|
||||
if (!line.trim() || line === 'NO_REVIEWS') continue;
|
||||
|
||||
@@ -246,6 +246,8 @@ case "$_P_PULL" in
|
||||
echo "gstack auto-update: update held ($_P_PULL); nothing was installed or changed, and your current hooks keep running. gstack retries at the next check. Fix now ($_WHERE): $_FF_FIX (setup checks every hook it registers). https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#auto-update-hook-check-unavailable" ;;
|
||||
unmerged-files*)
|
||||
echo "gstack auto-update: update held ($_P_PULL); an earlier merge, rebase or stash pop left conflicts in the install, and an update would bury them. Fix now ($_WHERE): $_FF_FIX (saves every edit, the unmerged files included, in a rescue ref before it updates). https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#auto-update-unmerged-files" ;;
|
||||
tracked-by-project*|version-reverted*)
|
||||
echo "gstack auto-update: update held ($_P_PULL); nothing was installed or changed. gstack retries at the next check. The full fix: bash $_Q/bin/gstack-ff-update --preflight $_Q. https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#auto-update-tracked-by-project" ;;
|
||||
autostash-unrecoverable*)
|
||||
echo "gstack auto-update: update held ($_P_PULL); your local edits are where they were and nothing was installed. Look first: git -C $_Q status --short. Fix now ($_WHERE): $_FF_FIX (once the cause above is gone). https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#auto-update-autostash-unrecoverable" ;;
|
||||
*)
|
||||
@@ -500,7 +502,7 @@ CALLER_PID=$(gstack_harness_pid "$PPID")
|
||||
log_entry "AUTOSTASH_CONFLICT_RECOVERED tree_reset=1 kept_stash=${4##*/} kept_ref=$4 $5"
|
||||
rescue_note "$4" "${5#unmerged=}" ;;
|
||||
0:*) ;;
|
||||
3:*) HELD=$FF_OUT PULL_EXIT=0 ;;
|
||||
3:*|6:*) HELD=$FF_OUT PULL_EXIT=0 ;;
|
||||
75:*) log_entry "SKIP ff_busy $(ff_flatten "$FF_OUT" 200)"; rm -f "$PULL_ERR_FILE" 2>/dev/null; exit 0 ;;
|
||||
*) printf '%s\n' "${FF_OUT:-bin/gstack-ff-update exited $PULL_EXIT}" > "${PULL_ERR_FILE:-/dev/null}" ;;
|
||||
esac
|
||||
|
||||
@@ -38,7 +38,7 @@ const ctx = { env: process.env, cwd: process.cwd() };
|
||||
const commands: Record<string, () => Outcome> = {
|
||||
start: () => cmdStart(rest, ctx), note: () => cmdNote(rest, ctx), check: () => cmdCheck(rest, ctx), settle: () => cmdSettle(rest, ctx), release: () => cmdSettle(rest, ctx, true),
|
||||
handoff: () => cmdHandoff(rest, ctx), launch: () => cmdLaunch(rest, ctx), 'verify-handoff': () => cmdVerifyHandoff(rest, ctx), return: () => cmdReturn(rest, ctx),
|
||||
reconcile: () => cmdReconcile(rest, ctx), resume: () => cmdResume(rest, ctx), close: () => cmdClose(rest, ctx), calls: () => cmdCalls(rest),
|
||||
reconcile: () => cmdReconcile(rest, ctx), resume: () => cmdResume(rest, ctx), close: () => cmdClose(rest, ctx), calls: () => cmdCalls(rest, ctx),
|
||||
};
|
||||
if (!command || command === '--help' || command === '-h' || !commands[command]) {
|
||||
const lines = (await Bun.file(import.meta.path).text()).split('\n');
|
||||
|
||||
Executable
+10
@@ -0,0 +1,10 @@
|
||||
#!/usr/bin/env -S bun --no-env-file --config=/dev/null
|
||||
/**
|
||||
* gstack-ship-parts — the section text /ship's next phase uses, verbatim, split at headings and
|
||||
* returned once per run inside the phase program before it (GSTA-167 C5). Logic: lib/ship-parts.ts.
|
||||
*/
|
||||
import { shipParts } from '../lib/ship-parts';
|
||||
|
||||
const out = shipParts(process.argv.slice(2));
|
||||
(out.exit === 2 ? process.stderr : process.stdout).write(out.text);
|
||||
process.exit(out.exit);
|
||||
@@ -14,10 +14,11 @@
|
||||
* gstack-ship-queue gate-ahead --list --pr <n>
|
||||
* gstack-ship-queue verify --pr <n> --screen (train policy: the head's screen verdict from its run)
|
||||
* gstack-ship-queue verify --post-merge <sha> [--timeout <s>] (after a merge: main's push workflows at that commit until they settle)
|
||||
* gstack-ship-queue land --pr <n> [--dry-run] [--no-wait] [--break-lock] (the merge step; lib/ship-queue-land.ts)
|
||||
* gstack-ship-queue land --pr <n> [--dry-run] [--no-wait] [--break-lock [--break-train-lock]] (the merge step; lib/ship-queue-land.ts)
|
||||
* gstack-ship-queue land --pr <n> --repair (after QUEUE_LAND_DRIFT: open the outside merge's release PR)
|
||||
* gstack-ship-queue land --census
|
||||
* gstack-ship-queue train build|status|verdict|bisect|rebuild|watch|prune ... (merge trains; train --help)
|
||||
* gstack-ship-queue wave plan|build|status|settle ... (wave PRs: 3-6 same-area PRs in one train slot; wave --help)
|
||||
*
|
||||
* Every subcommand takes --base <branch> (default origin/HEAD, else main) and
|
||||
* ends with GSTACK_RESULT: skill=ship-queue status=complete|refused run=<path>.
|
||||
@@ -56,7 +57,7 @@ const HELP = `gstack-ship-queue — the shared merge queue's protocol steps (doc
|
||||
verify --pr <n> --screen (train policy) the PR head's screen verdict read from its run (SCREEN_VERDICT)
|
||||
verify --post-merge <sha> [--timeout <s>] wait for every check run at a merged commit to settle: POST_MERGE: green|red|pending
|
||||
and one CHECK: line per workflow; resumable, default timeout 2700 s
|
||||
land --pr <n> [--dry-run] [--no-wait] [--timeout <min>] [--break-lock] [--allow-repo-commands]
|
||||
land --pr <n> [--dry-run] [--no-wait] [--timeout <min>] [--break-lock [--break-train-lock]] [--allow-repo-commands]
|
||||
the merge step: lock, merge the base, stamp (fragments folded), push the stamp commit,
|
||||
wait for its checks, squash with --match-head-commit, confirm the base tree; resumable
|
||||
land --pr <n> --repair after QUEUE_LAND_DRIFT: open the PR that gives the merge outside land its own
|
||||
@@ -65,6 +66,8 @@ const HELP = `gstack-ship-queue — the shared merge queue's protocol steps (doc
|
||||
train build|status|verdict|bisect|rebuild|watch|prune ...
|
||||
merge trains, when the base policy declares a train block (train --help;
|
||||
docs/ship-policy.md#run-a-merge-train); ready then takes --screen|--no-screen
|
||||
wave plan|build|status|settle ... wave PRs under a train policy: 3-6 same-area PRs in one slot and one version
|
||||
(wave --help; docs/ship-policy.md#wave-prs)
|
||||
|
||||
--base <branch>: base branch (default: origin/HEAD, else main). Nothing here pushes, except land (the stamp commit and the
|
||||
refs/notes/gstack-land lock), ready under changelog_fragments (its READY receipt note) and merge trains.
|
||||
@@ -162,13 +165,14 @@ async function main(argv: string[]): Promise<number> {
|
||||
if (!args[0] || args[0].startsWith('-')) { process.stdout.write(train.TRAIN_HELP + '\n'); return args.includes('--help') || args.includes('-h') ? EXIT.ok : EXIT.usage; }
|
||||
return train.trainMain(ctx, args);
|
||||
}
|
||||
if (cmd === 'wave') return (await import('../lib/ship-queue-wave')).waveMain(ctx, args);
|
||||
if (cmd === 'verify' && flag(args, '--post-merge') !== undefined) return (await import('../lib/ship-queue-train-cli')).postMergeMain(ctx, args);
|
||||
if (cmd === 'land') {
|
||||
const l = await import('../lib/ship-queue-land');
|
||||
if (args.includes('--census')) return finish('land', l.census(ctx));
|
||||
if (!pr || !/^\d+$/.test(pr)) return usage('land needs --pr <number> (or --census)');
|
||||
const timeout = flag(args, '--timeout');
|
||||
return finish('land', l.land(ctx, { pr, repair: args.includes('--repair'), dryRun: args.includes('--dry-run'), noWait: args.includes('--no-wait'), breakLock: args.includes('--break-lock'), allowRepoCommands: args.includes('--allow-repo-commands'), waitTimeoutMin: timeout && /^\d+$/.test(timeout) ? Number(timeout) : undefined }));
|
||||
return finish('land', l.land(ctx, { pr, repair: args.includes('--repair'), dryRun: args.includes('--dry-run'), noWait: args.includes('--no-wait'), breakLock: args.includes('--break-lock'), breakTrainLock: args.includes('--break-train-lock'), allowRepoCommands: args.includes('--allow-repo-commands'), waitTimeoutMin: timeout && /^\d+$/.test(timeout) ? Number(timeout) : undefined }));
|
||||
}
|
||||
if (!pr || !/^\d+$/.test(pr)) return usage(`${cmd} needs --pr <number>`);
|
||||
const gateExit = flag(args, '--gate-exit');
|
||||
|
||||
+105
-23
@@ -32,10 +32,12 @@
|
||||
*/
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { EXIT, exitTableHelp, type ShipReceipt } from '../lib/headless-artifacts';
|
||||
import { resultLine, resultLineWithDocs } from '../lib/result-codes';
|
||||
import { versionAtRef } from '../lib/restamp';
|
||||
import { runScriptSync } from '../lib/script-invocation';
|
||||
import { readPregateFile, verifyPregate } from '../lib/pregate/index';
|
||||
import { isUnavailable, loadPolicyFromBase, matchesAny, policyUnavailableLine, resolveBaseBranch, runningGstackVersion, stampFilesInTree, validatePolicy, type ShipPolicy } from '../lib/ship-policy';
|
||||
import { approvalForReceipt } from '../lib/approval-citations';
|
||||
@@ -45,6 +47,7 @@ import {
|
||||
receiptIssuesFrom, receiptSummaryLine, renderCensus, renderReceiptBlock, validateReceipt, type CensusPr,
|
||||
} from '../lib/ship-receipt';
|
||||
import { checkDocsCaps, docsCapLine, docsExceptionLine, parseReceiptDocs } from '../lib/ship-receipt-docs';
|
||||
import { comparePath, contextPacket, PACKET_SECTION_CAP, readScopedReview, touchedPaths } from '../lib/ship-patch';
|
||||
import { deeper, depthBelow, exceptionProblems, parseReviewLoopException, parseReviewTargeted, receiptDepthAtHead, reviewLoopExceptionLine } from '../lib/ship-receipt-depth';
|
||||
import { checkReceiptProse, proseLine } from '../lib/ship-receipt-prose';
|
||||
import { resolveStateRoot } from '../lib/state-root';
|
||||
@@ -118,7 +121,7 @@ function jsonArg(raw: string | undefined): Record<string, unknown> | undefined {
|
||||
return parsed;
|
||||
}
|
||||
function git(args: string[], cwd?: string): string | undefined {
|
||||
const r = spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 30_000 });
|
||||
const r = spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 30_000, maxBuffer: 256 * 1024 * 1024 });
|
||||
return r.status === 0 ? r.stdout.trim() : undefined;
|
||||
}
|
||||
function gh(args: string[]): { ok: boolean; out: string; err: string } {
|
||||
@@ -177,7 +180,7 @@ function cmdWrite(rest: string[], json: boolean): number {
|
||||
const tree = receiptFile ? JSON.parse(fs.readFileSync(receiptFile, 'utf8')) : null;
|
||||
let sessionKind = flag(rest, '--session-kind') ?? process.env.GSTACK_SESSION_KIND;
|
||||
if (!sessionKind) {
|
||||
const r = spawnSync(path.join(path.dirname(Bun.fileURLToPath(import.meta.url)), 'gstack-session-kind'), [], { encoding: 'utf8', timeout: 15_000 });
|
||||
const r = runScriptSync(path.join(path.dirname(Bun.fileURLToPath(import.meta.url)), 'gstack-session-kind'), [], { encoding: 'utf8', timeout: 15_000 });
|
||||
sessionKind = r.status === 0 ? r.stdout.trim() || undefined : undefined;
|
||||
}
|
||||
let pregate: PregateField | undefined;
|
||||
@@ -398,12 +401,37 @@ function writeRunRecord(a: { receipt: ShipReceipt; head: string; pr: string | un
|
||||
*/
|
||||
function patchId(repoRoot: string, from: string, to: string, pathspec: string[]): string | { error: string } {
|
||||
const firstLine = (s: string | null | undefined, fallback: string) => (s ?? '').trim().split('\n')[0]?.slice(0, 200) || fallback;
|
||||
const diff = spawnSync('git', ['diff', '--no-color', '--no-ext-diff', '--full-index', from, to, '--', ...pathspec], { cwd: repoRoot, encoding: 'utf8', timeout: 120_000, maxBuffer: 256 * 1024 * 1024 });
|
||||
if (diff.status !== 0) return { error: `git diff ${from.slice(0, 12)} ${to.slice(0, 12)}: ${firstLine(diff.stderr, diff.signal ? `killed by ${diff.signal}` : `exit ${diff.status}`)}` };
|
||||
if (!diff.stdout.trim()) return '';
|
||||
const id = spawnSync('git', ['patch-id', '--verbatim'], { cwd: repoRoot, input: diff.stdout, encoding: 'utf8', timeout: 120_000, maxBuffer: 64 * 1024 * 1024 });
|
||||
if (id.status !== 0) return { error: `git patch-id --verbatim: ${firstLine(id.stderr, id.signal ? `killed by ${id.signal}` : `exit ${id.status}`)}` };
|
||||
return id.stdout.trim().split(/\s+/)[0] ?? '';
|
||||
const root = path.resolve(repoRoot, git(['rev-parse', '--git-path', 'gstack-patch-id'], repoRoot)?.trim() || '.git/gstack-patch-id');
|
||||
sweepStalePatchDirs(root);
|
||||
let dir: string;
|
||||
try { fs.mkdirSync(root, { recursive: true }); dir = fs.mkdtempSync(path.join(root, 'run-')); } catch (e) { return { error: `cannot create a temp file under ${root} (${firstLine((e as Error).message, 'mkdtemp failed')}); make that directory writable` }; }
|
||||
const file = path.join(dir, 'diff.patch');
|
||||
let fd: number | undefined;
|
||||
try {
|
||||
const diff = spawnSync('git', ['diff', '--no-color', '--no-ext-diff', '--full-index', `--output=${file}`, from, to, '--', ...pathspec], { cwd: repoRoot, encoding: 'utf8', timeout: 120_000 });
|
||||
if (diff.status !== 0) return { error: `git diff ${from.slice(0, 12)} ${to.slice(0, 12)}: ${firstLine(diff.stderr, diff.signal ? `killed by ${diff.signal}` : `exit ${diff.status}`)}` };
|
||||
fd = fs.openSync(file, 'r');
|
||||
const id = spawnSync('git', ['patch-id', '--verbatim'], { cwd: repoRoot, stdio: [fd, 'pipe', 'pipe'], encoding: 'utf8', timeout: 120_000 });
|
||||
if (id.status !== 0) return { error: `git patch-id --verbatim: ${firstLine(id.stderr, id.signal ? `killed by ${id.signal}` : `exit ${id.status}`)}` };
|
||||
return id.stdout.trim().split(/\s+/)[0] ?? '';
|
||||
} finally {
|
||||
if (fd !== undefined) fs.closeSync(fd);
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
/** A carry killed mid patch-id (preflight's timeout) skips patchId's `finally`; its product diff is removed by the next run once an hour old. */
|
||||
let swept = false;
|
||||
function sweepStalePatchDirs(root: string): void {
|
||||
if (swept) return;
|
||||
swept = true;
|
||||
const cutoff = Date.now() - 60 * 60 * 1000;
|
||||
let names: string[] = [];
|
||||
try { names = fs.readdirSync(root).filter(n => n.startsWith('run-')); } catch { return; }
|
||||
for (const n of names) {
|
||||
const p = path.join(root, n);
|
||||
try { if (fs.statSync(p).mtimeMs < cutoff) fs.rmSync(p, { recursive: true, force: true }); } catch { /* another run removed it */ }
|
||||
}
|
||||
}
|
||||
|
||||
/** `git patch-id --verbatim` exists from git 2.39 (docs/ship-policy.md#receipt-carry); an older git is a named refusal, never a silent fallback to the whitespace-stripping default. */
|
||||
@@ -459,8 +487,15 @@ function productRules(repoRoot: string, base: string, outputs: readonly string[]
|
||||
* The product pathspec between two commits: every path but tests and release
|
||||
* metadata, where a metadata-listed path whose change from `from` to `to` is
|
||||
* not a stamp stays product (every glob that matches it leaves the exclusion).
|
||||
* With `branch` (the paths the branch itself changed against the base, and its
|
||||
* base..head ranges), only the branch's own non-stamp changes demote a glob: a
|
||||
* touched path whose change is not a stamp on either side, or between the two
|
||||
* heads. A path only a base merge brought in has the same patch against the base
|
||||
* at both heads, and demoting its glob would pull a matching path the branch did
|
||||
* change, such as a CHANGELOG fragment next to the base's changelog.d/README.md
|
||||
* (GST-343, #3606), into the product patch-id at one head only.
|
||||
*/
|
||||
function productSpec(repoRoot: string, rules: ProductRules, from: string, to: string): { changed: Array<{ path: string; class: ReturnType<typeof classifyCarryPath> }>; product: string[]; tests: string[] } | undefined {
|
||||
function productSpec(repoRoot: string, rules: ProductRules, from: string, to: string, branch?: { touched: ReadonlySet<string>; sides: ReadonlyArray<readonly [string, string]> }): { changed: Array<{ path: string; class: ReturnType<typeof classifyCarryPath> }>; product: string[]; tests: string[] } | undefined {
|
||||
const changedRaw = git(['diff', '--name-only', from, to], repoRoot);
|
||||
if (changedRaw === undefined) return undefined;
|
||||
const blobAt = (ref: string, p: string): string | null => { const r = spawnSync('git', ['show', `${ref}:${p}`], { cwd: repoRoot, encoding: 'utf8', timeout: 30_000, maxBuffer: 16 * 1024 * 1024 }); return r.status === 0 ? r.stdout : null; };
|
||||
@@ -469,11 +504,14 @@ function productSpec(repoRoot: string, rules: ProductRules, from: string, to: st
|
||||
if (file !== null) return file.trim() || null;
|
||||
try { const v = JSON.parse(blobAt(ref, 'package.json') ?? 'null')?.version; return typeof v === 'string' ? v : null; } catch { return null; }
|
||||
};
|
||||
const oldVersion = versionAt(from);
|
||||
const newVersion = versionAt(to);
|
||||
const changed = changedRaw.split('\n').filter(Boolean).map(p => ({ path: p, class: classifyCarryPath(p, rules.extra, matchesAny(rules.metadata, p) && !matchesAny(rules.extra.outputs, p) ? { before: blobAt(from, p), after: blobAt(to, p), oldVersion, newVersion, templates: rules.templates } : undefined) }));
|
||||
const versions = new Map<string, string | null>();
|
||||
const version = (ref: string) => { if (!versions.has(ref)) versions.set(ref, versionAt(ref)); return versions.get(ref)!; };
|
||||
const stamped = (p: string) => matchesAny(rules.metadata, p) && !matchesAny(rules.extra.outputs, p);
|
||||
const classify = (p: string, a: string, b: string) => classifyCarryPath(p, rules.extra, stamped(p) ? { before: blobAt(a, p), after: blobAt(b, p), oldVersion: version(a), newVersion: version(b), templates: rules.templates } : undefined);
|
||||
const changed = changedRaw.split('\n').filter(Boolean).map(p => ({ path: p, class: classify(p, from, to) }));
|
||||
const excludeGlobs = (globs: readonly string[]) => globs.map(g => `:(exclude,glob)${g}`);
|
||||
const demoted = changed.filter(c => c.class === 'product' && matchesAny(rules.metadata, c.path)).map(c => c.path);
|
||||
const demoted = !branch ? changed.filter(c => c.class === 'product' && matchesAny(rules.metadata, c.path)).map(c => c.path)
|
||||
: [...branch.touched].filter(p => stamped(p) && (changed.some(c => c.path === p && c.class === 'product') || branch.sides.some(([a, b]) => blobAt(a, p) !== blobAt(b, p) && classify(p, a, b) === 'product')));
|
||||
const metadataGlobs = rules.metadata.filter(g => !demoted.some(p => matchesAny([g], p)));
|
||||
return { changed, product: ['.', ...excludeGlobs(CARRY_TEST_GLOBS), ...excludeGlobs(metadataGlobs)], tests: [...CARRY_TEST_GLOBS.map(g => `:(glob)${g}`), ...excludeGlobs(metadataGlobs)] };
|
||||
}
|
||||
@@ -506,7 +544,7 @@ function cmdCarry(rest: string[], json: boolean): number {
|
||||
if (!repoRoot) { process.stderr.write('gstack-ship-receipt carry: run it inside the git checkout whose head carries the receipt\n'); return EXIT.usage; }
|
||||
const head = (flag(rest, '--head') ?? git(['rev-parse', 'HEAD'], repoRoot))?.toLowerCase();
|
||||
if (!head || !/^[0-9a-f]{40,64}$/.test(head)) { process.stderr.write('gstack-ship-receipt carry: cannot resolve HEAD\n'); return EXIT.usage; }
|
||||
const refused = (code: 'QUEUE_RECEIPT_MISSING' | 'QUEUE_RECEIPT_CONDITIONAL' | 'RECEIPT_CARRY_REFUSED' | 'RECEIPT_CARRY_PRODUCT_CHANGED' | 'RECEIPT_CARRY_TREE_MISMATCH' | 'RECEIPT_GIT_TOO_OLD' | 'RECEIPT_CARRY_PREGATE_STALE', message: string) => {
|
||||
const refused = (code: 'QUEUE_RECEIPT_MISSING' | 'QUEUE_RECEIPT_CONDITIONAL' | 'RECEIPT_CARRY_REFUSED' | 'RECEIPT_CARRY_PRODUCT_CHANGED' | 'RECEIPT_CARRY_SCOPED_REVIEW' | 'RECEIPT_CARRY_TREE_MISMATCH' | 'RECEIPT_GIT_TOO_OLD' | 'RECEIPT_CARRY_PREGATE_STALE', message: string) => {
|
||||
process.stderr.write(resultLineWithDocs('gstack-ship-receipt carry', code, message) + '\n');
|
||||
process.stdout.write(json ? JSON.stringify({ skill: 'ship', status: 'refused', run: '-', code, message }) + '\n' : `CARRY: refused (${code})\nGSTACK_RESULT: skill=ship status=refused run=-\n`);
|
||||
return EXIT.refused;
|
||||
@@ -570,6 +608,7 @@ function cmdCarry(rest: string[], json: boolean): number {
|
||||
const reviewedBase = receipt.base.replace(/^origin\//, '');
|
||||
const retarget = reviewedBase !== base.replace(/^origin\//, '');
|
||||
let plan: ReturnType<typeof planCarry>;
|
||||
let scopedDryRun: string | undefined;
|
||||
if (!ancestor) {
|
||||
// Rewritten history: the receipt follows only onto a tree it already covers, with the product diff it recorded.
|
||||
const recorded = receipt.product_patch_id !== undefined ? (receipt.product_patch_id === 'none' ? '' : receipt.product_patch_id) : fromKnown ? productPatchAgainstBase(repoRoot, rules, baseRef, from) : { error: 'the receipt records no product patch-id and its head is not in this clone' };
|
||||
@@ -578,7 +617,10 @@ function cmdCarry(rest: string[], json: boolean): number {
|
||||
} else {
|
||||
const fromBase = git(['merge-base', baseRef, from], repoRoot);
|
||||
if (!fromBase) return refused('RECEIPT_CARRY_REFUSED', `cannot resolve the merge base of ${baseRef} (fetch it first)`);
|
||||
const spec = productSpec(repoRoot, rules, from, head);
|
||||
const listed = [touchedPaths(repoRoot, fromBase, from), touchedPaths(repoRoot, headBase, head)];
|
||||
if (!listed[0] || !listed[1]) return refused('RECEIPT_CARRY_REFUSED', `git could not list the paths the branch touched against ${base}; run /ship on ${head.slice(0, 12)}`);
|
||||
const touched = new Set([...listed[0], ...listed[1]]);
|
||||
const spec = productSpec(repoRoot, rules, from, head, { touched, sides: [[fromBase, from], [headBase, head]] });
|
||||
if (!spec) return refused('RECEIPT_CARRY_REFUSED', `git diff ${from.slice(0, 12)} ${head.slice(0, 12)} failed`);
|
||||
const { changed } = spec;
|
||||
const productFrom = patchId(repoRoot, fromBase, from, spec.product);
|
||||
@@ -601,25 +643,64 @@ function cmdCarry(rest: string[], json: boolean): number {
|
||||
}
|
||||
const current = retarget ? headPatch : productHead;
|
||||
const changedProduct: string[] = [];
|
||||
const contextPaths: string[] = [];
|
||||
if (retarget && reviewed !== current) changedProduct.push(`HEAD's product diff against ${base} is not the one reviewed against ${reviewedBase} (the parent has not fully landed on ${base}, or the branch changed since)`);
|
||||
else if (productFrom !== productHead && !retarget) {
|
||||
// planCarry names at most CARRY_NAMED_PATHS differing paths, so the per-path comparison (two patch-ids each) stops there,
|
||||
// and it walks only the paths the branch itself touched against the base at either head: a base merge brings in every
|
||||
// path the base changed, none of which can differ between the two base diffs.
|
||||
const touched = new Set([...(git(['diff', '--name-only', fromBase, from], repoRoot) ?? '').split('\n'), ...(git(['diff', '--name-only', headBase, head], repoRoot) ?? '').split('\n')].filter(Boolean));
|
||||
for (const p of [...touched].sort()) {
|
||||
// planCarry names at most CARRY_NAMED_PATHS differing paths, so the walk stops there, and it reads only the paths the
|
||||
// branch itself touched against the base at either head: a base merge brings in every path the base changed, none of
|
||||
// which can differ between the two base diffs. A touched path with the same blob at both heads and at both bases has
|
||||
// the same patch, so only paths that moved on one side are compared (GST-448: 6000 touched paths). A path whose only difference is base context next to unchanged hunks
|
||||
// (lib/ship-patch.ts) needs a scoped review instead of the loop.
|
||||
const sides = { reviewedBase: fromBase, reviewed: from, currentBase: headBase, current: head };
|
||||
const moved = [touchedPaths(repoRoot, from, head), touchedPaths(repoRoot, fromBase, headBase)];
|
||||
if (moved.some(l => !l)) return refused('RECEIPT_CARRY_REFUSED', `git could not list the paths the branch touched against ${base}; run /ship on ${head.slice(0, 12)}`);
|
||||
const [movedHead, movedBase] = moved as Set<string>[];
|
||||
for (const p of [...touched].filter(p => movedHead.has(p) || movedBase.has(p)).sort()) {
|
||||
if (changedProduct.length >= CARRY_NAMED_PATHS) break;
|
||||
const cls = changed.find(c => c.path === p)?.class ?? classifyCarryPath(p, rules.extra);
|
||||
if (cls !== 'product') continue;
|
||||
if (asId(patchId(repoRoot, fromBase, from, [p])) !== asId(patchId(repoRoot, headBase, head, [p]))) changedProduct.push(p);
|
||||
const verdict = comparePath(repoRoot, sides, p);
|
||||
if (verdict === 'changed') changedProduct.push(p);
|
||||
else if (verdict === 'context') contextPaths.push(p);
|
||||
}
|
||||
if (!changedProduct.length && !contextPaths.length) changedProduct.push('(a product path outside the branch\'s touched files; compare the two base diffs)');
|
||||
else if (!changedProduct.length) {
|
||||
const rest = [...spec.product, ...contextPaths.map(p => `:(exclude,literal)${p}`)];
|
||||
const restFrom = patchId(repoRoot, fromBase, from, rest);
|
||||
const restHead = patchId(repoRoot, headBase, head, rest);
|
||||
if (typeof restFrom !== 'string' || typeof restHead !== 'string' || restFrom !== restHead) changedProduct.push(`(a product path outside ${contextPaths.join(', ')}; compare the two base diffs without them)`);
|
||||
}
|
||||
}
|
||||
let scopedReview: string | undefined;
|
||||
const packetBody = !changedProduct.length && contextPaths.length ? contextPacket(repoRoot, { reviewedBase: fromBase, reviewed: from, currentBase: headBase, current: head }, contextPaths) : null;
|
||||
if (packetBody?.over.length) changedProduct.push(...packetBody.over.map(p => `${p} (too large for the scoped review: over ${PACKET_SECTION_CAP} lines in a section)`));
|
||||
if (packetBody && !changedProduct.length) {
|
||||
const record = flag(rest, '--scoped-review');
|
||||
if (!record && rest.includes('--dry-run')) {
|
||||
scopedDryRun = `base context moved next to the reviewed hunks in ${contextPaths.join(', ')}: carry without --dry-run writes the scoped-review packet for one fix-check reviewer, then carry --scoped-review <record.json>; nothing written`;
|
||||
scopedReview = `base context moved (scoped review pending: ${contextPaths.join(', ')})`;
|
||||
} else if (!record) {
|
||||
const gitPath = git(['rev-parse', '--git-path', `gstack/scoped-review-${head.slice(0, 12)}.md`], repoRoot);
|
||||
const packet = path.resolve(repoRoot, gitPath ?? `.git/gstack/scoped-review-${head.slice(0, 12)}.md`);
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(packet), { recursive: true });
|
||||
fs.writeFileSync(packet, packetBody.text);
|
||||
} catch (e) {
|
||||
return refused('RECEIPT_CARRY_REFUSED', `base context moved next to the reviewed hunks in ${contextPaths.join(', ')}, but the scoped-review packet ${packet} cannot be written (${(e as Error).message.split('\n')[0]}); make its directory writable, then rerun carry`);
|
||||
}
|
||||
return refused('RECEIPT_CARRY_SCOPED_REVIEW', `base context moved next to the reviewed hunks in ${contextPaths.join(', ')}: one fix-check reviewer reads ${packet}, then rerun carry with --scoped-review <record.json> ({"head":"${head}","paths":[…],"reviewer":"<model>","verdict":"clean"}); a blocking finding means /ship on ${head.slice(0, 12)}`);
|
||||
}
|
||||
if (record) {
|
||||
const review = readScopedReview(record, { head }, contextPaths);
|
||||
if (!review.ok) return refused('RECEIPT_CARRY_REFUSED', `--scoped-review: ${review.message}`);
|
||||
scopedReview = `base context moved (scoped review by ${review.review.reviewer}: ${contextPaths.join(', ')})`;
|
||||
}
|
||||
if (!changedProduct.length) changedProduct.push('(a product path outside the branch\'s touched files; compare the two base diffs)');
|
||||
}
|
||||
const mergeCommits = (git(['rev-list', '--merges', '--count', `${from}..${head}`], repoRoot) ?? '0').trim();
|
||||
// Evidence for the record, never the decision: a slow range-diff is reported unavailable rather than waited for.
|
||||
const rangeRaw = spawnSync('git', ['range-diff', '--no-color', `${fromBase}..${from}`, `${headBase}..${head}`], { cwd: repoRoot, encoding: 'utf8', timeout: 15_000, maxBuffer: 64 * 1024 * 1024 });
|
||||
const rangeDiff = rangeRaw.status === 0 ? (rangeRaw.stdout.trim().split('\n').slice(0, 20).join('\n') || '(empty: identical ranges)') : `unavailable: ${(rangeRaw.stderr ?? '').trim().split('\n')[0] || (rangeRaw.signal ? `range-diff killed by ${rangeRaw.signal} after 15 s` : 'range-diff failed')}`;
|
||||
plan = planCarry({ from, head, changed, productPatchId: { from: reviewed as string, head: current as string }, testPatchId: { from: testFrom, head: testHead }, changedProduct, mergeCommits: Number(mergeCommits) || 0, rangeDiff, ...(retarget ? { retargetedFrom: reviewedBase } : {}) });
|
||||
plan = planCarry({ from, head, changed, productPatchId: { from: reviewed as string, head: current as string }, testPatchId: { from: testFrom, head: testHead }, changedProduct, mergeCommits: Number(mergeCommits) || 0, rangeDiff, ...(retarget ? { retargetedFrom: reviewedBase } : {}), ...(scopedReview ? { scopedReview } : {}) });
|
||||
}
|
||||
if (!plan.ok) return refused(plan.code, plan.message);
|
||||
// Eligibility is not publication: a receipt that recorded a pregate is rebound only with a pregate current for HEAD,
|
||||
@@ -654,6 +735,7 @@ function cmdCarry(rest: string[], json: boolean): number {
|
||||
if (errors.length) return refused('RECEIPT_CARRY_REFUSED', `the carried receipt does not validate: ${errors[0]!.message} at ${errors[0]!.path}`);
|
||||
const newBody = placeReceiptFirst(body, carried);
|
||||
const line = receiptSummaryLine(carried);
|
||||
if (scopedDryRun) return refused('RECEIPT_CARRY_SCOPED_REVIEW', scopedDryRun);
|
||||
if (rest.includes('--dry-run')) {
|
||||
process.stderr.write(line + '\n');
|
||||
process.stdout.write(json ? JSON.stringify({ skill: 'ship', status: 'dry-run', head, carry: plan.carry }) + '\n' : `CARRY: would carry from ${from.slice(0, 12)} to ${head.slice(0, 12)} (${plan.carry.reason}); nothing written\n`);
|
||||
|
||||
+19
-1
@@ -609,8 +609,26 @@ fi
|
||||
# is a quoted heredoc (markdown backticks must stay literal), so the resolved
|
||||
# bin path is substituted via sed on the __BIN__ placeholder. Under off the
|
||||
# routing rules are the auto-invocation the user turned off, so no offer.
|
||||
# Under core the heredoc's rule lines are replaced by the core rules below, so
|
||||
# the offered section routes only the four core skills.
|
||||
_CORE_ROUTING_RULES=$(cat <<'EOR'
|
||||
- Product ideas/brainstorming → invoke /office-hours
|
||||
- QA/testing site behavior → invoke /qa
|
||||
- Code review/diff check → invoke /review
|
||||
- Ship/deploy/PR → invoke /ship
|
||||
|
||||
Do not suggest or auto-invoke other gstack skills: they run when the user types them, or when a running skill hands off to them (`gstack-config set proactive all` routes every skill).
|
||||
EOR
|
||||
)
|
||||
_routing_offer() {
|
||||
if [ "$_PROACTIVE" = "core" ]; then
|
||||
_GSTACK_CORE_RULES="$_CORE_ROUTING_RULES" awk '/^- .* → invoke \// { if (!done) { print ENVIRON["_GSTACK_CORE_RULES"]; done = 1 } next } { print }'
|
||||
else
|
||||
cat
|
||||
fi | sed "s|__BIN__|$_BIN|g"
|
||||
}
|
||||
if [ "$_HAS_ROUTING" = "no" ] && [ "$_ROUTING_DECLINED" = "false" ] && [ "$_PROACTIVE_PROMPTED" = "yes" ] && [ "$_PROACTIVE_OFF" = "no" ]; then
|
||||
sed "s|__BIN__|$_BIN|g" <<'EOI' | _emit_block routing-injection
|
||||
_routing_offer <<'EOI' | _emit_block routing-injection
|
||||
gstack works best when the project CLAUDE.md includes skill routing rules. If no CLAUDE.md exists in the project root, create it. Use AskUserQuestion:
|
||||
|
||||
> gstack works best when your project's CLAUDE.md includes skill routing rules.
|
||||
|
||||
+215
-7
@@ -46,6 +46,11 @@
|
||||
#
|
||||
# Resolution order (highest precedence first):
|
||||
# 0. $GSTACK_PROJECT_SLUG env override (documented escape hatch)
|
||||
# 0m. Project marker (#2649, #1923): the innermost counted .gstack-project on
|
||||
# the cwd chain makes its folder the project root; nothing above it is
|
||||
# consulted (see _find_project_marker for when a marker counts). Its
|
||||
# result has its own cache entry, so removing the marker restores the
|
||||
# folder's earlier slug untouched.
|
||||
# 1. Walk UP from $(pwd) to the OUTERMOST ancestor containing a canonical
|
||||
# project-identity marker (.git, .project.yaml, package.json, pyproject.toml,
|
||||
# Cargo.toml, Gemfile, go.mod). Use that ancestor as the "project root".
|
||||
@@ -108,8 +113,8 @@ case "${1:-}" in
|
||||
"") ;;
|
||||
--identity) MODE=identity ;;
|
||||
--get) MODE=get; MODE_ARG="${2:-}"
|
||||
case "$MODE_ARG" in SLUG|BRANCH|PROJECT_REMOTE|PROJECT_ROOT|LEGACY_SLUG|FORMER_SLUGS) ;;
|
||||
*) echo "usage: gstack-slug --get SLUG|BRANCH|PROJECT_REMOTE|PROJECT_ROOT|LEGACY_SLUG|FORMER_SLUGS" >&2; exit 2 ;;
|
||||
case "$MODE_ARG" in SLUG|REPO_SLUG|BRANCH|PROJECT_REMOTE|PROJECT_ROOT|PROJECT_MARKER|LEGACY_SLUG|FORMER_SLUGS) ;;
|
||||
*) echo "usage: gstack-slug --get SLUG|REPO_SLUG|BRANCH|PROJECT_REMOTE|PROJECT_ROOT|PROJECT_MARKER|LEGACY_SLUG|FORMER_SLUGS" >&2; exit 2 ;;
|
||||
esac ;;
|
||||
--stamp-checkpoint) MODE=stamp; MODE_ARG="${2:-}"
|
||||
[[ -n "$MODE_ARG" ]] || { echo "usage: gstack-slug --stamp-checkpoint <file>" >&2; exit 2; } ;;
|
||||
@@ -127,6 +132,11 @@ case "${1:-}" in
|
||||
MODE=$([[ "$1" == "--from" ]] && echo adopt-from || echo adopt-dismiss)
|
||||
MODE_ARG=$(printf '%s' "${2:-}" | tr -cd 'a-zA-Z0-9._-')
|
||||
case "$MODE_ARG" in ""|.|..) echo "usage: gstack-slug --adopt-legacy $1 <slug>" >&2; exit 2 ;; esac
|
||||
ADOPT_APPLY=0
|
||||
if [[ "$1" == "--from" && "${3:-}" == "--under-root" ]]; then
|
||||
MODE=adopt-under
|
||||
case "${4:-}" in "") ;; --apply) ADOPT_APPLY=1 ;; *) echo "usage: gstack-slug --adopt-legacy --from <slug> --under-root [--apply]" >&2; exit 2 ;; esac
|
||||
fi
|
||||
elif [[ -n "${1:-}" ]]; then
|
||||
echo "usage: gstack-slug --adopt-legacy [--copy <relpath>... | --from <slug> | --dismiss <slug>]" >&2; exit 2
|
||||
fi ;;
|
||||
@@ -136,6 +146,8 @@ gstack-slug — print SLUG=<project slug> and BRANCH=<branch> for the current di
|
||||
eval "$(gstack-slug)" set SLUG and BRANCH
|
||||
SLUG=$(gstack-slug --get SLUG) print one raw value (SLUG, BRANCH, or an --identity
|
||||
field); no eval needed
|
||||
gstack-slug --get REPO_SLUG the slug ignoring every .gstack-project marker, for records
|
||||
that belong to one repository (policy, approvals, consent)
|
||||
gstack-slug --identity also print PROJECT_REMOTE (canonical, credential-free),
|
||||
PROJECT_ROOT and LEGACY_SLUG (pre-#3003 bucket, if different)
|
||||
gstack-slug --stamp-checkpoint FILE write remote:/project_root:/head: into FILE's frontmatter
|
||||
@@ -154,6 +166,12 @@ gstack-slug — print SLUG=<project slug> and BRANCH=<branch> for the current di
|
||||
de-duplication, missing files copy, differing files are
|
||||
listed as conflicts and never overwritten; S is kept
|
||||
gstack-slug --adopt-legacy --dismiss S stop naming former bucket S at skill start
|
||||
gstack-slug --adopt-legacy --from S --under-root [--apply]
|
||||
preview (or copy and restamp) S's checkpoints saved inside
|
||||
the folder a .gstack-project marks
|
||||
Marker: an empty .gstack-project file makes its folder a project of its own (nested repos,
|
||||
umbrella folders over several repos); --identity shows which marker applied or why one
|
||||
was ignored. Docs: docs/state-root.md#nested-repos-and-umbrella-folders
|
||||
Env: GSTACK_PROJECT_SLUG pins the slug (ssh host aliases, vendored sub-repos); never cached.
|
||||
USAGE
|
||||
exit 0 ;;
|
||||
@@ -319,13 +337,137 @@ _REMOTE_RESOLVED=0
|
||||
_resolve_remote() {
|
||||
if [[ "$_REMOTE_RESOLVED" -eq 1 ]]; then return 0; fi
|
||||
_REMOTE_RESOLVED=1
|
||||
REMOTE_ROOT=$(_outermost_remote_repo "$PROJECT_DIR")
|
||||
if [[ -n "${MARKER_ROOT:-}" ]]; then
|
||||
# A marked folder consults only its own remote, never an outer repo's.
|
||||
REMOTE_ROOT=""
|
||||
[[ -e "$MARKER_ROOT/.git" ]] && git -C "$MARKER_ROOT" remote get-url origin >/dev/null 2>&1 && REMOTE_ROOT="$MARKER_ROOT"
|
||||
else
|
||||
REMOTE_ROOT=$(_outermost_remote_repo "$PROJECT_DIR")
|
||||
fi
|
||||
if [[ -n "$REMOTE_ROOT" ]]; then
|
||||
REMOTE_URL=$(git -C "$REMOTE_ROOT" remote get-url origin 2>/dev/null) || REMOTE_URL=""
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# 0m. Project marker (#2649, #1923). A .gstack-project counts when it is a
|
||||
# regular file (not a link or a directory) owned by this user, below $HOME
|
||||
# when the cwd is under $HOME, and its folder is a git toplevel or not inside
|
||||
# any git work tree. So a cloned repo can't plant one in a subfolder to pick a
|
||||
# bucket by folder name, and a stray ~/.gstack-project can't swallow every
|
||||
# project. Ignored markers are listed by --identity only.
|
||||
MARKER_ROOT=""
|
||||
MARKER_IGNORED=""
|
||||
_HOME_LOG="${HOME:-}"
|
||||
_HOME_PHYS=$(cd "$_HOME_LOG" 2>/dev/null && pwd -P) || _HOME_PHYS=""
|
||||
_UNDER_HOME=0
|
||||
for _h in "$_HOME_LOG" "$_HOME_PHYS"; do
|
||||
[[ -n "$_h" && "$_h" != / && "$PROJECT_DIR" == "$_h"/* ]] && _UNDER_HOME=1
|
||||
done
|
||||
_is_home_or_above() {
|
||||
local h
|
||||
for h in "$_HOME_LOG" "$_HOME_PHYS"; do
|
||||
[[ -n "$h" && ( "$h" == "$1" || "$h" == "$1"/* || "$1" == / ) ]] && return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
# Prints why the marker in folder $1 does not count; nothing when it counts.
|
||||
# $2 overrides whether the walk stops at $HOME (default: the cwd is under it).
|
||||
_marker_reason() {
|
||||
local m="$1/.gstack-project" d parent depth=0 under="${2:-$_UNDER_HOME}"
|
||||
if [[ -L "$m" ]]; then echo "a symbolic link"; return 0; fi
|
||||
if [[ -d "$m" ]]; then echo "a directory, not a file: rmdir '$m' && touch '$m'"; return 0; fi
|
||||
[[ -f "$m" ]] || { echo "not a regular file"; return 0; }
|
||||
[[ -O "$m" ]] || { echo "owned by another user"; return 0; }
|
||||
case "$(basename "$1" | tr -cd 'a-zA-Z0-9._-')" in
|
||||
""|.|..) echo "its folder name has no letter or digit gstack can use in a project name; rename the folder or set GSTACK_PROJECT_SLUG"; return 0 ;;
|
||||
esac
|
||||
if [[ "$under" -eq 1 ]] && _is_home_or_above "$1"; then echo "at or above \$HOME"; return 0; fi
|
||||
[[ ! -e "$1/.git" ]] || return 0
|
||||
d=$(dirname "$1")
|
||||
while [[ $depth -lt 64 ]]; do
|
||||
if [[ "$under" -eq 1 ]] && _is_home_or_above "$d"; then break; fi
|
||||
if [[ -e "$d/.git" ]]; then echo "inside the git work tree at $d; a marker counts only at a repository's top folder"; return 0; fi
|
||||
parent=$(dirname "$d"); [[ "$parent" == "$d" ]] && break
|
||||
d="$parent"; depth=$((depth + 1))
|
||||
done
|
||||
return 0
|
||||
}
|
||||
_find_project_marker() {
|
||||
local dir="$1" parent="" why="" depth=0
|
||||
while [[ -n "$dir" && $depth -lt 64 ]]; do
|
||||
if [[ -e "$dir/.gstack-project" || -L "$dir/.gstack-project" ]]; then
|
||||
why=$(_marker_reason "$dir")
|
||||
if [[ -z "$why" ]]; then MARKER_ROOT=$(cd "$dir" 2>/dev/null && pwd -P) || MARKER_ROOT=""; [[ -z "$MARKER_ROOT" ]] || return 0; why="unreadable folder"; fi
|
||||
MARKER_IGNORED="${MARKER_IGNORED}$dir/.gstack-project ($why)"$'\n'
|
||||
fi
|
||||
if [[ "$_UNDER_HOME" -eq 1 ]] && _is_home_or_above "$dir"; then break; fi
|
||||
parent=$(dirname "$dir"); [[ "$parent" == "$dir" ]] && break
|
||||
dir="$parent"; depth=$((depth + 1))
|
||||
done
|
||||
return 0
|
||||
}
|
||||
# A linked worktree outside the marked folder follows its main checkout: on a
|
||||
# miss the walk repeats from the main checkout, under that folder's $HOME rule.
|
||||
if [[ -z "$SLUG" && ! ( "$MODE" == get && "$MODE_ARG" == REPO_SLUG ) ]]; then
|
||||
_find_project_marker "$PROJECT_DIR"
|
||||
_main=""
|
||||
[[ "$(basename "${GIT_COMMON_DIR:-x}")" != .git ]] || _main=$(dirname "$GIT_COMMON_DIR")
|
||||
if [[ -z "$MARKER_ROOT" && -n "$_main" && "$PROJECT_DIR" != "$_main" && "$PROJECT_DIR" != "$_main"/* ]]; then
|
||||
_cwd_under=$_UNDER_HOME
|
||||
_UNDER_HOME=0
|
||||
for _h in "$_HOME_LOG" "$_HOME_PHYS"; do
|
||||
[[ -n "$_h" && "$_h" != / && "$_main" == "$_h"/* ]] && _UNDER_HOME=1
|
||||
done
|
||||
_find_project_marker "$_main"
|
||||
_UNDER_HOME=$_cwd_under
|
||||
fi
|
||||
# A marker at a linked worktree's top folder stands for its main checkout.
|
||||
if [[ -n "$MARKER_ROOT" && -f "$MARKER_ROOT/.git" ]]; then
|
||||
_mc=$(_git_common_dir "$MARKER_ROOT")
|
||||
[[ "$(basename "${_mc:-x}")" != .git ]] || MARKER_ROOT=$(dirname "$_mc")
|
||||
fi
|
||||
fi
|
||||
_write_cache_entry() { # $1 file, $2 content; atomic, best-effort
|
||||
local tmp
|
||||
mkdir -p "$(dirname "$1")" 2>/dev/null || return 0
|
||||
tmp=$(mktemp "$(dirname "$1")/.slug-XXXXXX" 2>/dev/null) || return 0
|
||||
printf '%s' "$2" > "$tmp" && mv "$tmp" "$1" 2>/dev/null || rm -f "$tmp" 2>/dev/null
|
||||
return 0
|
||||
}
|
||||
if [[ -n "$MARKER_ROOT" ]]; then
|
||||
PROJECT_ROOT="$MARKER_ROOT"
|
||||
MARKER_CACHE="$CACHE_DIR/marker/$(printf '%s' "$MARKER_ROOT" | sed -e 's/%/%25/g' -e 's/_/%5f/g' -e 's#/#_#g')"
|
||||
_m=$(cat "$MARKER_CACHE" 2>/dev/null || true)
|
||||
if [[ "$_m" == v2:* ]]; then
|
||||
SLUG=$(printf '%s' "${_m#v2:}" | tr -cd 'a-zA-Z0-9._-')
|
||||
case "$SLUG" in .|..) SLUG="" ;; esac
|
||||
fi
|
||||
if [[ -z "$SLUG" ]]; then
|
||||
_resolve_remote
|
||||
if [[ -n "$REMOTE_URL" ]]; then
|
||||
gstack_remote_slug "$REMOTE_URL"
|
||||
SLUG="$_gri_slug"
|
||||
case "$SLUG" in ""|.|..|*/*) SLUG="" ;; esac
|
||||
fi
|
||||
SLUG="${SLUG:-$(basename "$MARKER_ROOT" | tr -cd 'a-zA-Z0-9._-')}"
|
||||
case "$SLUG" in .|..) SLUG="" ;; esac
|
||||
if [[ -n "$SLUG" ]]; then
|
||||
_write_cache_entry "$MARKER_CACHE" "v2:$SLUG"
|
||||
# One notice when the marker first moves this folder away from a bucket
|
||||
# that holds checkpoints (stderr only; later runs are marker-cache hits).
|
||||
for _f in "$CACHE_FILE" "$MAIN_CACHE_FILE" "$LEGACY_CACHE_FILE"; do
|
||||
[[ -n "$_f" && -f "$_f" ]] || continue
|
||||
_old=$(cat "$_f" 2>/dev/null || true); _old=$(printf '%s' "${_old#v2:}" | tr -cd 'a-zA-Z0-9._-')
|
||||
if [[ -n "$_old" && "$_old" != "$SLUG" && "$_old" != . && "$_old" != .. && -n "$(ls -A "$GSTACK_STATE_ROOT/projects/$_old/checkpoints" 2>/dev/null)" ]]; then
|
||||
echo "gstack: $MARKER_ROOT/.gstack-project files this folder under projects/$SLUG; checkpoints saved here before are in projects/$_old (preview recovering them with: gstack-slug --adopt-legacy --from $_old --under-root)" >&2
|
||||
fi
|
||||
break
|
||||
done
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# 1b. Cached identity is STICKY (#2212): a project that used gstack before it
|
||||
# adopted a git remote keeps its pre-origin slug — recomputing from the
|
||||
# remote here would rename the project mid-life and orphan everything
|
||||
@@ -408,6 +550,19 @@ if [[ -z "$SLUG" ]]; then
|
||||
# slash-bearing slugs and fall through to the basename fallback below.
|
||||
# (tr -cd already deletes "/", so */* is belt-and-braces.)
|
||||
case "$SLUG" in ""|.|..|*/*) SLUG="" ;; esac
|
||||
# One hint on a fresh resolve: this checkout has its own differing remote
|
||||
# but files under an outer repo's (stderr only; the cache keeps it quiet).
|
||||
if [[ -n "$SLUG" && -n "$REMOTE_ROOT" && "$(basename "${GIT_COMMON_DIR:-x}")" == .git ]]; then
|
||||
_here=$(dirname "$GIT_COMMON_DIR")
|
||||
_outer=$(cd "$REMOTE_ROOT" 2>/dev/null && pwd -P) || _outer=""
|
||||
if [[ -n "$_outer" && "$_here" != "$_outer" ]]; then
|
||||
_own=$(git -C "$_here" remote get-url origin 2>/dev/null) || _own=""
|
||||
if [[ -n "$_own" ]] && gstack_remote_slug "$_own" && [[ -n "$_gri_slug" && "$_gri_slug" != "$SLUG" ]]; then
|
||||
echo "gstack: $_here has its own remote but files under projects/$SLUG (the outer repo at $_outer); to give it its own memory: touch '$_here/.gstack-project' (docs/state-root.md#nested-repos-and-umbrella-folders)" >&2
|
||||
fi
|
||||
gstack_remote_slug "$REMOTE_URL"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -432,7 +587,7 @@ SLUG=$(printf '%s' "$SLUG" | tr -cd 'a-zA-Z0-9._-')
|
||||
|
||||
# A worktree whose own pre-#2767 entry named a different bucket that still
|
||||
# holds data: remember it so skill start can name the merge command.
|
||||
if [[ -n "$SLUG" && "$SLUG_FROM_ENV" -eq 0 && "$_ENTRY_FILE" != "$LEGACY_CACHE_FILE" && -f "$LEGACY_CACHE_FILE" ]]; then
|
||||
if [[ -n "$SLUG" && "$SLUG_FROM_ENV" -eq 0 && -z "$MARKER_ROOT" && "$_ENTRY_FILE" != "$LEGACY_CACHE_FILE" && -f "$LEGACY_CACHE_FILE" ]]; then
|
||||
_OWN=$(cat "$LEGACY_CACHE_FILE" 2>/dev/null || true)
|
||||
_OWN=$(printf '%s' "${_OWN#v2:}" | tr -cd 'a-zA-Z0-9._-')
|
||||
_FORMER_FILE="$GSTACK_STATE_ROOT/projects/$SLUG/.former-slugs"
|
||||
@@ -442,7 +597,7 @@ if [[ -n "$SLUG" && "$SLUG_FROM_ENV" -eq 0 && "$_ENTRY_FILE" != "$LEGACY_CACHE_F
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -n "$SLUG" && "$SLUG_FROM_ENV" -eq 0 ]]; then
|
||||
if [[ -n "$SLUG" && "$SLUG_FROM_ENV" -eq 0 && -z "$MARKER_ROOT" ]]; then
|
||||
for _f in "$CACHE_FILE" "$LEGACY_CACHE_FILE"; do
|
||||
CURRENT_CACHE=""
|
||||
if [[ -f "$_f" ]]; then
|
||||
@@ -470,8 +625,8 @@ RAW_BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null) || RAW_BRANCH=""
|
||||
BRANCH=$(printf '%s' "${RAW_BRANCH:-}" | tr '/' '-' | tr -cd 'a-zA-Z0-9._-')
|
||||
BRANCH="${BRANCH:-unknown}"
|
||||
|
||||
if [[ "$MODE" == "get" && ( "$MODE_ARG" == SLUG || "$MODE_ARG" == BRANCH ) ]]; then
|
||||
if [[ "$MODE_ARG" == SLUG ]]; then printf '%s\n' "$SLUG"; else printf '%s\n' "$BRANCH"; fi
|
||||
if [[ "$MODE" == "get" && ( "$MODE_ARG" == SLUG || "$MODE_ARG" == REPO_SLUG || "$MODE_ARG" == BRANCH ) ]]; then
|
||||
if [[ "$MODE_ARG" == BRANCH ]]; then printf '%s\n' "$BRANCH"; else printf '%s\n' "$SLUG"; fi
|
||||
exit 0
|
||||
fi
|
||||
FORMER_FILE="$GSTACK_STATE_ROOT/projects/$SLUG/.former-slugs"
|
||||
@@ -665,6 +820,7 @@ case "$MODE" in
|
||||
case "$MODE_ARG" in
|
||||
PROJECT_REMOTE) printf '%s\n' "$ID_REMOTE" ;;
|
||||
PROJECT_ROOT) printf '%s\n' "$ID_ROOT" ;;
|
||||
PROJECT_MARKER) [[ -z "$MARKER_ROOT" ]] && echo || printf '%s\n' "$MARKER_ROOT/.gstack-project" ;;
|
||||
LEGACY_SLUG) printf '%s\n' "$ID_LEGACY" ;;
|
||||
esac
|
||||
;;
|
||||
@@ -674,7 +830,11 @@ case "$MODE" in
|
||||
echo "BRANCH=$BRANCH"
|
||||
printf 'PROJECT_REMOTE=%q\n' "$ID_REMOTE"
|
||||
printf 'PROJECT_ROOT=%q\n' "$ID_ROOT"
|
||||
printf 'PROJECT_MARKER=%q\n' "${MARKER_ROOT:+$MARKER_ROOT/.gstack-project}"
|
||||
echo "LEGACY_SLUG=$ID_LEGACY"
|
||||
while IFS= read -r _ign; do
|
||||
if [[ -n "$_ign" ]]; then printf 'MARKER_IGNORED=%q\n' "$_ign"; fi
|
||||
done <<<"$MARKER_IGNORED"
|
||||
;;
|
||||
|
||||
stamp)
|
||||
@@ -799,6 +959,54 @@ case "$MODE" in
|
||||
done
|
||||
;;
|
||||
|
||||
adopt-under)
|
||||
# Recover checkpoints a former bucket holds for the marked folder: only
|
||||
# those stamped (worktree:, else project_root:) at or below it. Preview
|
||||
# unless --apply; copies are restamped with this identity and keep head:.
|
||||
[[ -n "$MARKER_ROOT" ]] || { echo "gstack-slug: no .gstack-project applies here, so there is no marked folder to recover checkpoints for (see gstack-slug --identity)" >&2; exit 2; }
|
||||
[[ "$MODE_ARG" != "$SLUG" ]] || { echo "gstack-slug: projects/$MODE_ARG is this project's own bucket." >&2; exit 2; }
|
||||
AU_FROM="$STATE_PHYS/projects/$MODE_ARG/checkpoints"
|
||||
AU_TO="$STATE_PHYS/projects/$SLUG/checkpoints"
|
||||
if [[ ! -d "$AU_FROM" || -L "$AU_FROM" ]]; then echo "Nothing to recover: no checkpoints in projects/$MODE_ARG."; exit 0; fi
|
||||
AU_WANT=0; AU_HAVE=0; AU_UNSTAMPED=0; AU_RC=0
|
||||
while IFS= read -r f; do
|
||||
[[ -n "$f" && -f "$f" && ! -L "$f" ]] || continue
|
||||
IFS=$'\037' read -r _b root wt _t <<<"$(_checkpoint_meta "$f")"
|
||||
if [[ -z "$root" && -z "$wt" ]]; then AU_UNSTAMPED=$((AU_UNSTAMPED + 1)); continue; fi
|
||||
where="${wt:-$root}"
|
||||
[[ "$where" == "$MARKER_ROOT" || "$where" == "$MARKER_ROOT"/* ]] || continue
|
||||
dst="$AU_TO/$(basename "$f")"
|
||||
if [[ -e "$dst" || -L "$dst" ]]; then echo "ALREADY $dst"; AU_HAVE=$((AU_HAVE + 1)); continue; fi
|
||||
AU_WANT=$((AU_WANT + 1))
|
||||
if [[ "$ADOPT_APPLY" -eq 0 ]]; then echo "WOULD_COPY $f"; continue; fi
|
||||
AU_WT="${wt:-$root}"; [[ "$AU_WT" != "$ID_ROOT" ]] || AU_WT=""
|
||||
mkdir -p "$AU_TO" && AU_TMP=$(mktemp "$AU_TO/.adopt-XXXXXX") || { echo "FAILED $f"; AU_RC=1; continue; }
|
||||
if GRI_REMOTE="$ID_REMOTE" GRI_ROOT="$ID_ROOT" GRI_WORKTREE="$AU_WT" awk '
|
||||
function ids() {
|
||||
if (ENVIRON["GRI_REMOTE"] != "") print "remote: " ENVIRON["GRI_REMOTE"]
|
||||
print "project_root: " ENVIRON["GRI_ROOT"]
|
||||
if (ENVIRON["GRI_WORKTREE"] != "") print "worktree: " ENVIRON["GRI_WORKTREE"]
|
||||
}
|
||||
NR == 1 && $0 == "---" { infm = 1; print; next }
|
||||
infm && $0 == "---" { ids(); print; infm = 0; next }
|
||||
infm && (/^remote:/ || /^project_root:/ || /^worktree:/) { next }
|
||||
{ print }' "$f" > "$AU_TMP" && touch -r "$f" "$AU_TMP" && ln "$AU_TMP" "$dst" 2>/dev/null; then
|
||||
rm -f "$AU_TMP"; echo "COPIED $dst"
|
||||
elif [[ -e "$dst" || -L "$dst" ]]; then
|
||||
rm -f "$AU_TMP"; echo "ALREADY $dst"; AU_WANT=$((AU_WANT - 1)); AU_HAVE=$((AU_HAVE + 1))
|
||||
else
|
||||
rm -f "$AU_TMP"; echo "FAILED $f"; AU_RC=1
|
||||
fi
|
||||
done < <(find "$AU_FROM" -maxdepth 1 -name '*.md' -type f 2>/dev/null | LC_ALL=C sort)
|
||||
if [[ "$ADOPT_APPLY" -eq 1 ]]; then
|
||||
echo "recovered $AU_WANT checkpoint(s) from projects/$MODE_ARG into projects/$SLUG ($AU_HAVE already there); projects/$MODE_ARG is unchanged"
|
||||
else
|
||||
echo "$AU_WANT checkpoint(s) in projects/$MODE_ARG were saved inside $MARKER_ROOT ($AU_HAVE already here); nothing copied. To copy and restamp them: gstack-slug --adopt-legacy --from $MODE_ARG --under-root --apply"
|
||||
fi
|
||||
[[ "$AU_UNSTAMPED" -eq 0 ]] || echo "$AU_UNSTAMPED checkpoint(s) predate identity stamps, so their folder is unknown; they stay in projects/$MODE_ARG"
|
||||
exit "$AU_RC"
|
||||
;;
|
||||
|
||||
adopt)
|
||||
if [[ -z "$ID_LEGACY" ]]; then
|
||||
echo "Nothing to adopt: this project's slug ($SLUG) did not change."
|
||||
|
||||
Executable
+108
@@ -0,0 +1,108 @@
|
||||
#!/usr/bin/env -S bun --no-env-file --config=/dev/null
|
||||
/**
|
||||
* gstack-tasks — a plan review's task list, and its reconciliation across reruns (G2, #1791).
|
||||
*
|
||||
* gstack-tasks prior --phase <p> [--branch <b>] [--dir <d>] [--json]
|
||||
* gstack-tasks add --phase <p> [--branch <b>] [--dir <d>]
|
||||
* gstack-tasks supersede --phase <p> --keep <list> [--branch <b>] [--dir <d>] [--json]
|
||||
*
|
||||
* --dir defaults to <state root>/projects/<gstack-slug SLUG>, --branch to the current git branch.
|
||||
* Logic: lib/tasks-prior.ts. Codes: lib/result-codes-plan.ts (TASKS_SUPERSEDE_REFUSED).
|
||||
*/
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { EXIT, exitTableHelp } from '../lib/headless-artifacts';
|
||||
import { resultLine } from '../lib/result-codes';
|
||||
import { runScriptSync } from '../lib/script-invocation';
|
||||
import { resolveStateRoot } from '../lib/state-root';
|
||||
import { SupersedeRefused, TASK_PHASES, addRows, finishPending, priorSet, readTaskFiles, rowsFileName, supersede, type TaskPhase } from '../lib/tasks-prior';
|
||||
|
||||
const HELP = `gstack-tasks — a plan review's task list across reruns
|
||||
|
||||
prior --phase <p> [--json] the newest earlier list for this phase and branch, and how to reconcile it;
|
||||
deletes any rows file an unfinished run left
|
||||
add --phase <p> validate the rows file, write this run's list (empty: ran, no findings) and its
|
||||
dispositions to task-dispositions-<p>-<branch>.jsonl, then supersede this branch's earlier lists
|
||||
supersede --phase <p> --keep <list> [--json] move this branch's reconciled earlier lists out of the aggregate
|
||||
|
||||
Every command takes [--dir <d>] (default <state root>/projects/<slug>) and [--branch <b>] (default the current git branch).
|
||||
Rows file <repo>/.gstack/tmp/tasks-<p>-rows.jsonl, one JSON object per line, written with a file-write tool
|
||||
(free text never goes on a command line; a missing file means zero rows):
|
||||
task {"id","priority","component","title","source_finding","effort_human","effort_cc"} strings,
|
||||
"files" an array of strings, optional "carried_from": "<run_id>/<id>"
|
||||
disposition {"prior": "<run_id>/<id>", "disposition": "done" | "dropped", "reason": "<commit or why>"}
|
||||
prior prints \`PRIOR_TASKS: none\` or \`PRIOR_TASKS: <file> run=<run_id> tasks=<n>\`, one JSON row per task, then a RECONCILE: line;
|
||||
\`PRIOR_TASKS_MALFORMED: <file>:<line>\` goes to stderr for a line that is not a JSON object (skipped, never edited).
|
||||
supersede renames each earlier tasks-<p>-*.jsonl whose rows are all this branch and not newer than --keep to
|
||||
superseded-<name> and prints \`SUPERSEDED: <file>\` or \`KEPT: <file> (<reason>)\`; --keep must validate first.
|
||||
${exitTableHelp()}`;
|
||||
|
||||
function flag(args: string[], name: string): string | undefined {
|
||||
const i = args.indexOf(name);
|
||||
return i >= 0 ? args[i + 1] : undefined;
|
||||
}
|
||||
function usage(message: string): number {
|
||||
process.stderr.write(`gstack-tasks: ${message}\n${HELP}\n`);
|
||||
return EXIT.usage;
|
||||
}
|
||||
const git = (...args: string[]) => { const r = spawnSync('git', args, { encoding: 'utf8', timeout: 10_000 }); return r.status === 0 ? r.stdout.trim() : ''; };
|
||||
function slug(name: 'SLUG' | 'BRANCH'): string {
|
||||
const r = runScriptSync(path.join(import.meta.dir, 'gstack-slug'), ['--get', name], { encoding: 'utf8', timeout: 10_000 });
|
||||
return (r.status === 0 && r.stdout.trim()) || 'unknown';
|
||||
}
|
||||
|
||||
/** The repository's git-excluded .gstack/tmp, where a host's file-write tool works without a permission card; the tasks directory outside a repository. */
|
||||
function rowsDir(fallback: string): string {
|
||||
const top = git('rev-parse', '--show-toplevel');
|
||||
if (!top) return fallback;
|
||||
const exclude = git('rev-parse', '--git-path', 'info/exclude');
|
||||
if (exclude) {
|
||||
const file = path.resolve(exclude);
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
if (!(fs.existsSync(file) && fs.readFileSync(file, 'utf8').split('\n').includes('/.gstack/tmp/'))) fs.appendFileSync(file, '/.gstack/tmp/\n');
|
||||
}
|
||||
return path.join(top, '.gstack', 'tmp');
|
||||
}
|
||||
|
||||
function main(argv: string[]): number {
|
||||
const [cmd, ...rest] = argv;
|
||||
if (!cmd || cmd === '--help' || cmd === '-h' || cmd === 'help') { process.stdout.write(HELP + '\n'); return cmd ? EXIT.ok : EXIT.usage; }
|
||||
if (!['prior', 'add', 'supersede'].includes(cmd)) return usage(`unknown command ${cmd}`);
|
||||
const phase = flag(rest, '--phase') as TaskPhase | undefined;
|
||||
if (!phase || !TASK_PHASES.includes(phase)) return usage(`--phase must be one of ${TASK_PHASES.join(', ')}`);
|
||||
const dir = path.resolve(flag(rest, '--dir') ?? path.join(resolveStateRoot(), 'projects', slug('SLUG')));
|
||||
const branch = flag(rest, '--branch') ?? (git('rev-parse', '--git-dir') ? git('branch', '--show-current') : 'unknown');
|
||||
const json = rest.includes('--json');
|
||||
try {
|
||||
if (cmd === 'add') {
|
||||
const r = addRows(dir, phase, branch, flag(rest, '--branch') ?? slug('BRANCH'), git('rev-parse', 'HEAD') || 'unknown', path.join(rowsDir(dir), rowsFileName(phase)));
|
||||
process.stdout.write([`ADDED: tasks=${r.tasks} dispositions=${r.dispositions} ${r.file}`, ...r.superseded.map(f => `SUPERSEDED: ${f}`)].join('\n') + '\n');
|
||||
return EXIT.ok;
|
||||
}
|
||||
if (cmd === 'prior') {
|
||||
finishPending(dir, phase);
|
||||
fs.rmSync(path.join(rowsDir(dir), rowsFileName(phase)), { force: true });
|
||||
const files = readTaskFiles(dir, phase);
|
||||
for (const f of files) for (const n of f.malformed) process.stderr.write(`PRIOR_TASKS_MALFORMED: ${f.file}:${n}\n`);
|
||||
const prior = priorSet(files, phase, branch);
|
||||
if (json) { process.stdout.write(JSON.stringify(prior ?? { file: null, run_id: null, rows: [] }) + '\n'); return EXIT.ok; }
|
||||
if (!prior) { process.stdout.write('PRIOR_TASKS: none\n'); return EXIT.ok; }
|
||||
process.stdout.write([`PRIOR_TASKS: ${prior.file} run=${prior.run_id} tasks=${prior.rows.length}`, ...prior.rows.map(r => JSON.stringify(r)),
|
||||
`RECONCILE: decide each task above in this run's rows file (.gstack/tmp/tasks-${phase}-rows.jsonl). Still needed: list it again with "carried_from": "${prior.run_id}/<id>". Shipped: a disposition row {"prior": "${prior.run_id}/<id>", "disposition": "done", "reason": "<commit>"}. Not wanted: the same with "dropped" and why. gstack-tasks add then retires this list.`].join('\n') + '\n');
|
||||
return EXIT.ok;
|
||||
}
|
||||
const keep = flag(rest, '--keep');
|
||||
if (!keep) return usage('supersede needs --keep <the list this run wrote>');
|
||||
const r = supersede(dir, phase, branch, keep);
|
||||
if (json) process.stdout.write(JSON.stringify(r) + '\n');
|
||||
else process.stdout.write([...r.moved.map(f => `SUPERSEDED: ${f}`), ...r.skipped.map(s => `KEPT: ${s.file} (${s.reason})`)].join('\n') + (r.moved.length || r.skipped.length ? '\n' : ''));
|
||||
return EXIT.ok;
|
||||
} catch (e) {
|
||||
if (!(e instanceof SupersedeRefused)) throw e;
|
||||
process.stderr.write(resultLine(`gstack-tasks ${cmd}`, 'TASKS_SUPERSEDE_REFUSED', e.message) + '\n');
|
||||
return EXIT.refused;
|
||||
}
|
||||
}
|
||||
|
||||
process.exitCode = main(process.argv.slice(2));
|
||||
@@ -82,7 +82,7 @@ else
|
||||
|
||||
```bash
|
||||
_GS=""
|
||||
for _D in "${GSTACK_ROOT:-}" "$HOME/.claude/skills/gstack" "$HOME/.codex/skills/gstack" "$HOME/.factory/skills/gstack" "$HOME/.kiro/skills/gstack" "$HOME/.config/opencode/skills/gstack" "$HOME/.slate/skills/gstack" "$HOME/.cursor/skills/gstack" "$HOME/.openclaw/skills/gstack" "$HOME/.hermes/skills/gstack" "$HOME/.gbrain/skills/gstack" "$HOME/.copilot/skills/gstack" "$HOME/.gemini/antigravity-cli/skills/gstack" "$HOME/.gstack/repos/gstack"; do
|
||||
for _D in "${GSTACK_ROOT:-}" "$HOME/.claude/skills/gstack" "$HOME/.codex/skills/gstack" "$HOME/.factory/skills/gstack" "$HOME/.kiro/skills/gstack" "$HOME/.config/opencode/skills/gstack" "$HOME/.slate/skills/gstack" "$HOME/.cursor/skills/gstack" "$HOME/.openclaw/skills/gstack" "$HOME/.hermes/skills/gstack" "$HOME/.gbrain/skills/gstack" "$HOME/.copilot/skills/gstack" "$HOME/.gemini/antigravity-cli/skills/gstack" "$HOME/.gemini/skills/gstack" "$HOME/.gstack/repos/gstack"; do
|
||||
[ -z "$_GS" ] && [ -n "$_D" ] && [ -d "$_D/bin" ] && _GS="$_D"
|
||||
done
|
||||
[ -n "$_GS" ] && echo "GSTACK_OK: $_GS" || echo "GSTACK_MISSING"
|
||||
@@ -153,7 +153,7 @@ const home = process.env.HOME || process.env.USERPROFILE || os.homedir();
|
||||
const roots = [process.env.GSTACK_ROOT || ''].concat([
|
||||
'.claude/skills/gstack', '.codex/skills/gstack', '.factory/skills/gstack', '.kiro/skills/gstack',
|
||||
'.config/opencode/skills/gstack', '.slate/skills/gstack', '.cursor/skills/gstack', '.openclaw/skills/gstack',
|
||||
'.hermes/skills/gstack', '.gbrain/skills/gstack', '.copilot/skills/gstack', '.gemini/antigravity-cli/skills/gstack', '.gstack/repos/gstack',
|
||||
'.hermes/skills/gstack', '.gbrain/skills/gstack', '.copilot/skills/gstack', '.gemini/antigravity-cli/skills/gstack', '.gemini/skills/gstack', '.gstack/repos/gstack',
|
||||
].map((rel) => path.join(home, rel)));
|
||||
const gstackRoot = roots.find((root) => {
|
||||
try { return root !== '' && fs.statSync(path.join(root, 'bin')).isDirectory(); } catch { return false; }
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
# ~/.config/opencode/skills/gstack* — OpenCode skill install + per-skill symlinks
|
||||
# ~/.copilot/skills/gstack* — GitHub Copilot CLI skill install + per-skill symlinks
|
||||
# ~/.gemini/antigravity-cli/skills/gstack* — Antigravity CLI skill install + per-skill symlinks
|
||||
# ~/.gemini/skills/gstack* — Gemini CLI skill install + per-skill symlinks (gstack-managed only)
|
||||
# harness registrations — every file gstack-harness wrote into OpenClaw,
|
||||
# Hermes, a Dot or another harness (from its
|
||||
# ledger; edited files move to a backup), and
|
||||
@@ -141,6 +142,7 @@ if [ "$FORCE" -eq 0 ]; then
|
||||
[ -d "$HOME/.config/opencode/skills" ] && echo " ~/.config/opencode/skills/gstack*"
|
||||
[ -d "$HOME/.copilot/skills" ] && echo " ~/.copilot/skills/gstack*"
|
||||
[ -d "$HOME/.gemini/antigravity-cli/skills" ] && echo " ~/.gemini/antigravity-cli/skills/gstack*"
|
||||
[ -d "$HOME/.gemini/skills" ] && echo " ~/.gemini/skills/gstack*"
|
||||
[ "$KEEP_STATE" -eq 0 ] && [ -d "$STATE_DIR" ] && echo " $STATE_DIR"
|
||||
[ -n "$_LEAVE_ROOT" ] && echo " (kept) $_LEAVE_ROOT: state root selected by $RESOLVED_VAR; left in place"
|
||||
|
||||
@@ -546,6 +548,22 @@ if [ -d "$ANTIGRAVITY_SKILLS" ]; then
|
||||
done
|
||||
fi
|
||||
|
||||
# ─── Remove Gemini CLI skills ───────────────────────────────
|
||||
# Same provenance gate. ~/.gemini/skills also holds the user's own skills, so
|
||||
# only gstack-managed gstack* entries go.
|
||||
GEMINI_SKILLS="$HOME/.gemini/skills"
|
||||
if [ -d "$GEMINI_SKILLS" ]; then
|
||||
for _ITEM in "$GEMINI_SKILLS"/gstack*; do
|
||||
[ -e "$_ITEM" ] || [ -L "$_ITEM" ] || continue
|
||||
if _cursor_item_is_gstack_managed "$_ITEM"; then
|
||||
rm -rf "$_ITEM"
|
||||
REMOVED+=("gemini/$(basename "$_ITEM")")
|
||||
else
|
||||
_SKIPPED_DIRS+=("$_ITEM")
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# ─── Reconcile the install registry ─────────────────────────
|
||||
# A registry that survives (--keep-state, or a state root selected by an env
|
||||
# var and left in place) must forget the installs removed above, or the next
|
||||
|
||||
+6
-2
@@ -21,7 +21,9 @@
|
||||
# is not a gstack checkout, HEAD is detached or not on the remote default
|
||||
# branch (a pin), any tracked byte is dirty, a local commit diverged, the
|
||||
# incoming release's Bun floor holds, or another updater holds the setup
|
||||
# lock; never stashes, never resets. An unreachable remote is
|
||||
# lock; never stashes, never resets. Before any fetch it also refuses when
|
||||
# bin/gstack-ff-update --preflight does: an enclosing project tracks the
|
||||
# install's files, or VERSION was put back behind HEAD (#1022). An unreachable remote is
|
||||
# UPGRADE_FETCH_FAILED (exit 3). While another live session holds a workflow
|
||||
# lease on this installation (bin/gstack-lease live) it refuses with
|
||||
# UPGRADE_BUSY (exit 3); GSTACK_UPGRADE_FORCE=1 overrides. Prints
|
||||
@@ -50,7 +52,7 @@ case "${1:-}" in
|
||||
--check) CHECK=1 ;;
|
||||
--ff-only-no-reinstall) REINSTALL=0 ;;
|
||||
'') ;;
|
||||
-h|--help) sed -n '2,38p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
||||
-h|--help) sed -n '2,41p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
||||
*) echo "gstack-upgrade: unknown argument '$1'" >&2; exit 2 ;;
|
||||
esac
|
||||
|
||||
@@ -71,6 +73,8 @@ bounded() { if command -v timeout >/dev/null 2>&1; then timeout "${GSTACK_UPGRAD
|
||||
TOPLEVEL=$(git -C "$ROOT" rev-parse --show-toplevel 2>/dev/null)
|
||||
[ -f "$ROOT/VERSION" ] && [ -f "$ROOT/setup" ] && [ -n "$TOPLEVEL" ] && gstack_path_same "$TOPLEVEL" "$ROOT" \
|
||||
|| refuse "$ROOT is not a gstack checkout" "run gstack-upgrade from a git checkout of https://github.com/garrytan/gstack"
|
||||
PF=$(bash "$ROOT/bin/gstack-ff-update" --preflight "$ROOT" 2>/dev/null)
|
||||
[ $? -eq 6 ] && { echo "gstack-upgrade: $PF (UPGRADE_REFUSED)" >&2; exit 3; }
|
||||
export GIT_TERMINAL_PROMPT=0
|
||||
HOST=$(git -C "$ROOT" remote get-url origin 2>/dev/null); HOST="${HOST#*://}"; HOST="${HOST#*@}"; HOST="${HOST%%[/:]*}"
|
||||
R="user ran gstack-upgrade"
|
||||
|
||||
Reference in new issue
Block a user