substrate-ai 0.1.20 → 0.1.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli/index.js CHANGED
@@ -3,6 +3,7 @@ import { AdapterRegistry, ConfigError, ConfigIncompatibleFormatError, DatabaseWr
3
3
  import { CURRENT_CONFIG_FORMAT_VERSION, CURRENT_TASK_GRAPH_VERSION, PartialSubstrateConfigSchema, SUPPORTED_CONFIG_FORMAT_VERSIONS, SubstrateConfigSchema } from "../config-schema-C9tTMcm1.js";
4
4
  import { defaultConfigMigrator } from "../version-manager-impl-O25ieEjS.js";
5
5
  import { registerUpgradeCommand } from "../upgrade-CHhsJc_q.js";
6
+ import { createRequire } from "module";
6
7
  import { Command } from "commander";
7
8
  import { fileURLToPath } from "url";
8
9
  import { dirname, extname, isAbsolute, join, relative, resolve } from "path";
@@ -23,8 +24,12 @@ import * as readline$1 from "readline";
23
24
  import * as readline from "readline";
24
25
  import { createInterface as createInterface$1 } from "readline";
25
26
  import { randomUUID as randomUUID$1 } from "crypto";
26
- import { createRequire } from "node:module";
27
+ import { createRequire as createRequire$1 } from "node:module";
27
28
 
29
+ //#region rolldown:runtime
30
+ var __require = /* @__PURE__ */ createRequire(import.meta.url);
31
+
32
+ //#endregion
28
33
  //#region src/cli/utils/formatting.ts
29
34
  /**
30
35
  * Build adapter list rows from discovery results.
@@ -6492,6 +6497,70 @@ const PIPELINE_EVENT_METADATA = [
6492
6497
  description: "Log message."
6493
6498
  }
6494
6499
  ]
6500
+ },
6501
+ {
6502
+ type: "pipeline:heartbeat",
6503
+ description: "Periodic heartbeat emitted every 30s when no other progress events have fired.",
6504
+ when: "Every 30 seconds during pipeline execution. Allows detection of stalled pipelines.",
6505
+ fields: [
6506
+ {
6507
+ name: "ts",
6508
+ type: "string",
6509
+ description: "ISO-8601 timestamp generated at emit time."
6510
+ },
6511
+ {
6512
+ name: "run_id",
6513
+ type: "string",
6514
+ description: "Pipeline run ID."
6515
+ },
6516
+ {
6517
+ name: "active_dispatches",
6518
+ type: "number",
6519
+ description: "Number of sub-agents currently running."
6520
+ },
6521
+ {
6522
+ name: "completed_dispatches",
6523
+ type: "number",
6524
+ description: "Number of dispatches completed."
6525
+ },
6526
+ {
6527
+ name: "queued_dispatches",
6528
+ type: "number",
6529
+ description: "Number of dispatches waiting to start."
6530
+ }
6531
+ ]
6532
+ },
6533
+ {
6534
+ type: "story:stall",
6535
+ description: "Emitted when the watchdog detects no progress for an extended period (default: 10 minutes).",
6536
+ when: "When a story has shown no progress for longer than the watchdog timeout. Indicates likely stall.",
6537
+ fields: [
6538
+ {
6539
+ name: "ts",
6540
+ type: "string",
6541
+ description: "ISO-8601 timestamp generated at emit time."
6542
+ },
6543
+ {
6544
+ name: "run_id",
6545
+ type: "string",
6546
+ description: "Pipeline run ID."
6547
+ },
6548
+ {
6549
+ name: "story_key",
6550
+ type: "string",
6551
+ description: "Story key that appears stalled."
6552
+ },
6553
+ {
6554
+ name: "phase",
6555
+ type: "string",
6556
+ description: "Phase the story was in when stall was detected."
6557
+ },
6558
+ {
6559
+ name: "elapsed_ms",
6560
+ type: "number",
6561
+ description: "Milliseconds since last progress event."
6562
+ }
6563
+ ]
6495
6564
  }
6496
6565
  ];
6497
6566
  /**
@@ -10066,6 +10135,10 @@ function createImplementationOrchestrator(deps) {
10066
10135
  const _stories = new Map();
10067
10136
  let _paused = false;
10068
10137
  let _pauseGate = null;
10138
+ let _lastProgressTs = Date.now();
10139
+ let _heartbeatTimer = null;
10140
+ const HEARTBEAT_INTERVAL_MS = 3e4;
10141
+ const WATCHDOG_TIMEOUT_MS = 6e5;
10069
10142
  function getStatus() {
10070
10143
  const stories = {};
10071
10144
  for (const [key, s] of _stories) stories[key] = { ...s };
@@ -10087,6 +10160,7 @@ function createImplementationOrchestrator(deps) {
10087
10160
  }
10088
10161
  function persistState() {
10089
10162
  if (config.pipelineRunId === void 0) return;
10163
+ recordProgress();
10090
10164
  try {
10091
10165
  const serialized = JSON.stringify(getStatus());
10092
10166
  updatePipelineRun(db, config.pipelineRunId, {
@@ -10097,6 +10171,50 @@ function createImplementationOrchestrator(deps) {
10097
10171
  logger$31.warn("Failed to persist orchestrator state", { err });
10098
10172
  }
10099
10173
  }
10174
+ function recordProgress() {
10175
+ _lastProgressTs = Date.now();
10176
+ }
10177
+ function startHeartbeat() {
10178
+ if (_heartbeatTimer !== null) return;
10179
+ _heartbeatTimer = setInterval(() => {
10180
+ if (_state !== "RUNNING") return;
10181
+ let active = 0;
10182
+ let completed = 0;
10183
+ let queued = 0;
10184
+ for (const s of _stories.values()) if (s.phase === "COMPLETE" || s.phase === "ESCALATED") completed++;
10185
+ else if (s.phase === "PENDING") queued++;
10186
+ else active++;
10187
+ eventBus.emit("orchestrator:heartbeat", {
10188
+ runId: config.pipelineRunId ?? "",
10189
+ activeDispatches: active,
10190
+ completedDispatches: completed,
10191
+ queuedDispatches: queued
10192
+ });
10193
+ const elapsed = Date.now() - _lastProgressTs;
10194
+ if (elapsed >= WATCHDOG_TIMEOUT_MS) {
10195
+ for (const [key, s] of _stories) if (s.phase !== "PENDING" && s.phase !== "COMPLETE" && s.phase !== "ESCALATED") {
10196
+ logger$31.warn({
10197
+ storyKey: key,
10198
+ phase: s.phase,
10199
+ elapsedMs: elapsed
10200
+ }, "Watchdog: possible stall detected");
10201
+ eventBus.emit("orchestrator:stall", {
10202
+ runId: config.pipelineRunId ?? "",
10203
+ storyKey: key,
10204
+ phase: s.phase,
10205
+ elapsedMs: elapsed
10206
+ });
10207
+ }
10208
+ }
10209
+ }, HEARTBEAT_INTERVAL_MS);
10210
+ if (_heartbeatTimer && typeof _heartbeatTimer === "object" && "unref" in _heartbeatTimer) _heartbeatTimer.unref();
10211
+ }
10212
+ function stopHeartbeat() {
10213
+ if (_heartbeatTimer !== null) {
10214
+ clearInterval(_heartbeatTimer);
10215
+ _heartbeatTimer = null;
10216
+ }
10217
+ }
10100
10218
  /**
10101
10219
  * Wait until the orchestrator is un-paused (if currently paused).
10102
10220
  */
@@ -10814,6 +10932,8 @@ function createImplementationOrchestrator(deps) {
10814
10932
  pipelineRunId: config.pipelineRunId
10815
10933
  });
