@massu/core 1.7.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.7.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
@@ -47,6 +47,18 @@ async function main(): Promise<void> {
47
47
  await runInstallCommands();
48
48
  break;
49
49
  }
50
+ case 'permissions': {
51
+ const { handlePermissionsSubcommand } = await import('./commands/permissions.ts');
52
+ const result = await handlePermissionsSubcommand(args.slice(1));
53
+ process.exit(result.exitCode);
54
+ return;
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
+ }
50
62
  case 'show-template': {
51
63
  const { runShowTemplate } = await import('./commands/show-template.ts');
52
64
  await runShowTemplate(args.slice(1));
@@ -162,12 +174,14 @@ Commands:
162
174
  init Set up Massu AI in your project (one command, full setup)
163
175
  doctor Check installation health
164
176
  install-hooks Install/update Claude Code hooks
165
- install-commands Install/update slash commands
177
+ install-commands Install/update slash commands (use --skip-permissions to opt out of MCP allowlist seeding)
166
178
  show-template Print the resolved variant of a bundled template (e.g. for diffs)
167
179
  watch Run the file-watcher daemon (auto-refresh on stack changes)
168
180
  refresh-log [N] Show the last N watcher auto-refresh events
169
181
  validate-config Validate massu.config.yaml (alias: config validate)
170
182
  config <sub> Config lifecycle: refresh | validate | upgrade | doctor | check-drift
183
+ permissions <sub> MCP permission lifecycle: install | verify | check-drift
184
+ changelog <sub> CHANGELOG generation / verification: generate | verify
171
185
  adapters <sub> Third-party adapter registry: list | refresh | search | add-local | remove-local | install | resign
172
186
 
173
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
+ }
@@ -23,6 +23,7 @@ import { fileURLToPath } from 'url';
23
23
  import { parse as parseYaml } from 'yaml';
24
24
  import { getConfig, getResolvedPaths } from '../config.ts';
25
25
  import { getCurrentTier, getLicenseInfo, daysUntilExpiry } from '../license.ts';
26
+ import { readSettingsAtPath } from '../lib/settings-local.ts';
26
27
 
27
28
  const __filename = fileURLToPath(import.meta.url);
28
29
  const __dirname = dirname(__filename);
@@ -103,7 +104,7 @@ function checkHooksConfig(projectRoot: string): CheckResult {
103
104
  }
104
105
 
