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/CHANGELOG.md +249 -0
- package/README.md +27 -15
- package/docs/host-contract.md +7 -1
- package/docs/mutation-contract.md +114 -21
- package/docs/work-claim-contract.md +292 -67
- package/package.json +2 -2
- package/schemas/core-envelope.json +4 -3
- package/schemas/index.json +18 -0
- package/schemas/ledger-repair-proposal.json +170 -0
- package/schemas/ledger-repair-request.json +61 -0
- package/schemas/ledger-repair-response.json +90 -0
- package/skills/wowbagger/SKILL.md +134 -22
- package/src/claim-coordinator.js +17 -3
- package/src/claim-journal.js +125 -4
- package/src/claim-publication.js +122 -93
- package/src/claim-request.js +9 -0
- package/src/cli.js +127 -36
- package/src/git-autocommit.js +26 -21
- package/src/launch.js +2 -2
- package/src/ledger-repair.js +1170 -0
- package/src/mutation.js +15 -2
- package/src/reconciliation-classifier.js +117 -0
- package/src/version-drift.js +98 -0
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(
|
|
233
|
+
(authorize, ledgerSnapshot) => createItemUnfenced(
|
|
234
|
+
ledgerDirectory, request, scenario, authorize, ledgerSnapshot,
|
|
235
|
+
),
|
|
234
236
|
);
|
|
235
237
|
}
|
|
236
238
|
|
|
237
|
-
async function createItemUnfenced(
|
|
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
|
+
}
|