diff --git a/bin/gstack-hook-check b/bin/gstack-hook-check index 1a0b9961b..23d60c215 100755 --- a/bin/gstack-hook-check +++ b/bin/gstack-hook-check @@ -34,8 +34,12 @@ # missing (no shim: setup skips registering it) # fail :: # Exit: 0 when nothing failed, 1 when a hook failed, 2 on usage, 3 when bun -# (needed when a hook runs TypeScript) is not found. Set GSTACK_HOOK_CHECK_BUN to -# the bun to run (setup passes its own). +# (needed when a hook runs TypeScript) is not found or is killed by a signal +# while bundling (a runtime crash says nothing about whether a hook parses). +# After a crash the rows bun already printed still count, and so do the shims, +# their sourced helpers and the merge-marker scan: any failure exits 1. Only an +# entry bun never reported is unchecked; its hook prints no ok. +# Set GSTACK_HOOK_CHECK_BUN to the bun to run (setup passes its own). set -u GSTACK_BUN_CONFIG=/dev/null; case "${OSTYPE:-}" in msys*|cygwin*) if command -v cygpath >/dev/null 2>&1; then GSTACK_BUN_CONFIG=NUL; fi ;; esac @@ -175,6 +179,7 @@ if [ -n "$ENTRIES" ] && ! command -v "$BUN" >/dev/null 2>&1; then echo "gstack-hook-check: bun not found, so TypeScript hook entries cannot be checked" >&2 exit 3 fi +BUILD_RC=0 if [ -n "$ENTRIES" ]; then # Bun reports native spellings; shellPath maps them back to the keys looked # up below (lib/path-spelling.ts shellPath; test/path-spelling.test.ts keeps @@ -193,7 +198,11 @@ if [ -n "$ENTRIES" ]; then const at = log.position ? `${shellPath(log.position.file)}:${log.position.line}` : key; console.log(`${key}\tfail\t${at}\t${String(log.message ?? log).split("\n")[0]}`); } - }' $ENTRIES) > "$BUILT" 2>/dev/null || true + }' $ENTRIES) > "$BUILT" 2>/dev/null || BUILD_RC=$? +fi +TS_UNCHECKED=0 +if [ "$BUILD_RC" -gt 128 ]; then + echo "gstack-hook-check: bun was killed by signal $((BUILD_RC - 128)) while bundling the TypeScript hook entries, so they cannot be checked" >&2 fi i=0 @@ -234,6 +243,11 @@ for hook in $HOOKS; do fi scan="$scan$NL$dir/$entry" result=$(awk -F '\t' -v e="$dir/$entry" '$1 == e' "$BUILT") + if [ -z "$result" ] && [ "$BUILD_RC" -gt 128 ]; then + TS_UNCHECKED=1 + hook_ok=0 + continue + fi if [ -z "$result" ]; then fail "$hook" "$dir/$entry:1" "bun could not bundle the TypeScript entry" hook_ok=0 @@ -266,4 +280,5 @@ for hook in $HOOKS; do done [ "$hook_ok" -eq 1 ] && echo "ok $hook" done +[ "$FAILED" -eq 0 ] && [ "$TS_UNCHECKED" -eq 1 ] && exit 3 exit "$FAILED" diff --git a/changelog.d/fix-flaky-tests-2026-11-08.md b/changelog.d/fix-flaky-tests-2026-11-08.md new file mode 100644 index 000000000..7ceeb8178 --- /dev/null +++ b/changelog.d/fix-flaky-tests-2026-11-08.md @@ -0,0 +1,2 @@ +- **`./setup` no longer refuses healthy hooks when Bun crashes while checking them.** The hook parse check bundles each TypeScript hook with one Bun process; when that process was killed by a signal (a Bun segfault, seen in Free Tests 42327570347), every hook it hadn't reported was listed as "does not parse" and setup exited 1 with a "this is a gstack bug" message. Now the results Bun printed before the crash still count and the shell checks still run, so a hook that really fails is still refused, and only a hook Bun never reached is registered unchecked. The check reports itself unavailable (exit 3, the same as a missing Bun) only when a hook went unreported and nothing else failed; setup then warns that hooks were registered unchecked, and the auto-updater holds the update as it does for any unavailable check. A Bun that exits normally without a report still fails every hook it left out. +- Two flaky tests are fixed. The gbrain prune test no longer sees another test's `gbrain autopilot` process, and the plan-review floor actor now answers its own cropped "overwrite" permission for the working plan instead of waiting it out. diff --git a/test/fixtures/plan-ceo-floor-overwrite-permission.json b/test/fixtures/plan-ceo-floor-overwrite-permission.json new file mode 100644 index 000000000..bc80c0da0 --- /dev/null +++ b/test/fixtures/plan-ceo-floor-overwrite-permission.json @@ -0,0 +1,14 @@ +{ + "provenance": { + "run": "42252633016", + "artifact": "paid-slice-7-a1", + "case": "plan-ceo-finding-floor", + "head": "cf4d847e", + "outcome": "permission_unanswered", + "note": "final screen; before = the owned file after its Write and two granted Edits; content = the pending Write" + }, + "basename": "gstack-test-plan-ceo-floor.md", + "screen": " -oper evidence in 1-2 weeks ✅ Gives a real price point and a real success metric instead of \"more signups\" ❌ De\n -lays the launch by roughly 1-2 weeks of interviews and funnel analysis \n 107 +Interview ~10 target developers and pull signup-to-activation drop-off data; launch the tier only if price is a \n +top barrier. Effort S (human: ~1-2 weeks / CC: ~1 hr for interview guide + funnel queries), risk low, reuses exi\n +sting analytics. ✅ Confirms or kills the price hypothesis with real developer evidence in 1-2 weeks ✅ Intervie\n +ws also surface the real non-price barriers if price turns out not to matter ❌ Delays the launch by roughly 1-2\n + weeks of interviews and funnel analysis \n 108 B) Launch now as written\n 107 -Ship the cheaper tier on the team's intuition, measured by signups. Effort S (zero new research work; tier build\n - cost unchanged), risk high, no verification of the premise. ✅ Fastest path; the team can ship the tier as soon\n - as billing supports it ✅ Zero new research work, keeps the plan exactly as the team agreed it ❌ Bets margin a\n -nd pricing trust on an admitted guess, judged by a vanity metric \n 109 +Ship the cheaper tier on the team's intuition. Effort S (zero new research work; tier build cost unchanged), ris\n +k high, no verification of the premise. ✅ Fastest path; the team can ship the tier as soon as billing supports \n +it ✅ Zero new research work, keeps the plan exactly as the team agreed it ❌ Bets margin and pricing trust on a\n +n admitted guess with no evidence behind it \n 110 C) Launch as a pricing experiment\n 109 -Offer the tier to a randomized share of new developer signups with a holdout, and compare activation, conversion\n - and downgrades. Effort M (human: ~3-6 weeks / CC: ~1 day for experiment wiring), risk medium, needs experiment \n -infra and enough traffic. ✅ Measures price sensitivity directly with a holdout and real conversion data ✅ Limi\n -ts damage to the paid base, since only an experiment arm sees the price ❌ Needs experiment infra and traffic; 3\n --6 weeks and may be underpowered \n 111 +Offer the tier to a randomized share of new developer signups with a holdout and compare behavior. Effort M (hum\n +an: ~3-6 weeks / CC: ~1 day for experiment wiring), risk medium, needs experiment infra and enough traffic. ✅ M\n +easures price sensitivity directly against a holdout with real behavior data ✅ Limits exposure, since only a ra\n +ndomized share of new signups sees the price ❌ Needs experiment infra and traffic; 3-6 weeks and may be underpo\n +wered \n 112 \n 113 ## Proposed (pending, not approved)\n 112 -- Nothing beyond PREMISE-1 yet. Section findings will be added as the review proceeds. \n 114 +- PREMISE-1, METRIC-1, CANNIBAL-1 as above. No tasks written. Section findings will be added as the review proce\n +eds. \n╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌\n Do you want to overwrite gstack-test-plan-ceo-floor.md?\n ❯ 1. Yes\n 2. Yes, and switch to accept edits (auto-approve file edits and common file commands) for this session (shift+tab)\n 3. No\n\n Esc to cancel · Tab to amend\n", + "before": "# CEO Review Working Plan: \"Developer-friendly\" pricing tier\n\nSource under review: `PLAN.md` (commit 831363c). Review: /plan-ceo-review, started 2026-11-06.\nReview depth: strategy-only (the plan has no implementation design; the user asked for a plan review of a pricing/strategy plan). Implementation owners must prove the items marked \"owner must prove\".\n\n## Plan under review (verbatim summary)\n- Goal: Increase developer adoption. (PLAN.md:14)\n- Success metric: More signups. (PLAN.md:17)\n- Premise: No developers interviewed; team agreed pricing \"feels like\" it should be cheaper. (PLAN.md:20-21)\n\n## Pre-review system audit\n- Repo: 1 commit (`831363c Seed review plan`), files CLAUDE.md, PLAN.md. No remote `origin`; platform unknown; base branch `main` (fallback).\n- No stashes, no TODO/FIXME markers, no TODOS.md, no architecture docs, no in-flight branches.\n- Design doc: none found. /office-hours offer skipped per user instruction (CLAUDE.md).\n- Prior learnings: 0. Cross-project learnings: `false` (already declined). Brain context: unreachable, no cache.\n- Retrospective: no prior reviews or reverts.\n- Frontend/UI scope: plan names no screens; a pricing page change is implied but not specified. DESIGN_SCOPE = not detected (Section 11 likely skipped).\n- Landscape check: Aside not installed (NEEDS_ASIDE); used WebSearch.\n - [Layer 1] Dev tools grow bottoms-up via a free tier that lets engineers evaluate core value; pricing must be transparent and forecastable (getmonetizely, maximize.partners).\n - [Layer 2] 2026 trend: free tiers shrinking (42 tools removed free tiers, 4 launched; 202 changes across 185 vendors) (dev.to/robbobobbo, agentdeals.dev). \"High signup volume but minimal engagement\" is a named failure sign of crippled tiers.\n - [Layer 3] Price is one of several adoption barriers (onboarding time, docs, missing integrations, trust). Cutting price without knowing which barrier binds can raise signups while activation and revenue stay flat or drop.\n\n## Step 0\n\n### 0A. Premise challenge\n- Real problem (stated): developer adoption is too low. Unknown: how low, measured how, compared to what.\n- Plan's remedy: a cheaper tier. That is a proxy. The premise (PLAN.md:20-21) openly says nobody checked that price is the barrier.\n- Success metric \"More signups\" (PLAN.md:17) is a vanity proxy: a cheaper tier can raise signups while adoption (activated, retained developers) and revenue fall, and cannibalize existing paid customers who downgrade.\n- Do-nothing cost: unknown, because there is no baseline adoption number. That itself is a finding.\n- Verdict: the plan does not solve the pain directly; it bets on an untested cause.\n\n### 0B. Existing code leverage\n- No code in repo. Billing system, pricing page, analytics and plan entitlements are all outside this repo: owner must prove which existing billing/entitlement path the tier reuses.\n\nPremise table:\n\n| premise | claim | receipt | verdict |\n|---|---|---|---|\n| P1 | Developer adoption is too low | PLAN.md:14 (no number) | unverified |\n| P2 | Current pricing is a barrier to developers | PLAN.md:20-21 (\"haven't talked to any developers\") | unverified (plan admits it) |\n| P3 | A cheaper tier increases adoption | PLAN.md:17 (no evidence) | unverified |\n| P4 | Signups measure adoption | PLAN.md:17 | unverified; contradicted by landscape evidence (signups without engagement) |\n\nunverified=4\n\nREALITY: premise-table finding 4 of 4 premises unverified PLAN.md:20\nREALITY: already-done pass no origin remote; only commit is 831363c; no pricing work exists PLAN.md:1\nREALITY: surface-check pass plan names no CLI commands, flags or routes PLAN.md:13\nREALITY: binding-decisions pass gstack-decision-search returned none; plan has no Binding decisions section PLAN.md:1\nREALITY: numbers finding plan contains no numbers: no baseline, target, price or date [estimated: none] PLAN.md:17\nREALITY: deferred-asks pass no linked issue PLAN.md:1\nREALITY: pr-count pass no .gstack/ship-policy.json; default one PR per wave PLAN.md:1\n\n### 0C. Dream state mapping\n```\n CURRENT STATE THIS PLAN 12-MONTH IDEAL\n Adoption \"low\" (unmeasured); Ship a cheaper tier because Known #1 adoption barrier, fixed;\n price barrier assumed, never -> it \"feels\" right; judge by -> activation + retention measured;\n asked; no baseline raw signups pricing set from evidence; paid\n base not cannibalized\n```\nThe plan moves toward the ideal only if price really is the binding barrier. Otherwise it spends margin and trust (pricing changes are hard to reverse with developers) on the wrong lever.\n\n## Decision ledger\n\n| ID and owner | Contract and evidence | Current | Proposed | Status | Exact approval and scope |\n|---|---|---|---|---|---|\n| MODE (user) | Review mode | HOLD SCOPE | n/a | approved | CLAUDE.md: \"For review-mode questions choose HOLD SCOPE (CEO)\". Mode only; approves no plan changes. |\n| PREMISE-1 (product owner) | Is price the adoption barrier? PLAN.md:19-21 admits no evidence | Launch cheaper tier on team intuition | See currentDecision PREMISE-1 | unresolved | none yet |\n\n## currentDecision (PREMISE-1)\nCommitment comparison:\n\n```text\nCommitment | Source/approval or pending | Current | A (validate first) | B (launch now) | C (experiment)\nLaunch a developer pricing tier | PLAN.md title (scope held) | yes | yes, after validation gate | yes, now | yes, to a randomized arm\nValidate price is the barrier | pending | no | yes: ~10 dev interviews + funnel data | no | partial: experiment measures sensitivity\nSuccess metric | pending | \"More signups\" | activated devs + paid conv. + downgrades | \"More signups\" | same as A, per arm\nCannibalization guard | pending | none | measure downgrades of paid users | none | holdout group\nTime to launch | pending | unstated | +1-2 weeks | fastest | +3-6 weeks\n```\n\nQuestion: D1 — PREMISE-1: Should the tier launch on the team's hunch, or only after checking that price is really what stops developers?\nProject/branch/task: main, CEO review of PLAN.md \"developer-friendly pricing tier\".\nELI10: The plan says the team never asked a developer whether price is the problem; it just feels that way. If the real blocker is setup pain, docs or a missing integration, a cheaper tier gets more signups who still don't stick, and you've cut prices for paying customers too. Checking first is cheap; un-cutting a price is not.\nStakes if we pick wrong: we lower revenue and train developers to expect a price we may later raise, while adoption stays flat.\nRecommendation: A because a 1-2 week check (interviews plus funnel data) either confirms price and lets you set the right number, or saves you from a hard-to-reverse price cut.\nNote: options differ in kind, not coverage — no completeness score.\nPros / cons:\nA) Validate first, then launch (recommended)\n ✅ Confirms or kills the price hypothesis with real developer evidence in 1-2 weeks\n ✅ Gives a real price point and a real success metric instead of \"more signups\"\n ❌ Delays the launch by roughly 1-2 weeks of interviews and funnel analysis\nB) Launch now as written\n ✅ Fastest path; the team can ship the tier as soon as billing supports it\n ✅ Zero new research work, keeps the plan exactly as the team agreed it\n ❌ Bets margin and pricing trust on an admitted guess, judged by a vanity metric\nC) Launch as a pricing experiment\n ✅ Measures price sensitivity directly with a holdout and real conversion data\n ✅ Limits damage to the paid base, since only an experiment arm sees the price\n ❌ Needs experiment infra and traffic; 3-6 weeks and may be underpowered\nNet: a short delay to learn the real barrier versus speed on an untested hunch.\nHeader: Premise\nA) Validate first, then launch (recommended)\nBefore setting a price, interview ~10 target developers and pull signup-to-activation drop-off data; launch the tier only if price shows up as a top barrier. Effort S (human: ~1-2 weeks / CC: ~1 hr to draft interview guide + funnel queries), risk low, reuses existing analytics. ✅ Confirms or kills the price hypothesis with real developer evidence in 1-2 weeks ✅ Gives a real price point and a real success metric instead of \"more signups\" ❌ Delays the launch by roughly 1-2 weeks of interviews and funnel analysis\nB) Launch now as written\nShip the cheaper tier on the team's intuition, measured by signups. Effort S (zero new research work; tier build cost unchanged), risk high, no verification of the premise. ✅ Fastest path; the team can ship the tier as soon as billing supports it ✅ Zero new research work, keeps the plan exactly as the team agreed it ❌ Bets margin and pricing trust on an admitted guess, judged by a vanity metric\nC) Launch as a pricing experiment\nOffer the tier to a randomized share of new developer signups with a holdout, and compare activation, conversion and downgrades. Effort M (human: ~3-6 weeks / CC: ~1 day for experiment wiring), risk medium, needs experiment infra and enough traffic. ✅ Measures price sensitivity directly with a holdout and real conversion data ✅ Limits damage to the paid base, since only an experiment arm sees the price ❌ Needs experiment infra and traffic; 3-6 weeks and may be underpowered\n\n## Proposed (pending, not approved)\n- Nothing beyond PREMISE-1 yet. Section findings will be added as the review proceeds.\n", + "content": "# CEO Review Working Plan: \"Developer-friendly\" pricing tier\n\nSource under review: `PLAN.md` (commit 831363c). Review: /plan-ceo-review, started 2026-11-06.\nReview depth: strategy-only (the plan has no implementation design; the user asked for a plan review of a pricing/strategy plan). Implementation owners must prove the items marked \"owner must prove\".\n\n## Plan under review (verbatim summary)\n- Goal: Increase developer adoption. (PLAN.md:14)\n- Success metric: More signups. (PLAN.md:17)\n- Premise: No developers interviewed; team agreed pricing \"feels like\" it should be cheaper. (PLAN.md:20-21)\n\n## Pre-review system audit\n- Repo: 1 commit (`831363c Seed review plan`), files CLAUDE.md, PLAN.md. No remote `origin`; platform unknown; base branch `main` (fallback).\n- No stashes, no TODO/FIXME markers, no TODOS.md, no architecture docs, no in-flight branches.\n- Design doc: none found. /office-hours offer skipped per user instruction (CLAUDE.md).\n- Prior learnings: 0. Cross-project learnings: `false` (already declined). Brain context: unreachable, no cache.\n- Retrospective: no prior reviews or reverts.\n- Frontend/UI scope: plan names no screens; a pricing page change is implied but not specified. DESIGN_SCOPE = not detected (Section 11 likely skipped).\n- Landscape check: Aside not installed (NEEDS_ASIDE); used WebSearch.\n - [Layer 1] Dev tools grow bottoms-up via a free tier that lets engineers evaluate core value; pricing must be transparent and forecastable (getmonetizely, maximize.partners).\n - [Layer 2] 2026 trend: free tiers shrinking (42 tools removed free tiers, 4 launched; 202 changes across 185 vendors) (dev.to/robbobobbo, agentdeals.dev). \"High signup volume but minimal engagement\" is a named failure sign of crippled tiers.\n - [Layer 3] Price is one of several adoption barriers (onboarding time, docs, missing integrations, trust). Cutting price without knowing which barrier binds can raise signups while activation and revenue stay flat or drop.\n\n## Step 0\n\n### 0A. Premise challenge\n- Real problem (stated): developer adoption is too low. Unknown: how low, measured how, compared to what.\n- Plan's remedy: a cheaper tier. That is a proxy. The premise (PLAN.md:20-21) openly says nobody checked that price is the barrier.\n- Success metric \"More signups\" (PLAN.md:17) is a vanity proxy: a cheaper tier can raise signups while adoption (activated, retained developers) and revenue fall, and cannibalize existing paid customers who downgrade.\n- Do-nothing cost: unknown, because there is no baseline adoption number. That itself is a finding.\n- Verdict: the plan does not solve the pain directly; it bets on an untested cause.\n\n### 0B. Existing code leverage\n- No code in repo. Billing system, pricing page, analytics and plan entitlements are all outside this repo: owner must prove which existing billing/entitlement path the tier reuses.\n\nPremise table:\n\n| premise | claim | receipt | verdict |\n|---|---|---|---|\n| P1 | Developer adoption is too low | PLAN.md:14 (no number) | unverified |\n| P2 | Current pricing is a barrier to developers | PLAN.md:20-21 (\"haven't talked to any developers\") | unverified (plan admits it) |\n| P3 | A cheaper tier increases adoption | PLAN.md:17 (no evidence) | unverified |\n| P4 | Signups measure adoption | PLAN.md:17 | unverified; contradicted by landscape evidence (signups without engagement) |\n\nunverified=4\n\nREALITY: premise-table finding 4 of 4 premises unverified PLAN.md:20\nREALITY: already-done pass no origin remote; only commit is 831363c; no pricing work exists PLAN.md:1\nREALITY: surface-check pass plan names no CLI commands, flags or routes PLAN.md:13\nREALITY: binding-decisions pass gstack-decision-search returned none; plan has no Binding decisions section PLAN.md:1\nREALITY: numbers finding plan contains no numbers: no baseline, target, price or date [estimated: none] PLAN.md:17\nREALITY: deferred-asks pass no linked issue PLAN.md:1\nREALITY: pr-count pass no .gstack/ship-policy.json; default one PR per wave PLAN.md:1\n\n### 0C. Dream state mapping\n```\n CURRENT STATE THIS PLAN 12-MONTH IDEAL\n Adoption \"low\" (unmeasured); Ship a cheaper tier because Known #1 adoption barrier, fixed;\n price barrier assumed, never -> it \"feels\" right; judge by -> activation + retention measured;\n asked; no baseline raw signups pricing set from evidence; paid\n base not cannibalized\n```\nThe plan moves toward the ideal only if price really is the binding barrier. Otherwise it spends margin and trust (pricing changes are hard to reverse with developers) on the wrong lever.\n\n## Decision ledger\n\n| ID and owner | Contract and evidence | Current | Proposed | Status | Exact approval and scope |\n|---|---|---|---|---|---|\n| MODE (user) | Review mode | HOLD SCOPE | n/a | approved | CLAUDE.md: \"For review-mode questions choose HOLD SCOPE (CEO)\". Mode only; approves no plan changes. |\n| PREMISE-1 (product owner) | Gate on whether price is the adoption barrier. PLAN.md:19-21 admits no evidence | Launch on team intuition, no validation | A) validation gate before launch; B) keep current; C) randomized price experiment | unresolved | none yet |\n| METRIC-1 (product owner) | Success metric. PLAN.md:17 \"More signups\" | \"More signups\" | Replace/augment with activation + paid conversion (pending; asked separately) | unresolved | none yet; held fixed across PREMISE-1 options |\n| CANNIBAL-1 (product owner) | Existing paid users downgrading to the cheaper tier. Not addressed in PLAN.md | none | Downgrade-rate guardrail (pending; asked separately) | unresolved | none yet; held fixed across PREMISE-1 options |\n\n## currentDecision (PREMISE-1)\nCommitment comparison:\n\n```text\nCommitment | Source/approval or pending | Current | A (validate first) | B (launch now) | C (experiment)\nLaunch a developer pricing tier | PLAN.md title (scope held) | yes | yes, if gate confirms price | yes, now | yes, to a randomized arm\nValidate price is the barrier | PREMISE-1 pending | no | ~10 dev interviews + funnel drop-off | no | measured by experiment arm vs holdout\nSuccess metric | METRIC-1 pending | \"More signups\" | unchanged (pending) | unchanged | unchanged (pending)\nCannibalization guard | CANNIBAL-1 pending | none | unchanged (pending) | unchanged | holdout limits exposure only\nTime to launch | pending | unstated | +1-2 weeks | fastest | +3-6 weeks\n```\n\nQuestion: D1 — PREMISE-1: Should the tier launch on the team's hunch, or only after checking that price is really what stops developers?\nProject/branch/task: main, CEO review of PLAN.md \"developer-friendly pricing tier\".\nELI10: The plan says the team never asked a developer whether price is the problem; it just feels that way. If the real blocker is setup pain, docs or a missing integration, a cheaper tier brings people who still don't stick, and you've cut your price for nothing. Checking first is cheap; un-cutting a price is not. (The success metric and downgrade risk are separate questions coming next.)\nStakes if we pick wrong: we lower revenue and train developers to expect a price we may later have to raise, while adoption stays flat.\nRecommendation: A because a 1-2 week check either confirms price and tells you what number to set, or saves you from a hard-to-reverse price cut.\nNote: options differ in kind, not coverage — no completeness score.\nPros / cons:\nA) Validate first, then launch (recommended)\n ✅ Confirms or kills the price hypothesis with real developer evidence in 1-2 weeks\n ✅ Interviews also surface the real non-price barriers if price turns out not to matter\n ❌ Delays the launch by roughly 1-2 weeks of interviews and funnel analysis\nB) Launch now as written\n ✅ Fastest path; the team can ship the tier as soon as billing supports it\n ✅ Zero new research work, keeps the plan exactly as the team agreed it\n ❌ Bets margin and pricing trust on an admitted guess with no evidence behind it\nC) Launch as a pricing experiment\n ✅ Measures price sensitivity directly against a holdout with real behavior data\n ✅ Limits exposure, since only a randomized share of new signups sees the price\n ❌ Needs experiment infra and traffic; 3-6 weeks and may be underpowered\nNet: a short delay to learn the real barrier versus speed on an untested hunch.\nHeader: Premise\nA) Validate first, then launch (recommended)\nInterview ~10 target developers and pull signup-to-activation drop-off data; launch the tier only if price is a top barrier. Effort S (human: ~1-2 weeks / CC: ~1 hr for interview guide + funnel queries), risk low, reuses existing analytics. ✅ Confirms or kills the price hypothesis with real developer evidence in 1-2 weeks ✅ Interviews also surface the real non-price barriers if price turns out not to matter ❌ Delays the launch by roughly 1-2 weeks of interviews and funnel analysis\nB) Launch now as written\nShip the cheaper tier on the team's intuition. Effort S (zero new research work; tier build cost unchanged), risk high, no verification of the premise. ✅ Fastest path; the team can ship the tier as soon as billing supports it ✅ Zero new research work, keeps the plan exactly as the team agreed it ❌ Bets margin and pricing trust on an admitted guess with no evidence behind it\nC) Launch as a pricing experiment\nOffer the tier to a randomized share of new developer signups with a holdout and compare behavior. Effort M (human: ~3-6 weeks / CC: ~1 day for experiment wiring), risk medium, needs experiment infra and enough traffic. ✅ Measures price sensitivity directly against a holdout with real behavior data ✅ Limits exposure, since only a randomized share of new signups sees the price ❌ Needs experiment infra and traffic; 3-6 weeks and may be underpowered\n\n## Proposed (pending, not approved)\n- PREMISE-1, METRIC-1, CANNIBAL-1 as above. No tasks written. Section findings will be added as the review proceeds.\n" +} diff --git a/test/gbrain-prune-gone-worktrees.test.ts b/test/gbrain-prune-gone-worktrees.test.ts index 893e897a6..5166b5fb1 100644 --- a/test/gbrain-prune-gone-worktrees.test.ts +++ b/test/gbrain-prune-gone-worktrees.test.ts @@ -98,6 +98,13 @@ case "$1 $2" in esac `); chmodSync(join(home, "bin", "gbrain"), 0o755); + // The real removal asks detectAutopilot, whose `pgrep -f "gbrain autopilot"` + // sees every process on the machine. A parallel shard's + // test/gbrain-guards.test.ts runs one with that command line for its + // live-probe case, which made the removal refuse (Free Tests 42448270551 + // shard 3). Stub pgrep to "no match", as test/gstack-gbrain-sync.test.ts does. + writeFileSync(join(home, "bin", "pgrep"), "#!/bin/sh\nexit 1\n"); + chmodSync(join(home, "bin", "pgrep"), 0o755); } function sync(args: string[]) { const r = spawnSync(process.execPath, [SYNC, ...args], { env, cwd: repo, encoding: "utf-8", timeout: 60_000 }); diff --git a/test/helpers/free-fixtures-data.ts b/test/helpers/free-fixtures-data.ts index 19648a51e..4bf2ee84f 100644 --- a/test/helpers/free-fixtures-data.ts +++ b/test/helpers/free-fixtures-data.ts @@ -185,6 +185,7 @@ export const FREE_FIXTURES: Readonly> = { 'test/fixtures/parity-baseline-v1.81.0.0.json': ['test/skill-size-budget.test.ts'], 'test/fixtures/plan-ceo-floor-create-quote-wrap.json': ['test/plan-create-prepublication.test.ts'], 'test/fixtures/plan-ceo-floor-edit-prepublication.json': ['test/plan-edit-cropped-permission.test.ts'], + 'test/fixtures/plan-ceo-floor-overwrite-permission.json': ['test/plan-count-file-permission.test.ts'], 'test/fixtures/plan-design-floor-create-rule-crop.json': ['test/plan-create-prepublication.test.ts'], 'test/fixtures/plan-ceo-floor-toolonly-create.json': ['test/plan-create-prepublication.test.ts', 'test/plan-floor-permission.test.ts'], 'test/fixtures/plan-count-design-questionless-report.md': ['test/plan-count-empty-review.test.ts'], diff --git a/test/helpers/plan-count-file-permission.ts b/test/helpers/plan-count-file-permission.ts index e6a534251..655e52e47 100644 --- a/test/helpers/plan-count-file-permission.ts +++ b/test/helpers/plan-count-file-permission.ts @@ -285,22 +285,22 @@ function croppedEditTarget(screen: string, cwd: string, expected: string): strin return !headerPath || path.resolve(cwd, headerPath) === target ? target : undefined; } -/** Bind either native Create footer to the owned Write when its heading is cropped. */ -function croppedCreatePane(screen: string, expected: string): { basename: string; preview: string } | undefined { +/** Bind either native Create (or Write overwrite) footer to the owned Write when its heading is cropped. */ +function croppedWritePane(screen: string, expected: string, verb: 'create' | 'overwrite'): { basename: string; preview: string } | undefined { const text=screen.replace(/\r+\n?/g,'\n'); - const pane=/^([\s\S]+)\n[╌─━]{3,}[ \t]*\n {0,3}Do you want to create ([^\n?\/\\]+)\?[ \t]*\n {0,3}❯[ \t]*1\.[ \t]*Yes[ \t]*\n\s*2\.[ \t]*Yes,\s+and\s+switch\s+to\s+accept\s+edits\s+\(auto-approve\s+file\s+edits\s+and\s+common\s+file\s+commands\)\s+for\s+this\s+session(?:;\s+Yes,\s+and\s+always\s+allow\s+access\s+to\s+([^\r\n]+?)\s+for\s+this\s+session)?(?:\s*\(shift\+tab\))?\s*\n\s*3\.[ \t]*No[ \t]*\n\s*Esc to cancel [·•] Tab to amend\s*$/.exec(text); + const pane=/^([\s\S]+)\n[╌─━]{3,}[ \t]*\n {0,3}Do you want to (create|overwrite) ([^\n?\/\\]+)\?[ \t]*\n {0,3}❯[ \t]*1\.[ \t]*Yes[ \t]*\n\s*2\.[ \t]*Yes,\s+and\s+switch\s+to\s+accept\s+edits\s+\(auto-approve\s+file\s+edits\s+and\s+common\s+file\s+commands\)\s+for\s+this\s+session(?:;\s+Yes,\s+and\s+always\s+allow\s+access\s+to\s+([^\r\n]+?)\s+for\s+this\s+session)?(?:\s*\(shift\+tab\))?\s*\n\s*3\.[ \t]*No[ \t]*\n\s*Esc to cancel [·•] Tab to amend\s*$/.exec(text); // As in croppedEditPane, a wrapped continuation row (five or more columns) may begin with '>'. - if (!pane || /[☐□]|^ {0,4}(?:>|`{3}|~{3})/m.test(pane[1]!)) return undefined; + if (!pane || pane[2] !== verb || /[☐□]|^ {0,4}(?:>|`{3}|~{3})/m.test(pane[1]!)) return undefined; // Native option 2 can combine accept-edits with directory access. It remains // unselected: its directory must agree, and the pending Write still owns every // preview row. Never infer ownership from this optional directory alone. - const directory=pane[3]?.trim(); + const directory=pane[4]?.trim(); if (directory!==undefined && (!path.isAbsolute(directory)||directory!==path.dirname(expected))) return undefined; - return {basename:pane[2]!.trim(),preview:pane[1]!}; + return {basename:pane[3]!.trim(),preview:pane[1]!}; } -/** Bind every displayed source row to the sole pending native Write, not its basename alone. */ -function currentCreatePreview(preview: string, r: any, config: string, cwd: string, startedAt: number, file: string): boolean { +/** Content of the sole pending owned native Write, published or witnessed by its own PreToolUse hook. */ +function pendingOwnedWriteContent(r: any, config: string, cwd: string, startedAt: number, file: string): string | undefined { const pending=new Map(), seen=new Map(); const completed=new Set(); let conflict=false; @@ -320,24 +320,36 @@ function currentCreatePreview(preview: string, r: any, config: string, cwd: stri let event=[...pending.values()][0]; const witness=r.writeInputSha256 ? readPendingWriteInput(file,r.expected,cwd,config,startedAt) : undefined; if(conflict||transcript.status!=='ready'||pending.size>1|| - (r.writeInputSha256 && (!witness||witness.pendingId!==r.pendingId||witness.sha256!==r.writeInputSha256))) return false; + (r.writeInputSha256 && (!witness||witness.pendingId!==r.pendingId||witness.sha256!==r.writeInputSha256))) return undefined; if(!event) { const id=r.pendingId.slice(r.sessionId.length+1); // A current PreToolUse witness breaks only the publication delay. A result, // an overlapping hook or another native use can never borrow that witness. - if(!witness||r.overlappingWriteInput||completed.has(id)||seen.has(id)) return false; + if(!witness||r.overlappingWriteInput||completed.has(id)||seen.has(id)) return undefined; event={sessionId:r.sessionId,toolUseId:id,kind:'use',name:'Write',timestamp:witness.timestamp,input:witness.input}; - } else if(witness && !isDeepStrictEqual(event.input,witness.input)) return false; + } else if(witness && !isDeepStrictEqual(event.input,witness.input)) return undefined; if(event.name!=='Write'||`${event.sessionId}:${event.toolUseId}`!==r.pendingId||event.input?.file_path!==r.expected|| Date.parse(event.timestamp)MAX_WRITE_INPUT_BYTES) return false; + Buffer.byteLength(event.input.content)>MAX_WRITE_INPUT_BYTES) return undefined; + return event.input.content; +} + +/** The preview rows below an optional file row and rule, or below a lone cropped rule. */ +function writePreviewBody(preview: string, cwd: string, expected: string): string | undefined { // A crop can keep the pane's file row and rule above the preview while its // "Create file" title scrolls away. That row must name the owned path. // It can also start on that single rule, as croppedEditPane allows. const header=/^ {0,3}(?![1-9]\d*(?:[ \t]|\n))(\S[^\n]*)\n[╌─━]{3,}[ \t]*\n/.exec(preview); - if(header && path.resolve(cwd,header[1]!.trim())!==r.expected) return false; - const body=header ? preview.slice(header[0].length) : preview.replace(/^[╌─━]{3,}[ \t]*\n/,''); - const source=event.input.content.split(/\r?\n/), rows=body.split('\n'); + if(header && path.resolve(cwd,header[1]!.trim())!==expected) return undefined; + return header ? preview.slice(header[0].length) : preview.replace(/^[╌─━]{3,}[ \t]*\n/,''); +} + +/** Bind every displayed source row to the sole pending native Write, not its basename alone. */ +function currentCreatePreview(preview: string, r: any, config: string, cwd: string, startedAt: number, file: string): boolean { + const content=pendingOwnedWriteContent(r,config,cwd,startedAt,file); + const body=content===undefined ? undefined : writePreviewBody(preview,cwd,r.expected); + if(content===undefined||body===undefined) return false; + const source=content.split(/\r?\n/), rows=body.split('\n'); const numbered:Array<{line:number;text:string}>=[]; let leading=''; for(const row of rows) { @@ -356,6 +368,72 @@ function currentCreatePreview(preview: string, r: any, config: string, cwd: stri (!i||row.line===numbered[i-1]!.line+1)&&compact(row.text)===compact(source[row.line-1]!)); } +/** A Write overwrite's visible rows are one contiguous window of its diff: '-' rows carry + * current-file line numbers, '+' and unchanged rows carry the pending content's numbers, + * and an unchanged row also consumes one current-file line. Wrapped rows continue under + * the gutter of their numbered row; a crop may begin inside one, anchored by the next row + * that shares its coordinate. */ +function overwritePreviewMatches(preview: string, before: string, after: string): boolean { + if(before===after) return false; + const lines={'-':before.split(/\r?\n/),'+':after.split(/\r?\n/),' ':after.split(/\r?\n/)} as Record; + const rows:Array<{line:number;kind:string;text:string;gutter:number}>=[]; + const leadingRows:string[]=[]; + for(const row of preview.split('\n')) { + if(!row.trim()||/^[╌─━]{3,}[ \t]*$/.test(row)) continue; + const numbered=/^( {0,3}([1-9]\d*) )([ +\-])(.*)$/.exec(row) ?? /^( {0,3}([1-9]\d*))()[ \t]*$/.exec(row); + if(numbered) { rows.push({line:Number(numbered[2]),kind:numbered[3]||' ',text:numbered[4]??'',gutter:numbered[1]!.length}); continue; } + const last=rows.at(-1); + if(!last) { leadingRows.push(row); continue; } + const indent=' '.repeat(last.gutter)+(last.kind===' '?' ':last.kind); + if(!row.startsWith(indent)) return false; + last.text+=row.slice(indent.length); + } + if(rows.length<2||!rows.some(row=>row.kind!==' ')) return false; + const compact=(value:string)=>value.replace(/\s/g,''); + const at:Record={}; + if(leadingRows.length) { + const gutter=rows[0]!.gutter, kinds=new Set(); + let text=''; + for(const row of leadingRows) { + const kind=row.startsWith(' '.repeat(gutter)+'-')?'-':row.startsWith(' '.repeat(gutter)+'+')?'+':row.startsWith(' '.repeat(gutter+1))?' ':undefined; + if(!kind) return false; + kinds.add(kind); text+=row.slice(gutter+1); + } + const kind=[...kinds][0]!; + const anchor=rows.findIndex(row=>kind==='-'?row.kind==='-':row.kind!=='-'); + if(kinds.size!==1||anchor<0||!compact(text)) return false; + const line=rows[anchor]!.line-1-(kind==='-'?rows.slice(0,anchor).filter(row=>row.kind===' ').length:0); + if(line<1||!compact(lines[kind]![line-1]??'').endsWith(compact(text))) return false; + if(kind==='-') at.old=line+1; else at.new=line+1; + } + for(const row of rows) { + if(row.kind==='-') { + if(at.old!==undefined&&row.line!==at.old) return false; + at.old=row.line+1; + } else { + if(at.new!==undefined&&row.line!==at.new) return false; + at.new=row.line+1; + if(row.kind===' '&&at.old!==undefined) { + if(compact(lines['-']![at.old-1]??'\0')!==compact(row.text)) return false; + at.old++; + } + } + if(row.line>lines[row.kind]!.length||compact(row.text)!==compact(lines[row.kind]![row.line-1]!)) return false; + } + return true; +} + +/** Bind a cropped overwrite to the sole pending owned Write and the current owned file bytes. */ +function currentOverwritePreview(preview: string, r: any, config: string, cwd: string, startedAt: number, file: string): boolean { + try { + const content=pendingOwnedWriteContent(r,config,cwd,startedAt,file); + const body=content===undefined ? undefined : writePreviewBody(preview,cwd,r.expected); + if(content===undefined||body===undefined) return false; + const bytes=boundedRegular(r.expected,MAX_WRITE_INPUT_BYTES), before=bytes.toString('utf8'); + return Buffer.from(before).equals(bytes) && overwritePreviewMatches(body,before,content); + } catch { return false; } +} + /** One Edit's exact visible diff against the current owned file bytes. */ function editPreviewMatches(preview: string, input: any, before: string): boolean { if (typeof input?.old_string !== 'string' || !input.old_string || typeof input.new_string !== 'string' || @@ -462,15 +540,16 @@ export function currentFilePermissionEpoch(file: string | undefined, expected: s cwd: string, config: string | null, startedAt: number, transcript: PlanCountTranscript, screen: string): FilePermissionEpoch | null | undefined { if (!file || !expected || !config) return undefined; - const panel = [...screen.matchAll(/(?:^|\n) {0,3}(?:Create|Edit|Write) file[ \t]*\n {0,3}([^\n]+)\n/g)].at(-1); - const create = panel ? undefined : croppedCreatePane(screen, expected); + const panel = [...screen.matchAll(/(?:^|\n) {0,3}(?:Create|Edit|Write|Overwrite) file[ \t]*\n {0,3}([^\n]+)\n/g)].at(-1); + const create = panel ? undefined : croppedWritePane(screen, expected, 'create'); + const overwrite = panel ? undefined : croppedWritePane(screen, expected, 'overwrite'); const edit = panel ? undefined : croppedEditPane(screen); const target = panel ? path.resolve(cwd,panel[1]!.trim()) : croppedEditTarget(screen, cwd, expected) ?? - (create?.basename===path.basename(expected) ? expected : undefined); + ((create ?? overwrite)?.basename===path.basename(expected) ? expected : undefined); if (target !== expected) { // A foreign path with this report's basename cannot fall back to a stale // owned grant. An incomplete owned menu also waits for full path identity. - const prompt = [...screen.matchAll(/^ {0,3}Do you want to (?:make this edit to|create) ([^\n?\/\\]+)\?[ \t]*$/gm)].at(-1); + const prompt = [...screen.matchAll(/^ {0,3}Do you want to (?:make this edit to|create|overwrite) ([^\n?\/\\]+)\?[ \t]*$/gm)].at(-1); return (target && path.basename(target) === path.basename(expected)) || prompt?.[1]?.trim() === path.basename(expected) ? null : undefined; } @@ -493,6 +572,7 @@ export function currentFilePermissionEpoch(file: string | undefined, expected: s r.completedIds.some((id: string) => id === r.pendingId || !r.seenIds.includes(id)) || (r.completedId === null ? r.completedIds.length !== 0 : r.completedIds.at(-1) !== r.completedId)) return null; if(create && !currentCreatePreview(create.preview,r,config,cwd,startedAt,file)) return null; + if(overwrite && !currentOverwritePreview(overwrite.preview,r,config,cwd,startedAt,file)) return null; const editId = edit && edit.directory === undefined ? currentEditPreview(edit.preview,r,config,cwd,startedAt,file) : undefined; if(editId === null) return null; return {pendingId:editId ?? r.pendingId,completedId:r.completedId,completedIds:r.completedIds}; diff --git a/test/plan-count-file-permission.test.ts b/test/plan-count-file-permission.test.ts index 41320c782..21d6b0ce2 100644 --- a/test/plan-count-file-permission.test.ts +++ b/test/plan-count-file-permission.test.ts @@ -5,9 +5,11 @@ import * as path from 'node:path'; import {pathToFileURL} from 'node:url'; import {createFilePermissionRecorder,recordFilePermission,currentFilePermissionEpoch} from './helpers/plan-count-file-permission'; import {createPlanCountPermissionGuard,classifyPlanCountFrame} from './helpers/claude-pty-runner'; +import {readPlanCountTranscript} from './helpers/plan-count-transcript'; import captured from './fixtures/plan-count-edit-permission-t.json'; import capturedAc from './fixtures/plan-count-permission-ac.json'; import largeCeo from './fixtures/ceo-report-permission-fb10.json'; +import overwriteCaptured from './fixtures/plan-ceo-floor-overwrite-permission.json'; function fixture() { const dir=fs.mkdtempSync(path.join(os.tmpdir(),'count-file-epoch-'));const cwd=path.join(dir,'cwd');fs.mkdirSync(cwd); @@ -864,3 +866,144 @@ process.stdout.write('PTY_READY:'+item.events+'\x1b[2J\x1b[H'); fs.rmSync(dir,{recursive:true,force:true});} },32000); }); + +/** + * plan-ceo-finding-floor, E2E Evals census 42252633016 paid-slice-7-a1 (main cf4d847e): the model rewrote its + * owned working plan with Write, Claude Code showed a cropped "Do you want to overwrite ...?" diff, and no owned + * binding came back, so the floor actor left the menu unanswered for 20 s (permission_unanswered). The overwrite + * now binds like a cropped Create: to the sole pending owned Write, with every visible diff row checked against + * the current file bytes ('-') and the pending content ('+' and unchanged rows). + */ +describe('cropped overwrite of the owned working plan', () => { + const captured = overwriteCaptured; + const sessionId = '61cc3af3-2822-44d8-9003-f1ebf5696b47', id = 'toolu_013g2nXtwb4Kt8Jv8QSr4ku9'; + + function fixture(options: {before?: string; content?: string; hookContent?: string} = {}) { + const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'overwrite-pane-'))); + const cwd = path.join(dir, 'cwd'), config = path.join(dir, 'config'); + fs.mkdirSync(cwd); + const expected = path.join(cwd, captured.basename); + fs.writeFileSync(expected, options.before ?? captured.before); + const journal = path.join(config, 'projects', 'owned', sessionId + '.jsonl'); + fs.mkdirSync(path.dirname(journal), {recursive: true}); + const recorder = createFilePermissionRecorder(cwd, config, expected)!; + const startedAt = Date.now() - 1000, timestamp = new Date().toISOString(); + const input = {file_path: expected, content: options.content ?? captured.content}; + const block = {type: 'tool_use', id, name: 'Write', input}; + const native = (role: string, content: any[]) => ({cwd, sessionId, isSidechain: false, timestamp, message: {role, content}}); + const original = [native('assistant', [{type: 'text', text: 'Updating the working plan.'}, block])]; + const write = (rows: any[]) => fs.writeFileSync(journal, rows.map(r => JSON.stringify(r)).join('\n') + '\n'); + write(original); + recordFilePermission(JSON.stringify({hook_event_name: 'PreToolUse', tool_name: 'Write', session_id: sessionId, + tool_use_id: id, cwd, transcript_path: journal, tool_input: {...input, content: options.hookContent ?? input.content}}), + recorder.file, cwd, config, expected); + const read = (screen = captured.screen) => currentFilePermissionEpoch(recorder.file, expected, cwd, config, startedAt, + readPlanCountTranscript(config, cwd), screen); + return {dir, cwd, expected, block, native, original, write, read, + close() { recorder.dispose(); fs.rmSync(dir, {recursive: true, force: true}); }}; + } + + const lines = captured.screen.split('\n'); + const replaceRow = (screen: string, from: string, to: string) => { + expect(screen).toContain(from); + return screen.replace(from, to); + }; + + test('the captured cropped overwrite binds the pending owned Write and grants once', () => { + expect(lines[0]).toMatch(/^ {6}-oper evidence/); + expect(captured.screen).toContain(' Do you want to overwrite gstack-test-plan-ceo-floor.md?'); + const f = fixture(); + try { + const epoch = f.read(); + expect(epoch?.pendingId).toBe(`${sessionId}:${id}`); + const guard = createPlanCountPermissionGuard(); + expect(guard(captured.screen, '', epoch)).toBe('grant'); + expect(guard(captured.screen, '', epoch)).toBe('handled'); + } finally { f.close(); } + }); + + test('a crop that starts on a numbered row, or on the rule above an uncropped diff, still binds', () => { + const f = fixture(); + try { + const from107 = captured.screen.slice(captured.screen.indexOf(' 107 +')); + expect(f.read(from107)?.pendingId).toBe(`${sessionId}:${id}`); + const fromContext = captured.screen.slice(captured.screen.indexOf(' 108 B)')); + expect(f.read(fromContext)?.pendingId).toBe(`${sessionId}:${id}`); + } finally { f.close(); } + }); + + test('a full Overwrite file heading names the owned path; a foreign same-basename heading blocks', () => { + const f = fixture(); + try { + const heading = (target: string) => ' Overwrite file\n ' + target + '\n' + captured.screen; + expect(f.read(heading(captured.basename))?.pendingId).toBe(`${sessionId}:${id}`); + expect(f.read(heading('../elsewhere/' + captured.basename))).toBeNull(); + } finally { f.close(); } + }); + + const screenNegatives: Array<[string, (screen: string) => string]> = [ + ['an added row that is not the pending content', s => replaceRow(s, 'Interview ~10 target developers', 'Interview ~12 target developers')], + ['a deleted row that is not the current file', s => replaceRow(s, " 107 -Ship the cheaper tier on the team's", " 107 -Ship a cheaper tier on the team's")], + ['an unchanged row that is not in either file', s => replaceRow(s, ' 108 B) Launch now as written', ' 108 B) Launch later')], + ['a shifted line number', s => replaceRow(s, ' 109 +Ship', ' 110 +Ship')], + ['a deleted row numbered as the next current line', s => replaceRow(s, ' 112 -- Nothing', ' 113 -- Nothing')], + ['a leading tail that is not the anchored current line', s => s.replace('-oper evidence in 1-2 weeks', '-other evidence in 1-2 weeks')], + ['a wrapped row under the wrong gutter', s => replaceRow(s, ' +eds.', ' +eds.')], + ['a wrapped row whose marker differs from its numbered row', s => replaceRow(s, ' +eds.', ' -eds.')], + ['a quoted pane', s => s.split('\n').map(row => '> ' + row).join('\n')], + ['a Create prompt over an overwrite diff', s => replaceRow(s, 'Do you want to overwrite', 'Do you want to create')], + ['a changed one-time action', s => replaceRow(s, '❯ 1. Yes', '❯ 2. Yes')], + ['a missing footer', s => replaceRow(s, 'Esc to cancel · Tab to amend', '')], + ]; + for (const [name, change] of screenNegatives) test(`${name} cannot bind the overwrite`, () => { + const f = fixture(); + try { + const screen = change(captured.screen); + expect(screen).not.toBe(captured.screen); + const epoch = f.read(screen); + expect(epoch).not.toBeTruthy(); + expect(createPlanCountPermissionGuard()(screen, '', epoch)).not.toBe('grant'); + } finally { f.close(); } + }); + + test('a foreign basename in the overwrite prompt leaves other permission policy alone', () => { + const f = fixture(); + try { expect(f.read(captured.screen.replace('overwrite gstack-test-plan-ceo-floor.md?', 'overwrite other.md?'))).toBeUndefined(); } + finally { f.close(); } + }); + + test('stale file bytes, a changed hook witness and an unchanged rewrite cannot bind', () => { + const staleLine = (n: number) => captured.before.split('\n').map((line, i) => i === n - 1 ? line + ' (since changed)' : line).join('\n'); + for (const options of [{before: staleLine(107)}, {before: staleLine(106)}, {before: staleLine(105)}, + {hookContent: captured.content + 'extra'}, {content: captured.before, hookContent: captured.before}]) { + const f = fixture(options); + try { expect(f.read(), JSON.stringify(Object.keys(options))).toBeNull(); } finally { f.close(); } + } + }); + + for (const variant of ['foreign-path', 'second-pending', 'completed', 'wrong-tool', 'wrong-session']) + test(`an overwrite rejects ${variant} native identity`, () => { + const f = fixture(); + try { + const changed = structuredClone(f.block) as any; + if (variant === 'foreign-path') changed.input.file_path = path.join(f.dir, captured.basename); + if (variant === 'wrong-tool') changed.name = 'Edit'; + const rows: any[] = [f.native('assistant', [{type: 'text', text: 'Updating the working plan.'}, changed])]; + if (variant === 'second-pending') rows.push(f.native('assistant', [{...f.block, id: 'toolu_second'}])); + if (variant === 'completed') rows.push(f.native('user', [{type: 'tool_result', tool_use_id: id, content: 'done'}])); + if (variant === 'wrong-session') rows[0].sessionId = 'foreign'; + f.write(rows); + expect(f.read()).toBeNull(); + } finally { f.close(); } + }); + + test.skipIf(process.platform === 'win32')('a symlinked owned file cannot supply the current bytes', () => { + const f = fixture(); + try { + const target = path.join(f.dir, 'other.md'); + fs.renameSync(f.expected, target); + fs.symlinkSync(target, f.expected); + expect(f.read()).toBeNull(); + } finally { f.close(); } + }); +}); diff --git a/test/setup-hook-syntax-gate.test.ts b/test/setup-hook-syntax-gate.test.ts index 43982ffd6..69fe989e1 100644 --- a/test/setup-hook-syntax-gate.test.ts +++ b/test/setup-hook-syntax-gate.test.ts @@ -250,6 +250,50 @@ describe('gstack-hook-check: what fails', () => { expect(r.code).toBe(0); expect(r.out).toContain('missing hosts/claude/hooks/timeline-stop-hook'); }); + + // Free Tests 42327570347 shard 8: Bun segfaulted in the bundle probe, and setup + // reported four healthy hooks as "do not parse". A crash makes the check + // unavailable (exit 3, like a missing bun); a bun that exits normally without + // a report still fails every entry it did not report. + test.skipIf(process.platform === 'win32')('a bun killed by a signal makes the check unavailable instead of failing hooks', () => { + const root = tmpBase(); + fixtureTree(root); + const fake = (name: string, body: string) => { + const file = path.join(tmpBase(), name); + fs.writeFileSync(file, `#!/bin/sh\n${body}\n`, { mode: 0o755 }); + return file; + }; + const run = (bun: string) => { + const r = spawnSync('bash', [CHECK, root], { encoding: 'utf8', timeout: 30_000, env: { ...process.env, GSTACK_HOOK_CHECK_BUN: bun } }); + return { code: r.status, stdout: r.stdout, stderr: r.stderr }; + }; + const crashed = run(fake('bun-segv', 'kill -SEGV $$')); + expect(crashed.code).toBe(3); + expect(crashed.stderr).toContain('bun was killed by signal 11 while bundling the TypeScript hook entries, so they cannot be checked'); + expect(crashed.stdout).not.toContain('fail '); + expect(crashed.stdout).not.toContain('ok hosts/claude/hooks/timeline-stop-hook'); + const helper = path.join(root, 'careful/bin/hook-extract.sh'); + fs.appendFileSync(helper, 'if then\n'); + const brokenShell = run(fake('bun-segv-2', 'kill -SEGV $$')); + expect(brokenShell.code).toBe(1); + expect(brokenShell.stdout).toMatch(/^fail careful\/bin\/check-careful\.sh .*: syntax error/m); + expect(brokenShell.stdout).not.toContain('bun could not bundle the TypeScript entry'); + const silent = run(fake('bun-exit-1', 'exit 1')); + expect(silent.code).toBe(1); + expect(silent.stdout).toContain('bun could not bundle the TypeScript entry'); + }); + + test.skipIf(process.platform === 'win32')('a failure bun reported before it was killed still fails its hook', () => { + const root = tmpBase(); + fixtureTree(root); + const entry = path.join(root, 'hosts/claude/hooks/timeline-stop-hook.ts'); + const bun = path.join(tmpBase(), 'bun-fail-then-segv'); + fs.writeFileSync(bun, `#!/bin/sh\nprintf '%s\\tfail\\t%s:3\\tSyntax error\\n' '${entry}' '${entry}'\nkill -SEGV $$\n`, { mode: 0o755 }); + const r = spawnSync('bash', [CHECK, root], { encoding: 'utf8', timeout: 30_000, env: { ...process.env, GSTACK_HOOK_CHECK_BUN: bun } }); + expect(r.status).toBe(1); + expect(r.stdout).toContain(`fail hosts/claude/hooks/timeline-stop-hook ${entry}:3: Syntax error`); + expect(r.stdout).not.toContain('bun could not bundle the TypeScript entry'); + }); }); describe('setup: registers the hooks that parse, refuses the rest, exits non-zero', () => {