@intentius/chant-lexicon-fountain 0.33.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 (171) hide show
  1. package/README.md +63 -0
  2. package/dist/codegen/docs-cli.d.ts +3 -0
  3. package/dist/codegen/docs-cli.d.ts.map +1 -0
  4. package/dist/codegen/docs.d.ts +16 -0
  5. package/dist/codegen/docs.d.ts.map +1 -0
  6. package/dist/codegen/generate-cli.d.ts +3 -0
  7. package/dist/codegen/generate-cli.d.ts.map +1 -0
  8. package/dist/codegen/generate-lexicon.d.ts +24 -0
  9. package/dist/codegen/generate-lexicon.d.ts.map +1 -0
  10. package/dist/codegen/generate-typescript.d.ts +11 -0
  11. package/dist/codegen/generate-typescript.d.ts.map +1 -0
  12. package/dist/codegen/generate.d.ts +15 -0
  13. package/dist/codegen/generate.d.ts.map +1 -0
  14. package/dist/codegen/naming.d.ts +14 -0
  15. package/dist/codegen/naming.d.ts.map +1 -0
  16. package/dist/codegen/package.d.ts +17 -0
  17. package/dist/codegen/package.d.ts.map +1 -0
  18. package/dist/composites/concierge-stack.d.ts +44 -0
  19. package/dist/composites/concierge-stack.d.ts.map +1 -0
  20. package/dist/coverage.d.ts +56 -0
  21. package/dist/coverage.d.ts.map +1 -0
  22. package/dist/describe-resources.d.ts +45 -0
  23. package/dist/describe-resources.d.ts.map +1 -0
  24. package/dist/detect.d.ts +10 -0
  25. package/dist/detect.d.ts.map +1 -0
  26. package/dist/entity-props.d.ts +11 -0
  27. package/dist/entity-props.d.ts.map +1 -0
  28. package/dist/export-resources.d.ts +29 -0
  29. package/dist/export-resources.d.ts.map +1 -0
  30. package/dist/generated/index.d.ts +5 -0
  31. package/dist/generated/index.d.ts.map +1 -0
  32. package/dist/generated/runtime.d.ts +5 -0
  33. package/dist/generated/runtime.d.ts.map +1 -0
  34. package/dist/import/generator.d.ts +15 -0
  35. package/dist/import/generator.d.ts.map +1 -0
  36. package/dist/import/parser.d.ts +19 -0
  37. package/dist/import/parser.d.ts.map +1 -0
  38. package/dist/index.d.ts +8 -0
  39. package/dist/index.d.ts.map +1 -0
  40. package/dist/init-templates.d.ts +13 -0
  41. package/dist/init-templates.d.ts.map +1 -0
  42. package/dist/integrity.json +21 -0
  43. package/dist/lint/audit-catalog.d.ts +15 -0
  44. package/dist/lint/audit-catalog.d.ts.map +1 -0
  45. package/dist/lint/post-synth/ftn010-networking-explicit.d.ts +11 -0
  46. package/dist/lint/post-synth/ftn010-networking-explicit.d.ts.map +1 -0
  47. package/dist/lint/post-synth/ftn011-no-unrestricted-networking.d.ts +11 -0
  48. package/dist/lint/post-synth/ftn011-no-unrestricted-networking.d.ts.map +1 -0
  49. package/dist/lint/post-synth/ftn012-no-cloud-credential-env.d.ts +3 -0
  50. package/dist/lint/post-synth/ftn012-no-cloud-credential-env.d.ts.map +1 -0
  51. package/dist/lint/post-synth/ftn013-substitution-resolvable.d.ts +3 -0
  52. package/dist/lint/post-synth/ftn013-substitution-resolvable.d.ts.map +1 -0
  53. package/dist/lint/post-synth/ftn014-vault-shadowing.d.ts +3 -0
  54. package/dist/lint/post-synth/ftn014-vault-shadowing.d.ts.map +1 -0
  55. package/dist/lint/post-synth/ftn015-mcp-secret-env-substitution.d.ts +3 -0
  56. package/dist/lint/post-synth/ftn015-mcp-secret-env-substitution.d.ts.map +1 -0
  57. package/dist/lint/post-synth/ftn016-runtime-model-valid.d.ts +3 -0
  58. package/dist/lint/post-synth/ftn016-runtime-model-valid.d.ts.map +1 -0
  59. package/dist/lint/post-synth/ftn017-unique-resource-names.d.ts +11 -0
  60. package/dist/lint/post-synth/ftn017-unique-resource-names.d.ts.map +1 -0
  61. package/dist/lint/post-synth/index.d.ts +3 -0
  62. package/dist/lint/post-synth/index.d.ts.map +1 -0
  63. package/dist/lint/rules/ftn001-no-secret-literals.d.ts +3 -0
  64. package/dist/lint/rules/ftn001-no-secret-literals.d.ts.map +1 -0
  65. package/dist/lint/rules/index.d.ts +5 -0
  66. package/dist/lint/rules/index.d.ts.map +1 -0
  67. package/dist/lsp/completions.d.ts +7 -0
  68. package/dist/lsp/completions.d.ts.map +1 -0
  69. package/dist/lsp/hover.d.ts +4 -0
  70. package/dist/lsp/hover.d.ts.map +1 -0
  71. package/dist/manifest.json +6 -0
  72. package/dist/meta.json +69 -0
  73. package/dist/op/activities/fountain-apply.d.ts +76 -0
  74. package/dist/op/activities/fountain-apply.d.ts.map +1 -0
  75. package/dist/op/activities/fountain-run.d.ts +36 -0
  76. package/dist/op/activities/fountain-run.d.ts.map +1 -0
  77. package/dist/op/activities/index.d.ts +11 -0
  78. package/dist/op/activities/index.d.ts.map +1 -0
  79. package/dist/package-cli.d.ts +3 -0
  80. package/dist/package-cli.d.ts.map +1 -0
  81. package/dist/plugin.d.ts +8 -0
  82. package/dist/plugin.d.ts.map +1 -0
  83. package/dist/reference-catalog.d.ts +16 -0
  84. package/dist/reference-catalog.d.ts.map +1 -0
  85. package/dist/rules/ftn001-no-secret-literals.ts +71 -0
  86. package/dist/rules/ftn010-networking-explicit.ts +37 -0
  87. package/dist/rules/ftn011-no-unrestricted-networking.ts +36 -0
  88. package/dist/rules/ftn012-no-cloud-credential-env.ts +58 -0
  89. package/dist/rules/ftn013-substitution-resolvable.ts +93 -0
  90. package/dist/rules/ftn014-vault-shadowing.ts +61 -0
  91. package/dist/rules/ftn015-mcp-secret-env-substitution.ts +49 -0
  92. package/dist/rules/ftn016-runtime-model-valid.ts +50 -0
  93. package/dist/rules/ftn017-unique-resource-names.ts +43 -0
  94. package/dist/serializer.d.ts +3 -0
  95. package/dist/serializer.d.ts.map +1 -0
  96. package/dist/skills/chant-fountain-locked-sandboxes.md +34 -0
  97. package/dist/skills/chant-fountain-secrets.md +29 -0
  98. package/dist/skills/chant-fountain.md +45 -0
  99. package/dist/spec/fetch.d.ts +28 -0
  100. package/dist/spec/fetch.d.ts.map +1 -0
  101. package/dist/spec/parse.d.ts +58 -0
  102. package/dist/spec/parse.d.ts.map +1 -0
  103. package/dist/types/index.d.ts +60 -0
  104. package/dist/validate-cli.d.ts +3 -0
  105. package/dist/validate-cli.d.ts.map +1 -0
  106. package/dist/validate.d.ts +15 -0
  107. package/dist/validate.d.ts.map +1 -0
  108. package/package.json +61 -0
  109. package/src/codegen/docs-cli.ts +7 -0
  110. package/src/codegen/docs.ts +278 -0
  111. package/src/codegen/generate-cli.ts +8 -0
  112. package/src/codegen/generate-lexicon.ts +58 -0
  113. package/src/codegen/generate-typescript.ts +69 -0
  114. package/src/codegen/generate.ts +109 -0
  115. package/src/codegen/naming.ts +45 -0
  116. package/src/codegen/package.ts +32 -0
  117. package/src/composites/composites.test.ts +35 -0
  118. package/src/composites/concierge-stack.ts +71 -0
  119. package/src/coverage.test.ts +63 -0
  120. package/src/coverage.ts +178 -0
  121. package/src/describe-resources.test.ts +203 -0
  122. package/src/describe-resources.ts +192 -0
  123. package/src/detect.ts +37 -0
  124. package/src/entity-props.ts +22 -0
  125. package/src/export-resources.ts +103 -0
  126. package/src/generated/.gitkeep +0 -0
  127. package/src/generated/index.d.ts +60 -0
  128. package/src/generated/index.ts +10 -0
  129. package/src/generated/lexicon-fountain.json +69 -0
  130. package/src/generated/runtime.ts +4 -0
  131. package/src/import/generator.ts +112 -0
  132. package/src/import/parser.ts +83 -0
  133. package/src/import/roundtrip.test.ts +113 -0
  134. package/src/index.ts +17 -0
  135. package/src/init-templates.ts +60 -0
  136. package/src/lint/audit-catalog.ts +107 -0
  137. package/src/lint/post-synth/ftn010-networking-explicit.ts +37 -0
  138. package/src/lint/post-synth/ftn011-no-unrestricted-networking.ts +36 -0
  139. package/src/lint/post-synth/ftn012-no-cloud-credential-env.ts +58 -0
  140. package/src/lint/post-synth/ftn013-substitution-resolvable.ts +93 -0
  141. package/src/lint/post-synth/ftn014-vault-shadowing.ts +61 -0
  142. package/src/lint/post-synth/ftn015-mcp-secret-env-substitution.ts +49 -0
  143. package/src/lint/post-synth/ftn016-runtime-model-valid.ts +50 -0
  144. package/src/lint/post-synth/ftn017-unique-resource-names.ts +43 -0
  145. package/src/lint/post-synth/index.ts +21 -0
  146. package/src/lint/post-synth/post-synth.test.ts +133 -0
  147. package/src/lint/rules/ftn001-no-secret-literals.ts +71 -0
  148. package/src/lint/rules/index.ts +7 -0
  149. package/src/lint/rules/rules.test.ts +126 -0
  150. package/src/lsp/completions.test.ts +51 -0
  151. package/src/lsp/completions.ts +21 -0
  152. package/src/lsp/hover.test.ts +47 -0
  153. package/src/lsp/hover.ts +70 -0
  154. package/src/op/activities/fountain-apply.test.ts +208 -0
  155. package/src/op/activities/fountain-apply.ts +249 -0
  156. package/src/op/activities/fountain-run.ts +95 -0
  157. package/src/op/activities/index.ts +28 -0
  158. package/src/package-cli.ts +26 -0
  159. package/src/plugin.test.ts +73 -0
  160. package/src/plugin.ts +170 -0
  161. package/src/reference-catalog.ts +27 -0
  162. package/src/serializer.test.ts +109 -0
  163. package/src/serializer.ts +143 -0
  164. package/src/skills/chant-fountain-locked-sandboxes.md +34 -0
  165. package/src/skills/chant-fountain-secrets.md +29 -0
  166. package/src/skills/chant-fountain.md +45 -0
  167. package/src/spec/fetch.ts +53 -0
  168. package/src/spec/fountain-openapi.snapshot.json +1 -0
  169. package/src/spec/parse.ts +309 -0
  170. package/src/validate-cli.ts +13 -0
  171. package/src/validate.ts +34 -0