105
106
  try {
106
- const content = JSON.parse(readFileSync(settingsPath, 'utf-8'));
107
+ const content = readSettingsAtPath(settingsPath);
107
108
  if (!content.hooks) {
108
109
  return { name: 'Hooks Config', status: 'fail', detail: 'No hooks configured. Run: npx massu install-hooks' };
109
110
  }
@@ -238,8 +239,8 @@ function checkShellHooksWired(_projectRoot: string): CheckResult {
238
239
  }
239
240
 
240
241
  try {
241
- const content = JSON.parse(readFileSync(settingsPath, 'utf-8'));
242
- const hooks = content.hooks ?? {};
242
+ const content = readSettingsAtPath(settingsPath);
243
+ const hooks = (content.hooks ?? {}) as Record<string, unknown>;
243
244
  const hasSessionStart = Array.isArray(hooks.SessionStart) && hooks.SessionStart.length > 0;
244
245
  const hasPreToolUse = Array.isArray(hooks.PreToolUse) && hooks.PreToolUse.length > 0;
245
246
  if (!hasSessionStart && !hasPreToolUse) {
@@ -34,6 +34,7 @@ import { stringify as yamlStringify, parse as yamlParse } from 'yaml';
34
34
  import { backfillMemoryFiles } from '../memory-file-ingest.ts';
35
35
  import { getConfig, resetConfig } from '../config.ts';
36
36
  import { installAll } from './install-commands.ts';
37
+ import { readSettingsLocal, writeSettingsLocalAtomic } from '../lib/settings-local.ts';
37
38
  import {
38
39
  runDetection,
39
40
  type DetectionResult,
@@ -1107,20 +1108,12 @@ export function installHooks(projectRoot: string): { installed: boolean; count:
1107
1108
  claudeDirName = '.claude';
1108
1109
  }
1109
1110
  const claudeDir = resolve(projectRoot, claudeDirName);
1110
- const settingsPath = resolve(claudeDir, 'settings.local.json');
1111
1111
 
1112
1112
  if (!existsSync(claudeDir)) {
1113
1113
  mkdirSync(claudeDir, { recursive: true });
1114
1114
  }
1115
1115
 
1116
- let settings: Record<string, unknown> = {};
1117
- if (existsSync(settingsPath)) {
1118
- try {
1119
- settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
1120
- } catch {
1121
- settings = {};
1122
- }
1123
- }
1116
+ const settings = readSettingsLocal(claudeDir);
1124
1117
 
1125
1118
  const hooksDir = resolveHooksDir();
1126
1119
  const hooksConfig = buildHooksConfig(hooksDir);
@@ -1134,7 +1127,7 @@ export function installHooks(projectRoot: string): { installed: boolean; count:
1134
1127
 
1135
1128
  settings.hooks = hooksConfig;
1136
1129
 
1137
- writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
1130
+ writeSettingsLocalAtomic(claudeDir, settings);
1138
1131
 
1139
1132
  return { installed: true, count: hookCount };
1140
1133
  }
@@ -20,17 +20,11 @@
20
20
  */
21
21
 
22
22
  import {
23
- closeSync,
24
23
  existsSync,
25
- fsyncSync,
26
- openSync,
27
24
  readFileSync,
28
- rmSync,
29
- writeSync,
30
25
  mkdirSync,
31
26
  readdirSync,
32
27
  statSync,
33
- renameSync,
34
28
  } from 'fs';
35
29
  import { resolve, dirname, relative, join } from 'path';
36
30
  import { fileURLToPath } from 'url';
@@ -38,6 +32,8 @@ import { createHash } from 'crypto';
38
32
  import { getConfig } from '../config.ts';
39
33
  import type { Config } from '../config.ts';
40
34
  import { renderTemplate, MissingVariableError, TemplateParseError } from './template-engine.ts';
35
+ import { atomicWriteFile } from '../lib/settings-local.ts';
36
+ import { installPermissions } from '../permissions.ts';
41
37
 
42
38
  const __filename = fileURLToPath(import.meta.url);
43
39
  const __dirname = dirname(__filename);
@@ -131,47 +127,7 @@ export function loadManifest(claudeDir: string): Manifest {
131
127
  }
132
128
  }
133
129
 
134
- /**
135
- * Iter-7 fix: atomic file write — tmp + fsync + rename.
136
- *
137
- * Plan 3a §3 Risk #4 ("Watcher writes to .claude/ while editor has it open:
138
- * editors can lose changes. Mitigation: write to .massu-tmp then atomic rename")
139
- * AND the watcher spec doc §3 Shutdown Semantics claim ("every file op the
140
- * refresh issues is already atomic-rename-safe... installAll writes <path>.tmp
141
- * then renameSync") both demand this. Previously installAll's per-file writes
142
- * (lines 463/467) and saveManifest were direct `writeFileSync` calls — a
143
- * SIGINT/power-loss between truncate and complete-write left a partial file.
144
- * Now both go through this helper so the watcher's iter-6 "we don't await
145
- * fireRefresh because atomic-rename covers everything" decision is sound.
146
- *
147
- * Writes via openSync + writeSync + fsyncSync + closeSync + renameSync so the
148
- * data hits the platter before the rename. On any error, removes the tmp file.
149
- * Tmp filename includes process.pid to avoid clashes with concurrent installs
150
- * from sibling processes (e.g. a manual `npx massu config refresh` racing the
151
- * watcher daemon — the install-lock should prevent this, but the file-level
152
- * tmp name disambiguates if it ever happens).
153
- */
154
- function atomicWriteFile(targetPath: string, content: string, mode = 0o644): void {
155
- const tmpPath = `${targetPath}.${process.pid}.tmp`;
156
- try {
157
- const fd = openSync(tmpPath, 'w', mode);
158
- try {
159
- const buf = Buffer.from(content, 'utf-8');
160
- writeSync(fd, buf, 0, buf.length, 0);
161
- fsyncSync(fd);
162
- } finally {
163
- closeSync(fd);
164
- }
165
- renameSync(tmpPath, targetPath);
166
- } catch (err) {
167
- if (existsSync(tmpPath)) {
168
- try { rmSync(tmpPath, { force: true }); } catch { /* ignore */ }
169
- }
170
- throw err;
171
- }
172
- }
173
-
174
- /** Write the manifest atomically: tempfile + fsync + renameSync. */
130
+ /** Write the manifest atomically: tempfile + fsync + renameSync (uses shared lib/settings-local.ts:atomicWriteFile). */
175
131
  export function saveManifest(claudeDir: string, manifest: Manifest): void {
176
132
  const dir = resolve(claudeDir, '.massu');
177
133
  if (!existsSync(dir)) {
@@ -541,7 +497,15 @@ export function buildTemplateVars(): Record<string, unknown> {
541
497
  };
542
498
  }
543
499
 
544
- export function installCommands(projectRoot: string): InstallCommandsResult {
500
+ export interface InstallCommandsOptions {
501
+ /** When true, skip seeding `mcp__massu__*` into permissions.allow. */
502
+ skipPermissions?: boolean;
503
+ }
504
+
505
+ export function installCommands(
506
+ projectRoot: string,
507
+ opts: InstallCommandsOptions = {},
508
+ ): InstallCommandsResult {
545
509
  const claudeDirName = getConfig().conventions?.claudeDirName ?? '.claude';
546
510
  const claudeDir = resolve(projectRoot, claudeDirName);
547
511
  const targetDir = resolve(claudeDir, 'commands');
@@ -559,9 +523,21 @@ export function installCommands(projectRoot: string): InstallCommandsResult {
559
523
 
560
524
  const framework = getConfig().framework;
561
525
  const templateVars = buildTemplateVars();
562
- const stats = runWithManifest(claudeDir, (manifest) =>
563
- syncDirectory(sourceDir, targetDir, framework, manifest, 'commands', true, templateVars),
564
- );
526
+ const stats = runWithManifest(claudeDir, (manifest) => {
527
+ const syncStats = syncDirectory(
528
+ sourceDir,
529
+ targetDir,
530
+ framework,
531
+ manifest,
532
+ 'commands',
533
+ true,
534
+ templateVars,
535
+ );
536
+ if (!opts.skipPermissions) {
537
+ installPermissions(claudeDir, manifest, { silent: true });
538
+ }
539
+ return syncStats;
540
+ });
565
541
  return { ...stats, commandsDir: targetDir };
566
542
  }
567
543
 
@@ -576,9 +552,19 @@ export interface InstallAllResult {
576
552
  totalSkipped: number;
577
553
  totalKept: number;
578
554
  claudeDir: string;
555
+ /** Permission-seeding outcome (undefined when --skip-permissions). */
556
+ permissions?: { installed: number; kept: number; skipped: number };
579
557
  }
580
558
 
581
- export function installAll(projectRoot: string): InstallAllResult {
559
+ export interface InstallAllOptions {
560
+ /** When true, skip seeding `mcp__massu__*` into permissions.allow. */
561
+ skipPermissions?: boolean;
562
+ }
563
+
564
+ export function installAll(
565
+ projectRoot: string,
566
+ opts: InstallAllOptions = {},
567
+ ): InstallAllResult {
582
568
  const claudeDirName = getConfig().conventions?.claudeDirName ?? '.claude';
583
569
  const claudeDir = resolve(projectRoot, claudeDirName);
584
570
 
@@ -587,6 +573,7 @@ export function installAll(projectRoot: string): InstallAllResult {
587
573
  let totalUpdated = 0;
588
574
  let totalSkipped = 0;
589
575
  let totalKept = 0;
576
+ let permissionsResult: { installed: number; kept: number; skipped: number } | undefined;
590
577
 
591
578
  const framework = getConfig().framework;
592
579
  const templateVars = buildTemplateVars();
@@ -613,6 +600,9 @@ export function installAll(projectRoot: string): InstallAllResult {
613
600
  totalSkipped += stats.skipped;
614
601
  totalKept += stats.kept;
615
602
  }
603
+ if (!opts.skipPermissions) {
604
+ permissionsResult = installPermissions(claudeDir, manifest, { silent: true });
605
+ }
616
606
  });
617
607
 
618
608
  return {
@@ -622,6 +612,7 @@ export function installAll(projectRoot: string): InstallAllResult {
622
612
  totalSkipped,
623
613
  totalKept,
624
614
  claudeDir,
615
+ permissions: permissionsResult,
625
616
  };
626
617
  }
627
618
 
@@ -631,13 +622,14 @@ export function installAll(projectRoot: string): InstallAllResult {
631
622
 
632
623
  export async function runInstallCommands(): Promise<void> {
633
624
  const projectRoot = process.cwd();
625
+ const skipPermissions = process.argv.slice(2).includes('--skip-permissions');
634
626
 
635
627
  console.log('');
636
628
  console.log('Massu AI - Install Project Assets');
637
629
  console.log('==================================');
638
630
  console.log('');
639
631
 
640
- const result = installAll(projectRoot);
632
+ const result = installAll(projectRoot, { skipPermissions });
641
633
 
642
634
  // Report per-asset-type
643
635
  for (const assetType of ASSET_TYPES) {
@@ -667,6 +659,23 @@ export async function runInstallCommands(): Promise<void> {
667
659
  ` ${result.totalKept} file(s) had local edits and were preserved (see stderr above).`,
668
660
  );
669
661
  }
662
+
663
+ // Permission seeding outcome line
664
+ if (skipPermissions) {
665
+ console.log(' Permission seeding skipped (--skip-permissions).');
666
+ } else if (result.permissions) {
667
+ if (result.permissions.installed > 0) {
668
+ console.log(
669
+ ` Wrote merged permissions block to .claude/settings.local.json (use --skip-permissions to opt out).`,
670
+ );
671
+ } else if (result.permissions.kept > 0) {
672
+ console.log(
673
+ ` MCP allowlist entry was edited by operator; preserved. Use \`npx massu permissions check-drift\` to inspect.`,
674
+ );
675
+ }
676
+ // skipped:1 → silent (already in sync, no operator-visible change)
677
+ }
678
+
670
679
  console.log('');
671
680
  console.log(' Restart your Claude Code session to use them.');
672
681
  console.log('');