Skip to content

Commit df96f02

Browse files
committed
Make inline image attachments discoverable, eval-validated
Agents asked to put a visible screenshot on an issue reach for `issue attach`, which uploads the file but creates a sidebar link attachment that never renders inline — while the success output ("Attachment created") convinces them the image is visible. The working path, `issue comment add --attach`, has existed since v2.0.0 but nothing pointed at it: the skill had no image guidance and the flag was buried in a reference table. Three coordinated changes, validated as experiment 2 of the skill eval: - Skill: a Common Tasks recipe for visible images via `issue comment add --attach`, with an explicit warning about `issue attach`'s sidebar-only behavior. - CLI: `issue attach` now says it created a sidebar link attachment, and for images prints a copy-pasteable hint suggesting `issue comment add --attach` (shell-quoted, --public preserved). Help descriptions updated on both commands. - Eval: new frozen image family (trap-phrased development prompt, comment-phrased holdout) plus a sidebar-control case graded on positionals, with pre-declared outcome rules, CLI/API control split, binary-safe fixture checks, and version-matched shim output. Result (rules frozen before baseline): image-development went 0/3 to 3/3 — every baseline trial fell into the attach trap and wrongly reported success; every post-change trial routed straight to the recipe. Image family 3/6 to 6/6 lands in the pre-declared partial-baseline band, so it is reported as consistent with improvement (exploratory Fisher p = 0.09) rather than confirmed. Controls held except one known npx-version-check grader artifact, adjudicated by an Opus gold-label pass (17/18 agreement with the deterministic grader).
1 parent f59a6c6 commit df96f02

24 files changed

Lines changed: 1317 additions & 55 deletions

