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 +13 -0
- package/README.md +1 -1
- package/README.zh.md +1 -1
- package/docs/command-reference.md +20 -0
- package/lib/commands/task.js +271 -0
- package/lib/task/task-quality-policy.js +109 -0
- package/lib/task/task-quality.js +378 -0
- package/package.json +1 -1
- package/template/.sce/config/task-quality-policy.json +8 -0
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
package/README.zh.md
CHANGED
|
@@ -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
|
package/lib/commands/task.js
CHANGED
|
@@ -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