@nanobpm/nano-workforce 0.191.0 → 0.192.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.
@@ -2,16 +2,16 @@
2
2
  #
3
3
  # https://nanobpm.io/install.sh is served as the ACTUAL bytes of THIS repo's
4
4
  # install.sh (GitHub Pages can't 301-redirect a `curl … | sh` URL). The site is
5
- # built in a DIFFERENT repo (Magikcraft/nano-bpm) whose Pages workflow re-fetches
5
+ # built in a DIFFERENT repo (nanobpm/nano-bpm) whose Pages workflow re-fetches
6
6
  # install.sh on every deploy — but that deploy only triggers on changes to files
7
7
  # in that repo, so an edit here would otherwise not reach the published mirror
8
8
  # until the nightly backstop cron. This job pokes that repo's Pages deploy via a
9
9
  # `repository_dispatch` (event type `install-sh-updated`) so the mirror refreshes
10
10
  # within minutes of merging an install.sh change.
11
11
  #
12
- # Requires a token that can write to Magikcraft/nano-bpm — the default
12
+ # Requires a token that can write to nanobpm/nano-bpm — the default
13
13
  # GITHUB_TOKEN is scoped to THIS repo only. Store a fine-grained PAT (or GitHub
14
- # App token) with `Contents: read and write` on Magikcraft/nano-bpm as the repo
14
+ # App token) with `Contents: read and write` on nanobpm/nano-bpm as the repo
15
15
  # secret PAGES_DISPATCH_TOKEN. Absent the secret, the job no-ops (the nightly
16
16
  # cron on the site keeps the mirror eventually-consistent).
17
17
  name: Refresh nanobpm.io install mirror
@@ -45,10 +45,10 @@ jobs:
45
45
  -H 'Accept: application/vnd.github+json' \
46
46
  -H "Authorization: Bearer ${DISPATCH_TOKEN}" \
47
47
  -H 'X-GitHub-Api-Version: 2022-11-28' \
48
- https://api.github.com/repos/Magikcraft/nano-bpm/dispatches \
48
+ https://api.github.com/repos/nanobpm/nano-bpm/dispatches \
49
49
  -d '{"event_type":"install-sh-updated"}')
50
50
  if [ "$code" != "204" ]; then
51
- echo "::error::repository_dispatch to Magikcraft/nano-bpm returned HTTP ${code} (expected 204). Check PAGES_DISPATCH_TOKEN scope (fine-grained PAT: Contents: read & write)."
51
+ echo "::error::repository_dispatch to nanobpm/nano-bpm returned HTTP ${code} (expected 204). Check PAGES_DISPATCH_TOKEN scope (fine-grained PAT: Contents: read & write)."
52
52
  exit 1
53
53
  fi
