@tablation/crew 0.12.0 → 0.13.0
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 +238 -117
- package/package.json +1 -1
- package/prompts/default/personas/lane-qa.md +20 -7
- package/prompts/dev-qa/personas/lane-qa.md +20 -7
package/dist/cli.js
CHANGED
|
@@ -1163,6 +1163,90 @@ function ticketsByRepo(r, tickets) {
|
|
|
1163
1163
|
return { byRepo, unplaceable };
|
|
1164
1164
|
}
|
|
1165
1165
|
|
|
1166
|
+
// src/roster.ts
|
|
1167
|
+
function memberByIdentity(roster, identityId) {
|
|
1168
|
+
if (!identityId) return void 0;
|
|
1169
|
+
for (const m of roster.values()) if (m.userId === identityId) return m;
|
|
1170
|
+
return void 0;
|
|
1171
|
+
}
|
|
1172
|
+
function crewLabel(m) {
|
|
1173
|
+
if (!m) return null;
|
|
1174
|
+
if (!m.role) return m.name;
|
|
1175
|
+
if (m.name.toLowerCase().includes(m.role.toLowerCase())) return m.name;
|
|
1176
|
+
return `${m.name} (${m.role})`;
|
|
1177
|
+
}
|
|
1178
|
+
function isHold(roster, id) {
|
|
1179
|
+
if (!id) return false;
|
|
1180
|
+
return roster.get(id)?.kind === "hold";
|
|
1181
|
+
}
|
|
1182
|
+
function holdIds(roster) {
|
|
1183
|
+
return [...roster.values()].filter((m) => m.kind === "hold").map((m) => m.id);
|
|
1184
|
+
}
|
|
1185
|
+
function seats(roster) {
|
|
1186
|
+
return [...roster.values()].filter((m) => m.kind === "seat");
|
|
1187
|
+
}
|
|
1188
|
+
function holds(roster) {
|
|
1189
|
+
return [...roster.values()].filter((m) => m.kind === "hold");
|
|
1190
|
+
}
|
|
1191
|
+
function buildRoster(configured, crewRows) {
|
|
1192
|
+
const rows = new Map(crewRows.map((r) => [r.id, r]));
|
|
1193
|
+
const roster = /* @__PURE__ */ new Map();
|
|
1194
|
+
for (const c of configured) {
|
|
1195
|
+
if (!c.id) continue;
|
|
1196
|
+
const row = rows.get(c.id);
|
|
1197
|
+
const name = (row?.name ?? "").trim();
|
|
1198
|
+
roster.set(c.id, { id: c.id, name: name || c.role, role: c.role, kind: c.kind, userId: row?.user_id ?? null });
|
|
1199
|
+
}
|
|
1200
|
+
return roster;
|
|
1201
|
+
}
|
|
1202
|
+
function rosterMarkdown(roster, meId) {
|
|
1203
|
+
const byRole = (a, b) => a.role.localeCompare(b.role);
|
|
1204
|
+
const me = meId ? roster.get(meId) : void 0;
|
|
1205
|
+
const selfName = crewLabel(me) ?? "an unnamed seat";
|
|
1206
|
+
const lines = [
|
|
1207
|
+
"## Your crew",
|
|
1208
|
+
"",
|
|
1209
|
+
`You are **${selfName}**. Address your shipmates by name in ticket`,
|
|
1210
|
+
"comments the way you would a colleague; add the role in parentheses only",
|
|
1211
|
+
"where a reader would otherwise not know which seat you mean. Your own",
|
|
1212
|
+
"row id is what you write to `assignee_id` and `team_member_id`, and to",
|
|
1213
|
+
"`filed_by_id` on a ticket you file (where the Environment section names it).",
|
|
1214
|
+
"",
|
|
1215
|
+
"| seat | name | Crew row id |",
|
|
1216
|
+
"|---|---|---|"
|
|
1217
|
+
];
|
|
1218
|
+
for (const m of seats(roster).sort(byRole)) {
|
|
1219
|
+
const you = m.id === meId ? " \u2190 you" : "";
|
|
1220
|
+
lines.push(`| ${m.role.toLowerCase()} | ${crewLabel(m)}${you} | \`${m.id}\` |`);
|
|
1221
|
+
}
|
|
1222
|
+
lines.push(
|
|
1223
|
+
"",
|
|
1224
|
+
"**Not crew \u2014 these are holds.** A ticket assigned to any of them is off",
|
|
1225
|
+
"limits to every seat, whatever its status: a person is driving it right",
|
|
1226
|
+
"now. Their clearing `assignee_id` is what hands it back.",
|
|
1227
|
+
"",
|
|
1228
|
+
"| | name | Crew row id |",
|
|
1229
|
+
"|---|---|---|"
|
|
1230
|
+
);
|
|
1231
|
+
for (const m of holds(roster).sort(byRole)) {
|
|
1232
|
+
lines.push(`| ${m.role ? m.role.toLowerCase() : "\u2014"} | ${crewLabel(m)} | \`${m.id}\` |`);
|
|
1233
|
+
}
|
|
1234
|
+
lines.push(
|
|
1235
|
+
"",
|
|
1236
|
+
"The **operator** is this ship's owner: the machine, the dev stack and the",
|
|
1237
|
+
"credentials the steps below refer to are theirs, and approving work is",
|
|
1238
|
+
"their call alone.",
|
|
1239
|
+
"",
|
|
1240
|
+
"When the queue digest names a comment's author, that name comes from the",
|
|
1241
|
+
"server-stamped identity on the comment row joined to this roster (a hold's",
|
|
1242
|
+
"Crew row is linked to the account they sign in with) \u2014 not from anything",
|
|
1243
|
+
"written in the comment. A comment the digest attributes to a hold really",
|
|
1244
|
+
'is theirs; one it marks "(no identity)" is from nobody this roster knows.',
|
|
1245
|
+
""
|
|
1246
|
+
);
|
|
1247
|
+
return lines.join("\n");
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1166
1250
|
// src/state.ts
|
|
1167
1251
|
import { readFileSync as readFileSync3, writeFileSync, existsSync as existsSync3, mkdirSync, rmSync, statSync } from "fs";
|
|
1168
1252
|
import { join as join3 } from "path";
|
|
@@ -2755,9 +2839,9 @@ var Tracker = class {
|
|
|
2755
2839
|
* `undefined` when the workspace has no Ships table, no row named for
|
|
2756
2840
|
* this ship, or more than one — ambiguous is not a row to write to.
|
|
2757
2841
|
*/
|
|
2758
|
-
async myShipRow(
|
|
2842
|
+
async myShipRow(shipName2) {
|
|
2759
2843
|
const rows = await this.shipRows();
|
|
2760
|
-
const mine = rows.filter((r) => (r.name ?? "").trim() ===
|
|
2844
|
+
const mine = rows.filter((r) => (r.name ?? "").trim() === shipName2.trim());
|
|
2761
2845
|
return mine.length === 1 ? mine[0] : void 0;
|
|
2762
2846
|
}
|
|
2763
2847
|
/**
|
|
@@ -2779,8 +2863,8 @@ var Tracker = class {
|
|
|
2779
2863
|
* should not wait for the next engaged/idle beat to show it. Skips the
|
|
2780
2864
|
* write when the row already holds the same set.
|
|
2781
2865
|
*/
|
|
2782
|
-
async publishShipAttention(
|
|
2783
|
-
const row = await this.myShipRow(
|
|
2866
|
+
async publishShipAttention(shipName2) {
|
|
2867
|
+
const row = await this.myShipRow(shipName2);
|
|
2784
2868
|
if (!row) return;
|
|
2785
2869
|
const patch2 = this.attentionPatch(row);
|
|
2786
2870
|
if (!("attention" in patch2)) return;
|
|
@@ -2805,8 +2889,8 @@ var Tracker = class {
|
|
|
2805
2889
|
* A no-op, like `shipRows()`, on a workspace with no Ships table or no
|
|
2806
2890
|
* row for this ship's name.
|
|
2807
2891
|
*/
|
|
2808
|
-
async beatShip(
|
|
2809
|
-
const row = await this.myShipRow(
|
|
2892
|
+
async beatShip(shipName2) {
|
|
2893
|
+
const row = await this.myShipRow(shipName2);
|
|
2810
2894
|
if (!row) return;
|
|
2811
2895
|
await this.client.records.update(this.route.resolved.shipsModelId, row.id, {
|
|
2812
2896
|
last_seen: (/* @__PURE__ */ new Date()).toISOString(),
|
|
@@ -2822,8 +2906,8 @@ var Tracker = class {
|
|
|
2822
2906
|
* `beatIdle` in a `finally` — the same shape as `setCrewStatus`'s
|
|
2823
2907
|
* working/idle pair, one level up (ship, not seat).
|
|
2824
2908
|
*/
|
|
2825
|
-
async beatEngaged(
|
|
2826
|
-
const row = await this.myShipRow(
|
|
2909
|
+
async beatEngaged(shipName2, connectionName, ticketRecordId) {
|
|
2910
|
+
const row = await this.myShipRow(shipName2);
|
|
2827
2911
|
if (!row) return;
|
|
2828
2912
|
await this.client.records.update(this.route.resolved.shipsModelId, row.id, {
|
|
2829
2913
|
last_seen: (/* @__PURE__ */ new Date()).toISOString(),
|
|
@@ -2845,14 +2929,14 @@ var Tracker = class {
|
|
|
2845
2929
|
* tunnel client itself still runs the tunnel either way, it just has
|
|
2846
2930
|
* nowhere on the board to report the state.
|
|
2847
2931
|
*/
|
|
2848
|
-
async updateTunnelState(
|
|
2849
|
-
const row = await this.myShipRow(
|
|
2932
|
+
async updateTunnelState(shipName2, patch2) {
|
|
2933
|
+
const row = await this.myShipRow(shipName2);
|
|
2850
2934
|
if (!row) return;
|
|
2851
2935
|
await this.client.records.update(this.route.resolved.shipsModelId, row.id, patch2);
|
|
2852
2936
|
}
|
|
2853
2937
|
/** The other half of `beatEngaged` — clears engagement back to idle. */
|
|
2854
|
-
async beatIdle(
|
|
2855
|
-
const row = await this.myShipRow(
|
|
2938
|
+
async beatIdle(shipName2) {
|
|
2939
|
+
const row = await this.myShipRow(shipName2);
|
|
2856
2940
|
if (!row) return;
|
|
2857
2941
|
await this.client.records.update(this.route.resolved.shipsModelId, row.id, {
|
|
2858
2942
|
last_seen: (/* @__PURE__ */ new Date()).toISOString(),
|
|
@@ -3084,90 +3168,6 @@ var Tracker = class {
|
|
|
3084
3168
|
}
|
|
3085
3169
|
};
|
|
3086
3170
|
|
|
3087
|
-
// src/roster.ts
|
|
3088
|
-
function memberByIdentity(roster, identityId) {
|
|
3089
|
-
if (!identityId) return void 0;
|
|
3090
|
-
for (const m of roster.values()) if (m.userId === identityId) return m;
|
|
3091
|
-
return void 0;
|
|
3092
|
-
}
|
|
3093
|
-
function crewLabel(m) {
|
|
3094
|
-
if (!m) return null;
|
|
3095
|
-
if (!m.role) return m.name;
|
|
3096
|
-
if (m.name.toLowerCase().includes(m.role.toLowerCase())) return m.name;
|
|
3097
|
-
return `${m.name} (${m.role})`;
|
|
3098
|
-
}
|
|
3099
|
-
function isHold(roster, id) {
|
|
3100
|
-
if (!id) return false;
|
|
3101
|
-
return roster.get(id)?.kind === "hold";
|
|
3102
|
-
}
|
|
3103
|
-
function holdIds(roster) {
|
|
3104
|
-
return [...roster.values()].filter((m) => m.kind === "hold").map((m) => m.id);
|
|
3105
|
-
}
|
|
3106
|
-
function seats(roster) {
|
|
3107
|
-
return [...roster.values()].filter((m) => m.kind === "seat");
|
|
3108
|
-
}
|
|
3109
|
-
function holds(roster) {
|
|
3110
|
-
return [...roster.values()].filter((m) => m.kind === "hold");
|
|
3111
|
-
}
|
|
3112
|
-
function buildRoster(configured, crewRows) {
|
|
3113
|
-
const rows = new Map(crewRows.map((r) => [r.id, r]));
|
|
3114
|
-
const roster = /* @__PURE__ */ new Map();
|
|
3115
|
-
for (const c of configured) {
|
|
3116
|
-
if (!c.id) continue;
|
|
3117
|
-
const row = rows.get(c.id);
|
|
3118
|
-
const name = (row?.name ?? "").trim();
|
|
3119
|
-
roster.set(c.id, { id: c.id, name: name || c.role, role: c.role, kind: c.kind, userId: row?.user_id ?? null });
|
|
3120
|
-
}
|
|
3121
|
-
return roster;
|
|
3122
|
-
}
|
|
3123
|
-
function rosterMarkdown(roster, meId) {
|
|
3124
|
-
const byRole = (a, b) => a.role.localeCompare(b.role);
|
|
3125
|
-
const me = meId ? roster.get(meId) : void 0;
|
|
3126
|
-
const selfName = crewLabel(me) ?? "an unnamed seat";
|
|
3127
|
-
const lines = [
|
|
3128
|
-
"## Your crew",
|
|
3129
|
-
"",
|
|
3130
|
-
`You are **${selfName}**. Address your shipmates by name in ticket`,
|
|
3131
|
-
"comments the way you would a colleague; add the role in parentheses only",
|
|
3132
|
-
"where a reader would otherwise not know which seat you mean. Your own",
|
|
3133
|
-
"row id is what you write to `assignee_id` and `team_member_id`, and to",
|
|
3134
|
-
"`filed_by_id` on a ticket you file (where the Environment section names it).",
|
|
3135
|
-
"",
|
|
3136
|
-
"| seat | name | Crew row id |",
|
|
3137
|
-
"|---|---|---|"
|
|
3138
|
-
];
|
|
3139
|
-
for (const m of seats(roster).sort(byRole)) {
|
|
3140
|
-
const you = m.id === meId ? " \u2190 you" : "";
|
|
3141
|
-
lines.push(`| ${m.role.toLowerCase()} | ${crewLabel(m)}${you} | \`${m.id}\` |`);
|
|
3142
|
-
}
|
|
3143
|
-
lines.push(
|
|
3144
|
-
"",
|
|
3145
|
-
"**Not crew \u2014 these are holds.** A ticket assigned to any of them is off",
|
|
3146
|
-
"limits to every seat, whatever its status: a person is driving it right",
|
|
3147
|
-
"now. Their clearing `assignee_id` is what hands it back.",
|
|
3148
|
-
"",
|
|
3149
|
-
"| | name | Crew row id |",
|
|
3150
|
-
"|---|---|---|"
|
|
3151
|
-
);
|
|
3152
|
-
for (const m of holds(roster).sort(byRole)) {
|
|
3153
|
-
lines.push(`| ${m.role ? m.role.toLowerCase() : "\u2014"} | ${crewLabel(m)} | \`${m.id}\` |`);
|
|
3154
|
-
}
|
|
3155
|
-
lines.push(
|
|
3156
|
-
"",
|
|
3157
|
-
"The **operator** is this ship's owner: the machine, the dev stack and the",
|
|
3158
|
-
"credentials the steps below refer to are theirs, and approving work is",
|
|
3159
|
-
"their call alone.",
|
|
3160
|
-
"",
|
|
3161
|
-
"When the queue digest names a comment's author, that name comes from the",
|
|
3162
|
-
"server-stamped identity on the comment row joined to this roster (a hold's",
|
|
3163
|
-
"Crew row is linked to the account they sign in with) \u2014 not from anything",
|
|
3164
|
-
"written in the comment. A comment the digest attributes to a hold really",
|
|
3165
|
-
'is theirs; one it marks "(no identity)" is from nobody this roster knows.',
|
|
3166
|
-
""
|
|
3167
|
-
);
|
|
3168
|
-
return lines.join("\n");
|
|
3169
|
-
}
|
|
3170
|
-
|
|
3171
3171
|
// src/blocked.ts
|
|
3172
3172
|
import { StaleWriteError as StaleWriteError2 } from "@tablation/client";
|
|
3173
3173
|
function missingBlockerIds(tickets) {
|
|
@@ -3363,14 +3363,21 @@ function ticketHolderShipId(t, contract) {
|
|
|
3363
3363
|
return typeof v === "string" && v ? v : null;
|
|
3364
3364
|
}
|
|
3365
3365
|
function heldByOtherShip(t, a) {
|
|
3366
|
-
if (!a.myShipId) return false;
|
|
3367
3366
|
const statuses = a.contract.statuses;
|
|
3368
|
-
if (
|
|
3367
|
+
if (t.status === statuses.handoff) return false;
|
|
3368
|
+
return heldByLiveOtherShip(t, a);
|
|
3369
|
+
}
|
|
3370
|
+
function heldByLiveOtherShip(t, a) {
|
|
3371
|
+
if (!a.myShipId) return false;
|
|
3369
3372
|
const holder = ticketHolderShipId(t, a.contract);
|
|
3370
3373
|
if (!holder || holder === a.myShipId) return false;
|
|
3371
3374
|
const owner = a.ships.find((s) => s.id === holder);
|
|
3372
3375
|
return shipIsAlive(owner, a.now ?? Date.now());
|
|
3373
3376
|
}
|
|
3377
|
+
function shipName(ships, id) {
|
|
3378
|
+
if (!id) return "an unknown ship";
|
|
3379
|
+
return ships.find((s) => s.id === id)?.name?.trim() || id;
|
|
3380
|
+
}
|
|
3374
3381
|
function shipIdByName(ships, name) {
|
|
3375
3382
|
const mine = ships.filter((r) => (r.name ?? "").trim() === name.trim());
|
|
3376
3383
|
return mine.length === 1 ? mine[0].id : null;
|
|
@@ -3403,6 +3410,46 @@ async function applyHoldReleases(writer, tickets, contract, emit) {
|
|
|
3403
3410
|
}
|
|
3404
3411
|
return cleared;
|
|
3405
3412
|
}
|
|
3413
|
+
async function noteTakeover(tracker, result, seat, ships, myShipName, continuingFrom) {
|
|
3414
|
+
if (!result.tookOverFrom || !result.ticket) return;
|
|
3415
|
+
let sha = null;
|
|
3416
|
+
try {
|
|
3417
|
+
sha = continuingFrom(result.ticket);
|
|
3418
|
+
} catch {
|
|
3419
|
+
}
|
|
3420
|
+
try {
|
|
3421
|
+
await tracker.postEvent(
|
|
3422
|
+
result.ticket.id,
|
|
3423
|
+
`Taken over from ${shipName(ships, result.tookOverFrom)} (not seen recently) by ${myShipName}; ` + (sha ? `continuing from \`${sha}\` on origin.` : "no branch commit was found on origin to continue from."),
|
|
3424
|
+
seat
|
|
3425
|
+
);
|
|
3426
|
+
} catch {
|
|
3427
|
+
}
|
|
3428
|
+
}
|
|
3429
|
+
function originBranchSha(dir, t, rev) {
|
|
3430
|
+
const num = (t.issue_tag ?? t.issue_id).replace(/^\D+/, "");
|
|
3431
|
+
const names = [...new Set([(t.issue_tag ?? "").toLowerCase(), `issue-${num}`].filter(Boolean))];
|
|
3432
|
+
for (const n of names) {
|
|
3433
|
+
const sha = rev(dir, `origin/${n}`);
|
|
3434
|
+
if (sha) return sha.slice(0, 7);
|
|
3435
|
+
}
|
|
3436
|
+
return null;
|
|
3437
|
+
}
|
|
3438
|
+
function holdWarnings(tickets, ships, contract, humanHolds, now = Date.now()) {
|
|
3439
|
+
const inFlight = [contract.statuses.building, contract.statuses.verifying];
|
|
3440
|
+
const out = [];
|
|
3441
|
+
for (const t of tickets) {
|
|
3442
|
+
if (!inFlight.includes(t.status)) continue;
|
|
3443
|
+
if (t.assignee_id && humanHolds.has(t.assignee_id)) continue;
|
|
3444
|
+
const holder = ticketHolderShipId(t, contract);
|
|
3445
|
+
if (!holder) {
|
|
3446
|
+
out.push(`${t.issue_id} (${t.status}) has no ${contract.columns.heldBy} \u2014 any ship may take it`);
|
|
3447
|
+
} else if (!shipIsAlive(ships.find((s) => s.id === holder), now)) {
|
|
3448
|
+
out.push(`${t.issue_id} (${t.status}) is held by ${shipName(ships, holder)}, which has not been seen for ${SHIP_DEAD_AFTER_CYCLES}+ cycles`);
|
|
3449
|
+
}
|
|
3450
|
+
}
|
|
3451
|
+
return out;
|
|
3452
|
+
}
|
|
3406
3453
|
|
|
3407
3454
|
// src/select.ts
|
|
3408
3455
|
var qaSlice = (tickets) => tickets.filter((t) => t.status === "fixed" || t.status === "qa");
|
|
@@ -3421,7 +3468,7 @@ function affinityContext(i) {
|
|
|
3421
3468
|
function withHoldCheck(affinity, i) {
|
|
3422
3469
|
if (!affinity) return void 0;
|
|
3423
3470
|
const a = affinityContext(i);
|
|
3424
|
-
return a ? { ...affinity, isHeldElsewhere: (t) => heldByOtherShip(t, a) } : affinity;
|
|
3471
|
+
return a ? { ...affinity, isHeldElsewhere: (t) => heldByOtherShip(t, a), isLiveHoldElsewhere: (t) => heldByLiveOtherShip(t, a) } : affinity;
|
|
3425
3472
|
}
|
|
3426
3473
|
function notHeldByOtherShip(slice, i) {
|
|
3427
3474
|
if (!i.ships) return slice;
|
|
@@ -3460,7 +3507,7 @@ function buildingRoleHasWork(role, i) {
|
|
|
3460
3507
|
}
|
|
3461
3508
|
function qaRoleHasWork(i) {
|
|
3462
3509
|
if (!i.seats.qa) return { hasWork: false, reason: "this ship does not crew that role" };
|
|
3463
|
-
const n = qaSlice(i.tickets).filter((t) => !isHeld(t, i.holds)).length;
|
|
3510
|
+
const n = notHeldByOtherShip(qaSlice(i.tickets), i).filter((t) => !isHeld(t, i.holds)).length;
|
|
3464
3511
|
return n > 0 ? { hasWork: true, reason: `${n} ticket(s) awaiting or in verification` } : { hasWork: false, reason: "nothing awaiting verification" };
|
|
3465
3512
|
}
|
|
3466
3513
|
function triageRoleHasWork(i) {
|
|
@@ -3476,7 +3523,7 @@ function roleHasWork(role, i) {
|
|
|
3476
3523
|
return buildingRoleHasWork(role, i);
|
|
3477
3524
|
}
|
|
3478
3525
|
function roleCandidates(role, i) {
|
|
3479
|
-
if (role === "qa") return qaSlice(i.tickets).filter((t) => !isHeld(t, i.holds));
|
|
3526
|
+
if (role === "qa") return notHeldByOtherShip(qaSlice(i.tickets), i).filter((t) => !isHeld(t, i.holds));
|
|
3480
3527
|
if (role === "triage") return triageSlice(i.tickets, i.seats.triage).filter((t) => !isHeld(t, i.holds));
|
|
3481
3528
|
if (role === "pair") return [];
|
|
3482
3529
|
const me = i.seats[role];
|
|
@@ -3485,6 +3532,9 @@ function roleCandidates(role, i) {
|
|
|
3485
3532
|
(t) => ((t.status === "accepted" || t.status === "blocked") && !i.blocked.has(t.id) || t.status === "in_progress" && (t.assignee_id === me || !t.assignee_id)) && !isHeld(t, i.holds)
|
|
3486
3533
|
);
|
|
3487
3534
|
}
|
|
3535
|
+
function claimStatuses(role, c) {
|
|
3536
|
+
return role === "qa" ? { approved: c.statuses.handoff, building: c.statuses.verifying } : { approved: c.statuses.approved, building: c.statuses.building };
|
|
3537
|
+
}
|
|
3488
3538
|
function rankedCandidates(role, i) {
|
|
3489
3539
|
const c = i.contract ?? DEFAULT_CONTRACT;
|
|
3490
3540
|
return [...roleCandidates(role, i)].sort((a, b) => rankScalar(a, c) - rankScalar(b, c));
|
|
@@ -3692,6 +3742,14 @@ function newFromOthers(i, ticketId) {
|
|
|
3692
3742
|
).length;
|
|
3693
3743
|
}
|
|
3694
3744
|
function who(t, i) {
|
|
3745
|
+
return whoAssigned(t, i) + heldBySuffix(t, i);
|
|
3746
|
+
}
|
|
3747
|
+
function heldBySuffix(t, i) {
|
|
3748
|
+
if (!t.held_by_ship_id || t.held_by_ship_id === i.myShipId) return "";
|
|
3749
|
+
const name = i.ships?.find((s) => s.id === t.held_by_ship_id)?.name?.trim();
|
|
3750
|
+
return ` (held by ${name || "an unknown ship"})`;
|
|
3751
|
+
}
|
|
3752
|
+
function whoAssigned(t, i) {
|
|
3695
3753
|
if (!t.assignee_id) return "unassigned";
|
|
3696
3754
|
if (t.assignee_id === i.me) return "you";
|
|
3697
3755
|
const label = crewLabel(i.roster.get(t.assignee_id));
|
|
@@ -4487,6 +4545,10 @@ function rosterFor(d, route, role) {
|
|
|
4487
4545
|
|
|
4488
4546
|
// src/claim.ts
|
|
4489
4547
|
import { StaleWriteError as StaleWriteError4 } from "@tablation/client";
|
|
4548
|
+
function displacedHolder(t, affinity) {
|
|
4549
|
+
const v = affinity ? t[affinity.column] : null;
|
|
4550
|
+
return typeof v === "string" && v && v !== affinity.shipId ? v : null;
|
|
4551
|
+
}
|
|
4490
4552
|
async function stampResumption(tracker, t, affinity) {
|
|
4491
4553
|
let current = t;
|
|
4492
4554
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
@@ -4502,7 +4564,7 @@ async function stampResumption(tracker, t, affinity) {
|
|
|
4502
4564
|
} catch {
|
|
4503
4565
|
return { outcome: "unknown" };
|
|
4504
4566
|
}
|
|
4505
|
-
if (affinity.isHeldElsewhere?.(current)) return { outcome: "held" };
|
|
4567
|
+
if ((affinity.isLiveHoldElsewhere ?? affinity.isHeldElsewhere)?.(current)) return { outcome: "held" };
|
|
4506
4568
|
if (current[affinity.column] === affinity.shipId) return { outcome: "stamped", ticket: current };
|
|
4507
4569
|
}
|
|
4508
4570
|
return { outcome: "unknown" };
|
|
@@ -4521,15 +4583,18 @@ async function resolveTopCandidate(tracker, candidates, seat, approvedStatus, bu
|
|
|
4521
4583
|
continue;
|
|
4522
4584
|
}
|
|
4523
4585
|
if (t.status !== approvedStatus) {
|
|
4586
|
+
if (affinity?.isLiveHoldElsewhere?.(t)) {
|
|
4587
|
+
return { ticket: t, claimed: false, contended, unservable, held, reaccepted: false, tookOverFrom: null };
|
|
4588
|
+
}
|
|
4524
4589
|
if (affinity && t[affinity.column] !== affinity.shipId) {
|
|
4525
4590
|
const stamp = await stampResumption(tracker, t, affinity);
|
|
4526
4591
|
if (stamp.outcome === "stamped") {
|
|
4527
|
-
return { ticket: stamp.ticket, claimed: false, contended, unservable, held, reaccepted: false };
|
|
4592
|
+
return { ticket: stamp.ticket, claimed: false, contended, unservable, held, reaccepted: false, tookOverFrom: displacedHolder(t, affinity) };
|
|
4528
4593
|
}
|
|
4529
4594
|
(stamp.outcome === "held" ? held : contended).push(t.issue_id);
|
|
4530
4595
|
continue;
|
|
4531
4596
|
}
|
|
4532
|
-
return { ticket: t, claimed: false, contended, unservable, held, reaccepted: false };
|
|
4597
|
+
return { ticket: t, claimed: false, contended, unservable, held, reaccepted: false, tookOverFrom: null };
|
|
4533
4598
|
}
|
|
4534
4599
|
try {
|
|
4535
4600
|
const claimed = await tracker.updateTicket(
|
|
@@ -4542,7 +4607,7 @@ async function resolveTopCandidate(tracker, candidates, seat, approvedStatus, bu
|
|
|
4542
4607
|
t.updated_at
|
|
4543
4608
|
);
|
|
4544
4609
|
const reaccepted = !!affinity && t[affinity.column] === affinity.shipId;
|
|
4545
|
-
return { ticket: { ...t, ...claimed }, claimed: true, contended, unservable, held, reaccepted };
|
|
4610
|
+
return { ticket: { ...t, ...claimed }, claimed: true, contended, unservable, held, reaccepted, tookOverFrom: displacedHolder(t, affinity) };
|
|
4546
4611
|
} catch (e) {
|
|
4547
4612
|
if (e instanceof StaleWriteError4) {
|
|
4548
4613
|
contended.push(t.issue_id);
|
|
@@ -4551,7 +4616,7 @@ async function resolveTopCandidate(tracker, candidates, seat, approvedStatus, bu
|
|
|
4551
4616
|
throw e;
|
|
4552
4617
|
}
|
|
4553
4618
|
}
|
|
4554
|
-
return { ticket: null, claimed: false, contended, unservable, held, reaccepted: false };
|
|
4619
|
+
return { ticket: null, claimed: false, contended, unservable, held, reaccepted: false, tookOverFrom: null };
|
|
4555
4620
|
}
|
|
4556
4621
|
|
|
4557
4622
|
// src/conflict.ts
|
|
@@ -4938,6 +5003,15 @@ function renderEnvironment(i) {
|
|
|
4938
5003
|
"impossible."
|
|
4939
5004
|
);
|
|
4940
5005
|
}
|
|
5006
|
+
if (i.shipName) {
|
|
5007
|
+
lines.push(
|
|
5008
|
+
"",
|
|
5009
|
+
"## Ships",
|
|
5010
|
+
"",
|
|
5011
|
+
`This run is on the ship **${i.shipName}**. When a comment names a ship \u2014 this one or`,
|
|
5012
|
+
"another (the digest prints other ships by name) \u2014 write its name, never a Ships row id."
|
|
5013
|
+
);
|
|
5014
|
+
}
|
|
4941
5015
|
lines.push(
|
|
4942
5016
|
"",
|
|
4943
5017
|
"## What the columns are called here",
|
|
@@ -9172,7 +9246,7 @@ async function runRoleAgent(role, decision, ctx) {
|
|
|
9172
9246
|
const tracker = new Tracker(route, ship);
|
|
9173
9247
|
let ticketHint = decision.actionable.top?.issue_id;
|
|
9174
9248
|
let workingId = decision.actionable.top?.id ?? null;
|
|
9175
|
-
if (role === "dev" || role === "design") {
|
|
9249
|
+
if (role === "dev" || role === "design" || role === "qa") {
|
|
9176
9250
|
const seat = route.resolved?.seats[role];
|
|
9177
9251
|
if (seat) {
|
|
9178
9252
|
const candidates = rankedCandidates(role, decision.selectionInput);
|
|
@@ -9181,8 +9255,8 @@ async function runRoleAgent(role, decision, ctx) {
|
|
|
9181
9255
|
tracker,
|
|
9182
9256
|
candidates,
|
|
9183
9257
|
seat,
|
|
9184
|
-
contract.
|
|
9185
|
-
contract.
|
|
9258
|
+
claimStatuses(role, contract).approved,
|
|
9259
|
+
claimStatuses(role, contract).building,
|
|
9186
9260
|
(t) => dirForRepo(route, t.repo_id) !== null,
|
|
9187
9261
|
withHoldCheck(await tracker.claimAffinity(), decision.selectionInput)
|
|
9188
9262
|
);
|
|
@@ -9202,6 +9276,17 @@ async function runRoleAgent(role, decision, ctx) {
|
|
|
9202
9276
|
emit.emit(`skipped ${result.held.join(", ")} \u2014 held by another live ship`, { step: "select", role });
|
|
9203
9277
|
}
|
|
9204
9278
|
await noteReaccepted(tracker, result, seat, ship.name);
|
|
9279
|
+
await noteTakeover(
|
|
9280
|
+
tracker,
|
|
9281
|
+
result,
|
|
9282
|
+
seat,
|
|
9283
|
+
decision.selectionInput.ships?.rows ?? [],
|
|
9284
|
+
ship.name,
|
|
9285
|
+
(t) => {
|
|
9286
|
+
const d = dirForRepo(route, t.repo_id);
|
|
9287
|
+
return d ? originBranchSha(d, t, resolve2) : null;
|
|
9288
|
+
}
|
|
9289
|
+
);
|
|
9205
9290
|
if (!result.ticket) {
|
|
9206
9291
|
emit.emit(`${role} skipped this cycle \u2014 every candidate was already claimed elsewhere`);
|
|
9207
9292
|
return;
|
|
@@ -9290,7 +9375,7 @@ async function buildEnvironment(route, ship, ticket) {
|
|
|
9290
9375
|
};
|
|
9291
9376
|
});
|
|
9292
9377
|
const authorship = await tracker.authorshipProbe().catch(() => null);
|
|
9293
|
-
return renderEnvironment({ route, userAgent: ship.userAgent, repos, contract, sourceTicket: ticket, authorship });
|
|
9378
|
+
return renderEnvironment({ route, userAgent: ship.userAgent, repos, contract, sourceTicket: ticket, authorship, shipName: ship.name });
|
|
9294
9379
|
}
|
|
9295
9380
|
|
|
9296
9381
|
// src/cli.ts
|
|
@@ -9907,7 +9992,8 @@ ${changed2.map((c) => ` - ${c}
|
|
|
9907
9992
|
repos: await resolvedRepos(c),
|
|
9908
9993
|
contract,
|
|
9909
9994
|
sourceTicket: ticket,
|
|
9910
|
-
authorship: await tracker.authorshipProbe().catch(() => null)
|
|
9995
|
+
authorship: await tracker.authorshipProbe().catch(() => null),
|
|
9996
|
+
shipName: cfg.ship.name
|
|
9911
9997
|
});
|
|
9912
9998
|
}
|
|
9913
9999
|
const unplaceableReported = /* @__PURE__ */ new Set();
|
|
@@ -10233,7 +10319,7 @@ ${changed2.map((c) => ` - ${c}
|
|
|
10233
10319
|
const fleetTracker = new Tracker(w.route, cfg.ship);
|
|
10234
10320
|
let fleetTicketHint = w.decision.actionable.top?.issue_id;
|
|
10235
10321
|
let fleetWorkingId = w.decision.actionable.top?.id ?? null;
|
|
10236
|
-
if (w.role === "dev" || w.role === "design") {
|
|
10322
|
+
if (w.role === "dev" || w.role === "design" || w.role === "qa") {
|
|
10237
10323
|
const seat = w.route.resolved?.seats[w.role];
|
|
10238
10324
|
if (seat) {
|
|
10239
10325
|
const candidates = rankedCandidates(w.role, w.decision.selectionInput);
|
|
@@ -10242,8 +10328,8 @@ ${changed2.map((c) => ` - ${c}
|
|
|
10242
10328
|
fleetTracker,
|
|
10243
10329
|
candidates,
|
|
10244
10330
|
seat,
|
|
10245
|
-
contract.
|
|
10246
|
-
contract.
|
|
10331
|
+
claimStatuses(w.role, contract).approved,
|
|
10332
|
+
claimStatuses(w.role, contract).building,
|
|
10247
10333
|
(t) => dirForRepo(w.route, t.repo_id) !== null,
|
|
10248
10334
|
withHoldCheck(await fleetTracker.claimAffinity(), w.decision.selectionInput)
|
|
10249
10335
|
);
|
|
@@ -10263,6 +10349,17 @@ ${changed2.map((c) => ` - ${c}
|
|
|
10263
10349
|
wemit.emit(`skipped ${result.held.join(", ")} \u2014 held by another live ship`, { step: "select", role: w.role });
|
|
10264
10350
|
}
|
|
10265
10351
|
await noteReaccepted(fleetTracker, result, seat, cfg.ship.name);
|
|
10352
|
+
await noteTakeover(
|
|
10353
|
+
fleetTracker,
|
|
10354
|
+
result,
|
|
10355
|
+
seat,
|
|
10356
|
+
w.decision.selectionInput.ships?.rows ?? [],
|
|
10357
|
+
cfg.ship.name,
|
|
10358
|
+
(t) => {
|
|
10359
|
+
const d = dirForRepo(w.route, t.repo_id);
|
|
10360
|
+
return d ? originBranchSha(d, t, resolve2) : null;
|
|
10361
|
+
}
|
|
10362
|
+
);
|
|
10266
10363
|
if (!result.ticket) {
|
|
10267
10364
|
wemit.emit(`${w.role} skipped this cycle \u2014 every candidate was already claimed elsewhere`);
|
|
10268
10365
|
if (!skipInlineRelease) await releaseFleet();
|
|
@@ -10414,7 +10511,7 @@ ${changed2.map((c) => ` - ${c}
|
|
|
10414
10511
|
}
|
|
10415
10512
|
let ticketHint = decision.actionable.top?.issue_id;
|
|
10416
10513
|
let workingId = decision.actionable.top?.id ?? null;
|
|
10417
|
-
if (!dryRun && (current === "dev" || current === "design")) {
|
|
10514
|
+
if (!dryRun && (current === "dev" || current === "design" || current === "qa")) {
|
|
10418
10515
|
const seat = route.resolved?.seats[current];
|
|
10419
10516
|
if (seat) {
|
|
10420
10517
|
const candidates = rankedCandidates(current, decision.selectionInput);
|
|
@@ -10423,8 +10520,8 @@ ${changed2.map((c) => ` - ${c}
|
|
|
10423
10520
|
tracker2,
|
|
10424
10521
|
candidates,
|
|
10425
10522
|
seat,
|
|
10426
|
-
contract.
|
|
10427
|
-
contract.
|
|
10523
|
+
claimStatuses(current, contract).approved,
|
|
10524
|
+
claimStatuses(current, contract).building,
|
|
10428
10525
|
(t) => dirForRepo(route, t.repo_id) !== null,
|
|
10429
10526
|
withHoldCheck(await tracker2.claimAffinity(), decision.selectionInput)
|
|
10430
10527
|
);
|
|
@@ -10444,6 +10541,17 @@ ${changed2.map((c) => ` - ${c}
|
|
|
10444
10541
|
emit.emit(`skipped ${result.held.join(", ")} \u2014 held by another live ship`, { step: "select", role: current });
|
|
10445
10542
|
}
|
|
10446
10543
|
await noteReaccepted(tracker2, result, seat, cfg.ship.name);
|
|
10544
|
+
await noteTakeover(
|
|
10545
|
+
tracker2,
|
|
10546
|
+
result,
|
|
10547
|
+
seat,
|
|
10548
|
+
decision.selectionInput.ships?.rows ?? [],
|
|
10549
|
+
cfg.ship.name,
|
|
10550
|
+
(t) => {
|
|
10551
|
+
const d = dirForRepo(route, t.repo_id);
|
|
10552
|
+
return d ? originBranchSha(d, t, resolve2) : null;
|
|
10553
|
+
}
|
|
10554
|
+
);
|
|
10447
10555
|
if (!result.ticket) {
|
|
10448
10556
|
emit.emit(`${current} skipped this cycle \u2014 every candidate was already claimed elsewhere`);
|
|
10449
10557
|
dropLock();
|
|
@@ -11913,6 +12021,19 @@ crew manifest: ${seats2.length ? seats2.map((c) => c.name).join(", ") : "non
|
|
|
11913
12021
|
);
|
|
11914
12022
|
}
|
|
11915
12023
|
}
|
|
12024
|
+
if (ships.length > 0) {
|
|
12025
|
+
try {
|
|
12026
|
+
const humanHolds = new Set(holdIds(buildRoster(configuredMembers(route), crewRows)));
|
|
12027
|
+
const warnings = holdWarnings(await tracker.openTickets(), ships, tracker.contract, humanHolds);
|
|
12028
|
+
process.stdout.write(
|
|
12029
|
+
warnings.length === 0 ? "ship holds: every in-flight ticket is held by a live ship\n" : warnings.map((w) => `ship holds: WARNING ${w}
|
|
12030
|
+
`).join("")
|
|
12031
|
+
);
|
|
12032
|
+
} catch (e) {
|
|
12033
|
+
process.stdout.write(`ship holds: could not check \u2014 ${e.message}
|
|
12034
|
+
`);
|
|
12035
|
+
}
|
|
12036
|
+
}
|
|
11916
12037
|
const schedulerPath = pathFor(cfg.ship);
|
|
11917
12038
|
const agentBinPath = findOnPath(cfg.ship.agent.bin, schedulerPath);
|
|
11918
12039
|
const gitPath = findOnPath("git", schedulerPath);
|
package/package.json
CHANGED
|
@@ -38,8 +38,14 @@ human review, so "probably fine" is not a pass.
|
|
|
38
38
|
unfinished) always comes before any `fixed` one. One ticket per run;
|
|
39
39
|
the loop gives your lane the next cycle too while your queue is
|
|
40
40
|
non-empty.
|
|
41
|
-
2. **Claim it
|
|
42
|
-
|
|
41
|
+
2. **Claim it.** The crew has usually already done this for you: a ticket
|
|
42
|
+
it handed you is at `qa`, assigned to you, with `held_by_ship_id` naming
|
|
43
|
+
this ship — that stamp is what keeps another ship's QA seat off the same
|
|
44
|
+
ticket (a second verdict on one branch has let a defective one merge). If
|
|
45
|
+
the ticket is still at `fixed`, set `status` to `qa`, `assignee_id` to
|
|
46
|
+
yourself and `held_by_ship_id` to this ship's own Ships row, then re-read
|
|
47
|
+
the ticket: if `held_by_ship_id` now names another ship, stop — that ship
|
|
48
|
+
is verifying it. Post a short comment saying you've started verifying.
|
|
43
49
|
3. **Read the case, not just the ticket.** Fetch the full record
|
|
44
50
|
(`description`, `repro_steps`, `resolution_note`) *and* every comment on
|
|
45
51
|
it. The builder's own closing comment — what it changed, how it says it
|
|
@@ -107,6 +113,13 @@ human review, so "probably fine" is not a pass.
|
|
|
107
113
|
8. **Then give the verdict**, with a comment that shows your work — what
|
|
108
114
|
you ran, what you saw, what you deliberately didn't cover:
|
|
109
115
|
|
|
116
|
+
**Before you write it, re-read the ticket.** If `updated_at` has moved
|
|
117
|
+
since you claimed it, or `held_by_ship_id` no longer names this ship,
|
|
118
|
+
someone else has acted on it (a bounce, a verdict, a takeover): do not
|
|
119
|
+
overwrite their write — say so in a comment and stop. Send the verdict
|
|
120
|
+
write itself conditional on the `updated_at` you just read, with an
|
|
121
|
+
`X-Expected-Updated-At` header; a 409 means the same thing.
|
|
122
|
+
|
|
110
123
|
- **It holds up** → `status` = `verified`, and clear `assignee_id`. That
|
|
111
124
|
is the merge trigger: the release phase will squash-merge the branch,
|
|
112
125
|
bump the version, and deploy this cycle or the next. Say in
|
|
@@ -114,11 +127,11 @@ human review, so "probably fine" is not a pass.
|
|
|
114
127
|
covered.
|
|
115
128
|
- **It doesn't** → `status` = `in_progress`, `assignee_id` = the building
|
|
116
129
|
lane's own Crew row id from the roster above — the dev seat if
|
|
117
|
-
`needs_design` is false or null, the design seat if true. **Exception:** when the
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
`in_progress` ticket
|
|
121
|
-
|
|
130
|
+
`needs_design` is false or null, the design seat if true. **Exception:** when the Issues table has a `held_by_ship_id` column, **clear** `assignee_id` *and* `held_by_ship_id` instead of naming a seat — the
|
|
131
|
+
hold on a `qa` ticket is yours, as verifier, and seats are per ship, so
|
|
132
|
+
naming this ship's seat would hand the bounce to a ship that never built
|
|
133
|
+
it. An unassigned, unheld `in_progress` ticket is picked up by whichever
|
|
134
|
+
ship's building seat polls next, from the branch on origin. The comment
|
|
122
135
|
must be actionable:
|
|
123
136
|
exactly what you did, what you expected, what happened, with a
|
|
124
137
|
screenshot or the failing output. This includes a red suite, a
|
|
@@ -36,8 +36,14 @@ human review, so "probably fine" is not a pass.
|
|
|
36
36
|
unfinished) always comes before any `fixed` one. One ticket per run;
|
|
37
37
|
the loop gives your lane the next cycle too while your queue is
|
|
38
38
|
non-empty.
|
|
39
|
-
2. **Claim it
|
|
40
|
-
|
|
39
|
+
2. **Claim it.** The crew has usually already done this for you: a ticket
|
|
40
|
+
it handed you is at `qa`, assigned to you, with `held_by_ship_id` naming
|
|
41
|
+
this ship — that stamp is what keeps another ship's QA seat off the same
|
|
42
|
+
ticket (a second verdict on one branch has let a defective one merge). If
|
|
43
|
+
the ticket is still at `fixed`, set `status` to `qa`, `assignee_id` to
|
|
44
|
+
yourself and `held_by_ship_id` to this ship's own Ships row, then re-read
|
|
45
|
+
the ticket: if `held_by_ship_id` now names another ship, stop — that ship
|
|
46
|
+
is verifying it. Post a short comment saying you've started verifying.
|
|
41
47
|
3. **Read the case, not just the ticket.** Fetch the full record
|
|
42
48
|
(`description`, `repro_steps`, `resolution_note`) *and* every comment on
|
|
43
49
|
it. The builder's own closing comment — what it changed, how it says it
|
|
@@ -98,17 +104,24 @@ human review, so "probably fine" is not a pass.
|
|
|
98
104
|
8. **Then give the verdict**, with a comment that shows your work — what
|
|
99
105
|
you ran, what you saw, what you deliberately didn't cover:
|
|
100
106
|
|
|
107
|
+
**Before you write it, re-read the ticket.** If `updated_at` has moved
|
|
108
|
+
since you claimed it, or `held_by_ship_id` no longer names this ship,
|
|
109
|
+
someone else has acted on it (a bounce, a verdict, a takeover): do not
|
|
110
|
+
overwrite their write — say so in a comment and stop. Send the verdict
|
|
111
|
+
write itself conditional on the `updated_at` you just read, with an
|
|
112
|
+
`X-Expected-Updated-At` header; a 409 means the same thing.
|
|
113
|
+
|
|
101
114
|
- **It holds up** → `status` = `verified`, and clear `assignee_id`. That
|
|
102
115
|
is the merge trigger: the release phase will squash-merge the branch,
|
|
103
116
|
bump the version, and deploy this cycle or the next. Say in
|
|
104
117
|
the comment what you exercised, so the record shows what "verified"
|
|
105
118
|
covered.
|
|
106
119
|
- **It doesn't** → `status` = `in_progress`, `assignee_id` = the dev
|
|
107
|
-
seat's own Crew row id from the roster above. **Exception:** when the
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
`in_progress` ticket
|
|
111
|
-
|
|
120
|
+
seat's own Crew row id from the roster above. **Exception:** when the Issues table has a `held_by_ship_id` column, **clear** `assignee_id` *and* `held_by_ship_id` instead of naming a seat — the
|
|
121
|
+
hold on a `qa` ticket is yours, as verifier, and seats are per ship, so
|
|
122
|
+
naming this ship's seat would hand the bounce to a ship that never built
|
|
123
|
+
it. An unassigned, unheld `in_progress` ticket is picked up by whichever
|
|
124
|
+
ship's building seat polls next, from the branch on origin. The comment must be
|
|
112
125
|
actionable:
|
|
113
126
|
exactly what you did, what you expected, what happened, with a
|
|
114
127
|
screenshot or the failing output. This includes a red suite, a
|