mandrel 1.71.0 → 1.73.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.
@@ -358,6 +358,19 @@ Read with `getCommands(config)` — see
358
358
  | ------------------ | -------- | ------- | -------------------------------------------------------------------------------------------------- |
359
359
  | `docsContextFiles` | No | `[]` | Files the context-hydration engine includes when assembling agent prompts. Resolved against `paths.docsRoot`. |
360
360
 
361
+ > **Entries are plain filenames, not globs.** Each entry is resolved as a
362
+ > single file under `paths.docsRoot`; the loader does **not** expand glob
363
+ > patterns. This matters for the decisions log: when a project adopts the
364
+ > index + `decisions/` ADR-directory layout (see
365
+ > [`documentation-and-adrs`](../skills/core/documentation-and-adrs/SKILL.md)),
366
+ > the **index** `decisions.md` stays the mandatory-read and the per-ADR bodies
367
+ > under `decisions/` are link-followed on demand — **index-only by default**.
368
+ > Auto-loading every ADR body into each task's context would reintroduce the
369
+ > bloat the directory split exists to remove. A project that genuinely wants
370
+ > the full ADR set in mandatory context must opt in by listing the individual
371
+ > ADR files explicitly (one filename per entry); there is no built-in
372
+ > `decisions/*.md` glob.
373
+
361
374
  ---
362
375
 
363
376
  ## `github`
@@ -86,11 +86,13 @@ apply by checking the `.agents/rules/` directory (e.g.,
86
86
  ### G. Structured Configuration
87
87
 
88
88
  Refer to `.agentrc.json` to understand your operational limits (e.g., allowed
89
- auto-run permissions, default personas). Refer to the **Tech Stack** section
90
- of `docs/architecture.md` for the project's specific technology choices
91
- (database, ORM, API framework, auth provider, validation library, workspace
92
- paths). Project-specific technology context is intentionally kept out of
93
- `.agentrc.json`.
89
+ auto-run permissions, default personas). For the project's specific
90
+ technology choices (database, ORM, API framework, auth provider, validation
91
+ library, workspace paths), refer to the project's Tech Stack inventory: a
92
+ dedicated `docs/tech-stack.md` when present (the single-ownership convention),
93
+ otherwise the **Tech Stack** section of `docs/architecture.md` (a numbered or
94
+ decorated heading such as `## 1. Tech Stack` is fine). Project-specific
95
+ technology context is intentionally kept out of `.agentrc.json`.
94
96
 
95
97
  ### H. Observability & Friction Telemetry
96
98
 
@@ -240,7 +242,13 @@ stops.
240
242
  data dictionary, decisions log, patterns, etc.) and replaces any
241
243
  hardcoded filename list. Resolve each entry against
242
244
  `project.paths.docsRoot` (default `docs/`) and skip silently
243
- when an entry's file is absent.
245
+ when an entry's file is absent. The decisions log (`decisions.md`) may
246
+ be either a single-file dated-entry log or an **index** into a
247
+ `decisions/` ADR directory — both are first-class layouts (see
248
+ [`skills/core/documentation-and-adrs`](skills/core/documentation-and-adrs/SKILL.md)).
249
+ When it is an index, only the index is the mandatory-read; the
250
+ per-ADR bodies under `decisions/` are link-followed on demand
251
+ (index-only by default), not auto-loaded into every task's context.
244
252
  - **Conditional Reads**: When the task touches UI copy, layout, or
245
253
  routing and the corresponding file is present in the project, also
246
254
  read `docs/style-guide.md` and `docs/web-routes.md`. Skip both when
@@ -14,8 +14,10 @@ express it through code or documented configuration files.
14
14
 
15
15
  1. **Read Context:** Before making any infrastructure changes, analyze the
16
16
  existing CI/CD, deployment, and security configurations.
