musubix3 0.1.20 → 0.1.21

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 (63) hide show
  1. package/.github/plugin/marketplace.json +2 -2
  2. package/.github/skills/sdd-change/SKILL.md +28 -45
  3. package/CHANGELOG.md +88 -0
  4. package/README-ja.md +103 -3
  5. package/README.md +113 -3
  6. package/dist/packages/analysis/src/approval.d.ts +51 -0
  7. package/dist/packages/analysis/src/approval.js +341 -2
  8. package/dist/packages/analysis/src/approval.js.map +1 -1
  9. package/dist/packages/analysis/src/attestation.js +4 -0
  10. package/dist/packages/analysis/src/attestation.js.map +1 -1
  11. package/dist/packages/analysis/src/change-evidence.d.ts +56 -13
  12. package/dist/packages/analysis/src/change-evidence.js +142 -81
  13. package/dist/packages/analysis/src/change-evidence.js.map +1 -1
  14. package/dist/packages/analysis/src/change-waiver.d.ts +37 -13
  15. package/dist/packages/analysis/src/change-waiver.js +425 -174
  16. package/dist/packages/analysis/src/change-waiver.js.map +1 -1
  17. package/dist/packages/analysis/src/change.d.ts +1 -0
  18. package/dist/packages/analysis/src/change.js +176 -23
  19. package/dist/packages/analysis/src/change.js.map +1 -1
  20. package/dist/packages/analysis/src/evidence-merge-guard.d.ts +5 -0
  21. package/dist/packages/analysis/src/evidence-merge-guard.js +15 -0
  22. package/dist/packages/analysis/src/evidence-merge-guard.js.map +1 -1
  23. package/dist/packages/analysis/src/evidence-merge.d.ts +2 -0
  24. package/dist/packages/analysis/src/evidence-merge.js +173 -38
  25. package/dist/packages/analysis/src/evidence-merge.js.map +1 -1
  26. package/dist/packages/analysis/src/evidence-writer-lock.d.ts +9 -2
  27. package/dist/packages/analysis/src/evidence-writer-lock.js +75 -21
  28. package/dist/packages/analysis/src/evidence-writer-lock.js.map +1 -1
  29. package/dist/packages/analysis/src/files.js +5 -0
  30. package/dist/packages/analysis/src/files.js.map +1 -1
  31. package/dist/packages/analysis/src/gate.d.ts +1 -1
  32. package/dist/packages/analysis/src/gate.js +17 -4
  33. package/dist/packages/analysis/src/gate.js.map +1 -1
  34. package/dist/packages/analysis/src/index.d.ts +1 -0
  35. package/dist/packages/analysis/src/index.js +1 -0
  36. package/dist/packages/analysis/src/index.js.map +1 -1
  37. package/dist/packages/analysis/src/order.d.ts +16 -1
  38. package/dist/packages/analysis/src/order.js +6 -0
  39. package/dist/packages/analysis/src/order.js.map +1 -1
  40. package/dist/packages/analysis/src/quality-refresh.d.ts +18 -0
  41. package/dist/packages/analysis/src/quality-refresh.js +312 -0
  42. package/dist/packages/analysis/src/quality-refresh.js.map +1 -0
  43. package/dist/packages/analysis/src/tdd.d.ts +84 -3
  44. package/dist/packages/analysis/src/tdd.js +589 -50
  45. package/dist/packages/analysis/src/tdd.js.map +1 -1
  46. package/dist/packages/analysis/src/trace.d.ts +24 -0
  47. package/dist/packages/analysis/src/trace.js +95 -3
  48. package/dist/packages/analysis/src/trace.js.map +1 -1
  49. package/dist/packages/analysis/src/workflow-waiver.d.ts +6 -2
  50. package/dist/packages/analysis/src/workflow-waiver.js +9 -5
  51. package/dist/packages/analysis/src/workflow-waiver.js.map +1 -1
  52. package/dist/packages/analysis/src/workflow.d.ts +1 -1
  53. package/dist/packages/analysis/src/workflow.js +4 -4
  54. package/dist/packages/analysis/src/workflow.js.map +1 -1
  55. package/dist/packages/cli/src/install.js +27 -4
  56. package/dist/packages/cli/src/install.js.map +1 -1
  57. package/dist/packages/cli/src/main.js +77 -18
  58. package/dist/packages/cli/src/main.js.map +1 -1
  59. package/dist/packages/domain/src/design.js +8 -1
  60. package/dist/packages/domain/src/design.js.map +1 -1
  61. package/dist/packages/domain/src/types.d.ts +1 -0
  62. package/package.json +7 -2
  63. package/plugin.json +1 -1
