@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.
Files changed (178) hide show
  1. package/CHANGELOG.md +1334 -0
  2. package/dist/adapter.js +6 -4
  3. package/dist/cli.js +581 -448
  4. package/dist/detect/adapters/.bundle-shasums.json +4 -7
  5. package/dist/detect/adapters/rails.js +6 -4
  6. package/dist/detect/adapters/spring.js +6 -4
  7. package/dist/detect/adapters/tree-sitter-loader.js +6 -4
  8. package/dist/hooks/auto-learning-pipeline.js +24 -1
  9. package/dist/hooks/classify-failure.js +32 -3
  10. package/dist/hooks/cost-tracker.js +24 -0
  11. package/dist/hooks/fix-detector.js +97 -1
  12. package/dist/hooks/incident-pipeline.js +30 -1
  13. package/dist/hooks/intent-suggester.js +6 -3
  14. package/dist/hooks/post-edit-context.js +24 -1
  15. package/dist/hooks/post-tool-use.js +67 -19
  16. package/dist/hooks/pre-compact.js +28 -3
  17. package/dist/hooks/pre-delete-check.js +53 -27
  18. package/dist/hooks/pre-tool-use-gate.js +962 -0
  19. package/dist/hooks/quality-event.js +24 -0
  20. package/dist/hooks/rule-enforcement-pipeline.js +24 -1
  21. package/dist/hooks/security-gate.js +29 -16
  22. package/dist/hooks/session-end.js +31 -6
  23. package/dist/hooks/session-start.js +8709 -8459
  24. package/dist/hooks/user-prompt.js +25 -1
  25. package/package.json +15 -18
  26. package/commands/README.md +0 -134
  27. package/src/adapter.ts +0 -121
  28. package/src/adr-generator.ts +0 -292
  29. package/src/analytics.ts +0 -373
  30. package/src/audit-trail.ts +0 -450
  31. package/src/backfill-sessions.ts +0 -182
  32. package/src/changelog-generator.ts +0 -178
  33. package/src/cli.ts +0 -246
  34. package/src/cloud-sync.ts +0 -234
  35. package/src/commands/adapters.ts +0 -824
  36. package/src/commands/changelog.ts +0 -165
  37. package/src/commands/config-check-drift.ts +0 -133
  38. package/src/commands/config-refresh.ts +0 -396
  39. package/src/commands/config-upgrade.ts +0 -127
  40. package/src/commands/doctor.ts +0 -485
  41. package/src/commands/hook-runner.ts +0 -145
  42. package/src/commands/init.ts +0 -1823
  43. package/src/commands/install-commands.ts +0 -692
  44. package/src/commands/install-hooks.ts +0 -27
  45. package/src/commands/permissions.ts +0 -150
  46. package/src/commands/refresh-log.ts +0 -37
  47. package/src/commands/show-template.ts +0 -65
  48. package/src/commands/template-engine.ts +0 -301
  49. package/src/commands/watch.ts +0 -430
  50. package/src/config.ts +0 -765
  51. package/src/cost-tracker.ts +0 -355
  52. package/src/db.ts +0 -370
  53. package/src/dependency-scorer.ts +0 -337
  54. package/src/detect/adapters/aspnet.ts +0 -4
  55. package/src/detect/adapters/discover.ts +0 -469
  56. package/src/detect/adapters/file-sampler.ts +0 -225
  57. package/src/detect/adapters/go-chi.ts +0 -4
  58. package/src/detect/adapters/index.ts +0 -51
  59. package/src/detect/adapters/nextjs-trpc.ts +0 -166
  60. package/src/detect/adapters/parse-guard.ts +0 -133
  61. package/src/detect/adapters/phoenix.ts +0 -4
  62. package/src/detect/adapters/python-django.ts +0 -208
  63. package/src/detect/adapters/python-fastapi.ts +0 -223
  64. package/src/detect/adapters/python-flask.ts +0 -235
  65. package/src/detect/adapters/query-helpers.ts +0 -170
  66. package/src/detect/adapters/rails.ts +0 -4
  67. package/src/detect/adapters/runner.ts +0 -284
  68. package/src/detect/adapters/spring.ts +0 -4
  69. package/src/detect/adapters/swift-swiftui.ts +0 -171
  70. package/src/detect/adapters/tree-sitter-loader.ts +0 -517
  71. package/src/detect/adapters/types.ts +0 -191
  72. package/src/detect/codebase-introspector.ts +0 -210
  73. package/src/detect/domain-inferrer.ts +0 -197
  74. package/src/detect/drift.ts +0 -199
  75. package/src/detect/framework-detector.ts +0 -307
  76. package/src/detect/index.ts +0 -209
  77. package/src/detect/manifest-registry.ts +0 -261
  78. package/src/detect/migrate.ts +0 -362
  79. package/src/detect/monorepo-detector.ts +0 -348
  80. package/src/detect/package-detector.ts +0 -828
  81. package/src/detect/passthrough.ts +0 -108
  82. package/src/detect/regex-fallback.ts +0 -449
  83. package/src/detect/source-dir-detector.ts +0 -297
  84. package/src/detect/vr-command-map.ts +0 -167
  85. package/src/docs-map.json +0 -100
  86. package/src/docs-tools.ts +0 -519
  87. package/src/domains.ts +0 -182
  88. package/src/hooks/auto-learning-pipeline.ts +0 -228
  89. package/src/hooks/classify-failure.ts +0 -259
  90. package/src/hooks/cost-tracker.ts +0 -66
  91. package/src/hooks/fix-detector.ts +0 -186
  92. package/src/hooks/incident-pipeline.ts +0 -190
  93. package/src/hooks/intent-suggester.ts +0 -131
  94. package/src/hooks/post-edit-context.ts +0 -91
  95. package/src/hooks/post-tool-use.ts +0 -413
  96. package/src/hooks/pre-compact.ts +0 -168
  97. package/src/hooks/pre-delete-check.ts +0 -243
  98. package/src/hooks/quality-event.ts +0 -127
  99. package/src/hooks/rule-enforcement-pipeline.ts +0 -159
  100. package/src/hooks/security-gate.ts +0 -153
  101. package/src/hooks/session-end.ts +0 -467
  102. package/src/hooks/session-start.ts +0 -436
  103. package/src/hooks/user-prompt.ts +0 -157
  104. package/src/import-resolver.ts +0 -226
  105. package/src/knowledge-db.ts +0 -176
  106. package/src/knowledge-indexer.ts +0 -708
  107. package/src/knowledge-tools.ts +0 -1414
  108. package/src/lib/fileLock.ts +0 -203
  109. package/src/lib/framework-supports.ts +0 -85
  110. package/src/lib/gitToplevel.ts +0 -22
  111. package/src/lib/hook-registry.ts +0 -43
  112. package/src/lib/installLock.ts +0 -66
  113. package/src/lib/memory-path.ts +0 -49
  114. package/src/lib/pidLiveness.ts +0 -67
  115. package/src/lib/settings-local.ts +0 -110
  116. package/src/lib/sql-table-names.ts +0 -71
  117. package/src/license.ts +0 -547
  118. package/src/lsp/auto-detect.ts +0 -98
  119. package/src/lsp/client.ts +0 -776
  120. package/src/lsp/enrich.ts +0 -127
  121. package/src/lsp/types.ts +0 -221
  122. package/src/memory-db.ts +0 -1614
  123. package/src/memory-file-ingest.ts +0 -128
  124. package/src/memory-tools.ts +0 -424
  125. package/src/middleware-tree.ts +0 -71
  126. package/src/observability-tools.ts +0 -343
  127. package/src/observation-extractor.ts +0 -418
  128. package/src/page-deps.ts +0 -285
  129. package/src/permissions.ts +0 -422
  130. package/src/prompt-analyzer.ts +0 -332
  131. package/src/python/coupling-detector.ts +0 -125
  132. package/src/python/domain-enforcer.ts +0 -84
  133. package/src/python/impact-analyzer.ts +0 -96
  134. package/src/python/import-parser.ts +0 -244
  135. package/src/python/import-resolver.ts +0 -136
  136. package/src/python/migration-indexer.ts +0 -116
  137. package/src/python/migration-parser.ts +0 -332
  138. package/src/python/model-indexer.ts +0 -71
  139. package/src/python/model-parser.ts +0 -279
  140. package/src/python/route-indexer.ts +0 -59
  141. package/src/python/route-parser.ts +0 -317
  142. package/src/python-tools.ts +0 -630
  143. package/src/regression-detector.ts +0 -319
  144. package/src/rules.ts +0 -57
  145. package/src/schema-mapper.ts +0 -232
  146. package/src/security/adapter-origin.ts +0 -130
  147. package/src/security/adapter-verifier.ts +0 -319
  148. package/src/security/atomic-write.ts +0 -164
  149. package/src/security/fetcher.ts +0 -200
  150. package/src/security/install-tracking.ts +0 -319
  151. package/src/security/license-pubkey.generated.ts +0 -30
  152. package/src/security/license-response-verifier.ts +0 -149
  153. package/src/security/local-fingerprint.ts +0 -225
  154. package/src/security/manifest-cache.ts +0 -333
  155. package/src/security/manifest-schema.ts +0 -129
  156. package/src/security/registry-pubkey.generated.ts +0 -35
  157. package/src/security/telemetry.ts +0 -320
  158. package/src/security-scorer.ts +0 -405
  159. package/src/security-utils.ts +0 -133
  160. package/src/sentinel-db.ts +0 -580
  161. package/src/sentinel-scanner.ts +0 -406
  162. package/src/sentinel-tools.ts +0 -513
  163. package/src/sentinel-types.ts +0 -140
  164. package/src/server-dispatch.ts +0 -225
  165. package/src/server.ts +0 -107
  166. package/src/session-archiver.ts +0 -111
  167. package/src/session-state-generator.ts +0 -174
  168. package/src/team-knowledge.ts +0 -407
  169. package/src/tool-db-needs.ts +0 -232
  170. package/src/tools.ts +0 -1206
  171. package/src/transcript-parser.ts +0 -458
  172. package/src/trpc-index.ts +0 -215
  173. package/src/validate-features-runner.ts +0 -107
  174. package/src/validation-engine.ts +0 -358
  175. package/src/watch/daemon.ts +0 -385
  176. package/src/watch/lockfile-detector.ts +0 -65
  177. package/src/watch/paths.ts +0 -279
  178. package/src/watch/state.ts +0 -178
