gsd-unsupervised 1.0.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.
Files changed (83) hide show
  1. package/README.md +263 -0
  2. package/bin/gsd-unsupervised +3 -0
  3. package/bin/start-daemon.sh +12 -0
  4. package/bin/unsupervised-gsd +2 -0
  5. package/dist/agent-runner.d.ts +26 -0
  6. package/dist/agent-runner.js +111 -0
  7. package/dist/agent-runner.spawn.test.d.ts +1 -0
  8. package/dist/agent-runner.spawn.test.js +128 -0
  9. package/dist/agent-runner.test.d.ts +1 -0
  10. package/dist/agent-runner.test.js +26 -0
  11. package/dist/bootstrap/wsl-bootstrap.d.ts +11 -0
  12. package/dist/bootstrap/wsl-bootstrap.js +14 -0
  13. package/dist/cli.d.ts +1 -0
  14. package/dist/cli.js +172 -0
  15. package/dist/config/paths.d.ts +8 -0
  16. package/dist/config/paths.js +36 -0
  17. package/dist/config/wsl.d.ts +4 -0
  18. package/dist/config/wsl.js +43 -0
  19. package/dist/config.d.ts +79 -0
  20. package/dist/config.js +95 -0
  21. package/dist/config.test.d.ts +1 -0
  22. package/dist/config.test.js +27 -0
  23. package/dist/cursor-agent.d.ts +17 -0
  24. package/dist/cursor-agent.invoker.test.d.ts +1 -0
  25. package/dist/cursor-agent.invoker.test.js +150 -0
  26. package/dist/cursor-agent.js +156 -0
  27. package/dist/cursor-agent.test.d.ts +1 -0
  28. package/dist/cursor-agent.test.js +60 -0
  29. package/dist/daemon.d.ts +17 -0
  30. package/dist/daemon.js +374 -0
  31. package/dist/git.d.ts +23 -0
  32. package/dist/git.js +76 -0
  33. package/dist/goals.d.ts +34 -0
  34. package/dist/goals.js +148 -0
  35. package/dist/gsd-state.d.ts +49 -0
  36. package/dist/gsd-state.js +76 -0
  37. package/dist/init-wizard.d.ts +5 -0
  38. package/dist/init-wizard.js +96 -0
  39. package/dist/lifecycle.d.ts +41 -0
  40. package/dist/lifecycle.js +103 -0
  41. package/dist/lifecycle.test.d.ts +1 -0
  42. package/dist/lifecycle.test.js +116 -0
  43. package/dist/logger.d.ts +12 -0
  44. package/dist/logger.js +31 -0
  45. package/dist/notifier.d.ts +6 -0
  46. package/dist/notifier.js +37 -0
  47. package/dist/orchestrator.d.ts +35 -0
  48. package/dist/orchestrator.js +791 -0
  49. package/dist/resource-governor.d.ts +54 -0
  50. package/dist/resource-governor.js +57 -0
  51. package/dist/resource-governor.test.d.ts +1 -0
  52. package/dist/resource-governor.test.js +33 -0
  53. package/dist/resume-pointer.d.ts +36 -0
  54. package/dist/resume-pointer.js +116 -0
  55. package/dist/roadmap-parser.d.ts +24 -0
  56. package/dist/roadmap-parser.js +105 -0
  57. package/dist/roadmap-parser.test.d.ts +1 -0
  58. package/dist/roadmap-parser.test.js +57 -0
  59. package/dist/session-log.d.ts +53 -0
  60. package/dist/session-log.js +92 -0
  61. package/dist/session-log.test.d.ts +1 -0
  62. package/dist/session-log.test.js +146 -0
  63. package/dist/state-index.d.ts +5 -0
  64. package/dist/state-index.js +31 -0
  65. package/dist/state-parser.d.ts +13 -0
  66. package/dist/state-parser.js +82 -0
  67. package/dist/state-parser.test.d.ts +1 -0
  68. package/dist/state-parser.test.js +228 -0
  69. package/dist/state-types.d.ts +20 -0
  70. package/dist/state-types.js +1 -0
  71. package/dist/state-watcher.d.ts +49 -0
  72. package/dist/state-watcher.js +148 -0
  73. package/dist/status-server.d.ts +112 -0
  74. package/dist/status-server.js +379 -0
  75. package/dist/status-server.test.d.ts +1 -0
  76. package/dist/status-server.test.js +206 -0
  77. package/dist/stream-events.d.ts +423 -0
  78. package/dist/stream-events.js +87 -0
  79. package/dist/stream-events.test.d.ts +1 -0
  80. package/dist/stream-events.test.js +304 -0
  81. package/dist/todos-api.d.ts +5 -0
  82. package/dist/todos-api.js +35 -0
  83. package/package.json +54 -0