@@ -1,9 +1,9 @@
1
- import { link, mkdir, open, readFile, realpath, rename, rm, unlink, writeFile } from 'node:fs/promises';
1
+ import { link, mkdir, open, readFile, rename, rm, unlink, writeFile } from 'node:fs/promises';
2
2
  import { dirname, isAbsolute, relative, resolve, sep } from 'node:path';
3
3
  import { canonicalJson, evaluateChangeWaiverState, loadChangeWaiverEvidence, } from './change-waiver.js';
4
- import { assertEvidenceMergeReady, assertEvidenceMergeStartable, } from './evidence-merge-guard.js';
5
- import { assertCoordinatedEvidenceRead, withEvidenceWriterLock, } from './evidence-writer-lock.js';
6
- import { batchKey, effectiveBatches, loadChangeEvidence, } from './change-evidence.js';
4
+ import { assertEvidenceMergeReady, assertEvidenceMergeStartable, evidenceJournals, } from './evidence-merge-guard.js';
5
+ import { assertCoordinatedEvidenceRead, resolveEvidenceWriterCoordinatedRoot, withEvidenceWriterLock, } from './evidence-writer-lock.js';
6
+ import { batchKey, effectiveBatches, loadChangeEvidence, qualityIdentity, qualityLineage, qualityPayloadForIdentity, } from './change-evidence.js';
7
7
  import { digest, exists, readText, safePath, within } from './files.js';
8
8
  import { loadEvidenceOrder, validateEvidenceOrderLog, } from './order.js';
9
9
  import { loadTddEvidence, } from './tdd.js';
@@ -15,7 +15,7 @@ const WAIVERS_PATH = `${EVIDENCE_DIR}/change-waivers.json`;
15
15
  const JOURNAL_PATH = `${EVIDENCE_DIR}/.merge-transaction.json`;
16
16
  const STAGING_PREFIX = `${EVIDENCE_DIR}/.merge-transaction.`;
17
17
  const TARGETS = [ORDER_PATH, TDD_PATH, CHANGES_PATH, WAIVERS_PATH];
18
- const TDD_PHASES = ['red', 'green', 'refactor', 'migrate', 'void'];
18
+ const TDD_PHASES = ['red', 'green', 'refactor', 'migrate', 'void', 'archive'];
19
19
  const CHANGE_PHASES = ['impact', 'requirements', 'design', 'red', 'implementation', 'green', 'quality'];
20
20
  const BATCH_PHASES = ['red', 'implementation', 'green'];
