openfeel 1.2.4 → 1.3.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.
Files changed (51) hide show
  1. package/README.en.md +40 -74
  2. package/README.md +39 -62
  3. package/README.zh-CN.md +40 -74
  4. package/dist/commands/setup.d.ts.map +1 -1
  5. package/dist/commands/setup.js +90 -5
  6. package/dist/commands/setup.js.map +1 -1
  7. package/dist/commands/update.d.ts.map +1 -1
  8. package/dist/commands/update.js +7 -2
  9. package/dist/commands/update.js.map +1 -1
  10. package/dist/core/config.js +2 -2
  11. package/dist/core/dsh-profile.d.ts +93 -0
  12. package/dist/core/dsh-profile.d.ts.map +1 -0
  13. package/dist/core/dsh-profile.js +400 -0
  14. package/dist/core/dsh-profile.js.map +1 -0
  15. package/dist/core/i18n-data/en.d.ts.map +1 -1
  16. package/dist/core/i18n-data/en.js +21 -2
  17. package/dist/core/i18n-data/en.js.map +1 -1
  18. package/dist/core/i18n-data/zh-CN.d.ts.map +1 -1
  19. package/dist/core/i18n-data/zh-CN.js +21 -2
  20. package/dist/core/i18n-data/zh-CN.js.map +1 -1
  21. package/dist/core/setup.d.ts +12 -1
  22. package/dist/core/setup.d.ts.map +1 -1
  23. package/dist/core/setup.js +25 -10
  24. package/dist/core/setup.js.map +1 -1
  25. package/dist/core/template-loader.d.ts.map +1 -1
  26. package/dist/core/template-loader.js +18 -9
  27. package/dist/core/template-loader.js.map +1 -1
  28. package/dist/core/update.d.ts +2 -0
  29. package/dist/core/update.d.ts.map +1 -1
  30. package/dist/core/update.js +25 -7
  31. package/dist/core/update.js.map +1 -1
  32. package/dsh/ACTION-GUIDE.md +366 -0
  33. package/dsh/ADAPTER.md +65 -0
  34. package/dsh/README.md +264 -0
  35. package/dsh/cordis.patch.yml +26 -0
  36. package/dsh/docs/compat/commands.md +873 -0
  37. package/dsh/docs/compat/kb-skills.md +883 -0
  38. package/dsh/docs/compat/persistence.md +1013 -0
  39. package/dsh/docs/compat/pipeline.md +1565 -0
  40. package/dsh/docs/compat/req.md +1243 -0
  41. package/dsh/docs/compat/stage.md +1437 -0
  42. package/dsh/lib/adapters/cli.js +624 -0
  43. package/dsh/lib/adapters/dashboard.js +393 -0
  44. package/dsh/lib/adapters/workspace.js +150 -0
  45. package/dsh/lib/index.js +189 -0
  46. package/dsh/lib/surface/command.js +186 -0
  47. package/dsh/lib/surface/prompt.js +131 -0
  48. package/dsh/lib/tools/index.js +1245 -0
  49. package/dsh/lib/tools/shared.js +320 -0
  50. package/dsh/package.json +45 -0
  51. package/package.json +9 -2
