@ikon85/agent-workflow-kit 0.32.0 → 0.33.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/census-update/SKILL.md +34 -3
- package/.agents/skills/kit-update/SKILL.md +18 -4
- package/.agents/skills/orchestrate-wave/SKILL.md +54 -54
- package/.agents/skills/orchestrate-wave/references/builder-contract.md +16 -3
- package/.agents/skills/orchestrate-wave/references/dispatch-subagents.md +79 -0
- package/.agents/skills/orchestrate-wave/references/dispatch-workflow.md +58 -0
- package/.agents/skills/orchestrate-wave/references/report-contracts.md +42 -0
- package/.agents/skills/setup-workflow/census.md +14 -7
- package/.claude/skills/census-update/SKILL.md +34 -3
- package/.claude/skills/kit-update/SKILL.md +18 -4
- package/.claude/skills/orchestrate-wave/SKILL.md +54 -54
- package/.claude/skills/orchestrate-wave/references/builder-contract.md +16 -3
- package/.claude/skills/orchestrate-wave/references/dispatch-subagents.md +79 -0
- package/.claude/skills/orchestrate-wave/references/dispatch-workflow.md +58 -0
- package/.claude/skills/orchestrate-wave/references/report-contracts.md +42 -0
- package/.claude/skills/setup-workflow/census.md +14 -7
- package/README.md +61 -1
- package/agent-workflow-kit.package.json +105 -13
- package/docs/adr/0002-capability-gated-orchestration.md +70 -0
- package/package.json +1 -1
- package/scripts/readiness.mjs +48 -14
- package/scripts/test_census_update_contract.test.mjs +14 -0
- package/scripts/test_orchestrate_wave_contract.py +136 -0
- package/scripts/test_worktree_setup_base_guard.py +140 -0
- package/scripts/worktree-lifecycle/setup.py +40 -0
- package/src/cli.mjs +2 -2
- package/src/commands/update.mjs +21 -3
- package/src/lib/bundle.mjs +10 -0
- package/src/lib/capabilityMatrix.mjs +94 -0
- package/src/lib/reconcileReconReports.mjs +111 -0
- package/src/lib/reportValidator.mjs +186 -0
- package/src/lib/updateCandidate.mjs +78 -4
- package/src/lib/waveClaim.mjs +134 -0
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { execFile } from 'node:child_process';
|
|
2
|
-
import { access, cp, mkdtemp, readFile, rm, stat, symlink } from 'node:fs/promises';
|
|
2
|
+
import { access, cp, lstat, mkdtemp, readFile, rm, stat, symlink } from 'node:fs/promises';
|
|
3
3
|
import { tmpdir } from 'node:os';
|
|
4
4
|
import { join, relative } from 'node:path';
|
|
5
5
|
import { promisify } from 'node:util';
|
|
@@ -12,10 +12,15 @@ import {
|
|
|
12
12
|
CONSUMER_MANIFEST_NAME, CONSUMER_ORIGIN, READINESS_MANIFEST_PATH,
|
|
13
13
|
indexByPath, readManifest, writeManifest,
|
|
14
14
|
} from './manifest.mjs';
|
|
15
|
-
import { checkSkill, evaluateCapability } from '../../scripts/readiness.mjs';
|
|
15
|
+
import { checkSkill, evaluateCapability, inspectProdSections } from '../../scripts/readiness.mjs';
|
|
16
16
|
|
|
17
17
|
const run = promisify(execFile);
|
|
18
18
|
const exists = (path) => access(path).then(() => true, () => false);
|
|
19
|
+
const pathEntryExists = (path) => lstat(path).then(() => true, (error) => {
|
|
20
|
+
if (error.code === 'ENOENT') return false;
|
|
21
|
+
throw error;
|
|
22
|
+
});
|
|
23
|
+
const MIGRATABLE_INSTRUCTION_PATHS = new Set(['CLAUDE.md', 'AGENTS.md']);
|
|
19
24
|
|
|
20
25
|
/** Copy a verification candidate without duplicating git metadata or dependencies. */
|
|
21
26
|
export async function stageConsumer(consumerRoot) {
|
|
@@ -40,7 +45,11 @@ export async function activateCandidate({
|
|
|
40
45
|
}) {
|
|
41
46
|
const changed = [...preview.added, ...preview.updated];
|
|
42
47
|
const generated = preview.generated ?? [];
|
|
43
|
-
const
|
|
48
|
+
const migrations = preview.migrations ?? [];
|
|
49
|
+
const touched = [
|
|
50
|
+
...changed, ...generated, ...migrations.map(({ path }) => path),
|
|
51
|
+
...preview.deleted, CONSUMER_MANIFEST_NAME,
|
|
52
|
+
];
|
|
44
53
|
const currentManifest = await readFile(join(consumerRoot, CONSUMER_MANIFEST_NAME));
|
|
45
54
|
if (!currentManifest.equals(consumerManifestBefore)) {
|
|
46
55
|
throw new Error('consumer manifest changed during verification');
|
|
@@ -58,6 +67,11 @@ export async function activateCandidate({
|
|
|
58
67
|
throw new Error(`generated candidate hash mismatch: ${path}`);
|
|
59
68
|
}
|
|
60
69
|
}
|
|
70
|
+
for (const migration of migrations) {
|
|
71
|
+
if (await sha256File(join(candidateRoot, migration.path)) !== migration.afterSha256) {
|
|
72
|
+
throw new Error(`migrated candidate hash mismatch: ${migration.path}`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
61
75
|
await assertConsumerStillMatchesPreview(consumerRoot, preview);
|
|
62
76
|
const rollback = new Map();
|
|
63
77
|
for (const path of touched) rollback.set(path, await snapshot(join(consumerRoot, path)));
|
|
@@ -68,6 +82,9 @@ export async function activateCandidate({
|
|
|
68
82
|
for (const path of generated) {
|
|
69
83
|
await writeAtomic(join(consumerRoot, path), await readFile(join(candidateRoot, path)));
|
|
70
84
|
}
|
|
85
|
+
for (const { path } of migrations) {
|
|
86
|
+
await writeAtomic(join(consumerRoot, path), await readFile(join(candidateRoot, path)));
|
|
87
|
+
}
|
|
71
88
|
await afterGenerated();
|
|
72
89
|
for (const path of preview.deleted) await rm(join(consumerRoot, path), { force: true });
|
|
73
90
|
await writeAtomic(
|
|
@@ -105,6 +122,17 @@ async function assertConsumerStillMatchesPreview(consumerRoot, preview) {
|
|
|
105
122
|
throw new Error(`consumer changed during verification: ${path}`);
|
|
106
123
|
}
|
|
107
124
|
}
|
|
125
|
+
for (const migration of preview.migrations ?? []) {
|
|
126
|
+
const present = await pathEntryExists(join(consumerRoot, migration.path));
|
|
127
|
+
if (present) await validateConsumerFile(consumerRoot, migration.path);
|
|
128
|
+
else if (!MIGRATABLE_INSTRUCTION_PATHS.has(migration.path)) {
|
|
129
|
+
throw new Error(`unsafe consumer path: ${migration.path}`);
|
|
130
|
+
}
|
|
131
|
+
const current = present ? await sha256File(join(consumerRoot, migration.path)) : null;
|
|
132
|
+
if (current !== migration.beforeSha256) {
|
|
133
|
+
throw new Error(`consumer changed during verification: ${migration.path}`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
108
136
|
for (const path of [...preview.updated, ...preview.deleted]) {
|
|
109
137
|
const prior = installed.get(path);
|
|
110
138
|
const current = await exists(join(consumerRoot, path))
|
|
@@ -144,6 +172,9 @@ export async function adoptReadinessCandidate({ candidateRoot, consumerRoot, pri
|
|
|
144
172
|
}
|
|
145
173
|
await writeManifest(manifestPath, { ...manifest, installed });
|
|
146
174
|
}
|
|
175
|
+
const { migrations, migrationConflicts } = await migrateProdSections({
|
|
176
|
+
candidateRoot, consumerRoot, nextManifest,
|
|
177
|
+
});
|
|
147
178
|
const before = await readinessSnapshot(consumerRoot, priorManifest);
|
|
148
179
|
const after = await readinessSnapshot(candidateRoot, nextManifest);
|
|
149
180
|
const incompatible = Object.entries(after.skills)
|
|
@@ -151,7 +182,50 @@ export async function adoptReadinessCandidate({ candidateRoot, consumerRoot, pri
|
|
|
151
182
|
&& before.skills[skill] && current.verdict === 'blocked')
|
|
152
183
|
.map(([skill]) => skill)
|
|
153
184
|
.sort();
|
|
154
|
-
return {
|
|
185
|
+
return {
|
|
186
|
+
generated,
|
|
187
|
+
migrations,
|
|
188
|
+
migrated: migrations.map(({ path }) => path),
|
|
189
|
+
migrationConflicts,
|
|
190
|
+
availability: readinessDiff(before, after),
|
|
191
|
+
incompatible,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function migrateProdSections({ candidateRoot, consumerRoot, nextManifest }) {
|
|
196
|
+
const paths = [...new Set(Object.values(nextManifest?.readiness?.capabilities ?? {})
|
|
197
|
+
.flatMap(({ evidence }) => evidence?.type === 'prod-section' ? evidence.paths ?? [] : []))];
|
|
198
|
+
if (paths.length < 2) return { migrations: [], migrationConflicts: [] };
|
|
199
|
+
if (paths.some((path) => !MIGRATABLE_INSTRUCTION_PATHS.has(path))) {
|
|
200
|
+
return { migrations: [], migrationConflicts: paths };
|
|
201
|
+
}
|
|
202
|
+
const sections = await inspectProdSections(candidateRoot, paths);
|
|
203
|
+
const invalid = sections.filter(({ state }) => state === 'invalid');
|
|
204
|
+
const validBodies = [...new Set(
|
|
205
|
+
sections.filter(({ state }) => state === 'valid').map(({ body }) => body),
|
|
206
|
+
)];
|
|
207
|
+
if (invalid.length || validBodies.length > 1) {
|
|
208
|
+
return { migrations: [], migrationConflicts: paths };
|
|
209
|
+
}
|
|
210
|
+
if (validBodies.length !== 1) return { migrations: [], migrationConflicts: [] };
|
|
211
|
+
|
|
212
|
+
const body = validBodies[0];
|
|
213
|
+
const migrations = [];
|
|
214
|
+
for (const entry of sections.filter(({ state }) => state === 'missing')) {
|
|
215
|
+
const candidatePath = join(candidateRoot, entry.path);
|
|
216
|
+
const present = await pathEntryExists(candidatePath);
|
|
217
|
+
if (present) await validateConsumerFile(candidateRoot, entry.path);
|
|
218
|
+
const before = present ? await readFile(candidatePath, 'utf8') : '';
|
|
219
|
+
const separator = before && !before.endsWith('\n') ? '\n\n' : (before ? '\n' : '');
|
|
220
|
+
await writeAtomic(candidatePath, `${before}${separator}## Prod\n\n${body}\n`);
|
|
221
|
+
migrations.push({
|
|
222
|
+
path: entry.path,
|
|
223
|
+
beforeSha256: await exists(join(consumerRoot, entry.path))
|
|
224
|
+
? await sha256File(join(consumerRoot, entry.path)) : null,
|
|
225
|
+
afterSha256: await sha256File(candidatePath),
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
return { migrations, migrationConflicts: [] };
|
|
155
229
|
}
|
|
156
230
|
|
|
157
231
|
function readinessStubPaths(manifest) {
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { execFile, spawn } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
|
|
4
|
+
const exec = promisify(execFile);
|
|
5
|
+
const CONTRACT_VERSION = 1;
|
|
6
|
+
|
|
7
|
+
async function git(repoRoot, args, options = {}) {
|
|
8
|
+
return exec('git', args, { cwd: repoRoot, encoding: 'utf8', ...options });
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// execFile has no `input` option — the child's stdin would stay open and a
|
|
12
|
+
// stdin-reading plumbing command (`git mktag`) would block forever. Spawn and
|
|
13
|
+
// close stdin explicitly instead.
|
|
14
|
+
function gitWithInput(repoRoot, args, input) {
|
|
15
|
+
return new Promise((resolve, reject) => {
|
|
16
|
+
const child = spawn('git', args, { cwd: repoRoot });
|
|
17
|
+
let stdout = '';
|
|
18
|
+
let stderr = '';
|
|
19
|
+
child.stdout.setEncoding('utf8');
|
|
20
|
+
child.stderr.setEncoding('utf8');
|
|
21
|
+
child.stdout.on('data', (chunk) => { stdout += chunk; });
|
|
22
|
+
child.stderr.on('data', (chunk) => { stderr += chunk; });
|
|
23
|
+
child.on('error', reject);
|
|
24
|
+
child.on('close', (code) => {
|
|
25
|
+
if (code === 0) resolve({ stdout, stderr });
|
|
26
|
+
else reject(new Error(`git ${args.join(' ')} failed (${code}): ${stderr.trim()}`));
|
|
27
|
+
});
|
|
28
|
+
child.stdin.end(input);
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function claimRef(anchor) {
|
|
33
|
+
if (typeof anchor !== 'string' || !/^[1-9][0-9]*$/.test(anchor)) {
|
|
34
|
+
throw new TypeError('anchor must be a positive issue number string');
|
|
35
|
+
}
|
|
36
|
+
return `refs/tags/wave-active/${anchor}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function validateOwner(owner) {
|
|
40
|
+
if (typeof owner !== 'string' || owner.trim() === '') {
|
|
41
|
+
throw new TypeError('owner must be a non-empty string');
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function resolveRef(repoRoot, ref) {
|
|
46
|
+
try {
|
|
47
|
+
const { stdout } = await git(repoRoot, ['rev-parse', '--verify', ref]);
|
|
48
|
+
return stdout.trim();
|
|
49
|
+
} catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function readClaimAt(repoRoot, ref, tagOid) {
|
|
55
|
+
const { stdout: type } = await git(repoRoot, ['cat-file', '-t', tagOid]);
|
|
56
|
+
if (type.trim() !== 'tag') throw new Error(`${ref} is not an annotated wave claim`);
|
|
57
|
+
const { stdout } = await git(repoRoot, ['cat-file', '-p', tagOid]);
|
|
58
|
+
const separator = stdout.indexOf('\n\n');
|
|
59
|
+
if (separator < 0) throw new Error(`${ref} has no ownership payload`);
|
|
60
|
+
let claim;
|
|
61
|
+
try {
|
|
62
|
+
claim = JSON.parse(stdout.slice(separator + 2).trim());
|
|
63
|
+
} catch (error) {
|
|
64
|
+
throw new Error(`${ref} has an invalid ownership payload`, { cause: error });
|
|
65
|
+
}
|
|
66
|
+
if (claim?.contractVersion !== CONTRACT_VERSION || typeof claim.owner !== 'string') {
|
|
67
|
+
throw new Error(`${ref} has an unsupported ownership payload`);
|
|
68
|
+
}
|
|
69
|
+
return claim;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function readWaveClaim({ repoRoot, anchor }) {
|
|
73
|
+
const ref = claimRef(anchor);
|
|
74
|
+
const tagOid = await resolveRef(repoRoot, ref);
|
|
75
|
+
return tagOid ? readClaimAt(repoRoot, ref, tagOid) : null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function createTagObject(repoRoot, anchor, claim) {
|
|
79
|
+
const ref = claimRef(anchor);
|
|
80
|
+
const [{ stdout: head }, { stdout: objectFormat }] = await Promise.all([
|
|
81
|
+
git(repoRoot, ['rev-parse', '--verify', 'HEAD^{commit}']),
|
|
82
|
+
git(repoRoot, ['rev-parse', '--show-object-format']),
|
|
83
|
+
]);
|
|
84
|
+
const epoch = Math.floor(Date.parse(claim.createdAt) / 1000);
|
|
85
|
+
const content = [
|
|
86
|
+
`object ${head.trim()}`,
|
|
87
|
+
'type commit',
|
|
88
|
+
`tag ${ref.slice('refs/tags/'.length)}`,
|
|
89
|
+
`tagger agent-workflow-kit waveClaim <wave-claim@localhost> ${epoch} +0000`,
|
|
90
|
+
'',
|
|
91
|
+
JSON.stringify(claim),
|
|
92
|
+
'',
|
|
93
|
+
].join('\n');
|
|
94
|
+
const { stdout: tagOid } = await gitWithInput(repoRoot, ['mktag'], content);
|
|
95
|
+
const zeroOid = '0'.repeat(objectFormat.trim() === 'sha256' ? 64 : 40);
|
|
96
|
+
return { ref, tagOid: tagOid.trim(), zeroOid };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Atomically acquire a local annotated-tag claim and read back its owner. */
|
|
100
|
+
export async function claimWave({ repoRoot, anchor, owner, sliceBranches = [], now = new Date() }) {
|
|
101
|
+
validateOwner(owner);
|
|
102
|
+
if (!Array.isArray(sliceBranches) || !sliceBranches.every((branch) => typeof branch === 'string')) {
|
|
103
|
+
throw new TypeError('sliceBranches must be an array of strings');
|
|
104
|
+
}
|
|
105
|
+
const createdAt = now instanceof Date ? now.toISOString() : new Date(now).toISOString();
|
|
106
|
+
const proposed = { contractVersion: CONTRACT_VERSION, anchor, owner, createdAt, sliceBranches };
|
|
107
|
+
const { ref, tagOid, zeroOid } = await createTagObject(repoRoot, anchor, proposed);
|
|
108
|
+
let acquired = true;
|
|
109
|
+
try {
|
|
110
|
+
await git(repoRoot, ['update-ref', ref, tagOid, zeroOid]);
|
|
111
|
+
} catch {
|
|
112
|
+
acquired = false;
|
|
113
|
+
}
|
|
114
|
+
const currentOid = await resolveRef(repoRoot, ref);
|
|
115
|
+
if (!currentOid) throw new Error(`wave claim ${ref} was not readable after creation`);
|
|
116
|
+
const claim = await readClaimAt(repoRoot, ref, currentOid);
|
|
117
|
+
return { acquired: acquired && currentOid === tagOid && claim.owner === owner, claim };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Remove a claim only while the annotated payload and ref still belong to owner. */
|
|
121
|
+
export async function releaseWaveClaim({ repoRoot, anchor, owner }) {
|
|
122
|
+
validateOwner(owner);
|
|
123
|
+
const ref = claimRef(anchor);
|
|
124
|
+
const tagOid = await resolveRef(repoRoot, ref);
|
|
125
|
+
if (!tagOid) return false;
|
|
126
|
+
const claim = await readClaimAt(repoRoot, ref, tagOid);
|
|
127
|
+
if (claim.owner !== owner) return false;
|
|
128
|
+
try {
|
|
129
|
+
await git(repoRoot, ['update-ref', '-d', ref, tagOid]);
|
|
130
|
+
} catch {
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
return await resolveRef(repoRoot, ref) === null;
|
|
134
|
+
}
|