@mmerterden/multi-agent-pipeline 13.2.0 → 13.4.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.
@@ -34,7 +34,38 @@ Categories:
34
34
 
35
35
  Component work is **no longer a bundled pipeline skill set**. Claude Code dispatches `taskType === "component"` to the enabled `ai-<platform>-engineering-toolkit` **marketplace plugin** (`create-component`, fallback `create-ui-component`) via the Skill tool - full contract in `$HOME/.claude/multi-agent-refs/component-dispatch.md`. The pipeline no longer ships `pipeline/skills/figma-ios|figma-android|figma-common`; the pipeline-unique component skills (iterate loops, performance harness, validate/review, commit, adapters, wiki) were absorbed into the `ai-ios-engineering-toolkit` plugin so component skills live in one place.
36
36
 
37
- **Intentional cross-CLI divergence.** Copilot CLI has no marketplace-plugin loader, so it retains its own standalone `~/.copilot/skills/figma-*` copies as a frozen fallback. Component/figma dispatch is therefore **not** byte-identical across CLIs, and figma-skill *inventory* parity is **no longer enforced** - this is a deliberate exception to the parity rule, not drift.
37
+ **How each host receives the plugin's skills.** Only Claude Code loads the marketplace
38
+ plugin natively. The other two are served by the installer, so all three end up with the
39
+ same active skill set:
40
+
41
+ | Host | Delivery | Discovery |
42
+ |---|---|---|
43
+ | Claude Code | the marketplace plugin, loaded natively | matched on description |
44
+ | Copilot CLI | `install/copilot.mjs` copies the **enabled** stack plugin's authored skills (`index`, `reference/`, `workflow/`, `tools/`) into `~/.copilot/skills/` | matched on description |
45
+ | Codex CLI | `install/codex.mjs` copies the same set **plus** `skills/shared/external` into `~/.codex/multi-agent-refs/skills/` as reference files | located by path; the AGENTS.md block carries the `ls` / `grep` recipe |
46
+
47
+ Three rules make that work:
48
+
49
+ 1. **`knowledge/` is never copied to Copilot.** A stack plugin's `knowledge/` tree is
50
+ generated from this pipeline's `skills/shared/external`, which the installer already
51
+ lays down there. Copying it again would duplicate ~5 MB the host has.
52
+ 2. **Only the ENABLED plugins are delivered.** `fix-bug` and `branch-and-pr` exist in
53
+ four stack plugins, `component`, `state` and `create-component` in three, and the
54
+ destination is flat - delivering every stack would be last-write-wins, so an iOS
55
+ repo could end up running the Android `create-component`. The enabled list in
56
+ `~/.claude/settings.json` is the source of truth, exactly as on Claude Code;
57
+ `--platform` is the fallback when no such list exists.
58
+ 3. **Codex takes refs, not list entries.** Measured on 0.145: one plugin declaring 142
59
+ skills surfaced 75 and evicted an unrelated user skill, and every plugin-provided
60
+ skill renders with an empty description, so a block entry buys nothing even when it
61
+ fits. Parity on that host is **reachability**, not an identical listing.
62
+
63
+ > This section previously claimed Copilot "retains its own standalone
64
+ > `~/.copilot/skills/figma-*` copies as a frozen fallback". `install/copilot.mjs` pruned
65
+ > exactly those directories, so the fallback did not exist: `create-screen`,
66
+ > `figma-validate`, `figma-review`, `component`, `state` and `navigation` were all
67
+ > absent, and a component task on Copilot had nothing to dispatch to. The contract
68
+ > documented a safety net the installer deleted.
38
69
 
39
70
  What still MUST match across CLIs for component tasks: the `taskType === "component"` classification, the `state.phases["3"].subphases[]` shape the dispatch layer writes, and `--dev` elision semantics. Resolution/routing is Claude-plugin vs Copilot-local-copy by design.
40
71
 
@@ -32,10 +32,51 @@ One fetcher per type. Each fetcher emits a normalized JSON view that the analysi
32
32
 
33
33
  - `0` (success) → output appended to `state.fetchedContext.<type>[]` and injected into the prompt.
