wowbagger 0.1.0-alpha.9 → 0.5.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/CHANGELOG.md +509 -0
- package/README.md +272 -136
- package/docs/adapter-contract.md +1 -1
- package/docs/host-contract.md +7 -1
- package/docs/mutation-contract.md +354 -82
- package/docs/work-claim-contract.md +466 -88
- package/package.json +2 -2
- package/schemas/core-capabilities-response.json +1 -1
- 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/schemas/report-config-v1.json +4 -0
- package/schemas/report-config-v2.json +5 -0
- package/skills/wowbagger/SKILL.md +242 -59
- package/src/adapter/core-probe.js +3 -4
- package/src/adapter/process-outcome.js +8 -1
- package/src/claim-capabilities.js +3 -3
- package/src/claim-coordinator.js +61 -12
- package/src/claim-journal.js +212 -7
- package/src/claim-prospective.js +1 -28
- package/src/claim-publication.js +412 -78
- package/src/claim-request.js +9 -0
- package/src/claim-store.js +9 -4
- package/src/cli.js +302 -56
- package/src/extensions.js +1 -0
- package/src/git-autocommit.js +106 -43
- package/src/git-reconciliation.js +74 -19
- package/src/git-worktrees.js +73 -0
- package/src/instrumentation.js +1 -0
- package/src/launch.js +2 -2
- package/src/ledger-repair.js +1170 -0
- package/src/mutation.js +73 -15
- package/src/reconciliation-classifier.js +117 -0
- package/src/report-evidence.js +158 -41
- package/src/report-graph.js +201 -73
- package/src/report-html.js +358 -155
- package/src/report-impact.js +106 -0
- package/src/report-selection.js +97 -0
- package/src/report-sequencing.js +4 -4
- package/src/report-svg.js +74 -20
- package/src/report-view.js +14 -1
- package/src/report.js +109 -23
- package/src/version-drift.js +98 -0
- package/src/worktree-identity.js +165 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { open, readFile, realpath, rename, rm } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
import { withClaimLock } from './claim-store.js';
|
|
6
|
+
import { listWorktrees, resolvePrivateGitDir } from './git-worktrees.js';
|
|
7
|
+
|
|
8
|
+
const IDENTITY_FILE = 'wowbagger-worktree-id';
|
|
9
|
+
const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
|
10
|
+
|
|
11
|
+
function invalidIdentity(reason) {
|
|
12
|
+
const error = new Error('worktree identity is invalid');
|
|
13
|
+
error.code = 'CLAIM_WORKTREE_IDENTITY_INVALID';
|
|
14
|
+
error.reason = reason;
|
|
15
|
+
return error;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function readWorktreeIdentity({ ledgerDirectory, gitCommonDir }) {
|
|
19
|
+
return readIdentityFile(await resolveIdentityPath(ledgerDirectory, gitCommonDir));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Two live worktrees answering to one UUID make every writer attribution in
|
|
23
|
+
// the shared journal ambiguous, and an ambiguous writer is exactly what
|
|
24
|
+
// reconciliation reasons from. The caller runs this before it classifies or
|
|
25
|
+
// publishes anything, so an ambiguous domain refuses rather than guesses.
|
|
26
|
+
//
|
|
27
|
+
// Nothing here creates an identity file: a worktree that has never written one
|
|
28
|
+
// stays anonymous and holds no UUID to collide with.
|
|
29
|
+
export async function assertUniqueWorktreeIdentity({ ledgerDirectory }) {
|
|
30
|
+
const identities = await enumerateWorktreeIdentities(ledgerDirectory);
|
|
31
|
+
const holders = new Map();
|
|
32
|
+
for (const identity of identities) {
|
|
33
|
+
if (identity !== null) holders.set(identity, (holders.get(identity) ?? 0) + 1);
|
|
34
|
+
}
|
|
35
|
+
// Sorted so a domain holding more than one collision always names the same
|
|
36
|
+
// one, and the diagnostic an operator reads twice does not move.
|
|
37
|
+
const duplicate = [...holders]
|
|
38
|
+
.filter(([, count]) => count > 1)
|
|
39
|
+
.sort(([left], [right]) => (left < right ? -1 : 1))
|
|
40
|
+
.at(0);
|
|
41
|
+
if (!duplicate) return;
|
|
42
|
+
const error = invalidIdentity('duplicate-worktree-identity');
|
|
43
|
+
error.identityDiagnostic = {
|
|
44
|
+
code: 'duplicate-worktree-identity',
|
|
45
|
+
worktree_id: duplicate[0],
|
|
46
|
+
live_worktree_count: duplicate[1],
|
|
47
|
+
};
|
|
48
|
+
throw error;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// One entry per live registered worktree: its recorded UUID, or null when it
|
|
52
|
+
// has never written one.
|
|
53
|
+
//
|
|
54
|
+
// Evidence gathering fails closed, because a roster this function could not
|
|
55
|
+
// finish reading is not a roster without duplicates. An enumeration that never
|
|
56
|
+
// ran, a private Git directory that will not resolve, a path that vanished
|
|
57
|
+
// between the listing and the read, and identity bytes that are present but
|
|
58
|
+
// malformed all leave the roster incomplete, so all of them refuse the caller
|
|
59
|
+
// rather than shrink the evidence. Silently dropping any of them could hide
|
|
60
|
+
// the very duplicate this check exists to find.
|
|
61
|
+
//
|
|
62
|
+
// Only what Git itself has already disowned is excluded: a record Git marks
|
|
63
|
+
// prunable, and a bare repository, which has no checkout to hold an identity.
|
|
64
|
+
async function enumerateWorktreeIdentities(ledgerDirectory) {
|
|
65
|
+
try {
|
|
66
|
+
const roster = await listWorktrees(ledgerDirectory);
|
|
67
|
+
return await Promise.all(roster
|
|
68
|
+
.filter((worktree) => !(worktree.bare || worktree.prunable))
|
|
69
|
+
.map(async (worktree) => readIdentityFile(path.join(
|
|
70
|
+
await resolvePrivateGitDir(worktree.path), IDENTITY_FILE,
|
|
71
|
+
))));
|
|
72
|
+
} catch {
|
|
73
|
+
const error = invalidIdentity('worktree-enumeration-failed');
|
|
74
|
+
// The roster is unknown, so the diagnostic names no UUID and no count.
|
|
75
|
+
error.identityDiagnostic = { code: 'worktree-enumeration-failed' };
|
|
76
|
+
throw error;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// A duplicate refusal keeps its caller's existing outer code and reason, and
|
|
81
|
+
// adds only this diagnostic to the details a client already tolerates extra
|
|
82
|
+
// members in.
|
|
83
|
+
export function identityDiagnosticDetails(error) {
|
|
84
|
+
return error?.identityDiagnostic ? { identity_diagnostic: error.identityDiagnostic } : {};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// The identity file is created once and never rewritten: a worktree that
|
|
88
|
+
// already answers to an ID keeps it, because journal entries elsewhere already
|
|
89
|
+
// name it.
|
|
90
|
+
//
|
|
91
|
+
// Read and create run under a lock keyed on the identity path, which is the
|
|
92
|
+
// worktree itself. The namespace write lock cannot stand in for it: two ledger
|
|
93
|
+
// namespaces can share one worktree, so they hold different namespace locks
|
|
94
|
+
// while contending for one identity file. Without this lock both would observe
|
|
95
|
+
// no identity, both would create one, and the later rename would leave the
|
|
96
|
+
// earlier writer holding an ID the file no longer contains. The lock is a
|
|
97
|
+
// try-lock, so a losing writer is refused with `CLAIM_LOCK_HELD` and retries
|
|
98
|
+
// rather than publishing a second identity.
|
|
99
|
+
//
|
|
100
|
+
// Creation still writes a fresh file in the same directory, fsyncs it, and
|
|
101
|
+
// renames it over the final path, so a reader taking no lock at all sees
|
|
102
|
+
// either no file or one whole ID. The final path is never opened for writing,
|
|
103
|
+
// so a crash can never leave a truncated identity behind.
|
|
104
|
+
export async function ensureWorktreeIdentity({ ledgerDirectory, gitCommonDir }) {
|
|
105
|
+
const identityPath = await resolveIdentityPath(ledgerDirectory, gitCommonDir);
|
|
106
|
+
return withClaimLock(identityPath, async () => {
|
|
107
|
+
const existing = await readIdentityFile(identityPath);
|
|
108
|
+
if (existing !== null) return existing;
|
|
109
|
+
|
|
110
|
+
const temporaryPath = path.join(
|
|
111
|
+
path.dirname(identityPath),
|
|
112
|
+
`.${IDENTITY_FILE}.${randomUUID()}`,
|
|
113
|
+
);
|
|
114
|
+
try {
|
|
115
|
+
const handle = await open(temporaryPath, 'wx', 0o600);
|
|
116
|
+
try {
|
|
117
|
+
await handle.writeFile(`${randomUUID()}\n`, 'utf8');
|
|
118
|
+
await handle.sync();
|
|
119
|
+
} finally {
|
|
120
|
+
await handle.close();
|
|
121
|
+
}
|
|
122
|
+
await rename(temporaryPath, identityPath);
|
|
123
|
+
} finally {
|
|
124
|
+
await rm(temporaryPath, { force: true });
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Read back rather than trust the value just written: the caller must act
|
|
128
|
+
// on the ID the next reader will see, not the one this process intended.
|
|
129
|
+
const written = await readIdentityFile(identityPath);
|
|
130
|
+
if (written === null) throw invalidIdentity('missing-after-write');
|
|
131
|
+
return written;
|
|
132
|
+
}, { counter: 'worktree_identity_lock_acquisitions' });
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// The verified common directory is the caller's proof of which repository it
|
|
136
|
+
// is fencing. A private directory that is neither that directory nor one of
|
|
137
|
+
// its linked worktrees belongs to some other repository, and an identity
|
|
138
|
+
// written there would name the wrong writer.
|
|
139
|
+
async function resolveIdentityPath(ledgerDirectory, gitCommonDir) {
|
|
140
|
+
const privateGitDir = await resolvePrivateGitDir(ledgerDirectory);
|
|
141
|
+
const [privateReal, commonReal] = await Promise.all([
|
|
142
|
+
realpath(privateGitDir),
|
|
143
|
+
realpath(gitCommonDir),
|
|
144
|
+
]);
|
|
145
|
+
const linked = path.join(commonReal, 'worktrees');
|
|
146
|
+
if (privateReal !== commonReal && path.dirname(privateReal) !== linked) {
|
|
147
|
+
throw invalidIdentity('private-git-dir-outside-common-dir');
|
|
148
|
+
}
|
|
149
|
+
return path.join(privateGitDir, IDENTITY_FILE);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function readIdentityFile(identityPath) {
|
|
153
|
+
let text;
|
|
154
|
+
try {
|
|
155
|
+
text = await readFile(identityPath, 'utf8');
|
|
156
|
+
} catch (error) {
|
|
157
|
+
if (error?.code === 'ENOENT') return null;
|
|
158
|
+
throw error;
|
|
159
|
+
}
|
|
160
|
+
if (!text.endsWith('\n')) throw invalidIdentity('missing-terminator');
|
|
161
|
+
const identity = text.slice(0, -1);
|
|
162
|
+
// The anchored pattern rejects a second line as well as a malformed one.
|
|
163
|
+
if (!UUID_V4.test(identity)) throw invalidIdentity('malformed-uuid');
|
|
164
|
+
return identity;
|
|
165
|
+
}
|