@animalabs/connectome-host 0.7.4 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/.env.example +12 -5
  2. package/.github/PULL_REQUEST_TEMPLATE.md +3 -2
  3. package/.github/workflows/changelog.yml +9 -4
  4. package/.github/workflows/ci.yml +5 -3
  5. package/.github/workflows/publish.yml +12 -6
  6. package/CHANGELOG.md +245 -0
  7. package/CONTRIBUTING.md +47 -19
  8. package/README.md +27 -0
  9. package/bun.lock +27 -31
  10. package/changelog.d/README.md +28 -0
  11. package/package.json +5 -5
  12. package/recipes/SETUP.md +11 -5
  13. package/recipes/TRIUMVIRATE-SETUP.md +68 -14
  14. package/recipes/knowledge-miner.json +0 -30
  15. package/recipes/mock-test.json +19 -0
  16. package/recipes/triumvirate.json +6 -1
  17. package/scripts/release-changelog.ts +210 -21
  18. package/src/cache-keepalive-log.ts +41 -0
  19. package/src/commands.ts +96 -0
  20. package/src/framework-strategy.ts +37 -0
  21. package/src/gate-telemetry.ts +106 -0
  22. package/src/headless.ts +10 -0
  23. package/src/index.ts +167 -55
  24. package/src/mcpl-config.ts +99 -1
  25. package/src/modules/identity-module.ts +310 -2
  26. package/src/modules/instructions-module.ts +265 -0
  27. package/src/modules/mcpl-admin-module.ts +58 -11
  28. package/src/modules/subagent-module.ts +18 -0
  29. package/src/recipe.ts +732 -25
  30. package/src/web/panel-data.ts +19 -0
  31. package/src/workspace-mounts.ts +73 -0
  32. package/test/audit-module-optins.test.ts +10 -3
  33. package/test/cache-keepalive-log.test.ts +83 -0
  34. package/test/conversations-recipe.test.ts +142 -0
  35. package/test/framework-fkm-composition.test.ts +35 -3
  36. package/test/framework-strategy-defaults.test.ts +19 -0
  37. package/test/gate-telemetry-adapter.test.ts +84 -0
  38. package/test/gate-telemetry.test.ts +91 -0
  39. package/test/identity-and-surfaces.test.ts +212 -1
  40. package/test/instructions-module.test.ts +258 -0
  41. package/test/mcpl-admin-module.test.ts +41 -0
  42. package/test/mcpl-agent-overlay.test.ts +51 -3
  43. package/test/mcpl-child-env.test.ts +64 -0
  44. package/test/nudge-command.test.ts +47 -0
  45. package/test/recipe-cache-keepalive.test.ts +59 -0
  46. package/test/recipe-compression-fallback.test.ts +19 -0
  47. package/test/recipe-hybrid-prose-routing.test.ts +12 -0
  48. package/test/recipe-instructions.test.ts +176 -0
  49. package/test/recipe-kv-unified.test.ts +87 -0
  50. package/test/recipe-mcp-source.test.ts +54 -0
  51. package/test/recipe-openai-compatible.test.ts +54 -0
  52. package/test/recipe-path-resolution.test.ts +19 -8
  53. package/test/recipe-provider.test.ts +14 -0
  54. package/test/recipe-save-unresolved.test.ts +244 -0
  55. package/test/recipe-source-only.test.ts +38 -0
  56. package/test/release-changelog.test.ts +202 -0
  57. package/test/subagent-prose-routing.test.ts +109 -0
  58. package/test/workspace-mounts.test.ts +68 -0
  59. package/web/src/App.tsx +1 -0
  60. package/web/src/Health.tsx +61 -1
