scene-capability-engine 3.6.9 → 3.6.11

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.
@@ -0,0 +1,378 @@
1
+ const fs = require('fs-extra');
2
+ const path = require('path');
3
+ const TaskClaimer = require('./task-claimer');
4
+ const { DEFAULT_TASK_QUALITY_POLICY } = require('./task-quality-policy');
5
+
6
+ const DEFAULT_TASK_GOVERNANCE_DIR = '.sce/task-governance';
7
+ const DEFAULT_DRAFTS_FILE = 'drafts.json';
8
+ const DEFAULT_SCHEMA_VERSION = '1.0';
9
+
10
+ function normalizeText(value) {
11
+ if (typeof value !== 'string') {
12
+ return '';
13
+ }
14
+ return value.trim();
15
+ }
16
+
17
+ function normalizeStringArray(value) {
18
+ if (!Array.isArray(value)) {
19
+ return [];
20
+ }
21
+ return value.map((item) => normalizeText(item)).filter(Boolean);
22
+ }
23
+
24
+ function toNonNegativeInteger(value, fallback = 0) {
25
+ const parsed = Number.parseInt(`${value}`, 10);
26
+ if (!Number.isFinite(parsed) || parsed < 0) {
27
+ return fallback;
28
+ }
29
+ return parsed;
30
+ }
31
+
32
+ function toPositiveInteger(value, fallback = 1) {
33
+ const parsed = Number.parseInt(`${value}`, 10);
34
+ if (!Number.isFinite(parsed) || parsed <= 0) {
35
+ return fallback;
36
+ }
37
+ return parsed;
38
+ }
39
+
40
+ function buildDraftId() {
41
+ const now = Date.now();
42
+ const random = Math.random().toString(36).slice(2, 8);
43
+ return `draft-${now}-${random}`;
44
+ }
45
+
46
+ function splitGoals(text) {
47
+ const normalized = normalizeText(text);
48
+ if (!normalized) {
49
+ return [];
50
+ }
51
+ const separators = /[;;\n]|(?:\s+and\s+)|(?:\s+then\s+)|(?:\s+also\s+)|(?:\s+plus\s+)|、|,|,|并且|同时|然后|以及|并/;
52
+ const parts = normalized.split(separators).map((item) => normalizeText(item)).filter(Boolean);
53
+ if (parts.length <= 1) {
54
+ return parts;
55
+ }
56
+ const unique = [];
57
+ for (const part of parts) {
58
+ if (!unique.some((item) => item === part)) {
59
+ unique.push(part);
60
+ }
61
+ }
62
+ return unique;
63
+ }
64
+
65
+ function normalizeTitle(text) {
66
+ const goals = splitGoals(text);
67
+ const base = goals.length > 0 ? goals[0] : normalizeText(text);
68
+ if (!base) {
69
+ return 'Untitled task';
70
+ }
71
+ return base.length > 120 ? `${base.slice(0, 117)}...` : base;
72
+ }
73
+
74
+ function suggestAcceptanceCriteria(goal) {
75
+ const normalized = normalizeText(goal);
76
+ if (!normalized) {
77
+ return [];
78
+ }
79
+ return [
80
+ `Define completion criteria for: ${normalized}`,
81
+ 'Verify no regressions in related flows',
82
+ 'Add/confirm validation evidence in tasks.md'
83
+ ];
84
+ }
85
+
86
+ function buildDraft(rawRequest, options = {}) {
87
+ const now = new Date().toISOString();
88
+ const normalizedRaw = normalizeText(rawRequest);
89
+ const goals = splitGoals(normalizedRaw);
90
+ const subGoals = goals.length > 1 ? goals.slice(1, 4) : [];
91
+ const needsSplit = goals.length > 1;
92
+ const acceptanceCriteria = normalizeStringArray(options.acceptance_criteria);
93
+ const acceptanceSuggestions = acceptanceCriteria.length === 0
94
+ ? suggestAcceptanceCriteria(goals.length > 0 ? goals[0] : normalizedRaw)
95
+ : [];
96
+ const confidenceBase = options.confidence !== undefined
97
+ ? Number(options.confidence)
98
+ : 0.55;
99
+ const confidence = Math.max(0.1, Math.min(0.95, confidenceBase + (acceptanceCriteria.length > 0 ? 0.1 : -0.05)));
100
+
101
+ return {
102
+ draft_id: buildDraftId(),
103
+ scene_id: normalizeText(options.scene_id),
104
+ spec_id: normalizeText(options.spec_id),
105
+ raw_request: normalizedRaw,
106
+ title_norm: normalizeTitle(normalizedRaw),
107
+ goal: goals.length > 0 ? goals[0] : normalizedRaw,
108
+ sub_goals: subGoals,
109
+ acceptance_criteria: acceptanceCriteria,
110
+ acceptance_suggestions: acceptanceSuggestions,
111
+ needs_split: needsSplit,
112
+ confidence,
113
+ status: 'draft',
114
+ created_at: now,
115
+ updated_at: now
116
+ };
117
+ }
118
+
119
+ function scoreDraft(draft, policy = DEFAULT_TASK_QUALITY_POLICY) {
120
+ const issues = [];
121
+ const goal = normalizeText(draft.goal);
122
+ const title = normalizeText(draft.title_norm);
123
+ const acceptance = normalizeStringArray(draft.acceptance_criteria);
124
+ const needsSplit = Boolean(draft.needs_split);
125
+ const requireAcceptance = policy ? policy.require_acceptance_criteria !== false : true;
126
+ const allowSplit = policy ? policy.allow_needs_split === true : false;
127
+
128
+ let clarity = 100;
129
+ if (!title || title.length < 4) {
130
+ clarity -= 30;
131
+ issues.push('title too short or missing');
132
+ }
133
+ if (needsSplit) {
134
+ clarity -= 25;
135
+ issues.push('multiple goals detected; split required');
136
+ }
137
+
138
+ let verifiability = 90;
139
+ if (requireAcceptance && acceptance.length === 0) {
140
+ verifiability = 45;
141
+ issues.push('acceptance criteria missing');
142
+ }
143
+
144
+ let executability = goal ? 85 : 40;
145
+ if (!goal) {
146
+ issues.push('goal missing');
147
+ }
148
+ if (!normalizeText(draft.spec_id)) {
149
+ executability -= 20;
150
+ issues.push('spec_id missing');
151
+ }
152
+
153
+ let risk = 40;
154
+ if (needsSplit) {
155
+ risk = 65;
156
+ }
157
+
158
+ clarity = Math.max(0, Math.min(100, Math.round(clarity)));
159
+ verifiability = Math.max(0, Math.min(100, Math.round(verifiability)));
160
+ executability = Math.max(0, Math.min(100, Math.round(executability)));
161
+ risk = Math.max(0, Math.min(100, Math.round(risk)));
162
+
163
+ const overall = Math.round(
164
+ (clarity * 0.3)
165
+ + (verifiability * 0.3)
166
+ + (executability * 0.3)
167
+ + ((100 - risk) * 0.1)
168
+ );
169
+
170
+ const minScore = policy && Number.isFinite(policy.min_quality_score)
171
+ ? Math.max(1, Math.min(100, Math.round(policy.min_quality_score)))
172
+ : 70;
173
+ const acceptancePassed = requireAcceptance ? acceptance.length > 0 : true;
174
+ const splitPassed = allowSplit ? true : !needsSplit;
175
+ const passed = overall >= minScore && acceptancePassed && splitPassed;
176
+
177
+ return {
178
+ score: overall,
179
+ passed,
180
+ breakdown: {
181
+ clarity,
182
+ verifiability,
183
+ executability,
184
+ risk
185
+ },
186
+ issues
187
+ };
188
+ }
189
+
190
+ async function loadDraftStore(projectPath, fileSystem = fs) {
191
+ const storePath = path.join(projectPath, DEFAULT_TASK_GOVERNANCE_DIR, DEFAULT_DRAFTS_FILE);
192
+ if (!await fileSystem.pathExists(storePath)) {
193
+ return {
194
+ path: storePath,
195
+ payload: {
196
+ schema_version: DEFAULT_SCHEMA_VERSION,
197
+ updated_at: new Date().toISOString(),
198
+ drafts: []
199
+ }
200
+ };
201
+ }
202
+
203
+ const payload = await fileSystem.readJson(storePath);
204
+ return {
205
+ path: storePath,
206
+ payload: payload && typeof payload === 'object'
207
+ ? payload
208
+ : {
209
+ schema_version: DEFAULT_SCHEMA_VERSION,
210
+ updated_at: new Date().toISOString(),
211
+ drafts: []
212
+ }
213
+ };
214
+ }
215
+
216
+ async function saveDraftStore(store, fileSystem = fs) {
217
+ const next = {
218
+ schema_version: store.payload.schema_version || DEFAULT_SCHEMA_VERSION,
219
+ updated_at: new Date().toISOString(),
220
+ drafts: Array.isArray(store.payload.drafts) ? store.payload.drafts : []
221
+ };
222
+ await fileSystem.ensureDir(path.dirname(store.path));
223
+ await fileSystem.writeJson(store.path, next, { spaces: 2 });
224
+ return { path: store.path, payload: next };
225
+ }
226
+
227
+ async function appendDraft(projectPath, draft, fileSystem = fs) {
228
+ const store = await loadDraftStore(projectPath, fileSystem);
229
+ store.payload.drafts = Array.isArray(store.payload.drafts) ? store.payload.drafts : [];
230
+ store.payload.drafts.push(draft);
231
+ await saveDraftStore(store, fileSystem);
232
+ return { draft, store: store.payload, store_path: store.path };
233
+ }
234
+
235
+ async function updateDraft(projectPath, draftId, updater, fileSystem = fs) {
236
+ const store = await loadDraftStore(projectPath, fileSystem);
237
+ const drafts = Array.isArray(store.payload.drafts) ? store.payload.drafts : [];
238
+ const index = drafts.findIndex((item) => item.draft_id === draftId);
239
+ if (index < 0) {
240
+ return null;
241
+ }
242
+ const updated = updater({ ...drafts[index] });
243
+ updated.updated_at = new Date().toISOString();
244
+ drafts[index] = updated;
245
+ store.payload.drafts = drafts;
246
+ await saveDraftStore(store, fileSystem);
247
+ return updated;
248
+ }
249
+
250
+ function consolidateDraftGroup(group) {
251
+ const merged = { ...group[0] };
252
+ const now = new Date().toISOString();
253
+ merged.status = 'consolidated';
254
+ merged.updated_at = now;
255
+ const rawRequests = group.map((item) => normalizeText(item.raw_request)).filter(Boolean);
256
+ const subGoals = group.flatMap((item) => normalizeStringArray(item.sub_goals));
257
+ const acceptance = group.flatMap((item) => normalizeStringArray(item.acceptance_criteria));
258
+ const suggestions = group.flatMap((item) => normalizeStringArray(item.acceptance_suggestions));
259
+ merged.raw_request = rawRequests.join(' | ');
260
+ merged.sub_goals = Array.from(new Set(subGoals));
261
+ merged.acceptance_criteria = Array.from(new Set(acceptance));
262
+ merged.acceptance_suggestions = Array.from(new Set(suggestions));
263
+ merged.needs_split = merged.sub_goals.length > 0;
264
+ return merged;
265
+ }
266
+
267
+ async function consolidateDrafts(projectPath, options = {}, fileSystem = fs) {
268
+ const store = await loadDraftStore(projectPath, fileSystem);
269
+ const drafts = Array.isArray(store.payload.drafts) ? store.payload.drafts : [];
270
+ const sceneId = normalizeText(options.scene_id);
271
+ const specId = normalizeText(options.spec_id);
272
+
273
+ const candidates = drafts.filter((draft) => {
274
+ if (sceneId && normalizeText(draft.scene_id) !== sceneId) {
275
+ return false;
276
+ }
277
+ if (specId && normalizeText(draft.spec_id) !== specId) {
278
+ return false;
279
+ }
280
+ return draft.status === 'draft' || draft.status === 'consolidated';
281
+ });
282
+
283
+ const groups = new Map();
284
+ candidates.forEach((draft) => {
285
+ const key = normalizeTitle(draft.raw_request || draft.title_norm);
286
+ if (!groups.has(key)) {
287
+ groups.set(key, []);
288
+ }
289
+ groups.get(key).push(draft);
290
+ });
291
+
292
+ const mergedDrafts = [];
293
+ const mergedLog = [];
294
+ for (const [key, group] of groups.entries()) {
295
+ if (group.length === 1) {
296
+ mergedDrafts.push(group[0]);
297
+ continue;
298
+ }
299
+ const merged = consolidateDraftGroup(group);
300
+ mergedDrafts.push(merged);
301
+ mergedLog.push({
302
+ title_norm: key,
303
+ draft_ids: group.map((item) => item.draft_id),
304
+ merged_id: merged.draft_id
305
+ });
306
+ }
307
+
308
+ const untouched = drafts.filter((draft) => {
309
+ if (sceneId && normalizeText(draft.scene_id) !== sceneId) {
310
+ return true;
311
+ }
312
+ if (specId && normalizeText(draft.spec_id) !== specId) {
313
+ return true;
314
+ }
315
+ return false;
316
+ });
317
+
318
+ store.payload.drafts = [...untouched, ...mergedDrafts];
319
+ await saveDraftStore(store, fileSystem);
320
+
321
+ return {
322
+ merged: mergedLog,
323
+ drafts: mergedDrafts,
324
+ store_path: store.path
325
+ };
326
+ }
327
+
328
+ function parseTaskIdValue(taskId) {
329
+ if (!taskId) {
330
+ return 0;
331
+ }
332
+ const token = `${taskId}`.split('.')[0];
333
+ return toNonNegativeInteger(token, 0);
334
+ }
335
+
336
+ async function promoteDraftToTasks(projectPath, draft, fileSystem = fs) {
337
+ const specId = normalizeText(draft.spec_id);
338
+ if (!specId) {
339
+ throw new Error('spec_id is required to promote draft to tasks.md');
340
+ }
341
+ const tasksPath = path.join(projectPath, '.sce', 'specs', specId, 'tasks.md');
342
+ if (!await fileSystem.pathExists(tasksPath)) {
343
+ throw new Error(`tasks.md not found: ${tasksPath}`);
344
+ }
345
+
346
+ const claimer = new TaskClaimer();
347
+ const tasks = await claimer.parseTasks(tasksPath);
348
+ const maxId = tasks.reduce((max, task) => {
349
+ const value = parseTaskIdValue(task.taskId);
350
+ return Math.max(max, value);
351
+ }, 0);
352
+ const nextId = maxId + 1;
353
+ const title = normalizeText(draft.title_norm) || normalizeText(draft.goal) || 'New task';
354
+ const line = `- [ ] ${nextId}. ${title}`;
355
+
356
+ const content = await fileSystem.readFile(tasksPath, 'utf8');
357
+ const nextContent = content.trimEnd() + '\n' + line + '\n';
358
+ await fileSystem.writeFile(tasksPath, nextContent, 'utf8');
359
+
360
+ return {
361
+ task_id: `${nextId}`,
362
+ tasks_path: tasksPath
363
+ };
364
+ }
365
+
366
+ module.exports = {
367
+ DEFAULT_TASK_GOVERNANCE_DIR,
368
+ DEFAULT_DRAFTS_FILE,
369
+ buildDraft,
370
+ suggestAcceptanceCriteria,
371
+ scoreDraft,
372
+ loadDraftStore,
373
+ saveDraftStore,
374
+ appendDraft,
375
+ updateDraft,
376
+ consolidateDrafts,
377
+ promoteDraftToTasks
378
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scene-capability-engine",
3
- "version": "3.6.9",
3
+ "version": "3.6.11",
4
4
  "description": "SCE (Scene Capability Engine) - A CLI tool and npm package for spec-driven development with AI coding assistants.",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -0,0 +1,8 @@
1
+ {
2
+ "schema_version": "1.0",
3
+ "min_quality_score": 70,
4
+ "require_acceptance_criteria": true,
5
+ "allow_needs_split": false,
6
+ "auto_suggest_acceptance": true,
7
+ "max_sub_goals": 3
8
+ }