mandrel 1.72.0 → 1.74.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/README.md +9 -5
- package/.agents/docs/configuration.md +13 -0
- package/.agents/instructions.md +14 -6
- package/.agents/personas/devops-engineer.md +4 -2
- package/.agents/scripts/agents-bootstrap-github.js +42 -43
- package/.agents/scripts/bootstrap.js +79 -11
- package/.agents/scripts/lib/bootstrap/issue-forms-template.js +430 -0
- package/.agents/scripts/lib/bootstrap/manifest.js +13 -5
- package/.agents/scripts/lib/bootstrap/project-bootstrap.js +43 -4
- package/.agents/scripts/lib/bootstrap/prompt.js +1 -1
- package/.agents/scripts/lib/bootstrap/summary.js +0 -6
- package/.agents/scripts/lib/bootstrap/workflow-audit.js +25 -12
- package/.agents/scripts/lib/label-taxonomy.js +0 -37
- package/.agents/scripts/lib/onboard/init-tail.js +9 -10
- package/.agents/scripts/lib/orchestration/column-sync.js +22 -41
- package/.agents/scripts/lib/orchestration/epic-spec-reconciler-discriminator.js +56 -2
- package/.agents/scripts/lib/orchestration/project-meta-resolver.js +129 -0
- package/.agents/scripts/lib/story-body/story-body.js +5 -2
- package/.agents/scripts/lib/story-plan.js +41 -4
- package/.agents/scripts/lint-issue-body.js +261 -0
- package/.agents/scripts/providers/github/project-board.js +5 -9
- package/.agents/scripts/providers/github/projects-v2-graphql.js +0 -166
- package/.agents/scripts/providers/github/tickets.js +10 -1
- package/.agents/scripts/providers/github.js +0 -1
- package/.agents/skills/core/documentation-and-adrs/SKILL.md +38 -2
- package/.agents/templates/docs/architecture.md +4 -1
- package/.agents/templates/docs/decisions/_template.md +35 -0
- package/.agents/templates/docs/decisions.index.md +49 -0
- package/.agents/templates/docs/decisions.md +11 -0
- package/.agents/workflows/helpers/plan-story.md +1 -1
- package/docs/CHANGELOG.md +22 -0
- package/package.json +1 -1
|
@@ -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
|
+
}
|
|
@@ -94,14 +94,14 @@ export const MANIFEST_ENTRY_FIELDS = Object.freeze([
|
|
|
94
94
|
* platform; remote targets are scoped to the resolved `owner/repo` slug.
|
|
95
95
|
*
|
|
96
96
|
* The `github-admin` group is omitted when `ctx.skipGithub` is set, and the
|
|
97
|
-
* `quality-gates` group is
|
|
98
|
-
* preview reflects the same flags the executing pipeline honours.
|
|
97
|
+
* `quality-gates` group is included only when `ctx.withQuality` is true, so
|
|
98
|
+
* the preview reflects the same flags the executing pipeline honours.
|
|
99
99
|
*
|
|
100
100
|
* @param {object} [ctx]
|
|
101
101
|
* @param {{ owner?: string, repo?: string }} [ctx.answers] — scopes the
|
|
102
102
|
* `github-admin` targets to the `owner/repo` slug.
|
|
103
103
|
* @param {boolean} [ctx.skipGithub] — omit the `github-admin` group.
|
|
104
|
-
* @param {boolean} [ctx.
|
|
104
|
+
* @param {boolean} [ctx.withQuality] — include the `quality-gates` group.
|
|
105
105
|
* @returns {MutationManifestEntry[]}
|
|
106
106
|
*/
|
|
107
107
|
export function buildMutationManifest(ctx = {}) {
|
|
@@ -177,12 +177,20 @@ 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 ----------------------------------------------------
|
|
183
191
|
// Stabilized quality-gate surface (husky pre-commit, quality npm
|
|
184
|
-
// scripts, .agentrc quality defaults).
|
|
185
|
-
if (
|
|
192
|
+
// scripts, .agentrc quality defaults). Included only when opted in.
|
|
193
|
+
if (ctx.withQuality) {
|
|
186
194
|
entries.push(
|
|
187
195
|
{
|
|
188
196
|
phaseGroup: PHASE_GROUPS.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,14 @@ 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) =>
|
|
728
|
+
ctx.withIssueForms === true
|
|
729
|
+
? ensureIssueFormsPhase(ctx)
|
|
730
|
+
: { skipped: true, reason: 'issue-forms-not-opted-in' },
|
|
731
|
+
},
|
|
693
732
|
{
|
|
694
733
|
name: 'sync',
|
|
695
734
|
phaseGroup: PHASE_GROUPS.IDE_WIRING,
|
|
@@ -706,9 +745,9 @@ export const BOOTSTRAP_PHASES = Object.freeze([
|
|
|
706
745
|
name: 'quality',
|
|
707
746
|
phaseGroup: PHASE_GROUPS.QUALITY_GATES,
|
|
708
747
|
run: (ctx) =>
|
|
709
|
-
ctx.
|
|
710
|
-
? {
|
|
711
|
-
:
|
|
748
|
+
ctx.withQuality === true
|
|
749
|
+
? applyQualityBootstrap({ projectRoot: ctx.projectRoot })
|
|
750
|
+
: { skipped: true, reason: 'quality-not-opted-in' },
|
|
712
751
|
},
|
|
713
752
|
{
|
|
714
753
|
name: 'winPerf',
|
|
@@ -808,7 +847,7 @@ export async function runPhases(phases, ctx) {
|
|
|
808
847
|
* @param {Set<string>} [ctx.approvedGroups] — when present, only phases
|
|
809
848
|
* whose `phaseGroup` is in this set execute (the consent-first gate from
|
|
810
849
|
* Story #3524); always-run infrastructure phases ignore it.
|
|
811
|
-
* @param {boolean} [ctx.
|
|
850
|
+
* @param {boolean} [ctx.withQuality]
|
|
812
851
|
* @param {boolean} [ctx.skipGithub]
|
|
813
852
|
* @param {boolean} [ctx.skipInstall]
|
|
814
853
|
* @param {boolean} [ctx.quiet]
|
|
@@ -57,12 +57,6 @@ export function printSummary(result) {
|
|
|
57
57
|
Logger.info(`Fields skipped: ${result.fields.skipped.length}`);
|
|
58
58
|
Logger.info(`Project: ${formatProjectSummary(result.project)}`);
|
|
59
59
|
Logger.info(`Status field: ${result.statusField.status}`);
|
|
60
|
-
const unavailableSuffix = result.views.unavailable
|
|
61
|
-
? ' (mutation unavailable)'
|
|
62
|
-
: '';
|
|
63
|
-
Logger.info(
|
|
64
|
-
`Views — created: ${result.views.created.length}, skipped: ${result.views.skipped.length}${unavailableSuffix}`,
|
|
65
|
-
);
|
|
66
60
|
Logger.info(
|
|
67
61
|
`Workflow audit: ${formatWorkflowAuditSummary(result.workflowAudit)}`,
|
|
68
62
|
);
|
|
@@ -36,6 +36,8 @@
|
|
|
36
36
|
* {@link reapConflictingWorkflows}.
|
|
37
37
|
*/
|
|
38
38
|
|
|
39
|
+
import { resolveProjectMeta } from '../orchestration/project-meta-resolver.js';
|
|
40
|
+
|
|
39
41
|
/**
|
|
40
42
|
* Workflows that **must not** be enabled when the orchestrator owns
|
|
41
43
|
* the Status column. Each entry writes Status as a side-effect of an
|
|
@@ -207,14 +209,23 @@ export async function reapConflictingWorkflows(args) {
|
|
|
207
209
|
}
|
|
208
210
|
|
|
209
211
|
/**
|
|
210
|
-
* Resolve a Project v2 node id from a project number
|
|
211
|
-
*
|
|
212
|
-
*
|
|
213
|
-
*
|
|
214
|
-
*
|
|
215
|
-
*
|
|
212
|
+
* Resolve a Project v2 node id from a project number. Used by the
|
|
213
|
+
* bootstrap CLI to convert the resolver's `projectNumber` into the node
|
|
214
|
+
* id required by {@link auditProjectWorkflows}.
|
|
215
|
+
*
|
|
216
|
+
* Walks the shared owner-type ladder — `organization(login:$owner)` →
|
|
217
|
+
* `user(login:$owner)` → `viewer` — via {@link resolveProjectMeta}, so an
|
|
218
|
+
* **org-owned** board resolves here the same way it does for `ColumnSync`
|
|
219
|
+
* (Story #4237). The owner login is read from `provider.projectOwner`
|
|
220
|
+
* (explicit board owner) and falls back to `provider.owner` (the repo
|
|
221
|
+
* owner) so org boards resolve even when no separate `projectOwner` is
|
|
222
|
+
* configured. Returns `null` when no owner scope can see the project
|
|
223
|
+
* (e.g. missing scope) so the caller can degrade gracefully.
|
|
216
224
|
*
|
|
217
|
-
* @param {{
|
|
225
|
+
* @param {{
|
|
226
|
+
* provider: { graphql: Function, owner?: string|null, projectOwner?: string|null },
|
|
227
|
+
* projectNumber: number,
|
|
228
|
+
* }} args
|
|
218
229
|
* @returns {Promise<string|null>}
|
|
219
230
|
*/
|
|
220
231
|
export async function resolveProjectIdByNumber(args) {
|
|
@@ -230,11 +241,13 @@ export async function resolveProjectIdByNumber(args) {
|
|
|
230
241
|
);
|
|
231
242
|
}
|
|
232
243
|
try {
|
|
233
|
-
const
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
244
|
+
const project = await resolveProjectMeta({
|
|
245
|
+
provider,
|
|
246
|
+
owner: provider.projectOwner ?? provider.owner ?? null,
|
|
247
|
+
projectNumber,
|
|
248
|
+
projectFields: 'id',
|
|
249
|
+
});
|
|
250
|
+
return project?.id ?? null;
|
|
238
251
|
} catch {
|
|
239
252
|
return null;
|
|
240
253
|
}
|
|
@@ -156,40 +156,3 @@ export const PROJECT_FIELD_DEFS = [
|
|
|
156
156
|
* @type {string[]}
|
|
157
157
|
*/
|
|
158
158
|
export const STATUS_FIELD_OPTIONS = ['Todo', 'In Progress', 'Done'];
|
|
159
|
-
|
|
160
|
-
/**
|
|
161
|
-
* Default Projects V2 saved Views. Filter strings follow GitHub's Projects
|
|
162
|
-
* search syntax (`label:`, `status:`, `assignee:`). Each is grouped by the
|
|
163
|
-
* Status field to match the board's columnar layout.
|
|
164
|
-
*
|
|
165
|
-
* GitHub's GraphQL surface does not expose a public `createProjectV2View`
|
|
166
|
-
* mutation, so bootstrap creates these via the REST Projects V2 views
|
|
167
|
-
* endpoint best-effort; when the endpoint is unavailable the views must be
|
|
168
|
-
* configured manually in the GitHub Projects UI.
|
|
169
|
-
*
|
|
170
|
-
* @type {Array<{ name: string, filter: string, groupBy: string,
|
|
171
|
-
* layout?: 'table'|'board'|'roadmap' }>}
|
|
172
|
-
*/
|
|
173
|
-
export const PROJECT_VIEW_DEFS = [
|
|
174
|
-
{
|
|
175
|
-
name: 'Mandrel Board',
|
|
176
|
-
filter: '',
|
|
177
|
-
groupBy: 'Status',
|
|
178
|
-
layout: 'board',
|
|
179
|
-
},
|
|
180
|
-
{
|
|
181
|
-
name: 'Epic Roadmap',
|
|
182
|
-
filter: 'label:type::epic',
|
|
183
|
-
groupBy: 'Status',
|
|
184
|
-
},
|
|
185
|
-
{
|
|
186
|
-
name: 'Active Stories',
|
|
187
|
-
filter: 'label:type::story -status:Done',
|
|
188
|
-
groupBy: 'Status',
|
|
189
|
-
},
|
|
190
|
-
{
|
|
191
|
-
name: 'My Queue',
|
|
192
|
-
filter: 'assignee:@me',
|
|
193
|
-
groupBy: 'Status',
|
|
194
|
-
},
|
|
195
|
-
];
|