34
34
  - `2` (blocked / missing token) → run the inline Token Save Flow per `setup.md`; on skip, mark the entry as `{status: "skipped", reason: "missing-token"}` and continue.
35
- - `3` (network/auth) → mark as `{status: "failed", reason: "<exit-msg>"}`, continue.
35
+ - `3` (network/auth) → **surface the Unreachable-source decision below.** Never silently downgrade.
36
36
  - `4`/`5`/`6` (config errors) → mark as `{status: "failed", reason: "<exit-msg>"}`, continue and log a setup hint.
37
37
 
38
- Failures are never fatal at Phase 1 - the agent still runs analysis, just without the referenced source.
38
+ ### MUST: an unreachable source is announced, not absorbed (BLOCKING)
39
+
40
+ A fetcher that fails is the pipeline losing the ground truth it was told to use. The
41
+ user is the only one who can fix it - the token is theirs to refresh, the VPN is theirs
42
+ to connect - and they cannot fix what they are not told about.
43
+
44
+ This used to read "mark as failed, continue". The observed result: an expired token and
45
+ a VPN-off remote both reached the analysis phase as *no data*, indistinguishable from a
46
+ ticket that referenced nothing. The run then produced a plan from a partial picture and
47
+ reported success, and the user found out by reading the output.
48
+
49
+ So on exit `3`, classify the stderr and surface an `AskUserQuestion` before continuing.
50
+ The classification is the same shape the Phase 6 remote gate uses, because the failures
51
+ are the same failures:
52
+
53
+ | stderr signal | Diagnosis | Question offers |
54
+ |---|---|---|
55
+ | `401`, `403`, `invalid_grant`, `token expired`, `unauthorized` | the credential is dead | **Refresh `<KEY_NAME>`** (Save Flow Step B, then retry) · **Continue without this source** · **Abort** |
56
+ | `could not resolve host`, `connection refused`, `timed out`, `network is unreachable`, corporate host in the URL | the host is unreachable, VPN almost certainly off | **Connect the VPN, then retry** (default) · **Continue without this source** · **Abort** |
57
+ | `404`, `no such issue`, `not found` | the reference is stale or the link points at something deleted | **Supply a current URL** · **Continue without this source** · **Abort** |
58
+ | anything else | unknown | verbatim stderr in the `description`, same three options |
59
+
60
+ Rules that make this actionable rather than decorative:
61
+
62
+ 1. **Name the thing.** The question states which source (`crashlytics`), which URL, and
63
+ which credential key by its logical name (`firebase`, never the raw Keychain item).
64
+ 2. **Say what is lost.** "Without it I plan from the exception message alone, with no
65
+ stack frames and no affected-version spread" beats "continuing without context".
66
+ 3. **Retry is a real branch.** On "Connect the VPN, then retry" / "Refresh", re-run the
67
+ same fetcher and re-enter this table on a second failure. Do not fall through to
68
+ "continue" after one attempt.
69
+ 4. **Record the choice.** `state.fetchedContext.<type>[].status` ∈ `ok | skipped | failed`
70
+ plus `userDecision` ∈ `retried-ok | continued-without | aborted`, so Phase 1 can put
71
+ "planned without Crashlytics, user's choice at <ts>" in the analysis doc's limitations
72
+ section instead of leaving a silent hole.
73
+ 5. **Autopilot still reports.** Autopilot takes "continue without this source" without
74
+ asking, but the skipped source is logged and carried into the Phase 7 report. An
75
+ autopilot run that quietly planned from a partial picture is the same defect with a
76
+ flag on it.
77
+
78
+ Failures remain non-fatal at Phase 1 by default - the agent still runs analysis. What
79
+ changed is that the user learns about it while the choice is still theirs to make.
39
80
 
40
81
  **Graylog specifics.** `fetch-graylog.sh` degrades to empty on any network/VPN failure: it emits a normalized empty object and exits `0`, so an unreachable host reads as `{status:"skipped", reason:"vpn-unreachable"}` and never blocks. Only a genuine auth rejection on a reachable host exits `3` (marked `failed`, still non-fatal here). `state.graylogContext` is prepended to the analysis prompt inside the **Referenced External Sources** section as diagnostic context that is **advisory only** - the agent may use it to orient on a reported error, but code remains ground truth, and there is no Phase 4 review gate for logs.
