@massu/core 0.1.2 → 0.4.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.
Files changed (84) hide show
  1. package/commands/_shared-preamble.md +76 -0
  2. package/commands/massu-audit-deps.md +211 -0
  3. package/commands/massu-changelog.md +174 -0
  4. package/commands/massu-cleanup.md +315 -0
  5. package/commands/massu-commit.md +481 -0
  6. package/commands/massu-create-plan.md +752 -0
  7. package/commands/massu-dead-code.md +131 -0
  8. package/commands/massu-debug.md +484 -0
  9. package/commands/massu-deploy.md +91 -0
  10. package/commands/massu-deps.md +374 -0
  11. package/commands/massu-doc-gen.md +279 -0
  12. package/commands/massu-docs.md +364 -0
  13. package/commands/massu-estimate.md +313 -0
  14. package/commands/massu-golden-path.md +973 -0
  15. package/commands/massu-guide.md +167 -0
  16. package/commands/massu-hotfix.md +480 -0
  17. package/commands/massu-loop-playwright.md +837 -0
  18. package/commands/massu-loop.md +775 -0
  19. package/commands/massu-new-feature.md +511 -0
  20. package/commands/massu-parity.md +214 -0
  21. package/commands/massu-plan.md +456 -0
  22. package/commands/massu-push-light.md +207 -0
  23. package/commands/massu-push.md +434 -0
  24. package/commands/massu-refactor.md +410 -0
  25. package/commands/massu-release.md +363 -0
  26. package/commands/massu-review.md +238 -0
  27. package/commands/massu-simplify.md +281 -0
  28. package/commands/massu-status.md +278 -0
  29. package/commands/massu-tdd.md +201 -0
  30. package/commands/massu-test.md +516 -0
  31. package/commands/massu-verify-playwright.md +281 -0
  32. package/commands/massu-verify.md +667 -0
  33. package/dist/cli.js +12521 -0
  34. package/dist/hooks/cost-tracker.js +80 -5
  35. package/dist/hooks/post-edit-context.js +72 -6
  36. package/dist/hooks/post-tool-use.js +234 -57
  37. package/dist/hooks/pre-compact.js +144 -5
  38. package/dist/hooks/pre-delete-check.js +141 -11
  39. package/dist/hooks/quality-event.js +80 -5
  40. package/dist/hooks/security-gate.js +29 -0
  41. package/dist/hooks/session-end.js +83 -8
  42. package/dist/hooks/session-start.js +153 -7
  43. package/dist/hooks/user-prompt.js +166 -5
  44. package/package.json +6 -5
  45. package/src/backfill-sessions.ts +5 -4
  46. package/src/cli.ts +6 -1
  47. package/src/commands/doctor.ts +193 -6
  48. package/src/commands/init.ts +235 -6
  49. package/src/commands/install-commands.ts +137 -0
  50. package/src/config.ts +68 -2
  51. package/src/db.ts +115 -2
  52. package/src/docs-tools.ts +8 -6
  53. package/src/hooks/post-edit-context.ts +1 -1
  54. package/src/hooks/post-tool-use.ts +130 -0
  55. package/src/hooks/pre-compact.ts +23 -1
  56. package/src/hooks/pre-delete-check.ts +92 -4
  57. package/src/hooks/security-gate.ts +32 -0
  58. package/src/hooks/session-start.ts +97 -4
  59. package/src/hooks/user-prompt.ts +46 -1
  60. package/src/import-resolver.ts +2 -1
  61. package/src/knowledge-db.ts +169 -0
  62. package/src/knowledge-indexer.ts +704 -0
  63. package/src/knowledge-tools.ts +1413 -0
  64. package/src/license.ts +482 -0
  65. package/src/memory-db.ts +14 -1
  66. package/src/observation-extractor.ts +11 -4
  67. package/src/page-deps.ts +3 -2
  68. package/src/python/coupling-detector.ts +124 -0
  69. package/src/python/domain-enforcer.ts +83 -0
  70. package/src/python/impact-analyzer.ts +95 -0
  71. package/src/python/import-parser.ts +244 -0
  72. package/src/python/import-resolver.ts +135 -0
  73. package/src/python/migration-indexer.ts +115 -0
  74. package/src/python/migration-parser.ts +332 -0
  75. package/src/python/model-indexer.ts +70 -0
  76. package/src/python/model-parser.ts +279 -0
  77. package/src/python/route-indexer.ts +58 -0
  78. package/src/python/route-parser.ts +317 -0
  79. package/src/python-tools.ts +629 -0
  80. package/src/sentinel-db.ts +2 -1
  81. package/src/server.ts +29 -6
  82. package/src/session-archiver.ts +4 -5
  83. package/src/tools.ts +283 -31
  84. package/README.md +0 -40
