@deskwork/core 0.9.5 → 0.9.6

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.
package/README.md ADDED
@@ -0,0 +1,29 @@
1
+ ## @deskwork/core
2
+
3
+ Core lifecycle library for the [deskwork](https://github.com/audiocontrol-org/deskwork) editorial-calendar plugin family. Pure logic with no entry point — config schema, content-tree walker, frontmatter binding, calendar parser, doctor rules, scrapbook resolver, review pipeline.
4
+
5
+ ### Audience
6
+
7
+ This package exists to back the deskwork Claude Code plugins; it is shared between [`@deskwork/cli`](https://www.npmjs.com/package/@deskwork/cli) and [`@deskwork/studio`](https://www.npmjs.com/package/@deskwork/studio). Direct npm install of `@deskwork/core` into a non-deskwork host is unusual — most adopters install the plugin instead, which `npm install`s this package on first run.
8
+
9
+ If you want the editorial calendar lifecycle as a Claude Code plugin (the canonical entry point), follow the install instructions at the [marketplace repo](https://github.com/audiocontrol-org/deskwork#install).
10
+
11
+ ### Use cases for direct install
12
+
13
+ You may want this package directly if you are:
14
+
15
+ - Writing a tool that walks deskwork-managed content trees (e.g. a custom doctor rule, a static-site renderer, an analytics script).
16
+ - Building a downstream plugin that reuses the calendar parser or frontmatter binding code.
17
+ - Embedding deskwork's lifecycle handlers into a non-Claude-Code surface.
18
+
19
+ ### Subpath exports
20
+
21
+ The package exposes ~25 subpath exports (`@deskwork/core/calendar`, `@deskwork/core/frontmatter`, `@deskwork/core/content-tree`, `@deskwork/core/doctor`, `@deskwork/core/review`, etc.) — see [`package.json`](./package.json) for the full list.
22
+
23
+ ### Source
24
+
25
+ Repository: [`audiocontrol-org/deskwork`](https://github.com/audiocontrol-org/deskwork) — monorepo with the package at [`packages/core/`](https://github.com/audiocontrol-org/deskwork/tree/main/packages/core), the CLI at [`packages/cli/`](https://github.com/audiocontrol-org/deskwork/tree/main/packages/cli), and the studio at [`packages/studio/`](https://github.com/audiocontrol-org/deskwork/tree/main/packages/studio). The plugin shells live under [`plugins/`](https://github.com/audiocontrol-org/deskwork/tree/main/plugins).
26
+
27
+ ### License
28
+
29
+ GPL-3.0-or-later — same as the monorepo. See [`LICENSE`](https://github.com/audiocontrol-org/deskwork/blob/main/LICENSE) for details.
@@ -0,0 +1,205 @@
1
+ /**
2
+ * Rule: calendar-uuid-missing.
3
+ *
4
+ * Detection: re-read the calendar markdown from disk and find rows
5
+ * whose UUID cell is empty or missing. The in-memory parser auto-
6
+ * backfills missing UUIDs, so the in-context calendar always has ids;
7
+ * we have to look at the on-disk bytes directly to see what hasn't
8
+ * been persisted yet.
9
+ *
10
+ * Repair: a single calendar write flushes the in-memory backfilled
11
+ * ids to disk. We read the calendar via `readCalendar` (which assigns
12
+ * UUIDs in-memory) and call `writeCalendar` to persist them.
13
+ *
14
+ * Sibling-relative imports per the project convention.
15
+ */
16
+
17
+ import { readFileSync, existsSync } from 'node:fs';
18
+ import { resolveCalendarPath } from '../../paths.ts';
19
+ import { readCalendar, writeCalendar } from '../../calendar.ts';
20
+ import type {
21
+ DoctorContext,
22
+ DoctorRule,
23
+ Finding,
24
+ RepairPlan,
25
+ RepairResult,
26
+ } from '../types.ts';
27
+
28
+ const RULE_ID = 'calendar-uuid-missing';
29
+
30
+ interface RawRow {
31
+ /** Slug parsed out of the row, for human-facing messages. */
32
+ slug: string;
33
+ /** Stage-table the row belonged to. */
34
+ stage: string;
35
+ /** Line number (1-based) in the calendar source. */
36
+ line: number;
37
+ }
38
+
39
+ const STAGE_HEADER_RE = /^##\s+(.+)\s*$/;
40
+
41
+ /**
42
+ * Walk the calendar markdown line-by-line, find stage tables, and
43
+ * report rows whose UUID column is empty/missing.
44
+ *
45
+ * Tolerant of column ordering: we identify the UUID column index from
46
+ * the table header (case-insensitive), then check each data row's
47
+ * cell at that index.
48
+ */
49
+ function scanRowsMissingUuid(markdown: string): RawRow[] {
50
+ const lines = markdown.split('\n');
51
+ const rows: RawRow[] = [];
52
+
53
+ let currentStage: string | null = null;
54
+ let i = 0;
55
+ while (i < lines.length) {
56
+ const line = lines[i];
57
+ const stageMatch = line.match(STAGE_HEADER_RE);
58
+ if (stageMatch) {
59
+ const name = stageMatch[1].trim();
60
+ // Only the lifecycle stage tables; Distribution / Shortform
61
+ // sections aren't entry rows so we skip their tables here.
62
+ if (name === 'Distribution' || name === 'Shortform Copy') {
63
+ currentStage = null;
64
+ } else {
65
+ currentStage = name;
66
+ }
67
+ i++;
68
+ continue;
69
+ }
70
+ if (line.startsWith('|') && currentStage) {
71
+ // Table found — first row is the header.
72
+ const headerCells = parseRow(line);
73
+ const uuidIdx = headerCells.findIndex(
74
+ (c) => c.trim().toLowerCase() === 'uuid' || c.trim().toLowerCase() === 'id',
75
+ );
76
+ const slugIdx = headerCells.findIndex(
77
+ (c) => c.trim().toLowerCase() === 'slug',
78
+ );
79
+ i++;
80
+ // Optional separator row.
81
+ if (i < lines.length && /^\|[\s:-]+\|/.test(lines[i])) i++;
82
+ while (i < lines.length && lines[i].startsWith('|')) {
83
+ const cells = parseRow(lines[i]);
84
+ const slug = slugIdx >= 0 ? (cells[slugIdx] ?? '').trim() : '';
85
+ if (slug) {
86
+ const uuidCell = uuidIdx >= 0 ? (cells[uuidIdx] ?? '').trim() : '';
87
+ if (!uuidCell) {
88
+ rows.push({
89
+ slug,
90
+ stage: currentStage,
91
+ line: i + 1,
92
+ });
93
+ }
94
+ }
95
+ i++;
96
+ }
97
+ continue;
98
+ }
99
+ i++;
100
+ }
101
+
102
+ return rows;
103
+ }
104
+
105
+ function parseRow(line: string): string[] {
106
+ return line.split('|').slice(1, -1).map((c) => c.trim());
107
+ }
108
+
109
+ const rule: DoctorRule = {
110
+ id: RULE_ID,
111
+ label: 'Calendar rows with missing UUIDs (not yet persisted)',
112
+
113
+ async audit(ctx: DoctorContext): Promise<Finding[]> {
114
+ const calendarPath = resolveCalendarPath(
115
+ ctx.projectRoot,
116
+ ctx.config,
117
+ ctx.site,
118
+ );
119
+ if (!existsSync(calendarPath)) return [];
120
+ let raw: string;
121
+ try {
122
+ raw = readFileSync(calendarPath, 'utf-8');
123
+ } catch {
124
+ return [];
125
+ }
126
+ const rows = scanRowsMissingUuid(raw);
127
+ if (rows.length === 0) return [];
128
+ return [
129
+ {
130
+ ruleId: RULE_ID,
131
+ site: ctx.site,
132
+ severity: 'warning',
133
+ message: `${rows.length} calendar row(s) have no UUID on disk; in-memory parser will backfill on next write`,
134
+ details: {
135
+ calendarPath,
136
+ rows: rows.map((r) => ({ slug: r.slug, stage: r.stage, line: r.line })),
137
+ },
138
+ },
139
+ ];
140
+ },
141
+
142
+ async plan(ctx: DoctorContext, finding: Finding): Promise<RepairPlan> {
143
+ const calendarPath = String(finding.details.calendarPath ?? '');
144
+ if (!calendarPath) {
145
+ return {
146
+ kind: 'report-only',
147
+ finding,
148
+ reason: 'finding missing calendarPath — re-run audit',
149
+ };
150
+ }
151
+ return {
152
+ kind: 'apply',
153
+ finding,
154
+ summary: `re-write ${calendarPath} so the in-memory backfilled UUIDs land on disk`,
155
+ payload: { calendarPath, site: ctx.site },
156
+ };
157
+ },
158
+
159
+ async apply(ctx: DoctorContext, plan: RepairPlan): Promise<RepairResult> {
160
+ if (plan.kind !== 'apply') {
161
+ return {
162
+ finding: plan.finding,
163
+ applied: false,
164
+ message: 'plan is not directly appliable; runner should resolve prompt first',
165
+ skipReason: 'apply-failed',
166
+ };
167
+ }
168
+ const calendarPath = String(plan.payload.calendarPath ?? '');
169
+ if (!calendarPath) {
170
+ return {
171
+ finding: plan.finding,
172
+ applied: false,
173
+ message: 'apply payload missing calendarPath',
174
+ skipReason: 'apply-failed',
175
+ };
176
+ }
177
+ try {
178
+ // readCalendar populates missing UUIDs in-memory; writeCalendar
179
+ // flushes the populated calendar back to disk. One round-trip
180
+ // migrates every row — also true at the call site here.
181
+ const cal = readCalendar(calendarPath);
182
+ writeCalendar(calendarPath, cal);
183
+ } catch (err) {
184
+ const reason = err instanceof Error ? err.message : String(err);
185
+ return {
186
+ finding: plan.finding,
187
+ applied: false,
188
+ message: `failed to re-write calendar: ${reason}`,
189
+ skipReason: 'apply-failed',
190
+ };
191
+ }
192
+ // Re-read what's now on disk to update the runner's view of
193
+ // ctx.calendar — though strictly the runner doesn't depend on
194
+ // it past this point.
195
+ void ctx;
196
+ return {
197
+ finding: plan.finding,
198
+ applied: true,
199
+ message: `re-wrote ${calendarPath} with UUIDs populated`,
200
+ details: { calendarPath },
201
+ };
202
+ },
203
+ };
204
+
205
+ export default rule;
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Rule: duplicate-id.
3
+ *
4
+ * Audit: more than one file under contentDir claims the same frontmatter
5
+ * id. The content index reports the byId map keeping only the first
6
+ * encountered, but byPath records every file. We re-walk byPath grouped
7
+ * by id and flag any group with > 1 entry.
8
+ *
9
+ * Repair: prompt the operator to pick a canonical file; clear the id
10
+ * from the others. With `--yes`, skip — picking a canonical file is
11
+ * an editorial decision, not something doctor should default.
12
+ */
13
+
14
+ import { readFileSync, writeFileSync } from 'node:fs';
15
+ import { join, relative } from 'node:path';
16
+ import { parseFrontmatter, removeFrontmatterPaths } from '../../frontmatter.ts';
17
+ import { resolveContentDir } from '../../paths.ts';
18
+ import type {
19
+ DoctorContext,
20
+ DoctorRule,
21
+ Finding,
22
+ RepairPlan,
23
+ RepairResult,
24
+ } from '../types.ts';
25
+
26
+ const RULE_ID = 'duplicate-id';
27
+
28
+ /**
29
+ * Clear the `deskwork.id` field from a markdown file. Returns true when
30
+ * the field was present and cleared. Issue #38: scoped to the
31
+ * namespaced key — top-level `id:` belongs to the operator and is left
32
+ * alone.
33
+ */
34
+ function clearFrontmatterId(absPath: string): boolean {
35
+ const raw = readFileSync(absPath, 'utf-8');
36
+ const { data } = parseFrontmatter(raw);
37
+ const block = data.deskwork;
38
+ if (block === undefined || block === null) return false;
39
+ if (typeof block !== 'object' || Array.isArray(block)) return false;
40
+ const blockObj = block as Record<string, unknown>;
41
+ if (!('id' in blockObj)) return false;
42
+
43
+ const updated = removeFrontmatterPaths(raw, [['deskwork', 'id']]);
44
+ if (updated === raw) return false;
45
+ writeFileSync(absPath, updated, 'utf-8');
46
+ return true;
47
+ }
48
+
49
+ interface DuplicateGroup {
50
+ id: string;
51
+ /** Absolute paths of every file claiming `id`. */
52
+ files: string[];
53
+ }
54
+
55
+ /**
56
+ * Group `index.byPath` (relPath → uuid) by uuid, return only groups
57
+ * with more than one file. Caller resolves relative paths against
58
+ * the site's contentDir to get absolute paths for repair.
59
+ */
60
+ export function findDuplicateGroups(
61
+ ctx: DoctorContext,
62
+ ): DuplicateGroup[] {
63
+ const contentDir = resolveContentDir(ctx.projectRoot, ctx.config, ctx.site);
64
+ const byUuid = new Map<string, string[]>();
65
+ for (const [relPath, uuid] of ctx.index.byPath) {
66
+ const abs = join(contentDir, relPath);
67
+ const list = byUuid.get(uuid);
68
+ if (list) list.push(abs);
69
+ else byUuid.set(uuid, [abs]);
70
+ }
71
+ const groups: DuplicateGroup[] = [];
72
+ for (const [uuid, files] of byUuid) {
73
+ if (files.length > 1) {
74
+ groups.push({ id: uuid, files: files.slice().sort() });
75
+ }
76
+ }
77
+ return groups;
78
+ }
79
+
80
+ const rule: DoctorRule = {
81
+ id: RULE_ID,
82
+ label: 'Multiple files share the same frontmatter id',
83
+
84
+ async audit(ctx: DoctorContext): Promise<Finding[]> {
85
+ const groups = findDuplicateGroups(ctx);
86
+ return groups.map((g) => ({
87
+ ruleId: RULE_ID,
88
+ site: ctx.site,
89
+ severity: 'error',
90
+ message: `id ${g.id} appears in ${g.files.length} files`,
91
+ details: { entryId: g.id, files: g.files },
92
+ }));
93
+ },
94
+
95
+ async plan(ctx: DoctorContext, finding: Finding): Promise<RepairPlan> {
96
+ const rawFiles = finding.details.files;
97
+ const files: string[] = Array.isArray(rawFiles)
98
+ ? rawFiles.filter((x): x is string => typeof x === 'string')
99
+ : [];
100
+ if (files.length < 2) {
101
+ return {
102
+ kind: 'report-only',
103
+ finding,
104
+ reason: 'duplicate group has fewer than 2 files — re-run audit',
105
+ };
106
+ }
107
+ return {
108
+ kind: 'prompt',
109
+ finding,
110
+ question: `Multiple files claim id ${finding.details.entryId}. Pick the canonical file; the id will be cleared from the others.`,
111
+ choices: files.map((abs) => ({
112
+ id: abs,
113
+ label: relative(ctx.projectRoot, abs),
114
+ payload: { canonical: abs, others: files.filter((f) => f !== abs) },
115
+ })),
116
+ };
117
+ },
118
+
119
+ async apply(ctx: DoctorContext, plan: RepairPlan): Promise<RepairResult> {
120
+ if (plan.kind !== 'apply') {
121
+ return {
122
+ finding: plan.finding,
123
+ applied: false,
124
+ message: 'plan is not directly appliable; runner should resolve prompt first',
125
+ skipReason: 'apply-failed',
126
+ };
127
+ }
128
+ const canonical = String(plan.payload.canonical ?? '');
129
+ const othersRaw = plan.payload.others;
130
+ const others = Array.isArray(othersRaw)
131
+ ? (othersRaw.filter((x): x is string => typeof x === 'string'))
132
+ : [];
133
+ if (!canonical || others.length === 0) {
134
+ return {
135
+ finding: plan.finding,
136
+ applied: false,
137
+ message: 'apply payload missing canonical or others',
138
+ skipReason: 'apply-failed',
139
+ };
140
+ }
141
+ const cleared: string[] = [];
142
+ const failed: string[] = [];
143
+ for (const abs of others) {
144
+ try {
145
+ const changed = clearFrontmatterId(abs);
146
+ if (changed) cleared.push(abs);
147
+ } catch {
148
+ failed.push(abs);
149
+ }
150
+ }
151
+ if (failed.length > 0) {
152
+ const partial = cleared.length > 0;
153
+ return {
154
+ finding: plan.finding,
155
+ applied: partial,
156
+ message: `cleared id from ${cleared.length} file(s); failed on ${failed.length}: ${failed.map((p) => relative(ctx.projectRoot, p)).join(', ')}`,
157
+ // When we cleared at least one file but some failed, treat as
158
+ // partial success — the `apply-failed` skip reason only fires
159
+ // when nothing landed on disk.
160
+ ...(partial ? {} : { skipReason: 'apply-failed' as const }),
161
+ details: { canonical, cleared, failed },
162
+ };
163
+ }
164
+ return {
165
+ finding: plan.finding,
166
+ applied: true,
167
+ message: `cleared id from ${cleared.length} file(s); canonical: ${relative(ctx.projectRoot, canonical)}`,
168
+ details: { canonical, cleared },
169
+ };
170
+ },
171
+ };
172
+
173
+ export default rule;
@@ -0,0 +1,264 @@
1
+ /**
2
+ * Rule: legacy-top-level-id-migration.
3
+ *
4
+ * Issue #38: v0.7.0 / v0.7.1 of deskwork wrote (and read) the calendar
5
+ * binding key as a top-level `id:` field in frontmatter. Starting in
6
+ * v0.7.2 the canonical location is `deskwork.id`, scoping the binding
7
+ * to a `deskwork:` namespace so deskwork doesn't claim the operator's
8
+ * global keyspace.
9
+ *
10
+ * Audit: walk every markdown file under `<contentDir>` and find files
11
+ * where:
12
+ * 1. top-level `id:` is present AND its value is a UUID matching a
13
+ * calendar entry, AND
14
+ * 2. `deskwork.id` is NOT present.
15
+ *
16
+ * The (1)+(2) conjunction makes the rule idempotent: once a file has
17
+ * migrated to the namespaced form, it is no longer reported. Files
18
+ * with a top-level `id:` whose value is NOT a calendar UUID belong to
19
+ * the operator and are left alone.
20
+ *
21
+ * Repair: round-trip-preserving rewrite — add `deskwork.id` with the
22
+ * old value, remove the top-level `id:`, leave every other byte
23
+ * untouched. Safe for `--yes` / `--fix=all` mode (clear-and-move with
24
+ * no editorial decision required).
25
+ *
26
+ * Sibling-relative imports per the project convention.
27
+ */
28
+
29
+ import { readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
30
+ import { extname, join, relative } from 'node:path';
31
+ import { resolveContentDir } from '../../paths.ts';
32
+ import {
33
+ parseFrontmatter,
34
+ removeFrontmatterPaths,
35
+ updateFrontmatter,
36
+ } from '../../frontmatter.ts';
37
+ import type {
38
+ DoctorContext,
39
+ DoctorRule,
40
+ Finding,
41
+ RepairPlan,
42
+ RepairResult,
43
+ } from '../types.ts';
44
+
45
+ const RULE_ID = 'legacy-top-level-id-migration';
46
+
47
+ const MARKDOWN_EXTENSIONS: ReadonlySet<string> = new Set([
48
+ '.md',
49
+ '.mdx',
50
+ '.markdown',
51
+ ]);
52
+ const SKIP_DIRS: ReadonlySet<string> = new Set([
53
+ 'scrapbook',
54
+ 'node_modules',
55
+ 'dist',
56
+ '.git',
57
+ ]);
58
+
59
+ const UUID_RE =
60
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
61
+
62
+ function shouldSkipDir(name: string): boolean {
63
+ if (name.startsWith('.')) return true;
64
+ return SKIP_DIRS.has(name.toLowerCase());
65
+ }
66
+
67
+ function collectMarkdownFiles(dir: string): string[] {
68
+ const out: string[] = [];
69
+ visit(dir);
70
+ out.sort();
71
+ return out;
72
+
73
+ function visit(currentDir: string): void {
74
+ let names: string[];
75
+ try {
76
+ names = readdirSync(currentDir);
77
+ } catch {
78
+ return;
79
+ }
80
+ for (const name of names) {
81
+ const abs = join(currentDir, name);
82
+ let st;
83
+ try {
84
+ st = statSync(abs);
85
+ } catch {
86
+ continue;
87
+ }
88
+ if (st.isDirectory()) {
89
+ if (shouldSkipDir(name)) continue;
90
+ visit(abs);
91
+ continue;
92
+ }
93
+ if (!st.isFile()) continue;
94
+ if (MARKDOWN_EXTENSIONS.has(extname(name).toLowerCase())) {
95
+ out.push(abs);
96
+ }
97
+ }
98
+ }
99
+ }
100
+
101
+ interface LegacyHit {
102
+ /** Absolute path to the file with a legacy top-level id. */
103
+ absolutePath: string;
104
+ /** The UUID value at the top-level id field. */
105
+ legacyId: string;
106
+ }
107
+
108
+ /**
109
+ * Inspect a single file's frontmatter and decide if it qualifies for
110
+ * migration. The conjunction is strict on purpose:
111
+ * - top-level `id:` must be a string AND match the UUID shape
112
+ * - that UUID must be present in the calendar (otherwise it isn't
113
+ * deskwork's id; it's the operator's)
114
+ * - `deskwork.id` must NOT exist (otherwise the file is already on
115
+ * the new shape; ignoring leaves the rule idempotent across runs)
116
+ */
117
+ function classify(
118
+ absPath: string,
119
+ calendarIds: ReadonlySet<string>,
120
+ ): LegacyHit | null {
121
+ let parsed;
122
+ try {
123
+ parsed = parseFrontmatter(readFileSync(absPath, 'utf-8'));
124
+ } catch {
125
+ return null;
126
+ }
127
+ const topLevelId = parsed.data.id;
128
+ if (typeof topLevelId !== 'string') return null;
129
+ const trimmed = topLevelId.trim();
130
+ if (trimmed === '' || !UUID_RE.test(trimmed)) return null;
131
+ if (!calendarIds.has(trimmed)) return null;
132
+
133
+ const block = parsed.data.deskwork;
134
+ if (block !== undefined && block !== null) {
135
+ if (typeof block === 'object' && !Array.isArray(block)) {
136
+ const nestedId = (block as Record<string, unknown>).id;
137
+ if (typeof nestedId === 'string' && nestedId.trim() !== '') {
138
+ return null;
139
+ }
140
+ }
141
+ }
142
+ return { absolutePath: absPath, legacyId: trimmed };
143
+ }
144
+
145
+ /**
146
+ * Apply the migration to a single file:
147
+ * 1. Add `deskwork.id` with the old value.
148
+ * 2. Remove the top-level `id:`.
149
+ *
150
+ * Both writes use the round-trip-preserving frontmatter API so every
151
+ * other byte stays put.
152
+ */
153
+ export function migrateLegacyTopLevelId(absPath: string, legacyId: string): void {
154
+ const raw = readFileSync(absPath, 'utf-8');
155
+ const withDeskwork = updateFrontmatter(raw, { deskwork: { id: legacyId } });
156
+ const withoutTopLevel = removeFrontmatterPaths(withDeskwork, [['id']]);
157
+ if (withoutTopLevel === raw) return;
158
+ writeFileSync(absPath, withoutTopLevel, 'utf-8');
159
+ }
160
+
161
+ const rule: DoctorRule = {
162
+ id: RULE_ID,
163
+ label: 'Frontmatter id at top level should be under `deskwork:` namespace',
164
+
165
+ async audit(ctx: DoctorContext): Promise<Finding[]> {
166
+ const contentDir = resolveContentDir(
167
+ ctx.projectRoot,
168
+ ctx.config,
169
+ ctx.site,
170
+ );
171
+ const calendarIds = new Set<string>();
172
+ for (const e of ctx.calendar.entries) {
173
+ if (e.id) calendarIds.add(e.id);
174
+ }
175
+
176
+ let files: string[];
177
+ try {
178
+ files = collectMarkdownFiles(contentDir);
179
+ } catch {
180
+ return [];
181
+ }
182
+
183
+ const findings: Finding[] = [];
184
+ for (const abs of files) {
185
+ const hit = classify(abs, calendarIds);
186
+ if (hit === null) continue;
187
+ findings.push({
188
+ ruleId: RULE_ID,
189
+ site: ctx.site,
190
+ severity: 'warning',
191
+ message:
192
+ `File ${relative(ctx.projectRoot, abs)} has a top-level \`id:\` ` +
193
+ `that should be migrated under \`deskwork.id\``,
194
+ details: {
195
+ absolutePath: abs,
196
+ legacyId: hit.legacyId,
197
+ },
198
+ });
199
+ }
200
+ return findings;
201
+ },
202
+
203
+ async plan(_ctx: DoctorContext, finding: Finding): Promise<RepairPlan> {
204
+ const absPath = String(finding.details.absolutePath ?? '');
205
+ const legacyId = String(finding.details.legacyId ?? '');
206
+ if (!absPath || !legacyId) {
207
+ return {
208
+ kind: 'report-only',
209
+ finding,
210
+ reason: 'finding missing absolutePath or legacyId — re-run audit',
211
+ };
212
+ }
213
+ return {
214
+ kind: 'apply',
215
+ finding,
216
+ summary:
217
+ `move top-level id ${legacyId} to deskwork.id in ${absPath}`,
218
+ payload: { absolutePath: absPath, legacyId },
219
+ };
220
+ },
221
+
222
+ async apply(ctx: DoctorContext, plan: RepairPlan): Promise<RepairResult> {
223
+ if (plan.kind !== 'apply') {
224
+ return {
225
+ finding: plan.finding,
226
+ applied: false,
227
+ message:
228
+ 'plan is not directly appliable; runner should resolve prompt first',
229
+ skipReason: 'apply-failed',
230
+ };
231
+ }
232
+ const absPath = String(plan.payload.absolutePath ?? '');
233
+ const legacyId = String(plan.payload.legacyId ?? '');
234
+ if (!absPath || !legacyId) {
235
+ return {
236
+ finding: plan.finding,
237
+ applied: false,
238
+ message: 'apply payload missing absolutePath or legacyId',
239
+ skipReason: 'apply-failed',
240
+ };
241
+ }
242
+ try {
243
+ migrateLegacyTopLevelId(absPath, legacyId);
244
+ } catch (err) {
245
+ const reason = err instanceof Error ? err.message : String(err);
246
+ return {
247
+ finding: plan.finding,
248
+ applied: false,
249
+ message: `failed to migrate frontmatter id: ${reason}`,
250
+ skipReason: 'apply-failed',
251
+ };
252
+ }
253
+ return {
254
+ finding: plan.finding,
255
+ applied: true,
256
+ message:
257
+ `migrated id ${legacyId} from top-level to deskwork.id in ` +
258
+ relative(ctx.projectRoot, absPath),
259
+ details: { absolutePath: absPath, legacyId },
260
+ };
261
+ },
262
+ };
263
+
264
+ export default rule;