@ikon85/agent-workflow-kit 0.34.6 → 0.35.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 (108) hide show
  1. package/.agents/skills/ask-matt/SKILL.md +8 -0
  2. package/.agents/skills/audit-skills/SKILL.md +8 -0
  3. package/.agents/skills/board-to-waves/SKILL.md +8 -0
  4. package/.agents/skills/census-update/SKILL.md +8 -0
  5. package/.agents/skills/code-review/SKILL.md +8 -0
  6. package/.agents/skills/codebase-design/SKILL.md +8 -0
  7. package/.agents/skills/codex-adapter-sync/SKILL.md +8 -0
  8. package/.agents/skills/decision-gate/SKILL.md +8 -0
  9. package/.agents/skills/diagnose/SKILL.md +8 -0
  10. package/.agents/skills/domain-modeling/SKILL.md +8 -0
  11. package/.agents/skills/git-worktree-recover/SKILL.md +8 -0
  12. package/.agents/skills/grill-me/SKILL.md +8 -0
  13. package/.agents/skills/grill-with-docs/SKILL.md +8 -0
  14. package/.agents/skills/implement/SKILL.md +8 -0
  15. package/.agents/skills/improve-codebase-architecture/SKILL.md +8 -0
  16. package/.agents/skills/kit-release/SKILL.md +8 -0
  17. package/.agents/skills/kit-update/SKILL.md +18 -4
  18. package/.agents/skills/local-ci/SKILL.md +8 -0
  19. package/.agents/skills/memory-lifecycle/SKILL.md +8 -0
  20. package/.agents/skills/orchestrate-wave/SKILL.md +2 -2
  21. package/.agents/skills/project-release/SKILL.md +8 -0
  22. package/.agents/skills/prototype/SKILL.md +8 -0
  23. package/.agents/skills/research/SKILL.md +8 -0
  24. package/.agents/skills/resolving-merge-conflicts/SKILL.md +8 -0
  25. package/.agents/skills/retro/SKILL.md +8 -0
  26. package/.agents/skills/scale-check/SKILL.md +8 -0
  27. package/.agents/skills/security-audit/SKILL.md +8 -0
  28. package/.agents/skills/setup-workflow/SKILL.md +30 -0
  29. package/.agents/skills/setup-workflow/orchestrate-wave-seed.md +1 -0
  30. package/.agents/skills/setup-workflow/spec-self-critique-seed.md +1 -0
  31. package/.agents/skills/spec-self-critique/SKILL.md +8 -0
  32. package/.agents/skills/tdd/SKILL.md +8 -0
  33. package/.agents/skills/to-issues/SKILL.md +8 -0
  34. package/.agents/skills/to-prd/SKILL.md +8 -0
  35. package/.agents/skills/to-waves/SKILL.md +8 -0
  36. package/.agents/skills/triage/SKILL.md +8 -0
  37. package/.agents/skills/verify-spike/SKILL.md +8 -0
  38. package/.agents/skills/wayfinder/SKILL.md +8 -0
  39. package/.agents/skills/wrapup/SKILL.md +8 -0
  40. package/.claude/skills/ask-matt/SKILL.md +8 -0
  41. package/.claude/skills/audit-skills/SKILL.md +8 -0
  42. package/.claude/skills/board-to-waves/SKILL.md +8 -0
  43. package/.claude/skills/census-update/SKILL.md +8 -0
  44. package/.claude/skills/code-review/SKILL.md +8 -0
  45. package/.claude/skills/codebase-design/SKILL.md +8 -0
  46. package/.claude/skills/codex-build/SKILL.md +8 -0
  47. package/.claude/skills/codex-review/SKILL.md +8 -0
  48. package/.claude/skills/decision-gate/SKILL.md +8 -0
  49. package/.claude/skills/diagnose/SKILL.md +8 -0
  50. package/.claude/skills/domain-modeling/SKILL.md +8 -0
  51. package/.claude/skills/git-guardrails-claude-code/SKILL.md +8 -0
  52. package/.claude/skills/git-worktree-recover/SKILL.md +8 -0
  53. package/.claude/skills/grill-me/SKILL.md +8 -0
  54. package/.claude/skills/grill-me-codex/SKILL.md +8 -0
  55. package/.claude/skills/grill-with-docs/SKILL.md +8 -0
  56. package/.claude/skills/grill-with-docs-codex/SKILL.md +8 -0
  57. package/.claude/skills/implement/SKILL.md +8 -0
  58. package/.claude/skills/improve-codebase-architecture/SKILL.md +8 -0
  59. package/.claude/skills/kit-release/SKILL.md +8 -0
  60. package/.claude/skills/kit-update/SKILL.md +18 -4
  61. package/.claude/skills/local-ci/SKILL.md +8 -0
  62. package/.claude/skills/memory-lifecycle/SKILL.md +8 -0
  63. package/.claude/skills/orchestrate-wave/SKILL.md +2 -2
  64. package/.claude/skills/project-release/SKILL.md +8 -0
  65. package/.claude/skills/prototype/SKILL.md +8 -0
  66. package/.claude/skills/research/SKILL.md +8 -0
  67. package/.claude/skills/resolving-merge-conflicts/SKILL.md +8 -0
  68. package/.claude/skills/retro/SKILL.md +8 -0
  69. package/.claude/skills/scale-check/SKILL.md +8 -0
  70. package/.claude/skills/security-audit/SKILL.md +8 -0
  71. package/.claude/skills/setup-pre-commit/SKILL.md +8 -0
  72. package/.claude/skills/setup-workflow/SKILL.md +30 -0
  73. package/.claude/skills/setup-workflow/orchestrate-wave-seed.md +1 -0
  74. package/.claude/skills/setup-workflow/spec-self-critique-seed.md +1 -0
  75. package/.claude/skills/spec-self-critique/SKILL.md +8 -0
  76. package/.claude/skills/tdd/SKILL.md +8 -0
  77. package/.claude/skills/to-issues/SKILL.md +8 -0
  78. package/.claude/skills/to-prd/SKILL.md +8 -0
  79. package/.claude/skills/to-waves/SKILL.md +8 -0
  80. package/.claude/skills/triage/SKILL.md +8 -0
  81. package/.claude/skills/verify-spike/SKILL.md +8 -0
  82. package/.claude/skills/wayfinder/SKILL.md +8 -0
  83. package/.claude/skills/wrapup/SKILL.md +8 -0
  84. package/.claude/skills/write-a-skill/SKILL.md +8 -0
  85. package/README.md +118 -4
  86. package/agent-workflow-kit.package.json +127 -87
  87. package/docs/agents/skills/local-ci.md +1 -0
  88. package/docs/agents/skills/orchestrate-wave.md +1 -1
  89. package/docs/agents/skills/spec-self-critique.md +1 -1
  90. package/package.json +1 -1
  91. package/scripts/project-skill-extension.mjs +37 -0
  92. package/scripts/readiness.mjs +2 -1
  93. package/scripts/test_skill_publish_audit.py +9 -1
  94. package/src/cli.mjs +29 -12
  95. package/src/commands/init.mjs +24 -0
  96. package/src/commands/own.mjs +4 -3
  97. package/src/commands/update.mjs +86 -21
  98. package/src/lib/bundle.mjs +5 -0
  99. package/src/lib/manifest.mjs +13 -2
  100. package/src/lib/ownershipClassifier.mjs +54 -0
  101. package/src/lib/projectSkillExtension.mjs +60 -0
  102. package/src/lib/skillRegistry.mjs +167 -0
  103. package/src/lib/updateCandidate.mjs +105 -4
  104. package/src/lib/updateDecisions.mjs +5 -0
  105. package/src/lib/updateReconcile.mjs +64 -8
  106. package/src/lib/verifyUpdateCandidate.mjs +13 -1
  107. package/src/lib/verifyUpdateCandidateProtocol.mjs +2 -0
  108. package/src/lib/verifyUpdateCandidateTransaction.mjs +9 -3
