@massu/core 1.2.1 → 1.4.0-soak.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/README.md +40 -0
  2. package/commands/README.md +137 -0
  3. package/commands/massu-deploy.python-docker.md +170 -0
  4. package/commands/massu-deploy.python-fly.md +189 -0
  5. package/commands/massu-deploy.python-launchd.md +144 -0
  6. package/commands/massu-deploy.python-systemd.md +163 -0
  7. package/commands/massu-deploy.python.md +200 -0
  8. package/commands/massu-scaffold-page.md +172 -59
  9. package/commands/massu-scaffold-page.swift.md +121 -0
  10. package/commands/massu-scaffold-router.python-django.md +153 -0
  11. package/commands/massu-scaffold-router.python-fastapi.md +145 -0
  12. package/commands/massu-scaffold-router.python.md +143 -0
  13. package/dist/cli.js +10170 -4138
  14. package/dist/hooks/auto-learning-pipeline.js +44 -6
  15. package/dist/hooks/classify-failure.js +44 -6
  16. package/dist/hooks/cost-tracker.js +44 -6
  17. package/dist/hooks/fix-detector.js +44 -6
  18. package/dist/hooks/incident-pipeline.js +44 -6
  19. package/dist/hooks/post-edit-context.js +44 -6
  20. package/dist/hooks/post-tool-use.js +44 -6
  21. package/dist/hooks/pre-compact.js +44 -6
  22. package/dist/hooks/pre-delete-check.js +44 -6
  23. package/dist/hooks/quality-event.js +44 -6
  24. package/dist/hooks/rule-enforcement-pipeline.js +44 -6
  25. package/dist/hooks/session-end.js +44 -6
  26. package/dist/hooks/session-start.js +4789 -410
  27. package/dist/hooks/user-prompt.js +44 -6
  28. package/package.json +10 -4
  29. package/src/cli.ts +28 -2
  30. package/src/commands/config-refresh.ts +88 -20
  31. package/src/commands/init.ts +130 -23
  32. package/src/commands/install-commands.ts +482 -42
  33. package/src/commands/refresh-log.ts +37 -0
  34. package/src/commands/show-template.ts +65 -0
  35. package/src/commands/template-engine.ts +262 -0
  36. package/src/commands/watch.ts +430 -0
  37. package/src/config.ts +69 -3
  38. package/src/detect/adapters/nextjs-trpc.ts +166 -0
  39. package/src/detect/adapters/parse-guard.ts +133 -0
  40. package/src/detect/adapters/python-django.ts +208 -0
  41. package/src/detect/adapters/python-fastapi.ts +223 -0
  42. package/src/detect/adapters/query-helpers.ts +170 -0
  43. package/src/detect/adapters/runner.ts +252 -0
  44. package/src/detect/adapters/swift-swiftui.ts +171 -0
  45. package/src/detect/adapters/tree-sitter-loader.ts +348 -0
  46. package/src/detect/adapters/types.ts +174 -0
  47. package/src/detect/codebase-introspector.ts +190 -0
  48. package/src/detect/index.ts +28 -2
  49. package/src/detect/regex-fallback.ts +449 -0
  50. package/src/hooks/session-start.ts +94 -3
  51. package/src/lib/gitToplevel.ts +22 -0
  52. package/src/lib/installLock.ts +179 -0
  53. package/src/lib/pidLiveness.ts +67 -0
  54. package/src/lsp/auto-detect.ts +89 -0
  55. package/src/lsp/client.ts +590 -0
  56. package/src/lsp/enrich.ts +127 -0
  57. package/src/lsp/types.ts +221 -0
  58. package/src/watch/daemon.ts +385 -0
  59. package/src/watch/lockfile-detector.ts +65 -0
  60. package/src/watch/paths.ts +279 -0
  61. package/src/watch/state.ts +178 -0
