wendkeep 0.78.0 → 0.79.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.
@@ -1,11 +1,12 @@
1
1
  // hooks/spec-core.mjs — living spec (07-Specs) + change delta merge (OpenSpec native).
2
- // Pure parsing/merge + promoteSpecs (fs). No import from change-core (avoids a cycle).
3
- import { createHash } from 'node:crypto';
2
+ // Pure parsing/merge + read-only promotion planning. No import from change-core (avoids a cycle).
3
+ import { createHash, randomUUID } from 'node:crypto';
4
4
  import { existsSync, readFileSync, readdirSync } from 'node:fs';
5
- import { join } from 'node:path';
5
+ import { join, relative } from 'node:path';
6
6
  import { getLocale } from './locale.mjs';
7
7
  import {
8
- assertVaultPathSafe, assertVaultPathsSafe, mkdirVaultPath, writeVaultFileAtomic, writeVaultFileSync,
8
+ assertVaultPathSafe, assertVaultPathsSafe, mkdirVaultPath,
9
+ writeVaultFileAtomic, writeVaultFileSync,
9
10
  } from './vault-path-safety.mjs';
10
11
 
11
12
  // Canonical SHA-256 fingerprint of tarefas.md — freshness binding between package/verdict and gate.
@@ -148,9 +149,20 @@ export function checkSpecsState(vaultBase) {
148
149
  return { ok: changed.length === 0, missing: false, changed, current, recorded };
149
150
  }
150
151
 
