@loomhelm/multi-task-flow 0.2.6 → 0.2.7

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 (93) hide show
  1. package/README.md +3 -3
  2. package/package.json +1 -1
  3. package/toolkit/artifact-manifest.json +330 -26
  4. package/toolkit/package.json +1 -1
  5. package/toolkit/skills/docs-migrate/scripts/_runtime/compatibility_cli.ts +12 -0
  6. package/toolkit/skills/docs-migrate/scripts/_runtime/compatibility_docs.ts +115 -0
  7. package/toolkit/skills/docs-migrate/scripts/_runtime/compatibility_shared.ts +230 -0
  8. package/toolkit/skills/docs-migrate/scripts/_runtime/modules/checks/replay.ts +336 -0
  9. package/toolkit/skills/docs-migrate/scripts/_runtime/modules/docs/git.ts +54 -0
  10. package/toolkit/skills/docs-migrate/scripts/_runtime/modules/docs/hardware_ingest.ts +377 -0
  11. package/toolkit/skills/docs-migrate/scripts/_runtime/modules/docs/hardware_locks.ts +181 -0
  12. package/toolkit/skills/docs-migrate/scripts/_runtime/modules/docs/legacy_spec_id.ts +131 -0
  13. package/toolkit/skills/docs-migrate/scripts/_runtime/modules/docs/migration_common.ts +291 -0
  14. package/toolkit/skills/docs-migrate/scripts/_runtime/modules/docs/next_plan.ts +199 -0
  15. package/toolkit/skills/docs-migrate/scripts/_runtime/modules/docs/next_plan_support.ts +245 -0
  16. package/toolkit/skills/docs-migrate/scripts/_runtime/modules/docs/spec_contract.ts +299 -0
  17. package/toolkit/skills/docs-migrate/scripts/_runtime/modules/docs/spec_drift.ts +387 -0
  18. package/toolkit/skills/docs-migrate/scripts/_runtime/modules/docs/spec_drift_helpers.ts +381 -0
  19. package/toolkit/skills/docs-migrate/scripts/_runtime/modules/docs/spec_drift_legacy.ts +376 -0
  20. package/toolkit/skills/docs-migrate/scripts/_runtime/modules/docs/spec_drift_paths.ts +3 -0
  21. package/toolkit/skills/docs-migrate/scripts/_runtime/modules/docs/spec_drift_types.ts +27 -0
  22. package/toolkit/skills/docs-migrate/scripts/_runtime/modules/docs/spec_drift_validation.ts +251 -0
  23. package/toolkit/skills/docs-migrate/scripts/_runtime/modules/docs/spec_migration.ts +191 -0
  24. package/toolkit/skills/docs-migrate/scripts/gen_legacy_spec_id.ts +1 -1
  25. package/toolkit/skills/docs-migrate/scripts/specs_migrate_guard.ts +1 -1
  26. package/toolkit/skills/find-next-plan/scripts/_runtime/compatibility_cli.ts +12 -0
  27. package/toolkit/skills/find-next-plan/scripts/_runtime/compatibility_docs.ts +115 -0
  28. package/toolkit/skills/find-next-plan/scripts/_runtime/compatibility_shared.ts +230 -0
  29. package/toolkit/skills/find-next-plan/scripts/_runtime/modules/checks/replay.ts +336 -0
  30. package/toolkit/skills/find-next-plan/scripts/_runtime/modules/docs/git.ts +54 -0
  31. package/toolkit/skills/find-next-plan/scripts/_runtime/modules/docs/hardware_ingest.ts +377 -0
  32. package/toolkit/skills/find-next-plan/scripts/_runtime/modules/docs/hardware_locks.ts +181 -0
  33. package/toolkit/skills/find-next-plan/scripts/_runtime/modules/docs/legacy_spec_id.ts +131 -0
  34. package/toolkit/skills/find-next-plan/scripts/_runtime/modules/docs/migration_common.ts +291 -0
  35. package/toolkit/skills/find-next-plan/scripts/_runtime/modules/docs/next_plan.ts +199 -0
  36. package/toolkit/skills/find-next-plan/scripts/_runtime/modules/docs/next_plan_support.ts +245 -0
  37. package/toolkit/skills/find-next-plan/scripts/_runtime/modules/docs/spec_contract.ts +299 -0
  38. package/toolkit/skills/find-next-plan/scripts/_runtime/modules/docs/spec_drift.ts +387 -0
  39. package/toolkit/skills/find-next-plan/scripts/_runtime/modules/docs/spec_drift_helpers.ts +381 -0
  40. package/toolkit/skills/find-next-plan/scripts/_runtime/modules/docs/spec_drift_legacy.ts +376 -0
  41. package/toolkit/skills/find-next-plan/scripts/_runtime/modules/docs/spec_drift_paths.ts +3 -0
  42. package/toolkit/skills/find-next-plan/scripts/_runtime/modules/docs/spec_drift_types.ts +27 -0
  43. package/toolkit/skills/find-next-plan/scripts/_runtime/modules/docs/spec_drift_validation.ts +251 -0
  44. package/toolkit/skills/find-next-plan/scripts/_runtime/modules/docs/spec_migration.ts +191 -0
  45. package/toolkit/skills/find-next-plan/scripts/find_next_plan.ts +1 -1
  46. package/toolkit/skills/hardware-doc-ingest/scripts/_runtime/compatibility_cli.ts +12 -0
  47. package/toolkit/skills/hardware-doc-ingest/scripts/_runtime/compatibility_docs.ts +115 -0
  48. package/toolkit/skills/hardware-doc-ingest/scripts/_runtime/compatibility_shared.ts +230 -0
  49. package/toolkit/skills/hardware-doc-ingest/scripts/_runtime/modules/checks/replay.ts +336 -0
  50. package/toolkit/skills/hardware-doc-ingest/scripts/_runtime/modules/docs/git.ts +54 -0
  51. package/toolkit/skills/hardware-doc-ingest/scripts/_runtime/modules/docs/hardware_ingest.ts +377 -0
  52. package/toolkit/skills/hardware-doc-ingest/scripts/_runtime/modules/docs/hardware_locks.ts +181 -0
  53. package/toolkit/skills/hardware-doc-ingest/scripts/_runtime/modules/docs/legacy_spec_id.ts +131 -0
  54. package/toolkit/skills/hardware-doc-ingest/scripts/_runtime/modules/docs/migration_common.ts +291 -0
  55. package/toolkit/skills/hardware-doc-ingest/scripts/_runtime/modules/docs/next_plan.ts +199 -0
  56. package/toolkit/skills/hardware-doc-ingest/scripts/_runtime/modules/docs/next_plan_support.ts +245 -0
  57. package/toolkit/skills/hardware-doc-ingest/scripts/_runtime/modules/docs/spec_contract.ts +299 -0
  58. package/toolkit/skills/hardware-doc-ingest/scripts/_runtime/modules/docs/spec_drift.ts +387 -0
  59. package/toolkit/skills/hardware-doc-ingest/scripts/_runtime/modules/docs/spec_drift_helpers.ts +381 -0
  60. package/toolkit/skills/hardware-doc-ingest/scripts/_runtime/modules/docs/spec_drift_legacy.ts +376 -0
  61. package/toolkit/skills/hardware-doc-ingest/scripts/_runtime/modules/docs/spec_drift_paths.ts +3 -0
  62. package/toolkit/skills/hardware-doc-ingest/scripts/_runtime/modules/docs/spec_drift_types.ts +27 -0
  63. package/toolkit/skills/hardware-doc-ingest/scripts/_runtime/modules/docs/spec_drift_validation.ts +251 -0
  64. package/toolkit/skills/hardware-doc-ingest/scripts/_runtime/modules/docs/spec_migration.ts +191 -0
  65. package/toolkit/skills/hardware-doc-ingest/scripts/ingest_mineru_extract.ts +1 -1
  66. package/toolkit/skills/hardware-doc-ingest/scripts/verify_doc_locks.ts +1 -1
  67. package/toolkit/skills/multi-task-flow/dist/app.js +66 -66
  68. package/toolkit/skills/multi-task-flow/lib/view.ts +6 -2
  69. package/toolkit/skills/multi-task-flow/web/app.ts +2 -2
  70. package/toolkit/skills/multi-task-flow/web/graph-layout.ts +3 -3
  71. package/toolkit/skills/source-manifest.json +315 -11
  72. package/toolkit/skills/spec-sync/SKILL.md +1 -1
  73. package/toolkit/skills/spec-sync/scripts/_runtime/compatibility_cli.ts +12 -0
  74. package/toolkit/skills/spec-sync/scripts/_runtime/compatibility_docs.ts +115 -0
  75. package/toolkit/skills/spec-sync/scripts/_runtime/compatibility_shared.ts +230 -0
  76. package/toolkit/skills/spec-sync/scripts/_runtime/modules/checks/replay.ts +336 -0
  77. package/toolkit/skills/spec-sync/scripts/_runtime/modules/docs/git.ts +54 -0
  78. package/toolkit/skills/spec-sync/scripts/_runtime/modules/docs/hardware_ingest.ts +377 -0
  79. package/toolkit/skills/spec-sync/scripts/_runtime/modules/docs/hardware_locks.ts +181 -0
  80. package/toolkit/skills/spec-sync/scripts/_runtime/modules/docs/legacy_spec_id.ts +131 -0
  81. package/toolkit/skills/spec-sync/scripts/_runtime/modules/docs/migration_common.ts +291 -0
  82. package/toolkit/skills/spec-sync/scripts/_runtime/modules/docs/next_plan.ts +199 -0
  83. package/toolkit/skills/spec-sync/scripts/_runtime/modules/docs/next_plan_support.ts +245 -0
  84. package/toolkit/skills/spec-sync/scripts/_runtime/modules/docs/spec_contract.ts +299 -0
  85. package/toolkit/skills/spec-sync/scripts/_runtime/modules/docs/spec_drift.ts +387 -0
  86. package/toolkit/skills/spec-sync/scripts/_runtime/modules/docs/spec_drift_helpers.ts +381 -0
  87. package/toolkit/skills/spec-sync/scripts/_runtime/modules/docs/spec_drift_legacy.ts +376 -0
  88. package/toolkit/skills/spec-sync/scripts/_runtime/modules/docs/spec_drift_paths.ts +3 -0
  89. package/toolkit/skills/spec-sync/scripts/_runtime/modules/docs/spec_drift_types.ts +27 -0
  90. package/toolkit/skills/spec-sync/scripts/_runtime/modules/docs/spec_drift_validation.ts +251 -0
  91. package/toolkit/skills/spec-sync/scripts/_runtime/modules/docs/spec_migration.ts +191 -0
  92. package/toolkit/skills/spec-sync/scripts/spec_drift_check.ts +1 -1
  93. package/toolkit/skills/support-manifest.json +77 -1
