@nanobpm/nano-workforce 0.28.0 → 0.30.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,78 @@
1
+ import { assert, assertEquals, assertStringIncludes } from "jsr:@std/assert@1";
2
+ import type { DataLayer } from "@nanobpm/urban";
3
+ import { appendEntry } from "../../app/blackboard.ts";
4
+ import handler from "./worker.ts";
5
+
6
+ // deno-lint-ignore no-explicit-any
7
+ function memData(): { data: DataLayer; stores: Record<string, any[]> } {
8
+ // deno-lint-ignore no-explicit-any
9
+ const stores: Record<string, any[]> = {};
10
+ const seq: Record<string, number> = {};
11
+ function tbl(name: string, pk = "id") {
12
+ // deno-lint-ignore no-explicit-any
13
+ const rows = (stores[name] ??= [] as any[]);
14
+ // deno-lint-ignore no-explicit-any
15
+ const match = (r: any, where: any) => Object.entries(where).every(([k, v]) => r[k] === v);
16
+ return {
17
+ // deno-lint-ignore no-explicit-any require-await
18
+ async insert(row: any) {
19
+ const id = (seq[name] = (seq[name] ?? 0) + 1);
20
+ rows.push(pk === "id" ? { id, ...row } : { ...row });
21
+ return pk === "id" ? id : row[pk];
22
+ },
23
+ // deno-lint-ignore no-explicit-any require-await
24
+ async find(where: any = {}) {
25
+ return rows.filter((r) => match(r, where));
26
+ },
27
+ // deno-lint-ignore no-explicit-any require-await
28
+ async findOne(where: any = {}) {
29
+ return rows.find((r) => match(r, where));
30
+ },
31
+ // deno-lint-ignore no-explicit-any require-await
32
+ async get(id: any) {
33
+ return rows.find((row) => row[pk] === id);
34
+ },
35
+ // deno-lint-ignore no-explicit-any require-await
36
+ async update() {},
37
+ };
38
+ }
39
+ // deno-lint-ignore no-explicit-any
40
+ const data = { table: (n: string, pk?: string) => tbl(n, pk) } as any as DataLayer;
41
+ return { data, stores };
42
+ }
43
+
44
+ Deno.test("retro-gather: emits a digest brief + learning count for the plan", async () => {
45
+ const { data, stores } = memData();
46
+ stores["plans"] = [{ plan_key: "o/r#3", repo: "o/r", issue_url: "https://x/3", title: "Epic" }];
47
+ await appendEntry(data, "o/r#3", { author_task: "t1", kind: "learning", body: "regen before build" });
48
+ await appendEntry(data, "o/r#3", { author_task: "t2", kind: "learning", body: "use nextest" });
49
+
50
+ const app = { data, log: () => undefined };
51
+ const out = await handler(
52
+ // deno-lint-ignore no-explicit-any
53
+ { variables: { planKey: "o/r#3" } } as any,
54
+ // deno-lint-ignore no-explicit-any
55
+ app as any,
56
+ ) as Record<string, unknown>;
57
+
58
+ assertEquals(out.retroLearnings, 2);
59
+ assertStringIncludes(String(out.retroDigest), "regen before build");
60
+ assertStringIncludes(String(out.retroDigest), "use nextest");
61
+ assertStringIncludes(String(out.retroDigest), "o/r#3");
62
+ });
63
+
64
+ Deno.test("retro-gather: an epic with no learnings still renders a valid brief", async () => {
65
+ const { data, stores } = memData();
66
+ stores["plans"] = [{ plan_key: "o/r#4", repo: "o/r", issue_url: "", title: null }];
67
+ const app = { data, log: () => undefined };
68
+ const out = await handler(
69
+ // deno-lint-ignore no-explicit-any
70
+ { variables: { planKey: "o/r#4" } } as any,
71
+ // deno-lint-ignore no-explicit-any
72
+ app as any,
73
+ ) as Record<string, unknown>;
74
+
75
+ assertEquals(out.retroLearnings, 0);
76
+ assert(typeof out.retroDigest === "string");
77
+ assertStringIncludes(String(out.retroDigest), "none");
78
+ });
@@ -0,0 +1,28 @@
1
+ // pr.retro-gather — first step of the `retro` process. Assemble the plan's accumulated
2
+ // coordination knowledge (the `learning` blackboard entries agents posted while implementing, plus
3
+ // the task-delta rollup and any other blackboard notes) into a compact markdown brief, and emit it
4
+ // as `retroDigest`. The next step maps that onto the `senior:retro` agent's `appendPrompt`, so the
5
+ // agent reflects on real material rather than re-deriving it.
6
+ import type { AppJobHandler } from "@nanobpm/urban";
7
+ import { gatherRetro, renderRetroBrief } from "../../app/retro.ts";
8
+
9
+ interface In extends Record<string, unknown> {
10
+ planKey: string;
11
+ }
12
+
13
+ interface Out extends Record<string, unknown> {
14
+ retroDigest: string;
15
+ retroLearnings: number;
16
+ }
17
+
18
+ const handler: AppJobHandler<In, Out> = async (job, app) => {
19
+ const planKey = job.variables.planKey;
20
+ const digest = await gatherRetro(app.data, planKey);
21
+ app.log("info", `retro-gather: ${planKey} — ${digest.counts.learnings} learnings, ${digest.counts.deltas} deltas`);
22
+ return {
23
+ retroDigest: renderRetroBrief(digest),
24
+ retroLearnings: digest.counts.learnings,
25
+ };
26
+ };
27
+
28
+ export default handler;
@@ -0,0 +1,127 @@
1
+ import { assertEquals } from "jsr:@std/assert@1";
2
+ import handler from "./worker.ts";
3
+
4
+ function fakeApp() {
5
+ // deno-lint-ignore no-explicit-any
6
+ const stores: Record<string, any[]> = { plan_retros: [] };
7
+ const seq: Record<string, number> = {};
8
+ function tbl(name: string, pk = "id") {
9
+ // deno-lint-ignore no-explicit-any
10
+ const rows = (stores[name] ??= [] as any[]);
11
+ // deno-lint-ignore no-explicit-any
12
+ const match = (r: any, where: any) => Object.entries(where).every(([k, v]) => r[k] === v);
13
+ return {
14
+ // deno-lint-ignore no-explicit-any require-await
15
+ async insert(row: any) {
16
+ const id = (seq[name] = (seq[name] ?? 0) + 1);
17
+ rows.push(pk === "id" ? { id, ...row } : { ...row });
18
+ return pk === "id" ? id : row[pk];
19
+ },
20
+ // deno-lint-ignore no-explicit-any require-await
21
+ async find(where: any = {}) {
22
+ return rows.filter((r) => match(r, where));
23
+ },
24
+ // deno-lint-ignore no-explicit-any require-await
25
+ async findOne(where: any = {}) {
26
+ return rows.find((r) => match(r, where));
27
+ },
28
+ // deno-lint-ignore no-explicit-any require-await
29
+ async get(id: any) {
30
+ return rows.find((row) => row[pk] === id);
31
+ },
32
+ // deno-lint-ignore no-explicit-any require-await
33
+ async update(id: any, patch: any) {
34
+ const r = rows.find((row) => row[pk] === id);
35
+ if (r) Object.assign(r, patch);
36
+ },
37
+ };
38
+ }
39
+ const app = { data: { table: (n: string, pk?: string) => tbl(n, pk) }, log: () => undefined };
40
+ return { app, stores };
41
+ }
42
+
43
+ Deno.test("retro-record: persists a filed retro from hoisted result vars", async () => {
44
+ const { app, stores } = fakeApp();
45
+ await handler(
46
+ // deno-lint-ignore no-explicit-any
47
+ {
48
+ variables: {
49
+ planKey: "o/r#5",
50
+ retroLearnings: 4,
51
+ status: "filed",
52
+ pr: "o/r#42",
53
+ summary: "promoted 2 lessons",
54
+ "io.nanobpm.agentResult": { output: "the full report" },
55
+ },
56
+ } as any,
57
+ // deno-lint-ignore no-explicit-any
58
+ app as any,
59
+ );
60
+
61
+ assertEquals(stores.plan_retros.length, 1);
62
+ const row = stores.plan_retros[0];
63
+ assertEquals(row.status, "filed");
64
+ assertEquals(row.pr_key, "o/r#42");
65
+ assertEquals(row.learnings, 4);
66
+ assertEquals(row.summary, "promoted 2 lessons");
67
+ assertEquals(row.report, "the full report");
68
+ });
69
+
70
+ Deno.test("retro-record: defaults to skipped when the agent filed no PR", async () => {
71
+ const { app, stores } = fakeApp();
72
+ await handler(
73
+ // deno-lint-ignore no-explicit-any
74
+ { variables: { planKey: "o/r#6", summary: "nothing durable" } } as any,
75
+ // deno-lint-ignore no-explicit-any
76
+ app as any,
77
+ );
78
+ assertEquals(stores.plan_retros[0].status, "skipped");
79
+ assertEquals(stores.plan_retros[0].pr_key, null);
80
+ });
81
+
82
+ Deno.test("retro-record: honours an explicit blocked status", async () => {
83
+ const { app, stores } = fakeApp();
84
+ await handler(
85
+ // deno-lint-ignore no-explicit-any
86
+ { variables: { planKey: "o/r#7", status: "blocked", summary: "no write access" } } as any,
87
+ // deno-lint-ignore no-explicit-any
88
+ app as any,
89
+ );
90
+ assertEquals(stores.plan_retros[0].status, "blocked");
91
+ });
92
+
93
+ Deno.test("retro-record: ignores a PR unless the status is filed", async () => {
94
+ const { app, stores } = fakeApp();
95
+ await handler(
96
+ // deno-lint-ignore no-explicit-any
97
+ { variables: { planKey: "o/r#8", status: "skipped", pr: "o/r#43", summary: "not durable" } } as any,
98
+ // deno-lint-ignore no-explicit-any
99
+ app as any,
100
+ );
101
+ assertEquals(stores.plan_retros[0].status, "skipped");
102
+ assertEquals(stores.plan_retros[0].pr_key, null);
103
+ });
104
+
105
+ Deno.test("retro-record: defaults invalid status from PR presence", async () => {
106
+ const { app, stores } = fakeApp();
107
+ await handler(
108
+ // deno-lint-ignore no-explicit-any
109
+ { variables: { planKey: "o/r#9", status: "done", pr: "o/r#44" } } as any,
110
+ // deno-lint-ignore no-explicit-any
111
+ app as any,
112
+ );
113
+ assertEquals(stores.plan_retros[0].status, "filed");
114
+ assertEquals(stores.plan_retros[0].pr_key, "o/r#44");
115
+ });
116
+
117
+ Deno.test("retro-record: coerces filed without a PR to skipped", async () => {
118
+ const { app, stores } = fakeApp();
119
+ await handler(
120
+ // deno-lint-ignore no-explicit-any
121
+ { variables: { planKey: "o/r#10", status: "filed", summary: "forgot the PR" } } as any,
122
+ // deno-lint-ignore no-explicit-any
123
+ app as any,
124
+ );
125
+ assertEquals(stores.plan_retros[0].status, "skipped");
126
+ assertEquals(stores.plan_retros[0].pr_key, null);
127
+ });
@@ -0,0 +1,60 @@
1
+ // pr.retro-record — final step of the `retro` process. Persist the `senior:retro` agent's result
2
+ // into `plan_retros` (016_plan_retro.sql): the outcome status, the promotion PR it opened on the
3
+ // target repo (if any), the learning count it distilled, and its summary/report. Advisory only —
4
+ // this gates no control flow; it exists so the epic surface can show what the retro concluded.
5
+ //
6
+ // The agentTask runner hoists the agent's result-JSON keys (`status`, `pr`, `summary`) into
7
+ // top-level process variables (same as pr.record-plan-review reads `job.variables.approved`), and
8
+ // exposes the raw transcript under the `io.nanobpm.agentResult` envelope's `.output`.
9
+ import type { AppJobHandler } from "@nanobpm/urban";
10
+ import { recordRetro } from "../../app/retro.ts";
11
+
12
+ const AGENT_RESULT_KEY = "io.nanobpm.agentResult";
13
+ const VALID_STATUSES = new Set(["filed", "skipped", "blocked"]);
14
+
15
+ interface In extends Record<string, unknown> {
16
+ planKey: string;
17
+ retroLearnings?: number;
18
+ status?: unknown; // filed | skipped | blocked
19
+ pr?: unknown; // "<owner>/<repo>#<n>" of the promotion PR, when filed
20
+ summary?: unknown;
21
+ }
22
+
23
+ function asStr(v: unknown): string | null {
24
+ return typeof v === "string" && v.trim() !== "" ? v.trim() : null;
25
+ }
26
+
27
+ function asStatus(v: unknown, hasPr: boolean): "filed" | "skipped" | "blocked" {
28
+ const s = asStr(v);
29
+ if (s && VALID_STATUSES.has(s)) {
30
+ if (s === "filed" && !hasPr) return "skipped";
31
+ return s as "filed" | "skipped" | "blocked";
32
+ }
33
+ return hasPr ? "filed" : "skipped";
34
+ }
35
+
36
+ const handler: AppJobHandler<In> = async (job, app) => {
37
+ const planKey = job.variables.planKey;
38
+
39
+ const rawPrKey = asStr(job.variables.pr);
40
+ // Default to "filed" only when a PR is present; otherwise the agent decided not to file.
41
+ const status = asStatus(job.variables.status, rawPrKey !== null);
42
+ const prKey = status === "filed" ? rawPrKey : null;
43
+ const summary = asStr(job.variables.summary);
44
+
45
+ const env = job.variables[AGENT_RESULT_KEY] as { output?: unknown } | undefined;
46
+ const report = typeof env?.output === "string" ? env.output : null;
47
+
48
+ await recordRetro(app.data, planKey, {
49
+ status,
50
+ prKey,
51
+ learnings: typeof job.variables.retroLearnings === "number" ? job.variables.retroLearnings : 0,
52
+ summary,
53
+ report,
54
+ });
55
+
56
+ app.log("info", `retro-record: ${planKey} — status=${status}${prKey ? ` pr=${prKey}` : ""}`);
57
+ return {};
58
+ };
59
+
60
+ export default handler;