omp-conductor 0.18.0 → 0.18.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.
Files changed (65) hide show
  1. package/README.md +35 -1
  2. package/REFERENCE.md +61 -11
  3. package/agents/to-spec.md +94 -0
  4. package/package.json +2 -1
  5. package/schema/config.schema.json +35 -1
  6. package/src/admission.ts +204 -75
  7. package/src/arm-challenge.ts +250 -57
  8. package/src/ask.ts +268 -7
  9. package/src/board.ts +17 -3
  10. package/src/briefs/orchestrator.md +62 -21
  11. package/src/briefs/to-spec.md +88 -0
  12. package/src/briefs/worker.md +2 -1
  13. package/src/cli.ts +124 -1
  14. package/src/command-help.ts +11 -0
  15. package/src/command-manifest.ts +38 -5
  16. package/src/commands/arm.ts +1 -1
  17. package/src/commands/context.ts +1 -0
  18. package/src/commands/drain.ts +176 -0
  19. package/src/commands/extend.ts +6 -10
  20. package/src/commands/intake.ts +4 -19
  21. package/src/commands/status.ts +5 -1
  22. package/src/commands/watch.ts +51 -16
  23. package/src/commands/worker.ts +9 -10
  24. package/src/config-schema.ts +43 -6
  25. package/src/config.ts +65 -9
  26. package/src/daemon.ts +879 -41
  27. package/src/dashboard/app.js +4 -1
  28. package/src/dashboard/server.ts +5 -2
  29. package/src/decisions.ts +243 -17
  30. package/src/diff-flags.ts +75 -1
  31. package/src/doctor.ts +60 -82
  32. package/src/escalate.ts +31 -14
  33. package/src/failure-class.ts +28 -2
  34. package/src/fleet.ts +239 -240
  35. package/src/gitops.ts +188 -81
  36. package/src/graph-health.ts +35 -1
  37. package/src/graph.ts +66 -1
  38. package/src/harness-loader.ts +59 -0
  39. package/src/host.ts +242 -2
  40. package/src/lifecycle.ts +122 -1
  41. package/src/omp-settings.ts +19 -0
  42. package/src/omp.ts +183 -21
  43. package/src/orchestrator-tick.ts +1591 -32
  44. package/src/orchestrator.ts +12 -0
  45. package/src/privileged.ts +1 -4
  46. package/src/release-policy.ts +503 -9
  47. package/src/session-host.ts +65 -6
  48. package/src/settlement.ts +69 -17
  49. package/src/setup-host.ts +1225 -9
  50. package/src/setup-install.ts +28 -0
  51. package/src/setup-wizard.ts +154 -3
  52. package/src/setup.ts +83 -17
  53. package/src/shell.ts +15 -0
  54. package/src/status-render.ts +216 -12
  55. package/src/store.ts +443 -42
  56. package/src/to-spec.ts +408 -0
  57. package/src/tracker/github.ts +104 -14
  58. package/src/types.ts +405 -19
  59. package/src/upgrade-verify.ts +209 -2
  60. package/src/upgrade.ts +175 -1
  61. package/src/verbs/protocol.ts +39 -0
  62. package/src/verbs/server.ts +765 -56
  63. package/src/verbs/socket.ts +24 -5
  64. package/src/worker.ts +12 -2
  65. package/src/worktree.ts +29 -12
package/src/admission.ts CHANGED
@@ -18,9 +18,13 @@ import { join } from "node:path";
18
18
  import { log, errText, safeEscalate } from "./log.ts";
