@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.
@@ -0,0 +1,164 @@
1
+ // worklease — git pre-commit hook adapter (the dogfood surface).
2
+ //
3
+ // This is what makes worklease actually prevent collisions in a live
4
+ // parallel-agent setup: a pre-commit hook that runs `worklease check` on the
5
+ // files a commit is about to change and surfaces any overlap with another
6
+ // agent's active claim. The git plumbing lives here; the coordination decision
7
+ // is delegated to the existing pure `check` core, so this adapter adds a new
8
+ // *surface*, not a second notion of "conflict".
9
+ //
10
+ // Design, matching roadmap principle #1 ("advisory, not enforcing"): the hook
11
+ // is warn-only by default (prints conflicts, exits 0, never blocks a commit).
12
+ // `worklease hook install --strict` bakes in blocking mode, where a conflict
13
+ // exits 1 and git aborts the commit. Both modes run the same `hook run` verb;
14
+ // the only difference is whether a conflict is fatal.
15
+ //
16
+ // Zero runtime dependencies: `git` is invoked via `child_process`, everything
17
+ // else reuses this package's own modules.
18
+
19
+ import { spawnSync } from "node:child_process";
20
+ import {
21
+ existsSync,
22
+ readFileSync,
23
+ writeFileSync,
24
+ chmodSync,
25
+ mkdirSync,
26
+ } from "node:fs";
27
+ import { dirname, join, isAbsolute } from "node:path";
28
+ import { loadRegistry, defaultRegistryPath } from "../registry.js";
29
+ import { check } from "../check.js";
30
+
31
+ // Markers delimiting worklease's managed region inside `.git/hooks/pre-commit`.
32
+ // Install rewrites only the text *between* (and including) these lines, so any
33
+ // pre-existing hook body a user or another tool wrote is preserved verbatim.
34
+ const START = "# >>> worklease >>>";
35
+ const END = "# <<< worklease <<<";
36
+
37
+ // --- staged-path check (the check-on-staged-paths function) ----------------
38
+
39
+ // stagedPaths({ cwd }) → the repo-relative paths staged for the pending commit.
40
+ //
41
+ // These are the "files about to change". A staged path is already a concrete
42
+ // path, and every concrete path is a valid glob in the committed subset (a
43
+ // literal), so the paths feed straight into `check` — `check("src/auth/x.ts",
44
+ // …)` overlaps a claim on `src/auth/**`. No path→glob translation is needed.
45
+ // A non-git directory (or any git failure) yields [] — the caller then treats
46
+ // the commit as clear, since an advisory hook must never wedge a commit.
47
+ export function stagedPaths(opts = {}) {
48
+ const { cwd = process.cwd() } = opts;
49
+ const r = spawnSync("git", ["diff", "--cached", "--name-only"], {
50
+ cwd,
51
+ encoding: "utf8",
52
+ });
53
+ if (r.status !== 0 || typeof r.stdout !== "string") return [];
54
+ return r.stdout
55
+ .split("\n")
56
+ .map((l) => l.trim())
57
+ .filter(Boolean);
58
+ }
59
+
60
+ // checkStagedPaths(paths, { registry, agent, now }) → { clear, conflicts, notes }
61
+ //
62
+ // The core the hook runs: load the registry once and run the shared `check`
63
+ // core over the staged paths. Empty input short-circuits to clear (an empty or
64
+ // docs-only commit can never collide). `notes` carries loadRegistry's
65
+ // skipped/tampered/expired warnings so the caller can surface them — a dropped
66
+ // line means `check` might call a held path clear, the exact collision this
67
+ // tool exists to catch.
68
+ export function checkStagedPaths(paths, opts = {}) {
69
+ const { registry = null, agent = null, now = Date.now() } = opts;
70
+ if (!paths || paths.length === 0) {
71
+ return { clear: true, conflicts: [], notes: [] };
72
+ }
73
+ const path = registry || defaultRegistryPath();
74
+ const { claims, notes } = loadRegistry(path, { now });
75
+ const result = check(paths, claims, { agent, now });
76
+ return { clear: result.clear, conflicts: result.conflicts, notes };
77
+ }
78
+
79
+ // --- hook install ----------------------------------------------------------
80
+
81
+ // gitPath(cwd, rel) → the absolute path of a file inside the git dir, or null
82
+ // if cwd is not a git repo. Uses `git rev-parse --git-path` so it resolves
83
+ // correctly for worktrees (where hooks live in the shared common dir) — the
84
+ // exact setup worklease targets, since fleets run in parallel worktrees.
85
+ function gitPath(cwd, rel) {
86
+ const r = spawnSync("git", ["rev-parse", "--git-path", rel], {
87
+ cwd,
88
+ encoding: "utf8",
89
+ });
90
+ if (r.status !== 0 || typeof r.stdout !== "string") return null;
91
+ const p = r.stdout.trim();
92
+ if (!p) return null;
93
+ return isAbsolute(p) ? p : join(cwd, p);
94
+ }
95
+
96
+ // hookPath(cwd) → absolute path of this repo's pre-commit hook, or null if cwd
97
+ // is not a git repository.
98
+ export function hookPath(cwd = process.cwd()) {
99
+ return gitPath(cwd, "hooks/pre-commit");
100
+ }
101
+
102
+ // renderHookBlock(strict) → the marker-delimited shell block install writes.
103
+ //
104
+ // The block delegates to `worklease hook run`, guarded by `command -v` so a
105
+ // clone where `worklease` isn't on PATH degrades gracefully (no hook error,
106
+ // consistent with advisory-first). The strict/warn policy lives entirely in
107
+ // `hook run`; install just chooses whether to pass `--strict`, keeping the
108
+ // exit-code decision in one place.
109
+ export function renderHookBlock(strict = false) {
110
+ return [
111
+ START,
112
+ "# Managed by `worklease hook install` — runs `worklease check` on staged files.",
113
+ strict
114
+ ? "# Mode: strict — a claim conflict blocks the commit (exit 1)."
115
+ : "# Mode: advisory — conflicts are printed but never block the commit.",
116
+ "if command -v worklease >/dev/null 2>&1; then",
117
+ strict ? " worklease hook run --strict" : " worklease hook run",
118
+ "fi",
119
+ END,
120
+ ].join("\n");
121
+ }
122
+
123
+ // installHook({ cwd, strict }) → { path, action, strict }
124
+ //
125
+ // Idempotent, existing-hook-preserving install:
126
+ // - no pre-commit hook yet → create one (shebang + block) ["created"]
127
+ // - a hook with our markers → replace only the marked block ["updated"]
128
+ // - a hook without our markers → append the block after it ["appended"]
129
+ // Re-running install therefore converges to a single managed block and never
130
+ // duplicates it or clobbers a hand-written hook. The file is chmod +x so git
131
+ // will execute it.
132
+ export function installHook(opts = {}) {
133
+ const { cwd = process.cwd(), strict = false } = opts;
134
+ const path = hookPath(cwd);
135
+ if (!path) {
136
+ throw new Error("not a git repository (run `git init` first)");
137
+ }
138
+
139
+ const block = renderHookBlock(strict);
140
+ let content;
141
+ let action;
142
+
143
+ if (!existsSync(path)) {
144
+ content = `#!/bin/sh\n${block}\n`;
145
+ action = "created";
146
+ } else {
147
+ const existing = readFileSync(path, "utf8");
148
+ const s = existing.indexOf(START);
149
+ const e = existing.indexOf(END);
150
+ if (s !== -1 && e !== -1 && e > s) {
151
+ content = existing.slice(0, s) + block + existing.slice(e + END.length);
152
+ action = "updated";
153
+ } else {
154
+ const sep = existing.endsWith("\n") ? "" : "\n";
155
+ content = `${existing}${sep}\n${block}\n`;
156
+ action = "appended";
157
+ }
158
+ }
159
+
160
+ mkdirSync(dirname(path), { recursive: true });
161
+ writeFileSync(path, content);
162
+ chmodSync(path, 0o755);
163
+ return { path, action, strict };
164
+ }
package/src/check.js ADDED
@@ -0,0 +1,43 @@
1
+ // worklease — `check` core (pure orchestration over `globsOverlap`).
2
+ //
3
+ // Given planned-edit globs and a resolved registry array, report whether the
4
+ // plan overlaps any *active* claim held by *another* agent. No filesystem, no
5
+ // clock: time is injected as `opts.now` so expiry is deterministic in tests.
6
+
7
+ import { globsOverlap } from "./glob.js";
8
+
9
+ // check(plannedGlobs, registry, opts) → { clear, conflicts }
10
+ //
11
+ // opts.agent — the caller's id; a claim by this agent counts as clear (you may
12
+ // edit what you already hold). If null/undefined, no same-agent
13
+ // filtering is applied (every active claim is treated as another
14
+ // agent's — the safe default).
15
+ // opts.now — epoch ms used to evaluate `expires`; injected for determinism.
16
+ //
17
+ // A claim is a conflict iff ALL of: status === "active", its `expires` is still
18
+ // in the future, its `agent` differs from the caller, and at least one of its
19
+ // globs intersects a planned glob. Each conflict's `overlapping_globs` is the
20
+ // deduped, sorted subset of THAT claim's globs which overlap the plan.
21
+ export function check(plannedGlobs, registry, opts = {}) {
22
+ const { agent = null, now = Date.now() } = opts;
23
+
24
+ const conflicts = [];
25
+ for (const claim of registry) {
26
+ if (claim.status !== "active") continue;
27
+ if (!(Date.parse(claim.expires) > now)) continue; // expired-by-time = clear
28
+ if (agent != null && claim.agent === agent) continue; // own claim = clear
29
+
30
+ const overlapping = claim.globs.filter((g) =>
31
+ plannedGlobs.some((p) => globsOverlap(p, g))
32
+ );
33
+ if (overlapping.length) {
34
+ conflicts.push({ claim, overlapping_globs: dedupeSort(overlapping) });
35
+ }
36
+ }
37
+
38
+ return { clear: conflicts.length === 0, conflicts };
39
+ }
40
+
41
+ function dedupeSort(globs) {
42
+ return [...new Set(globs)].sort();
43
+ }
package/src/claim.js ADDED
@@ -0,0 +1,70 @@
1
+ // worklease — `claim` core (pure claim constructor + ttl parsing).
2
+ //
3
+ // `makeClaim` builds a valid claim record with a deterministic content-hash id
4
+ // and a computed `expires`, with NO I/O and NO clock — `created` is injected via
5
+ // `meta`, so the result is fully determined by its inputs and unit-testable. The
6
+ // CLI (`bin/worklease.js`) is the only part that reads the clock and appends to
7
+ // the registry.
8
+
9
+ import { computeRecordId } from "./registry.js";
10
+
11
+ // makeClaim(globs, meta) → a claim record matching #1's CLAIM_FIELDS order.
12
+ //
13
+ // meta.agent — who is filing the claim
14
+ // meta.intent — why (what work the globs are for)
15
+ // meta.ttl_seconds — lease length in whole seconds (integer)
16
+ // meta.created — ISO-8601-UTC timestamp the claim is filed at
17
+ //
18
+ // `expires` is `created + ttl_seconds` to the millisecond, so #1's
19
+ // EXPIRES_MISMATCH cross-check holds by construction. `id` is the registry's
20
+ // shared content hash of the whole record (its `id` excluded), so a claim's id
21
+ // IS its content hash and it resolves cleanly through #4's store — one hasher
22
+ // across every record type. `status` is always "active" on creation. Pure and
23
+ // total: it does not throw and does no validation — the CLI validates the
24
+ // finished record via #1's `validateClaim`.
25
+ export function makeClaim(globs, meta = {}) {
26
+ const { agent, intent, ttl_seconds, created } = meta;
27
+ const expires = isoAddSeconds(created, ttl_seconds);
28
+ const record = {
29
+ agent,
30
+ globs,
31
+ intent,
32
+ ttl_seconds,
33
+ created,
34
+ expires,
35
+ status: "active",
36
+ };
37
+ return { id: computeRecordId(record), ...record };
38
+ }
39
+
40
+ // parseTtl(input) → integer seconds, or null on anything invalid.
41
+ //
42
+ // Accepts a compact duration shorthand (`<n>s`, `<n>m`, `<n>h`) or a bare
43
+ // positive integer interpreted as seconds (string or number). A bad unit, a
44
+ // non-integer, zero, a negative, or empty input returns null (never throws) so
45
+ // the caller can print one clear error and exit.
46
+ export function parseTtl(input) {
47
+ if (typeof input === "number") {
48
+ return Number.isInteger(input) && input > 0 ? input : null;
49
+ }
50
+ if (typeof input !== "string") return null;
51
+
52
+ const s = input.trim();
53
+ if (/^\d+$/.test(s)) {
54
+ const n = Number(s);
55
+ return n > 0 ? n : null; // bare seconds; "0" → null
56
+ }
57
+
58
+ const m = /^(\d+)(s|m|h)$/.exec(s);
59
+ if (!m) return null;
60
+ const n = Number(m[1]);
61
+ if (n <= 0) return null; // "0m" → null
62
+ const mult = { s: 1, m: 60, h: 3600 }[m[2]];
63
+ return n * mult;
64
+ }
65
+
66
+ // created + ttl_seconds as an ISO-8601-UTC string. With whole-second `created`
67
+ // and integer `ttl_seconds`, epoch-ms equality with #1's derived value holds.
68
+ function isoAddSeconds(created, ttl_seconds) {
69
+ return new Date(Date.parse(created) + ttl_seconds * 1000).toISOString();
70
+ }
@@ -0,0 +1,82 @@
1
+ // worklease — `conformance` core (pure orchestration over `globsOverlap`).
2
+ //
3
+ // The after-the-fact metric: given a resolved registry and a set of *merges*
4
+ // (the files each agent actually touched), score whether the fleet coordinated.
5
+ // For each `(agent, file)` change it asks two questions — did the acting agent
6
+ // hold a claim covering the file, and did the file fall under a *different*
7
+ // agent's live claim? — and partitions every change into exactly one of
8
+ // {respected, violation, warning}. No filesystem, no clock: all time comparisons
9
+ // use the merge record's own `at` (or fall back to claim `status`), so the core
10
+ // is pure and deterministic.
11
+ //
12
+ // A touched file is a concrete (wildcard-free) glob, so "does file F fall under
13
+ // claim glob G?" is exactly `globsOverlap(F, G)` from #3 — no new matching logic.
14
+
15
+ import { globsOverlap } from "./glob.js";
16
+ import { isIso8601Utc } from "./schema.js";
17
+
18
+ // Is claim `c` held/active at instant `at` (ISO string)? Temporal window
19
+ // [created, expires): created ≤ at < expires. Malformed timestamps are treated
20
+ // as "not held/active" (conservative — never invents coverage or a violation).
21
+ function within(c, at) {
22
+ if (!isIso8601Utc(at) || !isIso8601Utc(c.created) || !isIso8601Utc(c.expires)) {
23
+ return false;
24
+ }
25
+ const t = Date.parse(at);
26
+ return Date.parse(c.created) <= t && t < Date.parse(c.expires);
27
+ }
28
+
29
+ // Status fallback for coverage when a change has no `at`: any non-expired claim.
30
+ function notExpired(c) {
31
+ return c.status !== "expired";
32
+ }
33
+
34
+ // conformance(claims, merges, opts) → { score, total, respected, violations, warnings }
35
+ //
36
+ // claims — resolved registry array (latest record per id), each claim
37
+ // `{ id, agent, globs[], intent, ttl_seconds, created, expires, status }`.
38
+ // merges — array of `{ agent, files: string[], at? }`; flattened to one change
39
+ // per `(agent, file)`.
40
+ // opts — reserved; no clock needed (this is an after-the-fact audit).
41
+ //
42
+ // A change by agent A on file F at time T is:
43
+ // - covered — A held a matching claim at T (temporal `within` if `at`,
44
+ // else non-expired status).
45
+ // - violation — a DIFFERENT agent B held a matching claim live at T (temporal
46
+ // if `at`, else `status === "active"`); one entry per such claim.
47
+ // - respected — covered ∧ not a violation (the coordination numerator).
48
+ // - warning — uncovered ∧ not a violation (edited an unclaimed file).
49
+ // A colliding change is a violation even if A also held a claim (double-claim).
50
+ export function conformance(claims, merges, opts = {}) {
51
+ const changes = merges.flatMap((m) =>
52
+ m.files.map((file) => ({ agent: m.agent, file, at: m.at }))
53
+ );
54
+
55
+ const violations = [];
56
+ const warnings = [];
57
+ let respected = 0;
58
+
59
+ for (const { agent, file, at } of changes) {
60
+ const matching = claims.filter((c) => c.globs.some((g) => globsOverlap(file, g)));
61
+
62
+ const held = (c) => (at != null ? within(c, at) : notExpired(c));
63
+ const live = (c) => (at != null ? within(c, at) : c.status === "active");
64
+
65
+ const covered = matching.some((c) => c.agent === agent && held(c));
66
+ const colliding = matching.filter((c) => c.agent !== agent && live(c));
67
+
68
+ if (colliding.length) {
69
+ for (const c of colliding) {
70
+ violations.push({ agent, file, conflicting_claim: c });
71
+ }
72
+ } else if (covered) {
73
+ respected += 1;
74
+ } else {
75
+ warnings.push({ agent, file });
76
+ }
77
+ }
78
+
79
+ const total = changes.length;
80
+ const score = total === 0 ? 1 : respected / total;
81
+ return { score, total, respected, violations, warnings };
82
+ }
package/src/glob.js ADDED
@@ -0,0 +1,92 @@
1
+ // worklease — glob intersection (pure, zero-dependency, no filesystem access).
2
+ //
3
+ // Decides whether two globs from the committed subset (`**`, `*`, `/`, literals)
4
+ // could match a common concrete path. This is the conservative *satisfiability*
5
+ // rule used by `check`: two globs overlap iff there EXISTS at least one concrete
6
+ // path that matches both — evaluated purely over the glob strings, never the
7
+ // working tree, so it is safe for a check that runs *before* the edit (the file
8
+ // may not exist yet). Symmetric by construction: overlap(a, b) === overlap(b, a).
9
+ //
10
+ // The problem is pattern-vs-pattern satisfiability at two levels — across
11
+ // segments (`**` spans zero or more whole segments) and within a segment (`*`
12
+ // matches any run of characters but never crosses `/`). Each level is a memoized
13
+ // two-pointer recursion, bounding work to O(m·n) so stacked `**` cannot blow up.
14
+
15
+ // Normalize a glob string into an array of segment tokens.
16
+ // - strip a single leading "./"
17
+ // - collapse repeated "/" into one
18
+ // - drop a single trailing "/" (treat `src/auth/` as `src/auth`)
19
+ // - split on "/"
20
+ // - within each non-"**" segment, collapse any run of "*" to a single "*"
21
+ // (so `a**b` → `a*b`); "**" keeps its cross-segment meaning only when it is
22
+ // the entire segment.
23
+ function normalize(glob) {
24
+ let g = glob;
25
+ if (g.startsWith("./")) g = g.slice(2);
26
+ g = g.replace(/\/+/g, "/");
27
+ if (g.length > 1 && g.endsWith("/")) g = g.slice(0, -1);
28
+ return g
29
+ .split("/")
30
+ .map((seg) => (seg === "**" ? "**" : seg.replace(/\*+/g, "*")));
31
+ }
32
+
33
+ // Within-segment overlap: do single-segment patterns `a` and `b` generate a
34
+ // common string? `*` matches any run of characters (including empty); every
35
+ // other character is a case-sensitive literal. Memoized two-pointer over chars.
36
+ function segmentOverlap(a, b) {
37
+ const memo = new Map();
38
+ const seg = (i, j) => {
39
+ if (i === a.length && j === b.length) return true;
40
+ const key = i * (b.length + 1) + j;
41
+ if (memo.has(key)) return memo.get(key);
42
+ let res;
43
+ if (i < a.length && a[i] === "*") {
44
+ // `*` consumes zero chars of `b`, or one-or-more (advance `b`).
45
+ res = seg(i + 1, j) || (j < b.length && seg(i, j + 1));
46
+ } else if (j < b.length && b[j] === "*") {
47
+ res = seg(i, j + 1) || (i < a.length && seg(i + 1, j));
48
+ } else if (i === a.length || j === b.length) {
49
+ res = false; // one side ran out with a literal still pending
50
+ } else if (a[i] === b[j]) {
51
+ res = seg(i + 1, j + 1);
52
+ } else {
53
+ res = false;
54
+ }
55
+ memo.set(key, res);
56
+ return res;
57
+ };
58
+ return seg(0, 0);
59
+ }
60
+
61
+ // Segment-level overlap: `A`, `B` are segment-token arrays. A token is either
62
+ // the literal "**" (matches zero or more whole segments) or a single-segment
63
+ // pattern. Memoized on (i, j) so "**"-vs-"**" stays O(|A|·|B|).
64
+ function segmentsOverlap(A, B) {
65
+ const memo = new Map();
66
+ const overlap = (i, j) => {
67
+ if (i === A.length && j === B.length) return true;
68
+ const key = i * (B.length + 1) + j;
69
+ if (memo.has(key)) return memo.get(key);
70
+ let res;
71
+ if (i < A.length && A[i] === "**") {
72
+ // "**" matches zero segments, or one-or-more (consume a B segment).
73
+ res = overlap(i + 1, j) || (j < B.length && overlap(i, j + 1));
74
+ } else if (j < B.length && B[j] === "**") {
75
+ res = overlap(i, j + 1) || (i < A.length && overlap(i + 1, j));
76
+ } else if (i === A.length || j === B.length) {
77
+ res = false; // one side ran out with a real segment still pending
78
+ } else if (!segmentOverlap(A[i], B[j])) {
79
+ res = false; // this pair of segments can't align
80
+ } else {
81
+ res = overlap(i + 1, j + 1); // aligned — advance both
82
+ }
83
+ memo.set(key, res);
84
+ return res;
85
+ };
86
+ return overlap(0, 0);
87
+ }
88
+
89
+ // Public API: do two globs share at least one concrete matching path?
90
+ export function globsOverlap(globA, globB) {
91
+ return segmentsOverlap(normalize(globA), normalize(globB));
92
+ }
package/src/index.js ADDED
@@ -0,0 +1,39 @@
1
+ // worklease — public entry point (package `main`).
2
+ // Re-exports the pure schema/validator API.
3
+ // Re-exports the pure `check` / glob-overlap API.
4
+ // Re-exports the pure `conformance` after-the-fact coordination-score API.
5
+ // Re-exports the pure `makeClaim` / `parseTtl` claim-constructor API.
6
+ // Re-exports the append-only registry store (`loadRegistry`, `appendRecord`, …).
7
+ // Re-exports the git pre-commit adapter (`checkStagedPaths`, `installHook`, …).
8
+
9
+ export {
10
+ validateClaim,
11
+ validateRegistry,
12
+ isIso8601Utc,
13
+ isAllowedGlob,
14
+ STATUSES,
15
+ CLAIM_FIELDS,
16
+ ERROR_CODES,
17
+ } from "./schema.js";
18
+ export { globsOverlap } from "./glob.js";
19
+ export { check } from "./check.js";
20
+ export { conformance } from "./conformance.js";
21
+ export { makeClaim, parseTtl } from "./claim.js";
22
+ export {
23
+ canonicalize,
24
+ computeRecordId,
25
+ resolveRecords,
26
+ loadRegistry,
27
+ appendRecord,
28
+ defaultRegistryPath,
29
+ listActive,
30
+ formatRelative,
31
+ shortId,
32
+ } from "./registry.js";
33
+ export {
34
+ stagedPaths,
35
+ checkStagedPaths,
36
+ hookPath,
37
+ renderHookBlock,
38
+ installHook,
39
+ } from "./adapters/git-hook.js";