@applica-software-guru/sdd-core 1.8.1 → 1.8.3

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 (90) hide show
  1. package/dist/agent/agent-defaults.js +2 -2
  2. package/dist/agent/agent-defaults.js.map +1 -1
  3. package/dist/agent/agent-runner.d.ts +17 -0
  4. package/dist/agent/agent-runner.d.ts.map +1 -1
  5. package/dist/agent/agent-runner.js +163 -11
  6. package/dist/agent/agent-runner.js.map +1 -1
  7. package/dist/agent/worker-daemon.d.ts +12 -0
  8. package/dist/agent/worker-daemon.d.ts.map +1 -0
  9. package/dist/agent/worker-daemon.js +220 -0
  10. package/dist/agent/worker-daemon.js.map +1 -0
  11. package/dist/git/git.d.ts +6 -0
  12. package/dist/git/git.d.ts.map +1 -1
  13. package/dist/git/git.js +60 -0
  14. package/dist/git/git.js.map +1 -1
  15. package/dist/index.d.ts +8 -3
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +19 -3
  18. package/dist/index.js.map +1 -1
  19. package/dist/parser/bug-parser.d.ts.map +1 -1
  20. package/dist/parser/bug-parser.js +2 -1
  21. package/dist/parser/bug-parser.js.map +1 -1
  22. package/dist/parser/cr-parser.d.ts.map +1 -1
  23. package/dist/parser/cr-parser.js +2 -1
  24. package/dist/parser/cr-parser.js.map +1 -1
  25. package/dist/prompt/draft-prompt-generator.d.ts +1 -1
  26. package/dist/prompt/draft-prompt-generator.d.ts.map +1 -1
  27. package/dist/prompt/draft-prompt-generator.js +23 -35
  28. package/dist/prompt/draft-prompt-generator.js.map +1 -1
  29. package/dist/prompt/prompt-generator.d.ts.map +1 -1
  30. package/dist/prompt/prompt-generator.js +5 -0
  31. package/dist/prompt/prompt-generator.js.map +1 -1
  32. package/dist/remote/api-client.d.ts +2 -2
  33. package/dist/remote/api-client.d.ts.map +1 -1
  34. package/dist/remote/api-client.js +4 -4
  35. package/dist/remote/api-client.js.map +1 -1
  36. package/dist/remote/sync-engine.d.ts.map +1 -1
  37. package/dist/remote/sync-engine.js +13 -8
  38. package/dist/remote/sync-engine.js.map +1 -1
  39. package/dist/remote/worker-client.d.ts +22 -0
  40. package/dist/remote/worker-client.d.ts.map +1 -0
  41. package/dist/remote/worker-client.js +88 -0
  42. package/dist/remote/worker-client.js.map +1 -0
  43. package/dist/remote/worker-types.d.ts +38 -0
  44. package/dist/remote/worker-types.d.ts.map +1 -0
  45. package/dist/remote/worker-types.js +3 -0
  46. package/dist/remote/worker-types.js.map +1 -0
  47. package/dist/scaffold/skill-adapters.d.ts +0 -9
  48. package/dist/scaffold/skill-adapters.d.ts.map +1 -1
  49. package/dist/scaffold/skill-adapters.js +17 -0
  50. package/dist/scaffold/skill-adapters.js.map +1 -1
  51. package/dist/scaffold/templates.d.ts +1 -5
  52. package/dist/scaffold/templates.d.ts.map +1 -1
  53. package/dist/scaffold/templates.generated.d.ts +7 -0
  54. package/dist/scaffold/templates.generated.d.ts.map +1 -0
  55. package/dist/scaffold/templates.generated.js +482 -0
  56. package/dist/scaffold/templates.generated.js.map +1 -0
  57. package/dist/scaffold/templates.js +8 -338
  58. package/dist/scaffold/templates.js.map +1 -1
  59. package/dist/sdd.d.ts +0 -1
  60. package/dist/sdd.d.ts.map +1 -1
  61. package/dist/sdd.js +1 -18
  62. package/dist/sdd.js.map +1 -1
  63. package/package.json +2 -1
  64. package/scripts/generate-templates.mjs +38 -0
  65. package/src/agent/agent-defaults.ts +2 -2
  66. package/src/agent/agent-runner.ts +184 -12
  67. package/src/agent/worker-daemon.ts +254 -0
  68. package/src/git/git.ts +61 -0
  69. package/src/index.ts +8 -3
  70. package/src/parser/bug-parser.ts +5 -3
  71. package/src/parser/cr-parser.ts +5 -3
  72. package/src/prompt/draft-prompt-generator.ts +26 -38
  73. package/src/prompt/prompt-generator.ts +8 -0
  74. package/src/remote/api-client.ts +2 -2
  75. package/src/remote/sync-engine.ts +16 -15
  76. package/src/remote/worker-client.ts +141 -0
  77. package/src/remote/worker-types.ts +40 -0
  78. package/src/scaffold/skill-adapters.ts +18 -11
  79. package/src/scaffold/templates.generated.ts +484 -0
  80. package/src/scaffold/templates.ts +9 -342
  81. package/src/sdd.ts +1 -19
  82. package/tests/agent-defaults.test.ts +24 -0
  83. package/tests/api-client.test.ts +6 -6
  84. package/tests/draft-prompt.test.ts +78 -0
  85. package/dist/prompt/apply-prompt-generator.d.ts +0 -4
  86. package/dist/prompt/apply-prompt-generator.d.ts.map +0 -1
  87. package/dist/prompt/apply-prompt-generator.js +0 -97
  88. package/dist/prompt/apply-prompt-generator.js.map +0 -1
  89. package/src/prompt/apply-prompt-generator.ts +0 -117
  90. package/tests/apply.test.ts +0 -117