@@ -0,0 +1,279 @@
1
+ // Copyright (c) 2026 Massu. All rights reserved.
2
+ // Licensed under BSL 1.1 - see LICENSE file for details.
3
+
4
+ /**
5
+ * Watch glob derivation.
6
+ *
7
+ * Watch surface = manifest files (always) + source directories
8
+ * (from massu.config.yaml's paths.* and framework.languages.*.source_dirs,
9
+ * or fallback safe-default globs when both absent), bounded by exclusion
10
+ * globs.
11
+ */
12
+
13
+ import fastGlob from 'fast-glob';
14
+ import type { Config } from '../config.ts';
15
+
16
+ export const ALWAYS_WATCH_FILES = [
17
+ 'package.json',
18
+ 'pyproject.toml',
19
+ 'Cargo.toml',
20
+ 'go.mod',
21
+ 'Gemfile',
22
+ '*.csproj',
23
+ 'mix.exs',
24
+ 'requirements*.txt',
25
+ 'setup.py',
26
+ ] as const;
27
+
28
+ export const FALLBACK_SOURCE_GLOBS = [
29
+ 'src/**',
30
+ 'app/**',
31
+ 'apps/**',
32
+ 'packages/**',
33
+ 'lib/**',
34
+ 'cmd/**',
35
+ ] as const;
36
+
37
+ export const DEFAULT_EXCLUSIONS = [
38
+ '**/node_modules/**',
39
+ '**/.venv/**',
40
+ '**/venv/**',
41
+ '**/target/**',
42
+ '**/build/**',
43
+ '**/dist/**',
44
+ '**/.git/**',
45
+ '**/.massu/**',
46
+ '**/.claude/**',
47
+ '**/__pycache__/**',
48
+ '**/.pytest_cache/**',
49
+ '**/.mypy_cache/**',
50
+ // Plan 3a hotfix 2026-05-02: high-churn directories that are never
51
+ // legitimate stack-detection inputs and produced sustained 30-100% CPU
52
+ // when watched on Hedge (62K files / 42 GB tree).
53
+ '**/.next/**',
54
+ '**/coverage/**',
55
+ '**/logs/**',
56
+ '**/*.log',
57
+ // Runtime data dirs. Convention across Python/JS/Rust ecosystems is
58
+ // that `data/` holds runtime artifacts (caches, snapshots, model
59
+ // checkpoints, downloaded fixtures) that change frequently but are
60
+ // never stack-detection inputs. Hedge had 135K files in
61
+ // apps/ai-service/data alone, dwarfing legitimate source. If a
62
+ // project genuinely uses `data/` for source content, opt into
63
+ // `watch.scope: 'full'` and `watch.paths_full_root_opt_in: true`.
64
+ '**/data/**',
65
+ // Iter-7 fix: editor temp files inside watched dirs fire spurious chokidar
66
+ // events and inflate the storm-detection counter without representing real
67
+ // stack changes. Cover the most common cases:
68
+ // *.swp / *.swo / 4913 -> vim atomic-write probe + swap files
69
+ // .#* -> emacs lockfiles
70
+ // *~ -> gedit / many editors backup
71
+ // .DS_Store -> macOS Finder metadata
72
+ '**/*.swp',
73
+ '**/*.swo',
74
+ '**/4913',
75
+ '**/.#*',
76
+ '**/*~',
77
+ '**/.DS_Store',
78
+ ];
79
+
80
+ /**
81
+ * Plan 3a hotfix 2026-05-02: explicit root-watch sentinels. Any of these
82
+ * appearing in `framework.languages.*.source_dirs` is treated as the user
83
+ * asking to watch the entire repo root, which requires
84
+ * `watch.paths_full_root_opt_in: true` to override the file-count cap.
85
+ *
86
+ * Why these specifically: the toGlob() helper turns `'.'` into `'./**'`
87
+ * and leaves `'**'` / `'./**'` / `'./'` as-is. Each effectively makes
88
+ * chokidar walk the toplevel — silently, without warning — defeating
89
+ * the scope='paths' bound. We treat them as semantically equivalent to
90
+ * `scope: 'full'`.
91
+ */
92
+ export const ROOT_WATCH_SENTINELS = ['.', './', '**', './**', '*'] as const;
93
+
94
+ export function isRootWatchSentinel(dir: string): boolean {
95
+ return (ROOT_WATCH_SENTINELS as readonly string[]).includes(dir);
96
+ }
97
+
98
+ export class WatchSurfaceTooLargeError extends Error {
99
+ constructor(public readonly fileCount: number, public readonly cap: number) {
100
+ super(
101
+ `massu watch refuses to start: would monitor ${fileCount} files ` +
102
+ `(cap is ${cap}). Narrow framework.languages.*.source_dirs in ` +
103
+ `massu.config.yaml, or set watch.paths_full_root_opt_in: true (and ` +
104
+ `watch.max_watched_files: ${fileCount + 1000}) if root-level watching ` +
105
+ `is genuinely required. Note: monitoring more than 10K files routinely ` +
106
+ `produces 30-100% steady CPU under normal repo activity.`
107
+ );
108
+ this.name = 'WatchSurfaceTooLargeError';
109
+ }
110
+ }
111
+
112
+ export interface DerivedWatchGlobs {
113
+ /** Globs/files to watch. */
114
+ watch: string[];
115
+ /** Globs to exclude. */
116
+ ignore: string[];
117
+ /** True when fallback globs were used (because config didn't declare any source paths). */
118
+ usedFallback: boolean;
119
+ /** Effective scope after considering root sentinels in source_dirs. */
120
+ effectiveScope: 'paths' | 'full';
121
+ /** True when a root sentinel ('.', '**', etc.) was detected in source_dirs and promoted to full scope. */
122
+ rootWatchDetected: boolean;
123
+ }
124
+
125
+ /**
126
+ * Build the watch + ignore glob set for chokidar from a loaded Config.
127
+ * Returns project-relative globs; the daemon resolves them against its root.
128
+ *
129
+ * `watch.scope` (Plan 3a §167 + §251 risk #1):
130
+ * - `'paths'` (default) — watch only declared `paths.*` + `framework.languages.*.source_dirs`
131
+ * (or fallback safe-default globs when none declared). Bounded watch surface.
132
+ * - `'full'` — watch the entire project root (`'**'`) bounded by exclusion globs.
133
+ * Opt-in for users on small repos who want every file under the toplevel
134
+ * to count. NOT recommended for huge (>10K-file) repos.
135
+ */
136
+ export function deriveWatchGlobs(config: Config): DerivedWatchGlobs {
137
+ const sourceDirs = new Set<string>();
138
+ const explicitScope = config.watch?.scope ?? 'paths';
139
+ let rootWatchDetected = false;
140
+
141
+ if (explicitScope === 'full') {
142
+ // Full-repo opt-in. Just watch '**' and rely on DEFAULT_EXCLUSIONS for
143
+ // node_modules / .git / .massu / .claude / build dirs.
144
+ sourceDirs.add('**');
145
+ return {
146
+ watch: [...ALWAYS_WATCH_FILES, ...sourceDirs],
147
+ ignore: [...DEFAULT_EXCLUSIONS],
148
+ usedFallback: false,
149
+ effectiveScope: 'full',
150
+ rootWatchDetected: false,
151
+ };
152
+ }
153
+
154
+ if (config.paths.source && typeof config.paths.source === 'string') {
155
+ if (isRootWatchSentinel(config.paths.source)) {
156
+ rootWatchDetected = true;
157
+ } else {
158
+ sourceDirs.add(toGlob(config.paths.source));
159
+ }
160
+ }
161
+
162
+ const langs = config.framework.languages;
163
+ if (langs && typeof langs === 'object') {
164
+ for (const langEntry of Object.values(langs)) {
165
+ // Defensive: some entries may not include source_dirs. Use property
166
+ // narrowing instead of an `as unknown as Shape` cast.
167
+ if (
168
+ langEntry &&
169
+ typeof langEntry === 'object' &&
170
+ 'source_dirs' in langEntry
171
+ ) {
172
+ const dirs = (langEntry as { source_dirs?: unknown }).source_dirs;
173
+ if (Array.isArray(dirs)) {
174
+ for (const d of dirs) {
175
+ if (typeof d !== 'string' || !d) continue;
176
+ if (isRootWatchSentinel(d)) {
177
+ rootWatchDetected = true;
178
+ } else {
179
+ sourceDirs.add(toGlob(d));
180
+ }
181
+ }
182
+ }
183
+ }
184
+ }
185
+ }
186
+
187
+ const usedFallback = sourceDirs.size === 0 && !rootWatchDetected;
188
+ if (usedFallback) {
189
+ for (const g of FALLBACK_SOURCE_GLOBS) sourceDirs.add(g);
190
+ }
191
+
192
+ // Plan 3a hotfix: a root sentinel in source_dirs is semantically the same
193
+ // as scope='full'. Promote it. The daemon enforces the file-count cap on
194
+ // top of this; an unintentional `.` in source_dirs gets caught there.
195
+ let effectiveScope: 'paths' | 'full' = explicitScope;
196
+ if (rootWatchDetected) {
197
+ sourceDirs.add('**');
198
+ effectiveScope = 'full';
199
+ }
200
+
201
+ return {
202
+ watch: [...ALWAYS_WATCH_FILES, ...sourceDirs],
203
+ ignore: [...DEFAULT_EXCLUSIONS],
204
+ usedFallback,
205
+ effectiveScope,
206
+ rootWatchDetected,
207
+ };
208
+ }
209
+
210
+ function toGlob(dir: string): string {
211
+ if (dir.endsWith('/**') || dir.includes('*')) return dir;
212
+ return dir.replace(/\/+$/, '') + '/**';
213
+ }
214
+
215
+ /**
216
+ * Plan 3a hotfix 2026-05-02: count files matching the derived watch globs
217
+ * (after exclusions) without walking past the cap. Used as a startup
218
+ * preflight in the daemon — refuses to start if count > cap and the user
219
+ * hasn't set `watch.paths_full_root_opt_in: true`.
220
+ *
221
+ * Why upfront vs after chokidar: chokidar.getWatched() requires waiting
222
+ * for the `ready` event, which on a 62K-file tree takes 30+ seconds AND
223
+ * does the full walk we're trying to avoid. fast-glob with `onlyFiles`
224
+ * + early-exit via the iterator pattern walks once, bails when we've
225
+ * counted enough to know we're over the cap.
226
+ *
227
+ * Returns Infinity if more than `cap + 1` files exist (signals "exceeds")
228
+ * to avoid walking the full tree just to produce an exact count.
229
+ */
230
+ export async function countWatchSurface(
231
+ watch: readonly string[],
232
+ ignore: readonly string[],
233
+ cwd: string,
234
+ cap: number
235
+ ): Promise<number> {
236
+ // fast-glob's stream API yields one path per event, allowing early-exit.
237
+ const stream = fastGlob.stream(watch as string[], {
238
+ cwd,
239
+ ignore: ignore as string[],
240
+ onlyFiles: true,
241
+ dot: false,
242
+ followSymbolicLinks: false,
243
+ suppressErrors: true,
244
+ });
245
+ let count = 0;
246
+ for await (const _ of stream) {
247
+ count += 1;
248
+ if (count > cap) {
249
+ // We've proven the surface exceeds the cap. Cancel the iterator and
250
+ // return a sentinel value. The caller only needs the exceeds-or-not
251
+ // signal (plus the cap) to produce a useful error.
252
+ // @ts-expect-error fast-glob stream is a NodeJS.ReadableStream
253
+ stream.destroy?.();
254
+ return Infinity;
255
+ }
256
+ }
257
+ return count;
258
+ }
259
+
260
+ /**
261
+ * Plan 3a hotfix 2026-05-02: enforce the watch.max_watched_files cap.
262
+ * Throws WatchSurfaceTooLargeError if the count exceeds the cap and the
263
+ * user has not opted in via `watch.paths_full_root_opt_in: true`.
264
+ *
265
+ * Returns the actual count (or Infinity if early-exit tripped) so the
266
+ * daemon can log the surface size at startup for observability.
267
+ */
268
+ export async function enforceWatchSurfaceCap(
269
+ globs: DerivedWatchGlobs,
270
+ cwd: string,
271
+ cap: number,
272
+ optedIn: boolean
273
+ ): Promise<number> {
274
+ const count = await countWatchSurface(globs.watch, globs.ignore, cwd, cap);
275
+ if (count > cap && !optedIn) {
276
+ throw new WatchSurfaceTooLargeError(count, cap);
277
+ }
278
+ return count;
279
+ }
@@ -0,0 +1,178 @@
1
+ // Copyright (c) 2026 Massu. All rights reserved.
2
+ // Licensed under BSL 1.1 - see LICENSE file for details.
3
+
4
+ /**
5
+ * Watcher state persistence — `.massu/watch-state.json`.
6
+ *
7
+ * Crash-recovery contract: every mutation goes through writeStateAtomic()
8
+ * (write `.tmp` → fsyncSync → renameSync). At any kill -9 point either the
9
+ * old state or the new state survives intact (POSIX rename atomicity).
10
+ *
11
+ * Schema versioning: top-level `schema_version` field. Migrators in
12
+ * STATE_MIGRATORS bump from `from` → `from + 1` until MAX_SUPPORTED.
13
+ */
14
+
15
+ import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeFileSync, writeSync } from 'fs';
16
+ import { dirname, resolve } from 'path';
17
+
18
+ export const MIN_SUPPORTED_SCHEMA_VERSION = 1;
19
+ export const MAX_SUPPORTED_SCHEMA_VERSION = 1;
20
+
21
+ export interface WatchState {
22
+ schema_version: number;
23
+ lastFingerprint: string | null;
24
+ lastRefreshAt: string | null;
25
+ lastError: string | null;
26
+ daemonPid: number | null;
27
+ startedAt: string | null;
28
+ tickedAt: string | null;
29
+ }
30
+
31
+ export const DEFAULT_STATE: WatchState = {
32
+ schema_version: MAX_SUPPORTED_SCHEMA_VERSION,
33
+ lastFingerprint: null,
34
+ lastRefreshAt: null,
35
+ lastError: null,
36
+ daemonPid: null,
37
+ startedAt: null,
38
+ tickedAt: null,
39
+ };
40
+
41
+ /**
42
+ * Bumps state from a known prior version to `from + 1`. Empty at ship.
43
+ * When a future schema bump lands, register a migrator here for every
44
+ * `from` in [MIN_SUPPORTED_SCHEMA_VERSION..MAX_SUPPORTED_SCHEMA_VERSION-1].
45
+ */
46
+ export const STATE_MIGRATORS: Record<number, (old: Record<string, unknown>) => Record<string, unknown>> = {};
47
+
48
+ export function watchStatePath(projectRoot: string): string {
49
+ return resolve(projectRoot, '.massu', 'watch-state.json');
50
+ }
51
+
52
+ export function backupStatePath(projectRoot: string): string {
53
+ return resolve(projectRoot, '.massu', 'watch-state.v0.bak.json');
54
+ }
55
+
56
+ export class WatchStateNewerError extends Error {
57
+ constructor(public stateVersion: number, public daemonMax: number) {
58
+ super(`watch-state from newer massu version (v=${stateVersion}, daemon supports v=${daemonMax}); refusing to overwrite — upgrade massu or delete .massu/watch-state.json`);
59
+ this.name = 'WatchStateNewerError';
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Read state from disk. Behavior on schema mismatch:
65
+ * - missing schema_version: archive to backup path, return DEFAULT_STATE
66
+ * - older than supported: run STATE_MIGRATORS in sequence
67
+ * - newer than supported: throw WatchStateNewerError
68
+ */
69
+ export function readState(projectRoot: string): WatchState {
70
+ const path = watchStatePath(projectRoot);
71
+ if (!existsSync(path)) return { ...DEFAULT_STATE };
72
+
73
+ // Read once; reuse the content for archive-on-corrupt to avoid a redundant
74
+ // disk read in the failure path (iter-9 simplify finding E1).
75
+ const content = readFileSync(path, 'utf-8');
76
+
77
+ let raw: unknown;
78
+ try {
79
+ raw = JSON.parse(content);
80
+ } catch {
81
+ archiveCorrupt(projectRoot, content);
82
+ return { ...DEFAULT_STATE };
83
+ }
84
+
85
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
86
+ return { ...DEFAULT_STATE };
87
+ }
88
+
89
+ const obj = raw as Record<string, unknown>;
90
+ const ver = obj.schema_version;
91
+
92
+ if (typeof ver !== 'number') {
93
+ archiveCorrupt(projectRoot, content);
94
+ return { ...DEFAULT_STATE };
95
+ }
96
+
97
+ if (ver > MAX_SUPPORTED_SCHEMA_VERSION) {
98
+ throw new WatchStateNewerError(ver, MAX_SUPPORTED_SCHEMA_VERSION);
99
+ }
100
+
101
+ let migrated: Record<string, unknown> = obj;
102
+ for (let v = ver; v < MAX_SUPPORTED_SCHEMA_VERSION; v++) {
103
+ const migrator = STATE_MIGRATORS[v];
104
+ if (!migrator) {
105
+ throw new Error(
106
+ `watch-state.json: missing migrator for schema_version ${v} -> ${v + 1}. ` +
107
+ `Daemon supports up to v=${MAX_SUPPORTED_SCHEMA_VERSION}; this is a massu bug. ` +
108
+ `Workaround: delete .massu/watch-state.json (the daemon will rebuild it on next start).`,
109
+ );
110
+ }
111
+ migrated = migrator(migrated);
112
+ }
113
+
114
+ return {
115
+ schema_version: MAX_SUPPORTED_SCHEMA_VERSION,
116
+ lastFingerprint: typeof migrated.lastFingerprint === 'string' ? migrated.lastFingerprint : null,
117
+ lastRefreshAt: typeof migrated.lastRefreshAt === 'string' ? migrated.lastRefreshAt : null,
118
+ lastError: typeof migrated.lastError === 'string' ? migrated.lastError : null,
119
+ daemonPid: typeof migrated.daemonPid === 'number' ? migrated.daemonPid : null,
120
+ startedAt: typeof migrated.startedAt === 'string' ? migrated.startedAt : null,
121
+ tickedAt: typeof migrated.tickedAt === 'string' ? migrated.tickedAt : null,
122
+ };
123
+ }
124
+
125
+ function archiveCorrupt(projectRoot: string, content: string): void {
126
+ const bak = backupStatePath(projectRoot);
127
+ mkdirSync(dirname(bak), { recursive: true });
128
+ writeFileSync(bak, content, 'utf-8');
129
+ }
130
+
131
+ // Per-process counter for unique temp filenames. Combined with PID this
132
+ // makes the temp path unique across concurrent writers in the same project.
133
+ let writeStateAtomicCounter = 0;
134
+
135
+ /**
136
+ * Atomic write: tmp + fsync + rename. Survives kill -9 at any point.
137
+ *
138
+ * The temp filename is `<path>.<pid>.<counter>.tmp` so that two concurrent
139
+ * processes (e.g. foreground daemon + a `massu watch --apply-now` racing
140
+ * against it) never share the same temp file. POSIX rename is atomic per
141
+ * (target) so the final state file always reflects exactly one of the
142
+ * concurrent writers — never a torn write.
143
+ */
144
+ export function writeStateAtomic(projectRoot: string, state: WatchState): void {
145
+ const path = watchStatePath(projectRoot);
146
+ writeStateAtomicCounter = (writeStateAtomicCounter + 1) >>> 0;
147
+ const tmp = `${path}.${process.pid}.${writeStateAtomicCounter}.tmp`;
148
+ mkdirSync(dirname(path), { recursive: true });
149
+
150
+ // Iter-8 fix: clean up tmp on error. Without this, a writeSync/fsyncSync
151
+ // failure (e.g., ENOSPC, EIO mid-write) leaves a `.<pid>.<counter>.tmp`
152
+ // straggler in `.massu/` for every failed attempt. Mirrors the cleanup-on-
153
+ // error pattern used by `atomicWriteFile` in install-commands.ts.
154
+ let renamed = false;
155
+ try {
156
+ const fd = openSync(tmp, 'w');
157
+ try {
158
+ const buf = Buffer.from(JSON.stringify(state, null, 2) + '\n', 'utf-8');
159
+ writeSync(fd, buf, 0, buf.length, 0);
160
+ fsyncSync(fd);
161
+ } finally {
162
+ closeSync(fd);
163
+ }
164
+ renameSync(tmp, path);
165
+ renamed = true;
166
+ } finally {
167
+ if (!renamed && existsSync(tmp)) {
168
+ try { rmSync(tmp, { force: true }); } catch { /* best-effort */ }
169
+ }
170
+ }
171
+ }
172
+
173
+ export function updateState(projectRoot: string, patch: Partial<WatchState>): WatchState {
174
+ const current = readState(projectRoot);
175
+ const next: WatchState = { ...current, ...patch, schema_version: MAX_SUPPORTED_SCHEMA_VERSION };
176
+ writeStateAtomic(projectRoot, next);
177
+ return next;
178
+ }