@os-eco/overstory-cli 0.6.11 → 0.7.2

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 (87) hide show
  1. package/README.md +12 -13
  2. package/agents/builder.md +1 -1
  3. package/agents/coordinator.md +12 -11
  4. package/agents/lead.md +25 -24
  5. package/agents/monitor.md +4 -4
  6. package/agents/reviewer.md +1 -1
  7. package/agents/scout.md +5 -5
  8. package/agents/supervisor.md +36 -32
  9. package/package.json +5 -3
  10. package/src/agents/guard-rules.ts +97 -0
  11. package/src/agents/hooks-deployer.ts +7 -90
  12. package/src/agents/overlay.test.ts +30 -7
  13. package/src/agents/overlay.ts +10 -9
  14. package/src/commands/agents.test.ts +5 -0
  15. package/src/commands/clean.test.ts +3 -0
  16. package/src/commands/completions.ts +1 -1
  17. package/src/commands/coordinator.test.ts +1 -0
  18. package/src/commands/coordinator.ts +34 -18
  19. package/src/commands/costs.test.ts +6 -1
  20. package/src/commands/costs.ts +13 -20
  21. package/src/commands/dashboard.ts +38 -138
  22. package/src/commands/doctor.test.ts +1 -1
  23. package/src/commands/doctor.ts +2 -2
  24. package/src/commands/ecosystem.ts +2 -1
  25. package/src/commands/errors.test.ts +4 -5
  26. package/src/commands/errors.ts +4 -62
  27. package/src/commands/feed.test.ts +2 -2
  28. package/src/commands/feed.ts +12 -106
  29. package/src/commands/init.test.ts +1 -2
  30. package/src/commands/init.ts +1 -8
  31. package/src/commands/inspect.test.ts +14 -0
  32. package/src/commands/inspect.ts +10 -44
  33. package/src/commands/log.test.ts +14 -0
  34. package/src/commands/log.ts +39 -0
  35. package/src/commands/logs.ts +7 -63
  36. package/src/commands/mail.test.ts +5 -0
  37. package/src/commands/metrics.test.ts +2 -2
  38. package/src/commands/metrics.ts +3 -17
  39. package/src/commands/monitor.ts +30 -16
  40. package/src/commands/nudge.test.ts +1 -0
  41. package/src/commands/prime.test.ts +2 -0
  42. package/src/commands/prime.ts +6 -2
  43. package/src/commands/replay.test.ts +2 -2
  44. package/src/commands/replay.ts +12 -135
  45. package/src/commands/run.test.ts +1 -0
  46. package/src/commands/run.ts +7 -23
  47. package/src/commands/sling.test.ts +68 -1
  48. package/src/commands/sling.ts +62 -24
  49. package/src/commands/status.test.ts +1 -0
  50. package/src/commands/status.ts +4 -17
  51. package/src/commands/stop.test.ts +1 -0
  52. package/src/commands/supervisor.ts +35 -18
  53. package/src/commands/trace.test.ts +6 -6
  54. package/src/commands/trace.ts +11 -109
  55. package/src/commands/worktree.test.ts +9 -0
  56. package/src/config.ts +39 -0
  57. package/src/doctor/consistency.test.ts +14 -0
  58. package/src/e2e/init-sling-lifecycle.test.ts +3 -5
  59. package/src/index.ts +2 -1
  60. package/src/logging/format.ts +214 -0
  61. package/src/logging/theme.ts +132 -0
  62. package/src/mail/broadcast.test.ts +1 -0
  63. package/src/merge/resolver.ts +23 -4
  64. package/src/metrics/store.test.ts +46 -0
  65. package/src/metrics/store.ts +11 -0
  66. package/src/mulch/client.test.ts +20 -0
  67. package/src/mulch/client.ts +312 -45
  68. package/src/runtimes/claude.test.ts +616 -0
  69. package/src/runtimes/claude.ts +218 -0
  70. package/src/runtimes/pi-guards.test.ts +433 -0
  71. package/src/runtimes/pi-guards.ts +349 -0
  72. package/src/runtimes/pi.test.ts +620 -0
  73. package/src/runtimes/pi.ts +244 -0
  74. package/src/runtimes/registry.test.ts +86 -0
  75. package/src/runtimes/registry.ts +46 -0
  76. package/src/runtimes/types.ts +188 -0
  77. package/src/schema-consistency.test.ts +1 -0
  78. package/src/sessions/compat.ts +1 -0
  79. package/src/sessions/store.test.ts +31 -0
  80. package/src/sessions/store.ts +37 -4
  81. package/src/types.ts +21 -0
  82. package/src/watchdog/daemon.test.ts +7 -4
  83. package/src/watchdog/daemon.ts +1 -1
  84. package/src/watchdog/health.test.ts +1 -0
  85. package/src/watchdog/triage.ts +14 -4
  86. package/src/worktree/tmux.test.ts +28 -13
  87. package/src/worktree/tmux.ts +14 -28
