@aion0/forge 0.10.86 → 0.10.88
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/RELEASE_NOTES.md +4 -7
- package/app/api/connectors/import-config-template/route.ts +28 -0
- package/bin/forge-server.mjs +11 -0
- package/components/SkillsPanel.tsx +11 -0
- package/instrumentation.ts +2 -0
- package/lib/browser-bridge-standalone.ts +1 -0
- package/lib/chat/protocols/http.ts +16 -4
- package/lib/chat-standalone.ts +1 -0
- package/lib/connectors/test-runner.ts +8 -2
- package/lib/help-docs/10-troubleshooting.md +13 -0
- package/lib/memory-standalone.ts +2 -0
- package/lib/pipeline.ts +20 -3
- package/lib/proxy-setup.ts +21 -0
- package/lib/skills.ts +193 -65
- package/lib/task-manager.ts +15 -2
- package/lib/telegram-standalone.ts +1 -0
- package/lib/terminal-standalone.ts +1 -0
- package/lib/workspace-standalone.ts +1 -0
- package/package.json +1 -1
- package/src/core/db/database.ts +7 -0
package/RELEASE_NOTES.md
CHANGED
|
@@ -1,11 +1,8 @@
|
|
|
1
|
-
# Forge v0.10.
|
|
1
|
+
# Forge v0.10.88
|
|
2
2
|
|
|
3
|
-
Released: 2026-06-
|
|
3
|
+
Released: 2026-06-17
|
|
4
4
|
|
|
5
|
-
## Changes since v0.10.
|
|
5
|
+
## Changes since v0.10.87
|
|
6
6
|
|
|
7
|
-
### Other
|
|
8
|
-
- fix(schedules): pause/resume + extractor openai support
|
|
9
7
|
|
|
10
|
-
|
|
11
|
-
**Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.85...v0.10.86
|
|
8
|
+
**Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.87...v0.10.88
|
|
@@ -219,6 +219,8 @@ async function apply(template: any, values: Record<string, string>): Promise<{
|
|
|
219
219
|
fields_preserved: Array<{ connector: string; field: string; reason: string }>;
|
|
220
220
|
fields_left_empty: Array<{ connector: string; field: string }>;
|
|
221
221
|
agents_applied?: string[];
|
|
222
|
+
skills_installed?: string[];
|
|
223
|
+
skills_failed?: Array<{ name: string; error: string }>;
|
|
222
224
|
}> {
|
|
223
225
|
// Auto-inject user identity from settings so connectors (e.g. tp.username)
|
|
224
226
|
// can use {user_name} / {user_email} without prompting the user again.
|
|
@@ -359,6 +361,30 @@ async function apply(template: any, values: Record<string, string>): Promise<{
|
|
|
359
361
|
}
|
|
360
362
|
}
|
|
361
363
|
|
|
364
|
+
// ─── _skills block — auto-install marketplace skills to global ─────
|
|
365
|
+
//
|
|
366
|
+
// Template declares `"_skills": ["prelude", ...]`. On apply we sync the
|
|
367
|
+
// marketplace registry once, then installGlobal each — so e.g. the fortinet
|
|
368
|
+
// template pulls prelude into ~/.claude/skills/ at setup time. installGlobal
|
|
369
|
+
// always resolves the registry's latest version, so "0.2.0 and later" tracks
|
|
370
|
+
// automatically. Failures are collected, never abort the rest of the apply.
|
|
371
|
+
const skillsInstalled: string[] = [];
|
|
372
|
+
const skillsFailed: Array<{ name: string; error: string }> = [];
|
|
373
|
+
const skillsList = Array.isArray((template as any)._skills) ? (template as any)._skills : [];
|
|
374
|
+
if (skillsList.length > 0) {
|
|
375
|
+
const { syncSkills, installGlobal } = await import('@/lib/skills');
|
|
376
|
+
// syncSkills populates the local skills DB that installGlobal reads from.
|
|
377
|
+
// If it fails (offline), installGlobal will likely fail too (it downloads
|
|
378
|
+
// files from GitHub) — that surfaces per-skill in skills_failed, never aborts.
|
|
379
|
+
try { await syncSkills(); } catch { /* non-fatal — per-skill install still attempted */ }
|
|
380
|
+
for (const raw of skillsList) {
|
|
381
|
+
const name = typeof raw === 'string' ? raw.trim() : '';
|
|
382
|
+
if (!name) continue;
|
|
383
|
+
try { await installGlobal(name); skillsInstalled.push(name); }
|
|
384
|
+
catch (e: any) { skillsFailed.push({ name, error: e?.message || String(e) }); }
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
362
388
|
return {
|
|
363
389
|
applied,
|
|
364
390
|
installed_from_registry: installedFromRegistry,
|
|
@@ -367,6 +393,8 @@ async function apply(template: any, values: Record<string, string>): Promise<{
|
|
|
367
393
|
fields_preserved: preserved,
|
|
368
394
|
fields_left_empty: leftEmpty,
|
|
369
395
|
agents_applied: agentsApplied,
|
|
396
|
+
skills_installed: skillsInstalled,
|
|
397
|
+
skills_failed: skillsFailed,
|
|
370
398
|
};
|
|
371
399
|
}
|
|
372
400
|
|
package/bin/forge-server.mjs
CHANGED
|
@@ -28,6 +28,17 @@ import { homedir } from 'node:os';
|
|
|
28
28
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
29
29
|
const ROOT = join(__dirname, '..');
|
|
30
30
|
|
|
31
|
+
// Pre-install EnvHttpProxyAgent before any dynamic import('undici') can
|
|
32
|
+
// clobber Node's NODE_USE_ENV_PROXY agent (see lib/proxy-setup.ts).
|
|
33
|
+
// No-op off-corp (no proxy env), so non-docker deployments are unchanged.
|
|
34
|
+
if (process.env.HTTPS_PROXY || process.env.HTTP_PROXY ||
|
|
35
|
+
process.env.https_proxy || process.env.http_proxy) {
|
|
36
|
+
try {
|
|
37
|
+
const { setGlobalDispatcher, EnvHttpProxyAgent } = await import('undici');
|
|
38
|
+
setGlobalDispatcher(new EnvHttpProxyAgent());
|
|
39
|
+
} catch { /* undici unavailable — leave Node's default dispatcher */ }
|
|
40
|
+
}
|
|
41
|
+
|
|
31
42
|
/** Build Next.js — install devDependencies first if missing */
|
|
32
43
|
function buildNext() {
|
|
33
44
|
// Check if devDependencies are installed (e.g. @tailwindcss/postcss)
|
|
@@ -26,6 +26,8 @@ interface Skill {
|
|
|
26
26
|
installedProjects: string[];
|
|
27
27
|
deletedRemotely: boolean;
|
|
28
28
|
source?: 'registry' | 'local';
|
|
29
|
+
sourceId?: string;
|
|
30
|
+
isEnterprise?: boolean;
|
|
29
31
|
}
|
|
30
32
|
|
|
31
33
|
interface ProjectInfo {
|
|
@@ -584,6 +586,15 @@ export default function SkillsPanel({ projectFilter }: { projectFilter?: string
|
|
|
584
586
|
>
|
|
585
587
|
<div className="flex items-center gap-2">
|
|
586
588
|
<span className="text-[11px] font-semibold text-[var(--text-primary)] truncate flex-1">{skill.displayName}</span>
|
|
589
|
+
{/* Source badge: enterprise skills (synced from an enterprise
|
|
590
|
+
repo, winning over a same-name public skill) get a 🔒 chip
|
|
591
|
+
with the tenant id; public skills stay unmarked. */}
|
|
592
|
+
{skill.isEnterprise && (
|
|
593
|
+
<span
|
|
594
|
+
className="text-[8px] px-1 rounded font-medium bg-amber-500/20 text-amber-500 shrink-0"
|
|
595
|
+
title={`Enterprise source: ${skill.sourceId?.replace(/^enterprise-/, '') || ''}`}
|
|
596
|
+
>🔒 {skill.sourceId?.replace(/^enterprise-/, '') || 'enterprise'}</span>
|
|
597
|
+
)}
|
|
587
598
|
<span className="text-[8px] text-[var(--text-secondary)] font-mono shrink-0">v{skill.version}</span>
|
|
588
599
|
{skill.rating > 0 && (
|
|
589
600
|
<span className="text-[8px] text-[var(--yellow)] shrink-0" title={`Rating: ${skill.rating}/5`}>
|
package/instrumentation.ts
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
* Next.js instrumentation — runs once when the server starts.
|
|
3
3
|
* Loads .env.local and prints login password.
|
|
4
4
|
*/
|
|
5
|
+
import './lib/proxy-setup'; // MUST be first — see proxy-setup.ts header.
|
|
6
|
+
|
|
5
7
|
export async function register() {
|
|
6
8
|
// Only run on server, not Edge
|
|
7
9
|
if (process.env.NEXT_RUNTIME === 'nodejs') {
|
|
@@ -39,6 +39,7 @@
|
|
|
39
39
|
* Usage: npx tsx lib/browser-bridge-standalone.ts [--forge-port=8403]
|
|
40
40
|
*/
|
|
41
41
|
|
|
42
|
+
import './proxy-setup'; // MUST be first — see proxy-setup.ts header.
|
|
42
43
|
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
|
43
44
|
import { WebSocket, WebSocketServer } from 'ws';
|
|
44
45
|
import { randomUUID, createHash } from 'node:crypto';
|
|
@@ -241,8 +241,14 @@ async function exchangeBearerToken(
|
|
|
241
241
|
const exchangeFetchInit = { method: auth.exchange_method || 'POST', headers, body };
|
|
242
242
|
let res: Response;
|
|
243
243
|
if (verifyTls === false) {
|
|
244
|
-
|
|
245
|
-
|
|
244
|
+
// Per-call dispatcher bypasses the global proxy agent — route via
|
|
245
|
+
// ProxyAgent when a proxy is set (corp), else plain Agent (direct).
|
|
246
|
+
const { fetch: undiciFetch, Agent, ProxyAgent } = await import('undici');
|
|
247
|
+
const proxyUrl = process.env.HTTPS_PROXY || process.env.https_proxy ||
|
|
248
|
+
process.env.HTTP_PROXY || process.env.http_proxy;
|
|
249
|
+
const dispatcher = proxyUrl
|
|
250
|
+
? new ProxyAgent({ uri: proxyUrl, requestTls: { rejectUnauthorized: false } })
|
|
251
|
+
: new Agent({ connect: { rejectUnauthorized: false } });
|
|
246
252
|
res = await undiciFetch(exchangeUrl, { ...exchangeFetchInit, dispatcher }) as unknown as Response;
|
|
247
253
|
} else {
|
|
248
254
|
res = await fetch(exchangeUrl, exchangeFetchInit);
|
|
@@ -488,8 +494,14 @@ export async function runHttp({ tool, settings, args, connectorAuth, noTruncatio
|
|
|
488
494
|
// fetch are version-matched; passing an external undici Agent
|
|
489
495
|
// into Node's bundled global fetch fails with UND_ERR_INVALID_ARG
|
|
490
496
|
// because Node 22 ships an older undici than the installed one.
|
|
491
|
-
|
|
492
|
-
|
|
497
|
+
// Per-call dispatcher bypasses the global proxy agent — route via
|
|
498
|
+
// ProxyAgent when a proxy is set (corp), else plain Agent (direct).
|
|
499
|
+
const { fetch: undiciFetch, Agent, ProxyAgent } = await import('undici');
|
|
500
|
+
const proxyUrl = process.env.HTTPS_PROXY || process.env.https_proxy ||
|
|
501
|
+
process.env.HTTP_PROXY || process.env.http_proxy;
|
|
502
|
+
const dispatcher = proxyUrl
|
|
503
|
+
? new ProxyAgent({ uri: proxyUrl, requestTls: { rejectUnauthorized: false } })
|
|
504
|
+
: new Agent({ connect: { rejectUnauthorized: false } });
|
|
493
505
|
res = await undiciFetch(url, { method, headers, body, signal: controller.signal, dispatcher }) as unknown as Response;
|
|
494
506
|
} else {
|
|
495
507
|
res = await fetch(url, { method, headers, body, signal: controller.signal });
|
package/lib/chat-standalone.ts
CHANGED
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
* keep their existing WS path; HTTP-only surfaces use SSE.
|
|
31
31
|
*/
|
|
32
32
|
|
|
33
|
+
import './proxy-setup'; // MUST be first — see proxy-setup.ts header.
|
|
33
34
|
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
|
34
35
|
import {
|
|
35
36
|
createSession, getSession, listSessions, updateSession, deleteSession, listMessages,
|
|
@@ -134,8 +134,14 @@ async function runHttpProbe(
|
|
|
134
134
|
// NAC, ESXi …) need undici with rejectUnauthorized:false, same as http.ts.
|
|
135
135
|
const fetchInit = { method, headers, body, signal: ctrl.signal };
|
|
136
136
|
if (def.http?.verify_tls === false) {
|
|
137
|
-
|
|
138
|
-
|
|
137
|
+
// A per-call dispatcher bypasses the global proxy agent, so when a proxy
|
|
138
|
+
// is set (corp) route through ProxyAgent; else plain Agent (direct).
|
|
139
|
+
const { fetch: undiciFetch, Agent, ProxyAgent } = await import('undici');
|
|
140
|
+
const proxyUrl = process.env.HTTPS_PROXY || process.env.https_proxy ||
|
|
141
|
+
process.env.HTTP_PROXY || process.env.http_proxy;
|
|
142
|
+
const dispatcher = proxyUrl
|
|
143
|
+
? new ProxyAgent({ uri: proxyUrl, requestTls: { rejectUnauthorized: false } })
|
|
144
|
+
: new Agent({ connect: { rejectUnauthorized: false } });
|
|
139
145
|
res = await undiciFetch(url, { ...fetchInit, dispatcher } as any) as unknown as Response;
|
|
140
146
|
} else {
|
|
141
147
|
res = await fetch(url, fetchInit);
|
|
@@ -76,6 +76,19 @@ The WebSocket dropped (system suspend, network blip). Forge auto-reconnects afte
|
|
|
76
76
|
gh auth login
|
|
77
77
|
```
|
|
78
78
|
|
|
79
|
+
### Connector Test fails with `ENETUNREACH` behind a corp proxy
|
|
80
|
+
Symptom: a brand-new deployment behind a corporate proxy/ZTNA, configure a
|
|
81
|
+
connector (e.g. GitLab) → **Test** → `request failed: fetch failed: connect
|
|
82
|
+
ENETUNREACH <backend-ip>:443`.
|
|
83
|
+
|
|
84
|
+
Forge honours `HTTPS_PROXY` / `HTTP_PROXY` / `NO_PROXY` automatically — every
|
|
85
|
+
entry point pre-installs an `EnvHttpProxyAgent` before any code makes a request,
|
|
86
|
+
so a proxy set in the environment is used for all connector calls (including
|
|
87
|
+
`verify_tls: false` self-signed appliances like FortiNAC). Just make sure the
|
|
88
|
+
proxy env vars are exported for the Forge process (in Docker, via the
|
|
89
|
+
container's environment). Off-corp deployments with no proxy env are unaffected
|
|
90
|
+
— requests go direct as before.
|
|
91
|
+
|
|
79
92
|
### Skills not syncing
|
|
80
93
|
Click "Sync" in Skills tab. Check `skillsRepoUrl` in Settings points to valid registry.
|
|
81
94
|
|
package/lib/memory-standalone.ts
CHANGED
package/lib/pipeline.ts
CHANGED
|
@@ -10,7 +10,7 @@ import { existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync, statSy
|
|
|
10
10
|
import { execSync } from 'node:child_process';
|
|
11
11
|
import { join } from 'node:path';
|
|
12
12
|
import YAML from 'yaml';
|
|
13
|
-
import { createTask, getTask, onTaskEvent, taskModelOverrides, taskAppendSystemPromptOverrides, cancelTask } from './task-manager';
|
|
13
|
+
import { createTask, getTask, onTaskEvent, taskModelOverrides, taskAppendSystemPromptOverrides, taskSkillsOverrides, cancelTask } from './task-manager';
|
|
14
14
|
import { getProjectInfo, resolveOrCloneProject, resolveProjectStrict, getProjectWorktreeRoot, ensureScratchProject } from './projects';
|
|
15
15
|
import { loadSettings } from './settings';
|
|
16
16
|
import { getAgent, listAgents } from './agents';
|
|
@@ -182,6 +182,13 @@ export interface Workflow {
|
|
|
182
182
|
* Default: false (preserves the scratch fallback for connector-only / chat
|
|
183
183
|
* pipelines that don't touch a repo). */
|
|
184
184
|
requires_real_project?: boolean;
|
|
185
|
+
/** Skills auto-attached to every task this workflow spawns, declared in the
|
|
186
|
+
* workflow YAML. Merged (deduped) with any skills the dispatching
|
|
187
|
+
* Job/Schedule/chat passes at runtime — so a pipeline can carry a default
|
|
188
|
+
* skill (e.g. prelude) regardless of how it's triggered, including manual
|
|
189
|
+
* Fire which passes no opts.skills. Composed into each task's
|
|
190
|
+
* --append-system-prompt via renderSkillsAppendPrompt + auto-installed. */
|
|
191
|
+
skills?: string[];
|
|
185
192
|
/** Default execution backend for every node's task. 'tmux' runs interactive
|
|
186
193
|
* claude in a per-node tmux session (subscription billing); 'headless' /
|
|
187
194
|
* omitted uses default `claude -p`. Per-node `backend:` overrides this. */
|
|
@@ -535,6 +542,9 @@ export function parseWorkflow(raw: string): Workflow {
|
|
|
535
542
|
// would newly enable the preflight on existing fortinet pipelines, an
|
|
536
543
|
// unrelated behavior change that belongs in its own commit.
|
|
537
544
|
requires_real_project: parsed.requires_real_project === true,
|
|
545
|
+
skills: Array.isArray(parsed.skills)
|
|
546
|
+
? parsed.skills.filter((s: any) => typeof s === 'string' && s.trim()).map((s: string) => s.trim())
|
|
547
|
+
: undefined,
|
|
538
548
|
};
|
|
539
549
|
}
|
|
540
550
|
|
|
@@ -1093,7 +1103,10 @@ export function startPipeline(
|
|
|
1093
1103
|
nodes,
|
|
1094
1104
|
nodeOrder,
|
|
1095
1105
|
createdAt: new Date().toISOString(),
|
|
1096
|
-
skills:
|
|
1106
|
+
skills: (() => {
|
|
1107
|
+
const merged = [...(workflow.skills || []), ...(opts.skills || [])];
|
|
1108
|
+
return merged.length ? [...new Set(merged)] : undefined;
|
|
1109
|
+
})(),
|
|
1097
1110
|
// Runtime override (e.g. chat "use tmux") wins over the workflow's declared default.
|
|
1098
1111
|
backend: opts.backend ?? workflow.backend,
|
|
1099
1112
|
forEach: forEachState,
|
|
@@ -1274,7 +1287,10 @@ function scheduleNextConversationTurn(pipeline: Pipeline, contextForAgent: strin
|
|
|
1274
1287
|
pipelineTaskIds.add(task.id);
|
|
1275
1288
|
{
|
|
1276
1289
|
const skillsAppend = renderSkillsAppendPrompt(pipeline.skills);
|
|
1277
|
-
if (skillsAppend)
|
|
1290
|
+
if (skillsAppend) {
|
|
1291
|
+
taskAppendSystemPromptOverrides.set(task.id, skillsAppend);
|
|
1292
|
+
taskSkillsOverrides.set(task.id, pipeline.skills!);
|
|
1293
|
+
}
|
|
1278
1294
|
}
|
|
1279
1295
|
|
|
1280
1296
|
// Add pending message
|
|
@@ -2157,6 +2173,7 @@ async function scheduleReadyNodes(pipeline: Pipeline, workflow: Workflow) {
|
|
|
2157
2173
|
const skillsAppend = renderSkillsAppendPrompt(pipeline.skills);
|
|
2158
2174
|
if (skillsAppend) {
|
|
2159
2175
|
taskAppendSystemPromptOverrides.set(task.id, skillsAppend);
|
|
2176
|
+
taskSkillsOverrides.set(task.id, pipeline.skills!);
|
|
2160
2177
|
}
|
|
2161
2178
|
// Pipeline tasks use the same model selection as normal tasks
|
|
2162
2179
|
// (per-task override > agent scene model > agent default).
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pre-install an EnvHttpProxyAgent on undici's global dispatcher BEFORE
|
|
3
|
+
* anything dynamic-imports the npm undici (8.x).
|
|
4
|
+
*
|
|
5
|
+
* Why: Node honours NODE_USE_ENV_PROXY by putting EnvHttpProxyAgent on
|
|
6
|
+
* undici v1's dispatcher symbol. The npm undici uses v2; on load its
|
|
7
|
+
* `if (getGlobalDispatcher() === undefined) setGlobalDispatcher(new Agent())`
|
|
8
|
+
* reads v2 (undefined) and writes a plain Agent into BOTH v1 and v2,
|
|
9
|
+
* silently clobbering Node's proxy agent — after which every fetch ignores
|
|
10
|
+
* HTTPS_PROXY and direct-connects (ENETUNREACH behind corp ZTNA).
|
|
11
|
+
* Setting v2 here makes that check non-undefined, so npm undici skips it.
|
|
12
|
+
*
|
|
13
|
+
* MUST be the FIRST import in every entry point. Off-corp (no proxy env)
|
|
14
|
+
* this is a no-op, so non-docker / standalone deployments are unchanged.
|
|
15
|
+
*/
|
|
16
|
+
import { setGlobalDispatcher, EnvHttpProxyAgent } from 'undici';
|
|
17
|
+
|
|
18
|
+
if (process.env.HTTPS_PROXY || process.env.HTTP_PROXY ||
|
|
19
|
+
process.env.https_proxy || process.env.http_proxy) {
|
|
20
|
+
setGlobalDispatcher(new EnvHttpProxyAgent());
|
|
21
|
+
}
|
package/lib/skills.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { getDb } from '../src/core/db/database';
|
|
|
9
9
|
import { getDbPath } from '../src/config';
|
|
10
10
|
import { loadSettings } from './settings';
|
|
11
11
|
import { getClaudeDir } from './dirs';
|
|
12
|
+
import { listSources, fetchSourceFile, type SourceMeta } from './connectors/sync';
|
|
12
13
|
|
|
13
14
|
type ItemType = 'skill' | 'command';
|
|
14
15
|
|
|
@@ -30,6 +31,11 @@ interface SkillItem {
|
|
|
30
31
|
deletedRemotely: boolean;
|
|
31
32
|
/** 'registry' (synced from forge-skills) or 'local' (uploaded). */
|
|
32
33
|
source: 'registry' | 'local';
|
|
34
|
+
/** Which marketplace source the registry row came from: 'public' or an
|
|
35
|
+
* 'enterprise-<tenant>' id. Lets the UI show a per-skill source badge. */
|
|
36
|
+
sourceId: string;
|
|
37
|
+
/** True when sourceId is an enterprise source (for the 🔒 badge). */
|
|
38
|
+
isEnterprise: boolean;
|
|
33
39
|
}
|
|
34
40
|
|
|
35
41
|
function db() {
|
|
@@ -49,6 +55,40 @@ function getRepoInfo(): { owner: string; repo: string; branch: string } {
|
|
|
49
55
|
return { owner: 'aiwatching', repo: 'forge-skills', branch: 'main' };
|
|
50
56
|
}
|
|
51
57
|
|
|
58
|
+
// ─── Multi-source (#354) ──────────────────────────────────────
|
|
59
|
+
//
|
|
60
|
+
// Skills sync from the same source set as connectors: enterprise sources
|
|
61
|
+
// (by priority) layered OVER the public marketplace. We reuse the connector
|
|
62
|
+
// module's SourceMeta + fetchSourceFile (it already handles raw vs github_api
|
|
63
|
+
// + private-repo PAT auth), but the PUBLIC source must point at forge-skills,
|
|
64
|
+
// not forge-connectors — so we take the enterprise entries from listSources()
|
|
65
|
+
// and swap in the skills public base.
|
|
66
|
+
|
|
67
|
+
/** Ordered skill sources, highest priority first, public last. */
|
|
68
|
+
function listSkillSources(): SourceMeta[] {
|
|
69
|
+
const enterprise = listSources().filter((s) => s.is_enterprise);
|
|
70
|
+
const publicSrc: SourceMeta = {
|
|
71
|
+
id: 'public',
|
|
72
|
+
display_name: 'Public',
|
|
73
|
+
is_enterprise: false,
|
|
74
|
+
priority: enterprise.length,
|
|
75
|
+
fetch_mode: 'raw',
|
|
76
|
+
base_url: getBaseUrl(),
|
|
77
|
+
};
|
|
78
|
+
return [...enterprise, publicSrc];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function sourceById(id: string): SourceMeta | undefined {
|
|
82
|
+
return listSkillSources().find((s) => s.id === id);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** "owner/repo" for a source — for the source_repo DB column. */
|
|
86
|
+
function sourceRepoOf(s: SourceMeta): string {
|
|
87
|
+
if (s.is_enterprise) return (s.repo_url || '').replace(/^github\.com\//, '');
|
|
88
|
+
const r = getRepoInfo();
|
|
89
|
+
return `${r.owner}/${r.repo}`;
|
|
90
|
+
}
|
|
91
|
+
|
|
52
92
|
function compareVersions(a: string, b: string): number {
|
|
53
93
|
const pa = (a || '0.0.0').split('.').map(Number);
|
|
54
94
|
const pb = (b || '0.0.0').split('.').map(Number);
|
|
@@ -64,85 +104,112 @@ function compareVersions(a: string, b: string): number {
|
|
|
64
104
|
/** Max info.json enrichments per sync (incremental) */
|
|
65
105
|
const ENRICH_BATCH_SIZE = 10;
|
|
66
106
|
|
|
107
|
+
function parseRegistryItems(data: any): any[] {
|
|
108
|
+
if (data && data.version === 2) {
|
|
109
|
+
return [
|
|
110
|
+
...(data.skills || []).map((s: any) => ({ ...s, type: s.type || 'skill' })),
|
|
111
|
+
...(data.commands || []).map((c: any) => ({ ...c, type: c.type || 'command' })),
|
|
112
|
+
];
|
|
113
|
+
}
|
|
114
|
+
return (data?.skills || []).map((s: any) => ({ ...s, type: s.type || 'skill' }));
|
|
115
|
+
}
|
|
116
|
+
|
|
67
117
|
export async function syncSkills(): Promise<{ synced: number; enriched: number; total?: number; remaining?: number; error?: string }> {
|
|
68
118
|
console.log('[skills] Syncing from registry...');
|
|
69
119
|
const baseUrl = getBaseUrl();
|
|
120
|
+
const cacheBust = `_t=${Date.now()}`;
|
|
70
121
|
|
|
71
122
|
try {
|
|
72
|
-
// Step 1:
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
headers: { 'Accept': 'application/json', 'Cache-Control': 'no-cache' },
|
|
79
|
-
});
|
|
80
|
-
clearTimeout(timeout);
|
|
81
|
-
|
|
82
|
-
if (!res.ok) return { synced: 0, enriched: 0, error: `Registry fetch failed: ${res.status}` };
|
|
123
|
+
// Step 1+2: fetch + upsert from EVERY source. Process lowest-priority
|
|
124
|
+
// first (public), then enterprise — so an enterprise skill of the same
|
|
125
|
+
// name overwrites the public one on INSERT OR REPLACE (enterprise wins,
|
|
126
|
+
// matching connector/pipeline precedence). Each row records which source
|
|
127
|
+
// it came from so installGlobal knows where (and with what auth) to pull.
|
|
128
|
+
const ordered = [...listSkillSources()].reverse(); // public → … → highest enterprise
|
|
83
129
|
|
|
84
|
-
const data = await res.json();
|
|
85
|
-
|
|
86
|
-
// Parse registry items (v1 + v2 support)
|
|
87
|
-
let rawItems: any[] = [];
|
|
88
|
-
if (data.version === 2) {
|
|
89
|
-
rawItems = [
|
|
90
|
-
...(data.skills || []).map((s: any) => ({ ...s, type: s.type || 'skill' })),
|
|
91
|
-
...(data.commands || []).map((c: any) => ({ ...c, type: c.type || 'command' })),
|
|
92
|
-
];
|
|
93
|
-
} else {
|
|
94
|
-
rawItems = (data.skills || []).map((s: any) => ({ ...s, type: s.type || 'command' }));
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
// Step 2: Upsert all items from registry.json directly (fast, no extra fetch)
|
|
98
130
|
const upsertStmt = db().prepare(`
|
|
99
|
-
INSERT OR REPLACE INTO skills (name, type, display_name, description, author, version, tags, score, rating, source_url, archive,
|
|
131
|
+
INSERT OR REPLACE INTO skills (name, type, display_name, description, author, version, tags, score, rating, source_url, archive,
|
|
132
|
+
source_id, source_repo, source_branch, synced_at,
|
|
100
133
|
installed_global, installed_projects, installed_version)
|
|
101
134
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
|
|
135
|
+
?, ?, ?,
|
|
102
136
|
COALESCE((SELECT synced_at FROM skills WHERE name = ?), datetime('now')),
|
|
103
137
|
COALESCE((SELECT installed_global FROM skills WHERE name = ?), 0),
|
|
104
138
|
COALESCE((SELECT installed_projects FROM skills WHERE name = ?), '[]'),
|
|
105
139
|
COALESCE((SELECT installed_version FROM skills WHERE name = ?), ''))
|
|
106
140
|
`);
|
|
107
141
|
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
);
|
|
142
|
+
const registryNames = new Set<string>();
|
|
143
|
+
const sourceErrors: string[] = [];
|
|
144
|
+
let syncedTotal = 0;
|
|
145
|
+
let anyOk = false;
|
|
146
|
+
|
|
147
|
+
for (const source of ordered) {
|
|
148
|
+
let items: any[];
|
|
149
|
+
try {
|
|
150
|
+
// fetchSourceFile busts cache internally (raw appends ?_t, github_api
|
|
151
|
+
// appends &_t) — pass the bare path so we don't double the query string.
|
|
152
|
+
const text = await fetchSourceFile(source, 'registry.json');
|
|
153
|
+
items = parseRegistryItems(JSON.parse(text));
|
|
154
|
+
} catch (e) {
|
|
155
|
+
// One source down (offline / PAT issue) must not wipe the others.
|
|
156
|
+
sourceErrors.push(`${source.id}: ${e instanceof Error ? e.message : String(e)}`);
|
|
157
|
+
continue;
|
|
119
158
|
}
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
159
|
+
anyOk = true;
|
|
160
|
+
const repo = sourceRepoOf(source);
|
|
161
|
+
const branch = source.is_enterprise ? 'main' : getRepoInfo().branch;
|
|
162
|
+
const tx = db().transaction(() => {
|
|
163
|
+
for (const s of items) {
|
|
164
|
+
if (!s.name) continue;
|
|
165
|
+
upsertStmt.run(
|
|
166
|
+
s.name, s.type || 'skill',
|
|
167
|
+
s.display_name || '', s.description || '',
|
|
168
|
+
(s.author?.name || s.author || '').toString(), s.version || '',
|
|
169
|
+
JSON.stringify(s.tags || []),
|
|
170
|
+
s.score ?? 0, s.rating ?? 0, s.source?.url || s.source_url || '',
|
|
171
|
+
'', // archive
|
|
172
|
+
source.id, repo, branch,
|
|
173
|
+
s.name, s.name, s.name, s.name
|
|
174
|
+
);
|
|
175
|
+
registryNames.add(s.name);
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
tx();
|
|
179
|
+
syncedTotal += items.length;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (!anyOk) {
|
|
183
|
+
// Every source failed (typically offline) — leave the DB untouched.
|
|
184
|
+
return { synced: 0, enriched: 0, error: sourceErrors.join('; ') || 'all sources unreachable' };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Step 3: prune items no longer in ANY registry — but ONLY when every
|
|
188
|
+
// source synced cleanly. If an enterprise source was transiently down,
|
|
189
|
+
// skipping this avoids wrongly deleting its (still-valid) skills.
|
|
190
|
+
// 'local'-source rows are never pruned (user uploads, no upstream).
|
|
191
|
+
if (sourceErrors.length === 0) {
|
|
192
|
+
const dbItems = db().prepare('SELECT name, installed_global, installed_projects, source FROM skills').all() as any[];
|
|
193
|
+
for (const row of dbItems) {
|
|
194
|
+
if (row.source === 'local') continue;
|
|
195
|
+
if (!registryNames.has(row.name)) {
|
|
196
|
+
const hasLocal = !!row.installed_global || JSON.parse(row.installed_projects || '[]').length > 0;
|
|
197
|
+
if (hasLocal) {
|
|
198
|
+
db().prepare('UPDATE skills SET deleted_remotely = 1 WHERE name = ?').run(row.name);
|
|
199
|
+
} else {
|
|
200
|
+
db().prepare('DELETE FROM skills WHERE name = ?').run(row.name);
|
|
201
|
+
}
|
|
137
202
|
}
|
|
138
203
|
}
|
|
139
204
|
}
|
|
140
205
|
|
|
141
|
-
// Step 4: Incremental enrichment
|
|
142
|
-
//
|
|
206
|
+
// Step 4: Incremental enrichment from info.json — PUBLIC source only.
|
|
207
|
+
// Enterprise registry.json entries are authored in full (version, tags,
|
|
208
|
+
// description), so they need no enrichment; and their info.json lives in a
|
|
209
|
+
// private repo that the plain fetch() below can't read anyway.
|
|
143
210
|
const staleItems = db().prepare(`
|
|
144
211
|
SELECT name, type, version FROM skills
|
|
145
|
-
WHERE deleted_remotely = 0
|
|
212
|
+
WHERE deleted_remotely = 0 AND source_id = 'public'
|
|
146
213
|
ORDER BY synced_at ASC
|
|
147
214
|
LIMIT ?
|
|
148
215
|
`).all(ENRICH_BATCH_SIZE) as any[];
|
|
@@ -190,8 +257,16 @@ export async function syncSkills(): Promise<{ synced: number; enriched: number;
|
|
|
190
257
|
|
|
191
258
|
const totalCount = (db().prepare('SELECT count(*) as c FROM skills WHERE deleted_remotely = 0').get() as any).c;
|
|
192
259
|
const remaining = totalCount - ENRICH_BATCH_SIZE; // approximate items not yet enriched this round
|
|
193
|
-
console.log(`[skills] Synced ${
|
|
194
|
-
return {
|
|
260
|
+
console.log(`[skills] Synced ${syncedTotal} items across ${ordered.length} source(s), enriched ${enriched}/${staleItems.length} from info.json`);
|
|
261
|
+
return {
|
|
262
|
+
synced: syncedTotal,
|
|
263
|
+
enriched,
|
|
264
|
+
total: totalCount,
|
|
265
|
+
remaining: Math.max(0, remaining),
|
|
266
|
+
// Surface a partial-failure note (e.g. one enterprise source down) while
|
|
267
|
+
// still reporting success for the sources that did sync.
|
|
268
|
+
error: sourceErrors.length ? `partial: ${sourceErrors.join('; ')}` : undefined,
|
|
269
|
+
};
|
|
195
270
|
} catch (e) {
|
|
196
271
|
const msg = e instanceof Error ? e.message : String(e);
|
|
197
272
|
// Corp networks routinely can't reach GitHub raw — this is expected
|
|
@@ -226,6 +301,8 @@ export function listSkills(): SkillItem[] {
|
|
|
226
301
|
hasUpdate: isInstalled && !!registryVersion && !!installedVersion && compareVersions(registryVersion, installedVersion) > 0,
|
|
227
302
|
deletedRemotely: !!r.deleted_remotely,
|
|
228
303
|
source: r.source === 'local' ? 'local' : 'registry',
|
|
304
|
+
sourceId: r.source_id || 'public',
|
|
305
|
+
isEnterprise: typeof r.source_id === 'string' && r.source_id.startsWith('enterprise-'),
|
|
229
306
|
};
|
|
230
307
|
});
|
|
231
308
|
}
|
|
@@ -269,6 +346,59 @@ async function downloadFile(url: string): Promise<string> {
|
|
|
269
346
|
return res.text();
|
|
270
347
|
}
|
|
271
348
|
|
|
349
|
+
/** Recursively list a directory in a private enterprise repo via the GitHub
|
|
350
|
+
* contents API (PAT-authenticated). Returns repo-relative file paths. */
|
|
351
|
+
async function listEnterpriseDir(source: SourceMeta, dirPath: string): Promise<string[]> {
|
|
352
|
+
const repo = (source.repo_url || '').replace(/^github\.com\//, '');
|
|
353
|
+
const out: string[] = [];
|
|
354
|
+
async function recurse(path: string): Promise<void> {
|
|
355
|
+
const url = `https://api.github.com/repos/${repo}/contents/${path}?ref=main`;
|
|
356
|
+
const res = await fetch(url, {
|
|
357
|
+
headers: {
|
|
358
|
+
Authorization: `Bearer ${source.github_pat}`,
|
|
359
|
+
Accept: 'application/vnd.github.v3+json',
|
|
360
|
+
'User-Agent': 'forge-skills-sync/1.0',
|
|
361
|
+
},
|
|
362
|
+
});
|
|
363
|
+
if (!res.ok) return;
|
|
364
|
+
const items = await res.json();
|
|
365
|
+
if (!Array.isArray(items)) return;
|
|
366
|
+
for (const it of items) {
|
|
367
|
+
if (it.type === 'file') out.push(it.path);
|
|
368
|
+
else if (it.type === 'dir') await recurse(it.path);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
await recurse(dirPath);
|
|
372
|
+
return out;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** Source-aware fetch of all files for a skill/command. Public skills use the
|
|
376
|
+
* unauthenticated GitHub contents API; enterprise skills list + download via
|
|
377
|
+
* the recorded source's PAT (private repos). Returns local-relative paths. */
|
|
378
|
+
async function fetchSkillFiles(name: string, type: ItemType, sourceId: string): Promise<{ path: string; content: string }[]> {
|
|
379
|
+
const source = sourceById(sourceId);
|
|
380
|
+
// Public (or a source no longer configured) → legacy unauthenticated path.
|
|
381
|
+
if (!source || !source.is_enterprise) {
|
|
382
|
+
const files = await listRepoFiles(name, type);
|
|
383
|
+
const out: { path: string; content: string }[] = [];
|
|
384
|
+
for (const f of files) out.push({ path: f.path, content: await downloadFile(f.download_url) });
|
|
385
|
+
return out;
|
|
386
|
+
}
|
|
387
|
+
// Enterprise → private repo: list with PAT, fetch each via fetchSourceFile.
|
|
388
|
+
const dirs = type === 'skill' ? ['skills', 'commands'] : ['commands', 'skills'];
|
|
389
|
+
for (const dir of dirs) {
|
|
390
|
+
const repoPaths = await listEnterpriseDir(source, `${dir}/${name}`);
|
|
391
|
+
if (repoPaths.length === 0) continue;
|
|
392
|
+
const stripRe = new RegExp(`^${dir}/${name}/`);
|
|
393
|
+
const out: { path: string; content: string }[] = [];
|
|
394
|
+
for (const p of repoPaths) {
|
|
395
|
+
out.push({ path: p.replace(stripRe, ''), content: await fetchSourceFile(source, p) });
|
|
396
|
+
}
|
|
397
|
+
return out;
|
|
398
|
+
}
|
|
399
|
+
return [];
|
|
400
|
+
}
|
|
401
|
+
|
|
272
402
|
// ─── Install ─────────────────────────────────────────────────
|
|
273
403
|
|
|
274
404
|
function getClaudeHome(): string {
|
|
@@ -290,15 +420,14 @@ export async function installGlobal(name: string): Promise<void> {
|
|
|
290
420
|
const subdir = type === 'skill' ? 'skills' : 'commands';
|
|
291
421
|
const targetDir = join(claudeHome, subdir, name);
|
|
292
422
|
|
|
293
|
-
const files = await
|
|
423
|
+
const files = await fetchSkillFiles(name, type, skill.source_id || 'public');
|
|
294
424
|
if (files.length === 0) throw new Error(`No files found for ${name}`);
|
|
295
425
|
|
|
296
426
|
mkdirSync(targetDir, { recursive: true });
|
|
297
427
|
for (const f of files) {
|
|
298
|
-
const content = await downloadFile(f.download_url);
|
|
299
428
|
const targetPath = join(targetDir, f.path);
|
|
300
429
|
mkdirSync(dirname(targetPath), { recursive: true });
|
|
301
|
-
writeFileSync(targetPath, content);
|
|
430
|
+
writeFileSync(targetPath, f.content);
|
|
302
431
|
}
|
|
303
432
|
|
|
304
433
|
// Update installed state
|
|
@@ -333,15 +462,14 @@ export async function installProject(name: string, projectPath: string): Promise
|
|
|
333
462
|
const subdir = type === 'skill' ? 'skills' : 'commands';
|
|
334
463
|
const targetDir = join(projectPath, '.claude', subdir, name);
|
|
335
464
|
|
|
336
|
-
const files = await
|
|
465
|
+
const files = await fetchSkillFiles(name, type, skill.source_id || 'public');
|
|
337
466
|
if (files.length === 0) throw new Error(`No files found for ${name}`);
|
|
338
467
|
|
|
339
468
|
mkdirSync(targetDir, { recursive: true });
|
|
340
469
|
for (const f of files) {
|
|
341
|
-
const content = await downloadFile(f.download_url);
|
|
342
470
|
const targetPath = join(targetDir, f.path);
|
|
343
471
|
mkdirSync(dirname(targetPath), { recursive: true });
|
|
344
|
-
writeFileSync(targetPath, content);
|
|
472
|
+
writeFileSync(targetPath, f.content);
|
|
345
473
|
}
|
|
346
474
|
|
|
347
475
|
// Update installed state
|
package/lib/task-manager.ts
CHANGED
|
@@ -69,6 +69,19 @@ export const taskModelOverrides = new Map<string, string>();
|
|
|
69
69
|
*/
|
|
70
70
|
export const taskAppendSystemPromptOverrides = new Map<string, string>();
|
|
71
71
|
|
|
72
|
+
/**
|
|
73
|
+
* Skill names attached to a task (parallel to the append-system-prompt
|
|
74
|
+
* override above). Purely for observability — surfaced in the task's
|
|
75
|
+
* init log so a pipeline run shows which skills it loaded.
|
|
76
|
+
*/
|
|
77
|
+
export const taskSkillsOverrides = new Map<string, string[]>();
|
|
78
|
+
|
|
79
|
+
/** ` | Skills: a, b` for the init log, or '' when none attached. */
|
|
80
|
+
function skillsLogSuffix(taskId: string): string {
|
|
81
|
+
const s = taskSkillsOverrides.get(taskId);
|
|
82
|
+
return s && s.length ? ` | Skills: ${s.join(', ')}` : '';
|
|
83
|
+
}
|
|
84
|
+
|
|
72
85
|
// ─── CRUD ────────────────────────────────────────────────────
|
|
73
86
|
|
|
74
87
|
export function createTask(opts: {
|
|
@@ -538,7 +551,7 @@ function executeTmuxBackendTask(task: Task): Promise<void> {
|
|
|
538
551
|
|
|
539
552
|
updateTaskStatus(task.id, 'running');
|
|
540
553
|
db().prepare('UPDATE tasks SET started_at = datetime(\'now\') WHERE id = ?').run(task.id);
|
|
541
|
-
appendLog(task.id, { type: 'system', subtype: 'init', content: `Agent: ${(task as any).agent || 'claude'} | Backend: tmux`, timestamp: new Date().toISOString() });
|
|
554
|
+
appendLog(task.id, { type: 'system', subtype: 'init', content: `Agent: ${(task as any).agent || 'claude'} | Backend: tmux${skillsLogSuffix(task.id)}`, timestamp: new Date().toISOString() });
|
|
542
555
|
|
|
543
556
|
return executeTmuxTask(task, {
|
|
544
557
|
appendLog: (entry) => appendLog(task.id, entry),
|
|
@@ -670,7 +683,7 @@ function executeTask(task: Task): Promise<void> {
|
|
|
670
683
|
console.log(`[task] ${task.projectName} [${agentName}${supportsModel && model ? '/' + model : ''}]: "${task.prompt.slice(0, 60)}..."`);
|
|
671
684
|
|
|
672
685
|
// Log agent info as first entry
|
|
673
|
-
appendLog(task.id, { type: 'system', subtype: 'init', content: `Agent: ${agentName}${supportsModel && model && model !== 'default' ? ` | Model: ${model}` : ''}`, timestamp: new Date().toISOString() });
|
|
686
|
+
appendLog(task.id, { type: 'system', subtype: 'init', content: `Agent: ${agentName}${supportsModel && model && model !== 'default' ? ` | Model: ${model}` : ''}${skillsLogSuffix(task.id)}`, timestamp: new Date().toISOString() });
|
|
674
687
|
|
|
675
688
|
const needsTTY = adapter.config.capabilities?.requiresTTY;
|
|
676
689
|
let child: any;
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
* Usage: npx tsx lib/terminal-standalone.ts
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
|
+
import './proxy-setup'; // MUST be first — see proxy-setup.ts header.
|
|
27
28
|
import { WebSocketServer, WebSocket } from 'ws';
|
|
28
29
|
import * as pty from 'node-pty';
|
|
29
30
|
import { execSync } from 'node:child_process';
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* FORGE_DATA_DIR — data directory
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
+
import './proxy-setup'; // MUST be first — see proxy-setup.ts header.
|
|
15
16
|
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
|
16
17
|
import { readdirSync, statSync } from 'node:fs';
|
|
17
18
|
import { join, resolve } from 'node:path';
|
package/package.json
CHANGED
package/src/core/db/database.ts
CHANGED
|
@@ -246,6 +246,13 @@ function initSchema(db: Database.Database) {
|
|
|
246
246
|
// Local skills are kept across syncs (they're not in the remote registry,
|
|
247
247
|
// so the deleted_remotely housekeeping would otherwise wipe them).
|
|
248
248
|
migrate("ALTER TABLE skills ADD COLUMN source TEXT NOT NULL DEFAULT 'registry'");
|
|
249
|
+
// Multi-source skill sync (#354): which marketplace source a registry skill
|
|
250
|
+
// came from, so installGlobal knows where (and with what auth) to fetch its
|
|
251
|
+
// files. 'public' = the public forge-skills repo; otherwise an enterprise
|
|
252
|
+
// tenant_id. source_repo is "owner/repo", source_branch the ref.
|
|
253
|
+
migrate("ALTER TABLE skills ADD COLUMN source_id TEXT NOT NULL DEFAULT 'public'");
|
|
254
|
+
migrate("ALTER TABLE skills ADD COLUMN source_repo TEXT NOT NULL DEFAULT ''");
|
|
255
|
+
migrate("ALTER TABLE skills ADD COLUMN source_branch TEXT NOT NULL DEFAULT 'main'");
|
|
249
256
|
migrate('ALTER TABLE project_pipelines ADD COLUMN last_run_at TEXT');
|
|
250
257
|
migrate('ALTER TABLE pipeline_runs ADD COLUMN dedup_key TEXT');
|
|
251
258
|
migrate("ALTER TABLE tasks ADD COLUMN agent TEXT DEFAULT 'claude'");
|