mandrel 1.68.0 → 1.70.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 (75) hide show
  1. package/.agents/README.md +1 -1
  2. package/.agents/docs/agentrc-reference.json +1 -2
  3. package/.agents/docs/configuration.md +2 -4
  4. package/.agents/docs/workflows.md +1 -1
  5. package/.agents/schemas/agentrc.schema.json +1 -5
  6. package/.agents/schemas/lifecycle/epic.automerge.end.schema.json +2 -1
  7. package/.agents/scripts/agents-update-preflight.js +235 -0
  8. package/.agents/scripts/apply-quality-bootstrap.js +79 -0
  9. package/.agents/scripts/audit-labels-bootstrap.js +52 -30
  10. package/.agents/scripts/audit-to-stories.js +54 -0
  11. package/.agents/scripts/bootstrap.js +13 -3
  12. package/.agents/scripts/epic-deliver-preflight.js +30 -13
  13. package/.agents/scripts/epic-deliver-prepare.js +40 -53
  14. package/.agents/scripts/epic-execute-record-wave.js +119 -133
  15. package/.agents/scripts/generate-config-docs.js +189 -94
  16. package/.agents/scripts/lib/audit-suite/findings.js +0 -4
  17. package/.agents/scripts/lib/audit-to-stories/audit-lenses.js +99 -0
  18. package/.agents/scripts/lib/audit-to-stories/build-story-body.js +13 -5
  19. package/.agents/scripts/lib/baseline-snapshot.js +163 -4
  20. package/.agents/scripts/lib/baselines/refresh-service.js +13 -5
  21. package/.agents/scripts/lib/config/baselines.js +0 -20
  22. package/.agents/scripts/lib/config/explain.js +0 -2
  23. package/.agents/scripts/lib/config/limits.js +19 -8
  24. package/.agents/scripts/lib/config/temp-paths.js +0 -31
  25. package/.agents/scripts/lib/config-settings-schema.js +1 -2
  26. package/.agents/scripts/lib/crap-utils.js +281 -0
  27. package/.agents/scripts/lib/maintainability-utils.js +32 -9
  28. package/.agents/scripts/lib/orchestration/dispatch-engine.js +0 -2
  29. package/.agents/scripts/lib/orchestration/epic-cleanup.js +11 -7
  30. package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/cli.js +6 -6
  31. package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/context.js +11 -5
  32. package/.agents/scripts/lib/orchestration/epic-run-state-store.js +203 -110
  33. package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter/composition.js +38 -162
  34. package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter/signals.js +3 -4
  35. package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter/transport.js +16 -13
  36. package/.agents/scripts/lib/orchestration/epic-runner/sub-agent-return.js +10 -7
  37. package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-predicate.js +37 -24
  38. package/.agents/scripts/lib/orchestration/lifecycle/trace-logger.js +0 -4
  39. package/.agents/scripts/lib/orchestration/manifest-builder.js +6 -0
  40. package/.agents/scripts/lib/orchestration/retro/phases/compose-body.js +101 -70
  41. package/.agents/scripts/lib/orchestration/spec-renderer.js +42 -14
  42. package/.agents/scripts/lib/orchestration/ticket-lease.js +3 -0
  43. package/.agents/scripts/lib/orchestration/ticket-validator-sizing.js +6 -2
  44. package/.agents/scripts/lib/orchestration/wave-record-io.js +18 -77
  45. package/.agents/scripts/lib/orchestration/wave-record-notifications.js +78 -122
  46. package/.agents/scripts/lib/orchestration/wave-record-projection.js +21 -226
  47. package/.agents/scripts/lib/presentation/dispatch-manifest-render.js +18 -1
  48. package/.agents/scripts/lib/presentation/manifest-render-waves.js +77 -4
  49. package/.agents/scripts/lib/story-adjacency.js +14 -10
  50. package/.agents/scripts/lib/story-body/story-body.js +142 -65
  51. package/.agents/scripts/lib/templates/decomposer-prompts.js +23 -3
  52. package/.agents/scripts/lib/test-tiers.js +13 -7
  53. package/.agents/scripts/lib/wave-runner/ready-set.js +295 -0
  54. package/.agents/scripts/lib/wave-runner/tick.js +446 -216
  55. package/.agents/scripts/lib/wave-runner/wave-runner-error.js +2 -1
  56. package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +226 -0
  57. package/.agents/scripts/lint-label-vocabulary.js +1 -1
  58. package/.agents/scripts/providers/github/issues.js +48 -0
  59. package/.agents/scripts/providers/github.js +1 -0
  60. package/.agents/scripts/stories-wave-tick.js +262 -161
  61. package/.agents/skills/core/epic-plan-consolidate/SKILL.md +6 -0
  62. package/.agents/skills/core/epic-plan-decompose-author/SKILL.md +108 -101
  63. package/.agents/skills/skills.index.json +2 -2
  64. package/.agents/workflows/agents-update.md +205 -28
  65. package/.agents/workflows/deliver.md +12 -9
  66. package/.agents/workflows/helpers/deliver-epic.md +126 -90
  67. package/.agents/workflows/helpers/deliver-stories.md +131 -85
  68. package/.agents/workflows/helpers/plan-epic.md +13 -10
  69. package/.agents/workflows/plan.md +1 -1
  70. package/README.md +20 -0
  71. package/docs/CHANGELOG.md +46 -0
  72. package/lib/cli/registry.js +49 -6
  73. package/lib/cli/update.js +335 -332
  74. package/package.json +16 -11
  75. package/.agents/scripts/lib/wave-runner/wave-checkpoint.js +0 -91