package/dist/cli.js ADDED
@@ -0,0 +1,172 @@
1
+ import * as dotenv from 'dotenv';
2
+ dotenv.config();
3
+ import { Command } from 'commander';
4
+ import { readFileSync, existsSync } from 'node:fs';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { dirname, resolve } from 'node:path';
7
+ import { initLogger, createChildLogger } from './logger.js';
8
+ import { loadConfig } from './config.js';
9
+ import { loadGoals, getPendingGoals } from './goals.js';
10
+ import { runDaemon, registerShutdownHandlers } from './daemon.js';
11
+ import { validateCursorApiKey } from './cursor-agent.js';
12
+ import { applyWslBootstrap } from './bootstrap/wsl-bootstrap.js';
13
+ import { readGsdStateFromPath } from './gsd-state.js';
14
+ const __filename = fileURLToPath(import.meta.url);
15
+ const __dirname = dirname(__filename);
16
+ function getVersion() {
17
+ const pkgPath = resolve(__dirname, '..', 'package.json');
18
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
19
+ return pkg.version;
20
+ }
21
+ const program = new Command();
22
+ /** Default action: legacy direct daemon (goals, status-server, etc.). */
23
+ program
24
+ .name('gsd-unsupervised')
25
+ .description('Autonomous orchestrator for Cursor agent + GSD framework')
26
+ .version(getVersion())
27
+ .option('--goals <path>', 'Path to goals.md file', './goals.md')
28
+ .option('--config <path>', 'Path to config JSON file', './.autopilot/config.json')
29
+ .option('--parallel', 'Enable parallel project execution', false)
30
+ .option('--max-concurrent <n>', 'Max concurrent projects when parallel', '3')
31
+ .option('--verbose', 'Enable verbose/debug logging', false)
32
+ .option('--dry-run', 'Parse goals and show plan without executing', false)
33
+ .option('--agent <name>', 'Agent type: cursor (default), claude-code, gemini-cli, codex', 'cursor')
34
+ .option('--agent-path <path>', 'Path to cursor-agent binary', 'agent')
35
+ .option('--agent-timeout <ms>', 'Agent invocation timeout in milliseconds', '600000')
36
+ .option('--status-server <port>', 'Enable HTTP status server on port (GET / or /status)', undefined)
37
+ .option('--ngrok', 'Start ngrok tunnel to status server port (use with --status-server)', false)
38
+ .action(async (opts) => {
39
+ const verbose = opts.verbose;
40
+ const logger = initLogger({
41
+ level: verbose ? 'debug' : 'info',
42
+ pretty: verbose,
43
+ });
44
+ const log = createChildLogger(logger, 'cli');
45
+ try {
46
+ const config = loadConfig({
47
+ configPath: opts.config,
48
+ cliOverrides: {
49
+ goalsPath: opts.goals,
50
+ parallel: opts.parallel,
51
+ maxConcurrent: parseInt(opts.maxConcurrent, 10),
52
+ verbose,
53
+ agent: opts.agent,
54
+ cursorAgentPath: opts.agentPath,
55
+ agentTimeoutMs: parseInt(opts.agentTimeout, 10),
56
+ statusServerPort: opts.statusServer ? parseInt(opts.statusServer, 10) : undefined,
57
+ ngrok: opts.ngrok,
58
+ },
59
+ });
60
+ log.debug({ config }, 'Configuration loaded');
61
+ const resolvedEnv = applyWslBootstrap(config);
62
+ log.debug({
63
+ isWsl: resolvedEnv.isWsl,
64
+ cursorBinaryPath: resolvedEnv.cursorBinaryPath,
65
+ clipExePath: resolvedEnv.clipExePath ?? undefined,
66
+ workspace: resolvedEnv.workspace,
67
+ }, 'Resolved environment for current platform');
68
+ if (!opts.dryRun && config.agent === 'cursor') {
69
+ try {
70
+ validateCursorApiKey();
71
+ }
72
+ catch (err) {
73
+ const message = err instanceof Error ? err.message : String(err);
74
+ process.stderr.write(`Error: ${message}\n`);
75
+ process.exit(1);
76
+ }
77
+ }
78
+ if (opts.dryRun) {
79
+ const goals = await loadGoals(config.goalsPath);
80
+ const pending = getPendingGoals(goals);
81
+ console.log('\n Goals Queue Summary');
82
+ console.log(' ' + '─'.repeat(60));
83
+ console.log(' ' +
84
+ 'Title'.padEnd(50) +
85
+ 'Status');
86
+ console.log(' ' + '─'.repeat(60));
87
+ for (const g of goals) {
88
+ console.log(' ' +
89
+ g.title.slice(0, 49).padEnd(50) +
90
+ g.status);
91
+ }
92
+ console.log(' ' + '─'.repeat(60));
93
+ console.log(` Total: ${goals.length} | Pending: ${pending.length}\n`);
94
+ return;
95
+ }
96
+ registerShutdownHandlers(logger);
97
+ await runDaemon(config, logger);
98
+ }
99
+ catch (err) {
100
+ log.error({ err }, 'Fatal error');
101
+ process.exit(1);
102
+ }
103
+ });
104
+ /** Run daemon from .gsd/state.json (used by ./run script). */
105
+ program
106
+ .command('run')
107
+ .description('Start daemon using .gsd/state.json (single source of truth)')
108
+ .option('--state <path>', 'Path to .gsd/state.json', undefined)
109
+ .option('--verbose', 'Verbose logging', false)
110
+ .action(async (opts) => {
111
+ const cwd = process.cwd();
112
+ const statePath = opts.state
113
+ ? resolve(cwd, opts.state)
114
+ : resolve(cwd, '.gsd', 'state.json');
115
+ if (!existsSync(statePath)) {
116
+ console.error('No state found. Run: npx gsd-unsupervised init');
117
+ process.exit(1);
118
+ }
119
+ const projectRoot = dirname(dirname(statePath));
120
+ const state = await readGsdStateFromPath(statePath, projectRoot);
121
+ if (!state) {
122
+ console.error('Invalid or empty state at', statePath);
123
+ process.exit(1);
124
+ }
125
+ const workspaceRoot = resolve(projectRoot, state.workspaceRoot);
126
+ const goalsPath = state.goalsPath.startsWith('/')
127
+ ? state.goalsPath
128
+ : resolve(workspaceRoot, state.goalsPath);
129
+ const verbose = opts.verbose;
130
+ const logger = initLogger({ level: verbose ? 'debug' : 'info', pretty: verbose });
131
+ const log = createChildLogger(logger, 'cli');
132
+ try {
133
+ const config = loadConfig({
134
+ cliOverrides: {
135
+ workspaceRoot,
136
+ goalsPath,
137
+ statusServerPort: state.statusServerPort,
138
+ statePath,
139
+ verbose,
140
+ },
141
+ });
142
+ log.info({ mode: state.mode, project: state.project, goalsPath: config.goalsPath }, 'Resuming from state');
143
+ if (config.agent === 'cursor') {
144
+ try {
145
+ validateCursorApiKey();
146
+ }
147
+ catch (err) {
148
+ const message = err instanceof Error ? err.message : String(err);
149
+ process.stderr.write(`Error: ${message}\n`);
150
+ process.exit(1);
151
+ }
152
+ }
153
+ registerShutdownHandlers(logger);
154
+ await runDaemon(config, logger);
155
+ }
156
+ catch (err) {
157
+ log.error({ err }, 'Fatal error');
158
+ process.exit(1);
159
+ }
160
+ });
161
+ /** Onboarding wizard: create .gsd/ and goals. */
162
+ program
163
+ .command('init')
164
+ .description('Onboarding wizard: project name, repo path, first goal, Twilio/ngrok config')
165
+ .action(async () => {
166
+ const { runInit } = await import('./init-wizard.js');
167
+ await runInit();
168
+ });
169
+ export function main() {
170
+ program.parse();
171
+ }
172
+ main();
@@ -0,0 +1,8 @@
1
+ import type { AutopilotConfig } from '../config.js';
2
+ export declare function getCursorBinaryPath(config: AutopilotConfig): string;
3
+ export declare function getClipExePath(): string | null;
4
+ export interface WorkspaceDisplayPath {
5
+ wslPath: string;
6
+ windowsPath: string | null;
7
+ }
8
+ export declare function getWorkspaceDisplayPath(workspaceRoot: string): WorkspaceDisplayPath;
@@ -0,0 +1,36 @@
1
+ import { isWsl, toWindowsPath } from './wsl.js';
2
+ export function getCursorBinaryPath(config) {
3
+ const override = process.env.GSD_CURSOR_BIN;
4
+ if (override && override.trim() !== '') {
5
+ return override.trim();
6
+ }
7
+ const configured = config.cursorAgentPath ?? 'cursor-agent';
8
+ if (isWsl()) {
9
+ const maybeWindows = toWindowsPath(configured);
10
+ if (maybeWindows) {
11
+ return maybeWindows;
12
+ }
13
+ }
14
+ return configured;
15
+ }
16
+ export function getClipExePath() {
17
+ if (!isWsl())
18
+ return null;
19
+ const fromEnv = process.env.GSD_CLIP_EXE;
20
+ if (fromEnv && fromEnv.trim() !== '') {
21
+ return fromEnv.trim();
22
+ }
23
+ const windowsRoot = toWindowsPath('/mnt/c');
24
+ if (!windowsRoot || !windowsRoot.startsWith('C:\\')) {
25
+ return null;
26
+ }
27
+ return 'C:\\Windows\\System32\\clip.exe';
28
+ }
29
+ export function getWorkspaceDisplayPath(workspaceRoot) {
30
+ const wslPath = workspaceRoot;
31
+ if (!isWsl()) {
32
+ return { wslPath, windowsPath: null };
33
+ }
34
+ const windowsPath = toWindowsPath(workspaceRoot) ?? null;
35
+ return { wslPath, windowsPath };
36
+ }
@@ -0,0 +1,4 @@
1
+ export declare function isWindows(): boolean;
2
+ export declare function isWsl(): boolean;
3
+ export declare function getWindowsRoot(): string | null;
4
+ export declare function toWindowsPath(wslPath: string): string | null;
@@ -0,0 +1,43 @@
1
+ import { readFileSync } from 'node:fs';
2
+ const WSL_ENV_VARS = ['WSL_DISTRO_NAME', 'WSL_INTEROP'];
3
+ export function isWindows() {
4
+ return process.platform === 'win32';
5
+ }
6
+ export function isWsl() {
7
+ if (process.platform !== 'linux')
8
+ return false;
9
+ for (const key of WSL_ENV_VARS) {
10
+ if (process.env[key])
11
+ return true;
12
+ }
13
+ try {
14
+ const contents = readFileSync('/proc/version', 'utf-8').toLowerCase();
15
+ if (contents.includes('microsoft') || contents.includes('wsl')) {
16
+ return true;
17
+ }
18
+ }
19
+ catch {
20
+ // Ignore – conservative fallback is "not WSL".
21
+ }
22
+ return false;
23
+ }
24
+ export function getWindowsRoot() {
25
+ if (!isWsl())
26
+ return null;
27
+ // For now we assume the standard /mnt/c mount; callers can handle null.
28
+ return '/mnt/c';
29
+ }
30
+ export function toWindowsPath(wslPath) {
31
+ if (!wslPath.startsWith('/mnt/'))
32
+ return null;
33
+ const segments = wslPath.split('/');
34
+ // ['', 'mnt', '<drive>', ...]
35
+ if (segments.length < 4)
36
+ return null;
37
+ const driveLetter = segments[2];
38
+ if (!/^[a-zA-Z]$/.test(driveLetter))
39
+ return null;
40
+ const rest = segments.slice(3).join('\\');
41
+ const drive = driveLetter.toUpperCase();
42
+ return rest ? `${drive}:\\${rest}` : `${drive}:\\`;
43
+ }
@@ -0,0 +1,79 @@
1
+ import { z } from 'zod';
2
+ export declare const AgentIdSchema: z.ZodEnum<[string, ...string[]]>;
3
+ export declare const AutopilotConfigSchema: z.ZodObject<{
4
+ goalsPath: z.ZodDefault<z.ZodString>;
5
+ parallel: z.ZodDefault<z.ZodBoolean>;
6
+ maxConcurrent: z.ZodDefault<z.ZodNumber>;
7
+ /**
8
+ * Upper bound on allowed CPU usage before new agent work waits.
9
+ * Expressed as a fraction of total CPU capacity (1.0 = 100% of all cores).
10
+ */
11
+ maxCpuFraction: z.ZodDefault<z.ZodNumber>;
12
+ /**
13
+ * Upper bound on allowed memory usage before new agent work waits.
14
+ * Expressed as a fraction of total system memory (1.0 = 100% of RAM).
15
+ */
16
+ maxMemoryFraction: z.ZodDefault<z.ZodNumber>;
17
+ verbose: z.ZodDefault<z.ZodBoolean>;
18
+ logLevel: z.ZodDefault<z.ZodEnum<["debug", "info", "warn", "error"]>>;
19
+ workspaceRoot: z.ZodDefault<z.ZodString>;
20
+ /** Agent type: cursor (default), claude-code, gemini-cli, codex. Invalid values fail validation. */
21
+ agent: z.ZodDefault<z.ZodEnum<[string, ...string[]]>>;
22
+ cursorAgentPath: z.ZodDefault<z.ZodString>;
23
+ agentTimeoutMs: z.ZodDefault<z.ZodNumber>;
24
+ sessionLogPath: z.ZodDefault<z.ZodString>;
25
+ stateWatchDebounceMs: z.ZodDefault<z.ZodNumber>;
26
+ /** When true, refuse execute-plan when git working tree is dirty (default true). */
27
+ requireCleanGitBeforePlan: z.ZodDefault<z.ZodBoolean>;
28
+ /** When true and tree is dirty, create a checkpoint commit before execute-plan (default false). */
29
+ autoCheckpoint: z.ZodDefault<z.ZodBoolean>;
30
+ /** When set, start HTTP status server on this port (GET / or /status returns JSON). */
31
+ statusServerPort: z.ZodOptional<z.ZodNumber>;
32
+ /** When true and statusServerPort is set, spawn `ngrok http <port>` for the duration of the daemon. */
33
+ ngrok: z.ZodDefault<z.ZodBoolean>;
34
+ /** When set, daemon writes PID, progress, lastHeartbeat to this state file (.gsd/state.json). */
35
+ statePath: z.ZodOptional<z.ZodString>;
36
+ }, "strip", z.ZodTypeAny, {
37
+ goalsPath: string;
38
+ parallel: boolean;
39
+ maxConcurrent: number;
40
+ maxCpuFraction: number;
41
+ maxMemoryFraction: number;
42
+ verbose: boolean;
43
+ logLevel: "error" | "warn" | "info" | "debug";
44
+ workspaceRoot: string;
45
+ agent: string;
46
+ cursorAgentPath: string;
47
+ agentTimeoutMs: number;
48
+ sessionLogPath: string;
49
+ stateWatchDebounceMs: number;
50
+ requireCleanGitBeforePlan: boolean;
51
+ autoCheckpoint: boolean;
52
+ ngrok: boolean;
53
+ statusServerPort?: number | undefined;
54
+ statePath?: string | undefined;
55
+ }, {
56
+ goalsPath?: string | undefined;
57
+ parallel?: boolean | undefined;
58
+ maxConcurrent?: number | undefined;
59
+ maxCpuFraction?: number | undefined;
60
+ maxMemoryFraction?: number | undefined;
61
+ verbose?: boolean | undefined;
62
+ logLevel?: "error" | "warn" | "info" | "debug" | undefined;
63
+ workspaceRoot?: string | undefined;
64
+ agent?: string | undefined;
65
+ cursorAgentPath?: string | undefined;
66
+ agentTimeoutMs?: number | undefined;
67
+ sessionLogPath?: string | undefined;
68
+ stateWatchDebounceMs?: number | undefined;
69
+ requireCleanGitBeforePlan?: boolean | undefined;
70
+ autoCheckpoint?: boolean | undefined;
71
+ statusServerPort?: number | undefined;
72
+ ngrok?: boolean | undefined;
73
+ statePath?: string | undefined;
74
+ }>;
75
+ export type AutopilotConfig = z.infer<typeof AutopilotConfigSchema>;
76
+ export declare function loadConfig(options: {
77
+ configPath?: string;
78
+ cliOverrides?: Partial<AutopilotConfig>;
79
+ }): AutopilotConfig;
package/dist/config.js ADDED
@@ -0,0 +1,95 @@
1
+ import { z } from 'zod';
2
+ import { readFileSync, existsSync } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { SUPPORTED_AGENTS } from './agent-runner.js';
5
+ export const AgentIdSchema = z.enum(SUPPORTED_AGENTS);
6
+ export const AutopilotConfigSchema = z.object({
7
+ goalsPath: z.string().default('./goals.md'),
8
+ parallel: z.boolean().default(false),
9
+ maxConcurrent: z.number().int().min(1).max(10).default(3),
10
+ /**
11
+ * Upper bound on allowed CPU usage before new agent work waits.
12
+ * Expressed as a fraction of total CPU capacity (1.0 = 100% of all cores).
13
+ */
14
+ maxCpuFraction: z.number().min(0.1).max(1).default(0.75),
15
+ /**
16
+ * Upper bound on allowed memory usage before new agent work waits.
17
+ * Expressed as a fraction of total system memory (1.0 = 100% of RAM).
18
+ */
19
+ maxMemoryFraction: z.number().min(0.5).max(1).default(0.9),
20
+ verbose: z.boolean().default(false),
21
+ logLevel: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
22
+ workspaceRoot: z.string().default(process.cwd()),
23
+ /** Agent type: cursor (default), claude-code, gemini-cli, codex. Invalid values fail validation. */
24
+ agent: AgentIdSchema.default('cursor'),
25
+ cursorAgentPath: z.string().default('cursor-agent'),
26
+ agentTimeoutMs: z.number().int().min(10000).default(600000),
27
+ sessionLogPath: z.string().default('./session-log.jsonl'),
28
+ stateWatchDebounceMs: z.number().int().min(100).default(500),
29
+ /** When true, refuse execute-plan when git working tree is dirty (default true). */
30
+ requireCleanGitBeforePlan: z.boolean().default(true),
31
+ /** When true and tree is dirty, create a checkpoint commit before execute-plan (default false). */
32
+ autoCheckpoint: z.boolean().default(false),
33
+ /** When set, start HTTP status server on this port (GET / or /status returns JSON). */
34
+ statusServerPort: z.number().int().min(1).max(65535).optional(),
35
+ /** When true and statusServerPort is set, spawn `ngrok http <port>` for the duration of the daemon. */
36
+ ngrok: z.boolean().default(false),
37
+ /** When set, daemon writes PID, progress, lastHeartbeat to this state file (.gsd/state.json). */
38
+ statePath: z.string().optional(),
39
+ });
40
+ export function loadConfig(options) {
41
+ const { configPath, cliOverrides } = options;
42
+ let fileValues = {};
43
+ if (configPath && existsSync(configPath)) {
44
+ const raw = readFileSync(configPath, 'utf-8');
45
+ fileValues = JSON.parse(raw);
46
+ }
47
+ const workspaceRoot = cliOverrides?.workspaceRoot ??
48
+ fileValues.workspaceRoot ??
49
+ process.cwd();
50
+ // `.planning/config.json` is primarily for GSD framework behavior, but we also
51
+ // allow it to override a small set of daemon runtime flags (e.g. autoCheckpoint).
52
+ const planningOverrides = readPlanningOverrides(workspaceRoot);
53
+ const merged = {
54
+ ...fileValues,
55
+ ...planningOverrides,
56
+ ...stripUndefined(cliOverrides ?? {}),
57
+ };
58
+ const result = AutopilotConfigSchema.safeParse(merged);
59
+ if (!result.success) {
60
+ const issues = result.error.issues
61
+ .map((i) => ` ${i.path.join('.')}: ${i.message}`)
62
+ .join('\n');
63
+ throw new Error(`Config validation failed:\n${issues}`);
64
+ }
65
+ return result.data;
66
+ }
67
+ function stripUndefined(obj) {
68
+ return Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined));
69
+ }
70
+ function readPlanningOverrides(workspaceRoot) {
71
+ try {
72
+ const planningPath = path.join(workspaceRoot, '.planning', 'config.json');
73
+ if (!existsSync(planningPath))
74
+ return {};
75
+ const raw = readFileSync(planningPath, 'utf-8');
76
+ const parsed = JSON.parse(raw);
77
+ const overrides = {};
78
+ if (typeof parsed.autoCheckpoint === 'boolean') {
79
+ overrides.autoCheckpoint = parsed.autoCheckpoint;
80
+ }
81
+ if (typeof parsed.maxConcurrent === 'number') {
82
+ overrides.maxConcurrent = parsed.maxConcurrent;
83
+ }
84
+ if (typeof parsed.maxCpuFraction === 'number') {
85
+ overrides.maxCpuFraction = parsed.maxCpuFraction;
86
+ }
87
+ if (typeof parsed.maxMemoryFraction === 'number') {
88
+ overrides.maxMemoryFraction = parsed.maxMemoryFraction;
89
+ }
90
+ return overrides;
91
+ }
92
+ catch {
93
+ return {};
94
+ }
95
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,27 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { loadConfig } from './config.js';
3
+ describe('config agent', () => {
4
+ it('defaults agent to cursor when not specified', () => {
5
+ const config = loadConfig({ cliOverrides: {} });
6
+ expect(config.agent).toBe('cursor');
7
+ });
8
+ it('accepts agent cursor when explicitly set', () => {
9
+ const config = loadConfig({ cliOverrides: { agent: 'cursor' } });
10
+ expect(config.agent).toBe('cursor');
11
+ });
12
+ it('accepts agent claude-code', () => {
13
+ const config = loadConfig({ cliOverrides: { agent: 'claude-code' } });
14
+ expect(config.agent).toBe('claude-code');
15
+ });
16
+ it('accepts agent gemini-cli', () => {
17
+ const config = loadConfig({ cliOverrides: { agent: 'gemini-cli' } });
18
+ expect(config.agent).toBe('gemini-cli');
19
+ });
20
+ it('accepts agent codex', () => {
21
+ const config = loadConfig({ cliOverrides: { agent: 'codex' } });
22
+ expect(config.agent).toBe('codex');
23
+ });
24
+ it('rejects invalid agent with clear error', () => {
25
+ expect(() => loadConfig({ cliOverrides: { agent: 'bogus-agent' } })).toThrow(/Invalid enum value.*Expected 'cursor' \| 'claude-code' \| 'gemini-cli' \| 'codex'.*received 'bogus-agent'/);
26
+ });
27
+ });
@@ -0,0 +1,17 @@
1
+ import type { AgentInvoker } from './orchestrator.js';
2
+ import type { AgentId } from './agent-runner.js';
3
+ import { type SessionLogContext } from './session-log.js';
4
+ import type { AutopilotConfig } from './config.js';
5
+ export type { SessionLogContext };
6
+ export interface CursorAgentConfig {
7
+ agentPath: string;
8
+ defaultTimeoutMs: number;
9
+ sessionLogPath: string;
10
+ /** If set, write heartbeat timestamp here while agent runs (for crash detection). */
11
+ heartbeatPath?: string;
12
+ heartbeatIntervalMs?: number;
13
+ }
14
+ export declare function createCursorAgentInvoker(agentConfig: CursorAgentConfig): AgentInvoker;
15
+ export declare function validateCursorApiKey(): void;
16
+ /** Agent-agnostic factory: returns the appropriate invoker for the given agent ID. */
17
+ export declare function createAgentInvoker(agentId: AgentId, config: AutopilotConfig): AgentInvoker;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,150 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { createCursorAgentInvoker } from './cursor-agent.js';
3
+ import { initLogger } from './logger.js';
4
+ const appendSessionLogMock = vi.fn();
5
+ const runAgentMock = vi.fn();
6
+ vi.mock('./session-log.js', async () => {
7
+ const actual = await vi.importActual('./session-log.js');
8
+ return {
9
+ ...actual,
10
+ appendSessionLog: (...args) => appendSessionLogMock(...args),
11
+ };
12
+ });
13
+ vi.mock('./agent-runner.js', async () => {
14
+ const actual = await vi.importActual('./agent-runner.js');
15
+ return {
16
+ ...actual,
17
+ runAgent: (...args) => runAgentMock(...args),
18
+ };
19
+ });
20
+ const writeFileMock = vi.fn();
21
+ const unlinkMock = vi.fn();
22
+ vi.mock('node:fs/promises', async () => {
23
+ const actual = await vi.importActual('node:fs/promises');
24
+ return {
25
+ ...actual,
26
+ writeFile: (...args) => writeFileMock(...args),
27
+ unlink: (...args) => unlinkMock(...args),
28
+ };
29
+ });
30
+ function baseInvokerConfig(overrides) {
31
+ return {
32
+ agentPath: '/usr/bin/cursor-agent',
33
+ defaultTimeoutMs: 10_000,
34
+ sessionLogPath: '/tmp/session-log.jsonl',
35
+ heartbeatPath: '/tmp/heartbeat.txt',
36
+ heartbeatIntervalMs: 50,
37
+ ...overrides,
38
+ };
39
+ }
40
+ function resultEventSuccess() {
41
+ return {
42
+ type: 'result',
43
+ is_error: false,
44
+ duration_ms: 1,
45
+ result: 'ok',
46
+ };
47
+ }
48
+ function systemInit(sessionId = 'sess-1') {
49
+ return {
50
+ type: 'system',
51
+ subtype: 'init',
52
+ session_id: sessionId,
53
+ };
54
+ }
55
+ describe('cursor-agent invoker', () => {
56
+ const logger = initLogger({ level: 'silent', pretty: false });
57
+ beforeEach(() => {
58
+ appendSessionLogMock.mockReset();
59
+ runAgentMock.mockReset();
60
+ writeFileMock.mockReset();
61
+ unlinkMock.mockReset();
62
+ writeFileMock.mockResolvedValue(undefined);
63
+ unlinkMock.mockResolvedValue(undefined);
64
+ vi.useRealTimers();
65
+ });
66
+ afterEach(() => {
67
+ vi.useRealTimers();
68
+ });
69
+ it('writes running then done session-log entries on success', async () => {
70
+ runAgentMock.mockResolvedValue({
71
+ sessionId: 'sess-1',
72
+ resultEvent: resultEventSuccess(),
73
+ events: [systemInit('sess-1'), resultEventSuccess()],
74
+ exitCode: 0,
75
+ timedOut: false,
76
+ stderr: '',
77
+ });
78
+ const invoker = createCursorAgentInvoker(baseInvokerConfig({ heartbeatIntervalMs: 10 }));
79
+ const result = await invoker({ command: '/gsd/execute-plan', args: '/p', description: 'x' }, '/workspace', logger, { goalTitle: 'Goal', phaseNumber: 3, planNumber: 1 });
80
+ expect(result).toEqual({ success: true, output: 'ok' });
81
+ expect(appendSessionLogMock).toHaveBeenCalled();
82
+ const statuses = appendSessionLogMock.mock.calls.map((c) => c[1]?.status);
83
+ expect(statuses[0]).toBe('running');
84
+ expect(statuses[1]).toBe('done');
85
+ expect(appendSessionLogMock.mock.calls[1][1]).toMatchObject({
86
+ status: 'done',
87
+ sessionId: 'sess-1',
88
+ durationMs: expect.any(Number),
89
+ });
90
+ });
91
+ it('writes timeout status when runAgent timedOut is true', async () => {
92
+ runAgentMock.mockResolvedValue({
93
+ sessionId: 'sess-2',
94
+ resultEvent: null,
95
+ events: [systemInit('sess-2')],
96
+ exitCode: null,
97
+ timedOut: true,
98
+ stderr: 'some stderr',
99
+ });
100
+ const invoker = createCursorAgentInvoker(baseInvokerConfig());
101
+ const result = await invoker({ command: '/gsd/execute-plan', args: '/p', description: 'x' }, '/workspace', logger, { goalTitle: 'Goal', phaseNumber: 3, planNumber: 1 });
102
+ expect(result.success).toBe(false);
103
+ expect(result.error).toContain('timed out');
104
+ const statuses = appendSessionLogMock.mock.calls.map((c) => c[1]?.status);
105
+ expect(statuses).toEqual(['running', 'timeout']);
106
+ });
107
+ it('writes crashed status when agent exits non-zero without timeout', async () => {
108
+ runAgentMock.mockResolvedValue({
109
+ sessionId: 'sess-3',
110
+ resultEvent: null,
111
+ events: [systemInit('sess-3')],
112
+ exitCode: 130,
113
+ timedOut: false,
114
+ stderr: 'Aborting operation...\n',
115
+ });
116
+ const invoker = createCursorAgentInvoker(baseInvokerConfig());
117
+ const result = await invoker({ command: '/gsd/execute-plan', args: '/p', description: 'x' }, '/workspace', logger, { goalTitle: 'Goal', phaseNumber: 3, planNumber: 1 });
118
+ expect(result.success).toBe(false);
119
+ expect(result.error).toContain('exit 130');
120
+ const statuses = appendSessionLogMock.mock.calls.map((c) => c[1]?.status);
121
+ expect(statuses).toEqual(['running', 'crashed']);
122
+ expect(appendSessionLogMock.mock.calls[1][1]).toMatchObject({
123
+ status: 'crashed',
124
+ sessionId: 'sess-3',
125
+ error: expect.any(String),
126
+ });
127
+ });
128
+ it('heartbeat writes at least once and is unlinked on completion', async () => {
129
+ vi.useFakeTimers();
130
+ runAgentMock.mockImplementation(async (opts) => {
131
+ opts.onEvent?.(systemInit('sess-4'));
132
+ return {
133
+ sessionId: 'sess-4',
134
+ resultEvent: resultEventSuccess(),
135
+ events: [systemInit('sess-4'), resultEventSuccess()],
136
+ exitCode: 0,
137
+ timedOut: false,
138
+ stderr: '',
139
+ };
140
+ });
141
+ const invoker = createCursorAgentInvoker(baseInvokerConfig({ heartbeatIntervalMs: 10 }));
142
+ const promise = invoker({ command: '/gsd/execute-plan', args: '/p', description: 'x' }, '/workspace', logger, { goalTitle: 'Goal', phaseNumber: 3, planNumber: 1 });
143
+ // allow initial tick + at least one interval tick
144
+ await vi.advanceTimersByTimeAsync(15);
145
+ const result = await promise;
146
+ expect(result.success).toBe(true);
147
+ expect(writeFileMock).toHaveBeenCalled();
148
+ expect(unlinkMock).toHaveBeenCalledWith('/tmp/heartbeat.txt');
149
+ });
150
+ });