@birdie_moblie/open_spec 2.1.7 → 2.2.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.
Files changed (39) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/README.md +1 -1
  4. package/dist/cli/commands/ext.js +140 -21
  5. package/dist/cli/commands/init.js +22 -12
  6. package/dist/config/load.d.ts +4 -0
  7. package/dist/config/load.js +23 -5
  8. package/dist/config/preset-repo-tools.d.ts +36 -0
  9. package/dist/config/preset-repo-tools.js +114 -0
  10. package/dist/config/preset-repo.d.ts +164 -0
  11. package/dist/config/preset-repo.js +180 -0
  12. package/dist/config/preset-use.d.ts +79 -0
  13. package/dist/config/preset-use.js +282 -0
  14. package/dist/config/presets.d.ts +38 -113
  15. package/dist/config/presets.js +159 -84
  16. package/dist/config/schema.d.ts +136 -9
  17. package/dist/config/schema.js +30 -2
  18. package/dist/core/source-operations.js +6 -1
  19. package/dist/index.d.ts +15 -3
  20. package/dist/index.js +22 -28
  21. package/dist/marketplace/registry.d.ts +2 -2
  22. package/dist/marketplace/registry.js +6 -55
  23. package/dist/marketplace/upgrade.js +5 -3
  24. package/dist/shared/fs.d.ts +2 -0
  25. package/dist/shared/fs.js +6 -2
  26. package/dist/shared/git-snapshot.d.ts +18 -0
  27. package/dist/shared/git-snapshot.js +75 -0
  28. package/dist/shared/pkg.js +1 -1
  29. package/docs/getting-started.md +1 -1
  30. package/docs/guides/configuration.md +9 -2
  31. package/docs/guides/plugin-migration.md +5 -5
  32. package/docs/guides/ui-conventions-template.md +2 -2
  33. package/package.json +1 -2
  34. package/plugin.json +1 -1
  35. package/presets/backend-service.yaml +0 -19
  36. package/presets/flutter-mobile/plugin-hooks.json +0 -68
  37. package/presets/flutter-mobile/ui-conventions.md +0 -122
  38. package/presets/flutter-mobile.yaml +0 -205
  39. package/presets/web-product.yaml +0 -23
@@ -1,91 +1,180 @@
1
- import path from 'node:path';
2
- import { parse as parseYaml } from 'yaml';
1
+ import { isDeepStrictEqual } from 'node:util';
3
2
  import { z } from 'zod';
4
- import { exists, listFilesRecursive, readText, writeText } from '../shared/fs.js';
3
+ import { exists, readText, writeText } from '../shared/fs.js';
5
4
  import { AppError } from '../shared/errors.js';
6
- import { packageRoot } from '../shared/pkg.js';
7
5
  import { resolveInside } from '../shared/paths.js';
8
- import { PluginMinVersionSchema, ProjectConfigPatchSchema, ProjectConfigSchema } from './schema.js';
6
+ import { ProjectConfigSchema } from './schema.js';
9
7
  import { compareVersions } from '../shared/version.js';
