@massu/core 1.8.0 → 1.9.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@massu/core",
3
- "version": "1.8.0",
3
+ "version": "1.9.0",
4
4
  "type": "module",
5
5
  "description": "AI Engineering Governance MCP Server - Session memory, knowledge system, feature registry, code intelligence, rule enforcement, tiered tooling (12 free / 72 total), 55+ workflow commands, 11 agents, 20+ patterns",
6
6
  "main": "src/server.ts",
@@ -0,0 +1,178 @@
1
+ // Copyright (c) 2026 Massu. All rights reserved.
2
+ // Licensed under BSL 1.1 - see LICENSE file for details.
3
+
4
+ /**
5
+ * Release-boundary CHANGELOG entry generator. Reads `git log <last-tag>..HEAD`
6
+ * commit subjects, groups by `(plan-<token>)` paren-notation prefix, looks up
7
+ * the matching `docs/plans/*.md` files for their `## Changelog Summary` section,
8
+ * and emits a Keep-a-Changelog 1.1.0-compliant entry.
9
+ *
10
+ * Plan-token regex mirrors `scripts/lib/plan-token-regex.sh` (SSOT). See
11
+ * plan-1.9.0-plan-token-aware-changelog-batcher Phase B.
12
+ *
13
+ * Pure functions only — caller threads in commit subjects + plan directory.
14
+ * No git invocations inside this module (those live in commands/changelog.ts).
15
+ */
16
+
17
+ import { existsSync, readFileSync, readdirSync } from 'fs';
18
+ import { resolve } from 'path';
19
+
20
+ /**
21
+ * Plan-token regex (TS shim of scripts/lib/plan-token-regex.sh PLAN_TOKEN_REGEX).
22
+ * Matches subject prefix `<type>(plan-<token>):` where <type> ∈ {feat, fix, chore, docs}.
23
+ * Captures: group 1 = type, group 2 = full plan-<token>.
24
+ */
25
+ const PLAN_TOKEN_RE = /^(feat|fix|chore|docs)\((plan-[a-z0-9._-]+)\)/;
26
+
27
+ export class MissingPlanFileError extends Error {
28
+ constructor(token: string) {
29
+ super(`No plan file found in plans directory matching Plan Token: ${token}`);
30
+ this.name = 'MissingPlanFileError';
31
+ }
32
+ }
33
+
34
+ export class MissingChangelogSummaryError extends Error {
35
+ constructor(token: string, planFile: string) {
36
+ super(`Plan file ${planFile} for token ${token} has no '## Changelog Summary' section`);
37
+ this.name = 'MissingChangelogSummaryError';
38
+ }
39
+ }
40
+
41
+ export interface PlanSummary {
42
+ title: string;
43
+ summary: string;
44
+ }
45
+
46
+ export interface ParseResult {
47
+ tokens: Set<string>;
48
+ maintenance: string[];
49
+ }
50
+
51
+ /**
52
+ * Parse commit subject lines into:
53
+ * - `tokens`: Set of unique `plan-<token>` strings (with `plan-` prefix)
54
+ * - `maintenance`: subjects that DID NOT match the paren-notation pattern
55
+ */
56
+ export function parseCommitsForPlanTokens(subjects: readonly string[]): ParseResult {
57
+ const tokens = new Set<string>();
58
+ const maintenance: string[] = [];
59
+ for (const subject of subjects) {
60
+ const m = subject.match(PLAN_TOKEN_RE);
61
+ if (m && m[2]) {
62
+ tokens.add(m[2]);
63
+ } else {
64
+ maintenance.push(subject);
65
+ }
66
+ }
67
+ return { tokens, maintenance };
68
+ }
69
+
70
+ /**
71
+ * For each plan-token, find the corresponding `docs/plans/*.md` file and
72
+ * extract its title (first `# Plan: ...` heading) and `## Changelog Summary`
73
+ * section body. Throws on missing file or missing section.
74
+ */
75
+ export function loadPlanSummaries(
76
+ tokens: ReadonlySet<string>,
77
+ planDir: string,
78
+ ): Map<string, PlanSummary> {
79
+ const result = new Map<string, PlanSummary>();
80
+ if (tokens.size === 0) return result;
81
+
82
+ if (!existsSync(planDir)) {
83
+ throw new Error(`Plan directory does not exist: ${planDir}`);
84
+ }
85
+ const files = readdirSync(planDir).filter((f) => f.endsWith('.md'));
86
+
87
+ for (const token of tokens) {
88
+ let matchedFile: string | null = null;
89
+ let content = '';
90
+ for (const file of files) {
91
+ const path = resolve(planDir, file);
92
+ const text = readFileSync(path, 'utf-8');
93
+ // Match `**Plan Token**: `plan-<token>`` allowing optional surrounding text.
94
+ const tokenRe = new RegExp(
95
+ `^\\*\\*Plan Token\\*\\*:\\s*\`?${token.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\$&')}\`?(\\s|$)`,
96
+ 'm',
97
+ );
98
+ if (tokenRe.test(text)) {
99
+ matchedFile = file;
100
+ content = text;
101
+ break;
102
+ }
103
+ }
104
+ if (!matchedFile) {
105
+ throw new MissingPlanFileError(token);
106
+ }
107
+
108
+ // Extract title: first line starting with `# ` (after the metadata block).
109
+ const titleMatch = content.match(/^# (.+)$/m);
110
+ const title = titleMatch ? titleMatch[1].trim() : token;
111
+
112
+ // Extract `## Changelog Summary` section body.
113
+ const sectionRe = /^## Changelog Summary\s*\n([\s\S]*?)(?=\n## |\n---|\n# |$)/m;
114
+ const sectionMatch = content.match(sectionRe);
115
+ if (!sectionMatch || !sectionMatch[1].trim()) {
116
+ throw new MissingChangelogSummaryError(token, matchedFile);
117
+ }
118
+ const summary = sectionMatch[1].trim();
119
+ result.set(token, { title, summary });
120
+ }
121
+
122
+ return result;
123
+ }
124
+
125
+ export interface GenerateOptions {
126
+ version: string;
127
+ date: string;
128
+ planSummaries: ReadonlyMap<string, PlanSummary>;
129
+ maintenance: readonly string[];
130
+ }
131
+
132
+ /**
133
+ * Emit a CHANGELOG.md entry in Keep-a-Changelog 1.1.0 format. Each plan-token
134
+ * becomes one paragraph (or `### Added` subsection if the plan summary itself
135
+ * uses Keep-a-Changelog subsections). Maintenance commits go under a
136
+ * `### Maintenance` subsection at the end.
137
+ */
138
+ export function generateChangelogEntry(opts: GenerateOptions): string {
139
+ const parts: string[] = [];
140
+ parts.push(`## [${opts.version}] - ${opts.date}\n`);
141
+ parts.push('');
142
+
143
+ // Plan summaries, in iteration order
144
+ for (const [, planSum] of opts.planSummaries) {
145
+ parts.push(planSum.summary);
146
+ parts.push('');
147
+ }
148
+
149
+ // Maintenance fallback
150
+ if (opts.maintenance.length > 0) {
151
+ parts.push('### Maintenance');
152
+ parts.push('');
153
+ for (const subject of opts.maintenance) {
154
+ parts.push(`- ${subject}`);
155
+ }
156
+ parts.push('');
157
+ }
158
+
159
+ return parts.join('\n') + '\n';
160
+ }
161
+
162
+ /**
163
+ * Given a CHANGELOG.md entry body and a set of plan-tokens that should be
164
+ * referenced, return the subset of tokens NOT mentioned in the body.
165
+ * Used by `permissions check-drift` verification + pre-tag gate.
166
+ */
167
+ export function findCoverageGaps(
168
+ entryText: string,
169
+ tokens: ReadonlySet<string>,
170
+ ): string[] {
171
+ const gaps: string[] = [];
172
+ for (const token of tokens) {
173
+ if (!entryText.includes(token)) {
174
+ gaps.push(token);
175
+ }
176
+ }
177
+ return gaps;
178
+ }
package/src/cli.ts CHANGED
@@ -53,6 +53,12 @@ async function main(): Promise<void> {
53
53
  process.exit(result.exitCode);
54
54
  return;
55
55
  }
56
+ case 'changelog': {
57
+ const { handleChangelogSubcommand } = await import('./commands/changelog.ts');
58
+ const result = await handleChangelogSubcommand(args.slice(1));
59
+ process.exit(result.exitCode);
60
+ return;
61
+ }
56
62
  case 'show-template': {
57
63
  const { runShowTemplate } = await import('./commands/show-template.ts');
58
64
  await runShowTemplate(args.slice(1));
@@ -175,6 +181,7 @@ Commands:
175
181
  validate-config Validate massu.config.yaml (alias: config validate)
176
182
  config <sub> Config lifecycle: refresh | validate | upgrade | doctor | check-drift
177
183
  permissions <sub> MCP permission lifecycle: install | verify | check-drift
184
+ changelog <sub> CHANGELOG generation / verification: generate | verify
178
185
  adapters <sub> Third-party adapter registry: list | refresh | search | add-local | remove-local | install | resign
179
186
 
180
187
  Options:
@@ -0,0 +1,165 @@
1
+ // Copyright (c) 2026 Massu. All rights reserved.
2
+ // Licensed under BSL 1.1 - see LICENSE file for details.
3
+
4
+ /**
5
+ * `massu changelog <subcommand>` — CHANGELOG generation + verification CLI.
6
+ *
7
+ * Subcommands:
8
+ * generate Emit a draft CHANGELOG.md entry to stdout for commits since the
9
+ * last tag. Operator pipes/copies into CHANGELOG.md.
10
+ * verify Read current CHANGELOG.md latest entry; verify every plan-token
11
+ * in `git log <last-tag>..HEAD` subjects is referenced. Exit 0 if
12
+ * clean, exit 1 with one `gap: <token>` per missing.
13
+ *
14
+ * Plan ref: plan-1.9.0-plan-token-aware-changelog-batcher Phase C.
15
+ * Mirrors the `permissions <sub>` cluster precedent shipped in 1.8.0.
16
+ */
17
+
18
+ import { execSync } from 'child_process';
19
+ import { existsSync, readFileSync } from 'fs';
20
+ import { resolve } from 'path';
21
+ import {
22
+ parseCommitsForPlanTokens,
23
+ loadPlanSummaries,
24
+ generateChangelogEntry,
25
+ findCoverageGaps,
26
+ MissingPlanFileError,
27
+ MissingChangelogSummaryError,
28
+ } from '../changelog-generator.ts';
29
+
30
+ function resolveRepoRoot(): string {
31
+ try {
32
+ return execSync('git rev-parse --show-toplevel', { encoding: 'utf-8' }).trim();
33
+ } catch {
34
+ return process.cwd();
35
+ }
36
+ }
37
+
38
+ function getLastTag(): string | null {
39
+ try {
40
+ return execSync('git describe --tags --abbrev=0', { encoding: 'utf-8' }).trim();
41
+ } catch {
42
+ return null;
43
+ }
44
+ }
45
+
46
+ function getCommitSubjects(range: string): string[] {
47
+ try {
48
+ const out = execSync(`git log ${range} --pretty=format:%s`, { encoding: 'utf-8' });
49
+ return out.split('\n').filter((s) => s.length > 0);
50
+ } catch {
51
+ return [];
52
+ }
53
+ }
54
+
55
+ function getCurrentVersion(repoRoot: string): string {
56
+ const pkgPath = resolve(repoRoot, 'packages/core/package.json');
57
+ if (!existsSync(pkgPath)) return '0.0.0';
58
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
59
+ return pkg.version || '0.0.0';
60
+ }
61
+
62
+ function todayDate(): string {
63
+ return new Date().toISOString().slice(0, 10);
64
+ }
65
+
66
+ function getLatestChangelogEntryBody(repoRoot: string): string {
67
+ const path = resolve(repoRoot, 'CHANGELOG.md');
68
+ if (!existsSync(path)) return '';
69
+ const content = readFileSync(path, 'utf-8');
70
+ // Find first `## [...]` heading and capture until next or EOF
71
+ const m = content.match(/^## \[[\d.]+\][^\n]*\n([\s\S]*?)(?=\n## \[|$)/m);
72
+ return m ? m[1] : '';
73
+ }
74
+
75
+ export async function handleChangelogSubcommand(
76
+ args: string[],
77
+ ): Promise<{ exitCode: number }> {
78
+ const sub = args[0];
79
+ const repoRoot = resolveRepoRoot();
80
+ const planDir = resolve(repoRoot, 'docs/plans');
81
+
82
+ switch (sub) {
83
+ case 'generate': {
84
+ const lastTag = getLastTag();
85
+ const range = lastTag ? `${lastTag}..HEAD` : 'HEAD';
86
+ const subjects = getCommitSubjects(range);
87
+ const { tokens, maintenance } = parseCommitsForPlanTokens(subjects);
88
+
89
+ let planSummaries;
90
+ try {
91
+ planSummaries = loadPlanSummaries(tokens, planDir);
92
+ } catch (err) {
93
+ if (err instanceof MissingPlanFileError || err instanceof MissingChangelogSummaryError) {
94
+ process.stderr.write(`changelog generate: ${err.message}\n`);
95
+ return { exitCode: 2 };
96
+ }
97
+ throw err;
98
+ }
99
+
100
+ const entry = generateChangelogEntry({
101
+ version: getCurrentVersion(repoRoot),
102
+ date: todayDate(),
103
+ planSummaries,
104
+ maintenance,
105
+ });
106
+ process.stdout.write(entry);
107
+ return { exitCode: 0 };
108
+ }
109
+
110
+ case 'verify': {
111
+ const lastTag = getLastTag();
112
+ const range = lastTag ? `${lastTag}..HEAD` : 'HEAD';
113
+ const subjects = getCommitSubjects(range);
114
+ const { tokens } = parseCommitsForPlanTokens(subjects);
115
+
116
+ const entryBody = getLatestChangelogEntryBody(repoRoot);
117
+ const gaps = findCoverageGaps(entryBody, tokens);
118
+
119
+ if (gaps.length === 0) {
120
+ process.stdout.write('All plan-tokens referenced.\n');
121
+ return { exitCode: 0 };
122
+ }
123
+ for (const t of gaps) {
124
+ process.stderr.write(`gap: ${t}\n`);
125
+ }
126
+ return { exitCode: 1 };
127
+ }
128
+
129
+ case '--help':
130
+ case '-h':
131
+ case undefined: {
132
+ printChangelogHelp();
133
+ return { exitCode: 0 };
134
+ }
135
+
136
+ default: {
137
+ process.stderr.write(`massu: unknown changelog subcommand: ${sub}\n`);
138
+ printChangelogHelp();
139
+ return { exitCode: 1 };
140
+ }
141
+ }
142
+ }
143
+
144
+ export function printChangelogHelp(): void {
145
+ process.stdout.write(`
146
+ massu changelog <subcommand>
147
+
148
+ Subcommands:
149
+ generate Emit a draft CHANGELOG.md entry to stdout. Reads commit subjects
150
+ since the last git tag, groups by (plan-<token>) paren-notation,
151
+ looks up each plan file's ## Changelog Summary section, and emits
152
+ a Keep-a-Changelog 1.1.0 entry. Operator pipes/copies into
153
+ CHANGELOG.md (no forced overwrite).
154
+
155
+ verify Read-only check that the latest CHANGELOG.md entry references
156
+ every plan-token in commits since the last tag. Exit 0 if clean,
157
+ exit 1 with one 'gap: <token>' per missing.
158
+
159
+ Examples:
160
+ npx massu changelog generate > /tmp/draft-entry.md
161
+ npx massu changelog verify
162
+
163
+ Documentation: https://massu.ai/docs/reference/cli-reference#massu-changelog
164
+ `);
165
+ }
@@ -1,4 +1,4 @@
1
- // AUTO-GENERATED by scripts/bundle-pubkey.mjs at 2026-05-14T20:20:21.775Z.
1
+ // AUTO-GENERATED by scripts/bundle-pubkey.mjs at 2026-05-15T03:59:04.298Z.
2
2
  // Source pem: packages/core/security/registry-pubkey.pem
3
3
  // RAW-bytes sha256: 3b6226d036c472e533110d11a7d0cd2773ce1d7d4f1003517d5bd69c5418ed4c
4
4
  // DO NOT EDIT — regenerate via `node scripts/bundle-pubkey.mjs` or