@@ -0,0 +1,244 @@
1
+ /**
2
+ * Tests for unresolved recipe persistence: the `.recipe.json` snapshot in
3
+ * $DATA_DIR must never contain substituted secrets.
4
+ *
5
+ * Motivating finding (external recipe review against a production VM):
6
+ * loadRecipe substituted every `${VAR}` — including access tokens — into the
7
+ * recipe, and resolveRecipe then serialised the RESOLVED recipe to
8
+ * $DATA_DIR/.recipe.json, a directory deployments bind-mount and back up.
9
+ *
10
+ * Contract under test:
11
+ * - loadRecipeDetailed returns the resolved recipe for the runtime AND a
12
+ * `persistable` pre-substitution form; saveRecipe writes the latter, so
13
+ * the on-disk snapshot keeps `${VAR}` literals, never the secret values.
14
+ * - loadSavedRecipe re-runs substitution against the CURRENT environment
15
+ * (secret rotation takes effect on restart), hard-failing when a
16
+ * required var has gone missing.
17
+ * - Legacy snapshots (saved fully resolved by older versions, no marker)
18
+ * still load verbatim — including ones with a literal `${...}` in prose.
19
+ * - The snapshot file is chmod'd 0600 even when overwriting a legacy file.
20
+ * - A URL systemPrompt is persisted as the URL and re-fetched on resume.
21
+ */
22
+ import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
23
+ import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, statSync, chmodSync } from 'node:fs';
24
+ import { tmpdir } from 'node:os';
25
+ import { join, resolve } from 'node:path';
26
+ import {
27
+ loadRecipe,
28
+ loadRecipeDetailed,
29
+ saveRecipe,
30
+ loadSavedRecipe,
31
+ SAVED_RECIPE_UNRESOLVED_KEY,
32
+ } from '../src/recipe.js';
33
+
34
+ const SECRET = 'tok-glpat-supersecret-12345';
35
+
36
+ describe('unresolved recipe persistence', () => {
37
+ const originalEnv = process.env;
38
+ const originalFetch = globalThis.fetch;
39
+ let tmpDir: string;
40
+ let dataDir: string;
41
+ let recipePath: string;
42
+
43
+ beforeEach(() => {
44
+ process.env = { ...originalEnv };
45
+ tmpDir = mkdtempSync(join(tmpdir(), 'conhost-save-unresolved-'));
46
+ dataDir = join(tmpDir, 'data');
47
+ recipePath = join(tmpDir, 'recipe.json');
48
+ });
49
+
50
+ afterEach(() => {
51
+ process.env = originalEnv;
52
+ globalThis.fetch = originalFetch;
53
+ try { rmSync(tmpDir, { recursive: true, force: true }); } catch { /* noop */ }
54
+ });
55
+
56
+ function writeSecretRecipe(): void {
57
+ writeFileSync(recipePath, JSON.stringify({
58
+ name: 'Secret Test',
59
+ agent: { systemPrompt: 'be helpful' },
60
+ mcpServers: {
61
+ gitlab: {
62
+ command: 'node',
63
+ env: { GITLAB_PERSONAL_ACCESS_TOKEN: '${CONHOST_TEST_SECRET}' },
64
+ },
65
+ },
66
+ }), 'utf-8');
67
+ }
68
+
69
+ test('resolved recipe carries the secret in memory; persistable keeps the ${VAR} literal', async () => {
70
+ process.env.CONHOST_TEST_SECRET = SECRET;
71
+ writeSecretRecipe();
72
+
73
+ const { recipe, persistable } = await loadRecipeDetailed(recipePath);
74
+ expect((recipe.mcpServers as any).gitlab.env.GITLAB_PERSONAL_ACCESS_TOKEN).toBe(SECRET);
75
+ expect((persistable.mcpServers as any).gitlab.env.GITLAB_PERSONAL_ACCESS_TOKEN)
76
+ .toBe('${CONHOST_TEST_SECRET}');
77
+ });
78
+
79
+ test('the saved .recipe.json never contains the substituted secret', async () => {
80
+ process.env.CONHOST_TEST_SECRET = SECRET;
81
+ writeSecretRecipe();
82
+
83
+ const { persistable } = await loadRecipeDetailed(recipePath);
84
+ saveRecipe(dataDir, persistable);
85
+
86
+ const onDisk = readFileSync(join(dataDir, '.recipe.json'), 'utf-8');
87
+ expect(onDisk).not.toContain(SECRET);
88
+ expect(onDisk).toContain('${CONHOST_TEST_SECRET}');
89
+ expect(JSON.parse(onDisk)[SAVED_RECIPE_UNRESOLVED_KEY]).toBe(true);
90
+ });
91
+
92
+ test('loadSavedRecipe re-resolves against the CURRENT environment (secret rotation)', async () => {
93
+ process.env.CONHOST_TEST_SECRET = SECRET;
94
+ writeSecretRecipe();
95
+ const { persistable } = await loadRecipeDetailed(recipePath);
96
+ saveRecipe(dataDir, persistable);
97
+
98
+ process.env.CONHOST_TEST_SECRET = 'tok-rotated-67890';
99
+ const resumed = await loadSavedRecipe(dataDir);
100
+ expect(resumed).not.toBeNull();
101
+ expect((resumed!.mcpServers as any).gitlab.env.GITLAB_PERSONAL_ACCESS_TOKEN)
102
+ .toBe('tok-rotated-67890');
103
+ });
104
+
105
+ test('loadSavedRecipe throws (not null) when a required var disappeared from the environment', async () => {
106
+ process.env.CONHOST_TEST_SECRET = SECRET;
107
+ writeSecretRecipe();
108
+ const { persistable } = await loadRecipeDetailed(recipePath);
109
+ saveRecipe(dataDir, persistable);
110
+
111
+ delete process.env.CONHOST_TEST_SECRET;
112
+ await expect(loadSavedRecipe(dataDir)).rejects.toThrow(/CONHOST_TEST_SECRET/);
113
+ });
114
+
115
+ test('saveRecipe writes the snapshot with mode 0600, and chmods a pre-existing looser file', async () => {
116
+ process.env.CONHOST_TEST_SECRET = SECRET;
117
+ writeSecretRecipe();
118
+ const { persistable } = await loadRecipeDetailed(recipePath);
119
+
120
+ saveRecipe(dataDir, persistable);
121
+ const path = join(dataDir, '.recipe.json');
122
+ expect(statSync(path).mode & 0o777).toBe(0o600);
123
+
124
+ // Legacy deployments have a world-readable resolved snapshot; an
125
+ // overwrite must tighten it even though writeFileSync's mode only
126
+ // applies at creation.
127
+ chmodSync(path, 0o644);
128
+ expect(statSync(path).mode & 0o777).toBe(0o644);
129
+ saveRecipe(dataDir, persistable);
130
+ expect(statSync(path).mode & 0o777).toBe(0o600);
131
+ });
132
+
133
+ test('round-trip with no ${} patterns is a faithful no-op resolution', async () => {
134
+ writeFileSync(recipePath, JSON.stringify({
135
+ name: 'Plain',
136
+ description: 'no env refs',
137
+ agent: { systemPrompt: 'hello', model: 'claude-opus-4-6' },
138
+ }), 'utf-8');
139
+ const { recipe, persistable } = await loadRecipeDetailed(recipePath);
140
+ saveRecipe(dataDir, persistable);
141
+ const resumed = await loadSavedRecipe(dataDir);
142
+ expect(resumed).not.toBeNull();
143
+ expect(resumed!.name).toBe('Plain');
144
+ expect(resumed!.agent.systemPrompt).toBe(recipe.agent.systemPrompt);
145
+ expect(resumed!.agent.model).toBe('claude-opus-4-6');
146
+ });
147
+
148
+ test('relative fleet child recipe paths are persisted absolute (resume has no source base)', async () => {
149
+ writeFileSync(recipePath, JSON.stringify({
150
+ name: 'Fleet Parent',
151
+ agent: { systemPrompt: 'parent' },
152
+ modules: {
153
+ fleet: { children: [{ name: 'child-a', recipe: 'child.json' }] },
154
+ },
155
+ }), 'utf-8');
156
+
157
+ const { recipe, persistable } = await loadRecipeDetailed(recipePath);
158
+ const expected = resolve(tmpDir, 'child.json');
159
+ expect((recipe.modules!.fleet as any).children[0].recipe).toBe(expected);
160
+ expect(((persistable.modules as any).fleet.children[0]).recipe).toBe(expected);
161
+
162
+ saveRecipe(dataDir, persistable);
163
+ const resumed = await loadSavedRecipe(dataDir);
164
+ expect((resumed!.modules!.fleet as any).children[0].recipe).toBe(expected);
165
+ });
166
+
167
+ describe('URL systemPrompt', () => {
168
+ test('persisted as the URL and re-fetched on resume, picking up prompt updates', async () => {
169
+ let fetchCount = 0;
170
+ globalThis.fetch = (async (input: any) => {
171
+ expect(String(input)).toBe('https://prompts.example/agent.txt');
172
+ fetchCount++;
173
+ return new Response(fetchCount === 1 ? 'FETCHED PROMPT v1' : 'FETCHED PROMPT v2');
174
+ }) as typeof fetch;
175
+
176
+ writeFileSync(recipePath, JSON.stringify({
177
+ name: 'URL Prompt',
178
+ agent: { systemPrompt: 'https://prompts.example/agent.txt' },
179
+ }), 'utf-8');
180
+
181
+ const { recipe, persistable } = await loadRecipeDetailed(recipePath);
182
+ expect(recipe.agent.systemPrompt).toBe('FETCHED PROMPT v1');
183
+ expect((persistable.agent as any).systemPrompt).toBe('https://prompts.example/agent.txt');
184
+
185
+ saveRecipe(dataDir, persistable);
186
+ const onDisk = readFileSync(join(dataDir, '.recipe.json'), 'utf-8');
187
+ expect(onDisk).not.toContain('FETCHED PROMPT');
188
+
189
+ const resumed = await loadSavedRecipe(dataDir);
190
+ expect(resumed!.agent.systemPrompt).toBe('FETCHED PROMPT v2');
191
+ expect(fetchCount).toBe(2);
192
+ });
193
+ });
194
+
195
+ describe('legacy resolved snapshots (no marker)', () => {
196
+ test('load verbatim with no substitution', async () => {
197
+ // Simulate an older host's save: fully resolved, no marker.
198
+ const legacy = {
199
+ name: 'Legacy',
200
+ agent: { systemPrompt: 'resolved prompt' },
201
+ mcpServers: { gitlab: { command: 'node', env: { TOKEN: SECRET } } },
202
+ };
203
+ mkdirSync(dataDir, { recursive: true });
204
+ writeFileSync(join(dataDir, '.recipe.json'), JSON.stringify(legacy, null, 2) + '\n', 'utf-8');
205
+
206
+ const resumed = await loadSavedRecipe(dataDir);
207
+ expect(resumed).not.toBeNull();
208
+ expect((resumed!.mcpServers as any).gitlab.env.TOKEN).toBe(SECRET);
209
+ });
210
+
211
+ test('a surviving literal ${...} in prose does not hard-fail the load', async () => {
212
+ delete process.env.DEFINITELY_NOT_SET_ANYWHERE;
213
+ const legacy = {
214
+ name: 'Legacy Prose',
215
+ agent: {
216
+ systemPrompt: 'To configure, set ${DEFINITELY_NOT_SET_ANYWHERE} in your .env file.',
217
+ },
218
+ };
219
+ mkdirSync(dataDir, { recursive: true });
220
+ writeFileSync(join(dataDir, '.recipe.json'), JSON.stringify(legacy) + '\n', 'utf-8');
221
+
222
+ const resumed = await loadSavedRecipe(dataDir);
223
+ expect(resumed).not.toBeNull();
224
+ expect(resumed!.agent.systemPrompt).toContain('${DEFINITELY_NOT_SET_ANYWHERE}');
225
+ });
226
+
227
+ test('corrupt JSON still returns null rather than throwing', async () => {
228
+ mkdirSync(dataDir, { recursive: true });
229
+ writeFileSync(join(dataDir, '.recipe.json'), '{not json', 'utf-8');
230
+ expect(await loadSavedRecipe(dataDir)).toBeNull();
231
+ });
232
+
233
+ test('missing snapshot returns null', async () => {
234
+ expect(await loadSavedRecipe(dataDir)).toBeNull();
235
+ });
236
+ });
237
+
238
+ test('loadRecipe wrapper still returns the resolved recipe', async () => {
239
+ process.env.CONHOST_TEST_SECRET = SECRET;
240
+ writeSecretRecipe();
241
+ const recipe = await loadRecipe(recipePath);
242
+ expect((recipe.mcpServers as any).gitlab.env.GITLAB_PERSONAL_ACCESS_TOKEN).toBe(SECRET);
243
+ });
244
+ });
@@ -0,0 +1,38 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { validateRecipe } from '../src/recipe.js';
3
+ import { buildFrameworkStrategy } from '../src/framework-strategy.js';
4
+
5
+ function recipe(strategy: Record<string, unknown>) {
6
+ return {
7
+ name: 'source-only-test',
8
+ agent: { systemPrompt: 'sys', strategy: { type: 'autobiographical', ...strategy } },
9
+ };
10
+ }
11
+
12
+ describe('compressionSourceOnly recipe flag', () => {
13
+ test('preserves the flag through validation', () => {
14
+ expect(validateRecipe(recipe({ compressionSourceOnly: true }))
15
+ .agent.strategy?.compressionSourceOnly).toBe(true);
16
+ expect(validateRecipe(recipe({ compressionSourceOnly: false }))
17
+ .agent.strategy?.compressionSourceOnly).toBe(false);
18
+ // absent stays undefined — every other resident is unaffected
19
+ expect(validateRecipe(recipe({})).agent.strategy?.compressionSourceOnly).toBeUndefined();
20
+ });
21
+
22
+ test('rejects a non-boolean value', () => {
23
+ expect(() => validateRecipe(recipe({ compressionSourceOnly: 'yes' })))
24
+ .toThrow(/compressionSourceOnly/);
25
+ expect(() => validateRecipe(recipe({ compressionSourceOnly: 1 })))
26
+ .toThrow(/compressionSourceOnly/);
27
+ });
28
+
29
+ test('plumbs through PASSTHROUGH_KEYS into the built strategy config', () => {
30
+ const parsed = validateRecipe(recipe({ compressionSourceOnly: true, summaryParticipant: 'mythos' }));
31
+ const built = buildFrameworkStrategy(parsed, 'claude-fable-5', 'UTC');
32
+ // buildFrameworkStrategy copies PASSTHROUGH_KEYS onto the strategy options;
33
+ // the flag must reach the AutobiographicalStrategy config.
34
+ const cfg = (built as unknown as { config?: Record<string, unknown> }).config
35
+ ?? (built as unknown as Record<string, unknown>);
36
+ expect(cfg.compressionSourceOnly).toBe(true);
37
+ });
38
+ });
@@ -0,0 +1,202 @@
1
+ // Black-box coverage of scripts/release-changelog.ts: each case runs the
2
+ // real script in a throwaway directory, so assembly, validation, and the
3
+ // deletion of consumed fragments are all exercised exactly as `npm version`
4
+ // would run them.
5
+ import { test } from "bun:test";
6
+ import assert from "node:assert/strict";
7
+ import { execFileSync } from "node:child_process";
8
+ import {
9
+ mkdirSync,
10
+ mkdtempSync,
11
+ readdirSync,
12
+ readFileSync,
13
+ writeFileSync,
14
+ } from "node:fs";
15
+ import { tmpdir } from "node:os";
16
+ import { dirname, join } from "node:path";
17
+ import { fileURLToPath } from "node:url";
18
+
19
+ const SCRIPT = fileURLToPath(
20
+ new URL("../scripts/release-changelog.ts", import.meta.url),
21
+ );
22
+
23
+ const BASE_CHANGELOG = [
24
+ "# Changelog",
25
+ "",
26
+ "Intro that mentions ## Unreleased inline, which must not count as a heading.",
27
+ "",
28
+ "## Unreleased",
29
+ "",
30
+ "## 1.0.0 — 2026-01-01",
31
+ "",
32
+ "### Fixed",
33
+ "",
34
+ "- Old entry.",
35
+ "",
36
+ ].join("\n");
37
+
38
+ interface Fixture {
39
+ version?: string;
40
+ changelog?: string;
41
+ fragments?: Record<string, string>;
42
+ }
43
+
44
+ function setup(f: Fixture): string {
45
+ const dir = mkdtempSync(join(tmpdir(), "release-changelog-"));
46
+ writeFileSync(
47
+ join(dir, "package.json"),
48
+ JSON.stringify({ name: "fixture", version: f.version ?? "1.1.0" }),
49
+ );
50
+ writeFileSync(join(dir, "CHANGELOG.md"), f.changelog ?? BASE_CHANGELOG);
51
+ mkdirSync(join(dir, "changelog.d"));
52
+ writeFileSync(join(dir, "changelog.d", "README.md"), "# Pending fragments\n");
53
+ for (const [name, body] of Object.entries(f.fragments ?? {})) {
54
+ const path = join(dir, "changelog.d", name);
55
+ mkdirSync(dirname(path), { recursive: true });
56
+ writeFileSync(path, body);
57
+ }
58
+ return dir;
59
+ }
60
+
61
+ function run(dir: string): { status: number; stdout: string; stderr: string } {
62
+ try {
63
+ const stdout = execFileSync(process.execPath, [SCRIPT], {
64
+ cwd: dir,
65
+ encoding: "utf8",
66
+ stdio: ["ignore", "pipe", "pipe"],
67
+ });
68
+ return { status: 0, stdout, stderr: "" };
69
+ } catch (e) {
70
+ const err = e as { status: number; stdout?: string; stderr?: string };
71
+ return { status: err.status, stdout: err.stdout ?? "", stderr: err.stderr ?? "" };
72
+ }
73
+ }
74
+
75
+ function section(text: string, version: string): string {
76
+ const lines = text.split("\n");
77
+ const start = lines.findIndex((l) => l.startsWith(`## ${version} — `));
78
+ assert.notEqual(start, -1, `no '## ${version}' section in:\n${text}`);
79
+ const rest = lines.slice(start + 1);
80
+ const end = rest.findIndex((l) => l.startsWith("## "));
81
+ return rest.slice(0, end === -1 ? undefined : end).join("\n").trim();
82
+ }
83
+
84
+ const changelog = (dir: string) => readFileSync(join(dir, "CHANGELOG.md"), "utf8");
85
+ const pending = (dir: string) => readdirSync(join(dir, "changelog.d")).sort();
86
+
87
+ test("folds fragments into a versioned section in canonical order and deletes them", () => {
88
+ const dir = setup({
89
+ fragments: {
90
+ "z-later.fixed.md": "- Fixed thing.\n",
91
+ "a-first.added.md": "- Added thing,\n continued on an indented line.\n",
92
+ "m.breaking.md": "- **Module authors:** breaking thing.\n",
93
+ },
94
+ });
95
+ const r = run(dir);
96
+ assert.equal(r.status, 0, r.stderr);
97
+ const text = changelog(dir);
98
+ assert.equal(
99
+ section(text, "1.1.0"),
100
+ [
101
+ "### Breaking",
102
+ "",
103
+ "- **Module authors:** breaking thing.",
104
+ "",
105
+ "### Added",
106
+ "",
107
+ "- Added thing,",
108
+ " continued on an indented line.",
109
+ "",
110
+ "### Fixed",
111
+ "",
112
+ "- Fixed thing.",
113
+ ].join("\n"),
114
+ );
115
+ assert.match(text, /^## Unreleased\n\n## 1\.1\.0 — \d{4}-\d{2}-\d{2}\n/m, "fresh empty Unreleased above the cut");
116
+ assert.ok(text.startsWith("# Changelog\n\nIntro that mentions"), "file header preserved");
117
+ assert.equal(section(text, "1.0.0"), "### Fixed\n\n- Old entry.", "older section untouched");
118
+ assert.deepEqual(pending(dir), ["README.md"], "consumed fragments deleted, README kept");
119
+ });
120
+
121
+ test("merges fragments into directly-filed Unreleased entries, reordering subsections canonically", () => {
122
+ const dir = setup({
123
+ changelog: BASE_CHANGELOG.replace(
124
+ "## Unreleased\n",
125
+ "## Unreleased\n\n### Fixed\n\n- Manual fix.\n\n### Added\n\n- Manual add.\n",
126
+ ),
127
+ fragments: { "x.fixed.md": "- Fragment fix.\n" },
128
+ });
129
+ const r = run(dir);
130
+ assert.equal(r.status, 0, r.stderr);
131
+ assert.equal(
132
+ section(changelog(dir), "1.1.0"),
133
+ "### Added\n\n- Manual add.\n\n### Fixed\n\n- Manual fix.\n\n- Fragment fix.",
134
+ );
135
+ });
136
+
137
+ test("breaking fragments join an audience-qualified Breaking heading", () => {
138
+ const dir = setup({
139
+ changelog: BASE_CHANGELOG.replace(
140
+ "## Unreleased\n",
141
+ "## Unreleased\n\n### Fixed\n\n- Manual fix.\n\n### Breaking (module authors only)\n\n- Manual break.\n",
142
+ ),
143
+ fragments: { "b.breaking.md": "- Fragment break.\n" },
144
+ });
145
+ const r = run(dir);
146
+ assert.equal(r.status, 0, r.stderr);
147
+ assert.equal(
148
+ section(changelog(dir), "1.1.0"),
149
+ "### Breaking (module authors only)\n\n- Manual break.\n\n- Fragment break.\n\n### Fixed\n\n- Manual fix.",
150
+ );
151
+ });
152
+
153
+ test("accepts nested bullets and multi-line continuations", () => {
154
+ const dir = setup({
155
+ fragments: { "n.fixed.md": "- one\n - nested\n more text\n- two\n" },
156
+ });
157
+ const r = run(dir);
158
+ assert.equal(r.status, 0, r.stderr);
159
+ assert.equal(section(changelog(dir), "1.1.0"), "### Fixed\n\n- one\n - nested\n more text\n- two");
160
+ });
161
+
162
+ test("accepts bullet content that merely resembles headings or rules", () => {
163
+ const body = "- **Module authors:** bold opener.\n- -1 is now the sentinel.\n- #123 is referenced inline.\n- ***emphasis*** then text.\n";
164
+ const dir = setup({ fragments: { "r.fixed.md": body } });
165
+ const r = run(dir);
166
+ assert.equal(r.status, 0, r.stderr);
167
+ assert.equal(section(changelog(dir), "1.1.0"), `### Fixed\n\n${body.trim()}`);
168
+ });
169
+
170
+ const refusals: Array<[string, Fixture, RegExp]> = [
171
+ ["nothing to release", {}, /nothing to release as 1\.1\.0/],
172
+ ["duplicate version section", { version: "1.0.0", fragments: { "a.fixed.md": "- x\n" } }, /'## 1\.0\.0' section already exists/],
173
+ ["unrecognized category suffix", { fragments: { "oops.md": "- x\n" } }, /changelog\.d\/oops\.md: unrecognized file/],
174
+ ["stray non-markdown file", { fragments: { "notes.txt": "hi\n", "a.fixed.md": "- x\n" } }, /changelog\.d\/notes\.txt: unrecognized file/],
175
+ ["empty fragment", { fragments: { "e.fixed.md": "\n" } }, /changelog\.d\/e\.fixed\.md: empty fragment/],
176
+ ["fragment nested under a branch-name directory", { fragments: { "fix/foo.fixed.md": "- x\n", "a.fixed.md": "- y\n" } }, /changelog\.d\/fix: not a file/],
177
+ ["plain prose", { fragments: { "p.fixed.md": "prose only\n" } }, /offending line: 'prose only'/],
178
+ ["prose after a valid bullet", { fragments: { "p.fixed.md": "- ok\nrogue prose\n" } }, /offending line: 'rogue prose'/],
179
+ ["top-level heading after a bullet", { fragments: { "h.fixed.md": "- ok\n\n## 9.9.9 — fake\n" } }, /offending line: '## 9\.9\.9 — fake'/],
180
+ ["indented ATX heading", { fragments: { "h.fixed.md": "- ok\n ## 8.8.8 — injected\n - beneath\n" } }, /offending line: ' ## 8\.8\.8 — injected'/],
181
+ ["indented setext underline / rule", { fragments: { "s.fixed.md": "- ok\n ---\n" } }, /offending line: ' ---'/],
182
+ ["spaced thematic break shaped like a bullet", { fragments: { "s.fixed.md": "- ok\n- - -\n" } }, /offending line: '- - -'/],
183
+ ["asterisk thematic break with spaces", { fragments: { "s.fixed.md": "- ok\n* * *\n" } }, /offending line: '\* \* \*'/],
184
+ ["rule as bullet content", { fragments: { "s.fixed.md": "- ---\n" } }, /offending line: '- ---'/],
185
+ ["heading as bullet content", { fragments: { "h.fixed.md": "- ## 9.9.9 — embedded heading\n" } }, /offending line: '- ## 9\.9\.9 — embedded heading'/],
186
+ ["heading as nested bullet content", { fragments: { "h.fixed.md": "- ok\n - ### nested heading\n" } }, /offending line: ' - ### nested heading'/],
187
+ ["tab-indented continuation", { fragments: { "t.fixed.md": "- ok\n\tcontinued\n" } }, /offending line: '\tcontinued'/],
188
+ ["no Unreleased heading", { changelog: "# Changelog\n\n## 1.0.0 — 2026-01-01\n\n- x\n", fragments: { "a.fixed.md": "- x\n" } }, /no '## Unreleased' section/],
189
+ ["two Unreleased headings", { changelog: BASE_CHANGELOG + "\n## Unreleased\n\n- stranded\n", fragments: { "a.fixed.md": "- x\n" } }, /2 '## Unreleased' headings/],
190
+ ];
191
+
192
+ for (const [name, fixture, message] of refusals) {
193
+ test(`refuses ${name} without touching anything`, () => {
194
+ const dir = setup(fixture);
195
+ const before = { changelog: changelog(dir), pending: pending(dir) };
196
+ const r = run(dir);
197
+ assert.equal(r.status, 1, `expected refusal, got exit ${r.status}:\n${r.stdout}${r.stderr}`);
198
+ assert.match(r.stderr, message);
199
+ assert.equal(changelog(dir), before.changelog, "CHANGELOG.md must be untouched");
200
+ assert.deepEqual(pending(dir), before.pending, "no fragment may be deleted on refusal");
201
+ });
202
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Ephemeral subagents must inherit the caller's proseRouting mode.
3
+ *
4
+ * AF's Agent defaults proseRouting to 'locus' (ambient locus capture
5
+ * publishes plain prose to the open channel). Before this fix,
6
+ * SubagentModule never passed proseRouting to createEphemeralAgent, so a
7
+ * resident running proseRouting 'disabled' still spawned divers whose
8
+ * between-tool-calls prose leaked into its live Zulip topic as parent
9
+ * speech — field-confirmed on a deployed resident, 2026-08-26, and
10
+ * reproduced again after the recipe adopted 'disabled' (the recipe value
11
+ * reached only the resident, never the divers).
12
+ *
13
+ * Harness follows subagent-async-timeout.test.ts: real framework + mock
14
+ * membrane, runEphemeralToCompletion stubbed — here to capture the
15
+ * ephemeral Agent it receives so the test can read its proseRouting.
16
+ */
17
+ import { describe, test, expect } from 'bun:test';
18
+ import { mkdtempSync, rmSync } from 'node:fs';
19
+ import { tmpdir } from 'node:os';
20
+ import { join } from 'node:path';
21
+ import { AgentFramework } from '@animalabs/agent-framework';
22
+ import type { Module, ToolCall } from '@animalabs/agent-framework';
23
+ import { Membrane, MockAdapter, NativeFormatter } from '@animalabs/membrane';
24
+ import { SubagentModule } from '../src/modules/subagent-module.js';
25
+
26
+ async function makeHarness(parentProseRouting?: 'locus' | 'explicit' | 'hybrid' | 'disabled') {
27
+ const tmpDir = mkdtempSync(join(tmpdir(), 'sub-prose-'));
28
+ const adapter = new MockAdapter({ defaultResponse: 'ok' });
29
+ const membrane = new Membrane(adapter, { formatter: new NativeFormatter() });
30
+ const subagent = new SubagentModule({
31
+ parentAgentName: 'parent',
32
+ defaultModel: 'mock',
33
+ defaultMaxTokens: 256,
34
+ maxRetries: 0,
35
+ });
36
+ const framework = await AgentFramework.create({
37
+ storePath: join(tmpDir, 'store'),
38
+ membrane,
39
+ agents: [{
40
+ name: 'parent',
41
+ model: 'mock',
42
+ systemPrompt: 'parent',
43
+ maxTokens: 256,
44
+ ...(parentProseRouting !== undefined ? { proseRouting: parentProseRouting } : {}),
45
+ }],
46
+ modules: [subagent as unknown as Module],
47
+ });
48
+ subagent.setFramework(framework);
49
+
50
+ // Capture the ephemeral Agent handed to the run loop; resolve immediately.
51
+ let captured: { proseRouting?: string; name?: string } | null = null;
52
+ const fw = framework as unknown as {
53
+ runEphemeralToCompletion: (agent: unknown, ctxMgr: unknown) => Promise<{ speech: string; toolCallsCount: number }>;
54
+ };
55
+ fw.runEphemeralToCompletion = async (agent: unknown) => {
56
+ captured = agent as { proseRouting?: string; name?: string };
57
+ return { speech: 'done', toolCallsCount: 0 };
58
+ };
59
+
60
+ const cleanup = async () => {
61
+ await framework.stop().catch(() => {});
62
+ rmSync(tmpDir, { recursive: true, force: true });
63
+ };
64
+ return { subagent, getCaptured: () => captured, cleanup };
65
+ }
66
+
67
+ function spawnCall(): ToolCall {
68
+ return {
69
+ id: 'tc-1',
70
+ name: 'spawn',
71
+ callerAgentName: 'parent',
72
+ input: {
73
+ name: 'probe',
74
+ systemPrompt: 'you are a probe',
75
+ task: 'probe the harness',
76
+ sync: true,
77
+ },
78
+ } as unknown as ToolCall;
79
+ }
80
+
81
+ describe('subagent prose-routing inheritance', () => {
82
+ test("spawned subagent inherits the parent's proseRouting 'disabled'", async () => {
83
+ const h = await makeHarness('disabled');
84
+ try {
85
+ const result = await h.subagent.handleToolCall(spawnCall());
86
+ if (!result.success) console.error('SPAWN ERROR:', result.error);
87
+ expect(result.success).toBe(true);
88
+ expect(h.getCaptured()).not.toBeNull();
89
+ expect(h.getCaptured()!.proseRouting).toBe('disabled');
90
+ } finally {
91
+ await h.cleanup();
92
+ }
93
+ });
94
+
95
+ test("parent without explicit proseRouting yields AF's default on the child ('locus')", async () => {
96
+ const h = await makeHarness(undefined);
97
+ try {
98
+ const result = await h.subagent.handleToolCall(spawnCall());
99
+ if (!result.success) console.error('SPAWN ERROR:', result.error);
100
+ expect(result.success).toBe(true);
101
+ // Parent's resolved mode is AF's default 'locus'; inheritance passes it
102
+ // through explicitly — same value the child would default to, asserted
103
+ // so a future AF default change keeps parent and child in lockstep.
104
+ expect(h.getCaptured()!.proseRouting).toBe('locus');
105
+ } finally {
106
+ await h.cleanup();
107
+ }
108
+ });
109
+ });
@@ -0,0 +1,68 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { resolve } from 'node:path';
3
+ import { buildWorkspaceMounts } from '../src/workspace-mounts.js';
4
+
5
+ /**
6
+ * Contract tests for the shared mount builder. validateRecipe's instructions
7
+ * cross-check reasons over this exact output, so these pin the properties the
8
+ * validator depends on — most importantly that `_config` is NOT
9
+ * auto-materialized (agent edits stay Chronicle-side between branch-changing
10
+ * commands), which is why the validator rejects it as an instructions path.
11
+ */
12
+ describe('buildWorkspaceMounts', () => {
13
+ test('workspace: false disables mounts entirely', () => {
14
+ expect(buildWorkspaceMounts(false, '/store')).toBeNull();
15
+ });
16
+
17
+ test('implicit default (omitted or true): input ro + products rw, neither auto-materialized', () => {
18
+ for (const ws of [undefined, true as const]) {
19
+ const mounts = buildWorkspaceMounts(ws, '/store')!;
20
+ expect(mounts.map((m) => m.name)).toEqual(['input', 'products']);
21
+ const [input, products] = mounts;
22
+ expect(input.mode).toBe('read-only');
23
+ expect(products.mode).toBe('read-write');
24
+ expect(input.autoMaterialize).toBeUndefined();
25
+ expect(products.autoMaterialize).toBeUndefined();
26
+ }
27
+ });
28
+
29
+ test('explicit mounts pass through declared fields and default mode/watch', () => {
30
+ const mounts = buildWorkspaceMounts({
31
+ mounts: [
32
+ { name: 'instructions', path: './instructions', autoMaterialize: true },
33
+ { name: 'refs', path: './refs', mode: 'read-only', watch: 'always' },
34
+ ],
35
+ }, '/store')!;
36
+ expect(mounts[0]).toMatchObject({
37
+ name: 'instructions',
38
+ path: resolve('./instructions'),
39
+ mode: 'read-write', // defaulted
40
+ watch: 'never', // defaulted (no chokidar by default)
41
+ autoMaterialize: true,
42
+ });
43
+ expect(mounts[1]).toMatchObject({ mode: 'read-only', watch: 'always' });
44
+ expect(mounts[1].autoMaterialize).toBeUndefined();
45
+ });
46
+
47
+ test('_config mount (configMount: true) is read-write and NOT auto-materialized', () => {
48
+ // THE contract behind rejecting `_config/...` as an instructions path:
49
+ // the host materializes this mount only after branch-changing commands,
50
+ // never on ordinary agent writes. If this test starts failing because
51
+ // `_config` gained autoMaterialize, revisit the validator's rejection.
52
+ const mounts = buildWorkspaceMounts({ mounts: [], configMount: true }, '/store')!;
53
+ const config = mounts.find((m) => m.name === '_config')!;
54
+ expect(config).toBeDefined();
55
+ expect(config.mode).toBe('read-write');
56
+ expect(config.autoMaterialize).toBeUndefined();
57
+ expect(config.path).toBe(resolve('/store/config'));
58
+ expect(config.watch).toBe('always');
59
+ });
60
+
61
+ test('configMount composes with the implicit default mounts only in object form', () => {
62
+ // Matches the host: `workspace: true` cannot request the config mount.
63
+ const objForm = buildWorkspaceMounts({ mounts: undefined as never, configMount: true }, '/s');
64
+ expect((objForm ?? []).some((m) => m.name === '_config')).toBe(true);
65
+ const boolForm = buildWorkspaceMounts(true, '/s')!;
66
+ expect(boolForm.some((m) => m.name === '_config')).toBe(false);
67
+ });
68
+ });
package/web/src/App.tsx CHANGED
@@ -2065,6 +2065,7 @@ const COMMANDS: CommandHint[] = [
2065
2065
  { name: '/restore', blurb: 'switch to checkpoint' },
2066
2066
  { name: '/undo', blurb: 'revert before last agent turn' },
2067
2067
  { name: '/redo', blurb: 're-apply last undone action' },
2068
+ { name: '/nudge', blurb: 'run inference on current context, no new events' },
2068
2069
  { name: '/history', blurb: 'recent messages' },
2069
2070
  { name: '/lessons', blurb: 'list active lessons' },
2070
2071
  { name: '/export', blurb: 'export lessons to ./output/' },