vinta-ai-workflows 0.1.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 (40) hide show
  1. package/CHANGELOG.md +830 -0
  2. package/LICENSE +21 -0
  3. package/README.md +547 -0
  4. package/package.json +27 -0
  5. package/schemas/README.md +84 -0
  6. package/schemas/mcp-preflight-cache.v1.schema.json +50 -0
  7. package/schemas/prs-context-comments.v1.schema.json +51 -0
  8. package/schemas/prs-context-frontmatter.v1.schema.json +77 -0
  9. package/schemas/sub-agent.v1.schema.json +105 -0
  10. package/schemas/vinta-ai-workflows-config.v1.schema.json +283 -0
  11. package/skills/vinta-analyze-codebase/SKILL.md +325 -0
  12. package/skills/vinta-bootstrap-ai-tools/SKILL.md +375 -0
  13. package/skills/vinta-bootstrap-ai-tools/resources/stacks/README.md +50 -0
  14. package/skills/vinta-bootstrap-ai-tools/resources/stacks/django/notes.md +52 -0
  15. package/skills/vinta-bootstrap-ai-tools/resources/stacks/medplum/notes.md +45 -0
  16. package/skills/vinta-bootstrap-ai-tools/resources/stacks/nextjs-app-router/notes.md +35 -0
  17. package/skills/vinta-bootstrap-ai-tools/resources/stacks/python-package/notes.md +31 -0
  18. package/skills/vinta-bootstrap-ai-tools/resources/stacks/typescript-monorepo/notes.md +32 -0
  19. package/skills/vinta-derive-skills/SKILL.md +200 -0
  20. package/skills/vinta-derive-skills/resources/amend-plan-template.md +355 -0
  21. package/skills/vinta-derive-skills/resources/foundation-skills/add-one-off-script/SKILL.md +292 -0
  22. package/skills/vinta-derive-skills/resources/foundation-skills/add-one-off-script/resources/one_off_script_base.py +602 -0
  23. package/skills/vinta-derive-skills/resources/foundation-skills/add-one-off-script/resources/one_off_script_base.ts +624 -0
  24. package/skills/vinta-derive-skills/resources/foundation-skills/create-qa-use-cases/SKILL.md +232 -0
  25. package/skills/vinta-derive-skills/resources/foundation-skills/create-spec/SKILL.md +235 -0
  26. package/skills/vinta-derive-skills/resources/foundation-skills/open-pr-from-context/SKILL.md +78 -0
  27. package/skills/vinta-derive-skills/resources/foundation-skills/open-pr-from-context/scripts/open-pr.sh +357 -0
  28. package/skills/vinta-derive-skills/resources/foundation-skills/plan-feature/SKILL.md +418 -0
  29. package/skills/vinta-derive-skills/resources/implement-plan-template.md +375 -0
  30. package/skills/vinta-derive-skills/resources/prs-context-template.md +91 -0
  31. package/skills/vinta-derive-skills/resources/systematic-debugging-mcp-tools.md +125 -0
  32. package/skills/vinta-derive-skills/resources/systematic-debugging-template.md +167 -0
  33. package/skills/vinta-derive-subagents/SKILL.md +192 -0
  34. package/skills/vinta-install-ai-tools-setup/SKILL.md +138 -0
  35. package/skills/vinta-install-ai-tools-setup/resources/setup-ai-tools.mjs +392 -0
  36. package/skills/vinta-migrate-plans-specs/SKILL.md +229 -0
  37. package/skills/vinta-sync-ai-tools/SKILL.md +184 -0
  38. package/skills/vinta-update-project-skills/SKILL.md +178 -0
  39. package/skills/vinta-write-agents-md/SKILL.md +192 -0
  40. package/vinta-ai-workflows.mjs +316 -0
