@thxgg/steward 0.1.16 → 0.1.17

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/.env.example +6 -0
  2. package/.output/nitro.json +1 -1
  3. package/.output/public/_nuxt/{wbjFvimm.js → 4r0X30JV.js} +1 -1
  4. package/.output/public/_nuxt/{ynmyrfyT.js → BMdjSp24.js} +1 -1
  5. package/.output/public/_nuxt/{BWVTacYj.js → BSZqAKg4.js} +1 -1
  6. package/.output/public/_nuxt/{Dum5qplW.js → BdjPva1I.js} +1 -1
  7. package/.output/public/_nuxt/Beeir9iR.js +1 -0
  8. package/.output/public/_nuxt/Bh3vsUvl.js +1 -0
  9. package/.output/public/_nuxt/{Bibm_IDv.js → BlTKcjLJ.js} +2 -2
  10. package/.output/public/_nuxt/{CxHOXVf6.js → By7gAVcL.js} +1 -1
  11. package/.output/public/_nuxt/CbJfCtEa.js +1 -0
  12. package/.output/public/_nuxt/CbkpNvIu.js +141 -0
  13. package/.output/public/_nuxt/CmhLcqDu.js +1 -0
  14. package/.output/public/_nuxt/DC6iPLz1.js +30 -0
  15. package/.output/public/_nuxt/{BSA0RJ-H.js → DD--ojY9.js} +1 -1
  16. package/.output/public/_nuxt/Detail.DSyVQNdr.css +1 -0
  17. package/.output/public/_nuxt/DhKWRjCh.js +60 -0
  18. package/.output/public/_nuxt/_prd_.BkpxMFSV.css +1 -0
  19. package/.output/public/_nuxt/builds/latest.json +1 -1
  20. package/.output/public/_nuxt/builds/meta/f3f42dbd-d501-442b-871c-3d06157e7aa1.json +1 -0
  21. package/.output/public/_nuxt/c1sXju8w.js +1 -0
  22. package/.output/public/_nuxt/eGCjCghR.js +1 -0
  23. package/.output/public/_nuxt/nX8Sf7cz.js +13 -0
  24. package/.output/server/chunks/_/git-api.mjs +100 -7
  25. package/.output/server/chunks/_/git-api.mjs.map +1 -1
  26. package/.output/server/chunks/_/git.mjs +3 -10
  27. package/.output/server/chunks/_/git.mjs.map +1 -1
  28. package/.output/server/chunks/_/prd-service.mjs +234 -0
  29. package/.output/server/chunks/_/prd-service.mjs.map +1 -0
  30. package/.output/server/chunks/_/task-graph.mjs +3 -3
  31. package/.output/server/chunks/_/task-graph.mjs.map +1 -1
  32. package/.output/server/chunks/_/watcher.mjs +26 -46
  33. package/.output/server/chunks/_/watcher.mjs.map +1 -1
  34. package/.output/server/chunks/build/{Detail-CUfU85GY.mjs → Detail-MGwP_u2d.mjs} +63 -34
  35. package/.output/server/chunks/build/Detail-MGwP_u2d.mjs.map +1 -0
  36. package/.output/server/chunks/build/DiffViewer-styles-1.mjs-BFsE2PCW.mjs +4 -0
  37. package/.output/server/chunks/build/DiffViewer-styles-1.mjs-BFsE2PCW.mjs.map +1 -0
  38. package/.output/server/chunks/build/DiffViewer-styles.D2bqX3nK.mjs +8 -0
  39. package/.output/server/chunks/build/DiffViewer-styles.D2bqX3nK.mjs.map +1 -0
  40. package/.output/server/chunks/build/DiffViewer-styles.FoV36wuV.mjs +10 -0
  41. package/.output/server/chunks/build/DiffViewer-styles.FoV36wuV.mjs.map +1 -0
  42. package/.output/server/chunks/build/Viewer-styles.D6wYWFb1.mjs +8 -0
  43. package/.output/server/chunks/build/Viewer-styles.D6wYWFb1.mjs.map +1 -0
  44. package/.output/server/chunks/build/{_prd_-CeVnQzOV.mjs → _prd_-C-Aj4fVa.mjs} +75 -33
  45. package/.output/server/chunks/build/_prd_-C-Aj4fVa.mjs.map +1 -0
  46. package/.output/server/chunks/build/client.precomputed.mjs +1 -1
  47. package/.output/server/chunks/build/{default-DWCOHHTE.mjs → default-Cao5eO80.mjs} +4 -3
  48. package/.output/server/chunks/build/default-Cao5eO80.mjs.map +1 -0
  49. package/.output/server/chunks/build/error-404-Bf6kdO80.mjs +2 -1
  50. package/.output/server/chunks/build/error-500-D_bcARXN.mjs +2 -1
  51. package/.output/server/chunks/build/{index-CckL_NBD.mjs → index-ByZO4Bvq.mjs} +2 -2
  52. package/.output/server/chunks/build/index-ByZO4Bvq.mjs.map +1 -0
  53. package/.output/server/chunks/build/{index-QVeSHT3L.mjs → index-ljj9uTXI.mjs} +8 -5
  54. package/.output/server/chunks/build/index-ljj9uTXI.mjs.map +1 -0
  55. package/.output/server/chunks/build/nuxt-link-SvT1nf8Z.mjs +1 -1
  56. package/.output/server/chunks/build/{repo-graph-CHNl58mY.mjs → repo-graph-EuhMeFt7.mjs} +25 -10
  57. package/.output/server/chunks/build/repo-graph-EuhMeFt7.mjs.map +1 -0
  58. package/.output/server/chunks/build/server.mjs +7 -6
  59. package/.output/server/chunks/build/styles.mjs +4 -4
  60. package/.output/server/chunks/build/{usePrd-SqcxGyFU.mjs → usePrd-f7ylhIqs.mjs} +10 -34
  61. package/.output/server/chunks/build/usePrd-f7ylhIqs.mjs.map +1 -0
  62. package/.output/server/chunks/nitro/nitro.mjs +1106 -677
  63. package/.output/server/chunks/nitro/nitro.mjs.map +1 -1
  64. package/.output/server/chunks/routes/api/browse.get.mjs +12 -6
  65. package/.output/server/chunks/routes/api/browse.get.mjs.map +1 -1
  66. package/.output/server/chunks/routes/api/index.get.mjs +2 -1
  67. package/.output/server/chunks/routes/api/index.get.mjs.map +1 -1
  68. package/.output/server/chunks/routes/api/index.post.mjs +3 -2
  69. package/.output/server/chunks/routes/api/index.post.mjs.map +1 -1
  70. package/.output/server/chunks/routes/api/repos/_repoId/git/commits.get.mjs +11 -13
  71. package/.output/server/chunks/routes/api/repos/_repoId/git/commits.get.mjs.map +1 -1
  72. package/.output/server/chunks/routes/api/repos/_repoId/git/diff.get.mjs +11 -6
  73. package/.output/server/chunks/routes/api/repos/_repoId/git/diff.get.mjs.map +1 -1
  74. package/.output/server/chunks/routes/api/repos/_repoId/git/file-content.get.mjs +31 -12
  75. package/.output/server/chunks/routes/api/repos/_repoId/git/file-content.get.mjs.map +1 -1
  76. package/.output/server/chunks/routes/api/repos/_repoId/git/file-diff.get.mjs +13 -13
  77. package/.output/server/chunks/routes/api/repos/_repoId/git/file-diff.get.mjs.map +1 -1
  78. package/.output/server/chunks/routes/api/repos/_repoId/graph.get.mjs +5 -1
  79. package/.output/server/chunks/routes/api/repos/_repoId/graph.get.mjs.map +1 -1
  80. package/.output/server/chunks/routes/api/repos/_repoId/prd/_prdSlug/graph.get.mjs +14 -1
  81. package/.output/server/chunks/routes/api/repos/_repoId/prd/_prdSlug/graph.get.mjs.map +1 -1
  82. package/.output/server/chunks/routes/api/repos/_repoId/prd/_prdSlug/progress.get.mjs +20 -9
  83. package/.output/server/chunks/routes/api/repos/_repoId/prd/_prdSlug/progress.get.mjs.map +1 -1
  84. package/.output/server/chunks/routes/api/repos/_repoId/prd/_prdSlug/tasks/_taskId/commits.get.mjs +20 -85
  85. package/.output/server/chunks/routes/api/repos/_repoId/prd/_prdSlug/tasks/_taskId/commits.get.mjs.map +1 -1
  86. package/.output/server/chunks/routes/api/repos/_repoId/prd/_prdSlug/tasks.get.mjs +20 -9
  87. package/.output/server/chunks/routes/api/repos/_repoId/prd/_prdSlug/tasks.get.mjs.map +1 -1
  88. package/.output/server/chunks/routes/api/repos/_repoId/prd/_prdSlug_.get.mjs +30 -50
  89. package/.output/server/chunks/routes/api/repos/_repoId/prd/_prdSlug_.get.mjs.map +1 -1
  90. package/.output/server/chunks/routes/api/repos/_repoId/prds.get.mjs +19 -49
  91. package/.output/server/chunks/routes/api/repos/_repoId/prds.get.mjs.map +1 -1
  92. package/.output/server/chunks/routes/api/repos/_repoId/refresh-git-repos.post.mjs +6 -13
  93. package/.output/server/chunks/routes/api/repos/_repoId/refresh-git-repos.post.mjs.map +1 -1
  94. package/.output/server/chunks/routes/api/repos/_repoId_.delete.mjs +2 -1
  95. package/.output/server/chunks/routes/api/repos/_repoId_.delete.mjs.map +1 -1
  96. package/.output/server/chunks/routes/api/runtime.get.mjs +3 -2
  97. package/.output/server/chunks/routes/api/runtime.get.mjs.map +1 -1
  98. package/.output/server/chunks/routes/api/watch.get.mjs +5 -4
  99. package/.output/server/chunks/routes/api/watch.get.mjs.map +1 -1
  100. package/.output/server/chunks/routes/renderer.mjs +1 -1
  101. package/.output/server/index.mjs +3 -2
  102. package/.output/server/index.mjs.map +1 -1
  103. package/.output/server/node_modules/zod/index.js +4 -0
  104. package/.output/server/node_modules/zod/package.json +118 -0
  105. package/.output/server/node_modules/zod/v3/ZodError.js +133 -0
  106. package/.output/server/node_modules/zod/v3/errors.js +9 -0
  107. package/.output/server/node_modules/zod/v3/external.js +6 -0
  108. package/.output/server/node_modules/zod/v3/helpers/errorUtil.js +6 -0
  109. package/.output/server/node_modules/zod/v3/helpers/parseUtil.js +109 -0
  110. package/.output/server/node_modules/zod/v3/helpers/typeAliases.js +1 -0
  111. package/.output/server/node_modules/zod/v3/helpers/util.js +133 -0
  112. package/.output/server/node_modules/zod/v3/locales/en.js +109 -0
  113. package/.output/server/node_modules/zod/v3/types.js +3693 -0
  114. package/.output/server/package.json +2 -1
  115. package/README.md +7 -2
  116. package/dist/host/src/api/prds.js +6 -172
  117. package/dist/host/src/api/repos.js +3 -16
  118. package/dist/host/src/api/state.js +7 -2
  119. package/dist/host/src/executor-runner.js +368 -0
  120. package/dist/host/src/executor.js +138 -260
  121. package/dist/host/src/index.js +7 -2
  122. package/dist/host/src/mcp.js +27 -1
  123. package/dist/host/src/ui.js +18 -3
  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 +235 -0
  127. package/dist/server/utils/prd-state.js +57 -45
  128. package/dist/server/utils/repos.js +58 -13
  129. package/dist/server/utils/state-schema.js +61 -0
  130. package/dist/server/utils/task-graph.js +2 -2
  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,235 @@
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, migrateLegacyStateForRepo } 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
+ await migrateLegacyStateForRepo(repo);
95
+ const prdDir = join(repo.path, 'docs', 'prd');
96
+ let prdFiles = [];
97
+ try {
98
+ const files = await fs.readdir(prdDir);
99
+ prdFiles = files.filter((file) => {
100
+ if (!file.endsWith('.md')) {
101
+ return false;
102
+ }
103
+ const slug = basename(file, '.md');
104
+ return isValidPrdSlug(slug);
105
+ });
106
+ }
107
+ catch {
108
+ return [];
109
+ }
110
+ const stateSummaries = await getPrdStateSummaries(repo.id);
111
+ const items = await Promise.all(prdFiles.map(async (filename) => {
112
+ const slug = basename(filename, '.md');
113
+ const filePath = join(prdDir, filename);
114
+ let name = slug;
115
+ let modifiedAt = 0;
116
+ try {
117
+ const [content, stat] = await Promise.all([
118
+ fs.readFile(filePath, 'utf-8'),
119
+ fs.stat(filePath)
120
+ ]);
121
+ modifiedAt = stat.mtime.getTime();
122
+ name = extractPrdTitle(content, slug);
123
+ }
124
+ catch {
125
+ // Keep fallback values when file cannot be read.
126
+ }
127
+ const stateSummary = stateSummaries.get(slug);
128
+ return {
129
+ slug,
130
+ name,
131
+ source: `docs/prd/${filename}`,
132
+ hasState: !!stateSummary?.hasState,
133
+ modifiedAt,
134
+ ...(stateSummary?.taskCount !== undefined && { taskCount: stateSummary.taskCount }),
135
+ ...(stateSummary?.completedCount !== undefined && { completedCount: stateSummary.completedCount })
136
+ };
137
+ }));
138
+ items.sort((a, b) => b.modifiedAt - a.modifiedAt);
139
+ return items;
140
+ }
141
+ export async function readPrdTasks(repo, prdSlug) {
142
+ assertValidPrdSlug(prdSlug);
143
+ await migrateLegacyStateForRepo(repo);
144
+ const state = await getPrdState(repo.id, prdSlug);
145
+ return state?.tasks ?? null;
146
+ }
147
+ export async function readPrdProgress(repo, prdSlug) {
148
+ assertValidPrdSlug(prdSlug);
149
+ await migrateLegacyStateForRepo(repo);
150
+ const state = await getPrdState(repo.id, prdSlug);
151
+ return state?.progress ?? null;
152
+ }
153
+ function hasDiscoveredRepoChanges(repo, discoveredRepos) {
154
+ const normalizedExisting = normalizeGitRepos(repo.gitRepos) || [];
155
+ const normalizedDiscovered = normalizeGitRepos(discoveredRepos) || [];
156
+ if (normalizedExisting.length !== normalizedDiscovered.length) {
157
+ return true;
158
+ }
159
+ const existingPaths = new Set(normalizedExisting.map((gitRepo) => gitRepo.relativePath));
160
+ for (const gitRepo of normalizedDiscovered) {
161
+ if (!existingPaths.has(gitRepo.relativePath)) {
162
+ return true;
163
+ }
164
+ }
165
+ return false;
166
+ }
167
+ async function refreshDiscoveredGitRepos(repo) {
168
+ const discoveredRepos = normalizeGitRepos(await discoverGitRepos(repo.path));
169
+ if (!hasDiscoveredRepoChanges(repo, discoveredRepos)) {
170
+ return null;
171
+ }
172
+ const updated = await updateRepoGitRepos(repo.id, discoveredRepos);
173
+ if (!updated) {
174
+ return null;
175
+ }
176
+ return {
177
+ ...repo,
178
+ gitRepos: discoveredRepos
179
+ };
180
+ }
181
+ export async function resolveTaskCommits(repo, prdSlug, taskId) {
182
+ assertValidPrdSlug(prdSlug);
183
+ await migrateLegacyStateForRepo(repo);
184
+ const state = await getPrdState(repo.id, prdSlug);
185
+ const progress = state?.progress ?? null;
186
+ if (!progress) {
187
+ return [];
188
+ }
189
+ const taskLogs = Array.isArray(progress.taskLogs) ? progress.taskLogs : [];
190
+ const taskLog = taskLogs.find((log) => log.taskId === taskId);
191
+ if (!taskLog || !taskLog.commits || taskLog.commits.length === 0) {
192
+ return [];
193
+ }
194
+ const resolvedCommits = [];
195
+ const failedEntries = [];
196
+ for (const commitEntry of taskLog.commits) {
197
+ try {
198
+ const resolved = await resolveCommitRepo(repo, commitEntry);
199
+ resolvedCommits.push({
200
+ sha: resolved.sha,
201
+ repo: resolved.repoPath
202
+ });
203
+ }
204
+ catch {
205
+ failedEntries.push(commitEntry);
206
+ }
207
+ }
208
+ if (failedEntries.length === 0) {
209
+ return resolvedCommits;
210
+ }
211
+ const refreshedRepo = await refreshDiscoveredGitRepos(repo);
212
+ for (const commitEntry of failedEntries) {
213
+ if (!refreshedRepo) {
214
+ resolvedCommits.push({
215
+ sha: extractCommitSha(commitEntry),
216
+ repo: ''
217
+ });
218
+ continue;
219
+ }
220
+ try {
221
+ const resolved = await resolveCommitRepo(refreshedRepo, commitEntry);
222
+ resolvedCommits.push({
223
+ sha: resolved.sha,
224
+ repo: resolved.repoPath
225
+ });
226
+ }
227
+ catch {
228
+ resolvedCommits.push({
229
+ sha: extractCommitSha(commitEntry),
230
+ repo: ''
231
+ });
232
+ }
233
+ }
234
+ return resolvedCommits;
235
+ }
@@ -1,15 +1,18 @@
1
1
  import { promises as fs } from 'node:fs';
