@nanobpm/nano-workforce 0.107.0 → 0.107.1

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 CHANGED
@@ -1,3 +1,10 @@
1
+ ## [0.107.1](https://github.com/nanobpm/nano-workforce/compare/v0.107.0...v0.107.1) (2026-08-20)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * **merge-loop:** converge PRs merged/closed out-of-band at every merge-stage wait ([#370](https://github.com/nanobpm/nano-workforce/issues/370)) ([c673c82](https://github.com/nanobpm/nano-workforce/commit/c673c823b6ed5dde5710c4cb677f7c6f01ae59de)), closes [nanobpm/nano-workforce#368](https://github.com/nanobpm/nano-workforce/issues/368)
7
+
1
8
  # [0.107.0](https://github.com/nanobpm/nano-workforce/compare/v0.106.3...v0.107.0) (2026-08-20)
2
9
 
3
10
 
@@ -0,0 +1,178 @@
1
+ // Class regression guard for the out-of-band terminal escape shared by EVERY merge-stage durable
2
+ // wait (issue #368).
3
+ //
4
+ // #368: a PR merged (or closed) OUT-OF-BAND — a maintainer clicks Merge, or a mergify queue lands
5
+ // it — while its merge-loop instance is parked at a durable GitHub wait can silently wedge forever.
6
+ // The `waiting_deps` branch of `pollMerges` only advanced a PR when its *declared dependencies*
7
+ // merged and had NO check on the PR itself already being merged: if those deps never cleared, the
8
+ // instance sat at `wait-deps` forever (ACTIVE, no incident, no timer boundary). `waiting_merge`
9
+ // already guarded this; the fix lifts that guard into ONE shared pre-check
10
+ // (`advanceIfTerminalOutOfBand`) run at the top of all four merge-stage waits — `waiting_deps`,
11
+ // `waiting_merge`, `waiting_lane`, `queued` — so no stage can strand on an out-of-band terminal
12
+ // transition. Each wait subscribes to a DIFFERENT catch, so the pre-check must publish the escape
13
+ // message THAT wait correlates to; this test asserts the class over every (status × merged/closed).
14
+ import { test } from "node:test";
15
+ import { assertEquals } from "#test-assert";
16
+ import type { DataLayer, EngineClient } from "@nanobpm/urban";
17
+ import { pollMerges } from "./service.ts";
18
+
19
+ // In-memory record gateway (get/find/insert/update/delete), matching app/promotionPoll.test.ts.
20
+ function memData(): { data: DataLayer; stores: Record<string, any[]> } {
21
+ const stores: Record<string, any[]> = {};
22
+ function tbl(name: string, pk = "id") {
23
+ const rows = (stores[name] ??= [] as any[]);
24
+ const match = (r: any, where: any) => Object.entries(where).every(([k, v]) => r[k] === v);
25
+ return {
26
+ async all() {
27
+ return rows.slice();
28
+ },
29
+ async get(id: any) {
30
+ return rows.find((r) => r[pk] === id);
31
+ },
32
+ async find(where: any = {}) {
33
+ return rows.filter((r) => match(r, where));
34
+ },
35
+ async insert(row: any) {
36
+ rows.push({ ...row });
37
+ return row[pk];
38
+ },
39
+ async update(id: any, patch: any) {
40
+ const r = rows.find((row) => row[pk] === id);
41
+ if (r) Object.assign(r, patch);
42
+ },
43
+ async delete(id: any) {
44
+ for (let i = rows.length - 1; i >= 0; i--) if (rows[i][pk] === id) rows.splice(i, 1);
45
+ },
46
+ };
47
+ }
48
+ const data = { table: (n: string, pk?: string) => tbl(n, pk) } as any as DataLayer;
49
+ return { data, stores };
50
+ }
51
+
52
+ // Records every message the poller publishes so a test can assert the exact escape.
53
+ function recordingEngine(): { engine: EngineClient; messages: any[] } {
54
+ const messages: any[] = [];
55
+ const engine = {
56
+ async publishMessage(msg: any) {
57
+ messages.push(msg);
58
+ },
59
+ } as any as EngineClient;
60
+ return { engine, messages };
61
+ }
62
+
63
+ // A token-transport GitHub stub. Serves `GET /repos/{repo}/pulls/{n}` from a per-number liveness
64
+ // map so `fetchPrState` (→ `classifyPrLiveness`) reads "merged" / "closed" / "open".
65
+ type Live = "merged" | "closed" | "open";
66
+ function githubFetch(states: Map<number, Live>) {
67
+ return (url: string | URL | Request, _init?: RequestInit): Promise<Response> => {
68
+ const u = new URL(String(url));
69
+ const json = (obj: unknown, status = 200) =>
70
+ Promise.resolve(new Response(JSON.stringify(obj), { status, headers: { "content-type": "application/json" } }));
71
+ const m = u.pathname.match(/\/repos\/[^/]+\/[^/]+\/pulls\/(\d+)$/);
72
+ if (m) {
73
+ const n = Number(m[1]);
74
+ const live = states.get(n) ?? "open";
75
+ return json({
76
+ merged: live === "merged",
77
+ merged_at: live === "merged" ? "2026-08-20T02:35:42Z" : null,
78
+ state: live === "open" ? "open" : "closed",
79
+ mergeable_state: live === "open" ? "clean" : "unknown",
80
+ draft: false,
81
+ head: { sha: "deadbeef" },
82
+ });
83
+ }
84
+ return Promise.resolve(new Response(`unexpected ${u.pathname}`, { status: 500 }));
85
+ };
86
+ }
87
+
88
+ async function withGithub<T>(states: Map<number, Live>, fn: () => Promise<T>): Promise<T> {
89
+ const prevMode = process.env["NANO_PR_GITHUB_TRANSPORT"];
90
+ const prevFetch = globalThis.fetch;
91
+ process.env["NANO_PR_GITHUB_TRANSPORT"] = "token";
92
+ globalThis.fetch = githubFetch(states) as typeof fetch;
93
+ try {
94
+ return await fn();
95
+ } finally {
96
+ globalThis.fetch = prevFetch;
97
+ if (prevMode === undefined) delete process.env["NANO_PR_GITHUB_TRANSPORT"];
98
+ else process.env["NANO_PR_GITHUB_TRANSPORT"] = prevMode;
99
+ }
100
+ }
101
+
102
+ function prRow(prKey: string, number: number, status: string) {
103
+ const ts = "2026-08-20T00:00:00Z";
104
+ return {
105
+ pr_key: prKey,
106
+ repo: "o/r",
107
+ number,
108
+ url: `https://github.com/o/r/pull/${number}`,
109
+ title: "t",
110
+ status,
111
+ current_round: 0,
112
+ process_key: null,
113
+ waiting_since: null,
114
+ last_review_id: null,
115
+ outcome: null,
116
+ created_at: ts,
117
+ updated_at: ts,
118
+ converged_at: null,
119
+ merged_at: null,
120
+ };
121
+ }
122
+
123
+ // Every merge-stage durable wait, with the escape message its parked catch subscribes to. The
124
+ // escape differs per wait because each subscribes to a different message — the whole point of the
125
+ // shared pre-check is to publish the RIGHT one so it correlates instead of being dropped.
126
+ const CASES: { status: string; merged: string; closed: string }[] = [
127
+ // wait-deps subscribes only `deps-cleared` (→ arm-merge → wait-mergeable, where block 2 converges).
128
+ { status: "waiting_deps", merged: "deps-cleared", closed: "deps-cleared" },
129
+ // wait-mergeable subscribes `merge-ready` (→ gw-mergeable → attempt-merge terminal short-circuits).
130
+ { status: "waiting_merge", merged: "merge-ready", closed: "merge-ready" },
131
+ // waiting_lane is an app hold that leaves the process on wait-mergeable → also `merge-ready`.
132
+ { status: "waiting_lane", merged: "merge-ready", closed: "merge-ready" },
133
+ // wait-landed subscribes `merge-landed` (→ mark-merged) and `merge-evicted` (→ arm-merge). A
134
+ // merged queue PR lands; a closed one can never land, so re-arm and let block 2 abandon it.
135
+ { status: "queued", merged: "merge-landed", closed: "merge-evicted" },
136
+ ];
137
+
138
+ for (const c of CASES) {
139
+ for (const live of ["merged", "closed"] as const) {
140
+ test(`out-of-band ${live} PR at ${c.status} converges via ${live === "merged" ? c.merged : c.closed}`, async () => {
141
+ const { data, stores } = memData();
142
+ const { engine, messages } = recordingEngine();
143
+ stores["pull_requests"] = [prRow("o/r#100", 100, c.status)];
144
+ // A declared dependency that has NOT merged — the exact condition that wedged `waiting_deps`:
145
+ // the deps loop would never clear, so ONLY the PR's own terminal state can converge it.
146
+ stores["pr_dependencies"] = [{ pr_key: "o/r#100", depends_on_key: "o/r#200", created_at: "t" }];
147
+
148
+ await withGithub(new Map<number, Live>([[100, live], [200, "open"]]), () =>
149
+ pollMerges(data, engine, "tok"),
150
+ );
151
+
152
+ assertEquals(messages.length, 1, `expected exactly one escape message for ${c.status}/${live}`);
153
+ const expected = live === "merged" ? c.merged : c.closed;
154
+ assertEquals(messages[0].name, expected);
155
+ assertEquals(messages[0].correlationKey, "o/r#100");
156
+ if (expected === "merge-ready") assertEquals(messages[0].variables.mergeState, "ready");
157
+ // Flipped onto the transient `merging` status so a slow pass can't double-signal.
158
+ assertEquals(stores["pull_requests"][0].status, "merging");
159
+ });
160
+ }
161
+ }
162
+
163
+ // Negative guard: a still-OPEN `waiting_deps` PR whose declared dep is unmerged must NOT be forced
164
+ // terminal by the pre-check — it stays parked (no escape published), proving the pre-check fires
165
+ // only on a real out-of-band terminal transition and never drops a live PR.
166
+ test("still-open waiting_deps PR with an unmerged dep publishes nothing and stays parked", async () => {
167
+ const { data, stores } = memData();
168
+ const { engine, messages } = recordingEngine();
169
+ stores["pull_requests"] = [prRow("o/r#100", 100, "waiting_deps")];
170
+ stores["pr_dependencies"] = [{ pr_key: "o/r#100", depends_on_key: "o/r#200", created_at: "t" }];
171
+
172
+ await withGithub(new Map<number, Live>([[100, "open"], [200, "open"]]), () =>
173
+ pollMerges(data, engine, "tok"),
174
+ );
175
+
176
+ assertEquals(messages.length, 0);
177
+ assertEquals(stores["pull_requests"][0].status, "waiting_deps");
178
+ });
package/app/service.ts CHANGED
@@ -1014,6 +1014,87 @@ async function mirrorTaskStatusForPr(data: DataLayer, prKey: string, status: "op
1014
1014
  }
