@massu/core 1.10.8 → 1.12.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.
Files changed (178) hide show
  1. package/CHANGELOG.md +1303 -0
  2. package/dist/adapter.js +6 -4
  3. package/dist/cli.js +517 -397
  4. package/dist/detect/adapters/.bundle-shasums.json +4 -7
  5. package/dist/detect/adapters/rails.js +6 -4
  6. package/dist/detect/adapters/spring.js +6 -4
  7. package/dist/detect/adapters/tree-sitter-loader.js +6 -4
  8. package/dist/hooks/auto-learning-pipeline.js +24 -1
  9. package/dist/hooks/classify-failure.js +32 -3
  10. package/dist/hooks/cost-tracker.js +24 -0
  11. package/dist/hooks/fix-detector.js +97 -1
  12. package/dist/hooks/incident-pipeline.js +30 -1
  13. package/dist/hooks/intent-suggester.js +6 -3
  14. package/dist/hooks/post-edit-context.js +24 -1
  15. package/dist/hooks/post-tool-use.js +66 -18
  16. package/dist/hooks/pre-compact.js +27 -2
  17. package/dist/hooks/pre-delete-check.js +48 -22
  18. package/dist/hooks/pre-tool-use-gate.js +962 -0
  19. package/dist/hooks/quality-event.js +24 -0
  20. package/dist/hooks/rule-enforcement-pipeline.js +24 -1
  21. package/dist/hooks/security-gate.js +29 -16
  22. package/dist/hooks/session-end.js +24 -0
  23. package/dist/hooks/session-start.js +8710 -8460
  24. package/dist/hooks/user-prompt.js +24 -0
  25. package/package.json +15 -18
  26. package/commands/README.md +0 -134
  27. package/src/adapter.ts +0 -121
  28. package/src/adr-generator.ts +0 -292
  29. package/src/analytics.ts +0 -373
  30. package/src/audit-trail.ts +0 -450
  31. package/src/backfill-sessions.ts +0 -182
  32. package/src/changelog-generator.ts +0 -178
  33. package/src/cli.ts +0 -246
  34. package/src/cloud-sync.ts +0 -234
  35. package/src/commands/adapters.ts +0 -824
  36. package/src/commands/changelog.ts +0 -165
  37. package/src/commands/config-check-drift.ts +0 -133
  38. package/src/commands/config-refresh.ts +0 -396
  39. package/src/commands/config-upgrade.ts +0 -127
  40. package/src/commands/doctor.ts +0 -485
  41. package/src/commands/hook-runner.ts +0 -145
  42. package/src/commands/init.ts +0 -1823
  43. package/src/commands/install-commands.ts +0 -692
  44. package/src/commands/install-hooks.ts +0 -27
  45. package/src/commands/permissions.ts +0 -150
  46. package/src/commands/refresh-log.ts +0 -37
  47. package/src/commands/show-template.ts +0 -65
  48. package/src/commands/template-engine.ts +0 -301
  49. package/src/commands/watch.ts +0 -430
  50. package/src/config.ts +0 -765
  51. package/src/cost-tracker.ts +0 -355
  52. package/src/db.ts +0 -370
  53. package/src/dependency-scorer.ts +0 -337
  54. package/src/detect/adapters/aspnet.ts +0 -4
  55. package/src/detect/adapters/discover.ts +0 -469
  56. package/src/detect/adapters/file-sampler.ts +0 -225
  57. package/src/detect/adapters/go-chi.ts +0 -4
  58. package/src/detect/adapters/index.ts +0 -51
  59. package/src/detect/adapters/nextjs-trpc.ts +0 -166
  60. package/src/detect/adapters/parse-guard.ts +0 -133
  61. package/src/detect/adapters/phoenix.ts +0 -4
  62. package/src/detect/adapters/python-django.ts +0 -208
  63. package/src/detect/adapters/python-fastapi.ts +0 -223
  64. package/src/detect/adapters/python-flask.ts +0 -235
  65. package/src/detect/adapters/query-helpers.ts +0 -170
  66. package/src/detect/adapters/rails.ts +0 -4
  67. package/src/detect/adapters/runner.ts +0 -284
  68. package/src/detect/adapters/spring.ts +0 -4
  69. package/src/detect/adapters/swift-swiftui.ts +0 -171
  70. package/src/detect/adapters/tree-sitter-loader.ts +0 -517
  71. package/src/detect/adapters/types.ts +0 -191
  72. package/src/detect/codebase-introspector.ts +0 -210
  73. package/src/detect/domain-inferrer.ts +0 -197
  74. package/src/detect/drift.ts +0 -199
  75. package/src/detect/framework-detector.ts +0 -307
  76. package/src/detect/index.ts +0 -209
  77. package/src/detect/manifest-registry.ts +0 -261
  78. package/src/detect/migrate.ts +0 -362
  79. package/src/detect/monorepo-detector.ts +0 -348
  80. package/src/detect/package-detector.ts +0 -828
  81. package/src/detect/passthrough.ts +0 -108
  82. package/src/detect/regex-fallback.ts +0 -449
  83. package/src/detect/source-dir-detector.ts +0 -297
  84. package/src/detect/vr-command-map.ts +0 -167
  85. package/src/docs-map.json +0 -100
  86. package/src/docs-tools.ts +0 -519
  87. package/src/domains.ts +0 -182
  88. package/src/hooks/auto-learning-pipeline.ts +0 -228
  89. package/src/hooks/classify-failure.ts +0 -259
  90. package/src/hooks/cost-tracker.ts +0 -66
  91. package/src/hooks/fix-detector.ts +0 -186
  92. package/src/hooks/incident-pipeline.ts +0 -190
  93. package/src/hooks/intent-suggester.ts +0 -131
  94. package/src/hooks/post-edit-context.ts +0 -91
  95. package/src/hooks/post-tool-use.ts +0 -413
  96. package/src/hooks/pre-compact.ts +0 -168
  97. package/src/hooks/pre-delete-check.ts +0 -243
  98. package/src/hooks/quality-event.ts +0 -127
  99. package/src/hooks/rule-enforcement-pipeline.ts +0 -159
  100. package/src/hooks/security-gate.ts +0 -153
  101. package/src/hooks/session-end.ts +0 -467
  102. package/src/hooks/session-start.ts +0 -436
  103. package/src/hooks/user-prompt.ts +0 -157
  104. package/src/import-resolver.ts +0 -226
  105. package/src/knowledge-db.ts +0 -176
  106. package/src/knowledge-indexer.ts +0 -708
  107. package/src/knowledge-tools.ts +0 -1414
  108. package/src/lib/fileLock.ts +0 -203
  109. package/src/lib/framework-supports.ts +0 -85
  110. package/src/lib/gitToplevel.ts +0 -22
  111. package/src/lib/hook-registry.ts +0 -43
  112. package/src/lib/installLock.ts +0 -66
  113. package/src/lib/memory-path.ts +0 -49
  114. package/src/lib/pidLiveness.ts +0 -67
  115. package/src/lib/settings-local.ts +0 -110
  116. package/src/lib/sql-table-names.ts +0 -71
  117. package/src/license.ts +0 -547
  118. package/src/lsp/auto-detect.ts +0 -98
  119. package/src/lsp/client.ts +0 -776
  120. package/src/lsp/enrich.ts +0 -127
  121. package/src/lsp/types.ts +0 -221
  122. package/src/memory-db.ts +0 -1614
  123. package/src/memory-file-ingest.ts +0 -128
  124. package/src/memory-tools.ts +0 -424
  125. package/src/middleware-tree.ts +0 -71
  126. package/src/observability-tools.ts +0 -343
  127. package/src/observation-extractor.ts +0 -418
  128. package/src/page-deps.ts +0 -285
  129. package/src/permissions.ts +0 -422
  130. package/src/prompt-analyzer.ts +0 -332
  131. package/src/python/coupling-detector.ts +0 -125
  132. package/src/python/domain-enforcer.ts +0 -84
  133. package/src/python/impact-analyzer.ts +0 -96
  134. package/src/python/import-parser.ts +0 -244
  135. package/src/python/import-resolver.ts +0 -136
  136. package/src/python/migration-indexer.ts +0 -116
  137. package/src/python/migration-parser.ts +0 -332
  138. package/src/python/model-indexer.ts +0 -71
  139. package/src/python/model-parser.ts +0 -279
  140. package/src/python/route-indexer.ts +0 -59
  141. package/src/python/route-parser.ts +0 -317
  142. package/src/python-tools.ts +0 -630
  143. package/src/regression-detector.ts +0 -319
  144. package/src/rules.ts +0 -57
  145. package/src/schema-mapper.ts +0 -232
  146. package/src/security/adapter-origin.ts +0 -130
  147. package/src/security/adapter-verifier.ts +0 -319
  148. package/src/security/atomic-write.ts +0 -164
  149. package/src/security/fetcher.ts +0 -200
  150. package/src/security/install-tracking.ts +0 -319
  151. package/src/security/license-pubkey.generated.ts +0 -30
  152. package/src/security/license-response-verifier.ts +0 -149
  153. package/src/security/local-fingerprint.ts +0 -225
  154. package/src/security/manifest-cache.ts +0 -333
  155. package/src/security/manifest-schema.ts +0 -129
  156. package/src/security/registry-pubkey.generated.ts +0 -35
  157. package/src/security/telemetry.ts +0 -320
  158. package/src/security-scorer.ts +0 -405
  159. package/src/security-utils.ts +0 -133
  160. package/src/sentinel-db.ts +0 -580
  161. package/src/sentinel-scanner.ts +0 -406
  162. package/src/sentinel-tools.ts +0 -513
  163. package/src/sentinel-types.ts +0 -140
  164. package/src/server-dispatch.ts +0 -225
  165. package/src/server.ts +0 -107
  166. package/src/session-archiver.ts +0 -111
  167. package/src/session-state-generator.ts +0 -174
  168. package/src/team-knowledge.ts +0 -407
  169. package/src/tool-db-needs.ts +0 -232
  170. package/src/tools.ts +0 -1206
  171. package/src/transcript-parser.ts +0 -458
  172. package/src/trpc-index.ts +0 -215
  173. package/src/validate-features-runner.ts +0 -107
  174. package/src/validation-engine.ts +0 -358
  175. package/src/watch/daemon.ts +0 -385
  176. package/src/watch/lockfile-detector.ts +0 -65
  177. package/src/watch/paths.ts +0 -279
  178. package/src/watch/state.ts +0 -178
