@nanobpm/nano-workforce 0.171.4 → 0.171.6

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,15 @@
1
+ ## [0.171.6](https://github.com/nanobpm/nano-workforce/compare/v0.171.5...v0.171.6) (2026-09-01)
2
+
3
+ ### Bug Fixes
4
+
5
+ * **agentic:** emit the repository envelope on implementation jobs so they run in an isolated clone ([#685](https://github.com/nanobpm/nano-workforce/issues/685)) ([be98081](https://github.com/nanobpm/nano-workforce/commit/be980818321f18e101d0fbef85b30b75d6a6fd15)), closes [#684](https://github.com/nanobpm/nano-workforce/issues/684)
6
+
7
+ ## [0.171.5](https://github.com/nanobpm/nano-workforce/compare/v0.171.4...v0.171.5) (2026-09-01)
8
+
9
+ ### Performance Improvements
10
+
11
+ * **agents:** warm the build cache once, then build module-scoped — never a cold `-am` reactor build ([#682](https://github.com/nanobpm/nano-workforce/issues/682)) ([338057b](https://github.com/nanobpm/nano-workforce/commit/338057b7f65cbf4e61cf4d74efaa596b3422532b)), closes [#681](https://github.com/nanobpm/nano-workforce/issues/681)
12
+
1
13
  ## [0.171.4](https://github.com/nanobpm/nano-workforce/compare/v0.171.3...v0.171.4) (2026-09-01)
2
14
 
3
15
  ### Bug Fixes
package/app/contracts.ts CHANGED
@@ -382,11 +382,11 @@ export const WIRE_CONTRACTS = {
382
382
  "io.nanobpm.agentTask.repository": {
383
383
  category: "wire",
384
384
  name: "io.nanobpm.agentTask.repository",
385
- owner: "app/service.ts",
385
+ owner: "app/repoEnvelope.ts",
386
386
  semantics:
387
- "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). World-restore (issue #324, ADR 0062 Slice 4/5): an optional `commitSha` — the last durable push-checkpoint — is emitted so a REPLACEMENT activation on a fresh worktree reconstructs the tree to the EXACT pushed SHA (inverting the round's `git push` into `git fetch && git checkout <sha>`), omitted when the PR has no checkpoint yet. Gated on c8ctl provisioner support (jwulf/c8ctl-plugin-nano#91).",
387
+ "Repo-provisioning envelope the app emits as a `createInstance` process variable (`repoEnvelopeVars`, app/repoEnvelope.ts) and the c8ctl worker harness consumes to provision an isolated clone — instead of the agent inheriting the worker's launch dir (issue #684). `ref` is the branch checked out: the PR HEAD branch on the PR-based paths (review-round / fix-ci / rebase), or — on the PRE-PR implementation path (feature.bpmn / plan-fanout's `implement-cell`, issue #684) — the BASE branch, off which the harness cuts a new feature branch named by the optional `branch.create` (the deterministic `feat/<task.id>`, emitted only for a single-task feature run; the epic seed omits it so each slice's agent branches per MI child). 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). World-restore (issue #324, ADR 0062 Slice 4/5): an optional `commitSha` — the last durable push-checkpoint — is emitted so a REPLACEMENT activation on a fresh worktree reconstructs the tree to the EXACT pushed SHA (inverting the round's `git push` into `git fetch && git checkout <sha>`), omitted when the PR has no checkpoint yet. Gated on c8ctl provisioner support (jwulf/c8ctl-plugin-nano#91).",
388
388
  shape:
389
- '{ provider: "github", url: string, ref: string, singleBranch: true, filter: "blob:none", baseRef?: string, commitSha?: string }',
389
+ '{ provider: "github", url: string, ref: string, singleBranch: true, filter: "blob:none", baseRef?: string, commitSha?: string, branch?: { create: string } }',
390
390
  },
391
391
  "epicSet.submit": {
392
392
  category: "wire",
@@ -131,6 +131,36 @@ test("startFeature: seeds the single task slice + base-branch brief onto the ins
131
131
  assertEquals(v.status, null);
132
132
  });
133
133
 
134
+ test("startFeature: seeds the pre-PR repository envelope so the harness provisions an isolated clone (#684)", async () => {
135
+ let captured: any = null;
136
+ const engine = {
137
+ createInstance: (req: any) => {
138
+ captured = req;
139
+ return Promise.resolve({ processInstanceKey: "PI-684" });
140
+ },
141
+ } as any;
142
+ await startFeature(
143
+ memData({ feature_runs: { rows: [], key: "feature_key" } }),
144
+ engine,
145
+ PARSED,
146
+ "epic/x",
147
+ true,
148
+ false,
149
+ );
150
+ // Without the envelope the c8ctl harness leaves cwd undefined and the agent mutates the worker's
151
+ // shared launch dir; with it, the harness clones a throwaway workspace. The implementation path is
152
+ // PRE-PR, so it checks out the BASE branch (`ref`) and the harness cuts the deterministic
153
+ // `feat/<task.id>` feature branch off it (`branch.create`).
154
+ const repo = (captured.variables as Record<string, any>)["io.nanobpm.agentTask"].repository;
155
+ assertEquals(repo.url, "https://github.com/owner/repo.git");
156
+ assertEquals(repo.ref, "epic/x");
157
+ assertEquals(repo.branch.create, "feat/issue-42");
158
+ assertEquals(repo.branch.create, `feat/${featureTaskId(PARSED.number)}`);
159
+ // The blobless/single-branch monorepo shaping rides along, exactly like the PR-based envelope.
160
+ assertEquals(repo.singleBranch, true);
161
+ assertEquals(repo.filter, "blob:none");
162
+ });
163
+
134
164
  test("startFeature: custom instructions ride the instance as a variable (trimmed)", async () => {
135
165
  let captured: any = null;
136
166
  const engine = {
package/app/feature.ts CHANGED
@@ -19,6 +19,7 @@ import { TRANSCRIPT_URL_BASE_VAR, transcriptUrlBaseFor } from "./agentic/transcr
19
19
  import { coalesceTitle, fetchIssueTitle } from "./github.ts";
20
20
  import { ESCALATION_SLA_TIMEOUT, normalizeBaseBranch, type ParsedIssue, renderBaseBranchBrief } from "./plan.ts";
21
21
  import type { ReadinessProbe } from "./readiness.ts";
22
+ import { repoEnvelopeVars } from "./repoEnvelope.ts";
22
23
 
23
24
  /** Optional intake-time readiness gate for a feature run (issue #295): the `capability`/`command`/…
24
25
  * probes the run must ALL satisfy before its implementation agent is dispatched (parked, durably, at
@@ -336,6 +337,10 @@ export async function startFeature(
336
337
  updated_at: ts,
337
338
  });
338
339
  }
340
+ const taskId = featureTaskId(parsed.number);
341
+ // Pre-PR feature branch (issue #684): the deterministic `feat/<task.id>` the harness creates off the
342
+ // base for the implementation agent (the agent-guide's `feat/*` convention), matching `task.id`.
343
+ const prePrBranch = `feat/${taskId}`;
339
344
  const { processInstanceKey } = await engine.createInstance({
340
345
  processDefinitionId: FEATURE_PROCESS_ID,
341
346
  variables: {
@@ -349,7 +354,7 @@ export async function startFeature(
349
354
  // (resources/prompts/feature.md); `task.id` fixes its deterministic branch `feat/<task.id>` across a
350
355
  // resume. Unlike an epic, there is no planner — the whole issue IS the slice.
351
356
  task: {
352
- id: featureTaskId(parsed.number),
357
+ id: taskId,
353
358
  title: parsed.planKey,
354
359
  prompt:
355
360
  `Implement the GitHub issue ${parsed.planKey} end to end. Read it in full first ` +
@@ -406,6 +411,15 @@ export async function startFeature(
406
411
  // links this feature run to its agent transcript in Nano Explorer (feature.bpmn `implement-task`
407
412
  // ioMapping). Read down into the job via `=transcriptUrlBase`.
408
413
  [TRANSCRIPT_URL_BASE_VAR]: transcriptUrlBaseFor(),
414
+ // Host-git provisioning (c8ctl, issue #684): deliver the repository envelope so the
415
+ // `senior:feature` implementation agent gets an ISOLATED throwaway clone instead of inheriting
416
+ // the worker's launch dir (which, with several copilot workers on one host, means concurrent
417
+ // implementation jobs share — and clobber — one checkout, violating the durable-resume design).
418
+ // A feature run is PRE-PR: there is no head branch yet, so the harness checks out the BASE
419
+ // branch (`ref = base`) and creates the deterministic `feat/<task.id>` feature branch itself
420
+ // (`branchCreate`), matching the agent-guide's `feat/*` convention. Spread last so an unresolved
421
+ // repo (`{}`) leaves the other vars untouched.
422
+ ...repoEnvelopeVars(parsed.repo, base, null, null, prePrBranch),
409
423
  },
410
424
  });
411
425
  const processKey = processInstanceKey == null ? null : String(processInstanceKey);
package/app/plan.test.ts CHANGED
@@ -241,6 +241,15 @@ test("startPlan pins the base branch: persisted on the row + seeded as baseBranc
241
241
  // Process variables the implement-task consumes.
242
242
  assertEquals(seen.baseBranch, "epic/agent-protocol");
243
243
  assertEquals(seen.baseBranchBrief.includes("gh pr create --base epic/agent-protocol"), true);
244
+ // Host-git provisioning (#684): the whole-epic repository envelope so each slice's implementation
245
+ // agent gets an isolated clone. The epic seed checks out the BASE branch (`ref`) — each slice's
246
+ // `feat/<task.id>` branch differs per MI child, so NO `branch.create` here (the agent branches).
247
+ const repo = seen["io.nanobpm.agentTask"].repository;
248
+ assertEquals(repo.url, "https://github.com/owner/repo.git");
249
+ assertEquals(repo.ref, "epic/agent-protocol");
250
+ assertEquals("branch" in repo, false);
251
+ assertEquals(repo.singleBranch, true);
252
+ assertEquals(repo.filter, "blob:none");
244
253
  });
245
254
 
246
255
  test("startPlan renders baseBranchBrief unconditionally now that base is required", async () => {
package/app/plan.ts CHANGED
@@ -24,6 +24,7 @@ import {
24
24
  import { derivedTrackingTable } from "./instanceTracking.ts";
25
25
  import { clearExclusions } from "./mergeExclusion.ts";
26
26
  import type { ReadinessProbe } from "./readiness.ts";
27
+ import { repoEnvelopeVars } from "./repoEnvelope.ts";
27
28
  import { clearTaskDeltas } from "./taskDelta.ts";
28
29
 
29
30
  /** The BPMN process this module drives (resources/processes/plan-fanout.bpmn). */
@@ -1074,6 +1075,16 @@ export async function startPlan(
1074
1075
  // instance. A ROOT never runs the preflight, so its `gateKey` stays `null`, unused.
1075
1076
  gateKey: probes ? `preflight:${parsed.planKey}` : null,
1076
1077
  resolvedArtifacts: null,
1078
+ // Host-git provisioning (c8ctl, issue #684): deliver the repository envelope so each epic slice's
1079
+ // `senior:feature` implementation agent (plan-fanout's per-wave `implement-cell`) gets an
1080
+ // ISOLATED throwaway clone instead of inheriting the worker's launch dir — otherwise several
1081
+ // copilot workers on one host share (and clobber) a single checkout, violating the durable-resume
1082
+ // design. This is the whole-epic seed, so it carries `ref = base` (the epic integration branch)
1083
+ // but NO `branchCreate`: each slice's deterministic `feat/<task.id>` branch differs per MI child,
1084
+ // so the agent cuts its own branch inside the isolated clone (per resources/prompts/feature.md).
1085
+ // The process-level variable propagates through the wave subprocess + `implement-cell` callActivity
1086
+ // into each agent job. Spread last so an unresolved repo (`{}`) leaves the other vars untouched.
1087
+ ...repoEnvelopeVars(parsed.repo, base),
1077
1088
  },
1078
1089
  });
1079
1090
  const processKey = processInstanceKey == null ? null : String(processInstanceKey);
@@ -0,0 +1,102 @@
1
+ // nano-workforce — the repository-provisioning envelope (`io.nanobpm.agentTask.repository`).
2
+ //
3
+ // The ONE canonical builder of the agent-task repository envelope the c8ctl nano worker harness
4
+ // consumes to provision an ISOLATED clone for an agent job — instead of the agent inheriting
5
+ // whatever directory the worker was launched from (which, with several copilot workers on one host,
6
+ // means concurrent jobs share — and clobber — one checkout; see issue #684). It lives in its own
7
+ // module (not service.ts) so BOTH the PR-based dispatch (`service.ts`: review-round / fix-ci /
8
+ // rebase) and the PRE-PR implementation dispatch (`feature.ts`: `startFeature`; `plan.ts`:
9
+ // `startPlan` → the epic's `implement-cell`) derive from this single implementation without a
10
+ // `plan.ts ↔ service.ts` import cycle (AGENTS.md "derivation over duplication — no drift surfaces").
11
+ import { isCommitSha } from "./world/index.ts";
12
+
13
+ /** The reserved namespace key the c8ctl nano worker harness reads the agent-task envelope from
14
+ * (headers ∪ variables, deep-merged). See c8ctl `normalizeTaskEnvelope`. */
15
+ const AGENT_TASK_NS = "io.nanobpm.agentTask";
16
+
17
+ /** Build the repository slice of the agent-task envelope for an agent job. Delivered as a *process
18
+ * variable* under the reserved `io.nanobpm.agentTask` key so the harness provisions an isolated
19
+ * clone — instead of the agent inheriting whatever directory the worker was launched from (which
20
+ * only happened to be a usable checkout for repos already present locally). `ref` is the branch the
21
+ * harness checks out: the PR HEAD branch on the PR-based paths (review-round / fix-ci / rebase), or —
22
+ * on the PRE-PR implementation path (`branchCreate` set) — the BASE branch, off which the harness
23
+ * cuts the new feature branch. When `ref` is unresolved we emit nothing (no `repository.url`) so the
24
+ * harness falls back to the legacy launch-dir behavior rather than silently cloning the repo's
25
+ * default branch. The static `task.prompt` header on the service task deep-merges with this over the
26
+ * same namespace.
27
+ *
28
+ * The clone is requested **branch-scoped and blobless** (`singleBranch: true` + `filter:
29
+ * "blob:none"`) so large monorepos (e.g. `camunda/camunda`, ~1.16 GB) provision within the c8ctl
30
+ * clone timeout instead of full-cloning the whole history (issue #287). `blob:none` is a *blobless*
31
+ * partial clone (trees are still fetched up-front — a *treeless* clone would be `--filter=tree:0`); it
32
+ * keeps the full *commit graph* (so `git merge-base` / the review 3-dot diff stays correct) while
33
+ * fetching file blobs lazily — small upfront, correct diffs. `--depth 1` is deliberately NOT used:
34
+ * it would drop the merge-base and break `git diff origin/<base>...HEAD`. When the PR base branch
35
+ * is known we also emit `baseRef` so the harness fetches the base tip alongside the head, keeping
36
+ * that base reachable for the diff.
37
+ *
38
+ * World-restore (issue #324, ADR 0062 Slice 4/5): when a PR already has a durable push-checkpoint,
39
+ * `commitSha` is emitted so a REPLACEMENT activation (a fresh worktree after a lease loss)
40
+ * reconstructs the working tree to the EXACT pushed SHA — the inversion of the round's outbound
41
+ * `git push` into an inbound `git fetch && git checkout <sha>` — rather than to a branch tip that may
42
+ * have moved. Omitted (no key) when the PR has no checkpoint yet, so a first activation clones the
43
+ * head branch normally.
44
+ *
45
+ * Pre-PR provisioning (issue #684): the implementation path (feature.bpmn / plan-fanout's
46
+ * `implement-cell`) dispatches its agent BEFORE any PR exists, so it passes `ref = base` and a
47
+ * `branchCreate` naming the deterministic `feat/<task.id>` feature branch the harness cuts off that
48
+ * base itself (the agent-guide's `feat/*` convention) — instead of the agent branching by hand — so
49
+ * the isolated clone lands on the right branch deterministically across a resume. `branchCreate` is
50
+ * omitted on the PR-based paths, which check out an existing head. */
51
+ export function repoEnvelopeVars(
52
+ repo: string,
53
+ ref: string | null,
54
+ baseRef: string | null = null,
55
+ commitSha: string | null = null,
56
+ branchCreate: string | null = null,
57
+ ): Record<string, unknown> {
58
+ if (!ref) return {};
59
+ // Defence in depth: every current caller derives `repo` from parsePr/parseIssue (regex-bounded to
60
+ // `owner/repo`), but this is an exported helper the fan-out epic gives many new callers. A repo
61
+ // that is not exactly `owner/repo` would build a bogus clone URL, so emit nothing (the harness
62
+ // then falls back to the launch-dir behaviour) rather than handing the harness a malformed URL.
63
+ // The owner is a GitHub login (alphanumeric + hyphen); the repo-name segment additionally allows
64
+ // `.` and `_`. A trailing `.git` is rejected outright so we never emit a double-suffixed
65
+ // `…/owner/repo.git.git`, and the anchored allowlist bars query/fragment/host-injection chars.
66
+ if (!/^[A-Za-z0-9-]+\/[A-Za-z0-9._-]+$/.test(repo) || /\.git$/i.test(repo)) return {};
67
+ return {
68
+ [AGENT_TASK_NS]: {
69
+ repository: {
70
+ provider: "github",
71
+ url: `https://github.com/${repo}.git`,
72
+ ref,
73
+ // Branch-scoped, blobless partial clone (issue #287): fetch only the head branch with lazy
74
+ // blobs so large monorepos provision within the clone timeout. Single-branch + blob:none
75
+ // (not --depth 1) preserves the commit graph so the review's `git diff origin/<base>...HEAD`
76
+ // has a valid merge-base. Gated on c8ctl provisioner support (jwulf/c8ctl-plugin-nano#91).
77
+ singleBranch: true,
78
+ filter: "blob:none",
79
+ // The base branch this PR targets — emitted so the harness fetches its tip alongside the
80
+ // single-branch head, keeping `origin/<base>` reachable for the diff. Omitted when unknown.
81
+ ...(baseRef ? { baseRef } : {}),
82
+ // World-restore (issue #324): the last pushed SHA a replacement activation reconstructs the
83
+ // working tree to (inverting the round's push into a fetch+checkout). Only emitted when it is
84
+ // a well-formed 40-hex commit SHA: `commitSha` is forwarded to the harness as an EXACT
85
+ // checkout target, so a non-SHA ref or a whitespace-tainted value could reconstruct to an
86
+ // unintended ref (a moved branch tip) or fail provisioning. A malformed value degrades to
87
+ // omission — the harness then clones the head branch tip, the pre-#324 behaviour. Omitted too
88
+ // when the PR has no durable push-checkpoint yet.
89
+ ...(isCommitSha(commitSha) ? { commitSha } : {}),
90
+ // Pre-PR provisioning (issue #684): the implementation path (feature.bpmn / plan-fanout's
91
+ // implement-cell) dispatches its agent BEFORE any PR exists, so `ref` is the BASE branch, not a
92
+ // head. `branchCreate` asks the harness to cut the deterministic `feat/<task.id>` feature branch
93
+ // off that base itself (the agent-guide's `feat/*` convention) instead of the agent branching by
94
+ // hand — making the isolated clone land on the right branch deterministically across a resume.
95
+ // Omitted (no key) on the PR-based paths (review/fix-ci/rebase), which check out an existing head.
96
+ ...(typeof branchCreate === "string" && branchCreate.trim() !== ""
97
+ ? { branch: { create: branchCreate.trim() } }
98
+ : {}),
99
+ },
100
+ },
101
+ };
102
+ }
@@ -550,6 +550,34 @@ test("repoEnvelopeVars emits commitSha only for a well-formed 40-hex SHA (world-
550
550
  assertEquals("commitSha" in none, false);
551
551
  });
552
552
 
553
+ // Pre-PR provisioning (issue #684): the implementation path has no head branch yet, so it passes
554
+ // `ref = base` + a `branchCreate` so the harness clones the base and cuts the deterministic
555
+ // `feat/<task.id>` feature branch off it. `branch.create` is emitted only for a non-blank branch and
556
+ // is absent on the PR-based paths (which check out an existing head).
557
+ test("repoEnvelopeVars emits branch.create only for a non-blank pre-PR branch (#684)", () => {
558
+ const repo = (repoEnvelopeVars("owner/repo", "main", null, null, "feat/issue-7") as any)["io.nanobpm.agentTask"]
559
+ .repository;
560
+ assertEquals(repo.ref, "main", "the pre-PR envelope checks out the BASE branch as its ref");
561
+ assertEquals(repo.branch.create, "feat/issue-7", "the harness cuts the deterministic feature branch off the base");
562
+ // Still branch-scoped and blobless like the PR-based envelope.
563
+ assertEquals(repo.singleBranch, true);
564
+ assertEquals(repo.filter, "blob:none");
565
+ // A whitespace-tainted branch is trimmed; a blank/absent one omits the `branch` key entirely so the
566
+ // PR-based paths (and any caller that doesn't pre-create a branch) are unaffected.
567
+ assertEquals(
568
+ (repoEnvelopeVars("owner/repo", "main", null, null, " feat/issue-9 ") as any)["io.nanobpm.agentTask"].repository
569
+ .branch.create,
570
+ "feat/issue-9",
571
+ );
572
+ for (const blank of [null, undefined, "", " "]) {
573
+ const r = (repoEnvelopeVars("owner/repo", "main", null, null, blank as any) as any)["io.nanobpm.agentTask"]
574
+ .repository;
575
+ assertEquals("branch" in r, false, `expected no branch key for ${JSON.stringify(blank)}`);
576
+ }
577
+ // The default (4-arg) PR-based call never emits a branch.create.
578
+ assertEquals("branch" in (repoEnvelopeVars("owner/repo", "feat/x", "main") as any)["io.nanobpm.agentTask"].repository, false);
579
+ });
580
+
553
581
  // Durable-resume enrolment gate (issue #325, ADR 0062 Slice 5/5): `worldRestoreSha` — the seam
554
582
  // `submitPr`/`startMerge` thread into `repoEnvelopeVars` — hands the harness the last push-checkpoint
555
583
  // ONLY when the enrolled fleet advertises `durable-resume`. With no participant it degrades to null,
package/app/service.ts CHANGED
@@ -85,6 +85,12 @@ import {
85
85
  readinessPollEvery,
86
86
  readinessTimeout,
87
87
  } from "./readiness.ts";
88
+ // The repository-provisioning envelope builder lives in its own module (app/repoEnvelope.ts) so the
89
+ // PRE-PR implementation dispatch (`feature.ts`/`plan.ts`) can reuse the ONE canonical implementation
90
+ // without a `plan.ts ↔ service.ts` import cycle (issue #684). Imported for the PR-based callers
91
+ // (submitPr/startMerge) and re-exported below so the long-standing `import { repoEnvelopeVars } from
92
+ // "./service.ts"` call sites (and its tests) keep resolving.
93
+ import { repoEnvelopeVars } from "./repoEnvelope.ts";
88
94
  import { clampNudgeMinutes, reviewWaitTimeout } from "./reviewWait.ts";
89
95
  import { trialMergeAudits } from "./trialMerge.ts";
90
96
  import {
@@ -112,7 +118,7 @@ import {
112
118
  } from "./userTasks.ts";
113
119
  import { deriveWaitGate } from "./waitGate.ts";
114
120
  import { waveMergeTargets } from "./waves.ts";
115
- import { isCommitSha, WorldStore } from "./world/index.ts";
121
+ import { WorldStore } from "./world/index.ts";
116
122
 
117
123
  /** The BPMN process that drives review convergence (`resources/processes/convergence-loop.bpmn`). */
118
124
  export const PROCESS_ID = "convergence-loop";
@@ -413,77 +419,10 @@ async function registerDependencies(data: DataLayer, prKey: string, depKeys: str
413
419
  }
414
420
  }
415
421
 
416
- /** The reserved namespace key the c8ctl nano worker harness reads the agent-task envelope from
417
- * (headers variables, deep-merged). See c8ctl `normalizeTaskEnvelope`. */
418
- const AGENT_TASK_NS = "io.nanobpm.agentTask";
419
-
420
- /** Build the repository slice of the agent-task envelope for a PR-based agent job (review-round,
421
- * fix-ci, rebase). Delivered as a *process variable* under the reserved `io.nanobpm.agentTask`
422
- * key so the harness provisions an isolated clone checked out on the PR's head branch — instead of
423
- * the agent inheriting whatever directory the worker was launched from (which only happened to be
424
- * a usable checkout for repos already present locally). `ref` MUST be the PR head branch; when it
425
- * is unresolved we emit nothing (no `repository.url`) so the harness falls back to the legacy
426
- * launch-dir behavior rather than silently cloning the repo's default branch. The static
427
- * `task.prompt` header on the service task deep-merges with this over the same namespace.
428
- *
429
- * The clone is requested **branch-scoped and blobless** (`singleBranch: true` + `filter:
430
- * "blob:none"`) so large monorepos (e.g. `camunda/camunda`, ~1.16 GB) provision within the c8ctl
431
- * clone timeout instead of full-cloning the whole history (issue #287). `blob:none` is a *blobless*
432
- * partial clone (trees are still fetched up-front — a *treeless* clone would be `--filter=tree:0`); it
433
- * keeps the full *commit graph* (so `git merge-base` / the review 3-dot diff stays correct) while
434
- * fetching file blobs lazily — small upfront, correct diffs. `--depth 1` is deliberately NOT used:
435
- * it would drop the merge-base and break `git diff origin/<base>...HEAD`. When the PR base branch
436
- * is known we also emit `baseRef` so the harness fetches the base tip alongside the head, keeping
437
- * that base reachable for the diff.
438
- *
439
- * World-restore (issue #324, ADR 0062 Slice 4/5): when a PR already has a durable push-checkpoint,
440
- * `commitSha` is emitted so a REPLACEMENT activation (a fresh worktree after a lease loss)
441
- * reconstructs the working tree to the EXACT pushed SHA — the inversion of the round's outbound
442
- * `git push` into an inbound `git fetch && git checkout <sha>` — rather than to a branch tip that may
443
- * have moved. Omitted (no key) when the PR has no checkpoint yet, so a first activation clones the
444
- * head branch normally. */
445
- export function repoEnvelopeVars(
446
- repo: string,
447
- ref: string | null,
448
- baseRef: string | null = null,
449
- commitSha: string | null = null,
450
- ): Record<string, unknown> {
451
- if (!ref) return {};
452
- // Defence in depth: every current caller derives `repo` from parsePr/parseIssue (regex-bounded to
453
- // `owner/repo`), but this is an exported helper the fan-out epic gives many new callers. A repo
454
- // that is not exactly `owner/repo` would build a bogus clone URL, so emit nothing (the harness
455
- // then falls back to the launch-dir behaviour) rather than handing the harness a malformed URL.
456
- // The owner is a GitHub login (alphanumeric + hyphen); the repo-name segment additionally allows
457
- // `.` and `_`. A trailing `.git` is rejected outright so we never emit a double-suffixed
458
- // `…/owner/repo.git.git`, and the anchored allowlist bars query/fragment/host-injection chars.
459
- if (!/^[A-Za-z0-9-]+\/[A-Za-z0-9._-]+$/.test(repo) || /\.git$/i.test(repo)) return {};
460
- return {
461
- [AGENT_TASK_NS]: {
462
- repository: {
463
- provider: "github",
464
- url: `https://github.com/${repo}.git`,
465
- ref,
466
- // Branch-scoped, blobless partial clone (issue #287): fetch only the head branch with lazy
467
- // blobs so large monorepos provision within the clone timeout. Single-branch + blob:none
468
- // (not --depth 1) preserves the commit graph so the review's `git diff origin/<base>...HEAD`
469
- // has a valid merge-base. Gated on c8ctl provisioner support (jwulf/c8ctl-plugin-nano#91).
470
- singleBranch: true,
471
- filter: "blob:none",
472
- // The base branch this PR targets — emitted so the harness fetches its tip alongside the
473
- // single-branch head, keeping `origin/<base>` reachable for the diff. Omitted when unknown.
474
- ...(baseRef ? { baseRef } : {}),
475
- // World-restore (issue #324): the last pushed SHA a replacement activation reconstructs the
476
- // working tree to (inverting the round's push into a fetch+checkout). Only emitted when it is
477
- // a well-formed 40-hex commit SHA: `commitSha` is forwarded to the harness as an EXACT
478
- // checkout target, so a non-SHA ref or a whitespace-tainted value could reconstruct to an
479
- // unintended ref (a moved branch tip) or fail provisioning. A malformed value degrades to
480
- // omission — the harness then clones the head branch tip, the pre-#324 behaviour. Omitted too
481
- // when the PR has no durable push-checkpoint yet.
482
- ...(isCommitSha(commitSha) ? { commitSha } : {}),
483
- },
484
- },
485
- };
486
- }
422
+ // The repository-provisioning envelope builder now lives in its own module (app/repoEnvelope.ts);
423
+ // re-export it here so the long-standing `import { repoEnvelopeVars } from "./service.ts"` call
424
+ // sites (and its tests) keep resolving (issue #684).
425
+ export { repoEnvelopeVars };
487
426
 
488
427
  /** The last durable push-checkpoint SHA for a PR (issue #324, ADR 0062 Slice 4/5), or `null` when it
489
428
  * has none yet. Threaded into `repoEnvelopeVars` so a replacement activation reconstructs the exact
@@ -154,6 +154,30 @@ Track a plan the same way you track PRs — its `process_key` is an engine insta
154
154
  can inspect in §5, and the PRs it opens show up in `/status` as ordinary convergence
155
155
  loops.
156
156
 
157
+ ### Build hygiene — implementation agents warm up once, then build module-scoped
158
+
159
+ Implementation and CI-fix agents run on **stateless / ephemeral workers**: the
160
+ workspace, including its build outputs, is thrown away between runs, so each worker pays any
161
+ cold whole-reactor build tax independently. To keep iterations fast, the implementation
162
+ prompts (`resources/prompts/feature.md`, `resources/prompts/fix-ci.md`) instruct every
163
+ agent to, **before iterating on a build in the target repo**:
164
+
165
+ 1. **Read the target repo's own `AGENTS.md` / `CONTRIBUTING.md` build section** — the
166
+ recipe is authored there, never duplicated into nano-workforce (a per-repo copy here
167
+ would rot as the target's build changes).
168
+ 2. **Run the prescribed dependency warm-up once**, right after checkout. For a Maven
169
+ monorepo that ships the fast path (e.g. camunda), that is
170
+ `./mvnw install -Dquickly -T1C` — it installs every reactor SNAPSHOT into `~/.m2`.
171
+ 3. **Then build only the changed module, offline, without `-am`** —
172
+ `./mvnw -Dquickly -o -pl <module> <goals>`. Never run a cold `-am` reactor build per
173
+ iteration.
174
+
175
+ The repo-agnostic infra levers behind this — a host-persisted `~/.m2` and a shared
176
+ remote Maven build cache for JVM-capable worker hosts — live in worker-host config, not
177
+ in per-repo files here. The retro loop (`resources/prompts/retro.md`) promotes any
178
+ durable "warm the cache, then build module-scoped" lesson into the *target repo's*
179
+ `AGENTS.md`, keeping each target's recipe sharp without nano-workforce owning it.
180
+
157
181
  ---
158
182
 
159
183
  ## 3. Answer escalations (unblock a human-in-the-loop wait)
@@ -123,8 +123,18 @@ describe("single-issue feature run (#172 — feature.bpmn)", () => {
123
123
  }
124
124
 
125
125
  test("raise-only: an opened PR ends the run at `opened`, taking the raise-only branch", async () => {
126
+ // Host-git provisioning (#684): capture the repository envelope the `senior:feature` agent job
127
+ // carries — proof the implement-cell agent gets an ISOLATED clone (base branch + a harness-cut
128
+ // `feat/<task.id>`) instead of inheriting the worker's launch dir. It rides the process variable
129
+ // all the way down through feature.bpmn's `implement` callActivity into the cell's agent job.
130
+ let agentRepo: any = null;
126
131
  await withApp(
127
- { "senior:feature": () => ({ status: "opened", pr: "owner/repo#101", summary: "built it" }) },
132
+ {
133
+ "senior:feature": (job) => {
134
+ agentRepo = (job.variables as Record<string, any>)["io.nanobpm.agentTask"]?.repository ?? null;
135
+ return { status: "opened", pr: "owner/repo#101", summary: "built it" };
136
+ },
137
+ },
128
138
  { baseBranch: "epic/e2e" },
129
139
  async ({ app, featureKey }) => {
130
140
  const flows = takenFlows(app);
@@ -134,6 +144,11 @@ describe("single-issue feature run (#172 — feature.bpmn)", () => {
134
144
  );
135
145
  assert.ok(!flows.includes("gw-converge->converge"), "the converge hand-off branch was NOT taken");
136
146
 
147
+ assert.ok(agentRepo, "the senior:feature job carried the io.nanobpm.agentTask repository envelope");
148
+ assert.equal(agentRepo.url, "https://github.com/owner/repo.git", "the harness clones the target repo");
149
+ assert.equal(agentRepo.ref, "epic/e2e", "a PRE-PR job checks out the BASE branch, not a head");
150
+ assert.equal(agentRepo.branch.create, "feat/issue-7", "the harness cuts the deterministic feature branch");
151
+
137
152
  const run = await featureRow(app, featureKey);
138
153
  assert.equal(run.status, "opened", "the run settled at opened");
139
154
  assert.equal(run.pr_key, "owner/repo#101", "the raised PR key was recorded");
@@ -121,6 +121,34 @@ describe("plan-fanout escalations (U2 — task + plan-review + trial-merge → u
121
121
  const singleTaskPlan: Stub = () => ({ tasks: [{ id: "t1", title: "T1", prompt: "do t1" }] });
122
122
  const approveReview: Stub = () => ({ approved: true, findings: "" });
123
123
 
124
+ test("host-git provisioning: each slice's implement-cell agent job carries the repository envelope (#684)", async () => {
125
+ // The whole-epic seed emits `io.nanobpm.agentTask.repository` so every slice's `senior:feature`
126
+ // agent gets an ISOLATED clone instead of clobbering the worker's launch dir. The process variable
127
+ // rides down through the wave subprocess + `implement-cell-call` callActivity into each agent job.
128
+ // The epic seed carries the BASE branch as `ref` but NO `branch.create` — each slice's
129
+ // `feat/<task.id>` differs per MI child, so the agent cuts its own branch inside the clone.
130
+ let agentRepo: any = null;
131
+ await withApp(
132
+ {
133
+ "senior:plan": singleTaskPlan,
134
+ "senior:plan-review": approveReview,
135
+ "senior:feature": (job) => {
136
+ agentRepo = (job.variables as Record<string, any>)["io.nanobpm.agentTask"]?.repository ?? null;
137
+ return { status: "blocked", summary: "n/a" };
138
+ },
139
+ },
140
+ async ({ app }) => {
141
+ await app.settle();
142
+ assert.ok(agentRepo, "the epic slice's senior:feature job carried the io.nanobpm.agentTask envelope");
143
+ assert.equal(agentRepo.url, "https://github.com/owner/repo.git", "the harness clones the target repo");
144
+ assert.equal(agentRepo.ref, "epic/e2e", "the epic seed checks out the BASE (integration) branch");
145
+ assert.equal("branch" in agentRepo, false, "the epic seed omits branch.create — the agent branches per slice");
146
+ assert.equal(agentRepo.singleBranch, true, "the blobless/single-branch monorepo shaping rides along");
147
+ assert.equal(agentRepo.filter, "blob:none");
148
+ },
149
+ );
150
+ });
151
+
124
152
  test("task escalation: a native userTask parks the child; answering routes back to implement-task", async () => {
125
153
  let featureCalls = 0;
126
154
  await withApp(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.171.4",
3
+ "version": "0.171.6",
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",
@@ -91,6 +91,28 @@ against the wrong base will not be merged into the epic.
91
91
  pointing at an issue).
92
92
  5. Clean up any scratch clone/worktree you created outside the commit.
93
93
 
94
+ ## Build hygiene — warm up once, then build module-scoped; never a cold `-am` reactor build
95
+
96
+ Before you iterate on a build in the target repo,
97
+ **read its `AGENTS.md` / `CONTRIBUTING.md` build section** and run the prescribed
98
+ **dependency warm-up exactly once** right after checkout, then scope every
99
+ subsequent build to the module you changed. Do **not** run a cold whole-reactor /
100
+ all-modules build on each iteration — a stateless worker that skips the warm-up
101
+ pays the upstream reactor-compile tax inline on every run, which can block for
102
+ many minutes.
103
+
104
+ - **Warm up once (blocking, right after branching).** For a Maven monorepo that
105
+ ships this fast path (e.g. camunda), that is
106
+ `./mvnw install -Dquickly -T1C` — it installs every reactor SNAPSHOT into
107
+ `~/.m2` so later module builds resolve them instead of recompiling them.
108
+ - **Then build only the changed module, offline, without `-am`.** e.g.
109
+ `./mvnw -Dquickly -o -pl <module> test-compile`. Dropping `-am` after the
110
+ warm-up is what keeps each iteration to seconds; a per-iteration
111
+ `-am <goals>` (e.g. `-am clean test-compile`) re-compiles the upstream modules from source.
112
+ - **The recipe lives in the target repo, not here.** Always defer to that repo's
113
+ own `AGENTS.md` / `CONTRIBUTING.md` build section for the exact commands — the
114
+ Maven lines above are the common case, not a substitute for reading it.
115
+
94
116
  ## Closing keywords vs. scope splits — don't close a broader-scoped parent
95
117
 
96
118
  The convergence loop runs a **scope-integrity classifier** on your PR before it
@@ -50,6 +50,14 @@ the PR's checks yourself (`gh pr checks`, `gh run view`).
50
50
  3. Apply the **minimal, correct** fix. Keep it scoped to what the failing checks
51
51
  demand — do not refactor unrelated code.
52
52
  4. Run the relevant check locally to confirm it now passes.
53
+ **Build module-scoped, not cold.** Before iterating, read the target repo's
54
+ `AGENTS.md` / `CONTRIBUTING.md` build section and run its prescribed
55
+ **dependency warm-up once** (for a Maven monorepo that ships it, e.g. camunda,
56
+ `./mvnw install -Dquickly -T1C`), then scope the build to the changed module
57
+ and drop `-am` (`./mvnw -Dquickly -o -pl <module> <goals>`). Never run a cold
58
+ whole-reactor `-am` build per iteration — a stateless worker that skips the
59
+ warm-up pays the upstream reactor-compile tax inline and can block for many
60
+ minutes.
53
61
  5. Commit (sign off with `-s` if the repo enforces DCO) and push to the branch.
54
62
  6. **Make CI re-validate your fix.** Some repos deliberately run CI only when a
55
63
  PR is *opened* (to keep review cheap), so a follow-up push does **not**
@@ -44,7 +44,11 @@ You have `gh` / git authenticated for the target repository.
44
44
  4. **Choose the right home for each promoted lesson** — the whole point is to make the knowledge
45
45
  *load-bearing*, not just written down:
46
46
  - **`AGENTS.md`** (or `CONTRIBUTING.md`) — a convention, a "before you build, run X", a
47
- non-obvious constraint. The default home.
47
+ non-obvious constraint. The default home. A **build recipe** is the textbook case:
48
+ a durable "warm the dependency cache once, then build module-scoped — never a cold
49
+ whole-reactor build" lesson (e.g. Maven's `./mvnw install -Dquickly -T1C` warm-up,
50
+ then `-pl <module>` without `-am`) belongs in the *target repo's* `AGENTS.md`
51
+ build section, never duplicated into nano-workforce.
48
52
  - **A script** — if the lesson is "always run these steps in this order", encode it as a
49
53
  script (or a `make`/`npm`/`deno task` target) so it can't be forgotten.
50
54
  - **A CI step** — if the lesson is "this class of mistake should never merge", add a guard/gate