mandrel 1.69.0 → 1.71.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 (57) hide show
  1. package/.agents/README.md +7 -7
  2. package/.agents/docs/SDLC.md +4 -5
  3. package/.agents/docs/configuration.md +9 -9
  4. package/.agents/docs/workflows.md +4 -6
  5. package/.agents/schemas/qa-finding.schema.json +1 -1
  6. package/.agents/scripts/apply-quality-bootstrap.js +79 -0
  7. package/.agents/scripts/audit-labels-bootstrap.js +52 -30
  8. package/.agents/scripts/audit-to-stories.js +54 -0
  9. package/.agents/scripts/bootstrap.js +13 -3
  10. package/.agents/scripts/generate-config-docs.js +189 -94
  11. package/.agents/scripts/lib/audit-suite/findings.js +0 -4
  12. package/.agents/scripts/lib/audit-to-stories/audit-lenses.js +99 -0
  13. package/.agents/scripts/lib/audit-to-stories/build-story-body.js +13 -5
  14. package/.agents/scripts/lib/baseline-snapshot.js +163 -4
  15. package/.agents/scripts/lib/baselines/refresh-service.js +0 -4
  16. package/.agents/scripts/lib/bootstrap/ci-workflow-template.js +1 -1
  17. package/.agents/scripts/lib/bootstrap/quality-bootstrap.js +1 -1
  18. package/.agents/scripts/lib/config/baselines.js +0 -20
  19. package/.agents/scripts/lib/config/defaults.js +1 -1
  20. package/.agents/scripts/lib/config/sync-agentrc.js +1 -1
  21. package/.agents/scripts/lib/config/temp-paths.js +0 -31
  22. package/.agents/scripts/lib/config-resolver.js +1 -1
  23. package/.agents/scripts/lib/crap-utils.js +281 -0
  24. package/.agents/scripts/lib/orchestration/dispatch-engine.js +0 -2
  25. package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter/composition.js +0 -84
  26. package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter/signals.js +3 -4
  27. package/.agents/scripts/lib/orchestration/lifecycle/trace-logger.js +0 -4
  28. package/.agents/scripts/lib/orchestration/retro/phases/compose-body.js +101 -70
  29. package/.agents/scripts/lib/orchestration/spec-renderer.js +42 -14
  30. package/.agents/scripts/lib/orchestration/ticket-lease.js +3 -0
  31. package/.agents/scripts/lib/qa/qa-context-hydrator.js +1 -1
  32. package/.agents/scripts/lib/qa/resolve-qa-contract.js +1 -1
  33. package/.agents/scripts/lib/story-body/story-body.js +110 -65
  34. package/.agents/scripts/lib/test-tiers.js +13 -7
  35. package/.agents/scripts/lib/wave-runner/tick.js +177 -53
  36. package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +226 -0
  37. package/.agents/scripts/mandrel-update-preflight.js +235 -0
  38. package/.agents/scripts/providers/github/issues.js +48 -0
  39. package/.agents/scripts/providers/github.js +1 -0
  40. package/.agents/scripts/sync-agentrc.js +2 -2
  41. package/.agents/skills/skills.index.json +2 -2
  42. package/.agents/skills/stack/qa/playwright-bdd/SKILL.md +3 -3
  43. package/.agents/skills/stack/qa/qa-harness/SKILL.md +4 -4
  44. package/.agents/workflows/git-deliver.md +298 -0
  45. package/.agents/workflows/helpers/epic-testing.md +6 -6
  46. package/.agents/workflows/helpers/{agents-sync-config.md → mandrel-sync-config.md} +5 -4
  47. package/.agents/workflows/{agents-update.md → mandrel-update.md} +210 -33
  48. package/.agents/workflows/qa-explore.md +1 -1
  49. package/.agents/workflows/{qa-run-harness.md → qa-run.md} +5 -5
  50. package/README.md +40 -0
  51. package/docs/CHANGELOG.md +43 -0
  52. package/lib/cli/registry.js +49 -6
  53. package/lib/cli/update.js +335 -332
  54. package/package.json +16 -11
  55. package/.agents/workflows/git-commit-all.md +0 -15
  56. package/.agents/workflows/git-pr-all.md +0 -281
  57. package/.agents/workflows/git-push.md +0 -63
