scene-capability-engine 3.4.5 → 3.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +40 -0
- package/README.md +31 -1
- package/README.zh.md +31 -1
- package/docs/command-reference.md +73 -7
- package/docs/images/wechat-qr.png +0 -0
- package/lib/adoption/adoption-strategy.js +2 -0
- package/lib/adoption/detection-engine.js +2 -0
- package/lib/adoption/file-classifier.js +3 -1
- package/lib/adoption/smart-orchestrator.js +2 -0
- package/lib/commands/errorbook.js +142 -2
- package/lib/commands/studio.js +569 -25
- package/lib/problem/problem-evaluator.js +431 -16
- package/lib/spec/domain-modeling.js +49 -4
- package/lib/studio/spec-intake-governor.js +992 -0
- package/lib/workspace/takeover-baseline.js +128 -4
- package/package.json +1 -1
- package/template/.sce/config/problem-closure-policy.json +18 -0
- package/template/.sce/config/problem-eval-policy.json +43 -1
- package/template/.sce/config/spec-domain-policy.json +9 -1
- package/template/.sce/config/studio-intake-policy.json +68 -0
- package/template/.sce/config/takeover-baseline.json +88 -1
- package/template/.sce/steering/CORE_PRINCIPLES.md +4 -2
|
@@ -0,0 +1,992 @@
|
|
|
1
|
+
const path = require('path');
|
|
2
|
+
const fs = require('fs-extra');
|
|
3
|
+
const { DraftGenerator } = require('../spec/bootstrap/draft-generator');
|
|
4
|
+
const { ensureSpecDomainArtifacts } = require('../spec/domain-modeling');
|
|
5
|
+
|
|
6
|
+
const DEFAULT_STUDIO_INTAKE_POLICY_PATH = '.sce/config/studio-intake-policy.json';
|
|
7
|
+
const DEFAULT_STUDIO_GOVERNANCE_DIR = '.sce/spec-governance';
|
|
8
|
+
const DEFAULT_STUDIO_PORTFOLIO_REPORT = `${DEFAULT_STUDIO_GOVERNANCE_DIR}/scene-portfolio.latest.json`;
|
|
9
|
+
const DEFAULT_STUDIO_SCENE_INDEX = `${DEFAULT_STUDIO_GOVERNANCE_DIR}/scene-index.json`;
|
|
10
|
+
|
|
11
|
+
const DEFAULT_STUDIO_INTAKE_POLICY = Object.freeze({
|
|
12
|
+
schema_version: '1.0',
|
|
13
|
+
enabled: true,
|
|
14
|
+
auto_create_spec: true,
|
|
15
|
+
force_spec_for_studio_plan: true,
|
|
16
|
+
prefer_existing_scene_spec: true,
|
|
17
|
+
related_spec_min_score: 45,
|
|
18
|
+
allow_new_spec_when_goal_diverges: true,
|
|
19
|
+
divergence_similarity_threshold: 0.2,
|
|
20
|
+
goal_missing_strategy: 'create_for_tracking',
|
|
21
|
+
question_only_patterns: [
|
|
22
|
+
'how', 'what', 'why', 'when', 'where', 'which', 'can', 'could', 'should', 'would',
|
|
23
|
+
'是否', '怎么', '如何', '为什么', '吗', '么'
|
|
24
|
+
],
|
|
25
|
+
change_intent_patterns: [
|
|
26
|
+
'implement', 'build', 'create', 'add', 'update', 'upgrade', 'refactor', 'fix', 'stabilize',
|
|
27
|
+
'optimize', 'deliver', 'release', 'bootstrap', 'repair', 'patch',
|
|
28
|
+
'新增', '增加', '实现', '构建', '开发', '修复', '优化', '重构', '发布', '改造', '完善', '增强'
|
|
29
|
+
],
|
|
30
|
+
spec_id: {
|
|
31
|
+
prefix: 'auto',
|
|
32
|
+
max_goal_slug_tokens: 6
|
|
33
|
+
},
|
|
34
|
+
governance: {
|
|
35
|
+
auto_run_on_plan: true,
|
|
36
|
+
max_active_specs_per_scene: 3,
|
|
37
|
+
stale_days: 14,
|
|
38
|
+
duplicate_similarity_threshold: 0.66
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
function normalizeText(value) {
|
|
43
|
+
if (typeof value !== 'string') {
|
|
44
|
+
return '';
|
|
45
|
+
}
|
|
46
|
+
return value.trim();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function normalizeNumber(value, fallback = 0) {
|
|
50
|
+
const parsed = Number(value);
|
|
51
|
+
if (!Number.isFinite(parsed)) {
|
|
52
|
+
return fallback;
|
|
53
|
+
}
|
|
54
|
+
return parsed;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function normalizeInteger(value, fallback = 0, min = 0, max = Number.MAX_SAFE_INTEGER) {
|
|
58
|
+
const parsed = Number.parseInt(String(value), 10);
|
|
59
|
+
if (!Number.isFinite(parsed)) {
|
|
60
|
+
return fallback;
|
|
61
|
+
}
|
|
62
|
+
return Math.max(min, Math.min(max, parsed));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function normalizeBoolean(value, fallback = false) {
|
|
66
|
+
if (typeof value === 'boolean') {
|
|
67
|
+
return value;
|
|
68
|
+
}
|
|
69
|
+
if (typeof value === 'string') {
|
|
70
|
+
const lowered = value.trim().toLowerCase();
|
|
71
|
+
if (['1', 'true', 'yes', 'on'].includes(lowered)) {
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
if (['0', 'false', 'no', 'off'].includes(lowered)) {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return fallback;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function normalizeTextList(value = []) {
|
|
82
|
+
if (!Array.isArray(value)) {
|
|
83
|
+
return [];
|
|
84
|
+
}
|
|
85
|
+
return value
|
|
86
|
+
.map((item) => normalizeText(`${item}`))
|
|
87
|
+
.filter(Boolean);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function toRelativePosix(projectPath, absolutePath) {
|
|
91
|
+
return path.relative(projectPath, absolutePath).replace(/\\/g, '/');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function tokenizeText(value) {
|
|
95
|
+
const normalized = normalizeText(value).toLowerCase();
|
|
96
|
+
if (!normalized) {
|
|
97
|
+
return [];
|
|
98
|
+
}
|
|
99
|
+
return Array.from(new Set(
|
|
100
|
+
normalized
|
|
101
|
+
.split(/[^a-z0-9\u4e00-\u9fff]+/i)
|
|
102
|
+
.map((item) => item.trim())
|
|
103
|
+
.filter((item) => item.length >= 2 || /[\u4e00-\u9fff]/.test(item))
|
|
104
|
+
));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function computeJaccard(leftTokens = [], rightTokens = []) {
|
|
108
|
+
const left = new Set(leftTokens);
|
|
109
|
+
const right = new Set(rightTokens);
|
|
110
|
+
if (left.size === 0 && right.size === 0) {
|
|
111
|
+
return 1;
|
|
112
|
+
}
|
|
113
|
+
if (left.size === 0 || right.size === 0) {
|
|
114
|
+
return 0;
|
|
115
|
+
}
|
|
116
|
+
let intersection = 0;
|
|
117
|
+
for (const token of left) {
|
|
118
|
+
if (right.has(token)) {
|
|
119
|
+
intersection += 1;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
const union = left.size + right.size - intersection;
|
|
123
|
+
if (union <= 0) {
|
|
124
|
+
return 0;
|
|
125
|
+
}
|
|
126
|
+
return Number((intersection / union).toFixed(3));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function slugifyText(value, fallback = 'spec') {
|
|
130
|
+
const normalized = normalizeText(value).toLowerCase();
|
|
131
|
+
if (!normalized) {
|
|
132
|
+
return fallback;
|
|
133
|
+
}
|
|
134
|
+
const slug = normalized
|
|
135
|
+
.replace(/[^a-z0-9\u4e00-\u9fff]+/g, '-')
|
|
136
|
+
.replace(/-+/g, '-')
|
|
137
|
+
.replace(/^-|-$/g, '');
|
|
138
|
+
return slug || fallback;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function buildGoalSlug(goal, maxTokens = 6) {
|
|
142
|
+
const tokens = tokenizeText(goal).slice(0, Math.max(1, maxTokens));
|
|
143
|
+
if (tokens.length === 0) {
|
|
144
|
+
return 'work';
|
|
145
|
+
}
|
|
146
|
+
return slugifyText(tokens.join('-'), 'work');
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function normalizeSceneSlug(sceneId) {
|
|
150
|
+
const normalized = normalizeText(sceneId).replace(/^scene[._-]?/i, '');
|
|
151
|
+
return slugifyText(normalized, 'scene');
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function parseTasksProgress(tasksContent) {
|
|
155
|
+
const content = typeof tasksContent === 'string' ? tasksContent : '';
|
|
156
|
+
const taskLines = content.match(/^- \[[ xX]\] .+$/gm) || [];
|
|
157
|
+
const doneLines = content.match(/^- \[[xX]\] .+$/gm) || [];
|
|
158
|
+
const total = taskLines.length;
|
|
159
|
+
const done = doneLines.length;
|
|
160
|
+
const ratio = total > 0 ? Number((done / total).toFixed(3)) : 0;
|
|
161
|
+
return {
|
|
162
|
+
total,
|
|
163
|
+
done,
|
|
164
|
+
ratio
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function normalizeStudioIntakePolicy(raw = {}) {
|
|
169
|
+
const payload = raw && typeof raw === 'object' ? raw : {};
|
|
170
|
+
const specId = payload.spec_id && typeof payload.spec_id === 'object' ? payload.spec_id : {};
|
|
171
|
+
const governance = payload.governance && typeof payload.governance === 'object' ? payload.governance : {};
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
schema_version: normalizeText(payload.schema_version) || DEFAULT_STUDIO_INTAKE_POLICY.schema_version,
|
|
175
|
+
enabled: normalizeBoolean(payload.enabled, DEFAULT_STUDIO_INTAKE_POLICY.enabled),
|
|
176
|
+
auto_create_spec: normalizeBoolean(payload.auto_create_spec, DEFAULT_STUDIO_INTAKE_POLICY.auto_create_spec),
|
|
177
|
+
force_spec_for_studio_plan: normalizeBoolean(
|
|
178
|
+
payload.force_spec_for_studio_plan,
|
|
179
|
+
DEFAULT_STUDIO_INTAKE_POLICY.force_spec_for_studio_plan
|
|
180
|
+
),
|
|
181
|
+
prefer_existing_scene_spec: normalizeBoolean(
|
|
182
|
+
payload.prefer_existing_scene_spec,
|
|
183
|
+
DEFAULT_STUDIO_INTAKE_POLICY.prefer_existing_scene_spec
|
|
184
|
+
),
|
|
185
|
+
related_spec_min_score: normalizeInteger(
|
|
186
|
+
payload.related_spec_min_score,
|
|
187
|
+
DEFAULT_STUDIO_INTAKE_POLICY.related_spec_min_score,
|
|
188
|
+
0,
|
|
189
|
+
1000
|
|
190
|
+
),
|
|
191
|
+
allow_new_spec_when_goal_diverges: normalizeBoolean(
|
|
192
|
+
payload.allow_new_spec_when_goal_diverges,
|
|
193
|
+
DEFAULT_STUDIO_INTAKE_POLICY.allow_new_spec_when_goal_diverges
|
|
194
|
+
),
|
|
195
|
+
divergence_similarity_threshold: Math.max(
|
|
196
|
+
0,
|
|
197
|
+
Math.min(1, normalizeNumber(
|
|
198
|
+
payload.divergence_similarity_threshold,
|
|
199
|
+
DEFAULT_STUDIO_INTAKE_POLICY.divergence_similarity_threshold
|
|
200
|
+
))
|
|
201
|
+
),
|
|
202
|
+
goal_missing_strategy: ['create_for_tracking', 'bind_existing', 'skip'].includes(normalizeText(payload.goal_missing_strategy))
|
|
203
|
+
? normalizeText(payload.goal_missing_strategy)
|
|
204
|
+
: DEFAULT_STUDIO_INTAKE_POLICY.goal_missing_strategy,
|
|
205
|
+
question_only_patterns: (() => {
|
|
206
|
+
const values = normalizeTextList(payload.question_only_patterns);
|
|
207
|
+
return values.length > 0 ? values : [...DEFAULT_STUDIO_INTAKE_POLICY.question_only_patterns];
|
|
208
|
+
})(),
|
|
209
|
+
change_intent_patterns: (() => {
|
|
210
|
+
const values = normalizeTextList(payload.change_intent_patterns);
|
|
211
|
+
return values.length > 0 ? values : [...DEFAULT_STUDIO_INTAKE_POLICY.change_intent_patterns];
|
|
212
|
+
})(),
|
|
213
|
+
spec_id: {
|
|
214
|
+
prefix: normalizeText(specId.prefix) || DEFAULT_STUDIO_INTAKE_POLICY.spec_id.prefix,
|
|
215
|
+
max_goal_slug_tokens: normalizeInteger(
|
|
216
|
+
specId.max_goal_slug_tokens,
|
|
217
|
+
DEFAULT_STUDIO_INTAKE_POLICY.spec_id.max_goal_slug_tokens,
|
|
218
|
+
1,
|
|
219
|
+
12
|
|
220
|
+
)
|
|
221
|
+
},
|
|
222
|
+
governance: {
|
|
223
|
+
auto_run_on_plan: normalizeBoolean(
|
|
224
|
+
governance.auto_run_on_plan,
|
|
225
|
+
DEFAULT_STUDIO_INTAKE_POLICY.governance.auto_run_on_plan
|
|
226
|
+
),
|
|
227
|
+
max_active_specs_per_scene: normalizeInteger(
|
|
228
|
+
governance.max_active_specs_per_scene,
|
|
229
|
+
DEFAULT_STUDIO_INTAKE_POLICY.governance.max_active_specs_per_scene,
|
|
230
|
+
1,
|
|
231
|
+
200
|
|
232
|
+
),
|
|
233
|
+
stale_days: normalizeInteger(
|
|
234
|
+
governance.stale_days,
|
|
235
|
+
DEFAULT_STUDIO_INTAKE_POLICY.governance.stale_days,
|
|
236
|
+
1,
|
|
237
|
+
3650
|
|
238
|
+
),
|
|
239
|
+
duplicate_similarity_threshold: Math.max(
|
|
240
|
+
0,
|
|
241
|
+
Math.min(1, normalizeNumber(
|
|
242
|
+
governance.duplicate_similarity_threshold,
|
|
243
|
+
DEFAULT_STUDIO_INTAKE_POLICY.governance.duplicate_similarity_threshold
|
|
244
|
+
))
|
|
245
|
+
)
|
|
246
|
+
}
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async function loadStudioIntakePolicy(projectPath = process.cwd(), fileSystem = fs) {
|
|
251
|
+
const policyPath = path.join(projectPath, DEFAULT_STUDIO_INTAKE_POLICY_PATH);
|
|
252
|
+
let policyPayload = {};
|
|
253
|
+
let loadedFrom = 'default';
|
|
254
|
+
if (await fileSystem.pathExists(policyPath)) {
|
|
255
|
+
try {
|
|
256
|
+
policyPayload = await fileSystem.readJson(policyPath);
|
|
257
|
+
loadedFrom = 'file';
|
|
258
|
+
} catch (_error) {
|
|
259
|
+
policyPayload = {};
|
|
260
|
+
loadedFrom = 'default';
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
const policy = normalizeStudioIntakePolicy(policyPayload);
|
|
264
|
+
return {
|
|
265
|
+
policy,
|
|
266
|
+
policy_path: DEFAULT_STUDIO_INTAKE_POLICY_PATH,
|
|
267
|
+
loaded_from: loadedFrom
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function classifyStudioGoalIntent(goal = '', policy = DEFAULT_STUDIO_INTAKE_POLICY) {
|
|
272
|
+
const normalizedGoal = normalizeText(goal);
|
|
273
|
+
const loweredGoal = normalizedGoal.toLowerCase();
|
|
274
|
+
const changePatterns = Array.isArray(policy.change_intent_patterns) ? policy.change_intent_patterns : [];
|
|
275
|
+
const questionPatterns = Array.isArray(policy.question_only_patterns) ? policy.question_only_patterns : [];
|
|
276
|
+
|
|
277
|
+
let changeHits = 0;
|
|
278
|
+
for (const pattern of changePatterns) {
|
|
279
|
+
const keyword = normalizeText(pattern).toLowerCase();
|
|
280
|
+
if (keyword && loweredGoal.includes(keyword)) {
|
|
281
|
+
changeHits += 1;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
let questionHits = 0;
|
|
286
|
+
for (const pattern of questionPatterns) {
|
|
287
|
+
const keyword = normalizeText(pattern).toLowerCase();
|
|
288
|
+
if (keyword && loweredGoal.includes(keyword)) {
|
|
289
|
+
questionHits += 1;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (/[??]\s*$/.test(normalizedGoal)) {
|
|
294
|
+
questionHits += 1;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (!normalizedGoal) {
|
|
298
|
+
return {
|
|
299
|
+
intent_type: 'unknown',
|
|
300
|
+
requires_spec: false,
|
|
301
|
+
confidence: 'low',
|
|
302
|
+
signals: {
|
|
303
|
+
change_hits: 0,
|
|
304
|
+
question_hits: 0,
|
|
305
|
+
goal_missing: true
|
|
306
|
+
}
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (changeHits > 0 && changeHits >= questionHits) {
|
|
311
|
+
return {
|
|
312
|
+
intent_type: 'change_request',
|
|
313
|
+
requires_spec: true,
|
|
314
|
+
confidence: changeHits >= 2 ? 'high' : 'medium',
|
|
315
|
+
signals: {
|
|
316
|
+
change_hits: changeHits,
|
|
317
|
+
question_hits: questionHits,
|
|
318
|
+
goal_missing: false
|
|
319
|
+
}
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if (questionHits > 0 && changeHits === 0) {
|
|
324
|
+
return {
|
|
325
|
+
intent_type: 'analysis_only',
|
|
326
|
+
requires_spec: false,
|
|
327
|
+
confidence: 'medium',
|
|
328
|
+
signals: {
|
|
329
|
+
change_hits: changeHits,
|
|
330
|
+
question_hits: questionHits,
|
|
331
|
+
goal_missing: false
|
|
332
|
+
}
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
return {
|
|
337
|
+
intent_type: 'ambiguous',
|
|
338
|
+
requires_spec: false,
|
|
339
|
+
confidence: 'low',
|
|
340
|
+
signals: {
|
|
341
|
+
change_hits: changeHits,
|
|
342
|
+
question_hits: questionHits,
|
|
343
|
+
goal_missing: false
|
|
344
|
+
}
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
async function listExistingSpecIds(projectPath, fileSystem = fs) {
|
|
349
|
+
const specsRoot = path.join(projectPath, '.sce', 'specs');
|
|
350
|
+
if (!await fileSystem.pathExists(specsRoot)) {
|
|
351
|
+
return [];
|
|
352
|
+
}
|
|
353
|
+
const entries = await fileSystem.readdir(specsRoot);
|
|
354
|
+
const specIds = [];
|
|
355
|
+
for (const entry of entries) {
|
|
356
|
+
const candidatePath = path.join(specsRoot, entry);
|
|
357
|
+
try {
|
|
358
|
+
const stat = await fileSystem.stat(candidatePath);
|
|
359
|
+
if (stat && stat.isDirectory()) {
|
|
360
|
+
specIds.push(entry);
|
|
361
|
+
}
|
|
362
|
+
} catch (_error) {
|
|
363
|
+
// ignore unreadable entry
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
specIds.sort();
|
|
367
|
+
return specIds;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function createAutoSpecId(sceneId, goal, existingSpecIds = [], policy = DEFAULT_STUDIO_INTAKE_POLICY) {
|
|
371
|
+
const now = new Date();
|
|
372
|
+
const timestamp = now.toISOString().replace(/[-:TZ.]/g, '').slice(2, 14);
|
|
373
|
+
const sceneSlug = normalizeSceneSlug(sceneId);
|
|
374
|
+
const goalSlug = buildGoalSlug(goal, policy?.spec_id?.max_goal_slug_tokens || 6);
|
|
375
|
+
const prefix = slugifyText(normalizeText(policy?.spec_id?.prefix) || 'auto', 'auto');
|
|
376
|
+
const base = `${prefix}-${sceneSlug}-${goalSlug}-${timestamp}`.slice(0, 96);
|
|
377
|
+
const existing = new Set(existingSpecIds);
|
|
378
|
+
if (!existing.has(base)) {
|
|
379
|
+
return base;
|
|
380
|
+
}
|
|
381
|
+
for (let index = 2; index <= 99; index += 1) {
|
|
382
|
+
const candidate = `${base}-${index}`;
|
|
383
|
+
if (!existing.has(candidate)) {
|
|
384
|
+
return candidate;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
return `${base}-${Math.random().toString(36).slice(2, 7)}`;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
async function materializeIntakeSpec(projectPath, payload = {}, dependencies = {}) {
|
|
391
|
+
const fileSystem = dependencies.fileSystem || fs;
|
|
392
|
+
const sceneId = normalizeText(payload.scene_id);
|
|
393
|
+
const goal = normalizeText(payload.goal);
|
|
394
|
+
const fromChat = normalizeText(payload.from_chat);
|
|
395
|
+
const specId = normalizeText(payload.spec_id);
|
|
396
|
+
if (!specId) {
|
|
397
|
+
throw new Error('spec_id is required for intake spec creation');
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const specRoot = path.join(projectPath, '.sce', 'specs', specId);
|
|
401
|
+
if (await fileSystem.pathExists(specRoot)) {
|
|
402
|
+
return {
|
|
403
|
+
created: false,
|
|
404
|
+
spec_id: specId,
|
|
405
|
+
reason: 'already_exists',
|
|
406
|
+
spec_path: toRelativePosix(projectPath, specRoot)
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const allSpecs = await listExistingSpecIds(projectPath, fileSystem);
|
|
411
|
+
const draftGenerator = dependencies.draftGenerator || new DraftGenerator();
|
|
412
|
+
const problemStatement = goal || `Studio intake request from ${fromChat || 'chat-session'}`;
|
|
413
|
+
const draft = draftGenerator.generate({
|
|
414
|
+
specName: specId,
|
|
415
|
+
profile: 'studio-intake',
|
|
416
|
+
template: 'default',
|
|
417
|
+
context: {
|
|
418
|
+
projectPath,
|
|
419
|
+
totalSpecs: allSpecs.length
|
|
420
|
+
},
|
|
421
|
+
answers: {
|
|
422
|
+
problemStatement,
|
|
423
|
+
primaryFlow: `Scene ${sceneId || 'unknown'} iterative capability evolution`,
|
|
424
|
+
verificationPlan: 'Run spec gate + studio verify/release with closure gates'
|
|
425
|
+
}
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
const requirementsPath = path.join(specRoot, 'requirements.md');
|
|
429
|
+
const designPath = path.join(specRoot, 'design.md');
|
|
430
|
+
const tasksPath = path.join(specRoot, 'tasks.md');
|
|
431
|
+
await fileSystem.ensureDir(specRoot);
|
|
432
|
+
await fileSystem.writeFile(requirementsPath, draft.requirements, 'utf8');
|
|
433
|
+
await fileSystem.writeFile(designPath, draft.design, 'utf8');
|
|
434
|
+
await fileSystem.writeFile(tasksPath, draft.tasks, 'utf8');
|
|
435
|
+
const domainArtifacts = await ensureSpecDomainArtifacts(projectPath, specId, {
|
|
436
|
+
fileSystem,
|
|
437
|
+
force: true,
|
|
438
|
+
sceneId,
|
|
439
|
+
problemStatement,
|
|
440
|
+
primaryFlow: `Scene ${sceneId || 'unknown'} delivery`,
|
|
441
|
+
verificationPlan: 'spec gate + studio verify + problem-closure gate'
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
return {
|
|
445
|
+
created: true,
|
|
446
|
+
spec_id: specId,
|
|
447
|
+
spec_path: toRelativePosix(projectPath, specRoot),
|
|
448
|
+
files: {
|
|
449
|
+
requirements: toRelativePosix(projectPath, requirementsPath),
|
|
450
|
+
design: toRelativePosix(projectPath, designPath),
|
|
451
|
+
tasks: toRelativePosix(projectPath, tasksPath),
|
|
452
|
+
domain_map: toRelativePosix(projectPath, domainArtifacts.paths.domain_map),
|
|
453
|
+
scene_spec: toRelativePosix(projectPath, domainArtifacts.paths.scene_spec),
|
|
454
|
+
domain_chain: toRelativePosix(projectPath, domainArtifacts.paths.domain_chain),
|
|
455
|
+
problem_contract: toRelativePosix(projectPath, domainArtifacts.paths.problem_contract)
|
|
456
|
+
}
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function normalizeRelatedCandidates(relatedSpecLookup = {}) {
|
|
461
|
+
const items = Array.isArray(relatedSpecLookup.related_specs)
|
|
462
|
+
? relatedSpecLookup.related_specs
|
|
463
|
+
: [];
|
|
464
|
+
return items
|
|
465
|
+
.map((item) => ({
|
|
466
|
+
spec_id: normalizeText(item.spec_id),
|
|
467
|
+
score: normalizeNumber(item.score, 0),
|
|
468
|
+
scene_id: normalizeText(item.scene_id) || null,
|
|
469
|
+
problem_statement: normalizeText(item.problem_statement) || '',
|
|
470
|
+
reasons: Array.isArray(item.reasons) ? item.reasons : []
|
|
471
|
+
}))
|
|
472
|
+
.filter((item) => item.spec_id);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function resolveStudioSpecIntakeDecision(context = {}, policy = DEFAULT_STUDIO_INTAKE_POLICY) {
|
|
476
|
+
const goal = normalizeText(context.goal);
|
|
477
|
+
const explicitSpecId = normalizeText(context.explicit_spec_id);
|
|
478
|
+
const domainChainBinding = context.domain_chain_binding && typeof context.domain_chain_binding === 'object'
|
|
479
|
+
? context.domain_chain_binding
|
|
480
|
+
: {};
|
|
481
|
+
const relatedCandidates = normalizeRelatedCandidates(context.related_specs);
|
|
482
|
+
const intent = context.intent && typeof context.intent === 'object'
|
|
483
|
+
? context.intent
|
|
484
|
+
: classifyStudioGoalIntent(goal, policy);
|
|
485
|
+
|
|
486
|
+
if (!policy.enabled) {
|
|
487
|
+
return {
|
|
488
|
+
action: 'disabled',
|
|
489
|
+
reason: 'policy_disabled',
|
|
490
|
+
confidence: 'high',
|
|
491
|
+
spec_id: explicitSpecId || null,
|
|
492
|
+
source: explicitSpecId ? 'explicit-spec' : 'none',
|
|
493
|
+
intent
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
if (explicitSpecId) {
|
|
498
|
+
return {
|
|
499
|
+
action: 'bind_existing',
|
|
500
|
+
reason: 'explicit_spec',
|
|
501
|
+
confidence: 'high',
|
|
502
|
+
spec_id: explicitSpecId,
|
|
503
|
+
source: 'explicit-spec',
|
|
504
|
+
intent
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
const preferredRelated = relatedCandidates.find((item) => item.score >= policy.related_spec_min_score) || null;
|
|
509
|
+
const hasBoundDomainSpec = domainChainBinding.resolved === true && normalizeText(domainChainBinding.spec_id).length > 0;
|
|
510
|
+
const domainSpecId = hasBoundDomainSpec ? normalizeText(domainChainBinding.spec_id) : '';
|
|
511
|
+
const domainProblem = normalizeText(domainChainBinding?.summary?.problem_statement);
|
|
512
|
+
const goalSimilarityToDomain = computeJaccard(tokenizeText(goal), tokenizeText(domainProblem));
|
|
513
|
+
|
|
514
|
+
if (hasBoundDomainSpec && policy.prefer_existing_scene_spec) {
|
|
515
|
+
const shouldDivergeCreate = Boolean(
|
|
516
|
+
policy.allow_new_spec_when_goal_diverges
|
|
517
|
+
&& intent.requires_spec
|
|
518
|
+
&& goal
|
|
519
|
+
&& goalSimilarityToDomain < policy.divergence_similarity_threshold
|
|
520
|
+
);
|
|
521
|
+
if (!shouldDivergeCreate) {
|
|
522
|
+
return {
|
|
523
|
+
action: 'bind_existing',
|
|
524
|
+
reason: 'prefer_existing_scene_spec',
|
|
525
|
+
confidence: 'high',
|
|
526
|
+
spec_id: domainSpecId,
|
|
527
|
+
source: 'scene-domain-chain',
|
|
528
|
+
similarity: goalSimilarityToDomain,
|
|
529
|
+
intent
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
if (preferredRelated) {
|
|
535
|
+
return {
|
|
536
|
+
action: 'bind_existing',
|
|
537
|
+
reason: 'related_spec_match',
|
|
538
|
+
confidence: preferredRelated.score >= (policy.related_spec_min_score + 20) ? 'high' : 'medium',
|
|
539
|
+
spec_id: preferredRelated.spec_id,
|
|
540
|
+
source: 'related-spec',
|
|
541
|
+
matched_score: preferredRelated.score,
|
|
542
|
+
intent
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
const goalMissing = normalizeText(goal).length === 0;
|
|
547
|
+
const shouldCreateByMissingGoal = goalMissing && policy.goal_missing_strategy === 'create_for_tracking';
|
|
548
|
+
const shouldCreateByIntent = intent.requires_spec || policy.force_spec_for_studio_plan;
|
|
549
|
+
const shouldCreate = policy.auto_create_spec && (shouldCreateByIntent || shouldCreateByMissingGoal);
|
|
550
|
+
|
|
551
|
+
if (shouldCreate) {
|
|
552
|
+
return {
|
|
553
|
+
action: 'create_spec',
|
|
554
|
+
reason: goalMissing ? 'goal_missing_tracking' : 'intent_requires_spec',
|
|
555
|
+
confidence: intent.requires_spec ? intent.confidence : 'medium',
|
|
556
|
+
spec_id: null,
|
|
557
|
+
source: 'auto-create',
|
|
558
|
+
intent
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
return {
|
|
563
|
+
action: 'none',
|
|
564
|
+
reason: 'no_spec_required',
|
|
565
|
+
confidence: 'low',
|
|
566
|
+
spec_id: null,
|
|
567
|
+
source: 'none',
|
|
568
|
+
intent
|
|
569
|
+
};
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
async function runStudioAutoIntake(options = {}, dependencies = {}) {
|
|
573
|
+
const projectPath = dependencies.projectPath || process.cwd();
|
|
574
|
+
const fileSystem = dependencies.fileSystem || fs;
|
|
575
|
+
const sceneId = normalizeText(options.scene_id || options.sceneId);
|
|
576
|
+
const goal = normalizeText(options.goal);
|
|
577
|
+
const fromChat = normalizeText(options.from_chat || options.fromChat);
|
|
578
|
+
const explicitSpecId = normalizeText(options.explicit_spec_id || options.spec_id || options.specId);
|
|
579
|
+
const apply = options.apply === true;
|
|
580
|
+
const skip = options.skip === true;
|
|
581
|
+
|
|
582
|
+
const loadedPolicy = options.policy && typeof options.policy === 'object'
|
|
583
|
+
? { policy: normalizeStudioIntakePolicy(options.policy), policy_path: '(inline)', loaded_from: 'inline' }
|
|
584
|
+
: await loadStudioIntakePolicy(projectPath, fileSystem);
|
|
585
|
+
|
|
586
|
+
const policy = loadedPolicy.policy;
|
|
587
|
+
const intent = classifyStudioGoalIntent(goal, policy);
|
|
588
|
+
const decision = resolveStudioSpecIntakeDecision({
|
|
589
|
+
goal,
|
|
590
|
+
explicit_spec_id: explicitSpecId,
|
|
591
|
+
domain_chain_binding: options.domain_chain_binding || {},
|
|
592
|
+
related_specs: options.related_specs || {},
|
|
593
|
+
intent
|
|
594
|
+
}, policy);
|
|
595
|
+
|
|
596
|
+
const payload = {
|
|
597
|
+
mode: 'studio-auto-intake',
|
|
598
|
+
success: true,
|
|
599
|
+
enabled: policy.enabled === true && !skip,
|
|
600
|
+
policy_path: loadedPolicy.policy_path,
|
|
601
|
+
policy_loaded_from: loadedPolicy.loaded_from,
|
|
602
|
+
policy,
|
|
603
|
+
scene_id: sceneId || null,
|
|
604
|
+
from_chat: fromChat || null,
|
|
605
|
+
goal: goal || null,
|
|
606
|
+
intent,
|
|
607
|
+
decision: {
|
|
608
|
+
...decision
|
|
609
|
+
},
|
|
610
|
+
selected_spec_id: decision.spec_id || null,
|
|
611
|
+
created_spec: null
|
|
612
|
+
};
|
|
613
|
+
|
|
614
|
+
if (skip) {
|
|
615
|
+
payload.enabled = false;
|
|
616
|
+
payload.decision = {
|
|
617
|
+
action: 'disabled',
|
|
618
|
+
reason: 'manual_override',
|
|
619
|
+
confidence: 'high',
|
|
620
|
+
spec_id: explicitSpecId || null,
|
|
621
|
+
source: explicitSpecId ? 'explicit-spec' : 'none',
|
|
622
|
+
intent
|
|
623
|
+
};
|
|
624
|
+
payload.selected_spec_id = payload.decision.spec_id || null;
|
|
625
|
+
return payload;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
if (decision.action === 'create_spec') {
|
|
629
|
+
const existingSpecIds = await listExistingSpecIds(projectPath, fileSystem);
|
|
630
|
+
const autoSpecId = createAutoSpecId(sceneId, goal, existingSpecIds, policy);
|
|
631
|
+
payload.decision.spec_id = autoSpecId;
|
|
632
|
+
payload.selected_spec_id = autoSpecId;
|
|
633
|
+
if (apply) {
|
|
634
|
+
const createdSpec = await materializeIntakeSpec(projectPath, {
|
|
635
|
+
scene_id: sceneId,
|
|
636
|
+
from_chat: fromChat,
|
|
637
|
+
goal,
|
|
638
|
+
spec_id: autoSpecId
|
|
639
|
+
}, {
|
|
640
|
+
fileSystem
|
|
641
|
+
});
|
|
642
|
+
payload.created_spec = createdSpec;
|
|
643
|
+
payload.decision.created = createdSpec.created === true;
|
|
644
|
+
} else {
|
|
645
|
+
payload.decision.created = false;
|
|
646
|
+
}
|
|
647
|
+
return payload;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
payload.selected_spec_id = decision.spec_id || null;
|
|
651
|
+
return payload;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
async function readJsonSafe(filePath, fileSystem = fs) {
|
|
655
|
+
if (!await fileSystem.pathExists(filePath)) {
|
|
656
|
+
return null;
|
|
657
|
+
}
|
|
658
|
+
try {
|
|
659
|
+
return await fileSystem.readJson(filePath);
|
|
660
|
+
} catch (_error) {
|
|
661
|
+
return null;
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
async function readFileSafe(filePath, fileSystem = fs) {
|
|
666
|
+
if (!await fileSystem.pathExists(filePath)) {
|
|
667
|
+
return '';
|
|
668
|
+
}
|
|
669
|
+
try {
|
|
670
|
+
return await fileSystem.readFile(filePath, 'utf8');
|
|
671
|
+
} catch (_error) {
|
|
672
|
+
return '';
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
function classifySpecLifecycleState(record = {}, staleDays = 14) {
|
|
677
|
+
const nowMs = Date.now();
|
|
678
|
+
const updatedMs = Date.parse(record.updated_at || 0);
|
|
679
|
+
const ageDays = Number.isFinite(updatedMs)
|
|
680
|
+
? Number(((nowMs - updatedMs) / (1000 * 60 * 60 * 24)).toFixed(2))
|
|
681
|
+
: null;
|
|
682
|
+
|
|
683
|
+
if (record.tasks_total > 0 && record.tasks_done >= record.tasks_total) {
|
|
684
|
+
return {
|
|
685
|
+
state: 'completed',
|
|
686
|
+
age_days: ageDays
|
|
687
|
+
};
|
|
688
|
+
}
|
|
689
|
+
if (ageDays !== null && ageDays > staleDays) {
|
|
690
|
+
return {
|
|
691
|
+
state: 'stale',
|
|
692
|
+
age_days: ageDays
|
|
693
|
+
};
|
|
694
|
+
}
|
|
695
|
+
return {
|
|
696
|
+
state: 'active',
|
|
697
|
+
age_days: ageDays
|
|
698
|
+
};
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
async function scanSpecPortfolio(projectPath = process.cwd(), options = {}, dependencies = {}) {
|
|
702
|
+
const fileSystem = dependencies.fileSystem || fs;
|
|
703
|
+
const specsRoot = path.join(projectPath, '.sce', 'specs');
|
|
704
|
+
if (!await fileSystem.pathExists(specsRoot)) {
|
|
705
|
+
return [];
|
|
706
|
+
}
|
|
707
|
+
const staleDays = normalizeInteger(options.stale_days, 14, 1, 3650);
|
|
708
|
+
const entries = await fileSystem.readdir(specsRoot);
|
|
709
|
+
const records = [];
|
|
710
|
+
|
|
711
|
+
for (const entry of entries) {
|
|
712
|
+
const specRoot = path.join(specsRoot, entry);
|
|
713
|
+
let stat = null;
|
|
714
|
+
try {
|
|
715
|
+
stat = await fileSystem.stat(specRoot);
|
|
716
|
+
} catch (_error) {
|
|
717
|
+
continue;
|
|
718
|
+
}
|
|
719
|
+
if (!stat || !stat.isDirectory()) {
|
|
720
|
+
continue;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
const domainChainPath = path.join(specRoot, 'custom', 'problem-domain-chain.json');
|
|
724
|
+
const problemContractPath = path.join(specRoot, 'custom', 'problem-contract.json');
|
|
725
|
+
const requirementsPath = path.join(specRoot, 'requirements.md');
|
|
726
|
+
const designPath = path.join(specRoot, 'design.md');
|
|
727
|
+
const tasksPath = path.join(specRoot, 'tasks.md');
|
|
728
|
+
const [chain, contract, requirements, design, tasks] = await Promise.all([
|
|
729
|
+
readJsonSafe(domainChainPath, fileSystem),
|
|
730
|
+
readJsonSafe(problemContractPath, fileSystem),
|
|
731
|
+
readFileSafe(requirementsPath, fileSystem),
|
|
732
|
+
readFileSafe(designPath, fileSystem),
|
|
733
|
+
readFileSafe(tasksPath, fileSystem)
|
|
734
|
+
]);
|
|
735
|
+
|
|
736
|
+
const sceneId = normalizeText(
|
|
737
|
+
chain && chain.scene_id ? chain.scene_id : ''
|
|
738
|
+
) || 'scene.unassigned';
|
|
739
|
+
const problemStatement = normalizeText(
|
|
740
|
+
(chain && chain.problem && chain.problem.statement)
|
|
741
|
+
|| (contract && contract.issue_statement)
|
|
742
|
+
|| ''
|
|
743
|
+
);
|
|
744
|
+
const taskProgress = parseTasksProgress(tasks);
|
|
745
|
+
const lifecycle = classifySpecLifecycleState({
|
|
746
|
+
updated_at: stat && stat.mtime ? stat.mtime.toISOString() : null,
|
|
747
|
+
tasks_total: taskProgress.total,
|
|
748
|
+
tasks_done: taskProgress.done
|
|
749
|
+
}, staleDays);
|
|
750
|
+
const searchSeed = [
|
|
751
|
+
entry,
|
|
752
|
+
sceneId,
|
|
753
|
+
problemStatement,
|
|
754
|
+
normalizeText(requirements).slice(0, 1600),
|
|
755
|
+
normalizeText(design).slice(0, 1600),
|
|
756
|
+
normalizeText(tasks).slice(0, 1000)
|
|
757
|
+
].join('\n');
|
|
758
|
+
const tokens = tokenizeText(searchSeed);
|
|
759
|
+
|
|
760
|
+
records.push({
|
|
761
|
+
spec_id: entry,
|
|
762
|
+
scene_id: sceneId,
|
|
763
|
+
problem_statement: problemStatement || null,
|
|
764
|
+
updated_at: stat && stat.mtime ? stat.mtime.toISOString() : null,
|
|
765
|
+
tasks_total: taskProgress.total,
|
|
766
|
+
tasks_done: taskProgress.done,
|
|
767
|
+
tasks_progress: taskProgress.ratio,
|
|
768
|
+
lifecycle_state: lifecycle.state,
|
|
769
|
+
age_days: lifecycle.age_days,
|
|
770
|
+
tokens
|
|
771
|
+
});
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
records.sort((left, right) => String(right.updated_at || '').localeCompare(String(left.updated_at || '')));
|
|
775
|
+
return records;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
function buildSceneGovernanceReport(records = [], policy = DEFAULT_STUDIO_INTAKE_POLICY) {
|
|
779
|
+
const governance = policy.governance || DEFAULT_STUDIO_INTAKE_POLICY.governance;
|
|
780
|
+
const threshold = normalizeNumber(governance.duplicate_similarity_threshold, 0.66);
|
|
781
|
+
const maxActive = normalizeInteger(governance.max_active_specs_per_scene, 3, 1, 200);
|
|
782
|
+
|
|
783
|
+
const sceneMap = new Map();
|
|
784
|
+
for (const record of records) {
|
|
785
|
+
const sceneId = normalizeText(record.scene_id) || 'scene.unassigned';
|
|
786
|
+
if (!sceneMap.has(sceneId)) {
|
|
787
|
+
sceneMap.set(sceneId, []);
|
|
788
|
+
}
|
|
789
|
+
sceneMap.get(sceneId).push(record);
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
const scenes = [];
|
|
793
|
+
const mergeCandidates = [];
|
|
794
|
+
const archiveCandidates = [];
|
|
795
|
+
let duplicatePairs = 0;
|
|
796
|
+
|
|
797
|
+
for (const [sceneId, sceneSpecs] of sceneMap.entries()) {
|
|
798
|
+
const sortedSpecs = [...sceneSpecs].sort((left, right) => String(right.updated_at || '').localeCompare(String(left.updated_at || '')));
|
|
799
|
+
const activeSpecs = sortedSpecs.filter((item) => item.lifecycle_state === 'active');
|
|
800
|
+
const staleSpecs = sortedSpecs.filter((item) => item.lifecycle_state === 'stale');
|
|
801
|
+
const completedSpecs = sortedSpecs.filter((item) => item.lifecycle_state === 'completed');
|
|
802
|
+
|
|
803
|
+
const duplicates = [];
|
|
804
|
+
for (let i = 0; i < sortedSpecs.length; i += 1) {
|
|
805
|
+
for (let j = i + 1; j < sortedSpecs.length; j += 1) {
|
|
806
|
+
const left = sortedSpecs[i];
|
|
807
|
+
const right = sortedSpecs[j];
|
|
808
|
+
const similarity = computeJaccard(left.tokens, right.tokens);
|
|
809
|
+
if (similarity >= threshold) {
|
|
810
|
+
duplicatePairs += 1;
|
|
811
|
+
duplicates.push({
|
|
812
|
+
spec_a: left.spec_id,
|
|
813
|
+
spec_b: right.spec_id,
|
|
814
|
+
similarity
|
|
815
|
+
});
|
|
816
|
+
mergeCandidates.push({
|
|
817
|
+
scene_id: sceneId,
|
|
818
|
+
spec_primary: left.spec_id,
|
|
819
|
+
spec_secondary: right.spec_id,
|
|
820
|
+
similarity
|
|
821
|
+
});
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
const overflow = activeSpecs.length > maxActive
|
|
827
|
+
? activeSpecs.slice(maxActive).map((item) => item.spec_id)
|
|
828
|
+
: [];
|
|
829
|
+
for (const specId of overflow) {
|
|
830
|
+
archiveCandidates.push({
|
|
831
|
+
scene_id: sceneId,
|
|
832
|
+
spec_id: specId,
|
|
833
|
+
reason: `active spec count exceeds limit ${maxActive}`
|
|
834
|
+
});
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
scenes.push({
|
|
838
|
+
scene_id: sceneId,
|
|
839
|
+
total_specs: sortedSpecs.length,
|
|
840
|
+
active_specs: activeSpecs.length,
|
|
841
|
+
completed_specs: completedSpecs.length,
|
|
842
|
+
stale_specs: staleSpecs.length,
|
|
843
|
+
active_limit: maxActive,
|
|
844
|
+
active_overflow_count: overflow.length,
|
|
845
|
+
active_overflow_specs: overflow,
|
|
846
|
+
duplicate_pairs: duplicates,
|
|
847
|
+
specs: sortedSpecs.map((item) => ({
|
|
848
|
+
spec_id: item.spec_id,
|
|
849
|
+
lifecycle_state: item.lifecycle_state,
|
|
850
|
+
updated_at: item.updated_at,
|
|
851
|
+
age_days: item.age_days,
|
|
852
|
+
tasks_total: item.tasks_total,
|
|
853
|
+
tasks_done: item.tasks_done,
|
|
854
|
+
tasks_progress: item.tasks_progress,
|
|
855
|
+
problem_statement: item.problem_statement
|
|
856
|
+
}))
|
|
857
|
+
});
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
scenes.sort((left, right) => {
|
|
861
|
+
if (right.total_specs !== left.total_specs) {
|
|
862
|
+
return right.total_specs - left.total_specs;
|
|
863
|
+
}
|
|
864
|
+
return String(left.scene_id).localeCompare(String(right.scene_id));
|
|
865
|
+
});
|
|
866
|
+
|
|
867
|
+
const totalSpecs = records.length;
|
|
868
|
+
const activeTotal = records.filter((item) => item.lifecycle_state === 'active').length;
|
|
869
|
+
const staleTotal = records.filter((item) => item.lifecycle_state === 'stale').length;
|
|
870
|
+
const completedTotal = records.filter((item) => item.lifecycle_state === 'completed').length;
|
|
871
|
+
const overflowScenes = scenes.filter((item) => item.active_overflow_count > 0).length;
|
|
872
|
+
const alertCount = duplicatePairs + overflowScenes + staleTotal;
|
|
873
|
+
|
|
874
|
+
return {
|
|
875
|
+
scene_count: scenes.length,
|
|
876
|
+
total_specs: totalSpecs,
|
|
877
|
+
active_specs: activeTotal,
|
|
878
|
+
completed_specs: completedTotal,
|
|
879
|
+
stale_specs: staleTotal,
|
|
880
|
+
duplicate_pairs: duplicatePairs,
|
|
881
|
+
overflow_scenes: overflowScenes,
|
|
882
|
+
alert_count: alertCount,
|
|
883
|
+
status: alertCount > 0 ? 'attention' : 'healthy',
|
|
884
|
+
scenes,
|
|
885
|
+
actions: {
|
|
886
|
+
merge_candidates: mergeCandidates,
|
|
887
|
+
archive_candidates: archiveCandidates
|
|
888
|
+
}
|
|
889
|
+
};
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
async function runStudioSpecGovernance(options = {}, dependencies = {}) {
|
|
893
|
+
const projectPath = dependencies.projectPath || process.cwd();
|
|
894
|
+
const fileSystem = dependencies.fileSystem || fs;
|
|
895
|
+
const loaded = options.policy && typeof options.policy === 'object'
|
|
896
|
+
? { policy: normalizeStudioIntakePolicy(options.policy), policy_path: '(inline)', loaded_from: 'inline' }
|
|
897
|
+
: await loadStudioIntakePolicy(projectPath, fileSystem);
|
|
898
|
+
const policy = loaded.policy;
|
|
899
|
+
const governance = policy.governance || DEFAULT_STUDIO_INTAKE_POLICY.governance;
|
|
900
|
+
const apply = options.apply !== false;
|
|
901
|
+
const sceneFilter = normalizeText(options.scene_id || options.sceneId || options.scene);
|
|
902
|
+
|
|
903
|
+
const records = await scanSpecPortfolio(projectPath, {
|
|
904
|
+
stale_days: governance.stale_days
|
|
905
|
+
}, {
|
|
906
|
+
fileSystem
|
|
907
|
+
});
|
|
908
|
+
|
|
909
|
+
const filteredRecords = sceneFilter
|
|
910
|
+
? records.filter((item) => normalizeText(item.scene_id) === sceneFilter)
|
|
911
|
+
: records;
|
|
912
|
+
|
|
913
|
+
const summary = buildSceneGovernanceReport(filteredRecords, policy);
|
|
914
|
+
const generatedAt = new Date().toISOString();
|
|
915
|
+
const reportPayload = {
|
|
916
|
+
mode: 'studio-spec-governance',
|
|
917
|
+
success: true,
|
|
918
|
+
generated_at: generatedAt,
|
|
919
|
+
scene_filter: sceneFilter || null,
|
|
920
|
+
policy_path: loaded.policy_path,
|
|
921
|
+
policy_loaded_from: loaded.loaded_from,
|
|
922
|
+
policy: {
|
|
923
|
+
governance
|
|
924
|
+
},
|
|
925
|
+
summary: {
|
|
926
|
+
scene_count: summary.scene_count,
|
|
927
|
+
total_specs: summary.total_specs,
|
|
928
|
+
active_specs: summary.active_specs,
|
|
929
|
+
completed_specs: summary.completed_specs,
|
|
930
|
+
stale_specs: summary.stale_specs,
|
|
931
|
+
duplicate_pairs: summary.duplicate_pairs,
|
|
932
|
+
overflow_scenes: summary.overflow_scenes,
|
|
933
|
+
alert_count: summary.alert_count,
|
|
934
|
+
status: summary.status
|
|
935
|
+
},
|
|
936
|
+
scenes: summary.scenes,
|
|
937
|
+
actions: summary.actions
|
|
938
|
+
};
|
|
939
|
+
|
|
940
|
+
if (apply) {
|
|
941
|
+
const reportPath = path.join(projectPath, DEFAULT_STUDIO_PORTFOLIO_REPORT);
|
|
942
|
+
const indexPath = path.join(projectPath, DEFAULT_STUDIO_SCENE_INDEX);
|
|
943
|
+
await fileSystem.ensureDir(path.dirname(reportPath));
|
|
944
|
+
await fileSystem.writeJson(reportPath, reportPayload, { spaces: 2 });
|
|
945
|
+
const sceneIndex = {
|
|
946
|
+
schema_version: '1.0',
|
|
947
|
+
generated_at: generatedAt,
|
|
948
|
+
scene_filter: sceneFilter || null,
|
|
949
|
+
scenes: {}
|
|
950
|
+
};
|
|
951
|
+
for (const scene of summary.scenes) {
|
|
952
|
+
sceneIndex.scenes[scene.scene_id] = {
|
|
953
|
+
total_specs: scene.total_specs,
|
|
954
|
+
active_specs: scene.active_specs,
|
|
955
|
+
completed_specs: scene.completed_specs,
|
|
956
|
+
stale_specs: scene.stale_specs,
|
|
957
|
+
spec_ids: scene.specs.map((item) => item.spec_id),
|
|
958
|
+
active_spec_ids: scene.specs
|
|
959
|
+
.filter((item) => item.lifecycle_state === 'active')
|
|
960
|
+
.map((item) => item.spec_id),
|
|
961
|
+
stale_spec_ids: scene.specs
|
|
962
|
+
.filter((item) => item.lifecycle_state === 'stale')
|
|
963
|
+
.map((item) => item.spec_id)
|
|
964
|
+
};
|
|
965
|
+
}
|
|
966
|
+
await fileSystem.writeJson(indexPath, sceneIndex, { spaces: 2 });
|
|
967
|
+
reportPayload.report_file = DEFAULT_STUDIO_PORTFOLIO_REPORT;
|
|
968
|
+
reportPayload.scene_index_file = DEFAULT_STUDIO_SCENE_INDEX;
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
return reportPayload;
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
module.exports = {
|
|
975
|
+
DEFAULT_STUDIO_INTAKE_POLICY_PATH,
|
|
976
|
+
DEFAULT_STUDIO_INTAKE_POLICY,
|
|
977
|
+
DEFAULT_STUDIO_PORTFOLIO_REPORT,
|
|
978
|
+
DEFAULT_STUDIO_SCENE_INDEX,
|
|
979
|
+
normalizeStudioIntakePolicy,
|
|
980
|
+
loadStudioIntakePolicy,
|
|
981
|
+
classifyStudioGoalIntent,
|
|
982
|
+
resolveStudioSpecIntakeDecision,
|
|
983
|
+
createAutoSpecId,
|
|
984
|
+
materializeIntakeSpec,
|
|
985
|
+
runStudioAutoIntake,
|
|
986
|
+
parseTasksProgress,
|
|
987
|
+
scanSpecPortfolio,
|
|
988
|
+
buildSceneGovernanceReport,
|
|
989
|
+
runStudioSpecGovernance,
|
|
990
|
+
tokenizeText,
|
|
991
|
+
computeJaccard
|
|
992
|
+
};
|