@@ -1,165 +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 changelog <subcommand>` — CHANGELOG generation + verification CLI.
6
- *
7
- * Subcommands:
8
- * generate Emit a draft CHANGELOG.md entry to stdout for commits since the
9
- * last tag. Operator pipes/copies into CHANGELOG.md.
10
- * verify Read current CHANGELOG.md latest entry; verify every plan-token
11
- * in `git log <last-tag>..HEAD` subjects is referenced. Exit 0 if
12
- * clean, exit 1 with one `gap: <token>` per missing.
13
- *
14
- * Plan ref: plan-1.9.0-plan-token-aware-changelog-batcher Phase C.
15
- * Mirrors the `permissions <sub>` cluster precedent shipped in 1.8.0.
16
- */
17
-
18
- import { execSync } from 'child_process';
19
- import { existsSync, readFileSync } from 'fs';
20
- import { resolve } from 'path';
21
- import {
22
- parseCommitsForPlanTokens,
23
- loadPlanSummaries,
24
- generateChangelogEntry,
25
- findCoverageGaps,
26
- MissingPlanFileError,
27
- MissingChangelogSummaryError,
28
- } from '../changelog-generator.ts';
29
-
30
- function resolveRepoRoot(): string {
31
- try {
32
- return execSync('git rev-parse --show-toplevel', { encoding: 'utf-8' }).trim();
33
- } catch {
34
- return process.cwd();
35
- }
36
- }
37
-
38
- function getLastTag(): string | null {
39
- try {
40
- return execSync('git describe --tags --abbrev=0', { encoding: 'utf-8' }).trim();
41
- } catch {
42
- return null;
43
- }
44
- }
45
-
46
- function getCommitSubjects(range: string): string[] {
47
- try {
48
- const out = execSync(`git log ${range} --pretty=format:%s`, { encoding: 'utf-8' });
49
- return out.split('\n').filter((s) => s.length > 0);
50
- } catch {
51
- return [];
52
- }
53
- }
54
-
55
- function getCurrentVersion(repoRoot: string): string {
56
- const pkgPath = resolve(repoRoot, 'packages/core/package.json');
57
- if (!existsSync(pkgPath)) return '0.0.0';
58
- const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
59
- return pkg.version || '0.0.0';
60
- }
61
-
62
- function todayDate(): string {
63
- return new Date().toISOString().slice(0, 10);
64
- }
65
-
66
- function getLatestChangelogEntryBody(repoRoot: string): string {
67
- const path = resolve(repoRoot, 'CHANGELOG.md');
68
- if (!existsSync(path)) return '';
69
- const content = readFileSync(path, 'utf-8');
70
- // Find first `## [...]` heading and capture until next or EOF
71
- const m = content.match(/^## \[[\d.]+\][^\n]*\n([\s\S]*?)(?=\n## \[|$)/m);
72
- return m ? m[1] : '';
73
- }
74
-
75
- export async function handleChangelogSubcommand(
76
- args: string[],
77
- ): Promise<{ exitCode: number }> {
78
- const sub = args[0];
79
- const repoRoot = resolveRepoRoot();
80
- const planDir = resolve(repoRoot, 'docs/plans');
81
-
82
- switch (sub) {
83
- case 'generate': {
84
- const lastTag = getLastTag();
85
- const range = lastTag ? `${lastTag}..HEAD` : 'HEAD';
86
- const subjects = getCommitSubjects(range);
87
- const { tokens, maintenance } = parseCommitsForPlanTokens(subjects);
88
-
89
- let planSummaries;
90
- try {
91
- planSummaries = loadPlanSummaries(tokens, planDir);
92
- } catch (err) {
93
- if (err instanceof MissingPlanFileError || err instanceof MissingChangelogSummaryError) {
94
- process.stderr.write(`changelog generate: ${err.message}\n`);
95
- return { exitCode: 2 };
96
- }
97
- throw err;
98
- }
99
-
100
- const entry = generateChangelogEntry({
101
- version: getCurrentVersion(repoRoot),
102
- date: todayDate(),
103
- planSummaries,
104
- maintenance,
105
- });
106
- process.stdout.write(entry);
107
- return { exitCode: 0 };
108
- }
109
-
110
- case 'verify': {
111
- const lastTag = getLastTag();
112
- const range = lastTag ? `${lastTag}..HEAD` : 'HEAD';
113
- const subjects = getCommitSubjects(range);
114
- const { tokens } = parseCommitsForPlanTokens(subjects);
115
-
116
- const entryBody = getLatestChangelogEntryBody(repoRoot);
117
- const gaps = findCoverageGaps(entryBody, tokens);
118
-
119
- if (gaps.length === 0) {
120
- process.stdout.write('All plan-tokens referenced.\n');
121
- return { exitCode: 0 };
122
- }
123
- for (const t of gaps) {
124
- process.stderr.write(`gap: ${t}\n`);
125
- }
126
- return { exitCode: 1 };
127
- }
128
-
129
- case '--help':
130
- case '-h':
131
- case undefined: {
132
- printChangelogHelp();
133
- return { exitCode: 0 };
134
- }
135
-
136
- default: {
137
- process.stderr.write(`massu: unknown changelog subcommand: ${sub}\n`);
138
- printChangelogHelp();
139
- return { exitCode: 1 };
140
- }
141
- }
142
- }
143
-
144
- export function printChangelogHelp(): void {
145
- process.stdout.write(`
146
- massu changelog <subcommand>
147
-
148
- Subcommands:
149
- generate Emit a draft CHANGELOG.md entry to stdout. Reads commit subjects
150
- since the last git tag, groups by (plan-<token>) paren-notation,
151
- looks up each plan file's ## Changelog Summary section, and emits
152
- a Keep-a-Changelog 1.1.0 entry. Operator pipes/copies into
153
- CHANGELOG.md (no forced overwrite).
154
-
155
- verify Read-only check that the latest CHANGELOG.md entry references
156
- every plan-token in commits since the last tag. Exit 0 if clean,
157
- exit 1 with one 'gap: <token>' per missing.
158
-
159
- Examples:
160
- npx massu changelog generate > /tmp/draft-entry.md
161
- npx massu changelog verify
162
-
163
- Documentation: https://massu.ai/docs/reference/cli-reference#massu-changelog
164
- `);
165
- }
@@ -1,133 +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 config check-drift` — CI-safe drift gate.
6
- *
7
- * Reads massu.config.yaml, runs detection, and compares the result with both
8
- * the stored fingerprint (if present) and the structural detectDrift() check.
9
- *
10
- * Flags:
11
- * --verbose Emit the full changes[] to stdout (field: before -> after).
12
- *
13
- * Exit codes:
14
- * 0 no drift
15
- * 1 drift detected
16
- * 2 config missing or unparseable
17
- */
18
-
19
- import { existsSync, readFileSync } from 'fs';
20
- import { resolve } from 'path';
21
- import { parse as parseYaml } from 'yaml';
22
- import { runDetection } from '../detect/index.ts';
23
- import { computeFingerprint, detectDrift, type DriftChange } from '../detect/drift.ts';
24
- import type { AnyConfig } from '../detect/migrate.ts';
25
-
26
- export interface ConfigCheckDriftOptions {
27
- verbose?: boolean;
28
- cwd?: string;
29
- silent?: boolean;
30
- }
31
-
32
- export interface ConfigCheckDriftResult {
33
- exitCode: 0 | 1 | 2;
34
- drifted: boolean;
35
- changes: DriftChange[];
36
- storedFingerprint: string | null;
37
- currentFingerprint: string | null;
38
- message?: string;
39
- }
40
-
41
- function renderChanges(changes: DriftChange[]): string {
42
- if (changes.length === 0) return '(none)\n';
43
- return changes
44
- .map((c) => ` ${c.field}: ${JSON.stringify(c.before)} -> ${JSON.stringify(c.after)}`)
45
- .join('\n') + '\n';
46
- }
47
-
48
- export async function runConfigCheckDrift(
49
- opts: ConfigCheckDriftOptions = {}
50
- ): Promise<ConfigCheckDriftResult> {
51
- const cwd = opts.cwd ?? process.cwd();
52
- const configPath = resolve(cwd, 'massu.config.yaml');
53
- const log = opts.silent ? () => {} : (s: string) => process.stdout.write(s);
54
- const err = opts.silent ? () => {} : (s: string) => process.stderr.write(s);
55
-
56
- if (!existsSync(configPath)) {
57
- const message = 'massu.config.yaml not found. Run: npx massu init';
58
- err(message + '\n');
59
- return {
60
- exitCode: 2,
61
- drifted: false,
62
- changes: [],
63
- storedFingerprint: null,
64
- currentFingerprint: null,
65
- message,
66
- };
67
- }
68
-
69
- let config: AnyConfig;
70
- try {
71
- const content = readFileSync(configPath, 'utf-8');
72
- // pattern-scanner-allow: yaml-parse — reason: v1.1.0 drift detector. Raw YAML compare needed at arbitrary cwd before getConfig() cache populates; the drift detector intentionally bypasses the cached parse to inspect on-disk state.
73
- const parsed = parseYaml(content);
74
- if (!parsed || typeof parsed !== 'object') {
75
- throw new Error('config is not a YAML object');
76
- }
77
- config = parsed as AnyConfig;
78
- } catch (e) {
79
- const message = `Failed to parse massu.config.yaml: ${e instanceof Error ? e.message : String(e)}`;
80
- err(message + '\n');
81
- return {
82
- exitCode: 2,
83
- drifted: false,
84
- changes: [],
85
- storedFingerprint: null,
86
- currentFingerprint: null,
87
- message,
88
- };
89
- }
90
-
91
- const detection = await runDetection(cwd);
92
- const currentFp = computeFingerprint(detection);
93
- const storedFp =
94
- typeof (config.detection as Record<string, unknown> | undefined)?.fingerprint === 'string'
95
- ? ((config.detection as Record<string, unknown>).fingerprint as string)
96
- : null;
97
-
98
- const report = detectDrift(config, detection);
99
- const fingerprintDrift = storedFp !== null && storedFp !== currentFp;
100
- const drifted = report.drifted || fingerprintDrift;
101
-
102
- if (!drifted) {
103
- log('No drift detected.\n');
104
- if (opts.verbose) {
105
- log(`Fingerprint: ${currentFp}\n`);
106
- }
107
- return {
108
- exitCode: 0,
109
- drifted: false,
110
- changes: report.changes,
111
- storedFingerprint: storedFp,
112
- currentFingerprint: currentFp,
113
- };
114
- }
115
-
116
- err('Config drift detected; run `npx massu config refresh` to update.\n');
117
- if (opts.verbose) {
118
- if (storedFp !== null) {
119
- log(`Fingerprint: ${storedFp.slice(0, 16)} -> ${currentFp.slice(0, 16)}\n`);
120
- } else {
121
- log(`Fingerprint (new): ${currentFp.slice(0, 16)}\n`);
122
- }
123
- log('Changes:\n');
124
- log(renderChanges(report.changes));
125
- }
126
- return {
127
- exitCode: 1,
128
- drifted: true,
129
- changes: report.changes,
130
- storedFingerprint: storedFp,
131
- currentFingerprint: currentFp,
132
- };
133
- }
@@ -1,396 +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 config refresh` — re-run detection, diff against current config, apply
6
- * or print-only (--dry-run).
7
- *
8
- * Merge semantics:
9
- * - Detector-owned keys (framework, paths.source, verification, detection) are REFRESHED.
10
- * - User-authored keys (rules, domains, canonical_paths, accessScopes,
11
- * knownMismatches, dbAccessPattern, analytics, governance, security, team,
12
- * regression, cloud, conventions, autoLearning, verification_types,
13
- * python) are PRESERVED verbatim from the existing config.
14
- *
15
- * Flags:
16
- * --dry-run Emit the diff to stdout, exit 0, never write.
17
- * (none) Interactive: show diff, prompt for confirmation via @clack/prompts.
18
- * When stdin is not a TTY, behaves as --dry-run with a note.
19
- *
20
- * Exit codes:
21
- * 0 success (applied, or dry-run completed)
22
- * 1 missing massu.config.yaml (run `massu init`)
23
- * 2 unparseable massu.config.yaml
24
- */
25
-
26
- import { existsSync, readFileSync, rmSync } from 'fs';
27
- import { resolve } from 'path';
28
- import { parse as parseYaml } from 'yaml';
29
- import { runDetection } from '../detect/index.ts';
30
- import { computeFingerprint } from '../detect/drift.ts';
31
- import type { AnyConfig } from '../detect/migrate.ts';
32
- import { copyUnknownKeys, preserveNestedSubkeys } from '../detect/passthrough.ts';
33
- import { buildConfigFromDetection, renderConfigYaml, writeConfigAtomic } from './init.ts';
34
- import { installAll } from './install-commands.ts';
35
- import { resetConfig } from '../config.ts';
36
- import { withInstallLock } from '../lib/installLock.ts';
37
-
38
- const PRESERVED_FIELDS = [
39
- 'rules',
40
- 'domains',
41
- 'canonical_paths',
42
- 'verification_types',
43
- 'accessScopes',
44
- 'knownMismatches',
45
- 'dbAccessPattern',
46
- 'analytics',
47
- 'governance',
48
- 'security',
49
- 'team',
50
- 'regression',
51
- 'cloud',
52
- 'conventions',
53
- 'autoLearning',
54
- 'python',
55
- 'toolPrefix',
56
- ] as const;
57
-
58
- export interface ConfigRefreshOptions {
59
- dryRun?: boolean;
60
- cwd?: string;
61
- silent?: boolean;
62
- /**
63
- * Plan #2 P4-001: when true, skip the post-refresh `installAll` call so
64
- * `.claude/commands/` is NOT re-templated. Used by tests to keep file I/O
65
- * hermetic, and by users who want config-only refresh behavior.
66
- */
67
- skipCommands?: boolean;
68
- /**
69
- * Plan 3a Phase 6: when true, bypass BOTH the non-TTY bail and the
70
- * `@clack/prompts` confirm gate. The watcher daemon and `--yes` CLI flag
71
- * use this to auto-apply detected changes. Combined with
72
- * `skipCommands: true`, it lets the watcher delegate `installAll` to its
73
- * own outer call (single install, single lock acquire — see iter-3 G3-A9).
74
- */
75
- autoYes?: boolean;
76
- }
77
-
78
- export interface ConfigRefreshResult {
79
- exitCode: 0 | 1 | 2;
80
- applied: boolean;
81
- dryRun: boolean;
82
- diff: DiffLine[];
83
- message?: string;
84
- }
85
-
86
- export interface DiffLine {
87
- kind: 'add' | 'remove' | 'change' | 'same';
88
- path: string;
89
- before?: unknown;
90
- after?: unknown;
91
- }
92
-
93
- function flatten(obj: unknown, prefix = ''): Record<string, unknown> {
94
- const out: Record<string, unknown> = {};
95
- if (obj === null || obj === undefined) {
96
- out[prefix || '<root>'] = obj;
97
- return out;
98
- }
99
- if (typeof obj !== 'object' || Array.isArray(obj)) {
100
- out[prefix || '<root>'] = obj;
101
- return out;
102
- }
103
- const rec = obj as Record<string, unknown>;
104
- for (const [k, v] of Object.entries(rec)) {
105
- const p = prefix ? `${prefix}.${k}` : k;
106
- if (v !== null && typeof v === 'object' && !Array.isArray(v)) {
107
- Object.assign(out, flatten(v, p));
108
- } else {
109
- out[p] = v;
110
- }
111
- }
112
- return out;
113
- }
114
-
115
- export function computeDiff(before: AnyConfig, after: AnyConfig): DiffLine[] {
116
- const b = flatten(before);
117
- const a = flatten(after);
118
- const keys = new Set<string>([...Object.keys(b), ...Object.keys(a)]);
119
- const sorted = [...keys].sort();
120
- const lines: DiffLine[] = [];
121
- for (const k of sorted) {
122
- const bVal = b[k];
123
- const aVal = a[k];
124
- const bHas = k in b;
125
- const aHas = k in a;
126
- if (bHas && !aHas) {
127
- lines.push({ kind: 'remove', path: k, before: bVal });
128
- } else if (!bHas && aHas) {
129
- lines.push({ kind: 'add', path: k, after: aVal });
130
- } else if (JSON.stringify(bVal) !== JSON.stringify(aVal)) {
131
- lines.push({ kind: 'change', path: k, before: bVal, after: aVal });
132
- }
133
- }
134
- return lines;
135
- }
136
-
137
- export function mergeRefresh(existing: AnyConfig, refreshed: AnyConfig): AnyConfig {
138
- // P1-008: Start from detector output (fresh framework, paths.source, verification, detection).
139
- const out: AnyConfig = { ...refreshed };
140
-
141
- // Restore user-authored top-level sections verbatim.
142
- for (const field of PRESERVED_FIELDS) {
143
- if (existing[field] !== undefined) {
144
- out[field] = existing[field];
145
- }
146
- }
147
-
148
- // Restore toolPrefix from existing (never let detector-defaulted 'massu' overwrite a custom prefix).
149
- if (typeof existing.toolPrefix === 'string' && existing.toolPrefix !== '') {
150
- out.toolPrefix = existing.toolPrefix;
151
- }
152
-
153
- // For detector-owned blocks (framework, paths, project), preserve any user subkey the detector didn't emit.
154
- for (const block of ['framework', 'paths', 'project'] as const) {
155
- const existingBlock = existing[block];
156
- const outBlock = out[block];
157
- if (
158
- existingBlock && typeof existingBlock === 'object' && !Array.isArray(existingBlock) &&
159
- outBlock && typeof outBlock === 'object' && !Array.isArray(outBlock)
160
- ) {
161
- preserveNestedSubkeys(existingBlock, outBlock as Record<string, unknown>);
162
- }
163
- }
164
-
165
- // Restore user-set project.root (detector at init.ts:418 always writes 'auto'; user value wins).
166
- // Separated from the block loop above for readability (A-004 architecture-review follow-up).
167
- const existingProject = existing.project;
168
- const outProject = out.project;
169
- if (
170
- existingProject && typeof existingProject === 'object' && !Array.isArray(existingProject) &&
171
- outProject && typeof outProject === 'object' && !Array.isArray(outProject)
172
- ) {
173
- const userRoot = (existingProject as Record<string, unknown>).root;
174
- if (typeof userRoot === 'string' && userRoot !== '') {
175
- (outProject as Record<string, unknown>).root = userRoot;
176
- }
177
- }
178
-
179
- // paths.aliases is a 2-level-nested user block. Detector always writes
180
- // { '@': <source-dir> }; user-authored alias map must survive. Spread user
181
- // over detector so user keys win for any overlap AND user-only keys survive.
182
- // (P5-002 discovery — a downstream consumer's paths.aliases['@'] was being overwritten.)
183
- const existingPaths = existing.paths;
184
- const outPaths = out.paths;
185
- if (
186
- existingPaths && typeof existingPaths === 'object' && !Array.isArray(existingPaths) &&
187
- outPaths && typeof outPaths === 'object' && !Array.isArray(outPaths)
188
- ) {
189
- const existingAliases = (existingPaths as Record<string, unknown>).aliases;
190
- const outAliases = (outPaths as Record<string, unknown>).aliases;
191
- if (
192
- existingAliases && typeof existingAliases === 'object' && !Array.isArray(existingAliases) &&
193
- outAliases && typeof outAliases === 'object' && !Array.isArray(outAliases)
194
- ) {
195
- (outPaths as Record<string, unknown>).aliases = {
196
- ...(outAliases as Record<string, unknown>),
197
- ...(existingAliases as Record<string, unknown>),
198
- };
199
- } else if (existingAliases && typeof existingAliases === 'object' && !Array.isArray(existingAliases)) {
200
- (outPaths as Record<string, unknown>).aliases = existingAliases;
201
- }
202
- }
203
-
204
- // verification is the other 2-level-nested detector-owned block. Semantics
205
- // mirror migrate.ts:132-138 buildVerificationBlock: user's custom language
206
- // sections (e.g., a multi-runtime monorepo's `gateway`, `ios`, `runtime`, `web`) survive
207
- // wholesale; user's command overrides on shared languages (e.g., `python`)
208
- // win over detector defaults. (P5-002 discovery — a downstream consumer was losing 15
209
- // verification command entries across 4 custom language sections plus
210
- // having 4 python commands overwritten with detector defaults.)
211
- const existingVer = existing.verification;
212
- const outVer = out.verification;
213
- if (
214
- existingVer && typeof existingVer === 'object' && !Array.isArray(existingVer) &&
215
- outVer && typeof outVer === 'object' && !Array.isArray(outVer)
216
- ) {
217
- const eVer = existingVer as Record<string, unknown>;
218
- const oVer = outVer as Record<string, unknown>;
219
- for (const lang of Object.keys(eVer)) {
220
- const userLang = eVer[lang];
221
- if (userLang === undefined) continue;
222
- if (!(lang in oVer)) {
223
- // User-custom language (no detector counterpart) → preserve wholesale.
224
- oVer[lang] = userLang;
225
- } else if (
226
- userLang && typeof userLang === 'object' && !Array.isArray(userLang) &&
227
- oVer[lang] && typeof oVer[lang] === 'object' && !Array.isArray(oVer[lang])
228
- ) {
229
- // Shared language → user commands win over detector defaults (spread).
230
- oVer[lang] = {
231
- ...(oVer[lang] as Record<string, unknown>),
232
- ...(userLang as Record<string, unknown>),
233
- };
234
- }
235
- }
236
- }
237
-
238
- // Preserve top-level user keys not handled above (mirrors P1-001 passthrough for upgrade).
239
- const handledTopLevel = new Set<string>([
240
- 'schema_version', 'project', 'framework', 'paths', 'toolPrefix', 'verification', 'detection',
241
- ...PRESERVED_FIELDS,
242
- ]);
243
- copyUnknownKeys(existing, out, handledTopLevel);
244
-
245
- return out;
246
- }
247
-
248
- function renderDiff(diff: DiffLine[]): string {
249
- if (diff.length === 0) return '(no changes)\n';
250
- const lines: string[] = [];
251
- for (const d of diff) {
252
- if (d.kind === 'add') lines.push(`+ ${d.path}: ${JSON.stringify(d.after)}`);
253
- else if (d.kind === 'remove') lines.push(`- ${d.path}: ${JSON.stringify(d.before)}`);
254
- else if (d.kind === 'change') {
255
- lines.push(`~ ${d.path}: ${JSON.stringify(d.before)} -> ${JSON.stringify(d.after)}`);
256
- }
257
- }
258
- return lines.join('\n') + '\n';
259
- }
260
-
261
- export async function runConfigRefresh(opts: ConfigRefreshOptions = {}): Promise<ConfigRefreshResult> {
262
- const cwd = opts.cwd ?? process.cwd();
263
- const configPath = resolve(cwd, 'massu.config.yaml');
264
- const log = opts.silent ? () => {} : (s: string) => process.stdout.write(s);
265
-
266
- if (!existsSync(configPath)) {
267
- const message = 'massu.config.yaml not found. Run: npx massu init';
268
- if (!opts.silent) process.stderr.write(message + '\n');
269
- return { exitCode: 1, applied: false, dryRun: !!opts.dryRun, diff: [], message };
270
- }
271
-
272
- let existing: AnyConfig;
273
- try {
274
- const content = readFileSync(configPath, 'utf-8');
275
- // pattern-scanner-allow: yaml-parse — reason: v1.1.0 config lifecycle command. getConfig() Zod-rejects pre-migration v1 configs (which is exactly what `massu config refresh` is meant to read + migrate). Raw parse is required to inspect a pre-upgrade YAML structure.
276
- const parsed = parseYaml(content);
277
- if (!parsed || typeof parsed !== 'object') {
278
- throw new Error('config is not a YAML object');
279
- }
280
- existing = parsed as AnyConfig;
281
- } catch (err) {
282
- const message = `Failed to parse massu.config.yaml: ${err instanceof Error ? err.message : String(err)}`;
283
- if (!opts.silent) process.stderr.write(message + '\n');
284
- return { exitCode: 2, applied: false, dryRun: !!opts.dryRun, diff: [], message };
285
- }
286
-
287
- const detection = await runDetection(cwd);
288
- const refreshed = buildConfigFromDetection({
289
- projectRoot: cwd,
290
- detection,
291
- projectName: typeof (existing.project as Record<string, unknown> | undefined)?.name === 'string'
292
- ? (existing.project as Record<string, unknown>).name as string
293
- : undefined,
294
- });
295
- // buildConfigFromDetection already stamps detection.fingerprint. Double-check.
296
- if (!(refreshed.detection as Record<string, unknown> | undefined)?.fingerprint) {
297
- refreshed.detection = { fingerprint: computeFingerprint(detection) };
298
- }
299
-
300
- const merged = mergeRefresh(existing, refreshed);
301
- const diff = computeDiff(existing, merged);
302
-
303
- if (opts.dryRun) {
304
- log('Config diff (dry-run; no changes written):\n');
305
- log(renderDiff(diff));
306
- return { exitCode: 0, applied: false, dryRun: true, diff };
307
- }
308
-
309
- if (diff.length === 0) {
310
- log('No changes needed — config is already up to date.\n');
311
- return { exitCode: 0, applied: false, dryRun: false, diff };
312
- }
313
-
314
- // Plan 3a Phase 6: when autoYes=true, skip BOTH the non-TTY bail and the
315
- // confirm gate so the watcher (daemon stdin is detached) and the
316
- // `--yes`/-y CLI flag actually apply changes.
317
- if (!opts.autoYes) {
318
- // Interactive prompt; fall back to dry-run semantics when not a TTY.
319
- if (!process.stdin.isTTY) {
320
- log('Config diff (non-interactive; pass --dry-run to suppress this note or run interactively to apply):\n');
321
- log(renderDiff(diff));
322
- return {
323
- exitCode: 0,
324
- applied: false,
325
- dryRun: false,
326
- diff,
327
- message: 'non-interactive shell; no changes written',
328
- };
329
- }
330
-
331
- log('Config diff:\n');
332
- log(renderDiff(diff));
333
- const { confirm } = await import('@clack/prompts');
334
- const apply = await confirm({ message: 'Apply these changes to massu.config.yaml?' });
335
- if (apply !== true) {
336
- log('Aborted; no changes written.\n');
337
- return { exitCode: 0, applied: false, dryRun: false, diff, message: 'aborted by user' };
338
- }
339
- } else {
340
- log('Config diff (auto-applying via --yes / watcher):\n');
341
- log(renderDiff(diff));
342
- }
343
-
344
- const yamlContent = renderConfigYaml(merged);
345
- const writeRes = writeConfigAtomic(configPath, yamlContent);
346
- if (!writeRes.validated) {
347
- const message = `Failed to write config: ${writeRes.error}`;
348
- if (!opts.silent) process.stderr.write(message + '\n');
349
- return { exitCode: 2, applied: false, dryRun: false, diff, message };
350
- }
351
- log('Config refreshed.\n');
352
-
353
- // Plan #2 P4-001: re-template `.claude/commands/` against the freshly
354
- // written config so newly-detected stack changes get the right scaffolds.
355
- // P4-003 (auto-delete half): if a stack is now declared and the empty-init
356
- // placeholder still exists, remove it.
357
- if (!opts.skipCommands) {
358
- log('Will also re-template command files; pass --skip-commands to opt out.\n');
359
- // Reset cached config so installAll reads the freshly-written YAML.
360
- resetConfig();
361
- try {
362
- const installResult = withInstallLock(cwd, () => installAll(cwd));
363
- const total =
364
- installResult.totalInstalled +
365
- installResult.totalUpdated +
366
- installResult.totalSkipped +
367
- installResult.totalKept;
368
- log(`Re-templated ${total} command files (${installResult.totalInstalled} new, ${installResult.totalUpdated} updated).\n`);
369
-
370
- // Auto-delete the empty-init placeholder if at least one stack-specific
371
- // command was resolved this run (i.e., a non-zero install/update count).
372
- const stackResolved =
373
- installResult.totalInstalled > 0 || installResult.totalUpdated > 0;
374
- if (stackResolved) {
375
- const placeholderPath = resolve(installResult.claudeDir, 'commands', '_massu-needs-stack.md');
376
- if (existsSync(placeholderPath)) {
377
- try {
378
- rmSync(placeholderPath, { force: true });
379
- log('Removed _massu-needs-stack.md (stack now declared).\n');
380
- } catch {
381
- // Best-effort: never block refresh on placeholder cleanup.
382
- }
383
- }
384
- }
385
- } catch (err) {
386
- // Don't fail the whole refresh if re-template breaks; the YAML was already written.
387
- // InstallLockBusyError.message already follows plan §243 format —
388
- // `installAll already running (PID=X) — try again in <N>s` — so we
389
- // surface it verbatim rather than re-wrapping.
390
- const msg = err instanceof Error ? err.message : String(err);
391
- if (!opts.silent) process.stderr.write(`Warning: re-template failed: ${msg}\n`);
392
- }
393
- }
394
-
395
- return { exitCode: 0, applied: true, dryRun: false, diff };
396
- }