@tablation/crew 0.43.0 → 0.43.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -3184,6 +3184,7 @@ var DEFAULT_CONTRACT = {
3184
3184
  blockedBy: "blocked_by",
3185
3185
  updatedAt: "updated_at",
3186
3186
  needsDesign: "needs_design",
3187
+ attachments: "attachments",
3187
3188
  workMode: "work_mode",
3188
3189
  reportType: "report_type",
3189
3190
  epic: "epic_id",
@@ -4786,6 +4787,7 @@ var Tracker = class {
4786
4787
  * so concurrent first writes share one round of field-metadata reads.
4787
4788
  */
4788
4789
  authorship;
4790
+ attachments;
4789
4791
  stateDir;
4790
4792
  constructor(route, ship) {
4791
4793
  this.route = route;
@@ -5238,6 +5240,28 @@ var Tracker = class {
5238
5240
  shipId: shipRow?.id ?? null
5239
5241
  };
5240
5242
  }
5243
+ /**
5244
+ * The attachments probe (CREW-1558): which table the Issues `attachments`
5245
+ * column references, read once per process like the authorship probe. On
5246
+ * TABL-1557 two runs reported a ticket's screenshot as a 404 because, with
5247
+ * no table id in hand, they guessed `files/<attachment id>` paths; naming
5248
+ * the table in the Environment section is what makes the lookup mechanical.
5249
+ */
5250
+ attachmentsProbe() {
5251
+ this.attachments ??= this.probeAttachments();
5252
+ return this.attachments;
5253
+ }
5254
+ async probeAttachments() {
5255
+ try {
5256
+ const model = await this.client.dataModels.get(this.models.issues);
5257
+ const field = model.fields.find((f) => f.columnName === this.contract.columns.attachments);
5258
+ const fieldType = field?.fieldType;
5259
+ const target = fieldType?.targetModelId;
5260
+ return { mediaLibraryModelId: typeof target === "string" && target ? target : null };
5261
+ } catch {
5262
+ return { mediaLibraryModelId: null };
5263
+ }
5264
+ }
5241
5265
  async columnNames(modelId) {
5242
5266
  try {
5243
5267
  const model = await this.client.dataModels.get(modelId);
@@ -6874,6 +6898,7 @@ function renderEnvironment(i) {
6874
6898
  const only = repos.length === 1 ? repos[0] : void 0;
6875
6899
  const models = route.resolved?.models;
6876
6900
  const epicsModelId = route.resolved?.epicsModelId;
6901
+ const mediaLibraryModelId = i.attachments?.mediaLibraryModelId ?? null;
6877
6902
  const key = "ISSUE-000";
6878
6903
  const lines = [
6879
6904
  "# Your environment",
@@ -6898,7 +6923,8 @@ function renderEnvironment(i) {
6898
6923
  `- Issues: \`${models.issues}\``,
6899
6924
  `- Comments: \`${models.comments}\``,
6900
6925
  `- Crew: \`${models.crew}\``,
6901
- ...epicsModelId ? [`- Epics: \`${epicsModelId}\``] : []
6926
+ ...epicsModelId ? [`- Epics: \`${epicsModelId}\``] : [],
6927
+ ...mediaLibraryModelId ? [`- Media Library: \`${mediaLibraryModelId}\` (what a ticket's \`${contract.columns.attachments}\` ids point into)`] : []
6902
6928
  );
6903
6929
  }
6904
6930
  const workspaceId = route.resolved?.workspaceId;
@@ -6925,6 +6951,30 @@ function renderEnvironment(i) {
6925
6951
  "status and response body in your comment \u2014 do not write that uploads are",
6926
6952
  "impossible."
6927
6953
  );
6954
+ if (mediaLibraryModelId) {
6955
+ lines.push(
6956
+ "",
6957
+ "## Opening a ticket's attachments",
6958
+ "",
6959
+ `A ticket's or a comment's \`${contract.columns.attachments}\` value is a list of Media Library`,
6960
+ "**record ids** \u2014 not file ids and not URLs. `files/<id>`, `files/<id>/download` and",
6961
+ "`media-library/<id>/...` do not exist for such an id and return 404. For each id,",
6962
+ "read the record, then fetch the file by its storage key:",
6963
+ "",
6964
+ "```sh",
6965
+ `curl -s -H "Authorization: Bearer $${API_KEY_VAR}" -H "User-Agent: ${i.userAgent}" \\`,
6966
+ ` "${base}/api/workspaces/${workspaceId}/data-models/${mediaLibraryModelId}/records/<attachment id>"`,
6967
+ '# \u2192 JSON with `storage_key` ("<uuid>/<filename>") and `mime_type`',
6968
+ `curl -s -o <scratch dir>/<filename> -H "Authorization: Bearer $${API_KEY_VAR}" -H "User-Agent: ${i.userAgent}" \\`,
6969
+ ` "${base}/api/workspaces/${workspaceId}/files/<storage_key>"`,
6970
+ "```",
6971
+ "",
6972
+ "The MCP forms are `records_controller_find_one` (`dataModelId` = the Media Library",
6973
+ "id above) and `files_controller_download` (`uuid` and `filename` = the two halves",
6974
+ "of the storage key). Save the file outside the repository and open it; where it",
6975
+ "disagrees with the description, the picture is the reproduction."
6976
+ );
6977
+ }
6928
6978
  }
6929
6979
  if (i.shipName) {
6930
6980
  lines.push(
@@ -7275,6 +7325,12 @@ async function fetchLatestHistoryId(route, agentsModelId, recordId, userAgent) {
7275
7325
  function promptSourceFields(model, prompt) {
7276
7326
  return model.fields?.some((f) => f.columnName === "prompt_baseline") ? { prompt_source: "crew", prompt_baseline: prompt } : {};
7277
7327
  }
7328
+ function hasPromptBaselineColumn(model) {
7329
+ return model.fields?.some((f) => f.columnName === "prompt_baseline") === true;
7330
+ }
7331
+ function needsBaselineBackfill(model, row) {
7332
+ return hasPromptBaselineColumn(model) && row.prompt_customized !== true && (typeof row.prompt_baseline !== "string" || !row.prompt_source);
7333
+ }
7278
7334
  function isPromptGateRejection(e) {
7279
7335
  return e instanceof TablationApiError && e.status === 422;
7280
7336
  }
@@ -7319,8 +7375,16 @@ async function syncPersonas(route, opts) {
7319
7375
  continue;
7320
7376
  }
7321
7377
  if (row.prompt === defaultPrompt) {
7378
+ let anchoredAt = row.updated_at;
7379
+ if (!opts.dryRun && needsBaselineBackfill(agentsModel, row)) {
7380
+ try {
7381
+ anchoredAt = (await client2.records.update(agentsModel.id, row.id, promptSourceFields(agentsModel, defaultPrompt), row.updated_at)).updated_at;
7382
+ } catch (e) {
7383
+ if (!(e instanceof StaleWriteError6) && !isPromptGateRejection(e)) throw e;
7384
+ }
7385
+ }
7322
7386
  const historyId = await fetchLatestHistoryId(route, agentsModel.id, row.id, opts.userAgent);
7323
- agentPersonas[role] = { agentId: row.id, lastSyncedUpdatedAt: row.updated_at, ...historyId ? { historyId } : {} };
7387
+ agentPersonas[role] = { agentId: row.id, lastSyncedUpdatedAt: anchoredAt, ...historyId ? { historyId } : {} };
7324
7388
  outcomes.push({ role, action: "unchanged", agentId: row.id });
7325
7389
  continue;
7326
7390
  }
@@ -7350,20 +7414,28 @@ async function syncPersonas(route, opts) {
7350
7414
  continue;
7351
7415
  }
7352
7416
  const cached = cache[role]?.agentId === row.id ? cache[role] : void 0;
7353
- if (!cached?.historyId) {
7354
- outcomes.push({ role, action: "diverged", agentId: row.id });
7355
- continue;
7356
- }
7357
- let lastSyncedPrompt;
7358
- try {
7359
- const entries = await fetchHistoryEntries(route, agentsModel.id, row.id, opts.userAgent);
7360
- lastSyncedPrompt = entries.find((e) => e.history_id === cached.historyId)?.prompt;
7361
- } catch {
7362
- lastSyncedPrompt = void 0;
7363
- }
7364
- if (lastSyncedPrompt === void 0 || lastSyncedPrompt !== row.prompt) {
7365
- outcomes.push({ role, action: "diverged", agentId: row.id });
7366
- continue;
7417
+ const baselineKnown = hasPromptBaselineColumn(agentsModel) && typeof row.prompt_baseline === "string";
7418
+ if (baselineKnown) {
7419
+ if (row.prompt !== row.prompt_baseline) {
7420
+ outcomes.push({ role, action: "diverged", agentId: row.id });
7421
+ continue;
7422
+ }
7423
+ } else {
7424
+ if (!cached?.historyId) {
7425
+ outcomes.push({ role, action: "diverged", agentId: row.id });
7426
+ continue;
7427
+ }
7428
+ let lastSyncedPrompt;
7429
+ try {
7430
+ const entries = await fetchHistoryEntries(route, agentsModel.id, row.id, opts.userAgent);
7431
+ lastSyncedPrompt = entries.find((e) => e.history_id === cached.historyId)?.prompt;
7432
+ } catch {
7433
+ lastSyncedPrompt = void 0;
7434
+ }
7435
+ if (lastSyncedPrompt === void 0 || lastSyncedPrompt !== row.prompt) {
7436
+ outcomes.push({ role, action: "diverged", agentId: row.id });
7437
+ continue;
7438
+ }
7367
7439
  }
7368
7440
  if (opts.dryRun) {
7369
7441
  outcomes.push({ role, action: "updated", agentId: row.id });
@@ -7385,7 +7457,7 @@ async function syncPersonas(route, opts) {
7385
7457
  continue;
7386
7458
  }
7387
7459
  if (isPromptGateRejection(e)) {
7388
- agentPersonas[role] = { agentId: row.id, lastSyncedUpdatedAt: row.updated_at, historyId: cached.historyId };
7460
+ agentPersonas[role] = { agentId: row.id, lastSyncedUpdatedAt: row.updated_at, ...cached?.historyId ? { historyId: cached.historyId } : {} };
7389
7461
  outcomes.push({ role, action: "overridden", agentId: row.id });
7390
7462
  continue;
7391
7463
  }
@@ -7554,7 +7626,20 @@ async function syncSkills(route, opts) {
7554
7626
  continue;
7555
7627
  }
7556
7628
  if (row.description === skill.description && row.prompt === skill.prompt) {
7557
- agentSkills[skill.name] = { skillId: row.id, lastSyncedUpdatedAt: row.updated_at };
7629
+ let anchoredAt = row.updated_at;
7630
+ if (!opts.dryRun && needsBaselineBackfill(skillsModel, row)) {
7631
+ try {
7632
+ anchoredAt = (await client2.records.update(
7633
+ skillsModel.id,
7634
+ row.id,
7635
+ promptSourceFields(skillsModel, skill.prompt),
7636
+ row.updated_at
7637
+ )).updated_at;
7638
+ } catch (e) {
7639
+ if (!(e instanceof StaleWriteError7) && !isPromptGateRejection(e)) throw e;
7640
+ }
7641
+ }
7642
+ agentSkills[skill.name] = { skillId: row.id, lastSyncedUpdatedAt: anchoredAt };
7558
7643
  outcomes.push({ name: skill.name, action: "unchanged", skillId: row.id });
7559
7644
  continue;
7560
7645
  }
@@ -7563,7 +7648,32 @@ async function syncSkills(route, opts) {
7563
7648
  outcomes.push({ name: skill.name, action: "overridden", skillId: row.id });
7564
7649
  continue;
7565
7650
  }
7566
- const knownBaseline = cache[skill.name]?.skillId === row.id ? cache[skill.name]?.lastSyncedUpdatedAt : void 0;
7651
+ const body = { description: skill.description, prompt: skill.prompt, ...promptSourceFields(skillsModel, skill.prompt) };
7652
+ if (opts.force) {
7653
+ if (opts.dryRun) {
7654
+ outcomes.push({ name: skill.name, action: "forced", skillId: row.id });
7655
+ continue;
7656
+ }
7657
+ try {
7658
+ const updated = await client2.records.update(skillsModel.id, row.id, body);
7659
+ agentSkills[skill.name] = { skillId: row.id, lastSyncedUpdatedAt: updated.updated_at };
7660
+ outcomes.push({ name: skill.name, action: "forced", skillId: row.id });
7661
+ } catch (e) {
7662
+ if (!isPromptGateRejection(e)) throw e;
7663
+ outcomes.push({ name: skill.name, action: "overridden", skillId: row.id });
7664
+ }
7665
+ continue;
7666
+ }
7667
+ let knownBaseline;
7668
+ if (hasPromptBaselineColumn(skillsModel) && typeof row.prompt_baseline === "string") {
7669
+ if (row.prompt !== row.prompt_baseline) {
7670
+ outcomes.push({ name: skill.name, action: "diverged", skillId: row.id });
7671
+ continue;
7672
+ }
7673
+ knownBaseline = row.updated_at;
7674
+ } else {
7675
+ knownBaseline = cache[skill.name]?.skillId === row.id ? cache[skill.name]?.lastSyncedUpdatedAt : void 0;
7676
+ }
7567
7677
  if (!knownBaseline) {
7568
7678
  outcomes.push({ name: skill.name, action: "diverged", skillId: row.id });
7569
7679
  continue;
@@ -7576,7 +7686,7 @@ async function syncSkills(route, opts) {
7576
7686
  const updated = await client2.records.update(
7577
7687
  skillsModel.id,
7578
7688
  row.id,
7579
- { description: skill.description, prompt: skill.prompt, ...promptSourceFields(skillsModel, skill.prompt) },
7689
+ body,
7580
7690
  knownBaseline
7581
7691
  );
7582
7692
  agentSkills[skill.name] = { skillId: row.id, lastSyncedUpdatedAt: updated.updated_at };
@@ -7605,6 +7715,8 @@ function describeSkillSyncOutcome(o) {
7605
7715
  return `${o.name}: already in sync`;
7606
7716
  case "overridden":
7607
7717
  return `${o.name}: override in place \u2014 prompt not synced (clear "Customize prompt" on the Agent Skills row to resume)`;
7718
+ case "forced":
7719
+ return `${o.name}: overwritten from crew's local file (--force)`;
7608
7720
  case "diverged":
7609
7721
  return `${o.name}: HAS LOCAL EDITS \u2014 crew's default is newer but was NOT applied; review ${o.skillId} in the app before syncing again`;
7610
7722
  }
@@ -7629,6 +7741,7 @@ async function fetchSkillOverrides(route, opts) {
7629
7741
  for (const row of rows) {
7630
7742
  const name = row.name;
7631
7743
  if (row.prompt === local.get(name)?.prompt) continue;
7744
+ if (row.prompt_customized !== true && row.prompt === row.prompt_baseline) continue;
7632
7745
  const cached = cache[name];
7633
7746
  if (cached?.skillId === row.id && cached.lastSyncedUpdatedAt === row.updated_at) continue;
7634
7747
  out[name] = row.prompt;
@@ -11987,7 +12100,8 @@ async function buildEnvironment(route, ship, ticket) {
11987
12100
  };
11988
12101
  });
11989
12102
  const authorship = await tracker.authorshipProbe().catch(() => null);
11990
- return renderEnvironment({ route, userAgent: ship.userAgent, repos, contract, sourceTicket: ticket, authorship, shipName: ship.name });
12103
+ const attachments = await tracker.attachmentsProbe().catch(() => null);
12104
+ return renderEnvironment({ route, userAgent: ship.userAgent, repos, contract, sourceTicket: ticket, authorship, attachments, shipName: ship.name });
11991
12105
  }
11992
12106
 
11993
12107
  // src/cli.ts
@@ -12127,7 +12241,7 @@ function usage() {
12127
12241
  crew inbox [--member NAME] your tickets across every workspace (or a colleague's)
12128
12242
  crew connect resolve a workspace's ids into a crew.yaml block (--no-mcp skips registering the Tablation MCP server with Claude Code; --mcp replace overwrites a different one)
12129
12243
  crew disconnect remove what connect installed for Pair (Claude MCP; Codex/Gemini instructions block and MCP entry per ship.pair)
12130
- crew agents sync [route] [--force] push crew's personas AND skill files into the workspace Agents/Agent Skills tables (--force overwrites even a diverged persona)
12244
+ crew agents sync [route] [--force] push crew's personas AND skill files into the workspace Agents/Agent Skills tables (--force overwrites even a diverged persona or skill; never one marked Customize prompt)
12131
12245
  crew agents prompt R [route] print one persona's current prompt (e.g. R=pair, for a SessionStart hook; --harness H adds which CLI it serves)
12132
12246
  crew skills sync [route] push only crew's skill files (e.g. grill-me) into the workspace Agent Skills table
12133
12247
  crew repos add [ROUTE] [NAME] [PATH] attach a local checkout to a route in crew.yaml (--dry-run previews)
@@ -12620,6 +12734,7 @@ ${changed2.map((c) => ` - ${c}
12620
12734
  contract,
12621
12735
  sourceTicket: ticket,
12622
12736
  authorship: await tracker.authorshipProbe().catch(() => null),
12737
+ attachments: await tracker.attachmentsProbe().catch(() => null),
12623
12738
  shipName: cfg.ship.name
12624
12739
  });
12625
12740
  }
@@ -13567,7 +13682,7 @@ ${changed2.map((c) => ` - ${c}
13567
13682
  break;
13568
13683
  }
13569
13684
  if (sub !== "sync") {
13570
- process.stderr.write("crew agents sync [route] [--force] push crew's personas (Developer/Design/QA/Triage/Pair) AND skill files (e.g. grill-me) into the workspace Agents/Agent Skills tables (--force overwrites even a diverged persona)\n");
13685
+ process.stderr.write("crew agents sync [route] [--force] push crew's personas (Developer/Design/QA/Triage/Pair) AND skill files (e.g. grill-me) into the workspace Agents/Agent Skills tables (--force overwrites even a diverged persona or skill; never one marked Customize prompt)\n");
13571
13686
  process.stderr.write(`crew agents prompt <role> [route] print a persona's current prompt (roles: ${ROLE_NAMES.join(", ")})
13572
13687
  `);
13573
13688
  process.exit(2);
@@ -13610,7 +13725,7 @@ ${changed2.map((c) => ` - ${c}
13610
13725
  `);
13611
13726
  let skillResult;
13612
13727
  try {
13613
- skillResult = await syncSkills(target, { userAgent: cfg.ship.userAgent, dryRun });
13728
+ skillResult = await syncSkills(target, { userAgent: cfg.ship.userAgent, dryRun, force });
13614
13729
  } catch (e) {
13615
13730
  if (e instanceof SkillSyncError) {
13616
13731
  process.stderr.write(`crew agents sync: ${e.message}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tablation/crew",
3
- "version": "0.43.0",
3
+ "version": "0.43.2",
4
4
  "description": "A standing team of headless agents that picks work off a Tablation board, does it on a machine you control, and reports back.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -47,9 +47,12 @@ pass.
47
47
  builder's closing comment — what it changed, how it says it verified,
48
48
  what it admits it could not test — is the thing you are checking.
49
49
  Anything it skipped is your first test. **Open the ticket's own
50
- attachments first** (Step 3.4 in the shared policy says how): the
51
- reporter's screenshot is the repro you are judging the fix against. If
52
- you cannot open one, say so and set `needs_info` rather than passing it.
50
+ attachments first**, exactly as Step 3.4 in the shared policy says: read
51
+ the Media Library record for its `storage_key`, then fetch
52
+ `files/<storage_key>` — never a guessed `files/<id>` path. The reporter's
53
+ screenshot is the repro you are judging the fix against. If you cannot
54
+ open one, say so with the exact request and response and set `needs_info`
55
+ rather than passing it.
53
56
  4. **Get the branch and read the diff.** The branch on the remote is what
54
57
  you test, and you never need the builder's worktree: cut your own with
55
58
  `crew worktree <number>` (it prints the path), unless the builder's
@@ -284,12 +284,21 @@ before going further, and never change a ticket's `report_type` yourself.
284
284
  4. **Post a short comment** on what you are about to do (Comments table:
285
285
  `ticket_id`, `team_member_id` = your id, markdown `body`), and further
286
286
  comments at meaningful milestones — the operator wants visible progress,
287
- not just status flips. **Open the ticket's attachments before you start:**
288
- an `attachments` value is a list of Media Library record ids, usually the
289
- repro screenshot, and where it disagrees with the description the picture
290
- is the reproduction. If you cannot open one, that is a blocker (comment,
291
- `needs_info` plus `needs_planning`, stop). The `Ticket Attachments` skill
292
- has the resolution steps.
287
+ not just status flips. **Open the ticket's attachments before you start.**
288
+ An `attachments` value on the ticket or a comment is a list of Media
289
+ Library record ids — not file ids and not URLs, so no `files/<id>` path
290
+ will ever work. For each id: (1) read the Media Library record,
291
+ `GET /api/workspaces/<workspaceId>/data-models/<Media Library id>/records/<id>`
292
+ (the Environment section's "Opening a ticket's attachments" gives the
293
+ table id), and take its `storage_key`; (2) fetch the bytes with
294
+ `GET /api/workspaces/<workspaceId>/files/<storage_key>` (the MCP form is
295
+ `files_controller_download` with `uuid` and `filename` being the two
296
+ halves of the storage key) into a scratch directory and open it; (3)
297
+ compare it with the description — where they disagree, the picture is the
298
+ reproduction. If you cannot open one, that is a blocker (comment naming
299
+ it and the exact failure, `needs_info` plus `needs_planning`, stop); never
300
+ proceed on the prose alone. The `Ticket Attachments` skill has the edge
301
+ cases.
293
302
  5. **Implement the fix.** Read the relevant code first; do not guess at the
294
303
  architecture. **Never bump the version or edit the changelog on this
295
304
  branch** — the release phase assigns the version once per release, over
@@ -12,14 +12,20 @@ you picked up, and on any comment that has them, before you start.
12
12
 
13
13
  1. **Read the Media Library record for that id.** The `attachments` column is
14
14
  a reference into the workspace's Media Library table; its `storage_key`
15
- and `mime_type` are what you need. Take that table's id from the
16
- Environment section if it lists one, otherwise from a workspace-level
17
- model listing — never by reading the whole Issues model definition, which
18
- is over a megabyte.
19
- 2. **Fetch the bytes:** `GET /api/workspaces/<workspaceId>/files/<storage_key>`
20
- with the usual headers (the tracker MCP form is `files_controller_download`,
21
- which hands an image back as a viewable block). Save the file in a scratch
22
- directory outside the repository, then open it.
15
+ and `mime_type` are what you need:
16
+ `GET /api/workspaces/<workspaceId>/data-models/<Media Library id>/records/<id>`
17
+ (MCP: `records_controller_find_one`). The Environment section's "Opening
18
+ a ticket's attachments" names the table id; if it is absent, find the
19
+ table by name in a workspace-level model listing — never by reading the
20
+ whole Issues model definition, which is over a megabyte. The id is a
21
+ record id, not a file id: `files/<id>`, `files/<id>/download` and
22
+ `media-library/<id>/...` all 404 by design.
23
+ 2. **Fetch the bytes:** the `storage_key` has the form `<uuid>/<filename>`;
24
+ `GET /api/workspaces/<workspaceId>/files/<uuid>/<filename>` with the usual
25
+ headers streams it (the MCP form is `files_controller_download` with
26
+ `workspaceId`, `uuid` and `filename`, which hands an image back as a
27
+ viewable block). Save the file in a scratch directory outside the
28
+ repository, then open it.
23
29
  3. **Compare what the picture shows with what the description says.** Where
24
30
  they disagree, the picture is the reproduction.
25
31
 
@@ -46,9 +46,12 @@ pass.
46
46
  builder's closing comment — what it changed, how it says it verified,
47
47
  what it admits it could not test — is the thing you are checking.
48
48
  Anything it skipped is your first test. **Open the ticket's own
49
- attachments first** (Step 3.4 in the shared policy says how): the
50
- reporter's screenshot is the repro you are judging the fix against. If
51
- you cannot open one, say so and set `needs_info` rather than passing it.
49
+ attachments first**, exactly as Step 3.4 in the shared policy says: read
50
+ the Media Library record for its `storage_key`, then fetch
51
+ `files/<storage_key>` — never a guessed `files/<id>` path. The reporter's
52
+ screenshot is the repro you are judging the fix against. If you cannot
53
+ open one, say so with the exact request and response and set `needs_info`
54
+ rather than passing it.
52
55
  4. **Get the branch and read the diff.** The branch on the remote is what
53
56
  you test, and you never need the builder's worktree: cut your own with
54
57
  `crew worktree <number>` (it prints the path), unless the builder's
@@ -284,12 +284,21 @@ before going further, and never change a ticket's `report_type` yourself.
284
284
  4. **Post a short comment** on what you are about to do (Comments table:
285
285
  `ticket_id`, `team_member_id` = your id, markdown `body`), and further
286
286
  comments at meaningful milestones — the operator wants visible progress,
287
- not just status flips. **Open the ticket's attachments before you start:**
288
- an `attachments` value is a list of Media Library record ids, usually the
289
- repro screenshot, and where it disagrees with the description the picture
290
- is the reproduction. If you cannot open one, that is a blocker (comment,
291
- `needs_info` plus `needs_planning`, stop). The `Ticket Attachments` skill
292
- has the resolution steps.
287
+ not just status flips. **Open the ticket's attachments before you start.**
288
+ An `attachments` value on the ticket or a comment is a list of Media
289
+ Library record ids — not file ids and not URLs, so no `files/<id>` path
290
+ will ever work. For each id: (1) read the Media Library record,
291
+ `GET /api/workspaces/<workspaceId>/data-models/<Media Library id>/records/<id>`
292
+ (the Environment section's "Opening a ticket's attachments" gives the
293
+ table id), and take its `storage_key`; (2) fetch the bytes with
294
+ `GET /api/workspaces/<workspaceId>/files/<storage_key>` (the MCP form is
295
+ `files_controller_download` with `uuid` and `filename` being the two
296
+ halves of the storage key) into a scratch directory and open it; (3)
297
+ compare it with the description — where they disagree, the picture is the
298
+ reproduction. If you cannot open one, that is a blocker (comment naming
299
+ it and the exact failure, `needs_info` plus `needs_planning`, stop); never
300
+ proceed on the prose alone. The `Ticket Attachments` skill has the edge
301
+ cases.
293
302
  5. **Implement the fix.** Read the relevant code first; do not guess at the
294
303
  architecture. **Never bump the version or edit the changelog on this
295
304
  branch** — the release phase assigns the version once per release, over
@@ -12,14 +12,20 @@ you picked up, and on any comment that has them, before you start.
12
12
 
13
13
  1. **Read the Media Library record for that id.** The `attachments` column is
14
14
  a reference into the workspace's Media Library table; its `storage_key`
15
- and `mime_type` are what you need. Take that table's id from the
16
- Environment section if it lists one, otherwise from a workspace-level
17
- model listing — never by reading the whole Issues model definition, which
18
- is over a megabyte.
19
- 2. **Fetch the bytes:** `GET /api/workspaces/<workspaceId>/files/<storage_key>`
20
- with the usual headers (the tracker MCP form is `files_controller_download`,
21
- which hands an image back as a viewable block). Save the file in a scratch
22
- directory outside the repository, then open it.
15
+ and `mime_type` are what you need:
16
+ `GET /api/workspaces/<workspaceId>/data-models/<Media Library id>/records/<id>`
17
+ (MCP: `records_controller_find_one`). The Environment section's "Opening
18
+ a ticket's attachments" names the table id; if it is absent, find the
19
+ table by name in a workspace-level model listing — never by reading the
20
+ whole Issues model definition, which is over a megabyte. The id is a
21
+ record id, not a file id: `files/<id>`, `files/<id>/download` and
22
+ `media-library/<id>/...` all 404 by design.
23
+ 2. **Fetch the bytes:** the `storage_key` has the form `<uuid>/<filename>`;
24
+ `GET /api/workspaces/<workspaceId>/files/<uuid>/<filename>` with the usual
25
+ headers streams it (the MCP form is `files_controller_download` with
26
+ `workspaceId`, `uuid` and `filename`, which hands an image back as a
27
+ viewable block). Save the file in a scratch directory outside the
28
+ repository, then open it.
23
29
  3. **Compare what the picture shows with what the description says.** Where
24
30
  they disagree, the picture is the reproduction.
25
31