@avee1234/worklease 0.1.1
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.
- package/LICENSE +21 -0
- package/README.md +186 -0
- package/bin/worklease.js +671 -0
- package/package.json +39 -0
- package/src/adapters/git-hook.js +164 -0
- package/src/check.js +43 -0
- package/src/claim.js +70 -0
- package/src/conformance.js +82 -0
- package/src/glob.js +92 -0
- package/src/index.js +39 -0
- package/src/registry.js +233 -0
- package/src/schema.js +230 -0
package/bin/worklease.js
ADDED
|
@@ -0,0 +1,671 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// worklease CLI.
|
|
3
|
+
//
|
|
4
|
+
// Dispatches to subcommands:
|
|
5
|
+
// - `validate <file>`: validate a claim or registry file.
|
|
6
|
+
// - `check <globs...>`: check for overlap with active claims.
|
|
7
|
+
// - `claim <globs...>`: file a claim (append to the registry).
|
|
8
|
+
// - `list`: show active claims (who holds what, expiring when).
|
|
9
|
+
// - `release <id>`: drop a claim by appending a release record.
|
|
10
|
+
// - `conformance <registry> <merges>`: score whether merges respected claims.
|
|
11
|
+
// - `hook install|run`: git pre-commit adapter — auto-`check` staged files.
|
|
12
|
+
|
|
13
|
+
import { readFileSync } from "node:fs";
|
|
14
|
+
import { validateClaim, validateRegistry } from "../src/schema.js";
|
|
15
|
+
import { check } from "../src/check.js";
|
|
16
|
+
import { conformance } from "../src/conformance.js";
|
|
17
|
+
import { stagedPaths, checkStagedPaths, installHook } from "../src/adapters/git-hook.js";
|
|
18
|
+
import { makeClaim, parseTtl } from "../src/claim.js";
|
|
19
|
+
import {
|
|
20
|
+
loadRegistry,
|
|
21
|
+
appendRecord,
|
|
22
|
+
defaultRegistryPath,
|
|
23
|
+
formatRelative,
|
|
24
|
+
shortId,
|
|
25
|
+
} from "../src/registry.js";
|
|
26
|
+
|
|
27
|
+
const USAGE = `worklease — coordination format for fleets of AI coding agents
|
|
28
|
+
|
|
29
|
+
Usage:
|
|
30
|
+
worklease validate <file> [--json] Validate a claim or registry JSON file
|
|
31
|
+
worklease check <globs...> [--agent <id>] [--registry <path>] [--json]
|
|
32
|
+
Report whether the planned edit globs overlap any active claim held by
|
|
33
|
+
another agent. Exit 0 = clear, 1 = conflict.
|
|
34
|
+
worklease claim <globs...> --intent "<why>" [--ttl <dur>] [--agent <id>]
|
|
35
|
+
[--registry <path>] [--json]
|
|
36
|
+
File a claim for the globs and append it to the registry. Exit 0 on write.
|
|
37
|
+
worklease list [--all] [--verbose] [--agent <id>] [--registry <path>] [--json]
|
|
38
|
+
Show active claims: who holds what, expiring when. --all also shows
|
|
39
|
+
released/expired claims labeled with their effective status. --all or
|
|
40
|
+
--verbose also warn (to stderr) about skipped/tampered/expired lines.
|
|
41
|
+
worklease release <id> [--agent <id>] [--registry <path>] [--json]
|
|
42
|
+
Drop a claim (full id or unambiguous prefix) by appending a release
|
|
43
|
+
record. No-op with a note if it is already released/expired.
|
|
44
|
+
worklease conformance <registry> <merges> [--json]
|
|
45
|
+
Score whether merged changes respected the claims. Reads the registry and
|
|
46
|
+
a merges file (each agent's touched files) and reports a coordination
|
|
47
|
+
score, respected/total, violations, and warnings. Exit 0 = no violations,
|
|
48
|
+
1 = at least one violation.
|
|
49
|
+
worklease hook install [--strict] [--registry <path>]
|
|
50
|
+
Install a git pre-commit hook that runs \`worklease check\` on staged
|
|
51
|
+
files. Advisory by default (prints conflicts, never blocks); --strict
|
|
52
|
+
blocks the commit on a conflict. Idempotent; preserves an existing hook.
|
|
53
|
+
worklease hook run [--strict] [--agent <id>] [--registry <path>] [--json]
|
|
54
|
+
What the hook runs: check the staged files for overlap with another
|
|
55
|
+
agent's active claim. Exit 0 by default even on conflict; --strict exits 1
|
|
56
|
+
on conflict so git aborts the commit.
|
|
57
|
+
|
|
58
|
+
Flags:
|
|
59
|
+
--intent <str> why you're claiming (required for \`claim\`)
|
|
60
|
+
--ttl <dur> lease length: <n>s|m|h or bare seconds (claim; default 30m)
|
|
61
|
+
--all include released/expired claims (list)
|
|
62
|
+
--verbose warn about skipped/tampered/expired lines to stderr (list)
|
|
63
|
+
--strict block the commit on a conflict (hook install / hook run)
|
|
64
|
+
--agent <id> identify "me" (env WORKLEASE_AGENT); own claims are clear
|
|
65
|
+
--registry <path> registry file (default: env WORKLEASE_REGISTRY or
|
|
66
|
+
.worklease/registry.jsonl)
|
|
67
|
+
--json emit machine-readable output for the active command`;
|
|
68
|
+
|
|
69
|
+
function fail(message) {
|
|
70
|
+
process.stderr.write(`${message}\n`);
|
|
71
|
+
process.exit(1);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Surface loadRegistry/resolveRecords `notes` (skipped/tampered/expired lines)
|
|
75
|
+
// to stderr as warnings. A dropped line is a corrupt/stale/tampered claim, and
|
|
76
|
+
// PRODUCT.md decision 3 makes the warning the mitigation: without it a dropped
|
|
77
|
+
// claim silently vanishes and two agents can both be told a path is clear.
|
|
78
|
+
function warnNotes(notes) {
|
|
79
|
+
for (const note of notes) process.stderr.write(`warning: ${note}\n`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// `validate` subcommand implementation
|
|
83
|
+
function runValidate(args) {
|
|
84
|
+
const json = args.includes("--json");
|
|
85
|
+
const file = args.find((a) => a !== "--json");
|
|
86
|
+
|
|
87
|
+
if (!file) {
|
|
88
|
+
fail("error: `validate` requires a file argument\n\n" + USAGE);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
let raw;
|
|
93
|
+
try {
|
|
94
|
+
raw = readFileSync(file, "utf8");
|
|
95
|
+
} catch {
|
|
96
|
+
fail(`error: cannot read file: ${file}`);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
let parsed;
|
|
101
|
+
try {
|
|
102
|
+
parsed = JSON.parse(raw);
|
|
103
|
+
} catch (e) {
|
|
104
|
+
fail(`error: ${file} is not valid JSON: ${e.message}`);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const isArray = Array.isArray(parsed);
|
|
109
|
+
const result = isArray ? validateRegistry(parsed) : validateClaim(parsed);
|
|
110
|
+
const kind = isArray ? "registry" : "claim";
|
|
111
|
+
|
|
112
|
+
if (json) {
|
|
113
|
+
process.stdout.write(JSON.stringify(result) + "\n");
|
|
114
|
+
} else if (result.valid) {
|
|
115
|
+
process.stdout.write(`✓ ${file}: valid ${kind}\n`);
|
|
116
|
+
} else {
|
|
117
|
+
process.stdout.write(`✗ ${file}: invalid ${kind} (${result.errors.length} error${result.errors.length === 1 ? "" : "s"})\n`);
|
|
118
|
+
for (const e of result.errors) {
|
|
119
|
+
const at = e.path === "" ? "<root>" : e.path;
|
|
120
|
+
process.stdout.write(` ${at}: ${e.message} [${e.code}]\n`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
process.exit(result.valid ? 0 : 1);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
// `check` subcommand implementation
|
|
129
|
+
function parseCheckArgs(args) {
|
|
130
|
+
const globs = [];
|
|
131
|
+
let agent = process.env.WORKLEASE_AGENT || null;
|
|
132
|
+
let registry = null;
|
|
133
|
+
let json = false;
|
|
134
|
+
|
|
135
|
+
for (let i = 0; i < args.length; i++) {
|
|
136
|
+
const a = args[i];
|
|
137
|
+
if (a === "--json") {
|
|
138
|
+
json = true;
|
|
139
|
+
} else if (a === "--agent") {
|
|
140
|
+
agent = args[++i];
|
|
141
|
+
if (agent == null) fail("error: --agent requires a value\n\n" + USAGE);
|
|
142
|
+
} else if (a === "--registry") {
|
|
143
|
+
registry = args[++i];
|
|
144
|
+
if (registry == null) fail("error: --registry requires a value\n\n" + USAGE);
|
|
145
|
+
} else if (a.startsWith("--")) {
|
|
146
|
+
fail(`error: unknown flag: ${a}\n\n` + USAGE);
|
|
147
|
+
} else {
|
|
148
|
+
globs.push(a);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return { globs, agent, registry, json };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function runCheck(args) {
|
|
155
|
+
const { globs, agent, registry, json } = parseCheckArgs(args);
|
|
156
|
+
|
|
157
|
+
if (globs.length === 0) {
|
|
158
|
+
fail("error: `check` requires one or more globs\n\n" + USAGE);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const path = registry || defaultRegistryPath();
|
|
163
|
+
const now = Date.now();
|
|
164
|
+
const { claims, notes } = loadRegistry(path, { now });
|
|
165
|
+
// Always warn: a claim dropped here means `check` may report `clear` for a
|
|
166
|
+
// path another agent actually holds — the exact collision worklease prevents.
|
|
167
|
+
warnNotes(notes);
|
|
168
|
+
const result = check(globs, claims, { agent, now });
|
|
169
|
+
|
|
170
|
+
if (json) {
|
|
171
|
+
process.stdout.write(JSON.stringify(result) + "\n");
|
|
172
|
+
} else if (result.clear) {
|
|
173
|
+
process.stdout.write("clear ✓ — no overlap with active claims\n");
|
|
174
|
+
} else {
|
|
175
|
+
const n = result.conflicts.length;
|
|
176
|
+
process.stdout.write(
|
|
177
|
+
`⚠ conflict — planned edit overlaps ${n} active claim${n === 1 ? "" : "s"}:\n`
|
|
178
|
+
);
|
|
179
|
+
for (const { claim, overlapping_globs } of result.conflicts) {
|
|
180
|
+
process.stdout.write(
|
|
181
|
+
` ${claim.agent} holds ${overlapping_globs.join(", ")} — ` +
|
|
182
|
+
`"${claim.intent}" (expires ${claim.expires})\n`
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
process.exit(result.clear ? 0 : 1);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// `claim` subcommand implementation
|
|
191
|
+
function parseClaimArgs(args) {
|
|
192
|
+
const globs = [];
|
|
193
|
+
let intent = null;
|
|
194
|
+
let ttl = null;
|
|
195
|
+
let agent = process.env.WORKLEASE_AGENT || null;
|
|
196
|
+
let registry = null;
|
|
197
|
+
let json = false;
|
|
198
|
+
|
|
199
|
+
for (let i = 0; i < args.length; i++) {
|
|
200
|
+
const a = args[i];
|
|
201
|
+
if (a === "--json") {
|
|
202
|
+
json = true;
|
|
203
|
+
} else if (a === "--intent") {
|
|
204
|
+
intent = args[++i];
|
|
205
|
+
if (intent == null) fail("error: --intent requires a value\n\n" + USAGE);
|
|
206
|
+
} else if (a === "--ttl") {
|
|
207
|
+
ttl = args[++i];
|
|
208
|
+
if (ttl == null) fail("error: --ttl requires a value\n\n" + USAGE);
|
|
209
|
+
} else if (a === "--agent") {
|
|
210
|
+
agent = args[++i];
|
|
211
|
+
if (agent == null) fail("error: --agent requires a value\n\n" + USAGE);
|
|
212
|
+
} else if (a === "--registry") {
|
|
213
|
+
registry = args[++i];
|
|
214
|
+
if (registry == null) fail("error: --registry requires a value\n\n" + USAGE);
|
|
215
|
+
} else if (a.startsWith("--")) {
|
|
216
|
+
fail(`error: unknown flag: ${a}\n\n` + USAGE);
|
|
217
|
+
} else {
|
|
218
|
+
globs.push(a);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return { globs, intent, ttl, agent, registry, json };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function runClaim(args) {
|
|
225
|
+
const { globs, intent, ttl, agent, registry, json } = parseClaimArgs(args);
|
|
226
|
+
|
|
227
|
+
if (globs.length === 0) {
|
|
228
|
+
fail("error: `claim` requires one or more globs\n\n" + USAGE);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
if (intent == null || intent.trim().length === 0) {
|
|
232
|
+
fail("error: `claim` requires a non-empty --intent\n\n" + USAGE);
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
if (agent == null || agent.trim().length === 0) {
|
|
236
|
+
fail("error: `claim` requires --agent (or the WORKLEASE_AGENT env var)\n\n" + USAGE);
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
const ttl_seconds = ttl == null ? 1800 : parseTtl(ttl);
|
|
240
|
+
if (ttl_seconds == null) {
|
|
241
|
+
fail(`error: invalid --ttl: ${ttl} (use <n>s|m|h or a positive integer of seconds)`);
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// The clock is read only here; makeClaim stays pure over the injected `created`.
|
|
246
|
+
const created = new Date(Math.floor(Date.now() / 1000) * 1000).toISOString();
|
|
247
|
+
const claim = makeClaim(globs, { agent, intent, ttl_seconds, created });
|
|
248
|
+
|
|
249
|
+
// Validate the finished record via #1's validator; gate the write on it so an
|
|
250
|
+
// unsupported glob (or any other defect) is rejected rather than written.
|
|
251
|
+
const result = validateClaim(claim);
|
|
252
|
+
if (!result.valid) {
|
|
253
|
+
process.stdout.write(
|
|
254
|
+
`✗ cannot file claim (${result.errors.length} error${result.errors.length === 1 ? "" : "s"}):\n`
|
|
255
|
+
);
|
|
256
|
+
for (const e of result.errors) {
|
|
257
|
+
const at = e.path === "" ? "<root>" : e.path;
|
|
258
|
+
process.stdout.write(` ${at}: ${e.message} [${e.code}]\n`);
|
|
259
|
+
}
|
|
260
|
+
process.exit(1);
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const path = registry || defaultRegistryPath();
|
|
265
|
+
// Append-only via the shared store: one whole JSON line, existing lines never
|
|
266
|
+
// rewritten, parent dir created if missing. The claim already carries its
|
|
267
|
+
// content-hash id, so appendRecord writes it verbatim.
|
|
268
|
+
appendRecord(path, claim);
|
|
269
|
+
|
|
270
|
+
if (json) {
|
|
271
|
+
process.stdout.write(JSON.stringify(claim) + "\n");
|
|
272
|
+
} else {
|
|
273
|
+
process.stdout.write(
|
|
274
|
+
`filed ${claim.id} — ${claim.agent} holds ${claim.globs.join(", ")} — ` +
|
|
275
|
+
`"${claim.intent}" (expires ${claim.expires})\n`
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
process.exit(0);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// `list` subcommand implementation
|
|
282
|
+
function parseListArgs(args) {
|
|
283
|
+
let all = false;
|
|
284
|
+
let verbose = false;
|
|
285
|
+
let agent = null;
|
|
286
|
+
let registry = null;
|
|
287
|
+
let json = false;
|
|
288
|
+
|
|
289
|
+
for (let i = 0; i < args.length; i++) {
|
|
290
|
+
const a = args[i];
|
|
291
|
+
if (a === "--json") {
|
|
292
|
+
json = true;
|
|
293
|
+
} else if (a === "--all") {
|
|
294
|
+
all = true;
|
|
295
|
+
} else if (a === "--verbose") {
|
|
296
|
+
verbose = true;
|
|
297
|
+
} else if (a === "--agent") {
|
|
298
|
+
agent = args[++i];
|
|
299
|
+
if (agent == null) fail("error: --agent requires a value\n\n" + USAGE);
|
|
300
|
+
} else if (a === "--registry") {
|
|
301
|
+
registry = args[++i];
|
|
302
|
+
if (registry == null) fail("error: --registry requires a value\n\n" + USAGE);
|
|
303
|
+
} else if (a.startsWith("--")) {
|
|
304
|
+
fail(`error: unknown flag: ${a}\n\n` + USAGE);
|
|
305
|
+
} else {
|
|
306
|
+
fail(`error: \`list\` takes no positional arguments (got: ${a})\n\n` + USAGE);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
return { all, verbose, agent, registry, json };
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function runList(args) {
|
|
313
|
+
const { all, verbose, agent, registry, json } = parseListArgs(args);
|
|
314
|
+
|
|
315
|
+
const path = registry || defaultRegistryPath();
|
|
316
|
+
const now = Date.now();
|
|
317
|
+
const { claims, notes } = loadRegistry(path, { now });
|
|
318
|
+
// Surface skipped/tampered/expired notes to stderr under --all or --verbose,
|
|
319
|
+
// so a dropped (corrupt/stale) claim is visible rather than silently gone.
|
|
320
|
+
if (all || verbose) warnNotes(notes);
|
|
321
|
+
|
|
322
|
+
let rows = all ? claims : claims.filter((c) => c.status === "active");
|
|
323
|
+
if (agent != null) rows = rows.filter((c) => c.agent === agent);
|
|
324
|
+
|
|
325
|
+
if (json) {
|
|
326
|
+
process.stdout.write(JSON.stringify(rows) + "\n");
|
|
327
|
+
process.exit(0);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
if (rows.length === 0) {
|
|
331
|
+
process.stdout.write("no active claims\n");
|
|
332
|
+
process.exit(0);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
for (const c of rows) {
|
|
336
|
+
// Active rows show the relative expiry; released/expired rows show the label.
|
|
337
|
+
const when = c.status === "active" ? `expires ${formatRelative(c.expires, now)}` : c.status;
|
|
338
|
+
process.stdout.write(
|
|
339
|
+
`${c.agent} ${c.globs.join(", ")} "${c.intent}" ${when} ${shortId(c.id)}\n`
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
process.exit(0);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// `release` subcommand implementation
|
|
346
|
+
function parseReleaseArgs(args) {
|
|
347
|
+
let id = null;
|
|
348
|
+
let agent = process.env.WORKLEASE_AGENT || null;
|
|
349
|
+
let registry = null;
|
|
350
|
+
let json = false;
|
|
351
|
+
|
|
352
|
+
for (let i = 0; i < args.length; i++) {
|
|
353
|
+
const a = args[i];
|
|
354
|
+
if (a === "--json") {
|
|
355
|
+
json = true;
|
|
356
|
+
} else if (a === "--agent") {
|
|
357
|
+
agent = args[++i];
|
|
358
|
+
if (agent == null) fail("error: --agent requires a value\n\n" + USAGE);
|
|
359
|
+
} else if (a === "--registry") {
|
|
360
|
+
registry = args[++i];
|
|
361
|
+
if (registry == null) fail("error: --registry requires a value\n\n" + USAGE);
|
|
362
|
+
} else if (a.startsWith("--")) {
|
|
363
|
+
fail(`error: unknown flag: ${a}\n\n` + USAGE);
|
|
364
|
+
} else if (id == null) {
|
|
365
|
+
id = a;
|
|
366
|
+
} else {
|
|
367
|
+
fail(`error: \`release\` takes a single <id> (extra: ${a})\n\n` + USAGE);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
return { id, agent, registry, json };
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function runRelease(args) {
|
|
374
|
+
const { id, agent, registry, json } = parseReleaseArgs(args);
|
|
375
|
+
|
|
376
|
+
if (id == null || id.trim().length === 0) {
|
|
377
|
+
fail("error: `release` requires a claim <id>\n\n" + USAGE);
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const path = registry || defaultRegistryPath();
|
|
382
|
+
const now = Date.now();
|
|
383
|
+
const { claims } = loadRegistry(path, { now });
|
|
384
|
+
|
|
385
|
+
// Resolve the target: exact id first, else a unique id prefix.
|
|
386
|
+
let target = claims.find((c) => c.id === id);
|
|
387
|
+
if (!target) {
|
|
388
|
+
const matches = claims.filter((c) => c.id.startsWith(id));
|
|
389
|
+
if (matches.length > 1) {
|
|
390
|
+
fail(`error: ambiguous id prefix "${id}" matches ${matches.length} claims`);
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
target = matches[0];
|
|
394
|
+
}
|
|
395
|
+
if (!target) {
|
|
396
|
+
fail(`error: no claim with id "${id}"`);
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// Already inactive → the desired end state already holds; note and stop.
|
|
401
|
+
if (target.status === "released" || target.status === "expired") {
|
|
402
|
+
const note =
|
|
403
|
+
target.status === "released"
|
|
404
|
+
? `already released — nothing to do (${shortId(target.id)})`
|
|
405
|
+
: `already expired — nothing to do (${shortId(target.id)})`;
|
|
406
|
+
process.stdout.write(note + "\n");
|
|
407
|
+
process.exit(0);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const releaser = agent != null && agent.trim().length > 0 ? agent : "unknown";
|
|
411
|
+
const release = appendRecord(path, {
|
|
412
|
+
type: "release",
|
|
413
|
+
claim_id: target.id,
|
|
414
|
+
agent: releaser,
|
|
415
|
+
at: new Date(now).toISOString(),
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
if (json) {
|
|
419
|
+
process.stdout.write(JSON.stringify(release) + "\n");
|
|
420
|
+
} else {
|
|
421
|
+
process.stdout.write(`released ${shortId(target.id)} (held by ${target.agent})\n`);
|
|
422
|
+
}
|
|
423
|
+
process.exit(0);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// `conformance` subcommand implementation
|
|
427
|
+
//
|
|
428
|
+
// Load a merges file: if it parses as a JSON array, use it; otherwise treat it
|
|
429
|
+
// as JSONL (one merge record per non-empty line). A missing file → [] (total 0).
|
|
430
|
+
// A malformed JSON array or unparseable line throws for the caller to report.
|
|
431
|
+
function loadMerges(path) {
|
|
432
|
+
let raw;
|
|
433
|
+
try {
|
|
434
|
+
raw = readFileSync(path, "utf8");
|
|
435
|
+
} catch {
|
|
436
|
+
return []; // missing file → no changes
|
|
437
|
+
}
|
|
438
|
+
const trimmed = raw.trim();
|
|
439
|
+
if (trimmed === "") return [];
|
|
440
|
+
if (trimmed.startsWith("[")) return JSON.parse(trimmed);
|
|
441
|
+
return raw
|
|
442
|
+
.split("\n")
|
|
443
|
+
.map((l) => l.trim())
|
|
444
|
+
.filter((l) => l)
|
|
445
|
+
.map((l) => JSON.parse(l));
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function parseConformanceArgs(args) {
|
|
449
|
+
const positional = [];
|
|
450
|
+
let json = false;
|
|
451
|
+
for (const a of args) {
|
|
452
|
+
if (a === "--json") {
|
|
453
|
+
json = true;
|
|
454
|
+
} else if (a.startsWith("--")) {
|
|
455
|
+
fail(`error: unknown flag: ${a}\n\n` + USAGE);
|
|
456
|
+
} else {
|
|
457
|
+
positional.push(a);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
return { positional, json };
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function runConformance(args) {
|
|
464
|
+
const { positional, json } = parseConformanceArgs(args);
|
|
465
|
+
const [registryPath, mergesPath] = positional;
|
|
466
|
+
|
|
467
|
+
if (!registryPath || !mergesPath) {
|
|
468
|
+
fail("error: `conformance` requires <registry> and <merges> file arguments\n\n" + USAGE);
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// Reuse the same registry loader `check` uses so the two verbs never disagree
|
|
473
|
+
// on resolution; a missing registry file resolves to an empty claim set. But
|
|
474
|
+
// load WITHOUT wall-clock TTL expiry (`expire: false`): conformance is an
|
|
475
|
+
// after-the-fact audit that runs once short TTLs have elapsed, and it reasons
|
|
476
|
+
// about time itself via each merge's `at`. Applying `Date.now()` decay here
|
|
477
|
+
// would collapse every stored claim to `expired`, and the no-`at` status
|
|
478
|
+
// fallback would then miss real cross-agent collisions (reporting them as
|
|
479
|
+
// warnings). Resolving to the stored active/released state keeps the audit
|
|
480
|
+
// correct for timestamp-less merges.
|
|
481
|
+
const { claims } = loadRegistry(registryPath, { expire: false });
|
|
482
|
+
|
|
483
|
+
let merges;
|
|
484
|
+
try {
|
|
485
|
+
merges = loadMerges(mergesPath);
|
|
486
|
+
} catch (e) {
|
|
487
|
+
fail(`error: ${mergesPath} is not valid merges JSON: ${e.message}`);
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
const result = conformance(claims, merges, {});
|
|
492
|
+
|
|
493
|
+
if (json) {
|
|
494
|
+
process.stdout.write(JSON.stringify(result) + "\n");
|
|
495
|
+
} else {
|
|
496
|
+
process.stdout.write(
|
|
497
|
+
`coordination score ${result.score.toFixed(2)} — ` +
|
|
498
|
+
`${result.respected}/${result.total} change${result.total === 1 ? "" : "s"} respected\n`
|
|
499
|
+
);
|
|
500
|
+
for (const { agent, file, conflicting_claim: c } of result.violations) {
|
|
501
|
+
process.stdout.write(
|
|
502
|
+
` ✗ ${agent} edited ${file} under ${c.agent}'s claim — ` +
|
|
503
|
+
`"${c.intent}" (active ${c.created}–${c.expires})\n`
|
|
504
|
+
);
|
|
505
|
+
}
|
|
506
|
+
for (const { agent, file } of result.warnings) {
|
|
507
|
+
process.stdout.write(` • ${agent} edited ${file} (unclaimed)\n`);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
process.exit(result.violations.length === 0 ? 0 : 1);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// `hook` subcommand implementation
|
|
515
|
+
//
|
|
516
|
+
// A small verb group over the git pre-commit adapter:
|
|
517
|
+
// hook install [--strict] [--registry] — write/refresh the pre-commit hook.
|
|
518
|
+
// hook run [--strict] [--agent] [--registry] [--json] — what the hook runs.
|
|
519
|
+
function parseHookArgs(args, { positional = false } = {}) {
|
|
520
|
+
let strict = false;
|
|
521
|
+
let agent = process.env.WORKLEASE_AGENT || null;
|
|
522
|
+
let registry = null;
|
|
523
|
+
let json = false;
|
|
524
|
+
const rest = [];
|
|
525
|
+
|
|
526
|
+
for (let i = 0; i < args.length; i++) {
|
|
527
|
+
const a = args[i];
|
|
528
|
+
if (a === "--json") {
|
|
529
|
+
json = true;
|
|
530
|
+
} else if (a === "--strict") {
|
|
531
|
+
strict = true;
|
|
532
|
+
} else if (a === "--agent") {
|
|
533
|
+
agent = args[++i];
|
|
534
|
+
if (agent == null) fail("error: --agent requires a value\n\n" + USAGE);
|
|
535
|
+
} else if (a === "--registry") {
|
|
536
|
+
registry = args[++i];
|
|
537
|
+
if (registry == null) fail("error: --registry requires a value\n\n" + USAGE);
|
|
538
|
+
} else if (a.startsWith("--")) {
|
|
539
|
+
fail(`error: unknown flag: ${a}\n\n` + USAGE);
|
|
540
|
+
} else if (positional) {
|
|
541
|
+
rest.push(a);
|
|
542
|
+
} else {
|
|
543
|
+
fail(`error: unexpected argument: ${a}\n\n` + USAGE);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
return { strict, agent, registry, json, rest };
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function runHookInstall(args) {
|
|
550
|
+
const { strict, registry } = parseHookArgs(args);
|
|
551
|
+
|
|
552
|
+
let result;
|
|
553
|
+
try {
|
|
554
|
+
result = installHook({ strict });
|
|
555
|
+
} catch (e) {
|
|
556
|
+
fail(`error: ${e.message}`);
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
const mode = strict ? "strict (blocks on conflict)" : "advisory (warn-only)";
|
|
561
|
+
process.stdout.write(
|
|
562
|
+
`${result.action} pre-commit hook at ${result.path} — ${mode}\n`
|
|
563
|
+
);
|
|
564
|
+
// The hook runs `worklease check`, which resolves the registry the same way
|
|
565
|
+
// every other verb does; note when a non-default registry was requested so
|
|
566
|
+
// the operator sets WORKLEASE_REGISTRY for the hook's environment too.
|
|
567
|
+
if (registry) {
|
|
568
|
+
process.stdout.write(
|
|
569
|
+
`note: set WORKLEASE_REGISTRY=${registry} in the hook's environment to check that registry\n`
|
|
570
|
+
);
|
|
571
|
+
}
|
|
572
|
+
process.exit(0);
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function runHookRun(args) {
|
|
576
|
+
const { strict, agent, registry, json } = parseHookArgs(args);
|
|
577
|
+
|
|
578
|
+
const paths = stagedPaths();
|
|
579
|
+
const now = Date.now();
|
|
580
|
+
const { clear, conflicts, notes } = checkStagedPaths(paths, {
|
|
581
|
+
registry,
|
|
582
|
+
agent,
|
|
583
|
+
now,
|
|
584
|
+
});
|
|
585
|
+
// Same as `check`: a dropped registry line could hide a real hold, so always
|
|
586
|
+
// surface it — the warning is the only mitigation.
|
|
587
|
+
warnNotes(notes);
|
|
588
|
+
|
|
589
|
+
if (json) {
|
|
590
|
+
process.stdout.write(JSON.stringify({ clear, conflicts }) + "\n");
|
|
591
|
+
} else if (clear) {
|
|
592
|
+
process.stdout.write("clear ✓ — staged files overlap no active claim\n");
|
|
593
|
+
} else {
|
|
594
|
+
const n = conflicts.length;
|
|
595
|
+
process.stdout.write(
|
|
596
|
+
`⚠ conflict — staged files overlap ${n} active claim${n === 1 ? "" : "s"}:\n`
|
|
597
|
+
);
|
|
598
|
+
for (const { claim, overlapping_globs } of conflicts) {
|
|
599
|
+
process.stdout.write(
|
|
600
|
+
` ${claim.agent} holds ${overlapping_globs.join(", ")} — ` +
|
|
601
|
+
`"${claim.intent}" (expires ${claim.expires})\n`
|
|
602
|
+
);
|
|
603
|
+
}
|
|
604
|
+
if (!strict) {
|
|
605
|
+
process.stdout.write(
|
|
606
|
+
" (advisory: commit not blocked — install with --strict to block)\n"
|
|
607
|
+
);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// Advisory by default: exit 0 even on conflict so git never aborts the commit.
|
|
612
|
+
// --strict makes a conflict fatal (exit 1), which aborts the commit.
|
|
613
|
+
process.exit(strict && !clear ? 1 : 0);
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
function runHook(args) {
|
|
617
|
+
const sub = args[0];
|
|
618
|
+
if (sub === "install") {
|
|
619
|
+
runHookInstall(args.slice(1));
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
if (sub === "run") {
|
|
623
|
+
runHookRun(args.slice(1));
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
if (sub == null) {
|
|
627
|
+
fail("error: `hook` requires a subcommand: install | run\n\n" + USAGE);
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
fail(`error: unknown hook subcommand: ${sub} (expected install | run)\n\n` + USAGE);
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// Main router
|
|
634
|
+
function main(argv) {
|
|
635
|
+
const args = argv.slice(2);
|
|
636
|
+
const command = args[0];
|
|
637
|
+
|
|
638
|
+
if (command === "check") {
|
|
639
|
+
runCheck(args.slice(1));
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
if (command === "claim") {
|
|
643
|
+
runClaim(args.slice(1));
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
if (command === "list") {
|
|
647
|
+
runList(args.slice(1));
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
if (command === "release") {
|
|
651
|
+
runRelease(args.slice(1));
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
654
|
+
if (command === "conformance") {
|
|
655
|
+
runConformance(args.slice(1));
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
if (command === "hook") {
|
|
659
|
+
runHook(args.slice(1));
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
if (command === "validate") {
|
|
663
|
+
runValidate(args.slice(1));
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// Unknown / missing subcommand → usage on stderr, exit 1.
|
|
668
|
+
fail(USAGE);
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
main(process.argv);
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@avee1234/worklease",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "The open coordination format for fleets of AI coding agents. Agents declare intent to edit file globs before they start, so parallel agents don't duplicate work or collide on hotspot files. Zero dependencies, harness-neutral, git-backed.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"worklease": "bin/worklease.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"bin",
|
|
12
|
+
"src",
|
|
13
|
+
"README.md",
|
|
14
|
+
"LICENSE"
|
|
15
|
+
],
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=18"
|
|
18
|
+
},
|
|
19
|
+
"scripts": {
|
|
20
|
+
"test": "node --test"
|
|
21
|
+
},
|
|
22
|
+
"keywords": [
|
|
23
|
+
"ai",
|
|
24
|
+
"agent",
|
|
25
|
+
"multi-agent",
|
|
26
|
+
"coordination",
|
|
27
|
+
"parallel",
|
|
28
|
+
"coding-agents",
|
|
29
|
+
"git-worktree",
|
|
30
|
+
"file-lease",
|
|
31
|
+
"fleet",
|
|
32
|
+
"claude-code",
|
|
33
|
+
"codex",
|
|
34
|
+
"cursor",
|
|
35
|
+
"conflict-prevention",
|
|
36
|
+
"zero-dependency"
|
|
37
|
+
],
|
|
38
|
+
"license": "MIT"
|
|
39
|
+
}
|