@oh-my-pi/pi-coding-agent 16.2.2 → 16.2.3

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 (150) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/dist/cli.js +3624 -3568
  3. package/dist/types/advisor/__tests__/config.test.d.ts +1 -0
  4. package/dist/types/advisor/advise-tool.d.ts +8 -4
  5. package/dist/types/advisor/config.d.ts +88 -0
  6. package/dist/types/advisor/index.d.ts +1 -0
  7. package/dist/types/advisor/transcript-recorder.d.ts +13 -2
  8. package/dist/types/advisor/watchdog.d.ts +20 -0
  9. package/dist/types/collab/guest.d.ts +29 -0
  10. package/dist/types/config/settings-schema.d.ts +81 -0
  11. package/dist/types/debug/log-viewer.d.ts +1 -0
  12. package/dist/types/debug/raw-sse.d.ts +1 -0
  13. package/dist/types/edit/hashline/diff.d.ts +0 -11
  14. package/dist/types/extensibility/tool-event-input.d.ts +7 -0
  15. package/dist/types/extensibility/utils.d.ts +12 -0
  16. package/dist/types/mcp/transports/index.d.ts +1 -0
  17. package/dist/types/mcp/transports/sse.d.ts +20 -0
  18. package/dist/types/modes/components/advisor-config.d.ts +59 -0
  19. package/dist/types/modes/components/index.d.ts +1 -0
  20. package/dist/types/modes/components/model-selector.d.ts +9 -1
  21. package/dist/types/modes/components/settings-selector.d.ts +1 -0
  22. package/dist/types/modes/components/status-line/component.d.ts +30 -1
  23. package/dist/types/modes/components/status-line/types.d.ts +13 -1
  24. package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
  25. package/dist/types/modes/interactive-mode.d.ts +10 -4
  26. package/dist/types/modes/skill-command.d.ts +32 -0
  27. package/dist/types/modes/types.d.ts +7 -2
  28. package/dist/types/session/agent-session.d.ts +58 -10
  29. package/dist/types/session/indexed-session-storage.d.ts +7 -1
  30. package/dist/types/session/messages.d.ts +26 -0
  31. package/dist/types/session/messages.test.d.ts +1 -0
  32. package/dist/types/session/session-entries.d.ts +31 -3
  33. package/dist/types/session/session-history-format.d.ts +6 -0
  34. package/dist/types/session/session-loader.d.ts +9 -1
  35. package/dist/types/session/session-manager.d.ts +6 -4
  36. package/dist/types/session/session-storage.d.ts +11 -0
  37. package/dist/types/session/session-title-slot.d.ts +19 -0
  38. package/dist/types/ssh/connection-manager.d.ts +47 -0
  39. package/dist/types/ssh/utils.d.ts +16 -0
  40. package/dist/types/task/executor.d.ts +3 -16
  41. package/dist/types/task/render.d.ts +0 -5
  42. package/dist/types/task/renderer.d.ts +13 -0
  43. package/dist/types/task/types.d.ts +16 -0
  44. package/dist/types/task/yield-assembly.d.ts +28 -0
  45. package/dist/types/tiny/text.d.ts +8 -0
  46. package/dist/types/tools/render-utils.d.ts +2 -0
  47. package/dist/types/tools/review.d.ts +6 -4
  48. package/dist/types/tools/ssh.d.ts +1 -1
  49. package/dist/types/tools/todo.d.ts +6 -0
  50. package/dist/types/tools/yield.d.ts +8 -3
  51. package/dist/types/utils/thinking-display.d.ts +4 -0
  52. package/package.json +12 -12
  53. package/src/advisor/__tests__/advisor.test.ts +242 -10
  54. package/src/advisor/__tests__/config.test.ts +173 -0
  55. package/src/advisor/advise-tool.ts +11 -6
  56. package/src/advisor/config.ts +256 -0
  57. package/src/advisor/index.ts +1 -0
  58. package/src/advisor/runtime.ts +12 -2
  59. package/src/advisor/transcript-recorder.ts +25 -2
  60. package/src/advisor/watchdog.ts +57 -31
  61. package/src/autoresearch/index.ts +7 -2
  62. package/src/cli/gc-cli.ts +17 -10
  63. package/src/collab/guest.ts +43 -7
  64. package/src/config/model-registry.ts +80 -18
  65. package/src/config/settings-schema.ts +77 -0
  66. package/src/debug/index.ts +32 -7
  67. package/src/debug/log-viewer.ts +111 -53
  68. package/src/debug/raw-sse.ts +68 -48
  69. package/src/discovery/codex.ts +13 -5
  70. package/src/edit/hashline/diff.ts +57 -4
  71. package/src/eval/js/shared/local-module-loader.ts +23 -1
  72. package/src/export/html/template.js +13 -7
  73. package/src/extensibility/extensions/loader.ts +5 -3
  74. package/src/extensibility/extensions/wrapper.ts +9 -3
  75. package/src/extensibility/hooks/loader.ts +3 -3
  76. package/src/extensibility/hooks/tool-wrapper.ts +13 -4
  77. package/src/extensibility/plugins/manager.ts +2 -1
  78. package/src/extensibility/tool-event-input.ts +23 -0
  79. package/src/extensibility/utils.ts +74 -0
  80. package/src/internal-urls/docs-index.generated.txt +1 -1
  81. package/src/mcp/client.ts +3 -1
  82. package/src/mcp/manager.ts +12 -5
  83. package/src/mcp/transports/index.ts +1 -0
  84. package/src/mcp/transports/sse.ts +377 -0
  85. package/src/memories/index.ts +15 -6
  86. package/src/modes/components/advisor-config.ts +555 -0
  87. package/src/modes/components/advisor-message.ts +9 -2
  88. package/src/modes/components/agent-hub.ts +9 -4
  89. package/src/modes/components/index.ts +2 -0
  90. package/src/modes/components/model-selector.ts +79 -48
  91. package/src/modes/components/settings-selector.ts +1 -0
  92. package/src/modes/components/status-line/component.ts +144 -5
  93. package/src/modes/components/status-line/segments.ts +46 -21
  94. package/src/modes/components/status-line/types.ts +13 -1
  95. package/src/modes/components/tool-execution.ts +47 -6
  96. package/src/modes/controllers/command-controller.ts +23 -2
  97. package/src/modes/controllers/event-controller.ts +106 -0
  98. package/src/modes/controllers/extension-ui-controller.ts +1 -1
  99. package/src/modes/controllers/input-controller.ts +61 -61
  100. package/src/modes/controllers/selector-controller.ts +100 -9
  101. package/src/modes/interactive-mode.ts +65 -5
  102. package/src/modes/skill-command.ts +116 -0
  103. package/src/modes/types.ts +7 -2
  104. package/src/modes/utils/ui-helpers.ts +41 -23
  105. package/src/prompts/agents/reviewer.md +11 -10
  106. package/src/prompts/review-custom-request.md +1 -2
  107. package/src/prompts/review-request.md +1 -2
  108. package/src/prompts/system/interrupted-thinking.md +7 -0
  109. package/src/prompts/system/recap-user.md +9 -0
  110. package/src/prompts/system/subagent-system-prompt.md +8 -5
  111. package/src/prompts/system/subagent-yield-reminder.md +6 -5
  112. package/src/prompts/system/system-prompt.md +0 -1
  113. package/src/prompts/tools/irc.md +2 -2
  114. package/src/prompts/tools/read.md +2 -2
  115. package/src/sdk.ts +28 -24
  116. package/src/session/agent-session.ts +867 -428
  117. package/src/session/indexed-session-storage.ts +86 -13
  118. package/src/session/messages.test.ts +125 -0
  119. package/src/session/messages.ts +172 -9
  120. package/src/session/redis-session-storage.ts +49 -2
  121. package/src/session/session-entries.ts +39 -2
  122. package/src/session/session-history-format.ts +29 -2
  123. package/src/session/session-listing.ts +54 -24
  124. package/src/session/session-loader.ts +66 -3
  125. package/src/session/session-manager.ts +113 -19
  126. package/src/session/session-persistence.ts +95 -1
  127. package/src/session/session-storage.ts +36 -0
  128. package/src/session/session-title-slot.ts +141 -0
  129. package/src/session/sql-session-storage.ts +71 -11
  130. package/src/slash-commands/builtin-registry.ts +16 -3
  131. package/src/ssh/__tests__/connection-manager-args.test.ts +123 -1
  132. package/src/ssh/__tests__/file-transfer-posix-guard.test.ts +55 -18
  133. package/src/ssh/connection-manager.ts +139 -12
  134. package/src/ssh/file-transfer.ts +23 -18
  135. package/src/ssh/ssh-executor.ts +2 -13
  136. package/src/ssh/utils.ts +19 -0
  137. package/src/task/executor.ts +21 -23
  138. package/src/task/render.ts +162 -20
  139. package/src/task/renderer.ts +14 -0
  140. package/src/task/types.ts +17 -0
  141. package/src/task/yield-assembly.ts +207 -0
  142. package/src/tiny/text.ts +23 -0
  143. package/src/tools/ask.ts +55 -4
  144. package/src/tools/render-utils.ts +2 -0
  145. package/src/tools/renderers.ts +8 -2
  146. package/src/tools/review.ts +17 -7
  147. package/src/tools/ssh.ts +8 -4
  148. package/src/tools/todo.ts +17 -1
  149. package/src/tools/yield.ts +140 -31
  150. package/src/utils/thinking-display.ts +15 -0
