@cohortapp/agent-sdk 2.12.0 → 2.13.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 (127) hide show
  1. package/bin/maestro.mjs +6 -2
  2. package/docs/guides/front-door-session.md +49 -0
  3. package/lib/cli/design.mjs +185 -0
  4. package/lib/cli/design.test.mjs +270 -0
  5. package/lib/cli/global-setup-extras.mjs +44 -0
  6. package/lib/cli/global-setup-extras.test.mjs +95 -0
  7. package/lib/cli/session.mjs +11 -1
  8. package/lib/cli/session.test.mjs +17 -6
  9. package/lib/collective/global-config.mjs +5 -0
  10. package/lib/collective/global-config.test.mjs +5 -0
  11. package/lib/collective/vendor-skills.mjs +305 -0
  12. package/lib/collective/vendor-skills.test.mjs +306 -0
  13. package/lib/design/design-md.mjs +793 -0
  14. package/lib/design/design-md.test.mjs +318 -0
  15. package/lib/design/fixtures/DESIGN.golden.md +238 -0
  16. package/lib/design/fixtures/PRODUCT.golden.md +67 -0
  17. package/lib/design/fixtures/foundation.json +133 -0
  18. package/lib/design/refresh-gate.mjs +154 -0
  19. package/lib/design/refresh-gate.test.mjs +144 -0
  20. package/lib/design/write.mjs +275 -0
  21. package/lib/design/write.test.mjs +241 -0
  22. package/lib/prompts/parallelism.mjs +79 -0
  23. package/lib/prompts/parallelism.test.mjs +177 -0
  24. package/package.json +1 -1
  25. package/plugins/maestro-skills/plugin.json +4 -0
  26. package/plugins/maestro-skills/skills/cohort-design.md +153 -0
  27. package/plugins/maestro-skills/vendor/emilkowalski/LICENSE +21 -0
  28. package/plugins/maestro-skills/vendor/emilkowalski/UPSTREAM.json +70 -0
  29. package/plugins/maestro-skills/vendor/emilkowalski/skills/animate/RECIPES.md +324 -0
  30. package/plugins/maestro-skills/vendor/emilkowalski/skills/animate/SKILL.md +199 -0
  31. package/plugins/maestro-skills/vendor/emilkowalski/skills/animation-vocabulary/SKILL.md +173 -0
  32. package/plugins/maestro-skills/vendor/emilkowalski/skills/apple-design/SKILL.md +282 -0
  33. package/plugins/maestro-skills/vendor/emilkowalski/skills/emil-design-eng/SKILL.md +674 -0
  34. package/plugins/maestro-skills/vendor/emilkowalski/skills/find-animation-opportunities/SKILL.md +132 -0
  35. package/plugins/maestro-skills/vendor/emilkowalski/skills/improve-animations/AUDIT.md +115 -0
  36. package/plugins/maestro-skills/vendor/emilkowalski/skills/improve-animations/PLAN-TEMPLATE.md +73 -0
  37. package/plugins/maestro-skills/vendor/emilkowalski/skills/improve-animations/SKILL.md +101 -0
  38. package/plugins/maestro-skills/vendor/emilkowalski/skills/prototype/PICKER.md +197 -0
  39. package/plugins/maestro-skills/vendor/emilkowalski/skills/prototype/SKILL.md +90 -0
  40. package/plugins/maestro-skills/vendor/emilkowalski/skills/review-animations/SKILL.md +112 -0
  41. package/plugins/maestro-skills/vendor/emilkowalski/skills/review-animations/STANDARDS.md +187 -0
  42. package/plugins/maestro-skills/vendor/impeccable/LICENSE +191 -0
  43. package/plugins/maestro-skills/vendor/impeccable/NOTICE.md +11 -0
  44. package/plugins/maestro-skills/vendor/impeccable/SKILL.md +86 -0
  45. package/plugins/maestro-skills/vendor/impeccable/UPSTREAM.json +201 -0
  46. package/plugins/maestro-skills/vendor/impeccable/agents/impeccable-asset-producer.md +42 -0
  47. package/plugins/maestro-skills/vendor/impeccable/agents/impeccable-documenter.md +29 -0
  48. package/plugins/maestro-skills/vendor/impeccable/agents/impeccable-finish-reviewer.md +43 -0
  49. package/plugins/maestro-skills/vendor/impeccable/agents/impeccable-manual-edit-applier.md +97 -0
  50. package/plugins/maestro-skills/vendor/impeccable/reference/adapt.md +312 -0
  51. package/plugins/maestro-skills/vendor/impeccable/reference/adapt.native.md +58 -0
  52. package/plugins/maestro-skills/vendor/impeccable/reference/android.md +46 -0
  53. package/plugins/maestro-skills/vendor/impeccable/reference/animate.md +89 -0
  54. package/plugins/maestro-skills/vendor/impeccable/reference/audit.md +136 -0
  55. package/plugins/maestro-skills/vendor/impeccable/reference/audit.native.md +139 -0
  56. package/plugins/maestro-skills/vendor/impeccable/reference/bolder.md +33 -0
  57. package/plugins/maestro-skills/vendor/impeccable/reference/clarify.md +94 -0
  58. package/plugins/maestro-skills/vendor/impeccable/reference/colorize.md +86 -0
  59. package/plugins/maestro-skills/vendor/impeccable/reference/craft-floor.md +44 -0
  60. package/plugins/maestro-skills/vendor/impeccable/reference/craft.md +5 -0
  61. package/plugins/maestro-skills/vendor/impeccable/reference/critique.md +806 -0
  62. package/plugins/maestro-skills/vendor/impeccable/reference/degraded/asset-producer.md +37 -0
  63. package/plugins/maestro-skills/vendor/impeccable/reference/degraded/documenter.md +24 -0
  64. package/plugins/maestro-skills/vendor/impeccable/reference/degraded/finish-reviewer.md +38 -0
  65. package/plugins/maestro-skills/vendor/impeccable/reference/degraded/manual-edit-applier.md +92 -0
  66. package/plugins/maestro-skills/vendor/impeccable/reference/delight.md +70 -0
  67. package/plugins/maestro-skills/vendor/impeccable/reference/distill.md +111 -0
  68. package/plugins/maestro-skills/vendor/impeccable/reference/doctor.md +54 -0
  69. package/plugins/maestro-skills/vendor/impeccable/reference/document.md +416 -0
  70. package/plugins/maestro-skills/vendor/impeccable/reference/extract.md +69 -0
  71. package/plugins/maestro-skills/vendor/impeccable/reference/harden.md +336 -0
  72. package/plugins/maestro-skills/vendor/impeccable/reference/hooks.md +111 -0
  73. package/plugins/maestro-skills/vendor/impeccable/reference/init.md +131 -0
  74. package/plugins/maestro-skills/vendor/impeccable/reference/ios.md +51 -0
  75. package/plugins/maestro-skills/vendor/impeccable/reference/layout.md +84 -0
  76. package/plugins/maestro-skills/vendor/impeccable/reference/live-setup.md +104 -0
  77. package/plugins/maestro-skills/vendor/impeccable/reference/live.md +325 -0
  78. package/plugins/maestro-skills/vendor/impeccable/reference/new-work.md +147 -0
  79. package/plugins/maestro-skills/vendor/impeccable/reference/onboard.md +234 -0
  80. package/plugins/maestro-skills/vendor/impeccable/reference/operate.md +61 -0
  81. package/plugins/maestro-skills/vendor/impeccable/reference/optimize.md +258 -0
  82. package/plugins/maestro-skills/vendor/impeccable/reference/overdrive.md +127 -0
  83. package/plugins/maestro-skills/vendor/impeccable/reference/polish.md +105 -0
  84. package/plugins/maestro-skills/vendor/impeccable/reference/quieter.md +99 -0
  85. package/plugins/maestro-skills/vendor/impeccable/reference/routing.md +24 -0
  86. package/plugins/maestro-skills/vendor/impeccable/reference/shape.md +59 -0
  87. package/plugins/maestro-skills/vendor/impeccable/reference/typeset.md +80 -0
  88. package/plugins/maestro-skills/vendor/impeccable/reference/visualize.md +46 -0
  89. package/plugins/maestro-skills/vendor/taste-skill/LICENSE +21 -0
  90. package/plugins/maestro-skills/vendor/taste-skill/UPSTREAM.json +37 -0
  91. package/plugins/maestro-skills/vendor/taste-skill/skills/minimalist-skill/SKILL.md +85 -0
  92. package/plugins/maestro-skills/vendor/taste-skill/skills/redesign-skill/SKILL.md +178 -0
  93. package/plugins/maestro-skills/vendor/taste-skill/skills/soft-skill/SKILL.md +98 -0
  94. package/plugins/maestro-skills/vendor/taste-skill/skills/taste-skill/SKILL.md +1206 -0
  95. package/plugins/maestro-skills/vendor/unlazy/LICENSE +21 -0
  96. package/plugins/maestro-skills/vendor/unlazy/SECURITY.md +72 -0
  97. package/plugins/maestro-skills/vendor/unlazy/SKILL.md +104 -0
  98. package/plugins/maestro-skills/vendor/unlazy/UPSTREAM.json +94 -0
  99. package/plugins/maestro-skills/vendor/unlazy/references/dispatch.md +82 -0
  100. package/plugins/maestro-skills/vendor/unlazy/references/gates.md +149 -0
  101. package/plugins/maestro-skills/vendor/unlazy/references/method.md +49 -0
  102. package/plugins/maestro-skills/vendor/unlazy/references/orchestration.md +107 -0
  103. package/plugins/maestro-skills/vendor/unlazy/references/parallel.md +133 -0
  104. package/plugins/maestro-skills/vendor/unlazy/references/token-economy.md +48 -0
  105. package/plugins/maestro-skills/vendor/unlazy/scripts/dispatch-check.mjs +139 -0
  106. package/plugins/maestro-skills/vendor/unlazy/scripts/gate-check.mjs +960 -0
  107. package/plugins/maestro-skills/vendor/unlazy/scripts/gate-lint.mjs +245 -0
  108. package/plugins/maestro-skills/vendor/unlazy/scripts/lib/check-supervisor.mjs +46 -0
  109. package/plugins/maestro-skills/vendor/unlazy/scripts/lib/dispatch.mjs +293 -0
  110. package/plugins/maestro-skills/vendor/unlazy/scripts/lib/gates.mjs +953 -0
  111. package/plugins/maestro-skills/vendor/unlazy/scripts/lib/process-tree.mjs +161 -0
  112. package/plugins/maestro-skills/vendor/unlazy/scripts/lib/regex-worker.mjs +9 -0
  113. package/plugins/maestro-skills/vendor/unlazy/templates/PLAN.md +116 -0
  114. package/plugins/maestro-skills/vendor/unlazy/templates/gates-leaf.md +51 -0
  115. package/plugins/maestro-skills/vendor/unlazy/templates/gates-node.md +51 -0
  116. package/scripts/ci/check-skill-packs.mjs +388 -0
  117. package/scripts/ci/check-skill-packs.test.mjs +495 -0
  118. package/scripts/ci/check.mjs +3 -0
  119. package/scripts/daemon/agent-daemon-design.test.mjs +238 -0
  120. package/scripts/daemon/agent-daemon.mjs +108 -0
  121. package/scripts/daemon/cadence-consumer-frontdoor.test.mjs +61 -2
  122. package/scripts/daemon/cadence-consumer.mjs +46 -22
  123. package/scripts/daemon/prompt-builder.mjs +19 -3
  124. package/scripts/local-triggers/autoupdate.test.mjs +33 -3
  125. package/scripts/vendor/skill-packs.mjs +354 -0
  126. package/scripts/vendor/sync-skill-packs.mjs +242 -0
  127. package/scripts/vendor/sync-skill-packs.test.mjs +103 -0