151
- function recordPromotedSpecs(vaultBase, capabilities) {
152
+ function recordPromotedSpecs(vaultBase, capabilities, { writeAtomic = writeVaultFileSync } = {}) {
152
153
  const existing = readSpecsState(vaultBase);
153
- if (!existing) return adoptSpecsState(vaultBase);
154
+ if (!existing) {
155
+ const state = { version: 1, generatedAt: new Date().toISOString(), specs: readLivingSpecs(vaultBase) };
156
+ mkdirVaultPath(vaultBase, join(vaultBase, '.brain'), { label: 'raiz do estado de specs' });
157
+ writeAtomic(
158
+ vaultBase,
159
+ join(vaultBase, SPECS_STATE_FILE),
160
+ `${JSON.stringify(state, null, 2)}\n`,
161
+ 'utf8',
162
+ { label: 'estado consolidado de specs' },
163
+ );
164
+ return state;
165
+ }
154
166
  const current = readLivingSpecs(vaultBase);
155
167
  const specs = { ...(existing.specs || {}) };
156
168
  for (const capability of capabilities) {
@@ -158,7 +170,7 @@ function recordPromotedSpecs(vaultBase, capabilities) {
158
170
  else delete specs[capability];
159
171
  }
160
172
  const state = { version: 1, generatedAt: new Date().toISOString(), specs };
161
- writeVaultFileSync(
173
+ writeAtomic(
162
174
  vaultBase,
163
175
  join(vaultBase, SPECS_STATE_FILE),
164
176
  `${JSON.stringify(state, null, 2)}\n`,
@@ -348,12 +360,10 @@ export function discoverSpecDeltas(changeDir) {
348
360
  // per-change (many changes promote into the same file; the per-change record lives in
349
361
  // _arquivo). Generated + read-only. Written on init and refreshed on every archive so
350
362
  // existing vaults self-heal. Bilingual by vault locale.
351
- export function ensureSpecsReadme(vaultBase) {
363
+ function specsReadmeBody(vaultBase) {
352
364
  const loc = getLocale(vaultBase);
353
365
  const en = loc.id === 'en';
354
- const dir = join(vaultBase, loc.folders.specs);
355
- mkdirVaultPath(vaultBase, dir, { label: 'raiz de specs consolidadas' });
356
- const body = en
366
+ return en
357
367
  ? `# Specs — generated living contract
358
368
 
359
369
  **One file per _capability_, not per change.** Each file is the current, cumulative contract
@@ -382,7 +392,18 @@ Pense como código-fonte vs commits: esta pasta é o *código atual* de cada cap
382
392
  \`wendkeep change archive\` promove para esta pasta.
383
393
  - Histórico por mudança → \`${loc.folders.changes}/_arquivo/\`. Contrato atual → aqui.
384
394
  `;
385
- writeVaultFileSync(vaultBase, join(dir, 'README.md'), body, 'utf8', { label: 'README de specs' });
395
+ }
396
+
397
+ export function renderSpecsReadme(vaultBase) {
398
+ return specsReadmeBody(vaultBase);
399
+ }
400
+
401
+ export function ensureSpecsReadme(vaultBase, { writeAtomic = writeVaultFileSync } = {}) {
402
+ const loc = getLocale(vaultBase);
403
+ const dir = join(vaultBase, loc.folders.specs);
404
+ mkdirVaultPath(vaultBase, dir, { label: 'raiz de specs consolidadas' });
405
+ const body = specsReadmeBody(vaultBase);
406
+ writeAtomic(vaultBase, join(dir, 'README.md'), body, 'utf8', { label: 'README de specs' });
386
407
  }
387
408
 
388
409
  export function assertSpecPromotionTargetsSafe(vaultBase, changeDir, specs) {
@@ -426,8 +447,14 @@ export function assertSpecPromotionTargetsSafe(vaultBase, changeDir, specs) {
426
447
  return { specsRoot: checkedRoot.target };
427
448
  }
428
449
 
429
- // Merge each capability's delta (in the change) into the living spec in 07-Specs.
430
- export function promoteSpecs(vaultBase, changeDir, specs, { changeWikilink, dateStr } = {}) {
450
+ // Derive the immutable before/postimage plan used by the archive CLI. This helper
451
+ // is deliberately read-only: publication is private to src/change.mjs, after the
452
+ // provenance gate, receipt, recapture, and operation lock have all succeeded.
453
+ export function buildSpecPromotionPlan(vaultBase, changeDir, specs, {
454
+ changeWikilink,
455
+ dateStr,
456
+ recoveryRoot,
457
+ } = {}) {
431
458
  const loc = getLocale(vaultBase);
432
459
  const specsDir = loc.folders.specs;
433
460
  const promoted = [];
@@ -459,25 +486,62 @@ export function promoteSpecs(vaultBase, changeDir, specs, { changeWikilink, date
459
486
  content: renderSpec(cap, applied.reqs, { footer, reqHeading: loc.reqHeading }),
460
487
  });
461
488
  }
462
- mkdirVaultPath(vaultBase, specsRoot, { label: 'raiz de specs consolidadas' });
489
+ const statePath = join(vaultBase, SPECS_STATE_FILE);
490
+ const readmePath = join(specsRoot, 'README.md');
491
+ const existingState = readSpecsState(vaultBase);
492
+ const nextSpecs = existingState ? { ...(existingState.specs || {}) } : readLivingSpecs(vaultBase);
463
493
  for (const item of materialized) {
464
- writeVaultFileSync(
465
- vaultBase,
466
- item.livePath,
467
- item.content,
468
- 'utf8',
469
- { label: `spec consolidada ${item.capability}` },
470
- );
471
- promoted.push(item.capability);
494
+ nextSpecs[item.capability] = {
495
+ hash: contentHashOf(item.content),
496
+ requirements: Object.fromEntries(parseRequirements(item.content)
497
+ .map((requirement) => [requirement.id || requirement.name, contentHashOf(JSON.stringify(requirement))])),
498
+ };
472
499
  }
473
- recordPromotedSpecs(vaultBase, promoted);
474
- ensureSpecsReadme(vaultBase); // self-heal the explainer so existing vaults get it on archive
475
- return { promoted, warnings };
500
+ const stateContent = `${JSON.stringify({
501
+ version: 1,
502
+ generatedAt: new Date().toISOString(),
503
+ specs: nextSpecs,
504
+ }, null, 2)}\n`;
505
+ const planned = [
506
+ ...materialized.map((item) => ({
507
+ kind: 'capability', capability: item.capability, path: item.livePath, content: item.content,
508
+ })),
509
+ { kind: 'state', path: statePath, content: stateContent },
510
+ { kind: 'readme', path: readmePath, content: specsReadmeBody(vaultBase) },
511
+ ];
512
+ const image = (content, exists = true) => ({
513
+ exists,
514
+ content_base64: Buffer.from(content, 'utf8').toString('base64'),
515
+ digest: `sha256:${contentHashOf(content)}`,
516
+ });
517
+ const promotionRecoveryRoot = recoveryRoot
518
+ || join(vaultBase, '.brain', 'runtime', 'spec-promotion-plans', randomUUID());
519
+ const plan = {
520
+ schema_version: 1,
521
+ entries: planned.map((entry, index) => {
522
+ const beforeExists = existsSync(entry.path);
523
+ const beforeContent = beforeExists ? readFileSync(entry.path, 'utf8') : '';
524
+ return {
525
+ kind: entry.kind,
526
+ capability: entry.capability || null,
527
+ target: relative(vaultBase, entry.path).replaceAll('\\', '/'),
528
+ claim_target: relative(vaultBase, join(promotionRecoveryRoot, `${index}-${randomUUID()}.before`)).replaceAll('\\', '/'),
529
+ candidate_target: relative(vaultBase, join(promotionRecoveryRoot, `${index}-${randomUUID()}.candidate`)).replaceAll('\\', '/'),
530
+ before: image(beforeContent, beforeExists),
531
+ after: image(entry.content, true),
532
+ };
533
+ }),
534
+ };
535
+ const changes = materialized.map((item) => ({
536
+ capability: item.capability,
537
+ before_digest: plan.entries.find((entry) => entry.capability === item.capability).before.digest,
538
+ after_digest: `sha256:${contentHashOf(item.content)}`,
539
+ }));
540
+ plan.changes = changes;
541
+ promoted.push(...materialized.map((item) => item.capability));
542
+ return { promoted, warnings, changes, plan };
476
543
  }
477
544
 
478
- // Gate check for the independent verdict (Wave A). A requirement-bearing change must have
479
- // a verdict that is ok and covers every declared req id. A requirement-less change passes:
480
- // nothing for an independent verifier to check — the sensor gate is already the proof.
481
545
  export function evaluateVerdict(verdict, reqIds, {
482
546
  tasksHash,
483
547
  effectiveSpecHash,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wendkeep",
3
- "version": "0.78.0",
3
+ "version": "0.79.0",
4
4
  "description": "Vault-first persistent memory for AI coding agents, with an optional profile-aware governance runtime: OFF, FLOW, GUIDE, GOVERN, or ASSURE. Local-first and agent-agnostic (Claude Code, Codex, Cursor…).",
5
5
  "type": "module",
6
6
  "workspaces": [
@@ -41,7 +41,7 @@
41
41
  "node": ">=18"
42
42
  },
43
43
  "scripts": {
44
- "precheck": "node --check src/worktree.mjs && node --check src/worktree-cleanup.mjs && node --check src/evidence-envelope.mjs && node --check src/context.mjs && node --check src/active-context-health.mjs && node --check src/active-context-runtime.mjs && node --check hooks/active-context-store.mjs && node --check hooks/change-core.mjs && node --check hooks/brain-inject.mjs && node --check hooks/change-context.mjs && node --check packages/vault/src/worktree-metadata.mjs && node --check packages/vault/src/evidence-envelope.mjs",
44
+ "precheck": "node --check src/change.mjs && node --check src/archive-operation-lock.mjs && node --check src/worktree.mjs && node --check src/worktree-cleanup.mjs && node --check src/provenance-gate.mjs && node --check src/provenance-sources.mjs && node --check src/receipt-ledger.mjs && node --check src/evidence-envelope.mjs && node --check src/context.mjs && node --check src/active-context-health.mjs && node --check src/active-context-runtime.mjs && node --check hooks/active-context-store.mjs && node --check hooks/change-core.mjs && node --check hooks/brain-inject.mjs && node --check hooks/change-context.mjs && node --check packages/vault/src/worktree-metadata.mjs && node --check packages/vault/src/evidence-envelope.mjs",
45
45
  "check": "node --check scripts/release.mjs && node --check scripts/release-plan.mjs && node --check scripts/release-provenance.mjs && node --check scripts/run-scope.mjs && node --check src/release-provenance.mjs && node --check bin/wendkeep.mjs && node --check packages/cli/src/index.mjs && node --check src/init.mjs && node --check src/doctor.mjs && node --check src/active-context-health.mjs && node --check src/project-vault.mjs && node --check src/observer-auth.mjs && node --check src/observer-privacy.mjs && node --check src/observer-snapshot.mjs && node --check src/observer-store.mjs && node --check src/observer-memory.mjs && node --check src/observer-memory-publish.mjs && node --check src/observer-sql-store.mjs && node --check src/observer-sql-migrate.mjs && node --check src/observer-sql-publish.mjs && node --check src/observer-transcript-store.mjs && node --check src/observer-server.mjs && node --check src/observer.mjs && node --check src/observer-publish.mjs && node --check src/operating-profile.mjs && node --check src/profile.mjs && node --check src/flow.mjs && node --check src/work-kind.mjs && node --check src/delivery.mjs && node --check web/observer/app.mjs && node --check hooks/observer-publish.mjs && node --check hooks/evidence-context.mjs && node --check hooks/active-context-handoff-evidence.mjs && node --check hooks/evidence-recall.mjs && node --check hooks/memory-scope.mjs && node --check hooks/operating-profile-runtime.mjs && node --check hooks/operating-profile-task-store.mjs && node --check hooks/flow-core.mjs && node --check hooks/flow-protected-policy.mjs && node --check hooks/git-snapshot.mjs && node --check hooks/vault-path-safety.mjs && node --check hooks/vault-runtime-store.mjs && node --check packages/harness/src/index.mjs && node --check packages/harness/src/flow-store.mjs && node --check packages/harness/src/operating-profile.mjs && node --check packages/harness/src/sensors-core.mjs && node --check packages/integrations/src/host-hooks.mjs && node --check packages/integrations/src/hook-envelope.mjs && node --check packages/integrations/src/prompt-content.mjs && node --check packages/integrations/src/transcript-usage.mjs && node --check packages/integrations/src/transcripts.mjs && node --check packages/integrations/src/session-identity.mjs && node --check packages/integrations/src/index.mjs && node --check packages/mcp/src/config.mjs && node --check packages/mcp/src/index.mjs && node --check packages/vault/src/index.mjs && node --check packages/vault/src/project-vault.mjs && node --check packages/vault/src/vault-path-safety.mjs && node --check packages/vault/src/locale.mjs && node --check packages/vault/src/memory-schema.mjs && node --check packages/vault/src/memory-mode.mjs && node --check packages/vault/src/memory-scope.mjs && node --check packages/vault/src/memory-candidate-policy.mjs && node --check packages/vault/src/evidence-recall.mjs && node --check packages/vault/src/memory-handoff.mjs && node --check packages/vault/src/memory-store.mjs && node --check packages/vault/src/validate-core.mjs && node --check packages/vault/src/validate-memory.mjs",
46
46
  "test": "node --test --test-concurrency=2",
47
47
  "test:core": "node scripts/run-scope.mjs core",
@@ -0,0 +1,66 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://github.com/rogersialves/wendkeep/blob/main/schema/wendkeep.provenance-receipt-v2.schema.json",
4
+ "title": "WendKeep Provenance Receipt v2",
5
+ "description": "A canonical, hash-chained receipt for a provenance-gated operation.",
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "required": [
9
+ "schema_version",
10
+ "sequence",
11
+ "receipt_id",
12
+ "previous_hash",
13
+ "receipt_hash",
14
+ "kind",
15
+ "subject",
16
+ "claims",
17
+ "observations",
18
+ "recorded_at"
19
+ ],
20
+ "properties": {
21
+ "schema_version": {
22
+ "const": 2
23
+ },
24
+ "sequence": {
25
+ "type": "integer",
26
+ "minimum": 1
27
+ },
28
+ "receipt_id": {
29
+ "type": "string",
30
+ "pattern": "^sha256:[a-f0-9]{64}$"
31
+ },
32
+ "previous_hash": {
33
+ "type": "string",
34
+ "pattern": "^sha256:[a-f0-9]{64}$",
35
+ "description": "Hash of the previous v2 receipt, or the canonical legacy/empty prefix for genesis."
36
+ },
37
+ "receipt_hash": {
38
+ "type": "string",
39
+ "pattern": "^sha256:[a-f0-9]{64}$",
40
+ "description": "SHA-256 of the canonical receipt without receipt_hash."
41
+ },
42
+ "kind": {
43
+ "type": "string",
44
+ "pattern": "^[a-z][a-z0-9._-]{1,63}$"
45
+ },
46
+ "subject": {
47
+ "type": "object",
48
+ "description": "Sanitized identity and current operation target.",
49
+ "additionalProperties": true
50
+ },
51
+ "claims": {
52
+ "type": "object",
53
+ "description": "Sanitized claims made by the operation.",
54
+ "additionalProperties": true
55
+ },
56
+ "observations": {
57
+ "type": "object",
58
+ "description": "Sanitized source observations keyed by adapter/kind.",
59
+ "additionalProperties": true
60
+ },
61
+ "recorded_at": {
62
+ "type": "string",
63
+ "format": "date-time"
64
+ }
65
+ }
66
+ }
@@ -0,0 +1,235 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import {
3
+ closeSync, constants, existsSync, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync,
4
+ readFileSync, readdirSync, realpathSync, renameSync, rmdirSync, unlinkSync, writeFileSync,
5
+ } from 'node:fs';
6
+ import { basename, dirname, join, resolve } from 'node:path';
7
+
8
+ function archiveLockError(code, details = {}) {
9
+ const error = new Error(code);
10
+ error.code = code;
11
+ Object.assign(error, details);
12
+ return error;
13
+ }
14
+
15
+ function samePhysicalFile(left, right) {
16
+ if (!left?.isFile() || !right?.isFile() || left.ino !== right.ino) return false;
17
+ if (left.dev === right.dev) return true;
18
+ return process.platform === 'win32' && (left.dev === 0 || right.dev === 0);
19
+ }
20
+
21
+ function ownerProcessAlive(pid) {
22
+ try { process.kill(pid, 0); return true; }
23
+ catch (error) { return !['ESRCH', 'EINVAL'].includes(error?.code); }
24
+ }
25
+
26
+ function assertLockDirectory(target) {
27
+ const stat = lstatSync(target);
28
+ if (!stat.isDirectory() || stat.isSymbolicLink()) {
29
+ throw archiveLockError('WENDKEEP_ARCHIVE_LOCK_UNAVAILABLE');
30
+ }
31
+ return stat;
32
+ }
33
+
34
+ function readOwnerMarker(markerPath) {
35
+ const stat = lstatSync(markerPath);
36
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1) {
37
+ throw archiveLockError('WENDKEEP_ARCHIVE_LOCK_UNAVAILABLE');
38
+ }
39
+ let record;
40
+ try { record = JSON.parse(readFileSync(markerPath, 'utf8')); }
41
+ catch { throw archiveLockError('WENDKEEP_ARCHIVE_LOCK_UNAVAILABLE'); }
42
+ if (record?.schema_version !== 1
43
+ || typeof record.owner_token !== 'string' || !record.owner_token
44
+ || !Number.isSafeInteger(record.owner_pid) || record.owner_pid <= 0
45
+ || typeof record.acquired_at !== 'string' || !Number.isFinite(Date.parse(record.acquired_at))) {
46
+ throw archiveLockError('WENDKEEP_ARCHIVE_LOCK_UNAVAILABLE');
47
+ }
48
+ return { stat, record };
49
+ }
50
+
51
+ function inspectExistingLock(target) {
52
+ try { assertLockDirectory(target); }
53
+ catch (error) {
54
+ if (error?.code === 'ENOENT') return;
55
+ throw archiveLockError('WENDKEEP_ARCHIVE_LOCK_UNAVAILABLE');
56
+ }
57
+ const entries = readdirSync(target);
58
+ if (entries.length === 0) {
59
+ try { rmdirSync(target); } catch { /* re-observe on the next bounded attempt */ }
60
+ return;
61
+ }
62
+ if (entries.length !== 1 || !/^owner\.[0-9a-f-]+\.json$/i.test(entries[0])) {
63
+ throw archiveLockError('WENDKEEP_ARCHIVE_LOCK_UNAVAILABLE');
64
+ }
65
+ const markerPath = join(target, entries[0]);
66
+ const observed = readOwnerMarker(markerPath);
67
+ if (basename(markerPath) !== `owner.${observed.record.owner_token}.json`) {
68
+ throw archiveLockError('WENDKEEP_ARCHIVE_LOCK_UNAVAILABLE');
69
+ }
70
+ if (ownerProcessAlive(observed.record.owner_pid)) {
71
+ throw archiveLockError('WENDKEEP_ARCHIVE_BUSY', { owner_state: 'live' });
72
+ }
73
+ const confirmed = readOwnerMarker(markerPath);
74
+ if (!samePhysicalFile(observed.stat, confirmed.stat)
75
+ || confirmed.record.owner_token !== observed.record.owner_token
76
+ || confirmed.record.owner_pid !== observed.record.owner_pid) {
77
+ throw archiveLockError('WENDKEEP_ARCHIVE_LOCK_UNAVAILABLE');
78
+ }
79
+ try { unlinkSync(markerPath); }
80
+ catch { throw archiveLockError('WENDKEEP_ARCHIVE_LOCK_UNAVAILABLE'); }
81
+ try { rmdirSync(target); } catch { /* replacement/nonempty directory remains authoritative */ }
82
+ }
83
+
84
+ export function acquireArchiveOperationLock({
85
+ lockPath,
86
+ now = () => Date.now(),
87
+ maxAcquireAttempts = 3,
88
+ faultInjection = {},
89
+ }) {
90
+ if (!lockPath || !Number.isSafeInteger(maxAcquireAttempts) || maxAcquireAttempts < 1) {
91
+ throw archiveLockError('WENDKEEP_ARCHIVE_LOCK_INVALID');
92
+ }
93
+ const runtime = resolve(dirname(lockPath));
94
+ const target = resolve(lockPath);
95
+ mkdirSync(runtime, { recursive: true });
96
+ const runtimeStat = lstatSync(runtime);
97
+ if (!runtimeStat.isDirectory() || runtimeStat.isSymbolicLink()
98
+ || resolve(realpathSync(runtime)).toLowerCase() !== runtime.toLowerCase()) {
99
+ throw archiveLockError('WENDKEEP_ARCHIVE_LOCK_UNAVAILABLE');
100
+ }
101
+
102
+ const token = randomUUID();
103
+ const markerName = `owner.${token}.json`;
104
+ const pending = `${target}.pending.${process.pid}.${token}`;
105
+ const pendingMarker = join(pending, markerName);
106
+ let descriptor;
107
+ let descriptorIdentity;
108
+ let pendingExists = false;
109
+ let published = false;
110
+ try {
111
+ mkdirSync(pending);
112
+ pendingExists = true;
113
+ descriptor = openSync(
114
+ pendingMarker,
115
+ constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW || 0),
116
+ 0o600,
117
+ );
118
+ writeFileSync(descriptor, `${JSON.stringify({
119
+ schema_version: 1,
120
+ owner_token: token,
121
+ owner_pid: process.pid,
122
+ acquired_at: new Date(now()).toISOString(),
123
+ })}\n`, 'utf8');
124
+ fsyncSync(descriptor);
125
+ closeSync(descriptor);
126
+ descriptor = undefined;
127
+
128
+ for (let attempt = 1; attempt <= maxAcquireAttempts; attempt += 1) {
129
+ try {
130
+ if (typeof faultInjection?.beforePublishRename === 'function') {
131
+ faultInjection.beforePublishRename({ attempt, pending, lockPath: target });
132
+ }
133
+ // The pending directory is already nonempty and durable. Rename publishes metadata and
134
+ // exclusivity together; the canonical namespace never observes a partial marker/hardlink.
135
+ renameSync(pending, target);
136
+ pendingExists = false;
137
+ published = true;
138
+ if (typeof faultInjection?.afterPublishRename === 'function') {
139
+ faultInjection.afterPublishRename({ attempt, lockPath: target });
140
+ }
141
+ break;
142
+ } catch (error) {
143
+ if (published) throw error;
144
+ if (existsSync(target)) inspectExistingLock(target);
145
+ if (attempt === maxAcquireAttempts) {
146
+ throw archiveLockError('WENDKEEP_ARCHIVE_LOCK_UNAVAILABLE');
147
+ }
148
+ }
149
+ }
150
+
151
+ const markerPath = join(target, markerName);
152
+ descriptor = openSync(
153
+ markerPath,
154
+ constants.O_RDONLY | (constants.O_NOFOLLOW || 0),
155
+ );
156
+ const identity = readOwnerMarker(markerPath);
157
+ descriptorIdentity = fstatSync(descriptor);
158
+ if (!samePhysicalFile(descriptorIdentity, identity.stat)
159
+ || identity.record.owner_token !== token) {
160
+ throw archiveLockError('WENDKEEP_ARCHIVE_LOCK_OWNERSHIP_LOST');
161
+ }
162
+ } catch (error) {
163
+ if (descriptor !== undefined) {
164
+ try { closeSync(descriptor); } catch { /* original code remains authoritative */ }
165
+ descriptor = undefined;
166
+ }
167
+ if (pendingExists) {
168
+ try { unlinkSync(pendingMarker); } catch { /* marker may be absent */ }
169
+ try { rmdirSync(pending); } catch { /* private pending state is recoverable */ }
170
+ }
171
+ if (error?.code?.startsWith('WENDKEEP_ARCHIVE_')) throw error;
172
+ throw archiveLockError('WENDKEEP_ARCHIVE_LOCK_UNAVAILABLE');
173
+ }
174
+
175
+ const markerPath = join(target, markerName);
176
+ let terminal = false;
177
+ let assertionCount = 0;
178
+ const closeHandle = () => {
179
+ if (descriptor === undefined) return;
180
+ try { closeSync(descriptor); } finally { descriptor = undefined; }
181
+ };
182
+ const assertOwned = () => {
183
+ if (terminal || descriptor === undefined) {
184
+ throw archiveLockError('WENDKEEP_ARCHIVE_LOCK_OWNERSHIP_LOST');
185
+ }
186
+ try { assertLockDirectory(target); }
187
+ catch { throw archiveLockError('WENDKEEP_ARCHIVE_LOCK_OWNERSHIP_LOST'); }
188
+ let identity;
189
+ try { identity = readOwnerMarker(markerPath); }
190
+ catch { throw archiveLockError('WENDKEEP_ARCHIVE_LOCK_OWNERSHIP_LOST'); }
191
+ const currentDescriptorIdentity = fstatSync(descriptor);
192
+ if (!samePhysicalFile(currentDescriptorIdentity, identity.stat)
193
+ || identity.record.owner_token !== token) {
194
+ throw archiveLockError('WENDKEEP_ARCHIVE_LOCK_OWNERSHIP_LOST');
195
+ }
196
+ descriptorIdentity = currentDescriptorIdentity;
197
+ assertionCount += 1;
198
+ if (typeof faultInjection?.afterAssertOwned === 'function') {
199
+ faultInjection.afterAssertOwned({ count: assertionCount, lockPath: target, markerPath });
200
+ }
201
+ return true;
202
+ };
203
+
204
+ return Object.freeze({
205
+ token,
206
+ assertOwned,
207
+ release() {
208
+ if (terminal) return;
209
+ try {
210
+ assertOwned();
211
+ if (typeof faultInjection?.beforeReleaseCommit === 'function') {
212
+ faultInjection.beforeReleaseCommit({ lockPath: target, markerPath });
213
+ }
214
+ closeHandle();
215
+ const finalIdentity = readOwnerMarker(markerPath);
216
+ if (!samePhysicalFile(descriptorIdentity, finalIdentity.stat)
217
+ || finalIdentity.record.owner_token !== token) {
218
+ throw archiveLockError('WENDKEEP_ARCHIVE_LOCK_OWNERSHIP_LOST');
219
+ }
220
+ unlinkSync(markerPath);
221
+ rmdirSync(target);
222
+ } catch {
223
+ terminal = true;
224
+ closeHandle();
225
+ throw archiveLockError('WENDKEEP_ARCHIVE_LOCK_OWNERSHIP_LOST');
226
+ }
227
+ terminal = true;
228
+ closeHandle();
229
+ },
230
+ get active() {
231
+ if (terminal) return false;
232
+ try { return assertOwned(); } catch { return false; }
233
+ },
234
+ });
235
+ }