@@ -0,0 +1,173 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
2
+ import * as fsp from "node:fs/promises";
3
+ import * as os from "node:os";
4
+ import * as path from "node:path";
5
+ import {
6
+ advisorConfigFilePath,
7
+ discoverAdvisorConfigs,
8
+ loadWatchdogConfigFile,
9
+ resolveAdvisorConfigEditPath,
10
+ saveWatchdogConfigFile,
11
+ serializeWatchdogConfig,
12
+ slugifyAdvisorName,
13
+ type WatchdogConfigDoc,
14
+ } from "../config";
15
+
16
+ describe("discoverAdvisorConfigs", () => {
17
+ let tmp: string;
18
+ let agentDir: string;
19
+
20
+ beforeEach(async () => {
21
+ tmp = await fsp.mkdtemp(path.join(os.tmpdir(), "omp-advisor-config-"));
22
+ // Empty agent dir so the user-level search path can't pick up a real ~/.omp/WATCHDOG.yml.
23
+ agentDir = await fsp.mkdtemp(path.join(os.tmpdir(), "omp-advisor-agentdir-"));
24
+ });
25
+
26
+ afterEach(async () => {
27
+ await fsp.rm(tmp, { recursive: true, force: true });
28
+ await fsp.rm(agentDir, { recursive: true, force: true });
29
+ });
30
+
31
+ it("parses advisors, the model thinking suffix, tool filtering, and shared instructions", async () => {
32
+ const yaml = [
33
+ "instructions: Shared baseline for all advisors.",
34
+ "advisors:",
35
+ " - name: Architecture",
36
+ " model: x-ai/grok-code-fast:high",
37
+ " instructions: Watch module boundaries.",
38
+ " - name: Security Reviewer",
39
+ " tools: [read, definitely-not-a-tool]",
40
+ ].join("\n");
41
+ await Bun.write(path.join(tmp, "WATCHDOG.yml"), yaml);
42
+
43
+ const { advisors, sharedInstructions } = await discoverAdvisorConfigs(tmp, agentDir);
44
+ expect(advisors).toHaveLength(2);
45
+ const [arch, sec] = advisors;
46
+ expect(arch.name).toBe("Architecture");
47
+ // The model selector (incl. the `:high` thinking suffix) is stored verbatim;
48
+ // resolution happens later in the session, not here.
49
+ expect(arch.model).toBe("x-ai/grok-code-fast:high");
50
+ expect(arch.instructions).toBe("Watch module boundaries.");
51
+ expect(sec.name).toBe("Security Reviewer");
52
+ expect(sec.model).toBeUndefined();
53
+ // The unknown/non-read-only tool is dropped; only `read` survives.
54
+ expect(sec.tools).toEqual(["read"]);
55
+ expect(sharedInstructions).toBe("Shared baseline for all advisors.");
56
+ });
57
+
58
+ it("ignores a malformed YAML file without throwing", async () => {
59
+ await Bun.write(path.join(tmp, "WATCHDOG.yml"), "advisors: [unclosed bracket");
60
+ const result = await discoverAdvisorConfigs(tmp, agentDir);
61
+ expect(result.advisors).toEqual([]);
62
+ expect(result.sharedInstructions).toBeUndefined();
63
+ });
64
+
65
+ it("skips a file whose shape fails the schema (advisors must be a list)", async () => {
66
+ await Bun.write(path.join(tmp, "WATCHDOG.yml"), "advisors: not-an-array");
67
+ const result = await discoverAdvisorConfigs(tmp, agentDir);
68
+ expect(result.advisors).toEqual([]);
69
+ });
70
+
71
+ it("returns an empty roster when no config file exists", async () => {
72
+ const result = await discoverAdvisorConfigs(tmp, agentDir);
73
+ expect(result.advisors).toEqual([]);
74
+ expect(result.sharedInstructions).toBeUndefined();
75
+ });
76
+ });
77
+
78
+ describe("slugifyAdvisorName", () => {
79
+ it("lowercases and collapses non-alphanumeric runs to single hyphens", () => {
80
+ expect(slugifyAdvisorName("Security Reviewer")).toBe("security-reviewer");
81
+ expect(slugifyAdvisorName(" Arch/Boundaries! ")).toBe("arch-boundaries");
82
+ });
83
+
84
+ it("falls back to 'advisor' when nothing alphanumeric survives", () => {
85
+ expect(slugifyAdvisorName("!!!")).toBe("advisor");
86
+ });
87
+ });
88
+
89
+ describe("WATCHDOG.yml file round-trip", () => {
90
+ let tmp: string;
91
+ beforeEach(async () => {
92
+ tmp = await fsp.mkdtemp(path.join(os.tmpdir(), "omp-advisor-file-"));
93
+ });
94
+ afterEach(async () => {
95
+ await fsp.rm(tmp, { recursive: true, force: true });
96
+ });
97
+
98
+ const doc: WatchdogConfigDoc = {
99
+ instructions: 'Shared baseline.\nSecond line with: a colon and "quotes".',
100
+ advisors: [
101
+ { name: "Architecture", model: "x-ai/grok-code-fast:high", instructions: "Watch module boundaries." },
102
+ { name: "Security", tools: ["read", "grep"] },
103
+ ],
104
+ };
105
+
106
+ it("saves and reloads a doc byte-equivalently (incl. multiline and special chars)", async () => {
107
+ const file = path.join(tmp, "WATCHDOG.yml");
108
+ await saveWatchdogConfigFile(file, doc);
109
+ const loaded = await loadWatchdogConfigFile(file);
110
+ expect(loaded).toEqual(doc);
111
+ });
112
+
113
+ it("serializes block-style YAML that the discovery path also parses", async () => {
114
+ const file = path.join(tmp, "WATCHDOG.yml");
115
+ await saveWatchdogConfigFile(file, doc);
116
+ const text = await Bun.file(file).text();
117
+ // Block style (not the flow `{...}` form), so it stays hand-editable.
118
+ expect(text).toContain("advisors:");
119
+ expect(text).not.toMatch(/^\{/);
120
+ const { advisors, sharedInstructions } = await discoverAdvisorConfigs(tmp, tmp);
121
+ expect(advisors.map(a => a.name)).toEqual(["Architecture", "Security"]);
122
+ expect(sharedInstructions).toContain("Shared baseline.");
123
+ });
124
+
125
+ it("removes the file when the doc is empty so legacy discovery resumes", async () => {
126
+ const file = path.join(tmp, "WATCHDOG.yml");
127
+ await saveWatchdogConfigFile(file, doc);
128
+ await saveWatchdogConfigFile(file, { advisors: [] });
129
+ expect(await Bun.file(file).exists()).toBe(false);
130
+ // Loading a missing file yields an empty doc, never throws.
131
+ expect(await loadWatchdogConfigFile(file)).toEqual({ advisors: [] });
132
+ });
133
+
134
+ it("returns an empty serialization for an empty doc", () => {
135
+ expect(serializeWatchdogConfig({ advisors: [] })).toBe("");
136
+ });
137
+
138
+ it("resolves project and user scope paths", () => {
139
+ expect(advisorConfigFilePath("project", { projectDir: "/repo", agentDir: "/home/.omp" })).toBe(
140
+ path.join("/repo", "WATCHDOG.yml"),
141
+ );
142
+ expect(advisorConfigFilePath("user", { projectDir: "/repo", agentDir: "/home/.omp" })).toBe(
143
+ path.join("/home/.omp", "WATCHDOG.yml"),
144
+ );
145
+ });
146
+ });
147
+
148
+ describe("resolveAdvisorConfigEditPath", () => {
149
+ let tmp: string;
150
+ beforeEach(async () => {
151
+ tmp = await fsp.mkdtemp(path.join(os.tmpdir(), "omp-advisor-resolve-"));
152
+ });
153
+ afterEach(async () => {
154
+ await fsp.rm(tmp, { recursive: true, force: true });
155
+ });
156
+
157
+ const dirs = (d: string) => ({ projectDir: d, agentDir: d });
158
+
159
+ it("defaults to .yml when neither file exists", async () => {
160
+ expect(await resolveAdvisorConfigEditPath("project", dirs(tmp))).toBe(path.join(tmp, "WATCHDOG.yml"));
161
+ });
162
+
163
+ it("edits an existing .yaml in place when only it exists", async () => {
164
+ await Bun.write(path.join(tmp, "WATCHDOG.yaml"), "advisors: []\n");
165
+ expect(await resolveAdvisorConfigEditPath("project", dirs(tmp))).toBe(path.join(tmp, "WATCHDOG.yaml"));
166
+ });
167
+
168
+ it("prefers the canonical .yml when both exist", async () => {
169
+ await Bun.write(path.join(tmp, "WATCHDOG.yml"), "advisors: []\n");
170
+ await Bun.write(path.join(tmp, "WATCHDOG.yaml"), "advisors: []\n");
171
+ expect(await resolveAdvisorConfigEditPath("project", dirs(tmp))).toBe(path.join(tmp, "WATCHDOG.yml"));
172
+ });
173
+ });
@@ -6,7 +6,7 @@ import type {
6
6
  AgentToolResult,
7
7
  AgentToolUpdateCallback,
8
8
  } from "@oh-my-pi/pi-agent-core";
9
- import { escapeXmlText } from "@oh-my-pi/pi-utils";
9
+ import { escapeXmlAttribute, escapeXmlText } from "@oh-my-pi/pi-utils";
10
10
  import { type } from "arktype";
11
11
  import adviseDescription from "../prompts/advisor/advise-tool.md" with { type: "text" };
12
12
 
@@ -24,12 +24,16 @@ export type AdvisorSeverity = "nit" | "concern" | "blocker";
24
24
  export interface AdviseDetails {
25
25
  note: string;
26
26
  severity?: AdvisorSeverity;
27
+ /** Which configured advisor produced this note (omitted for the default advisor). */
28
+ advisor?: string;
27
29
  }
28
30
 
29
31
  /** One queued advice note. */
30
32
  export interface AdvisorNote {
31
33
  note: string;
32
34
  severity?: AdvisorSeverity;
35
+ /** Which configured advisor produced this note (omitted for the default advisor). */
36
+ advisor?: string;
33
37
  }
34
38
 
35
39
  /** Details payload on the batched `advisor` custom message rendered in the transcript. */
@@ -55,7 +59,8 @@ export function formatAdvisorBatchContent(notes: readonly AdvisorNote[]): string
55
59
  return notes
56
60
  .map(n => {
57
61
  const severity = n.severity ? ` severity="${n.severity}"` : "";
58
- return `<advisory${severity} guidance="${ADVISOR_GUIDANCE}">\n${escapeXmlText(n.note)}\n</advisory>`;
62
+ const who = n.advisor ? ` advisor="${escapeXmlAttribute(n.advisor)}"` : "";
63
+ return `<advisory${who}${severity} guidance="${ADVISOR_GUIDANCE}">\n${escapeXmlText(n.note)}\n</advisory>`;
59
64
  })
60
65
  .join("\n");
61
66
  }