19
19
  import type {
20
20
  Caps,
21
+ EffectiveModel,
21
22
  Escalation,
23
+ FileLane,
22
24
  IssueComment,
23
25
  IssueSnapshot,
26
+ LaneDeclaration,
27
+ ModelDeclaration,
24
28
  OpenCloser,
25
29
  ProjectConfig,
26
30
  RunRecord,
@@ -94,11 +98,16 @@ export function hasFailedAttemptBudget(failures: number, maxAttempts: number): b
94
98
  * (#608): the exact snapshot the overlap gate enforced. Dispatch renders this
95
99
  * value into the worker brief, so a changed or failed second comment read can
96
100
  * neither hide nor reword the lane admission held — the gate and the
97
- * worker-visible brief are one value, not two reads of the same thread. */
101
+ * worker-visible brief are one value, not two reads of the same thread.
102
+ * `model` is the effective `Model:` declaration resolved from the same read
103
+ * (#535): not a gate, but the same one-value contract — dispatch launches on
104
+ * this selector and the brief echoes it, so a second read can neither hide
105
+ * the model admission held nor drift from it. */
98
106
  export interface Admission {
99
107
  r: Routed;
100
108
  attempt: number;
101
109
  lane?: FileLane;
110
+ model?: EffectiveModel;
102
111
  }
103
112
 
104
113
  export interface AdmissionHold {
@@ -192,20 +201,42 @@ function planUsageEscalation(project: string, plan: PlanUsageStatus): Escalation
192
201
  * files an issue declares it will touch (#555).
193
202
  *
194
203
  * The orchestrator already writes exactly this list into every promotion brief
195
- * in prose; this parses that same sentence out of the issue body — or out of a
196
- * pre-dispatch comment, which is the same sentence on a supported brief
204
+ * in prose; this parses that same declaration out of the issue body — or out of
205
+ * a pre-dispatch comment, which is the same declaration on a supported brief
197
206
  * surface since #517 — so the interlock is load-bearing rather than advisory.
198
- * A line beginning with "file lane" (case-insensitive, optional
199
- * bold/heading markers) is accepted, and paths are read as backtick-delimited
200
- * spans (the brief form) with a bare comma/space-separated fallback that keeps
201
- * tokens that look like relative paths. A line whose tokens are not pathlike
202
- * (like `File lane: none`) is not a declaration. Anything else — including an
207
+ * Two spellings are accepted, and both work on every surface:
208
+ *
209
+ * - The inline line: a line beginning with "file lane" (case-insensitive,
210
+ * optional bold/heading markers) whose rest carries the paths as
211
+ * backtick-delimited spans (the brief form), with a bare
212
+ * comma/space-separated fallback that keeps tokens that look like relative
213
+ * paths.
214
+ * - The write-lane section (#825): a markdown heading "Exact write lane" (or
215
+ * "Write lane" / "write-lane") whose immediately following bullet items
216
+ * carry the paths — the package-floor decomposition format, where a groomed
217
+ * child names the files it writes as one backticked path per bullet. Only
218
+ * the contiguous bullet run under that heading is read: a following
219
+ * paragraph (like a "Read only:" caveat) or a later section ends it, so
220
+ * read-only entry points, proof commands and acceptance bullets elsewhere in
221
+ * the issue are never captured.
222
+ *
223
+ * Inline wins when both are present — the machine-shaped sentence has held
224
+ * since #555, and the mediated label echo shows exactly which declaration
225
+ * admission enforces. A line or section whose tokens are not pathlike (like
226
+ * `File lane: none`) is not a declaration. Anything else — including an
203
227
  * absent declaration, which is the default — is an empty lane: the issue is
204
228
  * admitted exactly as today (`fail open`), and the gate never refuses work for
205
- * wanting a lane. This is the one grammar for all surfaces; there is no second
206
- * comment-only spelling.
229
+ * wanting a lane. A clearly delimited write-lane section that parsed nothing
230
+ * is NOT an absent declaration: the promotion verb refuses it with an
231
+ * actionable syntax error ({@link writeLaneSectionHeading}) rather than
232
+ * echoing a fail-open the section's own heading contradicts.
207
233
  */
208
234
  export function laneDeclaration(text: string): LaneDeclaration | undefined {
235
+ return inlineLaneDeclaration(text) ?? sectionLaneDeclaration(text);
236
+ }
237
+
238
+ /** The inline `File lane:`/`File-lane=` sentence grammar (see {@link laneDeclaration}). */
239
+ function inlineLaneDeclaration(text: string): LaneDeclaration | undefined {
209
240
  const match = text.match(
210
241
  /^\s*(?:[#>*-]\s*)*file[- ]lane\s*[:=]\s*([^\n]*)$/im,
211
242
  );
@@ -222,34 +253,58 @@ export function laneDeclaration(text: string): LaneDeclaration | undefined {
222
253
  return { files, source: match[0].trim() };
223
254
  }
224
255
 
225
- /** One parsed `File lane:` declaration: the paths and the verbatim source
226
- * line, so a renderer can reproduce the declaration itself rather than a
227
- * summary of it. Exported for the brief's guarantee that the gate's effective
228
- * lane is always visible to the worker (#608). */
229
- export interface LaneDeclaration {
230
- files: string[];
231
- /** The declaration line verbatim, as written on the surface it came from. */
232
- source: string;
233
- }
234
-
235
256
  /**
236
- * The effective file lane as both admission and the worker brief must read it
237
- * (#608): the latest `File lane:` declaration among the issue body and every
238
- * comment, in the tracker's oldest-first order. This is the "later correction
239
- * visibly supersedes" contract applied across both surfaces at once, and it is
240
- * the single source of truth the gate enforces and the brief renders — so a
241
- * declaration can never control admission while staying invisible to the
242
- * worker. `at` records which surface won (`"body"`, or the 0-based comment
243
- * index), letting the brief reproduce the declaration verbatim when the
244
- * winning comment sits beyond its rendered discussion budget.
257
+ * A markdown heading that opens a clearly delimited write-lane section
258
+ * (#825): `## Exact write lane`, `## Write lane`, or a hyphenated spelling,
259
+ * optional trailing colon. Returns the heading line verbatim, or undefined.
260
+ * The promotion verb uses this to refuse a section that parsed to nothing —
261
+ * the echo must never claim fail-open for a heading that plainly tried to
262
+ * declare one.
245
263
  */
246
- export interface FileLane extends LaneDeclaration {
247
- at: "body" | number;
264
+ export function writeLaneSectionHeading(text: string): string | undefined {
265
+ const match = text.match(
266
+ /^[ \t]*#{1,6}[ \t]+(?:exact[ \t]+)?write[-\s]lane[ \t]*[:.]?[ \t]*$/im,
267
+ );
268
+ return match === null ? undefined : match[0].trim();
269
+ }
270
+
271
+ /** The write-lane section grammar (see {@link laneDeclaration}): the heading
272
+ * plus the contiguous bullet run beneath it. A non-bullet line ends the run,
273
+ * so a caveat paragraph or the next section bounds the declaration. */
274
+ function sectionLaneDeclaration(text: string): LaneDeclaration | undefined {
275
+ const lines = text.split("\n");
276
+ let start = -1;
277
+ for (let i = 0; i < lines.length; i++) {
278
+ if (writeLaneSectionHeading(lines[i]!) !== undefined) {
279
+ start = i;
280
+ break;
281
+ }
282
+ }
283
+ if (start < 0) return undefined;
284
+ const files: string[] = [];
285
+ const bullets: string[] = [];
286
+ for (let i = start + 1; i < lines.length; i++) {
287
+ const line = lines[i]!;
288
+ if (line.trim() === "") continue;
289
+ // A markdown bullet marker (`- `, `* `, `+ `, `1. `), with an optional
290
+ // task checkbox. Anything else ends the section's bullet run.
291
+ const marker = line.match(/^[ \t]*(?:[-*+]|\d+[.)])[ \t]+(?:\[[ xX]\][ \t]+)?/);
292
+ if (marker === null) break;
293
+ const rest = line.slice(marker[0].length);
294
+ const backticked = [...rest.matchAll(/`([^`]+)`/g)]
295
+ .map((m) => m[1]!.trim())
296
+ .filter(isPathLike);
297
+ files.push(...(backticked.length > 0 ? backticked : rest.split(/[,\s]+/).map((s) => s.trim()).filter(isPathLike)));
298
+ bullets.push(line.trim());
299
+ }
300
+ if (files.length === 0) return undefined;
301
+ return { files: [...new Set(files)], source: [lines[start]!.trim(), ...bullets].join("\n") };
248
302
  }
249
303
 
250
304
  /** Who holds one file's lane this admission pass, and which read proved it.
251
- * `"declared"` is the same-pass half: an admitted candidate's own lane
252
- * occupies for the rest of the pass without any probe read. */
305
+ * `"declared"` covers both occupancy halves: an admitted candidate's own
306
+ * lane for the rest of its pass, and since #744 — a live run's persisted
307
+ * declaration on every later pass, without any probe read. */
253
308
  export type LaneHolder = { issue: number; source: LaneSource | "declared" };
254
309
 
255
310
  /** Resolves the effective lane across the body and the whole comment thread. */
@@ -270,6 +325,41 @@ export function declaredLane(body: string): string[] {
270
325
  return laneDeclaration(body)?.files ?? [];
271
326
  }
272
327
 
328
+ /**
329
+ * The model half of a promotion brief (#535): the selector an issue declares
330
+ * it will dispatch on, read from the body or a pre-dispatch comment — the
331
+ * same two surfaces `File lane:` reads. Conductor names a selector and omp
332
+ * resolves what it means, so the declaration is one token in omp's own model
333
+ * syntax — a role alias (`@slow`) or a concrete `provider/model`, either
334
+ * optionally `:thinking`-suffixed — passed through verbatim as the session's
335
+ * `modelPattern`. A line whose rest is not exactly one selector (blank,
336
+ * `none`, multi-word prose) is not a declaration: dispatch fails open to the
337
+ * project's `workerModel`, exactly as a lane-less issue is admitted today.
338
+ * This is the one grammar for all surfaces; there is no second comment-only
339
+ * spelling.
340
+ */
341
+ export function modelDeclaration(text: string): ModelDeclaration | undefined {
342
+ const match = text.match(
343
+ /^\s*(?:[#>*-]\s*)*model\s*[:=]\s*([^\n]*)$/im,
344
+ );
345
+ if (match === null) return undefined;
346
+ const rest = (match[1] ?? "").trim().replace(/^[*`\s]+|[*`\s]+$/g, "").trim();
347
+ if (rest === "" || /^none$/i.test(rest) || /\s/.test(rest)) return undefined;
348
+ return { model: rest, source: match[0].trim() };
349
+ }
350
+
351
+ /** Resolves the effective model across the body and the whole comment thread. */
352
+ export function effectiveModel(body: string, comments: IssueComment[]): EffectiveModel | undefined {
353
+ let current: EffectiveModel | undefined;
354
+ const bodyDecl = modelDeclaration(body);
355
+ if (bodyDecl !== undefined) current = { ...bodyDecl, at: "body" };
356
+ for (let i = 0; i < comments.length; i++) {
357
+ const decl = modelDeclaration(comments[i]!.body);
358
+ if (decl !== undefined) current = { ...decl, at: i };
359
+ }
360
+ return current;
361
+ }
362
+
273
363
  /**
274
364
  * The file lane as one line, for surfaces where a human asserts the claim
275
365
  * (#724): the parsed file list exactly as admission will enforce it, or the
@@ -348,43 +438,61 @@ export async function admitCandidates(
348
438
  };
349
439
 
350
440
  // The file-lane interlock (#555): repo -> file -> the issue holding that file.
351
- // Seeded from the *actual* worktrees/branches of active runs never from
352
- // their issue bodies, which is precisely how #288 collidedand extended as
353
- // this pass admits candidates with declared lanes, so two overlapping
354
- // candidates cannot both clear the gate in one tick. Keyed by repo because a
355
- // path only collides within its own checkout: a `daemon.ts` on the api repo
356
- // and one on the web repo are different files. Built lazily and once, only
357
- // when the first candidate that declares a lane reaches the gate, so a queue
358
- // of laneless issues pays nothing for it.
441
+ // Seeded from what a live run actually *occupies*, never from its issue body:
442
+ // the probed worktrees/branches of active runs (#288) unionedsince #744
443
+ // with the lane declaration admission persisted on the row at dispatch, which
444
+ // is the exact enforced snapshot, not a re-parse. The declared half is what
445
+ // survives across passes: a run admitted with a lane it has not yet written
446
+ // still occupies until it reaches a terminal state and leaves the active set,
447
+ // and the declared lanes are extended as this pass admits candidates, so two
448
+ // overlapping candidates cannot both clear the gate in one tick. Keyed by
449
+ // repo because a path only collides within its own checkout: a `daemon.ts` on
450
+ // the api repo and one on the web repo are different files. Built lazily and
451
+ // once, only when the first candidate that declares a lane reaches the gate,
452
+ // so a queue of laneless issues pays nothing for it.
359
453
  let laneOccupancy: Map<string, Map<string, LaneHolder>> | undefined;
360
454
  const ensureLaneOccupancy = async (): Promise<Map<string, Map<string, LaneHolder>>> => {
361
455
  if (laneOccupancy !== undefined) return laneOccupancy;
362
456
  const occupied = new Map<string, Map<string, LaneHolder>>();
363
- for (const run of activeRuns) {
364
- if (d.probeWorktreeLane === undefined) break;
365
- const base = project.routing.repos[run.repo]?.defaultBranch ?? "main";
366
- let files: LaneFile[];
367
- try {
368
- files = await d.probeWorktreeLane({
369
- worktree: run.worktree,
370
- baseRef: `refs/remotes/origin/${base}`,
371
- ...(run.branch === "" ? {} : { branchRef: `refs/heads/${run.branch}` }),
372
- mirror: join(project.mirrorRoot, `${run.repo}.git`),
373
- });
374
- } catch {
375
- // Fail open, like an unreadable lane: the gate never refuses a
376
- // well-formed issue because one probe could not be answered.
377
- files = [];
457
+ // Shared per-file first-read-wins: a file a run both declares and has
458
+ // actually touched is tagged by the concrete probe read, and across runs
459
+ // whichever live run comes first in the active set is the holder named —
460
+ // both are legitimate for the gate, which holds on any overlap.
461
+ const occupy = (run: RunRecord, file: string, source: LaneSource | "declared"): void => {
462
+ let perRepo = occupied.get(run.repo);
463
+ if (perRepo === undefined) {
464
+ perRepo = new Map();
465
+ occupied.set(run.repo, perRepo);
378
466
  }
379
- for (const { file, source } of files) {
380
- let perRepo = occupied.get(run.repo);
381
- if (perRepo === undefined) {
382
- perRepo = new Map();
383
- occupied.set(run.repo, perRepo);
467
+ if (!perRepo.has(file)) perRepo.set(file, { issue: run.issue, source });
468
+ };
469
+ for (const run of activeRuns) {
470
+ // The probed actual half first, so the concrete read outnames the
471
+ // declaration for a file the run has already written.
472
+ if (d.probeWorktreeLane !== undefined) {
473
+ const base = project.routing.repos[run.repo]?.defaultBranch ?? "main";
474
+ let files: LaneFile[];
475
+ try {
476
+ files = await d.probeWorktreeLane({
477
+ worktree: run.worktree,
478
+ baseRef: `refs/remotes/origin/${base}`,
479
+ ...(run.branch === "" ? {} : { branchRef: `refs/heads/${run.branch}` }),
480
+ mirror: join(project.mirrorRoot, `${run.repo}.git`),
481
+ });
482
+ } catch {
483
+ // Fail open, like an unreadable lane: the gate never refuses a
484
+ // well-formed issue because one probe could not be answered.
485
+ files = [];
384
486
  }
385
- // First read wins: a file the run both commits and edits in its
386
- // worktree is tagged by its live uncommitted half.
387
- if (!perRepo.has(file)) perRepo.set(file, { issue: run.issue, source });
487
+ for (const { file, source } of files) occupy(run, file, source);
488
+ }
489
+ // The declared durable half (#744): the lane admission persisted at
490
+ // dispatch. Occupies even before the run has written anything, on every
491
+ // pass — the declaration lives on the row, not in a per-pass memo.
492
+ // Released by the active-set filter exactly like probed occupancy: a
493
+ // terminal run leaves `activeRuns` and its files stop occupying here.
494
+ if (run.lane !== undefined && run.lane.files.length > 0) {
495
+ for (const file of run.lane.files) occupy(run, file, "declared");
388
496
  }
389
497
  }
390
498
  laneOccupancy = occupied;
@@ -408,31 +516,45 @@ export async function admitCandidates(
408
516
  // the worker therefore agree on one lane, which is the #608 defect's shape.
409
517
  //
410
518
  // Comments are read at most once per issue per pass — the lane feeds both
411
- // the gate and the same-pass sibling occupancy below, so the cache is what
412
- // stops one candidate costing two comment reads. Unreadable comments fail
413
- // open to the body declaration (a body lane stays load-bearing), and with no
414
- // readable declaration at all the #555 fail-open admission is unchanged.
519
+ // the gate and the same-pass sibling occupancy below, and the model rides
520
+ // the same read for its one-value contract with dispatch (#535), so the
521
+ // cache is what stops one candidate costing two comment reads. Unreadable
522
+ // comments fail open to the body declarations (a body lane stays
523
+ // load-bearing, and a body model stays the one dispatch follows), and with
524
+ // no readable declaration at all the #555 fail-open admission is unchanged.
415
525
  //
416
526
  // The resolved `FileLane` — not just its paths — is what an admitted
417
527
  // candidate carries into dispatch (#608): the gate and the brief must agree
418
528
  // on the *same declaration* (paths, verbatim source line, and which surface
419
529
  // it came from), so dispatch renders this cached value rather than reading
420
- // the thread a second time and hoping it did not change.
530
+ // the thread a second time and hoping it did not change. The resolved
531
+ // `EffectiveModel` is carried the same way and for the same reason: dispatch
532
+ // launches on exactly this selector and the brief echoes it.
421
533
  const laneCache = new Map<number, FileLane | undefined>();
422
- const laneFor = async (r: Routed): Promise<FileLane | undefined> => {
534
+ const modelCache = new Map<number, EffectiveModel | undefined>();
535
+ const declarationsFor = async (r: Routed): Promise<void> => {
423
536
  const issue = r.issue.number;
424
- if (laneCache.has(issue)) return laneCache.get(issue);
537
+ if (laneCache.has(issue)) return;
425
538
  let lane: FileLane | undefined;
539
+ let model: EffectiveModel | undefined;
426
540
  try {
427
- lane = effectiveLane(r.issue.body, await tracker.listComments(issue));
541
+ const comments = await tracker.listComments(issue);
542
+ lane = effectiveLane(r.issue.body, comments);
543
+ model = effectiveModel(r.issue.body, comments);
428
544
  } catch (err) {
429
545
  const bodyDecl = laneDeclaration(r.issue.body);
430
546
  lane = bodyDecl === undefined ? undefined : { ...bodyDecl, at: "body" };
431
- log(`#${issue} comments unreadable at admission; the body's file lane stands: ${errText(err)}`);
547
+ const bodyModel = modelDeclaration(r.issue.body);
548
+ model = bodyModel === undefined ? undefined : { ...bodyModel, at: "body" };
549
+ log(`#${issue} comments unreadable at admission; the body's declarations stand: ${errText(err)}`);
432
550
  }
433
551
  laneCache.set(issue, lane);
434
- return lane;
552
+ modelCache.set(issue, model);
435
553
  };
554
+ const laneFor = (r: Routed): Promise<FileLane | undefined> =>
555
+ declarationsFor(r).then(() => laneCache.get(r.issue.number));
556
+ const modelFor = (r: Routed): Promise<EffectiveModel | undefined> =>
557
+ declarationsFor(r).then(() => modelCache.get(r.issue.number));
436
558
 
437
559
  // The plan allowance is a fleet-wide question, so it is asked once per pass
438
560
  // and answers for every candidate — unlike every gate below it, which is
@@ -450,6 +572,9 @@ export async function admitCandidates(
450
572
  log(`plan usage gate holding ${String(routed.length)} candidate(s): ${plan.detail}`);
451
573
  const escalation = planUsageEscalation(project.name, plan);
452
574
  if (escalation !== undefined) await safeEscalate(d, escalation);
575
+ // Every pass exit reconciles the durable blocked inventory against this
576
+ // pass's holds, so a hold that stopped applying clears itself (#735).
577
+ store.reconcileGrooming(project.name, holds);
453
578
  return { admitted: [], holds };
454
579
  }
455
580
 
@@ -1035,7 +1160,7 @@ export async function admitCandidates(
1035
1160
  if (blocked) continue;
1036
1161
  }
1037
1162
 
1038
- admitted.push({ r, attempt: priorRuns + 1, lane });
1163
+ admitted.push({ r, attempt: priorRuns + 1, lane, model: await modelFor(r) });
1039
1164
  liveByRepo.set(r.repo.name, (liveByRepo.get(r.repo.name) ?? 0) + 1);
1040
1165
  // Same-pass sibling occupancy for the file-lane gate: once admitted, a
1041
1166
  // candidate's declared lane occupies for the rest of the pass, so a later
@@ -1064,5 +1189,9 @@ export async function admitCandidates(
1064
1189
  }
1065
1190
  }
1066
1191
 
1192
+ // Every pass exit reconciles the durable blocked inventory against this
1193
+ // pass's holds, so a lane/dependency hold that stopped applying clears
1194
+ // itself on the very next pass (#735).
1195
+ store.reconcileGrooming(project.name, holds);
1067
1196
  return { admitted, holds };
1068
1197
  }