leerness 1.36.185 → 1.36.187

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 (51) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.ko.md +5 -0
  3. package/README.md +14 -4
  4. package/bin/leerness.js +291 -57
  5. package/docs/e2e-observation-contract.md +51 -0
  6. package/docs/runtime-compatibility-api.md +183 -0
  7. package/docs/state-paths-api.md +92 -0
  8. package/docs/state-scopes.md +266 -0
  9. package/docs/worktree-runtime-migration.md +278 -0
  10. package/lib/claims-baseline.js +1 -1
  11. package/lib/file-leases.js +2 -2
  12. package/lib/git.js +64 -15
  13. package/lib/io.js +9 -3
  14. package/lib/migrate.js +1 -1
  15. package/lib/preview-serve.js +5 -1
  16. package/lib/pure-utils.js +20 -0
  17. package/lib/role-fallback.js +3 -3
  18. package/lib/role-store.js +2 -2
  19. package/lib/runtime-layout.js +395 -0
  20. package/lib/runtime-writes.js +166 -0
  21. package/lib/session-close.js +1 -1
  22. package/lib/state-git.js +138 -0
  23. package/lib/state-inspect.js +39 -0
  24. package/lib/state-inventory.js +69 -0
  25. package/lib/state-paths.js +59 -0
  26. package/lib/workspace-dir.js +34 -10
  27. package/package.json +10 -6
  28. package/scripts/command-flags-probe.js +1 -1
  29. package/scripts/dead-flags-probe.js +1 -1
  30. package/scripts/e2e-child-diagnostics-probe.js +143 -0
  31. package/scripts/e2e-child-diagnostics.js +33 -0
  32. package/scripts/e2e-temp-scope-probe.js +127 -0
  33. package/scripts/e2e.js +108 -27
  34. package/scripts/encoding-selftest-probe.js +222 -0
  35. package/scripts/false-claim-probe.js +34 -3
  36. package/scripts/file-lease-probe.js +5 -2
  37. package/scripts/legacy-runtime-negative-probe.js +72 -0
  38. package/scripts/mutation-integrity-probe.js +3 -0
  39. package/scripts/role-fallback-probe.js +67 -6
  40. package/scripts/runtime-admission-probe.js +282 -0
  41. package/scripts/runtime-git-write-probe.js +116 -0
  42. package/scripts/runtime-layout-probe.js +1198 -0
  43. package/scripts/runtime-repl-probe.js +271 -0
  44. package/scripts/runtime-replacement-probe.js +190 -0
  45. package/scripts/runtime-write-probe.js +458 -0
  46. package/scripts/selftest-cleanup-probe.js +168 -0
  47. package/scripts/state-inspect-cli-probe.js +209 -0
  48. package/scripts/state-scopes-probe.js +590 -0
  49. package/scripts/workspace-dir-lock-order-probe.js +310 -35
  50. package/scripts/workspace-dir-migration-probe.js +17 -2
  51. package/scripts/workspace-selection-probe.js +230 -0
