lobstah 0.6.9 → 0.6.10
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 +63 -16
- package/dist/runner.js +30 -3
- package/docs/configuration.md +1 -0
- package/docs/vocabulary.md +2 -2
- package/package.json +1 -1
package/dist/main.js
CHANGED
|
@@ -1875,6 +1875,10 @@ function expandHome(p) {
|
|
|
1875
1875
|
function loadConfig() {
|
|
1876
1876
|
const file = configPath();
|
|
1877
1877
|
const raw = fs8.existsSync(file) ? parse(fs8.readFileSync(file, "utf8")) : {};
|
|
1878
|
+
const readySettleSecs = raw.readySettleSecs ?? 600;
|
|
1879
|
+
if (typeof readySettleSecs !== "number" || !Number.isFinite(readySettleSecs) || readySettleSecs < 0) {
|
|
1880
|
+
throw new Error(`readySettleSecs must be a non-negative number in ${configPath()}`);
|
|
1881
|
+
}
|
|
1878
1882
|
const reposRaw = raw.repos ?? {};
|
|
1879
1883
|
const repos = {};
|
|
1880
1884
|
for (const [key, r] of Object.entries(reposRaw)) {
|
|
@@ -1911,6 +1915,7 @@ function loadConfig() {
|
|
|
1911
1915
|
notifyCommand: raw.notifyCommand ? String(raw.notifyCommand) : void 0,
|
|
1912
1916
|
notifyVerbs: Array.isArray(raw.notifyVerbs) ? raw.notifyVerbs.map(String) : void 0,
|
|
1913
1917
|
remindSecs: raw.remindSecs !== void 0 ? Number(raw.remindSecs) : void 0,
|
|
1918
|
+
readySettleSecs,
|
|
1914
1919
|
attentionKinds: parseAttentionKinds(raw.attentionKinds)
|
|
1915
1920
|
};
|
|
1916
1921
|
}
|
|
@@ -5120,7 +5125,7 @@ function normalizeChecks(rollup) {
|
|
|
5120
5125
|
}
|
|
5121
5126
|
return [...latest.values()].map((v) => v.check).sort((a, b) => a.name.localeCompare(b.name));
|
|
5122
5127
|
}
|
|
5123
|
-
function prStandingKinds(pr) {
|
|
5128
|
+
function prStandingKinds(pr, settle2) {
|
|
5124
5129
|
if (pr.state !== "OPEN")
|
|
5125
5130
|
return [];
|
|
5126
5131
|
const out = [];
|
|
@@ -5134,8 +5139,11 @@ function prStandingKinds(pr) {
|
|
|
5134
5139
|
out.push("pr:checks");
|
|
5135
5140
|
if (isConflicting(pr.mergeStateStatus))
|
|
5136
5141
|
out.push("pr:conflict");
|
|
5137
|
-
if (!pr.checks.unknown && !pr.draft && !review && isMergeable(pr.mergeStateStatus) && failed === 0 && pending === 0 && (pr.reviewDecision === "APPROVED" || total > 0))
|
|
5138
|
-
|
|
5142
|
+
if (!pr.checks.unknown && !pr.draft && !review && isMergeable(pr.mergeStateStatus) && failed === 0 && pending === 0 && (pr.reviewDecision === "APPROVED" || total > 0)) {
|
|
5143
|
+
const since = Date.parse(pr.standingSince?.["pr:ready"] ?? pr.observedAt);
|
|
5144
|
+
if (!settle2 || settle2.readySettleSecs === 0 || settle2.now >= since + settle2.readySettleSecs * 1e3)
|
|
5145
|
+
out.push("pr:ready");
|
|
5146
|
+
}
|
|
5139
5147
|
return out;
|
|
5140
5148
|
}
|
|
5141
5149
|
function prReview(view) {
|
|
@@ -5491,7 +5499,7 @@ function upsertPr(pr, dispatchId) {
|
|
|
5491
5499
|
const merged = { ...before ?? {}, ...pr, failingChecks: pr.failingChecks };
|
|
5492
5500
|
const standingSince = {};
|
|
5493
5501
|
for (const kind of prStandingKinds(merged)) {
|
|
5494
|
-
standingSince[kind] = before?.standingSince?.[kind] ?? pr.observedAt;
|
|
5502
|
+
standingSince[kind] = kind === "pr:ready" && before?.headSha !== pr.headSha ? pr.observedAt : before?.standingSince?.[kind] ?? pr.observedAt;
|
|
5495
5503
|
}
|
|
5496
5504
|
const after = {
|
|
5497
5505
|
...merged,
|
|
@@ -6238,6 +6246,21 @@ async function allocate(repo, id, fromRemoteBranch = repo.trunk) {
|
|
|
6238
6246
|
await runSetup(repo, dir);
|
|
6239
6247
|
return dir;
|
|
6240
6248
|
}
|
|
6249
|
+
async function recoverWorktree(repo, id) {
|
|
6250
|
+
const dir = worktreePath(id);
|
|
6251
|
+
try {
|
|
6252
|
+
if (fs31.statSync(path27.join(dir, ".git")).isFile()) {
|
|
6253
|
+
const branch = await git2(dir, "symbolic-ref", "--quiet", "HEAD");
|
|
6254
|
+
const root = await git2(dir, "rev-parse", "--show-toplevel");
|
|
6255
|
+
const common = await git2(dir, "rev-parse", "--path-format=absolute", "--git-common-dir");
|
|
6256
|
+
const mine = await git2(repo.path, "rev-parse", "--path-format=absolute", "--git-common-dir");
|
|
6257
|
+
if (branch === `refs/heads/lobstah/${id}` && realpath(root) === realpath(dir) && realpath(common) === realpath(mine))
|
|
6258
|
+
return dir;
|
|
6259
|
+
}
|
|
6260
|
+
} catch {
|
|
6261
|
+
}
|
|
6262
|
+
throw new Error(`unrecorded worktree at ${dir} for ${id} \u2014 ownership not proven (expected lobstah/${id} in ${repo.path}); inspect and preserve it, then relocate the conflicting checkout before retrying`);
|
|
6263
|
+
}
|
|
6241
6264
|
async function runSetup(repo, dir) {
|
|
6242
6265
|
for (const cmd of repo.setup ?? []) {
|
|
6243
6266
|
await shell(cmd, { cwd: dir, env: { ...process.env, ...repo.env ?? {} } });
|
|
@@ -7776,17 +7799,19 @@ async function main(activeDir, lane, seams = {}) {
|
|
|
7776
7799
|
});
|
|
7777
7800
|
const wtFile = path64.join(activeDir, "worktree.json");
|
|
7778
7801
|
let recorded;
|
|
7802
|
+
let recovered = false;
|
|
7779
7803
|
let choice;
|
|
7780
7804
|
if (fs77.existsSync(wtFile)) {
|
|
7781
7805
|
recorded = JSON.parse(fs77.readFileSync(wtFile, "utf8")).path;
|
|
7782
7806
|
if (!fs77.existsSync(recorded))
|
|
7783
7807
|
throw new Error(`recorded worktree ${recorded} is gone`);
|
|
7784
7808
|
} else if (fs77.existsSync(worktreePath(id))) {
|
|
7785
|
-
|
|
7809
|
+
recorded = await recoverWorktree(repo, id);
|
|
7810
|
+
recovered = true;
|
|
7786
7811
|
} else if (descriptor.followUp && cfg.limits.reuseWorktree !== false) {
|
|
7787
7812
|
choice = await deps.chooseWorktree({ id, lane, repoKey: descriptor.repo, repo, followUp: descriptor.followUp });
|
|
7788
7813
|
}
|
|
7789
|
-
const worktreeNote = choice ? choice.reuse ? `reusing worktree of ${short3(choice.from)}` : `fresh worktree (${choice.reason})` : void 0;
|
|
7814
|
+
const worktreeNote = choice ? choice.reuse ? `reusing worktree of ${short3(choice.from)}` : `fresh worktree (${choice.reason})` : recovered ? "recovered own unrecorded worktree" : void 0;
|
|
7790
7815
|
const firstModel = modelForHarness(plan.harness, resolved.model);
|
|
7791
7816
|
const firstNote = [
|
|
7792
7817
|
wake ? `woke from pause: ${wake}` : attempts > 1 ? `attempt ${attempts}` : void 0,
|
|
@@ -7805,7 +7830,13 @@ async function main(activeDir, lane, seams = {}) {
|
|
|
7805
7830
|
let cwd;
|
|
7806
7831
|
if (recorded) {
|
|
7807
7832
|
cwd = recorded;
|
|
7808
|
-
acquireWorktreeLock(cwd, id, lane);
|
|
7833
|
+
const held = acquireWorktreeLock(cwd, id, lane);
|
|
7834
|
+
if (held)
|
|
7835
|
+
throw new Error(`worktree at ${cwd} is in use by ${held.id} \u2014 wait for that dispatch to release it before retrying`);
|
|
7836
|
+
if (recovered) {
|
|
7837
|
+
await deps.prepareReuse(repo, cwd);
|
|
7838
|
+
fs77.writeFileSync(wtFile, JSON.stringify({ path: cwd }, null, 2));
|
|
7839
|
+
}
|
|
7809
7840
|
} else if (choice?.reuse) {
|
|
7810
7841
|
cwd = choice.path;
|
|
7811
7842
|
const { setupRan } = await deps.prepareReuse(repo, cwd);
|
|
@@ -10433,8 +10464,7 @@ function removeAck(key) {
|
|
|
10433
10464
|
}
|
|
10434
10465
|
var sha2 = (v) => createHash7("sha1").update(JSON.stringify(v)).digest("hex").slice(0, 16);
|
|
10435
10466
|
function prStateHash(pr) {
|
|
10436
|
-
|
|
10437
|
-
if (readySince) return sha2({ "pr:ready": readySince });
|
|
10467
|
+
if (prStandingKinds(pr).includes("pr:ready")) return prReadyStateHash(pr.headSha);
|
|
10438
10468
|
return sha2({
|
|
10439
10469
|
headSha: pr.headSha,
|
|
10440
10470
|
state: pr.state,
|
|
@@ -10447,15 +10477,18 @@ function prStateHash(pr) {
|
|
|
10447
10477
|
repair: pr.repair ? { headSha: pr.repair.headSha, kind: pr.repair.kind, status: pr.repair.status, reason: pr.repair.reason } : void 0
|
|
10448
10478
|
});
|
|
10449
10479
|
}
|
|
10480
|
+
var prReadyStateHash = (headSha) => sha2({ "pr:ready": headSha });
|
|
10450
10481
|
function currentAck(key, stateHash) {
|
|
10451
10482
|
const a = readAck(key);
|
|
10452
10483
|
return a && a.stateHash === stateHash ? a : void 0;
|
|
10453
10484
|
}
|
|
10454
10485
|
function pruneStaleAcks(standing) {
|
|
10455
|
-
const
|
|
10486
|
+
const items = new Map(standing.map((s) => [s.key, s]));
|
|
10456
10487
|
const pruned = [];
|
|
10457
10488
|
for (const a of listAcks()) {
|
|
10458
|
-
const
|
|
10489
|
+
const item = items.get(a.key);
|
|
10490
|
+
if (a.kind === "pr:ready" && item?.headSha && a.stateHash === prReadyStateHash(item.headSha)) continue;
|
|
10491
|
+
const h = item?.stateHash;
|
|
10459
10492
|
if (h !== void 0 && h !== a.stateHash && removeAck(a.key)) pruned.push(a.key);
|
|
10460
10493
|
}
|
|
10461
10494
|
return pruned;
|
|
@@ -11632,7 +11665,7 @@ function prAttention(now2, observed = observedPrs(), cfg = loadConfig()) {
|
|
|
11632
11665
|
);
|
|
11633
11666
|
const out = [];
|
|
11634
11667
|
for (const { id, lane, pr, dispatch } of observed) {
|
|
11635
|
-
const kinds = prKinds(pr).filter(
|
|
11668
|
+
const kinds = prKinds(pr, { readySettleSecs: cfg.readySettleSecs, now: now2 }).filter(
|
|
11636
11669
|
(kind) => kind !== "pr:ready" || !readyBlockedByStack(
|
|
11637
11670
|
pr,
|
|
11638
11671
|
observed.map((x) => x.pr)
|
|
@@ -11648,7 +11681,8 @@ function prAttention(now2, observed = observedPrs(), cfg = loadConfig()) {
|
|
|
11648
11681
|
if (!human.show) continue;
|
|
11649
11682
|
const repairCannotAct = human.reason !== void 0 && !["auto-repair is off", "not owned by lobstah", "no active PR watch"].includes(human.reason);
|
|
11650
11683
|
if (!repairCannotAct && onTheHook(kind, chain, { reviewRounds, watchFollowUp })) continue;
|
|
11651
|
-
const
|
|
11684
|
+
const conditionSince = pr.standingSince?.[kind] ?? pr.observedAt;
|
|
11685
|
+
const standingSince = kind === "pr:ready" ? new Date(Date.parse(conditionSince) + cfg.readySettleSecs * 1e3).toISOString() : conditionSince;
|
|
11652
11686
|
out.push({
|
|
11653
11687
|
kind,
|
|
11654
11688
|
key: ref?.key ?? pr.url,
|
|
@@ -12379,6 +12413,7 @@ function renderDigest(d) {
|
|
|
12379
12413
|
}
|
|
12380
12414
|
|
|
12381
12415
|
// apps/cli/src/charter.ts
|
|
12416
|
+
var HELM_REMINDER = "Helm reminder: any question or choice for the human goes on a card with lobstah man ask, not only in chat.";
|
|
12382
12417
|
function charter(g) {
|
|
12383
12418
|
const repos = g.repos.length > 0 ? g.repos.join(", ") : "(no repos configured)";
|
|
12384
12419
|
return `the helm charter \u2014 grounds "${g.name}" (${repos})
|
|
@@ -12389,6 +12424,7 @@ Role:
|
|
|
12389
12424
|
- Triage incoming work. Dispatch it. Review each catch. Decide requeue or cancel.
|
|
12390
12425
|
- Do not do the work yourself. Write a self-contained brief and dispatch it.
|
|
12391
12426
|
- Answer a worker's question when you can decide. Send the answer. Ask the human with \`lobstah man ask\` when the choice is theirs.
|
|
12427
|
+
- ${HELM_REMINDER}
|
|
12392
12428
|
|
|
12393
12429
|
Fences:
|
|
12394
12430
|
- The daemon claims, spawns, and restarts workers. Do not supervise a running catch.
|
|
@@ -16666,7 +16702,7 @@ var COMMANDS = {
|
|
|
16666
16702
|
"man:init": { flags: { "--shared": {}, "--global": {}, "--marker": {} } },
|
|
16667
16703
|
"man:haul": { flags: { "--timeout": { value: "<secs>" }, "--park": {} } },
|
|
16668
16704
|
"man:brief": { flags: {} },
|
|
16669
|
-
hook: { flags: {}, subverbs: ["session-start", "stop", "post-tool-use", "session-end"], maxPositionals: 1 },
|
|
16705
|
+
hook: { flags: {}, subverbs: ["session-start", "user-prompt-submit", "stop", "post-tool-use", "session-end"], maxPositionals: 1 },
|
|
16670
16706
|
"man:file": {
|
|
16671
16707
|
flags: { "--attach": { value: "<file>", repeatable: true }, "--title": { value: "<text>" }, "--grounds": { value: "<name>" }, "--session": { value: "<id>" } },
|
|
16672
16708
|
positionals: "<file.md>"
|
|
@@ -16824,7 +16860,8 @@ Stores the answer; the helm's man wait receives it as decision-answer.`,
|
|
|
16824
16860
|
"man:brief": `SessionStart-hook entry point: announce the session id and fleet state into
|
|
16825
16861
|
the conversation.`,
|
|
16826
16862
|
hook: `The plugin hook entry points. Each detects the session's role (helm, trap, or
|
|
16827
|
-
neither): session-start runs man brief,
|
|
16863
|
+
neither): session-start runs man brief, user-prompt-submit adds a helm card reminder,
|
|
16864
|
+
stop runs man haul (--park, --timeout),
|
|
16828
16865
|
post-tool-use runs soak beat, session-end runs stow --quiet. The older commands
|
|
16829
16866
|
stay as aliases.`,
|
|
16830
16867
|
__runner: `Internal: run one dispatch inside the compiled binary (the daemon re-execs
|
|
@@ -17062,7 +17099,7 @@ lobstah man (orchestrator sessions \u2014 bare \`lobstah man\` prints the manual
|
|
|
17062
17099
|
~/.claude/settings.json with --global (any
|
|
17063
17100
|
directory with a .lobstah-man file then
|
|
17064
17101
|
parks); --marker touches .lobstah-man.
|
|
17065
|
-
hook session-start|stop|post-tool-use|session-end
|
|
17102
|
+
hook session-start|user-prompt-submit|stop|post-tool-use|session-end
|
|
17066
17103
|
the plugin hook entry points: each
|
|
17067
17104
|
detects the session's role (helm, trap,
|
|
17068
17105
|
or neither). session-start = man brief,
|
|
@@ -17410,6 +17447,16 @@ var HOOK_COMMANDS = {
|
|
|
17410
17447
|
};
|
|
17411
17448
|
async function mainCli() {
|
|
17412
17449
|
let [cmd, ...args] = process.argv.slice(2);
|
|
17450
|
+
if (cmd === "hook" && args[0] === "user-prompt-submit") {
|
|
17451
|
+
try {
|
|
17452
|
+
const hook = readHookStdin();
|
|
17453
|
+
if (hook?.session_id && !trapBySession(hook.session_id) && helmOf(hook.session_id)) {
|
|
17454
|
+
console.log(JSON.stringify({ hookSpecificOutput: { hookEventName: "UserPromptSubmit", additionalContext: HELM_REMINDER } }));
|
|
17455
|
+
}
|
|
17456
|
+
} catch {
|
|
17457
|
+
}
|
|
17458
|
+
return;
|
|
17459
|
+
}
|
|
17413
17460
|
if (cmd === "soak" && args[0] === "beat" || cmd === "hook" && args[0] === "post-tool-use") {
|
|
17414
17461
|
runBeat();
|
|
17415
17462
|
return;
|
package/dist/runner.js
CHANGED
|
@@ -962,6 +962,10 @@ function expandHome(p) {
|
|
|
962
962
|
function loadConfig() {
|
|
963
963
|
const file = configPath();
|
|
964
964
|
const raw = fs3.existsSync(file) ? parse(fs3.readFileSync(file, "utf8")) : {};
|
|
965
|
+
const readySettleSecs = raw.readySettleSecs ?? 600;
|
|
966
|
+
if (typeof readySettleSecs !== "number" || !Number.isFinite(readySettleSecs) || readySettleSecs < 0) {
|
|
967
|
+
throw new Error(`readySettleSecs must be a non-negative number in ${configPath()}`);
|
|
968
|
+
}
|
|
965
969
|
const reposRaw = raw.repos ?? {};
|
|
966
970
|
const repos = {};
|
|
967
971
|
for (const [key, r] of Object.entries(reposRaw)) {
|
|
@@ -998,6 +1002,7 @@ function loadConfig() {
|
|
|
998
1002
|
notifyCommand: raw.notifyCommand ? String(raw.notifyCommand) : void 0,
|
|
999
1003
|
notifyVerbs: Array.isArray(raw.notifyVerbs) ? raw.notifyVerbs.map(String) : void 0,
|
|
1000
1004
|
remindSecs: raw.remindSecs !== void 0 ? Number(raw.remindSecs) : void 0,
|
|
1005
|
+
readySettleSecs,
|
|
1001
1006
|
attentionKinds: parseAttentionKinds(raw.attentionKinds)
|
|
1002
1007
|
};
|
|
1003
1008
|
}
|
|
@@ -2788,6 +2793,21 @@ async function allocate(repo, id, fromRemoteBranch = repo.trunk) {
|
|
|
2788
2793
|
await runSetup(repo, dir);
|
|
2789
2794
|
return dir;
|
|
2790
2795
|
}
|
|
2796
|
+
async function recoverWorktree(repo, id) {
|
|
2797
|
+
const dir = worktreePath(id);
|
|
2798
|
+
try {
|
|
2799
|
+
if (fs18.statSync(path15.join(dir, ".git")).isFile()) {
|
|
2800
|
+
const branch = await git(dir, "symbolic-ref", "--quiet", "HEAD");
|
|
2801
|
+
const root = await git(dir, "rev-parse", "--show-toplevel");
|
|
2802
|
+
const common = await git(dir, "rev-parse", "--path-format=absolute", "--git-common-dir");
|
|
2803
|
+
const mine = await git(repo.path, "rev-parse", "--path-format=absolute", "--git-common-dir");
|
|
2804
|
+
if (branch === `refs/heads/lobstah/${id}` && realpath(root) === realpath(dir) && realpath(common) === realpath(mine))
|
|
2805
|
+
return dir;
|
|
2806
|
+
}
|
|
2807
|
+
} catch {
|
|
2808
|
+
}
|
|
2809
|
+
throw new Error(`unrecorded worktree at ${dir} for ${id} \u2014 ownership not proven (expected lobstah/${id} in ${repo.path}); inspect and preserve it, then relocate the conflicting checkout before retrying`);
|
|
2810
|
+
}
|
|
2791
2811
|
async function runSetup(repo, dir) {
|
|
2792
2812
|
for (const cmd of repo.setup ?? []) {
|
|
2793
2813
|
await shell(cmd, { cwd: dir, env: { ...process.env, ...repo.env ?? {} } });
|
|
@@ -3545,16 +3565,18 @@ async function main(activeDir, lane, seams = {}) {
|
|
|
3545
3565
|
});
|
|
3546
3566
|
const wtFile = path17.join(activeDir, "worktree.json");
|
|
3547
3567
|
let recorded;
|
|
3568
|
+
let recovered = false;
|
|
3548
3569
|
let choice;
|
|
3549
3570
|
if (fs20.existsSync(wtFile)) {
|
|
3550
3571
|
recorded = JSON.parse(fs20.readFileSync(wtFile, "utf8")).path;
|
|
3551
3572
|
if (!fs20.existsSync(recorded)) throw new Error(`recorded worktree ${recorded} is gone`);
|
|
3552
3573
|
} else if (fs20.existsSync(worktreePath(id))) {
|
|
3553
|
-
|
|
3574
|
+
recorded = await recoverWorktree(repo, id);
|
|
3575
|
+
recovered = true;
|
|
3554
3576
|
} else if (descriptor.followUp && cfg.limits.reuseWorktree !== false) {
|
|
3555
3577
|
choice = await deps.chooseWorktree({ id, lane, repoKey: descriptor.repo, repo, followUp: descriptor.followUp });
|
|
3556
3578
|
}
|
|
3557
|
-
const worktreeNote = choice ? choice.reuse ? `reusing worktree of ${short3(choice.from)}` : `fresh worktree (${choice.reason})` : void 0;
|
|
3579
|
+
const worktreeNote = choice ? choice.reuse ? `reusing worktree of ${short3(choice.from)}` : `fresh worktree (${choice.reason})` : recovered ? "recovered own unrecorded worktree" : void 0;
|
|
3558
3580
|
const firstModel = modelForHarness(plan.harness, resolved.model);
|
|
3559
3581
|
const firstNote = [
|
|
3560
3582
|
wake ? `woke from pause: ${wake}` : attempts > 1 ? `attempt ${attempts}` : void 0,
|
|
@@ -3573,7 +3595,12 @@ async function main(activeDir, lane, seams = {}) {
|
|
|
3573
3595
|
let cwd;
|
|
3574
3596
|
if (recorded) {
|
|
3575
3597
|
cwd = recorded;
|
|
3576
|
-
acquireWorktreeLock(cwd, id, lane);
|
|
3598
|
+
const held = acquireWorktreeLock(cwd, id, lane);
|
|
3599
|
+
if (held) throw new Error(`worktree at ${cwd} is in use by ${held.id} \u2014 wait for that dispatch to release it before retrying`);
|
|
3600
|
+
if (recovered) {
|
|
3601
|
+
await deps.prepareReuse(repo, cwd);
|
|
3602
|
+
fs20.writeFileSync(wtFile, JSON.stringify({ path: cwd }, null, 2));
|
|
3603
|
+
}
|
|
3577
3604
|
} else if (choice?.reuse) {
|
|
3578
3605
|
cwd = choice.path;
|
|
3579
3606
|
const { setupRan } = await deps.prepareReuse(repo, cwd);
|
package/docs/configuration.md
CHANGED
|
@@ -16,6 +16,7 @@ keys of that section.
|
|
|
16
16
|
| `notifyVerbs` | `["needs-decision", "blocked", "done", "failed"]` | Which verbs fire `notifyCommand`. |
|
|
17
17
|
| `attentionKinds` | `["question", "decision", "pr:ready", "pr:review", "pr:conflict", "pr:checks"]` | Which kinds `man tend`, the glass, and the desktop pet show. `decision` is a question the helm put to the human with `lobstah man ask`; the glass shows it as a card to answer, and it hides the raw `question` it frames. Without `decision`, framed decisions do not show and raw questions do. `pr:draft`, `landed`, and `report` are valid opt-in kinds. An explicit list is used unchanged. Unknown kinds are errors. See the [attention contract](vocabulary.md#attention-contract). |
|
|
18
18
|
| `remindSecs` | `900` | An unanswered `needs-decision`/`blocked` re-fires to `man wait`/`man haul` on this interval until answered. `0` = report once only. |
|
|
19
|
+
| `readySettleSecs` | `600` | `pr:ready` stands after continuously meeting the ready conditions on the same head for this many seconds. Any not-ready observation or new head resets the timer. Each attention read re-evaluates expiry from the persisted start, even without a new forge event. `0` restores immediate ready attention; other `pr:*` kinds are unchanged. |
|
|
19
20
|
|
|
20
21
|
## `[repos.<key>]` — workspace definitions
|
|
21
22
|
|
package/docs/vocabulary.md
CHANGED
|
@@ -534,7 +534,7 @@ never until someone acknowledges it.
|
|
|
534
534
|
| `pr:review` | An open PR has unresolved review questions, or requested changes that lobstah cannot repair, has exhausted, or is configured not to repair. | Every thread resolved and no changes requested, or merged / closed. |
|
|
535
535
|
| `pr:checks` | An open PR has a failed latest check, and lobstah cannot repair it, has exhausted attempts, or is configured not to repair. | Green on the head, or merged / closed. |
|
|
536
536
|
| `pr:conflict` | An open PR conflicts with its base, and lobstah cannot repair it, has exhausted attempts, or is configured not to repair. | The merge state leaves `DIRTY`, or merged / closed. |
|
|
537
|
-
| `pr:ready` | An open, non-draft PR has no review condition, a mergeable state (`CLEAN`, `HAS_HOOKS`, or `UNSTABLE`), and no failed, pending, or unknown latest checks. It is approved or has at least one check. | Merged or closed, or the ready conditions stop holding. |
|
|
537
|
+
| `pr:ready` | An open, non-draft PR has no review condition, a mergeable state (`CLEAN`, `HAS_HOOKS`, or `UNSTABLE`), and no failed, pending, or unknown latest checks. It is approved or has at least one check, continuously on the same head for `readySettleSecs` (default 600; `0` is immediate). Attention reads re-evaluate expiry without requiring a new forge event. | Merged or closed, or the ready conditions stop holding; any not-ready observation or new head resets settling. |
|
|
538
538
|
| `report` | A filed report (`report --report`, `man file`) has no ack for this filing. Opt-in. Key `report:<lane>:<uuid>` or `report:helm:<grounds>:<rid>`. | Not cleared: it stays listed until culled. `lobstah attention ack <key>` acks it, and a newer report in the same chain acks the older; an acked report no longer walks. |
|
|
539
539
|
|
|
540
540
|
`pr:*` kinds read only the `pr:` watch's evidence — never a forge call —
|
|
@@ -563,7 +563,7 @@ reminder loop apply the same predicate.
|
|
|
563
563
|
|
|
564
564
|
| Word | Meaning |
|
|
565
565
|
| ---- | ------- |
|
|
566
|
-
| ack | `~/.lobstah/acks/<item-key>.json` — `{ key, kind, stateHash, at, by }`, written only by `lobstah attention ack` (removed by `unack`, by the CLI's `man tend` / `attention` when its `stateHash` goes stale, and by `cull` when the item is gone). **Display-only**: it hides the item from the desktop pet and the glass lobs while the item's `stateHash` is unchanged; `man tend --json` keeps the item with `acked: { at, by }`, and `man wait`, the park, reminders, and `notifyCommand` never read acks. Item keys: `<lane>:<uuid>` (question, landed), `decision:<rid>`, `pr:<owner>/<repo>#<n>` (all of a PR's `pr:*` kinds — one ack covers them), `watch:<key>`. `stateHash` covers the status entry (question, landed) or the PR's head sha plus every evidence field a `pr:*` kind stands on (not `observedAt`); for a ready PR, only
|
|
566
|
+
| ack | `~/.lobstah/acks/<item-key>.json` — `{ key, kind, stateHash, at, by }`, written only by `lobstah attention ack` (removed by `unack`, by the CLI's `man tend` / `attention` when its `stateHash` goes stale, and by `cull` when the item is gone). **Display-only**: it hides the item from the desktop pet and the glass lobs while the item's `stateHash` is unchanged; `man tend --json` keeps the item with `acked: { at, by }`, and `man wait`, the park, reminders, and `notifyCommand` never read acks. Item keys: `<lane>:<uuid>` (question, landed), `decision:<rid>`, `pr:<owner>/<repo>#<n>` (all of a PR's `pr:*` kinds — one ack covers them), `watch:<key>`. `stateHash` covers the status entry (question, landed) or the PR's head sha plus every evidence field a `pr:*` kind stands on (not `observedAt`); for a ready PR, only the head sha. A ready ack is kept across same-head CI flaps and draft toggles, without hiding checks, conflicts, or review attention during the flap. A new head re-stands ready once after settling. |
|
|
567
567
|
| pet state | `~/.lobstah/pet/state.json` — `{ pid, at, ok, command, reason, consecutiveFailures, items, lastOkAt }`. The desktop pet's one write: it rewrites the file after each attention read (about every six seconds). `command` is the command that worked (`attention --json`, or `man tend --json` from an older CLI). `reason` says why the last read failed (timed out, non-zero exit, output that does not decode). Only `lobstah doctor` reads it, for its `pet` row: running means `pid` is alive and `at` is less than two minutes old. |
|
|
568
568
|
| budget stop | A headless runner's `failed` verb with a `budget:` note means its progress-extended active-work window reached the hard ceiling. This is out of time, not a code failure: the runner checkpoints eligible changes, pushes when enabled, names the saved branch/commit/draft PR, and invites `lobstah send <id> "continue"`. Paused `--waiting-on` time is excluded. |
|
|
569
569
|
|