scene-capability-engine 3.6.10 → 3.6.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [3.6.11] - 2026-03-05
11
+
12
+ ### Added
13
+ - Task quality governance commands:
14
+ - `sce task draft`
15
+ - `sce task consolidate`
16
+ - `sce task score`
17
+ - `sce task promote`
18
+ - Task quality policy support:
19
+ - `.sce/config/task-quality-policy.json`
20
+ - policy overrides via `--policy <path>`
21
+ - Task promotion now emits acceptance suggestions when acceptance criteria are missing.
22
+
10
23
  ## [3.6.10] - 2026-03-05
11
24
 
12
25
  ### Added
package/README.md CHANGED
@@ -218,5 +218,5 @@ MIT. See [LICENSE](LICENSE).
218
218
 
219
219
  ---
220
220
 
221
- **Version**: 3.6.10
221
+ **Version**: 3.6.11
222
222
  **Last Updated**: 2026-03-05
package/README.zh.md CHANGED
@@ -218,5 +218,5 @@ MIT,见 [LICENSE](LICENSE)。
218
218
 
219
219
  ---
220
220
 
221
- **版本**:3.6.10
221
+ **版本**:3.6.11
222
222
  **最后更新**:2026-03-05
@@ -201,6 +201,26 @@ Task references and studio runtime events are persisted in SQLite state store:
201
201
  - In-memory fallback is restricted to `NODE_ENV=test` or `SCE_STATE_ALLOW_MEMORY_FALLBACK=1`.
202
202
  - If SQLite runtime support is unavailable outside fallback conditions, `task ref/show/rerun` and `studio events` persistence operations fail fast.
203
203
 
204
+ #### Task Quality Governance (draft -> score -> promote)
205
+
206
+ ```bash
207
+ # Create draft from dialogue
208
+ sce task draft --scene scene.customer-order --input "Fix checkout timeout and add retry dashboard" --json
209
+
210
+ # Consolidate drafts for a scene
211
+ sce task consolidate --scene scene.customer-order --json
212
+
213
+ # Score draft quality
214
+ sce task score --draft <draft-id> --json
215
+
216
+ # Promote into tasks.md (quality gate enforced unless --force)
217
+ sce task promote --draft <draft-id> --spec 02-00-checkout-optimization --json
218
+ ```
219
+
220
+ Policy file:
221
+ - `.sce/config/task-quality-policy.json`
222
+ - Fields: `min_quality_score`, `require_acceptance_criteria`, `allow_needs_split`, `auto_suggest_acceptance`, `max_sub_goals`
223
+
204
224
  ### Context & Prompts
205
225
 
