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
@@ -4,6 +4,7 @@
4
4
  // requestSchemaError encodes for the reference model — production code must not
5
5
  // depend on test code, so the rules are duplicated here rather than imported.
6
6
  import { pointer } from './request.js';
7
+ import { validateLedgerRepairRequest } from './ledger-repair.js';
7
8
 
8
9
  const NAMESPACE_ID = /^wbns_[a-f0-9]{32}$/;
9
10
  const ITEM_ID = /^wb_[0-9A-HJKMNP-TV-Z]{26}$/;
@@ -24,7 +25,15 @@ const REQUIRED_MEMBERS = {
24
25
  // Returns an array of {path, code, message} issues (empty when the request is valid).
25
26
  // The request must already be JSON-parsed and deep-normalized (plain objects/arrays,
26
27
  // JsonNumber unwrapped) — this module only checks shape, not JSON syntax.
28
+ //
29
+ // `number-repair` is a ledger-repair request, not a work-claim one. It is
30
+ // answered here because the claim journal validates every entry it replays
31
+ // through this seam, so a repair request the journal accepts must be the exact
32
+ // request the command accepts. Its members are the repair domain's own.
27
33
  export function validateClaimRequest(operation, request) {
34
+ if (operation === 'number-repair') {
35
+ return validateLedgerRepairRequest(request);
36
+ }
28
37
  const required = REQUIRED_MEMBERS[operation];
29
38
  if (!isPlainObject(request)) {
30
39
  return [problem([], 'invalid-type', `The ${operation} request must be a JSON object.`)];
@@ -127,7 +127,12 @@ export function namespaceLockHeld(hold, storePath) {
127
127
  && hold.storePath === storePath;
128
128
  }
129
129
 
130
- export async function withClaimLock(storePath, fn) {
130
+ // `withClaimLock` is the one file-lock primitive; the counter name says which
131
+ // lock a caller is taking, so a profile can hold each one to its own number
132
+ // instead of watching a single total drift.
133
+ export async function withClaimLock(storePath, fn, {
134
+ counter = 'namespace_lock_acquisitions',
135
+ } = {}) {
131
136
  const directory = path.dirname(storePath);
132
137
  await mkdir(directory, { recursive: true });
133
138
  const lockPath = `${storePath}.lock`;
@@ -143,7 +148,7 @@ export async function withClaimLock(storePath, fn) {
143
148
  }
144
149
 
145
150
  try {
146
- await acquireClaimLock(candidatePath, lockPath, recoveryPath);
151
+ await acquireClaimLock(candidatePath, lockPath, recoveryPath, counter);
147
152
  const hold = { [NAMESPACE_LOCK_HOLD]: true, storePath, released: false };
148
153
  try {
149
154
  return await fn(hold);
@@ -158,7 +163,7 @@ export async function withClaimLock(storePath, fn) {
158
163
  }
159
164
  }
160
165
 
161
- async function acquireClaimLock(candidatePath, lockPath, recoveryPath) {
166
+ async function acquireClaimLock(candidatePath, lockPath, recoveryPath, counter) {
162
167
  for (;;) {
163
168
  const recoveryOwner = await readLockOwner(recoveryPath);
164
169
  if (recoveryOwner) {
@@ -168,7 +173,7 @@ async function acquireClaimLock(candidatePath, lockPath, recoveryPath) {
168
173
  }
169
174
  try {
170
175
  await link(candidatePath, lockPath);
171
- recordCount('namespace_lock_acquisitions');
176
+ recordCount(counter);
172
177
  return;
173
178
  } catch (error) {
174
179
  if (error?.code !== 'EEXIST') throw error;
package/src/cli.js CHANGED
@@ -1,5 +1,6 @@
1
+ import { randomUUID } from 'node:crypto';
1
2
  import { readFileSync } from 'node:fs';
2
- import { mkdir, open, writeFile } from 'node:fs/promises';
3
+ import { access, link, mkdir, open, unlink, writeFile } from 'node:fs/promises';
3
4
  import path from 'node:path';
4
5
  import { fileURLToPath } from 'node:url';
5
6
 
@@ -7,13 +8,16 @@ import { resolveClaimBackend, resolveWorkClaimCapability } from './claim-capabil
7
8
  import {
8
9
  appendClaimEntry,
9
10
  claimJournalPath,
11
+ isClaimJournalCapacityError,
10
12
  claimReconcileLogPath,
13
+ parseReconcileLog,
11
14
  replayClaimJournal,
12
15
  writeReconcileLog,
13
16
  } from './claim-journal.js';
14
17
  import { claimAcquire, claimRead, claimRelease, claimRenew } from './claim-operations.js';
15
18
  import {
16
19
  adoptItemRevision,
20
+ hydrateClaimJournalFromHead,
17
21
  publishClaimed,
18
22
  reconcileClaimJournal,
19
23
  readPublicationOutcome,
@@ -23,7 +27,7 @@ import {
23
27
  } from './claim-publication.js';
24
28
  import { loadExtensionDeclaration } from './extensions.js';
25
29
  import { validateClaimRequest } from './claim-request.js';
26
- import { checkProspectiveMerge, parseReconcileLog } from './claim-prospective.js';
30
+ import { checkProspectiveMerge } from './claim-prospective.js';
27
31
  import { proposeExtensionDeclaration } from './extension-provision.js';
28
32
  import { selectCommittedAdoptions } from './claim-sync.js';
29
33
  import {
@@ -36,6 +40,14 @@ import {
36
40
  import { readGitTreeFile, readGitTreeLedger } from './git-reconciliation.js';
37
41
  import { finalizeFromRecoveryToken, withAutoCommit } from './git-autocommit.js';
38
42
  import { loadLedger } from './ledger.js';
43
+ import {
44
+ finalizeNumberRepairCommit,
45
+ isNumberRepairRecoveryToken,
46
+ ledgerRepairInvalidRequest,
47
+ numberRepair,
48
+ numberRepairProposal,
49
+ } from './ledger-repair.js';
50
+ import { inspectVersionDrift } from './version-drift.js';
39
51
  import {
40
52
  assertReportOutputOutsideLedger,
41
53
  buildReportModel,
@@ -80,10 +92,20 @@ import { normalizeJsonValue, parseJsonRequest, sortIssues } from './request.js';
80
92
  import { selectReady } from './ready.js';
81
93
  import { isCalendarDate, validateLedger } from './validate.js';
82
94
  import { inspectWorkbench } from './workbench.js';
95
+ import { readWorktreeIdentity } from './worktree-identity.js';
83
96
 
84
97
  const CLAIM_OPERATIONS = { read: claimRead, acquire: claimAcquire, renew: claimRenew, release: claimRelease };
85
98
  const MUTATION_CONTRACT_VERSION = 5;
86
- const AUTO_COMMIT_COMMANDS = new Set(['create', 'transition', 'patch', 'publish-claimed']);
99
+ const ITEM_ID = /^wb_[0-7][0-9A-HJKMNP-TV-Z]{25}$/;
100
+ const AUTO_COMMIT_COMMANDS = new Set([
101
+ 'create',
102
+ 'transition',
103
+ 'parent-migrate',
104
+ 'snooze',
105
+ 'patch',
106
+ 'publish-claimed',
107
+ 'number-repair',
108
+ ]);
87
109
  // The report failures this command states. A code outside the set is an
88
110
  // unexpected condition, never a classification the command can pass through.
89
111
  const REPORT_FAILURE_CODES = new Set([
@@ -110,7 +132,7 @@ const COMMAND_SUMMARIES = {
110
132
  list: 'List a validated ledger as bounded, paginated item summaries.',
111
133
  create: 'Create one ledger item through atomic, no-clobber publication.',
112
134
  transition: "Transition one item's lifecycle, guarded by lock and compare-and-swap.",
113
- 'parent-migrate': 'Move one live item to or from an epic with CAS fencing.',
135
+ 'parent-migrate': 'Move one item to or from an epic with CAS fencing.',
114
136
  snooze: 'Set or clear an item snooze date with CAS fencing.',
115
137
  patch: "Patch an item's priority and relation lists, guarded the same way.",
116
138
  'extensions-provision': 'Declare explicitly selected existing extension members.',
@@ -120,6 +142,11 @@ const COMMAND_SUMMARIES = {
120
142
  'claim-sync': 'Import committed adoption evidence into the local claim journal.',
121
143
  'claim-adopt': 'Rule a committed out-of-protocol item revision legitimate.',
122
144
  'mutation-finalize': 'Complete the Git commit an auto-commit mutation could not establish.',
145
+ 'version-drift': 'Check installed skill and running core version compatibility.',
146
+ 'number-repair': 'Apply a proposed duplicate-number repair to a ledger the mutation gate refuses.',
147
+ provision: 'Provision a Git-backed work-claim namespace.',
148
+ claim: 'Read and coordinate work claims for one ledger.',
149
+ 'claim-verify': 'Reconcile the durable claim journal with ledger bytes and Git.',
123
150
  };
124
151
 
125
152
  const KNOWN_COMMANDS = new Set([
@@ -144,6 +171,8 @@ const KNOWN_COMMANDS = new Set([
144
171
  'claim-sync',
145
172
  'claim-adopt',
146
173
  'mutation-finalize',
174
+ 'version-drift',
175
+ 'number-repair',
147
176
  ]);
148
177
 
149
178
  const CLAIM_SUBCOMMAND_SUMMARIES = {
@@ -178,6 +207,22 @@ export async function runCli(argumentsList, { scenario } = {}) {
178
207
  return;
179
208
  }
180
209
 
210
+ if (command === 'version-drift') {
211
+ const parsedOptions = parseContractOptions(command, argumentsList.slice(1));
212
+ if (parsedOptions.issues.length > 0) {
213
+ writeInvalidRequest(command, parsedOptions.issues);
214
+ return;
215
+ }
216
+ writeClaimEnvelope(await inspectVersionDrift({
217
+ skillPath: parsedOptions.options.skill
218
+ ?? fileURLToPath(new URL('../skills/wowbagger/SKILL.md', import.meta.url)),
219
+ packagePath: fileURLToPath(new URL('../package.json', import.meta.url)),
220
+ runningDistribution: DISTRIBUTION_VERSION,
221
+ runningContractVersion: 5,
222
+ }));
223
+ return;
224
+ }
225
+
181
226
  if (command === 'capabilities') {
182
227
  const parsedOptions = parseContractOptions(command, argumentsList.slice(1));
183
228
  if (parsedOptions.issues.length > 0) {
@@ -380,8 +425,25 @@ export async function runCli(argumentsList, { scenario } = {}) {
380
425
  writeInvalidRequest(command, parsedRequest.issues);
381
426
  return;
382
427
  }
428
+ const ledger = await loadLedger(parsedOptions.options.ledger);
429
+ const validation = validateLedger(ledger);
430
+ if (!validation.valid) {
431
+ process.stdout.write(`${JSON.stringify({
432
+ ok: false,
433
+ command,
434
+ contract_version: MUTATION_CONTRACT_VERSION,
435
+ state: 'unchanged',
436
+ error: {
437
+ code: 'ledger-invalid',
438
+ message: 'The configured ledger is invalid.',
439
+ details: { validation_errors: validation.errors },
440
+ },
441
+ })}\n`);
442
+ process.exitCode = 3;
443
+ return;
444
+ }
383
445
  const proposal = proposeExtensionDeclaration({
384
- ledger: await loadLedger(parsedOptions.options.ledger),
446
+ ledger,
385
447
  members: parsedRequest.value?.members,
386
448
  });
387
449
  if (!proposal.ok) {
@@ -415,34 +477,72 @@ export async function runCli(argumentsList, { scenario } = {}) {
415
477
  })}\n`);
416
478
  return;
417
479
  }
480
+ const sameDeclaration = (declaration) => declaration
481
+ && Object.keys(declaration.members).length === Object.keys(proposal.declaration.members).length
482
+ && Object.entries(proposal.declaration.members)
483
+ .every(([name, type]) => declaration.members[name] === type);
484
+ const refuseConflict = () => {
485
+ process.stdout.write(`${JSON.stringify({
486
+ ok: false,
487
+ command,
488
+ contract_version: MUTATION_CONTRACT_VERSION,
489
+ state: 'unchanged',
490
+ error: {
491
+ code: 'extension-declaration-conflict',
492
+ message: 'The ledger already carries a different extension declaration.',
493
+ details: { output: '.wowbagger/extensions.json' },
494
+ },
495
+ })}\n`);
496
+ process.exitCode = 4;
497
+ };
418
498
  const existing = await loadExtensionDeclaration(parsedOptions.options.ledger);
419
499
  if (existing.declared) {
420
- const same = existing.declaration
421
- && JSON.stringify(existing.declaration) === JSON.stringify(proposal.declaration);
422
- if (!same) {
423
- process.stdout.write(`${JSON.stringify({
424
- ok: false,
425
- command,
426
- contract_version: MUTATION_CONTRACT_VERSION,
427
- state: 'unchanged',
428
- error: {
429
- code: 'extension-declaration-conflict',
430
- message: 'The ledger already carries a different extension declaration.',
431
- details: { output: '.wowbagger/extensions.json' },
432
- },
433
- })}\n`);
434
- process.exitCode = 4;
500
+ if (!sameDeclaration(existing.declaration)) {
501
+ refuseConflict();
435
502
  return;
436
503
  }
437
504
  } else {
438
505
  await mkdir(path.dirname(output), { recursive: true });
439
- const handle = await open(output, 'wx');
506
+ if (scenario === 'extension-provision-concurrent-same') {
507
+ await writeFile(
508
+ output,
509
+ '{\n "extensions_version": 1,\n "members": {"tags":"string-list"}\n}\n',
510
+ { flag: 'wx' },
511
+ );
512
+ } else if (scenario === 'extension-provision-concurrent-different') {
513
+ await writeFile(
514
+ output,
515
+ '{"extensions_version":1,"members":{"tier":"string"}}\n',
516
+ { flag: 'wx' },
517
+ );
518
+ }
519
+ const temporary = path.join(path.dirname(output), `.extensions-${randomUUID()}.tmp`);
520
+ const handle = await open(temporary, 'wx');
440
521
  try {
441
522
  await handle.writeFile(proposal.source, 'utf8');
442
523
  await handle.sync();
443
524
  } finally {
444
525
  await handle.close();
445
526
  }
527
+ try {
528
+ await extensionProvisionCheckpoint(scenario, parsedOptions.options.ledger);
529
+ try {
530
+ await link(temporary, output);
531
+ } catch (error) {
532
+ if (error?.code !== 'EEXIST') throw error;
533
+ const winner = await loadExtensionDeclaration(parsedOptions.options.ledger);
534
+ if (!winner.declared || !sameDeclaration(winner.declaration)) {
535
+ refuseConflict();
536
+ return;
537
+ }
538
+ }
539
+ } finally {
540
+ try {
541
+ await unlink(temporary);
542
+ } catch (error) {
543
+ if (error?.code !== 'ENOENT') throw error;
544
+ }
545
+ }
446
546
  }
447
547
  process.stdout.write(`${JSON.stringify({
448
548
  ok: true,
@@ -608,6 +708,12 @@ export async function runCli(argumentsList, { scenario } = {}) {
608
708
  writeClaimInvalidRequest(command, parsedOptions.issues);
609
709
  return;
610
710
  }
711
+ if (parsedOptions.options.id !== undefined && !ITEM_ID.test(parsedOptions.options.id)) {
712
+ writeClaimInvalidRequest(command, [
713
+ issue('/arguments', 'invalid-value', 'Argument --id must be a canonical Wowbagger item ID.'),
714
+ ]);
715
+ return;
716
+ }
611
717
  const ledgerDirectory = parsedOptions.options.ledger;
612
718
  const gitCommonDir = await resolveVerifiedGitCommonDir(ledgerDirectory);
613
719
  const namespace = gitCommonDir ? await readNamespace(ledgerDirectory) : null;
@@ -621,6 +727,7 @@ export async function runCli(argumentsList, { scenario } = {}) {
621
727
  ledgerDirectory,
622
728
  gitCommonDir,
623
729
  namespace,
730
+ targetItemId: parsedOptions.options.id ?? null,
624
731
  }));
625
732
  return;
626
733
  }
@@ -815,6 +922,13 @@ export async function runCli(argumentsList, { scenario } = {}) {
815
922
  writeClaimInvalidRequest(command, parsedOptions.issues);
816
923
  return;
817
924
  }
925
+ if (isNumberRepairRecoveryToken(parsedOptions.options.recoveryToken)) {
926
+ writeClaimEnvelope(await finalizeNumberRepairCommit({
927
+ ledgerDirectory: parsedOptions.options.ledger,
928
+ token: parsedOptions.options.recoveryToken,
929
+ }));
930
+ return;
931
+ }
818
932
  writeClaimEnvelope(await finalizeFromRecoveryToken({
819
933
  ledgerDirectory: parsedOptions.options.ledger,
820
934
  token: parsedOptions.options.recoveryToken,
@@ -822,6 +936,47 @@ export async function runCli(argumentsList, { scenario } = {}) {
822
936
  return;
823
937
  }
824
938
 
939
+ // The proposal is request-free and read-only: the ledger is its whole input.
940
+ if (command === 'number-repair-proposal') {
941
+ const parsedOptions = parseContractOptions(command, argumentsList.slice(1));
942
+ if (parsedOptions.issues.length > 0) {
943
+ writeClaimEnvelope(ledgerRepairInvalidRequest(command, parsedOptions.issues));
944
+ return;
945
+ }
946
+ writeClaimEnvelope(await numberRepairProposal(parsedOptions.options.ledger));
947
+ return;
948
+ }
949
+
950
+ if (command === 'number-repair') {
951
+ const parsedOptions = parseContractOptions(command, argumentsList.slice(1));
952
+ if (parsedOptions.issues.length > 0) {
953
+ writeClaimEnvelope(ledgerRepairInvalidRequest(command, parsedOptions.issues));
954
+ return;
955
+ }
956
+ let bytes;
957
+ try {
958
+ bytes = await requestSource(parsedOptions.options.input);
959
+ } catch {
960
+ writeClaimEnvelope(ledgerRepairInvalidRequest(command, [
961
+ issue('/input', 'invalid-value', 'Request input could not be read.'),
962
+ ]));
963
+ return;
964
+ }
965
+ const parsedRequest = parseJsonRequest(bytes);
966
+ if (parsedRequest.issues.length > 0) {
967
+ writeClaimEnvelope(ledgerRepairInvalidRequest(command, parsedRequest.issues));
968
+ return;
969
+ }
970
+ // normalizeJsonValue rebuilds the tree into plain objects with every
971
+ // JsonNumber unwrapped, so the exact-member check cannot be slipped past
972
+ // with a `__proto__` member and the number witnesses compare as numbers.
973
+ writeClaimEnvelope(await numberRepair(normalizeJsonValue(parsedRequest.value), {
974
+ ledgerDirectory: parsedOptions.options.ledger,
975
+ autoCommit: parsedOptions.options.autoCommit === true,
976
+ }));
977
+ return;
978
+ }
979
+
825
980
  if (command === 'claim-adopt') {
826
981
  const parsedOptions = parseContractOptions(command, argumentsList.slice(1));
827
982
  if (parsedOptions.issues.length > 0) {
@@ -1302,29 +1457,34 @@ function parseContractOptions(command, argumentsList) {
1302
1457
  const options = {};
1303
1458
  const issues = [];
1304
1459
  const seen = new Set();
1305
- const valueFlags = command === 'inspect'
1306
- ? new Map([['--ledger', 'ledger'], ['--id', 'id'], ['--number', 'number'], ['--as-of', 'asOf']])
1307
- : command === 'report'
1308
- ? new Map([['--ledger', 'ledger'], ['--as-of', 'asOf'], ['--out', 'out'], ['--view', 'view']])
1309
- : command === 'create' || command === 'transition' || command === 'patch' || command === 'list'
1310
- || command === 'parent-migrate'
1311
- || command === 'snooze'
1312
- || command === 'extensions-provision'
1313
- || command === 'publish-claimed' || command === 'publication-read'
1314
- || command === 'claim-read' || command === 'claim-acquire' || command === 'claim-renew'
1315
- || command === 'claim-release' || command === 'claim-adopt'
1316
- ? new Map([['--ledger', 'ledger'], ['--input', 'input']])
1317
- : command === 'mutation-finalize'
1318
- ? new Map([['--ledger', 'ledger'], ['--recovery-token', 'recoveryToken']])
1319
- : command === 'claim-merge-verify'
1320
- ? new Map([['--ledger', 'ledger'], ['--base', 'base'], ['--head', 'head']])
1321
- : command === 'provision' || command === 'claim-capabilities' || command === 'claim-verify'
1322
- || command === 'claim-sync'
1323
- ? new Map([['--ledger', 'ledger']])
1324
- : command === 'mint-id'
1325
- ? new Map([['--date', 'date']])
1326
- : new Map();
1327
- // Bare flags carry no value. `--auto-commit` is accepted only on the four
1460
+ const valueFlags = command === 'version-drift'
1461
+ ? new Map([['--skill', 'skill']])
1462
+ : command === 'inspect'
1463
+ ? new Map([['--ledger', 'ledger'], ['--id', 'id'], ['--number', 'number'], ['--as-of', 'asOf']])
1464
+ : command === 'report'
1465
+ ? new Map([['--ledger', 'ledger'], ['--as-of', 'asOf'], ['--out', 'out'], ['--view', 'view']])
1466
+ : command === 'create' || command === 'transition' || command === 'patch' || command === 'list'
1467
+ || command === 'parent-migrate'
1468
+ || command === 'snooze'
1469
+ || command === 'extensions-provision'
1470
+ || command === 'publish-claimed' || command === 'publication-read'
1471
+ || command === 'claim-read' || command === 'claim-acquire' || command === 'claim-renew'
1472
+ || command === 'claim-release' || command === 'claim-adopt'
1473
+ || command === 'number-repair'
1474
+ ? new Map([['--ledger', 'ledger'], ['--input', 'input']])
1475
+ : command === 'mutation-finalize'
1476
+ ? new Map([['--ledger', 'ledger'], ['--recovery-token', 'recoveryToken']])
1477
+ : command === 'claim-merge-verify'
1478
+ ? new Map([['--ledger', 'ledger'], ['--base', 'base'], ['--head', 'head']])
1479
+ : command === 'claim-verify'
1480
+ ? new Map([['--ledger', 'ledger'], ['--id', 'id']])
1481
+ : command === 'provision' || command === 'claim-capabilities'
1482
+ || command === 'claim-sync' || command === 'number-repair-proposal'
1483
+ ? new Map([['--ledger', 'ledger']])
1484
+ : command === 'mint-id'
1485
+ ? new Map([['--date', 'date']])
1486
+ : new Map();
1487
+ // Bare flags carry no value. `--auto-commit` is accepted only on the
1328
1488
  // commands whose Git finalization it folds in; `--workbench` only on the one
1329
1489
  const bareFlags = AUTO_COMMIT_COMMANDS.has(command)
1330
1490
  ? new Map([['--auto-commit', 'autoCommit']])
@@ -1333,13 +1493,17 @@ function parseContractOptions(command, argumentsList) {
1333
1493
  : command === 'inspect'
1334
1494
  ? new Map([['--workbench', 'workbench']])
1335
1495
  : new Map();
1336
- const optionalFlags = command === 'mint-id'
1337
- ? new Set(['--date'])
1338
- : command === 'report'
1339
- ? new Set(['--out', '--view'])
1340
- : command === 'inspect'
1341
- ? new Set(['--id', '--number', '--as-of'])
1342
- : new Set();
1496
+ const optionalFlags = command === 'version-drift'
1497
+ ? new Set(['--skill'])
1498
+ : command === 'mint-id'
1499
+ ? new Set(['--date'])
1500
+ : command === 'report'
1501
+ ? new Set(['--out', '--view'])
1502
+ : command === 'inspect'
1503
+ ? new Set(['--id', '--number', '--as-of'])
1504
+ : command === 'claim-verify'
1505
+ ? new Set(['--id'])
1506
+ : new Set();
1343
1507
  for (let index = 0; index < argumentsList.length; index += 1) {
1344
1508
  const argument = argumentsList[index];
1345
1509
  if (argument === '--json') {
@@ -1372,6 +1536,7 @@ function parseContractOptions(command, argumentsList) {
1372
1536
  continue;
1373
1537
  }
1374
1538
  seen.add(argument);
1539
+
1375
1540
  const value = argumentsList[index + 1];
1376
1541
  if (!value || value.startsWith('--')) {
1377
1542
  issues.push(argumentIssue(index + 1, 'missing-argument', `Argument ${argument} requires a value.`));
@@ -1410,6 +1575,23 @@ function parseContractOptions(command, argumentsList) {
1410
1575
  return { options, issues: sortIssues(issues) };
1411
1576
  }
1412
1577
 
1578
+ async function extensionProvisionCheckpoint(scenario, ledgerDirectory) {
1579
+ if (scenario !== 'extension-provision-pause-before-link') return;
1580
+ const reached = path.join(ledgerDirectory, '.wowbagger-test-extension-provision-reached');
1581
+ const allowed = path.join(ledgerDirectory, '.wowbagger-test-extension-provision-continue');
1582
+ await writeFile(reached, 'reached\n');
1583
+ const deadline = Date.now() + 2_000;
1584
+ while (Date.now() < deadline) {
1585
+ try {
1586
+ await access(allowed);
1587
+ return;
1588
+ } catch (error) {
1589
+ if (error?.code !== 'ENOENT') throw error;
1590
+ }
1591
+ await new Promise((resolve) => setTimeout(resolve, 10));
1592
+ }
1593
+ throw new Error('Timed out waiting to continue extension declaration publication.');
1594
+ }
1413
1595
  // `--auto-commit` is the only bare flag beyond `--json`, and it changes what
1414
1596
  // happens after the mutation, never the mutation itself.
1415
1597
  function autoCommitted(command, options, run, scenario, operationId = null, targetItemId = null) {
@@ -1451,6 +1633,7 @@ function writeInvalidRequest(command, issues) {
1451
1633
  ok: false,
1452
1634
  command,
1453
1635
  contract_version: MUTATION_CONTRACT_VERSION,
1636
+ ...(command === 'parent-migrate' || command === 'snooze' ? { state: 'unchanged' } : {}),
1454
1637
  error: outcome.error,
1455
1638
  })}\n`);
1456
1639
  process.exitCode = outcome.exit;
@@ -1623,18 +1806,42 @@ async function runClaimCommand(claimCommand, argumentsList) {
1623
1806
  const operation = CLAIM_OPERATIONS[claimCommand];
1624
1807
  if (claimCommand === 'read') {
1625
1808
  try {
1626
- const replayed = await replayClaimJournal(journalPath, namespace);
1809
+ // The lock-free read never writes: it projects committed evidence in
1810
+ // memory so an unhydrated worktree still answers with the real claim.
1811
+ const replayed = await hydrateClaimJournalFromHead({
1812
+ ledgerDirectory: parsedOptions.options.ledger,
1813
+ gitCommonDir,
1814
+ namespace,
1815
+ replayed: await replayClaimJournal(journalPath, namespace),
1816
+ persist: false,
1817
+ });
1627
1818
  writeClaimEnvelope(operation(replayed.state, request, new Date().toISOString()).envelope);
1628
- } catch {
1629
- writeClaimEnvelope(claimStoreUnavailable(claimCommand, 'claim-store-unreadable'));
1819
+ } catch (error) {
1820
+ writeClaimEnvelope(claimStoreUnavailable(
1821
+ claimCommand,
1822
+ isClaimJournalCapacityError(error) ? 'journal-capacity-exceeded' : 'claim-store-unreadable',
1823
+ ));
1630
1824
  }
1631
1825
  return;
1632
1826
  }
1633
1827
  try {
1634
1828
  const envelope = await withClaimLock(storePath, async () => {
1829
+ // A claim lifecycle command classifies item reconciliation and refuses
1830
+ // on it, so it must reason from the same writer evidence every other
1831
+ // classifying surface uses; otherwise one command grants a claim on
1832
+ // exactly the state another command refuses to write. It writes no item
1833
+ // byte and no writer-attributed entry, so it reads the identity it
1834
+ // already answers to rather than creating one. Journal and identity are
1835
+ // both store state this command only reads, so neither reading failure
1836
+ // is anything but an unreadable store.
1635
1837
  let replayed;
1838
+ let currentWorktreeId;
1636
1839
  try {
1637
1840
  replayed = await replayClaimJournal(journalPath, namespace);
1841
+ currentWorktreeId = await readWorktreeIdentity({
1842
+ ledgerDirectory: parsedOptions.options.ledger,
1843
+ gitCommonDir,
1844
+ });
1638
1845
  } catch (error) {
1639
1846
  throw taggedFailure('CLAIM_STORE_UNREADABLE', error);
1640
1847
  }
@@ -1648,6 +1855,8 @@ async function runClaimCommand(claimCommand, argumentsList) {
1648
1855
  replayed,
1649
1856
  physicalNow,
1650
1857
  targetItemId: request.item_id,
1858
+ currentWorktreeId,
1859
+ writeLogOnUnsafe: false,
1651
1860
  });
1652
1861
  } catch (error) {
1653
1862
  throw taggedFailure(
@@ -1657,7 +1866,15 @@ async function runClaimCommand(claimCommand, argumentsList) {
1657
1866
  error,
1658
1867
  );
1659
1868
  }
1660
- if (reconciled.unsafe) {
1869
+ // Release relinquishes authority; it never extends or grants any. A
1870
+ // barrier that refuses it strands the lease in the worktree least able
1871
+ // to clear the barrier, because no other worktree can take the item over
1872
+ // while the claim is held. Acquire and renew do extend authority against
1873
+ // bytes nobody has ruled legitimate, so they keep refusing. Only this
1874
+ // classification is bypassed: an unresolvable identity, an unreadable
1875
+ // journal, and a clock floor that will not persist all throw before this
1876
+ // point, and the tuple compare-and-swap below still rules on the request.
1877
+ if (reconciled.unsafe && claimCommand !== 'release') {
1661
1878
  return claimStoreUnavailable(claimCommand, 'publication-reconciliation-required', {
1662
1879
  findings: reconciled.findings,
1663
1880
  });
@@ -1697,6 +1914,10 @@ async function runClaimCommand(claimCommand, argumentsList) {
1697
1914
  writeClaimEnvelope(claimStoreUnavailable(claimCommand, 'claim-store-locked'));
1698
1915
  return;
1699
1916
  }
1917
+ if (isClaimJournalCapacityError(error)) {
1918
+ writeClaimEnvelope(claimStoreUnavailable(claimCommand, 'journal-capacity-exceeded'));
1919
+ return;
1920
+ }
1700
1921
  if (error?.code === 'CLAIM_STORE_UNREADABLE') {
1701
1922
  writeClaimEnvelope(claimStoreUnavailable(claimCommand, 'claim-store-unreadable'));
1702
1923
  return;
@@ -1827,7 +2048,7 @@ function usage(command) {
1827
2048
  return 'Usage: wowbagger claim-adopt --ledger <dir> --input <request.json> --json';
1828
2049
  }
1829
2050
  if (command === 'claim-verify') {
1830
- return 'Usage: wowbagger claim-verify --ledger <dir> --json';
2051
+ return 'Usage: wowbagger claim-verify --ledger <dir> [--id <wb_...>] --json';
1831
2052
  }
1832
2053
  if (command === 'snooze') {
1833
2054
  return 'Usage: wowbagger snooze --ledger <dir> --input <request.json> --json [--auto-commit]';
@@ -1843,6 +2064,17 @@ function usage(command) {
1843
2064
  return 'Usage: wowbagger mutation-finalize --ledger <dir> --recovery-token <token> --json';
1844
2065
  }
1845
2066
 
2067
+ if (command === 'version-drift') {
2068
+ return 'Usage: wowbagger version-drift [--skill <path>] --json';
2069
+ }
2070
+ if (command === 'number-repair-proposal') {
2071
+ return 'Usage: wowbagger number-repair-proposal --ledger <dir> --json';
2072
+ }
2073
+
2074
+ if (command === 'number-repair') {
2075
+ return 'Usage: wowbagger number-repair --ledger <dir> --input <json-file|-> --json [--auto-commit]';
2076
+ }
2077
+
1846
2078
  if (command === 'claim') {
1847
2079
  return 'Usage: wowbagger claim <read|acquire|renew|release|capabilities> [options]';
1848
2080
  }
@@ -1977,6 +2209,20 @@ function commandHelp(command) {
1977
2209
  ].join('\n');
1978
2210
  }
1979
2211
 
2212
+ if (command === 'list') {
2213
+ return [
2214
+ header,
2215
+ '',
2216
+ `${usage(command)}`,
2217
+ '',
2218
+ 'Request JSON members: query_version, as_of, filters, sort, page_size, and cursor.',
2219
+ 'sort is {field, direction}; direction is ascending or descending.',
2220
+ 'Use the returned page.next_cursor as cursor to resume a stable snapshot.',
2221
+ 'A changed snapshot returns list-snapshot-changed; restart without cursor.',
2222
+ '',
2223
+ ].join('\n');
2224
+ }
2225
+
1980
2226
  return [
1981
2227
  header,
1982
2228
  '',
package/src/extensions.js CHANGED
@@ -47,6 +47,7 @@ export const RESERVED_EXTENSION_MEMBERS = Object.freeze([
47
47
  'body',
48
48
  'number',
49
49
  'priority',
50
+ 'extensions',
50
51
  ]);
51
52
 
52
53
  const RESERVED = new Set(RESERVED_EXTENSION_MEMBERS);