@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.
- package/.agents/skills/kit-update/SKILL.md +22 -9
- package/.agents/skills/retro/SKILL.md +11 -3
- package/.agents/skills/setup-workflow/SKILL.md +22 -1
- package/.agents/skills/setup-workflow/contribution-routing.md +52 -0
- package/.claude/hooks/kit-origin-edit-hint.py +18 -13
- package/.claude/skills/kit-update/SKILL.md +22 -9
- package/.claude/skills/retro/SKILL.md +11 -3
- package/.claude/skills/setup-workflow/SKILL.md +22 -1
- package/.claude/skills/setup-workflow/contribution-routing.md +52 -0
- package/README.md +63 -4
- package/agent-workflow-kit.package.json +31 -11
- package/package.json +1 -1
- package/scripts/find-by-marker.py +2 -0
- package/scripts/test_marker_lib.py +8 -0
- package/scripts/test_retro_wrapup_contract.py +6 -4
- package/src/cli.mjs +60 -3
- package/src/commands/own.mjs +10 -1
- package/src/commands/update.mjs +1 -0
- package/src/lib/contributionBridge.mjs +176 -0
- package/src/lib/contributionRouting.mjs +152 -0
- package/src/lib/manifest.mjs +15 -4
- package/src/lib/ownershipClassifier.mjs +11 -0
- package/src/lib/updateReconcile.mjs +44 -5
- package/src/lib/verifyUpdateCandidate.mjs +15 -0
- package/src/lib/verifyUpdateCandidateTransaction.mjs +10 -0
|
@@ -42,13 +42,15 @@ class RetroEnforcementContract(unittest.TestCase):
|
|
|
42
42
|
for phrase in required:
|
|
43
43
|
self.assertIn(phrase, text)
|
|
44
44
|
|
|
45
|
-
def
|
|
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
|
-
"
|
|
50
|
-
"
|
|
51
|
-
"
|
|
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=
|
|
143
|
+
(cmd === 'own' ? ' [--as=explicit-fork]' : ''),
|
|
92
144
|
);
|
|
93
145
|
}
|
|
94
146
|
const origin = cmd === 'own' ? CONSUMER_ORIGIN : KIT_ORIGIN;
|
|
95
|
-
|
|
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}`);
|
package/src/commands/own.mjs
CHANGED
|
@@ -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
|
|
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 };
|
package/src/commands/update.mjs
CHANGED
|
@@ -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
|
+
}
|
package/src/lib/manifest.mjs
CHANGED
|
@@ -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(
|
|
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 {
|
|
114
|
+
const {
|
|
115
|
+
ownershipState: _removed,
|
|
116
|
+
contributionBridge: _removedBridge,
|
|
117
|
+
...core
|
|
118
|
+
} = entry;
|
|
113
119
|
return { ...core, origin };
|
|
114
120
|
}
|
|
115
|
-
if (
|
|
121
|
+
if (ownershipState !== 'explicit-fork') {
|
|
116
122
|
throw new Error(`invalid consumer ownership state: ${ownershipState}`);
|
|
117
123
|
}
|
|
118
|
-
return {
|
|
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
|
|
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
|
|
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;
|