17
- 2. **Follow Protocols:** Adhere strictly to the **Tech Stack** section of
18
- `docs/architecture.md` and the `orchestration` block of `.agentrc.json`.
17
+ 2. **Follow Protocols:** Adhere strictly to the project's Tech Stack
18
+ inventory — a dedicated `docs/tech-stack.md` when present, otherwise the
19
+ **Tech Stack** section of `docs/architecture.md` — and the `orchestration`
20
+ block of `.agentrc.json`.
19
21
  3. **Validate Always:** For every task, determine how the change will be
20
22
  monitored and validated (logs, health checks, or test gates).
21
23
 
@@ -0,0 +1,430 @@
1
+ /**
2
+ * bootstrap/issue-forms-template — Story #4227 (framework-gap)
3
+ *
4
+ * Generates GitHub **Issue Forms** (`.github/ISSUE_TEMPLATE/story.yml` and
5
+ * `epic.yml`) derived from the canonical Story-body SSOT
6
+ * (`lib/story-body/story-body.js`). The forms exist so a human filing a
7
+ * Story/Epic in the GitHub web UI produces a body that round-trips through
8
+ * the same `story-body.parse()` agents rely on — closing the
9
+ * human↔agent ticket-shape gap.
10
+ *
11
+ * ## Why generated, not hand-authored
12
+ *
13
+ * Hand-authoring the forms would let the field headings drift from what
14
+ * `parse()` expects. Instead the form field set is derived from a single
15
+ * `HUMAN_INTENT_FIELDS` table here, and each field's heading is the exact
16
+ * section name the parser maps (`goal` → `## Goal`, etc.). The CI
17
+ * conformance lint (`lint-issue-body.js`) runs the real `parse()` against
18
+ * human-opened issues so the form and the parser cannot silently drift —
19
+ * the same model `ci-workflow-template.js` follows for `ci.yml`.
20
+ *
21
+ * ## Form fields ⊆ body schema
22
+ *
23
+ * The forms expose only the human **intent subset** — `goal`, `changes`,
24
+ * `acceptance`, `verify`, `references`. Machine-managed sections (the
25
+ * `<!-- meta: … -->` block, the frozen dispatch manifest, `agent::*`
26
+ * transitions) are deliberately absent; the runtime fills those. The
27
+ * relationship is "form fields ⊆ body schema," not "form == body."
28
+ *
29
+ * ## GitHub serialization contract (the lossy seam)
30
+ *
31
+ * GitHub Issue Forms render every `textarea`/`input` field as:
32
+ *
33
+ * ```text
34
+ * ### {label}
35
+ *
36
+ * {value}
37
+ * ```
38
+ *
39
+ * i.e. the field label becomes a level-3 heading (`###`), not the level-2
40
+ * (`##`) the canonical serializer emits. `story-body.parse()` accepts both
41
+ * heading levels (Story #4227 widened its heading regex), so a body
42
+ * assembled from form output round-trips. This is the single point where
43
+ * the form shape and the canonical serializer differ, and it is covered by
44
+ * the round-trip test that feeds simulated GitHub output back through
45
+ * `parse()`.
46
+ *
47
+ * @module bootstrap/issue-forms-template
48
+ */
49
+
50
+ import fs from 'node:fs';
51
+ import path from 'node:path';
52
+
53
+ /**
54
+ * Directory (relative to a project root) GitHub reads issue forms from.
55
+ * Internal — the per-form path constants below are the exported surface.
56
+ */
57
+ const ISSUE_TEMPLATE_RELATIVE_DIR = '.github/ISSUE_TEMPLATE';
58
+
59
+ /**
60
+ * Relative paths of the two generated forms, surfaced as constants so tests
61
+ * and the bootstrap caller assert the canonical write targets without
62
+ * re-deriving them.
63
+ */
64
+ export const STORY_FORM_RELATIVE_PATH = `${ISSUE_TEMPLATE_RELATIVE_DIR}/story.yml`;
65
+ export const EPIC_FORM_RELATIVE_PATH = `${ISSUE_TEMPLATE_RELATIVE_DIR}/epic.yml`;
66
+
67
+ /**
68
+ * The human **intent subset** of the Story-body schema, in canonical
69
+ * `parse()` section order. Each entry drives one form field. `heading` is
70
+ * the exact section name `story-body.parse()` maps (case-insensitive); the
71
+ * generated YAML uses it verbatim as the field `label` so GitHub's
72
+ * `### {label}` render produces a heading the parser recognises.
73
+ *
74
+ * Machine-managed body fields (`wide`, `reason_to_exist`,
75
+ * `estimated_test_files`, `depends_on` meta) are intentionally absent —
76
+ * the runtime fills those. `depends_on` is exposed as a free-text input
77
+ * that serializes to the `blocked by #N` footer `parse()` already reads.
78
+ *
79
+ * @type {Array<{
80
+ * id: string,
81
+ * heading: string,
82
+ * label: string,
83
+ * description: string,
84
+ * placeholder: string,
85
+ * required: boolean,
86
+ * kind: 'textarea'|'input',
87
+ * }>}
88
+ */
89
+ export const HUMAN_INTENT_FIELDS = [
90
+ {
91
+ id: 'goal',
92
+ heading: 'Goal',
93
+ label: 'Goal',
94
+ description: 'One sentence: the purpose of this work.',
95
+ placeholder:
96
+ 'Add a conformance lint that parses human-opened issue bodies.',
97
+ required: true,
98
+ kind: 'textarea',
99
+ },
100
+ {
101
+ id: 'changes',
102
+ heading: 'Changes',
103
+ label: 'Changes',
104
+ description:
105
+ 'Files or globs this work touches, one per line (e.g. `- src/foo.js: add handler`). Advisory — the binding contract is Acceptance/Verify.',
106
+ placeholder: '- src/foo.js: add the handler\n- tests/foo.test.js: cover it',
107
+ required: false,
108
+ kind: 'textarea',
109
+ },
110
+ {
111
+ id: 'acceptance',
112
+ heading: 'Acceptance',
113
+ label: 'Acceptance',
114
+ description:
115
+ 'Observable, checkable criteria — one per line. This is the binding definition of done.',
116
+ placeholder:
117
+ '- The lint comments on a non-conformant body\n- A conformant body produces no comment',
118
+ required: true,
119
+ kind: 'textarea',
120
+ },
121
+ {
122
+ id: 'verify',
123
+ heading: 'Verify',
124
+ label: 'Verify',
125
+ description:
126
+ 'Exact commands that prove the work, one per line (annotate the tier, e.g. `(unit)`).',
127
+ placeholder: '- npm test -- tests/foo.test.js (unit)',
128
+ required: true,
129
+ kind: 'textarea',
130
+ },
131
+ {
132
+ id: 'references',
133
+ heading: 'References',
134
+ label: 'References',
135
+ description: 'Read-only paths worth consulting, one per line. Optional.',
136
+ placeholder: '- docs/architecture.md',
137
+ required: false,
138
+ kind: 'textarea',
139
+ },
140
+ ];
141
+
142
+ /**
143
+ * Escape a string for safe embedding inside a double-quoted YAML scalar.
144
+ * The generated YAML only ever quotes single-line scalars (labels,
145
+ * descriptions, placeholders), so we escape backslashes, double quotes,
146
+ * and collapse embedded newlines into the literal `\n` placeholder GitHub
147
+ * renders verbatim in the form preview.
148
+ *
149
+ * @param {string} value
150
+ * @returns {string}
151
+ */
152
+ function yamlQuote(value) {
153
+ const escaped = String(value)
154
+ .replace(/\\/g, '\\\\')
155
+ .replace(/"/g, '\\"')
156
+ .replace(/\n/g, '\\n');
157
+ return `"${escaped}"`;
158
+ }
159
+
160
+ /**
161
+ * Render a single Issue-Form field block (a `textarea` or `input`) from a
162
+ * {@link HUMAN_INTENT_FIELDS} descriptor. Indented to sit under the
163
+ * top-level `body:` sequence.
164
+ *
165
+ * @param {(typeof HUMAN_INTENT_FIELDS)[number]} field
166
+ * @returns {string}
167
+ */
168
+ function renderFieldBlock(field) {
169
+ return [
170
+ ` - type: ${field.kind}`,
171
+ ` id: ${field.id}`,
172
+ ' attributes:',
173
+ ` label: ${yamlQuote(field.label)}`,
174
+ ` description: ${yamlQuote(field.description)}`,
175
+ ` placeholder: ${yamlQuote(field.placeholder)}`,
176
+ ' validations:',
177
+ ` required: ${field.required}`,
178
+ ].join('\n');
179
+ }
180
+
181
+ /**
182
+ * Render the shared `depends_on` input. Its value serializes (by the
183
+ * conformance lint / body assembler) into the `blocked by #N` footer lines
184
+ * `parse()` already extracts, so it stays out of the heading-mapped field
185
+ * set above.
186
+ *
187
+ * @returns {string}
188
+ */
189
+ function renderDependsOnBlock() {
190
+ return [
191
+ ' - type: input',
192
+ ' id: depends_on',
193
+ ' attributes:',
194
+ ' label: "Blocked by"',
195
+ ' description: "Comma-separated issue refs this work depends on (e.g. #123, #456). Optional."',
196
+ ' placeholder: "#123, #456"',
197
+ ' validations:',
198
+ ' required: false',
199
+ ].join('\n');
200
+ }
201
+
202
+ /**
203
+ * @typedef {object} IssueFormOptions
204
+ * @property {string} [entryStateLabel='agent::review-spec'] - Lifecycle
205
+ * entry-state label auto-applied alongside the `type::*` label so
206
+ * human-filed tickets land in the same lane as agent-created ones.
207
+ * @property {string} [projectName] - Optional repo/project name woven into
208
+ * the form description. Purely cosmetic.
209
+ */
210
+
211
+ /**
212
+ * Shared header banner stamped on every generated form so the provenance
213
+ * (and the "regenerate, don't hand-edit" rule) travels with the file.
214
+ */
215
+ const GENERATED_BANNER =
216
+ '# Generated by agents-bootstrap-github (Story #4227) from the Story-body SSOT\n' +
217
+ '# (.agents/scripts/lib/story-body/story-body.js). Do NOT hand-edit the\n' +
218
+ '# field set — field headings must stay in lockstep with story-body.parse().\n' +
219
+ '# Re-run /agents-bootstrap-github to refresh. The CI conformance lint\n' +
220
+ '# (lint-issue-body.js) is the drift guard between this form and the parser.';
221
+
222
+ /**
223
+ * Render the GitHub Issue Form YAML for a given ticket type (`story` or
224
+ * `epic`). Both forms share the identical human-intent field set — the
225
+ * difference is the `type::` label and the form name/description — because
226
+ * `parse()` is type-agnostic over the body shape.
227
+ *
228
+ * The output is deterministic so the round-trip + idempotency tests assert
229
+ * on its exact shape.
230
+ *
231
+ * @param {'story'|'epic'} ticketType
232
+ * @param {IssueFormOptions} [opts]
233
+ * @returns {string}
234
+ */
235
+ export function renderIssueForm(ticketType, opts = {}) {
236
+ if (ticketType !== 'story' && ticketType !== 'epic') {
237
+ throw new Error(
238
+ `renderIssueForm: ticketType must be 'story' or 'epic', got ${ticketType}`,
239
+ );
240
+ }
241
+ const entryStateLabel = opts.entryStateLabel ?? 'agent::review-spec';
242
+ const typeLabel = `type::${ticketType}`;
243
+ const titleCase = ticketType === 'story' ? 'Story' : 'Epic';
244
+ const projectSuffix = opts.projectName ? ` for ${opts.projectName}` : '';
245
+
246
+ const fieldBlocks = HUMAN_INTENT_FIELDS.map(renderFieldBlock).join('\n');
247
+
248
+ return `${GENERATED_BANNER}
249
+ name: ${titleCase}
250
+ description: File a ${titleCase} that round-trips through the Mandrel body parser${projectSuffix}.
251
+ title: "[${titleCase}]: "
252
+ labels:
253
+ - ${typeLabel}
254
+ - ${entryStateLabel}
255
+ body:
256
+ - type: markdown
257
+ attributes:
258
+ value: |
259
+ Fill the fields below. They serialize to the canonical Story body
260
+ the framework parses — keep each section's content under its own
261
+ heading. Machine-managed sections (dispatch manifest, lifecycle
262
+ transitions) are added by the runtime; you do not author them here.
263
+ ${fieldBlocks}
264
+ ${renderDependsOnBlock()}
265
+ `;
266
+ }
267
+
268
+ /**
269
+ * Assemble a canonical Story-body markdown string from the per-field values
270
+ * a GitHub Issue Form yields (keyed by field `id`). This is the inverse of
271
+ * the form: it reconstructs what GitHub *would* serialize, using the
272
+ * canonical `## {Heading}` form so the result feeds straight into
273
+ * `story-body.parse()`. The conformance lint and the round-trip test use it
274
+ * to prove the form → parser contract without a live GitHub call.
275
+ *
276
+ * @param {Record<string, string>} values - Field id → raw textarea/input value.
277
+ * @returns {string} Canonical markdown body.
278
+ */
279
+ export function assembleBodyFromFormValues(values = {}) {
280
+ const sections = [];
281
+ for (const field of HUMAN_INTENT_FIELDS) {
282
+ const raw = values[field.id];
283
+ if (typeof raw !== 'string' || raw.trim().length === 0) continue;
284
+ sections.push(`## ${field.heading}\n${raw.trim()}`);
285
+ }
286
+ let body = sections.join('\n\n');
287
+
288
+ const dependsRaw = values.depends_on;
289
+ if (typeof dependsRaw === 'string' && dependsRaw.trim().length > 0) {
290
+ const refs = dependsRaw
291
+ .split(',')
292
+ .map((r) => r.trim())
293
+ .filter(Boolean)
294
+ .map((r) => (r.startsWith('#') ? r : `#${r.replace(/^#/, '')}`));
295
+ if (refs.length > 0) {
296
+ const footer = ['---', ...refs.map((r) => `blocked by ${r}`)].join('\n');
297
+ body = `${body}\n\n${footer}`;
298
+ }
299
+ }
300
+ return body;
301
+ }
302
+
303
+ /**
304
+ * Relative path of the issue-body conformance workflow — the drift guard
305
+ * that runs `story-body.parse()` against human-opened tickets.
306
+ */
307
+ export const CONFORMANCE_WORKFLOW_RELATIVE_PATH =
308
+ '.github/workflows/issue-body-conformance.yml';
309
+
310
+ /**
311
+ * Render the CI workflow that runs the issue-body conformance lint
312
+ * (`lint-issue-body.js`) on opened/edited `type::story` / `type::epic`
313
+ * issues. This is the mechanism that prevents the generated forms and
314
+ * `story-body.parse()` from silently drifting (Story #4227 acceptance).
315
+ * Deterministic so the bootstrap test asserts its exact shape. Internal —
316
+ * exposed to consumers only through {@link ensureIssueForms}.
317
+ *
318
+ * @returns {string}
319
+ */
320
+ function renderConformanceWorkflow() {
321
+ return `# Issue-body conformance lint (Story #4227).
322
+ #
323
+ # Generated by agents-bootstrap-github. Runs the canonical story-body parser
324
+ # against human-opened type::story / type::epic issues and comments when the
325
+ # body does not round-trip, instead of letting the supported human entry
326
+ # points (e.g. /plan from an existing Epic ID) fail silently later. The lint
327
+ # informs; it never fails the issue. Re-run /agents-bootstrap-github to refresh.
328
+ name: Issue Body Conformance
329
+
330
+ on:
331
+ issues:
332
+ types: [opened, edited]
333
+
334
+ permissions:
335
+ contents: read
336
+ issues: write
337
+
338
+ concurrency:
339
+ group: issue-body-conformance-\${{ github.event.issue.number }}
340
+ cancel-in-progress: true
341
+
342
+ jobs:
343
+ conformance:
344
+ name: Parse issue body
345
+ runs-on: ubuntu-latest
346
+ steps:
347
+ - name: Checkout Code
348
+ uses: actions/checkout@v4
349
+
350
+ - name: Setup Node.js
351
+ uses: actions/setup-node@v4
352
+ with:
353
+ node-version: '22'
354
+
355
+ - name: Lint issue body
356
+ env:
357
+ GH_TOKEN: \${{ github.token }}
358
+ ISSUE_NUMBER: \${{ github.event.issue.number }}
359
+ GITHUB_REPOSITORY: \${{ github.repository }}
360
+ run: node .agents/scripts/lint-issue-body.js
361
+ `;
362
+ }
363
+
364
+ /**
365
+ * Write (or refresh) both issue forms into a project checkout. Idempotent at
366
+ * the byte level — mirrors {@link ensureCiWorkflow}'s contract:
367
+ *
368
+ * - file absent → `created`
369
+ * - byte-identical → `unchanged`
370
+ * - operator-edited (differs from the rendered template) → `custom-skip`
371
+ * (the existing file is preserved; `rendered` is returned so the caller
372
+ * can offer a diff)
373
+ *
374
+ * Network-free; safe under tests with a tmp `projectRoot`.
375
+ *
376
+ * @param {object} args
377
+ * @param {string} args.projectRoot
378
+ * @param {IssueFormOptions} [args.options]
379
+ * @param {boolean} [args.write=true] - When `false`, compute the would-be
380
+ * actions without touching disk (dry-run).
381
+ * @returns {{ forms: Array<{ type: 'story'|'epic'|'conformance-workflow',
382
+ * action: 'created'|'unchanged'|'custom-skip',
383
+ * path: string, rendered: string }> }}
384
+ */
385
+ export function ensureIssueForms(args) {
386
+ const projectRoot = args.projectRoot;
387
+ const options = args.options ?? {};
388
+ const write = args.write !== false;
389
+
390
+ // Each target pairs a ticket-type key with the rendered body. The
391
+ // conformance workflow is materialized alongside the forms because it is
392
+ // the forms' drift guard — they ship as one unit.
393
+ const targets = [
394
+ {
395
+ type: 'story',
396
+ rel: STORY_FORM_RELATIVE_PATH,
397
+ rendered: renderIssueForm('story', options),
398
+ },
399
+ {
400
+ type: 'epic',
401
+ rel: EPIC_FORM_RELATIVE_PATH,
402
+ rendered: renderIssueForm('epic', options),
403
+ },
404
+ {
405
+ type: 'conformance-workflow',
406
+ rel: CONFORMANCE_WORKFLOW_RELATIVE_PATH,
407
+ rendered: renderConformanceWorkflow(),
408
+ },
409
+ ];
410
+
411
+ const forms = targets.map(({ type, rel, rendered }) => {
412
+ const target = path.join(projectRoot, rel);
413
+
414
+ if (!fs.existsSync(target)) {
415
+ if (write) {
416
+ fs.mkdirSync(path.dirname(target), { recursive: true });
417
+ fs.writeFileSync(target, rendered, 'utf8');
418
+ }
419
+ return { type, action: 'created', path: target, rendered };
420
+ }
421
+
422
+ const existing = fs.readFileSync(target, 'utf8');
423
+ if (existing === rendered) {
424
+ return { type, action: 'unchanged', path: target, rendered };
425
+ }
426
+ return { type, action: 'custom-skip', path: target, rendered };
427
+ });
428
+
429
+ return { forms };
430
+ }
@@ -177,6 +177,14 @@ export function buildMutationManifest(ctx = {}) {
177
177
  'Seed .agentrc.json from the bundled starter with the operator-supplied owner/repo/handle/base-branch.',
178
178
  reversible: true,
179
179
  },
180
+ {
181
+ phaseGroup: PHASE_GROUPS.REPO_CONFIG,
182
+ target: rel('.github', 'ISSUE_TEMPLATE'),
183
+ action: 'create',
184
+ detail:
185
+ 'Generate the Story/Epic GitHub Issue Forms from the body SSOT so human-filed tickets round-trip through story-body.parse(). Operator-edited forms are preserved.',
186
+ reversible: true,
187
+ },
180
188
  );
