silvermoon 0.2.2 → 0.3.0-rc.1

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.
package/src/adoption.js CHANGED
@@ -15,6 +15,7 @@ import { traceAsync } from "./trace.js";
15
15
 
16
16
  const packageRoot = fileURLToPath(new URL("..", import.meta.url));
17
17
  const packageJson = JSON.parse(await readFile(resolve(packageRoot, "package.json"), "utf8"));
18
+ const DEFAULT_FILESYSTEM = { lstat, readFile, readdir };
18
19
  const packagedSkillsRoot = resolve(packageRoot, "skills");
19
20
  const packagedSkill = resolve(packagedSkillsRoot, "silvermoon");
20
21
  const localSkillsRoot = "./node_modules/silvermoon/skills";
@@ -95,7 +96,7 @@ function packageManagerDescriptor(manager, workspace, reason = null) {
95
96
  };
96
97
  }
97
98
 
98
- async function detectPackageManager(root, manifest) {
99
+ async function detectPackageManager(root, manifest, filesystem) {
99
100
  const workspace = workspaceRoot(manifest);
100
101
  if (Object.hasOwn(manifest, "packageManager")) {
101
102
  const declaration = manifest.packageManager;
@@ -116,7 +117,7 @@ async function detectPackageManager(root, manifest) {
116
117
  const uncertain = [];
117
118
  for (const [filename, manager] of lockfileManagers) {
118
119
  try {
119
- const metadata = await lstat(resolve(root, filename));
120
+ const metadata = await filesystem.lstat(resolve(root, filename));
120
121
  if (metadata.isFile()) detected.push({ filename, manager });
121
122
  else uncertain.push(filename);
122
123
  } catch (caught) {
@@ -220,12 +221,15 @@ function skillInstruction(skill, packageManager) {
220
221
  export async function inspectNpmProject(
221
222
  snapshotRoot,
222
223
  repositoryRoot,
223
- { readManifest = readFile } = {},
224
+ {
225
+ filesystem = DEFAULT_FILESYSTEM,
226
+ readManifest = filesystem.readFile,
227
+ } = {},
224
228
  ) {
225
229
  const manifestPath = resolve(snapshotRoot, "package.json");
226
230
  let metadata;
227
231
  try {
228
- metadata = await lstat(manifestPath);
232
+ metadata = await filesystem.lstat(manifestPath);
229
233
  } catch (caught) {
230
234
  if (caught.code === "ENOENT") return { npmProject: false, findings: [] };
231
235
  return {
@@ -295,7 +299,11 @@ export async function inspectNpmProject(
295
299
  };
296
300
  }
297
301
 
298
- const packageManager = await detectPackageManager(snapshotRoot, manifest);
302
+ const packageManager = await detectPackageManager(
303
+ snapshotRoot,
304
+ manifest,
305
+ filesystem,
306
+ );
299
307
  const findings = [];
300
308
  const sourceCheckout = isSilvermoonSourceCheckout(repositoryRoot, manifest);
301
309
  if (!expectedDependency) {
@@ -369,11 +377,13 @@ function canonicalSkillBytes(content) {
369
377
  }
370
378
  }
371
379
 
372
- async function directoryDigest(root) {
380
+ async function directoryDigest(root, filesystem) {
373
381
  const hash = createHash("sha256");
374
382
 
375
383
  async function visit(directory) {
376
- const entries = (await readdir(directory, { withFileTypes: true }))
384
+ const entries = (
385
+ await filesystem.readdir(directory, { withFileTypes: true })
386
+ )
377
387
  .sort((left, right) => left.name.localeCompare(right.name));
378
388
  for (const entry of entries) {
379
389
  const path = resolve(directory, entry.name);
@@ -382,7 +392,7 @@ async function directoryDigest(root) {
382
392
  } else if (entry.isFile()) {
383
393
  hash.update(relative(root, path).split(sep).join("/"));
384
394
  hash.update("\0");
385
- hash.update(canonicalSkillBytes(await readFile(path)));
395
+ hash.update(canonicalSkillBytes(await filesystem.readFile(path)));
386
396
  hash.update("\0");
387
397
  } else if (entry.isSymbolicLink()) {
388
398
  await visit(path);
@@ -396,16 +406,16 @@ async function directoryDigest(root) {
396
406
  return hash.digest("hex");
397
407
  }
398
408
 
399
- async function inspectSkill(contentRoot) {
409
+ async function inspectSkill(contentRoot, filesystem) {
400
410
  const path = resolve(contentRoot, ...REPOSITORY_SKILL_PATH.split("/"));
401
411
  const expectedDigest = await traceAsync(
402
412
  "skill.digest-packaged",
403
413
  {},
404
- () => directoryDigest(packagedSkill),
414
+ () => directoryDigest(packagedSkill, DEFAULT_FILESYSTEM),
405
415
  );
406
416
  let metadata;
407
417
  try {
408
- metadata = await lstat(path);
418
+ metadata = await filesystem.lstat(path);
409
419
  } catch (caught) {
410
420
  if (caught.code !== "ENOENT") {
411
421
  return {
@@ -444,7 +454,7 @@ async function inspectSkill(contentRoot) {
444
454
  actualDigest = await traceAsync(
445
455
  "skill.digest-repository",
446
456
  {},
447
- () => directoryDigest(path),
457
+ () => directoryDigest(path, filesystem),
448
458
  );
449
459
  } catch (caught) {
450
460
  return {
@@ -473,10 +483,14 @@ async function inspectSkill(contentRoot) {
473
483
 
474
484
  export async function inspectAdoption({
475
485
  contentRoot,
486
+ filesystem = DEFAULT_FILESYSTEM,
487
+ repositoryRoot: knownRepositoryRoot,
476
488
  root = process.cwd(),
477
489
  } = {}) {
478
490
  const requestedRoot = resolve(root);
479
- const git = runGit(requestedRoot, ["rev-parse", "--show-toplevel"]);
491
+ const git = knownRepositoryRoot === undefined
492
+ ? runGit(requestedRoot, ["rev-parse", "--show-toplevel"])
493
+ : { ok: true, stdout: resolve(knownRepositoryRoot) };
480
494
  const repositoryRoot = git.ok ? resolve(git.stdout) : requestedRoot;
481
495
  const snapshotRoot = resolve(contentRoot ?? repositoryRoot);
482
496
  const findings = [];
@@ -495,7 +509,7 @@ export async function inspectAdoption({
495
509
  const loadedConfig = await traceAsync(
496
510
  "config.load",
497
511
  {},
498
- () => loadConfig({ root: snapshotRoot }),
512
+ () => loadConfig({ filesystem, root: snapshotRoot }),
499
513
  );
500
514
  for (const diagnostic of loadedConfig.diagnostics) {
501
515
  findings.push({
@@ -509,7 +523,7 @@ export async function inspectAdoption({
509
523
  const npm = await traceAsync(
510
524
  "npm-project.inspect",
511
525
  {},
512
- () => inspectNpmProject(snapshotRoot, repositoryRoot),
526
+ () => inspectNpmProject(snapshotRoot, repositoryRoot, { filesystem }),
513
527
  );
514
528
  findings.push(...npm.findings);
515
529
  const skillSource = npm.npmProject && !npm.sourceCheckout
@@ -518,7 +532,7 @@ export async function inspectAdoption({
518
532
  const skill = await traceAsync(
519
533
  "skill.inspect",
520
534
  {},
521
- () => inspectSkill(snapshotRoot, skillSource),
535
+ () => inspectSkill(snapshotRoot, filesystem),
522
536
  );
523
537
  if (!skill.ok) {
524
538
  findings.push({
package/src/cli.js CHANGED
@@ -15,7 +15,7 @@ import { listIdeas } from "./list-ideas.js";
15
15
  import { checkRepository } from "./index.js";
16
16
  import { createIdea } from "./create-idea.js";
17
17
  import { renderResponse } from "./response.js";
18
- import { withTraceFile } from "./trace.js";
18
+ import { traceAsync, withTraceFile } from "./trace.js";
19
19
  import { whatsNext } from "./whatsnext.js";
20
20
 
21
21
  const { version: VERSION } = JSON.parse(
@@ -23,6 +23,7 @@ const { version: VERSION } = JSON.parse(
23
23
  );
24
24
 
25
25
  const TRACE_FILE_SUFFIX = ".trace.jsonl";
26
+ const AUDIENCES = ["human", "agent"];
26
27
 
27
28
  function write(method, value) {
28
29
  const text = value.replace(/\n$/, "");
@@ -38,6 +39,14 @@ export function normalizeTraceFileName(value) {
38
39
  function addCommonOptions(command) {
39
40
  return command
40
41
  .option("--json", "serialize all four command projections as JSON")
42
+ .addOption(
43
+ new Option(
44
+ "--audience <audience>",
45
+ "select human TUI or agent Markdown output (default: human)",
46
+ )
47
+ .choices(AUDIENCES)
48
+ .conflicts("json"),
49
+ )
41
50
  .option(
42
51
  "--trace <file.trace.jsonl>",
43
52
  "write domain and timing events as JSONL (suffix is appended when omitted)",
@@ -46,11 +55,106 @@ function addCommonOptions(command) {
46
55
  .option("-r, --root <path>", "repository root", process.cwd());
47
56
  }
48
57
 
49
- export function render(report, json, io) {
50
- io.log(
51
- json
52
- ? JSON.stringify(report, null, 2)
53
- : renderResponse(report.response),
58
+ export function selectOutputRenderer({
59
+ audience = "human",
60
+ json = false,
61
+ stdinIsTTY = false,
62
+ stdoutIsTTY = false,
63
+ }) {
64
+ if (json) return "json";
65
+ if (audience === "human" && stdinIsTTY && stdoutIsTTY) return "tui";
66
+ return "markdown";
67
+ }
68
+
69
+ async function defaultTuiRenderer(content, terminal) {
70
+ const { renderTuiMarkdown } = await import("./tui.js");
71
+ await renderTuiMarkdown(content, terminal);
72
+ }
73
+
74
+ export async function render(
75
+ report,
76
+ { audience = "human", json = false } = {},
77
+ io = console,
78
+ {
79
+ renderTui = defaultTuiRenderer,
80
+ terminal = { stdin: process.stdin, stdout: process.stdout },
81
+ } = {},
82
+ ) {
83
+ const renderer = selectOutputRenderer({
84
+ audience,
85
+ json,
86
+ stdinIsTTY: terminal.stdin.isTTY === true,
87
+ stdoutIsTTY: terminal.stdout.isTTY === true,
88
+ });
89
+ if (renderer === "json") {
90
+ io.log(JSON.stringify(report, null, 2));
91
+ return;
92
+ }
93
+
94
+ const markdown = renderResponse(report.response);
95
+ if (renderer === "tui") {
96
+ await renderTui(markdown, terminal);
97
+ return;
98
+ }
99
+ io.log(markdown);
100
+ }
101
+
102
+ function outputRenderer(options, runtime) {
103
+ const terminal = runtime.terminal ?? {
104
+ stdin: process.stdin,
105
+ stdout: process.stdout,
106
+ };
107
+ return selectOutputRenderer({
108
+ audience: options.audience,
109
+ json: options.json,
110
+ stdinIsTTY: terminal.stdin.isTTY === true,
111
+ stdoutIsTTY: terminal.stdout.isTTY === true,
112
+ });
113
+ }
114
+
115
+ function bootstrapDuration(runtime) {
116
+ if (typeof runtime.bootstrapStartedAt !== "bigint") return {};
117
+ const elapsed = process.hrtime.bigint() - runtime.bootstrapStartedAt;
118
+ return {
119
+ bootstrapDurationMs: Math.round(Number(elapsed) / 1_000) / 1_000,
120
+ };
121
+ }
122
+
123
+ async function runCommand({
124
+ attributes,
125
+ execute,
126
+ name,
127
+ options,
128
+ }, io, runtime) {
129
+ const renderer = outputRenderer(options, runtime);
130
+ const traceAttributes = {
131
+ ...attributes,
132
+ outputRenderer: renderer,
133
+ ...bootstrapDuration(runtime),
134
+ };
135
+ if (renderer === "tui") {
136
+ const report = await withTraceFile(
137
+ options.trace,
138
+ name,
139
+ traceAttributes,
140
+ execute,
141
+ );
142
+ await render(report, options, io, runtime);
143
+ return report;
144
+ }
145
+ return withTraceFile(
146
+ options.trace,
147
+ name,
148
+ traceAttributes,
149
+ async () => {
150
+ const report = await execute();
151
+ await traceAsync(
152
+ "output.render",
153
+ { renderer },
154
+ () => render(report, options, io, runtime),
155
+ );
156
+ return report;
157
+ },
54
158
  );
55
159
  }
56
160
 
@@ -74,7 +178,7 @@ function collectOption(value, previous = []) {
74
178
  return [...previous, value];
75
179
  }
76
180
 
77
- export function createProgram(io = console) {
181
+ export function createProgram(io = console, runtime = {}) {
78
182
  const program = new Command();
79
183
  program
80
184
  .name("silvermoon")
@@ -93,10 +197,12 @@ export function createProgram(io = console) {
93
197
  .addHelpText("after", `
94
198
  Examples:
95
199
  $ silvermoon list-ideas
200
+ $ silvermoon list-ideas --language zh-CN
96
201
  $ silvermoon list-ideas --state completed --sort oldest --limit 10
97
202
  $ silvermoon whats-next
98
203
  $ silvermoon whats-next <idea>
99
204
  $ silvermoon whats-next <idea> --language zh-CN
205
+ $ silvermoon whats-next <idea> --audience agent
100
206
  $ silvermoon whats-next <idea> --json
101
207
  $ silvermoon whats-next <idea> --trace whats-next.trace.jsonl
102
208
  $ silvermoon create-idea
@@ -111,6 +217,11 @@ Examples:
111
217
  program
112
218
  .command("list-ideas")
113
219
  .description("query the current local idea inventory")
220
+ .option(
221
+ "--language <tag>",
222
+ "use a built-in output language (en, en-US, zh, or zh-CN) for this invocation",
223
+ outputLanguageArgument,
224
+ )
114
225
  .option(
115
226
  "--state <state>",
116
227
  "include a lifecycle state or active (repeatable)",
@@ -138,20 +249,21 @@ Examples:
138
249
  sort: options.sort,
139
250
  states: options.state,
140
251
  });
141
- const report = await withTraceFile(
142
- options.trace,
143
- "command.list-ideas",
144
- {
252
+ const report = await runCommand({
253
+ name: "command.list-ideas",
254
+ options,
255
+ attributes: {
145
256
  command: "list-ideas",
146
257
  hasQuery: query.query !== null,
258
+ outputLanguage: options.language ?? null,
147
259
  stateCount: query.states.length,
148
260
  },
149
- () => listIdeas({
261
+ execute: () => listIdeas({
150
262
  ...query,
263
+ language: options.language,
151
264
  root: options.root,
152
265
  }),
153
- );
154
- render(report, options.json, io);
266
+ }, io, runtime);
155
267
  program.setOptionValue(
156
268
  "resultCode",
157
269
  report.observation.state === "ideas-listed" ? 0 : 1,
@@ -164,24 +276,23 @@ Examples:
164
276
  .description("observe project, repository, and idea readiness")
165
277
  .option(
166
278
  "--language <tag>",
167
- "use a built-in output language (en-US or zh-CN) for this invocation",
279
+ "use a built-in output language (en, en-US, zh, or zh-CN) for this invocation",
168
280
  outputLanguageArgument,
169
281
  ),
170
282
  ).action(async (idea, options) => {
171
- const report = await withTraceFile(
172
- options.trace,
173
- "command.whats-next",
174
- {
283
+ const report = await runCommand({
284
+ name: "command.whats-next",
285
+ options,
286
+ attributes: {
175
287
  command: "whats-next",
176
288
  outputLanguage: options.language ?? null,
177
289
  },
178
- () => whatsNext({
290
+ execute: () => whatsNext({
179
291
  idea,
180
292
  language: options.language,
181
293
  root: options.root,
182
294
  }),
183
- );
184
- render(report, options.json, io);
295
+ }, io, runtime);
185
296
  program.setOptionValue("resultCode", 0);
186
297
  });
187
298
 
@@ -195,19 +306,18 @@ Examples:
195
306
  contentLanguageArgument,
196
307
  ),
197
308
  ).action(async (options) => {
198
- const report = await withTraceFile(
199
- options.trace,
200
- "command.create-idea",
201
- {
309
+ const report = await runCommand({
310
+ name: "command.create-idea",
311
+ options,
312
+ attributes: {
202
313
  command: "create-idea",
203
314
  contentLanguage: options.language ?? null,
204
315
  },
205
- () => createIdea({
316
+ execute: () => createIdea({
206
317
  language: options.language,
207
318
  root: options.root,
208
319
  }),
209
- );
210
- render(report, options.json, io);
320
+ }, io, runtime);
211
321
  program.setOptionValue("resultCode", 0);
212
322
  });
213
323
 
@@ -217,7 +327,7 @@ Examples:
217
327
  .description("validate Silvermoon configuration and idea state")
218
328
  .option(
219
329
  "--language <tag>",
220
- "use a built-in output language (en-US or zh-CN) for this invocation",
330
+ "use a built-in output language (en, en-US, zh, or zh-CN) for this invocation",
221
331
  outputLanguageArgument,
222
332
  )
223
333
  .addOption(new Option("--remote", "fetch and validate the configured primary tip").conflicts(["commit", "staged", "worktree"]))
@@ -225,14 +335,14 @@ Examples:
225
335
  .addOption(new Option("--staged", "validate the index snapshot").conflicts(["remote", "commit", "worktree"]))
226
336
  .addOption(new Option("--worktree", "validate HEAD plus staged, unstaged, and untracked changes").conflicts(["remote", "commit", "staged"])),
227
337
  ).action(async (options) => {
228
- const report = await withTraceFile(
229
- options.trace,
230
- "command.check",
231
- {
338
+ const report = await runCommand({
339
+ name: "command.check",
340
+ options,
341
+ attributes: {
232
342
  command: "check",
233
343
  outputLanguage: options.language ?? null,
234
344
  },
235
- () => checkRepository({
345
+ execute: () => checkRepository({
236
346
  commit: options.commit,
237
347
  language: options.language,
238
348
  remote: options.remote,
@@ -240,8 +350,7 @@ Examples:
240
350
  staged: options.staged,
241
351
  worktree: options.worktree,
242
352
  }),
243
- );
244
- render(report, options.json, io);
353
+ }, io, runtime);
245
354
  program.setOptionValue(
246
355
  "resultCode",
247
356
  report.observation.state === "project-ready" ? 0 : 1,
@@ -251,8 +360,8 @@ Examples:
251
360
  return program;
252
361
  }
253
362
 
254
- export async function runCli(args, io = console) {
255
- const program = createProgram(io);
363
+ export async function runCli(args, io = console, runtime = {}) {
364
+ const program = createProgram(io, runtime);
256
365
  try {
257
366
  await program.parseAsync(args.length === 0 ? ["--help"] : args, { from: "user" });
258
367
  } catch (caught) {
package/src/config.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { lstat, readFile } from "node:fs/promises";
2
2
  import { resolve } from "node:path";
3
3
 
4
+ import { inspectTreeLineage, readGitBlobs } from "./git.js";
4
5
  import { CONFIG_PATH, METADATA_ROOT } from "./layout.js";
5
6
  import { isCanonicalLanguageTag } from "./language.js";
6
7
  import { validBranchName, validRepository } from "./repository.js";
@@ -14,28 +15,32 @@ const CONFIG_KEYS = [
14
15
  "primaryBranch",
15
16
  "preferredLanguage",
16
17
  ];
18
+ const DEFAULT_FILESYSTEM = { lstat, readFile };
17
19
 
18
20
  function configDiagnostic(code, path, message, remediation) {
19
21
  return { code, level: "error", path, message, remediation };
20
22
  }
21
23
 
22
- async function regularRepositoryFile(root) {
24
+ async function regularRepositoryFile(root, filesystem) {
23
25
  const metadataRoot = resolve(root, METADATA_ROOT);
24
26
  const absolutePath = resolve(root, CONFIG_PATH);
25
27
  try {
26
- const directoryMetadata = await lstat(metadataRoot);
28
+ const directoryMetadata = await filesystem.lstat(metadataRoot);
27
29
  if (!directoryMetadata.isDirectory() || directoryMetadata.isSymbolicLink()) {
28
30
  throw Object.assign(new Error(`${METADATA_ROOT} must be a regular directory`), {
29
31
  code: "EINVAL",
30
32
  });
31
33
  }
32
- const fileMetadata = await lstat(absolutePath);
34
+ const fileMetadata = await filesystem.lstat(absolutePath);
33
35
  if (!fileMetadata.isFile() || fileMetadata.isSymbolicLink()) {
34
36
  throw Object.assign(new Error("Configuration must be a regular file"), {
35
37
  code: "EINVAL",
36
38
  });
37
39
  }
38
- return { absolutePath, source: await readFile(absolutePath, "utf8") };
40
+ return {
41
+ absolutePath,
42
+ source: await filesystem.readFile(absolutePath, "utf8"),
43
+ };
39
44
  } catch (error) {
40
45
  const missing = error.code === "ENOENT";
41
46
  return {
@@ -70,17 +75,8 @@ export function serializeConfig(config) {
70
75
  return stringifyCanonicalYaml(canonical);
71
76
  }
72
77
 
73
- export async function loadConfig({ root }) {
74
- const loaded = await regularRepositoryFile(root);
75
- if (loaded.diagnostic) {
76
- return {
77
- config: null,
78
- configPath: loaded.absolutePath,
79
- diagnostics: [loaded.diagnostic],
80
- };
81
- }
82
-
83
- const normalizedSource = loaded.source.replaceAll("\r\n", "\n");
78
+ function parseConfigSource({ absolutePath, root, source }) {
79
+ const normalizedSource = source.replaceAll("\r\n", "\n");
84
80
  let value;
85
81
  try {
86
82
  value = parseStrictYaml(normalizedSource);
@@ -187,7 +183,71 @@ export async function loadConfig({ root }) {
187
183
 
188
184
  return {
189
185
  config: diagnostics.length === 0 ? value : null,
190
- configPath: loaded.absolutePath,
186
+ configPath: absolutePath,
191
187
  diagnostics,
192
188
  };
193
189
  }
190
+
191
+ export async function loadConfig({
192
+ filesystem = DEFAULT_FILESYSTEM,
193
+ root,
194
+ }) {
195
+ const loaded = await regularRepositoryFile(root, filesystem);
196
+ if (loaded.diagnostic) {
197
+ return {
198
+ config: null,
199
+ configPath: loaded.absolutePath,
200
+ diagnostics: [loaded.diagnostic],
201
+ };
202
+ }
203
+ return parseConfigSource({
204
+ absolutePath: loaded.absolutePath,
205
+ root,
206
+ source: loaded.source,
207
+ });
208
+ }
209
+
210
+ export async function loadConfigSnapshot({ gitRoot, tree }) {
211
+ const absolutePath = resolve(gitRoot, CONFIG_PATH);
212
+ const entries = inspectTreeLineage(gitRoot, tree, CONFIG_PATH);
213
+ const metadataRoot = entries.find(({ name }) => name === METADATA_ROOT);
214
+ const config = entries.find(({ name }) => name === CONFIG_PATH);
215
+ if (metadataRoot === undefined || config === undefined) {
216
+ return {
217
+ config: null,
218
+ configPath: absolutePath,
219
+ diagnostics: [configDiagnostic(
220
+ "config.missing",
221
+ CONFIG_PATH,
222
+ `Missing Silvermoon configuration: ${CONFIG_PATH}`,
223
+ `Create ${CONFIG_PATH}.`,
224
+ )],
225
+ };
226
+ }
227
+ if (
228
+ metadataRoot.type !== "tree"
229
+ || metadataRoot.mode !== "040000"
230
+ || config.type !== "blob"
231
+ || !["100644", "100755"].includes(config.mode)
232
+ ) {
233
+ return {
234
+ config: null,
235
+ configPath: absolutePath,
236
+ diagnostics: [configDiagnostic(
237
+ "config.invalid-file",
238
+ CONFIG_PATH,
239
+ "Cannot read Silvermoon configuration: configuration paths must be regular",
240
+ `Replace ${METADATA_ROOT} and ${CONFIG_PATH} with repository-owned regular paths.`,
241
+ )],
242
+ };
243
+ }
244
+ const source = readGitBlobs(gitRoot, [config.object]).get(config.object);
245
+ if (source === undefined) {
246
+ throw new Error(`Cannot read Silvermoon configuration blob ${config.object}`);
247
+ }
248
+ return parseConfigSource({
249
+ absolutePath,
250
+ root: gitRoot,
251
+ source: source.toString("utf8"),
252
+ });
253
+ }
@@ -10,10 +10,7 @@ import {
10
10
  import { createCommandRun } from "./domain.js";
11
11
  import { inspectPhaseGuidance } from "./guidance.js";
12
12
  import {
13
- DEPLOYMENT_TEMPLATE,
14
- IDEA_TEMPLATE,
15
- IMPLEMENTATION_TEMPLATE,
16
- LEDGER_TEMPLATE,
13
+ ideaTemplates,
17
14
  } from "./idea-templates.js";
18
15
  import { isValidUlid, serializeIdeaStatus } from "./ideas.js";
19
16
  import { canonicalizeLanguageTag } from "./language.js";
@@ -209,6 +206,7 @@ async function cleanupScaffold(cleanupPlan, operations) {
209
206
 
210
207
  async function createScaffold({
211
208
  canonicalLanguage,
209
+ contentLanguage,
212
210
  generateId,
213
211
  operations,
214
212
  repositoryRoot,
@@ -263,11 +261,12 @@ async function createScaffold({
263
261
  paths.innerPath,
264
262
  paths.idealPath,
265
263
  ];
264
+ const templates = ideaTemplates(contentLanguage);
266
265
  const files = [
267
- [paths.ideaDocumentPath, IDEA_TEMPLATE],
268
- [paths.implementationDocumentPath, IMPLEMENTATION_TEMPLATE],
269
- [paths.deploymentDocumentPath, DEPLOYMENT_TEMPLATE],
270
- [paths.ledgerPath, LEDGER_TEMPLATE],
266
+ [paths.ideaDocumentPath, templates.idea],
267
+ [paths.implementationDocumentPath, templates.implementation],
268
+ [paths.deploymentDocumentPath, templates.deployment],
269
+ [paths.ledgerPath, templates.ledger],
271
270
  [paths.statusPath, serializeIdeaStatus({
272
271
  version: 1,
273
272
  id,
@@ -420,6 +419,7 @@ export async function createIdea({
420
419
  },
421
420
  () => createScaffold({
422
421
  canonicalLanguage,
422
+ contentLanguage: observed.contentLanguage,
423
423
  generateId,
424
424
  operations,
425
425
  repositoryRoot,
@@ -467,6 +467,18 @@ export async function createIdea({
467
467
  }
468
468
 
469
469
  const { createdIdea, paths } = completion.result;
470
+ const templates = ideaTemplates(observed.contentLanguage);
471
+ const languageInstruction = templates.localized
472
+ ? localize(
473
+ observed.outputLanguage,
474
+ `Use ${observed.contentLanguage} for all natural-language content in the canonical idea documents and ledger.`,
475
+ `在 canonical idea 文档和 ledger 的所有自然语言内容中使用 ${observed.contentLanguage}。`,
476
+ )
477
+ : localize(
478
+ observed.outputLanguage,
479
+ `Silvermoon has no built-in ${observed.contentLanguage} scaffold, so the new files contain explicit ${templates.templateLanguage} fallback placeholders. Replace all natural-language placeholders with ${observed.contentLanguage} before continuing.`,
480
+ `Silvermoon 没有内置的 ${observed.contentLanguage} 脚手架,因此新文件包含明确的 ${templates.templateLanguage} fallback 占位。继续前,将所有自然语言占位替换为 ${observed.contentLanguage}。`,
481
+ );
470
482
  return runtime.complete(
471
483
  dialogueReadyObservation(readiness.observation, "idea-created", {
472
484
  createdIdea,
@@ -477,8 +489,8 @@ export async function createIdea({
477
489
  {
478
490
  nextSteps: localize(
479
491
  observed.outputLanguage,
480
- `Use ${observed.contentLanguage} for natural-language content while describing the requested Ideal World in ${paths.ideaDocumentPath}; keep the stable IDs and placeholders in ${paths.implementationDocumentPath}, ${paths.deploymentDocumentPath}, and ${paths.ledgerPath} synchronized.`,
481
- `使用 ${observed.contentLanguage} 在 ${paths.ideaDocumentPath} 中描述请求的理想契约,并保持 ${paths.implementationDocumentPath}、${paths.deploymentDocumentPath} 与 ${paths.ledgerPath} 中的稳定 ID 和占位同步。`,
492
+ `${languageInstruction} Describe the requested Ideal World in ${paths.ideaDocumentPath}; keep canonical headings, stable IDs, and placeholders in ${paths.implementationDocumentPath}, ${paths.deploymentDocumentPath}, and ${paths.ledgerPath} synchronized.`,
493
+ `${languageInstruction} 在 ${paths.ideaDocumentPath} 中描述请求的理想契约;保持 ${paths.implementationDocumentPath}、${paths.deploymentDocumentPath} 与 ${paths.ledgerPath} 中的 canonical 标题、稳定 ID 和占位同步。`,
482
494
  ),
483
495
  },
484
496
  );