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.
Files changed (46) hide show
  1. package/CHANGELOG.md +509 -0
  2. package/README.md +272 -136
  3. package/docs/adapter-contract.md +1 -1
  4. package/docs/host-contract.md +7 -1
  5. package/docs/mutation-contract.md +354 -82
  6. package/docs/work-claim-contract.md +466 -88
  7. package/package.json +2 -2
  8. package/schemas/core-capabilities-response.json +1 -1
  9. package/schemas/core-envelope.json +4 -3
  10. package/schemas/index.json +18 -0
  11. package/schemas/ledger-repair-proposal.json +170 -0
  12. package/schemas/ledger-repair-request.json +61 -0
  13. package/schemas/ledger-repair-response.json +90 -0
  14. package/schemas/report-config-v1.json +4 -0
  15. package/schemas/report-config-v2.json +5 -0
  16. package/skills/wowbagger/SKILL.md +242 -59
  17. package/src/adapter/core-probe.js +3 -4
  18. package/src/adapter/process-outcome.js +8 -1
  19. package/src/claim-capabilities.js +3 -3
  20. package/src/claim-coordinator.js +61 -12
  21. package/src/claim-journal.js +212 -7
  22. package/src/claim-prospective.js +1 -28
  23. package/src/claim-publication.js +412 -78
  24. package/src/claim-request.js +9 -0
  25. package/src/claim-store.js +9 -4
  26. package/src/cli.js +302 -56
  27. package/src/extensions.js +1 -0
  28. package/src/git-autocommit.js +106 -43
  29. package/src/git-reconciliation.js +74 -19
  30. package/src/git-worktrees.js +73 -0
  31. package/src/instrumentation.js +1 -0
  32. package/src/launch.js +2 -2
  33. package/src/ledger-repair.js +1170 -0
  34. package/src/mutation.js +73 -15
  35. package/src/reconciliation-classifier.js +117 -0
  36. package/src/report-evidence.js +158 -41
  37. package/src/report-graph.js +201 -73
  38. package/src/report-html.js +358 -155
  39. package/src/report-impact.js +106 -0
  40. package/src/report-selection.js +97 -0
  41. package/src/report-sequencing.js +4 -4
  42. package/src/report-svg.js +74 -20
  43. package/src/report-view.js +14 -1
  44. package/src/report.js +109 -23
  45. package/src/version-drift.js +98 -0
  46. 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
+ }