@@ -415,6 +415,94 @@ function shellEscape(s) {
415
415
  return `'${str.replace(/'/g, `'\\''`)}'`;
416
416
  }
417
417
 
418
+ /**
419
+ * Render the body lines (everything below a section heading and its trailing
420
+ * blank) for a "proposed issues" bucket — the consumer and framework sections
421
+ * share this shape. Empty buckets collapse to a single `_None._`; populated
422
+ * buckets emit one fenced `gh issue create` stanza per item.
423
+ *
424
+ * @param {object[]} items
425
+ * @returns {string[]}
426
+ */
427
+ function renderIssueBucket(items) {
428
+ if (items.length === 0) return ['_None._'];
429
+ const lines = [];
430
+ for (const item of items) {
431
+ lines.push(`- **${item.title ?? item.category}**`);
432
+ lines.push('');
433
+ lines.push('```sh');
434
+ lines.push(String(item.command ?? ''));
435
+ lines.push('```');
436
+ lines.push('');
437
+ }
438
+ return lines;
439
+ }
440
+
441
+ /**
442
+ * Render the body lines for the "proposed memory updates" bucket — a plain
443
+ * instruction prelude followed by one bullet per insight, or `_None._` when
444
+ * empty. Deliberately NOT YAML frontmatter (asserted by the routed-sections
445
+ * contract test).
446
+ *
447
+ * @param {object[]} items
448
+ * @returns {string[]}
449
+ */
450
+ function renderMemoryBucket(items) {
451
+ if (items.length === 0) return ['_None._'];
452
+ return [
453
+ 'update your memory with the following insights:',
454
+ '',
455
+ ...items.map((m) => `- ${m.insight}`),
456
+ ];
457
+ }
458
+
459
+ /**
460
+ * Render the body lines for the "one-off / discarded" bucket — one bullet per
461
+ * discarded class naming its occurrence count and source, or `_None._`.
462
+ *
463
+ * @param {object[]} items
464
+ * @returns {string[]}
465
+ */
466
+ function renderDiscardedBucket(items) {
467
+ if (items.length === 0) return ['_None._'];
468
+ return items.map(
469
+ (d) =>
470
+ `- \`${d.category}\` (${d.occurrences ?? 1} occurrence, source: ${d.source ?? 'consumer'})`,
471
+ );
472
+ }
473
+
474
+ /**
475
+ * Descriptor table for the four routed-proposal sections, in deterministic
476
+ * emit order (consumer → framework → memory → discarded). Each descriptor
477
+ * pairs a heading, the `routedProposals` field it reads, and a body renderer.
478
+ * {@link renderRoutedSections} walks the table once, so reordering or adding a
479
+ * section is a data edit here rather than another copy-pasted emit block.
480
+ *
481
+ * @type {Array<{ heading: string, field: string, renderBucket: (items: object[]) => string[] }>}
482
+ */
483
+ const ROUTED_SECTIONS = [
484
+ {
485
+ heading: '### Proposed issues — consumer repo',
486
+ field: 'consumer',
487
+ renderBucket: renderIssueBucket,
488
+ },
489
+ {
490
+ heading: '### Proposed issues — framework repo',
491
+ field: 'framework',
492
+ renderBucket: renderIssueBucket,
493
+ },
494
+ {
495
+ heading: '### Proposed memory updates',
496
+ field: 'memory',
497
+ renderBucket: renderMemoryBucket,
498
+ },
499
+ {
500
+ heading: '### One-off / discarded',
501
+ field: 'discarded',
502
+ renderBucket: renderDiscardedBucket,
503
+ },
504
+ ];
505
+
418
506
  /**
419
507
  * Pure: render the four routed-proposal sections in deterministic order.
420
508
  * Returns `null` when `routedProposals` is absent or fully empty — the
@@ -432,80 +520,23 @@ function renderRoutedSections(routedProposals) {
432
520
  ) {
433
521
  return null;
434
522
  }
435
- const framework = Array.isArray(routedProposals.framework)
436
- ? routedProposals.framework
437
- : [];
438
- const consumer = Array.isArray(routedProposals.consumer)
439
- ? routedProposals.consumer
440
- : [];
441
- const memory = Array.isArray(routedProposals.memory)
442
- ? routedProposals.memory
443
- : [];
444
- const discarded = Array.isArray(routedProposals.discarded)
445
- ? routedProposals.discarded
446
- : [];
447
- if (
448
- framework.length === 0 &&
449
- consumer.length === 0 &&
450
- memory.length === 0 &&
451
- discarded.length === 0
452
- ) {
523
+ const buckets = ROUTED_SECTIONS.map((section) => {
524
+ const items = Array.isArray(routedProposals[section.field])
525
+ ? routedProposals[section.field]
526
+ : [];
527
+ return { section, items };
528
+ });
529
+ if (buckets.every(({ items }) => items.length === 0)) {
453
530
  return null;
454
531
  }
455
532
 
533
+ // Each section renders as `[heading, '', ...body]`; a single blank-line
534
+ // separator sits between consecutive sections (no trailing separator after
535
+ // the last), reproducing the original hand-unrolled push sequence exactly.
456
536
  const out = [];
457
- out.push('### Proposed issues consumer repo');
458
- out.push('');
459
- if (consumer.length === 0) {
460
- out.push('_None._');
461
- } else {
462
- for (const item of consumer) {
463
- out.push(`- **${item.title ?? item.category}**`);
464
- out.push('');
465
- out.push('```sh');
466
- out.push(String(item.command ?? ''));
467
- out.push('```');
468
- out.push('');
469
- }
470
- }
471
- out.push('');
472
- out.push('### Proposed issues — framework repo');
473
- out.push('');
474
- if (framework.length === 0) {
475
- out.push('_None._');
476
- } else {
477
- for (const item of framework) {
478
- out.push(`- **${item.title ?? item.category}**`);
479
- out.push('');
480
- out.push('```sh');
481
- out.push(String(item.command ?? ''));
482
- out.push('```');
483
- out.push('');
484
- }
485
- }
486
- out.push('');
487
- out.push('### Proposed memory updates');
488
- out.push('');
489
- if (memory.length === 0) {
490
- out.push('_None._');
491
- } else {
492
- out.push('update your memory with the following insights:');
493
- out.push('');
494
- for (const m of memory) {
495
- out.push(`- ${m.insight}`);
496
- }
497
- }
498
- out.push('');
499
- out.push('### One-off / discarded');
500
- out.push('');
501
- if (discarded.length === 0) {
502
- out.push('_None._');
503
- } else {
504
- for (const d of discarded) {
505
- out.push(
506
- `- \`${d.category}\` (${d.occurrences ?? 1} occurrence, source: ${d.source ?? 'consumer'})`,
507
- );
508
- }
537
+ for (const { section, items } of buckets) {
538
+ if (out.length > 0) out.push('');
539
+ out.push(section.heading, '', ...section.renderBucket(items));
509
540
  }
510
541
  return out;
511
542
  }
@@ -157,6 +157,45 @@ function sanitizeLabels(labels) {
157
157
  return out.length > 0 ? out : undefined;
158
158
  }
159
159
 
160
+ /**
161
+ * Descriptor table for the structured-body → markdown projection, in
162
+ * canonical emit order (`## Goal`, `## Changes`, `## Acceptance`,
163
+ * `## Verify`). Each descriptor reads one body field and returns the
164
+ * section's markdown block when the field is present and non-empty, or `null`
165
+ * to omit it. Adding a section is a one-line data edit rather than a new
166
+ * branch in {@link renderBody}.
167
+ *
168
+ * @type {Array<{ field: string, render: (value: unknown) => string | null }>}
169
+ */
170
+ const SPEC_BODY_SECTIONS = [
171
+ {
172
+ field: 'goal',
173
+ render: (goal) =>
174
+ typeof goal === 'string' && goal.length > 0 ? `## Goal\n${goal}` : null,
175
+ },
176
+ {
177
+ field: 'changes',
178
+ render: (changes) =>
179
+ Array.isArray(changes) && changes.length > 0
180
+ ? `## Changes\n${changes.map((c) => `- ${String(c)}`).join('\n')}`
181
+ : null,
182
+ },
183
+ {
184
+ field: 'acceptance',
185
+ render: (acceptance) =>
186
+ Array.isArray(acceptance) && acceptance.length > 0
187
+ ? `## Acceptance\n${acceptance.map((a) => `- [ ] ${String(a)}`).join('\n')}`
188
+ : null,
189
+ },
190
+ {
191
+ field: 'verify',
192
+ render: (verify) =>
193
+ Array.isArray(verify) && verify.length > 0
194
+ ? `## Verify\n${verify.map((v) => `- ${String(v)}`).join('\n')}`
195
+ : null,
196
+ },
197
+ ];
198
+
160
199
  /**
161
200
  * Convert a decomposer body value into a spec `body` string. The
162
201
  * decomposer schema admits two shapes for a Story body:
@@ -190,20 +229,9 @@ function renderBody(body) {
190
229
  if (typeof body !== 'object') return undefined;
191
230
 
192
231
  const sections = [];
193
- if (typeof body.goal === 'string' && body.goal.length > 0) {
194
- sections.push(`## Goal\n${body.goal}`);
195
- }
196
- if (Array.isArray(body.changes) && body.changes.length > 0) {
197
- const items = body.changes.map((c) => `- ${String(c)}`).join('\n');
198
- sections.push(`## Changes\n${items}`);
199
- }
200
- if (Array.isArray(body.acceptance) && body.acceptance.length > 0) {
201
- const items = body.acceptance.map((a) => `- [ ] ${String(a)}`).join('\n');
202
- sections.push(`## Acceptance\n${items}`);
203
- }
204
- if (Array.isArray(body.verify) && body.verify.length > 0) {
205
- const items = body.verify.map((v) => `- ${String(v)}`).join('\n');
206
- sections.push(`## Verify\n${items}`);
232
+ for (const descriptor of SPEC_BODY_SECTIONS) {
233
+ const block = descriptor.render(body[descriptor.field]);
234
+ if (block !== null) sections.push(block);
207
235
  }
208
236
  return sections.length > 0 ? sections.join('\n\n') : undefined;
209
237
  }
@@ -56,6 +56,9 @@ import { parseLedger } from './lifecycle/trace-logger.js';
56
56
  * coordinated under a shared identity) and no assignee PATCH ever writes a
57
57
  * literal `[USERNAME]` (HTTP 422).
58
58
  */
