@ikon85/agent-workflow-kit 0.27.1 → 0.28.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.
Files changed (57) hide show
  1. package/.agents/skills/ask-matt/SKILL.md +2 -2
  2. package/.agents/skills/grill-me/SKILL.md +1 -1
  3. package/.agents/skills/grill-with-docs/SKILL.md +1 -1
  4. package/.agents/skills/kit-update/SKILL.md +12 -0
  5. package/.agents/skills/orchestrate-wave/SKILL.md +68 -21
  6. package/.agents/skills/orchestrate-wave/references/builder-contract.md +6 -4
  7. package/.agents/skills/retro/SKILL.md +24 -1
  8. package/.agents/skills/scale-check/SKILL.md +2 -2
  9. package/.agents/skills/setup-workflow/SKILL.md +44 -1
  10. package/.agents/skills/setup-workflow/workflow-overview.md +5 -5
  11. package/.agents/skills/tdd/SKILL.md +68 -14
  12. package/.agents/skills/to-issues/SKILL.md +2 -2
  13. package/.agents/skills/verify-spike/SKILL.md +1 -1
  14. package/.agents/skills/wrapup/SKILL.md +14 -2
  15. package/.claude/hooks/drift-guard.py +5 -2
  16. package/.claude/hooks/kit-origin-edit-hint.py +64 -0
  17. package/.claude/skills/ask-matt/SKILL.md +2 -2
  18. package/.claude/skills/grill-me/SKILL.md +1 -1
  19. package/.claude/skills/grill-with-docs/SKILL.md +1 -1
  20. package/.claude/skills/kit-update/SKILL.md +12 -0
  21. package/.claude/skills/orchestrate-wave/SKILL.md +68 -21
  22. package/.claude/skills/orchestrate-wave/references/builder-contract.md +6 -4
  23. package/.claude/skills/retro/SKILL.md +23 -0
  24. package/.claude/skills/scale-check/SKILL.md +2 -2
  25. package/.claude/skills/setup-workflow/SKILL.md +44 -1
  26. package/.claude/skills/setup-workflow/workflow-overview.md +5 -5
  27. package/.claude/skills/skill-manifest.json +1 -1
  28. package/.claude/skills/tdd/SKILL.md +68 -14
  29. package/.claude/skills/to-issues/SKILL.md +2 -2
  30. package/.claude/skills/verify-spike/SKILL.md +1 -1
  31. package/.claude/skills/wrapup/SKILL.md +14 -2
  32. package/README.md +47 -7
  33. package/agent-workflow-kit.package.json +49 -33
  34. package/docs/adr/0001-consumer-divergence-policy.md +49 -0
  35. package/docs/agents/wave-anchor-template.md +1 -1
  36. package/package.json +1 -1
  37. package/scripts/board-sync.py +184 -5
  38. package/scripts/pr-body-check.py +34 -6
  39. package/scripts/pr_body_e2e.py +83 -0
  40. package/scripts/test_board_sync.py +208 -0
  41. package/scripts/test_census_backstop.py +56 -3
  42. package/scripts/test_orchestrate_wave_contract.py +116 -0
  43. package/scripts/test_pr_body_check.py +245 -0
  44. package/scripts/test_retro_wrapup_contract.py +111 -0
  45. package/scripts/test_tdd_contract.py +78 -0
  46. package/src/cli.mjs +44 -8
  47. package/src/commands/diff.mjs +5 -2
  48. package/src/commands/init.mjs +12 -0
  49. package/src/commands/own.mjs +16 -0
  50. package/src/commands/uninstall.mjs +8 -1
  51. package/src/commands/update.mjs +37 -9
  52. package/src/lib/bundle.mjs +4 -0
  53. package/src/lib/consumerPath.mjs +30 -0
  54. package/src/lib/manifest.mjs +18 -0
  55. package/src/lib/ownedDiff.mjs +88 -0
  56. package/src/lib/updateCandidate.mjs +14 -0
  57. package/src/lib/updateReconcile.mjs +45 -6
package/src/cli.mjs CHANGED
@@ -6,6 +6,8 @@ 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 { setOwnership } from './commands/own.mjs';
10
+ import { CONSUMER_ORIGIN, KIT_ORIGIN } from './lib/manifest.mjs';
9
11
  import { createCommandAdapter } from '../scripts/release-state.mjs';