@@ -399,6 +399,52 @@ describe("getSessionsByRun", () => {
399
399
  });
400
400
  });
401
401
 
402
+ // === countSessions ===
403
+
404
+ describe("countSessions", () => {
405
+ test("returns 0 for empty database", () => {
406
+ expect(store.countSessions()).toBe(0);
407
+ });
408
+
409
+ test("returns total count of sessions", () => {
410
+ store.recordSession(makeSession({ agentName: "a1", taskId: "t1" }));
411
+ store.recordSession(makeSession({ agentName: "a2", taskId: "t2" }));
412
+ store.recordSession(makeSession({ agentName: "a3", taskId: "t3" }));
413
+
414
+ expect(store.countSessions()).toBe(3);
415
+ });
416
+
417
+ test("returns accurate count beyond getRecentSessions default limit", () => {
418
+ // Insert 25 sessions (more than the default limit of 20)
419
+ for (let i = 0; i < 25; i++) {
420
+ store.recordSession(
421
+ makeSession({
422
+ agentName: `agent-${i}`,
423
+ taskId: `task-${i}`,
424
+ startedAt: new Date(Date.now() + i * 1000).toISOString(),
425
+ }),
426
+ );
427
+ }
428
+
429
+ // getRecentSessions is capped at 20 by default
430
+ expect(store.getRecentSessions().length).toBe(20);
431
+ // countSessions returns the true total without a cap
432
+ expect(store.countSessions()).toBe(25);
433
+ });
434
+
435
+ test("count updates after purge", () => {
436
+ store.recordSession(makeSession({ agentName: "a1", taskId: "t1" }));
437
+ store.recordSession(makeSession({ agentName: "a2", taskId: "t2" }));
438
+ expect(store.countSessions()).toBe(2);
439
+
440
+ store.purge({ agent: "a1" });
441
+ expect(store.countSessions()).toBe(1);
442
+
443
+ store.purge({ all: true });
444
+ expect(store.countSessions()).toBe(0);
445
+ });
446
+ });
447
+
402
448
  // === purge ===
403
449
 
