@ikon85/agent-workflow-kit 0.35.0 → 0.36.1

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.
@@ -42,13 +42,15 @@ class RetroEnforcementContract(unittest.TestCase):
42
42
  for phrase in required:
43
43
  self.assertIn(phrase, text)
44
44
 
45
- def test_upstream_or_own_and_exact_sanitized_approval_survive(self):
45
+ def test_capability_route_and_exact_sanitized_approval_survive(self):
46
46
  required = (
47
47
  "generic or project-specific",
48
48
  "recommend `own`",
49
- "sanitized preview",
50
- "explicitly approves that exact text",
51
- "does not need to be the kit maintainer",
49
+ "contribute status <path> --surface=retro",
50
+ "Missing, disabled, invalid, or unverifiable configuration",
51
+ "never infer maintainer status",
52
+ "sanitized exact preview",
53
+ "separate explicit approval",
52
54
  "docs/agents/skills/<skill>.md",
53
55
  )
54
56
  for surface in SURFACES:
package/src/cli.mjs CHANGED
@@ -7,6 +7,12 @@ import { renderUpdateFailure, update } from './commands/update.mjs';
7
7
  import { diff } from './commands/diff.mjs';
8
8
  import { uninstall } from './commands/uninstall.mjs';
9
9
  import { setOwnership } from './commands/own.mjs';
10
+ import {
11
+ beginContributionBridge, prepareContributionArtifact,
12
+ } from './lib/contributionBridge.mjs';
13
+ import {
14
+ inspectContributionRouting,
15
+ } from './lib/contributionRouting.mjs';
10
16
  import { CONSUMER_ORIGIN, KIT_ORIGIN } from './lib/manifest.mjs';
11
17
  import { nonInteractiveUpdateDecision } from './lib/updateDecisions.mjs';
12
18
  import { currentAgentSurface } from './lib/agentSurfaceRegistry.mjs';
