mandrel 1.66.0 → 1.68.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/docs/configuration.md +7 -0
- package/.agents/docs/workflows.md +2 -1
- package/.agents/personas/engineer.md +27 -0
- package/.agents/schemas/agentrc.schema.json +38 -0
- package/.agents/schemas/audit-rules.json +12 -0
- package/.agents/scripts/epic-audit-prepare.js +53 -3
- package/.agents/scripts/epic-plan-healthcheck.js +191 -2
- package/.agents/scripts/lib/audit-suite/index.js +5 -0
- package/.agents/scripts/lib/audit-suite/selector.js +73 -0
- package/.agents/scripts/lib/config-settings-schema-delivery.js +17 -0
- package/.agents/scripts/lib/config-settings-schema-quality.js +13 -0
- package/.agents/scripts/lib/config-settings-schema.js +22 -0
- package/.agents/scripts/lib/feedback-loop/prior-feedback-fetcher.js +82 -0
- package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/authoring-context.js +43 -18
- package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/run-spec-phase.js +32 -1
- package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/spec-authoring-grounding.js +147 -0
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/watcher.js +13 -8
- package/.agents/scripts/lib/orchestration/planning-risk.js +45 -6
- package/.agents/scripts/lib/orchestration/retro/phases/compose-body.js +63 -0
- package/.agents/scripts/lib/orchestration/retro/phases/post-and-mirror.js +11 -0
- package/.agents/scripts/lib/orchestration/retro-runner.js +19 -2
- package/.agents/scripts/lib/orchestration/spec-freshness.js +2 -2
- package/.agents/skills/core/epic-plan-decompose-author/SKILL.md +17 -0
- package/.agents/skills/core/epic-plan-premortem/SKILL.md +138 -0
- package/.agents/skills/core/epic-plan-spec-author/SKILL.md +15 -0
- package/.agents/skills/skills.index.json +12 -2
- package/.agents/workflows/audit-navigability.md +129 -0
- package/.agents/workflows/helpers/deliver-epic.md +116 -1
- package/.agents/workflows/helpers/plan-epic.md +60 -5
- package/docs/CHANGELOG.md +19 -0
- package/package.json +1 -1
|
@@ -12,6 +12,16 @@
|
|
|
12
12
|
* Tests inject a `spawnImpl` (or shape-compatible `execImpl`) to exercise
|
|
13
13
|
* the gh-exec surface deterministically; production code defaults to
|
|
14
14
|
* `child_process.spawn`.
|
|
15
|
+
*
|
|
16
|
+
* Story #4135 (Epic #4131, F11) — the envelope additionally carries a
|
|
17
|
+
* `recurringDefectClasses[]` array derived from the `friction::<class>`
|
|
18
|
+
* labels the retro routed-proposals composer stamps onto the meta issues it
|
|
19
|
+
* proposes. That closes the retro→planner loop: a recurring defect class
|
|
20
|
+
* caught by review/deliver is filed as a `meta::*` + `friction::<class>`
|
|
21
|
+
* issue, and the next `/plan` Phase 0 surfaces the class (with a recurrence
|
|
22
|
+
* count across the open feedback issues) to the decompose-author guidance so
|
|
23
|
+
* the planning floor ratchets up. The derivation is no-op-safe: issues with
|
|
24
|
+
* no `friction::*` label contribute nothing and the array is empty.
|
|
15
25
|
*/
|
|
16
26
|
|
|
17
27
|
import { META_LABELS } from '../label-constants.js';
|
|
@@ -19,6 +29,66 @@ import { runChild } from './graduator-core.js';
|
|
|
19
29
|
|
|
20
30
|
const DEFAULT_LIMIT = 50;
|
|
21
31
|
|
|
32
|
+
/** Prefix stamped on routed-proposal issue labels by the retro composer. */
|
|
33
|
+
const FRICTION_LABEL_PREFIX = 'friction::';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Pure: derive recurring-defect-class signals from a list of normalized
|
|
37
|
+
* issues by counting `friction::<class>` labels across them (Story #4135).
|
|
38
|
+
*
|
|
39
|
+
* Each fetched meta issue may carry one `friction::<class>` label (stamped by
|
|
40
|
+
* the retro routed-proposals composer when it proposed the issue). A class
|
|
41
|
+
* that appears across **multiple** open feedback issues is recurring across
|
|
42
|
+
* Epics — exactly the signal F11 surfaces to the planner. The count is the
|
|
43
|
+
* number of distinct open issues carrying the class; the `issues[]` array
|
|
44
|
+
* lists their numbers so the planner can cross-reference.
|
|
45
|
+
*
|
|
46
|
+
* Determinism: classes are sorted by descending recurrence count, ties
|
|
47
|
+
* broken by class name ASC, so a given input always yields a stable order.
|
|
48
|
+
*
|
|
49
|
+
* No-op-safe: issues without a `friction::*` label contribute nothing; an
|
|
50
|
+
* empty / non-array input yields `[]`.
|
|
51
|
+
*
|
|
52
|
+
* @param {Array<{ number: number, labels?: string[] }>} issues
|
|
53
|
+
* @returns {Array<{ class: string, count: number, issues: number[] }>}
|
|
54
|
+
*/
|
|
55
|
+
export function extractRecurringDefectClasses(issues) {
|
|
56
|
+
if (!Array.isArray(issues)) return [];
|
|
57
|
+
/** @type {Map<string, Set<number>>} */
|
|
58
|
+
const byClass = new Map();
|
|
59
|
+
for (const issue of issues) {
|
|
60
|
+
if (!issue || typeof issue !== 'object') continue;
|
|
61
|
+
const number = typeof issue.number === 'number' ? issue.number : null;
|
|
62
|
+
const labels = Array.isArray(issue.labels) ? issue.labels : [];
|
|
63
|
+
for (const label of labels) {
|
|
64
|
+
if (
|
|
65
|
+
typeof label !== 'string' ||
|
|
66
|
+
!label.startsWith(FRICTION_LABEL_PREFIX)
|
|
67
|
+
) {
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
const cls = label.slice(FRICTION_LABEL_PREFIX.length).trim();
|
|
71
|
+
if (cls.length === 0) continue;
|
|
72
|
+
let set = byClass.get(cls);
|
|
73
|
+
if (!set) {
|
|
74
|
+
set = new Set();
|
|
75
|
+
byClass.set(cls, set);
|
|
76
|
+
}
|
|
77
|
+
if (number !== null) set.add(number);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const out = [];
|
|
81
|
+
for (const [cls, set] of byClass) {
|
|
82
|
+
out.push({
|
|
83
|
+
class: cls,
|
|
84
|
+
count: set.size,
|
|
85
|
+
issues: [...set].sort((a, b) => a - b),
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
out.sort((a, b) => b.count - a.count || a.class.localeCompare(b.class));
|
|
89
|
+
return out;
|
|
90
|
+
}
|
|
91
|
+
|
|
22
92
|
/**
|
|
23
93
|
* Spawn the given gh CLI with the supplied args and resolve to
|
|
24
94
|
* `{ code, stdout, stderr, spawnError }`. Delegates to the shared
|
|
@@ -157,6 +227,7 @@ async function fetchByLabel({ owner, repo, label, ghPath, limit, spawnImpl }) {
|
|
|
157
227
|
* @returns {Promise<{
|
|
158
228
|
* frameworkGaps: object[],
|
|
159
229
|
* consumerImprovements: object[],
|
|
230
|
+
* recurringDefectClasses: Array<{ class: string, count: number, issues: number[] }>,
|
|
160
231
|
* fetchedAt: string,
|
|
161
232
|
* errors: string[],
|
|
162
233
|
* }>}
|
|
@@ -180,6 +251,7 @@ export async function fetchPriorFeedback({
|
|
|
180
251
|
const envelope = {
|
|
181
252
|
frameworkGaps: [],
|
|
182
253
|
consumerImprovements: [],
|
|
254
|
+
recurringDefectClasses: [],
|
|
183
255
|
fetchedAt: new Date().toISOString(),
|
|
184
256
|
errors,
|
|
185
257
|
};
|
|
@@ -223,6 +295,16 @@ export async function fetchPriorFeedback({
|
|
|
223
295
|
envelope.consumerImprovements.push(issue);
|
|
224
296
|
}
|
|
225
297
|
|
|
298
|
+
// Story #4135 (Epic #4131, F11) — close the retro→planner loop: derive the
|
|
299
|
+
// recurring defect classes from the `friction::<class>` labels carried by
|
|
300
|
+
// the deduped feedback issues, so the next /plan Phase 0 surfaces them to
|
|
301
|
+
// the decompose-author guidance. No-op-safe when no issue carries a
|
|
302
|
+
// `friction::*` label (empty array, no behavioural change).
|
|
303
|
+
envelope.recurringDefectClasses = extractRecurringDefectClasses([
|
|
304
|
+
...envelope.frameworkGaps,
|
|
305
|
+
...envelope.consumerImprovements,
|
|
306
|
+
]);
|
|
307
|
+
|
|
226
308
|
return envelope;
|
|
227
309
|
}
|
|
228
310
|
|
|
@@ -21,11 +21,13 @@ import { fetchPriorFeedback } from '../../../feedback-loop/prior-feedback-fetche
|
|
|
21
21
|
import { Logger } from '../../../Logger.js';
|
|
22
22
|
import { buildDocsContext } from '../../doc-reader.js';
|
|
23
23
|
import { applyBudget } from '../../planning-context-budget.js';
|
|
24
|
+
import { collectReferences, hasNewFileCue } from '../../spec-freshness.js';
|
|
24
25
|
import {
|
|
25
26
|
ACCEPTANCE_SPEC_SYSTEM_PROMPT,
|
|
26
27
|
PRD_SYSTEM_PROMPT,
|
|
27
28
|
TECH_SPEC_SYSTEM_PROMPT,
|
|
28
29
|
} from './prompts.js';
|
|
30
|
+
import { buildAuthoringGrounding } from './spec-authoring-grounding.js';
|
|
29
31
|
|
|
30
32
|
/**
|
|
31
33
|
* Resolve the per-project memory directory used by the memory-freshness
|
|
@@ -146,24 +148,47 @@ export async function buildAuthoringContext(
|
|
|
146
148
|
recentCommitWindow:
|
|
147
149
|
settings?.planning?.codebaseSnapshot?.recentCommitWindow,
|
|
148
150
|
});
|
|
149
|
-
// Story #
|
|
150
|
-
//
|
|
151
|
-
//
|
|
152
|
-
//
|
|
153
|
-
//
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
151
|
+
// Story #4139 (F10) — ground the spec author in the files it will cite.
|
|
152
|
+
// Two signals are attached to the snapshot envelope so the author (which
|
|
153
|
+
// consumes the JSON, not stderr) cannot miss them:
|
|
154
|
+
// 1. `grounding.truncation` — the structured, in-envelope form of the
|
|
155
|
+
// Story #3959 dropped-file warning. The skinny-tier cap used to drop
|
|
156
|
+
// the majority of matched files with only a stderr `Logger.warn` and
|
|
157
|
+
// a bare `truncated: true` flag; the author never learned the
|
|
158
|
+
// snapshot was partial (a real run dropped "377 of 627 files").
|
|
159
|
+
// 2. `grounding.citedButAbsent` — path-shaped references in the Epic
|
|
160
|
+
// body (the prose the author grounds *from*) that are absent from
|
|
161
|
+
// the snapshot's file set and not phrased as net-new, so cited-but-
|
|
162
|
+
// absent surfaces are visible *during* authoring rather than only
|
|
163
|
+
// after the post-author freshness gate (Story #2635).
|
|
164
|
+
// The grounding consults only the snapshot's file set and the Epic body —
|
|
165
|
+
// no new filesystem or git probes — so the context stays bounded for cost.
|
|
166
|
+
if (codebaseSnapshot) {
|
|
167
|
+
const grounding = buildAuthoringGrounding({
|
|
168
|
+
snapshot: codebaseSnapshot,
|
|
169
|
+
prose: epic.body ?? '',
|
|
170
|
+
collectReferences,
|
|
171
|
+
hasNewFileCue,
|
|
172
|
+
});
|
|
173
|
+
codebaseSnapshot.grounding = grounding;
|
|
174
|
+
if (grounding.truncation) {
|
|
175
|
+
const { dropped, matched, tier } = grounding.truncation;
|
|
176
|
+
Logger.warn(
|
|
177
|
+
`[epic-plan-spec] codebase snapshot truncated: ${dropped} of ` +
|
|
178
|
+
`${matched} matched file(s) dropped from the ${tier}-tier view. ` +
|
|
179
|
+
`The spec-author context is partial. To restore full grounding, ` +
|
|
180
|
+
`set planning.codebaseSnapshot.tier: "medium" and/or narrow ` +
|
|
181
|
+
`planning.codebaseSnapshot.include in .agentrc.json.`,
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
if (grounding.citedButAbsent.length > 0) {
|
|
185
|
+
Logger.warn(
|
|
186
|
+
`[epic-plan-spec] ${grounding.citedButAbsent.length} path(s) cited ` +
|
|
187
|
+
`in the Epic body are absent from the codebase snapshot: ` +
|
|
188
|
+
`${grounding.citedButAbsent.join(', ')}. The spec author will ` +
|
|
189
|
+
`flag these as drift unless they are net-new.`,
|
|
190
|
+
);
|
|
191
|
+
}
|
|
167
192
|
}
|
|
168
193
|
} catch (err) {
|
|
169
194
|
Logger.warn(`[epic-plan-spec] codebase snapshot skipped: ${err.message}`);
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import path from 'node:path';
|
|
10
|
+
import { verifyBddRunnerPendingTag } from '../../../bdd-runner-detect.js';
|
|
10
11
|
import { Logger } from '../../../Logger.js';
|
|
11
12
|
import { AGENT_LABELS, TYPE_LABELS } from '../../../label-constants.js';
|
|
12
13
|
import { cleanupPhaseTempFiles } from '../../../plan-phase-cleanup.js';
|
|
@@ -45,12 +46,20 @@ function buildRiskVerdictCommentBody({ epicId, riskVerdict, planningRisk }) {
|
|
|
45
46
|
verdict: riskVerdict,
|
|
46
47
|
planningRisk,
|
|
47
48
|
};
|
|
49
|
+
// Story #4145 — when the disposition was forced to not-applicable because
|
|
50
|
+
// no BDD runner exists, make the waiver operator-visible in the rendered
|
|
51
|
+
// comment (not just the fenced JSON record) so a reviewer sees why an
|
|
52
|
+
// otherwise-required AC table was waived.
|
|
53
|
+
const waiverNote = planningRisk.acceptanceWaivedReason
|
|
54
|
+
? ['', `> ⚠️ **Acceptance waived** — ${planningRisk.acceptanceWaivedReason}`]
|
|
55
|
+
: [];
|
|
48
56
|
return [
|
|
49
57
|
`### 🧭 Planning Risk Verdict — ${planningRisk.overallLevel} · ${planningRisk.gateDecision}`,
|
|
50
58
|
'',
|
|
51
59
|
riskVerdict.summary,
|
|
52
60
|
'',
|
|
53
61
|
...axisTable,
|
|
62
|
+
...waiverNote,
|
|
54
63
|
'',
|
|
55
64
|
'```json',
|
|
56
65
|
JSON.stringify(record, null, 2),
|
|
@@ -99,7 +108,29 @@ export async function runSpecPhase(
|
|
|
99
108
|
'[epic-plan-spec] risk verdict is required — author risk-verdict.json via the epic-plan-spec-author Skill and pass it with --risk-verdict.',
|
|
100
109
|
);
|
|
101
110
|
}
|
|
102
|
-
|
|
111
|
+
|
|
112
|
+
// Story #4145 — probe the project's BDD runner. When none is detected
|
|
113
|
+
// (`fallback === true`, e.g. a node:test repo with no tests/features/**),
|
|
114
|
+
// the acceptance disposition is forced to not-applicable inside
|
|
115
|
+
// deriveRiskEnvelope: an authored AC table could never be reconciled by
|
|
116
|
+
// `@epic-<id>-ac-*` feature tags, so /deliver finalize would otherwise
|
|
117
|
+
// abort and require a manual `acceptance::n-a`. The probe is static and
|
|
118
|
+
// best-effort — a detection failure degrades to "runner present" (no
|
|
119
|
+
// forced waiver), preserving the BDD-repo path, and never blocks Phase 7.
|
|
120
|
+
let bddRunner = null;
|
|
121
|
+
try {
|
|
122
|
+
bddRunner = await verifyBddRunnerPendingTag({ cwd: PROJECT_ROOT });
|
|
123
|
+
} catch (err) {
|
|
124
|
+
Logger.warn(
|
|
125
|
+
`[epic-plan-spec] BDD runner probe skipped (${err.message}); acceptance disposition derived from risk axes only.`,
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
const planningRisk = deriveRiskEnvelope(riskVerdict, { bddRunner });
|
|
129
|
+
if (planningRisk.acceptanceWaivedReason) {
|
|
130
|
+
Logger.info(
|
|
131
|
+
`[epic-plan-spec] Acceptance disposition forced to not-applicable for Epic #${epicId}: ${planningRisk.acceptanceWaivedReason}`,
|
|
132
|
+
);
|
|
133
|
+
}
|
|
103
134
|
|
|
104
135
|
const epic = await provider.getEpic(epicId);
|
|
105
136
|
if (!epic) {
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* phases/spec-authoring-grounding.js — F10 spec-authoring code-grounding.
|
|
3
|
+
*
|
|
4
|
+
* Story #4139 (Epic #4131). `/plan` Phase 7 authors the PRD + Tech Spec
|
|
5
|
+
* from the Epic body, the scraped project docs, and the
|
|
6
|
+
* `codebaseSnapshot` structural view of the consumer repo. Two failure
|
|
7
|
+
* modes made that grounding silently partial:
|
|
8
|
+
*
|
|
9
|
+
* 1. The skinny-tier snapshot caps its file list at `MAX_FILES_SKINNY`
|
|
10
|
+
* and sets `truncated: true` — but the only operator-visible signal
|
|
11
|
+
* was a stderr `Logger.warn` (Story #3959). The spec author consumes
|
|
12
|
+
* the JSON envelope, not stderr, so it never learned the snapshot
|
|
13
|
+
* was partial. A run that dropped "377 of 627 files" looked complete
|
|
14
|
+
* to the author.
|
|
15
|
+
*
|
|
16
|
+
* 2. When the Epic body cites a code path that is **absent** from the
|
|
17
|
+
* snapshot's file set, nothing surfaced the gap *during* authoring.
|
|
18
|
+
* The post-author spec-freshness gate (Story #2635) catches stale
|
|
19
|
+
* citations only after the Tech Spec is written — one phase too late
|
|
20
|
+
* to ground the author's choices.
|
|
21
|
+
*
|
|
22
|
+
* `buildAuthoringGrounding` derives a small, bounded `grounding` block that
|
|
23
|
+
* is attached to the `codebaseSnapshot` envelope so the author (and the
|
|
24
|
+
* operator inspecting the run) see both signals before the spec is written:
|
|
25
|
+
*
|
|
26
|
+
* - `truncation` — non-null when the snapshot dropped files. Carries the
|
|
27
|
+
* dropped count, the matched/shown totals, and the two remedies. This
|
|
28
|
+
* is the structured, in-envelope form of the Story #3959 stderr warning.
|
|
29
|
+
* - `citedButAbsent` — path-shaped references pulled from the authoring
|
|
30
|
+
* prose (the Epic body) that are **not** present in the snapshot's file
|
|
31
|
+
* set and are not phrased as net-new. Bounded to `MAX_CITED_ABSENT`
|
|
32
|
+
* entries so a pathological Epic body cannot blow the envelope budget.
|
|
33
|
+
*
|
|
34
|
+
* The grounding is targeted (the prose the author is grounding *from* and
|
|
35
|
+
* the files the snapshot already carries), not a whole-repo dump — the
|
|
36
|
+
* snapshot file set is the only source consulted, so this adds no new
|
|
37
|
+
* filesystem or git probes.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Hard cap on the `citedButAbsent` list so an Epic body that mentions a
|
|
42
|
+
* very large number of paths cannot inflate the authoring envelope. The
|
|
43
|
+
* cap is generous relative to a realistic Epic citation count; when it is
|
|
44
|
+
* hit, `citedButAbsentTruncated: true` flags the elision so the signal is
|
|
45
|
+
* not silently dropped (the very failure mode this Story fixes).
|
|
46
|
+
*/
|
|
47
|
+
export const MAX_CITED_ABSENT = 40;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Build the operator-visible truncation signal from a snapshot envelope.
|
|
51
|
+
* Returns `null` when the snapshot is absent or was not truncated.
|
|
52
|
+
*
|
|
53
|
+
* @param {object|null} snapshot - The `codebaseSnapshot` envelope.
|
|
54
|
+
* @returns {{ dropped: number, matched: number, shown: number, tier: string, remedies: string[] } | null}
|
|
55
|
+
*/
|
|
56
|
+
export function buildTruncationSignal(snapshot) {
|
|
57
|
+
if (!snapshot || snapshot.truncated !== true) return null;
|
|
58
|
+
const matched = Number.isInteger(snapshot.fileCount) ? snapshot.fileCount : 0;
|
|
59
|
+
const shown = Array.isArray(snapshot.files) ? snapshot.files.length : 0;
|
|
60
|
+
const dropped = Math.max(0, matched - shown);
|
|
61
|
+
return {
|
|
62
|
+
dropped,
|
|
63
|
+
matched,
|
|
64
|
+
shown,
|
|
65
|
+
tier: typeof snapshot.tier === 'string' ? snapshot.tier : 'skinny',
|
|
66
|
+
remedies: [
|
|
67
|
+
'Set planning.codebaseSnapshot.tier: "medium" in .agentrc.json to restore full grounding.',
|
|
68
|
+
'Narrow planning.codebaseSnapshot.include in .agentrc.json so the cited surfaces survive the cap.',
|
|
69
|
+
],
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Surface path-shaped references from the authoring prose that are absent
|
|
75
|
+
* from the snapshot's file set. Reuses the spec-freshness path extractor so
|
|
76
|
+
* the citation shapes recognised here match the post-author freshness gate
|
|
77
|
+
* exactly — a path the author cites in prose is detected the same way before
|
|
78
|
+
* and after authoring.
|
|
79
|
+
*
|
|
80
|
+
* A reference is reported only when it is **not** present in `snapshotFiles`
|
|
81
|
+
* **and** the surrounding prose does not phrase it as net-new (the same
|
|
82
|
+
* cue heuristic the freshness gate uses to demote intentional new-file
|
|
83
|
+
* mentions). Results are deduped by path, sorted, and bounded to
|
|
84
|
+
* `MAX_CITED_ABSENT`.
|
|
85
|
+
*
|
|
86
|
+
* @param {string} prose - The authoring prose (typically the Epic body).
|
|
87
|
+
* @param {string[]} snapshotFiles - The snapshot's `files` array.
|
|
88
|
+
* @param {object} deps
|
|
89
|
+
* @param {Function} deps.collectReferences - (body) => Array<{ path, index, matchLength }>.
|
|
90
|
+
* @param {Function} deps.hasNewFileCue - (body, index, matchLength) => boolean.
|
|
91
|
+
* @returns {{ paths: string[], truncated: boolean }}
|
|
92
|
+
*/
|
|
93
|
+
export function findCitedButAbsent(prose, snapshotFiles, deps) {
|
|
94
|
+
const { collectReferences, hasNewFileCue } = deps;
|
|
95
|
+
if (typeof prose !== 'string' || prose.length === 0) {
|
|
96
|
+
return { paths: [], truncated: false };
|
|
97
|
+
}
|
|
98
|
+
const present = new Set(
|
|
99
|
+
(Array.isArray(snapshotFiles) ? snapshotFiles : []).map((f) =>
|
|
100
|
+
String(f).replace(/\\/g, '/'),
|
|
101
|
+
),
|
|
102
|
+
);
|
|
103
|
+
const absent = new Set();
|
|
104
|
+
for (const { path, index, matchLength } of collectReferences(prose)) {
|
|
105
|
+
const normalised = path.replace(/\\/g, '/');
|
|
106
|
+
if (present.has(normalised)) continue;
|
|
107
|
+
if (hasNewFileCue(prose, index, matchLength)) continue;
|
|
108
|
+
absent.add(normalised);
|
|
109
|
+
}
|
|
110
|
+
const sorted = [...absent].sort();
|
|
111
|
+
return {
|
|
112
|
+
paths: sorted.slice(0, MAX_CITED_ABSENT),
|
|
113
|
+
truncated: sorted.length > MAX_CITED_ABSENT,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Build the full `grounding` block attached to the `codebaseSnapshot`
|
|
119
|
+
* envelope. Pure with respect to its inputs (no filesystem or git probes):
|
|
120
|
+
* the snapshot file set is the sole grounding source, keeping the context
|
|
121
|
+
* bounded for cost.
|
|
122
|
+
*
|
|
123
|
+
* @param {object} opts
|
|
124
|
+
* @param {object|null} opts.snapshot - The `codebaseSnapshot` envelope.
|
|
125
|
+
* @param {string} opts.prose - The authoring prose (Epic body) to scan.
|
|
126
|
+
* @param {Function} opts.collectReferences - spec-freshness path extractor.
|
|
127
|
+
* @param {Function} opts.hasNewFileCue - spec-freshness net-new cue check.
|
|
128
|
+
* @returns {{ truncation: object|null, citedButAbsent: string[], citedButAbsentTruncated: boolean }}
|
|
129
|
+
*/
|
|
130
|
+
export function buildAuthoringGrounding({
|
|
131
|
+
snapshot,
|
|
132
|
+
prose,
|
|
133
|
+
collectReferences,
|
|
134
|
+
hasNewFileCue,
|
|
135
|
+
}) {
|
|
136
|
+
const truncation = buildTruncationSignal(snapshot);
|
|
137
|
+
const { paths, truncated } = findCitedButAbsent(
|
|
138
|
+
prose,
|
|
139
|
+
snapshot?.files ?? [],
|
|
140
|
+
{ collectReferences, hasNewFileCue },
|
|
141
|
+
);
|
|
142
|
+
return {
|
|
143
|
+
truncation,
|
|
144
|
+
citedButAbsent: paths,
|
|
145
|
+
citedButAbsentTruncated: truncated,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
@@ -358,10 +358,15 @@ export async function pollUntilTerminal({
|
|
|
358
358
|
* @param {number} opts.maxPolls Hard cap on total poll iterations.
|
|
359
359
|
* @param {number} opts.maxUpdates Cap on `gh pr update-branch` recovery calls.
|
|
360
360
|
* @param {number} opts.pollIntervalMs Delay between poll ticks.
|
|
361
|
-
* @param {Function} opts.ghPrChecksFn
|
|
362
|
-
*
|
|
363
|
-
*
|
|
364
|
-
* @param {Function} opts.
|
|
361
|
+
* @param {Function} [opts.ghPrChecksFn] `gh pr checks` invoker. Defaults
|
|
362
|
+
* to the real `gh pr checks` spawn so the CLI path (which injects no
|
|
363
|
+
* port) works; tests override it with a stub. Story #4144.
|
|
364
|
+
* @param {Function} [opts.ghPrViewFn] `gh pr view` invoker. Defaults
|
|
365
|
+
* to the real spawn; tests override.
|
|
366
|
+
* @param {Function} [opts.ghPrUpdateBranchFn] `gh pr update-branch`
|
|
367
|
+
* invoker. Defaults to the real spawn; tests override.
|
|
368
|
+
* @param {Function} [opts.sleepFn] Poll-tick delay. Defaults to a
|
|
369
|
+
* real `setTimeout`-backed sleep; tests override with a no-op.
|
|
365
370
|
* @param {{ info?: Function, warn?: Function, debug?: Function }} opts.logger
|
|
366
371
|
* @param {{status:number,stdout:string,stderr:string}} [opts.firstProbe]
|
|
367
372
|
* Optional already-issued `gh pr checks` result. When the caller (the
|
|
@@ -388,10 +393,10 @@ export async function watchPrToTerminal({
|
|
|
388
393
|
maxPolls,
|
|
389
394
|
maxUpdates,
|
|
390
395
|
pollIntervalMs,
|
|
391
|
-
ghPrChecksFn,
|
|
392
|
-
ghPrViewFn,
|
|
393
|
-
ghPrUpdateBranchFn,
|
|
394
|
-
sleepFn,
|
|
396
|
+
ghPrChecksFn = ghPrChecks,
|
|
397
|
+
ghPrViewFn = ghPrView,
|
|
398
|
+
ghPrUpdateBranchFn = ghPrUpdateBranch,
|
|
399
|
+
sleepFn = defaultSleep,
|
|
395
400
|
logger,
|
|
396
401
|
firstProbe,
|
|
397
402
|
}) {
|
|
@@ -37,6 +37,18 @@
|
|
|
37
37
|
* @property {boolean} requiresReview
|
|
38
38
|
* @property {AcceptanceDisposition} acceptanceDisposition
|
|
39
39
|
* @property {GateDecision} gateDecision
|
|
40
|
+
* @property {string} [acceptanceWaivedReason] Present only when the
|
|
41
|
+
* acceptance disposition was forced to `not-applicable` by a non-axis
|
|
42
|
+
* signal (currently: no BDD runner detected). An operator-visible
|
|
43
|
+
* rationale so the override is never silent (Story #4145).
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* @typedef {Object} BddRunnerProbe
|
|
48
|
+
* @property {string|null} runner
|
|
49
|
+
* @property {boolean} fallback `true` when no supported BDD runner was
|
|
50
|
+
* detected in the project (`verifyBddRunnerPendingTag`).
|
|
51
|
+
* @property {string} [reason]
|
|
40
52
|
*/
|
|
41
53
|
|
|
42
54
|
const LEVEL_RANK = Object.freeze({ low: 0, medium: 1, high: 2 });
|
|
@@ -129,27 +141,54 @@ function resolveRequiresReview(overallLevel, axes) {
|
|
|
129
141
|
* (`epic-plan-spec.js`), never here, so a malformed verdict fails closed
|
|
130
142
|
* before this function runs.
|
|
131
143
|
*
|
|
144
|
+
* **No-BDD-runner waiver (Story #4145).** The acceptance disposition the risk
|
|
145
|
+
* axes derive presumes a BDD runner exists to satisfy an authored AC table.
|
|
146
|
+
* When `opts.bddRunner.fallback === true` (no supported runner detected — e.g.
|
|
147
|
+
* a `node:test` repo with no `tests/features/**`), an authored AC table can
|
|
148
|
+
* never be reconciled by `@epic-<id>-ac-*` feature tags, so `/deliver`
|
|
149
|
+
* finalize would abort. In that case the disposition is **forced** to
|
|
150
|
+
* `not-applicable` regardless of the risk axes, and `acceptanceWaivedReason`
|
|
151
|
+
* records the override so it is operator-visible, not silent. The
|
|
152
|
+
* `requiresReview` / `gateDecision` outputs are unaffected — a high-risk
|
|
153
|
+
* Epic still routes to review; only the acceptance-spec requirement is
|
|
154
|
+
* waived. Repos that ship a BDD runner (`fallback !== true`) are unaffected.
|
|
155
|
+
*
|
|
132
156
|
* @param {RiskVerdict} [verdict]
|
|
157
|
+
* @param {{ bddRunner?: BddRunnerProbe|null }} [opts]
|
|
133
158
|
* @returns {PlanningRiskEnvelope}
|
|
134
159
|
*/
|
|
135
|
-
export function deriveRiskEnvelope(verdict = {}) {
|
|
160
|
+
export function deriveRiskEnvelope(verdict = {}, { bddRunner = null } = {}) {
|
|
136
161
|
const axes = (Array.isArray(verdict.axes) ? verdict.axes : []).map(
|
|
137
162
|
({ axis, level, rationale }) => ({ axis, level, rationale }),
|
|
138
163
|
);
|
|
139
164
|
|
|
140
165
|
const overallLevel = resolveOverallLevel(axes);
|
|
141
|
-
const
|
|
142
|
-
axes,
|
|
143
|
-
overallLevel,
|
|
144
|
-
);
|
|
166
|
+
const axisDisposition = resolveAcceptanceDisposition(axes, overallLevel);
|
|
145
167
|
const requiresReview = resolveRequiresReview(overallLevel, axes);
|
|
146
168
|
const gateDecision = requiresReview ? 'review-required' : 'auto-proceed';
|
|
147
169
|
|
|
148
|
-
|
|
170
|
+
const noBddRunner = bddRunner?.fallback === true;
|
|
171
|
+
// Force the waiver only when the axes would otherwise have required (or
|
|
172
|
+
// recommended) an AC table; if the disposition is already not-applicable
|
|
173
|
+
// there is nothing to override and no waiver rationale to surface.
|
|
174
|
+
const forceWaiver = noBddRunner && axisDisposition !== 'not-applicable';
|
|
175
|
+
const acceptanceDisposition = forceWaiver
|
|
176
|
+
? 'not-applicable'
|
|
177
|
+
: axisDisposition;
|
|
178
|
+
|
|
179
|
+
/** @type {PlanningRiskEnvelope} */
|
|
180
|
+
const envelope = {
|
|
149
181
|
axes,
|
|
150
182
|
overallLevel,
|
|
151
183
|
requiresReview,
|
|
152
184
|
acceptanceDisposition,
|
|
153
185
|
gateDecision,
|
|
154
186
|
};
|
|
187
|
+
if (forceWaiver) {
|
|
188
|
+
envelope.acceptanceWaivedReason =
|
|
189
|
+
`no BDD runner detected (${bddRunner?.reason ?? 'no-bdd-runner-detected'}) — ` +
|
|
190
|
+
`an authored acceptance-spec AC table cannot be reconciled by feature tags, ` +
|
|
191
|
+
`so the acceptance disposition is waived to not-applicable (was ${axisDisposition}).`;
|
|
192
|
+
}
|
|
193
|
+
return envelope;
|
|
155
194
|
}
|
|
@@ -26,6 +26,69 @@ export function normalizeInterventionCount(value) {
|
|
|
26
26
|
return Math.trunc(value);
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Pure: derive the recurring-defect-class signal from the routed-proposal
|
|
31
|
+
* sections (Story #4135 / Epic #4131, F11).
|
|
32
|
+
*
|
|
33
|
+
* The routed-proposals composer (`retro-proposals.js`) already split the
|
|
34
|
+
* Epic's source-tagged friction into `framework` / `consumer` actionable
|
|
35
|
+
* items — a category lands there only when it recurred ≥2 times across the
|
|
36
|
+
* Epic OR was force-flagged by an unresolved `agent::blocked` event. Those
|
|
37
|
+
* are exactly the **recurring classes** of review/deliver-caught issues F11
|
|
38
|
+
* tracks, so we lift them verbatim rather than re-deriving a parallel
|
|
39
|
+
* threshold. Each entry carries the `friction::<category>` label the
|
|
40
|
+
* composer stamps onto its `gh issue create` command — that label is the
|
|
41
|
+
* join key the `/plan` Phase 0 prior-feedback fetcher reads back to surface
|
|
42
|
+
* recurring classes to the planner.
|
|
43
|
+
*
|
|
44
|
+
* Determinism: the two actionable arrays are already sorted by `category`
|
|
45
|
+
* ASC by the composer; we concatenate framework-then-consumer and re-sort by
|
|
46
|
+
* `category` so the merged signal is stable regardless of which source
|
|
47
|
+
* contributed a class.
|
|
48
|
+
*
|
|
49
|
+
* No-op-safe: a null / non-object / shapeless `routedProposals` (or one with
|
|
50
|
+
* empty actionable arrays) yields `[]` — the common clean-sprint case.
|
|
51
|
+
*
|
|
52
|
+
* @param {{ framework?: object[], consumer?: object[] } | null | undefined} routedProposals
|
|
53
|
+
* @returns {Array<{ category: string, occurrences: number, source: 'framework'|'consumer', label: string }>}
|
|
54
|
+
*/
|
|
55
|
+
export function deriveDefectClasses(routedProposals) {
|
|
56
|
+
if (
|
|
57
|
+
!routedProposals ||
|
|
58
|
+
typeof routedProposals !== 'object' ||
|
|
59
|
+
Array.isArray(routedProposals)
|
|
60
|
+
) {
|
|
61
|
+
return [];
|
|
62
|
+
}
|
|
63
|
+
const framework = Array.isArray(routedProposals.framework)
|
|
64
|
+
? routedProposals.framework
|
|
65
|
+
: [];
|
|
66
|
+
const consumer = Array.isArray(routedProposals.consumer)
|
|
67
|
+
? routedProposals.consumer
|
|
68
|
+
: [];
|
|
69
|
+
|
|
70
|
+
const out = [];
|
|
71
|
+
for (const item of [...framework, ...consumer]) {
|
|
72
|
+
if (!item || typeof item !== 'object') continue;
|
|
73
|
+
const category =
|
|
74
|
+
typeof item.category === 'string' ? item.category.trim() : '';
|
|
75
|
+
if (category.length === 0) continue;
|
|
76
|
+
const occurrences =
|
|
77
|
+
typeof item.occurrences === 'number' && Number.isFinite(item.occurrences)
|
|
78
|
+
? item.occurrences
|
|
79
|
+
: 0;
|
|
80
|
+
const source = item.source === 'framework' ? 'framework' : 'consumer';
|
|
81
|
+
out.push({
|
|
82
|
+
category,
|
|
83
|
+
occurrences,
|
|
84
|
+
source,
|
|
85
|
+
label: `friction::${category}`,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
out.sort((a, b) => a.category.localeCompare(b.category));
|
|
89
|
+
return out;
|
|
90
|
+
}
|
|
91
|
+
|
|
29
92
|
/**
|
|
30
93
|
* Pure: compose the retro markdown body. Exported for tests so they can
|
|
31
94
|
* verify the body shape without round-tripping through a stub provider.
|
|
@@ -18,6 +18,7 @@ import { upsertStructuredComment } from '../../ticketing.js';
|
|
|
18
18
|
import { appendChecksSection, collectRetroFindings } from './checks.js';
|
|
19
19
|
import {
|
|
20
20
|
composeRetroBody as defaultComposeRetroBody,
|
|
21
|
+
deriveDefectClasses,
|
|
21
22
|
normalizeInterventionCount,
|
|
22
23
|
} from './compose-body.js';
|
|
23
24
|
import { gatherRetroSignals as defaultGatherRetroSignals } from './gather-signals.js';
|
|
@@ -74,6 +75,15 @@ export async function composeAndPostRetro({
|
|
|
74
75
|
perfThresholds,
|
|
75
76
|
});
|
|
76
77
|
|
|
78
|
+
// Story #4135 (Epic #4131, F11) — derive the recurring-defect-class signal
|
|
79
|
+
// from the same routed proposals the body composer consumed. The signal is
|
|
80
|
+
// surfaced on the runRetro envelope (`defectClasses`) so callers/tests can
|
|
81
|
+
// observe the recurring classes the routed-proposal `gh issue create`
|
|
82
|
+
// commands stamp with `friction::<class>` labels — the join key the
|
|
83
|
+
// `/plan` Phase 0 fetcher reads back. No-op-safe: a clean sprint (no
|
|
84
|
+
// routed proposals) yields `[]`.
|
|
85
|
+
const defectClasses = deriveDefectClasses(signals.routedProposals);
|
|
86
|
+
|
|
77
87
|
const findings = await collectRetroFindings({
|
|
78
88
|
runChecksFn,
|
|
79
89
|
assembleStateFn,
|
|
@@ -128,6 +138,7 @@ export async function composeAndPostRetro({
|
|
|
128
138
|
scorecard,
|
|
129
139
|
body: bodyWithChecks,
|
|
130
140
|
findings,
|
|
141
|
+
defectClasses,
|
|
131
142
|
commentId: result?.commentId,
|
|
132
143
|
};
|
|
133
144
|
}
|
|
@@ -18,10 +18,23 @@
|
|
|
18
18
|
* existing import sites stay unchanged.
|
|
19
19
|
*
|
|
20
20
|
* Public API:
|
|
21
|
-
* - `runRetro({ epicId, provider, logger })` → `{ posted, compact, scorecard, body }`.
|
|
21
|
+
* - `runRetro({ epicId, provider, logger })` → `{ posted, compact, scorecard, body, defectClasses }`.
|
|
22
22
|
* - `composeRetroBody(input)` (pure, exported for tests).
|
|
23
23
|
* - `gatherRetroSignals({ epicId, provider })` (exported for tests).
|
|
24
24
|
* - `appendChecksSection(body, findings)` (pure, exported for tests).
|
|
25
|
+
* - `deriveDefectClasses(routedProposals)` (pure, exported for tests).
|
|
26
|
+
*
|
|
27
|
+
* Story #4135 (Epic #4131, F11) — the runner now derives a
|
|
28
|
+
* **recurring-defect-class signal** from the routed-proposal actionable
|
|
29
|
+
* items (categories that recurred ≥2 times across review/deliver-caught
|
|
30
|
+
* friction, or a force-flagged `agent::blocked` category). The derived
|
|
31
|
+
* classes ride on the `runRetro` envelope as `defectClasses[]` and are
|
|
32
|
+
* stamped onto the proposed `gh issue create` commands as `friction::<class>`
|
|
33
|
+
* labels (the routed-proposals composer already emits that label), which is
|
|
34
|
+
* the durable substrate the `/plan` Phase 0 prior-feedback fetcher reads
|
|
35
|
+
* back to surface recurring classes to the planner. The derivation is
|
|
36
|
+
* **no-op-safe**: absent or empty routed proposals yield an empty array and
|
|
37
|
+
* no behavioural change to the existing retro/post path.
|
|
25
38
|
*
|
|
26
39
|
* Behaviour:
|
|
27
40
|
* - Reads child Stories' `story-perf-summary` comments to aggregate
|
|
@@ -50,7 +63,10 @@ import { upsertStructuredComment } from './ticketing.js';
|
|
|
50
63
|
|
|
51
64
|
// Re-export phase-level helpers so existing import sites stay unchanged.
|
|
52
65
|
export { appendChecksSection } from './retro/phases/checks.js';
|
|
53
|
-
export {
|
|
66
|
+
export {
|
|
67
|
+
composeRetroBody,
|
|
68
|
+
deriveDefectClasses,
|
|
69
|
+
} from './retro/phases/compose-body.js';
|
|
54
70
|
export { gatherRetroSignals } from './retro/phases/gather-signals.js';
|
|
55
71
|
|
|
56
72
|
/**
|
|
@@ -92,6 +108,7 @@ export { gatherRetroSignals } from './retro/phases/gather-signals.js';
|
|
|
92
108
|
* scorecard: object,
|
|
93
109
|
* body: string,
|
|
94
110
|
* findings: object[],
|
|
111
|
+
* defectClasses: Array<{ category: string, occurrences: number, source: 'framework'|'consumer', label: string }>,
|
|
95
112
|
* commentId?: number,
|
|
96
113
|
* }>}
|
|
97
114
|
*/
|