@monoes/monomindcli 2.11.8 → 2.11.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.
Files changed (121) hide show
  1. package/README.md +1 -1
  2. package/bin/cli.js +17 -8
  3. package/dist/src/commands/crash-reporting.d.ts +3 -1
  4. package/dist/src/commands/crash-reporting.d.ts.map +1 -1
  5. package/dist/src/commands/crash-reporting.js +26 -10
  6. package/dist/src/commands/crash-reporting.js.map +1 -1
  7. package/dist/src/commands/doctor-env-checks.d.ts +9 -0
  8. package/dist/src/commands/doctor-env-checks.d.ts.map +1 -1
  9. package/dist/src/commands/doctor-env-checks.js +31 -0
  10. package/dist/src/commands/doctor-env-checks.js.map +1 -1
  11. package/dist/src/commands/doctor-monoes-checks.d.ts +12 -0
  12. package/dist/src/commands/doctor-monoes-checks.d.ts.map +1 -1
  13. package/dist/src/commands/doctor-monoes-checks.js +142 -1
  14. package/dist/src/commands/doctor-monoes-checks.js.map +1 -1
  15. package/dist/src/commands/doctor-project-checks.d.ts.map +1 -1
  16. package/dist/src/commands/doctor-project-checks.js +8 -3
  17. package/dist/src/commands/doctor-project-checks.js.map +1 -1
  18. package/dist/src/commands/doctor.d.ts.map +1 -1
  19. package/dist/src/commands/doctor.js +11 -3
  20. package/dist/src/commands/doctor.js.map +1 -1
  21. package/dist/src/commands/mcp.d.ts.map +1 -1
  22. package/dist/src/commands/mcp.js +16 -0
  23. package/dist/src/commands/mcp.js.map +1 -1
  24. package/dist/src/commands/org-observe.d.ts.map +1 -1
  25. package/dist/src/commands/org-observe.js +29 -4
  26. package/dist/src/commands/org-observe.js.map +1 -1
  27. package/dist/src/commands/org.d.ts.map +1 -1
  28. package/dist/src/commands/org.js +12 -3
  29. package/dist/src/commands/org.js.map +1 -1
  30. package/dist/src/init/executor.d.ts.map +1 -1
  31. package/dist/src/init/executor.js +17 -0
  32. package/dist/src/init/executor.js.map +1 -1
  33. package/dist/src/init/mcp-generator.d.ts +2 -7
  34. package/dist/src/init/mcp-generator.d.ts.map +1 -1
  35. package/dist/src/init/mcp-generator.js +15 -17
  36. package/dist/src/init/mcp-generator.js.map +1 -1
  37. package/dist/src/init/write-claude.d.ts.map +1 -1
  38. package/dist/src/init/write-claude.js +9 -0
  39. package/dist/src/init/write-claude.js.map +1 -1
  40. package/dist/src/init/write-runtime-config.d.ts +8 -0
  41. package/dist/src/init/write-runtime-config.d.ts.map +1 -1
  42. package/dist/src/init/write-runtime-config.js +69 -14
  43. package/dist/src/init/write-runtime-config.js.map +1 -1
  44. package/dist/src/mcp/monoes-mcp-entry.mjs +152 -0
  45. package/dist/src/mcp/monoes-proxy.d.ts +48 -0
  46. package/dist/src/mcp/monoes-proxy.d.ts.map +1 -0
  47. package/dist/src/mcp/monoes-proxy.js +240 -0
  48. package/dist/src/mcp/monoes-proxy.js.map +1 -0
  49. package/dist/src/memory/embedding-operations.d.ts.map +1 -1
  50. package/dist/src/memory/embedding-operations.js +5 -0
  51. package/dist/src/memory/embedding-operations.js.map +1 -1
  52. package/dist/src/orgrt/completion-gate.d.ts +58 -0
  53. package/dist/src/orgrt/completion-gate.d.ts.map +1 -0
  54. package/dist/src/orgrt/completion-gate.js +86 -0
  55. package/dist/src/orgrt/completion-gate.js.map +1 -0
  56. package/dist/src/orgrt/daemon.d.ts +15 -0
  57. package/dist/src/orgrt/daemon.d.ts.map +1 -1
  58. package/dist/src/orgrt/daemon.js +194 -12
  59. package/dist/src/orgrt/daemon.js.map +1 -1
  60. package/dist/src/orgrt/file-roots.d.ts +57 -0
  61. package/dist/src/orgrt/file-roots.d.ts.map +1 -0
  62. package/dist/src/orgrt/file-roots.js +100 -0
  63. package/dist/src/orgrt/file-roots.js.map +1 -0
  64. package/dist/src/orgrt/policy-git.d.ts.map +1 -1
  65. package/dist/src/orgrt/policy-git.js +122 -4
  66. package/dist/src/orgrt/policy-git.js.map +1 -1
  67. package/dist/src/orgrt/policy.d.ts +13 -1
  68. package/dist/src/orgrt/policy.d.ts.map +1 -1
  69. package/dist/src/orgrt/policy.js +60 -11
  70. package/dist/src/orgrt/policy.js.map +1 -1
  71. package/dist/src/orgrt/reporting.d.ts +56 -0
  72. package/dist/src/orgrt/reporting.d.ts.map +1 -1
  73. package/dist/src/orgrt/reporting.js +42 -3
  74. package/dist/src/orgrt/reporting.js.map +1 -1
  75. package/dist/src/orgrt/role-sandbox.d.ts.map +1 -1
  76. package/dist/src/orgrt/role-sandbox.js +19 -49
  77. package/dist/src/orgrt/role-sandbox.js.map +1 -1
  78. package/dist/src/orgrt/scheduler-integration.d.ts.map +1 -1
  79. package/dist/src/orgrt/scheduler-integration.js +4 -2
  80. package/dist/src/orgrt/scheduler-integration.js.map +1 -1
  81. package/dist/src/orgrt/session.d.ts +5 -2
  82. package/dist/src/orgrt/session.d.ts.map +1 -1
  83. package/dist/src/orgrt/session.js +51 -7
  84. package/dist/src/orgrt/session.js.map +1 -1
  85. package/dist/src/orgrt/task-dag.d.ts +11 -0
  86. package/dist/src/orgrt/task-dag.d.ts.map +1 -1
  87. package/dist/src/orgrt/task-dag.js +19 -0
  88. package/dist/src/orgrt/task-dag.js.map +1 -1
  89. package/dist/src/orgrt/types.d.ts +6 -0
  90. package/dist/src/orgrt/types.d.ts.map +1 -1
  91. package/dist/src/orgrt/types.js +10 -0
  92. package/dist/src/orgrt/types.js.map +1 -1
  93. package/dist/src/prompt.d.ts.map +1 -1
  94. package/dist/src/prompt.js +14 -0
  95. package/dist/src/prompt.js.map +1 -1
  96. package/dist/src/services/crash-consent.d.ts +35 -0
  97. package/dist/src/services/crash-consent.d.ts.map +1 -0
  98. package/dist/src/services/crash-consent.js +100 -0
  99. package/dist/src/services/crash-consent.js.map +1 -0
  100. package/dist/src/services/crash-lock.d.ts +22 -0
  101. package/dist/src/services/crash-lock.d.ts.map +1 -0
  102. package/dist/src/services/crash-lock.js +86 -0
  103. package/dist/src/services/crash-lock.js.map +1 -0
  104. package/dist/src/services/crash-race-timeout.d.ts +13 -0
  105. package/dist/src/services/crash-race-timeout.d.ts.map +1 -0
  106. package/dist/src/services/crash-race-timeout.js +15 -0
  107. package/dist/src/services/crash-race-timeout.js.map +1 -0
  108. package/dist/src/services/crash-reporter.d.ts +12 -4
  109. package/dist/src/services/crash-reporter.d.ts.map +1 -1
  110. package/dist/src/services/crash-reporter.js +105 -129
  111. package/dist/src/services/crash-reporter.js.map +1 -1
  112. package/dist/src/ui/dashboard.html +122 -0
  113. package/dist/src/ui/mastermind-diagram-fallback.html +14 -0
  114. package/dist/src/ui/orgs.html +12 -0
  115. package/dist/src/ui/routes-monoes.mjs +105 -21
  116. package/dist/src/utils/redaction.d.ts +17 -0
  117. package/dist/src/utils/redaction.d.ts.map +1 -1
  118. package/dist/src/utils/redaction.js +52 -7
  119. package/dist/src/utils/redaction.js.map +1 -1
  120. package/dist/tsconfig.tsbuildinfo +1 -1
  121. package/package.json +6 -6
