@ionivetech/mugiwara 0.6.0 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/.cursor-plugin/plugin.json +1 -1
  5. package/.kimi-plugin/plugin.json +1 -1
  6. package/.opencode/commands/mugiwara-onboard.md +15 -0
  7. package/.opencode/mugiwara-helpers.mjs +24 -0
  8. package/.opencode/plugins/mugiwara.mjs +16 -7
  9. package/AGENTS.md +1 -1
  10. package/README.md +118 -63
  11. package/content/agents/luffy-orchestrator.md +14 -4
  12. package/content/agents/onboarding-guide.md +24 -45
  13. package/content/agents/zoro-execution.md +4 -0
  14. package/content/skills/mugiwara-backend/SKILL.md +1 -1
  15. package/content/skills/mugiwara-brainstorm/SKILL.md +7 -0
  16. package/content/skills/mugiwara-checkpoint/SKILL.md +1 -1
  17. package/content/skills/mugiwara-claim-audit/SKILL.md +1 -1
  18. package/content/skills/mugiwara-execution/SKILL.md +44 -44
  19. package/content/skills/mugiwara-execution/references/dispatch.md +42 -0
  20. package/content/skills/mugiwara-frontend/SKILL.md +1 -1
  21. package/content/skills/mugiwara-healing/SKILL.md +1 -1
  22. package/content/skills/mugiwara-orchestration/SKILL.md +50 -49
  23. package/content/skills/mugiwara-orchestration/references/check-ins.md +34 -0
  24. package/content/skills/mugiwara-orchestration/references/closure.md +34 -0
  25. package/content/skills/mugiwara-orchestration/references/triage-escalation.md +12 -11
  26. package/content/skills/mugiwara-planning/SKILL.md +6 -0
  27. package/content/skills/mugiwara-pr/SKILL.md +18 -8
  28. package/content/skills/mugiwara-quality/SKILL.md +7 -0
  29. package/content/skills/mugiwara-ship/SKILL.md +11 -9
  30. package/content/skills/mugiwara-sunset/SKILL.md +1 -1
  31. package/content/skills/mugiwara-testcases/SKILL.md +7 -0
  32. package/content/skills/mugiwara-workflow/SKILL.md +4 -2
  33. package/content/skills/mugiwara-workflow/references/workspace-layout.md +7 -6
  34. package/content/skills/using-mugiwara/SKILL.md +11 -1
  35. package/dist/mugiwara.js +186 -13
  36. package/gemini-extension.json +1 -1
  37. package/hooks/mugiwara-mode-tracker.ts +0 -0
  38. package/hooks/session-start.ts +0 -0
  39. package/package.json +1 -1
  40. package/plugin.json +1 -1
  41. package/scripts/evidence.sh +16 -1
  42. package/scripts/gate-selftest.ts +52 -1
  43. package/scripts/initiative.ts +34 -20
  44. package/scripts/lane.sh +4 -2
  45. package/scripts/mission-report.sh +152 -29
  46. package/scripts/onboard.ts +3 -29
  47. package/scripts/release-notes.ts +152 -75
  48. package/scripts/savepoint.sh +67 -15
  49. package/scripts/validate-content.ts +20 -0
  50. package/src/cli.ts +20 -3
  51. package/src/installer.ts +37 -1
  52. package/src/mission.ts +108 -1
  53. package/src/targets/claude.ts +29 -8
  54. package/src/targets/opencode.ts +12 -8
@@ -2,6 +2,9 @@
2
2
  # scripts/mission-report.sh — generate aggregate mission report from state.json
3
3
  # + per-mission wave artifacts. Usage: mission-report.sh <mission>
4
4
  # Output: .mugiwara/reports/YYYY-MM-DD-<mission>.md — one-file summary of all waves.
5
+ # Enriched: mission header, token budget (WARN/STOP flag), tasks (state or plan
6
+ # doc fallback), gate/quality excerpt from 06-closure.md, evidence file list.
7
+ # Every section degrades to n/a when state.json or results/ artifacts are absent.
5
8
  set -u
6
9
 
7
10
  MISSION="${1:-}"
@@ -17,8 +20,6 @@ RESULTS_DIR="$MUGIWARA_DIR/results/$MISSION"
17
20
  REVIEW_DIR="$MUGIWARA_DIR/review"
18
21
  ISSUES_DIR="$MUGIWARA_DIR/issues"
19
22
 
20
- [ -f "$STATE_FILE" ] || { echo "mission-report: $STATE_FILE not found" >&2; exit 1; }
21
-
22
23
  export STATE_FILE REPORT_DIR RESULTS_DIR REVIEW_DIR ISSUES_DIR MISSION
23
24
  node << 'NODE'
24
25
  const fs = require('fs');
