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.
Files changed (32) hide show
  1. package/.agents/README.md +9 -5
  2. package/.agents/docs/configuration.md +13 -0
  3. package/.agents/instructions.md +14 -6
  4. package/.agents/personas/devops-engineer.md +4 -2
  5. package/.agents/scripts/agents-bootstrap-github.js +42 -43
  6. package/.agents/scripts/bootstrap.js +79 -11
  7. package/.agents/scripts/lib/bootstrap/issue-forms-template.js +430 -0
  8. package/.agents/scripts/lib/bootstrap/manifest.js +13 -5
  9. package/.agents/scripts/lib/bootstrap/project-bootstrap.js +43 -4
  10. package/.agents/scripts/lib/bootstrap/prompt.js +1 -1
  11. package/.agents/scripts/lib/bootstrap/summary.js +0 -6
  12. package/.agents/scripts/lib/bootstrap/workflow-audit.js +25 -12
  13. package/.agents/scripts/lib/label-taxonomy.js +0 -37
  14. package/.agents/scripts/lib/onboard/init-tail.js +9 -10
  15. package/.agents/scripts/lib/orchestration/column-sync.js +22 -41
  16. package/.agents/scripts/lib/orchestration/epic-spec-reconciler-discriminator.js +56 -2
  17. package/.agents/scripts/lib/orchestration/project-meta-resolver.js +129 -0
  18. package/.agents/scripts/lib/story-body/story-body.js +5 -2
  19. package/.agents/scripts/lib/story-plan.js +41 -4
  20. package/.agents/scripts/lint-issue-body.js +261 -0
  21. package/.agents/scripts/providers/github/project-board.js +5 -9
  22. package/.agents/scripts/providers/github/projects-v2-graphql.js +0 -166
  23. package/.agents/scripts/providers/github/tickets.js +10 -1
  24. package/.agents/scripts/providers/github.js +0 -1
  25. package/.agents/skills/core/documentation-and-adrs/SKILL.md +38 -2
  26. package/.agents/templates/docs/architecture.md +4 -1
  27. package/.agents/templates/docs/decisions/_template.md +35 -0
  28. package/.agents/templates/docs/decisions.index.md +49 -0
  29. package/.agents/templates/docs/decisions.md +11 -0
  30. package/.agents/workflows/helpers/plan-story.md +1 -1
  31. package/docs/CHANGELOG.md +22 -0
  32. 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? [Y/n]: ';
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. Yes is the default (`[Y/n]`): a bare Enter — or anything but
72
- * an explicit `n`/`no` — resolves to `true` (create the placeholders), since the
73
- * missing docs are known-needed and the stubs carry a `MANDREL:STUB` marker the
74
- * `/plan` preflight still flags until they are fleshed out. A read error
75
- * resolves to `false` so a genuine I/O failure never writes unattended. The
76
- * prompt text is written by the caller via `stdout`, so the question string
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? [Y/n]:` prompt already
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 !== 'n' && answer !== 'no';
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
- let project;
145
- if (this.projectOwner) {
146
- // When the project is owned by a different account than the
147
- // authenticated viewer, `viewer.projectV2` returns null. Use
148
- // `user(login: $owner).projectV2` instead. (Story #3560)
149
- const data = await this.provider.graphql(
150
- `
151
- query($owner: String!, $number: Int!) {
152
- user(login: $owner) {
153
- projectV2(number: $number) {
154
- id
155
- field(name: "Status") {
156
- ... on ProjectV2SingleSelectField {
157
- id
158
- options { id name }
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
- { owner: this.projectOwner, number: this.projectNumber },
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 ?? []) assertNoAgentLabels(op);
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
- const headingMatch = line.match(/^##\s+(\w+)\s*$/i);
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
- * Strip the leading "Tech Stack" section of `docs/architecture.md` for
231
- * the host LLM. Returns `null` when the file or section is missing.
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
- const match = content.match(/^##\s+Tech Stack\s*$([\s\S]*?)(?=^##\s+\S)/m);
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' });