@intentius/chant 0.0.18 → 0.0.22

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/bin/chant +4 -1
  2. package/package.json +20 -1
  3. package/src/build.test.ts +4 -2
  4. package/src/build.ts +3 -0
  5. package/src/builder.test.ts +3 -0
  6. package/src/cli/commands/__fixtures__/init-lexicon-output/docs/astro.config.mjs +0 -3
  7. package/src/cli/commands/build.ts +5 -12
  8. package/src/cli/commands/diff.test.ts +2 -1
  9. package/src/cli/commands/diff.ts +2 -1
  10. package/src/cli/commands/init-lexicon.test.ts +0 -9
  11. package/src/cli/commands/init-lexicon.ts +0 -94
  12. package/src/cli/commands/init.ts +2 -20
  13. package/src/cli/handlers/build.ts +3 -3
  14. package/src/cli/handlers/lint.ts +2 -2
  15. package/src/cli/handlers/spell.ts +396 -0
  16. package/src/cli/handlers/state.ts +230 -0
  17. package/src/cli/lsp/server.test.ts +4 -0
  18. package/src/cli/main.ts +37 -3
  19. package/src/cli/mcp/server.test.ts +13 -9
  20. package/src/cli/mcp/server.ts +220 -6
  21. package/src/cli/mcp/tools/build.ts +2 -1
  22. package/src/cli/plugins.ts +1 -1
  23. package/src/cli/reporters/stylish.test.ts +2 -2
  24. package/src/cli/reporters/stylish.ts +1 -1
  25. package/src/composite.test.ts +1 -1
  26. package/src/config.ts +4 -0
  27. package/src/declarable.test.ts +2 -1
  28. package/src/declarable.ts +1 -1
  29. package/src/discovery/graph.test.ts +40 -0
  30. package/src/discovery/import.test.ts +5 -5
  31. package/src/discovery/resolve.test.ts +20 -0
  32. package/src/discovery/resolve.ts +2 -2
  33. package/src/index.ts +2 -0
  34. package/src/lexicon.ts +24 -0
  35. package/src/lint/rule-options.test.ts +3 -3
  36. package/src/lint/rule-registry.test.ts +1 -1
  37. package/src/lint/rules/composite-scope.ts +1 -1
  38. package/src/serializer-walker.ts +2 -1
  39. package/src/spell/discovery.ts +183 -0
  40. package/src/spell/index.ts +3 -0
  41. package/src/spell/prompt.ts +133 -0
  42. package/src/spell/types.ts +89 -0
  43. package/src/state/digest.ts +88 -0
  44. package/src/state/git.ts +317 -0
  45. package/src/state/index.ts +4 -0
  46. package/src/state/snapshot.ts +179 -0
  47. package/src/state/types.ts +59 -0
  48. package/src/types.ts +2 -1
  49. package/src/utils.test.ts +16 -3
  50. package/src/utils.ts +31 -1
  51. package/src/validation.test.ts +11 -0
  52. package/src/cli/commands/__fixtures__/init-lexicon-output/docs/src/content/docs/getting-started.mdx +0 -6
  53. package/src/cli/commands/__fixtures__/init-lexicon-output/docs/src/content/docs/lint-rules.mdx +0 -6
  54. package/src/cli/commands/__fixtures__/init-lexicon-output/docs/src/content/docs/serialization.mdx +0 -6
  55. package/src/cli/commands/__fixtures__/init-lexicon-output/src/actions/.gitkeep +0 -0
  56. package/src/cli/commands/__fixtures__/init-lexicon-output/src/composites/.gitkeep +0 -0
  57. package/src/cli/commands/__fixtures__/init-lexicon-output/src/coverage.ts +0 -11
  58. package/src/cli/commands/__fixtures__/init-lexicon-output/src/import/generator.ts +0 -10
  59. package/src/cli/commands/__fixtures__/init-lexicon-output/src/import/parser.ts +0 -10
  60. package/src/cli/commands/__fixtures__/init-lexicon-output/src/lint/post-synth/.gitkeep +0 -0