@@ -0,0 +1,377 @@
1
+ import { createHash } from "node:crypto";
2
+ import {
3
+ chmodSync,
4
+ cpSync,
5
+ existsSync,
6
+ lstatSync,
7
+ mkdirSync,
8
+ readdirSync,
9
+ readFileSync,
10
+ rmSync,
11
+ statSync,
12
+ writeFileSync,
13
+ } from "node:fs";
14
+ import { dirname, join, resolve } from "node:path";
15
+
16
+ function lstatOrUndefined(path: string) {
17
+ try {
18
+ return lstatSync(path);
19
+ } catch (error) {
20
+ if (
21
+ error instanceof Error &&
22
+ "code" in error &&
23
+ (error as NodeJS.ErrnoException).code === "ENOENT"
24
+ )
25
+ return undefined;
26
+ throw error;
27
+ }
28
+ }
29
+
30
+ export interface IngestOptions {
31
+ extractPath: string;
32
+ destDir: string;
33
+ slug: string;
34
+ overwrite?: boolean;
35
+ docSubdir?: string;
36
+ sourceUrl?: string;
37
+ docType?: string;
38
+ vendor?: string;
39
+ part?: string;
40
+ addHeader?: boolean;
41
+ keepMineruJson?: boolean;
42
+ writeLock?: boolean;
43
+ lockGenerated?: boolean;
44
+ createReadme?: boolean;
45
+ }
46
+
47
+ export interface IngestResult {
48
+ pdf: string;
49
+ markdown: string;
50
+ lock?: string;
51
+ notes?: string;
52
+ images?: string;
53
+ metadata?: string;
54
+ lines: string[];
55
+ }
56
+
57
+ function requireSingleOriginPdf(root: string): string {
58
+ const matches = readdirSync(root)
59
+ .filter((name) => name.endsWith("_origin.pdf"))
60
+ .sort();
61
+ if (matches.length === 0)
62
+ throw new Error(`No files matched '*_origin.pdf' under ${root}`);
63
+ if (matches.length > 1)
64
+ throw new Error(
65
+ `Multiple files matched '*_origin.pdf' under ${root}: ${matches.join(", ")}`,
66
+ );
67
+ const path = join(root, matches[0] as string);
68
+ assertSafeSourceEntry(path, "origin PDF");
69
+ return path;
70
+ }
71
+
72
+ function assertSafeSourceEntry(path: string, label: string): void {
73
+ const stat = lstatSync(path);
74
+ if (stat.isSymbolicLink() || stat.nlink > 1)
75
+ throw new Error(`${label} must not be a symlink or hardlink: ${path}`);
76
+ if (!stat.isFile())
77
+ throw new Error(`${label} must be a regular file: ${path}`);
78
+ }
79
+
80
+ function assertSafeSourceTree(root: string): void {
81
+ const stat = lstatSync(root);
82
+ if (stat.isSymbolicLink() || !stat.isDirectory())
83
+ throw new Error(`extract_path must be a real directory: ${root}`);
84
+ for (const entry of readdirSync(root)) {
85
+ const path = join(root, entry);
86
+ const child = lstatSync(path);
87
+ if (child.isSymbolicLink() || (!child.isDirectory() && child.nlink > 1))
88
+ throw new Error(`extract tree contains an unsafe link: ${path}`);
89
+ if (child.isDirectory()) assertSafeSourceTree(path);
90
+ else if (!child.isFile())
91
+ throw new Error(`extract tree entry is not a file: ${path}`);
92
+ }
93
+ }
94
+
95
+ function trustedSystemLinks(): Set<string> {
96
+ return process.platform === "darwin"
97
+ ? new Set(["/etc", "/tmp", "/var"])
98
+ : process.platform === "linux"
99
+ ? new Set(["/bin", "/lib", "/lib64", "/sbin"])
100
+ : new Set<string>();
101
+ }
102
+
103
+ function assertNoUnsafeSymlink(path: string, label: string): void {
104
+ const trustedLinks = trustedSystemLinks();
105
+ let current = resolve(path);
106
+ while (true) {
107
+ if (
108
+ lstatOrUndefined(current)?.isSymbolicLink() &&
109
+ !trustedLinks.has(current)
110
+ )
111
+ throw new Error(`${label} contains a symlink: ${current}`);
112
+ const parent = dirname(current);
113
+ if (parent === current) return;
114
+ current = parent;
115
+ }
116
+ }
117
+
118
+ function assertSafeDestinationPath(path: string): void {
119
+ assertNoUnsafeSymlink(path, "destination path");
120
+ }
121
+
122
+ function safeRelative(value: string, field: string, nested: boolean): string {
123
+ if (
124
+ !value ||
125
+ value.includes("\0") ||
126
+ value.startsWith("/") ||
127
+ value.includes("\\") ||
128
+ /^[A-Za-z]:/.test(value)
129
+ )
130
+ throw new Error(`${field} must be a relative path`);
131
+ const parts = value.split("/");
132
+ if (!nested && parts.length !== 1)
133
+ throw new Error(`${field} must be a single path component`);
134
+ if (parts.some((part) => !part || part === "." || part === ".."))
135
+ throw new Error(`${field} contains an unsafe path component`);
136
+ return parts.join("/");
137
+ }
138
+
139
+ function replaceExisting(
140
+ path: string,
141
+ overwrite: boolean,
142
+ directory = false,
143
+ ): void {
144
+ const existing = lstatOrUndefined(path);
145
+ if (!existing) return;
146
+ if (
147
+ existing.isSymbolicLink() ||
148
+ (!existing.isDirectory() && existing.nlink > 1)
149
+ )
150
+ throw new Error(
151
+ `Refusing to operate on an unsafe destination entry: ${path}`,
152
+ );
153
+ if (!overwrite)
154
+ throw new Error(
155
+ `Refusing to overwrite existing ${directory ? "directory" : "file"}: ${path}`,
156
+ );
157
+ rmSync(path, { recursive: directory, force: true });
158
+ }
159
+
160
+ function copyFile(
161
+ source: string,
162
+ destination: string,
163
+ overwrite: boolean,
164
+ ): void {
165
+ assertSafeDestinationPath(destination);
166
+ replaceExisting(destination, overwrite);
167
+ mkdirSync(resolve(destination, ".."), { recursive: true });
168
+ cpSync(source, destination, { preserveTimestamps: true });
169
+ }
170
+
171
+ function copyTree(
172
+ source: string,
173
+ destination: string,
174
+ overwrite: boolean,
175
+ ): void {
176
+ assertSafeDestinationPath(destination);
177
+ replaceExisting(destination, overwrite, true);
178
+ mkdirSync(resolve(destination, ".."), { recursive: true });
179
+ cpSync(source, destination, { recursive: true, preserveTimestamps: true });
180
+ }
181
+
182
+ export function rewriteImagePaths(markdown: string, slug: string): string {
183
+ const prefix = `${slug}.assets/images/`;
184
+ return markdown
185
+ .replaceAll("(./images/", `(${prefix}`)
186
+ .replaceAll("(images/", `(${prefix}`)
187
+ .replaceAll('src="./images/', `src="${prefix}`)
188
+ .replaceAll("src='./images/", `src='${prefix}`)
189
+ .replaceAll('src="images/', `src="${prefix}`)
190
+ .replaceAll("src='images/", `src='${prefix}`);
191
+ }
192
+
193
+ export function buildHeader(options: IngestOptions): string {
194
+ const lines: string[] = [];
195
+ for (const [label, value] of [
196
+ ["Type", options.docType],
197
+ ["Vendor", options.vendor],
198
+ ["Part", options.part],
199
+ ["Source", options.sourceUrl],
200
+ ] as const) {
201
+ if (value) lines.push(`> ${label}: ${value}`);
202
+ }
203
+ lines.push(`> PDF: \`${options.slug}.pdf\``);
204
+ return lines.length === 0 ? "" : `${lines.join("\n")}\n\n`;
205
+ }
206
+
207
+ export function buildNotesReadme(options: IngestOptions): string {
208
+ const title =
209
+ [options.vendor, options.part, options.docType].filter(Boolean).join(" ") ||
210
+ options.slug;
211
+ const lines = [
212
+ `# ${title}`,
213
+ "",
214
+ "This folder contains a **generated** Markdown mirror of the vendor document:",
215
+ "",
216
+ `- Generated Markdown (**DO NOT EDIT**): \`${options.slug}.md\``,
217
+ `- Source PDF (fidelity): \`${options.slug}.pdf\``,
218
+ ];
219
+ if (options.sourceUrl) lines.push(`- Source URL: ${options.sourceUrl}`);
220
+ lines.push(
221
+ "",
222
+ "## Notes (editable)",
223
+ "",
224
+ "- Summary:",
225
+ "- Key specs:",
226
+ "- Project decisions / remarks:",
227
+ "",
228
+ );
229
+ return lines.join("\n");
230
+ }
231
+
232
+ function sha256(path: string): string {
233
+ return createHash("sha256").update(readFileSync(path)).digest("hex");
234
+ }
235
+
236
+ function buildLock(
237
+ options: IngestOptions,
238
+ markdown: string,
239
+ pdf: string,
240
+ ): string {
241
+ return `${JSON.stringify(
242
+ {
243
+ artifacts: {
244
+ md: { path: `${options.slug}.md`, sha256: sha256(markdown) },
245
+ pdf: { path: `${options.slug}.pdf`, sha256: sha256(pdf) },
246
+ },
247
+ generated_at_utc: new Date().toISOString().replace(/\.\d{3}Z$/, "+00:00"),
248
+ generator: { tool: "ingest_mineru_extract.ts" },
249
+ schema: "codex.hardware-doc-ingest.lock.v1",
250
+ slug: options.slug,
251
+ source: {
252
+ doc_type: options.docType ?? null,
253
+ part: options.part ?? null,
254
+ url: options.sourceUrl ?? null,
255
+ vendor: options.vendor ?? null,
256
+ },
257
+ },
258
+ null,
259
+ 2,
260
+ )}\n`;
261
+ }
262
+
263
+ export function ingestExtract(input: IngestOptions): IngestResult {
264
+ const options = {
265
+ overwrite: false,
266
+ addHeader: false,
267
+ keepMineruJson: false,
268
+ writeLock: true,
269
+ lockGenerated: true,
270
+ createReadme: true,
271
+ ...input,
272
+ };
273
+ const extract = resolve(options.extractPath);
274
+ const destination = resolve(options.destDir);
275
+ if (!existsSync(extract) || !statSync(extract).isDirectory())
276
+ throw new Error(
277
+ `extract_path does not exist or is not a directory: ${extract}`,
278
+ );
279
+ assertNoUnsafeSymlink(extract, "extract path");
280
+ assertSafeSourceTree(extract);
281
+ assertSafeDestinationPath(destination);
282
+ const slug = safeRelative(options.slug, "slug", false);
283
+ const docSubdir = options.docSubdir
284
+ ? safeRelative(options.docSubdir, "doc-subdir", true)
285
+ : undefined;
286
+ options.slug = slug;
287
+ if (docSubdir === undefined) delete options.docSubdir;
288
+ else options.docSubdir = docSubdir;
289
+ const fullMarkdown = join(extract, "full.md");
290
+ if (!existsSync(fullMarkdown))
291
+ throw new Error(`Missing full.md under ${extract}`);
292
+ assertSafeSourceEntry(fullMarkdown, "full.md");
293
+ const originPdf = requireSingleOriginPdf(extract);
294
+ const root = docSubdir ? join(destination, docSubdir) : destination;
295
+ const markdown = join(root, `${slug}.md`);
296
+ const pdf = join(root, `${slug}.pdf`);
297
+ const assets = join(root, `${slug}.assets`);
298
+ const notes = join(root, docSubdir ? "README.md" : `${slug}.README.md`);
299
+ const lock = join(root, `${slug}.lock.json`);
300
+ assertSafeDestinationPath(root);
301
+
302
+ copyFile(originPdf, pdf, options.overwrite);
303
+ let content = rewriteImagePaths(readFileSync(fullMarkdown, "utf8"), slug);
304
+ if (options.addHeader) content = buildHeader(options) + content;
305
+ replaceExisting(markdown, options.overwrite);
306
+ mkdirSync(resolve(markdown, ".."), { recursive: true });
307
+ writeFileSync(markdown, content, "utf8");
308
+ const images = join(extract, "images");
309
+ const destinationImages = join(assets, "images");
310
+ if (existsSync(images) && statSync(images).isDirectory())
311
+ copyTree(images, destinationImages, options.overwrite);
312
+ let metadata: string | undefined;
313
+ if (options.keepMineruJson) {
314
+ metadata = join(assets, "mineru");
315
+ assertSafeDestinationPath(metadata);
316
+ mkdirSync(metadata, { recursive: true });
317
+ for (const entry of readdirSync(extract)) {
318
+ if (entry.endsWith(".json")) {
319
+ assertSafeSourceEntry(join(extract, entry), `metadata ${entry}`);
320
+ copyFile(
321
+ join(extract, entry),
322
+ join(metadata, entry),
323
+ options.overwrite,
324
+ );
325
+ }
326
+ }
327
+ }
328
+ let notesPath: string | undefined;
329
+ if (options.createReadme) {
330
+ const existingNotes = lstatOrUndefined(notes);
331
+ if (
332
+ existingNotes?.isSymbolicLink() ||
333
+ (existingNotes && existingNotes.nlink > 1)
334
+ )
335
+ throw new Error(
336
+ `Refusing to operate on an unsafe destination entry: ${notes}`,
337
+ );
338
+ if (!existingNotes) {
339
+ assertSafeDestinationPath(notes);
340
+ mkdirSync(resolve(notes, ".."), { recursive: true });
341
+ writeFileSync(notes, buildNotesReadme(options), "utf8");
342
+ }
343
+ if (lstatOrUndefined(notes)) notesPath = notes;
344
+ }
345
+ let lockPath: string | undefined;
346
+ if (options.writeLock) {
347
+ lockPath = lock;
348
+ replaceExisting(lock, options.overwrite);
349
+ writeFileSync(lock, buildLock(options, markdown, pdf), "utf8");
350
+ }
351
+ if (options.lockGenerated) {
352
+ try {
353
+ chmodSync(markdown, 0o444);
354
+ chmodSync(pdf, 0o444);
355
+ } catch {
356
+ /* best effort */
357
+ }
358
+ }
359
+ const lines = [
360
+ `Staged mineru extract:`,
361
+ `- PDF: ${pdf}`,
362
+ `- MD: ${markdown}`,
363
+ ];
364
+ if (lockPath) lines.push(`- LOCK: ${lockPath}`);
365
+ if (notesPath) lines.push(`- NOTES: ${notesPath}`);
366
+ if (existsSync(destinationImages)) lines.push(`- IMG: ${destinationImages}`);
367
+ if (metadata) lines.push(`- META: ${metadata}`);
368
+ return {
369
+ pdf,
370
+ markdown,
371
+ ...(lockPath ? { lock: lockPath } : {}),
372
+ ...(notesPath ? { notes: notesPath } : {}),
373
+ ...(existsSync(destinationImages) ? { images: destinationImages } : {}),
374
+ ...(metadata ? { metadata } : {}),
375
+ lines,
376
+ };
377
+ }
@@ -0,0 +1,181 @@
1
+ import {
2
+ accessSync,
3
+ constants,
4
+ readdirSync,
5
+ readFileSync,
6
+ statSync,
7
+ } from "node:fs";
8
+ import { basename, dirname, join, resolve } from "node:path";
9
+ import { createHash } from "node:crypto";
10
+
11
+ export const LOCK_SCHEMA = "codex.hardware-doc-ingest.lock.v1";
12
+
13
+ interface JsonObject {
14
+ [key: string]: unknown;
15
+ }
16
+
17
+ export interface LockCheckOptions {
18
+ strictPdf?: boolean;
19
+ checkPerms?: boolean;
20
+ }
21
+
22
+ export interface LockCheckResult {
23
+ exitCode: number;
24
+ docs: number;
25
+ errors: number;
26
+ warnings: number;
27
+ lines: string[];
28
+ }
29
+
30
+ function object(value: unknown): JsonObject {
31
+ return value !== null && typeof value === "object" && !Array.isArray(value)
32
+ ? (value as JsonObject)
33
+ : {};
34
+ }
35
+
36
+ function sha256(path: string): string {
37
+ return createHash("sha256").update(readFileSync(path)).digest("hex");
38
+ }
39
+
40
+ function writable(path: string): boolean {
41
+ try {
42
+ accessSync(path, constants.W_OK);
43
+ return true;
44
+ } catch {
45
+ return false;
46
+ }
47
+ }
48
+
49
+ function lockFiles(root: string): string[] {
50
+ const found: string[] = [];
51
+ function visit(directory: string): void {
52
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
53
+ const path = join(directory, entry.name);
54
+ if (entry.isDirectory() && !entry.name.endsWith(".assets")) visit(path);
55
+ else if (entry.isFile() && entry.name.endsWith(".lock.json"))
56
+ found.push(path);
57
+ }
58
+ }
59
+ visit(root);
60
+ return found.sort();
61
+ }
62
+
63
+ function verifyLock(
64
+ path: string,
65
+ options: Required<LockCheckOptions>,
66
+ ): { errors: number; warnings: number; docs: number; lines: string[] } {
67
+ const lines: string[] = [];
68
+ let data: JsonObject;
69
+ try {
70
+ data = object(JSON.parse(readFileSync(path, "utf8")));
71
+ } catch (error) {
72
+ lines.push(`[ERROR] ${path}: invalid JSON (${String(error)})`);
73
+ return { errors: 1, warnings: 0, docs: 0, lines };
74
+ }
75
+ let errors = 0;
76
+ let warnings = 0;
77
+ if (data.schema !== LOCK_SCHEMA) {
78
+ lines.push(
79
+ `[WARN] ${path}: unknown schema ${JSON.stringify(data.schema)} (expected ${JSON.stringify(LOCK_SCHEMA)})`,
80
+ );
81
+ warnings += 1;
82
+ }
83
+ const artifacts = object(data.artifacts);
84
+ const md = object(artifacts.md);
85
+ const pdf = object(artifacts.pdf);
86
+ const mdRel = typeof md.path === "string" ? md.path : "";
87
+ const pdfRel = typeof pdf.path === "string" ? pdf.path : "";
88
+ const mdExpected = typeof md.sha256 === "string" ? md.sha256 : "";
89
+ const pdfExpected = typeof pdf.sha256 === "string" ? pdf.sha256 : "";
90
+ if (!mdRel || !mdExpected) {
91
+ lines.push(`[ERROR] ${path}: missing artifacts.md fields`);
92
+ return { errors: 1, warnings, docs: 0, lines };
93
+ }
94
+ const checkArtifact = (
95
+ relative: string,
96
+ expected: string,
97
+ kind: "Markdown" | "PDF",
98
+ strictMissing: boolean,
99
+ ): void => {
100
+ const artifact = resolve(dirname(path), relative);
101
+ if (!lstatSafe(artifact)) {
102
+ const prefix = strictMissing ? "[ERROR]" : "[WARN]";
103
+ const label = kind === "Markdown" ? "generated Markdown" : "source PDF";
104
+ lines.push(`${prefix} ${path}: missing ${label}: ${relative}`);
105
+ if (strictMissing) errors += 1;
106
+ else warnings += 1;
107
+ return;
108
+ }
109
+ const actual = sha256(artifact);
110
+ if (actual !== expected) {
111
+ lines.push(`[ERROR] ${path}: ${kind} SHA mismatch: ${relative}`);
112
+ errors += 1;
113
+ } else if (options.checkPerms && writable(artifact)) {
114
+ lines.push(
115
+ `[WARN] ${path}: ${kind} is writable (expected read-only): ${relative}`,
116
+ );
117
+ warnings += 1;
118
+ }
119
+ };
120
+ checkArtifact(mdRel, mdExpected, "Markdown", true);
121
+ if (pdfRel && pdfExpected)
122
+ checkArtifact(pdfRel, pdfExpected, "PDF", options.strictPdf);
123
+ return { errors, warnings, docs: 1, lines };
124
+ }
125
+
126
+ function lstatSafe(path: string): boolean {
127
+ try {
128
+ return statSync(path).isFile();
129
+ } catch {
130
+ return false;
131
+ }
132
+ }
133
+
134
+ export function verifyDocLocks(
135
+ rootInput = ".",
136
+ options: LockCheckOptions = {},
137
+ ): LockCheckResult {
138
+ const root = resolve(rootInput);
139
+ const lines: string[] = [];
140
+ if (!lstatSafe(root) && !directoryExists(root))
141
+ return {
142
+ exitCode: 2,
143
+ docs: 0,
144
+ errors: 1,
145
+ warnings: 0,
146
+ lines: [`[ERROR] root does not exist: ${rootInput}`],
147
+ };
148
+ const locks = lockFiles(root);
149
+ if (locks.length === 0)
150
+ return {
151
+ exitCode: 0,
152
+ docs: 0,
153
+ errors: 0,
154
+ warnings: 0,
155
+ lines: [`[INFO] No *.lock.json found under ${rootInput}`],
156
+ };
157
+ let errors = 0;
158
+ let warnings = 0;
159
+ let docs = 0;
160
+ const normalized = {
161
+ strictPdf: options.strictPdf ?? false,
162
+ checkPerms: options.checkPerms ?? true,
163
+ };
164
+ for (const lock of locks) {
165
+ const result = verifyLock(lock, normalized);
166
+ errors += result.errors;
167
+ warnings += result.warnings;
168
+ docs += result.docs;
169
+ lines.push(...result.lines);
170
+ }
171
+ lines.push(`[SUMMARY] docs=${docs} errors=${errors} warnings=${warnings}`);
172
+ return { exitCode: errors ? 1 : 0, docs, errors, warnings, lines };
173
+ }
174
+
175
+ function directoryExists(path: string): boolean {
176
+ try {
177
+ return statSync(path).isDirectory();
178
+ } catch {
179
+ return false;
180
+ }
181
+ }
@@ -0,0 +1,131 @@
1
+ import { randomInt } from "node:crypto";
2
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
3
+ import { resolve } from "node:path";
4
+
5
+ export const DEFAULT_ALPHABET = "23456789abcdefghjkmnpqrstuvwxyz";
6
+ export const DEFAULT_LENGTH = 5;
7
+
8
+ export interface LegacyIdOptions {
9
+ repoRoot?: string;
10
+ legacyCompat?: boolean;
11
+ count?: number;
12
+ length?: number;
13
+ alphabet?: string;
14
+ maxAttempts?: number;
15
+ }
16
+
17
+ export interface LegacyIdResult {
18
+ exitCode: number;
19
+ stdout: string;
20
+ stderr: string;
21
+ }
22
+
23
+ function idsInRoot(
24
+ root: string,
25
+ alphabet: string,
26
+ length: number,
27
+ ): Set<string> {
28
+ const ids = new Set<string>();
29
+ const idPattern = new RegExp(
30
+ `^(?:\\d{4}|[${escapeRegex(alphabet)}]{${length}})$`,
31
+ );
32
+ for (const relativeRoot of ["docs/specs", "docs/plan"]) {
33
+ const directory = resolve(root, relativeRoot);
34
+ if (existsSync(directory)) {
35
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
36
+ if (!entry.isDirectory()) continue;
37
+ const prefix = entry.name.split(/[-:]/, 1)[0]?.trim() ?? "";
38
+ if (idPattern.test(prefix)) ids.add(prefix);
39
+ }
40
+ }
41
+ const readme = resolve(directory, "README.md");
42
+ if (!existsSync(readme)) continue;
43
+ for (const line of readFileSync(readme, "utf8").split(/\r?\n/)) {
44
+ if (!line.startsWith("|")) continue;
45
+ const id =
46
+ line
47
+ .replace(/^\||\|$/g, "")
48
+ .split("|")[0]
49
+ ?.trim() ?? "";
50
+ if (idPattern.test(id)) ids.add(id);
51
+ }
52
+ }
53
+ return ids;
54
+ }
55
+
56
+ function escapeRegex(value: string): string {
57
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
58
+ }
59
+
60
+ function oneId(alphabet: string, length: number): string {
61
+ return Array.from(
62
+ { length },
63
+ () => alphabet[randomInt(alphabet.length)] ?? alphabet[0],
64
+ ).join("");
65
+ }
66
+
67
+ export function generateLegacySpecIds(
68
+ options: LegacyIdOptions = {},
69
+ ): LegacyIdResult {
70
+ if (!options.legacyCompat)
71
+ return {
72
+ exitCode: 2,
73
+ stdout: "",
74
+ stderr:
75
+ "[ERROR] legacy ID generation is migration-only; create new specs with docs/specs/<topic>/ instead.\n",
76
+ };
77
+ const count = options.count ?? 1;
78
+ const length = options.length ?? DEFAULT_LENGTH;
79
+ const alphabet = (options.alphabet ?? DEFAULT_ALPHABET).trim();
80
+ const maxAttempts = options.maxAttempts ?? 10_000;
81
+ if (count < 1)
82
+ return {
83
+ exitCode: 2,
84
+ stdout: "",
85
+ stderr: "[ERROR] --count must be >= 1\n",
86
+ };
87
+ if (length < 1)
88
+ return {
89
+ exitCode: 2,
90
+ stdout: "",
91
+ stderr: "[ERROR] --length must be >= 1\n",
92
+ };
93
+ if (!alphabet)
94
+ return {
95
+ exitCode: 2,
96
+ stdout: "",
97
+ stderr: "[ERROR] --alphabet must be non-empty\n",
98
+ };
99
+ if (new Set(alphabet).size !== alphabet.length)
100
+ return {
101
+ exitCode: 2,
102
+ stdout: "",
103
+ stderr: "[ERROR] --alphabet must not contain duplicate characters\n",
104
+ };
105
+
106
+ const existing = idsInRoot(
107
+ resolve(options.repoRoot ?? "."),
108
+ alphabet,
109
+ length,
110
+ );
111
+ const generated: string[] = [];
112
+ for (let index = 0; index < count; index += 1) {
113
+ let candidate: string | undefined;
114
+ for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
115
+ const next = oneId(alphabet, length);
116
+ if (!existing.has(next)) {
117
+ candidate = next;
118
+ existing.add(next);
119
+ break;
120
+ }
121
+ }
122
+ if (!candidate)
123
+ return {
124
+ exitCode: 1,
125
+ stdout: "",
126
+ stderr: `[ERROR] Failed to generate a unique id after ${maxAttempts} attempts.\n`,
127
+ };
128
+ generated.push(candidate);
129
+ }
130
+ return { exitCode: 0, stdout: `${generated.join("\n")}\n`, stderr: "" };
131
+ }