@@ -0,0 +1,624 @@
1
+ // @ts-nocheck
2
+ // ^ This file is a TEMPLATE shipped as-is to consumer projects. Type-checking
3
+ // happens in the target repo against its own tsconfig + @types/node +
4
+ // @aws-sdk/client-s3 (optional, dynamic-imported at runtime). Inside this
5
+ // tooling repo the file is content, not source — hence @ts-nocheck. Remove
6
+ // the directive after copying into a consumer project so its tsconfig
7
+ // validates the file properly.
8
+
9
+ /**
10
+ * BaseOneOffScript — canonical scaffold for a Vinta one-off operational script (TS / Node 20+).
11
+ *
12
+ * Copy this file once into your project at `<scripts_dir>/_base.ts` (default
13
+ * `scripts/one_off/_base.ts`) and reuse from every script. The `Runtime`
14
+ * interface below is what `run-one-off-script-<stack>` skills override to plug
15
+ * the script into a stack-specific surface (Medplum bot, Vercel Function,
16
+ * Lambda, K8s Job, etc). The default `LocalRuntime` covers a plain CLI
17
+ * invocation with filesystem state, PID-file single-instance lease,
18
+ * SIGINT/SIGTERM stop handling, and optional S3 upload.
19
+ *
20
+ * Subclasses override only the per-script hooks:
21
+ * - describe(): string
22
+ * - iterTargets(): AsyncIterable<T>
23
+ * - process(item: T): Promise<void>
24
+ * - itemId(item: T): string
25
+ * - tablesTouched(): string[] (destructive scripts only)
26
+ * - snapshot(item: T): Record<string, Record<string, unknown>> (destructive scripts only)
27
+ * - applyRestoreRow(table, row): Promise<void> (only if restore needed)
28
+ *
29
+ * Engine methods (run, execute, safeProcess, writeBackup) call into Runtime —
30
+ * DO NOT override them.
31
+ *
32
+ * See `add-one-off-script/SKILL.md` for the full contract this file enforces.
33
+ */
34
+
35
+ import {
36
+ appendFileSync,
37
+ closeSync,
38
+ existsSync,
39
+ fsyncSync,
40
+ mkdirSync,
41
+ openSync,
42
+ readFileSync,
43
+ readdirSync,
44
+ statSync,
45
+ unlinkSync,
46
+ writeFileSync,
47
+ } from "node:fs";
48
+ import { join, resolve } from "node:path";
49
+ import { parseArgs } from "node:util";
50
+
51
+ // ============================================================================
52
+ // Configuration
53
+ // ============================================================================
54
+
55
+ export interface ScriptConfig {
56
+ /** Stable identifier — typically the parent folder name (`<YYYY-MM-DD>-<descriptive-kebab>`). */
57
+ name: string;
58
+ /** Parent dir for run state. Per-script files land in `<logDir>/<name>/`. Default `.vinta-ai-workflows/one-off-runs`. */
59
+ logDir?: string;
60
+ /** Rows per chunk in iterTargets. Default 500. */
61
+ batchSize?: number;
62
+ /** Roll over to a new CSV chunk when (rows * cols) would exceed this. Default 1_000_000. */
63
+ csvMaxCells?: number;
64
+ /** fsync the log + processed-items file every N items. Default 50. */
65
+ fsyncEvery?: number;
66
+ }
67
+
68
+ export interface ResolvedConfig extends Required<ScriptConfig> {}
69
+
70
+ export function resolveConfig(c: ScriptConfig): ResolvedConfig {
71
+ return {
72
+ name: c.name,
73
+ logDir: c.logDir ?? ".vinta-ai-workflows/one-off-runs",
74
+ batchSize: c.batchSize ?? 500,
75
+ csvMaxCells: c.csvMaxCells ?? 1_000_000,
76
+ fsyncEvery: c.fsyncEvery ?? 50,
77
+ };
78
+ }
79
+
80
+ export type LogLevel = "INFO" | "WARN" | "ERROR";
81
+
82
+ // ============================================================================
83
+ // Runtime interface
84
+ // ============================================================================
85
+
86
+ /**
87
+ * Pluggable surface the engine calls into. Default = `LocalRuntime`.
88
+ * Stack-specific runners (Medplum bot, Vercel Function, Lambda, K8s Job)
89
+ * extend this to adapt the contract to their surface.
90
+ */
91
+ export abstract class Runtime {
92
+ readonly config: ResolvedConfig;
93
+ readonly runDir: string;
94
+
95
+ constructor(config: ScriptConfig) {
96
+ this.config = resolveConfig(config);
97
+ this.runDir = join(this.config.logDir, this.config.name);
98
+ mkdirSync(this.runDir, { recursive: true });
99
+ }
100
+
101
+ // ---- lifecycle ----
102
+ abstract acquireLease(): void;
103
+ abstract releaseLease(): void;
104
+ abstract installStopHandler(onStop: (reason: string) => void): void;
105
+ abstract shouldStop(): boolean;
106
+
107
+ // ---- logging ----
108
+ abstract log(level: LogLevel, message: string): void;
109
+ abstract fsyncLog(): void;
110
+
111
+ // ---- processed-items log (resume) ----
112
+ abstract loadProcessedIds(): Set<string>;
113
+ abstract markProcessed(itemId: string): void;
114
+
115
+ // ---- artifact paths ----
116
+ artifactPath(filename: string): string {
117
+ return join(this.runDir, filename);
118
+ }
119
+ abstract listRunArtifacts(): string[];
120
+
121
+ // ---- final upload ----
122
+ abstract uploadRunArtifacts(): Promise<void>;
123
+ }
124
+
125
+ // ============================================================================
126
+ // LocalRuntime — default for a plain CLI invocation
127
+ // ============================================================================
128
+
129
+ export class LocalRuntime extends Runtime {
130
+ private stop = false;
131
+ private stopCount = 0;
132
+ private logFd: number;
133
+ private logPath: string;
134
+ private processedPath: string;
135
+ private leasePath: string;
136
+ private s3Bucket: string | null;
137
+ private s3Prefix: string;
138
+
139
+ constructor(config: ScriptConfig, opts: { s3Bucket?: string; s3Prefix?: string } = {}) {
140
+ super(config);
141
+ this.logPath = join(this.runDir, "run.log");
142
+ this.processedPath = join(this.runDir, "processed.txt");
143
+ this.leasePath = join(this.runDir, "lease.pid");
144
+ this.logFd = openSync(this.logPath, "a");
145
+ this.s3Bucket = opts.s3Bucket ?? process.env.ONE_OFF_S3_BUCKET ?? null;
146
+ const defaultPrefix = process.env.ONE_OFF_S3_PREFIX ?? `one-off-runs/${this.config.name}/`;
147
+ this.s3Prefix = (opts.s3Prefix ?? defaultPrefix).replace(/\/+$/, "") + "/";
148
+ }
149
+
150
+ // ---- lease ----
151
+
152
+ acquireLease(): void {
153
+ if (existsSync(this.leasePath)) {
154
+ const existing = readFileSync(this.leasePath, "utf8").trim();
155
+ if (/^\d+$/.test(existing) && pidAlive(Number(existing))) {
156
+ throw new Error(
157
+ `lease ${this.leasePath} held by live process ${existing} — ` +
158
+ "another instance is running. Stop it before starting a new run.",
159
+ );
160
+ }
161
+ }
162
+ writeFileSync(this.leasePath, String(process.pid));
163
+ }
164
+
165
+ releaseLease(): void {
166
+ try {
167
+ unlinkSync(this.leasePath);
168
+ } catch {
169
+ // already gone
170
+ }
171
+ }
172
+
173
+ // ---- stop ----
174
+
175
+ installStopHandler(onStop: (reason: string) => void): void {
176
+ const handler = (signal: NodeJS.Signals) => {
177
+ this.stopCount++;
178
+ if (this.stopCount >= 2) {
179
+ this.log("ERROR", `second signal ${signal} received during shutdown — forcing exit`);
180
+ this.releaseLease();
181
+ process.exit(130);
182
+ }
183
+ this.stop = true;
184
+ onStop(`signal ${signal}`);
185
+ };
186
+ process.on("SIGINT", handler);
187
+ process.on("SIGTERM", handler);
188
+ }
189
+
190
+ shouldStop(): boolean {
191
+ return this.stop;
192
+ }
193
+
194
+ // ---- logging ----
195
+
196
+ log(level: LogLevel, message: string): void {
197
+ const line = `${new Date().toISOString()} ${level} ${message}\n`;
198
+ process.stdout.write(line);
199
+ appendFileSync(this.logFd, line);
200
+ }
201
+
202
+ fsyncLog(): void {
203
+ try {
204
+ fsyncSync(this.logFd);
205
+ } catch {
206
+ // best effort
207
+ }
208
+ }
209
+
210
+ // ---- processed log ----
211
+
212
+ loadProcessedIds(): Set<string> {
213
+ if (!existsSync(this.processedPath)) return new Set();
214
+ const out = new Set<string>();
215
+ for (const line of readFileSync(this.processedPath, "utf8").split("\n")) {
216
+ const t = line.trim();
217
+ if (t) out.add(t);
218
+ }
219
+ return out;
220
+ }
221
+
222
+ markProcessed(itemId: string): void {
223
+ const fd = openSync(this.processedPath, "a");
224
+ try {
225
+ writeFileSync(fd, `${itemId}\n`);
226
+ fsyncSync(fd);
227
+ } finally {
228
+ closeSync(fd);
229
+ }
230
+ }
231
+
232
+ // ---- artifacts ----
233
+
234
+ listRunArtifacts(): string[] {
235
+ if (!existsSync(this.runDir)) return [];
236
+ return readdirSync(this.runDir)
237
+ .filter((n) => n !== "lease.pid")
238
+ .map((n) => join(this.runDir, n))
239
+ .filter((p) => statSync(p).isFile());
240
+ }
241
+
242
+ async uploadRunArtifacts(): Promise<void> {
243
+ if (!this.s3Bucket) {
244
+ this.log("INFO", "s3: no bucket configured (ONE_OFF_S3_BUCKET unset), skipping upload");
245
+ return;
246
+ }
247
+ let S3Client: any, PutObjectCommand: any;
248
+ try {
249
+ const mod = await import("@aws-sdk/client-s3");
250
+ S3Client = mod.S3Client;
251
+ PutObjectCommand = mod.PutObjectCommand;
252
+ } catch {
253
+ this.log("WARN", "s3: @aws-sdk/client-s3 not installed, skipping upload");
254
+ return;
255
+ }
256
+ const client = new S3Client({});
257
+ let uploaded = 0;
258
+ for (const path of this.listRunArtifacts()) {
259
+ try {
260
+ const body = readFileSync(path);
261
+ const key = this.s3Prefix + path.split("/").pop()!;
262
+ await client.send(new PutObjectCommand({ Bucket: this.s3Bucket, Key: key, Body: body }));
263
+ uploaded++;
264
+ } catch (err) {
265
+ this.log(
266
+ "ERROR",
267
+ `s3: upload FAILED for ${path} — filesystem copy at ${this.runDir} is authoritative: ${(err as Error).message}`,
268
+ );
269
+ return;
270
+ }
271
+ }
272
+ this.log("INFO", `s3: uploaded ${uploaded} file(s) to s3://${this.s3Bucket}/${this.s3Prefix}`);
273
+ }
274
+ }
275
+
276
+ // ============================================================================
277
+ // Engine
278
+ // ============================================================================
279
+
280
+ export abstract class BaseOneOffScript<T> {
281
+ readonly config: ResolvedConfig;
282
+ readonly runtime: Runtime;
283
+ dryRun: boolean;
284
+ readonly resume: boolean;
285
+
286
+ private processedIds = new Set<string>();
287
+ private csvWriters = new Map<string, CsvChunkWriter>();
288
+ private itemsSinceFsync = 0;
289
+
290
+ constructor(
291
+ config: ScriptConfig,
292
+ opts: { runtime?: Runtime; dryRun?: boolean; resume?: boolean } = {},
293
+ ) {
294
+ this.config = resolveConfig(config);
295
+ this.runtime = opts.runtime ?? new LocalRuntime(config);
296
+ this.dryRun = opts.dryRun ?? true;
297
+ this.resume = opts.resume ?? false;
298
+
299
+ this.runtime.acquireLease();
300
+ this.runtime.installStopHandler((reason) => {
301
+ this.runtime.log("WARN", `stop signal received (${reason}) — finishing current item then exiting cleanly`);
302
+ });
303
+ if (this.resume) this.processedIds = this.runtime.loadProcessedIds();
304
+ }
305
+
306
+ // ---- subclass hooks ----
307
+
308
+ abstract describe(): string;
309
+ abstract iterTargets(): AsyncIterable<T>;
310
+ abstract process(item: T): Promise<void>;
311
+ abstract itemId(item: T): string;
312
+
313
+ tablesTouched(): string[] {
314
+ return [];
315
+ }
316
+ snapshot(_item: T): Record<string, Record<string, unknown>> {
317
+ return {};
318
+ }
319
+ async applyRestoreRow(table: string, _row: Record<string, string>): Promise<void> {
320
+ throw new Error(`restore not implemented for table ${JSON.stringify(table)}`);
321
+ }
322
+
323
+ // ---- public entry point ----
324
+
325
+ async execute(dryRun?: boolean): Promise<number> {
326
+ if (typeof dryRun === "boolean") this.dryRun = dryRun;
327
+ return this.run();
328
+ }
329
+
330
+ async run(): Promise<number> {
331
+ const log = this.runtime.log.bind(this.runtime);
332
+ log("INFO", "=".repeat(72));
333
+ log("INFO", `script: ${this.config.name}`);
334
+ log("INFO", `description: ${this.describe()}`);
335
+ log("INFO", `mode: ${this.dryRun ? "DRY-RUN (no writes)" : "APPLY (writes enabled)"}`);
336
+ log("INFO", `runtime: ${this.runtime.constructor.name}`);
337
+ log("INFO", `started_at: ${new Date().toISOString()}`);
338
+ if (this.resume) log("INFO", `resume: skipping ${this.processedIds.size} previously-completed items`);
339
+ const tables = this.tablesTouched();
340
+ if (tables.length) log("INFO", `tables_touched: ${tables.join(", ")}`);
341
+
342
+ let processed = 0;
343
+ let skipped = 0;
344
+ let failed = 0;
345
+
346
+ try {
347
+ for await (const item of this.iterTargets()) {
348
+ if (this.runtime.shouldStop()) {
349
+ log("WARN", `stop flag set; flushing and exiting cleanly after ${processed} items`);
350
+ break;
351
+ }
352
+ const iid = this.itemId(item);
353
+ if (this.resume && this.processedIds.has(iid)) {
354
+ skipped++;
355
+ continue;
356
+ }
357
+ const ok = await this.safeProcess(item, iid);
358
+ if (ok) processed++;
359
+ else failed++;
360
+
361
+ this.itemsSinceFsync++;
362
+ if (this.itemsSinceFsync >= this.config.fsyncEvery) {
363
+ this.runtime.fsyncLog();
364
+ this.itemsSinceFsync = 0;
365
+ }
366
+ }
367
+ } finally {
368
+ log("INFO", "flushing csv backups + log");
369
+ this.flush();
370
+ log("INFO", `summary: processed=${processed} skipped(resume)=${skipped} failed=${failed}`);
371
+ log("INFO", `finished_at: ${new Date().toISOString()}`);
372
+ await this.runtime.uploadRunArtifacts();
373
+ this.runtime.releaseLease();
374
+ }
375
+ return failed === 0 ? 0 : 1;
376
+ }
377
+
378
+ async restoreFromBackup(backupDir: string): Promise<number> {
379
+ const log = this.runtime.log.bind(this.runtime);
380
+ backupDir = resolve(backupDir);
381
+ if (!existsSync(backupDir) || !statSync(backupDir).isDirectory()) {
382
+ throw new Error(`backup dir not found: ${backupDir}`);
383
+ }
384
+ const files = readdirSync(backupDir).filter((n) => n.endsWith(".csv")).sort();
385
+ if (files.length === 0) throw new Error(`no backup CSV files in ${backupDir}`);
386
+
387
+ log("INFO", `restore: applying ${files.length} backup file(s)`);
388
+ let total = 0;
389
+ for (const fname of files) {
390
+ const table = fname.replace(/\.\d+\.csv$/, "");
391
+ log("INFO", `restore: file=${fname} table=${table}`);
392
+ const rows = parseCsv(readFileSync(join(backupDir, fname), "utf8"));
393
+ for (const row of rows) {
394
+ await this.applyRestoreRow(table, row);
395
+ total++;
396
+ if (total % 1000 === 0) log("INFO", `restore: ${total} rows applied`);
397
+ }
398
+ }
399
+ log("INFO", `restore: complete, ${total} rows applied`);
400
+ return total;
401
+ }
402
+
403
+ // ---- internals ----
404
+
405
+ private async safeProcess(item: T, iid: string): Promise<boolean> {
406
+ const log = this.runtime.log.bind(this.runtime);
407
+ try {
408
+ const tables = this.tablesTouched();
409
+ if (tables.length > 0) {
410
+ const snap = this.snapshot(item);
411
+ if (Object.keys(snap).length === 0) {
412
+ log("WARN", `item ${iid} declared tablesTouched but snapshot() returned empty — skipping`);
413
+ return false;
414
+ }
415
+ if (this.dryRun) {
416
+ log("INFO", `[dry-run] would back up tables ${Object.keys(snap).join(", ")} for item ${iid}`);
417
+ } else {
418
+ this.writeBackup(snap);
419
+ }
420
+ }
421
+
422
+ if (this.dryRun) {
423
+ log("INFO", `[dry-run] would process item ${iid}`);
424
+ return true;
425
+ }
426
+
427
+ await this.process(item);
428
+ this.runtime.markProcessed(iid);
429
+ return true;
430
+ } catch (err) {
431
+ log("ERROR", `item ${iid} FAILED — left for next --resume run: ${(err as Error).stack ?? err}`);
432
+ return false;
433
+ }
434
+ }
435
+
436
+ private writeBackup(snap: Record<string, Record<string, unknown>>): void {
437
+ for (const [table, row] of Object.entries(snap)) {
438
+ let writer = this.csvWriters.get(table);
439
+ if (!writer) {
440
+ writer = new CsvChunkWriter(this.runtime, table, this.config.csvMaxCells);
441
+ this.csvWriters.set(table, writer);
442
+ }
443
+ writer.write(row);
444
+ }
445
+ }
446
+
447
+ private flush(): void {
448
+ for (const w of this.csvWriters.values()) w.close();
449
+ this.runtime.fsyncLog();
450
+ }
451
+
452
+ // ---- CLI helper ----
453
+
454
+ static async cliMain<S extends BaseOneOffScript<unknown>>(
455
+ factory: (opts: { dryRun: boolean; resume: boolean; runtime?: Runtime }) => S,
456
+ config: ScriptConfig,
457
+ ): Promise<number> {
458
+ const { values } = parseArgs({
459
+ options: {
460
+ apply: { type: "boolean", default: false },
461
+ resume: { type: "boolean", default: false },
462
+ status: { type: "boolean", default: false },
463
+ restore: { type: "string" },
464
+ },
465
+ });
466
+ if (values.status) return printStatus(resolveConfig(config));
467
+ const instance = factory({ dryRun: !values.apply, resume: !!values.resume });
468
+ if (values.restore) {
469
+ await instance.restoreFromBackup(values.restore);
470
+ return 0;
471
+ }
472
+ return instance.execute();
473
+ }
474
+ }
475
+
476
+ // ============================================================================
477
+ // helpers
478
+ // ============================================================================
479
+
480
+ class CsvChunkWriter {
481
+ runtime: Runtime;
482
+ table: string;
483
+ maxCells: number;
484
+ files: string[] = [];
485
+ private chunkIdx = 0;
486
+ private fd: number | null = null;
487
+ private cellsInChunk = 0;
488
+ private fieldnames: string[] | null = null;
489
+
490
+ constructor(runtime: Runtime, table: string, maxCells: number) {
491
+ this.runtime = runtime;
492
+ this.table = table;
493
+ this.maxCells = maxCells;
494
+ }
495
+
496
+ write(row: Record<string, unknown>): void {
497
+ if (this.fieldnames === null) this.fieldnames = Object.keys(row);
498
+ const cols = this.fieldnames.length;
499
+ if (this.fd === null || this.cellsInChunk + cols > this.maxCells) this.roll();
500
+ const line = this.fieldnames.map((k) => csvEscape(row[k])).join(",") + "\n";
501
+ appendFileSync(this.fd!, line);
502
+ this.cellsInChunk += cols;
503
+ }
504
+
505
+ close(): void {
506
+ if (this.fd !== null) {
507
+ try {
508
+ fsyncSync(this.fd);
509
+ } catch {
510
+ // ignore
511
+ }
512
+ closeSync(this.fd);
513
+ this.fd = null;
514
+ }
515
+ }
516
+
517
+ private roll(): void {
518
+ this.close();
519
+ this.chunkIdx++;
520
+ const path = this.runtime.artifactPath(`${this.table}.${String(this.chunkIdx).padStart(3, "0")}.csv`);
521
+ this.files.push(path);
522
+ this.fd = openSync(path, "w");
523
+ const header = (this.fieldnames ?? []).map(csvEscape).join(",") + "\n";
524
+ appendFileSync(this.fd, header);
525
+ this.cellsInChunk = (this.fieldnames ?? []).length;
526
+ }
527
+ }
528
+
529
+ function csvEscape(v: unknown): string {
530
+ if (v === null || v === undefined) return "";
531
+ const s = String(v);
532
+ if (/[",\n\r]/.test(s)) return `"${s.replace(/"/g, '""')}"`;
533
+ return s;
534
+ }
535
+
536
+ function parseCsv(text: string): Array<Record<string, string>> {
537
+ const rows: string[][] = [];
538
+ let row: string[] = [];
539
+ let field = "";
540
+ let inQuotes = false;
541
+ for (let i = 0; i < text.length; i++) {
542
+ const c = text[i];
543
+ if (inQuotes) {
544
+ if (c === '"' && text[i + 1] === '"') {
545
+ field += '"';
546
+ i++;
547
+ } else if (c === '"') {
548
+ inQuotes = false;
549
+ } else {
550
+ field += c;
551
+ }
552
+ } else {
553
+ if (c === '"') inQuotes = true;
554
+ else if (c === ",") {
555
+ row.push(field);
556
+ field = "";
557
+ } else if (c === "\n") {
558
+ row.push(field);
559
+ rows.push(row);
560
+ row = [];
561
+ field = "";
562
+ } else if (c !== "\r") {
563
+ field += c;
564
+ }
565
+ }
566
+ }
567
+ if (field.length || row.length) {
568
+ row.push(field);
569
+ rows.push(row);
570
+ }
571
+ if (rows.length === 0) return [];
572
+ const headers = rows[0];
573
+ return rows
574
+ .slice(1)
575
+ .filter((r) => r.length > 1 || (r.length === 1 && r[0] !== ""))
576
+ .map((r) => {
577
+ const o: Record<string, string> = {};
578
+ headers.forEach((h, i) => {
579
+ o[h] = r[i] ?? "";
580
+ });
581
+ return o;
582
+ });
583
+ }
584
+
585
+ function pidAlive(pid: number): boolean {
586
+ if (!Number.isFinite(pid) || pid <= 0) return false;
587
+ try {
588
+ process.kill(pid, 0);
589
+ return true;
590
+ } catch (err) {
591
+ return (err as NodeJS.ErrnoException).code === "EPERM";
592
+ }
593
+ }
594
+
595
+ function printStatus(config: ResolvedConfig): number {
596
+ const runDir = join(config.logDir, config.name);
597
+ const lease = join(runDir, "lease.pid");
598
+ const logPath = join(runDir, "run.log");
599
+ const processed = join(runDir, "processed.txt");
600
+
601
+ console.log(`script: ${config.name}`);
602
+ console.log(`run_dir: ${runDir}`);
603
+ if (existsSync(lease)) {
604
+ const pid = readFileSync(lease, "utf8").trim();
605
+ const alive = /^\d+$/.test(pid) && pidAlive(Number(pid));
606
+ console.log(`lease: ${pid} (${alive ? "running" : "STALE — process gone"})`);
607
+ } else {
608
+ console.log("lease: (no lease file — script not running)");
609
+ }
610
+ if (existsSync(processed)) {
611
+ const count = readFileSync(processed, "utf8").split("\n").filter((l) => l.trim()).length;
612
+ console.log(`processed items: ${count}`);
613
+ } else {
614
+ console.log("processed items: 0 (no resume log yet)");
615
+ }
616
+ if (existsSync(logPath)) {
617
+ const tail = readFileSync(logPath, "utf8").trim().split("\n").slice(-20);
618
+ console.log(`log tail (${logPath}):`);
619
+ for (const line of tail) console.log(` ${line}`);
620
+ } else {
621
+ console.log("(no log file yet)");
622
+ }
623
+ return 0;
624
+ }