ralphctl 0.2.4 → 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.
Files changed (58) hide show
  1. package/README.md +21 -9
  2. package/dist/add-GX7P7XTT.mjs +16 -0
  3. package/dist/add-JGUOR4Z5.mjs +18 -0
  4. package/dist/bootstrap-FMHG6DRY.mjs +11 -0
  5. package/dist/chunk-3QBEBKMZ.mjs +103 -0
  6. package/dist/chunk-4GHVNKLV.mjs +5088 -0
  7. package/dist/{chunk-EDJX7TT6.mjs → chunk-57UWLHRH.mjs} +22 -2
  8. package/dist/chunk-747KW2RW.mjs +24 -0
  9. package/dist/chunk-CDOPLXFK.mjs +5485 -0
  10. package/dist/{chunk-7TG3EAQ2.mjs → chunk-CFUVE2BP.mjs} +1 -5
  11. package/dist/{chunk-IB6OCKZW.mjs → chunk-CTP2A436.mjs} +60 -55
  12. package/dist/{chunk-UBPZHHCD.mjs → chunk-D2YGPLIV.mjs} +84 -41
  13. package/dist/{chunk-QBXHAXHI.mjs → chunk-FKMKOWLA.mjs} +154 -208
  14. package/dist/chunk-HL4ZMHCQ.mjs +261 -0
  15. package/dist/{chunk-OEUJDSHY.mjs → chunk-IWXBJD2D.mjs} +1 -1
  16. package/dist/chunk-JXMHLW42.mjs +227 -0
  17. package/dist/{chunk-EUNAUHC3.mjs → chunk-NUYQK5MN.mjs} +80 -29
  18. package/dist/{chunk-JRFOUFD3.mjs → chunk-YCDUVPRT.mjs} +32 -52
  19. package/dist/cli.mjs +168 -3978
  20. package/dist/create-7WFSCMP4.mjs +15 -0
  21. package/dist/{handle-TA4MYNQJ.mjs → handle-BBAZJ44Y.mjs} +2 -2
  22. package/dist/mount-XZPBDRPZ.mjs +6751 -0
  23. package/dist/{project-YONEJICR.mjs → project-2IE7VWDB.mjs} +9 -5
  24. package/dist/prompts/harness-context.md +5 -0
  25. package/dist/prompts/ideate-auto.md +34 -19
  26. package/dist/prompts/ideate.md +21 -4
  27. package/dist/prompts/plan-auto.md +19 -24
  28. package/dist/prompts/plan-common.md +42 -17
  29. package/dist/prompts/plan-interactive.md +16 -21
  30. package/dist/prompts/signals-evaluation.md +6 -0
  31. package/dist/prompts/signals-planning.md +5 -0
  32. package/dist/prompts/signals-task.md +7 -0
  33. package/dist/prompts/sprint-feedback.md +48 -0
  34. package/dist/prompts/task-evaluation-resume.md +27 -13
  35. package/dist/prompts/task-evaluation.md +44 -34
  36. package/dist/prompts/task-execution.md +46 -46
  37. package/dist/prompts/ticket-refine.md +6 -5
  38. package/dist/prompts/validation-checklist.md +14 -0
  39. package/dist/{resolver-RXEY6EJE.mjs → resolver-EOE5WUMV.mjs} +5 -5
  40. package/dist/{sprint-FGLWYWKX.mjs → sprint-OGOFEJJH.mjs} +7 -9
  41. package/dist/start-MMWC7QLI.mjs +17 -0
  42. package/package.json +15 -13
  43. package/dist/add-3T225IX5.mjs +0 -16
  44. package/dist/add-6A5432U2.mjs +0 -16
  45. package/dist/chunk-742XQ7FL.mjs +0 -551
  46. package/dist/chunk-7LZ6GOGN.mjs +0 -53
  47. package/dist/chunk-DUU5346E.mjs +0 -59
  48. package/dist/chunk-U62BX47C.mjs +0 -4231
  49. package/dist/create-MYGOWO2F.mjs +0 -12
  50. package/dist/multiline-OHSNFCRG.mjs +0 -40
  51. package/dist/wizard-HWOH2HPV.mjs +0 -193
  52. package/schemas/config.schema.json +0 -30
  53. package/schemas/ideate-output.schema.json +0 -22
  54. package/schemas/projects.schema.json +0 -58
  55. package/schemas/requirements-output.schema.json +0 -24
  56. package/schemas/sprint.schema.json +0 -109
  57. package/schemas/task-import.schema.json +0 -56
  58. package/schemas/tasks.schema.json +0 -98