@@ -0,0 +1,137 @@
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 into a project.
6
+ *
7
+ * Copies all massu command .md files from the package's commands/ directory
8
+ * into the project's .claude/commands/ directory. Existing massu commands
9
+ * are updated; non-massu commands are preserved.
10
+ */
11
+
12
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from 'fs';
13
+ import { resolve, dirname } from 'path';
14
+ import { fileURLToPath } from 'url';
15
+ import { getConfig } from '../config.ts';
16
+
17
+ const __filename = fileURLToPath(import.meta.url);
18
+ const __dirname = dirname(__filename);
19
+
20
+ // ============================================================
21
+ // Command Installation
22
+ // ============================================================
23
+
24
+ /**
25
+ * Resolve the path to the bundled commands directory.
26
+ * Handles both npm-installed and local development scenarios.
27
+ */
28
+ export function resolveCommandsDir(): string | null {
29
+ const cwd = process.cwd();
30
+
31
+ // 1. npm-installed: node_modules/@massu/core/commands
32
+ const nodeModulesPath = resolve(cwd, 'node_modules/@massu/core/commands');
33
+ if (existsSync(nodeModulesPath)) {
34
+ return nodeModulesPath;
35
+ }
36
+
37
+ // 2. Relative to compiled dist/cli.js → ../commands
38
+ const distRelPath = resolve(__dirname, '../commands');
39
+ if (existsSync(distRelPath)) {
40
+ return distRelPath;
41
+ }
42
+
43
+ // 3. Relative to source src/commands/ → ../../commands
44
+ const srcRelPath = resolve(__dirname, '../../commands');
45
+ if (existsSync(srcRelPath)) {
46
+ return srcRelPath;
47
+ }
48
+
49
+ return null;
50
+ }
51
+
52
+ export interface InstallCommandsResult {
53
+ installed: number;
54
+ updated: number;
55
+ skipped: number;
56
+ commandsDir: string;
57
+ }
58
+
59
+ export function installCommands(projectRoot: string): InstallCommandsResult {
60
+ const claudeDirName = getConfig().conventions?.claudeDirName ?? '.claude';
61
+ const targetDir = resolve(projectRoot, claudeDirName, 'commands');
62
+
63
+ // Ensure .claude/commands directory exists
64
+ if (!existsSync(targetDir)) {
65
+ mkdirSync(targetDir, { recursive: true });
66
+ }
67
+
68
+ // Find source commands
69
+ const sourceDir = resolveCommandsDir();
70
+ if (!sourceDir) {
71
+ console.error(' ERROR: Could not find massu commands directory.');
72
+ console.error(' Try reinstalling: npm install @massu/core');
73
+ return { installed: 0, updated: 0, skipped: 0, commandsDir: targetDir };
74
+ }
75
+
76
+ // Read all command files from source
77
+ const sourceFiles = readdirSync(sourceDir).filter(f => f.endsWith('.md'));
78
+
79
+ let installed = 0;
80
+ let updated = 0;
81
+ let skipped = 0;
82
+
83
+ for (const file of sourceFiles) {
84
+ const sourcePath = resolve(sourceDir, file);
85
+ const targetPath = resolve(targetDir, file);
86
+ const sourceContent = readFileSync(sourcePath, 'utf-8');
87
+
88
+ if (existsSync(targetPath)) {
89
+ const existingContent = readFileSync(targetPath, 'utf-8');
90
+ if (existingContent === sourceContent) {
91
+ skipped++;
92
+ continue;
93
+ }
94
+ // Update existing command
95
+ writeFileSync(targetPath, sourceContent, 'utf-8');
96
+ updated++;
97
+ } else {
98
+ // Install new command
99
+ writeFileSync(targetPath, sourceContent, 'utf-8');
100
+ installed++;
101
+ }
102
+ }
103
+
104
+ return { installed, updated, skipped, commandsDir: targetDir };
105
+ }
106
+
107
+ // ============================================================
108
+ // Standalone CLI Runner
109
+ // ============================================================
110
+
111
+ export async function runInstallCommands(): Promise<void> {
112
+ const projectRoot = process.cwd();
113
+
114
+ console.log('');
115
+ console.log('Massu AI - Install Slash Commands');
116
+ console.log('==================================');
117
+ console.log('');
118
+
119
+ const result = installCommands(projectRoot);
120
+
121
+ if (result.installed > 0) {
122
+ console.log(` Installed ${result.installed} new commands`);
123
+ }
124
+ if (result.updated > 0) {
125
+ console.log(` Updated ${result.updated} existing commands`);
126
+ }
127
+ if (result.skipped > 0) {
128
+ console.log(` ${result.skipped} commands already up to date`);
129
+ }
130
+
131
+ const total = result.installed + result.updated + result.skipped;
132
+ console.log('');
133
+ console.log(` ${total} slash commands available in ${result.commandsDir}`);
134
+ console.log('');
135
+ console.log(' Restart your Claude Code session to use them.');
136
+ console.log('');
137
+ }
package/src/config.ts CHANGED
@@ -3,6 +3,7 @@
3
3
 
