@massu/core 1.10.8 → 1.12.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +1334 -0
- package/dist/adapter.js +6 -4
- package/dist/cli.js +581 -448
- package/dist/detect/adapters/.bundle-shasums.json +4 -7
- package/dist/detect/adapters/rails.js +6 -4
- package/dist/detect/adapters/spring.js +6 -4
- package/dist/detect/adapters/tree-sitter-loader.js +6 -4
- package/dist/hooks/auto-learning-pipeline.js +24 -1
- package/dist/hooks/classify-failure.js +32 -3
- package/dist/hooks/cost-tracker.js +24 -0
- package/dist/hooks/fix-detector.js +97 -1
- package/dist/hooks/incident-pipeline.js +30 -1
- package/dist/hooks/intent-suggester.js +6 -3
- package/dist/hooks/post-edit-context.js +24 -1
- package/dist/hooks/post-tool-use.js +67 -19
- package/dist/hooks/pre-compact.js +28 -3
- package/dist/hooks/pre-delete-check.js +53 -27
- package/dist/hooks/pre-tool-use-gate.js +962 -0
- package/dist/hooks/quality-event.js +24 -0
- package/dist/hooks/rule-enforcement-pipeline.js +24 -1
- package/dist/hooks/security-gate.js +29 -16
- package/dist/hooks/session-end.js +31 -6
- package/dist/hooks/session-start.js +8709 -8459
- package/dist/hooks/user-prompt.js +25 -1
- package/package.json +15 -18
- package/commands/README.md +0 -134
- package/src/adapter.ts +0 -121
- package/src/adr-generator.ts +0 -292
- package/src/analytics.ts +0 -373
- package/src/audit-trail.ts +0 -450
- package/src/backfill-sessions.ts +0 -182
- package/src/changelog-generator.ts +0 -178
- package/src/cli.ts +0 -246
- package/src/cloud-sync.ts +0 -234
- package/src/commands/adapters.ts +0 -824
- package/src/commands/changelog.ts +0 -165
- package/src/commands/config-check-drift.ts +0 -133
- package/src/commands/config-refresh.ts +0 -396
- package/src/commands/config-upgrade.ts +0 -127
- package/src/commands/doctor.ts +0 -485
- package/src/commands/hook-runner.ts +0 -145
- package/src/commands/init.ts +0 -1823
- package/src/commands/install-commands.ts +0 -692
- package/src/commands/install-hooks.ts +0 -27
- package/src/commands/permissions.ts +0 -150
- package/src/commands/refresh-log.ts +0 -37
- package/src/commands/show-template.ts +0 -65
- package/src/commands/template-engine.ts +0 -301
- package/src/commands/watch.ts +0 -430
- package/src/config.ts +0 -765
- package/src/cost-tracker.ts +0 -355
- package/src/db.ts +0 -370
- package/src/dependency-scorer.ts +0 -337
- package/src/detect/adapters/aspnet.ts +0 -4
- package/src/detect/adapters/discover.ts +0 -469
- package/src/detect/adapters/file-sampler.ts +0 -225
- package/src/detect/adapters/go-chi.ts +0 -4
- package/src/detect/adapters/index.ts +0 -51
- package/src/detect/adapters/nextjs-trpc.ts +0 -166
- package/src/detect/adapters/parse-guard.ts +0 -133
- package/src/detect/adapters/phoenix.ts +0 -4
- package/src/detect/adapters/python-django.ts +0 -208
- package/src/detect/adapters/python-fastapi.ts +0 -223
- package/src/detect/adapters/python-flask.ts +0 -235
- package/src/detect/adapters/query-helpers.ts +0 -170
- package/src/detect/adapters/rails.ts +0 -4
- package/src/detect/adapters/runner.ts +0 -284
- package/src/detect/adapters/spring.ts +0 -4
- package/src/detect/adapters/swift-swiftui.ts +0 -171
- package/src/detect/adapters/tree-sitter-loader.ts +0 -517
- package/src/detect/adapters/types.ts +0 -191
- package/src/detect/codebase-introspector.ts +0 -210
- package/src/detect/domain-inferrer.ts +0 -197
- package/src/detect/drift.ts +0 -199
- package/src/detect/framework-detector.ts +0 -307
- package/src/detect/index.ts +0 -209
- package/src/detect/manifest-registry.ts +0 -261
- package/src/detect/migrate.ts +0 -362
- package/src/detect/monorepo-detector.ts +0 -348
- package/src/detect/package-detector.ts +0 -828
- package/src/detect/passthrough.ts +0 -108
- package/src/detect/regex-fallback.ts +0 -449
- package/src/detect/source-dir-detector.ts +0 -297
- package/src/detect/vr-command-map.ts +0 -167
- package/src/docs-map.json +0 -100
- package/src/docs-tools.ts +0 -519
- package/src/domains.ts +0 -182
- package/src/hooks/auto-learning-pipeline.ts +0 -228
- package/src/hooks/classify-failure.ts +0 -259
- package/src/hooks/cost-tracker.ts +0 -66
- package/src/hooks/fix-detector.ts +0 -186
- package/src/hooks/incident-pipeline.ts +0 -190
- package/src/hooks/intent-suggester.ts +0 -131
- package/src/hooks/post-edit-context.ts +0 -91
- package/src/hooks/post-tool-use.ts +0 -413
- package/src/hooks/pre-compact.ts +0 -168
- package/src/hooks/pre-delete-check.ts +0 -243
- package/src/hooks/quality-event.ts +0 -127
- package/src/hooks/rule-enforcement-pipeline.ts +0 -159
- package/src/hooks/security-gate.ts +0 -153
- package/src/hooks/session-end.ts +0 -467
- package/src/hooks/session-start.ts +0 -436
- package/src/hooks/user-prompt.ts +0 -157
- package/src/import-resolver.ts +0 -226
- package/src/knowledge-db.ts +0 -176
- package/src/knowledge-indexer.ts +0 -708
- package/src/knowledge-tools.ts +0 -1414
- package/src/lib/fileLock.ts +0 -203
- package/src/lib/framework-supports.ts +0 -85
- package/src/lib/gitToplevel.ts +0 -22
- package/src/lib/hook-registry.ts +0 -43
- package/src/lib/installLock.ts +0 -66
- package/src/lib/memory-path.ts +0 -49
- package/src/lib/pidLiveness.ts +0 -67
- package/src/lib/settings-local.ts +0 -110
- package/src/lib/sql-table-names.ts +0 -71
- package/src/license.ts +0 -547
- package/src/lsp/auto-detect.ts +0 -98
- package/src/lsp/client.ts +0 -776
- package/src/lsp/enrich.ts +0 -127
- package/src/lsp/types.ts +0 -221
- package/src/memory-db.ts +0 -1614
- package/src/memory-file-ingest.ts +0 -128
- package/src/memory-tools.ts +0 -424
- package/src/middleware-tree.ts +0 -71
- package/src/observability-tools.ts +0 -343
- package/src/observation-extractor.ts +0 -418
- package/src/page-deps.ts +0 -285
- package/src/permissions.ts +0 -422
- package/src/prompt-analyzer.ts +0 -332
- package/src/python/coupling-detector.ts +0 -125
- package/src/python/domain-enforcer.ts +0 -84
- package/src/python/impact-analyzer.ts +0 -96
- package/src/python/import-parser.ts +0 -244
- package/src/python/import-resolver.ts +0 -136
- package/src/python/migration-indexer.ts +0 -116
- package/src/python/migration-parser.ts +0 -332
- package/src/python/model-indexer.ts +0 -71
- package/src/python/model-parser.ts +0 -279
- package/src/python/route-indexer.ts +0 -59
- package/src/python/route-parser.ts +0 -317
- package/src/python-tools.ts +0 -630
- package/src/regression-detector.ts +0 -319
- package/src/rules.ts +0 -57
- package/src/schema-mapper.ts +0 -232
- package/src/security/adapter-origin.ts +0 -130
- package/src/security/adapter-verifier.ts +0 -319
- package/src/security/atomic-write.ts +0 -164
- package/src/security/fetcher.ts +0 -200
- package/src/security/install-tracking.ts +0 -319
- package/src/security/license-pubkey.generated.ts +0 -30
- package/src/security/license-response-verifier.ts +0 -149
- package/src/security/local-fingerprint.ts +0 -225
- package/src/security/manifest-cache.ts +0 -333
- package/src/security/manifest-schema.ts +0 -129
- package/src/security/registry-pubkey.generated.ts +0 -35
- package/src/security/telemetry.ts +0 -320
- package/src/security-scorer.ts +0 -405
- package/src/security-utils.ts +0 -133
- package/src/sentinel-db.ts +0 -580
- package/src/sentinel-scanner.ts +0 -406
- package/src/sentinel-tools.ts +0 -513
- package/src/sentinel-types.ts +0 -140
- package/src/server-dispatch.ts +0 -225
- package/src/server.ts +0 -107
- package/src/session-archiver.ts +0 -111
- package/src/session-state-generator.ts +0 -174
- package/src/team-knowledge.ts +0 -407
- package/src/tool-db-needs.ts +0 -232
- package/src/tools.ts +0 -1206
- package/src/transcript-parser.ts +0 -458
- package/src/trpc-index.ts +0 -215
- package/src/validate-features-runner.ts +0 -107
- package/src/validation-engine.ts +0 -358
- package/src/watch/daemon.ts +0 -385
- package/src/watch/lockfile-detector.ts +0 -65
- package/src/watch/paths.ts +0 -279
- 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
|
-
}
|