@@ -0,0 +1,953 @@
1
+ // Shared gate parsing, scope resolution, durable writes, locks, and leases.
2
+ // Zero dependencies. Node 16+.
3
+
4
+ import {
5
+ closeSync, constants as fsConstants, existsSync, fstatSync, fsyncSync, lstatSync, mkdirSync,
6
+ openSync, readFileSync, readdirSync, readSync, realpathSync, renameSync, statSync, unlinkSync,
7
+ writeFileSync,
8
+ } from "node:fs";
9
+ import { createHash, randomBytes } from "node:crypto";
10
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
11
+
12
+ export const UNLAZY_DIR = ".unlazy";
13
+ export const LOCK_DIR = join(UNLAZY_DIR, "locks");
14
+ export const MAX_CHECK_OUTPUT_BYTES = 1024 * 1024;
15
+ export const MAX_AUTOMATIC_EVIDENCE_CHARS = 900;
16
+
17
+ export const sleep = (ms) => new Promise((done) => setTimeout(done, ms));
18
+ export const sha256 = (value) => createHash("sha256").update(String(value)).digest("hex");
19
+
20
+ const WINDOWS_TRANSIENT_FS_ERRORS = new Set(["EACCES", "EBUSY", "EPERM"]);
21
+ const SYNC_SLEEP_CELL = new Int32Array(new SharedArrayBuffer(4));
22
+ const DEFAULT_STABLE_FILE_MAX_BYTES = 8 * 1024 * 1024;
23
+ const LEASE_MAX_BYTES = 64 * 1024;
24
+
25
+ function isTransientWindowsFsError(error) {
26
+ return process.platform === "win32" && WINDOWS_TRANSIENT_FS_ERRORS.has(error && error.code);
27
+ }
28
+
29
+ function pathIsInside(parent, child) {
30
+ const rel = relative(resolve(parent), resolve(child));
31
+ return rel === "" || (!rel.startsWith(".." + sep) && rel !== ".." && !isAbsolute(rel));
32
+ }
33
+
34
+ export function sameFileIdentity(left, right) {
35
+ return left.dev === right.dev && left.ino === right.ino;
36
+ }
37
+
38
+ function sameSnapshot(left, right) {
39
+ return sameFileIdentity(left, right) && left.size === right.size &&
40
+ (left.mtimeNs === undefined ? left.mtimeMs : left.mtimeNs) ===
41
+ (right.mtimeNs === undefined ? right.mtimeMs : right.mtimeNs) &&
42
+ (left.ctimeNs === undefined ? left.ctimeMs : left.ctimeNs) ===
43
+ (right.ctimeNs === undefined ? right.ctimeMs : right.ctimeNs);
44
+ }
45
+
46
+ function assertRegularSingleLink(info, target, label, maxBytes) {
47
+ if (!info.isFile() || info.isSymbolicLink() || (info.nlink !== 1 && info.nlink !== 1n)) {
48
+ throw new Error(label + " must be one unchanged regular single-link file: " + target);
49
+ }
50
+ if (info.size > maxBytes) {
51
+ throw new Error(label + " exceeds " + maxBytes + " bytes: " + target);
52
+ }
53
+ }
54
+
55
+ // Path-based stat and descriptor-based stat used different Windows/libuv
56
+ // implementations in affected Node releases, so their `dev` fields are not
57
+ // comparable. Keep lstat as the named-entry link/type guard, but on Windows
58
+ // acquire identity through a second, non-creating descriptor opened from the
59
+ // current name. Callers can then compare fstat to fstat without weakening the
60
+ // strict volume-plus-file identity rule.
61
+ export function statCurrentNamedFile(path, options = {}) {
62
+ const target = resolve(path);
63
+ const kind = String(options.label || "file");
64
+ const limit = options.maxBytes === undefined ? Infinity : Number(options.maxBytes);
65
+ if (!(limit === Infinity || (Number.isInteger(limit) && limit >= 1))) {
66
+ throw new Error(kind + " maxBytes must be a positive integer");
67
+ }
68
+ const accessFlags = options.openFlags === undefined
69
+ ? fsConstants.O_RDONLY | (fsConstants.O_NONBLOCK || 0)
70
+ : Number(options.openFlags);
71
+ const mutatingFlags = (fsConstants.O_CREAT || 0) | (fsConstants.O_TRUNC || 0);
72
+ if (!Number.isInteger(accessFlags) || (accessFlags & mutatingFlags) !== 0) {
73
+ throw new Error(kind + " identity descriptor must use non-creating, non-truncating flags");
74
+ }
75
+
76
+ const before = lstatSync(target, { bigint: true });
77
+ assertRegularSingleLink(before, target, kind, limit);
78
+ if (process.platform !== "win32") return before;
79
+
80
+ let fd = null;
81
+ try {
82
+ fd = openSync(target, accessFlags);
83
+ const current = fstatSync(fd, { bigint: true });
84
+ assertRegularSingleLink(current, target, kind, limit);
85
+ const after = lstatSync(target, { bigint: true });
86
+ assertRegularSingleLink(after, target, kind, limit);
87
+ const afterCurrent = fstatSync(fd, { bigint: true });
88
+ assertRegularSingleLink(afterCurrent, target, kind, limit);
89
+ // These are two results from the same path-stat implementation, so their
90
+ // strict identity and snapshot fields remain comparable even on affected
91
+ // libuv builds. This brackets the secondary open without comparing
92
+ // path-stat `dev` to descriptor-stat `dev`.
93
+ const namedEntryUnchanged = options.stableSnapshot === false
94
+ ? sameFileIdentity(before, after)
95
+ : sameSnapshot(before, after);
96
+ if (!namedEntryUnchanged) {
97
+ throw new Error(kind + " changed while its named identity was checked: " + target);
98
+ }
99
+ // The lstat pair alone cannot detect A -> B -> A around a descriptor open.
100
+ // Exact BigInt inode equality supplies the comparable named-to-handle field
101
+ // exposed by the affected runtime; the caller still makes the decisive
102
+ // strict dev+ino fstat-to-fstat check. This remains a snapshot rather than
103
+ // atomic Windows path isolation; SECURITY.md documents that boundary.
104
+ if (before.ino !== current.ino || after.ino !== current.ino) {
105
+ throw new Error(kind + " descriptor does not identify its guarded name: " + target);
106
+ }
107
+ if (options.stableSnapshot !== false && !sameSnapshot(current, afterCurrent)) {
108
+ throw new Error(kind + " changed while its descriptor identity was checked: " + target);
109
+ }
110
+ return afterCurrent;
111
+ } finally {
112
+ // A close failure is an infrastructure failure, not a reason to accept an
113
+ // identity result whose secondary descriptor did not close cleanly.
114
+ if (fd !== null) closeSync(fd);
115
+ }
116
+ }
117
+
118
+ // Read repository and coordination inputs without following a pre-existing
119
+ // symlink, blocking on a FIFO, accepting a hard link, or crossing an optional
120
+ // canonical root. The descriptor and named entry must identify the same stable
121
+ // file before and after the bounded read.
122
+ export function readStableRegularFile(path, { root, maxBytes, label } = {}) {
123
+ const target = resolve(path);
124
+ const kind = String(label || "file");
125
+ const limit = maxBytes === undefined ? DEFAULT_STABLE_FILE_MAX_BYTES : Number(maxBytes);
126
+ if (!Number.isInteger(limit) || limit < 1) throw new Error(kind + " maxBytes must be a positive integer");
127
+
128
+ let fd = null;
129
+ const noFollow = process.platform === "win32" ? 0 : (fsConstants.O_NOFOLLOW || 0);
130
+ try {
131
+ fd = openSync(target, fsConstants.O_RDONLY | (fsConstants.O_NONBLOCK || 0) | noFollow);
132
+ } catch (error) {
133
+ if (error && error.code === "ELOOP") {
134
+ throw new Error(kind + " must be one unchanged regular single-link file: " + target);
135
+ }
136
+ if (error && error.code === "ENOENT") {
137
+ try {
138
+ const named = lstatSync(target);
139
+ assertRegularSingleLink(named, target, kind, limit);
140
+ throw new Error(kind + " appeared after its open reported it missing: " + target);
141
+ } catch (probeError) {
142
+ if (probeError && probeError.code === "ENOENT") throw error;
143
+ throw probeError;
144
+ }
145
+ }
146
+ // ENOENT is intentionally exposed only here. Callers that permit a missing
147
+ // file can distinguish absence at descriptor acquisition from a later race.
148
+ throw error;
149
+ }
150
+
151
+ try {
152
+ const opened = fstatSync(fd, { bigint: true });
153
+ const named = statCurrentNamedFile(target, { maxBytes: limit, label: kind });
154
+ assertRegularSingleLink(opened, target, kind, limit);
155
+ if (!sameFileIdentity(opened, named)) {
156
+ throw new Error(kind + " changed before it was read: " + target);
157
+ }
158
+ const canonicalRoot = root === undefined ? null : realpathSync(resolve(root));
159
+ const canonicalBefore = realpathSync(target);
160
+ if (canonicalRoot && !pathIsInside(canonicalRoot, canonicalBefore)) {
161
+ throw new Error(kind + " resolves outside the allowed root: " + target);
162
+ }
163
+
164
+ const chunks = [];
165
+ let total = 0;
166
+ for (;;) {
167
+ const chunk = Buffer.allocUnsafe(Math.min(64 * 1024, limit + 1 - total));
168
+ const count = readSync(fd, chunk, 0, chunk.length, null);
169
+ if (count === 0) break;
170
+ chunks.push(chunk.subarray(0, count));
171
+ total += count;
172
+ if (total > limit) throw new Error(kind + " exceeds " + limit + " bytes: " + target);
173
+ }
174
+
175
+ const afterOpened = fstatSync(fd, { bigint: true });
176
+ const afterNamed = statCurrentNamedFile(target, { maxBytes: limit, label: kind });
177
+ assertRegularSingleLink(afterOpened, target, kind, limit);
178
+ if (!sameSnapshot(opened, afterOpened) || !sameSnapshot(afterOpened, afterNamed)) {
179
+ throw new Error(kind + " changed while it was read: " + target);
180
+ }
181
+ const canonicalAfter = realpathSync(target);
182
+ if (canonicalAfter !== canonicalBefore || (canonicalRoot && !pathIsInside(canonicalRoot, canonicalAfter))) {
183
+ throw new Error(kind + " changed canonical location while it was read: " + target);
184
+ }
185
+ return Buffer.concat(chunks, total).toString("utf8");
186
+ } catch (error) {
187
+ if (error && error.code === "ENOENT") {
188
+ throw new Error(kind + " changed while it was read: " + target);
189
+ }
190
+ throw error;
191
+ } finally {
192
+ if (fd !== null) try { closeSync(fd); } catch { /* ignore */ }
193
+ }
194
+ }
195
+
196
+ function realDirectoryInside(root, directory) {
197
+ try {
198
+ const named = lstatSync(directory);
199
+ if (named.isSymbolicLink() || !named.isDirectory()) return false;
200
+ return pathIsInside(realpathSync(resolve(root)), realpathSync(directory));
201
+ } catch {
202
+ return false;
203
+ }
204
+ }
205
+
206
+ function namedEntry(file) {
207
+ try {
208
+ lstatSync(file);
209
+ return true;
210
+ } catch (error) {
211
+ if (error && error.code !== "ENOENT") return true;
212
+ return false;
213
+ }
214
+ }
215
+
216
+ // Windows scanners and indexers can briefly retain a handle to a file after it
217
+ // closes. Keep the same private temp file and retry replacement while the caller
218
+ // still holds its lock; never unlink the destination or fall back to an in-place
219
+ // write, either of which would sacrifice atomicity.
220
+ function replaceAtomic(temp, target) {
221
+ const deadline = Date.now() + 2000;
222
+ let delay = 5;
223
+ for (;;) {
224
+ try {
225
+ renameSync(temp, target);
226
+ return;
227
+ } catch (error) {
228
+ const remaining = deadline - Date.now();
229
+ if (!isTransientWindowsFsError(error) || remaining <= 0) throw error;
230
+ Atomics.wait(SYNC_SLEEP_CELL, 0, 0, Math.min(delay, remaining));
231
+ delay = Math.min(delay * 2, 100);
232
+ }
233
+ }
234
+ }
235
+
236
+ const GATE_RE = /^- \[( |x|X)\] (.*)$/;
237
+ const ATTR_RE = /^(\s+)(CHECK|EXPECT|EVIDENCE|CWD):\s?(.*)$/;
238
+ const UNINDENTED_ATTR_RE = /^(CHECK|EXPECT|EVIDENCE|CWD):\s?(.*)$/;
239
+ const ABANDON_RE = /^ABANDON:\s*(\S*)\s*(.*)$/;
240
+ const INDENTED_ABANDON_RE = /^\s+ABANDON:/;
241
+ const OWNS_RE = /^OWNS:\s*(.*)$/;
242
+ const FENCE_OPEN_RE = /^( {0,3})(`{3,}|~{3,})(.*)$/;
243
+ const REGEX_RE = /^\/([\s\S]*)\/([a-z]*)$/;
244
+ // A pattern author escapes an inner slash or has none. A literal path always
245
+ // carries one, so an unescaped inner slash marks the ambiguous reading.
246
+ const UNESCAPED_SLASH_RE = /(^|[^\\])\//;
247
+ const SCOPE_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
248
+
249
+ function parseRegex(expect) {
250
+ const match = String(expect).match(REGEX_RE);
251
+ if (!match) return { kind: "text", value: String(expect) };
252
+ if (match[1].length > 1000) return { error: "EXPECT regex is longer than 1000 characters" };
253
+ try {
254
+ // Matching happens in a disposable worker so catastrophic backtracking
255
+ // cannot hang the checker.
256
+ new RegExp(match[1], match[2]);
257
+ } catch (error) {
258
+ return { error: "invalid EXPECT regex: " + error.message };
259
+ }
260
+ return {
261
+ kind: "regex",
262
+ source: match[1],
263
+ flags: match[2],
264
+ pathLike: UNESCAPED_SLASH_RE.test(match[1]),
265
+ };
266
+ }
267
+
268
+ // The checker and Stop hook both consume this exact result. Diagnostics are
269
+ // returned together so callers can report all malformed input in one pass.
270
+ export function parseGates(text, options = {}) {
271
+ const source = String(text);
272
+ const eol = source.includes("\r\n") ? "\r\n" : "\n";
273
+ const finalNewline = source.endsWith("\n");
274
+ const lines = source.split(/\r?\n/);
275
+ const gates = [];
276
+ const abandoned = new Map();
277
+ const owns = [];
278
+ const errors = [];
279
+ const warnings = [];
280
+ const ids = new Map();
281
+ const attrs = new Map();
282
+ let current = null;
283
+ let seenGate = false;
284
+ let fence = null;
285
+
286
+ for (let index = 0; index < lines.length; index++) {
287
+ const line = lines[index];
288
+ if (fence) {
289
+ const close = line.match(/^( {0,3})(`+|~+)[ \t]*$/);
290
+ if (close && close[2][0] === fence.character && close[2].length >= fence.length) fence = null;
291
+ continue;
292
+ }
293
+ const fenceMatch = line.match(FENCE_OPEN_RE);
294
+ if (fenceMatch && !(fenceMatch[2][0] === "`" && fenceMatch[3].includes("`"))) {
295
+ fence = { character: fenceMatch[2][0], length: fenceMatch[2].length };
296
+ continue;
297
+ }
298
+
299
+ const gateMatch = line.match(GATE_RE);
300
+ if (gateMatch) {
301
+ seenGate = true;
302
+ const rawTitle = gateMatch[2].trim();
303
+ const idMatch = rawTitle.match(/^(\S+?):(?:\s+|$)/);
304
+ const id = idMatch ? idMatch[1] : "L" + (index + 1);
305
+ const title = idMatch ? rawTitle.slice(idMatch[0].length).trim() : rawTitle;
306
+ current = {
307
+ line: index,
308
+ checked: gateMatch[1].toLowerCase() === "x",
309
+ id,
310
+ title,
311
+ check: null,
312
+ expect: null,
313
+ evidence: null,
314
+ evidenceLine: -1,
315
+ cwd: null,
316
+ };
317
+ gates.push(current);
318
+ attrs.set(current, new Set());
319
+ if (!idMatch) errors.push("line " + (index + 1) + ": gate needs an explicit ID followed by a colon");
320
+ else if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(id)) {
321
+ errors.push("line " + (index + 1) + ": invalid gate id " + id);
322
+ }
323
+ if (!title) errors.push("line " + (index + 1) + ": gate outcome is blank");
324
+ if (ids.has(id)) {
325
+ errors.push("line " + (index + 1) + ": duplicate gate id " + id +
326
+ " (first declared on line " + ids.get(id) + ")");
327
+ } else ids.set(id, index + 1);
328
+ continue;
329
+ }
330
+
331
+ // Attributes must be indented and ABANDON must not be, so the two rules
332
+ // point opposite ways. Diagnose the indented abandonment rather than
333
+ // ignoring it, or the author's honest exit fails with no explanation.
334
+ if (INDENTED_ABANDON_RE.test(line)) {
335
+ errors.push("line " + (index + 1) +
336
+ ": indented ABANDON is not applied; start ABANDON at column 1");
337
+ current = null;
338
+ continue;
339
+ }
340
+
341
+ const unindented = line.match(UNINDENTED_ATTR_RE);
342
+ if (unindented) {
343
+ errors.push("line " + (index + 1) + ": unindented " + unindented[1] +
344
+ " is not attached to a gate; indent attribute lines with spaces");
345
+ current = null;
346
+ continue;
347
+ }
348
+
349
+ const anyAttr = line.match(ATTR_RE);
350
+ if (anyAttr && !current) {
351
+ errors.push("line " + (index + 1) + ": orphan " + anyAttr[2] + " is not attached to a gate");
352
+ continue;
353
+ }
354
+ const attrMatch = current && anyAttr;
355
+ if (attrMatch) {
356
+ const key = attrMatch[2].toLowerCase();
357
+ const value = attrMatch[3].trim();
358
+ if (attrs.get(current).has(key)) {
359
+ errors.push("line " + (index + 1) + ": duplicate " + attrMatch[2] +
360
+ " for gate " + current.id);
361
+ }
362
+ attrs.get(current).add(key);
363
+ if (key === "evidence") {
364
+ current.evidence = value;
365
+ current.evidenceLine = index;
366
+ } else current[key] = value;
367
+ continue;
368
+ }
369
+
370
+ const abandonMatch = line.match(ABANDON_RE);
371
+ if (abandonMatch) {
372
+ const id = abandonMatch[1].replace(/:$/, "");
373
+ const reason = abandonMatch[2].trim();
374
+ if (!id) errors.push("line " + (index + 1) + ": ABANDON needs a gate id and reason");
375
+ else if (!reason) errors.push("line " + (index + 1) + ": ABANDON " + id + " needs a non-blank reason");
376
+ else if (abandoned.has(id)) errors.push("line " + (index + 1) + ": duplicate ABANDON for " + id);
377
+ else abandoned.set(id, reason);
378
+ current = null;
379
+ continue;
380
+ }
381
+
382
+ const ownsMatch = line.match(OWNS_RE);
383
+ if (ownsMatch) {
384
+ if (seenGate) {
385
+ errors.push("line " + (index + 1) + ": OWNS must appear before the first gate");
386
+ current = null;
387
+ continue;
388
+ }
389
+ const declared = ownsMatch[1].split(",").map((item) => item.trim()).filter(Boolean);
390
+ if (!declared.length) errors.push("line " + (index + 1) + ": OWNS declares no paths");
391
+ for (const item of declared) {
392
+ const normalized = normalizeOwnsGlob(item);
393
+ if (normalized.error) errors.push("line " + (index + 1) + ": " + normalized.error);
394
+ else owns.push(normalized.value);
395
+ }
396
+ continue;
397
+ }
398
+ if (/^#|^- /.test(line)) current = null;
399
+ }
400
+
401
+ if (fence) errors.push("unclosed fenced block");
402
+
403
+ for (const gate of gates) {
404
+ const hasCheck = gate.check !== null && gate.check !== "";
405
+ const hasExpect = gate.expect !== null && gate.expect !== "";
406
+ if (hasCheck !== hasExpect) {
407
+ errors.push("gate " + gate.id + ": runnable gates require both non-blank CHECK and EXPECT");
408
+ }
409
+ if (gate.check === "" || gate.expect === "") {
410
+ errors.push("gate " + gate.id + ": CHECK and EXPECT cannot be blank");
411
+ }
412
+ if (hasExpect) {
413
+ const parsed = parseRegex(gate.expect);
414
+ if (parsed.error) errors.push("gate " + gate.id + ": " + parsed.error);
415
+ else if (parsed.pathLike) {
416
+ // Warn rather than reject: the pattern reading may be intended, and a
417
+ // literal path cannot be expressed once the wrapping slashes sniff.
418
+ warnings.push("gate " + gate.id + ": EXPECT " + JSON.stringify(gate.expect) +
419
+ " is read as a regular expression, so its dots and other metacharacters" +
420
+ " are wildcards. Escape the inner slashes to keep the pattern, or drop" +
421
+ " the wrapping slashes to match a literal substring.");
422
+ }
423
+ gate.expectation = parsed;
424
+ } else gate.expectation = null;
425
+ }
426
+
427
+ for (const id of abandoned.keys()) {
428
+ if (!ids.has(id)) errors.push("ABANDON references unknown gate " + id);
429
+ }
430
+ if (options.requireGates !== false && gates.length === 0) errors.push("ledger contains zero live gates");
431
+
432
+ return { lines, eol, finalNewline, gates, abandoned, owns, errors, warnings };
433
+ }
434
+
435
+ export function formatDocument(doc) {
436
+ let output = doc.lines.join(doc.eol);
437
+ if (doc.finalNewline && !output.endsWith(doc.eol)) output += doc.eol;
438
+ return output;
439
+ }
440
+
441
+ export function qualify(fileOrLabel, id) {
442
+ return basename(String(fileOrLabel)).replace(/\.md$/i, "") + ":" + id;
443
+ }
444
+
445
+ export function gateDefinitionDigest(gate) {
446
+ if (!gate || typeof gate.check !== "string" || gate.check === "" ||
447
+ typeof gate.expect !== "string" || gate.expect === "") return null;
448
+ return sha256(JSON.stringify([
449
+ "unlazy.gate-definition",
450
+ 1,
451
+ gate.check,
452
+ gate.expect,
453
+ gate.cwd === null || gate.cwd === undefined ? null : String(gate.cwd),
454
+ ]));
455
+ }
456
+
457
+ export function automaticEvidencePrefix(definitionDigest) {
458
+ if (!/^[a-f0-9]{64}$/.test(String(definitionDigest || ""))) {
459
+ throw new Error("automatic evidence needs a full lowercase SHA-256 definition digest");
460
+ }
461
+ return "automatic-evidence=v1; definition-sha256=" + definitionDigest + ";";
462
+ }
463
+
464
+ export function classifyGateEvidence(gate) {
465
+ const evidence = gate && gate.evidence === null ? "" : String((gate && gate.evidence) || "");
466
+ if (evidence === "" || /^pending$/i.test(evidence)) return "pending";
467
+ const definitionDigest = gateDefinitionDigest(gate);
468
+ if (definitionDigest !== null) {
469
+ const prefix = automaticEvidencePrefix(definitionDigest);
470
+ const decidingFields = evidence.slice(prefix.length);
471
+ const success = decidingFields.match(
472
+ /^ exit=0; EXPECT=matched; output-sha256=[a-f0-9]{64}; output-bytes=(0|[1-9][0-9]{0,6}); shell=./,
473
+ );
474
+ if (evidence.length <= MAX_AUTOMATIC_EVIDENCE_CHARS && evidence.startsWith(prefix) &&
475
+ success && Number(success[1]) <= MAX_CHECK_OUTPUT_BYTES) {
476
+ return "automatic-current";
477
+ }
478
+ }
479
+ if (evidence.startsWith("automatic-evidence=") || evidence.startsWith("exit=0; shell=")) {
480
+ return "automatic-stale";
481
+ }
482
+ return "human";
483
+ }
484
+
485
+ export function gateState(gate, abandoned) {
486
+ if (abandoned.has(gate.id)) return "abandoned";
487
+ if (!gate.checked) return "unmet";
488
+ const evidence = classifyGateEvidence(gate);
489
+ const runnable = gateDefinitionDigest(gate) !== null;
490
+ if (runnable) return evidence === "automatic-current" ? "met" : "stale-unmet";
491
+ if (evidence === "pending") return "unmet-no-evidence";
492
+ if (evidence === "automatic-current" || evidence === "automatic-stale") return "stale-unmet";
493
+ return "met";
494
+ }
495
+
496
+ export function tail(output, max = 240) {
497
+ const lines = String(output).split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
498
+ return (lines.slice(-2).join(" | ") || "(no output)").slice(0, max);
499
+ }
500
+
501
+ export function validateScopeId(value, label = "scope") {
502
+ const id = String(value || "");
503
+ if (!SCOPE_RE.test(id) || id === "." || id === "..") {
504
+ return label + " must match " + SCOPE_RE + " and cannot be . or ..";
505
+ }
506
+ return null;
507
+ }
508
+
509
+ export function normalizeOwnsGlob(value) {
510
+ const raw = String(value || "").trim().replace(/\\/g, "/").replace(/^\.\//, "");
511
+ if (!raw) return { error: "OWNS path is blank" };
512
+ if (isAbsolute(raw) || /^[A-Za-z]:\//.test(raw) || raw.startsWith("//")) {
513
+ return { error: "OWNS path must be relative: " + value };
514
+ }
515
+ const parts = raw.split("/");
516
+ if (raw.includes("\0") || parts.some((part) => part === "..")) {
517
+ return { error: "OWNS path cannot contain traversal: " + value };
518
+ }
519
+ const normalized = parts.filter((part) => part !== "" && part !== ".").join("/");
520
+ if (!normalized || normalized === ".") return { error: "OWNS path cannot claim an implicit root" };
521
+ return { value: normalized };
522
+ }
523
+
524
+ export function literalPrefix(glob) {
525
+ const normalized = normalizeOwnsGlob(glob);
526
+ if (normalized.error) return "";
527
+ const literal = [];
528
+ for (const part of normalized.value.split("/")) {
529
+ if (/[*?[{]/.test(part)) break;
530
+ literal.push(part);
531
+ }
532
+ return literal.join("/");
533
+ }
534
+
535
+ // Prove disjointness only when literal path segments disagree. Everything else
536
+ // conflicts, including mid-segment pairs such as a* and ab*.
537
+ export function globsOverlap(left, right) {
538
+ const a = normalizeOwnsGlob(left);
539
+ const b = normalizeOwnsGlob(right);
540
+ if (a.error || b.error) return true;
541
+ const as = a.value.split("/");
542
+ const bs = b.value.split("/");
543
+ const count = Math.min(as.length, bs.length);
544
+ for (let index = 0; index < count; index++) {
545
+ const av = as[index], bv = bs[index];
546
+ if (/[*?[{]/.test(av) || /[*?[{]/.test(bv)) return true;
547
+ if (av !== bv) return false;
548
+ }
549
+ // An exact prefix may denote a directory ownership claim, so it can overlap
550
+ // every descendant. Treat common-prefix length differences as conflicts.
551
+ if (as.length !== bs.length) return true;
552
+ return true;
553
+ }
554
+
555
+ export function scopeRoot(root, scope) {
556
+ return join(root, UNLAZY_DIR, scope);
557
+ }
558
+
559
+ export function listScopes(root) {
560
+ const directory = join(root, UNLAZY_DIR);
561
+ if (!existsSync(directory)) return [];
562
+ try {
563
+ if (!realDirectoryInside(root, directory)) return [];
564
+ return readdirSync(directory, { withFileTypes: true })
565
+ .filter((entry) => entry.isDirectory() && !entry.isSymbolicLink() && entry.name !== "locks" &&
566
+ !validateScopeId(entry.name) && realDirectoryInside(root, join(directory, entry.name)))
567
+ .map((entry) => entry.name)
568
+ .sort();
569
+ } catch {
570
+ return [];
571
+ }
572
+ }
573
+
574
+ function markdownDiscovery(root, directory) {
575
+ if (!namedEntry(directory)) return { files: [], errors: [] };
576
+ try {
577
+ if (!realDirectoryInside(root, directory)) {
578
+ return { files: [], errors: ["gate directory must be a real directory inside the repository: " + directory] };
579
+ }
580
+ const files = readdirSync(directory, { withFileTypes: true })
581
+ // Include every named Markdown entry. Consumers perform the stable-file
582
+ // check, so a FIFO, link, or directory cannot disappear as "no gates".
583
+ .filter((entry) => entry.name.endsWith(".md"))
584
+ .map((entry) => join(directory, entry.name))
585
+ .sort();
586
+ return { files, errors: [] };
587
+ } catch (error) {
588
+ return { files: [], errors: ["cannot inspect gate directory " + directory + ": " + error.message] };
589
+ }
590
+ }
591
+
592
+ function scopeDiscovery(root, scope) {
593
+ const base = scopeRoot(root, scope);
594
+ if (!realDirectoryInside(root, base)) {
595
+ return { files: [], errors: ["scope directory must be a real directory inside the repository: " + base] };
596
+ }
597
+ const files = [];
598
+ const top = join(base, "GATES.md");
599
+ if (namedEntry(top)) files.push(top);
600
+ const nested = markdownDiscovery(root, join(base, "gates"));
601
+ files.push(...nested.files);
602
+ return { files, errors: nested.errors };
603
+ }
604
+
605
+ function legacyDiscovery(root) {
606
+ const files = [];
607
+ const top = join(root, "GATES.md");
608
+ if (namedEntry(top)) files.push(top);
609
+ const nested = markdownDiscovery(root, join(root, "gates"));
610
+ files.push(...nested.files);
611
+ return { files, errors: nested.errors };
612
+ }
613
+
614
+ export function scopeFiles(root, scope) { return scopeDiscovery(root, scope).files; }
615
+ export function legacyFiles(root) { return legacyDiscovery(root).files; }
616
+
617
+ function targetFromDiscovery(mode, scope, discovery) {
618
+ return { mode, scope, files: discovery.files, discoveryErrors: discovery.errors };
619
+ }
620
+
621
+ export function resolveTarget(options = {}) {
622
+ const root = resolve(options.root || process.cwd());
623
+ const files = options.files || [];
624
+ const sessionId = options.sessionId || null;
625
+ if (files.length) return { mode: "explicit", scope: null, files: files.map((file) => resolve(root, file)) };
626
+
627
+ const scopes = listScopes(root);
628
+ const wanted = options.scope || process.env.UNLAZY_SCOPE || null;
629
+ if (wanted) {
630
+ const invalid = validateScopeId(wanted);
631
+ if (invalid) return { mode: "none", scope: wanted, files: [], error: invalid };
632
+ if (!scopes.includes(wanted)) {
633
+ const scopePath = scopeRoot(root, wanted);
634
+ const statePath = join(root, UNLAZY_DIR);
635
+ // A named scope that is physically absent is safe to treat as stale
636
+ // configuration. A named entry that exists but was excluded from
637
+ // listScopes (link, file, FIFO, outside-root directory, or unreadable
638
+ // state container) must remain visible as an invalid input instead of
639
+ // becoming the same harmless "no such scope" result.
640
+ if (namedEntry(scopePath) || (namedEntry(statePath) && !realDirectoryInside(root, statePath))) {
641
+ return targetFromDiscovery("scope", wanted, scopeDiscovery(root, wanted));
642
+ }
643
+ return {
644
+ mode: "none", scope: wanted, files: [],
645
+ error: "no such scope \"" + wanted + "\" under " + UNLAZY_DIR + "/ (have: " +
646
+ (scopes.join(", ") || "none") + ")",
647
+ };
648
+ }
649
+ return targetFromDiscovery("scope", wanted, scopeDiscovery(root, wanted));
650
+ }
651
+
652
+ if (scopes.length === 1) return targetFromDiscovery("scope", scopes[0], scopeDiscovery(root, scopes[0]));
653
+ if (scopes.length > 1) {
654
+ if (sessionId) {
655
+ const owned = scopes.filter((scope) => {
656
+ try {
657
+ return readStableRegularFile(join(scopeRoot(root, scope), "session"), {
658
+ root, maxBytes: 4096, label: "session binding",
659
+ }).trim() === String(sessionId).trim();
660
+ } catch { return false; }
661
+ });
662
+ if (owned.length === 1) return targetFromDiscovery("scope", owned[0], scopeDiscovery(root, owned[0]));
663
+ }
664
+ return {
665
+ mode: "none", scope: null, files: [], ambiguous: scopes,
666
+ error: scopes.length + " pipelines present (" + scopes.join(", ") +
667
+ "); pass --scope <id> or set UNLAZY_SCOPE. Refusing to guess.",
668
+ };
669
+ }
670
+
671
+ const legacy = legacyDiscovery(root);
672
+ if (legacy.files.length || legacy.errors.length) return targetFromDiscovery("legacy", null, legacy);
673
+ return { mode: "none", scope: null, files: [] };
674
+ }
675
+
676
+ export function statusLogPath(root, scope) {
677
+ return scope ? join(scopeRoot(root, scope), "status.log") : join(root, "unlazy-status.log");
678
+ }
679
+
680
+ export function hookStatePath(root, scope) {
681
+ return scope ? join(scopeRoot(root, scope), "hook-state.json") : join(root, ".unlazy-hook-state.json");
682
+ }
683
+
684
+ function assertSafeStatePath(root, target) {
685
+ const stateRoot = join(resolve(root), UNLAZY_DIR);
686
+ if (existsSync(stateRoot)) {
687
+ const info = lstatSync(stateRoot);
688
+ if (info.isSymbolicLink() || !info.isDirectory()) throw new Error(stateRoot + " must be a real directory, not a link or file");
689
+ }
690
+ const parent = dirname(target);
691
+ mkdirSync(parent, { recursive: true, mode: 0o700 });
692
+ const info = lstatSync(parent);
693
+ if (info.isSymbolicLink() || !info.isDirectory()) throw new Error(parent + " must be a real directory");
694
+ }
695
+
696
+ export function writeAtomic(file, text, options = {}) {
697
+ const target = resolve(file);
698
+ if (options.root) assertSafeStatePath(options.root, target);
699
+ else {
700
+ const parent = dirname(target);
701
+ mkdirSync(parent, { recursive: true });
702
+ const info = lstatSync(parent);
703
+ if (info.isSymbolicLink() || !info.isDirectory()) throw new Error(parent + " must be a real directory");
704
+ }
705
+ try {
706
+ const existing = lstatSync(target);
707
+ if (existing.isSymbolicLink()) throw new Error("refusing to replace symlink " + target);
708
+ } catch (error) {
709
+ if (error.code !== "ENOENT") throw error;
710
+ }
711
+ let temp = "";
712
+ let fd = null;
713
+ for (let attempt = 0; attempt < 8; attempt++) {
714
+ temp = target + "." + process.pid + "." + randomBytes(8).toString("hex") + ".tmp";
715
+ try { fd = openSync(temp, "wx", 0o600); break; }
716
+ catch (error) { if (error.code !== "EEXIST") throw error; }
717
+ }
718
+ if (fd === null) throw new Error("could not create a unique temporary file for " + target);
719
+ try {
720
+ writeFileSync(fd, String(text), "utf8");
721
+ fsyncSync(fd);
722
+ closeSync(fd);
723
+ fd = null;
724
+ replaceAtomic(temp, target);
725
+ } finally {
726
+ if (fd !== null) try { closeSync(fd); } catch { /* ignore */ }
727
+ if (temp) try { unlinkSync(temp); } catch { /* renamed or absent */ }
728
+ }
729
+ }
730
+
731
+ function lockDirectory(root) {
732
+ // Use the physical root so lexical aliases of one repository share one lock
733
+ // namespace. The root itself must exist for any unlazy operation.
734
+ const canonicalRoot = realpathSync(resolve(root));
735
+ const directory = join(canonicalRoot, LOCK_DIR);
736
+ assertSafeStatePath(canonicalRoot, directory);
737
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
738
+ const info = lstatSync(directory);
739
+ if (info.isSymbolicLink() || !info.isDirectory()) throw new Error(directory + " must be a real directory");
740
+ return directory;
741
+ }
742
+
743
+ function canonicalLockTarget(target) {
744
+ // Lock targets are often not files yet (dispatch state, hook state, or the
745
+ // lease registry). Canonicalize the nearest existing ancestor and rebuild
746
+ // the missing suffix so real-root and symlink-root spellings hash alike.
747
+ // Never follow the final named component: atomic replacement or a rejected
748
+ // final symlink must not change which lock protects that path.
749
+ const absolute = resolve(target);
750
+ let current = dirname(absolute);
751
+ const suffix = [basename(absolute)];
752
+ for (;;) {
753
+ try {
754
+ const canonical = realpathSync(current);
755
+ return suffix.length ? resolve(canonical, ...suffix) : canonical;
756
+ } catch (error) {
757
+ if (!error || error.code !== "ENOENT") throw error;
758
+ const parent = dirname(current);
759
+ if (parent === current) throw error;
760
+ suffix.unshift(basename(current));
761
+ current = parent;
762
+ }
763
+ }
764
+ }
765
+
766
+ export async function withFileLock(root, target, fn, options = {}) {
767
+ const timeoutMs = options.timeoutMs === undefined ? 30000 : options.timeoutMs;
768
+ const directory = lockDirectory(root);
769
+ const lockTarget = canonicalLockTarget(target);
770
+ const lock = join(directory, sha256(lockTarget).slice(0, 24) + ".filelock");
771
+ const deadline = Date.now() + timeoutMs;
772
+ const token = randomBytes(16).toString("hex");
773
+ let fd = null;
774
+ for (;;) {
775
+ try { fd = openSync(lock, "wx", 0o600); break; }
776
+ catch (error) {
777
+ // On Windows, opening or inspecting an existing file held by another
778
+ // process can surface as EPERM/EACCES/EBUSY rather than EEXIST. Treat
779
+ // only those platform-specific sharing errors as lock contention.
780
+ if (error.code !== "EEXIST" && !isTransientWindowsFsError(error)) throw error;
781
+ // Never unlink a lock observed by path: between stat and unlink its
782
+ // prior owner can release and a successor can acquire the same name
783
+ // (the classic ABA race). Missing-after-EEXIST simply means retry. A
784
+ // crashed owner's lock fails closed at timeout and can be removed by a
785
+ // human after inspecting its JSON metadata.
786
+ let missing = false;
787
+ try { statSync(lock); } catch (statError) {
788
+ if (statError.code === "ENOENT") missing = true;
789
+ else if (!isTransientWindowsFsError(statError)) throw statError;
790
+ }
791
+ if (Date.now() >= deadline) {
792
+ throw new Error("timed out waiting for lock on " + target + " (last filesystem error: " + error.code + ")");
793
+ }
794
+ if (missing && error.code === "EEXIST") continue;
795
+ await sleep(15 + Math.floor(Math.random() * 25));
796
+ }
797
+ }
798
+ let identified = false;
799
+ try {
800
+ writeFileSync(fd, JSON.stringify({ token, pid: process.pid, target: lockTarget, at: Date.now() }));
801
+ identified = true;
802
+ } catch { /* leave for manual cleanup rather than risk deleting a successor */ }
803
+ try { return await fn(); }
804
+ finally {
805
+ try { closeSync(fd); } catch { /* ignore */ }
806
+ if (identified) {
807
+ const deadline = Date.now() + 2000;
808
+ let delay = 5;
809
+ for (;;) {
810
+ try {
811
+ const current = JSON.parse(readFileSync(lock, "utf8"));
812
+ if (current.token === token) unlinkSync(lock);
813
+ break;
814
+ } catch (error) {
815
+ if (error && error.code === "ENOENT") break;
816
+ const remaining = deadline - Date.now();
817
+ if (!isTransientWindowsFsError(error) || remaining <= 0) break;
818
+ await sleep(Math.min(delay, remaining));
819
+ delay = Math.min(delay * 2, 100);
820
+ }
821
+ }
822
+ }
823
+ }
824
+ }
825
+
826
+ export function appendStatus(root, scope, line) {
827
+ const path = statusLogPath(root, scope);
828
+ assertSafeStatePath(root, path);
829
+ let fd = null;
830
+ try {
831
+ try {
832
+ const before = lstatSync(path);
833
+ if (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1) {
834
+ throw new Error("refusing non-file or linked status log " + path);
835
+ }
836
+ } catch (error) {
837
+ if (error.code !== "ENOENT") throw error;
838
+ }
839
+ // Open first but write only after validating a named-entry snapshot against
840
+ // the descriptor. This rejects persistent links and ordinary replacements;
841
+ // SECURITY.md documents the concurrent Windows path-control boundary.
842
+ const noFollow = process.platform === "win32" ? 0 : (fsConstants.O_NOFOLLOW || 0);
843
+ fd = openSync(path, fsConstants.O_WRONLY | fsConstants.O_APPEND | fsConstants.O_CREAT |
844
+ (fsConstants.O_NONBLOCK || 0) | noFollow, 0o600);
845
+ const opened = fstatSync(fd, { bigint: true });
846
+ const named = statCurrentNamedFile(path, {
847
+ label: "status log",
848
+ openFlags: fsConstants.O_WRONLY | fsConstants.O_APPEND | (fsConstants.O_NONBLOCK || 0),
849
+ // Independent O_APPEND writers are valid; identity, link, and type are
850
+ // the append boundary, while concurrent size/mtime changes are expected.
851
+ stableSnapshot: false,
852
+ });
853
+ if (!opened.isFile() || opened.nlink !== 1n || !sameFileIdentity(opened, named)) {
854
+ throw new Error("refusing non-file or replaced status log " + path);
855
+ }
856
+ writeFileSync(fd, String(line).replace(/[\r\n]+/g, " ") + "\n", "utf8");
857
+ fsyncSync(fd);
858
+ return path;
859
+ } finally {
860
+ if (fd !== null) try { closeSync(fd); } catch { /* ignore */ }
861
+ }
862
+ }
863
+
864
+ function readLeasesUnlocked(root) {
865
+ const directory = join(resolve(root), LOCK_DIR);
866
+ try { lstatSync(directory); }
867
+ catch (error) {
868
+ if (error && error.code === "ENOENT") return [];
869
+ return [{ scope: "(invalid)", leaf: "locks", globs: ["**"], file: directory, invalid: true }];
870
+ }
871
+ if (!realDirectoryInside(root, directory)) {
872
+ return [{ scope: "(invalid)", leaf: "locks", globs: ["**"], file: directory, invalid: true }];
873
+ }
874
+ const leases = [];
875
+ for (const name of readdirSync(directory).sort()) {
876
+ if (!name.endsWith(".lease")) continue;
877
+ const file = join(directory, name);
878
+ try {
879
+ const value = JSON.parse(readStableRegularFile(file, {
880
+ root, maxBytes: LEASE_MAX_BYTES, label: "lease record",
881
+ }));
882
+ if (!value || typeof value.scope !== "string" || validateScopeId(value.scope) ||
883
+ typeof value.leaf !== "string" || validateScopeId(value.leaf, "leaf") ||
884
+ !Array.isArray(value.globs) || !value.globs.length ||
885
+ name !== sha256(value.scope + "::" + value.leaf).slice(0, 24) + ".lease") {
886
+ throw new Error("invalid lease record shape or identity");
887
+ }
888
+ const normalized = value.globs.map((glob) => normalizeOwnsGlob(glob));
889
+ if (normalized.some((item) => item.error) ||
890
+ normalized.some((item, index) => item.value !== value.globs[index])) {
891
+ throw new Error("invalid lease record OWNS paths");
892
+ }
893
+ leases.push({ ...value, globs: normalized.map((item) => item.value), file });
894
+ } catch {
895
+ leases.push({ scope: "(invalid)", leaf: name, globs: ["**"], file, invalid: true });
896
+ }
897
+ }
898
+ return leases;
899
+ }
900
+
901
+ export function readLeases(root) {
902
+ return readLeasesUnlocked(root);
903
+ }
904
+
905
+ const leaseRegistry = (root) => join(resolve(root), LOCK_DIR, "lease-registry");
906
+
907
+ export async function claimLeases(root, spec) {
908
+ return withFileLock(root, leaseRegistry(root), () => {
909
+ const scopeError = validateScopeId(spec.scope);
910
+ const leafError = validateScopeId(spec.leaf, "leaf");
911
+ if (scopeError || leafError) return { ok: false, conflicts: [], error: scopeError || leafError };
912
+ const normalized = [];
913
+ for (const glob of spec.globs || []) {
914
+ const result = normalizeOwnsGlob(glob);
915
+ if (result.error) return { ok: false, conflicts: [], error: result.error };
916
+ normalized.push(result.value);
917
+ }
918
+ if (!normalized.length) return { ok: false, conflicts: [], error: "no OWNS paths to claim" };
919
+
920
+ const heldLeases = readLeasesUnlocked(root);
921
+ const sameOwner = heldLeases.find((held) => held.scope === spec.scope && held.leaf === spec.leaf);
922
+ if (sameOwner) {
923
+ return {
924
+ ok: false,
925
+ conflicts: [{ identity: true, with: spec.scope + "/" + spec.leaf, heldGlobs: sameOwner.globs }],
926
+ };
927
+ }
928
+
929
+ const conflicts = [];
930
+ for (const glob of normalized) {
931
+ for (const held of heldLeases) {
932
+ const theirGlob = held.globs.find((other) => globsOverlap(glob, other));
933
+ if (theirGlob) conflicts.push({ glob, with: held.scope + "/" + held.leaf, theirGlob });
934
+ }
935
+ }
936
+ if (conflicts.length) return { ok: false, conflicts };
937
+ const file = join(lockDirectory(root), sha256(spec.scope + "::" + spec.leaf).slice(0, 24) + ".lease");
938
+ writeAtomic(file, JSON.stringify({ scope: spec.scope, leaf: spec.leaf, globs: normalized, pid: process.pid }, null, 2) + "\n", { root });
939
+ return { ok: true, file, conflicts: [], globs: normalized };
940
+ });
941
+ }
942
+
943
+ export async function releaseLeases(root, spec) {
944
+ return withFileLock(root, leaseRegistry(root), () => {
945
+ let count = 0;
946
+ for (const lease of readLeasesUnlocked(root)) {
947
+ if (lease.scope !== spec.scope) continue;
948
+ if (spec.leaf && lease.leaf !== spec.leaf) continue;
949
+ try { unlinkSync(lease.file); count++; } catch { /* raced or absent */ }
950
+ }
951
+ return count;
952
+ });
953
+ }