| 138 | * surfaced as a loud, actionable error rather than a silent "no issues". |
| 139 | */ |
| 140 | export async function readWorkspaceIssues(wsDir: string): Promise<ReadIssuesResult> { |
| 141 | const dir = join(wsDir, ISSUES_DIR_REL) |
| 142 | |
| 143 | let entries: string[] |
| 144 | try { |
| 145 | entries = await readdir(dir) |
| 146 | } catch (err) { |
| 147 | if ((err as NodeJS.ErrnoException).code === 'ENOENT') { |
| 148 | // Loud over silent: a workspace still carrying the retired single file gets |
| 149 | // an actionable rename hint, not a bland "no issues" that hides the file. |
| 150 | try { |
| 151 | await stat(join(wsDir, LEGACY_ISSUE_FILE_REL)) |
| 152 | return { |
| 153 | ok: false, |
| 154 | reason: 'invalid', |
| 155 | error: |
| 156 | '`.alice/issue.json` is retired — split each issue into its own `.alice/issues/<id>.md` (one markdown file per issue, see the self-scheduling skill)', |
| 157 | } |
| 158 | } catch { |
| 159 | return { ok: false, reason: 'absent' } |
| 160 | } |
| 161 | } |
| 162 | return { ok: false, reason: 'invalid', error: err instanceof Error ? err.message : String(err) } |
| 163 | } |
| 164 | |
| 165 | const files = entries.filter((f) => f.toLowerCase().endsWith('.md')).sort() |
| 166 | const issues: IssueRecord[] = [] |
| 167 | const invalid: InvalidIssue[] = [] |
| 168 | for (const file of files) { |
| 169 | const id = file.slice(0, -'.md'.length) |
| 170 | const one = await readOneIssue(join(dir, file), id) |
| 171 | if (one.ok) issues.push(one.issue) |
| 172 | else invalid.push({ id, error: one.error }) |
| 173 | } |
| 174 | return { ok: true, issues, invalid } |
| 175 | } |
| 176 | |
| 177 | async function readOneIssue( |
| 178 | path: string, |