@mmerterden/multi-agent-pipeline 16.2.2 → 16.4.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/CHANGELOG.md +127 -0
- package/package.json +1 -1
- package/pipeline/commands/multi-agent/analysis/SKILL.md +2 -1
- package/pipeline/commands/multi-agent/channels/SKILL.md +1 -1
- package/pipeline/commands/multi-agent/prune-logs/SKILL.md +7 -0
- package/pipeline/lib/jira-publish.sh +173 -0
- package/pipeline/multi-agent-refs/analysis/render.md +20 -3
- package/pipeline/multi-agent-refs/channels/jira.md +33 -6
- package/pipeline/multi-agent-refs/generate-issue.md +3 -1
- package/pipeline/multi-agent-refs/issue-jira-triad.md +1 -1
- package/pipeline/multi-agent-refs/payload-contracts.md +1 -1
- package/pipeline/multi-agent-refs/phases/phase-0-init.md +13 -0
- package/pipeline/multi-agent-refs/phases/phase-4-review.md +29 -6
- package/pipeline/multi-agent-refs/phases/phase-6-commit.md +18 -0
- package/pipeline/multi-agent-refs/readiness-review.md +1 -1
- package/pipeline/schemas/agent-state.schema.json +73 -1
- package/pipeline/schemas/prefs.schema.json +19 -0
- package/pipeline/schemas/token-budget.json +1 -1
- package/pipeline/scripts/README.md +2 -2
- package/pipeline/scripts/anonymize-findings.mjs +199 -0
- package/pipeline/scripts/jira-wiki-escape.mjs +92 -0
- package/pipeline/scripts/plan-coverage-gate.mjs +219 -0
- package/pipeline/scripts/run-metrics.mjs +66 -1
- package/pipeline/scripts/validate-analysis-doc.mjs +8 -3
- package/pipeline/skills/shared/core/multi-agent-channels/SKILL.md +1 -1
- package/pipeline/skills/shared/core/multi-agent-create-jira/SKILL.md +1 -1
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// plan-coverage-gate.mjs - the plan's own work must be accounted for before commit.
|
|
3
|
+
//
|
|
4
|
+
// Phase 4 answers "is what changed correct" and Step 1.45 answers "did every
|
|
5
|
+
// planned TEST land". Nothing answered "did every planned TASK land". The
|
|
6
|
+
// criteria manifest's denominator is rule IDs, not plan steps, and the only
|
|
7
|
+
// place a step's status surfaced was render-work-summary.sh at Phase 7 - a
|
|
8
|
+
// report, printed after the commit. So a plan with seven steps could ship five
|
|
9
|
+
// and read as done.
|
|
10
|
+
//
|
|
11
|
+
// Pattern source: github/spec-kit templates/commands/converge.md. Two of its
|
|
12
|
+
// properties are copied deliberately:
|
|
13
|
+
// - it assesses the CURRENT STATE, not a diff. A file the plan promised is
|
|
14
|
+
// either on disk or it is not; git history does not enter into it.
|
|
15
|
+
// - a clean run writes and says nothing. No empty "Convergence" section, no
|
|
16
|
+
// noise to scroll past.
|
|
17
|
+
// What is not copied: spec-kit appends remediation tasks to tasks.md. This gate
|
|
18
|
+
// only reports and fails; appending work to a plan that the user already
|
|
19
|
+
// approved is a Phase 2 decision, not a gate's.
|
|
20
|
+
//
|
|
21
|
+
// A step is accounted for when it is `completed`, or `skipped` with a
|
|
22
|
+
// skipReason, or `failed` with a failureReason. A bare `pending` /
|
|
23
|
+
// `in_progress` is the gap this exists to catch: not "we decided not to do it"
|
|
24
|
+
// but "nobody noticed it was still open".
|
|
25
|
+
//
|
|
26
|
+
// Usage:
|
|
27
|
+
// plan-coverage-gate.mjs --state <agent-state.json> [--analysis <doc.md>] [--root <dir>] [--json]
|
|
28
|
+
// plan-coverage-gate.mjs --todos <plan-todos.json> [--analysis <doc.md>] [--root <dir>] [--json]
|
|
29
|
+
//
|
|
30
|
+
// Exit codes: 0 clean, 1 coverage gap, 2 usage / parse error.
|
|
31
|
+
|
|
32
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
33
|
+
import { join, isAbsolute } from "node:path";
|
|
34
|
+
|
|
35
|
+
export function auditTodos(todos) {
|
|
36
|
+
const gaps = [];
|
|
37
|
+
const accounted = [];
|
|
38
|
+
for (const todo of Array.isArray(todos) ? todos : []) {
|
|
39
|
+
const id = todo?.id ?? "(no id)";
|
|
40
|
+
const task = todo?.task ?? todo?.title ?? "(no task text)";
|
|
41
|
+
const status = todo?.status;
|
|
42
|
+
if (status === "completed") {
|
|
43
|
+
accounted.push({ id, status });
|
|
44
|
+
} else if (status === "skipped") {
|
|
45
|
+
if (String(todo?.skipReason ?? "").trim()) accounted.push({ id, status });
|
|
46
|
+
else gaps.push({ id, task, status, why: "skipped with no skipReason" });
|
|
47
|
+
} else if (status === "failed") {
|
|
48
|
+
if (String(todo?.failureReason ?? "").trim()) accounted.push({ id, status });
|
|
49
|
+
else gaps.push({ id, task, status, why: "failed with no failureReason" });
|
|
50
|
+
} else {
|
|
51
|
+
gaps.push({ id, task, status: status ?? "(absent)", why: "never reached a terminal status" });
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return { gaps, accounted };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Section 14 rows tagged `Add new` name files the analysis promised would
|
|
58
|
+
// exist. The tag vocabulary is Locked 16 and identical in both languages, so
|
|
59
|
+
// the heading is matched on the number rather than on either title.
|
|
60
|
+
export function filesToAdd(markdown) {
|
|
61
|
+
const lines = String(markdown).split("\n");
|
|
62
|
+
const start = lines.findIndex((l) => /^#{1,3}\s*14\.\s/.test(l));
|
|
63
|
+
if (start === -1) return null;
|
|
64
|
+
const rows = [];
|
|
65
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
66
|
+
const line = lines[i];
|
|
67
|
+
if (/^#{1,3}\s*(1[5-9]|2[0-9])\.\s/.test(line)) break;
|
|
68
|
+
if (!line.trim().startsWith("|")) continue;
|
|
69
|
+
const cells = line
|
|
70
|
+
.split("|")
|
|
71
|
+
.slice(1, -1)
|
|
72
|
+
.map((c) => c.trim());
|
|
73
|
+
if (cells.length < 2) continue;
|
|
74
|
+
const path = cells[0].replace(/`/g, "").trim();
|
|
75
|
+
const tag = cells[1].replace(/`/g, "").trim();
|
|
76
|
+
if (!path || /^-+$/.test(path)) continue;
|
|
77
|
+
if (path.includes("<") || tag.includes("<")) continue;
|
|
78
|
+
// No header-row filter: the tag column of the header reads "Etiket / Tag",
|
|
79
|
+
// which the `Add new` test below already rejects. The filter that used to be
|
|
80
|
+
// here matched on the PATH column instead and would have skipped a real row
|
|
81
|
+
// whose path begins with a `File/` directory.
|
|
82
|
+
if (!/^add new$/i.test(tag)) continue;
|
|
83
|
+
rows.push(path);
|
|
84
|
+
}
|
|
85
|
+
return rows;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function readJson(path) {
|
|
89
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const isMain = process.argv[1] && import.meta.url === `file://${process.argv[1]}`;
|
|
93
|
+
if (isMain) {
|
|
94
|
+
const args = process.argv.slice(2);
|
|
95
|
+
const flag = (name) => {
|
|
96
|
+
const i = args.indexOf(name);
|
|
97
|
+
return i === -1 ? undefined : args[i + 1];
|
|
98
|
+
};
|
|
99
|
+
// Repeatable: `state.analysis.docPath[]` is an array, one document per
|
|
100
|
+
// platform. Reading only the first would check one platform's promised files
|
|
101
|
+
// and report the verdict as if it covered the run.
|
|
102
|
+
const flagAll = (name) => {
|
|
103
|
+
const out = [];
|
|
104
|
+
for (let i = 0; i < args.length; i++) {
|
|
105
|
+
if (args[i] === name && args[i + 1] !== undefined) out.push(args[i + 1]);
|
|
106
|
+
}
|
|
107
|
+
return out;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
if (args.includes("--help") || args.length === 0) {
|
|
111
|
+
process.stdout.write(
|
|
112
|
+
[
|
|
113
|
+
"usage: plan-coverage-gate.mjs --state <agent-state.json> [--analysis <doc.md>] [--root <dir>] [--json]",
|
|
114
|
+
" plan-coverage-gate.mjs --todos <plan-todos.json> [--analysis <doc.md>] [--root <dir>] [--json]",
|
|
115
|
+
"",
|
|
116
|
+
"Fails when a plan step never reached a terminal status, when a skip or",
|
|
117
|
+
"failure carries no reason, or when an analysis Section 14 `Add new` file",
|
|
118
|
+
"is missing from the tree. Run before Phase 6 commit.",
|
|
119
|
+
"",
|
|
120
|
+
].join("\n"),
|
|
121
|
+
);
|
|
122
|
+
process.exit(args.includes("--help") ? 0 : 2);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const statePath = flag("--state");
|
|
126
|
+
const todosPath = flag("--todos");
|
|
127
|
+
const analysisPaths = flagAll("--analysis");
|
|
128
|
+
const root = flag("--root") ?? process.cwd();
|
|
129
|
+
const asJson = args.includes("--json");
|
|
130
|
+
|
|
131
|
+
if (!statePath && !todosPath) {
|
|
132
|
+
process.stderr.write("plan-coverage-gate: pass --state or --todos\n");
|
|
133
|
+
process.exit(2);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
let todos;
|
|
137
|
+
try {
|
|
138
|
+
if (todosPath) {
|
|
139
|
+
const parsed = readJson(todosPath);
|
|
140
|
+
todos = parsed?.todos ?? parsed;
|
|
141
|
+
} else {
|
|
142
|
+
const state = readJson(statePath);
|
|
143
|
+
todos = state?.plan?.todos ?? state?.plan?.todoList?.todos ?? null;
|
|
144
|
+
}
|
|
145
|
+
} catch (e) {
|
|
146
|
+
process.stderr.write(`plan-coverage-gate: cannot read the plan: ${e.message}\n`);
|
|
147
|
+
process.exit(2);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// No plan at all is not a pass, and neither is an empty one: a Phase 2 that
|
|
151
|
+
// produced no steps, or a state whose todos got cleared, would otherwise read
|
|
152
|
+
// as "0/0 accounted for" and let the commit through. A Short run has no Phase
|
|
153
|
+
// 2, and the caller is expected to skip this gate for those modes rather than
|
|
154
|
+
// let it report clean.
|
|
155
|
+
if (!Array.isArray(todos) || todos.length === 0) {
|
|
156
|
+
const msg = Array.isArray(todos)
|
|
157
|
+
? "the plan has zero steps - that is an unusable plan, not a clean one; skip this gate explicitly for modes with no Phase 2"
|
|
158
|
+
: "no plan todos found - if this mode has no Phase 2, skip this gate explicitly";
|
|
159
|
+
if (asJson)
|
|
160
|
+
process.stdout.write(`${JSON.stringify({ verdict: "unusable", reason: msg }, null, 2)}\n`);
|
|
161
|
+
else process.stderr.write(`plan-coverage-gate: ${msg}\n`);
|
|
162
|
+
process.exit(2);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const { gaps, accounted } = auditTodos(todos);
|
|
166
|
+
|
|
167
|
+
let missingFiles = [];
|
|
168
|
+
let filesVerdict;
|
|
169
|
+
if (analysisPaths.length) {
|
|
170
|
+
const allRows = [];
|
|
171
|
+
const docsWithoutSection = [];
|
|
172
|
+
for (const docPath of analysisPaths) {
|
|
173
|
+
let rows = null;
|
|
174
|
+
try {
|
|
175
|
+
rows = filesToAdd(readFileSync(docPath, "utf8"));
|
|
176
|
+
} catch (e) {
|
|
177
|
+
process.stderr.write(`plan-coverage-gate: cannot read the analysis doc: ${e.message}\n`);
|
|
178
|
+
process.exit(2);
|
|
179
|
+
}
|
|
180
|
+
if (rows === null) docsWithoutSection.push(docPath);
|
|
181
|
+
else allRows.push(...rows);
|
|
182
|
+
}
|
|
183
|
+
const unique = [...new Set(allRows)];
|
|
184
|
+
missingFiles = unique.filter((p) => !existsSync(isAbsolute(p) ? p : join(root, p)));
|
|
185
|
+
const docNote = docsWithoutSection.length
|
|
186
|
+
? `; ${docsWithoutSection.length} doc(s) carried no Section 14`
|
|
187
|
+
: "";
|
|
188
|
+
filesVerdict = unique.length
|
|
189
|
+
? `${unique.length - missingFiles.length}/${unique.length} promised files present across ${analysisPaths.length} doc(s)${docNote}`
|
|
190
|
+
: `no Section 14 rows in ${analysisPaths.length} doc(s)${docNote}`;
|
|
191
|
+
} else {
|
|
192
|
+
filesVerdict = "skipped (no --analysis given)";
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const failed = gaps.length > 0 || missingFiles.length > 0;
|
|
196
|
+
const result = {
|
|
197
|
+
verdict: failed ? "gap" : "clean",
|
|
198
|
+
todos: { total: todos.length, accounted: accounted.length, gaps },
|
|
199
|
+
filesToAdd: { verdict: filesVerdict, missing: missingFiles },
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
if (asJson) {
|
|
203
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
204
|
+
} else if (failed) {
|
|
205
|
+
process.stderr.write(
|
|
206
|
+
`plan coverage: ${gaps.length} unaccounted step(s), ${missingFiles.length} missing file(s)\n`,
|
|
207
|
+
);
|
|
208
|
+
for (const g of gaps) process.stderr.write(` ${g.id} [${g.status}] ${g.task} - ${g.why}\n`);
|
|
209
|
+
for (const f of missingFiles)
|
|
210
|
+
process.stderr.write(` missing file promised by analysis Section 14: ${f}\n`);
|
|
211
|
+
process.stderr.write(` files-to-add: ${filesVerdict}\n`);
|
|
212
|
+
} else {
|
|
213
|
+
// spec-kit's rule: a clean pass says one line and writes nothing.
|
|
214
|
+
process.stdout.write(
|
|
215
|
+
`plan coverage: ${accounted.length}/${todos.length} steps accounted for; files-to-add: ${filesVerdict}\n`,
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
process.exit(failed ? 1 : 0);
|
|
219
|
+
}
|
|
@@ -15,6 +15,12 @@
|
|
|
15
15
|
* firstPassClean - iteration 1 had 0 accepted BLOCKING
|
|
16
16
|
* accepted/deferred/rejected (final) - triage outcome
|
|
17
17
|
* reviewFindingsRaw -> acceptedRatio - signal-to-noise of the reviewers
|
|
18
|
+
* reviewSignal.perReviewer - the same ratio per reviewer model, so
|
|
19
|
+
* "which model is worth dispatching" stops
|
|
20
|
+
* being a guess. Needs the anonymization
|
|
21
|
+
* label map to attribute accepted findings;
|
|
22
|
+
* without it the raw counts still land and
|
|
23
|
+
* attribution reports "unavailable".
|
|
18
24
|
* consensusVerdict - unanimous-* / split / unverified
|
|
19
25
|
* buildPassed - per-repo build outcome
|
|
20
26
|
*
|
|
@@ -79,9 +85,42 @@ if (iterations.length) {
|
|
|
79
85
|
let rawFindings = 0;
|
|
80
86
|
let acceptedAll = 0;
|
|
81
87
|
let classifiedAll = 0;
|
|
88
|
+
// Per-reviewer tallies. `unknown` is its own bucket: a reviewer entry written
|
|
89
|
+
// before the shape was typed must not be folded into a named model, or the
|
|
90
|
+
// ratio reads as coverage that was never measured.
|
|
91
|
+
const rawByModel = new Map();
|
|
92
|
+
// Separate denominator: only iterations that carried a label map can contribute
|
|
93
|
+
// an accepted count, so mixing them with map-less iterations understates every
|
|
94
|
+
// reviewer's ratio. A resumed run that upgraded mid-flight is exactly that case.
|
|
95
|
+
const rawAttributableByModel = new Map();
|
|
96
|
+
const acceptedByModel = new Map();
|
|
97
|
+
let mappedIterations = 0;
|
|
98
|
+
let acceptedUnattributed = 0;
|
|
82
99
|
for (const it of iterations) {
|
|
100
|
+
const labelToModel = it?.anonymizationMap?.labelToModel || it?.triage?.labelToModel || null;
|
|
83
101
|
for (const r of Array.isArray(it.reviewers) ? it.reviewers : []) {
|
|
84
|
-
|
|
102
|
+
const n = Array.isArray(r?.findings) ? r.findings.length : 0;
|
|
103
|
+
rawFindings += n;
|
|
104
|
+
const model = typeof r?.model === "string" && r.model ? r.model : "unknown";
|
|
105
|
+
rawByModel.set(model, (rawByModel.get(model) || 0) + n);
|
|
106
|
+
if (labelToModel)
|
|
107
|
+
rawAttributableByModel.set(model, (rawAttributableByModel.get(model) || 0) + n);
|
|
108
|
+
}
|
|
109
|
+
if (labelToModel) mappedIterations += 1;
|
|
110
|
+
{
|
|
111
|
+
const acceptedList = Array.isArray(it?.triage?.accepted) ? it.triage.accepted : [];
|
|
112
|
+
for (const f of acceptedList) {
|
|
113
|
+
const model = labelToModel ? labelToModel[f?.foundBy] : undefined;
|
|
114
|
+
// Every accepted finding lands in exactly one bucket, so
|
|
115
|
+
// acceptedAll === sum(perReviewer.accepted) + acceptedUnattributed holds.
|
|
116
|
+
// Two things arrive here: a deterministic-gate finding, which carries no
|
|
117
|
+
// foundBy by design, and any finding from an iteration that had no map.
|
|
118
|
+
if (!model) {
|
|
119
|
+
acceptedUnattributed += 1;
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
acceptedByModel.set(model, (acceptedByModel.get(model) || 0) + 1);
|
|
123
|
+
}
|
|
85
124
|
}
|
|
86
125
|
const t = it.triage || {};
|
|
87
126
|
acceptedAll += Array.isArray(t.accepted) ? t.accepted.length : 0;
|
|
@@ -125,6 +164,32 @@ const metrics = {
|
|
|
125
164
|
classified: classifiedAll,
|
|
126
165
|
acceptedAll,
|
|
127
166
|
acceptedRatio: rawFindings > 0 ? Math.round((acceptedAll / rawFindings) * 100) / 100 : null,
|
|
167
|
+
perReviewer: [...rawByModel.entries()]
|
|
168
|
+
.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
|
|
169
|
+
.map(([model, raw]) => {
|
|
170
|
+
const rawAttributable = rawAttributableByModel.get(model) || 0;
|
|
171
|
+
const accepted = acceptedByModel.get(model) || 0;
|
|
172
|
+
return {
|
|
173
|
+
model,
|
|
174
|
+
rawFindings: raw,
|
|
175
|
+
rawAttributable,
|
|
176
|
+
accepted: mappedIterations > 0 ? accepted : null,
|
|
177
|
+
// Denominator is the attributable raw count, never the total: a run
|
|
178
|
+
// whose second iteration lost its map would otherwise read as if the
|
|
179
|
+
// reviewer produced noise it was never credited for.
|
|
180
|
+
acceptedRatio:
|
|
181
|
+
mappedIterations > 0 && rawAttributable > 0
|
|
182
|
+
? Math.round((accepted / rawAttributable) * 100) / 100
|
|
183
|
+
: null,
|
|
184
|
+
};
|
|
185
|
+
}),
|
|
186
|
+
// "map" means the mapping existed, not that it resolved something: zero
|
|
187
|
+
// accepted findings under a present map is a real 0, and reporting it as
|
|
188
|
+
// "unavailable" is the absent-vs-zero conflation this metric set exists to
|
|
189
|
+
// avoid. A number, not a "1/2" string, because metrics.jsonl is aggregated.
|
|
190
|
+
perReviewerAttribution: mappedIterations > 0 ? "map" : "unavailable",
|
|
191
|
+
mappedIterations,
|
|
192
|
+
acceptedUnattributed,
|
|
128
193
|
},
|
|
129
194
|
consensusVerdict: lastTriage.consensus ? lastTriage.consensus.verdict : null,
|
|
130
195
|
buildPassed,
|
|
@@ -95,10 +95,15 @@ function parseFrontMatter(text) {
|
|
|
95
95
|
}
|
|
96
96
|
|
|
97
97
|
function sectionBody(lines, keywords, level = 2) {
|
|
98
|
-
|
|
98
|
+
// Opened by a heading at exactly `level`; closed by the next heading at that
|
|
99
|
+
// level OR ANY SHALLOWER one. A sub-section that is the last of its parent has
|
|
100
|
+
// no sibling after it, so terminating only on equal depth runs the body into
|
|
101
|
+
// the following section and audits its rows as if they belonged here.
|
|
102
|
+
const open = new RegExp(`^#{${level}}\\s+\\d+(\\.\\d+)*\\.?\\s`);
|
|
103
|
+
const close = new RegExp(`^#{1,${level}}\\s`);
|
|
99
104
|
let start = -1;
|
|
100
105
|
for (let i = 0; i < lines.length; i++) {
|
|
101
|
-
if (
|
|
106
|
+
if (open.test(lines[i]) && keywords.some((k) => lines[i].includes(k))) {
|
|
102
107
|
start = i;
|
|
103
108
|
break;
|
|
104
109
|
}
|
|
@@ -106,7 +111,7 @@ function sectionBody(lines, keywords, level = 2) {
|
|
|
106
111
|
if (start < 0) return null;
|
|
107
112
|
let end = lines.length;
|
|
108
113
|
for (let i = start + 1; i < lines.length; i++) {
|
|
109
|
-
if (
|
|
114
|
+
if (close.test(lines[i])) {
|
|
110
115
|
end = i;
|
|
111
116
|
break;
|
|
112
117
|
}
|
|
@@ -120,7 +120,7 @@ PR: {prUrl}
|
|
|
120
120
|
# Scenario 2
|
|
121
121
|
```
|
|
122
122
|
|
|
123
|
-
First line: PR URL (one-click jump). POST `/rest/api/2/issue/{id}/comment` with heredoc + `jq --rawfile` + `curl --data-binary @file
|
|
123
|
+
First line: PR URL (one-click jump). Run the converted body through `node "$HOME/.claude/scripts/jira-wiki-escape.mjs"` before POST - Jira turns `:)` `(x)` `(!)` `(/)` into emoticon images, including inside `{{monospace}}`. POST `/rest/api/2/issue/{id}/comment` with heredoc + `jq --rawfile` + `curl --data-binary @file` from the escaped file. Real newlines, no HTML entities.
|
|
124
124
|
|
|
125
125
|
### Confluence page
|
|
126
126
|
|
|
@@ -40,7 +40,7 @@ A `C` section fires on: Design Reference → Figma URL; Screenshots → pasted i
|
|
|
40
40
|
- **Figma** (`FIGMA_URL` set) - 3-tier chain (MCP → REST `figma` PAT → user screenshot); standalone command, MCP allowed here.
|
|
41
41
|
- **Swagger** (`SWAGGER_URL` set or contract pasted) - fetch the spec, extract ONLY the referenced endpoints (paths/operationIds/tags named in `FREE_TEXT`, else the pointed-at group): method + path + one-line summary + key request/response fields. Never crawl the whole spec. Fetch failure → link-only or omit.
|
|
42
42
|
- **Screenshots** - staged for the step 8 attachment opt-in, referenced inline with `!file|thumbnail!`.
|
|
43
|
-
7. **Draft + clarifying questions** - compose summary (`{minedPrefix} {title}`, 255 trunc) + description from the type's **standard template** in Jira wiki markup (`h3.`), auto-sizing conditional sections, humanizer pass, in `outputLanguage`; body to a file for `jq -n --rawfile` (UTF-8 verbatim, `--data-binary @file`, no re-encode). Ask ONLY genuinely unknown fields: component, epic (when usage rate is high), priority (when norm ambiguous), labels, assignee, sprint vs backlog, required customs, and any always-present section still empty (Bug Steps/Environment, or a Task/Story with no derivable Scope/AC). Never invent content the user did not supply.
|
|
43
|
+
7. **Draft + clarifying questions** - compose summary (`{minedPrefix} {title}`, 255 trunc) + description from the type's **standard template** in Jira wiki markup (`h3.`), auto-sizing conditional sections, humanizer pass, in `outputLanguage`; body to a file for `jq -n --rawfile` (UTF-8 verbatim, `--data-binary @file`, no re-encode), passed through `node "$HOME/.claude/scripts/jira-wiki-escape.mjs"` first - the description field expands `:)` `(x)` `(!)` `(/)` into emoticon images. Ask ONLY genuinely unknown fields: component, epic (when usage rate is high), priority (when norm ambiguous), labels, assignee, sprint vs backlog, required customs, and any always-present section still empty (Bug Steps/Environment, or a Task/Story with no derivable Scope/AC). Never invent content the user did not supply.
|
|
44
44
|
8. **Full preview + approval gate (never skipped)** - render the complete issue (all fields + full description + attachment plan, uploads default off). Ask: `Approve` / `Edit` (apply → re-render → re-ask, loop) / `Cancel` (nothing created). No flag or mode bypasses this gate.
|
|
45
45
|
9. **Create** - `POST /rest/api/2/issue` with `jq -n --rawfile` payload + `curl --data-binary @file`. 400 field errors → show verbatim, re-ask those fields, re-preview.
|
|
46
46
|
10. **Post-create** (each failure-isolated: warn + continue): sprint chosen → `POST /rest/agile/1.0/sprint/{id}/issue` `{"issues":["KEY"]}`; Figma → `POST /rest/api/2/issue/{KEY}/remotelink`; Swagger → `remotelink` with title "API contract"; screenshots opt-in → `POST /rest/api/2/issue/{KEY}/attachments` with `X-Atlassian-Token: no-check`.
|