poe-code 3.0.271 → 3.0.272

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "poe-code",
3
- "version": "3.0.271",
3
+ "version": "3.0.272",
4
4
  "description": "CLI tool to configure Poe API for developer workflows.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -50,10 +50,28 @@ function sanitizeAgentForFileName(agent) {
50
50
  function resolvePath(cwd, filePath) {
51
51
  return path.isAbsolute(filePath) ? filePath : path.join(cwd, filePath);
52
52
  }
53
+ function requireNonEmptyString(value, label) {
54
+ const trimmed = value.trim();
55
+ if (trimmed.length === 0) {
56
+ throw new Error(`${label} must be a non-empty string.`);
57
+ }
58
+ return trimmed;
59
+ }
60
+ function resolveOptionalNonEmptyString(value, label) {
61
+ if (value === undefined) {
62
+ return undefined;
63
+ }
64
+ const trimmed = value.trim();
65
+ if (trimmed.length === 0) {
66
+ throw new Error(`${label} must be a non-empty string when provided.`);
67
+ }
68
+ return trimmed;
69
+ }
53
70
  async function resolveOutputPath(fs, cwd, analysisAgent, outputPath) {
54
- if (outputPath) {
55
- const absolutePath = resolvePath(cwd, outputPath);
56
- return { absolutePath, resultPath: outputPath };
71
+ const normalizedOutputPath = resolveOptionalNonEmptyString(outputPath, "outputPath");
72
+ if (normalizedOutputPath) {
73
+ const absolutePath = resolvePath(cwd, normalizedOutputPath);
74
+ return { absolutePath, resultPath: normalizedOutputPath };
57
75
  }
58
76
  const configDirectory = path.join(cwd, ".poe-code");
59
77
  const defaultPath = path.join(configDirectory, "gaslight.yaml");
@@ -77,6 +95,9 @@ function resolveSince(value) {
77
95
  return milliseconds === null ? undefined : new Date(Date.now() - milliseconds);
78
96
  }
79
97
  if (value instanceof Date) {
98
+ if (!Number.isFinite(value.getTime())) {
99
+ throw new Error(`Invalid since date "${String(value)}".`);
100
+ }
80
101
  return value;
81
102
  }
82
103
  const milliseconds = parseDuration(value);
@@ -93,6 +114,13 @@ async function resolveDataPath(cwd, keepDataPath) {
93
114
  const resultPath = path.join(".poe-code", "ingest", `human-prompts-${process.pid}-${Date.now()}-${process.hrtime.bigint()}.md`);
94
115
  return { absolutePath: path.join(cwd, resultPath), resultPath };
95
116
  }
117
+ function resolveLimit(value) {
118
+ const limit = value ?? 200;
119
+ if (!Number.isInteger(limit) || limit <= 0) {
120
+ throw new Error("limit must be a positive integer.");
121
+ }
122
+ return limit;
123
+ }
96
124
  function buildAnalysisPrompt(dataPath) {
97
125
  return [
98
126
  "Read this curated Markdown file of human prompts from coding-agent traces:",
@@ -111,7 +139,7 @@ function buildAnalysisPrompt(dataPath) {
111
139
  "- Do not put review questions, validation checks, cleanup checks, commit checks, or release checks in `prompt`; those belong in `followups`.",
112
140
  "- Prefer concise followups that generalize across tasks.",
113
141
  "- Do not produce two followups for the same workflow step; merge semantic duplicates.",
114
- "- Repeated short prompts like \"commit\" are evidence for one well-placed workflow check, not multiple followups.",
142
+ '- Repeated short prompts like "commit" are evidence for one well-placed workflow check, not multiple followups.',
115
143
  "- Order followups as a useful review sequence: quality, verification, cleanup, then commit or release when supported by the evidence.",
116
144
  "- Do not include project secrets, file paths, names, tokens, or one-off task details.",
117
145
  "- Preserve the user's direct style when it is reusable.",
@@ -148,8 +176,7 @@ function stripIdeSelection(value) {
148
176
  if (endIndex === -1) {
149
177
  return stripped;
150
178
  }
151
- stripped =
152
- stripped.slice(0, startIndex) + stripped.slice(endIndex + closeTag.length);
179
+ stripped = stripped.slice(0, startIndex) + stripped.slice(endIndex + closeTag.length);
153
180
  }
154
181
  }
155
182
  function normalizePromptText(value, cwd, homeDir) {
@@ -234,12 +261,7 @@ function buildRepeatedShortPromptSection(records, cwd, homeDir) {
234
261
  .sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0]))
235
262
  .slice(0, 12);
236
263
  if (repeated.length === 0) {
237
- return [
238
- "## Repeated short prompts",
239
- "",
240
- "No repeated short prompts were found.",
241
- ""
242
- ];
264
+ return ["## Repeated short prompts", "", "No repeated short prompts were found.", ""];
243
265
  }
244
266
  return [
245
267
  "## Repeated short prompts",
@@ -418,7 +440,10 @@ function extractGeneratedConfigContent(stdout) {
418
440
  async function writeGeneratedConfig(fs, content, absoluteOutputPath) {
419
441
  const yaml = extractYamlCandidate(extractGeneratedConfigContent(content));
420
442
  parseGaslightConfig(yaml, "generated gaslight config", { rejectExtraKeys: true });
421
- await fs.mkdir(path.dirname(absoluteOutputPath), { recursive: true });
443
+ const outputDirectory = path.dirname(absoluteOutputPath);
444
+ await assertNotSymlink(fs, outputDirectory, "Output directory");
445
+ await fs.mkdir(outputDirectory, { recursive: true });
446
+ await assertNotSymlink(fs, outputDirectory, "Output directory");
422
447
  const temporaryPath = `${absoluteOutputPath}.tmp-${process.pid}-${Date.now()}`;
423
448
  await fs.writeFile(temporaryPath, `${yaml}\n`, { encoding: "utf8" });
424
449
  if (fs.rename) {
@@ -427,19 +452,40 @@ async function writeGeneratedConfig(fs, content, absoluteOutputPath) {
427
452
  }
428
453
  await fs.writeFile(absoluteOutputPath, `${yaml}\n`, { encoding: "utf8" });
429
454
  }
455
+ async function assertNotSymlink(fs, targetPath, label) {
456
+ if (!fs.lstat) {
457
+ return;
458
+ }
459
+ try {
460
+ const stats = await fs.lstat(targetPath);
461
+ if (stats.isSymbolicLink()) {
462
+ throw new Error(`${label} cannot be a symbolic link: ${targetPath}`);
463
+ }
464
+ }
465
+ catch (error) {
466
+ if (isMissingFile(error)) {
467
+ return;
468
+ }
469
+ throw error;
470
+ }
471
+ }
430
472
  export async function ingestGaslight(options) {
431
473
  const cwd = options.cwd ?? process.cwd();
432
474
  const homeDir = options.homeDir ?? os.homedir();
433
475
  const fs = (options.fs ?? nodeFs);
434
476
  const spawn = options.spawn ?? defaultSpawn;
435
477
  const collectHumanPrompts = options.collectHumanPrompts ?? collectHumanPromptsWithStats;
478
+ const analysisAgent = requireNonEmptyString(options.analysisAgent, "analysisAgent");
479
+ const model = resolveOptionalNonEmptyString(options.model, "model");
480
+ const limit = resolveLimit(options.limit);
481
+ const outputPathOption = resolveOptionalNonEmptyString(options.outputPath, "outputPath");
436
482
  const since = resolveSince(options.since);
437
483
  const collection = await collectHumanPrompts({
438
484
  sources: options.sources,
439
485
  cwd,
440
486
  homeDir,
441
487
  since,
442
- limit: options.limit ?? 200,
488
+ limit,
443
489
  allWorkspaces: options.allWorkspaces,
444
490
  fs
445
491
  });
@@ -458,20 +504,20 @@ export async function ingestGaslight(options) {
458
504
  try {
459
505
  options.onEvent?.({
460
506
  type: "analysis.started",
461
- agent: options.analysisAgent,
507
+ agent: analysisAgent,
462
508
  dataPath: dataPath.absolutePath
463
509
  });
464
- const result = await spawn(options.analysisAgent, {
510
+ const result = await spawn(analysisAgent, {
465
511
  prompt: buildAnalysisPrompt(dataPath.absolutePath),
466
512
  cwd,
467
513
  mode: "read",
468
- ...(options.model ? { model: options.model } : {})
514
+ ...(model ? { model } : {})
469
515
  });
470
516
  if (result.exitCode !== 0) {
471
517
  const message = result.stderr.trim() || result.stdout.trim() || `exit code ${result.exitCode}`;
472
518
  throw new Error(`Gaslight ingest analysis failed: ${message}`);
473
519
  }
474
- const outputPath = await resolveOutputPath(fs, cwd, options.analysisAgent, options.outputPath);
520
+ const outputPath = await resolveOutputPath(fs, cwd, analysisAgent, outputPathOption);
475
521
  await writeGeneratedConfig(fs, result.stdout, outputPath.absolutePath);
476
522
  options.onEvent?.({ type: "config.written", path: outputPath.resultPath });
477
523
  return {
@@ -91,6 +91,23 @@ async function archivePlan(fs, cwd, planPath) {
91
91
  }
92
92
  return archivedPath;
93
93
  }
94
+ function archivePathForPlan(cwd, planPath) {
95
+ const absolutePath = path.resolve(cwd, planPath);
96
+ return path.join(path.dirname(absolutePath), "archive", path.basename(absolutePath));
97
+ }
98
+ async function assertArchiveDestinationAvailable(fs, cwd, planPath) {
99
+ const archivedPath = archivePathForPlan(cwd, planPath);
100
+ try {
101
+ await fs.readFile(archivedPath, "utf8");
102
+ throw new Error(`Archive destination already exists: ${archivedPath}`);
103
+ }
104
+ catch (error) {
105
+ if (!isMissingFile(error)) {
106
+ throw error;
107
+ }
108
+ }
109
+ await rejectArchiveSymlink(fs, path.dirname(archivedPath));
110
+ }
94
111
  function validateInlineConfig(prompt, followups) {
95
112
  if ((prompt === undefined) !== (followups === undefined)) {
96
113
  throw new Error("prompt and followups must be provided together.");
@@ -103,6 +120,23 @@ function validateInlineConfig(prompt, followups) {
103
120
  throw new Error("followups must be a non-empty array of non-empty strings.");
104
121
  }
105
122
  }
123
+ function requireNonEmptyString(value, label) {
124
+ const trimmed = value.trim();
125
+ if (trimmed.length === 0) {
126
+ throw new Error(`${label} must be a non-empty string.`);
127
+ }
128
+ return trimmed;
129
+ }
130
+ function resolveModel(value) {
131
+ if (value === undefined) {
132
+ return undefined;
133
+ }
134
+ const trimmed = value.trim();
135
+ if (trimmed.length === 0) {
136
+ throw new Error("model must be a non-empty string when provided.");
137
+ }
138
+ return trimmed;
139
+ }
106
140
  function resolvePlanPaths(options) {
107
141
  if (options.planPaths.length === 0) {
108
142
  throw new Error("Provide at least one plan path.");
@@ -112,17 +146,30 @@ function resolvePlanPaths(options) {
112
146
  throw new Error("plan paths must be non-empty strings.");
113
147
  }
114
148
  }
115
- return options.planPaths.map((planPath) => planPath.trim());
149
+ const planPaths = options.planPaths.map((planPath) => planPath.trim());
150
+ const seen = new Map();
151
+ for (const planPath of planPaths) {
152
+ const resolvedPath = path.resolve(options.cwd ?? process.cwd(), planPath);
153
+ const duplicate = seen.get(resolvedPath);
154
+ if (duplicate !== undefined) {
155
+ throw new Error(`Duplicate plan path: ${duplicate}`);
156
+ }
157
+ seen.set(resolvedPath, planPath);
158
+ }
159
+ return planPaths;
116
160
  }
117
161
  export async function runGaslight(options) {
118
162
  const cwd = options.cwd ?? process.cwd();
119
163
  const homeDir = options.homeDir ?? os.homedir();
120
164
  const fs = (options.fs ?? nodeFs);
121
165
  const spawn = options.spawn ?? defaultSpawn;
166
+ const agent = requireNonEmptyString(options.agent, "agent");
167
+ const model = resolveModel(options.model);
122
168
  validateInlineConfig(options.prompt, options.followups);
123
169
  const planPaths = resolvePlanPaths(options);
124
170
  for (const planPath of planPaths) {
125
171
  await requirePlan(fs, cwd, planPath);
172
+ await assertArchiveDestinationAvailable(fs, cwd, planPath);
126
173
  }
127
174
  const config = options.prompt !== undefined && options.followups !== undefined
128
175
  ? { prompt: options.prompt.trim(), followups: options.followups.map((value) => value.trim()) }
@@ -146,11 +193,11 @@ export async function runGaslight(options) {
146
193
  planIndex: planIndex + 1,
147
194
  totalPlans: planPaths.length
148
195
  });
149
- const result = await spawn(options.agent, {
196
+ const result = await spawn(agent, {
150
197
  prompt,
151
198
  cwd,
152
199
  mode: options.mode ?? "edit",
153
- ...(options.model ? { model: options.model } : {}),
200
+ ...(model ? { model } : {}),
154
201
  ...(resumeThreadId ? { resumeThreadId } : {}),
155
202
  ...(options.signal ? { signal: options.signal } : {})
156
203
  });