relay-kit 0.2.0 → 0.3.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.
Files changed (38) hide show
  1. package/.claude/commands/opsx/apply.md +152 -0
  2. package/.claude/commands/opsx/archive.md +157 -0
  3. package/.claude/commands/opsx/explore.md +173 -0
  4. package/.claude/commands/opsx/propose.md +106 -0
  5. package/.claude/skills/openspec-apply-change/SKILL.md +156 -0
  6. package/.claude/skills/openspec-archive-change/SKILL.md +114 -0
  7. package/.claude/skills/openspec-explore/SKILL.md +288 -0
  8. package/.claude/skills/openspec-propose/SKILL.md +110 -0
  9. package/.claude/skills/relay-delegator/SKILL.md +55 -0
  10. package/.claude/skills/relay-escalation/SKILL.md +47 -0
  11. package/.claude/skills/relay-planner/SKILL.md +64 -0
  12. package/.claude/skills/relay-reviewer/SKILL.md +59 -0
  13. package/.codex/skills/openspec-apply-change/SKILL.md +156 -0
  14. package/.codex/skills/openspec-archive-change/SKILL.md +114 -0
  15. package/.codex/skills/openspec-explore/SKILL.md +288 -0
  16. package/.codex/skills/openspec-propose/SKILL.md +110 -0
  17. package/.opencode/commands/opsx-apply.md +149 -0
  18. package/.opencode/commands/opsx-archive.md +154 -0
  19. package/.opencode/commands/opsx-explore.md +170 -0
  20. package/.opencode/commands/opsx-propose.md +103 -0
  21. package/.opencode/skills/openspec-apply-change/SKILL.md +156 -0
  22. package/.opencode/skills/openspec-archive-change/SKILL.md +114 -0
  23. package/.opencode/skills/openspec-explore/SKILL.md +288 -0
  24. package/.opencode/skills/openspec-propose/SKILL.md +110 -0
  25. package/CHANGELOG.md +33 -1
  26. package/README.en.md +247 -0
  27. package/README.md +202 -190
  28. package/dist/cli.js +1000 -215
  29. package/dist/cli.js.map +1 -1
  30. package/docs/CLI_SPEC.md +108 -53
  31. package/docs/FINAL_DESIGN.md +18 -16
  32. package/docs/OPENSPEC_INTEGRATION.md +60 -30
  33. package/docs/SKILLS_INSTALLATION.md +46 -4
  34. package/docs/USAGE_FLOW.md +47 -58
  35. package/package.json +15 -9
  36. package/skills/relay-delegator/SKILL.md +14 -2
  37. package/skills/relay-planner/SKILL.md +12 -0
  38. package/skills/relay-runner/SKILL.md +15 -2
package/dist/cli.js CHANGED
@@ -1,75 +1,64 @@
1
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";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropNames = Object.getOwnPropertyNames;
4
+ var __esm = (fn, res) => function __init() {
5
+ return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
6
+ };
7
+ var __export = (target, all) => {
8
+ for (var name in all)
9
+ __defProp(target, name, { get: all[name], enumerable: true });
10
+ };
32
11
 
33
12
  // 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
13
+ var RELAY_DIR, RELAYIGNORE_FILE, CONFIG_FILE, STATE_FILE, DEFAULT_HANDOFF_DIR, DEFAULT_LANE, DEFAULT_MAX_DIFF_LINES, DEFAULT_MAX_LOG_LINES, AGENTS_START_MARKER, AGENTS_END_MARKER, OPENSPEC_DIR, OPENSPEC_CHANGES_DIR, OPENSPEC_SPECS_DIR, OPENSPEC_ARCHIVE_DIR, OPENSPEC_CONFIG_FILE, OPENSPEC_DEFAULT_SCHEMA, DEFAULT_SKILLS, EXCLUDED_NAMES, EXCLUDED_GLOBS, DEFAULT_RELAYIGNORE_CONTENT;
14
+ var init_constants = __esm({
15
+ "src/core/constants.ts"() {
16
+ "use strict";
17
+ RELAY_DIR = ".relay";
18
+ RELAYIGNORE_FILE = ".relayignore";
19
+ CONFIG_FILE = `${RELAY_DIR}/config.json`;
20
+ STATE_FILE = `${RELAY_DIR}/state.json`;
21
+ DEFAULT_HANDOFF_DIR = "docs/agent-handoffs";
22
+ DEFAULT_LANE = "main";
23
+ DEFAULT_MAX_DIFF_LINES = 500;
24
+ DEFAULT_MAX_LOG_LINES = 160;
25
+ AGENTS_START_MARKER = "<!-- relay-kit:start -->";
26
+ AGENTS_END_MARKER = "<!-- relay-kit:end -->";
27
+ OPENSPEC_DIR = "openspec";
28
+ OPENSPEC_CHANGES_DIR = `${OPENSPEC_DIR}/changes`;
29
+ OPENSPEC_SPECS_DIR = `${OPENSPEC_DIR}/specs`;
30
+ OPENSPEC_ARCHIVE_DIR = `${OPENSPEC_DIR}/changes/archive`;
31
+ OPENSPEC_CONFIG_FILE = ".openspec.yaml";
32
+ OPENSPEC_DEFAULT_SCHEMA = "spec-driven";
33
+ DEFAULT_SKILLS = [
34
+ "relay-planner",
35
+ "relay-delegator",
36
+ "relay-escalation",
37
+ "relay-reviewer"
38
+ ];
39
+ EXCLUDED_NAMES = /* @__PURE__ */ new Set([
40
+ ".env",
41
+ ".git",
42
+ "node_modules",
43
+ "dist",
44
+ "build",
45
+ "coverage"
46
+ ]);
47
+ EXCLUDED_GLOBS = [
48
+ ".env",
49
+ ".env.*",
50
+ "node_modules/",
51
+ "dist/",
52
+ "build/",
53
+ "coverage/",
54
+ ".git/",
55
+ "*.pem",
56
+ "*.key",
57
+ "*.crt",
58
+ "*.p12",
59
+ "*.log"
60
+ ];
61
+ DEFAULT_RELAYIGNORE_CONTENT = `# relay-kit context ignore rules
73
62
  .env
74
63
  .env.*
75
64
  node_modules/
@@ -83,8 +72,11 @@ coverage/
83
72
  *.p12
84
73
  *.log
85
74
  `;
75
+ }
76
+ });
86
77
 
87
78
  // src/core/excludes.ts
79
+ import path from "path";
88
80
  function normalizePath(value) {
89
81
  return value.replace(/\\/g, "/");
90
82
  }
@@ -114,8 +106,647 @@ function assertInsideRoot(root, target) {
114
106
  }
115
107
  throw new Error(`Refusing to write outside project root: ${target}`);
116
108
  }
