@thxgg/steward 0.1.17 → 0.1.19

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 (124) hide show
  1. package/.env.example +0 -6
  2. package/.output/nitro.json +1 -1
  3. package/.output/public/_nuxt/{BlTKcjLJ.js → BCiXS3ZV.js} +2 -2
  4. package/.output/public/_nuxt/BfUfZSFp.js +60 -0
  5. package/.output/public/_nuxt/{BMdjSp24.js → BfV4oCiT.js} +1 -1
  6. package/.output/public/_nuxt/{BSZqAKg4.js → Bs6UO7IT.js} +1 -1
  7. package/.output/public/_nuxt/C9AsKFSQ.js +1 -0
  8. package/.output/public/_nuxt/{BdjPva1I.js → C9jB6HQI.js} +1 -1
  9. package/.output/public/_nuxt/CCruYste.js +3 -0
  10. package/.output/public/_nuxt/CIIE0-WR.js +1 -0
  11. package/.output/public/_nuxt/{By7gAVcL.js → Cg4hnDua.js} +1 -1
  12. package/.output/public/_nuxt/CwdD8083.js +30 -0
  13. package/.output/public/_nuxt/{4r0X30JV.js → D2RLSKEu.js} +1 -1
  14. package/.output/public/_nuxt/{CbkpNvIu.js → D30YtxUg.js} +1 -1
  15. package/.output/public/_nuxt/{Beeir9iR.js → DEekox9p.js} +1 -1
  16. package/.output/public/_nuxt/{nX8Sf7cz.js → DddVAa3N.js} +1 -1
  17. package/.output/public/_nuxt/{Bh3vsUvl.js → MO41rxll.js} +1 -1
  18. package/.output/public/_nuxt/builds/latest.json +1 -1
  19. package/.output/public/_nuxt/builds/meta/627332c0-2e14-4849-8cac-e350f64ed513.json +1 -0
  20. package/.output/public/_nuxt/entry.Dp3jx0Yw.css +1 -0
  21. package/.output/public/_nuxt/f7vKgp5U.js +1 -0
  22. package/.output/public/_nuxt/xc3v2JZH.js +1 -0
  23. package/.output/server/chunks/_/git-api.mjs +1 -1
  24. package/.output/server/chunks/_/prd-service.mjs +177 -12
  25. package/.output/server/chunks/_/prd-service.mjs.map +1 -1
  26. package/.output/server/chunks/_/repos.mjs +272 -0
  27. package/.output/server/chunks/_/repos.mjs.map +1 -0
  28. package/.output/server/chunks/_/task-graph.mjs +19 -16
  29. package/.output/server/chunks/_/task-graph.mjs.map +1 -1
  30. package/.output/server/chunks/_/watcher.mjs +11 -35
  31. package/.output/server/chunks/_/watcher.mjs.map +1 -1
  32. package/.output/server/chunks/build/{Detail-MGwP_u2d.mjs → Detail-BcQGdJY5.mjs} +112 -46
  33. package/.output/server/chunks/build/Detail-BcQGdJY5.mjs.map +1 -0
  34. package/.output/server/chunks/build/{_prd_-C-Aj4fVa.mjs → _prd_-CD_Bds_B.mjs} +80 -7
  35. package/.output/server/chunks/build/_prd_-CD_Bds_B.mjs.map +1 -0
  36. package/.output/server/chunks/build/client.precomputed.mjs +1 -1
  37. package/.output/server/chunks/build/{default-Cao5eO80.mjs → default-BKKgG7HJ.mjs} +220 -24
  38. package/.output/server/chunks/build/default-BKKgG7HJ.mjs.map +1 -0
  39. package/.output/server/chunks/build/error-404-Bf6kdO80.mjs +1 -1
  40. package/.output/server/chunks/build/error-500-D_bcARXN.mjs +1 -1
  41. package/.output/server/chunks/build/{index-ljj9uTXI.mjs → index-DE1tjHAd.mjs} +3 -4
  42. package/.output/server/chunks/build/index-DE1tjHAd.mjs.map +1 -0
  43. package/.output/server/chunks/build/nuxt-link-SvT1nf8Z.mjs +1 -1
  44. package/.output/server/chunks/build/{repo-graph-EuhMeFt7.mjs → repo-graph-CBfhpnd5.mjs} +25 -12
  45. package/.output/server/chunks/build/repo-graph-CBfhpnd5.mjs.map +1 -0
  46. package/.output/server/chunks/build/server.mjs +14 -14
  47. package/.output/server/chunks/build/styles.mjs +2 -2
  48. package/.output/server/chunks/build/{usePrd-f7ylhIqs.mjs → usePrd-hXZOmvAv.mjs} +113 -9
  49. package/.output/server/chunks/build/usePrd-hXZOmvAv.mjs.map +1 -0
  50. package/.output/server/chunks/nitro/nitro.mjs +1051 -1365
  51. package/.output/server/chunks/nitro/nitro.mjs.map +1 -1
  52. package/.output/server/chunks/routes/api/browse.get.mjs +24 -6
  53. package/.output/server/chunks/routes/api/browse.get.mjs.map +1 -1
  54. package/.output/server/chunks/routes/api/index.get.mjs +3 -2
  55. package/.output/server/chunks/routes/api/index.get.mjs.map +1 -1
  56. package/.output/server/chunks/routes/api/index.post.mjs +22 -8
  57. package/.output/server/chunks/routes/api/index.post.mjs.map +1 -1
  58. package/.output/server/chunks/routes/api/repos/_repoId/git/commits.get.mjs +20 -11
  59. package/.output/server/chunks/routes/api/repos/_repoId/git/commits.get.mjs.map +1 -1
  60. package/.output/server/chunks/routes/api/repos/_repoId/git/diff.get.mjs +3 -2
  61. package/.output/server/chunks/routes/api/repos/_repoId/git/diff.get.mjs.map +1 -1
  62. package/.output/server/chunks/routes/api/repos/_repoId/git/file-content.get.mjs +3 -2
  63. package/.output/server/chunks/routes/api/repos/_repoId/git/file-content.get.mjs.map +1 -1
  64. package/.output/server/chunks/routes/api/repos/_repoId/git/file-diff.get.mjs +3 -2
  65. package/.output/server/chunks/routes/api/repos/_repoId/git/file-diff.get.mjs.map +1 -1
  66. package/.output/server/chunks/routes/api/repos/_repoId/graph.get.mjs +27 -3
  67. package/.output/server/chunks/routes/api/repos/_repoId/graph.get.mjs.map +1 -1
  68. package/.output/server/chunks/routes/api/repos/_repoId/prd/_prdSlug/archive.post.mjs +93 -0
  69. package/.output/server/chunks/routes/api/repos/_repoId/prd/_prdSlug/archive.post.mjs.map +1 -0
  70. package/.output/server/chunks/routes/api/repos/_repoId/prd/_prdSlug/graph.get.mjs +3 -2
  71. package/.output/server/chunks/routes/api/repos/_repoId/prd/_prdSlug/graph.get.mjs.map +1 -1
  72. package/.output/server/chunks/routes/api/repos/_repoId/prd/_prdSlug/progress.get.mjs +4 -3
  73. package/.output/server/chunks/routes/api/repos/_repoId/prd/_prdSlug/progress.get.mjs.map +1 -1
  74. package/.output/server/chunks/routes/api/repos/_repoId/prd/_prdSlug/tasks/_taskId/commits.get.mjs +4 -3
  75. package/.output/server/chunks/routes/api/repos/_repoId/prd/_prdSlug/tasks/_taskId/commits.get.mjs.map +1 -1
  76. package/.output/server/chunks/routes/api/repos/_repoId/prd/_prdSlug/tasks.get.mjs +4 -3
  77. package/.output/server/chunks/routes/api/repos/_repoId/prd/_prdSlug/tasks.get.mjs.map +1 -1
  78. package/.output/server/chunks/routes/api/repos/_repoId/prd/_prdSlug_.get.mjs +3 -2
  79. package/.output/server/chunks/routes/api/repos/_repoId/prd/_prdSlug_.get.mjs.map +1 -1
  80. package/.output/server/chunks/routes/api/repos/_repoId/prds.get.mjs +27 -3
  81. package/.output/server/chunks/routes/api/repos/_repoId/prds.get.mjs.map +1 -1
  82. package/.output/server/chunks/routes/api/repos/_repoId/refresh-git-repos.post.mjs +3 -2
  83. package/.output/server/chunks/routes/api/repos/_repoId/refresh-git-repos.post.mjs.map +1 -1
  84. package/.output/server/chunks/routes/api/repos/_repoId_.delete.mjs +3 -2
  85. package/.output/server/chunks/routes/api/repos/_repoId_.delete.mjs.map +1 -1
  86. package/.output/server/chunks/routes/api/runtime.get.mjs +2 -2
  87. package/.output/server/chunks/routes/api/state-migration/status.get.mjs +21 -0
  88. package/.output/server/chunks/routes/api/state-migration/status.get.mjs.map +1 -0
  89. package/.output/server/chunks/routes/api/watch.get.mjs +3 -2
  90. package/.output/server/chunks/routes/api/watch.get.mjs.map +1 -1
  91. package/.output/server/chunks/routes/renderer.mjs +1 -1
  92. package/.output/server/index.mjs +2 -2
  93. package/.output/server/package.json +1 -1
  94. package/README.md +5 -7
  95. package/dist/host/src/api/repos.js +0 -2
  96. package/dist/host/src/api/state.js +1 -7
  97. package/dist/host/src/index.js +2 -7
  98. package/dist/host/src/ui.js +2 -7
  99. package/dist/server/utils/db.js +15 -0
  100. package/dist/server/utils/prd-archive.js +53 -0
  101. package/dist/server/utils/prd-service.js +27 -11
  102. package/dist/server/utils/prd-state.js +11 -122
  103. package/dist/server/utils/repos.js +14 -4
  104. package/dist/server/utils/state-migration.js +225 -0
  105. package/dist/server/utils/state-schema.js +181 -4
  106. package/dist/server/utils/task-graph.js +21 -14
  107. package/package.json +1 -1
  108. package/.output/public/_nuxt/CbJfCtEa.js +0 -1
  109. package/.output/public/_nuxt/CmhLcqDu.js +0 -1
  110. package/.output/public/_nuxt/DC6iPLz1.js +0 -30
  111. package/.output/public/_nuxt/DD--ojY9.js +0 -3
  112. package/.output/public/_nuxt/DhKWRjCh.js +0 -60
  113. package/.output/public/_nuxt/builds/meta/f3f42dbd-d501-442b-871c-3d06157e7aa1.json +0 -1
  114. package/.output/public/_nuxt/c1sXju8w.js +0 -1
  115. package/.output/public/_nuxt/eGCjCghR.js +0 -1
  116. package/.output/public/_nuxt/entry.LcDOtJnR.css +0 -1
  117. package/.output/server/chunks/build/Detail-MGwP_u2d.mjs.map +0 -1
  118. package/.output/server/chunks/build/_prd_-C-Aj4fVa.mjs.map +0 -1
  119. package/.output/server/chunks/build/default-Cao5eO80.mjs.map +0 -1
  120. package/.output/server/chunks/build/index-ByZO4Bvq.mjs +0 -76
  121. package/.output/server/chunks/build/index-ByZO4Bvq.mjs.map +0 -1
  122. package/.output/server/chunks/build/index-ljj9uTXI.mjs.map +0 -1
  123. package/.output/server/chunks/build/repo-graph-EuhMeFt7.mjs.map +0 -1
  124. package/.output/server/chunks/build/usePrd-f7ylhIqs.mjs.map +0 -1
