harnery 0.5.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.
- package/README.md +16 -6
- package/dist/commander.d.ts +29 -0
- package/dist/commander.d.ts.map +1 -1
- package/dist/commander.js +4 -0
- package/dist/commands/agents.d.ts.map +1 -1
- package/dist/commands/agents.js +94 -33
- package/dist/commands/browse-ai.js +1 -1
- package/dist/commands/browse.d.ts.map +1 -1
- package/dist/commands/browse.js +41 -9
- package/dist/commands/cookies.js +1 -1
- package/dist/commands/decision.d.ts +4 -0
- package/dist/commands/decision.d.ts.map +1 -0
- package/dist/commands/decision.js +354 -0
- package/dist/commands/deinit.d.ts.map +1 -1
- package/dist/commands/deinit.js +4 -0
- package/dist/commands/devtools.d.ts +4 -0
- package/dist/commands/devtools.d.ts.map +1 -0
- package/dist/commands/devtools.js +239 -0
- package/dist/commands/docs.d.ts.map +1 -1
- package/dist/commands/docs.js +74 -2
- package/dist/commands/doctor.js +12 -4
- package/dist/commands/env.d.ts.map +1 -1
- package/dist/commands/env.js +3 -63
- package/dist/commands/fetch.js +1 -1
- package/dist/commands/init.d.ts +1 -0
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +54 -14
- package/dist/commands/scratch.js +1 -1
- package/dist/commands/tunnel.d.ts.map +1 -1
- package/dist/commands/tunnel.js +273 -62
- package/dist/commands/web-fetch.js +1 -1
- package/dist/core/agents/coord-client.d.ts.map +1 -1
- package/dist/core/agents/coord-client.js +32 -8
- package/dist/core/agents/events/consume.d.ts +25 -2
- package/dist/core/agents/events/consume.d.ts.map +1 -1
- package/dist/core/agents/events/consume.js +55 -7
- package/dist/core/agents/events/emit.d.ts +2 -1
- package/dist/core/agents/events/emit.d.ts.map +1 -1
- package/dist/core/agents/events/emit.js +6 -1
- package/dist/core/agents/rules/claim-conflict.d.ts.map +1 -1
- package/dist/core/agents/rules/claim-conflict.js +16 -5
- package/dist/core/agents/state/scratch.d.ts +1 -1
- package/dist/core/agents/state/scratch.js +2 -2
- package/dist/core/config.d.ts +10 -0
- package/dist/core/config.d.ts.map +1 -1
- package/dist/core/config.js +13 -0
- package/dist/core/hooks/cli.js +3 -3
- package/dist/core/hooks/effects/index.d.ts +11 -7
- package/dist/core/hooks/effects/index.d.ts.map +1 -1
- package/dist/core/hooks/effects/index.js +15 -17
- package/dist/core/hooks/events/emit.d.ts.map +1 -1
- package/dist/core/hooks/events/emit.js +4 -0
- package/dist/core/hooks/events/rotate.d.ts +43 -0
- package/dist/core/hooks/events/rotate.d.ts.map +1 -0
- package/dist/core/hooks/events/rotate.js +142 -0
- package/dist/core/hooks/harness/events.d.ts +11 -1
- package/dist/core/hooks/harness/events.d.ts.map +1 -1
- package/dist/core/hooks/harness/events.js +22 -3
- package/dist/core/hooks/harness/wiring.d.ts +8 -0
- package/dist/core/hooks/harness/wiring.d.ts.map +1 -1
- package/dist/core/hooks/harness/wiring.js +34 -5
- package/dist/core/scratch/index.d.ts.map +1 -0
- package/dist/{lib → core}/scratch/index.js +2 -2
- package/dist/lib/agent-browser/client.js +1 -1
- package/dist/lib/browser/client.d.ts +14 -0
- package/dist/lib/browser/client.d.ts.map +1 -1
- package/dist/lib/browser/client.js +20 -0
- package/dist/lib/browser/index.d.ts +1 -0
- package/dist/lib/browser/index.d.ts.map +1 -1
- package/dist/lib/browser/runts.d.ts +44 -0
- package/dist/lib/browser/runts.d.ts.map +1 -0
- package/dist/lib/browser/runts.js +193 -0
- package/dist/lib/completion/walk.js +1 -1
- package/dist/lib/cookies/client.d.ts +1 -1
- package/dist/lib/cookies/client.d.ts.map +1 -1
- package/dist/lib/cookies/client.js +1 -1
- package/dist/lib/decision/index.d.ts +212 -0
- package/dist/lib/decision/index.d.ts.map +1 -0
- package/dist/lib/decision/index.js +523 -0
- package/dist/lib/devtools.d.ts +178 -0
- package/dist/lib/devtools.d.ts.map +1 -0
- package/dist/lib/devtools.js +1328 -0
- package/dist/lib/docs-frontmatter-migrate.d.ts +33 -0
- package/dist/lib/docs-frontmatter-migrate.d.ts.map +1 -0
- package/dist/lib/docs-frontmatter-migrate.js +364 -0
- package/dist/lib/docs-frontmatter.d.ts +33 -0
- package/dist/lib/docs-frontmatter.d.ts.map +1 -0
- package/dist/lib/docs-frontmatter.js +130 -0
- package/dist/lib/docs-index.d.ts +1 -0
- package/dist/lib/docs-index.d.ts.map +1 -1
- package/dist/lib/docs-index.js +4 -5
- package/dist/lib/docs-lint.d.ts +3 -0
- package/dist/lib/docs-lint.d.ts.map +1 -1
- package/dist/lib/docs-lint.js +67 -12
- package/dist/lib/docs-meta.d.ts +14 -0
- package/dist/lib/docs-meta.d.ts.map +1 -0
- package/dist/lib/docs-meta.js +34 -0
- package/dist/lib/docs-sweep.d.ts +12 -0
- package/dist/lib/docs-sweep.d.ts.map +1 -1
- package/dist/lib/docs-sweep.js +98 -103
- package/dist/lib/format.js +2 -2
- package/dist/lib/http/index.d.ts +1 -0
- package/dist/lib/http/index.d.ts.map +1 -1
- package/dist/lib/http/index.js +1 -0
- package/dist/lib/http/request.d.ts +77 -0
- package/dist/lib/http/request.d.ts.map +1 -0
- package/dist/lib/http/request.js +105 -0
- package/dist/lib/instructions/apply.d.ts +63 -0
- package/dist/lib/instructions/apply.d.ts.map +1 -0
- package/dist/lib/instructions/apply.js +255 -0
- package/dist/lib/instructions/splice.d.ts +73 -0
- package/dist/lib/instructions/splice.d.ts.map +1 -0
- package/dist/lib/instructions/splice.js +118 -0
- package/dist/lib/instructions/templates.d.ts +45 -0
- package/dist/lib/instructions/templates.d.ts.map +1 -0
- package/dist/lib/instructions/templates.js +258 -0
- package/dist/lib/tunnel/gate.d.ts +1 -0
- package/dist/lib/tunnel/gate.d.ts.map +1 -1
- package/dist/lib/tunnel/gate.js +15 -10
- package/dist/lib/tunnel/state.d.ts +11 -1
- package/dist/lib/tunnel/state.d.ts.map +1 -1
- package/dist/lib/tunnel/state.js +8 -3
- package/package.json +9 -6
- package/src/commander.ts +35 -0
- package/src/commands/agents.ts +97 -29
- package/src/commands/browse-ai.ts +1 -1
- package/src/commands/browse.ts +63 -8
- package/src/commands/cookies.ts +1 -1
- package/src/commands/decision.ts +438 -0
- package/src/commands/deinit.ts +5 -0
- package/src/commands/devtools.ts +284 -0
- package/src/commands/docs.ts +86 -2
- package/src/commands/doctor.ts +13 -4
- package/src/commands/env.ts +11 -77
- package/src/commands/fetch.ts +1 -1
- package/src/commands/init.ts +66 -15
- package/src/commands/scratch.ts +1 -1
- package/src/commands/tunnel.ts +316 -65
- package/src/commands/web-fetch.ts +1 -1
- package/src/core/agents/coord-client.ts +34 -7
- package/src/core/agents/events/consume.ts +65 -7
- package/src/core/agents/events/emit.ts +7 -1
- package/src/core/agents/rules/claim-conflict.ts +17 -6
- package/src/core/agents/state/scratch.ts +2 -2
- package/src/core/config.ts +15 -1
- package/src/core/hooks/cli.ts +3 -3
- package/src/core/hooks/effects/index.ts +23 -16
- package/src/core/hooks/events/emit.ts +5 -0
- package/src/core/hooks/events/rotate.ts +151 -0
- package/src/core/hooks/harness/events.ts +30 -3
- package/src/core/hooks/harness/wiring.ts +46 -5
- package/src/{lib → core}/scratch/index.ts +2 -2
- package/src/lib/agent-browser/client.ts +1 -1
- package/src/lib/browser/client.ts +28 -0
- package/src/lib/browser/index.ts +4 -0
- package/src/lib/browser/runts.ts +218 -0
- package/src/lib/completion/walk.ts +1 -1
- package/src/lib/cookies/client.ts +2 -2
- package/src/lib/decision/index.ts +685 -0
- package/src/lib/devtools.ts +1653 -0
- package/src/lib/docs-frontmatter-migrate.ts +427 -0
- package/src/lib/docs-frontmatter.ts +151 -0
- package/src/lib/docs-index.ts +4 -5
- package/src/lib/docs-lint.ts +61 -11
- package/src/lib/docs-meta.ts +44 -0
- package/src/lib/docs-sweep.ts +104 -102
- package/src/lib/format.ts +2 -2
- package/src/lib/http/index.ts +1 -0
- package/src/lib/http/request.ts +154 -0
- package/src/lib/instructions/apply.ts +318 -0
- package/src/lib/instructions/splice.ts +148 -0
- package/src/lib/instructions/templates.ts +295 -0
- package/src/lib/tunnel/gate.ts +15 -10
- package/src/lib/tunnel/state.ts +19 -4
- package/dist/lib/scratch/index.d.ts.map +0 -1
- /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
|
+
}
|
package/src/lib/docs-index.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
140
|
+
"Each file carries lifecycle status in leading YAML frontmatter.",
|
|
142
141
|
"",
|
|
143
142
|
regen,
|
|
144
143
|
"",
|