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
@@ -5,6 +5,7 @@ import {
5
5
  appendClaimEntry,
6
6
  assertClaimJournalCapacity,
7
7
  claimJournalPath,
8
+ isClaimJournalCapacityError,
8
9
  claimReconcileLogPath,
9
10
  replayClaimJournal,
10
11
  writeReconcileLog,
@@ -14,34 +15,69 @@ import { readBack } from './claim-operations.js';
14
15
  import { reconcileClaimJournal } from './claim-publication.js';
15
16
  import { claimStorePath, resolveVerifiedGitCommonDir, withClaimLock } from './claim-store.js';
16
17
  import { readNamespace } from './namespace.js';
18
+ import {
19
+ assertUniqueWorktreeIdentity,
20
+ ensureWorktreeIdentity,
21
+ identityDiagnosticDetails,
22
+ } from './worktree-identity.js';
17
23
 
18
- export async function withLegacyMutationFence(ledgerDirectory, itemId, command, write) {
24
+ export async function withLegacyMutationFence(
25
+ ledgerDirectory,
26
+ itemId,
27
+ command,
28
+ write,
29
+ { responseCommand = command } = {},
30
+ ) {
19
31
  let gitCommonDir;
20
32
  try {
21
33
  gitCommonDir = await resolveVerifiedGitCommonDir(ledgerDirectory, { failClosed: true });
22
34
  } catch {
23
- return claimStoreUnavailable(command, 'git-verification-failed');
35
+ return claimStoreUnavailable(responseCommand, 'git-verification-failed');
24
36
  }
25
37
  const namespace = gitCommonDir ? await readNamespace(ledgerDirectory) : null;
26
38
  const capability = resolveWorkClaimCapability({ gitCommonDir, namespace });
27
39
  if (!capability.claim_protected_publication) return write();
28
40
 
41
+ // A create is the one mutation that reads an identity it was not given: it
42
+ // allocates the ledger's next number from the items this checkout holds. Its
43
+ // reconciliation therefore stays target-scoped like every other mutation, and
44
+ // gains one extra barrier below, for the coordinated items this working
45
+ // ledger cannot see at all.
46
+ const create = command === 'create-v1';
29
47
  const storePath = claimStorePath(gitCommonDir, namespace);
30
48
  const journalPath = claimJournalPath(gitCommonDir, namespace);
31
49
  let intent = null;
32
50
  try {
33
51
  return await withClaimLock(storePath, async () => {
52
+ // The writer's own identity must exist before anything it does can be
53
+ // attributed, so it is established under the same lock that serializes
54
+ // the journal, ahead of reconciliation and of any intent append. Its own
55
+ // file is judged first, so bytes this worktree owns keep reporting as its
56
+ // own invalid identity rather than as an unreadable roster.
57
+ const currentWorktreeId = await ensureWorktreeIdentity({ ledgerDirectory, gitCommonDir });
58
+ // Then the domain: a UUID two live worktrees answer to attributes
59
+ // nothing, so nothing may be written from it.
60
+ await assertUniqueWorktreeIdentity({ ledgerDirectory });
34
61
  const replayed = await replayClaimJournal(journalPath, namespace);
35
62
  const reconciled = await reconcileClaimJournal({
36
63
  ledgerDirectory,
37
64
  gitCommonDir,
38
65
  namespace,
39
66
  replayed,
67
+ currentWorktreeId,
40
68
  physicalNow: new Date().toISOString(),
41
69
  targetItemId: itemId,
70
+ writeLogOnUnsafe: false,
71
+ writeLogWhenEmpty: !create,
42
72
  });
43
- if (reconciled.unsafe) {
44
- return claimStoreUnavailable(command, 'publication-reconciliation-required', {
73
+ // The extra create barrier promised above rides on the same refusal: a
74
+ // coordinated item this checkout does not hold carries a number nobody
75
+ // here can read, so the next number this create would allocate may be one
76
+ // a sibling worktree already published. A stale revision of an item that
77
+ // is present hides no number: an item's number is immutable, so target
78
+ // scoping above still lets that create through.
79
+ if (reconciled.unsafe || (create && reconciled.missingCoordinatedItems.length > 0)) {
80
+ return claimStoreUnavailable(responseCommand, 'publication-reconciliation-required', {
45
81
  findings: reconciled.findings,
46
82
  });
47
83
  }
@@ -52,10 +88,10 @@ export async function withLegacyMutationFence(ledgerDirectory, itemId, command,
52
88
  const projected = [...reconciled.entries];
53
89
  const record = reconciled.state.claims.find((entry) => entry.item_id === itemId)
54
90
  ?? { item_id: itemId, last_epoch: '0', active: null };
55
- const mustRefuse = command === 'create-v1'
91
+ const mustRefuse = create
56
92
  ? record.last_epoch !== '0'
57
93
  : record.active !== null && observedAt < record.active.expires_at;
58
- if (mustRefuse) return legacyRefusal(command, namespace, itemId, observedAt, record);
94
+ if (mustRefuse) return legacyRefusal(responseCommand, namespace, itemId, observedAt, record);
59
95
 
60
96
  const authorize = async (expectedRevision, candidateRevision, itemPath) => {
61
97
  const attemptId = randomUUID();
@@ -68,6 +104,7 @@ export async function withLegacyMutationFence(ledgerDirectory, itemId, command,
68
104
  expected_revision: expectedRevision,
69
105
  candidate_revision: candidateRevision,
70
106
  item_path: itemPath,
107
+ writer_worktree_id: currentWorktreeId,
71
108
  observed_at: observedAt,
72
109
  };
73
110
  const terminalEntry = {
@@ -78,6 +115,7 @@ export async function withLegacyMutationFence(ledgerDirectory, itemId, command,
78
115
  command,
79
116
  committed_revision: candidateRevision,
80
117
  item_path: itemPath,
118
+ writer_worktree_id: currentWorktreeId,
81
119
  observed_at: observedAt,
82
120
  };
83
121
  const abortEntry = {
@@ -85,6 +123,7 @@ export async function withLegacyMutationFence(ledgerDirectory, itemId, command,
85
123
  attempt_id: attemptId,
86
124
  ledger_namespace: namespace,
87
125
  item_id: itemId,
126
+ ...(create ? { command } : {}),
88
127
  observed_revision: expectedRevision,
89
128
  observed_at: observedAt,
90
129
  };
@@ -108,7 +147,7 @@ export async function withLegacyMutationFence(ledgerDirectory, itemId, command,
108
147
  outcome = await write(authorize, reconciled.ledger);
109
148
  } catch (error) {
110
149
  if (intent) {
111
- return claimStoreUnavailable(command, 'legacy-mutation-outcome-unknown', {
150
+ return claimStoreUnavailable(responseCommand, 'legacy-mutation-outcome-unknown', {
112
151
  attempt_id: intent.attempt_id,
113
152
  candidate_revision: intent.candidate_revision,
114
153
  }, 'unknown');
@@ -125,7 +164,7 @@ export async function withLegacyMutationFence(ledgerDirectory, itemId, command,
125
164
  ? outcome.item?.revision
126
165
  : outcome.error?.details?.revision;
127
166
  if (committedRevision !== intent.candidate_revision) {
128
- return claimStoreUnavailable(command, 'legacy-mutation-outcome-unknown', {
167
+ return claimStoreUnavailable(responseCommand, 'legacy-mutation-outcome-unknown', {
129
168
  attempt_id: intent.attempt_id,
130
169
  candidate_revision: intent.candidate_revision,
131
170
  }, 'unknown');
@@ -140,9 +179,10 @@ export async function withLegacyMutationFence(ledgerDirectory, itemId, command,
140
179
  committed_revision: committedRevision,
141
180
  observed_at: observedAt,
142
181
  item_path: intent.item_path,
182
+ writer_worktree_id: currentWorktreeId,
143
183
  }));
144
184
  } catch {
145
- return claimStoreUnavailable(command, 'legacy-mutation-record-failed', {
185
+ return claimStoreUnavailable(responseCommand, 'legacy-mutation-record-failed', {
146
186
  attempt_id: intent.attempt_id,
147
187
  committed_revision: committedRevision,
148
188
  }, 'unknown');
@@ -154,11 +194,12 @@ export async function withLegacyMutationFence(ledgerDirectory, itemId, command,
154
194
  attempt_id: intent.attempt_id,
155
195
  ledger_namespace: namespace,
156
196
  item_id: itemId,
197
+ ...(create ? { command } : {}),
157
198
  observed_revision: intent.expected_revision,
158
199
  observed_at: observedAt,
159
200
  }));
160
201
  } catch {
161
- return claimStoreUnavailable(command, 'legacy-mutation-record-failed');
202
+ return claimStoreUnavailable(responseCommand, 'legacy-mutation-record-failed');
162
203
  }
163
204
  }
164
205
  try {
@@ -182,9 +223,17 @@ export async function withLegacyMutationFence(ledgerDirectory, itemId, command,
182
223
  : outcome;
183
224
  });
184
225
  } catch (error) {
185
- return claimStoreUnavailable(command, error?.code === 'CLAIM_LOCK_HELD'
226
+ const reason = error?.code === 'CLAIM_LOCK_HELD'
186
227
  ? 'claim-store-locked'
187
- : 'claim-store-unreadable', {}, intent ? 'unknown' : 'unchanged');
228
+ : !intent && isClaimJournalCapacityError(error)
229
+ ? 'journal-capacity-exceeded'
230
+ : 'claim-store-unreadable';
231
+ return claimStoreUnavailable(
232
+ responseCommand,
233
+ reason,
234
+ identityDiagnosticDetails(error),
235
+ intent ? 'unknown' : 'unchanged',
236
+ );
188
237
  }
189
238
  }
190
239
 
@@ -2,6 +2,7 @@ import { constants } from 'node:fs';
2
2
  import { lstat, mkdir, open, readFile, stat } from 'node:fs/promises';
3
3
  import path from 'node:path';
4
4
  import { validateClaimRequest } from './claim-request.js';
5
+ import { isCalendarDate } from './validate.js';
5
6
 
6
7
  import { advanceClockFloor, claimAcquire, claimRelease, claimRenew } from './claim-operations.js';
7
8
  import { emptyClaimState } from './claim-store.js';
@@ -19,12 +20,33 @@ const JOURNAL_ENTRY_TYPES = new Set([
19
20
  'publish-finalization',
20
21
  'publish-intent',
21
22
  'revision-adoption',
23
+ 'number-repair-intent',
24
+ 'number-repair-final',
22
25
  ]);
26
+ // Every command the legacy mutation fence journals.
27
+ const LEGACY_MUTATION_COMMANDS = new Set(['patch-v1', 'transition-v1', 'create-v1']);
23
28
  // The reconciliation log is a tracked derived artifact. Entry types that every
24
29
  // invocation appends, including refusals and read-only verification, stay out
25
30
  // of it so a mutation that changes nothing leaves the working tree unchanged.
26
31
  const UNPROJECTED_ENTRY_TYPES = new Set(['clock', 'publish-finalization']);
27
32
 
33
+ // The one place that answers whether the reconciliation log carries an entry.
34
+ export function isProjectedJournalEntry(entry) {
35
+ return !UNPROJECTED_ENTRY_TYPES.has(entry.type);
36
+ }
37
+
38
+ export function isClaimJournalCapacityError(error) {
39
+ const seen = new Set();
40
+ for (let current = error; current && typeof current === 'object'; current = current.cause) {
41
+ if (seen.has(current)) return false;
42
+ seen.add(current);
43
+ if (current.code === 'CLAIM_JOURNAL_CAPACITY'
44
+ || current.reason === 'journal-capacity-exceeded') {
45
+ return true;
46
+ }
47
+ }
48
+ return false;
49
+ }
28
50
  export function claimJournalPath(commonDir, namespace) {
29
51
  return path.join(commonDir, 'wowbagger', namespace, 'journal.ndjson');
30
52
  }
@@ -66,6 +88,10 @@ export async function assertClaimJournalCapacity(journalPath, plannedEntries) {
66
88
 
67
89
  export async function replayClaimJournal(journalPath, namespace) {
68
90
  const entries = await readJournalEntries(journalPath, namespace);
91
+ return { state: replayClaimEntries(entries, namespace), entries };
92
+ }
93
+
94
+ export function replayClaimEntries(entries, namespace) {
69
95
  let state = emptyClaimState(namespace);
70
96
  const operations = { acquire: claimAcquire, release: claimRelease, renew: claimRenew };
71
97
  for (const entry of entries) {
@@ -75,11 +101,11 @@ export async function replayClaimJournal(journalPath, namespace) {
75
101
  state = operations[entry.command](state, entry.request, entry.physical_now).state;
76
102
  }
77
103
  }
78
- return { state, entries };
104
+ return state;
79
105
  }
80
106
 
81
107
  export async function writeReconcileLog(logPath, namespace, entries) {
82
- const mergeableEntries = entries.filter((entry) => !UNPROJECTED_ENTRY_TYPES.has(entry.type));
108
+ const mergeableEntries = entries.filter(isProjectedJournalEntry);
83
109
  const content = [
84
110
  `# Wowbagger reconciliation log \`${namespace}\``,
85
111
  '',
@@ -91,7 +117,8 @@ export async function writeReconcileLog(logPath, namespace, entries) {
91
117
  '',
92
118
  ].join('\n');
93
119
  if (Buffer.byteLength(content) > MAX_RECONCILE_LOG_BYTES) throw journalCapacityExceeded();
94
- await mkdir(path.dirname(logPath), { recursive: true });
120
+ const directory = path.dirname(logPath);
121
+ await ensureDirectoryNoFollow(directory);
95
122
  const handle = await openNoFollow(logPath,
96
123
  constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC);
97
124
  try {
@@ -102,6 +129,53 @@ export async function writeReconcileLog(logPath, namespace, entries) {
102
129
  }
103
130
  }
104
131
 
132
+ export function parseReconcileLog(source, namespace) {
133
+ const lines = source.toString('utf8').split('\n');
134
+ const start = lines.indexOf('```jsonl');
135
+ if (start < 0) return [];
136
+ const end = lines.indexOf('```', start + 1);
137
+ if (end < 0) return { error: { code: 'ambiguous-journal', reason: 'unterminated-jsonl-block' } };
138
+ const entries = [];
139
+ for (const line of lines.slice(start + 1, end)) {
140
+ if (line.trim() === '') continue;
141
+ try {
142
+ const entry = JSON.parse(line);
143
+ if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) {
144
+ return { error: { code: 'ambiguous-journal', reason: 'entry-not-object' } };
145
+ }
146
+ if (Object.hasOwn(entry, 'seq') && !validSequence(entry.seq)) {
147
+ return { error: { code: 'ambiguous-journal', reason: 'sequence-out-of-range' } };
148
+ }
149
+ const entryNamespace = entry.ledger_namespace
150
+ ?? entry.request?.ledger_namespace
151
+ ?? entry.fence?.ledger_namespace;
152
+ if (entryNamespace !== undefined && entryNamespace !== namespace) {
153
+ return { error: { code: 'ambiguous-journal', reason: 'namespace-mismatch' } };
154
+ }
155
+ entries.push(entry);
156
+ } catch {
157
+ return { error: { code: 'ambiguous-journal', reason: 'entry-not-json' } };
158
+ }
159
+ }
160
+ return entries;
161
+ }
162
+
163
+ async function ensureDirectoryNoFollow(directory) {
164
+ let info;
165
+ try {
166
+ info = await lstat(directory);
167
+ } catch (error) {
168
+ if (error?.code !== 'ENOENT') throw error;
169
+ await mkdir(directory);
170
+ info = await lstat(directory);
171
+ }
172
+ if (!info.isDirectory() || info.isSymbolicLink()) {
173
+ const error = new Error(`refusing non-directory reconciliation path ${directory}`);
174
+ error.code = 'ELOOP';
175
+ throw error;
176
+ }
177
+ }
178
+
105
179
  // O_NOFOLLOW is undefined on win32, so the no-follow guarantee falls back to
106
180
  // an lstat immediately before the open. The TOCTOU window that leaves is the
107
181
  // platform's best available answer; the thrown code stays ELOOP so every
@@ -137,6 +211,9 @@ async function readJournalEntries(journalPath, namespace = null) {
137
211
  const lines = source.split('\n').filter(Boolean);
138
212
  if (lines.length > MAX_JOURNAL_ENTRIES) throw journalCapacityExceeded();
139
213
  const entries = lines.map((line) => JSON.parse(line));
214
+ const createAttempts = new Map();
215
+ const repairIntents = new Map();
216
+ const repairFinals = new Set();
140
217
  for (let index = 0; index < entries.length; index += 1) {
141
218
  if (!Number.isSafeInteger(entries[index]?.seq) || entries[index].seq !== index + 1) {
142
219
  throw journalInvalid('non-contiguous-sequence');
@@ -147,6 +224,12 @@ async function readJournalEntries(journalPath, namespace = null) {
147
224
  if (!validJournalEntry(entries[index], namespace)) {
148
225
  throw journalInvalid('invalid-entry');
149
226
  }
227
+ if (!validCreateResolution(entries[index], createAttempts)) {
228
+ throw journalInvalid('invalid-entry');
229
+ }
230
+ if (!validRepairResolution(entries[index], repairIntents, repairFinals)) {
231
+ throw journalInvalid('invalid-entry');
232
+ }
150
233
  }
151
234
  return entries;
152
235
  }
@@ -220,6 +303,13 @@ function journalInvalid(reason) {
220
303
  return error;
221
304
  }
222
305
 
306
+ // A committed sequence outside the journal's own capacity names no reachable
307
+ // journal position, so it fails closed here rather than reaching hydration,
308
+ // which would otherwise fill every absent position below it.
309
+ function validSequence(seq) {
310
+ return Number.isSafeInteger(seq) && seq >= 1 && seq <= MAX_JOURNAL_ENTRIES;
311
+ }
312
+
223
313
  function validJournalEntry(entry, namespace) {
224
314
  if (entry.type === 'clock') {
225
315
  return typeof entry.now === 'string' && typeof entry.floor === 'string';
@@ -235,10 +325,14 @@ function validJournalEntry(entry, namespace) {
235
325
  && typeof entry.ledger_namespace === 'string'
236
326
  && (namespace === null || entry.ledger_namespace === namespace)
237
327
  && typeof entry.item_id === 'string'
238
- && ['patch-v1', 'transition-v1'].includes(entry.command)
239
- && typeof entry.expected_revision === 'string'
328
+ && LEGACY_MUTATION_COMMANDS.has(entry.command)
329
+ && (entry.command === 'create-v1'
330
+ ? entry.expected_revision === null
331
+ : typeof entry.expected_revision === 'string')
240
332
  && typeof entry.candidate_revision === 'string'
241
333
  && (!Object.hasOwn(entry, 'item_path') || typeof entry.item_path === 'string')
334
+ && (!Object.hasOwn(entry, 'writer_worktree_id')
335
+ || typeof entry.writer_worktree_id === 'string')
242
336
  && typeof entry.observed_at === 'string';
243
337
  }
244
338
  if (entry.type === 'legacy-mutation') {
@@ -246,17 +340,27 @@ function validJournalEntry(entry, namespace) {
246
340
  && typeof entry.ledger_namespace === 'string'
247
341
  && (namespace === null || entry.ledger_namespace === namespace)
248
342
  && typeof entry.item_id === 'string'
249
- && ['patch-v1', 'transition-v1'].includes(entry.command)
343
+ && LEGACY_MUTATION_COMMANDS.has(entry.command)
250
344
  && typeof entry.committed_revision === 'string'
251
345
  && (!Object.hasOwn(entry, 'item_path') || typeof entry.item_path === 'string')
346
+ && (!Object.hasOwn(entry, 'writer_worktree_id')
347
+ || typeof entry.writer_worktree_id === 'string')
252
348
  && typeof entry.observed_at === 'string';
253
349
  }
254
350
  if (entry.type === 'legacy-mutation-abort') {
351
+ // A create abort names no predecessor revision, so the absence of one is
352
+ // its evidence. Patch and transition aborts keep their exact legacy shape
353
+ // and never carry a command.
354
+ const validAbortRevision = (
355
+ !Object.hasOwn(entry, 'command') && typeof entry.observed_revision === 'string'
356
+ ) || (
357
+ entry.command === 'create-v1' && entry.observed_revision === null
358
+ );
255
359
  return typeof entry.attempt_id === 'string'
256
360
  && typeof entry.ledger_namespace === 'string'
257
361
  && (namespace === null || entry.ledger_namespace === namespace)
258
362
  && typeof entry.item_id === 'string'
259
- && typeof entry.observed_revision === 'string'
363
+ && validAbortRevision
260
364
  && typeof entry.observed_at === 'string';
261
365
  }
262
366
  if (entry.type === 'revision-adoption') {
@@ -271,6 +375,12 @@ function validJournalEntry(entry, namespace) {
271
375
  && typeof entry.git_commit === 'string'
272
376
  && (!Object.hasOwn(entry, 'item_path') || typeof entry.item_path === 'string');
273
377
  }
378
+ if (entry.type === 'number-repair-intent') {
379
+ return validRepairIntent(entry, namespace);
380
+ }
381
+ if (entry.type === 'number-repair-final') {
382
+ return validRepairFinal(entry, namespace);
383
+ }
274
384
  if (entry.type === 'publish-intent') {
275
385
  return typeof entry.operation_id === 'string'
276
386
  && typeof entry.operation_digest === 'string'
@@ -278,6 +388,8 @@ function validJournalEntry(entry, namespace) {
278
388
  && typeof entry.expected_revision === 'string'
279
389
  && typeof entry.candidate_sha256 === 'string'
280
390
  && (!Object.hasOwn(entry, 'item_path') || typeof entry.item_path === 'string')
391
+ && (!Object.hasOwn(entry, 'writer_worktree_id')
392
+ || typeof entry.writer_worktree_id === 'string')
281
393
  && isRecord(entry.fence)
282
394
  && (namespace === null || entry.fence.ledger_namespace === namespace)
283
395
  && entry.fence.item_id === entry.item_id;
@@ -288,6 +400,8 @@ function validJournalEntry(entry, namespace) {
288
400
  && typeof entry.ledger_namespace === 'string'
289
401
  && (namespace === null || entry.ledger_namespace === namespace)
290
402
  && typeof entry.item_id === 'string'
403
+ && (!Object.hasOwn(entry, 'writer_worktree_id')
404
+ || typeof entry.writer_worktree_id === 'string')
291
405
  && validPublicationOutcome(entry);
292
406
  }
293
407
  return typeof entry.operation_id === 'string'
@@ -296,6 +410,97 @@ function validJournalEntry(entry, namespace) {
296
410
  && typeof entry.git_commit === 'string';
297
411
  }
298
412
 
413
+ const REPAIR_ID = /^nr_\d{8}_\d{4}$/;
414
+ const REPAIR_ITEM_ID = /^wb_[0-7][0-9A-HJKMNP-TV-Z]{25}$/;
415
+ const REPAIR_REVISION = /^sha256:[0-9a-f]{64}$/;
416
+ const REPAIR_STAGING_ID = /^[A-Za-z0-9_-]{1,80}$/;
417
+
418
+ function validRepairIntent(entry, namespace) {
419
+ return REPAIR_ID.test(entry.repair_id)
420
+ && typeof entry.ledger_namespace === 'string'
421
+ && (namespace === null || entry.ledger_namespace === namespace)
422
+ && REPAIR_REVISION.test(entry.ledger_snapshot_revision)
423
+ && isCalendarDate(entry.date)
424
+ && REPAIR_STAGING_ID.test(entry.staging_id)
425
+ && validRepairItems(entry.items, { includeExpected: true });
426
+ }
427
+
428
+ function validRepairFinal(entry, namespace) {
429
+ return REPAIR_ID.test(entry.repair_id)
430
+ && typeof entry.ledger_namespace === 'string'
431
+ && (namespace === null || entry.ledger_namespace === namespace)
432
+ && REPAIR_STAGING_ID.test(entry.staging_id)
433
+ && typeof entry.observed_at === 'string'
434
+ && validRepairItems(entry.items, { includeCommitted: true });
435
+ }
436
+
437
+ function validRepairItems(items, options) {
438
+ if (!Array.isArray(items) || items.length === 0) return false;
439
+ const seen = new Set();
440
+ for (const item of items) {
441
+ if (!isRecord(item)
442
+ || !REPAIR_ITEM_ID.test(item.item_id)
443
+ || seen.has(item.item_id)
444
+ || typeof item.item_path !== 'string'
445
+ || !REPAIR_REVISION.test(item.candidate_revision)) {
446
+ return false;
447
+ }
448
+ if (options.includeExpected
449
+ && (!REPAIR_REVISION.test(item.expected_revision)
450
+ || !Number.isSafeInteger(item.expected_number)
451
+ || item.expected_number < 1
452
+ || !Number.isSafeInteger(item.replacement_number)
453
+ || item.replacement_number < 1)) {
454
+ return false;
455
+ }
456
+ if (options.includeCommitted && !REPAIR_REVISION.test(item.committed_revision)) return false;
457
+ seen.add(item.item_id);
458
+ }
459
+ return true;
460
+ }
461
+
462
+ function validRepairResolution(entry, intents, finals) {
463
+ if (entry.type === 'number-repair-intent') {
464
+ if (intents.has(entry.repair_id)) return false;
465
+ intents.set(entry.repair_id, entry);
466
+ return true;
467
+ }
468
+ if (entry.type !== 'number-repair-final') return true;
469
+ const intent = intents.get(entry.repair_id);
470
+ if (!intent || finals.has(entry.repair_id) || intent.staging_id !== entry.staging_id) return false;
471
+ const expected = new Map(intent.items.map((item) => [item.item_id, item.candidate_revision]));
472
+ if (entry.items.length !== expected.size) return false;
473
+ for (const item of entry.items) {
474
+ if (expected.get(item.item_id) !== item.candidate_revision) return false;
475
+ }
476
+ finals.add(entry.repair_id);
477
+ return true;
478
+ }
479
+
480
+ // A journal-fenced create is only meaningful as a pair: the terminal that ends
481
+ // an attempt must name the intent that opened it, so a standalone create
482
+ // terminal names no authorized attempt and fails closed. Patch and transition
483
+ // entries remain valid standing alone, so only create-v1 attempts are tracked.
484
+ function validCreateResolution(entry, attempts) {
485
+ if (entry.command !== 'create-v1') return true;
486
+ if (entry.type === 'legacy-mutation-intent') {
487
+ attempts.set(entry.attempt_id, entry);
488
+ return true;
489
+ }
490
+ if (entry.type !== 'legacy-mutation' && entry.type !== 'legacy-mutation-abort') return true;
491
+ const intent = attempts.get(entry.attempt_id);
492
+ if (intent === undefined || intent.item_id !== entry.item_id) return false;
493
+ // A committed create publishes exactly the revision its intent proposed; an
494
+ // aborted one publishes none.
495
+ if (entry.type === 'legacy-mutation' && intent.candidate_revision !== entry.committed_revision) {
496
+ return false;
497
+ }
498
+ // One attempt resolves once, so the intent is consumed here and a repeated
499
+ // resolution finds no open attempt to end.
500
+ attempts.delete(entry.attempt_id);
501
+ return true;
502
+ }
503
+
299
504
  function isRecord(value) {
300
505
  return value !== null && typeof value === 'object' && !Array.isArray(value);
301
506
  }
@@ -2,6 +2,7 @@ import { execFile } from 'node:child_process';
2
2
  import { promisify } from 'node:util';
3
3
 
4
4
  import { parseLedgerItemSource } from './ledger.js';
5
+ import { parseReconcileLog } from './claim-journal.js';
5
6
  import { readGitTreeFile, readGitTreeLedger } from './git-reconciliation.js';
6
7
  import { revisionFor } from './mutation.js';
7
8
 
@@ -69,34 +70,6 @@ export function checkProspectiveLedger({ namespace, items, entries, parents, can
69
70
  return { ok: true };
70
71
  }
71
72
 
72
- export function parseReconcileLog(bytes, namespace) {
73
- const lines = bytes.toString('utf8').split('\n');
74
- const start = lines.indexOf('```jsonl');
75
- if (start < 0) return [];
76
- const end = lines.indexOf('```', start + 1);
77
- if (end < 0) return { error: { code: 'ambiguous-journal', reason: 'unterminated-jsonl-block' } };
78
- const entries = [];
79
- for (const line of lines.slice(start + 1, end)) {
80
- if (line.trim() === '') continue;
81
- try {
82
- const entry = JSON.parse(line);
83
- if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) {
84
- return { error: { code: 'ambiguous-journal', reason: 'entry-not-object' } };
85
- }
86
- entries.push(entry);
87
- } catch {
88
- return { error: { code: 'ambiguous-journal', reason: 'entry-not-json' } };
89
- }
90
- }
91
- const namespaceIssue = entries.find((entry) => {
92
- const entryNamespace = entry.ledger_namespace
93
- ?? entry.request?.ledger_namespace
94
- ?? entry.fence?.ledger_namespace;
95
- return entryNamespace !== undefined && entryNamespace !== namespace;
96
- });
97
- return entries;
98
- }
99
-
100
73
  export async function checkProspectiveMerge({ ledgerDirectory, namespace, baseRef, headRef }) {
101
74
  const root = (await gitText(ledgerDirectory, ['rev-parse', '--show-toplevel'])).trim();
102
75
  const base = (await gitText(root, ['rev-parse', '--verify', baseRef])).trim();