@nanobpm/nano-workforce 0.87.0 → 0.88.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 CHANGED
@@ -1,3 +1,10 @@
1
+ # [0.88.0](https://github.com/nanobpm/nano-workforce/compare/v0.87.0...v0.88.0) (2026-08-18)
2
+
3
+
4
+ ### Features
5
+
6
+ * **service:** branch-scoped treeless clone for review-job repo envelope ([#287](https://github.com/nanobpm/nano-workforce/issues/287)) ([#288](https://github.com/nanobpm/nano-workforce/issues/288)) ([c90dc23](https://github.com/nanobpm/nano-workforce/commit/c90dc23274fbb5e19835e25eef755f88337ab86f)), closes [jwulf/c8ctl-plugin-nano#91](https://github.com/jwulf/c8ctl-plugin-nano/issues/91)
7
+
1
8
  # [0.87.0](https://github.com/nanobpm/nano-workforce/compare/v0.86.0...v0.87.0) (2026-08-18)
2
9
 
3
10
 
package/SPEC.md CHANGED
@@ -193,9 +193,17 @@ Consequences the prompt (`resources/prompts/review-round.md`) encodes:
193
193
  `io.nanobpm.agentTask.repository.url`; the **app** supplies it — plus the head
194
194
  branch as `…repository.ref` — as a process variable at `createInstance`
195
195
  (`repoEnvelopeVars` in `app/service.ts`, resolving the head via `fetchPrMeta`/
196
- `fetchPrHead`). The harness is PR-agnostic: it does **not** derive the head branch
197
- from `prNumber`/`prUrl`. When the head can't be resolved the envelope is omitted and
198
- the agent falls back to the worker's launch directory (the legacy behavior).
196
+ `fetchPrHead`). The envelope also carries clone-shaping fields so large monorepos
197
+ provision within the c8ctl clone timeout (issue #287): `singleBranch: true` and
198
+ `filter: "blob:none"` request a **branch-scoped, blobless partial clone** (trees are
199
+ still fetched up-front — a *treeless* clone would be `--filter=tree:0`; the full
200
+ commit graph is kept — no `--depth 1` — so `git merge-base` / the review 3-dot diff
201
+ stays correct while blobs fetch lazily), and, when the PR base branch is resolvable,
202
+ an optional `…repository.baseRef` so the harness fetches the base tip alongside the
203
+ head and keeps `origin/<base>` reachable for the diff. The harness is PR-agnostic: it
204
+ does **not** derive the head branch from `prNumber`/`prUrl`. When the head can't be
205
+ resolved the envelope is omitted and the agent falls back to the worker's launch
206
+ directory (the legacy behavior).
199
207
 
200
208
  ## 6. Signals
201
209
 
@@ -625,8 +633,11 @@ but encode incompatible decisions about a shared contract** — a genuine design
625
633
  integration provisions the repo and checks out the PR's head branch (it must
626
634
  already give the worker repo access to work at all). The **app** resolves the head
627
635
  branch and passes it in the `io.nanobpm.agentTask.repository.{url,ref}` envelope
628
- (a `createInstance` process variable — see `repoEnvelopeVars`); the harness is
629
- PR-agnostic and provisions from that envelope. The worker stays a pure provisioner.
636
+ (a `createInstance` process variable — see `repoEnvelopeVars`), along with the
637
+ branch-scoped, blobless clone-shaping fields (`singleBranch`, `filter`, optional
638
+ `baseRef`) that let large monorepos provision within the clone timeout (#287); the
639
+ harness is PR-agnostic and provisions from that envelope. The worker stays a pure
640
+ provisioner.
630
641
  - **review-ready via GitHub webhook** — same message, swappable faster trigger,
631
642
  when the app is publicly reachable. Deferred (poller-only for v1).
632
643
  - **Supervised vs external worker** — the agent runs as an external
package/app/contracts.ts CHANGED
@@ -334,6 +334,15 @@ export const WIRE_CONTRACTS = {
334
334
  "Op-tagged relay control frame a worker terminal chunk producer emits and the hub consumes. The op-tagged shape superseded the legacy positional `{stream, offset, chunk}` frame (nano-ide #234/#236); a producer must emit the op-tagged shape or the hub rejects it as `malformed relay message payload`.",
335
335
  shape: '{ op: "produce", incarnation: number, stream: string, offset: number, chunk: string }',
336
336
  },
337
+ "io.nanobpm.agentTask.repository": {
338
+ category: "wire",
339
+ name: "io.nanobpm.agentTask.repository",
340
+ owner: "app/service.ts",
341
+ semantics:
342
+ "Repo-provisioning envelope the app emits as a `createInstance` process variable (`repoEnvelopeVars`) and the c8ctl worker harness consumes to provision an isolated clone on the PR head branch. Beyond `{provider,url,ref}`, it carries clone-shaping fields for large monorepos (issue #287): `singleBranch:true` + `filter:\"blob:none\"` (a branch-scoped, blobless partial clone — trees fetched up-front, blobs lazily, no `--depth 1` so the merge-base/3-dot diff stays valid) and an optional `baseRef` (the PR base branch, emitted only when resolvable, so the harness fetches its tip and keeps `origin/<base>` reachable). Gated on c8ctl provisioner support (jwulf/c8ctl-plugin-nano#91).",
343
+ shape:
344
+ '{ provider: "github", url: string, ref: string, singleBranch: true, filter: "blob:none", baseRef?: string }',
345
+ },
337
346
  } as const satisfies Record<string, WireContract>;
338
347
 
339
348
  export const TYPE_CONTRACTS = {
package/app/github.ts CHANGED
@@ -406,6 +406,10 @@ export interface PrMeta {
406
406
  * workspace checkout (`io.nanobpm.agentTask.repository.ref`) so the review agent lands on the
407
407
  * PR branch instead of the worker's launch directory. `null` when GitHub doesn't return it. */
408
408
  headRef: string | null;
409
+ /** The PR's base branch name (e.g. `main`). Emitted in the repository envelope so the c8ctl
410
+ * harness fetches the base tip alongside the single-branch head clone, keeping `git diff
411
+ * origin/<base>...HEAD` (the review 3-dot diff) computable. `null` when GitHub doesn't return it. */
412
+ baseRef: string | null;
409
413
  }
410
414
 
411
415
  export async function fetchPrMeta(
@@ -414,10 +418,10 @@ export async function fetchPrMeta(
414
418
  token: string,
415
419
  ): Promise<PrMeta | null> {
416
420
  if (await useGh()) {
417
- const out = await runGh(["pr", "view", String(number), "--repo", repo, "--json", "title,body,headRefName"]);
421
+ const out = await runGh(["pr", "view", String(number), "--repo", repo, "--json", "title,body,headRefName,baseRefName"]);
418
422
  // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
419
- const j = JSON.parse(out) as { title?: string; body?: string; headRefName?: string | null };
420
- return { title: j.title ?? null, body: j.body ?? "", headRef: j.headRefName ?? null };
423
+ const j = JSON.parse(out) as { title?: string; body?: string; headRefName?: string | null; baseRefName?: string | null };
424
+ return { title: j.title ?? null, body: j.body ?? "", headRef: j.headRefName ?? null, baseRef: j.baseRefName ?? null };
421
425
  }
422
426
  if (!token) return null;
423
427
  const r = await fetch(`https://api.github.com/repos/${repo}/pulls/${number}`, {
@@ -425,8 +429,8 @@ export async function fetchPrMeta(
425
429
  });
426
430
  if (!r.ok) throw new Error(`github ${r.status} ${r.statusText}`.trim());
427
431
  // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
428
- const j = (await r.json()) as { title?: string; body?: string; head?: { ref?: string | null } };
429
- return { title: j.title ?? null, body: j.body ?? "", headRef: j.head?.ref ?? null };
432
+ const j = (await r.json()) as { title?: string; body?: string; head?: { ref?: string | null }; base?: { ref?: string | null } };
433
+ return { title: j.title ?? null, body: j.body ?? "", headRef: j.head?.ref ?? null, baseRef: j.base?.ref ?? null };
430
434
  }
431
435
 
432
436
  /** Fetch an issue's title via the configured transport, mirroring `fetchPrMeta` (both `gh` and
@@ -666,12 +670,12 @@ export async function fetchPrHead(
666
670
  repo: string,
667
671
  number: number | string,
668
672
  token: string,
669
- ): Promise<{ headRef: string | null; headSha: string | null } | null> {
673
+ ): Promise<{ headRef: string | null; headSha: string | null; baseRef: string | null } | null> {
670
674
  if (await useGh()) {
671
- const out = await runGh(["pr", "view", String(number), "--repo", repo, "--json", "headRefName,headRefOid"]);
675
+ const out = await runGh(["pr", "view", String(number), "--repo", repo, "--json", "headRefName,headRefOid,baseRefName"]);
672
676
  // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
673
- const j = JSON.parse(out) as { headRefName?: string | null; headRefOid?: string | null };
674
- return { headRef: j.headRefName ?? null, headSha: j.headRefOid ?? null };
677
+ const j = JSON.parse(out) as { headRefName?: string | null; headRefOid?: string | null; baseRefName?: string | null };
678
+ return { headRef: j.headRefName ?? null, headSha: j.headRefOid ?? null, baseRef: j.baseRefName ?? null };
675
679
  }
676
680
  if (!token) return null;
677
681
  const r = await fetch(`https://api.github.com/repos/${repo}/pulls/${number}`, {
@@ -679,8 +683,8 @@ export async function fetchPrHead(
679
683
  });
680
684
  if (!r.ok) throw new Error(`github ${r.status} ${r.statusText}`.trim());
681
685
  // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
682
- const j = (await r.json()) as { head?: { ref?: string | null; sha?: string | null } };
683
- return { headRef: j.head?.ref ?? null, headSha: j.head?.sha ?? null };
686
+ const j = (await r.json()) as { head?: { ref?: string | null; sha?: string | null }; base?: { ref?: string | null } };
687
+ return { headRef: j.head?.ref ?? null, headSha: j.head?.sha ?? null, baseRef: j.base?.ref ?? null };
684
688
  }
685
689
 
686
690
  /** The PR's current base branch ref — the branch this PR would land *into*. `null` when no
@@ -439,11 +439,26 @@ test("startMerge reads root_request_key off the PR row onto the merge instance",
439
439
  // checkout ref, and omitted entirely when the head branch couldn't be resolved (so the harness
440
440
  // falls back to the legacy launch-dir behavior instead of cloning the wrong default branch).
441
441
  test("repoEnvelopeVars emits the repository envelope keyed on the PR head branch", () => {
442
- const vars = repoEnvelopeVars("owner/repo", "feat/issue-12");
442
+ const vars = repoEnvelopeVars("owner/repo", "feat/issue-12", "main");
443
443
  const env = (vars as any)["io.nanobpm.agentTask"];
444
444
  assertEquals(env.repository.url, "https://github.com/owner/repo.git");
445
445
  assertEquals(env.repository.ref, "feat/issue-12");
446
446
  assertEquals(env.repository.provider, "github");
447
+ // Branch-scoped, blobless partial clone (issue #287): large monorepos provision within the clone
448
+ // timeout while the full commit graph is kept so `git diff origin/<base>...HEAD` has a merge-base.
449
+ assertEquals(env.repository.singleBranch, true);
450
+ assertEquals(env.repository.filter, "blob:none");
451
+ // The base branch is emitted so the harness fetches its tip, keeping `origin/<base>` reachable.
452
+ assertEquals(env.repository.baseRef, "main");
453
+ });
454
+
455
+ test("repoEnvelopeVars omits baseRef when the base branch is unresolved", () => {
456
+ const env = (repoEnvelopeVars("owner/repo", "feat/issue-12") as any)["io.nanobpm.agentTask"];
457
+ // The single-branch/blobless partial-clone request still stands without a base ref…
458
+ assertEquals(env.repository.singleBranch, true);
459
+ assertEquals(env.repository.filter, "blob:none");
460
+ // …but `baseRef` is omitted entirely rather than emitted as null (no key at all).
461
+ assertEquals("baseRef" in env.repository, false);
447
462
  });
448
463
 
449
464
  test("repoEnvelopeVars emits nothing when the head branch is unresolved", () => {
package/app/service.ts CHANGED
@@ -323,8 +323,18 @@ const AGENT_TASK_NS = "io.nanobpm.agentTask";
323
323
  * a usable checkout for repos already present locally). `ref` MUST be the PR head branch; when it
324
324
  * is unresolved we emit nothing (no `repository.url`) so the harness falls back to the legacy
325
325
  * launch-dir behavior rather than silently cloning the repo's default branch. The static
326
- * `task.prompt` header on the service task deep-merges with this over the same namespace. */
327
- export function repoEnvelopeVars(repo: string, ref: string | null): Record<string, unknown> {
326
+ * `task.prompt` header on the service task deep-merges with this over the same namespace.
327
+ *
328
+ * The clone is requested **branch-scoped and blobless** (`singleBranch: true` + `filter:
329
+ * "blob:none"`) so large monorepos (e.g. `camunda/camunda`, ~1.16 GB) provision within the c8ctl
330
+ * clone timeout instead of full-cloning the whole history (issue #287). `blob:none` is a *blobless*
331
+ * partial clone (trees are still fetched up-front — a *treeless* clone would be `--filter=tree:0`); it
332
+ * keeps the full *commit graph* (so `git merge-base` / the review 3-dot diff stays correct) while
333
+ * fetching file blobs lazily — small upfront, correct diffs. `--depth 1` is deliberately NOT used:
334
+ * it would drop the merge-base and break `git diff origin/<base>...HEAD`. When the PR base branch
335
+ * is known we also emit `baseRef` so the harness fetches the base tip alongside the head, keeping
336
+ * that base reachable for the diff. */
337
+ export function repoEnvelopeVars(repo: string, ref: string | null, baseRef: string | null = null): Record<string, unknown> {
328
338
  if (!ref) return {};
329
339
  // Defence in depth: every current caller derives `repo` from parsePr/parseIssue (regex-bounded to
330
340
  // `owner/repo`), but this is an exported helper the fan-out epic gives many new callers. A repo
@@ -336,7 +346,20 @@ export function repoEnvelopeVars(repo: string, ref: string | null): Record<strin
336
346
  if (!/^[A-Za-z0-9-]+\/[A-Za-z0-9._-]+$/.test(repo) || /\.git$/i.test(repo)) return {};
337
347
  return {
338
348
  [AGENT_TASK_NS]: {
339
- repository: { provider: "github", url: `https://github.com/${repo}.git`, ref },
349
+ repository: {
350
+ provider: "github",
351
+ url: `https://github.com/${repo}.git`,
352
+ ref,
353
+ // Branch-scoped, blobless partial clone (issue #287): fetch only the head branch with lazy
354
+ // blobs so large monorepos provision within the clone timeout. Single-branch + blob:none
355
+ // (not --depth 1) preserves the commit graph so the review's `git diff origin/<base>...HEAD`
356
+ // has a valid merge-base. Gated on c8ctl provisioner support (jwulf/c8ctl-plugin-nano#91).
357
+ singleBranch: true,
358
+ filter: "blob:none",
359
+ // The base branch this PR targets — emitted so the harness fetches its tip alongside the
360
+ // single-branch head, keeping `origin/<base>` reachable for the diff. Omitted when unknown.
361
+ ...(baseRef ? { baseRef } : {}),
362
+ },
340
363
  },
341
364
  };
342
365
  }
@@ -364,12 +387,14 @@ export async function submitPr(
364
387
  const token = process.env.GITHUB_TOKEN ?? "";
365
388
  let title: string | null = null;
366
389
  let headRef: string | null = null;
390
+ let baseRef: string | null = null;
367
391
  const depKeys = new Set(dependsOn.map((d) => parsePr(d)?.prKey).filter((k): k is string => !!k));
368
392
  try {
369
393
  const meta = await fetchPrMeta(parsed.repo, parsed.number, token);
370
394
  if (meta) {
371
395
  title = meta.title;
372
396
  headRef = meta.headRef;
397
+ baseRef = meta.baseRef;
373
398
  for (const k of parseDependsOn(meta.body)) depKeys.add(k);
374
399
  }
375
400
  } catch (err) {
@@ -468,7 +493,7 @@ export async function submitPr(
468
493
  // Host-git provisioning (c8ctl): deliver the repository envelope so the `senior:pr-review`
469
494
  // harness clones an isolated workspace checked out on the PR head branch. Spread last so an
470
495
  // unresolved head (`{}`) leaves the other vars untouched.
471
- ...repoEnvelopeVars(parsed.repo, headRef),
496
+ ...repoEnvelopeVars(parsed.repo, headRef, baseRef),
472
497
  },
473
498
  });
474
499
  const processKey = processInstanceKey == null ? null : String(processInstanceKey);
@@ -504,8 +529,11 @@ export async function startMerge(
504
529
  // means the envelope is omitted and the agent falls back to the worker's launch dir.
505
530
  const token = process.env.GITHUB_TOKEN ?? "";
506
531
  let headRef: string | null = null;
532
+ let baseRef: string | null = null;
507
533
  try {
508
- headRef = (await fetchPrHead(pr.repo, pr.number, token))?.headRef ?? null;
534
+ const head = await fetchPrHead(pr.repo, pr.number, token);
535
+ headRef = head?.headRef ?? null;
536
+ baseRef = head?.baseRef ?? null;
509
537
  } catch (err) {
510
538
  console.warn(`[startMerge] ${pr.prKey} head branch fetch: ${err}`);
511
539
  }
@@ -531,7 +559,7 @@ export async function startMerge(
531
559
  abandonBrief: renderAbandonBrief(abUrl),
532
560
  // Host-git provisioning (c8ctl): same repository envelope as the convergence loop, so the
533
561
  // fix-ci/rebase agents operate on an isolated checkout of the PR head branch.
534
- ...repoEnvelopeVars(pr.repo, headRef),
562
+ ...repoEnvelopeVars(pr.repo, headRef, baseRef),
535
563
  },
536
564
  });
537
565
  if (processInstanceKey != null) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.87.0",
3
+ "version": "0.88.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",
@@ -67,6 +67,7 @@
67
67
  "columns": [
68
68
  { "field": "title", "template": "{{title}}", "header": "Item", "subtitleField": "pr_key", "truncate": true, "width": "36%", "link": { "kind": "page", "page": "home", "keyField": "pr_key" } },
69
69
  { "field": "status", "header": "Status", "link": { "kind": "processExplorer", "keyField": "process_key" } },
70
+ { "field": "incident_message", "header": "Incident", "truncate": true, "badge": { "tone": "danger", "label": "1" } },
70
71
  { "field": "current_round", "template": "{{current_round}} · {{active_worker}}", "header": "Round · Agent" },
71
72
  { "field": "updated_at", "header": "Updated", "width": "9rem" }
72
73
  ]