scene-capability-engine 3.4.6 → 3.5.1

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,1315 @@
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
+ const {
6
+ DEFAULT_SPEC_SCENE_OVERRIDE_PATH,
7
+ loadSceneBindingOverrides,
8
+ normalizeSceneBindingOverrides,
9
+ resolveSceneIdFromOverrides
10
+ } = require('../spec/scene-binding-overrides');
11
+
12
+ const DEFAULT_STUDIO_INTAKE_POLICY_PATH = '.sce/config/studio-intake-policy.json';
13
+ const DEFAULT_STUDIO_GOVERNANCE_DIR = '.sce/spec-governance';
14
+ const DEFAULT_STUDIO_PORTFOLIO_REPORT = `${DEFAULT_STUDIO_GOVERNANCE_DIR}/scene-portfolio.latest.json`;
15
+ const DEFAULT_STUDIO_SCENE_INDEX = `${DEFAULT_STUDIO_GOVERNANCE_DIR}/scene-index.json`;
16
+ const DEFAULT_STUDIO_SCENE_OVERRIDE_PATH = DEFAULT_SPEC_SCENE_OVERRIDE_PATH;
17
+
18
+ const DEFAULT_STUDIO_SCENE_BACKFILL_RULES = Object.freeze([
19
+ {
20
+ id: 'moqui-core',
21
+ scene_id: 'scene.moqui-core',
22
+ keywords: ['moqui']
23
+ },
24
+ {
25
+ id: 'orchestration',
26
+ scene_id: 'scene.sce-orchestration',
27
+ keywords: ['orchestrate', 'runtime', 'controller', 'batch', 'parallel']
28
+ },
29
+ {
30
+ id: 'template-registry',
31
+ scene_id: 'scene.sce-template-registry',
32
+ keywords: ['template', 'scene-package', 'registry', 'catalog', 'scene-template']
33
+ },
34
+ {
35
+ id: 'spec-governance',
36
+ scene_id: 'scene.sce-spec-governance',
37
+ keywords: ['spec', 'gate', 'ontology', 'governance', 'policy']
38
+ },
39
+ {
40
+ id: 'quality',
41
+ scene_id: 'scene.sce-quality',
42
+ keywords: ['test', 'quality', 'stability', 'jest', 'coverage']
43
+ },
44
+ {
45
+ id: 'docs',
46
+ scene_id: 'scene.sce-docs',
47
+ keywords: ['document', 'documentation', 'onboarding', 'guide']
48
+ },
49
+ {
50
+ id: 'platform',
51
+ scene_id: 'scene.sce-platform',
52
+ keywords: ['adopt', 'upgrade', 'workspace', 'repo', 'environment', 'devops', 'release', 'github', 'npm']
53
+ }
54
+ ]);
55
+
56
+ const DEFAULT_STUDIO_INTAKE_POLICY = Object.freeze({
57
+ schema_version: '1.0',
58
+ enabled: true,
59
+ auto_create_spec: true,
60
+ force_spec_for_studio_plan: true,
61
+ allow_manual_spec_override: false,
62
+ prefer_existing_scene_spec: true,
63
+ related_spec_min_score: 45,
64
+ allow_new_spec_when_goal_diverges: true,
65
+ divergence_similarity_threshold: 0.2,
66
+ goal_missing_strategy: 'create_for_tracking',
67
+ question_only_patterns: [
68
+ 'how', 'what', 'why', 'when', 'where', 'which', 'can', 'could', 'should', 'would',
69
+ '是否', '怎么', '如何', '为什么', '吗', '么'
70
+ ],
71
+ change_intent_patterns: [
72
+ 'implement', 'build', 'create', 'add', 'update', 'upgrade', 'refactor', 'fix', 'stabilize',
73
+ 'optimize', 'deliver', 'release', 'bootstrap', 'repair', 'patch',
74
+ '新增', '增加', '实现', '构建', '开发', '修复', '优化', '重构', '发布', '改造', '完善', '增强'
75
+ ],
76
+ spec_id: {
77
+ prefix: 'auto',
78
+ max_goal_slug_tokens: 6
79
+ },
80
+ governance: {
81
+ auto_run_on_plan: true,
82
+ require_auto_on_plan: true,
83
+ max_active_specs_per_scene: 3,
84
+ stale_days: 14,
85
+ duplicate_similarity_threshold: 0.66
86
+ },
87
+ backfill: {
88
+ enabled: true,
89
+ active_only_default: true,
90
+ default_scene_id: 'scene.sce-core',
91
+ override_file: DEFAULT_STUDIO_SCENE_OVERRIDE_PATH,
92
+ rules: DEFAULT_STUDIO_SCENE_BACKFILL_RULES
93
+ }
94
+ });
95
+
96
+ function normalizeText(value) {
97
+ if (typeof value !== 'string') {
98
+ return '';
99
+ }
100
+ return value.trim();
101
+ }
102
+
103
+ function normalizeNumber(value, fallback = 0) {
104
+ const parsed = Number(value);
105
+ if (!Number.isFinite(parsed)) {
106
+ return fallback;
107
+ }
108
+ return parsed;
109
+ }
110
+
111
+ function normalizeInteger(value, fallback = 0, min = 0, max = Number.MAX_SAFE_INTEGER) {
112
+ const parsed = Number.parseInt(String(value), 10);
113
+ if (!Number.isFinite(parsed)) {
114
+ return fallback;
115
+ }
116
+ return Math.max(min, Math.min(max, parsed));
117
+ }
118
+
119
+ function normalizeBoolean(value, fallback = false) {
120
+ if (typeof value === 'boolean') {
121
+ return value;
122
+ }
123
+ if (typeof value === 'string') {
124
+ const lowered = value.trim().toLowerCase();
125
+ if (['1', 'true', 'yes', 'on'].includes(lowered)) {
126
+ return true;
127
+ }
128
+ if (['0', 'false', 'no', 'off'].includes(lowered)) {
129
+ return false;
130
+ }
131
+ }
132
+ return fallback;
133
+ }
134
+
135
+ function normalizeTextList(value = []) {
136
+ if (!Array.isArray(value)) {
137
+ return [];
138
+ }
139
+ return value
140
+ .map((item) => normalizeText(`${item}`))
141
+ .filter(Boolean);
142
+ }
143
+
144
+ function normalizeBackfillRules(value = []) {
145
+ if (!Array.isArray(value)) {
146
+ return [];
147
+ }
148
+ const rules = [];
149
+ for (const item of value) {
150
+ if (!item || typeof item !== 'object') {
151
+ continue;
152
+ }
153
+ const id = normalizeText(item.id);
154
+ const sceneId = normalizeText(item.scene_id || item.sceneId);
155
+ const keywords = normalizeTextList(item.keywords || item.match_any_keywords || item.matchAnyKeywords);
156
+ if (!id || !sceneId || keywords.length === 0) {
157
+ continue;
158
+ }
159
+ rules.push({
160
+ id,
161
+ scene_id: sceneId,
162
+ keywords
163
+ });
164
+ }
165
+ return rules;
166
+ }
167
+
168
+ function toRelativePosix(projectPath, absolutePath) {
169
+ return path.relative(projectPath, absolutePath).replace(/\\/g, '/');
170
+ }
171
+
172
+ function tokenizeText(value) {
173
+ const normalized = normalizeText(value).toLowerCase();
174
+ if (!normalized) {
175
+ return [];
176
+ }
177
+ return Array.from(new Set(
178
+ normalized
179
+ .split(/[^a-z0-9\u4e00-\u9fff]+/i)
180
+ .map((item) => item.trim())
181
+ .filter((item) => item.length >= 2 || /[\u4e00-\u9fff]/.test(item))
182
+ ));
183
+ }
184
+
185
+ function computeJaccard(leftTokens = [], rightTokens = []) {
186
+ const left = new Set(leftTokens);
187
+ const right = new Set(rightTokens);
188
+ if (left.size === 0 && right.size === 0) {
189
+ return 1;
190
+ }
191
+ if (left.size === 0 || right.size === 0) {
192
+ return 0;
193
+ }
194
+ let intersection = 0;
195
+ for (const token of left) {
196
+ if (right.has(token)) {
197
+ intersection += 1;
198
+ }
199
+ }
200
+ const union = left.size + right.size - intersection;
201
+ if (union <= 0) {
202
+ return 0;
203
+ }
204
+ return Number((intersection / union).toFixed(3));
205
+ }
206
+
207
+ function slugifyText(value, fallback = 'spec') {
208
+ const normalized = normalizeText(value).toLowerCase();
209
+ if (!normalized) {
210
+ return fallback;
211
+ }
212
+ const slug = normalized
213
+ .replace(/[^a-z0-9\u4e00-\u9fff]+/g, '-')
214
+ .replace(/-+/g, '-')
215
+ .replace(/^-|-$/g, '');
216
+ return slug || fallback;
217
+ }
218
+
219
+ function buildGoalSlug(goal, maxTokens = 6) {
220
+ const tokens = tokenizeText(goal).slice(0, Math.max(1, maxTokens));
221
+ if (tokens.length === 0) {
222
+ return 'work';
223
+ }
224
+ return slugifyText(tokens.join('-'), 'work');
225
+ }
226
+
227
+ function normalizeSceneSlug(sceneId) {
228
+ const normalized = normalizeText(sceneId).replace(/^scene[._-]?/i, '');
229
+ return slugifyText(normalized, 'scene');
230
+ }
231
+
232
+ function parseTasksProgress(tasksContent) {
233
+ const content = typeof tasksContent === 'string' ? tasksContent : '';
234
+ const taskLines = content.match(/^- \[[ xX]\] .+$/gm) || [];
235
+ const doneLines = content.match(/^- \[[xX]\] .+$/gm) || [];
236
+ const total = taskLines.length;
237
+ const done = doneLines.length;
238
+ const ratio = total > 0 ? Number((done / total).toFixed(3)) : 0;
239
+ return {
240
+ total,
241
+ done,
242
+ ratio
243
+ };
244
+ }
245
+
246
+ function normalizeStudioIntakePolicy(raw = {}) {
247
+ const payload = raw && typeof raw === 'object' ? raw : {};
248
+ const specId = payload.spec_id && typeof payload.spec_id === 'object' ? payload.spec_id : {};
249
+ const governance = payload.governance && typeof payload.governance === 'object' ? payload.governance : {};
250
+ const backfill = payload.backfill && typeof payload.backfill === 'object' ? payload.backfill : {};
251
+ const normalizedBackfillRules = normalizeBackfillRules(backfill.rules);
252
+
253
+ return {
254
+ schema_version: normalizeText(payload.schema_version) || DEFAULT_STUDIO_INTAKE_POLICY.schema_version,
255
+ enabled: normalizeBoolean(payload.enabled, DEFAULT_STUDIO_INTAKE_POLICY.enabled),
256
+ auto_create_spec: normalizeBoolean(payload.auto_create_spec, DEFAULT_STUDIO_INTAKE_POLICY.auto_create_spec),
257
+ force_spec_for_studio_plan: normalizeBoolean(
258
+ payload.force_spec_for_studio_plan,
259
+ DEFAULT_STUDIO_INTAKE_POLICY.force_spec_for_studio_plan
260
+ ),
261
+ allow_manual_spec_override: normalizeBoolean(
262
+ payload.allow_manual_spec_override,
263
+ DEFAULT_STUDIO_INTAKE_POLICY.allow_manual_spec_override
264
+ ),
265
+ prefer_existing_scene_spec: normalizeBoolean(
266
+ payload.prefer_existing_scene_spec,
267
+ DEFAULT_STUDIO_INTAKE_POLICY.prefer_existing_scene_spec
268
+ ),
269
+ related_spec_min_score: normalizeInteger(
270
+ payload.related_spec_min_score,
271
+ DEFAULT_STUDIO_INTAKE_POLICY.related_spec_min_score,
272
+ 0,
273
+ 1000
274
+ ),
275
+ allow_new_spec_when_goal_diverges: normalizeBoolean(
276
+ payload.allow_new_spec_when_goal_diverges,
277
+ DEFAULT_STUDIO_INTAKE_POLICY.allow_new_spec_when_goal_diverges
278
+ ),
279
+ divergence_similarity_threshold: Math.max(
280
+ 0,
281
+ Math.min(1, normalizeNumber(
282
+ payload.divergence_similarity_threshold,
283
+ DEFAULT_STUDIO_INTAKE_POLICY.divergence_similarity_threshold
284
+ ))
285
+ ),
286
+ goal_missing_strategy: ['create_for_tracking', 'bind_existing', 'skip'].includes(normalizeText(payload.goal_missing_strategy))
287
+ ? normalizeText(payload.goal_missing_strategy)
288
+ : DEFAULT_STUDIO_INTAKE_POLICY.goal_missing_strategy,
289
+ question_only_patterns: (() => {
290
+ const values = normalizeTextList(payload.question_only_patterns);
291
+ return values.length > 0 ? values : [...DEFAULT_STUDIO_INTAKE_POLICY.question_only_patterns];
292
+ })(),
293
+ change_intent_patterns: (() => {
294
+ const values = normalizeTextList(payload.change_intent_patterns);
295
+ return values.length > 0 ? values : [...DEFAULT_STUDIO_INTAKE_POLICY.change_intent_patterns];
296
+ })(),
297
+ spec_id: {
298
+ prefix: normalizeText(specId.prefix) || DEFAULT_STUDIO_INTAKE_POLICY.spec_id.prefix,
299
+ max_goal_slug_tokens: normalizeInteger(
300
+ specId.max_goal_slug_tokens,
301
+ DEFAULT_STUDIO_INTAKE_POLICY.spec_id.max_goal_slug_tokens,
302
+ 1,
303
+ 12
304
+ )
305
+ },
306
+ governance: {
307
+ auto_run_on_plan: normalizeBoolean(
308
+ governance.auto_run_on_plan,
309
+ DEFAULT_STUDIO_INTAKE_POLICY.governance.auto_run_on_plan
310
+ ),
311
+ require_auto_on_plan: normalizeBoolean(
312
+ governance.require_auto_on_plan,
313
+ DEFAULT_STUDIO_INTAKE_POLICY.governance.require_auto_on_plan
314
+ ),
315
+ max_active_specs_per_scene: normalizeInteger(
316
+ governance.max_active_specs_per_scene,
317
+ DEFAULT_STUDIO_INTAKE_POLICY.governance.max_active_specs_per_scene,
318
+ 1,
319
+ 200
320
+ ),
321
+ stale_days: normalizeInteger(
322
+ governance.stale_days,
323
+ DEFAULT_STUDIO_INTAKE_POLICY.governance.stale_days,
324
+ 1,
325
+ 3650
326
+ ),
327
+ duplicate_similarity_threshold: Math.max(
328
+ 0,
329
+ Math.min(1, normalizeNumber(
330
+ governance.duplicate_similarity_threshold,
331
+ DEFAULT_STUDIO_INTAKE_POLICY.governance.duplicate_similarity_threshold
332
+ ))
333
+ )
334
+ },
335
+ backfill: {
336
+ enabled: normalizeBoolean(
337
+ backfill.enabled,
338
+ DEFAULT_STUDIO_INTAKE_POLICY.backfill.enabled
339
+ ),
340
+ active_only_default: normalizeBoolean(
341
+ backfill.active_only_default,
342
+ DEFAULT_STUDIO_INTAKE_POLICY.backfill.active_only_default
343
+ ),
344
+ default_scene_id: normalizeText(backfill.default_scene_id)
345
+ || DEFAULT_STUDIO_INTAKE_POLICY.backfill.default_scene_id,
346
+ override_file: normalizeText(backfill.override_file)
347
+ || DEFAULT_STUDIO_INTAKE_POLICY.backfill.override_file,
348
+ rules: normalizedBackfillRules.length > 0
349
+ ? normalizedBackfillRules
350
+ : normalizeBackfillRules(DEFAULT_STUDIO_INTAKE_POLICY.backfill.rules)
351
+ }
352
+ };
353
+ }
354
+
355
+ async function loadStudioIntakePolicy(projectPath = process.cwd(), fileSystem = fs) {
356
+ const policyPath = path.join(projectPath, DEFAULT_STUDIO_INTAKE_POLICY_PATH);
357
+ let policyPayload = {};
358
+ let loadedFrom = 'default';
359
+ if (await fileSystem.pathExists(policyPath)) {
360
+ try {
361
+ policyPayload = await fileSystem.readJson(policyPath);
362
+ loadedFrom = 'file';
363
+ } catch (_error) {
364
+ policyPayload = {};
365
+ loadedFrom = 'default';
366
+ }
367
+ }
368
+ const policy = normalizeStudioIntakePolicy(policyPayload);
369
+ return {
370
+ policy,
371
+ policy_path: DEFAULT_STUDIO_INTAKE_POLICY_PATH,
372
+ loaded_from: loadedFrom
373
+ };
374
+ }
375
+
376
+ function classifyStudioGoalIntent(goal = '', policy = DEFAULT_STUDIO_INTAKE_POLICY) {
377
+ const normalizedGoal = normalizeText(goal);
378
+ const loweredGoal = normalizedGoal.toLowerCase();
379
+ const changePatterns = Array.isArray(policy.change_intent_patterns) ? policy.change_intent_patterns : [];
380
+ const questionPatterns = Array.isArray(policy.question_only_patterns) ? policy.question_only_patterns : [];
381
+
382
+ let changeHits = 0;
383
+ for (const pattern of changePatterns) {
384
+ const keyword = normalizeText(pattern).toLowerCase();
385
+ if (keyword && loweredGoal.includes(keyword)) {
386
+ changeHits += 1;
387
+ }
388
+ }
389
+
390
+ let questionHits = 0;
391
+ for (const pattern of questionPatterns) {
392
+ const keyword = normalizeText(pattern).toLowerCase();
393
+ if (keyword && loweredGoal.includes(keyword)) {
394
+ questionHits += 1;
395
+ }
396
+ }
397
+
398
+ if (/[??]\s*$/.test(normalizedGoal)) {
399
+ questionHits += 1;
400
+ }
401
+
402
+ if (!normalizedGoal) {
403
+ return {
404
+ intent_type: 'unknown',
405
+ requires_spec: false,
406
+ confidence: 'low',
407
+ signals: {
408
+ change_hits: 0,
409
+ question_hits: 0,
410
+ goal_missing: true
411
+ }
412
+ };
413
+ }
414
+
415
+ if (changeHits > 0 && changeHits >= questionHits) {
416
+ return {
417
+ intent_type: 'change_request',
418
+ requires_spec: true,
419
+ confidence: changeHits >= 2 ? 'high' : 'medium',
420
+ signals: {
421
+ change_hits: changeHits,
422
+ question_hits: questionHits,
423
+ goal_missing: false
424
+ }
425
+ };
426
+ }
427
+
428
+ if (questionHits > 0 && changeHits === 0) {
429
+ return {
430
+ intent_type: 'analysis_only',
431
+ requires_spec: false,
432
+ confidence: 'medium',
433
+ signals: {
434
+ change_hits: changeHits,
435
+ question_hits: questionHits,
436
+ goal_missing: false
437
+ }
438
+ };
439
+ }
440
+
441
+ return {
442
+ intent_type: 'ambiguous',
443
+ requires_spec: false,
444
+ confidence: 'low',
445
+ signals: {
446
+ change_hits: changeHits,
447
+ question_hits: questionHits,
448
+ goal_missing: false
449
+ }
450
+ };
451
+ }
452
+
453
+ async function listExistingSpecIds(projectPath, fileSystem = fs) {
454
+ const specsRoot = path.join(projectPath, '.sce', 'specs');
455
+ if (!await fileSystem.pathExists(specsRoot)) {
456
+ return [];
457
+ }
458
+ const entries = await fileSystem.readdir(specsRoot);
459
+ const specIds = [];
460
+ for (const entry of entries) {
461
+ const candidatePath = path.join(specsRoot, entry);
462
+ try {
463
+ const stat = await fileSystem.stat(candidatePath);
464
+ if (stat && stat.isDirectory()) {
465
+ specIds.push(entry);
466
+ }
467
+ } catch (_error) {
468
+ // ignore unreadable entry
469
+ }
470
+ }
471
+ specIds.sort();
472
+ return specIds;
473
+ }
474
+
475
+ function createAutoSpecId(sceneId, goal, existingSpecIds = [], policy = DEFAULT_STUDIO_INTAKE_POLICY) {
476
+ const now = new Date();
477
+ const timestamp = now.toISOString().replace(/[-:TZ.]/g, '').slice(2, 14);
478
+ const sceneSlug = normalizeSceneSlug(sceneId);
479
+ const goalSlug = buildGoalSlug(goal, policy?.spec_id?.max_goal_slug_tokens || 6);
480
+ const prefix = slugifyText(normalizeText(policy?.spec_id?.prefix) || 'auto', 'auto');
481
+ const base = `${prefix}-${sceneSlug}-${goalSlug}-${timestamp}`.slice(0, 96);
482
+ const existing = new Set(existingSpecIds);
483
+ if (!existing.has(base)) {
484
+ return base;
485
+ }
486
+ for (let index = 2; index <= 99; index += 1) {
487
+ const candidate = `${base}-${index}`;
488
+ if (!existing.has(candidate)) {
489
+ return candidate;
490
+ }
491
+ }
492
+ return `${base}-${Math.random().toString(36).slice(2, 7)}`;
493
+ }
494
+
495
+ async function materializeIntakeSpec(projectPath, payload = {}, dependencies = {}) {
496
+ const fileSystem = dependencies.fileSystem || fs;
497
+ const sceneId = normalizeText(payload.scene_id);
498
+ const goal = normalizeText(payload.goal);
499
+ const fromChat = normalizeText(payload.from_chat);
500
+ const specId = normalizeText(payload.spec_id);
501
+ if (!specId) {
502
+ throw new Error('spec_id is required for intake spec creation');
503
+ }
504
+
505
+ const specRoot = path.join(projectPath, '.sce', 'specs', specId);
506
+ if (await fileSystem.pathExists(specRoot)) {
507
+ return {
508
+ created: false,
509
+ spec_id: specId,
510
+ reason: 'already_exists',
511
+ spec_path: toRelativePosix(projectPath, specRoot)
512
+ };
513
+ }
514
+
515
+ const allSpecs = await listExistingSpecIds(projectPath, fileSystem);
516
+ const draftGenerator = dependencies.draftGenerator || new DraftGenerator();
517
+ const problemStatement = goal || `Studio intake request from ${fromChat || 'chat-session'}`;
518
+ const draft = draftGenerator.generate({
519
+ specName: specId,
520
+ profile: 'studio-intake',
521
+ template: 'default',
522
+ context: {
523
+ projectPath,
524
+ totalSpecs: allSpecs.length
525
+ },
526
+ answers: {
527
+ problemStatement,
528
+ primaryFlow: `Scene ${sceneId || 'unknown'} iterative capability evolution`,
529
+ verificationPlan: 'Run spec gate + studio verify/release with closure gates'
530
+ }
531
+ });
532
+
533
+ const requirementsPath = path.join(specRoot, 'requirements.md');
534
+ const designPath = path.join(specRoot, 'design.md');
535
+ const tasksPath = path.join(specRoot, 'tasks.md');
536
+ await fileSystem.ensureDir(specRoot);
537
+ await fileSystem.writeFile(requirementsPath, draft.requirements, 'utf8');
538
+ await fileSystem.writeFile(designPath, draft.design, 'utf8');
539
+ await fileSystem.writeFile(tasksPath, draft.tasks, 'utf8');
540
+ const domainArtifacts = await ensureSpecDomainArtifacts(projectPath, specId, {
541
+ fileSystem,
542
+ force: true,
543
+ sceneId,
544
+ problemStatement,
545
+ primaryFlow: `Scene ${sceneId || 'unknown'} delivery`,
546
+ verificationPlan: 'spec gate + studio verify + problem-closure gate'
547
+ });
548
+
549
+ return {
550
+ created: true,
551
+ spec_id: specId,
552
+ spec_path: toRelativePosix(projectPath, specRoot),
553
+ files: {
554
+ requirements: toRelativePosix(projectPath, requirementsPath),
555
+ design: toRelativePosix(projectPath, designPath),
556
+ tasks: toRelativePosix(projectPath, tasksPath),
557
+ domain_map: toRelativePosix(projectPath, domainArtifacts.paths.domain_map),
558
+ scene_spec: toRelativePosix(projectPath, domainArtifacts.paths.scene_spec),
559
+ domain_chain: toRelativePosix(projectPath, domainArtifacts.paths.domain_chain),
560
+ problem_contract: toRelativePosix(projectPath, domainArtifacts.paths.problem_contract)
561
+ }
562
+ };
563
+ }
564
+
565
+ function normalizeRelatedCandidates(relatedSpecLookup = {}) {
566
+ const items = Array.isArray(relatedSpecLookup.related_specs)
567
+ ? relatedSpecLookup.related_specs
568
+ : [];
569
+ return items
570
+ .map((item) => ({
571
+ spec_id: normalizeText(item.spec_id),
572
+ score: normalizeNumber(item.score, 0),
573
+ scene_id: normalizeText(item.scene_id) || null,
574
+ problem_statement: normalizeText(item.problem_statement) || '',
575
+ reasons: Array.isArray(item.reasons) ? item.reasons : []
576
+ }))
577
+ .filter((item) => item.spec_id);
578
+ }
579
+
580
+ function resolveStudioSpecIntakeDecision(context = {}, policy = DEFAULT_STUDIO_INTAKE_POLICY) {
581
+ const goal = normalizeText(context.goal);
582
+ const explicitSpecId = normalizeText(context.explicit_spec_id);
583
+ const domainChainBinding = context.domain_chain_binding && typeof context.domain_chain_binding === 'object'
584
+ ? context.domain_chain_binding
585
+ : {};
586
+ const relatedCandidates = normalizeRelatedCandidates(context.related_specs);
587
+ const intent = context.intent && typeof context.intent === 'object'
588
+ ? context.intent
589
+ : classifyStudioGoalIntent(goal, policy);
590
+
591
+ if (!policy.enabled) {
592
+ return {
593
+ action: 'disabled',
594
+ reason: 'policy_disabled',
595
+ confidence: 'high',
596
+ spec_id: explicitSpecId || null,
597
+ source: explicitSpecId ? 'explicit-spec' : 'none',
598
+ intent
599
+ };
600
+ }
601
+
602
+ if (explicitSpecId) {
603
+ return {
604
+ action: 'bind_existing',
605
+ reason: 'explicit_spec',
606
+ confidence: 'high',
607
+ spec_id: explicitSpecId,
608
+ source: 'explicit-spec',
609
+ intent
610
+ };
611
+ }
612
+
613
+ const preferredRelated = relatedCandidates.find((item) => item.score >= policy.related_spec_min_score) || null;
614
+ const hasBoundDomainSpec = domainChainBinding.resolved === true && normalizeText(domainChainBinding.spec_id).length > 0;
615
+ const domainSpecId = hasBoundDomainSpec ? normalizeText(domainChainBinding.spec_id) : '';
616
+ const domainProblem = normalizeText(domainChainBinding?.summary?.problem_statement);
617
+ const goalSimilarityToDomain = computeJaccard(tokenizeText(goal), tokenizeText(domainProblem));
618
+
619
+ if (hasBoundDomainSpec && policy.prefer_existing_scene_spec) {
620
+ const shouldDivergeCreate = Boolean(
621
+ policy.allow_new_spec_when_goal_diverges
622
+ && intent.requires_spec
623
+ && goal
624
+ && goalSimilarityToDomain < policy.divergence_similarity_threshold
625
+ );
626
+ if (!shouldDivergeCreate) {
627
+ return {
628
+ action: 'bind_existing',
629
+ reason: 'prefer_existing_scene_spec',
630
+ confidence: 'high',
631
+ spec_id: domainSpecId,
632
+ source: 'scene-domain-chain',
633
+ similarity: goalSimilarityToDomain,
634
+ intent
635
+ };
636
+ }
637
+ }
638
+
639
+ if (preferredRelated) {
640
+ return {
641
+ action: 'bind_existing',
642
+ reason: 'related_spec_match',
643
+ confidence: preferredRelated.score >= (policy.related_spec_min_score + 20) ? 'high' : 'medium',
644
+ spec_id: preferredRelated.spec_id,
645
+ source: 'related-spec',
646
+ matched_score: preferredRelated.score,
647
+ intent
648
+ };
649
+ }
650
+
651
+ const goalMissing = normalizeText(goal).length === 0;
652
+ const shouldCreateByMissingGoal = goalMissing && policy.goal_missing_strategy === 'create_for_tracking';
653
+ const shouldCreateByIntent = intent.requires_spec || policy.force_spec_for_studio_plan;
654
+ const shouldCreate = policy.auto_create_spec && (shouldCreateByIntent || shouldCreateByMissingGoal);
655
+
656
+ if (shouldCreate) {
657
+ return {
658
+ action: 'create_spec',
659
+ reason: goalMissing ? 'goal_missing_tracking' : 'intent_requires_spec',
660
+ confidence: intent.requires_spec ? intent.confidence : 'medium',
661
+ spec_id: null,
662
+ source: 'auto-create',
663
+ intent
664
+ };
665
+ }
666
+
667
+ return {
668
+ action: 'none',
669
+ reason: 'no_spec_required',
670
+ confidence: 'low',
671
+ spec_id: null,
672
+ source: 'none',
673
+ intent
674
+ };
675
+ }
676
+
677
+ async function runStudioAutoIntake(options = {}, dependencies = {}) {
678
+ const projectPath = dependencies.projectPath || process.cwd();
679
+ const fileSystem = dependencies.fileSystem || fs;
680
+ const sceneId = normalizeText(options.scene_id || options.sceneId);
681
+ const goal = normalizeText(options.goal);
682
+ const fromChat = normalizeText(options.from_chat || options.fromChat);
683
+ const explicitSpecId = normalizeText(options.explicit_spec_id || options.spec_id || options.specId);
684
+ const apply = options.apply === true;
685
+ const skip = options.skip === true;
686
+
687
+ const loadedPolicy = options.policy && typeof options.policy === 'object'
688
+ ? { policy: normalizeStudioIntakePolicy(options.policy), policy_path: '(inline)', loaded_from: 'inline' }
689
+ : await loadStudioIntakePolicy(projectPath, fileSystem);
690
+
691
+ const policy = loadedPolicy.policy;
692
+ const intent = classifyStudioGoalIntent(goal, policy);
693
+ const decision = resolveStudioSpecIntakeDecision({
694
+ goal,
695
+ explicit_spec_id: explicitSpecId,
696
+ domain_chain_binding: options.domain_chain_binding || {},
697
+ related_specs: options.related_specs || {},
698
+ intent
699
+ }, policy);
700
+
701
+ const payload = {
702
+ mode: 'studio-auto-intake',
703
+ success: true,
704
+ enabled: policy.enabled === true && !skip,
705
+ policy_path: loadedPolicy.policy_path,
706
+ policy_loaded_from: loadedPolicy.loaded_from,
707
+ policy,
708
+ scene_id: sceneId || null,
709
+ from_chat: fromChat || null,
710
+ goal: goal || null,
711
+ intent,
712
+ decision: {
713
+ ...decision
714
+ },
715
+ selected_spec_id: decision.spec_id || null,
716
+ created_spec: null
717
+ };
718
+
719
+ if (skip && policy.allow_manual_spec_override !== true) {
720
+ throw new Error(
721
+ 'manual spec override is disabled by studio intake policy (allow_manual_spec_override=false)'
722
+ );
723
+ }
724
+
725
+ if (skip) {
726
+ payload.enabled = false;
727
+ payload.decision = {
728
+ action: 'disabled',
729
+ reason: 'manual_override',
730
+ confidence: 'high',
731
+ spec_id: explicitSpecId || null,
732
+ source: explicitSpecId ? 'explicit-spec' : 'none',
733
+ intent
734
+ };
735
+ payload.selected_spec_id = payload.decision.spec_id || null;
736
+ return payload;
737
+ }
738
+
739
+ if (decision.action === 'create_spec') {
740
+ const existingSpecIds = await listExistingSpecIds(projectPath, fileSystem);
741
+ const autoSpecId = createAutoSpecId(sceneId, goal, existingSpecIds, policy);
742
+ payload.decision.spec_id = autoSpecId;
743
+ payload.selected_spec_id = autoSpecId;
744
+ if (apply) {
745
+ const createdSpec = await materializeIntakeSpec(projectPath, {
746
+ scene_id: sceneId,
747
+ from_chat: fromChat,
748
+ goal,
749
+ spec_id: autoSpecId
750
+ }, {
751
+ fileSystem
752
+ });
753
+ payload.created_spec = createdSpec;
754
+ payload.decision.created = createdSpec.created === true;
755
+ } else {
756
+ payload.decision.created = false;
757
+ }
758
+ return payload;
759
+ }
760
+
761
+ payload.selected_spec_id = decision.spec_id || null;
762
+ return payload;
763
+ }
764
+
765
+ async function readJsonSafe(filePath, fileSystem = fs) {
766
+ if (!await fileSystem.pathExists(filePath)) {
767
+ return null;
768
+ }
769
+ try {
770
+ return await fileSystem.readJson(filePath);
771
+ } catch (_error) {
772
+ return null;
773
+ }
774
+ }
775
+
776
+ async function readFileSafe(filePath, fileSystem = fs) {
777
+ if (!await fileSystem.pathExists(filePath)) {
778
+ return '';
779
+ }
780
+ try {
781
+ return await fileSystem.readFile(filePath, 'utf8');
782
+ } catch (_error) {
783
+ return '';
784
+ }
785
+ }
786
+
787
+ function classifySpecLifecycleState(record = {}, staleDays = 14) {
788
+ const nowMs = Date.now();
789
+ const updatedMs = Date.parse(record.updated_at || 0);
790
+ const ageDays = Number.isFinite(updatedMs)
791
+ ? Number(((nowMs - updatedMs) / (1000 * 60 * 60 * 24)).toFixed(2))
792
+ : null;
793
+
794
+ if (record.tasks_total > 0 && record.tasks_done >= record.tasks_total) {
795
+ return {
796
+ state: 'completed',
797
+ age_days: ageDays
798
+ };
799
+ }
800
+ if (ageDays !== null && ageDays > staleDays) {
801
+ return {
802
+ state: 'stale',
803
+ age_days: ageDays
804
+ };
805
+ }
806
+ return {
807
+ state: 'active',
808
+ age_days: ageDays
809
+ };
810
+ }
811
+
812
+ async function scanSpecPortfolio(projectPath = process.cwd(), options = {}, dependencies = {}) {
813
+ const fileSystem = dependencies.fileSystem || fs;
814
+ const specsRoot = path.join(projectPath, '.sce', 'specs');
815
+ if (!await fileSystem.pathExists(specsRoot)) {
816
+ return [];
817
+ }
818
+ const staleDays = normalizeInteger(options.stale_days, 14, 1, 3650);
819
+ const overrideContext = await loadSceneBindingOverrides(projectPath, {
820
+ overridePath: options.override_file || DEFAULT_STUDIO_SCENE_OVERRIDE_PATH
821
+ }, fileSystem);
822
+ const sceneOverrides = normalizeSceneBindingOverrides(overrideContext.overrides || {});
823
+ const entries = await fileSystem.readdir(specsRoot);
824
+ const records = [];
825
+
826
+ for (const entry of entries) {
827
+ const specRoot = path.join(specsRoot, entry);
828
+ let stat = null;
829
+ try {
830
+ stat = await fileSystem.stat(specRoot);
831
+ } catch (_error) {
832
+ continue;
833
+ }
834
+ if (!stat || !stat.isDirectory()) {
835
+ continue;
836
+ }
837
+
838
+ const domainChainPath = path.join(specRoot, 'custom', 'problem-domain-chain.json');
839
+ const problemContractPath = path.join(specRoot, 'custom', 'problem-contract.json');
840
+ const requirementsPath = path.join(specRoot, 'requirements.md');
841
+ const designPath = path.join(specRoot, 'design.md');
842
+ const tasksPath = path.join(specRoot, 'tasks.md');
843
+ const [chain, contract, requirements, design, tasks] = await Promise.all([
844
+ readJsonSafe(domainChainPath, fileSystem),
845
+ readJsonSafe(problemContractPath, fileSystem),
846
+ readFileSafe(requirementsPath, fileSystem),
847
+ readFileSafe(designPath, fileSystem),
848
+ readFileSafe(tasksPath, fileSystem)
849
+ ]);
850
+
851
+ const sceneFromChain = normalizeText(chain && chain.scene_id ? chain.scene_id : '');
852
+ const sceneFromOverride = resolveSceneIdFromOverrides(entry, sceneOverrides);
853
+ const sceneId = sceneFromChain || sceneFromOverride || 'scene.unassigned';
854
+ const sceneSource = sceneFromChain
855
+ ? 'domain-chain'
856
+ : (sceneFromOverride ? 'override' : 'unassigned');
857
+ const problemStatement = normalizeText(
858
+ (chain && chain.problem && chain.problem.statement)
859
+ || (contract && contract.issue_statement)
860
+ || ''
861
+ );
862
+ const taskProgress = parseTasksProgress(tasks);
863
+ const lifecycle = classifySpecLifecycleState({
864
+ updated_at: stat && stat.mtime ? stat.mtime.toISOString() : null,
865
+ tasks_total: taskProgress.total,
866
+ tasks_done: taskProgress.done
867
+ }, staleDays);
868
+ const searchSeed = [
869
+ entry,
870
+ sceneId,
871
+ problemStatement,
872
+ normalizeText(requirements).slice(0, 1600),
873
+ normalizeText(design).slice(0, 1600),
874
+ normalizeText(tasks).slice(0, 1000)
875
+ ].join('\n');
876
+ const tokens = tokenizeText(searchSeed);
877
+
878
+ records.push({
879
+ spec_id: entry,
880
+ scene_id: sceneId,
881
+ problem_statement: problemStatement || null,
882
+ updated_at: stat && stat.mtime ? stat.mtime.toISOString() : null,
883
+ tasks_total: taskProgress.total,
884
+ tasks_done: taskProgress.done,
885
+ tasks_progress: taskProgress.ratio,
886
+ lifecycle_state: lifecycle.state,
887
+ age_days: lifecycle.age_days,
888
+ scene_source: sceneSource,
889
+ tokens
890
+ });
891
+ }
892
+
893
+ records.sort((left, right) => String(right.updated_at || '').localeCompare(String(left.updated_at || '')));
894
+ return records;
895
+ }
896
+
897
+ function buildSceneGovernanceReport(records = [], policy = DEFAULT_STUDIO_INTAKE_POLICY) {
898
+ const governance = policy.governance || DEFAULT_STUDIO_INTAKE_POLICY.governance;
899
+ const threshold = normalizeNumber(governance.duplicate_similarity_threshold, 0.66);
900
+ const maxActive = normalizeInteger(governance.max_active_specs_per_scene, 3, 1, 200);
901
+
902
+ const sceneMap = new Map();
903
+ for (const record of records) {
904
+ const sceneId = normalizeText(record.scene_id) || 'scene.unassigned';
905
+ if (!sceneMap.has(sceneId)) {
906
+ sceneMap.set(sceneId, []);
907
+ }
908
+ sceneMap.get(sceneId).push(record);
909
+ }
910
+
911
+ const scenes = [];
912
+ const mergeCandidates = [];
913
+ const archiveCandidates = [];
914
+ let duplicatePairs = 0;
915
+
916
+ for (const [sceneId, sceneSpecs] of sceneMap.entries()) {
917
+ const sortedSpecs = [...sceneSpecs].sort((left, right) => String(right.updated_at || '').localeCompare(String(left.updated_at || '')));
918
+ const activeSpecs = sortedSpecs.filter((item) => item.lifecycle_state === 'active');
919
+ const staleSpecs = sortedSpecs.filter((item) => item.lifecycle_state === 'stale');
920
+ const completedSpecs = sortedSpecs.filter((item) => item.lifecycle_state === 'completed');
921
+
922
+ const duplicates = [];
923
+ for (let i = 0; i < sortedSpecs.length; i += 1) {
924
+ for (let j = i + 1; j < sortedSpecs.length; j += 1) {
925
+ const left = sortedSpecs[i];
926
+ const right = sortedSpecs[j];
927
+ const similarity = computeJaccard(left.tokens, right.tokens);
928
+ if (similarity >= threshold) {
929
+ duplicatePairs += 1;
930
+ duplicates.push({
931
+ spec_a: left.spec_id,
932
+ spec_b: right.spec_id,
933
+ similarity
934
+ });
935
+ mergeCandidates.push({
936
+ scene_id: sceneId,
937
+ spec_primary: left.spec_id,
938
+ spec_secondary: right.spec_id,
939
+ similarity
940
+ });
941
+ }
942
+ }
943
+ }
944
+
945
+ const overflow = activeSpecs.length > maxActive
946
+ ? activeSpecs.slice(maxActive).map((item) => item.spec_id)
947
+ : [];
948
+ for (const specId of overflow) {
949
+ archiveCandidates.push({
950
+ scene_id: sceneId,
951
+ spec_id: specId,
952
+ reason: `active spec count exceeds limit ${maxActive}`
953
+ });
954
+ }
955
+
956
+ scenes.push({
957
+ scene_id: sceneId,
958
+ total_specs: sortedSpecs.length,
959
+ active_specs: activeSpecs.length,
960
+ completed_specs: completedSpecs.length,
961
+ stale_specs: staleSpecs.length,
962
+ active_limit: maxActive,
963
+ active_overflow_count: overflow.length,
964
+ active_overflow_specs: overflow,
965
+ duplicate_pairs: duplicates,
966
+ specs: sortedSpecs.map((item) => ({
967
+ spec_id: item.spec_id,
968
+ lifecycle_state: item.lifecycle_state,
969
+ updated_at: item.updated_at,
970
+ age_days: item.age_days,
971
+ tasks_total: item.tasks_total,
972
+ tasks_done: item.tasks_done,
973
+ tasks_progress: item.tasks_progress,
974
+ problem_statement: item.problem_statement
975
+ }))
976
+ });
977
+ }
978
+
979
+ scenes.sort((left, right) => {
980
+ if (right.total_specs !== left.total_specs) {
981
+ return right.total_specs - left.total_specs;
982
+ }
983
+ return String(left.scene_id).localeCompare(String(right.scene_id));
984
+ });
985
+
986
+ const totalSpecs = records.length;
987
+ const activeTotal = records.filter((item) => item.lifecycle_state === 'active').length;
988
+ const staleTotal = records.filter((item) => item.lifecycle_state === 'stale').length;
989
+ const completedTotal = records.filter((item) => item.lifecycle_state === 'completed').length;
990
+ const overflowScenes = scenes.filter((item) => item.active_overflow_count > 0).length;
991
+ const alertCount = duplicatePairs + overflowScenes + staleTotal;
992
+
993
+ return {
994
+ scene_count: scenes.length,
995
+ total_specs: totalSpecs,
996
+ active_specs: activeTotal,
997
+ completed_specs: completedTotal,
998
+ stale_specs: staleTotal,
999
+ duplicate_pairs: duplicatePairs,
1000
+ overflow_scenes: overflowScenes,
1001
+ alert_count: alertCount,
1002
+ status: alertCount > 0 ? 'attention' : 'healthy',
1003
+ scenes,
1004
+ actions: {
1005
+ merge_candidates: mergeCandidates,
1006
+ archive_candidates: archiveCandidates
1007
+ }
1008
+ };
1009
+ }
1010
+
1011
+ function classifyBackfillRule(record = {}, backfillPolicy = {}) {
1012
+ const rules = Array.isArray(backfillPolicy.rules) ? backfillPolicy.rules : [];
1013
+ const defaultSceneId = normalizeText(backfillPolicy.default_scene_id) || 'scene.sce-core';
1014
+ const searchText = [
1015
+ normalizeText(record.spec_id).toLowerCase(),
1016
+ normalizeText(record.problem_statement).toLowerCase()
1017
+ ].join(' ');
1018
+ const searchTokens = new Set(tokenizeText(searchText));
1019
+ let bestMatch = null;
1020
+
1021
+ for (const rule of rules) {
1022
+ const ruleId = normalizeText(rule.id);
1023
+ const sceneId = normalizeText(rule.scene_id);
1024
+ const keywords = normalizeTextList(rule.keywords).map((item) => item.toLowerCase());
1025
+ if (!ruleId || !sceneId || keywords.length === 0) {
1026
+ continue;
1027
+ }
1028
+
1029
+ const matchedKeywords = [];
1030
+ for (const keyword of keywords) {
1031
+ if (!keyword) {
1032
+ continue;
1033
+ }
1034
+ if (searchText.includes(keyword) || searchTokens.has(keyword)) {
1035
+ matchedKeywords.push(keyword);
1036
+ }
1037
+ }
1038
+ if (matchedKeywords.length === 0) {
1039
+ continue;
1040
+ }
1041
+
1042
+ const score = matchedKeywords.length;
1043
+ if (!bestMatch || score > bestMatch.score) {
1044
+ bestMatch = {
1045
+ rule_id: ruleId,
1046
+ scene_id: sceneId,
1047
+ matched_keywords: matchedKeywords,
1048
+ score
1049
+ };
1050
+ }
1051
+ }
1052
+
1053
+ if (!bestMatch) {
1054
+ return {
1055
+ scene_id: defaultSceneId,
1056
+ rule_id: 'default',
1057
+ matched_keywords: [],
1058
+ confidence: 'low',
1059
+ source: 'default'
1060
+ };
1061
+ }
1062
+
1063
+ return {
1064
+ scene_id: bestMatch.scene_id,
1065
+ rule_id: bestMatch.rule_id,
1066
+ matched_keywords: bestMatch.matched_keywords,
1067
+ confidence: bestMatch.score >= 2 ? 'high' : 'medium',
1068
+ source: 'rule'
1069
+ };
1070
+ }
1071
+
1072
+ function clampBackfillLimit(value, fallback = 0, max = 1000) {
1073
+ if (value === undefined || value === null || value === '') {
1074
+ return fallback;
1075
+ }
1076
+ const parsed = Number.parseInt(String(value), 10);
1077
+ if (!Number.isFinite(parsed) || parsed < 0) {
1078
+ return fallback;
1079
+ }
1080
+ return Math.min(parsed, max);
1081
+ }
1082
+
1083
+ async function runStudioSceneBackfill(options = {}, dependencies = {}) {
1084
+ const projectPath = dependencies.projectPath || process.cwd();
1085
+ const fileSystem = dependencies.fileSystem || fs;
1086
+ const loaded = options.policy && typeof options.policy === 'object'
1087
+ ? { policy: normalizeStudioIntakePolicy(options.policy), policy_path: '(inline)', loaded_from: 'inline' }
1088
+ : await loadStudioIntakePolicy(projectPath, fileSystem);
1089
+ const policy = loaded.policy;
1090
+ const backfillPolicy = policy.backfill || DEFAULT_STUDIO_INTAKE_POLICY.backfill;
1091
+ const apply = options.apply === true;
1092
+ const refreshGovernance = options.refresh_governance !== false && options.refreshGovernance !== false;
1093
+ const sourceScene = normalizeText(options.source_scene || options.sourceScene || options.scene) || 'scene.unassigned';
1094
+ const includeAll = options.all === true || options.active_only === false || options.activeOnly === false;
1095
+ const activeOnly = includeAll ? false : (options.active_only === true || options.activeOnly === true || backfillPolicy.active_only_default !== false);
1096
+ const limit = clampBackfillLimit(options.limit, 0, 2000);
1097
+ const overrideFile = normalizeText(backfillPolicy.override_file) || DEFAULT_STUDIO_SCENE_OVERRIDE_PATH;
1098
+
1099
+ const records = await scanSpecPortfolio(projectPath, {
1100
+ stale_days: policy.governance && policy.governance.stale_days,
1101
+ override_file: overrideFile
1102
+ }, {
1103
+ fileSystem
1104
+ });
1105
+
1106
+ let candidates = records.filter((item) => normalizeText(item.scene_id) === sourceScene);
1107
+ if (activeOnly) {
1108
+ candidates = candidates.filter((item) => item.lifecycle_state === 'active');
1109
+ }
1110
+ if (limit > 0) {
1111
+ candidates = candidates.slice(0, limit);
1112
+ }
1113
+
1114
+ const assignmentPlan = candidates.map((record) => {
1115
+ const decision = classifyBackfillRule(record, backfillPolicy);
1116
+ return {
1117
+ spec_id: record.spec_id,
1118
+ from_scene_id: sourceScene,
1119
+ to_scene_id: decision.scene_id,
1120
+ lifecycle_state: record.lifecycle_state,
1121
+ rule_id: decision.rule_id,
1122
+ source: decision.source,
1123
+ confidence: decision.confidence,
1124
+ matched_keywords: decision.matched_keywords
1125
+ };
1126
+ });
1127
+
1128
+ const overrideContext = await loadSceneBindingOverrides(projectPath, {
1129
+ overridePath: overrideFile
1130
+ }, fileSystem);
1131
+ const existingOverrides = normalizeSceneBindingOverrides(overrideContext.overrides || {});
1132
+ const nextOverrides = normalizeSceneBindingOverrides(existingOverrides);
1133
+ const now = new Date().toISOString();
1134
+ let changedCount = 0;
1135
+
1136
+ for (const item of assignmentPlan) {
1137
+ const existing = existingOverrides.mappings[item.spec_id];
1138
+ const currentScene = normalizeText(existing && existing.scene_id);
1139
+ if (currentScene === item.to_scene_id) {
1140
+ continue;
1141
+ }
1142
+ nextOverrides.mappings[item.spec_id] = {
1143
+ scene_id: item.to_scene_id,
1144
+ source: 'scene-backfill',
1145
+ rule_id: item.rule_id,
1146
+ updated_at: now
1147
+ };
1148
+ changedCount += 1;
1149
+ }
1150
+
1151
+ const totalsByTargetScene = {};
1152
+ for (const item of assignmentPlan) {
1153
+ totalsByTargetScene[item.to_scene_id] = (totalsByTargetScene[item.to_scene_id] || 0) + 1;
1154
+ }
1155
+
1156
+ const payload = {
1157
+ mode: 'studio-scene-backfill',
1158
+ success: true,
1159
+ generated_at: now,
1160
+ policy_path: loaded.policy_path,
1161
+ policy_loaded_from: loaded.loaded_from,
1162
+ source_scene: sourceScene,
1163
+ active_only: activeOnly,
1164
+ apply,
1165
+ refresh_governance: refreshGovernance,
1166
+ override_file: overrideFile,
1167
+ summary: {
1168
+ candidate_count: assignmentPlan.length,
1169
+ changed_count: changedCount,
1170
+ target_scene_count: Object.keys(totalsByTargetScene).length
1171
+ },
1172
+ targets: totalsByTargetScene,
1173
+ assignments: assignmentPlan
1174
+ };
1175
+
1176
+ if (apply) {
1177
+ const overrideAbsolutePath = path.join(projectPath, overrideFile);
1178
+ await fileSystem.ensureDir(path.dirname(overrideAbsolutePath));
1179
+ const serialized = {
1180
+ schema_version: '1.0',
1181
+ generated_at: nextOverrides.generated_at || now,
1182
+ updated_at: now,
1183
+ source: 'studio-scene-backfill',
1184
+ mappings: nextOverrides.mappings
1185
+ };
1186
+ await fileSystem.writeJson(overrideAbsolutePath, serialized, { spaces: 2 });
1187
+ payload.override_written = overrideFile;
1188
+ if (refreshGovernance) {
1189
+ const refreshed = await runStudioSpecGovernance({
1190
+ apply: true
1191
+ }, {
1192
+ projectPath,
1193
+ fileSystem
1194
+ });
1195
+ payload.governance = {
1196
+ status: refreshed.summary ? refreshed.summary.status : null,
1197
+ alert_count: refreshed.summary ? Number(refreshed.summary.alert_count || 0) : 0,
1198
+ report_file: refreshed.report_file || null,
1199
+ scene_index_file: refreshed.scene_index_file || null
1200
+ };
1201
+ }
1202
+ }
1203
+
1204
+ return payload;
1205
+ }
1206
+
1207
+ async function runStudioSpecGovernance(options = {}, dependencies = {}) {
1208
+ const projectPath = dependencies.projectPath || process.cwd();
1209
+ const fileSystem = dependencies.fileSystem || fs;
1210
+ const loaded = options.policy && typeof options.policy === 'object'
1211
+ ? { policy: normalizeStudioIntakePolicy(options.policy), policy_path: '(inline)', loaded_from: 'inline' }
1212
+ : await loadStudioIntakePolicy(projectPath, fileSystem);
1213
+ const policy = loaded.policy;
1214
+ const governance = policy.governance || DEFAULT_STUDIO_INTAKE_POLICY.governance;
1215
+ const backfill = policy.backfill || DEFAULT_STUDIO_INTAKE_POLICY.backfill;
1216
+ const apply = options.apply !== false;
1217
+ const sceneFilter = normalizeText(options.scene_id || options.sceneId || options.scene);
1218
+ const overrideFile = normalizeText(backfill.override_file) || DEFAULT_STUDIO_SCENE_OVERRIDE_PATH;
1219
+
1220
+ const records = await scanSpecPortfolio(projectPath, {
1221
+ stale_days: governance.stale_days,
1222
+ override_file: overrideFile
1223
+ }, {
1224
+ fileSystem
1225
+ });
1226
+
1227
+ const filteredRecords = sceneFilter
1228
+ ? records.filter((item) => normalizeText(item.scene_id) === sceneFilter)
1229
+ : records;
1230
+
1231
+ const summary = buildSceneGovernanceReport(filteredRecords, policy);
1232
+ const generatedAt = new Date().toISOString();
1233
+ const reportPayload = {
1234
+ mode: 'studio-spec-governance',
1235
+ success: true,
1236
+ generated_at: generatedAt,
1237
+ scene_filter: sceneFilter || null,
1238
+ policy_path: loaded.policy_path,
1239
+ policy_loaded_from: loaded.loaded_from,
1240
+ policy: {
1241
+ governance,
1242
+ backfill: {
1243
+ override_file: overrideFile
1244
+ }
1245
+ },
1246
+ summary: {
1247
+ scene_count: summary.scene_count,
1248
+ total_specs: summary.total_specs,
1249
+ active_specs: summary.active_specs,
1250
+ completed_specs: summary.completed_specs,
1251
+ stale_specs: summary.stale_specs,
1252
+ duplicate_pairs: summary.duplicate_pairs,
1253
+ overflow_scenes: summary.overflow_scenes,
1254
+ alert_count: summary.alert_count,
1255
+ status: summary.status
1256
+ },
1257
+ scenes: summary.scenes,
1258
+ actions: summary.actions
1259
+ };
1260
+
1261
+ if (apply) {
1262
+ const reportPath = path.join(projectPath, DEFAULT_STUDIO_PORTFOLIO_REPORT);
1263
+ const indexPath = path.join(projectPath, DEFAULT_STUDIO_SCENE_INDEX);
1264
+ await fileSystem.ensureDir(path.dirname(reportPath));
1265
+ await fileSystem.writeJson(reportPath, reportPayload, { spaces: 2 });
1266
+ const sceneIndex = {
1267
+ schema_version: '1.0',
1268
+ generated_at: generatedAt,
1269
+ scene_filter: sceneFilter || null,
1270
+ scenes: {}
1271
+ };
1272
+ for (const scene of summary.scenes) {
1273
+ sceneIndex.scenes[scene.scene_id] = {
1274
+ total_specs: scene.total_specs,
1275
+ active_specs: scene.active_specs,
1276
+ completed_specs: scene.completed_specs,
1277
+ stale_specs: scene.stale_specs,
1278
+ spec_ids: scene.specs.map((item) => item.spec_id),
1279
+ active_spec_ids: scene.specs
1280
+ .filter((item) => item.lifecycle_state === 'active')
1281
+ .map((item) => item.spec_id),
1282
+ stale_spec_ids: scene.specs
1283
+ .filter((item) => item.lifecycle_state === 'stale')
1284
+ .map((item) => item.spec_id)
1285
+ };
1286
+ }
1287
+ await fileSystem.writeJson(indexPath, sceneIndex, { spaces: 2 });
1288
+ reportPayload.report_file = DEFAULT_STUDIO_PORTFOLIO_REPORT;
1289
+ reportPayload.scene_index_file = DEFAULT_STUDIO_SCENE_INDEX;
1290
+ }
1291
+
1292
+ return reportPayload;
1293
+ }
1294
+
1295
+ module.exports = {
1296
+ DEFAULT_STUDIO_INTAKE_POLICY_PATH,
1297
+ DEFAULT_STUDIO_INTAKE_POLICY,
1298
+ DEFAULT_STUDIO_SCENE_OVERRIDE_PATH,
1299
+ DEFAULT_STUDIO_PORTFOLIO_REPORT,
1300
+ DEFAULT_STUDIO_SCENE_INDEX,
1301
+ normalizeStudioIntakePolicy,
1302
+ loadStudioIntakePolicy,
1303
+ classifyStudioGoalIntent,
1304
+ resolveStudioSpecIntakeDecision,
1305
+ createAutoSpecId,
1306
+ materializeIntakeSpec,
1307
+ runStudioAutoIntake,
1308
+ parseTasksProgress,
1309
+ scanSpecPortfolio,
1310
+ buildSceneGovernanceReport,
1311
+ runStudioSceneBackfill,
1312
+ runStudioSpecGovernance,
1313
+ tokenizeText,
1314
+ computeJaccard
1315
+ };