10816
10934
  persistState();
10935
+ recordProgress();
10936
+ startHeartbeat();
10817
10937
  if (projectRoot !== void 0) {
10818
10938
  const seedResult = seedMethodologyContext(db, projectRoot);
10819
10939
  if (seedResult.decisionsCreated > 0) logger$31.info({
@@ -10830,12 +10950,14 @@ function createImplementationOrchestrator(deps) {
10830
10950
  try {
10831
10951
  await runWithConcurrency(groups, config.maxConcurrency);
10832
10952
  } catch (err) {
10953
+ stopHeartbeat();
10833
10954
  _state = "FAILED";
10834
10955
  _completedAt = new Date().toISOString();
10835
10956
  persistState();
10836
10957
  logger$31.error("Orchestrator failed with unhandled error", { err });
10837
10958
  return getStatus();
10838
10959
  }
10960
+ stopHeartbeat();
10839
10961
  _state = "COMPLETE";
10840
10962
  _completedAt = new Date().toISOString();
10841
10963
  let completed = 0;
@@ -13027,8 +13149,8 @@ const PACKAGE_ROOT = join(__dirname, "..", "..", "..");
13027
13149
  */
13028
13150
  function resolveBmadMethodSrcPath(fromDir = __dirname) {
13029
13151
  try {
13030
- const require = createRequire(join(fromDir, "synthetic.js"));
13031
- const pkgJsonPath = require.resolve("bmad-method/package.json");
13152
+ const require$1 = createRequire$1(join(fromDir, "synthetic.js"));
13153
+ const pkgJsonPath = require$1.resolve("bmad-method/package.json");
13032
13154
  return join(dirname(pkgJsonPath), "src");
13033
13155
  } catch {
13034
13156
  return null;
@@ -13040,9 +13162,9 @@ function resolveBmadMethodSrcPath(fromDir = __dirname) {
13040
13162
  */
13041
13163
  function resolveBmadMethodVersion(fromDir = __dirname) {
13042
13164
  try {
13043
- const require = createRequire(join(fromDir, "synthetic.js"));
13044
- const pkgJsonPath = require.resolve("bmad-method/package.json");
13045
- const pkg = require(pkgJsonPath);
13165
+ const require$1 = createRequire$1(join(fromDir, "synthetic.js"));
13166
+ const pkgJsonPath = require$1.resolve("bmad-method/package.json");
13167
+ const pkg = require$1(pkgJsonPath);
13046
13168
  return pkg.version ?? "unknown";
13047
13169
  } catch {
13048
13170
  return "unknown";
@@ -13163,7 +13285,9 @@ function buildPipelineStatusOutput(run, tokenSummary, decisionsCount, storiesCou
13163
13285
  cost_usd: totalCost
13164
13286
  },
13165
13287
  decisions_count: decisionsCount,
13166
- stories_count: storiesCount
13288
+ stories_count: storiesCount,
13289
+ last_activity: run.updated_at,
13290
+ staleness_seconds: Math.round((Date.now() - new Date(run.updated_at).getTime()) / 1e3)
13167
13291
  };
13168
13292
  }
13169
13293
  /**
@@ -13799,6 +13923,26 @@ async function runAutoRun(options) {
13799
13923
  msg: payload.msg
13800
13924
  });
13801
13925
  });
13926
+ eventBus.on("orchestrator:heartbeat", (payload) => {
13927
+ ndjsonEmitter.emit({
13928
+ type: "pipeline:heartbeat",
13929
+ ts: new Date().toISOString(),
13930
+ run_id: payload.runId,
13931
+ active_dispatches: payload.activeDispatches,
13932
+ completed_dispatches: payload.completedDispatches,
13933
+ queued_dispatches: payload.queuedDispatches
13934
+ });
13935
+ });
13936
+ eventBus.on("orchestrator:stall", (payload) => {
13937
+ ndjsonEmitter.emit({
13938
+ type: "story:stall",
13939
+ ts: new Date().toISOString(),
13940
+ run_id: payload.runId,
13941
+ story_key: payload.storyKey,
13942
+ phase: payload.phase,
13943
+ elapsed_ms: payload.elapsedMs
13944
+ });
13945
+ });
13802
13946
  }
13803
13947
  const orchestrator = createImplementationOrchestrator({
13804
13948
  db,
@@ -14501,6 +14645,175 @@ async function runAutoStatus(options) {
14501
14645
  } catch {}
14502
14646
  }
14503
14647
  }
14648
+ function inspectProcessTree() {
14649
+ const result = {
14650
+ orchestrator_pid: null,
14651
+ child_pids: [],
14652
+ zombies: []
14653
+ };
14654
+ try {
14655
+ const { execFileSync } = __require("node:child_process");
14656
+ const psOutput = execFileSync("ps", ["-eo", "pid,ppid,stat,command"], {
14657
+ encoding: "utf-8",
14658
+ timeout: 5e3
14659
+ });
14660
+ const lines = psOutput.split("\n");
14661
+ for (const line of lines) if (line.includes("substrate auto run") && !line.includes("grep")) {
14662
+ const match = line.trim().match(/^(\d+)/);
14663
+ if (match) {
14664
+ result.orchestrator_pid = parseInt(match[1], 10);
14665
+ break;
14666
+ }
14667
+ }
14668
+ if (result.orchestrator_pid !== null) for (const line of lines) {
14669
+ const parts = line.trim().split(/\s+/);
14670
+ if (parts.length >= 3) {
14671
+ const pid = parseInt(parts[0], 10);
14672
+ const ppid = parseInt(parts[1], 10);
14673
+ const stat$2 = parts[2];
14674
+ if (ppid === result.orchestrator_pid && pid !== result.orchestrator_pid) {
14675
+ result.child_pids.push(pid);
14676
+ if (stat$2.includes("Z")) result.zombies.push(pid);
14677
+ }
14678
+ }
14679
+ }
14680
+ } catch {}
14681
+ return result;
14682
+ }
14683
+ async function runAutoHealth(options) {
14684
+ const { outputFormat, runId, projectRoot } = options;
14685
+ const dbRoot = await resolveMainRepoRoot(projectRoot);
14686
+ const dbPath = join(dbRoot, ".substrate", "substrate.db");
14687
+ if (!existsSync(dbPath)) {
14688
+ const output = {
14689
+ verdict: "NO_PIPELINE_RUNNING",
14690
+ run_id: null,
14691
+ status: null,
14692
+ current_phase: null,
14693
+ staleness_seconds: 0,
14694
+ last_activity: "",
14695
+ process: {
14696
+ orchestrator_pid: null,
14697
+ child_pids: [],
14698
+ zombies: []
14699
+ },
14700
+ stories: {
14701
+ active: 0,
14702
+ completed: 0,
14703
+ escalated: 0,
14704
+ details: {}
14705
+ }
14706
+ };
14707
+ if (outputFormat === "json") process.stdout.write(formatOutput(output, "json", true) + "\n");
14708
+ else process.stdout.write("NO_PIPELINE_RUNNING — no substrate database found\n");
14709
+ return 0;
14710
+ }
14711
+ const dbWrapper = new DatabaseWrapper(dbPath);
14712
+ try {
14713
+ dbWrapper.open();
14714
+ const db = dbWrapper.db;
14715
+ let run;
14716
+ if (runId !== void 0) run = getPipelineRunById(db, runId);
14717
+ else run = getLatestRun(db);
14718
+ if (run === void 0) {
14719
+ const output$1 = {
14720
+ verdict: "NO_PIPELINE_RUNNING",
14721
+ run_id: null,
14722
+ status: null,
14723
+ current_phase: null,
14724
+ staleness_seconds: 0,
14725
+ last_activity: "",
14726
+ process: {
14727
+ orchestrator_pid: null,
14728
+ child_pids: [],
14729
+ zombies: []
14730
+ },
14731
+ stories: {
14732
+ active: 0,
14733
+ completed: 0,
14734
+ escalated: 0,
14735
+ details: {}
14736
+ }
14737
+ };
14738
+ if (outputFormat === "json") process.stdout.write(formatOutput(output$1, "json", true) + "\n");
14739
+ else process.stdout.write("NO_PIPELINE_RUNNING — no pipeline runs found\n");
14740
+ return 0;
14741
+ }
14742
+ const updatedAt = new Date(run.updated_at);
14743
+ const stalenessSeconds = Math.round((Date.now() - updatedAt.getTime()) / 1e3);
14744
+ let storyDetails = {};
14745
+ let active = 0;
14746
+ let completed = 0;
14747
+ let escalated = 0;
14748
+ try {
14749
+ if (run.token_usage_json) {
14750
+ const state = JSON.parse(run.token_usage_json);
14751
+ if (state.stories) for (const [key, s] of Object.entries(state.stories)) {
14752
+ storyDetails[key] = {
14753
+ phase: s.phase,
14754
+ review_cycles: s.reviewCycles
14755
+ };
14756
+ if (s.phase === "COMPLETE") completed++;
14757
+ else if (s.phase === "ESCALATED") escalated++;
14758
+ else if (s.phase !== "PENDING") active++;
14759
+ }
14760
+ }
14761
+ } catch {}
14762
+ const processInfo = inspectProcessTree();
14763
+ let verdict = "NO_PIPELINE_RUNNING";
14764
+ if (run.status === "running") if (processInfo.zombies.length > 0) verdict = "STALLED";
14765
+ else if (stalenessSeconds > 600) verdict = "STALLED";
14766
+ else if (processInfo.orchestrator_pid !== null && processInfo.child_pids.length === 0 && active > 0) verdict = "STALLED";
14767
+ else verdict = "HEALTHY";
14768
+ else if (run.status === "completed" || run.status === "failed" || run.status === "stopped") verdict = "NO_PIPELINE_RUNNING";
14769
+ const output = {
14770
+ verdict,
14771
+ run_id: run.id,
14772
+ status: run.status,
14773
+ current_phase: run.current_phase,
14774
+ staleness_seconds: stalenessSeconds,
14775
+ last_activity: run.updated_at,
14776
+ process: processInfo,
14777
+ stories: {
14778
+ active,
14779
+ completed,
14780
+ escalated,
14781
+ details: storyDetails
14782
+ }
14783
+ };
14784
+ if (outputFormat === "json") process.stdout.write(formatOutput(output, "json", true) + "\n");
14785
+ else {
14786
+ const verdictLabel = verdict === "HEALTHY" ? "HEALTHY" : verdict === "STALLED" ? "STALLED" : "NO PIPELINE RUNNING";
14787
+ process.stdout.write(`\nPipeline Health: ${verdictLabel}\n`);
14788
+ process.stdout.write(` Run: ${run.id}\n`);
14789
+ process.stdout.write(` Status: ${run.status}\n`);
14790
+ process.stdout.write(` Phase: ${run.current_phase ?? "N/A"}\n`);
14791
+ process.stdout.write(` Last Active: ${run.updated_at} (${stalenessSeconds}s ago)\n`);
14792
+ if (processInfo.orchestrator_pid !== null) {
14793
+ process.stdout.write(` Orchestrator: PID ${processInfo.orchestrator_pid}\n`);
14794
+ process.stdout.write(` Children: ${processInfo.child_pids.length} active`);
14795
+ if (processInfo.zombies.length > 0) process.stdout.write(` (${processInfo.zombies.length} ZOMBIE)`);
14796
+ process.stdout.write("\n");
14797
+ } else process.stdout.write(" Orchestrator: not running\n");
14798
+ if (Object.keys(storyDetails).length > 0) {
14799
+ process.stdout.write("\n Stories:\n");
14800
+ for (const [key, s] of Object.entries(storyDetails)) process.stdout.write(` ${key}: ${s.phase} (${s.review_cycles} review cycles)\n`);
14801
+ process.stdout.write(`\n Summary: ${active} active, ${completed} completed, ${escalated} escalated\n`);
14802
+ }
14803
+ }
14804
+ return 0;
14805
+ } catch (err) {
14806
+ const msg = err instanceof Error ? err.message : String(err);
14807
+ if (outputFormat === "json") process.stdout.write(formatOutput(null, "json", false, msg) + "\n");
14808
+ else process.stderr.write(`Error: ${msg}\n`);
14809
+ logger$3.error({ err }, "auto health failed");
14810
+ return 1;
14811
+ } finally {
14812
+ try {
14813
+ dbWrapper.close();
14814
+ } catch {}
14815
+ }
14816
+ }
14504
14817
  /**
14505
14818
  * Detect and apply supersessions after a phase completes in an amendment run.
14506
14819
  *
@@ -14869,6 +15182,15 @@ function registerAutoCommand(program, _version = "0.0.0", projectRoot = process.
14869
15182
  });
14870
15183
  process.exitCode = exitCode;
14871
15184
  });
15185
+ auto.command("health").description("Check pipeline health: process status, stall detection, and verdict").option("--run-id <id>", "Pipeline run ID to query (defaults to latest)").option("--project-root <path>", "Project root directory", projectRoot).option("--output-format <format>", "Output format: human (default) or json", "human").action(async (opts) => {
15186
+ const outputFormat = opts.outputFormat === "json" ? "json" : "human";
15187
+ const exitCode = await runAutoHealth({
15188
+ outputFormat,
15189
+ runId: opts.runId,
15190
+ projectRoot: opts.projectRoot
15191
+ });
15192
+ process.exitCode = exitCode;
15193
+ });
14872
15194
  auto.command("amend").description("Run an amendment pipeline against a completed run and an existing run").option("--concept <text>", "Amendment concept description (inline)").option("--concept-file <path>", "Path to concept file").option("--run-id <id>", "Parent run ID (defaults to latest completed run)").option("--stop-after <phase>", "Stop pipeline after this phase completes").option("--from <phase>", "Start pipeline from this phase").option("--pack <name>", "Methodology pack name", "bmad").option("--project-root <path>", "Project root directory", projectRoot).option("--output-format <format>", "Output format: human (default) or json", "human").action(async (opts) => {
14873
15195
  const exitCode = await runAmendCommand({
14874
15196
  concept: opts.concept,
package/dist/index.d.ts CHANGED
@@ -197,6 +197,40 @@ interface StoryLogEvent {
197
197
  /** Log message */
198
198
  msg: string;
199
199
  }
200
+ /**
201
+ * Emitted periodically (every 30s) when no other progress events have fired.
202
+ * Allows parent agents to distinguish "working silently" from "stuck".
203
+ */
204
+ interface PipelineHeartbeatEvent {
205
+ type: 'pipeline:heartbeat';
206
+ /** ISO-8601 timestamp generated at emit time */
207
+ ts: string;
208
+ /** Unique identifier for the current pipeline run */
209
+ run_id: string;
210
+ /** Number of sub-agent dispatches currently running */
211
+ active_dispatches: number;
212
+ /** Number of dispatches that have completed */
213
+ completed_dispatches: number;
214
+ /** Number of dispatches waiting to start */
215
+ queued_dispatches: number;
216
+ }
217
+ /**
218
+ * Emitted when the watchdog timer detects no progress for an extended period.
219
+ * Indicates a likely stall that may require operator intervention.
220
+ */
221
+ interface StoryStallEvent {
222
+ type: 'story:stall';
223
+ /** ISO-8601 timestamp generated at emit time */
224
+ ts: string;
225
+ /** Unique identifier for the current pipeline run */
226
+ run_id: string;
227
+ /** Story key that appears stalled */
228
+ story_key: string;
229
+ /** Phase the story was in when the stall was detected */
230
+ phase: string;
231
+ /** Milliseconds since the last progress event */
232
+ elapsed_ms: number;
233
+ }
200
234
  /**
201
235
  * Discriminated union of all pipeline event types.
202
236
  *
@@ -209,7 +243,7 @@ interface StoryLogEvent {
209
243
  * }
210
244
  * ```
211
245
  */
212
- type PipelineEvent = PipelineStartEvent | PipelineCompleteEvent | StoryPhaseEvent | StoryDoneEvent | StoryEscalationEvent | StoryWarnEvent | StoryLogEvent; //#endregion
246
+ type PipelineEvent = PipelineStartEvent | PipelineCompleteEvent | StoryPhaseEvent | StoryDoneEvent | StoryEscalationEvent | StoryWarnEvent | StoryLogEvent | PipelineHeartbeatEvent | StoryStallEvent; //#endregion
213
247
  //#region src/core/errors.d.ts
214
248
 
215
249
  /**
@@ -793,6 +827,20 @@ interface OrchestratorEvents {
793
827
  'orchestrator:paused': Record<string, never>;
794
828
  /** Implementation orchestrator has been resumed */
795
829
  'orchestrator:resumed': Record<string, never>;
830
+ /** Periodic heartbeat emitted every 30s during pipeline execution */
831
+ 'orchestrator:heartbeat': {
832
+ runId: string;
833
+ activeDispatches: number;
834
+ completedDispatches: number;
835
+ queuedDispatches: number;
836
+ };
837
+ /** Watchdog detected no progress for an extended period */
838
+ 'orchestrator:stall': {
839
+ runId: string;
840
+ storyKey: string;
841
+ phase: string;
842
+ elapsedMs: number;
843
+ };
796
844
  }
797
845
 
798
846
  //#endregion
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "substrate-ai",
3
- "version": "0.1.20",
3
+ "version": "0.1.21",
4
4
  "description": "Substrate — multi-agent orchestration daemon for AI coding agents",
5
5
  "type": "module",
6
6
  "license": "MIT",