@@ -1,4 +1,4 @@
1
- import { spawn } from 'node:child_process';
1
+ import { spawn, type ChildProcess } from 'node:child_process';
2
2
  import { writeFile, unlink } from 'node:fs/promises';
3
3
  import { tmpdir } from 'node:os';
4
4
  import { join } from 'node:path';
@@ -9,38 +9,164 @@ export interface AgentRunnerOptions {
9
9
  root: string;
10
10
  prompt: string;
11
11
  agent: string;
12
+ model?: string;
12
13
  agents?: Record<string, string>;
13
14
  onOutput?: (data: string) => void;
14
15
  }
15
16
 
16
- export async function runAgent(options: AgentRunnerOptions): Promise<number> {
17
- const { root, prompt, agent, agents, onOutput } = options;
17
+ /** Handle returned by startAgent() for interactive worker mode. */
18
+ export interface AgentRunnerHandle {
19
+ /** Resolves with the exit code when the agent process exits. */
20
+ exitPromise: Promise<number>;
21
+ /** Write data to the agent's stdin (for Q&A relay). */
22
+ writeStdin: (data: string) => void;
23
+ /** Kill the agent process. */
24
+ kill: () => void;
25
+ /** Path to the temp prompt file (cleaned up on exit). */
26
+ promptFile: string;
27
+ }
28
+
29
+ /**
30
+ * Extract human-readable text from a single stream-json NDJSON line.
31
+ * Returns the text to emit, or null if the line should be suppressed.
32
+ * Falls back to the raw line if it is not valid JSON (e.g. stderr noise).
33
+ */
34
+ function extractStreamJsonText(line: string): string | null {
35
+ let event: unknown;
36
+ try {
37
+ event = JSON.parse(line);
38
+ } catch {
39
+ // Not JSON — pass raw (e.g. stderr lines from subprocesses)
40
+ return line;
41
+ }
42
+
43
+ if (typeof event !== 'object' || event === null || !('type' in event)) {
44
+ return null;
45
+ }
46
+
47
+ const { type } = event as Record<string, unknown>;
48
+
49
+ if (type === 'assistant') {
50
+ const message = (event as Record<string, unknown>).message;
51
+ if (typeof message === 'object' && message !== null && 'content' in message) {
52
+ const content = (message as Record<string, unknown>).content;
53
+ if (Array.isArray(content)) {
54
+ const texts = content
55
+ .filter((b): b is { type: string; text: string } => b?.type === 'text' && typeof b?.text === 'string')
56
+ .map((b) => b.text);
57
+ return texts.length > 0 ? texts.join('') : null;
58
+ }
59
+ }
60
+ return null;
61
+ }
62
+
63
+ if (type === 'result') {
64
+ const result = (event as Record<string, unknown>).result;
65
+ if (typeof result === 'string' && result.trim()) {
66
+ return result;
67
+ }
68
+ return null;
69
+ }
70
+
71
+ // Suppress system/init/tool events — not useful for display
72
+ return null;
73
+ }
74
+
75
+ /**
76
+ * Attach NDJSON-parsing stream handlers to a child process.
77
+ * Each complete JSON line is parsed and the extracted text forwarded to `sink`.
78
+ * stderr lines are forwarded raw (they are not stream-json).
79
+ */
80
+ function pipeWithNdjsonParsing(
81
+ child: ChildProcess,
82
+ sink: (text: string) => void,
83
+ ): void {
84
+ if (child.stdout) {
85
+ let buf = '';
86
+ child.stdout.on('data', (data: Buffer) => {
87
+ buf += data.toString();
88
+ const lines = buf.split('\n');
89
+ buf = lines.pop() ?? '';
90
+ for (const line of lines) {
91
+ if (!line.trim()) continue;
92
+ const text = extractStreamJsonText(line);
93
+ if (text) sink(text);
94
+ }
95
+ });
96
+ child.stdout.on('end', () => {
97
+ if (buf.trim()) {
98
+ const text = extractStreamJsonText(buf);
99
+ if (text) sink(text);
100
+ }
101
+ });
102
+ }
103
+
104
+ if (child.stderr) {
105
+ child.stderr.on('data', (data: Buffer) => sink(data.toString()));
106
+ }
107
+ }
108
+
109
+ async function prepareAgent(options: AgentRunnerOptions): Promise<{ command: string; tmpFile: string; isClaudeAgent: boolean }> {
110
+ const { prompt, agent, model, agents } = options;
18
111
 
19
112
  const template = resolveAgentCommand(agent, agents);
20
113
  if (!template) {
21
114
  throw new Error(`Unknown agent "${agent}". Available: ${Object.keys(agents ?? {}).join(', ') || 'claude, codex, opencode'}`);
22
115
  }
23
116
 
24
- // Write prompt to temp file (too large for CLI arg)
25
117
  const tmpFile = join(tmpdir(), `sdd-prompt-${randomBytes(6).toString('hex')}.md`);
26
118
  await writeFile(tmpFile, prompt, 'utf-8');
27
119
 
28
- // Replace $PROMPT_FILE with the temp file path in the command template
29
- const command = template.replace(/\$PROMPT_FILE/g, tmpFile);
120
+ let command = template.replace(/\$PROMPT_FILE/g, tmpFile);
121
+ if (model) {
122
+ command = command.replace(/\$MODEL/g, model);
123
+ // If template doesn't have $MODEL but model is specified, inject --model flag
124
+ if (!template.includes('$MODEL')) {
125
+ command = command.replace(/^(\S+)/, `$1 --model ${model}`);
126
+ }
127
+ } else {
128
+ // Remove $MODEL placeholder and surrounding flags if not provided
129
+ command = command.replace(/--model\s+\$MODEL\s*/g, '');
130
+ command = command.replace(/\$MODEL\s*/g, '');
131
+ }
132
+
133
+ // For the claude CLI: switch to stream-json output for real-time streaming.
134
+ // --verbose is required alongside --output-format stream-json when using -p.
135
+ const isClaudeAgent = command.trimStart().startsWith('claude');
136
+ if (isClaudeAgent) {
137
+ // Ensure --verbose is present (required by stream-json in print mode)
138
+ if (!command.includes('--verbose')) {
139
+ command = command.trimEnd() + ' --verbose';
140
+ }
141
+ command = command.trimEnd() + ' --output-format stream-json';
142
+ }
143
+
144
+ return { command, tmpFile, isClaudeAgent };
145
+ }
146
+
147
+ export async function runAgent(options: AgentRunnerOptions): Promise<number> {
148
+ const { root, onOutput } = options;
149
+ const { command, tmpFile, isClaudeAgent } = await prepareAgent(options);
30
150
 
31
151
  try {
32
152
  const exitCode = await new Promise<number>((resolve, reject) => {
153
+ // For claude with stream-json we always pipe stdout so we can parse NDJSON.
154
+ // For other agents we inherit stdio when no onOutput is provided.
155
+ const usesPipe = isClaudeAgent || !!onOutput;
33
156
  const child = spawn(command, {
34
157
  cwd: root,
35
158
  shell: true,
36
- stdio: onOutput ? ['inherit', 'pipe', 'pipe'] : 'inherit',
159
+ stdio: usesPipe ? ['inherit', 'pipe', 'pipe'] : 'inherit',
37
160
  });
38
161
 
39
- if (onOutput && child.stdout) {
40
- child.stdout.on('data', (data: Buffer) => onOutput(data.toString()));
41
- }
42
- if (onOutput && child.stderr) {
43
- child.stderr.on('data', (data: Buffer) => onOutput(data.toString()));
162
+ if (usesPipe) {
163
+ const sink = onOutput ?? ((text: string) => process.stdout.write(text));
164
+ if (isClaudeAgent) {
165
+ pipeWithNdjsonParsing(child, sink);
166
+ } else {
167
+ if (child.stdout) child.stdout.on('data', (data: Buffer) => sink(data.toString()));
168
+ if (child.stderr) child.stderr.on('data', (data: Buffer) => sink(data.toString()));
169
+ }
44
170
  }
45
171
 
46
172
  child.on('error', reject);
@@ -52,3 +178,49 @@ export async function runAgent(options: AgentRunnerOptions): Promise<number> {
52
178
  await unlink(tmpFile).catch(() => {});
53
179
  }
54
180
  }
181
+
182
+ /**
183
+ * Start an agent in interactive mode (stdin piped for Q&A).
184
+ * Returns a handle for writing to stdin, killing the process, and awaiting exit.
185
+ */
186
+ export async function startAgent(options: AgentRunnerOptions): Promise<AgentRunnerHandle> {
187
+ const { root, onOutput } = options;
188
+ const { command, tmpFile, isClaudeAgent } = await prepareAgent(options);
189
+
190
+ const child: ChildProcess = spawn(command, {
191
+ cwd: root,
192
+ shell: true,
193
+ stdio: ['pipe', 'pipe', 'pipe'],
194
+ });
195
+
196
+ const sink = onOutput ?? ((text: string) => process.stdout.write(text));
197
+ if (isClaudeAgent) {
198
+ pipeWithNdjsonParsing(child, sink);
199
+ } else {
200
+ if (child.stdout) child.stdout.on('data', (data: Buffer) => sink(data.toString()));
201
+ if (child.stderr) child.stderr.on('data', (data: Buffer) => sink(data.toString()));
202
+ }
203
+
204
+ const exitPromise = new Promise<number>((resolve, reject) => {
205
+ child.on('error', reject);
206
+ child.on('close', (code) => {
207
+ unlink(tmpFile).catch(() => {});
208
+ resolve(code ?? 1);
209
+ });
210
+ });
211
+
212
+ return {
213
+ exitPromise,
214
+ writeStdin: (data: string) => {
215
+ if (child.stdin && !child.stdin.destroyed) {
216
+ child.stdin.write(data);
217
+ }
218
+ },
219
+ kill: () => {
220
+ if (!child.killed) {
221
+ child.kill('SIGTERM');
222
+ }
223
+ },
224
+ promptFile: tmpFile,
225
+ };
226
+ }
@@ -0,0 +1,254 @@
1
+ import { hostname } from 'node:os';
2
+ import { readFile, writeFile } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import type { ApiClientConfig } from '../remote/api-client.js';
5
+ import type { WorkerState, WorkerJobAssignment } from '../remote/worker-types.js';
6
+ import {
7
+ registerWorker,
8
+ workerHeartbeat,
9
+ workerPoll,
10
+ workerJobStarted,
11
+ workerJobOutput,
12
+ workerJobQuestion,
13
+ workerJobAnswers,
14
+ workerJobCompleted,
15
+ } from '../remote/worker-client.js';
16
+ import { startAgent } from './agent-runner.js';
17
+ import { checkoutBranch, getCurrentBranch, getCurrentCommit, getJobChangedFiles } from '../git/git.js';
18
+
19
+ export interface WorkerDaemonOptions {
20
+ root: string;
21
+ name?: string;
22
+ agent: string;
23
+ agents?: Record<string, string>;
24
+ apiConfig: ApiClientConfig;
25
+ onLog?: (message: string) => void;
26
+ renderPrompt?: (prompt: string) => string;
27
+ }
28
+
29
+ const HEARTBEAT_INTERVAL_MS = 15_000;
30
+ const OUTPUT_FLUSH_INTERVAL_MS = 2_000;
31
+ const QA_POLL_INTERVAL_MS = 2_000;
32
+ const WORKER_STATE_FILE = 'worker.json';
33
+
34
+ export async function startWorkerDaemon(options: WorkerDaemonOptions): Promise<void> {
35
+ const { root, agent, agents, apiConfig, onLog, renderPrompt } = options;
36
+ const workerName = options.name ?? hostname();
37
+ const sddDir = join(root, '.sdd');
38
+ const stateFile = join(sddDir, WORKER_STATE_FILE);
39
+
40
+ const log = onLog ?? ((msg: string) => process.stderr.write(`[worker] ${msg}\n`));
41
+ const shortId = (id: string) => id.slice(0, 8);
42
+
43
+ // --- Read current branch (informational — no checkout) ---
44
+ const branch = getCurrentBranch(root) ?? undefined;
45
+
46
+ // --- Register ---
47
+ log(`Registering worker "${workerName}" with agent "${agent}"${branch ? ` (branch: ${branch})` : ''}...`);
48
+ const registration = await registerWorker(apiConfig, workerName, agent, branch, {
49
+ hostname: hostname(),
50
+ platform: process.platform,
51
+ arch: process.arch,
52
+ });
53
+
54
+ const state: WorkerState = {
55
+ workerId: registration.id,
56
+ name: workerName,
57
+ registeredAt: registration.registered_at,
58
+ };
59
+ await writeFile(stateFile, JSON.stringify(state, null, 2), 'utf-8');
60
+ log(`Registered as worker ${shortId(registration.id)}`);
61
+
62
+ let running = true;
63
+ let currentJobKill: (() => void) | null = null;
64
+
65
+ // --- Graceful shutdown ---
66
+ const shutdown = async () => {
67
+ log('Shutting down...');
68
+ running = false;
69
+ if (currentJobKill) {
70
+ currentJobKill();
71
+ }
72
+ try {
73
+ await workerHeartbeat(apiConfig, registration.id, 'online');
74
+ } catch {
75
+ // best effort
76
+ }
77
+ };
78
+
79
+ process.on('SIGINT', () => { shutdown(); });
80
+ process.on('SIGTERM', () => { shutdown(); });
81
+
82
+ // --- Heartbeat loop ---
83
+ const heartbeatLoop = async () => {
84
+ while (running) {
85
+ try {
86
+ await workerHeartbeat(apiConfig, registration.id, currentJobKill ? 'busy' : 'online');
87
+ } catch (err) {
88
+ const msg = (err as Error).message;
89
+ if (!msg.includes('abort')) {
90
+ log(`Heartbeat error: ${msg}`);
91
+ }
92
+ }
93
+ await sleep(HEARTBEAT_INTERVAL_MS);
94
+ }
95
+ };
96
+
97
+ // --- Poll & execute loop ---
98
+ const pollLoop = async () => {
99
+ while (running) {
100
+ try {
101
+ const job = await workerPoll(apiConfig, registration.id);
102
+ if (job && running) {
103
+ await executeJob(job);
104
+ }
105
+ } catch (err) {
106
+ if (!running) break;
107
+ const msg = (err as Error).message;
108
+ // Don't log abort errors during shutdown
109
+ if (!msg.includes('abort')) {
110
+ log(`Poll error: ${msg}`);
111
+ }
112
+ await sleep(3_000);
113
+ }
114
+ }
115
+ };
116
+
117
+ // --- Job execution ---
118
+ const executeJob = async (job: WorkerJobAssignment) => {
119
+ const jobDesc = job.entity_type ? `${job.entity_type}/${job.entity_id}` : 'sync';
120
+ const jid = shortId(job.job_id);
121
+ log(`┌─ Job ${jid} (${jobDesc})`);
122
+ log(`│ Agent: ${job.agent}${job.model ? ` Model: ${job.model}` : ''}${job.branch ? ` Branch: ${job.branch}` : ''}`);
123
+ const promptPreview = job.prompt.split('\n').find(l => l.trim().length > 0) ?? '';
124
+ log(`└─ ${promptPreview.length > 80 ? promptPreview.slice(0, 80) + '…' : promptPreview}`);
125
+
126
+ // Checkout branch if the job explicitly requests one different from the current
127
+ if (job.branch && job.branch !== getCurrentBranch(root)) {
128
+ log(`Checking out branch "${job.branch}" for job ${job.job_id}...`);
129
+ try {
130
+ checkoutBranch(root, job.branch);
131
+ } catch (err) {
132
+ log(`Warning: could not checkout branch "${job.branch}": ${(err as Error).message}`);
133
+ }
134
+ }
135
+
136
+ // Capture git state before the job starts so we can diff at the end
137
+ const baseCommit = getCurrentCommit(root);
138
+
139
+ try {
140
+ await workerJobStarted(apiConfig, job.job_id);
141
+ } catch (err) {
142
+ log(`Failed to notify job start: ${(err as Error).message}`);
143
+ }
144
+
145
+ // Buffer for batched output
146
+ let outputBuffer: string[] = [];
147
+ let lastFlush = Date.now();
148
+ let lastAnswerSequence = 0;
149
+ let waitingForAnswer = false;
150
+
151
+ const flushOutput = async () => {
152
+ if (outputBuffer.length === 0) return;
153
+ const lines = outputBuffer.splice(0);
154
+ try {
155
+ await workerJobOutput(apiConfig, job.job_id, lines);
156
+ } catch (err) {
157
+ log(`Output flush error: ${(err as Error).message}`);
158
+ }
159
+ };
160
+
161
+ // Start the agent with interactive mode
162
+ const handle = await startAgent({
163
+ root,
164
+ prompt: job.prompt,
165
+ agent: job.agent,
166
+ model: job.model,
167
+ agents,
168
+ onOutput: (data: string) => {
169
+ // Split into lines but preserve partial lines
170
+ const lines = data.split('\n');
171
+ for (const line of lines) {
172
+ // Suppress the Claude CLI stdin warning — prompt is passed via -p flag,
173
+ // stdin is kept open only for Q&A relay and does not need initial data.
174
+ if (line.includes('no stdin data received') || line.includes('redirect stdin explicitly')) {
175
+ continue;
176
+ }
177
+ if (line.length > 0) {
178
+ outputBuffer.push(line);
179
+ }
180
+ }
181
+
182
+ // Flush if enough time has passed
183
+ if (Date.now() - lastFlush >= OUTPUT_FLUSH_INTERVAL_MS) {
184
+ lastFlush = Date.now();
185
+ flushOutput();
186
+ }
187
+ },
188
+ });
189
+
190
+ currentJobKill = handle.kill;
191
+
192
+ // Periodic output flush + answer polling loop
193
+ const flushAndPollLoop = async () => {
194
+ while (running) {
195
+ // Flush pending output
196
+ await flushOutput();
197
+ lastFlush = Date.now();
198
+
199
+ // If we detected a question, poll for answers
200
+ if (waitingForAnswer) {
201
+ try {
202
+ const answers = await workerJobAnswers(apiConfig, job.job_id, lastAnswerSequence);
203
+ if (answers.length > 0) {
204
+ for (const ans of answers) {
205
+ handle.writeStdin(ans.content + '\n');
206
+ lastAnswerSequence = ans.sequence;
207
+ }
208
+ waitingForAnswer = false;
209
+ }
210
+ } catch (err) {
211
+ log(`Answer poll error: ${(err as Error).message}`);
212
+ }
213
+ }
214
+
215
+ await sleep(QA_POLL_INTERVAL_MS);
216
+ }
217
+ };
218
+
219
+ // Run the flush/poll loop concurrently with the agent
220
+ const flushPromise = flushAndPollLoop();
221
+
222
+ try {
223
+ const exitCode = await handle.exitPromise;
224
+
225
+ // Final flush
226
+ running = true; // temporarily re-enable to allow final flush
227
+ await flushOutput();
228
+
229
+ const changedFiles = getJobChangedFiles(root, baseCommit);
230
+ const filesMsg = changedFiles.length > 0 ? `, ${changedFiles.length} file(s) changed` : '';
231
+ log(`Job ${jid} done — exit ${exitCode}${filesMsg}`);
232
+ await workerJobCompleted(apiConfig, job.job_id, exitCode, changedFiles);
233
+ } catch (err) {
234
+ log(`Job ${jid} error: ${(err as Error).message}`);
235
+ try {
236
+ const changedFiles = getJobChangedFiles(root, baseCommit);
237
+ await workerJobCompleted(apiConfig, job.job_id, 1, changedFiles);
238
+ } catch {
239
+ // best effort
240
+ }
241
+ } finally {
242
+ currentJobKill = null;
243
+ }
244
+ };
245
+
246
+ // --- Start concurrent loops ---
247
+ log(`Worker "${workerName}" is online. Waiting for jobs… (id: ${shortId(registration.id)})`);
248
+ await Promise.all([heartbeatLoop(), pollLoop()]);
249
+ log('Worker stopped.');
250
+ }
251
+
252
+ function sleep(ms: number): Promise<void> {
253
+ return new Promise((resolve) => setTimeout(resolve, ms));
254
+ }
package/src/git/git.ts CHANGED
@@ -85,6 +85,67 @@ export function getGitModifiedFiles(root: string): Set<string> {
85
85
  return modified;
86
86
  }
87
87
 
88
+ export function getCurrentBranch(root: string): string | null {
89
+ try {
90
+ return run('git rev-parse --abbrev-ref HEAD', root) || null;
91
+ } catch {
92
+ return null;
93
+ }
94
+ }
95
+
96
+ export function checkoutBranch(root: string, branch: string): void {
97
+ try {
98
+ run(`git checkout ${branch}`, root);
99
+ } catch {
100
+ // branch doesn't exist — create it
101
+ run(`git checkout -b ${branch}`, root);
102
+ }
103
+ }
104
+
105
+ export function getJobChangedFiles(
106
+ root: string,
107
+ fromCommit: string | null,
108
+ ): Array<{ path: string; status: 'new' | 'modified' | 'deleted' }> {
109
+ const result = new Map<string, 'new' | 'modified' | 'deleted'>();
110
+
111
+ try {
112
+ // Committed changes since the base commit
113
+ if (fromCommit) {
114
+ const committed = run(`git diff --name-status ${fromCommit} HEAD`, root);
115
+ for (const line of committed.split('\n').filter(Boolean)) {
116
+ const parts = line.split('\t');
117
+ const flag = parts[0][0]; // first char (A/M/D/R/C)
118
+ // Renames have two paths: old\tnew — use the new path
119
+ const path = parts.length >= 3 ? parts[2] : parts[1];
120
+ const fileStatus: 'new' | 'modified' | 'deleted' =
121
+ flag === 'A' ? 'new' : flag === 'D' ? 'deleted' : 'modified';
122
+ result.set(path, fileStatus);
123
+ }
124
+ }
125
+
126
+ // Uncommitted working tree changes (staged + unstaged + untracked)
127
+ const porcelain = run('git status --porcelain', root);
128
+ for (const line of porcelain.split('\n').filter(Boolean)) {
129
+ const xy = line.substring(0, 2);
130
+ const rawPath = line.substring(3).trim();
131
+ // Renamed files show as "old -> new" in porcelain v1
132
+ const path = rawPath.includes(' -> ') ? rawPath.split(' -> ')[1] : rawPath;
133
+
134
+ if (xy[0] === 'D' || xy[1] === 'D') {
135
+ result.set(path, 'deleted');
136
+ } else if (xy === '??' || xy[0] === 'A' || xy[1] === 'A') {
137
+ if (!result.has(path)) result.set(path, 'new');
138
+ } else {
139
+ if (!result.has(path)) result.set(path, 'modified');
140
+ }
141
+ }
142
+ } catch {
143
+ // not a git repo or no commits — return empty
144
+ }
145
+
146
+ return Array.from(result.entries()).map(([path, status]) => ({ path, status }));
147
+ }
148
+
88
149
  export function getChangedFiles(root: string, fromCommit: string | null): Array<{ path: string; status: 'new' | 'modified' | 'deleted' }> {
89
150
  try {
90
151
  if (!fromCommit) {
package/src/index.ts CHANGED
@@ -21,8 +21,11 @@ export type {
21
21
  export { SDDError, LockFileNotFoundError, ParseError, ProjectNotInitializedError, RemoteError, RemoteNotConfiguredError, RemoteTimeoutError } from "./errors.js";
22
22
  export type { ProjectInfo } from "./scaffold/templates.js";
23
23
  export { isSDDProject, readConfig, writeConfig } from "./config/config-manager.js";
24
- export { runAgent } from "./agent/agent-runner.js";
25
- export type { AgentRunnerOptions } from "./agent/agent-runner.js";
24
+ export { getCurrentBranch, checkoutBranch } from "./git/git.js";
25
+ export { runAgent, startAgent } from "./agent/agent-runner.js";
26
+ export type { AgentRunnerOptions, AgentRunnerHandle } from "./agent/agent-runner.js";
27
+ export { startWorkerDaemon } from "./agent/worker-daemon.js";
28
+ export type { WorkerDaemonOptions } from "./agent/worker-daemon.js";
26
29
  export { DEFAULT_AGENTS, resolveAgentCommand } from "./agent/agent-defaults.js";
27
30
  export { listSupportedAdapters, SKILL_ADAPTERS, syncSkillAdapters } from "./scaffold/skill-adapters.js";
28
31
  export type {
@@ -37,8 +40,10 @@ export type {
37
40
  // Remote sync
38
41
  export { generateDraftEnrichmentPrompt } from "./prompt/draft-prompt-generator.js";
39
42
  export type { DraftElements } from "./prompt/draft-prompt-generator.js";
40
- export { resolveApiKey, buildApiConfig, pullDocs, pushDocs, fetchPendingCRs, fetchOpenBugs, markCRAppliedRemote, markBugResolvedRemote, markDocEnriched, markCREnriched, markBugEnriched, resetProject, DEFAULT_REMOTE_TIMEOUT } from "./remote/api-client.js";
43
+ export { resolveApiKey, buildApiConfig, pullDocs, pushDocs, pullPendingCRs, pullOpenBugs, markCRAppliedRemote, markBugResolvedRemote, markDocEnriched, markCREnriched, markBugEnriched, resetProject, DEFAULT_REMOTE_TIMEOUT } from "./remote/api-client.js";
41
44
  export type { ApiClientConfig } from "./remote/api-client.js";
45
+ export { registerWorker, workerHeartbeat, workerPoll, workerJobStarted, workerJobOutput, workerJobQuestion, workerJobAnswers, workerJobCompleted } from "./remote/worker-client.js";
46
+ export type { WorkerRegistration, WorkerJobAssignment, WorkerJobAnswer, WorkerState } from "./remote/worker-types.js";
42
47
  export { readRemoteState, writeRemoteState } from "./remote/state.js";
43
48
  export { pushToRemote, pullFromRemote, pullCRsFromRemote, pullBugsFromRemote, getRemoteStatus, resetRemoteProject } from "./remote/sync-engine.js";
44
49
  export type {
@@ -1,10 +1,12 @@
1
1
  import { readFile } from 'node:fs/promises';
2
- import { resolve, relative } from 'node:path';
2
+ import { relative } from 'node:path';
3
3
  import { glob } from 'glob';
4
4
  import matter from 'gray-matter';
5
- import type { Bug, BugFrontmatter } from '../types.js';
5
+ import type { Bug, BugFrontmatter, BugStatus } from '../types.js';
6
6
  import { ParseError } from '../errors.js';
7
7
 
8
+ const VALID_BUG_STATUSES: Set<string> = new Set(['draft', 'open', 'resolved']);
9
+
8
10
  export async function discoverBugFiles(root: string): Promise<string[]> {
9
11
  const pattern = 'bugs/*.md';
10
12
  const matches = await glob(pattern, { cwd: root, absolute: true });
@@ -16,7 +18,7 @@ export function parseBugFile(filePath: string, content: string): { frontmatter:
16
18
  const { data, content: body } = matter(content);
17
19
  const frontmatter: BugFrontmatter = {
18
20
  title: data.title ?? '',
19
- status: data.status ?? 'open',
21
+ status: (VALID_BUG_STATUSES.has(data.status) ? data.status : 'open') as BugStatus,
20
22
  author: data.author ?? '',
21
23
  'created-at': data['created-at'] ?? '',
22
24
  };
@@ -1,10 +1,12 @@
1
1
  import { readFile } from 'node:fs/promises';
2
- import { resolve, relative } from 'node:path';
2
+ import { relative } from 'node:path';
3
3
  import { glob } from 'glob';
4
4
  import matter from 'gray-matter';
5
- import type { ChangeRequest, ChangeRequestFrontmatter } from '../types.js';
5
+ import type { ChangeRequest, ChangeRequestFrontmatter, ChangeRequestStatus } from '../types.js';
6
6
  import { ParseError } from '../errors.js';
7
7
 
8
+ const VALID_CR_STATUSES: Set<string> = new Set(['draft', 'pending', 'applied']);
9
+
8
10
  export async function discoverCRFiles(root: string): Promise<string[]> {
9
11
  const pattern = 'change-requests/*.md';
10
12
  const matches = await glob(pattern, { cwd: root, absolute: true });
@@ -16,7 +18,7 @@ export function parseCRFile(filePath: string, content: string): { frontmatter: C
16
18
  const { data, content: body } = matter(content);
17
19
  const frontmatter: ChangeRequestFrontmatter = {
18
20
  title: data.title ?? '',
19
- status: data.status ?? 'draft',
21
+ status: (VALID_CR_STATUSES.has(data.status) ? data.status : 'draft') as ChangeRequestStatus,
20
22
  author: data.author ?? '',
21
23
  'created-at': data['created-at'] ?? '',
22
24
  };