skill-family-engineering-kit 0.1.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.
Files changed (80) hide show
  1. package/CODE_OF_CONDUCT.md +131 -0
  2. package/CONTRIBUTING.md +69 -0
  3. package/LICENSE +201 -0
  4. package/README.md +74 -0
  5. package/SECURITY.md +34 -0
  6. package/data/licensing/registry.json +211 -0
  7. package/data/licensing/schema.json +207 -0
  8. package/docs/.nojekyll +0 -0
  9. package/docs/404.html +613 -0
  10. package/docs/architecture/index.html +904 -0
  11. package/docs/assets/images/favicon.png +0 -0
  12. package/docs/assets/javascripts/bundle.d7400e89.min.js +16 -0
  13. package/docs/assets/javascripts/lunr/min/lunr.ar.min.js +1 -0
  14. package/docs/assets/javascripts/lunr/min/lunr.da.min.js +18 -0
  15. package/docs/assets/javascripts/lunr/min/lunr.de.min.js +18 -0
  16. package/docs/assets/javascripts/lunr/min/lunr.du.min.js +18 -0
  17. package/docs/assets/javascripts/lunr/min/lunr.el.min.js +1 -0
  18. package/docs/assets/javascripts/lunr/min/lunr.es.min.js +18 -0
  19. package/docs/assets/javascripts/lunr/min/lunr.fi.min.js +18 -0
  20. package/docs/assets/javascripts/lunr/min/lunr.fr.min.js +18 -0
  21. package/docs/assets/javascripts/lunr/min/lunr.he.min.js +1 -0
  22. package/docs/assets/javascripts/lunr/min/lunr.hi.min.js +1 -0
  23. package/docs/assets/javascripts/lunr/min/lunr.hu.min.js +18 -0
  24. package/docs/assets/javascripts/lunr/min/lunr.hy.min.js +1 -0
  25. package/docs/assets/javascripts/lunr/min/lunr.it.min.js +18 -0
  26. package/docs/assets/javascripts/lunr/min/lunr.ja.min.js +1 -0
  27. package/docs/assets/javascripts/lunr/min/lunr.jp.min.js +1 -0
  28. package/docs/assets/javascripts/lunr/min/lunr.kn.min.js +1 -0
  29. package/docs/assets/javascripts/lunr/min/lunr.ko.min.js +1 -0
  30. package/docs/assets/javascripts/lunr/min/lunr.multi.min.js +1 -0
  31. package/docs/assets/javascripts/lunr/min/lunr.nl.min.js +18 -0
  32. package/docs/assets/javascripts/lunr/min/lunr.no.min.js +18 -0
  33. package/docs/assets/javascripts/lunr/min/lunr.pt.min.js +18 -0
  34. package/docs/assets/javascripts/lunr/min/lunr.ro.min.js +18 -0
  35. package/docs/assets/javascripts/lunr/min/lunr.ru.min.js +18 -0
  36. package/docs/assets/javascripts/lunr/min/lunr.sa.min.js +1 -0
  37. package/docs/assets/javascripts/lunr/min/lunr.stemmer.support.min.js +1 -0
  38. package/docs/assets/javascripts/lunr/min/lunr.sv.min.js +18 -0
  39. package/docs/assets/javascripts/lunr/min/lunr.ta.min.js +1 -0
  40. package/docs/assets/javascripts/lunr/min/lunr.te.min.js +1 -0
  41. package/docs/assets/javascripts/lunr/min/lunr.th.min.js +1 -0
  42. package/docs/assets/javascripts/lunr/min/lunr.tr.min.js +18 -0
  43. package/docs/assets/javascripts/lunr/min/lunr.vi.min.js +1 -0
  44. package/docs/assets/javascripts/lunr/min/lunr.zh.min.js +1 -0
  45. package/docs/assets/javascripts/lunr/tinyseg.js +206 -0
  46. package/docs/assets/javascripts/lunr/wordcut.js +6708 -0
  47. package/docs/assets/javascripts/workers/search.2c215733.min.js +42 -0
  48. package/docs/assets/stylesheets/main.ec1eaa64.min.css +1 -0
  49. package/docs/assets/stylesheets/palette.ab4e12ef.min.css +1 -0
  50. package/docs/git-lifecycle/index.html +976 -0
  51. package/docs/help/index.html +907 -0
  52. package/docs/index.html +837 -0
  53. package/docs/integration/audit/baseline/audit-codes.json +62 -0
  54. package/docs/integration/audit/failure-evidence/index.html +872 -0
  55. package/docs/integration/audit/independence/index.html +841 -0
  56. package/docs/integration/audit/index.html +826 -0
  57. package/docs/integration/audit/mutation-taxonomy/index.html +1022 -0
  58. package/docs/integration/audit/schemas/audit-evidence.schema.json +182 -0
  59. package/docs/integration/audit/version-compatibility/index.html +845 -0
  60. package/docs/migration/index.html +1213 -0
  61. package/docs/quickstart/index.html +842 -0
  62. package/docs/search/search_index.json +1 -0
  63. package/docs/setup/index.html +941 -0
  64. package/docs/sitemap.xml +51 -0
  65. package/package.json +42 -0
  66. package/src/adopt-plan.mjs +594 -0
  67. package/src/check.mjs +415 -0
  68. package/src/cli.mjs +230 -0
  69. package/src/errors.mjs +144 -0
  70. package/src/gitprobe.mjs +230 -0
  71. package/src/identity-check.mjs +563 -0
  72. package/src/index.mjs +233 -0
  73. package/src/license-texts/Apache-2.0.txt +201 -0
  74. package/src/license-texts/MIT.txt +21 -0
  75. package/src/licensing.mjs +299 -0
  76. package/src/migration.mjs +427 -0
  77. package/src/projection.mjs +397 -0
  78. package/src/scaffold.mjs +178 -0
  79. package/src/skeleton.mjs +886 -0
  80. package/src/workspace.mjs +236 -0
