@rallycry/conveyor-skills 1.0.11 → 1.0.13
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/README.md +28 -0
- package/bin/checkout-claim.mjs +524 -0
- package/bin/cli.mjs +11 -1
- package/package.json +5 -1
- package/skills/conveyor-build/SKILL.md +34 -10
- package/skills/conveyor-build/references/checkout-claim.md +98 -0
- package/skills/conveyor-build/references/pack-path.md +20 -3
- package/skills/conveyor-build/references/task-path.md +15 -5
- package/skills/conveyor-local-loop/SKILL.md +41 -18
- package/skills/conveyor-release-review/SKILL.md +399 -0
- package/skills/conveyor-release-review/references/dimensions.md +295 -0
- package/skills/conveyor-release-review/references/fan-out.md +130 -0
- package/skills/conveyor-release-review/references/report-and-cards.md +183 -0
- package/skills/conveyor-review/SKILL.md +6 -0
- package/skills/conveyor-workflows/SKILL.md +8 -0
package/README.md
CHANGED
|
@@ -17,6 +17,7 @@ normal dependency bumps: no git submodules, no manual syncing.
|
|
|
17
17
|
| `conveyor-start` | `/conveyor-start <card>` | Hand a planned card to a cloud pod, confirm the environment came up, report where to watch it (local surface only) |
|
|
18
18
|
| `conveyor-build` | `/conveyor-build <card>` | Execute a planned card to a PR — one task or a whole feature-branch pack |
|
|
19
19
|
| `conveyor-review` | `/conveyor-review <card>` | Review the PR against its plan and render one verdict, with risk |
|
|
20
|
+
| `conveyor-release-review` | `/conveyor-release-review [release]` | Audit everything in a pending release, keep only findings backed by evidence that survive an adversarial pass, and file them by priority as a blocker pack and a suggestions pack (local surface only) |
|
|
20
21
|
| `conveyor-consensus` | `/conveyor-consensus <question>` | Sweep the sources a project actually has, count what happened, score the proposals against those counts, and attach an HTML verdict to the card |
|
|
21
22
|
| `conveyor-local-loop` | `/loop /conveyor-local-loop` | Run this machine as a serial local agent: claim Open cards and packs, build to PR, repeat |
|
|
22
23
|
| `conveyor-prune` | `/conveyor-prune [project\|all]` | Sweep every Planning/Open card, give each one disposition (cancel, park on hold, merge into a pack, keep), apply only the confirmed rows (local surface only) |
|
|
@@ -79,6 +80,33 @@ and safe:
|
|
|
79
80
|
- `conveyor-skills link --check` verifies the links without changing anything
|
|
80
81
|
(exit 1 when out of date) — useful in CI.
|
|
81
82
|
|
|
83
|
+
## Taking turns on one checkout
|
|
84
|
+
|
|
85
|
+
Local agent sessions that share one git checkout take turns on it.
|
|
86
|
+
`/conveyor-build`, `/conveyor-local-loop` and local `/conveyor-review` fixes
|
|
87
|
+
hold a **checkout claim** while they use the working tree; a second session
|
|
88
|
+
that finds it held says so and waits instead of switching branches underneath
|
|
89
|
+
the first. Cloud pods have their own checkout and skip this.
|
|
90
|
+
|
|
91
|
+
The claim is one file in the git dir (`.git/conveyor-checkout-claim.json`, or
|
|
92
|
+
the per-worktree git dir), so it is never committed and never shows in
|
|
93
|
+
`git status`. The skills drive it through the same bin:
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
conveyor-skills checkout acquire --card <slug> [--branch <b>] # take / refresh
|
|
97
|
+
conveyor-skills checkout verify --card <slug> # still mine? (heartbeat)
|
|
98
|
+
conveyor-skills checkout release --card <slug> # give it back
|
|
99
|
+
conveyor-skills checkout status # who holds it
|
|
100
|
+
conveyor-skills checkout wait --timeout 1740 # block until free
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Each command prints one JSON line. A claim goes stale when its holder's
|
|
104
|
+
process is dead, when the same process started a new session, or when its
|
|
105
|
+
heartbeat is older than two hours (`CONVEYOR_CHECKOUT_CLAIM_TTL_SECONDS`).
|
|
106
|
+
`conveyor-skills checkout release --force` is the human override for a wedged
|
|
107
|
+
claim. The full protocol lives in
|
|
108
|
+
[`skills/conveyor-build/references/checkout-claim.md`](skills/conveyor-build/references/checkout-claim.md).
|
|
109
|
+
|
|
82
110
|
## Versioning
|
|
83
111
|
|
|
84
112
|
Versions are cut automatically from git tags on the Conveyor repo's `main`
|
|
@@ -0,0 +1,524 @@
|
|
|
1
|
+
// conveyor-skills checkout — make local agent sessions that share one git
|
|
2
|
+
// checkout take turns on it. A session holds the claim while it uses the
|
|
3
|
+
// working tree; any other session that finds the claim held waits instead of
|
|
4
|
+
// switching branches underneath the holder.
|
|
5
|
+
//
|
|
6
|
+
// The claim is one JSON file in the per-worktree git dir
|
|
7
|
+
// (`git rev-parse --absolute-git-dir`), so it is never committed, never shows
|
|
8
|
+
// in `git status`, survives branch switches, and linked worktrees get
|
|
9
|
+
// independent claims. Pods have their own checkout and never call this.
|
|
10
|
+
//
|
|
11
|
+
// Every command prints exactly ONE JSON line on stdout; hints go to stderr.
|
|
12
|
+
// Node built-ins only — this ships in the published package next to cli.mjs.
|
|
13
|
+
|
|
14
|
+
import { spawnSync } from "node:child_process";
|
|
15
|
+
import {
|
|
16
|
+
mkdirSync,
|
|
17
|
+
readFileSync,
|
|
18
|
+
renameSync,
|
|
19
|
+
rmSync,
|
|
20
|
+
statSync,
|
|
21
|
+
unlinkSync,
|
|
22
|
+
writeFileSync,
|
|
23
|
+
} from "node:fs";
|
|
24
|
+
import { hostname as osHostname } from "node:os";
|
|
25
|
+
import { join } from "node:path";
|
|
26
|
+
import process from "node:process";
|
|
27
|
+
|
|
28
|
+
export const CLAIM_FILE = "conveyor-checkout-claim.json";
|
|
29
|
+
export const MUTEX_DIR = "conveyor-checkout-claim.mutex";
|
|
30
|
+
export const SCHEMA = 1;
|
|
31
|
+
export const DEFAULT_TTL_SECONDS = 7200;
|
|
32
|
+
export const DEFAULT_WAIT_TIMEOUT_SECONDS = 1740;
|
|
33
|
+
export const DEFAULT_WAIT_INTERVAL_SECONDS = 5;
|
|
34
|
+
const MUTEX_RETRY_MS = 2000;
|
|
35
|
+
const MUTEX_STALE_MS = 30_000;
|
|
36
|
+
|
|
37
|
+
export const EXIT = { ok: 0, error: 1, held: 3, branchMoved: 4, dirty: 5 };
|
|
38
|
+
|
|
39
|
+
const USAGE = `Usage: conveyor-skills checkout <command> [options]
|
|
40
|
+
|
|
41
|
+
Commands:
|
|
42
|
+
acquire --card <slug> [--branch <b>] take or refresh the checkout claim
|
|
43
|
+
verify --card <slug> [--branch <b>] confirm the claim is still yours (also a heartbeat)
|
|
44
|
+
release --card <slug> [--force] drop the claim (--force: drop someone else's)
|
|
45
|
+
status show the claim; always exits 0
|
|
46
|
+
wait [--timeout 1740] [--interval 5] block until the claim is free, stale, or yours
|
|
47
|
+
|
|
48
|
+
Options:
|
|
49
|
+
--session <id> session identity (default: CONVEYOR_SESSION_ID, then
|
|
50
|
+
CLAUDE_CODE_SESSION_ID, then card:<slug>)
|
|
51
|
+
--ttl <secs> heartbeat age after which a claim is stale
|
|
52
|
+
(default: CONVEYOR_CHECKOUT_CLAIM_TTL_SECONDS or ${DEFAULT_TTL_SECONDS})
|
|
53
|
+
|
|
54
|
+
Exit codes: 0 ok, 1 usage error or not a git repo, 3 held by another session
|
|
55
|
+
(or lost), 4 branch moved under the claim, 5 dirty tree.
|
|
56
|
+
`;
|
|
57
|
+
|
|
58
|
+
/** Marker for a claim file that exists but does not parse as a claim. */
|
|
59
|
+
export const CORRUPT = Object.freeze({ corrupt: true });
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Who is asking. The session id is what owns a claim; the pid (the Claude Code
|
|
63
|
+
* process, when the harness exports it) is what lets a same-host caller prove
|
|
64
|
+
* a holder is dead instead of waiting out the TTL.
|
|
65
|
+
*/
|
|
66
|
+
export function resolveIdentity({ session, card, env = process.env } = {}) {
|
|
67
|
+
const resolved =
|
|
68
|
+
session ||
|
|
69
|
+
env.CONVEYOR_SESSION_ID ||
|
|
70
|
+
env.CLAUDE_CODE_SESSION_ID ||
|
|
71
|
+
(card ? `card:${card}` : null);
|
|
72
|
+
const pid = Number.parseInt(env.CLAUDE_PID ?? "", 10);
|
|
73
|
+
return { session: resolved, pid: Number.isInteger(pid) && pid > 0 ? pid : null };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function defaultPidAlive(pid) {
|
|
77
|
+
try {
|
|
78
|
+
process.kill(pid, 0);
|
|
79
|
+
return true;
|
|
80
|
+
} catch (err) {
|
|
81
|
+
// EPERM: the process exists but belongs to someone else — alive.
|
|
82
|
+
return err?.code === "EPERM";
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function isClaimRecord(value) {
|
|
87
|
+
return (
|
|
88
|
+
value !== null &&
|
|
89
|
+
typeof value === "object" &&
|
|
90
|
+
typeof value.session === "string" &&
|
|
91
|
+
typeof value.host === "string" &&
|
|
92
|
+
!Number.isNaN(Date.parse(value.heartbeatAt))
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Classify an existing claim relative to the caller.
|
|
98
|
+
* kind: none | own | live | stale | corrupt. `reason` explains a stale verdict.
|
|
99
|
+
*/
|
|
100
|
+
export function classifyClaim(claim, { identity, now, hostname, pidAlive, ttlSeconds }) {
|
|
101
|
+
if (claim === null || claim === undefined) return { kind: "none" };
|
|
102
|
+
if (!isClaimRecord(claim)) return { kind: "corrupt" };
|
|
103
|
+
const ageSeconds = Math.max(0, Math.round((now - Date.parse(claim.heartbeatAt)) / 1000));
|
|
104
|
+
if (identity.session && claim.session === identity.session) return { kind: "own", ageSeconds };
|
|
105
|
+
if (claim.host === hostname && Number.isInteger(claim.pid)) {
|
|
106
|
+
if (!pidAlive(claim.pid)) return { kind: "stale", reason: "pid-dead", ageSeconds };
|
|
107
|
+
// Same live process, new session id: the holder ran /clear and moved on.
|
|
108
|
+
if (identity.pid === claim.pid)
|
|
109
|
+
return { kind: "stale", reason: "same-pid-new-session", ageSeconds };
|
|
110
|
+
}
|
|
111
|
+
if (ageSeconds > ttlSeconds) return { kind: "stale", reason: "ttl-expired", ageSeconds };
|
|
112
|
+
return { kind: "live", ageSeconds };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* What `acquire` does with a classified claim.
|
|
117
|
+
* A stale claim is only taken over on a clean tree, or when it names the same
|
|
118
|
+
* card (the tree is that card's own unfinished work). No claim + dirty tree is
|
|
119
|
+
* refused: someone who never took a claim is mid-change.
|
|
120
|
+
*/
|
|
121
|
+
export function decideAcquire({ classification, claim, card, dirty }) {
|
|
122
|
+
switch (classification.kind) {
|
|
123
|
+
case "own":
|
|
124
|
+
return { write: true, state: "refreshed" };
|
|
125
|
+
case "none":
|
|
126
|
+
return dirty ? { write: false, state: "dirty" } : { write: true, state: "acquired" };
|
|
127
|
+
case "stale":
|
|
128
|
+
return !dirty || claim.card === card
|
|
129
|
+
? { write: true, state: "took-over" }
|
|
130
|
+
: { write: false, state: "dirty" };
|
|
131
|
+
default:
|
|
132
|
+
// live, corrupt
|
|
133
|
+
return { write: false, state: "held" };
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
// git + filesystem
|
|
139
|
+
|
|
140
|
+
function git(cwd, args, env) {
|
|
141
|
+
const result = spawnSync("git", args, { cwd, env, encoding: "utf8" });
|
|
142
|
+
return {
|
|
143
|
+
ok: result.status === 0,
|
|
144
|
+
out: (result.stdout ?? "").trim(),
|
|
145
|
+
err: (result.stderr ?? "").trim(),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function readRepo(cwd, env) {
|
|
150
|
+
const gitDir = git(cwd, ["rev-parse", "--absolute-git-dir"], env);
|
|
151
|
+
if (!gitDir.ok) return null;
|
|
152
|
+
const head = git(cwd, ["symbolic-ref", "--short", "-q", "HEAD"], env);
|
|
153
|
+
// --no-optional-locks: never take index.lock, so a status read cannot
|
|
154
|
+
// collide with the holder's own git commands.
|
|
155
|
+
const status = git(cwd, ["--no-optional-locks", "status", "--porcelain"], env);
|
|
156
|
+
return {
|
|
157
|
+
gitDir: gitDir.out,
|
|
158
|
+
head: head.ok && head.out ? head.out : null,
|
|
159
|
+
dirty: status.out.length > 0,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function readClaim(path) {
|
|
164
|
+
let raw;
|
|
165
|
+
try {
|
|
166
|
+
raw = readFileSync(path, "utf8");
|
|
167
|
+
} catch (err) {
|
|
168
|
+
if (err?.code === "ENOENT") return null;
|
|
169
|
+
throw err;
|
|
170
|
+
}
|
|
171
|
+
try {
|
|
172
|
+
const parsed = JSON.parse(raw);
|
|
173
|
+
return isClaimRecord(parsed) ? parsed : CORRUPT;
|
|
174
|
+
} catch {
|
|
175
|
+
return CORRUPT;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function writeClaim(path, record) {
|
|
180
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
181
|
+
writeFileSync(tmp, `${JSON.stringify(record, null, 2)}\n`);
|
|
182
|
+
renameSync(tmp, path);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function removeClaim(path) {
|
|
186
|
+
try {
|
|
187
|
+
unlinkSync(path);
|
|
188
|
+
} catch (err) {
|
|
189
|
+
if (err?.code !== "ENOENT") throw err;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function sleepSync(ms) {
|
|
194
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Run `fn` inside a mkdir-based critical section; mkdir is atomic everywhere. */
|
|
198
|
+
function withMutex(gitDir, fn) {
|
|
199
|
+
const mutex = join(gitDir, MUTEX_DIR);
|
|
200
|
+
const deadline = Date.now() + MUTEX_RETRY_MS;
|
|
201
|
+
for (;;) {
|
|
202
|
+
try {
|
|
203
|
+
mkdirSync(mutex);
|
|
204
|
+
break;
|
|
205
|
+
} catch (err) {
|
|
206
|
+
if (err?.code !== "EEXIST") throw err;
|
|
207
|
+
try {
|
|
208
|
+
if (Date.now() - statSync(mutex).mtimeMs > MUTEX_STALE_MS) {
|
|
209
|
+
rmSync(mutex, { recursive: true, force: true });
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
} catch {
|
|
213
|
+
// Vanished between mkdir and stat — retry.
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
if (Date.now() > deadline) return { busy: true };
|
|
217
|
+
sleepSync(25 + Math.floor(Math.random() * 25));
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
try {
|
|
221
|
+
return { value: fn() };
|
|
222
|
+
} finally {
|
|
223
|
+
rmSync(mutex, { recursive: true, force: true });
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ---------------------------------------------------------------------------
|
|
228
|
+
// CLI
|
|
229
|
+
|
|
230
|
+
function parseArgs(argv) {
|
|
231
|
+
const opts = { _: [] };
|
|
232
|
+
const valued = new Set(["card", "branch", "session", "ttl", "timeout", "interval"]);
|
|
233
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
234
|
+
const arg = argv[i];
|
|
235
|
+
if (!arg.startsWith("--")) {
|
|
236
|
+
opts._.push(arg);
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
const [key, inline] = arg.slice(2).split(/=(.*)/s, 2);
|
|
240
|
+
if (valued.has(key)) {
|
|
241
|
+
const value = inline ?? argv[(i += 1)];
|
|
242
|
+
if (value === undefined || value === "") throw new Error(`--${key} needs a value`);
|
|
243
|
+
opts[key] = value;
|
|
244
|
+
} else if (key === "force" || key === "help") {
|
|
245
|
+
opts[key] = true;
|
|
246
|
+
} else {
|
|
247
|
+
throw new Error(`unknown option --${key}`);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return opts;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function positiveNumber(raw, name) {
|
|
254
|
+
const n = Number(raw);
|
|
255
|
+
if (!Number.isFinite(n) || n <= 0) throw new Error(`--${name} must be a positive number`);
|
|
256
|
+
return n;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function describeHolder(claim, classification) {
|
|
260
|
+
if (claim === CORRUPT) return { corrupt: true };
|
|
261
|
+
return {
|
|
262
|
+
...claim,
|
|
263
|
+
ageSeconds: classification.ageSeconds,
|
|
264
|
+
stale: classification.kind === "stale",
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function holderHint(holder) {
|
|
269
|
+
if (holder.corrupt)
|
|
270
|
+
return "the claim file does not parse — a human decides: `conveyor-skills checkout release --force`";
|
|
271
|
+
const pid = holder.pid ? ` pid ${holder.pid}` : "";
|
|
272
|
+
return `checkout held by card ${holder.card ?? "?"} on branch ${holder.branch ?? "(detached)"} (${holder.host}${pid}, heartbeat ${holder.ageSeconds}s ago) — do not touch the tree; run \`conveyor-skills checkout wait\``;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const HINTS = {
|
|
276
|
+
dirty:
|
|
277
|
+
"working tree is dirty and no live claim covers it — touch nothing; the user commits or stashes",
|
|
278
|
+
"branch-moved": "HEAD is not the claimed branch — do not commit or push; report it",
|
|
279
|
+
lost: "you no longer hold the checkout claim — stop and re-acquire before touching the tree",
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
const COMMANDS = ["acquire", "verify", "release", "status", "wait"];
|
|
283
|
+
|
|
284
|
+
const EXIT_BY_STATE = {
|
|
285
|
+
held: EXIT.held,
|
|
286
|
+
lost: EXIT.held,
|
|
287
|
+
"branch-moved": EXIT.branchMoved,
|
|
288
|
+
dirty: EXIT.dirty,
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
/** Validate parsed options; returns an error message or null. */
|
|
292
|
+
function usageProblem(command, opts) {
|
|
293
|
+
if (!COMMANDS.includes(command)) return `unknown checkout command '${command}'`;
|
|
294
|
+
if (["acquire", "verify"].includes(command) && !opts.card)
|
|
295
|
+
return `${command} needs --card <slug>`;
|
|
296
|
+
if (command === "release" && !opts.card && !opts.force)
|
|
297
|
+
return "release needs --card <slug> (or --force)";
|
|
298
|
+
return null;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function acquireOp({ ctx, claim, classification, stamp }) {
|
|
302
|
+
const { opts, repo, identity, hostname, claimPath } = ctx;
|
|
303
|
+
const decision = decideAcquire({ classification, claim, card: opts.card, dirty: repo.dirty });
|
|
304
|
+
if (!decision.write) return { state: decision.state };
|
|
305
|
+
const kept = classification.kind === "own" ? claim : null;
|
|
306
|
+
const record = {
|
|
307
|
+
schema: SCHEMA,
|
|
308
|
+
session: identity.session,
|
|
309
|
+
pid: identity.pid,
|
|
310
|
+
host: hostname,
|
|
311
|
+
card: opts.card,
|
|
312
|
+
branch: opts.branch ?? kept?.branch ?? repo.head,
|
|
313
|
+
acquiredAt: kept?.acquiredAt ?? stamp,
|
|
314
|
+
heartbeatAt: stamp,
|
|
315
|
+
};
|
|
316
|
+
writeClaim(claimPath, record);
|
|
317
|
+
return { state: decision.state, record };
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function verifyOp({ ctx, claim, classification, stamp }) {
|
|
321
|
+
const { opts, repo, identity, claimPath } = ctx;
|
|
322
|
+
if (classification.kind !== "own") return { state: "lost" };
|
|
323
|
+
const expected = opts.branch ?? claim.branch;
|
|
324
|
+
if (expected && repo.head !== expected) return { state: "branch-moved", expected };
|
|
325
|
+
const record = { ...claim, pid: identity.pid ?? claim.pid, heartbeatAt: stamp };
|
|
326
|
+
writeClaim(claimPath, record);
|
|
327
|
+
return { state: "ok", record };
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function releaseOp({ ctx, classification }) {
|
|
331
|
+
if (classification.kind === "none") return { state: "none" };
|
|
332
|
+
if (classification.kind !== "own" && !ctx.opts.force) return { state: "held" };
|
|
333
|
+
removeClaim(ctx.claimPath);
|
|
334
|
+
return { state: "released" };
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const MUTATIONS = { acquire: acquireOp, verify: verifyOp, release: releaseOp };
|
|
338
|
+
|
|
339
|
+
/** Run a mutating command inside the mutex: read, decide, write. */
|
|
340
|
+
function mutate(ctx) {
|
|
341
|
+
// Test-only: widen the read→write window so the parallel-acquire test fails
|
|
342
|
+
// loudly if the mutex ever stops serializing callers.
|
|
343
|
+
const holdMs = Number(ctx.env.CONVEYOR_CHECKOUT_CLAIM_TEST_HOLD_MS) || 0;
|
|
344
|
+
return withMutex(ctx.repo.gitDir, () => {
|
|
345
|
+
const claim = readClaim(ctx.claimPath);
|
|
346
|
+
const classification = ctx.classify(claim);
|
|
347
|
+
if (holdMs > 0) sleepSync(holdMs);
|
|
348
|
+
const stamp = new Date(ctx.now()).toISOString();
|
|
349
|
+
const result = MUTATIONS[ctx.command]({ ctx, claim, classification, stamp });
|
|
350
|
+
return { ...result, claim, classification };
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** The JSON line and the stderr hint for a finished mutation. */
|
|
355
|
+
function describeOutcome(base, outcome) {
|
|
356
|
+
const { state, claim, classification, record, expected } = outcome;
|
|
357
|
+
const payload = { ...base, state };
|
|
358
|
+
if (state === "took-over") payload.reason = classification.reason;
|
|
359
|
+
if (record) payload.claim = record;
|
|
360
|
+
if (expected) payload.expectedBranch = expected;
|
|
361
|
+
let hint = HINTS[state] ?? null;
|
|
362
|
+
if (["held", "lost"].includes(state) && claim !== null) {
|
|
363
|
+
payload.holder = describeHolder(claim, classification);
|
|
364
|
+
if (state === "held" || classification.kind === "corrupt") hint = holderHint(payload.holder);
|
|
365
|
+
}
|
|
366
|
+
if (state === "took-over")
|
|
367
|
+
hint = `took over a stale claim (${classification.reason}) from card ${claim.card ?? "?"}`;
|
|
368
|
+
return { payload, hint };
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** Parse argv into a command; throws on a usage error. */
|
|
372
|
+
function parseCommand(argv, env) {
|
|
373
|
+
const opts = parseArgs(argv);
|
|
374
|
+
const ttlSeconds = positiveNumber(
|
|
375
|
+
opts.ttl ?? env.CONVEYOR_CHECKOUT_CLAIM_TTL_SECONDS ?? DEFAULT_TTL_SECONDS,
|
|
376
|
+
"ttl",
|
|
377
|
+
);
|
|
378
|
+
return { opts, command: opts._[0], ttlSeconds };
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** Everything a command needs to read and judge the claim. */
|
|
382
|
+
function buildContext({ command, opts, ttlSeconds, env, repo, deps }) {
|
|
383
|
+
const now = deps.now ?? (() => Date.now());
|
|
384
|
+
const hostname = deps.hostname ?? osHostname();
|
|
385
|
+
const pidAlive = deps.pidAlive ?? defaultPidAlive;
|
|
386
|
+
const identity = resolveIdentity({ session: opts.session, card: opts.card, env });
|
|
387
|
+
const claimPath = join(repo.gitDir, CLAIM_FILE);
|
|
388
|
+
const classify = (claim) =>
|
|
389
|
+
classifyClaim(claim, { identity, now: now(), hostname, pidAlive, ttlSeconds });
|
|
390
|
+
const base = { command, head: repo.head, dirty: repo.dirty, file: claimPath };
|
|
391
|
+
return { command, opts, env, repo, identity, hostname, claimPath, classify, now, base };
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
async function runStatus({ ctx, emit }) {
|
|
395
|
+
const claim = readClaim(ctx.claimPath);
|
|
396
|
+
const classification = ctx.classify(claim);
|
|
397
|
+
await emit({
|
|
398
|
+
...ctx.base,
|
|
399
|
+
state: classification.kind,
|
|
400
|
+
...(classification.reason ? { reason: classification.reason } : {}),
|
|
401
|
+
claim: claim === null ? null : describeHolder(claim, classification),
|
|
402
|
+
});
|
|
403
|
+
return EXIT.ok;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
async function runMutation({ ctx, emit, hint, fail }) {
|
|
407
|
+
const outcome = mutate(ctx);
|
|
408
|
+
if (outcome.busy) {
|
|
409
|
+
const message = `could not take ${MUTEX_DIR} within ${MUTEX_RETRY_MS}ms — another session is mid-claim; retry`;
|
|
410
|
+
return fail(message, ctx.base);
|
|
411
|
+
}
|
|
412
|
+
const { payload, hint: message } = describeOutcome(ctx.base, outcome.value);
|
|
413
|
+
if (message) hint(message);
|
|
414
|
+
await emit(payload);
|
|
415
|
+
return EXIT_BY_STATE[payload.state] ?? EXIT.ok;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Run one checkout command. Resolves to the exit code; writes exactly one JSON
|
|
420
|
+
* line to `deps.stdout` and waits for it to flush.
|
|
421
|
+
*/
|
|
422
|
+
export async function runCheckoutCli(argv, deps = {}) {
|
|
423
|
+
const env = deps.env ?? process.env;
|
|
424
|
+
const stdout = deps.stdout ?? process.stdout;
|
|
425
|
+
const stderr = deps.stderr ?? process.stderr;
|
|
426
|
+
let command = argv.find((a) => !a.startsWith("-")) ?? null;
|
|
427
|
+
const emit = (payload) =>
|
|
428
|
+
new Promise((resolve) => {
|
|
429
|
+
stdout.write(`${JSON.stringify(payload)}\n`, () => resolve());
|
|
430
|
+
});
|
|
431
|
+
const hint = (msg) => stderr.write(`conveyor-skills checkout: ${msg}\n`);
|
|
432
|
+
const fail = async (message, extra = {}) => {
|
|
433
|
+
hint(message);
|
|
434
|
+
await emit({ command, ...extra, state: "error", error: message });
|
|
435
|
+
return EXIT.error;
|
|
436
|
+
};
|
|
437
|
+
|
|
438
|
+
let parsed;
|
|
439
|
+
try {
|
|
440
|
+
parsed = parseCommand(argv, env);
|
|
441
|
+
} catch (err) {
|
|
442
|
+
return fail(err.message);
|
|
443
|
+
}
|
|
444
|
+
if (parsed.opts.help || !parsed.command) {
|
|
445
|
+
stderr.write(USAGE);
|
|
446
|
+
await emit({ command, state: "usage" });
|
|
447
|
+
return parsed.opts.help ? EXIT.ok : EXIT.error;
|
|
448
|
+
}
|
|
449
|
+
command = parsed.command;
|
|
450
|
+
const problem = usageProblem(command, parsed.opts);
|
|
451
|
+
if (problem) return fail(problem);
|
|
452
|
+
|
|
453
|
+
const repo = readRepo(deps.cwd ?? process.cwd(), env);
|
|
454
|
+
if (!repo) return fail("not inside a git repository");
|
|
455
|
+
const ctx = buildContext({ ...parsed, env, repo, deps });
|
|
456
|
+
const io = { ctx, emit, hint, fail };
|
|
457
|
+
if (command === "status") return runStatus(io);
|
|
458
|
+
if (command === "wait") return runWait(io);
|
|
459
|
+
return runMutation(io);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/** Sleep up to `ms`, returning early when `signal.wake()` is called. */
|
|
463
|
+
function interruptibleSleep(ms, signal) {
|
|
464
|
+
return new Promise((resolve) => {
|
|
465
|
+
const timer = setTimeout(resolve, ms);
|
|
466
|
+
signal.wake = () => {
|
|
467
|
+
clearTimeout(timer);
|
|
468
|
+
resolve();
|
|
469
|
+
};
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
const WAIT_VERDICTS = { none: "free", stale: "stale", own: "own" };
|
|
474
|
+
|
|
475
|
+
async function runWait({ ctx, emit, hint, fail }) {
|
|
476
|
+
const { opts, base, claimPath, classify } = ctx;
|
|
477
|
+
let timeoutSeconds;
|
|
478
|
+
let intervalSeconds;
|
|
479
|
+
try {
|
|
480
|
+
timeoutSeconds = positiveNumber(opts.timeout ?? DEFAULT_WAIT_TIMEOUT_SECONDS, "timeout");
|
|
481
|
+
intervalSeconds = positiveNumber(opts.interval ?? DEFAULT_WAIT_INTERVAL_SECONDS, "interval");
|
|
482
|
+
} catch (err) {
|
|
483
|
+
return fail(err.message, base);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
const signal = { interrupted: false, wake: () => {} };
|
|
487
|
+
const onSignal = () => {
|
|
488
|
+
signal.interrupted = true;
|
|
489
|
+
signal.wake();
|
|
490
|
+
};
|
|
491
|
+
process.once("SIGINT", onSignal);
|
|
492
|
+
process.once("SIGTERM", onSignal);
|
|
493
|
+
const deadline = Date.now() + timeoutSeconds * 1000;
|
|
494
|
+
let announced = false;
|
|
495
|
+
|
|
496
|
+
try {
|
|
497
|
+
for (;;) {
|
|
498
|
+
const claim = readClaim(claimPath);
|
|
499
|
+
const classification = classify(claim);
|
|
500
|
+
const verdict = WAIT_VERDICTS[classification.kind];
|
|
501
|
+
if (verdict) {
|
|
502
|
+
const holder =
|
|
503
|
+
claim && claim !== CORRUPT ? { holder: describeHolder(claim, classification) } : {};
|
|
504
|
+
await emit({ ...base, state: verdict, ...holder });
|
|
505
|
+
return EXIT.ok;
|
|
506
|
+
}
|
|
507
|
+
if (!announced) {
|
|
508
|
+
hint(`waiting — ${holderHint(describeHolder(claim, classification))}`);
|
|
509
|
+
announced = true;
|
|
510
|
+
}
|
|
511
|
+
if (signal.interrupted || Date.now() >= deadline) {
|
|
512
|
+
await emit({ ...base, state: signal.interrupted ? "interrupted" : "timeout" });
|
|
513
|
+
return EXIT.ok;
|
|
514
|
+
}
|
|
515
|
+
await interruptibleSleep(
|
|
516
|
+
Math.min(intervalSeconds * 1000, Math.max(0, deadline - Date.now())),
|
|
517
|
+
signal,
|
|
518
|
+
);
|
|
519
|
+
}
|
|
520
|
+
} finally {
|
|
521
|
+
process.removeListener("SIGINT", onSignal);
|
|
522
|
+
process.removeListener("SIGTERM", onSignal);
|
|
523
|
+
}
|
|
524
|
+
}
|
package/bin/cli.mjs
CHANGED
|
@@ -7,6 +7,9 @@
|
|
|
7
7
|
// Usage:
|
|
8
8
|
// conveyor-skills link (default) create/refresh links, prune stale ones
|
|
9
9
|
// conveyor-skills link --check verify links; exit 1 if missing/stale
|
|
10
|
+
// conveyor-skills checkout <acquire|verify|release|status|wait> ...
|
|
11
|
+
// take turns on a shared local checkout
|
|
12
|
+
// (see bin/checkout-claim.mjs)
|
|
10
13
|
//
|
|
11
14
|
// Only links owned by this package (target path contains conveyor-skills/skills/)
|
|
12
15
|
// are ever replaced or pruned. Real directories and foreign symlinks are left
|
|
@@ -68,10 +71,17 @@ function ownedBy(linkPath) {
|
|
|
68
71
|
}
|
|
69
72
|
|
|
70
73
|
const args = process.argv.slice(2);
|
|
74
|
+
|
|
75
|
+
if (args[0] === "checkout") {
|
|
76
|
+
const { runCheckoutCli } = await import("./checkout-claim.mjs");
|
|
77
|
+
// runCheckoutCli resolves only after its JSON line has flushed.
|
|
78
|
+
process.exit(await runCheckoutCli(args.slice(1)));
|
|
79
|
+
}
|
|
80
|
+
|
|
71
81
|
const checkOnly = args.includes("--check");
|
|
72
82
|
const command = args.find((a) => !a.startsWith("-")) ?? "link";
|
|
73
83
|
if (command !== "link")
|
|
74
|
-
fail(`unknown command '${command}' —
|
|
84
|
+
fail(`unknown command '${command}' — expected 'link' (with optional --check) or 'checkout'`);
|
|
75
85
|
|
|
76
86
|
// This package's own skills/ dir, resolved through any install symlink (bun
|
|
77
87
|
// may install the package as a symlink into its store).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rallycry/conveyor-skills",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.13",
|
|
4
4
|
"description": "Shared Claude Code skills for Conveyor consumer repos, linked into .claude/skills via the conveyor-skills CLI",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude",
|
|
@@ -20,5 +20,9 @@
|
|
|
20
20
|
"type": "module",
|
|
21
21
|
"publishConfig": {
|
|
22
22
|
"access": "public"
|
|
23
|
+
},
|
|
24
|
+
"scripts": {
|
|
25
|
+
"test": "node --test __tests__/checkout-claim.test.mjs",
|
|
26
|
+
"test:unit": "node --test __tests__/checkout-claim.test.mjs"
|
|
23
27
|
}
|
|
24
28
|
}
|