harnery 0.6.0 → 0.7.0

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 (132) hide show
  1. package/README.md +16 -6
  2. package/dist/commander.d.ts +19 -0
  3. package/dist/commander.d.ts.map +1 -1
  4. package/dist/commander.js +2 -0
  5. package/dist/commands/agents.d.ts.map +1 -1
  6. package/dist/commands/agents.js +51 -4
  7. package/dist/commands/deinit.d.ts.map +1 -1
  8. package/dist/commands/deinit.js +4 -0
  9. package/dist/commands/devtools.d.ts +4 -0
  10. package/dist/commands/devtools.d.ts.map +1 -0
  11. package/dist/commands/devtools.js +239 -0
  12. package/dist/commands/docs.d.ts.map +1 -1
  13. package/dist/commands/docs.js +69 -1
  14. package/dist/commands/doctor.js +12 -4
  15. package/dist/commands/env.d.ts.map +1 -1
  16. package/dist/commands/env.js +3 -63
  17. package/dist/commands/init.d.ts +1 -0
  18. package/dist/commands/init.d.ts.map +1 -1
  19. package/dist/commands/init.js +54 -14
  20. package/dist/commands/scratch.js +1 -1
  21. package/dist/commands/tunnel.d.ts.map +1 -1
  22. package/dist/commands/tunnel.js +273 -62
  23. package/dist/commands/web-fetch.js +1 -1
  24. package/dist/core/agents/coord-client.d.ts.map +1 -1
  25. package/dist/core/agents/coord-client.js +32 -8
  26. package/dist/core/agents/events/emit.d.ts.map +1 -1
  27. package/dist/core/agents/events/emit.js +4 -0
  28. package/dist/core/agents/rules/claim-conflict.d.ts.map +1 -1
  29. package/dist/core/agents/rules/claim-conflict.js +16 -5
  30. package/dist/core/config.d.ts +10 -0
  31. package/dist/core/config.d.ts.map +1 -1
  32. package/dist/core/config.js +13 -0
  33. package/dist/core/hooks/cli.js +3 -3
  34. package/dist/core/hooks/effects/index.d.ts +11 -7
  35. package/dist/core/hooks/effects/index.d.ts.map +1 -1
  36. package/dist/core/hooks/effects/index.js +15 -18
  37. package/dist/core/hooks/events/emit.d.ts.map +1 -1
  38. package/dist/core/hooks/events/emit.js +4 -0
  39. package/dist/core/hooks/events/rotate.d.ts +43 -0
  40. package/dist/core/hooks/events/rotate.d.ts.map +1 -0
  41. package/dist/core/hooks/events/rotate.js +142 -0
  42. package/dist/core/hooks/harness/events.d.ts +11 -1
  43. package/dist/core/hooks/harness/events.d.ts.map +1 -1
  44. package/dist/core/hooks/harness/events.js +22 -3
  45. package/dist/core/hooks/harness/wiring.d.ts +8 -0
  46. package/dist/core/hooks/harness/wiring.d.ts.map +1 -1
  47. package/dist/core/hooks/harness/wiring.js +34 -5
  48. package/dist/core/scratch/index.d.ts.map +1 -0
  49. package/dist/{lib → core}/scratch/index.js +2 -2
  50. package/dist/lib/devtools.d.ts +178 -0
  51. package/dist/lib/devtools.d.ts.map +1 -0
  52. package/dist/lib/devtools.js +1328 -0
  53. package/dist/lib/docs-frontmatter-migrate.d.ts +33 -0
  54. package/dist/lib/docs-frontmatter-migrate.d.ts.map +1 -0
  55. package/dist/lib/docs-frontmatter-migrate.js +364 -0
  56. package/dist/lib/docs-frontmatter.d.ts +33 -0
  57. package/dist/lib/docs-frontmatter.d.ts.map +1 -0
  58. package/dist/lib/docs-frontmatter.js +130 -0
  59. package/dist/lib/docs-index.d.ts +1 -0
  60. package/dist/lib/docs-index.d.ts.map +1 -1
  61. package/dist/lib/docs-index.js +4 -5
  62. package/dist/lib/docs-lint.d.ts +2 -0
  63. package/dist/lib/docs-lint.d.ts.map +1 -1
  64. package/dist/lib/docs-lint.js +18 -12
  65. package/dist/lib/docs-meta.d.ts +14 -0
  66. package/dist/lib/docs-meta.d.ts.map +1 -0
  67. package/dist/lib/docs-meta.js +34 -0
  68. package/dist/lib/docs-sweep.d.ts +12 -0
  69. package/dist/lib/docs-sweep.d.ts.map +1 -1
  70. package/dist/lib/docs-sweep.js +98 -103
  71. package/dist/lib/format.js +2 -2
  72. package/dist/lib/http/index.d.ts +1 -0
  73. package/dist/lib/http/index.d.ts.map +1 -1
  74. package/dist/lib/http/index.js +1 -0
  75. package/dist/lib/http/request.d.ts +77 -0
  76. package/dist/lib/http/request.d.ts.map +1 -0
  77. package/dist/lib/http/request.js +105 -0
  78. package/dist/lib/instructions/apply.d.ts +63 -0
  79. package/dist/lib/instructions/apply.d.ts.map +1 -0
  80. package/dist/lib/instructions/apply.js +255 -0
  81. package/dist/lib/instructions/splice.d.ts +73 -0
  82. package/dist/lib/instructions/splice.d.ts.map +1 -0
  83. package/dist/lib/instructions/splice.js +118 -0
  84. package/dist/lib/instructions/templates.d.ts +45 -0
  85. package/dist/lib/instructions/templates.d.ts.map +1 -0
  86. package/dist/lib/instructions/templates.js +258 -0
  87. package/dist/lib/tunnel/gate.d.ts +1 -0
  88. package/dist/lib/tunnel/gate.d.ts.map +1 -1
  89. package/dist/lib/tunnel/gate.js +14 -9
  90. package/dist/lib/tunnel/state.d.ts +11 -1
  91. package/dist/lib/tunnel/state.d.ts.map +1 -1
  92. package/dist/lib/tunnel/state.js +8 -3
  93. package/package.json +7 -6
  94. package/src/commander.ts +23 -0
  95. package/src/commands/agents.ts +50 -3
  96. package/src/commands/deinit.ts +5 -0
  97. package/src/commands/devtools.ts +284 -0
  98. package/src/commands/docs.ts +81 -1
  99. package/src/commands/doctor.ts +13 -4
  100. package/src/commands/env.ts +11 -77
  101. package/src/commands/init.ts +66 -15
  102. package/src/commands/scratch.ts +1 -1
  103. package/src/commands/tunnel.ts +316 -65
  104. package/src/commands/web-fetch.ts +1 -1
  105. package/src/core/agents/coord-client.ts +34 -7
  106. package/src/core/agents/events/emit.ts +5 -0
  107. package/src/core/agents/rules/claim-conflict.ts +17 -6
  108. package/src/core/config.ts +14 -0
  109. package/src/core/hooks/cli.ts +3 -3
  110. package/src/core/hooks/effects/index.ts +23 -17
  111. package/src/core/hooks/events/emit.ts +5 -0
  112. package/src/core/hooks/events/rotate.ts +151 -0
  113. package/src/core/hooks/harness/events.ts +30 -3
  114. package/src/core/hooks/harness/wiring.ts +46 -5
  115. package/src/{lib → core}/scratch/index.ts +2 -2
  116. package/src/lib/devtools.ts +1653 -0
  117. package/src/lib/docs-frontmatter-migrate.ts +427 -0
  118. package/src/lib/docs-frontmatter.ts +151 -0
  119. package/src/lib/docs-index.ts +4 -5
  120. package/src/lib/docs-lint.ts +17 -11
  121. package/src/lib/docs-meta.ts +44 -0
  122. package/src/lib/docs-sweep.ts +104 -102
  123. package/src/lib/format.ts +2 -2
  124. package/src/lib/http/index.ts +1 -0
  125. package/src/lib/http/request.ts +154 -0
  126. package/src/lib/instructions/apply.ts +318 -0
  127. package/src/lib/instructions/splice.ts +148 -0
  128. package/src/lib/instructions/templates.ts +295 -0
  129. package/src/lib/tunnel/gate.ts +14 -9
  130. package/src/lib/tunnel/state.ts +19 -4
  131. package/dist/lib/scratch/index.d.ts.map +0 -1
  132. /package/dist/{lib → core}/scratch/index.d.ts +0 -0
