# Row 169 (P19 382, #1666): activate past c216f47db with the PM after the harness kind Owner: Darkwing. Reviewer: Filbert. Gate owner: Sage. Brief: `docs/plans/2026-10-19_pm-after-harness.md` (blob bfc5fa7ba36e), from mos-0848 (a) and (b). Launch: sage-1014. Claimed at rev 1961. Base: 4b738e2da, row 166's landing (sage-1070, mos-0852 point 5). Built first on row 166's approved r1 candidate (manifest 4fc0613f...aced) applied on 56534526b; see "Rebase onto 4b738e2da". Worktree: `~/darkwing-scratch/r169/wt` at 4b738e2da. The 56534526b worktree moved to `~/darkwing-scratch/r169/wt-56534526b`. ## Files | File | Change | |---|---| | `scripts/stack-release.sh` | items 1 to 4 and the `--plan` lines | | `packages/cli/tests/stack-release.test.mjs` | T32 to T36; copies carry `kinds.mjs`, the business loader and `roles/`; every root gets a business file | | `docs/TOOLS.md` | the "Pinned stack update" section only | | this packet | | ## Items **1. Phase 1, `activate ID BUSINESS --pm-after-harness`.** `parse_opts` takes the flag only for `activate`, and `--pm` with it is a usage error (exit 4) in either order. `plugin_checks` runs after `precheck` and before anything stops. In `--plan` it runs after `check_target`. It refuses: - `pm-after-harness-target` when the target's `KINDS` has no `harness`; - `pm-after-harness-set` when the target's loader reads a `plugins.harness`, `[]` included. The message names `--pm`. `flags_drift` needs a pointer with `pm` true for `--pm-after-harness`, as it compares `--pm` with the pointer. `run_pm` sets the run's pm to 0 and pending to 1, so `install_stack` installs the bus without `--pm` and the gate skips `pm_launched`. The pointer gets `pm: false` and `pmPending: true`. The log's last line is `result ok` with `pm pending`, and the success line names the next step. **2. The pending state elsewhere.** `read_pointer` takes `pmPending` missing, or `true` beside `pm: false`. Any other value, and `true` beside `pm: true`, is an unreadable pointer (exit 1), for every verb. A plain `activate`, with or without `--pm` and with `--pm-after-harness`, refuses `pm-pending` before anything stops. `adopt` keeps `already-adopted`. `status` prints `pm: pending (activate --pm-after-harness); next: Jason's plugins set of harness, then stack-release.sh pm-on BUSINESS`. **3. `pm-on BUSINESS [--timeout S]`.** In order, before anything stops: - `not-pending` unless the pointer is pending; - `harness-unset` when the pointer copy's loader refuses the file, when it reads no `harness`, or when `harness` isn't a non-empty list; - `instance_busy`, `drop_in_override`, `read_units`, `no-claude-pin` and `flags_drift`. Then it takes a new activation id, logs `started`, captures the rollback set, stops the bus alone, installs it on the pointer's copy with `--pm` and the pointer's claude, starts it and runs the full gate. A pass writes `pm: true` with no `pmPending` and logs `result ok pm on`. The Console unit, the copy and the store are never touched. A failure goes to `pm_on_fail`: stop the bus, `put_back` the captured phase-1 unit, start it and rerun the gate without `pm_launched`. A pass there logs `pm-on-failed` with the code and why, keeps the pointer pending and exits 1. A failure there stops both units, logs `pm-on-down` with both codes and exits 2. **4. Rollback and plug-in kinds.** `run_pm` gives a rollback from a pending pointer pm 1, so OLD goes back with `--pm` and the gate checks `pm_launched`. `plugin_checks` runs for `activate` and `rollback` and refuses `unknown-kind-for-target` when the business file's `plugins` has a key the target's `KINDS` lacks. It names every such kind. On a rollback the message adds the fix: set each to `null` with `plugins set` through the live bus. **5. `--plan`.** In plan mode `check_refuse` collects refusals instead of exiting. `plan` prints the pending pointer line, the pending-start line for `--pm-after-harness`, then `refuses CODE: message` for each refusal, and exits 3 when there is any. ## Design notes - `pmPending` is a separate key because `read_pointer` refuses a non-boolean `pm`. True is only valid beside `pm: false`, so a pointer can't claim both a live and a pending PM. - One node helper (`PLUGINS_JS`) imports the copy's own `kinds.mjs`, and for `harness` its `business.mjs` loader with the copy's `roles/`. The unknown-kind check is a plain JSON read of `plugins`, because OLD's loader refuses the first unknown kind and the brief wants every kind named. A copy without `kinds.mjs` fails the import and refuses `business-unreadable`. No fallback. - `pm-after-harness-set` treats `[]` as set: the file already has the kind, so the operator should see that rather than a phase 1 that `pm-on` then refuses. `pm-on` needs a non-empty list, since `[]` switches no harness on. - `pm-on` has its own activation id. `from_log` reads only `adopt`, `activate` and `rollback` lines, so a `pm-on` line is never a rollback target or a backup source. It reuses `capture` and `put_back`, so the phase-1 unit is restored byte for byte. - `flags_drift` needs no pending clause. A pending pointer has `pm` false and its unit has no `--pm`, so they match. `activate` refuses `pm-pending` before `flags_drift` and `adopt` refuses `already-adopted`, so only `pm-on` and `rollback` reach it with a pending pointer. - A plain `activate --plan` from pending prints the `--pm` it would install (the run pm is 1) and then `refuses pm-pending`. - Tests use the real loader and `kinds.mjs`. Copies older than c216f47db get `kinds.mjs` with `harness` renamed, so `A` and `B` lack the kind and `C1` and `C` have it. ## Tests T32 to T36 in `packages/cli/tests/stack-release.test.mjs`. Each refusal test checks exit 3, the message, an unchanged fingerprint, no stand-in stop or start, and one log line. | Test | Brief item | Covers | |---|---|---| | T32 | 1 | usage errors, `pm-after-harness-target`, `pm-after-harness-set` (`["claude-code"]` and `[]`), pm-less pointer drift, phase 1 pass | | T33 | 2 | `read_pointer` bad `pmPending` values, `pm-pending` for three `activate` forms, `already-adopted`, the `status` line | | T34 | 3 | `not-pending`, `harness-unset` (none, `[]`), pass, `pm-on-failed` (exit 1), `pm-on-down` (exit 2) | | T35 | 4 | rollback from pending with `--pm`, `unknown-kind-for-target` on rollback then pass after the reset, on activate naming each kind | | T36 | 5 | `--plan` pending lines and refusals | ## Mutants `mutants.py` (copy in this packet, run log `mutants-run.log`), run with `python3 -I`. Each mutant edits one check and must fail the named test. | Id | Edit | Fails (node's fail count) | |---|---|---| | 1a | `pm-after-harness-target` removed | T32 (2 failed) | | 1b | `pm-after-harness-set` removed | T32 (2 failed) | | 1c | `--pm` with `--pm-after-harness` allowed | T32 (2 failed) | | 1d | `--pm-after-harness` accepts a pointer without pm | T32 (2 failed) | | 1e | phase 1 installs and gates `--pm` | T32 (2 failed) | | 1f | phase 1 writes no `pmPending` | T32 (2 failed) | | 2a | `read_pointer` takes any `pmPending` | T33 (2 failed) | | 2b | `read_pointer` takes `pmPending` beside `pm` true | T33 (2 failed) | | 2c | `pm-pending` removed | T33 (2 failed) | | 2d | `status` prints no pending line | T33 (2 failed) | | 3a | `not-pending` removed | T34 (2 failed) | | 3b | `harness-unset` for a missing harness removed | T34 (2 failed) | | 3c | `harness-unset` for an empty list removed | T34 (2 failed) | | 3d | `pm-on` installs without `--pm` | T34 (2 failed) | | 3e | `pm-on` gates without `pm_launched` | T34 (3 failed) | | 3f | a failed `pm-on` keeps the `--pm` unit | T34 (3 failed) | | 3g | a failed `pm-on` skips the phase-1 gate | T34 (2 failed) | | 4a | rollback from pending installs without `--pm` | T35 (2 failed) | | 4b | `unknown-kind-for-target` removed | T35 (3 failed) | | 4c | `unknown-kind-for-target` names only the first kind | T35 (2 failed) | | 5a | `--plan` prints no pending pointer line | T36 (1 failed) | | 5b | `--plan` lists no refusal | T36 (1 failed) | | 5c | `--plan` skips the plug-in checks | T36 (1 failed) | ## Rebase onto 4b738e2da (sage-1070) Row 166 landed as 4b738e2da. At 4b738e2da, `scripts/stack-release.sh` (blob ed5b41d4677e) and `packages/cli/tests/stack-release.test.mjs` are byte for byte the files in row 166's approved manifest, so the base for those two did not move. `docs/TOOLS.md` changed once between 56534526b and 4b738e2da: row 134 (3a600cf5b) rewrote two lines near line 685, outside the "Pinned stack update" section. `git apply` of the old `build.patch` on a clean 4b738e2da worktree: every hunk applied, no fuzz, no offset. The one thing that didn't apply as written is the `scripts/stack-release.sh` header `old mode 100644` / `new mode 100755`. The old patch was a `git diff --no-index` against a file copy of 166's tree, where the file was 0644 on disk. At 4b738e2da the file is already 100755, so git warned "has type 100755, expected 100644" and changed no mode. The new patch has no mode lines. After the apply, `scripts/stack-release.sh` (blob 0b501ffbc247) and the test file are identical to the 56534526b candidate. `docs/TOOLS.md` carries the same added and removed lines at the same hunk line numbers, on a base that includes row 134's change, so its sha256 is new. The patch-ids (`git patch-id --stable`): old 5dca3351b279, new 17a90c4ad795. With the two mode lines removed, the old patch also gives 17a90c4ad795, so the content of the change didn't move. ## Results on 4b738e2da On Sage's go (sage-1070), behind `busy.sh`, in the 4b738e2da worktree, `TMPDIR=~/darkwing-scratch/r169/tmp`, spec reporter, sums from awk over the logs: | File | Tests | Pass | Fail | |---|---|---|---| | `packages/cli/tests/stack-release.test.mjs` | 93 | 93 | 0 | | `packages/cli/tests/stack-copy.test.mjs` | 12 | 12 | 0 | | total | 105 | 105 | 0 | T32 to T36 are among the 93 and all passed on the first run. Mutants, `python3 -I mutants.py` right after (unchanged file; DRY=1 found every anchor first): CONTROL passed (T32 6, T33 3, T34 6, T35 4, T36 1 subtests) and all 23 mutants were killed, each failing the test in the table, the same tests and fail counts as on 56534526b. The run restored `scripts/stack-release.sh` to blob 0b501ffbc247, and the sweep after found 0 processes. Log: `mutants-run.log`. ## Results on 56534526b (superseded) On Sage's slot go (sage-1028), in the worktree, `TMPDIR=~/darkwing-scratch/r169/tmp`, spec reporter, sums from awk over the logs: | File | Tests | Pass | Fail | |---|---|---|---| | `packages/cli/tests/stack-release.test.mjs` | 93 | 93 | 0 | | `packages/cli/tests/stack-copy.test.mjs` | 12 | 12 | 0 | | total | 105 | 105 | 0 | T32 to T36 are among the 93 and all passed on the first run. Mutants, run with `python3 -I mutants.py` right after: CONTROL passed (T32 6, T33 3, T34 6, T35 4, T36 1 subtests) and all 23 mutants were killed, each failing the test in the table. The run restored `scripts/stack-release.sh` to its pristine blob, `0b501ffbc247`, and the sweep after found 0 processes. Only `stack-release.test.mjs` reads `scripts/stack-release.sh` or `docs/TOOLS.md` among `packages/*/tests` and `scripts/test-*.sh`, and it pins no hash. ## Candidate - `build.patch`: `git diff 4b738e2da` of the three files in the worktree. Applied to a clean 4b738e2da tree it gives the manifest's three sha256 (checked with `sha256sum -c`). patch-id 17a90c4ad79550af4f2910199338dcdd9bb20dc5. - `candidate-manifest.sha256`: the three files' sha256, digest c6aaac1ffa80a5c82158700113ab92f103630df5043ddd5c5164ab936e8f54af. - Superseded, kept: `build-56534526b.patch` (sha256 89c2bd5863de), `candidate-manifest-56534526b.sha256` (digest 0b304705d58a), `mutants-run-56534526b.log`. While renaming the old files I overwrote them with the new ones (`git mv -k` skips untracked files and exits 0). I rebuilt the old patch and manifest from the 56534526b worktree with the same commands, and both match the sha256 recorded when they were first written. The old mutants log is the run's own copy from `~/darkwing-scratch/r169/out-56534526b/`. ## Rehearsal Run 2026-10-19T22:52:43Z to 22:53:27Z (attempt 5), scratch only, on Sage's go (sage-1075) with Mos's conditions (mos-0878, sage-1077). Files are in `rehearsal/`. Every file's sha256 is in `rehearsal/SHA256SUMS`, whose own sha256 is a36c89eb1ec3cfe43ceabba13e19739611d670a31e3d0019bb8ec6261ee9b031. - Root `~/darkwing-scratch/r169/reh`, outside `~/.mosaic-dev`. It has a `git clone --shared` of the canonical checkout at 4b738e2da with this candidate applied, so `sha256sum -c candidate-manifest.sha256` passes in the clone. `refactor` in the clone is d195d171c. OLD is ca15d73e9 and NEW is d195d171c, both made fresh with `stack-copy.sh copy`. - The business file is the committed example (blob 3a81ca0faea6) with `{"usage-source":["transcript"],"channel":["cli","webui"]}` and no `harness` (`scratch-business-B.json`, sha256 629931c5...). `scratch-business-H.json` adds `"harness":["claude-code"]` for step 1. - Every call runs under `env -i` with HOME, PATH, TMPDIR, LANG, MOSAIC_CONFIG, XDG_* and SD_* only. There is no credential and no pi variable, and the scratch HOME is empty. - The units are stand-ins (`harness/systemctl`). No live bus or Console unit was touched. ### Live store `stat -c '%n %Y'` before step 1 and after step 6, from `rehearsal-169-live-store-stat.txt`: ``` # before prep, 2026-10-19T22:52:43Z /home/jwoltje/.mosaic-dev/bus/bus.sqlite 1792446324 /home/jwoltje/.mosaic-dev/bus 1792446325 # after teardown, 2026-10-19T22:53:27Z /home/jwoltje/.mosaic-dev/bus/bus.sqlite 1792446324 /home/jwoltje/.mosaic-dev/bus 1792446325 ``` They match. They also match before and after each of attempts 1 to 4. ### Result The transcript (`rehearsal-169.txt`) has 29 parts: 23 exit 0, 2 exit 1 and 4 exit 3. Each nonzero exit is the refusal its part expects: | Brief step | Parts | Outcome | |---|---|---| | 1 | 1a, 1b, 1c | OLD on H refuses `unknown kind "harness"`, exit 1. NEW on H and OLD on B pass | | 2 | 2a, 2b, 2c | `activate --pm-after-harness --plan`, then phase 1. The pointer has `pmPending: true` and the bus unit has no `--pm` | | 6, from pending | 6a-1 to 6a-3 | rollback from pending installs OLD with `--pm` and passes the gate | | 4, before 3 | 2d, 4-pre | a fresh phase 1, then `pm-on` refuses `harness-unset`, exit 3 | | 3 | 3 | `harness` `["claude-code"]` set (see the stand-in below) | | 4 | 4, 4a, 4b | `pm-on` passes. The pointer has `pm: true` and the unit has `--pm`. The PM launch is emulated | | 6, after the set | 6b-1 to 6b-4 | `rollback --plan` and `rollback` refuse `unknown-kind-for-target` by name before any stop (exit 3). After `harness` is set to null, rollback passes | | 5 | 5a, 5b, 5-route, 5c to 5e | a fresh phase 1 with `harness` `["pi"]`, then a real `pm-on`. The real launchPm refuses `no-route` and the gate fails `units-inactive`. pm_on_fail puts the phase-1 unit back and the phase-1 gate passes. The run exits 1 with the pointer still pending, and `launches list` is `[]` | | 6, from step 5 | 6c-1 to 6c-4 | rollback refuses `unknown-kind-for-target`. After `harness` is set to null, OLD is back with `--pm` | Step 5 started no pi process. Part 5c lists the processes under the root: the bus and the Console stand-in, and 0 named pi. The 5-route guard ran before the real `pm-on`. It passes only a chief route that `check()` refuses before the loader: `no-route`, or a claude-code primary with no fallback, which `harness-off` refuses with `["pi"]`. It found `no-route`. Teardown stopped both units, port 47393 closed, and 0 processes were left. The Docker network list (`docker-networks-before.txt`, `-after.txt`) has the same 15 names before and after. My sweep afterwards found 0 processes. ### Stand-ins and findings 1. **Step 3 is a stand-in.** The bus refuses the real `mosaic plugins` read and set `human-required`. `verifyHuman` (`packages/bus/src/human.mjs`) walks the caller's parents and refuses at this agent's claude process. That is the agent check working, and I didn't try to get past it. `harness/hcli.sh` runs the real CLI first and records the refusal (exit 3). Then `harness/hset.mjs` calls the writer the NEW host builds for `plugins.set` (`packages/bus/src/runtime.mjs:93`: `pluginSettings` with the same dir, rolesDir and `hasModule`) with actor `standin.darkwing`. So the file write, the revision check and the module check are the host's own. Only the bus's human proof is skipped. Steps 6b-3, 5b and 6c-2 use the same stand-in. In the window, the set is Jason's, in his own shell. 2. **`scripts/mosaic` at d195d171c doesn't dispatch `plugins`.** `scripts/mosaic plugins` prints the seat usage text, so the stand-in calls `packages/cli/src/cli.mjs plugins`. This matters for any window step that says `mosaic plugins set`. TOOLS.md and the window plan should name the command that works on the pinned copy. 3. **The example's chief has no `harness` var.** Since row 65, `check()` takes the route's harness from the instance vars only (`routeOf`, `packages/cli/src/launcher.mjs`). With the briefed file, every real PM launch at NEW refuses `no-route` at `routeOf("primary")`. That happens before readSkills, the harness-off check and the loader, whatever `plugins.harness` holds. So step 5's refusal is `no-route`, not `harness-off`. pm_on_fail's restore path doesn't depend on the code, and the transcript shows it. Step 4 is emulated (a real claude-code launch needs a session credential in preflight), and a real one on this file would also refuse `no-route`. Whether Jason's live file sets the chief's `harness` var is for the window plan's checks. I didn't read that file. 4. **The scratch human id is the example's `"jason"`.** It appears in the refused launch event in part 5c (`"by":"jason"`). It is a name in a scratch business, and nothing ran as Jason or in a human shell. ### Attempts Attempts 1 to 4 stopped on harness bugs, not on the candidate. Each transcript is kept as `attemptN-rehearsal-169.txt`, and each attempt's teardown left 0 processes, the live stat matched, and the networks were unchanged. 1. Step 3: `hcli.sh` read `.host.path` from `bus status --json`, which has no such field. 2. Step 3: `scripts/mosaic plugins` isn't dispatched (finding 2). 3. Step 3: the real `mosaic plugins` is refused `human-required` (finding 1). 4. 5-route: my probe called `resolveInstance` with `system: {}` instead of the `systemVars(config)` that `check()` uses. It threw, so the guard stopped the run before 5c. That probe found finding 3. The candidate files didn't change, so the digest stays c6aaac1ffa80a5c82158700113ab92f103630df5043ddd5c5164ab936e8f54af.