silvermoon 0.0.3 → 0.1.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.
package/src/whatsnext.js CHANGED
@@ -1,454 +1,605 @@
1
1
  import { resolve } from "node:path";
2
2
 
3
- import { inspectAdoption } from "./adoption.js";
4
- import { loadConfig } from "./config.js";
5
3
  import {
4
+ createEnvelope,
5
+ dialogueReadyObservation,
6
+ localize,
7
+ outcome,
8
+ } from "./dialogue.js";
9
+ import {
10
+ compareCommits,
6
11
  fetchPrimary,
7
- parseWorktreeChanges,
8
- runGit,
9
- withTemporaryWorktree,
12
+ inspectCurrentBranch,
13
+ inspectWorktreeChanges,
14
+ resolveHead,
15
+ sanitizeGitMessage,
10
16
  } from "./git.js";
11
- import { inspectIdeaLayout } from "./idea-layout.js";
12
- import { resolveLanguage } from "./language.js";
13
- import { loadUserConfig } from "./user-config.js";
17
+ import {
18
+ observeSnapshot,
19
+ repositoryProblemObservation,
20
+ withObservationLanguage,
21
+ } from "./observation.js";
22
+ import { traceAsync } from "./trace.js";
14
23
 
15
- const ACTIVE_STATES = new Set(["preparing", "implementing", "deploying"]);
24
+ export const CHANGE_SAMPLE_ITEM_LIMIT = 12;
25
+ export const CHANGE_SAMPLE_BYTE_LIMIT = 768;
16
26
 
