@ikon85/agent-workflow-kit 0.11.0 → 0.12.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.
@@ -0,0 +1,52 @@
1
+ ---
2
+ name: kit-update
3
+ description: "Preview and transactionally apply a parity-verified agent-workflow-kit release without overwriting local modifications or auto-resolving conflicts."
4
+ ---
5
+
6
+ # Kit Update
7
+
8
+ Update an installed consumer only from the public scoped package
9
+ `@ikon85/agent-workflow-kit`. The unscoped `agent-workflow-kit` package belongs
10
+ to another publisher and is never a valid update source. Existing installs from
11
+ `npx github:iKon85/agent-workflow-kit` remain valid, but updates must use the
12
+ scoped npm release so the shared release-parity checker can prove that npm and
13
+ the matching GitHub release contain the same artifact.
14
+
15
+ ## Workflow
16
+
17
+ 1. Preview the delta without writing:
18
+
19
+ ```sh
20
+ npx @ikon85/agent-workflow-kit@latest diff
21
+ ```
22
+
23
+ Review the named added, updated, locally modified, removed, and conflicting
24
+ paths. Do not reinterpret a local modification as permission to overwrite it.
25
+
26
+ 2. Apply the update:
27
+
28
+ ```sh
29
+ npx @ikon85/agent-workflow-kit@latest update
30
+ ```
31
+
32
+ The command checks npm/GitHub release parity before staging. It prepares a
33
+ complete candidate outside the consumer, runs the consumer's existing
34
+ `npm test` command there, and activates only a verified candidate. A staging
35
+ or verification failure leaves the installed tree byte-identical.
36
+
37
+ 3. Read the terminal report. `aktuell` proves a second run found no upstream
38
+ delta. A conflict report names and counts every category and leaves every
39
+ consumer file untouched. Follow its recommendation and resolve each named
40
+ conflict manually; never auto-merge, delete a local edit, or silently choose
41
+ the incoming copy.
42
+
43
+ 4. If a candidate is interrupted, discard its reported stage directory or
44
+ resume the transaction through the update API's `resumeFrom` option. Do not
45
+ copy staged files into the consumer by hand.
46
+
47
+ ## State contract
48
+
49
+ The update API reports `checking -> preview/awaiting_decision -> staging ->
50
+ verifying -> applied | conflicted | failed | aborted`. `conflicted`, `failed`,
51
+ and `aborted` never authorize partial activation. The existing manifest,
52
+ three-way diff, and atomic-write seams remain the source of truth.
@@ -19,6 +19,7 @@ Use this section as the entry-point map for agent-assisted work. The individual
19
19
  - **Implementation slice:** use `tdd` for one behavior at a time: RED, GREEN, then refactor.
20
20
  - **Finished slice:** use `wrapup` to prepare the branch, PR, and cleanup steps your repo expects.
21
21
  - **Kit release:** use `kit-release` to derive the shipped delta, confirm Semver, regenerate the manifest, run all gates, and then hand landing to `wrapup`.
22
+ - **Kit update:** use `kit-update` to preview and transactionally apply a parity-verified scoped release without overwriting local modifications.
22
23
  - **A huge, foggy effort, too big for one session:** use `wayfinder` — it charts it as a shared map of investigation tickets, resolving one per session.
23
24
 
24
25
  ## Routing Rule
