@ikon85/agent-workflow-kit 0.34.6 → 0.36.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 (116) 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 +32 -11
  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 +19 -3
  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 +52 -1
  29. package/.agents/skills/setup-workflow/contribution-routing.md +52 -0
  30. package/.agents/skills/setup-workflow/orchestrate-wave-seed.md +1 -0
  31. package/.agents/skills/setup-workflow/spec-self-critique-seed.md +1 -0
  32. package/.agents/skills/spec-self-critique/SKILL.md +8 -0
  33. package/.agents/skills/tdd/SKILL.md +8 -0
  34. package/.agents/skills/to-issues/SKILL.md +8 -0
  35. package/.agents/skills/to-prd/SKILL.md +8 -0
  36. package/.agents/skills/to-waves/SKILL.md +8 -0
  37. package/.agents/skills/triage/SKILL.md +8 -0
  38. package/.agents/skills/verify-spike/SKILL.md +8 -0
  39. package/.agents/skills/wayfinder/SKILL.md +8 -0
  40. package/.agents/skills/wrapup/SKILL.md +8 -0
  41. package/.claude/hooks/kit-origin-edit-hint.py +18 -13
  42. package/.claude/skills/ask-matt/SKILL.md +8 -0
  43. package/.claude/skills/audit-skills/SKILL.md +8 -0
  44. package/.claude/skills/board-to-waves/SKILL.md +8 -0
  45. package/.claude/skills/census-update/SKILL.md +8 -0
  46. package/.claude/skills/code-review/SKILL.md +8 -0
  47. package/.claude/skills/codebase-design/SKILL.md +8 -0
  48. package/.claude/skills/codex-build/SKILL.md +8 -0
  49. package/.claude/skills/codex-review/SKILL.md +8 -0
  50. package/.claude/skills/decision-gate/SKILL.md +8 -0
  51. package/.claude/skills/diagnose/SKILL.md +8 -0
  52. package/.claude/skills/domain-modeling/SKILL.md +8 -0
  53. package/.claude/skills/git-guardrails-claude-code/SKILL.md +8 -0
  54. package/.claude/skills/git-worktree-recover/SKILL.md +8 -0
  55. package/.claude/skills/grill-me/SKILL.md +8 -0
  56. package/.claude/skills/grill-me-codex/SKILL.md +8 -0
  57. package/.claude/skills/grill-with-docs/SKILL.md +8 -0
  58. package/.claude/skills/grill-with-docs-codex/SKILL.md +8 -0
  59. package/.claude/skills/implement/SKILL.md +8 -0
  60. package/.claude/skills/improve-codebase-architecture/SKILL.md +8 -0
  61. package/.claude/skills/kit-release/SKILL.md +8 -0
  62. package/.claude/skills/kit-update/SKILL.md +32 -11
  63. package/.claude/skills/local-ci/SKILL.md +8 -0
  64. package/.claude/skills/memory-lifecycle/SKILL.md +8 -0
  65. package/.claude/skills/orchestrate-wave/SKILL.md +2 -2
  66. package/.claude/skills/project-release/SKILL.md +8 -0
  67. package/.claude/skills/prototype/SKILL.md +8 -0
  68. package/.claude/skills/research/SKILL.md +8 -0
  69. package/.claude/skills/resolving-merge-conflicts/SKILL.md +8 -0
  70. package/.claude/skills/retro/SKILL.md +19 -3
  71. package/.claude/skills/scale-check/SKILL.md +8 -0
  72. package/.claude/skills/security-audit/SKILL.md +8 -0
  73. package/.claude/skills/setup-pre-commit/SKILL.md +8 -0
  74. package/.claude/skills/setup-workflow/SKILL.md +52 -1
  75. package/.claude/skills/setup-workflow/contribution-routing.md +52 -0
  76. package/.claude/skills/setup-workflow/orchestrate-wave-seed.md +1 -0
  77. package/.claude/skills/setup-workflow/spec-self-critique-seed.md +1 -0
  78. package/.claude/skills/spec-self-critique/SKILL.md +8 -0
  79. package/.claude/skills/tdd/SKILL.md +8 -0
  80. package/.claude/skills/to-issues/SKILL.md +8 -0
  81. package/.claude/skills/to-prd/SKILL.md +8 -0
  82. package/.claude/skills/to-waves/SKILL.md +8 -0
  83. package/.claude/skills/triage/SKILL.md +8 -0
  84. package/.claude/skills/verify-spike/SKILL.md +8 -0
  85. package/.claude/skills/wayfinder/SKILL.md +8 -0
  86. package/.claude/skills/wrapup/SKILL.md +8 -0
  87. package/.claude/skills/write-a-skill/SKILL.md +8 -0
  88. package/README.md +172 -5
  89. package/agent-workflow-kit.package.json +149 -89
  90. package/docs/agents/skills/local-ci.md +1 -0
  91. package/docs/agents/skills/orchestrate-wave.md +1 -1
  92. package/docs/agents/skills/spec-self-critique.md +1 -1
  93. package/package.json +1 -1
  94. package/scripts/find-by-marker.py +2 -0
  95. package/scripts/project-skill-extension.mjs +37 -0
  96. package/scripts/readiness.mjs +2 -1
  97. package/scripts/test_marker_lib.py +8 -0
  98. package/scripts/test_retro_wrapup_contract.py +6 -4
  99. package/scripts/test_skill_publish_audit.py +9 -1
  100. package/src/cli.mjs +86 -12
  101. package/src/commands/init.mjs +24 -0
  102. package/src/commands/own.mjs +7 -3
  103. package/src/commands/update.mjs +87 -21
  104. package/src/lib/bundle.mjs +5 -0
  105. package/src/lib/contributionBridge.mjs +176 -0
  106. package/src/lib/contributionRouting.mjs +152 -0
  107. package/src/lib/manifest.mjs +17 -2
  108. package/src/lib/ownershipClassifier.mjs +65 -0
  109. package/src/lib/projectSkillExtension.mjs +60 -0
  110. package/src/lib/skillRegistry.mjs +167 -0
  111. package/src/lib/updateCandidate.mjs +105 -4
  112. package/src/lib/updateDecisions.mjs +5 -0
  113. package/src/lib/updateReconcile.mjs +102 -9
  114. package/src/lib/verifyUpdateCandidate.mjs +28 -1
  115. package/src/lib/verifyUpdateCandidateProtocol.mjs +2 -0
  116. package/src/lib/verifyUpdateCandidateTransaction.mjs +19 -3
