openfeel 1.3.1 → 1.3.3
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/README.en.md +1 -0
- package/README.md +2 -1
- package/README.zh-CN.md +1 -0
- package/dist/commands/flow.d.ts.map +1 -1
- package/dist/commands/flow.js +98 -14
- package/dist/commands/flow.js.map +1 -1
- package/dist/commands/instructions.js +1 -1
- package/dist/commands/instructions.js.map +1 -1
- package/dist/commands/plan.d.ts.map +1 -1
- package/dist/commands/plan.js +9 -1
- package/dist/commands/plan.js.map +1 -1
- package/dist/core/archive/merge.js +8 -8
- package/dist/core/archive/merge.js.map +1 -1
- package/dist/core/artifact-graph/resolver.d.ts.map +1 -1
- package/dist/core/artifact-graph/resolver.js +8 -0
- package/dist/core/artifact-graph/resolver.js.map +1 -1
- package/dist/core/config.d.ts +12 -0
- package/dist/core/config.d.ts.map +1 -1
- package/dist/core/config.js +25 -3
- package/dist/core/config.js.map +1 -1
- package/dist/core/flow-manager.d.ts +26 -1
- package/dist/core/flow-manager.d.ts.map +1 -1
- package/dist/core/flow-manager.js +112 -9
- package/dist/core/flow-manager.js.map +1 -1
- package/dist/core/i18n-data/en.d.ts.map +1 -1
- package/dist/core/i18n-data/en.js +12 -0
- package/dist/core/i18n-data/en.js.map +1 -1
- package/dist/core/i18n-data/zh-CN.d.ts.map +1 -1
- package/dist/core/i18n-data/zh-CN.js +12 -0
- package/dist/core/i18n-data/zh-CN.js.map +1 -1
- package/dist/core/plan/stage.d.ts.map +1 -1
- package/dist/core/plan/stage.js +3 -1
- package/dist/core/plan/stage.js.map +1 -1
- package/dist/core/template-loader.d.ts.map +1 -1
- package/dist/core/template-loader.js +38 -18
- package/dist/core/template-loader.js.map +1 -1
- package/dist/core/update.d.ts.map +1 -1
- package/dist/core/update.js +25 -5
- package/dist/core/update.js.map +1 -1
- package/dist/core/view/entry.d.ts +13 -4
- package/dist/core/view/entry.d.ts.map +1 -1
- package/dist/core/view/entry.js +40 -16
- package/dist/core/view/entry.js.map +1 -1
- package/dist/core/workspace/structure.d.ts +21 -1
- package/dist/core/workspace/structure.d.ts.map +1 -1
- package/dist/core/workspace/structure.js +30 -11
- package/dist/core/workspace/structure.js.map +1 -1
- package/dsh/ADAPTER.md +17 -4
- package/dsh/README.md +41 -5
- package/dsh/cordis.patch.yml +14 -0
- package/dsh/lib/adapters/dashboard.js +2 -2
- package/dsh/lib/guard.js +424 -0
- package/dsh/lib/index.js +53 -7
- package/dsh/lib/surface/prompt.js +78 -2
- package/dsh/lib/tools/shared.js +28 -14
- package/dsh/package.json +2 -2
- package/package.json +1 -1
package/dsh/lib/guard.js
ADDED
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenFeel × DSH — 写入门禁
|
|
3
|
+
*
|
|
4
|
+
* 用 DSH 的 `ctx.tools.guard()` 在工具执行前做**同步拒绝**。守卫只读
|
|
5
|
+
* `.openfeel/flow.json`,不写任何状态;所有状态变更仍由 OpenFeel CLI 负责。
|
|
6
|
+
*
|
|
7
|
+
* 规则(与 `.openfeel/plan/v1/stage-103/plan.md` 一致):
|
|
8
|
+
* - 只守文件修改工具(默认 edit/write/apply_patch/str_replace_editor);
|
|
9
|
+
* - `.openfeel/**` 是流程产物区,任意阶段可写;但 flow.json / status.md 始终拒绝;
|
|
10
|
+
* - `.openfeel/` 外的源码/文档要求存在 `exec_running` 阶段(或 general 需求例外);
|
|
11
|
+
* - 无 `.openfeel/` 时:`always` 拒绝全部写文件,`project`/`off` 放行;
|
|
12
|
+
* - `apply_patch` 解析所有 patch 头目标,混合/无头时保守拒绝。
|
|
13
|
+
*
|
|
14
|
+
* @module openfeel-dsh/guard
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
18
|
+
import { basename, extname, join, resolve } from 'node:path';
|
|
19
|
+
|
|
20
|
+
import { isProjectWorkspace, OPENFEEL_DIR } from './adapters/workspace.js';
|
|
21
|
+
|
|
22
|
+
/** 默认受守卫的文件修改工具。 */
|
|
23
|
+
export const DEFAULT_MUTATING_TOOLS = Object.freeze([
|
|
24
|
+
'edit',
|
|
25
|
+
'write',
|
|
26
|
+
'apply_patch',
|
|
27
|
+
'str_replace_editor',
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 大小写不敏感文件系统:Windows + 默认 macOS(APFS/HFS+)。
|
|
32
|
+
* 在这两类平台上把路径统一小写,防止 `.OPENFEEL/FLOW.JSON` 绕过。
|
|
33
|
+
* 导出以允许在任意平台测试该分支(platform 可注入)。
|
|
34
|
+
*
|
|
35
|
+
* @param {string} value
|
|
36
|
+
* @param {NodeJS.Platform} [platform]
|
|
37
|
+
* @returns {string}
|
|
38
|
+
*/
|
|
39
|
+
export function normalizeForComparison(value, platform = process.platform) {
|
|
40
|
+
return platform === 'win32' || platform === 'darwin' ? value.toLowerCase() : value;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** 文档/笔记类扩展名(写门禁按产物类型分层:doc/note 不绑相位)。 */
|
|
44
|
+
export const DOC_EXTENSIONS = Object.freeze(['.md', '.mdx', '.txt', '.rst', '.adoc']);
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* 是否为文档/笔记类目标(P0-4):扩展名命中,或位于**工作区根级 `docs/`** 下。
|
|
48
|
+
* v1.3.3 复核:`docs` 必须直接位于会话工作区下(`src/docs/x.ts` 不算文档,防源码借名绕过相位门)。
|
|
49
|
+
* 仅用于「放行 doc/note」;源代码仍按 `exec_running` 严格判定。
|
|
50
|
+
* @param {string} absolute 已归一化绝对路径
|
|
51
|
+
* @param {string} cwd 已归一化工作区
|
|
52
|
+
* @returns {boolean}
|
|
53
|
+
*/
|
|
54
|
+
function isDocPath(absolute, cwd) {
|
|
55
|
+
if (DOC_EXTENSIONS.includes(extname(absolute).toLowerCase())) return true;
|
|
56
|
+
const docsRoot = normalizeTarget(cwd, 'docs');
|
|
57
|
+
return absolute === docsRoot
|
|
58
|
+
|| absolute.startsWith(`${docsRoot}\\`)
|
|
59
|
+
|| absolute.startsWith(`${docsRoot}/`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* 是否位于工作区内(bootstrap 例外边界;防 `..`/绝对路径写出工作区)。
|
|
64
|
+
* @param {string} absolute 已归一化绝对路径
|
|
65
|
+
* @param {string} cwd 已归一化工作区
|
|
66
|
+
* @returns {boolean}
|
|
67
|
+
*/
|
|
68
|
+
function isUnderWorkspace(absolute, cwd) {
|
|
69
|
+
return absolute === cwd || absolute.startsWith(`${cwd}\\`) || absolute.startsWith(`${cwd}/`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** flow.json 读取缓存(path → { mtimeMs, size, value })。 */
|
|
73
|
+
const flowCache = new Map();
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* 归一化路径:绝对化 + Windows 小写。
|
|
77
|
+
* @param {string} cwd
|
|
78
|
+
* @param {string} target
|
|
79
|
+
* @returns {string}
|
|
80
|
+
*/
|
|
81
|
+
function normalizeTarget(cwd, target) {
|
|
82
|
+
const absolute = resolve(cwd, target);
|
|
83
|
+
return normalizeForComparison(absolute);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* 去引号并去空白(patch 头里的路径可能带引号/空格)。
|
|
88
|
+
* @param {string} raw
|
|
89
|
+
* @returns {string}
|
|
90
|
+
*/
|
|
91
|
+
function cleanPatchPath(raw) {
|
|
92
|
+
let value = String(raw).trim();
|
|
93
|
+
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
|
94
|
+
value = value.slice(1, -1).trim();
|
|
95
|
+
}
|
|
96
|
+
return value;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* 是否为 `.openfeel/` 下的路径(含目录本身)。
|
|
101
|
+
* @param {string} absolute 已归一化
|
|
102
|
+
* @param {string} cwd 归一化后的工作区
|
|
103
|
+
* @returns {boolean}
|
|
104
|
+
*/
|
|
105
|
+
function isUnderOpenFeel(absolute, cwd) {
|
|
106
|
+
const root = normalizeTarget(cwd, OPENFEEL_DIR);
|
|
107
|
+
return absolute === root || absolute.startsWith(`${root}\\`) || absolute.startsWith(`${root}/`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* 是否为受保护路径(flow.json / `.openfeel/**\/status.md`)。
|
|
112
|
+
* @param {string} absolute 已归一化
|
|
113
|
+
* @param {string} cwd 归一化后的工作区
|
|
114
|
+
* @returns {boolean}
|
|
115
|
+
*/
|
|
116
|
+
function isProtectedPath(absolute, cwd) {
|
|
117
|
+
const flow = normalizeTarget(cwd, join(OPENFEEL_DIR, 'flow.json'));
|
|
118
|
+
if (absolute === flow) return true;
|
|
119
|
+
return isUnderOpenFeel(absolute, cwd) && basename(absolute).toLowerCase() === 'status.md';
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* 从工具参数提取目标路径(edit/write/str_replace_editor)。
|
|
124
|
+
* @param {unknown} args
|
|
125
|
+
* @returns {string[]}
|
|
126
|
+
*/
|
|
127
|
+
function extractArgumentPaths(args) {
|
|
128
|
+
if (typeof args !== 'object' || args === null) return [];
|
|
129
|
+
const out = [];
|
|
130
|
+
for (const key of ['path', 'file_path', 'filePath', 'target_file', 'targetFile']) {
|
|
131
|
+
const value = /** @type {Record<string, unknown>} */ (args)[key];
|
|
132
|
+
if (typeof value === 'string' && value.trim() !== '') out.push(value.trim());
|
|
133
|
+
}
|
|
134
|
+
return out;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* 从 apply_patch 参数提取 patch 文本。
|
|
139
|
+
* @param {unknown} args
|
|
140
|
+
* @returns {string | undefined}
|
|
141
|
+
*/
|
|
142
|
+
function extractPatchText(args) {
|
|
143
|
+
if (typeof args !== 'object' || args === null) return undefined;
|
|
144
|
+
for (const key of ['patch', 'input', 'diff', 'content']) {
|
|
145
|
+
const value = /** @type {Record<string, unknown>} */ (args)[key];
|
|
146
|
+
if (typeof value === 'string' && value.trim() !== '') return value;
|
|
147
|
+
}
|
|
148
|
+
return undefined;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* 解析 apply_patch 的全部目标路径与变更类型。
|
|
153
|
+
*
|
|
154
|
+
* 只认 patch 头,避免正文里出现 `.openfeel/` 被误判。支持:
|
|
155
|
+
* - `*** Update File:`, `*** Add File:`, `*** Delete File:`
|
|
156
|
+
* - `*** Move to:`(要求同块 `*** Update File:` 源,否则拒绝)
|
|
157
|
+
* - `--- a/path`, `+++ b/path`;忽略 `/dev/null`
|
|
158
|
+
*
|
|
159
|
+
* 返回 `entries`(路径 + kind)供 bootstrap 例外判定「是否纯新增」:
|
|
160
|
+
* kind ∈ `add | update | delete | move`;同一路径出现多种 kind 时从严记 `update`。
|
|
161
|
+
*
|
|
162
|
+
* @param {string} patch
|
|
163
|
+
* @returns {{ ok: true, paths: string[], entries: Array<{ path: string, kind: string }> } | { ok: false, reason: string }}
|
|
164
|
+
*/
|
|
165
|
+
export function parsePatchTargets(patch) {
|
|
166
|
+
const paths = [];
|
|
167
|
+
const kinds = new Map();
|
|
168
|
+
const seen = new Set();
|
|
169
|
+
const add = (raw, kind) => {
|
|
170
|
+
const value = cleanPatchPath(raw);
|
|
171
|
+
if (value === '' || value === '/dev/null') return;
|
|
172
|
+
const prev = kinds.get(value);
|
|
173
|
+
if (prev === undefined) {
|
|
174
|
+
kinds.set(value, kind);
|
|
175
|
+
} else if (prev !== kind) {
|
|
176
|
+
kinds.set(value, 'update'); // 同路径混合 kind → 从严(bootstrap 例外不成立)
|
|
177
|
+
}
|
|
178
|
+
if (seen.has(value)) return;
|
|
179
|
+
seen.add(value);
|
|
180
|
+
paths.push(value);
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
// 按块跟踪:*** Move to: 必须位于同一块 *** Update File: 之后。
|
|
184
|
+
let currentBlock = null;
|
|
185
|
+
for (const line of patch.split(/\r?\n/)) {
|
|
186
|
+
const update = /^\*\*\* Update File:\s*(.+?)\s*$/.exec(line);
|
|
187
|
+
if (update !== null) {
|
|
188
|
+
currentBlock = 'update';
|
|
189
|
+
add(update[1], 'update');
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
const addFile = /^\*\*\* Add File:\s*(.+?)\s*$/.exec(line);
|
|
193
|
+
if (addFile !== null) {
|
|
194
|
+
currentBlock = 'add';
|
|
195
|
+
add(addFile[1], 'add');
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
const deleteFile = /^\*\*\* Delete File:\s*(.+?)\s*$/.exec(line);
|
|
199
|
+
if (deleteFile !== null) {
|
|
200
|
+
currentBlock = 'delete';
|
|
201
|
+
add(deleteFile[1], 'delete');
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
const move = /^\*\*\* Move to:\s*(.+?)\s*$/.exec(line);
|
|
205
|
+
if (move !== null) {
|
|
206
|
+
if (currentBlock !== 'update') {
|
|
207
|
+
return { ok: false, reason: '*** Move to: 缺少同块 *** Update File: 源路径' };
|
|
208
|
+
}
|
|
209
|
+
add(move[1], 'move');
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
if (/^\*\*\* (?:Begin|End) Patch/.test(line)) {
|
|
213
|
+
currentBlock = null;
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
const diff = /^(?:---|\+\+\+) [ab]\/(.+?)\s*$/.exec(line);
|
|
217
|
+
if (diff !== null) add(diff[1], 'update');
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (paths.length === 0) {
|
|
221
|
+
return { ok: false, reason: '无法从 patch 头确定目标路径' };
|
|
222
|
+
}
|
|
223
|
+
return { ok: true, paths, entries: paths.map((path) => ({ path, kind: kinds.get(path) })) };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* 读取 flow.json(同步、按 mtime/size 缓存)。
|
|
228
|
+
* @param {string} cwd
|
|
229
|
+
* @returns {{ ok: true, value: any } | { ok: false, reason: string }}
|
|
230
|
+
*/
|
|
231
|
+
function readFlow(cwd) {
|
|
232
|
+
const path = join(cwd, OPENFEEL_DIR, 'flow.json');
|
|
233
|
+
try {
|
|
234
|
+
const stat = statSync(path);
|
|
235
|
+
const cached = flowCache.get(path);
|
|
236
|
+
if (cached !== undefined && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) {
|
|
237
|
+
return { ok: true, value: cached.value };
|
|
238
|
+
}
|
|
239
|
+
const value = JSON.parse(readFileSync(path, 'utf8'));
|
|
240
|
+
flowCache.set(path, { mtimeMs: stat.mtimeMs, size: stat.size, value });
|
|
241
|
+
return { ok: true, value };
|
|
242
|
+
} catch (error) {
|
|
243
|
+
return {
|
|
244
|
+
ok: false,
|
|
245
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* 是否存在 general 模式的 in_progress 需求(允许无阶段直接改)。
|
|
252
|
+
* @param {any} flow
|
|
253
|
+
* @returns {boolean}
|
|
254
|
+
*/
|
|
255
|
+
function hasGeneralRequirement(flow) {
|
|
256
|
+
const requirements = flow?.requirements;
|
|
257
|
+
if (!Array.isArray(requirements)) return false;
|
|
258
|
+
return requirements.some((requirement) => requirement
|
|
259
|
+
&& requirement.status === 'in_progress'
|
|
260
|
+
&& requirement.workflow_mode === 'general');
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** 活跃阶段(phase !== done)。 */
|
|
264
|
+
function activeStages(flow) {
|
|
265
|
+
const stages = flow?.stages;
|
|
266
|
+
if (typeof stages !== 'object' || stages === null) return [];
|
|
267
|
+
return Object.entries(stages)
|
|
268
|
+
.filter(([, stage]) => stage && stage.phase !== 'done')
|
|
269
|
+
.map(([id, stage]) => ({ ...stage, id }));
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* 生成守卫。
|
|
274
|
+
*
|
|
275
|
+
* @param {object} options
|
|
276
|
+
* @param {'off'|'project'|'always'} [options.enforce]
|
|
277
|
+
* @param {string[]} [options.mutatingTools]
|
|
278
|
+
* @param {boolean} [options.allowGeneral]
|
|
279
|
+
* @param {'zh-CN'|'en'} [options.language]
|
|
280
|
+
* @param {string} [options.cwd] 无会话时的兜底目录(通常为 config.cwd)
|
|
281
|
+
* @returns {(execution: { name?: string, arguments?: unknown, agent?: { session?: { header?: { cwd?: string } } } }) => string | undefined}
|
|
282
|
+
*/
|
|
283
|
+
export function createOpenFeelGuard(options = {}) {
|
|
284
|
+
const enforce = options.enforce === 'off' || options.enforce === 'project' || options.enforce === 'always'
|
|
285
|
+
? options.enforce
|
|
286
|
+
: 'always';
|
|
287
|
+
const mutating = new Set(Array.isArray(options.mutatingTools) && options.mutatingTools.length > 0
|
|
288
|
+
? options.mutatingTools
|
|
289
|
+
: DEFAULT_MUTATING_TOOLS);
|
|
290
|
+
const allowGeneral = options.allowGeneral !== false;
|
|
291
|
+
const english = options.language === 'en';
|
|
292
|
+
const fallbackCwd = typeof options.cwd === 'string' && options.cwd !== '' ? options.cwd : undefined;
|
|
293
|
+
|
|
294
|
+
const t = (zh, en) => (english ? en : zh);
|
|
295
|
+
/** bootstrap 例外提示去重(按工作区路径)。 */
|
|
296
|
+
const bootstrapHinted = new Set();
|
|
297
|
+
|
|
298
|
+
if (enforce === 'off') {
|
|
299
|
+
// 完全关闭:连 flow.json/status.md 也不拦。
|
|
300
|
+
return () => undefined;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
return (execution) => {
|
|
304
|
+
const name = execution?.name;
|
|
305
|
+
if (typeof name !== 'string' || !mutating.has(name)) return undefined;
|
|
306
|
+
|
|
307
|
+
const sessionCwd = execution?.agent?.session?.header?.cwd;
|
|
308
|
+
const cwd = typeof sessionCwd === 'string' && sessionCwd !== '' ? sessionCwd : fallbackCwd;
|
|
309
|
+
if (typeof cwd !== 'string' || cwd === '') return undefined;
|
|
310
|
+
const resolvedCwd = resolve(cwd);
|
|
311
|
+
const normalizedCwd = normalizeForComparison(resolvedCwd);
|
|
312
|
+
|
|
313
|
+
let rawTargets;
|
|
314
|
+
let patchEntries = null;
|
|
315
|
+
if (name === 'apply_patch') {
|
|
316
|
+
const patch = extractPatchText(execution?.arguments);
|
|
317
|
+
if (patch === undefined) {
|
|
318
|
+
return t(
|
|
319
|
+
'OpenFeel 写入门禁:apply_patch 缺少 patch 文本,无法确定目标;请改用 write/edit,或提供标准 patch 头。',
|
|
320
|
+
'OpenFeel write guard: apply_patch has no patch text, so targets are unknown. Use write/edit, or provide standard patch headers.',
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
const parsed = parsePatchTargets(patch);
|
|
324
|
+
if (!parsed.ok) {
|
|
325
|
+
return t(
|
|
326
|
+
`OpenFeel 写入门禁:${parsed.reason}。请改用 write/edit,或提供 *** Update File: 等标准头。`,
|
|
327
|
+
`OpenFeel write guard: ${parsed.reason}. Use write/edit, or provide standard *** Update File: headers.`,
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
rawTargets = parsed.paths;
|
|
331
|
+
patchEntries = parsed.entries;
|
|
332
|
+
} else {
|
|
333
|
+
rawTargets = extractArgumentPaths(execution?.arguments);
|
|
334
|
+
if (rawTargets.length === 0) {
|
|
335
|
+
return t(
|
|
336
|
+
'OpenFeel 写入门禁:无法确定目标路径;请提供 path / file_path。',
|
|
337
|
+
'OpenFeel write guard: cannot determine the target path; provide path / file_path.',
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const targets = rawTargets.map((target) => normalizeTarget(resolvedCwd, target));
|
|
343
|
+
const allDoc = targets.every((target) => isDocPath(target, normalizedCwd));
|
|
344
|
+
|
|
345
|
+
// 受保护路径:guard 启用时始终拒绝(enforce=off 已提前返回)。
|
|
346
|
+
if (targets.some((target) => isProtectedPath(target, normalizedCwd))) {
|
|
347
|
+
return t(
|
|
348
|
+
'OpenFeel 写入门禁:禁止手改 `.openfeel/flow.json` 或 `.openfeel/**/status.md`。请改用 feel_flow / feel_stage 等 CLI 工具。',
|
|
349
|
+
'OpenFeel write guard: never hand-edit `.openfeel/flow.json` or `.openfeel/**/status.md`. Use feel_flow / feel_stage instead.',
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const initialized = isProjectWorkspace(resolvedCwd);
|
|
354
|
+
if (!initialized) {
|
|
355
|
+
if (enforce === 'always') {
|
|
356
|
+
// P0-1 bootstrap 例外:空工作区 + 全部为「新增文件」→ 放行(最高风险等级不成立)。
|
|
357
|
+
// apply_patch 额外要求全部 block 为 `*** Add File:`(Update/Delete/Move/混合一律从严)。
|
|
358
|
+
// `.openfeel/**` 不适用:流程产物仍要求先 init(B8)。
|
|
359
|
+
const allNew = targets.every((target) => !existsSync(target));
|
|
360
|
+
const anyInternal = targets.some((target) => isUnderOpenFeel(target, normalizedCwd));
|
|
361
|
+
const allInside = targets.every((target) => isUnderWorkspace(target, normalizedCwd));
|
|
362
|
+
const addOnly = patchEntries === null || patchEntries.every((entry) => entry.kind === 'add');
|
|
363
|
+
if (allNew && addOnly && !anyInternal && allInside) {
|
|
364
|
+
if (!bootstrapHinted.has(normalizedCwd)) {
|
|
365
|
+
bootstrapHinted.add(normalizedCwd);
|
|
366
|
+
process.stderr.write(
|
|
367
|
+
`${t(
|
|
368
|
+
'OpenFeel 写入门禁:[bootstrap] 工作区尚未初始化,已放行本次新增文件;建议稍后运行 `openfeel init` 补账本。',
|
|
369
|
+
'OpenFeel write guard: [bootstrap] workspace not initialized; this new-file write was allowed. Run `openfeel init` later to record it.',
|
|
370
|
+
)}\n`,
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
return undefined;
|
|
374
|
+
}
|
|
375
|
+
return t(
|
|
376
|
+
'OpenFeel 写入门禁:当前工作区尚未初始化 OpenFeel(无 `.openfeel/`)。请先运行 `openfeel init` 并建立需求/阶段;或让用户把 profile 的 `enforce` 改为 `project` / `off`。',
|
|
377
|
+
'OpenFeel write guard: this workspace is not OpenFeel-initialized (no `.openfeel/`). Run `openfeel init` and create a requirement/stage first; or ask the user to set `enforce` to `project` / `off`.',
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
return undefined;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const allInternal = targets.every((target) => isUnderOpenFeel(target, normalizedCwd));
|
|
384
|
+
if (allInternal) return undefined;
|
|
385
|
+
|
|
386
|
+
const flow = readFlow(resolvedCwd);
|
|
387
|
+
if (!flow.ok) {
|
|
388
|
+
return t(
|
|
389
|
+
`OpenFeel 写入门禁:无法读取 .openfeel/flow.json(${flow.reason})。请运行 feel_verify action=health。`,
|
|
390
|
+
`OpenFeel write guard: cannot read .openfeel/flow.json (${flow.reason}). Run feel_verify action=health.`,
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const active = activeStages(flow.value);
|
|
395
|
+
if (active.length === 0) {
|
|
396
|
+
if (allowGeneral && hasGeneralRequirement(flow.value)) return undefined;
|
|
397
|
+
// P0-4:doc/note 类产物不要求活跃阶段(收口后仍可写文档);混合目标从严。
|
|
398
|
+
if (allDoc) return undefined;
|
|
399
|
+
return t(
|
|
400
|
+
'OpenFeel 写入门禁:当前无活跃阶段,不能修改源码/文档。请先 feel_req add → claim → feel_stage add 建立阶段。',
|
|
401
|
+
'OpenFeel write guard: no active stage, so source/docs are read-only. Create a stage via feel_req add → claim → feel_stage add.',
|
|
402
|
+
);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
if (active.some((stage) => stage.phase === 'exec_running')) return undefined;
|
|
406
|
+
// P0-4:非 exec 相位仍允许 doc/note(研究记录、设计文档等);代码仍严格。
|
|
407
|
+
if (allDoc) return undefined;
|
|
408
|
+
|
|
409
|
+
const summary = active
|
|
410
|
+
.map((stage) => `${stage.id ?? '(unknown)'}=${stage.phase ?? '(unknown)'}`)
|
|
411
|
+
.join(', ');
|
|
412
|
+
return t(
|
|
413
|
+
`OpenFeel 写入门禁:活跃阶段当前不在 exec_running(${summary}),不能修改源码/文档。请先 feel_flow action=advance 推进到 exec_running。`,
|
|
414
|
+
`OpenFeel write guard: active stage(s) are not in exec_running (${summary}), so source/docs are read-only. Advance to exec_running with feel_flow action=advance.`,
|
|
415
|
+
);
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* 测试用:清空 flow.json 缓存。
|
|
421
|
+
*/
|
|
422
|
+
export function clearGuardCache() {
|
|
423
|
+
flowCache.clear();
|
|
424
|
+
}
|
package/dsh/lib/index.js
CHANGED
|
@@ -24,8 +24,9 @@
|
|
|
24
24
|
|
|
25
25
|
import { FeelCli } from './adapters/cli.js';
|
|
26
26
|
import { resolveWorkspace } from './adapters/workspace.js';
|
|
27
|
+
import { createOpenFeelGuard } from './guard.js';
|
|
27
28
|
import { createFeelCommandHandler } from './surface/command.js';
|
|
28
|
-
import { FEEL_PROMPT_NAME, FEEL_PROMPT_ORDER, renderFeelPrompt } from './surface/prompt.js';
|
|
29
|
+
import { FEEL_PROMPT_NAME, FEEL_PROMPT_ORDER, renderBootstrapPrompt, renderFeelPrompt } from './surface/prompt.js';
|
|
29
30
|
import { createTools } from './tools/index.js';
|
|
30
31
|
|
|
31
32
|
/** Loader 入口 id。 */
|
|
@@ -51,7 +52,7 @@ const COMMAND_ID = '@openfeel/dsh/feel';
|
|
|
51
52
|
* - `log: false`(不写 `~/.openfeel/cli/logs/`,避免只读沙箱下失败)。
|
|
52
53
|
*
|
|
53
54
|
* @param {Record<string, any> | undefined} raw Loader 传入的配置。
|
|
54
|
-
* @returns {{ command: string, commandPrefix: string[], timeoutMs: number, log: boolean, autoPrompt: boolean, language: string, cwd: string | undefined }}
|
|
55
|
+
* @returns {{ command: string, commandPrefix: string[], timeoutMs: number, log: boolean, autoPrompt: boolean, activation: 'auto'|'always'|'off', language: string, cwd: string | undefined, enforce: 'off'|'project'|'always', mutatingTools: string[], allowGeneral: boolean }}
|
|
55
56
|
*/
|
|
56
57
|
export function normalizeConfig(raw) {
|
|
57
58
|
const source = typeof raw === 'object' && raw !== null ? raw : {};
|
|
@@ -59,14 +60,25 @@ export function normalizeConfig(raw) {
|
|
|
59
60
|
? source.commandPrefix.filter((item) => typeof item === 'string')
|
|
60
61
|
: [];
|
|
61
62
|
const timeoutMs = Number.isFinite(source.timeoutMs) && source.timeoutMs > 0 ? Number(source.timeoutMs) : 120_000;
|
|
63
|
+
const activation = source.activation === 'always' || source.activation === 'off' ? source.activation : 'auto';
|
|
64
|
+
const enforce = source.enforce === 'off' || source.enforce === 'project' || source.enforce === 'always'
|
|
65
|
+
? source.enforce
|
|
66
|
+
: 'always';
|
|
67
|
+
const mutatingTools = Array.isArray(source.mutatingTools)
|
|
68
|
+
? source.mutatingTools.filter((item) => typeof item === 'string' && item !== '')
|
|
69
|
+
: ['edit', 'write', 'apply_patch', 'str_replace_editor'];
|
|
62
70
|
return {
|
|
63
71
|
command: typeof source.command === 'string' && source.command !== '' ? source.command : 'openfeel',
|
|
64
72
|
commandPrefix,
|
|
65
73
|
timeoutMs,
|
|
66
74
|
log: source.log === true,
|
|
67
75
|
autoPrompt: source.autoPrompt !== false,
|
|
76
|
+
activation,
|
|
68
77
|
language: source.language === 'en' ? 'en' : 'zh-CN',
|
|
69
78
|
cwd: typeof source.cwd === 'string' && source.cwd !== '' ? source.cwd : undefined,
|
|
79
|
+
enforce,
|
|
80
|
+
mutatingTools,
|
|
81
|
+
allowGeneral: source.allowGeneral !== false,
|
|
70
82
|
};
|
|
71
83
|
}
|
|
72
84
|
|
|
@@ -122,6 +134,28 @@ export function apply(ctx, rawConfig) {
|
|
|
122
134
|
ctx.tools.register(tool);
|
|
123
135
|
}
|
|
124
136
|
|
|
137
|
+
/* ── 写入门禁 ───────────────────────────────────────────── */
|
|
138
|
+
|
|
139
|
+
if (config.enforce !== 'off') {
|
|
140
|
+
if (typeof ctx.tools.guard !== 'function') {
|
|
141
|
+
log.warn('当前 DSH 不支持 ctx.tools.guard;写入门禁未启用(仍可用 feel_* 工具)');
|
|
142
|
+
} else {
|
|
143
|
+
const registerGuard = () => ctx.tools.guard(createOpenFeelGuard({
|
|
144
|
+
enforce: config.enforce,
|
|
145
|
+
mutatingTools: config.mutatingTools,
|
|
146
|
+
allowGeneral: config.allowGeneral,
|
|
147
|
+
language: config.language,
|
|
148
|
+
cwd: config.cwd,
|
|
149
|
+
}));
|
|
150
|
+
if (typeof ctx.effect === 'function') {
|
|
151
|
+
ctx.effect(registerGuard);
|
|
152
|
+
} else {
|
|
153
|
+
const dispose = registerGuard();
|
|
154
|
+
if (typeof ctx.on === 'function' && typeof dispose === 'function') ctx.on('dispose', dispose);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
125
159
|
/* ── 人类命令面 ─────────────────────────────────────────── */
|
|
126
160
|
|
|
127
161
|
ctx.commands.register({
|
|
@@ -137,7 +171,7 @@ export function apply(ctx, rawConfig) {
|
|
|
137
171
|
|
|
138
172
|
/* ── 系统提示面 ─────────────────────────────────────────── */
|
|
139
173
|
|
|
140
|
-
if (config.autoPrompt) {
|
|
174
|
+
if (config.autoPrompt && config.activation !== 'off') {
|
|
141
175
|
ctx.systemPrompt.section({
|
|
142
176
|
name: FEEL_PROMPT_NAME,
|
|
143
177
|
order: FEEL_PROMPT_ORDER,
|
|
@@ -157,10 +191,22 @@ export function apply(ctx, rawConfig) {
|
|
|
157
191
|
} catch {
|
|
158
192
|
return '';
|
|
159
193
|
}
|
|
160
|
-
//
|
|
161
|
-
//
|
|
162
|
-
|
|
163
|
-
|
|
194
|
+
// 不回溯:只按会话工作区本身判定。
|
|
195
|
+
// - 已初始化 → 完整 Feel 纪律;
|
|
196
|
+
// - 未初始化 + activation=always → 完整提示(detected:false);
|
|
197
|
+
// - 未初始化 + activation=auto → 短 bootstrap;
|
|
198
|
+
// - activation=off → 外层不注册 section。
|
|
199
|
+
if (resolved.initialized) {
|
|
200
|
+
return renderFeelPrompt({ projectRoot: resolved.projectRoot, detected: true, language: config.language });
|
|
201
|
+
}
|
|
202
|
+
if (config.activation === 'always') {
|
|
203
|
+
return renderFeelPrompt({ projectRoot: resolved.projectRoot, detected: false, language: config.language });
|
|
204
|
+
}
|
|
205
|
+
return renderBootstrapPrompt({
|
|
206
|
+
projectRoot: resolved.projectRoot,
|
|
207
|
+
language: config.language,
|
|
208
|
+
enforce: config.enforce,
|
|
209
|
+
});
|
|
164
210
|
},
|
|
165
211
|
});
|
|
166
212
|
}
|
|
@@ -31,6 +31,80 @@ export function renderFeelPrompt(options = {}) {
|
|
|
31
31
|
return renderChinese(options);
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
/**
|
|
35
|
+
* 生成「未初始化工作区」的短 bootstrap 段落。
|
|
36
|
+
*
|
|
37
|
+
* 只在 `activation=auto` 且 `resolveWorkspace().initialized === false` 时注入;
|
|
38
|
+
* 不展开完整 Feel 纪律,避免把任意目录都灌成流程提示。
|
|
39
|
+
*
|
|
40
|
+
* @param {object} [options]
|
|
41
|
+
* @param {string} [options.projectRoot]
|
|
42
|
+
* @param {'zh-CN'|'en'} [options.language]
|
|
43
|
+
* @param {'off'|'project'|'always'} [options.enforce]
|
|
44
|
+
* @returns {string}
|
|
45
|
+
*/
|
|
46
|
+
export function renderBootstrapPrompt(options = {}) {
|
|
47
|
+
const language = options.language ?? 'zh-CN';
|
|
48
|
+
const enforce = options.enforce === 'off' || options.enforce === 'project' || options.enforce === 'always'
|
|
49
|
+
? options.enforce
|
|
50
|
+
: 'always';
|
|
51
|
+
return language === 'en' ? renderBootstrapEnglish(enforce, options.projectRoot) : renderBootstrapChinese(enforce, options.projectRoot);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* 未初始化 bootstrap(中文)。
|
|
56
|
+
* @param {'off'|'project'|'always'} enforce
|
|
57
|
+
* @param {string} [projectRoot]
|
|
58
|
+
* @returns {string}
|
|
59
|
+
*/
|
|
60
|
+
function renderBootstrapChinese(enforce, projectRoot) {
|
|
61
|
+
const where = typeof projectRoot === 'string' && projectRoot !== '' ? `(\`${projectRoot}\`)` : '';
|
|
62
|
+
const writeRule = enforce === 'always'
|
|
63
|
+
? '- 写文件(代码/文档):默认 `enforce=always`,写入门禁会**拒绝**,直到运行 `openfeel init` 并建立需求/阶段;或请用户把 profile 的 `enforce` 改为 `project` / `off`。'
|
|
64
|
+
: enforce === 'project'
|
|
65
|
+
? '- 写文件(代码/文档):当前 `enforce=project`,非 OpenFeel 工作区不拦截;建议先 `openfeel init` → `feel_req add` → `feel_stage add`。'
|
|
66
|
+
: '- 写文件(代码/文档):当前 `enforce=off`,不拦截;建议先 `openfeel init` → `feel_req add` → `feel_stage add`。';
|
|
67
|
+
|
|
68
|
+
return `<feel_mode>
|
|
69
|
+
OpenFeel 未初始化:当前工作区尚未检测到 \`.openfeel/\`${where}。
|
|
70
|
+
|
|
71
|
+
## 只读任务
|
|
72
|
+
- 只读任务可直接进行。
|
|
73
|
+
|
|
74
|
+
## 写文件
|
|
75
|
+
${writeRule}
|
|
76
|
+
- 用户要求使用 OpenFeel 时:先确认,再 \`openfeel init\` → \`feel_req add\` → \`feel_stage add\`。
|
|
77
|
+
- 不要假装 Feel 模式已激活;不要手改 \`.openfeel/flow.json\` / \`status.md\`。
|
|
78
|
+
</feel_mode>`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* 未初始化 bootstrap(英文)。
|
|
83
|
+
* @param {'off'|'project'|'always'} enforce
|
|
84
|
+
* @param {string} [projectRoot]
|
|
85
|
+
* @returns {string}
|
|
86
|
+
*/
|
|
87
|
+
function renderBootstrapEnglish(enforce, projectRoot) {
|
|
88
|
+
const where = typeof projectRoot === 'string' && projectRoot !== '' ? ` (${projectRoot})` : '';
|
|
89
|
+
const writeRule = enforce === 'always'
|
|
90
|
+
? '- Writing files (code/docs): the default `enforce=always` write guard will **deny** until you run `openfeel init` and create a requirement/stage; or ask the user to set the profile `enforce` to `project` / `off`.'
|
|
91
|
+
: enforce === 'project'
|
|
92
|
+
? '- Writing files (code/docs): `enforce=project` does not intercept non-OpenFeel workspaces; prefer `openfeel init` → `feel_req add` → `feel_stage add` first.'
|
|
93
|
+
: '- Writing files (code/docs): `enforce=off` does not intercept; prefer `openfeel init` → `feel_req add` → `feel_stage add` first.';
|
|
94
|
+
|
|
95
|
+
return `<feel_mode>
|
|
96
|
+
OpenFeel is not initialized: no \`.openfeel/\` detected in this workspace${where}.
|
|
97
|
+
|
|
98
|
+
## Read-only work
|
|
99
|
+
- Read-only work may proceed directly.
|
|
100
|
+
|
|
101
|
+
## Writing files
|
|
102
|
+
${writeRule}
|
|
103
|
+
- If the user wants OpenFeel: confirm first, then \`openfeel init\` → \`feel_req add\` → \`feel_stage add\`.
|
|
104
|
+
- Do not pretend Feel mode is active; never hand-edit \`.openfeel/flow.json\` / \`status.md\`.
|
|
105
|
+
</feel_mode>`;
|
|
106
|
+
}
|
|
107
|
+
|
|
34
108
|
/**
|
|
35
109
|
* 中文段落。
|
|
36
110
|
* @param {{ projectRoot?: string, detected?: boolean }} options
|
|
@@ -40,9 +114,10 @@ function renderChinese(options) {
|
|
|
40
114
|
const where = options.detected === true && options.projectRoot !== undefined
|
|
41
115
|
? `当前项目:\`${options.projectRoot}\`(已初始化 \`.openfeel/\`)。`
|
|
42
116
|
: '当前工作区尚未检测到 `.openfeel/`:若用户要求使用 Feel 流程,先提示运行 `openfeel init`。';
|
|
117
|
+
const status = options.detected === true ? 'Feel 模式已启用' : 'Feel 模式未初始化';
|
|
43
118
|
|
|
44
119
|
return `<feel_mode>
|
|
45
|
-
|
|
120
|
+
${status}:本会话在 OpenFeel 流程治理下工作。${where}
|
|
46
121
|
|
|
47
122
|
## 你的角色:调度者,不是执行者
|
|
48
123
|
你的价值是判断「该谁做」,而不是自己做。OpenFeel 的 15 个相位各有负责 Agent:
|
|
@@ -90,9 +165,10 @@ function renderEnglish(options) {
|
|
|
90
165
|
const where = options.detected === true && options.projectRoot !== undefined
|
|
91
166
|
? `Project: \`${options.projectRoot}\` (initialized \`.openfeel/\`).`
|
|
92
167
|
: 'No `.openfeel/` detected in this workspace: if the user asks for the Feel process, suggest `openfeel init` first.';
|
|
168
|
+
const status = options.detected === true ? 'Feel mode is active' : 'Feel mode is not initialized';
|
|
93
169
|
|
|
94
170
|
return `<feel_mode>
|
|
95
|
-
|
|
171
|
+
${status}: this session works under OpenFeel process governance. ${where}
|
|
96
172
|
|
|
97
173
|
## Your role: orchestrator, not implementer
|
|
98
174
|
Your value is deciding WHO acts, not acting yourself. The 15 pipeline phases each have an owning agent:
|