@@ -0,0 +1,393 @@
1
+ /**
2
+ * OpenFeel × DSH 适配器 — 状态快照(复合只读视图)
3
+ *
4
+ * v1.2.3 起,「阶段 → 负责 Agent / 相位标签 / 合法下一相位 / 守卫 / 阻塞审查」
5
+ * 的**决策权威在 CLI**:优先消费 `openfeel flow next --json`(v1.2.2 起的出口 #19),
6
+ * 失败时回退到 `openfeel flow status --json` + `openfeel flow mode --json`。
7
+ * 适配器**不再维护相位 → Agent 的本地镜像**,避免 OpenFeel 调整 Agent 编制后静默漂移。
8
+ *
9
+ * 关键纪律(来自 OpenFeel 的 feel 角色定义):
10
+ * - `pipeline.phase` 只是宏观态;**调度目标是 `phase != 'done'` 的活跃阶段**。
11
+ * - 合法转移必须从**该阶段自己的模式**解析,而不是全局默认。
12
+ */
13
+
14
+ import { FeelError } from './cli.js';
15
+
16
+ /** 15 个合法流水线相位(`flow phases --json.phases`)。 */
17
+ export const PIPELINE_PHASES = /** @type {const} */ ([
18
+ 'plan_pending',
19
+ 'plan_review',
20
+ 'plan_passed',
21
+ 'scheme_pending',
22
+ 'scheme_review',
23
+ 'scheme_passed',
24
+ 'exec_running',
25
+ 'review_pending',
26
+ 'review_failed',
27
+ 'review_passed',
28
+ 'test_pending',
29
+ 'test_failed',
30
+ 'test_passed',
31
+ 'archiving',
32
+ 'done',
33
+ ]);
34
+
35
+ /**
36
+ * 相位前缀 → checkpoint 字段名(`pipeline.yaml` 的 `checkpoint_mapping`)。
37
+ * @type {Record<string, string>}
38
+ */
39
+ export const CHECKPOINT_OF_PHASE = {
40
+ plan_pending: 'plan',
41
+ plan_review: 'plan',
42
+ plan_passed: 'plan',
43
+ scheme_pending: 'scheme',
44
+ scheme_review: 'scheme',
45
+ scheme_passed: 'scheme',
46
+ exec_running: 'exec',
47
+ review_pending: 'review',
48
+ review_failed: 'review',
49
+ review_passed: 'review',
50
+ test_pending: 'test',
51
+ test_failed: 'test',
52
+ test_passed: 'test',
53
+ archiving: 'archive',
54
+ done: 'archive',
55
+ };
56
+
57
+ /**
58
+ * 各工作流模式**不可达**的相位(用于向 agent 解释「为什么这条链没走」)。
59
+ * @type {Record<string, string[]>}
60
+ */
61
+ export const MODE_SKIPPED_PHASES = {
62
+ full: [],
63
+ fast: ['scheme_pending', 'scheme_review', 'scheme_passed', 'test_pending', 'test_failed', 'test_passed'],
64
+ general: [...PIPELINE_PHASES],
65
+ };
66
+
67
+ /**
68
+ * 解析某一相位在指定模式下的合法下一相位。
69
+ *
70
+ * @param {string} phase 当前相位。
71
+ * @param {Record<string, string[]> | undefined} transitions 该模式的转移表。
72
+ * @returns {string[]}
73
+ */
74
+ export function legalNextPhases(phase, transitions) {
75
+ if (transitions === undefined) return [];
76
+ const direct = transitions[phase];
77
+ if (Array.isArray(direct)) return direct;
78
+
79
+ // 支持 `|` 组合键:`'review_passed|test_passed': ['archiving']`。
80
+ /** @type {Set<string>} */
81
+ const merged = new Set();
82
+ for (const [key, targets] of Object.entries(transitions)) {
83
+ if (!key.includes('|')) continue;
84
+ const conditions = key.split('|').map((part) => part.trim());
85
+ if (!conditions.includes(phase)) continue;
86
+ for (const target of targets) merged.add(target);
87
+ }
88
+ return [...merged];
89
+ }
90
+
91
+ /**
92
+ * 读取状态快照。
93
+ *
94
+ * 并发发起 CLI 调用:
95
+ * - `flow next --json`:调度决策的**权威来源**(v1.2.2+);旧版 CLI 或单次失败时
96
+ * 不阻断快照,自动回退到 `flow status` / `flow mode`。
97
+ * - `flow status --json` 与 `flow mode --json`:阶段列表、模式与转移表(必需事实)。
98
+ * - `req list --json`:仅在 `withRequirements` 时附带。
99
+ *
100
+ * @param {import('./cli.js').FeelCli} cli 已锚定 cwd 的执行器。
101
+ * @param {object} [options]
102
+ * @param {AbortSignal} [options.signal]
103
+ * @param {boolean} [options.withRequirements] 是否附带需求概览。
104
+ * @returns {Promise<Record<string, any>>}
105
+ */
106
+ export async function readSnapshot(cli, options = {}) {
107
+ // `flow next` 是权威决策出口;失败(旧版 CLI / 未初始化 / 单次故障)时降级,
108
+ // 但取消信号必须继续向上传播。
109
+ const nextPromise = cli.json(['flow', 'next', '--json'], options).catch((error) => {
110
+ if (options.signal?.aborted === true) throw error;
111
+ return undefined;
112
+ });
113
+
114
+ const tasks = [
115
+ cli.json(['flow', 'status', '--json'], options),
116
+ cli.json(['flow', 'mode', '--json'], options),
117
+ nextPromise,
118
+ ];
119
+ if (options.withRequirements === true) {
120
+ tasks.push(cli.json(['req', 'list', '--json'], options));
121
+ }
122
+
123
+ const [status, mode, nextDoc, reqs] = await Promise.all(tasks);
124
+
125
+ if (status === undefined || typeof status !== 'object') {
126
+ throw new FeelError('openfeel flow status --json 未返回对象', {
127
+ kind: 'internal',
128
+ code: 'FEEL_BAD_STATUS',
129
+ });
130
+ }
131
+
132
+ const next = isRecord(nextDoc) ? nextDoc : undefined;
133
+ const pipelinePhase = status.pipeline?.phase ?? '';
134
+ const stages = Array.isArray(status.stages) ? status.stages : [];
135
+
136
+ // 调度目标是第一个未完成的阶段(OpenFeel 多阶段独立状态机)。
137
+ const activeStage = stages.find((stage) => stage.phase !== 'done');
138
+ const currentStageId = status.pipeline?.current?.stage ?? '';
139
+ const currentOpId = status.pipeline?.current?.op ?? '';
140
+
141
+ // `flow next` 指向 CLI 认为的当前阶段;缺失时回退到活跃阶段。
142
+ const nextStageId = typeof next?.stage === 'string' && next.stage !== '' ? next.stage : '';
143
+ const focusStageId = nextStageId !== '' ? nextStageId : (activeStage?.id ?? currentStageId);
144
+ const focusStage = stages.find((stage) => (stage.id ?? stage.name) === focusStageId) ?? activeStage;
145
+
146
+ const modeName = typeof mode?.mode === 'string' ? mode.mode : 'fast';
147
+ const modeSource = typeof mode?.source === 'string' ? mode.source : 'unknown';
148
+ const modes = mode?.modes ?? {};
149
+ const transitions = modes[modeName] ?? {};
150
+
151
+ // 以活跃阶段自己的模式为准(阶段级覆盖优先于需求级/项目级)。
152
+ // v1.2.3 的 status 字段名为 workflowMode;保留 snake_case 读取以兼容旧版。
153
+ const activeStageMode = firstString(
154
+ focusStage?.workflowMode,
155
+ focusStage?.workflow_mode,
156
+ next?.workflowMode,
157
+ modeName,
158
+ );
159
+ const activeTransitions = modes[activeStageMode] ?? transitions;
160
+ const nextPhase = typeof next?.phase === 'string' && next.phase !== '' ? next.phase : '';
161
+ // 无活跃阶段时 flow next 的 phase 为 null;快照统一用空串表示「无阶段」,
162
+ // 不回退到 pipeline.phase(宏观态 done 不等于有可调度阶段)。
163
+ const focusPhase = nextPhase !== '' ? nextPhase : (focusStage?.phase ?? '');
164
+ // flow next 在场时 workflowMode 也是权威值(无活跃阶段为 null);否则按阶段/全局派生。
165
+ const focusWorkflowMode = next !== undefined
166
+ ? (typeof next.workflowMode === 'string' ? next.workflowMode : null)
167
+ : activeStageMode;
168
+ // 合法下一相位必须来自 CLI:优先 flow next;回退时用 CLI 下发的转移表推导。
169
+ const nextPhases = Array.isArray(next?.legalNextPhases)
170
+ ? next.legalNextPhases.filter((phase) => typeof phase === 'string')
171
+ : legalNextPhases(focusPhase, activeTransitions);
172
+
173
+ return {
174
+ schemaVersion: 1,
175
+ project: {
176
+ root: cli.cwd ?? '',
177
+ initialized: true,
178
+ },
179
+ pipeline: {
180
+ /** 宏观态:active / paused / done(仅作元信息)。 */
181
+ phase: pipelinePhase,
182
+ currentStage: currentStageId,
183
+ currentOp: currentOpId,
184
+ totalStages: stages.length,
185
+ activeStageCount: stages.filter((stage) => stage.phase !== 'done').length,
186
+ doneStageCount: stages.filter((stage) => stage.phase === 'done').length,
187
+ },
188
+ focus: {
189
+ stage: focusStageId,
190
+ phase: focusPhase,
191
+ /** 相位标签由 `flow next` 下发;无权威值时退化为相位名(不维护本地映射)。 */
192
+ phaseLabel: typeof next?.phaseLabel === 'string' ? next.phaseLabel : focusPhase,
193
+ status: focusStage?.status ?? '',
194
+ deps: Array.isArray(focusStage?.deps) ? focusStage.deps : [],
195
+ ops: focusStage?.ops ?? {},
196
+ checkpoint: CHECKPOINT_OF_PHASE[focusPhase] ?? '',
197
+ /** 负责该相位的 Agent(CLI 权威字段;无 flow next 时为 unknown)。 */
198
+ responsibleAgent: typeof next?.responsibleAgent === 'string' ? next.responsibleAgent : 'unknown',
199
+ /** 阶段生效工作流模式(flow next / flow status 权威字段)。 */
200
+ workflowMode: focusWorkflowMode,
201
+ reviewTier: typeof next?.reviewTier === 'string' ? next.reviewTier : (focusStage?.reviewTier ?? null),
202
+ effectiveReviewTier: typeof next?.effectiveReviewTier === 'string'
203
+ ? next.effectiveReviewTier
204
+ : (focusStage?.effectiveReviewTier ?? null),
205
+ /** 该阶段在当前模式下的合法下一相位(CLI 权威;回退用 CLI 转移表推导)。 */
206
+ legalNextPhases: nextPhases,
207
+ /** 建议的推进命令(flow next 权威字段,空集为 null)。 */
208
+ suggestedCommand: typeof next?.suggestedCommand === 'string' ? next.suggestedCommand : null,
209
+ /** 守卫提示(flow next 权威字段)。 */
210
+ guards: Array.isArray(next?.guards) ? next.guards.filter((guard) => typeof guard === 'string') : [],
211
+ /** 当前阶段域内 blocking open REV(flow next 权威字段)。 */
212
+ blockingReviews: Array.isArray(next?.blockingReviews) ? next.blockingReviews : [],
213
+ /** 决策来源:flow next(权威)或 flow status(降级)。 */
214
+ source: next === undefined ? 'flow status' : 'flow next',
215
+ },
216
+ mode: {
217
+ /** 当前解析出的模式(按当前阶段派生)。 */
218
+ current: modeName,
219
+ /** 模式来源:status.md / requirement / config.yaml / profile.yaml / builtin。 */
220
+ source: modeSource,
221
+ /** 活跃阶段自身记录的模式(阶段级覆盖)。 */
222
+ activeStageMode,
223
+ /** 该模式下不可达的相位。 */
224
+ skippedPhases: MODE_SKIPPED_PHASES[activeStageMode] ?? [],
225
+ /** 全部三种模式的转移表(供 agent 自查)。 */
226
+ transitionsByMode: modes,
227
+ },
228
+ stages: stages.map((stage) => ({
229
+ id: stage.id ?? stage.name ?? '',
230
+ phase: stage.phase ?? '',
231
+ status: stage.status ?? '',
232
+ deps: Array.isArray(stage.deps) ? stage.deps : [],
233
+ workflowMode: stage.workflowMode ?? stage.workflow_mode ?? null,
234
+ requirementId: stage.requirementId ?? null,
235
+ reviewTier: stage.reviewTier ?? null,
236
+ effectiveReviewTier: stage.effectiveReviewTier ?? null,
237
+ opsSummary: isRecord(stage.opsSummary) ? stage.opsSummary : summarizeOps(stage.ops),
238
+ })),
239
+ ...(reqs === undefined ? {} : { requirements: summarizeRequirements(reqs) }),
240
+ };
241
+ }
242
+
243
+ /**
244
+ * 取第一个非空字符串。
245
+ * @param {...unknown} values
246
+ * @returns {string}
247
+ */
248
+ function firstString(...values) {
249
+ for (const value of values) {
250
+ if (typeof value === 'string' && value !== '') return value;
251
+ }
252
+ return '';
253
+ }
254
+
255
+ /**
256
+ * 判定是否为普通对象(排除 null 与数组)。
257
+ * @param {unknown} value
258
+ * @returns {value is Record<string, any>}
259
+ */
260
+ function isRecord(value) {
261
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
262
+ }
263
+
264
+ /**
265
+ * 汇总一个阶段的 op 状态计数与逐条状态。
266
+ * @param {unknown} ops
267
+ * @returns {{ total: number, byState: Record<string, number>, entries: Record<string, string> }}
268
+ */
269
+ export function summarizeOps(ops) {
270
+ if (typeof ops !== 'object' || ops === null || Array.isArray(ops)) {
271
+ return { total: 0, byState: {}, entries: {} };
272
+ }
273
+ /** @type {Record<string, number>} */
274
+ const byState = {};
275
+ /** @type {Record<string, string>} */
276
+ const entries = {};
277
+ for (const [id, state] of Object.entries(ops)) {
278
+ const value = typeof state === 'string' ? state : String(state);
279
+ entries[id] = value;
280
+ byState[value] = (byState[value] ?? 0) + 1;
281
+ }
282
+ return { total: Object.keys(entries).length, byState, entries };
283
+ }
284
+
285
+ /**
286
+ * 汇总需求列表。
287
+ * @param {any} payload `req list --json` 的返回。
288
+ * @returns {{ total: number, byStatus: Record<string, number>, unclaimed: string[], nextCandidates: string[] }}
289
+ */
290
+ export function summarizeRequirements(payload) {
291
+ const list = Array.isArray(payload?.requirements) ? payload.requirements : [];
292
+ /** @type {Record<string, number>} */
293
+ const byStatus = {};
294
+ /** @type {string[]} */
295
+ const unclaimed = [];
296
+ /** @type {string[]} */
297
+ const nextCandidates = [];
298
+
299
+ for (const item of list) {
300
+ const status = typeof item?.status === 'string' ? item.status : 'unknown';
301
+ byStatus[status] = (byStatus[status] ?? 0) + 1;
302
+ const owner = item?.owner;
303
+ if ((owner === undefined || owner === null || owner === '') && status === 'scheduled') {
304
+ unclaimed.push(item.id);
305
+ nextCandidates.push(item.id);
306
+ }
307
+ }
308
+
309
+ return { total: list.length, byStatus, unclaimed, nextCandidates };
310
+ }
311
+
312
+ /**
313
+ * 计算「下一步该做什么」的建议。
314
+ *
315
+ * 这是 Feel 角色的核心决策:**该谁做**。返回值刻意包含三种交付形态:
316
+ * - `cli`:可直接执行的 OpenFeel 命令(人读 / 兜底)。
317
+ * - `toolCall`:本适配器推荐的工具调用(agent 首选)。
318
+ * - `handoff`:OpenFeel 的 Agent 交接标记(下游 subagent 路由)。
319
+ *
320
+ * `phaseLabel` / `responsibleAgent` / `legalNextPhases` / `guards` /
321
+ * `blockingReviews` / `suggestedCommand` 均直接来自快照(即 CLI 权威字段);
322
+ * 适配器只额外补充 `adapterHints`(与门禁无关的交互提醒),不覆盖 CLI 守卫。
323
+ *
324
+ * @param {Record<string, any>} snapshot `readSnapshot` 的结果。
325
+ * @returns {Record<string, any>}
326
+ */
327
+ export function planNextStep(snapshot) {
328
+ const focus = snapshot.focus ?? {};
329
+ const phase = focus.phase ?? '';
330
+ const stage = focus.stage ?? '';
331
+ const agent = focus.responsibleAgent ?? 'unknown';
332
+ const stageMode = focus.workflowMode ?? snapshot.mode?.activeStageMode ?? 'fast';
333
+ const guards = Array.isArray(focus.guards) ? focus.guards : [];
334
+
335
+ // 无活跃阶段:CLI 约定 responsibleAgent=feel,phase/stage 为 null。
336
+ if (phase === '' || phase === null) {
337
+ const allDone = snapshot.pipeline?.activeStageCount === 0;
338
+ return {
339
+ phase: phase ?? '',
340
+ stage: stage ?? '',
341
+ phaseLabel: focus.phaseLabel ?? '',
342
+ responsibleAgent: agent,
343
+ workflowMode: focus.workflowMode ?? null,
344
+ reviewTier: focus.reviewTier ?? null,
345
+ effectiveReviewTier: focus.effectiveReviewTier ?? null,
346
+ summary: allDone
347
+ ? '无活跃阶段:所有阶段已完成。可考虑版本归档(feel_archive action=create)或接取新需求(feel_req action=next)。'
348
+ : `当前阶段 "${stage}" 已完成;flow.current 尚未指向新阶段。使用 feel_stage action=list 选择下一个未完成阶段。`,
349
+ legalNextPhases: [],
350
+ suggestedCommand: focus.suggestedCommand ?? null,
351
+ guards,
352
+ adapterHints: [],
353
+ cli: focus.suggestedCommand ?? (allDone ? 'openfeel flow status' : 'openfeel plan stage list'),
354
+ toolCall: null,
355
+ handoff: null,
356
+ };
357
+ }
358
+
359
+ const nextPhases = Array.isArray(focus.legalNextPhases) ? focus.legalNextPhases : [];
360
+ /** @type {string[]} */
361
+ const adapterHints = [];
362
+ if (phase === 'plan_passed' && stageMode !== 'general') {
363
+ adapterHints.push('若项目 auto_advance=disabled(手动执行模式),推进前必须询问用户是否开启自动推进。');
364
+ }
365
+ if (phase === 'review_failed' || phase === 'test_failed') {
366
+ adapterHints.push('失败回环:决定「重试 / 回退重定方案 / 请求人工介入」,并把结论记入 REV 条目。');
367
+ }
368
+
369
+ const cliCommand = typeof focus.suggestedCommand === 'string' && focus.suggestedCommand !== ''
370
+ ? focus.suggestedCommand
371
+ : `openfeel flow advance --stage ${stage} --to <${nextPhases[0] ?? 'phase'}>`;
372
+
373
+ return {
374
+ phase,
375
+ stage,
376
+ phaseLabel: focus.phaseLabel ?? phase,
377
+ responsibleAgent: agent,
378
+ workflowMode: focus.workflowMode ?? null,
379
+ reviewTier: focus.reviewTier ?? null,
380
+ effectiveReviewTier: focus.effectiveReviewTier ?? null,
381
+ summary: `阶段 "${stage}" 处于 ${phase}(${focus.phaseLabel ?? phase})→ 由 ${agent} 负责。`,
382
+ legalNextPhases: nextPhases,
383
+ suggestedCommand: focus.suggestedCommand ?? null,
384
+ guards,
385
+ adapterHints,
386
+ cli: cliCommand,
387
+ toolCall: nextPhases.length === 0
388
+ ? null
389
+ : { tool: 'feel_flow', arguments: { action: 'advance', stage, to: nextPhases[0] } },
390
+ /** OpenFeel 的交接标记;DSH 侧建议配合 `subagent` 工具派发。 */
391
+ handoff: agent === 'none' || agent === 'unknown' ? null : `[HANDOFF: ${agent}]`,
392
+ };
393
+ }
@@ -0,0 +1,150 @@
1
+ /**
2
+ * OpenFeel × DSH 适配器 — 工作区解析
3
+ *
4
+ * **语义与 OpenFeel CLI 严格一致:cwd 即项目根,不向上回溯。**
5
+ *
6
+ * 为什么明确禁止回溯(实测依据):
7
+ * - `openfeel flow status --json` 在 `%TEMP%\...\a\b\c`(祖先含 `~/.openfeel/`)返回
8
+ * **空态**而非该祖先的项目数据 ⇒ CLI 只认 `process.cwd()`,从不回溯。
9
+ * - `~/.openfeel/` 是 `openfeel setup` 建立的**跨项目全局家目录**(`config.json` /
10
+ * `cli/` / `locks/` / `secrets/`),**不含** `flow.json` 或 `config.yaml`。
11
+ *
12
+ * 若适配器自行回溯,就会把「全局家目录」误判成「项目」——这正是曾经出现
13
+ * 「在任意目录开会话都显示『当前项目 C:\Users\Liuary』」的根因。
14
+ *
15
+ * 因此本模块只做三件事:
16
+ * 1. 从工具执行上下文取会话工作区(`exec.agent.session.header.cwd`)。
17
+ * 2. 判定**该目录本身**是否为 OpenFeel 项目工作区(以 `flow.json` / `config.yaml` 为标记)。
18
+ * 3. 区分「不是项目」与「这是全局家目录」两种失败,给出可操作的诊断。
19
+ *
20
+ * 若将来需要 monorepo(子目录里工作),那属于 **OpenFeel 的能力**——应由 CLI 提供
21
+ * `openfeel project resolve --json` 供各适配器消费,而不是每个 harness 各写一套回溯。
22
+ */
23
+
24
+ import { existsSync, statSync } from 'node:fs';
25
+ import { join, resolve } from 'node:path';
26
+
27
+ import { FeelError } from './cli.js';
28
+
29
+ /** `.openfeel` 目录名(与 OpenFeel 单一约定一致)。 */
30
+ export const OPENFEEL_DIR = '.openfeel';
31
+
32
+ /**
33
+ * 项目工作区的标记文件(任一存在即视为项目)。
34
+ *
35
+ * - `flow.json`:`openfeel init` 必然创建,是核心数据存储。
36
+ * - `config.yaml`:同为 `init` 产物(`flow.json` 被手工删除时的兜底判据)。
37
+ *
38
+ * 全局家目录 `~/.openfeel/` 两者皆无,因此不会被误判。
39
+ */
40
+ export const PROJECT_MARKERS = /** @type {const} */ (['flow.json', 'config.yaml']);
41
+
42
+ /**
43
+ * 从工具执行上下文解析会话工作区目录。
44
+ *
45
+ * @param {object} exec DSH 工具执行上下文(`{ agent }` 亦可)。
46
+ * @param {string} [fallback] 兜底目录(通常是插件进程的 cwd)。
47
+ * @returns {string} 绝对路径。
48
+ * @throws {FeelError} 无法确定工作区时。
49
+ */
50
+ export function resolveSessionCwd(exec, fallback) {
51
+ const fromSession = exec?.agent?.session?.header?.cwd;
52
+ if (typeof fromSession === 'string' && fromSession !== '') {
53
+ return resolve(fromSession);
54
+ }
55
+ if (typeof fallback === 'string' && fallback !== '') return resolve(fallback);
56
+ const fromProcess = process.cwd();
57
+ if (typeof fromProcess === 'string' && fromProcess !== '') return resolve(fromProcess);
58
+ throw new FeelError('无法确定 OpenFeel 项目目录:当前会话没有工作区,且没有可用的兜底目录', {
59
+ kind: 'usage',
60
+ code: 'FEEL_NO_WORKSPACE',
61
+ });
62
+ }
63
+
64
+ /**
65
+ * 判定某个目录**本身**是否为 OpenFeel 项目工作区(不回溯)。
66
+ *
67
+ * @param {string} dir 待判定目录(绝对路径)。
68
+ * @returns {boolean}
69
+ */
70
+ export function isProjectWorkspace(dir) {
71
+ const openfeelDir = join(dir, OPENFEEL_DIR);
72
+ if (!isDirectory(openfeelDir)) return false;
73
+ return PROJECT_MARKERS.some((marker) => existsSync(join(openfeelDir, marker)));
74
+ }
75
+
76
+ /**
77
+ * 判定某个目录的 `.openfeel/` 是否为「全局家目录」形态(存在但无项目标记)。
78
+ *
79
+ * @param {string} dir 待判定目录。
80
+ * @returns {boolean}
81
+ */
82
+ export function isGlobalHomeLike(dir) {
83
+ const openfeelDir = join(dir, OPENFEEL_DIR);
84
+ if (!isDirectory(openfeelDir)) return false;
85
+ return !PROJECT_MARKERS.some((marker) => existsSync(join(openfeelDir, marker)));
86
+ }
87
+
88
+ /**
89
+ * 解析「要操作哪个 OpenFeel 项目」。
90
+ *
91
+ * 语义(与 CLI 对齐,**不回溯**):
92
+ * - 显式 `projectPath` 优先:必须是项目工作区,否则报错(相对路径按会话工作区解析)。
93
+ * - 否则以**会话工作区本身**为项目根候选。
94
+ * - 候选不是项目时返回 `initialized: false`,由上层决定提示 `openfeel init` 还是不注入提示。
95
+ *
96
+ * @param {object} exec 工具执行上下文(或 `{ agent }`)。
97
+ * @param {object} [options]
98
+ * @param {string} [options.projectPath] 显式项目路径(相对路径按会话工作区解析)。
99
+ * @param {string} [options.fallback] 兜底目录。
100
+ * @returns {{ projectRoot: string, sessionCwd: string, initialized: boolean, openfeelDir: string }}
101
+ */
102
+ export function resolveWorkspace(exec, options = {}) {
103
+ const sessionCwd = resolveSessionCwd(exec, options.fallback);
104
+
105
+ if (options.projectPath !== undefined && options.projectPath !== '') {
106
+ const explicit = resolve(sessionCwd, options.projectPath);
107
+ if (!isDirectory(explicit)) {
108
+ throw new FeelError(`指定的 OpenFeel 项目路径不存在或不是目录: ${explicit}`, {
109
+ kind: 'usage',
110
+ code: 'FEEL_PROJECT_NOT_FOUND',
111
+ detail: { projectPath: explicit },
112
+ });
113
+ }
114
+ if (!isProjectWorkspace(explicit)) {
115
+ // 区分「压根没有 .openfeel/」与「有 .openfeel/ 但那是全局家目录形态」。
116
+ const isGlobalHome = isGlobalHomeLike(explicit);
117
+ throw new FeelError(
118
+ isGlobalHome
119
+ ? `指定的目录含 ${OPENFEEL_DIR}/ 但那是 OpenFeel 的**全局配置目录**(无 flow.json / config.yaml),不是项目工作区:${explicit}。`
120
+ + '请把 project 指向真正的项目根(含 .openfeel/flow.json),或省略该参数以使用当前会话工作区。'
121
+ : `指定的目录不是 OpenFeel 项目(缺少 ${OPENFEEL_DIR}/ 或 ${OPENFEEL_DIR}/flow.json):${explicit}。`
122
+ + '请先在该目录运行 `openfeel init`,或省略 project 参数以使用当前会话工作区。',
123
+ { kind: 'usage', code: 'FEEL_NOT_A_PROJECT', detail: { projectPath: explicit, isGlobalHome } },
124
+ );
125
+ }
126
+ return { projectRoot: explicit, sessionCwd, initialized: true, openfeelDir: join(explicit, OPENFEEL_DIR) };
127
+ }
128
+
129
+ // 不回溯:会话工作区**本身**即项目根候选。
130
+ const initialized = isProjectWorkspace(sessionCwd);
131
+ return {
132
+ projectRoot: sessionCwd,
133
+ sessionCwd,
134
+ initialized,
135
+ openfeelDir: join(sessionCwd, OPENFEEL_DIR),
136
+ };
137
+ }
138
+
139
+ /**
140
+ * 判定是否为已存在目录。
141
+ * @param {string} path
142
+ * @returns {boolean}
143
+ */
144
+ export function isDirectory(path) {
145
+ try {
146
+ return existsSync(path) && statSync(path).isDirectory();
147
+ } catch {
148
+ return false;
149
+ }
150
+ }