59
+ // kept (dead-export allowlist): public config sentinel — the distributed
60
+ // `.agentrc.json` / templates carry this literal; exported so consumers and
61
+ // future call sites resolve it by symbol rather than re-typing the string.
59
62
  export const OPERATOR_HANDLE_PLACEHOLDER = '@[USERNAME]';
60
63
  const OPERATOR_HANDLE_PLACEHOLDER_BARE = '[USERNAME]';
61
64
 
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * QA context hydrator — Story #3805, Epic #3798 (f1-shared-qa-core).
3
3
  *
4
- * Both QA front-ends (`/qa-explore` and `/qa-run-harness`) need to load the
4
+ * Both QA front-ends (`/qa-explore` and `/qa-run`) need to load the
5
5
  * *grounded* surface context for an Epic before they reason about what to test:
6
6
  * the Epic body, its linked context tickets (PRD / Tech Spec / Acceptance
7
7
  * Spec), the project's `.feature` files, the implementation files the surface
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * `qa` contract resolver — Epic #3214, Story #3294.
3
3
  *
4
- * The agent-driven QA harness (`/qa-run-harness`) needs the
4
+ * The agent-driven QA harness (`/qa-run`) needs the
5
5
  * consumer's `.agentrc.json` `qa` block to know where the `.feature` root