41
82
 
@@ -40,6 +40,69 @@ Reasons:
40
40
 
41
41
  The smoke gate `pipeline/scripts/smoke-no-token-prompt.sh` greps the pipeline source for hardcoded "enter token" / "paste token" / "API key:" prompts and fails the build if any are introduced. Token I/O is exclusively `credential-store.sh` calls.
42
42
 
43
+ ### Rule 2 - MUST: never ask the user for what a mapped credential can fetch (BLOCKING)
44
+
45
+ Before asking the user for data that an external system holds - a stack trace, a log
46
+ line, a page body, a finding, a design frame - inventory the credentials first:
47
+
48
+ ```bash
49
+ bash "$HOME/.claude/lib/credential-inventory.sh" --json # what is configured
50
+ bash "$HOME/.claude/lib/credential-inventory.sh" --json --probe # and what actually answers
51
+ bash "$HOME/.claude/lib/credential-inventory.sh" --key firebase # exit 0 = usable
52
+ ```
53
+
54
+ The inventory reads `prefs.global.keychainMapping`, probes each logical key through
55
+ `credential-store.sh get` with the value discarded, and reports `present` /
56
+ `mapped-but-missing` / `unmapped` alongside what each key unlocks. Values are never
57
+ printed.
58
+
59
+ The answer then decides the shape of the question:
60
+
61
+ | Inventory says | The question must be |
62
+ |---|---|
63
+ | `present` | ask for the **pointer**, not the payload, and say you will fetch it: "Give me the Crashlytics issue URL - I have the Firebase service account mapped and will pull the stack frames, affected versions and device spread myself." |
64
+ | `mapped-but-missing` | say the credential is configured but not resolving, name the logical key, and offer the Save Flow: "`firebase` is mapped but the Keychain item is not resolving - refresh it, or paste the trace and I continue without it." |
65
+ | `unmapped` | say the capability is not configured and offer setup: "No `firebase` key is mapped, so I cannot reach Crashlytics. Onboard it via `/multi-agent:setup`, or paste the trace." |
66
+
67
+ **Never** present "paste it yourself" as the first and most efficient option when a
68
+ credential is present. That is the defect this rule exists for: a run asked the user to
69
+ paste a Crashlytics stack trace, offering the manual path as "the fastest and most
70
+ certain route", while a valid Firebase service-account JSON sat in the Keychain mapped
71
+ as `firebase`. The user had configured that key precisely so the pipeline would not ask.
72
+
73
+ **State the limit honestly too.** A credential is not omniscience: the Crashlytics
74
+ fetcher resolves a specific issue and needs its URL, so "I have the key" does not mean
75
+ "I can find the crash from an exception message alone". Say which piece is missing and
76
+ why, rather than implying either more or less capability than exists.
77
+
78
+ #### Present is not the same as working
79
+
80
+ `--probe` makes one cheap authenticated request per configured credential, because
81
+ "the key is in the Keychain" and "the service answers" are different claims and the user
82
+ fixes them in different ways:
83
+
84
+ | Verdict | What it means | What to tell the user |
85
+ |---|---|---|
86
+ | `reachable` | the service answered and accepted the credential | nothing - proceed |
87
+ | `auth-rejected` | 401/403: the credential is dead | name the logical key, run the Expired-token decision (Rule 1) |
88
+ | `unreachable` | no response at all | on a corporate host this is almost always the VPN: "`{FORTIFY_HOST}` is not answering - connect the VPN and I will retry, or I continue without the finding" |
89
+ | `no-host-configured` | a token exists but `global.hosts.<service>` was never recorded | "I hold the `jira` token but no Jira host is configured, so I cannot build a request URL - set it via `/multi-agent:setup`" |
90
+ | `well-formed` | structurally valid, liveness not checkable yet | say what is still needed (the Crashlytics issue URL) |
91
+ | `not-probeable` | no cheap probe exists for this credential type | do not claim it works, do not claim it fails |
92
+
93
+ Never collapse these into one "failed" bucket. A dead token, a closed VPN and a missing
94
+ host look identical in a `{status: "failed"}` field and lead the user to three different
95
+ wrong actions.
96
+
97
+ Probe only what is configured. An unmapped key is a capability the user chose not to
98
+ enable - reporting it as a problem trains them to ignore the report. Cache the result in
99
+ `prefs.global.serviceStatus.<service>` (`{ok, checkedAt, error?}`, TTL
100
+ `settings.serviceStatusCacheSeconds`) so a phase chain probes once, not per fetcher.
101
+
102
+ Record what the inventory found in `agent-state.json.credentialInventory`
103
+ (`{usable: [...], needsAttention: [...], at: <ts>}`) so a later phase can tell "the
104
+ source was unreachable" from "nobody looked".
105
+
43
106
  **Retrieve pattern (always use mapping):**
