| name | commander-update-orchestrator |
| description | Use when a Commander update command (`/experiments:commander-update-{patch,minor,major,engines}` and their deep variants) needs to apply npm dependency or toolchain-engine updates across every project registered in the user-scoped Commander registry. Owns the cross-project pipeline — list+filter projects, parallel scan dispatch, deduplicate updates, version-align (max-wins with per-project fallback; engine-version alignment at `level=engines`), dossier gate rendering (path + bounded digest in deep mode), sequential apply with stop-on-fail, per-project changeset gate with an apply teammate (deep mode), aggregated summary. Read-only against the registry; dependency-level writes go to each project's own manifests via `ncu --upgrade` + one `<pm> install` (output to on-disk logs, digest to the conversation), engines-level writes via `apply-engine-bumps` (runtime surfaces, no ncu). Never commits/pushes/opens PRs autonomously. |
commander-update-orchestrator
Cross-project npm-update orchestration. Parameterized by level / target, so every commander:update-* command (and the deep variants) drops into the same plumbing without re-implementing fan-out / fan-in.
When to use
- Invoked by
/experiments:commander-update-patch with level=patch, target=patch (shallow mode, default).
- Invoked by
/experiments:commander-update-deep-patch with level=patch, target=patch, mode=deep (deep mode, MON-199).
commander-update-{minor,major,engines} and commander-update-deep-{minor,major,engines} wire the matching level/target/mode trio. At level=engines the scan/apply steps route to the engine toolchain skills — see "Level-conditional routing: level=engines".
- Composes with
parallel-research-workflow (in cross-project mode) for the deep-mode research insertion (Step 6.5).
Never invoke directly from the user side. The skill is meant for command-layer composition.
Artifact glossary + main-window context diet. dossier.md is the global research document (formerly plan.md; no artifact is named plan.md), changeset.md is the per-project concrete apply plan written by an apply teammate, and Claude Code plan mode is the harness feature (used only as the changeset gate's review UI — see Step 10b). The orchestrator's main conversation holds only paths and small status digests (target ≤ ~30 lines each; structured tables such as the bump set may exceed the target but stay bounded digests, never full artifact bodies). It SHALL NOT load changelog bodies, per-group research files, or the dossier body into its own context, and ncu/install output SHALL NOT stream verbatim into the conversation (on-disk logs + digest + bounded tail-on-failure only, per apply-npm-updates).
Inputs
| Field | Type | Required | Notes |
|---|
level | string | yes | One of patch, minor, major, engines. Passed verbatim to experiments:scan-npm-updates. |
target | string | yes | One of patch, minor, major, engines. Passed verbatim to ncu --target. Matches level for the four shipped shallow commands and four future deep commands. |
mode | string | no | One of shallow, deep. Default shallow. Selects the deep-research path (cross-project changelog research inserted at Step 6.5 + per-project changeset gate round at apply time). The shallow path is byte-equivalent across mode: "shallow" and an absent mode input. See "Mode-conditional behavior" below. |
overrideRegistryPath | string | no | Repo-relative path to a pkg-upgrade-overrides.yaml file. Default: claude-plugins/experiments/skills/scan-npm-updates/data/pkg-upgrade-overrides.yaml. Identical default in both modes. |
projectsFilter | string[] | no | Project names to operate on. When set, the project picker is skipped. When unset, the multi-select picker is raised (Step 3). |
Input validation
Reject before any side effect:
- Unknown
level: abort with Error: invalid level "<value>". Expected patch|minor|major|engines.
- Unknown
target: abort with Error: invalid target "<value>". Expected patch|minor|major|engines.
- Unknown
mode: abort with Error: invalid mode "<value>". Expected shallow|deep.
Mode-conditional behavior
This skill ships two execution paths selected by the mode input. The shallow path is byte-equivalent to MON-194's shipped contract; the deep path layers research + a per-project changeset gate round on top.
| Step / Concern | mode === "shallow" (default) | mode === "deep" |
|---|
| Steps 1, 2, 3, 4, 5, 6, 8 | Identical (shared plumbing). | Identical (shared plumbing). |
| Step 6.5 (cross-project research) | SHALL NOT execute. No plan-dir is created. | Fires. Composes group-packages-for-research + parallel-research-workflow (cross-project mode). Produces <plan-dir>/dossier.md (synthesized by the workflow's teammate + checks). |
| Step 7 (gate rendering) | Generates the bump-set table inline from CrossProjectPlan. | Dossier gate rendering: references <plan-dir>/dossier.md by path + a bounded digest; appends orchestrator-owned drift sections (Warnings, scan-failed, path-missing). |
| Step 9 (gate) | Three options: apply-all, pick-subset, cancel. | Four options: apply-all, apply-bumps-only, pick-subset, cancel. pick-subset accepts both package names AND improvement-bullet titles. |
| Step 10 (apply) | Single per-project bumps loop (10.1–10.6). | Splits into Step 10a (bumps loop, identical mechanics; failure pauses for a stop/continue decision), Step 10b (per-project changeset gate round), Step 10c (end-of-flow cleanup). |
| Step 11 (summary) H1 | ## commander-update-<level> summary | ## commander-update-deep-<level> summary |
| Step 11 (summary) sections | Shallow set (Applied / Failed / Pending / Skipped-by-{path,scan,user,policy,override} / Warnings). | Shallow set PLUS Applied improvements, Skipped improvements, Inapplicable improvements, Skipped or unavailable groups. Conditional Review dossier.md bullet on keep-plan. |
The shallow path SHALL NOT execute Step 6.5, SHALL NOT run any changeset gate round at apply time, SHALL NOT invoke the workflow's end-of-flow cleanup, and SHALL NOT render deep-mode summary sections. The deep-mode insertions are local to Steps 6.5, 7, 9, 10a/10b/10c, and 11 — Steps 1, 2, 3, 4, 5, 6, and 8 behave identically across modes (in particular, Step 8 override registry consultation is shared verbatim — Decision 5 in design.md).
Level-conditional routing: level=engines (toolchain bump)
engines is not an ncu dependency level. When level === "engines" (and target === "engines"), the orchestrator swaps the per-project scan and apply steps to the engine toolchain skills and aligns cross-project on the engine version, while reusing the rest of the cross-project skeleton unchanged (project resolution Steps 1–3, subset selection, plan rendering Step 7, sequential apply with stop-on-fail Step 10, aggregated summary Step 11). For level ∈ {patch, minor, major} this entire section is inert — those levels use scan-npm-updates / apply-npm-updates exactly as before (design D6, additive delta).
The engines branch overrides exactly these surfaces:
- Step 4 — scan. The per-project scan agent invokes
detect-toolchain-surfaces (capability engine-surface-scanning) instead of scan-npm-updates. Each agent returns that skill's EngineSurfaceInventory JSON verbatim (or {"_error": "<string>"} on a precondition abort). The CWD + parse-and-tag plumbing of Step 4 is unchanged; only the skill name in the agent prompt changes.
- Steps 5–6 — aggregation + alignment. Aggregate per-engine across projects instead of per-package. Cross-project alignment is on the resolved engine version: resolve one target per engine once (Node → latest LTS; pnpm/npm/yarn/bun/deno → latest — via
apply-engine-bumps's resolution) and reuse it for every project. There is no per-package max-wins computation and no range-admission conflict prompt. A project already pinned above the resolved target is surfaced and left higher unless the user opts to converge. Intra-repo misalignment reported by detect-toolchain-surfaces is surfaced and converged to the resolved target (runtime loci only).
- Step 8 — override registry consultation is SKIPPED. Package-name overrides (Storybook-style families) have no meaning for runtime/PM surfaces.
OVERRIDE_RUN / OVERRIDE_SKIP are empty; every eligible runtime surface is in the generic apply set.
- Step 10.3 — apply. The per-project apply invokes
apply-engine-bumps (capability engine-update-apply) instead of apply-npm-updates. No ncu is invoked at engines level. The orchestrator passes the project's inventory + the resolved per-engine targets (with confirmed: true — the user already gated at Step 9, and any ambiguous loci were resolved by the command/orchestrator before apply) and folds the returned { resolvedTargets, applied, skipped, droppedHashes, failure? } fragment into the project's summary entry. support and unknownSurfaces loci are never touched; publishable-lib engines.<engine> support ranges are preserved across every project.
- Step 6.5 / 7.D — deep mode. When
mode === "deep", Step 6.5 invokes parallel-research-workflow with level=engines (research targets engine release notes, deduplicated once per engine/version) and Step 7.D surfaces the resulting dossier.md — including the presence of its ## Breaking changes & migration + ## Changelogs sections — by path + bounded digest (never verbatim bodies). No ## PR plan / partition-breaking-changes applies (Step 7.D point 3 runs only for level === "major"): an engine bump is a single coordinated co-upgrade (Node + its PM, moved together), so the PR-partition is meaningless — one bucket. Isolation, when chosen (Step 9.5), wraps the whole engine bump as one workspace per project.
Everything else (the project picker, the gate's apply-all/pick-subset/cancel shape, stop-on-fail, the summary skeleton, the hard rules, registry read-only) is reused verbatim. Where the steps below describe ncu/package mechanics, read them through this section's overrides when level === "engines".
Registry contract (read-only excerpt)
This skill reads the user-scoped Commander registry. The full contract is in commander:add. Relevant invariants repeated here so this file is self-contained — no shared sidecar yet (extraction deferred until the third commander consumer requires it).
Path
<HOME>/.claude/commander/projects.json — <HOME> resolves to $HOME on POSIX and %USERPROFILE% on Windows.
Lazy create — read MUST NOT touch disk
A missing file MUST be treated as an empty registry. The skill MUST NOT create the directory or the file. The on-disk state is byte-identical before and after every run (verifiable via shasum).
Schema (v2)
{
"version": 2,
"projects": {
"<name>": {
"name": "...",
"path": "...",
"keywords": ["..."],
"description": "...",
"createdAt": "<ISO-8601 UTC>",
"updatedAt": "<ISO-8601 UTC>",
"repoType": "single-repo | monorepo | multi-monorepo",
"specialRules": ["..."],
"monorepoRoot": "..."
}
}
}
repoType, specialRules, and monorepoRoot MAY be absent on legacy v1 records. The skill consumes only name and path for routing — repoType is informational.
Version gate
version <= 2 → read normally.
version > 2 → abort with unsupported registry version: <n> and exit non-zero. Do NOT touch the file.
Step 1 — Validate inputs
Apply the input validation listed above. If validation fails, abort with the exact error string and perform no scan or apply.
Step 2 — Resolve projects from the registry
- Resolve
REGISTRY_PATH = <HOME>/.claude/commander/projects.json.
- Probe with
Bash test -f "<REGISTRY_PATH>".
- If missing: print exactly
No projects registered. Use /commander:add to register one. and exit 0. Do NOT create the directory or the file.
Read the file and JSON-parse the contents.
- On parse failure: print
registry file is not valid JSON, exit non-zero, do NOT touch the file.
- Inspect
version:
- If
version > 2: print unsupported registry version: <n>, exit non-zero, do NOT touch the file.
- Inspect
projects:
- If absent,
null, or an empty object: print No projects registered. Use /commander:add to register one. and exit 0.
- Iterate
projects in JSON insertion order. Build the candidate list RESOLVED = [{ name, path, repoType? }, ...] preserving that order.
2.1 Apply projectsFilter (when provided)
- Compute
MATCHED = RESOLVED ∩ projectsFilter by name.
- For each name in
projectsFilter not present in RESOLVED, print one line: Filter name not found: <name>.
- Replace
RESOLVED ← MATCHED.
- If
RESOLVED is empty after the filter: print No projects matched the filter. and exit 0.
2.2 Detect missing-path drift
For each record retained, run Bash test -d "<record.path>". If exit is non-zero:
- Append
{ name, path } to a pathMissing[] buffer.
- Drop the record from
RESOLVED.
The skill SHALL NOT abort on missing paths. The aggregated summary lists every dropped record under Skipped (path missing) (<N>):.
2.3 Pass-through legacy v1 records
Records lacking repoType are kept as-is. Do NOT synthesize the field, do NOT abort, do NOT mutate the registry.
2.4 Empty-after-resolution exit
If RESOLVED is empty after Step 2.2 (every retained record has a missing path), print:
No projects registered. Use /commander:add to register one. — when the registry was empty initially (the empty-registry / empty-projects-object branches in Step 2 already exited there; this branch is effectively dead unless Step 2.2 emptied a non-empty list).
- Otherwise, print
No selectable projects after path-drift filtering. followed by every pathMissing[] entry as a - <name> — <path> bullet, and exit 0.
Step 3 — Project subset selection (skipped when projectsFilter is set)
When projectsFilter is provided, skip this step entirely — the resolved set is already final.
When projectsFilter is unset and RESOLVED is non-empty, the interface depends on N = RESOLVED.length — AskUserQuestion caps a question at 4 options, so the project options + all only fit when N ≤ 3:
N ≤ 3 — raise exactly one AskUserQuestion call configured as:
multiSelect: true
- One option per project, label =
<name> — <path>, description = a short hint (e.g., repoType: <value> when present, else legacy v1 record).
- A final
all option, label = All registered projects (<N>).
N ≥ 4 — SHALL NOT attempt the AskUserQuestion (its option count would exceed the tool cap and the call fails with invalid parameters). Instead, use a free-form selection message: print the selectable projects as a numbered <name> — <path> list and ask for a comma-separated list of names, or all. Unknown names → print the valid list and re-prompt. Empty response → treat as zero selection.
Selection handling (both interfaces)
- Selecting
all is equivalent to selecting every individual project.
- Selecting zero options → print
No projects selected. Cancelled. and exit 0 with no scan or apply.
- Otherwise, set
RESOLVED ← <selected subset> (preserving registry insertion order, NOT the order the user clicked).
Step 4 — Parallel scan dispatch
level=engines: substitute detect-toolchain-surfaces for experiments:scan-npm-updates in the agent prompt below (the agent returns an EngineSurfaceInventory JSON, or {"_error": …} on abort). The CWD config (4.1), the JSON parse + project-tag plumbing (4.2), and the per-project map (4.3) are unchanged — an EngineSurfaceInventory is tagged and stored exactly like a ScanResult. See "Level-conditional routing: level=engines".
For the resolved project set, send a single message containing N Agent tool-uses (one per project). Each agent call:
-
No model override — the agent inherits the session model. Do NOT force a latency-optimized tier: the agent executes the full scan-npm-updates skill (not a JSON echo); a weaker tier returned a fabricated empty ScanResult in dry-run 2026-07-12, silently dropping every update for a project.
-
subagent_type: "general-purpose".
-
description: short, e.g. commander scan: <name>.
-
prompt (verbatim, substituting <level> and <path>):
You are scanning a single project for npm dependency updates as part of a cross-project Commander run.
PROJECT_PATH: <absolute path from the resolved record>
Tasks:
1. Change working directory to PROJECT_PATH.
2. Invoke the `experiments:scan-npm-updates` skill with `level: <level>`.
3. Return the resulting `ScanResult` JSON verbatim.
CRITICAL OUTPUT FORMAT: your entire response MUST be a single JSON object (the `ScanResult`). No markdown fences (no ```json). No prose before or after. No explanations. The first character of your response MUST be `{` and the last character MUST be `}`. Nothing else.
If the skill aborts on a precondition error (no package manager detected, no manifest, etc.), your entire response MUST be a single JSON object of the shape:
{"_error": "<the precondition error string>"}
Do NOT attempt to recover. Do NOT modify any file. Do NOT run any package-manager command outside what `scan-npm-updates` itself runs.
4.1 Per-agent CWD
The Agent tool-call MUST configure the agent's working directory at <record.path> so scan-npm-updates detects the project's local package manager (pnpm/npm/yarn/bun/deno) without drift.
4.2 Parse responses
Collect each agent's response and parse it as JSON:
- If parse succeeds and the JSON has shape
{ packageManager, repoType, updates, warnings }: this is a ScanResult. Tag with the originating project's name/path.
- Empty-scan cross-check (
level ∈ {patch, minor, major} only; not engines): when updates is empty, cross-check with one direct read-only ncu invocation (--jsonUpgraded, no --upgrade, no file writes) in that project before accepting the empty result. On mismatch, re-dispatch that project's scan agent once; if it still mismatches, mark the project scan-failed with reason scan disagreed with ncu cross-check. An empty result the cross-check confirms is accepted normally.
- If parse succeeds and the JSON has shape
{ "_error": "<string>" }: mark the project as scan-failed, store the error string for the summary, and exclude the project from aggregation and apply.
- If parse fails (non-JSON or invalid JSON): same as
_error above, with a synthesized error string Agent response was not valid JSON.
4.3 Build the per-project map
Combine successful results into ScanResultByProject = { [projectName]: ScanResult }. The scanFailed[] buffer holds { name, error } for every project that failed.
The skill SHALL continue processing other projects' results normally; a failure in one project never blocks the rest.
Step 5 — Cross-project aggregation
Aggregate ScanResultByProject into a CrossProjectPlan:
interface CrossProjectPlan {
packages: Array<{
name: string;
occurrences: Array<{
projectName: string;
currentVersion: string;
targetVersion: string;
location: string;
sourceFile: string;
skippedByReleaseAge?: boolean;
}>;
proposedTarget: string;
conflict: boolean;
}>;
warnings: string[];
scanFailed: Array<{ name: string; error: string }>;
pathMissing: Array<{ name: string; path: string }>;
}
Aggregation rules:
- Group every
update across every project by package name (case-sensitive npm name).
- Preserve insertion order: packages appear in the order of their first occurrence across the iteration of projects (same as registry insertion order).
- Each
occurrence carries the full per-project context (projectName, currentVersion, targetVersion, location, sourceFile, skippedByReleaseAge).
- Concatenate
warnings[] across every project's ScanResult, prefixing each with <projectName>:.
Step 6 — Version alignment
For each aggregated package, compute:
proposedTarget = max(occurrences[].targetVersion) (semver max). Strip leading ^/~/= for comparison; preserve the prefix of the highest-versioned occurrence on output.
conflict = true when at least one occurrence's declared range (currentVersion interpreted as a range) does NOT admit proposedTarget. Range admission: standard semver satisfies(proposedTarget, currentVersion).
If any package.conflict === true, raise exactly one AskUserQuestion (regardless of how many packages conflict):
-
Question copy (verbatim, substitute the package list):
Cross-project version conflict for: <comma-separated conflicting package names>.
At least one project's declared range does not admit the proposed maximum target.
How should the run resolve every conflicting package?
-
multiSelect: false
-
Options:
use-max-where-possible — Apply proposedTarget only to occurrences whose range admits it; non-admitting occurrences keep their per-project targetVersion.
per-project — Every occurrence retains its per-project targetVersion; no max-alignment for the conflicting packages.
skip-package — Drop every conflicting package from the run entirely (their occurrences are removed from the plan).
The chosen policy applies to every conflicting package in the run. Do NOT prompt per-package.
Materialize the post-policy plan
Walk every package, applying the chosen policy to the conflicting subset:
use-max-where-possible: per occurrence, set effectiveTarget = proposedTarget when admissible, else effectiveTarget = occurrence.targetVersion.
per-project: per occurrence, effectiveTarget = occurrence.targetVersion.
skip-package: drop the package; record names under Skipped by conflict policy for the summary.
Non-conflicting packages always set effectiveTarget = proposedTarget for every occurrence (range admission already proven).
Step 6.5 — Cross-project research (deep mode only)
Fires only when mode === "deep". Inserted between Step 6 (version alignment) and Step 7 (dossier gate rendering). The shallow path SHALL skip this entire section.
This step composes three already-shipped pieces: experiments:group-packages-for-research, experiments:parallel-research-workflow (in cross-project mode), and — through the workflow's subagents — the fetch-changelog plugin executable. The orchestrator SHALL NOT advance the workflow's phases on its behalf; the workflow owns its own phase machine (0 → init → 1 → 2 → 3 → 4, through dossier synthesis).
6.5.1 Build the deduplicated package set
Convert the post-policy CrossProjectPlan.packages (output of Step 6) into a ScanResult.updates[]-compatible array — one record per unique package, NOT one per occurrence:
const dedupedUpdates = postPolicyPlan.packages.map((pkg) => ({
name: pkg.name,
targetVersion: pkg.effectiveTarget,
currentVersion: mostCommonCurrentVersion(pkg.occurrences),
location: "cross-project",
sourceFile: "cross-project",
}));
Sort the deduplicated set alphabetically by name (stable) — matches the Step 7 ordering shipped for the shallow rendering.
The deduplication is package-level. A package that appears in N projects produces exactly one updates[] record, not N. This is the deduplication contract MON-199 mandates: research keys off the unique package set, not off the (package, project) matrix.
Packages dropped by Step 6's skip-package conflict policy are NOT included in dedupedUpdates (they were already removed from postPolicyPlan.packages).
6.5.2 Group the deduplicated set
Invoke experiments:group-packages-for-research with { updates: dedupedUpdates } (and maxPerGroup only if the caller overrode it). Capture:
groups: Array<{ groupId, bucketKey, packages: [...] }> — input to the workflow.
warnings: string[] — append each entry to the orchestrator's running warnings[] list for the Step 11 summary.
6.5.3 Synthesize a cross-project scanResult
parallel-research-workflow requires a scanResult input shaped like a ScanResult. Build one from the cross-project plan:
const crossProjectScan = {
packageManager: unionPackageManager(allProjects),
repoType: "workspace",
updates: dedupedUpdates,
warnings: orchestratorWarnings,
};
unionPackageManager rule: when every project in the resolved set shares the same package manager (every ScanResult.packageManager value identical across ScanResultByProject), use that value verbatim. Otherwise, use the literal string "mixed".
When the result is "mixed", append the warning Mixed package managers across selected projects: <comma-separated unique pm list, alphabetical>. to the orchestrator's running warnings[] list (surfaces in Step 11 and is persisted in crossProjectScan.warnings).
The workflow does NOT consume scanResult.packageManager for routing in cross-project mode — this field is informational only. It propagates into <plan-dir>/scan-by-project.json (which the orchestrator writes — see 6.5.5) for user inspection.
6.5.4 Invoke the workflow
Call experiments:parallel-research-workflow with:
{
groups: groups,
level: skillInput.level,
scanResult: crossProjectScan,
mode: "cross-project",
slugOverride: `commander-deep-${level}`,
}
Capture the absolute <plan-dir> path the workflow returns. Inside this single invocation the workflow runs (the orchestrator does not advance any phase on the workflow's behalf):
- Phase 0 — stale-plan cleanup (pattern
^[a-z0-9-]+-(patch|minor|major|engines)-\d+(-\d+)?$ matches cross-project plan-dirs, e.g. commander-deep-patch-1715693231).
- Phase init — plan-dir creation under
~/.claude/experiments/plans/commander-deep-<level>-<unix-ts>[-N]/.
- Phase 1 — batched parallel changelog fetch via the
fetch-changelog executable (sequential batches of maxConcurrent, parallel within a batch; hard-wall fallback prompt on dispatch denial).
- Phase 2 — parallel research with the cross-project subagent prompt template (universal findings only, no codebase cross-reference).
- Phase 3 — mandatory integrity gate (
retry-failed / continue-without / abort if any group is non-healthy).
- Phase 4 — dossier synthesis: the workflow's named synthesizer teammate writes
<plan-dir>/dossier.md with the cross-project template (chronology assembled by script) and the two-layer compliance check runs before the dossier is surfaced.
6.5.5 Persist per-project scans alongside the cross-project plan
After Step 6.5.4 returns (i.e. dossier.md exists at <plan-dir>/dossier.md), the orchestrator SHALL write two additional artifacts under the plan-dir (the workflow's contract delegates these two files to the cross-project caller):
<plan-dir>/scan-by-project.json — JSON object mapping projectName → the verbatim per-project ScanResult from Step 4 (ScanResultByProject).
<plan-dir>/cross-project-plan.json — JSON object capturing the post-Step-6 CrossProjectPlan (deduplicated package list with per-occurrence projection — projectName, currentVersion, targetVersion, location, sourceFile, plus proposedTarget, effectiveTarget per occurrence, and the resolved conflict-policy outcome).
Both files are pretty-printed (2-space indent). The workflow itself does NOT require these files for dossier synthesis (it consumes groups[] and the synthesized scanResult directly through its inputs); they exist for the user's post-hoc inspection and as the data source for dossier.md's affects projects: rendering plus the chronology script's cross-project representative versions. The orchestrator MAY write them before invoking the workflow (Step 6.5.4) or after — implementations SHALL write them by end of Step 6.5 regardless. Writing before the workflow runs is preferred because the workflow's phase 4 synthesis reads <plan-dir>/cross-project-plan.json for the bump-set table's projects (locations) cell and the chronology headers. When aligning versions deterministically, the orchestrator MAY use the plugin script node ${CLAUDE_PLUGIN_ROOT}/scripts/semver-max-wins.mjs --scan-by-project <plan-dir>/scan-by-project.json (max-wins effectiveTarget + most-common representative currentVersion per package).
6.5.6 Workflow early-exit handling
The workflow can return one of three abort signals. The orchestrator SHALL handle each before advancing to Step 7:
- Phase 0
cancel (Cancelled by stale-cleanup): print exactly Cancelled. No files modified. and exit 0. Steps 7–11 SHALL NOT execute. No plan-dir is created for this run (phase 0's cancel short-circuits before plan-dir creation).
- Phase 1 hard-wall
abort: surface the workflow's abort message verbatim. Skip Steps 7–11 (no override prompts, no gate, no apply, no Step 10c cleanup invocation). The plan-dir IS preserved on disk per the workflow's contract; the orchestrator SHALL NOT re-invoke the workflow for cleanup on this path.
- Phase 3 integrity-gate
abort: same as Phase 1 hard-wall — surface message verbatim, skip Steps 7–11, plan-dir preserved on disk.
For Phase 1 degrade-to-direct-synthesis (a non-abort outcome of the hard-wall prompt), the workflow proceeds to phase 4 and emits dossier.md with the degraded banner (research consolidated from the changelog cache). The orchestrator continues normally to Step 7 — the degraded path is NOT an early exit.
Step 7 — Render the cross-project bump set / dossier gate
Rendering branches on mode. Shallow mode generates the bump-set table inline from CrossProjectPlan. Deep mode references the workflow-produced dossier.md by path plus a bounded digest and appends the orchestrator-owned drift sections — it does NOT ingest the full dossier.
7.S — Shallow mode (mode === "shallow" or absent)
Render a single markdown table:
| package | proposed target | projects | locations |
| ------- | --------------- | -------------- | -------------------------- |
| lodash | ^4.17.22 | proj-A, proj-B | root, workspace:@scope/foo |
| ... | ... | ... | ... |
- Sort rows alphabetically by
name (stable).
proposed target reflects the post-policy effectiveTarget. When use-max-where-possible produced a split, render the per-project values with a slash separator (e.g., ^4.17.22 / ~4.17.21 (proj-B)) and append a footnote * per-project under conflict policy.
projects lists project names where the package will be applied (deduplicated, comma-separated, registry insertion order).
locations lists the unique location strings across those projects (deduplicated, comma-separated).
- Append
Warnings: heading with each warning as a - bullet, when warnings[] is non-empty.
- Append
Skipped (scan-failed) (<N>): heading listing project names + error, when scanFailed[] is non-empty.
- Append
Skipped (path missing) (<N>): heading listing <name> — <path> bullets, when pathMissing[] is non-empty.
7.D — Deep mode (mode === "deep"): dossier gate rendering
When the workflow returned successfully in Step 6.5 (i.e. <plan-dir>/dossier.md exists at the plan-dir root):
-
Reference <plan-dir>/dossier.md by absolute path (so the user can open it) and surface a bounded digest of the dossier — NOT its full content. The digest comprises:
- the
Cross-project bump set table (a bounded structured table; the workflow produced it from <plan-dir>/cross-project-plan.json — the orchestrator SHALL NOT regenerate it inline),
- the improvement / workaround bullet titles with their
affects projects: tags,
- the
## Skipped or unavailable entries,
- section presence counts (e.g.
Breaking changes & migration: 4 items; Changelogs: 12 package blocks), plus any residual violations escalated by the workflow's two-layer compliance check.
The digest SHALL NOT include the ## Changelogs bodies or any full research content; the main conversation SHALL NOT ingest the full dossier (main-window context diet). The dossier's five H2 sections (in order: Improvements (universal — applicability checked per project at apply time), Workarounds resolved, Skipped or unavailable, Cross-project bump set, Changelogs; ## Breaking changes & migration first at level ∈ {major, engines}) live on disk for the user to open.
-
Append the orchestrator-owned drift sections after the digest, in this exact order. Each section is omitted when its count is zero:
**Warnings:** heading with each warning as a - bullet, when the orchestrator's warnings[] list (the running list across Step 5, Step 6.5.2 grouping-skill warnings, Step 6.5.3 mixed-pm warning, and any later source) is non-empty.
**Skipped (scan-failed) (<N>):** heading with <name>: <error> bullets, when scanFailed[] is non-empty.
**Skipped (path missing) (<N>):** heading with <name> — <path> bullets, when pathMissing[] is non-empty.
These three sections are orchestrator-owned (they originate at Steps 2.2, 4.2, 5, and 6.5.3) — the workflow does NOT know about per-project scan failures or path-missing drift, so it cannot emit them in dossier.md. The orchestrator MUST append them at Step 7 rendering time.
-
(level=major only) Append the ## PR plan. When level === "major", invoke the partition-breaking-changes skill and append its ## PR plan section (ordered buckets + count-by-policy summary; the section name ## PR plan is a retained legacy name — see the deep-update artifact glossary carve-outs) after the drift sections. Build its inputs from already-available data: bumpSet = the rows of the dossier's ## Cross-project bump set; breakingFindings = the per-package items under the dossier's ## Breaking changes & migration; depGraph = a per-project peerDependencies + import-site read (reuse ScanResultByProject); overrideFamilies = the shipped registry families. The ## PR plan is advisory cross-project — v1 isolation is one worktree per project (Step 9.5), NOT per bucket. For level ∈ {patch, minor, engines} this step SHALL NOT run (no ## PR plan is appended — output unchanged).
7.1 Empty-plan early exit
7.1.S — Shallow mode
If the post-policy plan has no apply-able packages (every package was scan-failed, path-missing, or skip-package-dropped):
- Print any warnings and the literal line
No <level> updates available across selected projects.
- Exit
0 with no apply, no install, no override execution.
7.1.D — Deep mode
If the workflow's dossier.md reports zero bumps (the Cross-project bump set table has no data rows) AND zero improvements (Improvements (...) section body is the _no improvements identified_ sentinel) AND zero workarounds (Workarounds resolved section body is the _no workarounds resolved_ sentinel):
- Print any orchestrator warnings (per the rules in Step 7.D, point 2 above).
- Print exactly
No <level> updates available across selected projects.
- Exit
0 without invoking Step 8, Step 9, Step 10a/10b/10c, or Step 11.
The plan-dir is preserved on disk; the workflow's end-of-flow cleanup runs separately when the next deep-mode invocation hits phase 0 stale-cleanup (>10 days). The orchestrator SHALL NOT delete the plan-dir on the empty-plan exit path.
Step 8 — Override registry consultation
level=engines: this entire step is SKIPPED (package-name overrides have no meaning for runtime/PM surfaces). Treat OVERRIDE_RUN and OVERRIDE_SKIP as empty and proceed to Step 9. See "Level-conditional routing: level=engines".
Mode-independent. Step 8 runs identically in both shallow and deep modes — same registry path default, same first-win matching, same run-override / skip-matched / force-generic prompt, same OVERRIDE_RUN / OVERRIDE_SKIP / GENERIC partitioning. This is Decision 5 in design.md: cross-project deep mode IS consulted for overrides (explicit divergence from single-project npm-update-deep-patch, which deliberately skips overrides). Rationale: in cross-project context, Storybook-style families spanning multiple projects need the same coordinated handling shallow already provides; degrading to "run shallow first, then deep" would defeat the one-command UX.
Resolve overrides using the apply-npm-updates override-resolution procedure (R1–R3) for registry load, first-win matching, and {version} resolution — the shared procedure, NOT an inline copy of the algorithm. The cross-project prompt (8.4) and the cross-project resolution scope stay owned by this skill.
8.1 Load + match + resolve (procedure R1–R3)
Invoke the procedure with the registry path from overrideRegistryPath (default claude-plugins/experiments/skills/scan-npm-updates/data/pkg-upgrade-overrides.yaml) and the resolution source set to the cross-project aggregated proposedTarget set (NOT per-project):
- R1 (load) — on a missing/unparseable registry the procedure prints
Override registry unavailable: <reason>. Proceeding without overrides. and treats it as empty. Do NOT abort.
- R2 (first-win glob match) — over the post-policy plan's package names. Build
MATCHED_BY_ENTRY = { entry.id → [packages bound to this entry] }.
- R3 (resolve + interpolate) — resolve
{version} against the cross-project proposedTarget set: target-of:<name> → the proposedTarget of the package whose name == <name> in the cross-project plan (prefix-stripped); max-target-of:<glob> → the max semver across proposedTarget of packages whose name matches <glob> (prefix-stripped); latest → the literal latest; with fallbackVersionSource fallback. {version} resolution SHALL run against the cross-project aggregate, never per-project. On an unresolvable entry the procedure warns, drops the entry, and its matched packages rejoin the generic flow. Otherwise the resolved version is interpolated into command.
8.4 Prompt once per matched entry across the run
For each remaining entry, raise exactly one AskUserQuestion:
-
Question copy (verbatim, substitute fields):
Override detected for {entry.id}. {entry.notes}
Matched packages (across all projects): {comma-separated names}.
Affected projects: {comma-separated project names where any matched package occurs}.
Suggested command: {interpolated command}.
{entry.reference ? "Reference: <url>" : ""}
How do you want to handle this family across every affected project?
-
multiSelect: false
-
Options:
run-override — Execute the command once per affected project; skip generic ncu bump for these packages.
skip-matched — Leave these packages untouched in every project; do not run the override and do not bump generically.
force-generic — Ignore the override and bump these packages with the generic ncu flow in every affected project.
Record the chosen action per entry into OVERRIDE_ACTIONS: Map<entry.id, "run-override"|"skip-matched"|"force-generic"> along with the interpolated command.
8.5 Partition for apply
Compute three disjoint subsets per package:
OVERRIDE_RUN — packages bound to a run-override entry. The override command runs once per project that has at least one matched occurrence.
OVERRIDE_SKIP — packages bound to a skip-matched entry. Excluded from everything.
GENERIC — packages not bound to any entry, plus packages bound to a force-generic entry.
If every package in the post-policy plan is in OVERRIDE_SKIP and OVERRIDE_RUN is empty, print All accepted updates were skipped by override policy. Nothing to apply. and exit 0 without touching files. (ACCEPTED is defined in Step 9; this short-circuit happens before the user confirmation gate.)
Step 9 — User confirmation gate
Raise exactly one AskUserQuestion. The option set depends on mode.
9.S — Shallow-mode options (three)
- Question copy:
Apply <level> updates across <N> project(s)?
multiSelect: false
- Options (in this exact order):
apply-all — Proceed with the entire (post-policy, post-override) plan.
pick-subset — Accept a free-form package-name list to exclude before apply.
cancel — Exit without modifying any file.
9.D — Deep-mode options (four)
- Question copy:
Apply <level> updates across <N> project(s)? (same as shallow)
multiSelect: false
- Options (in this exact order):
apply-all — Proceed with the entire (post-policy, post-override) plan, INCLUDING the post-bumps per-project changeset gate round (Step 10b).
apply-bumps-only — Apply bumps + overrides + installs sequentially per project (Step 10a), but SKIP the changeset gate round (Step 10b) entirely. The Step 11 summary's Applied improvements section is omitted (zero items). All run-override decisions resolved in Step 8 still execute on this path because they were resolved before the gate.
pick-subset — Accept a free-form selection of the items to APPLY, combining improvement-bullet titles AND package names. Substring match (case-insensitive) for improvements; exact match for bumps. Selected improvements are the only bullets in scope for the changeset gate round (Step 10b); selected packages are the only bumps applied in Step 10a; unlisted items are skipped (9.2.D).
cancel — Exit without modifying any file. In deep mode the plan-dir IS preserved on disk and the orchestrator invokes Step 10c (end-of-flow cleanup) before exiting; in shallow mode there is no plan-dir.
9.1 apply-all
Let ACCEPTED = post-policy ∖ OVERRIDE_SKIP. Proceed to Step 10 (shallow) or Step 10a (deep).
9.2 pick-subset
9.2.S — Shallow pick-subset (package names only)
-
Compute VALID_NAMES = unique names in the post-policy plan (post Step 6 conflict policy), then remove names in OVERRIDE_SKIP (post Step 8.5) — the resulting set is what the user can validly exclude.
-
Ask the user (free-form message, no AskUserQuestion):
Enter package names to exclude (comma-separated or one per line). Empty response means exclude none.
Valid names: {comma-separated VALID_NAMES}
-
Parse the response by splitting on commas and newlines, trimming whitespace, removing empty tokens. Result: EXCLUDED.
-
If EXCLUDED === [] → treat as apply-all.
-
Validate every name in EXCLUDED is in VALID_NAMES. On any invalid:
- Print
Unknown package name(s): {invalid names}. Valid names: {VALID_NAMES}.
- Re-prompt step 9.2.S.2.
-
Let ACCEPTED = post-policy \ {names in EXCLUDED} \ {names in OVERRIDE_SKIP} (set difference: drop both excluded and override-skipped packages — OVERRIDE_SKIP MUST NOT be re-included by pick-subset). Let SKIPPED_BY_USER = EXCLUDED.
-
If ACCEPTED is empty after exclusion → print All updates excluded; nothing to apply. and exit 0 without touching files.
-
Otherwise proceed to Step 10.
9.2.D — Deep pick-subset (package names AND improvement titles)
Free-form selection over both improvement bullets and package bump names (mirrors single-project npm-update-deep-patch.md Step 6c).
-
Compute VALID_BUMP_NAMES = unique names in the post-policy plan (post Step 6 conflict policy), then remove names in OVERRIDE_SKIP — same as shallow VALID_NAMES.
-
Compute VALID_IMPROVEMENT_TITLES = the leading title text of each - bullet under the ## Improvements (universal — applicability checked per project at apply time) heading in the workflow's dossier.md (a bounded titles-only read — no research bodies). The title is the prefix before the (group: ...; affects projects: ...) parenthetical — typically formatted as {package}: {opportunity description} or [{priority}] {package} — {opportunity}.
-
Ask the user (free-form message, no AskUserQuestion):
Enter the IDs to apply (comma-separated or one per line). Use plan-line excerpts for improvements
(case-insensitive substring match), package names for bumps. Empty response cancels.
Improvements: {comma-separated VALID_IMPROVEMENT_TITLES}
Bumps: {comma-separated VALID_BUMP_NAMES}
-
Parse the response: split on commas and newlines, trim whitespace, drop empty tokens. Result: SELECTIONS.
-
Empty SELECTIONS → equivalent to cancel (Step 9.3). Print Cancelled. No files modified. and proceed to Step 10c cleanup + Step 11 summary (with the cancel section).
-
For each token in SELECTIONS, classify it as:
- An improvement if it matches an entry in
VALID_IMPROVEMENT_TITLES via case-insensitive substring (the token is a substring of a valid title).
- A bump if it matches an entry in
VALID_BUMP_NAMES exactly (case-sensitive).
- Unknown if it matches neither.
Classification MAY be delegated to the deterministic plugin script node ${CLAUDE_PLUGIN_ROOT}/scripts/validate-subset.mjs (input { selection, bumpNames, improvementTitles }; output { bumpMatches, improvementMatches, unmatched } — matched tokens are the items to APPLY) — same semantics, no prose drift.
-
On any unknown tokens:
- Print
Unknown selection(s): {invalid items}. Valid improvements: {VALID_IMPROVEMENT_TITLES}. Valid bumps: {VALID_BUMP_NAMES}.
- Re-prompt step 9.2.D.3.
-
Compute:
ACCEPTED_BUMPS = post-policy ∖ OVERRIDE_SKIP, restricted to names in the bump-classified selections.
ACCEPTED_IMPROVEMENTS = improvement bullets whose title text matches at least one improvement-classified selection (case-insensitive substring).
SKIPPED_BY_USER = VALID_BUMP_NAMES \ ACCEPTED_BUMPS (package names the user chose to exclude).
SKIPPED_IMPROVEMENTS_BY_USER = VALID_IMPROVEMENT_TITLES \ ACCEPTED_IMPROVEMENTS (improvement bullets the user chose to exclude — appear in Step 11 Skipped improvements with (excluded via pick-subset)).
-
If both ACCEPTED_BUMPS is empty AND ACCEPTED_IMPROVEMENTS is empty → treat as cancel (Step 9.3). Print Cancelled. No files modified. and proceed to Step 10c + Step 11.
-
Otherwise proceed to Step 10a with ACCEPTED = ACCEPTED_BUMPS (Step 10a filters by name) and Step 10b with ACCEPTED_IMPROVEMENTS as the in-scope bullets only.
9.3 cancel
Print exactly:
Cancelled. No files modified.
In shallow mode: exit 0 without touching files. Do NOT run any apply, install, or override command.
In deep mode: the plan-dir exists (Step 6.5 created it). Do NOT run any apply, install, or override command, but DO invoke Step 10c (end-of-flow cleanup) before rendering the Step 11 summary. The summary's H1 SHALL be the deep H1 (## commander-update-deep-<level> summary) and the summary SHALL contain a single body line Cancelled. No files modified. plus the always-rendered Suggested next steps section.
Step 9.5 — Optional isolation gate (default none, both modes)
After the Step 9 gate resolves to an apply path (apply-all / apply-bumps-only / pick-subset with a non-empty accepted set) and before Step 10, raise exactly one AskUserQuestion offering branch/worktree isolation. On Step 9 cancel, this step SHALL NOT run.
- Question copy:
Isolate these updates before applying?
multiSelect: false
- Options (in this exact order):
none — Apply each project in its current working tree (default; no VCS action — byte-equivalent to pre-isolation behavior).
worktree — For each resolved project, create a branch + worktree via update-isolation (worktrunk-preferred) and apply there; the project's current checkout stays untouched.
branch — For each resolved project, create a branch in place via update-isolation and apply on it.
Build ISOLATION_BY_PROJECT (consumed by Step 10.2/10.3):
none → for every project, workdir = <record.path>, no VCS action.
worktree / branch → for each resolved project, invoke the update-isolation skill once with { projectPath: <record.path>, branchName: "deps/<level>-<YYYY-MM-DD>", strategy: <worktree → "auto"; branch → "branch"> }; record the returned workdir and installAlreadyRan. v1 cross-project isolation is one worktree per project — the deep-major ## PR plan buckets stay advisory; per-(project, bucket) worktrees are deferred.
update-isolation creates a branch/worktree only — it never commits, pushes, or opens a PR. On any update-isolation failure it degrades to none for that project (apply in place) with a surfaced note, never aborting the run.
Step 10 — Sequential apply (one project at a time, stop-on-fail)
The apply step splits by mode.
-
Shallow mode (mode === "shallow" or absent): a single per-project bumps loop with no changeset gate round and no end-of-flow cleanup invocation. The existing 10.1–10.6 sub-steps apply unchanged. The orchestrator returns after Step 10.6 (or on stop-on-fail) and renders Step 11.
-
Deep mode (mode === "deep"): split into three phases:
- Step 10a — Bumps loop (identical to shallow Step 10.1–10.6, except failure handling: a per-project failure pauses the run at the per-project failure gate — see 10.6.D).
- Step 10b — Per-project changeset gate round for improvements (conditional — see 10b's gating below).
- Step 10c — End-of-flow cleanup invocation (runs on every deep path except workflow-abort paths).
A Step 10a failure removes the failed and unattempted (pending) projects from Step 10b; projects that DID apply bumps successfully still get their changeset gate round (see 10b's gating). Step 10c always runs (the plan-dir exists and the user deserves a cleanup decision).
Step 10a — Bumps loop (both modes; renamed from "Step 10" for shallow)
Iterate the resolved project set in registry insertion order (already preserved through Steps 2–9). For each project:
10.1 Compute the per-project subset
Collect occurrences in ACCEPTED whose projectName matches this project. Apply:
- The chosen conflict policy (Step 6) — drop occurrences for
skip-package-dropped packages; preserve per-project effectiveTarget under per-project; honor partition under use-max-where-possible.
- The override partition (Step 8.5) — drop occurrences in
OVERRIDE_SKIP; route OVERRIDE_RUN packages to the apply spec's overrideCommands; route everything else (GENERIC) to the apply spec's manifestBumps / catalogEdits (built in Step 10.3).
- The user exclusion (Step 9.2) — already excluded from
ACCEPTED.
If the per-project subset is empty (no generic occurrences AND no override entries touch this project), skip apply AND install for this project. Continue to the next.
10.2 Set the working directory
Resolve this project's apply directory from ISOLATION_BY_PROJECT (Step 9.5): WORKDIR = ISOLATION_BY_PROJECT[project].workdir — which is <record.path> under none, or the isolation branch/worktree path otherwise. For every Bash invocation in the apply for this project, prepend cd "<WORKDIR>" && (or use absolute paths for ncu's --packageFile). The skill SHALL NOT mutate the user's shell state across iterations.
10.3 Build the per-project apply spec and invoke apply-npm-updates
level=engines: invoke apply-engine-bumps (capability engine-update-apply) for this project instead of apply-npm-updates. Pass { cwd: WORKDIR, inventory: <this project's EngineSurfaceInventory from Step 4>, resolvedTargets: <the per-engine targets resolved once in Step 6>, ambiguousResolutions: <any ambiguous loci the user resolved before the gate>, confirmed: true }. No ncu, no manifestBumps/catalogEdits/overrideCommands spec. Fold the returned { resolvedTargets, applied, skipped, droppedHashes, failure? } fragment into this project's summary entry; on a non-null failure apply Step 10.4/10.6 stop-on-fail exactly as below (the failure.step is resolve/write rather than ncu/catalog/override/install — surface its detail in the cross-project abort copy). The rest of 10.1–10.6 (subset, workdir, stop-on-fail) is unchanged. See "Level-conditional routing: level=engines".
The apply-npm-updates skill is the single source of truth for the per-project mechanical apply (generic ncu package.json bumps, catalog source edits — pnpm-workspace.yaml for pnpm, the root package.json for Bun — override commands, single install). The orchestrator builds the resolved spec for this project and invokes the skill once; it SHALL NOT restate the ncu / catalog / install recipe inline.
Build the spec from this project's subset (Step 10.1):
packageManager = this project's ScanResult.packageManager. cwd = WORKDIR (Step 10.2 — <record.path> under none, else the isolation branch/worktree). target = the orchestrator's target input (passed unchanged — the target → ncuTarget mapping, major→latest, and the exact-pin --removeRange write are owned by apply-npm-updates, the single source of truth). cooldown = the value scan-npm-updates resolved for this project (omit for pnpm). (This step runs only for target ∈ {patch, minor, major}; target=engines routes to apply-engine-bumps per the note above and never reaches this spec.)
manifestBumps — one element per distinct GENERIC package.json sourceFile: { sourceFile, names: <GENERIC names for this file, space-separated>, includeFilter }. Set includeFilter: true when any of: the user picked pick-subset and excluded ≥1 package for this project; any update for the file was removed by OVERRIDE_RUN/OVERRIDE_SKIP; or the conflict policy is use-max-where-possible and ncu's full set ≠ this project's effective subset. Otherwise false (ncu's own set equals the target set for this file). Additionally, when target is major (it maps to ncu --target latest), includeFilter SHALL ALWAYS be true for every element regardless of the above — the per-project names list is authoritative, preventing over-bumping dependencies that scan-npm-updates deliberately excluded. (apply-npm-updates also forces the filter for latest-mapped targets; this explicit set is defense-in-depth and keeps the spec readable. The patch/minor branch is unchanged.)
catalogEdits — one element per GENERIC occurrence whose location is catalog:default / catalog:<name> (pnpm pnpm-workspace.yaml or Bun root package.json): { name, targetVersion: <effectiveTarget>, catalogSource: <the scan record's catalogSource> }. Threading catalogSource lets apply-npm-updates target the exact source node; omitting it falls back to the legacy pnpm default.
overrideCommands — the OVERRIDE_RUN entries that touch this project, as { id, command: <interpolated command> }, in declaration order (run once per affected project).
skipInstall — true when every accepted package for this project went through run-override AND no generic ncu bump ran AND no catalog edit happened for this project (every override handles its own install); also true when Step 9.5's update-isolation reported installAlreadyRan for this project's worktree (a worktrunk hook already installed); otherwise false.
Invoke apply-npm-updates once with this spec and cwd: <record.path>, passing runDir: <plan-dir> in deep mode (shallow mode omits it; the skill logs to a temporary path). The skill redirects ncu / install / override stdout/stderr to an on-disk run log — one-line digests to the conversation, a bounded tail (≤ ~40 lines) on failure only, never verbatim streaming — and returns { appliedGeneric, appliedOverrides, installRan, logPath, failure }. Fold the returned fragment into this project's entry of the cross-project summary (Step 11).
10.4 On structured failure — format the cross-project failure copy
If apply-npm-updates returns a non-null failure, print the orchestrator-owned cross-project failure copy for the failing step (the skill never prints this copy — it only surfaces the bounded log tail), then apply the mode's failure handling (Step 10.6). In deep mode, replace each template's closing line Stopping the run. Subsequent projects not attempted. with the per-project failure gate (10.6.D) — whether subsequent projects are attempted is the user's stop/continue decision:
-
step: "ncu" →
ncu --upgrade failed on {sourceFile} ({projectName}, exit {code}).
Stopping the run. Subsequent projects not attempted.
-
step: "catalog" →
Failed to bump {name} in {catalogSource.sourceFile} ({projectName}): {reason}.
Stopping the run. Subsequent projects not attempted.
-
step: "override" →
Override command failed ({entry.id}, {projectName}, exit {code}): {interpolated command}.
Stopping the run. Subsequent projects not attempted.
-
step: "install" →
Install failed ({pm}, {projectName}, exit {code}). Manifests already bumped; review changes before retrying.
Stopping the run. Subsequent projects not attempted.
In shallow mode, then jump to Step 11 (summary) with the run partition (applied / failed / pending). In deep mode, the run partition is settled by the 10.6.D gate.
10.6 Failure handling
10.6.S — Shallow mode: unconditional stop-on-fail
On any failure (ncu, catalog, override, install) in any project:
- Stop iteration immediately.
- Do NOT attempt apply for subsequent projects.
- Mark the failed project with the failing step + exit code.
- Mark all unattempted projects as
pending.
- Proceed directly to Step 11 (summary).
10.6.D — Deep mode: per-project failure gate (stop vs continue)
On any failure (ncu, catalog, override, install — or apply-engine-bumps at level=engines) in any project, the run SHALL pause and raise exactly one AskUserQuestion per failure. It SHALL NOT silently continue and SHALL NOT abort the whole run without asking:
- Question copy:
Applying to <projectName> failed at <step> (exit <code>). Continue with the remaining <N> project(s)?
multiSelect: false
- Options (in this exact order):
stop — Stop the bumps loop. Mark the failed project with the failing step + exit code; mark all unattempted projects as pending. Failed and pending projects are excluded from Step 10b; projects that already applied bumps successfully still get their changeset gate round when 10b's gating holds. Proceed to Step 10b (if gated in), then Step 10c, then Step 11.
continue-remaining — Resume the bumps loop with the next project. The failed project is marked failed (excluded from Step 10b); a later failure raises this gate again.
Step 10b — Per-project changeset gate round (deep mode only)
Fires only when ALL of:
- (a)
mode === "deep".
- (b) The Step 9 gate option was
apply-all (NOT apply-bumps-only, NOT cancel). For pick-subset see 10b.0 below.
- (c) Step 10a completed without failure for at least one project. The round covers exactly the projects that successfully applied bumps; failed and
pending projects are excluded.
- (d) The workflow's
dossier.md contains at least one improvement bullet (Improvements (...) section body is NOT the _no improvements identified_ sentinel).
If any of (a)–(d) is false, Step 10b SHALL NOT execute. The Step 11 summary's Applied improvements section is omitted (zero items).
The round runs sequentially — one project at a time, in registry insertion order, one apply teammate per project. The orchestrator owns the human gate: it SHALL NOT apply improvement edits itself, SHALL NOT ingest research bodies or the full dossier (main-window context diet), and SHALL NOT trust a teammate completion message without verifying the result on disk.
10b.0 Determine in-scope improvements
When the gate was apply-all, every improvement bullet in dossier.md is in scope.
When the gate was pick-subset (deep path 9.2.D), only improvement bullets whose titles matched the user's selection (case-insensitive substring) are in scope — i.e., ACCEPTED_IMPROVEMENTS computed in 9.2.D step 8. If ACCEPTED_IMPROVEMENTS is empty (the user selected only bumps), Step 10b SHALL NOT execute and the Step 11 summary's Applied improvements section is omitted. Improvement bullets the user excluded are recorded for the Step 11 Skipped improvements section with the parenthetical (excluded via pick-subset).
Per project, the in-scope set is further restricted by each bullet's affects projects: tag (set by the workflow's phase 4 synthesis from <plan-dir>/cross-project-plan.json): BULLETS_FOR_PROJECT = in-scope bullets whose affects-projects list contains this project — a bounded titles-only read of dossier.md, the same read 9.2.D validation used; no research bodies. If BULLETS_FOR_PROJECT is empty, skip this project's round silently.
10b.1 Pre-spawn snapshot + apply teammate (turn 1 = recon + changeset, no source edit)
For each successfully-applied project with a non-empty BULLETS_FOR_PROJECT:
- Snapshot the project tree before spawning:
node ${CLAUDE_PLUGIN_ROOT}/scripts/check-source-untouched.mjs snapshot --dir "<WORKDIR>" --out "<plan-dir>/changesets/<projectName>/baseline.json".
- Spawn a single apply teammate for the project. The spawn prompt SHALL instruct, at minimum:
- Turn-1 task: read the
BULLETS_FOR_PROJECT bullets from <plan-dir>/dossier.md (titles, Hint: lines, bullet bodies), reconnoiter the project at <WORKDIR> (pure read — Read/Glob/Grep), classify each (bullet, project) pair as applicable (with the concrete edit: absolute file path, short imperative description, before/after snippet for non-trivial edits) or inapplicable (with a one-sentence reason, e.g. Project uses Solid, not React; useTransition has no equivalent here.), and write <plan-dir>/changesets/<projectName>/changeset.md.
changeset.md structure: H1 # Changeset: <projectName> (deep-<level>); ## Applicable (<N>) — per (bullet, project) pair an H3 ### {bullet title} with - **File**:, - **Description**:, and optional Before/After fenced snippets; ## Inapplicable (<M>) — one - {bullet title} — {one-sentence reason} line each; ## Summary — applicable: <N> / inapplicable: <M>.
- Turn-1 constraints: SHALL NOT modify any project file, SHALL NOT run tests/lint/build, SHALL NOT run any package-manager command. The only file written in turn 1 is
changeset.md (which lives under the plan-dir, outside the project tree).
- Turn boundary: after writing
changeset.md, END THE TURN and wait. A later turn arrives as proceed (apply exactly the approved changeset.md edits via Edit/Write, then report the list of files touched) or revise: <feedback> (update changeset.md accordingly, end the turn again).
- The teammate stays alive across the gate — its reconnaissance context is reused at apply time, so the run never pays reconnaissance twice.
10b.2 Pre-gate check (deterministic, before the human gate)
When the teammate's turn 1 ends, run node ${CLAUDE_PLUGIN_ROOT}/scripts/check-source-untouched.mjs check --dir "<WORKDIR>" --baseline "<plan-dir>/changesets/<projectName>/baseline.json":
- Exit
0 (untouched) AND changeset.md exists → open the human gate (10b.3).
- Exit
1 (modified) → abort this project's round without opening the gate: print Apply teammate modified <projectName> before approval. Aborting the changeset round for this project. (append the script's changed[] paths), tear the teammate down via TaskStop, record the project's in-scope bullets under Step 11 Skipped improvements with (aborted: early edit), and continue to the next project. Applied bumps are NOT reverted.
changeset.md missing after turn 1 → same abort path with the parenthetical (aborted: no changeset written).
10b.3 Human gate (orchestrator-owned)
Primary — orchestrator plan mode as the review/iteration UI. The orchestrator enters plan mode, reads <plan-dir>/changesets/<projectName>/changeset.md (a bounded, digest-sized document — reading it does not violate the context diet), and presents it through the plan-approval flow (ExitPlanMode). The orchestrator's OWN plan approval blocks for the human (verified under defaultMode: "auto", spike 2026-07-11); teammate-native plan approval is decided by the lead autonomously, never reaches the human, and SHALL NOT be used as the gate.
- Approved: leave plan mode WITHOUT implementing anything in the main; go to 10b.4.
- Rejected with feedback: relay it to the still-alive teammate as
revise: <feedback> via SendMessage; the teammate updates changeset.md; re-run the pre-gate check (10b.2) and re-present. The human drives this loop — no fixed round cap.
- Rejected outright: print exactly
Improvements rejected at the changeset gate. No improvement edits applied; bumps are preserved., tear the teammate down via TaskStop, record the project's in-scope bullets under Skipped improvements with (rejected at the changeset gate), and continue to the next project. Applied bumps from Step 10a are NOT reverted.
Fallback — AskUserQuestion. Used ONLY when the orchestrator's plan-mode approval is unavailable or non-blocking in the active runtime (e.g. non-interactive/headless execution): present the changeset digest (the ## Summary counts plus each applicable entry's file + description line) with options approve / reject; reject-with-feedback arrives through the built-in Other free-text. Same proceed/revise relay semantics as the primary gate.
In either interface the gate SHALL block for the human; the flow SHALL NOT continue to apply until the human responds.
10b.4 Apply, verify on disk, teardown
On approval:
- Send
proceed to the still-alive teammate via SendMessage. The teammate applies exactly the approved changeset.md edits with its reconnaissance context intact and reports the files it touched.
- Verify on disk — the teammate's completion message SHALL NOT be trusted: re-run the pre-gate script's
check against the pre-approval baseline and confirm the changed[] set matches the approved changeset — one change per applicable entry, all within the changeset's target files (equivalently: git diff --name-only + spot-Read of the edits); when the approved changeset has zero applicable entries, an EMPTY changed set is the expected match, not a failure. Run the check only after the teammate's turn has completed; if an applicable edit appears missing, re-run the check once after a short wait before concluding — the teammate's writes can land between reads (observed twice in dry-runs). On a confirmed mismatch (an applicable edit missing on the re-check, or files outside the changeset changed), print Apply verification failed for <projectName>: <detail>., record the project's bullets under Skipped improvements with (apply verification failed), and do NOT retry silently.
- Record the verified
(bullet, project) pairs into APPLIED_IMPROVEMENTS (Step 11 Applied improvements) and the teammate's inapplicable classifications (from changeset.md's ## Inapplicable section) into the Step 11 Inapplicable improvements buffer.
- Teardown via
TaskStop — structured shutdown requests are unreliable for idle teammates. TaskStop is the mandated teardown on every path out of the round (applied, rejected, aborted).
10b.5 Changeset-round hard rules
- The changeset SHALL NOT expand scope beyond bullets present in
dossier.md. Adjacent opportunities the teammate discovers during reconnaissance SHALL be surfaced in the Step 11 Suggested next steps list, NEVER silently added to changeset.md.
- After the approved edits are applied and verified, the round may run read-only verification over those edits and surface the result in the summary (read-only, no
--fix).
- The round SHALL NOT create commits or pull requests (or push); it stops for human-in-the-loop review before any such outward/VCS action. Branch/worktree isolation is a separate pre-apply step (Step 9.5); the changeset round itself creates no branch.
- Neither the teammate nor the orchestrator SHALL touch any file outside the bullet's
affects projects: project set.
- The orchestrator SHALL NOT apply improvement edits itself — approval always delegates the apply to the teammate.
Step 10c — End-of-flow cleanup invocation (deep mode only)
Fires only when mode === "deep". Re-invokes experiments:parallel-research-workflow for end-of-flow cleanup exactly once per deep run.
The workflow prompts the user via AskUserQuestion:
- Question:
Plan dir at <plan-dir>. Keep for inspection or delete?
multiSelect: false
- Options:
delete-plan — recursively rm -rf <plan-dir>.
keep-plan — leave it on disk; the next deep invocation's phase 0 stale-cleanup catches it after 10 days.
Capture the user's choice into cleanupOutcome ∈ { "delete-plan", "keep-plan" }. The Step 11 summary's Suggested next steps uses cleanupOutcome to decide whether to include the Review <plan-dir>/dossier.md before re-running. bullet.
Skip Step 10c when the workflow returned an abort signal in Step 6.5.6 (phase 1 hard-wall abort or phase 3 integrity-gate abort). On abort paths the orchestrator has already exited before reaching this point — Step 10c is not reached. On Step 9 cancel (deep), on Step 10a stop-on-fail (deep), on Step 10b rejection, and on the happy path, Step 10c DOES fire — the plan-dir exists and the user deserves a single cleanup decision per run.
The orchestrator SHALL NOT prompt for cleanup itself — the workflow owns the prompt. If the user picks delete-plan, the workflow removes the plan-dir before returning; the orchestrator's Step 11 summary still references <plan-dir> by its captured path but the Review <plan-dir>/dossier.md bullet is omitted (no dossier to review).
Step 11 — Cross-project summary
Print a markdown summary. The H1 varies by mode. Render sections conditionally; sections with count zero SHALL be omitted, except Suggested next steps, which SHALL always appear.
H1:
mode === "shallow" (or absent): ## commander-update-<level> summary (e.g. ## commander-update-patch summary)
mode === "deep": ## commander-update-deep-<level> summary (e.g. ## commander-update-deep-patch summary)
## commander-update-<level> summary # shallow
## commander-update-deep-<level> summary # deep
**Applied projects (<N>):**
- {projectName}: {bumps and overrides}
- {name} {currentVersion} → {effectiveTarget} ({location})
- ... per bump
- {entry.id}: {interpolated command} — matched {comma-separated names}
- ... per override
- ... per project
**Failed project:**
- {projectName}: failed at {step} (exit {code})
- {error message excerpt}
**Pending projects (<N>):**
- {projectName}, {projectName}, ...
**Applied improvements (<N>):** # deep mode only
- {bullet title} → {projectName} ({sourceFile or general hint})
- ...
**Skipped improvements (<N>):** # deep mode only
- {bullet title} (excluded via pick-subset)
- {bullet title} → {projectName} (rejected at the changeset gate)
- ...
**Inapplicable improvements (<N>):** # deep mode only
- {bullet title} → {projectName} ({one-sentence reason})
- ...
**Skipped or unavailable groups (<N>):** # deep mode only — copied verbatim from dossier.md
- {groupId} — {reason}.
- ...
**Skipped (path missing) (<N>):**
- {name} — {path}
- ...
**Skipped (scan-failed) (<N>):**
- {name}: {error}
- ...
**Skipped by user (<N>):**
- {name} (excluded by user)
- ...
**Skipped by conflict policy (<N>):**
- {name} (skip-package — affected projects: {comma-separated})
- ...
**Skipped by override (<N>):**
- {name} ({entry.id}, skip-matched)
- ...
**Warnings (<N>):**
- {projectName}: {warning text}
- ...
**Isolation:** {"none (applied in current tree)" | "worktree — one per applied project" | "branch — one per applied project"}
**Suggested next steps (not executed):**
- Run your test suite in each modified project.
- Run lint / typecheck in each modified project.
- Review changes (`git diff`) and commit per project — any isolation branch may not pass repo commit hooks, so run lint/build before committing.
- Review <plan-dir>/dossier.md before re-running. # deep mode only, when cleanupOutcome === "keep-plan"
11.1 Section gating
| Section | Mode(s) | Render when |
|---|
| Applied projects | both | At least one project applied successfully (full or empty). |
| Failed project | both | Stop-on-fail triggered. |
| Pending projects | both | Stop-on-fail triggered AND at least one project unattempted. |
| Applied improvements | deep only | Step 10b executed AND at least one (bullet, project) edit was approved, applied, and verified on disk. |
| Skipped improvements | deep only | At least one improvement bullet was excluded via pick-subset, rejected at the changeset gate, or dropped on an abort/verification-failure path (10b.2/10b.4). |
| Inapplicable improvements | deep only | At least one (bullet, project) pair was marked inapplicable in a project's changeset.md. |
| Skipped or unavailable groups | deep only | dossier.md's ## Skipped or unavailable section has at least one non-sentinel bullet. |
| Skipped (path missing) | both | pathMissing[] non-empty. |
| Skipped (scan-failed) | both | scanFailed[] non-empty. |
| Skipped by user | both | pick-subset excluded at least one package. |
| Skipped by conflict policy | both | skip-package policy chosen with at least one match. |
| Skipped by override | both | At least one override entry got skip-matched. |
| Warnings | both | warnings[] non-empty. |
| Isolation | both | Always (reflects the Step 9.5 choice; none when not isolated). |
| Suggested next steps | both | Always. |
11.1.D Deep-mode section formats
- Applied improvements: one line per applied-and-verified (bullet, project) pair. Format
- {bullet title} → {projectName} ({sourceFile or general path hint}). The sourceFile or hint cell is the absolute path of the primary file edited under that pair when a single file is dominant; otherwise a generic hint like multiple files under apps/<workspace>/src/.
- Skipped improvements: distinguish the skip reasons with the parenthetical:
(excluded via pick-subset) — when the user excluded the bullet at the gate (9.2.D).
(rejected at the changeset gate) — when the user rejected the project's changeset at 10b.3.