@ikon85/agent-workflow-kit 0.36.5 → 0.38.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.
- package/.agents/skills/orchestrate-wave/SKILL.md +20 -12
- package/.agents/skills/setup-workflow/SKILL.md +24 -3
- package/.agents/skills/setup-workflow/orchestrate-wave-seed.md +3 -2
- package/.agents/skills/setup-workflow/worktree-lifecycle.md +29 -1
- package/.agents/skills/wrapup/SKILL.md +42 -9
- package/.claude/hooks/migration-snapshot-reminder.py +1 -1
- package/.claude/skills/orchestrate-wave/SKILL.md +11 -11
- package/.claude/skills/setup-workflow/SKILL.md +24 -3
- package/.claude/skills/setup-workflow/orchestrate-wave-seed.md +3 -2
- package/.claude/skills/setup-workflow/worktree-lifecycle.md +29 -1
- package/.claude/skills/skill-manifest.json +1 -1
- package/.claude/skills/wrapup/SKILL.md +33 -10
- package/README.md +88 -1
- package/agent-workflow-kit.package.json +36 -28
- package/docs/adr/0007-session-teardown-requires-provenance-bound-ownership.md +88 -0
- package/docs/agents/workflow-capabilities.json +11 -0
- package/package.json +1 -1
- package/scripts/anchor_table.py +14 -8
- package/scripts/project-skill-extension.mjs +21 -2
- package/scripts/readiness.mjs +32 -4
- package/scripts/release-state.mjs +19 -9
- package/scripts/release-state.test.mjs +71 -8
- package/scripts/test_anchor_table.py +69 -0
- package/scripts/test_board_sync_create_idempotency.py +15 -2
- package/scripts/test_board_sync_wave_title.py +14 -2
- package/scripts/test_census_backstop.py +33 -0
- package/scripts/test_orchestrate_wave_contract.py +44 -0
- package/scripts/test_retro_wrapup_contract.py +19 -2
- package/scripts/test_worktree_wrapup_contract.py +1004 -3
- package/scripts/test_wrapup_land.py +428 -0
- package/scripts/workflow-advisories/core.py +44 -2
- package/scripts/worktree-lifecycle/README.md +126 -4
- package/scripts/worktree-lifecycle/capabilities.json +9 -1
- package/scripts/worktree-lifecycle/cleanup.py +143 -5
- package/scripts/worktree-lifecycle/core.py +1383 -20
- package/scripts/worktree-lifecycle/profile.py +40 -3
- package/scripts/worktree-lifecycle/session.py +1857 -0
- package/scripts/worktree-lifecycle/setup.py +15 -0
- package/scripts/wrapup-land.py +650 -27
- package/src/cli.mjs +60 -27
- package/src/lib/bundle.mjs +1 -0
- package/src/lib/manifest.mjs +173 -3
- package/src/lib/projectSkillExtension.mjs +78 -1
- package/src/lib/updateCandidate.mjs +3 -1
- package/src/lib/updateDecisions.mjs +2 -2
- package/src/lib/verifyUpdateCandidateProtocol.mjs +15 -0
package/src/cli.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { realpathSync } from 'node:fs';
|
|
2
3
|
import { fileURLToPath } from 'node:url';
|
|
3
4
|
import { dirname, resolve } from 'node:path';
|
|
4
5
|
import * as p from '@clack/prompts';
|
|
@@ -20,29 +21,51 @@ import { createCommandAdapter } from '../scripts/release-state.mjs';
|
|
|
20
21
|
import { installedIdentityFromDir } from '../scripts/release-parity.mjs';
|
|
21
22
|
|
|
22
23
|
const KIT_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
23
|
-
const consumerRoot = process.cwd();
|
|
24
24
|
|
|
25
25
|
function stamp() {
|
|
26
26
|
// backup suffix: YYYYMMDDTHHMMSS (no separators that collide with shells)
|
|
27
27
|
return new Date().toISOString().replace(/[-:]/g, '').replace(/\..+/, '');
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
30
|
+
export async function runCli({
|
|
31
|
+
argv = process.argv.slice(2),
|
|
32
|
+
kitRoot = KIT_ROOT,
|
|
33
|
+
consumerRoot = process.cwd(),
|
|
34
|
+
hasTTY = Boolean(process.stdin.isTTY && process.stdout.isTTY),
|
|
35
|
+
readUpdateRelease: releaseReader = readUpdateRelease,
|
|
36
|
+
updateCommand = update,
|
|
37
|
+
} = {}) {
|
|
38
|
+
const args = argv;
|
|
39
|
+
const cmd = args[0];
|
|
40
|
+
const force = args.includes('--force');
|
|
41
|
+
const yes = args.includes('--yes') || args.includes('-y');
|
|
42
|
+
const owned = args.includes('--owned');
|
|
43
|
+
const keepDeleted = args.includes('--keep-deleted');
|
|
44
|
+
const restoreDeleted = args.includes('--restore-deleted');
|
|
45
|
+
const ownershipState = args.find((arg) => arg.startsWith('--as='))?.slice('--as='.length);
|
|
46
|
+
let exitCode = 0;
|
|
36
47
|
|
|
37
|
-
|
|
48
|
+
if (cmd === 'update' && keepDeleted && restoreDeleted) {
|
|
49
|
+
process.stderr.write('Error: --keep-deleted and --restore-deleted are mutually exclusive.\n');
|
|
50
|
+
return 1;
|
|
51
|
+
}
|
|
52
|
+
if (cmd === 'update' && !hasTTY && !yes) {
|
|
53
|
+
process.stderr.write(
|
|
54
|
+
'Error: interactive prompts need a TTY. For non-interactive update, pass --yes '
|
|
55
|
+
+ 'and optionally --keep-deleted or --restore-deleted.\n',
|
|
56
|
+
);
|
|
57
|
+
return 1;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
p.intro('agent-workflow-kit');
|
|
38
61
|
|
|
39
|
-
try {
|
|
62
|
+
try {
|
|
40
63
|
if (cmd === 'init') {
|
|
41
64
|
const r = await init({
|
|
42
|
-
kitRoot
|
|
65
|
+
kitRoot,
|
|
43
66
|
consumerRoot,
|
|
44
67
|
force,
|
|
45
|
-
routingProfile: routingProfileOptions(),
|
|
68
|
+
routingProfile: routingProfileOptions(yes),
|
|
46
69
|
});
|
|
47
70
|
p.note(
|
|
48
71
|
`copied ${r.copied.length} · seeded ${r.seeded.length} stub(s)` +
|
|
@@ -53,21 +76,23 @@ try {
|
|
|
53
76
|
p.outro('Next: run /setup-workflow to fill the project layer + board profile. ' +
|
|
54
77
|
'To enable the drift-guard hook, add .claude/hooks/drift-guard.py to your settings.json hooks.');
|
|
55
78
|
} else if (cmd === 'diff') {
|
|
56
|
-
const r = await diff({ kitRoot
|
|
79
|
+
const r = await diff({ kitRoot, consumerRoot, owned });
|
|
57
80
|
printPlan(r);
|
|
58
81
|
p.outro('Dry run — nothing written. Run `update` to apply.');
|
|
59
82
|
} else if (cmd === 'update') {
|
|
60
83
|
const decide = (action, path, classification) => (
|
|
61
|
-
decideUpdate(action, path, yes, classification
|
|
84
|
+
decideUpdate(action, path, yes, classification, {
|
|
85
|
+
deleted: keepDeleted ? 'keep' : restoreDeleted ? 'restore' : undefined,
|
|
86
|
+
})
|
|
62
87
|
);
|
|
63
|
-
const releaseIdentities = await
|
|
64
|
-
const r = await
|
|
65
|
-
kitRoot
|
|
88
|
+
const releaseIdentities = await releaseReader();
|
|
89
|
+
const r = await updateCommand({
|
|
90
|
+
kitRoot,
|
|
66
91
|
consumerRoot,
|
|
67
92
|
now: stamp(),
|
|
68
93
|
decide,
|
|
69
94
|
releaseIdentities,
|
|
70
|
-
routingProfile: routingProfileOptions(),
|
|
95
|
+
routingProfile: routingProfileOptions(yes),
|
|
71
96
|
});
|
|
72
97
|
printPlan(r);
|
|
73
98
|
printRoutingProfile(r.routingProfile);
|
|
@@ -79,7 +104,7 @@ try {
|
|
|
79
104
|
`not applied · conflicts ${r.conflicts.length} · ` +
|
|
80
105
|
`ownership collisions ${r.collisions.length}`,
|
|
81
106
|
);
|
|
82
|
-
|
|
107
|
+
exitCode = 2;
|
|
83
108
|
} else if (r.status === 'current') {
|
|
84
109
|
p.outro(`aktuell · unchanged ${r.unchanged.length} · local modifications ${r.userModified.length}`);
|
|
85
110
|
} else {
|
|
@@ -87,7 +112,7 @@ try {
|
|
|
87
112
|
}
|
|
88
113
|
} else if (cmd === 'uninstall') {
|
|
89
114
|
const ok = yes || (await p.confirm({ message: 'Remove kit-installed files?' })) === true;
|
|
90
|
-
if (!ok) { p.cancel('Aborted.');
|
|
115
|
+
if (!ok) { p.cancel('Aborted.'); return 0; }
|
|
91
116
|
const r = await uninstall({ consumerRoot });
|
|
92
117
|
p.outro(`removed ${r.removed.length} · retained (edited/referenced) ${r.retained.length}`);
|
|
93
118
|
} else if (cmd === 'contribute') {
|
|
@@ -101,7 +126,7 @@ try {
|
|
|
101
126
|
);
|
|
102
127
|
}
|
|
103
128
|
if (action === 'start') {
|
|
104
|
-
await beginContributionBridge({ kitRoot
|
|
129
|
+
await beginContributionBridge({ kitRoot, consumerRoot, path });
|
|
105
130
|
p.outro(`${path} is now a contribution bridge; no remote was changed`);
|
|
106
131
|
} else if (action === 'status') {
|
|
107
132
|
const surface = args.find((arg) => arg.startsWith('--surface='))
|
|
@@ -132,7 +157,7 @@ try {
|
|
|
132
157
|
);
|
|
133
158
|
}
|
|
134
159
|
await prepareContributionArtifact({
|
|
135
|
-
kitRoot
|
|
160
|
+
kitRoot, consumerRoot, path, output,
|
|
136
161
|
});
|
|
137
162
|
p.outro(`prepared local contribution artifact ${output}; no remote was changed`);
|
|
138
163
|
}
|
|
@@ -145,7 +170,7 @@ try {
|
|
|
145
170
|
}
|
|
146
171
|
const origin = cmd === 'own' ? CONSUMER_ORIGIN : KIT_ORIGIN;
|
|
147
172
|
if (origin === CONSUMER_ORIGIN && ownershipState === 'contribution-bridge') {
|
|
148
|
-
await beginContributionBridge({ kitRoot
|
|
173
|
+
await beginContributionBridge({ kitRoot, consumerRoot, path: args[1] });
|
|
149
174
|
} else {
|
|
150
175
|
await setOwnership({ consumerRoot, path: args[1], origin, ownershipState });
|
|
151
176
|
}
|
|
@@ -153,12 +178,20 @@ try {
|
|
|
153
178
|
(origin === CONSUMER_ORIGIN ? ` (${ownershipState ?? 'explicit-fork'})` : ''));
|
|
154
179
|
} else {
|
|
155
180
|
p.note('Usage: agent-workflow-kit <init|update|diff|uninstall|own|disown|contribute> ' +
|
|
156
|
-
'[<path>] [--force] [--yes] [--
|
|
181
|
+
'[<path>] [--force] [--yes] [--keep-deleted|--restore-deleted] [--owned] ' +
|
|
182
|
+
'[--as=contribution-bridge|explicit-fork]');
|
|
157
183
|
p.outro('');
|
|
158
184
|
}
|
|
159
185
|
} catch (err) {
|
|
160
186
|
p.cancel(`Error: ${err.message}`);
|
|
161
|
-
|
|
187
|
+
return 1;
|
|
188
|
+
}
|
|
189
|
+
return exitCode;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const invokedPath = process.argv[1] ? realpathSync(process.argv[1]) : '';
|
|
193
|
+
if (invokedPath === fileURLToPath(import.meta.url)) {
|
|
194
|
+
process.exitCode = await runCli();
|
|
162
195
|
}
|
|
163
196
|
|
|
164
197
|
function printPlan(r) {
|
|
@@ -190,8 +223,8 @@ function printPlan(r) {
|
|
|
190
223
|
p.note(lines.join('\n') || 'no changes', 'plan');
|
|
191
224
|
}
|
|
192
225
|
|
|
193
|
-
async function decideUpdate(action, path, yes, classification) {
|
|
194
|
-
if (yes) return nonInteractiveUpdateDecision(action);
|
|
226
|
+
async function decideUpdate(action, path, yes, classification, choices = {}) {
|
|
227
|
+
if (yes) return nonInteractiveUpdateDecision(action, choices);
|
|
195
228
|
if (action === 'delete') {
|
|
196
229
|
return (await p.confirm({ message: `Upstream removed ${path} — delete it locally?` })) === true;
|
|
197
230
|
}
|
|
@@ -213,7 +246,7 @@ async function decideUpdate(action, path, yes, classification) {
|
|
|
213
246
|
throw new Error(`unknown update decision action: ${action}`);
|
|
214
247
|
}
|
|
215
248
|
|
|
216
|
-
function routingProfileOptions() {
|
|
249
|
+
function routingProfileOptions(yes) {
|
|
217
250
|
return {
|
|
218
251
|
currentSurface: currentAgentSurface(),
|
|
219
252
|
prompt: yes ? undefined : promptRoutingProfile,
|
package/src/lib/bundle.mjs
CHANGED
|
@@ -134,6 +134,7 @@ export const HELPER_FILES = [
|
|
|
134
134
|
{ path: 'scripts/worktree-lifecycle/profile.py', kind: 'script', mode: 0o644 },
|
|
135
135
|
{ path: 'scripts/worktree-lifecycle/setup.py', kind: 'script', mode: 0o755 },
|
|
136
136
|
{ path: 'scripts/worktree-lifecycle/cleanup.py', kind: 'script', mode: 0o755 },
|
|
137
|
+
{ path: 'scripts/worktree-lifecycle/session.py', kind: 'script', mode: 0o755 },
|
|
137
138
|
{ path: 'scripts/worktree-lifecycle/capabilities.json', kind: 'doc', mode: 0o644 },
|
|
138
139
|
{ path: 'scripts/worktree-lifecycle/README.md', kind: 'doc', mode: 0o644 },
|
|
139
140
|
// Shared hook utility imported by the shipped hooks (drift-guard,
|
package/src/lib/manifest.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises';
|
|
2
|
-
import { join } from 'node:path';
|
|
2
|
+
import { basename, join, posix } from 'node:path';
|
|
3
3
|
import { writeAtomic } from './atomicWrite.mjs';
|
|
4
4
|
|
|
5
5
|
// Two manifests (Codex R1#9 / R3#1):
|
|
@@ -20,6 +20,16 @@ export const READINESS_CONTRACT_VERSION = 1;
|
|
|
20
20
|
export const READINESS_MANIFEST_PATH = '.claude/skills/skill-manifest.json';
|
|
21
21
|
export { PROJECT_SKILL_REGISTRY_PATH } from './skillRegistry.mjs';
|
|
22
22
|
|
|
23
|
+
const HASH = /^[a-f0-9]{64}$/;
|
|
24
|
+
const KINDS = new Set(['skill', 'script', 'hook', 'template', 'doc']);
|
|
25
|
+
const SURFACES = new Set(['claude', 'codex']);
|
|
26
|
+
const INSTALL_ROLES = new Set([CONSUMER_INSTALL_ROLE, 'maintainer']);
|
|
27
|
+
const ORIGINS = new Set([KIT_ORIGIN, CONSUMER_ORIGIN]);
|
|
28
|
+
const OWNERSHIP_STATES = new Set([
|
|
29
|
+
'project-extension', 'contribution-bridge', 'explicit-fork',
|
|
30
|
+
]);
|
|
31
|
+
const READINESS_DECISIONS = new Set(['pending', 'not-applicable']);
|
|
32
|
+
|
|
23
33
|
/**
|
|
24
34
|
* Parse a JSON manifest, or null if the file does not exist. A corrupt file
|
|
25
35
|
* (invalid JSON — e.g. from an aborted write before writeManifest went atomic)
|
|
@@ -35,8 +45,9 @@ export async function readManifest(path) {
|
|
|
35
45
|
if (err.code === 'ENOENT') return null;
|
|
36
46
|
throw err;
|
|
37
47
|
}
|
|
48
|
+
let manifest;
|
|
38
49
|
try {
|
|
39
|
-
|
|
50
|
+
manifest = JSON.parse(raw);
|
|
40
51
|
} catch (err) {
|
|
41
52
|
throw new Error(
|
|
42
53
|
`${path} is corrupt (invalid JSON) and can't be read. ` +
|
|
@@ -44,6 +55,162 @@ export async function readManifest(path) {
|
|
|
44
55
|
{ cause: err }
|
|
45
56
|
);
|
|
46
57
|
}
|
|
58
|
+
const name = basename(path);
|
|
59
|
+
if (name === PACKAGE_MANIFEST_NAME) {
|
|
60
|
+
return validateManifest(manifest, { kind: 'package', path });
|
|
61
|
+
}
|
|
62
|
+
if (name === CONSUMER_MANIFEST_NAME) {
|
|
63
|
+
return validateManifest(manifest, { kind: 'consumer', path });
|
|
64
|
+
}
|
|
65
|
+
return manifest;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Validate the two persisted lifecycle manifests before their entries are
|
|
70
|
+
* indexed or trusted. Unknown extension keys remain untouched; known fields
|
|
71
|
+
* are deliberately strict. Compatibility is explicit:
|
|
72
|
+
* - package manifests before role-aware installs may omit `installRole`;
|
|
73
|
+
* - consumer ledgers before role-aware installs may omit `installRole`;
|
|
74
|
+
* - readiness fields introduced after the first consumer ledger are optional.
|
|
75
|
+
*/
|
|
76
|
+
export function validateManifest(manifest, { kind, path }) {
|
|
77
|
+
if (kind !== 'package' && kind !== 'consumer') {
|
|
78
|
+
throw new Error(`unknown manifest class: ${kind}`);
|
|
79
|
+
}
|
|
80
|
+
const recovery = kind === 'package'
|
|
81
|
+
? 'Reinstall the Kit or regenerate its package manifest from a trusted checkout.'
|
|
82
|
+
: 'Restore the consumer manifest from its ".bak" backup, or delete it and re-run `init`.';
|
|
83
|
+
const fail = (detail) => {
|
|
84
|
+
throw new Error(`${path} is an invalid ${kind} manifest: ${detail} ${recovery}`);
|
|
85
|
+
};
|
|
86
|
+
if (!plainObject(manifest)) fail('the root must be a JSON object.');
|
|
87
|
+
if (typeof manifest.kitVersion !== 'string' || manifest.kitVersion.length === 0) {
|
|
88
|
+
fail('kitVersion must be a non-empty string.');
|
|
89
|
+
}
|
|
90
|
+
const key = kind === 'package' ? 'files' : 'installed';
|
|
91
|
+
if (!Array.isArray(manifest[key])) fail(`${key} must be an array.`);
|
|
92
|
+
if (kind === 'consumer') validateConsumerRoot(manifest, fail);
|
|
93
|
+
|
|
94
|
+
const seen = new Set();
|
|
95
|
+
for (const [offset, entry] of manifest[key].entries()) {
|
|
96
|
+
const ordinal = `entry #${offset + 1}`;
|
|
97
|
+
if (!plainObject(entry)) fail(`${ordinal} must be a JSON object.`);
|
|
98
|
+
if (!safeManifestPath(entry.path)) {
|
|
99
|
+
fail(`${ordinal} has an unsafe consumer path (${display(entry.path)}); use a relative POSIX path without "." or ".." segments.`);
|
|
100
|
+
}
|
|
101
|
+
const label = `${ordinal} (${entry.path})`;
|
|
102
|
+
if (seen.has(entry.path)) fail(`${label} duplicates path ${entry.path}.`);
|
|
103
|
+
seen.add(entry.path);
|
|
104
|
+
validateEnum(entry, 'kind', KINDS, label, fail, true);
|
|
105
|
+
validateOptionalString(entry, 'ownerSkill', label, fail);
|
|
106
|
+
validateEnum(entry, 'surface', SURFACES, label, fail);
|
|
107
|
+
validateEnum(entry, 'installRole', INSTALL_ROLES, label, fail);
|
|
108
|
+
validateEnum(entry, 'origin', ORIGINS, label, fail, true);
|
|
109
|
+
if (kind === 'package') {
|
|
110
|
+
validateHash(entry.sha256, `${label} sha256`, fail);
|
|
111
|
+
if (!Number.isInteger(entry.mode) || entry.mode < 0 || entry.mode > 0o777) {
|
|
112
|
+
fail(`${label} mode must be an integer from 0 through 511.`);
|
|
113
|
+
}
|
|
114
|
+
if (entry.origin !== KIT_ORIGIN) fail(`${label} origin must be "kit".`);
|
|
115
|
+
} else {
|
|
116
|
+
validateHash(entry.installedSha256, `${label} installedSha256`, fail);
|
|
117
|
+
if ('sha256' in entry) validateHash(entry.sha256, `${label} sha256`, fail);
|
|
118
|
+
if ('mode' in entry
|
|
119
|
+
&& (!Number.isInteger(entry.mode) || entry.mode < 0 || entry.mode > 0o777)) {
|
|
120
|
+
fail(`${label} mode must be an integer from 0 through 511.`);
|
|
121
|
+
}
|
|
122
|
+
validateConsumerEntry(entry, label, fail);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return manifest;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function validateConsumerRoot(manifest, fail) {
|
|
129
|
+
if ('installRole' in manifest && manifest.installRole !== CONSUMER_INSTALL_ROLE) {
|
|
130
|
+
fail('installRole must be "consumer".');
|
|
131
|
+
}
|
|
132
|
+
if ('readinessContractVersion' in manifest
|
|
133
|
+
&& (!Number.isInteger(manifest.readinessContractVersion)
|
|
134
|
+
|| manifest.readinessContractVersion < 1)) {
|
|
135
|
+
fail('readinessContractVersion must be a positive integer.');
|
|
136
|
+
}
|
|
137
|
+
if ('readinessDecisions' in manifest) {
|
|
138
|
+
if (!plainObject(manifest.readinessDecisions)) {
|
|
139
|
+
fail('readinessDecisions must be a JSON object.');
|
|
140
|
+
}
|
|
141
|
+
for (const [capability, decision] of Object.entries(manifest.readinessDecisions)) {
|
|
142
|
+
if (!capability || !READINESS_DECISIONS.has(decision)) {
|
|
143
|
+
fail(`readinessDecisions.${capability || '<empty>'} has unsupported value ${display(decision)}.`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function validateConsumerEntry(entry, label, fail) {
|
|
150
|
+
if ('orphanedByUninstall' in entry && typeof entry.orphanedByUninstall !== 'boolean') {
|
|
151
|
+
fail(`${label} orphanedByUninstall must be a boolean.`);
|
|
152
|
+
}
|
|
153
|
+
validateEnum(entry, 'ownershipState', OWNERSHIP_STATES, label, fail);
|
|
154
|
+
if ('ownershipState' in entry && entry.origin !== CONSUMER_ORIGIN) {
|
|
155
|
+
fail(`${label} ownershipState requires origin "consumer".`);
|
|
156
|
+
}
|
|
157
|
+
if (!('contributionBridge' in entry)) return;
|
|
158
|
+
const bridge = entry.contributionBridge;
|
|
159
|
+
if (!plainObject(bridge)
|
|
160
|
+
|| bridge.schemaVersion !== 1
|
|
161
|
+
|| typeof bridge.baseKitVersion !== 'string'
|
|
162
|
+
|| bridge.baseKitVersion.length === 0) {
|
|
163
|
+
fail(`${label} contributionBridge must be a schemaVersion 1 object with baseKitVersion.`);
|
|
164
|
+
}
|
|
165
|
+
validateHash(bridge.baseSha256, `${label} contributionBridge.baseSha256`, fail);
|
|
166
|
+
validateHash(bridge.localSha256, `${label} contributionBridge.localSha256`, fail);
|
|
167
|
+
if (entry.origin !== CONSUMER_ORIGIN
|
|
168
|
+
|| entry.ownershipState !== 'contribution-bridge'
|
|
169
|
+
|| entry.installedSha256 !== bridge.localSha256) {
|
|
170
|
+
fail(`${label} contributionBridge must match consumer ownership and installedSha256.`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function validateEnum(entry, field, values, label, fail, required = false) {
|
|
175
|
+
if (!(field in entry)) {
|
|
176
|
+
if (required) fail(`${label} ${field} is required.`);
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
if (typeof entry[field] !== 'string' || !values.has(entry[field])) {
|
|
180
|
+
fail(`${label} ${field} has unsupported value ${display(entry[field])}.`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function validateOptionalString(entry, field, label, fail) {
|
|
185
|
+
if (field in entry && (typeof entry[field] !== 'string' || entry[field].length === 0)) {
|
|
186
|
+
fail(`${label} ${field} must be a non-empty string when present.`);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function validateHash(value, field, fail) {
|
|
191
|
+
if (typeof value !== 'string' || !HASH.test(value)) {
|
|
192
|
+
fail(`${field} must be a lowercase 64-hex SHA-256.`);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function safeManifestPath(path) {
|
|
197
|
+
return typeof path === 'string'
|
|
198
|
+
&& path.length > 0
|
|
199
|
+
&& path !== '.'
|
|
200
|
+
&& !path.includes('\\')
|
|
201
|
+
&& !path.includes('//')
|
|
202
|
+
&& !path.split('/').some((part) => part === '' || part === '.' || part === '..')
|
|
203
|
+
&& !posix.isAbsolute(path)
|
|
204
|
+
&& !/^[a-zA-Z]:/.test(path)
|
|
205
|
+
&& posix.normalize(path) === path;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function plainObject(value) {
|
|
209
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function display(value) {
|
|
213
|
+
return typeof value === 'string' ? JSON.stringify(value) : String(value);
|
|
47
214
|
}
|
|
48
215
|
|
|
49
216
|
/**
|
|
@@ -90,7 +257,10 @@ export function filesForInstallRole(manifest, installRole = CONSUMER_INSTALL_ROL
|
|
|
90
257
|
/** Map a manifest's file list (under `key`) by `path` for quick lookup. */
|
|
91
258
|
export function indexByPath(manifest, key) {
|
|
92
259
|
const idx = new Map();
|
|
93
|
-
for (const entry of (manifest?.[key] ?? []))
|
|
260
|
+
for (const entry of (manifest?.[key] ?? [])) {
|
|
261
|
+
if (idx.has(entry.path)) throw new Error(`duplicate manifest path: ${entry.path}`);
|
|
262
|
+
idx.set(entry.path, entry);
|
|
263
|
+
}
|
|
94
264
|
return idx;
|
|
95
265
|
}
|
|
96
266
|
|
|
@@ -6,6 +6,70 @@ const SKILL_NAME = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
|
|
|
6
6
|
const MARKER_PREFIX = '<!-- agent-workflow-kit: project-extension/';
|
|
7
7
|
const MARKER = /^<!-- agent-workflow-kit: project-extension\/([^;]+); skill=([a-z0-9-]+) -->$/;
|
|
8
8
|
|
|
9
|
+
function meaningfulSectionBody(lines) {
|
|
10
|
+
const body = lines.join('\n').replace(/<!--[\s\S]*?-->/g, '');
|
|
11
|
+
const content = [];
|
|
12
|
+
let fence = null;
|
|
13
|
+
for (const rawLine of body.split('\n')) {
|
|
14
|
+
const line = rawLine.trim();
|
|
15
|
+
const delimiter = /^(`{3,}|~{3,})/.exec(line)?.[1];
|
|
16
|
+
if (delimiter && !fence) {
|
|
17
|
+
fence = { character: delimiter[0], length: delimiter.length };
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
if (fence && new RegExp(`^\\${fence.character}{${fence.length},}\\s*$`).test(line)) {
|
|
21
|
+
fence = null;
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
if (line && !/^#{1,6}\s+/.test(line)) content.push(line);
|
|
25
|
+
}
|
|
26
|
+
const meaningful = content
|
|
27
|
+
.filter((line) =>
|
|
28
|
+
!/^(?:(?:[-*+]|\d+[.)])\s+(?:\[[ xX]\]\s*)?)?(?:TODO|TBD)(?:[.!:]*)$/i.test(line)
|
|
29
|
+
&& !/^<\/?[^>\n]+>$/.test(line)
|
|
30
|
+
&& !/^<(?:placeholder|fill|configure|add)(?:\s+[^>]*)?>[.!:]*$/i.test(line)
|
|
31
|
+
&& !/^(?:Run|Use|Configure|Add|Replace)\s+`?<[^>]+>`?[.!:]*$/i.test(line))
|
|
32
|
+
.join('\n');
|
|
33
|
+
return meaningful.trim();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function sectionBodies(body, path) {
|
|
37
|
+
const sections = new Map();
|
|
38
|
+
let current = null;
|
|
39
|
+
for (const line of body.split('\n')) {
|
|
40
|
+
const heading = /^##\s+(.+?)\s*$/.exec(line);
|
|
41
|
+
if (heading) {
|
|
42
|
+
if (sections.has(heading[1])) {
|
|
43
|
+
throw new Error(`Project extension has duplicate section ${heading[1]} at ${path}`);
|
|
44
|
+
}
|
|
45
|
+
current = [];
|
|
46
|
+
sections.set(heading[1], current);
|
|
47
|
+
} else if (current) {
|
|
48
|
+
current.push(line);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return sections;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function validateProjectSkillActivation(activation, path) {
|
|
55
|
+
if (activation === undefined) return null;
|
|
56
|
+
if (!activation || typeof activation !== 'object' || Array.isArray(activation)
|
|
57
|
+
|| activation.mode !== 'all-sections-filled'
|
|
58
|
+
|| !Array.isArray(activation.sections) || !activation.sections.length
|
|
59
|
+
|| activation.sections.some((section) =>
|
|
60
|
+
typeof section !== 'string' || !section.trim())
|
|
61
|
+
|| new Set(activation.sections).size !== activation.sections.length) {
|
|
62
|
+
throw new Error(`Project extension activation policy is invalid at ${path}`);
|
|
63
|
+
}
|
|
64
|
+
return activation.sections;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function unfilledSections(body, requiredSections, path) {
|
|
68
|
+
const sections = sectionBodies(body, path);
|
|
69
|
+
return requiredSections.filter((name) =>
|
|
70
|
+
!sections.has(name) || !meaningfulSectionBody(sections.get(name)));
|
|
71
|
+
}
|
|
72
|
+
|
|
9
73
|
export function projectSkillExtensionPath(skill) {
|
|
10
74
|
if (typeof skill !== 'string' || !SKILL_NAME.test(skill)) {
|
|
11
75
|
throw new Error(`invalid Project extension skill identity: ${skill}`);
|
|
@@ -13,8 +77,9 @@ export function projectSkillExtensionPath(skill) {
|
|
|
13
77
|
return `docs/agents/skills/${skill}.md`;
|
|
14
78
|
}
|
|
15
79
|
|
|
16
|
-
export async function inspectProjectSkillExtension({ root, skill }) {
|
|
80
|
+
export async function inspectProjectSkillExtension({ root, skill, activation }) {
|
|
17
81
|
const path = projectSkillExtensionPath(skill);
|
|
82
|
+
const requiredSections = validateProjectSkillActivation(activation, path);
|
|
18
83
|
const absolute = join(root, path);
|
|
19
84
|
let state;
|
|
20
85
|
try {
|
|
@@ -50,6 +115,18 @@ export async function inspectProjectSkillExtension({ root, skill }) {
|
|
|
50
115
|
`found skill=${match[2]}`,
|
|
51
116
|
);
|
|
52
117
|
}
|
|
118
|
+
if (requiredSections) {
|
|
119
|
+
const missingSections = unfilledSections(body, requiredSections, path);
|
|
120
|
+
if (missingSections.length) {
|
|
121
|
+
return {
|
|
122
|
+
state: 'inactive',
|
|
123
|
+
reason: 'sections-unfilled',
|
|
124
|
+
schemaVersion: 1,
|
|
125
|
+
path,
|
|
126
|
+
missingSections,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
}
|
|
53
130
|
const content = lines.filter(
|
|
54
131
|
(line) => line && line !== markerLine && !line.startsWith('<!-- setup-workflow:'),
|
|
55
132
|
);
|
|
@@ -13,7 +13,7 @@ import { stubSentinel } from './sentinel.mjs';
|
|
|
13
13
|
import { STUB_TARGETS } from './bundle.mjs';
|
|
14
14
|
import {
|
|
15
15
|
CONSUMER_MANIFEST_NAME, CONSUMER_ORIGIN, READINESS_MANIFEST_PATH,
|
|
16
|
-
filesForInstallRole, indexByPath, readManifest, writeManifest,
|
|
16
|
+
filesForInstallRole, indexByPath, readManifest, validateManifest, writeManifest,
|
|
17
17
|
} from './manifest.mjs';
|
|
18
18
|
import {
|
|
19
19
|
PROJECT_SKILL_REGISTRY_PATH, emptyProjectSkillRegistry, migrateLegacySkillRegistry,
|
|
@@ -38,6 +38,7 @@ export async function materializeUpdateCandidate({
|
|
|
38
38
|
consumerRoot, pkg, priorReadinessManifest, nextReadinessManifest,
|
|
39
39
|
afterInputValidation = async () => {},
|
|
40
40
|
}) {
|
|
41
|
+
validateManifest(pkg, { kind: 'package', path: 'update package manifest' });
|
|
41
42
|
const candidateRoot = await mkdtemp(join(tmpdir(), 'agent-workflow-kit-stage-'));
|
|
42
43
|
try {
|
|
43
44
|
const paths = candidateInputPaths({
|
|
@@ -208,6 +209,7 @@ export async function activateCandidate({
|
|
|
208
209
|
afterSnapshot = async () => {}, afterGenerated = async () => {},
|
|
209
210
|
beforeTargetRevalidation = async () => {},
|
|
210
211
|
}) {
|
|
212
|
+
validateManifest(pkg, { kind: 'package', path: 'update package manifest' });
|
|
211
213
|
const changed = [...preview.added, ...preview.updated];
|
|
212
214
|
const generated = preview.generated ?? [];
|
|
213
215
|
const migrations = preview.migrations ?? [];
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
export function nonInteractiveUpdateDecision(action) {
|
|
2
|
-
if (action === 'delete') return
|
|
1
|
+
export function nonInteractiveUpdateDecision(action, choices = {}) {
|
|
2
|
+
if (action === 'delete') return choices.deleted !== 'restore';
|
|
3
3
|
if (action === 'collision') return undefined;
|
|
4
4
|
throw new Error(`unknown update decision action: ${action}`);
|
|
5
5
|
}
|
|
@@ -2,6 +2,10 @@ import { readFile } from 'node:fs/promises';
|
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { CONSUMER_INSTALL_ROLE, CONSUMER_ORIGIN, filesForInstallRole } from './manifest.mjs';
|
|
4
4
|
import { validateCandidateManifestPath } from './updateCandidate.mjs';
|
|
5
|
+
import {
|
|
6
|
+
projectSkillExtensionPath,
|
|
7
|
+
validateProjectSkillActivation,
|
|
8
|
+
} from './projectSkillExtension.mjs';
|
|
5
9
|
import { readComposedSkillRegistry } from './skillRegistry.mjs';
|
|
6
10
|
|
|
7
11
|
const READINESS_MANIFEST_PATH = '.claude/skills/skill-manifest.json';
|
|
@@ -52,6 +56,17 @@ function validateReadinessManifest(manifest) {
|
|
|
52
56
|
throw new Error(`candidate invariant schema: unsafe capability path ${name}`);
|
|
53
57
|
}
|
|
54
58
|
}
|
|
59
|
+
if (evidence.type === 'project-extension') {
|
|
60
|
+
try {
|
|
61
|
+
const expected = projectSkillExtensionPath(evidence.skill);
|
|
62
|
+
if (evidence.paths.length !== 1 || evidence.paths[0] !== expected) {
|
|
63
|
+
throw new Error('path mismatch');
|
|
64
|
+
}
|
|
65
|
+
validateProjectSkillActivation(evidence.activation, expected);
|
|
66
|
+
} catch {
|
|
67
|
+
throw new Error(`candidate invariant schema: invalid capability evidence ${name}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
55
70
|
}
|
|
56
71
|
for (const [name, skill] of Object.entries(manifest.skills)) {
|
|
57
72
|
const readiness = skill?.readiness ?? {};
|