@ikon85/agent-workflow-kit 0.36.3 → 0.36.5

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/README.md CHANGED
@@ -445,6 +445,19 @@ the old way. Decision record:
445
445
 
446
446
  ## Release notes
447
447
 
448
+ ### 0.36.5
449
+
450
+ - changed: `src/lib/updateCandidate.mjs`
451
+ - changed: `src/lib/updateReconcile.mjs`
452
+ - changed: `src/lib/verifyUpdateCandidate.mjs`
453
+ - changed: `src/lib/verifyUpdateCandidateTransaction.mjs`
454
+
455
+ ### 0.36.4
456
+
457
+ - changed: `scripts/release-delta-guard.mjs`
458
+ - changed: `scripts/release-delta-guard.test.mjs`
459
+ - changed: `src/lib/updateReconcile.mjs`
460
+
448
461
  ### 0.36.3
449
462
 
450
463
  - Metadata-only release.
@@ -1,5 +1,5 @@
1
1
  {
2
- "kitVersion": "0.36.3",
2
+ "kitVersion": "0.36.5",
3
3
  "files": [
4
4
  {
5
5
  "path": ".agents/skills/ask-matt/SKILL.md",
@@ -2683,7 +2683,7 @@
2683
2683
  "path": "scripts/release-delta-guard.mjs",
2684
2684
  "kind": "script",
2685
2685
  "installRole": "maintainer",
2686
- "sha256": "1d2aab5f15bca25167b166b37232139916cf814e94de873fa74a653ecf9a4619",
2686
+ "sha256": "566d7bae5526af2f8863d7a06dfa3016c9383b241b616cd43757eb7b46555895",
2687
2687
  "mode": 420,
2688
2688
  "origin": "kit"
2689
2689
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ikon85/agent-workflow-kit",
3
- "version": "0.36.3",
3
+ "version": "0.36.5",
4
4
  "description": "Portable AI-agent workflow skills (plan → execute → land → learn) for Claude Code & Codex — grilling, TDD, diagnosis, two-axis code review, cross-model Codex review, design & domain-modeling, plus a skill router (ask-matt). npx init/update/diff/uninstall.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,6 +1,6 @@
1
1
  /** Block shipped changes whose version or checked manifest does not match a fresh build. */
2
2
  import { execFileSync } from 'node:child_process';
3
- import { mkdir, mkdtemp, readFile, rm } from 'node:fs/promises';
3
+ import { mkdir, mkdtemp, readFile, readdir, rm } from 'node:fs/promises';
4
4
  import { tmpdir } from 'node:os';
5
5
  import { dirname, join } from 'node:path';
6
6
  import { fileURLToPath } from 'node:url';
@@ -24,6 +24,12 @@ const describe = (delta) => ['added', 'removed', 'changed']
24
24
  .filter((kind) => delta[kind].length)
25
25
  .map((kind) => `${kind}: ${delta[kind].join(', ')}`).join('; ');
26
26
 
27
+ function mergeDeltas(...deltas) {
28
+ return Object.fromEntries(['added', 'removed', 'changed'].map((kind) => [
29
+ kind, [...new Set(deltas.flatMap((delta) => delta[kind]))].sort(),
30
+ ]));
31
+ }
32
+
27
33
  export function recommendBump(delta) {
28
34
  if (delta.removed.length) return 'major';
29
35
  if (delta.added.length) return 'minor';
@@ -40,7 +46,11 @@ function bumpKind(before, after) {
40
46
  }
41
47
 
42
48
  export function assessRelease(input) {
43
- const delta = manifestDelta(input.baseManifest, input.builtManifest);
49
+ const bundleDelta = manifestDelta(input.baseManifest, input.builtManifest);
50
+ const packageDelta = input.basePackagePayload && input.currentPackagePayload
51
+ ? manifestDelta(input.basePackagePayload, input.currentPackagePayload)
52
+ : { added: [], removed: [], changed: [] };
53
+ const delta = mergeDeltas(bundleDelta, packageDelta);
44
54
  const checkedDrift = manifestDelta(input.checkedManifest, input.builtManifest);
45
55
  const payloadDrift = manifestDelta(input.builtManifest, input.payloadManifest);
46
56
  const errors = [];
@@ -99,6 +109,18 @@ export function resolveBaseTag({ repoRoot, baseVersion, tagPrefix = 'v' }) {
99
109
  }
100
110
 
101
111
  export async function packedPayloadManifest({ repoRoot, manifest }) {
112
+ const payload = await packedPackagePayload({ repoRoot });
113
+ const indexed = byPath(payload);
114
+ return {
115
+ kitVersion: manifest.kitVersion,
116
+ files: manifest.files.map((entry) => ({
117
+ ...entry,
118
+ sha256: indexed.get(entry.path),
119
+ })),
120
+ };
121
+ }
122
+
123
+ export async function packedPackagePayload({ repoRoot }) {
102
124
  const tempRoot = await mkdtemp(join(tmpdir(), 'awkit-package-payload-'));
103
125
  try {
104
126
  const packOutput = execFileSync(
@@ -110,17 +132,38 @@ export async function packedPayloadManifest({ repoRoot, manifest }) {
110
132
  await mkdir(unpackDir);
111
133
  execFileSync('tar', ['-xzf', join(tempRoot, filename), '-C', unpackDir]);
112
134
  return {
113
- kitVersion: manifest.kitVersion,
114
- files: await Promise.all(manifest.files.map(async (entry) => ({
115
- ...entry,
116
- sha256: sha256(await readFile(join(unpackDir, 'package', entry.path))),
117
- }))),
135
+ files: await packageFiles(join(unpackDir, 'package')),
118
136
  };
119
137
  } finally {
120
138
  await rm(tempRoot, { recursive: true, force: true });
121
139
  }
122
140
  }
123
141
 
142
+ async function packageFiles(root, relative = '') {
143
+ const files = [];
144
+ for (const entry of await readdir(join(root, relative), { withFileTypes: true })) {
145
+ const path = relative ? `${relative}/${entry.name}` : entry.name;
146
+ if (entry.isDirectory()) files.push(...await packageFiles(root, path));
147
+ else if (entry.isFile()) {
148
+ files.push({ path, sha256: sha256(await readFile(join(root, path))) });
149
+ }
150
+ }
151
+ return files.sort((a, b) => a.path.localeCompare(b.path));
152
+ }
153
+
154
+ async function packagePayloadAtRef(repoRoot, ref) {
155
+ const archiveRoot = await mkdtemp(join(tmpdir(), 'awkit-package-base-'));
156
+ try {
157
+ const archive = execFileSync('git', ['archive', '--format=tar', ref], {
158
+ cwd: repoRoot, maxBuffer: 50 * 1024 * 1024,
159
+ });
160
+ execFileSync('tar', ['-xf', '-', '-C', archiveRoot], { input: archive });
161
+ return await packedPackagePayload({ repoRoot: archiveRoot });
162
+ } finally {
163
+ await rm(archiveRoot, { recursive: true, force: true });
164
+ }
165
+ }
166
+
124
167
  export async function checkReleaseDelta({ repoRoot, baseRef = 'origin/main' } = {}) {
125
168
  repoRoot ??= join(dirname(fileURLToPath(import.meta.url)), '..');
126
169
  const distDir = await mkdtemp(join(tmpdir(), 'awkit-release-guard-'));
@@ -129,6 +172,7 @@ export async function checkReleaseDelta({ repoRoot, baseRef = 'origin/main' } =
129
172
  const currentPackage = JSON.parse(await readFile(join(repoRoot, 'package.json'), 'utf8'));
130
173
  const builtManifest = JSON.parse(await readFile(join(distDir, 'agent-workflow-kit.package.json'), 'utf8'));
131
174
  const baseVersion = gitShowJson(repoRoot, baseRef, 'package.json').version;
175
+ const currentPackagePayload = await packedPackagePayload({ repoRoot });
132
176
  return assessRelease({
133
177
  baseVersion,
134
178
  baseTag: resolveBaseTag({ repoRoot, baseVersion }),
@@ -136,7 +180,15 @@ export async function checkReleaseDelta({ repoRoot, baseRef = 'origin/main' } =
136
180
  baseManifest: gitShowJson(repoRoot, baseRef, 'agent-workflow-kit.package.json'),
137
181
  checkedManifest: JSON.parse(await readFile(join(repoRoot, 'agent-workflow-kit.package.json'), 'utf8')),
138
182
  builtManifest,
139
- payloadManifest: await packedPayloadManifest({ repoRoot, manifest: builtManifest }),
183
+ payloadManifest: {
184
+ kitVersion: builtManifest.kitVersion,
185
+ files: builtManifest.files.map((entry) => ({
186
+ ...entry,
187
+ sha256: byPath(currentPackagePayload).get(entry.path),
188
+ })),
189
+ },
190
+ basePackagePayload: await packagePayloadAtRef(repoRoot, baseRef),
191
+ currentPackagePayload,
140
192
  });
141
193
  } finally { await rm(distDir, { recursive: true, force: true }); }
142
194
  }
@@ -39,6 +39,25 @@ test('an unbumped shipped change is blocked with its concrete delta', () => {
39
39
  assert.match(result.errors.join('\n'), /version remains 1\.2\.3/);
40
40
  });
41
41
 
42
+ test('an npm runtime change outside the Consumer manifest still requires a release', () => {
43
+ const consumer = { kitVersion: '1.2.3', files: [file('skill.md', 'same')] };
44
+ const result = assessRelease({
45
+ baseVersion: '1.2.3',
46
+ currentVersion: '1.2.3',
47
+ baseManifest: consumer,
48
+ builtManifest: consumer,
49
+ checkedManifest: consumer,
50
+ payloadManifest: consumer,
51
+ basePackagePayload: { files: [file('src/cli.mjs', 'old')] },
52
+ currentPackagePayload: { files: [file('src/cli.mjs', 'new')] },
53
+ });
54
+
55
+ assert.equal(result.ok, false);
56
+ assert.deepEqual(result.delta.changed, ['src/cli.mjs']);
57
+ assert.equal(result.recommendedBump, 'patch');
58
+ assert.match(result.errors.join('\n'), /version remains 1\.2\.3/);
59
+ });
60
+
42
61
  test('dead checked-manifest entries are rejected', () => {
43
62
  const result = assessRelease({
44
63
  baseVersion: '1.2.3', currentVersion: '1.3.0',
@@ -351,6 +351,14 @@ async function assertConsumerStillMatchesPreview(consumerRoot, preview) {
351
351
  throw new Error(`consumer changed during verification: ${path}`);
352
352
  }
353
353
  }
354
+ for (const snapshot of preview.userModifiedSnapshots ?? []) {
355
+ const target = join(consumerRoot, snapshot.path);
356
+ const current = await exists(target) ? await sha256File(target) : null;
357
+ const mode = current === null ? null : (await lstat(target)).mode & 0o777;
358
+ if (current !== snapshot.sha256 || mode !== snapshot.mode) {
359
+ throw new Error(`consumer changed during verification: ${snapshot.path}`);
360
+ }
361
+ }
354
362
  }
355
363
 
356
364
  /** Seed only newly declared, decision-free project-layer stubs in a staged candidate. */
@@ -143,7 +143,8 @@ export async function reconcile({ kitRoot, consumerRoot, decide = () => false, d
143
143
  const userEdited = current !== prior.installedSha256;
144
144
  const currentMode = (await lstat(dest)).mode & 0o777;
145
145
  const upstreamChanged = file.sha256 !== prior.installedSha256
146
- || currentMode !== file.mode;
146
+ || currentMode !== file.mode
147
+ || packageMetadataChanged(file, prior);
147
148
  if (!userEdited && upstreamChanged) {
148
149
  if (!dryRun) await writeAtomic(dest, await readFile(join(kitRoot, file.path)), file.mode);
149
150
  nextInstalled.push(entry(file, file.sha256));
@@ -155,6 +156,11 @@ export async function reconcile({ kitRoot, consumerRoot, decide = () => false, d
155
156
  } else if (userEdited) {
156
157
  nextInstalled.push(withInstallRole(prior));
157
158
  result.userModified.push(file.path);
159
+ result.userModifiedSnapshots.push({
160
+ path: file.path,
161
+ sha256: current,
162
+ mode: currentMode,
163
+ });
158
164
  } else {
159
165
  nextInstalled.push(withInstallRole(prior));
160
166
  result.unchanged.push(file.path);
@@ -185,7 +191,8 @@ export async function reconcile({ kitRoot, consumerRoot, decide = () => false, d
185
191
  }
186
192
  }
187
193
 
188
- result.manifestChanged = consumer.installRole !== CONSUMER_INSTALL_ROLE ||
194
+ result.manifestChanged = consumer.kitVersion !== pkg.kitVersion ||
195
+ consumer.installRole !== CONSUMER_INSTALL_ROLE ||
189
196
  result.bridgeRetired.length > 0 ||
190
197
  nextInstalled.some((next) => installedIdx.get(next.path)?.installRole !== next.installRole);
191
198
 
@@ -200,6 +207,7 @@ export async function reconcile({ kitRoot, consumerRoot, decide = () => false, d
200
207
  function emptyResult() {
201
208
  return {
202
209
  unchanged: [], updated: [], conflicts: [], collisions: [], collisionResolutions: [], userModified: [],
210
+ userModifiedSnapshots: [],
203
211
  ownershipStates: [],
204
212
  bridgeRetired: [],
205
213
  added: [], deleted: [], keptDeleted: [], consumerOwned: [], manifestChanged: false,
@@ -227,6 +235,12 @@ function withInstallRole(installed, installRole = CONSUMER_INSTALL_ROLE) {
227
235
  return { ...installed, installRole };
228
236
  }
229
237
 
238
+ function packageMetadataChanged(file, installed) {
239
+ return ['kind', 'ownerSkill', 'surface'].some(
240
+ (key) => (file[key] ?? null) !== (installed[key] ?? null),
241
+ );
242
+ }
243
+
230
244
  async function projectExtensionEvidence(consumerRoot, path) {
231
245
  const match = /^docs\/agents\/skills\/([a-z0-9-]+)\.md$/.exec(path);
232
246
  if (!match) return null;
@@ -61,11 +61,14 @@ export async function verifyCandidateMetadata(candidateRoot, context) {
61
61
  if (!state.isFile()) {
62
62
  throw new Error(`candidate invariant artifact: not a regular file ${entry.path}`);
63
63
  }
64
- if (tracked.origin === KIT_ORIGIN && (state.mode & 0o777) !== entry.mode) {
64
+ const localSnapshot = (context.preview.userModifiedSnapshots ?? [])
65
+ .find(({ path }) => path === entry.path);
66
+ const expectedMode = localSnapshot?.mode ?? entry.mode;
67
+ if (tracked.origin === KIT_ORIGIN && (state.mode & 0o777) !== expectedMode) {
65
68
  throw new Error(`candidate invariant artifact: mode mismatch ${entry.path}`);
66
69
  }
67
- const expectedHash = tracked.origin === CONSUMER_ORIGIN
68
- ? tracked.installedSha256 : entry.sha256;
70
+ const expectedHash = localSnapshot?.sha256 ?? (tracked.origin === CONSUMER_ORIGIN
71
+ ? tracked.installedSha256 : entry.sha256);
69
72
  if (await sha256File(join(candidateRoot, entry.path)) !== expectedHash) {
70
73
  throw new Error(`candidate invariant artifact: hash mismatch ${entry.path}`);
71
74
  }
@@ -44,7 +44,7 @@ export function verifyTransactionPreview(preview, installable, installed) {
44
44
  const installablePaths = new Set(installable.map(({ path }) => path));
45
45
  const owners = new Map();
46
46
  const actionSets = new Map();
47
- for (const key of ['added', 'updated', 'deleted', 'generated', 'keptDeleted']) {
47
+ for (const key of ['added', 'updated', 'deleted', 'generated', 'keptDeleted', 'userModified']) {
48
48
  if (!Array.isArray(preview[key] ?? [])) {
49
49
  throw new Error(`candidate invariant transaction: ${key} must be an array`);
50
50
  }
@@ -54,12 +54,34 @@ export function verifyTransactionPreview(preview, installable, installed) {
54
54
  if (['added', 'updated'].includes(key) && !installablePaths.has(path)) {
55
55
  throw new Error(`candidate invariant transaction: unmanaged ${key} path ${path}`);
56
56
  }
57
+ if (key === 'userModified' && !installablePaths.has(path)) {
58
+ throw new Error(`candidate invariant transaction: unmanaged userModified path ${path}`);
59
+ }
57
60
  if (key === 'deleted' && installablePaths.has(path)) {
58
61
  throw new Error(`candidate invariant transaction: deletes current package path ${path}`);
59
62
  }
60
63
  }
61
64
  actionSets.set(key, local);
62
65
  }
66
+ if (!Array.isArray(preview.userModifiedSnapshots ?? [])) {
67
+ throw new Error('candidate invariant transaction: userModifiedSnapshots must be an array');
68
+ }
69
+ const modified = actionSets.get('userModified');
70
+ const snapshots = new Set();
71
+ for (const snapshot of preview.userModifiedSnapshots ?? []) {
72
+ const path = snapshot?.path;
73
+ claimTransactionPath(path, 'userModifiedSnapshots', snapshots);
74
+ if (!modified.has(path)
75
+ || !HASH.test(snapshot.sha256 ?? '')
76
+ || !Number.isInteger(snapshot.mode)
77
+ || snapshot.mode < 0
78
+ || snapshot.mode > 0o777) {
79
+ throw new Error(`candidate invariant transaction: invalid local snapshot ${path}`);
80
+ }
81
+ }
82
+ if (snapshots.size !== modified.size) {
83
+ throw new Error('candidate invariant transaction: missing local snapshot');
84
+ }
63
85
  if (!Array.isArray(preview.bridgeRetired ?? [])) {
64
86
  throw new Error('candidate invariant transaction: bridgeRetired must be an array');
65
87
  }