@@ -0,0 +1,176 @@
1
+ import {
2
+ lstat, readFile,
3
+ } from 'node:fs/promises';
4
+ import {
5
+ isAbsolute, join, normalize, resolve,
6
+ } from 'node:path';
7
+ import { lineDiff, writeAtomic } from './atomicWrite.mjs';
8
+ import { validateConsumerFile } from './consumerPath.mjs';
9
+ import { sha256File } from './hash.mjs';
10
+ import {
11
+ CONSUMER_MANIFEST_NAME, CONSUMER_ORIGIN, KIT_ORIGIN, PACKAGE_MANIFEST_NAME,
12
+ readManifest, writeManifest,
13
+ } from './manifest.mjs';
14
+
15
+ export const CONTRIBUTION_BRIDGE_SCHEMA_VERSION = 1;
16
+ export const CONTRIBUTION_ARTIFACT_KIND = 'agent-workflow-kit/contribution';
17
+ const HASH = /^[a-f0-9]{64}$/;
18
+ const OUTPUT = /^\.agent-workflow-kit\/contributions\/[a-zA-Z0-9._-]+\.json$/;
19
+
20
+ export function validateContributionBridge(entry) {
21
+ const bridge = entry?.contributionBridge;
22
+ if (entry?.origin !== CONSUMER_ORIGIN
23
+ || entry?.ownershipState !== 'contribution-bridge'
24
+ || !bridge
25
+ || bridge.schemaVersion !== CONTRIBUTION_BRIDGE_SCHEMA_VERSION
26
+ || typeof bridge.baseKitVersion !== 'string'
27
+ || !bridge.baseKitVersion
28
+ || !HASH.test(bridge.baseSha256 ?? '')
29
+ || !HASH.test(bridge.localSha256 ?? '')
30
+ || bridge.baseSha256 === bridge.localSha256
31
+ || entry.installedSha256 !== bridge.localSha256) {
32
+ throw new Error(`invalid contribution bridge metadata: ${entry?.path ?? 'unknown path'}`);
33
+ }
34
+ return bridge;
35
+ }
36
+
37
+ export async function beginContributionBridge({ kitRoot, consumerRoot, path }) {
38
+ const { pkg, manifest, desired, tracked } = await loadCoreIdentity({
39
+ kitRoot, consumerRoot, path,
40
+ });
41
+ if (tracked.origin !== KIT_ORIGIN || tracked.ownershipState !== undefined) {
42
+ throw new Error(`contribution bridge requires clean Kit ownership: ${path}`);
43
+ }
44
+ if (manifest.kitVersion !== pkg.kitVersion
45
+ || tracked.installedSha256 !== desired.sha256
46
+ || await sha256File(join(kitRoot, path)) !== desired.sha256) {
47
+ throw new Error(`stale upstream base for contribution bridge: ${path}`);
48
+ }
49
+ const localSha256 = await sha256File(await validateConsumerFile(consumerRoot, path));
50
+ if (localSha256 === desired.sha256) {
51
+ throw new Error(`contribution bridge has no local Core change: ${path}`);
52
+ }
53
+ const bridge = {
54
+ schemaVersion: CONTRIBUTION_BRIDGE_SCHEMA_VERSION,
55
+ baseKitVersion: pkg.kitVersion,
56
+ baseSha256: desired.sha256,
57
+ localSha256,
58
+ };
59
+ const next = {
60
+ ...manifest,
61
+ installed: manifest.installed.map((entry) => entry.path === path ? {
62
+ ...entry,
63
+ installedSha256: localSha256,
64
+ origin: CONSUMER_ORIGIN,
65
+ ownershipState: 'contribution-bridge',
66
+ contributionBridge: bridge,
67
+ } : entry),
68
+ };
69
+ await writeManifest(join(consumerRoot, CONSUMER_MANIFEST_NAME), next);
70
+ return { path, state: 'contribution-bridge', bridge };
71
+ }
72
+
73
+ export async function prepareContributionArtifact({
74
+ kitRoot, consumerRoot, path, output,
75
+ }) {
76
+ const { pkg, desired, tracked } = await loadCoreIdentity({
77
+ kitRoot, consumerRoot, path,
78
+ });
79
+ const bridge = validateContributionBridge(tracked);
80
+ if (pkg.kitVersion !== bridge.baseKitVersion
81
+ || desired.sha256 !== bridge.baseSha256
82
+ || await sha256File(join(kitRoot, path)) !== bridge.baseSha256) {
83
+ throw new Error(`stale upstream base for contribution bridge: ${path}`);
84
+ }
85
+ const localPath = await validateConsumerFile(consumerRoot, path);
86
+ if (await sha256File(localPath) !== bridge.localSha256) {
87
+ throw new Error(`contribution bridge changed after classification: ${path}`);
88
+ }
89
+ const base = await readFile(join(kitRoot, path));
90
+ const local = await readFile(localPath);
91
+ const baseText = strictUtf8(base, path);
92
+ const localText = strictUtf8(local, path);
93
+ const artifact = {
94
+ schemaVersion: CONTRIBUTION_BRIDGE_SCHEMA_VERSION,
95
+ kind: CONTRIBUTION_ARTIFACT_KIND,
96
+ coreIdentity: {
97
+ path,
98
+ baseKitVersion: bridge.baseKitVersion,
99
+ baseSha256: bridge.baseSha256,
100
+ localSha256: bridge.localSha256,
101
+ },
102
+ diff: {
103
+ format: 'line-diff-v1',
104
+ text: lineDiff(baseText, localText),
105
+ },
106
+ };
107
+ const destination = await validateArtifactOutput(consumerRoot, output);
108
+ const content = `${JSON.stringify(artifact, null, 2)}\n`;
109
+ try {
110
+ const existing = await readFile(destination, 'utf8');
111
+ if (existing !== content) {
112
+ throw new Error(`contribution artifact already exists with different content: ${output}`);
113
+ }
114
+ } catch (error) {
115
+ if (error.code !== 'ENOENT') throw error;
116
+ await writeAtomic(destination, content, 0o600);
117
+ }
118
+ return { output, artifact };
119
+ }
120
+
121
+ async function loadCoreIdentity({ kitRoot, consumerRoot, path }) {
122
+ const pkg = await readManifest(join(kitRoot, PACKAGE_MANIFEST_NAME));
123
+ const manifest = await readManifest(join(consumerRoot, CONSUMER_MANIFEST_NAME));
124
+ if (!pkg) throw new Error('kit package manifest not found');
125
+ if (!manifest) throw new Error('not initialised — run `init` first');
126
+ const desired = pkg.files?.find((entry) => (
127
+ entry.path === path && (entry.installRole ?? 'consumer') === 'consumer'
128
+ ));
129
+ if (!desired || (desired.origin ?? KIT_ORIGIN) !== KIT_ORIGIN) {
130
+ throw new Error(`not declared Kit Core: ${path}`);
131
+ }
132
+ const tracked = manifest.installed?.find((entry) => entry.path === path);
133
+ if (!tracked) throw new Error(`not declared Kit Core in Consumer ledger: ${path}`);
134
+ return { pkg, manifest, desired, tracked };
135
+ }
136
+
137
+ function strictUtf8(bytes, path) {
138
+ const text = bytes.toString('utf8');
139
+ if (!Buffer.from(text).equals(bytes)) {
140
+ throw new Error(`binary Kit Core requires an Explicit fork: ${path}`);
141
+ }
142
+ return text;
143
+ }
144
+
145
+ async function validateArtifactOutput(consumerRoot, output) {
146
+ if (typeof output !== 'string'
147
+ || isAbsolute(output)
148
+ || normalize(output) !== output
149
+ || !OUTPUT.test(output)) {
150
+ throw new Error(`unsafe contribution artifact path: ${output}`);
151
+ }
152
+ const root = resolve(consumerRoot);
153
+ let current = root;
154
+ for (const segment of output.split('/').slice(0, -1)) {
155
+ current = join(current, segment);
156
+ try {
157
+ const entry = await lstat(current);
158
+ if (entry.isSymbolicLink() || !entry.isDirectory()) {
159
+ throw new Error(`unsafe contribution artifact path: ${output}`);
160
+ }
161
+ } catch (error) {
162
+ if (error.code === 'ENOENT') break;
163
+ throw error;
164
+ }
165
+ }
166
+ const destination = join(root, output);
167
+ try {
168
+ const entry = await lstat(destination);
169
+ if (entry.isSymbolicLink() || !entry.isFile()) {
170
+ throw new Error(`unsafe contribution artifact path: ${output}`);
171
+ }
172
+ } catch (error) {
173
+ if (error.code !== 'ENOENT') throw error;
174
+ }
175
+ return destination;
176
+ }
@@ -0,0 +1,152 @@
1
+ import { execFile as execFileCallback } from 'node:child_process';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { promisify } from 'node:util';
5
+ import { validateContributionBridge } from './contributionBridge.mjs';
6
+ import {
7
+ CONSUMER_MANIFEST_NAME, readManifest,
8
+ } from './manifest.mjs';
9
+
10
+ export const CONTRIBUTION_CAPABILITY_PATH = 'docs/agents/workflow-capabilities.json';
11
+ export const CONTRIBUTION_ROUTING_SCHEMA_VERSION = 1;
12
+ const SURFACES = new Set(['retro', 'pre-update', 'guard']);
13
+ const REPOSITORY = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
14
+ const REMOTE = /^[A-Za-z0-9._-]+$/;
15
+ const execFile = promisify(execFileCallback);
16
+
17
+ const GENERIC_ROUTES = Object.freeze([
18
+ Object.freeze({ id: 'preserve', remoteMutation: false }),
19
+ Object.freeze({ id: 'explicit-fork', remoteMutation: false }),
20
+ ]);
21
+
22
+ export async function inspectContributionRouting({
23
+ consumerRoot, path, surface, resolveRemote = defaultResolveRemote(consumerRoot),
24
+ }) {
25
+ if (!SURFACES.has(surface)) {
26
+ throw new Error(`unknown contribution routing surface: ${surface}`);
27
+ }
28
+ const manifest = await readManifest(join(consumerRoot, CONSUMER_MANIFEST_NAME));
29
+ const tracked = manifest?.installed?.find((entry) => entry.path === path);
30
+ const bridge = validateContributionBridge(tracked);
31
+ const lifecycle = {
32
+ surface,
33
+ path,
34
+ lifecycleState: 'contribution-bridge',
35
+ baseKitVersion: bridge.baseKitVersion,
36
+ routes: GENERIC_ROUTES.map((route) => ({ ...route })),
37
+ };
38
+ const loaded = await readCapability(consumerRoot);
39
+ if (loaded.state !== 'present') {
40
+ return {
41
+ ...lifecycle,
42
+ capabilityState: loaded.state,
43
+ ...(loaded.diagnostic ? { diagnostic: loaded.diagnostic } : {}),
44
+ };
45
+ }
46
+ const validated = validateCapability(loaded.value);
47
+ if (!validated.ok) {
48
+ return {
49
+ ...lifecycle,
50
+ capabilityState: 'invalid',
51
+ diagnostic: validated.diagnostic,
52
+ };
53
+ }
54
+ if (!validated.enabled) {
55
+ return { ...lifecycle, capabilityState: 'disabled' };
56
+ }
57
+ let remoteUrl;
58
+ try {
59
+ remoteUrl = await resolveRemote(validated.remote);
60
+ } catch {
61
+ return {
62
+ ...lifecycle,
63
+ capabilityState: 'invalid',
64
+ diagnostic: 'configured contribution remote is not verifiable',
65
+ };
66
+ }
67
+ if (repositoryFromRemote(remoteUrl) !== validated.repository.toLowerCase()) {
68
+ return {
69
+ ...lifecycle,
70
+ capabilityState: 'invalid',
71
+ diagnostic: 'configured contribution remote does not match required upstream',
72
+ };
73
+ }
74
+ return {
75
+ ...lifecycle,
76
+ capabilityState: 'ready',
77
+ repository: validated.repository,
78
+ routes: [
79
+ ...lifecycle.routes,
80
+ { id: 'prepare-local', remoteMutation: false },
81
+ {
82
+ id: 'upstream-pull-request',
83
+ remoteMutation: true,
84
+ requiresExplicitApproval: true,
85
+ },
86
+ ],
87
+ };
88
+ }
89
+
90
+ async function readCapability(consumerRoot) {
91
+ try {
92
+ const raw = await readFile(join(consumerRoot, CONTRIBUTION_CAPABILITY_PATH), 'utf8');
93
+ const profile = JSON.parse(raw);
94
+ if (!profile || typeof profile !== 'object' || Array.isArray(profile)) {
95
+ return { state: 'invalid', diagnostic: 'workflow capability profile must be an object' };
96
+ }
97
+ if (profile.contributionRouting === undefined) return { state: 'missing' };
98
+ return { state: 'present', value: profile.contributionRouting };
99
+ } catch (error) {
100
+ if (error.code === 'ENOENT') return { state: 'missing' };
101
+ return { state: 'invalid', diagnostic: 'workflow capability profile is unreadable or invalid' };
102
+ }
103
+ }
104
+
105
+ function validateCapability(value) {
106
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
107
+ return invalid('contribution routing capability must be an object');
108
+ }
109
+ if (value.schemaVersion !== CONTRIBUTION_ROUTING_SCHEMA_VERSION) {
110
+ return invalid('unsupported contribution routing capability schema');
111
+ }
112
+ if (typeof value.enabled !== 'boolean') {
113
+ return invalid('contribution routing enabled must be boolean');
114
+ }
115
+ if (!value.enabled) return { ok: true, enabled: false };
116
+ const repository = value.upstream?.repository;
117
+ const remote = value.upstream?.remote;
118
+ if (!REPOSITORY.test(repository ?? '') || !REMOTE.test(remote ?? '')) {
119
+ return invalid('contribution routing requires an explicit upstream repository and remote');
120
+ }
121
+ if (value.workflows?.prepareLocal !== true) {
122
+ return invalid('enabled contribution routing requires local preparation');
123
+ }
124
+ const pullRequest = value.workflows?.upstreamPullRequest;
125
+ if (pullRequest?.enabled !== true || pullRequest.requiresExplicitApproval !== true) {
126
+ return invalid('upstream pull request route requires explicit approval');
127
+ }
128
+ return { ok: true, enabled: true, repository, remote };
129
+ }
130
+
131
+ function invalid(diagnostic) {
132
+ return { ok: false, diagnostic };
133
+ }
134
+
135
+ function repositoryFromRemote(value) {
136
+ if (typeof value !== 'string') return null;
137
+ const trimmed = value.trim().replace(/\.git$/, '');
138
+ const match = trimmed.match(
139
+ /^(?:git@github\.com:|https?:\/\/github\.com\/|ssh:\/\/git@github\.com\/)([^/]+\/[^/]+)$/,
140
+ );
141
+ return match?.[1]?.toLowerCase() ?? null;
142
+ }
143
+
144
+ function defaultResolveRemote(consumerRoot) {
145
+ return async (remote) => {
146
+ const { stdout } = await execFile(
147
+ 'git', ['-C', consumerRoot, 'remote', 'get-url', remote],
148
+ { timeout: 5000, maxBuffer: 64 * 1024 },
149
+ );
150
+ return stdout.trim();
151
+ };
152
+ }
@@ -18,6 +18,7 @@ export const KIT_ORIGIN = 'kit';
18
18
  export const CONSUMER_ORIGIN = 'consumer';