@@ -0,0 +1,167 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { isDeepStrictEqual } from 'node:util';
4
+
5
+ export const PROJECT_SKILL_REGISTRY_PATH = 'docs/agents/skill-registry.json';
6
+ export const PROJECT_SKILL_REGISTRY_SCHEMA_VERSION = 1;
7
+
8
+ const CORE_ANNOTATION_FIELDS = new Set(['note']);
9
+ const SURFACES = new Set(['claude', 'codex']);
10
+ const SKILL_CLASSES = new Set(['generic', 'vendored', 'adapter', 'project-private']);
11
+
12
+ function isRecord(value) {
13
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
14
+ }
15
+
16
+ function compatibleSubset(prior, current) {
17
+ if (isRecord(prior)) {
18
+ return isRecord(current) && Object.entries(prior).every(
19
+ ([key, value]) => key in current && compatibleSubset(value, current[key]),
20
+ );
21
+ }
22
+ return isDeepStrictEqual(prior, current);
23
+ }
24
+
25
+ function corePart(declaration) {
26
+ return Object.fromEntries(
27
+ Object.entries(declaration).filter(([key]) => !CORE_ANNOTATION_FIELDS.has(key)),
28
+ );
29
+ }
30
+
31
+ function annotations(declaration) {
32
+ return Object.fromEntries(
33
+ Object.entries(declaration).filter(([key]) => CORE_ANNOTATION_FIELDS.has(key)),
34
+ );
35
+ }
36
+
37
+ function validateCore(core, label = 'Kit Core skill registry') {
38
+ if (!isRecord(core) || core.schema_version !== 1
39
+ || !isRecord(core.readiness) || !isRecord(core.readiness.capabilities)
40
+ || !isRecord(core.skills)) {
41
+ throw new Error(`${label} has an unsupported schema`);
42
+ }
43
+ for (const [name, declaration] of Object.entries(core.skills)) {
44
+ for (const capability of [
45
+ ...(declaration?.readiness?.required ?? []),
46
+ ...Object.values(declaration?.readiness?.optionalBlocks ?? {}),
47
+ ]) {
48
+ if (!core.readiness.capabilities[capability]) {
49
+ throw new Error(
50
+ `candidate invariant schema: unknown readiness reference ${name}.${capability}`,
51
+ );
52
+ }
53
+ }
54
+ }
55
+ }
56
+
57
+ export function emptyProjectSkillRegistry(core) {
58
+ validateCore(core);
59
+ return {
60
+ schemaVersion: PROJECT_SKILL_REGISTRY_SCHEMA_VERSION,
61
+ coreSchemaVersion: core.schema_version,
62
+ skills: {},
63
+ annotations: {},
64
+ };
65
+ }
66
+
67
+ function validateProjectSkill(name, declaration, capabilities) {
68
+ if (!isRecord(declaration)
69
+ || !SKILL_CLASSES.has(declaration.class)
70
+ || typeof declaration.publish !== 'boolean'
71
+ || !Array.isArray(declaration.surfaces)
72
+ || declaration.surfaces.some((surface) => !SURFACES.has(surface))
73
+ || new Set(declaration.surfaces).size !== declaration.surfaces.length) {
74
+ throw new Error(`Project skill registry has an invalid local skill: ${name}`);
75
+ }
76
+ const readiness = declaration.readiness ?? {};
77
+ if (!isRecord(readiness)
78
+ || !Array.isArray(readiness.required ?? [])
79
+ || !isRecord(readiness.optionalBlocks ?? {})) {
80
+ throw new Error(`Project skill registry has invalid readiness: ${name}`);
81
+ }
82
+ for (const capability of [
83
+ ...(readiness.required ?? []),
84
+ ...Object.values(readiness.optionalBlocks ?? {}),
85
+ ]) {
86
+ if (!capabilities[capability]) {
87
+ throw new Error(`Project skill registry references unknown capability: ${name}.${capability}`);
88
+ }
89
+ }
90
+ }
91
+
92
+ export function validateProjectSkillRegistry(core, registry) {
93
+ validateCore(core);
94
+ if (!isRecord(registry)
95
+ || registry.schemaVersion !== PROJECT_SKILL_REGISTRY_SCHEMA_VERSION
96
+ || registry.coreSchemaVersion !== core.schema_version
97
+ || !isRecord(registry.skills)
98
+ || !isRecord(registry.annotations)) {
99
+ throw new Error('Project skill registry has an unsupported schema');
100
+ }
101
+ for (const [name, declaration] of Object.entries(registry.skills)) {
102
+ if (core.skills[name]) {
103
+ throw new Error(`Project skill registry collides with Kit Core: ${name}`);
104
+ }
105
+ validateProjectSkill(name, declaration, core.readiness.capabilities ?? {});
106
+ }
107
+ for (const [name, annotation] of Object.entries(registry.annotations)) {
108
+ if (!core.skills[name] || !isRecord(annotation)
109
+ || Object.keys(annotation).some((key) => !CORE_ANNOTATION_FIELDS.has(key))
110
+ || Object.values(annotation).some((value) => typeof value !== 'string' || !value.trim())) {
111
+ throw new Error(`Project skill registry has an invalid Core annotation: ${name}`);
112
+ }
113
+ }
114
+ return registry;
115
+ }
116
+
117
+ export function composeSkillRegistry(core, registry) {
118
+ validateProjectSkillRegistry(core, registry);
119
+ const skills = Object.fromEntries(Object.entries(core.skills).map(([name, declaration]) => [
120
+ name, { ...declaration, ...(registry.annotations[name] ?? {}) },
121
+ ]));
122
+ for (const [name, declaration] of Object.entries(registry.skills)) {
123
+ skills[name] = declaration;
124
+ }
125
+ return { ...core, skills };
126
+ }
127
+
128
+ export function migrateLegacySkillRegistry({ legacyCore, nextCore }) {
129
+ validateCore(legacyCore, 'Legacy mixed skill registry');
130
+ validateCore(nextCore);
131
+ if (!compatibleSubset(legacyCore.readiness, nextCore.readiness)) {
132
+ throw new Error('Legacy mixed skill registry has ambiguous Core readiness changes');
133
+ }
134
+ const skills = {};
135
+ const coreAnnotations = {};
136
+ for (const [name, declaration] of Object.entries(legacyCore.skills)) {
137
+ if (!nextCore.skills[name]) {
138
+ skills[name] = declaration;
139
+ continue;
140
+ }
141
+ if (!compatibleSubset(corePart(declaration), nextCore.skills[name])) {
142
+ throw new Error(`Legacy mixed skill registry has ambiguous Kit Core changes: ${name}`);
143
+ }
144
+ const local = annotations(declaration);
145
+ if (Object.keys(local).length) coreAnnotations[name] = local;
146
+ }
147
+ const registry = {
148
+ schemaVersion: PROJECT_SKILL_REGISTRY_SCHEMA_VERSION,
149
+ coreSchemaVersion: nextCore.schema_version,
150
+ skills,
151
+ annotations: coreAnnotations,
152
+ };
153
+ validateProjectSkillRegistry(nextCore, registry);
154
+ return registry;
155
+ }
156
+
157
+ export async function readComposedSkillRegistry(root, core) {
158
+ core ??= JSON.parse(await readFile(join(root, '.claude/skills/skill-manifest.json'), 'utf8'));
159
+ let registry;
160
+ try {
161
+ registry = JSON.parse(await readFile(join(root, PROJECT_SKILL_REGISTRY_PATH), 'utf8'));
162
+ } catch (error) {
163
+ if (error.code === 'ENOENT') return core;
164
+ throw new Error(`Project skill registry is invalid: ${error.message}`, { cause: error });
165
+ }
166
+ return composeSkillRegistry(core, registry);
167
+ }
@@ -15,6 +15,10 @@ import {
15
15
  CONSUMER_MANIFEST_NAME, CONSUMER_ORIGIN, READINESS_MANIFEST_PATH,
16
16
  filesForInstallRole, indexByPath, readManifest, writeManifest,
17
17
  } from './manifest.mjs';
