@nanobpm/nano-workforce 0.151.0 → 0.152.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/.github/workflows/ci.yml +22 -0
- package/CHANGELOG.md +12 -0
- package/README.md +41 -0
- package/app/deliveryGraphRun.test.ts +49 -1
- package/app/service.ts +12 -6
- package/install.sh +782 -0
- package/package.json +1 -1
- package/test/install-smoke.sh +101 -0
package/.github/workflows/ci.yml
CHANGED
|
@@ -92,6 +92,28 @@ jobs:
|
|
|
92
92
|
# blocker, and a self-proving canary asserts the oracle's red path genuinely fires so the gate can
|
|
93
93
|
# never rot into a vacuous green while the corpus is parked. Hermetic (in-process wasm engine, no
|
|
94
94
|
# sockets), so it runs on every PR/push as its own job.
|
|
95
|
+
# install.sh onboarding script (issue #576): POSIX-sh cleanliness + a hermetic
|
|
96
|
+
# dry-run smoke test asserting the emitted command sequence. The script is
|
|
97
|
+
# served from `main` for `curl … | sh`, so a broken change is live the instant
|
|
98
|
+
# it merges — this gate keeps that surface honest.
|
|
99
|
+
install-script:
|
|
100
|
+
name: install.sh (shellcheck + dry-run smoke)
|
|
101
|
+
runs-on: ubuntu-latest
|
|
102
|
+
steps:
|
|
103
|
+
- name: Checkout
|
|
104
|
+
uses: actions/checkout@v4
|
|
105
|
+
|
|
106
|
+
- name: Setup Node.js
|
|
107
|
+
uses: actions/setup-node@v4
|
|
108
|
+
with:
|
|
109
|
+
node-version: "24"
|
|
110
|
+
|
|
111
|
+
- name: shellcheck (POSIX sh)
|
|
112
|
+
run: shellcheck -s sh install.sh test/install-smoke.sh
|
|
113
|
+
|
|
114
|
+
- name: Dry-run smoke test
|
|
115
|
+
run: sh test/install-smoke.sh
|
|
116
|
+
|
|
95
117
|
derivation-parity:
|
|
96
118
|
name: derivation parity (nwf corpus regression guard)
|
|
97
119
|
runs-on: ubuntu-latest
|
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
## [0.152.0](https://github.com/nanobpm/nano-workforce/compare/v0.151.1...v0.152.0) (2026-08-28)
|
|
2
|
+
|
|
3
|
+
### Features
|
|
4
|
+
|
|
5
|
+
* **onboarding:** one-command install script — hire your coding harnesses and bring up a Nano workforce ([#597](https://github.com/nanobpm/nano-workforce/issues/597)) ([73b840b](https://github.com/nanobpm/nano-workforce/commit/73b840bbb3c602e83d4cac6990e69d0b77ed0344)), closes [jwulf/c8ctl-plugin-nano#117](https://github.com/jwulf/c8ctl-plugin-nano/issues/117) [#576](https://github.com/nanobpm/nano-workforce/issues/576)
|
|
6
|
+
|
|
7
|
+
## [0.151.1](https://github.com/nanobpm/nano-workforce/compare/v0.151.0...v0.151.1) (2026-08-28)
|
|
8
|
+
|
|
9
|
+
### Bug Fixes
|
|
10
|
+
|
|
11
|
+
* **delivery-graph:** read parked human nodes via searchUserTasks until engine projects USER_TASK wait states ([#595](https://github.com/nanobpm/nano-workforce/issues/595)) ([5dfb3fe](https://github.com/nanobpm/nano-workforce/commit/5dfb3fed1b0483c3a110979bd0aa7802ef3b17d2)), closes [#542](https://github.com/nanobpm/nano-workforce/issues/542) [Magikcraft/nano-bpm#1042](https://github.com/Magikcraft/nano-bpm/issues/1042) [nanobpm/nano-workforce#594](https://github.com/nanobpm/nano-workforce/issues/594)
|
|
12
|
+
|
|
1
13
|
## [0.151.0](https://github.com/nanobpm/nano-workforce/compare/v0.150.4...v0.151.0) (2026-08-28)
|
|
2
14
|
|
|
3
15
|
### Features
|
package/README.md
CHANGED
|
@@ -79,6 +79,47 @@ You can also run Nano Workforce directly, outside Studio, against a running Nano
|
|
|
79
79
|
engine. This is the second-class path — no left-rail integration or update UI — but
|
|
80
80
|
it's the same app and the same behaviour.
|
|
81
81
|
|
|
82
|
+
### Install with one command
|
|
83
|
+
|
|
84
|
+
If you already have one or more coding-agent CLIs installed (`kimi`, `qwen`,
|
|
85
|
+
`copilot`, `claude`, `pi`), one command takes you from there to a **running Nano
|
|
86
|
+
engine with a supervised workforce of hired agents**:
|
|
87
|
+
|
|
88
|
+
```sh
|
|
89
|
+
curl -fsSL https://raw.githubusercontent.com/nanobpm/nano-workforce/main/install.sh | sh
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
It installs `@camunda8/cli` and the `c8ctl-plugin-nano` plugin, installs shell
|
|
93
|
+
completion, detects which of your harnesses are installed, lets you multi-select
|
|
94
|
+
which to hire (with a model and an instance count each), hires each with
|
|
95
|
+
`--rank senior --protocol acp --permission yolo`, composes a declarative
|
|
96
|
+
**workforce manifest** (`c8 nano workforce add`), and brings it up
|
|
97
|
+
(`c8 nano start` then `c8 nano workforce start`).
|
|
98
|
+
|
|
99
|
+
> ⚠️ The hired agents run **unattended** with `--permission yolo` — full tool
|
|
100
|
+
> access (shell, file writes, network) as your user on this host. The script shows
|
|
101
|
+
> a confirmation summary before it hires anything; pass `--yes` to skip it.
|
|
102
|
+
|
|
103
|
+
**What it does *not* do:** it stops at "engine up, workforce up, agents polling".
|
|
104
|
+
It does **not** install/deploy/run the Nano Workforce **app** itself — for that,
|
|
105
|
+
use Nano Studio (above) or the manual steps below.
|
|
106
|
+
|
|
107
|
+
Prompts read from `/dev/tty`, so the `curl … | sh` pipe works interactively. It is
|
|
108
|
+
also fully scriptable and safe to re-run (it converges rather than duplicating):
|
|
109
|
+
|
|
110
|
+
```sh
|
|
111
|
+
# Non-interactive (CI / re-provisioning); repeatable --harness <name>[:<model>][:<instances>]
|
|
112
|
+
curl -fsSL https://raw.githubusercontent.com/nanobpm/nano-workforce/main/install.sh | sh -s -- --harness copilot:gpt-5.4:5 --harness claude:opus:1 --yes
|
|
113
|
+
|
|
114
|
+
# Preview every command without changing anything
|
|
115
|
+
curl -fsSL https://raw.githubusercontent.com/nanobpm/nano-workforce/main/install.sh | sh -s -- --harness copilot:gpt-5.4:1 --dry-run
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
`qwen` must always be given an explicit model (it stalls without one). With no
|
|
119
|
+
controlling terminal and no `--harness`, the script exits with usage rather than
|
|
120
|
+
hanging. Because it is served from `main`, a merged change to `install.sh` is live
|
|
121
|
+
immediately.
|
|
122
|
+
|
|
82
123
|
### Prerequisites
|
|
83
124
|
|
|
84
125
|
- A running **Nano gateway/engine** (default `http://localhost:8080`) — what the app
|
|
@@ -115,7 +115,55 @@ test("pollDeliveryGraphPhase: a numeric engine processInstanceKey still matches
|
|
|
115
115
|
// The engine can yield a NUMERIC key; the poller compares against the string process_key.
|
|
116
116
|
const engine = {
|
|
117
117
|
searchProcessInstances: async () => [{ processInstanceKey: 12345, state: "COMPLETED" }],
|
|
118
|
-
|
|
118
|
+
searchUserTasks: async () => [],
|
|
119
|
+
};
|
|
120
|
+
await pollDeliveryGraphPhase(data, engine as never);
|
|
121
|
+
assertEquals((await runs.get("rk"))?.status, "done");
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// ── pollDeliveryGraphPhase: engine 422 on USER_TASK wait-state read (nano-bpm#1042) ────────────
|
|
126
|
+
// The deployed engine's wait-state read model only accepts JOB | MESSAGE, so a `waitStateType:
|
|
127
|
+
// "USER_TASK"` filter 422s on a live gateway. This pass reads parks via `searchUserTasks({ state:
|
|
128
|
+
// "CREATED" })` — NOT `searchElementInstanceWaitStates` — precisely so that 422 can never reject the
|
|
129
|
+
// `Promise.all` and skip reconciliation. Guard both projections this pass owns against regressing back
|
|
130
|
+
// onto the wait-state channel: the parked-label projection AND the COMPLETED → `done` transition.
|
|
131
|
+
test("pollDeliveryGraphPhase: with an engine that 422s on waitStateType=USER_TASK, the parked label still projects (reads via searchUserTasks)", async () => {
|
|
132
|
+
await withData(async (data) => {
|
|
133
|
+
const runs = deliveryGraphRuns(data);
|
|
134
|
+
const humanEl = humanTaskElementId("publish");
|
|
135
|
+
await runs.insert({
|
|
136
|
+
...claimRow("running"),
|
|
137
|
+
process_key: "PI-1",
|
|
138
|
+
human_labels: JSON.stringify({ [humanEl]: "run the manual OTP publish" }),
|
|
139
|
+
});
|
|
140
|
+
// A client that rejects the wait-state read the way a live gateway does (HTTP 422). If the poller
|
|
141
|
+
// reached for it, the throw would reject the Promise.all and reconciliation would be skipped.
|
|
142
|
+
const engine = {
|
|
143
|
+
searchProcessInstances: async () => [{ processInstanceKey: "PI-1", state: "ACTIVE" }],
|
|
144
|
+
searchUserTasks: async () => [{ userTaskKey: "ut-1", elementId: humanEl }],
|
|
145
|
+
searchElementInstanceWaitStates: async () => {
|
|
146
|
+
throw new Error("HttpSdkError: HTTP 422 — filter.waitStateType did not match any variant of untagged enum WaitStateTypeFilterProperty");
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
await pollDeliveryGraphPhase(data, engine as never);
|
|
150
|
+
const row = await runs.get("rk");
|
|
151
|
+
assertEquals(row?.status, "running");
|
|
152
|
+
assertEquals(row?.phase, "Parked on human node: run the manual OTP publish");
|
|
153
|
+
assertEquals(row?.phase_node_id, humanEl);
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("pollDeliveryGraphPhase: with an engine that 422s on waitStateType=USER_TASK, a COMPLETED instance still reconciles to done", async () => {
|
|
158
|
+
await withData(async (data) => {
|
|
159
|
+
const runs = deliveryGraphRuns(data);
|
|
160
|
+
await runs.insert({ ...claimRow("running"), process_key: "PI-2" });
|
|
161
|
+
const engine = {
|
|
162
|
+
searchProcessInstances: async () => [{ processInstanceKey: "PI-2", state: "COMPLETED" }],
|
|
163
|
+
searchUserTasks: async () => [],
|
|
164
|
+
searchElementInstanceWaitStates: async () => {
|
|
165
|
+
throw new Error("HttpSdkError: HTTP 422 — filter.waitStateType did not match any variant of untagged enum WaitStateTypeFilterProperty");
|
|
166
|
+
},
|
|
119
167
|
};
|
|
120
168
|
await pollDeliveryGraphPhase(data, engine as never);
|
|
121
169
|
assertEquals((await runs.get("rk"))?.status, "done");
|
package/app/service.ts
CHANGED
|
@@ -2391,13 +2391,19 @@ export async function pollEpicPhase(
|
|
|
2391
2391
|
* a graph that ends normally would otherwise stay `running` forever). Generalises the `epic_phase`
|
|
2392
2392
|
* derived-phase machinery to a graph whose element ids aren't known ahead of time: the parked-node
|
|
2393
2393
|
* label is derived from the run row's stamped `human_labels` + the instance's live USER_TASK parks.
|
|
2394
|
-
* The parked node is
|
|
2395
|
-
*
|
|
2396
|
-
*
|
|
2397
|
-
* `
|
|
2394
|
+
* The parked node is read via `searchUserTasks({ state: "CREATED" })` rather than the unified
|
|
2395
|
+
* element-instance wait-state channel (`searchElementInstanceWaitStates({ waitStateType: "USER_TASK" })`,
|
|
2396
|
+
* S8, #542/nano-ide#473): the deployed engine's wait-state read model only accepts `JOB | MESSAGE`, so
|
|
2397
|
+
* a `USER_TASK` filter 422s on a live gateway, rejecting the whole `Promise.all` and skipping this pass's
|
|
2398
|
+
* reconciliation entirely (the parked label never updates and the COMPLETED → `done` transition this pass
|
|
2399
|
+
* OWNS never fires, leaving a finished graph `running` forever). `deriveDeliveryPhase` consumes only
|
|
2400
|
+
* `elementId` from the park rows, which `searchUserTasks` supplies the same way.
|
|
2401
|
+
* TODO(Magikcraft/nano-bpm#1042): re-fold onto `searchElementInstanceWaitStates({ waitStateType:
|
|
2402
|
+
* "USER_TASK" })` once the engine projects `USER_TASK` wait states and that engine is deployed.
|
|
2403
|
+
* Scoped to `running` rows (an `awaiting-approval` run has no instance yet), so it stays O(in-flight). */
|
|
2398
2404
|
export async function pollDeliveryGraphPhase(
|
|
2399
2405
|
data: DataLayer,
|
|
2400
|
-
engine: Pick<EngineClient, "searchProcessInstances" | "
|
|
2406
|
+
engine: Pick<EngineClient, "searchProcessInstances" | "searchUserTasks">,
|
|
2401
2407
|
) {
|
|
2402
2408
|
for (const run of await deliveryGraphRuns(data).find({ status: "running" })) {
|
|
2403
2409
|
if (!run.process_key) continue;
|
|
@@ -2405,7 +2411,7 @@ export async function pollDeliveryGraphPhase(
|
|
|
2405
2411
|
try {
|
|
2406
2412
|
const [snapshots, parks] = await Promise.all([
|
|
2407
2413
|
engine.searchProcessInstances({ processInstanceKeys: [processKey] }),
|
|
2408
|
-
engine.
|
|
2414
|
+
engine.searchUserTasks({ processInstanceKey: processKey, state: "CREATED" }),
|
|
2409
2415
|
]);
|
|
2410
2416
|
const state = snapshots.find((s) => String(s.processInstanceKey) === processKey)?.state ?? null;
|
|
2411
2417
|
const projection = deriveDeliveryPhase(state, parks, parseHumanLabels(run.human_labels));
|
package/install.sh
ADDED
|
@@ -0,0 +1,782 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# Nano Workforce — one-command onboarding installer.
|
|
3
|
+
#
|
|
4
|
+
# curl -fsSL https://raw.githubusercontent.com/nanobpm/nano-workforce/main/install.sh | sh
|
|
5
|
+
#
|
|
6
|
+
# Takes a machine from "has some coding-agent CLIs installed" to "a running Nano
|
|
7
|
+
# engine with a supervised workforce of hired agents". It installs @camunda8/cli
|
|
8
|
+
# and the c8ctl-plugin-nano plugin, lets you pick which of your installed coding
|
|
9
|
+
# harnesses to hire (and with which model / how many instances), composes a
|
|
10
|
+
# declarative workforce manifest from those choices, and brings the engine and
|
|
11
|
+
# the workforce up.
|
|
12
|
+
#
|
|
13
|
+
# Scope: this script stops at "engine up, workforce up, agents polling". It does
|
|
14
|
+
# NOT install/deploy/run the Nano Workforce app itself (that is a follow-up).
|
|
15
|
+
#
|
|
16
|
+
# Constraints (see nanobpm/nano-workforce#576):
|
|
17
|
+
# - POSIX sh only (piped to `sh`, which is dash on many distros). No bashisms:
|
|
18
|
+
# no arrays, no `[[`, no `local`, no `read -a`, no `set -o pipefail`.
|
|
19
|
+
# - stdin is the curl pipe, NOT the keyboard: every prompt reads from /dev/tty.
|
|
20
|
+
# - never sudo on the user's behalf.
|
|
21
|
+
# - shellcheck -s sh clean (enforced in CI).
|
|
22
|
+
|
|
23
|
+
set -eu
|
|
24
|
+
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
# Curated data tables — refresh these against each harness's current docs.
|
|
27
|
+
# Kept together near the top so they are cheap to update.
|
|
28
|
+
#
|
|
29
|
+
# ACP invocation per harness (the base command line the worker spawns). Every
|
|
30
|
+
# hire uses --protocol acp; two harnesses ride an adapter binary:
|
|
31
|
+
#
|
|
32
|
+
# copilot copilot --acp (native; plugin appends --acp)
|
|
33
|
+
# kimi kimi acp (native subcommand)
|
|
34
|
+
# qwen qwen --experimental-acp (native, hidden flag)
|
|
35
|
+
# claude claude-code-acp (adapter: @zed-industries/claude-code-acp)
|
|
36
|
+
# pi pi-acp (adapter: pi-acp)
|
|
37
|
+
#
|
|
38
|
+
# The chosen model is baked into the --command as `--model <id>` so it actually
|
|
39
|
+
# reaches the harness (c8 nano hire --model only sets AGENT_MODEL in the env,
|
|
40
|
+
# which nothing forwards to the CLI automatically). --model is still passed to
|
|
41
|
+
# `hire` for bookkeeping (it shows in `hire --list` / `supervisor status`).
|
|
42
|
+
# ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
ALL_HARNESSES='kimi qwen copilot claude pi'
|
|
45
|
+
|
|
46
|
+
# Curated, known-good model ids for the non-queryable harnesses (copilot, claude,
|
|
47
|
+
# qwen). pi/kimi are queried live and fall back to these. Verify at refresh time.
|
|
48
|
+
MODELS_copilot='gpt-5.4 claude-sonnet-4.6 claude-opus-4.8'
|
|
49
|
+
MODELS_claude='sonnet opus haiku'
|
|
50
|
+
MODELS_qwen='qwen3-coder-plus qwen3-coder-flash qwen-max'
|
|
51
|
+
MODELS_pi='pi-fast pi-balanced pi-max'
|
|
52
|
+
MODELS_kimi='kimi-k2 kimi-k2-turbo'
|
|
53
|
+
|
|
54
|
+
MIN_NODE='22.18.0' # @camunda8/cli engines floor
|
|
55
|
+
PLUGIN='c8ctl-plugin-nano'
|
|
56
|
+
|
|
57
|
+
# ---------------------------------------------------------------------------
|
|
58
|
+
# Output helpers
|
|
59
|
+
# ---------------------------------------------------------------------------
|
|
60
|
+
if [ -t 2 ]; then
|
|
61
|
+
C_RESET=$(printf '\033[0m'); C_BOLD=$(printf '\033[1m')
|
|
62
|
+
C_RED=$(printf '\033[31m'); C_YEL=$(printf '\033[33m'); C_GRN=$(printf '\033[32m')
|
|
63
|
+
else
|
|
64
|
+
C_RESET=''; C_BOLD=''; C_RED=''; C_YEL=''; C_GRN=''
|
|
65
|
+
fi
|
|
66
|
+
|
|
67
|
+
info() { printf '%s\n' "${C_BOLD}==>${C_RESET} $*" >&2; }
|
|
68
|
+
note() { printf '%s\n' " $*" >&2; }
|
|
69
|
+
warn() { printf '%s\n' "${C_YEL}warning:${C_RESET} $*" >&2; }
|
|
70
|
+
err() { printf '%s\n' "${C_RED}error:${C_RESET} $*" >&2; }
|
|
71
|
+
ok() { printf '%s\n' "${C_GRN}ok:${C_RESET} $*" >&2; }
|
|
72
|
+
|
|
73
|
+
die() { err "$*"; exit 1; }
|
|
74
|
+
|
|
75
|
+
# ---------------------------------------------------------------------------
|
|
76
|
+
# Global state
|
|
77
|
+
# ---------------------------------------------------------------------------
|
|
78
|
+
DRY_RUN=0
|
|
79
|
+
ASSUME_YES=0
|
|
80
|
+
SHELL_OVERRIDE=''
|
|
81
|
+
INSTALL_ADAPTERS=0 # auto-install missing adapters without prompting
|
|
82
|
+
CLI_HARNESS_SPECS='' # newline-separated name[:model][:instances] from --harness
|
|
83
|
+
SELECTIONS='' # newline-separated "name<US>model<US>instances" (US = non-whitespace 0x1f, so an empty model field does not collapse under read)
|
|
84
|
+
FAILURES='' # newline-separated failure notes (partial-success report)
|
|
85
|
+
SEP=$(printf '\037') # US (unit separator): non-whitespace, so `read` never collapses adjacent/empty fields
|
|
86
|
+
|
|
87
|
+
# Test hooks (undocumented; used by the CI dry-run smoke test to stay hermetic).
|
|
88
|
+
# NANO_INSTALL_HARNESSES_OVERRIDE — space list of "detected" harnesses.
|
|
89
|
+
# NANO_INSTALL_ADAPTERS_PRESENT — space list of adapter bins to treat present.
|
|
90
|
+
HARNESS_OVERRIDE="${NANO_INSTALL_HARNESSES_OVERRIDE:-}"
|
|
91
|
+
ADAPTERS_PRESENT="${NANO_INSTALL_ADAPTERS_PRESENT:-}"
|
|
92
|
+
# Whether each override was *set at all* (even to ""). A set-but-empty value means
|
|
93
|
+
# "none detected/present" and must NOT fall back to command -v, or the smoke test
|
|
94
|
+
# stops being hermetic on a runner image that happens to ship a harness/adapter binary.
|
|
95
|
+
if [ "${NANO_INSTALL_HARNESSES_OVERRIDE+set}" = set ]; then HARNESS_OVERRIDE_SET=1; else HARNESS_OVERRIDE_SET=0; fi
|
|
96
|
+
if [ "${NANO_INSTALL_ADAPTERS_PRESENT+set}" = set ]; then ADAPTERS_PRESENT_SET=1; else ADAPTERS_PRESENT_SET=0; fi
|
|
97
|
+
|
|
98
|
+
CLI='' # resolved c8ctl / c8 binary
|
|
99
|
+
TTY='' # /dev/tty if usable, else empty
|
|
100
|
+
|
|
101
|
+
# ---------------------------------------------------------------------------
|
|
102
|
+
# Command runner — honours --dry-run for mutating commands.
|
|
103
|
+
# ---------------------------------------------------------------------------
|
|
104
|
+
run() {
|
|
105
|
+
if [ "$DRY_RUN" -eq 1 ]; then
|
|
106
|
+
printf '%s\n' "+ $*" >&2
|
|
107
|
+
return 0
|
|
108
|
+
fi
|
|
109
|
+
"$@"
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
# Like run() but for a command whose printed form differs from argv (a string we
|
|
113
|
+
# assembled). $1 is the human/dry-run string; the rest is the argv to execute.
|
|
114
|
+
run_as() {
|
|
115
|
+
_show=$1; shift
|
|
116
|
+
if [ "$DRY_RUN" -eq 1 ]; then
|
|
117
|
+
printf '%s\n' "+ $_show" >&2
|
|
118
|
+
return 0
|
|
119
|
+
fi
|
|
120
|
+
"$@"
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
record_failure() { FAILURES="${FAILURES}$1
|
|
124
|
+
"; }
|
|
125
|
+
|
|
126
|
+
# ---------------------------------------------------------------------------
|
|
127
|
+
# TTY-backed prompting (stdin is the curl pipe, so read from /dev/tty)
|
|
128
|
+
# ---------------------------------------------------------------------------
|
|
129
|
+
init_tty() {
|
|
130
|
+
if [ -e /dev/tty ] && (: >/dev/tty) 2>/dev/null && (: </dev/tty) 2>/dev/null; then
|
|
131
|
+
TTY=/dev/tty
|
|
132
|
+
else
|
|
133
|
+
TTY=''
|
|
134
|
+
fi
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
# ask "<prompt>" -> sets ANS (empty string on EOF)
|
|
138
|
+
ask() {
|
|
139
|
+
ANS=''
|
|
140
|
+
[ -n "$TTY" ] || return 0
|
|
141
|
+
printf '%s' "$1" >"$TTY"
|
|
142
|
+
IFS= read -r ANS <"$TTY" || ANS=''
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
# confirm "<prompt>" -> returns 0 for yes
|
|
146
|
+
confirm() {
|
|
147
|
+
ask "$1 [y/N] "
|
|
148
|
+
case "$ANS" in
|
|
149
|
+
y|Y|yes|YES|Yes) return 0 ;;
|
|
150
|
+
*) return 1 ;;
|
|
151
|
+
esac
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
# ---------------------------------------------------------------------------
|
|
155
|
+
# Version comparison — is $1 >= $2 (dotted numeric, e.g. 22.18.0)?
|
|
156
|
+
# ---------------------------------------------------------------------------
|
|
157
|
+
ver_field() { # ver index(1..3) -> numeric field, missing = 0
|
|
158
|
+
_v=$1; _i=$2
|
|
159
|
+
_v=${_v%%[!0-9.]*}
|
|
160
|
+
case "$_i" in
|
|
161
|
+
1) printf '%s' "${_v%%.*}" ;;
|
|
162
|
+
2) _r=${_v#*.}; [ "$_r" = "$_v" ] && { printf '0'; return; }; printf '%s' "${_r%%.*}" ;;
|
|
163
|
+
3) _r=${_v#*.}; [ "$_r" = "$_v" ] && { printf '0'; return; }
|
|
164
|
+
_p=${_r#*.}; [ "$_p" = "$_r" ] && { printf '0'; return; }
|
|
165
|
+
printf '%s' "${_p%%.*}" ;;
|
|
166
|
+
esac
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
ver_ge() { # $1 >= $2 ?
|
|
170
|
+
_i=1
|
|
171
|
+
while [ "$_i" -le 3 ]; do
|
|
172
|
+
_a=$(ver_field "$1" "$_i"); _b=$(ver_field "$2" "$_i")
|
|
173
|
+
[ -z "$_a" ] && _a=0; [ -z "$_b" ] && _b=0
|
|
174
|
+
if [ "$_a" -gt "$_b" ]; then return 0; fi
|
|
175
|
+
if [ "$_a" -lt "$_b" ]; then return 1; fi
|
|
176
|
+
_i=$((_i + 1))
|
|
177
|
+
done
|
|
178
|
+
return 0
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
# ---------------------------------------------------------------------------
|
|
182
|
+
# Usage
|
|
183
|
+
# ---------------------------------------------------------------------------
|
|
184
|
+
usage() {
|
|
185
|
+
cat >&2 <<'EOF'
|
|
186
|
+
Nano Workforce installer — hire your coding harnesses and bring up a workforce.
|
|
187
|
+
|
|
188
|
+
Usage:
|
|
189
|
+
curl -fsSL https://raw.githubusercontent.com/nanobpm/nano-workforce/main/install.sh | sh
|
|
190
|
+
sh install.sh [options]
|
|
191
|
+
|
|
192
|
+
Options:
|
|
193
|
+
--harness <name>[:<model>][:<instances>]
|
|
194
|
+
Non-interactive selection; repeatable. <name> is one of
|
|
195
|
+
kimi|qwen|copilot|claude|pi. Omit :model for the harness
|
|
196
|
+
default (NOT allowed for qwen). Omit :instances for 5 on the
|
|
197
|
+
first selection, 1 thereafter. To set instances WITHOUT a
|
|
198
|
+
model, leave the model field empty: name::instances (e.g.
|
|
199
|
+
copilot::2 — 'copilot:2' means model "2", not 2 instances).
|
|
200
|
+
-y, --yes Skip the confirmation summary.
|
|
201
|
+
--install-adapters Auto-install a selected harness's missing ACP adapter
|
|
202
|
+
(claude/pi) instead of prompting/skipping.
|
|
203
|
+
--shell <bash|zsh|fish>
|
|
204
|
+
Override shell-completion detection.
|
|
205
|
+
--dry-run Print every command that would run; change nothing.
|
|
206
|
+
-h, --help Show this help.
|
|
207
|
+
|
|
208
|
+
Non-interactive example:
|
|
209
|
+
curl -fsSL .../install.sh | sh -s -- --harness copilot:gpt-5.4:5 --harness claude:opus:1 --yes
|
|
210
|
+
|
|
211
|
+
With no controlling terminal (/dev/tty) and no --harness, the script exits
|
|
212
|
+
non-zero rather than hanging on a prompt.
|
|
213
|
+
EOF
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
# ---------------------------------------------------------------------------
|
|
217
|
+
# Argument parsing
|
|
218
|
+
# ---------------------------------------------------------------------------
|
|
219
|
+
parse_args() {
|
|
220
|
+
while [ $# -gt 0 ]; do
|
|
221
|
+
case "$1" in
|
|
222
|
+
--harness)
|
|
223
|
+
[ $# -ge 2 ] || die "--harness requires a value (name[:model][:instances])"
|
|
224
|
+
CLI_HARNESS_SPECS="${CLI_HARNESS_SPECS}$2
|
|
225
|
+
"
|
|
226
|
+
shift 2 ;;
|
|
227
|
+
--harness=*)
|
|
228
|
+
CLI_HARNESS_SPECS="${CLI_HARNESS_SPECS}${1#--harness=}
|
|
229
|
+
"
|
|
230
|
+
shift ;;
|
|
231
|
+
--yes|-y) ASSUME_YES=1; shift ;;
|
|
232
|
+
--install-adapters) INSTALL_ADAPTERS=1; shift ;;
|
|
233
|
+
--dry-run) DRY_RUN=1; shift ;;
|
|
234
|
+
--shell)
|
|
235
|
+
[ $# -ge 2 ] || die "--shell requires a value (bash|zsh|fish)"
|
|
236
|
+
SHELL_OVERRIDE=$2; shift 2 ;;
|
|
237
|
+
--shell=*) SHELL_OVERRIDE=${1#--shell=}; shift ;;
|
|
238
|
+
-h|--help) usage; exit 0 ;;
|
|
239
|
+
*) err "unknown option: $1"; usage; exit 2 ;;
|
|
240
|
+
esac
|
|
241
|
+
done
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
# ---------------------------------------------------------------------------
|
|
245
|
+
# Step 1 — Preflight
|
|
246
|
+
# ---------------------------------------------------------------------------
|
|
247
|
+
preflight() {
|
|
248
|
+
info "Preflight"
|
|
249
|
+
command -v node >/dev/null 2>&1 || die "node not found. Install Node >= ${MIN_NODE} (https://nodejs.org or use nvm), then re-run."
|
|
250
|
+
command -v npm >/dev/null 2>&1 || die "npm not found. Install Node/npm >= ${MIN_NODE} (https://nodejs.org), then re-run."
|
|
251
|
+
_nv=$(node -v 2>/dev/null | sed 's/^v//')
|
|
252
|
+
if ! ver_ge "$_nv" "$MIN_NODE"; then
|
|
253
|
+
die "node $_nv is too old — @camunda8/cli needs >= ${MIN_NODE}. Upgrade via https://nodejs.org or nvm, then re-run."
|
|
254
|
+
fi
|
|
255
|
+
ok "node $_nv (>= ${MIN_NODE}), npm present"
|
|
256
|
+
|
|
257
|
+
# Non-fatal: agents need GitHub access to do useful work.
|
|
258
|
+
if [ -z "${GITHUB_TOKEN:-}" ] && [ -z "${GH_TOKEN:-}" ]; then
|
|
259
|
+
if command -v gh >/dev/null 2>&1; then
|
|
260
|
+
if ! gh auth status >/dev/null 2>&1; then
|
|
261
|
+
warn "gh is installed but not authenticated, and no GITHUB_TOKEN/GH_TOKEN is set."
|
|
262
|
+
note "Agents need GitHub access — run 'gh auth login' or export GITHUB_TOKEN before they start pulling work."
|
|
263
|
+
fi
|
|
264
|
+
else
|
|
265
|
+
warn "no gh CLI and no GITHUB_TOKEN/GH_TOKEN in the environment."
|
|
266
|
+
note "Agents need GitHub access — install gh (https://cli.github.com) and 'gh auth login', or export GITHUB_TOKEN."
|
|
267
|
+
fi
|
|
268
|
+
fi
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
# ---------------------------------------------------------------------------
|
|
272
|
+
# Step 2 — Install the CLI + plugin, version-gate on `nano workforce`
|
|
273
|
+
# ---------------------------------------------------------------------------
|
|
274
|
+
resolve_cli() {
|
|
275
|
+
if command -v c8ctl >/dev/null 2>&1; then CLI=c8ctl
|
|
276
|
+
elif command -v c8 >/dev/null 2>&1; then CLI=c8
|
|
277
|
+
else CLI=c8ctl; fi
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
install_cli() {
|
|
281
|
+
info "Installing @camunda8/cli"
|
|
282
|
+
if ! run npm i -g @camunda8/cli; then
|
|
283
|
+
err "'npm i -g @camunda8/cli' failed."
|
|
284
|
+
note "If this was an EACCES permissions error, do NOT sudo blindly. Options:"
|
|
285
|
+
note " - set a user npm prefix: npm config set prefix ~/.npm-global (then add its bin to PATH)"
|
|
286
|
+
note " - use nvm so global installs land in your user dir"
|
|
287
|
+
note " - run 'sudo npm i -g @camunda8/cli' yourself if you understand the implications"
|
|
288
|
+
exit 1
|
|
289
|
+
fi
|
|
290
|
+
resolve_cli
|
|
291
|
+
if [ "$DRY_RUN" -eq 0 ]; then
|
|
292
|
+
command -v "$CLI" >/dev/null 2>&1 || die "@camunda8/cli installed but '$CLI' is not on PATH — add npm's global bin dir to PATH and re-run."
|
|
293
|
+
fi
|
|
294
|
+
ok "@camunda8/cli installed ($CLI)"
|
|
295
|
+
|
|
296
|
+
# Shell completion — non-fatal.
|
|
297
|
+
info "Installing shell completion"
|
|
298
|
+
if [ -n "$SHELL_OVERRIDE" ]; then
|
|
299
|
+
if run "$CLI" completion install --shell "$SHELL_OVERRIDE"; then ok "completion installed (--shell $SHELL_OVERRIDE)"; else warn "completion install failed (non-fatal) — continuing."; fi
|
|
300
|
+
else
|
|
301
|
+
if run "$CLI" completion install; then ok "completion installed"; else warn "completion install failed (non-fatal) — continuing."; fi
|
|
302
|
+
fi
|
|
303
|
+
|
|
304
|
+
# Load the nano plugin — idempotent (load, else upgrade, else already present).
|
|
305
|
+
info "Loading the $PLUGIN plugin"
|
|
306
|
+
if run "$CLI" load plugin "$PLUGIN"; then
|
|
307
|
+
ok "plugin loaded"
|
|
308
|
+
elif run "$CLI" upgrade plugin "$PLUGIN"; then
|
|
309
|
+
ok "plugin upgraded"
|
|
310
|
+
elif [ "$DRY_RUN" -eq 1 ] || "$CLI" nano >/dev/null 2>&1; then
|
|
311
|
+
note "plugin already loaded — continuing."
|
|
312
|
+
else
|
|
313
|
+
die "could not load the $PLUGIN plugin. Try: $CLI load plugin $PLUGIN"
|
|
314
|
+
fi
|
|
315
|
+
|
|
316
|
+
version_gate
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
# Version-gate: `c8 nano workforce` must exist. The command supports `list --json`
|
|
320
|
+
# (documented "for the install script / CI"); on a plugin new enough to have the
|
|
321
|
+
# workforce subcommand that emits a JSON object, older plugins print top-level
|
|
322
|
+
# usage text instead. Detect the JSON.
|
|
323
|
+
version_gate() {
|
|
324
|
+
info "Checking plugin version (needs 'nano workforce')"
|
|
325
|
+
if [ "$DRY_RUN" -eq 1 ]; then
|
|
326
|
+
note "dry-run: skipping live version-gate probe."
|
|
327
|
+
return 0
|
|
328
|
+
fi
|
|
329
|
+
_out=$("$CLI" nano workforce list --json 2>/dev/null || true)
|
|
330
|
+
# Require the first non-whitespace character to be '{' — a real JSON object.
|
|
331
|
+
# An older plugin prints usage/help text, which never starts with '{'. We strip
|
|
332
|
+
# all whitespace and inspect the first character so leading blank lines or
|
|
333
|
+
# indentation don't fool the gate, and so help text that merely mentions
|
|
334
|
+
# "workers"/"version"/"name" can't produce a false positive.
|
|
335
|
+
if [ "$(printf '%s' "$_out" | tr -d '[:space:]' | cut -c1)" = '{' ]; then
|
|
336
|
+
ok "'nano workforce' available"
|
|
337
|
+
else
|
|
338
|
+
die "the installed $PLUGIN is too old: it has no 'nano workforce' command (needs jwulf/c8ctl-plugin-nano#117). Upgrade with: $CLI upgrade plugin $PLUGIN"
|
|
339
|
+
fi
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
# ---------------------------------------------------------------------------
|
|
343
|
+
# Step 3 — Detect installed harnesses
|
|
344
|
+
# ---------------------------------------------------------------------------
|
|
345
|
+
harness_detected() { # $1 harness -> 0 if present
|
|
346
|
+
if [ "$HARNESS_OVERRIDE_SET" -eq 1 ]; then
|
|
347
|
+
case " $HARNESS_OVERRIDE " in *" $1 "*) return 0 ;; *) return 1 ;; esac
|
|
348
|
+
fi
|
|
349
|
+
command -v "$1" >/dev/null 2>&1
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
DETECTED=''
|
|
353
|
+
detect_harnesses() {
|
|
354
|
+
info "Detecting installed coding harnesses"
|
|
355
|
+
DETECTED=''
|
|
356
|
+
for _h in $ALL_HARNESSES; do
|
|
357
|
+
if harness_detected "$_h"; then
|
|
358
|
+
DETECTED="${DETECTED}${_h} "
|
|
359
|
+
note "found: $_h"
|
|
360
|
+
else
|
|
361
|
+
note "not installed (skipping): $_h"
|
|
362
|
+
fi
|
|
363
|
+
done
|
|
364
|
+
DETECTED=$(printf '%s' "$DETECTED" | sed 's/ *$//')
|
|
365
|
+
[ -n "$DETECTED" ] || die "none of ${ALL_HARNESSES} are installed — install a coding-agent CLI first, then re-run."
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
# ---------------------------------------------------------------------------
|
|
369
|
+
# ACP command / adapter tables
|
|
370
|
+
# ---------------------------------------------------------------------------
|
|
371
|
+
acp_base() { # $1 harness -> base ACP invocation
|
|
372
|
+
case "$1" in
|
|
373
|
+
copilot) printf 'copilot --acp' ;;
|
|
374
|
+
kimi) printf 'kimi acp' ;;
|
|
375
|
+
qwen) printf 'qwen --experimental-acp' ;;
|
|
376
|
+
claude) printf 'claude-code-acp' ;;
|
|
377
|
+
pi) printf 'pi-acp' ;;
|
|
378
|
+
*) printf '%s' "$1" ;;
|
|
379
|
+
esac
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
adapter_bin() { # $1 harness -> adapter binary name (empty if native)
|
|
383
|
+
case "$1" in
|
|
384
|
+
claude) printf 'claude-code-acp' ;;
|
|
385
|
+
pi) printf 'pi-acp' ;;
|
|
386
|
+
*) printf '' ;;
|
|
387
|
+
esac
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
adapter_pkg() { # $1 harness -> npm package for its adapter (empty if native)
|
|
391
|
+
case "$1" in
|
|
392
|
+
claude) printf '@zed-industries/claude-code-acp' ;;
|
|
393
|
+
pi) printf 'pi-acp' ;;
|
|
394
|
+
*) printf '' ;;
|
|
395
|
+
esac
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
adapter_present() { # $1 adapter bin -> 0 if present
|
|
399
|
+
if [ "$ADAPTERS_PRESENT_SET" -eq 1 ]; then
|
|
400
|
+
case " $ADAPTERS_PRESENT " in *" $1 "*) return 0 ;; *) return 1 ;; esac
|
|
401
|
+
fi
|
|
402
|
+
command -v "$1" >/dev/null 2>&1
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
# Assemble the full ACP command line for a harness+model (model baked in).
|
|
406
|
+
build_command() { # $1 harness $2 model
|
|
407
|
+
_cmd=$(acp_base "$1")
|
|
408
|
+
if [ -n "$2" ]; then _cmd="$_cmd --model $2"; fi
|
|
409
|
+
printf '%s' "$_cmd"
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
curated_models() { # $1 harness -> space list
|
|
413
|
+
case "$1" in
|
|
414
|
+
copilot) printf '%s' "$MODELS_copilot" ;;
|
|
415
|
+
claude) printf '%s' "$MODELS_claude" ;;
|
|
416
|
+
qwen) printf '%s' "$MODELS_qwen" ;;
|
|
417
|
+
pi) printf '%s' "$MODELS_pi" ;;
|
|
418
|
+
kimi) printf '%s' "$MODELS_kimi" ;;
|
|
419
|
+
*) printf '' ;;
|
|
420
|
+
esac
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
# Live model query for the queryable harnesses. Prints models (best-effort), one
|
|
424
|
+
# per line, or nothing if the query fails / is unavailable.
|
|
425
|
+
live_models() { # $1 harness -> newline list on stdout
|
|
426
|
+
case "$1" in
|
|
427
|
+
pi)
|
|
428
|
+
pi --list-models 2>/dev/null | awk 'NR>1 && $2 != "" {print $2}' ;;
|
|
429
|
+
kimi)
|
|
430
|
+
kimi provider list --json 2>/dev/null \
|
|
431
|
+
| tr ',' '\n' \
|
|
432
|
+
| sed -n 's/.*"\([A-Za-z0-9._]*-[A-Za-z0-9._-]*\)".*/\1/p' ;;
|
|
433
|
+
*) : ;;
|
|
434
|
+
esac
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
# ---------------------------------------------------------------------------
|
|
438
|
+
# Step 4/5 — interactive selection + model + instances
|
|
439
|
+
# ---------------------------------------------------------------------------
|
|
440
|
+
add_selection() { # $1 harness $2 model $3 instances
|
|
441
|
+
if [ -n "$2" ] && ! printf '%s' "$2" | grep -Eq '^[A-Za-z0-9._/@+-]+$'; then
|
|
442
|
+
die "refusing model id '$2' for $1: only [A-Za-z0-9._/@+-] are allowed (':' is reserved as the --harness field separator, so it must not appear in a model id; guards against shell-metacharacter injection into the hire --command)."
|
|
443
|
+
fi
|
|
444
|
+
SELECTIONS="${SELECTIONS}$1${SEP}$2${SEP}$3
|
|
445
|
+
"
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
# Prompt for a model for a harness. Sets ANS to the chosen model (may be empty
|
|
449
|
+
# for "harness default"; never empty for qwen).
|
|
450
|
+
choose_model() { # $1 harness
|
|
451
|
+
_h=$1
|
|
452
|
+
# Build the candidate list: live query for pi/kimi, else curated. Model ids
|
|
453
|
+
# contain no spaces, so a space-separated list + `for` keeps this in the
|
|
454
|
+
# current shell (no subshell to lose _map/_count to).
|
|
455
|
+
_live=$(live_models "$_h" 2>/dev/null | sed '/^$/d' | head -n 20 | tr '\n' ' ' || true)
|
|
456
|
+
if [ -n "$(printf '%s' "$_live" | tr -d ' ')" ]; then
|
|
457
|
+
_cands=$_live
|
|
458
|
+
note "live models for $_h:"
|
|
459
|
+
else
|
|
460
|
+
_cands=$(curated_models "$_h")
|
|
461
|
+
[ -n "$_cands" ] && note "known-good models for $_h:"
|
|
462
|
+
fi
|
|
463
|
+
|
|
464
|
+
_n=0
|
|
465
|
+
_map=''
|
|
466
|
+
for _m in $_cands; do
|
|
467
|
+
[ -n "$_m" ] || continue
|
|
468
|
+
_n=$((_n + 1))
|
|
469
|
+
printf ' %s) %s\n' "$_n" "$_m" >"$TTY"
|
|
470
|
+
_map="${_map}${_n}=${_m} "
|
|
471
|
+
done
|
|
472
|
+
_count=$_n
|
|
473
|
+
|
|
474
|
+
if [ "$_h" = qwen ]; then
|
|
475
|
+
note "qwen requires an explicit model (it stalls with none)."
|
|
476
|
+
_extra=" c) enter your own"
|
|
477
|
+
else
|
|
478
|
+
_extra=" c) enter your own
|
|
479
|
+
d) use the harness default (no model)"
|
|
480
|
+
fi
|
|
481
|
+
printf '%s\n' "$_extra" >"$TTY"
|
|
482
|
+
|
|
483
|
+
while :; do
|
|
484
|
+
if [ "$_h" = qwen ]; then
|
|
485
|
+
ask "Model for $_h (1-${_count} or c): "
|
|
486
|
+
else
|
|
487
|
+
ask "Model for $_h (1-${_count}, c, or d): "
|
|
488
|
+
fi
|
|
489
|
+
case "$ANS" in
|
|
490
|
+
c|C)
|
|
491
|
+
ask "Enter model id for $_h: "
|
|
492
|
+
_m=$(printf '%s' "$ANS" | awk '{print $1}')
|
|
493
|
+
if [ -n "$_m" ]; then ANS=$_m; return 0; fi
|
|
494
|
+
warn "empty model id" ;;
|
|
495
|
+
d|D)
|
|
496
|
+
if [ "$_h" = qwen ]; then warn "qwen needs an explicit model"; continue; fi
|
|
497
|
+
ANS=''; return 0 ;;
|
|
498
|
+
''|*[!0-9]*)
|
|
499
|
+
warn "please choose one of the listed options" ;;
|
|
500
|
+
*)
|
|
501
|
+
_sel=$(printf '%s' "$_map" | tr ' ' '\n' | grep "^${ANS}=" | head -n 1 | cut -d= -f2-)
|
|
502
|
+
if [ -n "$_sel" ]; then ANS=$_sel; return 0; fi
|
|
503
|
+
warn "no such option: $ANS" ;;
|
|
504
|
+
esac
|
|
505
|
+
done
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
# Ensure a harness's ACP adapter is present; may install (with consent) or skip.
|
|
509
|
+
# Returns 0 to keep the harness, 1 to skip it.
|
|
510
|
+
ensure_adapter() { # $1 harness; $2 "interactive" to allow the install prompt
|
|
511
|
+
_h=$1
|
|
512
|
+
_interactive=${2:-}
|
|
513
|
+
_bin=$(adapter_bin "$_h")
|
|
514
|
+
[ -n "$_bin" ] || return 0 # native, nothing to do
|
|
515
|
+
if adapter_present "$_bin"; then return 0; fi
|
|
516
|
+
_pkg=$(adapter_pkg "$_h")
|
|
517
|
+
if [ "$INSTALL_ADAPTERS" -eq 1 ]; then
|
|
518
|
+
info "Installing $_h ACP adapter ($_pkg)"
|
|
519
|
+
if run npm i -g "$_pkg"; then return 0; fi
|
|
520
|
+
warn "adapter install failed for $_h — skipping this harness."
|
|
521
|
+
record_failure "$_h: adapter install ($_pkg) failed"
|
|
522
|
+
return 1
|
|
523
|
+
fi
|
|
524
|
+
# Only prompt in the interactive selection path. A flag-driven (--harness) run
|
|
525
|
+
# must stay fully scriptable even from a terminal — use --install-adapters as
|
|
526
|
+
# the explicit non-interactive install mechanism.
|
|
527
|
+
if [ "$_interactive" = interactive ] && [ -n "$TTY" ] && confirm "$_h needs the ACP adapter '$_bin' ($_pkg). Install it globally now?"; then
|
|
528
|
+
info "Installing $_h ACP adapter ($_pkg)"
|
|
529
|
+
if run npm i -g "$_pkg"; then return 0; fi
|
|
530
|
+
warn "adapter install failed for $_h — skipping this harness."
|
|
531
|
+
record_failure "$_h: adapter install ($_pkg) failed"
|
|
532
|
+
return 1
|
|
533
|
+
fi
|
|
534
|
+
warn "skipping $_h — ACP adapter '$_bin' not installed (install with: npm i -g $_pkg)."
|
|
535
|
+
record_failure "$_h: skipped (missing ACP adapter $_bin)"
|
|
536
|
+
return 1
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
interactive_select() {
|
|
540
|
+
info "Select harnesses to hire"
|
|
541
|
+
_i=0
|
|
542
|
+
_idxmap=''
|
|
543
|
+
for _h in $DETECTED; do
|
|
544
|
+
_i=$((_i + 1))
|
|
545
|
+
printf ' %s) %s\n' "$_i" "$_h" >"$TTY"
|
|
546
|
+
_idxmap="${_idxmap}${_i}=${_h}
|
|
547
|
+
"
|
|
548
|
+
done
|
|
549
|
+
_total=$_i
|
|
550
|
+
|
|
551
|
+
while :; do
|
|
552
|
+
ask "Which to hire? (e.g. '1 3' or 'all'): "
|
|
553
|
+
_pick=$ANS
|
|
554
|
+
[ -n "$_pick" ] || { warn "select at least one, or Ctrl-C to abort"; continue; }
|
|
555
|
+
_chosen=''
|
|
556
|
+
if [ "$_pick" = all ] || [ "$_pick" = ALL ]; then
|
|
557
|
+
_chosen=$DETECTED
|
|
558
|
+
else
|
|
559
|
+
_bad=0
|
|
560
|
+
for _tok in $_pick; do
|
|
561
|
+
case "$_tok" in
|
|
562
|
+
''|*[!0-9]*) warn "not a number: $_tok"; _bad=1; break ;;
|
|
563
|
+
esac
|
|
564
|
+
_m=$(printf '%s' "$_idxmap" | grep "^${_tok}=" | head -n 1 | cut -d= -f2-)
|
|
565
|
+
if [ -z "$_m" ]; then warn "no such option: $_tok"; _bad=1; break; fi
|
|
566
|
+
case " $_chosen " in *" $_m "*) : ;; *) _chosen="$_chosen $_m" ;; esac
|
|
567
|
+
done
|
|
568
|
+
[ "$_bad" -eq 0 ] || continue
|
|
569
|
+
fi
|
|
570
|
+
_chosen=$(printf '%s' "$_chosen" | sed 's/^ *//;s/ *$//')
|
|
571
|
+
[ -n "$_chosen" ] && break
|
|
572
|
+
done
|
|
573
|
+
|
|
574
|
+
_first=1
|
|
575
|
+
for _h in $_chosen; do
|
|
576
|
+
ensure_adapter "$_h" interactive || continue
|
|
577
|
+
choose_model "$_h"; _model=$ANS
|
|
578
|
+
if [ "$_first" -eq 1 ]; then _def=5; else _def=1; fi
|
|
579
|
+
while :; do
|
|
580
|
+
ask "How many '$_h' workers? [default $_def]: "
|
|
581
|
+
if [ -z "$ANS" ]; then _inst=$_def; break; fi
|
|
582
|
+
case "$ANS" in
|
|
583
|
+
''|*[!0-9]*) warn "enter a whole number" ;;
|
|
584
|
+
*) if [ "$ANS" -ge 1 ]; then _inst=$ANS; break; else warn "must be >= 1"; fi ;;
|
|
585
|
+
esac
|
|
586
|
+
done
|
|
587
|
+
add_selection "$_h" "$_model" "$_inst"
|
|
588
|
+
_first=0
|
|
589
|
+
done
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
# Non-interactive selection from --harness specs.
|
|
593
|
+
noninteractive_select() {
|
|
594
|
+
info "Composing workforce from --harness flags"
|
|
595
|
+
_first=1
|
|
596
|
+
# Specs are name[:model][:instances] with no spaces, so iterate with `for`
|
|
597
|
+
# in the current shell (add_selection must mutate SELECTIONS here).
|
|
598
|
+
for _spec in $CLI_HARNESS_SPECS; do
|
|
599
|
+
[ -n "$_spec" ] || continue
|
|
600
|
+
_name=$(printf '%s' "$_spec" | cut -d: -f1)
|
|
601
|
+
_model=$(printf '%s' "$_spec" | cut -s -d: -f2)
|
|
602
|
+
_inst=$(printf '%s' "$_spec" | cut -s -d: -f3)
|
|
603
|
+
case " $ALL_HARNESSES " in
|
|
604
|
+
*" $_name "*) : ;;
|
|
605
|
+
*) die "unknown harness in --harness: '$_name' (expected one of ${ALL_HARNESSES})" ;;
|
|
606
|
+
esac
|
|
607
|
+
if ! harness_detected "$_name"; then
|
|
608
|
+
warn "--harness $_name: not installed on this host — skipping."
|
|
609
|
+
record_failure "$_name: not installed"
|
|
610
|
+
continue
|
|
611
|
+
fi
|
|
612
|
+
if [ "$_name" = qwen ] && [ -z "$_model" ]; then
|
|
613
|
+
die "--harness qwen requires an explicit model (qwen stalls with none): use qwen:<model>[:<instances>]"
|
|
614
|
+
fi
|
|
615
|
+
ensure_adapter "$_name" || continue
|
|
616
|
+
if [ -z "$_inst" ]; then
|
|
617
|
+
if [ "$_first" -eq 1 ]; then _inst=5; else _inst=1; fi
|
|
618
|
+
fi
|
|
619
|
+
case "$_inst" in
|
|
620
|
+
''|*[!0-9]*) die "--harness $_name: instances must be a whole number, got '$_inst'" ;;
|
|
621
|
+
esac
|
|
622
|
+
[ "$_inst" -ge 1 ] || die "--harness $_name: instances must be >= 1"
|
|
623
|
+
add_selection "$_name" "$_model" "$_inst"
|
|
624
|
+
_first=0
|
|
625
|
+
done
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
# ---------------------------------------------------------------------------
|
|
629
|
+
# Step 7 — confirm
|
|
630
|
+
# ---------------------------------------------------------------------------
|
|
631
|
+
print_summary() {
|
|
632
|
+
info "Workforce to compose"
|
|
633
|
+
printf '%s\n' "$SELECTIONS" | sed '/^$/d' | while IFS="$SEP" read -r _h _model _inst; do
|
|
634
|
+
_shown=${_model:-'(harness default)'}
|
|
635
|
+
note "$_h ×$_inst model: $_shown"
|
|
636
|
+
done
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
confirm_or_die() {
|
|
640
|
+
[ -n "$SELECTIONS" ] || die "no harnesses selected — nothing to do."
|
|
641
|
+
print_summary
|
|
642
|
+
warn "These agents run UNATTENDED with --permission yolo: full tool access"
|
|
643
|
+
warn "(shell, file writes, network) as $(id -un 2>/dev/null || printf 'the current user') on this host."
|
|
644
|
+
if [ "$ASSUME_YES" -eq 1 ] || [ "$DRY_RUN" -eq 1 ]; then
|
|
645
|
+
[ "$DRY_RUN" -eq 1 ] && note "dry-run: not asking for confirmation."
|
|
646
|
+
return 0
|
|
647
|
+
fi
|
|
648
|
+
if [ -z "$TTY" ]; then
|
|
649
|
+
die "refusing to proceed without confirmation and no /dev/tty — re-run with --yes."
|
|
650
|
+
fi
|
|
651
|
+
confirm "Proceed and bring up this unattended workforce?" || die "aborted by user."
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
# ---------------------------------------------------------------------------
|
|
655
|
+
# Step 6/7 — hire, compose, bring up
|
|
656
|
+
# ---------------------------------------------------------------------------
|
|
657
|
+
hire_all() {
|
|
658
|
+
info "Hiring agents"
|
|
659
|
+
while IFS="$SEP" read -r _h _model _inst; do
|
|
660
|
+
[ -n "$_h" ] || continue
|
|
661
|
+
_cmd=$(build_command "$_h" "$_model")
|
|
662
|
+
_show="$CLI nano hire --name $_h --rank senior --command '$_cmd' --model '$_model' --capabilities '' --protocol acp --permission yolo"
|
|
663
|
+
if run_as "$_show" "$CLI" nano hire \
|
|
664
|
+
--name "$_h" \
|
|
665
|
+
--rank senior \
|
|
666
|
+
--command "$_cmd" \
|
|
667
|
+
--model "$_model" \
|
|
668
|
+
--capabilities '' \
|
|
669
|
+
--protocol acp \
|
|
670
|
+
--permission yolo; then
|
|
671
|
+
ok "hired $_h"
|
|
672
|
+
else
|
|
673
|
+
warn "hire failed for $_h — continuing with the rest."
|
|
674
|
+
record_failure "$_h: hire failed"
|
|
675
|
+
fi
|
|
676
|
+
done <<EOF
|
|
677
|
+
$(printf '%s\n' "$SELECTIONS" | sed '/^$/d')
|
|
678
|
+
EOF
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
compose_workforce() {
|
|
682
|
+
info "Composing the workforce manifest"
|
|
683
|
+
while IFS="$SEP" read -r _h _model _inst; do
|
|
684
|
+
[ -n "$_h" ] || continue
|
|
685
|
+
if run "$CLI" nano workforce add "$_h" --instances "$_inst" --auto; then
|
|
686
|
+
ok "workforce add $_h ×$_inst"
|
|
687
|
+
else
|
|
688
|
+
warn "workforce add failed for $_h."
|
|
689
|
+
record_failure "$_h: workforce add failed"
|
|
690
|
+
fi
|
|
691
|
+
done <<EOF
|
|
692
|
+
$(printf '%s\n' "$SELECTIONS" | sed '/^$/d')
|
|
693
|
+
EOF
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
bring_up() {
|
|
697
|
+
info "Starting the engine"
|
|
698
|
+
if ! run "$CLI" nano start; then
|
|
699
|
+
err "'$CLI nano start' failed."
|
|
700
|
+
record_failure "engine: nano start failed"
|
|
701
|
+
return 1
|
|
702
|
+
fi
|
|
703
|
+
info "Starting the workforce"
|
|
704
|
+
if ! run "$CLI" nano workforce start; then
|
|
705
|
+
err "'$CLI nano workforce start' failed."
|
|
706
|
+
record_failure "workforce: nano workforce start failed"
|
|
707
|
+
return 1
|
|
708
|
+
fi
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
# ---------------------------------------------------------------------------
|
|
712
|
+
# Step 8 — output + hints
|
|
713
|
+
# ---------------------------------------------------------------------------
|
|
714
|
+
print_status_and_hints() {
|
|
715
|
+
info "Workforce status"
|
|
716
|
+
run "$CLI" nano workforce status || true
|
|
717
|
+
|
|
718
|
+
info "What now"
|
|
719
|
+
cat >&2 <<EOF
|
|
720
|
+
$CLI nano status engine health
|
|
721
|
+
$CLI nano workforce list the composed fleet (desired vs actual)
|
|
722
|
+
$CLI nano workforce add <profile> --instances N --auto grow the fleet, then 'workforce start'
|
|
723
|
+
$CLI nano workforce remove <profile> shrink it, then 'workforce start'
|
|
724
|
+
$CLI nano supervisor status per-worker state
|
|
725
|
+
$CLI nano supervisor logs <worker> --follow tail one worker's logs
|
|
726
|
+
$CLI nano workforce stop / $CLI nano stop shut the fleet / engine down
|
|
727
|
+
$CLI nano hire --list the hired profiles
|
|
728
|
+
|
|
729
|
+
Note: the Nano Workforce APP itself is not installed by this script yet — this
|
|
730
|
+
brings up the engine + a workforce of hired agents. See the README's "Run from
|
|
731
|
+
the CLI" section for installing/running the app.
|
|
732
|
+
EOF
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
# ---------------------------------------------------------------------------
|
|
736
|
+
# Partial-success report
|
|
737
|
+
# ---------------------------------------------------------------------------
|
|
738
|
+
report_and_exit() {
|
|
739
|
+
if [ -n "$FAILURES" ]; then
|
|
740
|
+
warn "Completed with some steps skipped or failed:"
|
|
741
|
+
printf '%s\n' "$FAILURES" | sed '/^$/d' | while IFS= read -r _f; do note "- $_f"; done
|
|
742
|
+
exit 1
|
|
743
|
+
fi
|
|
744
|
+
ok "Done — engine up, workforce of hired agents up."
|
|
745
|
+
exit 0
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
# ---------------------------------------------------------------------------
|
|
749
|
+
# main
|
|
750
|
+
# ---------------------------------------------------------------------------
|
|
751
|
+
main() {
|
|
752
|
+
parse_args "$@"
|
|
753
|
+
init_tty
|
|
754
|
+
|
|
755
|
+
# Decide interactive vs non-interactive up front.
|
|
756
|
+
_have_specs=0
|
|
757
|
+
[ -n "$(printf '%s' "$CLI_HARNESS_SPECS" | sed '/^$/d')" ] && _have_specs=1
|
|
758
|
+
if [ "$_have_specs" -eq 0 ] && [ -z "$TTY" ]; then
|
|
759
|
+
err "no controlling terminal (/dev/tty) and no --harness given — cannot prompt."
|
|
760
|
+
usage
|
|
761
|
+
exit 2
|
|
762
|
+
fi
|
|
763
|
+
|
|
764
|
+
preflight
|
|
765
|
+
install_cli
|
|
766
|
+
detect_harnesses
|
|
767
|
+
|
|
768
|
+
if [ "$_have_specs" -eq 1 ]; then
|
|
769
|
+
noninteractive_select
|
|
770
|
+
else
|
|
771
|
+
interactive_select
|
|
772
|
+
fi
|
|
773
|
+
|
|
774
|
+
confirm_or_die
|
|
775
|
+
hire_all
|
|
776
|
+
compose_workforce
|
|
777
|
+
bring_up || true
|
|
778
|
+
print_status_and_hints
|
|
779
|
+
report_and_exit
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
main "$@"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.152.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",
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# Hermetic smoke test for install.sh — asserts the command sequence it EMITS
|
|
3
|
+
# under --dry-run for a few selections, plus the guard-rail exit codes. No nano
|
|
4
|
+
# CLI or coding harness/adapter needs to be installed: --dry-run means the
|
|
5
|
+
# mutating commands are printed (never run), harness detection is stubbed via
|
|
6
|
+
# NANO_INSTALL_HARNESSES_OVERRIDE, and adapter presence via
|
|
7
|
+
# NANO_INSTALL_ADAPTERS_PRESENT. Only `node` and `npm` are required (the
|
|
8
|
+
# preflight probe still runs), so run it where both are available.
|
|
9
|
+
#
|
|
10
|
+
# Run from the repo root: sh test/install-smoke.sh
|
|
11
|
+
set -eu
|
|
12
|
+
|
|
13
|
+
ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
|
|
14
|
+
SCRIPT="$ROOT/install.sh"
|
|
15
|
+
FAILED=0
|
|
16
|
+
|
|
17
|
+
pass() { printf 'PASS: %s\n' "$1"; }
|
|
18
|
+
fail() { printf 'FAIL: %s\n' "$1" >&2; FAILED=1; }
|
|
19
|
+
|
|
20
|
+
# assert_contains "<label>" "<needle>" "<haystack>"
|
|
21
|
+
assert_contains() {
|
|
22
|
+
case "$3" in
|
|
23
|
+
*"$2"*) pass "$1" ;;
|
|
24
|
+
*) fail "$1 — expected to find: $2"; printf '%s\n' "$3" | sed 's/^/ | /' >&2 ;;
|
|
25
|
+
esac
|
|
26
|
+
}
|
|
27
|
+
assert_not_contains() {
|
|
28
|
+
case "$3" in
|
|
29
|
+
*"$2"*) fail "$1 — did NOT expect: $2" ;;
|
|
30
|
+
*) pass "$1" ;;
|
|
31
|
+
esac
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
# --- Scenario 1: copilot + qwen, models + instances, dry-run ----------------
|
|
35
|
+
OUT=$(NANO_INSTALL_HARNESSES_OVERRIDE="copilot qwen" \
|
|
36
|
+
sh "$SCRIPT" --harness copilot:gpt-5.4:5 --harness qwen:qwen3-coder-plus:2 --yes --dry-run 2>&1)
|
|
37
|
+
assert_contains "s1: copilot model baked into --command" \
|
|
38
|
+
"--command 'copilot --acp --model gpt-5.4'" "$OUT"
|
|
39
|
+
assert_contains "s1: copilot --model bookkeeping" "--model 'gpt-5.4'" "$OUT"
|
|
40
|
+
assert_contains "s1: copilot hired senior/acp/yolo" \
|
|
41
|
+
"--rank senior --command 'copilot --acp --model gpt-5.4' --model 'gpt-5.4' --capabilities '' --protocol acp --permission yolo" "$OUT"
|
|
42
|
+
assert_contains "s1: qwen model baked (mandatory)" \
|
|
43
|
+
"--command 'qwen --experimental-acp --model qwen3-coder-plus'" "$OUT"
|
|
44
|
+
assert_contains "s1: workforce add copilot x5 auto" \
|
|
45
|
+
"nano workforce add copilot --instances 5 --auto" "$OUT"
|
|
46
|
+
assert_contains "s1: workforce add qwen x2 auto" \
|
|
47
|
+
"nano workforce add qwen --instances 2 --auto" "$OUT"
|
|
48
|
+
assert_contains "s1: engine start then workforce start" "nano start" "$OUT"
|
|
49
|
+
assert_contains "s1: workforce start" "nano workforce start" "$OUT"
|
|
50
|
+
assert_contains "s1: yolo warning shown" "UNATTENDED with --permission yolo" "$OUT"
|
|
51
|
+
|
|
52
|
+
# --- Scenario 2: qwen with NO model must fail (mandatory-model rule) ---------
|
|
53
|
+
if NANO_INSTALL_HARNESSES_OVERRIDE="qwen" \
|
|
54
|
+
sh "$SCRIPT" --harness qwen --yes --dry-run >/dev/null 2>&1; then
|
|
55
|
+
fail "s2: qwen without a model should exit non-zero"
|
|
56
|
+
else
|
|
57
|
+
pass "s2: qwen without a model exits non-zero"
|
|
58
|
+
fi
|
|
59
|
+
|
|
60
|
+
# --- Scenario 3: missing ACP adapter, non-interactive decline -> skip + rc1 --
|
|
61
|
+
set +e
|
|
62
|
+
OUT=$(NANO_INSTALL_HARNESSES_OVERRIDE="claude copilot" NANO_INSTALL_ADAPTERS_PRESENT="" \
|
|
63
|
+
sh "$SCRIPT" --harness claude:opus:1 --harness copilot:gpt-5.4:2 --yes --dry-run 2>&1)
|
|
64
|
+
RC=$?
|
|
65
|
+
set -e
|
|
66
|
+
assert_contains "s3: claude skipped (missing adapter)" \
|
|
67
|
+
"skipping claude — ACP adapter 'claude-code-acp' not installed" "$OUT"
|
|
68
|
+
assert_not_contains "s3: claude NOT hired" "nano hire --name claude" "$OUT"
|
|
69
|
+
assert_contains "s3: copilot still hired" "nano hire --name copilot" "$OUT"
|
|
70
|
+
if [ "$RC" -ne 0 ]; then pass "s3: partial success exits non-zero"; else fail "s3: expected non-zero exit after a skip"; fi
|
|
71
|
+
|
|
72
|
+
# --- Scenario 4: no TTY + no --harness must not hang; exits non-zero --------
|
|
73
|
+
# install.sh reads prompts from /dev/tty, and `</dev/null` does not detach the
|
|
74
|
+
# controlling terminal — so from an interactive shell this scenario would open
|
|
75
|
+
# the real tty and hang. Only exercise it when there is no controlling TTY (CI),
|
|
76
|
+
# and skip it (still PASS) when run interactively.
|
|
77
|
+
if ( exec </dev/tty ) >/dev/null 2>&1; then
|
|
78
|
+
pass "s4: skipped (controlling TTY present; would hang interactively)"
|
|
79
|
+
else
|
|
80
|
+
set +e
|
|
81
|
+
sh "$SCRIPT" --dry-run </dev/null >/dev/null 2>&1
|
|
82
|
+
RC=$?
|
|
83
|
+
set -e
|
|
84
|
+
if [ "$RC" -ne 0 ]; then pass "s4: no TTY and no --harness exits non-zero"; else fail "s4: expected non-zero exit with no TTY and no flags"; fi
|
|
85
|
+
fi
|
|
86
|
+
|
|
87
|
+
# --- Scenario 5: default instances (5 then 1) and default model ------------
|
|
88
|
+
OUT=$(NANO_INSTALL_HARNESSES_OVERRIDE="copilot claude" NANO_INSTALL_ADAPTERS_PRESENT="claude-code-acp" \
|
|
89
|
+
sh "$SCRIPT" --harness copilot --harness claude --yes --dry-run 2>&1)
|
|
90
|
+
assert_contains "s5: first selection defaults to 5 instances" \
|
|
91
|
+
"nano workforce add copilot --instances 5 --auto" "$OUT"
|
|
92
|
+
assert_contains "s5: later selection defaults to 1 instance" \
|
|
93
|
+
"nano workforce add claude --instances 1 --auto" "$OUT"
|
|
94
|
+
assert_contains "s5: default model => bare adapter command, empty --model" \
|
|
95
|
+
"--command 'claude-code-acp' --model ''" "$OUT"
|
|
96
|
+
|
|
97
|
+
if [ "$FAILED" -ne 0 ]; then
|
|
98
|
+
printf '\nSMOKE TEST FAILED\n' >&2
|
|
99
|
+
exit 1
|
|
100
|
+
fi
|
|
101
|
+
printf '\nAll install.sh smoke assertions passed.\n'
|