@@ -0,0 +1,52 @@
1
+ ---
2
+ name: kit-update
3
+ description: "Preview and transactionally apply a parity-verified agent-workflow-kit release without overwriting local modifications or auto-resolving conflicts."
4
+ ---
5
+
6
+ # Kit Update
7
+
8
+ Update an installed consumer only from the public scoped package
9
+ `@ikon85/agent-workflow-kit`. The unscoped `agent-workflow-kit` package belongs
10
+ to another publisher and is never a valid update source. Existing installs from
11
+ `npx github:iKon85/agent-workflow-kit` remain valid, but updates must use the
12
+ scoped npm release so the shared release-parity checker can prove that npm and
13
+ the matching GitHub release contain the same artifact.
14
+
15
+ ## Workflow
16
+
17
+ 1. Preview the delta without writing:
18
+
19
+ ```sh
20
+ npx @ikon85/agent-workflow-kit@latest diff
21
+ ```
22
+
23
+ Review the named added, updated, locally modified, removed, and conflicting
24
+ paths. Do not reinterpret a local modification as permission to overwrite it.
25
+
26
+ 2. Apply the update:
27
+
28
+ ```sh
29
+ npx @ikon85/agent-workflow-kit@latest update
30
+ ```
31
+
32
+ The command checks npm/GitHub release parity before staging. It prepares a
33
+ complete candidate outside the consumer, runs the consumer's existing
34
+ `npm test` command there, and activates only a verified candidate. A staging
35
+ or verification failure leaves the installed tree byte-identical.
36
+
37
+ 3. Read the terminal report. `aktuell` proves a second run found no upstream
38
+ delta. A conflict report names and counts every category and leaves every
39
+ consumer file untouched. Follow its recommendation and resolve each named
40
+ conflict manually; never auto-merge, delete a local edit, or silently choose
41
+ the incoming copy.
42
+
43
+ 4. If a candidate is interrupted, discard its reported stage directory or
44
+ resume the transaction through the update API's `resumeFrom` option. Do not
45
+ copy staged files into the consumer by hand.
46
+
47
+ ## State contract
48
+
49
+ The update API reports `checking -> preview/awaiting_decision -> staging ->
50
+ verifying -> applied | conflicted | failed | aborted`. `conflicted`, `failed`,
51
+ and `aborted` never authorize partial activation. The existing manifest,
52
+ three-way diff, and atomic-write seams remain the source of truth.
@@ -19,6 +19,7 @@ Use this section as the entry-point map for agent-assisted work. The individual
19
19
  - **Implementation slice:** use `tdd` for one behavior at a time: RED, GREEN, then refactor.
20
20
  - **Finished slice:** use `wrapup` to prepare the branch, PR, and cleanup steps your repo expects.
21
21
  - **Kit release:** use `kit-release` to derive the shipped delta, confirm Semver, regenerate the manifest, run all gates, and then hand landing to `wrapup`.
22
+ - **Kit update:** use `kit-update` to preview and transactionally apply a parity-verified scoped release without overwriting local modifications.
22
23
  - **A huge, foggy effort, too big for one session:** use `wayfinder` — it charts it as a shared map of investigation tickets, resolving one per session.
23
24
 
24
25
  ## Routing Rule
@@ -384,6 +384,16 @@
384
384
  "codex"
385
385
  ],
386
386
  "provenance": "own"
387
+ },
388
+ "kit-update": {
389
+ "class": "generic",
390
+ "publish": true,
391
+ "entryPoint": true,
392
+ "surfaces": [
393
+ "claude",
394
+ "codex"
395
+ ],
396
+ "provenance": "own"
387
397
  }
388
398
  }
389
399
  }
package/README.md CHANGED
@@ -332,6 +332,13 @@ still reference. Flags: `--force` (overwrite pre-existing files on `init`),
332
332
 
333
333
  ## Release notes
334
334
 
335
+ ### 0.12.0
336
+
337
+ - added: `.agents/skills/kit-update/SKILL.md`
338
+ - added: `.claude/skills/kit-update/SKILL.md`
339
+ - changed: `.agents/skills/setup-workflow/workflow-overview.md`
340
+ - changed: `.claude/skills/setup-workflow/workflow-overview.md`
341
+
335
342
  ### 0.11.0
336
343
 
337
344
  - added: `scripts/release-parity.mjs`