6
6
  * lives, how to sign in, and which personas the seam accepts. The block is
7
7
  * *optional in the schema* (most repos never bind the harness, so config
@@ -683,6 +683,108 @@ function serializePathEntry(entry) {
683
683
  return JSON.stringify({ path: entry.path, assumption: entry.assumption });
684
684
  }
685
685
 
686
+ /**
687
+ * Descriptor table for the human-readable Story-body sections, in canonical
688
+ * emit order (`## Goal`, `## Changes`, `## Acceptance`, `## Verify`,
689
+ * `## References`). Each descriptor reads one body field and returns the
690
+ * section's markdown block when the field is present and non-empty, or `null`
691
+ * to omit the section.
692
+ *
693
+ * Standardising the section ladder as a single data table makes adding a new
694
+ * optional section a one-line edit here rather than a new control-flow branch
695
+ * in {@link serialize}.
696
+ *
697
+ * @type {Array<{ field: string, render: (value: unknown) => string | null }>}
698
+ */
699
+ const SERIALIZE_SECTIONS = [
700
+ {
701
+ field: 'goal',
702
+ render: (goal) =>
703
+ typeof goal === 'string' && goal.trim().length > 0
704
+ ? `## Goal\n${goal.trim()}`
705
+ : null,
706
+ },
707
+ {
708
+ field: 'changes',
709
+ render: (changes) =>
710
+ Array.isArray(changes) && changes.length > 0
711
+ ? `## Changes\n${changes.map((c) => `- ${serializePathEntry(c)}`).join('\n')}`
712
+ : null,
713
+ },
714
+ {
715
+ field: 'acceptance',
716
+ render: (acceptance) =>
717
+ Array.isArray(acceptance) && acceptance.length > 0
718
+ ? `## Acceptance\n${acceptance.map((a) => `- [ ] ${a}`).join('\n')}`
719
+ : null,
720
+ },
721
+ {
722
+ field: 'verify',
723
+ render: (verify) =>
724
+ Array.isArray(verify) && verify.length > 0
725
+ ? `## Verify\n${verify.map((v) => `- ${v}`).join('\n')}`
726
+ : null,
727
+ },
728
+ {
729
+ field: 'references',
730
+ render: (references) =>
731
+ Array.isArray(references) && references.length > 0
732
+ ? `## References\n${references.map((r) => `- ${serializePathEntry(r)}`).join('\n')}`
733
+ : null,
734
+ },
735
+ ];
736
+
737
+ /**
738
+ * Build the trailing `<!-- meta: {...} -->` block carrying the fields that
739
+ * have no human-readable section (`wide`, `reason_to_exist`,
740
+ * `estimated_test_files`). Returns the empty string when no meta field is
741
+ * present so {@link serialize} appends nothing.
742
+ *
743
+ * Key insertion order (`wide` → `reason_to_exist` → `estimated_test_files`)
744
+ * is load-bearing: it fixes the serialized JSON byte sequence the parser's
745
+ * meta round-trip and the unit suite assert against.
746
+ *
747
+ * @param {StoryBody} body
748
+ * @returns {string}
749
+ */
750
+ function serializeMetaBlock(body) {
751
+ const metaFields = {};
752
+ const wide = normalizeWide(body.wide);
753
+ if (wide !== null) {
754
+ metaFields.wide = wide;
755
+ }
756
+ const reasonToExist = normalizeReasonToExist(body.reason_to_exist);
757
+ if (reasonToExist !== null) {
758
+ metaFields.reason_to_exist = reasonToExist;
759
+ }
760
+ if (typeof body.estimated_test_files === 'number') {
761
+ metaFields.estimated_test_files = body.estimated_test_files;
762
+ }
763
+ if (Object.keys(metaFields).length === 0) return '';
764
+ return `\n\n<!-- meta: ${JSON.stringify(metaFields)} -->`;
765
+ }
766
+
767
+ /**
768
+ * Build the optional `---` footer block (`parent` / `Epic` / `blocked by`
769
+ * lines). Returns the empty string when `opts.includeFooter` is falsy.
770
+ *
771
+ * @param {StoryBody} body
772
+ * @param {SerializeOptions} opts
773
+ * @returns {string}
774
+ */
775
+ function serializeFooter(body, opts) {
776
+ if (!opts.includeFooter) return '';
777
+ const footerLines = ['---'];
778
+ if (opts.footer?.parent) footerLines.push(`parent: #${opts.footer.parent}`);
779
+ if (opts.footer?.epic) footerLines.push(`Epic: #${opts.footer.epic}`);
780
+ if (Array.isArray(body.depends_on)) {
781
+ for (const dep of body.depends_on) {
782
+ footerLines.push(`blocked by ${dep}`);
783
+ }
784
+ }
785
+ return `\n\n${footerLines.join('\n')}`;
786
+ }
787
+
686
788
  /**
687
789
  * Serialize a structured {@link StoryBody} back to the canonical markdown
688
790
  * format written to GitHub issue bodies.
@@ -707,73 +809,16 @@ export function serialize(body, opts = {}) {
707
809
  }
708
810
 
709
811
  const sections = [];
710
-
711
- // ## Goal
712
- if (typeof body.goal === 'string' && body.goal.trim().length > 0) {
713
- sections.push(`## Goal\n${body.goal.trim()}`);
714
- }
715
-
716
- // ## Changes
717
- if (Array.isArray(body.changes) && body.changes.length > 0) {
718
- const items = body.changes
719
- .map((c) => `- ${serializePathEntry(c)}`)
720
- .join('\n');
721
- sections.push(`## Changes\n${items}`);
722
- }
723
-
724
- // ## Acceptance
725
- if (Array.isArray(body.acceptance) && body.acceptance.length > 0) {
726
- const items = body.acceptance.map((a) => `- [ ] ${a}`).join('\n');
727
- sections.push(`## Acceptance\n${items}`);
728
- }
729
-
730
- // ## Verify
731
- if (Array.isArray(body.verify) && body.verify.length > 0) {
732
- const items = body.verify.map((v) => `- ${v}`).join('\n');
733
- sections.push(`## Verify\n${items}`);
734
- }
735
-
736
- // ## References (only when non-empty)
737
- if (Array.isArray(body.references) && body.references.length > 0) {
738
- const items = body.references
739
- .map((r) => `- ${serializePathEntry(r)}`)
740
- .join('\n');
741
- sections.push(`## References\n${items}`);
812
+ for (const descriptor of SERIALIZE_SECTIONS) {
813
+ const block = descriptor.render(body[descriptor.field]);
814
+ if (block !== null) sections.push(block);
742
815
  }
743
816
 
744
- let out = sections.join('\n\n');
745
-
746
- // Meta block for fields not representable as human-readable sections.
747
- const metaFields = {};
748
- const wide = normalizeWide(body.wide);
749
- if (wide !== null) {
750
- metaFields.wide = wide;
751
- }
752
- const reasonToExist = normalizeReasonToExist(body.reason_to_exist);
753
- if (reasonToExist !== null) {
754
- metaFields.reason_to_exist = reasonToExist;
755
- }
756
- if (typeof body.estimated_test_files === 'number') {
757
- metaFields.estimated_test_files = body.estimated_test_files;
758
- }
759
- if (Object.keys(metaFields).length > 0) {
760
- out += `\n\n<!-- meta: ${JSON.stringify(metaFields)} -->`;
761
- }
762
-
763
- // Footer
764
- if (opts.includeFooter) {
765
- const footerLines = ['---'];
766
- if (opts.footer?.parent) footerLines.push(`parent: #${opts.footer.parent}`);
767
- if (opts.footer?.epic) footerLines.push(`Epic: #${opts.footer.epic}`);
768
- if (Array.isArray(body.depends_on)) {
769
- for (const dep of body.depends_on) {
770
- footerLines.push(`blocked by ${dep}`);
771
- }
772
- }
773
- out += `\n\n${footerLines.join('\n')}`;
774
- }
775
-
776
- return out;
817
+ return (
818
+ sections.join('\n\n') +
819
+ serializeMetaBlock(body) +
820
+ serializeFooter(body, opts)
821
+ );
777
822
  }
778
823
 
779
824
  // ---------------------------------------------------------------------------
@@ -33,20 +33,26 @@ const matchesIntegration = picomatch(INTEGRATION_INCLUDE, { dot: true });
33
33
  * `tests` holds the framework's suite tree; `lib` holds the published CLI
34
34
  * (under `lib/cli` and `lib/migrations`) whose tests are colocated in
35
35
  * `__tests__` directories per the unit-tier convention in
36
- * `rules/testing-standards.md`. Without `lib` here, both the quick /
37
- * integration walk and the full-tier glob set miss the colocated CLI tests,
36
+ * `rules/testing-standards.md`. `.agents/scripts` holds the orchestration
37
+ * engine; some of its modules colocate tests in `__tests__` directories the
38
+ * same way (Story #4195). Without each root here, both the quick /
39
+ * integration walk and the full-tier glob set miss the colocated tests,
38
40
  * leaving that coverage dark in `npm test`. The matching full-tier globs
39
41
  * live in `FULL_TIER_GLOBS`.
40
42
  */
41
- const TEST_WALK_ROOTS = ['tests', 'lib'];
43
+ const TEST_WALK_ROOTS = ['tests', 'lib', '.agents/scripts'];
42
44
 
43
45
  /**
44
46
  * Glob targets for the `full` tier — one per walk root in `TEST_WALK_ROOTS`.
45
- * The `tests` glob is a flat recursive sweep; the `lib` glob is scoped to
46
- * `__tests__` subtrees so it only matches colocated tests, never the shipped
47
- * source modules themselves.
47
+ * The `tests` glob is a flat recursive sweep; the `lib` and `.agents/scripts`
48
+ * globs are scoped to `__tests__` subtrees so they only match colocated
49
+ * tests, never the shipped source modules themselves.
48
50
  */
49
- const FULL_TIER_GLOBS = ['tests/**/*.test.js', 'lib/**/__tests__/**/*.test.js'];
51
+ const FULL_TIER_GLOBS = [
52
+ 'tests/**/*.test.js',
53
+ 'lib/**/__tests__/**/*.test.js',
54
+ '.agents/scripts/**/__tests__/**/*.test.js',
55
+ ];
50
56
 
51
57
  /**
52
58
  * @param {string} dir