@nanobpm/nano-workforce 0.73.1 → 0.75.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/CHANGELOG.md +14 -0
- package/app/feature.test.ts +99 -1
- package/app/feature.ts +27 -0
- package/app/github.test.ts +77 -1
- package/app/github.ts +44 -0
- package/app/plan.test.ts +90 -1
- package/app/plan.ts +12 -1
- package/app/service.ts +9 -2
- package/db/migrations/035_feature_runs_title.sql +17 -0
- package/db/migrations/036_backfill_titles.sql +18 -0
- package/openapi.yaml +13 -0
- package/operations/startFeature.ts +7 -1
- package/package.json +1 -1
- package/pages/epic-detail.page.json +1 -1
- package/pages/epic.page.json +1 -1
- package/pages/feature.page.json +2 -1
- package/pages/home.page.json +3 -2
- package/pages/overview.page.json +5 -20
- package/resources/processes/feature.bpmn +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
# [0.75.0](https://github.com/nanobpm/nano-workforce/compare/v0.74.0...v0.75.0) (2026-08-17)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Features
|
|
5
|
+
|
|
6
|
+
* surface issue/PR titles on dispatch surfaces ([#248](https://github.com/nanobpm/nano-workforce/issues/248)) ([#249](https://github.com/nanobpm/nano-workforce/issues/249)) ([a63f6dd](https://github.com/nanobpm/nano-workforce/commit/a63f6dd9bab3040cdc61c807bdc999ce9a591299))
|
|
7
|
+
|
|
8
|
+
# [0.74.0](https://github.com/nanobpm/nano-workforce/compare/v0.73.1...v0.74.0) (2026-08-16)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* **feature:** thread optional custom instructions to the implementation agent ([#247](https://github.com/nanobpm/nano-workforce/issues/247)) ([8b65495](https://github.com/nanobpm/nano-workforce/commit/8b65495a739b01560937fa108ed4def16c3ffd85))
|
|
14
|
+
|
|
1
15
|
## [0.73.1](https://github.com/nanobpm/nano-workforce/compare/v0.73.0...v0.73.1) (2026-08-16)
|
|
2
16
|
|
|
3
17
|
|
package/app/feature.test.ts
CHANGED
|
@@ -5,10 +5,27 @@
|
|
|
5
5
|
// drive it against an in-memory data layer + a stub engine and assert the row shape, the
|
|
6
6
|
// short-circuit on an already-running run, the in-place restart of a settled run, and the seeded
|
|
7
7
|
// process variables (the single `task` slice + the base-branch brief).
|
|
8
|
-
import { test } from "node:test";
|
|
8
|
+
import { after, test } from "node:test";
|
|
9
9
|
import { assertEquals } from "#test-assert";
|
|
10
10
|
import { FEATURE_PROCESS_ID, FEATURE_TERMINAL_STATUSES, featureTaskId, startFeature } from "./feature.ts";
|
|
11
11
|
|
|
12
|
+
// `startFeature` now fetches the issue title (issue #248) via the GitHub transport. Force the token
|
|
13
|
+
// transport with no token so the fetch is a hermetic no-op (returns null) — no `gh` subprocess, no
|
|
14
|
+
// network — and the row `title` deterministically coalesces to the `owner/repo#N` key. A dedicated
|
|
15
|
+
// test below stubs a successful fetch to cover the real-title path. Capture the prior values and
|
|
16
|
+
// restore them after this file's tests so the module-scope mutation never leaks into other test
|
|
17
|
+
// files under concurrent `node --test`.
|
|
18
|
+
const PRIOR_TRANSPORT = process.env["NANO_PR_GITHUB_TRANSPORT"];
|
|
19
|
+
const PRIOR_TOKEN = process.env["GITHUB_TOKEN"];
|
|
20
|
+
process.env["NANO_PR_GITHUB_TRANSPORT"] = "token";
|
|
21
|
+
delete process.env["GITHUB_TOKEN"];
|
|
22
|
+
after(() => {
|
|
23
|
+
if (PRIOR_TRANSPORT === undefined) delete process.env["NANO_PR_GITHUB_TRANSPORT"];
|
|
24
|
+
else process.env["NANO_PR_GITHUB_TRANSPORT"] = PRIOR_TRANSPORT;
|
|
25
|
+
if (PRIOR_TOKEN === undefined) delete process.env["GITHUB_TOKEN"];
|
|
26
|
+
else process.env["GITHUB_TOKEN"] = PRIOR_TOKEN;
|
|
27
|
+
});
|
|
28
|
+
|
|
12
29
|
function memTable(rows: any[], key: string) {
|
|
13
30
|
return {
|
|
14
31
|
get: (k: any) => Promise.resolve(rows.find((r) => r[key] === k) ?? null),
|
|
@@ -114,6 +131,42 @@ test("startFeature: seeds the single task slice + base-branch brief onto the ins
|
|
|
114
131
|
assertEquals(v.status, null);
|
|
115
132
|
});
|
|
116
133
|
|
|
134
|
+
test("startFeature: custom instructions ride the instance as a variable (trimmed)", async () => {
|
|
135
|
+
let captured: any = null;
|
|
136
|
+
const engine = {
|
|
137
|
+
createInstance: (req: any) => {
|
|
138
|
+
captured = req;
|
|
139
|
+
return Promise.resolve({ processInstanceKey: "PI-3" });
|
|
140
|
+
},
|
|
141
|
+
} as any;
|
|
142
|
+
await startFeature(
|
|
143
|
+
memData({ feature_runs: { rows: [], key: "feature_key" } }),
|
|
144
|
+
engine,
|
|
145
|
+
PARSED,
|
|
146
|
+
"main",
|
|
147
|
+
false,
|
|
148
|
+
false,
|
|
149
|
+
" prefer Deno; keep the diff small ",
|
|
150
|
+
);
|
|
151
|
+
assertEquals(captured.variables.customInstructions, "prefer Deno; keep the diff small");
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("startFeature: blank/absent custom instructions are seeded as null", async () => {
|
|
155
|
+
let captured: any = null;
|
|
156
|
+
const engine = {
|
|
157
|
+
createInstance: (req: any) => {
|
|
158
|
+
captured = req;
|
|
159
|
+
return Promise.resolve({ processInstanceKey: "PI-4" });
|
|
160
|
+
},
|
|
161
|
+
} as any;
|
|
162
|
+
// Absent (default arg) → null.
|
|
163
|
+
await startFeature(memData({ feature_runs: { rows: [], key: "feature_key" } }), engine, PARSED, "main", false, false);
|
|
164
|
+
assertEquals(captured.variables.customInstructions, null);
|
|
165
|
+
// Whitespace-only → null (so the appendPrompt FEEL skips the block instead of appending an empty heading).
|
|
166
|
+
await startFeature(memData({ feature_runs: { rows: [], key: "feature_key" } }), engine, PARSED, "main", false, false, " ");
|
|
167
|
+
assertEquals(captured.variables.customInstructions, null);
|
|
168
|
+
});
|
|
169
|
+
|
|
117
170
|
test("startFeature: an already-running run short-circuits (no new instance)", async () => {
|
|
118
171
|
const stores = {
|
|
119
172
|
feature_runs: {
|
|
@@ -185,3 +238,48 @@ test("startFeature: a settled run is restarted in place (status reset, pr/outcom
|
|
|
185
238
|
assertEquals(row.auto_merge, 1);
|
|
186
239
|
assertEquals(row.process_key, "PI-2");
|
|
187
240
|
});
|
|
241
|
+
|
|
242
|
+
// Issue #248: the human-readable identity for the feature grids. Every start persists a non-blank
|
|
243
|
+
// `title` — the fetched issue title when available, else the `owner/repo#N` key — on BOTH the insert
|
|
244
|
+
// (new run) and update (in-place restart) paths, so the title-led grid never renders a blank cell.
|
|
245
|
+
test("startFeature: coalesces title to the key when the fetch yields nothing (insert path)", async () => {
|
|
246
|
+
const stores = { feature_runs: { rows: [] as any[], key: "feature_key" } };
|
|
247
|
+
const engine = { createInstance: () => Promise.resolve({ processInstanceKey: "PI-T1" }) } as any;
|
|
248
|
+
await startFeature(memData(stores), engine, PARSED, "main", false, false);
|
|
249
|
+
assertEquals(stores.feature_runs.rows[0].title, "owner/repo#42");
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test("startFeature: repopulates a non-blank title on the in-place restart (update path)", async () => {
|
|
253
|
+
const stores = {
|
|
254
|
+
feature_runs: {
|
|
255
|
+
rows: [{ feature_key: "owner/repo#42", status: "opened", title: null, process_key: "PI-OLD" }],
|
|
256
|
+
key: "feature_key",
|
|
257
|
+
},
|
|
258
|
+
};
|
|
259
|
+
const engine = { createInstance: () => Promise.resolve({ processInstanceKey: "PI-T2" }) } as any;
|
|
260
|
+
await startFeature(memData(stores), engine, PARSED, "main", false, false);
|
|
261
|
+
assertEquals(stores.feature_runs.rows[0].title, "owner/repo#42");
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test("startFeature: persists the real issue title when the fetch succeeds", async () => {
|
|
265
|
+
const prevTok = process.env["GITHUB_TOKEN"];
|
|
266
|
+
const prevFetch = globalThis.fetch;
|
|
267
|
+
process.env["GITHUB_TOKEN"] = "t0ken";
|
|
268
|
+
globalThis.fetch = ((url: string | URL | Request) => {
|
|
269
|
+
const u = String(url);
|
|
270
|
+
if (u.endsWith("/repos/owner/repo/issues/42")) {
|
|
271
|
+
return Promise.resolve(new Response(JSON.stringify({ title: "Add the widget" }), { status: 200 }));
|
|
272
|
+
}
|
|
273
|
+
throw new Error(`unexpected fetch: ${u}`);
|
|
274
|
+
}) as typeof fetch;
|
|
275
|
+
try {
|
|
276
|
+
const stores = { feature_runs: { rows: [] as any[], key: "feature_key" } };
|
|
277
|
+
const engine = { createInstance: () => Promise.resolve({ processInstanceKey: "PI-T3" }) } as any;
|
|
278
|
+
await startFeature(memData(stores), engine, PARSED, "main", false, false);
|
|
279
|
+
assertEquals(stores.feature_runs.rows[0].title, "Add the widget");
|
|
280
|
+
} finally {
|
|
281
|
+
globalThis.fetch = prevFetch;
|
|
282
|
+
if (prevTok === undefined) delete process.env["GITHUB_TOKEN"];
|
|
283
|
+
else process.env["GITHUB_TOKEN"] = prevTok;
|
|
284
|
+
}
|
|
285
|
+
});
|
package/app/feature.ts
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
// Data access goes through the record gateway (`data.table`), never hand-written
|
|
16
16
|
// SQL — matching app/plan.ts and app/service.ts.
|
|
17
17
|
import type { DataLayer, EngineClient } from "@nanobpm/urban";
|
|
18
|
+
import { coalesceTitle, fetchIssueTitle } from "./github.ts";
|
|
18
19
|
import { ESCALATION_SLA_TIMEOUT, normalizeBaseBranch, type ParsedIssue, renderBaseBranchBrief } from "./plan.ts";
|
|
19
20
|
|
|
20
21
|
/** The BPMN process this module drives (resources/processes/feature.bpmn). */
|
|
@@ -32,6 +33,11 @@ export interface FeatureRun {
|
|
|
32
33
|
repo: string;
|
|
33
34
|
issue_number: number;
|
|
34
35
|
issue_url: string;
|
|
36
|
+
/** Human-readable identity (the GitHub issue title) for the feature grids (issue #248). Fetched
|
|
37
|
+
* best-effort at `startFeature` and coalesced to the `owner/repo#N` key at write time, so it is
|
|
38
|
+
* ALWAYS non-blank — the grid's `{{title}}` template needs no fallback and a failed/absent title
|
|
39
|
+
* fetch still shows a usable identity (the key) rather than an empty cell. */
|
|
40
|
+
title: string | null;
|
|
35
41
|
base_branch: string;
|
|
36
42
|
status: FeatureRunStatus;
|
|
37
43
|
process_key: string | null;
|
|
@@ -267,7 +273,14 @@ export async function startFeature(
|
|
|
267
273
|
baseBranch: string,
|
|
268
274
|
converge: boolean,
|
|
269
275
|
autoMerge: boolean,
|
|
276
|
+
customInstructions: string | null = null,
|
|
270
277
|
) {
|
|
278
|
+
// Operator free-text steering for the implementation agent (issue #172 follow-on): blank/absent →
|
|
279
|
+
// null so the implement task's `appendPrompt` FEEL (`customInstructions = null`) skips the block
|
|
280
|
+
// rather than appending an empty "Operator custom instructions" heading.
|
|
281
|
+
const instructions = typeof customInstructions === "string" && customInstructions.trim() !== ""
|
|
282
|
+
? customInstructions.trim()
|
|
283
|
+
: null;
|
|
271
284
|
const table = featureRuns(data);
|
|
272
285
|
const existing = await table.get(parsed.planKey);
|
|
273
286
|
if (existing && !FEATURE_TERMINAL_STATUSES.includes(existing.status)) {
|
|
@@ -275,11 +288,20 @@ export async function startFeature(
|
|
|
275
288
|
}
|
|
276
289
|
const base = normalizeBaseBranch(baseBranch);
|
|
277
290
|
const ts = now();
|
|
291
|
+
// Human-readable identity for the feature grids (issue #248): fetch the issue title best-effort
|
|
292
|
+
// and coalesce to the `owner/repo#N` key so `feature_runs.title` is ALWAYS non-blank (see the
|
|
293
|
+
// interface note); a blank/whitespace fetch counts as missing. A fetch failure never blocks the
|
|
294
|
+
// start (`fetchIssueTitle` returns null on any error).
|
|
295
|
+
const title = coalesceTitle(
|
|
296
|
+
await fetchIssueTitle(parsed.repo, parsed.number, process.env.GITHUB_TOKEN ?? ""),
|
|
297
|
+
parsed.planKey,
|
|
298
|
+
);
|
|
278
299
|
if (existing) {
|
|
279
300
|
await table.update(parsed.planKey, {
|
|
280
301
|
status: "running",
|
|
281
302
|
base_branch: base,
|
|
282
303
|
issue_url: parsed.url,
|
|
304
|
+
title,
|
|
283
305
|
pr_key: null,
|
|
284
306
|
converge: converge ? 1 : 0,
|
|
285
307
|
auto_merge: autoMerge ? 1 : 0,
|
|
@@ -296,6 +318,7 @@ export async function startFeature(
|
|
|
296
318
|
repo: parsed.repo,
|
|
297
319
|
issue_number: parsed.number,
|
|
298
320
|
issue_url: parsed.url,
|
|
321
|
+
title,
|
|
299
322
|
base_branch: base,
|
|
300
323
|
status: "running",
|
|
301
324
|
process_key: null,
|
|
@@ -356,6 +379,10 @@ export async function startFeature(
|
|
|
356
379
|
// brief rides `appendPrompt` in the implement task, exactly like the epic implementer.
|
|
357
380
|
baseBranch: base,
|
|
358
381
|
baseBranchBrief: renderBaseBranchBrief(base),
|
|
382
|
+
// Optional operator steering, appended to the implement agent's prompt via the implement
|
|
383
|
+
// task's `appendPrompt` FEEL (feature.bpmn). Null when none was supplied; persists on the
|
|
384
|
+
// instance so it also rides the answer-loop redispatch back into the same implement task.
|
|
385
|
+
customInstructions: instructions,
|
|
359
386
|
},
|
|
360
387
|
});
|
|
361
388
|
const processKey = processInstanceKey == null ? null : String(processInstanceKey);
|
package/app/github.test.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// the merge-exclusion graph. Force the token transport and stub `globalThis.fetch`.
|
|
4
4
|
import { test } from "node:test";
|
|
5
5
|
import { assertEquals, assertRejects } from "#test-assert";
|
|
6
|
-
import { BaseBranchMustExistError, ensureBaseBranch, fetchPrFiles, isNotAPullRequestError } from "./github.ts";
|
|
6
|
+
import { BaseBranchMustExistError, coalesceTitle, ensureBaseBranch, fetchIssueTitle, fetchPrFiles, isNotAPullRequestError } from "./github.ts";
|
|
7
7
|
|
|
8
8
|
// A fake `fetch` that serves `pages` of file batches; each page N (1-based) returns `pages[N-1]`
|
|
9
9
|
// files (named `f{index}`), setting a `Link: rel="next"` header whenever a later page exists.
|
|
@@ -259,3 +259,79 @@ test("isNotAPullRequestError: transient failures stay blocking (false)", () => {
|
|
|
259
259
|
assertEquals(isNotAPullRequestError(new Error("fetch failed")), false);
|
|
260
260
|
assertEquals(isNotAPullRequestError(null), false);
|
|
261
261
|
});
|
|
262
|
+
|
|
263
|
+
// Issue #248: `fetchIssueTitle` labels the epics/feature rows with the real GitHub issue title. It
|
|
264
|
+
// is best-effort and MUST be tolerant of failure (returns null, never throws) so a title fetch can
|
|
265
|
+
// never block an epic/feature start — the caller falls back to the `owner/repo#N` key.
|
|
266
|
+
function withTitleFetch<T>(
|
|
267
|
+
fetchImpl: typeof globalThis.fetch,
|
|
268
|
+
token: string | undefined,
|
|
269
|
+
fn: () => Promise<T>,
|
|
270
|
+
): Promise<T> {
|
|
271
|
+
const prevMode = process.env["NANO_PR_GITHUB_TRANSPORT"];
|
|
272
|
+
const prevTok = process.env["GITHUB_TOKEN"];
|
|
273
|
+
const prevFetch = globalThis.fetch;
|
|
274
|
+
process.env["NANO_PR_GITHUB_TRANSPORT"] = "token";
|
|
275
|
+
if (token === undefined) delete process.env["GITHUB_TOKEN"];
|
|
276
|
+
else process.env["GITHUB_TOKEN"] = token;
|
|
277
|
+
globalThis.fetch = fetchImpl;
|
|
278
|
+
const restore = () => {
|
|
279
|
+
globalThis.fetch = prevFetch;
|
|
280
|
+
if (prevMode === undefined) delete process.env["NANO_PR_GITHUB_TRANSPORT"];
|
|
281
|
+
else process.env["NANO_PR_GITHUB_TRANSPORT"] = prevMode;
|
|
282
|
+
if (prevTok === undefined) delete process.env["GITHUB_TOKEN"];
|
|
283
|
+
else process.env["GITHUB_TOKEN"] = prevTok;
|
|
284
|
+
};
|
|
285
|
+
return fn().finally(restore);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
test("fetchIssueTitle: returns the title on a successful token-transport read", async () => {
|
|
289
|
+
const stub = ((url: string | URL | Request) => {
|
|
290
|
+
assertEquals(String(url).endsWith("/repos/owner/repo/issues/248"), true);
|
|
291
|
+
return Promise.resolve(new Response(JSON.stringify({ title: "Surface titles" }), { status: 200 }));
|
|
292
|
+
}) as typeof fetch;
|
|
293
|
+
const title = await withTitleFetch(stub, "t0ken", () => fetchIssueTitle("owner/repo", 248, "t0ken"));
|
|
294
|
+
assertEquals(title, "Surface titles");
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
test("fetchIssueTitle: a non-2xx response yields null (best-effort, never throws)", async () => {
|
|
298
|
+
const stub = (() => Promise.resolve(new Response("nope", { status: 404 }))) as typeof fetch;
|
|
299
|
+
const title = await withTitleFetch(stub, "t0ken", () => fetchIssueTitle("owner/repo", 999, "t0ken"));
|
|
300
|
+
assertEquals(title, null);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
test("fetchIssueTitle: a thrown transport error is swallowed to null", async () => {
|
|
304
|
+
const stub = (() => Promise.reject(new Error("network down"))) as typeof fetch;
|
|
305
|
+
const title = await withTitleFetch(stub, "t0ken", () => fetchIssueTitle("owner/repo", 7, "t0ken"));
|
|
306
|
+
assertEquals(title, null);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
test("fetchIssueTitle: token mode with no token is a no-op (null)", async () => {
|
|
310
|
+
const stub = (() => {
|
|
311
|
+
throw new Error("fetch must not be called without a token");
|
|
312
|
+
}) as typeof fetch;
|
|
313
|
+
const title = await withTitleFetch(stub, undefined, () => fetchIssueTitle("owner/repo", 7, ""));
|
|
314
|
+
assertEquals(title, null);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
// Issue #248: `coalesceTitle` guarantees a non-blank identity for the title-led grids. A best-effort
|
|
318
|
+
// fetch can legitimately return "" (or whitespace) — `??` would persist that blank; `coalesceTitle`
|
|
319
|
+
// treats it as missing (matching the 036 backfill's `trim(title) = ''`) and falls back to the key.
|
|
320
|
+
test("coalesceTitle: a non-blank first candidate wins", () => {
|
|
321
|
+
assertEquals(coalesceTitle("Real title", "owner/repo#1"), "Real title");
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
test("coalesceTitle: null/undefined candidates fall through to the key", () => {
|
|
325
|
+
assertEquals(coalesceTitle(null, "owner/repo#1"), "owner/repo#1");
|
|
326
|
+
assertEquals(coalesceTitle(undefined, "owner/repo#1"), "owner/repo#1");
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
test("coalesceTitle: a blank/whitespace title counts as missing", () => {
|
|
330
|
+
assertEquals(coalesceTitle("", "owner/repo#1"), "owner/repo#1");
|
|
331
|
+
assertEquals(coalesceTitle(" ", "owner/repo#1"), "owner/repo#1");
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
test("coalesceTitle: skips a blank middle candidate to the next non-blank one", () => {
|
|
335
|
+
assertEquals(coalesceTitle("", "Prior title", "owner/repo#1"), "Prior title");
|
|
336
|
+
assertEquals(coalesceTitle(null, " ", "owner/repo#1"), "owner/repo#1");
|
|
337
|
+
});
|
package/app/github.ts
CHANGED
|
@@ -429,6 +429,50 @@ export async function fetchPrMeta(
|
|
|
429
429
|
return { title: j.title ?? null, body: j.body ?? "", headRef: j.head?.ref ?? null };
|
|
430
430
|
}
|
|
431
431
|
|
|
432
|
+
/** Fetch an issue's title via the configured transport, mirroring `fetchPrMeta` (both `gh` and
|
|
433
|
+
* token transports). Best-effort and tolerant of failure: returns `null` when no transport is
|
|
434
|
+
* usable OR when the fetch fails/returns no title, so a caller can label a row with the real issue
|
|
435
|
+
* title on success and fall back to the `owner/repo#N` key otherwise — a title fetch must never
|
|
436
|
+
* block an epic/feature start. Unlike the merge-stage reads it does NOT throw on a transport
|
|
437
|
+
* failure; the identity it feeds is cosmetic, not a correctness gate. */
|
|
438
|
+
export async function fetchIssueTitle(
|
|
439
|
+
repo: string,
|
|
440
|
+
number: number | string,
|
|
441
|
+
token: string,
|
|
442
|
+
): Promise<string | null> {
|
|
443
|
+
try {
|
|
444
|
+
if (await useGh()) {
|
|
445
|
+
const out = await runGh(["issue", "view", String(number), "--repo", repo, "--json", "title"]);
|
|
446
|
+
// biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
|
|
447
|
+
const j = JSON.parse(out) as { title?: string };
|
|
448
|
+
return j.title ?? null;
|
|
449
|
+
}
|
|
450
|
+
if (!token) return null; // token mode with no token → no identity to fetch (caller falls back to the key)
|
|
451
|
+
const r = await fetch(`https://api.github.com/repos/${repo}/issues/${number}`, {
|
|
452
|
+
headers: { authorization: `Bearer ${token}`, accept: "application/vnd.github+json" },
|
|
453
|
+
});
|
|
454
|
+
if (!r.ok) return null;
|
|
455
|
+
// biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
|
|
456
|
+
const j = (await r.json()) as { title?: string };
|
|
457
|
+
return j.title ?? null;
|
|
458
|
+
} catch {
|
|
459
|
+
return null;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** Coalesce best-effort title candidates to a non-blank identity for the title-led grids (issue
|
|
464
|
+
* #248). A candidate that is null/undefined OR blank/whitespace-only is treated as missing —
|
|
465
|
+
* external data (`fetchIssueTitle`/`fetchPrMeta`) can legitimately return `""`, which `??` would
|
|
466
|
+
* wrongly persist as a blank identity cell. Returns the first non-blank candidate, else the last
|
|
467
|
+
* one (the caller's key fallback, which is always non-blank). Mirrors the 036 backfill's
|
|
468
|
+
* `trim(title) = ''` test so write-time and backfill agree. */
|
|
469
|
+
export function coalesceTitle(...candidates: (string | null | undefined)[]): string {
|
|
470
|
+
for (const c of candidates) {
|
|
471
|
+
if (c != null && c.trim() !== "") return c;
|
|
472
|
+
}
|
|
473
|
+
return candidates[candidates.length - 1] ?? "";
|
|
474
|
+
}
|
|
475
|
+
|
|
432
476
|
/** A PR's merge state, narrowed to what the merge poller needs to classify landability.
|
|
433
477
|
* `mergeStateStatus` uses GitHub's vocabulary (CLEAN | BLOCKED | BEHIND | DIRTY | UNSTABLE |
|
|
434
478
|
* DRAFT | HAS_HOOKS | UNKNOWN). `failingChecks` is `-1` when the transport can't enumerate
|
package/app/plan.test.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// `NaN`/`0` (e.g. unset, "", "abc"), the cap check `round + 1 >= cap` would never fire and the
|
|
5
5
|
// planner could revise forever. `positiveIntEnv` must fall back to the default on any value that
|
|
6
6
|
// is not a positive integer, so the loop is always bounded.
|
|
7
|
-
import { test } from "node:test";
|
|
7
|
+
import { after, test } from "node:test";
|
|
8
8
|
import { assertEquals, assertRejects, assertThrows } from "#test-assert";
|
|
9
9
|
import { positiveIntEnv } from "./plan.ts";
|
|
10
10
|
|
|
@@ -59,6 +59,23 @@ test("valid positive integer → honoured", () => {
|
|
|
59
59
|
// asserts the `plan_reviews` rows for the plan key are gone after a re-plan.
|
|
60
60
|
import { startPlan } from "./plan.ts";
|
|
61
61
|
|
|
62
|
+
// `startPlan` now fetches the epic issue title (issue #248) via the GitHub transport. Force the
|
|
63
|
+
// token transport with no token so the fetch is a hermetic no-op (returns null) and the row `title`
|
|
64
|
+
// deterministically coalesces to the `owner/repo#N` key — no `gh` subprocess, no network. A
|
|
65
|
+
// dedicated test below stubs a successful fetch to cover the real-title path. Capture the prior
|
|
66
|
+
// values and restore them after this file's tests so the module-scope mutation never leaks into
|
|
67
|
+
// other test files under concurrent `node --test`.
|
|
68
|
+
const PRIOR_TRANSPORT = process.env["NANO_PR_GITHUB_TRANSPORT"];
|
|
69
|
+
const PRIOR_TOKEN = process.env["GITHUB_TOKEN"];
|
|
70
|
+
process.env["NANO_PR_GITHUB_TRANSPORT"] = "token";
|
|
71
|
+
delete process.env["GITHUB_TOKEN"];
|
|
72
|
+
after(() => {
|
|
73
|
+
if (PRIOR_TRANSPORT === undefined) delete process.env["NANO_PR_GITHUB_TRANSPORT"];
|
|
74
|
+
else process.env["NANO_PR_GITHUB_TRANSPORT"] = PRIOR_TRANSPORT;
|
|
75
|
+
if (PRIOR_TOKEN === undefined) delete process.env["GITHUB_TOKEN"];
|
|
76
|
+
else process.env["GITHUB_TOKEN"] = PRIOR_TOKEN;
|
|
77
|
+
});
|
|
78
|
+
|
|
62
79
|
function memTable(rows: any[], key: string) {
|
|
63
80
|
return {
|
|
64
81
|
get: (k: any) => Promise.resolve(rows.find((r) => r[key] === k) ?? null),
|
|
@@ -499,3 +516,75 @@ test("findActivePlansByBase returns only non-terminal plans on the matching repo
|
|
|
499
516
|
assertEquals(active.length, 1);
|
|
500
517
|
assertEquals(active[0].plan_key, "o/x#1");
|
|
501
518
|
});
|
|
519
|
+
|
|
520
|
+
// Issue #248: the human-readable identity for the epics grids. `startPlan` persists a non-blank
|
|
521
|
+
// `plans.title` on BOTH the insert (new epic) and update (re-plan) paths — the fetched issue title
|
|
522
|
+
// when available, else the `owner/repo#N` key — so the title-led grid never renders a blank cell.
|
|
523
|
+
test("startPlan coalesces title to the key when the fetch yields nothing (insert path)", async () => {
|
|
524
|
+
const PLAN_KEY = "owner/repo#248";
|
|
525
|
+
const stores: Record<string, { rows: any[]; key: string }> = {
|
|
526
|
+
plans: { rows: [], key: "plan_key" },
|
|
527
|
+
plan_tasks: { rows: [], key: "id" },
|
|
528
|
+
plan_reviews: { rows: [], key: "plan_key" },
|
|
529
|
+
plan_task_deps: { rows: [], key: "plan_key" },
|
|
530
|
+
};
|
|
531
|
+
const engine = { createInstance: () => Promise.resolve({ processInstanceKey: "PI-T1" }) } as any;
|
|
532
|
+
await startPlan(memData(stores), engine, {
|
|
533
|
+
repo: "owner/repo",
|
|
534
|
+
number: 248,
|
|
535
|
+
url: "https://github.com/owner/repo/issues/248",
|
|
536
|
+
planKey: PLAN_KEY,
|
|
537
|
+
}, "main");
|
|
538
|
+
assertEquals((stores.plans.rows[0] as any).title, PLAN_KEY);
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
test("startPlan repopulates a non-blank title on re-plan (update path)", async () => {
|
|
542
|
+
const PLAN_KEY = "owner/repo#249";
|
|
543
|
+
const stores: Record<string, { rows: any[]; key: string }> = {
|
|
544
|
+
plans: { rows: [{ plan_key: PLAN_KEY, status: "done", task_count: 0, title: null }], key: "plan_key" },
|
|
545
|
+
plan_tasks: { rows: [], key: "id" },
|
|
546
|
+
plan_reviews: { rows: [], key: "plan_key" },
|
|
547
|
+
plan_task_deps: { rows: [], key: "plan_key" },
|
|
548
|
+
};
|
|
549
|
+
const engine = { createInstance: () => Promise.resolve({ processInstanceKey: "PI-T2" }) } as any;
|
|
550
|
+
await startPlan(memData(stores), engine, {
|
|
551
|
+
repo: "owner/repo",
|
|
552
|
+
number: 249,
|
|
553
|
+
url: "https://github.com/owner/repo/issues/249",
|
|
554
|
+
planKey: PLAN_KEY,
|
|
555
|
+
}, "main");
|
|
556
|
+
assertEquals((stores.plans.rows[0] as any).title, PLAN_KEY);
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
test("startPlan persists the real epic issue title when the fetch succeeds", async () => {
|
|
560
|
+
const prevTok = process.env["GITHUB_TOKEN"];
|
|
561
|
+
const prevFetch = globalThis.fetch;
|
|
562
|
+
process.env["GITHUB_TOKEN"] = "t0ken";
|
|
563
|
+
globalThis.fetch = ((url: string | URL | Request) => {
|
|
564
|
+
const u = String(url);
|
|
565
|
+
if (u.endsWith("/repos/owner/repo/issues/250")) {
|
|
566
|
+
return Promise.resolve(new Response(JSON.stringify({ title: "Ship the epic" }), { status: 200 }));
|
|
567
|
+
}
|
|
568
|
+
throw new Error(`unexpected fetch: ${u}`);
|
|
569
|
+
}) as typeof fetch;
|
|
570
|
+
try {
|
|
571
|
+
const stores: Record<string, { rows: any[]; key: string }> = {
|
|
572
|
+
plans: { rows: [], key: "plan_key" },
|
|
573
|
+
plan_tasks: { rows: [], key: "id" },
|
|
574
|
+
plan_reviews: { rows: [], key: "plan_key" },
|
|
575
|
+
plan_task_deps: { rows: [], key: "plan_key" },
|
|
576
|
+
};
|
|
577
|
+
const engine = { createInstance: () => Promise.resolve({ processInstanceKey: "PI-T3" }) } as any;
|
|
578
|
+
await startPlan(memData(stores), engine, {
|
|
579
|
+
repo: "owner/repo",
|
|
580
|
+
number: 250,
|
|
581
|
+
url: "https://github.com/owner/repo/issues/250",
|
|
582
|
+
planKey: "owner/repo#250",
|
|
583
|
+
}, "main");
|
|
584
|
+
assertEquals((stores.plans.rows[0] as any).title, "Ship the epic");
|
|
585
|
+
} finally {
|
|
586
|
+
globalThis.fetch = prevFetch;
|
|
587
|
+
if (prevTok === undefined) delete process.env["GITHUB_TOKEN"];
|
|
588
|
+
else process.env["GITHUB_TOKEN"] = prevTok;
|
|
589
|
+
}
|
|
590
|
+
});
|
package/app/plan.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
import type { DataLayer, EngineClient } from "@nanobpm/urban";
|
|
13
13
|
import { blackboardUrl, mintBlackboardToken, renderCoordinationBrief } from "./blackboard.ts";
|
|
14
14
|
import { DEFAULT_ESCALATION_SLA_TIMEOUT, escalationSlaTimeout } from "./escalationSla.ts";
|
|
15
|
-
import { ensureBaseBranch, fetchDefaultBranch } from "./github.ts";
|
|
15
|
+
import { coalesceTitle, ensureBaseBranch, fetchDefaultBranch, fetchIssueTitle } from "./github.ts";
|
|
16
16
|
import { clearExclusions } from "./mergeExclusion.ts";
|
|
17
17
|
import { clearTaskDeltas } from "./taskDelta.ts";
|
|
18
18
|
|
|
@@ -400,6 +400,15 @@ export async function startPlan(
|
|
|
400
400
|
}
|
|
401
401
|
const base = normalizeBaseBranch(baseBranch);
|
|
402
402
|
const ts = now();
|
|
403
|
+
// Human-readable identity for the epics grids (issue #248): fetch the epic issue's title,
|
|
404
|
+
// best-effort. Coalesce to the `owner/repo#N` key at write time so `plans.title` is ALWAYS
|
|
405
|
+
// non-blank — the grid's `{{title}}` template then needs no fallback, and a failed/absent/blank
|
|
406
|
+
// fetch still shows a usable identity (the key) rather than an empty cell. A fetch failure never
|
|
407
|
+
// blocks the start (`fetchIssueTitle` returns null on any error).
|
|
408
|
+
const title = coalesceTitle(
|
|
409
|
+
await fetchIssueTitle(parsed.repo, parsed.number, process.env.GITHUB_TOKEN ?? ""),
|
|
410
|
+
parsed.planKey,
|
|
411
|
+
);
|
|
403
412
|
// Mint (or reuse, on a re-plan) this plan's blackboard capability token, and render the
|
|
404
413
|
// coordination brief that carries its concrete URL. The token is the credential; agents reach
|
|
405
414
|
// the blackboard directly with the URL we seed into `appendPrompt` below (#51).
|
|
@@ -425,6 +434,7 @@ export async function startPlan(
|
|
|
425
434
|
status: "planning",
|
|
426
435
|
task_count: 0,
|
|
427
436
|
issue_url: parsed.url,
|
|
437
|
+
title,
|
|
428
438
|
outcome: null,
|
|
429
439
|
blackboard_token: token,
|
|
430
440
|
base_branch: base,
|
|
@@ -436,6 +446,7 @@ export async function startPlan(
|
|
|
436
446
|
repo: parsed.repo,
|
|
437
447
|
issue_number: parsed.number,
|
|
438
448
|
issue_url: parsed.url,
|
|
449
|
+
title,
|
|
439
450
|
status: "planning",
|
|
440
451
|
task_count: 0,
|
|
441
452
|
blackboard_token: token,
|
package/app/service.ts
CHANGED
|
@@ -14,6 +14,7 @@ import { agentSlaTimeout } from "./agentSla.ts";
|
|
|
14
14
|
import { deriveFeatureBlockedPatch, deriveFeatureDelivery, deriveFeatureEscalationPatch, FEATURE_BLOCKED_ELEMENT, FEATURE_ESCALATION_ELEMENT, FEATURE_RUN_STATUSES, type FeatureRun, type FeatureRunStatus, featureRuns } from "./feature.ts";
|
|
15
15
|
import {
|
|
16
16
|
classifyMergeability,
|
|
17
|
+
coalesceTitle,
|
|
17
18
|
ensureFreshHeadRun,
|
|
18
19
|
fetchPrHead,
|
|
19
20
|
fetchPrMeta,
|
|
@@ -460,7 +461,11 @@ export async function submitPr(
|
|
|
460
461
|
status: "converging",
|
|
461
462
|
current_round: 1,
|
|
462
463
|
url: parsed.url,
|
|
463
|
-
|
|
464
|
+
// Coalesce to the key so `pull_requests.title` stays non-blank for the title-led grids
|
|
465
|
+
// (issue #248): a fresh fetch wins, else the prior title, else the `owner/repo#N` key.
|
|
466
|
+
// A blank/whitespace title counts as missing (matches the 036 backfill), so an empty
|
|
467
|
+
// external title never lands as an unlabeled row.
|
|
468
|
+
title: coalesceTitle(title, existing.title, parsed.prKey),
|
|
464
469
|
waiting_since: null,
|
|
465
470
|
last_review_id: null,
|
|
466
471
|
last_nudge_at: null,
|
|
@@ -476,7 +481,9 @@ export async function submitPr(
|
|
|
476
481
|
repo: parsed.repo,
|
|
477
482
|
number: parsed.number,
|
|
478
483
|
url: parsed.url,
|
|
479
|
-
title
|
|
484
|
+
// Coalesce to the key so the title-led grids never render a blank identity (issue #248);
|
|
485
|
+
// a blank/whitespace external title counts as missing (matches the 036 backfill).
|
|
486
|
+
title: coalesceTitle(title, parsed.prKey),
|
|
480
487
|
status: "converging",
|
|
481
488
|
current_round: 1,
|
|
482
489
|
abandon_token: abandonToken,
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
-- Surface the GitHub issue title as the primary, human-readable identity on the
|
|
2
|
+
-- feature grids (issue #248, L0). Every dispatch row was keyed only by
|
|
3
|
+
-- `owner/repo#N` — meaningful while you remember the number, opaque hours later.
|
|
4
|
+
--
|
|
5
|
+
-- `plans.title` (004_planning.sql) and `pull_requests.title` already existed;
|
|
6
|
+
-- `feature_runs` had no title column at all. Add one so `startFeature` can persist
|
|
7
|
+
-- the fetched issue title (best-effort), coalesced to the `owner/repo#N` key at
|
|
8
|
+
-- write time so the column is ALWAYS non-blank — the grid's `{{title}}` template
|
|
9
|
+
-- then needs no fallback and a failed/absent fetch still shows a usable identity.
|
|
10
|
+
--
|
|
11
|
+
-- Forward-only, additive (expand): nullable with no default, so pre-#248 rows
|
|
12
|
+
-- grandfather in as NULL. Write-time coalescing keeps new rows non-blank; the
|
|
13
|
+
-- follow-up 036_backfill_titles.sql then backfills these legacy NULL/blank titles
|
|
14
|
+
-- to the row key, so the title-led grids never render a blank identity cell.
|
|
15
|
+
-- Numbered after the current highest prefix on origin/main (034); the runner wraps
|
|
16
|
+
-- each file in its own transaction, so this file must NOT contain BEGIN/COMMIT.
|
|
17
|
+
ALTER TABLE feature_runs ADD COLUMN title TEXT;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
-- Backfill legacy NULL/blank titles so the title-led grids (issue #248) never
|
|
2
|
+
-- render a blank identity cell for pre-#248 rows.
|
|
3
|
+
--
|
|
4
|
+
-- 035_feature_runs_title.sql added `feature_runs.title` nullable, and both
|
|
5
|
+
-- `plans.title` (004_planning.sql) and `pull_requests.title` (001_init.sql) have
|
|
6
|
+
-- long allowed NULL. The dispatch/overview grids now render `template: "{{title}}"`
|
|
7
|
+
-- directly off these tables with NO key fallback, so any historical row whose title
|
|
8
|
+
-- is NULL or blank shows as an unlabeled row. `startPlan`/`startFeature`/the PR
|
|
9
|
+
-- upsert already coalesce title to the `owner/repo#N` key at write time; this
|
|
10
|
+
-- migration applies the same coalesce once to the rows that predate that behaviour.
|
|
11
|
+
--
|
|
12
|
+
-- Forward-only, additive (data-only): sets title to the row's key where it is
|
|
13
|
+
-- currently NULL or blank. Idempotent — re-running is a no-op once titles are set.
|
|
14
|
+
-- Numbered after the current highest prefix on origin/main (035); the runner wraps
|
|
15
|
+
-- each file in its own transaction, so this file must NOT contain BEGIN/COMMIT.
|
|
16
|
+
UPDATE plans SET title = plan_key WHERE title IS NULL OR trim(title) = '';
|
|
17
|
+
UPDATE feature_runs SET title = feature_key WHERE title IS NULL OR trim(title) = '';
|
|
18
|
+
UPDATE pull_requests SET title = pr_key WHERE title IS NULL OR trim(title) = '';
|
package/openapi.yaml
CHANGED
|
@@ -668,6 +668,13 @@ components:
|
|
|
668
668
|
description: >-
|
|
669
669
|
Opt in to sharing a custom integration base branch with another already-active epic. See
|
|
670
670
|
`PlanStartByIssue.allowSharedBase`.
|
|
671
|
+
customInstructions:
|
|
672
|
+
type: string
|
|
673
|
+
maxLength: 8000
|
|
674
|
+
description: >-
|
|
675
|
+
OPTIONAL free-text steering appended to the implementation agent's prompt for this run
|
|
676
|
+
(via the implement task's `appendPrompt`). Blank/whitespace is treated as absent. Persists
|
|
677
|
+
on the instance, so it also applies to the agent's answer-loop redispatch.
|
|
671
678
|
FeatureStartByUrl:
|
|
672
679
|
type: object
|
|
673
680
|
additionalProperties: false
|
|
@@ -698,6 +705,12 @@ components:
|
|
|
698
705
|
allowSharedBase:
|
|
699
706
|
type: boolean
|
|
700
707
|
description: Share a custom integration base with another active epic. See `PlanStartByIssue.allowSharedBase`.
|
|
708
|
+
customInstructions:
|
|
709
|
+
type: string
|
|
710
|
+
maxLength: 8000
|
|
711
|
+
description: >-
|
|
712
|
+
OPTIONAL free-text steering appended to the implementation agent's prompt for this run.
|
|
713
|
+
See `FeatureStartByIssue.customInstructions`.
|
|
701
714
|
MessageResult:
|
|
702
715
|
type: object
|
|
703
716
|
description: The result of publishing a message / answering an escalation. Shape varies by message
|
|
@@ -115,12 +115,18 @@ export default defineOperation("startFeature", async ({ body }, app) => {
|
|
|
115
115
|
// Auto-merge is only meaningful as a follow-on to convergence; pin it off when converge is off so
|
|
116
116
|
// the persisted row and the process variable can't disagree.
|
|
117
117
|
const autoMerge = converge && "autoMerge" in body && body.autoMerge === true;
|
|
118
|
-
|
|
118
|
+
// Optional operator steering threaded to the implementation agent's prompt. Empty/whitespace is
|
|
119
|
+
// normalized to null downstream (startFeature) so it never appends an empty instruction block.
|
|
120
|
+
const customInstructions = "customInstructions" in body && typeof body.customInstructions === "string"
|
|
121
|
+
? body.customInstructions
|
|
122
|
+
: null;
|
|
123
|
+
const result = await startFeature(app.data, app.engine, parsed, normalizedBase, converge, autoMerge, customInstructions);
|
|
119
124
|
app.log.info("feature run started", {
|
|
120
125
|
featureKey: parsed.planKey,
|
|
121
126
|
requestedBaseBranch: normalizedBase,
|
|
122
127
|
converge,
|
|
123
128
|
autoMerge,
|
|
129
|
+
hasCustomInstructions: typeof customInstructions === "string" && customInstructions.trim() !== "",
|
|
124
130
|
alreadyRunning: "alreadyRunning" in result && result.alreadyRunning === true,
|
|
125
131
|
});
|
|
126
132
|
return { status: 202, body: result };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.75.0",
|
|
4
4
|
"description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "main.ts",
|
|
@@ -55,7 +55,7 @@
|
|
|
55
55
|
"filter": [{ "field": "plan_key", "eqParam": true }]
|
|
56
56
|
},
|
|
57
57
|
"columns": [
|
|
58
|
-
{ "field": "
|
|
58
|
+
{ "field": "title", "template": "{{title}}", "header": "Item", "linkField": "issue_url" },
|
|
59
59
|
{ "field": "status", "header": "Status", "link": { "kind": "processExplorer", "keyField": "process_key" } },
|
|
60
60
|
{ "field": "delivery", "header": "Delivery" },
|
|
61
61
|
{ "field": "wave_label", "header": "Wave" },
|
package/pages/epic.page.json
CHANGED
|
@@ -73,7 +73,7 @@
|
|
|
73
73
|
{ "label": "All", "filter": [] }
|
|
74
74
|
],
|
|
75
75
|
"columns": [
|
|
76
|
-
{ "field": "
|
|
76
|
+
{ "field": "title", "template": "{{title}}", "header": "Item", "link": { "kind": "page", "page": "epic-detail", "keyField": "plan_key" } },
|
|
77
77
|
{ "field": "status", "header": "Status", "link": { "kind": "processExplorer", "keyField": "process_key" } },
|
|
78
78
|
{ "field": "delivery", "header": "Delivery" },
|
|
79
79
|
{ "field": "base_branch", "header": "Base branch" },
|
package/pages/feature.page.json
CHANGED
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
"fields": [
|
|
43
43
|
{ "key": "issue", "label": "owner/repo#123 or a GitHub issue URL", "type": "text", "required": true, "requiredMessage": "An issue reference is required (owner/repo#123 or an issue URL)" },
|
|
44
44
|
{ "key": "baseBranch", "label": "Base branch: the branch the PR targets, e.g. main. A missing epic/* branch is auto-created off default HEAD; a non-epic/* branch must already exist.", "type": "text", "required": true, "requiredMessage": "Name the branch the PR targets (e.g. main or epic/agent-protocol)" },
|
|
45
|
+
{ "key": "customInstructions", "label": "Custom instructions (optional) \u2014 extra steering appended to the implementation agent's prompt, e.g. \u201cprefer Deno; keep the diff small; add integration tests\u201d", "type": "text" },
|
|
45
46
|
{ "key": "converge", "label": "Converge \u2014 hand the raised PR to the review-convergence loop", "type": "checkbox" },
|
|
46
47
|
{ "key": "autoMerge", "label": "Auto-merge \u2014 after convergence, drive the merge-loop (only applies when Converge is on)", "type": "checkbox" },
|
|
47
48
|
{ "key": "confirmDefaultBase", "label": "Confirm landing on the default branch \u2014 required only when the base above IS the repository default", "type": "checkbox" },
|
|
@@ -69,7 +70,7 @@
|
|
|
69
70
|
{ "label": "All", "filter": [] }
|
|
70
71
|
],
|
|
71
72
|
"columns": [
|
|
72
|
-
{ "field": "
|
|
73
|
+
{ "field": "title", "template": "{{title}}", "header": "Item", "linkField": "issue_url" },
|
|
73
74
|
{ "field": "status", "header": "Status", "link": { "kind": "processExplorer", "keyField": "process_key" } },
|
|
74
75
|
{ "field": "escalation_question", "header": "Escalation" },
|
|
75
76
|
{ "field": "base_branch", "header": "Base branch" },
|
package/pages/home.page.json
CHANGED
package/pages/overview.page.json
CHANGED
|
@@ -63,15 +63,9 @@
|
|
|
63
63
|
]
|
|
64
64
|
},
|
|
65
65
|
"columns": [
|
|
66
|
-
{ "field": "
|
|
66
|
+
{ "field": "title", "template": "{{title}}", "header": "Item", "link": { "kind": "page", "page": "home", "keyField": "pr_key" } },
|
|
67
67
|
{ "field": "status", "header": "Status", "link": { "kind": "processExplorer", "keyField": "process_key" } },
|
|
68
|
-
{
|
|
69
|
-
"field": "incident_message",
|
|
70
|
-
"header": "Incident",
|
|
71
|
-
"badge": { "tone": "danger", "label": "1" }
|
|
72
|
-
},
|
|
73
|
-
{ "field": "current_round", "header": "Round" },
|
|
74
|
-
{ "field": "active_worker", "header": "Agent" },
|
|
68
|
+
{ "field": "current_round", "template": "{{current_round}} · {{active_worker}}", "header": "Round · Agent" },
|
|
75
69
|
{ "field": "updated_at", "header": "Updated" }
|
|
76
70
|
]
|
|
77
71
|
}
|
|
@@ -94,13 +88,9 @@
|
|
|
94
88
|
"filter": [{ "field": "status", "in": ["planning", "dispatched"] }]
|
|
95
89
|
},
|
|
96
90
|
"columns": [
|
|
97
|
-
{ "field": "
|
|
91
|
+
{ "field": "title", "template": "{{title}}", "header": "Item", "link": { "kind": "page", "page": "epic-detail", "keyField": "plan_key" } },
|
|
98
92
|
{ "field": "status", "header": "Status", "link": { "kind": "processExplorer", "keyField": "process_key" } },
|
|
99
|
-
{ "field": "delivery", "header": "Delivery" },
|
|
100
|
-
{ "field": "base_branch", "header": "Base branch" },
|
|
101
93
|
{ "field": "wave_label", "header": "Wave" },
|
|
102
|
-
{ "field": "task_count", "header": "Tasks" },
|
|
103
|
-
{ "field": "issue_number", "header": "Issue", "linkField": "issue_url" },
|
|
104
94
|
{ "field": "updated_at", "header": "Updated" }
|
|
105
95
|
]
|
|
106
96
|
}
|
|
@@ -123,14 +113,9 @@
|
|
|
123
113
|
"filter": [{ "field": "status", "in": ["running", "escalated", "awaiting_operator"] }]
|
|
124
114
|
},
|
|
125
115
|
"columns": [
|
|
126
|
-
{ "field": "
|
|
116
|
+
{ "field": "title", "template": "{{title}}", "header": "Item", "link": { "kind": "page", "page": "feature", "keyField": "feature_key" } },
|
|
127
117
|
{ "field": "status", "header": "Status", "link": { "kind": "processExplorer", "keyField": "process_key" } },
|
|
128
|
-
{ "field": "
|
|
129
|
-
{ "field": "base_branch", "header": "Base branch" },
|
|
130
|
-
{ "field": "pr_key", "header": "PR", "link": { "kind": "page", "page": "home", "keyField": "pr_key" } },
|
|
131
|
-
{ "field": "converge", "header": "Converge" },
|
|
132
|
-
{ "field": "auto_merge", "header": "Auto-merge" },
|
|
133
|
-
{ "field": "outcome", "header": "Outcome" },
|
|
118
|
+
{ "field": "delivery_label", "header": "Delivery" },
|
|
134
119
|
{ "field": "updated_at", "header": "Updated" }
|
|
135
120
|
],
|
|
136
121
|
"rowActions": [
|
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
<zeebe:linkedResource resourceId="feature.md" bindingType="latest" resourceType="GenericScript" linkName="prompt" />
|
|
45
45
|
</zeebe:linkedResources>
|
|
46
46
|
<zeebe:ioMapping>
|
|
47
|
-
<zeebe:input source="=" --- " + task.prompt + (if (baseBranchBrief = null) then "" else baseBranchBrief)" target="appendPrompt" />
|
|
47
|
+
<zeebe:input source="=" --- " + task.prompt + (if (baseBranchBrief = null) then "" else baseBranchBrief) + (if (customInstructions = null) then "" else " --- ## Operator custom instructions The operator supplied these instructions for this run — follow them: " + customInstructions)" target="appendPrompt" />
|
|
48
48
|
<zeebe:output source="=status" target="status" />
|
|
49
49
|
<zeebe:output source="=question" target="question" />
|
|
50
50
|
</zeebe:ioMapping>
|