1015
1015
  }
1016
1016
 
1017
+ /** The escape message a merge-stage durable wait must publish when its PR has gone terminal
1018
+ * (merged/closed) OUT-OF-BAND — i.e. someone landed or closed it on GitHub while the process was
1019
+ * parked, so the wait's own declared trigger (deps clearing, a mergeable verdict, a lane release, a
1020
+ * queue landing) may never fire. Each wait subscribes to a DIFFERENT message, so the escape MUST be
1021
+ * the one its parked catch actually correlates to (publishing any other name is dropped by the
1022
+ * engine and re-wedges the PR in the transient `merging` status). All roads lead to the same proven
1023
+ * terminal path — `attempt-merge`'s idempotent already-merged / closed short-circuits (#368):
1024
+ * • waiting_deps → parked at `wait-deps`, subscribes ONLY `deps-cleared`. Deps are moot once the
1025
+ * PR itself landed, so clear them regardless of liveness; `deps-cleared` → `arm-merge` →
1026
+ * `wait-mergeable`, where block 2 (below) reads the terminal state and drives merged→mark-merged
1027
+ * / closed→abandon. This is the gap the incident hit — `wait-deps` had NO self-merged escape.
1028
+ * • waiting_merge / waiting_lane → both parked at `wait-mergeable` (waiting_lane is an app-internal
1029
+ * hold that leaves the process on `wait-mergeable`), which subscribes `merge-ready`. Route a
1030
+ * `ready` verdict through `gw-mergeable → attempt-merge`, whose short-circuits complete/abandon.
1031
+ * • queued → parked at `wait-landed`, subscribes `merge-landed` (→ mark-merged) and `merge-evicted`
1032
+ * (→ arm-merge). A merged queue PR lands (`merge-landed`); a closed-unmerged one can NEVER land,
1033
+ * so publishing `merge-landed` would falsely mark it merged — re-arm via `merge-evicted` instead
1034
+ * and let block 2 abandon it on the next pass. */
1035
+ function outOfBandEscapeMessage(
1036
+ status: string,
1037
+ liveness: "merged" | "closed",
1038
+ prKey: string,
1039
+ ): Parameters<EngineClient["publishMessage"]>[0] {
1040
+ switch (status) {
1041
+ case "waiting_deps":
1042
+ return { name: "deps-cleared", correlationKey: prKey, variables: {} };
1043
+ case "waiting_merge":
1044
+ case "waiting_lane":
1045
+ return {
1046
+ name: "merge-ready",
1047
+ correlationKey: prKey,
1048
+ variables: { mergeState: "ready", failingChecks: 0, failingChecksList: "" },
1049
+ };
1050
+ case "queued":
1051
+ return liveness === "merged"
1052
+ ? { name: "merge-landed", correlationKey: prKey, variables: {} }
1053
+ : { name: "merge-evicted", correlationKey: prKey, variables: {} };
1054
+ default:
1055
+ // Unreachable: only the four merge-stage durable waits call this. Fail loud rather than
1056
+ // mis-route a message the parked catch can't correlate (which would silently re-wedge the PR).
1057
+ throw new Error(`outOfBandEscapeMessage: unexpected merge-stage status ${JSON.stringify(status)}`);
1058
+ }
1059
+ }
1060
+
1061
+ /** ONE shared out-of-band terminal pre-check for EVERY merge-stage durable wait (#368). A PR parked
1062
+ * at any durable GitHub wait can be merged or closed out-of-band; without a per-branch check on the
1063
+ * PR's OWN state, a wait that keys only off its declared trigger (e.g. `waiting_deps`' declared
1064
+ * deps) strands its instance forever — ACTIVE, no incident, no timer boundary. `waiting_merge`
1065
+ * already guarded this; centralising the check here closes the whole class so no merge stage can
1066
+ * silently wedge on an out-of-band terminal transition.
1067
+ *
1068
+ * Reads the PR's live state (reusing an already-fetched `st` when the caller has one, e.g. block 2)
1069
+ * and, if terminal, publishes the escape message its parked catch subscribes to via
1070
+ * {@link flipToMergingThenPublish} — flipping to the transient `merging` so a slow pass can't
1071
+ * double-signal, reverting on a failed publish. Returns `true` when it advanced the PR (the caller
1072
+ * must `continue`), `false` when the PR is still live / unreadable and the caller should run its
1073
+ * normal per-status logic. Conservative: an unreadable (`null`) or ambiguous (`unknown`/open) state
1074
+ * never resolves terminal, so a false negative only costs a retry while dropping a live PR is
1075
+ * impossible. */
1076
+ async function advanceIfTerminalOutOfBand(
1077
+ data: DataLayer,
1078
+ engine: EngineClient,
1079
+ pr: { repo: string; number: number | string; pr_key: string; status: string },
1080
+ token: string,
1081
+ st?: PrState | null,
1082
+ ): Promise<boolean> {
1083
+ const state = st !== undefined ? st : await fetchPrState(pr.repo, pr.number, token);
1084
+ const liveness = classifyPrLiveness(state);
1085
+ if (liveness !== "merged" && liveness !== "closed") return false;
1086
+ const fromStatus = pr.status; // capture before flip: flipToMergingThenPublish mutates it to `merging`
1087
+ await flipToMergingThenPublish(
1088
+ data,
1089
+ engine,
1090
+ pr.pr_key,
1091
+ fromStatus,
1092
+ outOfBandEscapeMessage(fromStatus, liveness, pr.pr_key),
1093
+ );
1094
+ console.log(`[poller] out-of-band ${liveness} (${fromStatus}) -> ${pr.pr_key}`);
1095
+ return true;
1096
+ }
1097
+
1017
1098
  /** Merge-stage poll pass (SPEC §11). Four durable waits, each keyed off the PR's `status`, are
1018
1099
  * advanced by correlating a message — mirroring the review-ready pattern so the process owns
1019
1100
  * the wait and this glue only signals when a GitHub condition is met:
@@ -1023,12 +1104,20 @@ async function mirrorTaskStatusForPr(data: DataLayer, prKey: string, status: "op
1023
1104
  * • queued → the queued PR landed → `merge-landed`; or it conflicts (DIRTY) → `merge-evicted`
1024
1105
  * On publish we flip status to the transient `merging` (which no branch scans) so a slow pass
1025
1106
  * can't double-signal, exactly as `pollReviews` flips to `converging`; `flipToMergingThenPublish`
1026
- * reverts the flip if the publish fails so a failed handoff can't wedge the PR. */
1027
- async function pollMerges(data: DataLayer, engine: EngineClient, token: string) {
1107
+ * reverts the flip if the publish fails so a failed handoff can't wedge the PR.
1108
+ *
1109
+ * EVERY branch first runs {@link advanceIfTerminalOutOfBand} — one shared "is this PR already
1110
+ * terminal (merged/closed) out-of-band?" pre-check — so no merge stage can silently strand when a PR
1111
+ * is landed/closed outside the loop (the `waiting_deps` self-merged wedge, #368). */
1112
+ export async function pollMerges(data: DataLayer, engine: EngineClient, token: string) {
1028
1113
  // 1) Dependencies merged?
1029
1114
  for (const pr of await prs(data).find({ status: "waiting_deps" })) {
1030
1115
  const prKey = pr.pr_key;
1031
1116
  try {
1117
+ // Out-of-band terminal FIRST: a PR merged/closed outside the loop while parked at `wait-deps`
1118
+ // must converge even if its declared deps never clear (the #368 wedge). `wait-deps` subscribes
1119
+ // only `deps-cleared`, so the shared pre-check publishes exactly that.
1120
+ if (await advanceIfTerminalOutOfBand(data, engine, pr, token)) continue;
1032
1121
  const depRows = await deps(data).find({ pr_key: prKey });
1033
1122
  let allMerged = true;
1034
1123
  for (const d of depRows) {
@@ -1055,38 +1144,12 @@ async function pollMerges(data: DataLayer, engine: EngineClient, token: string)
1055
1144
  try {
1056
1145
  const st = await fetchPrState(repo, number, token);
1057
1146
  if (st === null) continue; // no transport → skip this PR (others may still advance)
1058
- const liveness = classifyPrLiveness(st);
1059
- if (liveness === "merged") {
1060
- // Landed out-of-band (a maintainer clicked Merge, a mergify queue merged it, etc.). The
1061
- // instance is parked at `wait-mergeable`, which subscribes to `merge-ready` NOT
1062
- // `merge-landed` (that catch, `wait-landed`, only exists later, after we enqueue). Publishing
1063
- // `merge-landed` here has no subscription to correlate to, so the engine drops it and the PR
1064
- // wedges forever in the transient `merging` status (which no poller branch re-scans).
1065
- // Publish `merge-ready` with a `ready` verdict instead: it routes through `gw-mergeable` to
1066
- // `attempt-merge`, whose idempotent already-merged check completes the loop (`mark-merged`).
1067
- await flipToMergingThenPublish(data, engine, prKey, "waiting_merge", {
1068
- name: "merge-ready",
1069
- correlationKey: prKey,
1070
- variables: { mergeState: "ready", failingChecks: 0, failingChecksList: "" },
1071
- });
1072
- console.log(`[poller] already merged -> ${prKey}`);
1073
- continue;
1074
- }
1075
- if (liveness === "closed") {
1076
- // Closed on GitHub WITHOUT merging (e.g. superseded by a newer PR — #350). The PR can never
1077
- // land, so it must NOT be classified as blocked/conflict and escalated (that orphans the
1078
- // process on a dead PR, #342). Route it through the same canonical `merge-ready` → `ready` →
1079
- // `attempt-merge` path as the merged case; the merge worker's closed short-circuit records a
1080
- // terminal `abandoned` audit row and drives the loop down its terminate/abandon end event.
1081
- // One canonical abandon implementation lives in the worker — the poller only routes to it.
1082
- await flipToMergingThenPublish(data, engine, prKey, "waiting_merge", {
1083
- name: "merge-ready",
1084
- correlationKey: prKey,
1085
- variables: { mergeState: "ready", failingChecks: 0, failingChecksList: "" },
1086
- });
1087
- console.log(`[poller] closed without merging -> ${prKey}`);
1088
- continue;
1089
- }
1147
+ // Out-of-band terminal (merged/closed) → the shared pre-check publishes `merge-ready {ready}`,
1148
+ // routing through `gw-mergeable → attempt-merge` whose idempotent already-merged check completes
1149
+ // the loop (`mark-merged`) and whose closed short-circuit abandons a PR closed without merging
1150
+ // (#342/#350). Reuse the `st` we just read so we don't double-fetch. This is the proven terminal
1151
+ // path the whole class (#368) now shares.
1152
+ if (await advanceIfTerminalOutOfBand(data, engine, pr, token, st)) continue;
1090
1153
  const verdict = classifyMergeability(st);
1091
1154
  if (verdict === "waiting") {
1092
1155
  // Frugal-CI remedy (#43): when the repo publishes a merge protocol that wants a fresh
@@ -1145,6 +1208,10 @@ async function pollMerges(data: DataLayer, engine: EngineClient, token: string)
1145
1208
  for (const pr of await prs(data).find({ status: "waiting_lane" })) {
1146
1209
  const prKey = pr.pr_key;
1147
1210
  try {
1211
+ // Out-of-band terminal FIRST: a lane-held PR merged/closed outside the loop must converge even
1212
+ // if its lane predecessor never releases the hold. waiting_lane leaves the process parked at
1213
+ // `wait-mergeable`, so the shared pre-check publishes `merge-ready {ready}` (→ attempt-merge).
1214
+ if (await advanceIfTerminalOutOfBand(data, engine, pr, token)) continue;
1148
1215
  const lane = await mergeLaneDecisionForPr(data, prKey);
1149
1216
  if (lane?.isHeld) continue;
1150
1217
  await prs(data).update(prKey, { status: "waiting_merge", updated_at: now() });
@@ -1172,15 +1239,16 @@ async function pollMerges(data: DataLayer, engine: EngineClient, token: string)
1172
1239
  try {
1173
1240
  const st = await fetchPrState(repo, number, token);
1174
1241
  if (st === null) continue; // no transport → skip this PR (others may still advance)
1175
- const verdict = queuedVerdict(st);
1176
- if (verdict === "landed") {
1177
- await flipToMergingThenPublish(data, engine, prKey, "queued", {
1178
- name: "merge-landed",
1179
- correlationKey: prKey,
1180
- variables: {},
1181
- });
1182
- console.log(`[poller] queued PR landed -> ${prKey}`);
1183
- } else if (verdict === "evicted") {
1242
+ // Out-of-band terminal FIRST (reusing `st`): a queued PR merged out-of-band lands
1243
+ // (`merge-landed` mark-merged); one CLOSED out-of-band without merging can never land, so the
1244
+ // pre-check re-arms it (`merge-evicted` arm-merge) and block 2 abandons it — a closed queued
1245
+ // PR would otherwise wedge, since `queuedVerdict` calls a non-DIRTY closed PR merely "waiting"
1246
+ // (#368). The DIRTY-while-open eviction below still handles a live-but-conflicted queue drop.
1247
+ if (await advanceIfTerminalOutOfBand(data, engine, pr, token, st)) continue;
1248
+ // Terminal states (merged/closed) are handled by the shared pre-check above; here the PR is
1249
+ // still open, so the only remaining reason to leave `wait-landed` is a live queue DROP — a real
1250
+ // merge CONFLICT (`DIRTY`). `queuedVerdict` stays the canonical classifier for that.
1251
+ if (queuedVerdict(st) === "evicted") {
1184
1252
  await flipToMergingThenPublish(data, engine, prKey, "queued", {
1185
1253
  name: "merge-evicted",
1186
1254
  correlationKey: prKey,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.107.0",
3
+ "version": "0.107.1",
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",