Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
580c506
refactor(shared): move the skill menu out of the agent
gewenyu99 Sep 22, 2026
6484ce1
build(agent): publish the agent through entry modules
gewenyu99 Sep 22, 2026
9f0d4c4
refactor(shared): stop shared code from reaching upward
gewenyu99 Sep 22, 2026
49614fb
docs: describe the agent and shared surfaces
gewenyu99 Sep 22, 2026
67ece68
docs(agent): name what stays in the entry modules and what leaves
gewenyu99 Sep 22, 2026
3e2f1be
docs(agent): skill install leaves with Release B, not a separate stage
gewenyu99 Sep 22, 2026
c620d09
docs(agent): group the entry exports by when they leave
gewenyu99 Sep 22, 2026
889af71
test(agent): cover public prompt streaming entry
gewenyu99 Sep 22, 2026
2aee250
fix(agent): propagate terminal failures and cancel sibling work
gewenyu99 Sep 23, 2026
1443587
test(agent): probe terminal SDK fault propagation
gewenyu99 Sep 23, 2026
da08b1c
Merge workbench/wizard-functional-a2b into posthog/functional-a3-entries
gewenyu99 Sep 23, 2026
62c4387
fix(agent): label decided failures as errors and cancel open asks whe…
gewenyu99 Sep 23, 2026
200961f
Merge workbench/wizard-functional-a2b into posthog/functional-a3-entries
gewenyu99 Sep 23, 2026
f4b3417
Merge workbench/wizard-functional-a2b into posthog/functional-a3-entries
gewenyu99 Sep 23, 2026
38f57e8
fix(agent): report the scan summary when a run is cancelled before it…
gewenyu99 Sep 23, 2026
9eb5375
Merge workbench/wizard-functional-a2b into posthog/functional-a3-entries
gewenyu99 Sep 23, 2026
bfe4c8e
Merge workbench/wizard-functional-a2b into posthog/functional-a3-entries
gewenyu99 Sep 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .eslintrc.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,27 @@ module.exports = {
],
},
},
{
// Outside the agent, import it through its entry modules only:
// `@agent` for values, `@agent/types` for types. The architecture test
// applies the same rule to resolved paths; this gives editor feedback.
files: ['bin.ts', 'src/**/*.ts', 'src/**/*.tsx'],
excludedFiles: ['src/agent/**', '**/__tests__/**', '**/__mocks__/**'],
rules: {
'@typescript-eslint/no-restricted-imports': [
'error',
{
patterns: [
{
group: ['@agent/**', '!@agent/types'],
message:
'Import the agent through @agent (values) or @agent/types (types).',
},
],
},
],
},
},
{
files: [
'*.test.js',
Expand Down
9 changes: 9 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ Each domain has a dedicated boundary:
[ai-gateway](https://github.com/PostHog/ai-gateway). To disable scanning in
the field without a release, see the kill-switch runbook:
`docs/runbooks/warlock-kill-switch.md`. ONLY USE THIS IF ABSOLUTELY NECESSARY.
- **Agent** → `src/agent/`, imported only through `@agent` (values) and
`@agent/types` (types); see [src/agent/README.md](src/agent/README.md)
- **Shared** → `src/shared/`, stateless library code with no upward imports;
see [src/shared/README.md](src/shared/README.md)
- **Programs** → step arrays in `src/lib/programs/`
- **TUI** → screen components and primitives in `src/ui/tui/`

Expand Down Expand Up @@ -197,6 +201,11 @@ wizard run points. Full catalog: [`docs/local-dev.md`](docs/local-dev.md).
types so they satisfy `Record<string, unknown>`.
- All UI calls go through `getUI()` (returns `WizardUI` interface). Never import
the store directly from business logic.
- Shared helpers never call `getUI()`; they take a sink or return data. `debug()`
reaches the UI through the sink `src/ui/index.ts` installs.
- Outside `src/agent`, import the agent through `@agent` or `@agent/types`. Add
to those entry modules rather than deep-importing; lint and
`pnpm test:arch` reject `@agent/*` paths elsewhere.
- Session mutations go through explicit store setters that call `emitChange()`.
Never mutate `session` directly — nanostore holds a shallow copy.
- The router resolves the active screen from session state. No imperative
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -504,7 +504,9 @@ Path aliases defined in `tsconfig.build.json`, resolved by tsdown:
|---|---|
| `@env` | `src/env.ts` |
| `@lib/*` | `src/lib/*` |
| `@agent/*` | `src/agent/*` |
| `@agent` | `src/agent/index.ts`, the agent's runtime entry; the only agent import outside `src/agent` besides types |
| `@agent/types` | `src/agent/types.ts`, type-only |
| `@agent/*` | `src/agent/*`, inside `src/agent` and its tests only |
| `@shared/*` | `src/shared/*` |
| `@utils/*` | `src/shared/utils/*` |
| `@ui/*` | `src/ui/*` |
Expand Down
2 changes: 1 addition & 1 deletion docs/error-catalog.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ guarantee.

Program detect steps write `{ kind, ...detail }` into
`session.frameworkContext.detectError`. `detectErrorCode()`
([`src/shared/errors/detect-map.ts`](../src/shared/errors/detect-map.ts)) maps `kind`
([`src/lib/programs/detect-map.ts`](../src/lib/programs/detect-map.ts)) maps `kind`
→ code, and the whole object — `kind` included — rides along as
`OutroData.errorDetail`.

Expand Down
2 changes: 1 addition & 1 deletion e2e-harness/__tests__/e2e-result.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { OutroKind, RunPhase } from '@lib/wizard-session';
import type { AskQuestion, WizardSession } from '@lib/wizard-session';
import { DETECTED_WAREHOUSE_SOURCES_KEY } from '@lib/programs/warehouse-source/detect';
import { Overlay } from '@ui/tui/router';
import { TASK_OUTCOMES_KEY } from '@agent/runner/sequence/orchestrator/queue';
import { TASK_OUTCOMES_KEY } from '@agent';
import {
E2eRunRecorder,
abortReasonFrom,
Expand Down
6 changes: 2 additions & 4 deletions e2e-harness/e2e-result.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,8 @@
import fs from 'fs';
import path from 'path';
import { OutroKind, type WizardSession } from '@lib/wizard-session';
import {
TASK_OUTCOMES_KEY,
type TaskOutcome,
} from '@agent/runner/sequence/orchestrator/queue';
import { TASK_OUTCOMES_KEY } from '@agent';
import type { TaskOutcome } from '@agent/types';
import { DETECTED_WAREHOUSE_SOURCES_KEY } from '@lib/programs/warehouse-source/detect';
import type { DetectedSource } from '@lib/warehouse-sources/types';
import type { E2eDecisionReport } from './e2e-profile.js';
Expand Down
119 changes: 119 additions & 0 deletions scripts/a3-fault-probe.no-jest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import type { RunConfig, RunInput } from '@agent/runner';

const gatewayUrl = process.env.WIZARD_FAULT_GATEWAY_URL;
const installDir = process.env.WIZARD_FAULT_INSTALL_DIR;
const harness = process.env.WIZARD_FAULT_HARNESS;
if (
!gatewayUrl ||
!installDir ||
!['anthropic', 'pi'].includes(harness ?? '')
) {
throw new Error(
'Expected WIZARD_FAULT_GATEWAY_URL, WIZARD_FAULT_INSTALL_DIR, and WIZARD_FAULT_HARNESS',
);
}

const routedFetch = globalThis.fetch;
globalThis.fetch = (input, init) => {
const url = new URL(input instanceof Request ? input.url : String(input));
if (url.hostname === 'internal-j.posthog.com') {
return Promise.resolve(new Response('{"status":1}', { status: 200 }));
}
if (url.hostname !== '127.0.0.1' && url.hostname !== 'localhost') {
return Promise.reject(
new Error('Fault probe blocked a non-loopback fetch'),
);
}
return routedFetch(input, init);
};

const { runAgent } = await import('@agent/runner');
const { configureGatewayCredentialsForCI } = await import(
'@agent/gateway-session'
);
const { DEFAULT_AGENT_MODEL, Harness, Sequence } = await import(
'@shared/constants'
);
const { HostResolution } = await import('@shared/host-resolution');
const { analytics } = await import('@utils/analytics');

// This probe has no telemetry sink and uses only synthetic local credentials.
analytics.capture = () => {};
analytics.captureException = () => {};
analytics.wizardCapture = () => {};
analytics.shutdown = async () => {};

configureGatewayCredentialsForCI(
'phe_synthetic_fault_probe',
228144,
gatewayUrl,
);

const config: RunConfig = {
programId: 'fault-probe',
run: {
integrationLabel: 'Fault probe',
spinnerMessage: 'Running fault probe',
successMessage: 'Fault probe completed',
estimatedDurationMinutes: 1,
reportFile: 'fault-probe.md',
docsUrl: 'https://posthog.com/docs',
customPrompt: () => 'Answer briefly without using tools.',
},
composed: false,
binding: {
sequence: Sequence.linear,
harness: harness as Harness,
model: DEFAULT_AGENT_MODEL,
},
switchboard: {
program: 'fault-probe',
flags: {},
cliHarness: harness as Harness,
},
skillsBaseUrl: 'http://127.0.0.1:1',
wizardFlags: {},
wizardFlagPayloads: {},
wizardMetadata: { run_id: 'fault-probe' },
};

const input: RunInput = {
installDir,
credentials: {
accessToken: 'phx_synthetic_fault_probe',
projectApiKey: 'phc_synthetic_fault_probe',
host: HostResolution.fromApiHost('http://127.0.0.1:1', { localMcp: true }),
projectId: 228144,
},
project: null,
apiUser: null,
flags: {
ci: true,
signup: false,
debug: false,
e2eAsk: false,
localMcp: true,
captureAio: false,
benchmark: false,
yaraReport: false,
},
host: { projectId: 228144, region: 'us' },
};

const result = await runAgent(config, input);
process.stdout.write(
`WIZARD_FAULT_RESULT ${JSON.stringify({
harness,
outcome: result.outcome,
code: result.failure?.code,
message: result.failure?.message,
hasError: result.failure?.error instanceof Error,
outroKind: result.outro?.kind,
})}\n`,
);
if (result.outcome === 'failed' || result.outcome === 'aborted') {
const { wizardAbort } = await import('@utils/wizard-abort');
await wizardAbort(result.failure);
} else {
process.exitCode = 2;
}
89 changes: 79 additions & 10 deletions src/__tests__/architecture/import-boundaries.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,15 @@ import * as fs from 'fs';
import * as path from 'path';
import { fileURLToPath } from 'url';

export type Surface = 'env' | 'legacy' | 'agent' | 'tui' | 'cli';
export type Surface = 'env' | 'shared' | 'legacy' | 'agent' | 'tui' | 'cli';

const HERE = path.dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = path.resolve(HERE, '../../..');

const SURFACE_RULES: ReadonlyArray<readonly [Surface, (p: string) => boolean]> =
[
['env', (p) => p === 'src/env.ts'],
['shared', (p) => p.startsWith('src/shared/')],
['agent', (p) => p.startsWith('src/agent/')],
[
'tui',
Expand All @@ -26,7 +27,6 @@ const SURFACE_RULES: ReadonlyArray<readonly [Surface, (p: string) => boolean]> =
(p) =>
p === 'bin.ts' ||
p === 'src/wizard.ts' ||
p === 'src/telemetry.ts' ||
p.startsWith('src/commands/') ||
p.startsWith('src/lib/runners/'),
],
Expand All @@ -42,12 +42,36 @@ export function classifySurface(relPath: string): Surface {

export const ALLOWED_IMPORTS: Record<Surface, readonly Surface[]> = {
env: [],
legacy: ['env', 'legacy'],
agent: ['env', 'legacy', 'agent'],
tui: ['env', 'legacy', 'tui'],
cli: ['env', 'legacy', 'agent', 'tui', 'cli'],
shared: ['env', 'shared'],
legacy: ['env', 'shared', 'legacy'],
// Program types stay importable from the agent until B1 moves the bindings.
agent: ['env', 'shared', 'legacy', 'agent'],
tui: ['env', 'shared', 'legacy', 'tui'],
cli: ['env', 'shared', 'legacy', 'agent', 'tui', 'cli'],
};

// The agent's public entries. Outside `src/agent`, an import into the agent
// must land on one of these; `types.ts` is type-only, so the TUI may take it.
const AGENT_VALUES_ENTRY = 'src/agent/index.ts';
const AGENT_TYPES_ENTRY = 'src/agent/types.ts';

/** The rule an edge breaks, or null when it is allowed. */
export function ruleFor(fromFile: string, toFile: string): string | null {
const from = classifySurface(fromFile);
const to = classifySurface(toFile);
if (to === 'agent' && from !== 'agent') {
const target = toFile.split(path.sep).join('/');
if (target !== AGENT_VALUES_ENTRY && target !== AGENT_TYPES_ENTRY) {
return 'agent-deep-import';
}
if (from === 'tui' && target !== AGENT_TYPES_ENTRY) {
return `matrix:${from}->${to}`;
}
return null;

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here's the potential issue: The known entry shortcut lets shared and environment code bypass their runtime import restrictions.

shared helper imports runAgent from @agent -> checks pass -> forbidden upward dependency goes unflagged

Suggested fix: Address caller restrictions and regression cases in planned C2's boundary-enforcement pass. This is an accepted follow-up, not a blocker for A3.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

C2d replaces this checker with per-layer TypeScript configs, and at the C3 head skill-map.ts no longer imports the agent:

import type { InstallSkillResult } from '../skills/skill-install';

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Disregarding this one with no change, since the checker is only a sanity ledger and C2d replaces it with compiler-enforced layer configs, where src/shared can resolve only @env, @shared and @utils:

"paths": {
"@env": [
"../../.tsbuild/env/env.d.ts"
],
"@shared/*": [
"./*"
],
"@utils/*": [
"./utils/*"
],

}
return ALLOWED_IMPORTS[from].includes(to) ? null : `matrix:${from}->${to}`;
}

const TUI_ONLY_PACKAGES = ['ink', 'react', '@inkjs/ui', 'ink-testing-library'];

const SKIP_DIRS = new Set([
Expand Down Expand Up @@ -267,7 +291,6 @@ function analyze(): Analysis {
fs.readFileSync(path.join(REPO_ROOT, file), 'utf8'),
);
const from = classifySurface(file);
const allowed = ALLOWED_IMPORTS[from];

for (const spec of specifiersIn(text)) {
const base = spec.startsWith('.')
Expand All @@ -293,8 +316,8 @@ function analyze(): Analysis {
const key = `${file} -> ${target}`;
edges.add(key);

const to = classifySurface(target);
if (!allowed.includes(to)) violations.set(key, `matrix:${from}->${to}`);
const broken = ruleFor(file, target);
if (broken !== null) violations.set(key, broken);
}
}

Expand Down Expand Up @@ -375,7 +398,8 @@ describe('import boundaries', () => {
describe('surface classification', () => {
it('maps representative paths to their surface', () => {
expect(classifySurface('src/env.ts')).toBe('env');
expect(classifySurface('src/shared/utils/analytics.ts')).toBe('legacy');
expect(classifySurface('src/shared/utils/analytics.ts')).toBe('shared');
expect(classifySurface('src/shared/errors/codes.ts')).toBe('shared');
expect(classifySurface('src/agent/agent-runner.ts')).toBe('agent');
expect(classifySurface('src/ui/tui/App.tsx')).toBe('tui');
expect(classifySurface('bin.ts')).toBe('cli');
Expand All @@ -392,3 +416,48 @@ describe('surface classification', () => {
).toBe('legacy');
});
});

describe('agent entry modules', () => {
const rule = (from: string, to: string) => ruleFor(from, to);

it('lets legacy and cli code reach the agent through its entries only', () => {
expect(rule('src/lib/programs/audit/index.ts', 'src/agent/index.ts')).toBe(
null,
);
expect(rule('src/lib/programs/audit/index.ts', 'src/agent/types.ts')).toBe(
null,
);
expect(rule('src/commands/skill.ts', 'src/agent/index.ts')).toBe(null);
expect(
rule('src/lib/programs/audit/index.ts', 'src/agent/agent-runner.ts'),
).toBe('agent-deep-import');
expect(rule('src/commands/skill.ts', 'src/agent/runner/index.ts')).toBe(
'agent-deep-import',
);
expect(rule('src/shared/errors/agent-map.ts', 'src/agent/signals.ts')).toBe(
'agent-deep-import',
);
});

it('lets the TUI take agent types but not agent values', () => {
expect(rule('src/ui/tui/App.tsx', 'src/agent/types.ts')).toBe(null);
expect(rule('src/ui/tui/App.tsx', 'src/agent/index.ts')).toBe(
'matrix:tui->agent',
);
expect(rule('src/ui/tui/App.tsx', 'src/agent/progress.ts')).toBe(
'agent-deep-import',
);
});

it('leaves agent-internal and non-agent edges to the matrix', () => {
expect(rule('src/agent/runner/index.ts', 'src/agent/progress.ts')).toBe(
null,
);
expect(rule('src/lib/programs/audit/index.ts', 'src/ui/tui/store.ts')).toBe(
'matrix:legacy->tui',
);
expect(rule('src/agent/runner/index.ts', 'src/ui/tui/store.ts')).toBe(
'matrix:agent->tui',
);
});
});
Loading
Loading