@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.
- package/.agents/skills/ask-matt/SKILL.md +2 -2
- package/.agents/skills/grill-me/SKILL.md +1 -1
- package/.agents/skills/grill-with-docs/SKILL.md +1 -1
- package/.agents/skills/kit-update/SKILL.md +12 -0
- package/.agents/skills/orchestrate-wave/SKILL.md +68 -21
- package/.agents/skills/orchestrate-wave/references/builder-contract.md +6 -4
- package/.agents/skills/retro/SKILL.md +24 -1
- package/.agents/skills/scale-check/SKILL.md +2 -2
- package/.agents/skills/setup-workflow/SKILL.md +44 -1
- package/.agents/skills/setup-workflow/workflow-overview.md +5 -5
- package/.agents/skills/tdd/SKILL.md +68 -14
- package/.agents/skills/to-issues/SKILL.md +2 -2
- package/.agents/skills/verify-spike/SKILL.md +1 -1
- package/.agents/skills/wrapup/SKILL.md +14 -2
- package/.claude/hooks/drift-guard.py +5 -2
- package/.claude/hooks/kit-origin-edit-hint.py +64 -0
- package/.claude/skills/ask-matt/SKILL.md +2 -2
- package/.claude/skills/grill-me/SKILL.md +1 -1
- package/.claude/skills/grill-with-docs/SKILL.md +1 -1
- package/.claude/skills/kit-update/SKILL.md +12 -0
- package/.claude/skills/orchestrate-wave/SKILL.md +68 -21
- package/.claude/skills/orchestrate-wave/references/builder-contract.md +6 -4
- package/.claude/skills/retro/SKILL.md +23 -0
- package/.claude/skills/scale-check/SKILL.md +2 -2
- package/.claude/skills/setup-workflow/SKILL.md +44 -1
- package/.claude/skills/setup-workflow/workflow-overview.md +5 -5
- package/.claude/skills/skill-manifest.json +1 -1
- package/.claude/skills/tdd/SKILL.md +68 -14
- package/.claude/skills/to-issues/SKILL.md +2 -2
- package/.claude/skills/verify-spike/SKILL.md +1 -1
- package/.claude/skills/wrapup/SKILL.md +14 -2
- package/README.md +47 -7
- package/agent-workflow-kit.package.json +49 -33
- package/docs/adr/0001-consumer-divergence-policy.md +49 -0
- package/docs/agents/wave-anchor-template.md +1 -1
- package/package.json +1 -1
- package/scripts/board-sync.py +184 -5
- package/scripts/pr-body-check.py +34 -6
- package/scripts/pr_body_e2e.py +83 -0
- package/scripts/test_board_sync.py +208 -0
- package/scripts/test_census_backstop.py +56 -3
- package/scripts/test_orchestrate_wave_contract.py +116 -0
- package/scripts/test_pr_body_check.py +245 -0
- package/scripts/test_retro_wrapup_contract.py +111 -0
- package/scripts/test_tdd_contract.py +78 -0
- package/src/cli.mjs +44 -8
- package/src/commands/diff.mjs +5 -2
- package/src/commands/init.mjs +12 -0
- package/src/commands/own.mjs +16 -0
- package/src/commands/uninstall.mjs +8 -1
- package/src/commands/update.mjs +37 -9
- package/src/lib/bundle.mjs +4 -0
- package/src/lib/consumerPath.mjs +30 -0
- package/src/lib/manifest.mjs +18 -0
- package/src/lib/ownedDiff.mjs +88 -0
- package/src/lib/updateCandidate.mjs +14 -0
- 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 =
|
|
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 [
|
|
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,
|
package/src/commands/diff.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/src/commands/init.mjs
CHANGED
|
@@ -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 {
|
|
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) {
|
package/src/commands/update.mjs
CHANGED
|
@@ -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
|
|
36
|
-
if (
|
|
37
|
-
|
|
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:
|
|
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
|
-
|
|
45
|
-
|
|
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: (
|
|
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}`);
|
package/src/lib/bundle.mjs
CHANGED
|
@@ -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
|
+
}
|
package/src/lib/manifest.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
136
|
+
installedSha256, origin, installRole: CONSUMER_INSTALL_ROLE,
|
|
98
137
|
};
|
|
99
138
|
}
|
|
100
139
|
|