@syntax-syllogism/aloop 0.5.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.
@@ -0,0 +1,77 @@
1
+ {{TASK_CONTEXT}}
2
+
3
+ Take on the role of a Sr. Dev. Your job is to review all work on the branch for
4
+ quality, following best practice and bugs. This work is in reference to task
5
+ `{{TASK_NAME}}`. Implemented on branch `{{BRANCH}}` in the worktree at
6
+ `{{REPO}}`.
7
+
8
+ If a task file is present (`{{TASK_FILE}}`), add your notes to its `## Code Review`
9
+ section, creating the section at the end of the file if it is absent. Start with Date
10
+ AND Time as there may be multiple rounds of code reviews. Provide a summary of your
11
+ review and note any gaps in completion of the task.
12
+
13
+ The implementation, repair, and documentation phases commit their work. The
14
+ worktree should be clean; confirm that with `git status`. Review the complete
15
+ cumulative branch diff with `git diff {{BASE_BRANCH}}...HEAD`, including all
16
+ new files. Review all of it, not a sample.
17
+
18
+ This is review round {{ROUND}} of at most {{MAX_ROUNDS}}.
19
+
20
+ Deterministic gate status: {{GATE_STATUS}}
21
+
22
+ ## If this is round 2 or later
23
+
24
+ The previous review ended at commit `{{SINCE_SHA}}`. First inspect the
25
+ incremental response with `git diff {{SINCE_SHA}}..HEAD` to confirm all findings
26
+ from the previous round have been addressed. Then sanity-check the cumulative
27
+ branch diff for regressions elsewhere. Report only blocking issues — style
28
+ preferences and minor suggestions burn a round without improving the merge.
29
+ Note explicitly which previous findings are now resolved and which are not.
30
+
31
+ ## Findings
32
+
33
+ Cite `file:line` in the cumulative branch diff for every finding. A finding you
34
+ cannot point to there is not a finding.
35
+
36
+ Classify each by severity:
37
+
38
+ - 🔴 **Blocker** — must fix before merge (bugs, security, broken spec alignment).
39
+ - 🟡 **Major** — should fix; meaningful correctness or quality concern.
40
+ - 🟢 **Minor** — nice to fix; style, small refactors.
41
+ - 💬 **Nit/Question** — optional, or a request for clarification.
42
+
43
+ Do not bury blockers under nits. Watch specifically for tests deleted, skipped,
44
+ or weakened to make a gate pass; acceptance criteria in the task that no test
45
+ exercises; changes outside the task's scope; and error paths that swallow failures silently.
46
+
47
+ Do not fix anything yourself. Reviewing and repairing are separate phases.
48
+
49
+ ## Machine-readable verdict
50
+
51
+ In addition to your notes in the task file (if present), write this JSON to
52
+ `{{VERDICT_FILE}}`. The loop reads only this file to decide whether to continue
53
+ — it does not read your prose or your stdout.
54
+
55
+ ```json
56
+ {
57
+ "verdict": "APPROVED | CHANGES_REQUESTED",
58
+ "summary": "one line",
59
+ "blocking": [
60
+ { "file": "src/x.ts", "line": 42, "issue": "what is wrong and what to do" }
61
+ ],
62
+ "nits": [
63
+ { "file": "src/y.ts", "line": 7, "issue": "..." }
64
+ ]
65
+ }
66
+ ```
67
+
68
+ Rules the loop enforces — violating them stalls the run:
69
+
70
+ - `blocking` holds every 🔴 and 🟡 finding. `nits` holds 🟢 and 💬.
71
+ - `CHANGES_REQUESTED` requires at least one entry in `blocking`.
72
+ - `APPROVED` requires `blocking` to be empty.
73
+ - Approve only if you would merge this as-is. If the gate status above is
74
+ FAILING, the verdict is `CHANGES_REQUESTED` regardless of code quality.
75
+
76
+ Approving weak work is a worse failure than requesting one more round. So is
77
+ inventing a finding to look thorough — if the work is genuinely ready, approve it.
@@ -0,0 +1,59 @@
1
+ {{TASK_CONTEXT}}
2
+
3
+ The structured review verdict is recorded in `{{VERDICT_FILE}}`, and the full
4
+ prose review is in `{{RUN_DIR}}/review-round-{{ROUND}}.md`. Address all blocking
5
+ findings.
6
+
7
+ The work is in the worktree at `{{REPO}}` on branch `{{BRANCH}}`; prior phases
8
+ have committed their changes.
9
+
10
+ This is round {{ROUND}} of at most {{MAX_ROUNDS}}. If the findings are not
11
+ resolved by round {{MAX_ROUNDS}} the run stops and a human picks it up, so fix
12
+ causes rather than symptoms.
13
+
14
+ ## Blocking findings
15
+
16
+ {{FINDINGS}}
17
+
18
+ Write any rebuttal to `{{RUN_DIR}}/response-round-{{ROUND}}.md`.
19
+
20
+ ## Deterministic gate
21
+
22
+ {{GATE_STATUS}}
23
+
24
+ If the gate is failing, that is your first priority — the review cannot clear
25
+ while it is red.
26
+
27
+ ## How to respond
28
+
29
+ Address every blocking finding. For each one, either fix it or — if you believe
30
+ the reviewer is wrong — leave the code as it is and record a short rebuttal in
31
+ `{{RUN_DIR}}/response-round-{{ROUND}}.md`, naming the finding and your reasoning.
32
+ A disagreement recorded in writing is a legitimate outcome; silently ignoring a
33
+ finding is not.
34
+
35
+ Do not:
36
+
37
+ - Delete, skip, or weaken a test to make a finding or a gate go away. If a test
38
+ is genuinely wrong, fix the test and explain why in the response file.
39
+ - Fix anything the reviewer did not raise. Unrelated changes make the next
40
+ review round harder and can introduce new findings.
41
+
42
+ Scope and token discipline from the implementation phase still apply: work from
43
+ the files the findings name, keep command output short, and summarize before
44
+ editing.
45
+
46
+ ## Verification
47
+
48
+ Run the targeted tests for what you changed, then the gate commands:
49
+
50
+ ```
51
+ {{GATE_COMMANDS}}
52
+ ```
53
+
54
+ Judge them by process exit code on unfiltered output.
55
+
56
+ At the end of the phase, commit the fixes and their tests in a conventional
57
+ commit whose message names the review findings addressed. Keep the worktree
58
+ clean for the next phase. If there is nothing to commit, leave the worktree
59
+ clean.
@@ -0,0 +1,23 @@
1
+ {{TASK_CONTEXT}}
2
+
3
+ Do a documentation-as-built pass for the committed work on branch `{{BRANCH}}`,
4
+ in the worktree at `{{REPO}}`.
5
+
6
+ This is a documentation reconciliation pass only. Do not make code changes
7
+ unless they are strictly necessary to correct documentation generation or a
8
+ broken reference.
9
+
10
+ Audit the final as-built state and update whatever durable documentation the
11
+ change affects: README files, docs/, or concise inline documentation. If the
12
+ documentation is already accurate, make no changes.
13
+
14
+ After the pass, run the smallest meaningful verification checks for
15
+ documentation changes, such as checking links and paths by inspection and
16
+ reviewing the documentation diff.
17
+
18
+ If you find a bug while reading, note it in your final summary rather than
19
+ fixing it; a code change at this point would go out unreviewed.
20
+
21
+ At the end of the phase, commit documentation changes in a `docs:` conventional
22
+ commit. Keep the worktree clean for the next phase. If the documentation is
23
+ already accurate, make no changes and leave the worktree clean.
package/prompts/git.md ADDED
@@ -0,0 +1,29 @@
1
+ {{TASK_CONTEXT}}
2
+
3
+ The code-producing phases have already committed their work in `{{REPO}}`, on
4
+ branch `{{BRANCH}}`.
5
+
6
+ Verify the worktree is clean and do not create any code commits in this phase.
7
+
8
+ - Inspect the full worktree and recent branch history before pushing.
9
+ - Do _NOT_ include notes about co-author / author.
10
+ - Report the existing code commit hashes and messages, validation performed,
11
+ and whether the worktree is clean.
12
+ - Push to `{{REMOTE}}` and create a PR to `{{BASE_BRANCH}}` on the same repo. PR
13
+ body should be < 250 words.
14
+
15
+ The PR body should cover what changed and why, a summary of the task, the review
16
+ outcome (how many rounds, plus any recorded disagreements), and anything
17
+ deliberately left out of scope.
18
+
19
+ ## Boundaries
20
+
21
+ - If the worktree is **not** clean — `git status --porcelain` reports anything —
22
+ do **not** edit files, tests, or configuration to make it clean, and do not
23
+ create commits. A dirty tree at this phase is a pipeline error: stop
24
+ immediately and report the offending paths. Reconciling it is out of scope.
25
+ - **Do not merge the pull request**, and do not enable auto-merge. A human
26
+ decides; an open PR is the correct end state for this run.
27
+ - Never force-push, and never rewrite commits that already exist on `{{REMOTE}}`.
28
+ - If the push is rejected because the branch moved, stop and report it rather
29
+ than forcing anything.
@@ -0,0 +1,45 @@
1
+ {{TASK_CONTEXT}}
2
+
3
+ Work in the worktree at
4
+ `{{REPO}}`, on the existing branch `{{BRANCH}}` based on `{{BASE_BRANCH}}`.
5
+ Do not create or switch branches.
6
+
7
+ Skip any steps that require manual human intervention. Implement the task from
8
+ start to finish.
9
+
10
+ This run is unattended, so you cannot ask clarifying questions. Where you would
11
+ have asked, choose the reading most consistent with the task and the surrounding
12
+ code, and record the question and assumption in `{{RUN_DIR}}/notes.md`. If a
13
+ step genuinely cannot proceed without a human, implement everything else and
14
+ record what you skipped and why in that file.
15
+
16
+ Scope:
17
+
18
+ - Start with the files referenced in the task.
19
+ - Do _not_ scan or explore the whole repo.
20
+ - Only read additional files if they are imported or referenced in the task.
21
+
22
+ Token discipline:
23
+
24
+ - Keep command output short.
25
+ - Summarize findings before editing.
26
+
27
+ Verification:
28
+
29
+ - Add or update targeted tests.
30
+ - Run only the relevant test file first.
31
+ - Run broader tests after the targeted test passes.
32
+ - These commands gate the work and will be run against your changes, so run
33
+ them yourself before finishing:
34
+
35
+ ```
36
+ {{GATE_COMMANDS}}
37
+ ```
38
+
39
+ - Judge them by process exit code on unfiltered output. A wrapper that
40
+ summarizes output can report a failure the tool never produced, and a cached
41
+ build can report success without compiling anything.
42
+ - At the end of the phase, commit the implementation and its targeted tests in
43
+ a conventional commit. Keep the worktree clean for the next phase.
44
+ - End your turn with a summary and next steps if applicable. The loop runs the
45
+ code review next, so you do not need to ask for one.
@@ -0,0 +1,74 @@
1
+ {{TASK_CONTEXT}}
2
+
3
+ Take on the role of a senior developer. Review all work on branch `{{BRANCH}}`
4
+ in the worktree at `{{REPO}}` for bugs, quality, and alignment with the task.
5
+
6
+ Write your prose review to `{{RUN_DIR}}/review-round-{{ROUND}}.md`. Include a
7
+ summary of the review and any gaps in completion of the task.
8
+
9
+ The implementation, repair, and documentation phases commit their work. The
10
+ worktree should be clean; confirm that with `git status`. Review the complete
11
+ cumulative branch diff with `git diff {{BASE_BRANCH}}...HEAD`, including all
12
+ new files. Review all of it, not a sample.
13
+
14
+ This is review round {{ROUND}} of at most {{MAX_ROUNDS}}.
15
+
16
+ Deterministic gate status: {{GATE_STATUS}}
17
+
18
+ ## If this is round 2 or later
19
+
20
+ The previous review ended at commit `{{SINCE_SHA}}`. First inspect the
21
+ incremental response with `git diff {{SINCE_SHA}}..HEAD` to confirm all findings
22
+ from the previous round have been addressed. Then sanity-check the cumulative
23
+ branch diff for regressions elsewhere. Report only blocking issues — style
24
+ preferences and minor suggestions burn a round without improving the merge.
25
+ Note explicitly which previous findings are now resolved and which are not.
26
+
27
+ ## Findings
28
+
29
+ Cite `file:line` in the cumulative branch diff for every finding. A finding you
30
+ cannot point to there is not a finding.
31
+
32
+ Classify each by severity:
33
+
34
+ - 🔴 **Blocker** — must fix before merge (bugs, security, broken spec alignment).
35
+ - 🟡 **Major** — should fix; meaningful correctness or quality concern.
36
+ - 🟢 **Minor** — nice to fix; style, small refactors.
37
+ - 💬 **Nit/Question** — optional, or a request for clarification.
38
+
39
+ Do not bury blockers under nits. Watch specifically for tests deleted, skipped,
40
+ or weakened to make a gate pass; acceptance criteria in the task that no test
41
+ exercises; changes outside the task's scope; and error paths that swallow
42
+ failures silently.
43
+
44
+ Do not fix anything yourself. Reviewing and repairing are separate phases.
45
+
46
+ ## Machine-readable verdict
47
+
48
+ In addition to your prose review, write this JSON to `{{VERDICT_FILE}}`. The
49
+ loop reads only this file to decide whether to continue — it does not read your
50
+ prose or your stdout.
51
+
52
+ ```json
53
+ {
54
+ "verdict": "APPROVED | CHANGES_REQUESTED",
55
+ "summary": "one line",
56
+ "blocking": [
57
+ { "file": "src/x.ts", "line": 42, "issue": "what is wrong and what to do" }
58
+ ],
59
+ "nits": [
60
+ { "file": "src/y.ts", "line": 7, "issue": "..." }
61
+ ]
62
+ }
63
+ ```
64
+
65
+ Rules the loop enforces — violating them stalls the run:
66
+
67
+ - `blocking` holds every 🔴 and 🟡 finding. `nits` holds 🟢 and 💬.
68
+ - `CHANGES_REQUESTED` requires at least one entry in `blocking`.
69
+ - `APPROVED` requires `blocking` to be empty.
70
+ - Approve only if you would merge this as-is. If the gate status above is
71
+ FAILING, the verdict is `CHANGES_REQUESTED` regardless of code quality.
72
+
73
+ Approving weak work is a worse failure than requesting one more round. So is
74
+ inventing a finding to look thorough — if the work is genuinely ready, approve it.
@@ -0,0 +1,281 @@
1
+ /**
2
+ * Engine adapters.
3
+ *
4
+ * Every adapter turns the same request — a prompt, a working directory, and a
5
+ * set of extra writable directories — into one CLI invocation. That is the
6
+ * entire portability story: phases, prompts, and the verdict contract are
7
+ * shared, and only this file knows which vendor is running.
8
+ *
9
+ * Adapters deliberately do not parse stdout for *results*. Steps communicate by
10
+ * writing files the runner reads, because stdout formats are per-vendor and
11
+ * change between releases while a JSON file on disk does not.
12
+ *
13
+ * Rendering progress is the one exception, and it stays here for the same
14
+ * reason: only this file knows a vendor's stdout format. An adapter may expose
15
+ * `createRenderer()` to turn its stdout into human-readable lines; the runner
16
+ * treats the result as opaque text.
17
+ */
18
+
19
+ /** Fields worth showing for a tool call, most specific first. */
20
+ const toolSummaryFields = ['command', 'file_path', 'pattern', 'path', 'url', 'description', 'prompt'];
21
+
22
+ function condense(text, limit = 120) {
23
+ const flat = text.replace(/\s+/g, ' ').trim();
24
+ return flat.length > limit ? `${flat.slice(0, limit - 1)}\u2026` : flat;
25
+ }
26
+
27
+ function summarizeToolInput(input) {
28
+ if (!input || typeof input !== 'object') return '';
29
+ for (const field of toolSummaryFields) {
30
+ if (typeof input[field] === 'string' && input[field].trim()) return condense(input[field]);
31
+ }
32
+ return '';
33
+ }
34
+
35
+ /**
36
+ * Summarize an agy tool call's parameters.
37
+ *
38
+ * agy names its parameters differently from Claude (`CommandLine`, `AbsolutePath`,
39
+ * …), so try the most descriptive keys first and fall back to the first string
40
+ * value — robust to tools this list has never seen.
41
+ */
42
+ function summarizeAgyParams(params) {
43
+ if (!params || typeof params !== 'object') return '';
44
+ const preferred = ['CommandLine', 'Command', 'AbsolutePath', 'FilePath', 'Path', 'Query', 'Url', 'Pattern'];
45
+ for (const key of preferred) {
46
+ if (typeof params[key] === 'string' && params[key].trim()) return condense(params[key]);
47
+ }
48
+ for (const value of Object.values(params)) {
49
+ if (typeof value === 'string' && value.trim()) return condense(value);
50
+ }
51
+ return '';
52
+ }
53
+
54
+ function renderClaudeBlock(block) {
55
+ if (block.type === 'text') return block.text?.trim() ? `${block.text.trimEnd()}\n` : '';
56
+ if (block.type === 'thinking') return ' \u00b7 thinking\n';
57
+ if (block.type === 'tool_use') {
58
+ const detail = summarizeToolInput(block.input);
59
+ return ` \u2192 ${block.name}${detail ? ` ${detail}` : ''}\n`;
60
+ }
61
+ return '';
62
+ }
63
+
64
+ /**
65
+ * Turn one Claude stream-json event into the line a watching human wants.
66
+ *
67
+ * Unrecognized events render as nothing rather than raw JSON: the vendor adds
68
+ * event types between releases, and a log that dumps every one of them is as
69
+ * unreadable as no log at all.
70
+ */
71
+ export function renderClaudeEvent(event) {
72
+ if (event.type === 'assistant') return (event.message?.content ?? []).map(renderClaudeBlock).join('');
73
+ if (event.type === 'user') {
74
+ return (event.message?.content ?? [])
75
+ .filter((block) => block.type === 'tool_result' && block.is_error)
76
+ .map((block) => ` \u2717 ${condense(String(block.content ?? 'tool call failed'), 160)}\n`)
77
+ .join('');
78
+ }
79
+ if (event.type === 'system' && event.subtype === 'init') {
80
+ return ` \u00b7 session ${String(event.session_id ?? '').slice(0, 8)}${event.model ? ` model ${event.model}` : ''}\n`;
81
+ }
82
+ if (event.type === 'system' && event.subtype === 'permission_denied') {
83
+ return ` \u26a0 permission denied: ${event.tool_name ?? 'tool'}\n`;
84
+ }
85
+ if (event.type === 'result') {
86
+ const turns = event.num_turns ? ` ${event.num_turns} turns` : '';
87
+ const seconds = event.duration_ms ? ` ${Math.round(event.duration_ms / 1000)}s` : '';
88
+ return ` ${event.is_error ? '\u2717 failed' : '\u2713 done'}${turns}${seconds}\n`;
89
+ }
90
+ return '';
91
+ }
92
+
93
+ /**
94
+ * Turn one agy stream-json event into the line a watching human wants.
95
+ *
96
+ * agy's events are `init` (session), `step_update` (per-step progress: user
97
+ * input, agent text deltas, tool calls), and `result` (final status). As with
98
+ * the Claude renderer, an unrecognized shape renders as nothing rather than raw
99
+ * JSON — notably the enormous `init` tool list is collapsed to one line.
100
+ */
101
+ export function renderAgyEvent(event) {
102
+ if (event.event === 'init') {
103
+ const id = String(event.conversation_id ?? '').slice(0, 8);
104
+ const model = event.init?.model;
105
+ return ` · session ${id}${model ? ` model ${model}` : ''}\n`;
106
+ }
107
+ if (event.event === 'step_update') {
108
+ const step = event.step_update ?? {};
109
+ // Assistant text arrives as deltas; stream them through verbatim.
110
+ if (step.step_type === 'agent_response' && typeof step.text_delta === 'string') return step.text_delta;
111
+ if (step.step_type === 'tool') {
112
+ if (step.state === 'ACTIVE') {
113
+ const detail = summarizeAgyParams(step.tool_info?.parameters);
114
+ return ` → ${step.tool_name ?? 'tool'}${detail ? ` ${detail}` : ''}\n`;
115
+ }
116
+ if (step.state === 'DONE' && step.tool_info?.error) {
117
+ return ` ✗ ${condense(String(step.tool_info.error), 160)}\n`;
118
+ }
119
+ }
120
+ return '';
121
+ }
122
+ if (event.event === 'result') {
123
+ const result = event.result ?? {};
124
+ const turns = result.num_turns ? ` ${result.num_turns} turn${result.num_turns === 1 ? '' : 's'}` : '';
125
+ const seconds = result.duration_seconds ? ` ${Math.round(result.duration_seconds)}s` : '';
126
+ if (result.status && result.status !== 'SUCCESS') {
127
+ const why = result.error ? `: ${condense(String(result.error), 160)}` : '';
128
+ return ` ✗ ${String(result.status).toLowerCase()}${why}${turns}${seconds}\n`;
129
+ }
130
+ return ` ✓ done${turns}${seconds}\n`;
131
+ }
132
+ return '';
133
+ }
134
+
135
+ /**
136
+ * Line-buffer a JSONL stream and render each complete event.
137
+ *
138
+ * Chunks split mid-line, so the tail is held until its newline arrives. A line
139
+ * that is not JSON passes through untouched — that is how a vendor warning
140
+ * printed alongside the stream still reaches the log.
141
+ */
142
+ export function createJsonlRenderer(renderEvent) {
143
+ let pending = '';
144
+ const renderLine = (line) => {
145
+ if (!line.trim()) return '';
146
+ try {
147
+ return renderEvent(JSON.parse(line));
148
+ } catch {
149
+ return `${line}\n`;
150
+ }
151
+ };
152
+ return {
153
+ write(text) {
154
+ pending += text;
155
+ const lines = pending.split('\n');
156
+ pending = lines.pop() ?? '';
157
+ return lines.map(renderLine).join('');
158
+ },
159
+ end() {
160
+ const rest = pending;
161
+ pending = '';
162
+ return renderLine(rest);
163
+ },
164
+ };
165
+ }
166
+
167
+ /** A renderer for engines whose stdout is already meant to be read. */
168
+ export function passthroughRenderer() {
169
+ return { write: (text) => text, end: () => '' };
170
+ }
171
+
172
+ const claudeAdapter = {
173
+ name: 'claude',
174
+ efforts: ['low', 'medium', 'high', 'xhigh', 'max'],
175
+ command({ prompt, addDirs, agent = {} }) {
176
+ // `auto`, not `acceptEdits`: under acceptEdits a non-interactive `-p` run
177
+ // auto-denies every Bash call, because there is no one to answer the
178
+ // prompt it raises. Phases that have to build, test, and commit then spin
179
+ // until the timeout kills them.
180
+ // stream-json, not text: `--output-format text` withholds every byte until
181
+ // the process exits, so a phase that runs for half an hour looks identical
182
+ // to one that has hung. stream-json emits an event per step, which
183
+ // `createRenderer` turns back into readable lines.
184
+ const args = ['-p', prompt, '--permission-mode', 'auto', '--output-format', 'stream-json', '--verbose'];
185
+ if (agent.model) args.push('--model', agent.model);
186
+ if (agent.effort) args.push('--effort', agent.effort);
187
+ for (const dir of addDirs) args.push('--add-dir', dir);
188
+ return { command: 'claude', args };
189
+ },
190
+ createRenderer: () => createJsonlRenderer(renderClaudeEvent),
191
+ };
192
+
193
+ const codexAdapter = {
194
+ name: 'codex',
195
+ efforts: ['low', 'medium', 'high', 'xhigh', 'max'],
196
+ command({ prompt, cwd, addDirs, agent = {} }) {
197
+ const args = ['exec', prompt, '--sandbox', 'workspace-write', '--cd', cwd];
198
+ if (agent.model) args.push('--model', agent.model);
199
+ if (agent.effort) args.push('-c', `model_reasoning_effort=${JSON.stringify(agent.effort)}`);
200
+ for (const dir of addDirs) args.push('--add-dir', dir);
201
+ return { command: 'codex', args };
202
+ },
203
+ };
204
+
205
+ const agyAdapter = {
206
+ name: 'agy',
207
+ efforts: ['low', 'medium', 'high'],
208
+ command({ prompt, addDirs, timeoutMs, agent = {} }) {
209
+ // `--dangerously-skip-permissions`, not bare `accept-edits`: in headless
210
+ // `--print` mode agy cannot prompt for the `command` permission its Bash-
211
+ // style tools need, so it auto-denies the first one and exits 0 having done
212
+ // nothing — the phase then "completes" instantly with an empty log. This is
213
+ // agy's equivalent of the claude adapter's `--permission-mode auto` above;
214
+ // agy has no `auto` mode (`--mode` is accept-edits|plan only).
215
+ //
216
+ // stream-json, not the default `text`: agy's text output withholds every
217
+ // byte until the process exits, so a multi-minute phase is indistinguishable
218
+ // from a hang (the run log shows only the header). stream-json emits an
219
+ // event per step, which `createRenderer` turns back into readable lines —
220
+ // same reasoning as the claude adapter above.
221
+ const args = [
222
+ '--print', prompt,
223
+ '--mode', 'accept-edits',
224
+ '--dangerously-skip-permissions',
225
+ '--output-format', 'stream-json',
226
+ ];
227
+ if (agent.model) args.push('--model', agent.model);
228
+ if (agent.effort) args.push('--effort', agent.effort);
229
+ if (timeoutMs) args.push('--print-timeout', `${Math.ceil(timeoutMs / 1000)}s`);
230
+ for (const dir of addDirs) args.push('--add-dir', dir);
231
+ return { command: 'agy', args };
232
+ },
233
+ createRenderer: () => createJsonlRenderer(renderAgyEvent),
234
+ };
235
+
236
+ const adapters = { claude: claudeAdapter, codex: codexAdapter, agy: agyAdapter };
237
+
238
+ function validateConfiguredAdapter(name, adapter) {
239
+ if (!adapter || typeof adapter !== 'object' || Array.isArray(adapter) || typeof adapter.command !== 'function') {
240
+ throw new Error(`Invalid adapter "${name}": expected an object with a command function.`);
241
+ }
242
+ if (adapter.efforts !== undefined && (!Array.isArray(adapter.efforts) || adapter.efforts.some((effort) => typeof effort !== 'string'))) {
243
+ throw new Error(`Invalid adapter "${name}": efforts must be an array of strings.`);
244
+ }
245
+ if (adapter.createRenderer !== undefined && typeof adapter.createRenderer !== 'function') {
246
+ throw new Error(`Invalid adapter "${name}": createRenderer must be a function.`);
247
+ }
248
+ return adapter;
249
+ }
250
+
251
+ export function adapterFor(engine, customAdapters = {}) {
252
+ const configuredNames = customAdapters && typeof customAdapters === 'object' ? Object.keys(customAdapters) : [];
253
+ const hasConfiguredAdapter = customAdapters && typeof customAdapters === 'object'
254
+ && Object.hasOwn(customAdapters, engine);
255
+ const adapter = hasConfiguredAdapter ? validateConfiguredAdapter(engine, customAdapters[engine]) : adapters[engine];
256
+ if (!adapter) {
257
+ const available = [...new Set([...Object.keys(adapters), ...configuredNames])];
258
+ throw new Error(`Unsupported engine "${engine}"; expected one of ${available.join(', ')}.`);
259
+ }
260
+ return adapter;
261
+ }
262
+
263
+ export function validateAgent(agent, customAdapters = {}) {
264
+ const adapter = adapterFor(agent.name, customAdapters);
265
+ if (agent.effort && adapter.efforts && !adapter.efforts.includes(agent.effort)) {
266
+ throw new Error(`Engine "${agent.name}" does not support effort "${agent.effort}"; expected one of ${adapter.efforts.join(', ')}.`);
267
+ }
268
+ return agent;
269
+ }
270
+
271
+ /** Resolve an agent descriptor: per-phase override, else the default. */
272
+ export function agentForPhase(config, phaseName) {
273
+ return config.engines[phaseName] ?? config.engines.default;
274
+ }
275
+
276
+ /** Resolve which executable runs a phase. Retained for string-based callers. */
277
+ export function engineForPhase(config, phaseName) {
278
+ return agentForPhase(config, phaseName).name;
279
+ }
280
+
281
+ export { adapters };
@@ -0,0 +1,65 @@
1
+ import { spawn } from 'node:child_process';
2
+
3
+ /**
4
+ * Run a command, capturing its output while optionally streaming it onward.
5
+ *
6
+ * Agent phases run for minutes at a time, so unlike a buffered helper this one
7
+ * forwards each chunk to `onOutput` as it arrives — a run you cannot watch is a
8
+ * run you cannot debug. `timeoutMs` exists because a silently hung agent is the
9
+ * most common unattended failure and it burns tokens for as long as it hangs.
10
+ */
11
+ export async function runCommand(command, args = [], options = {}) {
12
+ const { cwd, env = process.env, timeoutMs, onOutput, input } = options;
13
+ return new Promise((resolve, reject) => {
14
+ const child = spawn(command, args, { cwd, env });
15
+ let stdout = '';
16
+ let stderr = '';
17
+ let timedOut = false;
18
+
19
+ const timer = timeoutMs
20
+ ? setTimeout(() => {
21
+ timedOut = true;
22
+ child.kill('SIGKILL');
23
+ }, timeoutMs)
24
+ : null;
25
+
26
+ const collect = (stream, name) => {
27
+ stream.on('data', (chunk) => {
28
+ const text = String(chunk);
29
+ if (name === 'stdout') stdout += text;
30
+ else stderr += text;
31
+ onOutput?.(text, name);
32
+ });
33
+ };
34
+ collect(child.stdout, 'stdout');
35
+ collect(child.stderr, 'stderr');
36
+
37
+ const decorate = (error) => {
38
+ if (timer) clearTimeout(timer);
39
+ error.command = [command, ...args].join(' ');
40
+ error.stdout = stdout;
41
+ error.stderr = stderr;
42
+ error.output = [stdout, stderr].filter(Boolean).join('\n');
43
+ error.timedOut = timedOut;
44
+ return error;
45
+ };
46
+
47
+ child.on('error', (error) => reject(decorate(error)));
48
+ child.on('close', (code, signal) => {
49
+ if (timer) clearTimeout(timer);
50
+ if (timedOut) {
51
+ const error = decorate(new Error(`Command timed out after ${timeoutMs}ms: ${command} ${args.join(' ')}`));
52
+ error.code = 'ETIMEDOUT';
53
+ return reject(error);
54
+ }
55
+ if (code === 0) return resolve({ stdout, stderr, code });
56
+ const error = decorate(new Error(`Command failed: ${command} ${args.join(' ')}${stderr ? `\n${stderr}` : ''}`));
57
+ error.code = code;
58
+ error.signal = signal;
59
+ return reject(error);
60
+ });
61
+
62
+ if (input !== undefined) child.stdin.end(input);
63
+ else child.stdin.end();
64
+ });
65
+ }