mandrel 2.3.0 → 2.5.0

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 (76) hide show
  1. package/.agents/audit-checklists/accessibility.md +29 -0
  2. package/.agents/audit-checklists/architecture.md +4 -5
  3. package/.agents/audit-checklists/clean-code.md +10 -0
  4. package/.agents/audit-checklists/data-model.md +22 -0
  5. package/.agents/audit-checklists/dependencies.md +11 -2
  6. package/.agents/audit-checklists/devops.md +4 -0
  7. package/.agents/audit-checklists/navigability.md +3 -0
  8. package/.agents/audit-checklists/performance.md +8 -11
  9. package/.agents/audit-checklists/privacy.md +3 -4
  10. package/.agents/audit-checklists/quality.md +2 -0
  11. package/.agents/audit-checklists/security.md +4 -5
  12. package/.agents/audit-checklists/seo.md +7 -1
  13. package/.agents/audit-checklists/sre.md +14 -12
  14. package/.agents/audit-checklists/ux-ui.md +4 -0
  15. package/.agents/docs/configuration.md +3 -0
  16. package/.agents/docs/workflows.md +4 -3
  17. package/.agents/schemas/agentrc.schema.json +17 -0
  18. package/.agents/schemas/audit-rules.json +134 -19
  19. package/.agents/schemas/audit-rules.schema.json +6 -2
  20. package/.agents/scripts/audit-labels-bootstrap.js +4 -4
  21. package/.agents/scripts/audit-to-stories.js +244 -19
  22. package/.agents/scripts/lib/audit-suite/checklist-threading.js +26 -3
  23. package/.agents/scripts/lib/audit-suite/dispatch-checklist.js +132 -0
  24. package/.agents/scripts/lib/audit-suite/index.js +1 -0
  25. package/.agents/scripts/lib/audit-suite/selector.js +290 -14
  26. package/.agents/scripts/lib/audit-to-stories/audit-lenses.js +2 -1
  27. package/.agents/scripts/lib/audit-to-stories/build-story-body.js +5 -1
  28. package/.agents/scripts/lib/audit-to-stories/dedupe-against-github.js +23 -3
  29. package/.agents/scripts/lib/audit-to-stories/finding-adapter.js +38 -0
  30. package/.agents/scripts/lib/audit-to-stories/ledger.js +256 -0
  31. package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +41 -7
  32. package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +20 -2
  33. package/.agents/scripts/lib/command-header.js +1 -1
  34. package/.agents/scripts/lib/config-settings-schema-delivery.js +21 -0
  35. package/.agents/scripts/lib/dynamic-workflow/performance-report-contract.js +5 -3
  36. package/.agents/scripts/lib/feedback-loop/audit-results-graduator.js +56 -0
  37. package/.agents/scripts/lib/findings/route-finding.js +108 -10
  38. package/.agents/scripts/lib/observability/runtime-friction.js +62 -0
  39. package/.agents/scripts/lib/orchestration/resolve-stories.js +9 -1
  40. package/.agents/scripts/lib/orchestration/retro-proposals.js +49 -1
  41. package/.agents/scripts/lib/orchestration/single-story-close/phases/post-land.js +73 -15
  42. package/.agents/scripts/lib/orchestration/story-close/phases/local-lens-review.js +81 -1
  43. package/.agents/scripts/lib/orchestration/story-close/phases/review-core.js +1 -0
  44. package/.agents/scripts/lib/orchestration/ticket-lease.js +78 -10
  45. package/.agents/scripts/lib/orchestration/ticketing/transition.js +68 -16
  46. package/.agents/scripts/lib/single-story-sweep/sweep-lock.js +73 -0
  47. package/.agents/scripts/lib/wave-runner/live-probe.js +93 -12
  48. package/.agents/scripts/nav-registry-diff.js +449 -0
  49. package/.agents/scripts/single-story-init.js +189 -51
  50. package/.agents/scripts/stories-wave-tick.js +39 -2
  51. package/.agents/workflows/audit-accessibility.md +243 -0
  52. package/.agents/workflows/audit-architecture.md +89 -71
  53. package/.agents/workflows/audit-clean-code.md +87 -53
  54. package/.agents/workflows/audit-data-model.md +198 -0
  55. package/.agents/workflows/audit-dependencies.md +143 -28
  56. package/.agents/workflows/audit-devops.md +109 -18
  57. package/.agents/workflows/audit-documentation.md +25 -53
  58. package/.agents/workflows/audit-navigability.md +78 -22
  59. package/.agents/workflows/audit-performance.md +207 -103
  60. package/.agents/workflows/audit-privacy.md +51 -13
  61. package/.agents/workflows/audit-quality.md +71 -61
  62. package/.agents/workflows/audit-security.md +94 -71
  63. package/.agents/workflows/audit-seo.md +80 -25
  64. package/.agents/workflows/audit-sre.md +99 -66
  65. package/.agents/workflows/audit-to-stories.md +44 -5
  66. package/.agents/workflows/audit-ux-ui.md +71 -17
  67. package/.agents/workflows/deliver.md +32 -8
  68. package/.agents/workflows/helpers/audit-dual-path.md +59 -0
  69. package/.agents/workflows/helpers/audit-self-check.md +70 -0
  70. package/.agents/workflows/helpers/audit-severity-scale.md +19 -0
  71. package/.agents/workflows/helpers/deliver-story.md +25 -0
  72. package/docs/CHANGELOG.md +29 -0
  73. package/package.json +1 -1
  74. package/.agents/audit-checklists/lighthouse.md +0 -15
  75. package/.agents/schemas/audit-results.schema.json +0 -69
  76. package/.agents/workflows/audit-lighthouse.md +0 -269
