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.
@@ -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
+ };