relay-kit 0.2.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.
package/dist/cli.js ADDED
@@ -0,0 +1,1343 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.ts
4
+ import { Command } from "commander";
5
+
6
+ // src/commands/ask.ts
7
+ import path9 from "path";
8
+
9
+ // src/core/clipboard.ts
10
+ import { spawn } from "child_process";
11
+ async function copyToClipboard(content) {
12
+ const command = process.platform === "win32" ? "clip" : process.platform === "darwin" ? "pbcopy" : "xclip";
13
+ const args = process.platform === "linux" ? ["-selection", "clipboard"] : [];
14
+ return new Promise((resolve) => {
15
+ const child = spawn(command, args, { stdio: ["pipe", "ignore", "ignore"], windowsHide: true });
16
+ child.on("error", () => resolve(false));
17
+ child.on("close", (code) => resolve(code === 0));
18
+ child.stdin.end(content);
19
+ });
20
+ }
21
+
22
+ // src/core/command-runner.ts
23
+ import { exec } from "child_process";
24
+ import { promisify as promisify2 } from "util";
25
+
26
+ // src/core/git.ts
27
+ import { execFile } from "child_process";
28
+ import { promisify } from "util";
29
+
30
+ // src/core/excludes.ts
31
+ import path from "path";
32
+
33
+ // src/core/constants.ts
34
+ var RELAY_DIR = ".relay";
35
+ var RELAYIGNORE_FILE = ".relayignore";
36
+ var CONFIG_FILE = `${RELAY_DIR}/config.json`;
37
+ var STATE_FILE = `${RELAY_DIR}/state.json`;
38
+ var DEFAULT_HANDOFF_DIR = "docs/agent-handoffs";
39
+ var DEFAULT_LANE = "main";
40
+ var DEFAULT_MAX_DIFF_LINES = 500;
41
+ var DEFAULT_MAX_LOG_LINES = 160;
42
+ var AGENTS_START_MARKER = "<!-- relay-kit:start -->";
43
+ var AGENTS_END_MARKER = "<!-- relay-kit:end -->";
44
+ var DEFAULT_SKILLS = [
45
+ "relay-planner",
46
+ "relay-delegator",
47
+ "relay-escalation",
48
+ "relay-reviewer"
49
+ ];
50
+ var EXCLUDED_NAMES = /* @__PURE__ */ new Set([
51
+ ".env",
52
+ ".git",
53
+ "node_modules",
54
+ "dist",
55
+ "build",
56
+ "coverage"
57
+ ]);
58
+ var EXCLUDED_GLOBS = [
59
+ ".env",
60
+ ".env.*",
61
+ "node_modules/",
62
+ "dist/",
63
+ "build/",
64
+ "coverage/",
65
+ ".git/",
66
+ "*.pem",
67
+ "*.key",
68
+ "*.crt",
69
+ "*.p12",
70
+ "*.log"
71
+ ];
72
+ var DEFAULT_RELAYIGNORE_CONTENT = `# relay-kit context ignore rules
73
+ .env
74
+ .env.*
75
+ node_modules/
76
+ dist/
77
+ build/
78
+ coverage/
79
+ .git/
80
+ *.pem
81
+ *.key
82
+ *.crt
83
+ *.p12
84
+ *.log
85
+ `;
86
+
87
+ // src/core/excludes.ts
88
+ function normalizePath(value) {
89
+ return value.replace(/\\/g, "/");
90
+ }
91
+ function isExcludedPath(candidate) {
92
+ const normalized = normalizePath(candidate);
93
+ const parts = normalized.split("/").filter(Boolean);
94
+ const basename = parts.at(-1) ?? normalized;
95
+ if (basename === ".env" || basename.startsWith(".env.")) {
96
+ return true;
97
+ }
98
+ if (/\.(pem|key|crt|p12|log)$/i.test(basename)) {
99
+ return true;
100
+ }
101
+ return parts.some((part) => EXCLUDED_NAMES.has(part));
102
+ }
103
+ function filterExcludedLines(text) {
104
+ return text.split(/\r?\n/).filter((line) => {
105
+ return !isExcludedPath(line);
106
+ }).join("\n").trim();
107
+ }
108
+ function assertInsideRoot(root, target) {
109
+ const resolvedRoot = path.resolve(root);
110
+ const resolvedTarget = path.resolve(root, target);
111
+ const relative = path.relative(resolvedRoot, resolvedTarget);
112
+ if (relative === "" || !relative.startsWith("..") && !path.isAbsolute(relative)) {
113
+ return resolvedTarget;
114
+ }
115
+ throw new Error(`Refusing to write outside project root: ${target}`);
116
+ }
117
+
118
+ // src/core/git.ts
119
+ var execFileAsync = promisify(execFile);
120
+ async function git(root, args) {
121
+ try {
122
+ const { stdout, stderr } = await execFileAsync("git", args, {
123
+ cwd: root,
124
+ maxBuffer: 1024 * 1024 * 5,
125
+ windowsHide: true
126
+ });
127
+ return `${stdout}${stderr}`.trim();
128
+ } catch (error) {
129
+ if (error && typeof error === "object" && "stdout" in error) {
130
+ const failed = error;
131
+ return `${failed.stdout ?? ""}${failed.stderr ?? ""}`.trim();
132
+ }
133
+ return "";
134
+ }
135
+ }
136
+ async function collectGitContext(root, options) {
137
+ const contextOptions = typeof options === "number" ? { maxDiffLines: options } : options;
138
+ const insideWorkTree = await git(root, ["rev-parse", "--is-inside-work-tree"]);
139
+ if (insideWorkTree.trim() !== "true") {
140
+ return {
141
+ branch: "(not a git repository)",
142
+ status: "",
143
+ diffStat: "",
144
+ diff: ""
145
+ };
146
+ }
147
+ const pathspecs = contextOptions.gitExcludePathspecs ?? [];
148
+ const branch = sanitizeGitText(await git(root, ["branch", "--show-current"]), contextOptions) || "(unknown)";
149
+ const status = sanitizeGitText(await git(root, ["status", "--short", "--", ".", ...pathspecs]), contextOptions);
150
+ const diffStat = sanitizeGitText(await git(root, ["diff", "--stat", "--", ".", ...pathspecs]), contextOptions);
151
+ const diff = sanitizeGitDiff(await git(root, ["diff", "--", ".", ...pathspecs]), contextOptions);
152
+ const truncatedDiff = truncateLines(diff, contextOptions.maxDiffLines);
153
+ return {
154
+ branch,
155
+ status,
156
+ diffStat,
157
+ diff: truncatedDiff
158
+ };
159
+ }
160
+ function truncateLines(text, maxLines) {
161
+ const lines = text.split(/\r?\n/);
162
+ if (lines.length <= maxLines) {
163
+ return text;
164
+ }
165
+ return `${lines.slice(0, maxLines).join("\n")}
166
+ ... truncated ${lines.length - maxLines} lines ...`;
167
+ }
168
+ function sanitizeGitText(text, options) {
169
+ const filtered = filterIgnoredLines(filterExcludedLines(text), options.shouldIgnorePath);
170
+ return applyRedaction(filtered, options).trim();
171
+ }
172
+ function sanitizeGitDiff(text, options) {
173
+ const filtered = filterIgnoredLines(filterIgnoredDiffBlocks(text, options.shouldIgnorePath), options.shouldIgnorePath);
174
+ return applyRedaction(filtered, options).trim();
175
+ }
176
+ function applyRedaction(text, options) {
177
+ return options.redactText ? options.redactText(text) : text;
178
+ }
179
+ function filterIgnoredLines(text, shouldIgnorePath) {
180
+ return text.split(/\r?\n/).filter((line) => !lineReferencesIgnoredPath(line, shouldIgnorePath ?? (() => false))).join("\n").trim();
181
+ }
182
+ function filterIgnoredDiffBlocks(text, shouldIgnorePath) {
183
+ const lines = text.split(/\r?\n/);
184
+ const kept = [];
185
+ let skipping = false;
186
+ const ignore = shouldIgnorePath ?? (() => false);
187
+ for (const line of lines) {
188
+ if (line.startsWith("diff --git ")) {
189
+ skipping = lineReferencesIgnoredPath(line, ignore);
190
+ }
191
+ if (!skipping) {
192
+ kept.push(line);
193
+ }
194
+ }
195
+ return kept.join("\n").trim();
196
+ }
197
+ function lineReferencesIgnoredPath(line, shouldIgnorePath) {
198
+ return extractCandidatePaths(line).some((candidate) => shouldIgnorePath(candidate) || isExcludedPath(candidate));
199
+ }
200
+ function extractCandidatePaths(line) {
201
+ const candidates = /* @__PURE__ */ new Set();
202
+ const statusPath = line.match(/^[ MADRCU?!]{1,2}\s+(.+)$/)?.[1];
203
+ if (statusPath) {
204
+ for (const part of statusPath.split(/\s+->\s+/)) {
205
+ candidates.add(stripGitPathDecorations(part));
206
+ }
207
+ }
208
+ const diffPaths = line.matchAll(/\b[ab]\/([^\s]+)/g);
209
+ for (const match of diffPaths) {
210
+ candidates.add(stripGitPathDecorations(match[1]));
211
+ }
212
+ const statPath = line.includes("|") ? line.split("|")[0]?.trim() : "";
213
+ if (statPath) {
214
+ candidates.add(stripGitPathDecorations(statPath));
215
+ }
216
+ return [...candidates].filter(Boolean);
217
+ }
218
+ function stripGitPathDecorations(value) {
219
+ return value.replace(/^"|"$/g, "").replace(/\\/g, "/").trim();
220
+ }
221
+
222
+ // src/core/command-runner.ts
223
+ var execAsync = promisify2(exec);
224
+ async function runShellCommand(root, command, options = { maxLogLines: 160 }) {
225
+ try {
226
+ const { stdout, stderr } = await execAsync(command, {
227
+ cwd: root,
228
+ maxBuffer: 1024 * 1024 * 2,
229
+ windowsHide: true
230
+ });
231
+ return { command, exitCode: 0, output: sanitizeCommandOutput(`${stdout}${stderr}`.trim(), options) };
232
+ } catch (error) {
233
+ const failed = error;
234
+ return {
235
+ command,
236
+ exitCode: typeof failed.code === "number" ? failed.code : 1,
237
+ output: sanitizeCommandOutput(`${failed.stdout ?? ""}${failed.stderr ?? ""}`.trim(), options)
238
+ };
239
+ }
240
+ }
241
+ function sanitizeCommandOutput(output2, options) {
242
+ const redacted = options.redactText ? options.redactText(output2) : output2;
243
+ return truncateLines(redacted, options.maxLogLines);
244
+ }
245
+
246
+ // src/core/config.ts
247
+ import path3 from "path";
248
+
249
+ // src/core/fs.ts
250
+ import { constants as fsConstants } from "fs";
251
+ import fs from "fs/promises";
252
+ import path2 from "path";
253
+ async function pathExists(filePath) {
254
+ try {
255
+ await fs.access(filePath, fsConstants.F_OK);
256
+ return true;
257
+ } catch {
258
+ return false;
259
+ }
260
+ }
261
+ async function ensureDir(dirPath) {
262
+ await fs.mkdir(dirPath, { recursive: true });
263
+ }
264
+ async function readTextIfExists(filePath) {
265
+ if (!await pathExists(filePath)) {
266
+ return "";
267
+ }
268
+ return fs.readFile(filePath, "utf8");
269
+ }
270
+ async function readJsonIfExists(filePath) {
271
+ if (!await pathExists(filePath)) {
272
+ return void 0;
273
+ }
274
+ return JSON.parse(stripBom(await fs.readFile(filePath, "utf8")));
275
+ }
276
+ async function writeJsonFile(filePath, value) {
277
+ await ensureDir(path2.dirname(filePath));
278
+ await fs.writeFile(filePath, `${JSON.stringify(value, null, 2)}
279
+ `, "utf8");
280
+ }
281
+ async function safeWriteFile(root, relativePath, content, options = {}) {
282
+ const target = assertInsideRoot(root, relativePath);
283
+ await ensureDir(path2.dirname(target));
284
+ if (!options.force && await pathExists(target)) {
285
+ throw new Error(`Refusing to overwrite existing file without --force: ${relativePath}`);
286
+ }
287
+ await fs.writeFile(target, content, "utf8");
288
+ return target;
289
+ }
290
+ async function copyDirectory(source, target, options = {}) {
291
+ if (!await pathExists(source)) {
292
+ throw new Error(`Missing source directory: ${source}`);
293
+ }
294
+ await ensureDir(target);
295
+ const entries = await fs.readdir(source, { withFileTypes: true });
296
+ for (const entry of entries) {
297
+ const sourcePath = path2.join(source, entry.name);
298
+ const targetPath = path2.join(target, entry.name);
299
+ if (entry.isDirectory()) {
300
+ await copyDirectory(sourcePath, targetPath, options);
301
+ continue;
302
+ }
303
+ if (!entry.isFile()) {
304
+ continue;
305
+ }
306
+ if (!options.force && await pathExists(targetPath)) {
307
+ continue;
308
+ }
309
+ await fs.copyFile(sourcePath, targetPath);
310
+ }
311
+ }
312
+ function stripBom(value) {
313
+ return value.charCodeAt(0) === 65279 ? value.slice(1) : value;
314
+ }
315
+
316
+ // src/core/config.ts
317
+ function createDefaultConfig(project, mode) {
318
+ return {
319
+ projectName: project.name,
320
+ language: "zh",
321
+ mode,
322
+ handoffDir: DEFAULT_HANDOFF_DIR,
323
+ openSpecDir: "openspec",
324
+ sourceDirs: ["src", "apps", "packages"],
325
+ packageManager: project.packageManager === "unknown" ? "auto" : project.packageManager,
326
+ devCommand: project.packageScripts.dev ? packageScript(project.packageManager, "dev") : "",
327
+ buildCommand: project.packageScripts.build ? packageScript(project.packageManager, "build") : "",
328
+ testCommand: project.packageScripts.test ? packageScript(project.packageManager, "test") : "",
329
+ defaultExecutor: "opencode",
330
+ defaultAdvisor: "",
331
+ maxDiffLines: DEFAULT_MAX_DIFF_LINES,
332
+ maxLogLines: DEFAULT_MAX_LOG_LINES,
333
+ includeGitDiff: true,
334
+ includeOpenSpec: mode === "openspec",
335
+ includePackageScripts: true,
336
+ runLayout: "runs-lanes",
337
+ defaultLane: DEFAULT_LANE,
338
+ excludePatterns: EXCLUDED_GLOBS,
339
+ skills: {
340
+ install: {
341
+ manager: true,
342
+ claudeProject: true,
343
+ codexProject: true,
344
+ claudeUser: false,
345
+ codexUser: false
346
+ },
347
+ enabled: ["relay-planner", "relay-delegator", "relay-escalation", "relay-reviewer"],
348
+ optional: ["relay-lane-planner", "relay-docs"]
349
+ }
350
+ };
351
+ }
352
+ async function loadConfig(root) {
353
+ const config = await readJsonIfExists(path3.join(root, CONFIG_FILE));
354
+ if (!config) {
355
+ throw new Error("Missing .relay/config.json. Run relay init first.");
356
+ }
357
+ return normalizeConfig(config);
358
+ }
359
+ async function writeConfig(root, config) {
360
+ await writeJsonFile(path3.join(root, CONFIG_FILE), config);
361
+ }
362
+ function packageScript(packageManager, script) {
363
+ const runner = packageManager === "unknown" ? "npm" : packageManager;
364
+ return `${runner} run ${script}`;
365
+ }
366
+ function normalizeConfig(config) {
367
+ return {
368
+ ...config,
369
+ maxDiffLines: config.maxDiffLines ?? DEFAULT_MAX_DIFF_LINES,
370
+ maxLogLines: config.maxLogLines ?? DEFAULT_MAX_LOG_LINES,
371
+ excludePatterns: config.excludePatterns ?? EXCLUDED_GLOBS
372
+ };
373
+ }
374
+
375
+ // src/core/relayignore.ts
376
+ import path4 from "path";
377
+ async function loadRelayIgnoreMatcher(root, extraPatterns = []) {
378
+ const relayIgnorePath = path4.join(root, RELAYIGNORE_FILE);
379
+ const hasRelayIgnore = await pathExists(relayIgnorePath);
380
+ const relayIgnorePatterns = hasRelayIgnore ? parseRelayIgnore(await readTextIfExists(relayIgnorePath)) : [];
381
+ const patterns = uniquePatterns([...EXCLUDED_GLOBS, ...extraPatterns, ...relayIgnorePatterns]);
382
+ const rules = patterns.map(parseRule).filter((rule) => Boolean(rule));
383
+ return {
384
+ hasRelayIgnore,
385
+ patterns,
386
+ gitExcludePathspecs: buildGitExcludePathspecs(rules),
387
+ shouldIgnorePath(candidate) {
388
+ const normalized = normalizePath2(candidate);
389
+ return rules.some((rule) => matchesRule(rule, normalized));
390
+ }
391
+ };
392
+ }
393
+ function parseRelayIgnore(content) {
394
+ return stripBom(content).split(/\r?\n/).map((line) => line.trim()).filter((line) => line && !line.startsWith("#")).filter((line) => !line.startsWith("!"));
395
+ }
396
+ function normalizePath2(value) {
397
+ return value.replace(/\\/g, "/").replace(/^\.\//, "").replace(/^\/+/, "");
398
+ }
399
+ function parseRule(rawPattern) {
400
+ const raw = rawPattern.trim();
401
+ if (!raw || raw.startsWith("#") || raw.startsWith("!")) {
402
+ return void 0;
403
+ }
404
+ const rootOnly = raw.startsWith("/");
405
+ const directory = raw.endsWith("/");
406
+ const pattern = normalizePath2(raw).replace(/\/$/, "");
407
+ if (!pattern) {
408
+ return void 0;
409
+ }
410
+ return {
411
+ pattern,
412
+ raw,
413
+ directory,
414
+ rootOnly,
415
+ hasGlob: pattern.includes("*")
416
+ };
417
+ }
418
+ function matchesRule(rule, candidate) {
419
+ const normalized = normalizePath2(candidate);
420
+ const parts = normalized.split("/").filter(Boolean);
421
+ if (rule.directory) {
422
+ if (rule.hasGlob || rule.pattern.includes("/")) {
423
+ return pathMatchesPattern(normalized, rule.pattern) || normalized.startsWith(`${rule.pattern}/`);
424
+ }
425
+ return parts.includes(rule.pattern);
426
+ }
427
+ if (rule.hasGlob) {
428
+ const target = rule.pattern.includes("/") || rule.rootOnly ? normalized : parts.at(-1) ?? normalized;
429
+ return pathMatchesPattern(target, rule.pattern);
430
+ }
431
+ if (rule.pattern.includes("/") || rule.rootOnly) {
432
+ return normalized === rule.pattern;
433
+ }
434
+ return parts.includes(rule.pattern);
435
+ }
436
+ function pathMatchesPattern(candidate, pattern) {
437
+ return globToRegExp(pattern).test(candidate);
438
+ }
439
+ function globToRegExp(pattern) {
440
+ const source = pattern.split("*").map((part) => part.replace(/[.+?^${}()|[\]\\]/g, "\\$&")).join(".*");
441
+ return new RegExp(`^${source}$`);
442
+ }
443
+ function buildGitExcludePathspecs(rules) {
444
+ const pathspecs = [];
445
+ for (const rule of rules) {
446
+ const pattern = gitGlobForRule(rule);
447
+ pathspecs.push(`:(exclude,glob)${pattern}`);
448
+ if (!rule.rootOnly && !pattern.startsWith("**/")) {
449
+ pathspecs.push(`:(exclude,glob)**/${pattern}`);
450
+ }
451
+ }
452
+ return [...new Set(pathspecs)];
453
+ }
454
+ function gitGlobForRule(rule) {
455
+ if (rule.directory) {
456
+ return `${rule.pattern}/**`;
457
+ }
458
+ return rule.pattern;
459
+ }
460
+ function uniquePatterns(patterns) {
461
+ return [...new Set(patterns.map((pattern) => pattern.trim()).filter(Boolean))];
462
+ }
463
+
464
+ // src/core/redaction.ts
465
+ var PRIVATE_KEY_BLOCK = /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g;
466
+ var BEARER_TOKEN = /\b(Bearer\s+)([A-Za-z0-9._~+/=-]{6,})/gi;
467
+ var SENSITIVE_KEY_VALUE = /(["']?[\w.-]*(?:api[_-]?key|token|secret|password)[\w.-]*["']?\s*[:=]\s*)(["']?)([^\s"',;]+)/gi;
468
+ function redactSensitiveText(value) {
469
+ if (!value) {
470
+ return value;
471
+ }
472
+ return value.replace(PRIVATE_KEY_BLOCK, "[REDACTED_PRIVATE_KEY]").replace(BEARER_TOKEN, "$1[REDACTED]").replace(SENSITIVE_KEY_VALUE, (_match, prefix, quote) => `${prefix}${quote}[REDACTED]`);
473
+ }
474
+
475
+ // src/core/context-safety.ts
476
+ async function createContextSafety(root, config) {
477
+ const ignore = await loadRelayIgnoreMatcher(root, config.excludePatterns);
478
+ return {
479
+ ignore,
480
+ ignoreRulesStatus: ignore.hasRelayIgnore ? "applied: default rules + .relayignore" : "applied: default rules only",
481
+ redactionRulesStatus: "applied: basic sensitive value redaction",
482
+ shouldIgnorePath(candidate) {
483
+ return ignore.shouldIgnorePath(candidate);
484
+ },
485
+ redactText(value) {
486
+ return redactSensitiveText(value);
487
+ }
488
+ };
489
+ }
490
+
491
+ // src/core/openspec.ts
492
+ import fs2 from "fs/promises";
493
+ import path5 from "path";
494
+ async function listOpenSpecChanges(root) {
495
+ const changesDir = path5.join(root, "openspec", "changes");
496
+ if (!await pathExists(changesDir)) {
497
+ return [];
498
+ }
499
+ const entries = await fs2.readdir(changesDir, { withFileTypes: true });
500
+ return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort();
501
+ }
502
+ async function resolveOpenSpecChange(root, requested) {
503
+ if (requested) {
504
+ const changeDir = path5.join(root, "openspec", "changes", requested);
505
+ if (!await pathExists(changeDir)) {
506
+ throw new Error(`OpenSpec change not found: ${requested}`);
507
+ }
508
+ return requested;
509
+ }
510
+ const changes = await listOpenSpecChanges(root);
511
+ if (changes.length === 1) {
512
+ return changes[0];
513
+ }
514
+ if (changes.length === 0) {
515
+ throw new Error("No OpenSpec changes found.");
516
+ }
517
+ throw new Error(`Multiple OpenSpec changes found. Specify one with --change: ${changes.join(", ")}`);
518
+ }
519
+ async function readOpenSpecContext(root, change) {
520
+ const changeDir = path5.join(root, "openspec", "changes", change);
521
+ return {
522
+ change,
523
+ proposal: filterExcludedLines(await readTextIfExists(path5.join(changeDir, "proposal.md"))),
524
+ design: filterExcludedLines(await readTextIfExists(path5.join(changeDir, "design.md"))),
525
+ tasks: filterExcludedLines(await readTextIfExists(path5.join(changeDir, "tasks.md")))
526
+ };
527
+ }
528
+ function formatOpenSpecContext(context) {
529
+ if (!context) {
530
+ return "";
531
+ }
532
+ return [
533
+ `# OpenSpec Change: ${context.change}`,
534
+ "## proposal.md",
535
+ context.proposal || "(missing)",
536
+ "## design.md",
537
+ context.design || "(missing)",
538
+ "## tasks.md",
539
+ context.tasks || "(missing)"
540
+ ].join("\n\n");
541
+ }
542
+
543
+ // src/core/runs.ts
544
+ import path7 from "path";
545
+
546
+ // src/core/templates.ts
547
+ import fsSync from "fs";
548
+ import fs3 from "fs/promises";
549
+ import path6 from "path";
550
+ import { fileURLToPath } from "url";
551
+ function getPackageRoot() {
552
+ const moduleDir = path6.dirname(fileURLToPath(import.meta.url));
553
+ const candidates = [
554
+ moduleDir,
555
+ path6.resolve(moduleDir, ".."),
556
+ path6.resolve(moduleDir, "..", "..")
557
+ ];
558
+ for (const candidate of candidates) {
559
+ if (fsSync.existsSync(path6.join(candidate, "templates")) && fsSync.existsSync(path6.join(candidate, "skills"))) {
560
+ return candidate;
561
+ }
562
+ }
563
+ return path6.resolve(moduleDir, "..", "..");
564
+ }
565
+ function renderTemplate(template, values) {
566
+ return template.replace(/\{\{([A-Za-z0-9_]+)\}\}/g, (_, rawKey) => {
567
+ return values[rawKey] ?? "";
568
+ });
569
+ }
570
+ async function loadTemplate(name) {
571
+ const templatePath = path6.join(getPackageRoot(), "templates", name);
572
+ try {
573
+ return await fs3.readFile(templatePath, "utf8");
574
+ } catch (error) {
575
+ const detail = error instanceof Error ? error.message : String(error);
576
+ throw new Error(`Missing template ${name}: ${detail}`);
577
+ }
578
+ }
579
+
580
+ // src/core/runs.ts
581
+ function slugify(value) {
582
+ const slug = value.toLowerCase().replace(/[^a-z0-9\u4e00-\u9fa5]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 48);
583
+ return slug || "task";
584
+ }
585
+ function createRunId(title, date = /* @__PURE__ */ new Date()) {
586
+ const day = date.toISOString().slice(0, 10);
587
+ return `${day}-${slugify(title)}`;
588
+ }
589
+ function getRunContext(root, config, runId, lane = DEFAULT_LANE) {
590
+ const runDir = path7.join(root, config.handoffDir, "runs", runId);
591
+ return {
592
+ runId,
593
+ lane,
594
+ runDir,
595
+ laneDir: path7.join(runDir, "lanes", lane)
596
+ };
597
+ }
598
+ async function createRunStructure(root, config, runId, lane = DEFAULT_LANE, options = {}) {
599
+ const context = getRunContext(root, config, runId, lane);
600
+ await ensureDir(context.laneDir);
601
+ await ensureDir(path7.join(context.runDir, "history"));
602
+ const runRelative = path7.relative(root, path7.join(context.runDir, "RUN.md"));
603
+ await safeWriteFile(root, runRelative, `# RUN
604
+
605
+ - Run: ${runId}
606
+ - Lane: ${lane}
607
+ `, options);
608
+ const board = renderTemplate(await loadTemplate("TASK_BOARD.template.md"), { runId });
609
+ const boardRelative = path7.relative(root, path7.join(context.runDir, "TASK_BOARD.md"));
610
+ await safeWriteFile(root, boardRelative, board, options);
611
+ return context;
612
+ }
613
+
614
+ // src/core/state.ts
615
+ import path8 from "path";
616
+ function createDefaultState(mode) {
617
+ return {
618
+ currentRun: "",
619
+ currentLane: DEFAULT_LANE,
620
+ mode,
621
+ currentChange: "",
622
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
623
+ advisorMode: "review",
624
+ executorFailures: {
625
+ currentTask: 0,
626
+ totalEscalations: 0
627
+ },
628
+ directFixLog: []
629
+ };
630
+ }
631
+ async function loadState(root) {
632
+ const state = await readJsonIfExists(path8.join(root, STATE_FILE));
633
+ if (!state) {
634
+ throw new Error("Missing .relay/state.json. Run relay init first.");
635
+ }
636
+ return state;
637
+ }
638
+ async function writeState(root, state) {
639
+ await writeJsonFile(path8.join(root, STATE_FILE), state);
640
+ }
641
+ async function updateState(root, patch) {
642
+ const current = await loadState(root);
643
+ const next = { ...current, ...patch, updatedAt: (/* @__PURE__ */ new Date()).toISOString() };
644
+ await writeState(root, next);
645
+ return next;
646
+ }
647
+
648
+ // src/commands/ask.ts
649
+ function registerAskCommand(program2) {
650
+ program2.command("ask").description("Create a relay escalation request when the executor is stuck.").option("--run <target>", "Explicitly run build or test before generating ASK_ADVISOR.md.").option("--copy", "Copy generated content to clipboard.").option("--force", "Overwrite ASK_ADVISOR.md.").action(async (options) => {
651
+ const result = await runAsk(process.cwd(), options);
652
+ console.log(result.summary);
653
+ });
654
+ }
655
+ async function runAsk(root, options = {}) {
656
+ if (options.run && options.run !== "build" && options.run !== "test") {
657
+ throw new Error("--run must be build or test.");
658
+ }
659
+ const config = await loadConfig(root);
660
+ const state = await loadState(root);
661
+ const run = requireCurrentRun(root, config, state.currentRun, state.currentLane);
662
+ const contextSafety = await createContextSafety(root, config);
663
+ const git2 = await collectGitContext(root, {
664
+ maxDiffLines: config.maxDiffLines,
665
+ gitExcludePathspecs: contextSafety.ignore.gitExcludePathspecs,
666
+ shouldIgnorePath: contextSafety.shouldIgnorePath,
667
+ redactText: contextSafety.redactText
668
+ });
669
+ const currentTask = contextSafety.redactText(await readTextIfExists(path9.join(run.laneDir, "EXECUTOR_TASK.md")));
670
+ const commandResult = await maybeRunCommand(root, config, options.run);
671
+ const openSpecText = state.mode === "openspec" && state.currentChange ? contextSafety.redactText(formatOpenSpecContext(await readOpenSpecContext(root, state.currentChange))) : "";
672
+ const errorLog = contextSafety.redactText(commandResult ? [`Command: ${commandResult.command}`, `Exit Code: ${commandResult.exitCode}`, commandResult.output].join("\n") : "No build/test command was run. Pass --run build or --run test to include command output.");
673
+ const content = renderTemplate(await loadTemplate("ASK_ADVISOR.template.md"), {
674
+ projectName: config.projectName,
675
+ runId: state.currentRun,
676
+ lane: state.currentLane,
677
+ branch: git2.branch,
678
+ ignoreRulesStatus: contextSafety.ignoreRulesStatus,
679
+ redactionRulesStatus: contextSafety.redactionRulesStatus,
680
+ currentTask: [currentTask || "(missing EXECUTOR_TASK.md)", openSpecText].filter(Boolean).join("\n\n"),
681
+ errorLog,
682
+ gitStatus: git2.status || "(clean)",
683
+ gitDiffStat: git2.diffStat || "(no diff)"
684
+ });
685
+ const relative = path9.relative(root, path9.join(run.laneDir, "ASK_ADVISOR.md"));
686
+ const file = await safeWriteFile(root, relative, content, { force: options.force });
687
+ const copied = options.copy ? await copyToClipboard(content) : void 0;
688
+ return {
689
+ file,
690
+ summary: [`Created ${path9.relative(root, file)}`, copied === false ? "Clipboard copy failed; file was still generated." : ""].filter(Boolean).join("\n")
691
+ };
692
+ }
693
+ function requireCurrentRun(root, config, runId, lane) {
694
+ if (!runId) {
695
+ throw new Error("No current run. Run relay start first.");
696
+ }
697
+ return getRunContext(root, config, runId, lane);
698
+ }
699
+ async function maybeRunCommand(root, config, target) {
700
+ if (!target) {
701
+ return void 0;
702
+ }
703
+ const command = target === "build" ? config.buildCommand : config.testCommand;
704
+ if (!command) {
705
+ throw new Error(`No ${target} command configured or detected.`);
706
+ }
707
+ const contextSafety = await createContextSafety(root, config);
708
+ return runShellCommand(root, command, {
709
+ maxLogLines: config.maxLogLines,
710
+ redactText: contextSafety.redactText
711
+ });
712
+ }
713
+
714
+ // src/commands/doctor.ts
715
+ import path11 from "path";
716
+
717
+ // src/core/project.ts
718
+ import fs4 from "fs/promises";
719
+ import path10 from "path";
720
+ async function readPackageScripts(root) {
721
+ const packageJsonPath = path10.join(root, "package.json");
722
+ if (!await pathExists(packageJsonPath)) {
723
+ return {};
724
+ }
725
+ const packageJson = JSON.parse(stripBom(await fs4.readFile(packageJsonPath, "utf8")));
726
+ return packageJson.scripts ?? {};
727
+ }
728
+ async function detectPackageManager(root) {
729
+ if (await pathExists(path10.join(root, "pnpm-lock.yaml"))) return "pnpm";
730
+ if (await pathExists(path10.join(root, "package-lock.json"))) return "npm";
731
+ if (await pathExists(path10.join(root, "yarn.lock"))) return "yarn";
732
+ if (await pathExists(path10.join(root, "bun.lockb"))) return "bun";
733
+ return "unknown";
734
+ }
735
+ async function detectProject(root = process.cwd()) {
736
+ const resolvedRoot = path10.resolve(root);
737
+ const packageJsonPath = path10.join(resolvedRoot, "package.json");
738
+ const packageJson = await pathExists(packageJsonPath) ? JSON.parse(stripBom(await fs4.readFile(packageJsonPath, "utf8"))) : {};
739
+ return {
740
+ root: resolvedRoot,
741
+ name: packageJson.name ?? path10.basename(resolvedRoot),
742
+ hasGit: await pathExists(path10.join(resolvedRoot, ".git")),
743
+ hasPackageJson: await pathExists(packageJsonPath),
744
+ packageManager: await detectPackageManager(resolvedRoot),
745
+ packageScripts: await readPackageScripts(resolvedRoot),
746
+ hasOpenSpec: await hasOpenSpecStructure(resolvedRoot)
747
+ };
748
+ }
749
+ async function hasOpenSpecStructure(root) {
750
+ return await pathExists(path10.join(root, "openspec")) && (await pathExists(path10.join(root, "openspec", "changes")) || await pathExists(path10.join(root, "openspec", "specs")));
751
+ }
752
+
753
+ // src/commands/doctor.ts
754
+ function registerDoctorCommand(program2) {
755
+ program2.command("doctor").description("Check relay-kit project integration status.").action(async () => {
756
+ console.log(await runDoctor(process.cwd()));
757
+ });
758
+ }
759
+ async function runDoctor(root) {
760
+ const project = await detectProject(root);
761
+ const checks = [];
762
+ const configExists = await pathExists(path11.join(root, CONFIG_FILE));
763
+ const stateExists = await pathExists(path11.join(root, STATE_FILE));
764
+ checks.push(formatCheck(configExists, CONFIG_FILE, "Run relay init."));
765
+ checks.push(formatCheck(stateExists, STATE_FILE, "Run relay init."));
766
+ const agents = await readTextIfExists(path11.join(root, "AGENTS.md"));
767
+ checks.push(formatCheck(agents.includes(AGENTS_START_MARKER), "AGENTS.md relay-kit block", "Run relay init --force."));
768
+ if (configExists && stateExists) {
769
+ const config = await loadConfig(root);
770
+ const state = await loadState(root);
771
+ checks.push(formatCheck(await pathExists(path11.join(root, config.handoffDir, "runs")), `${config.handoffDir}/runs`, "Run relay init."));
772
+ for (const target of [".relay/skills", ".claude/skills", ".agents/skills"]) {
773
+ for (const skill of DEFAULT_SKILLS) {
774
+ checks.push(formatCheck(await pathExists(path11.join(root, target, skill, "SKILL.md")), `${target}/${skill}`, "Run relay init --force."));
775
+ }
776
+ }
777
+ if (state.currentRun) {
778
+ const run = getRunContext(root, config, state.currentRun, state.currentLane);
779
+ checks.push(formatCheck(await pathExists(run.laneDir), `current lane ${path11.relative(root, run.laneDir)}`, "Run relay start."));
780
+ checks.push(formatPending(!await pathExists(path11.join(run.laneDir, "ASK_ADVISOR.md")), "ASK_ADVISOR.md", "Advisor response may be needed."));
781
+ checks.push(formatPending(!await pathExists(path11.join(run.laneDir, "ADVISOR_DECISION.md")), "ADVISOR_DECISION.md", "Run relay resume if ready."));
782
+ }
783
+ }
784
+ checks.push(formatCheck(project.hasPackageJson, "package.json", "CLI works without it, but build/test detection will be limited."));
785
+ checks.push(formatCheck(project.packageScripts.build !== void 0, "package script: build", "Add a build script to enable relay ask --run build."));
786
+ checks.push(formatCheck(project.packageScripts.test !== void 0, "package script: test", "Add a test script to enable relay ask --run test."));
787
+ if (project.hasOpenSpec) {
788
+ const changes = await listOpenSpecChanges(root);
789
+ checks.push(`ok OpenSpec changes: ${changes.length ? changes.join(", ") : "(none)"}`);
790
+ } else {
791
+ checks.push("warn OpenSpec: not detected; simple mode is supported.");
792
+ }
793
+ return ["relay doctor", ...checks].join("\n");
794
+ }
795
+ function formatCheck(ok, label, fix) {
796
+ return ok ? `ok ${label}` : `warn ${label} - ${fix}`;
797
+ }
798
+ function formatPending(clear, fileName, fix) {
799
+ return clear ? `ok no pending ${fileName}` : `warn pending ${fileName} - ${fix}`;
800
+ }
801
+
802
+ // src/commands/init.ts
803
+ import fs5 from "fs/promises";
804
+ import path13 from "path";
805
+
806
+ // src/core/skills.ts
807
+ import path12 from "path";
808
+ async function installProjectSkills(root, config, options = {}) {
809
+ const sourceRoot = path12.join(getPackageRoot(), "skills");
810
+ const targets = [];
811
+ if (config.skills.install.manager) targets.push(path12.join(root, ".relay", "skills"));
812
+ if (config.skills.install.claudeProject) targets.push(path12.join(root, ".claude", "skills"));
813
+ if (config.skills.install.codexProject) targets.push(path12.join(root, ".agents", "skills"));
814
+ for (const targetRoot of targets) {
815
+ await ensureDir(targetRoot);
816
+ for (const skill of DEFAULT_SKILLS) {
817
+ await copyDirectory(path12.join(sourceRoot, skill), path12.join(targetRoot, skill), options);
818
+ }
819
+ }
820
+ return targets;
821
+ }
822
+
823
+ // src/commands/init.ts
824
+ function registerInitCommand(program2) {
825
+ program2.command("init").description("Initialize relay-kit in the current project.").option("--mode <mode>", "Initialization mode: simple or openspec.").option("--with-openspec", "Show OpenSpec setup guidance without silently creating openspec/.").option("--yes", "Accept non-destructive defaults.").option("--force", "Overwrite relay-kit managed files.").action(async (options) => {
826
+ const result = await runInit(process.cwd(), options);
827
+ console.log(result.summary);
828
+ });
829
+ }
830
+ async function runInit(root, options = {}) {
831
+ const project = await detectProject(root);
832
+ const mode = resolveMode(project.hasOpenSpec, options.mode);
833
+ if (options.mode === "openspec" && !project.hasOpenSpec) {
834
+ throw new Error("Cannot use --mode openspec because openspec/ was not detected.");
835
+ }
836
+ const config = createDefaultConfig(project, mode);
837
+ const state = createDefaultState(mode);
838
+ await ensureDir(path13.join(root, ".relay"));
839
+ if (!options.force && (await pathExists(path13.join(root, CONFIG_FILE)) || await pathExists(path13.join(root, STATE_FILE)))) {
840
+ throw new Error("relay-kit is already initialized. Use --force to rewrite managed config/state files.");
841
+ }
842
+ await writeConfig(root, config);
843
+ await writeState(root, state);
844
+ await ensureDir(path13.join(root, DEFAULT_HANDOFF_DIR, "runs"));
845
+ const relayIgnoreStatus = await ensureRelayIgnore(root);
846
+ await injectAgentsRules(root, options.force);
847
+ const skillTargets = await installProjectSkills(root, config, { force: options.force });
848
+ const guidance = options.withOpenspec && !project.hasOpenSpec ? "\nOpenSpec was requested, but relay-kit did not create openspec/. Install/init OpenSpec explicitly, then rerun relay init --mode openspec --force." : "";
849
+ return {
850
+ summary: [
851
+ `relay init complete (${mode}).`,
852
+ `Config: ${CONFIG_FILE}`,
853
+ `State: ${STATE_FILE}`,
854
+ `Relay ignore: ${relayIgnoreStatus}`,
855
+ `Skills: ${skillTargets.map((target) => path13.relative(root, target)).join(", ")}`,
856
+ guidance
857
+ ].filter(Boolean).join("\n")
858
+ };
859
+ }
860
+ async function ensureRelayIgnore(root) {
861
+ if (await pathExists(path13.join(root, RELAYIGNORE_FILE))) {
862
+ return `${RELAYIGNORE_FILE} (kept existing)`;
863
+ }
864
+ await safeWriteFile(root, RELAYIGNORE_FILE, DEFAULT_RELAYIGNORE_CONTENT);
865
+ return RELAYIGNORE_FILE;
866
+ }
867
+ function resolveMode(hasOpenSpec, requested) {
868
+ if (requested && requested !== "simple" && requested !== "openspec") {
869
+ throw new Error("--mode must be simple or openspec.");
870
+ }
871
+ return requested ?? (hasOpenSpec ? "openspec" : "simple");
872
+ }
873
+ async function injectAgentsRules(root, force = false) {
874
+ const agentsPath = path13.join(root, "AGENTS.md");
875
+ const existing = await readTextIfExists(agentsPath);
876
+ const block = (await loadTemplate("AGENTS.relay.md")).trim();
877
+ if (existing.includes(AGENTS_START_MARKER) && existing.includes(AGENTS_END_MARKER)) {
878
+ if (!force) {
879
+ return;
880
+ }
881
+ const pattern = new RegExp(`${escapeRegExp(AGENTS_START_MARKER)}[\\s\\S]*?${escapeRegExp(AGENTS_END_MARKER)}`);
882
+ await fs5.writeFile(agentsPath, `${existing.replace(pattern, block).trim()}
883
+ `, "utf8");
884
+ return;
885
+ }
886
+ const next = existing.trim() ? `${existing.trim()}
887
+
888
+ ${block}
889
+ ` : `${block}
890
+ `;
891
+ await safeWriteFile(root, "AGENTS.md", next, { force: true });
892
+ }
893
+ function escapeRegExp(value) {
894
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
895
+ }
896
+
897
+ // src/commands/resume.ts
898
+ import path14 from "path";
899
+ function registerResumeCommand(program2) {
900
+ program2.command("resume").description("Create a resume prompt from a relay decision.").option("--from <path>", "Read Advisor decision from a specific file.").option("--copy", "Copy generated content to clipboard.").option("--force", "Overwrite RESUME_PROMPT.md.").action(async (options) => {
901
+ const result = await runResume(process.cwd(), options);
902
+ console.log(result.summary);
903
+ });
904
+ }
905
+ async function runResume(root, options = {}) {
906
+ const config = await loadConfig(root);
907
+ const state = await loadState(root);
908
+ if (!state.currentRun) {
909
+ throw new Error("No current run. Run relay start first.");
910
+ }
911
+ const run = getRunContext(root, config, state.currentRun, state.currentLane);
912
+ const sourcePath = options.from ? path14.resolve(root, options.from) : path14.join(run.laneDir, "ADVISOR_DECISION.md");
913
+ const decision = await readTextIfExists(sourcePath);
914
+ if (!decision.trim()) {
915
+ throw new Error(`Decision is missing or empty: ${path14.relative(root, sourcePath)}`);
916
+ }
917
+ const prompt = extractPromptForExecutor(decision);
918
+ if (!prompt.trim()) {
919
+ throw new Error("Decision does not contain a non-empty 'Prompt For Executor' section.");
920
+ }
921
+ const content = renderTemplate(await loadTemplate("RESUME_PROMPT.template.md"), {
922
+ promptForExecutor: prompt
923
+ });
924
+ const relative = path14.relative(root, path14.join(run.laneDir, "RESUME_PROMPT.md"));
925
+ const file = await safeWriteFile(root, relative, content, { force: options.force });
926
+ const copied = options.copy ? await copyToClipboard(content) : void 0;
927
+ return {
928
+ file,
929
+ summary: [`Created ${path14.relative(root, file)}`, copied === false ? "Clipboard copy failed; file was still generated." : ""].filter(Boolean).join("\n")
930
+ };
931
+ }
932
+ function extractPromptForExecutor(decision) {
933
+ const heading = /^## Prompt For Executor\s*$/im.exec(decision);
934
+ if (!heading) {
935
+ return "";
936
+ }
937
+ const bodyStart = heading.index + heading[0].length;
938
+ const rest = decision.slice(bodyStart);
939
+ const nextHeading = /^##\s.+$/im.exec(rest);
940
+ return rest.slice(0, nextHeading?.index).trim();
941
+ }
942
+
943
+ // src/commands/review.ts
944
+ import path15 from "path";
945
+ function registerReviewCommand(program2) {
946
+ program2.command("review").description("Create a relay review request for the current implementation.").option("--copy", "Copy generated content to clipboard.").option("--force", "Overwrite REVIEW_REQUEST.md.").action(async (options) => {
947
+ const result = await runReview(process.cwd(), options);
948
+ console.log(result.summary);
949
+ });
950
+ }
951
+ async function runReview(root, options = {}) {
952
+ const config = await loadConfig(root);
953
+ const state = await loadState(root);
954
+ if (!state.currentRun) {
955
+ throw new Error("No current run. Run relay start first.");
956
+ }
957
+ const run = getRunContext(root, config, state.currentRun, state.currentLane);
958
+ const contextSafety = await createContextSafety(root, config);
959
+ const git2 = await collectGitContext(root, {
960
+ maxDiffLines: config.maxDiffLines,
961
+ gitExcludePathspecs: contextSafety.ignore.gitExcludePathspecs,
962
+ shouldIgnorePath: contextSafety.shouldIgnorePath,
963
+ redactText: contextSafety.redactText
964
+ });
965
+ const task = contextSafety.redactText(await readTextIfExists(path15.join(run.laneDir, "EXECUTOR_TASK.md")));
966
+ const openSpecText = state.mode === "openspec" && state.currentChange ? contextSafety.redactText(formatOpenSpecContext(await readOpenSpecContext(root, state.currentChange))) : "";
967
+ const content = renderTemplate(await loadTemplate("REVIEW_REQUEST.template.md"), {
968
+ projectName: config.projectName,
969
+ runId: state.currentRun,
970
+ lane: state.currentLane,
971
+ ignoreRulesStatus: contextSafety.ignoreRulesStatus,
972
+ redactionRulesStatus: contextSafety.redactionRulesStatus,
973
+ gitDiffStat: git2.diffStat || "(no diff)",
974
+ gitDiff: [git2.diff || "(no diff)", task ? `
975
+
976
+ # Current Executor Task
977
+ ${task}` : "", openSpecText].filter(Boolean).join("\n")
978
+ });
979
+ const relative = path15.relative(root, path15.join(run.laneDir, "REVIEW_REQUEST.md"));
980
+ const file = await safeWriteFile(root, relative, content, { force: options.force });
981
+ const copied = options.copy ? await copyToClipboard(content) : void 0;
982
+ return {
983
+ file,
984
+ summary: [`Created ${path15.relative(root, file)}`, copied === false ? "Clipboard copy failed; file was still generated." : ""].filter(Boolean).join("\n")
985
+ };
986
+ }
987
+
988
+ // src/commands/start.ts
989
+ import path16 from "path";
990
+ import { createInterface } from "readline/promises";
991
+ import { stdin as input, stdout as output } from "process";
992
+ function registerStartCommand(program2) {
993
+ program2.command("start").description("Start a relay handoff run and create an executor task.").option("--title <title>", "Simple mode task title.").option("--scope <scope>", "Allowed implementation scope.").option("--blocked-scope <scope>", "Scope that must not be changed.").option("--change <change>", "OpenSpec change name.").option("--copy", "Copy generated handoff content to clipboard.").option("--force", "Overwrite generated run files.").action(async (options) => {
994
+ const result = await runStart(process.cwd(), options);
995
+ console.log(result.summary);
996
+ });
997
+ }
998
+ async function runStart(root, options = {}) {
999
+ const config = await loadConfig(root);
1000
+ const state = await loadState(root);
1001
+ const mode = state.mode || config.mode;
1002
+ const answers = mode === "simple" ? await collectSimpleInputs(options) : options;
1003
+ const title = answers.title || (mode === "openspec" ? "OpenSpec handoff" : "Untitled task");
1004
+ const runId = createRunId(title);
1005
+ const run = await createRunStructure(root, config, runId, DEFAULT_LANE, { force: options.force });
1006
+ let change = options.change || state.currentChange;
1007
+ let openSpecText = "";
1008
+ if (mode === "openspec") {
1009
+ change = await resolveOpenSpecChange(root, change || void 0);
1010
+ openSpecText = formatOpenSpecContext(await readOpenSpecContext(root, change));
1011
+ }
1012
+ const content = renderTemplate(await loadTemplate("EXECUTOR_TASK.template.md"), {
1013
+ projectName: config.projectName,
1014
+ runId,
1015
+ lane: DEFAULT_LANE,
1016
+ mode,
1017
+ change: change || "",
1018
+ taskTitle: title,
1019
+ allowedScope: [answers.scope || "Follow the current task only.", openSpecText].filter(Boolean).join("\n\n"),
1020
+ blockedScope: answers.blockedScope || "Do not expand scope beyond EXECUTOR_TASK.md."
1021
+ });
1022
+ const relative = path16.relative(root, path16.join(run.laneDir, "EXECUTOR_TASK.md"));
1023
+ const file = await safeWriteFile(root, relative, content, { force: options.force });
1024
+ await updateState(root, { currentRun: runId, currentLane: DEFAULT_LANE, mode, currentChange: change || "" });
1025
+ const copied = options.copy ? await copyToClipboard(content) : void 0;
1026
+ return {
1027
+ file,
1028
+ summary: [`Created ${path16.relative(root, file)}`, copied === false ? "Clipboard copy failed; file was still generated." : ""].filter(Boolean).join("\n")
1029
+ };
1030
+ }
1031
+ async function collectSimpleInputs(options) {
1032
+ if (options.title && options.scope && options.blockedScope) {
1033
+ return options;
1034
+ }
1035
+ if (!input.isTTY || !output.isTTY) {
1036
+ return options;
1037
+ }
1038
+ const rl = createInterface({ input, output });
1039
+ try {
1040
+ return {
1041
+ ...options,
1042
+ title: options.title || await rl.question("Task title: "),
1043
+ scope: options.scope || await rl.question("Allowed scope: "),
1044
+ blockedScope: options.blockedScope || await rl.question("Blocked scope: ")
1045
+ };
1046
+ } finally {
1047
+ rl.close();
1048
+ }
1049
+ }
1050
+
1051
+ // src/core/skills-sync.ts
1052
+ import crypto from "crypto";
1053
+ import fs6 from "fs/promises";
1054
+ import os from "os";
1055
+ import path17 from "path";
1056
+ var SkillSyncConflictError = class extends Error {
1057
+ constructor(report) {
1058
+ super("Skills sync encountered conflicts. Re-run with --force to overwrite conflicted files.");
1059
+ this.report = report;
1060
+ }
1061
+ report;
1062
+ };
1063
+ var MANIFEST_FILE = path17.join(RELAY_DIR, "skills-sync.json");
1064
+ var ACTIONS = ["create", "update", "unchanged", "conflict", "skip"];
1065
+ async function syncSkills(root, config, options = {}) {
1066
+ const sourceRoot = path17.join(root, RELAY_DIR, "skills");
1067
+ const scope = options.scope ?? "project";
1068
+ const targets = resolveSkillSyncTargets(root, config, { ...options, scope });
1069
+ const sources = await collectSkillSourceFiles(sourceRoot);
1070
+ const manifest = await readManifest(root);
1071
+ const report = await planSkillSync(sourceRoot, sources, targets, manifest, options.target ?? "configured", scope, options);
1072
+ if (!options.force && report.summary.conflict > 0) {
1073
+ throw new SkillSyncConflictError(report);
1074
+ }
1075
+ if (!options.dryRun) {
1076
+ await applySkillSync(report, sources, manifest);
1077
+ await writeJsonFile(path17.join(root, MANIFEST_FILE), manifest);
1078
+ }
1079
+ return report;
1080
+ }
1081
+ function resolveSkillSyncTargets(root, config, options = {}) {
1082
+ const scope = options.scope ?? "project";
1083
+ const homeDir = options.homeDir ?? os.homedir();
1084
+ const tools = resolveTools(config, options.target, scope);
1085
+ return tools.map((tool) => ({
1086
+ tool,
1087
+ scope,
1088
+ root: resolveTargetRoot(root, homeDir, tool, scope)
1089
+ }));
1090
+ }
1091
+ function formatSkillSyncReport(report) {
1092
+ const targetLabels = report.targets.map((target) => pathLabel(target.root)).join(", ") || "(none)";
1093
+ const lines = [
1094
+ "relay sync --skills",
1095
+ `source: ${pathLabel(report.sourceRoot)}`,
1096
+ `scope: ${report.scope}`,
1097
+ `target: ${report.targetOption}`,
1098
+ `dry-run: ${report.dryRun ? "yes" : "no"}`,
1099
+ `force: ${report.force ? "yes" : "no"}`,
1100
+ `targets: ${targetLabels}`,
1101
+ `summary: create ${report.summary.create}, update ${report.summary.update}, unchanged ${report.summary.unchanged}, conflict ${report.summary.conflict}, skip ${report.summary.skip}`
1102
+ ];
1103
+ if (report.entries.length) {
1104
+ lines.push("files:");
1105
+ for (const entry of report.entries) {
1106
+ lines.push(`- ${entry.action} ${entry.tool}/${entry.scope} ${entry.skill}/${entry.relativePath} -> ${pathLabel(entry.targetPath)} (${entry.reason})`);
1107
+ }
1108
+ }
1109
+ if (report.summary.conflict > 0) {
1110
+ lines.push("conflicts:");
1111
+ for (const entry of report.entries.filter((item) => item.action === "conflict")) {
1112
+ lines.push(`- ${pathLabel(entry.targetPath)}`);
1113
+ }
1114
+ lines.push("Use --force to overwrite conflicted files after reviewing them.");
1115
+ }
1116
+ return lines.join("\n");
1117
+ }
1118
+ function resolveTools(config, target, scope) {
1119
+ if (target) {
1120
+ if (target === "all") {
1121
+ return ["claude", "codex"];
1122
+ }
1123
+ return [target];
1124
+ }
1125
+ if (scope === "project") {
1126
+ return [
1127
+ config.skills.install.claudeProject ? "claude" : void 0,
1128
+ config.skills.install.codexProject ? "codex" : void 0
1129
+ ].filter((tool) => Boolean(tool));
1130
+ }
1131
+ const tools = [
1132
+ config.skills.install.claudeUser ? "claude" : void 0,
1133
+ config.skills.install.codexUser ? "codex" : void 0
1134
+ ].filter((tool) => Boolean(tool));
1135
+ if (tools.length === 0) {
1136
+ throw new Error("No user-level skills targets are enabled. Pass --target claude, --target codex, or --target all with --scope user.");
1137
+ }
1138
+ return tools;
1139
+ }
1140
+ function resolveTargetRoot(root, homeDir, tool, scope) {
1141
+ if (scope === "user") {
1142
+ return path17.join(homeDir, tool === "claude" ? ".claude" : ".agents", "skills");
1143
+ }
1144
+ return path17.join(root, tool === "claude" ? ".claude" : ".agents", "skills");
1145
+ }
1146
+ async function collectSkillSourceFiles(sourceRoot) {
1147
+ if (!await pathExists(sourceRoot)) {
1148
+ throw new Error("Missing .relay/skills. Run relay init first.");
1149
+ }
1150
+ const entries = await fs6.readdir(sourceRoot, { withFileTypes: true });
1151
+ const files = [];
1152
+ for (const entry of entries) {
1153
+ if (!entry.isDirectory() || !entry.name.startsWith("relay-")) {
1154
+ continue;
1155
+ }
1156
+ const skillRoot = path17.join(sourceRoot, entry.name);
1157
+ if (!await pathExists(path17.join(skillRoot, "SKILL.md"))) {
1158
+ continue;
1159
+ }
1160
+ files.push(...await collectFiles(skillRoot, entry.name, ""));
1161
+ }
1162
+ if (files.length === 0) {
1163
+ throw new Error("No relay skills found in .relay/skills.");
1164
+ }
1165
+ return files;
1166
+ }
1167
+ async function collectFiles(skillRoot, skill, relativeDir) {
1168
+ const dir = path17.join(skillRoot, relativeDir);
1169
+ const entries = await fs6.readdir(dir, { withFileTypes: true });
1170
+ const files = [];
1171
+ for (const entry of entries) {
1172
+ const relativePath = path17.join(relativeDir, entry.name);
1173
+ const absolutePath = path17.join(skillRoot, relativePath);
1174
+ if (entry.isDirectory()) {
1175
+ files.push(...await collectFiles(skillRoot, skill, relativePath));
1176
+ continue;
1177
+ }
1178
+ if (!entry.isFile()) {
1179
+ continue;
1180
+ }
1181
+ const content = await fs6.readFile(absolutePath);
1182
+ files.push({
1183
+ skill,
1184
+ relativePath,
1185
+ absolutePath,
1186
+ content,
1187
+ hash: hashBuffer(content)
1188
+ });
1189
+ }
1190
+ return files;
1191
+ }
1192
+ async function planSkillSync(sourceRoot, sources, targets, manifest, targetOption, scope, options) {
1193
+ const entries = [];
1194
+ const summary = createEmptySummary();
1195
+ for (const target of targets) {
1196
+ for (const source of sources) {
1197
+ const targetPath = path17.join(target.root, source.skill, source.relativePath);
1198
+ const recordKey = manifestKey(target, source.skill, source.relativePath);
1199
+ const record = manifest.records[recordKey];
1200
+ const targetExists = await pathExists(targetPath);
1201
+ const targetHash = targetExists ? hashBuffer(await fs6.readFile(targetPath)) : "";
1202
+ const action = resolveAction(source.hash, targetExists, targetHash, record, Boolean(options.force));
1203
+ const reason = actionReason(action, targetExists, record, Boolean(options.force));
1204
+ entries.push({
1205
+ action,
1206
+ tool: target.tool,
1207
+ scope: target.scope,
1208
+ skill: source.skill,
1209
+ relativePath: normalizeRelative(source.relativePath),
1210
+ sourcePath: source.absolutePath,
1211
+ targetPath,
1212
+ reason
1213
+ });
1214
+ summary[action] += 1;
1215
+ }
1216
+ }
1217
+ return {
1218
+ sourceRoot,
1219
+ targetOption,
1220
+ scope,
1221
+ dryRun: Boolean(options.dryRun),
1222
+ force: Boolean(options.force),
1223
+ targets,
1224
+ entries,
1225
+ summary
1226
+ };
1227
+ }
1228
+ async function applySkillSync(report, sources, manifest) {
1229
+ const sourceByKey = new Map(sources.map((source) => [`${source.skill}/${normalizeRelative(source.relativePath)}`, source]));
1230
+ const syncedAt = (/* @__PURE__ */ new Date()).toISOString();
1231
+ for (const entry of report.entries) {
1232
+ const source = sourceByKey.get(`${entry.skill}/${entry.relativePath}`);
1233
+ if (!source) {
1234
+ continue;
1235
+ }
1236
+ if (entry.action === "create" || entry.action === "update") {
1237
+ await ensureDir(path17.dirname(entry.targetPath));
1238
+ await fs6.writeFile(entry.targetPath, source.content);
1239
+ }
1240
+ if (entry.action === "create" || entry.action === "update" || entry.action === "unchanged") {
1241
+ manifest.records[manifestKey(entry, entry.skill, entry.relativePath)] = {
1242
+ sourceHash: source.hash,
1243
+ targetHash: source.hash,
1244
+ syncedAt
1245
+ };
1246
+ }
1247
+ }
1248
+ }
1249
+ function resolveAction(sourceHash, targetExists, targetHash, record, force) {
1250
+ if (!targetExists) {
1251
+ return "create";
1252
+ }
1253
+ if (targetHash === sourceHash) {
1254
+ return "unchanged";
1255
+ }
1256
+ if (record?.targetHash === targetHash || force) {
1257
+ return "update";
1258
+ }
1259
+ return "conflict";
1260
+ }
1261
+ function actionReason(action, targetExists, record, force) {
1262
+ if (action === "create") return "target file does not exist";
1263
+ if (action === "unchanged") return record ? "target already matches source" : "target matches source; recording sync state";
1264
+ if (action === "update") return force ? "force enabled" : "target unchanged since last sync";
1265
+ if (action === "conflict") return targetExists ? "target differs and is not safe to overwrite" : "not applicable";
1266
+ return "skipped";
1267
+ }
1268
+ async function readManifest(root) {
1269
+ const manifest = await readJsonIfExists(path17.join(root, MANIFEST_FILE));
1270
+ if (!manifest || manifest.version !== 1 || typeof manifest.records !== "object") {
1271
+ return { version: 1, records: {} };
1272
+ }
1273
+ return manifest;
1274
+ }
1275
+ function manifestKey(target, skill, relativePath) {
1276
+ return `${target.scope}:${target.tool}:${skill}/${normalizeRelative(relativePath)}`;
1277
+ }
1278
+ function hashBuffer(value) {
1279
+ return `sha256:${crypto.createHash("sha256").update(value).digest("hex")}`;
1280
+ }
1281
+ function createEmptySummary() {
1282
+ return Object.fromEntries(ACTIONS.map((action) => [action, 0]));
1283
+ }
1284
+ function normalizeRelative(value) {
1285
+ return value.replace(/\\/g, "/");
1286
+ }
1287
+ function pathLabel(value) {
1288
+ return value.replace(/\\/g, "/");
1289
+ }
1290
+
1291
+ // src/commands/sync.ts
1292
+ function registerSyncCommand(program2) {
1293
+ program2.command("sync").description("Synchronize relay-kit managed resources.").option("--skills", "Synchronize relay Skills from .relay/skills.").option("--target <target>", "Skill target: claude, codex, or all.").option("--scope <scope>", "Skill sync scope: project or user.", "project").option("--dry-run", "Preview the sync plan without writing files.").option("--force", "Overwrite conflicted skill files.").action(async (options) => {
1294
+ const result = await runSync(process.cwd(), options);
1295
+ console.log(result.summary);
1296
+ });
1297
+ }
1298
+ async function runSync(root, options = {}) {
1299
+ if (!options.skills) {
1300
+ throw new Error("relay sync currently supports only --skills.");
1301
+ }
1302
+ validateTarget(options.target);
1303
+ validateScope(options.scope);
1304
+ const config = await loadConfig(root);
1305
+ try {
1306
+ const report = await syncSkills(root, config, options);
1307
+ return { summary: formatSkillSyncReport(report) };
1308
+ } catch (error) {
1309
+ if (error instanceof SkillSyncConflictError) {
1310
+ throw new Error(`${formatSkillSyncReport(error.report)}
1311
+ ${error.message}`);
1312
+ }
1313
+ throw error;
1314
+ }
1315
+ }
1316
+ function validateTarget(target) {
1317
+ if (target === void 0 || target === "claude" || target === "codex" || target === "all") {
1318
+ return;
1319
+ }
1320
+ throw new Error("--target must be claude, codex, or all.");
1321
+ }
1322
+ function validateScope(scope) {
1323
+ if (scope === void 0 || scope === "project" || scope === "user") {
1324
+ return;
1325
+ }
1326
+ throw new Error("--scope must be project or user.");
1327
+ }
1328
+
1329
+ // src/cli.ts
1330
+ var program = new Command();
1331
+ program.name("relay").description("Skills-first, CLI-assisted AI programming relay workflow toolkit.").version("0.2.0");
1332
+ registerInitCommand(program);
1333
+ registerStartCommand(program);
1334
+ registerAskCommand(program);
1335
+ registerResumeCommand(program);
1336
+ registerReviewCommand(program);
1337
+ registerDoctorCommand(program);
1338
+ registerSyncCommand(program);
1339
+ program.parseAsync(process.argv).catch((error) => {
1340
+ console.error(error instanceof Error ? error.message : String(error));
1341
+ process.exitCode = 1;
1342
+ });
1343
+ //# sourceMappingURL=cli.js.map