@@ -29,33 +30,49 @@ const reportDir = process.env.REPORT_DIR || ".mugiwara/reports";
29
30
  const resultsDir = process.env.RESULTS_DIR || ".mugiwara/results/unknown";
30
31
  const reviewDir = process.env.REVIEW_DIR || ".mugiwara/review";
31
32
  const issuesDir = process.env.ISSUES_DIR || ".mugiwara/issues";
33
+ const mugiDir = path.dirname(stateFile);
32
34
  const mission = process.env.MISSION || "unknown";
33
35
 
34
- if (!fs.existsSync(stateFile)) {
35
- console.error("mission-report: " + stateFile + " not found");
36
- process.exit(1);
36
+ fs.mkdirSync(reportDir, { recursive: true });
37
+
38
+ // state.json is optional — degrade gracefully, never hard-exit
39
+ let s = null;
40
+ if (fs.existsSync(stateFile)) {
41
+ try {
42
+ s = JSON.parse(fs.readFileSync(stateFile, 'utf8'));
43
+ } catch (e) {
44
+ console.error("mission-report: warning: " + stateFile + " unreadable — degraded report");
45
+ }
46
+ } else {
47
+ console.error("mission-report: warning: " + stateFile + " not found — degraded report");
37
48
  }
38
49
 
39
- fs.mkdirSync(reportDir, { recursive: true });
40
- const s = JSON.parse(fs.readFileSync(stateFile, 'utf8'));
50
+ const val = (obj, key, dflt) => {
51
+ if (!s || !obj) return dflt;
52
+ const v = obj[key];
53
+ return (v === undefined || v === null || v === "") ? dflt : v;
54
+ };
41
55
 
42
56
  const now = new Date().toISOString().slice(0, 10);
43
57
  const reportFile = path.join(reportDir, now + "-" + mission + ".md");
44
- const lane = s.lane || "unknown";
45
- const mode = s.mode || "guided";
46
- const actor = s.actor || "unknown";
47
- const branch = s.branch || "unknown";
48
- const wave = s.wave || 0;
49
- const filesTouched = s.files_touched || 0;
50
- const locDelta = s.loc_delta || 0;
51
- const sensitive = s.sensitive_paths || [];
52
- const tasks = s.tasks || {};
53
- const blockers = s.blockers_open || 0;
54
- const healCycle = s.heal_cycle || 1;
55
- const tokens = s.tokens_est || 0;
56
- const budget = s.budget || 0;
57
- const evidence = s.evidence || [];
58
- const updated = s.updated_at || new Date().toISOString();
58
+ const lane = val(s, 'lane', 'n/a');
59
+ const laneReason = val(s, 'lane_reason', 'n/a');
60
+ const mode = val(s, 'mode', 'n/a');
61
+ const actor = val(s, 'actor', 'n/a');
62
+ const branch = val(s, 'branch', 'n/a');
63
+ const wave = val(s, 'wave', 'n/a');
64
+ const filesTouched = val(s, 'files_touched', 0);
65
+ const locDelta = val(s, 'loc_delta', 0);
66
+ const sensitive = s ? (s.sensitive_paths || []) : [];
67
+ const tasks = s ? (s.tasks || {}) : {};
68
+ const blockers = val(s, 'blockers_open', 0);
69
+ const healCycle = val(s, 'heal_cycle', 1);
70
+ const tokens = Number(val(s, 'tokens_est', 0)) || 0;
71
+ const tokensSource = val(s, 'tokens_source', 'n/a');
72
+ const budget = Number(val(s, 'budget', 0)) || 0;
73
+ const budgetStatus = val(s, 'budget_status', 'n/a');
74
+ const evidence = s ? (s.evidence || []) : [];
75
+ const updated = val(s, 'updated_at', now);
59
76
 
60
77
  // --- wave artifact scan: results/<mission>/NN-*.md with verdict sniffing ---
61
78
  const WAVE_LABEL = {
@@ -117,26 +134,133 @@ if (fs.existsSync(issuesDir)) {
117
134
  }
118
135
  }
119
136
 
137
+ // --- tasks: state.json counts, plan-doc fallback (savepoint-style counting) ---
138
+ function findPlan(mDir, m) {
139
+ const plansDir = path.join(mDir, 'plans');
140
+ if (!fs.existsSync(plansDir)) return null;
141
+ const direct = path.join(plansDir, m + '.md');
142
+ if (fs.existsSync(direct)) return direct;
143
+ const matches = fs.readdirSync(plansDir).filter(f => f.endsWith('-' + m + '.md'));
144
+ return matches.length ? path.join(plansDir, matches.sort()[0]) : null;
145
+ }
146
+
147
+ let tasksDone = tasks.done || 0;
148
+ let tasksTotal = tasks.total || 0;
149
+ let tasksSource = "state.json";
150
+ if (tasksTotal === 0) {
151
+ const plan = findPlan(mugiDir, mission);
152
+ if (plan) {
153
+ const lines = fs.readFileSync(plan, 'utf8').split(/\r?\n/);
154
+ const done = lines.filter(l => l.includes('[x]')).length;
155
+ const total = done + lines.filter(l => l.includes('[ ]')).length;
156
+ if (total > 0) {
157
+ tasksDone = done;
158
+ tasksTotal = total;
159
+ tasksSource = "plan doc";
160
+ }
161
+ }
162
+ }
163
+ if (tasksDone === 0 && tasksTotal === 0) { tasksDone = 'n/a'; tasksTotal = 'n/a'; }
164
+
165
+ // --- token budget flag: WARN/STOP line when status is not ok ---
166
+ let budgetFlag = "";
167
+ if (budgetStatus === "warn" || budgetStatus === "stop") {
168
+ const warnAt = Math.floor(budget * 3 / 2);
169
+ const stopAt = budget * 3;
170
+ if (budgetStatus === "stop") {
171
+ budgetFlag = "🛑 STOP: tokens " + tokens.toLocaleString() + " ≥ 3× budget " + budget.toLocaleString() + " (stop at " + stopAt.toLocaleString() + ") — halt; escalate to Luffy";
172
+ } else {
173
+ budgetFlag = "⚠ WARN: tokens " + tokens.toLocaleString() + " ≥ 1.5× budget " + budget.toLocaleString() + " (warn at " + warnAt.toLocaleString() + ") — checkpoint before continuing";
174
+ }
175
+ }
176
+
177
+ // --- gate/quality excerpt: headings + body from 06-closure.md, as-is ---
178
+ function sectionExcerpt(file, heading) {
179
+ if (!fs.existsSync(file)) return null;
180
+ const lines = fs.readFileSync(file, 'utf8').split(/\r?\n/);
181
+ let collecting = false;
182
+ let content = 0;
183
+ const out = [];
184
+ for (const l of lines) {
185
+ if (/^##\s/.test(l)) {
186
+ if (collecting) break;
187
+ if (l.trim() === heading) { collecting = true; out.push(l); continue; }
188
+ } else if (collecting) {
189
+ if (l.trim()) {
190
+ out.push(l);
191
+ content++;
192
+ if (content >= 20) break; // cap excerpt
193
+ }
194
+ }
195
+ }
196
+ return collecting ? out : null;
197
+ }
198
+
199
+ const closureFile = path.join(resultsDir, "06-closure.md");
200
+ const gateSec = sectionExcerpt(closureFile, "## Gate verdicts");
201
+ const testsSec = sectionExcerpt(closureFile, "## Tests");
202
+
203
+ // --- evidence file list: all .md artifacts in results/<mission>/ ---
204
+ let evidenceFiles = [];
205
+ if (fs.existsSync(resultsDir)) {
206
+ evidenceFiles = fs.readdirSync(resultsDir).filter(f => f.endsWith('.md')).sort();
207
+ }
208
+
209
+ // --- assemble report ---
120
210
  let report = "# Mission: " + mission + " . " + now + "\n\n";
121
- report += "**Lane** " + lane + " . **Mode** " + mode + " . **Actor** " + actor + " . **Branch** " + branch + "\n\n";
211
+
212
+ report += "## Mission header\n\n| Field | Value |\n|-------|-------|\n";
213
+ report += "| Mission | " + mission + " |\n";
214
+ report += "| Branch | " + branch + " |\n";
215
+ report += "| Lane | " + lane + " |\n";
216
+ report += "| Lane reason | " + laneReason + " |\n";
217
+ report += "| Mode | " + mode + " |\n";
218
+ report += "| Wave | " + wave + " |\n";
219
+ report += "| Actor | " + actor + " |\n\n";
220
+
221
+ report += "## Token budget\n\n| Field | Value |\n|-------|-------|\n";
222
+ report += "| Tokens (est) | " + tokens.toLocaleString() + " |\n";
223
+ report += "| Tokens source | " + tokensSource + " |\n";
224
+ report += "| Budget | " + budget.toLocaleString() + " |\n";
225
+ report += "| Budget status | " + budgetStatus + " |\n";
226
+ if (budgetFlag) report += "\n" + budgetFlag;
227
+ report += "\n\n";
228
+
229
+ report += "## Tasks\n\n| Field | Value |\n|-------|-------|\n";
230
+ report += "| Done | " + tasksDone + " |\n";
231
+ report += "| Total | " + tasksTotal + " |\n";
232
+ report += "| Source | " + tasksSource + " |\n\n";
122
233
 
123
234
  report += "## What changed\n\n";
124
235
  report += filesTouched + " files, +" + locDelta + " LOC";
125
236
  if (sensitive.length) report += "\nSensitive paths: " + sensitive.join(", ");
237
+ report += "\n\n";
126
238
 
127
- report += "\n\n## Waves\n\n| Wave | Artifact | Verdict |\n|------|----------|---------|";
239
+ report += "## Waves\n\n| Wave | Artifact | Verdict |\n|------|----------|---------|";
128
240
  if (waveRows.length) {
129
241
  for (const r of waveRows) report += "\n| " + r.label + " | `" + r.file + "` | " + r.verdict + " |";
130
242
  } else {
131
243
  report += "\n| (no wave artifacts found in `" + resultsDir + "`) | | |";
132
244
  }
245
+ report += "\n\n";
246
+
247
+ report += "## Gate & quality\n\n";
248
+ if (gateSec) report += gateSec.join("\n") + "\n";
249
+ if (testsSec) report += (gateSec ? "\n" : "") + testsSec.join("\n") + "\n";
250
+ if (!gateSec && !testsSec) report += "n/a\n";
251
+ report += "\n";
133
252
 
134
- report += "\n\n## Review & blockers\n\n";
253
+ report += "## Evidence files\n\n";
254
+ report += evidenceFiles.length ? evidenceFiles.map(f => "- " + f).join("\n") : "n/a";
255
+ report += "\n\n";
256
+
257
+ report += "## Review & blockers\n\n";
135
258
  report += "Review + security files: " + (reviewFiles.length ? reviewFiles.join(", ") : "none") + "\n";
136
259
  report += "Findings: " + reviewFindings + "\n";
137
260
  report += "Blocker ledger rows: " + issueRows.length + (issueRows.length ? "\n" + issueRows.map(r => "- " + r).join("\n") : "");
261
+ report += "\n\n";
138
262
 
139
- report += "\n\n## State\n\n| Field | Value |\n|-------|-------|\n";
263
+ report += "## State\n\n| Field | Value |\n|-------|-------|\n";
140
264
  report += "| Wave | " + wave + " |\n";
141
265
  report += "| Tasks | " + (tasks.done || 0) + "/" + (tasks.total || 0) + " done |\n";
142
266
  report += "| Blockers open | " + blockers + " |\n";
@@ -144,9 +268,8 @@ report += "| Heal cycles | " + healCycle + " |\n";
144
268
  report += "| Tokens used | " + tokens.toLocaleString() + " / " + budget.toLocaleString() + " |\n\n";
145
269
 
146
270
  report += "## Evidence\n\n";
147
- for (const e of evidence) report += "- " + e + "\n";
148
-
149
- report += "\n## Updated\n\n" + updated + "\n";
271
+ report += evidence.length ? evidence.map(e => "- " + e).join("\n") : "n/a";
272
+ report += "\n\n## Updated\n\n" + updated + "\n";
150
273
 
151
274
  fs.writeFileSync(reportFile, report);
152
275
  console.log("\u2713 mission report: " + reportFile);
@@ -7,7 +7,6 @@ import { createInterface } from "node:readline";
7
7
  const root = join(import.meta.dirname, "..");
8
8
  const mugiwaraDir = join(root, ".mugiwara");
9
9
  const configPath = join(mugiwaraDir, "config");
10
- const onboardPath = join(mugiwaraDir, "onboard.json");
11
10
 
12
11
  function ask(rl: import("node:readline").Interface, prompt: string): Promise<string> {
13
12
  return new Promise((resolve) => {
@@ -38,7 +37,7 @@ async function main() {
38
37
  if (process.argv.includes("--help") || process.argv.includes("-h")) {
39
38
  console.log("Usage: bun scripts/onboard.ts");
40
39
  console.log("Runs the Mugiwara onboarding wizard (10 fixed questions).");
41
- console.log("Writes .mugiwara/config and .mugiwara/onboard.json.");
40
+ console.log("Writes .mugiwara/config.");
42
41
  process.exit(0);
43
42
  }
44
43
 
@@ -64,10 +63,6 @@ async function main() {
64
63
  console.log("");
65
64
  }
66
65
 
67
- const answers: Record<string, unknown> = {
68
- started_at: new Date().toISOString(),
69
- };
70
-
71
66
  // ---- Phase 1: Project Context ----
72
67
  console.log("── Phase 1: Project Context ──");
73
68
  console.log("");
@@ -80,7 +75,6 @@ async function main() {
80
75
  "Backend service / API",
81
76
  "Other",
82
77
  ]);
83
- answers.project_type = q1;
84
78
  console.log("");
85
79
 
86
80
  const q2 = await pick(rl, "Q2 — Primary language:\n [1] TypeScript\n [2] JavaScript\n [3] Python\n [4] Go\n [5] Rust\n [6] Java\n [7] Other\n > ", [
@@ -92,7 +86,6 @@ async function main() {
92
86
  "Java",
93
87
  "Other",
94
88
  ]);
95
- answers.primary_language = q2;
96
89
  console.log("");
97
90
 
98
91
  const q3 = await pick(rl, "Q3 — Team size:\n [1] Solo\n [2] 2–5\n [3] 6–15\n [4] 16+\n > ", [
@@ -101,7 +94,6 @@ async function main() {
101
94
  "6–15",
102
95
  "16+",
103
96
  ]);
104
- answers.team_size = q3;
105
97
  console.log("");
106
98
 
107
99
  const q4 = await pick(rl, "Q4 — Git workflow:\n [1] Trunk-based (feature/{type}-{issue}-{slug})\n [2] GitFlow (feature/{slug})\n [3] GitHub Flow (feat/{slug})\n [4] Other (feature/{slug})\n > ", [
@@ -110,7 +102,6 @@ async function main() {
110
102
  "github-flow",
111
103
  "other",
112
104
  ]);
113
- answers.git_workflow = q4;
114
105
  console.log("");
115
106
 
116
107
  const q5 = await pick(rl, "Q5 — CI/CD platform:\n [1] GitHub Actions\n [2] GitLab CI\n [3] CircleCI\n [4] Jenkins\n [5] None / manual\n [6] Other\n > ", [
@@ -121,7 +112,6 @@ async function main() {
121
112
  "None / manual",
122
113
  "Other",
123
114
  ]);
124
- answers.ci_cd = q5;
125
115
  console.log("");
126
116
 
127
117
  // ---- Phase 2: Mugiwara Preferences ----
@@ -133,27 +123,20 @@ async function main() {
133
123
  "semi",
134
124
  "auto",
135
125
  ]);
136
- answers.autonomy_mode = q6;
137
- console.log("");
138
-
139
- const q7 = await ask(rl, "Q7 — Agents to enable (comma-separated or 'all'):\n Available: brainstorm, plan, execute, checkpoint, quality, gates, review, security, healing\n Default: all\n > ");
140
- answers.enabled_agents = q7 || "all";
141
126
  console.log("");
142
127
 
143
- const q8a = await pick(rl, "Q8a — Code review depth:\n [1] full — breaking-change map, five-axis review, ≤3 cycles\n [2] standard — five-axis review, 1 cycle\n [3] quick — diff-only, no caller-map\n > ", [
128
+ const q8a = await pick(rl, "Q7 — Code review depth:\n [1] full — breaking-change map, five-axis review, ≤3 cycles\n [2] standard — five-axis review, 1 cycle\n [3] quick — diff-only, no caller-map\n > ", [
144
129
  "full",
145
130
  "standard",
146
131
  "quick",
147
132
  ]);
148
- answers.review_depth = q8a;
149
133
  console.log("");
150
134
 
151
- const q8b = await pick(rl, "Q8b — Quality check depth:\n [1] full — format, lint, typecheck, test, build\n [2] standard — lint, typecheck, test\n [3] quick — test only\n > ", [
135
+ const q8b = await pick(rl, "Q8 — Quality check depth:\n [1] full — format, lint, typecheck, test, build\n [2] standard — lint, typecheck, test\n [3] quick — test only\n > ", [
152
136
  "full",
153
137
  "standard",
154
138
  "quick",
155
139
  ]);
156
- answers.quality_depth = q8b;
157
140
  console.log("");
158
141
 
159
142
  console.log("Q9 — Test coverage threshold:");
@@ -180,9 +163,6 @@ async function main() {
180
163
  coverageNew = 0;
181
164
  coverageModified = 0;
182
165
  }
183
- answers.coverage_threshold = q9;
184
- answers.coverage_new = coverageNew;
185
- answers.coverage_modified = coverageModified;
186
166
  console.log("");
187
167
 
188
168
  const q10 = await pick(rl, "Q10 — Commit style:\n [1] Conventional Commits (feat:, fix:, chore:, docs:)\n [2] Semantic (type(scope): message)\n [3] Free-form\n > ", [
@@ -190,8 +170,6 @@ async function main() {
190
170
  "semantic",
191
171
  "free-form",
192
172
  ]);
193
- answers.commit_style = q10;
194
- answers.completed_at = new Date().toISOString();
195
173
  console.log("");
196
174
 
197
175
  // ---- Branch name format ----
@@ -230,11 +208,9 @@ async function main() {
230
208
  commit,
231
209
  review_depth: reviewDepth,
232
210
  quality_depth: qualityDepth,
233
- enabled_agents: answers.enabled_agents,
234
211
  };
235
212
 
236
213
  writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
237
- writeFileSync(onboardPath, JSON.stringify(answers, null, 2) + "\n");
238
214
 
239
215
  // ---- Summary ----
240
216
  const qLabels: Record<string, Record<number, string>> = {
@@ -275,14 +251,12 @@ async function main() {
275
251
  console.log(" Git workflow: ", qLabels.git_workflow[q4]);
276
252
  console.log(" CI/CD: ", qLabels.ci_cd[q5]);
277
253
  console.log(" Autonomy mode: ", qLabels.autonomy_mode[q6]);
278
- console.log(" Enabled agents: ", answers.enabled_agents);
279
254
  console.log(" Review depth: ", qLabels.review_depth[q8a]);
280
255
  console.log(" Quality depth: ", qLabels.quality_depth[q8b]);
281
256
  console.log(" Coverage: ", q9 === 3 ? `${coverageNew}/${coverageModified}` : qLabels.coverage_threshold[q9]);
282
257
  console.log(" Commit style: ", qLabels.commit_style[q10]);
283
258
  console.log("");
284
259
  console.log(` Config written: ${configPath}`);
285
- console.log(` Audit trail: ${onboardPath}`);
286
260
  console.log("");
287
261
  } finally {
288
262
  rl.close();
@@ -1,94 +1,171 @@
1
1
  #!/usr/bin/env bun
2
2
  // scripts/release-notes.ts — generates a detailed GitHub Release description
3
3
  // from git history between the previous tag and HEAD. Pulls the full commit
4
- // message (subject + body), groups by conventional-commit type, flags breaking
5
- // changes, and lists the affected scopes. No gh dependency.
4
+ // message (subject + body), groups by conventional-commit scope (feature area)
5
+ // in first-appearance order, falls back to type for unscoped commits, flags
6
+ // breaking changes, and lists the affected scopes. No gh dependency.
6
7
  //
7
8
  // bun scripts/release-notes.ts notes since the last tag → stdout
8
9
  // bun scripts/release-notes.ts --since v0.3.0 notes from that tag → stdout
9
10
  import { execFileSync } from 'node:child_process';
10
11
 
11
- const args = process.argv;
12
- const sinceIdx = args.indexOf('--since');
13
- let since = sinceIdx !== -1 ? args[sinceIdx + 1] : null;
14
-
15
- if (!since) {
16
- // The release workflow tags HEAD BEFORE generating notes, so the newest tag
17
- // is the release tag itself. The "since" boundary must be the tag BEFORE it,
18
- // otherwise range = "<release>..HEAD" is empty and the notes come out blank.
19
- // With only one tag (first release), there is no previous tag — show all.
20
- const tags = execFileSync('git', ['tag', '--sort=-version:refname'], { encoding: 'utf8' })
21
- .split(/\r?\n/).filter(Boolean);
22
- since = tags.length >= 2 ? tags[1] : null;
23
- }
24
-
25
- const range = since ? `${since}..HEAD` : '';
26
- const raw = execFileSync('git', ['log', '--pretty=%H%n%s%n%n%b%n__END__', ...(range ? [range] : [])], { encoding: 'utf8' });
27
- const commits = raw
28
- .split('__END__\n')
29
- .map(c => c.trim())
30
- .filter(Boolean)
31
- .map(c => {
32
- const [sha, subject, ...rest] = c.split('\n');
33
- return { sha: sha.slice(0, 7), subject: subject ?? '', body: rest.join('\n').trim() };
34
- });
35
-
36
- const groups: Record<string, { label: string; items: string[] }> = {
37
- feat: { label: 'New', items: [] },
38
- fix: { label: 'Fixed', items: [] },
39
- refactor: { label: 'Refactored', items: [] },
40
- perf: { label: 'Performance', items: [] },
41
- docs: { label: 'Docs', items: [] },
42
- chore: { label: 'Housekeeping', items: [] },
43
- test: { label: 'Housekeeping', items: [] },
44
- ci: { label: 'Housekeeping', items: [] },
12
+ // human label for a conventional-commit type: section fallback + per-bullet tag
13
+ const TYPE_LABEL: Record<string, string> = {
14
+ feat: 'New',
15
+ fix: 'Fixed',
16
+ refactor: 'Refactored',
17
+ perf: 'Performance',
18
+ docs: 'Docs',
19
+ chore: 'Housekeeping',
20
+ test: 'Housekeeping',
21
+ ci: 'Housekeeping',
45
22
  };
46
23
 
47
- const order = ['feat', 'fix', 'refactor', 'perf', 'docs', 'chore'];
24
+ const TYPE_ORDER = ['feat', 'fix', 'refactor', 'perf', 'docs', 'chore'];
48
25
 
49
26
  // strip signature trailers (Co-authored-by, Signed-off-by, review notes)
50
27
  const TRAILER = /^(Co-authored-by|Signed-off-by|Reviewed-by|Helped-by|Reported-by|Tested-by|Acked-by):/i;
51
28
 
52
- for (const commit of commits) {
53
- const m = /^(\w+)(?:\((.*?)\))?!?: (.*)/.exec(commit.subject);
54
- const type = m ? m[1] : 'chore';
55
- const scope = m?.[2] || '';
56
- const text = m ? m[3] : commit.subject;
57
- const breaking = Boolean(m?.[0]?.includes('!')) || /^BREAKING CHANGE:/m.test(commit.body);
58
-
59
- const title = text.charAt(0).toUpperCase() + text.slice(1);
60
- const scopeTag = scope ? ` \`${scope}\`` : '';
61
- const breakingTag = breaking ? ' ⚠️ **BREAKING**' : '';
62
-
63
- const bodyLines = commit.body
64
- .split(/\r?\n/)
65
- .map(l => l.trim())
66
- .filter(l => l && !TRAILER.test(l) && l !== '---')
67
- // the release-version marker commit repeats the subject as body — drop it
68
- .filter(l => l !== text);
69
-
70
- let entry = `- ${title}${scopeTag}${breakingTag} \`${commit.sha}\``;
71
- if (bodyLines.length) {
72
- entry += '\n' + bodyLines.map(l => ` - ${l}`).join('\n');
73
- }
74
- const g = groups[type] ?? groups.chore;
75
- g.items.push(entry);
29
+ // capitalize the first letter of each word; split on non-alphanumerics
30
+ // (opencode Opencode, release-notes → Release Notes)
31
+ const titleCase = (s: string) =>
32
+ s
33
+ .split(/[^a-zA-Z0-9]+/)
34
+ .filter(Boolean)
35
+ .map(w => w.charAt(0).toUpperCase() + w.slice(1))
36
+ .join(' ');
37
+
38
+ export interface ReleaseCommit {
39
+ sha: string;
40
+ subject: string;
41
+ body: string;
76
42
  }
77
43
 
78
- const lines: string[] = [];
79
- for (const t of order) {
80
- const g = groups[t];
81
- if (!g.items.length) continue;
82
- lines.push(`## ${g.label}`);
83
- lines.push('');
84
- for (const item of g.items) lines.push(item);
85
- lines.push('');
44
+ /**
45
+ * Group parsed commits into per-scope sections (first-appearance order) with a
46
+ * type-fallback for unscoped commits. Returns the rendered markdown body and
47
+ * the change count. Pure — no git access, deterministic. Exported so tests
48
+ * exercise the grouping without invoking git.
49
+ */
50
+ export function buildNotes(commits: ReleaseCommit[]): { count: number; markdown: string } {
51
+ // scoped (feature-area) sections, keyed by lowercased scope, first-appearance order
52
+ const scoped = new Map<string, { label: string; items: string[] }>();
53
+ const scopedOrder: string[] = [];
54
+ // unscoped fallback sections by type, rendered in canonical type order
55
+ const byType = new Map<string, { label: string; items: string[] }>();
56
+ for (const t of TYPE_ORDER) byType.set(t, { label: TYPE_LABEL[t], items: [] });
57
+
58
+ for (const commit of commits) {
59
+ const m = /^(\w+)(?:\((.*?)\))?(!)?: (.*)/.exec(commit.subject);
60
+ const type = m ? m[1] : 'chore';
61
+ const scope = m?.[2] || '';
62
+ const text = m ? m[4] : commit.subject;
63
+ // the conventional-commits `!` breaking marker sits between scope and
64
+ // colon; capture it positionally so `fix: handle a! in parser` is NOT
65
+ // flagged breaking (a bare `!` anywhere in the subject would be)
66
+ const breaking = m?.[3] === '!' || /^BREAKING CHANGE:/m.test(commit.body);
67
+
68
+ const title = text.charAt(0).toUpperCase() + text.slice(1);
69
+ const breakingTag = breaking ? ' ⚠️ **BREAKING**' : '';
70
+
71
+ const bodyLines = commit.body
72
+ .split(/\r?\n/)
73
+ .map(l => l.trim())
74
+ .filter(l => l && !TRAILER.test(l) && l !== '---')
75
+ // the release-version marker commit repeats the subject as body — drop it
76
+ .filter(l => l !== text);
77
+
78
+ const typeLabel = TYPE_LABEL[type] ?? 'Housekeeping';
79
+ const base = `${title}${breakingTag} \`${commit.sha}\``;
80
+ const bodyBlock = bodyLines.length ? '\n' + bodyLines.map(l => ` - ${l}`).join('\n') : '';
81
+
82
+ if (scope) {
83
+ const key = scope.toLowerCase();
84
+ let g = scoped.get(key);
85
+ if (!g) {
86
+ g = { label: titleCase(scope), items: [] };
87
+ scoped.set(key, g);
88
+ scopedOrder.push(key);
89
+ }
90
+ // scoped (feature-area) sections: the type label adds information because
91
+ // the heading names the area, not the type
92
+ g.items.push(`- **${typeLabel}** ${base}${bodyBlock}`);
93
+ } else {
94
+ // type-fallback sections: the heading already names the type, so a
95
+ // per-bullet label would be redundant (R5a)
96
+ (byType.get(type) ?? byType.get('chore')!).items.push(`- ${base}${bodyBlock}`);
97
+ }
98
+ }
99
+
100
+ // Render into a heading-keyed map (case-insensitive) so a type-fallback
101
+ // heading that collides with an earlier scoped heading merges into it
102
+ // instead of emitting a duplicate section (R5b).
103
+ const sections = new Map<string, { heading: string; items: string[] }>();
104
+ const order: string[] = [];
105
+ const addSection = (heading: string, items: string[]) => {
106
+ const key = heading.toLowerCase();
107
+ let s = sections.get(key);
108
+ if (!s) {
109
+ s = { heading, items: [] };
110
+ sections.set(key, s);
111
+ order.push(key);
112
+ }
113
+ s.items.push(...items);
114
+ };
115
+
116
+ // scoped (feature-area) sections first, in order of first appearance
117
+ for (const key of scopedOrder) addSection(scoped.get(key)!.label, scoped.get(key)!.items);
118
+ // then unscoped type-fallback sections, canonical type order
119
+ for (const t of TYPE_ORDER) {
120
+ const g = byType.get(t)!;
121
+ if (g.items.length) addSection(g.label, g.items);
122
+ }
123
+
124
+ const lines: string[] = [];
125
+ for (const key of order) {
126
+ const s = sections.get(key)!;
127
+ lines.push(`## ${s.heading}`);
128
+ lines.push('');
129
+ for (const item of s.items) lines.push(item);
130
+ lines.push('');
131
+ }
132
+
133
+ const bumpIdx = commits.findIndex(c => /chore: release|chore: bump/.test(c.subject));
134
+ const body = commits.slice(0, bumpIdx === -1 ? commits.length : bumpIdx);
135
+ const count = body.length || commits.length;
136
+
137
+ return { count, markdown: lines.join('\n').trimEnd() };
86
138
  }
87
139
 
88
- const bumpIdx = commits.findIndex(c => /chore: release|chore: bump/.test(c.subject));
89
- const body = commits.slice(0, bumpIdx === -1 ? commits.length : bumpIdx);
90
- const count = body.length || commits.length;
140
+ // Guard the CLI path so importing buildNotes in tests does not run git.
141
+ if (import.meta.main) {
142
+ const args = process.argv;
143
+ const sinceIdx = args.indexOf('--since');
144
+ let since = sinceIdx !== -1 ? args[sinceIdx + 1] : null;
145
+
146
+ if (!since) {
147
+ // The release workflow tags HEAD BEFORE generating notes, so the newest tag
148
+ // is the release tag itself. The "since" boundary must be the tag BEFORE it,
149
+ // otherwise range = "<release>..HEAD" is empty and the notes come out blank.
150
+ // With only one tag (first release), there is no previous tag — show all.
151
+ const tags = execFileSync('git', ['tag', '--sort=-version:refname'], { encoding: 'utf8' })
152
+ .split(/\r?\n/).filter(Boolean);
153
+ since = tags.length >= 2 ? tags[1] : null;
154
+ }
91
155
 
92
- console.log(`**${count} change${count === 1 ? '' : 's'} since ${since ?? 'the start'}.**`);
93
- console.log('');
94
- console.log(lines.join('\n').trimEnd());
156
+ const range = since ? `${since}..HEAD` : '';
157
+ const raw = execFileSync('git', ['log', '--pretty=%H%n%s%n%n%b%n__END__', ...(range ? [range] : [])], { encoding: 'utf8' });
158
+ const commits: ReleaseCommit[] = raw
159
+ .split('__END__\n')
160
+ .map(c => c.trim())
161
+ .filter(Boolean)
162
+ .map(c => {
163
+ const [sha, subject, ...rest] = c.split('\n');
164
+ return { sha: sha.slice(0, 7), subject: subject ?? '', body: rest.join('\n').trim() };
165
+ });
166
+
167
+ const { count, markdown } = buildNotes(commits);
168
+ console.log(`**${count} change${count === 1 ? '' : 's'} since ${since ?? 'the start.'}**`);
169
+ console.log('');
170
+ console.log(markdown);
171
+ }