wowbagger 0.1.0-alpha.13 → 0.1.0-alpha.17

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/src/mutation.js CHANGED
@@ -230,11 +230,15 @@ export async function createItem(ledgerDirectory, request, scenario) {
230
230
  ledgerDirectory,
231
231
  request.id,
232
232
  'create-v1',
233
- (authorize, ledgerSnapshot) => createItemUnfenced(ledgerDirectory, request, scenario, ledgerSnapshot),
233
+ (authorize, ledgerSnapshot) => createItemUnfenced(
234
+ ledgerDirectory, request, scenario, authorize, ledgerSnapshot,
235
+ ),
234
236
  );
235
237
  }
236
238
 
237
- async function createItemUnfenced(ledgerDirectory, request, scenario, ledgerSnapshot) {
239
+ async function createItemUnfenced(
240
+ ledgerDirectory, request, scenario, authorize, ledgerSnapshot,
241
+ ) {
238
242
  const root = path.resolve(ledgerDirectory);
239
243
  const id = request.id;
240
244
  const readPreLockLedger = snapshotReader(root, ledgerSnapshot);
@@ -362,6 +366,15 @@ async function createItemUnfenced(ledgerDirectory, request, scenario, ledgerSnap
362
366
  }));
363
367
  }
364
368
 
369
+ // The allocation this create proposes becomes journal-visible before any
370
+ // byte reaches the ledger, so a sibling worktree that cannot see this
371
+ // item's publication cannot hand the same number out again. The intent is
372
+ // appended only once the candidate is known publishable, so a refusal
373
+ // this command would have returned anyway records no attempt.
374
+ if (authorize) {
375
+ await authorize(null, revisionFor(bytes), relativeFinalPath);
376
+ }
377
+
365
378
  temporaryPath = path.join(finalDirectory, `.wowbagger-tmp-${id}-${randomSuffix()}`);
366
379
  const temporaryFailure = await prepareTemporary(temporaryPath, bytes, null, scenario);