10
12
  import { installedIdentityFromDir } from '../scripts/release-parity.mjs';
11
13
 
@@ -21,6 +23,7 @@ const args = process.argv.slice(2);
21
23
  const cmd = args[0];
22
24
  const force = args.includes('--force');
23
25
  const yes = args.includes('--yes') || args.includes('-y');
26
+ const owned = args.includes('--owned');
24
27
 
25
28
  p.intro('agent-workflow-kit');
26
29
 
@@ -35,15 +38,11 @@ try {
35
38
  p.outro('Next: run /setup-workflow to fill the project layer + board profile. ' +
36
39
  'To enable the drift-guard hook, add .claude/hooks/drift-guard.py to your settings.json hooks.');
37
40
  } else if (cmd === 'diff') {
38
- const r = await diff({ kitRoot: KIT_ROOT, consumerRoot });
41
+ const r = await diff({ kitRoot: KIT_ROOT, consumerRoot, owned });
39
42
  printPlan(r);
40
43
  p.outro('Dry run — nothing written. Run `update` to apply.');
41
44
  } else if (cmd === 'update') {
42
- const decide = async (_action, path) => {
43
- if (yes) return true;
44
- const ok = await p.confirm({ message: `Upstream removed ${path} — delete it locally?` });
45
- return ok === true;
46
- };
45
+ const decide = (action, path) => decideUpdate(action, path, yes);
47
46
  const releaseIdentities = await readUpdateRelease();
48
47
  const r = await update({
49
48
  kitRoot: KIT_ROOT, consumerRoot, now: stamp(), decide, releaseIdentities,
@@ -65,8 +64,13 @@ try {
65
64
  if (!ok) { p.cancel('Aborted.'); process.exit(0); }
66
65
  const r = await uninstall({ consumerRoot });
67
66
  p.outro(`removed ${r.removed.length} · retained (edited/referenced) ${r.retained.length}`);
67
+ } else if (cmd === 'own' || cmd === 'disown') {
68
+ if (!args[1]) throw new Error(`Usage: agent-workflow-kit ${cmd} <path>`);
69
+ const origin = cmd === 'own' ? CONSUMER_ORIGIN : KIT_ORIGIN;
70
+ await setOwnership({ consumerRoot, path: args[1], origin });
71
+ p.outro(`${args[1]} is now ${origin}-owned`);
68
72
  } else {
69
- p.note('Usage: agent-workflow-kit <init|update|diff|uninstall> [--force] [--yes]');
73
+ p.note('Usage: agent-workflow-kit <init|update|diff|uninstall|own|disown> [<path>] [--force] [--yes] [--owned]');
70
74
  p.outro('');
71
75
  }
72
76
  } catch (err) {
@@ -76,12 +80,44 @@ try {
76
80
 
77
81
  function printPlan(r) {
78
82
  const lines = [];
79
- for (const k of ['added', 'updated', 'userModified', 'unchanged', 'deleted', 'keptDeleted'])
83
+ for (const k of [
84
+ 'added', 'updated', 'userModified', 'consumerOwned', 'unchanged',
85
+ 'deleted', 'keptDeleted', 'collisions',
86
+ ])
80
87
  if (r[k]?.length) lines.push(`${k}: ${r[k].length}`);
81
88
  if (r.conflicts?.length) lines.push(`conflicts: ${r.conflicts.length}`);
89
+ for (const owned of r.ownedDiffs ?? []) {
90
+ lines.push(`${owned.state} ${owned.path}`);
91
+ if (owned.binary) {
92
+ lines.push(` local: ${owned.local.size} bytes sha256:${owned.local.sha256}`);
93
+ lines.push(` upstream: ${owned.upstream.size} bytes sha256:${owned.upstream.sha256}`);
94
+ } else if (owned.diff) {
95
+ lines.push(owned.diff);
96
+ }
97
+ }
82
98
  p.note(lines.join('\n') || 'no changes', 'plan');
83
99
  }
84
100
 
101
+ async function decideUpdate(action, path, yes) {
102
+ if (action === 'delete') {
103
+ if (yes) return true;
104
+ return (await p.confirm({ message: `Upstream removed ${path} — delete it locally?` })) === true;
105
+ }
106
+ if (action === 'collision') {
107
+ if (yes) return 'replace';
108
+ const choice = await p.select({
109
+ message: `${path} already exists but is not tracked. Choose its ownership:`,
110
+ options: [
111
+ { value: 'keep-as-owned', label: 'Keep existing file as consumer-owned' },
112
+ { value: 'replace', label: 'Replace with kit file' },
113
+ ],
114
+ });
115
+ if (p.isCancel(choice)) throw new Error(`collision decision cancelled for ${path}`);
116
+ return choice;
117
+ }
118
+ throw new Error(`unknown update decision action: ${action}`);
119
+ }
120
+
85
121
  async function readUpdateRelease() {
86
122
  const adapter = await createCommandAdapter({
87
123
  repoRoot: KIT_ROOT,
@@ -1,6 +1,9 @@
1
1
  import { update } from './update.mjs';
2
+ import { ownedDiff } from '../lib/ownedDiff.mjs';
2
3
 
3
4
  /** Dry-run of `update`: classifies what an update would do, writes nothing. */
4
- export async function diff({ kitRoot, consumerRoot }) {
5
- return update({ kitRoot, consumerRoot, now: 'dry', dryRun: true });
5
+ export async function diff({ kitRoot, consumerRoot, owned = false }) {
6
+ const preview = await update({ kitRoot, consumerRoot, now: 'dry', dryRun: true });
7
+ if (!owned) return preview;
8
+ return { ...preview, ownedDiffs: await ownedDiff({ kitRoot, consumerRoot }) };
6
9
  }
@@ -8,6 +8,7 @@ import {
8
8
  readManifest, writeManifest, emptyConsumerManifest,
9
9
  filesForInstallRole, CONSUMER_INSTALL_ROLE,
10
10
  indexByPath,
11
+ CONSUMER_ORIGIN,
11
12
  PACKAGE_MANIFEST_NAME, CONSUMER_MANIFEST_NAME,
12
13
  } from '../lib/manifest.mjs';
13
14
 
@@ -27,12 +28,19 @@ export async function init({ kitRoot, consumerRoot, force = false }) {
27
28
  if (!pkg) throw new Error('kit package manifest not found');
28
29
  const prior = await readManifest(join(consumerRoot, CONSUMER_MANIFEST_NAME));
29
30
  const tracked = new Set((prior?.installed ?? []).map((e) => e.path));
31
+ const consumerOwned = new Set(
32
+ (prior?.installed ?? []).filter((e) => e.origin === CONSUMER_ORIGIN).map((e) => e.path),
33
+ );
30
34
  const packageIdx = indexByPath(pkg, 'files');
31
35
 
32
36
  const result = { copied: [], skipped: [], seeded: [] };
33
37
  const installed = [];
34
38
 
35
39
  for (const entry of prior?.installed ?? []) {
40
+ if (entry.origin === CONSUMER_ORIGIN) {
41
+ installed.push(entry);
42
+ continue;
43
+ }
36
44
  const packageEntry = packageIdx.get(entry.path);
37
45
  if (packageEntry?.installRole === CONSUMER_INSTALL_ROLE || !packageEntry?.installRole) continue;
38
46
  if (!await exists(join(consumerRoot, entry.path))) continue;
@@ -41,6 +49,10 @@ export async function init({ kitRoot, consumerRoot, force = false }) {
41
49
 
42
50
  for (const f of filesForInstallRole(pkg)) {
43
51
  const dest = join(consumerRoot, f.path);
52
+ if (consumerOwned.has(f.path)) {
53
+ result.skipped.push(f.path);
54
+ continue;
55
+ }
44
56
  if (await exists(dest) && !tracked.has(f.path) && !force) {
45
57
  result.skipped.push(f.path); // pre-existing untracked → never-clobber
46
58
  continue;
@@ -0,0 +1,16 @@
1
+ import { join } from 'node:path';
2
+ import { validateConsumerFile } from '../lib/consumerPath.mjs';
3
+ import {
4
+ CONSUMER_MANIFEST_NAME, readManifest, withOrigin, writeManifest,
5
+ } from '../lib/manifest.mjs';
6
+
7
+ /** Mark one tracked consumer file as kit- or consumer-owned. */
8
+ export async function setOwnership({ consumerRoot, path, origin }) {
9
+ const manifestPath = join(consumerRoot, CONSUMER_MANIFEST_NAME);
10
+ const manifest = await readManifest(manifestPath);
11
+ if (!manifest) throw new Error('not initialised — run `init` first');
12
+ const next = withOrigin(manifest, path, origin);
13
+ await validateConsumerFile(consumerRoot, path);
14
+ await writeManifest(manifestPath, next);
15
+ return { path, origin };
16
+ }
@@ -2,7 +2,10 @@ import { access, rm } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
3
  import { sha256File } from '../lib/hash.mjs';
4
4
  import { hookReferenced } from '../lib/settings.mjs';
5
- import { writeManifest, readManifest, emptyConsumerManifest, CONSUMER_MANIFEST_NAME } from '../lib/manifest.mjs';
5
+ import {
6
+ writeManifest, readManifest, emptyConsumerManifest,
7
+ CONSUMER_MANIFEST_NAME, CONSUMER_ORIGIN,
8
+ } from '../lib/manifest.mjs';
6
9
 
7
10
  const exists = (p) => access(p).then(() => true, () => false);
8
11
 
@@ -22,6 +25,10 @@ export async function uninstall({ consumerRoot }) {
22
25
  for (const e of consumer.installed) {
23
26
  const dest = join(consumerRoot, e.path);
24
27
  if (!(await exists(dest))) continue; // already gone
28
+ if (e.origin === CONSUMER_ORIGIN) {
29
+ res.retained.push(e.path);
30
+ continue;
31
+ }
25
32
  const userEdited = (await sha256File(dest)) !== e.installedSha256;
26
33
  const referenced = e.kind === 'hook' && (await hookReferenced(consumerRoot, e.path));
27
34
  if (userEdited || referenced) {
@@ -32,24 +32,45 @@ export async function update(options) {
32
32
  const consumerManifestBefore = await readFile(consumerManifestPath);
33
33
 
34
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);
35
+ const choosePreview = async (action, path) => {
36
+ if (action === 'collision') return undefined;
37
+ const key = decisionKey(action, path);
38
+ if (!decisions.has(key)) decisions.set(key, await decide(action, path));
39
+ return decisions.get(key);
38
40
  };
39
- const preview = await reconcile({ kitRoot, consumerRoot, decide: choose, dryRun: true });
41
+ const preview = await reconcile({ kitRoot, consumerRoot, decide: choosePreview, dryRun: true });
40
42
  await transition('preview');
41
- if (preview.deleted.length || preview.keptDeleted.length) await transition('awaiting_decision');
42
43
  if (dryRun) return { ...preview, state: 'preview', history };
43
44
  if (preview.conflicts.length) return terminal(preview, 'conflicted', history, transition);
44
- if (!hasUpstreamDelta(preview)) {
45
- return { ...await terminal(preview, 'applied', history, transition), status: 'current' };
45
+ const resolvedPreview = await resolvePreview({
46
+ kitRoot, consumerRoot, preview, decisions, decide, transition,
47
+ });
48
+ if (resolvedPreview.conflicts.length) {
49
+ return terminal(resolvedPreview, 'conflicted', history, transition);
50
+ }
51
+ if (!hasUpstreamDelta(resolvedPreview)) {
52
+ return { ...await terminal(resolvedPreview, 'applied', history, transition), status: 'current' };
46
53
  }
47
54
  return applyTransaction({
48
- kitRoot, consumerRoot, pkg, preview, decisions, verify, signal, resumeFrom,
55
+ kitRoot, consumerRoot, pkg, preview: resolvedPreview, decisions, verify, signal, resumeFrom,
49
56
  consumerManifestBefore, history, transition,
50
57
  });
51
58
  }
52
59
 
60
+ async function resolvePreview({ kitRoot, consumerRoot, preview, decisions, decide, transition }) {
61
+ if (preview.deleted.length || preview.keptDeleted.length || preview.collisions.length) {
62
+ await transition('awaiting_decision');
63
+ }
64
+ for (const path of preview.collisions) {
65
+ decisions.set(decisionKey('collision', path), await decide('collision', path));
66
+ }
67
+ if (!preview.collisions.length) return preview;
68
+ return reconcile({
69
+ kitRoot, consumerRoot, dryRun: true,
70
+ decide: (action, path) => decisions.get(decisionKey(action, path)),
71
+ });
72
+ }
73
+
53
74
  async function applyTransaction(context) {
54
75
  const {
55
76
  kitRoot, consumerRoot, pkg, preview, decisions, verify, signal, resumeFrom,
@@ -59,11 +80,14 @@ async function applyTransaction(context) {
59
80
  let keepCandidate = false;
60
81
  try {
61
82
  await transition('staging');
83
+ if (candidateRoot && preview.collisionResolutions.length) {
84
+ throw new Error('collision-bearing candidate cannot be resumed safely');
85
+ }
62
86
  if (!candidateRoot) {
63
87
  candidateRoot = await stageConsumer(consumerRoot);
64
88
  await reconcile({
65
89
  kitRoot, consumerRoot: candidateRoot,
66
- decide: (_action, path) => decisions.get(path) === true,
90
+ decide: (action, path) => decisions.get(decisionKey(action, path)),
67
91
  });
68
92
  }
69
93
  await transition('verifying');
@@ -85,6 +109,10 @@ async function applyTransaction(context) {
85
109
  }
86
110
  }
87
111
 
112
+ function decisionKey(action, path) {
113
+ return `${action}\0${path}`;
114
+ }
115
+
88
116
  function verifyRelease(identities, kitVersion) {
89
117
  const release = assertConsumerReleaseParity(identities);
90
118
  if (release.name !== RELEASE_NAME) throw new Error(`invalid release origin: ${release.name}`);
@@ -34,6 +34,7 @@ export const HELPER_FILES = [
34
34
  { path: 'scripts/board-sync.py', kind: 'script', mode: 0o755 },
35
35
  { path: 'scripts/execute-ready-check.py', kind: 'script', mode: 0o755 },
36
36
  { path: 'scripts/pr-body-check.py', kind: 'script', mode: 0o755 },
37
+ { path: 'scripts/pr_body_e2e.py', kind: 'script', mode: 0o644 },
37
38
  // Mechanical executor for /wrapup (preflight/commit/land) — replaced the
38
39
  // Sonnet phase-2 subagent. Imports board_config + anchor_table
39
40
  // (both shipped above). Invokable CLI → 0o755.
@@ -91,6 +92,9 @@ export const HELPER_FILES = [
91
92
  { path: '.claude/hooks/enforce-worktree-cwd.py', kind: 'hook', mode: 0o755 },
92
93
  { path: '.claude/hooks/enforce-worktree-discipline.py', kind: 'hook', mode: 0o755 },
93
94
  { path: '.claude/hooks/slice-handoff-hint.py', kind: 'hook', mode: 0o755 },
95
+ // Advisory provenance hint for agent Edit/Write events. It reads the local
96
+ // consumer manifest once and fails open; setup-workflow owns activation.
97
+ { path: '.claude/hooks/kit-origin-edit-hint.py', kind: 'hook', mode: 0o755 },
94
98
  // Profile-driven non-blocking change-lifecycle advisories. The shell Stop
95
99
  // entry delegates to its sibling Python adapter; decisions stay in core.py.
96
100
  { path: 'scripts/workflow-advisories/core.py', kind: 'script', mode: 0o644 },
@@ -0,0 +1,30 @@
1
+ import { lstat } from 'node:fs/promises';
2
+ import { isAbsolute, join, normalize, relative, resolve } from 'node:path';
3
+
4
+ /** Resolve a normalized repo-relative path and require a regular, non-symlink file. */
5
+ export async function validateConsumerFile(consumerRoot, path) {
6
+ if (typeof path !== 'string' || !path || path === '.' || isAbsolute(path) || normalize(path) !== path) {
7
+ throw new Error(`unsafe consumer path: ${path}`);
8
+ }
9
+ const root = resolve(consumerRoot);
10
+ const absolute = resolve(join(root, path));
11
+ const fromRoot = relative(root, absolute);
12
+ if (!fromRoot || fromRoot.startsWith(`..${separator()}`) || fromRoot === '..' || isAbsolute(fromRoot)) {
13
+ throw new Error(`unsafe consumer path: ${path}`);
14
+ }
15
+ let stat;
16
+ try {
17
+ stat = await lstat(absolute);
18
+ } catch (error) {
19
+ if (error.code === 'ENOENT') throw new Error(`unsafe consumer path (not a regular file): ${path}`);
20
+ throw error;
21
+ }
22
+ if (stat.isSymbolicLink() || !stat.isFile()) {
23
+ throw new Error(`unsafe consumer path (not a regular file): ${path}`);
24
+ }
25
+ return absolute;
26
+ }
27
+
28
+ function separator() {
29
+ return process.platform === 'win32' ? '\\' : '/';
30
+ }
@@ -13,6 +13,8 @@ import { writeAtomic } from './atomicWrite.mjs';
13
13
  export const CONSUMER_MANIFEST_NAME = 'agent-workflow-kit.json';
14
14
  export const PACKAGE_MANIFEST_NAME = 'agent-workflow-kit.package.json';
15
15
  export const CONSUMER_INSTALL_ROLE = 'consumer';
16
+ export const KIT_ORIGIN = 'kit';
17
+ export const CONSUMER_ORIGIN = 'consumer';
16
18
 
17
19
  /**
18
20
  * Parse a JSON manifest, or null if the file does not exist. A corrupt file
@@ -66,3 +68,19 @@ export function indexByPath(manifest, key) {
66
68
  for (const entry of (manifest?.[key] ?? [])) idx.set(entry.path, entry);
67
69
  return idx;
68
70
  }
71
+
72
+ /** Return a manifest with one tracked entry moved to the requested ownership state. */
73
+ export function withOrigin(manifest, path, origin) {
74
+ if (![KIT_ORIGIN, CONSUMER_ORIGIN].includes(origin)) {
75
+ throw new Error(`invalid manifest origin: ${origin}`);
76
+ }
77
+ const current = (manifest?.installed ?? []).find((entry) => entry.path === path);
78
+ if (!current) throw new Error(`unknown tracked path: ${path}`);
79
+ if ((current.origin ?? KIT_ORIGIN) === origin) {
80
+ throw new Error(`${path} is already ${origin}-owned`);
81
+ }
82
+ return {
83
+ ...manifest,
84
+ installed: manifest.installed.map((entry) => entry.path === path ? { ...entry, origin } : entry),
85
+ };
86
+ }
@@ -0,0 +1,88 @@
1
+ import { lstat, readFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { lineDiff } from './atomicWrite.mjs';
4
+ import { validateConsumerFile } from './consumerPath.mjs';
5
+ import { sha256 } from './hash.mjs';
6
+ import {
7
+ CONSUMER_MANIFEST_NAME, CONSUMER_ORIGIN, PACKAGE_MANIFEST_NAME, indexByPath, readManifest,
8
+ } from './manifest.mjs';
9
+
10
+ /** Describe on demand how current package entries differ from consumer-owned paths. */
11
+ export async function ownedDiff({ kitRoot, consumerRoot }) {
12
+ const consumer = await readManifest(join(consumerRoot, CONSUMER_MANIFEST_NAME));
13
+ const pkg = await readManifest(join(kitRoot, PACKAGE_MANIFEST_NAME));
14
+ const packageByPath = indexByPath(pkg, 'files');
15
+ const results = [];
16
+
17
+ for (const installed of consumer.installed.filter(({ origin }) => origin === CONSUMER_ORIGIN)) {
18
+ const current = packageByPath.get(installed.path);
19
+ let localPath;
20
+ try {
21
+ localPath = await validateConsumerFile(consumerRoot, installed.path);
22
+ } catch (error) {
23
+ if (error.message === `unsafe consumer path (not a regular file): ${installed.path}` &&
24
+ await isMissing(join(consumerRoot, installed.path))) {
25
+ results.push({ path: installed.path, state: 'missing-locally' });
26
+ continue;
27
+ }
28
+ if (error.message.startsWith('unsafe consumer path')) {
29
+ results.push({ path: installed.path, state: 'unsafe-path' });
30
+ continue;
31
+ }
32
+ throw error;
33
+ }
34
+ if (!current) {
35
+ results.push({ path: installed.path, state: 'removed-upstream' });
36
+ continue;
37
+ }
38
+ const local = await readFile(localPath);
39
+ let packagePath;
40
+ try {
41
+ packagePath = await validateConsumerFile(kitRoot, current.path);
42
+ } catch (error) {
43
+ if (error.message.startsWith('unsafe consumer path')) {
44
+ results.push({ path: installed.path, state: 'unsafe-path' });
45
+ continue;
46
+ }
47
+ throw error;
48
+ }
49
+ const upstream = await readFile(packagePath);
50
+ if (local.equals(upstream)) {
51
+ results.push({ path: installed.path, state: 'identical' });
52
+ continue;
53
+ }
54
+ if (isBinary(local) || isBinary(upstream)) {
55
+ results.push({
56
+ path: installed.path,
57
+ state: 'changed-upstream',
58
+ binary: true,
59
+ local: { size: local.length, sha256: sha256(local) },
60
+ upstream: { size: upstream.length, sha256: sha256(upstream) },
61
+ });
62
+ continue;
63
+ }
64
+ results.push({
65
+ path: installed.path,
66
+ state: 'changed-upstream',
67
+ binary: false,
68
+ diff: lineDiff(local.toString('utf8'), upstream.toString('utf8')),
69
+ });
70
+ }
71
+ return results;
72
+ }
73
+
74
+ async function isMissing(path) {
75
+ try {
76
+ await lstat(path);
77
+ return false;
78
+ } catch (error) {
79
+ if (error.code === 'ENOENT') return true;
80
+ throw error;
81
+ }
82
+ }
83
+
84
+ function isBinary(bytes) {
85
+ if (bytes.includes(0)) return true;
86
+ const text = bytes.toString('utf8');
87
+ return !Buffer.from(text, 'utf8').equals(bytes);
88
+ }
@@ -4,6 +4,7 @@ import { tmpdir } from 'node:os';
4
4
  import { join, relative } from 'node:path';
5
5
  import { promisify } from 'node:util';
6
6
  import { writeAtomic } from './atomicWrite.mjs';
7
+ import { validateConsumerFile } from './consumerPath.mjs';
7
8
  import { sha256File } from './hash.mjs';
8
9
  import { CONSUMER_MANIFEST_NAME, indexByPath, readManifest } from './manifest.mjs';
9
10
 
@@ -63,7 +64,20 @@ export async function activateCandidate({
63
64
  async function assertConsumerStillMatchesPreview(consumerRoot, preview) {
64
65
  const manifest = await readManifest(join(consumerRoot, CONSUMER_MANIFEST_NAME));
65
66
  const installed = indexByPath(manifest, 'installed');
67
+ const replacements = new Set(
68
+ preview.collisionResolutions
69
+ .filter(({ outcome }) => outcome === 'replace')
70
+ .map(({ path }) => path),
71
+ );
72
+ for (const collision of preview.collisionResolutions) {
73
+ await validateConsumerFile(consumerRoot, collision.path);
74
+ const current = await sha256File(join(consumerRoot, collision.path));
75
+ if (current !== collision.destinationSha256) {
76
+ throw new Error(`consumer changed during verification: ${collision.path}`);
77
+ }
78
+ }
66
79
  for (const path of preview.added) {
80
+ if (replacements.has(path)) continue;
67
81
  if (await exists(join(consumerRoot, path))) throw new Error(`consumer changed during verification: ${path}`);
68
82
  }
69
83
  for (const path of [...preview.updated, ...preview.deleted]) {
@@ -3,9 +3,10 @@ import { join } from 'node:path';
3
3
  import { sha256File } from './hash.mjs';
4
4
  import { lineDiff, writeAtomic } from './atomicWrite.mjs';
5
5
  import { hookReferenced } from './settings.mjs';
6
+ import { validateConsumerFile } from './consumerPath.mjs';
6
7
  import {
7
8
  CONSUMER_MANIFEST_NAME, PACKAGE_MANIFEST_NAME, emptyConsumerManifest,
8
- CONSUMER_INSTALL_ROLE, filesForInstallRole,
9
+ CONSUMER_INSTALL_ROLE, CONSUMER_ORIGIN, KIT_ORIGIN, filesForInstallRole,
9
10
  indexByPath, readManifest, writeManifest,
10
11
  } from './manifest.mjs';
11
12
 
@@ -28,7 +29,40 @@ export async function reconcile({ kitRoot, consumerRoot, decide = () => false, d
28
29
  for (const file of installable) {
29
30
  const dest = join(consumerRoot, file.path);
30
31
  const prior = installedIdx.get(file.path);
31
- const current = (await exists(dest)) ? await sha256File(dest) : null;
32
+ if (prior?.origin === CONSUMER_ORIGIN) {
33
+ nextInstalled.push(withInstallRole(prior));
34
+ result.consumerOwned.push(file.path);
35
+ continue;
36
+ }
37
+ const present = await exists(dest);
38
+ if (!prior && present) {
39
+ await validateConsumerFile(consumerRoot, file.path);
40
+ const decision = await decide('collision', file.path);
41
+ if (decision === false || decision === null || decision === undefined) {
42
+ if (dryRun) {
43
+ result.collisions.push(file.path);
44
+ continue;
45
+ }
46
+ throw new Error(`collision decision required for ${file.path}`);
47
+ }
48
+ if (decision !== 'keep-as-owned' && decision !== 'replace') {
49
+ throw new Error(`collision decision for ${file.path} must be keep-as-owned or replace`);
50
+ }
51
+ const destinationSha256 = await sha256File(dest);
52
+ result.collisionResolutions.push({
53
+ path: file.path, outcome: decision, destinationSha256,
54
+ });
55
+ if (decision === 'keep-as-owned') {
56
+ nextInstalled.push(entry(file, destinationSha256, CONSUMER_ORIGIN));
57
+ result.consumerOwned.push(file.path);
58
+ } else {
59
+ if (!dryRun) await writeAtomic(dest, await readFile(join(kitRoot, file.path)), file.mode);
60
+ nextInstalled.push(entry(file, file.sha256));
61
+ result.added.push(file.path);
62
+ }
63
+ continue;
64
+ }
65
+ const current = present ? await sha256File(dest) : null;
32
66
  if (!prior || current === null) {
33
67
  if (!dryRun) await writeAtomic(dest, await readFile(join(kitRoot, file.path)), file.mode);
34
68
  nextInstalled.push(entry(file, file.sha256));
@@ -56,6 +90,11 @@ export async function reconcile({ kitRoot, consumerRoot, decide = () => false, d
56
90
 
57
91
  for (const prior of consumer.installed) {
58
92
  if (pkgIdx.has(prior.path)) continue;
93
+ if (prior.origin === CONSUMER_ORIGIN) {
94
+ nextInstalled.push(withInstallRole(prior));
95
+ result.consumerOwned.push(prior.path);
96
+ continue;
97
+ }
59
98
  const dest = join(consumerRoot, prior.path);
60
99
  const current = (await exists(dest)) ? await sha256File(dest) : null;
61
100
  const userEdited = current !== null && current !== prior.installedSha256;
@@ -86,15 +125,15 @@ export async function reconcile({ kitRoot, consumerRoot, decide = () => false, d
86
125
 
87
126
  function emptyResult() {
88
127
  return {
89
- unchanged: [], updated: [], conflicts: [], userModified: [],
90
- added: [], deleted: [], keptDeleted: [], manifestChanged: false,
128
+ unchanged: [], updated: [], conflicts: [], collisions: [], collisionResolutions: [], userModified: [],
129
+ added: [], deleted: [], keptDeleted: [], consumerOwned: [], manifestChanged: false,
91
130
  };
92
131
  }
93
132
 
94
- function entry(file, installedSha256) {
133
+ function entry(file, installedSha256, origin = KIT_ORIGIN) {
95
134
  return {
96
135
  path: file.path, kind: file.kind, ownerSkill: file.ownerSkill, surface: file.surface,
97
- installedSha256, origin: 'kit', installRole: CONSUMER_INSTALL_ROLE,
136
+ installedSha256, origin, installRole: CONSUMER_INSTALL_ROLE,
98
137
  };
99
138
  }
100
139