4
4
  import { resolve, dirname } from 'path';
5
5
  import { existsSync, readFileSync } from 'fs';
6
+ import { homedir } from 'os';
6
7
  import { parse as parseYaml } from 'yaml';
7
8
  import { z } from 'zod';
8
9
 
@@ -159,6 +160,45 @@ const CloudConfigSchema = z.object({
159
160
  }).optional();
160
161
  export type CloudConfig = z.infer<typeof CloudConfigSchema>;
161
162
 
163
+ // --- Conventions Config ---
164
+ const ConventionsConfigSchema = z.object({
165
+ claudeDirName: z.string().default('.claude').refine(
166
+ s => !s.includes('..') && !s.startsWith('/'),
167
+ { message: 'claudeDirName must not contain ".." or start with "/"' }
168
+ ),
169
+ sessionStatePath: z.string().default('.claude/session-state/CURRENT.md').refine(
170
+ s => !s.includes('..') && !s.startsWith('/'),
171
+ { message: 'sessionStatePath must not contain ".." or start with "/"' }
172
+ ),
173
+ sessionArchivePath: z.string().default('.claude/session-state/archive').refine(
174
+ s => !s.includes('..') && !s.startsWith('/'),
175
+ { message: 'sessionArchivePath must not contain ".." or start with "/"' }
176
+ ),
177
+ knowledgeCategories: z.array(z.string()).default([
178
+ 'patterns', 'commands', 'incidents', 'reference', 'protocols',
179
+ 'checklists', 'playbooks', 'critical', 'scripts', 'status',
180
+ 'templates', 'loop-state', 'session-state', 'agents',
181
+ ]),
182
+ knowledgeSourceFiles: z.array(z.string()).default(['CLAUDE.md', 'MEMORY.md', 'corrections.md']),
183
+ excludePatterns: z.array(z.string()).default(['/ARCHIVE/', '/SESSION-HISTORY/']),
184
+ }).optional();
185
+ export type ConventionsConfig = z.infer<typeof ConventionsConfigSchema>;
186
+
187
+ // --- Python Config ---
188
+ const PythonDomainConfigSchema = z.object({
189
+ name: z.string(),
190
+ packages: z.array(z.string()),
191
+ allowed_imports_from: z.array(z.string()).default([]),
192
+ });
193
+
194
+ const PythonConfigSchema = z.object({
195
+ root: z.string(),
196
+ alembic_dir: z.string().optional(),
197
+ domains: z.array(PythonDomainConfigSchema).default([]),
198
+ exclude_dirs: z.array(z.string()).default(['__pycache__', '.venv', 'venv', '.mypy_cache', '.pytest_cache']),
199
+ }).optional();
200
+ export type PythonConfig = z.infer<typeof PythonConfigSchema>;
201
+
162
202
  // --- Paths Config ---
