@thxgg/steward 0.1.16 → 0.1.18

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 (157) hide show
  1. package/.output/nitro.json +1 -1
  2. package/.output/public/_nuxt/{CxHOXVf6.js → -z_Gr0GN.js} +1 -1
  3. package/.output/public/_nuxt/5LlyHjkF.js +60 -0
  4. package/.output/public/_nuxt/BA0u_CRT.js +1 -0
  5. package/.output/public/_nuxt/{Bibm_IDv.js → BA4e9-N5.js} +2 -2
  6. package/.output/public/_nuxt/{BSA0RJ-H.js → BO8EM227.js} +1 -1
  7. package/.output/public/_nuxt/C0XT5P3Q.js +1 -0
  8. package/.output/public/_nuxt/{BWVTacYj.js → CGzrvVc6.js} +1 -1
  9. package/.output/public/_nuxt/{Dum5qplW.js → CJlXUkTg.js} +1 -1
  10. package/.output/public/_nuxt/CZsXZugv.js +1 -0
  11. package/.output/public/_nuxt/{ynmyrfyT.js → C_HVaH3B.js} +1 -1
  12. package/.output/public/_nuxt/{wbjFvimm.js → DAnnHVQP.js} +1 -1
  13. package/.output/public/_nuxt/DEr8q68O.js +141 -0
  14. package/.output/public/_nuxt/Detail.DSyVQNdr.css +1 -0
  15. package/.output/public/_nuxt/DrXxYwWw.js +30 -0
  16. package/.output/public/_nuxt/QAzsKGuP.js +1 -0
  17. package/.output/public/_nuxt/TSsR_oCL.js +1 -0
  18. package/.output/public/_nuxt/WUF6Thhn.js +13 -0
  19. package/.output/public/_nuxt/_prd_.BkpxMFSV.css +1 -0
  20. package/.output/public/_nuxt/builds/latest.json +1 -1
  21. package/.output/public/_nuxt/builds/meta/19e0e040-a531-4c25-b46d-a6ca54a1ae3e.json +1 -0
  22. package/.output/public/_nuxt/i9wn3hS7.js +1 -0
  23. package/.output/server/chunks/_/git-api.mjs +101 -8
  24. package/.output/server/chunks/_/git-api.mjs.map +1 -1
  25. package/.output/server/chunks/_/git.mjs +3 -10
  26. package/.output/server/chunks/_/git.mjs.map +1 -1
  27. package/.output/server/chunks/_/prd-service.mjs +366 -0
  28. package/.output/server/chunks/_/prd-service.mjs.map +1 -0
  29. package/.output/server/chunks/_/repos.mjs +448 -0
  30. package/.output/server/chunks/_/repos.mjs.map +1 -0
  31. package/.output/server/chunks/_/task-graph.mjs +13 -14
  32. package/.output/server/chunks/_/task-graph.mjs.map +1 -1
  33. package/.output/server/chunks/_/watcher.mjs +54 -68
  34. package/.output/server/chunks/_/watcher.mjs.map +1 -1
  35. package/.output/server/chunks/build/{Detail-CUfU85GY.mjs → Detail-BQSkP9Zm.mjs} +170 -74
  36. package/.output/server/chunks/build/Detail-BQSkP9Zm.mjs.map +1 -0
  37. package/.output/server/chunks/build/DiffViewer-styles-1.mjs-BFsE2PCW.mjs +4 -0
  38. package/.output/server/chunks/build/DiffViewer-styles-1.mjs-BFsE2PCW.mjs.map +1 -0
  39. package/.output/server/chunks/build/DiffViewer-styles.D2bqX3nK.mjs +8 -0
  40. package/.output/server/chunks/build/DiffViewer-styles.D2bqX3nK.mjs.map +1 -0
  41. package/.output/server/chunks/build/DiffViewer-styles.FoV36wuV.mjs +10 -0
  42. package/.output/server/chunks/build/DiffViewer-styles.FoV36wuV.mjs.map +1 -0
  43. package/.output/server/chunks/build/Viewer-styles.D6wYWFb1.mjs +8 -0
  44. package/.output/server/chunks/build/Viewer-styles.D6wYWFb1.mjs.map +1 -0
  45. package/.output/server/chunks/build/{_prd_-CeVnQzOV.mjs → _prd_-CBR_wm9i.mjs} +73 -33
  46. package/.output/server/chunks/build/_prd_-CBR_wm9i.mjs.map +1 -0
  47. package/.output/server/chunks/build/client.precomputed.mjs +1 -1
  48. package/.output/server/chunks/build/{default-DWCOHHTE.mjs → default-Cao5eO80.mjs} +2 -3
  49. package/.output/server/chunks/build/default-Cao5eO80.mjs.map +1 -0
  50. package/.output/server/chunks/build/error-404-Bf6kdO80.mjs +0 -1
  51. package/.output/server/chunks/build/error-500-D_bcARXN.mjs +0 -1
  52. package/.output/server/chunks/build/{index-CckL_NBD.mjs → index-ByZO4Bvq.mjs} +2 -2
  53. package/.output/server/chunks/build/index-ByZO4Bvq.mjs.map +1 -0
  54. package/.output/server/chunks/build/{index-QVeSHT3L.mjs → index-ljj9uTXI.mjs} +6 -5
  55. package/.output/server/chunks/build/index-ljj9uTXI.mjs.map +1 -0
  56. package/.output/server/chunks/build/nuxt-link-SvT1nf8Z.mjs +1 -1
  57. package/.output/server/chunks/build/{repo-graph-CHNl58mY.mjs → repo-graph-CVnkmn8i.mjs} +23 -10
  58. package/.output/server/chunks/build/repo-graph-CVnkmn8i.mjs.map +1 -0
  59. package/.output/server/chunks/build/server.mjs +5 -6
  60. package/.output/server/chunks/build/styles.mjs +4 -4
  61. package/.output/server/chunks/build/{usePrd-SqcxGyFU.mjs → usePrd-f7ylhIqs.mjs} +10 -34
  62. package/.output/server/chunks/build/usePrd-f7ylhIqs.mjs.map +1 -0
  63. package/.output/server/chunks/nitro/nitro.mjs +614 -1211
  64. package/.output/server/chunks/nitro/nitro.mjs.map +1 -1
  65. package/.output/server/chunks/routes/api/browse.get.mjs +34 -10
  66. package/.output/server/chunks/routes/api/browse.get.mjs.map +1 -1
  67. package/.output/server/chunks/routes/api/index.get.mjs +3 -2
  68. package/.output/server/chunks/routes/api/index.get.mjs.map +1 -1
  69. package/.output/server/chunks/routes/api/index.post.mjs +22 -7
  70. package/.output/server/chunks/routes/api/index.post.mjs.map +1 -1
  71. package/.output/server/chunks/routes/api/repos/_repoId/git/commits.get.mjs +29 -23
  72. package/.output/server/chunks/routes/api/repos/_repoId/git/commits.get.mjs.map +1 -1
  73. package/.output/server/chunks/routes/api/repos/_repoId/git/diff.get.mjs +12 -7
  74. package/.output/server/chunks/routes/api/repos/_repoId/git/diff.get.mjs.map +1 -1
  75. package/.output/server/chunks/routes/api/repos/_repoId/git/file-content.get.mjs +32 -13
  76. package/.output/server/chunks/routes/api/repos/_repoId/git/file-content.get.mjs.map +1 -1
  77. package/.output/server/chunks/routes/api/repos/_repoId/git/file-diff.get.mjs +14 -14
  78. package/.output/server/chunks/routes/api/repos/_repoId/git/file-diff.get.mjs.map +1 -1
  79. package/.output/server/chunks/routes/api/repos/_repoId/graph.get.mjs +7 -2
  80. package/.output/server/chunks/routes/api/repos/_repoId/graph.get.mjs.map +1 -1
  81. package/.output/server/chunks/routes/api/repos/_repoId/prd/_prdSlug/graph.get.mjs +16 -2
  82. package/.output/server/chunks/routes/api/repos/_repoId/prd/_prdSlug/graph.get.mjs.map +1 -1
  83. package/.output/server/chunks/routes/api/repos/_repoId/prd/_prdSlug/progress.get.mjs +21 -9
  84. package/.output/server/chunks/routes/api/repos/_repoId/prd/_prdSlug/progress.get.mjs.map +1 -1
  85. package/.output/server/chunks/routes/api/repos/_repoId/prd/_prdSlug/tasks/_taskId/commits.get.mjs +21 -85
  86. package/.output/server/chunks/routes/api/repos/_repoId/prd/_prdSlug/tasks/_taskId/commits.get.mjs.map +1 -1
  87. package/.output/server/chunks/routes/api/repos/_repoId/prd/_prdSlug/tasks.get.mjs +21 -9
  88. package/.output/server/chunks/routes/api/repos/_repoId/prd/_prdSlug/tasks.get.mjs.map +1 -1
  89. package/.output/server/chunks/routes/api/repos/_repoId/prd/_prdSlug_.get.mjs +31 -50
  90. package/.output/server/chunks/routes/api/repos/_repoId/prd/_prdSlug_.get.mjs.map +1 -1
  91. package/.output/server/chunks/routes/api/repos/_repoId/prds.get.mjs +20 -49
  92. package/.output/server/chunks/routes/api/repos/_repoId/prds.get.mjs.map +1 -1
  93. package/.output/server/chunks/routes/api/repos/_repoId/refresh-git-repos.post.mjs +6 -13
  94. package/.output/server/chunks/routes/api/repos/_repoId/refresh-git-repos.post.mjs.map +1 -1
  95. package/.output/server/chunks/routes/api/repos/_repoId_.delete.mjs +3 -2
  96. package/.output/server/chunks/routes/api/repos/_repoId_.delete.mjs.map +1 -1
  97. package/.output/server/chunks/routes/api/runtime.get.mjs +1 -2
  98. package/.output/server/chunks/routes/api/runtime.get.mjs.map +1 -1
  99. package/.output/server/chunks/routes/api/watch.get.mjs +5 -4
  100. package/.output/server/chunks/routes/api/watch.get.mjs.map +1 -1
  101. package/.output/server/chunks/routes/renderer.mjs +1 -1
  102. package/.output/server/index.mjs +1 -2
  103. package/.output/server/index.mjs.map +1 -1
  104. package/.output/server/node_modules/zod/index.js +4 -0
  105. package/.output/server/node_modules/zod/package.json +118 -0
  106. package/.output/server/node_modules/zod/v3/ZodError.js +133 -0
  107. package/.output/server/node_modules/zod/v3/errors.js +9 -0
  108. package/.output/server/node_modules/zod/v3/external.js +6 -0
  109. package/.output/server/node_modules/zod/v3/helpers/errorUtil.js +6 -0
  110. package/.output/server/node_modules/zod/v3/helpers/parseUtil.js +109 -0
  111. package/.output/server/node_modules/zod/v3/helpers/typeAliases.js +1 -0
  112. package/.output/server/node_modules/zod/v3/helpers/util.js +133 -0
  113. package/.output/server/node_modules/zod/v3/locales/en.js +109 -0
  114. package/.output/server/node_modules/zod/v3/types.js +3693 -0
  115. package/.output/server/package.json +2 -1
  116. package/README.md +2 -2
  117. package/dist/host/src/api/prds.js +6 -172
  118. package/dist/host/src/api/repos.js +3 -18
  119. package/dist/host/src/api/state.js +8 -9
  120. package/dist/host/src/executor-runner.js +368 -0
  121. package/dist/host/src/executor.js +138 -260
  122. package/dist/host/src/mcp.js +27 -1
  123. package/dist/host/src/ui.js +14 -4
  124. package/dist/server/utils/change-events.js +33 -0
  125. package/dist/server/utils/git.js +11 -16
  126. package/dist/server/utils/prd-service.js +231 -0
  127. package/dist/server/utils/prd-state.js +54 -162
  128. package/dist/server/utils/repos.js +72 -17
  129. package/dist/server/utils/state-schema.js +61 -0
  130. package/dist/server/utils/task-graph.js +13 -13
  131. package/package.json +2 -1
  132. package/.output/public/_nuxt/CVJh28bx.js +0 -1
  133. package/.output/public/_nuxt/CyZuidLG.js +0 -60
  134. package/.output/public/_nuxt/D0op9E2g.js +0 -1
  135. package/.output/public/_nuxt/DX8awZaa.js +0 -1
  136. package/.output/public/_nuxt/Detail.z33AHKev.css +0 -1
  137. package/.output/public/_nuxt/DiTJUZOC.js +0 -1
  138. package/.output/public/_nuxt/T_3JE9C-.js +0 -1
  139. package/.output/public/_nuxt/WOI2tLsR.js +0 -42
  140. package/.output/public/_nuxt/_prd_.KTotLoF_.css +0 -1
  141. package/.output/public/_nuxt/builds/meta/029070b0-b8e2-4988-84f4-d0c9ff55c998.json +0 -1
  142. package/.output/public/_nuxt/odRGDGwj.js +0 -1
  143. package/.output/server/chunks/build/Detail-CUfU85GY.mjs.map +0 -1
  144. package/.output/server/chunks/build/DiffViewer-styles-1.mjs-CS8FTppg.mjs +0 -4
  145. package/.output/server/chunks/build/DiffViewer-styles-1.mjs-CS8FTppg.mjs.map +0 -1
  146. package/.output/server/chunks/build/DiffViewer-styles.AUfwwelI.mjs +0 -10
  147. package/.output/server/chunks/build/DiffViewer-styles.AUfwwelI.mjs.map +0 -1
  148. package/.output/server/chunks/build/DiffViewer-styles.D_it8zfk.mjs +0 -8
  149. package/.output/server/chunks/build/DiffViewer-styles.D_it8zfk.mjs.map +0 -1
  150. package/.output/server/chunks/build/Viewer-styles.CshnetGw.mjs +0 -8
  151. package/.output/server/chunks/build/Viewer-styles.CshnetGw.mjs.map +0 -1
  152. package/.output/server/chunks/build/_prd_-CeVnQzOV.mjs.map +0 -1
  153. package/.output/server/chunks/build/default-DWCOHHTE.mjs.map +0 -1
  154. package/.output/server/chunks/build/index-CckL_NBD.mjs.map +0 -1
  155. package/.output/server/chunks/build/index-QVeSHT3L.mjs.map +0 -1
  156. package/.output/server/chunks/build/repo-graph-CHNl58mY.mjs.map +0 -1
  157. package/.output/server/chunks/build/usePrd-SqcxGyFU.mjs.map +0 -1