206
226
  ```bash
@@ -12,6 +12,16 @@ const TaskClaimer = require('../task/task-claimer');
12
12
  const WorkspaceManager = require('../workspace/workspace-manager');
13
13
  const { TaskRefRegistry } = require('../task/task-ref-registry');
14
14
  const { ensureWriteAuthorization } = require('../security/write-authorization');
15
+ const {
16
+ buildDraft,
17
+ scoreDraft,
18
+ appendDraft,
19
+ updateDraft,
20
+ consolidateDrafts,
21
+ loadDraftStore,
22
+ promoteDraftToTasks
23
+ } = require('../task/task-quality');
24
+ const { loadTaskQualityPolicy } = require('../task/task-quality-policy');
15
25
 
16
26
  function normalizeString(value) {
17
27
  if (typeof value !== 'string') {
@@ -32,6 +42,195 @@ function resolveStudioStageFromTaskKey(taskKey) {
32
42
  return normalizeString(normalized.slice('studio:'.length));
33
43
  }
34
44
 
45
+ async function runTaskDraftCommand(options = {}) {
46
+ const projectPath = process.cwd();
47
+ const sceneId = normalizeString(options.scene);
48
+ const specId = normalizeString(options.spec);
49
+ const inputText = normalizeString(options.input);
50
+ const inputFile = normalizeString(options.inputFile);
51
+ const policyConfig = await loadTaskQualityPolicy(projectPath, options.policy, fs);
52
+ const policy = policyConfig.policy;
53
+
54
+ if (!sceneId) {
55
+ throw new Error('scene is required');
56
+ }
57
+
58
+ let rawRequest = inputText;
59
+ if (!rawRequest && inputFile) {
60
+ rawRequest = await fs.readFile(inputFile, 'utf8');
61
+ }
62
+ if (!rawRequest) {
63
+ throw new Error('input text is required (use --input or --input-file)');
64
+ }
65
+
66
+ const draft = buildDraft(rawRequest, {
67
+ scene_id: sceneId,
68
+ spec_id: specId,
69
+ acceptance_criteria: options.acceptance ? String(options.acceptance).split('|') : [],
70
+ confidence: options.confidence,
71
+ policy
72
+ });
73
+ const quality = scoreDraft(draft, policy);
74
+ draft.quality_score = quality.score;
75
+ draft.quality_breakdown = quality.breakdown;
76
+ draft.quality_issues = quality.issues;
77
+ draft.quality_passed = quality.passed;
78
+
79
+ const result = await appendDraft(projectPath, draft, fs);
80
+ const payload = {
81
+ mode: 'task-draft',
82
+ draft: draft,
83
+ store_path: result.store_path
84
+ };
85
+
86
+ if (options.json) {
87
+ process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
88
+ return;
89
+ }
90
+
91
+ console.log(chalk.green('✅ Draft created'));
92
+ console.log(chalk.gray(` id: ${draft.draft_id}`));
93
+ console.log(chalk.gray(` score: ${draft.quality_score}`));
94
+ }
95
+
96
+ async function runTaskConsolidateCommand(options = {}) {
97
+ const projectPath = process.cwd();
98
+ const sceneId = normalizeString(options.scene);
99
+ const specId = normalizeString(options.spec);
100
+ if (!sceneId) {
101
+ throw new Error('scene is required');
102
+ }
103
+ const result = await consolidateDrafts(projectPath, {
104
+ scene_id: sceneId,
105
+ spec_id: specId
106
+ }, fs);
107
+
108
+ const payload = {
109
+ mode: 'task-consolidate',
110
+ scene_id: sceneId,
111
+ spec_id: specId || null,
112
+ merged: result.merged,
113
+ drafts: result.drafts,
114
+ store_path: result.store_path
115
+ };
116
+
117
+ if (options.json) {
118
+ process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
119
+ return;
120
+ }
121
+
122
+ console.log(chalk.green('✅ Drafts consolidated'));
123
+ console.log(chalk.gray(` merged: ${result.merged.length}`));
124
+ }
125
+
126
+ async function runTaskScoreCommand(options = {}) {
127
+ const projectPath = process.cwd();
128
+ const draftId = normalizeString(options.draft);
129
+ const all = Boolean(options.all);
130
+ const policyConfig = await loadTaskQualityPolicy(projectPath, options.policy, fs);
131
+ const policy = policyConfig.policy;
132
+ const store = await loadDraftStore(projectPath, fs);
133
+ const drafts = Array.isArray(store.payload.drafts) ? store.payload.drafts : [];
134
+
135
+ const targets = all
136
+ ? drafts
137
+ : drafts.filter((draft) => draft.draft_id === draftId);
138
+
139
+ if (!all && targets.length === 0) {
140
+ throw new Error(`draft not found: ${draftId}`);
141
+ }
142
+
143
+ const scored = [];
144
+ for (const draft of targets) {
145
+ const quality = scoreDraft(draft, policy);
146
+ const updated = await updateDraft(projectPath, draft.draft_id, (current) => ({
147
+ ...current,
148
+ quality_score: quality.score,
149
+ quality_breakdown: quality.breakdown,
150
+ quality_issues: quality.issues,
151
+ quality_passed: quality.passed
152
+ }), fs);
153
+ if (updated) {
154
+ scored.push(updated);
155
+ }
156
+ }
157
+
158
+ const payload = {
159
+ mode: 'task-score',
160
+ draft_id: draftId || null,
161
+ total: scored.length,
162
+ drafts: scored
163
+ };
164
+
165
+ if (options.json) {
166
+ process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
167
+ return;
168
+ }
169
+
170
+ console.log(chalk.green('✅ Draft scoring complete'));
171
+ console.log(chalk.gray(` scored: ${scored.length}`));
172
+ }
173
+
174
+ async function runTaskPromoteCommand(options = {}) {
175
+ const projectPath = process.cwd();
176
+ const draftId = normalizeString(options.draft);
177
+ const specId = normalizeString(options.spec);
178
+ const policyConfig = await loadTaskQualityPolicy(projectPath, options.policy, fs);
179
+ const policy = policyConfig.policy;
180
+ if (!draftId) {
181
+ throw new Error('draft id is required');
182
+ }
183
+ if (!specId) {
184
+ throw new Error('spec is required to promote draft');
185
+ }
186
+
187
+ const store = await loadDraftStore(projectPath, fs);
188
+ const drafts = Array.isArray(store.payload.drafts) ? store.payload.drafts : [];
189
+ const draft = drafts.find((item) => item.draft_id === draftId);
190
+ if (!draft) {
191
+ throw new Error(`draft not found: ${draftId}`);
192
+ }
193
+ const quality = scoreDraft(draft, policy);
194
+ if (!quality.passed && !options.force) {
195
+ throw new Error('draft quality gate failed; use --force to override');
196
+ }
197
+
198
+ const promoted = await promoteDraftToTasks(projectPath, {
199
+ ...draft,
200
+ spec_id: specId
201
+ }, fs);
202
+
203
+ const updated = await updateDraft(projectPath, draftId, (current) => ({
204
+ ...current,
205
+ spec_id: specId,
206
+ status: 'promoted',
207
+ quality_score: quality.score,
208
+ quality_breakdown: quality.breakdown,
209
+ quality_issues: quality.issues,
210
+ quality_passed: quality.passed,
211
+ promoted_at: new Date().toISOString(),
212
+ promoted_task_id: promoted.task_id,
213
+ promoted_tasks_path: promoted.tasks_path
214
+ }), fs);
215
+
216
+ const payload = {
217
+ mode: 'task-promote',
218
+ draft_id: draftId,
219
+ spec_id: specId,
220
+ task_id: promoted.task_id,
221
+ tasks_path: toRelativePosix(projectPath, promoted.tasks_path),
222
+ draft: updated
223
+ };
224
+
225
+ if (options.json) {
226
+ process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
227
+ return;
228
+ }
229
+
230
+ console.log(chalk.green('✅ Draft promoted to tasks.md'));
231
+ console.log(chalk.gray(` task: ${promoted.task_id}`));
232
+ }
233
+
35
234
  function isStudioTaskRef(lookup = {}) {
36
235
  const source = normalizeString(lookup.source);
37
236
  if (source === 'studio-stage') {
@@ -771,6 +970,74 @@ function registerTaskCommands(program) {
771
970
  process.exitCode = 1;
772
971
  }
773
972
  });
973
+
974
+ task
975
+ .command('draft')
976
+ .description('Create a task draft from dialogue input')
977
+ .requiredOption('--scene <scene-id>', 'Scene identifier')
978
+ .option('--spec <spec-id>', 'Spec identifier')
979
+ .option('--input <text>', 'Raw task input text')
980
+ .option('--input-file <path>', 'File containing raw task input')
981
+ .option('--acceptance <items>', 'Pipe-delimited acceptance criteria')
982
+ .option('--confidence <score>', 'Manual confidence (0-1)')
983
+ .option('--policy <path>', 'Task quality policy path override')
984
+ .option('--json', 'Print machine-readable JSON output')
985
+ .action(async (options) => {
986
+ try {
987
+ await runTaskDraftCommand(options);
988
+ } catch (error) {
989
+ console.error(chalk.red(`Task draft failed: ${error.message}`));
990
+ process.exitCode = 1;
991
+ }
992
+ });
993
+
994
+ task
995
+ .command('consolidate')
996
+ .description('Consolidate task drafts by scene/spec')
997
+ .requiredOption('--scene <scene-id>', 'Scene identifier')
998
+ .option('--spec <spec-id>', 'Spec identifier')
999
+ .option('--json', 'Print machine-readable JSON output')
1000
+ .action(async (options) => {
1001
+ try {
1002
+ await runTaskConsolidateCommand(options);
1003
+ } catch (error) {
1004
+ console.error(chalk.red(`Task consolidate failed: ${error.message}`));
1005
+ process.exitCode = 1;
1006
+ }
1007
+ });
1008
+
1009
+ task
1010
+ .command('score')
1011
+ .description('Score task draft quality')
1012
+ .option('--draft <draft-id>', 'Draft identifier')
1013
+ .option('--all', 'Score all drafts')
1014
+ .option('--policy <path>', 'Task quality policy path override')
1015
+ .option('--json', 'Print machine-readable JSON output')
1016
+ .action(async (options) => {
1017
+ try {
1018
+ await runTaskScoreCommand(options);
1019
+ } catch (error) {
1020
+ console.error(chalk.red(`Task score failed: ${error.message}`));
1021
+ process.exitCode = 1;
1022
+ }
1023
+ });
1024
+
1025
+ task
1026
+ .command('promote')
1027
+ .description('Promote a task draft into spec tasks.md')
1028
+ .requiredOption('--draft <draft-id>', 'Draft identifier')
1029
+ .requiredOption('--spec <spec-id>', 'Spec identifier')
1030
+ .option('--force', 'Override quality gate')
1031
+ .option('--policy <path>', 'Task quality policy path override')
1032
+ .option('--json', 'Print machine-readable JSON output')
1033
+ .action(async (options) => {
1034
+ try {
1035
+ await runTaskPromoteCommand(options);
1036
+ } catch (error) {
1037
+ console.error(chalk.red(`Task promote failed: ${error.message}`));
1038
+ process.exitCode = 1;
1039
+ }
1040
+ });
774
1041
  }
775
1042
 
776
1043
  module.exports = {
@@ -780,5 +1047,9 @@ module.exports = {
780
1047
  runTaskRefCommand,
781
1048
  runTaskShowCommand,
782
1049
  runTaskRerunCommand,
1050
+ runTaskDraftCommand,
1051
+ runTaskConsolidateCommand,
1052
+ runTaskScoreCommand,
1053
+ runTaskPromoteCommand,
783
1054
  registerTaskCommands
784
1055
  };
@@ -0,0 +1,109 @@
1
+ const fs = require('fs-extra');
2
+ const path = require('path');
3
+
4
+ const DEFAULT_POLICY_PATH = path.join('.sce', 'config', 'task-quality-policy.json');
5
+
6
+ const DEFAULT_TASK_QUALITY_POLICY = Object.freeze({
7
+ schema_version: '1.0',
8
+ min_quality_score: 70,
9
+ require_acceptance_criteria: true,
10
+ allow_needs_split: false,
11
+ auto_suggest_acceptance: true,
12
+ max_sub_goals: 3
13
+ });
14
+
15
+ function normalizeText(value) {
16
+ if (typeof value !== 'string') {
17
+ return '';
18
+ }
19
+ return value.trim();
20
+ }
21
+
22
+ function normalizeBoolean(value, fallback) {
23
+ if (typeof value === 'boolean') {
24
+ return value;
25
+ }
26
+ const normalized = normalizeText(`${value || ''}`).toLowerCase();
27
+ if (!normalized) {
28
+ return fallback;
29
+ }
30
+ if (['1', 'true', 'yes', 'y', 'on'].includes(normalized)) {
31
+ return true;
32
+ }
33
+ if (['0', 'false', 'no', 'n', 'off'].includes(normalized)) {
34
+ return false;
35
+ }
36
+ return fallback;
37
+ }
38
+
39
+ function toPositiveInteger(value, fallback) {
40
+ const parsed = Number.parseInt(`${value}`, 10);
41
+ if (!Number.isFinite(parsed) || parsed <= 0) {
42
+ return fallback;
43
+ }
44
+ return parsed;
45
+ }
46
+
47
+ function normalizePolicy(payload = {}) {
48
+ const policy = payload && typeof payload === 'object' ? payload : {};
49
+ return {
50
+ schema_version: normalizeText(policy.schema_version) || DEFAULT_TASK_QUALITY_POLICY.schema_version,
51
+ min_quality_score: toPositiveInteger(
52
+ policy.min_quality_score,
53
+ DEFAULT_TASK_QUALITY_POLICY.min_quality_score
54
+ ),
55
+ require_acceptance_criteria: normalizeBoolean(
56
+ policy.require_acceptance_criteria,
57
+ DEFAULT_TASK_QUALITY_POLICY.require_acceptance_criteria
58
+ ),
59
+ allow_needs_split: normalizeBoolean(
60
+ policy.allow_needs_split,
61
+ DEFAULT_TASK_QUALITY_POLICY.allow_needs_split
62
+ ),
63
+ auto_suggest_acceptance: normalizeBoolean(
64
+ policy.auto_suggest_acceptance,
65
+ DEFAULT_TASK_QUALITY_POLICY.auto_suggest_acceptance
66
+ ),
67
+ max_sub_goals: toPositiveInteger(
68
+ policy.max_sub_goals,
69
+ DEFAULT_TASK_QUALITY_POLICY.max_sub_goals
70
+ )
71
+ };
72
+ }
73
+
74
+ async function loadTaskQualityPolicy(projectPath, policyPath, fileSystem = fs) {
75
+ const resolvedPath = normalizeText(policyPath) || DEFAULT_POLICY_PATH;
76
+ const absolutePath = path.isAbsolute(resolvedPath)
77
+ ? resolvedPath
78
+ : path.join(projectPath, resolvedPath);
79
+
80
+ if (!await fileSystem.pathExists(absolutePath)) {
81
+ return {
82
+ policy: normalizePolicy(DEFAULT_TASK_QUALITY_POLICY),
83
+ path: resolvedPath,
84
+ loaded_from: 'default'
85
+ };
86
+ }
87
+
88
+ try {
89
+ const payload = await fileSystem.readJson(absolutePath);
90
+ return {
91
+ policy: normalizePolicy(payload),
92
+ path: resolvedPath,
93
+ loaded_from: 'file'
94
+ };
95
+ } catch (_error) {
96
+ return {
97
+ policy: normalizePolicy(DEFAULT_TASK_QUALITY_POLICY),
98
+ path: resolvedPath,
99
+ loaded_from: 'default'
100
+ };
101
+ }
102
+ }
103
+
104
+ module.exports = {
105
+ DEFAULT_POLICY_PATH,
106
+ DEFAULT_TASK_QUALITY_POLICY,
107
+ normalizePolicy,
108
+ loadTaskQualityPolicy
109
+ };
@@ -0,0 +1,378 @@
1
+ const fs = require('fs-extra');
2
+ const path = require('path');
3
+ const TaskClaimer = require('./task-claimer');
4
+ const { DEFAULT_TASK_QUALITY_POLICY } = require('./task-quality-policy');
5
+
6
+ const DEFAULT_TASK_GOVERNANCE_DIR = '.sce/task-governance';
7
+ const DEFAULT_DRAFTS_FILE = 'drafts.json';
8
+ const DEFAULT_SCHEMA_VERSION = '1.0';
9
+
10
+ function normalizeText(value) {
11
+ if (typeof value !== 'string') {
12
+ return '';
13
+ }
14
+ return value.trim();
15
+ }
16
+
17
+ function normalizeStringArray(value) {
18
+ if (!Array.isArray(value)) {
19
+ return [];
20
+ }
21
+ return value.map((item) => normalizeText(item)).filter(Boolean);
22
+ }
23
+
24
+ function toNonNegativeInteger(value, fallback = 0) {
25
+ const parsed = Number.parseInt(`${value}`, 10);
26
+ if (!Number.isFinite(parsed) || parsed < 0) {
27
+ return fallback;
28
+ }
29
+ return parsed;
30
+ }
31
+
32
+ function toPositiveInteger(value, fallback = 1) {
33
+ const parsed = Number.parseInt(`${value}`, 10);
34
+ if (!Number.isFinite(parsed) || parsed <= 0) {
35
+ return fallback;
36
+ }
37
+ return parsed;
38
+ }
39
+
40
+ function buildDraftId() {
41
+ const now = Date.now();
42
+ const random = Math.random().toString(36).slice(2, 8);
43
+ return `draft-${now}-${random}`;
44
+ }
45
+
46
+ function splitGoals(text) {
47
+ const normalized = normalizeText(text);
48
+ if (!normalized) {
49
+ return [];
50
+ }
51
+ const separators = /[;;\n]|(?:\s+and\s+)|(?:\s+then\s+)|(?:\s+also\s+)|(?:\s+plus\s+)|、|,|,|并且|同时|然后|以及|并/;
52
+ const parts = normalized.split(separators).map((item) => normalizeText(item)).filter(Boolean);
53
+ if (parts.length <= 1) {
54
+ return parts;
55
+ }
56
+ const unique = [];
57
+ for (const part of parts) {
58
+ if (!unique.some((item) => item === part)) {
59
+ unique.push(part);
60
+ }
61
+ }
62
+ return unique;
63
+ }
64
+
65
+ function normalizeTitle(text) {
66
+ const goals = splitGoals(text);
67
+ const base = goals.length > 0 ? goals[0] : normalizeText(text);
68
+ if (!base) {
69
+ return 'Untitled task';
70
+ }
71
+ return base.length > 120 ? `${base.slice(0, 117)}...` : base;
72
+ }
73
+
74
+ function suggestAcceptanceCriteria(goal) {
75
+ const normalized = normalizeText(goal);
76
+ if (!normalized) {
77
+ return [];
78
+ }
79
+ return [
80
+ `Define completion criteria for: ${normalized}`,
81
+ 'Verify no regressions in related flows',
82
+ 'Add/confirm validation evidence in tasks.md'
83
+ ];
84
+ }
85
+
86
+ function buildDraft(rawRequest, options = {}) {
87
+ const now = new Date().toISOString();
88
+ const normalizedRaw = normalizeText(rawRequest);
89
+ const goals = splitGoals(normalizedRaw);
90
+ const subGoals = goals.length > 1 ? goals.slice(1, 4) : [];
91
+ const needsSplit = goals.length > 1;
92
+ const acceptanceCriteria = normalizeStringArray(options.acceptance_criteria);
93
+ const acceptanceSuggestions = acceptanceCriteria.length === 0
94
+ ? suggestAcceptanceCriteria(goals.length > 0 ? goals[0] : normalizedRaw)
95
+ : [];
96
+ const confidenceBase = options.confidence !== undefined
97
+ ? Number(options.confidence)
98
+ : 0.55;
99
+ const confidence = Math.max(0.1, Math.min(0.95, confidenceBase + (acceptanceCriteria.length > 0 ? 0.1 : -0.05)));
100
+
101
+ return {
102
+ draft_id: buildDraftId(),
103
+ scene_id: normalizeText(options.scene_id),
104
+ spec_id: normalizeText(options.spec_id),
105
+ raw_request: normalizedRaw,
106
+ title_norm: normalizeTitle(normalizedRaw),
107
+ goal: goals.length > 0 ? goals[0] : normalizedRaw,
108
+ sub_goals: subGoals,
109
+ acceptance_criteria: acceptanceCriteria,
110
+ acceptance_suggestions: acceptanceSuggestions,
111
+ needs_split: needsSplit,
112
+ confidence,
113
+ status: 'draft',
114
+ created_at: now,
115
+ updated_at: now
116
+ };
117
+ }
118
+
119
+ function scoreDraft(draft, policy = DEFAULT_TASK_QUALITY_POLICY) {
120
+ const issues = [];
121
+ const goal = normalizeText(draft.goal);
122
+ const title = normalizeText(draft.title_norm);
123
+ const acceptance = normalizeStringArray(draft.acceptance_criteria);
124
+ const needsSplit = Boolean(draft.needs_split);
125
+ const requireAcceptance = policy ? policy.require_acceptance_criteria !== false : true;
126
+ const allowSplit = policy ? policy.allow_needs_split === true : false;
127
+
128
+ let clarity = 100;
129
+ if (!title || title.length < 4) {
130
+ clarity -= 30;
131
+ issues.push('title too short or missing');
132
+ }
133
+ if (needsSplit) {
134
+ clarity -= 25;
135
+ issues.push('multiple goals detected; split required');
136
+ }
137
+
138
+ let verifiability = 90;
139
+ if (requireAcceptance && acceptance.length === 0) {
140
+ verifiability = 45;
141
+ issues.push('acceptance criteria missing');
142
+ }
143
+
144
+ let executability = goal ? 85 : 40;
145
+ if (!goal) {
146
+ issues.push('goal missing');
147
+ }
148
+ if (!normalizeText(draft.spec_id)) {
149
+ executability -= 20;
150
+ issues.push('spec_id missing');
151
+ }
152
+
153
+ let risk = 40;
154
+ if (needsSplit) {
155
+ risk = 65;
156
+ }
157
+
158
+ clarity = Math.max(0, Math.min(100, Math.round(clarity)));
159
+ verifiability = Math.max(0, Math.min(100, Math.round(verifiability)));
160
+ executability = Math.max(0, Math.min(100, Math.round(executability)));
161
+ risk = Math.max(0, Math.min(100, Math.round(risk)));
162
+
163
+ const overall = Math.round(
164
+ (clarity * 0.3)
165
+ + (verifiability * 0.3)
166
+ + (executability * 0.3)
167
+ + ((100 - risk) * 0.1)
168
+ );
169
+
170
+ const minScore = policy && Number.isFinite(policy.min_quality_score)
171
+ ? Math.max(1, Math.min(100, Math.round(policy.min_quality_score)))
172
+ : 70;
173
+ const acceptancePassed = requireAcceptance ? acceptance.length > 0 : true;
174
+ const splitPassed = allowSplit ? true : !needsSplit;
175
+ const passed = overall >= minScore && acceptancePassed && splitPassed;
176
+
177
+ return {
178
+ score: overall,
179
+ passed,
180
+ breakdown: {
181
+ clarity,
182
+ verifiability,
183
+ executability,
184
+ risk
185
+ },
186
+ issues
187
+ };
188
+ }
189
+
190
+ async function loadDraftStore(projectPath, fileSystem = fs) {
191
+ const storePath = path.join(projectPath, DEFAULT_TASK_GOVERNANCE_DIR, DEFAULT_DRAFTS_FILE);
192
+ if (!await fileSystem.pathExists(storePath)) {
193
+ return {
194
+ path: storePath,
195
+ payload: {
196
+ schema_version: DEFAULT_SCHEMA_VERSION,
197
+ updated_at: new Date().toISOString(),
198
+ drafts: []
199
+ }
200
+ };
201
+ }
202
+
203
+ const payload = await fileSystem.readJson(storePath);
204
+ return {
205
+ path: storePath,
206
+ payload: payload && typeof payload === 'object'
207
+ ? payload
208
+ : {
209
+ schema_version: DEFAULT_SCHEMA_VERSION,
210
+ updated_at: new Date().toISOString(),
211
+ drafts: []
212
+ }
213
+ };
214
+ }
215
+
216
+ async function saveDraftStore(store, fileSystem = fs) {
217
+ const next = {
218
+ schema_version: store.payload.schema_version || DEFAULT_SCHEMA_VERSION,
219
+ updated_at: new Date().toISOString(),
220
+ drafts: Array.isArray(store.payload.drafts) ? store.payload.drafts : []
221
+ };
222
+ await fileSystem.ensureDir(path.dirname(store.path));
223
+ await fileSystem.writeJson(store.path, next, { spaces: 2 });
224
+ return { path: store.path, payload: next };
225
+ }
226
+
227
+ async function appendDraft(projectPath, draft, fileSystem = fs) {
228
+ const store = await loadDraftStore(projectPath, fileSystem);
229
+ store.payload.drafts = Array.isArray(store.payload.drafts) ? store.payload.drafts : [];
230
+ store.payload.drafts.push(draft);
231
+ await saveDraftStore(store, fileSystem);
232
+ return { draft, store: store.payload, store_path: store.path };
233
+ }
234
+
235
+ async function updateDraft(projectPath, draftId, updater, fileSystem = fs) {
236
+ const store = await loadDraftStore(projectPath, fileSystem);
237
+ const drafts = Array.isArray(store.payload.drafts) ? store.payload.drafts : [];
238
+ const index = drafts.findIndex((item) => item.draft_id === draftId);
239
+ if (index < 0) {
240
+ return null;
241
+ }
242
+ const updated = updater({ ...drafts[index] });
243
+ updated.updated_at = new Date().toISOString();
244
+ drafts[index] = updated;
245
+ store.payload.drafts = drafts;
246
+ await saveDraftStore(store, fileSystem);
247
+ return updated;
248
+ }
249
+
250
+ function consolidateDraftGroup(group) {
251
+ const merged = { ...group[0] };
252
+ const now = new Date().toISOString();
253
+ merged.status = 'consolidated';
254
+ merged.updated_at = now;
255
+ const rawRequests = group.map((item) => normalizeText(item.raw_request)).filter(Boolean);
256
+ const subGoals = group.flatMap((item) => normalizeStringArray(item.sub_goals));
257
+ const acceptance = group.flatMap((item) => normalizeStringArray(item.acceptance_criteria));
258
+ const suggestions = group.flatMap((item) => normalizeStringArray(item.acceptance_suggestions));
259
+ merged.raw_request = rawRequests.join(' | ');
260
+ merged.sub_goals = Array.from(new Set(subGoals));
261
+ merged.acceptance_criteria = Array.from(new Set(acceptance));
262
+ merged.acceptance_suggestions = Array.from(new Set(suggestions));
263
+ merged.needs_split = merged.sub_goals.length > 0;
264
+ return merged;
265
+ }
266
+
267
+ async function consolidateDrafts(projectPath, options = {}, fileSystem = fs) {
268
+ const store = await loadDraftStore(projectPath, fileSystem);
269
+ const drafts = Array.isArray(store.payload.drafts) ? store.payload.drafts : [];
270
+ const sceneId = normalizeText(options.scene_id);
271
+ const specId = normalizeText(options.spec_id);
272
+
273
+ const candidates = drafts.filter((draft) => {
274
+ if (sceneId && normalizeText(draft.scene_id) !== sceneId) {
275
+ return false;
276
+ }
277
+ if (specId && normalizeText(draft.spec_id) !== specId) {
278
+ return false;
279
+ }
280
+ return draft.status === 'draft' || draft.status === 'consolidated';
281
+ });
282
+
283
+ const groups = new Map();
284
+ candidates.forEach((draft) => {
285
+ const key = normalizeTitle(draft.raw_request || draft.title_norm);
286
+ if (!groups.has(key)) {
287
+ groups.set(key, []);
288
+ }
289
+ groups.get(key).push(draft);
290
+ });
291
+
292
+ const mergedDrafts = [];
293
+ const mergedLog = [];
294
+ for (const [key, group] of groups.entries()) {
295
+ if (group.length === 1) {
296
+ mergedDrafts.push(group[0]);
297
+ continue;
298
+ }
299
+ const merged = consolidateDraftGroup(group);
300
+ mergedDrafts.push(merged);
301
+ mergedLog.push({
302
+ title_norm: key,
303
+ draft_ids: group.map((item) => item.draft_id),
304
+ merged_id: merged.draft_id
305
+ });
306
+ }
307
+
308
+ const untouched = drafts.filter((draft) => {
309
+ if (sceneId && normalizeText(draft.scene_id) !== sceneId) {
310
+ return true;
311
+ }
312
+ if (specId && normalizeText(draft.spec_id) !== specId) {
313
+ return true;
314
+ }
315
+ return false;
316
+ });
317
+
318
+ store.payload.drafts = [...untouched, ...mergedDrafts];
319
+ await saveDraftStore(store, fileSystem);
320
+
321
+ return {
322
+ merged: mergedLog,
323
+ drafts: mergedDrafts,
324
+ store_path: store.path
325
+ };
326
+ }
327
+
328
+ function parseTaskIdValue(taskId) {
329
+ if (!taskId) {
330
+ return 0;
331
+ }
332
+ const token = `${taskId}`.split('.')[0];
333
+ return toNonNegativeInteger(token, 0);
334
+ }
335
+
336
+ async function promoteDraftToTasks(projectPath, draft, fileSystem = fs) {
337
+ const specId = normalizeText(draft.spec_id);
338
+ if (!specId) {
339
+ throw new Error('spec_id is required to promote draft to tasks.md');
340
+ }
341
+ const tasksPath = path.join(projectPath, '.sce', 'specs', specId, 'tasks.md');
342
+ if (!await fileSystem.pathExists(tasksPath)) {
343
+ throw new Error(`tasks.md not found: ${tasksPath}`);
344
+ }
345
+
346
+ const claimer = new TaskClaimer();
347
+ const tasks = await claimer.parseTasks(tasksPath);
348
+ const maxId = tasks.reduce((max, task) => {
349
+ const value = parseTaskIdValue(task.taskId);
350
+ return Math.max(max, value);
351
+ }, 0);
352
+ const nextId = maxId + 1;
353
+ const title = normalizeText(draft.title_norm) || normalizeText(draft.goal) || 'New task';
354
+ const line = `- [ ] ${nextId}. ${title}`;
355
+
356
+ const content = await fileSystem.readFile(tasksPath, 'utf8');
357
+ const nextContent = content.trimEnd() + '\n' + line + '\n';
358
+ await fileSystem.writeFile(tasksPath, nextContent, 'utf8');
359
+
360
+ return {
361
+ task_id: `${nextId}`,
362
+ tasks_path: tasksPath
363
+ };
364
+ }
365
+
366
+ module.exports = {
367
+ DEFAULT_TASK_GOVERNANCE_DIR,
368
+ DEFAULT_DRAFTS_FILE,
369
+ buildDraft,
370
+ suggestAcceptanceCriteria,
371
+ scoreDraft,
372
+ loadDraftStore,
373
+ saveDraftStore,
374
+ appendDraft,
375
+ updateDraft,
376
+ consolidateDrafts,
377
+ promoteDraftToTasks
378
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scene-capability-engine",
3
- "version": "3.6.10",
3
+ "version": "3.6.11",
4
4
  "description": "SCE (Scene Capability Engine) - A CLI tool and npm package for spec-driven development with AI coding assistants.",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -0,0 +1,8 @@
1
+ {
2
+ "schema_version": "1.0",
3
+ "min_quality_score": 70,
4
+ "require_acceptance_criteria": true,
5
+ "allow_needs_split": false,
6
+ "auto_suggest_acceptance": true,
7
+ "max_sub_goals": 3
8
+ }