Skip to content

Commit 4a818b2

Browse files
authored
Merge pull request #4 from im4codes/dev
Dev
2 parents 75e634d + 96a963b commit 4a818b2

77 files changed

Lines changed: 9756 additions & 684 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ci.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ jobs:
111111
- run: npm ci
112112
- run: npm run build
113113
- name: Run Windows-specific unit tests
114-
run: npx vitest run test/agent/wezterm.test.ts test/daemon/hook-send.test.ts test/daemon/env-injection.test.ts test/cli/send.test.ts test/util/windows-daemon.test.ts test/util/windows-upgrade-script.test.ts
114+
run: npx vitest run test/agent/wezterm.test.ts test/daemon/hook-send.test.ts test/daemon/env-injection.test.ts test/cli/send.test.ts test/util/windows-daemon.test.ts test/util/windows-upgrade-script.test.ts test/util/windows-launch-artifacts.test.ts test/util/windows-launch-artifacts.cmd-parse.test.ts test/util/windows-stale-watchdog-cleanup.test.ts
115115
env:
116116
IMCODES_MUX: wezterm
117117

@@ -127,7 +127,7 @@ jobs:
127127
- run: npm ci
128128
- run: npm run build
129129
- name: Run Windows ConPTY / startup regression tests
130-
run: npx vitest run test/agent/conpty.test.ts test/agent/drivers/drivers.test.ts test/util/windows-daemon.test.ts test/util/windows-upgrade-script.test.ts
130+
run: npx vitest run test/agent/conpty.test.ts test/agent/drivers/drivers.test.ts test/util/windows-daemon.test.ts test/util/windows-upgrade-script.test.ts test/util/windows-launch-artifacts.test.ts test/util/windows-launch-artifacts.cmd-parse.test.ts test/util/windows-stale-watchdog-cleanup.test.ts
131131
# ── Web frontend tests ────────────────────────────────────────────────────
132132

133133
web-tests-unit:

shared/p2p-advanced.ts

