@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.
- package/dist/agent/agent-defaults.js +2 -2
- package/dist/agent/agent-defaults.js.map +1 -1
- package/dist/agent/agent-runner.d.ts +17 -0
- package/dist/agent/agent-runner.d.ts.map +1 -1
- package/dist/agent/agent-runner.js +163 -11
- package/dist/agent/agent-runner.js.map +1 -1
- package/dist/agent/worker-daemon.d.ts +12 -0
- package/dist/agent/worker-daemon.d.ts.map +1 -0
- package/dist/agent/worker-daemon.js +220 -0
- package/dist/agent/worker-daemon.js.map +1 -0
- package/dist/git/git.d.ts +6 -0
- package/dist/git/git.d.ts.map +1 -1
- package/dist/git/git.js +60 -0
- package/dist/git/git.js.map +1 -1
- package/dist/index.d.ts +8 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +19 -3
- package/dist/index.js.map +1 -1
- package/dist/parser/bug-parser.d.ts.map +1 -1
- package/dist/parser/bug-parser.js +2 -1
- package/dist/parser/bug-parser.js.map +1 -1
- package/dist/parser/cr-parser.d.ts.map +1 -1
- package/dist/parser/cr-parser.js +2 -1
- package/dist/parser/cr-parser.js.map +1 -1
- package/dist/prompt/draft-prompt-generator.d.ts +1 -1
- package/dist/prompt/draft-prompt-generator.d.ts.map +1 -1
- package/dist/prompt/draft-prompt-generator.js +23 -35
- package/dist/prompt/draft-prompt-generator.js.map +1 -1
- package/dist/prompt/prompt-generator.d.ts.map +1 -1
- package/dist/prompt/prompt-generator.js +5 -0
- package/dist/prompt/prompt-generator.js.map +1 -1
- package/dist/remote/api-client.d.ts +2 -2
- package/dist/remote/api-client.d.ts.map +1 -1
- package/dist/remote/api-client.js +4 -4
- package/dist/remote/api-client.js.map +1 -1
- package/dist/remote/sync-engine.d.ts.map +1 -1
- package/dist/remote/sync-engine.js +13 -8
- package/dist/remote/sync-engine.js.map +1 -1
- package/dist/remote/worker-client.d.ts +22 -0
- package/dist/remote/worker-client.d.ts.map +1 -0
- package/dist/remote/worker-client.js +88 -0
- package/dist/remote/worker-client.js.map +1 -0
- package/dist/remote/worker-types.d.ts +38 -0
- package/dist/remote/worker-types.d.ts.map +1 -0
- package/dist/remote/worker-types.js +3 -0
- package/dist/remote/worker-types.js.map +1 -0
- package/dist/scaffold/skill-adapters.d.ts +0 -9
- package/dist/scaffold/skill-adapters.d.ts.map +1 -1
- package/dist/scaffold/skill-adapters.js +17 -0
- package/dist/scaffold/skill-adapters.js.map +1 -1
- package/dist/scaffold/templates.d.ts +1 -5
- package/dist/scaffold/templates.d.ts.map +1 -1
- package/dist/scaffold/templates.generated.d.ts +7 -0
- package/dist/scaffold/templates.generated.d.ts.map +1 -0
- package/dist/scaffold/templates.generated.js +482 -0
- package/dist/scaffold/templates.generated.js.map +1 -0
- package/dist/scaffold/templates.js +8 -338
- package/dist/scaffold/templates.js.map +1 -1
- package/dist/sdd.d.ts +0 -1
- package/dist/sdd.d.ts.map +1 -1
- package/dist/sdd.js +1 -18
- package/dist/sdd.js.map +1 -1
- package/package.json +2 -1
- package/scripts/generate-templates.mjs +38 -0
- package/src/agent/agent-defaults.ts +2 -2
- package/src/agent/agent-runner.ts +184 -12
- package/src/agent/worker-daemon.ts +254 -0
- package/src/git/git.ts +61 -0
- package/src/index.ts +8 -3
- package/src/parser/bug-parser.ts +5 -3
- package/src/parser/cr-parser.ts +5 -3
- package/src/prompt/draft-prompt-generator.ts +26 -38
- package/src/prompt/prompt-generator.ts +8 -0
- package/src/remote/api-client.ts +2 -2
- package/src/remote/sync-engine.ts +16 -15
- package/src/remote/worker-client.ts +141 -0
- package/src/remote/worker-types.ts +40 -0
- package/src/scaffold/skill-adapters.ts +18 -11
- package/src/scaffold/templates.generated.ts +484 -0
- package/src/scaffold/templates.ts +9 -342
- package/src/sdd.ts +1 -19
- package/tests/agent-defaults.test.ts +24 -0
- package/tests/api-client.test.ts +6 -6
- package/tests/draft-prompt.test.ts +78 -0
- package/dist/prompt/apply-prompt-generator.d.ts +0 -4
- package/dist/prompt/apply-prompt-generator.d.ts.map +0 -1
- package/dist/prompt/apply-prompt-generator.js +0 -97
- package/dist/prompt/apply-prompt-generator.js.map +0 -1
- package/src/prompt/apply-prompt-generator.ts +0 -117
- 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
|
-
|
|
17
|
-
|
|
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
|
-
|
|
29
|
-
|
|
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:
|
|
159
|
+
stdio: usesPipe ? ['inherit', 'pipe', 'pipe'] : 'inherit',
|
|
37
160
|
});
|
|
38
161
|
|
|
39
|
-
if (
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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 {
|
|
25
|
-
export
|
|
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,
|
|
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 {
|
package/src/parser/bug-parser.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises';
|
|
2
|
-
import {
|
|
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
|
|
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
|
};
|
package/src/parser/cr-parser.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises';
|
|
2
|
-
import {
|
|
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
|
|
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
|
};
|