@@ -133,11 +138,11 @@ export function deriveAdvisorTelemetry(
133
138
  }
134
139
 
135
140
  /**
136
- * Side-effect-free investigation tools handed to the advisor agent so it can
137
- * inspect the workspace before weighing in. Names match the primary session's
138
- * tool instances, which the advisor reuses.
141
+ * The tools an advisor receives by default when its config omits `tools` — the
142
+ * read-only investigative set. The full available pool is every built tool the
143
+ * session has (the advisor is a full agent); a config's `tools` selects from it.
139
144
  */
140
- export const ADVISOR_READONLY_TOOL_NAMES: ReadonlySet<string> = new Set(["read", "grep", "glob"]);
145
+ export const ADVISOR_DEFAULT_TOOL_NAMES: ReadonlySet<string> = new Set(["read", "grep", "glob"]);
141
146
 
142
147
  function advisorNoteDedupeKey(note: string): string {
143
148
  return note.trim().replace(/\s+/g, " ");
@@ -0,0 +1,256 @@
1
+ import * as fs from "node:fs/promises";
2
+ import * as path from "node:path";
3
+ import { isEnoent, logger } from "@oh-my-pi/pi-utils";
4
+ import { type } from "arktype";
5
+ import { YAML } from "bun";
6
+ import { expandAtImports } from "../discovery/at-imports";
7
+ import { BUILTIN_TOOL_NAMES, normalizeToolNames } from "../tools/builtin-names";
8
+ import { collectConfigCandidates } from "./watchdog";
9
+
10
+ /**
11
+ * One advisor declared in a `WATCHDOG.yml` file. `model` is a model selector
12
+ * with an optional `:level` thinking suffix (e.g. `x-ai/grok-code-fast:high`),
13
+ * resolved exactly like any other model override; `tools` is a subset of the
14
+ * read-only investigative tool names (default: all). `instructions` is the
15
+ * advisor's specialization, appended to the shared baseline.
16
+ */
17
+ export interface AdvisorConfig {
18
+ name: string;
19
+ model?: string;
20
+ tools?: string[];
21
+ instructions?: string;
22
+ }
23
+
24
+ /**
25
+ * The result of walking the `WATCHDOG.yml`/`WATCHDOG.yaml` search path: the
26
+ * deduped advisor roster plus the concatenated top-level `instructions` baseline
27
+ * that is prepended (alongside `WATCHDOG.md`) to every advisor.
28
+ */
29
+ export interface DiscoveredAdvisors {
30
+ advisors: AdvisorConfig[];
31
+ sharedInstructions: string | undefined;
32
+ }
33
+
34
+ const advisorEntrySchema = type({
35
+ name: "string",
36
+ "model?": "string",
37
+ "tools?": "string[]",
38
+ "instructions?": "string",
39
+ });
40
+
41
+ const watchdogYamlSchema = type({
42
+ "instructions?": "string",
43
+ "advisors?": advisorEntrySchema.array(),
44
+ });
45
+
46
+ /**
47
+ * Normalize an advisor name into a filesystem-/id-safe slug used for its
48
+ * transcript filename and session id: lowercase, non-alphanumerics collapsed to
49
+ * `-`, leading/trailing `-` trimmed. Falls back to `"advisor"` when nothing
50
+ * survives; callers dedupe collisions.
51
+ */
52
+ export function slugifyAdvisorName(name: string): string {
53
+ const slug = name
54
+ .toLowerCase()
55
+ .replace(/[^a-z0-9]+/g, "-")
56
+ .replace(/^-+|-+$/g, "");
57
+ return slug || "advisor";
58
+ }
59
+
60
+ /** Built tool names, for validating an advisor's `tools` list. */
61
+ const KNOWN_TOOL_NAMES = new Set<string>(BUILTIN_TOOL_NAMES);
62
+
63
+ /**
64
+ * Keep only valid tool names from an advisor's `tools` list, dropping unknowns
65
+ * with a warning. The advisor is a full agent, so any built tool may be granted;
66
+ * the runtime further filters to what's actually available this session. An empty
67
+ * result (or no list) means "use the default subset" (read/grep/glob).
68
+ */
69
+ function filterAdvisorTools(tools: string[] | undefined, sourcePath: string): string[] | undefined {
70
+ if (!tools || tools.length === 0) return undefined;
71
+ // Normalize legacy aliases (search→grep, find→glob) and dedupe before validating.
72
+ const filtered = normalizeToolNames(tools).filter(name => {
73
+ if (KNOWN_TOOL_NAMES.has(name)) return true;
74
+ logger.warn("Advisor config: dropping unknown tool", { path: sourcePath, tool: name });
75
+ return false;
76
+ });
77
+ return filtered.length > 0 ? filtered : undefined;
78
+ }
79
+
80
+ /**
81
+ * Discover advisor configs from `WATCHDOG.yml`/`WATCHDOG.yaml` files on the same
82
+ * user + project search path as `WATCHDOG.md`. Advisors are keyed by slug; a
83
+ * more-specific file (project leaf > project ancestor > user) replaces an earlier
84
+ * entry with the same slug. Top-level `instructions` across all files concatenate
85
+ * into the shared baseline. A malformed file is logged and skipped — never
86
+ * thrown — so a bad project config can't kill the session.
87
+ */
88
+ export async function discoverAdvisorConfigs(cwd: string, agentDir?: string): Promise<DiscoveredAdvisors> {
89
+ const items = await collectConfigCandidates(cwd, agentDir, ["WATCHDOG.yml", "WATCHDOG.yaml"]);
90
+ const advisors = new Map<string, AdvisorConfig>();
91
+ const sharedParts: string[] = [];
92
+
93
+ for (const item of items) {
94
+ let parsed: unknown;
95
+ try {
96
+ parsed = YAML.parse(item.content);
97
+ } catch (err) {
98
+ logger.warn("Advisor config: failed to parse YAML", { path: item.path, error: String(err) });
99
+ continue;
100
+ }
101
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
102
+ logger.warn("Advisor config: expected a YAML mapping", { path: item.path });
103
+ continue;
104
+ }
105
+ const result = watchdogYamlSchema(parsed);
106
+ if (result instanceof type.errors) {
107
+ logger.warn("Advisor config: invalid schema", { path: item.path, error: result.summary });
108
+ continue;
109
+ }
110
+
111
+ if (result.instructions?.trim()) {
112
+ const expanded = (await expandAtImports(result.instructions, item.path)).trim();
113
+ if (expanded) sharedParts.push(expanded);
114
+ }
115
+
116
+ for (const entry of result.advisors ?? []) {
117
+ const slug = slugifyAdvisorName(entry.name);
118
+ const instructions = entry.instructions?.trim()
119
+ ? (await expandAtImports(entry.instructions, item.path)).trim() || undefined
120
+ : undefined;
121
+ advisors.set(slug, {
122
+ name: entry.name,
123
+ model: entry.model?.trim() || undefined,
124
+ tools: filterAdvisorTools(entry.tools, item.path),
125
+ instructions,
126
+ });
127
+ }
128
+ }
129
+
130
+ return {
131
+ advisors: [...advisors.values()],
132
+ sharedInstructions: sharedParts.length > 0 ? sharedParts.join("\n\n") : undefined,
133
+ };
134
+ }
135
+
136
+ /** Which level a `WATCHDOG.yml` lives at: the project root or the user agent dir. */
137
+ export type AdvisorConfigScope = "project" | "user";
138
+
139
+ /**
140
+ * The editable contents of a single `WATCHDOG.yml` file: the shared top-level
141
+ * `instructions` plus the advisor roster. Unlike {@link DiscoveredAdvisors}, this
142
+ * is one file's raw view (no cross-level merge, no `@import` expansion) so the
143
+ * config editor round-trips exactly what the user wrote.
144
+ */
145
+ export interface WatchdogConfigDoc {
146
+ instructions?: string;
147
+ advisors: AdvisorConfig[];
148
+ }
149
+
150
+ /**
151
+ * Resolve the `WATCHDOG.yml` path for a scope: `project` → `<projectDir>/WATCHDOG.yml`
152
+ * (discovered by the project-level walk), `user` → `<agentDir>/WATCHDOG.yml` (the
153
+ * user-level candidate).
154
+ */
155
+ export function advisorConfigFilePath(
156
+ scope: AdvisorConfigScope,
157
+ dirs: { projectDir: string; agentDir: string },
158
+ ): string {
159
+ return path.join(scope === "user" ? dirs.agentDir : dirs.projectDir, "WATCHDOG.yml");
160
+ }
161
+
162
+ /**
163
+ * Resolve which `WATCHDOG.{yml,yaml}` to edit for a scope: prefer the canonical
164
+ * `.yml`, but when only a `.yaml` exists for that scope, edit it in place so an
165
+ * existing `.yaml` user isn't shown a blank editor and left with two files at the
166
+ * same precedence. Falls back to `.yml` when neither exists.
167
+ */
168
+ export async function resolveAdvisorConfigEditPath(
169
+ scope: AdvisorConfigScope,
170
+ dirs: { projectDir: string; agentDir: string },
171
+ ): Promise<string> {
172
+ const dir = scope === "user" ? dirs.agentDir : dirs.projectDir;
173
+ const yml = path.join(dir, "WATCHDOG.yml");
174
+ const yaml = path.join(dir, "WATCHDOG.yaml");
175
+ if (!(await Bun.file(yml).exists()) && (await Bun.file(yaml).exists())) return yaml;
176
+ return yml;
177
+ }
178
+
179
+ /**
180
+ * Load one `WATCHDOG.yml` file for editing — raw, un-merged, un-expanded. Missing,
181
+ * unparseable, or schema-invalid files yield an empty doc (never throws) so the
182
+ * editor opens cleanly on a fresh or broken file.
183
+ */
184
+ export async function loadWatchdogConfigFile(filePath: string): Promise<WatchdogConfigDoc> {
185
+ let text: string;
186
+ try {
187
+ text = await Bun.file(filePath).text();
188
+ } catch (err) {
189
+ if (!isEnoent(err))
190
+ logger.warn("Advisor config: failed to read for edit", { path: filePath, error: String(err) });
191
+ return { advisors: [] };
192
+ }
193
+ let parsed: unknown;
194
+ try {
195
+ parsed = YAML.parse(text);
196
+ } catch (err) {
197
+ logger.warn("Advisor config: failed to parse for edit", { path: filePath, error: String(err) });
198
+ return { advisors: [] };
199
+ }
200
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return { advisors: [] };
201
+ const result = watchdogYamlSchema(parsed);
202
+ if (result instanceof type.errors) {
203
+ logger.warn("Advisor config: invalid schema for edit", { path: filePath, error: result.summary });
204
+ return { advisors: [] };
205
+ }
206
+ return {
207
+ instructions: result.instructions?.trim() ? result.instructions : undefined,
208
+ advisors: (result.advisors ?? []).map(a => ({
209
+ name: a.name,
210
+ model: a.model?.trim() || undefined,
211
+ tools: a.tools?.length ? [...a.tools] : undefined,
212
+ instructions: a.instructions?.trim() ? a.instructions : undefined,
213
+ })),
214
+ };
215
+ }
216
+
217
+ /**
218
+ * Serialize an editable doc back to block-style `WATCHDOG.yml` text via Bun's
219
+ * `YAML.stringify` (the same API the repo uses for other hand-editable config),
220
+ * omitting empty fields. Round-trips through {@link loadWatchdogConfigFile}.
221
+ * Returns `""` for an empty doc.
222
+ */
223
+ export function serializeWatchdogConfig(doc: WatchdogConfigDoc): string {
224
+ const out: { instructions?: string; advisors?: AdvisorConfig[] } = {};
225
+ if (doc.instructions?.trim()) out.instructions = doc.instructions;
226
+ if (doc.advisors.length > 0) {
227
+ out.advisors = doc.advisors.map(a => {
228
+ const entry: AdvisorConfig = { name: a.name };
229
+ if (a.model?.trim()) entry.model = a.model;
230
+ if (a.tools?.length) entry.tools = [...a.tools];
231
+ if (a.instructions?.trim()) entry.instructions = a.instructions;
232
+ return entry;
233
+ });
234
+ }
235
+ if (out.instructions === undefined && out.advisors === undefined) return "";
236
+ const text = YAML.stringify(out, null, 2);
237
+ return text.endsWith("\n") ? text : `${text}\n`;
238
+ }
239
+
240
+ /**
241
+ * Write an editable doc to `WATCHDOG.yml`. An empty doc removes the file so
242
+ * discovery falls back to the legacy single-advisor path rather than leaving an
243
+ * empty config behind.
244
+ */
245
+ export async function saveWatchdogConfigFile(filePath: string, doc: WatchdogConfigDoc): Promise<void> {
246
+ const content = serializeWatchdogConfig(doc);
247
+ if (!content.trim()) {
248
+ try {
249
+ await fs.rm(filePath, { force: true });
250
+ } catch (err) {
251
+ if (!isEnoent(err)) throw err;
252
+ }
253
+ return;
254
+ }
255
+ await Bun.write(filePath, content);
256
+ }
@@ -1,4 +1,5 @@
1
1
  export * from "./advise-tool";