21
21
  function json(value) {
@@ -46,6 +46,8 @@ function nonWaiverOrderKey(record) {
46
46
  record.kind,
47
47
  record.entityId,
48
48
  record.phase,
49
+ record.oldTestId ?? null,
50
+ record.newTestId ?? null,
49
51
  record.code ?? null,
50
52
  record.requirementId ?? null,
51
53
  record.detail ?? null,
@@ -76,7 +78,12 @@ function batchPhase(record, change) {
76
78
  function payloadFor(history, record) {
77
79
  if (record.kind === 'tdd') {
78
80
  const cycle = history.tdd.cycles.find((entry) => entry.cycleId === record.entityId);
79
- const value = cycle?.[record.phase];
81
+ const value = record.phase === 'migrate'
82
+ ? (cycle?.migrateHistory?.find((entry) => entry.order === record.sequence
83
+ || ((entry.oldTestId ?? null) === (record.oldTestId ?? null)
84
+ && (entry.newTestId ?? null) === (record.newTestId ?? null)))
85
+ ?? (cycle?.migrate?.order === record.sequence ? cycle.migrate : undefined))
86
+ : cycle?.[record.phase];
80
87
  return value ? { kind: 'tdd', identity: `${record.entityId}:${record.phase}`, value } : undefined;
81
88
  }
82
89
  if (record.phase === 'waiver') {
@@ -86,6 +93,10 @@ function payloadFor(history, record) {
86
93
  const change = history.changes.changes.find((entry) => entry.changeId === record.entityId);
87
94
  if (!change)
88
95
  return undefined;
96
+ if (/^quality(?::[1-9]\d*)?$/.test(record.phase)) {
97
+ const value = qualityPayloadForIdentity(change, record.phase);
98
+ return value ? { kind: 'change', identity: `${record.entityId}:${record.phase}`, value } : undefined;
99
+ }
89
100
  const value = batchPhase(record, change);
90
101
  return value ? { kind: 'change', identity: `${record.entityId}:${record.phase}`, value } : undefined;
91
102
  }
@@ -165,15 +176,18 @@ function validateInputEntities(history) {
165
176
  }
166
177
  const phaseSet = new Set();
167
178
  for (const record of history.tdd.chain ?? []) {
168
- const key = `${record.cycleId}:${record.phase}`;
179
+ const key = canonicalJson(record.phase === 'migrate'
180
+ ? [record.cycleId, record.phase, record.oldTestId ?? null, record.newTestId ?? null]
181
+ : [record.cycleId, record.phase]);
169
182
  if (phaseSet.has(key))
170
183
  diagnostics.push(mergeDiagnostic('EVIDENCE_MERGE_CONFLICT', 'Duplicate TDD chain identity.', TDD_PATH, key));
171
184
  phaseSet.add(key);
172
185
  }
173
186
  for (const cycle of history.tdd.cycles) {
174
- for (const phase of TDD_PHASES) {
187
+ for (const phase of TDD_PHASES.filter((entry) => entry !== 'migrate')) {
175
188
  const value = cycle[phase];
176
- if (value && !phaseSet.has(`${cycle.cycleId}:${phase}`)) {
189
+ const phaseIdentity = canonicalJson([cycle.cycleId, phase]);
190
+ if (value && !phaseSet.has(phaseIdentity)) {
177
191
  diagnostics.push(mergeDiagnostic('EVIDENCE_MERGE_CONFLICT', 'Missing TDD chain identity.', TDD_PATH, `${cycle.cycleId}:${phase}`));
178
192
  }
179
193
  if (value && !Number.isInteger(value.order)) {
@@ -187,16 +201,75 @@ function validateInputEntities(history) {
187
201
  }
188
202
  }
189
203
  }
204
+ const migrations = cycle.migrateHistory?.length ? cycle.migrateHistory : (cycle.migrate ? [cycle.migrate] : []);
205
+ for (const migration of migrations) {
206
+ const identity = canonicalJson([cycle.cycleId, 'migrate', migration.oldTestId ?? null, migration.newTestId ?? null]);
207
+ if (!phaseSet.has(identity)) {
208
+ diagnostics.push(mergeDiagnostic('EVIDENCE_MERGE_CONFLICT', 'Missing TDD chain identity.', TDD_PATH, `${cycle.cycleId}:migrate`));
209
+ }
210
+ if (!Number.isInteger(migration.order)) {
211
+ diagnostics.push(mergeDiagnostic('EVIDENCE_MERGE_CONFLICT', 'TDD phase lacks monotonic order evidence; migrate or regenerate it before merging.', TDD_PATH, `${cycle.cycleId}:migrate`));
212
+ }
213
+ if (migration.order !== undefined) {
214
+ const matches = history.order.records.filter((record) => record.sequence === migration.order && record.kind === 'tdd'
215
+ && record.entityId === cycle.cycleId && record.phase === 'migrate'
216
+ && (record.oldTestId ?? null) === (migration.oldTestId ?? null)
217
+ && (record.newTestId ?? null) === (migration.newTestId ?? null));
218
+ if (matches.length !== 1) {
219
+ diagnostics.push(mergeDiagnostic('EVIDENCE_MERGE_CONFLICT', 'TDD phase does not pair to exactly one order record.', TDD_PATH, `${cycle.cycleId}:migrate`));
220
+ }
221
+ }
222
+ }
190
223
  }
191
224
  for (const key of phaseSet) {
192
- const [cycleId, phase] = key.split(':', 2);
225
+ const parsed = JSON.parse(key);
226
+ const [cycleId, phase, oldTestId, newTestId] = parsed;
193
227
  const cycle = history.tdd.cycles.find((entry) => entry.cycleId === cycleId);
194
- if (!cycle?.[phase]) {
228
+ const present = phase === 'migrate'
229
+ ? (cycle?.migrateHistory?.some((entry) => (entry.oldTestId ?? null) === (oldTestId ?? null) && (entry.newTestId ?? null) === (newTestId ?? null))
230
+ || (cycle?.migrate && (cycle.migrate.oldTestId ?? null) === (oldTestId ?? null) && (cycle.migrate.newTestId ?? null) === (newTestId ?? null)))
231
+ : !!cycle?.[phase];
232
+ if (!present) {
195
233
  diagnostics.push(mergeDiagnostic('EVIDENCE_MERGE_CONFLICT', 'Extra TDD chain identity.', TDD_PATH, key));
196
234
  }
197
235
  }
198
236
  for (const change of history.changes.changes) {
199
- for (const phase of CHANGE_PHASES) {
237
+ if (history.changes.schemaVersion === 1 && change.qualityHistory !== undefined) {
238
+ diagnostics.push(mergeDiagnostic('EVIDENCE_MERGE_CONFLICT', 'Schema version 1 cannot contain Quality history.', CHANGES_PATH, change.changeId));
239
+ }
240
+ const historyShapeValid = change.qualityHistory === undefined
241
+ || (Array.isArray(change.qualityHistory) && change.qualityHistory.length > 0);
242
+ if (!historyShapeValid) {
243
+ diagnostics.push(mergeDiagnostic('EVIDENCE_MERGE_CONFLICT', 'Quality history must be a non-empty array when present.', CHANGES_PATH, change.changeId));
244
+ }
245
+ if (Array.isArray(change.qualityHistory) && change.qualityHistory.length > 0 && !change.phases.quality) {
246
+ diagnostics.push(mergeDiagnostic('EVIDENCE_MERGE_CONFLICT', 'Quality history requires an authoritative Quality checkpoint.', CHANGES_PATH, change.changeId));
247
+ }
248
+ const lineage = Array.isArray(change.qualityHistory)
249
+ ? [...change.qualityHistory, ...(change.phases.quality ? [change.phases.quality] : [])]
250
+ : [...(change.phases.quality ? [change.phases.quality] : [])];
251
+ const qualityIdentities = new Set();
252
+ let previousQualityOrder = 0;
253
+ for (const [index, value] of lineage.entries()) {
254
+ const identity = qualityIdentity(index + 1);
255
+ qualityIdentities.add(identity);
256
+ if (value.phase !== 'quality' || typeof value.recordedAt !== 'string'
257
+ || !value.fingerprints || !Number.isInteger(value.order) || value.order <= previousQualityOrder) {
258
+ diagnostics.push(mergeDiagnostic('EVIDENCE_MERGE_CONFLICT', 'Quality lineage checkpoint is malformed.', CHANGES_PATH, `${change.changeId}:${identity}`));
259
+ continue;
260
+ }
261
+ previousQualityOrder = value.order;
262
+ const matches = history.order.records.filter((record) => record.sequence === value.order && record.kind === 'change'
263
+ && record.entityId === change.changeId && record.phase === identity);
264
+ if (matches.length !== 1) {
265
+ diagnostics.push(mergeDiagnostic('EVIDENCE_MERGE_CONFLICT', 'Quality checkpoint does not pair to exactly one order record.', CHANGES_PATH, `${change.changeId}:${identity}`));
266
+ }
267
+ }
268
+ if (history.order.records.some((record) => record.kind === 'change' && record.entityId === change.changeId
269
+ && /^quality(?::\d+)?$/.test(record.phase) && !qualityIdentities.has(record.phase))) {
270
+ diagnostics.push(mergeDiagnostic('EVIDENCE_MERGE_CONFLICT', 'Quality order identity is orphaned or malformed.', CHANGES_PATH, change.changeId));
271
+ }
272
+ for (const phase of CHANGE_PHASES.filter((entry) => entry !== 'quality')) {
200
273
  const value = change.phases[phase];
201
274
  if (value && !Number.isInteger(value.order)) {
202
275
  diagnostics.push(mergeDiagnostic('EVIDENCE_MERGE_CONFLICT', 'Change phase lacks monotonic order evidence; migrate or regenerate it before merging.', CHANGES_PATH, `${change.changeId}:${phase}`));
@@ -262,7 +335,8 @@ async function validateIncomingWorktree(root, base, incoming) {
262
335
  * @design DES-EVIDENCE-HISTORY-MERGE-001
263
336
  */
264
337
  async function planEvidenceMerge(root, incomingRoot) {
265
- const [baseReal, incomingReal] = await Promise.all([realpath(root), realpath(incomingRoot)]);
338
+ const baseReal = resolveEvidenceWriterCoordinatedRoot(root);
339
+ const incomingReal = resolveEvidenceWriterCoordinatedRoot(incomingRoot);
266
340
  const nested = (parent, child) => {
267
341
  const rel = relative(parent, child);
268
342
  return rel === '' || (rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
@@ -460,11 +534,16 @@ function mergeChanges(plan, diagnostics) {
460
534
  const changes = structuredClone(plan.base.changes.changes);
461
535
  const byId = new Map(changes.map((change) => [change.changeId, change]));
462
536
  for (const change of changes) {
463
- for (const phase of CHANGE_PHASES) {
537
+ for (const phase of CHANGE_PHASES.filter((entry) => entry !== 'quality')) {
464
538
  const item = change.phases[phase];
465
539
  if (item)
466
540
  change.phases[phase] = rewriteChangePhase(item, 'base', plan);
467
541
  }
542
+ if (change.qualityHistory) {
543
+ change.qualityHistory = change.qualityHistory.map((item) => rewriteChangePhase(item, 'base', plan));
544
+ }
545
+ if (change.phases.quality)
546
+ change.phases.quality = rewriteChangePhase(change.phases.quality, 'base', plan);
468
547
  for (const batch of change.tddBatches ?? []) {
469
548
  for (const phase of BATCH_PHASES) {
470
549
  const item = batch[phase];
@@ -477,11 +556,16 @@ function mergeChanges(plan, diagnostics) {
477
556
  const target = byId.get(sourceChange.changeId);
478
557
  if (!target) {
479
558
  const clone = structuredClone(sourceChange);
480
- for (const phase of CHANGE_PHASES) {
559
+ for (const phase of CHANGE_PHASES.filter((entry) => entry !== 'quality')) {
481
560
  const item = clone.phases[phase];
482
561
  if (item)
483
562
  clone.phases[phase] = rewriteChangePhase(item, 'incoming', plan);
484
563
  }
564
+ if (clone.qualityHistory) {
565
+ clone.qualityHistory = clone.qualityHistory.map((item) => rewriteChangePhase(item, 'incoming', plan));
566
+ }
567
+ if (clone.phases.quality)
568
+ clone.phases.quality = rewriteChangePhase(clone.phases.quality, 'incoming', plan);
485
569
  for (const batch of clone.tddBatches ?? []) {
486
570
  for (const phase of BATCH_PHASES) {
487
571
  const item = batch[phase];
@@ -497,7 +581,7 @@ function mergeChanges(plan, diagnostics) {
497
581
  diagnostics.push(mergeDiagnostic('EVIDENCE_MERGE_CONFLICT', 'Matching change ID has different requirement IDs.', CHANGES_PATH, sourceChange.changeId));
498
582
  continue;
499
583
  }
500
- for (const phase of CHANGE_PHASES) {
584
+ for (const phase of CHANGE_PHASES.filter((entry) => entry !== 'quality')) {
501
585
  const left = target.phases[phase];
502
586
  const right = sourceChange.phases[phase];
503
587
  if (left && right && !semanticEqual(left, right)) {
@@ -506,6 +590,21 @@ function mergeChanges(plan, diagnostics) {
506
590
  else if (!left && right)
507
591
  target.phases[phase] = rewriteChangePhase(right, 'incoming', plan);
508
592
  }
593
+ const leftLineage = qualityLineage(target);
594
+ const rightLineage = qualityLineage(sourceChange);
595
+ const shared = Math.min(leftLineage.length, rightLineage.length);
596
+ const prefix = Array.from({ length: shared }, (_, index) => semanticEqual(leftLineage[index], rightLineage[index])).every(Boolean);
597
+ if (!prefix) {
598
+ diagnostics.push(mergeDiagnostic('EVIDENCE_MERGE_CONFLICT', 'Quality lineages diverge at the same ordinal.', CHANGES_PATH, sourceChange.changeId));
599
+ }
600
+ else if (rightLineage.length > leftLineage.length) {
601
+ const rewritten = rightLineage.map((item) => rewriteChangePhase(item, 'incoming', plan));
602
+ target.phases.quality = rewritten.at(-1);
603
+ if (rewritten.length > 1)
604
+ target.qualityHistory = rewritten.slice(0, -1);
605
+ else
606
+ delete target.qualityHistory;
607
+ }
509
608
  for (const incomingBatch of sourceChange.tddBatches ?? []) {
510
609
  const targetBatches = target.tddBatches ??= [];
511
610
  const key = batchKey(incomingBatch.requirementIds);
@@ -527,7 +626,12 @@ function mergeChanges(plan, diagnostics) {
527
626
  }
528
627
  }
529
628
  }
530
- return { ...plan.base.changes, schemaVersion: 1, changes };
629
+ const hasHistory = changes.some((change) => (change.qualityHistory?.length ?? 0) > 0);
630
+ return {
631
+ ...plan.base.changes,
632
+ schemaVersion: hasHistory || plan.base.changes.schemaVersion === 2 || plan.incoming.changes.schemaVersion === 2 ? 2 : 1,
633
+ changes,
634
+ };
531
635
  }
532
636
  function mergeWaivers(plan) {
533
637
  const waivers = structuredClone(plan.base.waivers.waivers).map((record) => rewritePhase(record, 'base', plan));
@@ -606,16 +710,19 @@ function validateCandidateReferences(order, tdd, changes, waivers) {
606
710
  diagnostics.push(mergeDiagnostic('TDD_ORDER_SEQUENCE', 'Refactor is not after Green in monotonic evidence order.', TDD_PATH, `${cycle.cycleId}:refactor`));
607
711
  }
608
712
  const latestNonMigrateOrder = cycle.refactor?.valid ? cycle.refactor.order : cycle.green?.order;
609
- if (cycle.migrate?.order !== undefined && latestNonMigrateOrder !== undefined
610
- && cycle.migrate.order <= latestNonMigrateOrder) {
611
- diagnostics.push(mergeDiagnostic('TDD_ORDER_SEQUENCE', 'Migrate is not after Green/Refactor in monotonic evidence order.', TDD_PATH, `${cycle.cycleId}:migrate`));
713
+ const migrations = cycle.migrateHistory?.length ? cycle.migrateHistory : (cycle.migrate ? [cycle.migrate] : []);
714
+ for (const migration of migrations) {
715
+ if (migration.order !== undefined && latestNonMigrateOrder !== undefined
716
+ && migration.order <= latestNonMigrateOrder) {
717
+ diagnostics.push(mergeDiagnostic('TDD_ORDER_SEQUENCE', 'Migrate is not after Green/Refactor in monotonic evidence order.', TDD_PATH, `${cycle.cycleId}:migrate`));
718
+ }
612
719
  }
613
720
  }
614
721
  for (const identity of chainKeys) {
615
722
  diagnostics.push(mergeDiagnostic('TDD_CHAIN_ORPHAN', 'Rebuilt TDD chain entry has no phase payload.', TDD_PATH, identity));
616
723
  }
617
724
  for (const change of changes.changes) {
618
- for (const phase of CHANGE_PHASES) {
725
+ for (const phase of CHANGE_PHASES.filter((entry) => entry !== 'quality')) {
619
726
  const value = change.phases[phase];
620
727
  if (!value)
621
728
  continue;
@@ -624,6 +731,22 @@ function validateCandidateReferences(order, tdd, changes, waivers) {
624
731
  diagnostics.push(mergeDiagnostic('CHANGE_ORDER_MISMATCH', 'Rebuilt change phase does not match its order record.', CHANGES_PATH, `${change.changeId}:${phase}`));
625
732
  }
626
733
  }
734
+ const qualityIdentities = new Set();
735
+ let previousQualityOrder = 0;
736
+ for (const [index, value] of qualityLineage(change).entries()) {
737
+ const identity = qualityIdentity(index + 1);
738
+ qualityIdentities.add(identity);
739
+ const record = value.order === undefined ? undefined : orderAt.get(value.order);
740
+ if (!record || record.kind !== 'change' || record.entityId !== change.changeId
741
+ || record.phase !== identity || value.order <= previousQualityOrder) {
742
+ diagnostics.push(mergeDiagnostic('CHANGE_QUALITY_HISTORY_MALFORMED', 'Rebuilt Quality checkpoint does not match its ordinal order record.', CHANGES_PATH, `${change.changeId}:${identity}`));
743
+ }
744
+ previousQualityOrder = value.order ?? previousQualityOrder;
745
+ }
746
+ if (order.records.some((record) => record.kind === 'change' && record.entityId === change.changeId
747
+ && /^quality(?::\d+)?$/.test(record.phase) && !qualityIdentities.has(record.phase))) {
748
+ diagnostics.push(mergeDiagnostic('CHANGE_QUALITY_HISTORY_MALFORMED', 'Rebuilt Quality order record is orphaned.', CHANGES_PATH, change.changeId));
749
+ }
627
750
  const fullSetKey = batchKey(change.requirementIds);
628
751
  if (change.phases.requirements?.order !== undefined && change.phases.impact?.order !== undefined
629
752
  && change.phases.requirements.order <= change.phases.impact.order) {
@@ -706,8 +829,8 @@ function buildCandidates(plan) {
706
829
  },
707
830
  };
708
831
  }
709
- async function fsyncPath(path) {
710
- const handle = await open(path, 'r');
832
+ async function fsyncFile(path) {
833
+ const handle = await open(path, 'r+');
711
834
  try {
712
835
  await handle.sync();
713
836
  }
@@ -715,14 +838,19 @@ async function fsyncPath(path) {
715
838
  await handle.close();
716
839
  }
717
840
  }
718
- async function fsyncDirectory(path) {
841
+ export async function fsyncEvidenceMergeDirectory(path, openDirectory = open) {
842
+ let handle;
719
843
  try {
720
- await fsyncPath(path);
844
+ handle = await openDirectory(path, 'r');
845
+ await handle.sync();
721
846
  }
722
847
  catch (cause) {
723
848
  if (!['EISDIR', 'EPERM', 'EACCES', 'EINVAL'].includes(cause.code ?? ''))
724
849
  throw cause;
725
850
  }
851
+ finally {
852
+ await handle?.close();
853
+ }
726
854
  }
727
855
  async function removeIfPresent(path) {
728
856
  await rm(path, { force: true });
@@ -743,7 +871,7 @@ async function restoreJournal(root, journal) {
743
871
  if (target.existed) {
744
872
  const staging = `${absolute}.${journal.transactionId}.restore`;
745
873
  await writeFile(staging, Buffer.from(target.originalBase64, 'base64'));
746
- await fsyncPath(staging);
874
+ await fsyncFile(staging);
747
875
  await rename(staging, absolute);
748
876
  }
749
877
  else {
@@ -751,9 +879,9 @@ async function restoreJournal(root, journal) {
751
879
  }
752
880
  await removeIfPresent(await safePath(root, target.temporaryPath));
753
881
  }
754
- await fsyncDirectory(evidenceDirectory);
882
+ await fsyncEvidenceMergeDirectory(evidenceDirectory);
755
883
  await removeIfPresent(await safePath(root, JOURNAL_PATH));
756
- await fsyncDirectory(evidenceDirectory);
884
+ await fsyncEvidenceMergeDirectory(evidenceDirectory);
757
885
  }
758
886
  /** @id CODE-EVIDENCE-HISTORY-MERGE-003
759
887
  * @implements REQ-EVIDENCE-HISTORY-MERGE-004
@@ -784,7 +912,7 @@ async function applyCandidates(root, candidates, faultAt) {
784
912
  if (faultAt === 'journal:staging')
785
913
  throw new Error('Injected evidence merge failure at journal:staging.');
786
914
  await writeFile(stagingJournal, json(journal), { flag: 'wx' });
787
- await fsyncPath(stagingJournal);
915
+ await fsyncFile(stagingJournal);
788
916
  if (faultAt === 'journal:publication') {
789
917
  throw new Error('EVIDENCE_MERGE_RECOVERY_REQUIRED: injected failure at journal:publication.');
790
918
  }
@@ -799,7 +927,7 @@ async function applyCandidates(root, candidates, faultAt) {
799
927
  }
800
928
  throw cause;
801
929
  }
802
- await fsyncDirectory(dirname(journalAbsolute));
930
+ await fsyncEvidenceMergeDirectory(dirname(journalAbsolute));
803
931
  try {
804
932
  for (let index = 0; index < targets.length; index++) {
805
933
  if (faultAt === `temporary:${index}`)
@@ -807,7 +935,7 @@ async function applyCandidates(root, candidates, faultAt) {
807
935
  const target = targets[index];
808
936
  const temporary = await safePath(root, target.temporaryPath);
809
937
  await writeFile(temporary, Buffer.from(target.candidateBase64, 'base64'), { flag: 'wx' });
810
- await fsyncPath(temporary);
938
+ await fsyncFile(temporary);
811
939
  }
812
940
  for (let index = 0; index < targets.length; index++) {
813
941
  if (faultAt === `replace:${index}`)
@@ -815,15 +943,15 @@ async function applyCandidates(root, candidates, faultAt) {
815
943
  const target = targets[index];
816
944
  await rename(await safePath(root, target.temporaryPath), await safePath(root, target.path));
817
945
  }
818
- await fsyncDirectory(dirname(journalAbsolute));
946
+ await fsyncEvidenceMergeDirectory(dirname(journalAbsolute));
819
947
  journal.state = 'committed';
820
948
  const commitStaging = await safePath(root, `${STAGING_PREFIX}${transactionId}.commit.json`);
821
949
  await writeFile(commitStaging, json(journal), { flag: 'wx' });
822
- await fsyncPath(commitStaging);
950
+ await fsyncFile(commitStaging);
823
951
  if (faultAt === 'commit-marker')
824
952
  throw new Error('Injected evidence merge failure at commit-marker.');
825
953
  await rename(commitStaging, journalAbsolute);
826
- await fsyncDirectory(dirname(journalAbsolute));
954
+ await fsyncEvidenceMergeDirectory(dirname(journalAbsolute));
827
955
  if (faultAt === 'cleanup')
828
956
  throw new Error('EVIDENCE_MERGE_RECOVERY_REQUIRED: injected failure at cleanup.');
829
957
  await removeIfPresent(journalAbsolute);
@@ -968,6 +1096,13 @@ export async function recoverEvidenceMerge(root) {
968
1096
  return withEvidenceWriterLock(root, 'evidence merge --recover', () => recoverEvidenceMergeUnlocked(root));
969
1097
  }
970
1098
  async function recoverEvidenceMergeUnlocked(root) {
1099
+ const journals = await evidenceJournals(root);
1100
+ if (journals.merge && journals.qualityRefresh) {
1101
+ throw new Error('EVIDENCE_MERGE_RECOVERY_UNSAFE: merge and Quality-refresh journals coexist.');
1102
+ }
1103
+ if (journals.qualityRefresh) {
1104
+ throw new Error('CHANGE_QUALITY_REFRESH_RECOVERY_REQUIRED: run change quality-recover.');
1105
+ }
971
1106
  const journalAbsolute = await safePath(root, JOURNAL_PATH);
972
1107
  const evidenceDir = await safePath(root, EVIDENCE_DIR);
973
1108
  let staging = [];
@@ -1028,20 +1163,20 @@ async function recoverEvidenceMergeUnlocked(root) {
1028
1163
  if (digest(actual) !== target.candidateSha256) {
1029
1164
  const stagingPath = `${absolute}.${journal.transactionId}.recover`;
1030
1165
  await writeFile(stagingPath, Buffer.from(target.candidateBase64, 'base64'));
1031
- await fsyncPath(stagingPath);
1166
+ await fsyncFile(stagingPath);
1032
1167
  await rename(stagingPath, absolute);
1033
- await fsyncDirectory(evidenceDir);
1168
+ await fsyncEvidenceMergeDirectory(evidenceDir);
1034
1169
  }
1035
1170
  const verified = await readFile(absolute, 'utf8');
1036
1171
  if (digest(verified) !== target.candidateSha256) {
1037
1172
  throw unsafeRecovery('Candidate verification failed after rewrite.', inventory);
1038
1173
  }
1039
1174
  }
1040
- await fsyncDirectory(evidenceDir);
1175
+ await fsyncEvidenceMergeDirectory(evidenceDir);
1041
1176
  await removeIfPresent(journalAbsolute);
1042
1177
  for (const entry of staging)
1043
1178
  await removeIfPresent(resolve(evidenceDir, entry));
1044
- await fsyncDirectory(evidenceDir);
1179
+ await fsyncEvidenceMergeDirectory(evidenceDir);
1045
1180
  return { recovered: true, action: 'rolled-forward', discardedStaging: staging.length > 0 };
1046
1181
  }
1047
1182
  //# sourceMappingURL=evidence-merge.js.map