@@ -28,8 +28,8 @@ describe("importModule", () => {
28
28
 
29
29
  const module = await importModule(filePath);
30
30
  expect(module.default).toBeDefined();
31
- expect(module.default.name).toBe("test");
32
- expect(module.default.value).toBe(123);
31
+ expect((module.default as any).name).toBe("test");
32
+ expect((module.default as any).value).toBe(123);
33
33
  });
34
34
  });
35
35
 
@@ -125,7 +125,7 @@ describe("importModule", () => {
125
125
 
126
126
  const module = await importModule(filePath);
127
127
  expect(module.MyClass).toBeDefined();
128
- const instance = new module.MyClass(100);
128
+ const instance = new (module.MyClass as any)(100);
129
129
  expect(instance.getValue()).toBe(100);
130
130
  });
131
131
  });
@@ -142,8 +142,8 @@ describe("importModule", () => {
142
142
  const module = await importModule(filePath);
143
143
  expect(module.add).toBeInstanceOf(Function);
144
144
  expect(module.multiply).toBeInstanceOf(Function);
145
- expect(module.add(2, 3)).toBe(5);
146
- expect(module.multiply(4, 5)).toBe(20);
145
+ expect((module.add as Function)(2, 3)).toBe(5);
146
+ expect((module.multiply as Function)(4, 5)).toBe(20);
147
147
  });
148
148
  });
149
149
 