19
19
  export const READINESS_CONTRACT_VERSION = 1;
20
20
  export const READINESS_MANIFEST_PATH = '.claude/skills/skill-manifest.json';
21
+ export { PROJECT_SKILL_REGISTRY_PATH } from './skillRegistry.mjs';
21
22
 
22
23
  /**
23
24
  * Parse a JSON manifest, or null if the file does not exist. A corrupt file
@@ -94,7 +95,7 @@ export function indexByPath(manifest, key) {
94
95
  }
95
96
 
96
97
  /** Return a manifest with one tracked entry moved to the requested ownership state. */
97
- export function withOrigin(manifest, path, origin) {
98
+ export function withOrigin(manifest, path, origin, ownershipState = 'explicit-fork') {
98
99
  if (![KIT_ORIGIN, CONSUMER_ORIGIN].includes(origin)) {
99
100
  throw new Error(`invalid manifest origin: ${origin}`);
100
101
  }
@@ -105,6 +106,20 @@ export function withOrigin(manifest, path, origin) {
105
106
  }
106
107
  return {
107
108
  ...manifest,
108
- installed: manifest.installed.map((entry) => entry.path === path ? { ...entry, origin } : entry),
109
+ installed: manifest.installed.map((entry) => {
110
+ if (entry.path !== path) return entry;
111
+ if (origin === KIT_ORIGIN) {
112
+ const {
113
+ ownershipState: _removed,
114
+ contributionBridge: _removedBridge,
115
+ ...core
116
+ } = entry;
117
+ return { ...core, origin };
118
+ }
119
+ if (ownershipState !== 'explicit-fork') {
120
+ throw new Error(`invalid consumer ownership state: ${ownershipState}`);
121
+ }
122
+ return { ...entry, origin, ownershipState };
123
+ }),
109
124
  };
110
125
  }
@@ -0,0 +1,65 @@
1
+ export const OwnershipState = Object.freeze({
2
+ CLEAN_CORE: 'clean-core',
3
+ PROJECT_EXTENSION: 'project-extension',
4
+ CONTRIBUTION_BRIDGE: 'contribution-bridge',
5
+ EXPLICIT_FORK: 'explicit-fork',
6
+ AMBIGUOUS_COLLISION: 'ambiguous-collision',
7
+ });
8
+
9
+ const ROUTES = Object.freeze([
10
+ { id: 'project-extension', action: 'move Project data to docs/agents/skills/<skill>.md' },
11
+ { id: 'contribution-bridge', action: 'register a temporary contribution-bridge' },
12
+ { id: 'explicit-fork', action: 'register an explicit-fork with its own update line' },
13
+ { id: 'clean-core', action: 'explicitly replace the destination with Kit Core' },
14
+ ]);
15
+
16
+ export function classifyOwnershipEvidence({
17
+ path, packageEntry, installedEntry, destinationPresent, projectExtension,
18
+ contributionBridge,
19
+ }) {
20
+ const evidence = {
21
+ packageDeclared: Boolean(packageEntry),
22
+ ledgerOrigin: installedEntry?.origin ?? 'absent',
23
+ destination: destinationPresent ? 'present' : 'absent',
24
+ projectExtension: projectExtension
25
+ ? (projectExtension.invalid ? 'invalid' : `schema-v${projectExtension.schemaVersion}`)
26
+ : 'absent',
27
+ };
28
+ if (contributionBridge !== undefined) {
29
+ evidence.contributionBridge = contributionBridge
30
+ ? (contributionBridge.invalid ? 'invalid' : `schema-v${contributionBridge.schemaVersion}`)
31
+ : 'absent';
32
+ }
33
+ if (projectExtension?.invalid) evidence.extensionDiagnostic = projectExtension.invalid;
34
+ if (contributionBridge?.invalid) evidence.bridgeDiagnostic = contributionBridge.invalid;
35
+ if (installedEntry?.origin === 'kit') {
36
+ return { path, state: OwnershipState.CLEAN_CORE, evidence, routes: [] };
37
+ }
38
+ if (installedEntry?.origin === 'consumer') {
39
+ const state = installedEntry.ownershipState ?? OwnershipState.EXPLICIT_FORK;
40
+ if (![
41
+ OwnershipState.PROJECT_EXTENSION,
42
+ OwnershipState.CONTRIBUTION_BRIDGE,
43
+ OwnershipState.EXPLICIT_FORK,
44
+ ].includes(state)) {
45
+ return { path, state: OwnershipState.AMBIGUOUS_COLLISION, evidence, routes: [...ROUTES] };
46
+ }
47
+ if (state === OwnershipState.PROJECT_EXTENSION
48
+ && (!/^docs\/agents\/skills\/[a-z0-9-]+\.md$/.test(path)
49
+ || !projectExtension || projectExtension.invalid)) {
50
+ return { path, state: OwnershipState.AMBIGUOUS_COLLISION, evidence, routes: [...ROUTES] };
51
+ }
52
+ if (state === OwnershipState.CONTRIBUTION_BRIDGE
53
+ && (!contributionBridge || contributionBridge.invalid)) {
54
+ return { path, state: OwnershipState.AMBIGUOUS_COLLISION, evidence, routes: [...ROUTES] };
55
+ }
56
+ return { path, state, evidence, routes: [] };
57
+ }
58
+ if (projectExtension && !projectExtension.invalid) {
59
+ return { path, state: OwnershipState.PROJECT_EXTENSION, evidence, routes: [
60
+ { id: 'project-extension', action: 'keep the declared Project extension' },
61
+ { id: 'clean-core', action: 'explicitly replace the destination with Kit Core' },
62
+ ] };
63
+ }
64
+ return { path, state: OwnershipState.AMBIGUOUS_COLLISION, evidence, routes: [...ROUTES] };
65
+ }
@@ -0,0 +1,60 @@
1
+ import { lstat, readFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { firstLineState } from './sentinel.mjs';
4
+
5
+ const SKILL_NAME = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
6
+ const MARKER_PREFIX = '<!-- agent-workflow-kit: project-extension/';
7
+ const MARKER = /^<!-- agent-workflow-kit: project-extension\/([^;]+); skill=([a-z0-9-]+) -->$/;
8
+
9
+ export function projectSkillExtensionPath(skill) {
10
+ if (typeof skill !== 'string' || !SKILL_NAME.test(skill)) {
11
+ throw new Error(`invalid Project extension skill identity: ${skill}`);
12
+ }
13
+ return `docs/agents/skills/${skill}.md`;
14
+ }
15
+
16
+ export async function inspectProjectSkillExtension({ root, skill }) {
17
+ const path = projectSkillExtensionPath(skill);
18
+ const absolute = join(root, path);
19
+ let state;
20
+ try {
21
+ state = await lstat(absolute);
22
+ } catch (error) {
23
+ if (error.code === 'ENOENT') return { state: 'inactive', reason: 'absent' };
24
+ throw error;
25
+ }
26
+ if (!state.isFile()) {
27
+ throw new Error(`Project extension is not a regular file: ${path}`);
28
+ }
29
+ const body = await readFile(absolute, 'utf8');
30
+ const setupState = firstLineState(body);
31
+ if (setupState === 'stub' || setupState === 'not-applicable') {
32
+ return { state: 'inactive', reason: setupState };
33
+ }
34
+ const lines = body.split('\n').map((line) => line.trim());
35
+ const markerLine = lines.find((line) => line.startsWith(MARKER_PREFIX));
36
+ if (!markerLine) {
37
+ if (!body.trim()) return { state: 'inactive', reason: 'empty' };
38
+ return { state: 'active', schemaVersion: 0, path };
39
+ }
40
+ const match = MARKER.exec(markerLine);
41
+ if (!match || match[1] !== 'v1') {
42
+ throw new Error(
43
+ `Project extension has unsupported schema at ${path}; ` +
44
+ `expected project-extension/v1 for skill=${skill}`,
45
+ );
46
+ }
47
+ if (match[2] !== skill) {
48
+ throw new Error(
49
+ `Project extension identity mismatch at ${path}; expected skill=${skill}, ` +
50
+ `found skill=${match[2]}`,
51
+ );
52
+ }
53
+ const content = lines.filter(
54
+ (line) => line && line !== markerLine && !line.startsWith('<!-- setup-workflow:'),
55
+ );
56
+ if (!content.length) {
57
+ throw new Error(`Project extension has no instructions after its v1 marker: ${path}`);
58
+ }
59
+ return { state: 'active', schemaVersion: 1, path };
60
+ }
@@ -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
+ }