@ikon85/agent-workflow-kit 0.10.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,106 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { readFile } from 'node:fs/promises';
4
+ import { dirname, join } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import {
7
+ createCommandAdapter, githubReleaseArgs, inspectRelease, isMissingRelease,
8
+ npmTarballFilename, reconcileRelease,
9
+ } from './release-state.mjs';
10
+
11
+ const REPO = join(dirname(fileURLToPath(import.meta.url)), '..');
12
+
13
+ const identity = {
14
+ name: '@ikon85/agent-workflow-kit', version: '1.2.3',
15
+ tarballIntegrity: 'sha512-example', manifestSha256: 'abc123',
16
+ };
17
+
18
+ function adapter({ npmPublished = false, githubReleased = false, npmIdentity = identity } = {}) {
19
+ const events = [];
20
+ let npmExists = npmPublished;
21
+ let githubExists = githubReleased;
22
+ return {
23
+ events,
24
+ local: async () => ({ identity, tarball: '/tmp/kit.tgz' }),
25
+ npm: async () => { events.push('read npm'); return npmExists ? npmIdentity : null; },
26
+ github: async () => { events.push('read github'); return githubExists ? identity : null; },
27
+ publishNpm: async () => { events.push('publish npm'); npmExists = true; },
28
+ createGithub: async () => { events.push('create github'); githubExists = true; },
29
+ };
30
+ }
31
+
32
+ test('a fresh release publishes npm, verifies registry readback, then creates GitHub release', async () => {
33
+ const fixture = adapter();
34
+ assert.deepEqual(await reconcileRelease(fixture), { status: 'released', identity });
35
+ assert.deepEqual(fixture.events, [
36
+ 'read npm', 'read github', 'publish npm', 'read npm', 'create github', 'read github',
37
+ ]);
38
+ });
39
+
40
+ test('an npm-published release resumes at GitHub without a second npm publish', async () => {
41
+ const fixture = adapter({ npmPublished: true });
42
+ await reconcileRelease(fixture);
43
+ assert.deepEqual(fixture.events, ['read npm', 'read github', 'create github', 'read github']);
44
+ assert.ok(!fixture.events.includes('publish npm'));
45
+ });
46
+
47
+ test('post-merge status inspection is read-only and reports the reconstructable phase', async () => {
48
+ const fixture = adapter({ npmPublished: true });
49
+ assert.deepEqual(await inspectRelease(fixture), { status: 'awaiting-github', identity });
50
+ assert.deepEqual(fixture.events, ['read npm', 'read github']);
51
+ });
52
+
53
+ test('an unpublished npm version is reconstructable when npm reports ETARGET', () => {
54
+ assert.equal(isMissingRelease({
55
+ stderr: 'npm error code ETARGET\nnpm error notarget No matching version found',
56
+ }, 'npm'), true);
57
+ });
58
+
59
+ test('a mismatching npm package blocks GitHub release creation', async () => {
60
+ const fixture = adapter({
61
+ npmPublished: true,
62
+ npmIdentity: { ...identity, manifestSha256: 'wrong' },
63
+ });
64
+ await assert.rejects(reconcileRelease(fixture), /npm manifestSha256 mismatch/);
65
+ assert.ok(!fixture.events.includes('create github'));
66
+ });
67
+
68
+ test('release workflow uses supported OIDC trusted publishing without a long-lived npm token', async () => {
69
+ const workflow = await readFile(join(REPO, '.github/workflows/release.yml'), 'utf8');
70
+ assert.match(workflow, /node-version:\s*22\.14/);
71
+ assert.match(workflow, /npm@11\.5\.1/);
72
+ assert.match(workflow, /id-token:\s*write/);
73
+ assert.match(workflow, /contents:\s*write/);
74
+ assert.match(workflow, /registry-url:\s*['"]https:\/\/registry\.npmjs\.org['"]/);
75
+ assert.match(workflow, /node scripts\/release-state\.mjs/);
76
+ assert.doesNotMatch(workflow, /NPM_TOKEN|NODE_AUTH_TOKEN|npm_[A-Za-z0-9]/);
77
+ });
78
+
79
+ test('the production publisher explicitly requests npm provenance', async () => {
80
+ const commands = [];
81
+ const fixture = await createCommandAdapter({
82
+ repoRoot: REPO,
83
+ run: async (command, args) => { commands.push([command, ...args]); return { stdout: '' }; },
84
+ });
85
+ try {
86
+ await fixture.publishNpm({ tarball: '/tmp/kit.tgz' });
87
+ assert.deepEqual(commands, [[
88
+ 'npm', 'publish', '/tmp/kit.tgz', '--access', 'public', '--provenance',
89
+ ]]);
90
+ } finally { await fixture.dispose(); }
91
+ });
92
+
93
+ test('an incomplete GitHub release resumes by uploading the verified registry tarball', () => {
94
+ assert.deepEqual(githubReleaseArgs({
95
+ exists: true, tag: 'v1.2.3', tarball: '/tmp/ikon85-agent-workflow-kit-1.2.3.tgz', target: 'abc123',
96
+ }), [
97
+ 'release', 'upload', 'v1.2.3', '/tmp/ikon85-agent-workflow-kit-1.2.3.tgz', '--clobber',
98
+ ]);
99
+ });
100
+
101
+ test('a scoped npm identity maps to the registry tarball asset name', () => {
102
+ assert.equal(
103
+ npmTarballFilename('@ikon85/agent-workflow-kit', '1.2.3'),
104
+ 'ikon85-agent-workflow-kit-1.2.3.tgz',
105
+ );
106
+ });
@@ -0,0 +1,63 @@
1
+ """Contract tests for the user-visible Program planning route."""
2
+
3
+ from pathlib import Path
4
+ import unittest
5
+
6
+
7
+ ROOT = Path(__file__).resolve().parents[1]
8
+
9
+
10
+ class ProgramPlanningContractTest(unittest.TestCase):
11
+ def skill(self, name: str) -> str:
12
+ return (ROOT / ".claude" / "skills" / name / "SKILL.md").read_text()
13
+
14
+ def test_to_waves_finishes_with_every_wave_execute_ready_by_default(self):
15
+ text = self.skill("to-waves")
16
+ self.assertIn("## 7. Program completion contract", text)
17
+ self.assertIn("every published wave", text)
18
+ self.assertIn("execute-ready-check.py", text)
19
+ self.assertIn("X von Y Wellen ausführungsreif", text)
20
+
21
+ def test_program_preview_is_the_single_default_user_gate(self):
22
+ text = self.skill("to-waves")
23
+ self.assertIn("single user approval", text)
24
+ self.assertIn("do not ask for another per-wave approval", text)
25
+
26
+ def test_unresolved_work_is_modeled_as_an_explicit_gate_not_generic_late_binding(self):
27
+ text = self.skill("to-waves")
28
+ self.assertIn("Decision Gate", text)
29
+ self.assertIn("Verify Spike", text)
30
+ self.assertIn("Design-Grill", text)
31
+ self.assertIn("Late Binding is not the default", text)
32
+
33
+ def test_to_issues_supports_program_batch_and_keeps_human_actions_out_of_afk(self):
34
+ text = self.skill("to-issues")
35
+ self.assertIn("Program batch handoff", text)
36
+ self.assertIn("A mandatory human or external setup action is never AFK", text)
37
+
38
+ def test_atomic_program_wave_preserves_its_program_identity(self):
39
+ text = self.skill("to-issues")
40
+ self.assertIn("Program-batch atomic exception", text)
41
+ self.assertIn("preserve its existing Wave and Phase", text)
42
+
43
+ def test_atomic_program_wave_never_leaves_a_preliminary_child(self):
44
+ waves = self.skill("to-waves")
45
+ issues = self.skill("to-issues")
46
+ self.assertIn("do not create a preliminary child", waves)
47
+ self.assertIn("legacy or interrupted preliminary child", issues)
48
+ self.assertIn("superseded by atomic wave leaf", issues)
49
+ self.assertIn("Atomic supersession", waves)
50
+ self.assertIn("matching source marker remains discoverable after unlink", issues)
51
+
52
+ def test_program_completion_treats_audit_findings_as_blocking(self):
53
+ text = self.skill("to-waves")
54
+ self.assertIn("Program completion gate", text)
55
+ self.assertIn("do not count that wave as matured", text)
56
+
57
+ def test_public_program_route_promises_execute_ready_waves(self):
58
+ text = self.skill("scale-check")
59
+ self.assertIn("all waves execute-ready by default", text)
60
+
61
+
62
+ if __name__ == "__main__":
63
+ unittest.main()
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';
@@ -42,6 +42,11 @@ export const HELPER_FILES = [
42
42
  // Both are invoked through package scripts by /kit-release.
43
43
  { path: 'scripts/kit-release.mjs', kind: 'script', mode: 0o644 },
44
44
  { path: 'scripts/release-delta-guard.mjs', kind: 'script', mode: 0o644 },
45
+ // Neutral publish/readback parity and externally reconstructable release state.
46
+ // /kit-release uses these after merge; downstream update/consumer flows reuse
47
+ // the parity primitive rather than growing a second registry/GitHub comparison.
48
+ { path: 'scripts/release-parity.mjs', kind: 'script', mode: 0o644 },
49
+ { path: 'scripts/release-state.mjs', kind: 'script', mode: 0o644 },
45
50
  // Shared hook utility imported by the shipped hooks (drift-guard,
46
51
  // sync-board-status). Library (imported, not run) → 0o644. MUST ship or those
47
52
  // hooks ImportError on arrival.
@@ -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
+ }