kld-sdd 2.5.2 → 2.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -7
- package/lib/init.js +11 -1
- package/package.json +3 -2
- package/skywalk-sdd/context-client.cjs +160 -0
- package/skywalk-sdd/index.cjs +117 -13
- package/skywalk-sdd/ontology/archive-package.cjs +489 -0
- package/skywalk-sdd/ontology/identity-index.cjs +25 -0
- package/skywalk-sdd/ontology/runtime.cjs +179 -54
- package/skywalk-sdd/ontology/working-artifacts.cjs +243 -0
- package/templates/skills/kld-sdd/opsx-archive/SKILL.md +10 -1
- package/templates/skills/kld-sdd/opsx-archive/checklist.md +5 -1
- package/templates/skills/kld-sdd/opsx-check/SKILL.md +2 -0
- package/templates/skills/kld-sdd/opsx-check/checklist.md +2 -0
- package/templates/skills/kld-sdd/opsx-design/SKILL.md +2 -0
- package/templates/skills/kld-sdd/opsx-propose/SKILL.md +2 -0
- package/templates/skills/kld-sdd/opsx-propose/checklist.md +2 -0
- package/templates/skills/kld-sdd/opsx-spec/SKILL.md +23 -0
- package/templates/skills/kld-sdd/opsx-spec/checklist.md +5 -0
- package/templates/skills/kld-sdd/opsx-task/SKILL.md +2 -0
|
@@ -10,6 +10,11 @@ const { validateTraceability } = require('./traceability-validator.cjs');
|
|
|
10
10
|
const { buildIdentityCatalog } = require('./identity-index.cjs');
|
|
11
11
|
const { buildEffectiveGraph } = require('./effective-graph.cjs');
|
|
12
12
|
const { generateUuidV7 } = require('./id.cjs');
|
|
13
|
+
const {
|
|
14
|
+
WORKING_ARTIFACT_SCHEMA_VERSION,
|
|
15
|
+
ARTIFACT_INDEX_SCHEMA_VERSION,
|
|
16
|
+
buildWorkingArtifactFacts,
|
|
17
|
+
} = require('./working-artifacts.cjs');
|
|
13
18
|
const {
|
|
14
19
|
acquireChangeLock,
|
|
15
20
|
assertChangeLock,
|
|
@@ -20,26 +25,30 @@ function sha256(value) {
|
|
|
20
25
|
return crypto.createHash('sha256').update(value).digest('hex');
|
|
21
26
|
}
|
|
22
27
|
|
|
23
|
-
function
|
|
28
|
+
function resolveChangeDir(projectRoot, changeName) {
|
|
29
|
+
const root = path.resolve(projectRoot || process.cwd());
|
|
30
|
+
return path.join(root, 'openspec', 'changes', safeChangeName(changeName));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function changeOntologyPaths(projectRoot, changeName) {
|
|
34
|
+
const changeDir = resolveChangeDir(projectRoot, changeName);
|
|
35
|
+
return {
|
|
36
|
+
changeDir,
|
|
37
|
+
working: path.join(changeDir, 'working-ontology.json'),
|
|
38
|
+
diagnostics: path.join(changeDir, 'diagnostics.json'),
|
|
39
|
+
fileIndex: path.join(changeDir, 'file-index.json'),
|
|
40
|
+
artifactIndex: path.join(changeDir, 'artifact-index.json'),
|
|
41
|
+
artifactDir: path.join(changeDir, 'artifacts'),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function statePaths(projectRoot, changeName) {
|
|
24
46
|
const root = path.resolve(projectRoot || process.cwd());
|
|
25
47
|
const safeName = safeChangeName(changeName);
|
|
26
48
|
const dir = path.join(root, 'skywalk-sdd', 'state', 'ontology', safeName);
|
|
27
|
-
const revisions = path.join(dir, 'revisions');
|
|
28
|
-
const revisionDir = revision ? path.join(revisions, revision) : '';
|
|
29
49
|
return {
|
|
30
50
|
dir,
|
|
31
|
-
revisions,
|
|
32
51
|
current: path.join(dir, 'current.json'),
|
|
33
|
-
revisionDir,
|
|
34
|
-
working: revisionDir
|
|
35
|
-
? path.join(revisionDir, 'working-ontology.json')
|
|
36
|
-
: path.join(dir, 'working-ontology.json'),
|
|
37
|
-
diagnostics: revisionDir
|
|
38
|
-
? path.join(revisionDir, 'diagnostics.json')
|
|
39
|
-
: path.join(dir, 'diagnostics.json'),
|
|
40
|
-
fileIndex: revisionDir
|
|
41
|
-
? path.join(revisionDir, 'file-index.json')
|
|
42
|
-
: path.join(dir, 'file-index.json'),
|
|
43
52
|
};
|
|
44
53
|
}
|
|
45
54
|
|
|
@@ -48,7 +57,7 @@ function serialized(value) {
|
|
|
48
57
|
}
|
|
49
58
|
|
|
50
59
|
function atomicWriteIfChanged(filePath, value) {
|
|
51
|
-
const content = serialized(value);
|
|
60
|
+
const content = typeof value === 'string' ? value : serialized(value);
|
|
52
61
|
if (fs.existsSync(filePath) && fs.readFileSync(filePath, 'utf8') === content) {
|
|
53
62
|
return false;
|
|
54
63
|
}
|
|
@@ -74,7 +83,7 @@ function readJson(filePath, code) {
|
|
|
74
83
|
}
|
|
75
84
|
|
|
76
85
|
function readRevisionBundle(paths, expectedRevision) {
|
|
77
|
-
for (const filePath of [paths.working, paths.diagnostics, paths.fileIndex]) {
|
|
86
|
+
for (const filePath of [paths.working, paths.diagnostics, paths.fileIndex, paths.artifactIndex]) {
|
|
78
87
|
if (!fs.existsSync(filePath)) {
|
|
79
88
|
throw stateError('SEM_STATE_INCOMPLETE', `revision ${expectedRevision} 缺少 ${path.basename(filePath)}`);
|
|
80
89
|
}
|
|
@@ -82,59 +91,161 @@ function readRevisionBundle(paths, expectedRevision) {
|
|
|
82
91
|
const working = readJson(paths.working, 'SEM_STATE_CORRUPT');
|
|
83
92
|
const diagnostics = readJson(paths.diagnostics, 'SEM_STATE_CORRUPT');
|
|
84
93
|
const fileIndex = readJson(paths.fileIndex, 'SEM_STATE_CORRUPT');
|
|
85
|
-
const
|
|
94
|
+
const artifactIndex = readJson(paths.artifactIndex, 'SEM_STATE_CORRUPT');
|
|
95
|
+
const revisions = [working.revision, diagnostics.revision, fileIndex.revision, artifactIndex.revision];
|
|
86
96
|
if (revisions.some((revision) => revision !== expectedRevision)) {
|
|
87
97
|
throw stateError(
|
|
88
98
|
'SEM_STATE_REVISION_MISMATCH',
|
|
89
99
|
`current=${expectedRevision},working/diagnostics/file-index=${revisions.join('/')}`,
|
|
90
100
|
);
|
|
91
101
|
}
|
|
92
|
-
|
|
102
|
+
for (const artifact of artifactIndex.artifacts || []) {
|
|
103
|
+
const artifactPath = path.resolve(paths.changeDir, artifact.json_path || '');
|
|
104
|
+
const artifactRelative = path.relative(paths.changeDir, artifactPath);
|
|
105
|
+
if (
|
|
106
|
+
!artifact.json_path
|
|
107
|
+
|| artifactRelative.startsWith('..')
|
|
108
|
+
|| path.isAbsolute(artifactRelative)
|
|
109
|
+
|| !fs.existsSync(artifactPath)
|
|
110
|
+
) {
|
|
111
|
+
throw stateError(
|
|
112
|
+
'SEM_STATE_INCOMPLETE',
|
|
113
|
+
`revision ${expectedRevision} 缺少 Artifact JSON: ${artifact.json_path || '(empty)'}`,
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
const artifactFacts = readJson(artifactPath, 'SEM_STATE_CORRUPT');
|
|
117
|
+
if (artifactFacts.revision !== expectedRevision) {
|
|
118
|
+
throw stateError(
|
|
119
|
+
'SEM_STATE_REVISION_MISMATCH',
|
|
120
|
+
`current=${expectedRevision},artifact=${artifactFacts.revision}`,
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return { working, diagnostics, fileIndex, artifactIndex };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function pruneStaleArtifacts(changeDir, artifactIndex) {
|
|
128
|
+
const artifactRoot = path.join(changeDir, 'artifacts');
|
|
129
|
+
if (!fs.existsSync(artifactRoot)) return;
|
|
130
|
+
const allowed = new Set((artifactIndex.artifacts || []).map((artifact) => artifact.json_path));
|
|
131
|
+
const stale = [];
|
|
132
|
+
(function walk(currentDir, relativePrefix) {
|
|
133
|
+
for (const entry of fs.readdirSync(currentDir, { withFileTypes: true })) {
|
|
134
|
+
const relativePath = relativePrefix ? `${relativePrefix}/${entry.name}` : entry.name;
|
|
135
|
+
const absolutePath = path.join(currentDir, entry.name);
|
|
136
|
+
if (entry.isDirectory()) {
|
|
137
|
+
walk(absolutePath, relativePath);
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
if (!entry.name.endsWith('.ontology.json')) continue;
|
|
141
|
+
const jsonPath = `artifacts/${relativePath.replace(/\\/g, '/')}`;
|
|
142
|
+
if (!allowed.has(jsonPath)) stale.push(absolutePath);
|
|
143
|
+
}
|
|
144
|
+
}(artifactRoot, ''));
|
|
145
|
+
for (const filePath of stale) {
|
|
146
|
+
fs.rmSync(filePath, { force: true });
|
|
147
|
+
}
|
|
148
|
+
(function removeEmptyDirs(currentDir) {
|
|
149
|
+
if (!fs.existsSync(currentDir) || currentDir === artifactRoot) return;
|
|
150
|
+
const entries = fs.readdirSync(currentDir);
|
|
151
|
+
for (const entry of entries) {
|
|
152
|
+
const child = path.join(currentDir, entry);
|
|
153
|
+
if (fs.statSync(child).isDirectory()) removeEmptyDirs(child);
|
|
154
|
+
}
|
|
155
|
+
if (fs.readdirSync(currentDir).length === 0) fs.rmdirSync(currentDir);
|
|
156
|
+
}(artifactRoot));
|
|
93
157
|
}
|
|
94
158
|
|
|
95
159
|
function commitRevision(projectRoot, changeName, result, lock) {
|
|
96
160
|
assertChangeLock(lock);
|
|
97
161
|
const basePaths = statePaths(projectRoot, changeName);
|
|
98
|
-
const paths =
|
|
99
|
-
fs.mkdirSync(basePaths.
|
|
162
|
+
const paths = changeOntologyPaths(projectRoot, changeName);
|
|
163
|
+
fs.mkdirSync(basePaths.dir, { recursive: true });
|
|
164
|
+
fs.mkdirSync(paths.changeDir, { recursive: true });
|
|
100
165
|
|
|
101
|
-
if (fs.existsSync(paths.
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
166
|
+
if (fs.existsSync(paths.working)) {
|
|
167
|
+
const existing = readJson(paths.working, 'SEM_STATE_CORRUPT');
|
|
168
|
+
if (existing.revision === result.revision) {
|
|
169
|
+
readRevisionBundle(paths, result.revision);
|
|
170
|
+
const artifactIndex = readJson(paths.artifactIndex, 'SEM_STATE_CORRUPT');
|
|
171
|
+
return {
|
|
172
|
+
changed: false,
|
|
173
|
+
paths: {
|
|
174
|
+
...paths,
|
|
175
|
+
current: basePaths.current,
|
|
176
|
+
artifactFacts: (artifactIndex.artifacts || []).map((artifact) => (
|
|
177
|
+
path.join(paths.changeDir, artifact.json_path)
|
|
178
|
+
)),
|
|
179
|
+
},
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const stagingDir = path.join(basePaths.dir, `.${result.revision}.${lock.token}.staging`);
|
|
185
|
+
fs.rmSync(stagingDir, { recursive: true, force: true });
|
|
186
|
+
fs.mkdirSync(stagingDir, { recursive: true });
|
|
187
|
+
const artifactBundle = buildWorkingArtifactFacts(result);
|
|
188
|
+
const stagedFiles = {
|
|
189
|
+
working: path.join(stagingDir, 'working-ontology.json'),
|
|
190
|
+
diagnostics: path.join(stagingDir, 'diagnostics.json'),
|
|
191
|
+
fileIndex: path.join(stagingDir, 'file-index.json'),
|
|
192
|
+
artifactIndex: path.join(stagingDir, 'artifact-index.json'),
|
|
193
|
+
};
|
|
194
|
+
fs.writeFileSync(stagedFiles.working, serialized(result.state), 'utf8');
|
|
195
|
+
fs.writeFileSync(stagedFiles.diagnostics, serialized({
|
|
196
|
+
schema_version: SCHEMA_VERSION,
|
|
197
|
+
change: changeName,
|
|
198
|
+
profile: result.profile,
|
|
199
|
+
valid: result.valid,
|
|
200
|
+
revision: result.revision,
|
|
201
|
+
diagnostics: result.diagnostics,
|
|
202
|
+
}), 'utf8');
|
|
203
|
+
fs.writeFileSync(stagedFiles.fileIndex, serialized(result.fileIndex), 'utf8');
|
|
204
|
+
fs.writeFileSync(stagedFiles.artifactIndex, serialized(artifactBundle.index), 'utf8');
|
|
205
|
+
for (const artifactFile of artifactBundle.files) {
|
|
206
|
+
const targetPath = path.join(stagingDir, artifactFile.path);
|
|
207
|
+
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
|
208
|
+
fs.writeFileSync(targetPath, serialized(artifactFile.value), 'utf8');
|
|
127
209
|
}
|
|
210
|
+
assertChangeLock(lock);
|
|
211
|
+
readRevisionBundle({
|
|
212
|
+
changeDir: stagingDir,
|
|
213
|
+
working: stagedFiles.working,
|
|
214
|
+
diagnostics: stagedFiles.diagnostics,
|
|
215
|
+
fileIndex: stagedFiles.fileIndex,
|
|
216
|
+
artifactIndex: stagedFiles.artifactIndex,
|
|
217
|
+
}, result.revision);
|
|
218
|
+
|
|
219
|
+
atomicWriteIfChanged(paths.working, fs.readFileSync(stagedFiles.working, 'utf8'));
|
|
220
|
+
atomicWriteIfChanged(paths.diagnostics, fs.readFileSync(stagedFiles.diagnostics, 'utf8'));
|
|
221
|
+
atomicWriteIfChanged(paths.fileIndex, fs.readFileSync(stagedFiles.fileIndex, 'utf8'));
|
|
222
|
+
atomicWriteIfChanged(paths.artifactIndex, fs.readFileSync(stagedFiles.artifactIndex, 'utf8'));
|
|
223
|
+
for (const artifactFile of artifactBundle.files) {
|
|
224
|
+
const stagedPath = path.join(stagingDir, artifactFile.path);
|
|
225
|
+
atomicWriteIfChanged(path.join(paths.changeDir, artifactFile.path), fs.readFileSync(stagedPath, 'utf8'));
|
|
226
|
+
}
|
|
227
|
+
pruneStaleArtifacts(paths.changeDir, artifactBundle.index);
|
|
228
|
+
fs.rmSync(stagingDir, { recursive: true, force: true });
|
|
128
229
|
|
|
129
230
|
const pointer = {
|
|
130
231
|
schema_version: 'kld-sdd-state-pointer/v1',
|
|
131
232
|
change: safeChangeName(changeName),
|
|
132
233
|
revision: result.revision,
|
|
133
|
-
|
|
234
|
+
change_dir: path.relative(path.resolve(projectRoot), paths.changeDir).replace(/\\/g, '/'),
|
|
235
|
+
artifact_index: 'artifact-index.json',
|
|
134
236
|
};
|
|
135
237
|
assertChangeLock(lock);
|
|
136
238
|
const changed = atomicWriteIfChanged(basePaths.current, pointer);
|
|
137
|
-
return {
|
|
239
|
+
return {
|
|
240
|
+
changed,
|
|
241
|
+
paths: {
|
|
242
|
+
...paths,
|
|
243
|
+
current: basePaths.current,
|
|
244
|
+
artifactFacts: artifactBundle.index.artifacts.map((artifact) => (
|
|
245
|
+
path.join(paths.changeDir, artifact.json_path)
|
|
246
|
+
)),
|
|
247
|
+
},
|
|
248
|
+
};
|
|
138
249
|
}
|
|
139
250
|
|
|
140
251
|
function scanChange(projectRoot, changeName, options = {}) {
|
|
@@ -164,6 +275,8 @@ function scanChange(projectRoot, changeName, options = {}) {
|
|
|
164
275
|
const reviewStatus = options.markPending && validation.valid ? 'pending' : 'draft';
|
|
165
276
|
const revisionInput = {
|
|
166
277
|
schema_version: SCHEMA_VERSION,
|
|
278
|
+
artifact_schema_version: WORKING_ARTIFACT_SCHEMA_VERSION,
|
|
279
|
+
artifact_index_schema_version: ARTIFACT_INDEX_SCHEMA_VERSION,
|
|
167
280
|
change: changeName,
|
|
168
281
|
profile,
|
|
169
282
|
files: facts.files,
|
|
@@ -179,6 +292,8 @@ function scanChange(projectRoot, changeName, options = {}) {
|
|
|
179
292
|
const revision = sha256(JSON.stringify(revisionInput));
|
|
180
293
|
const state = JSON.parse(JSON.stringify({
|
|
181
294
|
schema_version: SCHEMA_VERSION,
|
|
295
|
+
artifact_schema_version: WORKING_ARTIFACT_SCHEMA_VERSION,
|
|
296
|
+
artifact_index_schema_version: ARTIFACT_INDEX_SCHEMA_VERSION,
|
|
182
297
|
change: changeName,
|
|
183
298
|
change_id: facts.change_id,
|
|
184
299
|
profile,
|
|
@@ -237,18 +352,18 @@ function reconcileChange(projectRoot, changeName, options = {}) {
|
|
|
237
352
|
|
|
238
353
|
function readWorkingState(projectRoot, changeName) {
|
|
239
354
|
const basePaths = statePaths(projectRoot, changeName);
|
|
355
|
+
const paths = changeOntologyPaths(projectRoot, changeName);
|
|
240
356
|
if (fs.existsSync(basePaths.current)) {
|
|
241
357
|
const pointer = readJson(basePaths.current, 'SEM_STATE_POINTER_CORRUPT');
|
|
242
358
|
if (!pointer.revision || pointer.change !== safeChangeName(changeName)) {
|
|
243
359
|
throw stateError('SEM_STATE_POINTER_CORRUPT', `current.json 不属于 Change ${changeName}`);
|
|
244
360
|
}
|
|
245
|
-
return readRevisionBundle(
|
|
246
|
-
statePaths(projectRoot, changeName, pointer.revision),
|
|
247
|
-
pointer.revision,
|
|
248
|
-
).working;
|
|
361
|
+
return readRevisionBundle(paths, pointer.revision).working;
|
|
249
362
|
}
|
|
250
|
-
if (!fs.existsSync(
|
|
251
|
-
|
|
363
|
+
if (!fs.existsSync(paths.working)) return null;
|
|
364
|
+
const working = readJson(paths.working, 'SEM_STATE_CORRUPT');
|
|
365
|
+
readRevisionBundle(paths, working.revision);
|
|
366
|
+
return working;
|
|
252
367
|
}
|
|
253
368
|
|
|
254
369
|
function createArchiveSnapshot(projectRoot, changeName, archiveDir, inputState) {
|
|
@@ -276,6 +391,14 @@ function createArchiveSnapshot(projectRoot, changeName, archiveDir, inputState)
|
|
|
276
391
|
if (!state.facts_hash || state.facts_hash !== actualFacts.facts_hash) {
|
|
277
392
|
throw new Error(`归档正文与待确认本体事实不一致: ${changeName}`);
|
|
278
393
|
}
|
|
394
|
+
const artifactIndexPath = path.join(targetDir, 'artifact-index.json');
|
|
395
|
+
if (!fs.existsSync(artifactIndexPath)) {
|
|
396
|
+
throw new Error(`归档目录缺少 artifact-index.json: ${targetDir}`);
|
|
397
|
+
}
|
|
398
|
+
const artifactIndex = readJson(artifactIndexPath, 'SEM_STATE_CORRUPT');
|
|
399
|
+
if (artifactIndex.revision !== state.revision) {
|
|
400
|
+
throw new Error(`归档 artifact-index 与待确认 revision 不一致: ${changeName}`);
|
|
401
|
+
}
|
|
279
402
|
const snapshotId = `SNAP-${sha256(`${changeName}:${state.revision}`).slice(0, 16).toUpperCase()}`;
|
|
280
403
|
const targetPath = path.join(targetDir, 'archive-ontology.json');
|
|
281
404
|
const previous = fs.existsSync(targetPath)
|
|
@@ -330,6 +453,8 @@ function createArchiveSnapshot(projectRoot, changeName, archiveDir, inputState)
|
|
|
330
453
|
}
|
|
331
454
|
|
|
332
455
|
module.exports = {
|
|
456
|
+
resolveChangeDir,
|
|
457
|
+
changeOntologyPaths,
|
|
333
458
|
statePaths,
|
|
334
459
|
atomicWriteIfChanged,
|
|
335
460
|
readRevisionBundle,
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
|
|
6
|
+
const WORKING_ARTIFACT_SCHEMA_VERSION = 'kld-sdd-working-artifact-facts/v1';
|
|
7
|
+
const ARTIFACT_INDEX_SCHEMA_VERSION = 'kld-sdd-working-artifact-index/v1';
|
|
8
|
+
|
|
9
|
+
function sha256(value) {
|
|
10
|
+
return crypto.createHash('sha256').update(value).digest('hex');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function prefixedHash(value) {
|
|
14
|
+
const normalized = String(value || '').trim().toLowerCase().replace(/^sha256:/, '');
|
|
15
|
+
return normalized ? `sha256:${normalized}` : '';
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function safeRelativePath(value) {
|
|
19
|
+
const input = String(value || '').trim().replace(/\\/g, '/');
|
|
20
|
+
const normalized = path.posix.normalize(input);
|
|
21
|
+
if (
|
|
22
|
+
!input
|
|
23
|
+
|| input.startsWith('/')
|
|
24
|
+
|| normalized !== input
|
|
25
|
+
|| normalized === '..'
|
|
26
|
+
|| normalized.startsWith('../')
|
|
27
|
+
|| input.includes('\0')
|
|
28
|
+
) {
|
|
29
|
+
throw new Error(`工作态 Artifact 路径不安全: ${value}`);
|
|
30
|
+
}
|
|
31
|
+
return normalized;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function artifactJsonPath(sourcePath) {
|
|
35
|
+
const normalized = safeRelativePath(sourcePath);
|
|
36
|
+
const extension = path.posix.extname(normalized);
|
|
37
|
+
const withoutExtension = extension
|
|
38
|
+
? normalized.slice(0, -extension.length)
|
|
39
|
+
: normalized;
|
|
40
|
+
return `artifacts/${withoutExtension}.ontology.json`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function normalizedSource(source, fallbackAnchor) {
|
|
44
|
+
const input = source && typeof source === 'object' ? source : {};
|
|
45
|
+
const line = Number(input.line || 0);
|
|
46
|
+
return {
|
|
47
|
+
artifact_type: String(input.artifact_type || 'unknown').trim().toLowerCase(),
|
|
48
|
+
file: safeRelativePath(input.file),
|
|
49
|
+
...(Number.isInteger(line) && line > 0 ? { line } : {}),
|
|
50
|
+
anchor_id: String(input.anchor_id || fallbackAnchor || '').trim().toUpperCase(),
|
|
51
|
+
content_hash: prefixedHash(input.content_hash),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function workingEntity(entity, reviewStatus) {
|
|
56
|
+
const anchorId = String(entity.anchor_id || entity.id || '').trim().toUpperCase();
|
|
57
|
+
return {
|
|
58
|
+
anchor_id: anchorId,
|
|
59
|
+
type: entity.type,
|
|
60
|
+
name: String(entity.name || anchorId).trim(),
|
|
61
|
+
entity_id: String(entity.entity_id || '').trim().toLowerCase(),
|
|
62
|
+
entity_version_id: String(entity.entity_version_id || entity.version_id || '').trim().toLowerCase(),
|
|
63
|
+
predecessor_version_id: entity.predecessor_version_id
|
|
64
|
+
? String(entity.predecessor_version_id).trim().toLowerCase()
|
|
65
|
+
: null,
|
|
66
|
+
delta_state: String(entity.delta_state || 'unresolved').trim().toLowerCase(),
|
|
67
|
+
content_hash: String(entity.content_hash || '').trim().toLowerCase(),
|
|
68
|
+
fact_kind: 'semantic',
|
|
69
|
+
assertion_type: 'asserted',
|
|
70
|
+
review_status: reviewStatus,
|
|
71
|
+
generation_role: entity.generation_role || 'current',
|
|
72
|
+
...(entity.inherited_from ? { inherited_from: entity.inherited_from } : {}),
|
|
73
|
+
attributes: entity.attributes && typeof entity.attributes === 'object' ? entity.attributes : {},
|
|
74
|
+
source: normalizedSource(entity.source, anchorId),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function normalizeRelationDirection(relation, entityByAnchor) {
|
|
79
|
+
const originalType = String(relation.type || '').trim();
|
|
80
|
+
const originalFrom = String(relation.from || '').trim().toUpperCase();
|
|
81
|
+
const originalTo = String(relation.to || '').trim().toUpperCase();
|
|
82
|
+
if (originalType === 'acceptedBy') {
|
|
83
|
+
return { type: 'verifiedBy', from: originalFrom, to: originalTo };
|
|
84
|
+
}
|
|
85
|
+
if (originalType !== 'constrains') {
|
|
86
|
+
return { type: originalType, from: originalFrom, to: originalTo };
|
|
87
|
+
}
|
|
88
|
+
const fromType = entityByAnchor.get(originalFrom)?.type;
|
|
89
|
+
const toType = entityByAnchor.get(originalTo)?.type;
|
|
90
|
+
if (fromType === 'Constraint' && toType === 'SpecificationStatement') {
|
|
91
|
+
return { type: 'constrainedBy', from: originalTo, to: originalFrom };
|
|
92
|
+
}
|
|
93
|
+
return { type: 'constrainedBy', from: originalFrom, to: originalTo };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function workingRelation(relation, entityByAnchor, reviewStatus, producerVersion) {
|
|
97
|
+
const normalized = normalizeRelationDirection(relation, entityByAnchor);
|
|
98
|
+
const fromEntity = entityByAnchor.get(normalized.from);
|
|
99
|
+
const toEntity = entityByAnchor.get(normalized.to);
|
|
100
|
+
const assertionType = String(relation.assertion_type || 'asserted').trim().toLowerCase();
|
|
101
|
+
const output = {
|
|
102
|
+
type: normalized.type,
|
|
103
|
+
from_anchor_id: normalized.from,
|
|
104
|
+
to_anchor_id: normalized.to,
|
|
105
|
+
from_entity_id: String(fromEntity?.entity_id || '').trim().toLowerCase(),
|
|
106
|
+
to_entity_id: String(toEntity?.entity_id || '').trim().toLowerCase(),
|
|
107
|
+
assertion_type: assertionType,
|
|
108
|
+
fact_kind: 'semantic',
|
|
109
|
+
review_status: assertionType === 'suggested' ? 'pending' : reviewStatus,
|
|
110
|
+
generation_role: relation.generation_role || 'current',
|
|
111
|
+
...(relation.inherited_from ? { inherited_from: relation.inherited_from } : {}),
|
|
112
|
+
source: normalizedSource(
|
|
113
|
+
relation.source,
|
|
114
|
+
`${normalized.from}-${normalized.type}-${normalized.to}`,
|
|
115
|
+
),
|
|
116
|
+
};
|
|
117
|
+
if (assertionType === 'inferred') {
|
|
118
|
+
output.rule_id = String(relation.rule_id || '').trim();
|
|
119
|
+
output.generator = relation.generator || 'kld-sdd';
|
|
120
|
+
output.generator_version = relation.generator_version || producerVersion;
|
|
121
|
+
} else if (relation.rule_id) {
|
|
122
|
+
output.rule_id = relation.rule_id;
|
|
123
|
+
}
|
|
124
|
+
if (assertionType === 'suggested') {
|
|
125
|
+
output.generator = relation.generator;
|
|
126
|
+
output.generator_version = relation.generator_version;
|
|
127
|
+
output.confidence = relation.confidence;
|
|
128
|
+
output.support_evidence = Array.isArray(relation.support_evidence) ? relation.support_evidence : [];
|
|
129
|
+
output.opposition_evidence = Array.isArray(relation.opposition_evidence) ? relation.opposition_evidence : [];
|
|
130
|
+
}
|
|
131
|
+
return output;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function compareFacts(left, right) {
|
|
135
|
+
return (
|
|
136
|
+
String(left.anchor_id || left.type || '').localeCompare(String(right.anchor_id || right.type || ''))
|
|
137
|
+
|| String(left.from_entity_id || '').localeCompare(String(right.from_entity_id || ''))
|
|
138
|
+
|| String(left.to_entity_id || '').localeCompare(String(right.to_entity_id || ''))
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function buildWorkingArtifactFacts(result, options = {}) {
|
|
143
|
+
const state = result.state || {};
|
|
144
|
+
const producerVersion = String(options.producerVersion || require('../../package.json').version);
|
|
145
|
+
const reviewStatus = state.review_status === 'pending' ? 'pending' : 'draft';
|
|
146
|
+
const rawEntities = Array.isArray(state.entities) ? state.entities : [];
|
|
147
|
+
const entityByAnchor = new Map(rawEntities.map((entity) => [
|
|
148
|
+
String(entity.anchor_id || entity.id || '').trim().toUpperCase(),
|
|
149
|
+
entity,
|
|
150
|
+
]));
|
|
151
|
+
const artifactFiles = [];
|
|
152
|
+
const artifactIndex = [];
|
|
153
|
+
|
|
154
|
+
for (const artifact of state.artifacts || []) {
|
|
155
|
+
const sourcePath = safeRelativePath(artifact.path);
|
|
156
|
+
const jsonPath = artifactJsonPath(sourcePath);
|
|
157
|
+
const artifactDiagnostics = (result.diagnostics || []).filter((diagnostic) => (
|
|
158
|
+
!diagnostic.file || String(diagnostic.file).replace(/\\/g, '/') === sourcePath
|
|
159
|
+
));
|
|
160
|
+
const artifactEntities = rawEntities
|
|
161
|
+
.filter((entity) => (
|
|
162
|
+
entity.source && String(entity.source.file).replace(/\\/g, '/') === sourcePath
|
|
163
|
+
))
|
|
164
|
+
.map((entity) => workingEntity(entity, reviewStatus))
|
|
165
|
+
.sort(compareFacts);
|
|
166
|
+
const artifactRelations = (state.relations || [])
|
|
167
|
+
.filter((relation) => (
|
|
168
|
+
relation.source && String(relation.source.file).replace(/\\/g, '/') === sourcePath
|
|
169
|
+
))
|
|
170
|
+
.map((relation) => workingRelation(relation, entityByAnchor, reviewStatus, producerVersion))
|
|
171
|
+
.sort(compareFacts);
|
|
172
|
+
const inheritedReferences = (state.inherited_references || []).filter((reference) => (
|
|
173
|
+
reference.source && String(reference.source.file).replace(/\\/g, '/') === sourcePath
|
|
174
|
+
));
|
|
175
|
+
const inheritedRelations = (state.inherited_relations || []).filter((relation) => (
|
|
176
|
+
relation.source && String(relation.source.file).replace(/\\/g, '/') === sourcePath
|
|
177
|
+
));
|
|
178
|
+
const semanticFacts = {
|
|
179
|
+
entities: artifactEntities,
|
|
180
|
+
relations: artifactRelations,
|
|
181
|
+
inherited_references: inheritedReferences,
|
|
182
|
+
inherited_relations: inheritedRelations,
|
|
183
|
+
};
|
|
184
|
+
const artifactFacts = {
|
|
185
|
+
schema_version: WORKING_ARTIFACT_SCHEMA_VERSION,
|
|
186
|
+
producer: `kld-sdd@${producerVersion}`,
|
|
187
|
+
canonical: false,
|
|
188
|
+
change: state.change,
|
|
189
|
+
change_id: state.change_id,
|
|
190
|
+
profile: state.profile,
|
|
191
|
+
stage: artifact.type,
|
|
192
|
+
review_status: reviewStatus,
|
|
193
|
+
valid: !artifactDiagnostics.some((diagnostic) => diagnostic.severity === 'error'),
|
|
194
|
+
revision: result.revision,
|
|
195
|
+
source: {
|
|
196
|
+
artifact_type: artifact.type,
|
|
197
|
+
path: sourcePath,
|
|
198
|
+
capability_id: artifact.capability_id || null,
|
|
199
|
+
content_hash: prefixedHash(artifact.content_hash),
|
|
200
|
+
},
|
|
201
|
+
facts_hash: sha256(JSON.stringify(semanticFacts)),
|
|
202
|
+
...semanticFacts,
|
|
203
|
+
diagnostics: artifactDiagnostics,
|
|
204
|
+
};
|
|
205
|
+
artifactFiles.push({ path: jsonPath, value: artifactFacts });
|
|
206
|
+
artifactIndex.push({
|
|
207
|
+
artifact_type: artifact.type,
|
|
208
|
+
source_path: sourcePath,
|
|
209
|
+
json_path: jsonPath,
|
|
210
|
+
capability_id: artifact.capability_id || null,
|
|
211
|
+
source_content_hash: prefixedHash(artifact.content_hash),
|
|
212
|
+
facts_hash: artifactFacts.facts_hash,
|
|
213
|
+
entity_count: artifactEntities.length,
|
|
214
|
+
relation_count: artifactRelations.length,
|
|
215
|
+
diagnostic_count: artifactDiagnostics.length,
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return {
|
|
220
|
+
index: {
|
|
221
|
+
schema_version: ARTIFACT_INDEX_SCHEMA_VERSION,
|
|
222
|
+
producer: `kld-sdd@${producerVersion}`,
|
|
223
|
+
canonical: false,
|
|
224
|
+
change: state.change,
|
|
225
|
+
change_id: state.change_id,
|
|
226
|
+
profile: state.profile,
|
|
227
|
+
review_status: reviewStatus,
|
|
228
|
+
valid: Boolean(result.valid),
|
|
229
|
+
revision: result.revision,
|
|
230
|
+
working_ontology_path: 'working-ontology.json',
|
|
231
|
+
artifacts: artifactIndex,
|
|
232
|
+
},
|
|
233
|
+
files: artifactFiles,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
module.exports = {
|
|
238
|
+
WORKING_ARTIFACT_SCHEMA_VERSION,
|
|
239
|
+
ARTIFACT_INDEX_SCHEMA_VERSION,
|
|
240
|
+
safeRelativePath,
|
|
241
|
+
artifactJsonPath,
|
|
242
|
+
buildWorkingArtifactFacts,
|
|
243
|
+
};
|
|
@@ -107,7 +107,8 @@ node skywalk-sdd/log.cjs archive-docs --project=. --change=<变更名称> --reas
|
|
|
107
107
|
|
|
108
108
|
该命令成功后必须已经完成:
|
|
109
109
|
- 活动目录 `openspec/changes/<name>/` 被移入 `openspec/changes/archive/<日期>-<name>/`。
|
|
110
|
-
- 归档目录写入 `archive-manifest.json`。
|
|
110
|
+
- 归档目录写入 `archive-ontology.json`、`canonical-facts.json`、`conversion-report.json` 和新版 `archive-manifest.json`。
|
|
111
|
+
- 生成知识库可直接消费的 `openspec/changes/archive/<日期>-<name>.zip`,包内文件必须由 manifest 完整列举并通过 SHA-256 校验。
|
|
111
112
|
- Full Spec 的 `specs/<capability>/spec.md` 同步到 `openspec/specs/<capability>/spec.md`。
|
|
112
113
|
- archive 阶段写入 `stage_end`。
|
|
113
114
|
- 未勾选 tasks 被写入 `archive_result.task_completion`。
|
|
@@ -132,6 +133,8 @@ node skywalk-sdd/log.cjs end --event-id=<event_id> --command=archive --project=.
|
|
|
132
133
|
> - 归档目录:`openspec/changes/archive/<日期>-<name>/`
|
|
133
134
|
> - 最终报告(默认同时生成 .md + .html):`openspec/changes/archive/<日期>-<name>/reports/<name>-report.md`、`openspec/changes/archive/<日期>-<name>/reports/<name>-report.html`
|
|
134
135
|
> - 执行日志:`openspec/changes/archive/<日期>-<name>/logs/execution-log.md`
|
|
136
|
+
> - 知识库归档包:`openspec/changes/archive/<日期>-<name>.zip`
|
|
137
|
+
> - 本体消费入口:`openspec/changes/archive/<日期>-<name>/canonical-facts.json`
|
|
135
138
|
> - 未勾选任务:X 项,已记录到报告,不阻断归档
|
|
136
139
|
> - 正式 specs:`openspec/specs/`
|
|
137
140
|
|
|
@@ -159,7 +162,13 @@ node skywalk-sdd/log.cjs record --type=baseline_record --command=archive --proje
|
|
|
159
162
|
|
|
160
163
|
- 归档前必须运行 `node skywalk-sdd/log.cjs semantic-reconcile --project=. --change=<变更名称>`,不能只信任文件观察事件。
|
|
161
164
|
- 随后必须运行 `node skywalk-sdd/log.cjs semantic-check --project=. --change=<变更名称>`;有阻断诊断时禁止移动活动 Change。
|
|
165
|
+
- Archive 不负责首次生成 propose/spec/design/tasks 的工作态事实;它重新解析原文核对 pending revision,补充 `OntologySnapshot/project_id/archive_id` 后冻结为 confirmed。
|
|
162
166
|
- `archive-docs` 成功后必须在归档目录生成 `archive-ontology.json`,并将 `review_status` 标记为 `confirmed`。
|
|
167
|
+
- 生产端必须把 confirmed 本体转换为 `canonical-facts.json`;实体固定为 `fact_kind=semantic`、`assertion_type=asserted`、`review_status=confirmed`,关系必须补齐对应语义边界字段。
|
|
168
|
+
- `acceptedBy` 在消费协议中归一化为 `verifiedBy`;inferred 关系必须携带 `rule_id`、`generator` 和 `generator_version`。
|
|
169
|
+
- 每个 canonical 实体和关系的 `source.file` 必须存在于 Archive Package,`source.content_hash` 必须等于该原文文件的 SHA-256。
|
|
170
|
+
- `archive-manifest.json` 必须使用 `kld-sdd-archive-manifest/v2`,并与 canonical facts 的 project/archive/change 身份一致。
|
|
171
|
+
- `conversion-report.json` 必须记录源/目标 schema、转换状态、实体数、关系数和归一化告警。
|
|
163
172
|
- Archive 快照必须固化人工锚点、逻辑实体 UUID、版本 UUID、前序版本 UUID、delta-state 和内容哈希;任一 UUID 冲突或谱系断裂都不得 confirmed。
|
|
164
173
|
- 归档目录已经复制但语义快照失败时,不得删除活动 Change,不得标记 confirmed。
|
|
165
174
|
- unchanged 正文保留在前序 Archive;当前归档只固化差量事实、继承引用和来源锚点。
|
|
@@ -18,7 +18,11 @@ description: "opsx-archive 前后日志/总结自检清单 — 仅在 archive
|
|
|
18
18
|
## B. 归档后自检
|
|
19
19
|
|
|
20
20
|
- [ ] 归档目录 `openspec/changes/archive/<日期>-<变更名称>/` 存在
|
|
21
|
-
- [ ] `openspec/changes/archive
|
|
21
|
+
- [ ] `openspec/changes/archive/<日期>-<变更名称>/` 下 `archive-ontology.json`、`canonical-facts.json`、`conversion-report.json` 和 `archive-manifest.json` 均存在
|
|
22
|
+
- [ ] 活动 change 目录下的 `artifacts/*.ontology.json` 与 `artifact-index.json` 已随归档目录一并迁移
|
|
23
|
+
- [ ] `archive-manifest.json` 为 `kld-sdd-archive-manifest/v2`,且 `files` 精确覆盖 ZIP 内除 manifest 自身外的全部文件
|
|
24
|
+
- [ ] `openspec/changes/archive/<日期>-<变更名称>.zip` 存在并可由知识库 `ArchivePackageReader` 读取
|
|
25
|
+
- [ ] canonical facts 中每个实体/关系均能通过 `source.file`、`source.anchor_id`、`source.content_hash` 定向展开到包内原文
|
|
22
26
|
- [ ] 最终报告 `openspec/changes/archive/<日期>-<变更名称>/reports/<变更名称>-report.md` 存在且含「归档结果」段
|
|
23
27
|
- [ ] `reports/<变更名称>-report.md` 与 `reports/<变更名称>-report.html` 都已生成(md+html 双产物)
|
|
24
28
|
- [ ] html 报告含 `SDD 效果度量报告` 标题与各度量章节(执行摘要/效率/质量/过程/归档结果/说明)
|
|
@@ -215,6 +215,8 @@ node skywalk-sdd/log.cjs semantic-check --project=. --change=<变更名称> --pr
|
|
|
215
215
|
- 所有 profile 都必须阻断重复人工锚点、UUID 缺失/非法、跨 Change 或 Archive 的实体 UUID 误复用、版本 UUID 重用、版本谱系断裂、悬空引用、非法 domain/range 和 Task 依赖成环。
|
|
216
216
|
- `added` 必须使用全新实体/版本 UUID;`modified/removed` 必须复用实体 UUID并指向直接前序版本;`unchanged` 必须复用历史实体和版本 UUID且不得复制历史正文。
|
|
217
217
|
- 文件观察结果只能作为快速上下文,check 必须重新全量 semantic-reconcile。
|
|
218
|
+
- propose/spec/design/task 对应工作态 JSON 应已在各作者阶段生成;check 不负责首次生成业务事实,只重新解析 Markdown、核对各 JSON 与同一 revision,并在全部通过时把派生 revision 更新为 `review_status=pending`。
|
|
219
|
+
- Check 只读是指不修改 proposal/spec/design/tasks 原文;允许原子刷新 `openspec/changes/<变更名称>/` 下可再生的 `working-ontology.json`、`artifact-index.json` 与 `artifacts/*.ontology.json`。
|
|
218
220
|
|
|
219
221
|
## Guardrails
|
|
220
222
|
|
|
@@ -35,3 +35,5 @@ description: "opsx-check 阶段日志自检清单 — 仅在 check 自检时读
|
|
|
35
35
|
|
|
36
36
|
- [ ] 完整性、一致性、算法正确性、可执行性四维均已输出
|
|
37
37
|
- [ ] 报告问题对应修复建议(spec/design/task)
|
|
38
|
+
- [ ] `openspec/changes/<变更名称>/artifact-index.json` 已覆盖当前全部 proposal/spec/design/tasks,且每份 `artifacts/*.ontology.json` 与 `working-ontology.json` revision 一致
|
|
39
|
+
- [ ] 全部语义门禁通过时工作态 JSON 已从 draft 刷新为 pending;check 未修改任何 Markdown 原文
|
|
@@ -172,6 +172,8 @@ design.md 中若使用 version 正则约束(如格式校验 `^\d+\.\d+\.\d+$`
|
|
|
172
172
|
- 每个 DES 必须通过 `**realizes**: STMT-*` 显式引用一个或多个真实 STMT。
|
|
173
173
|
- 不得仅凭文本相似创建 realizes;没有上游 STMT 时必须标记 unresolved。
|
|
174
174
|
- 生成结束后必须运行 `node skywalk-sdd/log.cjs semantic-reconcile --project=. --change=<name>`。
|
|
175
|
+
- reconcile 必须立即生成 `openspec/changes/<name>/artifacts/.../design.ontology.json`,其中 DES 和 realizes 关系保留原文 source;不得推迟到 check/archive。
|
|
176
|
+
- 阶段 `stage_end` 会在无 Hook 环境下幂等执行同步兜底。
|
|
175
177
|
|
|
176
178
|
## Guardrails
|
|
177
179
|
|
|
@@ -211,6 +211,8 @@ openspec instructions proposal --change "<name>" --json
|
|
|
211
211
|
- 每个 Change/Capability 同时写人工锚点、`entity-id`、`version-id` 和 `delta-state`。新增时必须调用 `node skywalk-sdd/log.cjs semantic-identity --delta-state=added`,不得手写或复制 UUID。
|
|
212
212
|
- 修改既有 Capability 时调用 `semantic-identity --delta-state=modified --entity-id=<历史实体UUID> --predecessor-version=<直接前序版本UUID>`;复用实体 UUID,但必须生成新版本 UUID。
|
|
213
213
|
- 本阶段只声明 Change 和 Capability,不得提前生成 STMT、AC、DES 或 TASK 事实。
|
|
214
|
+
- proposal.md 写入结束后必须立即运行 `node skywalk-sdd/log.cjs semantic-reconcile --project=. --change=<name>`,生成 `openspec/changes/<name>/artifacts/proposal.ontology.json`;不得等到 check 或 archive 才首次生成 JSON。
|
|
215
|
+
- 阶段 `stage_end` 会在无 Hook 环境下幂等执行同一次同步作为兜底,结果中的 `semantic_state.artifact_json_paths` 必须包含 proposal 对应 JSON。
|
|
214
216
|
|
|
215
217
|
## Guardrails
|
|
216
218
|
|