54
- echo "dispatched install-sh-updated to Magikcraft/nano-bpm (HTTP 204)"
54
+ echo "dispatched install-sh-updated to nanobpm/nano-bpm (HTTP 204)"
package/CHANGELOG.md CHANGED
@@ -1,3 +1,15 @@
1
+ ## [0.192.0](https://github.com/nanobpm/nano-workforce/compare/v0.191.1...v0.192.0) (2026-09-28)
2
+
3
+ ### Features
4
+
5
+ * **world:** self-heal a no-advance round onto a reachable push-checkpoint ([#819](https://github.com/nanobpm/nano-workforce/issues/819)) ([891add2](https://github.com/nanobpm/nano-workforce/commit/891add2a217042dd2abaa37c8f12b1f1877c89fc)), closes [26/#28](https://github.com/26/nano-workforce/issues/28) [#818](https://github.com/nanobpm/nano-workforce/issues/818) [#818](https://github.com/nanobpm/nano-workforce/issues/818) [#818](https://github.com/nanobpm/nano-workforce/issues/818) [704/#497](https://github.com/704/nano-workforce/issues/497)
6
+
7
+ ## [0.191.1](https://github.com/nanobpm/nano-workforce/compare/v0.191.0...v0.191.1) (2026-09-26)
8
+
9
+ ### Bug Fixes
10
+
11
+ * **tasks:** show full delivery human-step context and lift an embedded link ([#814](https://github.com/nanobpm/nano-workforce/issues/814)) ([cca3374](https://github.com/nanobpm/nano-workforce/commit/cca33740607f80816504dd2b5c60b785cb1440c6)), closes [#813](https://github.com/nanobpm/nano-workforce/issues/813) [#fragment](https://github.com/nanobpm/nano-workforce/issues/fragment) [#778](https://github.com/nanobpm/nano-workforce/issues/778) [#813](https://github.com/nanobpm/nano-workforce/issues/813)
12
+
1
13
  ## [0.191.0](https://github.com/nanobpm/nano-workforce/compare/v0.190.0...v0.191.0) (2026-09-23)
2
14
 
3
15
  ### Features
@@ -487,7 +487,7 @@ export function validateDeliveryGraph(graph: unknown): DeliveryGraphError[] {
487
487
  }
488
488
  // A `wait` node's `onTimeout: fail` cannot be honored yet: the compiler would emit a terminate
489
489
  // end on the not-ready-at-boundary path, but the engine treats terminate-end events as
490
- // parsed-not-executed (Magikcraft/nano-bpm bpmn.rs), so `fail` would silently degrade to a plain
490
+ // parsed-not-executed (nanobpm/nano-bpm bpmn.rs), so `fail` would silently degrade to a plain
491
491
  // end — the "declared knob silently ignored" defect class. Reject it loudly (path-qualified)
492
492
  // until engine parity lands (Magikcraft/nano-bpm#978), rather than mis-compile it. `escalate`
493
493
  // (default) and `continue` ARE honored.
@@ -201,7 +201,7 @@ test("computeRunKey: a non-blank caller key wins; a blank/absent key falls back
201
201
  });
202
202
 
203
203
  // ── buildHumanLabels / parseHumanLabels ───────────────────────────────────────
204
- test("buildHumanLabels: maps each human node's compiled user-task element id → its instruction label", async () => {
204
+ test("buildHumanLabels: maps each human node's compiled user-task element id → its FULL instruction", async () => {
205
205
  const graph = {
206
206
  nodes: [
207
207
  { id: "open-b", kind: "agent", agent: { jobType: "j" } },
@@ -216,7 +216,9 @@ test("buildHumanLabels: maps each human node's compiled user-task element id →
216
216
  const labels = buildHumanLabels(compiled);
217
217
  const publishEl = compiled.resolved.nodes.find((n) => n.id === "publish")?.element ?? "";
218
218
  const ackEl = compiled.resolved.nodes.find((n) => n.id === "ack")?.element ?? "";
219
- assertEquals(labels[humanTaskElementId(publishEl)], "run the manual OTP publish"); // first line only
219
+ // The FULL prompt is stored (issue #813) — not a clamped first line — so the Tasks "Decision
220
+ // context" can show the whole instruction. The phase pill clamps this at display (deriveDeliveryPhase).
221
+ assertEquals(labels[humanTaskElementId(publishEl)], "run the manual OTP publish\nsecond line");
220
222
  assertEquals(labels[humanTaskElementId(ackEl)], "ack"); // fallback to node id
221
223
  });
222
224
 
@@ -264,6 +266,16 @@ test("deriveDeliveryPhase: ACTIVE with an open human task → parked on that nod
264
266
  assertEquals(p.phase_node_id, el);
265
267
  });
266
268
 
269
+ test("deriveDeliveryPhase: clamps a long/multi-line stored instruction to a first-line phase pill (#813)", () => {
270
+ const el = humanTaskElementId("n2");
271
+ const full = `Review the console dependency-adopt PR opened by adopt-console (bumps the ranges).\nSecond line.`;
272
+ const p = deriveDeliveryPhase("ACTIVE", [{ elementId: el }], { [el]: full });
273
+ // Full instruction is stored for the Decision context, but the phase pill shows only the clamped
274
+ // first line (77 chars + …), never the second line.
275
+ assertEquals(p.phase, "Parked on human node: Review the console dependency-adopt PR opened by adopt-console (bumps the ran…");
276
+ assert(!p.phase.includes("Second line"));
277
+ });
278
+
267
279
  test("deriveDeliveryPhase: a parked node with no stored label falls back to the element id", () => {
268
280
  const el = humanTaskElementId("n5");
269
281
  const p = deriveDeliveryPhase("ACTIVE", [{ elementId: el }], {});
@@ -271,6 +283,17 @@ test("deriveDeliveryPhase: a parked node with no stored label falls back to the
271
283
  assertEquals(p.phase_node_id, el);
272
284
  });
273
285
 
286
+ test("deliveryHuman: a parked bounded-timeout twin (`…__esc`) resolves the base node's stored label in the phase pill (#813)", () => {
287
+ // The twin's exact `…__esc` id is never stamped into `human_labels` (only the base id is), so the
288
+ // phase-pill lookup must strip `__esc` — the same fallback the Tasks Decision-context helpers use —
289
+ // else a parked timeout twin shows the raw element id instead of the instruction's first line.
290
+ const base = humanTaskElementId("n2");
291
+ const twin = `${base}__esc`;
292
+ const p = deriveDeliveryPhase("ACTIVE", [{ elementId: twin }], { [base]: "manual OTP publish" });
293
+ assertEquals(p.phase, "Parked on human node: manual OTP publish");
294
+ assertEquals(p.phase_node_id, twin);
295
+ });
296
+
274
297
  test("deriveDeliveryPhase: ACTIVE with only a non-human open task (or none) → a bare Running", () => {
275
298
  assertEquals(deriveDeliveryPhase("ACTIVE", [], {}), { status: "running", phase: DELIVERY_PHASE.RUNNING, phase_node_id: null });
276
299
  assertEquals(deriveDeliveryPhase("ACTIVE", [{ elementId: "some-service-task" }], {}), {
@@ -206,9 +206,12 @@ function firstLine(text: string | undefined | null): string {
206
206
  return line.length > 80 ? `${line.slice(0, 77)}…` : line;
207
207
  }
208
208
 
209
- /** Map each human node's compiled user-task element id → a display label (its instruction's first
210
- * line, else its author node id), from the S1 compile result. Stamped on the run row at dispatch so
211
- * the poller renders the parked-node phase without recompiling the graph. */
209
+ /** Map each human node's compiled user-task element id → its FULL instruction, from the S1 compile
210
+ * result. Stamped on the run row at dispatch so the poller can (a) render the parked-node phase label
211
+ * — clamped at display by {@link deriveDeliveryPhase} — and (b) surface the WHOLE instruction as the
212
+ * Tasks-inbox "Decision context" (issue #813). Storing the full prompt (not a clamped first line) is
213
+ * what lets the operator read the entire instruction, and lets a URL embedded in the prompt reach the
214
+ * task's clickable link. Falls back to the node id when a human node declares no prompt. */
212
215
  export function buildHumanLabels(compiled: CompileDeliveryGraphResult): Record<string, string> {
213
216
  const elementByNodeId = new Map(compiled.resolved.nodes.map((n) => [n.id, n.element]));
214
217
  const labels: Record<string, string> = {};
@@ -220,8 +223,11 @@ export function buildHumanLabels(compiled: CompileDeliveryGraphResult): Record<s
220
223
  // task's Decision context in the Tasks inbox, so a URL credential in a human prompt (`//user:pass@…`)
221
224
  // must be stripped here too — else it is persisted and shown unredacted even though the BPMN display
222
225
  // path redacts it. The RAW prompt still reaches the runtime user task unmodified (issue #778 review).
223
- const safePrompt = typeof stop.prompt === "string" ? redactFreeText(stop.prompt) : "";
224
- labels[humanTaskElementId(element)] = firstLine(safePrompt) || stop.nodeId;
226
+ // The FULL redacted prompt is stored (not a clamped first line) so the Tasks "Decision context" can
227
+ // show the whole instruction and surface an embedded link; the phase pill clamps it at display via
228
+ // `firstLine` in `deriveDeliveryPhase` below (issue #813).
229
+ const safePrompt = typeof stop.prompt === "string" ? redactFreeText(stop.prompt).trim() : "";
230
+ labels[humanTaskElementId(element)] = safePrompt || stop.nodeId;
225
231
  }
226
232
  return labels;
227
233
  }
@@ -267,7 +273,13 @@ export function deriveDeliveryPhase(
267
273
  .filter((id): id is string => typeof id === "string" && isDeliveryHumanElement(id))
268
274
  .sort()[0];
269
275
  if (parkedOn !== undefined) {
270
- const label = humanLabels[parkedOn] ?? parkedOn;
276
+ // Clamp the compact phase label to the instruction's first line (the full prompt is stored in
277
+ // `human_labels` for the Tasks "Decision context" — issue #813 — but the phase pill wants a short
278
+ // one-liner). Use the SAME exact-then-`__esc`-stripped lookup as `deliveryHumanContext*` so a
279
+ // parked bounded-timeout twin (`…__esc`, whose exact id is never stamped) resolves to its base
280
+ // node's label instead of showing the raw element id. A missing label falls back to the raw id.
281
+ const stored = humanLabels[parkedOn] ?? humanLabels[parkedOn.replace(/__esc$/, "")];
282
+ const label = firstLine(stored) || parkedOn;
271
283
  return { status: "running", phase: `Parked on human node: ${label}`, phase_node_id: parkedOn };
272
284
  }
273
285
  return { status: "running", phase: DELIVERY_PHASE.RUNNING, phase_node_id: null };
@@ -11,7 +11,9 @@ import {
11
11
  bindHumanEmits,
12
12
  DELIVERY_HUMAN_ELEMENT,
13
13
  deliveryHumanContextQuestion,
14
+ deliveryHumanContextUrl,
14
15
  deriveHumanCategory,
16
+ firstHttpUrl,
15
17
  GENERIC_HUMAN_FORM,
16
18
  HUMAN_ACK_FORM,
17
19
  HUMAN_PUBLISH_FORM,
@@ -21,6 +23,7 @@ import {
21
23
  renderHumanEmitBrief,
22
24
  resolveHumanForm,
23
25
  } from "./deliveryHuman.ts";
26
+ import { redactFreeText } from "./deliveryGraphCompiler.ts";
24
27
  import { ESCALATION_TASK_ELEMENTS } from "./agentCompletion.ts";
25
28
  import { USER_TASK_KIND_LABELS } from "./userTasks.ts";
26
29
 
@@ -322,8 +325,82 @@ test("deliveryHumanContextQuestion: derives the node instruction from human_labe
322
325
  );
323
326
  });
324
327
 
325
- test("deliveryHumanContextQuestion: falls back to a static message when no label is stored", () => {
326
- // A parked human step must never render a blank Decision context — an untracked/absent label still
328
+ test("firstHttpUrl: extracts the first http(s) URL and trims trailing prose punctuation (#813)", () => {
329
+ assertEquals(firstHttpUrl("see https://github.com/o/r/pull/5 for details"), "https://github.com/o/r/pull/5");
330
+ // Trailing sentence punctuation / closing bracket is stripped so the link resolves.
331
+ assertEquals(firstHttpUrl("Review the PR (https://github.com/o/r/pull/5)."), "https://github.com/o/r/pull/5");
332
+ assertEquals(firstHttpUrl("http://example.test/a,"), "http://example.test/a");
333
+ // A balanced closing bracket is part of the URL and preserved (not truncated into a broken link).
334
+ assertEquals(
335
+ firstHttpUrl("see https://en.wikipedia.org/wiki/Nano_(technology) here"),
336
+ "https://en.wikipedia.org/wiki/Nano_(technology)",
337
+ );
338
+ // A balanced bracket followed by prose punctuation: strip only the punctuation, keep the pair.
339
+ assertEquals(
340
+ firstHttpUrl("ref https://en.wikipedia.org/wiki/Nano_(technology)."),
341
+ "https://en.wikipedia.org/wiki/Nano_(technology)",
342
+ );
343
+ // An UNBALANCED wrapping bracket is prose and still stripped.
344
+ assertEquals(firstHttpUrl("(https://github.com/o/r/pull/9)"), "https://github.com/o/r/pull/9");
345
+ // Multiple/nested UNBALANCED trailing closers + prose punctuation are all peeled off in one linear
346
+ // pass (guards the O(n) trim refactor — #813 review), while an inner balanced pair is preserved.
347
+ assertEquals(
348
+ firstHttpUrl("(see [https://en.wikipedia.org/wiki/Nano_(technology)])."),
349
+ "https://en.wikipedia.org/wiki/Nano_(technology)",
350
+ );
351
+ assertEquals(firstHttpUrl("look: {[(https://example.test/a)]}"), "https://example.test/a");
352
+ // An apostrophe is a valid URL sub-delimiter and preserved WITHIN the URL (not truncated at it)…
353
+ assertEquals(
354
+ firstHttpUrl("see https://en.wikipedia.org/wiki/It's_a_Wonderful_Life here"),
355
+ "https://en.wikipedia.org/wiki/It's_a_Wonderful_Life",
356
+ );
357
+ // …while a TRAILING apostrophe (a prose quote closer) is still trimmed.
358
+ assertEquals(firstHttpUrl("the link 'https://example.test/a' works"), "https://example.test/a");
359
+ // Scheme match is case-insensitive (RFC 3986 schemes are case-insensitive).
360
+ assertEquals(firstHttpUrl("see HTTPS://github.com/o/r/pull/5 now"), "HTTPS://github.com/o/r/pull/5");
361
+ assertEquals(firstHttpUrl("Http://example.test/b"), "Http://example.test/b");
362
+ // First wins when several are present.
363
+ assertEquals(firstHttpUrl("a https://one.test b https://two.test"), "https://one.test");
364
+ // No URL / non-string → null (preserves today's linkless behaviour).
365
+ assertEquals(firstHttpUrl("merge the PR opened by adopt-console"), null);
366
+ assertEquals(firstHttpUrl(undefined), null);
367
+ });
368
+
369
+ test("deliveryHumanContextUrl: lifts a prompt-embedded URL onto the task link (base + __esc twin), else null (#813)", () => {
370
+ const labels = {
371
+ "delivery-human-task__n7": "Review the adopt PR at https://github.com/nanobpm/nano-ide/pull/42 and merge it.",
372
+ "delivery-human-task__n8": "Confirm CI is green, then merge — no link here.",
373
+ };
374
+ assertEquals(deliveryHumanContextUrl(labels, "delivery-human-task__n7"), "https://github.com/nanobpm/nano-ide/pull/42");
375
+ // The bounded-timeout twin resolves the same node instruction.
376
+ assertEquals(deliveryHumanContextUrl(labels, "delivery-human-task__n7__esc"), "https://github.com/nanobpm/nano-ide/pull/42");
377
+ // A node whose instruction names no URL yields null (the linkless default).
378
+ assertEquals(deliveryHumanContextUrl(labels, "delivery-human-task__n8"), null);
379
+ assertEquals(deliveryHumanContextUrl(undefined, "delivery-human-task__n1"), null);
380
+ });
381
+
382
+ test("deliveryHumanContextUrl: declines to link a redaction-altered URL, links a credential-free one (#814)", () => {
383
+ // The stored `human_labels` are display-REDACTED via the SAME `redactFreeText` `buildHumanLabels` uses,
384
+ // so build the labels through it to mirror production exactly.
385
+ const withQuery = redactFreeText("Review the checks tab at https://example.test/o/r/pull/5?tab=checks now");
386
+ const withCred = redactFreeText("Open https://user:pass@example.test/o/r/pull/5 to review");
387
+ const withFragment = redactFreeText("Jump to https://example.test/o/r/pull/5#files section");
388
+ const clean = redactFreeText("Review the PR at https://example.test/o/r/pull/5 and merge it");
389
+ const labels = {
390
+ "delivery-human-task__q": withQuery,
391
+ "delivery-human-task__c": withCred,
392
+ "delivery-human-task__f": withFragment,
393
+ "delivery-human-task__ok": clean,
394
+ };
395
+ // A redacted query/userinfo/fragment would publish a DIFFERENT, broken link → decline (null).
396
+ assertEquals(deliveryHumanContextUrl(labels, "delivery-human-task__q"), null);
397
+ assertEquals(deliveryHumanContextUrl(labels, "delivery-human-task__c"), null);
398
+ assertEquals(deliveryHumanContextUrl(labels, "delivery-human-task__f"), null);
399
+ // A credential-free URL is untouched by redaction and links faithfully.
400
+ assertEquals(deliveryHumanContextUrl(labels, "delivery-human-task__ok"), "https://example.test/o/r/pull/5");
401
+ });
402
+
403
+ test("deliveryHumanContextQuestion: falls back to a static message when no label is stored", () => { // A parked human step must never render a blank Decision context — an untracked/absent label still
327
404
  // yields actionable guidance rather than null (which would leave the panel empty, issue #772).
328
405
  assertEquals(
329
406
  deliveryHumanContextQuestion({}, "delivery-human-task__n1"),
@@ -83,6 +83,98 @@ export function deliveryHumanContextQuestion(
83
83
  return label || "A scheduled delivery-graph step is waiting to be completed.";
84
84
  }
85
85
 
86
+ /** The clickable link for a parked delivery-graph `human` node's Tasks-inbox row (issue #813): the
87
+ * first `http(s)` URL embedded in the node's stored instruction, or `null` when it names none. The
88
+ * Tasks page renders the "Decision context" as PLAIN, non-clickable text (a `detail.fields` entry),
89
+ * and links only through the http(s)-gated `subject_url` `linkField` — so an author who wants the
90
+ * operator to have a clickable link (e.g. the PR to review) writes the URL into the node prompt and
91
+ * this lifts it onto `subject_url`. Uses the SAME exact-then-`__esc`-stripped lookup as the context
92
+ * question so the twin escalation task resolves to its real node's instruction. Returns `null` (not a
93
+ * fabricated link) when no URL is present, preserving today's linkless behaviour. */
94
+ export function deliveryHumanContextUrl(
95
+ humanLabels: Record<string, string> | undefined,
96
+ elementId: string,
97
+ ): string | null {
98
+ const labels = humanLabels ?? {};
99
+ const base = elementId.replace(/__esc$/, "");
100
+ const instruction = labels[elementId] ?? labels[base] ?? "";
101
+ const url = firstHttpUrl(instruction);
102
+ // The stored `human_labels` are display-REDACTED (`redactFreeText` in `buildHumanLabels`): a URL that
103
+ // carried a credential — `//user:pass@` userinfo, or a `?query`/`#fragment` (which can hide a signed/
104
+ // authenticating token) — has that component collapsed to the `***` sentinel (`//***@`, `?***`, `#***`).
105
+ // Lifting such an altered URL onto `subject_url` would publish a DIFFERENT, broken link (a truncated
106
+ // query changes the referenced resource; a signed URL becomes unusable). We cannot recover the real URL
107
+ // here — the raw prompt is deliberately NOT stored (issue #778) — so decline to link rather than silently
108
+ // publishing a redaction-mangled one. A URL with no credential component is untouched by redaction and
109
+ // links faithfully (issue #813 review). Callers wanting a clickable link should embed a credential-free
110
+ // URL in the prompt.
111
+ return url !== null && urlBearsRedactionMarker(url) ? null : url;
112
+ }
113
+
114
+ /** The `***` sentinel {@link redactFreeText}/`redactString` leaves where it collapsed a URL's credential
115
+ * span — `//***@` (userinfo), `?***` (query), `#***` (fragment). Its presence in an extracted URL means
116
+ * redaction ALTERED the link, so it must not be published as a `subject_url` (issue #813 review). */
117
+ const URL_REDACTION_MARKER = /\/\/\*\*\*@|[?#]\*\*\*/;
118
+
119
+ function urlBearsRedactionMarker(url: string): boolean {
120
+ return URL_REDACTION_MARKER.test(url);
121
+ }
122
+
123
+ const URL_TRAILING_CLOSERS: Readonly<Record<string, string>> = { ")": "(", "]": "[", "}": "{" };
124
+
125
+ const URL_TRAILING_PUNCTUATION = ".,;:!?'\"";
126
+
127
+ /** Trim trailing prose punctuation a URL commonly picks up when embedded in a sentence (`.,;:!?'"` and
128
+ * a wrapping `)`/`]`/`}`), WITHOUT corrupting a URL that legitimately ends in a closing bracket. A
129
+ * closing bracket is only stripped when it is UNBALANCED within the URL (a prose wrapper, e.g. the `)`
130
+ * in "see (https://x/y)"); a balanced pair — e.g. Wikipedia `…/Foo_(disambiguation)` — is part of the
131
+ * URL and preserved, so the persisted link isn't truncated into a broken one. Bracket balance is
132
+ * tracked in running per-pair counts (seeded by one linear scan, then decremented as trailing closers
133
+ * are stripped) so the whole trim is a single O(n) pass — NOT the prior re-`split()`-the-prefix-each-
134
+ * step quadratic (issue #813 review). */
135
+ function trimUrlTrailingPunctuation(url: string): string {
136
+ const opens: Record<string, number> = { "(": 0, "[": 0, "{": 0 };
137
+ const closes: Record<string, number> = { ")": 0, "]": 0, "}": 0 };
138
+ for (const ch of url) {
139
+ if (ch in opens) opens[ch] += 1;
140
+ else if (ch in closes) closes[ch] += 1;
141
+ }
142
+ let end = url.length;
143
+ while (end > 0) {
144
+ const ch = url[end - 1];
145
+ const opener = URL_TRAILING_CLOSERS[ch];
146
+ if (opener !== undefined) {
147
+ // `opens`/`closes` reflect url[0:end]; only trailing closers are ever removed below, so they stay
148
+ // accurate. Balanced (opener count ≥ closer count) → the bracket belongs to the URL; stop.
149
+ if (opens[opener] >= closes[ch]) break;
150
+ closes[ch] -= 1;
151
+ end -= 1;
152
+ continue;
153
+ }
154
+ if (URL_TRAILING_PUNCTUATION.includes(ch)) {
155
+ end -= 1;
156
+ continue;
157
+ }
158
+ break;
159
+ }
160
+ return url.slice(0, end);
161
+ }
162
+
163
+ /** Extract the first `http(s)` URL from free text, trimming trailing sentence punctuation/brackets a
164
+ * prose author commonly appends (`).,;` etc.) so the link resolves — but preserving a bracket that is
165
+ * BALANCED within the URL (e.g. Wikipedia `…_(technology)`), so a valid URL is never truncated into a
166
+ * broken task link. An apostrophe is a valid URL sub-delimiter (RFC 3986 §2.2), so it is allowed WITHIN
167
+ * the URL and only stripped when trailing (a prose quote closer) by `trimUrlTrailingPunctuation` — the
168
+ * same way a wrapping `)` is handled — so a link such as `…/It's_a_Wonderful_Life` is not truncated at
169
+ * the apostrophe (issue #813 review). The scheme match is case-insensitive (RFC 3986 schemes are
170
+ * case-insensitive). Returns `null` when none. */
171
+ export function firstHttpUrl(text: string | undefined | null): string | null {
172
+ if (typeof text !== "string") return null;
173
+ const match = text.match(/https?:\/\/[^\s<>"`]+/i);
174
+ if (!match) return null;
175
+ return trimUrlTrailingPunctuation(match[0]) || null;
176
+ }
177
+
86
178
  /** The GENERIC fallback form (Decision 4, step 3): captures ONE typed value into the node's single
87
179
  * declared emitted fact, so a human node with no explicit/category form can STILL emit downstream. */
88
180
  export const GENERIC_HUMAN_FORM = "delivery-human-generic";
@@ -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, checkConclusions, classifyMergeability, classifyPrLiveness, coalesceTitle, createPullRequest, ensureBaseBranch, ensurePromotionPr, fetchBranchHead, fetchIssueTitle, fetchPrFiles, fetchPrHead, fetchPrReviews, isNotAPullRequestError, listPrsForHead, type GhReview, type Mergeability, type PrState } from "./github.ts";
6
+ import { BaseBranchMustExistError, checkConclusions, classifyMergeability, classifyPrLiveness, coalesceTitle, compareCommits, createPullRequest, ensureBaseBranch, ensurePromotionPr, fetchBranchHead, fetchIssueTitle, fetchPrFiles, fetchPrHead, fetchPrReviews, isNotAPullRequestError, listPrsForHead, updateBranchRef, type GhReview, type Mergeability, type PrState } from "./github.ts";
7
7
  import { DEFAULT_MERGE_PROTOCOL, type MergeProtocol, type RequiredCheck } from "./mergeProtocol.ts";
8
8
 
9
9
  // A fake `fetch` that serves `pages` of file batches; each page N (1-based) returns `pages[N-1]`
@@ -956,3 +956,225 @@ test("fetchBranchHead: percent-encodes a special-character branch ref (preservin
956
956
  );
957
957
  assertEquals(sha, "cafef00d");
958
958
  });
959
+
960
+ // ── No-advance self-heal transports (issue #818) ────────────────────────────
961
+ // The self-heal's compare (`compareCommits`) and NON-force ref move (`updateBranchRef`) are
962
+ // safety-critical: a regression in the ahead/behind parsing or the `force:false` / 422-refusal
963
+ // handling silently changes whether a recovery mutates a PR head. These pin the token transport of
964
+ // both — a request-capturing fetch stub so the method/body/path and the refusal branch are covered.
965
+ async function withCapturingFetch<T>(
966
+ serve: (req: { path: string; method: string; body: unknown }) => { status: number; statusText?: string; body: unknown },
967
+ fn: () => Promise<T>,
968
+ ): Promise<T> {
969
+ const prevMode = process.env["NANO_PR_GITHUB_TRANSPORT"];
970
+ const prevFetch = globalThis.fetch;
971
+ process.env["NANO_PR_GITHUB_TRANSPORT"] = "token";
972
+ globalThis.fetch = ((url: string | URL | Request, init?: RequestInit): Promise<Response> => {
973
+ const path = new URL(String(url)).pathname.replace(/^\/repos\//, "");
974
+ const method = (init?.method ?? "GET").toUpperCase();
975
+ const body = typeof init?.body === "string" ? JSON.parse(init.body) : undefined;
976
+ const { status, statusText, body: resp } = serve({ path, method, body });
977
+ return Promise.resolve(new Response(JSON.stringify(resp), { status, statusText }));
978
+ }) as typeof fetch;
979
+ try {
980
+ return await fn();
981
+ } finally {
982
+ globalThis.fetch = prevFetch;
983
+ if (prevMode === undefined) delete process.env["NANO_PR_GITHUB_TRANSPORT"];
984
+ else process.env["NANO_PR_GITHUB_TRANSPORT"] = prevMode;
985
+ }
986
+ }
987
+
988
+ test("compareCommits: parses an 'ahead' fast-forward response and encodes the compare refs", async () => {
989
+ const cmp = await withCapturingFetch(
990
+ ({ path, method }) => {
991
+ assertEquals(method, "GET");
992
+ // Each ref segment is percent-encoded (`#` → %23) around the compare API's own `...` separator.
993
+ assertEquals(path, "o/r/compare/feat/x%23123...deadbeef");
994
+ return { status: 200, body: { status: "ahead", ahead_by: 3, behind_by: 0 } };
995
+ },
996
+ () => compareCommits("o/r", "feat/x#123", "deadbeef", "tok"),
997
+ );
998
+ assertEquals(cmp, { status: "ahead", aheadBy: 3, behindBy: 0 });
999
+ });
1000
+
1001
+ test("compareCommits: coerces an unknown status to 'diverged' and defaults missing counts to 0", async () => {
1002
+ const cmp = await withCapturingFetch(
1003
+ () => ({ status: 200, body: { status: "wat" } }),
1004
+ () => compareCommits("o/r", "base", "head", "tok"),
1005
+ );
1006
+ assertEquals(cmp, { status: "diverged", aheadBy: 0, behindBy: 0 });
1007
+ });
1008
+
1009
+ // Reject COERCED/UNSAFE compare counts at the transport boundary (Copilot review of #819). A bare
1010
+ // `Number(...)` would turn a malformed/tampered response into a spurious ancestry proof — a string
1011
+ // `"1"` or boolean `true` coerces to `1`, and an unsafe magnitude passes `Number.isInteger` — either
1012
+ // of which could fast-forward the ref WITHOUT a genuine GitHub proof. A present count must be a real,
1013
+ // non-negative SAFE integer number; anything else throws so the self-heal escalates rather than heals.
1014
+ test("compareCommits: rejects a string-coerced count (throws, never a spurious integer proof)", async () => {
1015
+ await assertRejects(
1016
+ () =>
1017
+ withCapturingFetch(
1018
+ () => ({ status: 200, body: { status: "ahead", ahead_by: "1", behind_by: 0 } }),
1019
+ () => compareCommits("o/r", "base", "head", "tok"),
1020
+ ),
1021
+ Error,
1022
+ "invalid ahead_by",
1023
+ );
1024
+ });
1025
+
1026
+ test("compareCommits: rejects a boolean-coerced count", async () => {
1027
+ await assertRejects(
1028
+ () =>
1029
+ withCapturingFetch(
1030
+ () => ({ status: 200, body: { status: "ahead", ahead_by: 1, behind_by: true } }),
1031
+ () => compareCommits("o/r", "base", "head", "tok"),
1032
+ ),
1033
+ Error,
1034
+ "invalid behind_by",
1035
+ );
1036
+ });
1037
+
1038
+ test("compareCommits: rejects an unsafe-magnitude integer count (passes Number.isInteger but is not a safe integer)", async () => {
1039
+ await assertRejects(
1040
+ () =>
1041
+ withCapturingFetch(
1042
+ () => ({ status: 200, body: { status: "ahead", ahead_by: 1e21, behind_by: 0 } }),
1043
+ () => compareCommits("o/r", "base", "head", "tok"),
1044
+ ),
1045
+ Error,
1046
+ "invalid ahead_by",
1047
+ );
1048
+ });
1049
+
1050
+ test("compareCommits: rejects a negative count", async () => {
1051
+ await assertRejects(
1052
+ () =>
1053
+ withCapturingFetch(
1054
+ () => ({ status: 200, body: { status: "ahead", ahead_by: -3, behind_by: 0 } }),
1055
+ () => compareCommits("o/r", "base", "head", "tok"),
1056
+ ),
1057
+ Error,
1058
+ "invalid ahead_by",
1059
+ );
1060
+ });
1061
+
1062
+ test("compareCommits: no token → null (idle transport, never a throw)", async () => {
1063
+ const cmp = await withCapturingFetch(
1064
+ () => {
1065
+ throw new Error("fetch must not be called without a token");
1066
+ },
1067
+ () => compareCommits("o/r", "base", "head", ""),
1068
+ );
1069
+ assertEquals(cmp, null);
1070
+ });
1071
+
1072
+ test("compareCommits: a non-2xx transport response throws (an outage is not a silent 'diverged')", async () => {
1073
+ await assertRejects(
1074
+ () =>
1075
+ withCapturingFetch(
1076
+ () => ({ status: 500, statusText: "Server Error", body: {} }),
1077
+ () => compareCommits("o/r", "base", "head", "tok"),
1078
+ ),
1079
+ Error,
1080
+ "500",
1081
+ );
1082
+ });
1083
+
1084
+ test("updateBranchRef: a successful fast-forward PATCHes force:false and returns true", async () => {
1085
+ let seen: { path: string; method: string; body: unknown } | undefined;
1086
+ const moved = await withCapturingFetch(
1087
+ (req) => {
1088
+ seen = req;
1089
+ return { status: 200, body: { ref: "refs/heads/feat/x", object: { sha: "deadbeef" } } };
1090
+ },
1091
+ () => updateBranchRef("o/r", "feat/x", "deadbeef", "tok"),
1092
+ );
1093
+ assertEquals(moved, true);
1094
+ assertEquals(seen?.method, "PATCH", "the ref move is a PATCH");
1095
+ assertEquals(seen?.path, "o/r/git/refs/heads/feat/x", "targeting the head ref");
1096
+ // The whole point of the self-heal transport: it NEVER force-updates — GitHub must be free to
1097
+ // refuse a non-fast-forward. A regression flipping this to force:true would rewrite history.
1098
+ assertEquals(seen?.body, { sha: "deadbeef", force: false });
1099
+ });
1100
+
1101
+ test("updateBranchRef: GitHub's 422 non-fast-forward refusal returns false, never throws", async () => {
1102
+ const moved = await withCapturingFetch(
1103
+ () => ({ status: 422, statusText: "Unprocessable Entity", body: { message: "Update is not a fast forward" } }),
1104
+ () => updateBranchRef("o/r", "feat/x", "deadbeef", "tok"),
1105
+ );
1106
+ assertEquals(moved, false, "a refused non-fast-forward is a safe false (→ the caller escalates)");
1107
+ });
1108
+
1109
+ test("updateBranchRef: a non-422 transport error throws (a real outage is not a silent refusal)", async () => {
1110
+ await assertRejects(
1111
+ () =>
1112
+ withCapturingFetch(
1113
+ () => ({ status: 500, statusText: "Server Error", body: { message: "boom" } }),
1114
+ () => updateBranchRef("o/r", "feat/x", "deadbeef", "tok"),
1115
+ ),
1116
+ Error,
1117
+ "500",
1118
+ );
1119
+ });
1120
+
1121
+ test("updateBranchRef: no token in token mode throws (no usable transport to move the ref)", async () => {
1122
+ await assertRejects(
1123
+ () =>
1124
+ withCapturingFetch(
1125
+ () => {
1126
+ throw new Error("fetch must not be called without a token");
1127
+ },
1128
+ () => updateBranchRef("o/r", "feat/x", "deadbeef", ""),
1129
+ ),
1130
+ Error,
1131
+ "no GitHub transport available",
1132
+ );
1133
+ });
1134
+
1135
+ // ── updateBranchRef compare-and-swap (Copilot review on #819) ────────────────
1136
+ // When `expectedSha` is given the move is a CAS on the ref's prior value: read the ref right before
1137
+ // the PATCH and refuse unless it still equals the validated base, so a head advanced/replaced by a
1138
+ // concurrent (superseded) run after the caller's fast-forward proof is never PATCHed.
1139
+ test("updateBranchRef: CAS advances when the ref still equals expectedSha (reads then PATCHes)", async () => {
1140
+ const seen: string[] = [];
1141
+ const moved = await withCapturingFetch(
1142
+ (req) => {
1143
+ seen.push(`${req.method} ${req.path}`);
1144
+ if (req.method === "GET") return { status: 200, body: { object: { sha: "basebase" } } };
1145
+ return { status: 200, body: { ref: "refs/heads/feat/x", object: { sha: "deadbeef" } } };
1146
+ },
1147
+ () => updateBranchRef("o/r", "feat/x", "deadbeef", "tok", "basebase"),
1148
+ );
1149
+ assertEquals(moved, true, "the ref still pointed at the validated base, so the fast-forward applied");
1150
+ assertEquals(
1151
+ seen,
1152
+ ["GET o/r/git/ref/heads/feat/x", "PATCH o/r/git/refs/heads/feat/x"],
1153
+ "the CAS reads the ref before mutating it",
1154
+ );
1155
+ });
1156
+
1157
+ test("updateBranchRef: CAS refuses (false, no PATCH) when the ref has moved off expectedSha", async () => {
1158
+ const methods: string[] = [];
1159
+ const moved = await withCapturingFetch(
1160
+ (req) => {
1161
+ methods.push(req.method);
1162
+ if (req.method === "GET") return { status: 200, body: { object: { sha: "movedmoved" } } };
1163
+ throw new Error("must not PATCH once the compare-and-swap has lost");
1164
+ },
1165
+ () => updateBranchRef("o/r", "feat/x", "deadbeef", "tok", "basebase"),
1166
+ );
1167
+ assertEquals(moved, false, "a ref moved off the validated base refuses the move (→ the caller escalates)");
1168
+ assertEquals(methods, ["GET"], "a lost CAS never issues the PATCH");
1169
+ });
1170
+
1171
+ test("updateBranchRef: CAS refuses (false) when the ref is absent/unreadable (null)", async () => {
1172
+ const moved = await withCapturingFetch(
1173
+ (req) => {
1174
+ if (req.method === "GET") return { status: 404, statusText: "Not Found", body: { message: "Not Found" } };
1175
+ throw new Error("must not PATCH when the base ref is gone");
1176
+ },
1177
+ () => updateBranchRef("o/r", "feat/x", "deadbeef", "tok", "basebase"),
1178
+ );
1179
+ assertEquals(moved, false, "a deleted/unreadable base ref is not the validated state, so the move is refused");
1180
+ });