silvermoon 0.2.2 → 0.3.0-rc.2

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.
@@ -5,6 +5,8 @@ import { deriveIdeaState, isValidUlid, parseIdeaStatus } from "./ideas.js";
5
5
  import { inspectTreePaths, worktreeSnapshot } from "./git.js";
6
6
  import { IDEAS_ROOT, ideaPaths } from "./layout.js";
7
7
 
8
+ const DEFAULT_FILESYSTEM = { lstat, readFile, readdir };
9
+
8
10
  function displayPath(root, path) {
9
11
  return relative(root, path).replaceAll("\\", "/");
10
12
  }
@@ -13,18 +15,18 @@ function error(code, path, message, remediation) {
13
15
  return { code, level: "error", path, message, remediation };
14
16
  }
15
17
 
16
- async function metadata(path) {
18
+ async function metadata(path, filesystem) {
17
19
  try {
18
- return await lstat(path);
20
+ return await filesystem.lstat(path);
19
21
  } catch (caught) {
20
22
  if (caught.code === "ENOENT") return null;
21
23
  throw caught;
22
24
  }
23
25
  }
24
26
 
25
- async function requireDirectory(root, path, diagnostics) {
27
+ async function requireDirectory(root, path, diagnostics, filesystem) {
26
28
  const absolute = resolve(root, path);
27
- const value = await metadata(absolute);
29
+ const value = await metadata(absolute, filesystem);
28
30
  if (!value || !value.isDirectory() || value.isSymbolicLink()) {
29
31
  diagnostics.push(error(
30
32
  "idea.world.invalid-directory",
@@ -37,9 +39,9 @@ async function requireDirectory(root, path, diagnostics) {
37
39
  return true;
38
40
  }
39
41
 
40
- async function requireDocument(root, path, diagnostics) {
42
+ async function requireDocument(root, path, diagnostics, filesystem) {
41
43
  const absolute = resolve(root, path);
42
- const value = await metadata(absolute);
44
+ const value = await metadata(absolute, filesystem);
43
45
  if (!value || !value.isFile() || value.isSymbolicLink()) {
44
46
  diagnostics.push(error(
45
47
  "idea.world.missing-document",
@@ -52,8 +54,8 @@ async function requireDocument(root, path, diagnostics) {
52
54
  return true;
53
55
  }
54
56
 
55
- async function requireLedger(root, path, diagnostics) {
56
- const value = await metadata(resolve(root, path));
57
+ async function requireLedger(root, path, diagnostics, filesystem) {
58
+ const value = await metadata(resolve(root, path), filesystem);
57
59
  if (!value) {
58
60
  diagnostics.push(error(
59
61
  "idea.ledger.missing-file",
@@ -75,11 +77,13 @@ async function requireLedger(root, path, diagnostics) {
75
77
  return true;
76
78
  }
77
79
 
78
- async function rejectSymlinks(root, path, diagnostics) {
80
+ async function rejectSymlinks(root, path, diagnostics, filesystem) {
79
81
  const absolute = resolve(root, path);
80
- for (const entry of await readdir(absolute, { withFileTypes: true })) {
82
+ for (
83
+ const entry of await filesystem.readdir(absolute, { withFileTypes: true })
84
+ ) {
81
85
  const entryPath = `${path}/${entry.name}`;
82
- const value = await lstat(resolve(root, entryPath));
86
+ const value = await filesystem.lstat(resolve(root, entryPath));
83
87
  if (value.isSymbolicLink()) {
84
88
  diagnostics.push(error(
85
89
  "idea.world.symlink",
@@ -89,7 +93,9 @@ async function rejectSymlinks(root, path, diagnostics) {
89
93
  ));
90
94
  continue;
91
95
  }
92
- if (value.isDirectory()) await rejectSymlinks(root, entryPath, diagnostics);
96
+ if (value.isDirectory()) {
97
+ await rejectSymlinks(root, entryPath, diagnostics, filesystem);
98
+ }
93
99
  }
94
100
  }
95
101
 
@@ -109,13 +115,14 @@ function requiredTree(objects, path) {
109
115
 
110
116
  export async function inspectIdeaLayout({
111
117
  config: _config,
118
+ filesystem = DEFAULT_FILESYSTEM,
112
119
  root,
113
120
  gitRoot = root,
114
121
  snapshotTree,
115
122
  }) {
116
123
  const diagnostics = [];
117
124
  const ideasRoot = resolve(root, IDEAS_ROOT);
118
- const ideasRootMetadata = await metadata(ideasRoot);
125
+ const ideasRootMetadata = await metadata(ideasRoot, filesystem);
119
126
  if (!ideasRootMetadata) {
120
127
  return {
121
128
  diagnostics: [error(
@@ -160,7 +167,9 @@ export async function inspectIdeaLayout({
160
167
  const aliases = new Map();
161
168
  const ideas = [];
162
169
  const caseNames = new Map();
163
- const entries = (await readdir(ideasRoot, { withFileTypes: true }))
170
+ const entries = (
171
+ await filesystem.readdir(ideasRoot, { withFileTypes: true })
172
+ )
164
173
  .sort((left, right) => left.name.localeCompare(right.name));
165
174
  const ideaIds = new Set(
166
175
  entries.filter((entry) => isValidUlid(entry.name)).map((entry) => entry.name),
@@ -171,7 +180,18 @@ export async function inspectIdeaLayout({
171
180
  });
172
181
  let worldObjects;
173
182
  try {
174
- worldObjects = inspectTreePaths(gitRoot, resolvedSnapshotTree, worldPaths);
183
+ worldObjects = typeof filesystem.snapshotEntry === "function"
184
+ ? new Map(worldPaths.map((path) => {
185
+ const entry = filesystem.snapshotEntry(path);
186
+ return [
187
+ path,
188
+ {
189
+ object: entry?.object ?? null,
190
+ type: entry?.type ?? "missing",
191
+ },
192
+ ];
193
+ }))
194
+ : inspectTreePaths(gitRoot, resolvedSnapshotTree, worldPaths);
175
195
  } catch (caught) {
176
196
  return {
177
197
  diagnostics: [error(
@@ -185,7 +205,7 @@ export async function inspectIdeaLayout({
185
205
  }
186
206
  for (const entry of entries) {
187
207
  const folderPath = resolve(ideasRoot, entry.name);
188
- const folderMetadata = await lstat(folderPath);
208
+ const folderMetadata = await filesystem.lstat(folderPath);
189
209
  const relativePath = displayPath(root, folderPath);
190
210
  const folded = entry.name.toUpperCase();
191
211
  const existingCase = caseNames.get(folded);
@@ -219,7 +239,9 @@ export async function inspectIdeaLayout({
219
239
  }
220
240
 
221
241
  const paths = ideaPaths(entry.name);
222
- const children = (await readdir(folderPath, { withFileTypes: true }))
242
+ const children = (
243
+ await filesystem.readdir(folderPath, { withFileTypes: true })
244
+ )
223
245
  .sort((left, right) => left.name.localeCompare(right.name));
224
246
  for (const child of children) {
225
247
  if (
@@ -236,7 +258,7 @@ export async function inspectIdeaLayout({
236
258
  "Keep only status.yaml, ledger.md, and outer/ at the idea root; put supporting files in their world.",
237
259
  ));
238
260
  }
239
- await requireLedger(root, paths.ledgerPath, diagnostics);
261
+ await requireLedger(root, paths.ledgerPath, diagnostics, filesystem);
240
262
  const requiredDirectories = [
241
263
  paths.outerPath,
242
264
  paths.innerPath,
@@ -248,14 +270,28 @@ export async function inspectIdeaLayout({
248
270
  paths.ideaDocumentPath,
249
271
  ];
250
272
  const validDirectories = (await Promise.all(
251
- requiredDirectories.map((path) => requireDirectory(root, path, diagnostics)),
273
+ requiredDirectories.map((path) =>
274
+ requireDirectory(root, path, diagnostics, filesystem)
275
+ ),
252
276
  )).every(Boolean);
253
277
  const validDocuments = (await Promise.all(
254
- requiredDocuments.map((path) => requireDocument(root, path, diagnostics)),
278
+ requiredDocuments.map((path) =>
279
+ requireDocument(root, path, diagnostics, filesystem)
280
+ ),
255
281
  )).every(Boolean);
256
- if (validDirectories) await rejectSymlinks(root, paths.outerPath, diagnostics);
282
+ if (validDirectories) {
283
+ await rejectSymlinks(
284
+ root,
285
+ paths.outerPath,
286
+ diagnostics,
287
+ filesystem,
288
+ );
289
+ }
257
290
 
258
- const statusMetadata = await metadata(resolve(root, paths.statusPath));
291
+ const statusMetadata = await metadata(
292
+ resolve(root, paths.statusPath),
293
+ filesystem,
294
+ );
259
295
  if (!statusMetadata || !statusMetadata.isFile() || statusMetadata.isSymbolicLink()) {
260
296
  diagnostics.push(error(
261
297
  "idea.status.invalid-file",
@@ -269,7 +305,7 @@ export async function inspectIdeaLayout({
269
305
  let status;
270
306
  try {
271
307
  status = parseIdeaStatus(
272
- await readFile(resolve(root, paths.statusPath), "utf8"),
308
+ await filesystem.readFile(resolve(root, paths.statusPath), "utf8"),
273
309
  { objectIdLength: repositoryObjectIdLength },
274
310
  );
275
311
  } catch (caught) {
@@ -0,0 +1,48 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { resolve } from "node:path";
3
+
4
+ import { extractIdeaTitle, ideaInventoryItem } from "./idea-query.js";
5
+ import { localize } from "./language.js";
6
+
7
+ export async function readIdeaInventoryItem(root, idea) {
8
+ const documentPath = idea.worlds.idealRevision.documentPath;
9
+ try {
10
+ const source = await readFile(
11
+ resolve(root, ...documentPath.split("/")),
12
+ "utf8",
13
+ );
14
+ return ideaInventoryItem(idea, extractIdeaTitle(source));
15
+ } catch (caught) {
16
+ throw new Error(
17
+ `Cannot read idea metadata for ${idea.id} at ${documentPath}: ${caught.message}`,
18
+ { cause: caught },
19
+ );
20
+ }
21
+ }
22
+
23
+ export function metadataFailureObservation(observed, caught, command) {
24
+ const { ideas: _ideas, ...base } = observed.observation;
25
+ const problem = {
26
+ type: "idea-metadata-unavailable",
27
+ summary: localize(
28
+ observed.outputLanguage,
29
+ caught.message,
30
+ `无法读取 idea metadata:${caught.message}`,
31
+ ),
32
+ };
33
+ return {
34
+ observation: {
35
+ ...base,
36
+ state: "project-setup-required",
37
+ observedThrough: "configuration",
38
+ problems: [problem],
39
+ },
40
+ responseContext: {
41
+ nextSteps: localize(
42
+ observed.outputLanguage,
43
+ `Repair the reported idea document and retry \`${command}\`.`,
44
+ `修复报告的 idea document 后,重新运行 \`${command}\`。`,
45
+ ),
46
+ },
47
+ };
48
+ }
@@ -107,3 +107,136 @@ export const LEDGER_TEMPLATE = `# Ledger
107
107
 
108
108
  - [ ] **D-AC01:** Criterion title
109
109
  `;
110
+
111
+ const CHINESE_IDEA_TEMPLATE = `# 将标题替换为这个 idea 的具体名称
112
+
113
+ ## 意图
114
+
115
+ <!-- 用一两句话说明期望结果。 -->
116
+
117
+ ## 背景
118
+
119
+ <!-- 描述当前问题、情境或机会。 -->
120
+
121
+ ## 期望结果
122
+
123
+ <!-- 描述应当实现的、对外有意义的状态。 -->
124
+
125
+ ## 范围
126
+
127
+ ### 范围内
128
+
129
+ <!-- 描述这个 idea 包含的内容。 -->
130
+
131
+ ### 范围外
132
+
133
+ <!-- 描述这个 idea 有意排除的相邻工作。 -->
134
+
135
+ ## 约束
136
+
137
+ <!-- 记录重要的产品、仓库、兼容性或操作约束。 -->
138
+
139
+ ## 待解决问题
140
+
141
+ <!-- 记录尚未解决的决定;没有时删除本节。 -->
142
+ `;
143
+
144
+ const CHINESE_IMPLEMENTATION_TEMPLATE = `# Implementation
145
+
146
+ ## Steps
147
+
148
+ <!--
149
+ 为每个步骤分配稳定的 I-Sxx 标识符和三级标题。
150
+ 说明修改内容、边界和重要设计细节。
151
+ 不要在本文档中使用任务列表复选框。
152
+ -->
153
+
154
+ ### I-S01: 步骤标题
155
+
156
+ <!-- 描述该实施步骤。 -->
157
+
158
+ ## Acceptance criteria
159
+
160
+ <!--
161
+ 为每项标准分配稳定的 I-ACxx 标识符和三级标题。
162
+ 同时说明可观察结果及其证明方法。
163
+ 不要创建单独的验证章节,也不要使用任务列表复选框。
164
+ -->
165
+
166
+ ### I-AC01: 标准标题
167
+
168
+ <!-- 描述所需结果以及 Agent 如何证明该结果。 -->
169
+ `;
170
+
171
+ const CHINESE_DEPLOYMENT_TEMPLATE = `# Deployment
172
+
173
+ ## Steps
174
+
175
+ <!--
176
+ 为每个步骤分配稳定的 D-Sxx 标识符和三级标题。
177
+ 描述部署或现实世界验证工作。
178
+ 不要在本文档中使用任务列表复选框。
179
+ -->
180
+
181
+ ### D-S01: 步骤标题
182
+
183
+ <!-- 描述该部署或外部验证步骤。 -->
184
+
185
+ ## Acceptance criteria
186
+
187
+ <!--
188
+ 为每项标准分配稳定的 D-ACxx 标识符和三级标题。
189
+ 同时说明可观察的外部结果及其证明方法。
190
+ 不要创建单独的验证章节,也不要使用任务列表复选框。
191
+ -->
192
+
193
+ ### D-AC01: 标准标题
194
+
195
+ <!-- 描述所需外部结果以及 Agent 如何证明该结果。 -->
196
+ `;
197
+
198
+ const CHINESE_LEDGER_TEMPLATE = `# Ledger
199
+
200
+ ## Implementation
201
+
202
+ ### Implementation steps
203
+
204
+ - [ ] **I-S01:** 步骤标题
205
+
206
+ ### Implementation acceptance criteria
207
+
208
+ - [ ] **I-AC01:** 标准标题
209
+
210
+ ## Deployment
211
+
212
+ ### Deployment steps
213
+
214
+ - [ ] **D-S01:** 步骤标题
215
+
216
+ ### Deployment acceptance criteria
217
+
218
+ - [ ] **D-AC01:** 标准标题
219
+ `;
220
+
221
+ export function ideaTemplates(contentLanguage) {
222
+ const normalized = contentLanguage.toLowerCase();
223
+ if (normalized === "zh" || normalized.startsWith("zh-")) {
224
+ return {
225
+ idea: CHINESE_IDEA_TEMPLATE,
226
+ implementation: CHINESE_IMPLEMENTATION_TEMPLATE,
227
+ deployment: CHINESE_DEPLOYMENT_TEMPLATE,
228
+ ledger: CHINESE_LEDGER_TEMPLATE,
229
+ localized: true,
230
+ templateLanguage: "zh-CN",
231
+ };
232
+ }
233
+ const localized = normalized === "en" || normalized.startsWith("en-");
234
+ return {
235
+ idea: IDEA_TEMPLATE,
236
+ implementation: IMPLEMENTATION_TEMPLATE,
237
+ deployment: DEPLOYMENT_TEMPLATE,
238
+ ledger: LEDGER_TEMPLATE,
239
+ localized,
240
+ templateLanguage: "en-US",
241
+ };
242
+ }
package/src/index.js CHANGED
@@ -1,20 +1,20 @@
1
1
  import { resolve } from "node:path";
2
2
 
3
+ import { REPOSITORY_SKILL_PATH } from "./adoption.js";
3
4
  import {
4
5
  diagnosticProblem,
5
6
  } from "./dialogue.js";
6
- import { loadConfig } from "./config.js";
7
+ import { loadConfigSnapshot } from "./config.js";
7
8
  import { createCommandRun } from "./domain.js";
8
9
  import {
9
10
  fetchPrimary,
10
11
  indexSnapshot,
11
- resolveCommit,
12
+ resolveSnapshot,
12
13
  runGit,
13
14
  sanitizeGitMessage,
14
15
  worktreeSnapshot,
15
- withTemporaryTree,
16
- withTemporaryWorktree,
17
16
  } from "./git.js";
17
+ import { createGitSnapshotFileSystem } from "./git-snapshot.js";
18
18
  import {
19
19
  canonicalizeOutputLanguage,
20
20
  DEFAULT_LANGUAGE,
@@ -23,6 +23,24 @@ import {
23
23
  observeSnapshot,
24
24
  unavailableObservation,
25
25
  } from "./observation.js";
26
+ import {
27
+ CONFIG_PATH,
28
+ GUIDANCE_ROOT,
29
+ IDEAS_ROOT,
30
+ } from "./layout.js";
31
+
32
+ function preloadCheckBlob({ name }) {
33
+ return (
34
+ name === CONFIG_PATH
35
+ || name === "package.json"
36
+ || name.startsWith(`${REPOSITORY_SKILL_PATH}/`)
37
+ || name.startsWith(`${GUIDANCE_ROOT}/`)
38
+ || (
39
+ name.startsWith(`${IDEAS_ROOT}/`)
40
+ && name.endsWith("/status.yaml")
41
+ )
42
+ );
43
+ }
26
44
 
27
45
  function targetArgument({ commit, remote, staged, worktree }) {
28
46
  if (remote) return { type: "remote" };
@@ -59,18 +77,23 @@ async function inspectTree({
59
77
  userHome,
60
78
  version,
61
79
  }) {
62
- return withTemporaryTree(gitRoot, tree, (contentRoot) =>
63
- observeSnapshot({
64
- contentRoot,
65
- gitRoot,
66
- outputLanguage,
67
- root,
68
- projectOnly: true,
69
- snapshotTree: tree,
70
- userHome,
71
- version,
72
- })
73
- );
80
+ const filesystem = createGitSnapshotFileSystem({
81
+ gitRoot,
82
+ preload: preloadCheckBlob,
83
+ root,
84
+ tree,
85
+ });
86
+ return observeSnapshot({
87
+ contentRoot: root,
88
+ filesystem,
89
+ gitRoot,
90
+ outputLanguage,
91
+ root,
92
+ projectOnly: true,
93
+ snapshotTree: tree,
94
+ userHome,
95
+ version,
96
+ });
74
97
  }
75
98
 
76
99
  function finishCheck(runtime, observed) {
@@ -133,7 +156,7 @@ export async function checkRepository({
133
156
  if (remote) {
134
157
  let head;
135
158
  try {
136
- head = resolveCommit(repositoryRoot, "HEAD");
159
+ head = resolveSnapshot(repositoryRoot, "HEAD");
137
160
  } catch (caught) {
138
161
  return failureReport({
139
162
  runtime,
@@ -148,11 +171,10 @@ export async function checkRepository({
148
171
  }
149
172
  let bootstrap;
150
173
  try {
151
- bootstrap = await withTemporaryWorktree(
152
- repositoryRoot,
153
- head,
154
- (contentRoot) => loadConfig({ root: contentRoot }),
155
- );
174
+ bootstrap = await loadConfigSnapshot({
175
+ gitRoot: repositoryRoot,
176
+ tree: head.tree,
177
+ });
156
178
  } catch (caught) {
157
179
  return failureReport({
158
180
  runtime,
@@ -205,20 +227,15 @@ export async function checkRepository({
205
227
  }
206
228
  const primary = fetched.result.commit;
207
229
  try {
208
- const observed = await withTemporaryWorktree(
209
- repositoryRoot,
210
- primary,
211
- (contentRoot, tree) => observeSnapshot({
212
- contentRoot,
213
- gitRoot: repositoryRoot,
214
- outputLanguage: canonicalLanguage,
215
- root: repositoryRoot,
216
- projectOnly: true,
217
- snapshotTree: tree,
218
- userHome,
219
- version: { type: "remote", commit: primary },
220
- }),
221
- );
230
+ const snapshot = resolveSnapshot(repositoryRoot, primary);
231
+ const observed = await inspectTree({
232
+ gitRoot: repositoryRoot,
233
+ outputLanguage: canonicalLanguage,
234
+ root: repositoryRoot,
235
+ tree: snapshot.tree,
236
+ userHome,
237
+ version: { type: "remote", commit: primary },
238
+ });
222
239
  return finishCheck(runtime, observed);
223
240
  } catch (caught) {
224
241
  return failureReport({
@@ -236,9 +253,9 @@ export async function checkRepository({
236
253
 
237
254
  if (commit !== undefined || (!staged && !worktree)) {
238
255
  const revision = commit ?? "HEAD";
239
- let resolvedCommit;
256
+ let snapshot;
240
257
  try {
241
- resolvedCommit = resolveCommit(repositoryRoot, revision);
258
+ snapshot = resolveSnapshot(repositoryRoot, revision);
242
259
  } catch (caught) {
243
260
  return failureReport({
244
261
  runtime,
@@ -252,20 +269,14 @@ export async function checkRepository({
252
269
  });
253
270
  }
254
271
  try {
255
- const observed = await withTemporaryWorktree(
256
- repositoryRoot,
257
- resolvedCommit,
258
- (contentRoot, tree) => observeSnapshot({
259
- contentRoot,
260
- gitRoot: repositoryRoot,
261
- outputLanguage: canonicalLanguage,
262
- root: repositoryRoot,
263
- projectOnly: true,
264
- snapshotTree: tree,
265
- userHome,
266
- version: { type: "commit", commit: resolvedCommit },
267
- }),
268
- );
272
+ const observed = await inspectTree({
273
+ gitRoot: repositoryRoot,
274
+ outputLanguage: canonicalLanguage,
275
+ root: repositoryRoot,
276
+ tree: snapshot.tree,
277
+ userHome,
278
+ version: { type: "commit", commit: snapshot.commit },
279
+ });
269
280
  return finishCheck(runtime, observed);
270
281
  } catch (caught) {
271
282
  return failureReport({
@@ -276,7 +287,7 @@ export async function checkRepository({
276
287
  summary: sanitizeGitMessage(caught.message),
277
288
  },
278
289
  root: repositoryRoot,
279
- version: { type: "commit", commit: resolvedCommit },
290
+ version: { type: "commit", commit: snapshot.commit },
280
291
  });
281
292
  }
282
293
  }
package/src/language.js CHANGED
@@ -40,6 +40,10 @@ export function canonicalizeOutputLanguage(value) {
40
40
  caught.exitCode = 2;
41
41
  throw caught;
42
42
  }
43
+ canonical = {
44
+ en: "en-US",
45
+ zh: "zh-CN",
46
+ }[canonical.toLowerCase()] ?? canonical;
43
47
  if (!OUTPUT_LANGUAGE_SET.has(canonical)) {
44
48
  const error = new Error(
45
49
  `unsupported output language: ${canonical}; expected ${OUTPUT_LANGUAGES.join(" or ")}`,