44
107
 
45
108
  ```bash
@@ -136,6 +136,22 @@ Results are cached in `global.serviceStatus` (existing TTL contract, default 300
136
136
 
137
137
  Probe failures caused by network (timeout, DNS) are NOT treated as expiry - log and continue; the mid-run 401 path still exists as the safety net.
138
138
 
139
+ **Record the inventory.** After the probes, persist what is reachable:
140
+
141
+ ```bash
142
+ # --probe when the task references an external source (Jira ID, Crashlytics/Fortify URL,
143
+ # a remote to fetch): "the key is in the Keychain" and "the service answers" are
144
+ # different claims, and only the second one lets the run proceed.
145
+ bash "$HOME/.claude/lib/credential-inventory.sh" --json --probe > /tmp/cred-inventory-${TASK_ID}.json
146
+ ```
147
+
148
+ Write `{usable, needsAttention, at}` into `agent-state.json.credentialInventory`. Later
149
+ phases read it instead of re-probing, and it is what makes `keychain.md` Rule 2
150
+ enforceable: any question that asks the user for data an external system holds must be
151
+ shaped by this file. A run that asks the user to paste a Crashlytics stack trace while
152
+ `firebase` is listed under `usable` is a Rule 2 violation, and the inventory is the
153
+ evidence.
154
+
139
155
  #### Step 1 - Parse Input
140
156
 
141
157
  **Branch from input**: If the user provided a branch name after the issue reference (space-separated), store as `baseBranch` and skip Step 3. Otherwise Step 3 asks interactively.
@@ -233,18 +249,30 @@ Scan `$HOME` (maxdepth 2) for project markers (`.xcodeproj`, `Package.swift`, `b
233
249
  | grep -E '(develop|release|main|master)' \
234
250
  | grep -v -E '(feature/|bugfix/|fix/|hotfix/|chore/)'
