skill-family-engineering-kit 0.26.0 → 0.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,466 @@
1
+ import { lstat } from "node:fs/promises";
2
+ import { CONTRACTS_VERSION, ContractsError, findSchemaByObject, validateDocument } from "skill-family-contracts";
3
+ import { classifyPathInput, digestBytes, publishFileExclusive, readFileContained, resolveContained } from "skill-family-harness-node";
4
+ import { isContainedDeclaration } from "./core-check.mjs";
5
+ import { KIT_ERROR_KINDS, kitError } from "./errors.mjs";
6
+ import {
7
+ FILE_REGISTRY_PATH,
8
+ KIT_TOOL_NAME,
9
+ KIT_VERSION,
10
+ MANAGED_LOCK_PATH,
11
+ PROJECT_MANIFEST_PATH,
12
+ } from "./skeleton.mjs";
13
+ import { matchAnyGitignorePattern, matchAnyGlob, normalizeRelPath, resolveTargetRoot } from "./workspace.mjs";
14
+
15
+ /**
16
+ * check register-existing — official first nonempty-project registration.
17
+ *
18
+ * Seeds the three closed-world state documents for an existing nonempty
19
+ * tree that already has a valid profile.json. It does not scaffold, does
20
+ * not apply adopt-plan, and never overwrites existing bytes. Relock still
21
+ * never seeds; this sub-action is the only legal first write of the
22
+ * registry, project-manifest, and managed-file-lock into a nonempty root.
23
+ *
24
+ * Transaction:
25
+ * 1. All reads and validation complete before the first write.
26
+ * 2. The three documents are computed in memory; the lock already binds
27
+ * the new registry and manifest bytes.
28
+ * 3. Missing all three → exclusive create (registry, manifest, lock).
29
+ * All three present and byte-identical → idempotent success, zero write.
30
+ * Any other combination, overlapping classes, or drifted bytes → refuse
31
+ * with zero overwrite.
32
+ */
33
+
34
+ export const REGISTER_EXISTING_REPORT_KIND = "skill-family.register-existing-report";
35
+
36
+ const DEFAULT_ARTIFACT_PATTERNS = Object.freeze([
37
+ "node_modules/",
38
+ "/site/",
39
+ ".cache/",
40
+ "/.qoder/",
41
+ "*.log",
42
+ ".DS_Store",
43
+ ".env",
44
+ ".env.*",
45
+ "/.artifacts/",
46
+ "/.runs/",
47
+ "/.evidence/",
48
+ ]);
49
+
50
+ const KIT_MANAGED_FILES = Object.freeze([
51
+ FILE_REGISTRY_PATH,
52
+ PROJECT_MANIFEST_PATH,
53
+ MANAGED_LOCK_PATH,
54
+ ]);
55
+
56
+ function isPlainObject(value) {
57
+ return value !== null && typeof value === "object" && !Array.isArray(value);
58
+ }
59
+
60
+ function jsonString(value) {
61
+ return `${JSON.stringify(value, null, 2)}\n`;
62
+ }
63
+
64
+ function refuse(message, extra) {
65
+ throw kitError(KIT_ERROR_KINDS.REGISTER_EXISTING_REFUSED, message, extra);
66
+ }
67
+
68
+ function splitCsv(value) {
69
+ if (value === undefined || value === null) return [];
70
+ if (Array.isArray(value)) return value;
71
+ if (typeof value !== "string") {
72
+ refuse("register-existing list options must be string arrays or comma-separated strings", {
73
+ field: "lists",
74
+ });
75
+ }
76
+ return value.split(",").map((item) => item.trim()).filter((item) => item.length > 0);
77
+ }
78
+
79
+ function normalizeList(value, field) {
80
+ const items = splitCsv(value);
81
+ const normalized = [];
82
+ const seen = new Set();
83
+ for (const item of items) {
84
+ if (typeof item !== "string" || item.trim().length === 0) {
85
+ refuse(`register-existing ${field} must contain non-empty relative paths or globs`, { field });
86
+ }
87
+ const rel = normalizeRelPath(item.trim());
88
+ if (rel.length === 0 || rel.startsWith("/") || rel.split("/").includes("..")) {
89
+ refuse(`register-existing ${field} entry escapes the target root: ${item}`, { field, path: item });
90
+ }
91
+ if (seen.has(rel)) continue;
92
+ seen.add(rel);
93
+ normalized.push(rel);
94
+ }
95
+ normalized.sort();
96
+ return normalized;
97
+ }
98
+
99
+ function normalizeArtifactList(value) {
100
+ const items = splitCsv(value);
101
+ const normalized = [];
102
+ const seen = new Set();
103
+ for (const item of items) {
104
+ if (typeof item !== "string" || item.trim().length === 0) {
105
+ refuse("register-existing artifacts must contain non-empty gitignore-style patterns", {
106
+ field: "artifacts",
107
+ });
108
+ }
109
+ const raw = item.trim().replaceAll("\\", "/").replace(/\/+/g, "/");
110
+ if (raw.includes("\0") || raw.split("/").filter((part) => part.length > 0).includes("..")) {
111
+ refuse(`register-existing artifacts entry escapes the target root: ${item}`, {
112
+ field: "artifacts",
113
+ path: item,
114
+ });
115
+ }
116
+ if (seen.has(raw)) continue;
117
+ seen.add(raw);
118
+ normalized.push(raw);
119
+ }
120
+ normalized.sort();
121
+ return normalized;
122
+ }
123
+
124
+ async function readOptionalBytes(rootAbs, rel) {
125
+ try {
126
+ return await readFileContained(rootAbs, rel);
127
+ } catch {
128
+ return null;
129
+ }
130
+ }
131
+
132
+ async function loadValidProfile(rootAbs) {
133
+ let bytes;
134
+ try {
135
+ bytes = await readFileContained(rootAbs, "profile.json");
136
+ } catch {
137
+ refuse("register-existing requires a readable profile.json at the target root", {
138
+ path: "profile.json",
139
+ });
140
+ }
141
+ let value;
142
+ try {
143
+ value = JSON.parse(bytes.toString("utf8"));
144
+ } catch {
145
+ refuse("register-existing requires parseable JSON in profile.json", { path: "profile.json" });
146
+ }
147
+ const registration = findSchemaByObject("project-profile");
148
+ if (!registration) {
149
+ refuse("register-existing cannot resolve the project-profile contract", { path: "profile.json" });
150
+ }
151
+ const validation = validateDocument(value, {
152
+ schemaId: registration.$id,
153
+ dialect: registration.dialect,
154
+ policy: "strict",
155
+ });
156
+ if (!validation.valid) {
157
+ refuse("register-existing requires a contract-valid profile.json", {
158
+ path: "profile.json",
159
+ errors: validation.errors,
160
+ });
161
+ }
162
+ return validation.data;
163
+ }
164
+
165
+ function assertNoClassOverlap({ handwritten, trackedToolLocks, artifacts }) {
166
+ for (const rel of KIT_MANAGED_FILES) {
167
+ if (handwritten.includes(rel) || matchAnyGlob(handwritten, rel)) {
168
+ refuse(`classification overlap: kit-managed ${rel} also matches handwritten`, { path: rel });
169
+ }
170
+ if (trackedToolLocks.includes(rel)) {
171
+ refuse(`classification overlap: kit-managed ${rel} also listed as trackedToolLocks`, { path: rel });
172
+ }
173
+ if (matchAnyGitignorePattern(artifacts, rel)) {
174
+ refuse(`classification overlap: kit-managed ${rel} also matches artifacts`, { path: rel });
175
+ }
176
+ }
177
+ for (const rel of trackedToolLocks) {
178
+ if (handwritten.includes(rel) || matchAnyGlob(handwritten, rel)) {
179
+ refuse(`classification overlap: trackedToolLocks ${rel} also matches handwritten`, { path: rel });
180
+ }
181
+ if (matchAnyGitignorePattern(artifacts, rel)) {
182
+ refuse(`classification overlap: trackedToolLocks ${rel} also matches artifacts`, { path: rel });
183
+ }
184
+ }
185
+ }
186
+
187
+ async function assertTrackedToolLocksExist(rootAbs, trackedToolLocks) {
188
+ for (const rel of trackedToolLocks) {
189
+ const classification = classifyPathInput(rel);
190
+ if (!classification.ok) {
191
+ refuse(`trackedToolLocks path is not a contained project path: ${rel}`, {
192
+ path: rel,
193
+ kind: classification.kind,
194
+ });
195
+ }
196
+ if (!isContainedDeclaration(rel)) {
197
+ refuse(`trackedToolLocks path escapes the target root: ${rel}`, { path: rel });
198
+ }
199
+ let st;
200
+ try {
201
+ const abs = await resolveContained(rootAbs, rel);
202
+ st = await lstat(abs);
203
+ } catch {
204
+ refuse(`trackedToolLocks path is missing: ${rel}`, { path: rel });
205
+ }
206
+ if (st.isSymbolicLink()) {
207
+ refuse(`trackedToolLocks path is a symbolic link: ${rel}`, { path: rel });
208
+ }
209
+ if (!st.isFile()) {
210
+ refuse(`trackedToolLocks path is not a regular file: ${rel}`, { path: rel });
211
+ }
212
+ }
213
+ }
214
+
215
+ function renderRegistry({ workspace, handwritten, artifacts, trackedToolLocks }) {
216
+ const classes = {
217
+ projenManaged: {
218
+ files: [],
219
+ note: "Existing nonempty projects keep their current toolchain; this path does not introduce projen.",
220
+ },
221
+ kitManaged: {
222
+ files: [FILE_REGISTRY_PATH, PROJECT_MANIFEST_PATH, MANAGED_LOCK_PATH],
223
+ note: "Foundation closed-world state documents generated by check register-existing / relock.",
224
+ },
225
+ handwritten: {
226
+ entries: handwritten,
227
+ note: "Caller-declared source material; tools never overwrite it.",
228
+ },
229
+ artifacts: {
230
+ patterns: artifacts,
231
+ note: "Ignored runtime/cache/evidence paths. Tracked generated products must not be listed here.",
232
+ },
233
+ };
234
+ if (trackedToolLocks.length > 0) {
235
+ classes.trackedToolLocks = {
236
+ entries: trackedToolLocks,
237
+ note: "Persistent tool-generated lock files; contents are not bound by the managed-file lock.",
238
+ };
239
+ }
240
+ return {
241
+ schemaVersion: 1,
242
+ kind: "skill-family.file-registry",
243
+ workspace,
244
+ generatedBy: { tool: KIT_TOOL_NAME, version: KIT_VERSION },
245
+ classes,
246
+ rules: {
247
+ closedWorld: "每个拟跟踪文件恰好属于 projenManaged/kitManaged/handwritten 之一;每个忽略项属于 artifacts;持久工具锁可登记 trackedToolLocks。",
248
+ gitPolicy: "工具只生成 Git 规则文本,绝不执行任何 Git 写操作。",
249
+ },
250
+ };
251
+ }
252
+
253
+ function renderManifest({ projectId, projectName, profileId, engineering }) {
254
+ const document = {
255
+ schemaVersion: 1,
256
+ kind: "skill-family.project-manifest",
257
+ project: {
258
+ id: projectId,
259
+ name: projectName,
260
+ description: `Skill Family project ${projectName}.`,
261
+ },
262
+ contracts: {
263
+ version: CONTRACTS_VERSION,
264
+ profile: profileId,
265
+ },
266
+ managedFiles: [FILE_REGISTRY_PATH, PROJECT_MANIFEST_PATH],
267
+ ...(engineering === undefined ? {} : { engineering }),
268
+ };
269
+ const registration = findSchemaByObject("project-manifest");
270
+ const validation = validateDocument(document, {
271
+ schemaId: registration.$id,
272
+ dialect: registration.dialect,
273
+ policy: "strict",
274
+ });
275
+ if (!validation.valid) {
276
+ throw new ContractsError(validation.errorCode, "register-existing project-manifest violates its contract", {
277
+ errors: validation.errors,
278
+ });
279
+ }
280
+ if (engineering !== undefined) {
281
+ const references = [
282
+ ...(engineering.entries ?? []).map((entry) => entry.physicalRef),
283
+ ...(engineering.releaseUnits ?? []).flatMap((unit) => [
284
+ unit.versionSource.path,
285
+ ...(unit.versionMirrors ?? []).map((mirror) => mirror.path),
286
+ ]),
287
+ ];
288
+ for (const reference of references) {
289
+ const classification = classifyPathInput(reference);
290
+ if (!classification.ok) {
291
+ refuse("engineering reference is not a contained project path", {
292
+ path: reference,
293
+ kind: classification.kind,
294
+ });
295
+ }
296
+ }
297
+ }
298
+ return document;
299
+ }
300
+
301
+ function renderLock({ registryBytes, manifestBytes }) {
302
+ const entries = [
303
+ {
304
+ path: FILE_REGISTRY_PATH,
305
+ hash: { algorithm: "sha256", value: digestBytes(registryBytes) },
306
+ generator: { tool: KIT_TOOL_NAME, version: KIT_VERSION },
307
+ policy: "managed",
308
+ },
309
+ {
310
+ path: PROJECT_MANIFEST_PATH,
311
+ hash: { algorithm: "sha256", value: digestBytes(manifestBytes) },
312
+ generator: { tool: KIT_TOOL_NAME, version: KIT_VERSION },
313
+ policy: "managed",
314
+ },
315
+ ].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
316
+ return {
317
+ schemaVersion: 1,
318
+ kind: "skill-family.managed-file-lock",
319
+ lockVersion: 1,
320
+ entries,
321
+ };
322
+ }
323
+
324
+ function sameBytes(actual, expected) {
325
+ return actual !== null && actual.equals(expected);
326
+ }
327
+
328
+ /**
329
+ * Official first nonempty registration.
330
+ * Options: { root, handwritten, engineering, artifacts, trackedToolLocks }.
331
+ */
332
+ export async function runRegisterExisting({
333
+ root,
334
+ handwritten,
335
+ engineering,
336
+ artifacts,
337
+ trackedToolLocks,
338
+ } = {}) {
339
+ const rootAbs = await resolveTargetRoot(root ?? ".");
340
+ const handwrittenList = normalizeList(handwritten, "handwritten");
341
+ if (handwrittenList.length === 0) {
342
+ refuse("register-existing requires a non-empty handwritten classification list", {
343
+ field: "handwritten",
344
+ });
345
+ }
346
+ const toolLocks = normalizeList(trackedToolLocks, "trackedToolLocks");
347
+ const artifactPatterns = [...new Set([...DEFAULT_ARTIFACT_PATTERNS, ...normalizeArtifactList(artifacts)])].sort();
348
+ assertNoClassOverlap({ handwritten: handwrittenList, trackedToolLocks: toolLocks, artifacts: artifactPatterns });
349
+ await assertTrackedToolLocksExist(rootAbs, toolLocks);
350
+
351
+ const profile = await loadValidProfile(rootAbs);
352
+ const projectId = profile.project.id;
353
+ const projectName = profile.project.workspace;
354
+ const profileId = profile.adoption?.foundation_profile?.id;
355
+ if (typeof profileId !== "string" || profileId.length === 0) {
356
+ refuse("register-existing requires profile.adoption.foundation_profile.id", {
357
+ path: "profile.json",
358
+ });
359
+ }
360
+
361
+ const registryDoc = renderRegistry({
362
+ workspace: projectName,
363
+ handwritten: handwrittenList,
364
+ artifacts: artifactPatterns,
365
+ trackedToolLocks: toolLocks,
366
+ });
367
+ const manifestDoc = renderManifest({ projectId, projectName, profileId, engineering });
368
+ const registryBytes = Buffer.from(jsonString(registryDoc), "utf8");
369
+ const manifestBytes = Buffer.from(jsonString(manifestDoc), "utf8");
370
+ const lockDoc = renderLock({ registryBytes, manifestBytes });
371
+ const lockBytes = Buffer.from(jsonString(lockDoc), "utf8");
372
+
373
+ const existing = {
374
+ [FILE_REGISTRY_PATH]: await readOptionalBytes(rootAbs, FILE_REGISTRY_PATH),
375
+ [PROJECT_MANIFEST_PATH]: await readOptionalBytes(rootAbs, PROJECT_MANIFEST_PATH),
376
+ [MANAGED_LOCK_PATH]: await readOptionalBytes(rootAbs, MANAGED_LOCK_PATH),
377
+ };
378
+ const computed = {
379
+ [FILE_REGISTRY_PATH]: registryBytes,
380
+ [PROJECT_MANIFEST_PATH]: manifestBytes,
381
+ [MANAGED_LOCK_PATH]: lockBytes,
382
+ };
383
+ const present = KIT_MANAGED_FILES.filter((rel) => existing[rel] !== null);
384
+ const missing = KIT_MANAGED_FILES.filter((rel) => existing[rel] === null);
385
+
386
+ if (present.length === 3 && KIT_MANAGED_FILES.every((rel) => sameBytes(existing[rel], computed[rel]))) {
387
+ return {
388
+ kind: REGISTER_EXISTING_REPORT_KIND,
389
+ schemaVersion: 1,
390
+ generatedBy: { tool: KIT_TOOL_NAME, version: KIT_VERSION },
391
+ target: { root: "." },
392
+ ok: true,
393
+ mode: "idempotent",
394
+ wrote: [],
395
+ registry: { path: FILE_REGISTRY_PATH, sha256: digestBytes(registryBytes) },
396
+ manifest: { path: PROJECT_MANIFEST_PATH, sha256: digestBytes(manifestBytes) },
397
+ lock: { path: MANAGED_LOCK_PATH, sha256: digestBytes(lockBytes), lockVersion: 1 },
398
+ policy: "rerun with identical classification is idempotent and writes nothing",
399
+ };
400
+ }
401
+
402
+ if (present.length > 0) {
403
+ const drifted = present.filter((rel) => !sameBytes(existing[rel], computed[rel]));
404
+ refuse("register-existing refuses because closed-world documents already exist and do not match the computed registration; zero overwrite", {
405
+ present,
406
+ missing,
407
+ drifted,
408
+ });
409
+ }
410
+
411
+ const receipts = {};
412
+ const publishOrder = [
413
+ [FILE_REGISTRY_PATH, registryBytes, true],
414
+ [PROJECT_MANIFEST_PATH, manifestBytes, false],
415
+ [MANAGED_LOCK_PATH, lockBytes, false],
416
+ ];
417
+ for (const [rel, bytes, createParents] of publishOrder) {
418
+ try {
419
+ receipts[rel] = await publishFileExclusive(rootAbs, rel, bytes, { createParents });
420
+ } catch (cause) {
421
+ throw kitError(
422
+ KIT_ERROR_KINDS.PROJECTION_WRITE_FAILED,
423
+ `register-existing publication failed at ${rel}: ${cause?.message ?? "unknown"}`,
424
+ {
425
+ path: rel,
426
+ publicationState: cause?.details?.publicationState ?? "indeterminate",
427
+ causeKind: cause?.details?.kind ?? "unknown",
428
+ receipts,
429
+ },
430
+ );
431
+ }
432
+ }
433
+
434
+ return {
435
+ kind: REGISTER_EXISTING_REPORT_KIND,
436
+ schemaVersion: 1,
437
+ generatedBy: { tool: KIT_TOOL_NAME, version: KIT_VERSION },
438
+ target: { root: "." },
439
+ ok: true,
440
+ mode: "created",
441
+ wrote: [...KIT_MANAGED_FILES],
442
+ registry: {
443
+ path: FILE_REGISTRY_PATH,
444
+ sha256: receipts[FILE_REGISTRY_PATH].sha256,
445
+ publicationState: receipts[FILE_REGISTRY_PATH].publicationState,
446
+ },
447
+ manifest: {
448
+ path: PROJECT_MANIFEST_PATH,
449
+ sha256: receipts[PROJECT_MANIFEST_PATH].sha256,
450
+ publicationState: receipts[PROJECT_MANIFEST_PATH].publicationState,
451
+ },
452
+ lock: {
453
+ path: MANAGED_LOCK_PATH,
454
+ sha256: receipts[MANAGED_LOCK_PATH].sha256,
455
+ publicationState: receipts[MANAGED_LOCK_PATH].publicationState,
456
+ lockVersion: 1,
457
+ },
458
+ policy:
459
+ "register-existing is one fail-closed transaction: validation completes before the first write; existing mismatched documents refuse with zero overwrite; identical documents are idempotent",
460
+ };
461
+ }
462
+
463
+ export async function registerExistingAction(options) {
464
+ const output = await runRegisterExisting(options);
465
+ return { status: "ok", output };
466
+ }
package/src/skeleton.mjs CHANGED
@@ -355,6 +355,7 @@ function renderSourceOfTruth({ projectId, projectName }) {
355
355
  "| 文件分类 | `.foundation/file-registry.json` | 闭世界登记:受管/手写/生成物各归一类 |",
356
356
  "| 受管文件字节 | `skill-family.managed-file-lock.json` | kit 受管文件的摘要锁 |",
357
357
  "| 采用声明与自加严覆盖 | `profile.json` | 机器可校验;只许更严、不许放松 |",
358
+ "| 默认可编辑技能源 | 技能包根下的 `skills/**` | 手写分类;没有业务 Skill 时目录可空,不伪造入口 |",
358
359
  "",
359
360
  "## 生成谱系",
360
361
  "",
@@ -981,6 +982,7 @@ export async function describeSkeletonFiles(inputs) {
981
982
  "docs/**",
982
983
  `${leafDir}/src/**`,
983
984
  `${leafDir}/test/**`,
985
+ "skills/**",
984
986
  MIGRATION_MANIFEST_PATH,
985
987
  SOURCE_OF_TRUTH_PATH,
986
988
  PROJECT_PROFILE_PATH,