2
2
  import { join } from 'node:path';
3
+ import { emitChange } from './change-events.js';
3
4
  import { dbAll, dbGet, dbRun } from './db.js';
5
+ import { parseProgressFile, parseTasksFile } from './state-schema.js';
4
6
  const LEGACY_STATE_STABLE_MS = 0;
5
7
  const migrationInFlight = new Map();
6
8
  const cleanupCompletedRepoIds = new Set();
7
- function parseStoredJson(raw, fieldName) {
9
+ function parseStoredJson(raw, fieldName, parseValue) {
8
10
  if (!raw) {
9
11
  return null;
10
12
  }
11
13
  try {
12
- return JSON.parse(raw);
14
+ const parsed = JSON.parse(raw);
15
+ return parseValue(parsed);
13
16
  }
14
17
  catch (error) {
15
18
  const message = error instanceof Error ? error.message : String(error);
@@ -24,25 +27,6 @@ function getTaskCounts(tasksFile) {
24
27
  const completedCount = tasksFile.tasks.filter(task => task.status === 'completed').length;
25
28
  return { taskCount, completedCount };
26
29
  }
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
30
  export async function getPrdState(repoId, slug) {
47
31
  const row = await dbGet(`
48
32
  SELECT repo_id, slug, tasks_json, progress_json, notes_md, updated_at
@@ -52,11 +36,12 @@ export async function getPrdState(repoId, slug) {
52
36
  if (!row) {
53
37
  return null;
54
38
  }
55
- const tasks = normalizeLegacyTasksFile(parseStoredJson(row.tasks_json, 'prd_states.tasks_json'));
39
+ const tasks = parseStoredJson(row.tasks_json, 'prd_states.tasks_json', parseTasksFile);
40
+ const progress = parseStoredJson(row.progress_json, 'prd_states.progress_json', parseProgressFile);
56
41
  return {
57
42
  slug: row.slug,
58
43
  tasks,
59
- progress: parseStoredJson(row.progress_json, 'prd_states.progress_json'),
44
+ progress,
60
45
  notes: row.notes_md,
61
46
  updatedAt: row.updated_at
62
47
  };
@@ -68,7 +53,7 @@ export async function getPrdStateSummaries(repoId) {
68
53
  const summary = { hasState: true };
69
54
  if (row.tasks_json) {
70
55
  try {
71
- const tasksFile = JSON.parse(row.tasks_json);
56
+ const tasksFile = parseTasksFile(JSON.parse(row.tasks_json));
72
57
  const counts = getTaskCounts(tasksFile);
73
58
  if (counts) {
74
59
  summary.taskCount = counts.taskCount;
@@ -84,33 +69,60 @@ export async function getPrdStateSummaries(repoId) {
84
69
  return summaries;
85
70
  }
86
71
  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));
72
+ const validatedTasks = update.tasks === undefined
73
+ ? undefined
74
+ : (update.tasks === null ? null : parseTasksFile(update.tasks));
75
+ const validatedProgress = update.progress === undefined
76
+ ? undefined
77
+ : (update.progress === null ? null : parseProgressFile(update.progress));
78
+ const updateTasks = validatedTasks !== undefined;
79
+ const updateProgress = validatedProgress !== undefined;
80
+ const updateNotes = update.notes !== undefined;
81
+ const tasksJson = validatedTasks === undefined
82
+ ? null
83
+ : (validatedTasks === null ? null : JSON.stringify(validatedTasks));
84
+ const progressJson = validatedProgress === undefined
85
+ ? null
86
+ : (validatedProgress === null ? null : JSON.stringify(validatedProgress));
98
87
  const notesMd = update.notes === undefined
99
- ? existing?.notes_md ?? null
88
+ ? null
100
89
  : update.notes;
101
90
  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
91
  await dbRun(`
111
92
  INSERT INTO prd_states (repo_id, slug, tasks_json, progress_json, notes_md, updated_at)
112
93
  VALUES (?, ?, ?, ?, ?, ?)
113
- `, [repoId, slug, tasksJson, progressJson, notesMd, updatedAt]);
94
+ ON CONFLICT(repo_id, slug) DO UPDATE SET
95
+ tasks_json = CASE WHEN ? THEN excluded.tasks_json ELSE prd_states.tasks_json END,
96
+ progress_json = CASE WHEN ? THEN excluded.progress_json ELSE prd_states.progress_json END,
97
+ notes_md = CASE WHEN ? THEN excluded.notes_md ELSE prd_states.notes_md END,
98
+ updated_at = excluded.updated_at
99
+ `, [
100
+ repoId,
101
+ slug,
102
+ tasksJson,
103
+ progressJson,
104
+ notesMd,
105
+ updatedAt,
106
+ updateTasks ? 1 : 0,
107
+ updateProgress ? 1 : 0,
108
+ updateNotes ? 1 : 0
109
+ ]);
110
+ if (validatedTasks !== undefined) {
111
+ emitChange({
112
+ type: 'change',
113
+ path: `state://${repoId}/${slug}/tasks.json`,
114
+ repoId,
115
+ category: 'tasks'
116
+ });
117
+ }
118
+ if (validatedProgress !== undefined) {
119
+ emitChange({
120
+ type: 'change',
121
+ path: `state://${repoId}/${slug}/progress.json`,
122
+ repoId,
123
+ category: 'progress'
124
+ });
125
+ }
114
126
  }
115
127
  async function readStableLegacyFile(filePath, minFileAgeMs) {
116
128
  try {
@@ -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]);
@@ -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
+ }
@@ -1,6 +1,6 @@
1
1
  import { promises as fs } from 'node:fs';
2
- import { join } from 'node:path';
3
2
  import { getPrdState, getPrdStateSummaries, migrateLegacyStateForRepo } from './prd-state.js';
3
+ import { resolvePrdMarkdownPath } from './prd-service.js';
4
4
  const NODE_SEPARATOR = '::';
5
5
  const MISSING_PREFIX = 'missing';
6
6
  export function createTaskNodeId(prdSlug, taskId) {
@@ -39,8 +39,8 @@ function parseDependency(rawDependency, currentPrdSlug) {
39
39
  };
40
40
  }
41
41
  async function resolvePrdName(repo, prdSlug) {
42
- const prdPath = join(repo.path, 'docs', 'prd', `${prdSlug}.md`);
43
42
  try {
43
+ const prdPath = resolvePrdMarkdownPath(repo.path, prdSlug);
44
44
  const content = await fs.readFile(prdPath, 'utf-8');
45
45
  const h1Match = content.match(/^#\s+(.+)$/m);
46
46
  return h1Match?.[1]?.trim() || prdSlug;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thxgg/steward",
3
- "version": "0.1.16",
3
+ "version": "0.1.17",
4
4
  "description": "Local-first PRD workflow steward with codemode MCP and web UI.",
5
5
  "type": "module",
6
6
  "author": "thxgg",
@@ -49,6 +49,7 @@
49
49
  "preview": "nuxt preview",
50
50
  "mcp": "npm run build:host && node ./bin/prd mcp",
51
51
  "ui": "npm run build && node ./bin/prd ui",
52
+ "test": "npm run build:host && node --test \"tests/**/*.test.mjs\"",
52
53
  "typecheck": "nuxt typecheck && npm run typecheck:host",
53
54
  "typecheck:host": "tsc -p tsconfig.host.json",
54
55
  "prepack": "npm run build"
@@ -1 +0,0 @@
1
- import{g as O,j as A,e as q,z as j,an as T,ao as E,D as N,ap as U,a0 as L,a7 as P,aq as I,C as H,q as D,ar as _,as as z,at as F,s as y,au as V,av as M,aw as B,ax as W,ay as $,az as G,aA as Q}from"./Bibm_IDv.js";const J=(...t)=>t.find(o=>o!==void 0);function K(t){const o=t.componentName||"NuxtLink";function f(e){return typeof e=="string"&&e.startsWith("#")}function R(e,l,v){const a=v??t.trailingSlash;if(!e||a!=="append"&&a!=="remove")return e;if(typeof e=="string")return S(e,a);const u="path"in e&&e.path!==void 0?e.path:l(e).path;return{...e,name:void 0,path:S(u,a)}}function k(e){const l=A(),v=Q(),a=y(()=>!!e.target&&e.target!=="_self"),u=y(()=>{const i=e.to||e.href||"";return typeof i=="string"&&B(i,{acceptRelative:!0})}),b=P("RouterLink"),h=b&&typeof b!="string"?b.useLink:void 0,c=y(()=>{if(e.external)return!0;const i=e.to||e.href||"";return typeof i=="object"?!1:i===""||u.value}),n=y(()=>{const i=e.to||e.href||"";return c.value?i:R(i,l.resolve,e.trailingSlash)}),g=c.value?void 0:h?.({...e,to:n}),m=y(()=>{const i=e.trailingSlash??t.trailingSlash;if(!n.value||u.value||f(n.value))return n.value;if(c.value){const x=typeof n.value=="object"&&"path"in n.value?_(n.value):n.value,C=typeof x=="object"?l.resolve(x).href:x;return S(C,i)}return typeof n.value=="object"?l.resolve(n.value)?.href??null:S(W(v.app.baseURL,n.value),i)});return{to:n,hasTarget:a,isAbsoluteUrl:u,isExternal:c,href:m,isActive:g?.isActive??y(()=>n.value===l.currentRoute.value.path),isExactActive:g?.isExactActive??y(()=>n.value===l.currentRoute.value.path),route:g?.route??y(()=>l.resolve(n.value)),async navigate(i){await F(m.value,{replace:e.replace,external:c.value||a.value})}}}return O({name:o,props:{to:{type:[String,Object],default:void 0,required:!1},href:{type:[String,Object],default:void 0,required:!1},target:{type:String,default:void 0,required:!1},rel:{type:String,default:void 0,required:!1},noRel:{type:Boolean,default:void 0,required:!1},prefetch:{type:Boolean,default:void 0,required:!1},prefetchOn:{type:[String,Object],default:void 0,required:!1},noPrefetch:{type:Boolean,default:void 0,required:!1},activeClass:{type:String,default:void 0,required:!1},exactActiveClass:{type:String,default:void 0,required:!1},prefetchedClass:{type:String,default:void 0,required:!1},replace:{type:Boolean,default:void 0,required:!1},ariaCurrentValue:{type:String,default:void 0,required:!1},external:{type:Boolean,default:void 0,required:!1},custom:{type:Boolean,default:void 0,required:!1},trailingSlash:{type:String,default:void 0,required:!1}},useLink:k,setup(e,{slots:l}){const v=A(),{to:a,href:u,navigate:b,isExternal:h,hasTarget:c,isAbsoluteUrl:n}=k(e),g=H(!1),m=D(null),i=s=>{m.value=e.custom?s?.$el?.nextElementSibling:s?.$el};function x(s){return!g.value&&(typeof e.prefetchOn=="string"?e.prefetchOn===s:e.prefetchOn?.[s]??t.prefetchOn?.[s])&&(e.prefetch??t.prefetch)!==!1&&e.noPrefetch!==!0&&e.target!=="_blank"&&!Z()}async function C(s=q()){if(g.value)return;g.value=!0;const d=typeof a.value=="string"?a.value:h.value?_(a.value):v.resolve(a.value).fullPath,r=h.value?new URL(d,window.location.href).href:d;await Promise.all([s.hooks.callHook("link:prefetch",r).catch(()=>{}),!h.value&&!c.value&&z(a.value,v).catch(()=>{})])}if(x("visibility")){const s=q();let d,r=null;j(()=>{const p=X();T(()=>{d=E(()=>{m?.value?.tagName&&(r=p.observe(m.value,async()=>{r?.(),r=null,await C(s)}))})})}),N(()=>{d&&U(d),r?.(),r=null})}return()=>{if(!h.value&&!c.value&&!f(a.value)){const r={ref:i,to:a.value,activeClass:e.activeClass||t.activeClass,exactActiveClass:e.exactActiveClass||t.exactActiveClass,replace:e.replace,ariaCurrentValue:e.ariaCurrentValue,custom:e.custom};return e.custom||(x("interaction")&&(r.onPointerenter=C.bind(null,void 0),r.onFocus=C.bind(null,void 0)),g.value&&(r.class=e.prefetchedClass||t.prefetchedClass),r.rel=e.rel||void 0),L(P("RouterLink"),r,l.default)}const s=e.target||null,d=J(e.noRel?"":e.rel,t.externalRelAttribute,n.value||c.value?"noopener noreferrer":"")||null;return e.custom?l.default?l.default({href:u.value,navigate:b,prefetch:C,get route(){if(!u.value)return;const r=new URL(u.value,window.location.href);return{path:r.pathname,fullPath:r.pathname,get query(){return I(r.search)},hash:r.hash,params:{},name:void 0,matched:[],redirectedFrom:void 0,meta:{},href:u.value}},rel:d,target:s,isExternal:h.value||c.value,isActive:!1,isExactActive:!1}):null:L("a",{ref:m,href:u.value||null,rel:d,target:s,onClick:async r=>{if(!(h.value||c.value)){r.preventDefault();try{const p=V(u.value);return await(e.replace?v.replace(p):v.push(p))}finally{if(f(a.value)){const p=a.value.slice(1);let w=p;try{w=decodeURIComponent(p)}catch{}document.getElementById(w)?.focus()}}}}},l.default?.())}}})}const ae=K(M);function S(t,o){const f=o==="append"?$:G;return B(t)&&!t.startsWith("http")?t:f(t,!0)}function X(){const t=q();if(t._observer)return t._observer;let o=null;const f=new Map,R=(e,l)=>(o||=new IntersectionObserver(v=>{for(const a of v){const u=f.get(a.target);(a.isIntersecting||a.intersectionRatio>0)&&u&&u()}}),f.set(e,l),o.observe(e),()=>{f.delete(e),o?.unobserve(e),f.size===0&&(o?.disconnect(),o=null)});return t._observer={observe:R}}const Y=/2g/;function Z(){const t=navigator.connection;return!!(t&&(t.saveData||Y.test(t.effectiveType)))}export{ae as _};