legacymaxxing 0.1.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.
@@ -0,0 +1,469 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { resolve } from "node:path";
3
+ import { createHash } from "node:crypto";
4
+ import { execFile } from "node:child_process";
5
+ import { promisify } from "node:util";
6
+ import { ToolError, errMessage } from "./errors.js";
7
+ import { PHASE_NAMES, PHASES, isPhaseName } from "./phases.js";
8
+ import { configRecordSchema, phaseRecordSchema, projectRecordSchema, } from "./types.js";
9
+ import { isDirectory, pathExists, readJson, writeJson } from "./fs.js";
10
+ import { claim, listLocks, lockPath, phaseRecordPath, release, runRecordPath } from "./state.js";
11
+ import { partitionArtifacts, providerByName } from "./provider.js";
12
+ import { runDetectors } from "./detectors/index.js";
13
+ import { buildPrompt } from "./prompts.js";
14
+ import { assertCleanWorktree, headSha, isGitRepo } from "./git.js";
15
+ const exec = promisify(execFile);
16
+ const STATE_EXCLUDE = [".legacymaxxing", "analysis"];
17
+ const MAX_SCAN_FILES = 5000;
18
+ // ---- small flag helpers -----------------------------------------------------
19
+ function flagStr(flags, key) {
20
+ const value = flags[key];
21
+ return typeof value === "string" ? value : null;
22
+ }
23
+ function flagBool(flags, key) {
24
+ return flags[key] === true || flags[key] === "true";
25
+ }
26
+ function requireStr(value, name, usage) {
27
+ if (!value)
28
+ throw new ToolError(`missing ${name} (${usage})`, 2, "invalid-usage");
29
+ return value;
30
+ }
31
+ function parsePhaseArg(flags) {
32
+ const raw = flagStr(flags, "phase");
33
+ if (raw && isPhaseName(raw))
34
+ return raw;
35
+ throw new ToolError(`unknown phase: ${raw ?? "(none)"} — one of: ${PHASE_NAMES.join(", ")}`, 2, "invalid-usage");
36
+ }
37
+ function progress(ctx, message) {
38
+ if (!ctx.options.quiet)
39
+ process.stderr.write(`${message}\n`);
40
+ }
41
+ // ---- state loaders ----------------------------------------------------------
42
+ async function readProject(ctx) {
43
+ if (!(await pathExists(ctx.paths.project))) {
44
+ throw new ToolError("not initialized; run `legacymaxxing init` first", 2, "invalid-usage");
45
+ }
46
+ return readJson(ctx.paths.project, projectRecordSchema);
47
+ }
48
+ async function readConfig(ctx) {
49
+ if (!(await pathExists(ctx.paths.config))) {
50
+ throw new ToolError("not initialized; run `legacymaxxing init` first", 2, "invalid-usage");
51
+ }
52
+ return readJson(ctx.paths.config, configRecordSchema);
53
+ }
54
+ async function resolveSystem(ctx, flags) {
55
+ const project = await readProject(ctx);
56
+ const requested = ctx.options.system ?? flagStr(flags, "system");
57
+ let entry = requested
58
+ ? project.systems.find((s) => s.name === requested)
59
+ : project.systems.length === 1
60
+ ? project.systems[0]
61
+ : undefined;
62
+ if (requested && !entry) {
63
+ throw new ToolError(`unknown system: ${requested}`, 2, "invalid-usage");
64
+ }
65
+ if (!entry) {
66
+ const names = project.systems.map((s) => s.name).join(", ") || "none";
67
+ throw new ToolError(`--system required (registered: ${names})`, 2, "invalid-usage");
68
+ }
69
+ return { system: entry.name, legacyPath: entry.legacyPath, baseSha: entry.baseSha };
70
+ }
71
+ async function loadPhase(ctx, system, phase) {
72
+ const path = phaseRecordPath(ctx.paths.stateDir, system, phase);
73
+ if (!(await pathExists(path)))
74
+ return null;
75
+ return readJson(path, phaseRecordSchema);
76
+ }
77
+ async function savePhase(ctx, record) {
78
+ await writeJson(phaseRecordPath(ctx.paths.stateDir, record.system, record.phase), record);
79
+ }
80
+ // ---- phase machine helpers --------------------------------------------------
81
+ async function isPhaseDone(ctx, system, phase) {
82
+ const record = await loadPhase(ctx, system, phase);
83
+ return record?.status === "validated" || record?.status === "gate-approved";
84
+ }
85
+ async function isGateApproved(ctx, system, phase) {
86
+ const record = await loadPhase(ctx, system, phase);
87
+ return record?.gate?.decision === "approve";
88
+ }
89
+ async function blockers(ctx, system, phase) {
90
+ const spec = PHASES[phase];
91
+ const out = [];
92
+ for (const dep of spec.dependsOn) {
93
+ if (!(await isPhaseDone(ctx, system, dep)))
94
+ out.push(`${dep} not done`);
95
+ }
96
+ for (const gated of spec.requiresApprovedGate) {
97
+ if (!(await isGateApproved(ctx, system, gated)))
98
+ out.push(`${gated} gate not approved`);
99
+ }
100
+ return out;
101
+ }
102
+ function makeRunId(system, phase, baseSha, startedAt) {
103
+ const hash = createHash("sha256")
104
+ .update(`${system}|${phase}|${baseSha ?? ""}|${startedAt}`)
105
+ .digest("hex")
106
+ .slice(0, 8);
107
+ return `${system}-${phase}-${hash}`;
108
+ }
109
+ async function assemblePrompt(ctx, resolved, phase, module, vision) {
110
+ const legacyAbs = resolve(ctx.options.root, resolved.legacyPath);
111
+ const seeds = await runDetectors(legacyAbs, { maxFiles: MAX_SCAN_FILES });
112
+ return buildPrompt({ system: resolved.system, legacyPath: resolved.legacyPath, seeds, module, vision }, phase);
113
+ }
114
+ async function validateArtifactShape(abs, kind) {
115
+ if (kind === "dir")
116
+ return isDirectory(abs);
117
+ let content;
118
+ try {
119
+ content = await readFile(abs, "utf8");
120
+ }
121
+ catch {
122
+ return false;
123
+ }
124
+ if (content.trim().length === 0)
125
+ return false;
126
+ if (kind === "json") {
127
+ try {
128
+ JSON.parse(content);
129
+ return true;
130
+ }
131
+ catch {
132
+ return false;
133
+ }
134
+ }
135
+ return true;
136
+ }
137
+ async function validateArtifacts(ctx, system, phase, module) {
138
+ const out = [];
139
+ for (const spec of PHASES[phase].artifacts) {
140
+ const rel = spec.path(system, module);
141
+ const abs = resolve(ctx.options.root, rel);
142
+ const present = await pathExists(abs);
143
+ const valid = present ? await validateArtifactShape(abs, spec.kind) : false;
144
+ out.push({ path: rel, kind: spec.kind, present, valid });
145
+ }
146
+ return out;
147
+ }
148
+ function allRequiredValid(phase, artifacts) {
149
+ return PHASES[phase].artifacts.every((spec, index) => !spec.required || (artifacts[index]?.valid ?? false));
150
+ }
151
+ // ---- commands ---------------------------------------------------------------
152
+ export async function initCommand(ctx, flags) {
153
+ const system = requireStr(ctx.options.system ?? flagStr(flags, "system"), "system", "--system <name>");
154
+ const legacyPath = requireStr(flagStr(flags, "legacy"), "legacy", "--legacy <path>");
155
+ const force = flagBool(flags, "force");
156
+ const provider = ctx.options.provider ?? "codex";
157
+ if ((await pathExists(ctx.paths.config)) && !force) {
158
+ throw new ToolError("already initialized (use --force to overwrite config)", 2, "invalid-usage");
159
+ }
160
+ const config = { schemaVersion: 1, provider, model: ctx.options.model };
161
+ await writeJson(ctx.paths.config, config);
162
+ const baseSha = (await isGitRepo(ctx.options.root)) ? await headSha(ctx.options.root) : null;
163
+ const now = new Date().toISOString();
164
+ const project = (await pathExists(ctx.paths.project))
165
+ ? await readJson(ctx.paths.project, projectRecordSchema)
166
+ : { schemaVersion: 1, systems: [] };
167
+ const systems = project.systems.filter((s) => s.name !== system);
168
+ systems.push({ name: system, legacyPath, baseSha, createdAt: now });
169
+ await writeJson(ctx.paths.project, { schemaVersion: 1, systems });
170
+ return { stateDir: ctx.paths.stateDir, system, legacyPath, baseSha, provider };
171
+ }
172
+ export async function statusCommand(ctx, flags) {
173
+ const { system } = await resolveSystem(ctx, flags);
174
+ const phases = [];
175
+ for (const phase of PHASE_NAMES) {
176
+ const record = await loadPhase(ctx, system, phase);
177
+ phases.push({
178
+ phase,
179
+ mutating: PHASES[phase].mutating,
180
+ gated: PHASES[phase].gated,
181
+ status: record?.status ?? "pending",
182
+ gate: record?.gate?.decision ?? null,
183
+ artifacts: record?.artifacts ?? [],
184
+ blockedBy: await blockers(ctx, system, phase),
185
+ });
186
+ }
187
+ return { system, phases };
188
+ }
189
+ export async function nextCommand(ctx, flags) {
190
+ const resolved = await resolveSystem(ctx, flags);
191
+ const module = flagStr(flags, "module");
192
+ const vision = flagStr(flags, "vision");
193
+ for (const phase of PHASE_NAMES) {
194
+ const record = await loadPhase(ctx, resolved.system, phase);
195
+ const status = record?.status ?? "pending";
196
+ if (status === "validated" || status === "gate-approved")
197
+ continue;
198
+ if (PHASES[phase].gated && (status === "produced" || status === "gate-rejected")) {
199
+ return {
200
+ phase,
201
+ mutating: PHASES[phase].mutating,
202
+ blockedBy: [
203
+ `awaiting human gate decision (legacymaxxing gate ${phase} --approve|--reject)`,
204
+ ],
205
+ prompt: null,
206
+ };
207
+ }
208
+ const blocked = await blockers(ctx, resolved.system, phase);
209
+ if (blocked.length > 0)
210
+ continue;
211
+ const prompt = await assemblePrompt(ctx, resolved, phase, module, vision);
212
+ return { phase, mutating: PHASES[phase].mutating, blockedBy: [], prompt };
213
+ }
214
+ return { phase: null, mutating: false, blockedBy: [], prompt: null, message: "all phases done" };
215
+ }
216
+ export async function runCommand(ctx, flags) {
217
+ const phase = parsePhaseArg(flags);
218
+ const resolved = await resolveSystem(ctx, flags);
219
+ const config = await readConfig(ctx);
220
+ const spec = PHASES[phase];
221
+ const blocked = await blockers(ctx, resolved.system, phase);
222
+ if (blocked.length > 0) {
223
+ throw new ToolError(`phase ${phase} blocked: ${blocked.join("; ")}`, 6, "validation-failed");
224
+ }
225
+ const module = flagStr(flags, "module");
226
+ const vision = flagStr(flags, "vision");
227
+ if (spec.needsModule && !module) {
228
+ throw new ToolError(`phase ${phase} requires --module <name>`, 2, "invalid-usage");
229
+ }
230
+ if (spec.needsVision && !vision) {
231
+ throw new ToolError(`phase ${phase} requires --vision <text>`, 2, "invalid-usage");
232
+ }
233
+ if (spec.mutating) {
234
+ if (!flagBool(flags, "apply") && !flagBool(flags, "yes")) {
235
+ throw new ToolError(`mutating phase ${phase} requires --apply`, 2, "invalid-usage");
236
+ }
237
+ await assertCleanWorktree(ctx.options.root, STATE_EXCLUDE);
238
+ }
239
+ const providerName = ctx.options.provider ?? config.provider;
240
+ const provider = providerByName(providerName);
241
+ const model = ctx.options.model ?? config.model;
242
+ const startedAt = new Date().toISOString();
243
+ const runId = makeRunId(resolved.system, phase, resolved.baseSha, startedAt);
244
+ const lock = lockPath(ctx.paths.stateDir, resolved.system, phase);
245
+ await claim(lock, runId);
246
+ try {
247
+ progress(ctx, `running ${phase} on ${resolved.system} via ${providerName}`);
248
+ const prompt = await assemblePrompt(ctx, resolved, phase, module, vision);
249
+ const raw = await provider.runPhase({
250
+ root: ctx.options.root,
251
+ system: resolved.system,
252
+ phase,
253
+ prompt,
254
+ mutating: spec.mutating,
255
+ module,
256
+ vision,
257
+ options: { model, noInput: ctx.options.noInput },
258
+ });
259
+ const { output, dropped } = partitionArtifacts(raw, `${phase} output`);
260
+ if (spec.mutating && resolved.baseSha) {
261
+ const head = await headSha(ctx.options.root);
262
+ if (head && head !== resolved.baseSha) {
263
+ progress(ctx, `note: HEAD moved since plan time (${resolved.baseSha} → ${head})`);
264
+ }
265
+ }
266
+ const artifacts = await validateArtifacts(ctx, resolved.system, phase, module);
267
+ const ok = allRequiredValid(phase, artifacts);
268
+ const status = ok ? (spec.gated ? "produced" : "validated") : "failed";
269
+ const now = new Date().toISOString();
270
+ const existing = await loadPhase(ctx, resolved.system, phase);
271
+ const record = {
272
+ schemaVersion: 1,
273
+ system: resolved.system,
274
+ phase,
275
+ status,
276
+ artifacts,
277
+ runId,
278
+ baseSha: resolved.baseSha,
279
+ gate: existing?.gate ?? null,
280
+ createdAt: existing?.createdAt ?? now,
281
+ updatedAt: now,
282
+ };
283
+ await savePhase(ctx, record);
284
+ const run = {
285
+ schemaVersion: 1,
286
+ runId,
287
+ system: resolved.system,
288
+ phase,
289
+ provider: providerName,
290
+ model,
291
+ mutating: spec.mutating,
292
+ startedAt,
293
+ finishedAt: now,
294
+ status: ok ? "ok" : "failed",
295
+ droppedArtifacts: dropped,
296
+ summary: output.summary,
297
+ };
298
+ await writeJson(runRecordPath(ctx.paths.stateDir, runId), run);
299
+ if (!ok) {
300
+ throw new ToolError(`phase ${phase} produced incomplete artifacts: ${artifacts
301
+ .filter((a) => !a.valid)
302
+ .map((a) => a.path)
303
+ .join(", ")}`, 6, "validation-failed");
304
+ }
305
+ return { phase, status, system: resolved.system, artifacts, runId, dropped };
306
+ }
307
+ finally {
308
+ await release(lock);
309
+ }
310
+ }
311
+ export async function loopCommand(ctx, flags) {
312
+ const resolved = await resolveSystem(ctx, flags);
313
+ const until = flagStr(flags, "until");
314
+ const ran = [];
315
+ for (const phase of PHASE_NAMES) {
316
+ if (PHASES[phase].mutating)
317
+ break; // loop never runs a mutating phase
318
+ const record = await loadPhase(ctx, resolved.system, phase);
319
+ const status = record?.status ?? "pending";
320
+ if (status === "validated" || status === "gate-approved") {
321
+ if (until === phase)
322
+ break;
323
+ continue;
324
+ }
325
+ if (PHASES[phase].gated && (status === "produced" || status === "gate-rejected")) {
326
+ return { ran, blockedAt: phase, gate: "awaiting decision" };
327
+ }
328
+ const blocked = await blockers(ctx, resolved.system, phase);
329
+ if (blocked.length > 0)
330
+ return { ran, blockedAt: phase, blockedBy: blocked };
331
+ await runCommand(ctx, { phase, system: resolved.system });
332
+ ran.push(phase);
333
+ if (PHASES[phase].gated)
334
+ return { ran, blockedAt: phase, gate: "awaiting decision" };
335
+ if (until === phase)
336
+ break;
337
+ }
338
+ return { ran, blockedAt: null };
339
+ }
340
+ export async function gateCommand(ctx, flags) {
341
+ const phase = parsePhaseArg(flags);
342
+ if (!PHASES[phase].gated) {
343
+ throw new ToolError(`phase ${phase} has no gate`, 2, "invalid-usage");
344
+ }
345
+ const { system: sys } = await resolveSystem(ctx, flags);
346
+ const approve = flagBool(flags, "approve");
347
+ const reject = flagBool(flags, "reject");
348
+ if (approve === reject) {
349
+ throw new ToolError("gate requires exactly one of --approve | --reject", 2, "invalid-usage");
350
+ }
351
+ const record = await loadPhase(ctx, sys, phase);
352
+ if (!record)
353
+ throw new ToolError(`phase ${phase} has not been run yet`, 2, "invalid-usage");
354
+ if (!["produced", "gate-approved", "gate-rejected"].includes(record.status)) {
355
+ throw new ToolError(`phase ${phase} is not awaiting a gate (status: ${record.status})`, 6, "validation-failed");
356
+ }
357
+ const now = new Date().toISOString();
358
+ const decision = approve ? "approve" : "reject";
359
+ await savePhase(ctx, {
360
+ ...record,
361
+ status: approve ? "gate-approved" : "gate-rejected",
362
+ gate: { decision, note: flagStr(flags, "note"), decidedAt: now },
363
+ updatedAt: now,
364
+ });
365
+ return { phase, system: sys, decision, decidedAt: now };
366
+ }
367
+ export async function reportCommand(ctx, flags) {
368
+ const { system: sys } = await resolveSystem(ctx, flags);
369
+ const rows = [];
370
+ for (const phase of PHASE_NAMES) {
371
+ const record = await loadPhase(ctx, sys, phase);
372
+ rows.push({
373
+ phase,
374
+ status: record?.status ?? "pending",
375
+ gate: record?.gate?.decision ?? null,
376
+ artifacts: record?.artifacts ?? [],
377
+ });
378
+ }
379
+ return { system: sys, phases: rows, markdown: renderReport(sys, rows) };
380
+ }
381
+ export async function revalidateCommand(ctx, flags) {
382
+ const phase = parsePhaseArg(flags);
383
+ const { system: sys } = await resolveSystem(ctx, flags);
384
+ const record = await loadPhase(ctx, sys, phase);
385
+ if (!record)
386
+ throw new ToolError(`phase ${phase} has not been run yet`, 2, "invalid-usage");
387
+ const artifacts = [];
388
+ for (const artifact of record.artifacts) {
389
+ const abs = resolve(ctx.options.root, artifact.path);
390
+ const present = await pathExists(abs);
391
+ const valid = present ? await validateArtifactShape(abs, artifact.kind) : false;
392
+ artifacts.push({ path: artifact.path, kind: artifact.kind, present, valid });
393
+ }
394
+ const ok = allRequiredValid(phase, artifacts);
395
+ const status = ok
396
+ ? PHASES[phase].gated
397
+ ? record.gate?.decision === "approve"
398
+ ? "gate-approved"
399
+ : "produced"
400
+ : "validated"
401
+ : "failed";
402
+ await savePhase(ctx, { ...record, artifacts, status, updatedAt: new Date().toISOString() });
403
+ return { phase, system: sys, status, artifacts };
404
+ }
405
+ export async function doctorCommand(ctx, _flags) {
406
+ const checks = [];
407
+ let providerName = ctx.options.provider ?? "codex";
408
+ try {
409
+ providerName = ctx.options.provider ?? (await readConfig(ctx)).provider;
410
+ }
411
+ catch {
412
+ // not initialized yet — fall back to default/flag provider
413
+ }
414
+ try {
415
+ const detail = await providerByName(providerName).check(ctx.options.root);
416
+ checks.push({ name: `provider:${providerName}`, ok: true, detail });
417
+ }
418
+ catch (error) {
419
+ checks.push({ name: `provider:${providerName}`, ok: false, detail: errMessage(error) });
420
+ }
421
+ for (const tool of ["scc", "cloc", "lizard"]) {
422
+ const ok = await hasBinary(tool);
423
+ checks.push({ name: `tool:${tool}`, ok, detail: ok ? "present" : "missing (optional)" });
424
+ }
425
+ const stateOk = await pathExists(ctx.paths.config);
426
+ checks.push({ name: "state", ok: stateOk, detail: ctx.paths.stateDir });
427
+ const ready = checks
428
+ .filter((c) => c.name.startsWith("provider:") || c.name === "state")
429
+ .every((c) => c.ok);
430
+ return { ready, checks, markdown: renderDoctor(ready, checks) };
431
+ }
432
+ export async function cleanLocksCommand(ctx, _flags) {
433
+ const locks = await listLocks(ctx.paths.stateDir);
434
+ for (const name of locks) {
435
+ await release(resolve(ctx.paths.locks, name));
436
+ }
437
+ return { removed: locks.length, locks };
438
+ }
439
+ function renderReport(system, rows) {
440
+ const lines = [
441
+ `# legacymaxxing report — ${system}`,
442
+ "",
443
+ "| phase | status | gate | artifacts (valid/total) |",
444
+ "| --- | --- | --- | --- |",
445
+ ];
446
+ for (const row of rows) {
447
+ const valid = row.artifacts.filter((a) => a.valid).length;
448
+ lines.push(`| ${row.phase} | ${row.status} | ${row.gate ?? "-"} | ${valid}/${row.artifacts.length} |`);
449
+ }
450
+ lines.push("");
451
+ return lines.join("\n");
452
+ }
453
+ function renderDoctor(ready, checks) {
454
+ const lines = [`# legacymaxxing doctor — ${ready ? "ready" : "not ready"}`, ""];
455
+ for (const check of checks) {
456
+ lines.push(`- [${check.ok ? "x" : " "}] ${check.name}: ${check.detail}`);
457
+ }
458
+ lines.push("");
459
+ return lines.join("\n");
460
+ }
461
+ async function hasBinary(name) {
462
+ try {
463
+ await exec(name, ["--version"]);
464
+ return true;
465
+ }
466
+ catch {
467
+ return false;
468
+ }
469
+ }
@@ -0,0 +1,25 @@
1
+ import { type StatePaths } from "./state.js";
2
+ export type Flags = Record<string, string | boolean>;
3
+ export type GlobalOptions = {
4
+ root: string;
5
+ stateDir: string;
6
+ config: string | null;
7
+ system: string | null;
8
+ provider: string | null;
9
+ model: string | null;
10
+ json: boolean;
11
+ plain: boolean;
12
+ quiet: boolean;
13
+ verbose: boolean;
14
+ debug: boolean;
15
+ noColor: boolean;
16
+ noInput: boolean;
17
+ };
18
+ export type Context = {
19
+ options: GlobalOptions;
20
+ paths: StatePaths;
21
+ isTty: boolean;
22
+ };
23
+ /** Precedence: flags > env (LEGACYMAXXING_*) > defaults. */
24
+ export declare function resolveGlobalOptions(flags: Flags, env: NodeJS.ProcessEnv): GlobalOptions;
25
+ export declare function makeContext(options: GlobalOptions): Context;
@@ -0,0 +1,36 @@
1
+ import { join, resolve } from "node:path";
2
+ import { statePaths } from "./state.js";
3
+ function asString(value) {
4
+ return typeof value === "string" ? value : null;
5
+ }
6
+ function asBool(value) {
7
+ return value === true || value === "true";
8
+ }
9
+ /** Precedence: flags > env (LEGACYMAXXING_*) > defaults. */
10
+ export function resolveGlobalOptions(flags, env) {
11
+ const root = resolve(asString(flags["root"]) ?? env["LEGACYMAXXING_ROOT"] ?? process.cwd());
12
+ const stateDir = resolve(asString(flags["stateDir"]) ?? env["LEGACYMAXXING_STATE_DIR"] ?? join(root, ".legacymaxxing"));
13
+ return {
14
+ root,
15
+ stateDir,
16
+ config: asString(flags["config"]),
17
+ system: asString(flags["system"]),
18
+ provider: asString(flags["provider"]) ?? env["LEGACYMAXXING_PROVIDER"] ?? null,
19
+ model: asString(flags["model"]) ?? env["LEGACYMAXXING_MODEL"] ?? null,
20
+ json: asBool(flags["json"]),
21
+ plain: asBool(flags["plain"]),
22
+ quiet: asBool(flags["quiet"]),
23
+ verbose: asBool(flags["verbose"]),
24
+ debug: asBool(flags["debug"]),
25
+ noColor: asBool(flags["noColor"]),
26
+ noInput: asBool(flags["noInput"]),
27
+ };
28
+ }
29
+ export function makeContext(options) {
30
+ const paths = statePaths(options.stateDir);
31
+ return {
32
+ options,
33
+ paths: options.config ? { ...paths, config: resolve(options.config) } : paths,
34
+ isTty: Boolean(process.stdout.isTTY),
35
+ };
36
+ }
@@ -0,0 +1,3 @@
1
+ import type { StackDetector } from "./types.js";
2
+ /** Cheapest possible language signal: count files by extension. */
3
+ export declare const extensionDetector: StackDetector;
@@ -0,0 +1,72 @@
1
+ import { readdir } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ const EXT_LANG = {
4
+ ".cbl": "cobol",
5
+ ".cob": "cobol",
6
+ ".cpy": "cobol",
7
+ ".java": "java",
8
+ ".jsp": "java",
9
+ ".js": "javascript",
10
+ ".mjs": "javascript",
11
+ ".ts": "typescript",
12
+ ".py": "python",
13
+ ".rb": "ruby",
14
+ ".php": "php",
15
+ ".go": "go",
16
+ ".c": "c",
17
+ ".h": "c",
18
+ ".cpp": "cpp",
19
+ ".cs": "csharp",
20
+ ".pl": "perl",
21
+ ".pas": "pascal",
22
+ ".f": "fortran",
23
+ ".for": "fortran",
24
+ };
25
+ async function walk(dir, budget, out) {
26
+ if (budget.left <= 0)
27
+ return;
28
+ let entries;
29
+ try {
30
+ entries = await readdir(dir, { withFileTypes: true });
31
+ }
32
+ catch {
33
+ return;
34
+ }
35
+ for (const entry of entries) {
36
+ if (budget.left <= 0)
37
+ return;
38
+ if (entry.name.startsWith(".") || entry.name === "node_modules")
39
+ continue;
40
+ const full = join(dir, entry.name);
41
+ if (entry.isDirectory()) {
42
+ await walk(full, budget, out);
43
+ }
44
+ else {
45
+ out.push(entry.name);
46
+ budget.left -= 1;
47
+ }
48
+ }
49
+ }
50
+ /** Cheapest possible language signal: count files by extension. */
51
+ export const extensionDetector = {
52
+ name: "extension",
53
+ detect: async (root, ctx) => {
54
+ const files = [];
55
+ await walk(root, { left: ctx.maxFiles }, files);
56
+ const counts = new Map();
57
+ for (const file of files) {
58
+ const dot = file.lastIndexOf(".");
59
+ if (dot < 0)
60
+ continue;
61
+ const language = EXT_LANG[file.slice(dot).toLowerCase()];
62
+ if (!language)
63
+ continue;
64
+ counts.set(language, (counts.get(language) ?? 0) + 1);
65
+ }
66
+ return [...counts.entries()].map(([language, n]) => ({
67
+ language,
68
+ files: n,
69
+ markers: ["extension"],
70
+ }));
71
+ },
72
+ };
@@ -0,0 +1,5 @@
1
+ import type { DetectCtx, StackDetector, StackSeed } from "./types.js";
2
+ /** Register breadth here. Each detector runs in parallel; seeds are deduped by language. */
3
+ export declare const detectors: StackDetector[];
4
+ export declare function dedupeSeeds(seeds: StackSeed[]): StackSeed[];
5
+ export declare function runDetectors(root: string, ctx: DetectCtx): Promise<StackSeed[]>;
@@ -0,0 +1,28 @@
1
+ import { extensionDetector } from "./extension.js";
2
+ /** Register breadth here. Each detector runs in parallel; seeds are deduped by language. */
3
+ export const detectors = [extensionDetector];
4
+ export function dedupeSeeds(seeds) {
5
+ const byLanguage = new Map();
6
+ for (const seed of seeds) {
7
+ const existing = byLanguage.get(seed.language);
8
+ if (existing) {
9
+ existing.files += seed.files;
10
+ for (const marker of seed.markers) {
11
+ if (!existing.markers.includes(marker))
12
+ existing.markers.push(marker);
13
+ }
14
+ }
15
+ else {
16
+ byLanguage.set(seed.language, {
17
+ language: seed.language,
18
+ files: seed.files,
19
+ markers: [...seed.markers],
20
+ });
21
+ }
22
+ }
23
+ return [...byLanguage.values()].toSorted((a, b) => b.files - a.files || a.language.localeCompare(b.language));
24
+ }
25
+ export async function runDetectors(root, ctx) {
26
+ const all = (await Promise.all(detectors.map((detector) => detector.detect(root, ctx)))).flat();
27
+ return dedupeSeeds(all);
28
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * The breadth axis for the swarm. A StackDetector contributes deterministic
3
+ * "what languages/stacks are in this legacy tree" seeds to the phase prompts —
4
+ * no network, no model. Adding COBOL/Java specificity is one new file + one
5
+ * array entry in index.ts; the core never changes.
6
+ */
7
+ export type StackSeed = {
8
+ language: string;
9
+ files: number;
10
+ markers: string[];
11
+ };
12
+ export type DetectCtx = {
13
+ /** Cap the walk so a huge legacy tree can't stall the deterministic pre-step. */
14
+ maxFiles: number;
15
+ };
16
+ export type StackDetector = {
17
+ name: string;
18
+ detect(root: string, ctx: DetectCtx): Promise<StackSeed[]>;
19
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Typed error: a stable string `code` (asserted in tests) plus a numeric
3
+ * `exitCode` (what the OS sees). Conversion to a process exit happens ONLY at the
4
+ * top-level catch in cli.ts. Reserved exit codes:
5
+ * 1 runtime · 2 invalid usage/preconditions · 3 dirty worktree ·
6
+ * 4 provider auth/config · 5 provider quota · 6 validation/gate failed ·
7
+ * 7 lock conflict / external-CLI failure · 8 malformed model output.
8
+ */
9
+ export declare class ToolError extends Error {
10
+ readonly exitCode: number;
11
+ readonly code: string;
12
+ constructor(message: string, exitCode?: number, code?: string);
13
+ }
14
+ export declare function errMessage(error: unknown): string;