235
251
  ```
236
- 6. Sort: `develop*` first, then `release/*`, then `main`/`master`. Present numbered list with standard UX pattern:
252
+ 6. Sort: `develop*` first, then `release/*`, then `main`/`master`. Surface through the
253
+ **native picker** per `picker-contract.md` (`AskUserQuestion` on Claude Code,
254
+ `ask_choice.sh` on Copilot CLI) - `question` + `description` in `outputLanguage`,
255
+ `label` English, the recent branch first and marked `(Recommended)`. The ASCII sketch
256
+ below is what the options carry, not a menu to print:
257
+
237
258
  ```
238
- Base branch:
239
- Recent: develop (enter to reuse)
240
- ────────────────────
241
- 1. origin/develop
242
- 2. origin/main
243
- ...
244
- Select [number] or enter for suggested:
259
+ header: "Base branch"
260
+ options: origin/develop (Recommended, reused from last run) | origin/main | release/8.4.0 | Other
245
261
  ```
246
262
  7. User picks → store as `baseBranch`. Save to `prefs.projects[{project}].branches` (dedup, max 10).
247
263
 
264
+ **MUST: this step is not skippable (BLOCKING).** The only legitimate skip is rule 4
265
+ above - `baseBranch` already supplied in the input. Everything else asks. A run once
266
+ took a Jira ID and implemented straight onto whatever the local checkout was pointing
267
+ at, with neither the project nor the branch picker ever shown; nothing failed, so
268
+ nothing surfaced it. `phase0-exit-gate.mjs` now refuses to close Phase 0 unless
269
+ `agent-state.json` carries `baseBranch` and `baseFetchStatus`, so a skipped picker is a
270
+ gate failure rather than a silent default.
271
+
272
+ This holds in every mode. `--dev` skips the *LLM* phases (Analysis, Planning); it does
273
+ not skip Phase 0's pickers. Autopilot resolves them to their defaults without prompting,
274
+ which still writes the fields - it does not leave them unset.
275
+
248
276
  **TTL filter for recent branches**:
249
277
 
250
278
  - `prefs.global.recentBranches[{projectKey}][]` carries `{name, lastUsed}`. Filter to those whose `lastUsed` is within `settings.branchTtlDays` (default 15).
@@ -253,20 +281,25 @@ Scan `$HOME` (maxdepth 2) for project markers (`.xcodeproj`, `Package.swift`, `b
253
281
 
254
282
  **Fetch-fail handling** (replaces silent `git fetch origin` failure):
255
283
 
256
- The legacy `git -C $PROJECT_ROOT fetch origin` step (line 110 above) MUST not silently fall back to a stale cached ref. On non-zero exit, present 4 options (per `feedback_multi_agent_fetch_fail`):
284
+ The legacy `git -C $PROJECT_ROOT fetch origin` step (line 110 above) MUST not silently fall back to a stale cached ref. On non-zero exit, surface the **native picker** (per `picker-contract.md`) with 4 options:
257
285
 
258
286
  ```
259
- git fetch origin failed for {project} (exit {code}, last attempt {ts}).
260
- Likely causes: VPN closed, host unreachable, auth expired.
261
-
262
- [1] Connect VPN / fix network, then resume #{taskId} (safest)
263
- [2] Use cached origin/{baseBranch} ref (stale risk: base sha {sha}, fetched {since})
264
- [3] Use local branch "{baseBranch}" as base (only if local branch exists; commit {sha})
265
- [4] Abort - no worktree, no branch, no state file
266
-
267
- Select:
287
+ question: "git fetch failed for {project} - the base ref may be stale. How should I proceed?"
288
+ description: "exit {code}, last successful fetch {ts}. Likely: VPN closed, host unreachable, auth expired."
289
+ header: "Base ref"
290
+ options:
291
+ Connect VPN and retry (Recommended) re-run the fetch, then continue with a fresh ref
292
+ Use cached origin ref stale risk: base sha {sha}, fetched {since}
293
+ Use local branch as base only offered when the local branch exists; commit {sha}
294
+ Abort no worktree, no branch, no state file
268
295
  ```
269
296
 
297
+ **Say which is which.** The question must name the corporate host when the remote points
298
+ at one - a `{BITBUCKET_HOST}` remote failing to resolve is almost always the VPN, and
299
+ telling the user that is the difference between a five-second fix and a run built on a
300
+ month-old ref. "Connect VPN and retry" re-runs the fetch and re-enters this picker if it
301
+ fails again; it is a real retry, not a label.
302
+
270
303
  Persist user choice in `agent-state.json.baseFetchStatus` ∈ `"fresh" | "cached-stale" | "local-branch" | "aborted"`. On any non-fresh choice, log:
271
304
  ```
272
305
  ⚠️ Base ref stale (fetch fail @ {ts}, choice: {cached-stale|local-branch})
@@ -163,10 +163,10 @@ Provider tools that print failed argv on retry leak credentials into the convers
163
163
  # goes through the cross-platform credential helper so the same snippet works
164
164
  # on macOS / Linux / Windows installs:
165
165
  VERCEL_TOKEN="$(~/.claude/lib/credential-store.sh get mmerterden_Vercel_Access_Token)" \
166
- bash $HOME/.claude/scripts/vercel-deploy.sh deploy --prod
166
+ bash $HOME/.claude/lib/vercel-deploy.sh deploy --prod
167
167
 
168
168
  # Health check before deploy:
169
- bash $HOME/.claude/scripts/vercel-deploy.sh doctor
169
+ bash $HOME/.claude/lib/vercel-deploy.sh doctor
170
170
  ```
171
171
 
172
172
  **Forbidden:**
@@ -177,7 +177,7 @@ vercel deploy --token=vcp_...
177
177
  vercel deploy --token "$VERCEL_TOKEN"
178
178
  ```
179
179
 
180
- The wrapper at `pipeline/lib/vercel-deploy.sh` (installed to `~/.claude/scripts/vercel-deploy.sh` and `~/.copilot/scripts/vercel-deploy.sh`) refuses any `--token=` argv input, runs the CLI with `VERCEL_TOKEN` env, and pipes every stdout/stderr line through a redact filter that scrubs `vcp_...`, `Bearer ...`, and JSON-body token shapes. Regression gate: `smoke-vercel-deploy-redact.sh` (12 assertions).
180
+ The wrapper at `pipeline/lib/vercel-deploy.sh` (installed to `~/.claude/lib/`, `~/.copilot/lib/` and `~/.codex/lib/` with the rest of the shell libraries) refuses any `--token=` argv input, runs the CLI with `VERCEL_TOKEN` env, and pipes every stdout/stderr line through a redact filter that scrubs `vcp_...`, `Bearer ...`, and JSON-body token shapes. Regression gate: `smoke-vercel-deploy-redact.sh` (12 assertions).
181
181
 
182
182
  Same rule applies to any future provider wrapper (e.g. `cloudflare-deploy.sh`, `npm-publish.sh`) - never pass tokens via argv when the underlying CLI may echo argv on failure.
183
183
 
@@ -35,9 +35,14 @@ import {
35
35
  import { join } from "node:path";
36
36
 
37
37
  const args = process.argv.slice(2);
38
+ // Accepts both `--key value` and `--key=value`. The `=` form used to fall through to
39
+ // the default, which meant a run meant for a throwaway checkout silently retargeted
40
+ // the user's real marketplace instead - a wrong-target write, reported as success.
38
41
  const getArg = (k, d) => {
42
+ const eq = args.find((a) => a.startsWith(`${k}=`));
43
+ if (eq) return eq.slice(k.length + 1);
39
44
  const i = args.indexOf(k);
40
- return i >= 0 ? args[i + 1] : d;
45
+ return i >= 0 && args[i + 1] && !args[i + 1].startsWith("--") ? args[i + 1] : d;
41
46
  };
42
47
  const HOME = process.env.HOME;
43
48
  const PLUGINS_REPO = getArg("--plugins-repo", join(HOME, "multi-agent-plugins"));
@@ -102,6 +107,29 @@ const isDir = (p) => {
102
107
  };
103
108
  const listDirs = (p) => (existsSync(p) ? readdirSync(p).filter((n) => isDir(join(p, n))) : []);
104
109
 
110
+ /**
111
+ * Cheap content fingerprint for a skill directory: every file's relative path and
112
+ * size. Enough to notice an edit without hashing megabytes on every run, and it is
113
+ * only used to decide whether a version bump is warranted.
114
+ */
115
+ function treeFingerprint(dir) {
116
+ const parts = [];
117
+ const walk = (d, rel) => {
118
+ for (const e of readdirSync(d, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
119
+ const full = join(d, e.name);
120
+ const r = rel ? `${rel}/${e.name}` : e.name;
121
+ if (e.isDirectory()) walk(full, r);
122
+ else if (e.isFile()) parts.push(`${r}:${statSync(full).size}`);
123
+ }
124
+ };
125
+ try {
126
+ walk(dir, "");
127
+ } catch {
128
+ return "";
129
+ }
130
+ return parts.join("|");
131
+ }
132
+
105
133
  // --- 1. enumerate authoring source ---------------------------------------
106
134
  if (!existsSync(EXTERNAL)) {
107
135
  console.error(`source not found: ${EXTERNAL}`);
@@ -180,13 +208,31 @@ for (const [plugin, want] of Object.entries(desired)) {
180
208
  const have = new Set(listDirs(kdir));
181
209
  const toAdd = [...want].filter((s) => !have.has(s)).sort();
182
210
  const toRemove = [...have].filter((s) => !want.has(s)).sort();
183
- const changed = toAdd.length > 0 || toRemove.length > 0;
211
+ const setChanged = toAdd.length > 0 || toRemove.length > 0;
184
212
 
185
- if (!DRY && changed) {
213
+ // Re-copy EVERY wanted skill, not just the newly added ones.
214
+ //
215
+ // This used to copy only on a set change, which quietly broke the single-source
216
+ // claim: editing a skill in shared/external left the plugin serving the old text
217
+ // forever, because the set had not changed. Found by editing a promoted skill and
218
+ // seeing the plugin copy stay stale while the generator reported "all plugins up
219
+ // to date".
220
+ //
221
+ // cpSync overwrites, so a full re-copy is idempotent and cheap relative to a
222
+ // publish. Removals still need the explicit delete.
223
+ let contentChanged = false;
224
+ if (!DRY) {
186
225
  if (!existsSync(kdir)) mkdirSync(kdir, { recursive: true });
187
- for (const s of toAdd) cpSync(join(EXTERNAL, s), join(kdir, s), { recursive: true });
226
+ for (const s of want) {
227
+ const from = join(EXTERNAL, s);
228
+ const to = join(kdir, s);
229
+ const before = existsSync(to) ? treeFingerprint(to) : "";
230
+ cpSync(from, to, { recursive: true });
231
+ if (treeFingerprint(to) !== before) contentChanged = true;
232
+ }
188
233
  for (const s of toRemove) rmSync(join(kdir, s), { recursive: true, force: true });
189
234
  }
235
+ const changed = setChanged || contentChanged;
190
236
 
191
237
  // regenerate plugin.json skills[]: keep non-knowledge entries, rebuild knowledge set
192
238
  const pj = JSON.parse(readFileSync(pjPath, "utf8"));
@@ -14,6 +14,12 @@
14
14
  * and spacing guessed at 16 where the frame said `Spacing/12`. Half the commits on
15
15
  * that branch were rework.
16
16
  *
17
+ * A second run then showed the other half of the same failure: a Jira task was
18
+ * implemented without the repo or branch picker ever appearing, straight on whatever
19
+ * the local checkout was pointing at. Step 3 and Step 8 own those decisions, and
20
+ * `agent-state.json` is the only durable evidence they happened - so the gate asserts
21
+ * their output too, not just `taskType`.
22
+ *
17
23
  * A phase that reports success without its output is worse than one that fails:
18
24
  * every later phase then reasons from a field that is not there. So this is a
19
25
  * gate, not a lint - the spec already said what to write, and prose alone did
@@ -120,6 +126,52 @@ export function evaluate(state, extraInput = "") {
120
126
  );
121
127
  }
122
128
 
129
+ // Step 3 picks the base branch and Step 8 creates the worktree. A run that skipped
130
+ // both develops on whatever the checkout happened to have, which is how a Jira task
131
+ // got implemented on a stale local branch with no picker ever shown. The state file
132
+ // is the only durable evidence those steps ran, so it has to carry their output.
133
+ //
134
+ // `local` provider runs still choose a base branch; what they skip is push and PR.
135
+ // So this applies to every provider.
136
+ const baseBranch = typeof state.baseBranch === "string" ? state.baseBranch.trim() : "";
137
+ if (!baseBranch) {
138
+ failures.push(
139
+ "agent-state.json has no baseBranch. Step 3 asks for it (or takes it from the " +
140
+ "input); an unset value means the branch picker never ran and development " +
141
+ "happens on whatever the checkout was already pointing at.",
142
+ );
143
+ }
144
+
145
+ // Which ref the base actually came from. Without it, "built on a fresh origin ref"
146
+ // and "built on a month-old local branch" are indistinguishable after the fact -
147
+ // and the fetch-failure picker exists precisely to make that an explicit choice.
148
+ const fetchStatus = typeof state.baseFetchStatus === "string" ? state.baseFetchStatus.trim() : "";
149
+ const FETCH_STATES = ["fresh", "cached-stale", "local-branch", "aborted"];
150
+ if (baseBranch && !FETCH_STATES.includes(fetchStatus)) {
151
+ failures.push(
152
+ `agent-state.json has baseFetchStatus="${fetchStatus || "<unset>"}"; Step 3 must ` +
153
+ `record one of ${FETCH_STATES.join(" | ")}. An unset value means nothing ` +
154
+ `established whether the base ref was fetched or reused from a stale cache.`,
155
+ );
156
+ }
157
+
158
+ // Worktree isolation is a standing rule: never develop in the primary checkout.
159
+ const worktree = typeof state.worktreePath === "string" ? state.worktreePath.trim() : "";
160
+ const projectRoot = typeof state.projectRoot === "string" ? state.projectRoot.trim() : "";
161
+ if (!worktree && !projectRoot) {
162
+ failures.push(
163
+ "agent-state.json records neither worktreePath nor projectRoot. Step 8 creates " +
164
+ "the worktree; with no recorded path, later phases cannot tell which tree they " +
165
+ "are meant to edit and default to the current directory.",
166
+ );
167
+ }
168
+ if (worktree && projectRoot && worktree === projectRoot) {
169
+ failures.push(
170
+ `worktreePath equals projectRoot (${worktree}). Development must happen in a ` +
171
+ `separate worktree, never in the primary checkout.`,
172
+ );
173
+ }
174
+
123
175
  // The Figma access chain records which tier answered. A component task with no
124
176
  // recorded tier means nothing verified that the design was actually reachable,
125
177
  // which is how a run ends up guessing spacing.
@@ -336,6 +336,25 @@ Full contract: `refs/tracker-contract.md` section "TaskCreate ordering (strict)"
336
336
  > 6. **Branch name confirm** - `feature/PROJ-{id}-{kebab}` or `bugfix/...` - user confirms or edits. In multi-repo mode the branch name is shared across all selected repos (collision check runs per-repo; any collision applies the suffix to all). (Step 4)
337
337
  > 7. **Git identity - per repo** - route via `prefs.global.platformIdentityRouting`; ask if ambiguous. Multi-repo mode resolves identity **per repo independently** (phase-0-init.md L283). (Step 6)
338
338
  > 8. **Instruction files + workspace creation - serially per repo** - detect `.instructions/figma/` etc. In multi-repo mode, worktree creation loops per repo serially (phase-0-init.md L308); any failure rolls back previously-created worktrees. Write `agent-state.json` with `state.projects[]` array (scalar `project`/`projectRoot`/`branch` mirror `projects[0]` for back-compat). (Steps 7-8)
339
+ >
340
+ > **Exit gate (BLOCKING).** The contract above was prose only, and a run still took a
341
+ > Jira ID and implemented straight onto the local checkout with no picker shown. Before
342
+ > marking Phase 0 completed:
343
+ >
344
+ > ```bash
345
+ > node "$HOME/.copilot/scripts/phase0-exit-gate.mjs" "$TASK_ID" --input "$USER_INPUT"
346
+ > ```
347
+ >
348
+ > Non-zero means Phase 0 did not produce its own output - `taskType`, `baseBranch`,
349
+ > `baseFetchStatus`, a worktree path distinct from the project root, and a recorded Figma
350
+ > access tier when the input carries a Figma URL. Fix the missing step; do not proceed.
351
+ >
352
+ > **Credential inventory (Step 0).** Run
353
+ > `bash "$HOME/.copilot/lib/credential-inventory.sh" --json` and store the result in
354
+ > `agent-state.json.credentialInventory`. Per `refs/keychain.md` Rule 2, never ask the
355
+ > user for data a mapped credential can fetch: a run asked for a hand-pasted Crashlytics
356
+ > stack trace while a valid Firebase service account was mapped and resolving. Ask for
357
+ > the pointer (the issue URL) instead, and say what you will do with it.
339
358
 
340
359
  **Step 1 - Project detection + input parsing:**
341
360