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.
- package/.agents/README.md +7 -7
- package/.agents/docs/SDLC.md +4 -5
- package/.agents/docs/configuration.md +9 -9
- package/.agents/docs/workflows.md +4 -6
- package/.agents/schemas/qa-finding.schema.json +1 -1
- package/.agents/scripts/apply-quality-bootstrap.js +79 -0
- package/.agents/scripts/audit-labels-bootstrap.js +52 -30
- package/.agents/scripts/audit-to-stories.js +54 -0
- package/.agents/scripts/bootstrap.js +13 -3
- package/.agents/scripts/generate-config-docs.js +189 -94
- package/.agents/scripts/lib/audit-suite/findings.js +0 -4
- package/.agents/scripts/lib/audit-to-stories/audit-lenses.js +99 -0
- package/.agents/scripts/lib/audit-to-stories/build-story-body.js +13 -5
- package/.agents/scripts/lib/baseline-snapshot.js +163 -4
- package/.agents/scripts/lib/baselines/refresh-service.js +0 -4
- package/.agents/scripts/lib/bootstrap/ci-workflow-template.js +1 -1
- package/.agents/scripts/lib/bootstrap/quality-bootstrap.js +1 -1
- package/.agents/scripts/lib/config/baselines.js +0 -20
- package/.agents/scripts/lib/config/defaults.js +1 -1
- package/.agents/scripts/lib/config/sync-agentrc.js +1 -1
- package/.agents/scripts/lib/config/temp-paths.js +0 -31
- package/.agents/scripts/lib/config-resolver.js +1 -1
- package/.agents/scripts/lib/crap-utils.js +281 -0
- package/.agents/scripts/lib/orchestration/dispatch-engine.js +0 -2
- package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter/composition.js +0 -84
- package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter/signals.js +3 -4
- package/.agents/scripts/lib/orchestration/lifecycle/trace-logger.js +0 -4
- package/.agents/scripts/lib/orchestration/retro/phases/compose-body.js +101 -70
- package/.agents/scripts/lib/orchestration/spec-renderer.js +42 -14
- package/.agents/scripts/lib/orchestration/ticket-lease.js +3 -0
- package/.agents/scripts/lib/qa/qa-context-hydrator.js +1 -1
- package/.agents/scripts/lib/qa/resolve-qa-contract.js +1 -1
- package/.agents/scripts/lib/story-body/story-body.js +110 -65
- package/.agents/scripts/lib/test-tiers.js +13 -7
- package/.agents/scripts/lib/wave-runner/tick.js +177 -53
- package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +226 -0
- package/.agents/scripts/mandrel-update-preflight.js +235 -0
- package/.agents/scripts/providers/github/issues.js +48 -0
- package/.agents/scripts/providers/github.js +1 -0
- package/.agents/scripts/sync-agentrc.js +2 -2
- package/.agents/skills/skills.index.json +2 -2
- package/.agents/skills/stack/qa/playwright-bdd/SKILL.md +3 -3
- package/.agents/skills/stack/qa/qa-harness/SKILL.md +4 -4
- package/.agents/workflows/git-deliver.md +298 -0
- package/.agents/workflows/helpers/epic-testing.md +6 -6
- package/.agents/workflows/helpers/{agents-sync-config.md → mandrel-sync-config.md} +5 -4
- package/.agents/workflows/{agents-update.md → mandrel-update.md} +210 -33
- package/.agents/workflows/qa-explore.md +1 -1
- package/.agents/workflows/{qa-run-harness.md → qa-run.md} +5 -5
- package/README.md +40 -0
- package/docs/CHANGELOG.md +43 -0
- package/lib/cli/registry.js +49 -6
- package/lib/cli/update.js +335 -332
- package/package.json +16 -11
- package/.agents/workflows/git-commit-all.md +0 -15
- package/.agents/workflows/git-pr-all.md +0 -281
- 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
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
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
|
-
|
|
458
|
-
|
|
459
|
-
|
|
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
|
-
|
|
194
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
712
|
-
|
|
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
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
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`.
|
|
37
|
-
*
|
|
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`
|
|
46
|
-
* `__tests__` subtrees so
|
|
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 = [
|
|
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
|