@@ -1,5 +1,5 @@
1
1
  {
2
- "kitVersion": "0.11.0",
2
+ "kitVersion": "0.12.0",
3
3
  "files": [
4
4
  {
5
5
  "path": ".agents/skills/ask-matt/SKILL.md",
@@ -325,6 +325,15 @@
325
325
  "mode": 420,
326
326
  "origin": "kit"
327
327
  },
328
+ {
329
+ "path": ".agents/skills/kit-update/SKILL.md",
330
+ "kind": "skill",
331
+ "ownerSkill": "kit-update",
332
+ "surface": "codex",
333
+ "sha256": "26737ea49ac104260e56fd1604f60249ca1d59f1a2c1f22db55649833d01ebc3",
334
+ "mode": 420,
335
+ "origin": "kit"
336
+ },
328
337
  {
329
338
  "path": ".agents/skills/local-ci/SKILL.md",
330
339
  "kind": "skill",
@@ -564,7 +573,7 @@
564
573
  "kind": "skill",
565
574
  "ownerSkill": "setup-workflow",
566
575
  "surface": "codex",
567
- "sha256": "87e5ab578f1c17905b39b9cb39b515e4661f3437d96de919f1b23a5775760c4f",
576
+ "sha256": "6ce3954e4f94d112801cba0d145e4d3b6f101aca5e50e6b1f9794aa0288881a9",
568
577
  "mode": 420,
569
578
  "origin": "kit"
570
579
  },
@@ -1271,6 +1280,15 @@
1271
1280
  "mode": 420,
1272
1281
  "origin": "kit"
1273
1282
  },
1283
+ {
1284
+ "path": ".claude/skills/kit-update/SKILL.md",
1285
+ "kind": "skill",
1286
+ "ownerSkill": "kit-update",
1287
+ "surface": "claude",
1288
+ "sha256": "26737ea49ac104260e56fd1604f60249ca1d59f1a2c1f22db55649833d01ebc3",
1289
+ "mode": 420,
1290
+ "origin": "kit"
1291
+ },
1274
1292
  {
1275
1293
  "path": ".claude/skills/local-ci/SKILL.md",
1276
1294
  "kind": "skill",
@@ -1528,7 +1546,7 @@
1528
1546
  "kind": "skill",
1529
1547
  "ownerSkill": "setup-workflow",
1530
1548
  "surface": "claude",
1531
- "sha256": "87e5ab578f1c17905b39b9cb39b515e4661f3437d96de919f1b23a5775760c4f",
1549
+ "sha256": "6ce3954e4f94d112801cba0d145e4d3b6f101aca5e50e6b1f9794aa0288881a9",
1532
1550
  "mode": 420,
1533
1551
  "origin": "kit"
1534
1552
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ikon85/agent-workflow-kit",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
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": {
package/src/cli.mjs CHANGED
@@ -6,6 +6,7 @@ import { init } from './commands/init.mjs';
6
6
  import { update } from './commands/update.mjs';
7
7
  import { diff } from './commands/diff.mjs';
8
8
  import { uninstall } from './commands/uninstall.mjs';
9
+ import { createCommandAdapter } from '../scripts/release-state.mjs';
9
10
 
10
11
  const KIT_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
11
12
  const consumerRoot = process.cwd();
@@ -42,11 +43,22 @@ try {
42
43
  const ok = await p.confirm({ message: `Upstream removed ${path} — delete it locally?` });
43
44
  return ok === true;
44
45
  };
45
- const r = await update({ kitRoot: KIT_ROOT, consumerRoot, now: stamp(), decide });
46
+ const releaseIdentities = await readUpdateRelease();
47
+ const r = await update({
48
+ kitRoot: KIT_ROOT, consumerRoot, now: stamp(), decide, releaseIdentities,
49
+ });
46
50
  printPlan(r);
47
- for (const c of r.conflicts) p.note(c.diff || '(binary/!text)', `conflict (backed up): ${c.path}`);
48
- p.outro(`updated ${r.updated.length} · added ${r.added.length} · ` +
49
- `conflicts ${r.conflicts.length} · deleted ${r.deleted.length}`);
51
+ for (const c of r.conflicts) p.note(c.diff || '(binary/!text)', `conflict (not applied): ${c.path}`);
52
+ if (r.state === 'failed') throw new Error(`candidate update failed: ${r.error}`);
53
+ if (r.state === 'conflicted') {
54
+ p.note(r.report.recommendation, 'recommendation');
55
+ p.outro(`not applied · conflicts ${r.conflicts.length}`);
56
+ process.exitCode = 2;
57
+ } else if (r.status === 'current') {
58
+ p.outro(`aktuell · unchanged ${r.unchanged.length} · local modifications ${r.userModified.length}`);
59
+ } else {
60
+ p.outro(`updated ${r.updated.length} · added ${r.added.length} · deleted ${r.deleted.length}`);
61
+ }
50
62
  } else if (cmd === 'uninstall') {
51
63
  const ok = yes || (await p.confirm({ message: 'Remove kit-installed files?' })) === true;
52
64
  if (!ok) { p.cancel('Aborted.'); process.exit(0); }
@@ -68,3 +80,16 @@ function printPlan(r) {
68
80
  if (r.conflicts?.length) lines.push(`conflicts: ${r.conflicts.length}`);
69
81
  p.note(lines.join('\n') || 'no changes', 'plan');
70
82
  }
83
+
84
+ async function readUpdateRelease() {
85
+ const adapter = await createCommandAdapter({
86
+ repoRoot: KIT_ROOT,
87
+ env: { ...process.env, GH_REPO: 'iKon85/agent-workflow-kit' },
88
+ });
89
+ try {
90
+ const local = (await adapter.local()).identity;
91
+ return { local, npm: await adapter.npm(local), github: await adapter.github(local) };
92
+ } finally {
93
+ await adapter.dispose();
94
+ }
95
+ }
@@ -1,95 +1,129 @@
1
- import { readFile, access, rm } from 'node:fs/promises';
1
+ import { readFile, rm } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
- import { sha256File } from '../lib/hash.mjs';
4
- import { writeAtomic, backupFile, lineDiff } from '../lib/atomicWrite.mjs';
5
- import { hookReferenced } from '../lib/settings.mjs';
3
+ import { assertReleaseParity } from '../../scripts/release-parity.mjs';
4
+ import { activateCandidate, stageConsumer, verifyCandidate } from '../lib/updateCandidate.mjs';
5
+ import { reconcile } from '../lib/updateReconcile.mjs';
6
6
  import {
7
- readManifest, writeManifest, emptyConsumerManifest,
8
- PACKAGE_MANIFEST_NAME, CONSUMER_MANIFEST_NAME, indexByPath,
7
+ CONSUMER_MANIFEST_NAME, PACKAGE_MANIFEST_NAME, readManifest,
9
8
  } from '../lib/manifest.mjs';
10
9
 
11
- const exists = (p) => access(p).then(() => true, () => false);
10
+ const RELEASE_NAME = '@ikon85/agent-workflow-kit';
12
11
 
13
12
  /**
14
- * Reconcile an installed consumer against the current kit (three-way: installed
15
- * hash vs current file = user edit?, vs package desired = upstream change?).
16
- * - unmodified + upstream changed → atomic overwrite (`updated`)
17
- * - user-edited + upstream changed → timestamped backup + diff, NOT clobbered (`conflicts`)
18
- * - user-edited + upstream same → left as-is (`userModified`)
19
- * - new upstream file → installed (`added`)
20
- * - upstream-removed + unmodified → offered for deletion via `decide` (`deleted`/`keptDeleted`)
21
- * - a hook file referenced by the consumer's settings is never auto-deleted (R3#7)
22
- * `dryRun` classifies without writing (powers `diff`). `now` = backup stamp.
13
+ * Transactionally reconcile a consumer with a parity-proven kit release.
14
+ * checking -> preview/awaiting_decision -> staging -> verifying -> terminal state.
23
15
  */
24
- export async function update({ kitRoot, consumerRoot, now, decide = () => false, dryRun = false }) {
16
+ export async function update(options) {
17
+ const {
18
+ kitRoot, consumerRoot, decide = () => false, dryRun = false,
19
+ releaseIdentities, verify = verifyCandidate, signal, onState = () => {}, resumeFrom,
20
+ } = options;
21
+ const history = [];
22
+ const transition = async (state) => { history.push(state); await onState(state); };
23
+
24
+ await transition('checking');
25
25
  const pkg = await readManifest(join(kitRoot, PACKAGE_MANIFEST_NAME));
26
26
  if (!pkg) throw new Error('kit package manifest not found');
27
- const consumer = await readManifest(join(consumerRoot, CONSUMER_MANIFEST_NAME));
28
- if (!consumer) throw new Error('not initialised — run `init` first');
29
-
30
- const installedIdx = indexByPath(consumer, 'installed');
31
- const pkgIdx = indexByPath(pkg, 'files');
32
- const res = { unchanged: [], updated: [], conflicts: [], userModified: [], added: [], deleted: [], keptDeleted: [] };
33
- const nextInstalled = [];
34
-
35
- for (const f of pkg.files) {
36
- const dest = join(consumerRoot, f.path);
37
- const prior = installedIdx.get(f.path);
38
- const cur = (await exists(dest)) ? await sha256File(dest) : null;
39
-
40
- if (!prior || cur === null) {
41
- if (!dryRun) await writeAtomic(dest, await readFile(join(kitRoot, f.path)), f.mode);
42
- nextInstalled.push(entry(f, f.sha256));
43
- res.added.push(f.path);
44
- continue;
45
- }
46
- const userEdited = cur !== prior.installedSha256;
47
- const upstreamChanged = f.sha256 !== prior.installedSha256;
27
+ if (!dryRun) verifyRelease(releaseIdentities, pkg.kitVersion);
28
+ const consumerManifestPath = join(consumerRoot, CONSUMER_MANIFEST_NAME);
29
+ if (!await readManifest(consumerManifestPath)) {
30
+ throw new Error('not initialised — run `init` first');
31
+ }
32
+ const consumerManifestBefore = await readFile(consumerManifestPath);
48
33
 
49
- if (!userEdited && upstreamChanged) {
50
- if (!dryRun) await writeAtomic(dest, await readFile(join(kitRoot, f.path)), f.mode);
51
- nextInstalled.push(entry(f, f.sha256));
52
- res.updated.push(f.path);
53
- } else if (userEdited && upstreamChanged) {
54
- const incoming = await readFile(join(kitRoot, f.path), 'utf8');
55
- const diff = lineDiff(await readFile(dest, 'utf8'), incoming);
56
- if (!dryRun) await backupFile(dest, now);
57
- nextInstalled.push(prior); // keep installed-hash baseline; file stays user's
58
- res.conflicts.push({ path: f.path, diff });
59
- } else if (userEdited) {
60
- nextInstalled.push(prior);
61
- res.userModified.push(f.path);
62
- } else {
63
- nextInstalled.push(prior);
64
- res.unchanged.push(f.path);
65
- }
34
+ const decisions = new Map();
35
+ const choose = async (_action, path) => {
36
+ if (!decisions.has(path)) decisions.set(path, await decide('delete', path));
37
+ return decisions.get(path);
38
+ };
39
+ const preview = await reconcile({ kitRoot, consumerRoot, decide: choose, dryRun: true });
40
+ await transition('preview');
41
+ if (preview.deleted.length || preview.keptDeleted.length) await transition('awaiting_decision');
42
+ if (dryRun) return { ...preview, state: 'preview', history };
43
+ if (preview.conflicts.length) return terminal(preview, 'conflicted', history, transition);
44
+ if (!hasUpstreamDelta(preview)) {
45
+ return { ...await terminal(preview, 'applied', history, transition), status: 'current' };
66
46
  }
47
+ return applyTransaction({
48
+ kitRoot, consumerRoot, pkg, preview, decisions, verify, signal, resumeFrom,
49
+ consumerManifestBefore, history, transition,
50
+ });
51
+ }
67
52
 
68
- // upstream-removed files (in consumer manifest, gone from package)
69
- for (const prior of consumer.installed) {
70
- if (pkgIdx.has(prior.path)) continue;
71
- const dest = join(consumerRoot, prior.path);
72
- const cur = (await exists(dest)) ? await sha256File(dest) : null;
73
- const userEdited = cur !== null && cur !== prior.installedSha256;
74
- const referenced = prior.kind === 'hook' && (await hookReferenced(consumerRoot, prior.path));
75
- if (userEdited || referenced || !(await decide('delete', prior.path))) {
76
- nextInstalled.push(prior);
77
- res.keptDeleted.push(prior.path);
78
- } else {
79
- if (!dryRun && cur !== null) await rm(dest);
80
- res.deleted.push(prior.path);
53
+ async function applyTransaction(context) {
54
+ const {
55
+ kitRoot, consumerRoot, pkg, preview, decisions, verify, signal, resumeFrom,
56
+ consumerManifestBefore, history, transition,
57
+ } = context;
58
+ let candidateRoot = resumeFrom;
59
+ let keepCandidate = false;
60
+ try {
61
+ await transition('staging');
62
+ if (!candidateRoot) {
63
+ candidateRoot = await stageConsumer(consumerRoot);
64
+ await reconcile({
65
+ kitRoot, consumerRoot: candidateRoot,
66
+ decide: (_action, path) => decisions.get(path) === true,
67
+ });
81
68
  }
69
+ await transition('verifying');
70
+ const abort = async () => {
71
+ keepCandidate = true;
72
+ return { ...await terminal(preview, 'aborted', history, transition), candidateRoot };
73
+ };
74
+ if (signal?.aborted) return abort();
75
+ await verify(candidateRoot);
76
+ if (signal?.aborted) return abort();
77
+ await activateCandidate({
78
+ candidateRoot, consumerRoot, pkg, preview, consumerManifestBefore,
79
+ });
80
+ return { ...await terminal(preview, 'applied', history, transition), status: 'updated' };
81
+ } catch (error) {
82
+ return { ...await terminal(preview, 'failed', history, transition), error: error.message };
83
+ } finally {
84
+ if (candidateRoot && !keepCandidate) await rm(candidateRoot, { recursive: true, force: true });
82
85
  }
86
+ }
83
87
 
84
- if (!dryRun) {
85
- await writeManifest(
86
- join(consumerRoot, CONSUMER_MANIFEST_NAME),
87
- { ...emptyConsumerManifest(pkg.kitVersion), installed: nextInstalled }
88
- );
88
+ function verifyRelease(identities, kitVersion) {
89
+ const release = assertReleaseParity(identities);
90
+ if (release.name !== RELEASE_NAME) throw new Error(`invalid release origin: ${release.name}`);
91
+ if (release.version !== kitVersion) {
92
+ throw new Error(`release version ${release.version} does not match kit ${kitVersion}`);
89
93
  }
90
- return res;
91
94
  }
92
95
 
93
- function entry(f, sha) {
94
- return { path: f.path, kind: f.kind, ownerSkill: f.ownerSkill, surface: f.surface, installedSha256: sha, origin: 'kit' };
96
+ function hasUpstreamDelta(result) {
97
+ return result.added.length + result.updated.length + result.deleted.length > 0;
95
98
  }
99
+
100
+ async function terminal(result, state, history, transition) {
101
+ await transition(state);
102
+ return {
103
+ ...result,
104
+ state,
105
+ history,
106
+ report: {
107
+ unchanged: result.unchanged.length,
108
+ added: result.added.length,
109
+ updated: result.updated.length,
110
+ deleted: result.deleted.length,
111
+ localModified: result.userModified.length,
112
+ conflicts: result.conflicts.length,
113
+ keptDeleted: result.keptDeleted.length,
114
+ paths: {
115
+ added: result.added,
116
+ updated: result.updated,
117
+ deleted: result.deleted,
118
+ localModified: result.userModified,
119
+ conflicts: result.conflicts.map(({ path }) => path),
120
+ keptDeleted: result.keptDeleted,
121
+ },
122
+ recommendation: result.conflicts.length
123
+ ? 'Review each named conflict; keep the local file or apply the incoming diff manually.'
124
+ : null,
125
+ },
126
+ };
127
+ }
128
+
129
+ export { verifyCandidate } from '../lib/updateCandidate.mjs';
@@ -0,0 +1,101 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { access, cp, mkdtemp, readFile, rm, stat, symlink } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import { join, relative } from 'node:path';
5
+ import { promisify } from 'node:util';
6
+ import { writeAtomic } from './atomicWrite.mjs';
7
+ import { sha256File } from './hash.mjs';
8
+ import { CONSUMER_MANIFEST_NAME, indexByPath, readManifest } from './manifest.mjs';
9
+
10
+ const run = promisify(execFile);
11
+ const exists = (path) => access(path).then(() => true, () => false);
12
+
13
+ /** Copy a verification candidate without duplicating git metadata or dependencies. */
14
+ export async function stageConsumer(consumerRoot) {
15
+ const candidateRoot = await mkdtemp(join(tmpdir(), 'agent-workflow-kit-stage-'));
16
+ const nodeModules = join(consumerRoot, 'node_modules');
17
+ await cp(consumerRoot, candidateRoot, {
18
+ recursive: true,
19
+ filter: (source) => {
20
+ const rel = relative(consumerRoot, source);
21
+ return rel !== '.git' && !rel.startsWith('.git/') &&
22
+ rel !== 'node_modules' && !rel.startsWith('node_modules/');
23
+ },
24
+ });
25
+ if (await exists(nodeModules)) await symlink(nodeModules, join(candidateRoot, 'node_modules'), 'dir');
26
+ return candidateRoot;
27
+ }
28
+
29
+ /** Activate only verified kit-owned deltas, rolling every touched path back on failure. */
30
+ export async function activateCandidate({
31
+ candidateRoot, consumerRoot, pkg, preview, consumerManifestBefore,
32
+ }) {
33
+ const changed = [...preview.added, ...preview.updated];
34
+ const touched = [...changed, ...preview.deleted, CONSUMER_MANIFEST_NAME];
35
+ const currentManifest = await readFile(join(consumerRoot, CONSUMER_MANIFEST_NAME));
36
+ if (!currentManifest.equals(consumerManifestBefore)) {
37
+ throw new Error('consumer manifest changed during verification');
38
+ }
39
+ const pkgIdx = indexByPath(pkg, 'files');
40
+ for (const path of changed) {
41
+ if (await sha256File(join(candidateRoot, path)) !== pkgIdx.get(path)?.sha256) {
42
+ throw new Error(`candidate hash mismatch: ${path}`);
43
+ }
44
+ }
45
+ await assertConsumerStillMatchesPreview(consumerRoot, preview);
46
+ const rollback = new Map();
47
+ for (const path of touched) rollback.set(path, await snapshot(join(consumerRoot, path)));
48
+ try {
49
+ for (const path of changed) {
50
+ await writeAtomic(join(consumerRoot, path), await readFile(join(candidateRoot, path)), pkgIdx.get(path)?.mode);
51
+ }
52
+ for (const path of preview.deleted) await rm(join(consumerRoot, path), { force: true });
53
+ await writeAtomic(
54
+ join(consumerRoot, CONSUMER_MANIFEST_NAME),
55
+ await readFile(join(candidateRoot, CONSUMER_MANIFEST_NAME)),
56
+ );
57
+ } catch (error) {
58
+ for (const path of touched.reverse()) await restore(join(consumerRoot, path), rollback.get(path));
59
+ throw error;
60
+ }
61
+ }
62
+
63
+ async function assertConsumerStillMatchesPreview(consumerRoot, preview) {
64
+ const manifest = await readManifest(join(consumerRoot, CONSUMER_MANIFEST_NAME));
65
+ const installed = indexByPath(manifest, 'installed');
66
+ for (const path of preview.added) {
67
+ if (await exists(join(consumerRoot, path))) throw new Error(`consumer changed during verification: ${path}`);
68
+ }
69
+ for (const path of [...preview.updated, ...preview.deleted]) {
70
+ const prior = installed.get(path);
71
+ const current = await exists(join(consumerRoot, path))
72
+ ? await sha256File(join(consumerRoot, path)) : null;
73
+ if (!prior || current !== prior.installedSha256) {
74
+ throw new Error(`consumer changed during verification: ${path}`);
75
+ }
76
+ }
77
+ }
78
+
79
+ async function snapshot(path) {
80
+ if (!await exists(path)) return null;
81
+ const info = await stat(path);
82
+ return { bytes: await readFile(path), mode: info.mode };
83
+ }
84
+
85
+ async function restore(path, saved) {
86
+ if (!saved) return rm(path, { force: true });
87
+ await writeAtomic(path, saved.bytes, saved.mode);
88
+ }
89
+
90
+ /** Default candidate gate: run the consumer's existing npm test command. */
91
+ export async function verifyCandidate(candidateRoot) {
92
+ let pkg;
93
+ try {
94
+ pkg = JSON.parse(await readFile(join(candidateRoot, 'package.json'), 'utf8'));
95
+ } catch (error) {
96
+ if (error.code === 'ENOENT') throw new Error('candidate has no package.json test command');
97
+ throw error;
98
+ }
99
+ if (!pkg.scripts?.test) throw new Error('candidate has no package.json test command');
100
+ await run('npm', ['test'], { cwd: candidateRoot });
101
+ }
@@ -0,0 +1,89 @@
1
+ import { access, readFile, rm } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { sha256File } from './hash.mjs';
4
+ import { lineDiff, writeAtomic } from './atomicWrite.mjs';
5
+ import { hookReferenced } from './settings.mjs';
6
+ import {
7
+ CONSUMER_MANIFEST_NAME, PACKAGE_MANIFEST_NAME, emptyConsumerManifest,
8
+ indexByPath, readManifest, writeManifest,
9
+ } from './manifest.mjs';
10
+
11
+ const exists = (path) => access(path).then(() => true, () => false);
12
+
13
+ /** Classify or apply one three-way reconcile inside a supplied root. */
14
+ export async function reconcile({ kitRoot, consumerRoot, decide = () => false, dryRun = false }) {
15
+ const pkg = await readManifest(join(kitRoot, PACKAGE_MANIFEST_NAME));
16
+ if (!pkg) throw new Error('kit package manifest not found');
17
+ const consumer = await readManifest(join(consumerRoot, CONSUMER_MANIFEST_NAME));
18
+ if (!consumer) throw new Error('not initialised — run `init` first');
19
+
20
+ const installedIdx = indexByPath(consumer, 'installed');
21
+ const pkgIdx = indexByPath(pkg, 'files');
22
+ const result = emptyResult();
23
+ const nextInstalled = [];
24
+
25
+ for (const file of pkg.files) {
26
+ const dest = join(consumerRoot, file.path);
27
+ const prior = installedIdx.get(file.path);
28
+ const current = (await exists(dest)) ? await sha256File(dest) : null;
29
+ if (!prior || current === null) {
30
+ if (!dryRun) await writeAtomic(dest, await readFile(join(kitRoot, file.path)), file.mode);
31
+ nextInstalled.push(entry(file, file.sha256));
32
+ result.added.push(file.path);
33
+ continue;
34
+ }
35
+ const userEdited = current !== prior.installedSha256;
36
+ const upstreamChanged = file.sha256 !== prior.installedSha256;
37
+ if (!userEdited && upstreamChanged) {
38
+ if (!dryRun) await writeAtomic(dest, await readFile(join(kitRoot, file.path)), file.mode);
39
+ nextInstalled.push(entry(file, file.sha256));
40
+ result.updated.push(file.path);
41
+ } else if (userEdited && upstreamChanged) {
42
+ const incoming = await readFile(join(kitRoot, file.path), 'utf8');
43
+ result.conflicts.push({ path: file.path, diff: lineDiff(await readFile(dest, 'utf8'), incoming) });
44
+ nextInstalled.push(prior);
45
+ } else if (userEdited) {
46
+ nextInstalled.push(prior);
47
+ result.userModified.push(file.path);
48
+ } else {
49
+ nextInstalled.push(prior);
50
+ result.unchanged.push(file.path);
51
+ }
52
+ }
53
+
54
+ for (const prior of consumer.installed) {
55
+ if (pkgIdx.has(prior.path)) continue;
56
+ const dest = join(consumerRoot, prior.path);
57
+ const current = (await exists(dest)) ? await sha256File(dest) : null;
58
+ const userEdited = current !== null && current !== prior.installedSha256;
59
+ const referenced = prior.kind === 'hook' && (await hookReferenced(consumerRoot, prior.path));
60
+ if (userEdited || referenced || !(await decide('delete', prior.path))) {
61
+ nextInstalled.push(prior);
62
+ result.keptDeleted.push(prior.path);
63
+ } else {
64
+ if (!dryRun && current !== null) await rm(dest);
65
+ result.deleted.push(prior.path);
66
+ }
67
+ }
68
+
69
+ if (!dryRun) {
70
+ await writeManifest(join(consumerRoot, CONSUMER_MANIFEST_NAME), {
71
+ ...emptyConsumerManifest(pkg.kitVersion), installed: nextInstalled,
72
+ });
73
+ }
74
+ return result;
75
+ }
76
+
77
+ function emptyResult() {
78
+ return {
79
+ unchanged: [], updated: [], conflicts: [], userModified: [],
80
+ added: [], deleted: [], keptDeleted: [],
81
+ };
82
+ }
83
+
84
+ function entry(file, installedSha256) {
85
+ return {
86
+ path: file.path, kind: file.kind, ownerSkill: file.ownerSkill, surface: file.surface,
87
+ installedSha256, origin: 'kit',
88
+ };
89
+ }