mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-01 14:34:42 +01:00
v1.91.16.0 fix: safe installs and upgrades, skills start everywhere, truthful completion (parallel wave) (#3014)
* fix(make-pdf,design): real TOC page numbers and anchors; design models move to gpt-5.5 + gpt-image-2 (#2903, #2807) * ci: pin every action to a SHA, Scorecard, checksummed Bun, scoped Codex CLI in the CI image, macOS named-regressions job (#1948, #1706, #1997) * fix(cso,browse): Windows/macOS lease identity, retention skips bad runs, truthful 'not assessed' reports, env.kv false positive, Windows browse spawn fixes (#2894, #2912, #2048, #2637, #2805) * fix(outside-voice): one Codex model precedence for probe and dispatch, bounded timeouts with KILL escalation, probe cache identity, guarded mktemp, skills hidden in nested Codex (#2776, #2914, #2787, #2881, #2847) * feat(setup): install registry, ./setup --status, staged activation, per-scope migrations, host tiers and conformance kit, OpenCode/Hermes fixes, Copilot CLI (experimental), disabled_skills, slimmer CLAUDE.md (#1925, #2879, #2347, #2338, #2626, #2629, #2825, #2015, #393, #1206, #2096) Co-authored-by: Andrey Esipov <61357035+andrey-esipov@users.noreply.github.com> Co-authored-by: Matthew Williams <5863886+ridermw@users.noreply.github.com> Co-authored-by: Rocky Wu <92764750+lolisaigao1234@users.noreply.github.com> * fix(preamble): skills start in worktree-isolated Claude Code sessions — one literal skill-start line, context-recovery helper, no eval in skill blocks, $N token tripwire, honest plan-mode precedence (#2763, #2896, #2851) * fix(setup): per-install renders for renamed, vendored and custom-config installs, space-safe alias roots, per-host /cso launcher path (#1882, #2906) * test: free tests never write the real HOME (home-write tripwire), no orphaned browse daemons, symlinked-TMPDIR (macOS) fixes, gbrain-on-PATH isolation (#2895, #2816, #2829, #2841, #2982, #2911, #2909, #2908, #2910) * chore: integrate wave follow-ups — regenerate all hosts, README model docs, TODOS, touchfiles, duration seeds, Bun 1.3 telemetry test fix, replay fixtures pinned to their recorded bytes * v1.91.14.0: release metadata * test(browse): group-kill cleanup falls back to the leader when macOS returns EPERM for a live group * ci: Codex login steps run under bash (the CI container's default sh rejects pipefail) * fix: full-suite repairs — private state roots for 7 real-HOME writers, truthful home-guard attribution with --attribute-home, gstack-paths shim in ship-hook fixtures, Copilot in team-init probes, test-of-test detector, cookie review fixture pinned to the approved excerpt * fix(windows): LF for Dockerfiles, normalized home-guard exclusions, path.relative fixture containment, ship push test spawn timeouts * test(design-detect): widen the whole-scan budget test's timing margins so a loaded macOS runner can't time out the single-batch call * fix(evals): qa protocol names defined for the judge, async PTY judge so overlapping sessions keep their cleanup deadline, bws setup cases use the standard JUDGE_MS tier * test(pty): the non-blocking judge case is POSIX-only (its fake judge is a shebang script, like the PTY harness it covers) * test(evals): ship-coverage-audit names absolute skill paths, uses Read, and skips the Step 7 artifact nothing consumes (removes the mechanism behind 3 of 4 historical timeouts) * test: deflake terminal-agent concurrent startup (write the placeholder record before spawning, like the product) and qa-evidence resumed pipe (barrier on a full high-water mark instead of a sleep) * fix(design): daemon log stream handles open errors instead of crashing the daemon; shutdown test awaits the stubbed exit * test(evals): skillify checks the product's resolved state root (no GSTACK_HOME bypass), negative cases check every root; tpa shims live outside the agent's cwd and answer consistently * test(evals): coverage-audit detector splits parallel columns at │ rails and [+] groups; coverage prompts name absolute skill paths; TODOs from the gate eval census * fix(plan-eng-review): scope gate always sends the menu (no headless 'Scope pending' exit); PTY harness ends idle turn-ends and API-error panels promptly with a precise diagnostic * test(evals): one position-bound coverage-read rule credits read-only && chains (labels, context ranges, git displays in either order) and still rejects forged output * fix(ship): documentation section has one saved source inserted by cat (never retyped); spawned-marker evidence is the child output; refreshed codex/factory ship goldens * test(docsync): the byte-exact insert positive is POSIX-only (the bash-command observer refuses backslash paths by design) * test(evals): plan-design-with-ui focus-title rule accepts the comma-less 'dimensions or focus' wording (replayed on the captured CI run, negative controls kept) * fix(qa-callers): exploratory preflight says the scope/method Reads come after exploratory.md; design-review scan check credits an executed scan proven by one engine run over the changed files --------- Co-authored-by: Andrey Esipov <61357035+andrey-esipov@users.noreply.github.com> Co-authored-by: Matthew Williams <5863886+ridermw@users.noreply.github.com> Co-authored-by: Rocky Wu <92764750+lolisaigao1234@users.noreply.github.com>
This commit is contained in:
392 files changed
+10217
-3784
No files matched your search
@@ -21,8 +21,6 @@ const GSTACK_DEV_DIR = path.join(os.homedir(), '.gstack-dev');
|
||||
// getProjectEvalDir() (or GSTACK_EVAL_DIR), so watching the legacy global
|
||||
// path missed it whenever slug detection succeeded — i.e. the normal case.
|
||||
const HEARTBEAT_PATH = path.join(GSTACK_DEV_DIR, 'e2e-live.json');
|
||||
const EVAL_DIR = process.env.GSTACK_EVAL_DIR || getProjectEvalDir();
|
||||
const PARTIAL_PATH = path.join(EVAL_DIR, '_partial-e2e*.json');
|
||||
const STALE_THRESHOLD_SEC = 600; // 10 minutes
|
||||
|
||||
export interface HeartbeatData {
|
||||
@@ -54,7 +52,7 @@ function readJSON<T>(filePath: string): T | null {
|
||||
}
|
||||
|
||||
/** Read the legacy collector and suite collectors in this shard, never finals. */
|
||||
export function readPartialResults(evalDir = EVAL_DIR): PartialData | null {
|
||||
export function readPartialResults(evalDir = process.env.GSTACK_EVAL_DIR || getProjectEvalDir()): PartialData | null {
|
||||
let names: string[];
|
||||
try { names = fs.readdirSync(evalDir).sort(); } catch { return null; }
|
||||
const partials = names.filter(name => /^_partial-e2e(?:-[a-z0-9]+(?:-[a-z0-9]+)*)?\.json$/.test(name))
|
||||
@@ -98,7 +96,7 @@ export function renderDashboard(heartbeat: HeartbeatData | null, partial: Partia
|
||||
lines.push('E2E Watch — No active run detected');
|
||||
lines.push('');
|
||||
lines.push(`Heartbeat: ${HEARTBEAT_PATH} (not found)`);
|
||||
lines.push(`Partial: ${PARTIAL_PATH} (not found)`);
|
||||
lines.push(`Partial: ${path.join(process.env.GSTACK_EVAL_DIR || getProjectEvalDir(), '_partial-e2e*.json')} (not found)`);
|
||||
lines.push('');
|
||||
lines.push('Start a run with: EVALS=1 bun test test/skill-e2e-*.test.ts');
|
||||
return lines.join('\n');
|
||||
|
||||
@@ -175,6 +175,7 @@
|
||||
"design/test/feedback-roundtrip.test.ts": 5918,
|
||||
"design/test/gallery.test.ts": 20,
|
||||
"design/test/image-gen-pairing.test.ts": 15,
|
||||
"design/test/models.test.ts": 159,
|
||||
"design/test/receipted-fetch.test.ts": 25,
|
||||
"design/test/serve.test.ts": 183,
|
||||
"design/test/variants-retry-after.test.ts": 8820,
|
||||
@@ -199,11 +200,14 @@
|
||||
"make-pdf/test/e2e/emoji-gate.test.ts": 6044,
|
||||
"make-pdf/test/e2e/format-gate.test.ts": 11440,
|
||||
"make-pdf/test/e2e/landscape-gate.test.ts": 11918,
|
||||
"make-pdf/test/e2e/toc-gate.test.ts": 11100,
|
||||
"make-pdf/test/image-policy.test.ts": 14,
|
||||
"make-pdf/test/pdftotext.test.ts": 42,
|
||||
"make-pdf/test/render-offline-sanitize.test.ts": 30,
|
||||
"make-pdf/test/render.test.ts": 48,
|
||||
"make-pdf/test/setup-smoke.test.ts": 152,
|
||||
"make-pdf/test/toc-anchors.test.ts": 131,
|
||||
"make-pdf/test/toc-pages.test.ts": 154,
|
||||
"test/agent-sdk-runner.test.ts": 8295,
|
||||
"test/agents-digest.test.ts": 44,
|
||||
"test/analytics.test.ts": 26,
|
||||
@@ -304,16 +308,19 @@
|
||||
"test/claude-code-runner.test.ts": 3032,
|
||||
"test/claude-code-skill.test.ts": 1206,
|
||||
"test/claude-code-windows-job.test.ts": 40,
|
||||
"test/claude-md-size.test.ts": 68,
|
||||
"test/claude-provider-keychain.test.ts": 87,
|
||||
"test/code-intelligence-cli.test.ts": 653,
|
||||
"test/code-intelligence.test.ts": 4182,
|
||||
"test/codex-carve-fixture.test.ts": 725,
|
||||
"test/codex-ci-access.test.ts": 243,
|
||||
"test/codex-eval-recording.test.ts": 301,
|
||||
"test/codex-generation-model.test.ts": 60,
|
||||
"test/codex-hardening.test.ts": 1675,
|
||||
"test/codex-model-probe.test.ts": 263,
|
||||
"test/codex-resume-flag-semantics.test.ts": 14,
|
||||
"test/codex-session-lifecycle.test.ts": 12041,
|
||||
"test/codex-skill-isolation.test.ts": 89,
|
||||
"test/codex-under-codex-detection.test.ts": 35,
|
||||
"test/codex-web-search-flag.test.ts": 118,
|
||||
"test/conductor-env-shim.test.ts": 9,
|
||||
@@ -443,6 +450,7 @@
|
||||
"test/extension-pty-inject-invariant.test.ts": 17,
|
||||
"test/flake-ledger.test.ts": 29,
|
||||
"test/founder-resources-optout.test.ts": 77,
|
||||
"test/free-home-guard.test.ts": 193,
|
||||
"test/free-tests-workflow-wiring.test.ts": 24,
|
||||
"test/freeze-owned-lifecycle.test.ts": 2663,
|
||||
"test/frontend-scope.test.ts": 987,
|
||||
@@ -491,6 +499,7 @@
|
||||
"test/gstack-config-key-locale.test.ts": 79,
|
||||
"test/gstack-config-memorable-key.test.ts": 178,
|
||||
"test/gstack-config-redact-keys.test.ts": 112,
|
||||
"test/gstack-context-recovery.test.ts": 165,
|
||||
"test/gstack-decision-bins.test.ts": 4013,
|
||||
"test/gstack-decision-semantic.test.ts": 36,
|
||||
"test/gstack-decision.test.ts": 30,
|
||||
@@ -522,6 +531,7 @@
|
||||
"test/gstack-settings-hook-symlink.test.ts": 1373,
|
||||
"test/gstack-skill-start.test.ts": 3622,
|
||||
"test/gstack-slug-cwd-walk-up.test.ts": 532,
|
||||
"test/gstack-slug-get.test.ts": 433,
|
||||
"test/gstack-slug-parity.test.ts": 842,
|
||||
"test/gstack-slug-sanitize.test.ts": 91,
|
||||
"test/gstack-state-root-override.test.ts": 351,
|
||||
@@ -554,6 +564,7 @@
|
||||
"test/hook-scripts.test.ts": 19738,
|
||||
"test/hooks-windows-paths.test.ts": 113,
|
||||
"test/host-config.test.ts": 566,
|
||||
"test/host-conformance.test.ts": 51490,
|
||||
"test/hostile-path-writers.test.ts": 1901,
|
||||
"test/impeccable-fixtures.test.ts": 19,
|
||||
"test/investigate-freeze-path.test.ts": 17,
|
||||
@@ -804,6 +815,7 @@
|
||||
"test/setup-gbrain-remote-caller.test.ts": 8376,
|
||||
"test/setup-help.test.ts": 47,
|
||||
"test/setup-hook-canonical-paths.test.ts": 20,
|
||||
"test/setup-install-registry.test.ts": 40972,
|
||||
"test/setup-kiro-native.test.ts": 196,
|
||||
"test/setup-link-ownership.test.ts": 579,
|
||||
"test/setup-needs-build.test.ts": 187,
|
||||
@@ -813,6 +825,7 @@
|
||||
"test/setup-prune-stale-generated.test.ts": 253,
|
||||
"test/setup-runtime-lib-command.test.ts": 11103,
|
||||
"test/setup-sections-linking.test.ts": 19,
|
||||
"test/setup-staged-copy.test.ts": 101,
|
||||
"test/setup-timeline-hook-gate.test.ts": 160,
|
||||
"test/setup-windows-fallback.test.ts": 37,
|
||||
"test/setup-windows-rerun-refresh.test.ts": 109,
|
||||
@@ -833,6 +846,7 @@
|
||||
"test/ship-document-release-dispatch.test.ts": 3620,
|
||||
"test/ship-hook-actor.test.ts": 1811,
|
||||
"test/ship-hook-refresh.test.ts": 1462,
|
||||
"test/ship-hook-windows-paths.test.ts": 449,
|
||||
"test/ship-plan-completion-invariants.test.ts": 136,
|
||||
"test/ship-pr-liveness-policy.test.ts": 16,
|
||||
"test/ship-publication-gates.test.ts": 166,
|
||||
@@ -907,11 +921,13 @@
|
||||
"test/v0-dormancy.test.ts": 34,
|
||||
"test/verify-gate.test.ts": 1906,
|
||||
"test/version-source.test.ts": 11,
|
||||
"test/workflow-action-pins.test.ts": 75,
|
||||
"test/workflow-boundaries-fixture.test.ts": 1813,
|
||||
"test/workflow-concurrency.test.ts": 17,
|
||||
"test/workflow-excerpt.test.ts": 116,
|
||||
"test/workflow-judge-cache.test.ts": 1728,
|
||||
"test/workflow-judge-input.test.ts": 52,
|
||||
"test/worktree-isolated-shapes.test.ts": 78,
|
||||
"test/worktree.test.ts": 480,
|
||||
"test/writing-style-resolver.test.ts": 22
|
||||
}
|
||||
|
||||
@@ -38,6 +38,8 @@ export interface GenerationOptions {
|
||||
dryRun?: boolean;
|
||||
outputRoot?: string;
|
||||
contentLinkRoot?: string | null;
|
||||
/** Install-context render contract: absolute install root this render serves. */
|
||||
installRoot?: string | null;
|
||||
model?: Model | null;
|
||||
catalogMode?: 'trim' | 'full';
|
||||
explainLevel?: 'default' | 'terse';
|
||||
@@ -48,6 +50,7 @@ export interface GenerationOptions {
|
||||
interface RenderOptions {
|
||||
outputRoot: string;
|
||||
contentLinkRoot: string | null;
|
||||
installRoot: string | null;
|
||||
model: Model | null;
|
||||
catalogMode: 'trim' | 'full';
|
||||
explainLevel: 'default' | 'terse';
|
||||
@@ -121,6 +124,10 @@ function parseGenerationArgs(args: string[]): GenerationOptions {
|
||||
}
|
||||
const outDir = value('--out-dir');
|
||||
const linkRoot = value('--link-root');
|
||||
const installRoot = value('--install-root');
|
||||
if (installRoot !== undefined && !INSTALL_ROOT_PATTERN.test(installRoot)) {
|
||||
throw new Error(`--install-root must be an absolute path of letters, digits and . _ - + @ / (got ${JSON.stringify(installRoot)})`);
|
||||
}
|
||||
// Swap-in callers use --link-root for the FINAL serving path (#2692).
|
||||
// Direct --out-dir callers retain their existing links into the render.
|
||||
return {
|
||||
@@ -130,9 +137,27 @@ function parseGenerationArgs(args: string[]): GenerationOptions {
|
||||
outputRoot: outDir === undefined ? ROOT : path.resolve(outDir),
|
||||
contentLinkRoot: linkRoot !== undefined ? path.resolve(linkRoot)
|
||||
: outDir !== undefined ? path.resolve(outDir) : null,
|
||||
installRoot: installRoot ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Install roots are spliced into shell lines unquoted, so only plain absolute paths. */
|
||||
const INSTALL_ROOT_PATTERN = /^\/[A-Za-z0-9_.@+\/-]*$/;
|
||||
|
||||
/**
|
||||
* Install-context render contract (docs/ADDING_A_HOST.md): a per-install render
|
||||
* names its own install root instead of the host's default global root. Null
|
||||
* keeps the committed bytes.
|
||||
*/
|
||||
function rewriteInstallRoot(content: string, hostConfig: HostConfig, installRoot: string | null): string {
|
||||
if (!installRoot) return content;
|
||||
const root = installRoot.replace(/\/+$/, '');
|
||||
const defaults = hostConfig.usesEnvVars
|
||||
? [`$HOME/${hostConfig.globalRoot}`, `~/${hostConfig.globalRoot}`]
|
||||
: ['$HOME/.claude/skills/gstack', '~/.claude/skills/gstack'];
|
||||
return defaults.reduce((text, from) => text.split(from).join(root), content);
|
||||
}
|
||||
|
||||
/** Repoint only Claude section links, retaining global bin/browse/doc paths. */
|
||||
function rewriteSectionBase(content: string, linkRoot: string | null): string {
|
||||
if (!linkRoot) return content;
|
||||
@@ -473,7 +498,8 @@ function transformFrontmatter(content: string, host: Host): string {
|
||||
|
||||
// Build frontmatter with allowed fields
|
||||
const indentedDesc = description.split('\n').map(l => ` ${l}`).join('\n');
|
||||
let newFm = `---\nname: ${name}\ndescription: |\n${indentedDesc}\n`;
|
||||
const fmName = fm.nameMatchesDirectory && name !== 'gstack' && !name.startsWith('gstack-') ? `gstack-${name}` : name;
|
||||
let newFm = `---\nname: ${fmName}\ndescription: |\n${indentedDesc}\n`;
|
||||
|
||||
// Add extra fields (host-wide)
|
||||
if (fm.extraFields) {
|
||||
@@ -528,8 +554,9 @@ function transformFrontmatter(content: string, host: Host): string {
|
||||
* Extract hook descriptions from frontmatter for inline safety prose.
|
||||
* Returns a description of what the hooks do, or null if no hooks.
|
||||
*/
|
||||
function extractHookSafetyProse(tmplContent: string): string | null {
|
||||
function extractHookSafetyProse(tmplContent: string, hostConfig: HostConfig): string | null {
|
||||
if (!tmplContent.match(/^hooks:/m)) return null;
|
||||
if (hostConfig.capabilities.safetyHooks === 'enforced') return null;
|
||||
|
||||
// Parse the hook matchers to build a human-readable safety description
|
||||
const matchers: string[] = [];
|
||||
@@ -552,7 +579,7 @@ function extractHookSafetyProse(tmplContent: string): string | null {
|
||||
.map(t => toolDescriptions[t] || `check ${t} operations for safety`)
|
||||
.join(', and ');
|
||||
|
||||
return `> **Safety Advisory:** This skill includes safety checks that ${safetyChecks}. When using this skill, always pause and verify before executing potentially destructive operations. If uncertain about a command's safety, ask the user for confirmation before proceeding.`;
|
||||
return `> **Safety Advisory — not enforced on ${hostConfig.displayName}:** advisory, not blocked. ${hostConfig.displayName} runs no gstack safety hooks, so nothing stops a command automatically. On Claude Code this skill's hooks ${safetyChecks}; here, do those checks yourself: always pause and verify before executing potentially destructive operations. If uncertain about a command's safety, ask the user for confirmation before proceeding.`;
|
||||
}
|
||||
|
||||
// ─── External Host Config (now derived from hosts/*.ts) ──────
|
||||
@@ -674,7 +701,7 @@ function buildContext(
|
||||
const interactive = interactiveMatch ? interactiveMatch[1] === 'true' : undefined;
|
||||
return {
|
||||
skillName, tmplPath, benefitsFrom, host, paths: HOST_PATHS[host],
|
||||
preambleTier, model: options.model ?? getHostConfig(host).defaultModel, interactive, explainLevel: options.explainLevel,
|
||||
preambleTier, model: options.model ?? getHostConfig(host).defaultModel, interactive, explainLevel: options.explainLevel, installRoot: options.installRoot,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -713,7 +740,7 @@ function processExternalHost(
|
||||
}
|
||||
|
||||
// Extract hook safety prose BEFORE transforming frontmatter (which strips hooks)
|
||||
const safetyProse = extractHookSafetyProse(tmplContent);
|
||||
const safetyProse = extractHookSafetyProse(tmplContent, hostConfig);
|
||||
|
||||
// Transform frontmatter (host-aware)
|
||||
let result = transformFrontmatter(content, host);
|
||||
@@ -812,6 +839,7 @@ function processTemplate(tmplPath: string, host: Host, options: RenderOptions):
|
||||
|
||||
// --out-dir: repoint section-base paths to the out-dir (no-op otherwise).
|
||||
if (host === 'claude') content = rewriteSectionBase(content, options.contentLinkRoot);
|
||||
content = rewriteInstallRoot(content, currentHostConfig, options.installRoot);
|
||||
|
||||
return { outputPath, content, symlinkLoop, metadata };
|
||||
}
|
||||
@@ -857,6 +885,7 @@ function processSectionTemplate(
|
||||
// repoint those to the out-dir too (no-op when --out-dir is unset).
|
||||
content = rewriteSectionBase(content, options.contentLinkRoot);
|
||||
}
|
||||
content = rewriteInstallRoot(content, hostConfig, options.installRoot);
|
||||
|
||||
// Plain generated header (no frontmatter to insert after).
|
||||
content = GENERATED_HEADER.replace('{{SOURCE}}', path.basename(sectionTmplPath)) + content;
|
||||
@@ -889,6 +918,7 @@ export async function runGeneration(settings: GenerationOptions = {}): Promise<G
|
||||
const options: RenderOptions = {
|
||||
outputRoot: path.resolve(settings.outputRoot ?? ROOT),
|
||||
contentLinkRoot: settings.contentLinkRoot ?? null,
|
||||
installRoot: settings.installRoot ?? null,
|
||||
model: settings.model ?? null,
|
||||
catalogMode: settings.catalogMode ?? 'trim',
|
||||
explainLevel: settings.explainLevel ?? 'default',
|
||||
|
||||
@@ -17,6 +17,24 @@
|
||||
import type { Model } from './models';
|
||||
import { validateModel } from './models';
|
||||
|
||||
export type HostTier = 'full' | 'experimental' | 'instruction-only';
|
||||
export const HOST_TIERS: readonly HostTier[] = ['full', 'experimental', 'instruction-only'];
|
||||
|
||||
export interface HostCapabilities {
|
||||
/** Runs shell commands, so bin/ helpers and preambles execute. */
|
||||
toolExecution: boolean;
|
||||
/** 'native': a structured question tool; 'prose': questions are plain text. */
|
||||
questions: 'native' | 'prose';
|
||||
/** Has plan-mode transitions (enter/exit plan mode). */
|
||||
planMode: boolean;
|
||||
/** Can delegate work to sub-agents. */
|
||||
delegation: boolean;
|
||||
/** Can drive gstack's browser (needs toolExecution). */
|
||||
browser: boolean;
|
||||
/** 'enforced': hooks can block a tool call; 'advisory': safety skills only warn. */
|
||||
safetyHooks: 'enforced' | 'advisory';
|
||||
}
|
||||
|
||||
export interface HostConfig {
|
||||
/** Unique host identifier (e.g., 'opencode'). Must match filename in hosts/. */
|
||||
name: string;
|
||||
@@ -30,6 +48,17 @@ export interface HostConfig {
|
||||
/** Model overlay used when generation does not receive an explicit --model. */
|
||||
defaultModel: Model;
|
||||
|
||||
// --- Support Contract (docs/ADDING_A_HOST.md "Host tiers and capabilities") ---
|
||||
/**
|
||||
* full: installs, renders, and has a dated certification record (a real
|
||||
* workflow run plus an upgrade from an existing install).
|
||||
* experimental: installs and renders; not certified by a real run.
|
||||
* instruction-only: no install arm; setup prints instructions and changes nothing.
|
||||
*/
|
||||
tier: HostTier;
|
||||
/** What the host runtime offers. Rendering and docs must not claim more. */
|
||||
capabilities: HostCapabilities;
|
||||
|
||||
// --- Path Configuration ---
|
||||
/** Global install path relative to $HOME (e.g., '.config/opencode/skills/gstack'). */
|
||||
globalRoot: string;
|
||||
@@ -54,6 +83,12 @@ export interface HostConfig {
|
||||
descriptionLimitBehavior?: 'error' | 'truncate' | 'warn';
|
||||
/** Additional frontmatter fields to inject (host-wide). */
|
||||
extraFields?: Record<string, unknown>;
|
||||
/**
|
||||
* Write the installed directory name (gstack-<skill>) into `name:`. For
|
||||
* hosts that index and de-duplicate skills by frontmatter name, where a
|
||||
* bare `review` would be shadowed by any other `review` skill (#2825).
|
||||
*/
|
||||
nameMatchesDirectory?: boolean;
|
||||
/** Rename fields from template (e.g., { 'voice-triggers': 'triggers' }). */
|
||||
renameFields?: Record<string, string>;
|
||||
/** Conditionally add fields based on template frontmatter values. */
|
||||
@@ -132,6 +167,20 @@ export function validateHostConfig(config: HostConfig, validResolverNames?: Read
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!HOST_TIERS.includes(config.tier)) {
|
||||
errors.push(`tier '${config.tier}' must be one of ${HOST_TIERS.join(', ')}`);
|
||||
}
|
||||
const caps = config.capabilities;
|
||||
if (!caps) {
|
||||
errors.push('capabilities are required');
|
||||
} else {
|
||||
if (caps.browser && !caps.toolExecution) errors.push('capabilities.browser requires toolExecution');
|
||||
if (!['native', 'prose'].includes(caps.questions)) errors.push(`capabilities.questions must be 'native' or 'prose'`);
|
||||
if (!['enforced', 'advisory'].includes(caps.safetyHooks)) errors.push(`capabilities.safetyHooks must be 'enforced' or 'advisory'`);
|
||||
}
|
||||
if (config.tier === 'instruction-only' && !config.install.instructionTier && config.name !== 'slate' && config.name !== 'gbrain') {
|
||||
errors.push('instruction-only hosts must declare install.instructionTier (what setup tells the user to copy)');
|
||||
}
|
||||
const modelError = validateModel(config.defaultModel);
|
||||
if (modelError) {
|
||||
errors.push(`defaultModel ${modelError}`);
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* Free-suite home-write tripwire (#2895).
|
||||
*
|
||||
* Snapshots path + mtime + size of the gstack install and config surfaces under
|
||||
* the real home before and after each free shard, and names the shard's files
|
||||
* when one of those entries changed. It watches only these surfaces; it is not
|
||||
* a universal write-containment check. Live agent-session logs that a
|
||||
* concurrently running host keeps writing are excluded, and directory mtimes
|
||||
* are ignored, so only file, link and directory-set changes count.
|
||||
*
|
||||
* A shard that runs alone owns its window, so its guard names its files.
|
||||
* Concurrent shards share one HOME and cannot tell whose write a change was, so
|
||||
* the runner guards that phase once and names no file; `--attribute-home`
|
||||
* then runs every file alone in a private HOME, where any write is that file's.
|
||||
*/
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { forEachFileAlone } from './shard-engine';
|
||||
|
||||
export const FREE_HOME_SURFACES = ['.gstack', '.claude', '.codex', '.agents', '.config/gstack'] as const;
|
||||
|
||||
export const FREE_HOME_VOLATILE = [
|
||||
'.claude/projects', '.claude/todos', '.claude/shell-snapshots', '.claude/statsig', '.claude/ide',
|
||||
'.claude/session-env', '.claude/file-history', '.claude/debug', '.claude/telemetry', '.claude/history.jsonl',
|
||||
'.claude/.credentials.json', '.codex/sessions', '.codex/archived_sessions', '.codex/log', '.codex/history.jsonl',
|
||||
'.codex/auth.json',
|
||||
] as const;
|
||||
|
||||
const MAX_DEPTH = 4;
|
||||
const MAX_REPORTED = 10;
|
||||
|
||||
export type FreeHomeSnapshot = Map<string, string>;
|
||||
|
||||
export function freeHomeDir(env: NodeJS.ProcessEnv = process.env): string {
|
||||
return env.HOME || env.USERPROFILE || os.homedir();
|
||||
}
|
||||
|
||||
export function snapshotFreeHome(home: string): FreeHomeSnapshot {
|
||||
const entries: FreeHomeSnapshot = new Map();
|
||||
const volatile = new Set<string>(FREE_HOME_VOLATILE.map(entry => path.normalize(entry)));
|
||||
const visit = (relative: string, depth: number) => {
|
||||
if (volatile.has(relative)) return;
|
||||
const absolute = path.join(home, relative);
|
||||
let stat: fs.BigIntStats;
|
||||
try {
|
||||
stat = fs.lstatSync(absolute, { bigint: true });
|
||||
} catch (error) {
|
||||
if (['ENOENT', 'ENOTDIR'].includes((error as NodeJS.ErrnoException).code ?? '')) return;
|
||||
throw error;
|
||||
}
|
||||
if (stat.isSymbolicLink()) {
|
||||
entries.set(relative, `link:${fs.readlinkSync(absolute)}:${stat.mtimeNs}`);
|
||||
return;
|
||||
}
|
||||
if (!stat.isDirectory()) {
|
||||
entries.set(relative, `file:${stat.size}:${stat.mtimeNs}`);
|
||||
return;
|
||||
}
|
||||
entries.set(relative, 'dir');
|
||||
if (depth >= MAX_DEPTH) return;
|
||||
let names: string[];
|
||||
try {
|
||||
names = fs.readdirSync(absolute);
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code ?? '';
|
||||
if (['EACCES', 'EPERM'].includes(code)) entries.set(relative, 'dir:unreadable');
|
||||
if (['ENOENT', 'ENOTDIR', 'EACCES', 'EPERM'].includes(code)) return;
|
||||
throw error;
|
||||
}
|
||||
for (const name of names.sort()) visit(path.join(relative, name), depth + 1);
|
||||
};
|
||||
for (const surface of FREE_HOME_SURFACES) visit(path.normalize(surface), 0);
|
||||
return entries;
|
||||
}
|
||||
|
||||
export function diffFreeHome(before: FreeHomeSnapshot, after: FreeHomeSnapshot): string[] {
|
||||
const changed = new Set<string>();
|
||||
for (const [entry, value] of before) if (after.get(entry) !== value) changed.add(entry);
|
||||
for (const entry of after.keys()) if (!before.has(entry)) changed.add(entry);
|
||||
return [...changed].sort();
|
||||
}
|
||||
|
||||
/** Who owns the snapshotted window: one shard, a concurrent phase, or one file in a private HOME. */
|
||||
export type FreeHomeScope = { kind: 'shard' } | { kind: 'concurrent'; shards: number } | { kind: 'private' };
|
||||
|
||||
export function formatFreeHomeChange(changed: string[], files: string[], scope: FreeHomeScope = { kind: 'shard' }): string {
|
||||
const shown = changed.slice(0, MAX_REPORTED).map(entry => `~/${entry.split(path.sep).join('/')}`);
|
||||
const more = changed.length > MAX_REPORTED ? ` (+${changed.length - MAX_REPORTED} more)` : '';
|
||||
const watched = `(watches ${FREE_HOME_SURFACES.map(s => `~/${s}`).join(', ')} only): ${shown.join(', ')}${more}.`;
|
||||
const fix = 'Give the writer a private HOME/GSTACK_HOME.';
|
||||
if (scope.kind === 'concurrent') {
|
||||
return `real home changed while ${scope.shards} shards ran concurrently ${watched} They share one HOME, so no shard or file `
|
||||
+ `is named. Find the writer with \`bun run scripts/test-free-shards.ts --attribute-home\` (each file alone in a private HOME). ${fix}`;
|
||||
}
|
||||
if (scope.kind === 'private') return `${files.join(', ')} wrote its private HOME ${watched} With the real HOME it writes there. ${fix}`;
|
||||
return `real home changed while this shard ran ${watched} ${fix} Shard files: ${files.join(', ')}`;
|
||||
}
|
||||
|
||||
export interface FreeHomeGuard { verify(): string | null }
|
||||
/** How runFreeShard guards one shard; it may redirect `env` before the child starts. */
|
||||
export type FreeHomeGuardFactory = (files: string[], env: NodeJS.ProcessEnv, stateDir: string) => FreeHomeGuard;
|
||||
|
||||
/** Take the baseline now; verify() returns null or the failure to report. */
|
||||
export function guardFreeHome(files: string[], env: NodeJS.ProcessEnv = process.env, scope: FreeHomeScope = { kind: 'shard' }): FreeHomeGuard {
|
||||
const home = freeHomeDir(env);
|
||||
const unreadable = (error: unknown) => `real home surfaces could not be snapshotted (${(error as NodeJS.ErrnoException).code ?? 'error'}); shard files: ${files.join(', ')}`;
|
||||
let before: FreeHomeSnapshot | undefined;
|
||||
let baselineError: string | undefined;
|
||||
try { before = snapshotFreeHome(home); } catch (error) { baselineError = unreadable(error); }
|
||||
return {
|
||||
verify() {
|
||||
if (!before) return baselineError!;
|
||||
try {
|
||||
const changed = diffFreeHome(before, snapshotFreeHome(home));
|
||||
return changed.length ? formatFreeHomeChange(changed, files, scope) : null;
|
||||
} catch (error) { return unreadable(error); }
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** For a shard inside a concurrently guarded phase: the phase guard reports, this one never does. */
|
||||
export const sharedFreeHome = (): FreeHomeGuard => ({ verify: () => null });
|
||||
|
||||
/**
|
||||
* Redirect one shard's HOME to `<stateDir>/home` and guard that instead. The
|
||||
* browser cache and git identity still come from the real home, so tests run
|
||||
* as they would there; only home writes are diverted.
|
||||
*/
|
||||
export function privateFreeHome(files: string[], env: NodeJS.ProcessEnv, stateDir: string): FreeHomeGuard {
|
||||
const real = freeHomeDir(env);
|
||||
const home = path.join(stateDir, 'home');
|
||||
fs.mkdirSync(home, { recursive: true, mode: 0o700 });
|
||||
env.PLAYWRIGHT_BROWSERS_PATH ??= path.join(real, process.platform === 'darwin' ? 'Library/Caches' : '.cache', 'ms-playwright');
|
||||
if (!env.GIT_CONFIG_GLOBAL && fs.existsSync(path.join(real, '.gitconfig'))) env.GIT_CONFIG_GLOBAL = path.join(real, '.gitconfig');
|
||||
env.HOME = home;
|
||||
if (env.USERPROFILE !== undefined) env.USERPROFILE = home;
|
||||
return guardFreeHome(files, env, { kind: 'private' });
|
||||
}
|
||||
|
||||
/**
|
||||
* `--attribute-home`: run each reader file alone with a private HOME, `jobs`
|
||||
* at a time, then each exclusive host-state file alone, and name every file
|
||||
* that wrote a watched surface. Exit 1 when any did.
|
||||
*/
|
||||
export async function attributeFreeHomeWriters(
|
||||
readers: string[], exclusive: string[], jobs: number,
|
||||
runAlone: (file: string, index: number, homeGuard: FreeHomeGuardFactory) => Promise<unknown>,
|
||||
): Promise<number> {
|
||||
const writers: string[] = [];
|
||||
console.log(`[test:free] attributing home writes: ${readers.length + exclusive.length} files, each alone in a private HOME, ${jobs} at a time`);
|
||||
await forEachFileAlone(readers, exclusive, jobs, async (file, index) => {
|
||||
let change = null as string | null;
|
||||
await runAlone(file, index, (files, env, stateDir) => {
|
||||
const guard = privateFreeHome(files, env, stateDir);
|
||||
return { verify: () => (change = guard.verify()) };
|
||||
});
|
||||
if (change) writers.push(change);
|
||||
});
|
||||
for (const writer of writers.sort()) console.error(`[test:free] ✗ ${writer}`);
|
||||
console.log(`[test:free] ${writers.length ? `${writers.length} file(s) wrote home surfaces` : 'no file wrote a watched home surface'}`);
|
||||
return writers.length ? 1 : 0;
|
||||
}
|
||||
@@ -230,3 +230,62 @@ export function partitionCaseExclusions(keys: string[]): { runnable: string[]; e
|
||||
});
|
||||
return { runnable, excluded };
|
||||
}
|
||||
|
||||
/**
|
||||
* Codex access in the CI eval image, scoped per paid shard.
|
||||
*
|
||||
* The image installs the pinned Codex CLI off PATH (GSTACK_CI_CODEX_BIN_DIR)
|
||||
* and the eval workflows log it in under a non-default home
|
||||
* (GSTACK_CI_CODEX_HOME). A gate shard never sees either. A non-gate shard
|
||||
* whose every file exercises Codex gets the CLI on PATH and the home as
|
||||
* CODEX_HOME. A file that mixes one Codex case with other cases keeps only
|
||||
* the two variables, and that case opts in on its own session; hermetic
|
||||
* child environments drop GSTACK_* names, so its sibling sessions see no
|
||||
* Codex. Every other shard sees neither, so a skill's outside-voice probe
|
||||
* (`command -v codex`) reports not_installed exactly as it did before Codex
|
||||
* entered the image. Outside CI neither variable is set and nothing changes.
|
||||
*/
|
||||
export const CODEX_CI_ENV = { binDir: 'GSTACK_CI_CODEX_BIN_DIR', home: 'GSTACK_CI_CODEX_HOME' } as const;
|
||||
|
||||
/** Paid files whose every case exercises the real Codex CLI. */
|
||||
export const CODEX_CI_FILES: readonly string[] = [
|
||||
'test/codex-e2e.test.ts',
|
||||
'test/codex-e2e-sol-scope.test.ts',
|
||||
'test/codex-e2e-shared-libs.test.ts',
|
||||
'test/codex-e2e-recommendation-substance.test.ts',
|
||||
'test/skill-e2e-outside-voice.test.ts',
|
||||
'test/skill-e2e-outside-plan-disabled.test.ts',
|
||||
];
|
||||
|
||||
/**
|
||||
* Codex cases inside files whose other cases must not see Codex. Each listed
|
||||
* case opts in by putting $GSTACK_CI_CODEX_BIN_DIR on its own session PATH
|
||||
* (inline, so the file's other touchfile keys do not grow a helper).
|
||||
*/
|
||||
export const CODEX_CI_CASES: Readonly<Record<string, readonly string[]>> = {
|
||||
'test/skill-e2e-workflow.test.ts': ['codex-review'],
|
||||
};
|
||||
|
||||
export type CodexShardAccess = 'none' | 'case-opt-in' | 'path';
|
||||
|
||||
/** What one shard, given the test files behind its keys, may see of the CI Codex install in `tier`. */
|
||||
export function codexShardAccess(files: readonly string[], tier: string | undefined): CodexShardAccess {
|
||||
if (tier === 'gate' || files.length === 0) return 'none';
|
||||
if (files.every(file => CODEX_CI_FILES.includes(file))) return 'path';
|
||||
return files.every(file => file in CODEX_CI_CASES) ? 'case-opt-in' : 'none';
|
||||
}
|
||||
|
||||
/** Scope one shard's child environment in place; `files` are the shard keys' test files. */
|
||||
export function scopeCodexAccess(env: NodeJS.ProcessEnv, files: readonly string[]): void {
|
||||
const binDir = env[CODEX_CI_ENV.binDir];
|
||||
if (!binDir) return;
|
||||
const home = env[CODEX_CI_ENV.home];
|
||||
const access = codexShardAccess(files, env.EVALS_TIER);
|
||||
delete env.CODEX_HOME;
|
||||
if (access === 'case-opt-in') return;
|
||||
delete env[CODEX_CI_ENV.binDir];
|
||||
delete env[CODEX_CI_ENV.home];
|
||||
if (access !== 'path') return;
|
||||
env.PATH = env.PATH ? `${binDir}${path.delimiter}${env.PATH}` : binDir;
|
||||
if (home) env.CODEX_HOME = home;
|
||||
}
|
||||
@@ -595,6 +595,22 @@ export async function removeShardSandbox(stateDir: string): Promise<void> {
|
||||
catch { /* a locked file must not turn a real verdict into an exception */ }
|
||||
}
|
||||
|
||||
/** Run once per file, alone: readers `jobs` at a time, then exclusive files one by one. Stops on termination. */
|
||||
export async function forEachFileAlone(
|
||||
readers: string[], exclusive: string[], jobs: number, run: (file: string, index: number) => Promise<void>,
|
||||
): Promise<void> {
|
||||
let cursor = 0;
|
||||
const worker = async (files: string[], offset: number) => {
|
||||
while (cursor < files.length && !isTerminationRequested()) {
|
||||
const index = cursor++;
|
||||
await run(files[index], offset + index);
|
||||
}
|
||||
};
|
||||
await Promise.all(Array.from({ length: Math.max(1, jobs) }, () => worker(readers, 0)));
|
||||
cursor = 0;
|
||||
await worker(exclusive, readers.length);
|
||||
}
|
||||
|
||||
let shardLogSequence = 0;
|
||||
|
||||
/** Timestamped log path; pid + sequence defeat same-millisecond collisions. */
|
||||
|
||||
@@ -103,6 +103,84 @@ export function resolveCodexGenerationModel(opts: {
|
||||
return { model, source: configPath, warnings };
|
||||
}
|
||||
|
||||
export type CodexInvocationKind = 'exec' | 'review';
|
||||
|
||||
export interface CodexRuntimeModelSelection {
|
||||
kind: CodexInvocationKind;
|
||||
/** Raw Codex model id, passed unchanged to the probe and to dispatch. */
|
||||
model: string;
|
||||
source: string;
|
||||
}
|
||||
|
||||
/** Characters a model id may carry before it is interpolated into `-c "model=\"…\""`. */
|
||||
export const CODEX_RUNTIME_MODEL_PATTERN = /^[A-Za-z0-9._:/-]{1,100}$/;
|
||||
|
||||
const REPAIR = 'Choose a model your Codex account can use: name it for this request, set GSTACK_CODEX_MODEL=<model>, or set model in the Codex config.toml.';
|
||||
|
||||
/**
|
||||
* Runtime model for gstack-owned Codex calls (#2914). Unlike the generation
|
||||
* resolver above, this returns the user's raw model id (no prompt-overlay
|
||||
* mapping), so ids without a dedicated overlay still work. Precedence: explicit
|
||||
* request, GSTACK_CODEX_MODEL, Codex config.toml (`review_model` first for native
|
||||
* review, then the legacy active profile's model, then `model`), then gstack's
|
||||
* default. Any invalid user choice throws a repair message instead of falling
|
||||
* back to the default.
|
||||
*/
|
||||
export function resolveCodexRuntimeModel(opts: {
|
||||
kind: CodexInvocationKind;
|
||||
explicit?: string;
|
||||
env?: Record<string, string | undefined>;
|
||||
codexHome?: string;
|
||||
home?: string;
|
||||
}): CodexRuntimeModelSelection {
|
||||
const env = opts.env ?? process.env;
|
||||
const chosen = (source: string, raw: string): CodexRuntimeModelSelection => {
|
||||
const model = raw.trim();
|
||||
if (!CODEX_RUNTIME_MODEL_PATTERN.test(model)) {
|
||||
throw new Error(`Invalid Codex model '${sanitize(raw)}' from ${sanitize(source)}: use 1-100 characters from A-Z a-z 0-9 . _ : / -. ${REPAIR}`);
|
||||
}
|
||||
return { kind: opts.kind, model, source: stripControl(source) };
|
||||
};
|
||||
if (opts.explicit) return chosen('explicit request', opts.explicit);
|
||||
if (env.GSTACK_CODEX_MODEL) return chosen('GSTACK_CODEX_MODEL', env.GSTACK_CODEX_MODEL);
|
||||
|
||||
const home = opts.home ?? env.HOME ?? os.homedir();
|
||||
const codexHome = opts.codexHome ?? env.CODEX_HOME ?? path.join(home, '.codex');
|
||||
if (!path.isAbsolute(codexHome)) {
|
||||
throw new Error(`CODEX_HOME '${sanitize(codexHome)}' is not an absolute path, so its config.toml model cannot be read. ${REPAIR}`);
|
||||
}
|
||||
const configPath = path.join(codexHome, 'config.toml');
|
||||
let raw: string;
|
||||
try {
|
||||
raw = fs.readFileSync(configPath, 'utf8');
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return { kind: opts.kind, model: CODEX_DEFAULT_MODEL, source: `gstack default (no ${stripControl(configPath)})` };
|
||||
}
|
||||
throw new Error(`Could not read ${sanitize(configPath)}. ${REPAIR}`);
|
||||
}
|
||||
let parsed: Record<string, unknown>;
|
||||
try {
|
||||
parsed = Bun.TOML.parse(raw) as Record<string, unknown>;
|
||||
} catch {
|
||||
throw new Error(`Could not parse ${sanitize(configPath)}. ${REPAIR}`);
|
||||
}
|
||||
const profileName = typeof parsed.profile === 'string' ? parsed.profile : undefined;
|
||||
const profiles = parsed.profiles as Record<string, Record<string, unknown> | undefined> | undefined;
|
||||
const profile = profileName && typeof profiles === 'object' ? profiles?.[profileName] : undefined;
|
||||
const candidates: Array<[string, unknown]> = [
|
||||
...(opts.kind === 'review' ? [[`${configPath} review_model`, parsed.review_model] as [string, unknown]] : []),
|
||||
[`${configPath} [profiles.${profileName}].model`, profile?.model],
|
||||
[`${configPath} model`, parsed.model],
|
||||
];
|
||||
for (const [source, value] of candidates) {
|
||||
if (value === undefined) continue;
|
||||
if (typeof value !== 'string') throw new Error(`${sanitize(source)} is not a string. ${REPAIR}`);
|
||||
return chosen(source, value);
|
||||
}
|
||||
return { kind: opts.kind, model: CODEX_DEFAULT_MODEL, source: `gstack default (no model in ${stripControl(configPath)})` };
|
||||
}
|
||||
|
||||
function readArg(name: string): string | undefined {
|
||||
const exact = process.argv.indexOf(name);
|
||||
if (exact >= 0) return process.argv[exact + 1];
|
||||
@@ -113,6 +191,13 @@ function readArg(name: string): string | undefined {
|
||||
|
||||
if (import.meta.main) {
|
||||
try {
|
||||
const runtime = readArg('--runtime');
|
||||
if (runtime !== undefined) {
|
||||
if (runtime !== 'exec' && runtime !== 'review') throw new Error('Usage: --runtime exec|review [--explicit <model>]');
|
||||
const selection = resolveCodexRuntimeModel({ kind: runtime, explicit: readArg('--explicit') });
|
||||
process.stdout.write(`${selection.model}\t${selection.source}\n`);
|
||||
process.exit(0);
|
||||
}
|
||||
const result = resolveCodexGenerationModel({
|
||||
explicit: readArg('--explicit'),
|
||||
codexHome: readArg('--codex-home'),
|
||||
|
||||
@@ -57,17 +57,28 @@ export const OPENAI_LITMUS_CHECKS = [
|
||||
export const CODEX_WEB_SEARCH_FLAG = `-c 'web_search="cached"'`;
|
||||
|
||||
/**
|
||||
* Default model for gstack-owned Codex invocations.
|
||||
* Default model for gstack-owned Codex invocations when nothing else chooses.
|
||||
*
|
||||
* Conductor's current Codex CLI default may lag the frontier model exposed to
|
||||
* agents, so gstack pins its own default and lets users override it per shell
|
||||
* with GSTACK_CODEX_MODEL or per invocation with an explicit `-c model=...`.
|
||||
* The -c form is accepted by both `codex exec` and `codex review`.
|
||||
* The runtime model is resolved per invocation kind by
|
||||
* `_gstack_codex_select_model exec|review` (bin/gstack-codex-probe, backed by
|
||||
* resolveCodexRuntimeModel in scripts/resolve-codex-generation-model.ts):
|
||||
* explicit request, then GSTACK_CODEX_MODEL, then Codex config.toml (`model`;
|
||||
* `review_model` first for native review; honors CODEX_HOME), then this default
|
||||
* (#2914). The selection is printed before the first paid call and the probe
|
||||
* checks the same record the flags below pass.
|
||||
*/
|
||||
export const CODEX_FRONTIER_MODEL = 'gpt-6-astra';
|
||||
export const CODEX_MODEL_CONFIG_FLAG = `-c "model=\\"\${GSTACK_CODEX_MODEL:-${CODEX_FRONTIER_MODEL}}\\""`;
|
||||
// Native review prefers review_model over model when the user has pinned it.
|
||||
export const CODEX_REVIEW_MODEL_CONFIG_FLAG = `${CODEX_MODEL_CONFIG_FLAG} -c "review_model=\\"\${GSTACK_CODEX_MODEL:-${CODEX_FRONTIER_MODEL}}\\""`;
|
||||
/**
|
||||
* Nested gstack Codex calls are one-shot reviews: keep installed skills (gstack's
|
||||
* own included) out of the model's context so the reviewer cannot re-run a whole
|
||||
* skill workflow inside its budget (#2847). Read-only sandboxes do not prevent this.
|
||||
*/
|
||||
export const CODEX_SKILLS_ISOLATION_FLAG = '-c skills.include_instructions=false';
|
||||
const SELECTED_MODEL = '${_GSTACK_CODEX_SEL:?}';
|
||||
/** Requires `_gstack_codex_select_model exec` earlier in the same shell; `:?` stops an unselected command. */
|
||||
export const CODEX_MODEL_CONFIG_FLAG = `-c "model=\\"${SELECTED_MODEL}\\"" ${CODEX_SKILLS_ISOLATION_FLAG}`;
|
||||
/** Requires `_gstack_codex_select_model review`; native review prefers review_model, so both carry the selection. */
|
||||
export const CODEX_REVIEW_MODEL_CONFIG_FLAG = `-c "review_model=\\"${SELECTED_MODEL}\\"" ${CODEX_MODEL_CONFIG_FLAG}`;
|
||||
|
||||
/**
|
||||
* Shared Codex error handling block for resolver output.
|
||||
@@ -111,7 +122,7 @@ On any error: continue — ${feature} is informational, not a gate.`;
|
||||
* - `codex-only` (diff adversarial): disabled gates only the Codex passes; the
|
||||
* free Claude adversarial subagent still runs.
|
||||
*/
|
||||
export function codexPreflight(opts: { modeVar?: string; disabledBehavior: 'skip-all' | 'codex-only' }): string {
|
||||
export function codexPreflight(opts: { modeVar?: string; disabledBehavior: 'skip-all' | 'codex-only'; nativeReview?: boolean }): string {
|
||||
const m = opts.modeVar ?? '_CODEX_MODE';
|
||||
const disabledLine = opts.disabledBehavior === 'codex-only'
|
||||
? 'Skip the Codex passes only; the Claude adversarial subagent below STILL runs (it is free and fast). Print: "Codex passes skipped (codex_reviews disabled) — running Claude adversarial only."'
|
||||
@@ -135,7 +146,8 @@ elif ! _gstack_codex_auth_probe >/dev/null 2>&1; then
|
||||
else
|
||||
# Capture the probe's code: 2 means the CLI cannot execute at all, which is a
|
||||
# different problem (and a different fix) from a model the account can't use.
|
||||
_gstack_codex_model_probe; _CODEX_MP=$?
|
||||
_gstack_codex_model_probe; _CODEX_MP=$?${opts.nativeReview ? `
|
||||
[ "$_CODEX_MP" -ne 0 ] || { _gstack_codex_model_probe review; _CODEX_MP=$?; }` : ''}
|
||||
if [ "$_CODEX_MP" -eq 2 ]; then
|
||||
${m}="broken_install"
|
||||
elif [ "$_CODEX_MP" -ne 0 ]; then
|
||||
@@ -153,7 +165,7 @@ Branch on the echoed \`CODEX_MODE\`:
|
||||
- **\`under_codex\`** — stale artifact selected its own harness. Print: "Codex outside review unavailable: harness mismatch; no outside process started. Missing coverage. Repair: setup --host codex." Skip the outside invocation and follow the workflow's native-review instructions below. Conflicting inherited harness markers are not grounds to guess another provider.
|
||||
- **\`not_authed\`** — installed but no credentials. Print: "Codex not authenticated; outside coverage unavailable. Run \`codex login\` or set \`$CODEX_API_KEY\`." ${nativeRoute}
|
||||
- **\`broken_install\`** — the CLI is on PATH but cannot execute (spawn ENOENT, non-executable binary, missing vendor payload). Print: "Codex is installed but its binary cannot run — Codex passes skipped. Reinstall: \`npm install -g @openai/codex\`." Relay the probe's HINT lines. ${nativeRoute}
|
||||
- **\`model_unusable\`** — authed but the account cannot use gstack's selected Codex model (#2477: HTTP 400 on every call). Relay the probe's HINT lines and tell the user the one-line fix (set \`GSTACK_CODEX_MODEL=<supported-model>\` or pass an explicit \`-c model=...\` override). ${nativeRoute} The ~10s round trip is cached for 1h; timeouts fail open to \`ready\`.
|
||||
- **\`model_unusable\`** — the selected model (see \`CODEX_MODEL:\`) is invalid or unavailable to the account (#2477: HTTP 400 on every call). Relay the probe's HINT lines and the fix (\`GSTACK_CODEX_MODEL=<supported-model>\` or config.toml \`model\`); never substitute a model. ${nativeRoute} The ~10s round trip is cached for 1h; timeouts fail open to \`ready\`.
|
||||
- **\`ready\`** — run the Codex pass below.`;
|
||||
}
|
||||
|
||||
|
||||
@@ -489,8 +489,8 @@ Compare screenshots and observations across pages for:
|
||||
|
||||
**Project-scoped:**
|
||||
\`\`\`bash
|
||||
eval "$(~/.claude/skills/gstack/bin/gstack-paths)"; : "\${GSTACK_STATE_ROOT:?gstack-paths failed; reinstall with ./setup or /gstack-upgrade}"
|
||||
eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" && mkdir -p "$GSTACK_STATE_ROOT/projects/$SLUG" && echo "PROJECT_DIR: $GSTACK_STATE_ROOT/projects/$SLUG"
|
||||
GSTACK_STATE_ROOT=$(~/.claude/skills/gstack/bin/gstack-paths --get GSTACK_STATE_ROOT); : "\${GSTACK_STATE_ROOT:?gstack-paths failed; reinstall with ./setup or /gstack-upgrade}"
|
||||
SLUG=$(~/.claude/skills/gstack/bin/gstack-slug --get SLUG 2>/dev/null) && mkdir -p "$GSTACK_STATE_ROOT/projects/$SLUG" && echo "PROJECT_DIR: $GSTACK_STATE_ROOT/projects/$SLUG"
|
||||
\`\`\`
|
||||
Write to: \`<PROJECT_DIR>/{user}-{branch}-design-audit-{datetime}.md\` (\`PROJECT_DIR\` printed above)
|
||||
|
||||
@@ -1177,8 +1177,8 @@ Generating visual mockups of the proposed design... (say "skip" if you don't nee
|
||||
**Step 1: Set up the design directory**
|
||||
|
||||
\`\`\`bash
|
||||
eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)"
|
||||
eval "$(~/.claude/skills/gstack/bin/gstack-paths)"; : "\${GSTACK_STATE_ROOT:?gstack-paths failed; reinstall with ./setup or /gstack-upgrade}"
|
||||
SLUG=$(~/.claude/skills/gstack/bin/gstack-slug --get SLUG 2>/dev/null)
|
||||
GSTACK_STATE_ROOT=$(~/.claude/skills/gstack/bin/gstack-paths --get GSTACK_STATE_ROOT); : "\${GSTACK_STATE_ROOT:?gstack-paths failed; reinstall with ./setup or /gstack-upgrade}"
|
||||
_DESIGN_DIR="$GSTACK_STATE_ROOT/projects/$SLUG/designs/mockup-$(date +%Y%m%d)"
|
||||
mkdir -p "$_DESIGN_DIR"
|
||||
echo "DESIGN_DIR: $_DESIGN_DIR"
|
||||
@@ -1387,9 +1387,9 @@ export function generateTasteProfile(ctx: TemplateContext): string {
|
||||
return `Read this project's taste profile:
|
||||
|
||||
\`\`\`bash
|
||||
eval "$("${ctx.paths.binDir}/gstack-slug" 2>/dev/null)"
|
||||
SLUG=$("${ctx.paths.binDir}/gstack-slug" --get SLUG 2>/dev/null)
|
||||
[ -n "\${SLUG:-}" ] || { echo "NO_TASTE_PROFILE"; exit 0; }
|
||||
eval "$("${ctx.paths.binDir}/gstack-paths")"; : "\${GSTACK_STATE_ROOT:?gstack-paths failed; reinstall with ./setup or /gstack-upgrade}"
|
||||
GSTACK_STATE_ROOT=$("${ctx.paths.binDir}/gstack-paths" --get GSTACK_STATE_ROOT); : "\${GSTACK_STATE_ROOT:?gstack-paths failed; reinstall with ./setup or /gstack-upgrade}"
|
||||
_TASTE_PROFILE="$GSTACK_STATE_ROOT/projects/$SLUG/taste-profile.json"
|
||||
if [ -f "$_TASTE_PROFILE" ]; then
|
||||
# Schema v1: { dimensions: { fonts, colors, layouts, aesthetics }, sessions: [] }
|
||||
|
||||
@@ -179,7 +179,7 @@ present in the loaded context; ground recommendations in what the brain
|
||||
prints for this skill.
|
||||
|
||||
\`\`\`bash
|
||||
eval "$(${binDir}/gstack-slug 2>/dev/null)" 2>/dev/null || true
|
||||
SLUG=$(${binDir}/gstack-slug --get SLUG 2>/dev/null) || true
|
||||
{
|
||||
printf '## Brain Context\\n\\n'
|
||||
${loadLines}
|
||||
@@ -218,7 +218,7 @@ This is non-blocking — the user doesn't wait. Next invocation benefits
|
||||
from the warm cache.`}
|
||||
|
||||
\`\`\`bash
|
||||
eval "$(${binDir}/gstack-slug 2>/dev/null)" 2>/dev/null || true
|
||||
SLUG=$(${binDir}/gstack-slug --get SLUG 2>/dev/null) || true
|
||||
(${binDir}/gstack-brain-cache refresh --project "$SLUG" 2>/dev/null &) || true
|
||||
\`\`\`
|
||||
`;
|
||||
@@ -267,7 +267,7 @@ source_skill: ${ctx.skillName}
|
||||
After write, invalidate affected digests:
|
||||
|
||||
\`\`\`bash
|
||||
eval "$(${ctx.paths.binDir}/gstack-slug 2>/dev/null)" 2>/dev/null || true
|
||||
SLUG=$(${ctx.paths.binDir}/gstack-slug --get SLUG 2>/dev/null) || true
|
||||
${invalidateBash || ' # (no per-skill invalidation targets configured)'}
|
||||
\`\`\``;
|
||||
}
|
||||
@@ -15,6 +15,7 @@
|
||||
*/
|
||||
|
||||
import type { TemplateContext, ResolverFn } from './types';
|
||||
import { getHostConfig } from '../../hosts/index';
|
||||
import { outsideVoiceFor, outsideVoiceGuard, outsideVoiceInvocation, outsideVoicePreflight, outsideVoiceProvenance, generateOutsideVoiceRouting } from './outside-voice';
|
||||
|
||||
// Domain modules
|
||||
@@ -55,7 +56,7 @@ export const RESOLVERS: Record<string, ResolverFn> = {
|
||||
OUTSIDE_PROVIDER: (ctx) => outsideVoiceFor(ctx).id,
|
||||
HOST_ID: (ctx) => ctx.host,
|
||||
OUTSIDE_PREFLIGHT: (ctx, args) => outsideVoicePreflight(ctx, { disabledBehavior: args?.[0] === 'opt-in' ? 'opt-in' : 'codex-only' }),
|
||||
OUTSIDE_INVOCATION: (ctx, args) => outsideVoiceInvocation(ctx, { timeoutMs: args?.[0] === 'spec' ? 120000 : 600000, gate: args?.[0] === 'spec' ? 'spec' : 'review', reasoningEffort: args?.[0] === 'spec' ? 'medium' : 'high' }),
|
||||
OUTSIDE_INVOCATION: (ctx, args) => outsideVoiceInvocation(ctx, { timeoutMs: args?.[0] === 'spec' ? 120000 : 540000, gate: args?.[0] === 'spec' ? 'spec' : 'review', reasoningEffort: args?.[0] === 'spec' ? 'medium' : 'high' }),
|
||||
OUTSIDE_PROVENANCE: (ctx, args) => outsideVoiceProvenance(ctx, args?.[0] ?? ctx.skillName),
|
||||
SLUG_EVAL: generateSlugEval,
|
||||
SLUG_SETUP: generateSlugSetup,
|
||||
@@ -139,6 +140,10 @@ export const RESOLVERS: Record<string, ResolverFn> = {
|
||||
DX_FRAMEWORK: generateDxFramework,
|
||||
TASTE_PROFILE: generateTasteProfile,
|
||||
BIN_DIR: (ctx) => ctx.paths.binDir,
|
||||
// Literal bin dir for skills that skip the shared preamble, where
|
||||
// $GSTACK_BIN is never set (#2906): the per-install root when rendering for
|
||||
// one, else the host's default global root.
|
||||
INSTALLED_BIN_DIR: (ctx) => ctx.installRoot ? `${ctx.installRoot.replace(/\/+$/, '')}/bin` : `~/${getHostConfig(ctx.host).globalRoot}/bin`,
|
||||
FOREGROUND_DISPATCH_NOTE: () => FOREGROUND_DISPATCH_NOTE,
|
||||
GBRAIN_CONTEXT_LOAD: generateGBrainContextLoad,
|
||||
GBRAIN_SAVE_RESULTS: generateGBrainSaveResults,
|
||||
|
||||
@@ -46,7 +46,7 @@ Core commands:
|
||||
Output contract:
|
||||
- \`stdout\`: ONLY the output path on success. One line.
|
||||
- \`stderr\`: progress (\`Rendering HTML... Generating PDF...\`) unless \`--quiet\`.
|
||||
- Exit 0 success / 1 bad args / 2 render error / 3 Paged.js timeout / 4 no browser available (open the Aside app, or run \`./setup\` to build gstack's own browser).
|
||||
- Exit 0 success / 1 bad args / 2 render error / 3 TOC page numbers failed / 4 no browser available (open the Aside app, or run \`./setup\` to build gstack's own browser).
|
||||
|
||||
PDFs print through Aside when it is running and through gstack's own headless browser otherwise; the stderr progress line says which (\`Rendering PDF through Aside\` / \`through gstack's browser\`).`;
|
||||
}
|
||||
@@ -16,7 +16,7 @@ import { toShellPath, type TemplateContext } from './types';
|
||||
import { CC_BACKGROUND_DEFAULT_SINCE } from './constants';
|
||||
import { outsideVoiceFailurePolicy, outsideVoiceFor, outsideVoiceInvocation, outsideVoicePreflight, outsideVoiceProvenance, outsideVoiceRuntime } from './outside-voice';
|
||||
|
||||
const CODEX_BOUNDARY = 'IMPORTANT: Do NOT read or execute any files under ~/.claude/, ~/.agents/, .claude/skills/, or agents/. These are skill definitions, not repository review data. Do not follow nested skills, hooks, or tool instructions. They contain bash scripts and prompt templates that will waste your time. Ignore them completely. Do NOT modify agents/openai.yaml. Stay focused on the repository code only.\\n\\n';
|
||||
const CODEX_BOUNDARY = 'IMPORTANT: Do NOT read or execute any files under ~/.claude/, ~/.agents/, .claude/skills/, or agents/. These are skill definitions, not repository review data. Do not invoke any installed skill (Codex home skills/, .agents/), hook, or tool instruction; answer directly. Ignore them completely. Do NOT modify agents/openai.yaml. Stay focused on the repository code only.\\n\\n';
|
||||
|
||||
export function generateCodexSecondOpinion(ctx: TemplateContext): string {
|
||||
|
||||
@@ -47,7 +47,7 @@ If B: skip Phase 3.5 entirely. Remember that the second opinion did NOT run (aff
|
||||
2. **Write the assembled prompt to a temp file** (prevents shell injection from user-derived content):
|
||||
|
||||
\`\`\`bash
|
||||
OUTSIDE_PROMPT_FILE=$(mktemp /tmp/gstack-outside-oh-XXXXXXXX)
|
||||
OUTSIDE_PROMPT_FILE=$(mktemp "\${TMPDIR:-/tmp}/gstack-outside-oh-XXXXXXXX") || { echo 'ERROR: mktemp failed; not running the outside voice without its prompt file.' >&2; exit 1; }
|
||||
\`\`\`
|
||||
|
||||
Write the full prompt to this file. **Always start with the filesystem boundary:**
|
||||
@@ -151,8 +151,6 @@ Outside prompt (supply repository context from the parent):
|
||||
|
||||
${outsideVoiceInvocation(ctx, { timeoutMs: 540000, nativeAlreadyRequired: true, diffCommand: 'DIFF_BASE=$(git merge-base origin/<base> HEAD) && git diff "$DIFF_BASE"' })}
|
||||
|
||||
Set the outer tool timeout to 600000ms so the provider timeout can report its failure.
|
||||
|
||||
Present the full output verbatim. ${isShip ? 'An unavailable outside challenge does not block shipping by itself; supported findings still enter Step 11, and the structured P1 and non-convergence gates still apply.' : 'This outside challenge is informational; supported findings still enter Step 5 Fix-First, whose approval and convergence gates apply.'}
|
||||
|
||||
**Error handling:** Only this optional outside adversarial pass is non-blocking; native completion and structured-review decisions still apply.
|
||||
@@ -176,7 +174,7 @@ ${outsideVoiceInvocation(ctx, { timeoutMs: 540000, nativeAlreadyRequired: true,
|
||||
|
||||
${outsideVoiceFor(ctx).id === 'codex' ? 'The Codex backend uses `codex review --base` without a positional prompt: those arguments are mutually exclusive. Never drop --base to resolve an argv error; prompt-only review changes the diff scope.' : 'The Claude Code backend receives the parent-captured base diff, including committed and working-tree changes, because review mode cannot execute git.'}
|
||||
|
||||
Set the outer tool timeout to 600000ms. Present output under \`${outsideVoiceFor(ctx).label.toUpperCase()} SAYS (code review):\` inside a \`tool-output\` fence.
|
||||
Present output under \`${outsideVoiceFor(ctx).label.toUpperCase()} SAYS (code review):\` inside a \`tool-output\` fence.
|
||||
Only a completed response with severity tags or an explicit no-findings conclusion establishes the gate. P1 findings (\`[P1]\` or native \`P1:\` labels) → GATE: FAIL. Completed without P1 → GATE: PASS. Refusal, failure, or missing markers → GATE: MISSING COVERAGE; preserve the existing user decision flow.
|
||||
|
||||
If GATE is FAIL, use AskUserQuestion:
|
||||
@@ -251,7 +249,7 @@ echo "DIFF_SIZE: $DIFF_TOTAL"
|
||||
|
||||
**Detect the ${outsideVoiceFor(ctx).label} master switch + tool availability:**
|
||||
|
||||
${outsideVoicePreflight(ctx, { disabledBehavior: 'codex-only' })}
|
||||
${outsideVoicePreflight(ctx, { disabledBehavior: 'codex-only', nativeReview: true })}
|
||||
|
||||
\`CODEX_MODE: disabled\` means skip the ${outsideVoiceFor(ctx).label} passes ONLY.
|
||||
\`ready\` runs them; every other mode skips them with the printed reason.
|
||||
@@ -410,13 +408,12 @@ THE PLAN:
|
||||
function codexPlanReviewRun(ctx: TemplateContext, ceo: boolean, needsApprovalReadiness: boolean): string {
|
||||
return `**If \`CODEX_MODE: ready\` — run ${outsideVoiceFor(ctx).label}:**
|
||||
|
||||
${['plan-ceo-review', 'plan-eng-review'].includes(ctx.skillName) ? `Run this block only for \`ready\`, in one foreground Bash call
|
||||
(\`run_in_background: false\`, \`timeout: 300000\`). Its opening harness guard
|
||||
rechecks the fresh shell: exit 78 uses the same Native fallback below, never a
|
||||
replacement provider. Finish termination before fallback and consume only
|
||||
completed output. Use private temporary paths, with no background jobs.` : `Run the selected backend in one foreground Bash invocation (\`run_in_background: false\`,
|
||||
\`timeout: 300000\`). Finish a failed attempt's termination before fallback;
|
||||
consume only its completed output. No background jobs or shared temporary paths.`}
|
||||
${['plan-ceo-review', 'plan-eng-review'].includes(ctx.skillName) ? `Run this block only for \`ready\`, in the one foreground Bash call described below.
|
||||
Its opening harness guard rechecks the fresh shell: exit 78 uses the same Native
|
||||
fallback below, never a replacement provider. Finish termination before fallback and consume only
|
||||
completed output. Use private temporary paths, with no background jobs.` : `Run the selected backend in the one foreground Bash invocation described below.
|
||||
Finish a failed attempt's termination before fallback; consume only its completed
|
||||
output. No background jobs or shared temporary paths.`}
|
||||
|
||||
${outsideVoiceInvocation(ctx, { timeoutMs: 300000 })}
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ export function outsideVoiceRuntime(ctx: TemplateContext): string {
|
||||
const global = ctx.host === 'codex'
|
||||
? '\${CODEX_HOME:-$HOME/.codex}/skills/gstack'
|
||||
: `$HOME/${host.globalRoot}`;
|
||||
return `# Preserve an explicit usable runtime; otherwise prefer the repo-local installation.
|
||||
return `# Prefer an explicit usable runtime, else the repo-local install.
|
||||
if [ -n "\${GSTACK_ROOT:-}" ] && [ -d "$GSTACK_ROOT/bin" ] && [ -f "$GSTACK_ROOT/lib/claude-bin.ts" ]; then
|
||||
GSTACK_BIN="$GSTACK_ROOT/bin"
|
||||
elif [ -n "\${GSTACK_BIN:-}" ] && [ -f "$GSTACK_BIN/../lib/claude-bin.ts" ]; then
|
||||
@@ -66,10 +66,10 @@ if { ${own}; }; then
|
||||
fi`;
|
||||
}
|
||||
|
||||
export function outsideVoicePreflight(ctx: TemplateContext, opts: { disabledBehavior: 'skip-all' | 'codex-only' | 'opt-in'; acceptedOnly?: boolean }): string {
|
||||
export function outsideVoicePreflight(ctx: TemplateContext, opts: { disabledBehavior: 'skip-all' | 'codex-only' | 'opt-in'; acceptedOnly?: boolean; nativeReview?: boolean }): string {
|
||||
const v = outsideVoiceFor(ctx);
|
||||
if (v.id === 'codex' && opts.disabledBehavior !== 'opt-in') {
|
||||
let preflight = outsideVoiceLabels(ctx, codexPreflight({ disabledBehavior: opts.disabledBehavior }))
|
||||
let preflight = outsideVoiceLabels(ctx, codexPreflight({ disabledBehavior: opts.disabledBehavior, nativeReview: opts.nativeReview }))
|
||||
.replace('```bash\n', `\`\`\`bash\n${outsideVoiceRuntime(ctx)}\n`);
|
||||
if (['plan-eng-review', 'plan-ceo-review'].includes(ctx.skillName)) {
|
||||
preflight = preflight.replace("follow the workflow's native-review instructions below",
|
||||
@@ -131,10 +131,9 @@ export function outsideVoiceCommand(ctx: TemplateContext, opts: OutsideCommandOp
|
||||
? `codex review --base ${sh(opts.structuredBase)} ${CODEX_REVIEW_MODEL_CONFIG_FLAG} -c 'model_reasoning_effort="${opts.reasoningEffort ?? 'high'}"' ${CODEX_WEB_SEARCH_FLAG} < /dev/null`
|
||||
: `codex exec "$_OUTSIDE_PROMPT" -C "$_REPO_ROOT" -s read-only ${CODEX_MODEL_CONFIG_FLAG} -c 'model_reasoning_effort="${opts.reasoningEffort ?? 'high'}"' ${CODEX_WEB_SEARCH_FLAG} < /dev/null`;
|
||||
const invocation = v.id === 'codex'
|
||||
? `source "${bin}/gstack-codex-probe" || exit 1
|
||||
? `source "${bin}/gstack-codex-probe" && _gstack_codex_select_model ${opts.structuredBase ? 'review' : 'exec'} || exit 1
|
||||
${opts.structuredBase ? '' : '_OUTSIDE_PROMPT=$(cat "$_OUTSIDE_INPUT") || exit 1\n'}_OUTSIDE_EXIT=0
|
||||
_gstack_codex_timeout_wrapper ${Math.ceil(opts.timeoutMs / 1000)} ${codex} >"$_OUTSIDE_TMP/text" 2>"$_OUTSIDE_TMP/stderr" || _OUTSIDE_EXIT=$?
|
||||
# Preserve findings and partial output even when transport or validation fails.
|
||||
cat "$_OUTSIDE_TMP/text" || { [ "$_OUTSIDE_EXIT" -ne 0 ] || _OUTSIDE_EXIT=1; }`
|
||||
: `_OUTSIDE_EXIT=0
|
||||
"${bin}/gstack-claude-code" --cwd "$_REPO_ROOT" --access ${opts.access ?? 'none'} --timeout-ms ${opts.timeoutMs} <"$_OUTSIDE_INPUT" >"$_OUTSIDE_TMP/result.json" 2>"$_OUTSIDE_TMP/stderr" || _OUTSIDE_EXIT=$?
|
||||
@@ -168,7 +167,15 @@ ${v.id === 'claude-code' ? 'cat "$_OUTSIDE_TMP/text" || exit 1' : ''}
|
||||
echo 'OUTSIDE_STATUS: completed provider=${v.id} host=${ctx.host}'`;
|
||||
}
|
||||
|
||||
export function outsideVoiceInvocation(ctx: TemplateContext, opts: OutsideCommandOptions = { timeoutMs: 300000 }): string {
|
||||
/** Claude Code's Bash tool maximum; every outer gate stays at or under it. */
|
||||
const OUTSIDE_GATE_MAX_MS = 600000;
|
||||
/** Gate headroom over the provider deadline: TERM, KILL after the grace, then report. */
|
||||
const OUTSIDE_CLEANUP_MS = 60000;
|
||||
|
||||
export function outsideVoiceInvocation(ctx: TemplateContext, requested: OutsideCommandOptions = { timeoutMs: 300000 }): string {
|
||||
// The provider deadline, not each caller, sets the outer gate (#2776).
|
||||
const opts = { ...requested, timeoutMs: Math.min(requested.timeoutMs, OUTSIDE_GATE_MAX_MS - OUTSIDE_CLEANUP_MS) };
|
||||
const gateMs = opts.timeoutMs + OUTSIDE_CLEANUP_MS;
|
||||
const nativeStructured = outsideVoiceFor(ctx).id === 'codex' && !!opts.structuredBase;
|
||||
const planRecommendation = ['plan-ceo-review', 'plan-eng-review'].includes(ctx.skillName)
|
||||
&& (opts.gate ?? 'review') === 'review';
|
||||
@@ -190,7 +197,7 @@ export function outsideVoiceInvocation(ctx: TemplateContext, opts: OutsideComman
|
||||
${outsideVoiceCommand(ctx, opts)}
|
||||
\`\`\`
|
||||
|
||||
Show the full response in a \`tool-output\` fence. Require successful execution and valid markers. Refusal, empty/malformed output, missing ${planRecommendation ? 'Recommendation: <action> because <reason>' : opts.purpose === 'design-direction' ? 'Recommendation' : 'score/severity/completion'} markers, timeout or CLI failure means \`outside_status: unavailable\`. ${opts.purpose === 'design-direction' ? 'Continue completed proposals; native completion does not count as outside coverage.' : opts.nativeAlreadyRequired ? 'Retain the required native pass without duplicating it; it cannot complete outside coverage.' : "Use the caller's fallback; missing coverage is never clean/PASS."} ${nativeStructured ? 'Scratch cleanup is automatic.' : 'After either outcome, delete only your private prompt; scratch cleanup is automatic.'}`;
|
||||
Use Bash \`timeout: ${gateMs}\`; show the full response in a \`tool-output\` fence. Require successful execution and valid markers. Refusal, empty/malformed output, missing ${planRecommendation ? 'Recommendation: <action> because <reason>' : opts.purpose === 'design-direction' ? 'Recommendation' : 'score/severity/completion'} markers, timeout or CLI failure means \`outside_status: unavailable\`. ${opts.purpose === 'design-direction' ? 'Continue completed proposals; native completion does not count as outside coverage.' : opts.nativeAlreadyRequired ? 'Retain the required native pass without duplicating it; it cannot complete outside coverage.' : "Use the caller's fallback; missing coverage is never clean/PASS."} ${nativeStructured ? 'Scratch cleanup is automatic.' : 'After either outcome, delete only your private prompt; scratch cleanup is automatic.'}`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -197,7 +197,7 @@ function generatePlanFileDiscovery(ship = false): string {
|
||||
setopt +o nomatch 2>/dev/null || true # zsh compat
|
||||
BRANCH=$(git branch --show-current 2>/dev/null | tr '/' '-' | tr -cd 'a-zA-Z0-9._-')
|
||||
REPO=$(basename "$(git rev-parse --show-toplevel 2>/dev/null)")
|
||||
eval "$(~/.claude/skills/gstack/bin/gstack-paths)"; : "\${GSTACK_STATE_ROOT:?gstack-paths failed; reinstall with ./setup or /gstack-upgrade}"
|
||||
GSTACK_STATE_ROOT=$(~/.claude/skills/gstack/bin/gstack-paths --get GSTACK_STATE_ROOT); : "\${GSTACK_STATE_ROOT:?gstack-paths failed; reinstall with ./setup or /gstack-upgrade}"
|
||||
_PLAN_SLUG=$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null | sed -n 's/^SLUG=//p') || true
|
||||
_PLAN_SLUG="\${_PLAN_SLUG:-$(basename "$PWD" | tr -cd 'a-zA-Z0-9._-')}"
|
||||
for PLAN_DIR in "$GSTACK_STATE_ROOT/projects/$_PLAN_SLUG" "$HOME/.claude/plans" "$HOME/.codex/plans" ".gstack/plans"; do
|
||||
|
||||
@@ -24,11 +24,11 @@ export function generatePlanModeInfo(ctx: TemplateContext): string {
|
||||
? 'starting from the Scope gate, then follow its Startup sequence'
|
||||
: 'starting from the Scope gate (current target announcement or answered question), then preamble and Step 0';
|
||||
const invocation = ['plan-design-review', 'plan-eng-review'].includes(ctx.skillName)
|
||||
? `The invoked skill overrides generic plan mode. **Execute the skill file** ${entryOrder}. Skill questions are valid; skip only questions the skill resolves. Any AskUserQuestion variant satisfies end-of-turn; use AskUserQuestion Format's tool resolution. If unavailable/failed: \`headless\` → BLOCKED; \`interactive\` → prose fallback (satisfies end-of-turn). At STOP, stop: no continuation or ExitPlanMode. Execute "PLAN MODE EXCEPTION — ALWAYS RUN" commands. ExitPlanMode only after skill completion or if the user cancels the skill or tells you to leave plan mode.`
|
||||
: `If the user invokes a skill in plan mode, the skill takes precedence over generic plan mode behavior. **Treat the skill file as executable instructions, not reference.** Follow it step by step starting from Step 0; any AskUserQuestion the skill fires is the workflow operating within plan mode, not a violation of it — and a skill whose instructions resolve a question themselves (e.g. a plan-mode auto-select) may legitimately not ask it. AskUserQuestion (any variant — \`mcp__*__AskUserQuestion\` or native; see "AskUserQuestion Format → Tool resolution") satisfies plan mode's end-of-turn requirement. If AskUserQuestion is unavailable or a call fails, follow the AskUserQuestion Format failure fallback: \`headless\` → BLOCKED; \`interactive\` → the prose fallback (also satisfies end-of-turn). At a STOP point, stop immediately. Do not continue the workflow or call ExitPlanMode there. Commands marked "PLAN MODE EXCEPTION — ALWAYS RUN" execute. Call ExitPlanMode only after the skill workflow completes, or if the user tells you to cancel the skill or leave plan mode.`;
|
||||
? `Run the invoked skill within the host's plan-mode limits. **Execute the skill file** ${entryOrder}. Skill questions are valid; skip only questions the skill resolves. Any AskUserQuestion variant satisfies end-of-turn; use AskUserQuestion Format's tool resolution. If unavailable/failed: \`headless\` → BLOCKED; \`interactive\` → prose fallback (satisfies end-of-turn). At STOP, stop: no continuation or ExitPlanMode. Run "PLAN MODE EXCEPTION — ALWAYS RUN" commands only where the host permits. ExitPlanMode only after skill completion or if the user cancels the skill or tells you to leave plan mode.`
|
||||
: `If the user invokes a skill in plan mode, run its workflow within the host's plan-mode limits. **Treat the skill file as executable instructions, not reference.** Follow it step by step starting from Step 0; any AskUserQuestion the skill fires is the workflow operating within plan mode, not a violation of it — and a skill whose instructions resolve a question themselves (e.g. a plan-mode auto-select) may legitimately not ask it. AskUserQuestion (any variant — \`mcp__*__AskUserQuestion\` or native; see "AskUserQuestion Format → Tool resolution") satisfies plan mode's end-of-turn requirement. If AskUserQuestion is unavailable or a call fails, follow the AskUserQuestion Format failure fallback: \`headless\` → BLOCKED; \`interactive\` → the prose fallback (also satisfies end-of-turn). At a STOP point, stop immediately. Do not continue the workflow or call ExitPlanMode there. Commands marked "PLAN MODE EXCEPTION — ALWAYS RUN" run only where the host permits them. Call ExitPlanMode only after the skill workflow completes, or if the user tells you to cancel the skill or leave plan mode.`;
|
||||
return `## Plan Mode Safe Operations
|
||||
|
||||
In plan mode, allowed because they inform the plan: \`$B\`, \`$D\`, \`codex exec\`/\`codex review\`, temp prompts, writes to \`~/.gstack/\`, writes to the plan file, and \`open\` for generated artifacts.
|
||||
Host and system plan-mode restrictions and the user's current scope take precedence over any skill; a skill cannot grant itself an exception to read-only mode. Where the host permits them, these inform the plan: \`$B\`, \`$D\`, \`codex exec\`/\`codex review\`, temp prompts, writes to \`~/.gstack/\`, writes to the plan file, and \`open\` for generated artifacts. If the host blocks one, skip it, say so, and continue the permitted work.
|
||||
|
||||
## Skill Invocation During Plan Mode
|
||||
|
||||
|
||||
@@ -3,42 +3,16 @@ import type { TemplateContext } from '../types';
|
||||
export function generateContextRecovery(ctx: TemplateContext): string {
|
||||
const binDir = ctx.paths.binDir; // env-var hosts already resolve to $GSTACK_BIN via types.ts
|
||||
|
||||
// Branch-form discipline (#2550/#1851): FILE-PATH positions use $BRANCH —
|
||||
// the canonical slug form the gstack-slug eval on the first line sets
|
||||
// (tr '/' '-' then tr -cd 'a-zA-Z0-9._-', matching what gstack-review-log
|
||||
// WRITES). Initialize raw $_BRANCH here: skill-start runs in a separate
|
||||
// process. Artifact filenames and timeline filters intentionally use different
|
||||
// branch forms: $BRANCH for slugged review filenames, $_BRANCH for raw
|
||||
// timeline JSON values. Do not collapse one into the other.
|
||||
// The listing lives in bin/gstack-context-recovery (#2763): worktree-isolated
|
||||
// Claude Code sessions refuse the eval and git-in-a-pipe forms the inline
|
||||
// fence used, but run one literal command. Branch-form discipline
|
||||
// (#2550/#1851) moved with it.
|
||||
return `## Context Recovery
|
||||
|
||||
At session start or after compaction, recover recent project context.
|
||||
|
||||
\`\`\`bash
|
||||
eval "$(${binDir}/gstack-slug 2>/dev/null)"
|
||||
_BRANCH=$(git branch --show-current 2>/dev/null | tr -cd 'a-zA-Z0-9._/-') || :; _BRANCH=\${_BRANCH:-unknown}
|
||||
eval "$(${binDir}/gstack-paths)"; : "\${GSTACK_STATE_ROOT:?gstack-paths failed; reinstall with ./setup or /gstack-upgrade}"
|
||||
_PROJ="$GSTACK_STATE_ROOT/projects/\${SLUG:-unknown}"
|
||||
if [ -d "$_PROJ" ]; then
|
||||
echo "--- RECENT ARTIFACTS ---"
|
||||
find "$_PROJ/ceo-plans" "$_PROJ/checkpoints" -type f -name "*.md" 2>/dev/null | xargs -r ls -t 2>/dev/null | head -3
|
||||
[ -f "$_PROJ/\${BRANCH:-unknown}-reviews.jsonl" ] && echo "REVIEWS: $(wc -l < "$_PROJ/\${BRANCH:-unknown}-reviews.jsonl" | tr -d ' ') entries"
|
||||
[ -f "$_PROJ/timeline.jsonl" ] && tail -5 "$_PROJ/timeline.jsonl"
|
||||
if [ -f "$_PROJ/timeline.jsonl" ]; then
|
||||
_LAST=$(grep "\\"branch\\":\\"\${_BRANCH}\\"" "$_PROJ/timeline.jsonl" 2>/dev/null | grep '"event":"completed"' | tail -1)
|
||||
[ -n "$_LAST" ] && echo "LAST_SESSION: $_LAST"
|
||||
_RECENT_SKILLS=$(grep "\\"branch\\":\\"\${_BRANCH}\\"" "$_PROJ/timeline.jsonl" 2>/dev/null | grep '"event":"completed"' | tail -3 | grep -o '"skill":"[^"]*"' | sed 's/"skill":"//;s/"//' | tr '\\n' ',')
|
||||
[ -n "$_RECENT_SKILLS" ] && echo "RECENT_PATTERN: $_RECENT_SKILLS"
|
||||
fi
|
||||
_LATEST_CP=$(find "$_PROJ/checkpoints" -name "*.md" -type f 2>/dev/null | xargs -r ls -t 2>/dev/null | head -1)
|
||||
[ -n "$_LATEST_CP" ] && echo "LATEST_CHECKPOINT: $_LATEST_CP"
|
||||
if [ -f "$_PROJ/decisions.active.json" ]; then
|
||||
echo "--- ACTIVE DECISIONS (recent, scope-relevant) ---"
|
||||
${binDir}/gstack-decision-search --recent 5 2>/dev/null
|
||||
echo "--- END DECISIONS ---"
|
||||
fi
|
||||
echo "--- END ARTIFACTS ---"
|
||||
fi
|
||||
${binDir}/gstack-context-recovery
|
||||
\`\`\`
|
||||
|
||||
If artifacts are listed, read the newest useful one. If \`LAST_SESSION\` or \`LATEST_CHECKPOINT\` appears, give a 2-sentence welcome back summary. If \`RECENT_PATTERN\` clearly implies a next skill, suggest it once.
|
||||
|
||||
@@ -14,9 +14,13 @@ import { getHostConfig } from '../../../hosts/index';
|
||||
* emitted by the script, for every host render).
|
||||
*
|
||||
* Divergences from the old inline bash are deliberate and enumerated in the
|
||||
* script header (EOV5): $0-relative paths, --parent-pid for session identity,
|
||||
* GSTACK_HOME normalization, SKILL_START_PROTO handshake, passthrough
|
||||
* sanitization.
|
||||
* script header (EOV5): $0-relative paths, script-derived harness pid for
|
||||
* session identity, GSTACK_HOME normalization, SKILL_START_PROTO handshake,
|
||||
* passthrough sanitization.
|
||||
*
|
||||
* The start is ONE plain command (#2763): worktree-isolated Claude Code
|
||||
* sessions refuse a command named by a variable, a `[ -x ] ||` fallback, or a
|
||||
* trailing `|| echo`. A missing helper is covered by the degraded-mode prose.
|
||||
*/
|
||||
export function generatePreambleBash(ctx: TemplateContext): string {
|
||||
const hostConfig = getHostConfig(ctx.host);
|
||||
@@ -30,10 +34,9 @@ GSTACK_DESIGN="$GSTACK_ROOT/design/dist"
|
||||
`
|
||||
: '';
|
||||
const brainHealthFlag = ctx.host === 'gbrain' || ctx.host === 'hermes' ? ' --brain-health' : '';
|
||||
// A leading ~ inside double quotes never expands in bash — the primary path
|
||||
// would silently fail -x and every run would take the fallback. Interpolate
|
||||
// through $HOME instead (env-var hosts already use $GSTACK_BIN).
|
||||
const shellPath = (p: string) => p.replace(/^~\//, '$HOME/');
|
||||
const startCommand = hostConfig.usesEnvVars
|
||||
? `"${ctx.paths.binDir}/gstack-skill-start"`
|
||||
: `${ctx.paths.binDir}/gstack-skill-start`;
|
||||
|
||||
const entry = ['plan-design-review', 'plan-eng-review'].includes(ctx.skillName)
|
||||
? `## Preamble (after scope gate)
|
||||
@@ -44,10 +47,7 @@ GSTACK_DESIGN="$GSTACK_ROOT/design/dist"
|
||||
return `${entry}
|
||||
|
||||
\`\`\`bash
|
||||
${runtimeRoot}_SS="${shellPath(ctx.paths.binDir)}/gstack-skill-start"
|
||||
[ -x "$_SS" ] || _SS="${shellPath(ctx.paths.localSkillRoot)}/bin/gstack-skill-start"
|
||||
"$_SS" --skill "${ctx.skillName}" --model "${ctx.model ?? 'none'}" --parent-pid "$PPID"${brainHealthFlag} \\
|
||||
|| echo "SKILL_START: unavailable — stale install; run ./setup or /gstack-upgrade (preamble degraded, continue the user's task)"
|
||||
${runtimeRoot}${startCommand} --skill "${ctx.skillName}" --model "${ctx.model ?? 'none'}"${brainHealthFlag}
|
||||
\`\`\`
|
||||
|
||||
Read the echoed \`KEY: value\` STATUS lines — they drive every preamble rule
|
||||
|
||||
@@ -18,8 +18,9 @@ Then build the complete version of what remains.
|
||||
|
||||
**Eureka:** When first-principles reasoning contradicts conventional wisdom, name it and log:
|
||||
\`\`\`bash
|
||||
eval "$(${ctx.paths.binDir}/gstack-paths)"; : "\${GSTACK_STATE_ROOT:?gstack-paths failed; reinstall with ./setup or /gstack-upgrade}"
|
||||
jq -n --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" --arg skill "SKILL_NAME" --arg branch "$(git branch --show-current 2>/dev/null)" --arg insight "ONE_LINE_SUMMARY" '{ts:$ts,skill:$skill,branch:$branch,insight:$insight}' >> "$GSTACK_STATE_ROOT/analytics/eureka.jsonl" 2>/dev/null || true
|
||||
GSTACK_STATE_ROOT=$(${ctx.paths.binDir}/gstack-paths --get GSTACK_STATE_ROOT); : "\${GSTACK_STATE_ROOT:?gstack-paths failed; reinstall with ./setup or /gstack-upgrade}"
|
||||
BRANCH=$(${ctx.paths.binDir}/gstack-slug --get BRANCH 2>/dev/null)
|
||||
jq -nc --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" --arg skill "SKILL_NAME" --arg branch "$BRANCH" --arg insight "ONE_LINE_SUMMARY" '{ts:$ts,skill:$skill,branch:$branch,insight:$insight}' >> "$GSTACK_STATE_ROOT/analytics/eureka.jsonl" 2>/dev/null || true
|
||||
\`\`\``;
|
||||
}
|
||||
|
||||
+12
-7
@@ -79,13 +79,17 @@ Set SECONDS to the shorter mode/caller limit; an unlimited mode uses the caller'
|
||||
Without a total time limit, do not start D; announce finite command timeouts.
|
||||
Stop when scoped contracts are tested or blocked.
|
||||
Clocks/checkpoints use REPORT_DIR; mixed standalone runs use REPORT_DIR/browser and REPORT_DIR/functional, with one final report at REPORT_DIR. Caller paths win.
|
||||
R = owned probe directory; D = R/deadline.json. Quote paths.
|
||||
G = \`${quoteSafePath(ctx.paths.binDir)}/gstack-qa-deadline\`; Q = \`${quoteSafePath(ctx.paths.binDir)}/gstack-qa-evidence\`.
|
||||
Names used below (quote every path):
|
||||
- R: this surface's owned probe directory, chosen above. D: \`R/deadline.json\`, which exists only for a bounded run.
|
||||
- SECONDS: the total probe budget set above. NNN: a fresh three-digit ID (001, 002, ...) for each capture or checkpoint; never reuse one.
|
||||
- G = \`${quoteSafePath(ctx.paths.binDir)}/gstack-qa-deadline\`, the deadline guard: \`start\` creates D, \`status\` prints \`remainingMs\` and \`expired\`, and \`run\` executes one command, stopping it at the deadline.
|
||||
- Q = \`${quoteSafePath(ctx.paths.binDir)}/gstack-qa-evidence\`, the functional evidence recorder: \`capture\` runs one command, stores its exit code, stdout and stderr under \`R/.qa-evidence/NNN/\`, and prints status \`complete\`, \`incomplete\` or \`sensitive\`.
|
||||
|
||||
Start once before baseline: \`bun G start D SECONDS [EARLIER_UTC]\` if bounded.
|
||||
EARLIER_UTC = caller's absolute deadline, if set.
|
||||
Functional: \`bun Q capture R NNN [--public] --deadline D -- COMMAND ARGS\`.
|
||||
Unbounded: use \`--timeout-ms MS\` instead. Use fresh three-digit IDs.
|
||||
--public requires approved public/synthetic output; Q screens credentials. For complete private captures, await a safe Read of \`R/.qa-evidence/NNN/observation.json\`. Sensitive/incomplete captures cannot anchor checkpoints.
|
||||
Unbounded: use \`--timeout-ms MS\` instead of \`--deadline D\`.
|
||||
--public requires approved public/synthetic output; Q screens credentials. For complete private captures, await a safe Read of \`R/.qa-evidence/NNN/observation.json\` (the decoded stdout). Sensitive/incomplete captures cannot anchor checkpoints.
|
||||
Bounded browsers: \`bun G run D -- COMMAND ARGS\`. No detached probes.
|
||||
Never reset D/bypass G. Expiry or invalid/missing D stops probes; report unfinished coverage. QA_DEADLINE receipts are not observations.
|
||||
|
||||
@@ -115,8 +119,8 @@ ${reportOnly ? ` For guarded text, copy the complete span between the guard's
|
||||
Preserve every safe program-JSON key/value and identity hash unchanged.
|
||||
Withhold unsafe values, disclose limits and stop that chain.
|
||||
Check fields before publication. No drafts/placeholders or invented safe-path redactions; corrections cannot repair published notes.
|
||||
Functional: the next capture publishes it: \`... --after PREV --hypothesis 'why' -- CMD\` (PREV: last complete capture). Q supplies observed; never transcribe it.
|
||||
Browser checkpoints use Write.
|
||||
Functional: do not write this file; the next capture publishes it: \`bun Q capture R NNN --deadline D --after PREV --hypothesis 'why' -- CMD\` (PREV: the last complete capture's ID). Q supplies observed; never transcribe it.
|
||||
Browser checkpoints use Write: create the four-field file yourself.
|
||||
Wait for successful checkpoint publication before dispatch.
|
||||
Never backfill or overwrite notes.
|
||||
3. Run that exact probe; G enforces the deadline when bounded.
|
||||
@@ -227,6 +231,7 @@ export function generateQAReviewPreflight(ctx: TemplateContext): string {
|
||||
return `> **STOP.** Before any probe, including plan checks, complete the ordered scope/method Reads below and await them. Templates cannot replace them.
|
||||
${ctx.skillName === 'review' ? 'Step 4 is read-only: defer charters, setup and probes to Step 4.7.\n' : ''}
|
||||
{{QA_RESOURCE:exploratory}}
|
||||
Reading exploratory.md does not complete them: when it returns, Read the scope section and selected surface methods it lists, in order, and await them.
|
||||
|
||||
Resolve QA's \`sections/...\` and \`templates/...\` paths from that installed QA SKILL.md directory, not the caller or product directory.`;
|
||||
}
|
||||
@@ -242,7 +247,7 @@ Never overwrite another run's reports. Batch only independent Reads.
|
||||
${ship ? `**1. Load methods before any QA or explicit-verification probe.**
|
||||
|
||||
${generateQAReviewPreflight(ctx)}` : `**1. Set the charter and isolation.**
|
||||
Reuse Step 4's surfaces and completed Reads. Finish missing methods before charters; do not repeat completed Reads.
|
||||
Reuse Step 4's surfaces and completed Reads. Finish any missing scope/method Reads before charters, setup or probes; do not repeat completed Reads.
|
||||
Write the Charter and complete the shared isolation/permission preflight before setup.`}
|
||||
|
||||
**2. ${ship ? 'List required checks.' : 'Check readiness and list required checks.'}**
|
||||
|
||||
@@ -114,7 +114,7 @@ inventing duplicate counts. Preserve the Assignment, coaching, approval, and Han
|
||||
Append the helper's actual metrics to the existing analytics log (telemetry is
|
||||
best-effort and must not block approval):
|
||||
\`\`\`bash
|
||||
eval "$(~/.claude/skills/gstack/bin/gstack-paths)"; : "\${GSTACK_STATE_ROOT:?gstack-paths failed; reinstall with ./setup or /gstack-upgrade}"
|
||||
GSTACK_STATE_ROOT=$(~/.claude/skills/gstack/bin/gstack-paths --get GSTACK_STATE_ROOT); : "\${GSTACK_STATE_ROOT:?gstack-paths failed; reinstall with ./setup or /gstack-upgrade}"
|
||||
mkdir -p "$GSTACK_STATE_ROOT/analytics"
|
||||
echo '{"skill":"office-hours","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","iterations":ITERATIONS,"issues_found":FOUND,"issues_fixed":FIXED,"remaining":REMAINING,"quality_score":SCORE}' >> "$GSTACK_STATE_ROOT/analytics/spec-review.jsonl" 2>/dev/null || true
|
||||
\`\`\`
|
||||
@@ -210,7 +210,7 @@ if a required save fails, stop before claiming completion.` : `After the loop co
|
||||
|
||||
3. Append metrics:`}
|
||||
\`\`\`bash
|
||||
eval "$(~/.claude/skills/gstack/bin/gstack-paths)"; : "\${GSTACK_STATE_ROOT:?gstack-paths failed; reinstall with ./setup or /gstack-upgrade}"
|
||||
GSTACK_STATE_ROOT=$(~/.claude/skills/gstack/bin/gstack-paths --get GSTACK_STATE_ROOT); : "\${GSTACK_STATE_ROOT:?gstack-paths failed; reinstall with ./setup or /gstack-upgrade}"
|
||||
mkdir -p "$GSTACK_STATE_ROOT/analytics"${ceo ? ' || exit 1' : ''}
|
||||
echo '{"skill":"${_ctx.skillName}","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","iterations":ITERATIONS,"issues_found":FOUND,"issues_fixed":FIXED,"remaining":REMAINING,"quality_score":SCORE}' >> "$GSTACK_STATE_ROOT/analytics/spec-review.jsonl"${ceo ? ' || exit 1' : ' 2>/dev/null || true'}
|
||||
\`\`\`
|
||||
|
||||
@@ -59,8 +59,9 @@ Rules:
|
||||
backslashes serialize cleanly — never use hand-rolled \`echo\` / \`printf\`.
|
||||
|
||||
\`\`\`bash
|
||||
eval "$(~/.claude/skills/gstack/bin/gstack-paths)"; : "\${GSTACK_STATE_ROOT:?gstack-paths failed; reinstall with ./setup or /gstack-upgrade}"
|
||||
eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)"
|
||||
GSTACK_STATE_ROOT=$(~/.claude/skills/gstack/bin/gstack-paths --get GSTACK_STATE_ROOT); : "\${GSTACK_STATE_ROOT:?gstack-paths failed; reinstall with ./setup or /gstack-upgrade}"
|
||||
BRANCH=$(~/.claude/skills/gstack/bin/gstack-slug --get BRANCH 2>/dev/null)
|
||||
SLUG=$(~/.claude/skills/gstack/bin/gstack-slug --get SLUG 2>/dev/null)
|
||||
TASKS_DIR="$GSTACK_STATE_ROOT/projects/\${SLUG:-unknown}"
|
||||
mkdir -p "$TASKS_DIR"
|
||||
TASKS_FILE="$TASKS_DIR/tasks-${phase}-$(date +%Y%m%d-%H%M%S).jsonl"
|
||||
@@ -106,8 +107,9 @@ Before rendering the Final Approval Gate output block below, aggregate the
|
||||
per-phase task lists each review skill wrote.
|
||||
|
||||
\`\`\`bash
|
||||
eval "$(~/.claude/skills/gstack/bin/gstack-paths)"; : "\${GSTACK_STATE_ROOT:?gstack-paths failed; reinstall with ./setup or /gstack-upgrade}"
|
||||
eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)"
|
||||
GSTACK_STATE_ROOT=$(~/.claude/skills/gstack/bin/gstack-paths --get GSTACK_STATE_ROOT); : "\${GSTACK_STATE_ROOT:?gstack-paths failed; reinstall with ./setup or /gstack-upgrade}"
|
||||
BRANCH=$(~/.claude/skills/gstack/bin/gstack-slug --get BRANCH 2>/dev/null)
|
||||
SLUG=$(~/.claude/skills/gstack/bin/gstack-slug --get SLUG 2>/dev/null)
|
||||
TASKS_DIR="$GSTACK_STATE_ROOT/projects/\${SLUG:-unknown}"
|
||||
BRANCH=$(git branch --show-current 2>/dev/null || echo unknown)
|
||||
# Commit window: last 5 commits on this branch. Drops stale standalone reviews.
|
||||
|
||||
@@ -489,8 +489,9 @@ ${subheading} Test Plan Artifact
|
||||
After resolving the Test review decisions, record the approved test requirements in an artifact for \`/qa\` and \`/qa-only\`. List any unresolved choices separately as pending, not required implementation. Update this artifact if later approved decisions change the tests. Use the Review record and write policy above.
|
||||
|
||||
\`\`\`bash
|
||||
eval "$(~/.claude/skills/gstack/bin/gstack-paths)"; : "\${GSTACK_STATE_ROOT:?gstack-paths failed; reinstall with ./setup or /gstack-upgrade}"
|
||||
eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" && mkdir -p "$GSTACK_STATE_ROOT/projects/$SLUG" && echo "PROJECT_DIR: $GSTACK_STATE_ROOT/projects/$SLUG" # sets SLUG and BRANCH
|
||||
GSTACK_STATE_ROOT=$(~/.claude/skills/gstack/bin/gstack-paths --get GSTACK_STATE_ROOT); : "\${GSTACK_STATE_ROOT:?gstack-paths failed; reinstall with ./setup or /gstack-upgrade}"
|
||||
BRANCH=$(~/.claude/skills/gstack/bin/gstack-slug --get BRANCH 2>/dev/null)
|
||||
SLUG=$(~/.claude/skills/gstack/bin/gstack-slug --get SLUG 2>/dev/null) && mkdir -p "$GSTACK_STATE_ROOT/projects/$SLUG" && echo "PROJECT_DIR: $GSTACK_STATE_ROOT/projects/$SLUG" # sets SLUG and BRANCH
|
||||
TEST_PLAN_USER=$(whoami)
|
||||
DATETIME=$(date +%Y%m%d-%H%M%S)
|
||||
\`\`\`
|
||||
@@ -615,8 +616,8 @@ ${subheading} Test Plan Artifact
|
||||
After producing the coverage diagram, write a test plan artifact so \`/qa\` and \`/qa-only\` can consume it:
|
||||
|
||||
\`\`\`bash
|
||||
eval "$(~/.claude/skills/gstack/bin/gstack-paths)"; : "\${GSTACK_STATE_ROOT:?gstack-paths failed; reinstall with ./setup or /gstack-upgrade}"
|
||||
eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" && mkdir -p "$GSTACK_STATE_ROOT/projects/$SLUG" && echo "PROJECT_DIR: $GSTACK_STATE_ROOT/projects/$SLUG"
|
||||
GSTACK_STATE_ROOT=$(~/.claude/skills/gstack/bin/gstack-paths --get GSTACK_STATE_ROOT); : "\${GSTACK_STATE_ROOT:?gstack-paths failed; reinstall with ./setup or /gstack-upgrade}"
|
||||
SLUG=$(~/.claude/skills/gstack/bin/gstack-slug --get SLUG 2>/dev/null) && mkdir -p "$GSTACK_STATE_ROOT/projects/$SLUG" && echo "PROJECT_DIR: $GSTACK_STATE_ROOT/projects/$SLUG"
|
||||
USER=$(whoami)
|
||||
DATETIME=$(date +%Y%m%d-%H%M%S)
|
||||
\`\`\`
|
||||
|
||||
@@ -111,6 +111,12 @@ export interface TemplateContext {
|
||||
* Terse builds make the compression structural — bytes never ship in the first place.
|
||||
*/
|
||||
explainLevel?: 'default' | 'terse';
|
||||
/**
|
||||
* Install-context render contract (docs/ADDING_A_HOST.md): absolute install
|
||||
* root a per-install render serves (`gen-skill-docs --install-root`).
|
||||
* null/undefined = the host's default root (committed render, unchanged bytes).
|
||||
*/
|
||||
installRoot?: string | null;
|
||||
}
|
||||
|
||||
/** Resolver function signature. args is populated for parameterized placeholders like {{INVOKE_SKILL:name}}. */
|
||||
|
||||
@@ -29,12 +29,12 @@ export function generateClaudeModelFlag(_ctx: TemplateContext): string {
|
||||
}
|
||||
|
||||
export function generateSlugEval(ctx: TemplateContext): string {
|
||||
return `eval "$(${ctx.paths.binDir}/gstack-slug 2>/dev/null)"`;
|
||||
return `SLUG=$(${ctx.paths.binDir}/gstack-slug --get SLUG 2>/dev/null)`;
|
||||
}
|
||||
|
||||
export function generateSlugSetup(ctx: TemplateContext): string {
|
||||
return `eval "$(${ctx.paths.binDir}/gstack-paths)"; : "\${GSTACK_STATE_ROOT:?gstack-paths failed; reinstall with ./setup or /gstack-upgrade}"
|
||||
eval "$(${ctx.paths.binDir}/gstack-slug 2>/dev/null)" && mkdir -p "$GSTACK_STATE_ROOT/projects/$SLUG" && echo "PROJECT_DIR: $GSTACK_STATE_ROOT/projects/$SLUG"`;
|
||||
return `GSTACK_STATE_ROOT=$(${ctx.paths.binDir}/gstack-paths --get GSTACK_STATE_ROOT); : "\${GSTACK_STATE_ROOT:?gstack-paths failed; reinstall with ./setup or /gstack-upgrade}"
|
||||
SLUG=$(${ctx.paths.binDir}/gstack-slug --get SLUG 2>/dev/null) && mkdir -p "$GSTACK_STATE_ROOT/projects/$SLUG" && echo "PROJECT_DIR: $GSTACK_STATE_ROOT/projects/$SLUG"`;
|
||||
}
|
||||
|
||||
export function generateBaseBranchDetect(_ctx: TemplateContext): string {
|
||||
@@ -173,6 +173,7 @@ Visit every reachable page (5-15 minutes). Score health; document 5-10 evidenced
|
||||
|
||||
### Regression (\`--regression <baseline>\`)
|
||||
Run Full; append fixed/new issues and score delta. Preserve the supplied prior baseline.
|
||||
A missing or unreadable baseline is a missing prerequisite: it blocks the comparison, not the Full run.
|
||||
|
||||
## Workflow
|
||||
|
||||
|
||||
+34
-34
@@ -73,6 +73,7 @@
|
||||
* bun run scripts/test-free-shards.ts --wall-timeout 600 # override the kill deadline
|
||||
* bun run scripts/test-free-shards.ts --verbose # forward the full child stream
|
||||
* bun run scripts/test-free-shards.ts --quick # explicit fast subset, not acceptance
|
||||
* bun run scripts/test-free-shards.ts --attribute-home # name real-home writers: each file alone, private HOME
|
||||
* bun run scripts/test-free-shards.ts --ci-plan plan.json --shards 20
|
||||
* bun run scripts/test-free-shards.ts --ci-run plan.json --shard 1 --result result.json
|
||||
* bun run scripts/test-free-shards.ts --ci-verify plan.json --results results/
|
||||
@@ -86,10 +87,12 @@ import { StringDecoder } from 'node:string_decoder';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { isPaidTestFile } from '../test/helpers/paid-test-set';
|
||||
import { resolveStateRoot } from '../lib/state-root';
|
||||
import { attributeFreeHomeWriters, guardFreeHome, sharedFreeHome, type FreeHomeGuardFactory } from './lib/free-home-guard';
|
||||
import {
|
||||
BunTestOutputClassifier,
|
||||
createShardSandbox,
|
||||
exactTestFileSelectors,
|
||||
forEachFileAlone,
|
||||
isTerminationRequested,
|
||||
killProcessGroup,
|
||||
BunFailureSummaryParser,
|
||||
@@ -107,7 +110,6 @@ import {
|
||||
type LanePolicy,
|
||||
type ShardChildResult,
|
||||
} from './lib/shard-engine';
|
||||
|
||||
export { normalizeRelativePath } from './lib/shard-engine';
|
||||
|
||||
/**
|
||||
@@ -354,6 +356,7 @@ export const KNOWN_WINDOWS_INCOMPATIBLE: Array<{ file: string; reason: string }>
|
||||
// pattern hit is a false positive — the point of these files is Windows
|
||||
// coverage, so auto-excluding them defeats the regression tests they carry.
|
||||
const KNOWN_WINDOWS_SAFE: Array<{ file: string; reason: string }> = [
|
||||
{ file: 'test/ship-hook-windows-paths.test.ts', reason: 'runs bin/ helpers through explicit bash and Bun argv with forward-slash paths; never executes a shebang; the path-spelling simulation is skipIf win32' },
|
||||
{
|
||||
file: 'test/state-root-parity.test.ts',
|
||||
reason: 'runs the bash twin and lib/state-root.ts over an env table with PATH empty; no shebang execution, raw-string comparison is platform-neutral',
|
||||
@@ -384,8 +387,7 @@ const KNOWN_WINDOWS_SAFE: Array<{ file: string; reason: string }> = [
|
||||
},
|
||||
{
|
||||
file: 'test/claude-code-runner.test.ts',
|
||||
// The bin/ path is launched through process.execPath (Bun), never as a
|
||||
// shebang executable. Keep taskkill tree supervision in the Windows lane.
|
||||
// The bin/ path is launched through process.execPath (Bun), never as a shebang; keep taskkill supervision in the Windows lane.
|
||||
reason: 'invokes the runner via Bun argv; fake CLI and timeout descendant assertions cover native Windows taskkill',
|
||||
},
|
||||
{
|
||||
@@ -902,6 +904,7 @@ type CliOptions = {
|
||||
/** True when --wall-timeout was passed explicitly; full-suite mode only auto-scales the default. */
|
||||
wallTimeoutExplicit: boolean;
|
||||
quick: boolean;
|
||||
attributeHome: boolean;
|
||||
ciPlan: string | null;
|
||||
ciRun: string | null;
|
||||
ciVerify: string | null;
|
||||
@@ -920,6 +923,7 @@ export function parseCliOptions(argv: string[]): CliOptions {
|
||||
let wallTimeoutMs = DEFAULT_WALL_TIMEOUT_MS;
|
||||
let wallTimeoutExplicit = false;
|
||||
let quick = false;
|
||||
let attributeHome = false;
|
||||
const paths: Record<'ciPlan' | 'ciRun' | 'ciVerify' | 'result' | 'results', string | null> = {
|
||||
ciPlan: null, ciRun: null, ciVerify: null, result: null, results: null,
|
||||
};
|
||||
@@ -936,6 +940,7 @@ export function parseCliOptions(argv: string[]): CliOptions {
|
||||
'--windows-only': () => { windowsOnly = true; },
|
||||
'--verbose': () => { verbose = true; },
|
||||
'--quick': () => { quick = true; },
|
||||
'--attribute-home': () => { attributeHome = true; },
|
||||
'--ci-plan': pathFlag('--ci-plan', 'ciPlan'),
|
||||
'--ci-run': pathFlag('--ci-run', 'ciRun'),
|
||||
'--ci-verify': pathFlag('--ci-verify', 'ciVerify'),
|
||||
@@ -960,11 +965,11 @@ export function parseCliOptions(argv: string[]): CliOptions {
|
||||
});
|
||||
|
||||
const ciModes = [paths.ciPlan, paths.ciRun, paths.ciVerify].filter(Boolean).length;
|
||||
if (ciModes > 1 || (ciModes && (quick || listOnly || dryRun || recordDurations || windowsOnly))) throw new Error('CI modes cannot be combined with other selection modes');
|
||||
if (ciModes > 1 || (ciModes && (quick || listOnly || dryRun || recordDurations || windowsOnly || attributeHome))) throw new Error('CI modes cannot be combined with other selection modes');
|
||||
if (paths.ciRun && (shardIndex === null || !paths.result)) throw new Error('--ci-run requires --shard and --result');
|
||||
if (paths.ciVerify && !paths.results) throw new Error('--ci-verify requires --results');
|
||||
if (quick && (recordDurations || windowsOnly || shardIndex !== null)) throw new Error('--quick cannot change recording, Windows or shard selection');
|
||||
return { dryRun, listOnly, recordDurations, windowsOnly, verbose, shardCount, shardIndex, wallTimeoutMs, wallTimeoutExplicit, quick, ...paths };
|
||||
return { dryRun, listOnly, recordDurations, windowsOnly, verbose, shardCount, shardIndex, wallTimeoutMs, wallTimeoutExplicit, quick, attributeHome, ...paths };
|
||||
}
|
||||
|
||||
function formatShardSummary(shards: string[][]): string[] {
|
||||
@@ -1394,6 +1399,8 @@ export interface RunFreeShardOptions {
|
||||
/** Per-run full-stream log path (tests inject). Default: a private retained file under .context/free-test-logs. */
|
||||
logFilePath?: string;
|
||||
log?: (line: string) => void;
|
||||
/** Home tripwire for this shard. Default: guard the real home and name this shard's files. */
|
||||
homeGuard?: FreeHomeGuardFactory;
|
||||
}
|
||||
|
||||
const EPILOGUE_WORD: Record<FreeShardStatus, string> = {
|
||||
@@ -1810,7 +1817,7 @@ function explainFreeVerdict(label: string, status: FreeShardStatus, facts: {
|
||||
summary: ReturnType<BunTestOutputClassifier['end']>; expectedFiles: number; wallTimeoutMs: number;
|
||||
}): void {
|
||||
const { summary, exitCode } = facts;
|
||||
if (facts.cleanupError) console.error(`${label} browser cleanup failed: ${facts.cleanupError}; retained ${facts.stateDir}`);
|
||||
if (facts.cleanupError) console.error(`${label} shard cleanup or home containment failed: ${facts.cleanupError}; retained ${facts.stateDir}`);
|
||||
if (status === 'timed-out') {
|
||||
console.error(
|
||||
`${label} exceeded the ${Math.round(facts.wallTimeoutMs / 1000)}s wall-clock deadline — `
|
||||
@@ -1832,7 +1839,7 @@ function explainFreeVerdict(label: string, status: FreeShardStatus, facts: {
|
||||
function logFreeRecovery(log: (line: string) => void, outcome: FreeShardOutcome, facts: {
|
||||
cleanupError: string | null; logWriteFailed: boolean; captureIncomplete: boolean; rootDir: string;
|
||||
}): void {
|
||||
const problem = facts.cleanupError ? 'Owned-process cleanup is unconfirmed; inspect the retained state before another run.'
|
||||
const problem = facts.cleanupError ? 'Owned-process cleanup or home containment is unconfirmed; inspect the reported paths and retained state before another run.'
|
||||
: facts.logWriteFailed ? 'The evidence log could not be retained; repair the log destination before another run.'
|
||||
: facts.captureIncomplete ? 'Evidence capture is incomplete; repair the stream or early exit before another run.'
|
||||
: outcome.status === 'timed-out' ? 'Execution exceeded its deadline; inspect the last completed step before changing code or rerunning.'
|
||||
@@ -1915,6 +1922,7 @@ export async function runFreeShard(
|
||||
// shards and prior daemons replace each other's state; override inherited state.
|
||||
env.BROWSE_STATE_FILE = path.join(stateDir, '.gstack', 'browse.json');
|
||||
env.GSTACK_FREE_SHARD_ID = randomUUID();
|
||||
const home = options.homeGuard ? options.homeGuard(files, env, stateDir) : guardFreeHome(files, env);
|
||||
|
||||
const startedAt = Date.now();
|
||||
const classifier = new BunTestOutputClassifier();
|
||||
@@ -1953,7 +1961,7 @@ export async function runFreeShard(
|
||||
command, args, cwd: rootDir, env, timeoutMs: wallTimeoutMs,
|
||||
attach: () => {
|
||||
const browser = trackShardBrowser(stateDir, env);
|
||||
return { signal: (force) => browser.signal(force), settle: async () => { cleanupError = await browser.settle(); } };
|
||||
return { signal: (force) => browser.signal(force), settle: async () => { cleanupError = await browser.settle() ?? home.verify(); } };
|
||||
},
|
||||
hookStreams: (spawned) => [consumeStream(spawned.stdout, 'stdout'), consumeStream(spawned.stderr, 'stderr')],
|
||||
});
|
||||
@@ -2053,30 +2061,18 @@ async function recordFreeTestDurations(files: string[], jobs: number): Promise<n
|
||||
const durations: Record<string, number> = {};
|
||||
const failed: string[] = [];
|
||||
const expectedCount = files.length;
|
||||
let cursor = 0;
|
||||
console.log(`[test:free] recording per-file durations: ${files.length} files across ${jobs} workers`);
|
||||
const worker = async (): Promise<void> => {
|
||||
for (;;) {
|
||||
if (isTerminationRequested()) return;
|
||||
const index = cursor;
|
||||
cursor += 1;
|
||||
if (index >= files.length) return;
|
||||
const file = files[index];
|
||||
const outcome = await runFreeShard([file], index + 1, files.length, {
|
||||
wallTimeoutMs: wallTimeoutForShard(1),
|
||||
quiet: true,
|
||||
});
|
||||
durations[normalizeRelativePath(file)] = outcome.elapsedMs;
|
||||
if (outcome.status !== 'passed') failed.push(file);
|
||||
}
|
||||
};
|
||||
const phaseHome = guardFreeHome(files, process.env, { kind: 'concurrent', shards: jobs });
|
||||
// As in full-suite mode, finish parallel work before exclusive host-state fixtures.
|
||||
const exclusive = files.filter(file => file in TREE_MUTATING);
|
||||
files = files.filter(file => !(file in TREE_MUTATING));
|
||||
await Promise.all(Array.from({ length: Math.max(1, jobs) }, () => worker()));
|
||||
files = exclusive;
|
||||
cursor = 0;
|
||||
await worker();
|
||||
await forEachFileAlone(files.filter(file => !(file in TREE_MUTATING)), files.filter(file => file in TREE_MUTATING), jobs, async (file, index) => {
|
||||
const outcome = await runFreeShard([file], index + 1, files.length, {
|
||||
wallTimeoutMs: wallTimeoutForShard(1), quiet: true, homeGuard: sharedFreeHome,
|
||||
});
|
||||
durations[normalizeRelativePath(file)] = outcome.elapsedMs;
|
||||
if (outcome.status !== 'passed') failed.push(file);
|
||||
});
|
||||
const homeChange = phaseHome.verify();
|
||||
if (homeChange) console.error(`[test:free] ${homeChange}`);
|
||||
if (Object.keys(durations).length !== expectedCount) {
|
||||
throw new Error('Duration recording was interrupted; the seed was not replaced.');
|
||||
}
|
||||
@@ -2085,7 +2081,7 @@ async function recordFreeTestDurations(files: string[], jobs: number): Promise<n
|
||||
// Atomic: a killed recorder never leaves a truncated seed behind.
|
||||
writeDurationSeed(target, durations);
|
||||
console.log(`[test:free] wrote ${Object.keys(durations).length} durations to ${path.relative(ROOT, target)}`);
|
||||
if (failed.length > 0) {
|
||||
if (failed.length > 0 || homeChange) {
|
||||
// Failures still recorded (a red file's duration is still a real cost),
|
||||
// but surfaced loudly — recording from a broken tree deserves a look.
|
||||
console.error(`[test:free] WARNING: ${failed.length} file(s) failed while recording:`);
|
||||
@@ -2245,6 +2241,8 @@ async function main(): Promise<number> {
|
||||
if (options.recordDurations) {
|
||||
return recordFreeTestDurations(files, fullSuiteJobs());
|
||||
}
|
||||
if (options.attributeHome) return attributeFreeHomeWriters(files.filter(file => !(file in TREE_MUTATING)), files.filter(file => file in TREE_MUTATING),
|
||||
fullSuiteJobs(), (file, index, homeGuard) => runFreeShard([file], index + 1, files.length, { wallTimeoutMs: wallTimeoutForShard(1), quiet: true, log: () => {}, homeGuard }));
|
||||
|
||||
if (options.dryRun) {
|
||||
const shards = assignFilesToShards(files, options.shardCount);
|
||||
@@ -2305,8 +2303,10 @@ async function main(): Promise<number> {
|
||||
}
|
||||
const shardTimeout = (fileCount: number): number =>
|
||||
options.wallTimeoutExplicit ? options.wallTimeoutMs : wallTimeoutForShard(fileCount, options.wallTimeoutMs);
|
||||
const phaseHome = guardFreeHome(readers, process.env, { kind: 'concurrent', shards: shards.length });
|
||||
const outcomes = await Promise.all(
|
||||
shards.map((shardFiles, index) => runFreeShard(shardFiles, index + 1, totalShards, {
|
||||
homeGuard: sharedFreeHome,
|
||||
// Packed shards get duration-aware walls: LPT decouples file count from
|
||||
// cost BY DESIGN, so the 5s/file heuristic would undersize a shard
|
||||
// holding few expensive files.
|
||||
@@ -2316,7 +2316,8 @@ async function main(): Promise<number> {
|
||||
verbose: options.verbose,
|
||||
})),
|
||||
);
|
||||
let worst = Math.max(...outcomes.map((o) => exitCodeFor(o.status)));
|
||||
const homeChange = phaseHome.verify();
|
||||
if (homeChange) console.error(`[test:free] ${homeChange}`);
|
||||
// Cancellation stops the run: don't launch the exclusive host-state shard
|
||||
// after a SIGINT/SIGTERM already killed the parallel phase.
|
||||
if (exclusive.length > 0 && !isTerminationRequested()) {
|
||||
@@ -2324,7 +2325,6 @@ async function main(): Promise<number> {
|
||||
wallTimeoutMs: shardTimeout(exclusive.length),
|
||||
verbose: options.verbose,
|
||||
});
|
||||
worst = Math.max(worst, exitCodeFor(exclusiveOutcome.status));
|
||||
if (exclusiveOutcome.status !== 'passed') {
|
||||
// Fixture safety rests on each test restoring default state itself; a
|
||||
// SIGKILL at the wall deadline (or a mid-regeneration crash) defeats
|
||||
@@ -2340,7 +2340,7 @@ async function main(): Promise<number> {
|
||||
outcomes.push(exclusiveOutcome);
|
||||
}
|
||||
|
||||
return (await retryFailedFreeFiles(outcomes, totalShards, options)).exitCode;
|
||||
return Math.max(homeChange ? 1 : 0, (await retryFailedFreeFiles(outcomes, totalShards, options)).exitCode);
|
||||
}
|
||||
|
||||
/** Dirty generated artifacts (SKILL.md / host outputs) after a failed exclusive shard. */
|
||||
|
||||
@@ -105,7 +105,7 @@ import {
|
||||
export { PAID_TEST_GLOBS, isPaidTestFile };
|
||||
export { PERIODIC_CI_EXCLUDE };
|
||||
|
||||
import { shardFile, shardCaseId, shardTrial, trialShardKey, type CaseTrialPlan, caseTrialPlan, excludedCasesNamePattern, caseTestNamePattern, expandCaseShards, expandTrialShards, partitionCaseExclusions } from './lib/paid-cases';
|
||||
import { scopeCodexAccess, shardFile, shardCaseId, shardTrial, trialShardKey, type CaseTrialPlan, caseTrialPlan, excludedCasesNamePattern, caseTestNamePattern, expandCaseShards, expandTrialShards, partitionCaseExclusions } from './lib/paid-cases';
|
||||
import { retriesForFiles, trialPanelKey, sliceExecutionOrder, buildRunManifest, parseRunManifest, type SliceResult, sliceExitCode, guardTrialRecords, formatSlicePlan, formatCapacityPreflight } from './lib/paid-plan';
|
||||
import { caseFile, runCaseDiagnosis, formatPanelLine, runPaidReport } from './lib/paid-report';
|
||||
export * from './lib/paid-cases';
|
||||
@@ -953,6 +953,7 @@ export async function runPaidShard(
|
||||
const expectedCases = options.expectedCases ?? (caseId !== null ? { [files[0]!]: 1 } : undefined);
|
||||
|
||||
const baseEnv = { ...(options.env ?? process.env) };
|
||||
scopeCodexAccess(baseEnv, files.map(shardFile));
|
||||
if (options.evalDirBase) {
|
||||
baseEnv.GSTACK_EVAL_DIR = path.join(options.evalDirBase, 'shards', shardSlug(files));
|
||||
}
|
||||
|
||||
@@ -116,6 +116,7 @@ function matches(file: string, patterns: readonly string[]): boolean {
|
||||
|
||||
export const FREE_ONLY_PR_FILES = [
|
||||
'scripts/test-free-shards.ts',
|
||||
'scripts/lib/free-home-guard.ts', // Imported only by the free shard runner.
|
||||
'test/helpers/auq-parallel-worker.ts',
|
||||
// Read only by free tests (context-budget ratchet, host-config goldens), never by a paid case.
|
||||
'test/fixtures/context-budget.json',
|
||||
|
||||
@@ -68,9 +68,6 @@
|
||||
"ios-qa/daemon/test/tunnel-bootstrap.test.ts\tTS2352\tConversion of type '() => Promise<Response>' to type 'typeof fetch' may be a mistake because neither type sufficiently overlaps with the other. If this was intentional, convert the expression to 'unknown' first. Property 'preconnect' is missing in type '() => Promise<Response>' but required in type 'typeof fetch'.": 5,
|
||||
"ios-qa/daemon/test/tunnel-bootstrap.test.ts\tTS2352\tConversion of type '() => Promise<never>' to type 'typeof fetch' may be a mistake because neither type sufficiently overlaps with the other. If this was intentional, convert the expression to 'unknown' first. Property 'preconnect' is missing in type '() => Promise<never>' but required in type 'typeof fetch'.": 1,
|
||||
"make-pdf/test/diagram-prepass.test.ts\tTS2769\tNo overload matches this call. The last overload gave the following error. Argument of type 'string | undefined' is not assignable to parameter of type 'string'. Type 'undefined' is not assignable to type 'string'.": 1,
|
||||
"make-pdf/test/e2e/diagram-gate.test.ts\tTS2345\tArgument of type '\"pdftotext\"' is not assignable to parameter of type '\"pdffonts\" | \"pdfimages\" | \"pdftoppm\"'.": 2,
|
||||
"make-pdf/test/e2e/landscape-gate.test.ts\tTS2345\tArgument of type '\"pdfinfo\"' is not assignable to parameter of type '\"pdffonts\" | \"pdfimages\" | \"pdftoppm\"'.": 2,
|
||||
"make-pdf/test/e2e/landscape-gate.test.ts\tTS2345\tArgument of type '\"pdftotext\"' is not assignable to parameter of type '\"pdffonts\" | \"pdfimages\" | \"pdftoppm\"'.": 3,
|
||||
"test/auto-decide-fixture.test.ts\tTS7006\tParameter 'fact' implicitly has an 'any' type.": 1,
|
||||
"test/autoplan-dual-voice-evidence.test.ts\tTS2339\tProperty 'content' does not exist on type '{ type: string; id: string; name: string; input: any; } | { type: string; tool_use_id: string; content: string; is_error: boolean; }'. Property 'content' does not exist on type '{ type: string; id: string; name: string; input: any; }'.": 1,
|
||||
"test/autoplan-dual-voice-evidence.test.ts\tTS2339\tProperty 'id' does not exist on type '{ type: string; id: string; name: string; input: { command: string; description: string; }; } | { type: string; tool_use_id: string; content: string; is_error: boolean; } | { type: string; id: string; name: string; input: { ...; }; } | { ...; } | { ...; } | { ...; }'. Property 'id' does not exist on type '{ type: string; tool_use_id: string; content: string; is_error: boolean; }'.": 2,
|
||||
|
||||
Reference in new issue
Block a user