181
189
 
182
190
  // --- quality-gates ----------------------------------------------------
@@ -18,6 +18,7 @@ import path from 'node:path';
18
18
  import { pathToFileURL } from 'node:url';
19
19
  import { detectPackageManager as detectPm } from '../detect-package-manager.js';
20
20
  import { LEDGER_RELATIVE_PATH } from './install-ledger.js';
21
+ import { ensureIssueForms } from './issue-forms-template.js';
21
22
  import { PHASE_GROUPS, previewMutationManifest } from './manifest.js';
22
23
  import { applyQualityBootstrap } from './quality-bootstrap.js';
23
24
 
@@ -439,6 +440,36 @@ export function ensureGitignore(ctx) {
439
440
  return { ...outcomes, path: target };
440
441
  }
441
442
 
443
+ /**
444
+ * Materialize the generated GitHub Issue Forms
445
+ * (`.github/ISSUE_TEMPLATE/story.yml` + `epic.yml`) into the consumer
446
+ * project (Story #4227). Derived from the Story-body SSOT so a human-filed
447
+ * ticket round-trips through `story-body.parse()`. Idempotent and additive,
448
+ * mirroring `ensureGitignore`: byte-identical forms are `unchanged`,
449
+ * operator-edited forms are preserved (`custom-skip`). Honours `ctx.preview`
450
+ * (no writes) like the other phases.
451
+ *
452
+ * Returns the per-form action envelope keyed by ticket type.
453
+ *
454
+ * Internal — consumed only by the `issueForms` entry in
455
+ * {@link BOOTSTRAP_PHASES} below.
456
+ *
457
+ * @param {object} ctx
458
+ * @param {string} ctx.projectRoot
459
+ * @param {boolean} [ctx.preview]
460
+ */
461
+ function ensureIssueFormsPhase(ctx) {
462
+ const { forms } = ensureIssueForms({
463
+ projectRoot: ctx.projectRoot,
464
+ write: !ctx.preview,
465
+ });
466
+ const outcomes = {};
467
+ for (const form of forms) {
468
+ outcomes[form.type] = { action: form.action, path: form.path };
469
+ }
470
+ return outcomes;
471
+ }
472
+
442
473
  /**
443
474
  * Step 5 — Run the sync script. Step 6 (parity) is enforced by the
444
475
  * sync script itself (it removes stale entries and writes from the
@@ -690,6 +721,11 @@ export const BOOTSTRAP_PHASES = Object.freeze([
690
721
  phaseGroup: PHASE_GROUPS.IDE_WIRING,
691
722
  run: (ctx) => ensureGitignore(ctx),
692
723
  },
724
+ {
725
+ name: 'issueForms',
726
+ phaseGroup: PHASE_GROUPS.REPO_CONFIG,
727
+ run: (ctx) => ensureIssueFormsPhase(ctx),
728
+ },
693
729
  {
694
730
  name: 'sync',
695
731
  phaseGroup: PHASE_GROUPS.IDE_WIRING,
@@ -26,6 +26,15 @@
26
26
  * "clean sprint" retro trailer), emit `epic.merge.ready`. Otherwise emit
27
27
  * `epic.merge.blocked` with a non-empty reason.
28
28
  *
29
+ * Code-review parse-miss policy (Story #4222): a code-review comment that is
30
+ * present but whose severity bullets cannot be parsed is treated as a DISTINCT
31
+ * condition — surfaced via the `codeReviewUnparseable` signal — and FAILS OPEN
32
+ * rather than blocking. Failing closed on a format miss is indistinguishable,
33
+ * to the operator and to downstream telemetry, from a real disqualifying
34
+ * finding; a parser miss must never masquerade as "the signal said no" inside
35
+ * a generic `epic.merge.blocked`. Genuine critical/high findings still block,
36
+ * because those require the counts to have parsed.
37
+ *
29
38
  * Critical contract:
30
39
  * - The verdict for any given input set is byte-identical to the
31
40
  * pre-inlining legacy module's output — this file is its
@@ -229,11 +238,30 @@ function evaluateCodeReviewSignals(codeReview, reasons) {
229
238
  : { critical: null, high: null, medium: null, suggestion: null };
230
239
  if (!codeReviewFound) {
231
240
  reasons.push('code-review structured comment not found on Epic');
232
- return { codeReviewFound, severity };
241
+ return { codeReviewFound, codeReviewUnparseable: false, severity };
233
242
  }
234
- if (severity.critical === null || severity.high === null) {
235
- reasons.push('code-review severity bullets could not be parsed');
236
- return { codeReviewFound, severity };
243
+ // "Present but unparseable" is a DISTINCT condition from "present and says
244
+ // no" (Story #4222). The canonical renderer
245
+ // (`review-providers/findings-renderer.js`) always emits all four severity
246
+ // bullets, so a body whose critical/high counts we cannot extract is a
247
+ // FORMAT MISS, not a disqualifying signal. Failing closed here — pushing a
248
+ // generic block reason — is indistinguishable, to the operator and to
249
+ // downstream telemetry (the mandrel-bench Autonomy dimension), from a real
250
+ // critical finding: it stalls an otherwise-clean unattended run for a
251
+ // non-reason.
252
+ //
253
+ // Chosen policy: FAIL OPEN on an unparseable code-review body. We surface
254
+ // the condition explicitly via the `codeReviewUnparseable` signal so
255
+ // telemetry can tell a parser miss from a true HITL hand-off, but we do NOT
256
+ // add a disqualifying `reasons[]` entry — the absence of a parseable
257
+ // critical/high count cannot, on its own, block a run whose other signals
258
+ // are clean. Genuine disqualifying review findings (critical > 0 /
259
+ // high > 0) still block below, because those require the counts to have
260
+ // parsed successfully.
261
+ const codeReviewUnparseable =
262
+ severity.critical === null || severity.high === null;
263
+ if (codeReviewUnparseable) {
264
+ return { codeReviewFound, codeReviewUnparseable, severity };
237
265
  }
238
266
  if (severity.critical > 0) {
239
267
  reasons.push(`code-review has ${severity.critical} 🔴 Critical Blocker(s)`);
@@ -241,7 +269,7 @@ function evaluateCodeReviewSignals(codeReview, reasons) {
241
269
  if (severity.high > 0) {
242
270
  reasons.push(`code-review has ${severity.high} 🟠 High Risk finding(s)`);
243
271
  }
244
- return { codeReviewFound, severity };
272
+ return { codeReviewFound, codeReviewUnparseable, severity };
245
273
  }
246
274
 
247
275
  function evaluateRetroSignals(retro, reasons) {
@@ -287,6 +315,7 @@ function evaluateRetroSignals(retro, reasons) {
287
315
  * storyStatuses: string[],
288
316
  * storyBlockers: number,
289
317
  * severity: { critical: number|null, high: number|null, medium: number|null, suggestion: number|null },
318
+ * codeReviewUnparseable: boolean,
290
319
  * retroCompact: boolean,
291
320
  * codeReviewFound: boolean,
292
321
  * retroFound: boolean,
@@ -308,6 +337,7 @@ export function deriveAutoMergeVerdict({ state, codeReview, retro }) {
308
337
  storyStatuses: stateSig.storyStatuses,
309
338
  storyBlockers: stateSig.storyBlockers,
310
339
  severity: reviewSig.severity,
340
+ codeReviewUnparseable: reviewSig.codeReviewUnparseable,
311
341
  retroCompact: retroSig.retroCompact,
312
342
  codeReviewFound: reviewSig.codeReviewFound,
313
343
  retroFound: retroSig.retroFound,
@@ -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)) {