@tablation/crew 0.19.4 → 0.20.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 +89 -16
- package/package.json +1 -1
- package/prompts/default/personas/common.md +5 -0
package/dist/cli.js
CHANGED
|
@@ -1792,6 +1792,7 @@ var DEFAULT_CONTRACT = {
|
|
|
1792
1792
|
blockedBy: "blocked_by",
|
|
1793
1793
|
updatedAt: "updated_at",
|
|
1794
1794
|
needsDesign: "needs_design",
|
|
1795
|
+
workMode: "work_mode",
|
|
1795
1796
|
reportType: "report_type",
|
|
1796
1797
|
epic: "epic_id",
|
|
1797
1798
|
author: "filed_by_id",
|
|
@@ -1840,6 +1841,7 @@ var DEFAULT_CONTRACT = {
|
|
|
1840
1841
|
severityOrder: ["s1", "s2", "s3", "s4"],
|
|
1841
1842
|
unknownPriorityRank: 2,
|
|
1842
1843
|
coordinatingValue: "coordinating",
|
|
1844
|
+
manualWorkMode: "manual",
|
|
1843
1845
|
repoExemptReportTypes: ["coordinating", "question", "investigation"]
|
|
1844
1846
|
};
|
|
1845
1847
|
function closedStatuses(c) {
|
|
@@ -1860,6 +1862,7 @@ function resolveContract(override) {
|
|
|
1860
1862
|
severityOrder: override.severityOrder ?? DEFAULT_CONTRACT.severityOrder,
|
|
1861
1863
|
unknownPriorityRank: override.unknownPriorityRank ?? DEFAULT_CONTRACT.unknownPriorityRank,
|
|
1862
1864
|
coordinatingValue: override.coordinatingValue ?? DEFAULT_CONTRACT.coordinatingValue,
|
|
1865
|
+
manualWorkMode: override.manualWorkMode ?? DEFAULT_CONTRACT.manualWorkMode,
|
|
1863
1866
|
// A workspace overriding `coordinatingValue` alone (renaming the rollup
|
|
1864
1867
|
// marker) still gets that renamed value exempted here by default,
|
|
1865
1868
|
// without also having to repeat it — only an explicit
|
|
@@ -3239,6 +3242,17 @@ var SHELL_WORDS = /* @__PURE__ */ new Set([
|
|
|
3239
3242
|
"ok"
|
|
3240
3243
|
]);
|
|
3241
3244
|
|
|
3245
|
+
// src/work-mode.ts
|
|
3246
|
+
function manualProjectIds(rows, contract) {
|
|
3247
|
+
const out = /* @__PURE__ */ new Set();
|
|
3248
|
+
for (const r of rows) if (r[contract.columns.workMode] === contract.manualWorkMode) out.add(r.id);
|
|
3249
|
+
return out;
|
|
3250
|
+
}
|
|
3251
|
+
function withinWorkMode(tickets, me, manual) {
|
|
3252
|
+
if (!manual || manual.size === 0) return tickets;
|
|
3253
|
+
return tickets.filter((t) => !t.project_id || !manual.has(t.project_id) || !!me && t.assignee_id === me);
|
|
3254
|
+
}
|
|
3255
|
+
|
|
3242
3256
|
// src/board-lock.ts
|
|
3243
3257
|
import { StaleWriteError } from "@tablation/client";
|
|
3244
3258
|
function releaseLockScope(route, repo) {
|
|
@@ -3460,6 +3474,22 @@ var Tracker = class {
|
|
|
3460
3474
|
return void 0;
|
|
3461
3475
|
}
|
|
3462
3476
|
}
|
|
3477
|
+
/**
|
|
3478
|
+
* Ids of the Projects (area) rows in `manual` work mode (CREW-1445). One
|
|
3479
|
+
* extra read per cycle. A workspace with no Projects table, no `work_mode`
|
|
3480
|
+
* column, a null value or an unreadable table yields the empty set: every
|
|
3481
|
+
* project stays automatic, exactly as before the column existed.
|
|
3482
|
+
*/
|
|
3483
|
+
async manualProjectIds() {
|
|
3484
|
+
const model = this.route.resolved?.areaModelId;
|
|
3485
|
+
if (!model) return /* @__PURE__ */ new Set();
|
|
3486
|
+
try {
|
|
3487
|
+
const rows = await this.client.records.list(model, { limit: 200 });
|
|
3488
|
+
return manualProjectIds(rows, this.contract);
|
|
3489
|
+
} catch {
|
|
3490
|
+
return /* @__PURE__ */ new Set();
|
|
3491
|
+
}
|
|
3492
|
+
}
|
|
3463
3493
|
/** Every `Projects` (area) row — what `crew repos add` offers when a new Repos row needs one. */
|
|
3464
3494
|
async projectRows() {
|
|
3465
3495
|
const model = this.route.resolved?.areaModelId;
|
|
@@ -4154,7 +4184,7 @@ function notHeldByOtherShip(slice, i) {
|
|
|
4154
4184
|
function buildingRoleHasWork(role, i) {
|
|
4155
4185
|
const me = i.seats[role];
|
|
4156
4186
|
if (!me) return { hasWork: false, reason: "this ship does not crew that role" };
|
|
4157
|
-
const mine = notInStoppedRepo(notHeldByOtherShip(sliceFor(i.tickets, role), i), i);
|
|
4187
|
+
const mine = withinWorkMode(notInStoppedRepo(notHeldByOtherShip(sliceFor(i.tickets, role), i), i), me, i.manualProjects);
|
|
4158
4188
|
const startable = mine.filter(
|
|
4159
4189
|
(t) => (t.status === "accepted" || t.status === "blocked") && !i.blocked.has(t.id) && !isHeld(t, i.holds)
|
|
4160
4190
|
);
|
|
@@ -4204,7 +4234,7 @@ function roleCandidates(role, i) {
|
|
|
4204
4234
|
if (role === "pair") return [];
|
|
4205
4235
|
const me = i.seats[role];
|
|
4206
4236
|
if (!me) return [];
|
|
4207
|
-
return notInStoppedRepo(notHeldByOtherShip(sliceFor(i.tickets, role), i), i).filter(
|
|
4237
|
+
return withinWorkMode(notInStoppedRepo(notHeldByOtherShip(sliceFor(i.tickets, role), i), i), me, i.manualProjects).filter(
|
|
4208
4238
|
(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)
|
|
4209
4239
|
);
|
|
4210
4240
|
}
|
|
@@ -4499,6 +4529,7 @@ ${sorted(held).map((t) => `| ${displayKey2(t)} | ${stopOf(t)} |`).join("\n")}
|
|
|
4499
4529
|
"\nAlready filtered to your lane, and already ordered by the Step 2 rule.\nTicket bodies are deliberately omitted: fetch the full record of only the\nticket you actually pick up. **Do not re-fetch the whole tracker.** This\ndigest comes from the same API call the poll just made, moments ago.\n",
|
|
4500
4530
|
'\n"new since last poll" counts comments from someone other than you since\nthe poll watermark \u2014 the same signal that woke this run.\n',
|
|
4501
4531
|
"\n**`repo` is the checkout a ticket's work happens in** \u2014 an area spans\nseveral repositories, so cut the worktree beside THAT directory, not beside\nwhichever one you started in. A ticket marked **NO CHECKOUT** is not yours:\nthis ship has no clone of its repository, and another ship may serve it.\n\n**Use the `branch` and `worktree` columns verbatim.** They are that\nrepository's own naming convention, rendered for THIS ticket \u2014 cut the\nworktree at `<repo>/../<worktree>`, on branch `<branch>`. Do not derive\neither yourself, and prefer these over the Environment section's own worked\nexample if the two ever disagree \u2014 that example is illustrative only,\nrendered before any ticket is picked.\n",
|
|
4532
|
+
...i.manualProjects && i.manualProjects.size > 0 ? ["\n**Manual projects.** Some projects on this board are in manual work mode: their tickets appear below only when a person has assigned them to YOU. An unassigned `accepted` ticket in such a project is not yours to claim and is deliberately absent \u2014 do not go looking for it.\n"] : [],
|
|
4502
4533
|
"\n### Step 1 \u2014 open tickets that may be yours to act on\n",
|
|
4503
4534
|
"\n`fixed` tickets are deliberately absent: they belong to the QA lane.\n",
|
|
4504
4535
|
table(step1, i, header, row),
|
|
@@ -4686,12 +4717,13 @@ async function decideCycle(o) {
|
|
|
4686
4717
|
const { route, ship, state, emit } = o;
|
|
4687
4718
|
const tracker = new Tracker(route, ship);
|
|
4688
4719
|
emit.enter("poll");
|
|
4689
|
-
const [tickets, comments, crewRows, epicRows, ships] = await Promise.all([
|
|
4720
|
+
const [tickets, comments, crewRows, epicRows, ships, manualProjects] = await Promise.all([
|
|
4690
4721
|
tracker.openTickets(),
|
|
4691
4722
|
tracker.comments(200),
|
|
4692
4723
|
tracker.crewRows(),
|
|
4693
4724
|
tracker.epicRows(),
|
|
4694
|
-
tracker.shipRows()
|
|
4725
|
+
tracker.shipRows(),
|
|
4726
|
+
tracker.manualProjectIds()
|
|
4695
4727
|
]);
|
|
4696
4728
|
emit.emit(`${tickets.length} open ticket(s), ${comments.length} comment(s)`, {
|
|
4697
4729
|
data: { tickets: tickets.length, comments: comments.length }
|
|
@@ -4799,6 +4831,7 @@ async function decideCycle(o) {
|
|
|
4799
4831
|
await checkRepoDisks({ state, emit, route, ship });
|
|
4800
4832
|
const selectionInput = {
|
|
4801
4833
|
repoStop: repoStopFor(openShipAttention(state), route),
|
|
4834
|
+
manualProjects,
|
|
4802
4835
|
tickets,
|
|
4803
4836
|
comments,
|
|
4804
4837
|
watermark,
|
|
@@ -4893,7 +4926,8 @@ function writeDigest(o, d, role, stateDir) {
|
|
|
4893
4926
|
}
|
|
4894
4927
|
return existingBranchForTicket(dir, repoFor(dir), t, role);
|
|
4895
4928
|
},
|
|
4896
|
-
tickets: role === "triage" ? d.tickets.filter((t) => t.assignee_id === o.route.resolved?.seats.triage) : sliceFor(d.tickets, role),
|
|
4929
|
+
tickets: role === "triage" ? d.tickets.filter((t) => t.assignee_id === o.route.resolved?.seats.triage) : role === "dev" || role === "design" ? withinWorkMode(sliceFor(d.tickets, role), me, d.selectionInput.manualProjects) : sliceFor(d.tickets, role),
|
|
4930
|
+
manualProjects: d.selectionInput.manualProjects,
|
|
4897
4931
|
comments: d.comments,
|
|
4898
4932
|
me,
|
|
4899
4933
|
roster: d.roster,
|
|
@@ -6434,6 +6468,15 @@ function describeUnplaceable(unplaceable, verifiedStatus) {
|
|
|
6434
6468
|
}
|
|
6435
6469
|
return notes;
|
|
6436
6470
|
}
|
|
6471
|
+
var unplaceableReported = /* @__PURE__ */ new Set();
|
|
6472
|
+
function reportUnplaceable(routeName, unplaceable, verified, emit) {
|
|
6473
|
+
if (unplaceableReported.has(routeName)) return;
|
|
6474
|
+
unplaceableReported.add(routeName);
|
|
6475
|
+
for (const note of describeUnplaceable(unplaceable, verified)) {
|
|
6476
|
+
if (note.level === "warn") emit.warn(note.message, { step: "release" });
|
|
6477
|
+
else emit.emit(note.message, { step: "release" });
|
|
6478
|
+
}
|
|
6479
|
+
}
|
|
6437
6480
|
function checkGuards(cwd, base = "main") {
|
|
6438
6481
|
const branch = currentBranch(cwd);
|
|
6439
6482
|
if (branch !== base) {
|
|
@@ -6585,6 +6628,10 @@ function decideRelease(cwd, tickets, contract, opts = {}) {
|
|
|
6585
6628
|
unseeded: lastTag === null
|
|
6586
6629
|
};
|
|
6587
6630
|
}
|
|
6631
|
+
function releaseIdle(i) {
|
|
6632
|
+
if (i.force || i.external || i.unseeded || !i.upToDate || i.unstamped > 0) return false;
|
|
6633
|
+
return !i.tickets.some((t) => t.status === i.verifiedStatus);
|
|
6634
|
+
}
|
|
6588
6635
|
var renderTag = (template, version) => template.replace(/\{version\}/g, version);
|
|
6589
6636
|
|
|
6590
6637
|
// src/stamp.ts
|
|
@@ -10526,15 +10573,8 @@ if (command === "passenger-sync-daemon") {
|
|
|
10526
10573
|
const out = refreshBaseBranch(r.dir, remote, base, true);
|
|
10527
10574
|
const unsafe = baseBranchUnsafe(out);
|
|
10528
10575
|
return { action: out.action, unsafe, detail: unsafe ? describeUnsafeBase(out, remote, base) : out.detail };
|
|
10529
|
-
}, reportUnplaceable = function(c, unplaceable, verified, emit2) {
|
|
10530
|
-
if (unplaceableReported.has(c.route)) return;
|
|
10531
|
-
unplaceableReported.add(c.route);
|
|
10532
|
-
for (const note of describeUnplaceable(unplaceable, verified)) {
|
|
10533
|
-
if (note.level === "warn") emit2.warn(note.message, { step: "release" });
|
|
10534
|
-
else emit2.emit(note.message, { step: "release" });
|
|
10535
|
-
}
|
|
10536
10576
|
};
|
|
10537
|
-
requireArmed2 = requireArmed, parseTicketNumber2 = parseTicketNumber, passengerApiKeys2 = passengerApiKeys, endpointApiFor2 = endpointApiFor, endpointReconcileDeps2 = endpointReconcileDeps, releaseRefused2 = releaseRefused, baseHealth2 = baseHealth
|
|
10577
|
+
requireArmed2 = requireArmed, parseTicketNumber2 = parseTicketNumber, passengerApiKeys2 = passengerApiKeys, endpointApiFor2 = endpointApiFor, endpointReconcileDeps2 = endpointReconcileDeps, releaseRefused2 = releaseRefused, baseHealth2 = baseHealth;
|
|
10538
10578
|
let cfg;
|
|
10539
10579
|
let firstRun;
|
|
10540
10580
|
try {
|
|
@@ -10668,6 +10708,7 @@ if (command === "passenger-sync-daemon") {
|
|
|
10668
10708
|
}
|
|
10669
10709
|
async function releaseFleet(opts = {}) {
|
|
10670
10710
|
if (releaseRefused()) return;
|
|
10711
|
+
precheckTickets.clear();
|
|
10671
10712
|
const summaries = [];
|
|
10672
10713
|
if (!dryRun) state.releaseHeartbeat().touch();
|
|
10673
10714
|
for (const c of cfg.routes) {
|
|
@@ -10679,6 +10720,7 @@ if (command === "passenger-sync-daemon") {
|
|
|
10679
10720
|
}
|
|
10680
10721
|
async function releaseTargets(opts = {}) {
|
|
10681
10722
|
if (releaseRefused()) return;
|
|
10723
|
+
precheckTickets.clear();
|
|
10682
10724
|
const only = value("repo");
|
|
10683
10725
|
const targets = reposOf(route);
|
|
10684
10726
|
const chosen = only ? targets.filter((t) => t.name === only) : targets;
|
|
@@ -10915,7 +10957,7 @@ ${changed2.map((c) => ` - ${c}
|
|
|
10915
10957
|
}
|
|
10916
10958
|
return `${who2}; ${lock2}`;
|
|
10917
10959
|
}
|
|
10918
|
-
const
|
|
10960
|
+
const precheckTickets = /* @__PURE__ */ new Map();
|
|
10919
10961
|
async function releasePhase(c, target, opts = {}) {
|
|
10920
10962
|
const scope = releaseLockScope(c.route, target.name);
|
|
10921
10963
|
const remit = emit.forRoute(c.route);
|
|
@@ -10950,6 +10992,38 @@ ${changed2.map((c) => ` - ${c}
|
|
|
10950
10992
|
}
|
|
10951
10993
|
if (!dryRun && !c.enabled) return { scope, tests: "skipped", outcome: "skipped", detail: "route not enabled" };
|
|
10952
10994
|
const tracker = new Tracker(c, cfg.ship);
|
|
10995
|
+
if (!dryRun) {
|
|
10996
|
+
const force2 = command === "deploy" || flag("force");
|
|
10997
|
+
let seen = precheckTickets.get(c.route);
|
|
10998
|
+
if (!seen) precheckTickets.set(c.route, seen = tracker.openTickets());
|
|
10999
|
+
const { byRepo: preByRepo, unplaceable: preUnplaceable } = ticketsByRepo(c, await seen);
|
|
11000
|
+
const pre = preByRepo.get(target.name) ?? [];
|
|
11001
|
+
reportUnplaceable(c.route, preUnplaceable, tracker.contract.statuses.verified, remit);
|
|
11002
|
+
const d = decideRelease(target.dir, pre, tracker.contract, {
|
|
11003
|
+
tagPattern: repo.release.tagPattern ?? "v*",
|
|
11004
|
+
base: repo.branch.base
|
|
11005
|
+
});
|
|
11006
|
+
const unstamped = planReleasedBranches(
|
|
11007
|
+
target.dir,
|
|
11008
|
+
pre,
|
|
11009
|
+
tracker.contract,
|
|
11010
|
+
(t) => locateBranchForTicket(target.dir, repo, t)?.branch ?? null,
|
|
11011
|
+
repo.branch.base,
|
|
11012
|
+
repo.release.tagPattern ?? "v*"
|
|
11013
|
+
).length;
|
|
11014
|
+
if (releaseIdle({
|
|
11015
|
+
tickets: pre,
|
|
11016
|
+
verifiedStatus: tracker.contract.statuses.verified,
|
|
11017
|
+
force: force2,
|
|
11018
|
+
external: repo.release.mode === "external",
|
|
11019
|
+
upToDate: d.upToDate,
|
|
11020
|
+
unseeded: d.unseeded,
|
|
11021
|
+
unstamped
|
|
11022
|
+
})) {
|
|
11023
|
+
remit.emit(`${target.name}: nothing verified \u2014 not locking`, { step: "release" });
|
|
11024
|
+
return { scope, tests: "skipped", outcome: "skipped", detail: "nothing verified \u2014 not locking" };
|
|
11025
|
+
}
|
|
11026
|
+
}
|
|
10953
11027
|
if (!dryRun) {
|
|
10954
11028
|
const holderLabel = `${cfg.ship.name}:${process.pid}`;
|
|
10955
11029
|
const got = await tracker.acquireBoardLock(scope, holderLabel, RELEASE_LOCK_TTL_MS);
|
|
@@ -10972,7 +11046,7 @@ ${changed2.map((c) => ` - ${c}
|
|
|
10972
11046
|
const all = await tracker.openTickets();
|
|
10973
11047
|
const { byRepo, unplaceable } = ticketsByRepo(c, all);
|
|
10974
11048
|
const tickets = byRepo.get(target.name) ?? [];
|
|
10975
|
-
reportUnplaceable(c, unplaceable, tracker.contract.statuses.verified, remit);
|
|
11049
|
+
reportUnplaceable(c.route, unplaceable, tracker.contract.statuses.verified, remit);
|
|
10976
11050
|
const outcome = await runRelease({
|
|
10977
11051
|
cwd: target.dir,
|
|
10978
11052
|
repo,
|
|
@@ -13258,4 +13332,3 @@ var endpointApiFor2;
|
|
|
13258
13332
|
var endpointReconcileDeps2;
|
|
13259
13333
|
var releaseRefused2;
|
|
13260
13334
|
var baseHealth2;
|
|
13261
|
-
var reportUnplaceable2;
|
package/package.json
CHANGED
|
@@ -305,6 +305,11 @@ Never start work on a ticket without first setting `assignee_id` to your
|
|
|
305
305
|
own id (Step 3.3 already does this) — that claim is itself part of what
|
|
306
306
|
keeps a concurrent interactive session from re-entering the same ticket.
|
|
307
307
|
|
|
308
|
+
A project in **manual work mode** (its Projects row says so) is never consumed
|
|
309
|
+
automatically: the digest lists its tickets only when a person has assigned them to
|
|
310
|
+
your seat, and status alone never makes one yours. The digest says so when it
|
|
311
|
+
applies; do not go looking for the missing tickets. QA is unaffected.
|
|
312
|
+
|
|
308
313
|
Never touch a ticket still at `new` — only `accepted` tickets are yours to
|
|
309
314
|
pick up; triage (a separate process) is what promotes `new` → `accepted`/
|
|
310
315
|
`needs_info`.
|