@@ -0,0 +1,427 @@
1
+ import { existsSync, readdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
+ import { dirname, join, relative, resolve } from "node:path";
3
+ import { dump as dumpYaml, JSON_SCHEMA } from "js-yaml";
4
+ import { type DocKind, normalizeStatus, parseFrontmatter } from "./docs-frontmatter.ts";
5
+
6
+ let REPO_ROOT = "";
7
+ let SUBMODULES: readonly string[] = [];
8
+
9
+ export function initDocsMigrationContext(opts: {
10
+ repoRoot: string;
11
+ submodules: readonly string[];
12
+ }): void {
13
+ REPO_ROOT = opts.repoRoot;
14
+ SUBMODULES = opts.submodules;
15
+ }
16
+
17
+ export interface FrontmatterMigrationOpts {
18
+ repo?: string;
19
+ apply?: boolean;
20
+ }
21
+
22
+ export type FrontmatterMigrationStatus = "would-update" | "updated" | "skipped" | "error";
23
+
24
+ export interface FrontmatterMigrationRow {
25
+ repo: string;
26
+ path: string;
27
+ kind: DocKind;
28
+ status: FrontmatterMigrationStatus;
29
+ fields: string[];
30
+ message?: string;
31
+ }
32
+
33
+ export interface FrontmatterConversion {
34
+ status: "convert" | "skipped" | "error";
35
+ content?: string;
36
+ fields: string[];
37
+ message?: string;
38
+ }
39
+
40
+ interface ParsedBoldField {
41
+ index: number;
42
+ label: string;
43
+ value: string;
44
+ }
45
+
46
+ const FIELD_KEYS: Record<string, string> = {
47
+ status: "status",
48
+ date: "date",
49
+ "last updated": "last_updated",
50
+ "date identified": "date",
51
+ "date resolved": "resolved",
52
+ "date fixed": "resolved",
53
+ prerequisites: "prerequisites",
54
+ severity: "severity",
55
+ resolved: "resolved",
56
+ affected: "affected",
57
+ impact: "affected",
58
+ owner: "owner",
59
+ continues: "continues",
60
+ "what you're picking up": "synopsis",
61
+ };
62
+
63
+ const FIELD_ORDER = [
64
+ "status",
65
+ "date",
66
+ "last_updated",
67
+ "status_note",
68
+ "prerequisites",
69
+ "owner",
70
+ "severity",
71
+ "resolved",
72
+ "affected",
73
+ "continues",
74
+ "synopsis",
75
+ ];
76
+
77
+ function normalizeLabel(label: string): string {
78
+ return label.trim().toLowerCase();
79
+ }
80
+
81
+ function openingFields(body: string): ParsedBoldField[] {
82
+ const lines = body.split("\n");
83
+ const fields: ParsedBoldField[] = [];
84
+ for (let index = 0; index < Math.min(lines.length, 40); index++) {
85
+ const line = lines[index]!;
86
+ if (/^##\s+/.test(line) || (index > 0 && /^---\s*$/.test(line))) break;
87
+ const match =
88
+ line.match(/^\*\*([^*]+):\*\*\s*(.*)$/) ?? line.match(/^\*\*([^*]+)\*\*:\s*(.*)$/);
89
+ if (!match) continue;
90
+ const label = normalizeLabel(match[1]!);
91
+ if (!(label in FIELD_KEYS)) continue;
92
+ fields.push({ index, label, value: match[2]!.trim() });
93
+ }
94
+ return fields;
95
+ }
96
+
97
+ function cleanNote(value: string): string {
98
+ let note = value.trim().replace(/^[-–—\s]+/, "");
99
+ if (note.startsWith("(") && note.endsWith(")")) note = note.slice(1, -1);
100
+ return note
101
+ .replace(/\*\*/g, "")
102
+ .replace(/^[*_`]+|[*_`]+$/g, "")
103
+ .trim();
104
+ }
105
+
106
+ function cleanStatusToken(value: string): string {
107
+ return value.trim().replace(/^[*_`]+|[*_`]+$/g, "");
108
+ }
109
+
110
+ function splitStatus(
111
+ raw: string,
112
+ kind: DocKind,
113
+ ): { status: string; note?: string } | { error: string } {
114
+ const clean = raw.replace(/^[^\p{L}\p{N}]+/u, "").trim();
115
+ if (!clean) return { error: "empty status value" };
116
+
117
+ const separator = clean.match(/\s+[-–—]\s+|\s*\(/);
118
+ if (separator?.index != null) {
119
+ const token = cleanStatusToken(clean.slice(0, separator.index));
120
+ const normalized = normalizeStatus(token, kind);
121
+ if (normalized) {
122
+ const note = cleanNote(clean.slice(separator.index));
123
+ return { status: normalized, ...(note ? { note } : {}) };
124
+ }
125
+ }
126
+
127
+ const whole = normalizeStatus(cleanStatusToken(clean), kind);
128
+ if (whole) return { status: whole };
129
+
130
+ const words = clean.split(/\s+/);
131
+ for (const count of [1, 2, 3]) {
132
+ if (words.length <= count) continue;
133
+ const token = cleanStatusToken(words.slice(0, count).join(" "));
134
+ const normalized = normalizeStatus(token, kind);
135
+ if (!normalized) continue;
136
+ const note = cleanNote(words.slice(count).join(" "));
137
+ return { status: normalized, ...(note ? { note } : {}) };
138
+ }
139
+
140
+ return { error: `unsupported ${kind} status '${raw}'` };
141
+ }
142
+
143
+ function leadingDate(raw: string): { value: string; note?: string } {
144
+ const match = raw.match(/^(\d{4}-\d{2}-\d{2})(.*)$/);
145
+ if (!match) return { value: raw };
146
+ const note = cleanNote(match[2]!);
147
+ return { value: match[1]!, ...(note ? { note } : {}) };
148
+ }
149
+
150
+ function splitSeverity(
151
+ raw: string,
152
+ ): { severity: "low" | "medium" | "high" | "critical"; note?: string } | { error: string } {
153
+ const plain = raw.replace(/\*\*/g, "").trim();
154
+ const leading = plain.match(/^(low|medium|high|critical)\b(.*)$/i);
155
+ if (leading) {
156
+ const note = cleanNote(leading[2]!);
157
+ return {
158
+ severity: leading[1]!.toLowerCase() as "low" | "medium" | "high" | "critical",
159
+ ...(note ? { note } : {}),
160
+ };
161
+ }
162
+
163
+ const severity = /\bcritical\b/i.test(plain)
164
+ ? "critical"
165
+ : /\bhigh\b/i.test(plain)
166
+ ? "high"
167
+ : /\b(?:medium|moderate)\b/i.test(plain)
168
+ ? "medium"
169
+ : /\blow\b/i.test(plain)
170
+ ? "low"
171
+ : null;
172
+ if (!severity) return { error: `unsupported severity '${raw}'` };
173
+ return { severity, note: plain };
174
+ }
175
+
176
+ function serializeFields(fields: Record<string, unknown>): string {
177
+ return FIELD_ORDER.filter((key) => Object.hasOwn(fields, key))
178
+ .map((key) =>
179
+ dumpYaml(
180
+ { [key]: fields[key] },
181
+ {
182
+ schema: JSON_SCHEMA,
183
+ noRefs: true,
184
+ lineWidth: -1,
185
+ sortKeys: false,
186
+ },
187
+ ).trimEnd(),
188
+ )
189
+ .join("\n");
190
+ }
191
+
192
+ function mergeStatusNote(existing: unknown, notes: string[]): string | undefined {
193
+ const parts = [
194
+ ...(typeof existing === "string" && existing.trim() ? [existing.trim()] : []),
195
+ ...notes.filter(Boolean),
196
+ ];
197
+ return parts.length > 0 ? parts.join("; ") : undefined;
198
+ }
199
+
200
+ function valuesEqual(left: unknown, right: unknown): boolean {
201
+ return JSON.stringify(left) === JSON.stringify(right);
202
+ }
203
+
204
+ /**
205
+ * Convert one lifecycle document without writing it.
206
+ *
207
+ * Only recognized bold fields in the opening block are removed. Narrative
208
+ * labels and bold examples deeper in the body are left byte-for-byte intact.
209
+ */
210
+ export function convertLifecycleFrontmatter(content: string, kind: DocKind): FrontmatterConversion {
211
+ const parsed = parseFrontmatter(content);
212
+ const boldFields = openingFields(parsed.body);
213
+ const statusFields = boldFields.filter((field) => field.label === "status");
214
+ const hasYamlStatus = typeof parsed.data.status === "string" && parsed.data.status.trim();
215
+ if (statusFields.length === 0 && !hasYamlStatus) {
216
+ const variant = parsed.body
217
+ .split("\n")
218
+ .slice(0, 40)
219
+ .find((line) => /^\*\*Status/i.test(line));
220
+ return {
221
+ status: variant ? "error" : "skipped",
222
+ fields: [],
223
+ message: variant ? `unsupported bold status shape '${variant.trim()}'` : "no opening status",
224
+ };
225
+ }
226
+ if (statusFields.length === 0 && hasYamlStatus && boldFields.length === 0) {
227
+ return {
228
+ status: "skipped",
229
+ fields: [],
230
+ message: "already has YAML status",
231
+ };
232
+ }
233
+ if (statusFields.length > 1) {
234
+ return {
235
+ status: "error",
236
+ fields: [],
237
+ message: "multiple opening Status fields",
238
+ };
239
+ }
240
+
241
+ const migrated: Record<string, unknown> = {};
242
+ const notes: string[] = [];
243
+ const remove = new Set<number>();
244
+ const hasOpeningDate = boldFields.some((field) => field.label === "date");
245
+
246
+ for (const field of boldFields) {
247
+ const key = FIELD_KEYS[field.label]!;
248
+ let value: unknown = field.value;
249
+ if (key === "status") {
250
+ const split = splitStatus(field.value, kind);
251
+ if ("error" in split) return { status: "error", fields: [], message: split.error };
252
+ value = split.status;
253
+ if (
254
+ split.note &&
255
+ /^\d{4}-\d{2}-\d{2}$/.test(split.note) &&
256
+ kind === "plan" &&
257
+ split.status === "proposed" &&
258
+ !hasOpeningDate &&
259
+ !Object.hasOwn(parsed.data, "date")
260
+ ) {
261
+ migrated.date = split.note;
262
+ } else if (split.note) {
263
+ notes.push(split.note);
264
+ }
265
+ } else if (key === "date" || key === "last_updated" || key === "resolved") {
266
+ const date = leadingDate(field.value);
267
+ value = date.value;
268
+ if (date.note) notes.push(`${key}: ${date.note}`);
269
+ } else if (key === "prerequisites" && field.value.toLowerCase() === "none") {
270
+ value = [];
271
+ } else if (key === "severity") {
272
+ const severity = splitSeverity(field.value);
273
+ if ("error" in severity) {
274
+ return { status: "error", fields: [], message: severity.error };
275
+ }
276
+ value = severity.severity;
277
+ if (severity.note) notes.push(`severity: ${severity.note}`);
278
+ }
279
+
280
+ if (Object.hasOwn(migrated, key)) {
281
+ return {
282
+ status: "error",
283
+ fields: [],
284
+ message: `multiple opening '${field.label}' fields`,
285
+ };
286
+ }
287
+ if (Object.hasOwn(parsed.data, key)) {
288
+ if (!valuesEqual(parsed.data[key], value)) {
289
+ return {
290
+ status: "error",
291
+ fields: [],
292
+ message: `YAML '${key}' conflicts with bold '${field.label}'`,
293
+ };
294
+ }
295
+ } else {
296
+ migrated[key] = value;
297
+ }
298
+ remove.add(field.index);
299
+ }
300
+
301
+ const statusNote = mergeStatusNote(parsed.data.status_note, notes);
302
+ if (notes.length > 0 && Object.hasOwn(parsed.data, "status_note")) {
303
+ return {
304
+ status: "error",
305
+ fields: [],
306
+ message: "YAML 'status_note' conflicts with migrated metadata notes",
307
+ };
308
+ }
309
+ if (statusNote) {
310
+ migrated.status_note = statusNote;
311
+ }
312
+
313
+ const body = parsed.body
314
+ .split("\n")
315
+ .filter((_, index) => !remove.has(index))
316
+ .join("\n")
317
+ .replace(/^\n+/, "");
318
+
319
+ const newFields = serializeFields(migrated);
320
+ const yaml = parsed.raw ? `${parsed.raw.trimEnd()}\n${newFields}`.trim() : newFields;
321
+ const next = `---\n${yaml}\n---\n\n${body}`;
322
+
323
+ return {
324
+ status: "convert",
325
+ content: next,
326
+ fields: Object.keys(migrated),
327
+ };
328
+ }
329
+
330
+ function walkMarkdown(dir: string): string[] {
331
+ if (!existsSync(dir)) return [];
332
+ const files: string[] = [];
333
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
334
+ const path = join(dir, entry.name);
335
+ if (entry.isDirectory()) files.push(...walkMarkdown(path));
336
+ else if (entry.isFile() && entry.name.endsWith(".md") && entry.name !== "README.md") {
337
+ files.push(path);
338
+ }
339
+ }
340
+ return files;
341
+ }
342
+
343
+ function lifecycleFiles(repoPath: string): { path: string; kind: DocKind }[] {
344
+ const kinds: { dir: string; kind: DocKind }[] = [
345
+ { dir: "plans", kind: "plan" },
346
+ { dir: "issues", kind: "issue" },
347
+ { dir: "handoffs", kind: "handoff" },
348
+ ];
349
+ return kinds.flatMap(({ dir, kind }) =>
350
+ walkMarkdown(join(repoPath, "docs", dir)).map((path) => ({ path, kind })),
351
+ );
352
+ }
353
+
354
+ function isInitializedRepo(path: string): boolean {
355
+ return existsSync(join(path, ".git"));
356
+ }
357
+
358
+ function writeAtomic(path: string, content: string): void {
359
+ const temp = join(dirname(path), `.${Date.now()}-${process.pid}.frontmatter.tmp`);
360
+ writeFileSync(temp, content, "utf8");
361
+ renameSync(temp, path);
362
+ }
363
+
364
+ export function runFrontmatterMigration(opts: FrontmatterMigrationOpts): FrontmatterMigrationRow[] {
365
+ const targets = [
366
+ { name: "(root)", path: REPO_ROOT },
367
+ ...SUBMODULES.map((name) => ({ name, path: resolve(REPO_ROOT, name) })).filter((target) =>
368
+ isInitializedRepo(target.path),
369
+ ),
370
+ ];
371
+ const filter = opts.repo === "." ? "(root)" : opts.repo;
372
+ const selected = filter ? targets.filter((target) => target.name === filter) : targets;
373
+ if (filter && selected.length === 0) throw new Error(`Unknown repository: ${opts.repo}`);
374
+
375
+ const rows: FrontmatterMigrationRow[] = [];
376
+ const pending: { row: FrontmatterMigrationRow; path: string; content: string }[] = [];
377
+ for (const target of selected) {
378
+ for (const file of lifecycleFiles(target.path)) {
379
+ const displayPath = relative(REPO_ROOT, file.path);
380
+ let content: string;
381
+ try {
382
+ content = readFileSync(file.path, "utf8");
383
+ } catch {
384
+ rows.push({
385
+ repo: target.name,
386
+ path: displayPath,
387
+ kind: file.kind,
388
+ status: "error",
389
+ fields: [],
390
+ message: "unable to read file",
391
+ });
392
+ continue;
393
+ }
394
+
395
+ const conversion = convertLifecycleFrontmatter(content, file.kind);
396
+ if (conversion.status === "error" || conversion.status === "skipped") {
397
+ rows.push({
398
+ repo: target.name,
399
+ path: displayPath,
400
+ kind: file.kind,
401
+ status: conversion.status,
402
+ fields: conversion.fields,
403
+ message: conversion.message,
404
+ });
405
+ continue;
406
+ }
407
+
408
+ const row: FrontmatterMigrationRow = {
409
+ repo: target.name,
410
+ path: displayPath,
411
+ kind: file.kind,
412
+ status: "would-update",
413
+ fields: conversion.fields,
414
+ };
415
+ rows.push(row);
416
+ pending.push({ row, path: file.path, content: conversion.content! });
417
+ }
418
+ }
419
+
420
+ if (opts.apply && !rows.some((row) => row.status === "error")) {
421
+ for (const item of pending) {
422
+ writeAtomic(item.path, item.content);
423
+ item.row.status = "updated";
424
+ }
425
+ }
426
+ return rows.sort((a, b) => a.path.localeCompare(b.path));
427
+ }
@@ -0,0 +1,151 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { JSON_SCHEMA, load as loadYaml } from "js-yaml";
3
+
4
+ /**
5
+ * Shared YAML-frontmatter parsing + status reads for lifecycle docs
6
+ * (plans / issues / handoffs). Kept generic: no host-specific vocabulary, so
7
+ * it can live next to docs-sweep / docs-lint and ship in the published package.
8
+ */
9
+
10
+ export interface ParsedFrontmatter {
11
+ /** Parsed YAML mapping (empty object when there is no frontmatter). */
12
+ data: Record<string, unknown>;
13
+ /** Document body after the closing `---` (or the whole text when none). */
14
+ body: string;
15
+ /** Raw YAML block text, or null when the doc has no frontmatter. */
16
+ raw: string | null;
17
+ }
18
+
19
+ /** Doc lifecycle kinds that carry a status. */
20
+ export type DocKind = "plan" | "issue" | "handoff";
21
+
22
+ // Leading `---\n … \n---` block. Tolerates a BOM and CRLF line endings.
23
+ const FRONTMATTER_RE = /^?---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/;
24
+
25
+ /**
26
+ * Split leading YAML frontmatter from a markdown document.
27
+ * Never throws: malformed YAML yields an empty `data` with the block still
28
+ * stripped from `body`, so status readers do not need try/catch.
29
+ */
30
+ export function parseFrontmatter(text: string): ParsedFrontmatter {
31
+ const m = text.match(FRONTMATTER_RE);
32
+ if (!m) return { data: {}, body: text, raw: null };
33
+ let data: Record<string, unknown> = {};
34
+ try {
35
+ // JSON_SCHEMA keeps values predictable: `date: 2026-07-08` stays a string
36
+ // instead of becoming a Date (the default schema's timestamp type), and
37
+ // there are no YAML 1.1 bool surprises (`no`/`yes`/`on`).
38
+ const parsed = loadYaml(m[1]!, { schema: JSON_SCHEMA });
39
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
40
+ data = parsed as Record<string, unknown>;
41
+ }
42
+ } catch {
43
+ data = {};
44
+ }
45
+ return { data, body: text.slice(m[0].length), raw: m[1]! };
46
+ }
47
+
48
+ /**
49
+ * Kind-independent token normalization (spacing / casing / punctuation
50
+ * variants). Kind-specific collapses (done -> shipped vs resolved) are applied
51
+ * in `normalizeStatus`.
52
+ */
53
+ const GENERIC_NORMALIZE: Record<string, string> = {
54
+ in_progress: "in-progress",
55
+ inprogress: "in-progress",
56
+ "in progress": "in-progress",
57
+ "in-progress": "in-progress",
58
+ wip: "in-progress",
59
+ "wont-fix": "wontfix",
60
+ wontfix: "wontfix",
61
+ proposed: "proposed",
62
+ abandoned: "abandoned",
63
+ open: "open",
64
+ resolved: "resolved",
65
+ shipped: "shipped",
66
+ };
67
+
68
+ // "done"-family tokens collapse to different canonical values per kind.
69
+ const DONE_FAMILY = new Set(["done", "complete", "completed", "finished"]);
70
+ const KIND_NORMALIZE: Record<DocKind, Record<string, string>> = {
71
+ plan: {
72
+ planning: "proposed",
73
+ draft: "proposed",
74
+ approved: "proposed",
75
+ plan: "proposed",
76
+ open: "proposed",
77
+ deferred: "proposed",
78
+ implemented: "shipped",
79
+ resolved: "shipped",
80
+ fixed: "shipped",
81
+ archived: "shipped",
82
+ shelved: "abandoned",
83
+ },
84
+ issue: {
85
+ "in progress": "open",
86
+ in_progress: "open",
87
+ "in-progress": "open",
88
+ blocked: "open",
89
+ mitigated: "open",
90
+ fixed: "resolved",
91
+ shipped: "resolved",
92
+ },
93
+ handoff: {
94
+ "in progress": "open",
95
+ in_progress: "open",
96
+ "in-progress": "open",
97
+ blocked: "open",
98
+ fixed: "resolved",
99
+ shipped: "resolved",
100
+ },
101
+ };
102
+ const ALLOWED_BY_KIND: Record<DocKind, ReadonlySet<string>> = {
103
+ plan: new Set(["proposed", "in-progress", "shipped", "abandoned"]),
104
+ issue: new Set(["open", "resolved", "wontfix"]),
105
+ handoff: new Set(["open", "resolved", "abandoned"]),
106
+ };
107
+ const ALL_CANONICAL = new Set(Object.values(ALLOWED_BY_KIND).flatMap((values) => [...values]));
108
+
109
+ /**
110
+ * Normalize a raw status token to the canonical enum for its kind.
111
+ * Returns null when the token can't be mapped (caller may fail loud).
112
+ */
113
+ export function normalizeStatus(raw: string, kind?: DocKind): string | null {
114
+ const t = raw.trim().toLowerCase();
115
+ if (!t) return null;
116
+ if (DONE_FAMILY.has(t)) {
117
+ // plans ship; issues/handoffs resolve. Default to "shipped" when unknown.
118
+ return kind === "issue" || kind === "handoff" ? "resolved" : "shipped";
119
+ }
120
+ const normalized = (kind ? KIND_NORMALIZE[kind][t] : undefined) ?? GENERIC_NORMALIZE[t];
121
+ if (!normalized) return null;
122
+ const allowed = kind ? ALLOWED_BY_KIND[kind] : ALL_CANONICAL;
123
+ return allowed.has(normalized) ? normalized : null;
124
+ }
125
+
126
+ /** Read and normalize a doc's YAML lifecycle status, or null when absent/invalid. */
127
+ export function readDocStatus(filePath: string, kind?: DocKind): string | null {
128
+ let content: string;
129
+ try {
130
+ content = readFileSync(filePath, "utf8");
131
+ } catch {
132
+ return null;
133
+ }
134
+ return readDocStatusFromText(content, kind);
135
+ }
136
+
137
+ /** Same as {@link readDocStatus} but from an in-memory string (testable). */
138
+ export function readDocStatusFromText(content: string, kind?: DocKind): string | null {
139
+ const { data } = parseFrontmatter(content);
140
+ const yamlStatus = data.status;
141
+ if (typeof yamlStatus === "string" && yamlStatus.trim()) {
142
+ return normalizeStatus(yamlStatus, kind);
143
+ }
144
+ return null;
145
+ }
146
+
147
+ /** Whether a doc carries a non-empty status in leading YAML frontmatter. */
148
+ export function hasYamlStatus(content: string): boolean {
149
+ const { data } = parseFrontmatter(content);
150
+ return typeof data.status === "string" && data.status.trim().length > 0;
151
+ }
@@ -23,6 +23,7 @@ function isSubmoduleInitialized(name: string): boolean {
23
23
  import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
24
24
  import { join, relative } from "node:path";
25
25
  import { resolveBinName } from "../core/config.ts";
26
+ import { readDocStatusFromText } from "./docs-frontmatter.ts";
26
27
 
27
28
  /**
28
29
  * Regenerates index READMEs in docs/audits/ and docs/issues/ directories.
@@ -66,10 +67,8 @@ function extractTitle(content: string, fallbackSlug: string): string {
66
67
  return fallbackSlug.replace(/[-_]/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
67
68
  }
68
69
 
69
- function extractStatus(content: string): string | undefined {
70
- const head = content.split("\n").slice(0, 20).join("\n");
71
- const match = head.match(/\*\*Status:\*\*\s*([a-zA-Z][a-zA-Z-]*)/);
72
- return match ? match[1]!.toLowerCase() : undefined;
70
+ export function extractStatus(content: string): string | undefined {
71
+ return readDocStatusFromText(content, "issue") ?? undefined;
73
72
  }
74
73
 
75
74
  function readEntries(dir: string, includeStatus: boolean): DatedEntry[] {
@@ -138,7 +137,7 @@ function defaultPreamble(kind: "audits" | "issues"): string {
138
137
  "# Issues",
139
138
  "",
140
139
  "Date-stamped post-mortems and investigations. File names follow `YYYY-MM-DD_<slug>.md`.",
141
- "Each file carries a `**Status:**` line (open | resolved | wontfix).",
140
+ "Each file carries lifecycle status in leading YAML frontmatter.",
142
141
  "",
143
142
  regen,
144
143
  "",
@@ -1,5 +1,6 @@
1
1
  import { existsSync as __existsSyncForDocs } from "node:fs";
2
2
  import { resolve as __resolveForDocs } from "node:path";
3
+ import { hasYamlStatus } from "./docs-frontmatter.ts";
3
4
  import { sh } from "./exec.ts";
4
5
 
5
6
  // Module-level docs context, initialized by initDocsContext() before any
@@ -174,10 +175,13 @@ function isDeclaredMonolith(path: string): boolean {
174
175
  return /INTENTIONAL-MONOLITH/i.test(head);
175
176
  }
176
177
 
177
- /** Detect whether a file carries a Status line in its opening block */
178
- function hasStatusHeader(path: string): boolean {
179
- const head = readHead(path, 15);
180
- return /\*\*Status:\*\*/i.test(head);
178
+ /** Detect whether a file carries lifecycle status in leading YAML frontmatter. */
179
+ export function hasStatusHeader(path: string): boolean {
180
+ try {
181
+ return hasYamlStatus(readFileSync(path, "utf8"));
182
+ } catch {
183
+ return false;
184
+ }
181
185
  }
182
186
 
183
187
  // --- Individual checks ---
@@ -373,26 +377,28 @@ function checkChangelogNames(repoName: string, _repoPath: string, files: string[
373
377
  return violations;
374
378
  }
375
379
 
376
- /** Plans and issues must carry a Status header (content check, slow) */
380
+ /** Plans, issues, and handoffs must carry YAML lifecycle status (content check, slow). */
377
381
  function checkStatusHeaders(repoName: string, repoPath: string, files: string[]): Violation[] {
378
382
  const violations: Violation[] = [];
379
- const targetDirs = ["docs/plans/", "docs/issues/"];
383
+ const targetDirs = ["docs/plans/", "docs/issues/", "docs/handoffs/"];
380
384
  for (const rel of files) {
381
385
  const dirMatch = targetDirs.some((d) => rel.startsWith(d));
382
386
  if (!dirMatch) continue;
383
387
  const name = basename(rel);
384
388
  if (name === "README.md") continue;
385
- // Skip archive subdir
386
- if (rel.includes("/archive/")) continue;
387
389
  const full = join(repoPath, rel);
388
390
  if (!hasStatusHeader(full)) {
389
- const kind = rel.startsWith("docs/plans/") ? "plan" : "issue";
391
+ const kind = rel.startsWith("docs/plans/")
392
+ ? "plan"
393
+ : rel.startsWith("docs/issues/")
394
+ ? "issue"
395
+ : "handoff";
390
396
  violations.push({
391
- severity: "warning",
397
+ severity: "error",
392
398
  repo: repoName,
393
399
  path: join(repoName === "(root)" ? "" : repoName, rel),
394
400
  rule: "missing-status-header",
395
- message: `${kind} missing **Status:** line in opening block`,
401
+ message: `${kind} missing status in leading YAML frontmatter`,
396
402
  });
397
403
  }
398
404
  }