skill-family-engineering-kit 0.1.2 → 0.1.3

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,223 @@
1
+ import { lstat } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { KIT_ERROR_KINDS } from "./errors.mjs";
4
+ import { CHECK_CLASSES, runChecks } from "./check.mjs";
5
+ import {
6
+ IDENTITY_RECORD_PATH,
7
+ MANAGED_LOCK_PATH,
8
+ PROJECT_MANIFEST_PATH,
9
+ } from "./skeleton.mjs";
10
+ import {
11
+ listTargetEntries,
12
+ loadTargetFacts,
13
+ matchAnyGitignorePattern,
14
+ matchAnyGlob,
15
+ resolveTargetRoot,
16
+ } from "./workspace.mjs";
17
+
18
+ /**
19
+ * runCoreCheck — the single shared production entry of the closed-world
20
+ * project check (C2).
21
+ *
22
+ * The generated `scripts/check-core.mjs` entrypoints of scaffolded projects
23
+ * ONLY import this function: safe file-tree enumeration, lstat/symlink/FIFO
24
+ * and special-entry rejection, file-registry classification (managed /
25
+ * handwritten / artifact closed world), declaration path containment, the
26
+ * seven check classes, and the stable finding/exit mapping all live here and
27
+ * nowhere else. No generated string may re-derive a recursive walk, an lstat
28
+ * classification, a glob match, or a local closed-world algorithm.
29
+ *
30
+ * Stages (all read-only; never opens a file before it is lstat-classified):
31
+ * 1. declaration containment — escaping managed declarations (absolute
32
+ * paths, `..` segments) are reported lexically and never touch the fs;
33
+ * 2. managed preflight — lstat only: missing / symlink / non-regular
34
+ * managed paths fail closed before anything could read them;
35
+ * 3. tree enumeration — every symlink and special entry fails closed;
36
+ * every regular file must belong to the registered closed world
37
+ * (managed, handwritten, artifact, or a tracked tool lock);
38
+ * 4. the seven check classes through runChecks — skipped whenever an
39
+ * unsafe path (symlink/special at a managed or class-read path) would
40
+ * be opened by them: FIFOs must never be read (they block), symlinks
41
+ * must never be followed.
42
+ *
43
+ * Exit mapping: 0 clean; 2 when any finding carries a security kind
44
+ * (CORE_CHECK_SECURITY_KINDS, including the harness containment kinds that
45
+ * can surface through escaping lock entries); 1 otherwise.
46
+ */
47
+
48
+ /** Finding kinds that always map to the security exit code 2. */
49
+ export const CORE_CHECK_SECURITY_KINDS = Object.freeze([
50
+ // Kit kinds of this entry.
51
+ KIT_ERROR_KINDS.SYMLINK_AT_MANAGED_PATH,
52
+ KIT_ERROR_KINDS.NOT_A_REGULAR_FILE,
53
+ KIT_ERROR_KINDS.SYMLINK_ENTRY,
54
+ KIT_ERROR_KINDS.SPECIAL_ENTRY,
55
+ KIT_ERROR_KINDS.UNCONTAINED_DECLARATION,
56
+ // Stable harness containment kinds that surface through drift findings when
57
+ // a lock entry tries to escape the target (reported, never followed).
58
+ "absolute-path",
59
+ "windows-drive-path",
60
+ "windows-path",
61
+ "unc-path",
62
+ "path-traversal",
63
+ "symlink-escape",
64
+ "realpath-escape",
65
+ "invalid-path",
66
+ ]);
67
+
68
+ const CORE_CLASS = "core";
69
+
70
+ /**
71
+ * Paths the seven check classes open by name. They take part in the unsafe
72
+ * read-set even when they are not declared managed (README.md is handwritten
73
+ * in the skeleton), so a symlink/FIFO there can never hang or escape a read.
74
+ */
75
+ const CLASS_READ_PATHS = Object.freeze([
76
+ "README.md",
77
+ "package.json",
78
+ PROJECT_MANIFEST_PATH,
79
+ MANAGED_LOCK_PATH,
80
+ IDENTITY_RECORD_PATH,
81
+ "identity-record.json",
82
+ ]);
83
+
84
+ /** Lexical containment of a managed declaration; never touches the fs. */
85
+ export function isContainedDeclaration(rel) {
86
+ if (typeof rel !== "string" || rel.length === 0) return false;
87
+ if (rel.startsWith("/")) return false;
88
+ if (rel.split("/").includes("..")) return false;
89
+ return true;
90
+ }
91
+
92
+ /**
93
+ * Runs the unified closed-world check over one target.
94
+ * Options: { root, allowGitSpawn, profilesRoot }.
95
+ * Returns { kind, ok, exitCode, findings, classes, unsafeReadPaths }.
96
+ * Never writes anywhere.
97
+ */
98
+ export async function runCoreCheck({ root, allowGitSpawn = true, profilesRoot } = {}) {
99
+ const rootAbs = await resolveTargetRoot(root ?? ".");
100
+ const facts = await loadTargetFacts(rootAbs);
101
+ const findings = [];
102
+ const push = (kind, message, extra) =>
103
+ findings.push({ class: CORE_CLASS, kind, code: "SFC2004", message, ...(extra ?? {}) });
104
+
105
+ // ---- Stage 1: declaration containment (purely lexical) -----------------
106
+ const declarations = [...facts.managedSet].sort();
107
+ const containedDeclarations = [];
108
+ for (const rel of declarations) {
109
+ if (isContainedDeclaration(rel)) {
110
+ containedDeclarations.push(rel);
111
+ } else {
112
+ push(
113
+ KIT_ERROR_KINDS.UNCONTAINED_DECLARATION,
114
+ `managed declaration escapes the target root (absolute path or '..' segment): ${rel}`,
115
+ { path: rel },
116
+ );
117
+ }
118
+ }
119
+
120
+ // ---- Stage 2: managed preflight (lstat only, never open/read) ----------
121
+ const unsafeReadPaths = new Set();
122
+ for (const rel of containedDeclarations) {
123
+ let st;
124
+ try {
125
+ st = await lstat(path.join(rootAbs, rel));
126
+ } catch {
127
+ push(
128
+ KIT_ERROR_KINDS.MANAGED_FILE_MISSING,
129
+ `managed file declared in the registry/manifest/lock does not exist: ${rel}`,
130
+ { path: rel },
131
+ );
132
+ continue;
133
+ }
134
+ if (st.isSymbolicLink()) {
135
+ push(KIT_ERROR_KINDS.SYMLINK_AT_MANAGED_PATH, `managed path is a symbolic link: ${rel}`, { path: rel });
136
+ unsafeReadPaths.add(rel);
137
+ } else if (!st.isFile()) {
138
+ push(
139
+ KIT_ERROR_KINDS.NOT_A_REGULAR_FILE,
140
+ `managed path is not a regular file (FIFO/socket/device/directory): ${rel}`,
141
+ { path: rel },
142
+ );
143
+ unsafeReadPaths.add(rel);
144
+ }
145
+ }
146
+
147
+ // ---- Stage 3: safe tree enumeration + closed-world classification ------
148
+ const entries = await listTargetEntries(rootAbs);
149
+ for (const entry of entries) {
150
+ if (entry.kind === "directory" || entry.kind === "directory-opaque") continue;
151
+ if (entry.kind === "symlink") {
152
+ // Symlinks are never classified by content patterns: unregistered ones
153
+ // fail closed; managed ones were already reported by the preflight.
154
+ if (!facts.managedSet.has(entry.path)) {
155
+ push(
156
+ KIT_ERROR_KINDS.SYMLINK_ENTRY,
157
+ `symbolic link is outside the registered closed world (managed/handwritten/artifact): ${entry.path}`,
158
+ { path: entry.path },
159
+ );
160
+ }
161
+ if (facts.managedSet.has(entry.path) || CLASS_READ_PATHS.includes(entry.path)) {
162
+ unsafeReadPaths.add(entry.path);
163
+ }
164
+ continue;
165
+ }
166
+ if (entry.kind === "special") {
167
+ if (!facts.managedSet.has(entry.path)) {
168
+ push(
169
+ KIT_ERROR_KINDS.SPECIAL_ENTRY,
170
+ `special entry (FIFO/socket/device) is outside the registered closed world: ${entry.path}`,
171
+ { path: entry.path },
172
+ );
173
+ }
174
+ if (facts.managedSet.has(entry.path) || CLASS_READ_PATHS.includes(entry.path)) {
175
+ unsafeReadPaths.add(entry.path);
176
+ }
177
+ continue;
178
+ }
179
+ // Regular files must belong to exactly one registered class. Managed
180
+ // bytes are verified by the drift class, never re-derived here.
181
+ if (facts.managedSet.has(entry.path)) continue;
182
+ if (matchAnyGlob(facts.handwrittenPatterns, entry.path)) continue;
183
+ if (matchAnyGitignorePattern(facts.artifactPatterns, entry.path)) continue;
184
+ if (facts.trackedToolLocks.includes(entry.path)) continue;
185
+ push(
186
+ KIT_ERROR_KINDS.UNREGISTERED_FILE,
187
+ `regular file is not registered in the closed world (managed/handwritten/artifact): ${entry.path}`,
188
+ { path: entry.path },
189
+ );
190
+ }
191
+
192
+ // ---- Stage 4: the seven check classes through the shared entry ---------
193
+ let classReport = null;
194
+ if (unsafeReadPaths.size === 0) {
195
+ classReport = await runChecks({ root: rootAbs, allowGitSpawn, profilesRoot });
196
+ findings.push(...classReport.findings);
197
+ }
198
+
199
+ let exitCode = 0;
200
+ if (findings.length > 0) {
201
+ const securityKinds = new Set(CORE_CHECK_SECURITY_KINDS);
202
+ exitCode = findings.some((f) => securityKinds.has(f.kind)) ? 2 : 1;
203
+ }
204
+ if (classReport && classReport.mechanism) exitCode = 2;
205
+
206
+ return {
207
+ kind: "skill-family.core-check-report",
208
+ schemaVersion: 1,
209
+ target: { root: ".", entryCount: entries.length },
210
+ ok: findings.length === 0,
211
+ exitCode,
212
+ findings,
213
+ classes:
214
+ classReport?.classes ??
215
+ CHECK_CLASSES.map((name) => ({ name, selected: false, completed: false, findings: 0 })),
216
+ classChecksSkipped:
217
+ unsafeReadPaths.size > 0
218
+ ? { reason: "unsafe read-set (symlink/special at a managed or class-read path)", paths: [...unsafeReadPaths].sort() }
219
+ : null,
220
+ policy:
221
+ "core check is diagnosis only: it enumerates with lstat, never opens an unclassified entry, never follows symlinks, and never writes",
222
+ };
223
+ }
package/src/errors.mjs CHANGED
@@ -48,9 +48,12 @@ export const KIT_ERROR_KINDS = Object.freeze({
48
48
  // check / shared
49
49
  CONTRACTS_MISSING: "contracts-missing",
50
50
  CONTRACT_PARSE_FAILED: "contract-parse-failed",
51
+ DOCUMENT_INCOMPLETE: "document-incomplete",
52
+ CHECK_CLASS_FAILED: "check-class-failed",
51
53
  MANAGED_FILE_MISSING: "managed-file-missing",
52
54
  MANAGED_FILE_DRIFT: "managed-file-drift",
53
55
  SYMLINK_AT_MANAGED_PATH: "symlink-at-managed-path",
56
+ NOT_A_REGULAR_FILE: "not-a-regular-file",
54
57
  CLOSURE_INPUT_MISSING: "closure-input-missing",
55
58
  CONTRACTS_VERSION_MISMATCH: "contracts-version-mismatch",
56
59
  README_MISSING: "readme-missing",
@@ -59,6 +62,11 @@ export const KIT_ERROR_KINDS = Object.freeze({
59
62
  GIT_NO_COMMITS: "git-no-commits",
60
63
  GIT_DIRTY: "git-dirty",
61
64
  MUTATION_MODE_REQUESTED: "mutation-mode-requested",
65
+ // core check: the single shared closed-world entry (C2)
66
+ UNREGISTERED_FILE: "unregistered-file",
67
+ SYMLINK_ENTRY: "symlink-entry",
68
+ SPECIAL_ENTRY: "special-entry",
69
+ UNCONTAINED_DECLARATION: "uncontained-declaration",
62
70
  });
63
71
 
64
72
  /** Error carrying one frozen SFC code plus a stable kit kind in details. */
package/src/index.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  import { ContractsError } from "skill-family-contracts";
2
2
  import { buildHandoffDraft, HANDOFF_FIELDS, planAdoption } from "./adopt-plan.mjs";
3
- import { CHECK_CLASSES, runChecks } from "./check.mjs";
3
+ import { CHECK_CLASSES, DOCUMENT_STATES, runChecks } from "./check.mjs";
4
+ import { CORE_CHECK_SECURITY_KINDS, isContainedDeclaration, runCoreCheck } from "./core-check.mjs";
4
5
  import {
5
6
  invalidParamsError,
6
7
  KitError,
@@ -150,10 +151,13 @@ export async function runCommand(command, options = {}) {
150
151
  }
151
152
  case "check": {
152
153
  const output = await runChecks(options);
153
- return {
154
- exitCode: output.ok ? KIT_EXIT_CODES.ok : KIT_EXIT_CODES.findings,
155
- output,
156
- };
154
+ // 0 clean; 2 mechanism (a selected class could not complete); 1 findings.
155
+ const exitCode = output.mechanism
156
+ ? KIT_EXIT_CODES.rejected
157
+ : output.ok
158
+ ? KIT_EXIT_CODES.ok
159
+ : KIT_EXIT_CODES.findings;
160
+ return { exitCode, output };
157
161
  }
158
162
  default: {
159
163
  // TOP_LEVEL_COMMANDS is frozen with exactly the cases above; this
@@ -181,6 +185,10 @@ export {
181
185
  loadProjectionManifest,
182
186
  runChecks,
183
187
  CHECK_CLASSES,
188
+ DOCUMENT_STATES,
189
+ runCoreCheck,
190
+ CORE_CHECK_SECURITY_KINDS,
191
+ isContainedDeclaration,
184
192
  probeGitState,
185
193
  probeGitFacts,
186
194
  GIT_READ_ONLY_ALLOWLIST,
package/src/skeleton.mjs CHANGED
@@ -379,88 +379,28 @@ function renderDocsDeployWorkflow() {
379
379
  }
380
380
 
381
381
  function renderCheckCoreScript() {
382
+ // Thin generated shim. ALL mechanics — safe tree enumeration, entry-type
383
+ // rejection (symlink / FIFO / special entries), file-registry closed-world
384
+ // classification, path containment, the seven check classes, and the
385
+ // stable finding/exit mapping — live in the kit's single shared production
386
+ // entry (runCoreCheck). This file must never grow a local tree scan,
387
+ // entry-type classifier, glob matcher, or closed-world algorithm of its own.
382
388
  return [
383
389
  "#!/usr/bin/env node",
384
- "// core check: delegates to the Foundation's production check entry and",
385
- "// verifies the file registry closed-world classification.",
386
- "// No local algorithm duplication — the generated project consumes the",
387
- "// shared check closure from skill-family-engineering-kit.",
388
- 'import { lstat } from "node:fs/promises";',
389
- 'import { readFileSync } from "node:fs";',
390
- 'import { readdir } from "node:fs/promises";',
391
- 'import path from "node:path";',
392
- 'import { runChecks, matchAnyGlob } from "skill-family-engineering-kit";',
393
- "",
394
- "const root = process.cwd();",
395
- "const failures = [];",
396
- "",
397
- "// Load file registry for closed-world classification",
398
- 'const registry = JSON.parse(readFileSync(path.join(root, ".foundation/file-registry.json"), "utf8"));',
399
- 'const lock = JSON.parse(readFileSync(path.join(root, "skill-family.managed-file-lock.json"), "utf8"));',
400
- "",
401
- "// Build managed file set from file registry (projenManaged + kitManaged)",
402
- "const managedSet = new Set([",
403
- " ...registry.classes.projenManaged.files,",
404
- " ...registry.classes.kitManaged.files,",
405
- "]);",
406
- "",
407
- "// Build handwritten patterns from registry",
408
- "const handwrittenPatterns = registry.classes.handwritten.entries.filter((e) => typeof e === \"string\");",
409
- "",
410
- "// Artifact patterns from registry",
411
- "const artifactPatterns = registry.classes.artifacts.patterns.filter((e) => typeof e === \"string\");",
412
- "",
413
- "// Pre-flight: verify managed files are regular files (catches symlinks, FIFOs, etc.)",
414
- "// This prevents runChecks from hanging on FIFOs and provides clear error messages.",
415
- "for (const relPath of managedSet) {",
416
- " let st;",
417
- " try {",
418
- " st = await lstat(path.resolve(root, relPath));",
419
- " } catch {",
420
- ' failures.push(`managed file missing: ${relPath}`);',
421
- " continue;",
422
- " }",
423
- " if (st.isSymbolicLink()) {",
424
- ' failures.push(`managed file is a symlink: ${relPath}`);',
425
- " } else if (!st.isFile()) {",
426
- ' failures.push(`managed file is not a regular file: ${relPath}`);',
427
- " }",
428
- "}",
429
- "",
430
- "// Closed-world file classification: every tracked file must be classified",
431
- "const OPAQUE = new Set([\".git\", \"node_modules\"]);",
432
- "async function walk(dir, base) {",
433
- " const dirents = await readdir(dir, { withFileTypes: true });",
434
- " for (const dirent of dirents) {",
435
- " const rel = base ? `${base}/${dirent.name}` : dirent.name;",
436
- " if (dirent.isDirectory() && !dirent.isSymbolicLink()) {",
437
- " if (!OPAQUE.has(dirent.name)) await walk(path.join(dir, dirent.name), rel);",
438
- " continue;",
439
- " }",
440
- " if (!dirent.isFile()) continue;",
441
- " const isManaged = managedSet.has(rel);",
442
- " const isHandwritten = matchAnyGlob(handwrittenPatterns, rel);",
443
- " const isArtifact = matchAnyGlob(artifactPatterns, rel);",
444
- " if (!isManaged && !isHandwritten && !isArtifact) {",
445
- ' failures.push(`unregistered file: ${rel}`);',
446
- " }",
447
- " }",
448
- "}",
449
- "await walk(root, \"\");",
450
- "",
451
- "// Run the shared production checks (schema, drift, closure, version, docs, git, identity)",
452
- "if (failures.length === 0) {",
453
- " const report = await runChecks({ root, allowGitSpawn: true });",
454
- " for (const f of report.findings) {",
455
- ' failures.push(`${f.class}: ${f.message}`);',
456
- " }",
457
- "}",
458
- "",
459
- "if (failures.length > 0) {",
460
- ' for (const f of failures) console.error(`[check-core] ${f}`);',
461
- " process.exit(failures.some((f) => f.includes(\"symlink\") || f.includes(\"not a regular\") || f.includes(\"traversal\") || f.includes(\"escape\")) ? 2 : 1);",
390
+ "// core check: generated shim. It only imports the Foundation's single",
391
+ "// shared production check entry (runCoreCheck), passes the target root,",
392
+ "// prints the structured findings, and maps the stable exit code.",
393
+ "// No local algorithm duplication: safe tree enumeration, symlink/FIFO/",
394
+ "// special-entry rejection, closed-world classification, path containment,",
395
+ "// and the seven check classes all live in skill-family-engineering-kit.",
396
+ 'import { runCoreCheck } from "skill-family-engineering-kit";',
397
+ "",
398
+ "const report = await runCoreCheck({ root: process.cwd(), allowGitSpawn: true });",
399
+ "for (const f of report.findings) {",
400
+ ' console.error(`[check-core] ${f.class}: ${f.kind}: ${f.message}`);',
462
401
  "}",
463
- 'console.log(`[check-core] OK`);',
402
+ "if (!report.ok) process.exit(report.exitCode);",
403
+ 'console.log("[check-core] OK");',
464
404
  "",
465
405
  ].join("\n");
466
406
  }
package/src/workspace.mjs CHANGED
@@ -101,7 +101,11 @@ export async function listTargetEntries(root) {
101
101
  }
102
102
  if (dirent.isFile()) {
103
103
  entries.push({ path: rel, kind: "file" });
104
+ continue;
104
105
  }
106
+ // Anything else (FIFO, socket, device, …) is a special entry: it is
107
+ // recorded so classification can fail closed on it; it is never opened.
108
+ entries.push({ path: rel, kind: "special" });
105
109
  }
106
110
  }
107
111
  await walk(root, "");
@@ -169,6 +173,61 @@ export function matchAnyGlob(patterns, relPath) {
169
173
  return Array.isArray(patterns) && patterns.some((pattern) => matchGlob(pattern, relPath));
170
174
  }
171
175
 
176
+ function gitignoreSegmentRegExp(segment) {
177
+ let expression = "";
178
+ for (const char of segment) {
179
+ if (char === "*") expression += "[^/]*";
180
+ else expression += char.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
181
+ }
182
+ return new RegExp(`^${expression}$`);
183
+ }
184
+
185
+ /**
186
+ * Gitignore-style lexical match for file-registry artifact patterns. The
187
+ * registry's artifacts class is mechanically identical to the .gitignore
188
+ * patterns (check-structure enforces the equality), so the closed-world
189
+ * classification interprets them with gitignore semantics:
190
+ * - a leading "/" anchors the pattern at the target root;
191
+ * - a "/" in the middle (after the trailing one is stripped) also anchors;
192
+ * - a trailing "/" marks a directory pattern: it matches everything under
193
+ * a directory with that name, never a regular file of that name;
194
+ * - "*" is a per-segment wildcard (never crosses "/");
195
+ * - unanchored patterns match at any depth.
196
+ * Purely lexical; never touches the filesystem.
197
+ */
198
+ export function matchGitignoreStylePattern(pattern, relPath) {
199
+ if (typeof pattern !== "string" || typeof relPath !== "string") return false;
200
+ let trimmed = pattern.trim();
201
+ if (trimmed === "" || trimmed.startsWith("#")) return false;
202
+ let rootAnchored = trimmed.startsWith("/");
203
+ if (rootAnchored) trimmed = trimmed.slice(1);
204
+ const directoryOnly = trimmed.endsWith("/");
205
+ if (directoryOnly) trimmed = trimmed.slice(0, -1);
206
+ if (trimmed.includes("/")) rootAnchored = true;
207
+ const patternSegments = trimmed.split("/").filter((segment) => segment !== "");
208
+ if (patternSegments.length === 0) return false;
209
+ const pathSegments = relPath.split("/").filter((segment) => segment !== "");
210
+ const matchesAt = (start) => {
211
+ if (start + patternSegments.length > pathSegments.length) return false;
212
+ for (let i = 0; i < patternSegments.length; i += 1) {
213
+ if (!gitignoreSegmentRegExp(patternSegments[i]).test(pathSegments[start + i])) return false;
214
+ }
215
+ const end = start + patternSegments.length;
216
+ // A directory pattern matches everything strictly under the directory;
217
+ // a file pattern matches exactly the named entry.
218
+ return directoryOnly ? end < pathSegments.length : end === pathSegments.length;
219
+ };
220
+ if (rootAnchored) return matchesAt(0);
221
+ for (let start = 0; start < pathSegments.length; start += 1) {
222
+ if (matchesAt(start)) return true;
223
+ }
224
+ return false;
225
+ }
226
+
227
+ export function matchAnyGitignorePattern(patterns, relPath) {
228
+ return Array.isArray(patterns) && patterns.some((pattern) => matchGitignoreStylePattern(pattern, relPath));
229
+ }
230
+
172
231
  /** Normalizes a relative path to forward slashes without touching the fs. */
173
232
  export function normalizeRelPath(relPath) {
174
233
  return String(relPath).replaceAll("\\", "/").replace(/\/+/g, "/").replace(/^\.\//, "");
@@ -189,6 +248,8 @@ export async function loadTargetFacts(root) {
189
248
  managedLock: null,
190
249
  managedSet: new Set(),
191
250
  handwrittenPatterns: [...DEFAULT_HANDWRITTEN_PATTERNS],
251
+ artifactPatterns: [],
252
+ trackedToolLocks: [],
192
253
  hasOwnRegistry: false,
193
254
  };
194
255
 
@@ -196,16 +257,37 @@ export async function loadTargetFacts(root) {
196
257
  if (registry.ok && registry.value && typeof registry.value === "object") {
197
258
  facts.fileRegistry = registry.value;
198
259
  facts.hasOwnRegistry = true;
260
+ // Foundation-shape registry: one flat managed list.
199
261
  const managed = registry.value?.classes?.managed;
200
262
  if (Array.isArray(managed)) {
201
263
  for (const entry of managed) {
202
264
  if (typeof entry === "string") facts.managedSet.add(normalizeRelPath(entry));
203
265
  }
204
266
  }
267
+ // Generated-skeleton registry: the managed set is declared as two classes
268
+ // (projen-managed + kit-managed). Both shapes feed the same closed world.
269
+ for (const classKey of ["projenManaged", "kitManaged"]) {
270
+ const files = registry.value?.classes?.[classKey]?.files;
271
+ if (Array.isArray(files)) {
272
+ for (const entry of files) {
273
+ if (typeof entry === "string") facts.managedSet.add(normalizeRelPath(entry));
274
+ }
275
+ }
276
+ }
205
277
  const patterns = registry.value?.classes?.handwritten?.entries;
206
278
  if (Array.isArray(patterns) && patterns.length > 0) {
207
279
  facts.handwrittenPatterns = patterns.filter((entry) => typeof entry === "string");
208
280
  }
281
+ const artifactPatterns = registry.value?.classes?.artifacts?.patterns;
282
+ if (Array.isArray(artifactPatterns)) {
283
+ facts.artifactPatterns = artifactPatterns.filter((entry) => typeof entry === "string");
284
+ }
285
+ const toolLocks = registry.value?.classes?.trackedToolLocks?.entries;
286
+ if (Array.isArray(toolLocks)) {
287
+ facts.trackedToolLocks = toolLocks
288
+ .filter((entry) => typeof entry === "string")
289
+ .map((entry) => normalizeRelPath(entry));
290
+ }
209
291
  }
210
292
 
211
293
  const manifest = await readOptionalJson(root, "skill-family.project-manifest.json");