mandrel 1.72.0 → 1.74.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 +9 -5
- package/.agents/docs/configuration.md +13 -0
- package/.agents/instructions.md +14 -6
- package/.agents/personas/devops-engineer.md +4 -2
- package/.agents/scripts/agents-bootstrap-github.js +42 -43
- package/.agents/scripts/bootstrap.js +79 -11
- package/.agents/scripts/lib/bootstrap/issue-forms-template.js +430 -0
- package/.agents/scripts/lib/bootstrap/manifest.js +13 -5
- package/.agents/scripts/lib/bootstrap/project-bootstrap.js +43 -4
- package/.agents/scripts/lib/bootstrap/prompt.js +1 -1
- package/.agents/scripts/lib/bootstrap/summary.js +0 -6
- package/.agents/scripts/lib/bootstrap/workflow-audit.js +25 -12
- package/.agents/scripts/lib/label-taxonomy.js +0 -37
- package/.agents/scripts/lib/onboard/init-tail.js +9 -10
- package/.agents/scripts/lib/orchestration/column-sync.js +22 -41
- package/.agents/scripts/lib/orchestration/epic-spec-reconciler-discriminator.js +56 -2
- package/.agents/scripts/lib/orchestration/project-meta-resolver.js +129 -0
- package/.agents/scripts/lib/story-body/story-body.js +5 -2
- package/.agents/scripts/lib/story-plan.js +41 -4
- package/.agents/scripts/lint-issue-body.js +261 -0
- package/.agents/scripts/providers/github/project-board.js +5 -9
- package/.agents/scripts/providers/github/projects-v2-graphql.js +0 -166
- package/.agents/scripts/providers/github/tickets.js +10 -1
- package/.agents/scripts/providers/github.js +0 -1
- package/.agents/skills/core/documentation-and-adrs/SKILL.md +38 -2
- package/.agents/templates/docs/architecture.md +4 -1
- package/.agents/templates/docs/decisions/_template.md +35 -0
- package/.agents/templates/docs/decisions.index.md +49 -0
- package/.agents/templates/docs/decisions.md +11 -0
- package/.agents/workflows/helpers/plan-story.md +1 -1
- package/docs/CHANGELOG.md +22 -0
- package/package.json +1 -1
|
@@ -62,23 +62,22 @@ function formatMissingList(missing) {
|
|
|
62
62
|
}
|
|
63
63
|
|
|
64
64
|
/** Prompt text shown only on a TTY when asking to scaffold. */
|
|
65
|
-
const SCAFFOLD_PROMPT = '\nCreate placeholders? [
|
|
65
|
+
const SCAFFOLD_PROMPT = '\nCreate placeholders? [y/N]: ';
|
|
66
66
|
|
|
67
67
|
/**
|
|
68
68
|
* Async y/N read from stdin via `node:readline` (mirrors the prompt mechanism
|
|
69
69
|
* in `bootstrap.js`). Returns on Enter and never blocks waiting for EOF the way
|
|
70
70
|
* `fs.readFileSync(0)` did — that EOF-blocking read hung `mandrel init` on an
|
|
71
|
-
* interactive TTY.
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
* passed here is empty.
|
|
71
|
+
* interactive TTY. No is the default (`[y/N]`): only an explicit `y`/`yes`
|
|
72
|
+
* resolves to `true` (create the placeholders). A bare Enter or any other input
|
|
73
|
+
* declines, matching the same default-off policy as `--with-issue-forms`. A
|
|
74
|
+
* read error resolves to `false` so a genuine I/O failure never writes
|
|
75
|
+
* unattended. The prompt text is written by the caller via `stdout`, so the
|
|
76
|
+
* question string passed here is empty.
|
|
78
77
|
*
|
|
79
78
|
* `terminal: false` is **load-bearing**: with terminal mode on (the default
|
|
80
79
|
* when stdout is a TTY) readline emits cursor-control escapes
|
|
81
|
-
* (`\x1b[1G\x1b[0J`) that erase the `Create placeholders? [
|
|
80
|
+
* (`\x1b[1G\x1b[0J`) that erase the `Create placeholders? [y/N]:` prompt already
|
|
82
81
|
* written via the caller's `stdout`, leaving the operator staring at a blank,
|
|
83
82
|
* dead-looking line. Disabling terminal mode preserves the pre-written prompt
|
|
84
83
|
* and reads the line via the TTY's cooked-mode echo. `createInterface` is
|
|
@@ -97,7 +96,7 @@ export async function readConfirm({
|
|
|
97
96
|
});
|
|
98
97
|
try {
|
|
99
98
|
const answer = (await rl.question('')).trim().toLowerCase();
|
|
100
|
-
return answer
|
|
99
|
+
return answer === 'y' || answer === 'yes';
|
|
101
100
|
} catch {
|
|
102
101
|
return false;
|
|
103
102
|
} finally {
|
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
*/
|
|
36
36
|
|
|
37
37
|
import { AGENT_LABELS } from '../label-constants.js';
|
|
38
|
+
import { resolveProjectMeta } from './project-meta-resolver.js';
|
|
38
39
|
|
|
39
40
|
export const LABEL_TO_COLUMN = Object.freeze({
|
|
40
41
|
[AGENT_LABELS.REVIEW_SPEC]: 'Todo',
|
|
@@ -141,49 +142,29 @@ export class ColumnSync {
|
|
|
141
142
|
async #loadMeta() {
|
|
142
143
|
if (this._meta !== null) return this._meta || null;
|
|
143
144
|
try {
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
145
|
+
// Resolve the board by walking the owner-type ladder
|
|
146
|
+
// (organization → user → viewer) via the shared resolver so the
|
|
147
|
+
// org-owned path can't drift from `workflow-audit.js`. The Status
|
|
148
|
+
// single-select field is projected alongside the board id in one
|
|
149
|
+
// round-trip. (Story #4237; org-owner support extends the
|
|
150
|
+
// user/viewer ladder added in #3560.)
|
|
151
|
+
const project = await resolveProjectMeta({
|
|
152
|
+
provider: this.provider,
|
|
153
|
+
// Prefer the explicit `github.projectOwner`; fall back to the repo
|
|
154
|
+
// owner so an org-owned board still gets a login to scope
|
|
155
|
+
// `organization(login:)` / `user(login:)` by even when no separate
|
|
156
|
+
// projectOwner is configured. `viewer` is always the final rung.
|
|
157
|
+
owner: this.projectOwner ?? this.provider.owner ?? null,
|
|
158
|
+
projectNumber: this.projectNumber,
|
|
159
|
+
projectFields: `
|
|
160
|
+
id
|
|
161
|
+
field(name: "Status") {
|
|
162
|
+
... on ProjectV2SingleSelectField {
|
|
163
|
+
id
|
|
164
|
+
options { id name }
|
|
162
165
|
}
|
|
163
166
|
}`,
|
|
164
|
-
|
|
165
|
-
);
|
|
166
|
-
project = data?.user?.projectV2;
|
|
167
|
-
} else {
|
|
168
|
-
const data = await this.provider.graphql(
|
|
169
|
-
`
|
|
170
|
-
query($number: Int!) {
|
|
171
|
-
viewer {
|
|
172
|
-
projectV2(number: $number) {
|
|
173
|
-
id
|
|
174
|
-
field(name: "Status") {
|
|
175
|
-
... on ProjectV2SingleSelectField {
|
|
176
|
-
id
|
|
177
|
-
options { id name }
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
}`,
|
|
183
|
-
{ number: this.projectNumber },
|
|
184
|
-
);
|
|
185
|
-
project = data?.viewer?.projectV2;
|
|
186
|
-
}
|
|
167
|
+
});
|
|
187
168
|
const field = project?.field;
|
|
188
169
|
if (!project || !field) {
|
|
189
170
|
this._meta = false;
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
* @property {string} [reason] Structured reason code when allowed=false.
|
|
46
46
|
*/
|
|
47
47
|
|
|
48
|
-
import { AGENT_LABELS } from '../label-constants.js';
|
|
48
|
+
import { AGENT_LABELS, TYPE_LABELS } from '../label-constants.js';
|
|
49
49
|
|
|
50
50
|
/**
|
|
51
51
|
* Execution-signal labels that block Close. Stored as a frozen Set for
|
|
@@ -245,6 +245,57 @@ export class LabelAllowListViolation extends Error {
|
|
|
245
245
|
}
|
|
246
246
|
}
|
|
247
247
|
|
|
248
|
+
/**
|
|
249
|
+
* Error class thrown synchronously by `assertStoryTypeLabel` when a Story
|
|
250
|
+
* create operation carries no `type::story` label. Named distinctly from
|
|
251
|
+
* `LabelAllowListViolation` so callers can route it separately.
|
|
252
|
+
*
|
|
253
|
+
* The class carries structured metadata (`slug`, `title`) so the error
|
|
254
|
+
* message can name the offending Story clearly.
|
|
255
|
+
*/
|
|
256
|
+
export class MissingTypeLabelError extends Error {
|
|
257
|
+
/**
|
|
258
|
+
* @param {string} message
|
|
259
|
+
* @param {{slug?: string, title?: string}} [meta]
|
|
260
|
+
*/
|
|
261
|
+
constructor(message, meta = {}) {
|
|
262
|
+
super(message);
|
|
263
|
+
this.name = 'MissingTypeLabelError';
|
|
264
|
+
if (meta.slug !== undefined) this.slug = meta.slug;
|
|
265
|
+
if (meta.title !== undefined) this.title = meta.title;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Diff-time assertion. Throws `MissingTypeLabelError` synchronously when a
|
|
271
|
+
* Story create operation is missing the mandatory `type::story` label.
|
|
272
|
+
*
|
|
273
|
+
* Symmetric with `assertNoAgentLabels`: both fire at diff time so the plan
|
|
274
|
+
* fails loudly before the apply pipeline touches GitHub.
|
|
275
|
+
*
|
|
276
|
+
* Only validates Story create ops — Epic creates carry a different mandatory
|
|
277
|
+
* label (`type::epic`) that the caller already hard-codes at issue-creation
|
|
278
|
+
* time; the assertion is not needed there.
|
|
279
|
+
*
|
|
280
|
+
* @param {{slug?: string, title?: string, entity?: string, labels?: string[]}} op
|
|
281
|
+
* @returns {void}
|
|
282
|
+
*/
|
|
283
|
+
export function assertStoryTypeLabel(op) {
|
|
284
|
+
if (!op || typeof op !== 'object') return;
|
|
285
|
+
if (op.entity !== 'story') return;
|
|
286
|
+
// A create op for a Story MUST carry type::story. An absent or empty labels
|
|
287
|
+
// array means the mandatory label is missing — fail loud so the operator
|
|
288
|
+
// sees a named Story rather than a silent unlabeled issue on GitHub.
|
|
289
|
+
if (!Array.isArray(op.labels) || !op.labels.includes(TYPE_LABELS.STORY)) {
|
|
290
|
+
throw new MissingTypeLabelError(
|
|
291
|
+
`create plan for story slug=${op.slug ?? '?'} ("${op.title ?? ''}") is ` +
|
|
292
|
+
`missing the mandatory "${TYPE_LABELS.STORY}" label. Add it to the ` +
|
|
293
|
+
`spec's labels array for this Story and re-run.`,
|
|
294
|
+
{ slug: op.slug, title: op.title },
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
248
299
|
/**
|
|
249
300
|
* Diff-time assertion. Throws `LabelAllowListViolation` synchronously
|
|
250
301
|
* when an operation targets an `agent::*` label. The assertion is the
|
|
@@ -329,7 +380,10 @@ export function assertNoAgentLabels(op) {
|
|
|
329
380
|
*/
|
|
330
381
|
export function assertPlanLabelAllowList(plan) {
|
|
331
382
|
if (!plan || typeof plan !== 'object') return;
|
|
332
|
-
for (const op of plan.creates ?? [])
|
|
383
|
+
for (const op of plan.creates ?? []) {
|
|
384
|
+
assertNoAgentLabels(op);
|
|
385
|
+
assertStoryTypeLabel(op);
|
|
386
|
+
}
|
|
333
387
|
for (const op of plan.updates ?? []) assertNoAgentLabels(op);
|
|
334
388
|
// closes/relinks do not carry label payloads — nothing to assert.
|
|
335
389
|
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* project-meta-resolver — shared GitHub Projects v2 owner-resolution
|
|
3
|
+
* primitive (Story #4237).
|
|
4
|
+
*
|
|
5
|
+
* Background:
|
|
6
|
+
* Both `ColumnSync._loadMeta` (`lib/orchestration/column-sync.js`) and
|
|
7
|
+
* `resolveProjectIdByNumber` (`lib/bootstrap/workflow-audit.js`)
|
|
8
|
+
* needed to turn a `(owner, projectNumber)` pair into a Projects v2
|
|
9
|
+
* board node id. Each historically resolved only **user-owned** /
|
|
10
|
+
* `viewer`-owned boards: `viewer.projectV2(number:)` first, then
|
|
11
|
+
* `user(login:$owner).projectV2(number:)` (Story #3560). Neither had an
|
|
12
|
+
* `organization(login:$owner)` branch, so for an **org-owned** board
|
|
13
|
+
* every lookup failed with `NOT_FOUND` and the `agent::*` → board
|
|
14
|
+
* Status mirror silently no-oped (reproduced on `Beestera/swarm-os`).
|
|
15
|
+
*
|
|
16
|
+
* Fix:
|
|
17
|
+
* A single shared resolver that walks the owner-type ladder in order —
|
|
18
|
+
* `organization(login:$owner)` → `user(login:$owner)` → `viewer` —
|
|
19
|
+
* returning the first board it can resolve. Centralising the ladder in
|
|
20
|
+
* one place means the org path can never again drift between the two
|
|
21
|
+
* call sites.
|
|
22
|
+
*
|
|
23
|
+
* The resolver issues a sub-query for the project itself (`field(name:
|
|
24
|
+
* "Status") { … }` for the column-sync caller, or a bare `id` for the
|
|
25
|
+
* workflow-audit caller). Pass the desired projection in via
|
|
26
|
+
* `projectFields`; the resolver wraps it in the right owner scope and
|
|
27
|
+
* extracts the resolved `projectV2` node.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The owner-resolution ladder, in priority order. Each entry names the
|
|
32
|
+
* GraphQL root field and whether it requires the `$owner` variable.
|
|
33
|
+
*
|
|
34
|
+
* `organization` and `user` are keyed by `login: $owner`; `viewer` is the
|
|
35
|
+
* authenticated identity and takes no owner argument. The viewer rung is
|
|
36
|
+
* the historical default and stays last so a configured owner is always
|
|
37
|
+
* preferred over the ambient identity.
|
|
38
|
+
*/
|
|
39
|
+
const OWNER_SCOPES = Object.freeze([
|
|
40
|
+
{ root: 'organization', needsOwner: true },
|
|
41
|
+
{ root: 'user', needsOwner: true },
|
|
42
|
+
{ root: 'viewer', needsOwner: false },
|
|
43
|
+
]);
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Build the GraphQL document for a single owner scope.
|
|
47
|
+
*
|
|
48
|
+
* @param {{ root: string, needsOwner: boolean }} scope
|
|
49
|
+
* @param {string} projectFields — the inner `projectV2(number: $number) { … }`
|
|
50
|
+
* selection body (everything between the braces).
|
|
51
|
+
* @returns {string}
|
|
52
|
+
*/
|
|
53
|
+
function buildScopedQuery(scope, projectFields) {
|
|
54
|
+
if (scope.needsOwner) {
|
|
55
|
+
return `
|
|
56
|
+
query($owner: String!, $number: Int!) {
|
|
57
|
+
${scope.root}(login: $owner) {
|
|
58
|
+
projectV2(number: $number) {
|
|
59
|
+
${projectFields}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}`;
|
|
63
|
+
}
|
|
64
|
+
return `
|
|
65
|
+
query($number: Int!) {
|
|
66
|
+
${scope.root} {
|
|
67
|
+
projectV2(number: $number) {
|
|
68
|
+
${projectFields}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Resolve a Projects v2 board node by walking the owner-type ladder.
|
|
76
|
+
*
|
|
77
|
+
* Tries `organization(login:$owner)` → `user(login:$owner)` → `viewer` in
|
|
78
|
+
* order, returning the first non-null `projectV2` node. A scope that
|
|
79
|
+
* throws (e.g. GitHub returns `NOT_FOUND` for the wrong owner type) or
|
|
80
|
+
* resolves to `null` is treated as a miss and the ladder advances to the
|
|
81
|
+
* next rung. Returns `null` when every rung misses.
|
|
82
|
+
*
|
|
83
|
+
* When `owner` is falsy, only the `viewer` rung is attempted (there is no
|
|
84
|
+
* login to scope `organization`/`user` by) — this preserves the original
|
|
85
|
+
* viewer-only behaviour for callers that never configured a project owner.
|
|
86
|
+
*
|
|
87
|
+
* @param {{
|
|
88
|
+
* provider: { graphql: Function },
|
|
89
|
+
* owner?: string | null,
|
|
90
|
+
* projectNumber: number,
|
|
91
|
+
* projectFields: string,
|
|
92
|
+
* }} args
|
|
93
|
+
* @returns {Promise<object|null>} the resolved `projectV2` node, or null.
|
|
94
|
+
*/
|
|
95
|
+
export async function resolveProjectMeta(args) {
|
|
96
|
+
const { provider, owner, projectNumber, projectFields } = args ?? {};
|
|
97
|
+
if (!provider || typeof provider.graphql !== 'function') {
|
|
98
|
+
throw new TypeError('resolveProjectMeta requires a provider with graphql');
|
|
99
|
+
}
|
|
100
|
+
if (typeof projectFields !== 'string' || projectFields.length === 0) {
|
|
101
|
+
throw new TypeError(
|
|
102
|
+
'resolveProjectMeta requires a projectFields selection',
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
for (const scope of OWNER_SCOPES) {
|
|
107
|
+
// Skip the owner-scoped rungs when no owner login is available.
|
|
108
|
+
if (scope.needsOwner && !owner) continue;
|
|
109
|
+
|
|
110
|
+
const query = buildScopedQuery(scope, projectFields);
|
|
111
|
+
const vars = scope.needsOwner
|
|
112
|
+
? { owner, number: projectNumber }
|
|
113
|
+
: { number: projectNumber };
|
|
114
|
+
|
|
115
|
+
let data;
|
|
116
|
+
try {
|
|
117
|
+
data = await provider.graphql(query, vars);
|
|
118
|
+
} catch {
|
|
119
|
+
// Wrong owner type (NOT_FOUND), missing scope, etc. — advance the
|
|
120
|
+
// ladder rather than aborting the whole resolution.
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const node = data?.[scope.root]?.projectV2;
|
|
125
|
+
if (node) return node;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
@@ -342,8 +342,11 @@ function splitSections(markdown) {
|
|
|
342
342
|
}
|
|
343
343
|
}
|
|
344
344
|
|
|
345
|
-
// Detect `## Heading` lines
|
|
346
|
-
|
|
345
|
+
// Detect `## Heading` (canonical) or `### Heading` lines. GitHub Issue
|
|
346
|
+
// Forms (Story #4227) render every field label as a level-3 heading
|
|
347
|
+
// (`### Goal`), not the level-2 the canonical serializer emits, so the
|
|
348
|
+
// parser accepts both levels. Any other heading depth is ignored.
|
|
349
|
+
const headingMatch = line.match(/^#{2,3}\s+(\w+)\s*$/i);
|
|
347
350
|
if (headingMatch) {
|
|
348
351
|
const name = headingMatch[1].toLowerCase();
|
|
349
352
|
if (HEADING_TO_FIELD.has(name)) {
|
|
@@ -227,13 +227,51 @@ export function buildContextEnvelope({
|
|
|
227
227
|
}
|
|
228
228
|
|
|
229
229
|
/**
|
|
230
|
-
*
|
|
231
|
-
*
|
|
230
|
+
* Extract the "Tech Stack" `##` section from a markdown document.
|
|
231
|
+
*
|
|
232
|
+
* Tolerates a numbered / decorated heading (`## 1. Tech Stack`,
|
|
233
|
+
* `## Tech Stack`, etc.) and a section that is the final `##` in the
|
|
234
|
+
* file (the terminator matches the next `##` heading **or** end-of-file).
|
|
235
|
+
* Returns the matched section text (re-headed to a clean `## Tech Stack`)
|
|
236
|
+
* or `null` when no Tech Stack heading is present.
|
|
237
|
+
*
|
|
238
|
+
* @param {string} content
|
|
239
|
+
* @returns {string|null}
|
|
240
|
+
*/
|
|
241
|
+
function extractTechStackSection(content) {
|
|
242
|
+
const match = content.match(
|
|
243
|
+
/^##\s+(?:\d+[.)]\s+)?Tech Stack\s*$([\s\S]*?)(?=^##\s+|(?![\s\S]))/m,
|
|
244
|
+
);
|
|
245
|
+
return match ? `## Tech Stack${match[1]}`.trim() : null;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Resolve the project's Tech Stack inventory for the host LLM, in order:
|
|
250
|
+
*
|
|
251
|
+
* 1. A dedicated `docs/tech-stack.md` when present (the emerging
|
|
252
|
+
* single-ownership convention — WHAT in tech-stack.md, HOW in
|
|
253
|
+
* architecture.md, WHY in the ADRs). Its full body is returned.
|
|
254
|
+
* 2. Otherwise, the `## Tech Stack` section of `docs/architecture.md`,
|
|
255
|
+
* tolerating a numbered/decorated heading and a final-section
|
|
256
|
+
* heading (no following `##` required).
|
|
257
|
+
*
|
|
258
|
+
* Returns `null` when neither source yields an inventory.
|
|
232
259
|
*
|
|
233
260
|
* @param {string} projectRoot
|
|
234
261
|
* @returns {Promise<string|null>}
|
|
235
262
|
*/
|
|
236
263
|
export async function readTechStackSummary(projectRoot) {
|
|
264
|
+
const dedicatedPath = path.join(projectRoot, 'docs', 'tech-stack.md');
|
|
265
|
+
try {
|
|
266
|
+
const dedicated = await readFile(dedicatedPath, 'utf8');
|
|
267
|
+
const trimmed = dedicated.trim();
|
|
268
|
+
if (trimmed) {
|
|
269
|
+
return trimmed;
|
|
270
|
+
}
|
|
271
|
+
} catch {
|
|
272
|
+
// No dedicated tech-stack.md — fall through to architecture.md.
|
|
273
|
+
}
|
|
274
|
+
|
|
237
275
|
const archPath = path.join(projectRoot, 'docs', 'architecture.md');
|
|
238
276
|
let content;
|
|
239
277
|
try {
|
|
@@ -241,6 +279,5 @@ export async function readTechStackSummary(projectRoot) {
|
|
|
241
279
|
} catch {
|
|
242
280
|
return null;
|
|
243
281
|
}
|
|
244
|
-
|
|
245
|
-
return match ? `## Tech Stack${match[1]}`.trim() : null;
|
|
282
|
+
return extractTechStackSection(content);
|
|
246
283
|
}
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// .agents/scripts/lint-issue-body.js
|
|
3
|
+
/**
|
|
4
|
+
* Issue-body conformance lint (Story #4227).
|
|
5
|
+
*
|
|
6
|
+
* Runs the canonical `story-body.parse()` against a human-opened
|
|
7
|
+
* `type::story` / `type::epic` issue body and reports whether the body
|
|
8
|
+
* round-trips. This is the drift guard between the generated GitHub Issue
|
|
9
|
+
* Forms (`lib/bootstrap/issue-forms-template.js`) and the parser: if a
|
|
10
|
+
* human files a ticket whose body `parse()` rejects (or which lacks the
|
|
11
|
+
* binding `goal` / `acceptance` / `verify` sections), the lint surfaces a
|
|
12
|
+
* **comment** on the issue rather than failing silently — the supported
|
|
13
|
+
* human entry points (`/plan` from an existing Epic ID, the qa-assist →
|
|
14
|
+
* `/plan` handoff) depend on a parseable body.
|
|
15
|
+
*
|
|
16
|
+
* ## Design
|
|
17
|
+
*
|
|
18
|
+
* - `evaluateIssueBody(body)` is **pure** (no I/O): it parses the body and
|
|
19
|
+
* returns a structured conformance verdict. This is the unit-tested core.
|
|
20
|
+
* - The CLI wrapper reads the issue body + labels (via `gh` or env), runs
|
|
21
|
+
* the evaluator, and posts/updates a single marker comment when the body
|
|
22
|
+
* is non-conformant. Network-touching, exercised in CI.
|
|
23
|
+
*
|
|
24
|
+
* GitHub Issue Forms render a skipped optional field as the literal
|
|
25
|
+
* `_No response_`; the evaluator strips that sentinel so an empty optional
|
|
26
|
+
* section does not masquerade as content.
|
|
27
|
+
*
|
|
28
|
+
* @module lint-issue-body
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { spawnSync } from 'node:child_process';
|
|
32
|
+
import { runAsCli } from './lib/cli-utils.js';
|
|
33
|
+
import { parse, StoryBodyParseError } from './lib/story-body/story-body.js';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Marker that identifies the lint's own comment so re-runs update rather
|
|
37
|
+
* than duplicate it.
|
|
38
|
+
*/
|
|
39
|
+
export const LINT_COMMENT_MARKER = '<!-- mandrel:issue-body-conformance -->';
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The binding sections every conformant ticket body MUST carry. `changes`
|
|
43
|
+
* and `references` are advisory (per the Engineer persona's implementation
|
|
44
|
+
* latitude), so they are not required here.
|
|
45
|
+
*/
|
|
46
|
+
const REQUIRED_SECTIONS = [
|
|
47
|
+
{ field: 'goal', label: 'Goal' },
|
|
48
|
+
{ field: 'acceptance', label: 'Acceptance' },
|
|
49
|
+
{ field: 'verify', label: 'Verify' },
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Strip the GitHub Issue Form empty-field sentinel so a skipped optional
|
|
54
|
+
* field is treated as absent rather than literal content.
|
|
55
|
+
*
|
|
56
|
+
* @param {string} body
|
|
57
|
+
* @returns {string}
|
|
58
|
+
*/
|
|
59
|
+
function stripNoResponseSentinel(body) {
|
|
60
|
+
return body
|
|
61
|
+
.split('\n')
|
|
62
|
+
.filter((line) => line.trim() !== '_No response_')
|
|
63
|
+
.join('\n');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* @typedef {object} ConformanceVerdict
|
|
68
|
+
* @property {boolean} conformant - True when the body parses AND carries
|
|
69
|
+
* every required section with non-empty content.
|
|
70
|
+
* @property {string[]} problems - Human-readable problem statements
|
|
71
|
+
* (empty when conformant).
|
|
72
|
+
* @property {string[]} warnings - Non-fatal parser warnings surfaced for
|
|
73
|
+
* transparency (e.g. legacy-path-entry).
|
|
74
|
+
* @property {boolean} parseFailed - True when `parse()` threw (fail-closed).
|
|
75
|
+
*/
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Evaluate an issue body for conformance with the canonical Story-body
|
|
79
|
+
* schema. Pure — no I/O. Fail-closed parse errors are caught and reported
|
|
80
|
+
* as a non-conformant verdict (never thrown), because the caller's job is
|
|
81
|
+
* to *comment*, not to crash CI.
|
|
82
|
+
*
|
|
83
|
+
* @param {string} body - Raw issue-body markdown.
|
|
84
|
+
* @returns {ConformanceVerdict}
|
|
85
|
+
*/
|
|
86
|
+
export function evaluateIssueBody(body) {
|
|
87
|
+
if (typeof body !== 'string' || body.trim().length === 0) {
|
|
88
|
+
return {
|
|
89
|
+
conformant: false,
|
|
90
|
+
problems: ['The issue body is empty.'],
|
|
91
|
+
warnings: [],
|
|
92
|
+
parseFailed: true,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const cleaned = stripNoResponseSentinel(body);
|
|
97
|
+
|
|
98
|
+
let result;
|
|
99
|
+
try {
|
|
100
|
+
result = parse(cleaned);
|
|
101
|
+
} catch (err) {
|
|
102
|
+
if (err instanceof StoryBodyParseError) {
|
|
103
|
+
return {
|
|
104
|
+
conformant: false,
|
|
105
|
+
problems: [
|
|
106
|
+
`The body could not be parsed into the canonical schema: ${err.message}`,
|
|
107
|
+
],
|
|
108
|
+
warnings: [],
|
|
109
|
+
parseFailed: true,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
throw err;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const problems = [];
|
|
116
|
+
|
|
117
|
+
// A legacy string body parses but carries no structured sections — that
|
|
118
|
+
// is exactly the human-filed shape this lint exists to catch.
|
|
119
|
+
if (result.info.isLegacyStringBody) {
|
|
120
|
+
problems.push(
|
|
121
|
+
'The body has no recognised `## Goal` / `## Acceptance` / `## Verify` sections. ' +
|
|
122
|
+
'File via the Story/Epic issue form so it round-trips through the parser.',
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
for (const { field, label } of REQUIRED_SECTIONS) {
|
|
127
|
+
const value = result.body[field];
|
|
128
|
+
const empty =
|
|
129
|
+
value == null ||
|
|
130
|
+
(typeof value === 'string' && value.trim().length === 0) ||
|
|
131
|
+
(Array.isArray(value) && value.length === 0);
|
|
132
|
+
if (empty) {
|
|
133
|
+
problems.push(
|
|
134
|
+
`The required \`## ${label}\` section is missing or empty.`,
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
conformant: problems.length === 0,
|
|
141
|
+
problems,
|
|
142
|
+
warnings: result.warnings,
|
|
143
|
+
parseFailed: false,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Render the markdown comment body the lint posts on a non-conformant
|
|
149
|
+
* issue. Carries {@link LINT_COMMENT_MARKER} so re-runs update in place.
|
|
150
|
+
*
|
|
151
|
+
* @param {ConformanceVerdict} verdict
|
|
152
|
+
* @returns {string}
|
|
153
|
+
*/
|
|
154
|
+
export function renderConformanceComment(verdict) {
|
|
155
|
+
const lines = [
|
|
156
|
+
LINT_COMMENT_MARKER,
|
|
157
|
+
'### ⚠️ Ticket body does not round-trip through the Mandrel parser',
|
|
158
|
+
'',
|
|
159
|
+
'Agents build ticket bodies from a canonical schema that this body does ' +
|
|
160
|
+
'not match, so the supported human entry points (e.g. `/plan` from an ' +
|
|
161
|
+
'existing Epic ID) will reject it. Please fix the following:',
|
|
162
|
+
'',
|
|
163
|
+
...verdict.problems.map((p) => `- ${p}`),
|
|
164
|
+
'',
|
|
165
|
+
'The quickest fix is to refile using the **Story** or **Epic** issue form ' +
|
|
166
|
+
'(New issue → pick the template), which lays out the required sections.',
|
|
167
|
+
];
|
|
168
|
+
if (verdict.warnings.length > 0) {
|
|
169
|
+
lines.push(
|
|
170
|
+
'',
|
|
171
|
+
'<details><summary>Parser warnings (non-blocking)</summary>',
|
|
172
|
+
'',
|
|
173
|
+
...verdict.warnings.map((w) => `- ${w}`),
|
|
174
|
+
'',
|
|
175
|
+
'</details>',
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
return lines.join('\n');
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Thin `gh` wrapper. Returns the trimmed stdout, throwing on a non-zero
|
|
183
|
+
* exit so the CLI surfaces the failure (orchestration-error-handling rule).
|
|
184
|
+
*
|
|
185
|
+
* @param {string[]} args
|
|
186
|
+
* @returns {string}
|
|
187
|
+
*/
|
|
188
|
+
function gh(args) {
|
|
189
|
+
const res = spawnSync('gh', args, { encoding: 'utf8' });
|
|
190
|
+
if (res.status !== 0) {
|
|
191
|
+
throw new Error(
|
|
192
|
+
`gh ${args.join(' ')} failed (exit ${res.status}): ${res.stderr?.trim() ?? ''}`,
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
return (res.stdout ?? '').trim();
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* CLI entry. Reads the target issue (number from `--issue` or the
|
|
200
|
+
* `ISSUE_NUMBER` env), fetches its body + labels, and — when the body is
|
|
201
|
+
* non-conformant — upserts a single marker comment. Always exits 0 (the
|
|
202
|
+
* lint *informs*, it does not block), unless an unexpected I/O error occurs.
|
|
203
|
+
*
|
|
204
|
+
* Flags:
|
|
205
|
+
* --issue <n> Issue number (defaults to env ISSUE_NUMBER).
|
|
206
|
+
* --repo <o/r> owner/repo (defaults to env GITHUB_REPOSITORY).
|
|
207
|
+
* --dry-run Evaluate + print the verdict, never touch GitHub.
|
|
208
|
+
*/
|
|
209
|
+
async function main() {
|
|
210
|
+
const args = process.argv.slice(2);
|
|
211
|
+
const get = (flag) => {
|
|
212
|
+
const i = args.indexOf(flag);
|
|
213
|
+
return i >= 0 ? args[i + 1] : undefined;
|
|
214
|
+
};
|
|
215
|
+
const dryRun = args.includes('--dry-run');
|
|
216
|
+
const issue = get('--issue') ?? process.env.ISSUE_NUMBER;
|
|
217
|
+
const repo = get('--repo') ?? process.env.GITHUB_REPOSITORY;
|
|
218
|
+
|
|
219
|
+
if (!issue) {
|
|
220
|
+
throw new Error('lint-issue-body: --issue <n> or ISSUE_NUMBER is required');
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const repoArgs = repo ? ['--repo', repo] : [];
|
|
224
|
+
const raw = gh([
|
|
225
|
+
'issue',
|
|
226
|
+
'view',
|
|
227
|
+
String(issue),
|
|
228
|
+
...repoArgs,
|
|
229
|
+
'--json',
|
|
230
|
+
'body,labels',
|
|
231
|
+
]);
|
|
232
|
+
const { body, labels } = JSON.parse(raw);
|
|
233
|
+
const labelNames = (labels ?? []).map((l) => l.name);
|
|
234
|
+
const isTicket =
|
|
235
|
+
labelNames.includes('type::story') || labelNames.includes('type::epic');
|
|
236
|
+
|
|
237
|
+
if (!isTicket) {
|
|
238
|
+
// Machine-parsable JSON envelope → process.stdout.write (not console.log),
|
|
239
|
+
// per the .agents/scripts logging contract (tests/enforcement/no-console).
|
|
240
|
+
process.stdout.write(
|
|
241
|
+
`${JSON.stringify({ issue: Number(issue), skipped: 'not-a-ticket' })}\n`,
|
|
242
|
+
);
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const verdict = evaluateIssueBody(body ?? '');
|
|
247
|
+
process.stdout.write(
|
|
248
|
+
`${JSON.stringify({
|
|
249
|
+
issue: Number(issue),
|
|
250
|
+
conformant: verdict.conformant,
|
|
251
|
+
problems: verdict.problems,
|
|
252
|
+
})}\n`,
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
if (verdict.conformant || dryRun) return;
|
|
256
|
+
|
|
257
|
+
const comment = renderConformanceComment(verdict);
|
|
258
|
+
gh(['issue', 'comment', String(issue), ...repoArgs, '--body', comment]);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
runAsCli(import.meta.url, main, { source: 'lint-issue-body' });
|