mandrel 2.1.0 → 2.2.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/agents/acceptance-critic.md +11 -2
- package/.agents/agents/story-worker.md +4 -2
- package/.agents/docs/SDLC.md +11 -4
- package/.agents/docs/configuration.md +1 -1
- package/.agents/docs/quality-gates.md +3 -3
- package/.agents/rules/gherkin-standards.md +10 -0
- package/.agents/schemas/acceptance-eval-verdict.schema.json +2 -2
- package/.agents/schemas/agentrc.schema.json +1 -1
- package/.agents/scripts/acceptance-eval.js +2 -2
- package/.agents/scripts/lib/config/acceptance-eval.js +2 -2
- package/.agents/scripts/lib/config-settings-schema-delivery.js +3 -3
- package/.agents/scripts/lib/orchestration/change-set.js +103 -0
- package/.agents/scripts/lib/orchestration/code-review.js +24 -35
- package/.agents/scripts/lib/orchestration/plan-context.js +2 -9
- package/.agents/scripts/lib/orchestration/plan-critic-conditions.js +17 -16
- package/.agents/scripts/lib/orchestration/plan-critics-evaluate.js +28 -15
- package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +0 -25
- package/.agents/scripts/lib/orchestration/plan-text-hygiene.js +230 -0
- package/.agents/scripts/lib/orchestration/planning/decomposer-context.js +1 -2
- package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +1 -1
- package/.agents/scripts/lib/orchestration/story-close/phases/code-review.js +97 -255
- package/.agents/scripts/lib/orchestration/story-close/phases/local-lens-review.js +191 -0
- package/.agents/scripts/lib/orchestration/story-close/phases/review-core.js +120 -0
- package/.agents/scripts/lib/story-body/story-body.js +75 -8
- package/.agents/scripts/lib/templates/decomposer-prompts.js +8 -13
- package/.agents/scripts/lib/wave-runner/live-probe.js +315 -0
- package/.agents/scripts/plan-context.js +0 -1
- package/.agents/scripts/plan-critics.js +203 -0
- package/.agents/scripts/quality-preview.js +13 -6
- package/.agents/scripts/stories-wave-tick.js +307 -55
- package/.agents/workflows/deliver.md +50 -15
- package/.agents/workflows/helpers/acceptance-self-eval.md +14 -5
- package/.agents/workflows/helpers/code-quality-guardrails.md +7 -4
- package/.agents/workflows/helpers/code-review.md +2 -2
- package/.agents/workflows/helpers/deliver-story.md +22 -6
- package/.agents/workflows/plan.md +55 -0
- package/docs/CHANGELOG.md +22 -0
- package/lib/migrations/index.js +6 -1
- package/lib/migrations/steps/2.2.0-retire-epic-ac-tags.js +154 -0
- package/package.json +2 -2
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* plan-text-hygiene.js — deterministic text-hygiene lints over draft Story
|
|
3
|
+
* bodies (Story #4599).
|
|
4
|
+
*
|
|
5
|
+
* The text analysis of Stories #4592–#4594 (and domio#1684) surfaced three
|
|
6
|
+
* body-defect classes no gate checks: dangling prose citations whose carrier
|
|
7
|
+
* document is unlocatable, operator-directed open questions persisted into
|
|
8
|
+
* tickets executed by non-interactive sub-agents, and `## Slicing` sections
|
|
9
|
+
* carrying more mass than the `## Spec` they are supposed to checkpoint.
|
|
10
|
+
* This module makes those classes checkable at the one point a re-author
|
|
11
|
+
* loop exists — the pre-persist critic gate (`plan-critics.js`).
|
|
12
|
+
*
|
|
13
|
+
* Advisory by contract: findings are deterministic text for the workflow's
|
|
14
|
+
* re-author round. They never gate persist, never flip a dispatch verdict,
|
|
15
|
+
* and spawn nothing.
|
|
16
|
+
*
|
|
17
|
+
* Heuristics are deliberately narrow (few false positives over recall):
|
|
18
|
+
*
|
|
19
|
+
* - **dangling-citation** — a sentence referencing a document section
|
|
20
|
+
* (`§`, "design note", "review doc") with no repo-relative path and no
|
|
21
|
+
* `#<digits>` issue anchor in the same sentence.
|
|
22
|
+
* - **open-question** — interrogative-to-operator phrasing ("Flag if",
|
|
23
|
+
* "TBD", "confirm with the operator", a trailing `?`) in Goal/Spec
|
|
24
|
+
* prose outside code spans. Bodies record decisions; unresolved
|
|
25
|
+
* unknowns belong in declarative Key Assumptions.
|
|
26
|
+
* - **slicing-mass** — `## Slicing` character mass exceeding `## Spec`
|
|
27
|
+
* character mass when both are present: checkpoints carrying
|
|
28
|
+
* Spec-grade detail the Spec then re-covers.
|
|
29
|
+
*
|
|
30
|
+
* Pure, synchronous, no I/O. Operates on the draft `stories.json` array,
|
|
31
|
+
* reusing `parse()` from `lib/story-body/story-body.js` for section access.
|
|
32
|
+
* An unparseable draft body is skipped, not failed — hygiene is advisory
|
|
33
|
+
* and the persist validators own structural rejection.
|
|
34
|
+
*
|
|
35
|
+
* @module lib/orchestration/plan-text-hygiene
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import { parse } from '../story-body/story-body.js';
|
|
39
|
+
|
|
40
|
+
/** Truncation length for the `evidence` excerpt on each finding. */
|
|
41
|
+
const EVIDENCE_MAX_CHARS = 160;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Phrases the citation heuristic treats as a reference to an external
|
|
45
|
+
* carrier document. Matched case-insensitively within one sentence.
|
|
46
|
+
*/
|
|
47
|
+
const CITATION_MARKERS = [/§/, /\bdesign note\b/i, /\breview doc\b/i];
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Anchors that locate a citation: a `#<digits>` issue reference or a
|
|
51
|
+
* repo-relative path (a slash-joined token carrying a file-ish segment).
|
|
52
|
+
*/
|
|
53
|
+
const ISSUE_ANCHOR = /#\d+/;
|
|
54
|
+
const REPO_PATH_ANCHOR = /[\w.-]+\/[\w./-]+/;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Operator-directed open-question phrasings. Each match is an instruction
|
|
58
|
+
* or question aimed at a human, which a non-interactive delivery sub-agent
|
|
59
|
+
* can never answer.
|
|
60
|
+
*/
|
|
61
|
+
const OPEN_QUESTION_MARKERS = [
|
|
62
|
+
/\bflag if\b/i,
|
|
63
|
+
/\bTBD\b/,
|
|
64
|
+
/\bconfirm with the operator\b/i,
|
|
65
|
+
];
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* @typedef {Object} TextHygieneFinding
|
|
69
|
+
* @property {'dangling-citation'|'open-question'|'slicing-mass'} kind
|
|
70
|
+
* @property {string} slug - The draft Story's slug ('' when absent).
|
|
71
|
+
* @property {string} evidence - Excerpt of the offending text.
|
|
72
|
+
* @property {string} message - Human-readable, re-author-actionable text.
|
|
73
|
+
*/
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Strip fenced code blocks and inline code spans so code content (shell
|
|
77
|
+
* snippets, grep patterns, JSON) never trips a prose heuristic.
|
|
78
|
+
*
|
|
79
|
+
* @param {string} text
|
|
80
|
+
* @returns {string}
|
|
81
|
+
*/
|
|
82
|
+
function stripCodeSpans(text) {
|
|
83
|
+
return text.replace(/```[\s\S]*?```/g, ' ').replace(/`[^`\n]*`/g, ' ');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Split prose into sentence-ish units. Newlines are boundaries too, so a
|
|
88
|
+
* bullet list yields one unit per bullet.
|
|
89
|
+
*
|
|
90
|
+
* @param {string} text
|
|
91
|
+
* @returns {string[]}
|
|
92
|
+
*/
|
|
93
|
+
function splitSentences(text) {
|
|
94
|
+
return text
|
|
95
|
+
.split(/(?<=[.!?])\s+|\n+/)
|
|
96
|
+
.map((s) => s.trim())
|
|
97
|
+
.filter(Boolean);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Truncate an excerpt for the finding's `evidence` field.
|
|
102
|
+
*
|
|
103
|
+
* @param {string} text
|
|
104
|
+
* @returns {string}
|
|
105
|
+
*/
|
|
106
|
+
function excerpt(text) {
|
|
107
|
+
const flat = text.replace(/\s+/g, ' ').trim();
|
|
108
|
+
return flat.length > EVIDENCE_MAX_CHARS
|
|
109
|
+
? `${flat.slice(0, EVIDENCE_MAX_CHARS - 1)}…`
|
|
110
|
+
: flat;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* dangling-citation: a citation-marker sentence with no locating anchor.
|
|
115
|
+
*
|
|
116
|
+
* @param {string} prose - Code-stripped body prose.
|
|
117
|
+
* @param {string} slug
|
|
118
|
+
* @returns {TextHygieneFinding[]}
|
|
119
|
+
*/
|
|
120
|
+
function findDanglingCitations(prose, slug) {
|
|
121
|
+
const findings = [];
|
|
122
|
+
for (const sentence of splitSentences(prose)) {
|
|
123
|
+
const cites = CITATION_MARKERS.some((m) => m.test(sentence));
|
|
124
|
+
if (!cites) continue;
|
|
125
|
+
const anchored =
|
|
126
|
+
ISSUE_ANCHOR.test(sentence) || REPO_PATH_ANCHOR.test(sentence);
|
|
127
|
+
if (anchored) continue;
|
|
128
|
+
findings.push({
|
|
129
|
+
kind: 'dangling-citation',
|
|
130
|
+
slug,
|
|
131
|
+
evidence: excerpt(sentence),
|
|
132
|
+
message:
|
|
133
|
+
'Citation names a document section but no repo-relative path or ' +
|
|
134
|
+
'#<issue> anchor locates it in the same sentence — the executing ' +
|
|
135
|
+
'agent cannot follow it. Anchor the citation or inline the claim.',
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
return findings;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* open-question: operator-directed phrasing (or a trailing `?`) in prose a
|
|
143
|
+
* non-interactive sub-agent executes.
|
|
144
|
+
*
|
|
145
|
+
* @param {string} prose - Code-stripped Goal/Spec prose.
|
|
146
|
+
* @param {string} slug
|
|
147
|
+
* @returns {TextHygieneFinding[]}
|
|
148
|
+
*/
|
|
149
|
+
function findOpenQuestions(prose, slug) {
|
|
150
|
+
const findings = [];
|
|
151
|
+
for (const sentence of splitSentences(prose)) {
|
|
152
|
+
const marked =
|
|
153
|
+
OPEN_QUESTION_MARKERS.some((m) => m.test(sentence)) ||
|
|
154
|
+
sentence.endsWith('?');
|
|
155
|
+
if (!marked) continue;
|
|
156
|
+
findings.push({
|
|
157
|
+
kind: 'open-question',
|
|
158
|
+
slug,
|
|
159
|
+
evidence: excerpt(sentence),
|
|
160
|
+
message:
|
|
161
|
+
'Body text carries an operator-directed open question; the Story ' +
|
|
162
|
+
'is executed by a non-interactive sub-agent that cannot answer it. ' +
|
|
163
|
+
'Record the decision, or restate the unknown as a declarative Key ' +
|
|
164
|
+
'Assumption.',
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
return findings;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* slicing-mass: `## Slicing` outweighing `## Spec` when both are present.
|
|
172
|
+
*
|
|
173
|
+
* @param {{ slicing?: string, spec?: string }} body - Parsed Story body.
|
|
174
|
+
* @param {string} slug
|
|
175
|
+
* @returns {TextHygieneFinding[]}
|
|
176
|
+
*/
|
|
177
|
+
function findSlicingMass(body, slug) {
|
|
178
|
+
const slicing = typeof body.slicing === 'string' ? body.slicing : '';
|
|
179
|
+
const spec = typeof body.spec === 'string' ? body.spec : '';
|
|
180
|
+
if (slicing.length === 0 || spec.length === 0) return [];
|
|
181
|
+
if (slicing.length <= spec.length) return [];
|
|
182
|
+
return [
|
|
183
|
+
{
|
|
184
|
+
kind: 'slicing-mass',
|
|
185
|
+
slug,
|
|
186
|
+
evidence: excerpt(slicing),
|
|
187
|
+
message:
|
|
188
|
+
`## Slicing (${slicing.length} chars) outweighs ## Spec ` +
|
|
189
|
+
`(${spec.length} chars) — checkpoints are carrying Spec-grade ` +
|
|
190
|
+
'detail. Keep each Slicing checkpoint to one line and move the ' +
|
|
191
|
+
'detail into ## Spec.',
|
|
192
|
+
},
|
|
193
|
+
];
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Evaluate the three text-hygiene lints over a draft Story array.
|
|
198
|
+
*
|
|
199
|
+
* @param {{ draftStories?: Array<object>|null }} args - The draft
|
|
200
|
+
* `stories.json` array (raw Story objects with top-level `slug` /
|
|
201
|
+
* `body`). Null/absent evaluates to zero findings (the single-delivery
|
|
202
|
+
* shape authors no draft tickets).
|
|
203
|
+
* @returns {{ findings: TextHygieneFinding[] }}
|
|
204
|
+
*/
|
|
205
|
+
export function evaluateTextHygiene({ draftStories = null } = {}) {
|
|
206
|
+
const stories = Array.isArray(draftStories) ? draftStories : [];
|
|
207
|
+
const findings = [];
|
|
208
|
+
for (const story of stories) {
|
|
209
|
+
const slug = typeof story?.slug === 'string' ? story.slug : '';
|
|
210
|
+
let body;
|
|
211
|
+
try {
|
|
212
|
+
body = parse(story?.body).body;
|
|
213
|
+
} catch {
|
|
214
|
+
// Advisory lint: an unparseable body is the persist validators'
|
|
215
|
+
// rejection to make, not this evaluator's.
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
const goal = typeof body.goal === 'string' ? body.goal : '';
|
|
219
|
+
const spec = typeof body.spec === 'string' ? body.spec : '';
|
|
220
|
+
const bodyProse = stripCodeSpans(
|
|
221
|
+
typeof story.body === 'string' ? story.body : [goal, spec].join('\n'),
|
|
222
|
+
);
|
|
223
|
+
findings.push(
|
|
224
|
+
...findDanglingCitations(bodyProse, slug),
|
|
225
|
+
...findOpenQuestions(stripCodeSpans([goal, spec].join('\n')), slug),
|
|
226
|
+
...findSlicingMass(body, slug),
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
return { findings };
|
|
230
|
+
}
|
|
@@ -13,11 +13,10 @@ import { renderDecomposerSystemPrompt } from '../../templates/decomposer-prompts
|
|
|
13
13
|
|
|
14
14
|
export function buildDecomposerSystemPrompt(
|
|
15
15
|
heuristics = [],
|
|
16
|
-
{ maxTickets
|
|
16
|
+
{ maxTickets } = {},
|
|
17
17
|
) {
|
|
18
18
|
const base = renderDecomposerSystemPrompt({
|
|
19
19
|
maxTickets,
|
|
20
|
-
epicId,
|
|
21
20
|
});
|
|
22
21
|
const heuristicsStr =
|
|
23
22
|
heuristics.length > 0
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
*/
|
|
29
29
|
|
|
30
30
|
import { parsePrNumberFromUrl } from '../../../github-url.js';
|
|
31
|
-
import { runStoryReviewCore } from '../../story-close/phases/
|
|
31
|
+
import { runStoryReviewCore } from '../../story-close/phases/review-core.js';
|
|
32
32
|
import { postStructuredComment } from '../../ticketing/state.js';
|
|
33
33
|
|
|
34
34
|
/**
|
|
@@ -28,248 +28,111 @@
|
|
|
28
28
|
* critical-halt path so the Epic-scoped lifecycle ledger still sees
|
|
29
29
|
* the Story drop out.
|
|
30
30
|
*
|
|
31
|
-
*
|
|
32
|
-
* `
|
|
33
|
-
*
|
|
34
|
-
*
|
|
31
|
+
* The shared spine both close paths call `runCodeReview` through
|
|
32
|
+
* (`runStoryReviewCore`, Story #3653) and the shift-left local-lens pass
|
|
33
|
+
* (Epic #4405) live in `review-core.js` and `local-lens-review.js`
|
|
34
|
+
* respectively (extracted by Story #4603). This module is the Epic-attached
|
|
35
|
+
* phase entry point: it owns the advisory error posture and the
|
|
36
|
+
* critical-halt → blocked-envelope translation, and nothing else.
|
|
35
37
|
*/
|
|
36
38
|
|
|
37
|
-
import {
|
|
38
|
-
runAuditSuite,
|
|
39
|
-
selectLocalLenses,
|
|
40
|
-
} from '../../../audit-suite/index.js';
|
|
41
|
-
import { gitSpawn } from '../../../git-utils.js';
|
|
42
39
|
import { Logger } from '../../../Logger.js';
|
|
43
40
|
import { runCodeReview } from '../../code-review.js';
|
|
44
41
|
import { emitBlockedCloseResult } from '../emit-blocked.js';
|
|
42
|
+
import { runLocalLensReview } from './local-lens-review.js';
|
|
43
|
+
import { runStoryReviewCore } from './review-core.js';
|
|
45
44
|
|
|
46
45
|
/**
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
* the maker-blind Story-scope review runs its matched local lenses at `light`
|
|
50
|
-
* depth here rather than paying a deeper pass at Epic close. Fixed for this
|
|
51
|
-
* tier — it is not risk-scaled like the code-review pillar depth.
|
|
52
|
-
*/
|
|
53
|
-
export const STORY_SCOPE_LENS_DEPTH = 'light';
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* Enumerate the files changed in the `baseRef...headRef` diff via
|
|
57
|
-
* `git diff --name-only`. Best-effort: returns `[]` when the diff cannot be
|
|
58
|
-
* enumerated (git failure, missing ref) and never throws, mirroring the
|
|
59
|
-
* advisory posture of the surrounding review phase. Synchronous `gitSpawn`
|
|
60
|
-
* (returns `{ status, stdout }`) is the same seam `code-review.js#countChangedFiles`
|
|
61
|
-
* uses.
|
|
46
|
+
* Read a review envelope's severity counts, tolerating the partial envelope a
|
|
47
|
+
* misbehaving provider adapter can return. Pure.
|
|
62
48
|
*
|
|
63
|
-
* @param {
|
|
64
|
-
* @returns {
|
|
49
|
+
* @param {object|null|undefined} reviewResult
|
|
50
|
+
* @returns {{ critical: number, high: number, medium: number, suggestion: number }}
|
|
65
51
|
*/
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
process.cwd(),
|
|
74
|
-
'diff',
|
|
75
|
-
'--name-only',
|
|
76
|
-
`${baseRef}...${headRef}`,
|
|
77
|
-
);
|
|
78
|
-
if (!result || result.status !== 0 || typeof result.stdout !== 'string') {
|
|
79
|
-
return [];
|
|
52
|
+
function resolveSeverity(reviewResult) {
|
|
53
|
+
return (
|
|
54
|
+
reviewResult?.severity ?? {
|
|
55
|
+
critical: 0,
|
|
56
|
+
high: 0,
|
|
57
|
+
medium: 0,
|
|
58
|
+
suggestion: 0,
|
|
80
59
|
}
|
|
81
|
-
|
|
82
|
-
.split('\n')
|
|
83
|
-
.map((f) => f.trim())
|
|
84
|
-
.filter(Boolean);
|
|
85
|
-
} catch {
|
|
86
|
-
return [];
|
|
87
|
-
}
|
|
60
|
+
);
|
|
88
61
|
}
|
|
89
62
|
|
|
90
63
|
/**
|
|
91
|
-
*
|
|
92
|
-
* `filePatterns` match the actual Story diff (`baseRef...headRef`) and
|
|
93
|
-
* materialize their lens-prompt bodies at `light` depth. This is the
|
|
94
|
-
* shift-left tier from Epic #4405 — it runs **inside** the story-close
|
|
95
|
-
* subprocess spine (called only from {@link runStoryReviewCore}), never in the
|
|
96
|
-
* delivering child's (maker's) context, so a maker never grades its own work.
|
|
97
|
-
*
|
|
98
|
-
* A diff that matches no local lens adds **no** lens work: the roster is empty
|
|
99
|
-
* and `runAuditSuite` is never invoked. Best-effort and total — a git or
|
|
100
|
-
* materialization failure degrades to `{ skipped: true, lenses: [] }` and is
|
|
101
|
-
* logged via `progress`, matching the advisory posture the review phase already
|
|
102
|
-
* takes for provider/transport failures.
|
|
64
|
+
* Collect the extra fields for the code-review-critical blocked envelope. Pure.
|
|
103
65
|
*
|
|
104
|
-
* @param {{
|
|
105
|
-
*
|
|
106
|
-
* headRef: string,
|
|
107
|
-
* progress: (tag: string, msg: string) => void,
|
|
108
|
-
* progressTag?: string,
|
|
109
|
-
* gitSpawnFn?: typeof gitSpawn,
|
|
110
|
-
* selectLocalLensesFn?: typeof selectLocalLenses,
|
|
111
|
-
* runAuditSuiteFn?: typeof runAuditSuite,
|
|
112
|
-
* }} args
|
|
113
|
-
* @returns {Promise<{
|
|
114
|
-
* depth: 'light',
|
|
115
|
-
* lenses: string[],
|
|
116
|
-
* skipped: boolean,
|
|
117
|
-
* materialized: object|null,
|
|
118
|
-
* }>}
|
|
119
|
-
*/
|
|
120
|
-
export async function runLocalLensReview({
|
|
121
|
-
baseRef,
|
|
122
|
-
headRef,
|
|
123
|
-
progress,
|
|
124
|
-
progressTag = 'CODE-REVIEW',
|
|
125
|
-
gitSpawnFn = gitSpawn,
|
|
126
|
-
selectLocalLensesFn = selectLocalLenses,
|
|
127
|
-
runAuditSuiteFn = runAuditSuite,
|
|
128
|
-
}) {
|
|
129
|
-
const empty = {
|
|
130
|
-
depth: STORY_SCOPE_LENS_DEPTH,
|
|
131
|
-
lenses: [],
|
|
132
|
-
skipped: true,
|
|
133
|
-
materialized: null,
|
|
134
|
-
};
|
|
135
|
-
let lenses;
|
|
136
|
-
try {
|
|
137
|
-
const changedFiles = enumerateChangedFiles({
|
|
138
|
-
baseRef,
|
|
139
|
-
headRef,
|
|
140
|
-
gitSpawnFn,
|
|
141
|
-
});
|
|
142
|
-
lenses = selectLocalLensesFn({ changedFiles });
|
|
143
|
-
if (lenses.length === 0) {
|
|
144
|
-
progress(
|
|
145
|
-
progressTag,
|
|
146
|
-
'No local lens matched the Story diff — skipping the lens pass.',
|
|
147
|
-
);
|
|
148
|
-
return empty;
|
|
149
|
-
}
|
|
150
|
-
const materialized = await runAuditSuiteFn({ auditWorkflows: lenses });
|
|
151
|
-
progress(
|
|
152
|
-
progressTag,
|
|
153
|
-
`Ran ${lenses.length} local lens(es) at ${STORY_SCOPE_LENS_DEPTH} depth: ${lenses.join(', ')}.`,
|
|
154
|
-
);
|
|
155
|
-
return {
|
|
156
|
-
depth: STORY_SCOPE_LENS_DEPTH,
|
|
157
|
-
lenses,
|
|
158
|
-
skipped: false,
|
|
159
|
-
materialized,
|
|
160
|
-
};
|
|
161
|
-
} catch (err) {
|
|
162
|
-
// The lens pass is advisory: a git or materialization failure must not
|
|
163
|
-
// fail the close. Log and degrade to a skipped envelope.
|
|
164
|
-
progress(
|
|
165
|
-
progressTag,
|
|
166
|
-
`⚠️ local lens pass failed (continuing without it): ${err?.message ?? err}`,
|
|
167
|
-
);
|
|
168
|
-
return empty;
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
/**
|
|
173
|
-
* Collect the extra fields for the code-review-critical blocked envelope.
|
|
174
|
-
* Pure; used by `runStoryCodeReview` to populate the `extra` argument of
|
|
175
|
-
* `emitBlockedCloseResult`.
|
|
66
|
+
* @param {{ storyId: number, reviewResult: object }} args
|
|
67
|
+
* @returns {object}
|
|
176
68
|
*/
|
|
177
69
|
function buildCodeReviewBlockedExtra({ storyId, reviewResult }) {
|
|
178
|
-
const severity = reviewResult?.severity ?? {
|
|
179
|
-
critical: 0,
|
|
180
|
-
high: 0,
|
|
181
|
-
medium: 0,
|
|
182
|
-
suggestion: 0,
|
|
183
|
-
};
|
|
184
70
|
return {
|
|
185
|
-
storyId
|
|
71
|
+
storyId,
|
|
186
72
|
blockerReason: reviewResult?.blockerReason ?? null,
|
|
187
|
-
severity,
|
|
73
|
+
severity: resolveSeverity(reviewResult),
|
|
188
74
|
posted: reviewResult?.posted ?? false,
|
|
189
75
|
exitCode: 1,
|
|
190
76
|
};
|
|
191
77
|
}
|
|
192
78
|
|
|
193
79
|
/**
|
|
194
|
-
*
|
|
195
|
-
*
|
|
196
|
-
* (`runStoryCodeReview`) and the standalone close path
|
|
197
|
-
* (`single-story-close/phases/code-review.js#runStoryScopeReview`) so the
|
|
198
|
-
* invocation pattern lives in one place (Story #3653).
|
|
199
|
-
*
|
|
200
|
-
* The caller is responsible for error handling and result interpretation —
|
|
201
|
-
* this function propagates throws rather than swallowing them, because the
|
|
202
|
-
* two callers have different advisory postures:
|
|
80
|
+
* Render the operator-facing one-line summary of a completed (non-halting)
|
|
81
|
+
* review. Pure.
|
|
203
82
|
*
|
|
204
|
-
*
|
|
205
|
-
*
|
|
206
|
-
|
|
83
|
+
* @param {object} reviewResult
|
|
84
|
+
* @returns {string}
|
|
85
|
+
*/
|
|
86
|
+
function formatReviewSummary(reviewResult) {
|
|
87
|
+
const { high, medium, suggestion } = resolveSeverity(reviewResult);
|
|
88
|
+
const posted = reviewResult?.posted ?? false;
|
|
89
|
+
return `Review complete — high=${high} medium=${medium} suggestion=${suggestion} (posted=${posted}).`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Run the shared review spine, absorbing an adapter / wiring failure into this
|
|
94
|
+
* phase's advisory posture: the review is best-effort when the provider cannot
|
|
95
|
+
* complete, and the gates already vouched for the diff at this point.
|
|
207
96
|
*
|
|
208
|
-
*
|
|
209
|
-
*
|
|
210
|
-
*
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
97
|
+
* @param {object} args Spine arguments (see {@link runStoryReviewCore}).
|
|
98
|
+
* @returns {Promise<object|null>} The review envelope, or `null` when the
|
|
99
|
+
* review threw and the close should proceed unblocked.
|
|
100
|
+
*/
|
|
101
|
+
async function invokeReviewCore(args) {
|
|
102
|
+
try {
|
|
103
|
+
return await runStoryReviewCore(args);
|
|
104
|
+
} catch (err) {
|
|
105
|
+
Logger.warn?.(
|
|
106
|
+
`[story-close] ⚠️ code-review phase failed (continuing without blocker): ${err?.message ?? err}`,
|
|
107
|
+
);
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Translate a halting (critical-findings) review into the blocked envelope the
|
|
114
|
+
* caller returns verbatim, emitting `story.blocked` onto the bus on the way.
|
|
214
115
|
*
|
|
215
116
|
* @param {{
|
|
216
|
-
* storyId: number
|
|
217
|
-
*
|
|
218
|
-
*
|
|
219
|
-
* commentTargetId?: number|null,
|
|
220
|
-
* provider: object,
|
|
117
|
+
* storyId: number,
|
|
118
|
+
* reviewResult: object,
|
|
119
|
+
* bus: { emit: Function }|null,
|
|
221
120
|
* progress: (tag: string, msg: string) => void,
|
|
222
|
-
* progressTag?: string,
|
|
223
|
-
* runCodeReviewFn?: typeof runCodeReview,
|
|
224
|
-
* runLocalLensReviewFn?: typeof runLocalLensReview,
|
|
225
121
|
* }} args
|
|
226
|
-
* @returns {Promise<object>}
|
|
227
|
-
* with a `localLensReview` field carrying the Story-scope local-lens pass
|
|
228
|
-
* outcome (Epic #4405, Story #4409). Both close entry points reach the lens
|
|
229
|
-
* pass through this single spine, so it runs on the Epic-attached and
|
|
230
|
-
* standalone paths alike and always inside the close subprocess.
|
|
122
|
+
* @returns {Promise<object>} The blocked envelope.
|
|
231
123
|
*/
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
runCodeReviewFn = runCodeReview,
|
|
241
|
-
runLocalLensReviewFn = runLocalLensReview,
|
|
242
|
-
}) {
|
|
243
|
-
const storyIdNum = Number(storyId);
|
|
244
|
-
const opts = {
|
|
245
|
-
scope: 'story',
|
|
246
|
-
ticketId: storyIdNum,
|
|
247
|
-
baseRef,
|
|
248
|
-
headRef,
|
|
249
|
-
provider,
|
|
250
|
-
logger: {
|
|
251
|
-
info: (m) => progress(progressTag, m),
|
|
252
|
-
warn: (m) => progress(progressTag, `⚠️ ${m}`),
|
|
253
|
-
},
|
|
254
|
-
};
|
|
255
|
-
if (commentTargetId != null) {
|
|
256
|
-
opts.commentTargetId = commentTargetId;
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
// Shift-left local-lens pass (Epic #4405). Runs matched local lenses at
|
|
260
|
-
// `light` depth against the actual Story diff, inside this close-subprocess
|
|
261
|
-
// spine so the maker never grades its own work. Advisory — it never blocks
|
|
262
|
-
// the close and its outcome rides on the returned envelope for downstream
|
|
263
|
-
// consumers.
|
|
264
|
-
const localLensReview = await runLocalLensReviewFn({
|
|
265
|
-
baseRef,
|
|
266
|
-
headRef,
|
|
124
|
+
async function emitCriticalBlock({ storyId, reviewResult, bus, progress }) {
|
|
125
|
+
const { critical } = resolveSeverity(reviewResult);
|
|
126
|
+
return emitBlockedCloseResult({
|
|
127
|
+
storyId,
|
|
128
|
+
phase: 'closing',
|
|
129
|
+
reason: 'code-review-critical',
|
|
130
|
+
extra: buildCodeReviewBlockedExtra({ storyId, reviewResult }),
|
|
131
|
+
bus,
|
|
267
132
|
progress,
|
|
268
|
-
|
|
133
|
+
blockedMessage: `Story #${storyId} blocked: code-review reported ${critical} critical blocker(s).`,
|
|
134
|
+
logger: Logger,
|
|
269
135
|
});
|
|
270
|
-
|
|
271
|
-
const result = await runCodeReviewFn(opts);
|
|
272
|
-
return { ...result, localLensReview };
|
|
273
136
|
}
|
|
274
137
|
|
|
275
138
|
/**
|
|
@@ -301,65 +164,44 @@ export async function runStoryReviewCore({
|
|
|
301
164
|
* (Epic #4405, Story #4409) when the review completed; it is absent only when
|
|
302
165
|
* the whole review phase threw (advisory failure).
|
|
303
166
|
*/
|
|
304
|
-
export async function runStoryCodeReview(
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
} = args;
|
|
315
|
-
|
|
167
|
+
export async function runStoryCodeReview({
|
|
168
|
+
storyId,
|
|
169
|
+
baseBranch,
|
|
170
|
+
storyBranch,
|
|
171
|
+
provider,
|
|
172
|
+
bus,
|
|
173
|
+
progress,
|
|
174
|
+
runCodeReviewFn = runCodeReview,
|
|
175
|
+
runLocalLensReviewFn = runLocalLensReview,
|
|
176
|
+
}) {
|
|
316
177
|
const storyIdNum = Number(storyId);
|
|
317
178
|
progress(
|
|
318
179
|
'CODE-REVIEW',
|
|
319
180
|
`Running Story-scope review (${baseBranch}…${storyBranch})...`,
|
|
320
181
|
);
|
|
321
182
|
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
});
|
|
333
|
-
} catch (err) {
|
|
334
|
-
// Adapter / wiring failure — log and proceed. The review is advisory
|
|
335
|
-
// when the provider cannot complete; the gates already vouched for
|
|
336
|
-
// the diff at this point.
|
|
337
|
-
Logger.warn?.(
|
|
338
|
-
`[story-close] ⚠️ code-review phase failed (continuing without blocker): ${err?.message ?? err}`,
|
|
339
|
-
);
|
|
340
|
-
return { blocked: null };
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
const localLensReview = reviewResult?.localLensReview;
|
|
183
|
+
const reviewResult = await invokeReviewCore({
|
|
184
|
+
storyId: storyIdNum,
|
|
185
|
+
baseRef: baseBranch,
|
|
186
|
+
headRef: storyBranch,
|
|
187
|
+
provider,
|
|
188
|
+
progress,
|
|
189
|
+
runCodeReviewFn,
|
|
190
|
+
runLocalLensReviewFn,
|
|
191
|
+
});
|
|
192
|
+
if (reviewResult === null) return { blocked: null };
|
|
344
193
|
|
|
345
|
-
|
|
346
|
-
|
|
194
|
+
const localLensReview = reviewResult.localLensReview;
|
|
195
|
+
if (reviewResult.halted) {
|
|
196
|
+
const blocked = await emitCriticalBlock({
|
|
347
197
|
storyId: storyIdNum,
|
|
348
|
-
|
|
349
|
-
reason: 'code-review-critical',
|
|
350
|
-
extra: buildCodeReviewBlockedExtra({ storyId: storyIdNum, reviewResult }),
|
|
198
|
+
reviewResult,
|
|
351
199
|
bus,
|
|
352
200
|
progress,
|
|
353
|
-
blockedMessage: `Story #${storyIdNum} blocked: code-review reported ${reviewResult.severity.critical} critical blocker(s).`,
|
|
354
|
-
logger: Logger,
|
|
355
201
|
});
|
|
356
202
|
return { blocked, localLensReview };
|
|
357
203
|
}
|
|
358
204
|
|
|
359
|
-
|
|
360
|
-
progress(
|
|
361
|
-
'CODE-REVIEW',
|
|
362
|
-
`Review complete — high=${counts.high ?? 0} medium=${counts.medium ?? 0} suggestion=${counts.suggestion ?? 0} (posted=${reviewResult?.posted ?? false}).`,
|
|
363
|
-
);
|
|
205
|
+
progress('CODE-REVIEW', formatReviewSummary(reviewResult));
|
|
364
206
|
return { blocked: null, localLensReview };
|
|
365
207
|
}
|