10
- const ScaffoldEntrySchema = z.object({
8
+ export const ScaffoldEntrySchema = z.object({
11
9
  /** 项目内目标路径,通常与某个 required file hook 的 path 一致 */
12
10
  path: z.string().min(1),
13
- /** 相对 presets/ 目录的模板文件 */
11
+ /** 模板文件,相对该 preset 所在目录 */
14
12
  template: z.string().min(1),
15
13
  description: z.string().optional(),
16
14
  }).strict();
17
- export const TeamPresetSchema = z.object({
18
- schemaVersion: z.literal('openspec.team-preset.v1'),
19
- id: z.string(),
20
- version: z.string(),
21
- title: z.string(),
22
- description: z.string().optional(),
23
- marketplaces: z.array(z.object({
24
- name: z.string(),
25
- repo: z.string(),
26
- ref: z.string(),
27
- }).strict()).optional(),
28
- plugins: z.array(z.object({
29
- name: z.string(),
30
- marketplace: z.string(),
31
- minVersion: PluginMinVersionSchema.optional(),
32
- }).strict()).optional(),
33
- configPatch: ProjectConfigPatchSchema.optional(),
34
- /** 应用 preset 时按需生成的项目文件:只创建、不覆盖 */
35
- scaffold: z.array(ScaffoldEntrySchema).optional(),
36
- notes: z.array(z.string()).optional(),
37
- warnings: z.array(z.string()).optional(),
38
- }).strict();
39
- export function presetsDir() {
40
- return path.join(packageRoot(), 'presets');
41
- }
42
- export function listPresets() {
43
- return listFilesRecursive(presetsDir())
44
- .filter((file) => file.endsWith('.yaml') || file.endsWith('.yml'))
45
- .map((file) => loadPresetFile(file));
46
- }
47
- export function loadPreset(id) {
48
- const file = path.join(presetsDir(), `${id}.yaml`);
49
- if (!exists(file))
50
- throw new AppError('preset_not_found', `找不到 preset: ${id}`);
51
- return loadPresetFile(file);
52
- }
53
- export function mergePreset(base, preset) {
54
- const patch = preset.configPatch ?? { schema: 'spec-product' };
15
+ /**
16
+ * 生效配置 = preset 基线 + 项目层,项目层优先:分类按名覆盖(null 删除);hook 同 ID 整条替换并保留基线位置,
17
+ * disableHooks 按 ID 去掉基线 hook,项目新增 hook 追加在后;验收设置按字段覆盖;插件取较高 minVersion;
18
+ * marketplace 以项目声明为准,基线只补缺;tools 只来自项目层。没有基线时项目层原样即生效配置。
19
+ */
20
+ export function resolveEffectiveConfig(layer, baseline) {
21
+ const { disableHooks = [], hookOrder, ...project } = layer;
22
+ if (!baseline) {
23
+ if (disableHooks.length > 0) {
24
+ throw new AppError('disable_hooks_without_preset', `disableHooks 只能禁用 preset 中的 hook,当前未引用 preset: ${disableHooks.join(', ')}`);
25
+ }
26
+ if (!hookOrder)
27
+ return parseEffective(project, '项目配置不合法');
28
+ return parseEffective({ ...project, hooks: orderHooks(project.hooks ?? [], hookOrder) }, '项目配置不合法');
29
+ }
30
+ const base = baseline.patch;
31
+ const baseHooks = base.hooks ?? [];
32
+ const baseHookIds = new Set(baseHooks.map((hook) => hook.id));
33
+ const ownHooks = project.hooks ?? [];
34
+ const unknown = disableHooks.filter((id) => !baseHookIds.has(id));
35
+ if (unknown.length > 0) {
36
+ throw new AppError('unknown_disabled_hook', `disableHooks 引用了 preset ${baseline.id} 中不存在的 hook: ${unknown.join(', ')}`);
37
+ }
38
+ const conflicting = ownHooks.filter((hook) => disableHooks.includes(hook.id)).map((hook) => hook.id);
39
+ if (conflicting.length > 0) {
40
+ throw new AppError('hook_disabled_and_overridden', `hook 不能同时被禁用和覆盖: ${conflicting.join(', ')}`);
41
+ }
42
+ const overrides = new Map(ownHooks.map((hook) => [hook.id, hook]));
43
+ const hooks = orderHooks([
44
+ ...baseHooks.filter((hook) => !disableHooks.includes(hook.id)).map((hook) => overrides.get(hook.id) ?? hook),
45
+ ...ownHooks.filter((hook) => !baseHookIds.has(hook.id)),
46
+ ], hookOrder);
47
+ const reviewScope = { ...base.verify?.reviewScope, ...project.verify?.reviewScope };
48
+ const failurePolicy = { ...base.verify?.failurePolicy, ...project.verify?.failurePolicy };
49
+ const plugins = mergePluginDependencies(base.plugins ?? [], project.plugins ?? []);
50
+ const marketplaces = [
51
+ ...(project.marketplaces ?? []),
52
+ ...(base.marketplaces ?? []).filter((item) => !(project.marketplaces ?? []).some((existing) => existing.name === item.name)),
53
+ ];
54
+ const taskCategories = { ...base.taskCategories, ...project.taskCategories };
55
55
  const merged = {
56
56
  schema: 'spec-product',
57
- taskCategories: { ...base.taskCategories, ...patch.taskCategories },
58
- hooks: upsertBy(base.hooks ?? [], patch.hooks ?? [], (item) => item.id),
59
- verify: {
60
- reviewScope: {
61
- ...base.verify?.reviewScope,
62
- ...patch.verify?.reviewScope,
57
+ ...(Object.keys(taskCategories).length > 0 ? { taskCategories } : {}),
58
+ ...(hooks.length > 0 ? { hooks } : {}),
59
+ ...(base.verify || project.verify ? {
60
+ verify: {
61
+ ...(Object.keys(reviewScope).length > 0 ? { reviewScope } : {}),
62
+ ...(Object.keys(failurePolicy).length > 0 ? { failurePolicy } : {}),
63
63
  },
64
- failurePolicy: {
65
- ...base.verify?.failurePolicy,
66
- ...patch.verify?.failurePolicy,
67
- },
68
- },
69
- plugins: mergePluginDependencies(base.plugins ?? [], preset.plugins ?? []),
70
- // 宿主已声明的 marketplace 保留自己的 repo/ref:preset 的 ref 只是基线,不能把独立升级过的宿主降回去。
71
- marketplaces: [
72
- ...(base.marketplaces ?? []),
73
- ...(preset.marketplaces ?? []).filter((item) => !(base.marketplaces ?? []).some((existing) => existing.name === item.name)),
74
- ],
75
- tools: base.tools,
64
+ } : {}),
65
+ ...(plugins.length > 0 ? { plugins } : {}),
66
+ ...(marketplaces.length > 0 ? { marketplaces } : {}),
67
+ ...(project.tools ? { tools: project.tools } : {}),
76
68
  };
77
- return ProjectConfigSchema.parse(merged);
69
+ return parseEffective(merged, `preset ${baseline.id} 与项目配置合成后不合法`);
70
+ }
71
+ /** 逐项标注生效配置的来源,供查看生效配置、升级提示遮蔽与迁移使用。 */
72
+ export function describeOrigins(layer, baseline) {
73
+ const base = baseline?.patch ?? {};
74
+ const classify = (inBase, inProject) => (inBase ? (inProject ? 'override' : 'preset') : 'project');
75
+ const taskCategories = {};
76
+ for (const id of new Set([...Object.keys(base.taskCategories ?? {}), ...Object.keys(layer.taskCategories ?? {})])) {
77
+ const own = layer.taskCategories?.[id];
78
+ taskCategories[id] = own === null ? 'removed' : classify(Boolean(base.taskCategories?.[id]), own !== undefined);
79
+ }
80
+ const hooks = {};
81
+ const ownHookIds = new Set((layer.hooks ?? []).map((hook) => hook.id));
82
+ for (const hook of base.hooks ?? []) {
83
+ hooks[hook.id] = (layer.disableHooks ?? []).includes(hook.id) ? 'removed' : classify(true, ownHookIds.has(hook.id));
84
+ }
85
+ for (const id of ownHookIds)
86
+ hooks[id] ??= 'project';
87
+ const selector = (item) => `${item.name}@${item.marketplace}`;
88
+ const plugins = {};
89
+ const ownPlugins = new Set((layer.plugins ?? []).map(selector));
90
+ // 同一插件取较高的最低版本:生效条目仍等于 preset 的条目时,项目的声明没有改变生效值。
91
+ const effectivePlugins = new Map(mergePluginDependencies(base.plugins ?? [], layer.plugins ?? []).map((item) => [selector(item), item]));
92
+ for (const item of base.plugins ?? []) {
93
+ const id = selector(item);
94
+ plugins[id] = ownPlugins.has(id) && !isDeepStrictEqual(effectivePlugins.get(id), item) ? 'override' : 'preset';
95
+ }
96
+ for (const id of ownPlugins)
97
+ plugins[id] ??= 'project';
98
+ const marketplaces = {};
99
+ const ownMarkets = new Set((layer.marketplaces ?? []).map((item) => item.name));
100
+ for (const item of base.marketplaces ?? [])
101
+ marketplaces[item.name] = classify(true, ownMarkets.has(item.name));
102
+ for (const name of ownMarkets)
103
+ marketplaces[name] ??= 'project';
104
+ const verifyFields = (value) => Object.entries(value ?? {})
105
+ .flatMap(([group, fields]) => Object.keys(fields ?? {}).map((field) => `${group}.${field}`));
106
+ const verify = {};
107
+ const ownVerify = new Set(verifyFields(layer.verify));
108
+ for (const id of verifyFields(base.verify))
109
+ verify[id] = classify(true, ownVerify.has(id));
110
+ for (const id of ownVerify)
111
+ verify[id] ??= 'project';
112
+ return { taskCategories, hooks, plugins, marketplaces, verify };
113
+ }
114
+ /**
115
+ * 按条目比较两个基线:分类按名、hook 按 ID、插件按 selector、marketplace 按名;
116
+ * 验收设置与合成一样按字段,条目为 `<分组>.<字段>`(如 reviewScope.excludeDirs)。
117
+ */
118
+ export function diffBaselines(previous, next) {
119
+ const keyed = (patch) => ({
120
+ taskCategories: new Map(Object.entries(patch.taskCategories ?? {}).map(([id, value]) => [id, JSON.stringify(value)])),
121
+ hooks: new Map((patch.hooks ?? []).map((hook) => [hook.id, JSON.stringify(hook)])),
122
+ plugins: new Map((patch.plugins ?? []).map((item) => [`${item.name}@${item.marketplace}`, JSON.stringify(item)])),
123
+ marketplaces: new Map((patch.marketplaces ?? []).map((item) => [item.name, JSON.stringify(item)])),
124
+ verify: new Map(Object.entries(patch.verify ?? {}).flatMap(([group, fields]) => Object.entries(fields ?? {}).map(([field, value]) => [`${group}.${field}`, JSON.stringify(value)]))),
125
+ });
126
+ const before = keyed(previous);
127
+ const after = keyed(next);
128
+ const sections = Object.keys(before);
129
+ return Object.fromEntries(sections.map((section) => {
130
+ const old = before[section];
131
+ const now = after[section];
132
+ return [section, {
133
+ added: [...now.keys()].filter((id) => !old.has(id)),
134
+ changed: [...now.keys()].filter((id) => old.has(id) && old.get(id) !== now.get(id)),
135
+ removed: [...old.keys()].filter((id) => !now.has(id)),
136
+ }];
137
+ }));
138
+ }
139
+ /** 按 hookOrder 重排各阶段:列出的 ID 依次在前,其余保持原相对顺序;各阶段仍占原来的位置。 */
140
+ function orderHooks(hooks, hookOrder) {
141
+ if (!hookOrder)
142
+ return hooks;
143
+ const result = [...hooks];
144
+ for (const [stage, ids] of Object.entries(hookOrder)) {
145
+ const slots = hooks.flatMap((hook, index) => (hook.stage === stage ? [index] : []));
146
+ const inStage = slots.map((index) => hooks[index]);
147
+ const unknown = ids.filter((id) => !inStage.some((hook) => hook.id === id));
148
+ if (unknown.length > 0) {
149
+ throw new AppError('unknown_ordered_hook', `hookOrder.${stage} 引用了该阶段不存在的 hook: ${unknown.join(', ')}`);
150
+ }
151
+ const ordered = [
152
+ ...ids.map((id) => inStage.find((hook) => hook.id === id)),
153
+ ...inStage.filter((hook) => !ids.includes(hook.id)),
154
+ ];
155
+ slots.forEach((slot, position) => { result[slot] = ordered[position]; });
156
+ }
157
+ return result;
158
+ }
159
+ function parseEffective(config, message) {
160
+ const result = ProjectConfigSchema.safeParse(config);
161
+ if (!result.success)
162
+ throw new AppError('invalid_config', message, result.error.issues);
163
+ return result.data;
78
164
  }
79
165
  /**
80
166
  * 把 preset 声明的模板文件放到项目里。已存在的文件一律不动——
81
- * 团队填写后的内容不能被重复 init 覆盖;模板本身带 openspec:fill-me 标记,未填写时 file hook 不会放行。
167
+ * 团队填写后的内容不能被重复引用或升级覆盖;模板本身带 openspec:fill-me 标记,未填写时 file hook 不会放行。
168
+ * 先核对全部模板与目标路径再写入,缺模板时不留下部分生成的文件。
82
169
  */
83
- export function scaffoldPreset(projectRoot, preset) {
84
- return (preset.scaffold ?? []).map((entry) => {
85
- const source = resolveInside(presetsDir(), entry.template, 'preset_template_outside_presets');
170
+ export function scaffoldFrom(projectRoot, templateRoot, presetId, entries) {
171
+ const planned = entries.map((entry) => {
172
+ const source = resolveInside(templateRoot, entry.template, 'preset_template_outside_presets');
86
173
  if (!exists(source))
87
- throw new AppError('preset_template_missing', `preset ${preset.id} 缺少模板文件: ${entry.template}`);
88
- const target = resolveInside(projectRoot, entry.path, 'scaffold_path_outside_project');
174
+ throw new AppError('preset_template_missing', `preset ${presetId} 缺少模板文件: ${entry.template}`);
175
+ return { entry, source, target: resolveInside(projectRoot, entry.path, 'scaffold_path_outside_project') };
176
+ });
177
+ return planned.map(({ entry, source, target }) => {
89
178
  if (exists(target))
90
179
  return { path: entry.path, status: 'exists', description: entry.description };
91
180
  writeText(target, readText(source));
@@ -98,13 +187,6 @@ function upsertBy(base, patch, key) {
98
187
  values.set(key(item), item);
99
188
  return [...values.values()];
100
189
  }
101
- /** 合并时保留了宿主 ref、且与 preset 基线不同的 marketplace,供 init / preset apply 提示。 */
102
- export function keptMarketplaceRefs(base, preset) {
103
- return (preset.marketplaces ?? []).flatMap((item) => {
104
- const existing = (base.marketplaces ?? []).find((candidate) => candidate.name === item.name);
105
- return existing && existing.ref !== item.ref ? [{ name: item.name, ref: existing.ref, presetRef: item.ref }] : [];
106
- });
107
- }
108
190
  /** 同一插件取较高的 minVersion:宿主提高过的最低版本不被 preset 默认值调低。 */
109
191
  function mergePluginDependencies(base, patch) {
110
192
  const previous = new Map(base.map((item) => [`${item.name}@${item.marketplace}`, item]));
@@ -115,11 +197,4 @@ function mergePluginDependencies(base, patch) {
115
197
  return (compareVersions(existing, item.minVersion) ?? 0) > 0 ? { ...item, minVersion: existing } : item;
116
198
  }), (item) => `${item.name}@${item.marketplace}`);
117
199
  }
118
- function loadPresetFile(file) {
119
- const result = TeamPresetSchema.safeParse(parseYaml(readText(file)));
120
- if (!result.success) {
121
- throw new AppError('invalid_preset', `preset 不合法: ${file}`, result.error.issues);
122
- }
123
- return result.data;
124
- }
125
200
  //# sourceMappingURL=presets.js.map
@@ -48,10 +48,21 @@ export declare const HookSchema: z.ZodObject<{
48
48
  category: z.ZodOptional<z.ZodString>;
49
49
  description: z.ZodOptional<z.ZodString>;
50
50
  }, z.core.$strict>;
51
+ /** 团队 preset 引用:独立 Git 仓库中的一个 preset,ref 同样必须不可变。 */
52
+ declare const PresetReferenceSchema: z.ZodObject<{
53
+ repo: z.ZodString;
54
+ ref: z.ZodString;
55
+ id: z.ZodString;
56
+ }, z.core.$strict>;
51
57
  /** 插件最低版本:与插件 manifest 的 version 比较,只接受 major.minor.patch。 */
52
58
  export declare const PluginMinVersionSchema: z.ZodString;
53
59
  declare const ProjectConfigObjectSchema: z.ZodObject<{
54
60
  schema: z.ZodLiteral<"spec-product">;
61
+ preset: z.ZodOptional<z.ZodObject<{
62
+ repo: z.ZodString;
63
+ ref: z.ZodString;
64
+ id: z.ZodString;
65
+ }, z.core.$strict>>;
55
66
  taskCategories: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodNullable<z.ZodObject<{
56
67
  notes: z.ZodOptional<z.ZodString>;
57
68
  }, z.core.$strict>>>>;
@@ -123,12 +134,31 @@ declare const ProjectConfigObjectSchema: z.ZodObject<{
123
134
  codex: "codex";
124
135
  cursor: "cursor";
125
136
  }>>>;
137
+ disableHooks: z.ZodOptional<z.ZodArray<z.ZodString>>;
138
+ hookOrder: z.ZodOptional<z.ZodRecord<z.ZodEnum<{
139
+ collect: "collect";
140
+ parse: "parse";
141
+ tech: "tech";
142
+ plan: "plan";
143
+ execute: "execute";
144
+ verify: "verify";
145
+ }> & z.core.$partial, z.ZodArray<z.ZodString>>>;
126
146
  }, z.core.$strict>;
127
147
  export declare const ProjectConfigPatchSchema: z.ZodObject<{
128
- schema: z.ZodOptional<z.ZodLiteral<"spec-product">>;
148
+ verify: z.ZodOptional<z.ZodOptional<z.ZodObject<{
149
+ reviewScope: z.ZodOptional<z.ZodObject<{
150
+ includeDirs: z.ZodOptional<z.ZodArray<z.ZodString>>;
151
+ excludeDirs: z.ZodOptional<z.ZodArray<z.ZodString>>;
152
+ }, z.core.$strict>>;
153
+ failurePolicy: z.ZodOptional<z.ZodObject<{
154
+ failOnHookFailure: z.ZodOptional<z.ZodBoolean>;
155
+ failOnRequiredCommandFailure: z.ZodOptional<z.ZodBoolean>;
156
+ }, z.core.$strict>>;
157
+ }, z.core.$strict>>>;
129
158
  taskCategories: z.ZodOptional<z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodNullable<z.ZodObject<{
130
159
  notes: z.ZodOptional<z.ZodString>;
131
160
  }, z.core.$strict>>>>>;
161
+ schema: z.ZodOptional<z.ZodLiteral<"spec-product">>;
132
162
  hooks: z.ZodOptional<z.ZodOptional<z.ZodArray<z.ZodObject<{
133
163
  id: z.ZodString;
134
164
  stage: z.ZodEnum<{
@@ -172,7 +202,80 @@ export declare const ProjectConfigPatchSchema: z.ZodObject<{
172
202
  category: z.ZodOptional<z.ZodString>;
173
203
  description: z.ZodOptional<z.ZodString>;
174
204
  }, z.core.$strict>>>>;
175
- verify: z.ZodOptional<z.ZodOptional<z.ZodObject<{
205
+ plugins: z.ZodOptional<z.ZodOptional<z.ZodArray<z.ZodObject<{
206
+ name: z.ZodString;
207
+ marketplace: z.ZodString;
208
+ minVersion: z.ZodOptional<z.ZodString>;
209
+ }, z.core.$strict>>>>;
210
+ marketplaces: z.ZodOptional<z.ZodOptional<z.ZodArray<z.ZodObject<{
211
+ name: z.ZodString;
212
+ repo: z.ZodString;
213
+ ref: z.ZodString;
214
+ }, z.core.$strict>>>>;
215
+ tools: z.ZodOptional<z.ZodOptional<z.ZodArray<z.ZodEnum<{
216
+ claude: "claude";
217
+ codex: "codex";
218
+ cursor: "cursor";
219
+ }>>>>;
220
+ }, z.core.$strict>;
221
+ /**
222
+ * 项目层:只做结构与重复校验。分类引用等跨层约束要在合成生效配置后才能判定,
223
+ * 因为 hook 引用的分类可能只由 preset 定义。
224
+ */
225
+ export declare const ProjectConfigLayerSchema: z.ZodObject<{
226
+ schema: z.ZodLiteral<"spec-product">;
227
+ preset: z.ZodOptional<z.ZodObject<{
228
+ repo: z.ZodString;
229
+ ref: z.ZodString;
230
+ id: z.ZodString;
231
+ }, z.core.$strict>>;
232
+ taskCategories: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodNullable<z.ZodObject<{
233
+ notes: z.ZodOptional<z.ZodString>;
234
+ }, z.core.$strict>>>>;
235
+ hooks: z.ZodOptional<z.ZodArray<z.ZodObject<{
236
+ id: z.ZodString;
237
+ stage: z.ZodEnum<{
238
+ collect: "collect";
239
+ parse: "parse";
240
+ tech: "tech";
241
+ plan: "plan";
242
+ execute: "execute";
243
+ verify: "verify";
244
+ }>;
245
+ type: z.ZodOptional<z.ZodEnum<{
246
+ command: "command";
247
+ skill: "skill";
248
+ notes: "notes";
249
+ file: "file";
250
+ check: "check";
251
+ }>>;
252
+ capability: z.ZodOptional<z.ZodEnum<{
253
+ requirements: "requirements";
254
+ interfaces: "interfaces";
255
+ design: "design";
256
+ }>>;
257
+ use: z.ZodOptional<z.ZodString>;
258
+ operation: z.ZodOptional<z.ZodLiteral<"preview">>;
259
+ input: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
260
+ inputFiles: z.ZodOptional<z.ZodArray<z.ZodString>>;
261
+ name: z.ZodOptional<z.ZodString>;
262
+ notes: z.ZodOptional<z.ZodString>;
263
+ taskCategories: z.ZodOptional<z.ZodArray<z.ZodString>>;
264
+ command: z.ZodOptional<z.ZodString>;
265
+ target: z.ZodOptional<z.ZodEnum<{
266
+ project: "project";
267
+ inScopeChangedFiles: "inScopeChangedFiles";
268
+ }>>;
269
+ include: z.ZodOptional<z.ZodArray<z.ZodString>>;
270
+ exclude: z.ZodOptional<z.ZodArray<z.ZodString>>;
271
+ allowEmpty: z.ZodOptional<z.ZodBoolean>;
272
+ required: z.ZodOptional<z.ZodBoolean>;
273
+ purpose: z.ZodOptional<z.ZodString>;
274
+ path: z.ZodOptional<z.ZodString>;
275
+ category: z.ZodOptional<z.ZodString>;
276
+ description: z.ZodOptional<z.ZodString>;
277
+ }, z.core.$strict>>>;
278
+ verify: z.ZodOptional<z.ZodObject<{
176
279
  reviewScope: z.ZodOptional<z.ZodObject<{
177
280
  includeDirs: z.ZodOptional<z.ZodArray<z.ZodString>>;
178
281
  excludeDirs: z.ZodOptional<z.ZodArray<z.ZodString>>;
@@ -181,25 +284,39 @@ export declare const ProjectConfigPatchSchema: z.ZodObject<{
181
284
  failOnHookFailure: z.ZodOptional<z.ZodBoolean>;
182
285
  failOnRequiredCommandFailure: z.ZodOptional<z.ZodBoolean>;
183
286
  }, z.core.$strict>>;
184
- }, z.core.$strict>>>;
185
- plugins: z.ZodOptional<z.ZodOptional<z.ZodArray<z.ZodObject<{
287
+ }, z.core.$strict>>;
288
+ plugins: z.ZodOptional<z.ZodArray<z.ZodObject<{
186
289
  name: z.ZodString;
187
290
  marketplace: z.ZodString;
188
291
  minVersion: z.ZodOptional<z.ZodString>;
189
- }, z.core.$strict>>>>;
190
- marketplaces: z.ZodOptional<z.ZodOptional<z.ZodArray<z.ZodObject<{
292
+ }, z.core.$strict>>>;
293
+ marketplaces: z.ZodOptional<z.ZodArray<z.ZodObject<{
191
294
  name: z.ZodString;
192
295
  repo: z.ZodString;
193
296
  ref: z.ZodString;
194
- }, z.core.$strict>>>>;
195
- tools: z.ZodOptional<z.ZodOptional<z.ZodArray<z.ZodEnum<{
297
+ }, z.core.$strict>>>;
298
+ tools: z.ZodOptional<z.ZodArray<z.ZodEnum<{
196
299
  claude: "claude";
197
300
  codex: "codex";
198
301
  cursor: "cursor";
199
- }>>>>;
302
+ }>>>;
303
+ disableHooks: z.ZodOptional<z.ZodArray<z.ZodString>>;
304
+ hookOrder: z.ZodOptional<z.ZodRecord<z.ZodEnum<{
305
+ collect: "collect";
306
+ parse: "parse";
307
+ tech: "tech";
308
+ plan: "plan";
309
+ execute: "execute";
310
+ verify: "verify";
311
+ }> & z.core.$partial, z.ZodArray<z.ZodString>>>;
200
312
  }, z.core.$strict>;
201
313
  export declare const ProjectConfigSchema: z.ZodObject<{
202
314
  schema: z.ZodLiteral<"spec-product">;
315
+ preset: z.ZodOptional<z.ZodObject<{
316
+ repo: z.ZodString;
317
+ ref: z.ZodString;
318
+ id: z.ZodString;
319
+ }, z.core.$strict>>;
203
320
  taskCategories: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodNullable<z.ZodObject<{
204
321
  notes: z.ZodOptional<z.ZodString>;
205
322
  }, z.core.$strict>>>>;
@@ -271,9 +388,19 @@ export declare const ProjectConfigSchema: z.ZodObject<{
271
388
  codex: "codex";
272
389
  cursor: "cursor";
273
390
  }>>>;
391
+ disableHooks: z.ZodOptional<z.ZodArray<z.ZodString>>;
392
+ hookOrder: z.ZodOptional<z.ZodRecord<z.ZodEnum<{
393
+ collect: "collect";
394
+ parse: "parse";
395
+ tech: "tech";
396
+ plan: "plan";
397
+ execute: "execute";
398
+ verify: "verify";
399
+ }> & z.core.$partial, z.ZodArray<z.ZodString>>>;
274
400
  }, z.core.$strict>;
275
401
  export type ProjectConfig = z.infer<typeof ProjectConfigObjectSchema>;
276
402
  export type ProjectHook = z.infer<typeof HookSchema>;
403
+ export type PresetReference = z.infer<typeof PresetReferenceSchema>;
277
404
  export declare function effectiveTaskCategories(config: Pick<ProjectConfig, 'taskCategories'>): Record<string, {
278
405
  notes: string;
279
406
  }>;
@@ -91,6 +91,12 @@ export const HookSchema = z.object({
91
91
  });
92
92
  const TaskCategorySchema = z.object({ notes: z.string().optional() }).strict();
93
93
  const ImmutableMarketplaceRefSchema = z.string().min(1).refine((value) => /^[0-9a-f]{40}$/i.test(value) || /^refs\/tags\/[^/]+$/.test(value), 'marketplace ref 必须是完整 Git SHA 或 refs/tags/<tag>');
94
+ /** 团队 preset 引用:独立 Git 仓库中的一个 preset,ref 同样必须不可变。 */
95
+ const PresetReferenceSchema = z.object({
96
+ repo: z.string().min(1),
97
+ ref: z.string().min(1).refine((value) => /^[0-9a-f]{40}$/i.test(value) || /^refs\/tags\/[^/]+$/.test(value), 'preset ref 必须是完整 Git SHA 或 refs/tags/<tag>'),
98
+ id: PathTokenSchema,
99
+ }).strict();
94
100
  const MarketplaceSchema = z.object({
95
101
  name: PathTokenSchema,
96
102
  repo: z.string().min(1),
@@ -115,15 +121,22 @@ const VerifySchema = z.object({
115
121
  }).strict();
116
122
  const ProjectConfigObjectSchema = z.object({
117
123
  schema: z.literal('spec-product'),
124
+ /** 引用的团队 preset;其余字段是项目层,与 preset 基线合成生效配置。 */
125
+ preset: PresetReferenceSchema.optional(),
118
126
  taskCategories: z.record(CategoryIdSchema, TaskCategorySchema.nullable()).optional(),
119
127
  hooks: z.array(HookSchema).optional(),
120
128
  verify: VerifySchema.optional(),
121
129
  plugins: z.array(PluginDependencySchema).optional(),
122
130
  marketplaces: z.array(MarketplaceSchema).optional(),
123
131
  tools: z.array(z.enum(['claude', 'codex', 'cursor'])).optional(),
132
+ /** 按 ID 禁用 preset 基线中的 hook;只属于项目层,不进入生效配置。 */
133
+ disableHooks: z.array(PathTokenSchema).optional(),
134
+ /** 按阶段指定 hook 顺序:列出的 ID 依次排在该阶段最前,其余保持原相对顺序;只属于项目层。 */
135
+ hookOrder: z.partialRecord(z.enum(HOOK_STAGES), z.array(PathTokenSchema)).optional(),
124
136
  }).strict();
125
- export const ProjectConfigPatchSchema = ProjectConfigObjectSchema.partial();
126
- export const ProjectConfigSchema = ProjectConfigObjectSchema.superRefine((config, ctx) => {
137
+ export const ProjectConfigPatchSchema = ProjectConfigObjectSchema.omit({ preset: true, disableHooks: true, hookOrder: true }).partial();
138
+ /** 单层内即可判定的约束:重复项。项目层与生效配置都要满足。 */
139
+ function checkDuplicates(config, ctx) {
127
140
  const ids = new Set();
128
141
  const collectCapabilities = new Set();
129
142
  for (const [index, hook] of (config.hooks ?? []).entries()) {
@@ -155,6 +168,21 @@ export const ProjectConfigSchema = ProjectConfigObjectSchema.superRefine((config
155
168
  if (new Set(config.tools ?? []).size !== (config.tools ?? []).length) {
156
169
  ctx.addIssue({ code: 'custom', path: ['tools'], message: 'tools 不能重复' });
157
170
  }
171
+ if (new Set(config.disableHooks ?? []).size !== (config.disableHooks ?? []).length) {
172
+ ctx.addIssue({ code: 'custom', path: ['disableHooks'], message: 'disableHooks 不能重复' });
173
+ }
174
+ for (const [stage, ids] of Object.entries(config.hookOrder ?? {})) {
175
+ if (new Set(ids).size !== ids.length)
176
+ ctx.addIssue({ code: 'custom', path: ['hookOrder', stage], message: `hookOrder.${stage} 不能重复` });
177
+ }
178
+ }
179
+ /**
180
+ * 项目层:只做结构与重复校验。分类引用等跨层约束要在合成生效配置后才能判定,
181
+ * 因为 hook 引用的分类可能只由 preset 定义。
182
+ */
183
+ export const ProjectConfigLayerSchema = ProjectConfigObjectSchema.superRefine(checkDuplicates);
184
+ export const ProjectConfigSchema = ProjectConfigObjectSchema.superRefine((config, ctx) => {
185
+ checkDuplicates(config, ctx);
158
186
  const categories = new Set(Object.keys(effectiveTaskCategories(config)));
159
187
  for (const [index, hook] of (config.hooks ?? []).entries()) {
160
188
  for (const category of hook.taskCategories ?? []) {
@@ -1,10 +1,11 @@
1
1
  import path from 'node:path';
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import { configPathOf } from '../config/load.js';
4
+ import { presetLockPath } from '../config/preset-repo.js';
4
5
  import { resolveAdapterEntry } from '../marketplace/adapters.js';
5
6
  import { readPluginInput, runPluginCommand, sameIdentity } from '../hooks/plugin-runtime.js';
6
7
  import { sha256File } from '../shared/hash.js';
7
- import { readText, writeJson } from '../shared/fs.js';
8
+ import { exists, readText, writeJson } from '../shared/fs.js';
8
9
  import { AppError } from '../shared/errors.js';
9
10
  import { artifactPath, artifactRel, requireChange } from './change.js';
10
11
  import { loadLedger } from './sources.js';
@@ -19,6 +20,10 @@ export function operationContext(root, change, binding, outputDir, inputFile, wo
19
20
  const configPath = configPathOf(root);
20
21
  if (configPath)
21
22
  params.files.push(path.relative(root, configPath));
23
+ // 引用 preset 时生效规则还取决于锁定的 preset 快照,锁变化同样要让指纹失效。
24
+ const presetLock = presetLockPath(root);
25
+ if (exists(presetLock))
26
+ params.files.push(path.relative(root, presetLock));
22
27
  if (binding.kind === 'adapter') {
23
28
  if (state.sourceRevision !== state.collectedRevision)
24
29
  throw new AppError('source_not_collected', '来源尚未完成采集');
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { listPresets, loadPreset, type ScaffoldResult } from './config/presets.js';
1
+ import { type ScaffoldResult } from './config/presets.js';
2
2
  import { effectiveTaskCategories, type ProjectConfig } from './config/schema.js';
3
3
  import { createChange, listChangeNames, requireChange, resolveChangeName } from './core/change.js';
4
4
  import { nextAction } from './core/flow.js';
@@ -22,6 +22,11 @@ export declare function project(cwd?: string): {
22
22
  projectRoot: string;
23
23
  config: {
24
24
  schema: "spec-product";
25
+ preset?: {
26
+ repo: string;
27
+ ref: string;
28
+ id: string;
29
+ } | undefined;
25
30
  taskCategories?: Record<string, {
26
31
  notes?: string | undefined;
27
32
  } | null> | undefined;
@@ -69,12 +74,19 @@ export declare function project(cwd?: string): {
69
74
  ref: string;
70
75
  }[] | undefined;
71
76
  tools?: ("claude" | "codex" | "cursor")[] | undefined;
77
+ disableHooks?: string[] | undefined;
78
+ hookOrder?: Partial<Record<"collect" | "parse" | "tech" | "plan" | "execute" | "verify", string[]>> | undefined;
72
79
  };
73
80
  };
74
81
  export declare function initProject(input: {
75
82
  cwd?: string;
76
83
  tools?: HostId[];
77
- preset?: string;
84
+ /** 引用 preset 仓库中的 preset;等同于初始化后执行 preset use */
85
+ preset?: {
86
+ id: string;
87
+ repo: string;
88
+ ref: string;
89
+ };
78
90
  yes?: boolean;
79
91
  skipPlugins?: boolean;
80
92
  /** 每个耗时步骤开始前调用一次,供 CLI 在终端反馈进度;缺省静默。 */
@@ -92,7 +104,7 @@ export declare function initProject(input: {
92
104
  }>;
93
105
  scaffold: ScaffoldResult[];
94
106
  };
95
- export { createChange, listChangeNames, requireChange, resolveChangeName, nextAction, buildInstructions, addSources, collect, abortUpdate, parseSourceArg, parseSourceFileArg, createWorker, acceptWorker, answerWorker, cancelWorker, approveGate, rejectGate, isKnownGate, runVerifyCommands, recordEvidence, writeReport, renderReportMarkdown, requestFix, changedFilesFromState, archiveChange, collectPendingWork, renderPendingWork, listPresets, loadPreset, addMarketplace, listMarketplaces, installPlugin, verifyDelivery, verifyInstallCli, effectiveTaskCategories, BUILTIN_MARKETPLACE, assertPluginsPresent, inspectMarketplaceDependencies, inspectHookEntries, };
107
+ export { createChange, listChangeNames, requireChange, resolveChangeName, nextAction, buildInstructions, addSources, collect, abortUpdate, parseSourceArg, parseSourceFileArg, createWorker, acceptWorker, answerWorker, cancelWorker, approveGate, rejectGate, isKnownGate, runVerifyCommands, recordEvidence, writeReport, renderReportMarkdown, requestFix, changedFilesFromState, archiveChange, collectPendingWork, renderPendingWork, addMarketplace, listMarketplaces, installPlugin, verifyDelivery, verifyInstallCli, effectiveTaskCategories, BUILTIN_MARKETPLACE, assertPluginsPresent, inspectMarketplaceDependencies, inspectHookEntries, };
96
108
  export type { HostId, GateId, WorkflowName, ProjectConfig };
97
109
  export { analyzeUsage, renderUsageMarkdown } from './usage/analyze.js';
98
110
  export type { UsageReport, UsageOptions, Tokens, RateCard, Billing } from './usage/types.js';