@@ -1,11 +1,7 @@
1
- import { promises as fs } from 'node:fs';
2
- import { join } from 'node:path';
3
1
  import { emitChange } from './change-events.js';
4
2
  import { dbAll, dbGet, dbRun } from './db.js';
5
- import { parseProgressFile, parseTasksFile } from './state-schema.js';
6
- const LEGACY_STATE_STABLE_MS = 0;
7
- const migrationInFlight = new Map();
8
- const cleanupCompletedRepoIds = new Set();
3
+ import { ensureStateMigrationReady } from './state-migration.js';
4
+ import { parseProgressFile, parseStoredProgressFile, parseTasksFile } from './state-schema.js';
9
5
  function parseStoredJson(raw, fieldName, parseValue) {
10
6
  if (!raw) {
11
7
  return null;
@@ -28,6 +24,7 @@ function getTaskCounts(tasksFile) {
28
24
  return { taskCount, completedCount };
29
25
  }
30
26
  export async function getPrdState(repoId, slug) {
27
+ await ensureStateMigrationReady();
31
28
  const row = await dbGet(`
32
29
  SELECT repo_id, slug, tasks_json, progress_json, notes_md, updated_at
33
30
  FROM prd_states
@@ -37,7 +34,12 @@ export async function getPrdState(repoId, slug) {
37
34
  return null;
38
35
  }
39
36
  const tasks = parseStoredJson(row.tasks_json, 'prd_states.tasks_json', parseTasksFile);
40
- const progress = parseStoredJson(row.progress_json, 'prd_states.progress_json', parseProgressFile);
37
+ const tasksCountHint = Array.isArray(tasks?.tasks) ? tasks.tasks.length : undefined;
38
+ const prdNameFallback = tasks?.prd?.name || row.slug;
39
+ const progress = parseStoredJson(row.progress_json, 'prd_states.progress_json', (value) => parseStoredProgressFile(value, {
40
+ totalTasksHint: tasksCountHint,
41
+ prdNameFallback
42
+ }));
41
43
  return {
42
44
  slug: row.slug,
43
45
  tasks,
@@ -47,6 +49,7 @@ export async function getPrdState(repoId, slug) {
47
49
  };
48
50
  }
49
51
  export async function getPrdStateSummaries(repoId) {
52
+ await ensureStateMigrationReady();
50
53
  const rows = await dbAll('SELECT slug, tasks_json FROM prd_states WHERE repo_id = ?', [repoId]);
51
54
  const summaries = new Map();
52
55
  for (const row of rows) {
@@ -69,6 +72,7 @@ export async function getPrdStateSummaries(repoId) {
69
72
  return summaries;
70
73
  }
71
74
  export async function upsertPrdState(repoId, slug, update) {
75
+ await ensureStateMigrationReady();
72
76
  const validatedTasks = update.tasks === undefined
73
77
  ? undefined
74
78
  : (update.tasks === null ? null : parseTasksFile(update.tasks));
@@ -124,118 +128,3 @@ export async function upsertPrdState(repoId, slug, update) {
124
128
  });
125
129
  }
126
130
  }
127
- async function readStableLegacyFile(filePath, minFileAgeMs) {
128
- try {
129
- const stats = await fs.stat(filePath);
130
- if (!stats.isFile()) {
131
- return null;
132
- }
133
- if (Date.now() - stats.mtimeMs < minFileAgeMs) {
134
- return null;
135
- }
136
- return await fs.readFile(filePath, 'utf-8');
137
- }
138
- catch {
139
- return null;
140
- }
141
- }
142
- async function readLegacyJsonFile(filePath, label, minFileAgeMs) {
143
- const content = await readStableLegacyFile(filePath, minFileAgeMs);
144
- if (!content) {
145
- return { value: null, imported: false };
146
- }
147
- try {
148
- return { value: JSON.parse(content), imported: true };
149
- }
150
- catch (error) {
151
- const message = error instanceof Error ? error.message : String(error);
152
- console.warn(`[legacy-state] Skipping invalid ${label} at ${filePath}: ${message}`);
153
- return { value: null, imported: false };
154
- }
155
- }
156
- async function removeIfExists(filePath) {
157
- try {
158
- await fs.unlink(filePath);
159
- }
160
- catch {
161
- // File may already be removed.
162
- }
163
- }
164
- async function removeDirIfEmpty(dirPath) {
165
- try {
166
- const entries = await fs.readdir(dirPath);
167
- if (entries.length === 0) {
168
- await fs.rmdir(dirPath);
169
- }
170
- }
171
- catch {
172
- // Directory may not exist or may contain files.
173
- }
174
- }
175
- async function runLegacyStateMigration(repo, cleanupLegacyFiles, minFileAgeMs) {
176
- const legacyStateDir = join(repo.path, '.claude', 'state');
177
- const entries = await fs.readdir(legacyStateDir, { withFileTypes: true, encoding: 'utf8' }).catch(() => null);
178
- if (!entries) {
179
- return;
180
- }
181
- for (const entry of entries) {
182
- if (!entry.isDirectory()) {
183
- continue;
184
- }
185
- const slug = entry.name;
186
- const slugDir = join(legacyStateDir, slug);
187
- const tasksPath = join(slugDir, 'tasks.json');
188
- const progressPath = join(slugDir, 'progress.json');
189
- const notesPath = join(slugDir, 'notes.md');
190
- const [tasksResult, progressResult, notesContent] = await Promise.all([
191
- readLegacyJsonFile(tasksPath, 'tasks.json', minFileAgeMs),
192
- readLegacyJsonFile(progressPath, 'progress.json', minFileAgeMs),
193
- readStableLegacyFile(notesPath, minFileAgeMs)
194
- ]);
195
- const shouldImportNotes = notesContent !== null;
196
- const shouldImport = tasksResult.imported || progressResult.imported || shouldImportNotes;
197
- if (!shouldImport) {
198
- continue;
199
- }
200
- await upsertPrdState(repo.id, slug, {
201
- ...(tasksResult.imported && { tasks: tasksResult.value }),
202
- ...(progressResult.imported && { progress: progressResult.value }),
203
- ...(shouldImportNotes && { notes: notesContent })
204
- });
205
- if (cleanupLegacyFiles) {
206
- if (tasksResult.imported) {
207
- await removeIfExists(tasksPath);
208
- }
209
- if (progressResult.imported) {
210
- await removeIfExists(progressPath);
211
- }
212
- if (shouldImportNotes) {
213
- await removeIfExists(notesPath);
214
- }
215
- await removeDirIfEmpty(slugDir);
216
- }
217
- }
218
- if (cleanupLegacyFiles) {
219
- await removeDirIfEmpty(legacyStateDir);
220
- }
221
- }
222
- export async function migrateLegacyStateForRepo(repo, options = {}) {
223
- const cleanupLegacyFiles = options.cleanupLegacyFiles
224
- ?? !cleanupCompletedRepoIds.has(repo.id);
225
- const minFileAgeMs = options.minFileAgeMs ?? LEGACY_STATE_STABLE_MS;
226
- const inFlight = migrationInFlight.get(repo.id);
227
- if (inFlight) {
228
- return inFlight;
229
- }
230
- const migrationPromise = runLegacyStateMigration(repo, cleanupLegacyFiles, minFileAgeMs)
231
- .then(() => {
232
- if (cleanupLegacyFiles) {
233
- cleanupCompletedRepoIds.add(repo.id);
234
- }
235
- })
236
- .finally(() => {
237
- migrationInFlight.delete(repo.id);
238
- });
239
- migrationInFlight.set(repo.id, migrationPromise);
240
- return migrationPromise;
241
- }
@@ -274,12 +274,22 @@ export async function discoverGitRepos(basePath, maxDepth = 4) {
274
274
  return discovered;
275
275
  }
276
276
  export async function validateRepoPath(path) {
277
- // Normalize the path
278
- const resolvedPath = resolve(path);
279
- // Ensure path is absolute (starts with / on Unix or drive letter on Windows)
280
- 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) {
281
289
  return { valid: false, error: 'Path must be absolute' };
282
290
  }
291
+ // Normalize the path
292
+ const resolvedPath = resolve(trimmedPath);
283
293
  try {
284
294
  const stats = await fs.stat(resolvedPath);
285
295
  if (!stats.isDirectory()) {
@@ -0,0 +1,225 @@
1
+ import { emitChange } from './change-events.js';
2
+ import { dbAll, dbExec, dbGet, dbRun } from './db.js';
3
+ import { needsProgressMigration, parseStoredProgressFile, parseTasksFile } from './state-schema.js';
4
+ const MIGRATION_VERSION = 'progress-json-v2';
5
+ const MIGRATION_META_KEY = `state-migration:${MIGRATION_VERSION}`;
6
+ let status = {
7
+ state: 'idle',
8
+ version: MIGRATION_VERSION,
9
+ startedAt: null,
10
+ completedAt: null,
11
+ totalRows: 0,
12
+ processedRows: 0,
13
+ migratedRows: 0,
14
+ failedRows: 0,
15
+ currentSlug: null,
16
+ errorMessage: null,
17
+ percent: 0
18
+ };
19
+ let migrationPromise = null;
20
+ function nowIso() {
21
+ return new Date().toISOString();
22
+ }
23
+ function toPercent(processedRows, totalRows) {
24
+ if (totalRows <= 0) {
25
+ return 100;
26
+ }
27
+ return Math.min(100, Math.floor((processedRows / totalRows) * 100));
28
+ }
29
+ function resetRunningStatus(totalRows) {
30
+ status = {
31
+ state: 'running',
32
+ version: MIGRATION_VERSION,
33
+ startedAt: nowIso(),
34
+ completedAt: null,
35
+ totalRows,
36
+ processedRows: 0,
37
+ migratedRows: 0,
38
+ failedRows: 0,
39
+ currentSlug: null,
40
+ errorMessage: null,
41
+ percent: totalRows === 0 ? 100 : 0
42
+ };
43
+ }
44
+ function markCompleted(marker) {
45
+ const completedAt = marker?.completedAt || nowIso();
46
+ const totalRows = marker?.totalRows ?? status.totalRows;
47
+ const migratedRows = marker?.migratedRows ?? status.migratedRows;
48
+ status = {
49
+ ...status,
50
+ state: 'completed',
51
+ completedAt,
52
+ totalRows,
53
+ processedRows: totalRows,
54
+ migratedRows,
55
+ failedRows: 0,
56
+ currentSlug: null,
57
+ errorMessage: null,
58
+ percent: 100
59
+ };
60
+ }
61
+ function markFailed(message) {
62
+ status = {
63
+ ...status,
64
+ state: 'failed',
65
+ completedAt: nowIso(),
66
+ currentSlug: null,
67
+ errorMessage: message,
68
+ percent: toPercent(status.processedRows, status.totalRows)
69
+ };
70
+ }
71
+ async function ensureMetaTable() {
72
+ await dbExec(`
73
+ CREATE TABLE IF NOT EXISTS app_meta (
74
+ key TEXT PRIMARY KEY,
75
+ value TEXT NOT NULL,
76
+ updated_at TEXT NOT NULL
77
+ );
78
+ `);
79
+ }
80
+ async function readMigrationMarker() {
81
+ const row = await dbGet('SELECT value FROM app_meta WHERE key = ?', [MIGRATION_META_KEY]);
82
+ if (!row?.value) {
83
+ return null;
84
+ }
85
+ try {
86
+ const parsed = JSON.parse(row.value);
87
+ if (!parsed || typeof parsed !== 'object') {
88
+ return null;
89
+ }
90
+ if (parsed.version !== MIGRATION_VERSION) {
91
+ return null;
92
+ }
93
+ if (typeof parsed.completedAt !== 'string') {
94
+ return null;
95
+ }
96
+ if (typeof parsed.totalRows !== 'number' || typeof parsed.migratedRows !== 'number') {
97
+ return null;
98
+ }
99
+ return parsed;
100
+ }
101
+ catch {
102
+ return null;
103
+ }
104
+ }
105
+ async function writeMigrationMarker(totalRows, migratedRows) {
106
+ const completedAt = nowIso();
107
+ const marker = {
108
+ version: MIGRATION_VERSION,
109
+ completedAt,
110
+ totalRows,
111
+ migratedRows
112
+ };
113
+ await dbRun(`
114
+ INSERT INTO app_meta (key, value, updated_at)
115
+ VALUES (?, ?, ?)
116
+ ON CONFLICT(key) DO UPDATE SET
117
+ value = excluded.value,
118
+ updated_at = excluded.updated_at
119
+ `, [MIGRATION_META_KEY, JSON.stringify(marker), completedAt]);
120
+ }
121
+ async function migrateProgressRows() {
122
+ await ensureMetaTable();
123
+ const marker = await readMigrationMarker();
124
+ if (marker) {
125
+ markCompleted(marker);
126
+ return;
127
+ }
128
+ const rows = await dbAll(`
129
+ SELECT repo_id, slug, tasks_json, progress_json
130
+ FROM prd_states
131
+ WHERE progress_json IS NOT NULL
132
+ ORDER BY repo_id ASC, slug ASC
133
+ `);
134
+ resetRunningStatus(rows.length);
135
+ for (let index = 0; index < rows.length; index += 1) {
136
+ const row = rows[index];
137
+ status.currentSlug = row.slug;
138
+ try {
139
+ const parsedProgress = row.progress_json
140
+ ? JSON.parse(row.progress_json)
141
+ : null;
142
+ let tasksCountHint;
143
+ let prdNameFallback;
144
+ if (row.tasks_json) {
145
+ try {
146
+ const tasks = parseTasksFile(JSON.parse(row.tasks_json));
147
+ tasksCountHint = tasks.tasks.length;
148
+ prdNameFallback = tasks.prd.name;
149
+ }
150
+ catch {
151
+ // Keep fallback below when tasks_json is malformed.
152
+ }
153
+ }
154
+ const shouldMigrate = needsProgressMigration(parsedProgress);
155
+ if (shouldMigrate) {
156
+ const normalized = parseStoredProgressFile(parsedProgress, {
157
+ prdNameFallback: prdNameFallback || row.slug,
158
+ totalTasksHint: tasksCountHint
159
+ });
160
+ const updatedAt = nowIso();
161
+ await dbRun(`
162
+ UPDATE prd_states
163
+ SET progress_json = ?, updated_at = ?
164
+ WHERE repo_id = ? AND slug = ?
165
+ `, [JSON.stringify(normalized), updatedAt, row.repo_id, row.slug]);
166
+ status.migratedRows += 1;
167
+ emitChange({
168
+ type: 'change',
169
+ path: `state://${row.repo_id}/${row.slug}/progress.json`,
170
+ repoId: row.repo_id,
171
+ category: 'progress'
172
+ });
173
+ }
174
+ }
175
+ catch {
176
+ status.failedRows += 1;
177
+ }
178
+ status.processedRows = index + 1;
179
+ status.percent = toPercent(status.processedRows, status.totalRows);
180
+ }
181
+ if (status.failedRows > 0) {
182
+ markFailed(`Failed to migrate ${status.failedRows} PRD progress row(s).`);
183
+ return;
184
+ }
185
+ await writeMigrationMarker(status.totalRows, status.migratedRows);
186
+ markCompleted();
187
+ }
188
+ async function runMigration() {
189
+ try {
190
+ await migrateProgressRows();
191
+ }
192
+ catch (error) {
193
+ const message = error instanceof Error ? error.message : String(error);
194
+ markFailed(message);
195
+ }
196
+ }
197
+ export function getStateMigrationStatus() {
198
+ return { ...status };
199
+ }
200
+ export function startStateMigration() {
201
+ if (!migrationPromise) {
202
+ if (status.state === 'idle') {
203
+ status = {
204
+ ...status,
205
+ state: 'running',
206
+ startedAt: nowIso(),
207
+ completedAt: null,
208
+ errorMessage: null,
209
+ percent: 0
210
+ };
211
+ }
212
+ migrationPromise = runMigration().finally(() => {
213
+ migrationPromise = null;
214
+ });
215
+ }
216
+ return migrationPromise;
217
+ }
218
+ export async function ensureStateMigrationReady() {
219
+ if (status.state !== 'completed') {
220
+ await startStateMigration();
221
+ }
222
+ if (status.state === 'failed') {
223
+ throw new Error(status.errorMessage || 'State migration failed');
224
+ }
225
+ }
@@ -6,6 +6,7 @@ const commitRefSchema = z.object({
6
6
  sha: z.string().min(1),
7
7
  repo: z.string()
8
8
  });
