silvermoon 0.0.3 → 0.1.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.
@@ -2,7 +2,12 @@ import { randomBytes } from "node:crypto";
2
2
  import { lstat, mkdir, readFile, rm, rmdir, writeFile } from "node:fs/promises";
3
3
  import { resolve } from "node:path";
4
4
 
5
- import { loadConfig } from "./config.js";
5
+ import {
6
+ createEnvelope,
7
+ dialogueReadyObservation,
8
+ localize,
9
+ outcome,
10
+ } from "./dialogue.js";
6
11
  import {
7
12
  DEPLOYMENT_TEMPLATE,
8
13
  IDEA_TEMPLATE,
@@ -12,7 +17,11 @@ import {
12
17
  import { isValidUlid, serializeIdeaStatus } from "./ideas.js";
13
18
  import { canonicalizeLanguageTag } from "./language.js";
14
19
  import { IDEAS_ROOT, ideaPaths } from "./layout.js";
15
- import { whatsNext } from "./whatsnext.js";
20
+ import { observeSnapshot } from "./observation.js";
21
+ import {
22
+ assessIdeaCreationReadiness,
23
+ projectInstructions,
24
+ } from "./whatsnext.js";
16
25
 
17
26
  const ULID_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
18
27
  const MAX_ID_ATTEMPTS = 32;
@@ -45,29 +54,31 @@ async function removeOwnedFile(path, expected, read, remove) {
45
54
  try {
46
55
  current = await read(path);
47
56
  } catch (caught) {
48
- if (caught.code === "ENOENT") return;
57
+ if (caught.code === "ENOENT") return { path, status: "missing" };
49
58
  throw caught;
50
59
  }
51
60
  const expectedBytes = Buffer.from(expected);
52
- if (
53
- current.length <= expectedBytes.length &&
54
- expectedBytes.subarray(0, current.length).equals(current)
55
- ) {
61
+ if (current.equals(expectedBytes)) {
56
62
  await remove(path, { force: true });
63
+ return { path, status: "removed" };
57
64
  }
65
+ return { path, status: "preserved-modified" };
58
66
  }
59
67
 
60
68
  async function removeCreatedDirectories(created, removeDirectory) {
69
+ const results = [];
61
70
  for (const path of [...created].reverse()) {
62
71
  try {
63
72
  await removeDirectory(path);
64
- } catch {
65
- return;
73
+ results.push({ path, status: "removed" });
74
+ } catch (caught) {
75
+ results.push({ path, status: "preserved", error: caught.message });
66
76
  }
67
77
  }
78
+ return results;
68
79
  }
69
80
 
70
- async function ensureDirectoryPath(root, relativePath, inspect, makeDirectory, removeDirectory) {
81
+ async function ensureDirectoryPath(root, relativePath, operations) {
71
82
  const created = [];
72
83
  let current = root;
73
84
  try {
@@ -75,7 +86,7 @@ async function ensureDirectoryPath(root, relativePath, inspect, makeDirectory, r
75
86
  current = resolve(current, segment);
76
87
  let metadata;
77
88
  try {
78
- metadata = await inspect(current);
89
+ metadata = await operations.inspect(current);
79
90
  } catch (caught) {
80
91
  if (caught.code !== "ENOENT") throw caught;
81
92
  }
@@ -86,40 +97,67 @@ async function ensureDirectoryPath(root, relativePath, inspect, makeDirectory, r
86
97
  continue;
87
98
  }
88
99
  try {
89
- await makeDirectory(current);
100
+ await operations.makeDirectory(current);
90
101
  created.push(current);
91
102
  } catch (caught) {
92
103
  if (caught.code !== "EEXIST") throw caught;
93
- metadata = await inspect(current);
104
+ metadata = await operations.inspect(current);
94
105
  if (!metadata.isDirectory() || metadata.isSymbolicLink()) throw caught;
95
106
  }
96
107
  }
97
108
  return created;
98
109
  } catch (caught) {
99
- await removeCreatedDirectories(created, removeDirectory);
110
+ caught.cleanup = await removeCreatedDirectories(
111
+ created,
112
+ operations.removeDirectory,
113
+ );
100
114
  throw caught;
101
115
  }
102
116
  }
103
117
 
104
- function failure(root, preflight, caught) {
105
- return {
106
- command: "create-idea",
107
- ok: false,
108
- root,
109
- diagnostics: [{
110
- code: "idea.create.failed",
111
- level: "error",
112
- message: caught.message,
113
- remediation: "Preserve existing paths, resolve the filesystem error, and retry.",
114
- }],
115
- result: {
116
- observedPrimaryCommit: preflight.result?.observedPrimaryCommit ?? null,
117
- request: { kind: "create-idea" },
118
- selectedIdea: null,
119
- action: null,
120
- createdIdea: null,
121
- },
122
- };
118
+ function cleanupSummary(results) {
119
+ const preserved = results.filter(({ status }) => status.startsWith("preserved"));
120
+ return preserved.length === 0
121
+ ? "All paths still owned by this operation were removed."
122
+ : `Preserved ${preserved.length} path(s) because they changed or could not be removed: `
123
+ + preserved.map(({ path }) => path).join(", ");
124
+ }
125
+
126
+ function failureEnvelope({
127
+ caught,
128
+ intention,
129
+ observation,
130
+ outcomes,
131
+ cleanup = [],
132
+ language,
133
+ }) {
134
+ outcomes.push(outcome(
135
+ "create-idea-scaffold",
136
+ "failure",
137
+ localize(
138
+ language,
139
+ `Idea creation failed: ${caught.message} ${cleanupSummary(cleanup)}`,
140
+ `创建 idea 失败:${caught.message} ${cleanupSummary(cleanup)}`,
141
+ ),
142
+ ));
143
+ return createEnvelope(
144
+ intention,
145
+ dialogueReadyObservation(observation, "idea-create-failed"),
146
+ outcomes,
147
+ localize(
148
+ language,
149
+ "Preserve any reported paths, resolve the filesystem error, and retry `silvermoon create-idea`.",
150
+ "保留报告中的路径,解决文件系统错误后,再运行 `silvermoon create-idea`。",
151
+ ),
152
+ );
153
+ }
154
+
155
+ function projectCreationObservation(observation) {
156
+ const { ideas: _ideas, ...project } = observation;
157
+ if (project.observedThrough === "ideas") {
158
+ project.observedThrough = "configuration";
159
+ }
160
+ return project;
123
161
  }
124
162
 
125
163
  export async function createIdea({
@@ -129,42 +167,56 @@ export async function createIdea({
129
167
  root = process.cwd(),
130
168
  userHome,
131
169
  } = {}) {
132
- const repositoryRoot = resolve(root);
133
- let canonicalLanguage;
134
- if (language !== undefined) {
135
- try {
136
- canonicalLanguage = canonicalizeLanguageTag(language);
137
- } catch (caught) {
138
- return {
139
- command: "create-idea",
140
- ok: false,
141
- root: repositoryRoot,
142
- diagnostics: [{
143
- code: "idea.language.invalid",
144
- level: "error",
145
- message: caught.message,
146
- remediation: "Use a valid BCP 47 tag such as en or zh-CN.",
147
- }],
148
- result: {
149
- observedPrimaryCommit: null,
150
- request: { kind: "create-idea" },
151
- selectedIdea: null,
152
- action: null,
153
- createdIdea: null,
154
- },
155
- };
156
- }
170
+ const requestedRoot = resolve(root);
171
+ const canonicalLanguage = language === undefined
172
+ ? undefined
173
+ : canonicalizeLanguageTag(language);
174
+ const intention = {
175
+ command: "create-idea",
176
+ args: { language: canonicalLanguage ?? null },
177
+ };
178
+ const outcomes = [];
179
+ const observed = await observeSnapshot({
180
+ allowMissingIdeas: true,
181
+ ideaLanguage: canonicalLanguage,
182
+ root: requestedRoot,
183
+ userHome,
184
+ version: { type: "worktree" },
185
+ });
186
+ const recheckCommand = canonicalLanguage === undefined
187
+ ? "silvermoon create-idea"
188
+ : `silvermoon create-idea --language ${canonicalLanguage}`;
189
+ if (!observed.projectReady) {
190
+ return createEnvelope(
191
+ intention,
192
+ projectCreationObservation(observed.observation),
193
+ outcomes,
194
+ projectInstructions(
195
+ observed,
196
+ observed.observation.root,
197
+ observed.outputLanguage,
198
+ recheckCommand,
199
+ ),
200
+ );
157
201
  }
158
- const preflight = await whatsNext({
159
- create: true,
202
+ const repositoryRoot = observed.observation.root;
203
+ const readiness = await assessIdeaCreationReadiness({
204
+ observed,
205
+ outcomes,
206
+ recheckCommand,
160
207
  root: repositoryRoot,
161
- userHome,
162
208
  });
163
- const routed = { ...preflight, command: "create-idea" };
164
- if (!preflight.ok || preflight.result.action?.code !== "create-idea") return routed;
165
-
166
- const loaded = await loadConfig({ root: repositoryRoot });
167
- if (!loaded.config) return { ...routed, ok: false, diagnostics: loaded.diagnostics };
209
+ if (!readiness.ready) {
210
+ return createEnvelope(
211
+ intention,
212
+ dialogueReadyObservation(
213
+ readiness.observation,
214
+ "repository-preparation-required",
215
+ ),
216
+ outcomes,
217
+ readiness.instructions,
218
+ );
219
+ }
168
220
 
169
221
  const inspect = operations.lstat ?? lstat;
170
222
  const makeDirectory = operations.mkdir ?? mkdir;
@@ -172,32 +224,55 @@ export async function createIdea({
172
224
  const remove = operations.rm ?? rm;
173
225
  const removeDirectory = operations.rmdir ?? rmdir;
174
226
  const write = operations.writeFile ?? writeFile;
227
+ const fileOperations = {
228
+ inspect,
229
+ makeDirectory,
230
+ removeDirectory,
231
+ };
175
232
  let rootDirectories;
176
233
  try {
177
234
  rootDirectories = await ensureDirectoryPath(
178
235
  repositoryRoot,
179
236
  IDEAS_ROOT,
180
- inspect,
181
- makeDirectory,
182
- removeDirectory,
237
+ fileOperations,
183
238
  );
184
239
  } catch (caught) {
185
- return failure(repositoryRoot, preflight, caught);
240
+ return failureEnvelope({
241
+ caught,
242
+ intention,
243
+ observation: readiness.observation,
244
+ outcomes,
245
+ cleanup: caught.cleanup,
246
+ language: observed.outputLanguage,
247
+ });
186
248
  }
187
249
 
188
- const failRoot = async (caught) => {
189
- await removeCreatedDirectories(rootDirectories, removeDirectory);
190
- return failure(repositoryRoot, preflight, caught);
191
- };
192
-
193
250
  for (let attempt = 0; attempt < MAX_ID_ATTEMPTS; attempt += 1) {
194
- const id = await generateId();
195
- if (!isValidUlid(id)) {
196
- return failRoot(new Error(`Generated idea id is not a canonical ULID: ${id}`));
251
+ let id;
252
+ let paths;
253
+ let folder;
254
+ try {
255
+ id = await generateId();
256
+ if (!isValidUlid(id)) {
257
+ throw new Error(`Generated idea id is not a canonical ULID: ${id}`);
258
+ }
259
+ paths = ideaPaths(id);
260
+ folder = resolve(repositoryRoot, paths.ideaPath);
261
+ if (await pathExists(folder, inspect)) continue;
262
+ } catch (caught) {
263
+ const cleanup = await removeCreatedDirectories(
264
+ rootDirectories,
265
+ removeDirectory,
266
+ );
267
+ return failureEnvelope({
268
+ caught,
269
+ intention,
270
+ observation: readiness.observation,
271
+ outcomes,
272
+ cleanup,
273
+ language: observed.outputLanguage,
274
+ });
197
275
  }
198
- const paths = ideaPaths(id);
199
- const folder = resolve(repositoryRoot, paths.ideaPath);
200
- if (await pathExists(folder, inspect)) continue;
201
276
 
202
277
  const directoryPaths = [
203
278
  paths.ideaPath,
@@ -218,6 +293,7 @@ export async function createIdea({
218
293
  ];
219
294
  const createdDirectories = [];
220
295
  const cleanupFiles = [];
296
+ let collisionPath = null;
221
297
  try {
222
298
  for (const relativePath of directoryPaths) {
223
299
  const absolutePath = resolve(repositoryRoot, relativePath);
@@ -231,42 +307,78 @@ export async function createIdea({
231
307
  try {
232
308
  await write(absolutePath, source, { flag: "wx" });
233
309
  } catch (caught) {
234
- if (caught.code === "EEXIST") cleanupFiles.pop();
310
+ if (caught.code === "EEXIST") {
311
+ cleanupFiles.pop();
312
+ collisionPath = absolutePath;
313
+ }
235
314
  throw caught;
236
315
  }
237
316
  }
238
- return {
239
- command: "create-idea",
240
- ok: true,
241
- root: repositoryRoot,
242
- diagnostics: [],
243
- result: {
244
- observedPrimaryCommit: preflight.result.observedPrimaryCommit,
245
- request: { kind: "create-idea" },
246
- selectedIdea: null,
247
- createdIdea: {
248
- id,
249
- ideaPath: paths.ideaPath,
250
- statusPath: paths.statusPath,
251
- ideaDocumentPath: paths.ideaDocumentPath,
252
- implementationDocumentPath: paths.implementationDocumentPath,
253
- deploymentDocumentPath: paths.deploymentDocumentPath,
254
- ledgerPath: paths.ledgerPath,
255
- ...(canonicalLanguage === undefined ? {} : { language: canonicalLanguage }),
256
- },
257
- },
258
- };
317
+ outcomes.push(outcome(
318
+ "create-idea-scaffold",
319
+ "success",
320
+ localize(
321
+ observed.outputLanguage,
322
+ `Created idea ${id} at ${paths.ideaPath}.`,
323
+ `已在 ${paths.ideaPath} 创建 idea ${id}。`,
324
+ ),
325
+ ));
326
+ return createEnvelope(
327
+ intention,
328
+ dialogueReadyObservation(readiness.observation, "idea-created", {
329
+ createdIdea: { id, path: paths.ideaPath, state: "preparing" },
330
+ }),
331
+ outcomes,
332
+ localize(
333
+ observed.outputLanguage,
334
+ `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.`,
335
+ `使用 ${observed.contentLanguage} 在 ${paths.ideaDocumentPath} 中描述请求的理想契约,并保持 ${paths.implementationDocumentPath}、${paths.deploymentDocumentPath} 与 ${paths.ledgerPath} 中的稳定 ID 和占位同步。`,
336
+ ),
337
+ );
259
338
  } catch (caught) {
339
+ const cleanup = [];
260
340
  for (const [path, source] of [...cleanupFiles].reverse()) {
261
- await removeOwnedFile(path, source, read, remove).catch(() => { });
341
+ try {
342
+ cleanup.push(await removeOwnedFile(path, source, read, remove));
343
+ } catch (cleanupError) {
344
+ cleanup.push({
345
+ path,
346
+ status: "preserved",
347
+ error: cleanupError.message,
348
+ });
349
+ }
350
+ }
351
+ cleanup.push(...await removeCreatedDirectories(
352
+ createdDirectories,
353
+ removeDirectory,
354
+ ));
355
+ if (collisionPath) {
356
+ cleanup.push({ path: collisionPath, status: "preserved-existing" });
262
357
  }
263
- await removeCreatedDirectories(createdDirectories, removeDirectory);
264
- if (caught.code === "EEXIST") continue;
265
- return failRoot(caught);
358
+ cleanup.push(...await removeCreatedDirectories(
359
+ rootDirectories,
360
+ removeDirectory,
361
+ ));
362
+ return failureEnvelope({
363
+ caught,
364
+ intention,
365
+ observation: readiness.observation,
366
+ outcomes,
367
+ cleanup,
368
+ language: observed.outputLanguage,
369
+ });
266
370
  }
267
371
  }
268
372
 
269
- return failRoot(
270
- new Error(`Could not allocate a unique idea id after ${MAX_ID_ATTEMPTS} attempts.`),
271
- );
373
+ const cleanup = await removeCreatedDirectories(rootDirectories, removeDirectory);
374
+ return failureEnvelope({
375
+ caught: new Error(
376
+ `Could not allocate a unique idea id after ${MAX_ID_ATTEMPTS} attempts.`,
377
+ ),
378
+ intention,
379
+ observation: readiness.observation,
380
+ outcomes,
381
+ cleanup,
382
+ language: observed.outputLanguage,
383
+ });
272
384
  }