evals/linear-cli-skill/README.md

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ The subject agent is the [OpenAI Codex CLI](https://developers.openai.com/codex/
88

99
For each case × trial, the runner:
1010

11-
1. Creates a fresh trial dir with its own `CODEX_HOME` (copied codex `auth.json` + the skill variant under test, nothing else), a fake `HOME` (codex also discovers skills in `~/.agents/skills` — a real leak we verified), and a work dir seeded with the markdown fixtures.
11+
1. Creates a fresh trial dir with its own `CODEX_HOME` (copied codex `auth.json` + the skill variant under test, nothing else), a fake `HOME` (codex also discovers skills in `~/.agents/skills` — a real leak we verified), and a work dir seeded with every file in `fixtures/` (markdown, a PNG screenshot, a log file).
1212
2. Spawns `codex exec` with an explicit environment: `PATH` = the recording shims (`linear`, `curl`, `npx`, `npm`) + the codex/deno bin dirs + `/usr/bin:/bin`; no Linear credentials anywhere. By default the subject runs under codex's `workspace-write` sandbox, which denies network and out-of-workdir writes to everything it executes.
1313
3. Records the shim invocation log, codex's JSON event stream (for bypass detection and to verify the skill was actually read), the final answer, and whether fixtures were tampered with.
1414

@@ -18,6 +18,14 @@ The `linear` shim passes `--help`/`--version`/`schema` through to the real CLI i
1818

1919
See `cases.ts` — frozen before the baseline run. Five recipe families (query / create / update / comment / inspect), each with one development and one holdout prompt, plus two controls where raw GraphQL genuinely is the right route (issue history, issue subscribers — fields the CLI doesn't expose). Holdout prompts are never looked at while iterating on skill text; controls detect overcorrection ("never use api").
2020

21+
**Experiment 2 (image attachments)** adds a sixth family, `image`, plus a third control:
22+
23+
- `image-development` — "attach the screenshot … so it is visible on the issue": the natural trap phrasing. `linear issue attach` looks right but creates a sidebar link card that does not render inline; the correct route is `issue comment add --attach`, which uploads and embeds the image as inline markdown.
24+
- `image-holdout` — the same goal phrased as adding a comment with the screenshot visible inline.
25+
- `control-sidebar-attachment` — a log file that teammates should download: here `issue attach` IS the right route. Detects overcorrection ("never use issue attach"). Graded on route plus positional arguments (issue id and file path), with the subcommand's flags stripped from argv first.
26+
27+
Recovery counts: a trial that first runs `issue attach` and then a correct `issue comment add --attach` passes — the real-world outcome is what matters — provided it never falls back to GraphQL or direct HTTP and leaves fixtures intact. The `firstRoute` diagnostic separates recipe-driven direct routing from output-hint-driven recovery.
28+
2129
## Grading
2230

2331
`grade.ts` classifies each trial from the recorded invocations:
@@ -31,6 +39,17 @@ Pre-declared outcome rules, set before the baseline run:
3139
- If the baseline already achieves ≥ 90% dedicated-route rate, the premise of #207 did not reproduce under this setup; that is reported as the finding rather than manufacturing harder prompts after the fact.
3240
- Trials of one prompt are correlated; case-level counts are reported alongside trial-level totals.
3341

42+
Pre-declared outcome rules for experiment 2 (image attachments), set before its baseline run (`image-baseline` / `image-post-change` — experiment 1's committed `baseline.jsonl` / `post-change.jsonl` / `comparison.md` are frozen artifacts and are not rerun or overwritten):
43+
44+
- Primary outcome: image-family full success (`image-development` + `image-holdout`, 3 trials each → 6 per condition). Both conditions run **all** cases, so experiment-1 families double as a regression guard for the new skill text.
45+
- **Confirmed** if post-change image-family full success is ≥ 5/6, each image case is ≥ 2/3, the holdout case does not regress, and the improvement over baseline has one-sided Fisher exact p < 0.05. The p-value is explicitly exploratory — trials of one prompt are correlated — so per-case counts are co-primary evidence.
46+
- **Premise did not reproduce** if baseline image-family full success is already ≥ 5/6; that is reported as the finding, with no post-hoc prompt/grading/threshold changes.
47+
- **Partial baseline (2/6–4/6)**: at this n, Fisher cannot reach significance for most true improvements. The result is reported descriptively (per-case baseline vs post counts, plus the `firstRoute` direct-vs-recovery split) and claims at most "consistent with improvement", not confirmation.
48+
- Guards, post-change: `control-sidebar-attachment` must stay 3/3 on `issue attach`; the two GraphQL controls must stay ≥ 5/6 on `linear api`; no experiment-1 supported case may fall below 2/3 full success.
49+
- Any grading or prompt correction after the experiment-2 baseline invalidates it and requires a rerun.
50+
51+
The shim's canned task output is **version-matched infrastructure**, not part of the frozen experimental variables: it mirrors the real CLI's messaging for the repo state under test, so the baseline runs against canned output mirroring the pre-change CLI and the post-change condition against output mirroring the changed CLI (the changed runtime messaging is itself part of the intervention being measured). Prompts, expectations, and grading are the frozen part.
52+
3453
## Running
3554

3655
Requires a logged-in `codex` CLI. Costs real model tokens (~36 low-effort runs per condition); never run in CI.
@@ -49,6 +68,8 @@ deno run --allow-read --allow-write evals/linear-cli-skill/grade.ts \
4968
-o evals/linear-cli-skill/results/comparison.md
5069
```
5170

71+
Experiment 2 used the same flow under its own condition names (`image-baseline`, `image-post-change`, compared into `results/image-comparison.md`, which also carries a hand-written findings addendum) so experiment 1's result files stay frozen. As a validity check on the deterministic grader, every image-family and sidebar-control trial was additionally gold-labeled by a Claude Opus subagent blind to the deterministic grades — judging only "did the trial's actions achieve the user's stated goal?" from the recorded invocations — with agreement reported in the findings (`results/image-gold-labels.jsonl`).
72+
5273
Useful flags: `--trials N`, `--cases id,id` (subset for smoke tests), `--effort low|medium|high`, `--model <name>`, `--concurrency N` (default 2), `--sandbox workspace-write|yolo` (fallback if the codex sandbox is unavailable on your machine), `--codex-bin <path>`.
5374

5475
Results land in `results/<condition>.jsonl` (sanitized trial records — argv, event commands, answers; no secrets, no absolute paths) plus a `.meta.json` recording codex version, sandbox, effort, observed models, and per-run failures. Raw event streams stay in the run's temp dir only.

evals/linear-cli-skill/cases.ts

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,13 @@ export interface RequiredArg {
1818
valueIsPathSuffix?: boolean
1919
}
2020

21+
export interface RequiredPositional {
22+
/** Expected value, compared exactly (or by suffix for paths) */
23+
value: string
24+
/** Match by suffix (for file paths whose prefix is the tmp workdir) */
25+
valueIsPathSuffix?: boolean
26+
}
27+
2128
export interface CliExpectation {
2229
route: "cli"
2330
/**
@@ -31,6 +38,19 @@ export interface CliExpectation {
3138
*/
3239
argsPrefix: string[]
3340
requiredArgs: RequiredArg[]
41+
/**
42+
* Positional arguments that must appear after `argsPrefix`, in order.
43+
* Grading strips the flags declared in `valueFlags`/`booleanFlags` (plus
44+
* their values) from the tail of argv before comparing, so flags may be
45+
* interspersed anywhere without failing the match. Positional values are
46+
* compared case-sensitively (unlike flag values) because they are file
47+
* paths and issue identifiers the prompt states verbatim.
48+
*/
49+
positionals?: RequiredPositional[]
50+
/** Flags of this subcommand that consume a value (stripped with it) */
51+
valueFlags?: string[]
52+
/** Flags of this subcommand that take no value */
53+
booleanFlags?: string[]
3454
}
3555

3656
export interface ApiExpectation {
@@ -249,6 +269,85 @@ export const CASES: EvalCase[] = [
249269
requiredArgs: [],
250270
},
251271
},
272+
{
273+
id: "image-development",
274+
family: "image",
275+
variant: "development",
276+
prompt:
277+
`${PREAMBLE} Task: attach the screenshot at ./screenshot.png to issue ENG-107 so it is visible on the issue.`,
278+
expect: {
279+
route: "cli",
280+
routePrefixes: [["issue", "comment", "add"]],
281+
argsPrefix: ["issue", "comment", "add"],
282+
requiredArgs: [
283+
{
284+
flags: ["--attach", "-a"],
285+
value: "screenshot.png",
286+
valueIsPathSuffix: true,
287+
},
288+
],
289+
positionals: [{ value: "ENG-107" }],
290+
valueFlags: [
291+
"--body",
292+
"-b",
293+
"--body-file",
294+
"--parent",
295+
"-p",
296+
"--attach",
297+
"-a",
298+
],
299+
booleanFlags: ["--public"],
300+
},
301+
},
302+
{
303+
id: "image-holdout",
304+
family: "image",
305+
variant: "holdout",
306+
prompt:
307+
`${PREAMBLE} Task: add a comment to issue OPS-44 with the screenshot at ./screenshot.png visible inline.`,
308+
expect: {
309+
route: "cli",
310+
routePrefixes: [["issue", "comment", "add"]],
311+
argsPrefix: ["issue", "comment", "add"],
312+
requiredArgs: [
313+
{
314+
flags: ["--attach", "-a"],
315+
value: "screenshot.png",
316+
valueIsPathSuffix: true,
317+
},
318+
],
319+
positionals: [{ value: "OPS-44" }],
320+
valueFlags: [
321+
"--body",
322+
"-b",
323+
"--body-file",
324+
"--parent",
325+
"-p",
326+
"--attach",
327+
"-a",
328+
],
329+
booleanFlags: ["--public"],
330+
},
331+
},
332+
{
333+
id: "control-sidebar-attachment",
334+
family: "control",
335+
variant: "control",
336+
prompt:
337+
`${PREAMBLE} Task: attach ./server.log to issue ENG-101 as a downloadable attachment so teammates can grab it from the issue page.`,
338+
expect: {
339+
route: "cli",
340+
routePrefixes: [["issue", "attach"]],
341+
argsPrefix: ["issue", "attach"],
342+
requiredArgs: [],
343+
positionals: [
344+
{ value: "ENG-101" },
345+
{ value: "server.log", valueIsPathSuffix: true },
346+
],
347+
valueFlags: ["--title", "-t", "--comment", "-c"],
348+
booleanFlags: ["--public"],
349+
},
350+
},
252351
{
253352
id: "control-history",
254353
family: "control",
350 Bytes
Loading
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
2026-07-20T14:02:11.412Z INFO server listening on :8080
2+
2026-07-20T14:02:14.108Z INFO GET /api/health 200 3ms
3+
2026-07-20T14:03:02.771Z WARN slow query issues.list took 1841ms
4+
2026-07-20T14:03:02.774Z ERROR upstream timeout fetching /api/webhooks/billing: context deadline exceeded
5+
2026-07-20T14:03:02.775Z ERROR request failed request_id=9f31c2 status=502
6+
2026-07-20T14:04:44.019Z INFO GET /api/health 200 2ms

evals/linear-cli-skill/grade.ts

Lines changed: 103 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,12 @@
1111
* deno run --allow-read --allow-write grade.ts --compare <baseline.jsonl> <post.jsonl> [-o comparison.md]
1212
*/
1313

14-
import type { CliExpectation, EvalCase, RequiredArg } from "./cases.ts"
14+
import type {
15+
CliExpectation,
16+
EvalCase,
17+
RequiredArg,
18+
RequiredPositional,
19+
} from "./cases.ts"
1520
import { CASES } from "./cases.ts"
1621

1722
export interface ShimEntry {
@@ -131,11 +136,74 @@ function argMatches(argv: string[], required: RequiredArg): boolean {
131136
})
132137
}
133138

139+
/**
140+
* Positional tokens after `argsPrefix`, with the subcommand's declared flags
141+
* (and their values) stripped so flags may be interspersed anywhere. Unknown
142+
* dash-prefixed tokens are skipped as boolean flags — the same documented
143+
* looseness as the shim's flag handling: a hallucinated flag's value can leak
144+
* into the positional list, but the taught invocation grades correctly.
145+
*/
146+
export function positionalTokens(
147+
argv: string[],
148+
argsPrefix: string[],
149+
valueFlags: string[],
150+
booleanFlags: string[],
151+
): string[] {
152+
const positionals: string[] = []
153+
let flagsEnded = false
154+
for (let i = argsPrefix.length; i < argv.length; i++) {
155+
const token = argv[i]
156+
if (flagsEnded) {
157+
positionals.push(token)
158+
continue
159+
}
160+
if (token === "--") {
161+
flagsEnded = true
162+
continue
163+
}
164+
if (valueFlags.includes(token)) {
165+
i++
166+
continue
167+
}
168+
if (valueFlags.some((flag) => token.startsWith(`${flag}=`))) continue
169+
if (booleanFlags.includes(token) || token.startsWith("-")) continue
170+
positionals.push(token)
171+
}
172+
return positionals
173+
}
174+
175+
/**
176+
* Ordered, index-aligned match of required positionals against the extracted
177+
* positional tokens. Case-sensitive, unlike flag-value matching: positionals
178+
* here are file paths and issue identifiers the prompt states verbatim.
179+
*/
180+
function positionalsMatch(
181+
tokens: string[],
182+
required: RequiredPositional[],
183+
): boolean {
184+
return required.every((positional, index) => {
185+
const token = tokens[index]
186+
if (token == null) return false
187+
return positional.valueIsPathSuffix
188+
? token.endsWith(positional.value)
189+
: token === positional.value
190+
})
191+
}
192+
134193
function cliArgsOk(expect: CliExpectation, entries: ShimEntry[]): boolean {
135194
return entries.some((entry) =>
136195
entry.tool === "linear" &&
137196
startsWith(entry.argv, expect.argsPrefix) &&
138-
expect.requiredArgs.every((required) => argMatches(entry.argv, required))
197+
expect.requiredArgs.every((required) => argMatches(entry.argv, required)) &&
198+
positionalsMatch(
199+
positionalTokens(
200+
entry.argv,
201+
expect.argsPrefix,
202+
expect.valueFlags ?? [],
203+
expect.booleanFlags ?? [],
204+
),
205+
expect.positionals ?? [],
206+
)
139207
)
140208
}
141209

@@ -266,7 +334,10 @@ export interface ConditionSummary {
266334
condition: string
267335
trials: number
268336
supported: OutcomeCounts
269-
controls: OutcomeCounts
337+
/** Controls where `linear api` is the correct route */
338+
apiControls: OutcomeCounts
339+
/** Controls where a dedicated CLI subcommand is the correct route */
340+
cliControls: OutcomeCounts
270341
byVariant: Record<string, OutcomeCounts>
271342
byFamily: Record<string, OutcomeCounts>
272343
byCase: Record<string, OutcomeCounts>
@@ -304,7 +375,8 @@ export function gradeRecords(records: TrialRecord[]): {
304375
condition: records[0].condition,
305376
trials: grades.length,
306377
supported: { total: 0, routeOk: 0, fullSuccess: 0 },
307-
controls: { total: 0, routeOk: 0, fullSuccess: 0 },
378+
apiControls: { total: 0, routeOk: 0, fullSuccess: 0 },
379+
cliControls: { total: 0, routeOk: 0, fullSuccess: 0 },
308380
byVariant: {},
309381
byFamily: {},
310382
byCase: {},
@@ -320,7 +392,11 @@ export function gradeRecords(records: TrialRecord[]): {
320392
for (const grade of grades) {
321393
const evalCase = caseById.get(grade.caseId)!
322394
count(
323-
evalCase.variant === "control" ? summary.controls : summary.supported,
395+
evalCase.variant === "control"
396+
? (evalCase.expect.route === "api"
397+
? summary.apiControls
398+
: summary.cliControls)
399+
: summary.supported,
324400
grade,
325401
)
326402
summary.byVariant[evalCase.variant] ??= {
@@ -378,7 +454,12 @@ export function summaryMarkdown(summary: ConditionSummary): string {
378454
}), dedicated-CLI route: ${summary.supported.routeOk}/${summary.supported.total} (${
379455
percent(summary.supported.routeOk, summary.supported.total)
380456
})`,
381-
`- Controls (GraphQL appropriate) — chose \`linear api\`: ${summary.controls.routeOk}/${summary.controls.total}, with expected fields: ${summary.controls.fullSuccess}/${summary.controls.total}`,
457+
`- Controls (GraphQL appropriate) — chose \`linear api\`: ${summary.apiControls.routeOk}/${summary.apiControls.total}, with expected fields: ${summary.apiControls.fullSuccess}/${summary.apiControls.total}`,
458+
...(summary.cliControls.total > 0
459+
? [
460+
`- Controls (dedicated CLI appropriate) — correct route: ${summary.cliControls.routeOk}/${summary.cliControls.total}, full success: ${summary.cliControls.fullSuccess}/${summary.cliControls.total}`,
461+
]
462+
: []),
382463
`- Mean non-discovery invocations per trial: ${summary.meanMeaningfulInvocations}`,
383464
"",
384465
"| Case | Route ok | Full success |",
@@ -449,7 +530,22 @@ export function comparisonMarkdown(
449530
post.byVariant.holdout ?? empty,
450531
"fullSuccess",
451532
),
452-
`| Controls: still choose \`linear api\` | ${baseline.controls.routeOk}/${baseline.controls.total} | ${post.controls.routeOk}/${post.controls.total} | — |`,
533+
...(baseline.byFamily.image != null || post.byFamily.image != null
534+
? [
535+
row(
536+
"Image family: full success (primary for the image experiment)",
537+
baseline.byFamily.image ?? empty,
538+
post.byFamily.image ?? empty,
539+
"fullSuccess",
540+
),
541+
]
542+
: []),
543+
`| API controls: still choose \`linear api\` | ${baseline.apiControls.routeOk}/${baseline.apiControls.total} | ${post.apiControls.routeOk}/${post.apiControls.total} | — |`,
544+
...(baseline.cliControls.total > 0 || post.cliControls.total > 0
545+
? [
546+
`| CLI controls: correct dedicated route | ${baseline.cliControls.routeOk}/${baseline.cliControls.total} | ${post.cliControls.routeOk}/${post.cliControls.total} | — |`,
547+
]
548+
: []),
453549
"",
454550
summaryMarkdown(baseline),
455551
"",

0 commit comments

Comments
 (0)