@@ -14,12 +14,14 @@ import { OrgBus } from './bus.js';
14
14
  import { captureCheckpoint, generateChecksum, isCheckpointExpired, migrateCheckpoint, restoreMailboxQueue, validateCheckpoint, } from './checkpoint.js';
15
15
  import * as checkpointOps from './checkpoint-ops.js';
16
16
  import { CodexAgentRunner } from './codex-runner.js';
17
+ import { checkCompletion } from './completion-gate.js';
17
18
  import { CopilotAgentRunner } from './copilot-runner.js';
18
19
  import * as crossOrg from './cross-org.js';
19
20
  import { CrushAgentRunner } from './crush-runner.js';
20
21
  import * as decisionOps from './decisions.js';
21
22
  import { agentRoles, hasActiveEndpointWait, isEndpointRole, retryQueuedEndpoints, startEndpointRetryLoop, stopEndpointRetries, } from './endpoint-roles.js';
22
23
  import { createFenceForRole, loadGlobalFenceConfig, mergeFenceConfigs, } from './fence.js';
24
+ import { fileToolRoots } from './file-roots.js';
23
25
  import { attachForwarder } from './forwarder.js';
24
26
  import { GrokAgentRunner } from './grok-runner.js';
25
27
  import { HermesAgentRunner } from './hermes-runner.js';
