Merge origin/main (5225e35, v1.92.42.0) into gsta215-ceo-only-driver

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-11-01 21:52:30 +00:00
commit f4547e07cc
249 files changed
+15292 -6073

No files matched your search

+6 -3
View File
@@ -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)
+59 -2
View File
@@ -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.
@@ -1379,7 +1433,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 +1445,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
}
+15 -2
View File
@@ -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
;;
+21 -4
View File
@@ -4,7 +4,7 @@
*
* 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
@@ -16,6 +16,9 @@
* 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
@@ -32,6 +35,7 @@
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 });
@@ -90,10 +94,20 @@ 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>]...
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[] {
if (!record || typeof record !== 'object' || Array.isArray(record)) return ['not a JSON object'];
@@ -113,7 +127,7 @@ function main(argv: string[]): void {
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());
@@ -121,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']));
@@ -128,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(' ')}`);
+2
View File
@@ -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' ;;
@@ -360,6 +361,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' ;;
+24 -8
View File
@@ -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);
+57 -4
View File
@@ -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
+1 -1
View File
@@ -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"; }
+2 -1
View File
@@ -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"
+12 -30
View File
@@ -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;
+33
View File
@@ -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"
+30 -12
View File
@@ -59,12 +59,14 @@ 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.
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 +75,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>]
@@ -157,16 +159,30 @@ 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[] }; fix_range?: { base: string } | null };
const fixChecks = 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)`)];
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 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);
@@ -176,8 +192,9 @@ function pass(): number {
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.filter(d => d.role !== 'fix-check'), next: step, planLines, learnings: readFile(flag('--learnings-file'), 'learnings') ?? null, extras,
targetedClasses: p.targeted?.classes, mergeBase, rangeBase });
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,
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);
@@ -224,10 +241,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'));
+1 -1
View File
@@ -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');
+10
View File
@@ -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);
+69 -22
View File
@@ -32,6 +32,7 @@
*/
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';
@@ -119,7 +120,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 } {
@@ -399,12 +400,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. */
@@ -460,8 +486,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; };
@@ -470,11 +503,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)] };
}
@@ -571,6 +607,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' };
@@ -579,7 +616,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);
@@ -607,13 +647,14 @@ function cmdCarry(rest: string[], json: boolean): number {
else if (productFrom !== productHead && !retarget) {
// 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 path whose only difference is base context next to unchanged hunks
// 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 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]]);
for (const p of [...touched].sort()) {
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;
@@ -634,7 +675,10 @@ function cmdCarry(rest: string[], json: boolean): number {
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) {
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 {
@@ -645,9 +689,11 @@ function cmdCarry(rest: string[], json: boolean): number {
}
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)}`);
}
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 (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(', ')})`;
}
}
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.
@@ -688,6 +734,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`);
+2 -2
View File
@@ -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; }
+18
View File
@@ -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