@ikarmic/forge-core 5.1.0 → 5.2.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/README.md +1 -1
- package/dist/agent/agent.js +39 -5
- package/dist/agent/goal.d.ts +5 -8
- package/dist/agent/prompts.d.ts +25 -1
- package/dist/agent/prompts.js +61 -17
- package/dist/agent/race-abort.d.ts +8 -0
- package/dist/agent/race-abort.js +24 -0
- package/dist/agent/stack-facts.d.ts +23 -0
- package/dist/agent/stack-facts.js +150 -0
- package/dist/code-index/parser.js +48 -3
- package/dist/github/github-tools.d.ts +2 -18
- package/dist/index.d.ts +7 -3
- package/dist/index.js +8 -3
- package/dist/journey/answers-block.d.ts +17 -0
- package/dist/journey/answers-block.js +28 -0
- package/dist/journey/index.d.ts +9 -0
- package/dist/journey/index.js +8 -0
- package/dist/journey/journey.d.ts +13 -0
- package/dist/journey/journey.js +40 -0
- package/dist/journey/offer.d.ts +25 -0
- package/dist/journey/offer.js +49 -0
- package/dist/journey/questions.d.ts +23 -0
- package/dist/journey/questions.js +49 -0
- package/dist/journey/stages.d.ts +13 -0
- package/dist/journey/stages.js +107 -0
- package/dist/journey/survey.d.ts +10 -0
- package/dist/journey/survey.js +203 -0
- package/dist/journey/trigger.d.ts +19 -0
- package/dist/journey/trigger.js +79 -0
- package/dist/journey/types.d.ts +72 -0
- package/dist/journey/types.js +1 -0
- package/dist/mcp/json-schema-to-zod.js +12 -1
- package/dist/orchestrator/index.d.ts +1 -1
- package/dist/orchestrator/index.js +1 -1
- package/dist/orchestrator/orchestrator.d.ts +45 -6
- package/dist/orchestrator/orchestrator.js +62 -12
- package/dist/orchestrator/router.d.ts +1 -1
- package/dist/orchestrator/router.js +4 -1
- package/dist/orchestrator/specialists.d.ts +6 -1
- package/dist/orchestrator/specialists.js +165 -68
- package/dist/orchestrator/types.d.ts +1 -1
- package/dist/policy/policy.d.ts +6 -59
- package/dist/pricing.d.ts +2 -0
- package/dist/pricing.js +35 -16
- package/dist/skills/builtin.js +1 -1
- package/dist/specs/generate.d.ts +18 -9
- package/dist/specs/generate.js +174 -14
- package/dist/specs/index.d.ts +2 -2
- package/dist/specs/index.js +1 -1
- package/dist/specs/store.d.ts +14 -2
- package/dist/specs/store.js +26 -1
- package/dist/specs/types.d.ts +11 -0
- package/dist/structured/schema.js +9 -12
- package/dist/tools/apply-patch.d.ts +1 -5
- package/dist/tools/ask-user.d.ts +2 -10
- package/dist/tools/ask-user.js +39 -3
- package/dist/tools/browser-tools.d.ts +15 -31
- package/dist/tools/codebase-search.d.ts +1 -7
- package/dist/tools/dependency-audit.d.ts +30 -0
- package/dist/tools/dependency-audit.js +197 -0
- package/dist/tools/edit-file.d.ts +1 -15
- package/dist/tools/fetch-url.d.ts +1 -18
- package/dist/tools/find-references.d.ts +1 -5
- package/dist/tools/find-symbol.d.ts +10 -8
- package/dist/tools/git-diff.d.ts +13 -0
- package/dist/tools/git-diff.js +85 -0
- package/dist/tools/index.d.ts +3 -1
- package/dist/tools/index.js +5 -1
- package/dist/tools/list-files.d.ts +1 -19
- package/dist/tools/list-symbols.d.ts +1 -5
- package/dist/tools/memory.d.ts +23 -43
- package/dist/tools/notebook.d.ts +11 -17
- package/dist/tools/read-file.d.ts +1 -11
- package/dist/tools/read-file.js +5 -0
- package/dist/tools/read-image.d.ts +1 -12
- package/dist/tools/read-only.d.ts +1 -1
- package/dist/tools/read-only.js +2 -0
- package/dist/tools/run-command.d.ts +1 -17
- package/dist/tools/search-files.d.ts +1 -17
- package/dist/tools/sub-agent-wait.d.ts +2 -8
- package/dist/tools/sub-agent.d.ts +2 -70
- package/dist/tools/sub-agent.js +3 -0
- package/dist/tools/todo.d.ts +8 -20
- package/dist/tools/web-search.d.ts +1 -7
- package/dist/tools/write-file.d.ts +1 -7
- package/dist/types.d.ts +27 -0
- package/package.json +3 -3
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tier 1 of the journey trigger: does this message deserve a read-only Survey?
|
|
3
|
+
*
|
|
4
|
+
* It decides whether to *look*, not whether to show a card. A false positive
|
|
5
|
+
* costs a few auto-approved, token-capped reads; a false positive on the card
|
|
6
|
+
* is the nagging that kills the feature (spec anti-goal 1). So this is
|
|
7
|
+
* deliberately generous and the vetoes carry the weight.
|
|
8
|
+
*
|
|
9
|
+
* Signals are noun-driven, not verb-driven: a change verb on its own trips on
|
|
10
|
+
* everything ("add a button"), so it only counts beside a noun that implies
|
|
11
|
+
* many files.
|
|
12
|
+
*/
|
|
13
|
+
import type { TriggerVerdict } from './types.js';
|
|
14
|
+
/**
|
|
15
|
+
* `typed` is what the person wrote, `sent` what goes to the model. They differ
|
|
16
|
+
* when a slash command, skill or `@agent` mention expanded into a long body;
|
|
17
|
+
* judging that text would offer a journey for a one-line request (RB-16).
|
|
18
|
+
*/
|
|
19
|
+
export declare function shouldSurvey(typed: string, sent: string): TriggerVerdict;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
const BUILD_VERBS = /\b(?:build|create|make|develop|scaffold|set up|generate|write me)\b/i;
|
|
2
|
+
const CHANGE_VERBS = /\b(?:add|extend|integrate|migrate|refactor|support|introduce|replace|rework|wire up|hook up|implement)\b/i;
|
|
3
|
+
const SYSTEM_NOUNS = /\b(?:app|application|system|platform|crm|erp|cms|web ?site|api|backend|frontend|dashboard|portal|saas|marketplace|game)\b/i;
|
|
4
|
+
/** Nouns that imply many files rather than one element. */
|
|
5
|
+
const SCALE_NOUNS = /\b(?:module|subsystem|service|integration|pipeline|workflow|migration|multi-?tenancy|auth|authentication|authorisation|authorization|sso|oauth|billing|payments|notifications|search|reporting|analytics|audit log|admin panel|permissions|rbac|onboarding|checkout)\b/i;
|
|
6
|
+
/** Hard vetoes: the person has already told us the scope is small. */
|
|
7
|
+
const SMALL_VERBS = /\b(?:fix|typo|rename|bump|tweak|format|indent|lint|spelling|revert)\b/i;
|
|
8
|
+
const UI_ATOM = /\b(?:button|label|icon|tooltip|placeholder|colour|color|margin|padding|border|font)\b/i;
|
|
9
|
+
const CROSS_CUTTING = /\b(?:across|everywhere|all our|end-to-end|throughout|every (?:page|screen|endpoint))\b/i;
|
|
10
|
+
const PLURAL_DELIVERABLE = /\b(?:pages|endpoints|models|screens|tables|services|roles|reports)\b/i;
|
|
11
|
+
const REQUIREMENT_INTENT = /\b(?:need|want|should|must|would like|let (?:people|users|them|me))\b/i;
|
|
12
|
+
const LONGISH_WORDS = 25;
|
|
13
|
+
const MIN_LIST_SEPARATORS = 3;
|
|
14
|
+
/** Cap before any regex runs, so a pathological message cannot stall the editor. */
|
|
15
|
+
const MAX_INPUT = 4000;
|
|
16
|
+
/**
|
|
17
|
+
* Technology names that are shaped exactly like filenames. Without this,
|
|
18
|
+
* "Build me a Next.js SaaS platform" — close to the canonical phrasing of the
|
|
19
|
+
* flagship scenario — is vetoed as naming a file and silently gets nothing.
|
|
20
|
+
*/
|
|
21
|
+
const TECH_TOKENS = /\b(?:next|node|nuxt|vue|three|d3|express|react|nest|ember|backbone|socket|chart|moment|day)\.(?:js|io|ts)\b/gi;
|
|
22
|
+
/**
|
|
23
|
+
* A file path or extension means a scoped change, however big the noun beside it.
|
|
24
|
+
*
|
|
25
|
+
* Two narrowings against the version in `vscode/src/chat/large-request.ts`:
|
|
26
|
+
* technology names are removed first, and the extension must be letters, so a
|
|
27
|
+
* version number ("the 2.0 release") is not a filename.
|
|
28
|
+
*/
|
|
29
|
+
function namesAFile(text) {
|
|
30
|
+
const withoutTech = text.replace(TECH_TOKENS, ' ');
|
|
31
|
+
return /[\\/]|\.[a-z]{1,5}\b/i.test(withoutTech.replace(/[.!?]+(?=\s|$)/g, ''));
|
|
32
|
+
}
|
|
33
|
+
function listSeparators(text) {
|
|
34
|
+
const commasAndAnds = (text.match(/,|\band\b/gi) ?? []).length;
|
|
35
|
+
const bullets = (text.match(/^\s*(?:[-*•]|\d+[.)])\s/gm) ?? []).length;
|
|
36
|
+
return commasAndAnds + bullets;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* `typed` is what the person wrote, `sent` what goes to the model. They differ
|
|
40
|
+
* when a slash command, skill or `@agent` mention expanded into a long body;
|
|
41
|
+
* judging that text would offer a journey for a one-line request (RB-16).
|
|
42
|
+
*/
|
|
43
|
+
export function shouldSurvey(typed, sent) {
|
|
44
|
+
const full = typed.trim();
|
|
45
|
+
if (!full)
|
|
46
|
+
return { survey: false, reasons: ['empty'] };
|
|
47
|
+
if (full !== sent.trim())
|
|
48
|
+
return { survey: false, reasons: ['expanded command'] };
|
|
49
|
+
if (/^\/\w/.test(full))
|
|
50
|
+
return { survey: false, reasons: ['slash command'] };
|
|
51
|
+
if (/(?:^|\s)@\S/.test(full))
|
|
52
|
+
return { survey: false, reasons: ['@agent mention'] };
|
|
53
|
+
const t = full.slice(0, MAX_INPUT);
|
|
54
|
+
if (namesAFile(t))
|
|
55
|
+
return { survey: false, reasons: ['veto: names a file or path'] };
|
|
56
|
+
if (SMALL_VERBS.test(t))
|
|
57
|
+
return { survey: false, reasons: ['veto: small-change verb'] };
|
|
58
|
+
const reasons = [];
|
|
59
|
+
const change = CHANGE_VERBS.test(t);
|
|
60
|
+
if (BUILD_VERBS.test(t) && SYSTEM_NOUNS.test(t))
|
|
61
|
+
reasons.push('build verb + system noun');
|
|
62
|
+
if (change && SCALE_NOUNS.test(t))
|
|
63
|
+
reasons.push('change verb + scale noun');
|
|
64
|
+
if (change && SYSTEM_NOUNS.test(t))
|
|
65
|
+
reasons.push('change verb + system noun');
|
|
66
|
+
if (CROSS_CUTTING.test(t))
|
|
67
|
+
reasons.push('cross-cutting scope');
|
|
68
|
+
if (change && PLURAL_DELIVERABLE.test(t))
|
|
69
|
+
reasons.push('plural deliverables');
|
|
70
|
+
if (t.split(/\s+/).length >= LONGISH_WORDS && REQUIREMENT_INTENT.test(t) && listSeparators(t) >= MIN_LIST_SEPARATORS) {
|
|
71
|
+
reasons.push('long multi-requirement message');
|
|
72
|
+
}
|
|
73
|
+
if (reasons.length === 0)
|
|
74
|
+
return { survey: false, reasons: ['no scope signal'] };
|
|
75
|
+
// One UI element with a single weak signal is a scoped edit, not a subsystem.
|
|
76
|
+
if (UI_ATOM.test(t) && reasons.length < 2)
|
|
77
|
+
return { survey: false, reasons: ['veto: single UI element'] };
|
|
78
|
+
return { survey: true, reasons };
|
|
79
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared types for the guided journey. Types only, no logic, so every other
|
|
3
|
+
* file in `journey/` can import this one without a cycle.
|
|
4
|
+
*
|
|
5
|
+
* Design: docs/superpowers/specs/2026-10-23-guided-journey-design.md (rev 3)
|
|
6
|
+
*/
|
|
7
|
+
import type { DetectedCheck } from '../verify/detect.js';
|
|
8
|
+
/** Tier-1 verdict: whether a read-only Survey is worth running, and why. */
|
|
9
|
+
export interface TriggerVerdict {
|
|
10
|
+
survey: boolean;
|
|
11
|
+
/** Human-readable signals or vetoes; the offer card shows these. */
|
|
12
|
+
reasons: string[];
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* What the Survey read. Every field is absent or empty when it was not read —
|
|
16
|
+
* never guessed. There is deliberately no greenfield/brownfield flag: a
|
|
17
|
+
* workspace that is half-scaffolded is neither, and a label would have to lie
|
|
18
|
+
* about it. `journey-questions.test.ts` forbids that identifier anywhere under
|
|
19
|
+
* `src/`, prose included, which is why this note spells it out in words.
|
|
20
|
+
*/
|
|
21
|
+
export interface SurveyFacts {
|
|
22
|
+
manifest?: {
|
|
23
|
+
path: string;
|
|
24
|
+
packageManager?: string;
|
|
25
|
+
};
|
|
26
|
+
checks: DetectedCheck[];
|
|
27
|
+
isGitRepo: boolean;
|
|
28
|
+
hasCommits: boolean;
|
|
29
|
+
sourceFileCount: number;
|
|
30
|
+
conventionFiles: string[];
|
|
31
|
+
relatedSymbols: string[];
|
|
32
|
+
priorSpecIds: string[];
|
|
33
|
+
}
|
|
34
|
+
/** Tier-2 decision, with the evidence that earns the card. */
|
|
35
|
+
export interface JourneyOffer {
|
|
36
|
+
offer: boolean;
|
|
37
|
+
evidence: string[];
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* One interview question. `answeredBy` is the precondition: when it returns a
|
|
41
|
+
* value, the Survey already answered this and the question is never asked.
|
|
42
|
+
*/
|
|
43
|
+
export interface InterviewQuestion {
|
|
44
|
+
id: string;
|
|
45
|
+
question: string;
|
|
46
|
+
options?: string[];
|
|
47
|
+
multiSelect?: boolean;
|
|
48
|
+
answeredBy: (facts: SurveyFacts) => string | undefined;
|
|
49
|
+
}
|
|
50
|
+
/** A question the person answered, carried into the system prompt. */
|
|
51
|
+
export interface Answer {
|
|
52
|
+
id: string;
|
|
53
|
+
question: string;
|
|
54
|
+
answer: string;
|
|
55
|
+
}
|
|
56
|
+
export type StageId = 'survey' | 'interview' | 'plan' | 'spec' | 'implPlan' | 'build';
|
|
57
|
+
export interface Stage {
|
|
58
|
+
id: StageId;
|
|
59
|
+
/** Survey and Interview are host-driven and run no model at all. */
|
|
60
|
+
usesModel: boolean;
|
|
61
|
+
promptSuffix: string;
|
|
62
|
+
allowTools: readonly string[];
|
|
63
|
+
artefact: string;
|
|
64
|
+
gateOptions: readonly string[];
|
|
65
|
+
}
|
|
66
|
+
export interface JourneyState {
|
|
67
|
+
/** `'done'` once the journey is over, including when a saved stage id is unknown. */
|
|
68
|
+
current: StageId | 'done';
|
|
69
|
+
answers: Answer[];
|
|
70
|
+
/** Index into the approved `tasks.md` during the Build stage. */
|
|
71
|
+
taskCursor: number;
|
|
72
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -55,7 +55,18 @@ export function jsonSchemaToZod(schema) {
|
|
|
55
55
|
const propZod = jsonSchemaToZod(propSchema);
|
|
56
56
|
shape[key] = required.has(key) ? propZod : propZod.optional();
|
|
57
57
|
}
|
|
58
|
-
|
|
58
|
+
const objectZod = z.object(shape).passthrough();
|
|
59
|
+
// Zod 4 reads a missing `__proto__` key off the prototype chain and skips the property, so a required one is checked on the raw input first.
|
|
60
|
+
inner =
|
|
61
|
+
required.has('__proto__') && Object.prototype.hasOwnProperty.call(s.properties, '__proto__')
|
|
62
|
+
? z
|
|
63
|
+
.any()
|
|
64
|
+
.refine((v) => typeof v === 'object' && v !== null && Object.prototype.hasOwnProperty.call(v, '__proto__'), {
|
|
65
|
+
message: 'Invalid input: required property "__proto__" is missing',
|
|
66
|
+
path: ['__proto__'],
|
|
67
|
+
})
|
|
68
|
+
.pipe(objectZod)
|
|
69
|
+
: objectZod;
|
|
59
70
|
}
|
|
60
71
|
else if (s.additionalProperties === true) {
|
|
61
72
|
inner = z.record(z.string(), z.any());
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
export { BUILTIN_SPECIALISTS, getSpecialist, effectiveToolsFor } from './specialists.js';
|
|
9
9
|
export { parseRouterOutput, ROUTER_SYSTEM_PROMPT } from './router.js';
|
|
10
|
-
export { runOrchestrator, runRouter, groupStepsByParallel, resolveEffectiveModel, _dispatchGroup, type OrchestratorOptions, type OrchestratorProgressEvent, type OrchestratorWorktreeMode, } from './orchestrator.js';
|
|
10
|
+
export { runOrchestrator, runRouter, groupStepsByParallel, resolveEffectiveModel, specialistAgentConfig, handoffBlock, MAX_HANDOFF_CHARS, _dispatchGroup, type OrchestratorOptions, type StepLimits, type OrchestratorProgressEvent, type OrchestratorWorktreeMode, } from './orchestrator.js';
|
|
11
11
|
export { MessageBoard, DEFAULT_BOARD_CAP, type BoardMessage } from './board.js';
|
|
12
12
|
export { createBoardTools, BOARD_TOOL_NAMES } from './board-tools.js';
|
|
13
13
|
export { registerSpecialist, unregisterSpecialist, listSpecialists, getRegisteredSpecialist, loadSpecialistsFromConfig, saveSpecialistsToConfig, validateSpecialistConfig, defaultConfigPath, _resetSpecialistsForTests, _customSpecialistCount, type CustomSpecialistConfig, type SpecialistsConfigFile, type LoadSpecialistsResult, } from './specialists-registry.js';
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
export { BUILTIN_SPECIALISTS, getSpecialist, effectiveToolsFor } from './specialists.js';
|
|
9
9
|
export { parseRouterOutput, ROUTER_SYSTEM_PROMPT } from './router.js';
|
|
10
|
-
export { runOrchestrator, runRouter, groupStepsByParallel, resolveEffectiveModel, _dispatchGroup, } from './orchestrator.js';
|
|
10
|
+
export { runOrchestrator, runRouter, groupStepsByParallel, resolveEffectiveModel, specialistAgentConfig, handoffBlock, MAX_HANDOFF_CHARS, _dispatchGroup, } from './orchestrator.js';
|
|
11
11
|
export { MessageBoard, DEFAULT_BOARD_CAP } from './board.js';
|
|
12
12
|
export { createBoardTools, BOARD_TOOL_NAMES } from './board-tools.js';
|
|
13
13
|
export { registerSpecialist, unregisterSpecialist, listSpecialists, getRegisteredSpecialist, loadSpecialistsFromConfig, saveSpecialistsToConfig, validateSpecialistConfig, defaultConfigPath, _resetSpecialistsForTests, _customSpecialistCount, } from './specialists-registry.js';
|
|
@@ -29,7 +29,8 @@
|
|
|
29
29
|
* AND the step is part of a parallel group. Solo steps never get
|
|
30
30
|
* a worktree (it's wasted overhead).
|
|
31
31
|
*/
|
|
32
|
-
import type {
|
|
32
|
+
import type { PermissionConfig } from '../tools/permission-rules.js';
|
|
33
|
+
import type { AgentConfig, AgentRunResult, ModelConfig, ToolSandbox } from '../types.js';
|
|
33
34
|
import { MessageBoard, type BoardMessage } from './board.js';
|
|
34
35
|
import type { OrchestratorPlan, OrchestratorRunResult, OrchestratorStep, OrchestratorStepResult, SpecialistConfig } from './types.js';
|
|
35
36
|
/**
|
|
@@ -63,6 +64,12 @@ export interface OrchestratorOptions {
|
|
|
63
64
|
maxParallelism?: number;
|
|
64
65
|
/** Approval callback for the router (specialists don't ask the user: they're scoped). */
|
|
65
66
|
approve?: AgentConfig['approve'];
|
|
67
|
+
/** Tools a step runs without asking (the person's `autoApproveTools`); default: the read-only tools. */
|
|
68
|
+
autoApproveTools?: readonly string[];
|
|
69
|
+
/** The person's permission rules, kept by every step. */
|
|
70
|
+
permissionRules?: PermissionConfig;
|
|
71
|
+
/** The person's tool sandbox; a step's tools are narrowed to its `allowTools`. */
|
|
72
|
+
toolSandbox?: ToolSandbox;
|
|
66
73
|
/** Optional modelFactory override (for tests). */
|
|
67
74
|
modelFactory?: AgentConfig['modelFactory'];
|
|
68
75
|
/**
|
|
@@ -180,11 +187,7 @@ export declare function runOrchestrator(options: OrchestratorOptions): Promise<O
|
|
|
180
187
|
* git worktree. When `always`, every step gets a worktree regardless
|
|
181
188
|
* of group size. `never` disables the feature.
|
|
182
189
|
*/
|
|
183
|
-
export declare function _dispatchGroup(group: OrchestratorStep[], base: {
|
|
184
|
-
model: ModelConfig;
|
|
185
|
-
cwd?: string;
|
|
186
|
-
modelFactory?: AgentConfig['modelFactory'];
|
|
187
|
-
}, dispatchOpts: {
|
|
190
|
+
export declare function _dispatchGroup(group: OrchestratorStep[], base: StepBase, dispatchOpts: {
|
|
188
191
|
worktrees: OrchestratorWorktreeMode;
|
|
189
192
|
cleanupBranches?: boolean;
|
|
190
193
|
commitChanges?: boolean;
|
|
@@ -195,6 +198,8 @@ export declare function _dispatchGroup(group: OrchestratorStep[], base: {
|
|
|
195
198
|
}) => void;
|
|
196
199
|
onWorktreeEvent?: OrchestratorOptions['onWorktreeEvent'];
|
|
197
200
|
board?: MessageBoard;
|
|
201
|
+
/** Results of the groups that ran before this one, handed to every step of this group. */
|
|
202
|
+
prior?: readonly OrchestratorStepResult[];
|
|
198
203
|
}, maxParallelism: number): Promise<OrchestratorStepResult[]>;
|
|
199
204
|
/**
|
|
200
205
|
* Run the router agent. Exposed for testing + for the
|
|
@@ -233,3 +238,37 @@ export declare function runRouter(args: {
|
|
|
233
238
|
* without going through `Agent.run()`.
|
|
234
239
|
*/
|
|
235
240
|
export declare function resolveEffectiveModel(spec: SpecialistConfig, baseModel: ModelConfig): ModelConfig;
|
|
241
|
+
/** The most of one earlier step's reply handed to a later step. */
|
|
242
|
+
export declare const MAX_HANDOFF_CHARS = 6000;
|
|
243
|
+
/**
|
|
244
|
+
* What the earlier steps of an orchestrator run produced, for a later step. Without it each step saw only the
|
|
245
|
+
* line the router wrote before any step ran, so in "plan, then code" Code never saw Plan's plan.
|
|
246
|
+
*/
|
|
247
|
+
export declare function handoffBlock(prior: readonly OrchestratorStepResult[]): string;
|
|
248
|
+
/** What a step inherits from the run: the model, where it works, and the limits the person set. */
|
|
249
|
+
interface StepBase {
|
|
250
|
+
model: ModelConfig;
|
|
251
|
+
cwd?: string;
|
|
252
|
+
modelFactory?: AgentConfig['modelFactory'];
|
|
253
|
+
limits?: StepLimits;
|
|
254
|
+
}
|
|
255
|
+
/** The person's limits a step must keep: a step has no one to ask, so these decide what it may do (RB-14, RB-39). */
|
|
256
|
+
export interface StepLimits {
|
|
257
|
+
autoApproveTools?: readonly string[];
|
|
258
|
+
permissionRules?: PermissionConfig;
|
|
259
|
+
toolSandbox?: ToolSandbox;
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* The Agent configuration for one orchestrator step. The step sees exactly its agent's effective tools
|
|
263
|
+
* (builtins off, the sandbox narrowed to that list), and runs its read-only tools without asking, as a
|
|
264
|
+
* `sub_agent` child does: there is no one to ask, so without the list every such call is refused.
|
|
265
|
+
*/
|
|
266
|
+
export declare function specialistAgentConfig(args: {
|
|
267
|
+
spec: SpecialistConfig;
|
|
268
|
+
model: ModelConfig;
|
|
269
|
+
cwd?: string;
|
|
270
|
+
modelFactory?: AgentConfig['modelFactory'];
|
|
271
|
+
board?: MessageBoard;
|
|
272
|
+
limits: StepLimits;
|
|
273
|
+
}): AgentConfig;
|
|
274
|
+
export {};
|
|
@@ -31,12 +31,14 @@
|
|
|
31
31
|
*/
|
|
32
32
|
import { randomBytes } from 'node:crypto';
|
|
33
33
|
import { Agent } from '../agent/agent.js';
|
|
34
|
+
import { NO_TOOLS_SENTINEL } from '../journey/stages.js';
|
|
34
35
|
import { BUILTIN_TOOLS } from '../tools/index.js';
|
|
36
|
+
import { READ_ONLY_TOOLS } from '../tools/read-only.js';
|
|
35
37
|
import { commitAllInWorktree } from '../worktree/branch-ops.js';
|
|
36
38
|
import { WorktreeError, WorktreeManager } from '../worktree/index.js';
|
|
37
39
|
import { MessageBoard } from './board.js';
|
|
38
40
|
import { createBoardTools } from './board-tools.js';
|
|
39
|
-
import { getSpecialist, effectiveToolsFor } from './specialists.js';
|
|
41
|
+
import { BUILTIN_SPECIALISTS, getSpecialist, effectiveToolsFor } from './specialists.js';
|
|
40
42
|
import { parseRouterOutput, ROUTER_SYSTEM_PROMPT } from './router.js';
|
|
41
43
|
/** Default cap on how many specialists can run at once within a group. */
|
|
42
44
|
const DEFAULT_MAX_PARALLELISM = 5;
|
|
@@ -134,6 +136,11 @@ export async function runOrchestrator(options) {
|
|
|
134
136
|
model: options.model,
|
|
135
137
|
cwd: options.cwd,
|
|
136
138
|
modelFactory: options.modelFactory,
|
|
139
|
+
limits: {
|
|
140
|
+
...(options.autoApproveTools ? { autoApproveTools: options.autoApproveTools } : {}),
|
|
141
|
+
...(options.permissionRules ? { permissionRules: options.permissionRules } : {}),
|
|
142
|
+
...(options.toolSandbox ? { toolSandbox: options.toolSandbox } : {}),
|
|
143
|
+
},
|
|
137
144
|
};
|
|
138
145
|
const board = new MessageBoard();
|
|
139
146
|
if (options.onBoardEvent)
|
|
@@ -158,7 +165,8 @@ export async function runOrchestrator(options) {
|
|
|
158
165
|
};
|
|
159
166
|
const groups = groupStepsByParallel(plan.steps);
|
|
160
167
|
for (const group of groups) {
|
|
161
|
-
|
|
168
|
+
// Steps in one group run in parallel and do not see each other; they see every group before them.
|
|
169
|
+
const groupResults = await _dispatchGroup(group, base, { ...dispatchOpts, prior: [...stepResults] }, maxParallelism);
|
|
162
170
|
for (const result of groupResults) {
|
|
163
171
|
stepResults.push(result);
|
|
164
172
|
totalIn += result.inputTokens;
|
|
@@ -362,7 +370,7 @@ async function runSpecialistStepInner(step, base, dispatchOpts, isParallelGroup,
|
|
|
362
370
|
inputTokens: 0,
|
|
363
371
|
outputTokens: 0,
|
|
364
372
|
costUsd: 0,
|
|
365
|
-
error: `unknown specialist "${step.specialist}": known:
|
|
373
|
+
error: `unknown specialist "${step.specialist}": known: ${BUILTIN_SPECIALISTS.map((s) => s.id).join(', ')}`,
|
|
366
374
|
};
|
|
367
375
|
}
|
|
368
376
|
// Worktree setup: same fallback pattern as `subAgentTool`:
|
|
@@ -408,12 +416,14 @@ async function runSpecialistStepInner(step, base, dispatchOpts, isParallelGroup,
|
|
|
408
416
|
const effectiveCwd = worktree ? worktree.path : base.cwd;
|
|
409
417
|
const effectiveModel = resolveEffectiveModel(spec, base.model);
|
|
410
418
|
try {
|
|
419
|
+
const handoff = handoffBlock(dispatchOpts.prior ?? []);
|
|
411
420
|
const result = await runSpecialist({
|
|
412
421
|
spec,
|
|
413
|
-
task: step.task,
|
|
422
|
+
task: handoff ? `${step.task}\n\n${handoff}` : step.task,
|
|
414
423
|
model: effectiveModel,
|
|
415
424
|
cwd: effectiveCwd,
|
|
416
425
|
modelFactory: base.modelFactory,
|
|
426
|
+
limits: base.limits ?? {},
|
|
417
427
|
...(dispatchOpts.board ? { board: dispatchOpts.board } : {}),
|
|
418
428
|
});
|
|
419
429
|
if (dispatchOpts.commitChanges && worktree) {
|
|
@@ -474,23 +484,63 @@ async function runSpecialistStepInner(step, base, dispatchOpts, isParallelGroup,
|
|
|
474
484
|
}
|
|
475
485
|
}
|
|
476
486
|
}
|
|
477
|
-
/**
|
|
478
|
-
|
|
479
|
-
|
|
487
|
+
/** The most of one earlier step's reply handed to a later step. */
|
|
488
|
+
export const MAX_HANDOFF_CHARS = 6000;
|
|
489
|
+
/** Remove the block's closing tag until none is left, so a reply cannot end the block early (RB-35). */
|
|
490
|
+
function stripHandoffTag(text) {
|
|
491
|
+
let out = text;
|
|
492
|
+
for (let prev = ''; prev !== out;) {
|
|
493
|
+
prev = out;
|
|
494
|
+
out = out.replace(/<\/previous-steps>/gi, '');
|
|
495
|
+
}
|
|
496
|
+
return out;
|
|
497
|
+
}
|
|
498
|
+
/**
|
|
499
|
+
* What the earlier steps of an orchestrator run produced, for a later step. Without it each step saw only the
|
|
500
|
+
* line the router wrote before any step ran, so in "plan, then code" Code never saw Plan's plan.
|
|
501
|
+
*/
|
|
502
|
+
export function handoffBlock(prior) {
|
|
503
|
+
if (prior.length === 0)
|
|
504
|
+
return '';
|
|
505
|
+
const parts = prior.map((r, i) => {
|
|
506
|
+
const reply = stripHandoffTag(r.finalText.trim());
|
|
507
|
+
const capped = reply.length > MAX_HANDOFF_CHARS ? `${reply.slice(0, MAX_HANDOFF_CHARS)}\n[cut: ${reply.length - MAX_HANDOFF_CHARS} characters left out]` : reply;
|
|
508
|
+
const status = r.error ? `failed: ${stripHandoffTag(r.error)}` : r.reason;
|
|
509
|
+
return `## Step ${i + 1}: ${r.step.specialist} (${status})\nTask: ${stripHandoffTag(r.step.task)}\n\n${capped || '(no reply)'}`;
|
|
510
|
+
});
|
|
511
|
+
return ['<previous-steps>', 'Earlier steps of this task, in order. Build on their results; do not redo their work.', '', ...parts, '</previous-steps>'].join('\n');
|
|
512
|
+
}
|
|
513
|
+
/**
|
|
514
|
+
* The Agent configuration for one orchestrator step. The step sees exactly its agent's effective tools
|
|
515
|
+
* (builtins off, the sandbox narrowed to that list), and runs its read-only tools without asking, as a
|
|
516
|
+
* `sub_agent` child does: there is no one to ask, so without the list every such call is refused.
|
|
517
|
+
*/
|
|
518
|
+
export function specialistAgentConfig(args) {
|
|
519
|
+
const effective = effectiveToolsFor(args.spec);
|
|
520
|
+
const offered = new Set(effective);
|
|
480
521
|
const tools = [
|
|
481
|
-
...BUILTIN_TOOLS.filter((t) =>
|
|
482
|
-
...(args.board ? createBoardTools(args.board, args.spec.id).filter((t) =>
|
|
522
|
+
...BUILTIN_TOOLS.filter((t) => offered.has(t.name)),
|
|
523
|
+
...(args.board ? createBoardTools(args.board, args.spec.id).filter((t) => offered.has(t.name)) : []),
|
|
483
524
|
];
|
|
484
|
-
const
|
|
525
|
+
const parentAllow = args.limits.toolSandbox?.allowTools;
|
|
526
|
+
const narrowed = parentAllow ? effective.filter((t) => parentAllow.includes(t)) : effective;
|
|
527
|
+
return {
|
|
485
528
|
model: args.model,
|
|
486
529
|
systemPrompt: args.spec.systemPrompt,
|
|
487
530
|
...(args.cwd ? { cwd: args.cwd } : {}),
|
|
488
531
|
auto: args.spec.auto ?? false,
|
|
489
532
|
maxIterations: args.spec.maxIterations ?? 10,
|
|
490
533
|
extraTools: tools,
|
|
491
|
-
disableBuiltins:
|
|
534
|
+
disableBuiltins: true,
|
|
535
|
+
autoApproveTools: args.limits.autoApproveTools ? [...args.limits.autoApproveTools] : [...READ_ONLY_TOOLS],
|
|
536
|
+
...(args.limits.permissionRules ? { permissionRules: args.limits.permissionRules } : {}),
|
|
537
|
+
// An empty allowTools means "no whitelist" to the sandbox, so "nothing left" is the sentinel (RB-29).
|
|
538
|
+
toolSandbox: { ...(args.limits.toolSandbox ?? {}), allowTools: narrowed.length > 0 ? narrowed : [NO_TOOLS_SENTINEL] },
|
|
492
539
|
...(args.modelFactory ? { modelFactory: args.modelFactory } : {}),
|
|
493
540
|
};
|
|
494
|
-
|
|
541
|
+
}
|
|
542
|
+
/** Construct + run a specialist sub-agent. */
|
|
543
|
+
async function runSpecialist(args) {
|
|
544
|
+
const agent = new Agent(specialistAgentConfig(args));
|
|
495
545
|
return agent.run({ task: args.task });
|
|
496
546
|
}
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import type { OrchestratorPlan } from './types.js';
|
|
15
15
|
/** System prompt used by the router agent. */
|
|
16
|
-
export declare const ROUTER_SYSTEM_PROMPT
|
|
16
|
+
export declare const ROUTER_SYSTEM_PROMPT: string;
|
|
17
17
|
/**
|
|
18
18
|
* Parse the router agent's final text into an {@link OrchestratorPlan}.
|
|
19
19
|
* Tolerant of:
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* from a free-form model response. We strip ```json fences, try
|
|
12
12
|
* JSON.parse, and accept the first object that has a steps array.
|
|
13
13
|
*/
|
|
14
|
+
import { BUILTIN_SPECIALISTS } from './specialists.js';
|
|
14
15
|
/** System prompt used by the router agent. */
|
|
15
16
|
export const ROUTER_SYSTEM_PROMPT = `You are the IKarmic Forge ORCHESTRATOR. Your job is to read the user's task and decide which specialist(s) to dispatch to.
|
|
16
17
|
|
|
@@ -20,6 +21,7 @@ Available specialists:
|
|
|
20
21
|
- **debug** — investigates failures and identifies the root cause
|
|
21
22
|
- **ask** — pure Q&A, no file mutations
|
|
22
23
|
- **review** — read-only code review, identifies issues + suggests fixes
|
|
24
|
+
- **security** — finds exploitable security problems and vulnerable dependencies; changes files only for findings the user named
|
|
23
25
|
|
|
24
26
|
Your output MUST be a single JSON object (no prose around it) with this exact shape:
|
|
25
27
|
|
|
@@ -37,6 +39,7 @@ Routing heuristics:
|
|
|
37
39
|
- "What does X do?" or "How does Y work?" → 1 step: ask
|
|
38
40
|
- "Implement X" / "Add feature Y" → 2 steps in order: plan, code (sequential, parallelGroup unset)
|
|
39
41
|
- "Review PR / diff / file" → 1 step: review
|
|
42
|
+
- "Check for security problems / vulnerabilities / audit" → 1 step: security
|
|
40
43
|
- "Refactor X" → 2 steps: plan, code
|
|
41
44
|
- Multi-area research (e.g. "compare X and Y") → 2+ ask steps with the SAME parallelGroup so they run concurrently
|
|
42
45
|
- "Fix and add tests for X" → 2 steps: debug, code
|
|
@@ -51,7 +54,7 @@ About parallelGroup:
|
|
|
51
54
|
- Do NOT use parallelGroup for steps with side effects on the same files (e.g. two 'code' specialists editing the same module) — they would race.
|
|
52
55
|
|
|
53
56
|
Constraints:
|
|
54
|
-
- All specialist ids must be one of:
|
|
57
|
+
- All specialist ids must be one of: ${BUILTIN_SPECIALISTS.map((s) => s.id).join(', ')}.
|
|
55
58
|
- Tasks must be focused (1-3 sentences each).
|
|
56
59
|
- Don't dispatch more than 5 steps — decompose is the user's job, not yours.`;
|
|
57
60
|
/**
|
|
@@ -16,7 +16,12 @@
|
|
|
16
16
|
* `~/.forge/specialists.json` (loaded automatically on startup).
|
|
17
17
|
*/
|
|
18
18
|
import type { SpecialistConfig, SpecialistId } from './types.js';
|
|
19
|
-
/**
|
|
19
|
+
/**
|
|
20
|
+
* The built-in specialists. Order is the order shown in `forge orchestrator list` and in the chat's mode menu.
|
|
21
|
+
* Each prompt works both as a chat mode and as an orchestrator step: nothing in it assumes another agent.
|
|
22
|
+
* Prompt rules (RB-27): a tool named in the text is a built-in, in backticks, and never one that is only
|
|
23
|
+
* registered when a feature is on. `core/tests/specialist-profiles.test.ts` pins tools, limits and size.
|
|
24
|
+
*/
|
|
20
25
|
export declare const BUILTIN_SPECIALISTS: readonly SpecialistConfig[];
|
|
21
26
|
/**
|
|
22
27
|
* Look up a specialist by id. Checks custom registrations first
|