163
203
  const PathsConfigSchema = z.object({
164
204
  source: z.string().default('src'),
@@ -198,6 +238,8 @@ const RawConfigSchema = z.object({
198
238
  team: TeamConfigSchema,
199
239
  regression: RegressionConfigSchema,
200
240
  cloud: CloudConfigSchema,
241
+ conventions: ConventionsConfigSchema,
242
+ python: PythonConfigSchema,
201
243
  }).passthrough();
202
244
 
203
245
  // --- Final Config interface (derived from Zod) ---
@@ -217,6 +259,8 @@ export interface Config {
217
259
  team?: TeamConfig;
218
260
  regression?: RegressionConfig;
219
261
  cloud?: CloudConfig;
262
+ conventions?: ConventionsConfig;
263
+ python?: PythonConfig;
220
264
  }
221
265
 
222
266
  let _config: Config | null = null;
@@ -312,8 +356,20 @@ export function getConfig(): Config {
312
356
  team: parsed.team,
313
357
  regression: parsed.regression,
314
358
  cloud: parsed.cloud,
359
+ conventions: parsed.conventions,
360
+ python: parsed.python,
315
361
  };
316
362
 
363
+ // Allow environment variable override for API key (security best practice)
364
+ if (!_config.cloud?.apiKey && process.env.MASSU_API_KEY) {
365
+ _config.cloud = {
366
+ enabled: true,
367
+ sync: { memory: true, analytics: true, audit: true },
368
+ ..._config.cloud,
369
+ apiKey: process.env.MASSU_API_KEY,
370
+ };
371
+ }
372
+
317
373
  return _config;
318
374
  }
319
375
 
@@ -324,6 +380,7 @@ export function getConfig(): Config {
324
380
  export function getResolvedPaths() {
325
381
  const config = getConfig();
326
382
  const root = getProjectRoot();
383
+ const claudeDirName = config.conventions?.claudeDirName ?? '.claude';
327
384
 
328
385
  return {
329
386
  codegraphDbPath: resolve(root, '.codegraph/codegraph.db'),
@@ -340,11 +397,20 @@ export function getResolvedPaths() {
340
397
  ) as Record<string, string>,
341
398
  extensions: ['.ts', '.tsx', '.js', '.jsx'] as const,
342
399
  indexFiles: ['index.ts', 'index.tsx', 'index.js', 'index.jsx'] as const,
343
- patternsDir: resolve(root, '.claude/patterns'),
344
- claudeMdPath: resolve(root, '.claude/CLAUDE.md'),
400
+ patternsDir: resolve(root, claudeDirName, 'patterns'),
401
+ claudeMdPath: resolve(root, claudeDirName, 'CLAUDE.md'),
345
402
  docsMapPath: resolve(root, '.massu/docs-map.json'),
346
403
  helpSitePath: resolve(root, '../' + config.project.name + '-help'),
347
404
  memoryDbPath: resolve(root, '.massu/memory.db'),
405
+ knowledgeDbPath: resolve(root, '.massu/knowledge.db'),
406
+ plansDir: resolve(root, 'docs/plans'),
407
+ docsDir: resolve(root, 'docs'),
408
+ claudeDir: resolve(root, claudeDirName),
409
+ memoryDir: resolve(homedir(), claudeDirName, 'projects', root.replace(/\//g, '-'), 'memory'),
410
+ sessionStatePath: resolve(root, config.conventions?.sessionStatePath ?? `${claudeDirName}/session-state/CURRENT.md`),
411
+ sessionArchivePath: resolve(root, config.conventions?.sessionArchivePath ?? `${claudeDirName}/session-state/archive`),
412
+ mcpJsonPath: resolve(root, '.mcp.json'),
413
+ settingsLocalPath: resolve(root, claudeDirName, 'settings.local.json'),
348
414
  };
349
415
  }
350
416
 
package/src/db.ts CHANGED
@@ -2,8 +2,8 @@
2
2
  // Licensed under BSL 1.1 - see LICENSE file for details.
3
3
 
4
4
  import Database from 'better-sqlite3';
5
- import { dirname } from 'path';
6
- import { existsSync, mkdirSync } from 'fs';
5
+ import { dirname, join } from 'path';
6
+ import { existsSync, mkdirSync, readdirSync, statSync } from 'fs';
7
7
  import { getResolvedPaths } from './config.ts';
8
8
 
9
9
  /**
@@ -96,6 +96,84 @@ function initDataSchema(db: Database.Database): void {
96
96
  value TEXT NOT NULL
97
97
  );
98
98
 
99
+ -- ============================================================
100
+ -- Python Code Intelligence Tables
101
+ -- ============================================================
102
+
103
+ CREATE TABLE IF NOT EXISTS massu_py_imports (
104
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
105
+ source_file TEXT NOT NULL,
106
+ target_file TEXT NOT NULL,
107
+ import_type TEXT NOT NULL CHECK(import_type IN ('absolute', 'relative', 'from_absolute', 'from_relative')),
108
+ imported_names TEXT NOT NULL DEFAULT '[]',
109
+ line INTEGER NOT NULL DEFAULT 0
110
+ );
111
+ CREATE INDEX IF NOT EXISTS idx_massu_py_imports_source ON massu_py_imports(source_file);
112
+ CREATE INDEX IF NOT EXISTS idx_massu_py_imports_target ON massu_py_imports(target_file);
113
+
114
+ CREATE TABLE IF NOT EXISTS massu_py_meta (
115
+ key TEXT PRIMARY KEY,
116
+ value TEXT NOT NULL
117
+ );
118
+
119
+ CREATE TABLE IF NOT EXISTS massu_py_routes (
120
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
121
+ file TEXT NOT NULL,
122
+ method TEXT NOT NULL,
123
+ path TEXT NOT NULL,
124
+ function_name TEXT NOT NULL,
125
+ dependencies TEXT NOT NULL DEFAULT '[]',
126
+ request_model TEXT,
127
+ response_model TEXT,
128
+ is_authenticated INTEGER NOT NULL DEFAULT 0,
129
+ line INTEGER NOT NULL DEFAULT 0
130
+ );
131
+ CREATE INDEX IF NOT EXISTS idx_massu_py_routes_path ON massu_py_routes(path);
132
+ CREATE INDEX IF NOT EXISTS idx_massu_py_routes_file ON massu_py_routes(file);
133
+
134
+ CREATE TABLE IF NOT EXISTS massu_py_route_callers (
135
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
136
+ route_id INTEGER NOT NULL REFERENCES massu_py_routes(id) ON DELETE CASCADE,
137
+ frontend_file TEXT NOT NULL,
138
+ line INTEGER NOT NULL DEFAULT 0,
139
+ call_pattern TEXT NOT NULL
140
+ );
141
+ CREATE INDEX IF NOT EXISTS idx_massu_py_route_callers_route ON massu_py_route_callers(route_id);
142
+
143
+ CREATE TABLE IF NOT EXISTS massu_py_models (
144
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
145
+ class_name TEXT NOT NULL,
146
+ table_name TEXT,
147
+ file TEXT NOT NULL,
148
+ line INTEGER NOT NULL DEFAULT 0,
149
+ columns TEXT NOT NULL DEFAULT '[]',
150
+ relationships TEXT NOT NULL DEFAULT '[]',
151
+ foreign_keys TEXT NOT NULL DEFAULT '[]'
152
+ );
153
+ CREATE INDEX IF NOT EXISTS idx_massu_py_models_file ON massu_py_models(file);
154
+ CREATE INDEX IF NOT EXISTS idx_massu_py_models_table ON massu_py_models(table_name);
155
+
156
+ CREATE TABLE IF NOT EXISTS massu_py_fk_edges (
157
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
158
+ source_table TEXT NOT NULL,
159
+ source_column TEXT NOT NULL,
160
+ target_table TEXT NOT NULL,
161
+ target_column TEXT NOT NULL
162
+ );
163
+ CREATE INDEX IF NOT EXISTS idx_massu_py_fk_source ON massu_py_fk_edges(source_table);
164
+ CREATE INDEX IF NOT EXISTS idx_massu_py_fk_target ON massu_py_fk_edges(target_table);
165
+
166
+ CREATE TABLE IF NOT EXISTS massu_py_migrations (
167
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
168
+ revision TEXT NOT NULL UNIQUE,
169
+ down_revision TEXT,
170
+ file TEXT NOT NULL,
171
+ description TEXT,
172
+ operations TEXT NOT NULL DEFAULT '[]',
173
+ is_head INTEGER NOT NULL DEFAULT 0
174
+ );
175
+ CREATE INDEX IF NOT EXISTS idx_massu_py_migrations_rev ON massu_py_migrations(revision);
176
+
99
177
  -- ============================================================
100
178
  -- Sentinel: Feature Registry Tables
101
179
  -- ============================================================
@@ -231,3 +309,38 @@ export function isDataStale(dataDb: Database.Database, codegraphDb: Database.Dat
231
309
  export function updateBuildTimestamp(dataDb: Database.Database): void {
232
310
  dataDb.prepare("INSERT OR REPLACE INTO massu_meta (key, value) VALUES ('last_build_time', ?)").run(new Date().toISOString());
233
311
  }
312
+
313
+ /**
314
+ * Check if Python indexes are stale based on massu_py_meta.last_build_time.
315
+ */
316
+ export function isPythonDataStale(dataDb: Database.Database, pythonRoot: string): boolean {
317
+ const lastBuild = dataDb.prepare("SELECT value FROM massu_py_meta WHERE key = 'last_build_time'").get() as { value: string } | undefined;
318
+ if (!lastBuild) return true;
319
+
320
+ const lastBuildTime = new Date(lastBuild.value).getTime();
321
+ // Check if any .py file is newer than last build
322
+ function checkDir(dir: string): boolean {
323
+ try {
324
+ const entries = readdirSync(dir, { withFileTypes: true });
325
+ for (const entry of entries) {
326
+ const fullPath = join(dir, entry.name);
327
+ if (entry.isDirectory()) {
328
+ if (['__pycache__', '.venv', 'venv', 'node_modules', '.mypy_cache', '.pytest_cache'].includes(entry.name)) continue;
329
+ if (checkDir(fullPath)) return true;
330
+ } else if (entry.name.endsWith('.py')) {
331
+ if (statSync(fullPath).mtimeMs > lastBuildTime) return true;
332
+ }
333
+ }
334
+ } catch { /* directory may not exist */ }
335
+ return false;
336
+ }
337
+
338
+ return checkDir(pythonRoot);
339
+ }
340
+
341
+ /**
342
+ * Update the Python build timestamp in massu_py_meta.
343
+ */
344
+ export function updatePythonBuildTimestamp(dataDb: Database.Database): void {
345
+ dataDb.prepare("INSERT OR REPLACE INTO massu_py_meta (key, value) VALUES ('last_build_time', ?)").run(new Date().toISOString());
346
+ }
package/src/docs-tools.ts CHANGED
@@ -3,7 +3,8 @@
3
3
 
4
4
  import { readFileSync, existsSync } from 'fs';
5
5
  import { resolve, basename } from 'path';
6
- import { getConfig, getResolvedPaths } from './config.ts';
6
+ import { getConfig, getResolvedPaths, getProjectRoot } from './config.ts';
7
+ import { ensureWithinRoot } from './security-utils.ts';
7
8
  import type { ToolDefinition, ToolResult } from './tools.ts';
8
9
 
9
10
  /** Prefix a base tool name with the configured tool prefix. */
@@ -252,10 +253,11 @@ function extractFrontmatter(content: string): Record<string, string> | null {
252
253
  * Extract procedure names from a router file.
253
254
  */
254
255
  function extractProcedureNames(routerPath: string): string[] {
255
- const absPath = resolve(getResolvedPaths().srcDir, '..', routerPath);
256
+ const root = getProjectRoot();
257
+ const absPath = ensureWithinRoot(resolve(getResolvedPaths().srcDir, '..', routerPath), root);
256
258
  if (!existsSync(absPath)) {
257
259
  // Try from project root
258
- const altPath = resolve(getResolvedPaths().srcDir, '../server/api/routers', basename(routerPath));
260
+ const altPath = ensureWithinRoot(resolve(getResolvedPaths().srcDir, '../server/api/routers', basename(routerPath)), root);
259
261
  if (!existsSync(altPath)) return [];
260
262
  return extractProcedureNamesFromContent(readFileSync(altPath, 'utf-8'));
261
263
  }
@@ -325,7 +327,7 @@ function handleDocsAudit(args: Record<string, unknown>): ToolResult {
325
327
  const mapping = docsMap.mappings.find(m => m.id === mappingId);
326
328
  if (!mapping) continue;
327
329
 
328
- const helpPagePath = resolve(getResolvedPaths().helpSitePath, mapping.helpPage);
330
+ const helpPagePath = ensureWithinRoot(resolve(getResolvedPaths().helpSitePath, mapping.helpPage), getProjectRoot());
329
331
 
330
332
  if (!existsSync(helpPagePath)) {
331
333
  results.push({
@@ -394,7 +396,7 @@ function handleDocsAudit(args: Record<string, unknown>): ToolResult {
394
396
  // Also flag inherited user guides
395
397
  for (const [guideName, parentId] of Object.entries(docsMap.userGuideInheritance.examples)) {
396
398
  if (parentId === mappingId) {
397
- const guidePath = resolve(getResolvedPaths().helpSitePath, `pages/user-guides/${guideName}/index.mdx`);
399
+ const guidePath = ensureWithinRoot(resolve(getResolvedPaths().helpSitePath, `pages/user-guides/${guideName}/index.mdx`), getProjectRoot());
398
400
  if (existsSync(guidePath)) {
399
401
  const guideContent = readFileSync(guidePath, 'utf-8');
400
402
  const guideFrontmatter = extractFrontmatter(guideContent);
@@ -439,7 +441,7 @@ function handleDocsCoverage(args: Record<string, unknown>): ToolResult {
439
441
  : docsMap.mappings;
440
442
 
441
443
  for (const mapping of mappings) {
442
- const helpPagePath = resolve(getResolvedPaths().helpSitePath, mapping.helpPage);
444
+ const helpPagePath = ensureWithinRoot(resolve(getResolvedPaths().helpSitePath, mapping.helpPage), getProjectRoot());
443
445
  const exists = existsSync(helpPagePath);
444
446
  let hasContent = false;
445
447
  let lineCount = 0;
@@ -37,7 +37,7 @@ async function main(): Promise<void> {
37
37
  const rel = filePath.startsWith(root + '/') ? filePath.slice(root.length + 1) : filePath;
38
38
 
39
39
  // Only process src/ files
40
- if (!rel.startsWith('src/')) {
40
+ if (!rel.startsWith('src/') && !rel.endsWith('.py')) {
41
41
  process.exit(0);
42
42
  return;
43
43
  }
@@ -14,6 +14,9 @@ import { logAuditEntry } from '../audit-trail.ts';
14
14
  import { trackModification } from '../regression-detector.ts';
15
15
  import { validateFile, storeValidationResult } from '../validation-engine.ts';
16
16
  import { scoreFileSecurity, storeSecurityScore } from '../security-scorer.ts';
17
+ import { readFileSync, existsSync } from 'fs';
18
+ import { join } from 'path';
19
+ import { parse as parseYaml } from 'yaml';
17
20
 
18
21
  interface HookInput {
19
22
  session_id: string;
@@ -126,6 +129,41 @@ async function main(): Promise<void> {
126
129
  } catch (_securityErr) {
127
130
  // Best-effort: never block post-tool-use
128
131
  }
132
+
133
+ // MEMORY.md integrity check on write
134
+ try {
135
+ if (tool_name === 'Edit' || tool_name === 'Write') {
136
+ const filePath = (tool_input.file_path as string) ?? '';
137
+ if (filePath && filePath.endsWith('MEMORY.md') && filePath.includes('/memory/')) {
138
+ const issues = checkMemoryFileIntegrity(filePath);
139
+ if (issues.length > 0) {
140
+ addObservation(db, session_id, 'incident_near_miss',
141
+ 'MEMORY.md integrity issue detected',
142
+ issues.join('; '),
143
+ { importance: 4 }
144
+ );
145
+ }
146
+ }
147
+ }
148
+ } catch (_memoryErr) {
149
+ // Best-effort: never block post-tool-use
150
+ }
151
+
152
+ // Knowledge index staleness check on knowledge file edits
153
+ try {
154
+ if (tool_name === 'Edit' || tool_name === 'Write') {
155
+ const filePath = (tool_input.file_path as string) ?? '';
156
+ if (filePath && isKnowledgeSourceFile(filePath)) {
157
+ addObservation(db, session_id, 'discovery',
158
+ 'Knowledge source file modified - index may be stale',
159
+ `Edited ${filePath.split('/').pop() ?? filePath}. Run knowledge re-index to update.`,
160
+ { importance: 3 }
161
+ );
162
+ }
163
+ }
164
+ } catch (_knowledgeErr) {
165
+ // Best-effort: never block post-tool-use
166
+ }
129
167
  } finally {
130
168
  db.close();
131
169
  }
@@ -172,4 +210,96 @@ function readStdin(): Promise<string> {
172
210
  });
173
211
  }
174
212
 
213
+ /**
214
+ * Read the conventions section from massu.config.yaml directly.
215
+ * Hooks are compiled with esbuild and cannot use getConfig() from config.ts.
216
+ * Falls back to sensible defaults if the config file is not found.
217
+ */
218
+ function readConventions(cwd?: string): {
219
+ knowledgeSourceFiles: string[];
220
+ claudeDirName: string;
221
+ } {
222
+ const defaults = {
223
+ knowledgeSourceFiles: ['CLAUDE.md', 'MEMORY.md', 'corrections.md'],
224
+ claudeDirName: '.claude',
225
+ };
226
+ try {
227
+ const projectRoot = cwd ?? process.cwd();
228
+ const configPath = join(projectRoot, 'massu.config.yaml');
229
+ if (!existsSync(configPath)) return defaults;
230
+ const content = readFileSync(configPath, 'utf-8');
231
+ const parsed = parseYaml(content) as Record<string, unknown> | null;
232
+ if (!parsed || typeof parsed !== 'object') return defaults;
233
+ const conventions = parsed.conventions as Record<string, unknown> | undefined;
234
+ if (!conventions || typeof conventions !== 'object') return defaults;
235
+ return {
236
+ knowledgeSourceFiles: Array.isArray(conventions.knowledgeSourceFiles)
237
+ ? conventions.knowledgeSourceFiles as string[]
238
+ : defaults.knowledgeSourceFiles,
239
+ claudeDirName: typeof conventions.claudeDirName === 'string'
240
+ ? conventions.claudeDirName
241
+ : defaults.claudeDirName,
242
+ };
243
+ } catch {
244
+ return defaults;
245
+ }
246
+ }
247
+
248
+ /**
249
+ * Check if a file path is a knowledge source file (CLAUDE.md, corrections.md,
250
+ * memory files, or knowledge system source files).
251
+ * When these are edited, the knowledge index may become stale.
252
+ */
253
+ function isKnowledgeSourceFile(filePath: string): boolean {
254
+ const basename = filePath.split('/').pop() ?? '';
255
+ const conventions = readConventions();
256
+ const knowledgeSourcePatterns = [
257
+ ...conventions.knowledgeSourceFiles,
258
+ 'file-index.md',
259
+ 'knowledge-db.ts',
260
+ 'knowledge-indexer.ts',
261
+ 'knowledge-tools.ts',
262
+ ];
263
+ return knowledgeSourcePatterns.some(p => basename === p) ||
264
+ filePath.includes('/memory/') ||
265
+ filePath.includes(conventions.claudeDirName + '/');
266
+ }
267
+
268
+ /**
269
+ * Check MEMORY.md file integrity after a write.
270
+ * Verifies: file exists, has expected structure, and is under line limit.
271
+ * Returns array of issue descriptions (empty = all good).
272
+ */
273
+ function checkMemoryFileIntegrity(filePath: string): string[] {
274
+ const issues: string[] = [];
275
+
276
+ try {
277
+ if (!existsSync(filePath)) {
278
+ issues.push('MEMORY.md file does not exist after write');
279
+ return issues;
280
+ }
281
+
282
+ const content = readFileSync(filePath, 'utf-8');
283
+ const lines = content.split('\n');
284
+
285
+ // Check line count (CLAUDE.md truncates after ~200 lines)
286
+ const MAX_LINES = 200;
287
+ if (lines.length > MAX_LINES) {
288
+ issues.push(`MEMORY.md exceeds ${MAX_LINES} lines (currently ${lines.length}). Consider archiving old entries.`);
289
+ }
290
+
291
+ // Check required structure sections
292
+ const requiredSections = ['# Massu Memory', '## Key Learnings', '## Common Gotchas'];
293
+ for (const section of requiredSections) {
294
+ if (!content.includes(section)) {
295
+ issues.push(`Missing required section: "${section}"`);
296
+ }
297
+ }
298
+ } catch (_e) {
299
+ // Graceful degradation: don't report issues if we can't check
300
+ }
301
+
302
+ return issues;
303
+ }
304
+
175
305
  main();
@@ -7,7 +7,7 @@
7
7
  // Captures current session state into DB before compaction destroys context.
8
8
  // ============================================================
9
9
 
10
- import { getMemoryDb, addSummary, createSession } from '../memory-db.ts';
10
+ import { getMemoryDb, addSummary, createSession, addObservation } from '../memory-db.ts';
11
11
  import { logAuditEntry } from '../audit-trail.ts';
12
12
  import type { SessionSummary } from '../memory-db.ts';
13
13
 
@@ -45,6 +45,28 @@ async function main(): Promise<void> {
45
45
  // 4. Store with pre_compact marker in plan_progress
46
46
  addSummary(db, session_id, summary);
47
47
 
48
+ // 5. Preserve knowledge system state for post-compaction context
49
+ try {
50
+ const knowledgeObs = observations.filter(
51
+ o => (o.title as string)?.includes('knowledge') ||
52
+ (o.title as string)?.includes('Knowledge') ||
53
+ (o.detail as string)?.includes('knowledge')
54
+ );
55
+ if (knowledgeObs.length > 0) {
56
+ const knowledgeContext = knowledgeObs
57
+ .map(o => `[${o.type}] ${o.title}`)
58
+ .join('; ');
59
+ // Store as a high-importance observation so session-start picks it up
60
+ addObservation(db, session_id, 'discovery',
61
+ 'Knowledge context preserved before compaction',
62
+ knowledgeContext,
63
+ { importance: 4 }
64
+ );
65
+ }
66
+ } catch (_knowledgeErr) {
67
+ // Best-effort: never block compaction
68
+ }
69
+
48
70
  // Log compaction event for audit trail continuity
49
71
  try {
50
72
  logAuditEntry(db, {