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 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
- out.push("pr:ready");
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
- throw new Error(`unrecorded worktree already exists for ${id} \u2014 refusing to proceed`);
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
- const readySince = pr.standingSince?.["pr:ready"];
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 hashes = new Map(standing.map((s) => [s.key, s.stateHash]));
10486
+ const items = new Map(standing.map((s) => [s.key, s]));
10456
10487
  const pruned = [];
10457
10488
  for (const a of listAcks()) {
10458
- const h = hashes.get(a.key);
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 standingSince = pr.standingSince?.[kind] ?? pr.observedAt;
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, stop runs man haul (--park, --timeout),
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
- throw new Error(`unrecorded worktree already exists for ${id} \u2014 refusing to proceed`);
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);
@@ -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
 
@@ -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 when `pr:ready` began standing (its `standingSince`), so repeat observations of the same ready state keep the ack and ready → not ready → ready re-stands it once. |
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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lobstah",
3
- "version": "0.6.9",
3
+ "version": "0.6.10",
4
4
  "description": "Harness-agnostic, token-efficient supervision framework for coding agents",
5
5
  "license": "MIT",
6
6
  "author": "aequitas labs LLC",