@@ -0,0 +1,395 @@
1
+ 'use strict';
2
+
3
+ // Compatibility-only admission: no descriptor creation, activation or fencing.
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const { TextDecoder } = require('util');
7
+ const SG = require('./state-git');
8
+ const W = require('./workspace-dir');
9
+ const MAX_DESCRIPTOR_BYTES = 16 * 1024;
10
+ const FIELDS = ['schema', 'schemaVersion', 'scope', 'generation', 'layout', 'requiredWriterProtocol'];
11
+ // Existing standalone module stores do not require a full initialized
12
+ // workspace. Reserve exact known names; an extra foreign name still refuses
13
+ // admission. This does not validate or read any of these stores' contents.
14
+ // Sources: state-inventory, role-store/fallback, and the named domain modules.
15
+ const STANDALONE_STORE_FILES = new Set([
16
+ 'agent-roles.json', 'providers.json', 'execution-ledger.jsonl',
17
+ 'decisions.json', 'decisions.md', 'lessons.json', 'lessons.md', 'task-log.md',
18
+ 'routing-log.json', 'current-state.md', 'session-handoff.md',
19
+ 'active-wakeups.json', 'auto-resume-plan.json', 'next-action-queue.json',
20
+ 'pre-wake-report.json', 'last-handoff.json', 'bugfix-receipts.json',
21
+ 'claims-baseline.json', 'previews.json', 'preview-config.json', 'referees.json',
22
+ 'tech-profile.json', 'toggles.json', 'leerness-config.json', 'permissions.json',
23
+ 'project-brief.md', 'plan.md', 'review-evidence.md', 'rules.md',
24
+ 'teams.json', 'teams.md', 'secret-baseline.json',
25
+ // Existing init-free CLI outputs; individual command prerequisites remain
26
+ // enforced by their domains. See runtime-compatibility-api.md for producers.
27
+ 'user-requests.json', 'shell-failures.json', 'platform-constraints.json',
28
+ 'wakeup-history.json', 'agent-slash-commands.json', 'environment.json',
29
+ 'agent-permissions.json', 'credentials.local.json', 'llm-bench-history.md',
30
+ 'glossary.md', 'glossary.json', 'reuse-map.md', 'design-system.md',
31
+ 'skill-suggestions.md', 'skill-auto-cache.json', 'provider-probe-cache.json',
32
+ 'orchestrate-log.md', 'feature-graph.md', 'enforce.json', 'agent-reminders.md',
33
+ // Legacy memory deletion/restoration retains these exact Markdown stores.
34
+ 'decisions.archive.md', 'lessons.archive.md', 'plan.archive.md', 'rules.archive.md',
35
+ ]);
36
+ // Exact output directories of existing init-free producers: preview builders,
37
+ // API/ordinary skills, personas, review prompts, incident storage and skill
38
+ // publish staging. Admission checks only their kind, not contents or domain
39
+ // validity. Recognizing a staging directory never authorizes a publication.
40
+ const STANDALONE_STORE_DIRECTORIES = new Set([
41
+ 'previews', 'api-skills', 'skills', 'personas', 'skills-export', 'reviews',
42
+ 'incidents', 'skills-publish', 'skills-publish-tarball',
43
+ ]);
44
+ // role-store's POSIX CAS retains mkdtemp(".agent-roles-cas-")/before.
45
+ // Recognize that exact producer shape only beside its regular store. This
46
+ // grants no trust, cleanup permission, or content access to recovery data.
47
+ const ROLE_RECOVERY_DIRECTORY = /^\.agent-roles-cas-[A-Za-z0-9]{6}$/;
48
+ // _withLock reserves <store>.lock directories and releases them through
49
+ // <store>.lock.release-TOKEN (16 random bytes in lowercase hex). io.writeUtf8 writes
50
+ // <store>.tmp-PID-SEQ and may retain JSON originals as
51
+ // <store>.corrupt-TIME-PID-SEQ. The live file can be absent during either
52
+ // first installation or corruption evacuation; the exact known base name
53
+ // supplies the producer association without reading private store contents.
54
+ const STORE_AUXILIARY = /^(.+)\.(lock(?:\.release-[a-f0-9]{32})?|tmp-[1-9][0-9]*-[1-9][0-9]*|corrupt-[1-9][0-9]*-[1-9][0-9]*-[1-9][0-9]*)$/;
55
+ // Exclusive first-install files from role-store and role-fallback, respectively.
56
+ // io.detachCommittedHardLink may create one empty detacher beside that exact
57
+ // prepared alias when both alias removal methods fail after installation.
58
+ const STORE_CREATE_FILE = /^\.(?:agent-roles|execution-ledger)-create-[1-9][0-9]*-[a-f0-9]{24}(?:\.detach-[1-9][0-9]*-[a-f0-9]{16})?$/;
59
+ const fail = reasonCode => { throw Object.assign(new Error('Runtime layout could not be accepted.'), { reasonCode }); };
60
+ const envValue = name => {
61
+ const values = Object.keys(process.env).filter(key => key.toUpperCase() === name).map(key => process.env[key]);
62
+ if (new Set(values).size > 1) fail('environment_ambiguous');
63
+ return values[0] || '';
64
+ };
65
+ const checkDiscoveryOverrides = () => {
66
+ const across = envValue('GIT_DISCOVERY_ACROSS_FILESYSTEM');
67
+ if (envValue('GIT_CEILING_DIRECTORIES') || (across && !/^(0|false|no|off)$/i.test(across))) {
68
+ fail('discovery_override_unsupported');
69
+ }
70
+ };
71
+ const statMaybe = file => {
72
+ try { return fs.lstatSync(file, { bigint: true }); }
73
+ catch (error) { if (error.code === 'ENOENT') return null; fail('layout_unreadable'); }
74
+ };
75
+ const sameIdentity = (a, b) => a && b && a.dev === b.dev && a.ino === b.ino
76
+ && a.mode === b.mode && a.nlink === b.nlink;
77
+
78
+ // An init target may not exist yet. Canonicalize only its existing ancestor.
79
+ function projectLocation(value) {
80
+ if (typeof value !== 'string' || !value.trim() || /[\x00-\x1f\x7f]/.test(value)) fail('path_invalid');
81
+ let current = path.resolve(value);
82
+ const suffix = [];
83
+ for (let depth = 0; depth < 128; depth++) {
84
+ const stat = statMaybe(current);
85
+ if (stat) {
86
+ const ancestor = SG.canonicalDirectory(current);
87
+ return { root: path.join(ancestor, ...suffix), ancestor };
88
+ }
89
+ const parent = path.dirname(current);
90
+ if (parent === current) fail('path_not_found');
91
+ suffix.unshift(path.basename(current));
92
+ current = parent;
93
+ }
94
+ fail('path_depth_limit');
95
+ }
96
+
97
+ function hasBareMarker(root) {
98
+ const device = fs.statSync(root, { bigint: true }).dev;
99
+ let current = root;
100
+ for (let depth = 0; depth < 128; depth++) {
101
+ if (statMaybe(path.join(current, 'HEAD')) && statMaybe(path.join(current, 'objects'))
102
+ && (statMaybe(path.join(current, 'refs')) || statMaybe(path.join(current, 'config')))) return true;
103
+ const parent = path.dirname(current);
104
+ if (parent === current || fs.statSync(parent, { bigint: true }).dev !== device) return false;
105
+ current = parent;
106
+ }
107
+ fail('path_depth_limit');
108
+ }
109
+
110
+ function optionalGit(root) {
111
+ try { return SG.resolveGitTopology(root); }
112
+ catch (error) {
113
+ if (error.code !== 'git_missing') throw error;
114
+ // No executable is acceptable only for positively observed non-repositories.
115
+ if (SG.hasRepositoryMarker(root, process.env) || hasBareMarker(root)) fail('git_missing');
116
+ return null;
117
+ }
118
+ }
119
+
120
+ // A topology snapshot saves Git invocations, not admission decisions. In
121
+ // particular, creating Git metadata after a non-Git reader was constructed
122
+ // must not keep admitting the old project-local backend.
123
+ function verifyTopology(snapshot, current) {
124
+ if (!snapshot.git) {
125
+ if (SG.hasRepositoryMarker(current.ancestor, process.env) || hasBareMarker(current.ancestor)) {
126
+ fail('topology_changed');
127
+ }
128
+ return;
129
+ }
130
+ const { worktreeRoot, gitDir } = snapshot.git;
131
+ for (const location of snapshot.topologyDirectories) {
132
+ const stat = statMaybe(location.file);
133
+ if (!stat || !stat.isDirectory() || stat.isSymbolicLink()
134
+ || stat.dev !== location.stat.dev || stat.ino !== location.stat.ino
135
+ || SG.canonicalDirectory(location.file) !== location.file) fail('topology_changed');
136
+ }
137
+ let directory = current.ancestor;
138
+ for (let depth = 0; depth < 128; depth++) {
139
+ if (directory === worktreeRoot) {
140
+ const marker = statMaybe(path.join(directory, '.git'));
141
+ const before = snapshot.gitMarker;
142
+ if (!marker || marker.isSymbolicLink() || !before || marker.dev !== before.dev || marker.ino !== before.ino
143
+ || marker.isFile() !== before.isFile() || (marker.isFile() && (marker.size !== before.size
144
+ || marker.mtimeNs !== before.mtimeNs || marker.ctimeNs !== before.ctimeNs))) fail('topology_changed');
145
+ return;
146
+ }
147
+ if (statMaybe(path.join(directory, '.git'))) fail('topology_changed');
148
+ const parent = path.dirname(directory);
149
+ if (parent === directory) fail('topology_changed');
150
+ directory = parent;
151
+ }
152
+ fail('path_depth_limit');
153
+ }
154
+
155
+ // Check each component without following workspace/descriptor parent links.
156
+ function checkedPath(base, parts) {
157
+ let file = base;
158
+ const parents = [];
159
+ for (let i = -1; i < parts.length; i++) {
160
+ if (i >= 0) file = path.join(file, parts[i]);
161
+ const stat = statMaybe(file);
162
+ if (!stat) return { file: path.join(base, ...parts), stat: null, parents };
163
+ if (stat.isSymbolicLink()) fail('layout_linked');
164
+ if (i < parts.length - 1) {
165
+ if (!stat.isDirectory()) fail('layout_parent_invalid');
166
+ parents.push({ file, stat });
167
+ } else return { file, stat, parents };
168
+ }
169
+ fail('layout_path_invalid');
170
+ }
171
+ const verifyParents = parents => {
172
+ for (const before of parents) {
173
+ const after = statMaybe(before.file);
174
+ if (!sameIdentity(before.stat, after) || !after.isDirectory()) fail('layout_changed');
175
+ }
176
+ };
177
+ const assertRegular = stat => {
178
+ if (!stat || !stat.isFile()) fail('descriptor_not_regular');
179
+ if (stat.nlink !== 1n) fail('descriptor_hard_linked');
180
+ if (stat.size > BigInt(MAX_DESCRIPTOR_BYTES)) fail('descriptor_too_large');
181
+ };
182
+
183
+ function readDescriptor(location) {
184
+ assertRegular(location.stat);
185
+ let fd;
186
+ try {
187
+ // A replacement with a FIFO between lstat and open must not block the
188
+ // reader before fstat can reject it as non-regular (where supported).
189
+ fd = fs.openSync(location.file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0)
190
+ | (fs.constants.O_NONBLOCK || 0));
191
+ const opened = fs.fstatSync(fd, { bigint: true });
192
+ assertRegular(opened);
193
+ if (!sameIdentity(opened, location.stat)) fail('layout_changed');
194
+ const bytes = Buffer.alloc(MAX_DESCRIPTOR_BYTES + 1);
195
+ let length = 0;
196
+ while (length < bytes.length) {
197
+ const count = fs.readSync(fd, bytes, length, bytes.length - length, length);
198
+ if (!count) break;
199
+ length += count;
200
+ }
201
+ if (length > MAX_DESCRIPTOR_BYTES) fail('descriptor_too_large');
202
+ const after = fs.fstatSync(fd, { bigint: true });
203
+ const named = statMaybe(location.file);
204
+ if (!sameIdentity(opened, after) || !sameIdentity(after, named) || named.isSymbolicLink()
205
+ || opened.size !== BigInt(length) || after.size !== opened.size
206
+ || after.mtimeNs !== opened.mtimeNs || after.ctimeNs !== opened.ctimeNs) fail('layout_changed');
207
+ verifyParents(location.parents);
208
+ try { return new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes.subarray(0, length)); }
209
+ catch { fail('descriptor_invalid_utf8'); }
210
+ } finally { if (fd !== undefined) fs.closeSync(fd); }
211
+ }
212
+
213
+ function decodeDescriptor(text, scope) {
214
+ let data;
215
+ try { data = JSON.parse(text); } catch { fail('descriptor_invalid_json'); }
216
+ if (!data || typeof data !== 'object' || Array.isArray(data)) fail('descriptor_invalid');
217
+ const keys = Object.keys(data);
218
+ if (keys.length !== FIELDS.length || keys.some(key => !FIELDS.includes(key))) fail('descriptor_invalid');
219
+ // The schema is flat and scalar. Count syntactic keys, decoding escapes first,
220
+ // so duplicate or escaped-duplicate keys cannot be hidden by JSON.parse.
221
+ const sourceKeys = [...text.matchAll(/"((?:[^"\\]|\\.)*)"\s*:/g)].map(match => JSON.parse(`"${match[1]}"`));
222
+ if (sourceKeys.length !== keys.length || new Set(sourceKeys).size !== keys.length) fail('descriptor_duplicate_key');
223
+ if (typeof data.schema !== 'string' || !Number.isSafeInteger(data.schemaVersion) || data.schemaVersion < 1
224
+ || typeof data.scope !== 'string' || !Number.isSafeInteger(data.generation) || data.generation < 1
225
+ || typeof data.layout !== 'string' || !Number.isSafeInteger(data.requiredWriterProtocol)
226
+ || data.requiredWriterProtocol < 1) fail('descriptor_invalid');
227
+ if (data.scope !== scope) fail('descriptor_scope_mismatch');
228
+ if (data.schema !== 'leerness.runtime-layout/v1' || data.schemaVersion !== 1
229
+ || data.requiredWriterProtocol !== 1 || data.layout !== 'legacy') fail('layout_unsupported');
230
+ return data;
231
+ }
232
+
233
+ function standaloneEntryAllowed(directory, name) {
234
+ const knownDirectory = STANDALONE_STORE_DIRECTORIES.has(name);
235
+ const recovery = ROLE_RECOVERY_DIRECTORY.test(name);
236
+ const auxiliary = STORE_AUXILIARY.exec(name);
237
+ const knownAuxiliary = auxiliary && STANDALONE_STORE_FILES.has(auxiliary[1])
238
+ && (!auxiliary[2].startsWith('corrupt-') || auxiliary[1].endsWith('.json'));
239
+ // Unknown names fail even if they disappear after workspace enumeration.
240
+ if (!STANDALONE_STORE_FILES.has(name) && !knownDirectory && !recovery && !knownAuxiliary && !STORE_CREATE_FILE.test(name)) return false;
241
+ const stat = statMaybe(path.join(directory, name));
242
+ // A competing producer can release its lock or rename its temporary file
243
+ // between readdir and lstat. Absence introduces no foreign object.
244
+ if (!stat) return true;
245
+ if (stat.isSymbolicLink()) return false;
246
+ if (recovery) {
247
+ const store = statMaybe(path.join(directory, 'agent-roles.json'));
248
+ return stat.isDirectory() && store && store.isFile() && !store.isSymbolicLink();
249
+ }
250
+ return knownDirectory || (knownAuxiliary && auxiliary[2].startsWith('lock')) ? stat.isDirectory() : stat.isFile();
251
+ }
252
+
253
+ function validateWorkspace(root) {
254
+ const snapshot = W.inspectWorkspace(root);
255
+ if (snapshot.legacy.inspectionError || snapshot.canonical.inspectionError) fail('workspace_unreadable');
256
+ W.selectWorkspaceDirName(snapshot, envValue('LEERNESS_WORKSPACE_DIR'));
257
+ if (snapshot.legacy.exists && !snapshot.legacy.live && snapshot.legacy.entries.length) fail('workspace_ambiguous');
258
+ if (snapshot.canonical.foreign) {
259
+ const standalone = snapshot.canonical.isDirectory && snapshot.canonical.nonSubstrateEntries
260
+ .every(name => standaloneEntryAllowed(snapshot.canonical.abs, name));
261
+ // These stores have their own authority; they are not the migratable
262
+ // pre-existing structured-state substrate beside a live legacy store.
263
+ if (!standalone || snapshot.legacy.live) fail('workspace_ambiguous');
264
+ }
265
+ }
266
+
267
+ function disposition(reasonCode, scope = 'unknown', observedLayout = 'unknown') {
268
+ const compatible = reasonCode === 'legacy_absent' || reasonCode === 'legacy_supported';
269
+ return { schema: 'leerness.runtime-compatibility/v1', schemaVersion: 1, ok: compatible, compatible,
270
+ writeDisposition: compatible ? 'allowed' : 'blocked', reasonCode, scope, observedLayout,
271
+ supportedWriterProtocol: 1, activationSupported: false, workspaceAdmission: 'operation-start' };
272
+ }
273
+ function failureReport(error, scope) {
274
+ const known = error instanceof SG.StatePathError || error instanceof W.WorkspaceDirectoryError;
275
+ const reason = error.reasonCode || (known ? String(error.code).replace(/^E_/, '').toLowerCase() : 'layout_unreadable');
276
+ return disposition(reason, scope, reason === 'layout_unsupported' ? 'unsupported' : 'unknown');
277
+ }
278
+
279
+ function inspectSnapshot(snapshot) {
280
+ const { root, ancestor, git, scope } = snapshot;
281
+ try {
282
+ checkDiscoveryOverrides();
283
+ // A newly-created target may no longer be the path admitted by this snapshot.
284
+ const current = projectLocation(root);
285
+ if (current.root !== root) fail('layout_changed');
286
+ const ancestorNow = SG.canonicalDirectory(ancestor);
287
+ if (ancestorNow !== ancestor) fail('layout_changed');
288
+ verifyTopology(snapshot, current);
289
+ // Ownership/selection was admitted when this operation started. A legacy
290
+ // init or lock-owned workspace migration creates temporary names and may
291
+ // briefly be dual-live. Do not reclassify its own intermediate contents.
292
+ // Each fixed path walk below still checks fresh directory kinds/links and
293
+ // runtime indicators. A new public inspection makes a new admission.
294
+ const canonical = name => checkedPath(root, [W.CANONICAL_WORKSPACE_DIR, 'cache', name]);
295
+ const alternate = name => checkedPath(root, [W.LEGACY_WORKSPACE_DIR, 'cache', name]);
296
+ if (alternate('state-layout.json').stat || alternate('state-runtime').stat) fail('alternate_layout_present');
297
+ const localDescriptor = canonical('state-layout.json');
298
+ const localRuntime = canonical('state-runtime');
299
+ if (git && (localDescriptor.stat || localRuntime.stat)) fail('backend_transition_unresolved');
300
+ if (!git && localRuntime.stat) fail('runtime_without_compatible_layout');
301
+ const location = git ? checkedPath(git.gitDir, ['leerness', 'layout.json']) : localDescriptor;
302
+ if (git && checkedPath(git.gitDir, ['leerness', 'projects']).stat) fail('runtime_without_compatible_layout');
303
+ if (!location.stat) return disposition('legacy_absent', scope, 'legacy');
304
+ decodeDescriptor(readDescriptor(location), scope);
305
+ return disposition('legacy_supported', scope, 'legacy');
306
+ } catch (error) { return failureReport(error, scope); }
307
+ }
308
+
309
+ function createRuntimeCompatibilityReader(root = process.cwd()) {
310
+ try {
311
+ checkDiscoveryOverrides();
312
+ const location = projectLocation(root);
313
+ const git = optionalGit(location.ancestor);
314
+ validateWorkspace(location.root);
315
+ const topologyDirectories = git ? [...new Set([git.worktreeRoot, git.gitDir])]
316
+ .map(file => ({ file, stat: statMaybe(file) })) : [];
317
+ if (topologyDirectories.some(value => !value.stat)) fail('topology_changed');
318
+ const gitMarker = git ? statMaybe(path.join(git.worktreeRoot, '.git')) : null;
319
+ const snapshot = Object.freeze({ ...location, git, topologyDirectories, gitMarker,
320
+ scope: git ? 'worktree' : 'project-local' });
321
+ return () => inspectSnapshot(snapshot);
322
+ } catch (error) { return () => failureReport(error, 'unknown'); }
323
+ }
324
+ const inspectRuntimeCompatibility = root => createRuntimeCompatibilityReader(root)();
325
+ // Internal writer identity: existing aliases and Windows namespaced paths must
326
+ // share one operation admission. Preserve the reader's bounded failure taxonomy.
327
+ function canonicalRuntimeRoot(root) {
328
+ try { return projectLocation(root).root; }
329
+ catch (error) {
330
+ throw Object.assign(new Error('Runtime project identity could not be accepted.'), {
331
+ code: 'E_RUNTIME_LAYOUT_INCOMPATIBLE', reasonCode: failureReport(error, 'unknown').reasonCode,
332
+ });
333
+ }
334
+ }
335
+ // Only new writer operations join an existing legacy migration. Waiting is
336
+ // read-only: lock presence never grants ownership or admits a conflicting
337
+ // workspace. Public diagnosis above remains immediate and unchanged.
338
+ function createRuntimeWriterAdmission(root = process.cwd(), lockWaitMs) {
339
+ let waitedForLock = false;
340
+ const clock = () => Number(process.hrtime.bigint() / 1000000n);
341
+ const started = clock();
342
+ const result = reader => ({ reader, waitedForLock, lockWaitedMs: waitedForLock ? clock() - started : 0 });
343
+ try {
344
+ const location = projectLocation(root);
345
+ const lock = path.join(location.root, '.leerness-workspace-migration.lock');
346
+ const configured = lockWaitMs === undefined ? envValue('LEERNESS_WORKSPACE_MIGRATION_LOCK_WAIT_MS') : lockWaitMs;
347
+ const parsed = configured === '' || configured === undefined ? NaN : Number(configured);
348
+ const budget = Number.isFinite(parsed) ? Math.max(0, Math.min(60000, parsed)) : 10000;
349
+ const deadline = started + budget;
350
+ const contention = () => {
351
+ waitedForLock = true;
352
+ if (clock() >= deadline) fail('workspace_dir_migration_locked');
353
+ };
354
+ const pending = () => {
355
+ const stat = statMaybe(lock);
356
+ if (!stat) return false;
357
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1n) fail('workspace_dir_migration_locked');
358
+ return true;
359
+ };
360
+ let refreshedConflict = false;
361
+ for (;;) {
362
+ while (pending()) {
363
+ contention();
364
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, Math.max(0, Math.min(25, deadline - clock())));
365
+ }
366
+ const reader = createRuntimeCompatibilityReader(location.root);
367
+ const report = reader();
368
+ const workspaceConflict = ['workspace_dir_conflict', 'workspace_ambiguous'].includes(report.reasonCode);
369
+ // Once observed, an incompatible descriptor/topology is terminal for
370
+ // this operation, even if another process removes it before check().
371
+ if (!report.compatible && !workspaceConflict) return result(() => report);
372
+ // A peer can start while the snapshot is being classified. Only after
373
+ // retaining any observed non-workspace denial may we join and refresh.
374
+ if (pending()) { contention(); continue; }
375
+ // A peer may also finish AND release between those observations. One
376
+ // fresh classification closes that stale-conflict path without turning
377
+ // genuine persistent conflicts into a retry loop or permission grant.
378
+ if (!refreshedConflict && workspaceConflict) {
379
+ refreshedConflict = true;
380
+ continue;
381
+ }
382
+ return result(reader);
383
+ }
384
+ } catch (error) { return result(() => failureReport(error, 'unknown')); }
385
+ }
386
+ function assertRuntimeWriteAllowed(root) {
387
+ const report = createRuntimeWriterAdmission(root).reader();
388
+ if (report.compatible) return report;
389
+ throw Object.assign(new Error('Runtime layout is incompatible; no metadata write is allowed.'), {
390
+ code: 'E_RUNTIME_LAYOUT_INCOMPATIBLE', reasonCode: report.reasonCode,
391
+ });
392
+ }
393
+
394
+ module.exports = { MAX_DESCRIPTOR_BYTES, createRuntimeCompatibilityReader, inspectRuntimeCompatibility,
395
+ assertRuntimeWriteAllowed, createRuntimeWriterAdmission, canonicalRuntimeRoot };
@@ -0,0 +1,166 @@
1
+ 'use strict';
2
+
3
+ // 호환 릴리스의 관측 기반 쓰기 경계. 원자 fencing/activation 프로토콜이 아니다.
4
+ // topology만 한 작업 동안 재사용하고 descriptor/indicator는 매 쓰기마다 다시 읽는다.
5
+ const fs = require('fs');
6
+ const path = require('path');
7
+ const { fileURLToPath } = require('url');
8
+ const { AsyncLocalStorage } = require('async_hooks');
9
+ const operations = new AsyncLocalStorage();
10
+ const descriptors = new Map();
11
+ let installed = false;
12
+
13
+ function key(root) {
14
+ const value = path.resolve(root);
15
+ return process.platform === 'win32' ? value.toLowerCase() : value;
16
+ }
17
+
18
+ function metadataRoot(value) {
19
+ if (typeof value === 'number' || value == null) return null;
20
+ const input = value instanceof URL ? fileURLToPath(value) : Buffer.isBuffer(value) ? value.toString() : value;
21
+ if (typeof input !== 'string') return null;
22
+ const absolute = path.resolve(input);
23
+ const parsed = path.parse(absolute);
24
+ const parts = absolute.slice(parsed.root.length).split(path.sep);
25
+ const W = require('./workspace-dir');
26
+ const index = parts.findIndex(part => [W.CANONICAL_WORKSPACE_DIR, W.LEGACY_WORKSPACE_DIR].includes(part.toLowerCase()));
27
+ return index < 0 ? null : path.join(parsed.root, ...parts.slice(0, index));
28
+ }
29
+
30
+ function canonicalRoot(operation, root) {
31
+ if (operation.failure) throw operation.failure;
32
+ try { return require('./runtime-layout').canonicalRuntimeRoot(root); }
33
+ catch (error) { return reject(operation, { reasonCode: error.reasonCode || 'layout_unreadable' }); }
34
+ }
35
+
36
+ function addReader(operation, root, lockWaitMs) {
37
+ // Re-resolve each target: caching the spelling of a junction would retain
38
+ // stale authority after retargeting. Bind both the key and reader to this
39
+ // same canonical path, including Node 18's internal \\?\ rollback paths.
40
+ const canonical = canonicalRoot(operation, root);
41
+ const id = key(canonical);
42
+ if (!operation.readers.has(id)) {
43
+ const { createRuntimeWriterAdmission } = require('./runtime-layout');
44
+ const admission = createRuntimeWriterAdmission(canonical, lockWaitMs);
45
+ operation.readers.set(id, admission.reader);
46
+ operation.migrationWaits.set(id, { waitedForLock: admission.waitedForLock, lockWaitedMs: admission.lockWaitedMs });
47
+ }
48
+ }
49
+
50
+ function reject(operation, report) {
51
+ const error = Object.assign(new Error(`Runtime layout blocks writes (${report.reasonCode}). Run state compatibility to diagnose; no activation is supported.`), {
52
+ code: 'E_RUNTIME_LAYOUT_INCOMPATIBLE', reasonCode: report.reasonCode,
53
+ });
54
+ operation.failure = error;
55
+ throw error;
56
+ }
57
+
58
+ function check(operation, targets = []) {
59
+ if (!operation) return;
60
+ if (operation.failure) throw operation.failure;
61
+ for (const target of targets) {
62
+ const root = metadataRoot(target);
63
+ if (root) addReader(operation, root);
64
+ }
65
+ for (const reader of operation.readers.values()) {
66
+ const report = reader();
67
+ if (report.writeDisposition !== 'allowed') reject(operation, report);
68
+ }
69
+ }
70
+
71
+ function assertCurrentRuntimeWrite(target) {
72
+ const operation = operations.getStore();
73
+ if (operation) check(operation, target == null ? [] : [target]);
74
+ }
75
+ function runtimeWriteRoots() { const operation = operations.getStore(); return operation ? [...operation.readers.keys()] : []; }
76
+ function runtimeMigrationWait(root) {
77
+ const operation = operations.getStore();
78
+ return (operation && operation.migrationWaits.get(key(canonicalRoot(operation, root))))
79
+ || { waitedForLock: false, lockWaitedMs: 0 };
80
+ }
81
+
82
+ // best-effort catch가 거부를 성공으로 바꾸지 않게 래치한다. async도 같은 scope다.
83
+ function withRuntimeWrites(root, fn, options = {}) {
84
+ const parent = operations.getStore();
85
+ if (parent && !options.fresh) {
86
+ addReader(parent, root, options.migrationLockWaitMs);
87
+ check(parent);
88
+ return fn();
89
+ }
90
+ const operation = { readers: new Map(), migrationWaits: new Map(), failure: null };
91
+ addReader(operation, root, options.migrationLockWaitMs);
92
+ check(operation);
93
+ installRuntimeWriteInterception();
94
+ return operations.run(operation, () => {
95
+ const finish = value => { if (operation.failure) throw operation.failure; return value; };
96
+ const failed = error => { throw operation.failure || error; };
97
+ try {
98
+ const result = fn();
99
+ return result && typeof result.then === 'function' ? result.then(finish, failed) : finish(result);
100
+ } catch (error) { return failed(error); }
101
+ });
102
+ }
103
+
104
+ function withRuntimePathWrite(target, fn) {
105
+ const operation = operations.getStore();
106
+ if (operation) { check(operation, [target]); return fn(); }
107
+ const root = metadataRoot(target);
108
+ return root ? withRuntimeWrites(root, fn) : fn();
109
+ }
110
+
111
+ function projectWriter(fn) {
112
+ return function (...args) { return withRuntimeWrites(args[0], () => fn.apply(this, args)); };
113
+ }
114
+
115
+ function pathWriter(fn) {
116
+ return function (...args) { return withRuntimePathWrite(args[0], () => fn.apply(this, args)); };
117
+ }
118
+
119
+ function writingFlags(flags) {
120
+ if (typeof flags === 'number') return !!(flags & (fs.constants.O_WRONLY | fs.constants.O_RDWR | fs.constants.O_CREAT | fs.constants.O_TRUNC | fs.constants.O_APPEND));
121
+ return typeof flags === 'string' && /[wa+]/.test(flags);
122
+ }
123
+
124
+ // 제품은 동기 path/FD writer를 사용한다. import만으로 host fs를 바꾸지 않고
125
+ // 첫 명시적 writer에서 설치하며, operation 밖의 외부 편집은 이 경계의 대상이 아니다.
126
+ function installRuntimeWriteInterception() {
127
+ if (installed) return;
128
+ installed = true;
129
+ const both = new Set(['renameSync', 'copyFileSync', 'cpSync', 'linkSync', 'symlinkSync']);
130
+ const mutations = ['writeFileSync', 'appendFileSync', 'unlinkSync', 'rmSync', 'rmdirSync',
131
+ 'renameSync', 'copyFileSync', 'cpSync', 'mkdirSync', 'mkdtempSync', 'mkdtempDisposableSync', 'chmodSync', 'utimesSync',
132
+ 'symlinkSync', 'linkSync', 'truncateSync', 'writeSync', 'writevSync', 'ftruncateSync', 'fchmodSync', 'futimesSync'];
133
+ for (const name of mutations) {
134
+ const original = fs[name];
135
+ if (typeof original !== 'function') continue;
136
+ fs[name] = function (...args) {
137
+ const operation = operations.getStore();
138
+ check(operation, both.has(name) ? args.slice(0, 2) : [args[0]]);
139
+ if (typeof args[0] === 'number') {
140
+ const opened = descriptors.get(args[0]);
141
+ if (opened && opened !== operation) {
142
+ try { check(opened); }
143
+ catch (error) { if (operation) operation.failure = error; throw error; }
144
+ }
145
+ }
146
+ return original.apply(this, args);
147
+ };
148
+ }
149
+ const open = fs.openSync;
150
+ fs.openSync = function (file, flags, ...args) {
151
+ const operation = operations.getStore();
152
+ if (writingFlags(flags)) check(operation, [file]);
153
+ const fd = open.call(this, file, flags, ...args);
154
+ if (operation && writingFlags(flags)) descriptors.set(fd, operation);
155
+ return fd;
156
+ };
157
+ const close = fs.closeSync;
158
+ fs.closeSync = function (fd) {
159
+ const result = close.call(this, fd);
160
+ descriptors.delete(fd);
161
+ return result;
162
+ };
163
+ }
164
+
165
+ module.exports = { withRuntimeWrites, withRuntimePathWrite, assertCurrentRuntimeWrite,
166
+ installRuntimeWriteInterception, projectWriter, pathWriter, runtimeWriteRoots, runtimeMigrationWait };
@@ -799,4 +799,4 @@ function sessionClose(root, opts = {}, deps = {}) {
799
799
  }
800
800
  }
801
801
 
802
- module.exports = { sessionClose, _upsertAutoLines, _executionProvenanceSummary }; // 1.36.98: 보존 규칙을 selftest 가 직접 검증할 수 있게 노출
802
+ module.exports = { sessionClose: require('./runtime-writes').projectWriter(sessionClose), _upsertAutoLines, _executionProvenanceSummary }; // 1.36.98: 보존 규칙을 selftest 가 직접 검증할 수 있게 노출