@@ -0,0 +1,70 @@
1
+ import { createRequire } from "module";
2
+ import type { HoverContext, HoverInfo } from "@intentius/chant/lsp/types";
3
+ import { LexiconIndex, lexiconHover, type LexiconEntry } from "@intentius/chant/lsp/lexicon-providers";
4
+ const require = createRequire(import.meta.url);
5
+
6
+ let cachedIndex: LexiconIndex | null = null;
7
+
8
+ function getIndex(): LexiconIndex {
9
+ if (cachedIndex) return cachedIndex;
10
+ const data = require("../generated/lexicon-fountain.json") as Record<string, LexiconEntry>;
11
+ cachedIndex = new LexiconIndex(data);
12
+ return cachedIndex;
13
+ }
14
+
15
+ /**
16
+ * Per-kind notes. The security-relevant semantics (deny-all networking,
17
+ * vault precedence, conversation-scoped vault attachment) are the ones an
18
+ * author gets wrong from the property names alone, so hover carries them
19
+ * rather than leaving them to the skills.
20
+ */
21
+ const KIND_NOTES: Record<string, string> = {
22
+ Environment:
23
+ "Sandbox baseline — packages, repositories, env_vars, secrets, networking.\n\n" +
24
+ "`networking_type: limited` restricts egress to `networking_config.allowed_hosts`; " +
25
+ "an empty list denies all egress. `unrestricted` is open — FTN010/FTN011 flag both silence and openness.",
26
+ Vault:
27
+ "Env-var overrides attached at conversation create. Vault values win on key " +
28
+ "collision with the environment, silently — FTN014 surfaces the shadowing in review.",
29
+ Agent:
30
+ "A runnable agent config bound to one Environment. `allowed_vault_ids`: " +
31
+ "`null` allows any tenant vault, `[]` forbids all, a list is an allowlist — " +
32
+ "set `[]` when the reviewed environment must not be overridable at spawn.",
33
+ };
34
+
35
+ /** Enum-valued props are worth spelling out inline — they are the typo surface. */
36
+ function enumLines(entry: LexiconEntry): string[] {
37
+ const constraints = entry.propertyConstraints as
38
+ | Record<string, { enum?: unknown[] }>
39
+ | undefined;
40
+ if (!constraints) return [];
41
+
42
+ const lines: string[] = [];
43
+ for (const [prop, c] of Object.entries(constraints)) {
44
+ if (!Array.isArray(c?.enum) || c.enum.length === 0) continue;
45
+ lines.push(`- \`${prop}\`: ${c.enum.map((v) => `\`${String(v)}\``).join(" | ")}`);
46
+ }
47
+ return lines;
48
+ }
49
+
50
+ function fountainHover(className: string, entry: LexiconEntry): HoverInfo | undefined {
51
+ const lines: string[] = [`**${className}**`, "", `Fountain type: \`${entry.resourceType}\``];
52
+
53
+ if (entry.kind === "property") {
54
+ lines.push("", "*Property type — a nested shape, not a standalone declarable.*");
55
+ return { contents: lines.join("\n") };
56
+ }
57
+
58
+ const note = KIND_NOTES[className];
59
+ if (note) lines.push("", note);
60
+
61
+ const enums = enumLines(entry);
62
+ if (enums.length > 0) lines.push("", "Enumerated properties:", ...enums);
63
+
64
+ return { contents: lines.join("\n") };
65
+ }
66
+
67
+ /** Provide LSP hover information for fountain resources. */
68
+ export function hover(ctx: HoverContext): HoverInfo | undefined {
69
+ return lexiconHover(ctx, getIndex(), fountainHover);
70
+ }
@@ -0,0 +1,208 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ fountainApply,
4
+ splitSecrets,
5
+ isChantOwned,
6
+ resolveEndpoint,
7
+ parsePlan,
8
+ type FountainHttp,
9
+ type FountainPlan,
10
+ } from "./fountain-apply";
11
+ import { fountainRun } from "./fountain-run";
12
+
13
+ interface Call {
14
+ method: string;
15
+ path: string;
16
+ body?: unknown;
17
+ }
18
+
19
+ /** Scripted fake http: records calls, answers from a route table. */
20
+ function fakeHttp(routes: Record<string, { status: number; json?: unknown }>): {
21
+ http: FountainHttp;
22
+ calls: Call[];
23
+ } {
24
+ const calls: Call[] = [];
25
+ const http: FountainHttp = async (method, path, body) => {
26
+ calls.push({ method, path, body });
27
+ const key = `${method} ${path}`;
28
+ const hit = routes[key];
29
+ if (!hit) throw new Error(`unrouted: ${key}`);
30
+ return { status: hit.status, json: hit.json ?? null };
31
+ };
32
+ return { http, calls };
33
+ }
34
+
35
+ const EMPTY_LISTS = {
36
+ "GET /api/environments": { status: 200, json: { data: [] } },
37
+ "GET /api/vaults": { status: 200, json: { data: [] } },
38
+ "GET /api/agents": { status: 200, json: { data: [] } },
39
+ };
40
+
41
+ describe("pure helpers", () => {
42
+ it("resolveEndpoint precedence: arg > env > default", () => {
43
+ expect(resolveEndpoint({ endpoint: "http://x/" }, {})).toBe("http://x");
44
+ expect(resolveEndpoint({}, { FOUNTAIN_ENDPOINT: "http://env" })).toBe("http://env");
45
+ expect(resolveEndpoint({}, {})).toBe("https://founta.inevitable.fyi");
46
+ });
47
+
48
+ it("splitSecrets extracts valid entries and leaves the body clean", () => {
49
+ const { body, secrets } = splitSecrets({
50
+ name: "e",
51
+ secrets: [{ key: "K", value: "v" }, { bogus: true }],
52
+ });
53
+ expect(body).toEqual({ name: "e" });
54
+ expect(secrets).toEqual([{ key: "K", value: "v" }]);
55
+ });
56
+
57
+ it("isChantOwned keys on the metadata marker", () => {
58
+ expect(isChantOwned({ metadata: { "managed-by": "chant" } })).toBe(true);
59
+ expect(isChantOwned({ metadata: { "managed-by": "human" } })).toBe(false);
60
+ expect(isChantOwned({})).toBe(false);
61
+ });
62
+ });
63
+
64
+ describe("fountainApply", () => {
65
+ it("creates in Environment -> Vault -> Agent order and resolves the environment ref", async () => {
66
+ const plan: FountainPlan = {
67
+ researcher: {
68
+ kind: "Agent",
69
+ spec: { name: "researcher", model: "a/m", runtime: "claude", environment: "conciergeEnv" },
70
+ },
71
+ conciergeEnv: { kind: "Environment", spec: { name: "concierge-env", networking_type: "limited" } },
72
+ };
73
+
74
+ const { http, calls } = fakeHttp({
75
+ ...EMPTY_LISTS,
76
+ "POST /api/environments": { status: 201, json: { data: { id: "env-1" } } },
77
+ "POST /api/agents": { status: 201, json: { data: { id: "agent-1" } } },
78
+ });
79
+
80
+ const summary = await fountainApply({ planContent: JSON.stringify(plan) }, http);
81
+
82
+ const mutations = calls.filter((c) => c.method === "POST");
83
+ expect(mutations.map((c) => c.path)).toEqual(["/api/environments", "/api/agents"]);
84
+ const agentBody = mutations[1].body as Record<string, unknown>;
85
+ expect(agentBody.environment_id).toBe("env-1");
86
+ expect(agentBody.environment).toBeUndefined();
87
+ expect(summary.created).toEqual(["Environment/concierge-env", "Agent/researcher"]);
88
+ });
89
+
90
+ it("updates by name when the resource already exists", async () => {
91
+ const plan: FountainPlan = {
92
+ v: { kind: "Vault", spec: { name: "staging-creds", description: "d" } },
93
+ };
94
+ const { http, calls } = fakeHttp({
95
+ ...EMPTY_LISTS,
96
+ "GET /api/vaults": { status: 200, json: { data: [{ id: "v-9", name: "staging-creds" }] } },
97
+ "PUT /api/vaults/v-9": { status: 200, json: { data: { id: "v-9" } } },
98
+ });
99
+
100
+ const summary = await fountainApply({ planContent: JSON.stringify(plan) }, http);
101
+ expect(summary.updated).toEqual(["Vault/staging-creds"]);
102
+ expect(calls.some((c) => c.method === "PUT" && c.path === "/api/vaults/v-9")).toBe(true);
103
+ });
104
+
105
+ it("upserts secrets through the sub-resource", async () => {
106
+ const plan: FountainPlan = {
107
+ e: {
108
+ kind: "Environment",
109
+ spec: { name: "e", secrets: [{ key: "OPENAI_API_KEY", value: "${FROM_PROVIDER}" }] },
110
+ },
111
+ };
112
+ const { http, calls } = fakeHttp({
113
+ ...EMPTY_LISTS,
114
+ "POST /api/environments": { status: 201, json: { data: { id: "env-1" } } },
115
+ "POST /api/environments/env-1/secrets": { status: 201 },
116
+ });
117
+
118
+ const summary = await fountainApply({ planContent: JSON.stringify(plan) }, http);
119
+ expect(summary.secretsUpserted).toBe(1);
120
+ const create = calls.find((c) => c.method === "POST" && c.path === "/api/environments")!;
121
+ expect((create.body as Record<string, unknown>).secrets).toBeUndefined();
122
+ });
123
+
124
+ it("prunes only chant-owned resources, in reverse kind order", async () => {
125
+ const plan: FountainPlan = {};
126
+ const { http, calls } = fakeHttp({
127
+ "GET /api/environments": {
128
+ status: 200,
129
+ json: { data: [{ id: "e-1", name: "owned-env", metadata: { "managed-by": "chant" } }] },
130
+ },
131
+ "GET /api/vaults": { status: 200, json: { data: [] } },
132
+ "GET /api/agents": {
133
+ status: 200,
134
+ json: {
135
+ data: [
136
+ { id: "a-1", name: "owned-agent", metadata: { "managed-by": "chant" } },
137
+ { id: "a-2", name: "human-agent", metadata: {} },
138
+ ],
139
+ },
140
+ },
141
+ "DELETE /api/agents/a-1": { status: 204 },
142
+ "DELETE /api/environments/e-1": { status: 204 },
143
+ });
144
+
145
+ const summary = await fountainApply({ planContent: "{}", prune: true }, http);
146
+ const deletes = calls.filter((c) => c.method === "DELETE").map((c) => c.path);
147
+ expect(deletes).toEqual(["/api/agents/a-1", "/api/environments/e-1"]);
148
+ expect(summary.pruned).toEqual(["Agent/owned-agent", "Environment/owned-env"]);
149
+ });
150
+
151
+ it("parsePlan round-trips", () => {
152
+ const plan = parsePlan('{"e":{"kind":"Environment","spec":{"name":"e"}}}');
153
+ expect(plan.e.kind).toBe("Environment");
154
+ });
155
+ });
156
+
157
+ describe("fountainRun", () => {
158
+ it("resolves the agent by name, starts, and polls to a terminal status", async () => {
159
+ let polls = 0;
160
+ const http: FountainHttp = async (method, path, body) => {
161
+ if (path.startsWith("/api/agents?search=")) {
162
+ return { status: 200, json: { data: [{ id: "agent-1", name: "researcher" }] } };
163
+ }
164
+ if (method === "POST" && path === "/api/conversations") {
165
+ expect((body as Record<string, unknown>).agent_id).toBe("agent-1");
166
+ return { status: 201, json: { data: { id: "conv-1" } } };
167
+ }
168
+ if (method === "GET" && path === "/api/conversations/conv-1") {
169
+ polls += 1;
170
+ return {
171
+ status: 200,
172
+ json: { data: { status: polls < 3 ? "running" : "completed" } },
173
+ };
174
+ }
175
+ throw new Error(`unrouted: ${method} ${path}`);
176
+ };
177
+
178
+ const result = await fountainRun(
179
+ { agent: "researcher", prompt: "hi", pollMs: 1, sleep: async () => {} },
180
+ http,
181
+ );
182
+ expect(result).toEqual({ conversationId: "conv-1", status: "completed", terminatedByDeadline: false });
183
+ });
184
+
185
+ it("terminates the conversation when the deadline passes", async () => {
186
+ const calls: string[] = [];
187
+ const http: FountainHttp = async (method, path) => {
188
+ calls.push(`${method} ${path}`);
189
+ if (path === "/api/conversations" && method === "POST") {
190
+ return { status: 201, json: { data: { id: "conv-2" } } };
191
+ }
192
+ if (method === "GET") return { status: 200, json: { data: { status: "running" } } };
193
+ return { status: 200, json: null };
194
+ };
195
+
196
+ const result = await fountainRun(
197
+ {
198
+ agent: "123e4567-e89b-42d3-a456-426614174000",
199
+ timeoutMs: 1,
200
+ pollMs: 1,
201
+ sleep: async () => {},
202
+ },
203
+ http,
204
+ );
205
+ expect(result.terminatedByDeadline).toBe(true);
206
+ expect(calls).toContain("POST /api/conversations/conv-2/terminate");
207
+ });
208
+ });
@@ -0,0 +1,249 @@
1
+ /**
2
+ * fountain native applier.
3
+ *
4
+ * Direct-REST reconciler against fountain's API (the same surface
5
+ * `fountain apply` uses, but API-first: create-if-new, update-if-changed
6
+ * by name, optional owned-only prune). Input is the serializer's
7
+ * `fountain-plan.json` sidecar — entity name → { kind, spec } — so no
8
+ * YAML parsing happens on the apply side.
9
+ *
10
+ * Apply order is Environment → Vault → Agent: agents reference their
11
+ * environment by entity name in the plan, resolved to the live id here
12
+ * (mirroring the CLI's name→id resolution in apply.go).
13
+ *
14
+ * Secrets: `spec.secrets` entries ({key, value}) are stripped from the
15
+ * resource body and upserted through the secrets sub-resource. Values are
16
+ * write-only upstream, so this is upsert-always — a changed value cannot
17
+ * be detected, only overwritten. (BinaryBourbon/fountain#148's reference
18
+ * model would make this a plain diff; until then, upsert-always is the
19
+ * honest semantic.)
20
+ *
21
+ * Endpoint/token resolution: explicit args win, then FOUNTAIN_ENDPOINT /
22
+ * FOUNTAIN_TOKEN, then the hosted default endpoint.
23
+ */
24
+
25
+ import { readFileSync } from "node:fs";
26
+
27
+ export const DEFAULT_FOUNTAIN_BASE_URL = "https://founta.inevitable.fyi";
28
+
29
+ /** Ownership marker checked by the owned-only prune. */
30
+ export const OWNERSHIP_KEY = "managed-by";
31
+ export const OWNERSHIP_VALUE = "chant";
32
+
33
+ const KIND_PATHS: Record<string, string> = {
34
+ Environment: "environments",
35
+ Vault: "vaults",
36
+ Agent: "agents",
37
+ };
38
+
39
+ /** Environments before vaults before agents; prune runs in reverse. */
40
+ const APPLY_ORDER = ["Environment", "Vault", "Agent"] as const;
41
+
42
+ export interface PlanEntry {
43
+ kind: string;
44
+ spec: Record<string, unknown>;
45
+ }
46
+
47
+ export type FountainPlan = Record<string, PlanEntry>;
48
+
49
+ export interface FountainHttp {
50
+ (method: string, path: string, body?: unknown): Promise<{ status: number; json: unknown }>;
51
+ }
52
+
53
+ export interface FountainApplyArgs {
54
+ /** Path to the serializer's fountain-plan.json. */
55
+ planPath?: string;
56
+ /** Inline plan content (takes precedence over planPath). */
57
+ planContent?: string;
58
+ endpoint?: string;
59
+ token?: string;
60
+ /** Delete chant-owned resources absent from the plan. Off by default. */
61
+ prune?: boolean;
62
+ }
63
+
64
+ export interface FountainApplySummary {
65
+ created: string[];
66
+ updated: string[];
67
+ pruned: string[];
68
+ secretsUpserted: number;
69
+ }
70
+
71
+ // ── Pure helpers ──────────────────────────────────────────────────────────
72
+
73
+ export function resolveEndpoint(
74
+ args: { endpoint?: string } = {},
75
+ env: NodeJS.ProcessEnv = process.env,
76
+ ): string {
77
+ const base = args.endpoint || env.FOUNTAIN_ENDPOINT || DEFAULT_FOUNTAIN_BASE_URL;
78
+ return base.replace(/\/$/, "");
79
+ }
80
+
81
+ export function resolveToken(
82
+ args: { token?: string } = {},
83
+ env: NodeJS.ProcessEnv = process.env,
84
+ ): string {
85
+ const token = args.token || env.FOUNTAIN_TOKEN;
86
+ if (!token) throw new Error("fountainApply: no token — set FOUNTAIN_TOKEN or pass token");
87
+ return token;
88
+ }
89
+
90
+ export function parsePlan(content: string): FountainPlan {
91
+ return JSON.parse(content) as FountainPlan;
92
+ }
93
+
94
+ /** Split spec.secrets ({key, value}[]) from the resource body. Pure. */
95
+ export function splitSecrets(spec: Record<string, unknown>): {
96
+ body: Record<string, unknown>;
97
+ secrets: Array<{ key: string; value: string }>;
98
+ } {
99
+ const { secrets, ...body } = spec;
100
+ if (!Array.isArray(secrets)) return { body: spec, secrets: [] };
101
+ const valid = secrets.filter(
102
+ (s): s is { key: string; value: string } =>
103
+ !!s && typeof s === "object" && typeof (s as { key?: unknown }).key === "string",
104
+ );
105
+ return { body, secrets: valid };
106
+ }
107
+
108
+ /** Is a live resource chant-owned (by its metadata marker)? Pure. */
109
+ export function isChantOwned(resource: { metadata?: unknown }): boolean {
110
+ const meta = resource.metadata;
111
+ if (!meta || typeof meta !== "object") return false;
112
+ return (meta as Record<string, unknown>)[OWNERSHIP_KEY] === OWNERSHIP_VALUE;
113
+ }
114
+
115
+ // ── HTTP ──────────────────────────────────────────────────────────────────
116
+
117
+ export function defaultFountainHttp(endpoint: string, token: string): FountainHttp {
118
+ return async (method, path, body) => {
119
+ const res = await fetch(`${endpoint}${path}`, {
120
+ method,
121
+ headers: {
122
+ authorization: `Bearer ${token}`,
123
+ ...(body !== undefined ? { "content-type": "application/json" } : {}),
124
+ },
125
+ ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
126
+ });
127
+ let json: unknown = null;
128
+ try {
129
+ json = await res.json();
130
+ } catch {
131
+ // 204s and empty bodies are fine.
132
+ }
133
+ return { status: res.status, json };
134
+ };
135
+ }
136
+
137
+ // ── Applier ───────────────────────────────────────────────────────────────
138
+
139
+ interface LiveResource {
140
+ id: string;
141
+ name: string;
142
+ metadata?: Record<string, unknown>;
143
+ }
144
+
145
+ async function listByName(http: FountainHttp, kind: string): Promise<Map<string, LiveResource>> {
146
+ const { status, json } = await http("GET", `/api/${KIND_PATHS[kind]}`);
147
+ if (status !== 200) throw new Error(`fountainApply: list ${kind} failed (${status})`);
148
+ const data = (json as { data?: LiveResource[] })?.data ?? [];
149
+ return new Map(data.map((r) => [r.name, r]));
150
+ }
151
+
152
+ export async function fountainApply(
153
+ args: FountainApplyArgs,
154
+ http?: FountainHttp,
155
+ ): Promise<FountainApplySummary> {
156
+ const content = args.planContent ?? readFileSync(args.planPath!, "utf-8");
157
+ const plan = parsePlan(content);
158
+
159
+ const endpoint = resolveEndpoint(args);
160
+ const client = http ?? defaultFountainHttp(endpoint, resolveToken(args));
161
+
162
+ const summary: FountainApplySummary = { created: [], updated: [], pruned: [], secretsUpserted: 0 };
163
+ const idByKindAndName = new Map<string, string>();
164
+ const live = new Map<string, Map<string, LiveResource>>();
165
+ for (const kind of APPLY_ORDER) {
166
+ live.set(kind, await listByName(client, kind));
167
+ }
168
+
169
+ for (const kind of APPLY_ORDER) {
170
+ for (const [entityName, entry] of Object.entries(plan)) {
171
+ if (entry.kind !== kind) continue;
172
+
173
+ const { body, secrets } = splitSecrets(entry.spec);
174
+ const payload: Record<string, unknown> = { ...body };
175
+ // Manifest identity: the fountain resource is named after the spec's
176
+ // own `name` when present, else the entity name.
177
+ const resourceName = typeof payload.name === "string" ? (payload.name as string) : entityName;
178
+ payload.name = resourceName;
179
+
180
+ // Agent environment ref: the plan carries the referenced *entity*
181
+ // name; resolve through the id map (created this run) or live state.
182
+ if (kind === "Agent" && typeof payload.environment === "string") {
183
+ const refEntity = payload.environment as string;
184
+ const refPlanEntry = plan[refEntity];
185
+ const refResourceName =
186
+ refPlanEntry && typeof refPlanEntry.spec.name === "string"
187
+ ? (refPlanEntry.spec.name as string)
188
+ : refEntity;
189
+ const envId =
190
+ idByKindAndName.get(`Environment/${refEntity}`) ??
191
+ live.get("Environment")?.get(refResourceName)?.id;
192
+ if (!envId) throw new Error(`fountainApply: agent "${entityName}" references unknown environment "${refEntity}"`);
193
+ delete payload.environment;
194
+ payload.environment_id = envId;
195
+ }
196
+
197
+ const existing = live.get(kind)!.get(resourceName);
198
+ let id: string;
199
+ if (existing) {
200
+ const { status } = await client("PUT", `/api/${KIND_PATHS[kind]}/${existing.id}`, payload);
201
+ if (status !== 200) throw new Error(`fountainApply: update ${kind} "${resourceName}" failed (${status})`);
202
+ id = existing.id;
203
+ summary.updated.push(`${kind}/${resourceName}`);
204
+ } else {
205
+ const { status, json } = await client("POST", `/api/${KIND_PATHS[kind]}`, payload);
206
+ if (status !== 201 && status !== 200) {
207
+ throw new Error(`fountainApply: create ${kind} "${resourceName}" failed (${status})`);
208
+ }
209
+ id = (json as { data?: { id?: string } })?.data?.id ?? "";
210
+ summary.created.push(`${kind}/${resourceName}`);
211
+ }
212
+ idByKindAndName.set(`${kind}/${entityName}`, id);
213
+
214
+ // Secrets sub-resource: upsert-always (values are write-only upstream).
215
+ if (secrets.length > 0 && (kind === "Environment" || kind === "Vault")) {
216
+ for (const secret of secrets) {
217
+ const { status } = await client("POST", `/api/${KIND_PATHS[kind]}/${id}/secrets`, secret);
218
+ if (status !== 200 && status !== 201) {
219
+ throw new Error(`fountainApply: secret upsert on ${kind} "${resourceName}" failed (${status})`);
220
+ }
221
+ summary.secretsUpserted += 1;
222
+ }
223
+ }
224
+ }
225
+ }
226
+
227
+ if (args.prune) {
228
+ const planned = new Set<string>();
229
+ for (const [entityName, entry] of Object.entries(plan)) {
230
+ const resourceName =
231
+ typeof entry.spec.name === "string" ? (entry.spec.name as string) : entityName;
232
+ planned.add(`${entry.kind}/${resourceName}`);
233
+ }
234
+ // Reverse order: agents first, then vaults, then environments.
235
+ for (const kind of [...APPLY_ORDER].reverse()) {
236
+ for (const [name, resource] of live.get(kind)!) {
237
+ if (planned.has(`${kind}/${name}`)) continue;
238
+ if (!isChantOwned(resource)) continue;
239
+ const { status } = await client("DELETE", `/api/${KIND_PATHS[kind]}/${resource.id}`);
240
+ if (status !== 204 && status !== 200) {
241
+ throw new Error(`fountainApply: prune ${kind} "${name}" failed (${status})`);
242
+ }
243
+ summary.pruned.push(`${kind}/${name}`);
244
+ }
245
+ }
246
+ }
247
+
248
+ return summary;
249
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * fountainRun — start a conversation from a declared Agent and follow it
3
+ * to a terminal status.
4
+ *
5
+ * Conversations are runs, not declarables (the one fountain kind the
6
+ * lexicon deliberately does not model as a resource). This op is the
7
+ * imperative half: resolve the agent by name, POST the conversation,
8
+ * poll until `completed | failed | timed_out | terminated`, and terminate
9
+ * on deadline so a hung run never outlives the op that started it.
10
+ */
11
+
12
+ import {
13
+ resolveEndpoint,
14
+ resolveToken,
15
+ defaultFountainHttp,
16
+ type FountainHttp,
17
+ } from "./fountain-apply";
18
+
19
+ export const TERMINAL_STATUSES = new Set(["completed", "failed", "timed_out", "terminated"]);
20
+
21
+ export interface FountainRunArgs {
22
+ /** Agent name (resolved against /api/agents) or a raw agent id. */
23
+ agent: string;
24
+ prompt?: string;
25
+ /** Optional vault to attach (subject to the agent's allowlist upstream). */
26
+ vaultId?: string;
27
+ endpoint?: string;
28
+ token?: string;
29
+ /** Give up (and terminate the conversation) after this long. Default 10 min. */
30
+ timeoutMs?: number;
31
+ /** Poll interval. Default 5s. */
32
+ pollMs?: number;
33
+ /** Injectable clock/sleep for tests. */
34
+ sleep?: (ms: number) => Promise<void>;
35
+ }
36
+
37
+ export interface FountainRunResult {
38
+ conversationId: string;
39
+ status: string;
40
+ /** True when the op hit its deadline and terminated the conversation. */
41
+ terminatedByDeadline: boolean;
42
+ }
43
+
44
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
45
+
46
+ export async function resolveAgentId(http: FountainHttp, agent: string): Promise<string> {
47
+ if (UUID_RE.test(agent)) return agent;
48
+ const { status, json } = await http("GET", `/api/agents?search=${encodeURIComponent(agent)}`);
49
+ if (status !== 200) throw new Error(`fountainRun: agent lookup failed (${status})`);
50
+ const data = (json as { data?: Array<{ id: string; name: string }> })?.data ?? [];
51
+ const exact = data.find((a) => a.name === agent);
52
+ if (!exact) throw new Error(`fountainRun: no agent named "${agent}"`);
53
+ return exact.id;
54
+ }
55
+
56
+ export async function fountainRun(
57
+ args: FountainRunArgs,
58
+ http?: FountainHttp,
59
+ ): Promise<FountainRunResult> {
60
+ const endpoint = resolveEndpoint(args);
61
+ const client = http ?? defaultFountainHttp(endpoint, resolveToken(args));
62
+ const sleep = args.sleep ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)));
63
+ const timeoutMs = args.timeoutMs ?? 600_000;
64
+ const pollMs = args.pollMs ?? 5_000;
65
+
66
+ const agentId = await resolveAgentId(client, args.agent);
67
+
68
+ const createBody: Record<string, unknown> = { agent_id: agentId };
69
+ if (args.prompt !== undefined) createBody.prompt = args.prompt;
70
+ if (args.vaultId !== undefined) createBody.vault_id = args.vaultId;
71
+
72
+ const created = await client("POST", "/api/conversations", createBody);
73
+ if (created.status !== 201 && created.status !== 200) {
74
+ throw new Error(`fountainRun: conversation create failed (${created.status})`);
75
+ }
76
+ const conversationId = (created.json as { data?: { id?: string } })?.data?.id;
77
+ if (!conversationId) throw new Error("fountainRun: conversation create returned no id");
78
+
79
+ const deadline = Date.now() + timeoutMs;
80
+ let status = "pending";
81
+ while (Date.now() < deadline) {
82
+ const res = await client("GET", `/api/conversations/${conversationId}`);
83
+ if (res.status === 200) {
84
+ status = (res.json as { data?: { status?: string } })?.data?.status ?? status;
85
+ if (TERMINAL_STATUSES.has(status)) {
86
+ return { conversationId, status, terminatedByDeadline: false };
87
+ }
88
+ }
89
+ await sleep(pollMs);
90
+ }
91
+
92
+ // Deadline: end the conversation so the sandbox does not outlive the op.
93
+ await client("POST", `/api/conversations/${conversationId}/terminate`);
94
+ return { conversationId, status: "terminated", terminatedByDeadline: true };
95
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * fountain Op activities — resolved by the core activity registry when a
3
+ * project's `chant.config.ts` lists the `fountain` lexicon. Contributes
4
+ * the native applier (`fountainApply` — direct REST against fountain's
5
+ * API, no CLI, no state file) and the conversation runner (`fountainRun`).
6
+ */
7
+ export {
8
+ fountainApply,
9
+ resolveEndpoint,
10
+ resolveToken,
11
+ parsePlan,
12
+ splitSecrets,
13
+ isChantOwned,
14
+ defaultFountainHttp,
15
+ DEFAULT_FOUNTAIN_BASE_URL,
16
+ OWNERSHIP_KEY,
17
+ OWNERSHIP_VALUE,
18
+ } from "./fountain-apply";
19
+ export type {
20
+ FountainApplyArgs,
21
+ FountainApplySummary,
22
+ FountainPlan,
23
+ PlanEntry,
24
+ FountainHttp,
25
+ } from "./fountain-apply";
26
+
27
+ export { fountainRun, resolveAgentId, TERMINAL_STATUSES } from "./fountain-run";
28
+ export type { FountainRunArgs, FountainRunResult } from "./fountain-run";
@@ -0,0 +1,26 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * Thin entry point for `npm run bundle` in lexicon-fountain.
4
+ * Generates src/generated/ files and writes dist/ bundle.
5
+ *
6
+ * NOTE: Uses top-level await (matching AWS/Azure pattern) to avoid
7
+ * event loop references from async wrappers keeping the process alive.
8
+ */
9
+ import { packageLexicon } from "./codegen/package";
10
+ import { writeBundleSpec } from "@intentius/chant/codegen/package";
11
+ import { dirname, join } from "path";
12
+ import { fileURLToPath } from "url";
13
+
14
+ const pkgDir = dirname(fileURLToPath(import.meta.url));
15
+ const distDir = join(pkgDir, "..", "dist");
16
+
17
+ const verbose = process.argv.includes("--verbose") || !process.argv.includes("--quiet");
18
+ const force = process.argv.includes("--force");
19
+
20
+ const { spec, stats } = await packageLexicon({ verbose, force });
21
+ writeBundleSpec(spec, distDir);
22
+
23
+ console.error(
24
+ `Packaged ${stats.resources} resources, ${stats.ruleCount} rules, ${stats.skillCount} skills`,
25
+ );
26
+ console.error(`dist/ written to ${distDir}`);