@@ -84,19 +90,69 @@ try {
84
90
  if (!ok) { p.cancel('Aborted.'); process.exit(0); }
85
91
  const r = await uninstall({ consumerRoot });
86
92
  p.outro(`removed ${r.removed.length} · retained (edited/referenced) ${r.retained.length}`);
93
+ } else if (cmd === 'contribute') {
94
+ const action = args[1];
95
+ const path = args[2];
96
+ if (!['start', 'status', 'prepare'].includes(action) || !path) {
97
+ throw new Error(
98
+ 'Usage: agent-workflow-kit contribute <start|status|prepare> <path> ' +
99
+ '[--surface=retro|pre-update|guard] ' +
100
+ '[--output=.agent-workflow-kit/contributions/<name>.json]',
101
+ );
102
+ }
103
+ if (action === 'start') {
104
+ await beginContributionBridge({ kitRoot: KIT_ROOT, consumerRoot, path });
105
+ p.outro(`${path} is now a contribution bridge; no remote was changed`);
106
+ } else if (action === 'status') {
107
+ const surface = args.find((arg) => arg.startsWith('--surface='))
108
+ ?.slice('--surface='.length) ?? 'guard';
109
+ const routing = await inspectContributionRouting({
110
+ consumerRoot, path, surface,
111
+ });
112
+ p.note(
113
+ [
114
+ `lifecycle: ${routing.lifecycleState}`,
115
+ `capability: ${routing.capabilityState}`,
116
+ `routes: ${routing.routes.map(({ id }) => id).join(', ')}`,
117
+ ...(routing.diagnostic ? [`diagnostic: ${routing.diagnostic}`] : []),
118
+ ].join('\n'),
119
+ `contribution routing (${routing.surface})`,
120
+ );
121
+ p.outro('read-only routing report; no local or remote state changed');
122
+ } else {
123
+ const output = args.find((arg) => arg.startsWith('--output='))?.slice('--output='.length);
124
+ if (!output) throw new Error('contribute prepare requires --output=<path>');
125
+ const routing = await inspectContributionRouting({
126
+ consumerRoot, path, surface: 'guard',
127
+ });
128
+ if (!routing.routes.some(({ id }) => id === 'prepare-local')) {
129
+ throw new Error(
130
+ `local contribution preparation is unavailable: ${routing.capabilityState}` +
131
+ (routing.diagnostic ? ` (${routing.diagnostic})` : ''),
132
+ );
133
+ }
134
+ await prepareContributionArtifact({
135
+ kitRoot: KIT_ROOT, consumerRoot, path, output,
136
+ });
137
+ p.outro(`prepared local contribution artifact ${output}; no remote was changed`);
138
+ }
87
139
  } else if (cmd === 'own' || cmd === 'disown') {
88
140
  if (!args[1]) {
89
141
  throw new Error(
90
142
  `Usage: agent-workflow-kit ${cmd} <path>` +
91
- (cmd === 'own' ? ' [--as=contribution-bridge|explicit-fork]' : ''),
143
+ (cmd === 'own' ? ' [--as=explicit-fork]' : ''),
92
144
  );
93
145
  }
94
146
  const origin = cmd === 'own' ? CONSUMER_ORIGIN : KIT_ORIGIN;
95
- await setOwnership({ consumerRoot, path: args[1], origin, ownershipState });
147
+ if (origin === CONSUMER_ORIGIN && ownershipState === 'contribution-bridge') {
148
+ await beginContributionBridge({ kitRoot: KIT_ROOT, consumerRoot, path: args[1] });
149
+ } else {
150
+ await setOwnership({ consumerRoot, path: args[1], origin, ownershipState });
151
+ }
96
152
  p.outro(`${args[1]} is now ${origin}-owned` +
97
153
  (origin === CONSUMER_ORIGIN ? ` (${ownershipState ?? 'explicit-fork'})` : ''));
98
154
  } else {
99
- p.note('Usage: agent-workflow-kit <init|update|diff|uninstall|own|disown> ' +
155
+ p.note('Usage: agent-workflow-kit <init|update|diff|uninstall|own|disown|contribute> ' +
100
156
  '[<path>] [--force] [--yes] [--owned] [--as=contribution-bridge|explicit-fork]');
101
157
  p.outro('');
102
158
  }
@@ -110,6 +166,7 @@ function printPlan(r) {
110
166
  for (const k of [
111
167
  'added', 'updated', 'userModified', 'consumerOwned', 'unchanged',
112
168
  'deleted', 'keptDeleted', 'collisions', 'migrated',
169
+ 'bridgeRetired',
113
170
  ])
114
171
  if (r[k]?.length) lines.push(`${k}: ${r[k].length}`);
115
172
  if (r.conflicts?.length) lines.push(`conflicts: ${r.conflicts.length}`);
@@ -3,14 +3,23 @@ import { validateConsumerFile } from '../lib/consumerPath.mjs';
3
3
  import {
4
4
  CONSUMER_MANIFEST_NAME, readManifest, withOrigin, writeManifest,
5
5
  } from '../lib/manifest.mjs';
6
+ import { sha256File } from '../lib/hash.mjs';
6
7
 
7
8
  /** Mark one tracked consumer file as kit- or consumer-owned. */
8
9
  export async function setOwnership({ consumerRoot, path, origin, ownershipState }) {
10
+ if (origin === 'consumer' && ownershipState === 'contribution-bridge') {
11
+ throw new Error('contribution bridge requires `contribute start` with Kit provenance');
12
+ }
9
13
  const manifestPath = join(consumerRoot, CONSUMER_MANIFEST_NAME);
10
14
  const manifest = await readManifest(manifestPath);
11
15
  if (!manifest) throw new Error('not initialised — run `init` first');
12
- const next = withOrigin(manifest, path, origin, ownershipState);
16
+ const validatedOwnership = withOrigin(manifest, path, origin, ownershipState);
13
17
  await validateConsumerFile(consumerRoot, path);
18
+ const installedSha256 = origin === 'consumer'
19
+ ? await sha256File(join(consumerRoot, path)) : undefined;
20
+ const next = installedSha256
21
+ ? withOrigin(manifest, path, origin, ownershipState, installedSha256)
22
+ : validatedOwnership;
14
23
  await writeManifest(manifestPath, next);
15
24
  return { path, origin, ownershipState: origin === 'consumer'
16
25
  ? (ownershipState ?? 'explicit-fork') : undefined };
@@ -260,6 +260,7 @@ function verifyRelease(identities, kitVersion) {
260
260
  function hasUpstreamDelta(result) {
261
261
  return result.manifestChanged ||
262
262
  (result.migrations?.length ?? 0) > 0 ||
263
+ (result.bridgeRetired?.length ?? 0) > 0 ||
263
264
  result.added.length + result.updated.length + result.deleted.length > 0;
264
265
  }
265
266
 
@@ -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
+ }
@@ -95,7 +95,9 @@ export function indexByPath(manifest, key) {
95
95
  }
96
96
 
97
97
  /** Return a manifest with one tracked entry moved to the requested ownership state. */
98
- export function withOrigin(manifest, path, origin, ownershipState = 'explicit-fork') {
98
+ export function withOrigin(
99
+ manifest, path, origin, ownershipState = 'explicit-fork', installedSha256,
100
+ ) {
99
101
  if (![KIT_ORIGIN, CONSUMER_ORIGIN].includes(origin)) {
100
102
  throw new Error(`invalid manifest origin: ${origin}`);
101
103
  }
@@ -109,13 +111,22 @@ export function withOrigin(manifest, path, origin, ownershipState = 'explicit-fo
109
111
  installed: manifest.installed.map((entry) => {
110
112
  if (entry.path !== path) return entry;
111
113
  if (origin === KIT_ORIGIN) {
112
- const { ownershipState: _removed, ...core } = entry;
114
+ const {
115
+ ownershipState: _removed,
116
+ contributionBridge: _removedBridge,
117
+ ...core
118
+ } = entry;
113
119
  return { ...core, origin };
114
120
  }
115
- if (!['contribution-bridge', 'explicit-fork'].includes(ownershipState)) {
121
+ if (ownershipState !== 'explicit-fork') {
116
122
  throw new Error(`invalid consumer ownership state: ${ownershipState}`);
117
123
  }
118
- return { ...entry, origin, ownershipState };
124
+ return {
125
+ ...entry,
126
+ ...(installedSha256 ? { installedSha256 } : {}),
127
+ origin,
128
+ ownershipState,
129
+ };
119
130
  }),
120
131
  };
121
132
  }
@@ -15,6 +15,7 @@ const ROUTES = Object.freeze([
15
15
 
16
16
  export function classifyOwnershipEvidence({
17
17
  path, packageEntry, installedEntry, destinationPresent, projectExtension,
18
+ contributionBridge,
18
19
  }) {
19
20
  const evidence = {
20
21
  packageDeclared: Boolean(packageEntry),
@@ -24,7 +25,13 @@ export function classifyOwnershipEvidence({
24
25
  ? (projectExtension.invalid ? 'invalid' : `schema-v${projectExtension.schemaVersion}`)
25
26
  : 'absent',
26
27
  };
28
+ if (contributionBridge !== undefined) {
29
+ evidence.contributionBridge = contributionBridge
30
+ ? (contributionBridge.invalid ? 'invalid' : `schema-v${contributionBridge.schemaVersion}`)
31
+ : 'absent';
32
+ }
27
33
  if (projectExtension?.invalid) evidence.extensionDiagnostic = projectExtension.invalid;
34
+ if (contributionBridge?.invalid) evidence.bridgeDiagnostic = contributionBridge.invalid;
28
35
  if (installedEntry?.origin === 'kit') {
29
36
  return { path, state: OwnershipState.CLEAN_CORE, evidence, routes: [] };
30
37
  }
@@ -42,6 +49,10 @@ export function classifyOwnershipEvidence({
42
49
  || !projectExtension || projectExtension.invalid)) {
43
50
  return { path, state: OwnershipState.AMBIGUOUS_COLLISION, evidence, routes: [...ROUTES] };
44
51
  }
52
+ if (state === OwnershipState.CONTRIBUTION_BRIDGE
53
+ && (!contributionBridge || contributionBridge.invalid)) {
54
+ return { path, state: OwnershipState.AMBIGUOUS_COLLISION, evidence, routes: [...ROUTES] };
55
+ }
45
56
  return { path, state, evidence, routes: [] };
46
57
  }
47
58
  if (projectExtension && !projectExtension.invalid) {
@@ -1,9 +1,10 @@
1
- import { access, readFile, rm } from 'node:fs/promises';
1
+ import { access, lstat, readFile, rm } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
3
  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 { validateContributionBridge } from './contributionBridge.mjs';
7
8
  import { inspectProjectSkillExtension } from './projectSkillExtension.mjs';
8
9
  import {
9
10
  OwnershipState, classifyOwnershipEvidence,
@@ -36,6 +37,8 @@ export async function reconcile({ kitRoot, consumerRoot, decide = () => false, d
36
37
  const dest = join(consumerRoot, file.path);
37
38
  const prior = installedIdx.get(file.path);
38
39
  if (prior?.origin === CONSUMER_ORIGIN) {
40
+ const bridge = prior.ownershipState === OwnershipState.CONTRIBUTION_BRIDGE
41
+ ? contributionBridgeEvidence(prior) : null;
39
42
  const classification = classifyOwnershipEvidence({
40
43
  path: file.path,
41
44
  packageEntry: file,
@@ -43,6 +46,7 @@ export async function reconcile({ kitRoot, consumerRoot, decide = () => false, d
43
46
  destinationPresent: await exists(dest),
44
47
  projectExtension: prior.ownershipState === OwnershipState.PROJECT_EXTENSION
45
48
  ? await projectExtensionEvidence(consumerRoot, file.path) : null,
49
+ contributionBridge: bridge,
46
50
  });
47
51
  result.ownershipStates.push(classification);
48
52
  if (classification.state === OwnershipState.AMBIGUOUS_COLLISION) {
@@ -52,6 +56,21 @@ export async function reconcile({ kitRoot, consumerRoot, decide = () => false, d
52
56
  diff: 'Consumer lifecycle metadata does not match its declared path/schema.',
53
57
  });
54
58
  }
59
+ if (classification.state === OwnershipState.CONTRIBUTION_BRIDGE) {
60
+ const present = await exists(dest);
61
+ if (present) {
62
+ await validateConsumerFile(consumerRoot, file.path);
63
+ const current = await sha256File(dest);
64
+ if (current === file.sha256 && current === bridge.localSha256) {
65
+ if (!dryRun) {
66
+ await writeAtomic(dest, await readFile(join(kitRoot, file.path)), file.mode);
67
+ }
68
+ nextInstalled.push(entry(file, file.sha256));
69
+ result.bridgeRetired.push(file.path);
70
+ continue;
71
+ }
72
+ }
73
+ }
55
74
  nextInstalled.push(withInstallRole(prior));
56
75
  result.consumerOwned.push(file.path);
57
76
  continue;
@@ -95,8 +114,16 @@ export async function reconcile({ kitRoot, consumerRoot, decide = () => false, d
95
114
  }
96
115
  result.collisionResolutions.push(resolution);
97
116
  if (decision !== 'replace') {
117
+ const lifecycle = resolvedState === OwnershipState.CONTRIBUTION_BRIDGE ? {
118
+ contributionBridge: {
119
+ schemaVersion: 1,
120
+ baseKitVersion: pkg.kitVersion,
121
+ baseSha256: file.sha256,
122
+ localSha256: destinationSha256,
123
+ },
124
+ } : {};
98
125
  nextInstalled.push(entry(
99
- file, destinationSha256, CONSUMER_ORIGIN, resolvedState,
126
+ file, destinationSha256, CONSUMER_ORIGIN, resolvedState, lifecycle,
100
127
  ));
101
128
  result.consumerOwned.push(file.path);
102
129
  } else {
@@ -114,7 +141,9 @@ export async function reconcile({ kitRoot, consumerRoot, decide = () => false, d
114
141
  continue;
115
142
  }
116
143
  const userEdited = current !== prior.installedSha256;
117
- const upstreamChanged = file.sha256 !== prior.installedSha256;
144
+ const currentMode = (await lstat(dest)).mode & 0o777;
145
+ const upstreamChanged = file.sha256 !== prior.installedSha256
146
+ || currentMode !== file.mode;
118
147
  if (!userEdited && upstreamChanged) {
119
148
  if (!dryRun) await writeAtomic(dest, await readFile(join(kitRoot, file.path)), file.mode);
120
149
  nextInstalled.push(entry(file, file.sha256));
@@ -157,6 +186,7 @@ export async function reconcile({ kitRoot, consumerRoot, decide = () => false, d
157
186
  }
158
187
 
159
188
  result.manifestChanged = consumer.installRole !== CONSUMER_INSTALL_ROLE ||
189
+ result.bridgeRetired.length > 0 ||
160
190
  nextInstalled.some((next) => installedIdx.get(next.path)?.installRole !== next.installRole);
161
191
 
162
192
  if (!dryRun) {
@@ -171,14 +201,23 @@ function emptyResult() {
171
201
  return {
172
202
  unchanged: [], updated: [], conflicts: [], collisions: [], collisionResolutions: [], userModified: [],
173
203
  ownershipStates: [],
204
+ bridgeRetired: [],
174
205
  added: [], deleted: [], keptDeleted: [], consumerOwned: [], manifestChanged: false,
175
206
  };
176
207
  }
177
208
 
178
- function entry(file, installedSha256, origin = KIT_ORIGIN, ownershipState) {
209
+ function contributionBridgeEvidence(installed) {
210
+ try {
211
+ return validateContributionBridge(installed);
212
+ } catch (error) {
213
+ return { invalid: error.message };
214
+ }
215
+ }
216
+
217
+ function entry(file, installedSha256, origin = KIT_ORIGIN, ownershipState, lifecycle = {}) {
179
218
  const result = {
180
219
  path: file.path, kind: file.kind, ownerSkill: file.ownerSkill, surface: file.surface,
181
- installedSha256, origin, installRole: CONSUMER_INSTALL_ROLE,
220
+ installedSha256, origin, installRole: CONSUMER_INSTALL_ROLE, ...lifecycle,
182
221
  };
183
222
  if (ownershipState) result.ownershipState = ownershipState;
184
223
  return result;