17
- function diagnostic(code, message, remediation, path) {
18
- return { code, level: "error", ...(path ? { path } : {}), message, remediation };
27
+ function ideaName(idea) {
28
+ return idea.alias ?? idea.id;
19
29
  }
20
30
 
21
- function failed(root, diagnostics, request, observedPrimaryCommit = null) {
22
- return {
23
- command: "whats-next",
24
- ok: false,
25
- root,
26
- diagnostics,
27
- result: request
28
- ? { observedPrimaryCommit, request, selectedIdea: null, action: null }
29
- : null,
30
- };
31
+ function command(root, value) {
32
+ return `\`${value.replace("<root>", root)}\``;
31
33
  }
32
34
 
33
- function ideaName(idea) {
34
- return idea.alias ?? idea.id;
35
+ function joinInstructions(lines) {
36
+ return lines.filter(Boolean).join("\n");
35
37
  }
36
38
 
37
- function summary(idea) {
38
- const value = {
39
- id: idea.id,
40
- idealRevision: idea.idealRevision,
41
- implementationRevision: idea.implementationRevision,
42
- deploymentRevision: idea.deploymentRevision,
43
- ledgerPath: idea.ledgerPath,
44
- state: idea.state,
45
- };
46
- if (idea.alias !== undefined) value.alias = idea.alias;
47
- return value;
39
+ function formatInstructionSteps(steps) {
40
+ return steps.length === 1
41
+ ? steps
42
+ : steps.map((step, index) => `${index + 1}. ${step}`);
48
43
  }
49
44
 
50
- function action(code, message, details = {}) {
51
- return { code, message, details };
45
+ export function projectInstructions(
46
+ observed,
47
+ root,
48
+ language,
49
+ recheckCommand = "silvermoon whats-next",
50
+ ) {
51
+ return joinInstructions([
52
+ ...formatInstructionSteps(observed.findings.map(({ instruction }) => instruction)),
53
+ localize(
54
+ language,
55
+ `After completing the applicable steps, run ${command(root, recheckCommand)} again.`,
56
+ `完成适用步骤后,再运行 ${command(root, recheckCommand)}。`,
57
+ ),
58
+ ]);
52
59
  }
53
60
 
54
- function actionWithLanguage(nextAction, language) {
61
+ function sampleChanges(changes) {
62
+ const entries = [
63
+ ...changes.conflicted.map(({ path }) => `conflicted:${path}`),
64
+ ...changes.staged.map(({ path }) => `staged:${path}`),
65
+ ...changes.unstaged.map(({ path }) => `unstaged:${path}`),
66
+ ...changes.untracked.map(({ path }) => `untracked:${path}`),
67
+ ];
68
+ const samples = [];
69
+ let bytes = 0;
70
+ for (const entry of entries) {
71
+ if (samples.length >= CHANGE_SAMPLE_ITEM_LIMIT) break;
72
+ const nextBytes = Buffer.byteLength(
73
+ samples.length === 0 ? entry : `, ${entry}`,
74
+ "utf8",
75
+ );
76
+ if (bytes + nextBytes > CHANGE_SAMPLE_BYTE_LIMIT) break;
77
+ samples.push(entry);
78
+ bytes += nextBytes;
79
+ }
55
80
  return {
56
- ...nextAction,
57
- message: `${nextAction.message} Use ${language.tag} for natural-language content and user-facing explanations.`,
81
+ counts: {
82
+ conflicted: changes.conflicted.length,
83
+ staged: changes.staged.length,
84
+ unstaged: changes.unstaged.length,
85
+ untracked: changes.untracked.length,
86
+ },
87
+ omitted: entries.length - samples.length,
88
+ samples,
58
89
  };
59
90
  }
60
91
 
61
- function onboardingGaps(onboarding) {
62
- if (onboarding.findings.length === 0) return null;
63
- return {
64
- status: onboarding.status,
65
- gaps: onboarding.findings,
66
- recommendedAction: onboarding.recommendedAction,
67
- recheck: onboarding.recheck,
92
+ export function summarizeWorktreeChanges(changes, language = "en-US") {
93
+ const summary = sampleChanges(changes);
94
+ const labels = {
95
+ conflicted: localize(language, "conflicted", "冲突"),
96
+ staged: localize(language, "staged", "已暂存"),
97
+ unstaged: localize(language, "unstaged", "未暂存"),
98
+ untracked: localize(language, "untracked", "未跟踪"),
68
99
  };
100
+ const counts = Object.entries(summary.counts)
101
+ .map(([kind, count]) => `${labels[kind]}=${count}`)
102
+ .join(localize(language, ", ", ","));
103
+ const samples = summary.samples.length === 0
104
+ ? localize(language, "none", "无")
105
+ : summary.samples.join(localize(language, ", ", ","));
106
+ return localize(
107
+ language,
108
+ `${counts}; samples=[${samples}]; omitted=${summary.omitted}`,
109
+ `${counts};样例=[${samples}];省略=${summary.omitted}`,
110
+ );
69
111
  }
70
112
 
71
- function success(root, observedPrimaryCommit, request, selectedIdea, nextAction, language) {
72
- return {
73
- command: "whats-next",
74
- ok: true,
75
- root,
76
- diagnostics: [],
77
- result: {
78
- observedPrimaryCommit,
79
- request,
113
+ function worktreeInstructionSteps(changes, language) {
114
+ const lines = [];
115
+ if (changes.conflicted.length > 0) {
116
+ lines.push(localize(
80
117
  language,
81
- selectedIdea: selectedIdea ? summary(selectedIdea) : null,
82
- action: actionWithLanguage(nextAction, language),
83
- },
84
- };
118
+ "Inspect every conflicted path and its contents, then resolve the conflicts without discarding either side.",
119
+ "检查全部冲突路径及其内容,再在不丢弃任一方内容的前提下解决冲突。",
120
+ ));
121
+ }
122
+ const hasOrdinaryChanges =
123
+ changes.staged.length > 0
124
+ || changes.unstaged.length > 0
125
+ || changes.untracked.length > 0;
126
+ if (hasOrdinaryChanges) {
127
+ lines.push(localize(
128
+ language,
129
+ "Inspect all staged, unstaged, and untracked paths and their changes, not just the samples above. Preserve unknown work, then commit, isolate, or explicitly discard each change.",
130
+ "检查全部 staged、unstaged 和 untracked 路径及其修改内容,不要只依据上述样例。保留未知工作,再逐项提交、隔离,或在获得明确授权后放弃。",
131
+ ));
132
+ }
133
+ return lines;
85
134
  }
86
135
 
87
- export function stateAction(idea) {
88
- if (idea.state === "abandoned") {
89
- return action(
90
- "review-abandoned",
91
- `Review abandoned idea ${ideaName(idea)} and decide whether to keep it abandoned or revise it.`,
92
- { statusPath: idea.statusPath, ledgerPath: idea.ledgerPath },
93
- );
94
- }
136
+ function localRepositoryInstructions(root, steps, language, recheckCommand) {
137
+ return joinInstructions([
138
+ ...formatInstructionSteps(steps),
139
+ localize(
140
+ language,
141
+ `After completing every applicable step, run ${command(root, recheckCommand)} again.`,
142
+ `完成全部适用步骤后,再运行 ${command(root, recheckCommand)}。`,
143
+ ),
144
+ ]);
145
+ }
146
+
147
+ function selectIdea(ideas, selector) {
148
+ if (selector === undefined) return null;
149
+ return ideas.find(({ id }) => id === selector)
150
+ ?? ideas.find(({ alias }) => alias === selector)
151
+ ?? null;
152
+ }
153
+
154
+ function lifecycleInstruction(idea, language) {
155
+ const name = ideaName(idea);
95
156
  if (idea.state === "preparing") {
96
- const world = idea.worlds.idealRevision;
97
- return action(
98
- "prepare-idea",
99
- `Clarify the Ideal World (道心) for idea ${ideaName(idea)} in Idea.md and its supporting files, then record approval for ${idea.idealRevision}.`,
100
- {
101
- ideaPath: idea.relativePath,
102
- statusPath: idea.statusPath,
103
- ledgerPath: idea.ledgerPath,
104
- world: {
105
- ...world,
106
- auxiliaryRoot: world.path,
107
- revision: idea.idealRevision,
108
- decisionField: "approvedRevision",
109
- cascade: "Changes invalidate approval, implementation acceptance, and deployment acceptance.",
110
- },
111
- },
157
+ return localize(
158
+ language,
159
+ `Continue idea ${name} in ${idea.worlds.idealRevision.documentPath} and ${idea.ledgerPath}. Preserve the other worlds. When the Ideal World is ready, ask the user to approve exact revision ${idea.idealRevision}; only after explicit approval write it to approvedRevision in ${idea.statusPath}.`,
160
+ `继续在 ${idea.worlds.idealRevision.documentPath} 和 ${idea.ledgerPath} 推进 idea ${name},并保留其他世界。理想契约就绪后,请用户明确批准精确 revision ${idea.idealRevision};只有获得明确批准后,才将其写入 ${idea.statusPath} 的 approvedRevision。`,
112
161
  );
113
162
  }
114
163
  if (idea.state === "implementing") {
115
- const world = idea.worlds.implementationRevision;
116
- return action(
117
- "implement-idea",
118
- `Shape the Inner World (内景) for idea ${ideaName(idea)} in Implementation.md and its supporting files, then accept implementation ${idea.implementationRevision}.`,
119
- {
120
- ideaPath: idea.relativePath,
121
- statusPath: idea.statusPath,
122
- ledgerPath: idea.ledgerPath,
123
- world: {
124
- ...world,
125
- auxiliaryRoot: world.path,
126
- nestedWorldPath: idea.worlds.idealRevision.path,
127
- revision: idea.implementationRevision,
128
- decisionField: "implementationAcceptedRevision",
129
- cascade: "Inner World changes invalidate implementation and deployment acceptance; Ideal World changes return to preparing.",
130
- },
131
- },
164
+ return localize(
165
+ language,
166
+ `Continue idea ${name} in ${idea.worlds.implementationRevision.documentPath}, its supporting files under ${idea.worlds.implementationRevision.path}, and ${idea.ledgerPath}. Do not change ${idea.worlds.idealRevision.path} unless the Ideal World must change. After all implementation evidence is published, ask the user to accept exact revision ${idea.implementationRevision}; only then write it to implementationAcceptedRevision in ${idea.statusPath}.`,
167
+ `继续在 ${idea.worlds.implementationRevision.documentPath}、${idea.worlds.implementationRevision.path} 下的辅助文件和 ${idea.ledgerPath} 推进 idea ${name}。除非理想契约确实需要变化,否则不要修改 ${idea.worlds.idealRevision.path}。全部实现证据发布后,请用户明确验收精确 revision ${idea.implementationRevision};只有获得明确验收后,才将其写入 ${idea.statusPath} 的 implementationAcceptedRevision。`,
132
168
  );
133
169
  }
134
170
  if (idea.state === "deploying") {
135
- const world = idea.worlds.deploymentRevision;
136
- return action(
137
- "deploy-idea",
138
- `Verify the Outer World (现世) for idea ${ideaName(idea)} from Deployment.md and its supporting files, then accept deployment ${idea.deploymentRevision}.`,
139
- {
140
- ideaPath: idea.relativePath,
141
- statusPath: idea.statusPath,
142
- ledgerPath: idea.ledgerPath,
143
- world: {
144
- ...world,
145
- auxiliaryRoot: world.path,
146
- nestedWorldPath: idea.worlds.implementationRevision.path,
147
- revision: idea.deploymentRevision,
148
- decisionField: "deploymentAcceptedRevision",
149
- cascade: "Outer World changes invalidate deployment acceptance only; nested world changes cascade inward.",
150
- },
151
- },
171
+ return localize(
172
+ language,
173
+ `Continue idea ${name} from ${idea.worlds.deploymentRevision.documentPath}, its supporting files under ${idea.worlds.deploymentRevision.path}, and ${idea.ledgerPath}. Preserve nested worlds. After external evidence is complete and published, ask the user to accept exact revision ${idea.deploymentRevision}; only then write it to deploymentAcceptedRevision in ${idea.statusPath}.`,
174
+ `从 ${idea.worlds.deploymentRevision.documentPath}、${idea.worlds.deploymentRevision.path} 下的辅助文件和 ${idea.ledgerPath} 继续推进 idea ${name},并保留内层世界。外部证据完成且发布后,请用户明确验收精确 revision ${idea.deploymentRevision};只有获得明确验收后,才将其写入 ${idea.statusPath} 的 deploymentAcceptedRevision。`,
152
175
  );
153
176
  }
154
- return action(
155
- "review-completed",
156
- `Review completed idea ${ideaName(idea)} and decide whether to revise it or create a new idea.`,
157
- {
158
- ideaPath: idea.relativePath,
159
- statusPath: idea.statusPath,
160
- ledgerPath: idea.ledgerPath,
161
- revisions: idea.revisions,
162
- },
177
+ if (idea.state === "abandoned") {
178
+ return localize(
179
+ language,
180
+ `Review abandoned idea ${name} at ${idea.relativePath}. Keep abandoned: true in ${idea.statusPath}, remove it only after an explicit decision to resume, or discuss a different goal and run silvermoon create-idea.`,
181
+ `复查位于 ${idea.relativePath} 的已放弃 idea ${name}。保持 ${idea.statusPath} 中的 abandoned: true;只有明确决定恢复时才移除它,或者讨论另一个目标并运行 silvermoon create-idea。`,
182
+ );
183
+ }
184
+ return localize(
185
+ language,
186
+ `Review completed idea ${name} at ${idea.relativePath}. If its definition must change, revise the existing idea; otherwise discuss a new goal and run silvermoon create-idea.`,
187
+ `复查位于 ${idea.relativePath} 的已完成 idea ${name}。若其定义需要变化则修订现有 idea;否则讨论新目标并运行 silvermoon create-idea。`,
163
188
  );
164
189
  }
165
190
 
166
- function selectIdea(ideas, selector) {
167
- if (!selector) return null;
168
- return ideas.find(({ id, alias }) => id === selector || alias === selector) ?? null;
191
+ function navigationInstruction(ideas, language) {
192
+ const hasActiveIdea = ideas.some(({ state }) =>
193
+ state === "preparing" || state === "implementing" || state === "deploying"
194
+ );
195
+ return joinInstructions([
196
+ localize(
197
+ language,
198
+ "Choose explicitly whether to continue an active idea or create a new one.",
199
+ "请明确选择继续一个 active idea,或创建一个新 idea。",
200
+ ),
201
+ hasActiveIdea
202
+ ? null
203
+ : localize(language, "No active ideas are available.", "当前没有 active idea。"),
204
+ localize(
205
+ language,
206
+ "To continue, run `silvermoon whats-next <ULID-or-alias>`. To start something else, discuss the goal and run `silvermoon create-idea`.",
207
+ "如需继续,运行 `silvermoon whats-next <ULID-or-alias>`;如需开始其他工作,先讨论目标,再运行 `silvermoon create-idea`。",
208
+ ),
209
+ ]);
169
210
  }
170
211
 
171
- function worktreeAction(root, config, observedPrimaryCommit) {
172
- const symbolic = runGit(root, ["symbolic-ref", "--quiet", "HEAD"]);
173
- const expectedBranch = `refs/heads/${config.primaryBranch}`;
174
- if (!symbolic.ok || symbolic.stdout !== expectedBranch) {
175
- return action(
176
- "switch-to-primary",
177
- `Preserve current work, then switch to configured primary branch ${config.primaryBranch}.`,
178
- { actual: symbolic.ok ? symbolic.stdout : null, expected: expectedBranch },
179
- );
180
- }
181
-
182
- const status = runGit(root, ["status", "--porcelain=v2", "-z", "--untracked-files=all"]);
183
- if (!status.ok) {
184
- return action(
185
- "inspect-worktree-changes",
186
- "Worktree changes could not be inspected; repair Git state before continuing.",
187
- { error: status.stderr },
188
- );
189
- }
212
+ async function assessRepositoryReadinessInternal({
213
+ observed,
214
+ outcomes,
215
+ requirePrimaryBranch = false,
216
+ recheckCommand = "silvermoon whats-next",
217
+ root,
218
+ synchronizePrimary = true,
219
+ }) {
220
+ const language = observed.language;
190
221
  let changes;
191
222
  try {
192
- changes = parseWorktreeChanges(status.stdout);
223
+ changes = inspectWorktreeChanges(root);
193
224
  } catch (caught) {
194
- return action(
195
- "inspect-worktree-changes",
196
- "Worktree changes could not be parsed; inspect Git state before continuing.",
197
- { error: caught.message },
198
- );
199
- }
200
- if (changes.conflicted.length > 0) {
201
- return action(
202
- "resolve-conflicts",
203
- "Resolve worktree conflicts without discarding either side, then run whats-next again.",
204
- changes,
205
- );
225
+ const problem = {
226
+ type: "worktree-inspection-failed",
227
+ summary: sanitizeGitMessage(caught.message),
228
+ };
229
+ return {
230
+ observation: repositoryProblemObservation(observed.observation, [problem]),
231
+ instructions: localize(
232
+ language,
233
+ `Repair the local Git state, verify that all changes can be inspected, then run ${command(root, recheckCommand)} again.`,
234
+ `修复本地 Git 状态,确认可检查全部修改后,再运行 ${command(root, recheckCommand)}。`,
235
+ ),
236
+ ready: false,
237
+ };
206
238
  }
207
- if (changes.staged.length > 0 || changes.unstaged.length > 0 || changes.untracked.length > 0) {
208
- return action(
209
- "inspect-worktree-changes",
210
- "Inspect the exact worktree diff; preserve unknown work and commit, isolate, or explicitly handle each path.",
211
- changes,
212
- );
239
+
240
+ const dirty =
241
+ changes.conflicted.length > 0
242
+ || changes.staged.length > 0
243
+ || changes.unstaged.length > 0
244
+ || changes.untracked.length > 0;
245
+ const localProblems = [];
246
+ const localSteps = [];
247
+ if (dirty) {
248
+ if (changes.conflicted.length > 0) {
249
+ localProblems.push({
250
+ type: "worktree-conflicts",
251
+ summary: localize(
252
+ language,
253
+ `${changes.conflicted.length} conflicted path(s); ${summarizeWorktreeChanges({
254
+ conflicted: changes.conflicted,
255
+ staged: [],
256
+ unstaged: [],
257
+ untracked: [],
258
+ }, language)}`,
259
+ `${changes.conflicted.length} 个冲突路径;${summarizeWorktreeChanges({
260
+ conflicted: changes.conflicted,
261
+ staged: [],
262
+ unstaged: [],
263
+ untracked: [],
264
+ }, language)}`,
265
+ ),
266
+ });
267
+ }
268
+ if (
269
+ changes.staged.length > 0
270
+ || changes.unstaged.length > 0
271
+ || changes.untracked.length > 0
272
+ ) {
273
+ localProblems.push({
274
+ type: "worktree-changes",
275
+ summary: summarizeWorktreeChanges({
276
+ conflicted: [],
277
+ staged: changes.staged,
278
+ unstaged: changes.unstaged,
279
+ untracked: changes.untracked,
280
+ }, language),
281
+ });
282
+ }
283
+ localSteps.push(...worktreeInstructionSteps(changes, language));
213
284
  }
214
285
 
215
- const local = runGit(root, ["rev-parse", "HEAD"]);
216
- if (!local.ok) {
217
- return action(
218
- "integrate-primary",
219
- "Local HEAD is unavailable; repair the primary checkout before continuing.",
220
- { error: local.stderr },
221
- );
286
+ const head = resolveHead(root);
287
+ if (head === null) {
288
+ localProblems.push({
289
+ type: "head-missing",
290
+ summary: localize(
291
+ language,
292
+ "The repository has no commit at HEAD.",
293
+ "repository 的 HEAD 尚无 commit。",
294
+ ),
295
+ });
296
+ localSteps.push(localize(
297
+ language,
298
+ "After resolving conflicts and deciding which local changes belong, create the initial commit on the intended branch.",
299
+ "解决冲突并确认应保留的本地修改后,在预期分支创建初始 commit。",
300
+ ));
222
301
  }
223
- if (local.stdout === observedPrimaryCommit) return null;
224
- if (runGit(root, ["merge-base", "--is-ancestor", local.stdout, observedPrimaryCommit]).ok) {
225
- return action(
226
- "fast-forward-primary",
227
- `Fast-forward local ${config.primaryBranch} to observed primary ${observedPrimaryCommit}.`,
228
- { from: local.stdout, to: observedPrimaryCommit },
229
- );
302
+
303
+ const branch = inspectCurrentBranch(root);
304
+ if (branch.branch === null) {
305
+ localProblems.push({
306
+ type: "detached-head",
307
+ summary: localize(
308
+ language,
309
+ head === null
310
+ ? "The repository has no current local branch."
311
+ : `HEAD ${head} is detached and has no current branch.`,
312
+ head === null
313
+ ? "repository 当前没有本地分支。"
314
+ : `HEAD ${head} 处于 detached 状态,没有当前分支。`,
315
+ ),
316
+ });
317
+ localSteps.push(localize(
318
+ language,
319
+ `Preserve current work, then switch to or create the intended local branch whose upstream is ${observed.config.primaryRepository}#${observed.config.primaryBranch}.`,
320
+ `保留当前工作,然后切换或创建 upstream 为 ${observed.config.primaryRepository}#${observed.config.primaryBranch} 的预期本地分支。`,
321
+ ));
322
+ } else {
323
+ if (requirePrimaryBranch && branch.branch !== observed.config.primaryBranch) {
324
+ localProblems.push({
325
+ type: "primary-branch-mismatch",
326
+ summary: localize(
327
+ language,
328
+ `Current branch is ${branch.branch}; expected configured primary branch ${observed.config.primaryBranch}.`,
329
+ `当前分支为 ${branch.branch};预期为 configured primary branch ${observed.config.primaryBranch}。`,
330
+ ),
331
+ });
332
+ localSteps.push(localize(
333
+ language,
334
+ `Preserve current work, then switch to configured primary branch ${observed.config.primaryBranch}.`,
335
+ `保留当前工作,然后切换到 configured primary branch ${observed.config.primaryBranch}。`,
336
+ ));
337
+ }
338
+ if (
339
+ branch.repository !== observed.config.primaryRepository
340
+ || branch.upstreamBranch !== observed.config.primaryBranch
341
+ ) {
342
+ const actual = branch.remote === null
343
+ ? localize(language, "none", "无")
344
+ : `${branch.repository ?? branch.remote}#${branch.upstreamBranch ?? localize(language, "unknown", "未知")}`;
345
+ localProblems.push({
346
+ type: "primary-upstream-mismatch",
347
+ summary: localize(
348
+ language,
349
+ `Branch ${branch.branch} has upstream ${actual}; expected ${observed.config.primaryRepository}#${observed.config.primaryBranch}.`,
350
+ `分支 ${branch.branch} 的 upstream 是 ${actual};预期为 ${observed.config.primaryRepository}#${observed.config.primaryBranch}。`,
351
+ ),
352
+ });
353
+ localSteps.push(localize(
354
+ language,
355
+ `Configure a named remote for ${observed.config.primaryRepository}, then set branch ${branch.branch} to track that remote's ${observed.config.primaryBranch} branch. Verify with ${command(root, 'git -C "<root>" rev-parse --abbrev-ref --symbolic-full-name \'@{upstream}\'')}.`,
356
+ `为 ${observed.config.primaryRepository} 配置 named remote,再将分支 ${branch.branch} 的 upstream 设为该 remote 的 ${observed.config.primaryBranch}。使用 ${command(root, 'git -C "<root>" rev-parse --abbrev-ref --symbolic-full-name \'@{upstream}\'')} 验证。`,
357
+ ));
358
+ }
230
359
  }
231
- if (runGit(root, ["merge-base", "--is-ancestor", observedPrimaryCommit, local.stdout]).ok) {
232
- return action(
233
- "publish-primary",
234
- `Validate and publish local primary ${local.stdout} with expected remote tip ${observedPrimaryCommit}.`,
235
- {
236
- repository: config.primaryRepository,
237
- branch: config.primaryBranch,
238
- commit: local.stdout,
239
- expectedRemoteTip: observedPrimaryCommit,
240
- validation: { target: "commit", revision: local.stdout },
241
- },
242
- );
360
+
361
+ if (localProblems.length > 0) {
362
+ return {
363
+ branch,
364
+ changes,
365
+ head,
366
+ observation: repositoryProblemObservation(
367
+ observed.observation,
368
+ localProblems,
369
+ ),
370
+ instructions: localRepositoryInstructions(
371
+ root,
372
+ localSteps,
373
+ language,
374
+ recheckCommand,
375
+ ),
376
+ ready: false,
377
+ };
243
378
  }
244
- return action(
245
- "integrate-primary",
246
- "Preserve both local and remote primary histories and integrate them without force-pushing.",
247
- { local: local.stdout, remote: observedPrimaryCommit },
248
- );
249
- }
250
379
 
251
- async function lifecycleWhatsNext({
252
- create = false,
253
- idea: selector,
254
- root = process.cwd(),
255
- userHome,
256
- } = {}) {
257
- const repositoryRoot = resolve(root);
258
- const request = create
259
- ? { kind: "create-idea" }
260
- : selector === undefined
261
- ? { kind: "navigate" }
262
- : { kind: "select-idea", selector };
263
- let local;
264
- try {
265
- local = await withTemporaryWorktree(repositoryRoot, "HEAD", (worktree) =>
266
- loadConfig({ root: worktree }),
267
- );
268
- } catch (caught) {
269
- return failed(repositoryRoot, [diagnostic(
270
- "head.observation-failed",
271
- caught.message,
272
- "Restore a valid committed HEAD snapshot and retry.",
273
- )], request);
380
+ if (!synchronizePrimary) {
381
+ return {
382
+ branch,
383
+ changes,
384
+ head,
385
+ observation: observed.observation,
386
+ ready: true,
387
+ };
274
388
  }
275
- if (!local.config) return failed(repositoryRoot, local.diagnostics, request);
276
389
 
277
- let observedPrimaryCommit;
390
+ let primary;
278
391
  try {
279
- observedPrimaryCommit = fetchPrimary(repositoryRoot, local.config);
392
+ primary = fetchPrimary(root, observed.config);
393
+ outcomes.push(outcome(
394
+ "fetch-primary",
395
+ "success",
396
+ localize(
397
+ language,
398
+ `Fetched ${observed.config.primaryRepository}#${observed.config.primaryBranch} at ${primary} without moving the worktree, index, branch, or named refs.`,
399
+ `已 fetch ${observed.config.primaryRepository}#${observed.config.primaryBranch} 的 ${primary},未移动 worktree、index、branch 或 named refs。`,
400
+ ),
401
+ ));
280
402
  } catch (caught) {
281
- return failed(repositoryRoot, [diagnostic(
282
- "primary.refresh-failed",
283
- caught.message,
284
- "Check network, authorization, repository URL, and primary branch, then retry.",
285
- )], request);
403
+ const message = sanitizeGitMessage(caught.message);
404
+ outcomes.push(outcome(
405
+ "fetch-primary",
406
+ "failure",
407
+ localize(
408
+ language,
409
+ `Could not fetch configured primary: ${message}`,
410
+ `无法 fetch configured primary:${message}`,
411
+ ),
412
+ ));
413
+ return {
414
+ observation: repositoryProblemObservation(observed.observation, [{
415
+ type: "primary-fetch-failed",
416
+ summary: message,
417
+ }]),
418
+ instructions: localize(
419
+ language,
420
+ `Check network access, authorization, ${observed.config.primaryRepository}, and branch ${observed.config.primaryBranch}; after an observable fix, run ${command(root, recheckCommand)} again.`,
421
+ `检查网络、授权、${observed.config.primaryRepository} 和分支 ${observed.config.primaryBranch};产生可观察修复后,再运行 ${command(root, recheckCommand)}。`,
422
+ ),
423
+ ready: false,
424
+ };
286
425
  }
287
426
 
288
- let observed;
427
+ let relation;
289
428
  try {
290
- observed = await withTemporaryWorktree(
291
- repositoryRoot,
292
- observedPrimaryCommit,
293
- async (worktree, tree) => {
294
- const loaded = await loadConfig({ root: worktree });
295
- if (!loaded.config) return { config: null, diagnostics: loaded.diagnostics, ideas: [] };
296
- const layout = await inspectIdeaLayout({
297
- config: loaded.config,
298
- gitRoot: repositoryRoot,
299
- historyCommit: observedPrimaryCommit,
300
- root: worktree,
301
- snapshotTree: tree,
302
- });
303
- return { config: loaded.config, ...layout };
304
- },
305
- );
429
+ relation = compareCommits(root, head, primary);
306
430
  } catch (caught) {
307
- return failed(repositoryRoot, [diagnostic(
308
- "primary.observation-failed",
309
- caught.message,
310
- "Repair the fetched primary snapshot and retry.",
311
- )], request, observedPrimaryCommit);
312
- }
313
- const observationDiagnostics = create
314
- ? observed.diagnostics.filter(({ code }) => code !== "layout.ideas.missing")
315
- : observed.diagnostics;
316
- if (observationDiagnostics.length > 0) {
317
- return failed(repositoryRoot, observationDiagnostics, request, observedPrimaryCommit);
431
+ return {
432
+ branch,
433
+ head,
434
+ observation: repositoryProblemObservation(observed.observation, [{
435
+ type: "primary-ancestry-inspection-failed",
436
+ summary: sanitizeGitMessage(caught.message),
437
+ }]),
438
+ primary,
439
+ instructions: localize(
440
+ language,
441
+ `Repair or deepen local Git history until ${head} and ${primary} can be compared, then run ${command(root, recheckCommand)} again.`,
442
+ `修复或补全本地 Git 历史,直到可以比较 ${head} 和 ${primary},然后再运行 ${command(root, recheckCommand)}。`,
443
+ ),
444
+ ready: false,
445
+ };
318
446
  }
319
-
320
- const loadedUserConfig = await loadUserConfig({ home: userHome });
321
- if (loadedUserConfig.diagnostics.length > 0) {
322
- return failed(
323
- repositoryRoot,
324
- loadedUserConfig.diagnostics,
325
- request,
326
- observedPrimaryCommit,
327
- );
447
+ if (relation === "aligned") {
448
+ return { branch, head, observation: observed.observation, primary, ready: true };
328
449
  }
329
- const requestedIdea = selectIdea(observed.ideas, selector);
330
- const repositoryLanguage = {
331
- project: observed.config.preferredLanguage,
332
- global: loadedUserConfig.config?.preferredLanguage,
450
+ const problem = {
451
+ type: relation === "unknown"
452
+ ? "primary-history-incomplete"
453
+ : `primary-${relation}`,
454
+ summary: localize(
455
+ language,
456
+ `Local HEAD is ${head}; observed primary is ${primary}; relationship is ${relation}.`,
457
+ `本地 HEAD 为 ${head};观测到的 primary 为 ${primary};两者关系为 ${relation}。`,
458
+ ),
333
459
  };
334
- const languageFor = (idea) => resolveLanguage({
335
- idea: idea?.status.language,
336
- ...repositoryLanguage,
337
- });
338
-
339
- const hygiene = worktreeAction(repositoryRoot, local.config, observedPrimaryCommit);
340
- if (hygiene) {
341
- return success(
342
- repositoryRoot,
343
- observedPrimaryCommit,
344
- request,
345
- null,
346
- hygiene,
347
- languageFor(requestedIdea),
460
+ let instructions;
461
+ if (relation === "unknown") {
462
+ instructions = localize(
463
+ language,
464
+ `Deepen the shallow history from remote ${branch.remote} until ${head} and ${primary} can be compared, then run ${command(root, recheckCommand)} again.`,
465
+ `从 remote ${branch.remote} 补全 shallow history,直到可以比较 ${head} 和 ${primary},然后再运行 ${command(root, recheckCommand)}。`,
466
+ );
467
+ } else if (relation === "behind") {
468
+ instructions = localize(
469
+ language,
470
+ `Fast-forward branch ${branch.branch} to observed primary ${primary} with ${command(root, `git -C "<root>" merge --ff-only ${primary}`)} without rewriting history, then run ${command(root, recheckCommand)} again.`,
471
+ `使用 ${command(root, `git -C "<root>" merge --ff-only ${primary}`)} 将分支 ${branch.branch} fast-forward 到已观察 primary ${primary},不要改写历史,然后再运行 ${command(root, recheckCommand)}。`,
472
+ );
473
+ } else if (relation === "ahead") {
474
+ instructions = localize(
475
+ language,
476
+ `Validate local commit ${head}, confirm the remote tip is still ${primary}, then push normally to ${branch.remote}/${observed.config.primaryBranch} without force. Re-run ${command(root, recheckCommand)} after the push.`,
477
+ `验证本地 commit ${head},确认 remote tip 仍为 ${primary},再普通 push 到 ${branch.remote}/${observed.config.primaryBranch},不要 force。push 后重新运行 ${command(root, recheckCommand)}。`,
478
+ );
479
+ } else {
480
+ instructions = localize(
481
+ language,
482
+ `Preserve local ${head} and remote ${primary}, integrate both histories without force-pushing, resolve and validate the result, then run ${command(root, recheckCommand)} again.`,
483
+ `保留本地 ${head} 与 remote ${primary},在不 force-push 的前提下整合两边历史,解决并验证结果,然后再运行 ${command(root, recheckCommand)}。`,
348
484
  );
349
485
  }
486
+ return {
487
+ branch,
488
+ head,
489
+ observation: repositoryProblemObservation(observed.observation, [problem]),
490
+ primary,
491
+ instructions,
492
+ ready: false,
493
+ };
494
+ }
350
495
 
351
- if (create) {
352
- return success(repositoryRoot, observedPrimaryCommit, request, null, action(
353
- "create-idea",
354
- "Create one new three-world idea scaffold in .silvermoon/ideas/.",
355
- ), languageFor());
356
- }
357
-
358
- if (!selector) {
359
- const active = observed.ideas.filter(({ state }) => ACTIVE_STATES.has(state));
360
- if (active.length > 1) {
361
- return success(repositoryRoot, observedPrimaryCommit, request, null, action(
362
- "select-active-idea",
363
- "Select one active idea by ULID or unique alias.",
364
- { ideas: active.map(summary) },
365
- ), languageFor());
366
- }
367
- if (active.length === 1) {
368
- return success(repositoryRoot, observedPrimaryCommit, request, active[0], action(
369
- "continue-active-idea",
370
- `Continue active idea ${ideaName(active[0])}.`,
371
- { idea: summary(active[0]) },
372
- ), languageFor(active[0]));
373
- }
374
- return success(repositoryRoot, observedPrimaryCommit, request, null, action(
375
- "create-idea",
376
- "Discuss the next goal and create one self-contained three-world idea.",
377
- ), languageFor());
378
- }
379
-
380
- const selected = requestedIdea;
381
- if (!selected) {
382
- return failed(repositoryRoot, [diagnostic(
383
- "idea.not-found",
384
- `Idea ${selector} does not exist in observed primary ${observedPrimaryCommit}.`,
385
- "Choose a ULID or exact alias reported by silvermoon whats-next.",
386
- selector,
387
- )], request, observedPrimaryCommit);
388
- }
496
+ export async function assessRepositoryReadiness(options) {
497
+ return traceAsync(
498
+ "repository.assess-readiness",
499
+ {},
500
+ () => assessRepositoryReadinessInternal(options),
501
+ );
502
+ }
389
503
 
390
- return success(
391
- repositoryRoot,
392
- observedPrimaryCommit,
393
- request,
394
- selected,
395
- stateAction(selected),
396
- languageFor(selected),
504
+ export async function assessIdeaCreationReadiness(options) {
505
+ return traceAsync(
506
+ "repository.assess-creation-readiness",
507
+ {},
508
+ () => assessRepositoryReadinessInternal({
509
+ ...options,
510
+ requirePrimaryBranch: true,
511
+ synchronizePrimary: false,
512
+ }),
397
513
  );
398
514
  }
399
515
 
400
516
  export async function whatsNext({
401
- create = false,
402
- enforceOnboarding = false,
403
517
  idea: selector,
404
518
  root = process.cwd(),
405
519
  userHome,
406
520
  } = {}) {
407
- const repositoryRoot = resolve(root);
408
- const onboarding = await inspectAdoption({ root: repositoryRoot, selector });
409
- const gapReport = onboardingGaps(onboarding);
410
- const request = create
411
- ? { kind: "create-idea" }
412
- : selector === undefined
413
- ? { kind: "navigate" }
414
- : { kind: "select-idea", selector };
415
- if (enforceOnboarding && onboarding.status !== "ready") {
416
- const loadedUserConfig = await loadUserConfig({ home: userHome });
417
- if (loadedUserConfig.diagnostics.length > 0) {
418
- return failed(repositoryRoot, loadedUserConfig.diagnostics, request);
419
- }
420
- const language = resolveLanguage({
421
- global: loadedUserConfig.config?.preferredLanguage,
422
- });
423
- return {
424
- command: "whats-next",
425
- ok: true,
426
- root: repositoryRoot,
427
- diagnostics: [],
428
- result: {
429
- observedPrimaryCommit: null,
430
- request,
431
- language,
432
- selectedIdea: null,
433
- onboarding: gapReport,
434
- action: actionWithLanguage(action(
435
- "adopt-silvermoon",
436
- "Complete the blocking Silvermoon onboarding gaps before idea lifecycle work.",
437
- {
438
- gaps: gapReport.gaps,
439
- recommendedAction: gapReport.recommendedAction,
440
- recheck: gapReport.recheck,
441
- },
442
- ), language),
443
- },
444
- };
445
- }
446
- const report = await lifecycleWhatsNext({
447
- create,
448
- idea: selector,
449
- root: repositoryRoot,
521
+ const requestedRoot = resolve(root);
522
+ const intention = {
523
+ command: "whats-next",
524
+ args: { idea: selector ?? null },
525
+ };
526
+ const recheckCommand = selector === undefined
527
+ ? "silvermoon whats-next"
528
+ : `silvermoon whats-next ${JSON.stringify(selector)}`;
529
+ const outcomes = [];
530
+ let observed = await observeSnapshot({
531
+ root: requestedRoot,
450
532
  userHome,
533
+ version: { type: "worktree" },
534
+ });
535
+ if (!observed.projectReady) {
536
+ return createEnvelope(
537
+ intention,
538
+ observed.observation,
539
+ outcomes,
540
+ projectInstructions(
541
+ observed,
542
+ observed.observation.root,
543
+ observed.language,
544
+ recheckCommand,
545
+ ),
546
+ );
547
+ }
548
+
549
+ const selected = selectIdea(observed.layout.ideas, selector);
550
+ if (selected?.status.language) {
551
+ observed = withObservationLanguage(observed, selected.status.language);
552
+ }
553
+ const readiness = await assessRepositoryReadiness({
554
+ observed,
555
+ outcomes,
556
+ recheckCommand,
557
+ root: observed.observation.root,
451
558
  });
452
- if (report.result && gapReport) report.result.onboarding = gapReport;
453
- return report;
454
- }
559
+ if (!readiness.ready) {
560
+ return createEnvelope(
561
+ intention,
562
+ readiness.observation,
563
+ outcomes,
564
+ readiness.instructions,
565
+ );
566
+ }
567
+
568
+ if (selector === undefined) {
569
+ return createEnvelope(
570
+ intention,
571
+ { ...readiness.observation, state: "navigation-ready" },
572
+ outcomes,
573
+ navigationInstruction(observed.layout.ideas, observed.language),
574
+ );
575
+ }
576
+ if (!selected) {
577
+ return createEnvelope(
578
+ intention,
579
+ dialogueReadyObservation(readiness.observation, "idea-not-found", {
580
+ candidates: readiness.observation.ideas.activeIdeas,
581
+ }),
582
+ outcomes,
583
+ joinInstructions([
584
+ localize(
585
+ observed.language,
586
+ `Idea ${selector} does not match an observed ULID or unique alias.`,
587
+ `Idea ${selector} 未匹配任何已观察到的 ULID 或唯一 alias。`,
588
+ ),
589
+ navigationInstruction(observed.layout.ideas, observed.language),
590
+ ]),
591
+ );
592
+ }
593
+ return createEnvelope(
594
+ intention,
595
+ dialogueReadyObservation(readiness.observation, "idea-selected", {
596
+ selectedIdea: {
597
+ id: selected.id,
598
+ ...(selected.alias === undefined ? {} : { alias: selected.alias }),
599
+ state: selected.state,
600
+ },
601
+ }),
602
+ outcomes,
603
+ lifecycleInstruction(selected, observed.language),
604
+ );
605
+ }