mandrel 2.61.0 → 2.62.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.
@@ -0,0 +1,158 @@
1
+ /**
2
+ * merge-queue.js — the one GraphQL seam for a merge-queue-protected base:
3
+ * whether the PR's base requires a queue, whether the PR sits in it, and the
4
+ * dequeue. Keyed on the PR node, so no owner/repo is needed.
5
+ */
6
+
7
+ import { gh as defaultGh, describeGhFailure } from '../gh-exec.js';
8
+
9
+ /** Bound on each merge-queue GraphQL call, so a wedged `gh` cannot stall close. */
10
+ const MERGE_QUEUE_GH_TIMEOUT_MS = 30_000;
11
+
12
+ const MERGE_QUEUE_STATE_QUERY =
13
+ 'query($id: ID!) { node(id: $id) { ... on PullRequest { isMergeQueueEnabled isInMergeQueue } } }';
14
+
15
+ const DEQUEUE_MUTATION =
16
+ 'mutation($id: ID!) { dequeuePullRequest(input: { id: $id }) { mergeQueueEntry { id } } }';
17
+
18
+ /** `gh` stderr when the base requires a merge queue and the arm spelling is wrong. */
19
+ export const MERGE_QUEUE_REFUSAL = /when merge queue enabled/i;
20
+
21
+ /**
22
+ * @param {{ stdout?: string }|string|undefined} result
23
+ * @returns {object|null}
24
+ */
25
+ function parseGraphqlData(result) {
26
+ const text = typeof result === 'string' ? result : result?.stdout;
27
+ const parsed = JSON.parse(String(text ?? ''));
28
+ if (Array.isArray(parsed?.errors) && parsed.errors.length > 0) {
29
+ throw new Error(
30
+ `graphql: ${parsed.errors.map((e) => e?.message).join('; ')}`,
31
+ );
32
+ }
33
+ return parsed?.data ?? null;
34
+ }
35
+
36
+ /**
37
+ * Whether the PR's base requires a merge queue, and whether the PR sits in
38
+ * it now. Keyed on the PR node, so it needs no owner/repo. Never throws: any
39
+ * failure returns `queueRequired: null` and the caller keeps the non-queue
40
+ * behaviour.
41
+ *
42
+ * @param {{ prNumber?: number|string, prRef?: string, prNodeId?: string,
43
+ * gh?: object, timeoutMs?: number }} args `prNodeId` skips the id lookup.
44
+ * @returns {Promise<{ queueRequired: boolean|null, inQueue: boolean|null,
45
+ * prNodeId: string|null, error?: string }>}
46
+ */
47
+ export async function readMergeQueueState({
48
+ prNumber,
49
+ prRef,
50
+ prNodeId,
51
+ gh,
52
+ timeoutMs = MERGE_QUEUE_GH_TIMEOUT_MS,
53
+ }) {
54
+ const facade = gh ?? defaultGh;
55
+ let id = prNodeId ?? null;
56
+ try {
57
+ if (!id) {
58
+ const view = await facade.pr.view(String(prRef ?? prNumber), ['id']);
59
+ id = typeof view?.id === 'string' && view.id ? view.id : null;
60
+ }
61
+ if (!id) throw new Error('PR node id unavailable');
62
+ const data = parseGraphqlData(
63
+ await facade.api({
64
+ method: 'POST',
65
+ endpoint: 'graphql',
66
+ body: { query: MERGE_QUEUE_STATE_QUERY, variables: { id } },
67
+ execOpts: { timeoutMs },
68
+ }),
69
+ );
70
+ const node = data?.node;
71
+ if (typeof node?.isMergeQueueEnabled !== 'boolean') {
72
+ throw new Error('merge-queue fields absent from the GraphQL response');
73
+ }
74
+ return {
75
+ queueRequired: node.isMergeQueueEnabled,
76
+ inQueue: node.isInMergeQueue === true,
77
+ prNodeId: id,
78
+ };
79
+ } catch (err) {
80
+ return {
81
+ queueRequired: null,
82
+ inQueue: null,
83
+ prNodeId: id,
84
+ error: describeGhFailure(err),
85
+ };
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Take an enqueued PR out of the merge queue. `--disable-auto` cannot: GitHub
91
+ * clears the auto-merge request on enqueue, so it reads as never armed.
92
+ *
93
+ * @returns {Promise<{ ok: boolean, detail: string }>}
94
+ */
95
+ async function dequeuePullRequest({ prNodeId, gh }) {
96
+ try {
97
+ parseGraphqlData(
98
+ await (gh ?? defaultGh).api({
99
+ method: 'POST',
100
+ endpoint: 'graphql',
101
+ body: { query: DEQUEUE_MUTATION, variables: { id: prNodeId } },
102
+ execOpts: { timeoutMs: MERGE_QUEUE_GH_TIMEOUT_MS },
103
+ }),
104
+ );
105
+ return { ok: true, detail: 'dequeued' };
106
+ } catch (err) {
107
+ return { ok: false, detail: describeGhFailure(err) };
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Arm on a merge-queue base: a bare `--auto`, and never the direct-merge
113
+ * fallback — a merge that bypassed the queue would defeat the protection it
114
+ * exists for.
115
+ *
116
+ * @returns {Promise<{ enabled: boolean, mergeQueue: true, reason?: string }>}
117
+ */
118
+ export async function armForMergeQueue({ exec, prNumber, armCwd }) {
119
+ try {
120
+ const result = await exec(['pr', 'merge', String(prNumber), '--auto'], {
121
+ cwd: armCwd,
122
+ });
123
+ if (result.status === 0) return { enabled: true, mergeQueue: true };
124
+ return {
125
+ enabled: false,
126
+ mergeQueue: true,
127
+ reason: `merge-queue arm failed; gh-exit-${result.status}: ${(result.stderr ?? '').trim().slice(0, 200)}`,
128
+ };
129
+ } catch (err) {
130
+ return {
131
+ enabled: false,
132
+ mergeQueue: true,
133
+ reason: `gh-spawn-error: ${err?.message ?? err}`,
134
+ };
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Disarm an enqueued PR by dequeueing it. The caller owns the still-armed
140
+ * warning, so both disarm paths share it.
141
+ *
142
+ * @returns {Promise<{ disarmed: boolean, alreadyUnarmed: false, detail: string }>}
143
+ */
144
+ export async function disarmByDequeue({ prNodeId, ref, gh, progress }) {
145
+ const dequeued = await dequeuePullRequest({ prNodeId, gh });
146
+ if (!dequeued.ok) {
147
+ return {
148
+ disarmed: false,
149
+ alreadyUnarmed: false,
150
+ detail: `merge-queue dequeue failed: ${dequeued.detail}`.slice(0, 200),
151
+ };
152
+ }
153
+ progress?.(
154
+ 'CONFIRM',
155
+ `🔓 PR #${ref} removed from the merge queue — the PR stays open and hand-mergeable.`,
156
+ );
157
+ return { disarmed: true, alreadyUnarmed: false, detail: 'dequeued' };
158
+ }
@@ -3,6 +3,10 @@
3
3
  * Non-fatal: a failure returns `{ enabled: false, reason }`. The arm runs from
4
4
  * the primary worktree so `--delete-branch`'s local `git checkout <base>`
5
5
  * cannot collide with a worktree holding the base.
6
+ *
7
+ * A merge-queue base is armed with a bare `--auto` instead: `gh` refuses
8
+ * `--delete-branch` under a queue, the queue rule picks the strategy, and the
9
+ * repo's `delete_branch_on_merge` owns the remote branch reap.
6
10
  */
7
11
 
8
12
  import { gh as defaultGh, describeGhFailure } from '../../../gh-exec.js';
@@ -11,6 +15,12 @@ import {
11
15
  decideAdvisoryGateBlock,
12
16
  deriveRedHeadRuns,
13
17
  } from '../../merge-poll.js';
18
+ import {
19
+ armForMergeQueue,
20
+ disarmByDequeue,
21
+ MERGE_QUEUE_REFUSAL,
22
+ readMergeQueueState,
23
+ } from '../../merge-queue.js';
14
24
 
15
25
  /**
16
26
  * The operator deliberately owns the merge, so close does not wait for it;
@@ -104,8 +114,9 @@ async function directMergeFallback({ exec, prNumber, armCwd, autoReason }) {
104
114
  * gh?: ReturnType<typeof import('../../../gh-exec.js').createGh>,
105
115
  * runner?: (args: string[], opts: object) => ({ status: number, stdout?: string, stderr?: string } | Promise<{ status: number, stdout?: string, stderr?: string }>),
106
116
  * resolveArmCwd?: (cwd: string) => string,
107
- * }} opts
108
- * @returns {Promise<{ enabled: boolean, reason?: string, localCleanupDeferred?: boolean, directMerged?: boolean }>}
117
+ * queueRequired?: boolean,
118
+ * }} opts `queueRequired`: the base requires a merge queue.
119
+ * @returns {Promise<{ enabled: boolean, reason?: string, localCleanupDeferred?: boolean, directMerged?: boolean, mergeQueue?: boolean }>}
109
120
  */
110
121
  export async function enableAutoMergeWith({
111
122
  cwd,
@@ -113,9 +124,19 @@ export async function enableAutoMergeWith({
113
124
  gh,
114
125
  runner,
115
126
  resolveArmCwd = resolveAutoMergeArmCwd,
127
+ queueRequired = false,
116
128
  }) {
117
129
  const exec = runner ?? makeDefaultGhAutoMergeRunner(gh ?? defaultGh);
118
130
  const armCwd = resolveArmCwd(cwd);
131
+ const arm = queueRequired ? armForMergeQueue : armNativeAutoMerge;
132
+ return arm({ exec, prNumber, armCwd });
133
+ }
134
+
135
+ /**
136
+ * `--auto --squash --delete-branch`, falling back to a direct squash-merge
137
+ * only when native auto-merge is unavailable.
138
+ */
139
+ async function armNativeAutoMerge({ exec, prNumber, armCwd }) {
119
140
  try {
120
141
  const result = await exec(
121
142
  [
@@ -134,6 +155,10 @@ export async function enableAutoMergeWith({
134
155
  // The remote side stands; the land tail finishes the local ref reap.
135
156
  return { enabled: true, localCleanupDeferred: true, reason: detail };
136
157
  }
158
+ if (MERGE_QUEUE_REFUSAL.test(String(result.stderr ?? ''))) {
159
+ // The detection probe missed the queue; the refusal names it.
160
+ return armForMergeQueue({ exec, prNumber, armCwd });
161
+ }
137
162
  if (isAutoMergeUnavailable(result.stderr)) {
138
163
  return directMergeFallback({
139
164
  exec,
@@ -218,15 +243,32 @@ const NOT_ARMED = /not enabled|isn't enabled|is not set|no auto-?merge/i;
218
243
 
219
244
  /**
220
245
  * Lives here because `lifecycle-lint` confines `gh pr merge` to this module.
221
- * Never throws.
246
+ * An enqueued PR is dequeued instead. Never throws.
222
247
  *
223
248
  * @param {{ prNumber?: number|string, prRef?: string, gh?: object,
224
- * progress?: (tag: string, msg: string) => void }} args `prRef` wins over
225
- * `prNumber` when both are given.
249
+ * progress?: (tag: string, msg: string) => void,
250
+ * readMergeQueueStateFn?: typeof readMergeQueueState }} args `prRef` wins
251
+ * over `prNumber` when both are given.
226
252
  * @returns {Promise<{ disarmed: boolean, alreadyUnarmed: boolean, detail: string }>}
227
253
  */
228
- export async function disarmAutoMerge({ prNumber, prRef, gh, progress }) {
254
+ export async function disarmAutoMerge({
255
+ prNumber,
256
+ prRef,
257
+ gh,
258
+ progress,
259
+ readMergeQueueStateFn = readMergeQueueState,
260
+ }) {
229
261
  const ref = String(prRef ?? prNumber);
262
+ const queue = await readMergeQueueStateFn({ prNumber, prRef, gh });
263
+ const outcome =
264
+ queue?.inQueue === true && queue.prNodeId
265
+ ? await disarmByDequeue({ prNodeId: queue.prNodeId, ref, gh, progress })
266
+ : await disableNativeAutoMerge({ ref, gh, progress });
267
+ warnIfStillArmed({ outcome, ref, progress });
268
+ return outcome;
269
+ }
270
+
271
+ async function disableNativeAutoMerge({ ref, gh, progress }) {
230
272
  try {
231
273
  await (gh ?? defaultGh).pr.merge(ref, ['--disable-auto']);
232
274
  progress?.(
@@ -235,9 +277,7 @@ export async function disarmAutoMerge({ prNumber, prRef, gh, progress }) {
235
277
  );
236
278
  return { disarmed: true, alreadyUnarmed: false, detail: 'disarmed' };
237
279
  } catch (err) {
238
- const outcome = classifyDisarmFailure(describeGhFailure(err));
239
- warnIfStillArmed({ outcome, ref, progress });
240
- return outcome;
280
+ return classifyDisarmFailure(describeGhFailure(err));
241
281
  }
242
282
  }
243
283
 
@@ -307,6 +347,62 @@ async function evaluateAdvisoryGate({
307
347
  };
308
348
  }
309
349
 
350
+ /**
351
+ * Report an arm that stood, naming how it will land.
352
+ *
353
+ * @returns {{ autoMergeEnabled: true, autoMergeReason: null, localCleanupDeferred: boolean, directMerged?: boolean, mergeQueue?: boolean }}
354
+ */
355
+ function reportArmed({ result, prNumber, progress }) {
356
+ if (result.mergeQueue) {
357
+ progress(
358
+ 'PR',
359
+ `✅ Auto-merge enabled on PR #${prNumber} (merge queue — the queue rule picks the strategy).`,
360
+ );
361
+ return {
362
+ autoMergeEnabled: true,
363
+ autoMergeReason: null,
364
+ localCleanupDeferred: false,
365
+ mergeQueue: true,
366
+ };
367
+ }
368
+ if (result.directMerged) {
369
+ progress(
370
+ 'PR',
371
+ `✅ Native auto-merge unavailable on PR #${prNumber} — direct squash-merge landed it` +
372
+ (result.localCleanupDeferred
373
+ ? " (gh's LOCAL branch cleanup deferred to the land tail; the merge stands)."
374
+ : '.'),
375
+ );
376
+ return {
377
+ autoMergeEnabled: true,
378
+ autoMergeReason: null,
379
+ directMerged: true,
380
+ localCleanupDeferred: Boolean(result.localCleanupDeferred),
381
+ };
382
+ }
383
+ if (result.localCleanupDeferred) {
384
+ progress(
385
+ 'PR',
386
+ `⚠️ Auto-merge armed on PR #${prNumber}, but gh's LOCAL branch cleanup failed ` +
387
+ `(${result.reason}) — deferring the local ref reap to the land tail; the merge stands.`,
388
+ );
389
+ return {
390
+ autoMergeEnabled: true,
391
+ autoMergeReason: null,
392
+ localCleanupDeferred: true,
393
+ };
394
+ }
395
+ progress(
396
+ 'PR',
397
+ `✅ Auto-merge enabled on PR #${prNumber} (squash, delete-branch).`,
398
+ );
399
+ return {
400
+ autoMergeEnabled: true,
401
+ autoMergeReason: null,
402
+ localCleanupDeferred: false,
403
+ };
404
+ }
405
+
310
406
  /**
311
407
  * Dispatch auto-merge enablement.
312
408
  *
@@ -319,7 +415,7 @@ async function evaluateAdvisoryGate({
319
415
  * gh?: ReturnType<typeof import('../../../gh-exec.js').createGh>,
320
416
  * progress: (tag: string, msg: string) => void,
321
417
  * }} args
322
- * @returns {Promise<{ autoMergeEnabled: boolean, autoMergeReason: string|null, localCleanupDeferred?: boolean, directMerged?: boolean }>}
418
+ * @returns {Promise<{ autoMergeEnabled: boolean, autoMergeReason: string|null, localCleanupDeferred?: boolean, directMerged?: boolean, mergeQueue?: boolean }>}
323
419
  * `localCleanupDeferred`: the arm stands but the land tail owns the local
324
420
  * ref reap. `directMerged`: landed by direct squash-merge instead.
325
421
  */
@@ -334,6 +430,7 @@ export async function runAutoMergePhase({
334
430
  gh,
335
431
  progress,
336
432
  readPrWaitProbeFn = readAdvisoryProbe,
433
+ readMergeQueueStateFn = readMergeQueueState,
337
434
  }) {
338
435
  if (noAutoMerge) {
339
436
  progress('PR', '⏭ Auto-merge disabled (--no-auto-merge).');
@@ -384,45 +481,14 @@ export async function runAutoMergePhase({
384
481
  },
385
482
  };
386
483
  }
387
- const result = await enableAutoMergeWith({ cwd, prNumber, gh });
388
- if (result.enabled) {
389
- if (result.directMerged) {
390
- progress(
391
- 'PR',
392
- `✅ Native auto-merge unavailable on PR #${prNumber} — direct squash-merge landed it` +
393
- (result.localCleanupDeferred
394
- ? " (gh's LOCAL branch cleanup deferred to the land tail; the merge stands)."
395
- : '.'),
396
- );
397
- return {
398
- autoMergeEnabled: true,
399
- autoMergeReason: null,
400
- directMerged: true,
401
- localCleanupDeferred: Boolean(result.localCleanupDeferred),
402
- };
403
- }
404
- if (result.localCleanupDeferred) {
405
- progress(
406
- 'PR',
407
- `⚠️ Auto-merge armed on PR #${prNumber}, but gh's LOCAL branch cleanup failed ` +
408
- `(${result.reason}) — deferring the local ref reap to the land tail; the merge stands.`,
409
- );
410
- return {
411
- autoMergeEnabled: true,
412
- autoMergeReason: null,
413
- localCleanupDeferred: true,
414
- };
415
- }
416
- progress(
417
- 'PR',
418
- `✅ Auto-merge enabled on PR #${prNumber} (squash, delete-branch).`,
419
- );
420
- return {
421
- autoMergeEnabled: true,
422
- autoMergeReason: null,
423
- localCleanupDeferred: false,
424
- };
425
- }
484
+ const queue = await readMergeQueueStateFn({ prNumber, gh });
485
+ const result = await enableAutoMergeWith({
486
+ cwd,
487
+ prNumber,
488
+ gh,
489
+ queueRequired: queue?.queueRequired === true,
490
+ });
491
+ if (result.enabled) return reportArmed({ result, prNumber, progress });
426
492
  progress(
427
493
  'PR',
428
494
  `⚠️ Auto-merge enablement failed (${result.reason}) — operator can merge manually.`,
@@ -49,6 +49,7 @@ import {
49
49
  readRunSummary,
50
50
  resolveAdvisoryGateVerdict,
51
51
  } from '../../merge-poll.js';
52
+ import { readMergeQueueState } from '../../merge-queue.js';
52
53
  import { NEXT_COMMANDS } from '../../story-deliver-terminal.js';
53
54
  import {
54
55
  postStructuredComment,
@@ -117,6 +118,30 @@ function readString(value, absent = undefined) {
117
118
  return typeof value === 'string' && value ? value : absent;
118
119
  }
119
120
 
121
+ /**
122
+ * `inMergeQueue` for a green open PR, the only state GitHub enqueues from;
123
+ * `undefined` otherwise, so the common poll pays no extra call. An enqueued
124
+ * PR has no auto-merge request and can read BLOCKED — neither is a disarm.
125
+ */
126
+ async function readInMergeQueue({
127
+ view,
128
+ checksStatus,
129
+ gh,
130
+ ghTimeoutMs,
131
+ readMergeQueueStateFn,
132
+ }) {
133
+ const prNodeId = readString(view?.id);
134
+ if (view?.state !== 'OPEN' || checksStatus !== 'success' || !prNodeId) {
135
+ return undefined;
136
+ }
137
+ const queue = await readMergeQueueStateFn({
138
+ prNodeId,
139
+ gh,
140
+ timeoutMs: ghTimeoutMs,
141
+ });
142
+ return typeof queue?.inQueue === 'boolean' ? queue.inQueue : undefined;
143
+ }
144
+
120
145
  /**
121
146
  * One probe per poll iteration. A failed or timed-out read degrades to
122
147
  * `{ checksStatus: 'pending', error }` so a flaky read is never a verdict.
@@ -127,10 +152,12 @@ export async function readPrWaitProbe({
127
152
  prNumber,
128
153
  gh = defaultGh,
129
154
  ghTimeoutMs = MERGE_WAIT_GH_TIMEOUT_MS,
155
+ readMergeQueueStateFn = readMergeQueueState,
130
156
  }) {
131
157
  try {
132
158
  const view = await withGhTimeout(
133
159
  gh.pr.view(prNumber, [
160
+ 'id',
134
161
  'state',
135
162
  'mergedAt',
136
163
  'createdAt',
@@ -142,13 +169,21 @@ export async function readPrWaitProbe({
142
169
  ghTimeoutMs,
143
170
  `gh pr view ${prNumber}`,
144
171
  );
172
+ const checksStatus = deriveChecksStatus(view?.statusCheckRollup);
145
173
  return {
146
174
  state: readString(view?.state, null),
147
175
  mergedAt: readString(view?.mergedAt, null),
148
176
  createdAt: readString(view?.createdAt, null),
149
177
  mergeStateStatus: readString(view?.mergeStateStatus),
150
178
  reviewDecision: readString(view?.reviewDecision),
151
- checksStatus: deriveChecksStatus(view?.statusCheckRollup),
179
+ checksStatus,
180
+ inMergeQueue: await readInMergeQueue({
181
+ view,
182
+ checksStatus,
183
+ gh,
184
+ ghTimeoutMs,
185
+ readMergeQueueStateFn,
186
+ }),
152
187
  // Head-anchored, so superseded/pending runs don't read as red; `null`
153
188
  // when the rollup is empty (the consecutive-probe fallback owns that).
154
189
  requiredRunEvidence: deriveRequiredRunEvidence(view?.statusCheckRollup),
@@ -644,6 +679,18 @@ async function resolveAdvisoryUnlanded({
644
679
  };
645
680
  }
646
681
 
682
+ /**
683
+ * An enqueued PR reads BLOCKED with every check green, which the classifier
684
+ * would call human-required — but the queue lands it unattended.
685
+ */
686
+ function queuedExhaustionVerdict(probe, cumulativeMs) {
687
+ if (probe?.inMergeQueue !== true) return {};
688
+ return {
689
+ blockClassOverride: 'checks-pending-timeout',
690
+ reasonOverride: `PR is still in the merge queue after ${Math.round(cumulativeMs / 1000)} seconds — GitHub merges it when the queue's checks pass; a queued base may need a larger delivery.mergeWatch.maxBudgetSeconds`,
691
+ };
692
+ }
693
+
647
694
  /** Classify, emit `merge.unlanded`, post friction, block — all best-effort. */
648
695
  async function blockOnUnlanded({
649
696
  storyId,
@@ -1126,6 +1173,7 @@ export async function runConfirmMergePhase({
1126
1173
  exhausted: true,
1127
1174
  elapsedSeconds: Math.round(cumulativeMs / 1000),
1128
1175
  },
1176
+ ...queuedExhaustionVerdict(probe, cumulativeMs),
1129
1177
  };
1130
1178
  }
1131
1179
  }
package/docs/CHANGELOG.md CHANGED
@@ -15,6 +15,13 @@ All notable changes to this project will be documented in this file.
15
15
  -->
16
16
  <!-- markdownlint-disable-file MD004 MD012 MD037 -->
17
17
 
18
+ ## [2.62.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.61.0...mandrel-v2.62.0) (2026-09-18)
19
+
20
+
21
+ ### Fixed
22
+
23
+ * **close:** arm auto-merge on a merge-queue-protected base ([#5395](https://github.com/dsj1984/mandrel/issues/5395)) ([#5396](https://github.com/dsj1984/mandrel/issues/5396)) ([6d739ea](https://github.com/dsj1984/mandrel/commit/6d739eadb8ae00bedeb7279b1a765c3e0cc5791e))
24
+
18
25
  ## [2.61.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.60.0...mandrel-v2.61.0) (2026-09-18)
19
26
 
20
27
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mandrel",
3
- "version": "2.61.0",
3
+ "version": "2.62.0",
4
4
  "description": "Claude Code-first opinionated workflow framework: instructions, skills, rules, and SDLC workflows that govern AI coding assistants.",
5
5
  "files": [
6
6
  ".agents/",