367
380
  if (temporaryFailure) {
@@ -0,0 +1,117 @@
1
+ // The reconciliation topology, decided once, from evidence alone.
2
+ //
3
+ // Every command that reconciles a claim journal has to answer the same
4
+ // question about a drifted item: which of the recognized topologies is this,
5
+ // and does it block the write in front of us? Answering it inside each command
6
+ // is how the answers drifted apart, so the decision lives here, pure: no Git,
7
+ // no filesystem, no prose. Callers gather the evidence, render the sentences,
8
+ // and keep the scope to themselves.
9
+ //
10
+ // The vocabulary:
11
+ //
12
+ // revision state where a surface's bytes stand against the journal —
13
+ // `expected` the authorized revision itself, `authorized`
14
+ // some other revision the journal once ruled legitimate,
15
+ // `unknown` bytes no ruling covers, `absent` no bytes at all.
16
+ // owner evidence `{ kind, ref?, commit? }` from `findRevisionOwner`: which
17
+ // live worktree, if any, carries the expected revision.
18
+ // expected writer `current` when the journal names this worktree as the
19
+ // writer of the expected revision, `other` when it names
20
+ // another, `unknown` when nothing can be attributed.
21
+ // scope who a finding blocks: `global` every write, `target` only
22
+ // a write against the item it names, `none` nobody.
23
+ // remediation which remedy the topology prescribes; the caller renders
24
+ // the sentence, so the kinds carry no wording.
25
+
26
+ // Where one surface's bytes stand against the journal. `expected` is a
27
+ // refinement of `authorized`, so it is tested first.
28
+ export function normalizeRevision(revision, expectedRevision, authorizedRevisions) {
29
+ if (revision === null) return 'absent';
30
+ if (revision === expectedRevision) return 'expected';
31
+ return authorizedRevisions.has(revision) ? 'authorized' : 'unknown';
32
+ }
33
+
34
+ // Owner evidence costs a walk of every live worktree's history, so the two
35
+ // topologies that never consult it must not pay for it. The predicate answers
36
+ // from the same states the classifier judges, through the same helper, so
37
+ // neither can drift from the other.
38
+ export function requiresOwnerEvidence({ workingTree, head }) {
39
+ return !isUnattributed(workingTree, head) && workingTree !== 'expected';
40
+ }
41
+
42
+ // Bytes no ruling covers, on either surface, and a working tree that is gone
43
+ // while another surface still holds bytes. Nothing here is attributable to a
44
+ // writer or an owner: the local state is simply out of protocol.
45
+ function isUnattributed(workingTree, head) {
46
+ return workingTree === 'unknown'
47
+ || head === 'unknown'
48
+ || (workingTree === 'absent' && head !== 'absent');
49
+ }
50
+
51
+ // One topology, one member. `expectedOwner` is required exactly when
52
+ // `requiresOwnerEvidence` says so, and is never read otherwise.
53
+ export function classifyReconciliation({ workingTree, head, expectedOwner, expectedWriter }) {
54
+ if (isUnattributed(workingTree, head)) return UNAUTHORIZED_REVISION;
55
+ // The authorized bytes are here and Git has yet to record them. Nothing is
56
+ // in doubt but the commit.
57
+ if (workingTree === 'expected') {
58
+ return { scope: 'global', reason: 'git-finalization-required', remediation: 'commit-in-git' };
59
+ }
60
+ // An item absent from both local surfaces has never existed in this
61
+ // checkout. A sibling may carry the expected revision, but that does not
62
+ // establish ownership for a checkout with no local history or item path.
63
+ if (workingTree === 'absent') {
64
+ return {
65
+ scope: 'target',
66
+ reason: 'worktree-synchronization-required',
67
+ remediation: 'establish-ownership',
68
+ };
69
+ }
70
+ // A live named worktree carries the expected revision, so there is a ref to
71
+ // wait on and a commit to name. This outranks the remaining synchronization
72
+ // answers, because it is the only one that names an owner.
73
+ if (expectedOwner.kind === 'named-sibling') {
74
+ return {
75
+ scope: 'target',
76
+ reason: 'worktree-synchronization-required',
77
+ owner: expectedOwner,
78
+ remediation: 'wait-for-named-owner',
79
+ };
80
+ }
81
+ // Advice to wait for an owning worktree needs an owner that could still
82
+ // appear. When the journal names this worktree as the writer of the expected
83
+ // revision, or this worktree's own history reaches it, the successor exists
84
+ // nowhere but in the journal: there is nothing to synchronize from, and the
85
+ // authorized bytes on disk are simply the wrong ones.
86
+ if (expectedWriter !== 'current' && expectedOwner.kind !== 'current') {
87
+ return {
88
+ scope: 'target',
89
+ reason: 'worktree-synchronization-required',
90
+ // Waiting is only truthful while the commit is still missing. Git already
91
+ // reaches a `reachable-unowned` revision through a tag, a remote-tracking
92
+ // ref, an unchecked branch, or a detached HEAD, so telling a reader to
93
+ // wait for a commit names a wait that can never end: the bytes are there
94
+ // to inspect, and no named worktree will publish them.
95
+ remediation: expectedOwner.kind === 'reachable-unowned'
96
+ ? 'inspect-reachable-history'
97
+ : 'await-owner-commit',
98
+ };
99
+ }
100
+ return UNAUTHORIZED_REVISION;
101
+ }
102
+
103
+ const UNAUTHORIZED_REVISION = Object.freeze({
104
+ scope: 'global',
105
+ reason: 'unauthorized-revision',
106
+ remediation: 'restore-or-adopt',
107
+ });
108
+
109
+ // Scope, never reason text, decides what a finding refuses. A mutation names
110
+ // the item it targets, and a synchronization another item waits on is a wait
111
+ // this mutation does not touch. A caller that names no target, such as the
112
+ // `claim-verify` command, keeps every finding blocking.
113
+ export function blocksTarget(scope, itemId, targetItemId) {
114
+ if (scope === 'none') return false;
115
+ if (scope === 'global') return true;
116
+ return targetItemId === null || itemId === targetItemId;
117
+ }
@@ -0,0 +1,98 @@
1
+ import { lstat, readFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+
4
+ const SKILL_DISTRIBUTION = /requires distribution version[\s`*:-]+`([^`]+)`/u;
5
+ const SKILL_CONTRACT = /core[\s`*:-]+`contract_version:\s*(\d+)`/u;
6
+
7
+ export async function inspectVersionDrift({
8
+ skillPath,
9
+ packagePath,
10
+ runningDistribution,
11
+ runningContractVersion,
12
+ }) {
13
+ let skillSource;
14
+ let packageManifest;
15
+ try {
16
+ [skillSource, packageManifest] = await Promise.all([
17
+ readFile(skillPath, 'utf8'),
18
+ readFile(packagePath, 'utf8').then((source) => JSON.parse(source)),
19
+ ]);
20
+ } catch {
21
+ return unavailable('Could not read the skill or core package metadata.', {
22
+ skill_path: skillPath,
23
+ package_path: packagePath,
24
+ });
25
+ }
26
+ const distribution = SKILL_DISTRIBUTION.exec(skillSource)?.[1] ?? null;
27
+ const contract = Number(SKILL_CONTRACT.exec(skillSource)?.[1] ?? NaN);
28
+ const requiredDistribution = packageManifest.version;
29
+ const requiredContract = 5;
30
+ const details = {
31
+ installed_distribution: distribution,
32
+ required_distribution: requiredDistribution,
33
+ running_distribution: runningDistribution ?? requiredDistribution,
34
+ installed_contract_version: Number.isSafeInteger(contract) ? contract : null,
35
+ required_contract_version: requiredContract,
36
+ running_contract_version: runningContractVersion ?? requiredContract,
37
+ provenance: await classifyProvenance(skillPath),
38
+ };
39
+ const drift = details.installed_distribution !== details.required_distribution
40
+ || details.running_distribution !== details.required_distribution
41
+ || details.installed_contract_version !== details.required_contract_version
42
+ || details.running_contract_version !== details.required_contract_version;
43
+ if (drift) {
44
+ return {
45
+ exit: 4,
46
+ stdout: {
47
+ ok: false,
48
+ command: 'version-drift',
49
+ contract_version: requiredContract,
50
+ error: {
51
+ code: 'version-drift-detected',
52
+ message: 'The installed skill and running core do not satisfy the required versions.',
53
+ details: {
54
+ ...details,
55
+ remediation: 'Update the skill package or linked checkout, then rerun version-drift before mutation.',
56
+ },
57
+ },
58
+ },
59
+ };
60
+ }
61
+ return {
62
+ exit: 0,
63
+ stdout: {
64
+ ok: true,
65
+ command: 'version-drift',
66
+ contract_version: requiredContract,
67
+ result: details,
68
+ },
69
+ };
70
+ }
71
+
72
+ async function classifyProvenance(skillPath) {
73
+ try {
74
+ const info = await lstat(skillPath);
75
+ if (info.isSymbolicLink()) {
76
+ return { kind: 'global-link', path: skillPath };
77
+ }
78
+ } catch {
79
+ return { kind: 'unknown', path: skillPath };
80
+ }
81
+ const normalized = path.resolve(skillPath).split(path.sep).join('/');
82
+ if (normalized.includes('/node_modules/')) return { kind: 'registry-package', path: skillPath };
83
+ if (normalized.includes('/.claude/plugins/cache/')) return { kind: 'plugin-cache', path: skillPath };
84
+ if (normalized.includes('/.git/')) return { kind: 'git-tag', path: skillPath };
85
+ return { kind: 'direct-path', path: skillPath };
86
+ }
87
+
88
+ function unavailable(message, details) {
89
+ return {
90
+ exit: 5,
91
+ stdout: {
92
+ ok: false,
93
+ command: 'version-drift',
94
+ contract_version: 5,
95
+ error: { code: 'version-drift-unavailable', message, details },
96
+ },
97
+ };
98
+ }