@@ -0,0 +1,231 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import { basename, isAbsolute, join, relative, resolve } from 'node:path';
3
+ import { resolveCommitRepo } from './git.js';
4
+ import { getPrdState, getPrdStateSummaries } from './prd-state.js';
5
+ import { discoverGitRepos, updateRepoGitRepos } from './repos.js';
6
+ const PRD_SLUG_PATTERN = /^[A-Za-z0-9][A-Za-z0-9-]*$/;
7
+ function normalizePathSlashes(path) {
8
+ return path.replaceAll('\\', '/');
9
+ }
10
+ function normalizeGitRepos(gitRepos) {
11
+ if (!gitRepos || gitRepos.length === 0) {
12
+ return undefined;
13
+ }
14
+ return gitRepos.map((gitRepo) => ({
15
+ ...gitRepo,
16
+ relativePath: normalizePathSlashes(gitRepo.relativePath)
17
+ }));
18
+ }
19
+ function hasPathTraversal(basePath, candidatePath) {
20
+ const relativePath = relative(resolve(basePath), resolve(candidatePath));
21
+ return relativePath.startsWith('..') || isAbsolute(relativePath);
22
+ }
23
+ function extractCommitSha(entry) {
24
+ return typeof entry === 'string' ? entry : entry.sha;
25
+ }
26
+ function parseMetadata(content) {
27
+ const metadata = {};
28
+ const authorMatch = content.match(/\*{0,2}Author\*{0,2}:\*{0,2}\s*(.+?)(?:\n|$)/i);
29
+ if (authorMatch?.[1]) {
30
+ metadata.author = authorMatch[1].trim();
31
+ }
32
+ const dateMatch = content.match(/\*{0,2}Date\*{0,2}:\*{0,2}\s*(.+?)(?:\n|$)/i);
33
+ if (dateMatch?.[1]) {
34
+ metadata.date = dateMatch[1].trim();
35
+ }
36
+ const statusMatch = content.match(/\*{0,2}Status\*{0,2}:\*{0,2}\s*(.+?)(?:\n|$)/i);
37
+ if (statusMatch?.[1]) {
38
+ metadata.status = statusMatch[1].trim();
39
+ }
40
+ const shortcutLinkMatch = content.match(/\[([Ss][Cc]-\d+)\]\(([^)]+)\)/);
41
+ if (shortcutLinkMatch?.[1] && shortcutLinkMatch[2]) {
42
+ metadata.shortcutStory = shortcutLinkMatch[1];
43
+ metadata.shortcutUrl = shortcutLinkMatch[2];
44
+ }
45
+ else {
46
+ const shortcutIdMatch = content.match(/\*{0,2}Shortcut(?:\s+Story)?\*{0,2}:\*{0,2}\s*([Ss][Cc]-\d+)/i);
47
+ if (shortcutIdMatch?.[1]) {
48
+ metadata.shortcutStory = shortcutIdMatch[1];
49
+ }
50
+ }
51
+ return metadata;
52
+ }
53
+ export function isValidPrdSlug(prdSlug) {
54
+ return PRD_SLUG_PATTERN.test(prdSlug);
55
+ }
56
+ export function assertValidPrdSlug(prdSlug) {
57
+ if (!isValidPrdSlug(prdSlug)) {
58
+ throw new Error('Invalid PRD slug format');
59
+ }
60
+ }
61
+ export function resolvePrdMarkdownPath(repoPath, prdSlug) {
62
+ assertValidPrdSlug(prdSlug);
63
+ const prdDir = resolve(repoPath, 'docs', 'prd');
64
+ const prdPath = resolve(prdDir, `${prdSlug}.md`);
65
+ if (hasPathTraversal(prdDir, prdPath)) {
66
+ throw new Error('Invalid PRD slug path traversal');
67
+ }
68
+ return prdPath;
69
+ }
70
+ export function extractPrdTitle(content, fallbackSlug) {
71
+ const h1Match = content.match(/^#\s+(.+)$/m);
72
+ if (h1Match?.[1]) {
73
+ return h1Match[1].trim();
74
+ }
75
+ return fallbackSlug;
76
+ }
77
+ export async function readPrdDocument(repo, prdSlug) {
78
+ const prdPath = resolvePrdMarkdownPath(repo.path, prdSlug);
79
+ let content;
80
+ try {
81
+ content = await fs.readFile(prdPath, 'utf-8');
82
+ }
83
+ catch {
84
+ throw new Error('PRD not found');
85
+ }
86
+ return {
87
+ slug: prdSlug,
88
+ name: extractPrdTitle(content, prdSlug),
89
+ content,
90
+ metadata: parseMetadata(content)
91
+ };
92
+ }
93
+ export async function listPrdDocuments(repo) {
94
+ const prdDir = join(repo.path, 'docs', 'prd');
95
+ let prdFiles = [];
96
+ try {
97
+ const files = await fs.readdir(prdDir);
98
+ prdFiles = files.filter((file) => {
99
+ if (!file.endsWith('.md')) {
100
+ return false;
101
+ }
102
+ const slug = basename(file, '.md');
103
+ return isValidPrdSlug(slug);
104
+ });
105
+ }
106
+ catch {
107
+ return [];
108
+ }
109
+ const stateSummaries = await getPrdStateSummaries(repo.id);
110
+ const items = await Promise.all(prdFiles.map(async (filename) => {
111
+ const slug = basename(filename, '.md');
112
+ const filePath = join(prdDir, filename);
113
+ let name = slug;
114
+ let modifiedAt = 0;
115
+ try {
116
+ const [content, stat] = await Promise.all([
117
+ fs.readFile(filePath, 'utf-8'),
118
+ fs.stat(filePath)
119
+ ]);
120
+ modifiedAt = stat.mtime.getTime();
121
+ name = extractPrdTitle(content, slug);
122
+ }
123
+ catch {
124
+ // Keep fallback values when file cannot be read.
125
+ }
126
+ const stateSummary = stateSummaries.get(slug);
127
+ return {
128
+ slug,
129
+ name,
130
+ source: `docs/prd/${filename}`,
131
+ hasState: !!stateSummary?.hasState,
132
+ modifiedAt,
133
+ ...(stateSummary?.taskCount !== undefined && { taskCount: stateSummary.taskCount }),
134
+ ...(stateSummary?.completedCount !== undefined && { completedCount: stateSummary.completedCount })
135
+ };
136
+ }));
137
+ items.sort((a, b) => b.modifiedAt - a.modifiedAt);
138
+ return items;
139
+ }
140
+ export async function readPrdTasks(repo, prdSlug) {
141
+ assertValidPrdSlug(prdSlug);
142
+ const state = await getPrdState(repo.id, prdSlug);
143
+ return state?.tasks ?? null;
144
+ }
145
+ export async function readPrdProgress(repo, prdSlug) {
146
+ assertValidPrdSlug(prdSlug);
147
+ const state = await getPrdState(repo.id, prdSlug);
148
+ return state?.progress ?? null;
149
+ }
150
+ function hasDiscoveredRepoChanges(repo, discoveredRepos) {
151
+ const normalizedExisting = normalizeGitRepos(repo.gitRepos) || [];
152
+ const normalizedDiscovered = normalizeGitRepos(discoveredRepos) || [];
153
+ if (normalizedExisting.length !== normalizedDiscovered.length) {
154
+ return true;
155
+ }
156
+ const existingPaths = new Set(normalizedExisting.map((gitRepo) => gitRepo.relativePath));
157
+ for (const gitRepo of normalizedDiscovered) {
158
+ if (!existingPaths.has(gitRepo.relativePath)) {
159
+ return true;
160
+ }
161
+ }
162
+ return false;
163
+ }
164
+ async function refreshDiscoveredGitRepos(repo) {
165
+ const discoveredRepos = normalizeGitRepos(await discoverGitRepos(repo.path));
166
+ if (!hasDiscoveredRepoChanges(repo, discoveredRepos)) {
167
+ return null;
168
+ }
169
+ const updated = await updateRepoGitRepos(repo.id, discoveredRepos);
170
+ if (!updated) {
171
+ return null;
172
+ }
173
+ return {
174
+ ...repo,
175
+ gitRepos: discoveredRepos
176
+ };
177
+ }
178
+ export async function resolveTaskCommits(repo, prdSlug, taskId) {
179
+ assertValidPrdSlug(prdSlug);
180
+ const state = await getPrdState(repo.id, prdSlug);
181
+ const progress = state?.progress ?? null;
182
+ if (!progress) {
183
+ return [];
184
+ }
185
+ const taskLogs = Array.isArray(progress.taskLogs) ? progress.taskLogs : [];
186
+ const taskLog = taskLogs.find((log) => log.taskId === taskId);
187
+ if (!taskLog || !taskLog.commits || taskLog.commits.length === 0) {
188
+ return [];
189
+ }
190
+ const resolvedCommits = [];
191
+ const failedEntries = [];
192
+ for (const commitEntry of taskLog.commits) {
193
+ try {
194
+ const resolved = await resolveCommitRepo(repo, commitEntry);
195
+ resolvedCommits.push({
196
+ sha: resolved.sha,
197
+ repo: resolved.repoPath
198
+ });
199
+ }
200
+ catch {
201
+ failedEntries.push(commitEntry);
202
+ }
203
+ }
204
+ if (failedEntries.length === 0) {
205
+ return resolvedCommits;
206
+ }
207
+ const refreshedRepo = await refreshDiscoveredGitRepos(repo);
208
+ for (const commitEntry of failedEntries) {
209
+ if (!refreshedRepo) {
210
+ resolvedCommits.push({
211
+ sha: extractCommitSha(commitEntry),
212
+ repo: ''
213
+ });
214
+ continue;
215
+ }
216
+ try {
217
+ const resolved = await resolveCommitRepo(refreshedRepo, commitEntry);
218
+ resolvedCommits.push({
219
+ sha: resolved.sha,
220
+ repo: resolved.repoPath
221
+ });
222
+ }
223
+ catch {
224
+ resolvedCommits.push({
225
+ sha: extractCommitSha(commitEntry),
226
+ repo: ''
227
+ });
228
+ }
229
+ }
230
+ return resolvedCommits;
231
+ }
@@ -1,15 +1,13 @@
1
- import { promises as fs } from 'node:fs';
2
- import { join } from 'node:path';
1
+ import { emitChange } from './change-events.js';
3
2
  import { dbAll, dbGet, dbRun } from './db.js';
4
- const LEGACY_STATE_STABLE_MS = 0;
5
- const migrationInFlight = new Map();
6
- const cleanupCompletedRepoIds = new Set();
7
- function parseStoredJson(raw, fieldName) {
3
+ import { parseProgressFile, parseTasksFile } from './state-schema.js';
4
+ function parseStoredJson(raw, fieldName, parseValue) {
8
5
  if (!raw) {
9
6
  return null;
10
7
  }
11
8
  try {
12
- return JSON.parse(raw);
9
+ const parsed = JSON.parse(raw);
10
+ return parseValue(parsed);
13
11
  }
14
12
  catch (error) {
15
13
  const message = error instanceof Error ? error.message : String(error);
@@ -24,25 +22,6 @@ function getTaskCounts(tasksFile) {
24
22
  const completedCount = tasksFile.tasks.filter(task => task.status === 'completed').length;
25
23
  return { taskCount, completedCount };
26
24
  }
27
- function normalizeLegacyTasksFile(tasksFile) {
28
- if (!tasksFile || !Array.isArray(tasksFile.tasks)) {
29
- return tasksFile;
30
- }
31
- const tasks = tasksFile.tasks.map((task) => {
32
- const passes = task.passes;
33
- if (Array.isArray(passes)) {
34
- return task;
35
- }
36
- return {
37
- ...task,
38
- passes: []
39
- };
40
- });
41
- return {
42
- ...tasksFile,
43
- tasks
44
- };
45
- }
46
25
  export async function getPrdState(repoId, slug) {
47
26
  const row = await dbGet(`
48
27
  SELECT repo_id, slug, tasks_json, progress_json, notes_md, updated_at
@@ -52,11 +31,12 @@ export async function getPrdState(repoId, slug) {
52
31
  if (!row) {
53
32
  return null;
54
33
  }
55
- const tasks = normalizeLegacyTasksFile(parseStoredJson(row.tasks_json, 'prd_states.tasks_json'));
34
+ const tasks = parseStoredJson(row.tasks_json, 'prd_states.tasks_json', parseTasksFile);
35
+ const progress = parseStoredJson(row.progress_json, 'prd_states.progress_json', parseProgressFile);
56
36
  return {
57
37
  slug: row.slug,
58
38
  tasks,
59
- progress: parseStoredJson(row.progress_json, 'prd_states.progress_json'),
39
+ progress,
60
40
  notes: row.notes_md,
61
41
  updatedAt: row.updated_at
62
42
  };
@@ -68,7 +48,7 @@ export async function getPrdStateSummaries(repoId) {
68
48
  const summary = { hasState: true };
69
49
  if (row.tasks_json) {
70
50
  try {
71
- const tasksFile = JSON.parse(row.tasks_json);
51
+ const tasksFile = parseTasksFile(JSON.parse(row.tasks_json));
72
52
  const counts = getTaskCounts(tasksFile);
73
53
  if (counts) {
74
54
  summary.taskCount = counts.taskCount;
@@ -84,146 +64,58 @@ export async function getPrdStateSummaries(repoId) {
84
64
  return summaries;
85
65
  }
86
66
  export async function upsertPrdState(repoId, slug, update) {
87
- const existing = await dbGet(`
88
- SELECT repo_id, slug, tasks_json, progress_json, notes_md, updated_at
89
- FROM prd_states
90
- WHERE repo_id = ? AND slug = ?
91
- `, [repoId, slug]);
92
- const tasksJson = update.tasks === undefined
93
- ? existing?.tasks_json ?? null
94
- : (update.tasks === null ? null : JSON.stringify(update.tasks));
95
- const progressJson = update.progress === undefined
96
- ? existing?.progress_json ?? null
97
- : (update.progress === null ? null : JSON.stringify(update.progress));
67
+ const validatedTasks = update.tasks === undefined
68
+ ? undefined
69
+ : (update.tasks === null ? null : parseTasksFile(update.tasks));
70
+ const validatedProgress = update.progress === undefined
71
+ ? undefined
72
+ : (update.progress === null ? null : parseProgressFile(update.progress));
73
+ const updateTasks = validatedTasks !== undefined;
74
+ const updateProgress = validatedProgress !== undefined;
75
+ const updateNotes = update.notes !== undefined;
76
+ const tasksJson = validatedTasks === undefined
77
+ ? null
78
+ : (validatedTasks === null ? null : JSON.stringify(validatedTasks));
79
+ const progressJson = validatedProgress === undefined
80
+ ? null
81
+ : (validatedProgress === null ? null : JSON.stringify(validatedProgress));
98
82
  const notesMd = update.notes === undefined
99
- ? existing?.notes_md ?? null
83
+ ? null
100
84
  : update.notes;
101
85
  const updatedAt = new Date().toISOString();
102
- if (existing) {
103
- await dbRun(`
104
- UPDATE prd_states
105
- SET tasks_json = ?, progress_json = ?, notes_md = ?, updated_at = ?
106
- WHERE repo_id = ? AND slug = ?
107
- `, [tasksJson, progressJson, notesMd, updatedAt, repoId, slug]);
108
- return;
109
- }
110
86
  await dbRun(`
111
87
  INSERT INTO prd_states (repo_id, slug, tasks_json, progress_json, notes_md, updated_at)
112
88
  VALUES (?, ?, ?, ?, ?, ?)
113
- `, [repoId, slug, tasksJson, progressJson, notesMd, updatedAt]);
114
- }
115
- async function readStableLegacyFile(filePath, minFileAgeMs) {
116
- try {
117
- const stats = await fs.stat(filePath);
118
- if (!stats.isFile()) {
119
- return null;
120
- }
121
- if (Date.now() - stats.mtimeMs < minFileAgeMs) {
122
- return null;
123
- }
124
- return await fs.readFile(filePath, 'utf-8');
125
- }
126
- catch {
127
- return null;
128
- }
129
- }
130
- async function readLegacyJsonFile(filePath, label, minFileAgeMs) {
131
- const content = await readStableLegacyFile(filePath, minFileAgeMs);
132
- if (!content) {
133
- return { value: null, imported: false };
134
- }
135
- try {
136
- return { value: JSON.parse(content), imported: true };
137
- }
138
- catch (error) {
139
- const message = error instanceof Error ? error.message : String(error);
140
- console.warn(`[legacy-state] Skipping invalid ${label} at ${filePath}: ${message}`);
141
- return { value: null, imported: false };
142
- }
143
- }
144
- async function removeIfExists(filePath) {
145
- try {
146
- await fs.unlink(filePath);
147
- }
148
- catch {
149
- // File may already be removed.
150
- }
151
- }
152
- async function removeDirIfEmpty(dirPath) {
153
- try {
154
- const entries = await fs.readdir(dirPath);
155
- if (entries.length === 0) {
156
- await fs.rmdir(dirPath);
157
- }
158
- }
159
- catch {
160
- // Directory may not exist or may contain files.
161
- }
162
- }
163
- async function runLegacyStateMigration(repo, cleanupLegacyFiles, minFileAgeMs) {
164
- const legacyStateDir = join(repo.path, '.claude', 'state');
165
- const entries = await fs.readdir(legacyStateDir, { withFileTypes: true, encoding: 'utf8' }).catch(() => null);
166
- if (!entries) {
167
- return;
168
- }
169
- for (const entry of entries) {
170
- if (!entry.isDirectory()) {
171
- continue;
172
- }
173
- const slug = entry.name;
174
- const slugDir = join(legacyStateDir, slug);
175
- const tasksPath = join(slugDir, 'tasks.json');
176
- const progressPath = join(slugDir, 'progress.json');
177
- const notesPath = join(slugDir, 'notes.md');
178
- const [tasksResult, progressResult, notesContent] = await Promise.all([
179
- readLegacyJsonFile(tasksPath, 'tasks.json', minFileAgeMs),
180
- readLegacyJsonFile(progressPath, 'progress.json', minFileAgeMs),
181
- readStableLegacyFile(notesPath, minFileAgeMs)
182
- ]);
183
- const shouldImportNotes = notesContent !== null;
184
- const shouldImport = tasksResult.imported || progressResult.imported || shouldImportNotes;
185
- if (!shouldImport) {
186
- continue;
187
- }
188
- await upsertPrdState(repo.id, slug, {
189
- ...(tasksResult.imported && { tasks: tasksResult.value }),
190
- ...(progressResult.imported && { progress: progressResult.value }),
191
- ...(shouldImportNotes && { notes: notesContent })
89
+ ON CONFLICT(repo_id, slug) DO UPDATE SET
90
+ tasks_json = CASE WHEN ? THEN excluded.tasks_json ELSE prd_states.tasks_json END,
91
+ progress_json = CASE WHEN ? THEN excluded.progress_json ELSE prd_states.progress_json END,
92
+ notes_md = CASE WHEN ? THEN excluded.notes_md ELSE prd_states.notes_md END,
93
+ updated_at = excluded.updated_at
94
+ `, [
95
+ repoId,
96
+ slug,
97
+ tasksJson,
98
+ progressJson,
99
+ notesMd,
100
+ updatedAt,
101
+ updateTasks ? 1 : 0,
102
+ updateProgress ? 1 : 0,
103
+ updateNotes ? 1 : 0
104
+ ]);
105
+ if (validatedTasks !== undefined) {
106
+ emitChange({
107
+ type: 'change',
108
+ path: `state://${repoId}/${slug}/tasks.json`,
109
+ repoId,
110
+ category: 'tasks'
192
111
  });
193
- if (cleanupLegacyFiles) {
194
- if (tasksResult.imported) {
195
- await removeIfExists(tasksPath);
196
- }
197
- if (progressResult.imported) {
198
- await removeIfExists(progressPath);
199
- }
200
- if (shouldImportNotes) {
201
- await removeIfExists(notesPath);
202
- }
203
- await removeDirIfEmpty(slugDir);
204
- }
205
- }
206
- if (cleanupLegacyFiles) {
207
- await removeDirIfEmpty(legacyStateDir);
208
112
  }
209
- }
210
- export async function migrateLegacyStateForRepo(repo, options = {}) {
211
- const cleanupLegacyFiles = options.cleanupLegacyFiles
212
- ?? !cleanupCompletedRepoIds.has(repo.id);
213
- const minFileAgeMs = options.minFileAgeMs ?? LEGACY_STATE_STABLE_MS;
214
- const inFlight = migrationInFlight.get(repo.id);
215
- if (inFlight) {
216
- return inFlight;
113
+ if (validatedProgress !== undefined) {
114
+ emitChange({
115
+ type: 'change',
116
+ path: `state://${repoId}/${slug}/progress.json`,
117
+ repoId,
118
+ category: 'progress'
119
+ });
217
120
  }
218
- const migrationPromise = runLegacyStateMigration(repo, cleanupLegacyFiles, minFileAgeMs)
219
- .then(() => {
220
- if (cleanupLegacyFiles) {
221
- cleanupCompletedRepoIds.add(repo.id);
222
- }
223
- })
224
- .finally(() => {
225
- migrationInFlight.delete(repo.id);
226
- });
227
- migrationInFlight.set(repo.id, migrationPromise);
228
- return migrationPromise;
229
121
  }
@@ -1,13 +1,35 @@
1
- import { promises as fs } from 'node:fs';
2
- import { join, basename, resolve, relative } from 'node:path';
1
+ import { existsSync, promises as fs } from 'node:fs';
2
+ import { join, basename, dirname, resolve, relative, isAbsolute } from 'node:path';
3
3
  import { randomUUID } from 'node:crypto';
4
+ import { fileURLToPath } from 'node:url';
4
5
  import { dbAll, dbGet, dbRun } from './db.js';
5
- const LEGACY_REPOS_FILE = join(process.cwd(), 'server', 'data', 'repos.json');
6
+ function findPackageRoot(startDir) {
7
+ let currentDir = startDir;
8
+ while (true) {
9
+ if (existsSync(join(currentDir, 'package.json'))) {
10
+ return currentDir;
11
+ }
12
+ const parentDir = dirname(currentDir);
13
+ if (parentDir === currentDir) {
14
+ return startDir;
15
+ }
16
+ currentDir = parentDir;
17
+ }
18
+ }
19
+ function normalizePathSlashes(path) {
20
+ return path.replaceAll('\\', '/');
21
+ }
22
+ function isPathWithin(basePath, candidatePath) {
23
+ const relativePath = relative(resolve(basePath), resolve(candidatePath));
24
+ return relativePath === '' || (!relativePath.startsWith('..') && !isAbsolute(relativePath));
25
+ }
26
+ const PACKAGE_ROOT = findPackageRoot(dirname(fileURLToPath(import.meta.url)));
27
+ const LEGACY_REPOS_FILE = join(PACKAGE_ROOT, 'server', 'data', 'repos.json');
6
28
  let legacyImportPromise = null;
7
29
  function serializeGitRepos(gitRepos) {
8
30
  return gitRepos && gitRepos.length > 0 ? JSON.stringify(gitRepos) : null;
9
31
  }
10
- function parseGitRepos(gitReposJson) {
32
+ function parseGitRepos(repoPath, gitReposJson) {
11
33
  if (!gitReposJson) {
12
34
  return undefined;
13
35
  }
@@ -16,21 +38,39 @@ function parseGitRepos(gitReposJson) {
16
38
  if (!Array.isArray(parsed)) {
17
39
  return undefined;
18
40
  }
19
- const validRepos = parsed.filter((item) => {
20
- return !!item
21
- && typeof item === 'object'
22
- && typeof item.relativePath === 'string'
23
- && typeof item.absolutePath === 'string'
24
- && typeof item.name === 'string';
25
- });
26
- return validRepos.length > 0 ? validRepos : undefined;
41
+ const repoRoot = resolve(repoPath);
42
+ const validRepos = new Map();
43
+ for (const item of parsed) {
44
+ if (!item || typeof item !== 'object') {
45
+ continue;
46
+ }
47
+ const relativePath = item.relativePath;
48
+ const name = item.name;
49
+ if (!relativePath || !name) {
50
+ continue;
51
+ }
52
+ const normalizedRelativePath = normalizePathSlashes(relativePath).replace(/^\.\//, '');
53
+ if (!normalizedRelativePath || normalizedRelativePath === '.') {
54
+ continue;
55
+ }
56
+ const absolutePath = resolve(repoRoot, normalizedRelativePath);
57
+ if (!isPathWithin(repoRoot, absolutePath)) {
58
+ continue;
59
+ }
60
+ validRepos.set(normalizedRelativePath, {
61
+ relativePath: normalizedRelativePath,
62
+ absolutePath,
63
+ name
64
+ });
65
+ }
66
+ return validRepos.size > 0 ? Array.from(validRepos.values()) : undefined;
27
67
  }
28
68
  catch {
29
69
  return undefined;
30
70
  }
31
71
  }
32
72
  function rowToRepo(row) {
33
- const gitRepos = parseGitRepos(row.git_repos_json);
73
+ const gitRepos = parseGitRepos(row.path, row.git_repos_json);
34
74
  return {
35
75
  id: row.id,
36
76
  name: row.name,
@@ -145,6 +185,11 @@ export async function getRepoById(id) {
145
185
  const row = await dbGet('SELECT id, name, path, added_at, git_repos_json FROM repos WHERE id = ?', [id]);
146
186
  return row ? rowToRepo(row) : undefined;
147
187
  }
188
+ export async function updateRepoGitRepos(id, gitRepos) {
189
+ await importLegacyReposIfNeeded();
190
+ const result = await dbRun('UPDATE repos SET git_repos_json = ? WHERE id = ?', [serializeGitRepos(gitRepos), id]);
191
+ return result.changes > 0;
192
+ }
148
193
  export async function removeRepo(id) {
149
194
  await importLegacyReposIfNeeded();
150
195
  const result = await dbRun('DELETE FROM repos WHERE id = ?', [id]);
@@ -229,12 +274,22 @@ export async function discoverGitRepos(basePath, maxDepth = 4) {
229
274
  return discovered;
230
275
  }
231
276
  export async function validateRepoPath(path) {
232
- // Normalize the path
233
- const resolvedPath = resolve(path);
234
- // Ensure path is absolute (starts with / on Unix or drive letter on Windows)
235
- if (!resolvedPath.startsWith('/') && !/^[A-Za-z]:/.test(resolvedPath)) {
277
+ const trimmedPath = path.trim();
278
+ if (trimmedPath.length === 0) {
279
+ return { valid: false, error: 'Path is required' };
280
+ }
281
+ if (trimmedPath.length > 4096) {
282
+ return { valid: false, error: 'Path is too long' };
283
+ }
284
+ if (trimmedPath.includes('\u0000')) {
285
+ return { valid: false, error: 'Path contains invalid characters' };
286
+ }
287
+ const isWindowsAbsolutePath = /^[A-Za-z]:[\\/]/.test(trimmedPath);
288
+ if (!isAbsolute(trimmedPath) && !isWindowsAbsolutePath) {
236
289
  return { valid: false, error: 'Path must be absolute' };
237
290
  }
291
+ // Normalize the path
292
+ const resolvedPath = resolve(trimmedPath);
238
293
  try {
239
294
  const stats = await fs.stat(resolvedPath);
240
295
  if (!stats.isDirectory()) {
@@ -0,0 +1,61 @@
1
+ import { z } from 'zod';
2
+ const taskStatusSchema = z.enum(['pending', 'in_progress', 'completed']);
3
+ const taskCategorySchema = z.enum(['setup', 'feature', 'integration', 'testing', 'documentation']);
4
+ const taskPrioritySchema = z.enum(['critical', 'high', 'medium', 'low']);
5
+ const commitRefSchema = z.object({
6
+ sha: z.string().min(1),
7
+ repo: z.string()
8
+ });
9
+ const taskSchema = z.object({
10
+ id: z.string().min(1),
11
+ category: taskCategorySchema,
12
+ title: z.string(),
13
+ description: z.string(),
14
+ steps: z.array(z.string()).default([]),
15
+ passes: z.array(z.string()).default([]),
16
+ dependencies: z.array(z.string()).default([]),
17
+ priority: taskPrioritySchema,
18
+ status: taskStatusSchema,
19
+ startedAt: z.string().optional(),
20
+ completedAt: z.string().optional()
21
+ });
22
+ const tasksFileSchema = z.object({
23
+ prd: z.object({
24
+ name: z.string(),
25
+ source: z.string(),
26
+ shortcutStory: z.string().optional(),
27
+ createdAt: z.string()
28
+ }),
29
+ tasks: z.array(taskSchema)
30
+ });
31
+ const taskLogSchema = z.object({
32
+ taskId: z.string().min(1),
33
+ status: taskStatusSchema,
34
+ startedAt: z.string(),
35
+ completedAt: z.string().optional(),
36
+ implemented: z.string().optional(),
37
+ filesChanged: z.array(z.string()).optional(),
38
+ learnings: z.string().optional(),
39
+ commits: z.array(z.union([z.string().min(1), commitRefSchema])).optional()
40
+ });
41
+ const progressFileSchema = z.object({
42
+ prdName: z.string(),
43
+ shortcutStory: z.string().optional(),
44
+ totalTasks: z.number(),
45
+ completed: z.number(),
46
+ inProgress: z.number(),
47
+ blocked: z.number(),
48
+ startedAt: z.string().nullable(),
49
+ lastUpdated: z.string(),
50
+ patterns: z.array(z.object({
51
+ name: z.string(),
52
+ description: z.string()
53
+ })),
54
+ taskLogs: z.array(taskLogSchema)
55
+ });
56
+ export function parseTasksFile(value) {
57
+ return tasksFileSchema.parse(value);
58
+ }
59
+ export function parseProgressFile(value) {
60
+ return progressFileSchema.parse(value);
61
+ }