@@ -8,11 +8,13 @@ import { LOGICAL_NAME_SYMBOL, getLogicalName } from "../utils";
8
8
  describe("resolveAttrRefs", () => {
9
9
  test("sets logical names on all entities", () => {
10
10
  const entity1: Declarable = {
11
+ lexicon: "test",
11
12
  entityType: "Test1",
12
13
  [DECLARABLE_MARKER]: true,
13
14
  };
14
15
 
15
16
  const entity2: Declarable = {
17
+ lexicon: "test",
16
18
  entityType: "Test2",
17
19
  [DECLARABLE_MARKER]: true,
18
20
  };
@@ -30,11 +32,13 @@ describe("resolveAttrRefs", () => {
30
32
 
31
33
  test("resolves AttrRef with parent in entities collection", () => {
32
34
  const parent: Declarable = {
35
+ lexicon: "test",
33
36
  entityType: "Parent",
34
37
  [DECLARABLE_MARKER]: true,
35
38
  };
36
39
 
37
40
  const child: Declarable & { ref: AttrRef } = {
41
+ lexicon: "test",
38
42
  entityType: "Child",
39
43
  [DECLARABLE_MARKER]: true,
40
44
  ref: new AttrRef(parent, "Arn"),
@@ -55,11 +59,13 @@ describe("resolveAttrRefs", () => {
55
59
 
56
60
  test("resolves multiple AttrRefs on same entity", () => {
57
61
  const parent: Declarable = {
62
+ lexicon: "test",
58
63
  entityType: "Parent",
59
64
  [DECLARABLE_MARKER]: true,
60
65
  };
61
66
 
62
67
  const child: Declarable & { arn: AttrRef; name: AttrRef } = {
68
+ lexicon: "test",
63
69
  entityType: "Child",
64
70
  [DECLARABLE_MARKER]: true,
65
71
  arn: new AttrRef(parent, "Arn"),
@@ -83,16 +89,19 @@ describe("resolveAttrRefs", () => {
83
89
 
84
90
  test("resolves AttrRefs with different parents", () => {
85
91
  const parent1: Declarable = {
92
+ lexicon: "test",
86
93
  entityType: "Parent1",
87
94
  [DECLARABLE_MARKER]: true,
88
95
  };
89
96
 
90
97
  const parent2: Declarable = {
98
+ lexicon: "test",
91
99
  entityType: "Parent2",
92
100
  [DECLARABLE_MARKER]: true,
93
101
  };
94
102
 
95
103
  const child: Declarable & { ref1: AttrRef; ref2: AttrRef } = {
104
+ lexicon: "test",
96
105
  entityType: "Child",
97
106
  [DECLARABLE_MARKER]: true,
98
107
  ref1: new AttrRef(parent1, "Arn"),
@@ -119,6 +128,7 @@ describe("resolveAttrRefs", () => {
119
128
  const parent = {}; // Not a Declarable, not in entities
120
129
 
121
130
  const child: Declarable & { ref: AttrRef } = {
131
+ lexicon: "test",
122
132
  entityType: "Child",
123
133
  [DECLARABLE_MARKER]: true,
124
134
  ref: new AttrRef(parent, "Arn"),
@@ -133,11 +143,13 @@ describe("resolveAttrRefs", () => {
133
143
 
134
144
  test("handles entities with no AttrRefs", () => {
135
145
  const entity1: Declarable = {
146
+ lexicon: "test",
136
147
  entityType: "Test1",
137
148
  [DECLARABLE_MARKER]: true,
138
149
  };
139
150
 
140
151
  const entity2: Declarable & { prop: string } = {
152
+ lexicon: "test",
141
153
  entityType: "Test2",
142
154
  [DECLARABLE_MARKER]: true,
143
155
  prop: "value",
@@ -162,17 +174,20 @@ describe("resolveAttrRefs", () => {
162
174
 
163
175
  test("resolves chain of entities with AttrRefs", () => {
164
176
  const root: Declarable = {
177
+ lexicon: "test",
165
178
  entityType: "Root",
166
179
  [DECLARABLE_MARKER]: true,
167
180
  };
168
181
 
169
182
  const middle: Declarable & { rootRef: AttrRef } = {
183
+ lexicon: "test",
170
184
  entityType: "Middle",
171
185
  [DECLARABLE_MARKER]: true,
172
186
  rootRef: new AttrRef(root, "Id"),
173
187
  };
174
188
 
175
189
  const leaf: Declarable & { middleRef: AttrRef } = {
190
+ lexicon: "test",
176
191
  entityType: "Leaf",
177
192
  [DECLARABLE_MARKER]: true,
178
193
  middleRef: new AttrRef(middle, "Name"),
@@ -196,6 +211,7 @@ describe("resolveAttrRefs", () => {
196
211
 
197
212
  test("handles entity referencing itself", () => {
198
213
  const entity: Declarable & { selfRef: AttrRef } = {
214
+ lexicon: "test",
199
215
  entityType: "SelfReferencing",
200
216
  [DECLARABLE_MARKER]: true,
201
217
  selfRef: null as unknown as AttrRef, // Will be set below
@@ -214,6 +230,7 @@ describe("resolveAttrRefs", () => {
214
230
 
215
231
  test("preserves logical name symbol on entities", () => {
216
232
  const entity: Declarable = {
233
+ lexicon: "test",
217
234
  entityType: "Test",
218
235
  [DECLARABLE_MARKER]: true,
219
236
  };
@@ -228,6 +245,7 @@ describe("resolveAttrRefs", () => {
228
245
 
229
246
  test("uses export name as logical name", () => {
230
247
  const entity: Declarable = {
248
+ lexicon: "test",
231
249
  entityType: "Test",
232
250
  [DECLARABLE_MARKER]: true,
233
251
  };
@@ -243,11 +261,13 @@ describe("resolveAttrRefs", () => {
243
261
 
244
262
  test("handles complex attribute names in AttrRef", () => {
245
263
  const parent: Declarable = {
264
+ lexicon: "test",
246
265
  entityType: "Parent",
247
266
  [DECLARABLE_MARKER]: true,
248
267
  };
249
268
 
250
269
  const child: Declarable & { ref: AttrRef } = {
270
+ lexicon: "test",
251
271
  entityType: "Child",
252
272
  [DECLARABLE_MARKER]: true,
253
273
  ref: new AttrRef(parent, "Outputs.WebsiteURL"),
@@ -1,6 +1,6 @@
1
1
  import type { Declarable } from "../declarable";
2
2
  import { AttrRef } from "../attrref";
3
- import { LOGICAL_NAME_SYMBOL, getAttributes } from "../utils";
3
+ import { LOGICAL_NAME_SYMBOL, getAttributes, isAttrRefLike } from "../utils";
4
4
 
5
5
  /**
6
6
  * Resolves all AttrRef instances in a collection of entities
@@ -22,7 +22,7 @@ export function resolveAttrRefs(entities: Map<string, Declarable>): void {
22
22
  for (const attrName of attributes) {
23
23
  const attrRef = (entity as unknown as Record<string, unknown>)[attrName];
24
24
 
25
- if (attrRef instanceof AttrRef) {
25
+ if (isAttrRefLike(attrRef)) {
26
26
  const parent = attrRef.parent.deref();
27
27
 
28
28
  if (!parent) {
package/src/index.ts CHANGED
@@ -59,3 +59,5 @@ export * from "./child-project";
59
59
  export * from "./lsp/types";
60
60
  export * from "./lsp/lexicon-providers";
61
61
  export * from "./mcp/types";
62
+ export * from "./state/index";
63
+ export * from "./spell/index";
package/src/lexicon.ts CHANGED
@@ -193,6 +193,30 @@ export interface LexiconPlugin {
193
193
 
194
194
  /** Return MCP resource contributions */
195
195
  mcpResources?(): McpResourceContribution[];
196
+
197
+ // State
198
+ /** Query deployed resources and return API metadata. Opt-in. */
199
+ describeResources?(options: {
200
+ environment: string;
201
+ buildOutput: string;
202
+ entityNames: string[];
203
+ }): Promise<Record<string, ResourceMetadata>>;
204
+ }
205
+
206
+ /**
207
+ * Metadata about a deployed resource, returned by describeResources.
208
+ */
209
+ export interface ResourceMetadata {
210
+ /** Entity type (e.g. AWS::S3::Bucket, K8s::Apps::Deployment) */
211
+ type: string;
212
+ /** Provider-assigned physical ID (ARN, resource ID, pod name) */
213
+ physicalId?: string;
214
+ /** Provider-specific status string */
215
+ status: string;
216
+ /** ISO timestamp of last update */
217
+ lastUpdated?: string;
218
+ /** Cloud-assigned output properties */
219
+ attributes?: Record<string, unknown>;
196
220
  }
197
221
 
198
222
  /**
@@ -2,7 +2,7 @@ import { describe, test, expect, beforeEach, afterEach } from "bun:test";
2
2
  import { parseRuleConfig, loadConfig } from "./config";
3
3
  import { fileDeclarableLimitRule } from "./rules/file-declarable-limit";
4
4
  import * as ts from "typescript";
5
- import type { LintContext } from "./rule";
5
+ import type { LintContext, RuleConfig } from "./rule";
6
6
  import { writeFileSync, mkdirSync, rmSync } from "fs";
7
7
  import { join } from "path";
8
8
 
@@ -37,12 +37,12 @@ describe("parseRuleConfig", () => {
37
37
  });
38
38
 
39
39
  test("parses [severity, options] tuple", () => {
40
- const result = parseRuleConfig(["warning", { max: 12 }]);
40
+ const result = parseRuleConfig(["warning" as const, { max: 12 }]);
41
41
  expect(result).toEqual({ severity: "warning", options: { max: 12 } });
42
42
  });
43
43
 
44
44
  test("throws for invalid tuple length", () => {
45
- expect(() => parseRuleConfig([] as unknown as [string, Record<string, unknown>])).toThrow(
45
+ expect(() => parseRuleConfig([] as unknown as RuleConfig)).toThrow(
46
46
  /expected a severity string or \[severity, options\] tuple/
47
47
  );
48
48
  });
@@ -88,7 +88,7 @@ describe("buildRuleRegistry", () => {
88
88
 
89
89
  test("falls back to rule ID when description is missing", () => {
90
90
  const rule = mockRule("COR001");
91
- delete (rule as Record<string, unknown>).description;
91
+ delete (rule as unknown as Record<string, unknown>).description;
92
92
 
93
93
  const entries = buildRuleRegistry([rule]);
94
94
  expect(entries[0].description).toBe("COR001");
@@ -14,7 +14,7 @@ export function isInsideCompositeFactory(node: ts.Node): boolean {
14
14
 
15
15
  while (current) {
16
16
  if (ts.isArrowFunction(current) || ts.isFunctionExpression(current)) {
17
- const parent = current.parent;
17
+ const parent: ts.Node | undefined = current.parent;
18
18
  if (parent && ts.isCallExpression(parent) && parent.arguments[0] === current) {
19
19
  const callee = parent.expression;
20
20
  if (isCompositeCallee(callee)) {
@@ -9,6 +9,7 @@ import type { Declarable } from "./declarable";
9
9
  import { isPropertyDeclarable } from "./declarable";
10
10
  import { INTRINSIC_MARKER } from "./intrinsic";
11
11
  import { AttrRef } from "./attrref";
12
+ import { isAttrRefLike } from "./utils";
12
13
 
13
14
  export interface SerializerVisitor {
14
15
  /** Format an attribute reference (e.g. CFN Fn::GetAttr). */
@@ -33,7 +34,7 @@ export function walkValue(
33
34
  }
34
35
 
35
36
  // Handle AttrRef
36
- if (value instanceof AttrRef) {
37
+ if (isAttrRefLike(value)) {
37
38
  const name = value.getLogicalName();
38
39
  if (!name) {
39
40
  throw new Error(
@@ -0,0 +1,183 @@
1
+ /**
2
+ * Spell discovery: find, import, validate, and index spell files.
3
+ */
4
+ import { getRuntime } from "../runtime-adapter";
5
+ import type { SpellDefinition, Status } from "./types";
6
+ import { readdir } from "node:fs/promises";
7
+ import { join } from "node:path";
8
+
9
+ export interface DiscoveredSpell {
10
+ definition: SpellDefinition;
11
+ filePath: string;
12
+ status: Status;
13
+ }
14
+
15
+ export interface SpellDiscoveryResult {
16
+ spells: Map<string, DiscoveredSpell>;
17
+ errors: string[];
18
+ }
19
+
20
+ /**
21
+ * Find the git root directory.
22
+ */
23
+ async function findGitRoot(cwd?: string): Promise<string> {
24
+ const rt = getRuntime();
25
+ const result = await rt.spawn(["git", "rev-parse", "--show-toplevel"], { cwd });
26
+ if (result.exitCode !== 0) {
27
+ throw new Error("Not in a git repository");
28
+ }
29
+ return result.stdout.trim();
30
+ }
31
+
32
+ /**
33
+ * Discover all spells from the spells/ directory at the git root.
34
+ */
35
+ export async function discoverSpells(
36
+ opts?: { cwd?: string },
37
+ ): Promise<SpellDiscoveryResult> {
38
+ const errors: string[] = [];
39
+ const spells = new Map<string, DiscoveredSpell>();
40
+
41
+ const gitRoot = await findGitRoot(opts?.cwd);
42
+ const spellsDir = join(gitRoot, "spells");
43
+
44
+ // List *.spell.ts files
45
+ let files: string[];
46
+ try {
47
+ const entries = await readdir(spellsDir);
48
+ files = entries.filter((f) => f.endsWith(".spell.ts")).map((f) => join(spellsDir, f));
49
+ } catch {
50
+ // spells/ directory doesn't exist — that's OK
51
+ return { spells, errors };
52
+ }
53
+
54
+ // Import each file
55
+ const fileMap = new Map<string, string>(); // name → filePath for duplicate detection
56
+ for (const filePath of files) {
57
+ try {
58
+ const mod = await import(filePath);
59
+ const def = mod.default as SpellDefinition | undefined;
60
+
61
+ if (!def) {
62
+ errors.push(`File ${filePath} has no default export`);
63
+ continue;
64
+ }
65
+
66
+ // Validate shape
67
+ if (!def.name || typeof def.name !== "string") {
68
+ errors.push(`File ${filePath}: default export has no valid name`);
69
+ continue;
70
+ }
71
+ if (!def.tasks || !Array.isArray(def.tasks)) {
72
+ errors.push(`File ${filePath}: default export has no valid tasks`);
73
+ continue;
74
+ }
75
+
76
+ // Duplicate check
77
+ if (fileMap.has(def.name)) {
78
+ errors.push(
79
+ `Duplicate name "${def.name}" in ${filePath} and ${fileMap.get(def.name)}`,
80
+ );
81
+ continue;
82
+ }
83
+
84
+ fileMap.set(def.name, filePath);
85
+ spells.set(def.name, {
86
+ definition: def,
87
+ filePath,
88
+ status: "ready", // placeholder — computed after all are loaded
89
+ });
90
+ } catch (err) {
91
+ errors.push(
92
+ `${filePath}: ${err instanceof Error ? err.message : String(err)}`,
93
+ );
94
+ }
95
+ }
96
+
97
+ // Validate dependencies
98
+ for (const [name, spell] of spells) {
99
+ const deps = spell.definition.depends;
100
+ if (!deps) continue;
101
+
102
+ for (const depName of deps) {
103
+ if (!spells.has(depName)) {
104
+ errors.push(
105
+ `Spell "${name}" depends on "${depName}" which does not exist`,
106
+ );
107
+ }
108
+ }
109
+ }
110
+
111
+ // Detect circular dependencies via topological sort
112
+ const circularError = detectCycles(spells);
113
+ if (circularError) {
114
+ errors.push(circularError);
115
+ }
116
+
117
+ // Compute status for each spell
118
+ computeStatuses(spells);
119
+
120
+ return { spells, errors };
121
+ }
122
+
123
+ /**
124
+ * Detect circular dependencies. Returns an error message or null.
125
+ */
126
+ function detectCycles(spells: Map<string, DiscoveredSpell>): string | null {
127
+ const visited = new Set<string>();
128
+ const visiting = new Set<string>();
129
+
130
+ function visit(name: string, path: string[]): string | null {
131
+ if (visiting.has(name)) {
132
+ const cycle = [...path.slice(path.indexOf(name)), name];
133
+ return `Circular dependency: ${cycle.join(" → ")}`;
134
+ }
135
+ if (visited.has(name)) return null;
136
+
137
+ visiting.add(name);
138
+ path.push(name);
139
+
140
+ const spell = spells.get(name);
141
+ if (spell?.definition.depends) {
142
+ for (const dep of spell.definition.depends) {
143
+ if (spells.has(dep)) {
144
+ const err = visit(dep, path);
145
+ if (err) return err;
146
+ }
147
+ }
148
+ }
149
+
150
+ visiting.delete(name);
151
+ visited.add(name);
152
+ path.pop();
153
+ return null;
154
+ }
155
+
156
+ for (const name of spells.keys()) {
157
+ const err = visit(name, []);
158
+ if (err) return err;
159
+ }
160
+ return null;
161
+ }
162
+
163
+ /**
164
+ * Compute statuses: blocked / ready / done.
165
+ */
166
+ function computeStatuses(spells: Map<string, DiscoveredSpell>): void {
167
+ for (const [, spell] of spells) {
168
+ const allTasksDone = spell.definition.tasks.every((t) => t.done);
169
+ if (allTasksDone) {
170
+ spell.status = "done";
171
+ continue;
172
+ }
173
+
174
+ const deps = spell.definition.depends ?? [];
175
+ const hasIncompleteDep = deps.some((depName) => {
176
+ const dep = spells.get(depName);
177
+ if (!dep) return true; // dangling dep counts as incomplete
178
+ return !dep.definition.tasks.every((t) => t.done);
179
+ });
180
+
181
+ spell.status = hasIncompleteDep ? "blocked" : "ready";
182
+ }
183
+ }
@@ -0,0 +1,3 @@
1
+ export * from "./types";
2
+ export * from "./discovery";
3
+ export * from "./prompt";
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Bootstrap prompt generation for spells.
3
+ *
4
+ * Resolves context items (static strings, files, commands), assembles the
5
+ * full prompt with overview, context, task list, and afterAll instructions.
6
+ */
7
+ import { readFile } from "node:fs/promises";
8
+ import { join } from "node:path";
9
+ import { getRuntime } from "../runtime-adapter";
10
+ import type { SpellDefinition, ContextItem, Task } from "./types";
11
+ import type { LexiconPlugin } from "../lexicon";
12
+
13
+ /**
14
+ * Resolve a single context item to a string.
15
+ */
16
+ async function resolveContextItem(
17
+ item: string | ContextItem,
18
+ gitRoot: string,
19
+ ): Promise<string> {
20
+ if (typeof item === "string") return item;
21
+
22
+ if (item.type === "file") {
23
+ const filePath = join(gitRoot, item.value);
24
+ try {
25
+ const content = await readFile(filePath, "utf-8");
26
+ return `--- ${item.value} ---\n${content}`;
27
+ } catch {
28
+ return `[Context error: ${item.value} not found]`;
29
+ }
30
+ }
31
+
32
+ if (item.type === "cmd") {
33
+ const rt = getRuntime();
34
+ try {
35
+ const result = await rt.spawn(["sh", "-c", item.value], { cwd: gitRoot });
36
+ if (result.exitCode !== 0) {
37
+ return `[Context error: command "${item.value}" failed with exit code ${result.exitCode}]\n${result.stderr}`;
38
+ }
39
+ return `--- $ ${item.value} ---\n${result.stdout}`;
40
+ } catch (err) {
41
+ return `[Context error: command "${item.value}" failed: ${err instanceof Error ? err.message : String(err)}]`;
42
+ }
43
+ }
44
+
45
+ return String(item);
46
+ }
47
+
48
+ /**
49
+ * Format the task list for the prompt.
50
+ */
51
+ function formatTasks(tasks: Task[]): string {
52
+ return tasks
53
+ .map((t, i) => {
54
+ const check = t.done ? "[x]" : "[ ]";
55
+ return `${i + 1}. ${check} ${t.description}`;
56
+ })
57
+ .join("\n");
58
+ }
59
+
60
+ /**
61
+ * Find relevant skill content from a lexicon plugin.
62
+ */
63
+ function getLexiconSkillContent(
64
+ lexiconName: string,
65
+ plugins: LexiconPlugin[],
66
+ ): string | null {
67
+ const plugin = plugins.find((p) => p.name === lexiconName);
68
+ if (!plugin?.skills) return null;
69
+ const skills = plugin.skills();
70
+ if (skills.length === 0) return null;
71
+
72
+ return skills
73
+ .map((s) => `### ${s.name}\n\n${s.content}`)
74
+ .join("\n\n");
75
+ }
76
+
77
+ export interface PromptOptions {
78
+ gitRoot: string;
79
+ plugins?: LexiconPlugin[];
80
+ }
81
+
82
+ /**
83
+ * Generate the bootstrap prompt for a spell.
84
+ */
85
+ export async function generatePrompt(
86
+ spell: SpellDefinition,
87
+ opts: PromptOptions,
88
+ ): Promise<string> {
89
+ const sections: string[] = [];
90
+
91
+ // Header
92
+ sections.push(`# Spell: ${spell.name}\n`);
93
+
94
+ // Overview
95
+ sections.push(`## Overview\n\n${spell.overview}\n`);
96
+
97
+ // Resolved context
98
+ if (spell.context && spell.context.length > 0) {
99
+ const resolved = await Promise.all(
100
+ spell.context.map((item) => resolveContextItem(item, opts.gitRoot)),
101
+ );
102
+ sections.push(`## Context\n\n${resolved.join("\n\n")}\n`);
103
+ }
104
+
105
+ // Lexicon skill guidance
106
+ if (spell.lexicon && opts.plugins) {
107
+ const skillContent = getLexiconSkillContent(spell.lexicon, opts.plugins);
108
+ if (skillContent) {
109
+ sections.push(`## ${spell.lexicon} Guidance\n\n${skillContent}\n`);
110
+ }
111
+ }
112
+
113
+ // Task list
114
+ sections.push(`## Tasks\n\n${formatTasks(spell.tasks)}\n`);
115
+
116
+ // After all
117
+ if (spell.afterAll && spell.afterAll.length > 0) {
118
+ sections.push(
119
+ `## After Completion\n\nAfter all tasks are done, run:\n${spell.afterAll.map((c) => `- \`${c}\``).join("\n")}\n`,
120
+ );
121
+ }
122
+
123
+ // Instructions
124
+ sections.push(
125
+ `## Instructions\n\n` +
126
+ `- Mark tasks done with: \`chant spell done ${spell.name} <task-number>\`\n` +
127
+ `- Task numbers are 1-based\n` +
128
+ `- Commit with trailer: \`Spell: ${spell.name}\`\n` +
129
+ `- Work through tasks in order\n`,
130
+ );
131
+
132
+ return sections.join("\n");
133
+ }