mandrel 1.72.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.
- package/.agents/docs/configuration.md +13 -0
- package/.agents/instructions.md +14 -6
- package/.agents/personas/devops-engineer.md +4 -2
- package/.agents/scripts/lib/bootstrap/issue-forms-template.js +430 -0
- package/.agents/scripts/lib/bootstrap/manifest.js +8 -0
- package/.agents/scripts/lib/bootstrap/project-bootstrap.js +36 -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/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 +8 -0
- package/package.json +1 -1
|
@@ -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`
|
package/.agents/instructions.md
CHANGED
|
@@ -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).
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
|
18
|
-
`docs/
|
|
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,
|
|
@@ -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' });
|
|
@@ -12,7 +12,7 @@ description:
|
|
|
12
12
|
|
|
13
13
|
- Document the **why**, not the what. Capture context, constraints, alternatives considered, and trade-offs — code already shows what was built.
|
|
14
14
|
- Write an ADR for any decision that would be expensive to reverse (framework choice, data model, auth strategy, API architecture, hosting platform).
|
|
15
|
-
-
|
|
15
|
+
- Mandrel ships **two first-class decisions-log layouts** — pick one at onboarding (see [Decisions-log layouts](#decisions-log-layouts)): the **single-file dated-entry** `docs/decisions.md` (default; best for small projects) or the **index + `docs/decisions/` directory** (MADR-style, one file per ADR; best once the log outgrows a single file). Either way, the canonical ADR sections are **Status, Date, Deciders, Context, Decision, (Alternatives Considered), Consequences**.
|
|
16
16
|
- Mark an ADR's status as `Accepted`, `Superseded by ADR-XXX`, or `Deprecated`. Never silently delete an ADR — supersede it.
|
|
17
17
|
- Do **not** document obvious code; do **not** restate what the code already says. Stale or redundant docs are worse than no docs.
|
|
18
18
|
- Comments explain **non-obvious intent** (the why). If a comment describes what the code does, refactor the code instead.
|
|
@@ -54,9 +54,41 @@ highest-value documentation you can write.
|
|
|
54
54
|
- Choosing between build tools, hosting platforms, or infrastructure
|
|
55
55
|
- Any decision that would be expensive to reverse
|
|
56
56
|
|
|
57
|
+
### Decisions-log layouts
|
|
58
|
+
|
|
59
|
+
Mandrel ships **two supported layouts** for the decisions log. Both keep the
|
|
60
|
+
mandatory-read file named `docs/decisions.md` (the `project.docsContextFiles`
|
|
61
|
+
default), so `config-resolver.js` and every `.agents/` reference resolve the
|
|
62
|
+
same regardless of which you pick — only the **shape** differs. Choose one at
|
|
63
|
+
onboarding:
|
|
64
|
+
|
|
65
|
+
| Layout | Shape | Template(s) | When to use |
|
|
66
|
+
| ----------------------------------- | --------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- |
|
|
67
|
+
| **Single-file dated entries** (default) | One `decisions.md` of append-only `## YYYY-MM-DD — title` entries | [`templates/docs/decisions.md`](../../../templates/docs/decisions.md) | Small projects; a handful of decisions; you want everything in one scannable file. |
|
|
68
|
+
| **Index + `decisions/` directory** | `decisions.md` is a one-row-per-ADR **index**; each ADR is `decisions/NNNN-*.md` | [`templates/docs/decisions.index.md`](../../../templates/docs/decisions.index.md) + [`templates/docs/decisions/_template.md`](../../../templates/docs/decisions/_template.md) | The log has outgrown a single file (dozens of ADRs); you want per-decision history and `git blame` per ADR. |
|
|
69
|
+
|
|
70
|
+
To adopt the directory layout, replace `decisions.md` with the index variant,
|
|
71
|
+
create a `decisions/` directory beside it, and scaffold each ADR from
|
|
72
|
+
`decisions/_template.md` using zero-padded sequential numbering
|
|
73
|
+
(`0001-*.md`, `0002-*.md`, …).
|
|
74
|
+
|
|
75
|
+
> **Loading model (resolved design question).** The decisions **index** is the
|
|
76
|
+
> only artifact loaded into mandatory task context — individual ADR bodies
|
|
77
|
+
> under `decisions/` are **lazy / link-followed**, not auto-loaded. This is
|
|
78
|
+
> **index-only by default**: auto-loading every ADR body into each task's
|
|
79
|
+
> context would reintroduce exactly the bloat the split exists to remove.
|
|
80
|
+
> `project.docsContextFiles` entries are plain filenames resolved against the
|
|
81
|
+
> docs root (no glob expansion in the loader), so the index ships as a normal
|
|
82
|
+
> mandatory-read with no loader change. A project that genuinely wants the full
|
|
83
|
+
> ADR set in mandatory context can add explicit per-file entries (or a
|
|
84
|
+
> `decisions/*.md`-style entry if it maintains its own globbing) as a
|
|
85
|
+
> deliberate opt-in, but that is the exception, not the default.
|
|
86
|
+
|
|
57
87
|
### ADR Template
|
|
58
88
|
|
|
59
|
-
|
|
89
|
+
In the **single-file** layout, append a short dated entry per the
|
|
90
|
+
`templates/docs/decisions.md` format. In the **directory** layout, store ADRs
|
|
91
|
+
in `docs/decisions/` with sequential numbering:
|
|
60
92
|
|
|
61
93
|
```markdown
|
|
62
94
|
# ADR-001: Use PostgreSQL for primary database
|
|
@@ -69,6 +101,10 @@ Accepted | Superseded by ADR-XXX | Deprecated
|
|
|
69
101
|
|
|
70
102
|
2025-01-15
|
|
71
103
|
|
|
104
|
+
## Deciders
|
|
105
|
+
|
|
106
|
+
The platform team (architect + two senior engineers).
|
|
107
|
+
|
|
72
108
|
## Context
|
|
73
109
|
|
|
74
110
|
We need a primary database for the task management application. Key
|
|
@@ -27,4 +27,7 @@ Describe the top-level directories and their responsibilities.
|
|
|
27
27
|
|
|
28
28
|
## Key Decisions
|
|
29
29
|
|
|
30
|
-
Link to `decisions.md` for the architectural decision log.
|
|
30
|
+
Link to `decisions.md` for the architectural decision log. Mandrel supports two
|
|
31
|
+
first-class layouts for it: a single-file dated-entry `decisions.md` (default)
|
|
32
|
+
or an index + `decisions/` ADR directory — see
|
|
33
|
+
[`.agents/skills/core/documentation-and-adrs/SKILL.md`](../../skills/core/documentation-and-adrs/SKILL.md).
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# ADR-NNNN: <short decision title>
|
|
2
|
+
|
|
3
|
+
> Copy this file to `decisions/NNNN-<kebab-title>.md` (zero-padded, sequential
|
|
4
|
+
> — `0001`, `0002`, …) and add a matching row to the `decisions.md` index.
|
|
5
|
+
> ADRs are **append-only**: never rewrite or delete an accepted ADR — supersede
|
|
6
|
+
> it with a new one and flip this one's **Status** to `Superseded by ADR-NNNN`.
|
|
7
|
+
|
|
8
|
+
## Status
|
|
9
|
+
|
|
10
|
+
Accepted
|
|
11
|
+
|
|
12
|
+
<!-- One of: Proposed | Accepted | Superseded by ADR-NNNN | Deprecated -->
|
|
13
|
+
|
|
14
|
+
## Date
|
|
15
|
+
|
|
16
|
+
YYYY-MM-DD
|
|
17
|
+
|
|
18
|
+
## Deciders
|
|
19
|
+
|
|
20
|
+
<!-- Who made the call (names / roles / "the team"). -->
|
|
21
|
+
|
|
22
|
+
## Context
|
|
23
|
+
|
|
24
|
+
<!-- What forced the decision: the constraint, problem, or trade-off. What
|
|
25
|
+
were the requirements and the forces in tension? -->
|
|
26
|
+
|
|
27
|
+
## Decision
|
|
28
|
+
|
|
29
|
+
<!-- What was chosen, stated plainly. -->
|
|
30
|
+
|
|
31
|
+
## Consequences
|
|
32
|
+
|
|
33
|
+
<!-- What this enables and what it costs going forward — positive and negative.
|
|
34
|
+
Include follow-on work, new constraints, and anything a future reader must
|
|
35
|
+
know before reversing this. -->
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# Architectural Decisions Log (Index)
|
|
2
|
+
|
|
3
|
+
> **Directory-layout variant.** This is the MADR-style **index + `decisions/`
|
|
4
|
+
> directory** alternative to the default single-file dated-entry
|
|
5
|
+
> [`decisions.md`](decisions.md). To adopt it, replace your `decisions.md` with
|
|
6
|
+
> this index, create a `decisions/` directory next to it, and scaffold ADRs
|
|
7
|
+
> from [`decisions/_template.md`](decisions/_template.md). This file stays named
|
|
8
|
+
> `decisions.md` so it remains the `project.docsContextFiles` mandatory-read
|
|
9
|
+
> every `.agents/` reference and `config-resolver.js` already point at — only
|
|
10
|
+
> its **shape** changes from dated entries to an index.
|
|
11
|
+
>
|
|
12
|
+
> Prefer this layout once the single-file log grows past a few dozen entries
|
|
13
|
+
> (athportal hit ~1060 lines / 32 ADRs before splitting). For small projects,
|
|
14
|
+
> keep the default single-file template instead.
|
|
15
|
+
|
|
16
|
+
## How this layout works
|
|
17
|
+
|
|
18
|
+
- **This file is the index** — one row per ADR, newest at the top. It is the
|
|
19
|
+
mandatory-read; agents scan the index and follow the link into a specific
|
|
20
|
+
ADR only when the detail is relevant (index-only by default — see
|
|
21
|
+
[Loading model](#loading-model)).
|
|
22
|
+
- **Each ADR is its own file** under `decisions/`, named
|
|
23
|
+
`NNNN-<kebab-title>.md` with a zero-padded sequential number.
|
|
24
|
+
- **ADRs are append-only.** Never rewrite or delete an accepted ADR — write a
|
|
25
|
+
new one and flip the old one's status to `Superseded by ADR-NNNN`.
|
|
26
|
+
- **Scaffold new ADRs** from [`decisions/_template.md`](decisions/_template.md)
|
|
27
|
+
(Status / Date / Deciders / Context / Decision / Consequences).
|
|
28
|
+
|
|
29
|
+
## Loading model
|
|
30
|
+
|
|
31
|
+
This index is the only decisions artifact loaded into mandatory task context
|
|
32
|
+
(`project.docsContextFiles`). Individual ADR bodies under `decisions/` are
|
|
33
|
+
**lazy / link-followed**, not auto-loaded — that is the whole point of the
|
|
34
|
+
split: keep the per-task context lean while preserving the full decision
|
|
35
|
+
history on disk. If a project genuinely wants the entire ADR set in mandatory
|
|
36
|
+
context, it can add an explicit `decisions/*.md`-style entry to
|
|
37
|
+
`project.docsContextFiles` as an opt-in (see the configuration reference), but
|
|
38
|
+
index-only is the intended default.
|
|
39
|
+
|
|
40
|
+
## Index
|
|
41
|
+
|
|
42
|
+
| ADR | Title | Status | Date |
|
|
43
|
+
| -------- | ---------------------------------------- | -------- | ---------- |
|
|
44
|
+
| ADR-0001 | _Example — replace with your first ADR_ | Proposed | YYYY-MM-DD |
|
|
45
|
+
|
|
46
|
+
_Add new rows above this line, newest first. Once you scaffold a real ADR from
|
|
47
|
+
[`decisions/_template.md`](decisions/_template.md) into
|
|
48
|
+
`decisions/0001-<title>.md`, link the ADR id to that file (e.g.
|
|
49
|
+
`[ADR-0001](decisions/0001-<title>.md)`) and delete this example row._
|
|
@@ -4,6 +4,17 @@
|
|
|
4
4
|
> decisions here as dated entries. This file is one of the
|
|
5
5
|
> `project.docsContextFiles` mandatory-reads — agents consult it before every
|
|
6
6
|
> task to avoid re-litigating settled choices.
|
|
7
|
+
>
|
|
8
|
+
> **Two supported layouts — pick one at onboarding.** This single-file
|
|
9
|
+
> dated-entry layout is the **default**, ideal for small projects. Once the
|
|
10
|
+
> log grows past a few dozen entries it becomes a context-bloat liability;
|
|
11
|
+
> at that point switch to the first-class **index + `decisions/` directory**
|
|
12
|
+
> variant ([`decisions.index.md`](decisions.index.md) + the ADR scaffold at
|
|
13
|
+
> [`decisions/_template.md`](decisions/_template.md)). Both layouts keep the
|
|
14
|
+
> file named `decisions.md` so the `project.docsContextFiles` mandatory-read
|
|
15
|
+
> resolves the same; only the shape differs. See
|
|
16
|
+
> [`.agents/skills/core/documentation-and-adrs/SKILL.md`](../../skills/core/documentation-and-adrs/SKILL.md)
|
|
17
|
+
> for when to choose which.
|
|
7
18
|
|
|
8
19
|
## Format
|
|
9
20
|
|
|
@@ -97,7 +97,7 @@ Envelope fields (`kind: "story-plan-context"`, `version: 1`):
|
|
|
97
97
|
| `bodyTemplate` | Contents of `.agents/templates/single-story-body.md`. |
|
|
98
98
|
| `requiredSections` | `["Context", "Acceptance Criteria", "Out of Scope", "Notes"]`. |
|
|
99
99
|
| `duplicateCandidates` | Ranked open Stories whose titles fuzzy-match the seed. |
|
|
100
|
-
| `techStack` | The `## Tech Stack` section of `docs/architecture.md
|
|
100
|
+
| `techStack` | The project's Tech Stack inventory, resolved in order: `docs/tech-stack.md` (full body) when present, else the `## Tech Stack` section of `docs/architecture.md` (numbered/decorated and final-section headings tolerated). |
|
|
101
101
|
| `deliverContract` | Workflow path + required/forbidden labels and references. |
|
|
102
102
|
|
|
103
103
|
### Refine heuristic
|
package/docs/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [1.73.0](https://github.com/dsj1984/mandrel/compare/mandrel-v1.72.0...mandrel-v1.73.0) (2026-06-17)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
|
|
10
|
+
* generate GitHub issue forms from the Story/Epic body SSOT (human↔agent ticket consistency) ([#4227](https://github.com/dsj1984/mandrel/issues/4227)) ([#4233](https://github.com/dsj1984/mandrel/issues/4233)) ([d42b0cb](https://github.com/dsj1984/mandrel/commit/d42b0cb6122f46d8528d4bbb3793f27be2854bc1))
|
|
11
|
+
* **plan:** robust tech-stack hydrator resolution (refs [#4228](https://github.com/dsj1984/mandrel/issues/4228)) ([#4230](https://github.com/dsj1984/mandrel/issues/4230)) ([4f1ad3c](https://github.com/dsj1984/mandrel/commit/4f1ad3c13b9a50f4e436d63282c7f6c2f461ab0e))
|
|
12
|
+
|
|
5
13
|
## [1.72.0](https://github.com/dsj1984/mandrel/compare/mandrel-v1.71.0...mandrel-v1.72.0) (2026-06-17)
|
|
6
14
|
|
|
7
15
|
|
package/package.json
CHANGED