agent-rules-init 0.4.0 → 0.5.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/dist/cli.d.ts CHANGED
@@ -2,7 +2,7 @@ import { type GeneratedFile, type WriteResult } from "./core/writer.js";
2
2
  import { type Lang } from "./core/i18n.js";
3
3
  import { type AgentRulesConfig } from "./core/config.js";
4
4
  import { type PromptFn } from "./core/prompt-engine.js";
5
- import { type ExecFn } from "./core/llm-bridge.js";
5
+ import { type AssistantId, type ExecFn } from "./core/llm-bridge.js";
6
6
  import type { RepoFacts } from "./core/types.js";
7
7
  export interface RunCliOptions {
8
8
  promptFn?: PromptFn;
@@ -11,8 +11,14 @@ export interface RunCliOptions {
11
11
  lang?: Lang;
12
12
  /** Generate results without changing the filesystem. */
13
13
  dryRun?: boolean;
14
- /** Do not prompt or offer AI polishing. */
14
+ /** Do not prompt or offer AI enrichment. */
15
15
  nonInteractive?: boolean;
16
+ /** Run AI enrichment without asking, even without a TTY. Ignored when skipLlm or noAi apply. */
17
+ enrich?: boolean;
18
+ /** Assistant to enrich with; defaults to the first one installed. */
19
+ assistant?: AssistantId;
20
+ /** Model forwarded verbatim to the assistant CLI; its default when omitted. */
21
+ model?: string;
16
22
  /** Receives the rendered files before they are written (or planned). */
17
23
  onGeneratedFiles?: (files: readonly GeneratedFile[]) => void;
18
24
  /** Preloaded repository configuration; loaded from disk when omitted. */
@@ -28,6 +34,9 @@ export interface CliRunOptions {
28
34
  check?: true;
29
35
  json?: true;
30
36
  nonInteractive?: true;
37
+ enrich?: true;
38
+ assistant?: AssistantId;
39
+ model?: string;
31
40
  }
32
41
  export type CliAction = ({
33
42
  kind: "run";
@@ -38,6 +47,12 @@ export type CliAction = ({
38
47
  } | {
39
48
  kind: "invalid-lang";
40
49
  value: string;
50
+ } | {
51
+ kind: "invalid-assistant";
52
+ value: string;
53
+ } | {
54
+ kind: "missing-value";
55
+ flag: string;
41
56
  } | {
42
57
  kind: "unknown";
43
58
  flag: string;
package/dist/cli.js CHANGED
@@ -11,7 +11,7 @@ import { loadConfig } from "./core/config.js";
11
11
  import { applyProjectExcludes, buildPackageUnits } from "./core/project-units.js";
12
12
  import { renderProjectUnitAgents } from "./core/project-unit-output.js";
13
13
  import { collectLowConfidenceQuestions, askQuestions, applyAnswers, makeDefaultPromptFn, hasInteractiveTty, } from "./core/prompt-engine.js";
14
- import { detectAvailableAssistants, polishFilesWithAssistant, defaultExecFn } from "./core/llm-bridge.js";
14
+ import { detectAvailableAssistants, enrichFilesWithAssistant, defaultExecFn, } from "./core/llm-bridge.js";
15
15
  import { jsTsPack } from "./packs/js-ts.js";
16
16
  import { pythonPack } from "./packs/python.js";
17
17
  import { javaPack } from "./packs/java.js";
@@ -44,6 +44,23 @@ const ALL_PACKS = [
44
44
  scalaPack,
45
45
  rPack,
46
46
  ];
47
+ // Final-name docs the tool itself would generate; when they already exist they carry the
48
+ // team's hand-written intent, so enrichment must integrate them instead of ignoring them.
49
+ const EXISTING_DOC_PATHS = ["CLAUDE.md", "AGENTS.md", ".github/copilot-instructions.md"];
50
+ const MAX_EXISTING_DOC_CHARS = 20_000;
51
+ function readExistingDocs(rootPath) {
52
+ const docs = [];
53
+ for (const relativePath of EXISTING_DOC_PATHS) {
54
+ const absolutePath = path.join(rootPath, relativePath);
55
+ if (!fs.existsSync(absolutePath))
56
+ continue;
57
+ const content = fs.readFileSync(absolutePath, "utf8");
58
+ if (content.trim() === "")
59
+ continue;
60
+ docs.push({ path: relativePath, content: content.slice(0, MAX_EXISTING_DOC_CHARS) });
61
+ }
62
+ return docs;
63
+ }
47
64
  export async function runCli(rootPath, options = {}) {
48
65
  const loadedConfig = options.config ? { config: options.config, warnings: [] } : loadConfig(rootPath);
49
66
  const config = loadedConfig.config;
@@ -93,15 +110,49 @@ export async function runCli(rootPath, options = {}) {
93
110
  if (scoped)
94
111
  files.push(scoped);
95
112
  }
96
- if (!options.skipLlm && !config.noAi && !options.nonInteractive && hasInteractiveTty()) {
113
+ const wantsEnrich = options.enrich === true;
114
+ const canOfferEnrich = !options.nonInteractive && hasInteractiveTty();
115
+ if (!options.skipLlm && !config.noAi && (wantsEnrich || canOfferEnrich)) {
116
+ // With --enrich there may be no TTY (scripts, CI); route progress through stderr so
117
+ // stdout stays parseable in --json mode.
118
+ const notify = canOfferEnrich ? (message) => clack.log.info(message) : (message) => console.warn(message);
97
119
  const assistants = await detectAvailableAssistants(execFn);
98
- if (assistants.length > 0) {
99
- const chosenAssistant = assistants[0];
100
- clack.log.info(ui.polishDetected(chosenAssistant));
101
- const usePolish = await clack.confirm({ message: ui.polishConfirm(chosenAssistant) });
102
- if (usePolish === true) {
103
- const polished = await polishFilesWithAssistant(chosenAssistant, files, execFn, lang);
104
- files.splice(0, files.length, ...polished);
120
+ const requested = options.assistant;
121
+ if (requested && !assistants.includes(requested)) {
122
+ console.warn(ui.assistantNotAvailable(requested));
123
+ }
124
+ else if (assistants.length === 0) {
125
+ if (wantsEnrich)
126
+ console.warn(ui.enrichNoAssistant);
127
+ }
128
+ else {
129
+ const chosenAssistant = requested ?? assistants[0];
130
+ let proceed = wantsEnrich;
131
+ if (!proceed) {
132
+ clack.log.info(ui.enrichDetected(chosenAssistant));
133
+ proceed = (await clack.confirm({ message: ui.enrichConfirm(chosenAssistant) })) === true;
134
+ }
135
+ if (proceed) {
136
+ const spinner = canOfferEnrich ? clack.spinner() : undefined;
137
+ if (spinner)
138
+ spinner.start(ui.enrichWorking(chosenAssistant));
139
+ else
140
+ notify(ui.enrichWorking(chosenAssistant));
141
+ const enriched = await enrichFilesWithAssistant(chosenAssistant, files, {
142
+ execFn,
143
+ lang,
144
+ cwd: rootPath,
145
+ mustKeep: facts.canonical.map((c) => c.command),
146
+ existingDocs: readExistingDocs(rootPath),
147
+ model: options.model,
148
+ });
149
+ const changed = enriched.some((file, i) => file.content !== files[i].content);
150
+ const outcome = changed ? ui.enrichDone : ui.enrichKept;
151
+ if (spinner)
152
+ spinner.stop(outcome);
153
+ else
154
+ notify(outcome);
155
+ files.splice(0, files.length, ...enriched);
105
156
  }
106
157
  }
107
158
  }
@@ -117,6 +168,9 @@ export async function runCli(rootPath, options = {}) {
117
168
  function isLang(value) {
118
169
  return value === "es" || value === "en";
119
170
  }
171
+ function isAssistant(value) {
172
+ return value === "claude" || value === "codex";
173
+ }
120
174
  export function resolveCliAction(argv) {
121
175
  const options = {};
122
176
  for (let i = 0; i < argv.length; i++) {
@@ -148,6 +202,24 @@ export function resolveCliAction(argv) {
148
202
  options.nonInteractive = true;
149
203
  continue;
150
204
  }
205
+ if (arg === "--enrich") {
206
+ options.enrich = true;
207
+ continue;
208
+ }
209
+ if (arg === "--assistant" || arg.startsWith("--assistant=")) {
210
+ const value = arg.startsWith("--assistant=") ? arg.slice("--assistant=".length) : argv[++i] ?? "";
211
+ if (!isAssistant(value))
212
+ return { kind: "invalid-assistant", value };
213
+ options.assistant = value;
214
+ continue;
215
+ }
216
+ if (arg === "--model" || arg.startsWith("--model=")) {
217
+ const value = arg.startsWith("--model=") ? arg.slice("--model=".length) : argv[++i] ?? "";
218
+ if (value === "")
219
+ return { kind: "missing-value", flag: "--model" };
220
+ options.model = value;
221
+ continue;
222
+ }
151
223
  return { kind: "unknown", flag: arg };
152
224
  }
153
225
  return { kind: "run", ...options };
@@ -183,9 +255,24 @@ export async function main() {
183
255
  process.exitCode = 1;
184
256
  return;
185
257
  }
258
+ if (action.kind === "invalid-assistant") {
259
+ console.error(`${ui.invalidAssistant(action.value)}\n\n${usageWithAutomationOptions(ui)}`);
260
+ process.exitCode = 1;
261
+ return;
262
+ }
263
+ if (action.kind === "missing-value") {
264
+ console.error(`${ui.missingFlagValue(action.flag)}\n\n${usageWithAutomationOptions(ui)}`);
265
+ process.exitCode = 1;
266
+ return;
267
+ }
186
268
  const machineOutput = action.json === true;
187
269
  const planningOnly = action.dryRun === true || action.check === true;
188
270
  const nonInteractive = action.nonInteractive === true || machineOutput || planningOnly;
271
+ // Enrichment output is non-deterministic, so --check (freshness comparison) must stay
272
+ // on the deterministic baseline.
273
+ const enrich = action.enrich === true && action.check !== true;
274
+ if (action.enrich === true && action.check === true)
275
+ console.warn("--enrich is ignored with --check.");
189
276
  if (!machineOutput)
190
277
  clack.intro("agent-rules-init");
191
278
  if (!machineOutput && !nonInteractive && !hasInteractiveTty()) {
@@ -206,7 +293,10 @@ export async function main() {
206
293
  config: loadedConfig.config,
207
294
  dryRun: planningOnly,
208
295
  nonInteractive,
209
- skipLlm: nonInteractive,
296
+ skipLlm: nonInteractive && !enrich,
297
+ enrich,
298
+ assistant: action.assistant,
299
+ model: action.model,
210
300
  onGeneratedFiles: (files) => {
211
301
  generatedFiles = files;
212
302
  },
@@ -28,13 +28,21 @@ export interface UiTexts {
28
28
  automationUsage: string;
29
29
  unknownOption: (flag: string) => string;
30
30
  invalidLang: (value: string) => string;
31
+ invalidAssistant: (value: string) => string;
32
+ missingFlagValue: (flag: string) => string;
33
+ assistantNotAvailable: (assistant: string) => string;
31
34
  noTtyWarning: string;
32
35
  skippedQuestion: (message: string) => string;
33
- polishDetected: (assistant: string) => string;
34
- polishConfirm: (assistant: string) => string;
35
- polishFailed: (assistant: string, error: string) => string;
36
- polishPrompt: (content: string) => string;
37
- polishBatchPrompt: (filesJson: string) => string;
36
+ enrichDetected: (assistant: string) => string;
37
+ enrichConfirm: (assistant: string) => string;
38
+ enrichWorking: (assistant: string) => string;
39
+ enrichDone: string;
40
+ enrichKept: string;
41
+ enrichFailed: (assistant: string, error: string) => string;
42
+ enrichNoAssistant: string;
43
+ enrichEvidenceDropped: (paths: readonly string[]) => string;
44
+ enrichRetrying: (assistant: string) => string;
45
+ enrichPrompt: (filesJson: string, mustKeep: readonly string[], existingDocsJson?: string) => string;
38
46
  fileSkipped: (path: string) => string;
39
47
  outroWritten: string;
40
48
  outroNothing: string;
package/dist/core/i18n.js CHANGED
@@ -75,6 +75,7 @@ export const UI = {
75
75
 
76
76
  Uso:
77
77
  npx agent-rules-init escanea el directorio actual y genera los archivos *.generated.*
78
+ npx agent-rules-init --enrich además, usa tu claude/codex instalado para analizar el código y enriquecer el resultado
78
79
  npx agent-rules-init --lang es fuerza el idioma del contenido (es|en); por defecto se detecta del sistema
79
80
  npx agent-rules-init --help muestra esta ayuda
80
81
  npx agent-rules-init --version muestra la versión
@@ -85,17 +86,43 @@ revisa su contenido y quita el sufijo para activarlos.`,
85
86
  --dry-run renderiza y muestra archivos sin escribir
86
87
  --check termina con error si faltan archivos generados; nunca escribe
87
88
  --json emite un único resultado JSON legible por máquinas
88
- --non-interactive omite preguntas y el pulido con IA`,
89
+ --non-interactive omite preguntas y la oferta de enriquecimiento con IA
90
+ --enrich fuerza el enriquecimiento con IA sin preguntar (también sin TTY; combinable con --non-interactive)
91
+ --assistant <id> elige el asistente para enriquecer: claude o codex (por defecto, el primero instalado)
92
+ --model <modelo> modelo a usar, pasado tal cual al asistente (p. ej. haiku, gpt-5.5); por defecto, el del asistente`,
89
93
  unknownOption: (flag) => `Opción no reconocida: ${flag}`,
90
94
  invalidLang: (value) => `Valor de --lang no válido: "${value}" (usa "es" o "en").`,
95
+ invalidAssistant: (value) => `Valor de --assistant no válido: "${value}" (usa "claude" o "codex").`,
96
+ missingFlagValue: (flag) => `La opción ${flag} requiere un valor.`,
97
+ assistantNotAvailable: (assistant) => `Se pidió ${assistant} con --assistant pero no está instalado; se conserva la versión generada.`,
91
98
  noTtyWarning: "No se detectó una terminal interactiva (esto pasa a veces en Git Bash en Windows). " +
92
- "Continuando sin preguntas ni oferta de pulido con IA; se usarán los valores detectados.",
99
+ "Continuando sin preguntas ni oferta de enriquecimiento con IA; se usarán los valores detectados.",
93
100
  skippedQuestion: (message) => `No se detectó una terminal interactiva; se omite la pregunta "${message}" y se usa el valor detectado.`,
94
- polishDetected: (assistant) => `${assistant} detectado — puede ayudar a pulir la redacción final.`,
95
- polishConfirm: (assistant) => `Se detectó ${assistant}. ¿Quieres que pula la redacción final?`,
96
- polishFailed: (assistant, error) => `No se pudo pulir el contenido con ${assistant}, se mantiene el original: ${error}`,
97
- polishPrompt: (content) => `Pule la redacción del siguiente documento de instrucciones para un agente de IA, sin cambiar su significado ni estructura. Devuelve únicamente el documento pulido, sin comentarios ni explicaciones adicionales:\n\n${content}`,
98
- polishBatchPrompt: (filesJson) => "Pule la redacción de estos archivos de instrucciones sin cambiar su significado, estructura, rutas ni formato Markdown. " +
101
+ enrichDetected: (assistant) => `${assistant} detectado — puede analizar el código de este repo y sustituir las secciones genéricas por reglas específicas verificadas.`,
102
+ enrichConfirm: (assistant) => `¿Quieres que ${assistant} analice el repositorio y enriquezca los archivos generados? Usará tu instalación de ${assistant} y puede tardar unos minutos.`,
103
+ enrichWorking: (assistant) => `${assistant} está analizando el repositorio y enriqueciendo los archivos…`,
104
+ enrichDone: "Archivos enriquecidos con lo observado en el repositorio.",
105
+ enrichKept: "No se aplicó el enriquecimiento; se conserva la versión generada.",
106
+ enrichFailed: (assistant, error) => `No se pudo enriquecer el contenido con ${assistant}, se mantiene la versión generada: ${error}`,
107
+ enrichNoAssistant: "Se pidió --enrich pero no se encontró ningún asistente (claude o codex) instalado; se conserva la versión generada.",
108
+ enrichEvidenceDropped: (paths) => `Se descartaron afirmaciones del enriquecimiento porque su evidencia citada no existe en el repo: ${paths.join(", ")}`,
109
+ enrichRetrying: (assistant) => `La respuesta de ${assistant} no pasó la validación; se reintenta una vez…`,
110
+ enrichPrompt: (filesJson, mustKeep, existingDocsJson) => "Estás ejecutándote en la raíz de un repositorio. Los siguientes archivos de instrucciones para agentes de IA " +
111
+ "se generaron solo a partir de manifiestos, CI y configuración, por lo que algunas secciones (convenciones, arquitectura, prompts) son genéricas.\n" +
112
+ "Primero investiga el repositorio real con tus herramientas de lectura: configuración de estilo (linter, formatter, pre-commit), " +
113
+ "CONTRIBUTING/README, y el código fuente y los tests suficientes para entender sus convenciones y arquitectura reales.\n" +
114
+ "Después reescribe cada archivo sustituyendo o ampliando los consejos genéricos con reglas específicas y comprobables de este repositorio, " +
115
+ "citando la evidencia de cada afirmación nueva con el formato (evidencia: `ruta/del/archivo`); las rutas citadas se verificarán contra el repo. " +
116
+ "No inventes comandos, rutas ni APIs; no afirmes nada que no hayas comprobado. " +
117
+ "Conserva el idioma, el formato Markdown y las rutas de cada archivo.\n" +
118
+ (mustKeep.length > 0
119
+ ? `Conserva literalmente estos comandos, sin modificarlos: ${mustKeep.map((c) => `\`${c}\``).join(", ")}.\n`
120
+ : "") +
121
+ (existingDocsJson
122
+ ? "El repositorio ya contiene estos documentos de instrucciones mantenidos a mano; reflejan la intención del equipo. " +
123
+ "Integra sus reglas en los archivos generados correspondientes sin contradecirlas ni perderlas.\n" +
124
+ `Documentos existentes (JSON):\n${existingDocsJson}\n`
125
+ : "") +
99
126
  "Devuelve únicamente un array JSON válido con exactamente los mismos objetos path/content y en el mismo orden, sin bloque de código ni comentarios. " +
100
127
  `Entrada JSON:\n${filesJson}`,
101
128
  fileSkipped: (path) => `${path}: ya existía, se conserva sin cambios.`,
@@ -151,6 +178,7 @@ revisa su contenido y quita el sufijo para activarlos.`,
151
178
 
152
179
  Usage:
153
180
  npx agent-rules-init scan the current directory and generate the *.generated.* files
181
+ npx agent-rules-init --enrich additionally, use your installed claude/codex to analyze the code and enrich the output
154
182
  npx agent-rules-init --lang en force the content language (es|en); defaults to the system locale
155
183
  npx agent-rules-init --help show this help
156
184
  npx agent-rules-init --version show the version
@@ -161,17 +189,43 @@ review their content and drop the suffix to activate them.`,
161
189
  --dry-run render and print files without writing
162
190
  --check exit non-zero if generated files are missing; never write
163
191
  --json emit a single machine-readable JSON result
164
- --non-interactive skip questions and AI polishing`,
192
+ --non-interactive skip questions and the AI-enrichment offer
193
+ --enrich force AI enrichment without asking (works without a TTY; composable with --non-interactive)
194
+ --assistant <id> pick the enrichment assistant: claude or codex (defaults to the first one installed)
195
+ --model <model> model to use, forwarded verbatim to the assistant (e.g. haiku, gpt-5.5); defaults to the assistant's own`,
165
196
  unknownOption: (flag) => `Unknown option: ${flag}`,
166
197
  invalidLang: (value) => `Invalid --lang value: "${value}" (use "es" or "en").`,
198
+ invalidAssistant: (value) => `Invalid --assistant value: "${value}" (use "claude" or "codex").`,
199
+ missingFlagValue: (flag) => `The ${flag} option requires a value.`,
200
+ assistantNotAvailable: (assistant) => `${assistant} was requested with --assistant but is not installed; keeping the generated version.`,
167
201
  noTtyWarning: "No interactive terminal detected (this sometimes happens in Git Bash on Windows). " +
168
- "Continuing without questions or the AI-polish offer; detected values will be used.",
202
+ "Continuing without questions or the AI-enrichment offer; detected values will be used.",
169
203
  skippedQuestion: (message) => `No interactive terminal detected; skipping the question "${message}" and using the detected value.`,
170
- polishDetected: (assistant) => `${assistant} detected — it can help polish the final wording.`,
171
- polishConfirm: (assistant) => `${assistant} was detected. Do you want it to polish the final wording?`,
172
- polishFailed: (assistant, error) => `Couldn't polish the content with ${assistant}, keeping the original: ${error}`,
173
- polishPrompt: (content) => `Polish the wording of the following instructions document for an AI agent, without changing its meaning or structure. Return only the polished document, with no extra comments or explanations:\n\n${content}`,
174
- polishBatchPrompt: (filesJson) => "Polish the wording of these instruction files without changing their meaning, structure, paths, or Markdown format. " +
204
+ enrichDetected: (assistant) => `${assistant} detected — it can analyze this repo's code and replace the generic sections with verified, repo-specific rules.`,
205
+ enrichConfirm: (assistant) => `Do you want ${assistant} to analyze the repository and enrich the generated files? It will use your ${assistant} installation and may take a few minutes.`,
206
+ enrichWorking: (assistant) => `${assistant} is analyzing the repository and enriching the files…`,
207
+ enrichDone: "Files enriched with what was observed in the repository.",
208
+ enrichKept: "Enrichment was not applied; keeping the generated version.",
209
+ enrichFailed: (assistant, error) => `Couldn't enrich the content with ${assistant}, keeping the generated version: ${error}`,
210
+ enrichNoAssistant: "--enrich was requested but no installed assistant (claude or codex) was found; keeping the generated version.",
211
+ enrichEvidenceDropped: (paths) => `Dropped enrichment claims because their cited evidence does not exist in the repo: ${paths.join(", ")}`,
212
+ enrichRetrying: (assistant) => `${assistant}'s response failed validation; retrying once…`,
213
+ enrichPrompt: (filesJson, mustKeep, existingDocsJson) => "You are running at the root of a repository. The following instruction files for AI agents were generated " +
214
+ "from manifests, CI and configuration only, so some sections (conventions, architecture, prompts) are generic.\n" +
215
+ "First investigate the actual repository with your read tools: style configuration (linter, formatter, pre-commit), " +
216
+ "CONTRIBUTING/README, and enough of the source code and tests to understand its real conventions and architecture.\n" +
217
+ "Then rewrite each file, replacing or extending the generic advice with specific, verifiable rules from this repository, " +
218
+ "citing the evidence for every new claim in the form (evidence: `path/to/file`); cited paths will be checked against the repo. " +
219
+ "Do not invent commands, paths or APIs; do not state anything you have not verified. " +
220
+ "Keep each file's language, Markdown format and path.\n" +
221
+ (mustKeep.length > 0
222
+ ? `Keep these commands verbatim, unmodified: ${mustKeep.map((c) => `\`${c}\``).join(", ")}.\n`
223
+ : "") +
224
+ (existingDocsJson
225
+ ? "The repository already contains these hand-maintained instruction documents; they reflect the team's intent. " +
226
+ "Integrate their rules into the corresponding generated files without contradicting or losing them.\n" +
227
+ `Existing documents (JSON):\n${existingDocsJson}\n`
228
+ : "") +
175
229
  "Return only a valid JSON array with exactly the same path/content objects in the same order, without a code fence or commentary. " +
176
230
  `Input JSON:\n${filesJson}`,
177
231
  fileSkipped: (path) => `${path}: already existed, left unchanged.`,
@@ -5,9 +5,24 @@ export interface ExecResult {
5
5
  stdout: string;
6
6
  exitCode: number;
7
7
  }
8
- export type ExecFn = (command: string, args: string[], stdin?: string) => Promise<ExecResult>;
8
+ export type ExecFn = (command: string, args: string[], stdin?: string, cwd?: string) => Promise<ExecResult>;
9
9
  export declare const defaultExecFn: ExecFn;
10
10
  export declare function detectAvailableAssistants(execFn?: ExecFn): Promise<AssistantId[]>;
11
- export declare function polishWithAssistant(assistant: AssistantId, content: string, execFn?: ExecFn, lang?: Lang): Promise<string>;
12
- /** Polishes typical runs in one assistant process, splitting only very large outputs. */
13
- export declare function polishFilesWithAssistant(assistant: AssistantId, files: GeneratedFile[], execFn?: ExecFn, lang?: Lang): Promise<GeneratedFile[]>;
11
+ export interface EnrichOptions {
12
+ execFn?: ExecFn;
13
+ lang?: Lang;
14
+ /** Repo root where the assistant is spawned so its read tools explore the right project. */
15
+ cwd?: string;
16
+ /** Commands that must survive verbatim (canonical test/lint/build commands). */
17
+ mustKeep?: readonly string[];
18
+ /** Hand-maintained docs already in the repo (CLAUDE.md, AGENTS.md, …) to integrate, not contradict. */
19
+ existingDocs?: readonly GeneratedFile[];
20
+ /** Model identifier forwarded verbatim to the assistant CLI; its default when omitted. */
21
+ model?: string;
22
+ }
23
+ /**
24
+ * Asks the assistant to investigate the repository it runs in and rewrite the generated
25
+ * files with repo-specific, evidence-backed guidance. Typical runs use one assistant
26
+ * process; only very large outputs are split. Falls back to the originals per batch.
27
+ */
28
+ export declare function enrichFilesWithAssistant(assistant: AssistantId, files: GeneratedFile[], options?: EnrichOptions): Promise<GeneratedFile[]>;
@@ -1,18 +1,41 @@
1
1
  import { spawn } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
2
4
  import { UI } from "./i18n.js";
3
5
  const VERSION_ARGS = {
4
6
  claude: ["--version"],
5
7
  codex: ["--version"],
6
8
  };
7
- export const defaultExecFn = (command, args, stdin) => new Promise((resolve, reject) => {
9
+ // Non-interactive invocation per assistant. claude reads the prompt from stdin with -p;
10
+ // codex has no -p: its non-interactive mode is `codex exec`, where "-" reads the
11
+ // instructions from stdin. Without --skip-git-repo-check codex refuses to run in
12
+ // directories that aren't a git repo; enrichment is read-only, so skipping is safe
13
+ // (verified against codex-cli 0.130.0). The model string is passed through untouched —
14
+ // the assistant validates it, so new models need no package update.
15
+ function printArgs(assistant, model) {
16
+ const modelArgs = model ? ["--model", model] : [];
17
+ if (assistant === "claude")
18
+ return ["-p", ...modelArgs];
19
+ return ["exec", "--skip-git-repo-check", ...modelArgs, "-"];
20
+ }
21
+ // A hung assistant (expired session, dead network) must not hang the CLI forever;
22
+ // 10 minutes comfortably covers real enrichment runs, and on expiry the rejection
23
+ // lands in the normal fallback path that keeps the deterministic files.
24
+ const EXEC_TIMEOUT_MS = 600_000;
25
+ export const defaultExecFn = (command, args, stdin, cwd) => new Promise((resolve, reject) => {
8
26
  // shell:true is only needed on Windows, to resolve npm-installed .cmd/.ps1 shims.
9
- const child = spawn(command, args, { shell: process.platform === "win32" });
27
+ // cwd matters for enrichment: the assistant explores the repo it runs in with its
28
+ // own read tools, so it must be spawned at the target repo root, not wherever the
29
+ // CLI process happens to live.
30
+ const child = spawn(command, args, { shell: process.platform === "win32", cwd, timeout: EXEC_TIMEOUT_MS });
10
31
  let stdout = "";
11
32
  child.stdout?.on("data", (chunk) => (stdout += chunk.toString()));
12
33
  child.on("error", reject);
13
34
  child.on("close", (exitCode) => {
14
35
  if (exitCode === 0)
15
36
  resolve({ stdout, exitCode });
37
+ else if (child.killed)
38
+ reject(new Error(`${command} timed out after ${EXEC_TIMEOUT_MS / 1000}s`));
16
39
  else
17
40
  reject(new Error(`${command} exited with code ${exitCode}`));
18
41
  });
@@ -35,24 +58,14 @@ export async function detectAvailableAssistants(execFn = defaultExecFn) {
35
58
  }));
36
59
  return results.filter((id) => id !== null);
37
60
  }
38
- export async function polishWithAssistant(assistant, content, execFn = defaultExecFn, lang = "es") {
39
- try {
40
- const result = await execFn(assistant, ["-p"], UI[lang].polishPrompt(content));
41
- return result.stdout.trim() || content;
42
- }
43
- catch (err) {
44
- console.warn(UI[lang].polishFailed(assistant, err.message));
45
- return content;
46
- }
47
- }
48
- const MAX_POLISH_BATCH_CHARS = 60_000;
61
+ const MAX_BATCH_CHARS = 60_000;
49
62
  function makeBatches(files) {
50
63
  const batches = [];
51
64
  let current = [];
52
65
  let currentSize = 2;
53
66
  for (const file of files) {
54
67
  const size = JSON.stringify(file).length + 1;
55
- if (current.length > 0 && currentSize + size > MAX_POLISH_BATCH_CHARS) {
68
+ if (current.length > 0 && currentSize + size > MAX_BATCH_CHARS) {
56
69
  batches.push(current);
57
70
  current = [];
58
71
  currentSize = 2;
@@ -64,10 +77,24 @@ function makeBatches(files) {
64
77
  batches.push(current);
65
78
  return batches;
66
79
  }
67
- function parsePolishedBatch(stdout, originals) {
80
+ // Despite being told to return only JSON, assistants often prepend prose ("I investigated
81
+ // the repo and...") or wrap the array in a fence. Try the strict forms first and fall back
82
+ // to the outermost [...] slice; path/count validation below rejects any wrong pick.
83
+ function extractJsonArray(stdout) {
68
84
  const trimmed = stdout.trim();
69
- const withoutFence = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i)?.[1] ?? trimmed;
70
- const parsed = JSON.parse(withoutFence);
85
+ if (trimmed.startsWith("["))
86
+ return trimmed;
87
+ const fenced = trimmed.match(/```(?:json)?\s*([\s\S]*?)\s*```/i)?.[1];
88
+ if (fenced?.trim().startsWith("["))
89
+ return fenced;
90
+ const start = trimmed.indexOf("[");
91
+ const end = trimmed.lastIndexOf("]");
92
+ if (start !== -1 && end > start)
93
+ return trimmed.slice(start, end + 1);
94
+ return trimmed;
95
+ }
96
+ function parseAssistantBatch(stdout, originals) {
97
+ const parsed = JSON.parse(extractJsonArray(stdout));
71
98
  if (!Array.isArray(parsed) || parsed.length !== originals.length) {
72
99
  throw new Error("assistant returned a different number of files");
73
100
  }
@@ -81,19 +108,92 @@ function parsePolishedBatch(stdout, originals) {
81
108
  return { path: originals[index].path, content: entry.content };
82
109
  });
83
110
  }
84
- /** Polishes typical runs in one assistant process, splitting only very large outputs. */
85
- export async function polishFilesWithAssistant(assistant, files, execFn = defaultExecFn, lang = "es") {
86
- const polished = [];
87
- for (const batch of makeBatches(files)) {
88
- try {
89
- const input = UI[lang].polishBatchPrompt(JSON.stringify(batch));
90
- const result = await execFn(assistant, ["-p"], input);
91
- polished.push(...parsePolishedBatch(result.stdout, batch));
111
+ // The assistant is free-form: it may drop the one command CI actually runs and invent a
112
+ // plausible replacement. Any lost must-keep command invalidates the whole batch.
113
+ function assertMustKeepSurvive(enriched, originals, mustKeep) {
114
+ const originalText = originals.map((f) => f.content).join("\n");
115
+ const enrichedText = enriched.map((f) => f.content).join("\n");
116
+ for (const command of mustKeep) {
117
+ if (originalText.includes(command) && !enrichedText.includes(command)) {
118
+ throw new Error(`assistant dropped a canonical command: ${command}`);
92
119
  }
93
- catch (err) {
94
- console.warn(UI[lang].polishFailed(assistant, err.message));
95
- polished.push(...batch);
120
+ }
121
+ }
122
+ const EVIDENCE_GROUP = /\((?:evidencia|evidence):([^)]*)\)/gi;
123
+ // A checkable citation is a plain relative path; tokens with spaces, URLs or globs are
124
+ // left alone rather than guessed at.
125
+ const PATH_TOKEN = /^[\w.@~-]+(?:[\\/][\w.@~-]+)*[\\/]?$/;
126
+ function checkableCitedPaths(line) {
127
+ const paths = [];
128
+ for (const group of line.matchAll(EVIDENCE_GROUP)) {
129
+ for (const token of group[1].matchAll(/`([^`]+)`/g)) {
130
+ // Normalize `src/app.py:12` and `pyproject.toml [tool.ruff]` down to the file path.
131
+ const normalized = token[1].replace(/:\d+(?:-\d+)?$/, "").replace(/\s*\[[^\]]*\]$/, "").trim();
132
+ if (PATH_TOKEN.test(normalized))
133
+ paths.push(normalized);
134
+ }
135
+ }
136
+ return paths;
137
+ }
138
+ // Cited evidence is the enrichment's core promise, so it gets checked against the repo:
139
+ // a claim whose cited files all fail to exist is unverifiable. Bullet claims are dropped;
140
+ // prose lines are kept (removing them would mangle paragraphs) but still reported.
141
+ function dropUnverifiableClaims(files, rootPath) {
142
+ const missing = new Set();
143
+ const checked = files.map((file) => {
144
+ const kept = file.content.split("\n").filter((line) => {
145
+ const cited = checkableCitedPaths(line);
146
+ if (cited.length === 0)
147
+ return true;
148
+ if (cited.some((p) => fs.existsSync(path.join(rootPath, p))))
149
+ return true;
150
+ for (const p of cited)
151
+ missing.add(p);
152
+ return !/^\s*[-*] /.test(line);
153
+ });
154
+ return { path: file.path, content: kept.join("\n") };
155
+ });
156
+ return { files: checked, missing: [...missing] };
157
+ }
158
+ /**
159
+ * Asks the assistant to investigate the repository it runs in and rewrite the generated
160
+ * files with repo-specific, evidence-backed guidance. Typical runs use one assistant
161
+ * process; only very large outputs are split. Falls back to the originals per batch.
162
+ */
163
+ export async function enrichFilesWithAssistant(assistant, files, options = {}) {
164
+ const { execFn = defaultExecFn, lang = "es", cwd, mustKeep = [], existingDocs = [], model } = options;
165
+ const existingDocsJson = existingDocs.length > 0 ? JSON.stringify(existingDocs) : undefined;
166
+ const enriched = [];
167
+ for (const batch of makeBatches(files)) {
168
+ const input = UI[lang].enrichPrompt(JSON.stringify(batch), mustKeep, existingDocsJson);
169
+ // Contract violations (invalid JSON, changed paths, dropped commands) are stochastic,
170
+ // especially with smaller models; one bounded retry recovers most of them.
171
+ let attemptsLeft = 2;
172
+ while (attemptsLeft > 0) {
173
+ attemptsLeft--;
174
+ try {
175
+ const result = await execFn(assistant, printArgs(assistant, model), input, cwd);
176
+ let parsed = parseAssistantBatch(result.stdout, batch);
177
+ assertMustKeepSurvive(parsed, batch, mustKeep);
178
+ if (cwd) {
179
+ const verified = dropUnverifiableClaims(parsed, cwd);
180
+ if (verified.missing.length > 0)
181
+ console.warn(UI[lang].enrichEvidenceDropped(verified.missing));
182
+ parsed = verified.files;
183
+ }
184
+ enriched.push(...parsed);
185
+ break;
186
+ }
187
+ catch (err) {
188
+ if (attemptsLeft > 0) {
189
+ console.warn(UI[lang].enrichRetrying(assistant));
190
+ }
191
+ else {
192
+ console.warn(UI[lang].enrichFailed(assistant, err.message));
193
+ enriched.push(...batch);
194
+ }
195
+ }
96
196
  }
97
197
  }
98
- return polished;
198
+ return enriched;
99
199
  }
@@ -1,8 +1,6 @@
1
1
  import { type Lang } from "./i18n.js";
2
2
  import type { CiCommand, ArchitectureFact, CommandEntry, CommandSource, ConventionFact, DirEntry, RepoFacts, RepoSignals } from "./types.js";
3
3
  export declare function extractJsPackageCommands(signals: RepoSignals): CommandEntry[];
4
- /** @deprecated Conservado como alias de API; también devuelve comandos pnpm/Yarn/Bun. */
5
- export declare const extractNpmCommands: typeof extractJsPackageCommands;
6
4
  export declare function extractMakeTargets(signals: RepoSignals): CommandEntry[];
7
5
  export declare function extractMixAliases(signals: RepoSignals): CommandEntry[];
8
6
  export declare function extractToxEnvs(signals: RepoSignals): CommandEntry[];
@@ -28,8 +28,6 @@ export function extractJsPackageCommands(signals) {
28
28
  }
29
29
  return entries;
30
30
  }
31
- /** @deprecated Conservado como alias de API; también devuelve comandos pnpm/Yarn/Bun. */
32
- export const extractNpmCommands = extractJsPackageCommands;
33
31
  function jsScriptInvocation(manager, packageDir, script) {
34
32
  const name = quoteShellArg(script);
35
33
  if (manager === "npm") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-rules-init",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Generates CLAUDE.md, AGENTS.md, copilot-instructions.md and prompt files from what your repo actually is.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -45,6 +45,7 @@
45
45
  "yaml": "^2.9.0"
46
46
  },
47
47
  "devDependencies": {
48
+ "@types/node": "^18.19.130",
48
49
  "typescript": "^5.6.0",
49
50
  "vitest": "^2.1.0"
50
51
  }