@@ -211,6 +211,16 @@ export async function describeLease(opts) {
211
211
  * `reason: 'reclaimed'`.
212
212
  * - Foreign claim + `steal:true` → reassign operator, `acquired: true`,
213
213
  * `reason: 'stolen'`.
214
+ * - Lost a write race → a foreign login co-assigned between our
215
+ * PATCH and the verify re-read; back the
216
+ * operator out, `acquired: false`,
217
+ * `owner: <foreign>`, `reason: 'lost-race'`.
218
+ *
219
+ * Every claiming write is verified: GitHub's assignee PATCH is not a
220
+ * compare-and-set, so two runs that both read the ticket unassigned will both
221
+ * write themselves. {@link claimAndVerify} re-reads after the write and refuses
222
+ * (fail-closed) when a foreign login is present, so the loser of a simultaneous
223
+ * claim never proceeds as though it holds the lease.
214
224
  *
215
225
  * @param {object} opts
216
226
  * @param {object} opts.provider Ticketing provider.
@@ -225,7 +235,7 @@ export async function describeLease(opts) {
225
235
  * acquired: boolean,
226
236
  * owner: string,
227
237
  * previousOwner: string|null,
228
- * reason: 'unclaimed'|'already-held'|'reclaimed'|'stolen'|'held',
238
+ * reason: 'unclaimed'|'already-held'|'reclaimed'|'stolen'|'held'|'lost-race',
229
239
  * }>}
230
240
  */
