@stackmemoryai/stackmemory 1.5.2 → 1.5.4

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.
@@ -3,10 +3,17 @@ import { dirname as __pathDirname } from 'path';
3
3
  const __filename = __fileURLToPath(import.meta.url);
4
4
  const __dirname = __pathDirname(__filename);
5
5
  import { spawn, execSync } from "child_process";
6
- import { existsSync, mkdirSync, rmSync } from "fs";
6
+ import {
7
+ existsSync,
8
+ mkdirSync,
9
+ rmSync,
10
+ writeFileSync,
11
+ createWriteStream
12
+ } from "fs";
7
13
  import { join, dirname } from "path";
8
- import { tmpdir } from "os";
14
+ import { tmpdir, homedir } from "os";
9
15
  import { fileURLToPath } from "url";
16
+ import { Transform } from "stream";
10
17
  import { logger } from "../../core/monitoring/logger.js";
11
18
  import {
12
19
  LinearClient
@@ -26,6 +33,95 @@ function findPackageRoot() {
26
33
  }
27
34
  return dirname(currentFile);
28
35
  }
36
+ function getAgentStatusDir(issueIdentifier) {
37
+ return join(
38
+ homedir(),
39
+ ".stackmemory",
40
+ "conductor",
41
+ "agents",
42
+ issueIdentifier
43
+ );
44
+ }
45
+ function ensureAgentStatusDir(issueIdentifier) {
46
+ const dir = getAgentStatusDir(issueIdentifier);
47
+ if (!existsSync(dir)) {
48
+ mkdirSync(dir, { recursive: true });
49
+ }
50
+ return dir;
51
+ }
52
+ class TeeTransform extends Transform {
53
+ logStream;
54
+ constructor(logStream) {
55
+ super();
56
+ this.logStream = logStream;
57
+ }
58
+ _transform(chunk, _encoding, callback) {
59
+ this.logStream.write(chunk);
60
+ this.push(chunk);
61
+ callback();
62
+ }
63
+ _flush(callback) {
64
+ this.logStream.end();
65
+ callback();
66
+ }
67
+ }
68
+ function inferPhaseFromStreamJson(event) {
69
+ if (event.type !== "assistant") return null;
70
+ const message = event.message;
71
+ const content = message?.content || [];
72
+ for (const block of content) {
73
+ if (block.type !== "tool_use") continue;
74
+ const toolLower = (block.name || "").toLowerCase();
75
+ if (toolLower.includes("read") || toolLower.includes("glob") || toolLower.includes("grep") || toolLower.includes("search")) {
76
+ return "reading";
77
+ }
78
+ if (toolLower.includes("todowrite") || toolLower.includes("todo")) {
79
+ return "planning";
80
+ }
81
+ if (toolLower.includes("edit") || toolLower.includes("write") || toolLower.includes("bash")) {
82
+ if (toolLower === "bash") {
83
+ const input = block.input;
84
+ const command = (input?.command ?? "").toLowerCase();
85
+ if (command.includes("git commit") || command.includes("git add")) {
86
+ return "committing";
87
+ }
88
+ }
89
+ return "implementing";
90
+ }
91
+ if (toolLower.includes("test")) {
92
+ return "testing";
93
+ }
94
+ }
95
+ return null;
96
+ }
97
+ function inferPhase(msg) {
98
+ const method = msg.method;
99
+ const params = msg.params;
100
+ if (method === "item/commandExecution/started") {
101
+ const tool = params?.tool || params?.name || "";
102
+ const toolLower = tool.toLowerCase();
103
+ if (toolLower.includes("read") || toolLower.includes("glob") || toolLower.includes("grep") || toolLower.includes("search")) {
104
+ return "reading";
105
+ }
106
+ if (toolLower.includes("todowrite") || toolLower.includes("todo")) {
107
+ return "planning";
108
+ }
109
+ if (toolLower.includes("edit") || toolLower.includes("write") || toolLower.includes("bash")) {
110
+ return "implementing";
111
+ }
112
+ if (toolLower.includes("test")) {
113
+ return "testing";
114
+ }
115
+ }
116
+ if (method === "item/commandExecution/started") {
117
+ const args = params?.arguments;
118
+ const command = (args?.command ?? params?.command) || "";
119
+ if (command.includes("git commit") || command.includes("git add")) {
120
+ return "committing";
121
+ }
122
+ }
123
+ return null;
124
+ }
29
125
  const DEFAULT_CONFIG = {
30
126
  activeStates: ["Todo"],
31
127
  terminalStates: ["Done", "Cancelled", "Canceled", "Closed"],
@@ -44,7 +140,8 @@ const DEFAULT_CONFIG = {
44
140
  ),
45
141
  turnTimeoutMs: 36e5,
46
142
  maxRetries: 1,
47
- hookTimeoutMs: 6e4
143
+ hookTimeoutMs: 6e4,
144
+ agentMode: "cli"
48
145
  };