Lines changed: 381 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,381 @@
1+
import { isTransportSessionAgentType } from './agent-types.js';
2+
3+
const LEGACY_MODE_KEYS = new Set(['audit', 'review', 'plan', 'brainstorm', 'discuss']);
4+
const COMBO_SEPARATOR = '>';
5+
6+
export type P2pAdvancedPresetKey = 'openspec';
7+
export type P2pRoundPreset =
8+
| 'discussion'
9+
| 'openspec_propose'
10+
| 'proposal_audit'
11+
| 'implementation'
12+
| 'implementation_audit'
13+
| 'custom';
14+
export type P2pRoundExecutionMode = 'single_main' | 'multi_dispatch';
15+
export type P2pRoundPermissionScope = 'analysis_only' | 'artifact_generation' | 'implementation';
16+
export type P2pRoundVerdictPolicy = 'none' | 'smart_gate' | 'forced_rework';
17+
export type P2pContextReducerMode = 'reuse_existing_session' | 'clone_sdk_session';
18+
export type P2pVerdictMarker = 'PASS' | 'REWORK';
19+
export type P2pDispatchStyle = 'initiator_only' | 'worker_hops';
20+
export type P2pSynthesisStyle = 'none' | 'initiator_summary';
21+
22+
export interface P2pContextReducerConfig {
23+
mode: P2pContextReducerMode;
24+
sessionName?: string;
25+
templateSession?: string;
26+
}
27+
28+
export interface P2pAdvancedJumpRule {
29+
targetRoundId: string;
30+
marker?: P2pVerdictMarker;
31+
minTriggers: number;
32+
maxTriggers: number;
33+
}
34+
35+
export interface P2pAdvancedRound {
36+
id: string;
37+
title: string;
38+
preset: P2pRoundPreset;
39+
executionMode: P2pRoundExecutionMode;
40+
permissionScope: P2pRoundPermissionScope;
41+
timeoutMinutes?: number;
42+
artifactOutputs?: string[];
43+
promptAppend?: string;
44+
verdictPolicy?: P2pRoundVerdictPolicy;
45+
jumpRule?: P2pAdvancedJumpRule;
46+
}
47+
48+
export interface P2pParticipantSnapshotEntry {
49+
sessionName: string;
50+
agentType: string;
51+
parentSession?: string | null;
52+
}
53+
54+
export interface P2pHelperDiagnostic {
55+
code:
56+
| 'P2P_HELPER_PRIMARY_FAILED'
57+
| 'P2P_HELPER_FALLBACK_FAILED'
58+
| 'P2P_HELPER_CLEANUP_FAILED'
59+
| 'P2P_COMPRESSION_SKIPPED_NO_FALLBACK'
60+
| 'P2P_VERDICT_MISSING';
61+
attempt: number;
62+
sourceSession?: string | null;
63+
templateSession?: string | null;
64+
fallbackSession?: string | null;
65+
timestamp: number;
66+
message?: string;
67+
}
68+
69+
export interface P2pResolvedRound {
70+
id: string;
71+
title: string;
72+
modeKey: string;
73+
preset: P2pRoundPreset;
74+
executionMode: P2pRoundExecutionMode;
75+
permissionScope: P2pRoundPermissionScope;
76+
timeoutMinutes: number;
77+
timeoutMs: number;
78+
promptAppend: string;
79+
verdictPolicy: P2pRoundVerdictPolicy;
80+
jumpRule?: P2pAdvancedJumpRule;
81+
dispatchStyle: P2pDispatchStyle;
82+
synthesisStyle: P2pSynthesisStyle;
83+
requiresVerdict: boolean;
84+
presetPrompt: string;
85+
summaryPrompt?: string;
86+
authoritativeVerdictWriter: 'initiator_summary' | 'initiator_only' | null;
87+
allowRouting: boolean;
88+
artifactOutputs: string[];
89+
artifactConvention: 'none' | 'explicit' | 'openspec_convention';
90+
}
91+
92+
export interface ResolveP2pRoundPlanOptions {
93+
modeOverride?: string;
94+
roundsOverride?: number;
95+
hopTimeoutMinutes?: number;
96+
advancedPresetKey?: string | null;
97+
advancedRounds?: P2pAdvancedRound[] | null;
98+
advancedRunTimeoutMinutes?: number | null;
99+
contextReducer?: P2pContextReducerConfig | null;
100+
participants?: P2pParticipantSnapshotEntry[] | null;
101+
}
102+
103+
export interface P2pResolvedPlan {
104+
advanced: boolean;
105+
rounds: P2pResolvedRound[];
106+
overallRunTimeoutMinutes?: number;
107+
contextReducer?: P2pContextReducerConfig;
108+
helperEligibleSnapshot?: P2pParticipantSnapshotEntry[];
109+
}
110+
111+
const DEFAULT_HOP_TIMEOUT_MINUTES = 8;
112+
const DEFAULT_ADVANCED_RUN_TIMEOUT_MINUTES = 30;
113+
114+
function parseModePipeline(mode: string): string[] {
115+
if (mode.includes(COMBO_SEPARATOR)) {
116+
return mode.split(COMBO_SEPARATOR).map((entry) => entry.trim()).filter(Boolean);
117+
}
118+
return [mode];
119+
}
120+
121+
function isValidLegacyMode(mode: string): boolean {
122+
return LEGACY_MODE_KEYS.has(mode);
123+
}
124+
125+
function validateLegacyMode(mode: string): void {
126+
const pipeline = parseModePipeline(mode);
127+
if (pipeline.length === 0 || pipeline.some((entry) => !isValidLegacyMode(entry))) {
128+
throw new Error(`Invalid P2P mode pipeline: ${mode}`);
129+
}
130+
}
131+
132+
function cloneRound<T>(value: T): T {
133+
return JSON.parse(JSON.stringify(value)) as T;
134+
}
135+
136+
function createOpenSpecPreset(): P2pAdvancedRound[] {
137+
return [
138+
{
139+
id: 'discussion',
140+
title: 'Discussion',
141+
preset: 'discussion',
142+
executionMode: 'multi_dispatch',
143+
permissionScope: 'analysis_only',
144+
timeoutMinutes: 5,
145+
verdictPolicy: 'none',
146+
},
147+
{
148+
id: 'openspec_propose',
149+
title: 'OpenSpec Propose',
150+
preset: 'openspec_propose',
151+
executionMode: 'single_main',
152+
permissionScope: 'artifact_generation',
153+
timeoutMinutes: 8,
154+
verdictPolicy: 'none',
155+
},
156+
{
157+
id: 'proposal_audit',
158+
title: 'Proposal Audit',
159+
preset: 'proposal_audit',
160+
executionMode: 'single_main',
161+
permissionScope: 'analysis_only',
162+
timeoutMinutes: 6,
163+
verdictPolicy: 'none',
164+
},
165+
{
166+
id: 'implementation',
167+
title: 'Implementation',
168+
preset: 'implementation',
169+
executionMode: 'multi_dispatch',
170+
permissionScope: 'implementation',
171+
timeoutMinutes: 8,
172+
verdictPolicy: 'none',
173+
},
174+
{
175+
id: 'implementation_audit',
176+
title: 'Implementation Audit',
177+
preset: 'implementation_audit',
178+
executionMode: 'single_main',
179+
permissionScope: 'analysis_only',
180+
timeoutMinutes: 6,
181+
verdictPolicy: 'smart_gate',
182+
jumpRule: {
183+
targetRoundId: 'implementation',
184+
marker: 'REWORK',
185+
minTriggers: 0,
186+
maxTriggers: 2,
187+
},
188+
},
189+
];
190+
}
191+
192+
export const BUILT_IN_ADVANCED_PRESETS: Record<P2pAdvancedPresetKey, P2pAdvancedRound[]> = {
193+
openspec: createOpenSpecPreset(),
194+
};
195+
196+
const PRESET_PROMPTS: Record<P2pRoundPreset, string> = {
197+
discussion: 'Clarify the request, collect missing constraints, and synthesize the strongest next-step understanding from the evidence in the discussion file and referenced code.',
198+
openspec_propose: 'Produce an OpenSpec-ready proposal/design/tasks result from the discussion and code context. Write concrete artifacts, acceptance criteria, and implementation scope rather than broad notes.',
199+
proposal_audit: 'Audit the proposal artifacts for missing scope, missing acceptance criteria, contradictions, and weak assumptions. Strengthen the proposal without changing the requested objective.',
200+
implementation: 'Execute the implementation work required by the current round. Prefer concrete code and tests over commentary, while staying within the stated scope and artifact targets.',
201+
implementation_audit: 'Audit the implementation result against the requested scope, artifact outputs, and acceptance criteria. End with an authoritative verdict marker.',
202+
custom: 'Follow the configured round contract exactly. Stay within the declared permission scope and use the configured outputs and prompt append as the operative instruction.',
203+
};
204+
205+
const SUMMARY_PROMPTS: Partial<Record<P2pRoundPreset, string>> = {
206+
discussion: 'Synthesize the key points, areas of agreement, and open questions from this round. Then assign concrete follow-up focus for the next round.',
207+
implementation: 'Synthesize the implementation outputs from the worker evidence. Produce one authoritative implementation summary that references the latest completed attempt.',
208+
implementation_audit: 'Write one authoritative audit synthesis and end with exactly one verdict marker line: `<!-- P2P_VERDICT: PASS -->` or `<!-- P2P_VERDICT: REWORK -->`.',
209+
};
210+
211+
function buildLegacyResolvedRound(mode: string, roundIndex: number, totalRounds: number, hopTimeoutMinutes?: number): P2pResolvedRound {
212+
const pipeline = parseModePipeline(mode);
213+
const modeKey = pipeline[Math.min(roundIndex - 1, pipeline.length - 1)] ?? mode;
214+
return {
215+
id: `legacy_${roundIndex}`,
216+
title: `Round ${roundIndex}`,
217+
modeKey,
218+
preset: 'custom',
219+
executionMode: 'multi_dispatch',
220+
permissionScope: 'analysis_only',
221+
timeoutMinutes: hopTimeoutMinutes ?? DEFAULT_HOP_TIMEOUT_MINUTES,
222+
timeoutMs: (hopTimeoutMinutes ?? DEFAULT_HOP_TIMEOUT_MINUTES) * 60_000,
223+
promptAppend: '',
224+
verdictPolicy: 'none',
225+
dispatchStyle: 'worker_hops',
226+
synthesisStyle: 'initiator_summary',
227+
requiresVerdict: false,
228+
presetPrompt: '',
229+
summaryPrompt: totalRounds === roundIndex ? undefined : 'Synthesize the key points, areas of agreement, and open questions from this round. Then assign concrete follow-up focus for the next round.',
230+
authoritativeVerdictWriter: null,
231+
allowRouting: false,
232+
artifactOutputs: [],
233+
artifactConvention: 'none',
234+
};
235+
}
236+
237+
function defaultArtifactConvention(round: P2pAdvancedRound): 'none' | 'explicit' | 'openspec_convention' {
238+
if (round.preset === 'openspec_propose' && (!round.artifactOutputs || round.artifactOutputs.length === 0)) {
239+
return 'openspec_convention';
240+
}
241+
if (round.permissionScope === 'artifact_generation') return 'explicit';
242+
return 'none';
243+
}
244+
245+
function normalizeAdvancedRound(round: P2pAdvancedRound): P2pResolvedRound {
246+
const verdictPolicy = round.verdictPolicy ?? 'none';
247+
const artifactConvention = defaultArtifactConvention(round);
248+
const artifactOutputs = artifactConvention === 'openspec_convention'
249+
? ['openspec/changes']
250+
: [...(round.artifactOutputs ?? [])];
251+
const synthesisStyle: P2pSynthesisStyle = round.executionMode === 'multi_dispatch' ? 'initiator_summary' : 'none';
252+
const requiresVerdict = verdictPolicy !== 'none';
253+
const authoritativeVerdictWriter = requiresVerdict
254+
? (round.executionMode === 'multi_dispatch' ? 'initiator_summary' : 'initiator_only')
255+
: null;
256+
const allowRouting = round.preset !== 'proposal_audit' && requiresVerdict && !!round.jumpRule;
257+
return {
258+
id: round.id,
259+
title: round.title,
260+
modeKey: round.preset === 'custom' ? 'custom' : round.preset,
261+
preset: round.preset,
262+
executionMode: round.executionMode,
263+
permissionScope: round.permissionScope,
264+
timeoutMinutes: round.timeoutMinutes ?? DEFAULT_HOP_TIMEOUT_MINUTES,
265+
timeoutMs: (round.timeoutMinutes ?? DEFAULT_HOP_TIMEOUT_MINUTES) * 60_000,
266+
promptAppend: round.promptAppend?.trim() ?? '',
267+
verdictPolicy,
268+
jumpRule: round.jumpRule ? cloneRound(round.jumpRule) : undefined,
269+
dispatchStyle: round.executionMode === 'single_main' ? 'initiator_only' : 'worker_hops',
270+
synthesisStyle,
271+
requiresVerdict,
272+
presetPrompt: PRESET_PROMPTS[round.preset],
273+
summaryPrompt: synthesisStyle === 'initiator_summary' ? SUMMARY_PROMPTS[round.preset] : undefined,
274+
authoritativeVerdictWriter,
275+
allowRouting,
276+
artifactOutputs,
277+
artifactConvention,
278+
};
279+
}
280+
281+
function validateAdvancedRoundIds(rounds: P2pAdvancedRound[]): void {
282+
const seen = new Set<string>();
283+
for (const round of rounds) {
284+
if (!round.id.trim()) throw new Error('Advanced P2P round ids must be non-empty');
285+
if (seen.has(round.id)) throw new Error(`Duplicate advanced P2P round id: ${round.id}`);
286+
seen.add(round.id);
287+
}
288+
}
289+
290+
function validateContextReducer(
291+
reducer: P2pContextReducerConfig | null | undefined,
292+
participants: P2pParticipantSnapshotEntry[] | null | undefined,
293+
): P2pContextReducerConfig | undefined {
294+
if (!reducer) return undefined;
295+
const snapshot = participants ?? [];
296+
const lookup = new Map(snapshot.map((entry) => [entry.sessionName, entry]));
297+
if (reducer.mode === 'reuse_existing_session') {
298+
if (!reducer.sessionName) throw new Error('contextReducer.sessionName is required for reuse_existing_session');
299+
const target = lookup.get(reducer.sessionName);
300+
if (!target || !isTransportSessionAgentType(target.agentType)) {
301+
throw new Error(`Reducer session is not an eligible SDK-backed participant: ${reducer.sessionName}`);
302+
}
303+
} else {
304+
if (!reducer.templateSession) throw new Error('contextReducer.templateSession is required for clone_sdk_session');
305+
const template = lookup.get(reducer.templateSession);
306+
if (!template || !isTransportSessionAgentType(template.agentType)) {
307+
throw new Error(`Reducer template is not an eligible SDK-backed participant: ${reducer.templateSession}`);
308+
}
309+
}
310+
return cloneRound(reducer);
311+
}
312+
313+
function validateAdvancedRounds(rounds: P2pAdvancedRound[]): void {
314+
validateAdvancedRoundIds(rounds);
315+
const ids = new Set(rounds.map((round) => round.id));
316+
for (const round of rounds) {
317+
const verdictPolicy = round.verdictPolicy ?? 'none';
318+
const artifactConvention = defaultArtifactConvention(round);
319+
if (round.permissionScope === 'artifact_generation' && artifactConvention === 'explicit' && (!round.artifactOutputs || round.artifactOutputs.length === 0)) {
320+
throw new Error(`Artifact-generation round "${round.id}" must declare artifact outputs`);
321+
}
322+
if (verdictPolicy === 'forced_rework') {
323+
if (!round.jumpRule) throw new Error(`forced_rework round "${round.id}" requires a jumpRule`);
324+
if (round.jumpRule.minTriggers < 0) throw new Error(`forced_rework round "${round.id}" has invalid minTriggers`);
325+
if (round.jumpRule.maxTriggers < round.jumpRule.minTriggers) throw new Error(`forced_rework round "${round.id}" has invalid maxTriggers`);
326+
}
327+
if (round.jumpRule) {
328+
if (!ids.has(round.jumpRule.targetRoundId)) throw new Error(`Round "${round.id}" jumps to unknown target "${round.jumpRule.targetRoundId}"`);
329+
const currentIndex = rounds.findIndex((entry) => entry.id === round.id);
330+
const targetIndex = rounds.findIndex((entry) => entry.id === round.jumpRule?.targetRoundId);
331+
if (targetIndex >= currentIndex) throw new Error(`Round "${round.id}" must jump backward to an earlier round`);
332+
if (round.preset === 'proposal_audit') throw new Error('proposal_audit cannot drive routing in v1');
333+
}
334+
}
335+
}
336+
337+
export function resolveP2pRoundPlan(options: ResolveP2pRoundPlanOptions): P2pResolvedPlan {
338+
const {
339+
modeOverride,
340+
roundsOverride,
341+
hopTimeoutMinutes,
342+
advancedPresetKey,
343+
advancedRounds,
344+
advancedRunTimeoutMinutes,
345+
contextReducer,
346+
participants,
347+
} = options;
348+
349+
const advancedRequested = !!advancedPresetKey || !!advancedRounds?.length;
350+
if (!advancedRequested) {
351+
const mode = modeOverride ?? 'discuss';
352+
validateLegacyMode(mode);
353+
const comboRounds = parseModePipeline(mode).length;
354+
const totalRounds = Math.max(1, roundsOverride ?? comboRounds);
355+
return {
356+
advanced: false,
357+
rounds: Array.from({ length: totalRounds }, (_, index) => buildLegacyResolvedRound(mode, index + 1, totalRounds, hopTimeoutMinutes)),
358+
};
359+
}
360+
361+
if (advancedPresetKey && advancedPresetKey !== 'openspec') {
362+
throw new Error(`Unknown advanced P2P preset: ${advancedPresetKey}`);
363+
}
364+
365+
const presetRounds = advancedPresetKey === 'openspec'
366+
? cloneRound(BUILT_IN_ADVANCED_PRESETS.openspec)
367+
: [];
368+
const rawRounds = advancedRounds?.length ? cloneRound(advancedRounds) : presetRounds;
369+
if (rawRounds.length === 0) throw new Error('Advanced P2P requires at least one round');
370+
validateAdvancedRounds(rawRounds);
371+
const validatedReducer = validateContextReducer(contextReducer, participants);
372+
const helperEligibleSnapshot = (participants ?? []).filter((entry) => isTransportSessionAgentType(entry.agentType));
373+
374+
return {
375+
advanced: true,
376+
rounds: rawRounds.map((round) => normalizeAdvancedRound(round)),
377+
overallRunTimeoutMinutes: advancedRunTimeoutMinutes ?? DEFAULT_ADVANCED_RUN_TIMEOUT_MINUTES,
378+
contextReducer: validatedReducer,
379+
helperEligibleSnapshot,
380+
};
381+
}

0 commit comments

Comments
 (0)