mandrel 1.88.0 → 1.90.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 +18 -13
- package/.agents/audit-checklists/architecture.md +24 -0
- package/.agents/audit-checklists/clean-code.md +24 -0
- package/.agents/audit-checklists/dependencies.md +14 -0
- package/.agents/audit-checklists/devops.md +17 -0
- package/.agents/audit-checklists/documentation.md +22 -0
- package/.agents/audit-checklists/lighthouse.md +15 -0
- package/.agents/audit-checklists/navigability.md +14 -0
- package/.agents/audit-checklists/performance.md +22 -0
- package/.agents/audit-checklists/privacy.md +21 -0
- package/.agents/audit-checklists/quality.md +18 -0
- package/.agents/audit-checklists/security.md +22 -0
- package/.agents/audit-checklists/seo.md +16 -0
- package/.agents/audit-checklists/sre.md +24 -0
- package/.agents/audit-checklists/ux-ui.md +21 -0
- package/.agents/docs/SDLC.md +62 -27
- package/.agents/docs/configuration.md +5 -4
- package/.agents/instructions.md +51 -21
- package/.agents/personas/architect.md +10 -7
- package/.agents/personas/engineer.md +4 -3
- package/.agents/personas/project-manager.md +5 -2
- package/.agents/personas/refactorer.md +5 -3
- package/.agents/rules/git-conventions.md +77 -0
- package/.agents/schemas/agentrc.schema.json +10 -6
- package/.agents/schemas/audit-rules.json +16 -2
- package/.agents/schemas/audit-rules.schema.json +7 -6
- package/.agents/schemas/lifecycle/epic.blocked.schema.json +1 -1
- package/.agents/schemas/lifecycle/merge.unlanded.schema.json +39 -0
- package/.agents/schemas/signal-event.schema.json +28 -13
- package/.agents/scripts/acceptance-spec-reconciler.js +6 -4
- package/.agents/scripts/check-context-budget.js +320 -0
- package/.agents/scripts/coverage-capture.js +17 -0
- package/.agents/scripts/diagnose-friction.js +4 -4
- package/.agents/scripts/epic-audit-prepare.js +30 -2
- package/.agents/scripts/epic-audit-recheck.js +46 -13
- package/.agents/scripts/epic-deliver-prepare.js +80 -8
- package/.agents/scripts/epic-plan-spec.js +4 -8
- package/.agents/scripts/generate-lens-checklists.js +180 -0
- package/.agents/scripts/lib/audit-suite/checklist-threading.js +300 -0
- package/.agents/scripts/lib/audit-suite/findings.js +27 -0
- package/.agents/scripts/lib/audit-suite/index.js +9 -0
- package/.agents/scripts/lib/audit-suite/lens-checklist.js +212 -0
- package/.agents/scripts/lib/audit-suite/selector.js +136 -5
- package/.agents/scripts/lib/checks/loop-health.js +340 -0
- package/.agents/scripts/lib/cli-args.js +8 -0
- package/.agents/scripts/lib/close-validation/gates.js +64 -24
- package/.agents/scripts/lib/config/ci.js +12 -1
- package/.agents/scripts/lib/config/runners.js +13 -5
- package/.agents/scripts/lib/config/temp-paths.js +24 -0
- package/.agents/scripts/lib/config-settings-schema-delivery.js +28 -8
- package/.agents/scripts/lib/doc-tiers.js +291 -0
- package/.agents/scripts/lib/epic-body-sections.js +5 -2
- package/.agents/scripts/lib/epic-merge-lock.js +83 -0
- package/.agents/scripts/lib/epic-plan-clarity.js +3 -1
- package/.agents/scripts/lib/feedback-loop/audit-results-graduator.js +47 -15
- package/.agents/scripts/lib/feedback-loop/graduator-core.js +395 -86
- package/.agents/scripts/lib/feedback-loop/memory-freshness.js +299 -72
- package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +438 -0
- package/.agents/scripts/lib/gates/friction.js +15 -5
- package/.agents/scripts/lib/npm-scripts.js +55 -0
- package/.agents/scripts/lib/observability/perf-aggregator.js +30 -104
- package/.agents/scripts/lib/observability/perf-report-readers.js +1 -1
- package/.agents/scripts/lib/observability/signal-validator.js +204 -0
- package/.agents/scripts/lib/observability/signals-writer.js +157 -54
- package/.agents/scripts/lib/observability/tool-trace-hook.js +42 -4
- package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +1 -1
- package/.agents/scripts/lib/orchestration/code-review.js +74 -4
- package/.agents/scripts/lib/orchestration/consolidation-precondition.js +213 -0
- package/.agents/scripts/lib/orchestration/doc-reader.js +4 -96
- package/.agents/scripts/lib/orchestration/docs-digest.js +34 -0
- package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/authoring-context.js +56 -19
- package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/run-spec-phase.js +22 -0
- package/.agents/scripts/lib/orchestration/lifecycle/emit-merge-unlanded.js +193 -0
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/README.md +6 -0
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-armer.js +248 -13
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-predicate.js +109 -12
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/finalizer.js +47 -61
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/index.js +46 -4
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/label-transitioner.js +144 -0
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/merge-watcher.js +258 -14
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/notify-dispatcher.js +6 -0
- package/.agents/scripts/lib/orchestration/merge-block-class.js +246 -0
- package/.agents/scripts/lib/orchestration/plan-review-routing.js +1 -1
- package/.agents/scripts/lib/orchestration/post-merge/phases/worktree-reap.js +3 -3
- package/.agents/scripts/lib/orchestration/retro/phases/compose-body.js +63 -34
- package/.agents/scripts/lib/orchestration/retro/phases/gather-signals.js +167 -52
- package/.agents/scripts/lib/orchestration/retro/phases/post-and-mirror.js +49 -2
- package/.agents/scripts/lib/orchestration/retro-proposals.js +12 -55
- package/.agents/scripts/lib/orchestration/retro-runner.js +9 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +5 -1
- package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +8 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +419 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +35 -2
- package/.agents/scripts/lib/orchestration/single-story-close/phases/wrong-tree-guard.js +353 -69
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +66 -4
- package/.agents/scripts/lib/orchestration/spec-section-validator.js +60 -9
- package/.agents/scripts/lib/orchestration/story-close/auto-refresh-runner.js +7 -5
- package/.agents/scripts/lib/orchestration/story-close/merge-runner.js +24 -2
- package/.agents/scripts/lib/orchestration/story-close/phases/code-review.js +167 -8
- package/.agents/scripts/lib/orchestration/story-close/pre-merge-validation.js +8 -1
- package/.agents/scripts/lib/orchestration/story-close/shared-checkout-guard.js +163 -0
- package/.agents/scripts/lib/orchestration/ticketing/reads.js +20 -9
- package/.agents/scripts/lib/planning-corpus.js +306 -0
- package/.agents/scripts/lib/signals/detectors/common.js +10 -10
- package/.agents/scripts/lib/signals/detectors/index.js +4 -4
- package/.agents/scripts/lib/signals/detectors/retry.js +19 -18
- package/.agents/scripts/lib/signals/detectors/rework.js +1 -1
- package/.agents/scripts/lib/signals/schema.js +56 -81
- package/.agents/scripts/lib/signals/span-tree.js +6 -5
- package/.agents/scripts/lib/story-plan.js +3 -0
- package/.agents/scripts/lib/wave-runner/tick.js +10 -2
- package/.agents/scripts/lifecycle-emit.js +39 -8
- package/.agents/scripts/providers/github/issues.js +12 -1
- package/.agents/scripts/resolve-doc-tiers.js +83 -0
- package/.agents/scripts/retro-run.js +51 -0
- package/.agents/scripts/signals-view.js +1 -1
- package/.agents/scripts/single-story-close.js +20 -1
- package/.agents/scripts/standalone-feedback-rollup.js +188 -0
- package/.agents/scripts/story-close.js +48 -0
- package/.agents/scripts/story-plan.js +51 -12
- package/.agents/scripts/validate-docs-freshness.js +69 -15
- package/.agents/skills/core/documentation-and-adrs/SKILL.md +58 -0
- package/.agents/skills/core/epic-plan-decompose-author/SKILL.md +5 -3
- package/.agents/skills/core/epic-plan-spec-author/SKILL.md +20 -7
- package/.agents/skills/core/scope-triage/SKILL.md +61 -0
- package/.agents/skills/skills.index.json +3 -3
- package/.agents/workflows/audit-documentation.md +82 -2
- package/.agents/workflows/helpers/code-review.md +116 -43
- package/.agents/workflows/helpers/deliver-epic.md +123 -54
- package/.agents/workflows/helpers/deliver-stories.md +26 -0
- package/.agents/workflows/helpers/epic-audit.md +116 -366
- package/.agents/workflows/helpers/epic-deliver-story.md +14 -0
- package/.agents/workflows/helpers/epic-plan-decompose.md +18 -200
- package/.agents/workflows/helpers/epic-plan-spec.md +18 -180
- package/.agents/workflows/helpers/plan-epic.md +141 -105
- package/.agents/workflows/helpers/plan-story.md +32 -0
- package/.agents/workflows/helpers/single-story-deliver.md +43 -0
- package/.agents/workflows/loops/nightly-audit.md +9 -7
- package/docs/CHANGELOG.md +29 -0
- package/lib/cli/doctor.js +44 -0
- package/package.json +4 -3
- package/.agents/scripts/epic-plan-spec-validate.js +0 -111
- package/.agents/scripts/lib/feedback-loop/code-review-graduator.js +0 -224
- package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/prompts.js +0 -58
- package/.agents/scripts/lib/signals/detectors/hotspot.js +0 -292
|
@@ -26,6 +26,37 @@ export function normalizeInterventionCount(value) {
|
|
|
26
26
|
return Math.trunc(value);
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Pure: does `routedProposals` carry at least one actionable item (a
|
|
31
|
+
* framework- or consumer-routed `gh issue create` proposal)? Story #4417 —
|
|
32
|
+
* the compact retro shape (and therefore the `automerge-verdict`
|
|
33
|
+
* `cleanSprint: true` flag it drives) MUST be suppressed whenever an
|
|
34
|
+
* actionable proposal exists, regardless of `forceFull` or the
|
|
35
|
+
* comment-derived counts: the clean-sprint verdict is an auto-merge input
|
|
36
|
+
* and must never read "clean" while actionable friction sits in the routed
|
|
37
|
+
* proposals. Discarded (single-occurrence) items do NOT count — they are
|
|
38
|
+
* the retro's explicit "nothing to file" bucket.
|
|
39
|
+
*
|
|
40
|
+
* @param {{ framework?: object[], consumer?: object[] } | null | undefined} routedProposals
|
|
41
|
+
* @returns {boolean}
|
|
42
|
+
*/
|
|
43
|
+
export function hasActionableProposals(routedProposals) {
|
|
44
|
+
if (
|
|
45
|
+
!routedProposals ||
|
|
46
|
+
typeof routedProposals !== 'object' ||
|
|
47
|
+
Array.isArray(routedProposals)
|
|
48
|
+
) {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
const framework = Array.isArray(routedProposals.framework)
|
|
52
|
+
? routedProposals.framework
|
|
53
|
+
: [];
|
|
54
|
+
const consumer = Array.isArray(routedProposals.consumer)
|
|
55
|
+
? routedProposals.consumer
|
|
56
|
+
: [];
|
|
57
|
+
return framework.length > 0 || consumer.length > 0;
|
|
58
|
+
}
|
|
59
|
+
|
|
29
60
|
/**
|
|
30
61
|
* Pure: derive the recurring-defect-class signal from the routed-proposal
|
|
31
62
|
* sections (Story #4135 / Epic #4131, F11).
|
|
@@ -125,7 +156,15 @@ export function composeRetroBody(input) {
|
|
|
125
156
|
} = input;
|
|
126
157
|
|
|
127
158
|
const interventions = normalizeInterventionCount(counts?.interventions);
|
|
128
|
-
|
|
159
|
+
// Story #4417 — the compact shape requires BOTH a clean manifest AND zero
|
|
160
|
+
// actionable routed proposals. The routed proposals and the counts are
|
|
161
|
+
// derived from the same unified signals scan (gather-signals.js), so this
|
|
162
|
+
// guard closes the window where the comment-derived counts read "clean"
|
|
163
|
+
// while the ndjson-derived routed proposals hold an actionable item.
|
|
164
|
+
const compact =
|
|
165
|
+
!forceFull &&
|
|
166
|
+
isCleanManifest({ ...counts, interventions }) &&
|
|
167
|
+
!hasActionableProposals(routedProposals);
|
|
129
168
|
const heading = `## 🪞 Sprint Retrospective — Epic #${epicId}: ${epicTitle}`;
|
|
130
169
|
const generatedLine = `_Generated ${timestamp}_`;
|
|
131
170
|
const scorecardRows = [
|
|
@@ -214,12 +253,11 @@ export function composeRetroBody(input) {
|
|
|
214
253
|
legacyActionItems.length > 0 ? legacyActionItems.join('\n') : '_None._';
|
|
215
254
|
|
|
216
255
|
// Story #2558 — routed-proposals mode. When routedProposals is supplied
|
|
217
|
-
// AND any of the
|
|
256
|
+
// AND any of the three buckets is non-empty, render the three explicit
|
|
218
257
|
// sections in deterministic order ABOVE the retro-complete marker:
|
|
219
258
|
// 1. Proposed issues — consumer repo
|
|
220
259
|
// 2. Proposed issues — framework repo
|
|
221
|
-
// 3.
|
|
222
|
-
// 4. One-off / discarded
|
|
260
|
+
// 3. One-off / discarded
|
|
223
261
|
// Otherwise the legacy "Action Items for Next Epic" section renders.
|
|
224
262
|
const routedSectionsBlock = renderRoutedSections(routedProposals);
|
|
225
263
|
|
|
@@ -419,7 +457,10 @@ function shellEscape(s) {
|
|
|
419
457
|
* Render the body lines (everything below a section heading and its trailing
|
|
420
458
|
* blank) for a "proposed issues" bucket — the consumer and framework sections
|
|
421
459
|
* share this shape. Empty buckets collapse to a single `_None._`; populated
|
|
422
|
-
* buckets emit
|
|
460
|
+
* buckets emit, per item, either the **filed issue reference** (when the
|
|
461
|
+
* retro auto-filer already filed it — Story #4418, stamped as
|
|
462
|
+
* `item.filedIssue`) or the paste-ready fenced `gh issue create` stanza (the
|
|
463
|
+
* toggle-OFF / filing-skipped fallback).
|
|
423
464
|
*
|
|
424
465
|
* @param {object[]} items
|
|
425
466
|
* @returns {string[]}
|
|
@@ -430,32 +471,21 @@ function renderIssueBucket(items) {
|
|
|
430
471
|
for (const item of items) {
|
|
431
472
|
lines.push(`- **${item.title ?? item.category}**`);
|
|
432
473
|
lines.push('');
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
474
|
+
if (item.filedIssue && item.filedIssue.url) {
|
|
475
|
+
const ref = Number.isInteger(item.filedIssue.number)
|
|
476
|
+
? `#${item.filedIssue.number}`
|
|
477
|
+
: 'issue';
|
|
478
|
+
lines.push(` Filed: [${ref}](${item.filedIssue.url})`);
|
|
479
|
+
} else {
|
|
480
|
+
lines.push('```sh');
|
|
481
|
+
lines.push(String(item.command ?? ''));
|
|
482
|
+
lines.push('```');
|
|
483
|
+
}
|
|
436
484
|
lines.push('');
|
|
437
485
|
}
|
|
438
486
|
return lines;
|
|
439
487
|
}
|
|
440
488
|
|
|
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
489
|
/**
|
|
460
490
|
* Render the body lines for the "one-off / discarded" bucket — one bullet per
|
|
461
491
|
* discarded class naming its occurrence count and source, or `_None._`.
|
|
@@ -472,12 +502,16 @@ function renderDiscardedBucket(items) {
|
|
|
472
502
|
}
|
|
473
503
|
|
|
474
504
|
/**
|
|
475
|
-
* Descriptor table for the
|
|
476
|
-
* emit order (consumer → framework →
|
|
477
|
-
*
|
|
505
|
+
* Descriptor table for the three routed-proposal sections, in deterministic
|
|
506
|
+
* emit order (consumer → framework → discarded). Each descriptor pairs a
|
|
507
|
+
* heading, the `routedProposals` field it reads, and a body renderer.
|
|
478
508
|
* {@link renderRoutedSections} walks the table once, so reordering or adding a
|
|
479
509
|
* section is a data edit here rather than another copy-pasted emit block.
|
|
480
510
|
*
|
|
511
|
+
* The former "Proposed memory updates" section was deleted in the Epic
|
|
512
|
+
* #4406 signal-contract cutover — no writer ever produced the memory-pane
|
|
513
|
+
* records it rendered.
|
|
514
|
+
*
|
|
481
515
|
* @type {Array<{ heading: string, field: string, renderBucket: (items: object[]) => string[] }>}
|
|
482
516
|
*/
|
|
483
517
|
const ROUTED_SECTIONS = [
|
|
@@ -491,11 +525,6 @@ const ROUTED_SECTIONS = [
|
|
|
491
525
|
field: 'framework',
|
|
492
526
|
renderBucket: renderIssueBucket,
|
|
493
527
|
},
|
|
494
|
-
{
|
|
495
|
-
heading: '### Proposed memory updates',
|
|
496
|
-
field: 'memory',
|
|
497
|
-
renderBucket: renderMemoryBucket,
|
|
498
|
-
},
|
|
499
528
|
{
|
|
500
529
|
heading: '### One-off / discarded',
|
|
501
530
|
field: 'discarded',
|
|
@@ -10,8 +10,12 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import { TYPE_LABELS } from '../../../label-constants.js';
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
forEachEpicLine,
|
|
15
|
+
forEachLine,
|
|
16
|
+
} from '../../../observability/signals-writer.js';
|
|
14
17
|
import { concurrentMap } from '../../../util/concurrent-map.js';
|
|
18
|
+
import { read as readEpicRunState } from '../../epic-run-state-store.js';
|
|
15
19
|
import { composeRoutedProposals } from '../../retro-proposals.js';
|
|
16
20
|
import { parseFencedJsonComment } from '../../structured-comment-parser.js';
|
|
17
21
|
import { findStructuredComment } from '../../ticketing.js';
|
|
@@ -38,6 +42,15 @@ const SIGNALS_READ_CONCURRENCY = 8;
|
|
|
38
42
|
// zero-descendant walk is suspicious.
|
|
39
43
|
const EPIC_BODY_REFERENCE = /#\d+/;
|
|
40
44
|
|
|
45
|
+
/**
|
|
46
|
+
* Fallback category stamped on a snapshot-derived blocked event that
|
|
47
|
+
* carries no `category` of its own. A non-empty category is what promotes
|
|
48
|
+
* the event to an actionable routed proposal in `composeRoutedProposals`,
|
|
49
|
+
* so a category-less `agent::blocked` record must still resolve to a
|
|
50
|
+
* concrete bucket rather than being silently dropped.
|
|
51
|
+
*/
|
|
52
|
+
const BLOCKED_EVENT_FALLBACK_CATEGORY = 'agent-blocked';
|
|
53
|
+
|
|
41
54
|
/**
|
|
42
55
|
* Pure: aggregate `frictionByCategory` payloads into a single integer.
|
|
43
56
|
*/
|
|
@@ -50,6 +63,83 @@ function sumFriction(byCategory) {
|
|
|
50
63
|
return total;
|
|
51
64
|
}
|
|
52
65
|
|
|
66
|
+
/**
|
|
67
|
+
* Fold one parsed `signals.ndjson` record into the routed-signal
|
|
68
|
+
* accumulator. Shared by the per-Story and per-Epic scans so both streams
|
|
69
|
+
* contribute to the SAME unified counts that feed the routed proposals AND
|
|
70
|
+
* the compact/full retro decision. Reads the canonical envelope: top-level
|
|
71
|
+
* `category` and the string `source` classifier tag (Epic #4406). Non-object
|
|
72
|
+
* records and records without a `category` contribute nothing.
|
|
73
|
+
*
|
|
74
|
+
* @param {unknown} parsed
|
|
75
|
+
* @param {Array<{ category: string, source: 'framework'|'consumer' }>} routedSignals
|
|
76
|
+
*/
|
|
77
|
+
function accumulateSignalRecord(parsed, routedSignals) {
|
|
78
|
+
if (parsed === null || typeof parsed !== 'object') return;
|
|
79
|
+
const record = /** @type {Record<string, unknown>} */ (parsed);
|
|
80
|
+
const category = typeof record.category === 'string' ? record.category : null;
|
|
81
|
+
if (!category) return;
|
|
82
|
+
const source = record.source === 'framework' ? 'framework' : 'consumer';
|
|
83
|
+
routedSignals.push({ category, source });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Read the `epic-run-state` snapshot and project every Story still recorded
|
|
88
|
+
* as `blocked` into an `unresolvedBlockedEvents` entry for
|
|
89
|
+
* `composeRoutedProposals`. Each event carries a **non-empty** category —
|
|
90
|
+
* the snapshot record's own `category` when present, otherwise the
|
|
91
|
+
* `agent-blocked` fallback — so it force-promotes an actionable proposal
|
|
92
|
+
* even when no friction signal shares its category. Best-effort: a read
|
|
93
|
+
* failure or shapeless snapshot degrades to an empty array (observability
|
|
94
|
+
* MUST NOT take down the retro path).
|
|
95
|
+
*
|
|
96
|
+
* @param {{
|
|
97
|
+
* epicId: number,
|
|
98
|
+
* provider: object,
|
|
99
|
+
* logger?: { warn?: Function },
|
|
100
|
+
* readFn: typeof readEpicRunState,
|
|
101
|
+
* }} args
|
|
102
|
+
* @returns {Promise<Array<{ ticketId?: number, category: string, source: 'framework'|'consumer' }>>}
|
|
103
|
+
*/
|
|
104
|
+
async function readUnresolvedBlockedEvents({
|
|
105
|
+
epicId,
|
|
106
|
+
provider,
|
|
107
|
+
logger,
|
|
108
|
+
readFn,
|
|
109
|
+
}) {
|
|
110
|
+
let snapshot;
|
|
111
|
+
try {
|
|
112
|
+
snapshot = await readFn({ provider, epicId });
|
|
113
|
+
} catch (err) {
|
|
114
|
+
logger?.warn?.(
|
|
115
|
+
`[retro-runner] Failed to read epic-run-state for blocked events (continuing): ${
|
|
116
|
+
err?.message ?? err
|
|
117
|
+
}`,
|
|
118
|
+
);
|
|
119
|
+
return [];
|
|
120
|
+
}
|
|
121
|
+
const storiesMap =
|
|
122
|
+
snapshot && typeof snapshot.stories === 'object' && snapshot.stories
|
|
123
|
+
? snapshot.stories
|
|
124
|
+
: {};
|
|
125
|
+
const events = [];
|
|
126
|
+
for (const [key, record] of Object.entries(storiesMap)) {
|
|
127
|
+
if (!record || typeof record !== 'object' || record.status !== 'blocked') {
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
const rawCategory =
|
|
131
|
+
typeof record.category === 'string' ? record.category.trim() : '';
|
|
132
|
+
const category =
|
|
133
|
+
rawCategory.length > 0 ? rawCategory : BLOCKED_EVENT_FALLBACK_CATEGORY;
|
|
134
|
+
const source = record.source === 'framework' ? 'framework' : 'consumer';
|
|
135
|
+
const ticketId = Number(key);
|
|
136
|
+
const event = { category, source };
|
|
137
|
+
if (Number.isInteger(ticketId) && ticketId > 0) event.ticketId = ticketId;
|
|
138
|
+
events.push(event);
|
|
139
|
+
}
|
|
140
|
+
return events;
|
|
141
|
+
}
|
|
142
|
+
|
|
53
143
|
/**
|
|
54
144
|
* Walk every descendant ticket of `epicId` once. Returns the flat list with
|
|
55
145
|
* each ticket's labels + body + state — the consumer derives its own
|
|
@@ -154,6 +244,8 @@ export async function gatherRetroSignals({
|
|
|
154
244
|
frameworkRepo,
|
|
155
245
|
consumerRepo,
|
|
156
246
|
forEachLineFn = forEachLine,
|
|
247
|
+
forEachEpicLineFn = forEachEpicLine,
|
|
248
|
+
epicRunStateReadFn = readEpicRunState,
|
|
157
249
|
composeRoutedProposalsFn = composeRoutedProposals,
|
|
158
250
|
}) {
|
|
159
251
|
const descendants = await collectDescendants(provider, epicId);
|
|
@@ -232,54 +324,35 @@ export async function gatherRetroSignals({
|
|
|
232
324
|
};
|
|
233
325
|
}
|
|
234
326
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
//
|
|
243
|
-
//
|
|
244
|
-
//
|
|
245
|
-
//
|
|
246
|
-
//
|
|
247
|
-
//
|
|
327
|
+
// Story #4417 — the compact/full retro decision, the automerge-verdict
|
|
328
|
+
// cleanSprint flag, and the routed proposals must all read from ONE
|
|
329
|
+
// signals scan. We fold the per-Story `signals.ndjson` streams AND the
|
|
330
|
+
// per-Epic `signals.ndjson` stream (the `appendEpicSignal` wave-lifecycle
|
|
331
|
+
// writers, e.g. lifecycle-emit) into a single `routedSignals`
|
|
332
|
+
// accumulation; `counts.friction` then derives from that same scan so a
|
|
333
|
+
// retro can never read "clean" from the comment substrate while
|
|
334
|
+
// actionable friction sits unrendered in the ndjson substrate.
|
|
335
|
+
//
|
|
336
|
+
// Story #2558 — the streams are source-tagged by the writer's
|
|
337
|
+
// `tagSignalSource`; `accumulateSignalRecord` reads the canonical
|
|
338
|
+
// envelope (top-level `category` + string `source`, Epic #4406). Read
|
|
339
|
+
// failures degrade silently — observability MUST NOT take down the retro.
|
|
248
340
|
//
|
|
249
|
-
// Story #3347 — the per-Story reads
|
|
250
|
-
//
|
|
251
|
-
//
|
|
252
|
-
//
|
|
253
|
-
// input order so we concatenate the per-Story results in `stories` order.
|
|
254
|
-
// That keeps `routedSignals` / `memorablePatterns` — and therefore the
|
|
255
|
-
// composed `routedProposals` — byte-for-byte identical to the prior
|
|
256
|
-
// serial behaviour, independent of which read settles first.
|
|
341
|
+
// Story #3347 — the per-Story reads fan out via `concurrentMap` with a
|
|
342
|
+
// bounded cap (`SIGNALS_READ_CONCURRENCY`); `concurrentMap` preserves
|
|
343
|
+
// input order so we concatenate the per-Story `routedSignals` in
|
|
344
|
+
// `stories` order, keeping the composed `routedProposals` deterministic.
|
|
257
345
|
const perStorySignals = await concurrentMap(
|
|
258
346
|
stories,
|
|
259
347
|
async (story) => {
|
|
260
348
|
const sid = Number(story.id ?? story.number);
|
|
261
349
|
if (!Number.isInteger(sid) || sid <= 0) {
|
|
262
|
-
return { routedSignals: []
|
|
350
|
+
return { routedSignals: [] };
|
|
263
351
|
}
|
|
264
352
|
const localRoutedSignals = [];
|
|
265
|
-
const localMemorablePatterns = [];
|
|
266
353
|
try {
|
|
267
354
|
await forEachLineFn(epicId, sid, (parsed) => {
|
|
268
|
-
|
|
269
|
-
const record = /** @type {Record<string, unknown>} */ (parsed);
|
|
270
|
-
const category =
|
|
271
|
-
typeof record.category === 'string' ? record.category : null;
|
|
272
|
-
const source =
|
|
273
|
-
record.source === 'framework' ? 'framework' : 'consumer';
|
|
274
|
-
if (category) {
|
|
275
|
-
localRoutedSignals.push({ category, source });
|
|
276
|
-
}
|
|
277
|
-
if (record.memorable === true && typeof record.insight === 'string') {
|
|
278
|
-
localMemorablePatterns.push({
|
|
279
|
-
category: category ?? 'general',
|
|
280
|
-
insight: record.insight,
|
|
281
|
-
});
|
|
282
|
-
}
|
|
355
|
+
accumulateSignalRecord(parsed, localRoutedSignals);
|
|
283
356
|
});
|
|
284
357
|
} catch (err) {
|
|
285
358
|
logger?.warn?.(
|
|
@@ -288,24 +361,60 @@ export async function gatherRetroSignals({
|
|
|
288
361
|
}`,
|
|
289
362
|
);
|
|
290
363
|
}
|
|
291
|
-
return {
|
|
292
|
-
routedSignals: localRoutedSignals,
|
|
293
|
-
memorablePatterns: localMemorablePatterns,
|
|
294
|
-
};
|
|
364
|
+
return { routedSignals: localRoutedSignals };
|
|
295
365
|
},
|
|
296
366
|
{ concurrency: SIGNALS_READ_CONCURRENCY },
|
|
297
367
|
);
|
|
298
368
|
const routedSignals = [];
|
|
299
|
-
const memorablePatterns = [];
|
|
300
369
|
for (const perStory of perStorySignals) {
|
|
301
370
|
routedSignals.push(...perStory.routedSignals);
|
|
302
|
-
memorablePatterns.push(...perStory.memorablePatterns);
|
|
303
371
|
}
|
|
304
372
|
|
|
305
|
-
//
|
|
306
|
-
//
|
|
307
|
-
//
|
|
308
|
-
//
|
|
373
|
+
// Story #4417 — fold in the per-Epic `signals.ndjson` stream
|
|
374
|
+
// (`appendEpicSignal` wave-lifecycle writers, e.g. lifecycle-emit) so the
|
|
375
|
+
// unified scan is not blind to Epic-scoped friction that never lands on
|
|
376
|
+
// an individual Story stream.
|
|
377
|
+
try {
|
|
378
|
+
await forEachEpicLineFn(epicId, (parsed) => {
|
|
379
|
+
accumulateSignalRecord(parsed, routedSignals);
|
|
380
|
+
});
|
|
381
|
+
} catch (err) {
|
|
382
|
+
logger?.warn?.(
|
|
383
|
+
`[retro-runner] forEachEpicLine failed for epic #${epicId} (continuing): ${
|
|
384
|
+
err?.message ?? err
|
|
385
|
+
}`,
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const counts = {
|
|
390
|
+
// `friction` derives from the unified ndjson scan (every categorized
|
|
391
|
+
// signal across the per-Story and per-Epic streams) PLUS the legacy
|
|
392
|
+
// story-perf-summary totals, so a non-empty ndjson substrate forces the
|
|
393
|
+
// full retro even when no `story-perf-summary` comment was posted.
|
|
394
|
+
friction: frictionFromSummaries + routedSignals.length,
|
|
395
|
+
parked: parkedFollowOns.parked.length,
|
|
396
|
+
recuts: parkedFollowOns.recuts.length,
|
|
397
|
+
hitl,
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
// Story #4417 — project unresolved `agent::blocked` Stories from the
|
|
401
|
+
// epic-run-state snapshot into force-flag events for the routed-proposal
|
|
402
|
+
// composer. This replaces the formerly hardwired empty array: a blocked
|
|
403
|
+
// Story now force-promotes an actionable proposal even absent a matching
|
|
404
|
+
// friction category (its category falls back to `agent-blocked`).
|
|
405
|
+
const unresolvedBlockedEvents = await readUnresolvedBlockedEvents({
|
|
406
|
+
epicId,
|
|
407
|
+
provider,
|
|
408
|
+
logger,
|
|
409
|
+
readFn: epicRunStateReadFn,
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
// Resolve repos. The framework repo falls back to the Mandrel mirror
|
|
413
|
+
// constant (a known, stable default). The consumer repo does NOT fall
|
|
414
|
+
// back to the framework repo: when the caller resolves no consumer repo
|
|
415
|
+
// from `config.github`, the consumer proposal pane is DISABLED loudly
|
|
416
|
+
// (Story #4417) rather than silently routing consumer-tagged friction at
|
|
417
|
+
// the framework mirror.
|
|
309
418
|
const resolvedFrameworkRepo =
|
|
310
419
|
typeof frameworkRepo === 'string' && frameworkRepo.length > 0
|
|
311
420
|
? frameworkRepo
|
|
@@ -313,15 +422,21 @@ export async function gatherRetroSignals({
|
|
|
313
422
|
const resolvedConsumerRepo =
|
|
314
423
|
typeof consumerRepo === 'string' && consumerRepo.length > 0
|
|
315
424
|
? consumerRepo
|
|
316
|
-
:
|
|
425
|
+
: '';
|
|
426
|
+
if (resolvedConsumerRepo.length === 0) {
|
|
427
|
+
logger?.warn?.(
|
|
428
|
+
'[retro-runner] No consumer repo resolved from config.github — the ' +
|
|
429
|
+
'consumer proposal pane is DISABLED for this retro. Set ' +
|
|
430
|
+
'github.owner / github.repo to route consumer-tagged friction.',
|
|
431
|
+
);
|
|
432
|
+
}
|
|
317
433
|
|
|
318
434
|
const routedProposals = composeRoutedProposalsFn({
|
|
319
435
|
epicId,
|
|
320
436
|
frameworkRepo: resolvedFrameworkRepo,
|
|
321
437
|
consumerRepo: resolvedConsumerRepo,
|
|
322
438
|
signals: routedSignals,
|
|
323
|
-
unresolvedBlockedEvents
|
|
324
|
-
memorablePatterns,
|
|
439
|
+
unresolvedBlockedEvents,
|
|
325
440
|
});
|
|
326
441
|
|
|
327
442
|
return {
|
|
@@ -14,6 +14,7 @@ import nodeFs from 'node:fs';
|
|
|
14
14
|
import path from 'node:path';
|
|
15
15
|
|
|
16
16
|
import { epicRetroMirrorPath } from '../../../config/temp-paths.js';
|
|
17
|
+
import { fileRetroProposals as defaultFileRetroProposals } from '../../../feedback-loop/retro-proposals-graduator.js';
|
|
17
18
|
import { upsertStructuredComment } from '../../ticketing.js';
|
|
18
19
|
import { appendChecksSection, collectRetroFindings } from './checks.js';
|
|
19
20
|
import {
|
|
@@ -37,9 +38,15 @@ export async function composeAndPostRetro({
|
|
|
37
38
|
bus,
|
|
38
39
|
now,
|
|
39
40
|
manualInterventions,
|
|
41
|
+
frameworkRepo,
|
|
42
|
+
consumerRepo,
|
|
43
|
+
config = null,
|
|
40
44
|
gatherFn = defaultGatherRetroSignals,
|
|
41
45
|
composeFn = defaultComposeRetroBody,
|
|
42
46
|
upsertFn = upsertStructuredComment,
|
|
47
|
+
fileRetroProposalsFn = defaultFileRetroProposals,
|
|
48
|
+
ghPath,
|
|
49
|
+
spawnImpl,
|
|
43
50
|
runChecksFn,
|
|
44
51
|
assembleStateFn,
|
|
45
52
|
cwd,
|
|
@@ -48,7 +55,47 @@ export async function composeAndPostRetro({
|
|
|
48
55
|
onMirrorWritten,
|
|
49
56
|
perfThresholds = null,
|
|
50
57
|
}) {
|
|
51
|
-
const signals = await gatherFn({
|
|
58
|
+
const signals = await gatherFn({
|
|
59
|
+
epicId,
|
|
60
|
+
provider,
|
|
61
|
+
logger,
|
|
62
|
+
frameworkRepo,
|
|
63
|
+
consumerRepo,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// Story #4418 — auto-file the retro's actionable routed proposals BEFORE
|
|
67
|
+
// the body composes, so the rendered retro sections list the real filed
|
|
68
|
+
// issue numbers instead of paste-ready `gh` command stanzas. Runs behind
|
|
69
|
+
// the `delivery.feedbackLoop.retroProposals` toggle (default ON); when
|
|
70
|
+
// OFF (or when filing is skipped / fails) the proposals pass through
|
|
71
|
+
// unenriched and the composer falls back to the command stanzas. Never
|
|
72
|
+
// throws — a filing failure degrades gracefully.
|
|
73
|
+
const { routedProposals: filedRoutedProposals, summary: filingSummary } =
|
|
74
|
+
await fileRetroProposalsFn({
|
|
75
|
+
epicId,
|
|
76
|
+
provider,
|
|
77
|
+
config,
|
|
78
|
+
frameworkRepo,
|
|
79
|
+
consumerRepo,
|
|
80
|
+
routedProposals: signals.routedProposals,
|
|
81
|
+
ghPath,
|
|
82
|
+
spawnImpl,
|
|
83
|
+
cwd,
|
|
84
|
+
logger,
|
|
85
|
+
});
|
|
86
|
+
// Surface (never throw on) per-proposal filing failures. Dropping
|
|
87
|
+
// `summary.errors` silently made a failed `gh issue create` — e.g. a
|
|
88
|
+
// `friction::<category>` label that doesn't exist in the target repo —
|
|
89
|
+
// invisible except as an unenriched command stanza in the retro body.
|
|
90
|
+
if (Array.isArray(filingSummary?.errors) && filingSummary.errors.length) {
|
|
91
|
+
for (const err of filingSummary.errors) {
|
|
92
|
+
logger?.warn?.(
|
|
93
|
+
`[retro] proposal auto-file error (degrading to command stanza): ${
|
|
94
|
+
typeof err === 'string' ? err : JSON.stringify(err)
|
|
95
|
+
}`,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
52
99
|
|
|
53
100
|
// Best-effort fetch of the Epic title for the heading.
|
|
54
101
|
let epicTitle;
|
|
@@ -69,7 +116,7 @@ export async function composeAndPostRetro({
|
|
|
69
116
|
storyPerfSummaries: signals.storyPerfSummaries,
|
|
70
117
|
epicPerfReport: signals.epicPerfReport,
|
|
71
118
|
parkedFollowOns: signals.parkedFollowOns,
|
|
72
|
-
routedProposals:
|
|
119
|
+
routedProposals: filedRoutedProposals,
|
|
73
120
|
timestamp,
|
|
74
121
|
forceFull,
|
|
75
122
|
perfThresholds,
|