diff --git a/CHANGELOG.md b/CHANGELOG.md index f6c0708bf..0b6f110fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,24 @@ # Changelog +## [1.91.170.0] - 2026-10-20 + +**/autoplan no longer stops a healthy run with a false "event order" or "init" denial when Claude Code is slow to write its session journal.** Daily flaky-fix pass, group 5 (other paid cases): the same pass clarifies how the office-hours reviewer recovers from a seal error and fixes two plan-review floor harness gaps in reading cropped permission menus. + +### Fixed +- **/autoplan publication guard (`autoplan/bin/phase-publication-hook.ts`).** If you ran /autoplan on Claude Code 2.1.292, the first Phase 1 step could be blocked with "The native parent event order is unavailable or inconsistent" (code `event_order`) or "needs its own successful init" (code `init_own`), even though the init had already run. Claude Code can run the guard's hook before it writes that turn's earlier steps to the session journal, so the guard judged a journal that did not yet show them. The guard now keeps reading inside its existing 2-second window while the journal shows no step yet, or nothing since your latest message. If Claude Code still has not written it, a journal with no steps lets the call proceed with the existing "unverified" warning (`journal_lag`). A journal that ends at your message gets the normal check, so a run that really skipped init is still denied. Seen on PR gate 39333263804 (`autoplan-guard-pty`, attempt 1). +- **Office-hours spec reviewer (`lib/office-hours-review.ts`).** The reviewer prompt's "Do not use Edit" sat in the sentence about not changing the design, so a reviewer could patch its own verdict file with Edit after a seal error. The prompt now forbids Edit on every file, the verdict file included, and says to Write the complete corrected JSON to the same path and run the seal command again. + +### Tests +- **Plan-review floor harness (`test/helpers/plan-count-file-permission.ts`).** A cropped owned plan-file Edit menu whose top row is the cwd-relative file row (a bare basename for a file in the working directory) now binds to its pending Edit. Before, only an absolute or `./` row was accepted, so `plan-ceo-finding-floor` sat on the menu until its 600 s timeout (PR gate 39293281037, attempt 1). The row must name the menu's own file and still resolve to the owned path. +- **Same harness, quote guard.** The guard that keeps a quoted AskUserQuestion from passing as a permission menu matched any row that began with whitespace and `>`, including a soft-wrapped preview row that continued a table cell with `>activation->paid)`. It now matches a quote or fence only within four columns. Wrapped continuations start at five columns or more, and every preview row is still bound to the owned file's bytes. This stalled focused trial t3 on its owned Create menu (`permission_unanswered` at 92 s). +- `test/autoplan-publication-payload.test.ts` replays the captured journal shape (slash turn and meta skill body, no public event). Flushed inside the window, it gets the normal verdict; never flushed, it is `journal_lag`. Negative controls: a late flush that shows an unpublished phase is still `publication_missing`, an `/autoplan` turn whose init never appears is still `init_own`, and the evaluation denies empty, foreign-session and out-of-order events as `event_order`. Without the fix, four of these fail with `event_order` and `init_own`. +- `test/plan-edit-cropped-permission.test.ts` adds the captured gate viewport as fixture case `ci-39293281037-a1` (Edit input reconstructed from its diff rows, as for t07 and t08). It is granted once from its own PreToolUse input. Negative controls: a foreign directory, a foreign basename, a numbered diff row, a changed diff row and an unwitnessed Edit are refused. +- `test/plan-create-prepublication.test.ts` adds trial t3's viewport as `test/fixtures/plan-ceo-floor-create-quote-wrap.json`. It is granted once from its own PreToolUse Write input. Negative controls: a quoted row, a fence and a checkbox above the menu, and a changed continuation row, are refused. +- `test/office-hours-review.test.ts` pins the reviewer's seal-error recovery. `test/office-hours-completion.test.ts` checks that the validator accepts a seal error recovered by a complete re-Write and rejects the same recovery done with Edit. +- Gate integrity: no threshold, trial count, case kind, quarantine or budget changed. + +### Gate integrity + ## [1.91.169.0] - 2026-10-20 **The docs-sync fixture now accepts joined Git reads, `for` loops over literal file names and a pipe into `tr` as reads, and tells the actor that the Claude config dir is off-limits. The late-result fixture now says what its one repair corrects.** In the last 20 Opus gates, `ship-docsync-current` went red 3 times. Two of those were pure reads the validator rejected: `cat personal-note.txt; echo ---; git diff main HEAD` (run 39347827595) and `for f in …; do echo "=== $f"; cat $f; done` (run 39344306426). The third (run 39338691964) was a real deviation: a stray `for_none=1;` assignment, plus the parent reading its child's session transcript under the fixture's CLAUDE_CONFIG_DIR. Both still fail. Focused trials then hit `git ls-files -z … | tr '\0' '\n'` twice, a form Fable also used in run 39186278010. `ship-docsync-late-result` went red once (run 39367310001): the parent stopped the hung child and judged that no concrete correction was available, because the prompt never said what the declared repair corrects. Focused Opus 5.5 trials: on the final tree, `ship-docsync-current` 4/5 and `ship-docsync-completion` 5/5. The current red was an unquoted `--include=*` that the shared grammar rejects as a globbed option, which is outside this change, and it passed its rule-16 rerun. Earlier trees: `ship-docsync-current` 5/5 (before `tr`), `ship-docsync-late-result` 5/5 (first repair), and one trial each of the other nine docs-sync cases, all passing. diff --git a/VERSION b/VERSION index c06066a6c..655eb2be3 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.91.169.0 +1.91.170.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 19e93814b..83c065f3a 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.91.169.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.91.170.0 — regenerate/re-copy after upgrading gstack Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed for agent hosts without a full skill install. The full skills add workflows, diff --git a/autoplan/bin/phase-publication-hook.ts b/autoplan/bin/phase-publication-hook.ts index 2569193b0..41b95ec83 100644 --- a/autoplan/bin/phase-publication-hook.ts +++ b/autoplan/bin/phase-publication-hook.ts @@ -697,8 +697,11 @@ export function ownedRead(journal: string, owners: string[], input: PublicationH * A ready journal holding the current call takes the journal path; one that * lacks it takes the payload path at once (Claude Code may write the record only * with its result). The 2-second window remains for a journal that does not exist - * yet, a journal lagging more than one message, and the unrecognized-shape double - * read. Claude Code's appends never fail a read; a changed prefix is `rewritten`. + * yet, a journal lagging more than one message, a journal with no public event or + * none after its latest human turn (Claude Code 2.1.292 can flush the turn's + * earlier calls after this one's hook; at expiry an empty journal is journal lag + * and a human-turn tail takes the payload path), and the unrecognized-shape + * double read. Claude Code's appends never fail a read; a changed prefix is `rewritten`. */ export async function runPublicationHook(value: unknown, root: string): Promise { return approveOwnedRead(value, await guardOutput(value, root), root); @@ -731,7 +734,9 @@ async function guardOutput(value: unknown, root: string): Promise { if (read.transcript.status === 'ready') { if (read.events.some(e => e.kind === 'use' && e.toolUseId === input.tool_use_id)) return decided(journalDecision(input, root, read.events, version), 'journal', version); - if (!journalLagged(read.events)) return decided(payloadDecision(input, root, read.events), 'payload', version); + const tail = read.events.at(-1)?.kind; + if (tail && !journalLagged(read.events) && (tail !== 'user_turn' || expired)) + return decided(payloadDecision(input, root, read.events), 'payload', version); if (expired) return unverifiedOutput('journal_lag', {}, version, 'payload'); } else if (code === 'too_large') { return unverifiedOutput('too_large', { size: measure.recordBytes ? mib(measure.recordBytes) : undefined, limit: mib(ownedRecordLimit()) }, diff --git a/lib/office-hours-review.ts b/lib/office-hours-review.ts index ad74ba386..017a76266 100644 --- a/lib/office-hours-review.ts +++ b/lib/office-hours-review.ts @@ -248,8 +248,8 @@ Verdict: ${verdictPath} Use only Read, Write, and the one Bash seal command for this review. Read the design at ${JSON.stringify(document)} with Read and ${previous ? `perform a delta re-review against the preceding verdict and the exact design changes captured below.` - : 'review all 5 dimensions independently, including new defects.'} Do not use Edit, and do not change the design. -Use Write to save your complete verdict as JSON to ${JSON.stringify(verdictPath)}. Then run the \`Seal:\` command from your dispatch message with Bash, exactly as given; it validates the saved file and prints one receipt line. If it reports an error, correct the saved JSON with Write and run the same command again. Use Bash for nothing else. + : 'review all 5 dimensions independently, including new defects.'} Never use Edit on any file, your verdict file included, and do not change the design. +Use Write to save your complete verdict as JSON to ${JSON.stringify(verdictPath)}. Then run the \`Seal:\` command from your dispatch message with Bash, exactly as given; it validates the saved file and prints one receipt line. If it reports an error, Write the complete corrected JSON to the same path (never Edit, even for a one-field fix) and run the same command again. Use Bash for nothing else. Return only that printed \`OFFICE_HOURS_VERDICT round=${round} sha256= path=\` line, unchanged, as your entire response: no JSON, Markdown fences, or prose. The parent verifies the receipt against the saved bytes. The saved JSON is your sole findings inventory: include every unresolved problem and necessary remedy, including minor findings that a short conclusion might omit. Use one finding per distinct obligation. An exact duplicate shares a finding; a shared component does not combine separate decisions, behavior, or effort. diff --git a/package.json b/package.json index 7127fda12..08a5766d7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.91.169", + "version": "1.91.170", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module", diff --git a/test/autoplan-publication-payload.test.ts b/test/autoplan-publication-payload.test.ts index ca7b86446..0515e426f 100644 --- a/test/autoplan-publication-payload.test.ts +++ b/test/autoplan-publication-payload.test.ts @@ -8,8 +8,10 @@ import { afterEach, describe, expect, test } from 'bun:test'; import * as fs from 'node:fs'; import * as path from 'node:path'; -import { guardFixture, section, denial, codeOf, type GuardFixture } from './helpers/autoplan-guard-fixture'; -import { AGENT_KEYS, CHECKED_CLAUDE_CODE, nativeToolInput, newerThanChecked } from '../autoplan/bin/phase-publication-hook.ts'; +import * as crypto from 'node:crypto'; +import { guardFixture, section, denial, codeOf, ROOT, type GuardFixture } from './helpers/autoplan-guard-fixture'; +import { AGENT_KEYS, CHECKED_CLAUDE_CODE, evaluatePayloadPublication, nativeToolInput, newerThanChecked } from '../autoplan/bin/phase-publication-hook.ts'; +import { readOwnedClaudePublicTranscript } from '../lib/claude-owned-journal'; import { nativePathSpelling } from '../lib/claude-public-transcript'; import captured from './fixtures/claude-agent-payload-2.1.292.json'; import { OWNED_READ_APPROVAL } from '../autoplan/bin/owned-read'; @@ -236,6 +238,65 @@ describe('ENG-1: a background reviewer keeps the invocation armed', () => { }); }); +describe('Cause C: the hook runs before Claude Code flushes the turn it acts in (PR gate 39333263804)', () => { + // The PTY eval's slash turn (no /autoplan user_turn), its meta skill body, and nothing else: what the + // failing attempt's first guarded Read saw at 22:25:20, after the model's init Bash had already returned. + const SLASH = 'autoplan-guard-transition\n/autoplan-guard-transition'; + /** Writes the slash turn, its meta skill body and the first `kept` later records; flush() appends the rest, as Claude Code does. */ + function cut(f: GuardFixture, upto: number, kept: number, opening: 'eval' | 'autoplan' = 'eval') { + f.journal(upto); + let rows = fs.readFileSync(f.transcript, 'utf8').trim().split('\n').map(line => JSON.parse(line)); + const meta = { ...rows[0], uuid: crypto.randomUUID(), parentUuid: rows[0].uuid, isMeta: true, origin: undefined, promptId: undefined, + message: { role: 'user', content: [{ type: 'text', text: 'Base directory for this skill: /project/.claude/skills/autoplan' }] } }; + if (rows[1]) rows[1] = { ...rows[1], parentUuid: meta.uuid }; + rows = [opening === 'eval' ? { ...rows[0], message: { role: 'user', content: SLASH } } : rows[0], meta, ...rows.slice(1)]; + const text = (part: object[]) => part.map(r => JSON.stringify(r) + '\n').join(''); + fs.writeFileSync(f.transcript, text(rows.slice(0, 2 + kept))); + return { flush: () => fs.appendFileSync(f.transcript, text(rows.slice(2 + kept))) }; + } + const entry = (f: GuardFixture) => f.input('entry-now', 'Read', { file_path: section('ceo-phase.md'), offset: 1, limit: 20 }); + + test('a ready journal with no public event, flushed inside the window, gets the normal payload verdict', async () => { + const f = make(); const { flush } = cut(f, 2, 0); + expect(readOwnedClaudePublicTranscript(f.transcript, f.cwd, f.sessionId)).toMatchObject({ transcript: { status: 'ready' }, events: [] }); + const timer = setTimeout(flush, 300); + const output = await f.hook(entry(f)); clearTimeout(timer); + expect(denial(output)).toBeUndefined(); + expect(f.log()).toEqual([expect.objectContaining({ decision: 'allow', disposition: 'allow', path: 'payload' })]); + }); + + test('a ready journal that stays without a public event is journal lag at expiry, never event_order', async () => { + const f = make(); cut(f, 2, 0); + const output = await f.hook(entry(f)); + expect(output.hookSpecificOutput.permissionDecision).toBeUndefined(); + expect(codeOf(output)).toBe('journal_lag'); + expect(f.log()).toEqual([expect.objectContaining({ decision: 'allow', disposition: 'unverified', code: 'journal_lag' })]); + }); + + test('control: a late flush that shows an unpublished phase is still denied', async () => { + const f = make(); const { flush } = cut(f, f.steps.length, 0); + const timer = setTimeout(flush, 300); + const output = await f.hook(f.input('next', 'Read', { file_path: section('design-phase.md') })); clearTimeout(timer); + expect(codeOf(output)).toBe('publication_missing'); + }); + + test('an /autoplan turn whose init is flushed late is allowed; control: an init that never appears is init_own', async () => { + const late = make(); const { flush } = cut(late, 2, 0, 'autoplan'); + const timer = setTimeout(flush, 300); + expect(denial(await late.hook(entry(late)))).toBeUndefined(); clearTimeout(timer); + const missing = make(); cut(missing, 2, 0, 'autoplan'); + expect(codeOf(await missing.hook(entry(missing)))).toBe('init_own'); + }); + + test('the evaluation itself still denies empty, foreign-session and out-of-order events as event_order', () => { + const f = make(); f.journal(2); + const events = readOwnedClaudePublicTranscript(f.transcript, f.cwd, f.sessionId).events, input = entry(f); + expect(evaluatePayloadPublication(input, ROOT, events)).toEqual({ allow: true }); + for (const bad of [[], events.map(e => ({ ...e, sessionId: 'foreign-session' })), [...events].reverse()]) + expect(evaluatePayloadPublication(input, ROOT, bad)).toMatchObject({ allow: false, reason: expect.stringContaining('(code event_order,') }); + }); +}); + test('the captured 2.1.292 evidence fixture records Cause A exactly', () => { const forced = captured.forcedRunInBackground; expect(forced.journalInput.run_in_background).toBe('false'); diff --git a/test/fixtures/plan-ceo-floor-create-quote-wrap.json b/test/fixtures/plan-ceo-floor-create-quote-wrap.json new file mode 100644 index 000000000..64e38ab40 --- /dev/null +++ b/test/fixtures/plan-ceo-floor-create-quote-wrap.json @@ -0,0 +1,14 @@ +{ + "source": "Focused paid trial t3 of plan-ceo-finding-floor (flaky-fix pass 2026-10-20, main c580561 plus group-5 repairs): outcome permission_unanswered at 92 s on the owned working-plan Create menu.", + "note": "Exact captured viewport and the owned Write input from its own PreToolUse witness (pendingWriteInputs). A soft-wrapped table row continues with '>activation->paid)', which the quote guard read as a blockquote.", + "sessionId": "58a085ea-fc9e-4852-823e-bb648581db59", + "toolUseId": "toolu_014sC3KNsp4KzaCAjVEBBcwr", + "basename": "gstack-test-plan-ceo-floor.md", + "recorded": { + "outcome": "permission_unanswered", + "summary": "PERMISSION_DIALOG_UNANSWERED: a Claude Code permission dialog stayed open for 20060ms without an owned grant" + }, + "screen": " ate passes | yes, as a time-boxed test cohort\n 52 Developer interviews before launch | pending | none | none | 10-15 interv\n iews | 5-8 interviews to shape the test\n 53 Funnel baseline before launch | pending | none | none | yes (signup-\n >activation->paid) | yes\n 54 Success metric | pending | \"more signups\" | \"more signups\" | baseline+tar\n get+window, plus activation and revenue guardrails | same as B plus pre-registered test bars\n 55 Launch decision rule | pending | team agreement | team agreement | go only if e\n vidence names price as a top barrier | go-wide only if test beats control\n 56 Reversibility | pending | low | low | high until l\n aunch | medium (cohort only)\n 57 ```\n 58\n 59 Question: D1 — PREM-1: Should the dev pricing tier launch on intuition, or only after the \"price is the barrier\"\n premise is checked?\n 60 Header: Premise\n 61 A) Launch the tier as planned\n 62 Keep PLAN.md as written: build and ship the cheaper tier based on team intuition. Effort S (zero extra work beyo\n nd the plan), risk high, no verification. ✅ Fastest path to a visible change, ships this cycle with no research\n delay. ✅ No interview or analytics work is needed before engineering starts. ❌ If price is not the real barri\n er, adoption stays flat and the price cut is hard to undo.\n 63 B) Validate first, then launch (recommended)\n 64 Same tier scope, gated on evidence: 10-15 developer interviews plus a funnel baseline. Success metric becomes ba\n seline+target+window with activation and revenue guardrails. Effort M (human ~2 weeks / CC ~1 day for scripts an\n d analysis), risk low. ✅ Confirms or kills the premise before an irreversible price change ships to everyone.\n ✅ Replaces \"more signups\" with a metric that catches revenue and activation regressions. ❌ Delays launch about\n two weeks and may show the tier is the wrong lever.\n 65 C) Run a price experiment\n 66 Ship the tier to a randomized new-signup cohort against current pricing, with pre-registered success bars and a\n few interviews to shape it. Effort L (human ~4 weeks / CC ~2 days), risk medium. ✅ Gives causal evidence on whe\n ther price moves adoption, not just stated opinions. ✅ Limits blast radius to a cohort, so rollback is cheaper\n than a full launch. ❌ Needs enough signup volume for significance, plus billing support for split pricing.\n╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌\n Do you want to create 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", + "screenSha256": "e0bcf289f30ebfa0ba2821cc4e36651cd2cd1bb6e6798ea8f93e4009c3684eba", + "writeContent": "# CEO Review Working Plan: \"Developer-friendly\" pricing tier\n\nSource under review: PLAN.md (commit e553585). Reviewer: /plan-ceo-review, 2026-10-20.\nReview depth: strategy-only (the plan is a pricing/product strategy with no implementation design).\nMode: HOLD SCOPE (pre-answered in CLAUDE.md: \"For review-mode questions choose HOLD SCOPE (CEO)\"). Mode selection happens in 0E; it approves no plan changes.\n\n## Context\n\nPLAN.md proposes launching a cheaper \"developer-friendly\" pricing tier.\n- Goal: \"Increase developer adoption.\" (PLAN.md:3-4)\n- Success metric: \"More signups.\" (PLAN.md:6-7)\n- Premise: \"We haven't talked to any developers about whether the current pricing is actually a barrier. The team agreed it 'feels like' it should be cheaper.\" (PLAN.md:9-11)\n\n## Pre-review system audit\n\n- Repo has only CLAUDE.md and PLAN.md. One commit (e553585 \"Seed review plan\"). No stashes, no TODOS.md, no FIXME/TODO markers, no design doc, no prior learnings, no recorded decisions.\n- No code exists, so 0B (existing code leverage) has nothing to reuse. Pricing config, billing and signup funnel live outside this repo and are unknown.\n- Landscape (WebSearch; Aside not installed): free/usage-based tiers are the standard dev-tool adoption pattern ([monetizely](https://www.getmonetizely.com/articles/developer-tool-pricing-strategy-how-to-structure-tiers-and-gate-technical-features-for-maximum-adoption)). Unpredictable bills, not only the sticker price, hold back adoption. Q1 2026 brought net *tightening* of dev free tiers, 23 negative changes vs 9 positive ([agentdeals](https://agentdeals.dev/q1-2026-developer-pricing-report)). Layer 3: cutting price only works if price is the barrier. Activation, docs, onboarding friction and bill predictability are usually the bigger levers, and the plan has no evidence on any of them.\n\n## 0A. Premise challenge (finding, pending decision)\n\n- **Real problem:** developer adoption is lower than wanted. Nobody has diagnosed the cause.\n- **What the plan solves:** a guessed cause (price). That is a proxy. The plan admits it has no evidence (PLAN.md:9-11).\n- **Success metric is a proxy too:** \"More signups\" has no baseline, target, timeframe or segment. A cheaper tier can raise signups while revenue, activation or retention fall, and the plan would still call that a win.\n- **Do-nothing cost:** unknown, because there is no measured adoption baseline. That is itself a finding.\n- **Cost of being wrong:** price cuts are hard to reverse (grandfathering, anchoring, cannibalizing paid plans). If price is not the barrier, adoption stays flat, revenue per user drops, and the team loses a quarter of learning.\n\n## Premise table (reality row)\n\n| premise | claim | receipt | verdict |\n|---|---|---|---|\n| P1 | Current pricing is a barrier for developers | PLAN.md:9-11 (\"haven't talked to any developers\") | unverified |\n| P2 | A cheaper tier increases developer adoption | PLAN.md:3-4 | unverified |\n| P3 | Signups are the right measure of adoption | PLAN.md:6-7 | unverified |\n\nunverified=3\n\n## Decision ledger\n\n| ID and owner | Contract and evidence | Current | Proposed | Status | Exact approval and scope |\n|---|---|---|---|---|---|\n| MODE (user, CLAUDE.md) | Review mode | HOLD SCOPE | none | approved | CLAUDE.md: \"For review-mode questions choose HOLD SCOPE (CEO)\" |\n| SCOPE (user, CLAUDE.md) | Product scope = dev pricing tier | Preserve | none | approved | CLAUDE.md: \"Preserve the supplied product scope\"; this does not approve premise/approach |\n| PREM-1 (plan owner / team) | Premise \"pricing is a barrier\" is unvalidated (PLAN.md:9-11) | Build and launch the cheaper tier on team intuition | A) as-is; B) validation gate before any price change; C) instrumented price experiment | unresolved | none yet |\n\n## currentDecision (PREM-1)\nCommitment comparison:\n\n```text\nCommitment | Source/approval or pending | Current | A | B | C\nShip a dev pricing tier (scope) | approved (SCOPE) | yes | yes | yes, after gate passes | yes, as a time-boxed test cohort\nDeveloper interviews before launch | pending | none | none | 10-15 interviews | 5-8 interviews to shape the test\nFunnel baseline before launch | pending | none | none | yes (signup->activation->paid) | yes\nSuccess metric | pending | \"more signups\" | \"more signups\" | baseline+target+window, plus activation and revenue guardrails | same as B plus pre-registered test bars\nLaunch decision rule | pending | team agreement | team agreement | go only if evidence names price as a top barrier | go-wide only if test beats control\nReversibility | pending | low | low | high until launch | medium (cohort only)\n```\n\nQuestion: D1 — PREM-1: Should the dev pricing tier launch on intuition, or only after the \"price is the barrier\" premise is checked?\nHeader: Premise\nA) Launch the tier as planned\nKeep PLAN.md as written: build and ship the cheaper tier based on team intuition. Effort S (zero extra work beyond the plan), risk high, no verification. ✅ Fastest path to a visible change, ships this cycle with no research delay. ✅ No interview or analytics work is needed before engineering starts. ❌ If price is not the real barrier, adoption stays flat and the price cut is hard to undo.\nB) Validate first, then launch (recommended)\nSame tier scope, gated on evidence: 10-15 developer interviews plus a funnel baseline. Success metric becomes baseline+target+window with activation and revenue guardrails. Effort M (human ~2 weeks / CC ~1 day for scripts and analysis), risk low. ✅ Confirms or kills the premise before an irreversible price change ships to everyone. ✅ Replaces \"more signups\" with a metric that catches revenue and activation regressions. ❌ Delays launch about two weeks and may show the tier is the wrong lever.\nC) Run a price experiment\nShip the tier to a randomized new-signup cohort against current pricing, with pre-registered success bars and a few interviews to shape it. Effort L (human ~4 weeks / CC ~2 days), risk medium. ✅ Gives causal evidence on whether price moves adoption, not just stated opinions. ✅ Limits blast radius to a cohort, so rollback is cheaper than a full launch. ❌ Needs enough signup volume for significance, plus billing support for split pricing.\n" +} diff --git a/test/fixtures/plan-ceo-floor-edit-prepublication.json b/test/fixtures/plan-ceo-floor-edit-prepublication.json index ecdf9c197..271017fd4 100644 --- a/test/fixtures/plan-ceo-floor-edit-prepublication.json +++ b/test/fixtures/plan-ceo-floor-edit-prepublication.json @@ -1,6 +1,6 @@ { "source": "Ship-measure round baseline at 6f32737 (local, diagnostic): plan-ceo-finding-floor reds t04, t07, t08 ended permission_unanswered on the owned working-plan Edit menu.", - "note": "t04 Edit inputs are the published native inputs. t07/t08 Edits were never published before the deadline; their inputs are reconstructed from the captured diff rows (every removed row matched the captured Write bytes exactly) and their tool IDs are placeholders.", + "note": "t04 Edit inputs are the published native inputs. t07/t08 Edits were never published before the deadline; their inputs are reconstructed from the captured diff rows (every removed row matched the captured Write bytes exactly) and their tool IDs are placeholders. ci-39293281037-a1 (PR #3292 gate, attempt 1): the Edit was never published before the 600 s deadline; its input is reconstructed the same way and its tool ID is a placeholder. Its viewport keeps the bare cwd-relative file row above the diff rule.", "cases": [ { "trial": "t04", @@ -110,6 +110,34 @@ } } ] + }, + { + "trial": "ci-39293281037-a1", + "source": "PR #3292 @ 62b5f0f gate 39293281037 attempt 1, eval-slices (6): paid-slice-6-a1/shards/skill-e2e-plan-ceo-finding-floor/pty-count/ci-39293281037-1-eval-slices-6/plan-ceo-review-1792431211130-XgsobW (terminal.screen.log, observation.json)", + "sessionId": "ba342bba-8917-4ee1-8118-029be14583c3", + "basename": "gstack-test-plan-ceo-floor.md", + "recorded": { + "outcome": "timeout", + "transcriptStatus": "ready", + "pendingWriteInputs": [], + "publishedEdits": 0 + }, + "screen": " gstack-test-plan-ceo-floor.md\n╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌\n 59 Success metric | pending (P2) | \"more signups\" | unchanged | unchanged here (separate row) | u\n nchanged here (separate row)\n 60 ```\n 61 \n 62 -Question: D1 — P1: Validate the \"price is the barrier\" premise before launching the tier? \n 62 +Question: D1 — P1: Validate the \"price is the barrier\" premise before launching the tier? Project: PLAN.md on main,\n + dev pricing tier. ELI10: The plan says the team never asked developers whether price stops them; it just feels tha\n +t way. If price isn't the real blocker, a cheaper tier cuts revenue and adoption barely moves. Stakes if we pick wr\n +ong: we discount every new developer forever on a guess, or we delay a launch that would have worked. Recommendatio\n +n: A because a week of interviews plus a baseline costs little and tells us whether the tier fixes the actual block\n +er. Note: options differ in kind, not coverage — no completeness score. Net: one to two weeks of validation versus \n +launching blind. \n 63 Header: Premise\n 64 A) Validate first, then launch (recommended)\n 65 +Keep the dev tier in scope but gate launch on 8-10 developer interviews plus a signup/activation baseline. Effort S\n + (human: ~1-2 wks / CC: ~1 hr to draft script and analysis), risk low. ✅ Confirms or kills the price hypothesis be\n +fore discounting revenue permanently. ✅ Produces a baseline so any lift after launch is actually measurable. ❌ De\n +lays the launch by roughly one to two weeks of interviewing. \n 66 B) Launch as written\n 67 +Ship the cheaper tier now on team intuition, no interviews or baseline. Effort S (zero extra validation work), risk\n + high. ✅ Fastest path; the tier is live this cycle with no research overhead. ✅ Real market response is itself da\n +ta, if someone measures it. ❌ No baseline, so nobody can tell whether the tier worked, and price cuts are hard to \n +reverse. \n 68 C) Full adoption-funnel diagnosis\n 67 - \n 68 -(Full option descriptions in the AskUserQuestion payload; mirrored below after answer.) \n 69 +Interviews plus a full funnel audit (awareness, signup, activation, retention) before choosing the tier or other le\n +vers. Effort M (human: ~3-4 wks / CC: ~3 hrs), risk medium. ✅ Finds the real blocker even if it is onboarding or d\n +ocs, not price. ✅ Sets up a durable adoption dashboard for the next 12 months. ❌ Slower and broader; may reopen w\n +hether the tier ships at all. \n╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌\n Do you want to make this edit to 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", + "screenSha256": "45fc740c0063c7d7129000e310b5be9dfe8d7906fd0eee245233d337d79b327a", + "writeContent": "# CEO Review Working Plan: \"Developer-friendly\" pricing tier\n\nSource under review: `PLAN.md` (commit ca5b9f0). Review: /plan-ceo-review, 2026-10-19, branch main.\nReview depth: strategy-only (pricing/product plan, no code or implementation surface in repo).\n\n## Plan under review (verbatim scope, preserved)\n- Goal: Increase developer adoption. (PLAN.md:3-4)\n- Success metric: More signups. (PLAN.md:6-7)\n- Premise: No developer conversations; team \"feels like\" pricing should be cheaper. (PLAN.md:9-11)\n\n## Pre-review system audit\n- Repo: 2 files (CLAUDE.md, PLAN.md), 1 commit, no remote, no stashes, no TODOS.md, no TODO/FIXME markers.\n- No design doc; /office-hours skipped per user instruction (CLAUDE.md).\n- Routing, cross-project recall, outside reviewers: declined by user (CLAUDE.md). Learnings: 0. Decisions on branch: none.\n- Landscape: Aside unavailable; WebSearch used. Findings: free tiers lift top-of-funnel but convert ~2-5% to paid; guidance is to measure activation (first API call / first deploy) rather than signups; median paid dev-tool entry ~$19-20/mo. (business.daily.dev, saasdealer.com)\n\n## Step 0 findings\n\n### 0A Premise challenge\n- Real problem: unknown. Plan asserts low developer adoption but cites no number, and asserts price is the cause with zero evidence (PLAN.md:10-11 says so explicitly).\n- Plan solves a proxy: \"more signups\" is a vanity metric. A cheaper tier can raise signups while activation, retention and revenue fall.\n- Do-nothing cost: unknown, because adoption baseline is unmeasured.\n- Alternative causes not ruled out: onboarding friction, docs, time-to-first-value, missing SDKs, awareness. Price is one hypothesis among several.\n\n### 0B Existing leverage\n- No code in repo. Reusable assets the implementer must prove exist: signup/activation analytics, billing system tier support, current pricing page.\n- REALITY: premise-table finding \"price is the barrier\" unverified, \"adoption is low\" unverified; unverified=2 PLAN.md:10\n- REALITY: already-done n/a no remote/base history beyond seed commit PLAN.md:1\n- REALITY: surface-check n/a plan names no CLI, flag or route PLAN.md:1\n- REALITY: binding-decisions pass no prior decisions, no `## Binding decisions` section PLAN.md:1\n- REALITY: numbers finding plan states no numbers; baseline signups/activation [re-measure before gate] PLAN.md:7\n- REALITY: deferred-asks pass no linked issue PLAN.md:1\n- REALITY: pr-count n/a no ship-policy; non-code plan PLAN.md:1\n\n### 0C Dream state\n```\n CURRENT STATE THIS PLAN 12-MONTH IDEAL\n Adoption baseline unknown --> Cheaper tier on gut feel --> Known activation funnel, price set\n Price-as-barrier assumed Success = \"more signups\" from evidence, measured lift in\n activated + paying developers\n```\nThe plan moves toward the ideal only if the premise turns out true. As written it cannot tell.\n\n## Decision ledger\n\n| ID and owner | Contract and evidence | Current | Proposed | Status | Exact approval and scope |\n|---|---|---|---|---|---|\n| MODE (user) | CLAUDE.md: \"choose HOLD SCOPE (CEO)\" | HOLD SCOPE | none | approved | User instruction in CLAUDE.md; mode only, approves no plan changes |\n| P1 premise/approach (plan owner) | PLAN.md:9-11 premise unverified; no baseline | Launch cheaper dev tier on team intuition | A) as written; B) validate premise first, then launch tier; C) broader adoption-funnel diagnosis | unresolved | pending D1 |\n\n## currentDecision (P1)\nCommitment comparison:\n```text\nCommitment | Source/approval | Current | A | B | C\nDev tier stays in scope | CLAUDE.md scope | yes | yes | yes (gated on evidence) | yes (one of several levers)\nDeveloper interviews | pending | none | none | 8-10 interviews before pricing | 8-10 interviews + funnel audit\nBaseline metrics | pending | none | none | signups + activation baseline | full funnel baseline\nLaunch timing | pending | immediate | immediate | after ~1-2 wk validation | after ~3-4 wk diagnosis\nSuccess metric | pending (P2) | \"more signups\" | unchanged | unchanged here (separate row) | unchanged here (separate row)\n```\n\nQuestion: D1 — P1: Validate the \"price is the barrier\" premise before launching the tier?\nHeader: Premise\nA) Validate first, then launch (recommended)\nB) Launch as written\nC) Full adoption-funnel diagnosis\n\n(Full option descriptions in the AskUserQuestion payload; mirrored below after answer.)\n", + "edits": [ + { + "toolUseId": "toolu_ci39293281037_unpublished_edit", + "timestamp": null, + "published": false, + "reconstructed": true, + "input": { + "old_string": "Question: D1 — P1: Validate the \"price is the barrier\" premise before launching the tier?\nHeader: Premise\nA) Validate first, then launch (recommended)\nB) Launch as written\nC) Full adoption-funnel diagnosis\n\n(Full option descriptions in the AskUserQuestion payload; mirrored below after answer.)", + "new_string": "Question: D1 — P1: Validate the \"price is the barrier\" premise before launching the tier? Project: PLAN.md on main, dev pricing tier. ELI10: The plan says the team never asked developers whether price stops them; it just feels that way. If price isn't the real blocker, a cheaper tier cuts revenue and adoption barely moves. Stakes if we pick wrong: we discount every new developer forever on a guess, or we delay a launch that would have worked. Recommendation: A because a week of interviews plus a baseline costs little and tells us whether the tier fixes the actual blocker. Note: options differ in kind, not coverage — no completeness score. Net: one to two weeks of validation versus launching blind.\nHeader: Premise\nA) Validate first, then launch (recommended)\nKeep the dev tier in scope but gate launch on 8-10 developer interviews plus a signup/activation baseline. Effort S (human: ~1-2 wks / CC: ~1 hr to draft script and analysis), risk low. ✅ Confirms or kills the price hypothesis before discounting revenue permanently. ✅ Produces a baseline so any lift after launch is actually measurable. ❌ Delays the launch by roughly one to two weeks of interviewing.\nB) Launch as written\nShip the cheaper tier now on team intuition, no interviews or baseline. Effort S (zero extra validation work), risk high. ✅ Fastest path; the tier is live this cycle with no research overhead. ✅ Real market response is itself data, if someone measures it. ❌ No baseline, so nobody can tell whether the tier worked, and price cuts are hard to reverse.\nC) Full adoption-funnel diagnosis\nInterviews plus a full funnel audit (awareness, signup, activation, retention) before choosing the tier or other levers. Effort M (human: ~3-4 wks / CC: ~3 hrs), risk medium. ✅ Finds the real blocker even if it is onboarding or docs, not price. ✅ Sets up a durable adoption dashboard for the next 12 months. ❌ Slower and broader; may reopen whether the tier ships at all.", + "replace_all": false + } + } + ] } ] } diff --git a/test/helpers/free-fixtures-data.ts b/test/helpers/free-fixtures-data.ts index fe508b1f1..c2799ab1d 100644 --- a/test/helpers/free-fixtures-data.ts +++ b/test/helpers/free-fixtures-data.ts @@ -152,6 +152,7 @@ export const FREE_FIXTURES: Readonly> = { 'test/fixtures/parity-baseline-v1.44.1.json': ['test/helpers/capture-parity-baseline.test.ts', 'test/parity-baseline-integrity.test.ts'], 'test/fixtures/parity-baseline-v1.64.1.0.json': ['test/parity-suite.test.ts', 'test/qa-lazy-sections.test.ts', 'test/skill-size-budget.test.ts'], '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-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 b68850b50..805ff5c11 100644 --- a/test/helpers/plan-count-file-permission.ts +++ b/test/helpers/plan-count-file-permission.ts @@ -205,11 +205,15 @@ function croppedEditPane(screen: string) { const text = screen.replace(/\r+\n?/g, '\n'); // Cropping may begin inside a wrapped added/deleted diff row (four/five-space gutter). // Still require numbered rows below and the full native footer; never a quoted AUQ. - // The heading can be cropped one row earlier, leaving the complete path. + // A quote or fence starts within four columns; a soft-wrapped row continues at five or more. + // The heading can be cropped one row earlier, leaving the complete path, or + // the cwd-relative file row (a bare basename for a cwd file) that names the menu's own file. // Keep it only when the existing menu independently identifies that target. + const prompt = [...text.matchAll(/^ {0,3}Do you want to make this edit to ([^\n?\/\\]+)\?[ \t]*\n([\s\S]*)$/gm)].at(-1); const header = /^ {0,3}([^\n]+)\n[╌─━]{3,}[ \t]*\n/.exec(text); const headerPath = header?.[1]?.trim(); - const pathOnly = headerPath && (path.isAbsolute(headerPath) || /^\.\.?[/\\]/.test(headerPath)); + const pathOnly = headerPath && (path.isAbsolute(headerPath) || /^\.\.?[/\\]/.test(headerPath) || + (!/^[1-9]\d*(?:\s|$)/.test(headerPath) && path.basename(headerPath) === prompt?.[1]?.trim())); // A crop can start on the single native rule immediately above the diff. let diff = pathOnly ? text.slice(header![0].length) : text.replace(/^[╌─━]{3,}[ \t]*\n/, ''); // A crop may start partway through several soft-wrapped pieces of one @@ -230,8 +234,7 @@ function croppedEditPane(screen: string) { continuation = { tail: tails.join('').trimEnd(), nextLine: Number(nextRow[2]) }; diff = diff.slice(nextRow.index); } - if (!/^(?:\s*\d+\s+[ +\-]?| {4,5}[+\-])/.test(diff) || /[☐□]|^\s*(?:>|`{3}|~{3})/m.test(text)) return undefined; - const prompt = [...text.matchAll(/^ {0,3}Do you want to make this edit to ([^\n?\/\\]+)\?[ \t]*\n([\s\S]*)$/gm)].at(-1); + if (!/^(?:\s*\d+\s+[ +\-]?| {4,5}[+\-])/.test(diff) || /[☐□]|^ {0,4}(?:>|`{3}|~{3})/m.test(text)) return undefined; if (!prompt || (text.slice(0, prompt.index).match(/^\s*\d+\s+/gm)?.length ?? 0) < 2) return undefined; // The unselected option supplies path identity only. Input remains one-time Yes. // A redraw can leave this exact keyboard-hint tail on the unselected No row. @@ -286,7 +289,8 @@ function croppedEditTarget(screen: string, cwd: string, expected: string): strin function croppedCreatePane(screen: string, expected: string): { 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); - if (!pane || /[☐□]|^\s*(?:>|`{3}|~{3})/m.test(pane[1]!)) return undefined; + // 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; // 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. diff --git a/test/office-hours-completion.test.ts b/test/office-hours-completion.test.ts index cf73e5eaa..2ed612a42 100644 --- a/test/office-hours-completion.test.ts +++ b/test/office-hours-completion.test.ts @@ -1040,6 +1040,20 @@ Next: /plan-eng-review after observing the workflow. The user declined launching expect(() => checkArtifacts(evidence, artifacts)).toThrow('observed JSON Write'); }); + test('a seal error recovered by a complete re-Write passes; the same recovery by Edit is not the observed Write', () => { + const recovered = structured(); + const final = recovered.evidence.toolCalls.findLastIndex(call => call.input?.file_path === recovered.artifacts[1].path); + recovered.evidence.toolCalls.splice(final, 0, + { tool: 'Write', input: { file_path: recovered.artifacts[1].path, content: recovered.artifacts[1].content.replace('"round":2', '"round":3') } }, + { tool: 'Bash', input: { command: 'gstack-office-hours-review seal --design design.md --verdict round-2.json round-1.json' }, output: 'Error: verdict round does not match' }); + expect(() => checkArtifacts(recovered.evidence, recovered.artifacts)).not.toThrow(); + const patched = structured(); + const write = patched.evidence.toolCalls.findLast(call => call.input?.file_path === patched.artifacts[1].path)!; + write.input!.content = patched.artifacts[1].content.replace('"round":2', '"round":3'); + patched.evidence.toolCalls.push({ tool: 'Edit', input: { file_path: patched.artifacts[1].path, old_string: '"round":3', new_string: '"round":2' } }); + expect(() => checkArtifacts(patched.evidence, patched.artifacts)).toThrow('artifact differs from its observed Write'); + }); + test('accepts relative and dot-segment Writes to the assigned artifact', () => { const { evidence, artifacts } = structured(); evidence.toolCalls.find(call => call.input?.file_path === artifacts[0].path)!.input!.file_path = './review/round-1.json'; diff --git a/test/office-hours-review.test.ts b/test/office-hours-review.test.ts index f0a44d82b..a5321e801 100644 --- a/test/office-hours-review.test.ts +++ b/test/office-hours-review.test.ts @@ -159,6 +159,12 @@ describe('office-hours severity-based convergence', () => { expect(prompt).toMatch(/- \*\*minor\*\*: clarity, wording, or polish[^\n]+Example:/); expect(prompt).toMatch(/never relabel it minor/); }); + + test('a seal error is corrected by rewriting the whole verdict, never by Edit (ohsl flaky ledger 2026-10-19)', () => { + const prompt = renderOfficeHoursReviewerPrompt({ document: '/tmp/design.md', verdictPath: '/tmp/round-1.json' }); + expect(prompt).toContain('Never use Edit on any file, your verdict file included'); + expect(prompt).toMatch(/If it reports an error, Write the complete corrected JSON to the same path \(never Edit[^)]*\) and run the same command again\./); + }); }); describe('office-hours delta re-review', () => { diff --git a/test/plan-create-prepublication.test.ts b/test/plan-create-prepublication.test.ts index f385ea74d..96ae0ac31 100644 --- a/test/plan-create-prepublication.test.ts +++ b/test/plan-create-prepublication.test.ts @@ -9,6 +9,7 @@ import {createPlanCountPermissionGuard,countingCapture,type CountingRun} from '. import {createPlanCountSnapshotWriter} from './helpers/plan-count-artifacts'; import captured from './fixtures/plan-create-prepublication-491.json'; import toolOnlyCreate from './fixtures/plan-ceo-floor-toolonly-create.json'; +import quoteWrapCreate from './fixtures/plan-ceo-floor-create-quote-wrap.json'; function fixture(){ const root=fs.mkdtempSync(path.join(os.tmpdir(),'gstack-prewrite-'));const cwd=path.join(root,'owned'),config=path.join(root,'config');fs.mkdirSync(cwd); @@ -219,3 +220,38 @@ test('a tool-only session gets no credit for a changed preview or a foreign crea expect(f.read(toolOnlyCreate.screen.replaceAll(toolOnlyCreate.basename,'foreign-plan.md'))).toBeUndefined(); }finally{f.close();} }); + +// Focused trial t3 (2026-10-20): a soft-wrapped table row continued with '>activation->paid)' and the +// blockquote guard refused the owned Create menu until permission_unanswered. +function quoteWrap(){ + const root=fs.mkdtempSync(path.join(os.tmpdir(),'gstack-quote-wrap-')),cwd=path.join(root,'owned'),config=path.join(root,'config');fs.mkdirSync(cwd); + const expected=path.join(cwd,quoteWrapCreate.basename),sid=quoteWrapCreate.sessionId,id=quoteWrapCreate.toolUseId; + const journal=path.join(config,'projects','owned',sid+'.jsonl');fs.mkdirSync(path.dirname(journal),{recursive:true}); + const startedAt=Date.now()-1000,recorder=createFilePermissionRecorder(cwd,config,expected)!,input={file_path:expected,content:quoteWrapCreate.writeContent}; + fs.writeFileSync(journal,JSON.stringify({cwd,sessionId:sid,isSidechain:false,timestamp:new Date().toISOString(),message:{role:'assistant',content:[{type:'text',text:'Writing the working plan.'}]}})+'\n'); + recordFilePermission(JSON.stringify({hook_event_name:'PreToolUse',tool_name:'Write',session_id:sid,tool_use_id:id,cwd,transcript_path:journal,tool_input:input}),recorder.file,cwd,config,expected); + const read=(screen=quoteWrapCreate.screen)=>currentFilePermissionEpoch(recorder.file,expected,cwd,config,startedAt,readPlanCountTranscript(config,cwd),screen); + return {sid,id,read,close(){recorder.dispose();fs.rmSync(root,{recursive:true,force:true});}}; +} + +test('t3: a wrapped preview row that continues with ">" still binds the owned Create once',()=>{ + const f=quoteWrap();try{ + expect(createHash('sha256').update(quoteWrapCreate.screen).digest('hex')).toBe(quoteWrapCreate.screenSha256); + expect(quoteWrapCreate.screen).toContain('\n >activation->paid) | yes\n'); + expect(f.read()?.pendingId).toBe(`${f.sid}:${f.id}`); + const guard=createPlanCountPermissionGuard();expect(guard(quoteWrapCreate.screen,'',f.read())).toBe('grant');expect(guard(quoteWrapCreate.screen,'',f.read())).toBe('handled'); + }finally{f.close();} +}); + +test.each(['quoted-row','fenced-row','checkbox','changed-continuation'])('t3 negative control: %s above the Create menu is refused',mode=>{ + const f=quoteWrap();try{ + const row=' 54 Success metric',screen=quoteWrapCreate.screen; + const changed=mode==='quoted-row'?screen.replace(row,' > Question: D1 approve the tier?\n'+row) + :mode==='fenced-row'?screen.replace(row,' ```\n'+row) + :mode==='checkbox'?screen.replace(row,' ☐ Premise\n'+row) + :screen.replace(' >activation->paid) | yes',' >activation->retained) | yes'); + expect(changed).not.toBe(screen); + expect(f.read(changed)).toBeNull(); + expect(createPlanCountPermissionGuard()(changed,'',f.read(changed))).not.toBe('grant'); + }finally{f.close();} +}); diff --git a/test/plan-edit-cropped-permission.test.ts b/test/plan-edit-cropped-permission.test.ts index 47605bf89..56f7d858a 100644 --- a/test/plan-edit-cropped-permission.test.ts +++ b/test/plan-edit-cropped-permission.test.ts @@ -175,6 +175,34 @@ test.each(['t07','t08'])('%s: an unpublished owned Edit is granted once from its }finally{f.close();} }); +// Gate 39293281037 attempt 1: the viewport kept the bare cwd-relative file row above the diff rule. +test('ci-39293281037-a1: a crop headed by the owned cwd-relative file row is granted once from its PreToolUse input',()=>{ + const f=edits('ci-39293281037-a1');try{ + expect(f.c.recorded).toMatchObject({outcome:'timeout',transcriptStatus:'ready',publishedEdits:0}); + expect(createHash('sha256').update(f.c.screen).digest('hex')).toBe(f.c.screenSha256); + expect(f.c.screen.split('\n')[0]).toBe(' gstack-test-plan-ceo-floor.md'); + expect(isCroppedEditPermissionVisible(f.c.screen)).toBe(true); + const [edit]=f.pending;expect(f.read()).toBeNull(); + f.hook('PreToolUse','Edit',edit!.id,edit!.input); + expect(f.read()?.pendingId).toBe(`${f.sid}:${edit!.id}`); + const guard=createPlanCountPermissionGuard();expect(guard(f.c.screen,'',f.read())).toBe('grant');expect(guard(f.c.screen,'',f.read())).toBe('handled'); + expect(isPermissionDialogVisible(f.c.screen)).toBe(false); + }finally{f.close();} +}); + +test.each(['foreign-directory','foreign-basename','numbered-row','changed-row','unwitnessed'])('ci-39293281037-a1 header negative control: %s is refused',mode=>{ + const f=edits('ci-39293281037-a1');try{ + const [edit]=f.pending;let screen=f.c.screen; + if(mode!=='unwitnessed')f.hook('PreToolUse','Edit',edit!.id,edit!.input); + if(mode==='foreign-directory')screen=screen.replace(' gstack-test-plan-ceo-floor.md\n',' other/gstack-test-plan-ceo-floor.md\n'); + if(mode==='foreign-basename')screen=screen.replace(' gstack-test-plan-ceo-floor.md\n',' notes.md\n'); + if(mode==='numbered-row')screen=screen.replace(' gstack-test-plan-ceo-floor.md\n',' 58 gstack-test-plan-ceo-floor.md\n'); + if(mode==='changed-row')screen=screen.replace('Header: Premise','Header: Pricing'); + expect(f.read(screen)).toBeFalsy(); + expect(createPlanCountPermissionGuard()(screen,'',f.read(screen))).not.toBe('grant'); + }finally{f.close();} +}); + test.each(['before','after'])('t04: the menu binds to the one of three pending owned Edits its diff shows (later hooks %s the menu)',order=>{ const f=edits('t04');try{ expect(f.c.recorded).toMatchObject({outcome:'permission_unanswered',publishedEdits:3});