@@ -205,6 +207,45 @@ export function activeRoleCount(org) {
205
207
  n++;
206
208
  return n;
207
209
  }
210
+ /** #302: the `org_complete` consent gate's emit-and-return logic, extracted
211
+ * (matching `resolvedIdleNudgeCount`'s and `runOutcomeResult`'s precedent)
212
+ * so it is unit-testable without a live daemon, a running org, or the SDK's
213
+ * own tool-calling loop — none of which a test can drive directly, since
214
+ * `queryFn` replaces the whole SDK `query()`, tool orchestration included.
215
+ * `onComplete` gathers the facts (needs `this`/`running`) and calls this;
216
+ * this does the one decision (`checkCompletion`) and its two possible
217
+ * side effects. Returns the refusal string, or `null` to allow — exactly
218
+ * what the org_complete tool handler (session.ts) relays as the result. */
219
+ export function resolveOrgComplete(bus, role, outcome, summary, blocker, blockerDetail, runFacts) {
220
+ const refusal = checkCompletion({ outcome, blocker, blockerDetail, ...runFacts });
221
+ if (refusal) {
222
+ // Visible in `org logs` — a silent refusal reads to an operator as a
223
+ // hung boss, not a boss that was told no.
224
+ bus.emit({
225
+ type: 'audit',
226
+ from: role,
227
+ reason: 'org-complete-refused',
228
+ msg: refusal,
229
+ data: { outcome, blocker, blockerDetail },
230
+ });
231
+ return refusal;
232
+ }
233
+ // #302 AC6: the blocker must show up in RENDERED output, not just `data`
234
+ // — `org logs`'s formatter prints an event's `msg` verbatim and never
235
+ // looks at `data`, so a blocker recorded only there would satisfy a unit
236
+ // assertion and never reach a human reading the actual log.
237
+ const blockerSuffix = blocker
238
+ ? ` (blocker: ${blocker}${blockerDetail ? ` — ${blockerDetail}` : ''})`
239
+ : '';
240
+ bus.emit({
241
+ type: 'status',
242
+ from: role,
243
+ reason: 'org-complete',
244
+ msg: `run outcome: ${outcome}${blockerSuffix}`,
245
+ data: { outcome, summary, blocker, blockerDetail },
246
+ });
247
+ return null;
248
+ }
208
249
  export class OrgDaemon {
209
250
  root;
210
251
  opts;
@@ -448,7 +489,9 @@ export class OrgDaemon {
448
489
  // Only this call can have registered the name (the reservation above
449
490
  // holds until `finally`), so anything in the map is ours to tear down.
450
491
  if (this.orgs.has(name)) {
451
- await this.stopOrg(name).catch((stopErr) => console.error(`org ${name}: teardown after failed start failed:`, stopErr instanceof Error ? stopErr.message : stopErr));
492
+ // #302: tag the real cause so a run's history/report can never read
493
+ // this as a boss-attributed outcome — nothing here asked the boss.
494
+ await this.stopOrg(name, { closedBy: 'failed-start' }).catch((stopErr) => console.error(`org ${name}: teardown after failed start failed:`, stopErr instanceof Error ? stopErr.message : stopErr));
452
495
  }
453
496
  throw err;
454
497
  }
@@ -460,6 +503,27 @@ export class OrgDaemon {
460
503
  * reservation guard above runs synchronously, before any `await` in here —
461
504
  * see the bug 2 comment in startOrg(). */
462
505
  async startOrgInner(name, taskOverride, options) {
506
+ // #301: the PRIMARY fix — the stop-side backstops below (finishStop,
507
+ // process 'exit') can only run for a run that ends through code we
508
+ // control; a SIGKILL leaves .git/worktrees/<name> metadata behind with
509
+ // nothing left to clean it up, and so did every run that leaked before
510
+ // this fix existed. Pruning here turns "did the last run clean up,
511
+ // however it died?" into "a run always begins clean" — the only thing
512
+ // that recovers both the SIGKILL case and worktrees already orphaned
513
+ // before this daemon process started. Unconditional and best-effort for
514
+ // the same reasons as the stop-side prune (see finishStop): it only
515
+ // drops metadata whose worktree directory is already gone, so it cannot
516
+ // touch a live worktree, including the owner's.
517
+ try {
518
+ execFileSync('git', ['worktree', 'prune'], {
519
+ cwd: this.root,
520
+ stdio: 'ignore',
521
+ timeout: 30_000,
522
+ });
523
+ }
524
+ catch {
525
+ /* best-effort: not a git repo, git missing, or a wedged hook */
526
+ }
463
527
  const defPath = join(this.root, ORG_DIR, `${name}.json`);
464
528
  const def = OrgDefSchema.parse(JSON.parse(readFileSync(defPath, 'utf8')));
465
529
  let run;
@@ -885,6 +949,26 @@ export class OrgDaemon {
885
949
  catch {
886
950
  /* best-effort */
887
951
  }
952
+ // #301: `process.on('exit')` is the SECOND termination path — it fires
953
+ // for a normal stop too (finishStop's own prune above already covers
954
+ // that case, so this is a harmless idempotent repeat there) but also
955
+ // for every path that reaches it via an explicit `process.exit()`
956
+ // (org.ts's SIGTERM/SIGINT/SIGHUP handlers, uncaughtException,
957
+ // unhandledRejection) — none of which run finishStop's cleanup at all.
958
+ // execFileSync is already a static top-level import (see the comment
959
+ // above), so this stays synchronous-safe like the rest of this
960
+ // handler. SIGKILL cannot reach here — no in-process code runs for
961
+ // it — which is why prune-at-start exists as the complement.
962
+ try {
963
+ execFileSync('git', ['worktree', 'prune'], {
964
+ cwd: this.root,
965
+ stdio: 'ignore',
966
+ timeout: 30_000,
967
+ });
968
+ }
969
+ catch {
970
+ /* best-effort: not a git repo, git missing, or a wedged hook */
971
+ }
888
972
  };
889
973
  process.on('exit', crashCleanup);
890
974
  running._crashCleanup = crashCleanup;
@@ -967,7 +1051,11 @@ export class OrgDaemon {
967
1051
  const idleStop = (msg) => {
968
1052
  stopping = true;
969
1053
  bus.emit({ type: 'audit', reason: 'idle-stop', msg });
970
- this.stopOrg(name).catch((err) => console.error(`org ${name}: idle-stop failed:`, err instanceof Error ? err.message : err));
1054
+ // #302: closedBy: 'idle-stop' — the truth gate at finishStop's
1055
+ // history write reads this to record what actually happened
1056
+ // (including any runnable work left in org_tasks) instead of
1057
+ // letting a null outcome default to a plain "completed".
1058
+ this.stopOrg(name, { closedBy: 'idle-stop' }).catch((err) => console.error(`org ${name}: idle-stop failed:`, err instanceof Error ? err.message : err));
971
1059
  };
972
1060
  // #296: publish the projected stop time for `org status --json`. Only
973
1061
  // written when it changes; a failed write must not throw out of the
@@ -1258,7 +1346,12 @@ export class OrgDaemon {
1258
1346
  maxTokens: role.budget_tokens ?? perRoleBudget,
1259
1347
  maxUsd: role.budget_usd,
1260
1348
  ...(role.policy ?? {}),
1261
- }, bus, roleCwd);
1349
+ }, bus, roleCwd,
1350
+ // #303: the file tools (Read/Write/Edit/Glob/Grep) get the same extra
1351
+ // roots the Bash sandbox already treats as writable (role-sandbox.ts) —
1352
+ // $TMPDIR, the org root, and any policy.sandbox.allowWrite entries.
1353
+ // $HOME is deliberately excluded; see file-roots.ts.
1354
+ fileToolRoots({ cwd: roleCwd, orgRoot: this.root }, role.policy?.sandbox));
1262
1355
  policy.setToolContext({
1263
1356
  providerPrefixes: () => roleProviderPrefixes(role),
1264
1357
  trace: () => this.roleTrace(name, role.id),
@@ -1350,14 +1443,35 @@ export class OrgDaemon {
1350
1443
  // true by actually denying tool use while this role has a pending gate,
1351
1444
  // the same way pending approvals already do.
1352
1445
  hasPendingGate: () => this.listGates(name, 'pending').some((g) => g.roleId === role.id),
1446
+ // #302: refuse an unsatisfiable org_complete BEFORE the 'org-complete'
1447
+ // status event ever exists — the bus subscriber at the top of this
1448
+ // function auto-stops the run on that event alone, so a refusal that
1449
+ // still emitted it would be undone by the very next tick regardless of
1450
+ // what this function returns to the tool handler.
1353
1451
  onComplete: role.id === running.bossRoleId
1354
- ? (r, outcome, summary) => {
1355
- bus.emit({
1356
- type: 'status',
1357
- from: r,
1358
- reason: 'org-complete',
1359
- msg: `run outcome: ${outcome}`,
1360
- data: { outcome, summary },
1452
+ ? (r, outcome, summary, blocker, blockerDetail) => {
1453
+ let maxBudgetFraction = 0;
1454
+ for (const rt of running.agents.values()) {
1455
+ const p = rt.policy;
1456
+ if (p.policy.maxTokens)
1457
+ maxBudgetFraction = Math.max(maxBudgetFraction, p.usage / p.policy.maxTokens);
1458
+ if (p.policy.maxUsd)
1459
+ maxBudgetFraction = Math.max(maxBudgetFraction, p.usageUsd / p.policy.maxUsd);
1460
+ }
1461
+ // Same predicates the idle watchdog uses for its own
1462
+ // legitimate-wait check (:1316-1328) — a pending gate or an
1463
+ // unanswered question is the same "genuinely waiting on a
1464
+ // human" fact either way.
1465
+ const pendingHumanWaits = this.listGates(name, 'pending').length +
1466
+ questionOps
1467
+ .readQuestions(this.root, name)
1468
+ .questions.filter((q) => q.answer === null).length;
1469
+ return resolveOrgComplete(bus, r, outcome, summary, blocker, blockerDetail, {
1470
+ mode: def.run_config.completion ?? 'boss',
1471
+ maxBudgetFraction,
1472
+ pendingHumanWaits,
1473
+ hasActiveBlock: running.taskDag?.hasActiveBlock(Date.now()) ?? false,
1474
+ hasPendingWork: running.taskDag?.hasPendingWork() ?? false,
1361
1475
  });
1362
1476
  }
1363
1477
  : undefined,
@@ -1543,11 +1657,19 @@ export class OrgDaemon {
1543
1657
  }
1544
1658
  if (abortedByStop) {
1545
1659
  runtime.status = 'ended';
1660
+ // #304: `message` here is always an abort string (see abortedByStop
1661
+ // above) — either "Operation aborted" or the SDK's "Claude Code
1662
+ // process aborted by user". Echoing it made a planned stop read as
1663
+ // a human interruption, and made roles of the same run read
1664
+ // differently. Report WHY the org stopped instead; the raw string
1665
+ // stays in `data` for debugging.
1666
+ const why = running.closedBy === 'org-complete' ? 'org_complete' : 'stop requested';
1546
1667
  bus.emit({
1547
1668
  type: 'status',
1548
1669
  from: role.id,
1549
- msg: `agent "${role.id}" stopped with the org (${message})`,
1670
+ msg: `agent "${role.id}" stopped with the org (${why})`,
1550
1671
  reason: 'agent-stopped',
1672
+ data: { agentId: role.id, error: message },
1551
1673
  });
1552
1674
  return;
1553
1675
  }
@@ -2000,6 +2122,10 @@ export class OrgDaemon {
2000
2122
  return; // already stopped
2001
2123
  org.pendingRoles?.clear(); // prevent lazy spawns after stop
2002
2124
  this.spawning.delete(name); // clean up spawning tracking for this org
2125
+ // #304: set before the delete below, since that delete is what makes
2126
+ // abortedByStop (role loop) true — the role loop reads it off this same
2127
+ // object reference, not a fresh lookup (the org is gone from the map by then).
2128
+ org.closedBy = opts?.closedBy;
2003
2129
  // Remove immediately (not at the end) so a concurrent stopOrg(name) call —
2004
2130
  // e.g. stopAll() racing a scheduler-triggered stop on SIGINT — joins this
2005
2131
  // shutdown via `stopping` instead of re-running the whole sequence and
@@ -2127,7 +2253,26 @@ export class OrgDaemon {
2127
2253
  /* best-effort */
2128
2254
  }
2129
2255
  }
2130
- org.bus.emit({ type: 'status', msg: 'org stopped' });
2256
+ // #302 truth gate: every stop path funnels through here, so this is the
2257
+ // one place that can record how the run ACTUALLY ended, regardless of
2258
+ // which of the five paths triggered it. `closedBy` is undefined only for
2259
+ // a bare manual `stopOrg(name)` (CLI `org stop`, shutdown) — every
2260
+ // automated path above now tags its own real cause. reporting.ts reads
2261
+ // this event (reason: 'org-stopped') to decide whether the run's outcome
2262
+ // may be rendered as a boss-attributed 'partial'/'achieved' at all: only
2263
+ // closedBy === 'org-complete' may be.
2264
+ const runnableTasks = org.taskDag?.pendingTaskCount() ?? 0;
2265
+ // Rendered, not just recorded (#302 AC6, same reasoning as the
2266
+ // blockerSuffix above): `org logs` prints `msg` verbatim.
2267
+ const stopSuffix = closedBy && closedBy !== 'org-complete'
2268
+ ? ` (${closedBy}${runnableTasks > 0 ? `, ${runnableTasks} task(s) left` : ''})`
2269
+ : '';
2270
+ org.bus.emit({
2271
+ type: 'status',
2272
+ reason: 'org-stopped',
2273
+ msg: `org stopped${stopSuffix}`,
2274
+ data: { closedBy, runnableTasks },
2275
+ });
2131
2276
  await org.bus.flush();
2132
2277
  // Append this run's summary to <org>/history.jsonl — read back from the
2133
2278
  // flushed bus.jsonl (the full durable record) rather than the bounded
@@ -2230,6 +2375,43 @@ export class OrgDaemon {
2230
2375
  }
2231
2376
  }
2232
2377
  }
2378
+ // #301: roles create their own linked worktrees with Bash (paths the
2379
+ // daemon never recorded — org.worktreePath/agent.worktreePath above are
2380
+ // only ever set for workspace: 'worktree'/'worktree-per-role', empty
2381
+ // for the common workspace: 'repo' shape), and deleting the working
2382
+ // directory from inside a role sandbox leaves .git/worktrees/<name>
2383
+ // behind — `git worktree list` then hides it, and it never gets
2384
+ // cleaned up. Unconditional on purpose: gating this on
2385
+ // org/agent.worktreePath would skip exactly the runs that hit the bug.
2386
+ // prune only drops metadata whose worktree directory is already gone,
2387
+ // so a live worktree — including the owner's — is never touched; it is
2388
+ // idempotent; and the two removals just above already run `git
2389
+ // worktree remove --force` against this same repo from this same cwd,
2390
+ // so this is strictly less invasive than what already ships. Run after
2391
+ // both removal loops so a worktree just removed is also pruned.
2392
+ //
2393
+ // Bounded race, measured rather than assumed (same treatment as the
2394
+ // SIGKILL case above): an entry whose `gitdir` file is absent is
2395
+ // pruned unconditionally, and `--expire` cannot protect it — measured
2396
+ // across every window from `--expire=now` to `--expire=3.months.ago`,
2397
+ // a fresh no-gitdir entry is removed regardless, while `--expire` also
2398
+ // makes an already-deleted worktree SURVIVE, breaking the "a run
2399
+ // always begins clean" guarantee this fix exists to provide. So a
2400
+ // concurrent `git worktree add` by another process in this repo is
2401
+ // vulnerable for the microseconds between its `mkdir` and its
2402
+ // `gitdir` write. A mid-creation state cannot persist longer than
2403
+ // that, so an entry found in that state is dead metadata, not a live
2404
+ // worktree in progress.
2405
+ try {
2406
+ execFileSync('git', ['worktree', 'prune'], {
2407
+ cwd: this.root,
2408
+ stdio: 'ignore',
2409
+ timeout: 30_000,
2410
+ });
2411
+ }
2412
+ catch {
2413
+ /* best-effort: not a git repo, git missing, or a wedged hook */
2414
+ }
2233
2415
  }
2234
2416
  catch {
2235
2417
  /* node:child_process unavailable — skip */