9
+ const taskStatusValues = new Set(['pending', 'in_progress', 'completed']);
9
10
  const taskSchema = z.object({
10
11
  id: z.string().min(1),
11
12
  category: taskCategorySchema,
@@ -38,6 +39,19 @@ const taskLogSchema = z.object({
38
39
  learnings: z.string().optional(),
39
40
  commits: z.array(z.union([z.string().min(1), commitRefSchema])).optional()
40
41
  });
42
+ const progressPatternSchema = z.union([
43
+ z.string().trim().min(1).transform((value) => ({
44
+ name: value,
45
+ description: value
46
+ })),
47
+ z.object({
48
+ name: z.string().trim().min(1),
49
+ description: z.string().trim().min(1).optional()
50
+ }).transform((value) => ({
51
+ name: value.name,
52
+ description: value.description ?? value.name
53
+ }))
54
+ ]);
41
55
  const progressFileSchema = z.object({
42
56
  prdName: z.string(),
43
57
  shortcutStory: z.string().optional(),
@@ -47,12 +61,175 @@ const progressFileSchema = z.object({
47
61
  blocked: z.number(),
48
62
  startedAt: z.string().nullable(),
49
63
  lastUpdated: z.string(),
50
- patterns: z.array(z.object({
51
- name: z.string(),
52
- description: z.string()
53
- })),
64
+ patterns: z.array(progressPatternSchema),
54
65
  taskLogs: z.array(taskLogSchema)
55
66
  });
67
+ const progressInputSchema = z.object({
68
+ prdName: z.string().trim().min(1).optional(),
69
+ startedAt: z.union([z.string(), z.null()]).optional(),
70
+ started: z.union([z.string(), z.null()]).optional(),
71
+ totalTasks: z.number().optional(),
72
+ completed: z.union([z.number(), z.array(z.unknown())]).optional(),
73
+ inProgress: z.union([z.number(), z.null()]).optional(),
74
+ blocked: z.number().optional(),
75
+ lastUpdated: z.string().optional(),
76
+ patterns: z.array(z.unknown()).optional(),
77
+ taskLogs: z.array(z.unknown()).optional(),
78
+ taskProgress: z.record(z.object({
79
+ status: z.string().optional(),
80
+ startedAt: z.string().optional(),
81
+ completedAt: z.string().optional(),
82
+ implemented: z.string().optional(),
83
+ filesChanged: z.array(z.string()).optional(),
84
+ learnings: z.string().optional(),
85
+ commits: z.array(z.union([z.string().min(1), commitRefSchema])).optional()
86
+ })).optional()
87
+ }).passthrough();
88
+ function coerceNonNegativeNumber(value) {
89
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
90
+ return undefined;
91
+ }
92
+ return Math.max(0, Math.floor(value));
93
+ }
94
+ function normalizeTaskLogStatus(value) {
95
+ if (typeof value === 'string' && taskStatusValues.has(value)) {
96
+ return value;
97
+ }
98
+ return 'pending';
99
+ }
100
+ function normalizeLegacyTaskProgress(taskProgress, now) {
101
+ if (!taskProgress) {
102
+ return [];
103
+ }
104
+ return Object.entries(taskProgress).map(([taskId, value]) => ({
105
+ taskId,
106
+ status: normalizeTaskLogStatus(value.status),
107
+ startedAt: typeof value.startedAt === 'string' ? value.startedAt : now,
108
+ ...(typeof value.completedAt === 'string' && { completedAt: value.completedAt }),
109
+ ...(typeof value.implemented === 'string' && { implemented: value.implemented }),
110
+ ...(Array.isArray(value.filesChanged) && { filesChanged: value.filesChanged }),
111
+ ...(typeof value.learnings === 'string' && { learnings: value.learnings }),
112
+ ...(Array.isArray(value.commits) && { commits: value.commits })
113
+ }));
114
+ }
115
+ function normalizePatterns(patterns) {
116
+ if (!patterns) {
117
+ return [];
118
+ }
119
+ const normalized = [];
120
+ for (const pattern of patterns) {
121
+ const parsed = progressPatternSchema.safeParse(pattern);
122
+ if (parsed.success) {
123
+ normalized.push(parsed.data);
124
+ }
125
+ }
126
+ return normalized;
127
+ }
128
+ function normalizeTaskLogs(taskLogs, now) {
129
+ if (!taskLogs) {
130
+ return [];
131
+ }
132
+ const normalized = [];
133
+ for (const taskLog of taskLogs) {
134
+ const parsed = taskLogSchema.safeParse(taskLog);
135
+ if (parsed.success) {
136
+ normalized.push(parsed.data);
137
+ continue;
138
+ }
139
+ if (!taskLog || typeof taskLog !== 'object' || Array.isArray(taskLog)) {
140
+ continue;
141
+ }
142
+ const raw = taskLog;
143
+ if (typeof raw.taskId !== 'string' || raw.taskId.trim().length === 0) {
144
+ continue;
145
+ }
146
+ const startedAt = typeof raw.startedAt === 'string' ? raw.startedAt : now;
147
+ const commits = Array.isArray(raw.commits)
148
+ ? raw.commits.filter((commit) => {
149
+ if (typeof commit === 'string' && commit.trim().length > 0) {
150
+ return true;
151
+ }
152
+ if (!commit || typeof commit !== 'object' || Array.isArray(commit)) {
153
+ return false;
154
+ }
155
+ const ref = commit;
156
+ return typeof ref.sha === 'string' && ref.sha.trim().length > 0 && typeof ref.repo === 'string';
157
+ })
158
+ : undefined;
159
+ normalized.push({
160
+ taskId: raw.taskId,
161
+ status: normalizeTaskLogStatus(raw.status),
162
+ startedAt,
163
+ ...(typeof raw.completedAt === 'string' && { completedAt: raw.completedAt }),
164
+ ...(typeof raw.implemented === 'string' && { implemented: raw.implemented }),
165
+ ...(Array.isArray(raw.filesChanged) && {
166
+ filesChanged: raw.filesChanged.filter((file) => typeof file === 'string')
167
+ }),
168
+ ...(typeof raw.learnings === 'string' && { learnings: raw.learnings }),
169
+ ...(commits && commits.length > 0 && { commits })
170
+ });
171
+ }
172
+ return normalized;
173
+ }
174
+ export function normalizeProgressFile(value, options = {}) {
175
+ const now = options.now || new Date().toISOString();
176
+ const parsed = progressInputSchema.safeParse(value);
177
+ const input = parsed.success ? parsed.data : {};
178
+ const prdName = input.prdName
179
+ || options.prdNameFallback
180
+ || 'Unknown PRD';
181
+ const patterns = normalizePatterns(input.patterns);
182
+ const taskLogs = input.taskLogs && input.taskLogs.length > 0
183
+ ? normalizeTaskLogs(input.taskLogs, now)
184
+ : normalizeLegacyTaskProgress(input.taskProgress, now);
185
+ const completed = typeof input.completed === 'number'
186
+ ? Math.max(0, Math.floor(input.completed))
187
+ : (Array.isArray(input.completed) ? input.completed.length : taskLogs.filter((log) => log.status === 'completed').length);
188
+ const inProgress = coerceNonNegativeNumber(input.inProgress)
189
+ ?? taskLogs.filter((log) => log.status === 'in_progress').length;
190
+ const blocked = coerceNonNegativeNumber(input.blocked) ?? 0;
191
+ const totalTasks = coerceNonNegativeNumber(input.totalTasks)
192
+ ?? coerceNonNegativeNumber(options.totalTasksHint)
193
+ ?? Math.max(completed + inProgress + blocked, taskLogs.length);
194
+ return progressFileSchema.parse({
195
+ prdName,
196
+ shortcutStory: undefined,
197
+ totalTasks,
198
+ completed,
199
+ inProgress,
200
+ blocked,
201
+ startedAt: input.startedAt ?? input.started ?? null,
202
+ lastUpdated: input.lastUpdated || now,
203
+ patterns,
204
+ taskLogs
205
+ });
206
+ }
207
+ export function parseStoredProgressFile(value, options = {}) {
208
+ return normalizeProgressFile(value, options);
209
+ }
210
+ export function needsProgressMigration(value) {
211
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
212
+ return true;
213
+ }
214
+ const record = value;
215
+ if (Object.prototype.hasOwnProperty.call(record, 'started')) {
216
+ return true;
217
+ }
218
+ if (Object.prototype.hasOwnProperty.call(record, 'taskProgress')) {
219
+ return true;
220
+ }
221
+ if (!Array.isArray(record.patterns) || !Array.isArray(record.taskLogs)) {
222
+ return true;
223
+ }
224
+ if (Array.isArray(record.completed)) {
225
+ return true;
226
+ }
227
+ if (record.inProgress === null) {
228
+ return true;
229
+ }
230
+ const result = progressFileSchema.safeParse(value);
231
+ return !result.success;
232
+ }
56
233
  export function parseTasksFile(value) {
57
234
  return tasksFileSchema.parse(value);
58
235
  }
@@ -1,5 +1,6 @@
1
1
  import { promises as fs } from 'node:fs';
2
- import { getPrdState, getPrdStateSummaries, migrateLegacyStateForRepo } from './prd-state.js';
2
+ import { getPrdArchiveMap } from './prd-archive.js';
3
+ import { getPrdState, getPrdStateSummaries } from './prd-state.js';
3
4
  import { resolvePrdMarkdownPath } from './prd-service.js';
4
5
  const NODE_SEPARATOR = '::';
5
6
  const MISSING_PREFIX = 'missing';
@@ -139,7 +140,6 @@ function buildGraphFromInputs(inputs) {
139
140
  };
140
141
  }
141
142
  export async function buildPrdGraph(repo, prdSlug) {
142
- await migrateLegacyStateForRepo(repo);
143
143
  const [state, prdName] = await Promise.all([
144
144
  getPrdState(repo.id, prdSlug),
145
145
  resolvePrdName(repo, prdSlug)
@@ -161,23 +161,30 @@ export async function buildPrdGraph(repo, prdSlug) {
161
161
  stats: graph.stats
162
162
  };
163
163
  }
164
- export async function buildRepoGraph(repo) {
165
- await migrateLegacyStateForRepo(repo);
166
- const summaries = await getPrdStateSummaries(repo.id);
167
- const slugs = [...summaries.keys()].sort((a, b) => a.localeCompare(b));
168
- const inputs = [];
169
- for (const slug of slugs) {
170
- const state = await getPrdState(repo.id, slug);
164
+ export async function buildRepoGraph(repo, options = {}) {
165
+ const includeArchived = options.includeArchived === true;
166
+ const [summaries, archiveMap] = await Promise.all([
167
+ getPrdStateSummaries(repo.id),
168
+ getPrdArchiveMap(repo.id)
169
+ ]);
170
+ const slugs = [...summaries.keys()]
171
+ .filter((slug) => includeArchived || !archiveMap.has(slug))
172
+ .sort((a, b) => a.localeCompare(b));
173
+ const inputs = (await Promise.all(slugs.map(async (slug) => {
174
+ const [state, prdName] = await Promise.all([
175
+ getPrdState(repo.id, slug),
176
+ resolvePrdName(repo, slug)
177
+ ]);
171
178
  const tasks = state?.tasks?.tasks;
172
179
  if (!Array.isArray(tasks)) {
173
- continue;
180
+ return null;
174
181
  }
175
- inputs.push({
182
+ return {
176
183
  prdSlug: slug,
177
- prdName: await resolvePrdName(repo, slug),
184
+ prdName,
178
185
  tasks
179
- });
180
- }
186
+ };
187
+ }))).filter((input) => input !== null);
181
188
  const graph = buildGraphFromInputs(inputs);
182
189
  return {
183
190
  scope: 'repo',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thxgg/steward",
3
- "version": "0.1.17",
3
+ "version": "0.1.19",
4
4
  "description": "Local-first PRD workflow steward with codemode MCP and web UI.",
5
5
  "type": "module",
6
6
  "author": "thxgg",