@@ -1,692 +0,0 @@
1
- // Copyright (c) 2026 Massu. All rights reserved.
2
- // Licensed under BSL 1.1 - see LICENSE file for details.
3
-
4
- /**
5
- * `massu install-commands` — Install massu slash commands, agents, patterns,
6
- * protocols, and reference files into a project.
7
- *
8
- * Copies all massu assets from the npm package into the project's .claude/
9
- * directory. Existing massu files are updated; non-massu files are preserved.
10
- * Handles subdirectories recursively (e.g., golden-path/references/).
11
- *
12
- * v1.3.0 — Stack-aware variants + local-edit protection (manifest):
13
- * - Variant resolution at the top level of `commands/`: a template named
14
- * `<base>.<variant>.md` is preferred over `<base>.md` when the consumer's
15
- * `massu.config.yaml` declares a matching language. See `pickVariant`.
16
- * - Local edits are preserved across reinstalls via a per-consumer manifest
17
- * (`<claudeDir>/.massu/install-manifest.json`) that records the SHA-256 of
18
- * each file at last install. See "Layer 3: Local-edit protection" in the
19
- * 2026-04-26 plan doc.
20
- */
21
-
22
- import {
23
- existsSync,
24
- readFileSync,
25
- mkdirSync,
26
- readdirSync,
27
- statSync,
28
- } from 'fs';
29
- import { resolve, dirname, relative, join } from 'path';
30
- import { fileURLToPath } from 'url';
31
- import { createHash } from 'crypto';
32
- import { getConfig } from '../config.ts';
33
- import type { Config } from '../config.ts';
34
- import { renderTemplate, MissingVariableError, TemplateParseError } from './template-engine.ts';
35
- import { atomicWriteFile } from '../lib/settings-local.ts';
36
- import { installPermissions } from '../permissions.ts';
37
-
38
- const __filename = fileURLToPath(import.meta.url);
39
- const __dirname = dirname(__filename);
40
-
41
- // ============================================================
42
- // Asset Types
43
- // ============================================================
44
-
45
- /** Asset categories distributed by massu */
46
- const ASSET_TYPES = [
47
- { name: 'commands', targetSubdir: 'commands', description: 'slash commands' },
48
- { name: 'agents', targetSubdir: 'agents', description: 'agent definitions' },
49
- { name: 'patterns', targetSubdir: 'patterns', description: 'pattern files' },
50
- { name: 'protocols', targetSubdir: 'protocols', description: 'protocol files' },
51
- { name: 'reference', targetSubdir: 'reference', description: 'reference files' },
52
- ] as const;
53
-
54
- // ============================================================
55
- // Directory Resolution
56
- // ============================================================
57
-
58
- /**
59
- * Resolve the path to a bundled asset directory.
60
- * Handles both npm-installed and local development scenarios.
61
- */
62
- export function resolveAssetDir(assetName: string): string | null {
63
- const cwd = process.cwd();
64
-
65
- // 1. npm-installed: node_modules/@massu/core/{assetName}
66
- const nodeModulesPath = resolve(cwd, 'node_modules/@massu/core', assetName);
67
- if (existsSync(nodeModulesPath)) {
68
- return nodeModulesPath;
69
- }
70
-
71
- // 2. Relative to compiled dist/cli.js → ../{assetName}
72
- const distRelPath = resolve(__dirname, '..', assetName);
73
- if (existsSync(distRelPath)) {
74
- return distRelPath;
75
- }
76
-
77
- // 3. Relative to source src/commands/ → ../../{assetName}
78
- const srcRelPath = resolve(__dirname, '../..', assetName);
79
- if (existsSync(srcRelPath)) {
80
- return srcRelPath;
81
- }
82
-
83
- return null;
84
- }
85
-
86
- /** Legacy alias for backwards compatibility */
87
- export function resolveCommandsDir(): string | null {
88
- return resolveAssetDir('commands');
89
- }
90
-
91
- // ============================================================
92
- // Manifest (local-edit protection)
93
- // ============================================================
94
-
95
- const MANIFEST_VERSION = 1;
96
- const MANIFEST_RELPATH = join('.massu', 'install-manifest.json');
97
-
98
- /** Manifest file shape — see plan §"Manifest JSON shape". */
99
- export interface Manifest {
100
- version: number;
101
- generatedBy: string;
102
- generatedAt: string;
103
- /** key: path relative to the consumer's claudeDir; value: SHA-256 hex digest. */
104
- entries: Record<string, string>;
105
- }
106
-
107
- /** SHA-256 hex digest of a string. */
108
- export function hashContent(content: string): string {
109
- return createHash('sha256').update(content, 'utf-8').digest('hex');
110
- }
111
-
112
- /** Load the manifest from `<claudeDir>/.massu/install-manifest.json`, or return an empty manifest. */
113
- export function loadManifest(claudeDir: string): Manifest {
114
- const path = resolve(claudeDir, MANIFEST_RELPATH);
115
- if (!existsSync(path)) {
116
- return emptyManifest();
117
- }
118
- try {
119
- const raw = readFileSync(path, 'utf-8');
120
- const parsed = JSON.parse(raw) as Manifest;
121
- if (!parsed || typeof parsed !== 'object' || !parsed.entries) {
122
- return emptyManifest();
123
- }
124
- return parsed;
125
- } catch {
126
- return emptyManifest();
127
- }
128
- }
129
-
130
- /** Write the manifest atomically: tempfile + fsync + renameSync (uses shared lib/settings-local.ts:atomicWriteFile). */
131
- export function saveManifest(claudeDir: string, manifest: Manifest): void {
132
- const dir = resolve(claudeDir, '.massu');
133
- if (!existsSync(dir)) {
134
- mkdirSync(dir, { recursive: true });
135
- }
136
- const finalPath = resolve(dir, 'install-manifest.json');
137
- manifest.generatedAt = new Date().toISOString();
138
- atomicWriteFile(finalPath, JSON.stringify(manifest, null, 2));
139
- }
140
-
141
- function emptyManifest(): Manifest {
142
- return {
143
- version: MANIFEST_VERSION,
144
- generatedBy: '@massu/core',
145
- generatedAt: new Date().toISOString(),
146
- entries: {},
147
- };
148
- }
149
-
150
- /**
151
- * Run a function with the manifest loaded; persist atomically afterward.
152
- * Used by both `installAll` and the legacy `installCommands` so any caller
153
- * of either entry point gets the manifest written exactly once per run.
154
- */
155
- export function runWithManifest<T>(claudeDir: string, fn: (m: Manifest) => T): T {
156
- const manifest = loadManifest(claudeDir);
157
- const result = fn(manifest);
158
- saveManifest(claudeDir, manifest);
159
- return result;
160
- }
161
-
162
- // ============================================================
163
- // Variant Resolution (Phase 1)
164
- // ============================================================
165
-
166
- /** Discriminated-union return shape for `pickVariant`. */
167
- export type PickVariantResult =
168
- | { kind: 'hit'; suffix: string } // found a variant (suffix may be "")
169
- | { kind: 'miss' } // no candidate found, caller SKIPS the file
170
- | { kind: 'fallback'; reason: string }; // misconfig / safe fallback, caller copies UNSUFFIXED default
171
-
172
- /** Well-known language keys for the passthrough-fallback step in `pickVariant`. */
173
- const PASSTHROUGH_LANG_KEYS = [
174
- 'typescript',
175
- 'javascript',
176
- 'python',
177
- 'swift',
178
- 'rust',
179
- 'go',
180
- ] as const;
181
-
182
- /**
183
- * Choose the variant suffix for a base template name.
184
- *
185
- * Two-axis priority (Plan #2 P2-001 extends Plan #1's lang-only axis):
186
- * For each language `L` in priority order (primary, languages.*, passthrough.*):
187
- * a. If a sub-framework `F` is declared for L, probe `<base>.<L>-<F>.md`.
188
- * b. Probe `<base>.<L>.md` (lang-only fallback).
189
- * Then probe the unsuffixed `<base>.md`.
190
- *
191
- * Priority order for the language list (Plan #1):
192
- * 1. `framework.primary` (or `framework.type` if primary undefined). With the
193
- * sub-framework axis, the candidate framework is the matching
194
- * `framework.languages[primary].framework` if present, else `framework.router`
195
- * / `framework.orm` / `framework.ui` heuristics, else just lang-only.
196
- * 2. Each declared `framework.languages.<lang>` entry with a non-empty `framework`,
197
- * in YAML declaration order. Sub-framework = `entry.framework`.
198
- * 3. Passthrough fallback: well-known top-level `framework.<lang>` blocks with a
199
- * non-empty `framework` field, in fixed order, excluding entries already covered.
200
- * Sub-framework = top-level block's `framework` field.
201
- * 4. The unsuffixed default ("").
202
- *
203
- * The function NEVER throws. It returns a discriminated union so the caller can
204
- * distinguish "skip this file" from "copy the default" — see plan §"Error semantics".
205
- */
206
- export function pickVariant(
207
- baseName: string,
208
- sourceDir: string,
209
- framework: Config['framework'],
210
- ): PickVariantResult {
211
- // Build (lang, subFramework) candidate pairs in priority order. Sub-framework
212
- // can be undefined — in that case only the lang-only axis is probed for that
213
- // language.
214
- type Candidate = { lang: string; subFramework?: string };
215
- const candidates: Candidate[] = [];
216
- const seenLangs = new Set<string>();
217
-
218
- function pushCandidate(lang: string, sub: string | undefined): void {
219
- if (seenLangs.has(lang)) return;
220
- seenLangs.add(lang);
221
- candidates.push({ lang, subFramework: sub && sub.length > 0 ? sub : undefined });
222
- }
223
-
224
- // 1. framework.primary (or fall back to framework.type)
225
- const primary = framework.primary ?? framework.type;
226
- if (primary && primary !== 'multi') {
227
- // Best-effort sub-framework detection for the primary lang:
228
- // - If `framework.languages[primary]` has a `framework`, use that.
229
- // - Else, fall back to top-level passthrough `framework[primary].framework`.
230
- let primarySub: string | undefined;
231
- if (framework.languages && framework.languages[primary]?.framework) {
232
- primarySub = framework.languages[primary].framework;
233
- } else {
234
- const passthrough = framework as unknown as Record<string, unknown>;
235
- const block = passthrough[primary];
236
- if (block && typeof block === 'object') {
237
- const fw = (block as { framework?: unknown }).framework;
238
- if (typeof fw === 'string' && fw.length > 0) primarySub = fw;
239
- }
240
- }
241
- pushCandidate(primary, primarySub);
242
- }
243
-
244
- // 2. framework.languages declaration order
245
- if (framework.languages) {
246
- for (const lang of Object.keys(framework.languages)) {
247
- const entry = framework.languages[lang];
248
- if (entry && typeof entry.framework === 'string' && entry.framework.length > 0) {
249
- pushCandidate(lang, entry.framework);
250
- }
251
- }
252
- }
253
-
254
- // 3. Passthrough fallback — `framework.<lang>` (top-level passthrough block).
255
- const passthrough = framework as unknown as Record<string, unknown>;
256
- for (const lang of PASSTHROUGH_LANG_KEYS) {
257
- if (seenLangs.has(lang)) continue;
258
- const block = passthrough[lang];
259
- if (block && typeof block === 'object') {
260
- const fw = (block as { framework?: unknown }).framework;
261
- if (typeof fw === 'string' && fw.length > 0) {
262
- pushCandidate(lang, fw);
263
- }
264
- }
265
- }
266
-
267
- // 4. Probe disk — for each (lang, sub) pair, try lang-sub first, then lang-only.
268
- for (const cand of candidates) {
269
- if (cand.subFramework) {
270
- const subPath = resolve(sourceDir, `${baseName}.${cand.lang}-${cand.subFramework}.md`);
271
- if (existsSync(subPath)) {
272
- return { kind: 'hit', suffix: `.${cand.lang}-${cand.subFramework}` };
273
- }
274
- }
275
- const langPath = resolve(sourceDir, `${baseName}.${cand.lang}.md`);
276
- if (existsSync(langPath)) {
277
- return { kind: 'hit', suffix: `.${cand.lang}` };
278
- }
279
- }
280
- // Unsuffixed default
281
- const defaultPath = resolve(sourceDir, `${baseName}.md`);
282
- if (existsSync(defaultPath)) {
283
- return { kind: 'hit', suffix: '' };
284
- }
285
-
286
- // No hit. Risk #7: framework.type=multi without primary → safe fallback.
287
- if (framework.type === 'multi' && !framework.primary) {
288
- process.stderr.write(
289
- 'massu: warning - framework.type=multi but framework.primary is undefined; ' +
290
- 'falling back to default templates\n',
291
- );
292
- return { kind: 'fallback', reason: 'multi-without-primary' };
293
- }
294
-
295
- return { kind: 'miss' };
296
- }
297
-
298
- // ============================================================
299
- // Recursive File Sync
300
- // ============================================================
301
-
302
- interface SyncStats {
303
- installed: number;
304
- updated: number;
305
- skipped: number;
306
- kept: number;
307
- }
308
-
309
- /** Returns true if a top-level entry name has the `<base>.<variant>.md` shape. */
310
- function isVariantFilename(entry: string): boolean {
311
- // Match exactly one inner dot before `.md`. `_shared-preamble.md` (no inner dot) survives.
312
- return /^[^.]+\.[^.]+\.md$/.test(entry);
313
- }
314
-
315
- /**
316
- * Recursively sync all .md files from sourceDir to targetDir.
317
- *
318
- * At top level (`topLevel === true`), apply variant resolution:
319
- * - Skip entries that match `<base>.<variant>.md` (the variant siblings are
320
- * selected indirectly via `pickVariant` so they never land in the consumer
321
- * dir directly).
322
- * - For each base entry `<base>.md`, call `pickVariant` to choose the source.
323
- *
324
- * At depth ≥ 1 (subdirectory recursion), copy files as-is — no variant logic,
325
- * no dot-skip filter (so future authors can use dotted filenames in subdirs).
326
- */
327
- export function syncDirectory(
328
- sourceDir: string,
329
- targetDir: string,
330
- framework: Config['framework'],
331
- manifest: Manifest,
332
- manifestKeyPrefix: string,
333
- topLevel: boolean = true,
334
- templateVars: Record<string, unknown> = {},
335
- ): SyncStats {
336
- const stats: SyncStats = { installed: 0, updated: 0, skipped: 0, kept: 0 };
337
-
338
- if (!existsSync(targetDir)) {
339
- mkdirSync(targetDir, { recursive: true });
340
- }
341
-
342
- const entries = readdirSync(sourceDir);
343
-
344
- for (const entry of entries) {
345
- const sourcePath = resolve(sourceDir, entry);
346
- const entryStat = statSync(sourcePath);
347
-
348
- if (entryStat.isDirectory()) {
349
- // Recurse — depth > 0 disables variant filtering for nested files.
350
- const subTargetDir = resolve(targetDir, entry);
351
- const subPrefix = manifestKeyPrefix === ''
352
- ? entry
353
- : `${manifestKeyPrefix}/${entry}`;
354
- const subStats = syncDirectory(
355
- sourcePath,
356
- subTargetDir,
357
- framework,
358
- manifest,
359
- subPrefix,
360
- false,
361
- templateVars,
362
- );
363
- stats.installed += subStats.installed;
364
- stats.updated += subStats.updated;
365
- stats.skipped += subStats.skipped;
366
- stats.kept += subStats.kept;
367
- continue;
368
- }
369
-
370
- if (!entry.endsWith('.md')) continue;
371
-
372
- let sourceFilename = entry;
373
- let baseName = entry.slice(0, -'.md'.length);
374
-
375
- if (topLevel) {
376
- // Skip variant siblings — they are selected indirectly via the base name.
377
- if (isVariantFilename(entry)) continue;
378
-
379
- const choice = pickVariant(baseName, sourceDir, framework);
380
- if (choice.kind === 'miss') {
381
- // No file to copy.
382
- continue;
383
- }
384
- // 'hit' or 'fallback' both copy a file:
385
- // - 'hit' uses the chosen suffix (may be "")
386
- // - 'fallback' copies the unsuffixed default (same as suffix === "")
387
- const suffix = choice.kind === 'hit' ? choice.suffix : '';
388
- sourceFilename = suffix === '' ? `${baseName}.md` : `${baseName}${suffix}.md`;
389
- }
390
-
391
- const resolvedSourcePath = resolve(sourceDir, sourceFilename);
392
- if (!existsSync(resolvedSourcePath)) {
393
- // Defensive: pickVariant said hit, but file vanished between probe and read.
394
- continue;
395
- }
396
-
397
- // Target filename is always the BASE name (variant suffix is internal to the package).
398
- const targetFilename = topLevel ? `${baseName}.md` : entry;
399
- const targetPath = resolve(targetDir, targetFilename);
400
- const rawContent = readFileSync(resolvedSourcePath, 'utf-8');
401
-
402
- // Plan #2 P1-003: render any `{{var}}` substitutions BEFORE hashing so
403
- // the manifest entry hash matches the byte-stream that lands on disk.
404
- // Engine errors (missing var, malformed token) fail this single file but
405
- // never abort the whole install — see spec §"Error semantics".
406
- let sourceContent: string;
407
- try {
408
- sourceContent = renderTemplate(rawContent, templateVars);
409
- } catch (err) {
410
- if (err instanceof MissingVariableError || err instanceof TemplateParseError) {
411
- process.stderr.write(
412
- `massu: skipping ${resolvedSourcePath}: ${err.message}\n`,
413
- );
414
- stats.skipped++;
415
- continue;
416
- }
417
- throw err;
418
- }
419
- const sourceHash = hashContent(sourceContent);
420
-
421
- const manifestKey = manifestKeyPrefix === ''
422
- ? targetFilename
423
- : `${manifestKeyPrefix}/${targetFilename}`;
424
- const lastInstalledHash = manifest.entries[manifestKey];
425
-
426
- if (existsSync(targetPath)) {
427
- const existingContent = readFileSync(targetPath, 'utf-8');
428
- const existingHash = hashContent(existingContent);
429
-
430
- if (existingHash === sourceHash) {
431
- // Already byte-identical to upstream. Ensure manifest reflects that.
432
- manifest.entries[manifestKey] = sourceHash;
433
- stats.skipped++;
434
- continue;
435
- }
436
-
437
- if (lastInstalledHash === undefined) {
438
- // First-install ambiguity: file exists but no manifest entry.
439
- // Treat as user-edited: keep, record existing hash, print one-line notice.
440
- manifest.entries[manifestKey] = existingHash;
441
- process.stderr.write(
442
- `First-install heuristic: keeping existing ${targetPath} (differs from upstream).\n` +
443
- ` To accept upstream: rm ${targetPath} && npx massu install-commands\n`,
444
- );
445
- stats.kept++;
446
- continue;
447
- }
448
-
449
- if (existingHash !== lastInstalledHash) {
450
- // User edited it after the last install. Preserve.
451
- process.stderr.write(
452
- `${targetFilename} has local edits - kept your version.\n` +
453
- ` To accept upstream: rm ${targetPath} && npx massu install-commands\n` +
454
- ` To diff: diff ${targetPath} <(npx massu show-template ${baseName})\n`,
455
- );
456
- stats.kept++;
457
- continue;
458
- }
459
-
460
- // existingHash === lastInstalledHash and sourceHash differs → safe upgrade.
461
- atomicWriteFile(targetPath, sourceContent);
462
- manifest.entries[manifestKey] = sourceHash;
463
- stats.updated++;
464
- } else {
465
- atomicWriteFile(targetPath, sourceContent);
466
- manifest.entries[manifestKey] = sourceHash;
467
- stats.installed++;
468
- }
469
- }
470
-
471
- return stats;
472
- }
473
-
474
- // ============================================================
475
- // Install Commands (legacy API — preserved for backwards compat)
476
- // ============================================================
477
-
478
- export interface InstallCommandsResult {
479
- installed: number;
480
- updated: number;
481
- skipped: number;
482
- kept: number;
483
- commandsDir: string;
484
- }
485
-
486
- /**
487
- * Build the variable scope passed to the templating engine.
488
- * See spec §"Variable scope passed to the engine" for the contract.
489
- */
490
- export function buildTemplateVars(): Record<string, unknown> {
491
- const config = getConfig();
492
- return {
493
- framework: config.framework,
494
- paths: config.paths,
495
- detected: config.detected ?? {},
496
- config,
497
- // P-H006 (plan-stage-c-high-batch): RESERVED CLAUDE CODE PLACEHOLDER.
498
- // Claude Code reads `{{ARGUMENTS}}` as a runtime placeholder inside
499
- // slash-command files. The Massu template engine has no native concept
500
- // of reserved literals; we model the placeholder as a variable whose
501
- // value IS the literal `{{ARGUMENTS}}` string. Because the engine never
502
- // re-renders output, this passes through verbatim. Closes the bug class
503
- // where `/massu-article-review`, `/massu-autoresearch`, etc. silently
504
- // failed to install because the engine threw MissingVariableError on
505
- // their {{ARGUMENTS}} usage.
506
- ARGUMENTS: '{{ARGUMENTS}}',
507
- };
508
- }
509
-
510
- export interface InstallCommandsOptions {
511
- /** When true, skip seeding `mcp__massu__*` into permissions.allow. */
512
- skipPermissions?: boolean;
513
- }
514
-
515
- export function installCommands(
516
- projectRoot: string,
517
- opts: InstallCommandsOptions = {},
518
- ): InstallCommandsResult {
519
- const claudeDirName = getConfig().conventions?.claudeDirName ?? '.claude';
520
- const claudeDir = resolve(projectRoot, claudeDirName);
521
- const targetDir = resolve(claudeDir, 'commands');
522
-
523
- if (!existsSync(targetDir)) {
524
- mkdirSync(targetDir, { recursive: true });
525
- }
526
-
527
- const sourceDir = resolveAssetDir('commands');
528
- if (!sourceDir) {
529
- console.error(' ERROR: Could not find massu commands directory.');
530
- console.error(' Try reinstalling: npm install @massu/core');
531
- return { installed: 0, updated: 0, skipped: 0, kept: 0, commandsDir: targetDir };
532
- }
533
-
534
- const framework = getConfig().framework;
535
- const templateVars = buildTemplateVars();
536
- const stats = runWithManifest(claudeDir, (manifest) => {
537
- const syncStats = syncDirectory(
538
- sourceDir,
539
- targetDir,
540
- framework,
541
- manifest,
542
- 'commands',
543
- true,
544
- templateVars,
545
- );
546
- if (!opts.skipPermissions) {
547
- installPermissions(claudeDir, manifest, { silent: true });
548
- }
549
- return syncStats;
550
- });
551
- return { ...stats, commandsDir: targetDir };
552
- }
553
-
554
- // ============================================================
555
- // Install All Assets
556
- // ============================================================
557
-
558
- export interface InstallAllResult {
559
- assets: Record<string, SyncStats>;
560
- totalInstalled: number;
561
- totalUpdated: number;
562
- totalSkipped: number;
563
- totalKept: number;
564
- claudeDir: string;
565
- /** Permission-seeding outcome (undefined when --skip-permissions). */
566
- permissions?: { installed: number; kept: number; skipped: number };
567
- }
568
-
569
- export interface InstallAllOptions {
570
- /** When true, skip seeding `mcp__massu__*` into permissions.allow. */
571
- skipPermissions?: boolean;
572
- }
573
-
574
- export function installAll(
575
- projectRoot: string,
576
- opts: InstallAllOptions = {},
577
- ): InstallAllResult {
578
- const claudeDirName = getConfig().conventions?.claudeDirName ?? '.claude';
579
- const claudeDir = resolve(projectRoot, claudeDirName);
580
-
581
- const assets: Record<string, SyncStats> = {};
582
- let totalInstalled = 0;
583
- let totalUpdated = 0;
584
- let totalSkipped = 0;
585
- let totalKept = 0;
586
- let permissionsResult: { installed: number; kept: number; skipped: number } | undefined;
587
-
588
- const framework = getConfig().framework;
589
- const templateVars = buildTemplateVars();
590
-
591
- runWithManifest(claudeDir, (manifest) => {
592
- for (const assetType of ASSET_TYPES) {
593
- const sourceDir = resolveAssetDir(assetType.name);
594
- if (!sourceDir) continue;
595
-
596
- const targetDir = resolve(claudeDir, assetType.targetSubdir);
597
- const stats = syncDirectory(
598
- sourceDir,
599
- targetDir,
600
- framework,
601
- manifest,
602
- assetType.targetSubdir,
603
- true,
604
- templateVars,
605
- );
606
-
607
- assets[assetType.name] = stats;
608
- totalInstalled += stats.installed;
609
- totalUpdated += stats.updated;
610
- totalSkipped += stats.skipped;
611
- totalKept += stats.kept;
612
- }
613
- if (!opts.skipPermissions) {
614
- permissionsResult = installPermissions(claudeDir, manifest, { silent: true });
615
- }
616
- });
617
-
618
- return {
619
- assets,
620
- totalInstalled,
621
- totalUpdated,
622
- totalSkipped,
623
- totalKept,
624
- claudeDir,
625
- permissions: permissionsResult,
626
- };
627
- }
628
-
629
- // ============================================================
630
- // Standalone CLI Runner
631
- // ============================================================
632
-
633
- export async function runInstallCommands(): Promise<void> {
634
- const projectRoot = process.cwd();
635
- const skipPermissions = process.argv.slice(2).includes('--skip-permissions');
636
-
637
- console.log('');
638
- console.log('Massu AI - Install Project Assets');
639
- console.log('==================================');
640
- console.log('');
641
-
642
- const result = installAll(projectRoot, { skipPermissions });
643
-
644
- // Report per-asset-type
645
- for (const assetType of ASSET_TYPES) {
646
- const stats = result.assets[assetType.name];
647
- if (!stats) {
648
- continue;
649
- }
650
- const total = stats.installed + stats.updated + stats.skipped + stats.kept;
651
- if (total === 0) continue;
652
-
653
- const parts: string[] = [];
654
- if (stats.installed > 0) parts.push(`${stats.installed} new`);
655
- if (stats.updated > 0) parts.push(`${stats.updated} updated`);
656
- if (stats.skipped > 0) parts.push(`${stats.skipped} current`);
657
- if (stats.kept > 0) parts.push(`${stats.kept} kept (local edits)`);
658
-
659
- const description = assetType.description;
660
- console.log(` ${description}: ${parts.join(', ')} (${total} total)`);
661
- }
662
-
663
- const grandTotal =
664
- result.totalInstalled + result.totalUpdated + result.totalSkipped + result.totalKept;
665
- console.log('');
666
- console.log(` ${grandTotal} total files synced to ${result.claudeDir}`);
667
- if (result.totalKept > 0) {
668
- console.log(
669
- ` ${result.totalKept} file(s) had local edits and were preserved (see stderr above).`,
670
- );
671
- }
672
-
673
- // Permission seeding outcome line
674
- if (skipPermissions) {
675
- console.log(' Permission seeding skipped (--skip-permissions).');
676
- } else if (result.permissions) {
677
- if (result.permissions.installed > 0) {
678
- console.log(
679
- ` Wrote merged permissions block to .claude/settings.local.json (use --skip-permissions to opt out).`,
680
- );
681
- } else if (result.permissions.kept > 0) {
682
- console.log(
683
- ` MCP allowlist entry was edited by operator; preserved. Use \`npx massu permissions check-drift\` to inspect.`,
684
- );
685
- }
686
- // skipped:1 → silent (already in sync, no operator-visible change)
687
- }
688
-
689
- console.log('');
690
- console.log(' Restart your Claude Code session to use them.');
691
- console.log('');
692
- }