@@ -0,0 +1,261 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ assertSprintStatus,
4
+ resolveSprintId
5
+ } from "./chunk-YCDUVPRT.mjs";
6
+ import {
7
+ unwrapOrThrow
8
+ } from "./chunk-IWXBJD2D.mjs";
9
+ import {
10
+ SprintSchema,
11
+ generateUuid8,
12
+ getSprintFilePath,
13
+ readValidatedJson,
14
+ writeValidatedJson
15
+ } from "./chunk-CTP2A436.mjs";
16
+ import {
17
+ IssueFetchError,
18
+ TicketNotFoundError
19
+ } from "./chunk-57UWLHRH.mjs";
20
+
21
+ // src/integration/persistence/ticket.ts
22
+ async function getSprintData(sprintId) {
23
+ const id = await resolveSprintId(sprintId);
24
+ const result = await readValidatedJson(getSprintFilePath(id), SprintSchema);
25
+ if (!result.ok) throw result.error;
26
+ return result.value;
27
+ }
28
+ async function saveSprintData(sprint) {
29
+ const result = await writeValidatedJson(getSprintFilePath(sprint.id), sprint, SprintSchema);
30
+ if (!result.ok) throw result.error;
31
+ }
32
+ async function addTicket(input, sprintId) {
33
+ const sprint = await getSprintData(sprintId);
34
+ assertSprintStatus(sprint, ["draft"], "add tickets");
35
+ const ticket = {
36
+ id: generateUuid8(),
37
+ title: input.title,
38
+ description: input.description,
39
+ link: input.link,
40
+ affectedRepoIds: input.affectedRepoIds,
41
+ requirementStatus: "pending"
42
+ };
43
+ sprint.tickets.push(ticket);
44
+ await saveSprintData(sprint);
45
+ return ticket;
46
+ }
47
+ async function updateTicket(ticketId, updates, sprintId) {
48
+ const sprint = await getSprintData(sprintId);
49
+ assertSprintStatus(sprint, ["draft"], "update tickets");
50
+ const ticketIdx = sprint.tickets.findIndex((t) => t.id === ticketId);
51
+ if (ticketIdx === -1) {
52
+ throw new TicketNotFoundError(ticketId);
53
+ }
54
+ const ticket = sprint.tickets[ticketIdx];
55
+ if (!ticket) {
56
+ throw new TicketNotFoundError(ticketId);
57
+ }
58
+ if (updates.title !== void 0) {
59
+ ticket.title = updates.title;
60
+ }
61
+ if (updates.description !== void 0) {
62
+ ticket.description = updates.description || void 0;
63
+ }
64
+ if (updates.link !== void 0) {
65
+ ticket.link = updates.link || void 0;
66
+ }
67
+ await saveSprintData(sprint);
68
+ return ticket;
69
+ }
70
+ async function removeTicket(ticketId, sprintId) {
71
+ const sprint = await getSprintData(sprintId);
72
+ assertSprintStatus(sprint, ["draft"], "remove tickets");
73
+ const index = sprint.tickets.findIndex((t) => t.id === ticketId);
74
+ if (index === -1) {
75
+ throw new TicketNotFoundError(ticketId);
76
+ }
77
+ sprint.tickets.splice(index, 1);
78
+ await saveSprintData(sprint);
79
+ }
80
+ async function listTickets(sprintId) {
81
+ const sprint = await getSprintData(sprintId);
82
+ return sprint.tickets;
83
+ }
84
+ async function getTicket(ticketId, sprintId) {
85
+ const sprint = await getSprintData(sprintId);
86
+ const ticket = sprint.tickets.find((t) => t.id === ticketId);
87
+ if (!ticket) {
88
+ throw new TicketNotFoundError(ticketId);
89
+ }
90
+ return ticket;
91
+ }
92
+ function allRequirementsApproved(tickets) {
93
+ return tickets.length > 0 && tickets.every((t) => t.requirementStatus === "approved");
94
+ }
95
+ function getPendingRequirements(tickets) {
96
+ return tickets.filter((t) => t.requirementStatus === "pending");
97
+ }
98
+ function formatTicketDisplay(ticket) {
99
+ return `[${ticket.id}] ${ticket.title}`;
100
+ }
101
+
102
+ // src/integration/external/issue-fetch.ts
103
+ import { spawnSync } from "child_process";
104
+ import { Result } from "typescript-result";
105
+ var MAX_COMMENTS = 20;
106
+ function parseIssueUrl(url) {
107
+ let parsed;
108
+ try {
109
+ parsed = new URL(url);
110
+ } catch {
111
+ return null;
112
+ }
113
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
114
+ return null;
115
+ }
116
+ const segments = parsed.pathname.split("/").filter(Boolean);
117
+ if (parsed.hostname === "github.com") {
118
+ const owner = segments[0];
119
+ const repo = segments[1];
120
+ if (segments.length >= 4 && segments[2] === "issues" && owner && repo) {
121
+ const num = Number(segments[3]);
122
+ if (Number.isInteger(num) && num > 0) {
123
+ return { host: "github", hostname: parsed.hostname, owner, repo, number: num };
124
+ }
125
+ }
126
+ return null;
127
+ }
128
+ const dashIdx = segments.indexOf("-");
129
+ if (dashIdx >= 2 && segments[dashIdx + 1] === "issues") {
130
+ const num = Number(segments[dashIdx + 2]);
131
+ if (Number.isInteger(num) && num > 0) {
132
+ const repo = segments[dashIdx - 1];
133
+ if (repo) {
134
+ const owner = segments.slice(0, dashIdx - 1).join("/");
135
+ return { host: "gitlab", hostname: parsed.hostname, owner, repo, number: num };
136
+ }
137
+ }
138
+ }
139
+ return null;
140
+ }
141
+ function fetchGitHubIssueResult(parsed) {
142
+ const result = spawnSync(
143
+ "gh",
144
+ [
145
+ "issue",
146
+ "view",
147
+ String(parsed.number),
148
+ "--repo",
149
+ `${parsed.owner}/${parsed.repo}`,
150
+ "--json",
151
+ "title,body,comments"
152
+ ],
153
+ { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"], timeout: 3e4 }
154
+ );
155
+ if (result.status !== 0) {
156
+ const stderr = result.stderr.trim();
157
+ return Result.error(new IssueFetchError(`gh issue view failed: ${stderr || "unknown error"}`));
158
+ }
159
+ const data = JSON.parse(result.stdout);
160
+ const comments = (data.comments ?? []).slice(-MAX_COMMENTS).map((c) => ({
161
+ author: c.author?.login ?? "unknown",
162
+ createdAt: c.createdAt ?? "",
163
+ body: c.body ?? ""
164
+ }));
165
+ return Result.ok({
166
+ title: data.title ?? "",
167
+ body: data.body ?? "",
168
+ comments,
169
+ url: `https://${parsed.hostname}/${parsed.owner}/${parsed.repo}/issues/${String(parsed.number)}`
170
+ });
171
+ }
172
+ function fetchGitLabIssueResult(parsed) {
173
+ const result = spawnSync(
174
+ "glab",
175
+ ["issue", "view", String(parsed.number), "--repo", `${parsed.owner}/${parsed.repo}`, "--output", "json"],
176
+ { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"], timeout: 3e4 }
177
+ );
178
+ if (result.status !== 0) {
179
+ const stderr = result.stderr.trim();
180
+ return Result.error(new IssueFetchError(`glab issue view failed: ${stderr || "unknown error"}`));
181
+ }
182
+ const data = JSON.parse(result.stdout);
183
+ const notesResult = spawnSync(
184
+ "glab",
185
+ ["issue", "note", "list", String(parsed.number), "--repo", `${parsed.owner}/${parsed.repo}`, "--output", "json"],
186
+ { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"], timeout: 3e4 }
187
+ );
188
+ let comments = [];
189
+ if (notesResult.status === 0 && notesResult.stdout.trim()) {
190
+ try {
191
+ const notes = JSON.parse(notesResult.stdout);
192
+ comments = notes.slice(-MAX_COMMENTS).map((n) => ({
193
+ author: n.author?.username ?? "unknown",
194
+ createdAt: n.created_at ?? "",
195
+ body: n.body ?? ""
196
+ }));
197
+ } catch {
198
+ }
199
+ }
200
+ return Result.ok({
201
+ title: data.title ?? "",
202
+ body: data.description ?? "",
203
+ comments,
204
+ url: `https://${parsed.hostname}/${parsed.owner}/${parsed.repo}/-/issues/${String(parsed.number)}`
205
+ });
206
+ }
207
+ function fetchIssueResult(parsed) {
208
+ if (parsed.host === "github") {
209
+ return fetchGitHubIssueResult(parsed);
210
+ }
211
+ return fetchGitLabIssueResult(parsed);
212
+ }
213
+ function fetchIssue(parsed) {
214
+ return unwrapOrThrow(fetchIssueResult(parsed));
215
+ }
216
+ function fetchIssueFromUrl(url) {
217
+ const parsed = parseIssueUrl(url);
218
+ if (!parsed) return null;
219
+ return fetchIssue(parsed);
220
+ }
221
+ function formatIssueContext(data) {
222
+ const lines = [];
223
+ lines.push("## Source Issue Data");
224
+ lines.push("");
225
+ lines.push(`> Fetched live from ${data.url}`);
226
+ lines.push("");
227
+ lines.push(`**Title:** ${data.title}`);
228
+ lines.push("");
229
+ if (data.body) {
230
+ lines.push("**Body:**");
231
+ lines.push("");
232
+ lines.push(data.body);
233
+ lines.push("");
234
+ }
235
+ if (data.comments.length > 0) {
236
+ lines.push(`**Comments (${String(data.comments.length)}):**`);
237
+ lines.push("");
238
+ for (const comment of data.comments) {
239
+ const timestamp = comment.createdAt ? ` (${comment.createdAt})` : "";
240
+ lines.push(`---`);
241
+ lines.push(`**@${comment.author}**${timestamp}:`);
242
+ lines.push("");
243
+ lines.push(comment.body);
244
+ lines.push("");
245
+ }
246
+ }
247
+ return lines.join("\n");
248
+ }
249
+
250
+ export {
251
+ addTicket,
252
+ updateTicket,
253
+ removeTicket,
254
+ listTickets,
255
+ getTicket,
256
+ allRequirementsApproved,
257
+ getPendingRequirements,
258
+ formatTicketDisplay,
259
+ fetchIssueFromUrl,
260
+ formatIssueContext
261
+ };
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- // src/utils/result-helpers.ts
3
+ // src/integration/utils/result-helpers.ts
4
4
  import { Result } from "typescript-result";
5
5
  async function wrapAsync(fn, mapError) {
6
6
  try {
@@ -0,0 +1,227 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ getProjectById
4
+ } from "./chunk-NUYQK5MN.mjs";
5
+ import {
6
+ addTicket,
7
+ fetchIssueFromUrl
8
+ } from "./chunk-HL4ZMHCQ.mjs";
9
+ import {
10
+ EXIT_ERROR,
11
+ exitWithCode
12
+ } from "./chunk-CFUVE2BP.mjs";
13
+ import {
14
+ getPrompt
15
+ } from "./chunk-747KW2RW.mjs";
16
+ import {
17
+ getCurrentSprintOrThrow
18
+ } from "./chunk-YCDUVPRT.mjs";
19
+ import {
20
+ createSpinner,
21
+ emoji,
22
+ error,
23
+ field,
24
+ fieldMultiline,
25
+ icons,
26
+ log,
27
+ renderCard,
28
+ showError,
29
+ showSuccess,
30
+ showWarning
31
+ } from "./chunk-FKMKOWLA.mjs";
32
+ import {
33
+ ensureError,
34
+ wrapAsync
35
+ } from "./chunk-IWXBJD2D.mjs";
36
+ import {
37
+ IOError,
38
+ SprintStatusError
39
+ } from "./chunk-57UWLHRH.mjs";
40
+
41
+ // src/integration/cli/commands/ticket/add.ts
42
+ import { Result as Result2 } from "typescript-result";
43
+
44
+ // src/integration/ui/prompts/editor-input.ts
45
+ import { Result } from "typescript-result";
46
+ async function editorInput(options) {
47
+ try {
48
+ const result = await getPrompt().editor({
49
+ message: options.message,
50
+ default: options.default,
51
+ kind: "markdown"
52
+ });
53
+ if (result === null) {
54
+ return Result.error(new IOError("Editor cancelled"));
55
+ }
56
+ return Result.ok(result);
57
+ } catch (err) {
58
+ return Result.error(
59
+ new IOError(
60
+ `Editor failed: ${err instanceof Error ? err.message : String(err)}`,
61
+ err instanceof Error ? err : void 0
62
+ )
63
+ );
64
+ }
65
+ }
66
+
67
+ // src/integration/cli/commands/ticket/add.ts
68
+ function tryFetchIssue(url) {
69
+ const spinner = createSpinner("Fetching issue data...");
70
+ spinner.start();
71
+ const fetchR = Result2.try(() => fetchIssueFromUrl(url));
72
+ if (!fetchR.ok) {
73
+ spinner.fail("Could not fetch issue data");
74
+ showWarning(fetchR.error.message);
75
+ log.newline();
76
+ return void 0;
77
+ }
78
+ const data = fetchR.value;
79
+ if (!data) {
80
+ spinner.stop();
81
+ return void 0;
82
+ }
83
+ spinner.succeed("Issue data fetched");
84
+ log.newline();
85
+ const bodyPreview = data.body.length > 200 ? data.body.slice(0, 200) + "..." : data.body;
86
+ const cardLines = [`Title: ${data.title}`, "", bodyPreview];
87
+ if (data.comments.length > 0) {
88
+ cardLines.push("", `${String(data.comments.length)} comment(s)`);
89
+ }
90
+ console.log(renderCard(`${icons.info} Fetched Issue`, cardLines));
91
+ log.newline();
92
+ return data;
93
+ }
94
+ function validateUrl(url) {
95
+ try {
96
+ new URL(url);
97
+ return true;
98
+ } catch {
99
+ return false;
100
+ }
101
+ }
102
+ async function addSingleTicketNonInteractive(options) {
103
+ const errors = [];
104
+ const trimmedTitle = options.title?.trim();
105
+ if (!trimmedTitle) {
106
+ errors.push("--title is required");
107
+ }
108
+ if (options.link && !validateUrl(options.link)) {
109
+ errors.push("--link must be a valid URL");
110
+ }
111
+ if (errors.length > 0 || !trimmedTitle) {
112
+ showError("Validation failed");
113
+ for (const e of errors) {
114
+ log.item(error(e));
115
+ }
116
+ log.newline();
117
+ exitWithCode(EXIT_ERROR);
118
+ }
119
+ const title = trimmedTitle;
120
+ const trimmedDesc = options.description?.trim();
121
+ const description = trimmedDesc === "" ? void 0 : trimmedDesc;
122
+ const trimmedLink = options.link?.trim();
123
+ const link = trimmedLink === "" ? void 0 : trimmedLink;
124
+ const addR = await wrapAsync(() => addTicket({ title, description, link }), ensureError);
125
+ if (!addR.ok) {
126
+ handleTicketError(addR.error);
127
+ return;
128
+ }
129
+ await showTicketResult(addR.value);
130
+ }
131
+ async function addSingleTicketInteractive(options) {
132
+ const link = await getPrompt().input({
133
+ message: `${icons.info} Issue link (optional):`,
134
+ default: options.link?.trim(),
135
+ validate: (v) => {
136
+ if (!v) return true;
137
+ return validateUrl(v) ? true : "Invalid URL format";
138
+ }
139
+ });
140
+ const trimmedLink = link.trim();
141
+ const normalizedLink = trimmedLink === "" ? void 0 : trimmedLink;
142
+ let prefill;
143
+ if (normalizedLink) {
144
+ prefill = tryFetchIssue(normalizedLink);
145
+ }
146
+ let title = await getPrompt().input({
147
+ message: `${icons.ticket} Title:`,
148
+ default: prefill?.title ?? options.title?.trim(),
149
+ validate: (v) => v.trim().length > 0 ? true : "Title is required"
150
+ });
151
+ const descR = await editorInput({
152
+ message: "Description (recommended):",
153
+ default: prefill?.body ?? options.description?.trim()
154
+ });
155
+ if (!descR.ok) {
156
+ showError(`Editor input failed: ${descR.error.message}`);
157
+ return null;
158
+ }
159
+ const description = descR.value;
160
+ title = title.trim();
161
+ const trimmedDescription = description.trim();
162
+ const normalizedDescription = trimmedDescription === "" ? void 0 : trimmedDescription;
163
+ const addR = await wrapAsync(
164
+ () => addTicket({ title, description: normalizedDescription, link: normalizedLink }),
165
+ ensureError
166
+ );
167
+ if (!addR.ok) {
168
+ handleTicketError(addR.error);
169
+ return null;
170
+ }
171
+ await showTicketResult(addR.value);
172
+ return addR.value;
173
+ }
174
+ async function showTicketResult(ticket) {
175
+ const sprintR = await wrapAsync(() => getCurrentSprintOrThrow(), ensureError);
176
+ const projectLabel = sprintR.ok ? await wrapAsync(() => getProjectById(sprintR.value.projectId), ensureError).then(
177
+ (r) => r.ok ? `${r.value.displayName} (${r.value.name})` : sprintR.value.projectId
178
+ ) : "unknown";
179
+ showSuccess("Ticket added!", [
180
+ ["ID", ticket.id],
181
+ ["Title", ticket.title],
182
+ ["Project", projectLabel]
183
+ ]);
184
+ if (ticket.description) {
185
+ console.log(fieldMultiline("Description", ticket.description));
186
+ }
187
+ if (ticket.link) {
188
+ console.log(field("Link", ticket.link));
189
+ }
190
+ console.log("");
191
+ }
192
+ function handleTicketError(err) {
193
+ if (err instanceof SprintStatusError) {
194
+ showError(err.message);
195
+ } else if (err instanceof Error && err.message.includes("does not exist")) {
196
+ showError(err.message);
197
+ } else {
198
+ throw err;
199
+ }
200
+ }
201
+ async function ticketAddCommand(options = {}) {
202
+ if (options.interactive === false) {
203
+ await addSingleTicketNonInteractive(options);
204
+ return;
205
+ }
206
+ let count = 0;
207
+ while (true) {
208
+ const ticket = await addSingleTicketInteractive(options);
209
+ if (ticket) {
210
+ count++;
211
+ log.dim(`${String(count)} ticket(s) added in this session`);
212
+ } else {
213
+ break;
214
+ }
215
+ const another = await getPrompt().confirm({
216
+ message: `${emoji.donut} Add another ticket?`,
217
+ default: true
218
+ });
219
+ if (!another) break;
220
+ }
221
+ }
222
+
223
+ export {
224
+ editorInput,
225
+ addSingleTicketInteractive,
226
+ ticketAddCommand
227
+ };
@@ -3,35 +3,51 @@ import {
3
3
  ProjectsSchema,
4
4
  expandTilde,
5
5
  fileExists,
6
+ generateUuid8,
6
7
  getProjectsFilePath,
7
8
  readValidatedJson,
8
9
  validateProjectPath,
9
10
  writeValidatedJson
10
- } from "./chunk-IB6OCKZW.mjs";
11
+ } from "./chunk-CTP2A436.mjs";
11
12
  import {
12
13
  ParseError,
13
14
  ProjectExistsError,
14
15
  ProjectNotFoundError,
15
16
  ValidationError
16
- } from "./chunk-EDJX7TT6.mjs";
17
+ } from "./chunk-57UWLHRH.mjs";
17
18
 
18
- // src/store/project.ts
19
+ // src/integration/persistence/project.ts
19
20
  import { basename, resolve } from "path";
20
21
  function migrateProjectIfNeeded(project) {
21
- if (project.repositories) {
22
- return project;
23
- }
24
- if (project.paths) {
22
+ const id = project.id ?? generateUuid8();
23
+ if (project.paths && !project.repositories) {
25
24
  return {
25
+ id,
26
26
  name: project.name,
27
27
  displayName: project.displayName,
28
28
  repositories: project.paths.map((p) => ({
29
+ id: generateUuid8(),
29
30
  name: basename(p),
30
31
  path: resolve(expandTilde(p))
31
32
  })),
32
33
  description: project.description
33
34
  };
34
35
  }
36
+ if (project.repositories) {
37
+ return {
38
+ id,
39
+ name: project.name,
40
+ displayName: project.displayName,
41
+ repositories: project.repositories.map((r) => ({
42
+ id: r.id ?? generateUuid8(),
43
+ name: r.name,
44
+ path: r.path,
45
+ checkScript: r.checkScript,
46
+ checkTimeout: r.checkTimeout
47
+ })),
48
+ description: project.description
49
+ };
50
+ }
35
51
  throw new ParseError(`Invalid project data: no paths or repositories for ${project.name}`);
36
52
  }
37
53
  async function listProjects() {
@@ -42,7 +58,11 @@ async function listProjects() {
42
58
  const { readFile } = await import("fs/promises");
43
59
  const content = await readFile(filePath, "utf-8");
44
60
  const rawData = JSON.parse(content);
45
- const needsMigration = rawData.some((p) => p.paths && !p.repositories);
61
+ const needsMigration = rawData.some((p) => {
62
+ if (!p.id) return true;
63
+ if (p.paths && !p.repositories) return true;
64
+ return (p.repositories ?? []).some((r) => !r.id);
65
+ });
46
66
  if (needsMigration) {
47
67
  const migrated = rawData.map(migrateProjectIfNeeded);
48
68
  const validated = ProjectsSchema.parse(migrated);
@@ -76,17 +96,37 @@ async function getProject(name) {
76
96
  }
77
97
  return project;
78
98
  }
99
+ async function getProjectById(id) {
100
+ const projects = await listProjects();
101
+ const project = projects.find((p) => p.id === id);
102
+ if (!project) {
103
+ throw new ProjectNotFoundError(id);
104
+ }
105
+ return project;
106
+ }
107
+ async function getRepoById(repoId) {
108
+ const projects = await listProjects();
109
+ for (const project of projects) {
110
+ const repo = project.repositories.find((r) => r.id === repoId);
111
+ if (repo) return { project, repo };
112
+ }
113
+ throw new ValidationError(`Repository not found: ${repoId}`, "repoId");
114
+ }
115
+ async function resolveRepoPath(repoId) {
116
+ const { repo } = await getRepoById(repoId);
117
+ return repo.path;
118
+ }
79
119
  async function projectExists(name) {
80
120
  const projects = await listProjects();
81
121
  return projects.some((p) => p.name === name);
82
122
  }
83
- async function createProject(project) {
123
+ async function createProject(input) {
84
124
  const projects = await listProjects();
85
- if (projects.some((p) => p.name === project.name)) {
86
- throw new ProjectExistsError(project.name);
125
+ if (projects.some((p) => p.name === input.name)) {
126
+ throw new ProjectExistsError(input.name);
87
127
  }
88
128
  const pathErrors = [];
89
- for (const repo of project.repositories) {
129
+ for (const repo of input.repositories) {
90
130
  const resolved = resolve(expandTilde(repo.path));
91
131
  const validation = await validateProjectPath(resolved);
92
132
  if (!validation.ok) {
@@ -98,12 +138,20 @@ async function createProject(project) {
98
138
  ${pathErrors.join("\n")}`, "repositories");
99
139
  }
100
140
  const normalizedProject = {
101
- ...project,
102
- repositories: project.repositories.map((repo) => ({
103
- ...repo,
104
- name: repo.name || basename(repo.path),
105
- path: resolve(expandTilde(repo.path))
106
- }))
141
+ id: input.id ?? generateUuid8(),
142
+ name: input.name,
143
+ displayName: input.displayName,
144
+ description: input.description,
145
+ repositories: input.repositories.map((repo) => {
146
+ const resolvedPath = resolve(expandTilde(repo.path));
147
+ return {
148
+ id: repo.id ?? generateUuid8(),
149
+ name: repo.name && repo.name.length > 0 ? repo.name : basename(resolvedPath),
150
+ path: resolvedPath,
151
+ checkScript: repo.checkScript,
152
+ checkTimeout: repo.checkTimeout
153
+ };
154
+ })
107
155
  };
108
156
  projects.push(normalizedProject);
109
157
  const writeResult = await writeValidatedJson(getProjectsFilePath(), projects, ProjectsSchema);
@@ -130,9 +178,11 @@ async function updateProject(name, updates) {
130
178
  ${pathErrors.join("\n")}`, "repositories");
131
179
  }
132
180
  updates.repositories = updates.repositories.map((repo) => ({
133
- ...repo,
134
- name: repo.name || basename(repo.path),
135
- path: resolve(expandTilde(repo.path))
181
+ id: repo.id.length > 0 ? repo.id : generateUuid8(),
182
+ name: repo.name && repo.name.length > 0 ? repo.name : basename(resolve(expandTilde(repo.path))),
183
+ path: resolve(expandTilde(repo.path)),
184
+ checkScript: repo.checkScript,
185
+ checkTimeout: repo.checkTimeout
136
186
  }));
137
187
  }
138
188
  const existingProject = projects[index];
@@ -140,6 +190,7 @@ ${pathErrors.join("\n")}`, "repositories");
140
190
  throw new ProjectNotFoundError(name);
141
191
  }
142
192
  const updatedProject = {
193
+ id: existingProject.id,
143
194
  name: existingProject.name,
144
195
  displayName: updates.displayName ?? existingProject.displayName,
145
196
  repositories: updates.repositories ?? existingProject.repositories,
@@ -160,10 +211,6 @@ async function removeProject(name) {
160
211
  const writeResult = await writeValidatedJson(getProjectsFilePath(), projects, ProjectsSchema);
161
212
  if (!writeResult.ok) throw writeResult.error;
162
213
  }
163
- async function getProjectRepos(name) {
164
- const project = await getProject(name);
165
- return project.repositories;
166
- }
167
214
  async function addProjectRepo(name, repo) {
168
215
  const project = await getProject(name);
169
216
  const resolvedPath = resolve(expandTilde(repo.path));
@@ -175,9 +222,11 @@ async function addProjectRepo(name, repo) {
175
222
  return project;
176
223
  }
177
224
  const normalizedRepo = {
178
- ...repo,
179
- name: repo.name || basename(resolvedPath),
180
- path: resolvedPath
225
+ id: repo.id ?? generateUuid8(),
226
+ name: repo.name && repo.name.length > 0 ? repo.name : basename(resolvedPath),
227
+ path: resolvedPath,
228
+ checkScript: repo.checkScript,
229
+ checkTimeout: repo.checkTimeout
181
230
  };
182
231
  return updateProject(name, {
183
232
  repositories: [...project.repositories, normalizedRepo]
@@ -199,11 +248,13 @@ async function removeProjectRepo(name, path) {
199
248
  export {
200
249
  listProjects,
201
250
  getProject,
251
+ getProjectById,
252
+ getRepoById,
253
+ resolveRepoPath,
202
254
  projectExists,
203
255
  createProject,
204
256
  updateProject,
205
257
  removeProject,
206
- getProjectRepos,
207
258
  addProjectRepo,
208
259
  removeProjectRepo
209
260
  };