@@ -67,10 +67,9 @@ export const PHASE_ORDER = [
67
67
  ];
68
68
 
69
69
  /**
70
- * Emoji prefix per high-level state. Shared between the per-poll table
71
- * (`composition.renderProgressTable`) and the rolled-up Epic table
72
- * (`composition.upsertEpicRunProgress`) so operators see the same icon
73
- * in both surfaces.
70
+ * Emoji prefix per high-level state. Consumed by the rolled-up Epic table
71
+ * (`composition.upsertEpicRunProgress`) so operators see a consistent icon
72
+ * per state across the progress surface.
74
73
  */
75
74
  export const STATE_EMOJI = {
76
75
  done: '✅',
@@ -38,10 +38,10 @@ export const EPIC_PROGRESS_EVENT = 'epic-progress';
38
38
 
39
39
  /**
40
40
  * Fire a curated `epic-progress` webhook event. Event-driven only — called
41
- * at wave boundaries and after blocker raise/clear transitions. Carries
42
- * the rollup payload `{ pct, done, total, currentWave, totalWaves, phase,
43
- * openBlockers }`, which Slack consumers and downstream subscribers use to
44
- * track epic progress without subscribing to per-story chatter.
41
+ * per recorder beat and after blocker raise/clear transitions. Carries the
42
+ * rollup payload `{ pct, done, total, phase, openBlockers }`, which Slack
43
+ * consumers and downstream subscribers use to track epic progress without
44
+ * subscribing to per-story chatter.
45
45
  *
46
46
  * The dispatch passes `skipComment: true` — the operator-facing GitHub
47
47
  * comment is owned by `ProgressReporter.fire()` and `upsertEpicRunProgress`,
@@ -50,13 +50,17 @@ export const EPIC_PROGRESS_EVENT = 'epic-progress';
50
50
  * Failures are swallowed by design: the runner must keep moving even if
51
51
  * the webhook URL is misconfigured or the network is flaky.
52
52
  *
53
+ * Story #4155 — the Epic `/deliver` runtime cut over to the continuous
54
+ * ready-set scheduler, which has **no wave index**. The wave segment was
55
+ * dropped from the message and the rollup payload entirely (rather than
56
+ * rendered as `Wave undefined/undefined`); the sole live caller
57
+ * (`wave-record-notifications.js`) never supplied wave coordinates.
58
+ *
53
59
  * @param {{
54
60
  * notify: Function|null,
55
61
  * epicId: number,
56
62
  * done: number,
57
63
  * total: number,
58
- * currentWave: number,
59
- * totalWaves: number,
60
64
  * phase?: string,
61
65
  * openBlockers?: Array<{ reason: string, storyId?: number }>,
62
66
  * logger?: { warn?: Function },
@@ -68,8 +72,6 @@ export async function emitEpicProgress({
68
72
  epicId,
69
73
  done,
70
74
  total,
71
- currentWave,
72
- totalWaves,
73
75
  phase,
74
76
  openBlockers = [],
75
77
  logger,
@@ -85,7 +87,7 @@ export async function emitEpicProgress({
85
87
  blockerCount > 0
86
88
  ? ` · 🚧 ${blockerCount} blocker${blockerCount === 1 ? '' : 's'}`
87
89
  : '';
88
- const message = `Epic #${epicIdNum} progress · Wave ${currentWave}/${totalWaves} · ${doneN}/${totalN} stories done (${pct}%)${blockerSuffix}`;
90
+ const message = `Epic #${epicIdNum} progress · ${doneN}/${totalN} stories done (${pct}%)${blockerSuffix}`;
89
91
 
90
92
  const payload = {
91
93
  severity: blockerCount > 0 ? 'high' : 'medium',
@@ -109,8 +111,6 @@ export async function emitEpicProgress({
109
111
  pct,
110
112
  done: doneN,
111
113
  total: totalN,
112
- currentWave,
113
- totalWaves,
114
114
  phase,
115
115
  openBlockers: openBlockers ?? [],
116
116
  },
@@ -121,11 +121,14 @@ export async function emitEpicProgress({
121
121
  * Fire a curated `epic-started` webhook event at /deliver kickoff.
122
122
  * The Slack consumer anchors the rest of the epic narrative to this fire.
123
123
  * Failures are swallowed.
124
+ *
125
+ * Story #4155 — the ready-set runtime has no wave count, so the wave
126
+ * segment was dropped from the message (rather than rendered as
127
+ * `undefined wave(s)`); the sole live caller never supplied one.
124
128
  */
125
129
  export async function emitEpicStarted({
126
130
  notify,
127
131
  epicId,
128
- totalWaves,
129
132
  totalStories,
130
133
  title,
131
134
  logger,
@@ -133,7 +136,7 @@ export async function emitEpicStarted({
133
136
  if (typeof notify !== 'function') return null;
134
137
  const epicIdNum = Number(epicId);
135
138
  if (!Number.isInteger(epicIdNum) || epicIdNum <= 0) return null;
136
- const message = `Epic #${epicIdNum} started · ${totalWaves} wave${totalWaves === 1 ? '' : 's'} · ${totalStories} stor${totalStories === 1 ? 'y' : 'ies'}${title ? ` — ${title}` : ''}`;
139
+ const message = `Epic #${epicIdNum} started · ${totalStories} stor${totalStories === 1 ? 'y' : 'ies'}${title ? ` — ${title}` : ''}`;
137
140
  try {
138
141
  await notify(
139
142
  epicIdNum,
@@ -241,26 +241,29 @@ export async function reconcileStoryFromGitHub({ provider, storyId } = {}) {
241
241
 
242
242
  /**
243
243
  * Render a single friction-comment body listing every malformed sub-agent
244
- * return for a given wave. Pure helper — no provider call. Exposed so tests
245
- * can pin the body shape.
244
+ * return for a recorder beat. Pure helper — no provider call. Exposed so
245
+ * tests can pin the body shape.
246
+ *
247
+ * Story #4155 — under the ready-set runtime there is no wave index; the
248
+ * recorder records the Stories it was handed, so the body is keyed by Epic
249
+ * only.
246
250
  *
247
251
  * @param {{
248
252
  * epicId: number,
249
- * wave: number,
250
253
  * failures: Array<{ storyId: number, error: string, returnText: string }>,
251
254
  * }} args
252
255
  * @returns {string}
253
256
  */
254
- export function renderMalformedReturnsFriction({ epicId, wave, failures }) {
257
+ export function renderMalformedReturnsFriction({ epicId, failures }) {
255
258
  const lines = [
256
- `### 🚧 epic-execute friction — Epic #${epicId}, wave ${wave}`,
259
+ `### 🚧 epic-execute friction — Epic #${epicId}`,
257
260
  '',
258
261
  `**Reason:** \`malformed-subagent-return\``,
259
262
  '',
260
263
  `${failures.length} sub-agent return(s) did not match the /deliver return contract.`,
261
264
  'Each Story below was reconciled from GitHub (labels + `story-run-progress`)',
262
- 'and its wave-row downgraded to `failed` unless the live ticket already carried',
263
- '`agent::done`.',
265
+ 'and its recorded status downgraded to `failed` unless the live ticket',
266
+ 'already carried `agent::done`.',
264
267
  '',
265
268
  ];
266
269
  for (const f of failures) {
@@ -21,7 +21,7 @@
21
21
  * is a hard block evaluated BEFORE the structured-signal evaluator.
22
22
  *
23
23
  * Either trigger evaluates the same verdict: if `evaluateAutoMergePredicate`
24
- * reports `clean: true` (no manual interventions, no incomplete waves,
24
+ * reports `clean: true` (no manual interventions, every Story done,
25
25
  * no story blockers, no critical/high review findings, machine-readable
26
26
  * "clean sprint" retro trailer), emit `epic.merge.ready`. Otherwise emit
27
27
  * `epic.merge.blocked` with a non-empty reason.
@@ -172,38 +172,51 @@ function evaluateStateSignals(state, reasons) {
172
172
  .join('; ')}${interventionCount > 3 ? '; …' : ''}`,
173
173
  );
174
174
  }
175
- const waves = Array.isArray(state?.waves) ? state.waves : [];
176
- const waveStatuses = waves.map((w) => w.status ?? 'unknown');
177
- const nonCompleteWaves = waveStatuses.filter((s) => s !== 'complete');
178
- if (nonCompleteWaves.length > 0) {
175
+ // Story #4155 the ready-set runtime records a flat per-Story status map
176
+ // on the checkpoint (`stories: { [id]: { status, blockerCommentId? } }`)
177
+ // instead of a per-wave `waves[]` history. The clean-run certification
178
+ // reads it directly: a run is clean only when every Story reached `done`
179
+ // and none carries a recorded blocker comment.
180
+ const stories =
181
+ state?.stories && typeof state.stories === 'object' ? state.stories : {};
182
+ const storyStatuses = Object.values(stories).map(
183
+ (s) => s?.status ?? 'pending',
184
+ );
185
+ const nonDoneStatuses = storyStatuses.filter((s) => s !== 'done');
186
+ if (nonDoneStatuses.length > 0) {
179
187
  reasons.push(
180
- `${nonCompleteWaves.length} wave(s) not complete (statuses: ${nonCompleteWaves.join(', ')})`,
188
+ `${nonDoneStatuses.length} story(ies) not done (statuses: ${nonDoneStatuses.join(', ')})`,
181
189
  );
182
190
  }
183
- const storyBlockers = countStoryBlockers(waves);
191
+ const storyBlockers = countStoryBlockers(stories);
184
192
  if (storyBlockers > 0) {
185
193
  reasons.push(
186
194
  `${storyBlockers} story-level blocker(s) recorded in run-state`,
187
195
  );
188
196
  }
189
- return { interventionCount, waveStatuses, storyBlockers };
197
+ return { interventionCount, storyStatuses, storyBlockers };
190
198
  }
191
199
 
192
- function countStoryBlockers(waves) {
200
+ /**
201
+ * Count blockers in the flat per-Story `stories` status map: each Story with
202
+ * a recorded `blockerCommentId` and each Story whose status is not `done`
203
+ * contributes one blocker (matching the prior per-wave count semantics).
204
+ *
205
+ * @param {Record<string, { status?: string, blockerCommentId?: string }>} stories
206
+ * @returns {number}
207
+ */
208
+ function countStoryBlockers(stories) {
193
209
  let blockers = 0;
194
- for (const w of waves) {
195
- if (!Array.isArray(w.stories)) continue;
196
- for (const s of w.stories) {
197
- if (
198
- s &&
199
- typeof s.blockerCommentId === 'string' &&
200
- s.blockerCommentId.length > 0
201
- ) {
202
- blockers += 1;
203
- }
204
- if (s?.status && s.status !== 'done') {
205
- blockers += 1;
206
- }
210
+ for (const s of Object.values(stories ?? {})) {
211
+ if (
212
+ s &&
213
+ typeof s.blockerCommentId === 'string' &&
214
+ s.blockerCommentId.length > 0
215
+ ) {
216
+ blockers += 1;
217
+ }
218
+ if (s?.status && s.status !== 'done') {
219
+ blockers += 1;
207
220
  }
208
221
  }
209
222
  return blockers;
@@ -271,7 +284,7 @@ function evaluateRetroSignals(retro, reasons) {
271
284
  * reasons: string[],
272
285
  * signals: {
273
286
  * manualInterventions: number,
274
- * waveStatuses: string[],
287
+ * storyStatuses: string[],
275
288
  * storyBlockers: number,
276
289
  * severity: { critical: number|null, high: number|null, medium: number|null, suggestion: number|null },
277
290
  * retroCompact: boolean,
@@ -292,7 +305,7 @@ export function deriveAutoMergeVerdict({ state, codeReview, retro }) {
292
305
  reasons,
293
306
  signals: {
294
307
  manualInterventions: stateSig.interventionCount,
295
- waveStatuses: stateSig.waveStatuses,
308
+ storyStatuses: stateSig.storyStatuses,
296
309
  storyBlockers: stateSig.storyBlockers,
297
310
  severity: reviewSig.severity,
298
311
  retroCompact: retroSig.retroCompact,
@@ -353,7 +353,3 @@ export class TraceLogger {
353
353
  });
354
354
  }
355
355
  }
356
-
357
- export function createTraceLogger(opts) {
358
- return new TraceLogger(opts);
359
- }
@@ -139,6 +139,12 @@ function buildStoryOnlyManifest(stories, epicId) {
139
139
  type: 'story',
140
140
  branchName: getStoryBranch(epicId, story.id),
141
141
  earliestWave,
142
+ // Carry the resolved cross-Story dependency edges on the entry so the
143
+ // presentation layer can derive grouping depth at render time via
144
+ // `assignLayers` (Story #4157) instead of trusting the persisted
145
+ // `earliestWave`. This is the same `explicitStoryDeps` set the wave
146
+ // computation consumed, already closed over the scheduled Story set.
147
+ dependsOn: explicitStoryDeps.get(story.id) ?? [],
142
148
  tasks: [],
143
149
  };
144
150
  });
@@ -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
 
@@ -18,7 +18,7 @@
18
18
  *
19
19
  * Sizing model (Story #3760 — profile-matrix collapse; Story #3874 — one
20
20
  * uniform relaxed profile):
21
- * - Flat knobs: `softFiles` (~8), `hardFiles` (~30), `maxAcceptance` (~14),
21
+ * - Flat knobs: `softFiles` (~15), `hardFiles` (~30), `maxAcceptance` (~14),
22
22
  * `softAcceptanceCount` (~10). No per-profile ceiling map, no parallel
23
23
  * `testSurface` axis, no selector and no second profile.
24
24
  * - The four-profile `sizingProfile` enum is replaced by a single optional
@@ -32,7 +32,11 @@
32
32
 
33
33
  export const DEFAULT_TASK_SIZING = Object.freeze({
34
34
  // Typical-Story warning thresholds (soft — emit advisory findings).
35
- softFiles: 8,
35
+ // Story #4162 raised `softFiles` 8 → 15: a capability-sized Story routinely
36
+ // touches a dozen-plus files for one cohesive reason, so the advisory width
37
+ // nudge fired far too eagerly and biased the decomposer toward over-slicing.
38
+ // The hard `hardFiles` rejection (30) is unchanged.
39
+ softFiles: 15,
36
40
  softAcceptanceCount: 10,
37
41
  // Hard ceilings (rejection unless lifted).
38
42
  hardFiles: 30,