lobstah 0.3.0 → 0.3.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/main.js +320 -184
- package/docs/lobsterman.md +12 -0
- package/docs/pickup.md +38 -7
- package/package.json +1 -1
package/dist/main.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// apps/cli/src/main.ts
|
|
4
|
-
import * as
|
|
4
|
+
import * as fs28 from "node:fs";
|
|
5
5
|
import * as os6 from "node:os";
|
|
6
|
-
import * as
|
|
6
|
+
import * as path22 from "node:path";
|
|
7
7
|
import { randomUUID as randomUUID4 } from "node:crypto";
|
|
8
8
|
import { spawnSync as spawnSync6 } from "node:child_process";
|
|
9
9
|
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
@@ -1957,6 +1957,12 @@ var PickupState = class {
|
|
|
1957
1957
|
this.data.map[key] = { ...cur, ...patch };
|
|
1958
1958
|
this.save();
|
|
1959
1959
|
}
|
|
1960
|
+
releaseIssue(key) {
|
|
1961
|
+
const entry = this.get(key);
|
|
1962
|
+
if (entry?.kind !== "issue" || entry.lastReported !== "failed")
|
|
1963
|
+
return;
|
|
1964
|
+
this.update(key, { released: true, attempts: entry.attempts ?? 1 });
|
|
1965
|
+
}
|
|
1960
1966
|
approvalConsumed(dedupKey) {
|
|
1961
1967
|
return dedupKey in this.data.consumedApprovals;
|
|
1962
1968
|
}
|
|
@@ -1984,6 +1990,38 @@ var PickupState = class {
|
|
|
1984
1990
|
}
|
|
1985
1991
|
};
|
|
1986
1992
|
|
|
1993
|
+
// apps/pick/dist/pr-evidence.js
|
|
1994
|
+
import * as fs16 from "node:fs";
|
|
1995
|
+
import * as path11 from "node:path";
|
|
1996
|
+
function prUrlKey(url) {
|
|
1997
|
+
return url.replace(/\/+$/, "");
|
|
1998
|
+
}
|
|
1999
|
+
function prEvidenceIndex() {
|
|
2000
|
+
const byUrl = /* @__PURE__ */ new Map();
|
|
2001
|
+
const stateDir = laneDirs("work").state;
|
|
2002
|
+
let files;
|
|
2003
|
+
try {
|
|
2004
|
+
files = fs16.readdirSync(stateDir).filter((f) => f.endsWith(".evidence"));
|
|
2005
|
+
} catch {
|
|
2006
|
+
return /* @__PURE__ */ new Map();
|
|
2007
|
+
}
|
|
2008
|
+
for (const f of files) {
|
|
2009
|
+
const file = path11.join(stateDir, f);
|
|
2010
|
+
try {
|
|
2011
|
+
const prUrl = JSON.parse(fs16.readFileSync(file, "utf8")).prUrl;
|
|
2012
|
+
if (!prUrl)
|
|
2013
|
+
continue;
|
|
2014
|
+
const key = prUrlKey(prUrl);
|
|
2015
|
+
const mtime = fs16.statSync(file).mtimeMs;
|
|
2016
|
+
const cur = byUrl.get(key);
|
|
2017
|
+
if (!cur || mtime > cur.mtime)
|
|
2018
|
+
byUrl.set(key, { uuid: f.slice(0, -".evidence".length), mtime });
|
|
2019
|
+
} catch {
|
|
2020
|
+
}
|
|
2021
|
+
}
|
|
2022
|
+
return new Map([...byUrl].map(([url, v]) => [url, v.uuid]));
|
|
2023
|
+
}
|
|
2024
|
+
|
|
1987
2025
|
// apps/pick/dist/sources/github.js
|
|
1988
2026
|
var API = "https://api.github.com";
|
|
1989
2027
|
var GithubSource = class {
|
|
@@ -2032,26 +2070,70 @@ ${issue.body ?? ""}
|
|
|
2032
2070
|
When the change is complete, commit it, push the branch, and open a PR against ${this.cfg.repo} referencing #${issue.number}.`
|
|
2033
2071
|
});
|
|
2034
2072
|
}
|
|
2073
|
+
const evidence = prEvidenceIndex();
|
|
2035
2074
|
const pulls = await this.api("GET", `/repos/${this.cfg.repo}/pulls?state=open&per_page=50`);
|
|
2036
2075
|
for (const pr of pulls) {
|
|
2037
2076
|
const m = /^lobstah\/([0-9a-f-]{36})$/.exec(pr.head.ref);
|
|
2038
|
-
|
|
2077
|
+
const uuid = m?.[1] ?? evidence.get(prUrlKey(pr.html_url));
|
|
2078
|
+
if (!uuid)
|
|
2039
2079
|
continue;
|
|
2040
|
-
const
|
|
2041
|
-
|
|
2042
|
-
if (!changes)
|
|
2080
|
+
const feedback = await this.latestFeedback(pr.number);
|
|
2081
|
+
if (!feedback)
|
|
2043
2082
|
continue;
|
|
2083
|
+
const subject = `gh:${this.cfg.repo}#pr${pr.number}`;
|
|
2044
2084
|
items.push({
|
|
2045
|
-
key:
|
|
2085
|
+
key: `${subject}@${feedback}`,
|
|
2086
|
+
subject,
|
|
2046
2087
|
kind: "review",
|
|
2047
2088
|
repoKey: this.cfg.key,
|
|
2048
2089
|
title: pr.title,
|
|
2049
|
-
followUp:
|
|
2050
|
-
brief: `Address the review feedback on ${pr.html_url} (branch ${pr.head.ref}). Check out that branch in this worktree, read the review
|
|
2090
|
+
followUp: uuid,
|
|
2091
|
+
brief: `Address the review feedback on ${pr.html_url} (branch ${pr.head.ref}). Check out that branch in this worktree, read the full review discussion with \`gh pr view ${pr.number} --comments\`, implement what the feedback asks, and push. Reply to review threads where a reply is warranted. When the feedback is addressed, report status done.`
|
|
2051
2092
|
});
|
|
2052
2093
|
}
|
|
2053
2094
|
return items;
|
|
2054
2095
|
}
|
|
2096
|
+
/**
|
|
2097
|
+
* The newest human feedback event on a PR, as an opaque round tag —
|
|
2098
|
+
* `rv<id>` for a submitted review, `rc<id>` for an inline review-thread
|
|
2099
|
+
* comment, `ic<id>` for a conversation comment. Feedback means: a
|
|
2100
|
+
* CHANGES_REQUESTED review on any sha (a review of a slightly stale head
|
|
2101
|
+
* still asks for changes), a COMMENTED review with a body, or any comment
|
|
2102
|
+
* — always by someone other than the configured identity, and never a
|
|
2103
|
+
* marker comment. APPROVED reviews are the merge loop's signal, not
|
|
2104
|
+
* feedback. Returns undefined when no human has spoken.
|
|
2105
|
+
*/
|
|
2106
|
+
async latestFeedback(n) {
|
|
2107
|
+
const events = [];
|
|
2108
|
+
const human = (login) => login !== this.cfg.identity;
|
|
2109
|
+
const reviews = await this.paged(`/repos/${this.cfg.repo}/pulls/${n}/reviews`);
|
|
2110
|
+
for (const r of reviews) {
|
|
2111
|
+
if (!human(r.user.login))
|
|
2112
|
+
continue;
|
|
2113
|
+
const actionable = r.state === "CHANGES_REQUESTED" || r.state === "COMMENTED" && (r.body ?? "").trim() !== "";
|
|
2114
|
+
if (actionable)
|
|
2115
|
+
events.push({ at: r.submitted_at, tag: `rv${r.id}` });
|
|
2116
|
+
}
|
|
2117
|
+
for (const c of await this.paged(`/repos/${this.cfg.repo}/pulls/${n}/comments`)) {
|
|
2118
|
+
if (human(c.user.login))
|
|
2119
|
+
events.push({ at: c.created_at, tag: `rc${c.id}` });
|
|
2120
|
+
}
|
|
2121
|
+
for (const c of await this.paged(`/repos/${this.cfg.repo}/issues/${n}/comments`)) {
|
|
2122
|
+
if (human(c.user.login) && !MARKER.test(c.body))
|
|
2123
|
+
events.push({ at: c.created_at, tag: `ic${c.id}` });
|
|
2124
|
+
}
|
|
2125
|
+
events.sort((a, b) => a.at === b.at ? a.tag.localeCompare(b.tag) : a.at.localeCompare(b.at));
|
|
2126
|
+
return events.at(-1)?.tag;
|
|
2127
|
+
}
|
|
2128
|
+
async paged(url) {
|
|
2129
|
+
const all = [];
|
|
2130
|
+
for (let page = 1; ; page++) {
|
|
2131
|
+
const batch = await this.api("GET", `${url}?per_page=100&page=${page}`);
|
|
2132
|
+
all.push(...batch);
|
|
2133
|
+
if (batch.length < 100)
|
|
2134
|
+
return all;
|
|
2135
|
+
}
|
|
2136
|
+
}
|
|
2055
2137
|
async claim(item) {
|
|
2056
2138
|
if (item.kind === "review")
|
|
2057
2139
|
return true;
|
|
@@ -2166,6 +2248,7 @@ When the change is complete, commit it, push the branch, and open a PR against $
|
|
|
2166
2248
|
|
|
2167
2249
|
// apps/pick/dist/sources/linear.js
|
|
2168
2250
|
var API2 = "https://api.linear.app/graphql";
|
|
2251
|
+
var CLOSED_TYPES = ["completed", "canceled"];
|
|
2169
2252
|
function stateFor(cfg, verb) {
|
|
2170
2253
|
if (verb === "done")
|
|
2171
2254
|
return cfg.doneState;
|
|
@@ -2192,15 +2275,22 @@ var LinearSource = class {
|
|
|
2192
2275
|
}
|
|
2193
2276
|
return json.data;
|
|
2194
2277
|
}
|
|
2195
|
-
async assigned(
|
|
2278
|
+
async assigned(filter) {
|
|
2196
2279
|
const field = this.cfg.assignField ?? "assignee";
|
|
2197
|
-
const
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2280
|
+
const issues = [];
|
|
2281
|
+
let after = null;
|
|
2282
|
+
for (; ; ) {
|
|
2283
|
+
const data = await this.gql(`query($filter: IssueFilter!, $after: String) {
|
|
2284
|
+
issues(filter: $filter, first: 50, after: $after) {
|
|
2285
|
+
nodes { id identifier title description state { name type } team { key id } }
|
|
2286
|
+
pageInfo { hasNextPage endCursor }
|
|
2287
|
+
}
|
|
2288
|
+
}`, { filter: { [field]: { isMe: { eq: true } }, ...filter }, after });
|
|
2289
|
+
issues.push(...data.issues.nodes);
|
|
2290
|
+
if (!data.issues.pageInfo.hasNextPage)
|
|
2291
|
+
return issues;
|
|
2292
|
+
after = data.issues.pageInfo.endCursor;
|
|
2293
|
+
}
|
|
2204
2294
|
}
|
|
2205
2295
|
async stateId(teamId, name) {
|
|
2206
2296
|
const cacheKey = `${teamId}:${name}`;
|
|
@@ -2228,7 +2318,8 @@ var LinearSource = class {
|
|
|
2228
2318
|
await this.gql(`mutation($id: String!, $body: String!) { commentCreate(input: { issueId: $id, body: $body }) { success } }`, { id: issue.id, body });
|
|
2229
2319
|
}
|
|
2230
2320
|
async poll() {
|
|
2231
|
-
const
|
|
2321
|
+
const state = this.cfg.startStateTypes?.length ? { type: { in: this.cfg.startStateTypes } } : { name: { eq: this.cfg.startState } };
|
|
2322
|
+
const issues = await this.assigned({ state });
|
|
2232
2323
|
return issues.flatMap((issue) => {
|
|
2233
2324
|
const repoKey = this.cfg.route[issue.team.key];
|
|
2234
2325
|
if (!repoKey)
|
|
@@ -2267,7 +2358,7 @@ When the change is complete, commit it, push the branch, and open a PR whose des
|
|
|
2267
2358
|
lines.push(evidence.note);
|
|
2268
2359
|
await this.commentOn(issue, lines.join("\n"));
|
|
2269
2360
|
const target = stateFor(this.cfg, verb);
|
|
2270
|
-
if (target)
|
|
2361
|
+
if (target && !CLOSED_TYPES.includes(issue.state.type))
|
|
2271
2362
|
await this.moveTo(issue, target);
|
|
2272
2363
|
}
|
|
2273
2364
|
async inbound(key, since) {
|
|
@@ -2276,8 +2367,10 @@ When the change is complete, commit it, push the branch, and open a PR whose des
|
|
|
2276
2367
|
return data.issue.comments.nodes.filter((c) => !c.user?.isMe && !MARKER.test(c.body)).filter((c) => !since || c.createdAt > since).map((c) => c.body);
|
|
2277
2368
|
}
|
|
2278
2369
|
async inProgress() {
|
|
2279
|
-
const issues = await this.assigned(
|
|
2280
|
-
|
|
2370
|
+
const issues = await this.assigned({
|
|
2371
|
+
or: [{ state: { name: { eq: this.cfg.claimedState } } }, { state: { type: { in: CLOSED_TYPES } } }]
|
|
2372
|
+
});
|
|
2373
|
+
return issues.map((i) => ({ key: `linear:${i.identifier}`, open: !CLOSED_TYPES.includes(i.state.type) }));
|
|
2281
2374
|
}
|
|
2282
2375
|
async recoverUuid(key) {
|
|
2283
2376
|
const identifier = key.replace(/^linear:/, "");
|
|
@@ -2298,36 +2391,14 @@ When the change is complete, commit it, push the branch, and open a PR whose des
|
|
|
2298
2391
|
|
|
2299
2392
|
// apps/pick/dist/loops/dispatch.js
|
|
2300
2393
|
import { randomUUID } from "node:crypto";
|
|
2301
|
-
async function dispatchLoop(source, state, log = () => {
|
|
2302
|
-
}) {
|
|
2303
|
-
const items = await source.poll();
|
|
2304
|
-
for (const item of items) {
|
|
2305
|
-
if (state.get(item.key))
|
|
2306
|
-
continue;
|
|
2307
|
-
if (!await source.claim(item)) {
|
|
2308
|
-
log(`${item.key}: claim lost \u2014 another machine has it`);
|
|
2309
|
-
continue;
|
|
2310
|
-
}
|
|
2311
|
-
const id = randomUUID();
|
|
2312
|
-
enqueue({
|
|
2313
|
-
id,
|
|
2314
|
-
repo: item.repoKey,
|
|
2315
|
-
brief: item.brief,
|
|
2316
|
-
// A review dispatch forks the implementation session; issues start cold.
|
|
2317
|
-
...item.kind === "review" && item.followUp ? { followUp: item.followUp } : {}
|
|
2318
|
-
}, "work");
|
|
2319
|
-
state.set(item.key, { uuid: id, kind: item.kind, createdAt: (/* @__PURE__ */ new Date()).toISOString() });
|
|
2320
|
-
log(`${item.key}: dispatched as ${id}`);
|
|
2321
|
-
}
|
|
2322
|
-
}
|
|
2323
2394
|
|
|
2324
2395
|
// apps/pick/dist/loops/report.js
|
|
2325
|
-
import * as
|
|
2326
|
-
import * as
|
|
2396
|
+
import * as fs17 from "node:fs";
|
|
2397
|
+
import * as path12 from "node:path";
|
|
2327
2398
|
function dispatchLane(uuid) {
|
|
2328
2399
|
for (const lane of ["work", "chore"]) {
|
|
2329
2400
|
const d = laneDirs(lane);
|
|
2330
|
-
if (
|
|
2401
|
+
if (fs17.existsSync(path12.join(d.active, uuid)) || fs17.existsSync(path12.join(d.queue, `${uuid}.json`)) || fs17.existsSync(path12.join(d.done, uuid)) || fs17.existsSync(path12.join(d.state, `${uuid}.status`))) {
|
|
2331
2402
|
return lane;
|
|
2332
2403
|
}
|
|
2333
2404
|
}
|
|
@@ -2337,6 +2408,8 @@ async function reportLoop(source, state, log = () => {
|
|
|
2337
2408
|
}, notify = () => {
|
|
2338
2409
|
}) {
|
|
2339
2410
|
for (const [key, entry] of state.entries()) {
|
|
2411
|
+
if (entry.released)
|
|
2412
|
+
continue;
|
|
2340
2413
|
const lane = dispatchLane(entry.uuid);
|
|
2341
2414
|
if (!lane)
|
|
2342
2415
|
continue;
|
|
@@ -2357,6 +2430,11 @@ async function reportLoop(source, state, log = () => {
|
|
|
2357
2430
|
prUrl: evidence.prUrl
|
|
2358
2431
|
});
|
|
2359
2432
|
}
|
|
2433
|
+
if (entry.kind === "issue" && verb === "failed" && fs17.existsSync(path12.join(laneDirs(lane).done, entry.uuid))) {
|
|
2434
|
+
state.releaseIssue(key);
|
|
2435
|
+
log(`${key}: finalized failure released for a bounded retry`);
|
|
2436
|
+
continue;
|
|
2437
|
+
}
|
|
2360
2438
|
const msgs = await source.inbound(key, entry.lastInboundAt);
|
|
2361
2439
|
if (msgs.length > 0) {
|
|
2362
2440
|
for (const m of msgs)
|
|
@@ -2367,13 +2445,71 @@ async function reportLoop(source, state, log = () => {
|
|
|
2367
2445
|
}
|
|
2368
2446
|
}
|
|
2369
2447
|
|
|
2448
|
+
// apps/pick/dist/loops/dispatch.js
|
|
2449
|
+
function terminal(uuid) {
|
|
2450
|
+
const lane = dispatchLane(uuid);
|
|
2451
|
+
if (!lane)
|
|
2452
|
+
return true;
|
|
2453
|
+
const verb = reconcile({ log: readStatusLog(uuid, lane), lastEventAt: lastEventAt(uuid, lane) });
|
|
2454
|
+
return verb === "done" || verb === "failed";
|
|
2455
|
+
}
|
|
2456
|
+
function roundPlan(item, state) {
|
|
2457
|
+
const rounds = item.subject ? state.entries().filter(([k]) => k.startsWith(`${item.subject}@`)).map(([, e]) => e) : [];
|
|
2458
|
+
const chain = [...rounds.map((e) => e.uuid), ...item.followUp ? [item.followUp] : []];
|
|
2459
|
+
if (chain.some((uuid) => !terminal(uuid)))
|
|
2460
|
+
return { hold: true };
|
|
2461
|
+
const forkable = rounds.filter((e) => dispatchLane(e.uuid)).sort((a, b) => a.createdAt.localeCompare(b.createdAt)).at(-1)?.uuid;
|
|
2462
|
+
const target = forkable ?? item.followUp;
|
|
2463
|
+
return { hold: false, followUp: target && dispatchLane(target) ? target : void 0 };
|
|
2464
|
+
}
|
|
2465
|
+
async function dispatchLoop(source, state, log = () => {
|
|
2466
|
+
}) {
|
|
2467
|
+
const items = await source.poll();
|
|
2468
|
+
const maxAttempts = 1 + loadConfig().limits.maxRestartAttempts;
|
|
2469
|
+
for (const item of items) {
|
|
2470
|
+
const previous = state.get(item.key);
|
|
2471
|
+
if (previous) {
|
|
2472
|
+
if (item.kind !== "issue" || previous.kind !== "issue" || !previous.released)
|
|
2473
|
+
continue;
|
|
2474
|
+
if ((previous.attempts ?? 1) >= maxAttempts) {
|
|
2475
|
+
log(`${item.key}: issue attempt limit (${maxAttempts}) reached \u2014 holding for a human`);
|
|
2476
|
+
continue;
|
|
2477
|
+
}
|
|
2478
|
+
}
|
|
2479
|
+
const plan = item.kind === "review" ? roundPlan(item, state) : { hold: false, followUp: void 0 };
|
|
2480
|
+
if (plan.hold) {
|
|
2481
|
+
log(`${item.key}: a prior round is still in flight \u2014 holding`);
|
|
2482
|
+
continue;
|
|
2483
|
+
}
|
|
2484
|
+
if (!await source.claim(item)) {
|
|
2485
|
+
log(`${item.key}: claim lost \u2014 another machine has it`);
|
|
2486
|
+
continue;
|
|
2487
|
+
}
|
|
2488
|
+
const id = randomUUID();
|
|
2489
|
+
enqueue({
|
|
2490
|
+
id,
|
|
2491
|
+
repo: item.repoKey,
|
|
2492
|
+
brief: item.brief,
|
|
2493
|
+
// A review dispatch forks the latest session in its chain; issues start cold.
|
|
2494
|
+
...plan.followUp ? { followUp: plan.followUp } : {}
|
|
2495
|
+
}, "work");
|
|
2496
|
+
state.set(item.key, {
|
|
2497
|
+
uuid: id,
|
|
2498
|
+
kind: item.kind,
|
|
2499
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2500
|
+
...item.kind === "issue" ? { attempts: previous ? (previous.attempts ?? 1) + 1 : 1 } : {}
|
|
2501
|
+
});
|
|
2502
|
+
log(`${item.key}: dispatched as ${id}`);
|
|
2503
|
+
}
|
|
2504
|
+
}
|
|
2505
|
+
|
|
2370
2506
|
// apps/pick/dist/loops/reconcile.js
|
|
2371
|
-
import * as
|
|
2372
|
-
import * as
|
|
2507
|
+
import * as fs18 from "node:fs";
|
|
2508
|
+
import * as path13 from "node:path";
|
|
2373
2509
|
function dispatchActive(uuid) {
|
|
2374
2510
|
for (const lane of ["work", "chore"]) {
|
|
2375
2511
|
const d = laneDirs(lane);
|
|
2376
|
-
if (
|
|
2512
|
+
if (fs18.existsSync(path13.join(d.active, uuid)) || fs18.existsSync(path13.join(d.queue, `${uuid}.json`))) {
|
|
2377
2513
|
return lane;
|
|
2378
2514
|
}
|
|
2379
2515
|
}
|
|
@@ -2413,28 +2549,28 @@ async function reconcileLoop(source, state, log = () => {
|
|
|
2413
2549
|
|
|
2414
2550
|
// apps/pick/dist/loops/merge.js
|
|
2415
2551
|
import { randomUUID as randomUUID2 } from "node:crypto";
|
|
2416
|
-
import * as
|
|
2417
|
-
import * as
|
|
2552
|
+
import * as fs20 from "node:fs";
|
|
2553
|
+
import * as path15 from "node:path";
|
|
2418
2554
|
|
|
2419
2555
|
// apps/pick/dist/merge-view.js
|
|
2420
|
-
import * as
|
|
2421
|
-
import * as
|
|
2556
|
+
import * as fs19 from "node:fs";
|
|
2557
|
+
import * as path14 from "node:path";
|
|
2422
2558
|
function viewPath() {
|
|
2423
|
-
return
|
|
2559
|
+
return path14.join(lobstahHome(), "pickup", "merge-view.json");
|
|
2424
2560
|
}
|
|
2425
2561
|
function readMergeView() {
|
|
2426
2562
|
try {
|
|
2427
|
-
return JSON.parse(
|
|
2563
|
+
return JSON.parse(fs19.readFileSync(viewPath(), "utf8"));
|
|
2428
2564
|
} catch {
|
|
2429
2565
|
return void 0;
|
|
2430
2566
|
}
|
|
2431
2567
|
}
|
|
2432
2568
|
function writeMergeView(view) {
|
|
2433
2569
|
const file = viewPath();
|
|
2434
|
-
|
|
2570
|
+
fs19.mkdirSync(path14.dirname(file), { recursive: true });
|
|
2435
2571
|
const tmp = `${file}.tmp-${process.pid}`;
|
|
2436
|
-
|
|
2437
|
-
|
|
2572
|
+
fs19.writeFileSync(tmp, JSON.stringify(view, null, 2));
|
|
2573
|
+
fs19.renameSync(tmp, file);
|
|
2438
2574
|
}
|
|
2439
2575
|
var RECENT_RETENTION_MS = 48 * 36e5;
|
|
2440
2576
|
async function recordMergeView(repo, open, disposition, now = /* @__PURE__ */ new Date()) {
|
|
@@ -2456,7 +2592,7 @@ async function recordMergeView(repo, open, disposition, now = /* @__PURE__ */ ne
|
|
|
2456
2592
|
}
|
|
2457
2593
|
function readPickupMap() {
|
|
2458
2594
|
try {
|
|
2459
|
-
const raw = JSON.parse(
|
|
2595
|
+
const raw = JSON.parse(fs19.readFileSync(path14.join(lobstahHome(), "pickup", "state.json"), "utf8"));
|
|
2460
2596
|
return raw.map ?? {};
|
|
2461
2597
|
} catch {
|
|
2462
2598
|
return {};
|
|
@@ -2490,7 +2626,7 @@ function rebaseBrief(pr) {
|
|
|
2490
2626
|
}
|
|
2491
2627
|
function choreVerdict(uuid) {
|
|
2492
2628
|
const d = laneDirs("chore");
|
|
2493
|
-
if (
|
|
2629
|
+
if (fs20.existsSync(path15.join(d.active, uuid)) || fs20.existsSync(path15.join(d.queue, `${uuid}.json`)))
|
|
2494
2630
|
return "active";
|
|
2495
2631
|
const last = readStatusLog(uuid, "chore").at(-1)?.verb;
|
|
2496
2632
|
return last === "done" ? "done" : "failed";
|
|
@@ -2584,8 +2720,8 @@ async function mergeLoop(ms, policy, state, log = () => {
|
|
|
2584
2720
|
}
|
|
2585
2721
|
|
|
2586
2722
|
// apps/pick/dist/loops/watch.js
|
|
2587
|
-
import * as
|
|
2588
|
-
import * as
|
|
2723
|
+
import * as fs21 from "node:fs";
|
|
2724
|
+
import * as path16 from "node:path";
|
|
2589
2725
|
import { randomUUID as randomUUID3 } from "node:crypto";
|
|
2590
2726
|
var DEFAULT_BRIEF = `You are resuming earlier work. The watched source {key} produced new events:
|
|
2591
2727
|
|
|
@@ -2595,7 +2731,7 @@ Read them, act on what they ask, and report the outcome.`;
|
|
|
2595
2731
|
function laneOf(id) {
|
|
2596
2732
|
for (const lane of ["work", "chore"]) {
|
|
2597
2733
|
const d = laneDirs(lane);
|
|
2598
|
-
if (
|
|
2734
|
+
if (fs21.existsSync(path16.join(d.active, id)) || fs21.existsSync(path16.join(d.queue, `${id}.json`)) || fs21.existsSync(path16.join(d.done, id)) || fs21.existsSync(path16.join(d.state, `${id}.status`))) {
|
|
2599
2735
|
return lane;
|
|
2600
2736
|
}
|
|
2601
2737
|
}
|
|
@@ -2604,13 +2740,13 @@ function laneOf(id) {
|
|
|
2604
2740
|
function descriptorOf(id, lane) {
|
|
2605
2741
|
const d = laneDirs(lane);
|
|
2606
2742
|
for (const bucket of ["active", "done"]) {
|
|
2607
|
-
const f =
|
|
2608
|
-
if (
|
|
2609
|
-
return JSON.parse(
|
|
2743
|
+
const f = path16.join(d[bucket], id, "descriptor.json");
|
|
2744
|
+
if (fs21.existsSync(f))
|
|
2745
|
+
return JSON.parse(fs21.readFileSync(f, "utf8"));
|
|
2610
2746
|
}
|
|
2611
|
-
const q =
|
|
2612
|
-
if (
|
|
2613
|
-
return JSON.parse(
|
|
2747
|
+
const q = path16.join(d.queue, `${id}.json`);
|
|
2748
|
+
if (fs21.existsSync(q))
|
|
2749
|
+
return JSON.parse(fs21.readFileSync(q, "utf8"));
|
|
2614
2750
|
return void 0;
|
|
2615
2751
|
}
|
|
2616
2752
|
function isTerminal(id) {
|
|
@@ -2843,21 +2979,21 @@ function mergeHaulHook(existing) {
|
|
|
2843
2979
|
}
|
|
2844
2980
|
|
|
2845
2981
|
// apps/cli/src/tend.ts
|
|
2846
|
-
import * as
|
|
2847
|
-
import * as
|
|
2982
|
+
import * as fs22 from "node:fs";
|
|
2983
|
+
import * as path17 from "node:path";
|
|
2848
2984
|
var HEARTBEAT_STALE_MS = 9e4;
|
|
2849
2985
|
var CLAIM_STALE_MS = 12e4;
|
|
2850
2986
|
var DAY_MS = 24 * 36e5;
|
|
2851
2987
|
function readJson(file) {
|
|
2852
2988
|
try {
|
|
2853
|
-
return JSON.parse(
|
|
2989
|
+
return JSON.parse(fs22.readFileSync(file, "utf8"));
|
|
2854
2990
|
} catch {
|
|
2855
2991
|
return void 0;
|
|
2856
2992
|
}
|
|
2857
2993
|
}
|
|
2858
2994
|
function doneIds(lane) {
|
|
2859
2995
|
try {
|
|
2860
|
-
return
|
|
2996
|
+
return fs22.readdirSync(laneDirs(lane).done).filter((f) => !f.startsWith("."));
|
|
2861
2997
|
} catch {
|
|
2862
2998
|
return [];
|
|
2863
2999
|
}
|
|
@@ -2876,16 +3012,16 @@ function followUps(uuid) {
|
|
|
2876
3012
|
const desc = readJson(file);
|
|
2877
3013
|
if (desc?.followUp === uuid) out.push({ id, bucket });
|
|
2878
3014
|
};
|
|
2879
|
-
for (const id of pendingIds("work")) scan(id, "queued",
|
|
2880
|
-
for (const id of activeIds("work")) scan(id, "active",
|
|
2881
|
-
for (const id of doneIds("work")) scan(id, "done",
|
|
3015
|
+
for (const id of pendingIds("work")) scan(id, "queued", path17.join(d.queue, `${id}.json`));
|
|
3016
|
+
for (const id of activeIds("work")) scan(id, "active", path17.join(d.active, id, "descriptor.json"));
|
|
3017
|
+
for (const id of doneIds("work")) scan(id, "done", path17.join(d.done, id, "descriptor.json"));
|
|
2882
3018
|
return out;
|
|
2883
3019
|
}
|
|
2884
3020
|
function bucketOf(uuid) {
|
|
2885
3021
|
const d = laneDirs("work");
|
|
2886
|
-
if (
|
|
2887
|
-
if (
|
|
2888
|
-
if (
|
|
3022
|
+
if (fs22.existsSync(path17.join(d.active, uuid))) return "active";
|
|
3023
|
+
if (fs22.existsSync(path17.join(d.done, uuid))) return "done";
|
|
3024
|
+
if (fs22.existsSync(path17.join(d.queue, `${uuid}.json`))) return "queued";
|
|
2889
3025
|
return void 0;
|
|
2890
3026
|
}
|
|
2891
3027
|
function buildTendReport(now = Date.now()) {
|
|
@@ -2945,7 +3081,7 @@ function buildTendReport(now = Date.now()) {
|
|
|
2945
3081
|
}
|
|
2946
3082
|
}
|
|
2947
3083
|
const oldestQueuedAge = queued.reduce((max, id) => {
|
|
2948
|
-
const st =
|
|
3084
|
+
const st = fs22.statSync(path17.join(laneDirs("work").queue, `${id}.json`), { throwIfNoEntry: false });
|
|
2949
3085
|
return st ? Math.max(max, now - st.mtimeMs) : max;
|
|
2950
3086
|
}, 0);
|
|
2951
3087
|
const stalled = daemonUp && queued.length > 0 && active.length < cfg.limits.maxConcurrent && oldestQueuedAge > CLAIM_STALE_MS;
|
|
@@ -3082,13 +3218,13 @@ function renderTend(r) {
|
|
|
3082
3218
|
}
|
|
3083
3219
|
|
|
3084
3220
|
// apps/cli/src/cull.ts
|
|
3085
|
-
import * as
|
|
3086
|
-
import * as
|
|
3221
|
+
import * as fs23 from "node:fs";
|
|
3222
|
+
import * as path18 from "node:path";
|
|
3087
3223
|
import { execFileSync as execFileSync2 } from "node:child_process";
|
|
3088
3224
|
var DAY = 864e5;
|
|
3089
3225
|
function idsIn(dir) {
|
|
3090
3226
|
try {
|
|
3091
|
-
return new Set(
|
|
3227
|
+
return new Set(fs23.readdirSync(dir).filter((f) => !f.startsWith(".")).map((f) => f.replace(/\.json$/, "")));
|
|
3092
3228
|
} catch {
|
|
3093
3229
|
return /* @__PURE__ */ new Set();
|
|
3094
3230
|
}
|
|
@@ -3103,39 +3239,39 @@ function planCull(olderThanDays, now = Date.now()) {
|
|
|
3103
3239
|
for (const id of idsIn(d.queue)) live.add(id);
|
|
3104
3240
|
for (const id of idsIn(d.active)) live.add(id);
|
|
3105
3241
|
for (const id of idsIn(d.done)) {
|
|
3106
|
-
const p =
|
|
3107
|
-
const m =
|
|
3242
|
+
const p = path18.join(d.done, id);
|
|
3243
|
+
const m = fs23.statSync(p).mtimeMs;
|
|
3108
3244
|
doneMtimes.set(id, m);
|
|
3109
3245
|
if (m < cutoff) items.push({ kind: "done", id, target: p, ageDays: Math.floor((now - m) / DAY) });
|
|
3110
3246
|
}
|
|
3111
3247
|
}
|
|
3112
|
-
const wtRoot =
|
|
3248
|
+
const wtRoot = path18.join(lobstahHome(), "worktrees");
|
|
3113
3249
|
for (const id of idsIn(wtRoot)) {
|
|
3114
3250
|
if (live.has(id)) continue;
|
|
3115
3251
|
const doneAt = doneMtimes.get(id);
|
|
3116
3252
|
if (doneAt !== void 0 && doneAt >= cutoff) continue;
|
|
3117
|
-
const p =
|
|
3118
|
-
items.push({ kind: "worktree", id, target: p, ageDays: Math.floor((now -
|
|
3253
|
+
const p = path18.join(wtRoot, id);
|
|
3254
|
+
items.push({ kind: "worktree", id, target: p, ageDays: Math.floor((now - fs23.statSync(p).mtimeMs) / DAY) });
|
|
3119
3255
|
}
|
|
3120
3256
|
for (const lane of ["work", "chore"]) {
|
|
3121
3257
|
const d = laneDirs(lane);
|
|
3122
3258
|
const seen = /* @__PURE__ */ new Set();
|
|
3123
|
-
for (const f of
|
|
3259
|
+
for (const f of fs23.readdirSync(d.state)) {
|
|
3124
3260
|
const id = f.replace(/\.(status|events|evidence|attn|notified|runner\.log)$/, "");
|
|
3125
3261
|
if (id === f || seen.has(id) || live.has(id) || doneMtimes.has(id)) continue;
|
|
3126
|
-
const m =
|
|
3262
|
+
const m = fs23.statSync(path18.join(d.state, f)).mtimeMs;
|
|
3127
3263
|
if (m >= cutoff) continue;
|
|
3128
3264
|
seen.add(id);
|
|
3129
|
-
items.push({ kind: "state", id, target:
|
|
3265
|
+
items.push({ kind: "state", id, target: path18.join(d.state, `${id}.*`), ageDays: Math.floor((now - m) / DAY) });
|
|
3130
3266
|
}
|
|
3131
3267
|
}
|
|
3132
3268
|
return items;
|
|
3133
3269
|
}
|
|
3134
3270
|
function removeWorktree(id, dir) {
|
|
3135
3271
|
for (const lane of ["work", "chore"]) {
|
|
3136
|
-
const descFile =
|
|
3272
|
+
const descFile = path18.join(laneDirs(lane).done, id, "descriptor.json");
|
|
3137
3273
|
try {
|
|
3138
|
-
const desc = JSON.parse(
|
|
3274
|
+
const desc = JSON.parse(fs23.readFileSync(descFile, "utf8"));
|
|
3139
3275
|
const repo = loadConfig().repos[desc.repo];
|
|
3140
3276
|
if (repo) {
|
|
3141
3277
|
execFileSync2("git", ["worktree", "remove", "--force", dir], { cwd: repo.path, stdio: "ignore" });
|
|
@@ -3144,15 +3280,15 @@ function removeWorktree(id, dir) {
|
|
|
3144
3280
|
} catch {
|
|
3145
3281
|
}
|
|
3146
3282
|
}
|
|
3147
|
-
|
|
3283
|
+
fs23.rmSync(dir, { recursive: true, force: true });
|
|
3148
3284
|
}
|
|
3149
3285
|
function applyCull(items) {
|
|
3150
3286
|
for (const item of items.filter((i) => i.kind === "worktree")) removeWorktree(item.id, item.target);
|
|
3151
|
-
for (const item of items.filter((i) => i.kind === "done"))
|
|
3287
|
+
for (const item of items.filter((i) => i.kind === "done")) fs23.rmSync(item.target, { recursive: true, force: true });
|
|
3152
3288
|
for (const item of items.filter((i) => i.kind === "state")) {
|
|
3153
|
-
const dir =
|
|
3154
|
-
for (const f of
|
|
3155
|
-
if (f.startsWith(`${item.id}.`))
|
|
3289
|
+
const dir = path18.dirname(item.target);
|
|
3290
|
+
for (const f of fs23.readdirSync(dir)) {
|
|
3291
|
+
if (f.startsWith(`${item.id}.`)) fs23.rmSync(path18.join(dir, f), { force: true });
|
|
3156
3292
|
}
|
|
3157
3293
|
}
|
|
3158
3294
|
}
|
|
@@ -3206,7 +3342,7 @@ paste into your liaison instructions:
|
|
|
3206
3342
|
full pattern and trade-offs: docs/lobsterman.md \xB7 config: docs/configuration.md`;
|
|
3207
3343
|
|
|
3208
3344
|
// apps/cli/src/doctor.ts
|
|
3209
|
-
import * as
|
|
3345
|
+
import * as fs24 from "node:fs";
|
|
3210
3346
|
import { spawnSync as spawnSync2 } from "node:child_process";
|
|
3211
3347
|
var HEARTBEAT_STALE_MS2 = 9e4;
|
|
3212
3348
|
function git(repoPath, ...args) {
|
|
@@ -3229,13 +3365,13 @@ function runDoctor(now = Date.now()) {
|
|
|
3229
3365
|
codexPath ? "on PATH" : codexSdk ? "vendored SDK (attach uses it too)" : "neither codex nor the SDK \u2014 codex dispatches will fail"
|
|
3230
3366
|
);
|
|
3231
3367
|
const cfgFile = configPath();
|
|
3232
|
-
if (!
|
|
3368
|
+
if (!fs24.existsSync(cfgFile)) {
|
|
3233
3369
|
push("config", "fail", `${cfgFile} missing \u2014 run \`lobstah init\``);
|
|
3234
3370
|
return rows;
|
|
3235
3371
|
}
|
|
3236
3372
|
let parsed;
|
|
3237
3373
|
try {
|
|
3238
|
-
parsed = parse(
|
|
3374
|
+
parsed = parse(fs24.readFileSync(cfgFile, "utf8"));
|
|
3239
3375
|
push("config", "ok", cfgFile);
|
|
3240
3376
|
} catch (err) {
|
|
3241
3377
|
push("config", "fail", `${cfgFile}: ${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -3246,7 +3382,7 @@ function runDoctor(now = Date.now()) {
|
|
|
3246
3382
|
if (repoKeys.length === 0) push("repos", "warn", "none configured \u2014 add one with `lobstah repos add <path>`");
|
|
3247
3383
|
for (const [key, repo] of Object.entries(cfg.repos)) {
|
|
3248
3384
|
const label = `repo ${key}`;
|
|
3249
|
-
if (!
|
|
3385
|
+
if (!fs24.existsSync(repo.path)) {
|
|
3250
3386
|
push(label, "fail", `path ${repo.path} does not exist`);
|
|
3251
3387
|
continue;
|
|
3252
3388
|
}
|
|
@@ -3274,11 +3410,11 @@ function runDoctor(now = Date.now()) {
|
|
|
3274
3410
|
}
|
|
3275
3411
|
}
|
|
3276
3412
|
const hb = executorPath();
|
|
3277
|
-
if (!
|
|
3413
|
+
if (!fs24.existsSync(hb)) {
|
|
3278
3414
|
push("daemon", "warn", "no heartbeat \u2014 daemon not running (`lobstah daemon install`)");
|
|
3279
3415
|
} else {
|
|
3280
3416
|
try {
|
|
3281
|
-
const payload = JSON.parse(
|
|
3417
|
+
const payload = JSON.parse(fs24.readFileSync(hb, "utf8"));
|
|
3282
3418
|
const age = now - new Date(payload.heartbeat ?? 0).getTime();
|
|
3283
3419
|
push(
|
|
3284
3420
|
"daemon",
|
|
@@ -3294,9 +3430,9 @@ function runDoctor(now = Date.now()) {
|
|
|
3294
3430
|
}
|
|
3295
3431
|
|
|
3296
3432
|
// apps/cli/src/service.ts
|
|
3297
|
-
import * as
|
|
3433
|
+
import * as fs25 from "node:fs";
|
|
3298
3434
|
import * as os5 from "node:os";
|
|
3299
|
-
import * as
|
|
3435
|
+
import * as path19 from "node:path";
|
|
3300
3436
|
import { spawnSync as spawnSync3 } from "node:child_process";
|
|
3301
3437
|
function xml(s) {
|
|
3302
3438
|
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
@@ -3320,8 +3456,8 @@ function renderLaunchdPlist(spec) {
|
|
|
3320
3456
|
</dict>
|
|
3321
3457
|
<key>RunAtLoad</key><true/>
|
|
3322
3458
|
<key>KeepAlive</key><true/>
|
|
3323
|
-
<key>StandardOutPath</key><string>${xml(
|
|
3324
|
-
<key>StandardErrorPath</key><string>${xml(
|
|
3459
|
+
<key>StandardOutPath</key><string>${xml(path19.posix.join(spec.logDir, `${spec.kind}.log`))}</string>
|
|
3460
|
+
<key>StandardErrorPath</key><string>${xml(path19.posix.join(spec.logDir, `${spec.kind}.err`))}</string>
|
|
3325
3461
|
</dict>
|
|
3326
3462
|
</plist>
|
|
3327
3463
|
`;
|
|
@@ -3336,8 +3472,8 @@ Restart=always
|
|
|
3336
3472
|
RestartSec=5
|
|
3337
3473
|
Environment=PATH=${spec.pathEnv}
|
|
3338
3474
|
Environment=LOBSTAH_HOME=${spec.home}
|
|
3339
|
-
StandardOutput=append:${
|
|
3340
|
-
StandardError=append:${
|
|
3475
|
+
StandardOutput=append:${path19.posix.join(spec.logDir, `${spec.kind}.log`)}
|
|
3476
|
+
StandardError=append:${path19.posix.join(spec.logDir, `${spec.kind}.err`)}
|
|
3341
3477
|
|
|
3342
3478
|
[Install]
|
|
3343
3479
|
WantedBy=default.target
|
|
@@ -3345,8 +3481,8 @@ WantedBy=default.target
|
|
|
3345
3481
|
}
|
|
3346
3482
|
function servicePathEnv(nodePath, home = os5.homedir()) {
|
|
3347
3483
|
const dirs = [
|
|
3348
|
-
|
|
3349
|
-
|
|
3484
|
+
path19.posix.dirname(nodePath),
|
|
3485
|
+
path19.posix.join(home, ".local", "bin"),
|
|
3350
3486
|
"/opt/homebrew/bin",
|
|
3351
3487
|
"/usr/local/bin",
|
|
3352
3488
|
"/usr/bin",
|
|
@@ -3355,7 +3491,7 @@ function servicePathEnv(nodePath, home = os5.homedir()) {
|
|
|
3355
3491
|
return [...new Set(dirs)].join(":");
|
|
3356
3492
|
}
|
|
3357
3493
|
function serviceFile(kind) {
|
|
3358
|
-
return process.platform === "darwin" ?
|
|
3494
|
+
return process.platform === "darwin" ? path19.join(os5.homedir(), "Library", "LaunchAgents", `lobstah.${kind}.plist`) : path19.join(os5.homedir(), ".config", "systemd", "user", `lobstah-${kind}.service`);
|
|
3359
3495
|
}
|
|
3360
3496
|
function run(cmd, args) {
|
|
3361
3497
|
const res = spawnSync3(cmd, args, { encoding: "utf8" });
|
|
@@ -3365,28 +3501,28 @@ function installService(kind) {
|
|
|
3365
3501
|
if (process.platform === "win32") {
|
|
3366
3502
|
throw new Error(`no service manager support on Windows \u2014 run \`lobstah ${kind}\` under your process manager of choice`);
|
|
3367
3503
|
}
|
|
3368
|
-
const entry =
|
|
3504
|
+
const entry = fs25.realpathSync(process.argv[1]);
|
|
3369
3505
|
const spec = {
|
|
3370
3506
|
kind,
|
|
3371
3507
|
nodePath: process.execPath,
|
|
3372
3508
|
entry,
|
|
3373
3509
|
pathEnv: servicePathEnv(process.execPath),
|
|
3374
3510
|
home: lobstahHome(),
|
|
3375
|
-
logDir:
|
|
3511
|
+
logDir: path19.join(lobstahHome(), "logs")
|
|
3376
3512
|
};
|
|
3377
3513
|
if (!onPath("git", spec.pathEnv)) {
|
|
3378
3514
|
throw new Error(`git is not reachable on the service PATH (${spec.pathEnv}) \u2014 the daemon cannot allocate worktrees`);
|
|
3379
3515
|
}
|
|
3380
|
-
|
|
3516
|
+
fs25.mkdirSync(spec.logDir, { recursive: true });
|
|
3381
3517
|
const file = serviceFile(kind);
|
|
3382
|
-
|
|
3518
|
+
fs25.mkdirSync(path19.dirname(file), { recursive: true });
|
|
3383
3519
|
if (process.platform === "darwin") {
|
|
3384
|
-
if (
|
|
3385
|
-
|
|
3520
|
+
if (fs25.existsSync(file)) run("launchctl", ["unload", file]);
|
|
3521
|
+
fs25.writeFileSync(file, renderLaunchdPlist(spec));
|
|
3386
3522
|
const load = run("launchctl", ["load", file]);
|
|
3387
3523
|
return { file, loaded: load.ok, detail: load.ok ? `label lobstah.${kind}` : load.out };
|
|
3388
3524
|
}
|
|
3389
|
-
|
|
3525
|
+
fs25.writeFileSync(file, renderSystemdUnit(spec));
|
|
3390
3526
|
run("systemctl", ["--user", "daemon-reload"]);
|
|
3391
3527
|
const enable = run("systemctl", ["--user", "enable", "--now", `lobstah-${kind}.service`]);
|
|
3392
3528
|
return { file, loaded: enable.ok, detail: enable.ok ? `unit lobstah-${kind}.service` : enable.out };
|
|
@@ -3394,19 +3530,19 @@ function installService(kind) {
|
|
|
3394
3530
|
function uninstallService(kind) {
|
|
3395
3531
|
const file = serviceFile(kind);
|
|
3396
3532
|
if (process.platform === "darwin") {
|
|
3397
|
-
if (
|
|
3533
|
+
if (fs25.existsSync(file)) run("launchctl", ["unload", file]);
|
|
3398
3534
|
} else {
|
|
3399
3535
|
run("systemctl", ["--user", "disable", "--now", `lobstah-${kind}.service`]);
|
|
3400
3536
|
}
|
|
3401
|
-
const removed =
|
|
3402
|
-
if (removed)
|
|
3537
|
+
const removed = fs25.existsSync(file);
|
|
3538
|
+
if (removed) fs25.unlinkSync(file);
|
|
3403
3539
|
if (process.platform !== "darwin") run("systemctl", ["--user", "daemon-reload"]);
|
|
3404
3540
|
return { file, removed };
|
|
3405
3541
|
}
|
|
3406
3542
|
|
|
3407
3543
|
// apps/cli/src/repos.ts
|
|
3408
|
-
import * as
|
|
3409
|
-
import * as
|
|
3544
|
+
import * as fs26 from "node:fs";
|
|
3545
|
+
import * as path20 from "node:path";
|
|
3410
3546
|
import { spawnSync as spawnSync4 } from "node:child_process";
|
|
3411
3547
|
function git2(dir, ...args) {
|
|
3412
3548
|
const res = spawnSync4("git", ["-C", dir, ...args], { encoding: "utf8" });
|
|
@@ -3422,41 +3558,41 @@ var LOCKFILE_SETUP = [
|
|
|
3422
3558
|
["poetry.lock", ["poetry install"]]
|
|
3423
3559
|
];
|
|
3424
3560
|
function detectRepo(dir) {
|
|
3425
|
-
const abs =
|
|
3561
|
+
const abs = path20.resolve(dir);
|
|
3426
3562
|
if (git2(abs, "rev-parse", "--is-inside-work-tree") !== "true") return void 0;
|
|
3427
3563
|
const top = git2(abs, "rev-parse", "--show-toplevel");
|
|
3428
3564
|
const canon2 = (p) => {
|
|
3429
|
-
const real =
|
|
3565
|
+
const real = path20.resolve(fs26.realpathSync.native(p));
|
|
3430
3566
|
return process.platform === "win32" ? real.toLowerCase() : real;
|
|
3431
3567
|
};
|
|
3432
3568
|
if (!top || canon2(top) !== canon2(abs)) return void 0;
|
|
3433
3569
|
const origin = git2(abs, "remote", "get-url", "origin");
|
|
3434
3570
|
const headRef = git2(abs, "symbolic-ref", "--short", "refs/remotes/origin/HEAD");
|
|
3435
3571
|
const trunk = headRef?.replace(/^origin\//, "") ?? git2(abs, "branch", "--show-current") ?? "main";
|
|
3436
|
-
const setup = LOCKFILE_SETUP.find(([f]) =>
|
|
3437
|
-
return { key:
|
|
3572
|
+
const setup = LOCKFILE_SETUP.find(([f]) => fs26.existsSync(path20.join(abs, f)))?.[1];
|
|
3573
|
+
return { key: path20.basename(abs), path: abs, trunk, origin, setup };
|
|
3438
3574
|
}
|
|
3439
3575
|
function scanForRepos(roots) {
|
|
3440
3576
|
const found = [];
|
|
3441
3577
|
for (const root of roots) {
|
|
3442
|
-
const abs =
|
|
3443
|
-
if (!
|
|
3578
|
+
const abs = path20.resolve(root);
|
|
3579
|
+
if (!fs26.existsSync(abs)) continue;
|
|
3444
3580
|
const self = detectRepo(abs);
|
|
3445
3581
|
if (self) {
|
|
3446
3582
|
found.push(self);
|
|
3447
3583
|
continue;
|
|
3448
3584
|
}
|
|
3449
|
-
for (const entry of
|
|
3585
|
+
for (const entry of fs26.readdirSync(abs, { withFileTypes: true })) {
|
|
3450
3586
|
if (!entry.isDirectory() || entry.name.startsWith(".")) continue;
|
|
3451
|
-
const child = detectRepo(
|
|
3587
|
+
const child = detectRepo(path20.join(abs, entry.name));
|
|
3452
3588
|
if (child) found.push(child);
|
|
3453
3589
|
}
|
|
3454
3590
|
}
|
|
3455
3591
|
return found;
|
|
3456
3592
|
}
|
|
3457
3593
|
function configuredRepoKeys(file = configPath()) {
|
|
3458
|
-
if (!
|
|
3459
|
-
const raw = parse(
|
|
3594
|
+
if (!fs26.existsSync(file)) return /* @__PURE__ */ new Set();
|
|
3595
|
+
const raw = parse(fs26.readFileSync(file, "utf8"));
|
|
3460
3596
|
return new Set(Object.keys(raw.repos ?? {}));
|
|
3461
3597
|
}
|
|
3462
3598
|
function appendRepoBlock(repo, opts = {}, file = configPath()) {
|
|
@@ -3464,7 +3600,7 @@ function appendRepoBlock(repo, opts = {}, file = configPath()) {
|
|
|
3464
3600
|
if (repo.origin) lines.push(`origin = ${JSON.stringify(repo.origin)}`);
|
|
3465
3601
|
if (repo.setup) lines.push(`setup = [${repo.setup.map((s) => JSON.stringify(s)).join(", ")}]`);
|
|
3466
3602
|
if (opts.pickup) lines.push(`pickup = true`);
|
|
3467
|
-
|
|
3603
|
+
fs26.appendFileSync(file, `${lines.join("\n")}
|
|
3468
3604
|
`);
|
|
3469
3605
|
}
|
|
3470
3606
|
|
|
@@ -3477,8 +3613,8 @@ function parseReportArgs(rest) {
|
|
|
3477
3613
|
}
|
|
3478
3614
|
|
|
3479
3615
|
// apps/cli/src/soak-site.ts
|
|
3480
|
-
import * as
|
|
3481
|
-
import * as
|
|
3616
|
+
import * as fs27 from "node:fs";
|
|
3617
|
+
import * as path21 from "node:path";
|
|
3482
3618
|
import { spawnSync as spawnSync5 } from "node:child_process";
|
|
3483
3619
|
function git3(dir, ...args) {
|
|
3484
3620
|
const res = spawnSync5("git", ["-C", dir, ...args], { encoding: "utf8" });
|
|
@@ -3486,10 +3622,10 @@ function git3(dir, ...args) {
|
|
|
3486
3622
|
}
|
|
3487
3623
|
function canon(p) {
|
|
3488
3624
|
try {
|
|
3489
|
-
const real =
|
|
3625
|
+
const real = path21.resolve(fs27.realpathSync.native(p));
|
|
3490
3626
|
return process.platform === "win32" ? real.toLowerCase() : real;
|
|
3491
3627
|
} catch {
|
|
3492
|
-
return
|
|
3628
|
+
return path21.resolve(p);
|
|
3493
3629
|
}
|
|
3494
3630
|
}
|
|
3495
3631
|
function inspectSoakSite(cwd, repos) {
|
|
@@ -3498,16 +3634,16 @@ function inspectSoakSite(cwd, repos) {
|
|
|
3498
3634
|
const gitDir = git3(cwd, "rev-parse", "--git-dir");
|
|
3499
3635
|
const commonDir = git3(cwd, "rev-parse", "--git-common-dir");
|
|
3500
3636
|
if (!gitDir || !commonDir) return void 0;
|
|
3501
|
-
const abs = (p) =>
|
|
3637
|
+
const abs = (p) => path21.isAbsolute(p) ? p : path21.join(cwd, p);
|
|
3502
3638
|
const primary = canon(abs(gitDir)) === canon(abs(commonDir));
|
|
3503
|
-
const primaryRoot = canon(
|
|
3639
|
+
const primaryRoot = canon(path21.dirname(abs(commonDir)));
|
|
3504
3640
|
const repoKey = Object.entries(repos).find(([, r]) => canon(r.path) === primaryRoot)?.[0];
|
|
3505
3641
|
return { worktree: canon(top), primary, repoKey };
|
|
3506
3642
|
}
|
|
3507
3643
|
function readHookStdin() {
|
|
3508
3644
|
if (process.stdin.isTTY) return void 0;
|
|
3509
3645
|
try {
|
|
3510
|
-
const raw =
|
|
3646
|
+
const raw = fs27.readFileSync(0, "utf8");
|
|
3511
3647
|
if (!raw.trim()) return void 0;
|
|
3512
3648
|
const parsed = JSON.parse(raw);
|
|
3513
3649
|
return typeof parsed === "object" && parsed !== null ? parsed : void 0;
|
|
@@ -3841,7 +3977,7 @@ function emitWatchAttention(attns) {
|
|
|
3841
3977
|
function findLane(id) {
|
|
3842
3978
|
for (const lane of ["work", "chore"]) {
|
|
3843
3979
|
const d = laneDirs(lane);
|
|
3844
|
-
if (
|
|
3980
|
+
if (fs28.existsSync(path22.join(d.active, id)) || fs28.existsSync(path22.join(d.queue, `${id}.json`)) || fs28.existsSync(path22.join(d.done, id)) || fs28.existsSync(path22.join(d.state, `${id}.status`))) {
|
|
3845
3981
|
return lane;
|
|
3846
3982
|
}
|
|
3847
3983
|
}
|
|
@@ -3849,7 +3985,7 @@ function findLane(id) {
|
|
|
3849
3985
|
}
|
|
3850
3986
|
function rowsFor(lane, bucket) {
|
|
3851
3987
|
const dir = laneDirs(lane)[bucket];
|
|
3852
|
-
const entries =
|
|
3988
|
+
const entries = fs28.readdirSync(dir).filter((f) => !f.startsWith(".")).map((f) => ({ f, m: fs28.statSync(path22.join(dir, f)).mtimeMs })).sort((a, b) => b.m - a.m);
|
|
3853
3989
|
const sliced = bucket === "done" ? entries.slice(0, 10) : entries;
|
|
3854
3990
|
return sliced.map(({ f, m }) => {
|
|
3855
3991
|
const id = f.replace(/\.json$/, "");
|
|
@@ -3891,7 +4027,7 @@ ${usageFor(cmd)}`);
|
|
|
3891
4027
|
const d = {
|
|
3892
4028
|
id: arg(args, "--id") ?? randomUUID4(),
|
|
3893
4029
|
repo,
|
|
3894
|
-
brief: briefText ??
|
|
4030
|
+
brief: briefText ?? fs28.readFileSync(briefFile, "utf8"),
|
|
3895
4031
|
harness: arg(args, "--harness"),
|
|
3896
4032
|
model: arg(args, "--model"),
|
|
3897
4033
|
effort: arg(args, "--effort"),
|
|
@@ -3942,8 +4078,8 @@ ${usageFor(cmd)}`);
|
|
|
3942
4078
|
if (!id) throw new Error("logs requires a dispatch id");
|
|
3943
4079
|
const lane = findLane(id);
|
|
3944
4080
|
const file = eventsPath(id, lane);
|
|
3945
|
-
if (
|
|
3946
|
-
const raw =
|
|
4081
|
+
if (fs28.existsSync(file)) {
|
|
4082
|
+
const raw = fs28.readFileSync(file, "utf8");
|
|
3947
4083
|
const lines = raw.split("\n").filter((l) => l.length > 0);
|
|
3948
4084
|
const LIMIT = 50;
|
|
3949
4085
|
if (!args.includes("--full") && lines.length > LIMIT) {
|
|
@@ -3956,15 +4092,15 @@ ${usageFor(cmd)}`);
|
|
|
3956
4092
|
}
|
|
3957
4093
|
}
|
|
3958
4094
|
if (args.includes("--follow")) {
|
|
3959
|
-
let size =
|
|
4095
|
+
let size = fs28.existsSync(file) ? fs28.statSync(file).size : 0;
|
|
3960
4096
|
setInterval(() => {
|
|
3961
|
-
if (!
|
|
3962
|
-
const now =
|
|
4097
|
+
if (!fs28.existsSync(file)) return;
|
|
4098
|
+
const now = fs28.statSync(file).size;
|
|
3963
4099
|
if (now > size) {
|
|
3964
|
-
const fd =
|
|
4100
|
+
const fd = fs28.openSync(file, "r");
|
|
3965
4101
|
const buf = Buffer.alloc(now - size);
|
|
3966
|
-
|
|
3967
|
-
|
|
4102
|
+
fs28.readSync(fd, buf, 0, buf.length, size);
|
|
4103
|
+
fs28.closeSync(fd);
|
|
3968
4104
|
process.stdout.write(buf.toString("utf8"));
|
|
3969
4105
|
size = now;
|
|
3970
4106
|
}
|
|
@@ -4018,16 +4154,16 @@ ${usageFor(cmd)}`);
|
|
|
4018
4154
|
);
|
|
4019
4155
|
}
|
|
4020
4156
|
const dirs = laneDirs(lane);
|
|
4021
|
-
const descriptorFile = ["active", "done"].map((b) =>
|
|
4022
|
-
const harness = descriptorFile ? JSON.parse(
|
|
4157
|
+
const descriptorFile = ["active", "done"].map((b) => path22.join(dirs[b], id, "descriptor.json")).find((f) => fs28.existsSync(f));
|
|
4158
|
+
const harness = descriptorFile ? JSON.parse(fs28.readFileSync(descriptorFile, "utf8")).harness ?? "claude" : "claude";
|
|
4023
4159
|
let sessionId;
|
|
4024
4160
|
try {
|
|
4025
|
-
sessionId = JSON.parse(
|
|
4161
|
+
sessionId = JSON.parse(fs28.readFileSync(path22.join(dirs.state, `${id}.evidence`), "utf8")).sessionId;
|
|
4026
4162
|
} catch {
|
|
4027
4163
|
}
|
|
4028
4164
|
if (!sessionId) throw new Error(`${id} has no recorded harness session to attach to`);
|
|
4029
|
-
const worktree =
|
|
4030
|
-
const cwd =
|
|
4165
|
+
const worktree = path22.join(lobstahHome(), "worktrees", id);
|
|
4166
|
+
const cwd = fs28.existsSync(worktree) ? worktree : process.cwd();
|
|
4031
4167
|
const invocation = harness === "codex" ? codexInvocation(["resume", sessionId]) : { file: "claude", argv: ["--resume", sessionId] };
|
|
4032
4168
|
if (!invocation) throw new Error("no codex CLI found \u2014 neither on PATH nor vendored by @openai/codex-sdk");
|
|
4033
4169
|
if (args.includes("--print")) {
|
|
@@ -4042,19 +4178,19 @@ ${usageFor(cmd)}`);
|
|
|
4042
4178
|
const id = args[0];
|
|
4043
4179
|
if (!id) throw new Error("swap requires a dispatch id");
|
|
4044
4180
|
const lane = findLane(id);
|
|
4045
|
-
const activeDir =
|
|
4046
|
-
if (!
|
|
4047
|
-
const descFile =
|
|
4048
|
-
const descriptor = JSON.parse(
|
|
4181
|
+
const activeDir = path22.join(laneDirs(lane).active, id);
|
|
4182
|
+
if (!fs28.existsSync(activeDir)) throw new Error(`${id} is not active \u2014 swap only applies to in-flight dispatches`);
|
|
4183
|
+
const descFile = path22.join(activeDir, "descriptor.json");
|
|
4184
|
+
const descriptor = JSON.parse(fs28.readFileSync(descFile, "utf8"));
|
|
4049
4185
|
const fromHarness = descriptor.harness ?? "default";
|
|
4050
4186
|
for (const key of ["harness", "model", "effort"]) {
|
|
4051
4187
|
const v = arg(args, `--${key}`);
|
|
4052
4188
|
if (v) descriptor[key] = v;
|
|
4053
4189
|
}
|
|
4054
|
-
|
|
4055
|
-
const worktree =
|
|
4190
|
+
fs28.writeFileSync(descFile, JSON.stringify(descriptor, null, 2));
|
|
4191
|
+
const worktree = path22.join(lobstahHome(), "worktrees", id);
|
|
4056
4192
|
let progress = "No worktree progress recorded.";
|
|
4057
|
-
if (
|
|
4193
|
+
if (fs28.existsSync(worktree)) {
|
|
4058
4194
|
const git4 = (...a) => spawnSync6("git", a, { cwd: worktree, encoding: "utf8" }).stdout?.trim() ?? "";
|
|
4059
4195
|
const commits = git4("log", "--oneline", "-15");
|
|
4060
4196
|
const status = git4("status", "--short");
|
|
@@ -4064,17 +4200,17 @@ ${commits || "(none)"}
|
|
|
4064
4200
|
Uncommitted changes:
|
|
4065
4201
|
${status || "(clean)"}`;
|
|
4066
4202
|
}
|
|
4067
|
-
|
|
4068
|
-
|
|
4203
|
+
fs28.writeFileSync(
|
|
4204
|
+
path22.join(activeDir, "handoff"),
|
|
4069
4205
|
`You are taking over this dispatch from a previous agent session (harness: ${fromHarness}). Its conversation is not available \u2014 the worktree state below is the ground truth. Review it, then continue the brief from where it stops.
|
|
4070
4206
|
|
|
4071
4207
|
${progress}`
|
|
4072
4208
|
);
|
|
4073
|
-
const runnerFile =
|
|
4074
|
-
if (
|
|
4075
|
-
const runner = JSON.parse(
|
|
4209
|
+
const runnerFile = path22.join(activeDir, "runner.json");
|
|
4210
|
+
if (fs28.existsSync(runnerFile)) {
|
|
4211
|
+
const runner = JSON.parse(fs28.readFileSync(runnerFile, "utf8"));
|
|
4076
4212
|
if (pidAlive(runner.pid, runner.processStartTime)) killGroup(runner.pid, "SIGKILL");
|
|
4077
|
-
|
|
4213
|
+
fs28.unlinkSync(runnerFile);
|
|
4078
4214
|
}
|
|
4079
4215
|
appendStatus(id, lane, "working", `swapped to ${descriptor.harness ?? "default"} \u2014 awaiting respawn`);
|
|
4080
4216
|
console.log(toonKV({ id, harness: descriptor.harness ?? "default", model: descriptor.model, swapped: true }));
|
|
@@ -4179,20 +4315,20 @@ ${progress}`
|
|
|
4179
4315
|
break;
|
|
4180
4316
|
}
|
|
4181
4317
|
case "man:init": {
|
|
4182
|
-
const file = args.includes("--global") ?
|
|
4318
|
+
const file = args.includes("--global") ? path22.join(os6.homedir(), ".claude", "settings.json") : path22.join(".claude", args.includes("--shared") ? "settings.json" : "settings.local.json");
|
|
4183
4319
|
let existing;
|
|
4184
4320
|
try {
|
|
4185
|
-
existing = JSON.parse(
|
|
4321
|
+
existing = JSON.parse(fs28.readFileSync(file, "utf8"));
|
|
4186
4322
|
} catch {
|
|
4187
4323
|
existing = void 0;
|
|
4188
4324
|
}
|
|
4189
4325
|
const { settings, changed } = mergeHaulHook(existing);
|
|
4190
4326
|
if (changed) {
|
|
4191
|
-
|
|
4192
|
-
|
|
4327
|
+
fs28.mkdirSync(path22.dirname(file), { recursive: true });
|
|
4328
|
+
fs28.writeFileSync(file, `${JSON.stringify(settings, null, 2)}
|
|
4193
4329
|
`);
|
|
4194
4330
|
}
|
|
4195
|
-
if (args.includes("--marker"))
|
|
4331
|
+
if (args.includes("--marker")) fs28.writeFileSync(".lobstah-man", "");
|
|
4196
4332
|
console.log(
|
|
4197
4333
|
toonKV({
|
|
4198
4334
|
hook: "lobstah man haul",
|
|
@@ -4211,9 +4347,9 @@ ${progress}`
|
|
|
4211
4347
|
await soakPark(soakReg.sessionId, args);
|
|
4212
4348
|
break;
|
|
4213
4349
|
}
|
|
4214
|
-
if (process.env.LOBSTAH_MAN !== "1" && !
|
|
4350
|
+
if (process.env.LOBSTAH_MAN !== "1" && !fs28.existsSync(".lobstah-man")) break;
|
|
4215
4351
|
const anyActive = ["work", "chore"].some(
|
|
4216
|
-
(l) =>
|
|
4352
|
+
(l) => fs28.readdirSync(laneDirs(l).active).some((f) => !f.startsWith("."))
|
|
4217
4353
|
);
|
|
4218
4354
|
const anyWatch = listWatches().some((w) => w.owner === "man");
|
|
4219
4355
|
if (!anyActive && !anyWatch) break;
|
|
@@ -4375,7 +4511,7 @@ ${progress}`
|
|
|
4375
4511
|
path: r.path,
|
|
4376
4512
|
trunk: r.trunk,
|
|
4377
4513
|
pickup: r.pickup ?? false,
|
|
4378
|
-
exists:
|
|
4514
|
+
exists: fs28.existsSync(r.path)
|
|
4379
4515
|
}));
|
|
4380
4516
|
console.log(toonTable("repos", rows, ["key", "path", "trunk", "pickup", "exists"]));
|
|
4381
4517
|
break;
|
|
@@ -4425,7 +4561,7 @@ ${progress}`
|
|
|
4425
4561
|
break;
|
|
4426
4562
|
}
|
|
4427
4563
|
case "init": {
|
|
4428
|
-
if (!
|
|
4564
|
+
if (!fs28.existsSync(configPath())) {
|
|
4429
4565
|
const exampleRepo = args.includes("--scan") ? "" : `[repos.example]
|
|
4430
4566
|
path = "~/src/example"
|
|
4431
4567
|
trunk = "main"
|
|
@@ -4434,7 +4570,7 @@ trunk = "main"
|
|
|
4434
4570
|
# pickup = true # opt into [pickup.github] multi-repo tracker pickup
|
|
4435
4571
|
|
|
4436
4572
|
`;
|
|
4437
|
-
|
|
4573
|
+
fs28.writeFileSync(
|
|
4438
4574
|
configPath(),
|
|
4439
4575
|
`# lobstah workspace definitions \u2014 the descriptor's repo key resolves here.
|
|
4440
4576
|
# Top-level keys must come before any [section].
|
|
@@ -4472,12 +4608,12 @@ wallClockSecs = 3600
|
|
|
4472
4608
|
let reference = "https://github.com/aequitas-labs/lobstah/blob/main/docs/configuration.md";
|
|
4473
4609
|
try {
|
|
4474
4610
|
const local = fileURLToPath2(new URL("../docs/configuration.md", import.meta.url));
|
|
4475
|
-
if (
|
|
4611
|
+
if (fs28.existsSync(local)) reference = local;
|
|
4476
4612
|
} catch {
|
|
4477
4613
|
}
|
|
4478
4614
|
console.log(
|
|
4479
4615
|
toonKV({
|
|
4480
|
-
home:
|
|
4616
|
+
home: path22.dirname(configPath()),
|
|
4481
4617
|
config: configPath(),
|
|
4482
4618
|
...added.length > 0 ? { added: added.join(", ") } : {},
|
|
4483
4619
|
...unmarked.length > 0 ? { note: `none marked pickable \u2014 set pickup = true per [repos.*] (or rerun with --pickup)` } : {},
|
package/docs/lobsterman.md
CHANGED
|
@@ -125,6 +125,18 @@ After dispatching work, run `lobstah man wait` as a background task. When it
|
|
|
125
125
|
completes, follow its `next:` instruction, then re-arm it.
|
|
126
126
|
```
|
|
127
127
|
|
|
128
|
+
**Arm it long-lived.** The default (`--timeout 0`) waits forever and returns
|
|
129
|
+
only when something happens — that is the right idiom, not a compromise.
|
|
130
|
+
Every wake costs the session a turn, so a short timeout used as a "check in
|
|
131
|
+
periodically" mechanism buys nothing but noise: attention is level-triggered
|
|
132
|
+
and stands on disk until consumed, so a wait that dies mid-shift drops zero
|
|
133
|
+
events — the next wait delivers everything that accumulated. There is no
|
|
134
|
+
report to miss either; a quiet fleet has nothing to say. Pass `--timeout`
|
|
135
|
+
only as a dead-man's check on the channel itself (an hour, not minutes): a
|
|
136
|
+
quiet timeout prints one line, exits 3, and the re-arm proves the line was
|
|
137
|
+
alive. Liveness reassurance for the human belongs on a status surface
|
|
138
|
+
(`lobstah man tend`), never in the transcript.
|
|
139
|
+
|
|
128
140
|
**Tier 3 — park the session on a Stop hook (Claude Code only).** A Stop hook
|
|
129
141
|
that blocks on `lobstah man wait`, so the session never
|
|
130
142
|
really idles — it parks for free and continues the moment something needs it.
|
package/docs/pickup.md
CHANGED
|
@@ -92,12 +92,30 @@ The three loops are tracker-agnostic and drive whichever sources are configured.
|
|
|
92
92
|
| Rule | Trigger | Descriptor |
|
|
93
93
|
|---|---|---|
|
|
94
94
|
| Issue pickup | Assigned to the configured identity, in the configured start state | Implementation brief from the issue |
|
|
95
|
-
|
|
|
96
|
-
|
|
97
|
-
A
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
95
|
+
| Feedback pickup | Human feedback on an open PR that maps back to a dispatch | Address-feedback brief; the agent reads the live thread |
|
|
96
|
+
|
|
97
|
+
A dispatch reports `done` when its PR opens, so the reviewer's side of the
|
|
98
|
+
conversation has to re-enter the queue as its own work. Feedback pickup
|
|
99
|
+
covers all of it: a `CHANGES_REQUESTED` review on any sha (a review of a
|
|
100
|
+
slightly stale head still asks for changes), a comment review with a body,
|
|
101
|
+
an inline review-thread comment, or a conversation comment — always from
|
|
102
|
+
someone other than the configured identity, never a lobstah marker comment.
|
|
103
|
+
`APPROVED` reviews are the merge loop's signal, not feedback.
|
|
104
|
+
|
|
105
|
+
The PR maps back to a dispatch two ways: a `lobstah/<uuid>` branch names it
|
|
106
|
+
directly, and any other branch — a soaked session's PR — resolves through
|
|
107
|
+
the PR URL its worker reported as evidence (`report done --pr <url>`). A PR
|
|
108
|
+
that maps to neither is not lobstah's to answer.
|
|
109
|
+
|
|
110
|
+
Each feedback **round** is keyed by the newest feedback event, so a new
|
|
111
|
+
comment after a round completes opens the next round, while the same standing
|
|
112
|
+
feedback never double-dispatches. Rounds on one PR serialize — a round that
|
|
113
|
+
arrives while the previous one runs buffers until it finishes — and each
|
|
114
|
+
round sets `followUp` to the latest session in the chain (the previous
|
|
115
|
+
round, else the implementation dispatch), so feedback lands on the context
|
|
116
|
+
that made the choices; a fully culled chain starts cold and reads the thread
|
|
117
|
+
like anyone else. A rebase chore starts cold on purpose — the conflict is
|
|
118
|
+
about commits the original session never saw.
|
|
101
119
|
|
|
102
120
|
### Claiming
|
|
103
121
|
|
|
@@ -282,6 +300,19 @@ An orphan verdict requires a trustworthy mapping — see
|
|
|
282
300
|
[Pickup state](#pickup-state): a missing table entry is `unknown` and triggers
|
|
283
301
|
a rebuild, never a reset.
|
|
284
302
|
|
|
303
|
+
Linear reconciliation includes assigned/delegated issues in the claimed state
|
|
304
|
+
and terminal (`completed`/`canceled`) states, following every results page.
|
|
305
|
+
A failure report leaves an already closed Linear issue closed.
|
|
306
|
+
|
|
307
|
+
After an issue dispatch finalizes as `failed` and its tracker report succeeds,
|
|
308
|
+
pickup releases its key for another claim when the tracker offers it in the
|
|
309
|
+
start state. The ledger retains the last UUID and an attempt count across
|
|
310
|
+
restarts: one initial attempt plus `[limits].maxRestartAttempts` retries
|
|
311
|
+
(default two). Legacy entries count as one attempt. At the cap, the issue stays
|
|
312
|
+
in the start state for human attention; polling does not claim it again.
|
|
313
|
+
Successful dispatches and review-round keys remain deduplicated. A `done`
|
|
314
|
+
report alone is not a failure verdict, regardless of commit count.
|
|
315
|
+
|
|
285
316
|
**Residual, by honesty:** same-machine reconciliation cannot detect
|
|
286
317
|
whole-machine death — the reconciler dies with the laptop. The stale
|
|
287
318
|
`executor.json` heartbeat covers that case, but only for a remote reader — a
|
|
@@ -339,7 +370,7 @@ Pickup's three loops and the daemon absorb that whole layer:
|
|
|
339
370
|
| Fleet-script job | Fate |
|
|
340
371
|
|---|---|
|
|
341
372
|
| Poll tracker, dispatch assigned issues | Dispatch loop, issue rule |
|
|
342
|
-
| Watch PRs for review feedback | Dispatch loop,
|
|
373
|
+
| Watch PRs for review feedback | Dispatch loop, feedback rule |
|
|
343
374
|
| Per-dispatch outcome checks and cron re-arms | Daemon supervision + `report` |
|
|
344
375
|
| Merge approved PRs | Merge loop |
|
|
345
376
|
| Detect in-progress issues nothing backs | Dead/wedged half → daemon; tracker-drift half → reconciliation loop |
|