@nonameolsson/nightjar 0.0.0-stage → 0.2.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.
- package/LICENSE +21 -0
- package/README.md +405 -2
- package/dist/account.d.ts +12 -0
- package/dist/account.js +57 -0
- package/dist/account.js.map +1 -0
- package/dist/adapter-module.d.ts +18 -0
- package/dist/adapter-module.js +83 -0
- package/dist/adapter-module.js.map +1 -0
- package/dist/adapters.d.ts +15 -0
- package/dist/adapters.js +160 -0
- package/dist/adapters.js.map +1 -0
- package/dist/args.d.ts +13 -0
- package/dist/args.js +84 -0
- package/dist/args.js.map +1 -0
- package/dist/ask.d.ts +6 -0
- package/dist/ask.js +27 -0
- package/dist/ask.js.map +1 -0
- package/dist/azure-devops/api.d.ts +34 -0
- package/dist/azure-devops/api.js +98 -0
- package/dist/azure-devops/api.js.map +1 -0
- package/dist/azure-devops/code-host.d.ts +36 -0
- package/dist/azure-devops/code-host.js +185 -0
- package/dist/azure-devops/code-host.js.map +1 -0
- package/dist/azure-devops/fake-azure-devops.d.ts +90 -0
- package/dist/azure-devops/fake-azure-devops.js +728 -0
- package/dist/azure-devops/fake-azure-devops.js.map +1 -0
- package/dist/azure-devops/index.d.ts +8 -0
- package/dist/azure-devops/index.js +11 -0
- package/dist/azure-devops/index.js.map +1 -0
- package/dist/azure-devops/sign-in.d.ts +11 -0
- package/dist/azure-devops/sign-in.js +68 -0
- package/dist/azure-devops/sign-in.js.map +1 -0
- package/dist/azure-devops/text.d.ts +1 -0
- package/dist/azure-devops/text.js +41 -0
- package/dist/azure-devops/text.js.map +1 -0
- package/dist/azure-devops/tracker.d.ts +44 -0
- package/dist/azure-devops/tracker.js +333 -0
- package/dist/azure-devops/tracker.js.map +1 -0
- package/dist/build.d.ts +75 -0
- package/dist/build.js +698 -0
- package/dist/build.js.map +1 -0
- package/dist/check.d.ts +8 -0
- package/dist/check.js +25 -0
- package/dist/check.js.map +1 -0
- package/dist/checks.d.ts +45 -0
- package/dist/checks.js +243 -0
- package/dist/checks.js.map +1 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +11 -0
- package/dist/cli.js.map +1 -0
- package/dist/code-host.d.ts +19 -0
- package/dist/code-host.js +2 -0
- package/dist/code-host.js.map +1 -0
- package/dist/config.d.ts +86 -0
- package/dist/config.js +294 -0
- package/dist/config.js.map +1 -0
- package/dist/contract/case.d.ts +4 -0
- package/dist/contract/case.js +2 -0
- package/dist/contract/case.js.map +1 -0
- package/dist/contract/code-host.d.ts +24 -0
- package/dist/contract/code-host.js +108 -0
- package/dist/contract/code-host.js.map +1 -0
- package/dist/contract/command.d.ts +2 -0
- package/dist/contract/command.js +36 -0
- package/dist/contract/command.js.map +1 -0
- package/dist/contract/feature-writer.d.ts +3 -0
- package/dist/contract/feature-writer.js +101 -0
- package/dist/contract/feature-writer.js.map +1 -0
- package/dist/contract/run.d.ts +18 -0
- package/dist/contract/run.js +89 -0
- package/dist/contract/run.js.map +1 -0
- package/dist/contract/tracker.d.ts +30 -0
- package/dist/contract/tracker.js +250 -0
- package/dist/contract/tracker.js.map +1 -0
- package/dist/create.d.ts +9 -0
- package/dist/create.js +112 -0
- package/dist/create.js.map +1 -0
- package/dist/doctor.d.ts +4 -0
- package/dist/doctor.js +42 -0
- package/dist/doctor.js.map +1 -0
- package/dist/fakes/code-host.d.ts +21 -0
- package/dist/fakes/code-host.js +42 -0
- package/dist/fakes/code-host.js.map +1 -0
- package/dist/fakes/harness.d.ts +7 -0
- package/dist/fakes/harness.js +68 -0
- package/dist/fakes/harness.js.map +1 -0
- package/dist/fakes/machine.d.ts +11 -0
- package/dist/fakes/machine.js +101 -0
- package/dist/fakes/machine.js.map +1 -0
- package/dist/fakes/tracker.d.ts +40 -0
- package/dist/fakes/tracker.js +116 -0
- package/dist/fakes/tracker.js.map +1 -0
- package/dist/feature-file.d.ts +12 -0
- package/dist/feature-file.js +84 -0
- package/dist/feature-file.js.map +1 -0
- package/dist/git.d.ts +9 -0
- package/dist/git.js +12 -0
- package/dist/git.js.map +1 -0
- package/dist/github/api.d.ts +37 -0
- package/dist/github/api.js +66 -0
- package/dist/github/api.js.map +1 -0
- package/dist/github/code-host.d.ts +19 -0
- package/dist/github/code-host.js +105 -0
- package/dist/github/code-host.js.map +1 -0
- package/dist/github/fake-gh.d.ts +17 -0
- package/dist/github/fake-gh.js +51 -0
- package/dist/github/fake-gh.js.map +1 -0
- package/dist/github/fake-github.d.ts +58 -0
- package/dist/github/fake-github.js +285 -0
- package/dist/github/fake-github.js.map +1 -0
- package/dist/github/labels.d.ts +3 -0
- package/dist/github/labels.js +44 -0
- package/dist/github/labels.js.map +1 -0
- package/dist/github/tracker.d.ts +28 -0
- package/dist/github/tracker.js +208 -0
- package/dist/github/tracker.js.map +1 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +13 -0
- package/dist/index.js.map +1 -0
- package/dist/init.d.ts +19 -0
- package/dist/init.js +225 -0
- package/dist/init.js.map +1 -0
- package/dist/ledger.d.ts +13 -0
- package/dist/ledger.js +49 -0
- package/dist/ledger.js.map +1 -0
- package/dist/main.d.ts +11 -0
- package/dist/main.js +215 -0
- package/dist/main.js.map +1 -0
- package/dist/person-config.d.ts +9 -0
- package/dist/person-config.js +46 -0
- package/dist/person-config.js.map +1 -0
- package/dist/plan.d.ts +9 -0
- package/dist/plan.js +27 -0
- package/dist/plan.js.map +1 -0
- package/dist/ports.d.ts +16 -0
- package/dist/ports.js +104 -0
- package/dist/ports.js.map +1 -0
- package/dist/prompt.d.ts +166 -0
- package/dist/prompt.js +217 -0
- package/dist/prompt.js.map +1 -0
- package/dist/queue.d.ts +20 -0
- package/dist/queue.js +94 -0
- package/dist/queue.js.map +1 -0
- package/dist/say.d.ts +2 -0
- package/dist/say.js +4 -0
- package/dist/say.js.map +1 -0
- package/dist/serve.d.ts +10 -0
- package/dist/serve.js +85 -0
- package/dist/serve.js.map +1 -0
- package/dist/session.d.ts +39 -0
- package/dist/session.js +144 -0
- package/dist/session.js.map +1 -0
- package/dist/settings.d.ts +10 -0
- package/dist/settings.js +92 -0
- package/dist/settings.js.map +1 -0
- package/dist/signals.d.ts +5 -0
- package/dist/signals.js +13 -0
- package/dist/signals.js.map +1 -0
- package/dist/skills.d.ts +4 -0
- package/dist/skills.js +51 -0
- package/dist/skills.js.map +1 -0
- package/dist/status-shape.d.ts +53 -0
- package/dist/status-shape.js +3 -0
- package/dist/status-shape.js.map +1 -0
- package/dist/status.d.ts +23 -0
- package/dist/status.js +56 -0
- package/dist/status.js.map +1 -0
- package/dist/tracker.d.ts +56 -0
- package/dist/tracker.js +12 -0
- package/dist/tracker.js.map +1 -0
- package/dist/workflow.d.ts +33 -0
- package/dist/workflow.js +53 -0
- package/dist/workflow.js.map +1 -0
- package/dist/worktree.d.ts +44 -0
- package/dist/worktree.js +193 -0
- package/dist/worktree.js.map +1 -0
- package/docs/ticket-contract.md +95 -0
- package/package.json +74 -5
- package/plugin/nightjar-status/.claude-plugin/plugin.json +5 -0
- package/plugin/nightjar-status/hooks/format.ts +48 -0
- package/plugin/nightjar-status/hooks/hooks.json +1 -0
- package/plugin/nightjar-status/hooks/register.ts +51 -0
- package/schema/factory.schema.json +437 -0
- package/skills/nightjar-feature/SKILL.md +73 -0
- package/src/status-shape.ts +44 -0
package/dist/build.js
ADDED
|
@@ -0,0 +1,698 @@
|
|
|
1
|
+
// The build loop: one Feature from the Go to a draft pull request, through the Tracker and Code host seams alone.
|
|
2
|
+
import { join, relative } from 'node:path';
|
|
3
|
+
import { record, spentOn } from './ledger.js';
|
|
4
|
+
import { DEFAULT_DAILY_USD } from './person-config.js';
|
|
5
|
+
import { nextTicket, waiting } from './plan.js';
|
|
6
|
+
import { BUILD_SCHEMA, REVIEW_SCHEMA, STEP_SCHEMA, buildPrompt, fixPrompt, questionRounds, questionsComment, readBuildAnswer, readReview, readStepAnswer, reviewPrompt, stepPrompt, stepRoundPrompt } from './prompt.js';
|
|
7
|
+
import { BUILD_TOOLS, REVIEW_TOOLS, buildSettings, reviewSettings, stepTools } from './settings.js';
|
|
8
|
+
import { branchFor, openWorktree } from './worktree.js';
|
|
9
|
+
import { DEFAULT_ROUNDS } from './config.js';
|
|
10
|
+
import { chooseWorkflow, stepKey, workflowSection } from './workflow.js';
|
|
11
|
+
export const LIMITS = {
|
|
12
|
+
ticketUsd: 15,
|
|
13
|
+
ticketMinutes: 60,
|
|
14
|
+
fixRounds: 3,
|
|
15
|
+
reviewUsd: 3,
|
|
16
|
+
reviewMinutes: 10,
|
|
17
|
+
questionRounds: 2,
|
|
18
|
+
dailyUsd: DEFAULT_DAILY_USD
|
|
19
|
+
};
|
|
20
|
+
// What every session and command of the run sees: the machine's environment and the reserved ports.
|
|
21
|
+
const envOf = (setup) => ({ ...setup.env, ...setup.portEnv });
|
|
22
|
+
export const ledgerPath = (root) => join(root, '.factory', 'runs.jsonl');
|
|
23
|
+
const logsPath = (root) => join(root, '.factory', 'logs');
|
|
24
|
+
export const FOOTER = '\n\n---\n_Built by Nightjar._';
|
|
25
|
+
// A Ticket's own `**Model:**` and `**Effort:**` lines.
|
|
26
|
+
export function ticketLine(body, name) {
|
|
27
|
+
const match = new RegExp(`^(?:\\*\\*)?${name}:(?:\\*\\*)?\\s*(\\S+)`, 'im').exec(body);
|
|
28
|
+
return match?.[1]?.toLowerCase();
|
|
29
|
+
}
|
|
30
|
+
const dollars = (n) => `$${n.toFixed(2)}`;
|
|
31
|
+
const hash = (id) => `#${id}`;
|
|
32
|
+
const NEXT = { kind: 'next' };
|
|
33
|
+
export async function buildFeature(deps, setup, id) {
|
|
34
|
+
const { tracker } = deps;
|
|
35
|
+
const feature = await tracker.feature(id);
|
|
36
|
+
const branch = branchFor(feature.id, feature.title);
|
|
37
|
+
deps.say(`${hash(id)} ${feature.title}: building on ${branch}`);
|
|
38
|
+
await tracker.addSignal(id, 'working');
|
|
39
|
+
await tracker.removeSignal(id, 'go');
|
|
40
|
+
deps.status.doing({
|
|
41
|
+
kind: 'building',
|
|
42
|
+
feature: id,
|
|
43
|
+
ticket: null,
|
|
44
|
+
step: 'setup',
|
|
45
|
+
round: null,
|
|
46
|
+
rounds: null,
|
|
47
|
+
session: null,
|
|
48
|
+
startedAt: deps.now().toISOString()
|
|
49
|
+
});
|
|
50
|
+
const tickets = await tracker.tickets(id);
|
|
51
|
+
const chosen = chooseWorkflow(setup, feature.body);
|
|
52
|
+
if (chosen.kind === 'refused') {
|
|
53
|
+
const why = `its Workflow cannot be used: ${chosen.why}`;
|
|
54
|
+
return needsInfoStop(deps, id, why, `Nightjar built nothing: ${chosen.why}`, waiting(tickets, new Set()));
|
|
55
|
+
}
|
|
56
|
+
const tree = await openWorktree(setup.root, setup.worktrees, branch, setup.baseBranch, setup.env);
|
|
57
|
+
const built = await tree.built();
|
|
58
|
+
if (setup.setupCommand !== undefined && !(await tree.isSetUp())) {
|
|
59
|
+
const command = setup.setupCommand;
|
|
60
|
+
deps.say(`${hash(id)}: setup: ${command}`);
|
|
61
|
+
const got = await deps.runSetup(command, tree.at.folder, envOf(setup));
|
|
62
|
+
if (!got.ok) {
|
|
63
|
+
const why = `the setup command \`${command}\` failed`;
|
|
64
|
+
return needsInfoStop(deps, id, why, `Nightjar built nothing: ${why} in the worktree.\n\n\`\`\`\n${got.tail}\n\`\`\``, waiting(tickets, built));
|
|
65
|
+
}
|
|
66
|
+
await tree.markSetUp();
|
|
67
|
+
}
|
|
68
|
+
const workflow = chosen.kind === 'workflow' ? chosen : null;
|
|
69
|
+
const run = new FeatureRun(deps, setup, feature, tree, tickets, built, workflow);
|
|
70
|
+
return run.build();
|
|
71
|
+
}
|
|
72
|
+
// A Feature stopped before it built anything, with the reason on it; a later Go builds it again.
|
|
73
|
+
async function needsInfoStop(deps, id, why, comment, left) {
|
|
74
|
+
deps.status.happened({ kind: 'needs-info', feature: id, ticket: null, why });
|
|
75
|
+
await deps.tracker.removeSignal(id, 'working');
|
|
76
|
+
await deps.tracker.addSignal(id, 'needsInfo');
|
|
77
|
+
await deps.tracker.comment(id, `${comment}${FOOTER}`);
|
|
78
|
+
return { kind: 'nothing', waiting: left };
|
|
79
|
+
}
|
|
80
|
+
// The most of a Feature's diff a Step's prompt carries; the rest is in git.
|
|
81
|
+
const DIFF_CHARS = 60_000;
|
|
82
|
+
class FeatureRun {
|
|
83
|
+
deps;
|
|
84
|
+
setup;
|
|
85
|
+
feature;
|
|
86
|
+
tree;
|
|
87
|
+
tickets;
|
|
88
|
+
built;
|
|
89
|
+
workflow;
|
|
90
|
+
spent = 0;
|
|
91
|
+
featureComments = [];
|
|
92
|
+
// the Steps the branch holds already, by their trailers
|
|
93
|
+
stepsDone = new Set();
|
|
94
|
+
reports = [];
|
|
95
|
+
constructor(deps, setup, feature, tree, tickets, built, workflow) {
|
|
96
|
+
this.deps = deps;
|
|
97
|
+
this.setup = setup;
|
|
98
|
+
this.feature = feature;
|
|
99
|
+
this.tree = tree;
|
|
100
|
+
this.tickets = tickets;
|
|
101
|
+
this.built = built;
|
|
102
|
+
this.workflow = workflow;
|
|
103
|
+
}
|
|
104
|
+
get aborted() {
|
|
105
|
+
return this.setup.signal?.aborted === true;
|
|
106
|
+
}
|
|
107
|
+
// `aborted` again after an await, which TypeScript does not know may have changed
|
|
108
|
+
stopped() {
|
|
109
|
+
return this.aborted;
|
|
110
|
+
}
|
|
111
|
+
async build() {
|
|
112
|
+
this.featureComments = await this.trusted(this.feature.id);
|
|
113
|
+
if (this.workflow !== null)
|
|
114
|
+
this.stepsDone = await this.tree.stepsDone();
|
|
115
|
+
let last = NEXT;
|
|
116
|
+
while (last.kind === 'next' && !this.aborted) {
|
|
117
|
+
const paused = this.dailyPause();
|
|
118
|
+
if (paused !== null) {
|
|
119
|
+
last = { kind: 'paused', why: paused };
|
|
120
|
+
break;
|
|
121
|
+
}
|
|
122
|
+
const ticket = nextTicket(this.tickets, this.built);
|
|
123
|
+
if (ticket === null)
|
|
124
|
+
break;
|
|
125
|
+
last = await this.buildTicket(ticket);
|
|
126
|
+
}
|
|
127
|
+
// the Steps of "all Tickets" run on a Feature whose Tickets are all built
|
|
128
|
+
const whole = this.built.size > 0 && waiting(this.tickets, this.built).length === 0;
|
|
129
|
+
if (last.kind === 'next' && !this.aborted && whole) {
|
|
130
|
+
last = await this.runSteps('afterAllTickets', null);
|
|
131
|
+
}
|
|
132
|
+
return this.finish(last);
|
|
133
|
+
}
|
|
134
|
+
dailyPause() {
|
|
135
|
+
const today = spentOn(ledgerPath(this.setup.root), this.deps.now());
|
|
136
|
+
return today >= this.setup.limits.dailyUsd
|
|
137
|
+
? `today's estimated spend, ${dollars(today)}, reached the daily limit of ${dollars(this.setup.limits.dailyUsd)}`
|
|
138
|
+
: null;
|
|
139
|
+
}
|
|
140
|
+
async trusted(id) {
|
|
141
|
+
return (await this.deps.tracker.comments(id)).filter((c) => c.trusted);
|
|
142
|
+
}
|
|
143
|
+
log(entry) {
|
|
144
|
+
this.spent += entry.costUsd;
|
|
145
|
+
record(ledgerPath(this.setup.root), {
|
|
146
|
+
at: this.deps.now().toISOString(),
|
|
147
|
+
repo: this.setup.repo,
|
|
148
|
+
feature: this.feature.id,
|
|
149
|
+
branch: this.tree.at.branch,
|
|
150
|
+
...entry
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
request(over) {
|
|
154
|
+
const { setup } = this;
|
|
155
|
+
return {
|
|
156
|
+
cwd: this.tree.at.folder,
|
|
157
|
+
schema: BUILD_SCHEMA,
|
|
158
|
+
tools: BUILD_TOOLS,
|
|
159
|
+
settings: buildSettings(setup.allowedCommands),
|
|
160
|
+
budgetUsd: setup.limits.ticketUsd,
|
|
161
|
+
minutes: setup.limits.ticketMinutes,
|
|
162
|
+
log: join(logsPath(setup.root), `${over.sessionId}.jsonl`),
|
|
163
|
+
allowApiKey: setup.allowApiKey,
|
|
164
|
+
env: envOf(setup),
|
|
165
|
+
signal: setup.signal,
|
|
166
|
+
...over
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
// Hands a Ticket to a person: no code kept on the branch, the reason on the Ticket.
|
|
170
|
+
async handBack(ticket, before, body, signal, why) {
|
|
171
|
+
const { tracker } = this.deps;
|
|
172
|
+
this.deps.status.happened({
|
|
173
|
+
kind: 'needs-info',
|
|
174
|
+
feature: this.feature.id,
|
|
175
|
+
ticket: ticket.id,
|
|
176
|
+
why
|
|
177
|
+
});
|
|
178
|
+
const side = await this.tree.discard(before, ticket.id);
|
|
179
|
+
const kept = side ? `\n\nThe attempt is kept on branch \`${side}\`.` : '';
|
|
180
|
+
await tracker.comment(ticket.id, `${body}${kept}${FOOTER}`);
|
|
181
|
+
await tracker.removeSignal(ticket.id, 'ready');
|
|
182
|
+
await tracker.addSignal(ticket.id, signal);
|
|
183
|
+
const i = this.tickets.indexOf(ticket);
|
|
184
|
+
this.tickets[i] = {
|
|
185
|
+
...ticket,
|
|
186
|
+
signals: [...ticket.signals.filter((s) => s !== 'ready'), signal]
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
async buildTicket(ticket) {
|
|
190
|
+
const before = await this.tree.head();
|
|
191
|
+
const sessionId = this.deps.newId();
|
|
192
|
+
const ticketComments = await this.trusted(ticket.id);
|
|
193
|
+
this.deps.say(`${hash(ticket.id)} ${ticket.title}: session ${sessionId}`);
|
|
194
|
+
this.building(ticket.id, 'build', sessionId, {
|
|
195
|
+
round: questionRounds(ticketComments) + 1,
|
|
196
|
+
rounds: this.setup.limits.questionRounds + 1
|
|
197
|
+
});
|
|
198
|
+
const prompt = buildPrompt({
|
|
199
|
+
feature: this.feature,
|
|
200
|
+
ticket,
|
|
201
|
+
featureComments: this.featureComments,
|
|
202
|
+
ticketComments,
|
|
203
|
+
built: this.tickets.filter((t) => this.built.has(t.id)),
|
|
204
|
+
allowed: this.setup.allowedCommands,
|
|
205
|
+
check: this.setup.check,
|
|
206
|
+
rules: this.setup.rules
|
|
207
|
+
});
|
|
208
|
+
const result = await this.deps.session(this.request({
|
|
209
|
+
prompt,
|
|
210
|
+
sessionId,
|
|
211
|
+
model: this.setup.model ?? ticketLine(ticket.body, 'Model') ?? this.setup.defaults?.model,
|
|
212
|
+
effort: this.setup.effort ?? ticketLine(ticket.body, 'Effort') ?? this.setup.defaults?.effort
|
|
213
|
+
}));
|
|
214
|
+
const cost = 'costUsd' in result ? result.costUsd : 0;
|
|
215
|
+
const entry = { ticket: ticket.id, step: 'build', session: sessionId, costUsd: cost };
|
|
216
|
+
if (this.aborted) {
|
|
217
|
+
this.log({ ...entry, outcome: 'stopped by a person' });
|
|
218
|
+
await this.tree.discard(before, ticket.id);
|
|
219
|
+
return { kind: 'aborted' };
|
|
220
|
+
}
|
|
221
|
+
if (result.kind === 'api-key' || result.kind === 'rate-limited') {
|
|
222
|
+
await this.tree.discard(before, ticket.id);
|
|
223
|
+
const why = result.kind === 'api-key' ? API_KEY_PAUSE : limitPause(result.resetsAt);
|
|
224
|
+
this.log({ ...entry, outcome: `paused: ${why}` });
|
|
225
|
+
return { kind: 'paused', why };
|
|
226
|
+
}
|
|
227
|
+
const answer = result.kind === 'done' ? readBuildAnswer(result.output) : null;
|
|
228
|
+
if (answer === null) {
|
|
229
|
+
const why = result.kind === 'stopped' ? result.why : 'it answered in a shape not known';
|
|
230
|
+
this.log({ ...entry, outcome: `failed: ${why}` });
|
|
231
|
+
await this.handBack(ticket, before, `Nightjar could not build this Ticket: ${why}.\n\nSession: \`claude --resume ${sessionId}\``, 'needsInfo', why);
|
|
232
|
+
return NEXT;
|
|
233
|
+
}
|
|
234
|
+
if (answer.outcome === 'questions') {
|
|
235
|
+
const round = questionRounds(ticketComments) + 1;
|
|
236
|
+
this.log({ ...entry, outcome: `questions, round ${String(round)}` });
|
|
237
|
+
await this.askAbout(ticket, before, answer.questions, round);
|
|
238
|
+
return NEXT;
|
|
239
|
+
}
|
|
240
|
+
this.log({ ...entry, outcome: 'built' });
|
|
241
|
+
return this.checkAndCommit(ticket, before, sessionId, answer.summary, cost);
|
|
242
|
+
}
|
|
243
|
+
async askAbout(ticket, before, questions, round) {
|
|
244
|
+
const limit = this.setup.limits.questionRounds;
|
|
245
|
+
if (round <= limit) {
|
|
246
|
+
const asked = `it asked ${String(questions.length)} question${questions.length === 1 ? '' : 's'}, round ${String(round)}`;
|
|
247
|
+
await this.handBack(ticket, before, questionsComment(questions, round), 'needsInfo', asked);
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
const asked = questions.map((q, i) => `${String(i + 1)}. ${q.question}`).join('\n');
|
|
251
|
+
await this.handBack(ticket, before, `Nightjar asked about this Ticket ${String(limit)} times and still has questions, so it needs a person:\n\n${asked}`, 'human', `still unclear after ${String(limit)} question rounds, so a person builds it`);
|
|
252
|
+
}
|
|
253
|
+
// The repo's check, the fix rounds while it fails, and the Ticket's one commit.
|
|
254
|
+
async checkAndCommit(ticket, before, sessionId, built, cost) {
|
|
255
|
+
const { limits, check: command } = this.setup;
|
|
256
|
+
const first = await this.deps.runCheck(command, this.tree.at.folder, envOf(this.setup));
|
|
257
|
+
const fixed = await this.fixUntilGreen({
|
|
258
|
+
ticket: ticket.id,
|
|
259
|
+
name: hash(ticket.id),
|
|
260
|
+
sessionId,
|
|
261
|
+
cap: limits.ticketUsd,
|
|
262
|
+
spent: cost,
|
|
263
|
+
check: first,
|
|
264
|
+
request: {},
|
|
265
|
+
finished: (output) => {
|
|
266
|
+
const answer = readBuildAnswer(output);
|
|
267
|
+
return answer?.outcome === 'built' ? answer.summary : null;
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
const { check } = fixed;
|
|
271
|
+
const summary = [built, ...fixed.summaries].join('\n\n');
|
|
272
|
+
if (this.aborted) {
|
|
273
|
+
await this.tree.discard(before, ticket.id);
|
|
274
|
+
return { kind: 'aborted' };
|
|
275
|
+
}
|
|
276
|
+
if (!(await this.tree.commitTicket(ticket.id, ticket.title, summary, sessionId))) {
|
|
277
|
+
await this.handBack(ticket, before, `Nightjar's session said it built this Ticket but changed no file.\n\nSession: \`claude --resume ${sessionId}\``, 'needsInfo', 'it said it built the Ticket but changed no file');
|
|
278
|
+
return NEXT;
|
|
279
|
+
}
|
|
280
|
+
this.built.add(ticket.id);
|
|
281
|
+
this.deps.status.happened({ kind: 'built', feature: this.feature.id, ticket: ticket.id });
|
|
282
|
+
// later Tickets would build on red, so the run stops here and a person looks
|
|
283
|
+
if (!check.ok)
|
|
284
|
+
return { kind: 'red', tail: check.tail };
|
|
285
|
+
return this.runSteps('afterEachTicket', ticket);
|
|
286
|
+
}
|
|
287
|
+
// Fix rounds while the check fails, each in the session that built the work; `cap` is the most the whole piece of work may cost.
|
|
288
|
+
async fixUntilGreen(o) {
|
|
289
|
+
const { limits, check: command } = this.setup;
|
|
290
|
+
const summaries = [];
|
|
291
|
+
let { check, spent } = o;
|
|
292
|
+
for (let round = 1; !check.ok && round <= limits.fixRounds; round += 1) {
|
|
293
|
+
const budget = o.cap - spent;
|
|
294
|
+
if (budget < 1)
|
|
295
|
+
break;
|
|
296
|
+
this.deps.say(`${o.name}: the check fails, fix round ${String(round)}`);
|
|
297
|
+
this.building(o.ticket, 'fix', o.sessionId, { round, rounds: limits.fixRounds });
|
|
298
|
+
const fix = await this.deps.session(this.request({
|
|
299
|
+
prompt: fixPrompt(round, limits.fixRounds, command, check.tail),
|
|
300
|
+
sessionId: o.sessionId,
|
|
301
|
+
resume: o.sessionId,
|
|
302
|
+
budgetUsd: budget,
|
|
303
|
+
...o.request
|
|
304
|
+
}));
|
|
305
|
+
const summary = fix.kind === 'done' ? o.finished(fix.output) : null;
|
|
306
|
+
const fixCost = 'costUsd' in fix ? fix.costUsd : 0;
|
|
307
|
+
spent += fixCost;
|
|
308
|
+
this.log({
|
|
309
|
+
ticket: o.ticket,
|
|
310
|
+
step: 'fix',
|
|
311
|
+
session: o.sessionId,
|
|
312
|
+
costUsd: fixCost,
|
|
313
|
+
outcome: `round ${String(round)}${summary === null ? ' gave up' : ''}`
|
|
314
|
+
});
|
|
315
|
+
if (summary === null || this.aborted)
|
|
316
|
+
break;
|
|
317
|
+
summaries.push(summary);
|
|
318
|
+
check = await this.deps.runCheck(command, this.tree.at.folder, envOf(this.setup));
|
|
319
|
+
}
|
|
320
|
+
return { check, spent, summaries };
|
|
321
|
+
}
|
|
322
|
+
building(ticket, step, session, rounds) {
|
|
323
|
+
this.deps.status.doing({
|
|
324
|
+
kind: 'building',
|
|
325
|
+
feature: this.feature.id,
|
|
326
|
+
ticket,
|
|
327
|
+
step,
|
|
328
|
+
...rounds,
|
|
329
|
+
session,
|
|
330
|
+
startedAt: this.deps.now().toISOString()
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
// The Workflow's Steps at one point, in order; a Step that fails is thrown away and the next still runs.
|
|
334
|
+
async runSteps(point, ticket) {
|
|
335
|
+
const { workflow } = this;
|
|
336
|
+
if (workflow === null)
|
|
337
|
+
return NEXT;
|
|
338
|
+
const todo = workflow.workflow.steps.filter((s) => s.point === point && !this.stepsDone.has(stepKey(workflow.name, s.name, ticket?.id ?? null)));
|
|
339
|
+
if (todo.length === 0)
|
|
340
|
+
return NEXT;
|
|
341
|
+
const served = await this.serveFor(workflow.workflow);
|
|
342
|
+
try {
|
|
343
|
+
for (const step of todo) {
|
|
344
|
+
if (this.aborted)
|
|
345
|
+
return { kind: 'aborted' };
|
|
346
|
+
const paused = this.dailyPause();
|
|
347
|
+
if (paused !== null)
|
|
348
|
+
return { kind: 'paused', why: paused };
|
|
349
|
+
const end = served.ok
|
|
350
|
+
? await this.runStep(workflow.name, workflow.workflow, step, ticket)
|
|
351
|
+
: { kind: 'undone', why: `its serve command failed: ${served.tail}` };
|
|
352
|
+
if (end.kind === 'paused' || end.kind === 'aborted')
|
|
353
|
+
return end;
|
|
354
|
+
if (end.kind === 'undone') {
|
|
355
|
+
this.reports.push({
|
|
356
|
+
step,
|
|
357
|
+
ticket: ticket?.id ?? null,
|
|
358
|
+
state: 'undone',
|
|
359
|
+
why: end.why,
|
|
360
|
+
left: []
|
|
361
|
+
});
|
|
362
|
+
this.deps.say(`${workflow.name}/${step.name}: not done, ${end.why}`);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
return NEXT;
|
|
366
|
+
}
|
|
367
|
+
finally {
|
|
368
|
+
if (served.ok)
|
|
369
|
+
await served.stop();
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
async serveFor(workflow) {
|
|
373
|
+
if (workflow.serve === undefined)
|
|
374
|
+
return { ok: true, stop: () => Promise.resolve() };
|
|
375
|
+
const first = Number(Object.values(this.setup.portEnv ?? {})[0]);
|
|
376
|
+
this.deps.say(`${hash(this.feature.id)}: serving ${workflow.serve}`);
|
|
377
|
+
return this.deps.serve(workflow.serve, this.tree.at.folder, envOf(this.setup), Number.isInteger(first) ? first : null);
|
|
378
|
+
}
|
|
379
|
+
// One Step: its rounds, the check and fix rounds after each, and its own commit.
|
|
380
|
+
async runStep(name, workflow, step, ticket) {
|
|
381
|
+
const { setup } = this;
|
|
382
|
+
const after = ticket?.id ?? null;
|
|
383
|
+
const key = stepKey(name, step.name, after);
|
|
384
|
+
const label = `${name}/${step.name}`;
|
|
385
|
+
const before = await this.tree.head();
|
|
386
|
+
const sessionId = this.deps.newId();
|
|
387
|
+
const rounds = step.until === undefined ? 1 : (step.maxRounds ?? DEFAULT_ROUNDS);
|
|
388
|
+
const cap = step.budgetUsd ?? setup.limits.ticketUsd;
|
|
389
|
+
const notes = await this.tree.notes();
|
|
390
|
+
const diff = step.point === 'afterAllTickets' ? await this.tree.diff(DIFF_CHARS) : undefined;
|
|
391
|
+
const extras = {
|
|
392
|
+
allowedCommands: workflow.allowedCommands,
|
|
393
|
+
allowedTools: workflow.allowedTools,
|
|
394
|
+
localNetwork: workflow.localNetwork
|
|
395
|
+
};
|
|
396
|
+
const over = {
|
|
397
|
+
schema: STEP_SCHEMA,
|
|
398
|
+
tools: stepTools(extras),
|
|
399
|
+
settings: buildSettings(setup.allowedCommands, extras),
|
|
400
|
+
model: setup.model ?? step.model ?? setup.defaults?.model,
|
|
401
|
+
effort: setup.effort ?? step.effort ?? setup.defaults?.effort
|
|
402
|
+
};
|
|
403
|
+
const first = stepPrompt({
|
|
404
|
+
workflow: name,
|
|
405
|
+
step,
|
|
406
|
+
rounds,
|
|
407
|
+
feature: this.feature,
|
|
408
|
+
...(ticket === null ? {} : { ticket }),
|
|
409
|
+
allowed: [...setup.allowedCommands, ...(workflow.allowedCommands ?? [])],
|
|
410
|
+
check: setup.check,
|
|
411
|
+
...(setup.rules === undefined ? {} : { rules: setup.rules }),
|
|
412
|
+
notes: relative(this.tree.at.folder, notes),
|
|
413
|
+
base: this.tree.at.base,
|
|
414
|
+
...(diff === undefined ? {} : { diff })
|
|
415
|
+
});
|
|
416
|
+
const giveUp = async (why) => {
|
|
417
|
+
await this.tree.revert(before);
|
|
418
|
+
return { kind: 'undone', why };
|
|
419
|
+
};
|
|
420
|
+
this.deps.say(`${label}: session ${sessionId}`);
|
|
421
|
+
const summaries = [];
|
|
422
|
+
let spent = 0;
|
|
423
|
+
let left = [];
|
|
424
|
+
let holds = step.until === undefined;
|
|
425
|
+
let ran = 0;
|
|
426
|
+
for (let round = 1; round <= rounds && !(round > 1 && holds); round += 1) {
|
|
427
|
+
const budget = cap - spent;
|
|
428
|
+
if (round > 1 && budget < 1)
|
|
429
|
+
break;
|
|
430
|
+
this.building(after, 'step', sessionId, { round, rounds });
|
|
431
|
+
const result = await this.deps.session(this.request({
|
|
432
|
+
...over,
|
|
433
|
+
prompt: round === 1 || step.until === undefined
|
|
434
|
+
? first
|
|
435
|
+
: stepRoundPrompt(round, rounds, step.until, left),
|
|
436
|
+
sessionId,
|
|
437
|
+
...(round > 1 ? { resume: sessionId } : {}),
|
|
438
|
+
budgetUsd: budget
|
|
439
|
+
}));
|
|
440
|
+
ran = round;
|
|
441
|
+
const cost = 'costUsd' in result ? result.costUsd : 0;
|
|
442
|
+
spent += cost;
|
|
443
|
+
const entry = { ticket: after, step: 'step', session: sessionId, costUsd: cost };
|
|
444
|
+
if (this.aborted) {
|
|
445
|
+
this.log({ ...entry, outcome: `${label}: stopped by a person` });
|
|
446
|
+
await this.tree.revert(before);
|
|
447
|
+
return { kind: 'aborted' };
|
|
448
|
+
}
|
|
449
|
+
if (result.kind === 'api-key' || result.kind === 'rate-limited') {
|
|
450
|
+
await this.tree.revert(before);
|
|
451
|
+
const why = result.kind === 'api-key' ? API_KEY_PAUSE : limitPause(result.resetsAt);
|
|
452
|
+
this.log({ ...entry, outcome: `${label}: paused: ${why}` });
|
|
453
|
+
return { kind: 'paused', why };
|
|
454
|
+
}
|
|
455
|
+
const answer = result.kind === 'done' ? readStepAnswer(result.output) : null;
|
|
456
|
+
if (answer === null) {
|
|
457
|
+
const why = result.kind === 'stopped' ? result.why : 'it answered in a shape not known';
|
|
458
|
+
this.log({ ...entry, outcome: `${label}: failed: ${why}` });
|
|
459
|
+
return giveUp(why);
|
|
460
|
+
}
|
|
461
|
+
if (answer.outcome === 'blocked') {
|
|
462
|
+
const why = `it reported itself blocked${answer.blocker ? `: ${answer.blocker}` : ''}`;
|
|
463
|
+
this.log({ ...entry, outcome: `${label}: blocked` });
|
|
464
|
+
return giveUp(why);
|
|
465
|
+
}
|
|
466
|
+
this.log({ ...entry, outcome: `${label}: round ${String(round)}` });
|
|
467
|
+
summaries.push(answer.summary);
|
|
468
|
+
const checked = await this.fixUntilGreen({
|
|
469
|
+
ticket: after,
|
|
470
|
+
name: label,
|
|
471
|
+
sessionId,
|
|
472
|
+
cap,
|
|
473
|
+
spent,
|
|
474
|
+
check: await this.deps.runCheck(setup.check, this.tree.at.folder, envOf(setup)),
|
|
475
|
+
request: over,
|
|
476
|
+
finished: (output) => {
|
|
477
|
+
const fixed = readStepAnswer(output);
|
|
478
|
+
return fixed?.outcome === 'done' ? fixed.summary : null;
|
|
479
|
+
}
|
|
480
|
+
});
|
|
481
|
+
spent = checked.spent;
|
|
482
|
+
summaries.push(...checked.summaries);
|
|
483
|
+
if (this.stopped()) {
|
|
484
|
+
await this.tree.revert(before);
|
|
485
|
+
return { kind: 'aborted' };
|
|
486
|
+
}
|
|
487
|
+
if (!checked.check.ok)
|
|
488
|
+
return giveUp('its changes leave the check failing');
|
|
489
|
+
left = answer.left;
|
|
490
|
+
holds = step.until === undefined || answer.untilHolds;
|
|
491
|
+
}
|
|
492
|
+
await this.tree.commitStep({
|
|
493
|
+
workflow: name,
|
|
494
|
+
step: step.name,
|
|
495
|
+
key,
|
|
496
|
+
after,
|
|
497
|
+
summary: summaries.join('\n\n'),
|
|
498
|
+
session: sessionId
|
|
499
|
+
});
|
|
500
|
+
this.stepsDone.add(key);
|
|
501
|
+
this.reports.push({
|
|
502
|
+
step,
|
|
503
|
+
ticket: after,
|
|
504
|
+
state: holds ? 'done' : 'unmet',
|
|
505
|
+
rounds: ran,
|
|
506
|
+
left: holds ? [] : left
|
|
507
|
+
});
|
|
508
|
+
return { kind: 'done' };
|
|
509
|
+
}
|
|
510
|
+
async finish(last) {
|
|
511
|
+
const { tracker, codeHost } = this.deps;
|
|
512
|
+
const id = this.feature.id;
|
|
513
|
+
const left = waiting(this.tickets, this.built);
|
|
514
|
+
const paused = last.kind === 'paused' ? last.why : null;
|
|
515
|
+
await tracker.removeSignal(id, 'working');
|
|
516
|
+
if (this.aborted || last.kind === 'aborted') {
|
|
517
|
+
await tracker.comment(id, `The Nightjar run was stopped by a person.${FOOTER}`);
|
|
518
|
+
return { kind: 'stopped' };
|
|
519
|
+
}
|
|
520
|
+
if (paused) {
|
|
521
|
+
// the Go back, so a later run goes on from the branch
|
|
522
|
+
await tracker.addSignal(id, 'go');
|
|
523
|
+
this.deps.say(`paused: ${paused}`);
|
|
524
|
+
this.deps.status.doing({ kind: 'paused', why: paused, until: null });
|
|
525
|
+
}
|
|
526
|
+
if ((await this.tree.ahead()) === 0) {
|
|
527
|
+
if (paused)
|
|
528
|
+
return { kind: 'paused', why: paused };
|
|
529
|
+
this.deps.status.happened({
|
|
530
|
+
kind: 'needs-info',
|
|
531
|
+
feature: id,
|
|
532
|
+
ticket: null,
|
|
533
|
+
why: 'it built nothing'
|
|
534
|
+
});
|
|
535
|
+
await tracker.addSignal(id, 'needsInfo');
|
|
536
|
+
await tracker.comment(id, `Nightjar built nothing for this Feature.\n\n${waitingList(left)}${FOOTER}`);
|
|
537
|
+
return { kind: 'nothing', waiting: left };
|
|
538
|
+
}
|
|
539
|
+
const red = last.kind === 'red' ? { ticket: [...this.built].at(-1) ?? '', tail: last.tail } : null;
|
|
540
|
+
const done = [...this.built].flatMap((n) => this.tickets.filter((t) => t.id === n));
|
|
541
|
+
const review = this.setup.review?.enabled === false
|
|
542
|
+
? 'off'
|
|
543
|
+
: await reviewFeature(this.deps, this.setup, this.tree, this.feature, done, (e) => this.log(e));
|
|
544
|
+
await codeHost.push(this.tree.at.folder, this.tree.at.branch);
|
|
545
|
+
const complete = left.length === 0 && red === null && paused === null;
|
|
546
|
+
const workflow = this.workflowSection(done);
|
|
547
|
+
const body = pullBody({
|
|
548
|
+
tracker,
|
|
549
|
+
feature: this.feature,
|
|
550
|
+
check: this.setup.check,
|
|
551
|
+
complete,
|
|
552
|
+
done,
|
|
553
|
+
left,
|
|
554
|
+
red,
|
|
555
|
+
review,
|
|
556
|
+
spent: this.spent,
|
|
557
|
+
paused,
|
|
558
|
+
workflow
|
|
559
|
+
});
|
|
560
|
+
const pull = await this.openDraft(body);
|
|
561
|
+
const linked = [...(complete ? [id] : []), ...done.map((t) => t.id)];
|
|
562
|
+
await codeHost.placeReferences(pull, linked.map((n) => tracker.reference(n)));
|
|
563
|
+
if (review !== 'off' && review)
|
|
564
|
+
await codeHost.comment(pull, reviewComment(review));
|
|
565
|
+
this.deps.say(`${hash(id)}: draft pull request ${pull.url}`);
|
|
566
|
+
this.deps.status.happened({ kind: 'pull', feature: id, url: pull.url });
|
|
567
|
+
if (!paused && (left.length > 0 || red)) {
|
|
568
|
+
const waits = `${String(left.length)} Ticket${left.length === 1 ? '' : 's'} wait`;
|
|
569
|
+
this.deps.status.happened({
|
|
570
|
+
kind: 'needs-info',
|
|
571
|
+
feature: id,
|
|
572
|
+
ticket: null,
|
|
573
|
+
why: red ? `the check fails after ${hash(red.ticket)}` : waits
|
|
574
|
+
});
|
|
575
|
+
await tracker.addSignal(id, 'needsInfo');
|
|
576
|
+
const failing = red ? `The check fails after ${hash(red.ticket)}.\n\n` : '';
|
|
577
|
+
await tracker.comment(id, `Nightjar opened ${pull.url}, and this Feature still needs a person.\n\n${failing}${waitingList(left)}${FOOTER}`);
|
|
578
|
+
}
|
|
579
|
+
return { kind: 'pull', pull, built: [...this.built], waiting: left };
|
|
580
|
+
}
|
|
581
|
+
// What the Workflow's Steps did, from this run and from the branch's commits; a Step that did not run is listed too.
|
|
582
|
+
workflowSection(done) {
|
|
583
|
+
const { workflow } = this;
|
|
584
|
+
if (workflow === null)
|
|
585
|
+
return null;
|
|
586
|
+
const reports = [...this.reports];
|
|
587
|
+
const seen = (step, ticket) => reports.some((r) => r.step === step && r.ticket === ticket);
|
|
588
|
+
const pairs = workflow.workflow.steps.flatMap((step) => step.point === 'afterEachTicket' ? done.map((t) => [step, t.id]) : [[step, null]]);
|
|
589
|
+
for (const [step, ticket] of pairs) {
|
|
590
|
+
if (seen(step, ticket))
|
|
591
|
+
continue;
|
|
592
|
+
if (this.stepsDone.has(stepKey(workflow.name, step.name, ticket))) {
|
|
593
|
+
reports.push({ step, ticket, state: 'done', left: [] });
|
|
594
|
+
}
|
|
595
|
+
else if (step.point === 'afterAllTickets') {
|
|
596
|
+
const why = 'it did not run, as the Feature is not complete';
|
|
597
|
+
reports.push({ step, ticket, state: 'undone', why, left: [] });
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
const order = (r) => workflow.workflow.steps.indexOf(r.step);
|
|
601
|
+
reports.sort((a, b) => order(a) - order(b));
|
|
602
|
+
return workflowSection(workflow.name, reports, (id) => this.deps.tracker.reference(id).text);
|
|
603
|
+
}
|
|
604
|
+
// The branch's open pull request with a new body, else a new draft.
|
|
605
|
+
async openDraft(body) {
|
|
606
|
+
const { codeHost } = this.deps;
|
|
607
|
+
const open = await codeHost.openPull(this.tree.at.branch);
|
|
608
|
+
if (open) {
|
|
609
|
+
await codeHost.editBody(open, body);
|
|
610
|
+
return open;
|
|
611
|
+
}
|
|
612
|
+
return codeHost.createDraft({
|
|
613
|
+
title: this.feature.title,
|
|
614
|
+
head: this.tree.at.branch,
|
|
615
|
+
base: this.setup.baseBranch,
|
|
616
|
+
body
|
|
617
|
+
});
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
const API_KEY_PAUSE = 'the session billed an API key; unset ANTHROPIC_API_KEY or pass --use-api-key';
|
|
621
|
+
const limitPause = (resetsAt) => `the plan's usage limit was reached${resetsAt ? `; it resets at ${new Date(resetsAt * 1000).toISOString()}` : ''}`;
|
|
622
|
+
async function reviewFeature(deps, setup, tree, feature, built, log) {
|
|
623
|
+
const sessionId = deps.newId();
|
|
624
|
+
deps.say(`${hash(feature.id)}: review, session ${sessionId}`);
|
|
625
|
+
deps.status.doing({
|
|
626
|
+
kind: 'building',
|
|
627
|
+
feature: feature.id,
|
|
628
|
+
ticket: null,
|
|
629
|
+
step: 'review',
|
|
630
|
+
round: null,
|
|
631
|
+
rounds: null,
|
|
632
|
+
session: sessionId,
|
|
633
|
+
startedAt: deps.now().toISOString()
|
|
634
|
+
});
|
|
635
|
+
const result = await deps.session({
|
|
636
|
+
cwd: tree.at.folder,
|
|
637
|
+
prompt: reviewPrompt(feature, tree.at.base, built, setup.rules),
|
|
638
|
+
schema: REVIEW_SCHEMA,
|
|
639
|
+
tools: REVIEW_TOOLS,
|
|
640
|
+
settings: reviewSettings(),
|
|
641
|
+
budgetUsd: setup.limits.reviewUsd,
|
|
642
|
+
minutes: setup.limits.reviewMinutes,
|
|
643
|
+
model: setup.model ?? setup.review?.model ?? setup.defaults?.model,
|
|
644
|
+
effort: setup.effort ?? setup.review?.effort ?? setup.defaults?.effort,
|
|
645
|
+
sessionId,
|
|
646
|
+
log: join(logsPath(setup.root), `${sessionId}.jsonl`),
|
|
647
|
+
allowApiKey: setup.allowApiKey,
|
|
648
|
+
env: envOf(setup),
|
|
649
|
+
signal: setup.signal
|
|
650
|
+
});
|
|
651
|
+
const review = result.kind === 'done' ? readReview(result.output) : null;
|
|
652
|
+
log({
|
|
653
|
+
ticket: null,
|
|
654
|
+
step: 'review',
|
|
655
|
+
session: sessionId,
|
|
656
|
+
costUsd: 'costUsd' in result ? result.costUsd : 0,
|
|
657
|
+
outcome: review
|
|
658
|
+
? review.verdict
|
|
659
|
+
: result.kind === 'stopped'
|
|
660
|
+
? `failed: ${result.why}`
|
|
661
|
+
: result.kind
|
|
662
|
+
});
|
|
663
|
+
return review;
|
|
664
|
+
}
|
|
665
|
+
// `name` is how the place the list goes names a Ticket: #N on the Tracker, its reference on the Code host.
|
|
666
|
+
function waitingList(left, name = hash) {
|
|
667
|
+
if (left.length === 0)
|
|
668
|
+
return 'No Ticket is waiting.';
|
|
669
|
+
const line = (w) => `- ${name(w.id)} ${w.title}: ${w.why}${w.blocker === undefined ? '' : ` ${name(w.blocker)}`}`;
|
|
670
|
+
return `Waiting:\n\n${left.map(line).join('\n')}`;
|
|
671
|
+
}
|
|
672
|
+
function pullBody(p) {
|
|
673
|
+
const ref = (id) => p.tracker.reference(id).text;
|
|
674
|
+
const parts = [
|
|
675
|
+
`Nightjar built ${p.complete ? 'all of' : 'part of'} ${ref(p.feature.id)} ${p.feature.title}.`,
|
|
676
|
+
`## Built\n\n${p.done.map((t) => `- ${ref(t.id)} ${t.title}`).join('\n')}`
|
|
677
|
+
];
|
|
678
|
+
if (p.red) {
|
|
679
|
+
parts.push(`## The check fails\n\nAfter ${ref(p.red.ticket)}, \`${p.check}\` still fails after the fix rounds:\n\n\`\`\`\n${p.red.tail}\n\`\`\``);
|
|
680
|
+
}
|
|
681
|
+
if (p.left.length > 0)
|
|
682
|
+
parts.push(`## ${waitingList(p.left, ref)}`);
|
|
683
|
+
if (p.paused)
|
|
684
|
+
parts.push(`## Paused\n\nThe run paused because ${p.paused}. It goes on from here when the Feature gets the Go again.`);
|
|
685
|
+
if (p.workflow !== null)
|
|
686
|
+
parts.push(p.workflow);
|
|
687
|
+
if (p.review !== 'off')
|
|
688
|
+
parts.push(`## Review\n\n${p.review ? `${p.review.verdict === 'approve' ? 'Approve' : 'Changes asked'}: ${p.review.summary}` : 'The review did not finish.'}`);
|
|
689
|
+
parts.push(`Estimated cost of this run: ${dollars(p.spent)}.`);
|
|
690
|
+
return `${parts.join('\n\n')}${FOOTER}`;
|
|
691
|
+
}
|
|
692
|
+
function reviewComment(review) {
|
|
693
|
+
const issues = review.issues
|
|
694
|
+
.map((i) => `- \`${i.file}${i.line ? `:${String(i.line)}` : ''}\`: ${i.problem}`)
|
|
695
|
+
.join('\n');
|
|
696
|
+
return `**Nightjar review: ${review.verdict === 'approve' ? 'approve' : 'changes'}**\n\n${review.summary}${issues ? `\n\n${issues}` : ''}${FOOTER}`;
|
|
697
|
+
}
|
|
698
|
+
//# sourceMappingURL=build.js.map
|