49
146
  class Conductor {
50
147
  config;
@@ -130,6 +227,7 @@ class Conductor {
130
227
  console.log(
131
228
  `Orchestrator started \u2014 polling every ${this.config.pollIntervalMs / 1e3}s, max ${this.config.maxConcurrent} concurrent`
132
229
  );
230
+ this.writeStatusFile();
133
231
  const shutdown = () => this.stop();
134
232
  process.on("SIGINT", shutdown);
135
233
  process.on("SIGTERM", shutdown);
@@ -175,6 +273,7 @@ class Conductor {
175
273
  }
176
274
  this.running.clear();
177
275
  this.claimed.clear();
276
+ this.clearStatusFile();
178
277
  console.log(
179
278
  `Orchestrator stopped. Completed: ${this.completeCount}, Failed: ${this.failCount}`
180
279
  );
@@ -198,6 +297,82 @@ class Conductor {
198
297
  issues
199
298
  };
200
299
  }
300
+ // ── Status File ──
301
+ /**
302
+ * Write current conductor state to .stackmemory/conductor-status.json
303
+ * for consumption by `stackmemory dashboard` and other tools.
304
+ */
305
+ writeStatusFile() {
306
+ const statusDir = join(this.config.repoRoot, ".stackmemory");
307
+ if (!existsSync(statusDir)) return;
308
+ const status = {
309
+ pid: process.pid,
310
+ startedAt: this.startedAt,
311
+ updatedAt: Date.now(),
312
+ running: Array.from(this.running.values()).map((r) => ({
313
+ identifier: r.issue.identifier,
314
+ title: r.issue.title,
315
+ status: r.status,
316
+ attempt: r.attempt,
317
+ startedAt: r.startedAt,
318
+ runtime: Date.now() - r.startedAt
319
+ })),
320
+ queued: Array.from(this.claimed).filter(
321
+ (id) => !this.running.has(id) && !this.completed.has(id)
322
+ ).length,
323
+ completed: this.completeCount,
324
+ failed: this.failCount,
325
+ totalAttempts: this.totalAttempts,
326
+ maxConcurrent: this.config.maxConcurrent,
327
+ stopping: this.stopping
328
+ };
329
+ try {
330
+ writeFileSync(
331
+ join(statusDir, "conductor-status.json"),
332
+ JSON.stringify(status, null, 2)
333
+ );
334
+ } catch {
335
+ }
336
+ }
337
+ clearStatusFile() {
338
+ const statusPath = join(
339
+ this.config.repoRoot,
340
+ ".stackmemory",
341
+ "conductor-status.json"
342
+ );
343
+ try {
344
+ if (existsSync(statusPath)) rmSync(statusPath);
345
+ } catch {
346
+ }
347
+ }
348
+ // ── Agent Status Files ──
349
+ /**
350
+ * Write per-agent status to ~/.stackmemory/conductor/agents/<issue-id>/status.json
351
+ */
352
+ writeAgentStatus(issueIdentifier, run) {
353
+ try {
354
+ const dir = ensureAgentStatusDir(issueIdentifier);
355
+ const status = {
356
+ issue: issueIdentifier,
357
+ pid: run.process?.pid || process.pid,
358
+ started: new Date(run.startedAt).toISOString(),
359
+ lastUpdate: (/* @__PURE__ */ new Date()).toISOString(),
360
+ phase: run.phase,
361
+ filesModified: run.filesModified,
362
+ toolCalls: run.toolCalls,
363
+ tokensUsed: run.tokensUsed
364
+ };
365
+ writeFileSync(join(dir, "status.json"), JSON.stringify(status, null, 2));
366
+ } catch {
367
+ }
368
+ }
369
+ /**
370
+ * Open a log file write stream for an agent.
371
+ */
372
+ openAgentLogStream(issueIdentifier) {
373
+ const dir = ensureAgentStatusDir(issueIdentifier);
374
+ return createWriteStream(join(dir, "output.log"), { flags: "a" });
375
+ }
201
376
  // ── Polling ──
202
377
  async schedulePoll() {
203
378
  while (!this.stopping) {
@@ -210,6 +385,7 @@ class Conductor {
210
385
  } catch (err) {
211
386
  logger.error("Poll cycle failed", { error: err.message });
212
387
  }
388
+ this.writeStatusFile();
213
389
  }
214
390
  }
215
391
  async poll() {
@@ -337,8 +513,13 @@ class Conductor {
337
513
  process: null,
338
514
  attempt: 1,
339
515
  startedAt: Date.now(),
340
- status: "starting"
516
+ status: "starting",
517
+ phase: "reading",
518
+ toolCalls: 0,
519
+ filesModified: 0,
520
+ tokensUsed: 0
341
521
  };
522
+ this.writeAgentStatus(issue.identifier, run);
342
523
  this.running.set(issueId, run);
343
524
  this.totalAttempts++;
344
525
  console.log(`[${issue.identifier}] Dispatching: ${issue.title}`);
@@ -347,23 +528,26 @@ class Conductor {
347
528
  run.workspacePath = workspacePath;
348
529
  await this.transitionIssue(issue, this.config.inProgressState);
349
530
  await this.runHook("after-create", workspacePath, issue);
350
- run.status = "running";
351
- await this.runAgent(issue, run);
352
- run.status = "completed";
353
- this.completeCount++;
354
- await this.runHook("after-run", workspacePath, issue, run.attempt);
355
- this.takeSnapshot(workspacePath, issue);
356
- await this.transitionIssue(issue, this.config.inReviewState);
357
- console.log(`[${issue.identifier}] Completed successfully`);
531
+ await this.attemptRun(issue, run);
358
532
  } catch (err) {
359
- run.status = "failed";
360
- run.error = err.message;
361
- logger.error("Issue dispatch failed", {
362
- identifier: issue.identifier,
363
- error: run.error,
364
- attempt: run.attempt
365
- });
366
- if (run.workspacePath) {
533
+ this.failCount++;
534
+ console.log(`[${issue.identifier}] Failed: ${err.message}`);
535
+ } finally {
536
+ this.running.delete(issueId);
537
+ this.writeStatusFile();
538
+ }
539
+ }
540
+ /**
541
+ * Run the agent with retry logic. Throws on final failure.
542
+ */
543
+ async attemptRun(issue, run) {
544
+ const maxAttempts = this.config.maxRetries + 1;
545
+ while (run.attempt <= maxAttempts) {
546
+ try {
547
+ run.status = "running";
548
+ await this.runAgent(issue, run);
549
+ run.status = "completed";
550
+ this.completeCount++;
367
551
  await this.runHook(
368
552
  "after-run",
369
553
  run.workspacePath,
@@ -371,47 +555,44 @@ class Conductor {
371
555
  run.attempt
372
556
  ).catch(() => {
373
557
  });
374
- }
375
- if (run.attempt < this.config.maxRetries + 1) {
558
+ this.takeSnapshot(run.workspacePath, issue);
559
+ await this.transitionIssue(issue, this.config.inReviewState);
376
560
  console.log(
377
- `[${issue.identifier}] Failed (attempt ${run.attempt}), retrying...`
561
+ run.attempt === 1 ? `[${issue.identifier}] Completed successfully` : `[${issue.identifier}] Completed on retry ${run.attempt}`
378
562
  );
379
- run.attempt++;
380
- this.totalAttempts++;
381
- const backoffMs = Math.min(1e3 * Math.pow(2, run.attempt - 1), 3e5);
382
- await new Promise((r) => setTimeout(r, backoffMs));
383
- if (!this.stopping) {
384
- try {
385
- run.status = "running";
386
- await this.runAgent(issue, run);
387
- run.status = "completed";
388
- this.completeCount++;
389
- await this.runHook(
390
- "after-run",
391
- run.workspacePath,
392
- issue,
393
- run.attempt
394
- ).catch(() => {
395
- });
396
- await this.transitionIssue(issue, this.config.inReviewState);
397
- console.log(
398
- `[${issue.identifier}] Completed on retry ${run.attempt}`
399
- );
400
- } catch (retryErr) {
401
- run.status = "failed";
402
- run.error = retryErr.message;
403
- this.failCount++;
404
- console.log(
405
- `[${issue.identifier}] Failed after ${run.attempt} attempts: ${run.error}`
406
- );
407
- }
563
+ return;
564
+ } catch (err) {
565
+ run.status = "failed";
566
+ run.error = err.message;
567
+ logger.error("Agent run failed", {
568
+ identifier: issue.identifier,
569
+ error: run.error,
570
+ attempt: run.attempt
571
+ });
572
+ if (run.workspacePath) {
573
+ await this.runHook(
574
+ "after-run",
575
+ run.workspacePath,
576
+ issue,
577
+ run.attempt
578
+ ).catch(() => {
579
+ });
580
+ }
581
+ if (run.attempt < maxAttempts && !this.stopping) {
582
+ console.log(
583
+ `[${issue.identifier}] Failed (attempt ${run.attempt}), retrying...`
584
+ );
585
+ run.attempt++;
586
+ this.totalAttempts++;
587
+ const backoffMs = Math.min(
588
+ 1e3 * Math.pow(2, run.attempt - 1),
589
+ 3e5
590
+ );
591
+ await new Promise((r) => setTimeout(r, backoffMs));
592
+ } else {
593
+ throw err;
408
594
  }
409
- } else {
410
- this.failCount++;
411
- console.log(`[${issue.identifier}] Failed: ${run.error}`);
412
595
  }
413
- } finally {
414
- this.running.delete(issueId);
415
596
  }
416
597
  }
417
598
  // ── Workspace Management ──
@@ -492,13 +673,167 @@ class Conductor {
492
673
  return identifier.replace(/[^A-Za-z0-9._-]/g, "_");
493
674
  }
494
675
  // ── Agent Execution ──
495
- runAgent(issue, run) {
676
+ async runAgent(issue, run) {
677
+ if (this.config.agentMode === "cli") {
678
+ try {
679
+ return await this.runAgentCLI(issue, run);
680
+ } catch (err) {
681
+ const msg = err.message || "";
682
+ if (msg.includes("usage limits") || msg.includes("rate_limit") || msg.includes("overloaded")) {
683
+ logger.warn("CLI mode hit limits, falling back to adapter", {
684
+ identifier: issue.identifier,
685
+ error: msg.slice(0, 200)
686
+ });
687
+ run.toolCalls = 0;
688
+ run.filesModified = 0;
689
+ run.tokensUsed = 0;
690
+ run.phase = "reading";
691
+ return this.runAgentAdapter(issue, run);
692
+ }
693
+ throw err;
694
+ }
695
+ }
696
+ return this.runAgentAdapter(issue, run);
697
+ }
698
+ /**
699
+ * CLI mode: spawn `claude -p --output-format stream-json` directly.
700
+ * Uses whatever auth the environment provides (session quota, API key, etc).
701
+ */
702
+ runAgentCLI(issue, run) {
496
703
  return new Promise((resolve, reject) => {
497
704
  const prompt = this.buildPrompt(issue, run.attempt);
705
+ const proc = spawn(
706
+ "claude",
707
+ [
708
+ "-p",
709
+ "--output-format",
710
+ "stream-json",
711
+ "--dangerously-skip-permissions",
712
+ prompt
713
+ ],
714
+ {
715
+ cwd: run.workspacePath,
716
+ env: (() => {
717
+ const env = { ...process.env };
718
+ delete env.CLAUDECODE;
719
+ delete env.ANTHROPIC_API_KEY;
720
+ return {
721
+ ...env,
722
+ SYMPHONY_WORKSPACE_DIR: run.workspacePath,
723
+ SYMPHONY_ISSUE_ID: issue.id,
724
+ SYMPHONY_ISSUE_IDENTIFIER: issue.identifier,
725
+ SYMPHONY_ATTEMPT: String(run.attempt)
726
+ };
727
+ })(),
728
+ stdio: ["pipe", "pipe", "pipe"]
729
+ }
730
+ );
731
+ run.process = proc;
732
+ const logStream = this.openAgentLogStream(issue.identifier);
733
+ run.logStream = logStream;
734
+ const tee = new TeeTransform(logStream);
735
+ proc.stdout.pipe(tee);
736
+ this.writeAgentStatus(issue.identifier, run);
737
+ let stderr = "";
738
+ let lastResultText = "";
739
+ const timer = setTimeout(() => {
740
+ logger.warn("Agent turn timeout (cli)", {
741
+ identifier: issue.identifier,
742
+ timeoutMs: this.config.turnTimeoutMs
743
+ });
744
+ proc.kill("SIGTERM");
745
+ reject(new Error(`Agent timeout after ${this.config.turnTimeoutMs}ms`));
746
+ }, this.config.turnTimeoutMs);
747
+ let lineBuffer = "";
748
+ tee.on("data", (chunk) => {
749
+ lineBuffer += chunk.toString();
750
+ const lines = lineBuffer.split("\n");
751
+ lineBuffer = lines.pop() || "";
752
+ for (const line of lines) {
753
+ if (!line.trim()) continue;
754
+ try {
755
+ const event = JSON.parse(line);
756
+ const phase = inferPhaseFromStreamJson(event);
757
+ if (phase) {
758
+ run.phase = phase;
759
+ }
760
+ if (event.type === "assistant") {
761
+ const message = event.message;
762
+ const content = message?.content || [];
763
+ for (const block of content) {
764
+ if (block.type === "tool_use") {
765
+ run.toolCalls++;
766
+ const toolLower = (block.name || "").toLowerCase();
767
+ if (toolLower.includes("edit") || toolLower.includes("write")) {
768
+ run.filesModified++;
769
+ }
770
+ }
771
+ if (block.type === "text" && block.text) {
772
+ run.tokensUsed += Math.ceil(
773
+ block.text.length / 4
774
+ );
775
+ }
776
+ }
777
+ }
778
+ if (event.type === "result" && event.result) {
779
+ lastResultText = typeof event.result === "string" ? event.result : JSON.stringify(event.result);
780
+ }
781
+ if (run.toolCalls % 5 === 0 || phase) {
782
+ this.writeAgentStatus(issue.identifier, run);
783
+ }
784
+ } catch {
785
+ }
786
+ }
787
+ });
788
+ proc.stderr.on("data", (data) => {
789
+ stderr += data.toString();
790
+ const lines = data.toString().split("\n").filter((l) => l.trim());
791
+ for (const line of lines) {
792
+ logger.debug(`[${issue.identifier}] ${line}`);
793
+ }
794
+ });
795
+ proc.on("error", (err) => {
796
+ clearTimeout(timer);
797
+ reject(new Error(`Failed to spawn claude: ${err.message}`));
798
+ });
799
+ proc.on("close", (code) => {
800
+ clearTimeout(timer);
801
+ run.process = null;
802
+ if (run.logStream && !run.logStream.destroyed) {
803
+ run.logStream.end();
804
+ }
805
+ this.writeAgentStatus(issue.identifier, run);
806
+ if (code === 0) {
807
+ logger.info("Agent completed (cli)", {
808
+ identifier: issue.identifier,
809
+ toolCalls: run.toolCalls,
810
+ resultLength: lastResultText.length
811
+ });
812
+ resolve();
813
+ } else {
814
+ reject(
815
+ new Error(
816
+ `Claude exited with code ${code}: ${stderr.slice(0, 500)}`
817
+ )
818
+ );
819
+ }
820
+ });
821
+ });
822
+ }
823
+ /**
824
+ * Adapter mode: spawn claude-app-server.cjs via JSON-RPC protocol.
825
+ * Uses ANTHROPIC_API_KEY for auth.
826
+ */
827
+ runAgentAdapter(issue, run) {
828
+ return new Promise((resolve, reject) => {
829
+ const prompt = this.buildPrompt(issue, run.attempt);
830
+ const env = { ...process.env };
831
+ delete env.CLAUDECODE;
832
+ delete env.ANTHROPIC_API_KEY;
498
833
  const proc = spawn("node", [this.config.appServerPath], {
499
834
  cwd: run.workspacePath,
500
835
  env: {
501
- ...process.env,
836
+ ...env,
502
837
  SYMPHONY_WORKSPACE_DIR: run.workspacePath,
503
838
  SYMPHONY_ISSUE_ID: issue.id,
504
839
  SYMPHONY_ISSUE_IDENTIFIER: issue.identifier,
@@ -507,6 +842,11 @@ class Conductor {
507
842
  stdio: ["pipe", "pipe", "pipe"]
508
843
  });
509
844
  run.process = proc;
845
+ const logStream = this.openAgentLogStream(issue.identifier);
846
+ run.logStream = logStream;
847
+ const tee = new TeeTransform(logStream);
848
+ proc.stdout.pipe(tee);
849
+ this.writeAgentStatus(issue.identifier, run);
510
850
  let stderr = "";
511
851
  let turnCompleted = false;
512
852
  const timer = setTimeout(() => {
@@ -525,7 +865,7 @@ class Conductor {
525
865
  proc.stdin.write(JSON.stringify(msg) + "\n");
526
866
  };
527
867
  let lineBuffer = "";
528
- proc.stdout.on("data", (chunk) => {
868
+ tee.on("data", (chunk) => {
529
869
  lineBuffer += chunk.toString();
530
870
  const lines = lineBuffer.split("\n");
531
871
  lineBuffer = lines.pop() || "";
@@ -534,11 +874,33 @@ class Conductor {
534
874
  try {
535
875
  const msg = JSON.parse(line);
536
876
  this.handleAgentMessage(msg, issue, run);
877
+ const phase = inferPhase(msg);
878
+ if (phase) {
879
+ run.phase = phase;
880
+ }
881
+ if (msg.method === "item/commandExecution/started" || msg.method === "item/toolUse/started") {
882
+ run.toolCalls++;
883
+ }
884
+ const params = msg.params;
885
+ if (msg.method === "item/commandExecution/started" && params) {
886
+ const tool = (params.tool || params.name || "").toLowerCase();
887
+ if (tool.includes("edit") || tool.includes("write")) {
888
+ run.filesModified++;
889
+ }
890
+ }
891
+ if (msg.method === "item/text" && params?.text) {
892
+ run.tokensUsed += Math.ceil(params.text.length / 4);
893
+ }
894
+ if (run.toolCalls % 5 === 0 || phase) {
895
+ this.writeAgentStatus(issue.identifier, run);
896
+ }
537
897
  if (msg.method === "turn/completed") {
538
898
  turnCompleted = true;
899
+ this.writeAgentStatus(issue.identifier, run);
539
900
  }
540
901
  if (msg.method === "turn/failed") {
541
902
  turnCompleted = true;
903
+ this.writeAgentStatus(issue.identifier, run);
542
904
  const errMsg = msg.params?.error?.message || "Agent turn failed";
543
905
  clearTimeout(timer);
544
906
  reject(new Error(errMsg));
@@ -562,6 +924,10 @@ class Conductor {
562
924
  proc.on("close", (code) => {
563
925
  clearTimeout(timer);
564
926
  run.process = null;
927
+ if (run.logStream && !run.logStream.destroyed) {
928
+ run.logStream.end();
929
+ }
930
+ this.writeAgentStatus(issue.identifier, run);
565
931
  if (turnCompleted) {
566
932
  resolve();
567
933
  } else if (code === 0) {
@@ -800,5 +1166,6 @@ class Conductor {
800
1166
  }
801
1167
  }
802
1168
  export {
803
- Conductor
1169
+ Conductor,
1170
+ getAgentStatusDir
804
1171
  };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@stackmemoryai/stackmemory",
3
- "version": "1.5.2",
4
- "description": "Project-scoped memory for AI coding tools. Durable context across sessions with 56 MCP tools, FTS5 search, Claude/Codex/OpenCode wrappers, Linear sync, automatic hooks, and log analysis.",
3
+ "version": "1.5.4",
4
+ "description": "Lossless, project-scoped memory for AI coding tools. Durable context across sessions with 56 MCP tools, FTS5 search, conductor orchestrator, loop/watch monitoring, snapshot capture, pre-flight overlap checks, Claude/Codex/OpenCode wrappers, Linear sync, and automatic hooks.",
5
5
  "engines": {
6
6
  "node": ">=20.0.0",
7
7
  "npm": ">=10.0.0"