relay-kit 0.2.0 → 0.3.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/.claude/commands/opsx/apply.md +152 -0
- package/.claude/commands/opsx/archive.md +157 -0
- package/.claude/commands/opsx/explore.md +173 -0
- package/.claude/commands/opsx/propose.md +106 -0
- package/.claude/skills/openspec-apply-change/SKILL.md +156 -0
- package/.claude/skills/openspec-archive-change/SKILL.md +114 -0
- package/.claude/skills/openspec-explore/SKILL.md +288 -0
- package/.claude/skills/openspec-propose/SKILL.md +110 -0
- package/.claude/skills/relay-delegator/SKILL.md +55 -0
- package/.claude/skills/relay-escalation/SKILL.md +47 -0
- package/.claude/skills/relay-planner/SKILL.md +64 -0
- package/.claude/skills/relay-reviewer/SKILL.md +59 -0
- package/.codex/skills/openspec-apply-change/SKILL.md +156 -0
- package/.codex/skills/openspec-archive-change/SKILL.md +114 -0
- package/.codex/skills/openspec-explore/SKILL.md +288 -0
- package/.codex/skills/openspec-propose/SKILL.md +110 -0
- package/.opencode/commands/opsx-apply.md +149 -0
- package/.opencode/commands/opsx-archive.md +154 -0
- package/.opencode/commands/opsx-explore.md +170 -0
- package/.opencode/commands/opsx-propose.md +103 -0
- package/.opencode/skills/openspec-apply-change/SKILL.md +156 -0
- package/.opencode/skills/openspec-archive-change/SKILL.md +114 -0
- package/.opencode/skills/openspec-explore/SKILL.md +288 -0
- package/.opencode/skills/openspec-propose/SKILL.md +110 -0
- package/CHANGELOG.md +27 -1
- package/dist/cli.js +997 -215
- package/dist/cli.js.map +1 -1
- package/package.json +13 -8
- package/skills/relay-delegator/SKILL.md +14 -2
- package/skills/relay-planner/SKILL.md +12 -0
- 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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
|
35
|
-
var
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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/
|
|
492
|
-
|
|
493
|
-
|
|
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,55 @@ 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
|
|
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 =
|
|
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
|
+
if (!project.hasOpenSpec) {
|
|
1388
|
+
await initOpenSpecStructure(root);
|
|
1389
|
+
openspecInitNotes.push(`Created ${OPENSPEC_DIR}/ structure (changes/, specs/, archive/).`);
|
|
1390
|
+
}
|
|
1391
|
+
await detectAndHandleExternalOpenspec(root, options, openspecInitNotes);
|
|
1392
|
+
const openspecTargets = await installOpenspecFiles(root, { force: options.force });
|
|
1393
|
+
openspecInitNotes.push(`OpenSpec files installed to: ${openspecTargets.map((t) => path13.relative(root, t)).join(", ")}`);
|
|
1394
|
+
}
|
|
842
1395
|
await writeConfig(root, config);
|
|
843
1396
|
await writeState(root, state);
|
|
844
1397
|
await ensureDir(path13.join(root, DEFAULT_HANDOFF_DIR, "runs"));
|
|
845
1398
|
const relayIgnoreStatus = await ensureRelayIgnore(root);
|
|
846
1399
|
await injectAgentsRules(root, options.force);
|
|
847
1400
|
const skillTargets = await installProjectSkills(root, config, { force: options.force });
|
|
848
|
-
const guidance = options.withOpenspec &&
|
|
1401
|
+
const guidance = options.withOpenspec && mode !== "openspec" ? "\nOpenSpec was requested but not selected. Run relay init --mode openspec to enable OpenSpec mode." : "";
|
|
849
1402
|
return {
|
|
850
1403
|
summary: [
|
|
851
1404
|
`relay init complete (${mode}).`,
|
|
@@ -853,10 +1406,113 @@ async function runInit(root, options = {}) {
|
|
|
853
1406
|
`State: ${STATE_FILE}`,
|
|
854
1407
|
`Relay ignore: ${relayIgnoreStatus}`,
|
|
855
1408
|
`Skills: ${skillTargets.map((target) => path13.relative(root, target)).join(", ")}`,
|
|
1409
|
+
...openspecInitNotes,
|
|
856
1410
|
guidance
|
|
857
1411
|
].filter(Boolean).join("\n")
|
|
858
1412
|
};
|
|
859
1413
|
}
|
|
1414
|
+
async function resolveModeInteractive(hasOpenSpec, options) {
|
|
1415
|
+
if (options.mode === "simple") return "simple";
|
|
1416
|
+
if (options.mode === "openspec") return "openspec";
|
|
1417
|
+
if (hasOpenSpec) return "openspec";
|
|
1418
|
+
if (options.withOpenspec) return "simple";
|
|
1419
|
+
if (options.yes) return "simple";
|
|
1420
|
+
return promptModeSelection();
|
|
1421
|
+
}
|
|
1422
|
+
async function promptModeSelection() {
|
|
1423
|
+
const readline = await import("readline");
|
|
1424
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
1425
|
+
return "openspec";
|
|
1426
|
+
}
|
|
1427
|
+
const rl = readline.createInterface({
|
|
1428
|
+
input: process.stdin,
|
|
1429
|
+
output: process.stdout
|
|
1430
|
+
});
|
|
1431
|
+
const question = (q) => new Promise((resolve) => rl.question(q, resolve));
|
|
1432
|
+
console.log();
|
|
1433
|
+
console.log("Select initialization mode:");
|
|
1434
|
+
console.log(" 1. Simple mode \u2014 relay-kit standalone (no OpenSpec)");
|
|
1435
|
+
console.log(" 2. OpenSpec mode \u2014 integrated with OpenSpec for spec-driven development (recommended)");
|
|
1436
|
+
let answer;
|
|
1437
|
+
try {
|
|
1438
|
+
answer = await question("Choose mode (1/2, default 2): ");
|
|
1439
|
+
} finally {
|
|
1440
|
+
rl.close();
|
|
1441
|
+
}
|
|
1442
|
+
const trimmed = answer.trim();
|
|
1443
|
+
if (trimmed === "1" || trimmed.toLowerCase() === "simple") {
|
|
1444
|
+
return "simple";
|
|
1445
|
+
}
|
|
1446
|
+
return "openspec";
|
|
1447
|
+
}
|
|
1448
|
+
async function detectAndHandleExternalOpenspec(root, options, notes) {
|
|
1449
|
+
const externalCliPath = await findExternalOpenspecCli();
|
|
1450
|
+
if (!externalCliPath) {
|
|
1451
|
+
notes.push("No external openspec CLI detected. Using relay-kit built-in implementation.");
|
|
1452
|
+
return;
|
|
1453
|
+
}
|
|
1454
|
+
const syncMode = options.openspecSync ?? (options.yes ? "use_relay" : void 0);
|
|
1455
|
+
if (syncMode) {
|
|
1456
|
+
if (syncMode === "use_relay") {
|
|
1457
|
+
notes.push("External openspec CLI found but relay-kit built-in will be used.");
|
|
1458
|
+
} else if (syncMode === "use_external") {
|
|
1459
|
+
notes.push("Using external openspec CLI. Upgrade relay-kit to switch to built-in.");
|
|
1460
|
+
}
|
|
1461
|
+
return;
|
|
1462
|
+
}
|
|
1463
|
+
const readline = await import("readline");
|
|
1464
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
1465
|
+
notes.push("External openspec CLI detected. Using relay-kit built-in (non-TTY default).");
|
|
1466
|
+
return;
|
|
1467
|
+
}
|
|
1468
|
+
const rl = readline.createInterface({
|
|
1469
|
+
input: process.stdin,
|
|
1470
|
+
output: process.stdout
|
|
1471
|
+
});
|
|
1472
|
+
const question = (q) => new Promise((resolve) => rl.question(q, resolve));
|
|
1473
|
+
console.log();
|
|
1474
|
+
console.log(`External openspec CLI detected at: ${externalCliPath}`);
|
|
1475
|
+
console.log("relay-kit now has built-in OpenSpec support.");
|
|
1476
|
+
console.log(" 1. Use relay-kit built-in (recommended) \u2014 no external dependency needed");
|
|
1477
|
+
console.log(" 2. Use external openspec CLI \u2014 keep existing setup");
|
|
1478
|
+
console.log(" 3. Skip \u2014 don't set up OpenSpec");
|
|
1479
|
+
let answer;
|
|
1480
|
+
try {
|
|
1481
|
+
answer = await question("Choose option (1/2/3, default 1): ");
|
|
1482
|
+
} finally {
|
|
1483
|
+
rl.close();
|
|
1484
|
+
}
|
|
1485
|
+
const trimmed = answer.trim();
|
|
1486
|
+
if (trimmed === "2" || trimmed.toLowerCase() === "external") {
|
|
1487
|
+
notes.push("Using external openspec CLI. Run relay init --openspec-sync use_relay to switch.");
|
|
1488
|
+
return;
|
|
1489
|
+
}
|
|
1490
|
+
if (trimmed === "3" || trimmed.toLowerCase() === "skip") {
|
|
1491
|
+
notes.push("OpenSpec CLI handling skipped.");
|
|
1492
|
+
return;
|
|
1493
|
+
}
|
|
1494
|
+
notes.push("Using relay-kit built-in OpenSpec implementation.");
|
|
1495
|
+
}
|
|
1496
|
+
async function findExternalOpenspecCli() {
|
|
1497
|
+
const whichCmd = process.platform === "win32" ? "where.exe" : "which";
|
|
1498
|
+
try {
|
|
1499
|
+
const { execFile: execFile2 } = await import("child_process");
|
|
1500
|
+
const { stdout } = await new Promise((resolve, reject) => {
|
|
1501
|
+
execFile2(whichCmd, ["openspec"], { timeout: 5e3 }, (error, stdout2) => {
|
|
1502
|
+
if (error) reject(error);
|
|
1503
|
+
else resolve({ stdout: stdout2 });
|
|
1504
|
+
});
|
|
1505
|
+
});
|
|
1506
|
+
const first = stdout.split("\n")[0]?.trim();
|
|
1507
|
+
if (!first) return null;
|
|
1508
|
+
if (first.includes("node_modules\\relay-kit") || first.includes("node_modules/relay-kit")) {
|
|
1509
|
+
return null;
|
|
1510
|
+
}
|
|
1511
|
+
return first;
|
|
1512
|
+
} catch {
|
|
1513
|
+
return null;
|
|
1514
|
+
}
|
|
1515
|
+
}
|
|
860
1516
|
async function ensureRelayIgnore(root) {
|
|
861
1517
|
if (await pathExists(path13.join(root, RELAYIGNORE_FILE))) {
|
|
862
1518
|
return `${RELAYIGNORE_FILE} (kept existing)`;
|
|
@@ -864,12 +1520,6 @@ async function ensureRelayIgnore(root) {
|
|
|
864
1520
|
await safeWriteFile(root, RELAYIGNORE_FILE, DEFAULT_RELAYIGNORE_CONTENT);
|
|
865
1521
|
return RELAYIGNORE_FILE;
|
|
866
1522
|
}
|
|
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
1523
|
async function injectAgentsRules(root, force = false) {
|
|
874
1524
|
const agentsPath = path13.join(root, "AGENTS.md");
|
|
875
1525
|
const existing = await readTextIfExists(agentsPath);
|
|
@@ -894,8 +1544,116 @@ function escapeRegExp(value) {
|
|
|
894
1544
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
895
1545
|
}
|
|
896
1546
|
|
|
1547
|
+
// src/commands/openspec.ts
|
|
1548
|
+
init_openspec();
|
|
1549
|
+
init_schema();
|
|
1550
|
+
function registerOpenspecCommand(program2) {
|
|
1551
|
+
const openspecCmd = program2.command("openspec").description("Manage OpenSpec changes (built-in).").action(() => {
|
|
1552
|
+
console.log("Usage: relay openspec <action> [options]\n");
|
|
1553
|
+
console.log("Actions:");
|
|
1554
|
+
console.log(" new-change <name> Create a new OpenSpec change");
|
|
1555
|
+
console.log(" status Show artifact completion status");
|
|
1556
|
+
console.log(" list List active changes");
|
|
1557
|
+
console.log(" instructions <id> Get artifact creation instructions");
|
|
1558
|
+
console.log(" apply-instructions Get apply instructions for a change");
|
|
1559
|
+
console.log(" archive <name> Archive a completed change");
|
|
1560
|
+
console.log(" schemas List available schemas");
|
|
1561
|
+
});
|
|
1562
|
+
openspecCmd.command("new-change <name>").description("Create a new OpenSpec change.").option("--schema <schema>", "Schema name (default: spec-driven).").action(async (name, options) => {
|
|
1563
|
+
const result = await newChange(process.cwd(), name, options.schema);
|
|
1564
|
+
console.log(`Created change: ${name}
|
|
1565
|
+
Schema: ${options.schema ?? "spec-driven"}
|
|
1566
|
+
Path: ${result}`);
|
|
1567
|
+
});
|
|
1568
|
+
openspecCmd.command("status").description("Display artifact completion status for a change.").option("--change <change>", "Change name.").option("--json", "Output as JSON.").action(async (options) => {
|
|
1569
|
+
const change = options.change ?? await resolveCurrentChange();
|
|
1570
|
+
const status = await getChangeStatus(process.cwd(), change);
|
|
1571
|
+
if (options.json) {
|
|
1572
|
+
console.log(JSON.stringify(status, null, 2));
|
|
1573
|
+
return;
|
|
1574
|
+
}
|
|
1575
|
+
console.log(`Change: ${change}`);
|
|
1576
|
+
console.log(`Schema: ${status.schemaName}`);
|
|
1577
|
+
console.log(`Apply Requires: ${status.applyRequires.join(", ")}`);
|
|
1578
|
+
console.log();
|
|
1579
|
+
for (const artifact of status.artifacts) {
|
|
1580
|
+
console.log(` ${artifact.id}: ${artifact.status}`);
|
|
1581
|
+
}
|
|
1582
|
+
});
|
|
1583
|
+
openspecCmd.command("list").description("List active OpenSpec changes.").option("--json", "Output as JSON.").action(async (options) => {
|
|
1584
|
+
const entries = await listOpenSpecChangesDetailed(process.cwd());
|
|
1585
|
+
if (options.json) {
|
|
1586
|
+
console.log(JSON.stringify(entries, null, 2));
|
|
1587
|
+
return;
|
|
1588
|
+
}
|
|
1589
|
+
if (entries.length === 0) {
|
|
1590
|
+
console.log("No active changes.");
|
|
1591
|
+
return;
|
|
1592
|
+
}
|
|
1593
|
+
for (const entry of entries) {
|
|
1594
|
+
console.log(` ${entry.name} (schema: ${entry.schema}, created: ${entry.created})`);
|
|
1595
|
+
}
|
|
1596
|
+
});
|
|
1597
|
+
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) => {
|
|
1598
|
+
const change = options.change ?? await resolveCurrentChange();
|
|
1599
|
+
const instructions = await getInstructions(process.cwd(), change, artifactId);
|
|
1600
|
+
if (options.json) {
|
|
1601
|
+
console.log(JSON.stringify(instructions, null, 2));
|
|
1602
|
+
return;
|
|
1603
|
+
}
|
|
1604
|
+
console.log(`Artifact: ${instructions.artifactId}`);
|
|
1605
|
+
console.log(`Output: ${instructions.outputPath}`);
|
|
1606
|
+
console.log(`Dependencies: ${instructions.dependencies.join(", ") || "(none)"}`);
|
|
1607
|
+
console.log();
|
|
1608
|
+
console.log("## Instruction");
|
|
1609
|
+
console.log(instructions.instruction);
|
|
1610
|
+
console.log();
|
|
1611
|
+
console.log("## Template");
|
|
1612
|
+
console.log(instructions.template);
|
|
1613
|
+
});
|
|
1614
|
+
openspecCmd.command("apply-instructions").description("Output apply instructions for a change.").option("--change <change>", "Change name.").option("--json", "Output as JSON.").action(async (options) => {
|
|
1615
|
+
const change = options.change ?? await resolveCurrentChange();
|
|
1616
|
+
const instructions = await getApplyInstructions(process.cwd(), change);
|
|
1617
|
+
if (options.json) {
|
|
1618
|
+
console.log(JSON.stringify(instructions, null, 2));
|
|
1619
|
+
return;
|
|
1620
|
+
}
|
|
1621
|
+
console.log(`Change: ${instructions.changeName}`);
|
|
1622
|
+
console.log(`Schema: ${instructions.schemaName}`);
|
|
1623
|
+
console.log(`State: ${instructions.state}`);
|
|
1624
|
+
console.log(`Progress: ${instructions.complete}/${instructions.total} tasks complete`);
|
|
1625
|
+
console.log();
|
|
1626
|
+
console.log("## Instruction");
|
|
1627
|
+
console.log(instructions.instruction);
|
|
1628
|
+
if (instructions.tasks.length > 0) {
|
|
1629
|
+
console.log();
|
|
1630
|
+
console.log("## Tasks");
|
|
1631
|
+
for (const task of instructions.tasks) {
|
|
1632
|
+
console.log(` - [${task.completed ? "x" : " "}] ${task.index} ${task.description}`);
|
|
1633
|
+
}
|
|
1634
|
+
}
|
|
1635
|
+
});
|
|
1636
|
+
openspecCmd.command("archive <name>").description("Archive a completed change.").action(async (name, _options) => {
|
|
1637
|
+
const archiveDir = await archiveChange(process.cwd(), name);
|
|
1638
|
+
console.log(`Archived change "${name}" to ${archiveDir}`);
|
|
1639
|
+
});
|
|
1640
|
+
openspecCmd.command("schemas").description("List available workflow schemas.").action(() => {
|
|
1641
|
+
const schemas = getAvailableSchemas();
|
|
1642
|
+
for (const schema of schemas) {
|
|
1643
|
+
console.log(`${schema.name}: ${schema.description}`);
|
|
1644
|
+
console.log(` Artifacts: ${schema.artifacts.map((a) => a.id).join(", ")}`);
|
|
1645
|
+
console.log();
|
|
1646
|
+
}
|
|
1647
|
+
});
|
|
1648
|
+
}
|
|
1649
|
+
async function resolveCurrentChange() {
|
|
1650
|
+
const { resolveOpenSpecChange: resolveOpenSpecChange2 } = await Promise.resolve().then(() => (init_openspec(), openspec_exports));
|
|
1651
|
+
return resolveOpenSpecChange2(process.cwd());
|
|
1652
|
+
}
|
|
1653
|
+
|
|
897
1654
|
// src/commands/resume.ts
|
|
898
1655
|
import path14 from "path";
|
|
1656
|
+
init_fs();
|
|
899
1657
|
function registerResumeCommand(program2) {
|
|
900
1658
|
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
1659
|
const result = await runResume(process.cwd(), options);
|
|
@@ -942,6 +1700,8 @@ function extractPromptForExecutor(decision) {
|
|
|
942
1700
|
|
|
943
1701
|
// src/commands/review.ts
|
|
944
1702
|
import path15 from "path";
|
|
1703
|
+
init_fs();
|
|
1704
|
+
init_openspec();
|
|
945
1705
|
function registerReviewCommand(program2) {
|
|
946
1706
|
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
1707
|
const result = await runReview(process.cwd(), options);
|
|
@@ -986,9 +1746,12 @@ ${task}` : "", openSpecText].filter(Boolean).join("\n")
|
|
|
986
1746
|
}
|
|
987
1747
|
|
|
988
1748
|
// src/commands/start.ts
|
|
1749
|
+
init_constants();
|
|
989
1750
|
import path16 from "path";
|
|
990
1751
|
import { createInterface } from "readline/promises";
|
|
991
1752
|
import { stdin as input, stdout as output } from "process";
|
|
1753
|
+
init_fs();
|
|
1754
|
+
init_openspec();
|
|
992
1755
|
function registerStartCommand(program2) {
|
|
993
1756
|
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
1757
|
const result = await runStart(process.cwd(), options);
|
|
@@ -1048,7 +1811,12 @@ async function collectSimpleInputs(options) {
|
|
|
1048
1811
|
}
|
|
1049
1812
|
}
|
|
1050
1813
|
|
|
1814
|
+
// src/commands/sync.ts
|
|
1815
|
+
import path18 from "path";
|
|
1816
|
+
|
|
1051
1817
|
// src/core/skills-sync.ts
|
|
1818
|
+
init_constants();
|
|
1819
|
+
init_fs();
|
|
1052
1820
|
import crypto from "crypto";
|
|
1053
1821
|
import fs6 from "fs/promises";
|
|
1054
1822
|
import os from "os";
|
|
@@ -1290,28 +2058,41 @@ function pathLabel(value) {
|
|
|
1290
2058
|
|
|
1291
2059
|
// src/commands/sync.ts
|
|
1292
2060
|
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) => {
|
|
2061
|
+
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
2062
|
const result = await runSync(process.cwd(), options);
|
|
1295
2063
|
console.log(result.summary);
|
|
1296
2064
|
});
|
|
1297
2065
|
}
|
|
1298
2066
|
async function runSync(root, options = {}) {
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
const
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
2067
|
+
const runSkills = options.all || options.skills;
|
|
2068
|
+
const runOpenspec = options.all || options.openspec;
|
|
2069
|
+
if (!runSkills && !runOpenspec) {
|
|
2070
|
+
throw new Error("Specify --skills, --openspec, or --all.");
|
|
2071
|
+
}
|
|
2072
|
+
const lines = [];
|
|
2073
|
+
if (runSkills) {
|
|
2074
|
+
validateTarget(options.target);
|
|
2075
|
+
validateScope(options.scope);
|
|
2076
|
+
const config = await loadConfig(root);
|
|
2077
|
+
try {
|
|
2078
|
+
const report = await syncSkills(root, config, options);
|
|
2079
|
+
lines.push(formatSkillSyncReport(report));
|
|
2080
|
+
} catch (error) {
|
|
2081
|
+
if (error instanceof SkillSyncConflictError) {
|
|
2082
|
+
throw new Error(`${formatSkillSyncReport(error.report)}
|
|
1311
2083
|
${error.message}`);
|
|
2084
|
+
}
|
|
2085
|
+
throw error;
|
|
2086
|
+
}
|
|
2087
|
+
}
|
|
2088
|
+
if (runOpenspec) {
|
|
2089
|
+
const installed = await installOpenspecFiles(root, { force: options.force });
|
|
2090
|
+
lines.push(`relay sync --openspec: installed ${installed.length} directories.`);
|
|
2091
|
+
for (const dir of installed) {
|
|
2092
|
+
lines.push(` ${path18.relative(root, dir)}`);
|
|
1312
2093
|
}
|
|
1313
|
-
throw error;
|
|
1314
2094
|
}
|
|
2095
|
+
return { summary: lines.join("\n") };
|
|
1315
2096
|
}
|
|
1316
2097
|
function validateTarget(target) {
|
|
1317
2098
|
if (target === void 0 || target === "claude" || target === "codex" || target === "all") {
|
|
@@ -1330,6 +2111,7 @@ function validateScope(scope) {
|
|
|
1330
2111
|
var program = new Command();
|
|
1331
2112
|
program.name("relay").description("Skills-first, CLI-assisted AI programming relay workflow toolkit.").version("0.2.0");
|
|
1332
2113
|
registerInitCommand(program);
|
|
2114
|
+
registerOpenspecCommand(program);
|
|
1333
2115
|
registerStartCommand(program);
|
|
1334
2116
|
registerAskCommand(program);
|
|
1335
2117
|
registerResumeCommand(program);
|