@aion0/forge 0.10.84 → 0.10.86

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.
@@ -360,12 +360,12 @@ function buildConnectorCatalog(openSet: Set<string>): string[] {
360
360
  return lines;
361
361
  }
362
362
 
363
- function buildSystemPrompt(
363
+ async function buildSystemPrompt(
364
364
  openConnectorTools: LlmTool[],
365
365
  openSet: Set<string>,
366
366
  builtinDefs: typeof BUILTIN_TOOL_DEFS,
367
367
  sessionSystemPrompt: string | null,
368
- ): string {
368
+ ): Promise<string> {
369
369
  const now = new Date().toISOString();
370
370
 
371
371
  // Inject a brief Forge context block (project names only) so the LLM can
@@ -375,7 +375,7 @@ function buildSystemPrompt(
375
375
  // names are cheap enough to ship every turn.
376
376
  let projectNames: string[] = [];
377
377
  try {
378
- const { scanProjects } = require('../projects') as typeof import('../projects');
378
+ const { scanProjects } = await import('../projects');
379
379
  projectNames = scanProjects().map((p) => p.name);
380
380
  } catch { /* projects roots not configured / read failed — omit */ }
381
381
 
@@ -751,8 +751,8 @@ export async function runTurn(args: RunTurnArgs): Promise<{ ok: boolean; error?:
751
751
  }
752
752
 
753
753
  const sessionSystemPrompt = session.system_prompt;
754
- function buildSystem(openTools: LlmTool[], openSet: Set<string>): string {
755
- let s = buildSystemPrompt(openTools, openSet, builtinDefsAll, sessionSystemPrompt);
754
+ async function buildSystem(openTools: LlmTool[], openSet: Set<string>): Promise<string> {
755
+ let s = await buildSystemPrompt(openTools, openSet, builtinDefsAll, sessionSystemPrompt);
756
756
  if (narrowDirective) s += narrowDirective;
757
757
  return s;
758
758
  }
@@ -766,7 +766,7 @@ export async function runTurn(args: RunTurnArgs): Promise<{ ok: boolean; error?:
766
766
  // per-iteration when the profile's maxInputTokens is tight. The
767
767
  // assembled string is recomputed each iter (after open-set + memory
768
768
  // trim). `system` here holds the base (no memory section).
769
- let system = buildSystem(openConnectorTools, openSet);
769
+ let system = await buildSystem(openConnectorTools, openSet);
770
770
  if (memStore.enabled) {
771
771
  const searchHint = memStore.kind === 'local'
772
772
  ? '• memory_search is keyword LIKE over local blocks + episodes — useful for finding past notes; prefer memory_get_block / memory_list_blocks for first-person facts.'
@@ -848,7 +848,7 @@ export async function runTurn(args: RunTurnArgs): Promise<{ ok: boolean; error?:
848
848
  openSet = newOpenSet;
849
849
  openConnectorTools = allConnectorTools.filter((t) => openSet.has(t.name.split('.')[0]!));
850
850
  allTools = [...builtinToolDefs, ...openConnectorTools];
851
- system = buildSystem(openConnectorTools, openSet);
851
+ system = await buildSystem(openConnectorTools, openSet);
852
852
  console.log(`[chat] open set → {${[...openSet].join(',')}} (${openConnectorTools.length} connector tools active)`);
853
853
  }
854
854
 
@@ -1071,10 +1071,9 @@ export async function runTurn(args: RunTurnArgs): Promise<{ ok: boolean; error?:
1071
1071
  const leftovers = consumeNotes(args.sessionId);
1072
1072
  endTurn(args.sessionId);
1073
1073
  if (leftovers.length > 0) {
1074
- // Lazy require: input-queue imports runTurn from this module —
1075
- // a static import here would be circular.
1076
- // eslint-disable-next-line @typescript-eslint/no-require-imports
1077
- const { enqueueChatInput } = require('./input-queue') as typeof import('./input-queue');
1074
+ // Lazy dynamic import: input-queue imports runTurn from this module —
1075
+ // a static import here would be circular. ESM requires await import (no require).
1076
+ const { enqueueChatInput } = await import('./input-queue');
1078
1077
  for (const text of leftovers) {
1079
1078
  enqueueChatInput({
1080
1079
  sessionId: args.sessionId,
package/lib/pipeline.ts CHANGED
@@ -11,7 +11,7 @@ import { execSync } from 'node:child_process';
11
11
  import { join } from 'node:path';
12
12
  import YAML from 'yaml';
13
13
  import { createTask, getTask, onTaskEvent, taskModelOverrides, taskAppendSystemPromptOverrides, cancelTask } from './task-manager';
14
- import { getProjectInfo, resolveOrCloneProject, getProjectWorktreeRoot, ensureScratchProject } from './projects';
14
+ import { getProjectInfo, resolveOrCloneProject, resolveProjectStrict, getProjectWorktreeRoot, ensureScratchProject } from './projects';
15
15
  import { loadSettings } from './settings';
16
16
  import { getAgent, listAgents } from './agents';
17
17
  import type { Task } from '../src/types';
@@ -172,6 +172,16 @@ export interface Workflow {
172
172
  /** Declares the pipeline pushes to git. Enables an up-front OTP/2FA preflight
173
173
  * so a 2fa_verify wall aborts BEFORE any code work instead of at push time. */
174
174
  git_push?: boolean;
175
+ /** Declares the pipeline needs a real git checkout to operate (e.g. it will
176
+ * cd into the project and run `git`, `glab`, build/test commands). When
177
+ * true, startPipeline runs the strict resolver (resolveProjectStrict) and
178
+ * aborts the whole run with a clear error if no project can be resolved —
179
+ * instead of silently falling back to scratch, which causes downstream
180
+ * shell nodes to crash with "fatal: not a git repository".
181
+ *
182
+ * Default: false (preserves the scratch fallback for connector-only / chat
183
+ * pipelines that don't touch a repo). */
184
+ requires_real_project?: boolean;
175
185
  /** Default execution backend for every node's task. 'tmux' runs interactive
176
186
  * claude in a per-node tmux session (subscription billing); 'headless' /
177
187
  * omitted uses default `claude -p`. Per-node `backend:` overrides this. */
@@ -517,6 +527,14 @@ export function parseWorkflow(raw: string): Workflow {
517
527
  for_each,
518
528
  conversation,
519
529
  backend: parsed.backend === 'tmux' || parsed.backend === 'headless' ? parsed.backend : undefined,
530
+ // Top-level boolean flag — must be explicitly passed through; the return is
531
+ // a literal, so any field omitted here is silently dropped from the parsed
532
+ // workflow. NOTE: `git_push` has the SAME latent issue (it's declared in
533
+ // the interface + read at startPipeline but never parsed here, so its OTP
534
+ // preflight has never actually fired). Left as-is on purpose: wiring it up
535
+ // would newly enable the preflight on existing fortinet pipelines, an
536
+ // unrelated behavior change that belongs in its own commit.
537
+ requires_real_project: parsed.requires_real_project === true,
520
538
  };
521
539
  }
522
540
 
@@ -1081,6 +1099,30 @@ export function startPipeline(
1081
1099
  forEach: forEachState,
1082
1100
  };
1083
1101
 
1102
+ // Strict project resolution — only for pipelines that declared they need a
1103
+ // real git checkout. MUST run BEFORE ensurePipelineTmpDir: the lenient
1104
+ // resolver inside that helper falls back to scratch (building tmp_dir in the
1105
+ // wrong place) and downstream `git` / `glab` commands then crash with
1106
+ // "fatal: not a git repository". The strict resolver instead clones/locates
1107
+ // the real repo (or returns a structured error we surface here), and seeds
1108
+ // input.project so the tmp_dir lands inside that repo.
1109
+ if (workflow.requires_real_project) {
1110
+ const r = resolveProjectStrict(input.project || '');
1111
+ if ('error' in r) {
1112
+ pipeline.status = 'failed';
1113
+ pipeline.error = r.message;
1114
+ pipeline.completedAt = new Date().toISOString();
1115
+ savePipeline(pipeline);
1116
+ finalizePipeline(pipeline);
1117
+ return pipeline;
1118
+ }
1119
+ // Seed the resolved project name into input so ensurePipelineTmpDir and
1120
+ // every node's lenient resolveOrCloneProject hit it immediately as
1121
+ // 'existing' (no redundant scan/clone). pipeline.input === input here
1122
+ // (same ref from construction), so an in-place mutation updates both.
1123
+ if (r.project.name) input.project = r.project.name;
1124
+ }
1125
+
1084
1126
  ensurePipelineTmpDir(pipeline);
1085
1127
 
1086
1128
  // Git OTP/2FA preflight — abort the whole run up front so a push wall never
package/lib/projects.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { readdirSync, existsSync, statSync, readFileSync, mkdirSync, writeFileSync } from 'node:fs';
1
+ import { readdirSync, existsSync, statSync, readFileSync, mkdirSync, writeFileSync, accessSync, constants as fsConstants } from 'node:fs';
2
2
  import { execSync } from 'node:child_process';
3
3
  import { join, resolve, isAbsolute, parse } from 'node:path';
4
4
  import { homedir } from 'node:os';
@@ -231,6 +231,159 @@ export interface ResolveResult {
231
231
  clone_url?: string;
232
232
  }
233
233
 
234
+ // ─── Container mode + auto-root ───────────────────────────────────
235
+
236
+ const CONTAINER_DEFAULT_ROOT = '/data/project';
237
+
238
+ /** True when Forge is running inside a Docker/container deployment.
239
+ * Two-channel detection (per user choice C):
240
+ * 1. FORGE_CONTAINER=1 env var (set by entrypoint)
241
+ * 2. /data/project exists AND is writable (probe; covers ad-hoc mounts)
242
+ * Either signal is enough. */
243
+ export function detectContainerMode(): boolean {
244
+ if (process.env.FORGE_CONTAINER === '1') return true;
245
+ try {
246
+ accessSync(CONTAINER_DEFAULT_ROOT, fsConstants.W_OK);
247
+ return true;
248
+ } catch { return false; }
249
+ }
250
+
251
+ /** In container mode, register `/data/project` as a project root if no roots
252
+ * are configured yet. Idempotent: returns the path that was added (or
253
+ * already present), or null when we're not in container mode / the path
254
+ * isn't writable. Auto-add (user choice 3) so subsequent runs use it without
255
+ * re-triggering this path.
256
+ *
257
+ * Persists to settings so the path also appears in Settings → Projects. */
258
+ export function ensureContainerRoot(): string | null {
259
+ if (!detectContainerMode()) return null;
260
+ try {
261
+ mkdirSync(CONTAINER_DEFAULT_ROOT, { recursive: true });
262
+ accessSync(CONTAINER_DEFAULT_ROOT, fsConstants.W_OK);
263
+ } catch { return null; }
264
+ const settings = loadSettings();
265
+ const roots = settings.projectRoots || [];
266
+ if (roots.includes(CONTAINER_DEFAULT_ROOT)) return CONTAINER_DEFAULT_ROOT;
267
+ settings.projectRoots = [...roots, CONTAINER_DEFAULT_ROOT];
268
+ saveSettings(settings);
269
+ invalidateProjectScan();
270
+ return CONTAINER_DEFAULT_ROOT;
271
+ }
272
+
273
+ // ─── Strict resolver (for pipelines that declare requires_real_project) ───
274
+
275
+ export type StrictResolveError =
276
+ | 'no_repo_specified'
277
+ | 'clone_failed';
278
+
279
+ export interface StrictResolveResult {
280
+ project: LocalProject;
281
+ source: 'existing' | 'gitlab-cloned';
282
+ clone_url?: string;
283
+ }
284
+
285
+ /**
286
+ * Strict project resolver — used by pipelines that declare
287
+ * `requires_real_project: true`. Differs from resolveOrCloneProject in that
288
+ * it NEVER falls back to scratch; on any failure it returns a structured
289
+ * error so the caller (startPipeline) can abort the run with a clear message
290
+ * instead of letting git commands inside the task crash with
291
+ * "fatal: not a git repository".
292
+ *
293
+ * Flow (matches the user's spec):
294
+ * 1. Existing project by name / owner-repo / fuzzy → use it
295
+ * 2. Otherwise figure out the target repo URL:
296
+ * - input name contains '/' → treat as owner/repo
297
+ * - else gitlab connector default_project_path
298
+ * - else → error('no_repo_specified')
299
+ * 3. Re-scan locally for a checkout whose origin matches the target → use it
300
+ * 4. Clone via tryGitlabClone — writes to the Forge-managed cache
301
+ * (<dataDir>/cloned-projects), which is ALWAYS writable and does NOT
302
+ * need a configured projectRoot. We best-effort register the container
303
+ * root (/data/project) so manually-placed checkouts there get scanned,
304
+ * but never block the clone on it.
305
+ * - gitlab connector missing → error('no_repo_specified')
306
+ * - clone failed → error('clone_failed')
307
+ */
308
+ export function resolveProjectStrict(name: string | undefined): StrictResolveResult | { error: StrictResolveError; message: string } {
309
+ const trimmed = (name || '').trim();
310
+
311
+ // Step 1: existing match — reuse the lenient resolver's match logic by
312
+ // delegating, but discard its scratch fallback.
313
+ if (trimmed) {
314
+ const hit = getProjectInfo(trimmed);
315
+ if (hit) return { project: hit, source: 'existing' };
316
+
317
+ const projects = scanProjects();
318
+ if (trimmed.includes('/')) {
319
+ const want = normalizeRepoPath(trimmed) || trimmed.toLowerCase().replace(/^\/+|\/+$/g, '');
320
+ const wantBase = want.split('/').pop()!;
321
+ const byRepo = projects.filter((p) => p.repo && p.repo === want);
322
+ if (byRepo.length === 1) return { project: byRepo[0], source: 'existing' };
323
+ const byRepoBase = projects.filter((p) => p.repo && p.repo.split('/').pop() === wantBase);
324
+ if (byRepoBase.length === 1) return { project: byRepoBase[0], source: 'existing' };
325
+ }
326
+ const base = trimmed.split('/').pop()!.trim();
327
+ const ownerRepo = trimmed.replace(/\//g, '-');
328
+ const cands = projects.filter((p) =>
329
+ p.name === base || p.name === ownerRepo || p.name.toLowerCase() === base.toLowerCase());
330
+ if (cands.length === 1) return { project: cands[0], source: 'existing' };
331
+ }
332
+
333
+ // Step 2: derive target repo path
334
+ const gl = readGitlabConnector();
335
+ const targetPath = trimmed && trimmed.includes('/')
336
+ ? trimmed
337
+ : (gl?.default_project_path || '').trim();
338
+ if (!targetPath) {
339
+ return {
340
+ error: 'no_repo_specified',
341
+ message: trimmed
342
+ ? `Could not resolve project "${trimmed}". Pass an owner/repo path, or set GitLab connector's default_project_path.`
343
+ : 'No repository specified. Set `project: owner/repo` in pipeline input, or set GitLab connector\'s default_project_path.',
344
+ };
345
+ }
346
+
347
+ // Step 3: scan locally by origin remote
348
+ if (gl) {
349
+ const projects = scanProjects();
350
+ const want = normalizeRepoPath(targetPath) || targetPath.toLowerCase().replace(/^\/+|\/+$/g, '');
351
+ const wantBase = want.split('/').pop()!;
352
+ const localHit =
353
+ projects.find((p) => p.repo && p.repo === want) ||
354
+ projects.find((p) => p.repo && p.repo.split('/').pop() === wantBase) ||
355
+ projects.find((p) => p.name.toLowerCase() === wantBase.toLowerCase());
356
+ if (localHit) return { project: localHit, source: 'existing' };
357
+ }
358
+
359
+ // Step 4: clone. tryGitlabClone writes to <dataDir>/cloned-projects (always
360
+ // writable) — it does NOT use projectRoots, so there's no "no project root"
361
+ // failure here: as long as the gitlab connector is configured, a clone has
362
+ // somewhere to land. Best-effort register the container root so manual
363
+ // checkouts under /data/project are discoverable on the next scan; ignore
364
+ // its result — it must never block the clone.
365
+ ensureContainerRoot();
366
+
367
+ if (!gl) {
368
+ return {
369
+ error: 'no_repo_specified',
370
+ message: `GitLab connector is not configured; cannot clone "${targetPath}". Install + configure the GitLab connector first.`,
371
+ };
372
+ }
373
+ const cloned = tryGitlabClone(targetPath);
374
+ if (!cloned) {
375
+ return {
376
+ error: 'clone_failed',
377
+ message: `Failed to clone ${gl.base_url}/${targetPath}. Check GitLab connectivity, credentials, and that the path exists.`,
378
+ };
379
+ }
380
+ return {
381
+ project: cloned,
382
+ source: 'gitlab-cloned',
383
+ clone_url: `${gl.base_url}/${targetPath.replace(/^\/+|\/+$/g, '')}.git`,
384
+ };
385
+ }
386
+
234
387
  /**
235
388
  * Pick a writable directory to clone into, in priority order:
236
389
  * 1. settings.projectRoots[0] — what the user already trusts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aion0/forge",
3
- "version": "0.10.84",
3
+ "version": "0.10.86",
4
4
  "description": "Unified AI workflow platform — multi-model task orchestration, persistent sessions, web terminal, remote access",
5
5
  "type": "module",
6
6
  "scripts": {