@@ -0,0 +1,51 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
3
+ <url>
4
+ <loc>https://ifoohoo.github.io/skill-family-engineering-kit/</loc>
5
+ <lastmod>1970-01-01</lastmod>
6
+ </url>
7
+ <url>
8
+ <loc>https://ifoohoo.github.io/skill-family-engineering-kit/architecture/</loc>
9
+ <lastmod>1970-01-01</lastmod>
10
+ </url>
11
+ <url>
12
+ <loc>https://ifoohoo.github.io/skill-family-engineering-kit/git-lifecycle/</loc>
13
+ <lastmod>1970-01-01</lastmod>
14
+ </url>
15
+ <url>
16
+ <loc>https://ifoohoo.github.io/skill-family-engineering-kit/help/</loc>
17
+ <lastmod>1970-01-01</lastmod>
18
+ </url>
19
+ <url>
20
+ <loc>https://ifoohoo.github.io/skill-family-engineering-kit/quickstart/</loc>
21
+ <lastmod>1970-01-01</lastmod>
22
+ </url>
23
+ <url>
24
+ <loc>https://ifoohoo.github.io/skill-family-engineering-kit/setup/</loc>
25
+ <lastmod>1970-01-01</lastmod>
26
+ </url>
27
+ <url>
28
+ <loc>https://ifoohoo.github.io/skill-family-engineering-kit/integration/audit/</loc>
29
+ <lastmod>1970-01-01</lastmod>
30
+ </url>
31
+ <url>
32
+ <loc>https://ifoohoo.github.io/skill-family-engineering-kit/integration/audit/failure-evidence/</loc>
33
+ <lastmod>1970-01-01</lastmod>
34
+ </url>
35
+ <url>
36
+ <loc>https://ifoohoo.github.io/skill-family-engineering-kit/integration/audit/independence/</loc>
37
+ <lastmod>1970-01-01</lastmod>
38
+ </url>
39
+ <url>
40
+ <loc>https://ifoohoo.github.io/skill-family-engineering-kit/integration/audit/mutation-taxonomy/</loc>
41
+ <lastmod>1970-01-01</lastmod>
42
+ </url>
43
+ <url>
44
+ <loc>https://ifoohoo.github.io/skill-family-engineering-kit/integration/audit/version-compatibility/</loc>
45
+ <lastmod>1970-01-01</lastmod>
46
+ </url>
47
+ <url>
48
+ <loc>https://ifoohoo.github.io/skill-family-engineering-kit/migration/</loc>
49
+ <lastmod>1970-01-01</lastmod>
50
+ </url>
51
+ </urlset>
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "skill-family-engineering-kit",
3
+ "version": "0.1.0",
4
+ "private": false,
5
+ "description": "Build-time scaffold, adoption planning, projection, and checks.",
6
+ "author": "广州市风荷科技有限公司",
7
+ "license": "Apache-2.0",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://github.com/ifoohoo/skill-family-engineering-kit.git"
11
+ },
12
+ "homepage": "https://github.com/ifoohoo/skill-family-engineering-kit",
13
+ "bugs": {
14
+ "url": "https://github.com/ifoohoo/skill-family-engineering-kit/issues"
15
+ },
16
+ "type": "module",
17
+ "engines": {
18
+ "node": ">=22.22.2 <23"
19
+ },
20
+ "exports": "./src/index.mjs",
21
+ "dependencies": {
22
+ "skill-family-contracts": "0.1.0",
23
+ "skill-family-harness-node": "0.1.0"
24
+ },
25
+ "bin": {
26
+ "skill-family-kit": "./src/cli.mjs"
27
+ },
28
+ "files": [
29
+ "src",
30
+ "data",
31
+ "LICENSE",
32
+ "SECURITY.md",
33
+ "CONTRIBUTING.md",
34
+ "CODE_OF_CONDUCT.md",
35
+ "docs"
36
+ ],
37
+ "//": "~~ Generated by projen. To modify, edit .projenrc.js and run \"npx projen\".",
38
+ "scripts": {
39
+ "check": "node --test",
40
+ "test": "node --test"
41
+ }
42
+ }
@@ -0,0 +1,594 @@
1
+ import path from "node:path";
2
+ import { CONTRACTS_VERSION } from "skill-family-contracts";
3
+ import { digestBytes, readFileContained } from "skill-family-harness-node";
4
+ import { runChecks } from "./check.mjs";
5
+ import { probeGitFacts, probeGitState } from "./gitprobe.mjs";
6
+ import {
7
+ assessAdoptionBinding,
8
+ assessLegacyExitList,
9
+ assessVerificationEvidence,
10
+ evaluateMigrationCompletion,
11
+ findNestedRepositories,
12
+ loadMigrationManifestState,
13
+ MIGRATION_MANIFEST_PATH,
14
+ validateException,
15
+ VERIFICATION_EVIDENCE_KINDS,
16
+ } from "./migration.mjs";
17
+ import {
18
+ describeSkeletonFiles,
19
+ KIT_TOOL_NAME,
20
+ KIT_VERSION,
21
+ normalizeSkeletonInputs,
22
+ } from "./skeleton.mjs";
23
+ import {
24
+ listTargetEntries,
25
+ loadTargetFacts,
26
+ matchAnyGlob,
27
+ normalizeRelPath,
28
+ resolveTargetRoot,
29
+ } from "./workspace.mjs";
30
+
31
+ /**
32
+ * adopt-plan — strictly read-only adoption planning.
33
+ *
34
+ * Computes the exact write set, conflicts, risks, and verification plan for
35
+ * adopting the Skill Family skeleton into an existing target. The function
36
+ * contains no write call of any kind: it reads the target, computes the
37
+ * plan in memory, and returns it. Callers print it; nothing is written,
38
+ * not even a temporary file (so "zero byte change on dirty repositories"
39
+ * is trivially verifiable with before/after hash walks).
40
+ *
41
+ * Planned content comes from describeSkeletonFiles — the same pure function
42
+ * scaffold consumes — so the plan and the later action are byte-identical.
43
+ */
44
+
45
+ async function readExistingBytes(rootAbs, relPath) {
46
+ // Contained read: even though planned paths are a frozen set, every fs
47
+ // access goes through the harness containment layer.
48
+ try {
49
+ return await readFileContained(rootAbs, relPath);
50
+ } catch {
51
+ return null;
52
+ }
53
+ }
54
+
55
+ /**
56
+ * The ten-field per-repository adoption hand-off draft.
57
+ *
58
+ * adopt-plan must carry enough structured data to draft the planner's
59
+ * ten-field hand-off. Fields that cannot be derived mechanically from
60
+ * read-only evidence are marked INCOMPLETE and block the draft — the kit
61
+ * never guesses. The draft binds the target file-set digest and the
62
+ * Foundation plan digest; rendering is a separate read-only planner task
63
+ * that only ever writes the planner-side draft, never the consumer repo.
64
+ */
65
+ export const HANDOFF_FIELDS = Object.freeze([
66
+ "project-identity",
67
+ "repository-state",
68
+ "foundation-binding",
69
+ "write-set-policy",
70
+ "legacy-exits",
71
+ "business-logic-to-keep",
72
+ "plan-summary",
73
+ "verification-entries",
74
+ "legacy-removal-recovery",
75
+ "authorization-status",
76
+ ]);
77
+
78
+ export function buildHandoffDraft({
79
+ projectId,
80
+ rootAbs,
81
+ agentsFiles,
82
+ git,
83
+ gitFacts,
84
+ unregisteredPaths,
85
+ nestedRepositories,
86
+ inputs,
87
+ migrationManifest,
88
+ writeSet,
89
+ conflicts,
90
+ risks,
91
+ legacyExitList,
92
+ verificationFacts,
93
+ migrationState,
94
+ entries,
95
+ }) {
96
+ const fields = [];
97
+ const push = (id, name, status, value) => fields.push({ id, name, status, value });
98
+
99
+ // 1. Project identity, absolute path, project-level AGENTS files.
100
+ push(1, HANDOFF_FIELDS[0], "derived", {
101
+ projectId,
102
+ root: rootAbs,
103
+ agentsFiles: agentsFiles ?? [],
104
+ });
105
+
106
+ // 2. dirty / untracked / nested-repository state — facts or not-proven,
107
+ // never fabricated.
108
+ push(2, HANDOFF_FIELDS[1], "derived", {
109
+ repository: git.repository,
110
+ headCommit: git.headCommit,
111
+ cleanState: git.cleanState,
112
+ dirty: git.cleanState === false,
113
+ unregisteredContent: git.repository ? null : unregisteredPaths ?? [],
114
+ nestedRepositories: nestedRepositories ?? [],
115
+ trackedButIgnored: gitFacts?.status === "proven" ? gitFacts.trackedButIgnored : null,
116
+ gitFactsStatus: gitFacts?.status ?? "not-proven",
117
+ });
118
+
119
+ // 3. Profile plus exact Foundation versions and digests: only the
120
+ // manifest-declared binding counts; an undeclared binding is INCOMPLETE.
121
+ const declaredPackages = Array.isArray(migrationManifest?.foundationPackages)
122
+ ? migrationManifest.foundationPackages
123
+ : null;
124
+ push(3, HANDOFF_FIELDS[2], declaredPackages && declaredPackages.length > 0 ? "derived" : "INCOMPLETE", {
125
+ plannedProfile: inputs.profileId,
126
+ contractsVersion: CONTRACTS_VERSION,
127
+ foundationPackages: declaredPackages,
128
+ });
129
+
130
+ // 4. Allowed and forbidden write sets.
131
+ const creates = writeSet.filter((item) => item.action !== "unchanged").map((item) => item.path).sort();
132
+ push(4, HANDOFF_FIELDS[3], "derived", {
133
+ allowed: creates,
134
+ unchanged: writeSet.length - creates.length,
135
+ forbidden:
136
+ "nothing outside the listed write set is ever written; the kit never renames, deletes, rewrites handwritten files, or touches remotes; legacy removal is a human-performed exit step",
137
+ });
138
+
139
+ // 5. The validator/builder/docs-pipeline/git-preflight slated for exit.
140
+ push(5, HANDOFF_FIELDS[4], "derived", {
141
+ exits: legacyExitList.map((item) => ({ path: item.path, replacedBy: item.replacedBy, status: item.status })),
142
+ });
143
+
144
+ // 6. Business logic that must be kept: never derivable from repo facts.
145
+ push(6, HANDOFF_FIELDS[5], "INCOMPLETE", {
146
+ reason: "business-logic boundaries cannot be derived mechanically; a human owner must enumerate them before any hand-off starts",
147
+ });
148
+
149
+ // 7. adopt-plan summary.
150
+ push(7, HANDOFF_FIELDS[6], "derived", {
151
+ writeSetCreates: creates.length,
152
+ writeSetUnchanged: writeSet.length - creates.length,
153
+ conflicts: conflicts.length,
154
+ risks: risks.length,
155
+ migrationState,
156
+ });
157
+
158
+ // 8. Unit / integration / consumer / independent-audit entries: derived
159
+ // only when every evidence kind is proven. assessVerificationEvidence
160
+ // always returns one status-bearing fact per kind (undeclared/missing/
161
+ // invalid-path/unreadable/identity-mismatch/proven), so presence alone
162
+ // proves nothing — fail-closed, never guessed (FC-12).
163
+ const evidenceByKind = new Map((verificationFacts ?? []).map((fact) => [fact.kind, fact]));
164
+ const missingEvidence = VERIFICATION_EVIDENCE_KINDS.filter((kind) => evidenceByKind.get(kind)?.status !== "proven");
165
+ push(8, HANDOFF_FIELDS[7], missingEvidence.length === 0 ? "derived" : "INCOMPLETE", {
166
+ entries: (verificationFacts ?? []).map((fact) => ({ kind: fact.kind, path: fact.path ?? null, status: fact.status })),
167
+ missing: missingEvidence,
168
+ });
169
+
170
+ // 9. How removed legacy implementations can be recovered: never
171
+ // derivable mechanically (depends on the target's own history/backups).
172
+ push(9, HANDOFF_FIELDS[8], "INCOMPLETE", {
173
+ reason: "recovery paths for removed legacy implementations depend on the target's own history and backups; the kit never guesses them",
174
+ });
175
+
176
+ // 10. commit/push/tag/publish authorization: a fixed policy fact —
177
+ // adoption planning authorizes none of them.
178
+ push(10, HANDOFF_FIELDS[9], "derived", {
179
+ commit: false,
180
+ push: false,
181
+ tag: false,
182
+ publish: false,
183
+ note: "adoption authorizes no git write, tag, or release; each requires explicit per-task user authorization",
184
+ });
185
+
186
+ const incompleteFields = fields.filter((field) => field.status === "INCOMPLETE").map((field) => field.name);
187
+
188
+ // Binding digests: the target file-set summary and the Foundation plan.
189
+ const targetSetDigest = digestBytes(
190
+ Buffer.from(entries.map((entry) => `${entry.path}:${entry.kind}`).sort().join("\n"), "utf8"),
191
+ );
192
+ const foundationPlanDigest = digestBytes(
193
+ Buffer.from(writeSet.map((item) => `${item.path}:${item.action}:${item.sha256}`).sort().join("\n"), "utf8"),
194
+ );
195
+
196
+ return {
197
+ kind: "skill-family.handoff-draft",
198
+ schemaVersion: 1,
199
+ fields,
200
+ incompleteFields,
201
+ ready: incompleteFields.length === 0,
202
+ binding: { targetSetDigest, foundationPlanDigest },
203
+ };
204
+ }
205
+
206
+ /**
207
+ * Plans the adoption of the skeleton into root.
208
+ * Options: { root, projectId, projectName, profileId, licensingProfile, licensingProfileData, profilesRoot, allowGitSpawn, now }.
209
+ * Returns the plan document; throws KitError only for unusable inputs
210
+ * (an unreadable target). Never writes anywhere.
211
+ */
212
+ export async function planAdoption({
213
+ root,
214
+ projectId,
215
+ projectName,
216
+ profileId,
217
+ licensingProfile,
218
+ licensingVariant,
219
+ licensingProfileData,
220
+ profilesRoot,
221
+ identityProjections,
222
+ allowGitSpawn = true,
223
+ now = Date.now(),
224
+ } = {}) {
225
+ const rootAbs = await resolveTargetRoot(root ?? ".");
226
+ const entries = await listTargetEntries(rootAbs);
227
+ const facts = await loadTargetFacts(rootAbs);
228
+ const git = await probeGitState(rootAbs, { allowSpawn: allowGitSpawn });
229
+
230
+ const inputs = normalizeSkeletonInputs({
231
+ projectId,
232
+ projectName,
233
+ profileId,
234
+ licensingProfile,
235
+ licensingVariant,
236
+ rootBasename: path.basename(rootAbs),
237
+ });
238
+ const skeleton = await describeSkeletonFiles({
239
+ ...inputs,
240
+ licensingProfileData,
241
+ profilesRoot,
242
+ identityProjections,
243
+ });
244
+
245
+ const entryByPath = new Map(entries.map((entry) => [entry.path, entry]));
246
+
247
+ const writeSet = [];
248
+ const conflicts = [];
249
+ for (const file of skeleton.files) {
250
+ const rel = normalizeRelPath(file.path);
251
+ const plannedSha256 = digestBytes(Buffer.from(file.content, "utf8"));
252
+ const existing = entryByPath.get(rel);
253
+
254
+ if (!existing) {
255
+ writeSet.push({
256
+ path: rel,
257
+ fileClass: file.fileClass,
258
+ action: "create",
259
+ sha256: plannedSha256,
260
+ });
261
+ continue;
262
+ }
263
+
264
+ if (existing.kind === "symlink") {
265
+ conflicts.push({
266
+ path: rel,
267
+ kind: "symlink-on-planned-path",
268
+ code: "SFC2004",
269
+ detail:
270
+ "a symbolic link occupies the planned path; the kit never writes through links — resolve it manually before adoption",
271
+ });
272
+ continue;
273
+ }
274
+ if (existing.kind === "directory") {
275
+ conflicts.push({
276
+ path: rel,
277
+ kind: "type-conflict",
278
+ code: "SFC2004",
279
+ detail: "a directory occupies the planned file path",
280
+ });
281
+ continue;
282
+ }
283
+
284
+ const bytes = await readExistingBytes(rootAbs, rel);
285
+ const existingSha256 = bytes === null ? null : digestBytes(bytes);
286
+ if (existingSha256 === plannedSha256) {
287
+ writeSet.push({
288
+ path: rel,
289
+ fileClass: file.fileClass,
290
+ action: "unchanged",
291
+ sha256: plannedSha256,
292
+ });
293
+ continue;
294
+ }
295
+
296
+ // The migration manifest is target-held data once seeded: adoption
297
+ // never overwrites an existing manifest, and its contract validity is
298
+ // assessed exclusively by the migration section below (parse/schema,
299
+ // legacy exit list, temporary exceptions).
300
+ if (rel === MIGRATION_MANIFEST_PATH) continue;
301
+
302
+ // Existing, different content: adoption never overwrites. Handwritten
303
+ // material is a hard conflict; a drifted managed declaration needs an
304
+ // explicit human decision before any re-projection.
305
+ const handwritten =
306
+ matchAnyGlob(facts.handwrittenPatterns, rel) || !facts.managedSet.has(rel);
307
+ conflicts.push({
308
+ path: rel,
309
+ kind: handwritten ? "handwritten-conflict" : "managed-drift",
310
+ code: "SFC2004",
311
+ detail: handwritten
312
+ ? "existing content is handwritten (or not declared managed); the kit will never overwrite it"
313
+ : "existing content differs from the planned managed bytes; reconcile before adoption",
314
+ });
315
+ }
316
+
317
+ const risks = [];
318
+ // Git-independent unregistered content (also feeds the ten-field hand-off
319
+ // draft field 2): neither planned nor declared managed.
320
+ const plannedPaths = new Set(
321
+ skeleton.files
322
+ .filter((file) => normalizeRelPath(file.path) !== MIGRATION_MANIFEST_PATH)
323
+ .map((file) => normalizeRelPath(file.path)),
324
+ );
325
+ const unregisteredPaths = entries
326
+ .filter(
327
+ (entry) =>
328
+ (entry.kind === "file" || entry.kind === "symlink") &&
329
+ !plannedPaths.has(entry.path) &&
330
+ !facts.managedSet.has(entry.path),
331
+ )
332
+ .map((entry) => entry.path)
333
+ .sort();
334
+ if (git.repository && git.headCommit === false) {
335
+ risks.push({
336
+ kind: "git-no-commits",
337
+ detail: "the target is a git repository without any commit; nothing is protected by history yet",
338
+ });
339
+ }
340
+ if (git.repository && git.cleanState === false) {
341
+ risks.push({
342
+ kind: "git-dirty",
343
+ detail: "the target has uncommitted changes; settle them before adoption (the plan itself changes nothing)",
344
+ });
345
+ }
346
+ if (!git.repository) {
347
+ // Git-independent dirty equivalence (FND-001 modelling): content that
348
+ // is neither planned nor declared managed counts as uncommitted state.
349
+ // The migration manifest is target-held data, not planned content: it
350
+ // is judged by the migration contract, so it stays in this equivalence.
351
+ if (unregisteredPaths.length > 0) {
352
+ risks.push({
353
+ kind: "unregistered-content",
354
+ detail: `${unregisteredPaths.length} entr${unregisteredPaths.length === 1 ? "y" : "ies"} are neither planned nor declared managed; adoption leaves them untouched`,
355
+ });
356
+ }
357
+ }
358
+ if (conflicts.some((conflict) => conflict.kind === "symlink-on-planned-path")) {
359
+ risks.push({
360
+ kind: "symlink-on-planned-path",
361
+ detail: "at least one planned path is a symbolic link; writes through links are refused",
362
+ });
363
+ }
364
+
365
+ // Migration-closure facts (FND-070): nested repositories and
366
+ // tracked-but-ignored hazards are reported, never acted upon.
367
+ const nestedRepositories = findNestedRepositories(entries);
368
+ if (nestedRepositories.length > 0) {
369
+ risks.push({
370
+ kind: "nested-repository",
371
+ detail: `embedded repositories present: ${nestedRepositories.join(", ")}; adoption never operates inside a nested repository`,
372
+ });
373
+ }
374
+ // Read-only Git evidence probe: tracked/ignore facts come from real git semantics (read-only
375
+ // index + git's own ignore machinery), never from lexical guesses and
376
+ // never from managed declarations impersonating tracked state. When git
377
+ // is unavailable the facts stay not-proven — unknown, not fabricated.
378
+ const gitFactCandidates = [
379
+ ...entries.filter((entry) => entry.kind === "file" || entry.kind === "symlink").map((entry) => entry.path),
380
+ ...facts.managedSet,
381
+ ];
382
+ const gitFacts = await probeGitFacts(rootAbs, gitFactCandidates, { allowSpawn: allowGitSpawn });
383
+ if (gitFacts.status === "proven" && gitFacts.trackedButIgnored.length > 0) {
384
+ risks.push({
385
+ kind: "tracked-but-ignored",
386
+ detail: `tracked in the git index yet matched by ignore rules: ${gitFacts.trackedButIgnored.join(", ")}; reconcile tracking and ignore state before adoption`,
387
+ });
388
+ }
389
+
390
+ // Legacy exit list and temporary exceptions come from the target's own
391
+ // migration manifest; the kit only assesses, it never deletes or renews.
392
+ // The manifest is a formal Contract — only a contract-valid
393
+ // manifest counts as declared; parse/schema failures are hard conflicts.
394
+ const manifestState = await loadMigrationManifestState(rootAbs);
395
+ const manifestDeclared = manifestState.status === "valid";
396
+ if (manifestState.status === "parse-failed") {
397
+ conflicts.push({
398
+ path: MIGRATION_MANIFEST_PATH,
399
+ kind: "migration-manifest-parse-failed",
400
+ code: "SFC2004",
401
+ detail: "the migration manifest is not parseable JSON; fix it before adoption can proceed",
402
+ });
403
+ } else if (manifestState.status === "schema-invalid") {
404
+ conflicts.push({
405
+ path: MIGRATION_MANIFEST_PATH,
406
+ kind: "migration-manifest-invalid",
407
+ code: "SFC1001",
408
+ detail: `the migration manifest violates the migration-manifest contract: ${manifestState.problems.join("; ")}`,
409
+ });
410
+ }
411
+ const migrationManifest = manifestDeclared ? manifestState.manifest : null;
412
+ const legacyExitList = await assessLegacyExitList(rootAbs, migrationManifest?.legacyInfra ?? []);
413
+ for (const item of legacyExitList) {
414
+ if (item.status === "invalid") {
415
+ conflicts.push({
416
+ path: MIGRATION_MANIFEST_PATH,
417
+ kind: "legacy-path-containment",
418
+ code: "SFC2004",
419
+ detail: `legacy path rejected by harness containment, fail-closed: ${item.path ?? "<malformed>"} (${item.invalidKind ?? "unknown"}); user paths are only ever read inside the target root`,
420
+ });
421
+ }
422
+ }
423
+ const exceptionFindings = [];
424
+ const declaredExceptions = Array.isArray(migrationManifest?.exceptions) ? migrationManifest.exceptions : [];
425
+ for (const [index, exception] of declaredExceptions.entries()) {
426
+ for (const finding of validateException(exception, index, now)) {
427
+ exceptionFindings.push(finding);
428
+ if (finding.kind === "exception-incomplete") {
429
+ conflicts.push({
430
+ path: MIGRATION_MANIFEST_PATH,
431
+ kind: "exception-incomplete",
432
+ code: "SFC2004",
433
+ detail: `temporary exception #${index} is missing required fields: ${finding.missing.join(", ")} (owner, reason, deadline and migrationTarget are all mandatory)`,
434
+ });
435
+ } else if (finding.kind === "exception-invalid-deadline") {
436
+ conflicts.push({
437
+ path: MIGRATION_MANIFEST_PATH,
438
+ kind: "exception-invalid-deadline",
439
+ code: "SFC2004",
440
+ detail: `temporary exception #${index} carries an unparseable deadline: ${finding.deadline}`,
441
+ });
442
+ } else {
443
+ risks.push({
444
+ kind: "exception-expired",
445
+ detail: `temporary exception #${index} expired at ${finding.deadline}; expired exceptions are never auto-renewed`,
446
+ });
447
+ }
448
+ }
449
+ }
450
+ // Migration completion facts — all derived read-only from evidence on disk:
451
+ // the adoption binding declared by the manifest, the managed-byte proof,
452
+ // the remaining write actions, the Foundation check gate, and the four
453
+ // verification evidence documents.
454
+ const binding = assessAdoptionBinding(migrationManifest, inputs.profileId);
455
+ const adoptionProof = {
456
+ checked: skeleton.files.filter((file) => file.fileClass === "managed").length,
457
+ matched: writeSet.filter((item) => item.fileClass === "managed" && item.action === "unchanged").length,
458
+ missing: writeSet
459
+ .filter((item) => item.fileClass === "managed" && item.action !== "unchanged")
460
+ .map((item) => item.path)
461
+ .sort(),
462
+ mismatched: conflicts.filter((conflict) => conflict.kind === "managed-drift").map((conflict) => conflict.path).sort(),
463
+ };
464
+ const pendingWrites = writeSet.filter((item) => item.action !== "unchanged").length;
465
+ let checkGreen = false;
466
+ let checkNote = null;
467
+ try {
468
+ // check is diagnosis-only (read-only git probes included), so running it
469
+ // inside the read-only plan keeps the zero-byte-change promise.
470
+ const checkReport = await runChecks({ root: rootAbs, allowGitSpawn, profilesRoot });
471
+ checkGreen = checkReport.ok === true;
472
+ } catch (cause) {
473
+ checkNote = cause?.message ?? "check gate could not run";
474
+ }
475
+ const verificationFacts = await assessVerificationEvidence(
476
+ rootAbs,
477
+ migrationManifest?.verification,
478
+ inputs.projectId,
479
+ );
480
+
481
+ const completion = evaluateMigrationCompletion({
482
+ manifestDeclared,
483
+ legacyExitList,
484
+ exceptionFindings,
485
+ conflicts,
486
+ binding,
487
+ adoptionProof,
488
+ pendingWrites,
489
+ checkGreen,
490
+ verificationFacts,
491
+ });
492
+
493
+ const existingManagedDeclarations = [...facts.managedSet].sort();
494
+
495
+ // The ten-field hand-off draft. Fields that cannot be derived
496
+ // mechanically stay INCOMPLETE and block readiness — never guessed.
497
+ const agentsFiles = entries
498
+ .filter((entry) => entry.kind === "file" && (entry.path === "AGENTS.md" || entry.path === "CLAUDE.md"))
499
+ .map((entry) => entry.path)
500
+ .sort();
501
+ const handoffDraft = buildHandoffDraft({
502
+ projectId: inputs.projectId,
503
+ rootAbs,
504
+ agentsFiles,
505
+ git,
506
+ gitFacts,
507
+ unregisteredPaths,
508
+ nestedRepositories,
509
+ inputs,
510
+ migrationManifest,
511
+ writeSet,
512
+ conflicts,
513
+ risks,
514
+ legacyExitList,
515
+ verificationFacts,
516
+ migrationState: completion.state,
517
+ entries,
518
+ });
519
+
520
+ return {
521
+ kind: "skill-family.adoption-plan",
522
+ schemaVersion: 1,
523
+ generatedBy: { tool: KIT_TOOL_NAME, version: KIT_VERSION },
524
+ target: {
525
+ root: ".",
526
+ entryCount: entries.length,
527
+ hasOwnFileRegistry: facts.hasOwnRegistry,
528
+ existingManagedDeclarations,
529
+ },
530
+ project: {
531
+ ...inputs,
532
+ licensingProfile: skeleton.licensing.profile,
533
+ licensingVariant: skeleton.licensing.variant,
534
+ contractsVersion: CONTRACTS_VERSION,
535
+ },
536
+ git: {
537
+ repository: git.repository,
538
+ headCommit: git.headCommit,
539
+ cleanState: git.cleanState,
540
+ probe: git.probe,
541
+ },
542
+ gitFacts:
543
+ gitFacts.status === "proven"
544
+ ? {
545
+ status: "proven",
546
+ trackedButIgnored: gitFacts.trackedButIgnored,
547
+ ignoredNotTracked: gitFacts.ignoredNotTracked,
548
+ }
549
+ : { status: "not-proven", reason: gitFacts.reason },
550
+ writeSet,
551
+ conflicts,
552
+ risks,
553
+ migration: {
554
+ manifestDeclared,
555
+ manifestStatus: manifestState.status,
556
+ manifestPath: MIGRATION_MANIFEST_PATH,
557
+ legacyExitList,
558
+ exceptions: { declared: declaredExceptions.length, findings: exceptionFindings },
559
+ binding,
560
+ adoptionProof,
561
+ pendingWrites,
562
+ check: { green: checkGreen, note: checkNote },
563
+ verification: verificationFacts,
564
+ state: completion.state,
565
+ completion,
566
+ },
567
+ handoffDraft,
568
+ traceability: {
569
+ contractsVersion: CONTRACTS_VERSION,
570
+ skeletonSource: "describeSkeletonFiles",
571
+ licensingProfile: skeleton.licensing.profile,
572
+ licensingVariant: skeleton.licensing.variant,
573
+ },
574
+ verificationPlan: [
575
+ {
576
+ step: 1,
577
+ command: "sf-kit check --root <target>",
578
+ purpose: "diagnose contracts, drift, closure, versions, doc facts, and git pre-state",
579
+ },
580
+ {
581
+ step: 2,
582
+ command: "sf-kit projection --root <target>",
583
+ purpose: "materialize only manifest-authorized managed artifacts (never handwritten files)",
584
+ },
585
+ {
586
+ step: 3,
587
+ command: "sf-kit check --root <target>",
588
+ purpose: "re-diagnose after projection; the kit never auto-fixes any finding",
589
+ },
590
+ ],
591
+ policy:
592
+ "strictly read-only: this plan changes nothing in the target (no files, no temp files, no git); every listed byte is computed from describeSkeletonFiles, the same source scaffold consumes; the kit never renames repositories or directories and never touches remotes — infrastructure adoption and repository renaming are separate decisions; migration completion additionally requires every legacy implementation to have exited (dual-track wiring alone is not completion)",
593
+ };
594
+ }