109
+ var init_excludes = __esm({
110
+ "src/core/excludes.ts"() {
111
+ "use strict";
112
+ init_constants();
113
+ }
114
+ });
115
+
116
+ // src/core/fs.ts
117
+ import { constants as fsConstants } from "fs";
118
+ import fs from "fs/promises";
119
+ import path2 from "path";
120
+ async function pathExists(filePath) {
121
+ try {
122
+ await fs.access(filePath, fsConstants.F_OK);
123
+ return true;
124
+ } catch {
125
+ return false;
126
+ }
127
+ }
128
+ async function ensureDir(dirPath) {
129
+ await fs.mkdir(dirPath, { recursive: true });
130
+ }
131
+ async function readTextIfExists(filePath) {
132
+ if (!await pathExists(filePath)) {
133
+ return "";
134
+ }
135
+ return fs.readFile(filePath, "utf8");
136
+ }
137
+ async function readJsonIfExists(filePath) {
138
+ if (!await pathExists(filePath)) {
139
+ return void 0;
140
+ }
141
+ return JSON.parse(stripBom(await fs.readFile(filePath, "utf8")));
142
+ }
143
+ async function writeJsonFile(filePath, value) {
144
+ await ensureDir(path2.dirname(filePath));
145
+ await fs.writeFile(filePath, `${JSON.stringify(value, null, 2)}
146
+ `, "utf8");
147
+ }
148
+ async function safeWriteFile(root, relativePath, content, options = {}) {
149
+ const target = assertInsideRoot(root, relativePath);
150
+ await ensureDir(path2.dirname(target));
151
+ if (!options.force && await pathExists(target)) {
152
+ throw new Error(`Refusing to overwrite existing file without --force: ${relativePath}`);
153
+ }
154
+ await fs.writeFile(target, content, "utf8");
155
+ return target;
156
+ }
157
+ async function copyDirectory(source, target, options = {}) {
158
+ if (!await pathExists(source)) {
159
+ throw new Error(`Missing source directory: ${source}`);
160
+ }
161
+ await ensureDir(target);
162
+ const entries = await fs.readdir(source, { withFileTypes: true });
163
+ for (const entry of entries) {
164
+ const sourcePath = path2.join(source, entry.name);
165
+ const targetPath = path2.join(target, entry.name);
166
+ if (entry.isDirectory()) {
167
+ await copyDirectory(sourcePath, targetPath, options);
168
+ continue;
169
+ }
170
+ if (!entry.isFile()) {
171
+ continue;
172
+ }
173
+ if (!options.force && await pathExists(targetPath)) {
174
+ continue;
175
+ }
176
+ await fs.copyFile(sourcePath, targetPath);
177
+ }
178
+ }
179
+ function stripBom(value) {
180
+ return value.charCodeAt(0) === 65279 ? value.slice(1) : value;
181
+ }
182
+ var init_fs = __esm({
183
+ "src/core/fs.ts"() {
184
+ "use strict";
185
+ init_excludes();
186
+ }
187
+ });
188
+
189
+ // src/core/schema.ts
190
+ function getSchema(name) {
191
+ return BUILTIN_SCHEMAS[name];
192
+ }
193
+ function getAvailableSchemas() {
194
+ return Object.values(BUILTIN_SCHEMAS);
195
+ }
196
+ function getArtifact(schema, artifactId) {
197
+ return schema.artifacts.find((a) => a.id === artifactId);
198
+ }
199
+ function getArtifactTemplate(templateFile) {
200
+ return ARTIFACT_TEMPLATES[templateFile] ?? `<!-- Template not found: ${templateFile} -->`;
201
+ }
202
+ function getApplyInstruction(schema) {
203
+ return schema.apply.instruction;
204
+ }
205
+ function getApplyRequires(schema) {
206
+ return schema.apply.requires;
207
+ }
208
+ function getDefaultSchema() {
209
+ return SPEC_DRIVEN_SCHEMA;
210
+ }
211
+ var SPEC_DRIVEN_SCHEMA, ARTIFACT_TEMPLATES, BUILTIN_SCHEMAS;
212
+ var init_schema = __esm({
213
+ "src/core/schema.ts"() {
214
+ "use strict";
215
+ SPEC_DRIVEN_SCHEMA = {
216
+ name: "spec-driven",
217
+ version: 1,
218
+ description: "Default OpenSpec workflow - proposal \u2192 specs \u2192 design \u2192 tasks",
219
+ artifacts: [
220
+ {
221
+ id: "proposal",
222
+ generates: "proposal.md",
223
+ description: "Initial proposal document outlining the change",
224
+ template: "proposal.md",
225
+ instruction: `Create the proposal document that establishes WHY this change is needed.
226
+
227
+ Sections:
228
+ - **Why**: 1-2 sentences on the problem or opportunity. What problem does this solve? Why now?
229
+ - **What Changes**: Bullet list of changes. Be specific about new capabilities, modifications, or removals. Mark breaking changes with **BREAKING**.
230
+ - **Capabilities**: Identify which specs will be created or modified:
231
+ - **New Capabilities**: List capabilities being introduced. Each becomes a new specs/<name>/spec.md. Use kebab-case names (e.g., user-auth, data-export).
232
+ - **Modified Capabilities**: List existing capabilities whose REQUIREMENTS are changing. Only include if spec-level behavior changes (not just implementation details). Each needs a delta spec file. Check openspec/specs/ for existing spec names. Leave empty if no requirement changes.
233
+ - **Impact**: Affected code, APIs, dependencies, or systems.
234
+
235
+ IMPORTANT: The Capabilities section is critical. It creates the contract between
236
+ proposal and specs phases. Research existing specs before filling this in.
237
+ Each capability listed here will need a corresponding spec file.
238
+
239
+ Keep it concise (1-2 pages). Focus on the "why" not the "how" -
240
+ implementation details belong in design.md.
241
+
242
+ This is the foundation - specs, design, and tasks all build on this.`,
243
+ requires: []
244
+ },
245
+ {
246
+ id: "specs",
247
+ generates: "specs/**/*.md",
248
+ description: "Detailed specifications for the change",
249
+ template: "spec.md",
250
+ instruction: `Create specification files that define WHAT the system should do.
251
+
252
+ Create one spec file per capability listed in the proposal's Capabilities section.
253
+ - New capabilities: use the exact kebab-case name from the proposal (specs/<capability>/spec.md).
254
+ - Modified capabilities: use the existing spec folder name from openspec/specs/<capability>/ when creating the delta spec at specs/<capability>/spec.md.
255
+
256
+ Delta operations (use ## headers):
257
+ - **ADDED Requirements**: New capabilities
258
+ - **MODIFIED Requirements**: Changed behavior - MUST include full updated content
259
+ - **REMOVED Requirements**: Deprecated features - MUST include **Reason** and **Migration**
260
+ - **RENAMED Requirements**: Name changes only - use FROM:/TO: format
261
+
262
+ Format requirements:
263
+ - Each requirement: ### Requirement: <name> followed by description
264
+ - Use SHALL/MUST for normative requirements (avoid should/may)
265
+ - Each scenario: #### Scenario: <name> with WHEN/THEN format
266
+ - **CRITICAL**: Scenarios MUST use exactly 4 hashtags (####). Using 3 hashtags or bullets will fail silently.
267
+ - Every requirement MUST have at least one scenario.
268
+
269
+ MODIFIED requirements workflow:
270
+ 1. Locate the existing requirement in openspec/specs/<capability>/spec.md
271
+ 2. Copy the ENTIRE requirement block (from ### Requirement: through all scenarios)
272
+ 3. Paste under ## MODIFIED Requirements and edit to reflect new behavior
273
+ 4. Ensure header text matches exactly (whitespace-insensitive)
274
+
275
+ Common pitfall: Using MODIFIED with partial content loses detail at archive time.
276
+ If adding new concerns without changing existing behavior, use ADDED instead.`,
277
+ requires: ["proposal"]
278
+ },
279
+ {
280
+ id: "design",
281
+ generates: "design.md",
282
+ description: "Technical design document with implementation details",
283
+ template: "design.md",
284
+ instruction: `Create the design document that explains HOW to implement the change.
285
+
286
+ When to include design.md (create only if any apply):
287
+ - Cross-cutting change (multiple services/modules) or new architectural pattern
288
+ - New external dependency or significant data model changes
289
+ - Security, performance, or migration complexity
290
+ - Ambiguity that benefits from technical decisions before coding
291
+
292
+ Sections:
293
+ - **Context**: Background, current state, constraints, stakeholders
294
+ - **Goals / Non-Goals**: What this design achieves and explicitly excludes
295
+ - **Decisions**: Key technical choices with rationale (why X over Y?). Include alternatives considered for each decision.
296
+ - **Risks / Trade-offs**: Known limitations, things that could go wrong. Format: [Risk] \u2192 Mitigation
297
+ - **Migration Plan**: Steps to deploy, rollback strategy (if applicable)
298
+ - **Open Questions**: Outstanding decisions or unknowns to resolve
299
+
300
+ Focus on architecture and approach, not line-by-line implementation.
301
+ Reference the proposal for motivation and specs for requirements.
302
+
303
+ Good design docs explain the "why" behind technical decisions.`,
304
+ requires: ["proposal"]
305
+ },
306
+ {
307
+ id: "tasks",
308
+ generates: "tasks.md",
309
+ description: "Implementation checklist with trackable tasks",
310
+ template: "tasks.md",
311
+ instruction: `Create the task list that breaks down the implementation work.
312
+
313
+ **IMPORTANT: Follow the template below exactly.** The apply phase parses
314
+ checkbox format to track progress. Tasks not using - [ ] won't be tracked.
315
+
316
+ Guidelines:
317
+ - Group related tasks under ## numbered headings
318
+ - Each task MUST be a checkbox: - [ ] X.Y Task description
319
+ - Tasks should be small enough to complete in one session
320
+ - Order tasks by dependency (what must be done first?)
321
+
322
+ Reference specs for what needs to be built, design for how to build it.
323
+ Each task should be verifiable - you know when it's done.`,
324
+ requires: ["specs", "design"]
325
+ }
326
+ ],
327
+ apply: {
328
+ requires: ["tasks"],
329
+ tracks: "tasks.md",
330
+ instruction: "Read context files, work through pending tasks, mark complete as you go.\nPause if you hit blockers or need clarification."
331
+ }
332
+ };
333
+ ARTIFACT_TEMPLATES = {
334
+ "proposal.md": `## Why
335
+
336
+ <!-- Explain the motivation for this change. What problem does this solve? Why now? -->
337
+
338
+ ## What Changes
339
+
340
+ <!-- Describe what will change. Be specific about new capabilities, modifications, or removals. -->
341
+
342
+ ## Capabilities
343
+
344
+ ### New Capabilities
345
+ <!-- Capabilities being introduced. Replace <name> with kebab-case identifier (e.g., user-auth, data-export, api-rate-limiting). Each creates specs/<name>/spec.md -->
346
+ - \`<name>\`: <brief description of what this capability covers>
347
+
348
+ ### Modified Capabilities
349
+ <!-- Existing capabilities whose REQUIREMENTS are changing (not just implementation).
350
+ Only list here if spec-level behavior changes. Each needs a delta spec file.
351
+ Use existing spec names from openspec/specs/. Leave empty if no requirement changes. -->
352
+ - \`<existing-name>\`: <what requirement is changing>
353
+
354
+ ## Impact
355
+
356
+ <!-- Affected code, APIs, dependencies, systems -->`,
357
+ "design.md": `## Context
358
+
359
+ <!-- Background and current state -->
360
+
361
+ ## Goals / Non-Goals
362
+
363
+ **Goals:**
364
+ <!-- What this design aims to achieve -->
365
+
366
+ **Non-Goals:**
367
+ <!-- What is explicitly out of scope -->
368
+
369
+ ## Decisions
370
+
371
+ <!-- Key design decisions and rationale -->
372
+
373
+ ## Risks / Trade-offs
374
+
375
+ <!-- Known risks and trade-offs -->`,
376
+ "tasks.md": `## 1. <!-- Task Group Name -->
377
+
378
+ - [ ] 1.1 <!-- Task description -->
379
+ - [ ] 1.2 <!-- Task description -->
380
+
381
+ ## 2. <!-- Task Group Name -->
382
+
383
+ - [ ] 2.1 <!-- Task description -->
384
+ - [ ] 2.2 <!-- Task description -->`,
385
+ "spec.md": `## ADDED Requirements
386
+
387
+ ### Requirement: <!-- requirement name -->
388
+ <!-- requirement text -->
389
+
390
+ #### Scenario: <!-- scenario name -->
391
+ - **WHEN** <!-- condition -->
392
+ - **THEN** <!-- expected outcome -->`
393
+ };
394
+ BUILTIN_SCHEMAS = {
395
+ "spec-driven": SPEC_DRIVEN_SCHEMA
396
+ };
397
+ }
398
+ });
399
+
400
+ // src/core/openspec.ts
401
+ var openspec_exports = {};
402
+ __export(openspec_exports, {
403
+ archiveChange: () => archiveChange,
404
+ formatDate: () => formatDate,
405
+ formatOpenSpecContext: () => formatOpenSpecContext,
406
+ getApplyInstructions: () => getApplyInstructions,
407
+ getChangeStatus: () => getChangeStatus,
408
+ getInstructions: () => getInstructions,
409
+ initOpenSpecStructure: () => initOpenSpecStructure,
410
+ listOpenSpecChanges: () => listOpenSpecChanges,
411
+ listOpenSpecChangesDetailed: () => listOpenSpecChangesDetailed,
412
+ newChange: () => newChange,
413
+ readOpenSpecContext: () => readOpenSpecContext,
414
+ resolveOpenSpecChange: () => resolveOpenSpecChange
415
+ });
416
+ import fs2 from "fs/promises";
417
+ import path5 from "path";
418
+ function formatDate(date = /* @__PURE__ */ new Date()) {
419
+ return date.toISOString().slice(0, 10);
420
+ }
421
+ async function initOpenSpecStructure(root) {
422
+ await ensureDir(path5.join(root, OPENSPEC_DIR));
423
+ await ensureDir(path5.join(root, OPENSPEC_CHANGES_DIR));
424
+ await ensureDir(path5.join(root, OPENSPEC_SPECS_DIR));
425
+ await ensureDir(path5.join(root, OPENSPEC_ARCHIVE_DIR));
426
+ const gitkeepFiles = [OPENSPEC_CHANGES_DIR, OPENSPEC_SPECS_DIR, OPENSPEC_ARCHIVE_DIR];
427
+ for (const dir of gitkeepFiles) {
428
+ const gitkeep = path5.join(root, dir, ".gitkeep");
429
+ if (!await pathExists(gitkeep)) {
430
+ await fs2.writeFile(gitkeep, "", "utf8");
431
+ }
432
+ }
433
+ }
434
+ async function newChange(root, name, schema = OPENSPEC_DEFAULT_SCHEMA) {
435
+ if (!/^[a-z]/.test(name)) {
436
+ throw new Error("Change name must start with a letter.");
437
+ }
438
+ if (!/^[a-z0-9][-a-z0-9]*$/.test(name)) {
439
+ throw new Error("Change name must be kebab-case (lowercase letters, digits, hyphens).");
440
+ }
441
+ const changeDir = path5.join(root, OPENSPEC_CHANGES_DIR, name);
442
+ if (await pathExists(changeDir)) {
443
+ throw new Error(`Change already exists: ${name}`);
444
+ }
445
+ const schemaDef = getSchema(schema);
446
+ if (!schemaDef) {
447
+ throw new Error(`Unknown schema: ${schema}. Available: ${["spec-driven"].join(", ")}`);
448
+ }
449
+ await ensureDir(changeDir);
450
+ const config = {
451
+ schema,
452
+ created: formatDate()
453
+ };
454
+ await fs2.writeFile(
455
+ path5.join(changeDir, OPENSPEC_CONFIG_FILE),
456
+ `schema: ${config.schema}
457
+ created: ${config.created}
458
+ `,
459
+ "utf8"
460
+ );
461
+ return changeDir;
462
+ }
463
+ async function listOpenSpecChanges(root) {
464
+ const changesDir = path5.join(root, OPENSPEC_CHANGES_DIR);
465
+ if (!await pathExists(changesDir)) {
466
+ return [];
467
+ }
468
+ const entries = await fs2.readdir(changesDir, { withFileTypes: true });
469
+ return entries.filter((entry) => entry.isDirectory() && entry.name !== "archive").map((entry) => entry.name).sort();
470
+ }
471
+ async function listOpenSpecChangesDetailed(root) {
472
+ const names = await listOpenSpecChanges(root);
473
+ const entries = [];
474
+ for (const name of names) {
475
+ const config = await readChangeConfig(root, name);
476
+ entries.push({
477
+ name,
478
+ schema: config?.schema ?? OPENSPEC_DEFAULT_SCHEMA,
479
+ created: config?.created ?? ""
480
+ });
481
+ }
482
+ return entries;
483
+ }
484
+ async function resolveOpenSpecChange(root, requested) {
485
+ if (requested) {
486
+ const changeDir = path5.join(root, OPENSPEC_CHANGES_DIR, requested);
487
+ if (!await pathExists(changeDir)) {
488
+ throw new Error(`OpenSpec change not found: ${requested}`);
489
+ }
490
+ return requested;
491
+ }
492
+ const changes = await listOpenSpecChanges(root);
493
+ if (changes.length === 1) {
494
+ return changes[0];
495
+ }
496
+ if (changes.length === 0) {
497
+ throw new Error("No OpenSpec changes found.");
498
+ }
499
+ throw new Error(`Multiple OpenSpec changes found. Specify one with --change: ${changes.join(", ")}`);
500
+ }
501
+ async function readChangeConfig(root, change) {
502
+ const configPath = path5.join(root, OPENSPEC_CHANGES_DIR, change, OPENSPEC_CONFIG_FILE);
503
+ const content = await readTextIfExists(configPath);
504
+ if (!content) return null;
505
+ const schemaMatch = content.match(/^schema:\s*(.+)$/m);
506
+ const createdMatch = content.match(/^created:\s*(.+)$/m);
507
+ return {
508
+ schema: schemaMatch?.[1]?.trim() ?? OPENSPEC_DEFAULT_SCHEMA,
509
+ created: createdMatch?.[1]?.trim() ?? ""
510
+ };
511
+ }
512
+ async function getChangeStatus(root, change) {
513
+ const changeDir = path5.join(root, OPENSPEC_CHANGES_DIR, change);
514
+ if (!await pathExists(changeDir)) {
515
+ throw new Error(`Change not found: ${change}`);
516
+ }
517
+ const config = await readChangeConfig(root, change);
518
+ const schemaName = config?.schema ?? OPENSPEC_DEFAULT_SCHEMA;
519
+ const schemaDef = getSchema(schemaName);
520
+ if (!schemaDef) {
521
+ throw new Error(`Unknown schema: ${schemaName}`);
522
+ }
523
+ const applyRequires = getApplyRequires(schemaDef);
524
+ const artifacts = [];
525
+ for (const artifact of schemaDef.artifacts) {
526
+ const status = await computeArtifactStatus(root, change, artifact);
527
+ artifacts.push(status);
528
+ }
529
+ return {
530
+ changeName: change,
531
+ schemaName,
532
+ applyRequires,
533
+ artifacts
534
+ };
535
+ }
536
+ async function computeArtifactStatus(root, change, artifact) {
537
+ const changeDir = path5.join(root, OPENSPEC_CHANGES_DIR, change);
538
+ if (artifact.id === "specs") {
539
+ const specsDir = path5.join(changeDir, "specs");
540
+ if (!await pathExists(specsDir)) {
541
+ const depsOk = artifact.requires.length === 0;
542
+ return { id: artifact.id, status: depsOk ? "ready" : "blocked" };
543
+ }
544
+ const entries = await fs2.readdir(specsDir, { withFileTypes: true });
545
+ const hasFiles = entries.some(
546
+ (entry) => entry.isFile() && entry.name.endsWith(".md")
547
+ );
548
+ return { id: artifact.id, status: hasFiles ? "done" : "ready" };
549
+ }
550
+ const filePath = path5.join(changeDir, artifact.generates);
551
+ const fileExists = await pathExists(filePath);
552
+ if (fileExists) {
553
+ return { id: artifact.id, status: "done" };
554
+ }
555
+ const allDepsDone = await checkDepsDone(root, change, artifact.requires);
556
+ return { id: artifact.id, status: allDepsDone ? "ready" : "blocked" };
557
+ }
558
+ async function checkDepsDone(root, change, deps) {
559
+ if (deps.length === 0) return true;
560
+ const changeDir = path5.join(root, OPENSPEC_CHANGES_DIR, change);
561
+ const schemaDef = getDefaultSchema();
562
+ for (const depId of deps) {
563
+ const depArtifact = getArtifact(schemaDef, depId);
564
+ if (!depArtifact) continue;
565
+ if (depArtifact.id === "specs") {
566
+ const specsDir = path5.join(changeDir, "specs");
567
+ if (!await pathExists(specsDir)) return false;
568
+ const entries = await fs2.readdir(specsDir, { withFileTypes: true });
569
+ if (!entries.some((e) => e.isFile() && e.name.endsWith(".md"))) return false;
570
+ continue;
571
+ }
572
+ const depPath = path5.join(changeDir, depArtifact.generates);
573
+ if (!await pathExists(depPath)) return false;
574
+ }
575
+ return true;
576
+ }
577
+ async function getInstructions(root, change, artifactId) {
578
+ const config = await readChangeConfig(root, change);
579
+ const schemaName = config?.schema ?? OPENSPEC_DEFAULT_SCHEMA;
580
+ const schemaDef = getSchema(schemaName);
581
+ if (!schemaDef) {
582
+ throw new Error(`Unknown schema: ${schemaName}`);
583
+ }
584
+ const artifact = getArtifact(schemaDef, artifactId);
585
+ if (!artifact) {
586
+ throw new Error(`Unknown artifact: ${artifactId} in schema ${schemaName}`);
587
+ }
588
+ const template = getArtifactTemplate(artifact.template);
589
+ const changeDir = path5.join(root, OPENSPEC_CHANGES_DIR, change);
590
+ const outputPath = artifactId === "specs" ? path5.join(changeDir, "specs") : path5.join(changeDir, artifact.generates);
591
+ return {
592
+ artifactId,
593
+ context: `Project: ${root}. Change: ${change}. Schema: ${schemaName}.`,
594
+ rules: "Keep artifacts concise. Follow the template structure.",
595
+ template,
596
+ instruction: artifact.instruction,
597
+ outputPath,
598
+ dependencies: artifact.requires
599
+ };
600
+ }
601
+ async function getApplyInstructions(root, change) {
602
+ const config = await readChangeConfig(root, change);
603
+ const schemaName = config?.schema ?? OPENSPEC_DEFAULT_SCHEMA;
604
+ const schemaDef = getSchema(schemaName);
605
+ if (!schemaDef) {
606
+ throw new Error(`Unknown schema: ${schemaName}`);
607
+ }
608
+ const changeDir = path5.join(root, OPENSPEC_CHANGES_DIR, change);
609
+ const status = await getChangeStatus(root, change);
610
+ const applyReq = getApplyRequires(schemaDef);
611
+ const allRequiredDone = applyReq.every((id) => {
612
+ const artifact = status.artifacts.find((a) => a.id === id);
613
+ return artifact?.status === "done";
614
+ });
615
+ const contextFiles = {};
616
+ for (const artifact of schemaDef.artifacts) {
617
+ if (artifact.id === "specs") {
618
+ const specsDir = path5.join(changeDir, "specs");
619
+ if (await pathExists(specsDir)) {
620
+ const entries = await fs2.readdir(specsDir, { withFileTypes: true });
621
+ const mdFiles = entries.filter((e) => e.isFile() && e.name.endsWith(".md")).map((e) => path5.join(specsDir, e.name));
622
+ if (mdFiles.length > 0) {
623
+ contextFiles[artifact.id] = mdFiles;
624
+ }
625
+ }
626
+ continue;
627
+ }
628
+ const filePath = path5.join(changeDir, artifact.generates);
629
+ if (await pathExists(filePath)) {
630
+ contextFiles[artifact.id] = [filePath];
631
+ }
632
+ }
633
+ const tasks = await parseTasks(root, change);
634
+ let state = "ready";
635
+ if (!allRequiredDone) {
636
+ state = "blocked";
637
+ } else if (tasks.length > 0 && tasks.every((t) => t.completed)) {
638
+ state = "all_done";
639
+ }
640
+ return {
641
+ schemaName,
642
+ changeName: change,
643
+ state,
644
+ contextFiles,
645
+ total: tasks.length,
646
+ complete: tasks.filter((t) => t.completed).length,
647
+ remaining: tasks.filter((t) => !t.completed).length,
648
+ tasks,
649
+ instruction: getApplyInstruction(schemaDef)
650
+ };
651
+ }
652
+ async function parseTasks(root, change) {
653
+ const tasksPath = path5.join(root, OPENSPEC_CHANGES_DIR, change, "tasks.md");
654
+ const content = await readTextIfExists(tasksPath);
655
+ if (!content) return [];
656
+ const tasks = [];
657
+ const checkboxRe = /^-\s*\[(x|\s)\]\s+(\S+)\s+(.+)$/;
658
+ for (const line of content.split("\n")) {
659
+ const match = line.match(checkboxRe);
660
+ if (match) {
661
+ tasks.push({
662
+ index: match[2],
663
+ description: match[3].trim(),
664
+ completed: match[1].toLowerCase() === "x"
665
+ });
666
+ }
667
+ }
668
+ return tasks;
669
+ }
670
+ async function archiveChange(root, change) {
671
+ const changeDir = path5.join(root, OPENSPEC_CHANGES_DIR, change);
672
+ if (!await pathExists(changeDir)) {
673
+ throw new Error(`Change not found: ${change}`);
674
+ }
675
+ const dateStr = formatDate();
676
+ const archiveName = `${dateStr}-${change}`;
677
+ const archiveDir = path5.join(root, OPENSPEC_ARCHIVE_DIR, archiveName);
678
+ if (await pathExists(archiveDir)) {
679
+ throw new Error(`Archive already exists: ${archiveName}`);
680
+ }
681
+ await ensureDir(path5.join(root, OPENSPEC_ARCHIVE_DIR));
682
+ await fs2.rename(changeDir, archiveDir);
683
+ return archiveDir;
684
+ }
685
+ async function readOpenSpecContext(root, change) {
686
+ return {
687
+ change,
688
+ proposal: filterExcludedLines(
689
+ await readTextIfExists(path5.join(root, OPENSPEC_CHANGES_DIR, change, "proposal.md"))
690
+ ),
691
+ design: filterExcludedLines(
692
+ await readTextIfExists(path5.join(root, OPENSPEC_CHANGES_DIR, change, "design.md"))
693
+ ),
694
+ tasks: filterExcludedLines(
695
+ await readTextIfExists(path5.join(root, OPENSPEC_CHANGES_DIR, change, "tasks.md"))
696
+ )
697
+ };
698
+ }
699
+ function formatOpenSpecContext(context) {
700
+ if (!context) {
701
+ return "";
702
+ }
703
+ return [
704
+ `# OpenSpec Change: ${context.change}`,
705
+ "## proposal.md",
706
+ context.proposal || "(missing)",
707
+ "## design.md",
708
+ context.design || "(missing)",
709
+ "## tasks.md",
710
+ context.tasks || "(missing)"
711
+ ].join("\n\n");
712
+ }
713
+ var init_openspec = __esm({
714
+ "src/core/openspec.ts"() {
715
+ "use strict";
716
+ init_constants();
717
+ init_excludes();
718
+ init_fs();
719
+ init_schema();
720
+ }
721
+ });
722
+
723
+ // src/cli.ts
724
+ import { Command } from "commander";
725
+
726
+ // src/commands/ask.ts
727
+ import path9 from "path";
728
+
729
+ // src/core/clipboard.ts
730
+ import { spawn } from "child_process";
731
+ async function copyToClipboard(content) {
732
+ const command = process.platform === "win32" ? "clip" : process.platform === "darwin" ? "pbcopy" : "xclip";
733
+ const args = process.platform === "linux" ? ["-selection", "clipboard"] : [];
734
+ return new Promise((resolve) => {
735
+ const child = spawn(command, args, { stdio: ["pipe", "ignore", "ignore"], windowsHide: true });
736
+ child.on("error", () => resolve(false));
737
+ child.on("close", (code) => resolve(code === 0));
738
+ child.stdin.end(content);
739
+ });
740
+ }
741
+
742
+ // src/core/command-runner.ts
743
+ import { exec } from "child_process";
744
+ import { promisify as promisify2 } from "util";
117
745
 