231
241
  export async function acquireLease(opts) {
@@ -240,13 +250,13 @@ export async function acquireLease(opts) {
240
250
 
241
251
  // Unclaimed → take it.
242
252
  if (owner === null) {
243
- await provider.updateTicket(ticketId, { assignees: [operator] });
244
- return {
245
- acquired: true,
246
- owner: operator,
253
+ return claimAndVerify({
254
+ provider,
255
+ ticketId,
256
+ operator,
247
257
  previousOwner: null,
248
258
  reason: 'unclaimed',
249
- };
259
+ });
250
260
  }
251
261
 
252
262
  // Already ours → no write needed.
@@ -270,12 +280,70 @@ export async function acquireLease(opts) {
270
280
  };
271
281
  }
272
282
 
273
- await provider.updateTicket(ticketId, { assignees: [operator] });
274
- return {
275
- acquired: true,
276
- owner: operator,
283
+ return claimAndVerify({
284
+ provider,
285
+ ticketId,
286
+ operator,
277
287
  previousOwner: owner,
278
288
  reason: steal && live ? 'stolen' : 'reclaimed',
289
+ });
290
+ }
291
+
292
+ /**
293
+ * Write the operator to a ticket's assignees, then re-read to confirm the
294
+ * claim actually stuck before reporting success.
295
+ *
296
+ * The assignee write is not atomic — GitHub offers no compare-and-set on the
297
+ * assignees surface — so two runs that both observed the ticket unassigned (or
298
+ * a stale foreign claim) will both PATCH themselves in. Without a check the
299
+ * loser of that race returns `acquired: true` and marches into the worktree
300
+ * the winner is already building. The verify closes that window: it re-reads
301
+ * with `fresh: true` (bypassing any provider cache so it sees the other run's
302
+ * write, not our own), and if a foreign login is present it concedes — removes
303
+ * the operator from the assignee set so no phantom co-owner lingers, and
304
+ * returns `acquired: false` / `reason: 'lost-race'` so the fail-closed caller
305
+ * refuses. A clean read (assignees exactly `[operator]`) confirms the claim.
306
+ *
307
+ * It does not eliminate the race — two writes still happen — but it makes the
308
+ * outcome deterministic: exactly one operator survives as the sole assignee,
309
+ * and the other is told it lost.
310
+ *
311
+ * @param {object} args
312
+ * @param {object} args.provider Ticketing provider.
313
+ * @param {number} args.ticketId Ticket being claimed.
314
+ * @param {string} args.operator Operator acquiring the lease.
315
+ * @param {string|null} args.previousOwner Owner before this write (for the result).
316
+ * @param {string} args.reason Success reason when the claim holds.
317
+ * @returns {Promise<{ acquired: boolean, owner: string, previousOwner: string|null, reason: string }>}
318
+ */
319
+ async function claimAndVerify({
320
+ provider,
321
+ ticketId,
322
+ operator,
323
+ previousOwner,
324
+ reason,
325
+ }) {
326
+ await provider.updateTicket(ticketId, { assignees: [operator] });
327
+
328
+ const after = await provider.getTicket(ticketId, { fresh: true });
329
+ const assignees = Array.isArray(after?.assignees) ? after.assignees : [];
330
+ const foreign = assignees.filter((login) => login !== operator);
331
+
332
+ if (foreign.length === 0) {
333
+ return { acquired: true, owner: operator, previousOwner, reason };
334
+ }
335
+
336
+ // A foreign login co-assigned after our write — we lost a simultaneous
337
+ // claim. Back ourselves out so the winner is the sole assignee, and report
338
+ // the loss so the fail-closed caller refuses rather than double-delivering.
339
+ await provider
340
+ .updateTicket(ticketId, { assignees: foreign })
341
+ .catch(() => undefined);
342
+ return {
343
+ acquired: false,
344
+ owner: foreign[0],
345
+ previousOwner,
346
+ reason: 'lost-race',
279
347
  };
280
348
  }
281
349
 
@@ -38,6 +38,7 @@ import {
38
38
  renderTransitionMessage,
39
39
  } from '../../notifications/notifier.js';
40
40
  import {
41
+ emitBlockRecoveredFriction,
41
42
  emitRuntimeFriction,
42
43
  RUNTIME_FRICTION_CATEGORIES,
43
44
  } from '../../observability/runtime-friction.js';
@@ -122,20 +123,41 @@ function validateTransitionInputs(newState) {
122
123
  return newState;
123
124
  }
124
125
 
126
+ /**
127
+ * Active states a `agent::blocked` Story can recover into (Story #4622). A
128
+ * `blocked → {executing|ready}` transition is a self-resolved block; every
129
+ * other target (`done`, `closing`) is a real terminal outcome, not a
130
+ * recovery.
131
+ */
132
+ const BLOCK_RECOVERY_TARGETS = [STATE_LABELS.EXECUTING, STATE_LABELS.READY];
133
+
125
134
  /**
126
135
  * Resolve the pre-transition ticket snapshot that drives the notify
127
136
  * payload and the provider's label-merge path. Honors the caller-supplied
128
137
  * `opts.ticketSnapshot` (Story #1795) when present; otherwise issues a
129
138
  * best-effort `getTicket` and returns `null` on transient failure.
130
139
  *
140
+ * The snapshot is loaded when a caller threads `notify` (its `fromState`
141
+ * feeds the notification payload) OR when `needFromState` is set — Story
142
+ * #4622's recovery detection needs the *prior* state, and `getTicket` after
143
+ * `updateTicket` would already read the new label. Bounding the extra read
144
+ * to recovery-target transitions keeps every other flip on the snapshot-free
145
+ * fast path.
146
+ *
131
147
  * @param {object} provider
132
148
  * @param {{ notify?: Function, ticketSnapshot?: object|null }} opts
133
149
  * @param {number} ticketId
150
+ * @param {boolean} [needFromState]
134
151
  * @returns {Promise<object|null>}
135
152
  */
136
- async function loadTicketSnapshot(provider, opts, ticketId) {
153
+ async function loadTicketSnapshot(provider, opts, ticketId, needFromState) {
137
154
  if (opts.ticketSnapshot) return opts.ticketSnapshot;
138
- if (!opts.notify || typeof provider.getTicket !== 'function') return null;
155
+ if (
156
+ (!opts.notify && !needFromState) ||
157
+ typeof provider.getTicket !== 'function'
158
+ ) {
159
+ return null;
160
+ }
139
161
  try {
140
162
  return await provider.getTicket(ticketId);
141
163
  } catch (err) {
@@ -301,26 +323,50 @@ function dispatchTransitionNotification(args) {
301
323
  * (see `frictionForTerminal`): the two would otherwise count one incident
302
324
  * twice.
303
325
  *
304
- * Best-effort and awaited: `emitRuntimeFriction` swallows its own failures
305
- * and resolves `false`, so this can neither throw nor block the transition.
326
+ * Story #4622 extends the hook to the inverse edge: a `blocked active`
327
+ * transition emits a recovery marker so a transient block that self-resolved
328
+ * can be netted out of the retro's `story-blocked` recurrence total.
329
+ *
330
+ * Best-effort and awaited: the friction emitters swallow their own failures
331
+ * and resolve `false`, so this can neither throw nor block the transition.
306
332
  * It is awaited rather than fire-and-forget because CLI entry points exit
307
333
  * via `process.exit` as soon as `main` resolves (`cli-utils.runAsCli` with
308
334
  * `propagateExitCode`), which would discard a still-pending append.
309
335
  *
310
336
  * @param {number} ticketId
337
+ * @param {string|null} fromState Prior state label, or null.
311
338
  * @param {string} newState
312
339
  * @param {{ config?: object }} opts
313
340
  * @returns {Promise<void>}
314
341
  */
315
- async function emitBlockedFriction(ticketId, newState, opts) {
316
- if (newState !== STATE_LABELS.BLOCKED) return;
317
- await emitRuntimeFriction({
318
- storyId: ticketId,
319
- category: RUNTIME_FRICTION_CATEGORIES.STORY_BLOCKED,
320
- tool: 'transitionTicketState',
321
- details: { toState: newState },
322
- config: opts?.config,
323
- });
342
+ async function emitBlockedFriction(ticketId, fromState, newState, opts) {
343
+ if (newState === STATE_LABELS.BLOCKED) {
344
+ await emitRuntimeFriction({
345
+ storyId: ticketId,
346
+ category: RUNTIME_FRICTION_CATEGORIES.STORY_BLOCKED,
347
+ tool: 'transitionTicketState',
348
+ details: { toState: newState },
349
+ config: opts?.config,
350
+ });
351
+ return;
352
+ }
353
+ // Story #4622 — a transition *out* of `agent::blocked` into an active state
354
+ // is a recovery: the earlier block self-resolved. Emit its recovery marker
355
+ // so the retro composer can net the transient block out of the
356
+ // `story-blocked` recurrence total (swarm-os friction #581). Only a genuine
357
+ // block→active recovery qualifies; blocked→done/closing is a real
358
+ // terminal outcome, not a recovery, so it is left counted.
359
+ if (
360
+ fromState === STATE_LABELS.BLOCKED &&
361
+ BLOCK_RECOVERY_TARGETS.includes(newState)
362
+ ) {
363
+ await emitBlockRecoveredFriction({
364
+ storyId: ticketId,
365
+ fromState,
366
+ toState: newState,
367
+ config: opts?.config,
368
+ });
369
+ }
324
370
  }
325
371
 
326
372
  /**
@@ -382,7 +428,12 @@ export async function transitionTicketState(
382
428
  // snapshot is also forwarded to `provider.updateTicket` so the label
383
429
  // merge path skips its own `getTicket` call (the second of the two
384
430
  // round-trips this seam eliminates).
385
- const ticketSnapshot = await loadTicketSnapshot(provider, opts, ticketId);
431
+ const ticketSnapshot = await loadTicketSnapshot(
432
+ provider,
433
+ opts,
434
+ ticketId,
435
+ BLOCK_RECOVERY_TARGETS.includes(newState),
436
+ );
386
437
  const fromState =
387
438
  ticketSnapshot?.labels?.find((l) => ALL_STATES.includes(l)) ?? null;
388
439
 
@@ -406,8 +457,9 @@ export async function transitionTicketState(
406
457
  });
407
458
 
408
459
  // Story #4578 — derive a friction signal from the block, at the point the
409
- // runtime already knows. Best-effort; never blocks the transition.
410
- await emitBlockedFriction(ticketId, newState, opts);
460
+ // runtime already knows. Story #4622 also emits the recovery marker on the
461
+ // inverse block→active transition. Best-effort; never blocks the transition.
462
+ await emitBlockedFriction(ticketId, fromState, newState, opts);
411
463
 
412
464
  // Story #2548 — mirror the new state onto the Projects v2 Status
413
465
  // column. Best-effort; never blocks the transition.
@@ -167,3 +167,76 @@ function buildAcquired(lockPath, ownerId, fsImpl) {
167
167
  }
168
168
  return { acquired: true, release, ownerId };
169
169
  }
170
+
171
+ const DEFAULT_WAIT_MS = 8_000;
172
+ const DEFAULT_POLL_MS = 150;
173
+
174
+ /**
175
+ * Promise-based delay. Injectable so tests can drive the wait loop on a fake
176
+ * clock without a real timer.
177
+ *
178
+ * @param {number} ms
179
+ * @returns {Promise<void>}
180
+ */
181
+ function defaultSleep(ms) {
182
+ return new Promise((resolve) => {
183
+ setTimeout(resolve, ms);
184
+ });
185
+ }
186
+
187
+ /**
188
+ * Bounded-wait wrapper over {@link acquireSweepLock}.
189
+ *
190
+ * `acquireSweepLock` is single-attempt on purpose: a *skipped* sweep is
191
+ * harmless, so the sweep caller proceeds immediately on contention. The
192
+ * post-land tail is the opposite case — proceeding immediately IS the race
193
+ * two concurrent closes hit on a shared main checkout — so this wrapper
194
+ * polls the primitive with short backoff up to `waitMs` before giving up.
195
+ *
196
+ * It is still **never load-bearing**: on `waitMs` exhaustion it returns
197
+ * `{ acquired: false, reason: 'contended-after-wait' }` and the caller is
198
+ * expected to proceed anyway. The bounded wait is a best-effort collision
199
+ * damper, not a mutual-exclusion guarantee. A hard I/O error short-circuits
200
+ * the loop (spinning would just re-hit it).
201
+ *
202
+ * @param {object} opts
203
+ * @param {string} opts.lockPath
204
+ * @param {number} [opts.waitMs] Max total time to wait for the lock.
205
+ * @param {number} [opts.pollMs] Delay between acquire attempts.
206
+ * @param {number} [opts.timeoutMs] Stale-lock expiry, forwarded to the
207
+ * underlying acquire.
208
+ * @param {string} [opts.ownerId]
209
+ * @param {() => number} [opts.nowFn]
210
+ * @param {(ms: number) => Promise<void>} [opts.sleepFn]
211
+ * @param {object} [opts.fsImpl]
212
+ * @returns {Promise<{ acquired: true, release: () => void, ownerId: string }
213
+ * | { acquired: false, reason: 'contended-after-wait' | 'error', detail?: string }>}
214
+ */
215
+ export async function acquireLockWithWait({
216
+ lockPath,
217
+ waitMs = DEFAULT_WAIT_MS,
218
+ pollMs = DEFAULT_POLL_MS,
219
+ timeoutMs = DEFAULT_TIMEOUT_MS,
220
+ ownerId,
221
+ nowFn = Date.now,
222
+ sleepFn = defaultSleep,
223
+ fsImpl = fs,
224
+ } = {}) {
225
+ const deadline = nowFn() + Math.max(0, waitMs);
226
+ for (;;) {
227
+ const res = acquireSweepLock({
228
+ lockPath,
229
+ timeoutMs,
230
+ ownerId,
231
+ nowFn,
232
+ fsImpl,
233
+ });
234
+ if (res.acquired) return res;
235
+ // A hard error will not resolve by retrying — surface it immediately.
236
+ if (res.reason === 'error') return res;
237
+ if (nowFn() >= deadline) {
238
+ return { acquired: false, reason: 'contended-after-wait' };
239
+ }
240
+ await sleepFn(Math.max(0, pollMs));
241
+ }
242
+ }
@@ -25,11 +25,16 @@
25
25
  * labels, **unioned with the ids the host says it has dispatched**
26
26
  * (`--dispatched`). The label alone is not sufficient: the kernel's
27
27
  * contract counts "executing / closing / dispatched-not-yet-labelled" as
28
- * in-flight, and `single-story-init.js` flips `agent::executing` at step 6
29
- * of 6 *after* a 3–6 minute worktree install. For that whole window a
30
- * dispatched Story still reads `agent::ready`, so a label-only derivation
31
- * re-emits it in the next beat's `ready[]` and the host dispatches it a
32
- * second time onto the same branch and worktree (Story #4601).
28
+ * in-flight, and there is still a window between the host spawning a
29
+ * sub-agent and that sub-agent's `single-story-init.js` publishing the
30
+ * `agent::executing` label. Story #4620 shrank that window sharply the
31
+ * flip now lands before the multi-minute worktree install rather than
32
+ * after it but it is not zero (init still runs the lease acquire and a
33
+ * branch fetch first), so a label-only derivation could still re-emit a
34
+ * just-dispatched Story in the next beat's `ready[]` and dispatch it a
35
+ * second time onto the same branch (Story #4601). `--dispatched` closes
36
+ * the residual window; foreign runs are covered by the assignee lease
37
+ * (see `deriveForeignHeld`).
33
38
  * - **blocked** — the ids carrying `agent::blocked`. `classifyStory` has
34
39
  * always returned this class; nothing consumed it, so a blocked Story was
35
40
  * neither done, ready, nor in-flight and the beat reported a permanent
@@ -56,6 +61,10 @@ import {
56
61
  } from '../../resolve-stories.js';
57
62
  import { AGENT_LABELS } from '../label-constants.js';
58
63
  import { buildStoriesEnvelope } from '../orchestration/resolve-stories.js';
64
+ import {
65
+ currentOwner,
66
+ normalizeOperatorHandle,
67
+ } from '../orchestration/ticket-lease.js';
59
68
  import { classifyStory, storyIdOf } from './ready-set.js';
60
69
 
61
70
  /**
@@ -67,10 +76,13 @@ import { classifyStory, storyIdOf } from './ready-set.js';
67
76
  * 1. **Live labels.** `classifyStory` folds `agent::executing` and
68
77
  * `agent::closing` into one `executing` class — both are in-flight and
69
78
  * neither may be re-dispatched.
70
- * 2. **`dispatched`** — ids the host has spawned. This closes the init
71
- * window: `single-story-init.js` flips `agent::executing` last, after a
72
- * 3–6 minute install, so between spawn and flip a dispatched Story reads
73
- * `agent::ready` and a label-only derivation hands it back as ready.
79
+ * 2. **`dispatched`** — ids the host has spawned. This closes the residual
80
+ * init window: between the host spawning a sub-agent and that agent's
81
+ * `single-story-init.js` publishing `agent::executing`, a dispatched Story
82
+ * still reads `agent::ready` and a label-only derivation hands it back as
83
+ * ready. Story #4620 moved the flip ahead of the worktree install, so the
84
+ * window is now short rather than minutes-long, but `--dispatched` still
85
+ * covers it deterministically.
74
86
  *
75
87
  * `dispatched` is deliberately **not** the `--done`-style accounting probe
76
88
  * mode retired. Three properties keep it from becoming one:
@@ -129,6 +141,54 @@ function deriveBlockedIds(storyRecords) {
129
141
  .sort((a, b) => a - b);
130
142
  }
131
143
 
144
+ /**
145
+ * Identify Stories claimed by a **different** operator's lease.
146
+ *
147
+ * The Story lease rides the ticket's assignees (`ticket-lease.js`): the sole
148
+ * assignee is the operator driving that Story's run. `single-story-init.js`
149
+ * takes the lease at init, but flips `agent::executing` only after a 3–6 minute
150
+ * worktree install — so for that whole window a Story another operator is
151
+ * actively delivering still reads `agent::ready` with no in-flight label. A
152
+ * label-only probe classifies it `ready` and hands it to this run, which then
153
+ * dispatches into a guaranteed init failure (the fail-closed lease refuses a
154
+ * foreign assignee) mid-batch. Reading the assignee lets the probe withhold it
155
+ * up front and report who holds it instead.
156
+ *
157
+ * Only Stories that would otherwise be `ready` are considered — a `done`,
158
+ * `blocked`, or already-`executing` Story is handled by its own class, and a
159
+ * self-held assignee is this run's own claim and never withholds.
160
+ *
161
+ * When `self` is unresolved (no `github.operatorHandle`), foreign cannot be
162
+ * told from self, so this returns empty and warns once: the probe is a
163
+ * read-only path that must not fail closed, and init's lease acquire remains
164
+ * the backstop.
165
+ *
166
+ * @param {Array<{id?: number, number?: number, labels?: string[], state?: string, assignees?: string[]}>} storyRecords
167
+ * @param {string|null|undefined} self Resolved bare operator login for this run.
168
+ * @param {(msg: string) => void} [warn]
169
+ * @returns {Map<number, string>} Foreign-held Story id → holder login.
170
+ */
171
+ function deriveForeignHeld(storyRecords, self, warn) {
172
+ const held = new Map();
173
+ if (!self) {
174
+ warn?.(
175
+ '[live-probe] github.operatorHandle is unset (or the shipped ' +
176
+ '@[USERNAME] placeholder), so a foreign lease cannot be told from ' +
177
+ 'this run’s own claim — skipping assignee-based withholding. ' +
178
+ 'Set your handle in .agentrc.local.json to de-conflict concurrent ' +
179
+ 'runs at probe time; init’s lease still refuses a foreign claim.',
180
+ );
181
+ return held;
182
+ }
183
+ for (const rec of storyRecords) {
184
+ const id = storyIdOf(rec);
185
+ if (id === null || classifyStory(rec) !== 'ready') continue;
186
+ const owner = currentOwner(rec.assignees);
187
+ if (owner && owner !== self) held.set(id, owner);
188
+ }
189
+ return held;
190
+ }
191
+
132
192
  /**
133
193
  * Resolve the provider + repo coordinates the probe reads through.
134
194
  *
@@ -138,13 +198,21 @@ function deriveBlockedIds(storyRecords) {
138
198
  *
139
199
  * @param {object} [deps]
140
200
  * @param {Function} [deps.resolveProvider] Injection seam for tests.
141
- * @returns {{ provider: object, owner: string|undefined, repo: string|undefined }}
201
+ * @returns {{ provider: object, owner: string|undefined, repo: string|undefined, self: string|null }}
142
202
  */
143
203
  export function createProbeContext({
144
204
  resolveProvider = resolveStoriesProvider,
145
205
  } = {}) {
146
206
  const { provider, config } = resolveProvider();
147
- return { provider, owner: config?.github?.owner, repo: config?.github?.repo };
207
+ return {
208
+ provider,
209
+ owner: config?.github?.owner,
210
+ repo: config?.github?.repo,
211
+ // Bare login this run claims leases under. Normalised (leading `@` stripped,
212
+ // `@[USERNAME]` placeholder → null) so it compares against the bare assignee
213
+ // logins GitHub returns; `null` disables assignee-based withholding.
214
+ self: normalizeOperatorHandle(config?.github?.operatorHandle),
215
+ };
148
216
  }
149
217
 
150
218
  /**
@@ -165,6 +233,10 @@ export function createProbeContext({
165
233
  * @param {boolean} [args.native=true] Read native `blocked_by` edges.
166
234
  * @param {number[]} [args.dispatched=[]] Ids the host has spawned but may not
167
235
  * yet have observed labelled `agent::executing` (see `deriveInFlightIds`).
236
+ * @param {string|null} [args.self] Resolved bare operator login for this
237
+ * run, used to withhold Stories another operator's lease holds
238
+ * (`deriveForeignHeld`). Absent/unresolved → assignee-based withholding is
239
+ * skipped (the probe never fails closed).
168
240
  * @param {(msg: string) => void} [args.warn]
169
241
  * Each returned node carries its **live labels**. That is load-bearing, not
170
242
  * decoration: `selectReadySet` classifies from labels, so a node stripped of
@@ -177,7 +249,8 @@ export function createProbeContext({
177
249
  * nodes: Array<{id: number, dependsOn: number[], files: string[], labels: string[]}>,
178
250
  * doneIds: Set<number>,
179
251
  * inFlight: number,
180
- * blockedIds: number[]
252
+ * blockedIds: number[],
253
+ * foreignHeld: Array<{id: number, holder: string}>
181
254
  * }>}
182
255
  */
183
256
  export async function probeLiveState({
@@ -187,6 +260,7 @@ export async function probeLiveState({
187
260
  repo,
188
261
  native = true,
189
262
  dispatched = [],
263
+ self,
190
264
  warn,
191
265
  }) {
192
266
  const stories = await fetchStories(provider, ids);
@@ -209,6 +283,12 @@ export async function probeLiveState({
209
283
 
210
284
  const labelsById = new Map(stories.map((s) => [s.id, s.labels ?? []]));
211
285
  const inFlightIds = deriveInFlightIds(stories, dispatched);
286
+ // A Story another operator's lease holds occupies a (global) dispatch slot
287
+ // just like an in-flight one: fold it into the in-flight set so it is both
288
+ // withheld (via the projected label) and excluded from a false wedge, but
289
+ // never dispatched by this run.
290
+ const foreignHeld = deriveForeignHeld(stories, self, warn);
291
+ for (const id of foreignHeld.keys()) inFlightIds.add(id);
212
292
  return {
213
293
  nodes: envelope.dag.map((node) => ({
214
294
  ...node,
@@ -220,6 +300,7 @@ export async function probeLiveState({
220
300
  doneIds: new Set(envelope.done),
221
301
  inFlight: inFlightIds.size,
222
302
  blockedIds: deriveBlockedIds(stories),
303
+ foreignHeld: [...foreignHeld].map(([id, holder]) => ({ id, holder })),
223
304
  };
224
305
  }
225
306