2
+ export * from "./config";
2
3
  export * from "./emission-guard";
3
4
  export * from "./runtime";
4
5
  export * from "./transcript-recorder";
@@ -209,6 +209,7 @@ export class AdvisorRuntime {
209
209
  includeToolIntent: true,
210
210
  watchedRoles: true,
211
211
  expandPrimaryContext: true,
212
+ expandEditDiffs: true,
212
213
  });
213
214
  if (!md.trim()) return null;
214
215
  return `### Session update\n\n${md}`;
@@ -434,11 +435,20 @@ function obfuscateDetails(
434
435
  function obfuscateAdvisorMessage(obfuscator: SecretObfuscator, message: AgentMessage): AgentMessage {
435
436
  switch (message.role) {
436
437
  case "user":
437
- case "developer":
438
- case "toolResult": {
438
+ case "developer": {
439
439
  const content = obfuscateTextualContent(obfuscator, message.content as TextualContent);
440
440
  return content === message.content ? message : ({ ...(message as object), content } as AgentMessage);
441
441
  }
442
+ case "toolResult": {
443
+ const msg = message as AgentMessage & {
444
+ content: TextualContent;
445
+ details?: Record<string, unknown>;
446
+ };
447
+ const content = obfuscateTextualContent(obfuscator, msg.content);
448
+ const details = obfuscateDetails(obfuscator, msg.details);
449
+ if (content === msg.content && details === msg.details) return message;
450
+ return { ...(message as object), content, details } as AgentMessage;
451
+ }
442
452
  case "assistant":
443
453
  return obfuscateAssistantMessage(obfuscator, message as AssistantMessage) as AgentMessage;
444
454
  case "custom":
@@ -14,6 +14,23 @@ export const ADVISOR_TRANSCRIPT_FILENAME = `${ADVISOR_TRANSCRIPT_STEM}.jsonl`;
14
14
 
15
15
  const JSONL_SUFFIX = ".jsonl";
16
16
 
17
+ /**
18
+ * Transcript filename for an advisor: `__advisor.jsonl` for the legacy/default
19
+ * advisor (empty slug), `__advisor.<slug>.jsonl` for a named advisor. The `.`
20
+ * separator keeps named files out of the output manager's `-<n>` bump namespace.
21
+ */
22
+ export function advisorTranscriptFilename(slug: string): string {
23
+ return slug ? `${ADVISOR_TRANSCRIPT_STEM}.${slug}${JSONL_SUFFIX}` : ADVISOR_TRANSCRIPT_FILENAME;
24
+ }
25
+
26
+ /** Whether a filename is any advisor transcript (`__advisor.jsonl` or `__advisor.<slug>.jsonl`). */
27
+ export function isAdvisorTranscriptName(name: string): boolean {
28
+ return (
29
+ name === ADVISOR_TRANSCRIPT_FILENAME ||
30
+ (name.startsWith(`${ADVISOR_TRANSCRIPT_STEM}.`) && name.endsWith(JSONL_SUFFIX))
31
+ );
32
+ }
33
+
17
34
  /**
18
35
  * Append-only persister for an advisor agent's transcript.
19
36
  *
@@ -38,19 +55,25 @@ const JSONL_SUFFIX = ".jsonl";
38
55
  export class AdvisorTranscriptRecorder {
39
56
  #manager: SessionManager | undefined;
40
57
  #file: string | undefined;
58
+ #filename: string;
41
59
  /** Serializes the async open/close against synchronous appends so records land in order. */
42
60
  #queue: Promise<void>;
43
61
 
44
62
  /**
63
+ * @param filename Transcript filename within the session dir. Defaults to
64
+ * `__advisor.jsonl`; named advisors pass `__advisor.<slug>.jsonl` via
65
+ * {@link advisorTranscriptFilename}.
45
66
  * @param after Optional barrier the queue starts behind — used on the advisor
46
67
  * on→off→on toggle so a fresh recorder's first `open` waits for the prior
47
- * recorder's `close` and the two never hold the same `__advisor.jsonl` at once.
68
+ * recorder's `close` and the two never hold the same file at once.
48
69
  */
49
70
  constructor(
50
71
  private readonly resolveSessionFile: () => string | undefined,
51
72
  private readonly resolveCwd: () => string,
73
+ filename: string = ADVISOR_TRANSCRIPT_FILENAME,
52
74
  after?: Promise<unknown>,
53
75
  ) {
76
+ this.#filename = filename;
54
77
  this.#queue = after
55
78
  ? after.then(
56
79
  () => {},
@@ -83,7 +106,7 @@ export class AdvisorTranscriptRecorder {
83
106
  }
84
107
  const sessionFile = this.resolveSessionFile();
85
108
  if (!sessionFile?.endsWith(JSONL_SUFFIX)) return;
86
- const file = path.join(sessionFile.slice(0, -JSONL_SUFFIX.length), ADVISOR_TRANSCRIPT_FILENAME);
109
+ const file = path.join(sessionFile.slice(0, -JSONL_SUFFIX.length), this.#filename);
87
110
  const cwd = this.resolveCwd();
88
111
  this.#enqueue(async () => {
89
112
  if (file !== this.#file) {