404
450
  describe("purge", () => {
@@ -14,6 +14,8 @@ export interface MetricsStore {
14
14
  getSessionsByAgent(agentName: string): SessionMetrics[];
15
15
  getSessionsByRun(runId: string): SessionMetrics[];
16
16
  getAverageDuration(capability?: string): number;
17
+ /** Count the total number of sessions in the database (no limit cap). */
18
+ countSessions(): number;
17
19
  /** Delete metrics matching the given criteria. Returns the number of rows deleted. */
18
20
  purge(options: { all?: boolean; agent?: string }): number;
19
21
  /** Record a token usage snapshot for a running agent. */
@@ -252,6 +254,10 @@ export function createMetricsStore(dbPath: string): MetricsStore {
252
254
  SELECT AVG(duration_ms) AS avg_duration FROM sessions WHERE completed_at IS NOT NULL
253
255
  `);
254
256
 
257
+ const countSessionsStmt = db.prepare<{ cnt: number }, Record<string, never>>(`
258
+ SELECT COUNT(*) as cnt FROM sessions
259
+ `);
260
+
255
261
  const avgDurationByCapStmt = db.prepare<
256
262
  { avg_duration: number | null },
257
263
  { $capability: string }
@@ -345,6 +351,11 @@ export function createMetricsStore(dbPath: string): MetricsStore {
345
351
  return row?.avg_duration ?? 0;
346
352
  },
347
353
 
354
+ countSessions(): number {
355
+ const row = countSessionsStmt.get({});
356
+ return row?.cnt ?? 0;
357
+ },
358
+
348
359
  purge(options: { all?: boolean; agent?: string }): number {
349
360
  if (options.all) {
350
361
  const countRow = db
@@ -451,6 +451,26 @@ describe("createMulchClient", () => {
451
451
  const result = await client.search("test", { file: "src/config.ts", sortByScore: true });
452
452
  expect(typeof result).toBe("string");
453
453
  });
454
+
455
+ test.skipIf(!hasMulch)("roundtrip: record via API then search and find it", async () => {
456
+ await initMulch();
457
+ const addProc = Bun.spawn(["ml", "add", "roundtrip"], {
458
+ cwd: tempDir,
459
+ stdout: "pipe",
460
+ stderr: "pipe",
461
+ });
462
+ await addProc.exited;
463
+
464
+ const client = createMulchClient(tempDir);
465
+ await client.record("roundtrip", {
466
+ type: "convention",
467
+ description: "unique-roundtrip-marker-xyz",
468
+ });
469
+
470
+ const result = await client.search("unique-roundtrip-marker-xyz");
471
+ expect(typeof result).toBe("string");
472
+ expect(result).toContain("unique-roundtrip-marker-xyz");
473
+ });
454
474
  });
455
475
 
456
476
  describe("diff", () => {
@@ -2,7 +2,11 @@
2
2
  * Mulch CLI client.
3
3
  *
4
4
  * Wraps the `mulch` command-line tool for structured expertise operations.
5
- * Uses Bun.spawn zero runtime dependencies.
5
+ * record(), search(), and query() use the @os-eco/mulch-cli programmatic API
6
+ * via a variable-based dynamic import so tsc cannot statically resolve the
7
+ * module (avoiding type errors in mulch's raw .ts source files).
8
+ * Remaining methods (prime, status, diff, learn, prune, doctor, ready, compact)
9
+ * remain as Bun.spawn CLI wrappers.
6
10
  */
7
11
 
8
12
  import { AgentError } from "../errors.ts";
@@ -87,6 +91,149 @@ export interface MulchClient {
87
91
  ): Promise<MulchCompactResult>;
88
92
  }
89
93
 
94
+ /**
95
+ * Local type matching @os-eco/mulch-cli ExpertiseRecord.
96
+ * Defined locally to avoid tsc following into mulch's raw .ts source
97
+ * (which conflicts with our noUncheckedIndexedAccess setting).
98
+ */
99
+ type MulchClassification = "foundational" | "tactical" | "observational";
100
+
101
+ interface MulchEvidence {
102
+ commit?: string;
103
+ date?: string;
104
+ issue?: string;
105
+ file?: string;
106
+ bead?: string;
107
+ }
108
+
109
+ interface MulchOutcome {
110
+ status: "success" | "failure" | "partial";
111
+ duration?: number;
112
+ test_results?: string;
113
+ agent?: string;
114
+ notes?: string;
115
+ recorded_at?: string;
116
+ }
117
+
118
+ type MulchExpertiseRecord =
119
+ | {
120
+ type: "convention";
121
+ content: string;
122
+ classification: MulchClassification;
123
+ recorded_at: string;
124
+ id?: string;
125
+ tags?: string[];
126
+ evidence?: MulchEvidence;
127
+ outcomes?: MulchOutcome[];
128
+ relates_to?: string[];
129
+ supersedes?: string[];
130
+ }
131
+ | {
132
+ type: "pattern";
133
+ name: string;
134
+ description: string;
135
+ files?: string[];
136
+ classification: MulchClassification;
137
+ recorded_at: string;
138
+ id?: string;
139
+ tags?: string[];
140
+ evidence?: MulchEvidence;
141
+ outcomes?: MulchOutcome[];
142
+ relates_to?: string[];
143
+ supersedes?: string[];
144
+ }
145
+ | {
146
+ type: "failure";
147
+ description: string;
148
+ resolution: string;
149
+ classification: MulchClassification;
150
+ recorded_at: string;
151
+ id?: string;
152
+ tags?: string[];
153
+ evidence?: MulchEvidence;
154
+ outcomes?: MulchOutcome[];
155
+ relates_to?: string[];
156
+ supersedes?: string[];
157
+ }
158
+ | {
159
+ type: "decision";
160
+ title: string;
161
+ rationale: string;
162
+ classification: MulchClassification;
163
+ recorded_at: string;
164
+ id?: string;
165
+ tags?: string[];
166
+ evidence?: MulchEvidence;
167
+ outcomes?: MulchOutcome[];
168
+ relates_to?: string[];
169
+ supersedes?: string[];
170
+ }
171
+ | {
172
+ type: "reference";
173
+ name: string;
174
+ description: string;
175
+ files?: string[];
176
+ classification: MulchClassification;
177
+ recorded_at: string;
178
+ id?: string;
179
+ tags?: string[];
180
+ evidence?: MulchEvidence;
181
+ outcomes?: MulchOutcome[];
182
+ relates_to?: string[];
183
+ supersedes?: string[];
184
+ }
185
+ | {
186
+ type: "guide";
187
+ name: string;
188
+ description: string;
189
+ classification: MulchClassification;
190
+ recorded_at: string;
191
+ id?: string;
192
+ tags?: string[];
193
+ evidence?: MulchEvidence;
194
+ outcomes?: MulchOutcome[];
195
+ relates_to?: string[];
196
+ supersedes?: string[];
197
+ };
198
+
199
+ /**
200
+ * Interface for mulch programmatic API functions.
201
+ * Uses a dynamic import with a variable specifier so tsc cannot statically
202
+ * resolve the module (avoiding type errors in mulch's raw .ts source files).
203
+ */
204
+ interface MulchProgrammaticApi {
205
+ recordExpertise(
206
+ domain: string,
207
+ record: MulchExpertiseRecord,
208
+ options?: { force?: boolean; cwd?: string },
209
+ ): Promise<{ action: "created" | "updated" | "skipped"; record: MulchExpertiseRecord }>;
210
+ searchExpertise(
211
+ query: string,
212
+ options?: {
213
+ domain?: string;
214
+ type?: string;
215
+ tag?: string;
216
+ classification?: string;
217
+ file?: string;
218
+ cwd?: string;
219
+ },
220
+ ): Promise<Array<{ domain: string; records: MulchExpertiseRecord[] }>>;
221
+ queryDomain(
222
+ domain: string,
223
+ options?: { type?: string; classification?: string; file?: string; cwd?: string },
224
+ ): Promise<MulchExpertiseRecord[]>;
225
+ }
226
+
227
+ const MULCH_PKG = "@os-eco/mulch-cli";
228
+ let _mulchApi: MulchProgrammaticApi | undefined;
229
+
230
+ async function loadMulchApi(): Promise<MulchProgrammaticApi> {
231
+ if (!_mulchApi) {
232
+ _mulchApi = (await import(MULCH_PKG)) as MulchProgrammaticApi;
233
+ }
234
+ return _mulchApi;
235
+ }
236
+
90
237
  /**
91
238
  * Run a shell command and capture its output.
92
239
  */
@@ -105,6 +252,127 @@ async function runCommand(
105
252
  return { stdout, stderr, exitCode };
106
253
  }
107
254
 
255
+ /**
256
+ * Build an ExpertiseRecord from record() options.
257
+ *
258
+ * CRITICAL MAPPING: --description maps to record.content for convention records,
259
+ * but to record.description for all other types.
260
+ */
261
+ function buildExpertiseRecord(options: {
262
+ type: string;
263
+ name?: string;
264
+ description?: string;
265
+ title?: string;
266
+ rationale?: string;
267
+ tags?: string[];
268
+ classification?: string;
269
+ evidenceBead?: string;
270
+ outcomeStatus?: "success" | "failure";
271
+ outcomeDuration?: number;
272
+ outcomeTestResults?: string;
273
+ outcomeAgent?: string;
274
+ }): MulchExpertiseRecord {
275
+ const base = {
276
+ classification: (options.classification ?? "tactical") as
277
+ | "foundational"
278
+ | "tactical"
279
+ | "observational",
280
+ recorded_at: new Date().toISOString(),
281
+ tags: options.tags,
282
+ evidence: options.evidenceBead ? { bead: options.evidenceBead } : undefined,
283
+ outcomes: options.outcomeStatus
284
+ ? [
285
+ {
286
+ status: options.outcomeStatus as "success" | "failure" | "partial",
287
+ duration: options.outcomeDuration,
288
+ test_results: options.outcomeTestResults,
289
+ agent: options.outcomeAgent,
290
+ recorded_at: new Date().toISOString(),
291
+ },
292
+ ]
293
+ : undefined,
294
+ };
295
+
296
+ switch (options.type) {
297
+ case "convention":
298
+ return { ...base, type: "convention", content: options.description ?? "" };
299
+ case "pattern":
300
+ return {
301
+ ...base,
302
+ type: "pattern",
303
+ name: options.name ?? "",
304
+ description: options.description ?? "",
305
+ };
306
+ case "failure":
307
+ return {
308
+ ...base,
309
+ type: "failure",
310
+ description: options.description ?? "",
311
+ resolution: "",
312
+ };
313
+ case "decision":
314
+ return {
315
+ ...base,
316
+ type: "decision",
317
+ title: options.title ?? "",
318
+ rationale: options.rationale ?? "",
319
+ };
320
+ case "reference":
321
+ return {
322
+ ...base,
323
+ type: "reference",
324
+ name: options.name ?? "",
325
+ description: options.description ?? "",
326
+ };
327
+ case "guide":
328
+ return {
329
+ ...base,
330
+ type: "guide",
331
+ name: options.name ?? "",
332
+ description: options.description ?? "",
333
+ };
334
+ default:
335
+ return {
336
+ ...base,
337
+ type: "convention",
338
+ content: options.description ?? "",
339
+ } as MulchExpertiseRecord;
340
+ }
341
+ }
342
+
343
+ /**
344
+ * Format search/query results as a plain string for callers that expect string output.
345
+ * Preserves behavior for parseConflictPatterns regex in resolver.ts.
346
+ */
347
+ function formatSearchResults(
348
+ results: Array<{ domain: string; records: MulchExpertiseRecord[] }>,
349
+ ): string {
350
+ const lines: string[] = [];
351
+ for (const result of results) {
352
+ for (const record of result.records) {
353
+ lines.push(formatRecordText(record));
354
+ }
355
+ }
356
+ return lines.join("\n");
357
+ }
358
+
359
+ function formatRecordText(record: MulchExpertiseRecord): string {
360
+ switch (record.type) {
361
+ case "convention":
362
+ return record.content;
363
+ case "pattern":
364
+ return record.description;
365
+ case "failure":
366
+ return record.description;
367
+ case "decision":
368
+ return `${record.title}: ${record.rationale}`;
369
+ case "reference":
370
+ return record.description;
371
+ case "guide":
372
+ return record.description;
373
+ }
374
+ }
375
+
108
376
  /**
109
377
  * Create a MulchClient bound to the given working directory.
110
378
  *
@@ -158,61 +426,60 @@ export function createMulchClient(cwd: string): MulchClient {
158
426
  },
159
427
 
160
428
  async record(domain, options) {
161
- const args = ["record", domain, "--type", options.type];
162
- if (options.name) {
163
- args.push("--name", options.name);
164
- }
165
- if (options.description) {
166
- args.push("--description", options.description);
167
- }
168
- if (options.title) {
169
- args.push("--title", options.title);
170
- }
171
- if (options.rationale) {
172
- args.push("--rationale", options.rationale);
173
- }
174
- if (options.tags && options.tags.length > 0) {
175
- args.push("--tags", options.tags.join(","));
176
- }
177
- if (options.classification) {
178
- args.push("--classification", options.classification);
179
- }
429
+ // stdin mode: no programmatic API equivalent, fall back to CLI
180
430
  if (options.stdin) {
431
+ const args = ["record", domain, "--type", options.type];
432
+ if (options.description) args.push("--description", options.description);
181
433
  args.push("--stdin");
434
+ await runMulch(args, `record ${domain}`);
435
+ return;
182
436
  }
183
- if (options.evidenceBead) {
184
- args.push("--evidence-bead", options.evidenceBead);
185
- }
186
- if (options.outcomeStatus) {
187
- args.push("--outcome-status", options.outcomeStatus);
188
- }
189
- if (options.outcomeDuration !== undefined) {
190
- args.push("--outcome-duration", String(options.outcomeDuration));
191
- }
192
- if (options.outcomeTestResults) {
193
- args.push("--outcome-test-results", options.outcomeTestResults);
194
- }
195
- if (options.outcomeAgent) {
196
- args.push("--outcome-agent", options.outcomeAgent);
437
+
438
+ const expertiseRecord = buildExpertiseRecord(options);
439
+ const api = await loadMulchApi();
440
+ try {
441
+ await api.recordExpertise(domain, expertiseRecord, { cwd });
442
+ } catch (error) {
443
+ if (error instanceof Error && error.message.includes("not found in config")) {
444
+ // Auto-create domain (matching mulch CLI 0.6.1+ behavior)
445
+ await runMulch(["add", domain], `add ${domain}`);
446
+ await api.recordExpertise(domain, expertiseRecord, { cwd });
447
+ } else {
448
+ throw new AgentError(
449
+ `mulch record ${domain} failed: ${error instanceof Error ? error.message : String(error)}`,
450
+ );
451
+ }
197
452
  }
198
- await runMulch(args, `record ${domain}`);
199
453
  },
200
454
 
201
455
  async query(domain) {
202
- const args = ["query"];
203
- if (domain) {
204
- args.push(domain);
456
+ if (!domain) {
457
+ throw new AgentError("mulch query failed (exit 1): domain argument required");
458
+ }
459
+ try {
460
+ const api = await loadMulchApi();
461
+ const records = await api.queryDomain(domain, { cwd });
462
+ return formatSearchResults([{ domain, records }]);
463
+ } catch (error) {
464
+ throw new AgentError(
465
+ `mulch query failed: ${error instanceof Error ? error.message : String(error)}`,
466
+ );
205
467
  }
206
- const { stdout } = await runMulch(args, "query");
207
- return stdout;
208
468
  },
209
469
 
210
470
  async search(query, options) {
211
- const args = ["search", query];
212
- if (options?.file) args.push("--file", options.file);
213
- if (options?.sortByScore) args.push("--sort-by-score");
214
- const { stdout } = await runMulch(args, "search");
215
- return stdout;
471
+ try {
472
+ const api = await loadMulchApi();
473
+ const results = await api.searchExpertise(query, {
474
+ file: options?.file,
475
+ cwd,
476
+ });
477
+ return formatSearchResults(results);
478
+ } catch (error) {
479
+ throw new AgentError(
480
+ `mulch search failed: ${error instanceof Error ? error.message : String(error)}`,
481
+ );
482
+ }
216
483
  },
217
484
 
218
485
  async diff(options) {