@massu/core 0.9.2 → 1.1.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/dist/cli.js +11182 -1559
- package/dist/hooks/auto-learning-pipeline.js +99 -19
- package/dist/hooks/classify-failure.js +99 -19
- package/dist/hooks/cost-tracker.js +97 -11
- package/dist/hooks/fix-detector.js +99 -19
- package/dist/hooks/incident-pipeline.js +97 -11
- package/dist/hooks/post-edit-context.js +97 -11
- package/dist/hooks/post-tool-use.js +101 -20
- package/dist/hooks/pre-compact.js +97 -11
- package/dist/hooks/pre-delete-check.js +97 -11
- package/dist/hooks/quality-event.js +97 -11
- package/dist/hooks/rule-enforcement-pipeline.js +97 -11
- package/dist/hooks/session-end.js +97 -11
- package/dist/hooks/session-start.js +8803 -782
- package/dist/hooks/user-prompt.js +98 -43
- package/package.json +13 -3
- package/reference/hook-execution-order.md +17 -25
- package/src/cli.ts +81 -2
- package/src/commands/config-check-drift.ts +132 -0
- package/src/commands/config-refresh.ts +224 -0
- package/src/commands/config-upgrade.ts +126 -0
- package/src/commands/doctor.ts +1 -29
- package/src/commands/init.ts +756 -216
- package/src/config.ts +168 -12
- package/src/detect/domain-inferrer.ts +142 -0
- package/src/detect/drift.ts +199 -0
- package/src/detect/framework-detector.ts +281 -0
- package/src/detect/index.ts +174 -0
- package/src/detect/migrate.ts +278 -0
- package/src/detect/monorepo-detector.ts +347 -0
- package/src/detect/package-detector.ts +728 -0
- package/src/detect/source-dir-detector.ts +264 -0
- package/src/detect/vr-command-map.ts +167 -0
- package/src/hooks/auto-learning-pipeline.ts +2 -2
- package/src/hooks/classify-failure.ts +2 -2
- package/src/hooks/fix-detector.ts +2 -2
- package/src/hooks/session-start.ts +43 -2
- package/src/hooks/user-prompt.ts +1 -21
- package/src/knowledge-indexer.ts +1 -1
- package/src/license.ts +1 -2
- package/src/memory-db.ts +0 -5
- package/src/memory-file-ingest.ts +6 -13
- package/src/tools.ts +0 -8
- package/templates/multi-runtime/massu.config.yaml +80 -0
- package/templates/python-django/massu.config.yaml +51 -0
- package/templates/python-fastapi/massu.config.yaml +50 -0
- package/templates/rust-actix/massu.config.yaml +38 -0
- package/templates/swift-ios/massu.config.yaml +37 -0
- package/templates/ts-nestjs/massu.config.yaml +43 -0
- package/templates/ts-nextjs/massu.config.yaml +43 -0
- package/README.md +0 -40
- package/src/claude-md-templates.ts +0 -342
- package/src/mcp-bridge-tools.ts +0 -458
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
// Copyright (c) 2026 Massu. All rights reserved.
|
|
2
|
+
// Licensed under BSL 1.1 - see LICENSE file for details.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Source Directory Detector (P1-003)
|
|
6
|
+
* ==================================
|
|
7
|
+
*
|
|
8
|
+
* For each detected language, glob files of that type repo-wide (honoring
|
|
9
|
+
* .gitignore and a hard-coded ignore set), then cluster by common prefix
|
|
10
|
+
* directory and pick the directory with the highest file density.
|
|
11
|
+
*
|
|
12
|
+
* Handles:
|
|
13
|
+
* - Colocated tests (src/**/*.test.ts next to source). Sets `colocated: true`
|
|
14
|
+
* on the language entry when test files are <30% of a dir's file count
|
|
15
|
+
* AND no dedicated tests/__tests__/ dir exists.
|
|
16
|
+
* - Top-level `tests/` or `__tests__/` directories (honored as test_dirs).
|
|
17
|
+
* - Monorepo packages/apps/services/libs/modules subtrees (each treated as
|
|
18
|
+
* a workspace root).
|
|
19
|
+
*
|
|
20
|
+
* Security (CR-3, CR-9):
|
|
21
|
+
* - Glob roots strictly inside projectRoot. Symlinks escaping projectRoot
|
|
22
|
+
* are rejected.
|
|
23
|
+
* - Secret-ish files (.env*, *.pem, *.key, .aws/, .ssh/, credentials.json)
|
|
24
|
+
* are excluded from globs.
|
|
25
|
+
* - Only the filename is ever read — never file contents.
|
|
26
|
+
*
|
|
27
|
+
* Usage:
|
|
28
|
+
* ```ts
|
|
29
|
+
* import { detectSourceDirs } from './detect/source-dir-detector.ts';
|
|
30
|
+
* const map = detectSourceDirs('/repo', ['python', 'typescript']);
|
|
31
|
+
* ```
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { realpathSync } from 'fs';
|
|
35
|
+
import { resolve } from 'path';
|
|
36
|
+
import fg from 'fast-glob';
|
|
37
|
+
import type { SupportedLanguage } from './package-detector.ts';
|
|
38
|
+
|
|
39
|
+
export interface SourceDirInfo {
|
|
40
|
+
/** Source directories (relative to projectRoot, forward-slash). */
|
|
41
|
+
source_dirs: string[];
|
|
42
|
+
/** Test directories (relative to projectRoot, forward-slash). */
|
|
43
|
+
test_dirs: string[];
|
|
44
|
+
/** True when tests live next to source (no dedicated tests/ dir). */
|
|
45
|
+
colocated: boolean;
|
|
46
|
+
/** Number of source files detected for this language. */
|
|
47
|
+
file_count: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type SourceDirMap = Partial<Record<SupportedLanguage, SourceDirInfo>>;
|
|
51
|
+
|
|
52
|
+
const IGNORE_PATTERNS: string[] = [
|
|
53
|
+
'**/node_modules/**',
|
|
54
|
+
'**/.venv/**',
|
|
55
|
+
'**/venv/**',
|
|
56
|
+
'**/__pycache__/**',
|
|
57
|
+
'**/dist/**',
|
|
58
|
+
'**/build/**',
|
|
59
|
+
'**/.build/**',
|
|
60
|
+
'**/target/**',
|
|
61
|
+
'**/.next/**',
|
|
62
|
+
'**/.nuxt/**',
|
|
63
|
+
'**/coverage/**',
|
|
64
|
+
'**/.git/**',
|
|
65
|
+
'**/.massu/**',
|
|
66
|
+
'**/.turbo/**',
|
|
67
|
+
'**/.cache/**',
|
|
68
|
+
'**/.pytest_cache/**',
|
|
69
|
+
'**/.mypy_cache/**',
|
|
70
|
+
'**/DerivedData/**',
|
|
71
|
+
'**/Pods/**',
|
|
72
|
+
// Secret-ish patterns
|
|
73
|
+
'**/.env',
|
|
74
|
+
'**/.env.*',
|
|
75
|
+
'**/*.pem',
|
|
76
|
+
'**/*.key',
|
|
77
|
+
'**/.aws/**',
|
|
78
|
+
'**/.ssh/**',
|
|
79
|
+
'**/credentials.json',
|
|
80
|
+
'**/*.p12',
|
|
81
|
+
'**/*.pfx',
|
|
82
|
+
];
|
|
83
|
+
|
|
84
|
+
const EXTENSIONS: Record<SupportedLanguage, string[]> = {
|
|
85
|
+
python: ['py'],
|
|
86
|
+
typescript: ['ts', 'tsx'],
|
|
87
|
+
javascript: ['js', 'jsx', 'mjs', 'cjs'],
|
|
88
|
+
rust: ['rs'],
|
|
89
|
+
swift: ['swift'],
|
|
90
|
+
go: ['go'],
|
|
91
|
+
java: ['java', 'kt'],
|
|
92
|
+
ruby: ['rb'],
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const TEST_FILE_PATTERNS: Record<SupportedLanguage, RegExp[]> = {
|
|
96
|
+
python: [/_test\.py$/, /test_[^/]*\.py$/],
|
|
97
|
+
typescript: [/\.test\.tsx?$/, /\.spec\.tsx?$/],
|
|
98
|
+
javascript: [/\.test\.[mc]?jsx?$/, /\.spec\.[mc]?jsx?$/],
|
|
99
|
+
rust: [/tests\/.*\.rs$/],
|
|
100
|
+
swift: [/Tests\//],
|
|
101
|
+
go: [/_test\.go$/],
|
|
102
|
+
java: [/Test[^/]*\.(java|kt)$/, /[^/]*Test\.(java|kt)$/],
|
|
103
|
+
ruby: [/_spec\.rb$/, /_test\.rb$/],
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const TEST_DIR_KEYWORDS = ['tests', 'test', '__tests__', 'spec', 'specs'];
|
|
107
|
+
|
|
108
|
+
function extsFor(language: SupportedLanguage): string[] {
|
|
109
|
+
return EXTENSIONS[language] ?? [];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function isTestPath(language: SupportedLanguage, path: string): boolean {
|
|
113
|
+
// Any dedicated test-dir keyword in the path segments
|
|
114
|
+
const segments = path.split('/');
|
|
115
|
+
for (const seg of segments) {
|
|
116
|
+
if (TEST_DIR_KEYWORDS.includes(seg)) return true;
|
|
117
|
+
}
|
|
118
|
+
const patterns = TEST_FILE_PATTERNS[language] ?? [];
|
|
119
|
+
return patterns.some((re) => re.test(path));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Get the top-level directory segment of a relative path (or '.' for root). */
|
|
123
|
+
function topSegment(rel: string): string {
|
|
124
|
+
const parts = rel.split('/');
|
|
125
|
+
return parts.length > 1 ? parts[0] : '.';
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Check that a path (after realpath) is inside the projectRoot.
|
|
130
|
+
* Symlinks escaping the tree are rejected.
|
|
131
|
+
*/
|
|
132
|
+
function isInsideRoot(root: string, candidate: string): boolean {
|
|
133
|
+
try {
|
|
134
|
+
const realRoot = realpathSync(root);
|
|
135
|
+
const realCand = realpathSync(resolve(root, candidate));
|
|
136
|
+
return realCand === realRoot || realCand.startsWith(realRoot + '/');
|
|
137
|
+
} catch {
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Detect source and test directories per language.
|
|
144
|
+
*
|
|
145
|
+
* @param projectRoot absolute path to repo root
|
|
146
|
+
* @param languages list of languages to probe (derived from P1-001 manifests)
|
|
147
|
+
*/
|
|
148
|
+
export function detectSourceDirs(
|
|
149
|
+
projectRoot: string,
|
|
150
|
+
languages: SupportedLanguage[]
|
|
151
|
+
): SourceDirMap {
|
|
152
|
+
const out: SourceDirMap = {};
|
|
153
|
+
for (const lang of languages) {
|
|
154
|
+
const exts = extsFor(lang);
|
|
155
|
+
if (exts.length === 0) continue;
|
|
156
|
+
const patterns = exts.map((e) => `**/*.${e}`);
|
|
157
|
+
let files: string[];
|
|
158
|
+
try {
|
|
159
|
+
files = fg.sync(patterns, {
|
|
160
|
+
cwd: projectRoot,
|
|
161
|
+
dot: false,
|
|
162
|
+
ignore: IGNORE_PATTERNS,
|
|
163
|
+
followSymbolicLinks: false,
|
|
164
|
+
suppressErrors: true,
|
|
165
|
+
});
|
|
166
|
+
} catch {
|
|
167
|
+
files = [];
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Drop any file whose resolved realpath escapes the root (defence in depth).
|
|
171
|
+
files = files.filter((f) => isInsideRoot(projectRoot, f));
|
|
172
|
+
|
|
173
|
+
if (files.length === 0) {
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Split into source vs test files.
|
|
178
|
+
const sourceFiles: string[] = [];
|
|
179
|
+
const testFiles: string[] = [];
|
|
180
|
+
for (const f of files) {
|
|
181
|
+
if (isTestPath(lang, f)) testFiles.push(f);
|
|
182
|
+
else sourceFiles.push(f);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Cluster by top segment.
|
|
186
|
+
const srcCluster = new Map<string, number>();
|
|
187
|
+
for (const f of sourceFiles) {
|
|
188
|
+
const k = topSegment(f);
|
|
189
|
+
srcCluster.set(k, (srcCluster.get(k) ?? 0) + 1);
|
|
190
|
+
}
|
|
191
|
+
const testCluster = new Map<string, number>();
|
|
192
|
+
for (const f of testFiles) {
|
|
193
|
+
const k = topSegment(f);
|
|
194
|
+
testCluster.set(k, (testCluster.get(k) ?? 0) + 1);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const source_dirs: string[] = [];
|
|
198
|
+
const test_dirs: string[] = [];
|
|
199
|
+
|
|
200
|
+
// Any top segment with at least one source file counts.
|
|
201
|
+
// Sort by density desc, then name asc for determinism.
|
|
202
|
+
const srcSorted = [...srcCluster.entries()].sort((a, b) => {
|
|
203
|
+
if (b[1] !== a[1]) return b[1] - a[1];
|
|
204
|
+
return a[0].localeCompare(b[0]);
|
|
205
|
+
});
|
|
206
|
+
for (const [seg] of srcSorted) source_dirs.push(seg);
|
|
207
|
+
|
|
208
|
+
// Test dirs: any top-level dir named in TEST_DIR_KEYWORDS that accrued files,
|
|
209
|
+
// plus any top segment with test files.
|
|
210
|
+
const testSet = new Set<string>();
|
|
211
|
+
for (const [seg] of testCluster.entries()) {
|
|
212
|
+
if (TEST_DIR_KEYWORDS.includes(seg)) testSet.add(seg);
|
|
213
|
+
}
|
|
214
|
+
// Also pick up "tests/" under each source dir — e.g. apps/ai-service/tests/
|
|
215
|
+
// Glob again just for test dirs this language may have.
|
|
216
|
+
let testDirHits: string[] = [];
|
|
217
|
+
try {
|
|
218
|
+
testDirHits = fg.sync(
|
|
219
|
+
TEST_DIR_KEYWORDS.map((k) => `**/${k}/**/*.${exts[0]}`),
|
|
220
|
+
{
|
|
221
|
+
cwd: projectRoot,
|
|
222
|
+
dot: false,
|
|
223
|
+
ignore: IGNORE_PATTERNS,
|
|
224
|
+
followSymbolicLinks: false,
|
|
225
|
+
suppressErrors: true,
|
|
226
|
+
}
|
|
227
|
+
);
|
|
228
|
+
} catch {
|
|
229
|
+
testDirHits = [];
|
|
230
|
+
}
|
|
231
|
+
const testPrefixes = new Set<string>();
|
|
232
|
+
for (const f of testDirHits) {
|
|
233
|
+
// Keep the prefix up to and including the test keyword dir
|
|
234
|
+
const segs = f.split('/');
|
|
235
|
+
for (let i = 0; i < segs.length; i++) {
|
|
236
|
+
if (TEST_DIR_KEYWORDS.includes(segs[i])) {
|
|
237
|
+
testPrefixes.add(segs.slice(0, i + 1).join('/'));
|
|
238
|
+
break;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
for (const p of testPrefixes) testSet.add(p);
|
|
243
|
+
for (const seg of testSet) test_dirs.push(seg);
|
|
244
|
+
test_dirs.sort();
|
|
245
|
+
|
|
246
|
+
// Colocated if test_dirs set is empty AND test files exist AND testFiles/(src+test) < 0.3
|
|
247
|
+
const totalFiles = sourceFiles.length + testFiles.length;
|
|
248
|
+
const testRatio = totalFiles === 0 ? 0 : testFiles.length / totalFiles;
|
|
249
|
+
const hasDedicatedTestDir = test_dirs.length > 0;
|
|
250
|
+
const colocated =
|
|
251
|
+
!hasDedicatedTestDir && testFiles.length > 0 && testRatio < 0.3;
|
|
252
|
+
if (colocated) {
|
|
253
|
+
for (const s of source_dirs) if (!test_dirs.includes(s)) test_dirs.push(s);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
out[lang] = {
|
|
257
|
+
source_dirs,
|
|
258
|
+
test_dirs,
|
|
259
|
+
colocated,
|
|
260
|
+
file_count: files.length,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
return out;
|
|
264
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// Copyright (c) 2026 Massu. All rights reserved.
|
|
2
|
+
// Licensed under BSL 1.1 - see LICENSE file for details.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* VR Command Map (P1-005)
|
|
6
|
+
* =======================
|
|
7
|
+
*
|
|
8
|
+
* Given a language + framework info + workspace directory, produce the
|
|
9
|
+
* verification command set (VR-TEST, VR-TYPE, VR-BUILD, VR-SYNTAX, VR-LINT)
|
|
10
|
+
* that the skill runners should invoke.
|
|
11
|
+
*
|
|
12
|
+
* User overrides from `massu.config.yaml` `verification.<language>.*` take
|
|
13
|
+
* precedence over the built-in mapping.
|
|
14
|
+
*
|
|
15
|
+
* Usage:
|
|
16
|
+
* ```ts
|
|
17
|
+
* import { getVRCommands } from './detect/vr-command-map.ts';
|
|
18
|
+
* const cmds = getVRCommands('python', { test_framework: 'pytest', ... }, 'apps/ai-service');
|
|
19
|
+
* // => { test: 'cd apps/ai-service && python3 -m pytest -q', ... }
|
|
20
|
+
* ```
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import type { SupportedLanguage } from './package-detector.ts';
|
|
24
|
+
import type { FrameworkInfo } from './framework-detector.ts';
|
|
25
|
+
|
|
26
|
+
export interface VRCommandSet {
|
|
27
|
+
/** VR-TEST command. */
|
|
28
|
+
test: string | null;
|
|
29
|
+
/** VR-TYPE command. */
|
|
30
|
+
type: string | null;
|
|
31
|
+
/** VR-BUILD command. */
|
|
32
|
+
build: string | null;
|
|
33
|
+
/** VR-SYNTAX command. */
|
|
34
|
+
syntax: string | null;
|
|
35
|
+
/** VR-LINT command. */
|
|
36
|
+
lint: string | null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Shape read from `config.verification[<language>]`. */
|
|
40
|
+
export interface UserVerificationEntry {
|
|
41
|
+
type?: string;
|
|
42
|
+
test?: string;
|
|
43
|
+
syntax?: string;
|
|
44
|
+
lint?: string;
|
|
45
|
+
build?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function prefix(dir: string, cmd: string): string {
|
|
49
|
+
if (!dir || dir === '.') return cmd;
|
|
50
|
+
return `cd ${dir} && ${cmd}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function defaultsFor(
|
|
54
|
+
language: SupportedLanguage,
|
|
55
|
+
fw: FrameworkInfo,
|
|
56
|
+
dir: string
|
|
57
|
+
): VRCommandSet {
|
|
58
|
+
switch (language) {
|
|
59
|
+
case 'python': {
|
|
60
|
+
const testFw = fw.test_framework ?? 'pytest';
|
|
61
|
+
return {
|
|
62
|
+
test:
|
|
63
|
+
testFw === 'unittest'
|
|
64
|
+
? prefix(dir, 'python3 -m unittest')
|
|
65
|
+
: prefix(dir, 'python3 -m pytest -q'),
|
|
66
|
+
type: prefix(dir, 'python3 -m mypy .'),
|
|
67
|
+
build: null,
|
|
68
|
+
syntax: prefix(dir, 'python3 -m py_compile'),
|
|
69
|
+
lint: prefix(dir, 'python3 -m ruff check .'),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
case 'typescript': {
|
|
73
|
+
const testFw = fw.test_framework ?? 'vitest';
|
|
74
|
+
// Test command uses npm test (respecting package.json script); fallback mapping
|
|
75
|
+
// is fine because npm test routes through whatever runner is wired.
|
|
76
|
+
return {
|
|
77
|
+
test: prefix(dir, 'npm test'),
|
|
78
|
+
type: prefix(dir, 'npx tsc --noEmit'),
|
|
79
|
+
build: prefix(dir, 'npm run build'),
|
|
80
|
+
syntax: null,
|
|
81
|
+
lint: prefix(dir, 'npx eslint .'),
|
|
82
|
+
// testFw currently only affects defaults; npm test is runner-agnostic
|
|
83
|
+
...(testFw === 'mocha'
|
|
84
|
+
? { test: prefix(dir, 'npx mocha') }
|
|
85
|
+
: {}),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
case 'javascript': {
|
|
89
|
+
return {
|
|
90
|
+
test: prefix(dir, 'npm test'),
|
|
91
|
+
type: null,
|
|
92
|
+
build: prefix(dir, 'npm run build'),
|
|
93
|
+
syntax: null,
|
|
94
|
+
lint: prefix(dir, 'npx eslint .'),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
case 'rust': {
|
|
98
|
+
return {
|
|
99
|
+
test: prefix(dir, 'cargo test'),
|
|
100
|
+
type: prefix(dir, 'cargo check'),
|
|
101
|
+
build: prefix(dir, 'cargo build'),
|
|
102
|
+
syntax: null,
|
|
103
|
+
lint: prefix(dir, 'cargo clippy -- -D warnings'),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
case 'swift': {
|
|
107
|
+
return {
|
|
108
|
+
test: prefix(dir, 'swift test'),
|
|
109
|
+
type: prefix(dir, 'swift build'),
|
|
110
|
+
build: prefix(dir, 'xcodebuild build'),
|
|
111
|
+
syntax: null,
|
|
112
|
+
lint: prefix(dir, 'swiftlint'),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
case 'go': {
|
|
116
|
+
return {
|
|
117
|
+
test: prefix(dir, 'go test ./...'),
|
|
118
|
+
type: prefix(dir, 'go vet ./...'),
|
|
119
|
+
build: prefix(dir, 'go build ./...'),
|
|
120
|
+
syntax: null,
|
|
121
|
+
lint: prefix(dir, 'golangci-lint run'),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
case 'java': {
|
|
125
|
+
return {
|
|
126
|
+
test: prefix(dir, 'mvn test'),
|
|
127
|
+
type: prefix(dir, 'mvn compile'),
|
|
128
|
+
build: prefix(dir, 'mvn package'),
|
|
129
|
+
syntax: null,
|
|
130
|
+
lint: null,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
case 'ruby': {
|
|
134
|
+
return {
|
|
135
|
+
test: prefix(dir, 'bundle exec rspec'),
|
|
136
|
+
type: null,
|
|
137
|
+
build: null,
|
|
138
|
+
syntax: prefix(dir, 'ruby -c'),
|
|
139
|
+
lint: prefix(dir, 'bundle exec rubocop'),
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
default:
|
|
143
|
+
return { test: null, type: null, build: null, syntax: null, lint: null };
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Produce the VR command set for a language.
|
|
149
|
+
*
|
|
150
|
+
* User-provided entries (if any) override built-ins key-by-key.
|
|
151
|
+
*/
|
|
152
|
+
export function getVRCommands(
|
|
153
|
+
language: SupportedLanguage,
|
|
154
|
+
framework: FrameworkInfo,
|
|
155
|
+
dir: string,
|
|
156
|
+
userOverrides?: UserVerificationEntry
|
|
157
|
+
): VRCommandSet {
|
|
158
|
+
const built = defaultsFor(language, framework, dir);
|
|
159
|
+
if (!userOverrides) return built;
|
|
160
|
+
return {
|
|
161
|
+
test: userOverrides.test ?? built.test,
|
|
162
|
+
type: userOverrides.type ?? built.type,
|
|
163
|
+
build: userOverrides.build ?? built.build,
|
|
164
|
+
syntax: userOverrides.syntax ?? built.syntax,
|
|
165
|
+
lint: userOverrides.lint ?? built.lint,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
// ============================================================
|
|
18
18
|
|
|
19
19
|
import { execSync } from 'child_process';
|
|
20
|
-
import { existsSync, readFileSync, unlinkSync, readdirSync } from 'fs';
|
|
20
|
+
import { existsSync, readFileSync, unlinkSync, readdirSync, statSync } from 'fs';
|
|
21
21
|
import { tmpdir } from 'os';
|
|
22
22
|
import { join } from 'path';
|
|
23
23
|
import { getProjectRoot, getConfig } from '../config.ts';
|
|
@@ -172,7 +172,7 @@ function cleanup(flagPath: string): void {
|
|
|
172
172
|
for (const file of readdirSync(dir)) {
|
|
173
173
|
const fullPath = join(dir, file);
|
|
174
174
|
try {
|
|
175
|
-
const stat =
|
|
175
|
+
const stat = statSync(fullPath);
|
|
176
176
|
if (now - stat.mtimeMs > 86400000) {
|
|
177
177
|
unlinkSync(fullPath);
|
|
178
178
|
}
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
// Must complete in <1000ms.
|
|
21
21
|
// ============================================================
|
|
22
22
|
|
|
23
|
-
import { existsSync, readFileSync, readdirSync, unlinkSync } from 'fs';
|
|
23
|
+
import { existsSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from 'fs';
|
|
24
24
|
import { tmpdir } from 'os';
|
|
25
25
|
import { join, basename } from 'path';
|
|
26
26
|
import { getProjectRoot, getConfig } from '../config.ts';
|
|
@@ -166,7 +166,7 @@ async function main(): Promise<void> {
|
|
|
166
166
|
|
|
167
167
|
// Mark that we've classified this file
|
|
168
168
|
try {
|
|
169
|
-
|
|
169
|
+
writeFileSync(dedupeMarker, '1');
|
|
170
170
|
} catch { /* ignore */ }
|
|
171
171
|
|
|
172
172
|
// Score against failure taxonomy in database
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
// ============================================================
|
|
16
16
|
|
|
17
17
|
import { execSync } from 'child_process';
|
|
18
|
-
import { existsSync, appendFileSync, mkdirSync } from 'fs';
|
|
18
|
+
import { existsSync, appendFileSync, mkdirSync, readFileSync } from 'fs';
|
|
19
19
|
import { tmpdir } from 'os';
|
|
20
20
|
import { join } from 'path';
|
|
21
21
|
import { getProjectRoot, getConfig } from '../config.ts';
|
|
@@ -158,7 +158,7 @@ async function main(): Promise<void> {
|
|
|
158
158
|
appendFileSync(flagPath, JSON.stringify(signal) + '\n');
|
|
159
159
|
|
|
160
160
|
// Count total fixes this session
|
|
161
|
-
const lines =
|
|
161
|
+
const lines = readFileSync(flagPath, 'utf-8').split('\n').filter(Boolean);
|
|
162
162
|
if (lines.length === 1) {
|
|
163
163
|
// First fix detected — output advisory
|
|
164
164
|
console.log(
|
|
@@ -11,8 +11,11 @@
|
|
|
11
11
|
import { getMemoryDb, getSessionSummaries, getRecentObservations, getFailedAttempts, getCrossTaskProgress, autoDetectTaskId, linkSessionToTask, createSession } from '../memory-db.ts';
|
|
12
12
|
import { getConfig, getResolvedPaths } from '../config.ts';
|
|
13
13
|
import { readFileSync, existsSync } from 'fs';
|
|
14
|
-
import { join } from 'path';
|
|
14
|
+
import { join, resolve } from 'path';
|
|
15
|
+
import { parse as parseYaml } from 'yaml';
|
|
15
16
|
import type Database from 'better-sqlite3';
|
|
17
|
+
import { runDetection } from '../detect/index.ts';
|
|
18
|
+
import { computeFingerprint } from '../detect/drift.ts';
|
|
16
19
|
|
|
17
20
|
interface HookInput {
|
|
18
21
|
session_id: string;
|
|
@@ -52,7 +55,7 @@ async function main(): Promise<void> {
|
|
|
52
55
|
process.stdout.write(
|
|
53
56
|
'=== MASSU AI: Active ===\n' +
|
|
54
57
|
'Session memory, code intelligence, and governance are now active.\n' +
|
|
55
|
-
`
|
|
58
|
+
`11 hooks monitoring this session. Type "${getConfig().toolPrefix ?? 'massu'}_sync" to index your codebase.\n` +
|
|
56
59
|
'=== END MASSU ===\n\n'
|
|
57
60
|
);
|
|
58
61
|
}
|
|
@@ -63,6 +66,12 @@ async function main(): Promise<void> {
|
|
|
63
66
|
if (context.trim()) {
|
|
64
67
|
process.stdout.write(context);
|
|
65
68
|
}
|
|
69
|
+
|
|
70
|
+
// P5-001: drift banner (runs after memory context, independent of it).
|
|
71
|
+
const driftBanner = await buildDriftBanner();
|
|
72
|
+
if (driftBanner) {
|
|
73
|
+
process.stdout.write(driftBanner);
|
|
74
|
+
}
|
|
66
75
|
} finally {
|
|
67
76
|
db.close();
|
|
68
77
|
}
|
|
@@ -244,6 +253,38 @@ function readStdin(): Promise<string> {
|
|
|
244
253
|
});
|
|
245
254
|
}
|
|
246
255
|
|
|
256
|
+
/**
|
|
257
|
+
* P5-001: compare the fingerprint stored in massu.config.yaml (detection.fingerprint,
|
|
258
|
+
* stamped by init/refresh/upgrade) against a freshly-computed fingerprint. If they
|
|
259
|
+
* disagree, return a plain-text banner. Returns '' on any error or when the
|
|
260
|
+
* config has no fingerprint (back-compat with v1 configs).
|
|
261
|
+
*/
|
|
262
|
+
async function buildDriftBanner(): Promise<string> {
|
|
263
|
+
try {
|
|
264
|
+
const configPath = resolve(process.cwd(), 'massu.config.yaml');
|
|
265
|
+
if (!existsSync(configPath)) return '';
|
|
266
|
+
const content = readFileSync(configPath, 'utf-8');
|
|
267
|
+
const parsed = parseYaml(content) as Record<string, unknown> | null;
|
|
268
|
+
if (!parsed || typeof parsed !== 'object') return '';
|
|
269
|
+
const det = parsed.detection as Record<string, unknown> | undefined;
|
|
270
|
+
const storedFp = typeof det?.fingerprint === 'string' ? (det.fingerprint as string) : null;
|
|
271
|
+
if (!storedFp) return '';
|
|
272
|
+
const detection = await runDetection(process.cwd());
|
|
273
|
+
const currentFp = computeFingerprint(detection);
|
|
274
|
+
if (currentFp === storedFp) return '';
|
|
275
|
+
return (
|
|
276
|
+
'=== Massu Config Drift ===\n' +
|
|
277
|
+
'Detected stack has changed since last config refresh.\n' +
|
|
278
|
+
`Fingerprint: ${storedFp.slice(0, 16)} -> ${currentFp.slice(0, 16)}\n` +
|
|
279
|
+
'Run: npx massu config refresh\n' +
|
|
280
|
+
'=== END ===\n'
|
|
281
|
+
);
|
|
282
|
+
} catch (_e) {
|
|
283
|
+
// Never block session start on drift-check failure.
|
|
284
|
+
return '';
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
247
288
|
function safeParseJson(json: string): Record<string, string> | null {
|
|
248
289
|
try {
|
|
249
290
|
return JSON.parse(json);
|
package/src/hooks/user-prompt.ts
CHANGED
|
@@ -8,9 +8,7 @@
|
|
|
8
8
|
// ============================================================
|
|
9
9
|
|
|
10
10
|
import { getMemoryDb, createSession, addUserPrompt, linkSessionToTask, autoDetectTaskId, addObservation } from '../memory-db.ts';
|
|
11
|
-
import { existsSync
|
|
12
|
-
import { tmpdir } from 'os';
|
|
13
|
-
import { join } from 'path';
|
|
11
|
+
import { existsSync } from 'fs';
|
|
14
12
|
import { getResolvedPaths } from '../config.ts';
|
|
15
13
|
|
|
16
14
|
interface HookInput {
|
|
@@ -109,24 +107,6 @@ async function main(): Promise<void> {
|
|
|
109
107
|
} catch (_memoryNagErr) {
|
|
110
108
|
// Best-effort: never block prompt capture
|
|
111
109
|
}
|
|
112
|
-
|
|
113
|
-
// 7. Failure context markers: write detected failure keywords to temp file
|
|
114
|
-
// so classify-failure.ts can use them for scoring
|
|
115
|
-
try {
|
|
116
|
-
const failureKeywords = [
|
|
117
|
-
'bug', 'broken', 'crash', 'error', 'fail', 'fix', 'wrong', 'missing',
|
|
118
|
-
'undefined', 'null', 'exception', 'stack trace', 'regression', 'revert',
|
|
119
|
-
'doesn\'t work', 'not working', 'stopped working', 'broke',
|
|
120
|
-
];
|
|
121
|
-
const promptLower = prompt.toLowerCase();
|
|
122
|
-
const matched = failureKeywords.filter(kw => promptLower.includes(kw));
|
|
123
|
-
if (matched.length > 0) {
|
|
124
|
-
const contextFile = join(tmpdir(), `massu-failure-context-${session_id.slice(0, 8)}-${Date.now()}`);
|
|
125
|
-
writeFileSync(contextFile, matched.join(' '), 'utf-8');
|
|
126
|
-
}
|
|
127
|
-
} catch {
|
|
128
|
-
// Best-effort: never block prompt capture
|
|
129
|
-
}
|
|
130
110
|
} finally {
|
|
131
111
|
db.close();
|
|
132
112
|
}
|
package/src/knowledge-indexer.ts
CHANGED
|
@@ -599,7 +599,7 @@ export function indexAllKnowledge(db: Database.Database): IndexStats {
|
|
|
599
599
|
// Parse plan documents for structured metadata
|
|
600
600
|
if (category === 'plan') {
|
|
601
601
|
// Extract plan items (P1-001, P2-001, etc.)
|
|
602
|
-
const planItemRegex = /^###\s+(P
|
|
602
|
+
const planItemRegex = /^###\s+(P\d+-\d+):\s+(.+)$/gm;
|
|
603
603
|
let planMatch;
|
|
604
604
|
while ((planMatch = planItemRegex.exec(content)) !== null) {
|
|
605
605
|
insertChunk.run(docId, 'pattern', planMatch[1], `${planMatch[1]}: ${planMatch[2]}`, null, null, JSON.stringify({ plan_item_id: planMatch[1] }));
|
package/src/license.ts
CHANGED
|
@@ -54,7 +54,7 @@ export function tierLevel(tier: ToolTier): number {
|
|
|
54
54
|
* Enterprise: audit, security, dependency
|
|
55
55
|
*/
|
|
56
56
|
export const TOOL_TIER_MAP: Record<string, ToolTier> = {
|
|
57
|
-
// --- Free tier (
|
|
57
|
+
// --- Free tier (12 tools: core navigation + basic memory + regression + license) ---
|
|
58
58
|
sync: 'free',
|
|
59
59
|
context: 'free',
|
|
60
60
|
impact: 'free',
|
|
@@ -64,7 +64,6 @@ export const TOOL_TIER_MAP: Record<string, ToolTier> = {
|
|
|
64
64
|
coupling_check: 'free',
|
|
65
65
|
memory_search: 'free',
|
|
66
66
|
memory_ingest: 'free',
|
|
67
|
-
memory_backfill: 'free',
|
|
68
67
|
regression_risk: 'free',
|
|
69
68
|
feature_health: 'free',
|
|
70
69
|
license_status: 'free',
|
package/src/memory-db.ts
CHANGED
|
@@ -1509,11 +1509,6 @@ export interface FailureClassMatch {
|
|
|
1509
1509
|
/**
|
|
1510
1510
|
* Score all failure classes against provided match text, file path, and prompt context.
|
|
1511
1511
|
* Returns the best match with its score.
|
|
1512
|
-
*
|
|
1513
|
-
* Scoring:
|
|
1514
|
-
* +diffPatternWeight (default 3) per diff_pattern match
|
|
1515
|
-
* +filePatternWeight (default 2) per file_pattern match
|
|
1516
|
-
* +promptKeywordWeight (default 2) per prompt_keyword match
|
|
1517
1512
|
*/
|
|
1518
1513
|
export function scoreFailureClasses(
|
|
1519
1514
|
db: Database.Database,
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import type Database from 'better-sqlite3';
|
|
12
12
|
import { readFileSync, existsSync, readdirSync } from 'fs';
|
|
13
13
|
import { join } from 'path';
|
|
14
|
+
import { parse as parseYaml } from 'yaml';
|
|
14
15
|
import { addObservation } from './memory-db.ts';
|
|
15
16
|
|
|
16
17
|
export type IngestResult = 'inserted' | 'updated' | 'skipped';
|
|
@@ -41,21 +42,13 @@ export function ingestMemoryFile(
|
|
|
41
42
|
|
|
42
43
|
if (frontmatterMatch) {
|
|
43
44
|
try {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
const sep = line.indexOf(':');
|
|
49
|
-
if (sep > 0) {
|
|
50
|
-
fm[line.slice(0, sep).trim()] = line.slice(sep + 1).trim();
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
name = fm.name ?? basename;
|
|
54
|
-
description = fm.description ?? '';
|
|
55
|
-
type = fm.type ?? 'discovery';
|
|
45
|
+
const fm = parseYaml(frontmatterMatch[1]) as Record<string, unknown>;
|
|
46
|
+
name = (fm.name as string) ?? basename;
|
|
47
|
+
description = (fm.description as string) ?? '';
|
|
48
|
+
type = (fm.type as string) ?? 'discovery';
|
|
56
49
|
confidence = fm.confidence != null ? Number(fm.confidence) : undefined;
|
|
57
50
|
} catch {
|
|
58
|
-
// Use defaults if
|
|
51
|
+
// Use defaults if YAML parsing fails
|
|
59
52
|
}
|
|
60
53
|
}
|
|
61
54
|
|