118
746
  // src/core/git.ts
747
+ init_excludes();
748
+ import { execFile } from "child_process";
749
+ import { promisify } from "util";
119
750
  var execFileAsync = promisify(execFile);
120
751
  async function git(root, args) {
121
752
  try {
@@ -244,76 +875,9 @@ function sanitizeCommandOutput(output2, options) {
244
875
  }
245
876
 
246
877
  // src/core/config.ts
878
+ init_constants();
879
+ init_fs();
247
880
  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
881
  function createDefaultConfig(project, mode) {
318
882
  return {
319
883
  projectName: project.name,
@@ -373,6 +937,8 @@ function normalizeConfig(config) {
373
937
  }
374
938
 
375
939
  // src/core/relayignore.ts
940
+ init_constants();
941
+ init_fs();
376
942
  import path4 from "path";
377
943
  async function loadRelayIgnoreMatcher(root, extraPatterns = []) {
378
944
  const relayIgnorePath = path4.join(root, RELAYIGNORE_FILE);
@@ -488,59 +1054,13 @@ async function createContextSafety(root, config) {
488
1054
  };
489
1055
  }
490
1056
 
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
- }
1057
+ // src/commands/ask.ts
1058
+ init_fs();
1059
+ init_openspec();
542
1060
 
543
1061
  // src/core/runs.ts
1062
+ init_constants();
1063
+ init_fs();
544
1064
  import path7 from "path";
545
1065
 
546
1066
  // src/core/templates.ts
@@ -612,6 +1132,8 @@ async function createRunStructure(root, config, runId, lane = DEFAULT_LANE, opti
612
1132
  }
613
1133
 
614
1134
  // src/core/state.ts
1135
+ init_constants();
1136
+ init_fs();
615
1137
  import path8 from "path";
616
1138
  function createDefaultState(mode) {
617
1139
  return {
@@ -712,9 +1234,13 @@ async function maybeRunCommand(root, config, target) {
712
1234
  }
713
1235
 
714
1236
  // src/commands/doctor.ts
1237
+ init_constants();
715
1238
  import path11 from "path";
1239
+ init_fs();
1240
+ init_openspec();
716
1241
 
717
1242
  // src/core/project.ts
1243
+ init_fs();
718
1244
  import fs4 from "fs/promises";
719
1245
  import path10 from "path";
720
1246
  async function readPackageScripts(root) {
@@ -800,10 +1326,15 @@ function formatPending(clear, fileName, fix) {
800
1326
  }
801
1327
 
802
1328
  // src/commands/init.ts
1329
+ init_constants();
803
1330
  import fs5 from "fs/promises";
804
1331
  import path13 from "path";
1332
+ init_fs();
1333
+ init_openspec();
805
1334
 
806
1335
  // src/core/skills.ts
1336
+ init_constants();
1337
+ init_fs();
807
1338
  import path12 from "path";
808
1339
  async function installProjectSkills(root, config, options = {}) {
809
1340
  const sourceRoot = path12.join(getPackageRoot(), "skills");
@@ -819,33 +1350,58 @@ async function installProjectSkills(root, config, options = {}) {
819
1350
  }
820
1351
  return targets;
821
1352
  }
1353
+ async function installOpenspecFiles(root, options = {}) {
1354
+ const pkgRoot = getPackageRoot();
1355
+ const installed = [];
1356
+ const specs = [
1357
+ { source: path12.join(pkgRoot, ".opencode"), target: path12.join(root, ".opencode") },
1358
+ { source: path12.join(pkgRoot, ".claude"), target: path12.join(root, ".claude") },
1359
+ { source: path12.join(pkgRoot, ".codex"), target: path12.join(root, ".codex") }
1360
+ ];
1361
+ for (const spec of specs) {
1362
+ if (!await pathExists(spec.source)) continue;
1363
+ await copyDirectory(spec.source, spec.target, options);
1364
+ installed.push(spec.target);
1365
+ }
1366
+ return installed;
1367
+ }
822
1368
 
823
1369
  // src/commands/init.ts
824
1370
  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) => {
1371
+ 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.").option("--openspec-sync <mode>", "How to handle existing openspec CLI: use_relay, use_external, skip.").option("--yes", "Accept non-destructive defaults.").option("--force", "Overwrite relay-kit managed files.").action(async (options) => {
826
1372
  const result = await runInit(process.cwd(), options);
827
1373
  console.log(result.summary);
828
1374
  });
829
1375
  }
830
1376
  async function runInit(root, options = {}) {
831
1377
  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
- }
1378
+ const mode = await resolveModeInteractive(project.hasOpenSpec, options);
836
1379
  const config = createDefaultConfig(project, mode);
837
1380
  const state = createDefaultState(mode);
838
1381
  await ensureDir(path13.join(root, ".relay"));
839
1382
  if (!options.force && (await pathExists(path13.join(root, CONFIG_FILE)) || await pathExists(path13.join(root, STATE_FILE)))) {
840
1383
  throw new Error("relay-kit is already initialized. Use --force to rewrite managed config/state files.");
841
1384
  }
1385
+ const openspecInitNotes = [];
1386
+ if (mode === "openspec") {
1387
+ const isExisting = project.hasOpenSpec;
1388
+ if (!isExisting) {
1389
+ await initOpenSpecStructure(root);
1390
+ openspecInitNotes.push(`Created ${OPENSPEC_DIR}/ structure (changes/, specs/, archive/).`);
1391
+ } else {
1392
+ openspecInitNotes.push(`Detected existing ${OPENSPEC_DIR}/ structure. Updating OpenSpec files.`);
1393
+ }
1394
+ await detectAndHandleExternalOpenspec(root, options, openspecInitNotes);
1395
+ const openspecTargets = await installOpenspecFiles(root, { force: isExisting || options.force });
1396
+ openspecInitNotes.push(`OpenSpec files installed to: ${openspecTargets.map((t) => path13.relative(root, t)).join(", ")}`);
1397
+ }
842
1398
  await writeConfig(root, config);
843
1399
  await writeState(root, state);
844
1400
  await ensureDir(path13.join(root, DEFAULT_HANDOFF_DIR, "runs"));
845
1401
  const relayIgnoreStatus = await ensureRelayIgnore(root);
846
1402
  await injectAgentsRules(root, options.force);
847
1403
  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." : "";
1404
+ const guidance = options.withOpenspec && mode !== "openspec" ? "\nOpenSpec was requested but not selected. Run relay init --mode openspec to enable OpenSpec mode." : "";
849
1405
  return {
850
1406
  summary: [
851
1407
  `relay init complete (${mode}).`,
@@ -853,10 +1409,113 @@ async function runInit(root, options = {}) {
853
1409
  `State: ${STATE_FILE}`,
854
1410
  `Relay ignore: ${relayIgnoreStatus}`,
855
1411
  `Skills: ${skillTargets.map((target) => path13.relative(root, target)).join(", ")}`,
1412
+ ...openspecInitNotes,
856
1413
  guidance
857
1414
  ].filter(Boolean).join("\n")
858
1415
  };
859
1416
  }
1417
+ async function resolveModeInteractive(hasOpenSpec, options) {
1418
+ if (options.mode === "simple") return "simple";
1419
+ if (options.mode === "openspec") return "openspec";
1420
+ if (hasOpenSpec) return "openspec";
1421
+ if (options.withOpenspec) return "simple";
1422
+ if (options.yes) return "simple";
1423
+ return promptModeSelection();
1424
+ }
1425
+ async function promptModeSelection() {
1426
+ const readline = await import("readline");
1427
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
1428
+ return "openspec";
1429
+ }
1430
+ const rl = readline.createInterface({
1431
+ input: process.stdin,
1432
+ output: process.stdout
1433
+ });
1434
+ const question = (q) => new Promise((resolve) => rl.question(q, resolve));
1435
+ console.log();
1436
+ console.log("\u9009\u62E9\u521D\u59CB\u5316\u6A21\u5F0F\uFF1A");
1437
+ console.log(" 1. \u7B80\u5355\u6A21\u5F0F \u2014 relay-kit \u72EC\u7ACB\u4F7F\u7528\uFF08\u4E0D\u542B OpenSpec\uFF09");
1438
+ console.log(" 2. OpenSpec \u6A21\u5F0F \u2014 \u96C6\u6210 OpenSpec\uFF0C\u89C4\u8303\u9A71\u52A8\u5F00\u53D1\uFF08\u63A8\u8350\uFF09");
1439
+ let answer;
1440
+ try {
1441
+ answer = await question("\u8BF7\u9009\u62E9 (1/2, \u9ED8\u8BA4 2): ");
1442
+ } finally {
1443
+ rl.close();
1444
+ }
1445
+ const trimmed = answer.trim();
1446
+ if (trimmed === "1" || trimmed.toLowerCase() === "simple") {
1447
+ return "simple";
1448
+ }
1449
+ return "openspec";
1450
+ }
1451
+ async function detectAndHandleExternalOpenspec(root, options, notes) {
1452
+ const externalCliPath = await findExternalOpenspecCli();
1453
+ if (!externalCliPath) {
1454
+ notes.push("\u672A\u68C0\u6D4B\u5230\u5916\u90E8 openspec CLI\u3002\u4F7F\u7528 relay-kit \u5185\u7F6E\u5B9E\u73B0\u3002");
1455
+ return;
1456
+ }
1457
+ const syncMode = options.openspecSync ?? (options.yes ? "use_relay" : void 0);
1458
+ if (syncMode) {
1459
+ if (syncMode === "use_relay") {
1460
+ notes.push("\u53D1\u73B0\u5916\u90E8 openspec CLI\uFF0C\u5DF2\u9009\u62E9\u4F7F\u7528 relay-kit \u5185\u7F6E\u5B9E\u73B0\u3002");
1461
+ } else if (syncMode === "use_external") {
1462
+ notes.push("\u4F7F\u7528\u5916\u90E8 openspec CLI\u3002\u53EF\u901A\u8FC7 relay init --openspec-sync use_relay \u5207\u6362\u3002");
1463
+ }
1464
+ return;
1465
+ }
1466
+ const readline = await import("readline");
1467
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
1468
+ notes.push("External openspec CLI detected. Using relay-kit built-in (non-TTY default).");
1469
+ return;
1470
+ }
1471
+ const rl = readline.createInterface({
1472
+ input: process.stdin,
1473
+ output: process.stdout
1474
+ });
1475
+ const question = (q) => new Promise((resolve) => rl.question(q, resolve));
1476
+ console.log();
1477
+ console.log(`\u68C0\u6D4B\u5230\u5916\u90E8 openspec CLI: ${externalCliPath}`);
1478
+ console.log("relay-kit \u73B0\u5DF2\u5185\u7F6E OpenSpec \u5B9E\u73B0\u3002");
1479
+ console.log(" 1. \u4F7F\u7528 relay-kit \u5185\u7F6E\uFF08\u63A8\u8350\uFF09\u2014 \u65E0\u9700\u5916\u90E8\u4F9D\u8D56");
1480
+ console.log(" 2. \u4F7F\u7528\u5916\u90E8 openspec CLI \u2014 \u4FDD\u6301\u73B0\u6709\u8BBE\u7F6E");
1481
+ console.log(" 3. \u8DF3\u8FC7 \u2014 \u4E0D\u8BBE\u7F6E OpenSpec");
1482
+ let answer;
1483
+ try {
1484
+ answer = await question("\u8BF7\u9009\u62E9 (1/2/3, \u9ED8\u8BA4 1): ");
1485
+ } finally {
1486
+ rl.close();
1487
+ }
1488
+ const trimmed = answer.trim();
1489
+ if (trimmed === "2" || trimmed.toLowerCase() === "external") {
1490
+ notes.push("Using external openspec CLI. Run relay init --openspec-sync use_relay to switch.");
1491
+ return;
1492
+ }
1493
+ if (trimmed === "3" || trimmed.toLowerCase() === "skip") {
1494
+ notes.push("OpenSpec CLI handling skipped.");
1495
+ return;
1496
+ }
1497
+ notes.push("Using relay-kit built-in OpenSpec implementation.");
1498
+ }
1499
+ async function findExternalOpenspecCli() {
1500
+ const whichCmd = process.platform === "win32" ? "where.exe" : "which";
1501
+ try {
1502
+ const { execFile: execFile2 } = await import("child_process");
1503
+ const { stdout } = await new Promise((resolve, reject) => {
1504
+ execFile2(whichCmd, ["openspec"], { timeout: 5e3 }, (error, stdout2) => {
1505
+ if (error) reject(error);
1506
+ else resolve({ stdout: stdout2 });
1507
+ });
1508
+ });
1509
+ const first = stdout.split("\n")[0]?.trim();
1510
+ if (!first) return null;
1511
+ if (first.includes("node_modules\\relay-kit") || first.includes("node_modules/relay-kit")) {
1512
+ return null;
1513
+ }
1514
+ return first;
1515
+ } catch {
1516
+ return null;
1517
+ }
1518
+ }
860
1519
  async function ensureRelayIgnore(root) {
861
1520
  if (await pathExists(path13.join(root, RELAYIGNORE_FILE))) {
862
1521
  return `${RELAYIGNORE_FILE} (kept existing)`;
@@ -864,12 +1523,6 @@ async function ensureRelayIgnore(root) {
864
1523
  await safeWriteFile(root, RELAYIGNORE_FILE, DEFAULT_RELAYIGNORE_CONTENT);
865
1524
  return RELAYIGNORE_FILE;
866
1525
  }
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
1526
  async function injectAgentsRules(root, force = false) {
874
1527
  const agentsPath = path13.join(root, "AGENTS.md");
875
1528
  const existing = await readTextIfExists(agentsPath);
@@ -894,8 +1547,116 @@ function escapeRegExp(value) {
894
1547
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
895
1548
  }
896
1549
 
1550
+ // src/commands/openspec.ts
1551
+ init_openspec();
1552
+ init_schema();
1553
+ function registerOpenspecCommand(program2) {
1554
+ const openspecCmd = program2.command("openspec").description("Manage OpenSpec changes (built-in).").action(() => {
1555
+ console.log("Usage: relay openspec <action> [options]\n");
1556
+ console.log("Actions:");
1557
+ console.log(" new-change <name> Create a new OpenSpec change");
1558
+ console.log(" status Show artifact completion status");
1559
+ console.log(" list List active changes");
1560
+ console.log(" instructions <id> Get artifact creation instructions");
1561
+ console.log(" apply-instructions Get apply instructions for a change");
1562
+ console.log(" archive <name> Archive a completed change");
1563
+ console.log(" schemas List available schemas");
1564
+ });
1565
+ openspecCmd.command("new-change <name>").description("Create a new OpenSpec change.").option("--schema <schema>", "Schema name (default: spec-driven).").action(async (name, options) => {
1566
+ const result = await newChange(process.cwd(), name, options.schema);
1567
+ console.log(`Created change: ${name}
1568
+ Schema: ${options.schema ?? "spec-driven"}
1569
+ Path: ${result}`);
1570
+ });
1571
+ openspecCmd.command("status").description("Display artifact completion status for a change.").option("--change <change>", "Change name.").option("--json", "Output as JSON.").action(async (options) => {
1572
+ const change = options.change ?? await resolveCurrentChange();
1573
+ const status = await getChangeStatus(process.cwd(), change);
1574
+ if (options.json) {
1575
+ console.log(JSON.stringify(status, null, 2));
1576
+ return;
1577
+ }
1578
+ console.log(`Change: ${change}`);
1579
+ console.log(`Schema: ${status.schemaName}`);
1580
+ console.log(`Apply Requires: ${status.applyRequires.join(", ")}`);
1581
+ console.log();
1582
+ for (const artifact of status.artifacts) {
1583
+ console.log(` ${artifact.id}: ${artifact.status}`);
1584
+ }
1585
+ });
1586
+ openspecCmd.command("list").description("List active OpenSpec changes.").option("--json", "Output as JSON.").action(async (options) => {
1587
+ const entries = await listOpenSpecChangesDetailed(process.cwd());
1588
+ if (options.json) {
1589
+ console.log(JSON.stringify(entries, null, 2));
1590
+ return;
1591
+ }
1592
+ if (entries.length === 0) {
1593
+ console.log("No active changes.");
1594
+ return;
1595
+ }
1596
+ for (const entry of entries) {
1597
+ console.log(` ${entry.name} (schema: ${entry.schema}, created: ${entry.created})`);
1598
+ }
1599
+ });
1600
+ openspecCmd.command("instructions <artifactId>").description("Output enriched instructions for creating an artifact.").option("--change <change>", "Change name.").option("--json", "Output as JSON.").action(async (artifactId, options) => {
1601
+ const change = options.change ?? await resolveCurrentChange();
1602
+ const instructions = await getInstructions(process.cwd(), change, artifactId);
1603
+ if (options.json) {
1604
+ console.log(JSON.stringify(instructions, null, 2));
1605
+ return;
1606
+ }
1607
+ console.log(`Artifact: ${instructions.artifactId}`);
1608
+ console.log(`Output: ${instructions.outputPath}`);
1609
+ console.log(`Dependencies: ${instructions.dependencies.join(", ") || "(none)"}`);
1610
+ console.log();
1611
+ console.log("## Instruction");
1612
+ console.log(instructions.instruction);
1613
+ console.log();
1614
+ console.log("## Template");
1615
+ console.log(instructions.template);
1616
+ });
1617
+ openspecCmd.command("apply-instructions").description("Output apply instructions for a change.").option("--change <change>", "Change name.").option("--json", "Output as JSON.").action(async (options) => {
1618
+ const change = options.change ?? await resolveCurrentChange();
1619
+ const instructions = await getApplyInstructions(process.cwd(), change);
1620
+ if (options.json) {
1621
+ console.log(JSON.stringify(instructions, null, 2));
1622
+ return;
1623
+ }
1624
+ console.log(`Change: ${instructions.changeName}`);
1625
+ console.log(`Schema: ${instructions.schemaName}`);
1626
+ console.log(`State: ${instructions.state}`);
1627
+ console.log(`Progress: ${instructions.complete}/${instructions.total} tasks complete`);
1628
+ console.log();
1629
+ console.log("## Instruction");
1630
+ console.log(instructions.instruction);
1631
+ if (instructions.tasks.length > 0) {
1632
+ console.log();
1633
+ console.log("## Tasks");
1634
+ for (const task of instructions.tasks) {
1635
+ console.log(` - [${task.completed ? "x" : " "}] ${task.index} ${task.description}`);
1636
+ }
1637
+ }
1638
+ });
1639
+ openspecCmd.command("archive <name>").description("Archive a completed change.").action(async (name, _options) => {
1640
+ const archiveDir = await archiveChange(process.cwd(), name);
1641
+ console.log(`Archived change "${name}" to ${archiveDir}`);
1642
+ });
1643
+ openspecCmd.command("schemas").description("List available workflow schemas.").action(() => {
1644
+ const schemas = getAvailableSchemas();
1645
+ for (const schema of schemas) {
1646
+ console.log(`${schema.name}: ${schema.description}`);
1647
+ console.log(` Artifacts: ${schema.artifacts.map((a) => a.id).join(", ")}`);
1648
+ console.log();
1649
+ }
1650
+ });
1651
+ }
1652
+ async function resolveCurrentChange() {
1653
+ const { resolveOpenSpecChange: resolveOpenSpecChange2 } = await Promise.resolve().then(() => (init_openspec(), openspec_exports));
1654
+ return resolveOpenSpecChange2(process.cwd());
1655
+ }
1656
+
897
1657
  // src/commands/resume.ts
898
1658
  import path14 from "path";
1659
+ init_fs();
899
1660
  function registerResumeCommand(program2) {
900
1661
  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
1662
  const result = await runResume(process.cwd(), options);
@@ -942,6 +1703,8 @@ function extractPromptForExecutor(decision) {
942
1703
 
943
1704
  // src/commands/review.ts
944
1705
  import path15 from "path";
1706
+ init_fs();
1707
+ init_openspec();
945
1708
  function registerReviewCommand(program2) {
946
1709
  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
1710
  const result = await runReview(process.cwd(), options);
@@ -986,9 +1749,12 @@ ${task}` : "", openSpecText].filter(Boolean).join("\n")
986
1749
  }
987
1750
 
988
1751
  // src/commands/start.ts
1752
+ init_constants();
989
1753
  import path16 from "path";
990
1754
  import { createInterface } from "readline/promises";
991
1755
  import { stdin as input, stdout as output } from "process";
1756
+ init_fs();
1757
+ init_openspec();
992
1758
  function registerStartCommand(program2) {
993
1759
  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
1760
  const result = await runStart(process.cwd(), options);
@@ -1048,7 +1814,12 @@ async function collectSimpleInputs(options) {
1048
1814
  }
1049
1815
  }
1050
1816
 
1817
+ // src/commands/sync.ts
1818
+ import path18 from "path";
1819
+
1051
1820
  // src/core/skills-sync.ts
1821
+ init_constants();
1822
+ init_fs();
1052
1823
  import crypto from "crypto";
1053
1824
  import fs6 from "fs/promises";
1054
1825
  import os from "os";
@@ -1290,28 +2061,41 @@ function pathLabel(value) {
1290
2061
 
1291
2062
  // src/commands/sync.ts
1292
2063
  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) => {
2064
+ program2.command("sync").description("Synchronize relay-kit managed resources.").option("--skills", "Synchronize relay Skills from .relay/skills.").option("--openspec", "Synchronize OpenSpec command/skill files.").option("--all", "Synchronize everything (skills + OpenSpec files).").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
2065
  const result = await runSync(process.cwd(), options);
1295
2066
  console.log(result.summary);
1296
2067
  });
1297
2068
  }
1298
2069
  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)}
2070
+ const runSkills = options.all || options.skills;
2071
+ const runOpenspec = options.all || options.openspec;
2072
+ if (!runSkills && !runOpenspec) {
2073
+ throw new Error("Specify --skills, --openspec, or --all.");
2074
+ }
2075
+ const lines = [];
2076
+ if (runSkills) {
2077
+ validateTarget(options.target);
2078
+ validateScope(options.scope);
2079
+ const config = await loadConfig(root);
2080
+ try {
2081
+ const report = await syncSkills(root, config, options);
2082
+ lines.push(formatSkillSyncReport(report));
2083
+ } catch (error) {
2084
+ if (error instanceof SkillSyncConflictError) {
2085
+ throw new Error(`${formatSkillSyncReport(error.report)}
1311
2086
  ${error.message}`);
2087
+ }
2088
+ throw error;
2089
+ }
2090
+ }
2091
+ if (runOpenspec) {
2092
+ const installed = await installOpenspecFiles(root, { force: options.force });
2093
+ lines.push(`relay sync --openspec: installed ${installed.length} directories.`);
2094
+ for (const dir of installed) {
2095
+ lines.push(` ${path18.relative(root, dir)}`);
1312
2096
  }
1313
- throw error;
1314
2097
  }
2098
+ return { summary: lines.join("\n") };
1315
2099
  }
1316
2100
  function validateTarget(target) {
1317
2101
  if (target === void 0 || target === "claude" || target === "codex" || target === "all") {
@@ -1330,6 +2114,7 @@ function validateScope(scope) {
1330
2114
  var program = new Command();
1331
2115
  program.name("relay").description("Skills-first, CLI-assisted AI programming relay workflow toolkit.").version("0.2.0");
1332
2116
  registerInitCommand(program);
2117
+ registerOpenspecCommand(program);
1333
2118
  registerStartCommand(program);
1334
2119
  registerAskCommand(program);
1335
2120
  registerResumeCommand(program);