mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-09 22:48:57 +02:00
Merge remote-tracking branch 'origin/main' into garrytan/gbrain-code-smell-audit
# Conflicts: # CHANGELOG.md # browse/test/dual-listener.test.ts # browse/test/fixtures/security-bench-haiku-responses.json # browse/test/sidebar-tabs.test.ts # browse/test/sidebar-ux.test.ts # browse/test/terminal-agent.test.ts # claude/SKILL.md.tmpl # scripts/gen-skill-docs.ts # scripts/proactive-suggestions.json # spec/SKILL.md # test/gen-skill-docs.test.ts # test/host-config.test.ts
This commit is contained in:
@@ -317,12 +317,16 @@ export function splitCatalogDescription(description: string): CatalogParts {
|
||||
const hasGstackTag = /\(gstack\)/.test(working);
|
||||
if (hasGstackTag) working = working.replace(/\(gstack\)/, '').trim();
|
||||
|
||||
// Lead = first sentence (up to first period followed by space or end of string).
|
||||
// We tolerate sentences with embedded periods (URLs, "v1.45.0.0") by requiring
|
||||
// the period to be followed by whitespace OR end-of-text.
|
||||
// Lead = first sentence, ending at the first `.`/`!`/`?` that is followed by
|
||||
// whitespace or end-of-text. Terminator chars NOT followed by whitespace/end
|
||||
// (embedded periods in "TODOS.md", URLs, "v1.45.0.0") are consumed by the
|
||||
// second alternative `[.!?](?!\s|$)` and do NOT end the sentence. The two
|
||||
// alternatives are disjoint character classes, so there is no ambiguity and
|
||||
// no catastrophic-backtracking risk. If no terminator-followed-by-boundary
|
||||
// exists at all, we fall back to a 20-word cut below.
|
||||
// First normalize to single-line for sentence detection, then back out.
|
||||
const collapsed = working.replace(/\s+/g, ' ').trim();
|
||||
const sentenceMatch = collapsed.match(/^([^.!?]*[.!?])(?:\s|$)/);
|
||||
const sentenceMatch = collapsed.match(/^((?:[^.!?]|[.!?](?!\s|$))*[.!?])(?:\s|$)/);
|
||||
// sentenceLead is the FULL first sentence (no truncation). We compute routing
|
||||
// from this position, then optionally truncate the displayed lead afterwards.
|
||||
// Truncating first then computing routing was the v1.45.0.0 bug — when the
|
||||
@@ -793,7 +797,14 @@ function processExternalHost(
|
||||
}
|
||||
|
||||
function processTemplate(tmplPath: string, host: Host = 'claude'): { outputPath: string; content: string; symlinkLoop?: boolean } {
|
||||
const tmplContent = fs.readFileSync(tmplPath, 'utf-8');
|
||||
// Normalize to LF at the entry point. Templates may have CRLF on disk when
|
||||
// checked out on Windows with core.autocrlf=true. Downstream regexes
|
||||
// (processVoiceTriggers, transformFrontmatter) hardcode \n, so without
|
||||
// normalization they silently no-op on CRLF — producing different output
|
||||
// than CI (Linux, LF) and breaking the Skill Docs Freshness check.
|
||||
// (catalogParts left the return type with the proactive-suggestions
|
||||
// retirement — merge of the two v1.64 waves.)
|
||||
const tmplContent = fs.readFileSync(tmplPath, 'utf-8').replace(/\r\n/g, '\n');
|
||||
const relTmplPath = path.relative(ROOT, tmplPath);
|
||||
let outputPath = tmplPath.replace(/\.tmpl$/, '');
|
||||
|
||||
|
||||
@@ -14,6 +14,9 @@
|
||||
export const ALL_MODEL_NAMES = [
|
||||
'claude',
|
||||
'opus-4-7',
|
||||
'fable-5',
|
||||
'opus-4-8',
|
||||
'sonnet-5',
|
||||
'gpt',
|
||||
'gpt-5.4',
|
||||
'gemini',
|
||||
@@ -53,6 +56,9 @@ export function resolveModel(input: string): Model | null {
|
||||
if (/^gpt(-|$)/.test(s)) return 'gpt';
|
||||
if (/^o[0-9]+(-|$)/.test(s)) return 'o-series';
|
||||
if (/^claude-opus-4-7(-|$)/.test(s)) return 'opus-4-7';
|
||||
if (/^claude-fable-5(-|$)/.test(s)) return 'fable-5';
|
||||
if (/^claude-opus-4-8(-|$)/.test(s)) return 'opus-4-8';
|
||||
if (/^claude-sonnet-5(-|$)/.test(s)) return 'sonnet-5';
|
||||
if (/^claude(-|$)/.test(s)) return 'claude';
|
||||
if (/^gemini(-|$)/.test(s)) return 'gemini';
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { TemplateContext } from './types';
|
||||
import { type TemplateContext, toShellPath } from './types';
|
||||
import { COMMAND_DESCRIPTIONS } from '../../browse/src/commands';
|
||||
import { SNAPSHOT_FLAGS } from '../../browse/src/snapshot';
|
||||
|
||||
@@ -106,7 +106,7 @@ export function generateBrowseSetup(ctx: TemplateContext): string {
|
||||
_ROOT=$(git rev-parse --show-toplevel 2>/dev/null)
|
||||
B=""
|
||||
[ -n "$_ROOT" ] && [ -x "$_ROOT/${ctx.paths.localSkillRoot}/browse/dist/browse" ] && B="$_ROOT/${ctx.paths.localSkillRoot}/browse/dist/browse"
|
||||
[ -z "$B" ] && B="$HOME${ctx.paths.browseDir.replace(/^~/, '')}/browse"
|
||||
[ -z "$B" ] && B="${toShellPath(ctx.paths.browseDir)}/browse"
|
||||
if [ -x "$B" ]; then
|
||||
echo "READY: $B"
|
||||
else
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { TemplateContext } from './types';
|
||||
import { type TemplateContext, toShellPath } from './types';
|
||||
import { AI_SLOP_BLACKLIST, OPENAI_HARD_REJECTIONS, OPENAI_LITMUS_CHECKS } from './constants';
|
||||
|
||||
export function generateDesignReviewLite(ctx: TemplateContext): string {
|
||||
@@ -792,7 +792,7 @@ export function generateDesignSetup(ctx: TemplateContext): string {
|
||||
_ROOT=$(git rev-parse --show-toplevel 2>/dev/null)
|
||||
D=""
|
||||
[ -n "$_ROOT" ] && [ -x "$_ROOT/${ctx.paths.localSkillRoot}/design/dist/design" ] && D="$_ROOT/${ctx.paths.localSkillRoot}/design/dist/design"
|
||||
[ -z "$D" ] && D="$HOME${ctx.paths.designDir.replace(/^~/, '')}/design"
|
||||
[ -z "$D" ] && D="${toShellPath(ctx.paths.designDir)}/design"
|
||||
if [ -x "$D" ]; then
|
||||
echo "DESIGN_READY: $D"
|
||||
else
|
||||
@@ -800,7 +800,7 @@ else
|
||||
fi
|
||||
B=""
|
||||
[ -n "$_ROOT" ] && [ -x "$_ROOT/${ctx.paths.localSkillRoot}/browse/dist/browse" ] && B="$_ROOT/${ctx.paths.localSkillRoot}/browse/dist/browse"
|
||||
[ -z "$B" ] && B="$HOME${ctx.paths.browseDir.replace(/^~/, '')}/browse"
|
||||
[ -z "$B" ] && B="${toShellPath(ctx.paths.browseDir)}/browse"
|
||||
if [ -x "$B" ]; then
|
||||
echo "BROWSE_READY: $B"
|
||||
else
|
||||
@@ -837,7 +837,7 @@ export function generateDesignMockup(ctx: TemplateContext): string {
|
||||
_ROOT=$(git rev-parse --show-toplevel 2>/dev/null)
|
||||
D=""
|
||||
[ -n "$_ROOT" ] && [ -x "$_ROOT/${ctx.paths.localSkillRoot}/design/dist/design" ] && D="$_ROOT/${ctx.paths.localSkillRoot}/design/dist/design"
|
||||
[ -z "$D" ] && D="$HOME${ctx.paths.designDir.replace(/^~/, '')}/design"
|
||||
[ -z "$D" ] && D="${toShellPath(ctx.paths.designDir)}/design"
|
||||
[ -x "$D" ] && echo "DESIGN_READY" || echo "DESIGN_NOT_AVAILABLE"
|
||||
\`\`\`
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { TemplateContext } from './types';
|
||||
import { type TemplateContext, toShellPath } from './types';
|
||||
|
||||
/**
|
||||
* {{MAKE_PDF_SETUP}} — emits the shell preamble that resolves $P to the
|
||||
@@ -19,7 +19,7 @@ _ROOT=$(git rev-parse --show-toplevel 2>/dev/null)
|
||||
P=""
|
||||
[ -n "$MAKE_PDF_BIN" ] && [ -x "$MAKE_PDF_BIN" ] && P="$MAKE_PDF_BIN"
|
||||
[ -z "$P" ] && [ -n "$_ROOT" ] && [ -x "$_ROOT/${ctx.paths.localSkillRoot}/make-pdf/dist/pdf" ] && P="$_ROOT/${ctx.paths.localSkillRoot}/make-pdf/dist/pdf"
|
||||
[ -z "$P" ] && P="$HOME${ctx.paths.makePdfDir.replace(/^~/, '')}/pdf"
|
||||
[ -z "$P" ] && P="${toShellPath(ctx.paths.makePdfDir)}/pdf"
|
||||
if [ -x "$P" ]; then
|
||||
echo "MAKE_PDF_READY: $P"
|
||||
alias _p_="$P" # shellcheck alias helper (not exported)
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
* to `gstack-brain-sync --discover-new` + `--once`.
|
||||
*/
|
||||
import type { TemplateContext } from '../types';
|
||||
import { quoteSafePath } from '../types';
|
||||
|
||||
export function generateBrainSyncBlock(ctx: TemplateContext): string {
|
||||
const isBrainHost = ctx.host === 'gbrain' || ctx.host === 'hermes';
|
||||
@@ -42,8 +43,8 @@ if [ -f "$HOME/.gstack-artifacts-remote.txt" ]; then
|
||||
else
|
||||
_BRAIN_REMOTE_FILE="$HOME/.gstack-brain-remote.txt"
|
||||
fi
|
||||
_BRAIN_SYNC_BIN="${ctx.paths.binDir}/gstack-brain-sync"
|
||||
_BRAIN_CONFIG_BIN="${ctx.paths.binDir}/gstack-config"
|
||||
_BRAIN_SYNC_BIN="${quoteSafePath(ctx.paths.binDir)}/gstack-brain-sync"
|
||||
_BRAIN_CONFIG_BIN="${quoteSafePath(ctx.paths.binDir)}/gstack-config"
|
||||
|
||||
# /sync-gbrain context-load: teach the agent to use gbrain when it's available.
|
||||
# Per-worktree pin: post-spike redesign uses kubectl-style \`.gbrain-source\` in the
|
||||
@@ -152,8 +153,8 @@ If A/B and \`~/.gstack/.git\` is missing, ask whether to run \`gstack-artifacts-
|
||||
At skill END before telemetry:
|
||||
|
||||
\`\`\`bash
|
||||
"${ctx.paths.binDir}/gstack-brain-sync" --discover-new 2>/dev/null || true
|
||||
"${ctx.paths.binDir}/gstack-brain-sync" --once 2>/dev/null || true
|
||||
"${quoteSafePath(ctx.paths.binDir)}/gstack-brain-sync" --discover-new 2>/dev/null || true
|
||||
"${quoteSafePath(ctx.paths.binDir)}/gstack-brain-sync" --once 2>/dev/null || true
|
||||
\`\`\`
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -73,11 +73,15 @@ fi
|
||||
if [ "$_TEL" != "off" ] && [ -x ~/.claude/skills/gstack/bin/gstack-telemetry-log ]; then
|
||||
~/.claude/skills/gstack/bin/gstack-telemetry-log \\
|
||||
--skill "SKILL_NAME" --duration "$_TEL_DUR" --outcome "OUTCOME" \\
|
||||
--used-browse "USED_BROWSE" --session-id "$_SESSION_ID" 2>/dev/null &
|
||||
--used-browse "USED_BROWSE" --session-id "$_SESSION_ID" \\
|
||||
--error-message "ERROR_MESSAGE" --failed-step "FAILED_STEP" 2>/dev/null &
|
||||
fi
|
||||
\`\`\`
|
||||
|
||||
Replace \`SKILL_NAME\`, \`OUTCOME\`, and \`USED_BROWSE\` before running.
|
||||
Replace \`ERROR_MESSAGE\` with a short description of the error (if outcome is error,
|
||||
otherwise use empty string ""), and \`FAILED_STEP\` with the step name or number where
|
||||
the failure occurred (if outcome is error, otherwise use empty string "").
|
||||
|
||||
## Plan Status Footer
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { TemplateContext } from '../types';
|
||||
import { quoteSafePath } from '../types';
|
||||
import { getHostConfig } from '../../../hosts/index';
|
||||
|
||||
export function generatePreambleBash(ctx: TemplateContext): string {
|
||||
@@ -67,13 +68,15 @@ if [ "$_EXPLAIN_LEVEL" != "default" ] && [ "$_EXPLAIN_LEVEL" != "terse" ]; then
|
||||
echo "EXPLAIN_LEVEL: $_EXPLAIN_LEVEL"
|
||||
_QUESTION_TUNING=$(${ctx.paths.binDir}/gstack-config get question_tuning 2>/dev/null || echo "false")
|
||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||
_UPDATE_CHECK=$(${ctx.paths.binDir}/gstack-config get update_check 2>/dev/null || echo "true")
|
||||
echo "UPDATE_CHECK: $_UPDATE_CHECK"
|
||||
mkdir -p ~/.gstack/analytics
|
||||
if [ "$_TEL" != "off" ]; then
|
||||
echo '{"skill":"${ctx.skillName}","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(_repo=$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null | tr -cd 'a-zA-Z0-9._-'); echo "\${_repo:-unknown}")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
||||
fi
|
||||
for _PF in $(find ~/.gstack/analytics -maxdepth 1 -name '.pending-*' 2>/dev/null); do
|
||||
if [ -f "$_PF" ]; then
|
||||
if [ "$_TEL" != "off" ] && [ -x "${ctx.paths.binDir}/gstack-telemetry-log" ]; then
|
||||
if [ "$_TEL" != "off" ] && [ -x "${quoteSafePath(ctx.paths.binDir)}/gstack-telemetry-log" ]; then
|
||||
${ctx.paths.binDir}/gstack-telemetry-log --event-type skill_run --skill _pending_finalize --outcome unknown --session-id "$_SESSION_ID" 2>/dev/null || true
|
||||
fi
|
||||
rm -f "$_PF" 2>/dev/null || true
|
||||
|
||||
@@ -5,6 +5,8 @@ export function generateUpgradeCheck(ctx: TemplateContext): string {
|
||||
|
||||
If \`SKILL_PREFIX\` is \`"true"\`, suggest/invoke \`/gstack-*\` names. Disk paths stay \`${ctx.paths.skillRoot}/[skill-name]/SKILL.md\`.
|
||||
|
||||
If \`UPDATE_CHECK\` is \`"false"\`, skip the next two lines — the update-check binary emits nothing in that mode, so there is no \`UPGRADE_AVAILABLE\` / \`JUST_UPGRADED\` output to act on.
|
||||
|
||||
If output shows \`UPGRADE_AVAILABLE <old> <new>\`: read \`${ctx.paths.skillRoot}/gstack-upgrade/SKILL.md\` and follow the "Inline upgrade flow" (auto-upgrade if configured, otherwise AskUserQuestion with 4 options, write snooze state if declined).
|
||||
|
||||
If output shows \`JUST_UPGRADED <from> <to>\`: print "Running gstack v{to} (just updated!)". If \`SPAWNED_SESSION\` is true, skip feature discovery.
|
||||
|
||||
@@ -129,7 +129,7 @@ CHECKLIST:
|
||||
|
||||
**Subagent configuration:**
|
||||
- Use \`subagent_type: "general-purpose"\`
|
||||
- Do NOT use \`run_in_background\` — all specialists must complete before merge
|
||||
- Pass \`run_in_background: false\` on every specialist Agent call — subagents run in the BACKGROUND by default since Claude Code v2.1.198, and all specialists must complete before merge. (Merely omitting the flag no longer produces a foreground run; it must be explicitly false.)
|
||||
- If any specialist subagent fails or times out, log the failure and continue with results from successful specialists. Specialists are additive — partial results are better than no results.`;
|
||||
}
|
||||
|
||||
|
||||
@@ -352,7 +352,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
|
||||
CODEX_PROMPT_FILE=$(mktemp /tmp/gstack-codex-oh-XXXXXXXX.txt)
|
||||
CODEX_PROMPT_FILE=$(mktemp /tmp/gstack-codex-oh-XXXXXXXX)
|
||||
\`\`\`
|
||||
|
||||
Write the full prompt to this file. **Always start with the filesystem boundary:**
|
||||
@@ -528,10 +528,14 @@ If \`CODEX_MODE\` is \`ready\`:
|
||||
\`\`\`bash
|
||||
TMPERR_ADV=$(mktemp /tmp/codex-adv-XXXXXXXX)
|
||||
_REPO_ROOT=$(git rev-parse --show-toplevel) || { echo "ERROR: not in a git repo" >&2; exit 1; }
|
||||
codex exec "${CODEX_BOUNDARY}Review the changes on this branch against the base branch. Run DIFF_BASE=$(git merge-base origin/<base> HEAD) && git diff "$DIFF_BASE" to see the diff. Your job is to find ways this code will fail in production. Think like an attacker and a chaos engineer. Find edge cases, race conditions, security holes, resource leaks, failure modes, and silent data corruption paths. Be adversarial. Be thorough. No compliments — just the problems. End your output with ONE line in the canonical format \`Recommendation: <action> because <one-line reason naming the most exploitable finding>\`. Generic reasons like 'because it's safer' do not qualify; the reason must point to a specific finding or no-fix rationale." -C "$_REPO_ROOT" -s read-only -c 'model_reasoning_effort="high"' --enable web_search_cached < /dev/null 2>"$TMPERR_ADV"
|
||||
# Shell functions do not survive between Bash blocks, so re-source the probe
|
||||
# here. It defines _gstack_codex_timeout_wrapper (gtimeout -> timeout ->
|
||||
# unwrapped fallback), added in #1056 but never wired into this call site.
|
||||
source ~/.claude/skills/gstack/bin/gstack-codex-probe 2>/dev/null || true
|
||||
_gstack_codex_timeout_wrapper 540 codex exec "${CODEX_BOUNDARY}Review the changes on this branch against the base branch. Run DIFF_BASE=$(git merge-base origin/<base> HEAD) && git diff "$DIFF_BASE" to see the diff. Your job is to find ways this code will fail in production. Think like an attacker and a chaos engineer. Find edge cases, race conditions, security holes, resource leaks, failure modes, and silent data corruption paths. Be adversarial. Be thorough. No compliments — just the problems. End your output with ONE line in the canonical format \`Recommendation: <action> because <one-line reason naming the most exploitable finding>\`. Generic reasons like 'because it's safer' do not qualify; the reason must point to a specific finding or no-fix rationale." -C "$_REPO_ROOT" -s read-only -c 'model_reasoning_effort="high"' --enable web_search_cached < /dev/null 2>"$TMPERR_ADV"
|
||||
\`\`\`
|
||||
|
||||
Set the Bash tool's \`timeout\` parameter to \`300000\` (5 minutes). Do NOT use the \`timeout\` shell command — it doesn't exist on macOS. After the command completes, read stderr:
|
||||
Set the Bash tool's \`timeout\` parameter to \`600000\` (10 minutes). It sits ABOVE the 540s wrapper deliberately, so the wrapper fires first and a stall surfaces as a diagnosable exit 124 instead of a harness kill that returns nothing. The wrapper resolves \`gtimeout\`, then \`timeout\`, then runs unwrapped, so it is safe on a macOS without coreutils. After the command completes, read stderr:
|
||||
\`\`\`bash
|
||||
cat "$TMPERR_ADV"
|
||||
\`\`\`
|
||||
@@ -540,7 +544,7 @@ Present the full output verbatim. This is informational — it never blocks ship
|
||||
|
||||
**Error handling:** All errors are non-blocking — adversarial review is a quality enhancement, not a prerequisite.
|
||||
- **Auth failure:** If stderr contains "auth", "login", "unauthorized", or "API key": "Codex authentication failed. Run \\\`codex login\\\` to authenticate."
|
||||
- **Timeout:** "Codex timed out after 5 minutes."
|
||||
- **Timeout (exit 124):** "Codex exceeded 9 minutes and was terminated; this pass produced NO findings." A timed-out pass is MISSING COVERAGE, not a clean bill — say so explicitly rather than continuing as if Codex had reviewed. Whatever it produced before the cut is recoverable from that run's rollout log under \`~/.codex/sessions/<YYYY>/<MM>/<DD>/\`.
|
||||
- **Empty response:** "Codex returned no response. Stderr: <paste relevant error>."
|
||||
|
||||
**Cleanup:** Run \`rm -f "$TMPERR_ADV"\` after processing.
|
||||
@@ -557,10 +561,16 @@ If \`DIFF_TOTAL >= 200\` AND \`CODEX_MODE\` is \`ready\`:
|
||||
TMPERR=$(mktemp /tmp/codex-review-XXXXXXXX)
|
||||
_REPO_ROOT=$(git rev-parse --show-toplevel) || { echo "ERROR: not in a git repo" >&2; exit 1; }
|
||||
cd "$_REPO_ROOT"
|
||||
codex review "${CODEX_BOUNDARY}Review the changes on this branch against the base branch <base>. Run git diff origin/<base>...HEAD 2>/dev/null || git diff <base>...HEAD to see the diff and review only those changes." -c 'model_reasoning_effort="high"' --enable web_search_cached < /dev/null 2>"$TMPERR"
|
||||
# Shell functions do not survive between Bash blocks, so re-source the probe
|
||||
# here. It defines _gstack_codex_timeout_wrapper (gtimeout -> timeout ->
|
||||
# unwrapped fallback), added in #1056 but never wired into this call site.
|
||||
source ~/.claude/skills/gstack/bin/gstack-codex-probe 2>/dev/null || true
|
||||
_gstack_codex_timeout_wrapper 540 codex review --base <base> -c 'model_reasoning_effort="high"' --enable web_search_cached < /dev/null 2>"$TMPERR"
|
||||
\`\`\`
|
||||
|
||||
Set the Bash tool's \`timeout\` parameter to \`300000\` (5 minutes). Do NOT use the \`timeout\` shell command — it doesn't exist on macOS. Present output under \`CODEX SAYS (code review):\` header.
|
||||
**No prompt argument.** \`--base\` is what scopes the review, and the positional \`[PROMPT]\` is mutually exclusive with it — passing both fails at argv parsing. Do NOT "fix" that error by dropping \`--base\` and keeping the prompt: a prompt-only \`codex review\` silently falls back to the **uncommitted working-tree** scope (\`git status --short; git diff\`), so it reviews the wrong changes and reports "no changes" on a clean tree. Prompt text describing the diff range does not change what the CLI feeds the reviewer. Unlike the adversarial pass above, which uses \`codex exec\` and really does run the git command it's told to, this path gets a pre-computed diff from the CLI — which is also why it needs no filesystem boundary.
|
||||
|
||||
Set the Bash tool's \`timeout\` parameter to \`600000\` (10 minutes). It sits ABOVE the 540s wrapper deliberately, so the wrapper fires first and a stall surfaces as a diagnosable exit 124 instead of a harness kill that returns nothing. The wrapper resolves \`gtimeout\`, then \`timeout\`, then runs unwrapped, so it is safe on a macOS without coreutils. Present output under \`CODEX SAYS (code review):\` header.
|
||||
Check for \`[P1]\` markers: found → \`GATE: FAIL\`, not found → \`GATE: PASS\`.
|
||||
|
||||
If GATE is FAIL, use AskUserQuestion:
|
||||
|
||||
@@ -120,8 +120,12 @@ if command -v jq >/dev/null 2>&1; then
|
||||
# Filter to current branch + recent commits, then keep records for the
|
||||
# latest run_id only. (Single phase may have multiple files if the user
|
||||
# re-ran the review; aggregator takes the newest.)
|
||||
# NOTE: bind .commit BEFORE the split pipe. Inside ($commits | split(...))
|
||||
# the "." context is the resulting ARRAY, so a bare .commit there raises
|
||||
# "Cannot index array with string" on every record — and the 2>/dev/null
|
||||
# below swallows it, so the whole aggregation silently yields zero tasks.
|
||||
jq -c --arg branch "$BRANCH" --arg commits "$COMMITS_RECENT" \\
|
||||
'select(.branch == $branch and ($commits | split("|") | index(.commit) != null))' \\
|
||||
'select(.branch == $branch and ((.commit) as $c | ($commits | split("|") | index($c)) != null))' \\
|
||||
"$f" 2>/dev/null >> "$ALL_JSONL" || true
|
||||
done < <(find "$TASKS_DIR" -maxdepth 1 -name "tasks-$phase-*.jsonl" 2>/dev/null | sort)
|
||||
# Reduce to latest run_id per phase
|
||||
|
||||
@@ -222,7 +222,7 @@ ls -d test/ tests/ spec/ __tests__/ cypress/ e2e/ 2>/dev/null
|
||||
|
||||
\`\`\`bash
|
||||
# Count test files before any generation
|
||||
find . -name '*.test.*' -o -name '*.spec.*' -o -name '*_test.*' -o -name '*_spec.*' | grep -v node_modules | wc -l
|
||||
git ls-files 2>/dev/null | grep -E '(\\.test\\.|\\.spec\\.|_test\\.|_spec\\.)' | wc -l
|
||||
\`\`\`
|
||||
|
||||
Store this number for the PR body.`);
|
||||
@@ -430,7 +430,7 @@ If no test framework AND user declined bootstrap → diagram only, no generation
|
||||
|
||||
\`\`\`bash
|
||||
# Count test files after generation
|
||||
find . -name '*.test.*' -o -name '*.spec.*' -o -name '*_test.*' -o -name '*_spec.*' | grep -v node_modules | wc -l
|
||||
git ls-files 2>/dev/null | grep -E '(\\.test\\.|\\.spec\\.|_test\\.|_spec\\.)' | wc -l
|
||||
\`\`\`
|
||||
|
||||
For PR body: \`Tests: {before} → {after} (+{delta} new)\`
|
||||
|
||||
@@ -16,6 +16,27 @@ export interface HostPaths {
|
||||
makePdfDir: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a host path safe to interpolate INSIDE DOUBLE QUOTES in generated bash.
|
||||
*
|
||||
* Tilde-based hosts (Claude, factory) resolve to paths like
|
||||
* `~/.claude/skills/gstack/bin`. Bash only performs tilde expansion when the
|
||||
* `~` is UNQUOTED, so `"~/.claude/..."` is a literal relative path that never
|
||||
* resolves. A `[ -x "~/..." ]` test is therefore always false and a
|
||||
* `"~/..." --flag` invocation always fails — the surrounding block silently
|
||||
* becomes dead code rather than erroring.
|
||||
*
|
||||
* Env-var hosts already use `$GSTACK_BIN`, which expands correctly when
|
||||
* quoted, so they pass through untouched.
|
||||
*
|
||||
* Use this ONLY where the path lands inside double quotes. Unquoted
|
||||
* interpolations (`${ctx.paths.binDir}/gstack-slug`) expand fine as-is and are
|
||||
* left alone so generated docs keep the more readable `~`.
|
||||
*/
|
||||
export function quoteSafePath(hostPath: string): string {
|
||||
return hostPath.startsWith('~/') ? `$HOME/${hostPath.slice(2)}` : hostPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* HOST_PATHS — derived from host configs.
|
||||
* Each config's globalRoot/localSkillRoot determines the path structure.
|
||||
@@ -50,6 +71,16 @@ function buildHostPaths(): Record<string, HostPaths> {
|
||||
|
||||
export const HOST_PATHS: Record<string, HostPaths> = buildHostPaths();
|
||||
|
||||
/**
|
||||
* Render a HostPaths binary dir as a shell-expandable absolute path.
|
||||
* Claude-style dirs are `~`-rooted (e.g. `~/.claude/skills/gstack/browse/dist`)
|
||||
* and expand via `$HOME`; env-var hosts already carry an absolute `$GSTACK_*`
|
||||
* value, so they pass through untouched — prepending `$HOME` would double it.
|
||||
*/
|
||||
export function toShellPath(dir: string): string {
|
||||
return dir.startsWith('~') ? `$HOME${dir.slice(1)}` : dir;
|
||||
}
|
||||
|
||||
import type { Model } from '../models';
|
||||
export type { Model } from '../models';
|
||||
|
||||
|
||||
@@ -57,8 +57,11 @@ echo "$DEPLOY_CONFIG"
|
||||
|
||||
# If config exists, parse it
|
||||
if [ "$DEPLOY_CONFIG" != "NO_CONFIG" ]; then
|
||||
PROD_URL=$(echo "$DEPLOY_CONFIG" | grep -i "production.*url" | head -1 | sed 's/.*: *//')
|
||||
PLATFORM=$(echo "$DEPLOY_CONFIG" | grep -i "platform" | head -1 | sed 's/.*: *//')
|
||||
# Cut at the FIRST ": ", not the last. A greedy 's/.*: *//' ate the scheme of
|
||||
# any URL: "Production URL: https://x.com" became "//x.com", because the last
|
||||
# ":" belongs to "https:".
|
||||
PROD_URL=$(echo "$DEPLOY_CONFIG" | grep -i "production.*url" | head -1 | sed 's/^[^:]*: *//')
|
||||
PLATFORM=$(echo "$DEPLOY_CONFIG" | grep -i "platform" | head -1 | sed 's/^[^:]*: *//')
|
||||
echo "PERSISTED_PLATFORM:$PLATFORM"
|
||||
echo "PERSISTED_URL:$PROD_URL"
|
||||
fi
|
||||
|
||||
@@ -30,7 +30,10 @@ import { spawnSync } from 'child_process';
|
||||
import { isPaidTestFile } from '../test/helpers/paid-test-set';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const TEST_ROOTS = ['browse/test', 'test', 'make-pdf/test'] as const;
|
||||
// design/test was silently absent from BOTH the package.json test script and
|
||||
// this list — design tests (including a teardown bomb) never ran in any CI
|
||||
// or local free run. Keep the two lists in sync.
|
||||
const TEST_ROOTS = ['browse/test', 'test', 'make-pdf/test', 'design/test'] as const;
|
||||
const TEST_FILE_REGEX = /\.test\.(?:[cm]?[jt]s|tsx|jsx)$/;
|
||||
|
||||
// POSIX-only patterns that indicate a test will fail on windows-latest no
|
||||
@@ -91,6 +94,32 @@ const KNOWN_WINDOWS_INCOMPATIBLE: Array<{ file: string; reason: string }> = [
|
||||
},
|
||||
];
|
||||
|
||||
// Force-include overrides: files a WINDOWS_FRAGILE_PATTERNS regex excludes for
|
||||
// a reason that does not actually apply to them. Each entry documents WHY the
|
||||
// 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: 'browse/test/file-permissions.test.ts',
|
||||
// Trips the POSIX-mode-bitmask pattern, but every `mode & 0o777` assertion
|
||||
// is platform-guarded (win32 returns early / takes the icacls branch).
|
||||
// This file carries the win32-only icacls-by-SID regression tests, which
|
||||
// can ONLY execute on windows-latest — excluding it here means the
|
||||
// machine-account ACL lockout regression is never exercised on the one
|
||||
// platform it bricks.
|
||||
reason: 'mode-bitmask hits are POSIX-branch only; win32-only ACL regression tests must run on windows-latest',
|
||||
},
|
||||
{
|
||||
file: 'browse/test/terminal-agent-owner-watchdog.test.ts',
|
||||
// Trips the spawn(['bun','run',...]) pattern, whose reason is the
|
||||
// Playwright-bound browse server. This test spawns terminal-agent.ts,
|
||||
// which imports only fs/path/crypto + local helpers (no Playwright, no
|
||||
// PTY at module scope) and boots under Bun on Windows — the owner-PID
|
||||
// orphan leak it pins was reported on Windows (#2019).
|
||||
reason: 'spawns terminal-agent (no Playwright), not the browse server; owner-orphan leak is a Windows defect',
|
||||
},
|
||||
];
|
||||
|
||||
export const DEFAULT_SHARD_COUNT = 20;
|
||||
export const FREE_TEST_TIMEOUT_MS = 10_000;
|
||||
|
||||
@@ -160,12 +189,17 @@ export function curateWindowsSafe(files: string[], rootDir = ROOT): CurationResu
|
||||
const safe: string[] = [];
|
||||
const excluded: Array<{ file: string; reason: string }> = [];
|
||||
const knownBad = new Map(KNOWN_WINDOWS_INCOMPATIBLE.map((e) => [e.file, e.reason]));
|
||||
const knownSafe = new Set(KNOWN_WINDOWS_SAFE.map((e) => e.file));
|
||||
for (const relativePath of files) {
|
||||
const knownReason = knownBad.get(relativePath);
|
||||
if (knownReason) {
|
||||
excluded.push({ file: relativePath, reason: knownReason });
|
||||
continue;
|
||||
}
|
||||
if (knownSafe.has(relativePath)) {
|
||||
safe.push(relativePath);
|
||||
continue;
|
||||
}
|
||||
const absolute = path.join(rootDir, relativePath);
|
||||
const fragility = detectWindowsFragility(absolute);
|
||||
if (fragility) {
|
||||
@@ -254,14 +288,39 @@ function formatShardSummary(shards: string[][]): string[] {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a shard's output shows the run ended WITHOUT bun's final summary
|
||||
* ("Ran N tests across ..."). A process.exit() fired mid-suite skips the
|
||||
* summary AND hands back whatever code the caller passed — historically 0,
|
||||
* which made a truncated shard indistinguishable from a green one. Exit code
|
||||
* alone is therefore not evidence of completion; the summary line is.
|
||||
* (Fault-injection coverage: test/exit-propagation.test.ts.)
|
||||
*/
|
||||
export function shardRunLooksTruncated(status: number | null, output: string): boolean {
|
||||
if (status !== 0) return false; // already failing — not the silent case
|
||||
return !/Ran \d+ tests? across \d+ files?/.test(output);
|
||||
}
|
||||
|
||||
function runShard(files: string[], shardNumber: number, totalShards: number): number {
|
||||
const header = `[test:free] shard ${shardNumber}/${totalShards} (${files.length} files)`;
|
||||
console.log(header);
|
||||
const result = spawnSync(process.execPath, buildShardArgs(files), {
|
||||
cwd: ROOT,
|
||||
stdio: 'inherit',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
encoding: 'utf8',
|
||||
env: process.env,
|
||||
});
|
||||
// Preserve the inherit-style UX: replay the shard's output.
|
||||
if (result.stdout) process.stdout.write(result.stdout);
|
||||
if (result.stderr) process.stderr.write(result.stderr);
|
||||
const combined = `${result.stdout ?? ''}${result.stderr ?? ''}`;
|
||||
if (shardRunLooksTruncated(result.status, combined)) {
|
||||
console.error(
|
||||
`${header} exited 0 WITHOUT bun's final summary — the run was truncated ` +
|
||||
'(a process.exit fired mid-suite). Treating as FAILED.',
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
console.error(`${header} failed with exit code ${result.status ?? 1}`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user