18
+ import {
19
+ PROJECT_SKILL_REGISTRY_PATH, emptyProjectSkillRegistry, migrateLegacySkillRegistry,
20
+ readComposedSkillRegistry,
21
+ } from './skillRegistry.mjs';
18
22
  import { checkSkill, evaluateCapability, inspectProdSections } from '../../scripts/readiness.mjs';
19
23
 
20
24
  const exists = (path) => access(path).then(() => true, () => false);
@@ -56,6 +60,7 @@ export async function materializeUpdateCandidate({
56
60
  export function candidateInputPaths({ pkg, manifests }) {
57
61
  const candidates = [
58
62
  CONSUMER_MANIFEST_NAME,
63
+ PROJECT_SKILL_REGISTRY_PATH,
59
64
  ...INTEGRATION_INPUTS,
60
65
  ...filesForInstallRole(pkg).map(({ path }) => path),
61
66
  ];
@@ -349,12 +354,18 @@ async function assertConsumerStillMatchesPreview(consumerRoot, preview) {
349
354
  }
350
355
 
351
356
  /** Seed only newly declared, decision-free project-layer stubs in a staged candidate. */
352
- export async function adoptReadinessCandidate({ candidateRoot, consumerRoot, priorManifest, nextManifest }) {
357
+ export async function adoptReadinessCandidate({
358
+ candidateRoot, consumerRoot, kitRoot, priorManifest, nextManifest,
359
+ }) {
353
360
  const priorPaths = readinessStubPaths(priorManifest);
354
361
  const manifestPath = join(candidateRoot, CONSUMER_MANIFEST_NAME);
355
- const manifest = await readManifest(manifestPath);
362
+ let manifest = await readManifest(manifestPath);
363
+ const registry = await adoptProjectSkillRegistry({
364
+ candidateRoot, consumerRoot, kitRoot, manifest, priorManifest, nextManifest,
365
+ });
366
+ manifest = await readManifest(manifestPath);
356
367
  const candidateInstalled = indexByPath(manifest, 'installed');
357
- const generated = [];
368
+ const generated = [...registry.generated];
358
369
  for (const path of readinessStubPaths(nextManifest)) {
359
370
  if (priorPaths.has(path)) continue;
360
371
  if (await exists(join(candidateRoot, path))) {
@@ -377,9 +388,11 @@ export async function adoptReadinessCandidate({ candidateRoot, consumerRoot, pri
377
388
  }
378
389
  await writeManifest(manifestPath, { ...manifest, installed });
379
390
  }
380
- const { migrations, migrationConflicts } = await migrateProdSections({
391
+ const prod = await migrateProdSections({
381
392
  candidateRoot, consumerRoot, nextManifest,
382
393
  });
394
+ const migrations = [...registry.migrations, ...prod.migrations];
395
+ const migrationConflicts = [...registry.migrationConflicts, ...prod.migrationConflicts];
383
396
  const before = await readinessSnapshot(consumerRoot, priorManifest);
384
397
  const after = await readinessSnapshot(candidateRoot, nextManifest);
385
398
  const incompatible = Object.entries(after.skills)
@@ -397,6 +410,94 @@ export async function adoptReadinessCandidate({ candidateRoot, consumerRoot, pri
397
410
  };
398
411
  }
399
412
 
413
+ async function adoptProjectSkillRegistry({
414
+ candidateRoot, consumerRoot, kitRoot, manifest, priorManifest, nextManifest,
415
+ }) {
416
+ if (!nextManifest) {
417
+ return { generated: [], migrations: [], migrationConflicts: [] };
418
+ }
419
+ const tracked = manifest.installed.find(({ path }) => path === READINESS_MANIFEST_PATH);
420
+ if (tracked?.origin !== CONSUMER_ORIGIN) {
421
+ const registryPath = join(candidateRoot, PROJECT_SKILL_REGISTRY_PATH);
422
+ if (!await exists(registryPath)) {
423
+ await writeAtomic(
424
+ registryPath,
425
+ `${JSON.stringify(emptyProjectSkillRegistry(nextManifest), null, 2)}\n`,
426
+ );
427
+ const installedSha256 = await sha256File(registryPath);
428
+ await writeManifest(join(candidateRoot, CONSUMER_MANIFEST_NAME), {
429
+ ...manifest,
430
+ installed: [...manifest.installed, {
431
+ path: PROJECT_SKILL_REGISTRY_PATH,
432
+ kind: 'doc',
433
+ installedSha256,
434
+ origin: CONSUMER_ORIGIN,
435
+ installRole: 'consumer',
436
+ }],
437
+ });
438
+ await readComposedSkillRegistry(candidateRoot, nextManifest);
439
+ return {
440
+ generated: [PROJECT_SKILL_REGISTRY_PATH],
441
+ migrations: [],
442
+ migrationConflicts: [],
443
+ };
444
+ }
445
+ await readComposedSkillRegistry(candidateRoot, nextManifest);
446
+ return { generated: [], migrations: [], migrationConflicts: [] };
447
+ }
448
+ const registryPath = join(candidateRoot, PROJECT_SKILL_REGISTRY_PATH);
449
+ if (await exists(registryPath)) {
450
+ return {
451
+ generated: [],
452
+ migrations: [],
453
+ migrationConflicts: [
454
+ `${PROJECT_SKILL_REGISTRY_PATH}: legacy mixed Core already has a Project registry`,
455
+ ],
456
+ };
457
+ }
458
+ let registry;
459
+ try {
460
+ registry = migrateLegacySkillRegistry({ legacyCore: priorManifest, nextCore: nextManifest });
461
+ } catch (error) {
462
+ return {
463
+ generated: [], migrations: [],
464
+ migrationConflicts: [`${READINESS_MANIFEST_PATH}: ${error.message}`],
465
+ };
466
+ }
467
+ const corePath = join(candidateRoot, READINESS_MANIFEST_PATH);
468
+ await writeAtomic(corePath, await readFile(join(kitRoot, READINESS_MANIFEST_PATH)));
469
+ await writeAtomic(registryPath, `${JSON.stringify(registry, null, 2)}\n`);
470
+ const coreHash = await sha256File(corePath);
471
+ const registryHash = await sha256File(registryPath);
472
+ const installed = manifest.installed.map((entry) => {
473
+ if (entry.path !== READINESS_MANIFEST_PATH) return entry;
474
+ const { ownershipState: _legacyLifecycle, ...coreEntry } = entry;
475
+ return { ...coreEntry, installedSha256: coreHash, origin: 'kit' };
476
+ });
477
+ if (!installed.some(({ path }) => path === PROJECT_SKILL_REGISTRY_PATH)) {
478
+ installed.push({
479
+ path: PROJECT_SKILL_REGISTRY_PATH,
480
+ kind: 'doc',
481
+ installedSha256: registryHash,
482
+ origin: CONSUMER_ORIGIN,
483
+ installRole: 'consumer',
484
+ });
485
+ }
486
+ await writeManifest(join(candidateRoot, CONSUMER_MANIFEST_NAME), {
487
+ ...manifest, installed,
488
+ });
489
+ return {
490
+ generated: [PROJECT_SKILL_REGISTRY_PATH],
491
+ migrations: [{
492
+ path: READINESS_MANIFEST_PATH,
493
+ beforeSha256: await sha256File(join(consumerRoot, READINESS_MANIFEST_PATH)),
494
+ afterSha256: coreHash,
495
+ ownership: 'kit-core',
496
+ }],
497
+ migrationConflicts: [],
498
+ };
499
+ }
500
+
400
501
  async function migrateProdSections({ candidateRoot, consumerRoot, nextManifest }) {
401
502
  const paths = [...new Set(Object.values(nextManifest?.readiness?.capabilities ?? {})
402
503
  .flatMap(({ evidence }) => evidence?.type === 'prod-section' ? evidence.paths ?? [] : []))];
@@ -0,0 +1,5 @@
1
+ export function nonInteractiveUpdateDecision(action) {
2
+ if (action === 'delete') return true;
3
+ if (action === 'collision') return undefined;
4
+ throw new Error(`unknown update decision action: ${action}`);
5
+ }
@@ -4,6 +4,10 @@ import { sha256File } from './hash.mjs';
4
4
  import { lineDiff, writeAtomic } from './atomicWrite.mjs';
5
5
  import { hookReferenced } from './settings.mjs';
6
6
  import { validateConsumerFile } from './consumerPath.mjs';
7
+ import { inspectProjectSkillExtension } from './projectSkillExtension.mjs';
8
+ import {
9
+ OwnershipState, classifyOwnershipEvidence,
10
+ } from './ownershipClassifier.mjs';
7
11
  import {
8
12
  CONSUMER_MANIFEST_NAME, PACKAGE_MANIFEST_NAME, emptyConsumerManifest,
9
13
  CONSUMER_INSTALL_ROLE, CONSUMER_ORIGIN, KIT_ORIGIN, filesForInstallRole,
@@ -32,6 +36,22 @@ export async function reconcile({ kitRoot, consumerRoot, decide = () => false, d
32
36
  const dest = join(consumerRoot, file.path);
33
37
  const prior = installedIdx.get(file.path);
34
38
  if (prior?.origin === CONSUMER_ORIGIN) {
39
+ const classification = classifyOwnershipEvidence({
40
+ path: file.path,
41
+ packageEntry: file,
42
+ installedEntry: prior,
43
+ destinationPresent: await exists(dest),
44
+ projectExtension: prior.ownershipState === OwnershipState.PROJECT_EXTENSION
45
+ ? await projectExtensionEvidence(consumerRoot, file.path) : null,
46
+ });
47
+ result.ownershipStates.push(classification);
48
+ if (classification.state === OwnershipState.AMBIGUOUS_COLLISION) {
49
+ result.conflicts.push({
50
+ path: file.path,
51
+ kind: 'ownership-lifecycle',
52
+ diff: 'Consumer lifecycle metadata does not match its declared path/schema.',
53
+ });
54
+ }
35
55
  nextInstalled.push(withInstallRole(prior));
36
56
  result.consumerOwned.push(file.path);
37
57
  continue;
@@ -39,6 +59,13 @@ export async function reconcile({ kitRoot, consumerRoot, decide = () => false, d
39
59
  const present = await exists(dest);
40
60
  if (!prior && present) {
41
61
  await validateConsumerFile(consumerRoot, file.path);
62
+ const classification = classifyOwnershipEvidence({
63
+ path: file.path,
64
+ packageEntry: file,
65
+ destinationPresent: true,
66
+ projectExtension: await projectExtensionEvidence(consumerRoot, file.path),
67
+ });
68
+ result.ownershipStates.push(classification);
42
69
  const decision = await decide('collision', file.path);
43
70
  if (decision === false || decision === null || decision === undefined) {
44
71
  if (dryRun) {
@@ -47,15 +74,30 @@ export async function reconcile({ kitRoot, consumerRoot, decide = () => false, d
47
74
  }
48
75
  throw new Error(`collision decision required for ${file.path}`);
49
76
  }
50
- if (decision !== 'keep-as-owned' && decision !== 'replace') {
51
- throw new Error(`collision decision for ${file.path} must be keep-as-owned or replace`);
77
+ const allowed = [
78
+ 'keep-as-owned', 'project-extension', 'contribution-bridge', 'explicit-fork', 'replace',
79
+ ];
80
+ if (!allowed.includes(decision)
81
+ || (decision === 'project-extension'
82
+ && classification.state !== OwnershipState.PROJECT_EXTENSION)) {
83
+ throw new Error(
84
+ `collision decision for ${file.path} must select a valid explicit ownership route`,
85
+ );
52
86
  }
53
87
  const destinationSha256 = await sha256File(dest);
54
- result.collisionResolutions.push({
88
+ const resolvedState = decision === 'replace' ? OwnershipState.CLEAN_CORE
89
+ : (decision === 'keep-as-owned' ? OwnershipState.EXPLICIT_FORK : decision);
90
+ const resolution = {
55
91
  path: file.path, outcome: decision, destinationSha256,
56
- });
57
- if (decision === 'keep-as-owned') {
58
- nextInstalled.push(entry(file, destinationSha256, CONSUMER_ORIGIN));
92
+ };
93
+ if (!['replace', 'keep-as-owned'].includes(decision)) {
94
+ resolution.ownershipState = resolvedState;
95
+ }
96
+ result.collisionResolutions.push(resolution);
97
+ if (decision !== 'replace') {
98
+ nextInstalled.push(entry(
99
+ file, destinationSha256, CONSUMER_ORIGIN, resolvedState,
100
+ ));
59
101
  result.consumerOwned.push(file.path);
60
102
  } else {
61
103
  if (!dryRun) await writeAtomic(dest, await readFile(join(kitRoot, file.path)), file.mode);
@@ -128,17 +170,31 @@ export async function reconcile({ kitRoot, consumerRoot, decide = () => false, d
128
170
  function emptyResult() {
129
171
  return {
130
172
  unchanged: [], updated: [], conflicts: [], collisions: [], collisionResolutions: [], userModified: [],
173
+ ownershipStates: [],
131
174
  added: [], deleted: [], keptDeleted: [], consumerOwned: [], manifestChanged: false,
132
175
  };
133
176
  }
134
177
 
135
- function entry(file, installedSha256, origin = KIT_ORIGIN) {
136
- return {
178
+ function entry(file, installedSha256, origin = KIT_ORIGIN, ownershipState) {
179
+ const result = {
137
180
  path: file.path, kind: file.kind, ownerSkill: file.ownerSkill, surface: file.surface,
138
181
  installedSha256, origin, installRole: CONSUMER_INSTALL_ROLE,
139
182
  };
183
+ if (ownershipState) result.ownershipState = ownershipState;
184
+ return result;
140
185
  }
141
186
 
142
187
  function withInstallRole(installed, installRole = CONSUMER_INSTALL_ROLE) {
143
188
  return { ...installed, installRole };
144
189
  }
190
+
191
+ async function projectExtensionEvidence(consumerRoot, path) {
192
+ const match = /^docs\/agents\/skills\/([a-z0-9-]+)\.md$/.exec(path);
193
+ if (!match) return null;
194
+ try {
195
+ const result = await inspectProjectSkillExtension({ root: consumerRoot, skill: match[1] });
196
+ return result.state === 'active' ? result : null;
197
+ } catch (error) {
198
+ return { invalid: error.message };
199
+ }
200
+ }
@@ -189,6 +189,14 @@ function validateInstalledEntry(tracked, desired, origin) {
189
189
  if (tracked.origin === KIT_ORIGIN && tracked.installedSha256 !== desired.sha256) {
190
190
  throw new Error(`candidate invariant ownership: Kit hash identity mismatch ${tracked.path}`);
191
191
  }
192
+ if (tracked.origin === KIT_ORIGIN && tracked.ownershipState !== undefined) {
193
+ throw new Error(`candidate invariant ownership: Kit path has Consumer lifecycle ${tracked.path}`);
194
+ }
195
+ if (tracked.origin === CONSUMER_ORIGIN && ![
196
+ undefined, 'project-extension', 'contribution-bridge', 'explicit-fork',
197
+ ].includes(tracked.ownershipState)) {
198
+ throw new Error(`candidate invariant ownership: invalid lifecycle ${tracked.path}`);
199
+ }
192
200
  }
193
201
 
194
202
  function verifyLedgerMetadata(ledger, context) {
@@ -207,8 +215,12 @@ function verifyLedgerMetadata(ledger, context) {
207
215
  }
208
216
 
209
217
  function expectedOrigin(path, priorInstalled, preview) {
218
+ const transferredToCore = (preview.migrations ?? []).some(
219
+ (migration) => migration.path === path && migration.ownership === 'kit-core',
220
+ );
221
+ if (transferredToCore) return KIT_ORIGIN;
210
222
  const keptOwned = (preview.collisionResolutions ?? []).some(
211
- (resolution) => resolution.path === path && resolution.outcome === 'keep-as-owned',
223
+ (resolution) => resolution.path === path && resolution.outcome !== 'replace',
212
224
  );
213
225
  return keptOwned || (priorInstalled.get(path)?.origin ?? KIT_ORIGIN) === CONSUMER_ORIGIN
214
226
  ? CONSUMER_ORIGIN
@@ -2,6 +2,7 @@ import { readFile } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
3
  import { CONSUMER_INSTALL_ROLE, CONSUMER_ORIGIN, filesForInstallRole } from './manifest.mjs';
4
4
  import { validateCandidateManifestPath } from './updateCandidate.mjs';
5
+ import { readComposedSkillRegistry } from './skillRegistry.mjs';
5
6
 
6
7
  const READINESS_MANIFEST_PATH = '.claude/skills/skill-manifest.json';
7
8
  const SURFACE_ROOT = {
@@ -18,6 +19,7 @@ export async function verifyCandidateProtocol(candidateRoot, pkg, installed) {
18
19
  throw new Error('candidate invariant schema: readiness manifest is invalid JSON');
19
20
  }
20
21
  validateReadinessManifest(manifest);
22
+ await readComposedSkillRegistry(candidateRoot, manifest);
21
23
  validateSkillArtifactReferences(manifest, pkg);
22
24
  for (const [name, declaration] of Object.entries(manifest.skills)) {
23
25
  if (!declaration.publish
@@ -109,15 +109,21 @@ function verifyCollisionRecords(preview, installablePaths, installed, actionSets
109
109
  for (const resolution of preview.collisionResolutions ?? []) {
110
110
  const path = resolution?.path;
111
111
  claimTransactionPath(path, 'collision resolution', resolutions);
112
+ const nonReplace = resolution?.outcome !== 'replace';
113
+ const expectedOwnershipState = resolution?.outcome === 'keep-as-owned'
114
+ ? 'explicit-fork' : resolution?.ownershipState;
112
115
  if (!installablePaths.has(path)
113
- || !['keep-as-owned', 'replace'].includes(resolution?.outcome)
116
+ || ![
117
+ 'keep-as-owned', 'project-extension', 'contribution-bridge', 'explicit-fork', 'replace',
118
+ ].includes(resolution?.outcome)
114
119
  || !HASH.test(resolution?.destinationSha256 ?? '')) {
115
120
  throw new Error(`candidate invariant transaction: invalid collision resolution ${path}`);
116
121
  }
117
- const expectedOrigin = resolution.outcome === 'keep-as-owned' ? CONSUMER_ORIGIN : KIT_ORIGIN;
122
+ const expectedOrigin = nonReplace ? CONSUMER_ORIGIN : KIT_ORIGIN;
118
123
  if (installed.get(path)?.origin !== expectedOrigin
124
+ || (nonReplace && installed.get(path)?.ownershipState !== expectedOwnershipState)
119
125
  || (resolution.outcome === 'replace' && !actionSets.get('added').has(path))
120
- || (resolution.outcome === 'keep-as-owned' && owners.has(path))) {
126
+ || (nonReplace && owners.has(path))) {
121
127
  throw new Error(`candidate invariant transaction: incoherent collision resolution ${path}`);
122
128
  }
123
129
  }