@mmerterden/multi-agent-pipeline 15.12.2 → 15.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -16,6 +16,38 @@ Internal file-layout changes that don't affect the slash-command surface are sti
16
16
 
17
17
  ## [Unreleased]
18
18
 
19
+ ## [15.14.0] - 2026-08-22
20
+
21
+ ### Added
22
+ - **A release can now be required, not just available.** `/multi-agent:update` stays exactly what it was, and most releases change nothing: the run-start check keeps reading `dist-tags.latest` and keeps asking politely. What is new is a second tag. `npm dist-tag add <pkg>@<version> required` names the oldest version a user may run, and an install below that floor is not behind, it is wrong - it would produce work against a contract that no longer holds, which then has to be redone. Below the floor, Phase 0 Step 0.6 halts: it runs the update flow and stops, and the user re-issues the command on the new version. It does not continue on the freshly updated install, because this run's phase docs, refs and scripts were already loaded from the old one, and that is the drift the floor exists to prevent. Interactive and autopilot behave identically - there is nothing to decide.
23
+ - **`require-supported-version.sh`** turns the signal into an exit code for shell callers: 0 = proceed, 3 = halt, with `force|<local>|<latest>|<required>` on stdout and a human block on stderr. It shares `update-check.sh`'s cache, so a second command inside the TTL window costs no network call.
24
+
25
+ ### Fixed
26
+ - **The Firebase Crashlytics fetcher called an endpoint that does not exist.** It built the app reference by hand as `<platform>:<bundle>` and asked for `/v1alpha/projects/<p>/apps/<ref>/issues/<issueId>`. The appId is opaque (`1:1234567890:ios:abcdef`) and cannot be derived from a bundle, and v1alpha has no get-issue-by-id route, so every fetch failed - and the failure was reported as `api-not-enabled`, which sent anyone debugging it to look at Google's API allowlist instead of at the URL. It now resolves the real appId through the Firebase Management API (`iosApps` / `androidApps`, matching `bundleId` / `packageName`), then reads `reports/topIssues` for the summary and metrics and `events?filter.issue.id=<id>` for the newest event. The payload gains what that event carries and the old shape could not: the full `stackTrace[]`, the breadcrumb and log timeline with screen names, session and occurrence counts, and the console URI. A multi-app project with no bundle match exits 3 as `app-not-found` rather than picking a neighbouring app.
27
+ - **Fortify findings that arrive without a URL are no longer invisible.** A scanner-to-tracker bridge writes the instance id and the `file:line` into the ticket and never writes the SSC link - it knows the id, and the person reading the ticket never needed the URL. The extractor was URL-only, so those tickets produced an empty `contextLinks[]`, Phase 0 skipped the deep fetch, and Phase 4's Fortify gate reported `n/a` on a ticket that exists *because of* a security finding. It now also matches a labelled `Fortify Instance ID` / `issue instance id` / `fortify id` and emits a URL-less entry, the same shape graylog trx ids already used. `fetch-fortify.sh --instance-id <id>` resolves the project version by asking each id in `prefs.global.fortify.versionIds` in order. To stay off prose, the id must be at least 16 characters and contain a digit, and the bare label `instance id` is deliberately not matched.
28
+ - **`prefs.global.fortify` did not exist.** Phase 4 Gate 5 documented `fortify.alwaysCheck` as its opt-in from the day it shipped, but `global` is closed to additional properties and the schema had no `fortify` object, so setting it failed validation - the gate could only ever run off a referenced URL. The object now exists with `alwaysCheck` and `versionIds`.
29
+ - Fortify URLs of the form `/ssc/html/ssc/version/<id>` and `#/version/<id>` now yield a version id. Only the API shape `/projectVersions/<id>` was matched before, so the UI links people actually paste parsed to `projectId: null`.
30
+
31
+ ### Changed
32
+ - `update-check.sh` reads both tags in one call and now uses the abbreviated packument (60 kB instead of 250 kB for the same answer). Its own contract is unchanged and deliberately so: it still always exits 0, still says nothing when the registry is unreachable, and still emits `<local>|<latest>` for a plain update. The floor appends a third field, `force`, which a `cut -f1`/`-f2` reader ignores. The cache file grows a third field too; a two-field cache written by an older install still reads, and its missing floor means "unknown", never "none".
33
+ - **Fail-open, on purpose.** Offline, a blocked registry, an undeterminable local version, a `required` tag published above `latest`, or no tag at all: every one of these exits 0. A version gate that bricks the pipeline on a flaky network is worse than the drift it guards against.
34
+ - **Not opt-out.** `updateCheck.enabled: false` silences the advisory "update available" prompt, which is what it always meant; it does not lift a floor. The single override is the env var `MULTI_AGENT_ALLOW_OUTDATED=1`, which exits 0 with a warning and is logged in the run record, so a broken release cannot strand someone mid-incident. Exempt commands - `update`, `setup`, `uninstall`, `help`, `status`, `log`, `search`, `routines`, `forget`, `language` - are the remedy or cannot depend on a contract.
35
+ - Phase-doc token budget: total 54050 -> 54400. Compression came first and took the new Step 0.6 prose from 469 tokens to 337, by moving the rationale, the exemption list and the `npm dist-tag add` recipe into `multi-agent-refs/rules.md` "Supported Version Gate" (loaded by 25 commands, outside this budget) and into the script header, leaving the phase doc with the call, the decision table and the halt.
36
+ - `smoke-update-check.sh` grew from 12 to 27 assertions, covering the floor, the clamp, the legacy cache, the wrapper's three exit paths and the doc wiring on both sides.
37
+ - **`smoke-context-links.sh`: the extractor has a behaviour test now.** A 250-line deterministic classifier that every Phase 0 deep fetch dispatches on had no test of its own, so a regression in any pattern would only ever surface as "the ticket had a link and nothing was enriched", with nothing red to point at. 32 assertions over every emitted type, both Fortify entry shapes, dedup, and the two things that must NOT match: prose containing the words, and a bare non-doc URL.
38
+ - `sync/SKILL.md` lost a "Special inputs" table that repeated the argument table from the top of the same file, minus one row.
39
+
40
+ ## [15.13.0] - 2026-08-22
41
+
42
+ ### Fixed
43
+ - **Three settings that were declared and did nothing are now wired up.** `learningsLedger.maxBriefEntries` had a default of 20 while both phase docs hardcoded `--max 20`, so raising it changed nothing. `testGap.scanTree` and `testGap.promoteSeverity` were declared in the schema AND implemented as `--scan-tree` / `--severity-promote` in the scanner, with nothing in between reading the pref and passing the flag: the plumbing existed at both ends and the middle was missing. A user who set any of the three got the default back with no error and no warning, and the schema told them they had done it right.
44
+
45
+ ### Added
46
+ - **`smoke-prefs-consumed.sh`: every setting the schema declares must be read by something.** This class has now shipped five times - the two `contextOffload` fields fixed in 15.11.0 and the three above - which is enough to gate rather than to keep catching by hand. The check walks the nested schema and asserts each leaf key is mentioned as a whole word somewhere outside `schemas/`. Deliberately loose: it asks "did anyone wire this up", not "is the wiring correct", because a stricter rule would have to understand shell, JS and markdown, and a gate that is wrong is worse than one that is broad. `keychainMapping.*` is exempt with a reason - those are resolved dynamically, so the literal name never appears in code by design. Verified by planting a setting nothing reads and watching the gate go red.
47
+
48
+ ### Changed
49
+ - Phase-doc token budget: total 53950 -> 54050. The 94 tokens are the wiring itself, not prose - two `--max` substitutions and a three-line flag block. Compression came first and twice: the rationale moved into the new gate's header, where it is enforced rather than described, and a `--severity-promote` table row was dropped because the invocation above it now shows the flag and names the pref that triggers it.
50
+
19
51
  ## [15.12.2] - 2026-08-22
20
52
 
21
53
  ### Fixed
package/docs/features.md CHANGED
@@ -162,7 +162,11 @@ Existing tests are immutable during a task: deleting, renaming, or weakening an
162
162
 
163
163
  ### Update Check at Run Start
164
164
 
165
- Phase 0 Step 0.6, opt-out via `prefs.global.updateCheck`. Once per `ttlHours` window (cached, 3s-bounded curl to the npm registry), the installed version is compared against `dist-tags.latest`. Newer version: interactive modes ask "Update now?" (yes runs the `/multi-agent:update` flow, then the run continues); autopilot logs one line and never asks. `autoUpdate: true` updates silently before the worktree exists. Offline and failed checks are silent - never blocks.
165
+ Phase 0 Step 0.6. Once per `ttlHours` window (cached, 3s-bounded curl to the npm registry), the installed version is compared against two dist-tags.
166
+
167
+ **`latest` - advisory**, opt-out via `prefs.global.updateCheck.enabled`. Newer version: interactive modes ask "Update now?" (yes runs the `/multi-agent:update` flow, then the run continues); autopilot logs one line and never asks. `autoUpdate: true` updates silently before the worktree exists. Offline and failed checks are silent - never blocks.
168
+
169
+ **`required` - blocking** (v15.14.0+). Most releases do not publish this tag and nothing changes for them. A release that changed a contract a run depends on is promoted with `npm dist-tag add <pkg>@<version> required`, and an install below that floor is not behind, it is wrong: `require-supported-version.sh` exits 3, the run halts, `/multi-agent:update` runs, and the user re-issues the command on the new version rather than continuing on docs already loaded from the old one. Interactive and autopilot behave identically. The gate fails open on every undeterminable answer (offline, blocked registry, no tag), `updateCheck.enabled: false` does not disable it, and the single override is the env var `MULTI_AGENT_ALLOW_OUTDATED=1`, which is logged in the run record. Exemptions: `update`, `setup`, `uninstall`, `help`, `status`, `log`, `search`, `routines`, `forget`, `language`.
166
170
 
167
171
  ### Structured Handoff Blocks
168
172
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mmerterden/multi-agent-pipeline",
3
- "version": "15.12.2",
3
+ "version": "15.14.0",
4
4
  "description": "8-phase AI development pipeline with full orchestration on Claude Code, Copilot CLI and Codex CLI. Analysis, planning, TDD, CLI-aware parallel review with consensus surfacing + Fable triage, default-FAIL evidence gates, secret + intent guards, per-phase cost ledger, persistent learnings memory, wiki generation, commit automation. Token-preserving uninstall.",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -72,15 +72,6 @@ Step 6: Report Summary: synced targets, platform, changed files, deploy sta
72
72
 
73
73
  If nothing is stale → report "All targets up to date" and stop.
74
74
 
75
- ## Special inputs
76
-
77
- | Input | Behaviour |
78
- |-------|----------|
79
- | (no args) | Full ecosystem sync (default) |
80
- | `release` | Full sync + version bump + tag + npm publish + website deploy |
81
- | `multi-agent-toolkit` | Run Step 3d only: gate, commit and publish the companion MCP server |
82
- | `"change description"` | Apply the description to EVERY target at once |
83
-
84
75
  ---
85
76
 
86
77
  ## Sync rules
@@ -427,6 +418,8 @@ npm publish --userconfig "$NPMRC"
427
418
 
428
419
  Verify the publish landed: on GitHub Packages `npm view <package> version --userconfig "$NPMRC"`; on npmjs read the registry directly (`curl -s https://registry.npmjs.org/<package>/latest`) rather than `npm view`, which can answer from a stale cache.
429
420
 
421
+ **Required release** (optional; ask first even in `release` mode, it halts every older install): `npm dist-tag add "$(node -p "require('./package.json').name")@$VER" required --userconfig "$NPMRC"`. Out of band, so promote or demote any time. Contract: `multi-agent-refs/rules.md` "Supported Version Gate".
422
+
430
423
  **Approval**: outside autopilot and outside `release`, ask before shipping.
431
424
 
432
425
  > "multi-agent-toolkit moved (N changed files, M unpushed commits, v$VER). What should I do?"
@@ -25,9 +25,17 @@ A git clone of the pipeline repo is a maintainer workspace, kept in sync by `/mu
25
25
  echo "Registry unreachable ($REG) - check your network and retry."
26
26
  exit 1
27
27
  fi
28
- echo "Current: v$CUR Latest: v$LATEST"
28
+ # dist-tags.required is the supported-version floor (absent on most releases).
29
+ REQUIRED=$(curl -fsS -H 'Accept: application/vnd.npm.install-v1+json' \
30
+ "$REG/$(printf '%s' "$PKG" | sed 's|/|%2F|')" \
31
+ | node -pe 'JSON.parse(require("fs").readFileSync(0,"utf8"))["dist-tags"].required || ""' 2>/dev/null)
32
+ echo "Current: v$CUR Latest: v$LATEST${REQUIRED:+ Required floor: v$REQUIRED}"
29
33
  ```
30
34
 
35
+ This command is never blocked by the floor - it is the remedy for it. The
36
+ line above is where the floor is reported, so the user sees why a run halted;
37
+ `$REQUIRED` does not survive into the later shell blocks.
38
+
31
39
  2. **Stop early when already current** (still refresh the marketplace, step 4b):
32
40
  ```bash
33
41
  [ "$CUR" = "$LATEST" ] && echo "✓ Already up to date: v$LATEST"
@@ -10,7 +10,8 @@
10
10
  # - swagger : OpenAPI / Swagger JSON or YAML spec URLs
11
11
  # - confluence : Confluence pages (host pattern + display/spaces/pageId)
12
12
  # - crashlytics : Firebase Crashlytics issue/session URLs
13
- # - fortify : Fortify SSC findings URLs
13
+ # - fortify : Fortify SSC finding URLs, and (URL-less, like graylog)
14
+ # labelled issue instance ids - "Fortify Instance ID: <id>"
14
15
  # - graylog : Graylog trx-id / conversation-id references. Unlike the
15
16
  # others this is matched from free text (labels like "trx",
16
17
  # "trxid", "transaction id", "conversationId", "convId",
@@ -31,7 +32,7 @@
31
32
  # { "type": "confluence", "url": "...", "metadata": { "host": "...", "pageId": "...|null", "spaceKey": "...|null" } },
32
33
  # { "type": "swagger", "url": "...", "metadata": { "format": "json|yaml|unknown" } },
33
34
  # { "type": "crashlytics","url": "...", "metadata": { "projectId": "...", "platform": "ios|android", "issueId": "...", "sessionId": "...|null" } },
34
- # { "type": "fortify", "url": "...", "metadata": { "host": "...", "projectId": "...|null" } },
35
+ # { "type": "fortify", "url": "...|null", "metadata": { "host": "...|null", "versionId": "...|null", "instanceId": "...|null", "source": "url|label" } },
35
36
  # { "type": "graylog", "url": null, "metadata": { "idType": "trx|conversation", "id": "...", "label": "..." } },
36
37
  # { "type": "figma", "url": "...", "metadata": { "kind": "design|make|board|slides", "fileKey": "...", "nodeId": "...|null" } },
37
38
  # ...
@@ -155,11 +156,18 @@ def classify(url: str):
155
156
  # https://<ssc-host>/ssc/html/ssc/...
156
157
  # https://<ssc-host>/ssc/api/v1/projectVersions/<id>/...
157
158
  if "fortify" in host or "/ssc/" in path or "/api/v1/projectVersions/" in path:
158
- project_id = None
159
- m = re.search(r"/projectVersions/(?P<id>\d+)", path)
160
- if m:
161
- project_id = m.group("id")
162
- return ("fortify", {"host": host, "projectId": project_id})
159
+ # The SSC UI keeps the version id in the fragment on some deployments
160
+ # and in the path on others; search both.
161
+ where = path + ("#" + parsed.fragment if parsed.fragment else "")
162
+ m = re.search(r"/(?:projectVersions|version)/(?P<id>\d+)", where)
163
+ version_id = m.group("id") if m else None
164
+ m2 = re.search(r"/fix/(?P<inst>[A-Za-z0-9_-]{8,})", where)
165
+ return ("fortify", {
166
+ "host": host,
167
+ "versionId": version_id,
168
+ "instanceId": m2.group("inst") if m2 else None,
169
+ "source": "url",
170
+ })
163
171
 
164
172
  # --- Generic doc fallbacks ---------------------------------------------
165
173
  # Notion, Google Docs, GitHub markdown blob/wiki, gists. Useful as
@@ -228,5 +236,45 @@ for m in GRAYLOG_RE.finditer(text):
228
236
  "metadata": {"idType": id_type, "id": gid, "label": label},
229
237
  })
230
238
 
239
+ # --- Fortify issue instance ids (label-based, not URL) ---------------------
240
+ # A finding often arrives as a ticket written by a scanner-to-tracker bridge,
241
+ # which quotes the instance id and the file:line but no SSC deep link - the
242
+ # bridge knows the id, the reader does not need the URL, and the pipeline then
243
+ # saw nothing to enrich. Same shape as the graylog entries: "url": null, the id
244
+ # in metadata. Resolution to a project version happens in fetch-fortify.sh,
245
+ # which is where the prefs live.
246
+ FORTIFY_ID_RE = re.compile(
247
+ r"(?P<label>"
248
+ r"fortify[\s_-]*instance[\s_-]*id"
249
+ r"|issue[\s_-]*instance[\s_-]*id"
250
+ r"|fortify[\s_-]*id"
251
+ r")"
252
+ r"[\s*_~`]*[:=#]?[\s*_~`]*"
253
+ r"[\"']?(?P<id>(?=[A-Za-z0-9._-]*[0-9])[A-Za-z0-9][A-Za-z0-9._-]{15,})",
254
+ re.IGNORECASE,
255
+ )
256
+ already_linked = {
257
+ e["metadata"].get("instanceId")
258
+ for e in out
259
+ if e["type"] == "fortify" and e["metadata"].get("instanceId")
260
+ }
261
+ for m in FORTIFY_ID_RE.finditer(text):
262
+ fid = m.group("id").rstrip(".,;:!?)")
263
+ key = ("fortify", fid)
264
+ if not fid or key in seen or fid in already_linked:
265
+ continue
266
+ seen.add(key)
267
+ out.append({
268
+ "type": "fortify",
269
+ "url": None,
270
+ "metadata": {
271
+ "host": None,
272
+ "versionId": None,
273
+ "instanceId": fid,
274
+ "source": "label",
275
+ "label": m.group("label"),
276
+ },
277
+ })
278
+
231
279
  print(json.dumps(out))
232
280
  PY
@@ -19,16 +19,21 @@
19
19
  # Output (stdout, single JSON object):
20
20
  # {
21
21
  # "fetchedAt": "<ISO8601>",
22
- # "source": { "url": "<url|null>", "projectId": "...", "platform": "ios|android",
23
- # "bundle": "...", "issueId": "...", "sessionId": "<id|null>" },
24
- # "issue": { "title": "...", "subtitle": "...", "type": "fatal|nonFatal|ANR",
25
- # "occurrences": <n>, "impactedUsers": <n>,
26
- # "firstSeen": "<ISO8601>", "lastSeen": "<ISO8601>",
27
- # "topStackFrame": { "file": "...", "line": <n>, "symbol": "..." },
22
+ # "source": { "url": "<url|null>", "projectId": "...", "appId": "<resolved|null>",
23
+ # "platform": "ios|android", "bundle": "...", "issueId": "...",
24
+ # "sessionId": "<id|null>" },
25
+ # "issue": { "title": "...", "subtitle": "...", "type": "FATAL|NON_FATAL|ANR",
26
+ # "state": "...", "consoleUri": "<url|null>",
27
+ # "occurrences": <n>, "impactedUsers": <n>, "sessions": <n>,
28
+ # "firstSeenVersion": "<v|null>", "lastSeenVersion": "<v|null>",
29
+ # "topStackFrame": { "file": "<library>", "line": 0, "symbol": "..." },
30
+ # "stackTrace": [ { "library": "...", "symbol": "..." }, ... ],
28
31
  # "topDevices": [ "<name>", ... ],
29
32
  # "topOS": [ "<version>", ... ],
30
33
  # "affectedAppVersions": [ "<version>", ... ] },
31
- # "session": null | { "id": "...", "events": [ { "type": "...", "time": "...", "summary": "..." } ] }
34
+ # "session": null | { "id": "<eventId>", "time": "<ISO8601>",
35
+ # "events": [ { "type": "breadcrumb|log", "time": "...",
36
+ # "summary": "...", "screen": "<name|null>" } ] }
32
37
  # }
33
38
  #
34
39
  # Exit codes:
@@ -39,11 +44,13 @@
39
44
  # 5 project mismatch - SA JSON project_id != URL project (configuration bug)
40
45
  #
41
46
  # Notes:
42
- # The Firebase Crashlytics REST surface (`firebasecrashlytics.googleapis.com`)
43
- # is currently invitation-only in some regions. When the API returns 404 for
44
- # a valid project the script exits 3 with reason "api-not-enabled"; the
45
- # orchestrator surfaces this as "fetcher unavailable, advisory only" to
46
- # keep the pipeline moving.
47
+ # Two v1alpha calls back this, because there is no get-issue-by-id endpoint:
48
+ # `reports/topIssues` for the summary and metrics, and `events?filter.issue.id`
49
+ # for the stack trace, device, OS and breadcrumbs. The opaque appId is resolved
50
+ # first through the Firebase Management API (`iosApps` / `androidApps`), since a
51
+ # console URL only ever carries the bundle id. Any failure exits 3 with a reason
52
+ # ("app-not-found", "crashlytics-unreachable"); the orchestrator surfaces that as
53
+ # "fetcher unavailable, advisory only" and keeps the pipeline moving.
47
54
 
48
55
  set -euo pipefail
49
56
 
@@ -279,80 +286,183 @@ fi
279
286
  # never appears in argv (argv is visible to ps).
280
287
  crashlytics_auth_cfg() { printf 'header = "Authorization: Bearer %s"\n' "$ACCESS_TOKEN"; }
281
288
 
282
- # Construct the appId in the form Firebase expects:
283
- # ios: ios:<bundleId> → api: /projects/<n>/apps/<bundleId> (looked up via list)
284
- # android: android:<package>
285
- # In practice the appId is opaque (1:1234567890:ios:abcdef). We try the
286
- # Crashlytics v1alpha endpoint with the bundle as a fallback "app" - the API
287
- # accepts the package/bundle when scoped to the project.
288
- APP_REF="$PLATFORM:$BUNDLE"
289
- ISSUE_URL_API="https://firebasecrashlytics.googleapis.com/v1alpha/projects/$PROJECT_ID/apps/$APP_REF/issues/$ISSUE_ID"
290
-
291
- ISSUE_JSON=$(curl -sS --fail --max-time "$TIMEOUT" --connect-timeout 5 \
292
- -K <(crashlytics_auth_cfg) -H "Accept: application/json" \
293
- "$ISSUE_URL_API" 2>/dev/null || true)
294
-
295
- if [ -z "$ISSUE_JSON" ]; then
296
- printf '{"status":"failed","reason":"api-not-enabled-or-app-mismatch","appRef":"%s","issueId":"%s"}\n' "$APP_REF" "$ISSUE_ID" >&2
289
+ fb_get() {
290
+ curl -sS --fail --max-time "$TIMEOUT" --connect-timeout 5 \
291
+ -K <(crashlytics_auth_cfg) -H "Accept: application/json" "$1" 2>/dev/null || true
292
+ }
293
+
294
+ # The appId is opaque (1:1234567890:ios:abcdef) and cannot be derived from the
295
+ # bundle id. Ask the Firebase Management API which app owns this bundle; the
296
+ # console URL only ever carries the bundle.
297
+ FB_MGMT="https://firebase.googleapis.com/v1beta1/projects/$PROJECT_ID"
298
+ if [ "$PLATFORM" = "ios" ]; then
299
+ APPS_JSON=$(fb_get "$FB_MGMT/iosApps?pageSize=200")
300
+ MATCH_FIELD="bundleId"
301
+ else
302
+ APPS_JSON=$(fb_get "$FB_MGMT/androidApps?pageSize=200")
303
+ MATCH_FIELD="packageName"
304
+ fi
305
+
306
+ APP_ID=$(APPS_IN="$APPS_JSON" WANT="$BUNDLE" FIELD="$MATCH_FIELD" python3 -c "
307
+ import json, os
308
+ try:
309
+ apps = (json.loads(os.environ['APPS_IN'] or '{}').get('apps') or [])
310
+ except Exception:
311
+ apps = []
312
+ want = (os.environ['WANT'] or '').lower()
313
+ field = os.environ['FIELD']
314
+ for a in apps:
315
+ if str(a.get(field) or '').lower() == want:
316
+ print(a.get('appId') or '')
317
+ break
318
+ else:
319
+ # A single-app project needs no disambiguation; anything else stays unresolved
320
+ # rather than guessing at the wrong app.
321
+ print(apps[0].get('appId') or '' if len(apps) == 1 else '')
322
+ " 2>/dev/null || true)
323
+
324
+ if [ -z "$APP_ID" ]; then
325
+ printf '{"status":"failed","reason":"app-not-found","platform":"%s","bundle":"%s","projectId":"%s"}\n' \
326
+ "$PLATFORM" "$BUNDLE" "$PROJECT_ID" >&2
297
327
  exit 3
298
328
  fi
299
329
 
300
- SESSION_JSON=""
301
- if [ -n "$SESSION_ID" ]; then
302
- SESSION_URL_API="$ISSUE_URL_API/sessions/$SESSION_ID"
303
- SESSION_JSON=$(curl -sS --fail --max-time "$TIMEOUT" --connect-timeout 5 \
304
- -K <(crashlytics_auth_cfg) -H "Accept: application/json" \
305
- "$SESSION_URL_API" 2>/dev/null || true)
330
+ CRASHLYTICS_APP="https://firebasecrashlytics.googleapis.com/v1alpha/projects/$PROJECT_ID/apps/$APP_ID"
331
+
332
+ # There is no get-issue-by-id endpoint on v1alpha. The issue summary comes from
333
+ # the topIssues report (filtered to the id we want) and the stack trace, device,
334
+ # OS and breadcrumbs come from the issue's most recent event. An earlier version
335
+ # of this script called /issues/<id> with a guessed "<platform>:<bundle>" app
336
+ # reference; neither the path nor the reference exists, so every fetch failed and
337
+ # reported it as "api-not-enabled".
338
+ #
339
+ # Two partial answers are treated as answers, not failures. A long-tail issue
340
+ # outside the top 200 leaves the summary empty and the event still carries the
341
+ # stack trace, and an event fetch that comes back empty still leaves the summary.
342
+ # Only both being empty is a failure. A URL naming a specific session gets that
343
+ # issue's most recent event instead: v1alpha exposes no per-session read, and
344
+ # `source.sessionId` is passed through so the caller can see what was asked for.
345
+ ISSUE_ID_ENC=$(ISSUE_IN="$ISSUE_ID" python3 -c "
346
+ import os, urllib.parse
347
+ print(urllib.parse.quote(os.environ['ISSUE_IN'], safe=''))
348
+ " 2>/dev/null || printf '%s' "$ISSUE_ID")
349
+ TOP_ISSUES_JSON=$(fb_get "$CRASHLYTICS_APP/reports/topIssues?pageSize=200")
350
+ EVENT_JSON=$(fb_get "$CRASHLYTICS_APP/events?filter.issue.id=$ISSUE_ID_ENC&pageSize=1")
351
+
352
+ if [ -z "$TOP_ISSUES_JSON" ] && [ -z "$EVENT_JSON" ]; then
353
+ printf '{"status":"failed","reason":"crashlytics-unreachable","appId":"%s","issueId":"%s"}\n' "$APP_ID" "$ISSUE_ID" >&2
354
+ exit 3
306
355
  fi
307
356
 
357
+ ISSUE_JSON="$TOP_ISSUES_JSON"
358
+ SESSION_JSON="$EVENT_JSON"
359
+
308
360
  SRC_URL="$URL" SRC_PROJECT="$PROJECT_ID" SRC_PLATFORM="$PLATFORM" SRC_BUNDLE="$BUNDLE" \
309
- SRC_ISSUE="$ISSUE_ID" SRC_SESSION="$SESSION_ID" \
361
+ SRC_ISSUE="$ISSUE_ID" SRC_SESSION="$SESSION_ID" SRC_APPID="$APP_ID" \
310
362
  ISSUE_JSON_IN="$ISSUE_JSON" SESSION_JSON_IN="$SESSION_JSON" \
311
363
  python3 - <<'PY'
312
364
  import json, os, datetime
313
365
 
314
- issue = json.loads(os.environ["ISSUE_JSON_IN"] or "{}")
315
- session_raw = os.environ.get("SESSION_JSON_IN") or ""
316
- session = json.loads(session_raw) if session_raw else None
366
+ def load(name):
367
+ raw = os.environ.get(name) or ""
368
+ try:
369
+ return json.loads(raw) if raw else {}
370
+ except Exception:
371
+ return {}
372
+
373
+ issue_id = os.environ["SRC_ISSUE"]
374
+
375
+ # topIssues returns groups: {"issue": {...}, "metrics": [{...}]}. Find ours.
376
+ groups = load("ISSUE_JSON_IN").get("topIssues") or load("ISSUE_JSON_IN").get("issues") or []
377
+ group = {}
378
+ for g in groups:
379
+ if str(((g or {}).get("issue") or {}).get("id") or "") == issue_id:
380
+ group = g
381
+ break
382
+ issue = (group.get("issue") or {}) if isinstance(group, dict) else {}
383
+ metrics_list = (group.get("metrics") or []) if isinstance(group, dict) else []
384
+ metrics = metrics_list[0] if metrics_list else {}
385
+
386
+ def count(value):
387
+ try:
388
+ return int(value)
389
+ except (TypeError, ValueError):
390
+ return 0
391
+
392
+ events_body = load("SESSION_JSON_IN")
393
+ events = events_body.get("events") or events_body.get("errorEvents") or []
394
+ event = events[0] if events else {}
395
+
396
+ exceptions = event.get("exceptions") or []
397
+ top_exception = exceptions[0] if exceptions else {}
398
+ frames = top_exception.get("frames") or []
399
+ top_frame = frames[0] if frames else {}
400
+
401
+ # v1alpha frames carry library + symbol, not file + line. Keeping the documented
402
+ # key names with the library in "file" beats inventing a second shape for the
403
+ # one consumer (Phase 1 prompt injection) that reads them.
404
+ stack = [
405
+ {"library": f.get("library") or "", "symbol": f.get("symbol") or f.get("rawSymbol") or ""}
406
+ for f in frames[:40]
407
+ ]
408
+
409
+ versions = []
410
+ for v in (issue.get("firstSeenVersion"), issue.get("lastSeenVersion"),
411
+ ((event.get("version") or {}).get("displayName"))):
412
+ if v and v not in versions:
413
+ versions.append(v)
317
414
 
318
- def first(d, *keys, default=None):
319
- for k in keys:
320
- if isinstance(d, dict) and k in d:
321
- return d[k]
322
- return default
415
+ timeline = []
416
+ for crumb in (event.get("breadcrumbs") or [])[:25]:
417
+ screen = ((crumb.get("params") or {}).get("firebase_screen_class")) or None
418
+ timeline.append({
419
+ "type": "breadcrumb",
420
+ "time": crumb.get("eventTime") or "",
421
+ "summary": crumb.get("title") or "",
422
+ "screen": screen,
423
+ })
424
+ for log in (event.get("logs") or [])[:25]:
425
+ message = log.get("message") or ""
426
+ if message:
427
+ timeline.append({"type": "log", "time": "", "summary": message, "screen": None})
323
428
 
324
- top_frame_raw = first(issue, "topFrame", "frame", default={}) or {}
325
429
  result = {
326
- "fetchedAt": datetime.datetime.utcnow().isoformat() + "Z",
430
+ "fetchedAt": datetime.datetime.now(datetime.timezone.utc).isoformat().replace("+00:00", "Z"),
327
431
  "source": {
328
432
  "url": os.environ.get("SRC_URL") or None,
329
433
  "projectId": os.environ["SRC_PROJECT"],
434
+ "appId": os.environ.get("SRC_APPID") or None,
330
435
  "platform": os.environ["SRC_PLATFORM"],
331
436
  "bundle": os.environ["SRC_BUNDLE"],
332
- "issueId": os.environ["SRC_ISSUE"],
437
+ "issueId": issue_id,
333
438
  "sessionId": os.environ.get("SRC_SESSION") or None,
334
439
  },
335
440
  "issue": {
336
- "title": first(issue, "title", "errorTitle", "subtitle", default=""),
337
- "subtitle": first(issue, "subtitle", "errorMessage", default=""),
338
- "type": first(issue, "type", "errorType", default="unknown"),
339
- "occurrences": first(issue, "occurrences", "eventCount", default=0),
340
- "impactedUsers": first(issue, "impactedUsers", "userCount", default=0),
341
- "firstSeen": first(issue, "firstSeen", "createTime", default=None),
342
- "lastSeen": first(issue, "lastSeen", "updateTime", default=None),
441
+ "title": issue.get("title") or top_exception.get("title") or "",
442
+ "subtitle": issue.get("subtitle") or "",
443
+ "type": issue.get("errorType") or "unknown",
444
+ "state": issue.get("state") or "",
445
+ "consoleUri": issue.get("uri") or None,
446
+ "occurrences": count(metrics.get("eventsCount")),
447
+ "impactedUsers": count(metrics.get("impactedUsersCount")),
448
+ "sessions": count(metrics.get("sessionsCount")),
449
+ "firstSeenVersion": issue.get("firstSeenVersion") or None,
450
+ "lastSeenVersion": issue.get("lastSeenVersion") or None,
343
451
  "topStackFrame": {
344
- "file": first(top_frame_raw, "fileName", "file", default=""),
345
- "line": first(top_frame_raw, "lineNumber", "line", default=0),
346
- "symbol": first(top_frame_raw, "symbol", "function", default=""),
452
+ "file": top_frame.get("library") or "",
453
+ "line": 0,
454
+ "symbol": top_frame.get("symbol") or top_frame.get("rawSymbol") or "",
347
455
  },
348
- "topDevices": (issue.get("topDevices") or [])[:5],
349
- "topOS": (issue.get("topOperatingSystems") or issue.get("topOS") or [])[:5],
350
- "affectedAppVersions": (issue.get("affectedVersions") or issue.get("appVersions") or [])[:5],
456
+ "stackTrace": stack,
457
+ "topDevices": [d for d in [((event.get("device") or {}).get("displayName"))] if d],
458
+ "topOS": [o for o in [((event.get("operatingSystem") or {}).get("displayName"))] if o],
459
+ "affectedAppVersions": versions,
351
460
  },
352
461
  "session": ({
353
- "id": os.environ.get("SRC_SESSION") or "",
354
- "events": (session.get("events") if isinstance(session, dict) else None) or [],
355
- } if session else None),
462
+ "id": event.get("eventId") or os.environ.get("SRC_SESSION") or "",
463
+ "time": event.get("eventTime") or "",
464
+ "events": timeline,
465
+ } if event else None),
356
466
  }
357
467
  print(json.dumps(result, ensure_ascii=False))
358
468
  PY
@@ -5,13 +5,20 @@
5
5
  # emit a normalized JSON view that Phase 4 (Review) consumes as a
6
6
  # deterministic security gate.
7
7
  #
8
- # Two input forms:
8
+ # Three input forms:
9
9
  # ./fetch-fortify.sh <ssc-url> # parses versionId / issue from URL
10
10
  # ./fetch-fortify.sh --version-id <id> [--issue <id>] # explicit components
11
+ # ./fetch-fortify.sh --instance-id <id> # instance id alone; the
12
+ # project version is resolved by searching prefs.global.fortify.versionIds
13
+ # in order. A finding often reaches a ticket as "Fortify Instance ID: <id>"
14
+ # with no SSC link at all - a scanner-to-tracker bridge knows the id, and
15
+ # the person reading the ticket never needed the URL.
11
16
  #
12
17
  # Required configuration:
13
18
  # prefs.global.hosts.fortify - SSC host (e.g. ssc.example.com)
14
19
  # prefs.global.keychainMapping.fortify - keychain key holding the API token
20
+ # prefs.global.fortify.versionIds[] - project versions searched, in order,
21
+ # when only an instance id is known
15
22
  #
16
23
  # Optional env:
17
24
  # FORTIFY_TIMEOUT_SECONDS default 25
@@ -39,7 +46,8 @@
39
46
  # 2 missing/expired credential (orchestrator handles the Save Flow)
40
47
  # 3 network/auth failure
41
48
  # 4 bad usage
42
- # 6 host not configured (prefs.global.hosts.fortify is empty)
49
+ # 6 not configured (prefs.global.hosts.fortify empty, or an instance-id-only
50
+ # lookup with no prefs.global.fortify.versionIds to search)
43
51
  #
44
52
  # Phase 4 review gate contract:
45
53
  # Critical > 0 → blocking=true, reason="critical-findings"
@@ -58,8 +66,9 @@ while [ $# -gt 0 ]; do
58
66
  case "$1" in
59
67
  --version-id) VERSION_ID="$2"; shift 2 ;;
60
68
  --issue) ISSUE_ID="$2"; shift 2 ;;
69
+ --instance-id) ISSUE_ID="$2"; shift 2 ;;
61
70
  -h|--help)
62
- echo "usage: $0 <ssc-url> | $0 --version-id <id> [--issue <id>]" >&2
71
+ echo "usage: $0 <ssc-url> | $0 --version-id <id> [--issue <id>] | $0 --instance-id <id>" >&2
63
72
  exit 4 ;;
64
73
  *)
65
74
  if [ -z "$URL" ]; then URL="$1"; shift; else
@@ -109,11 +118,30 @@ PY
109
118
  [ -z "$ISSUE_ID" ] && ISSUE_ID=$(printf '%s' "$PARSED" | cut -f2)
110
119
  fi
111
120
 
112
- if [ -z "$VERSION_ID" ]; then
113
- echo "ERR: no Fortify version id (pass --version-id or use a URL containing /version/<id>)" >&2
121
+ # An instance id with no version is resolvable: search the configured versions
122
+ # below, once the token is in hand. Anything else is a usage error.
123
+ if [ -z "$VERSION_ID" ] && [ -z "$ISSUE_ID" ]; then
124
+ echo "ERR: no Fortify version id (pass --version-id, --instance-id, or a URL containing /version/<id>)" >&2
114
125
  exit 4
115
126
  fi
116
127
 
128
+ VERSION_CANDIDATES=""
129
+ if [ -z "$VERSION_ID" ] && [ -f "$PREFS" ]; then
130
+ VERSION_CANDIDATES=$(python3 -c "
131
+ import json
132
+ try:
133
+ p = json.load(open('$PREFS'))
134
+ ids = p.get('global', {}).get('fortify', {}).get('versionIds') or []
135
+ print(' '.join(str(i) for i in ids if str(i).strip()))
136
+ except Exception:
137
+ print('')
138
+ ")
139
+ fi
140
+ if [ -z "$VERSION_ID" ] && [ -z "$VERSION_CANDIDATES" ]; then
141
+ printf '%s\n' '{"status":"blocked","reason":"version-not-configured","service":"fortify","expected_pref":"global.fortify.versionIds"}' >&2
142
+ exit 6
143
+ fi
144
+
117
145
  # Token via keychain mapping.
118
146
  TOKEN_KEY=""
119
147
  if [ -f "$PREFS" ]; then
@@ -202,6 +230,31 @@ fortify_get() {
202
230
  return 1
203
231
  }
204
232
 
233
+ # Instance-id-only lookup: ask each configured version whether it owns the id.
234
+ # First hit wins; instance ids are globally unique in practice, and searching a
235
+ # handful of versions costs one bounded call each.
236
+ if [ -z "$VERSION_ID" ]; then
237
+ for _cand in $VERSION_CANDIDATES; do
238
+ _probe=$(fortify_get "projectVersions/$_cand/issues?q=issueInstanceId:\"$ISSUE_ID\"&limit=1" 2>/dev/null || true)
239
+ _hit=$(printf '%s' "$_probe" | python3 -c "
240
+ import sys, json
241
+ try:
242
+ print('yes' if (json.load(sys.stdin).get('data') or []) else 'no')
243
+ except Exception:
244
+ print('no')
245
+ " 2>/dev/null || echo no)
246
+ if [ "$_hit" = "yes" ]; then
247
+ VERSION_ID="$_cand"
248
+ break
249
+ fi
250
+ done
251
+ if [ -z "$VERSION_ID" ]; then
252
+ echo "ERR: Fortify instance $ISSUE_ID not found in configured versions ($VERSION_CANDIDATES)" >&2
253
+ exit 3
254
+ fi
255
+ fi
256
+ unset _cand _probe _hit 2>/dev/null || true
257
+
205
258
  # Check fortify_get's actual exit status, not a string pattern over its
206
259
  # output - the old `case ... HTTP_AUTH_FAILED|*HTTP_4*)` only recognized
207
260
  # auth failures and 4xx. A dropped connection, timeout, or 5xx made
@@ -31,7 +31,7 @@ Output schema (one array entry per URL - full contract in `~/.claude/lib/conte
31
31
  { "type": "swagger", "url": "<url>", "metadata": { "format": "json|yaml|unknown" } },
32
32
  { "type": "confluence", "url": "<url>", "metadata": { "host": "<host>", "pageId": "<id|null>", "spaceKey": "<key|null>" } },
33
33
  { "type": "crashlytics", "url": "<url>", "metadata": { "projectId": "...", "platform": "ios|android", "bundle": "...", "issueId": "...", "sessionId": "<id|null>" } },
34
- { "type": "fortify", "url": "<url>", "metadata": { "host": "<host>", "projectId": "<id|null>" } },
34
+ { "type": "fortify", "url": "<url|null>", "metadata": { "host": "<host|null>", "versionId": "<id|null>", "instanceId": "<id|null>", "source": "url|label" } },
35
35
  { "type": "graylog", "url": null, "metadata": { "idType": "trx|conversation", "id": "...", "label": "..." } },
36
36
  { "type": "figma", "url": "<url>", "metadata": { "kind": "design|make|board|slides", "fileKey": "...", "nodeId": "<id|null>" } },
37
37
  { "type": "generic-doc", "url": "<url>", "metadata": { "host": "<host>", "kind": "notion|google-docs|gist|github-doc" } }
@@ -47,16 +47,20 @@ URL pattern (caught by the extractor): `console.firebase.google.com/(u/[0-9]+/)?
47
47
  - Host is fixed (`console.firebase.google.com`); no `hosts.firebase` pref needed.
48
48
  - Token resolution: `prefs.global.keychainMapping.firebase` → `~/.claude/lib/credential-store.sh get "<key>" | base64 -d` → Service Account JSON. `project_id` read from decoded JSON - verify it matches the `<projectId>` from the URL; mismatch → warn, skip enrichment.
49
49
  - Exchange the SA JSON for a short-lived GCP access token (scope `https://www.googleapis.com/auth/firebase https://www.googleapis.com/auth/cloud-platform`) using `google-auth` (Python) or an inline JWT exchange (OpenSSL sign → `https://oauth2.googleapis.com/token`). Cache in-memory for the pipeline run only - never persist.
50
- - Fetch crash detail: `GET https://firebasecrashlytics.googleapis.com/v1alpha/projects/<projectId>/apps/<appId>/issues/<issueId>` (issue summary + top device + OS + impacted users + top stack frame). If `sessionId` present, also fetch `/sessions/<sessionId>` for the full session timeline.
51
- - Store as `state.crashContext = { projectId, appId, issueId, title, subtitle, impactedUsers, topDevices[], topOS[], topStackFrame, sessionTimeline?, fetchedAt }`.
50
+ - Resolve the opaque `appId` first: `GET https://firebase.googleapis.com/v1beta1/projects/<projectId>/(iosApps|androidApps)?pageSize=200` and match `bundleId` (iOS) / `packageName` (Android) against the bundle from the URL. The console URL only carries the bundle, and the Crashlytics API only accepts the appId (`1:1234567890:ios:abcdef`). No match in a multi-app project → exit `3`, reason `app-not-found`; never guess at another app.
51
+ - Fetch crash detail with two calls - v1alpha has no get-issue-by-id endpoint: `GET .../v1alpha/projects/<projectId>/apps/<appId>/reports/topIssues?pageSize=200` for the summary and metrics (filter to the issue id), and `GET .../apps/<appId>/events?filter.issue.id=<issueId>&pageSize=1` for the newest event, which carries the stack frames, device, OS and breadcrumbs.
52
+ - Store as `state.crashContext = { projectId, appId, issueId, title, subtitle, type, state, occurrences, impactedUsers, sessions, firstSeenVersion, lastSeenVersion, topStackFrame, stackTrace[], topDevices[], topOS[], affectedAppVersions[], sessionTimeline?, fetchedAt }`.
52
53
  - Phase 1 Analysis prepends `state.crashContext` to the agent prompt under a **Known Crash Context** section (do not rediscover, use as ground truth).
53
54
  - Soft-fail: any network/auth error → log warning, continue pipeline without enrichment (ticket still gets manually analysed).
54
55
 
55
56
  ##### Step 1b.2 - Fortify SSC deep fetch (runs when `type == "fortify"` present in `state.contextLinks[]`)
56
57
 
57
- URL pattern (caught by the extractor): `https://<prefs.global.hosts.fortify>/ssc/html/(ssc/)?version/<versionId>(/.*)?(#/version/<versionId>)?(/audit|/issues|/artifacts)?(\?issue=<issueId>)?` (plus the short-link variant `/ssc/html/ssc/version/<versionId>/fix/<issueInstanceId>`).
58
+ Two entry shapes reach this step, and `metadata.source` says which:
58
59
 
59
- - Host comes from `prefs.global.hosts.fortify` (collected during Token Save Flow Step 3.5). URL host must match, else warn and skip (protects against pasting another company's Fortify URL by accident).
60
+ - `source: "url"` - `https://<prefs.global.hosts.fortify>/ssc/html/(ssc/)?version/<versionId>(/.*)?(#/version/<versionId>)?(/audit|/issues|/artifacts)?(\?issue=<issueId>)?`, plus the short-link variant `/ssc/html/ssc/version/<versionId>/fix/<issueInstanceId>`. The version id is read from the path or the fragment, whichever the deployment uses.
61
+ - `source: "label"` - a bare `Fortify Instance ID: <id>` (also `issue instance id`, `fortify id`; the id must be at least 16 characters and carry a digit, so a word following the label is never mistaken for a finding) with no URL anywhere in the text, so the entry carries `"url": null`, exactly like a graylog entry. This is the common shape when a scanner-to-tracker bridge opens the ticket: it knows the instance id and the file:line, and never writes the SSC link. Call `~/.claude/lib/fetch-fortify.sh --instance-id <id>`; it resolves the project version by asking each id in `prefs.global.fortify.versionIds` in order and taking the first hit. No `versionIds` configured → the fetcher exits `6` with reason `version-not-configured`; log the setup hint and skip. Configured but no version owns the id → exit `3`, reason `instance-not-found`, also a skip.
62
+
63
+ - Host comes from `prefs.global.hosts.fortify` (collected during Token Save Flow Step 3.5). On the URL form the URL host must match, else warn and skip (protects against pasting another company's Fortify URL by accident); the label form has no host to disagree with.
60
64
  - Token resolution: `prefs.global.keychainMapping.fortify` → `~/.claude/lib/credential-store.sh get "<key>"` → API token. Auth header: `Authorization: FortifyToken <base64(token)>` (legacy tokens) or `Bearer <token>` (scoped tokens) - try Bearer first, fall back to FortifyToken on 401.
61
65
  - Fetch finding detail (VPN required; respect `vpnServices.fortify`):
62
66
  - Version: `GET /ssc/api/v1/projectVersions/<versionId>` → name, project name, current issue count.
@@ -100,14 +100,28 @@ Log the resolved tier in the agent log:
100
100
 
101
101
  Full chain definition, REST endpoints, URL parsing, canonical-component contract: `$HOME/.claude/rules/figma-pipeline.md` "MUST: Figma access - 3-tier fallback chain (BLOCKING, pipeline-wide)". Do not duplicate it here.
102
102
 
103
- #### Step 0.6 - Update check (advisory, opt-out via `prefs.global.updateCheck.enabled`)
103
+ #### Step 0.6 - Update check (advisory by default, blocking on a required release)
104
104
 
105
- Run `bash $HOME/.claude/scripts/update-check.sh` (cached per `updateCheck.ttlHours`, default 24h; 3s-bounded; every failure path silent). Empty output continue. Output `<local>|<latest>` means a newer version exists:
105
+ Run `bash $HOME/.claude/scripts/update-check.sh` (cached per `updateCheck.ttlHours`, default 24h; 3s-bounded; every failure path silent; exit code always 0). Read stdout:
106
+
107
+ | stdout | Meaning | Branch |
108
+ |---|---|---|
109
+ | empty | current, ahead of the registry, or the check could not run | continue |
110
+ | `<local>\|<latest>` | a newer release exists | advisory |
111
+ | `<local>\|<latest>\|force` | installed version is below `dist-tags.required` | **required - the run halts** |
112
+
113
+ **Required branch (blocking).** Confirm with `bash $HOME/.claude/scripts/require-supported-version.sh` (exit 3 = halt; stdout `force|<local>|<latest>|<required>`, stderr the human block), then:
114
+
115
+ - Log `→ update required: v<local> < required v<required> (halting)`.
116
+ - Interactive and autopilot are identical - there is nothing to decide. Run the `/multi-agent:update` flow, then **stop the run** and ask the user, in `outputLanguage`, to re-issue the command. Do NOT continue into Phase 1: this run loaded its docs and scripts from the old version, which is the drift the floor exists to prevent. An update flow that itself fails halts too, never falls through.
117
+ - `MULTI_AGENT_ALLOW_OUTDATED=1` is the only override (`updateCheck.enabled: false` silences the advisory nag, not this); when set, log `→ supported-version gate overridden` and continue. Exemptions, fail-open rules and how a floor is published: `$HOME/.claude/multi-agent-refs/rules.md` "Supported Version Gate".
118
+
119
+ **Advisory branch (never blocks).**
106
120
 
107
121
  - **Interactive**: log `→ update available: v<local> -> v<latest>`, ask ONE AskUserQuestion - **Update now** (recommended) / **Continue without updating**. On *Update now* (or `updateCheck.autoUpdate: true`, which skips the question): run the `/multi-agent:update` flow, log `→ updated to v<latest>`, continue the run (note in the log: already-loaded phase docs finish this run on the old version; full effect next session). On *Continue*: no re-ask until the TTL expires.
108
122
  - **Autopilot**: never ask (zero-interaction contract). Log `→ update available: ... (log-only; run /multi-agent:update)` and continue - unless `autoUpdate: true`, then update silently first.
109
123
 
110
- Advisory - NEVER blocks. Must run BEFORE Step 6 (worktree creation) so an accepted update cannot mutate `~/.claude` under a mid-phase run.
124
+ Both branches must run BEFORE Step 6 (worktree creation) so an accepted or forced update cannot mutate `~/.claude` under a mid-phase run.
111
125
 
112
126
  #### Step 0.7 - Token expiry pre-flight (runs before Step 1 fetch)
113
127
 
@@ -40,7 +40,7 @@ Exit 0 with empty output = pref off or no memory on disk - skip. Otherwise the
40
40
  # HEAD of the prompt: task-independent, byte-stable, so it caches.
41
41
  node $HOME/.claude/scripts/learnings-ledger.mjs profile 2>/dev/null
42
42
  # END of the prompt, after the task text: ranked against this task.
43
- node $HOME/.claude/scripts/learnings-ledger.mjs brief --max 20 \
43
+ node $HOME/.claude/scripts/learnings-ledger.mjs brief --max "${prefs_learningsLedger_maxBriefEntries:-20}" \
44
44
  --task "$TASK_TITLE $TASK_DESCRIPTION" 2>/dev/null
45
45
  ```
46
46
 
@@ -36,7 +36,7 @@ This prevents a false "it built" claim with no log behind it. On exit 1, treat t
36
36
 
37
37
  ##### Gate 5 - Fortify SSC findings (runs when `state.contextLinks[]` contains a `fortify` entry, or when `prefs.global.fortify.alwaysCheck === true`)
38
38
 
39
- If the task description referenced a Fortify version, `~/.claude/lib/fetch-fortify.sh` already populated `state.fortifyFinding` in Phase 0. Phase 4 reuses that payload and applies the deterministic gate:
39
+ If the task description referenced a Fortify version, or named a bare issue instance id, `~/.claude/lib/fetch-fortify.sh` already populated `state.fortifyFinding` in Phase 0 (`alwaysCheck` needs `prefs.global.fortify.versionIds` to know what to scan). Phase 4 reuses that payload and applies the deterministic gate:
40
40
 
41
41
  ```bash
42
42
  gate=$(jq -r '.fortifyFinding.gateOutcome // empty' "$STATE_FILE")
@@ -423,7 +423,7 @@ Hits are relevance-ranked (`prefs.global.memoryRecall`); a finding matching noth
423
423
  **Rejected-preference brief (on by default via `prefs.global.learningsLedger.injectIntoTriage`).** Inject the durable rejected-preference list so triage does not re-accept a suggestion the team already rejected on this repo:
424
424
 
425
425
  ```bash
426
- node $HOME/.claude/scripts/learnings-ledger.mjs brief --max 20 \
426
+ node $HOME/.claude/scripts/learnings-ledger.mjs brief --max "${prefs_learningsLedger_maxBriefEntries:-20}" \
427
427
  --task "$(jq -r '[.findings[].issue] | join(" ")' <<< "$MERGED_FINDINGS")" 2>/dev/null
428
428
  ```
429
429
 
@@ -19,8 +19,11 @@ case "$STACK" in
19
19
  *) SCAN_STACK="" ;;
20
20
  esac
21
21
  if [ -n "$SCAN_STACK" ] && [ "${prefs_testGap_enabled:-true}" = "true" ]; then
22
+ GAP_FLAGS=""
23
+ [ "${prefs_testGap_scanTree:-false}" = "true" ] && GAP_FLAGS="$GAP_FLAGS --scan-tree"
24
+ [ "${prefs_testGap_promoteSeverity:-false}" = "true" ] && GAP_FLAGS="$GAP_FLAGS --severity-promote"
22
25
  GAP_JSON=$(node $HOME/.claude/scripts/test-gap-scan.mjs \
23
- --base "$BASE_BRANCH" --head HEAD --stack "$SCAN_STACK" 2>/dev/null)
26
+ --base "$BASE_BRANCH" --head HEAD --stack "$SCAN_STACK" $GAP_FLAGS 2>/dev/null)
24
27
  echo "$GAP_JSON" | node $HOME/.claude/scripts/validate-test-gap.mjs - >/dev/null 2>&1 || GAP_JSON=""
25
28
  fi
26
29
  ```
@@ -42,7 +45,6 @@ fi
42
45
  |---|---|
43
46
  | `composable_fun`, `view_struct`, `config_struct`, `interface`, `objc_export`, `public_proto` | important |
44
47
  | Other public API additions (`public_func`, `open_fun`, `named_export`, `default_export`, ...) | suggestion |
45
- | `--severity-promote` | forces every gap to `important` (used when audit-mode is on) |
46
48
 
47
49
  **Gating** (opt-in): if `prefs.testGap.blockingThreshold` is set and `gapBySeverity.important + gapBySeverity.blocking` exceeds it, Phase 5 surfaces the report as a Phase 4 rework finding and loops back. **Default off** - gaps render as advisory under "Test Gap Report" only.
48
50
 
@@ -228,6 +228,17 @@ Violation: smoke gate `smoke-no-mcp-in-dev-phases.sh` fails the run.
228
228
 
229
229
  Memory: [[mcp-only-in-analysis]]
230
230
 
231
+ ## Supported Version Gate (pipeline-wide)
232
+
233
+ The npm dist-tag `required` names the oldest version a user may run. Most releases do not set it, and nothing changes for them. A release that changes a contract a run depends on sets it, and then an older install is not "behind" - it is wrong, and its output would have to be redone.
234
+
235
+ - **Where it runs**: Phase 0 Step 0.6 for every pipeline mode, and as the first step of any standalone command that reaches an external system or writes to a repo. The check is cached (`updateCheck.ttlHours`, default 24h) and shared, so a second command in the same window costs nothing.
236
+ - **Exempt commands** - they are the remedy or cannot depend on a contract: `update`, `setup`, `uninstall`, `help`, `status`, `log`, `search`, `routines`, `forget`, `language`.
237
+ - **How to check**: `bash $HOME/.claude/scripts/require-supported-version.sh`. Exit 0 = proceed. Exit 3 = halt: run the `/multi-agent:update` flow, then stop and ask the user to re-issue the command - never continue into the work on the freshly updated install, because this run's docs and scripts were already loaded from the old version.
238
+ - **Fail-open**: offline, blocked registry, unknown local version or no `required` tag all exit 0. A version gate that bricks the pipeline on a flaky network is worse than the drift it guards.
239
+ - **Not opt-out**: `updateCheck.enabled: false` silences the advisory "update available" nag, not this gate. The only override is `MULTI_AGENT_ALLOW_OUTDATED=1`, which exits 0 with a warning and must be logged in the run record when used.
240
+ - **Setting the floor** (maintainers): `npm dist-tag add @<scope>/multi-agent-pipeline@<version> required`. It is set out of band, so a release can be promoted to required after the fact, and demoted the same way.
241
+
231
242
  ## Naming & Hygiene
232
243
 
233
244
  - **No task-sequence shortcuts in code.** "F1", "F2", "F3", "Feature1", "Step2" etc. are task-numbering UX labels in pickers / Confluence; they do not belong in identifiers, comments, log categories, commit messages, branch names, or file names. Use the full feature name (`FlightStatus`, `CheckoutFlow`, `PasswordReset`). Existing legacy references stay until a touch-up touches the same line; do not mass-rename without scope.
@@ -582,6 +582,24 @@
582
582
  }
583
583
  }
584
584
  },
585
+ "fortify": {
586
+ "type": "object",
587
+ "additionalProperties": false,
588
+ "description": "v15.14+ - Fortify SSC behaviour. The host and the API token live in global.hosts.fortify and global.keychainMapping.fortify; this object holds the two things that are neither. Phase 4 Gate 5 read `fortify.alwaysCheck` from the moment it shipped, but global was closed to additional properties and this object did not exist, so setting it failed validation and the gate could only ever run off a referenced URL.",
589
+ "properties": {
590
+ "alwaysCheck": {
591
+ "type": "boolean",
592
+ "default": false,
593
+ "description": "Run the Phase 4 Fortify gate on every run, not only when the task referenced a finding. Needs versionIds to know what to scan."
594
+ },
595
+ "versionIds": {
596
+ "type": "array",
597
+ "items": { "type": "string" },
598
+ "default": [],
599
+ "description": "SSC project version ids, searched in order. Used when a ticket names an issue instance id but no SSC URL (fetch-fortify.sh --instance-id), and by alwaysCheck. Ids are strings because SSC returns them as strings in some deployments."
600
+ }
601
+ }
602
+ },
585
603
  "reportChannels": {
586
604
  "type": "object",
587
605
  "additionalProperties": false,
@@ -749,12 +767,12 @@
749
767
  "updateCheck": {
750
768
  "type": "object",
751
769
  "additionalProperties": false,
752
- "description": "v10.9+ - Phase 0 Step 0.6 advisory version check. Once per ttlHours window, a bounded (3s) registry read compares the installed version against dist-tags.latest. Newer version found: interactive modes ask 'Update now / Continue' (yes runs the /multi-agent:update flow, then the run continues); autopilot logs one line and never asks. Offline/failed checks are silent; the step never blocks the pipeline.",
770
+ "description": "v10.9+ - Phase 0 Step 0.6 version check. Once per ttlHours window, a bounded (3s) registry read compares the installed version against dist-tags.latest and dist-tags.required. Newer version found: interactive modes ask 'Update now / Continue' (yes runs the /multi-agent:update flow, then the run continues); autopilot logs one line and never asks; offline/failed checks are silent and never block. v15.14+ adds the required floor: when the installed version is BELOW dist-tags.required the run halts until /multi-agent:update ran - that branch is governed by no key here (enabled:false silences the advisory nag only), its single override is the env var MULTI_AGENT_ALLOW_OUTDATED=1, and it fails open on every undeterminable answer.",
753
771
  "properties": {
754
772
  "enabled": {
755
773
  "type": "boolean",
756
774
  "default": true,
757
- "description": "Master switch. On by default - the cost is at most one 3s-bounded curl per ttlHours."
775
+ "description": "Master switch for the ADVISORY 'update available' prompt. On by default - the cost is at most one 3s-bounded curl per ttlHours. Does not disable the required-version floor; see the object description."
758
776
  },
759
777
  "ttlHours": {
760
778
  "type": "integer",
@@ -36,6 +36,6 @@
36
36
  "warn_tokens": 5600
37
37
  }
38
38
  },
39
- "total_max_tokens": 53950,
40
- "note": "Token estimate = ceil(chars / 4). Per-phase budget rule: warn = current+10% (rounded to nearest 50), max = current+25%. Gives ~6 edit cycles of headroom before warn trips - intentionally quiet under normal maintenance, loud when a phase grows unusually. Only the active phase is loaded (lazy). Recalibrated at v10.0.0 after the validator/consistency/simplifier/lesson gate contracts landed in phases 1-4. Recalibrated again at v10.9.0 after the verify-by-test (Phase 4 Step 3.7), update-check (Phase 0 Step 0.6), immutable-test (Phase 3 GREEN) and redTests re-entry contracts landed - Step 3.7 prose was compressed to a pointer into refs/features/verify-by-test.md before the recalibration. Total bumped 50000 -> 51000 at v12.5.0 after the worktree residue/traversal-prune contract (Phase 0 + Phase 5 heal) and the Reflexion causal-diagnosis contract (Phase 4 lesson memory) landed; the prose was compressed first (161 tokens reclaimed) and every per-phase max still passes - only the aggregate needed room. Recalibrated again at v13.6.0 after the install-relative path correction: an instruction that names `pipeline/scripts/x` resolves only from a repo checkout, and a run happens in the user's worktree, so 157 references across these docs moved to `$HOME/.claude/...` at +5 bytes each - 196 tokens of pure correctness cost. Same discipline as before: prose was compressed FIRST (149 tokens reclaimed, by pointing Phase 1's Figma tier table at the Phase 0 probe that already resolved it and Phase 4's Codex constraints at the always-loaded AGENTS.md block), and only then were the budgets moved. Five warn lines had been permanently amber, which makes the amber tier useless as a signal, so every warn was reset to the documented current+10% and the four maxes that the new warn would have collided with were reset to current+25%. Aggregate 51000 -> 51500. Total bumped 51500 -> 52200 at v14.0.0 after Phase 4 Review entered the four --dev mode phase sets and the criteria-resolution contract (Step 1.78) landed. Same discipline as every prior bump: prose was compressed FIRST, 820 tokens reclaimed, before the number moved. Two of those compressions are structural rather than cosmetic - the hardcoded SwiftUI interaction list in Step 1.5 and the SwiftUI convention paragraph in Step 2.8 were transcriptions of rules that now live in a scoped registry, so keeping them here would have re-created the drift this release exists to remove, and the third moved the Step 1.78 full contract into refs/features/skill-conformance.md leaving a pointer. What remains is contract text that cannot be inferred: the manifest's four consumer-visible parts, the conformance checklist the reviewers must return, and the fail-closed semantics. Every per-phase max still passes (phase-4 12405/14750); only the aggregate needed room. Total bumped 52200 -> 52700 at v14.1.0 after two more contracts landed: stack skill routing (Phase 3 pre-flight step 9) and worktree finalize (Phase 6 step 9). Compression came first, as always, and twice: 224 tokens out of Phase 3 by pointing its criteria-ledger and routing steps at their feature files instead of restating them, and 190 out of Phase 6 by moving the finalize contract into refs/features/worktree-finalize.md and leaving the invocation plus the exit-3 semantics. Both new contracts follow the pattern the earlier ones set: the phase doc carries the call and the decision, the feature file carries the reasoning, and the feature files are outside this budget because it loops only the eight phase-N-* keys. Every per-phase max still passes (phase-3 7677/8950, phase-6 5223/6150 and both under warn); only the aggregate needed room. Total bumped 52700 -> 52750 for the Phase 0 Step 3 branch-persistence correction: the step wrote the legacy `projects[].branches` while the TTL filter two sections below read `global.recentBranches`, and both spots named a `{name, lastUsed}` shape the schema rejects (`branch` required, `additionalProperties: false`), so the recent-branch picker option could never populate and a literal implementation would have failed prefs validation. Naming the right target, the right key and the legacy field to avoid costs 41 tokens over the one line it replaces. Compression came first and was applied three times to the replacement text itself, from 120 tokens down to 66, by moving the rationale out of the phase doc entirely: the reasoning now lives where it is enforced, in the migrate-prefs carry-forward comment and the smoke-pref-migration f7 block, leaving the phase doc with only the instruction. 50 was the smallest step that clears it; phase-0-init sits at 10893/12400, far under its own max, so this is purely an aggregate ceiling. v15.0.0: total 52750 -> 53100, the stack-skill tables in phase-1/2/4 now carry plugin-namespaced names (ai-<stack>-toolkit:<skill>) - functional prefixes, ~170 tokens. v15.10.0: total 53350 -> 53950 for the memory-recall + context-offload contracts (Phase 1 two-block durable-knowledge injection and its telemetry, Phase 3 build-log offload pipe, Phase 4 ranked prior art, offload pipe and recall telemetry). Compression came first and twice, taking the new prose from 1168 tokens to 580: the reasoning behind the two blocks lives in multi-agent-refs/prompt-assembly.md and the reasoning behind the offload filter lives in the offload-ref.sh header, both outside this budget, so the phase docs carry only the call, the pref that gates it and the one fact an agent cannot infer - that the evidence gate still reads the whole build log, so offloading changes what is read, never what counts as a verified pass. Every per-phase max still passes (phase-3 7985/8950, phase-4 12997/14750); phase-3 and phase-4 crossed their warn lines and are left amber on purpose, because that is the signal that those two docs are the next ones needing structural compression rather than another bump."
39
+ "total_max_tokens": 54400,
40
+ "note": "Token estimate = ceil(chars / 4). Per-phase budget rule: warn = current+10% (rounded to nearest 50), max = current+25%. Gives ~6 edit cycles of headroom before warn trips - intentionally quiet under normal maintenance, loud when a phase grows unusually. Only the active phase is loaded (lazy). Recalibrated at v10.0.0 after the validator/consistency/simplifier/lesson gate contracts landed in phases 1-4. Recalibrated again at v10.9.0 after the verify-by-test (Phase 4 Step 3.7), update-check (Phase 0 Step 0.6), immutable-test (Phase 3 GREEN) and redTests re-entry contracts landed - Step 3.7 prose was compressed to a pointer into refs/features/verify-by-test.md before the recalibration. Total bumped 50000 -> 51000 at v12.5.0 after the worktree residue/traversal-prune contract (Phase 0 + Phase 5 heal) and the Reflexion causal-diagnosis contract (Phase 4 lesson memory) landed; the prose was compressed first (161 tokens reclaimed) and every per-phase max still passes - only the aggregate needed room. Recalibrated again at v13.6.0 after the install-relative path correction: an instruction that names `pipeline/scripts/x` resolves only from a repo checkout, and a run happens in the user's worktree, so 157 references across these docs moved to `$HOME/.claude/...` at +5 bytes each - 196 tokens of pure correctness cost. Same discipline as before: prose was compressed FIRST (149 tokens reclaimed, by pointing Phase 1's Figma tier table at the Phase 0 probe that already resolved it and Phase 4's Codex constraints at the always-loaded AGENTS.md block), and only then were the budgets moved. Five warn lines had been permanently amber, which makes the amber tier useless as a signal, so every warn was reset to the documented current+10% and the four maxes that the new warn would have collided with were reset to current+25%. Aggregate 51000 -> 51500. Total bumped 51500 -> 52200 at v14.0.0 after Phase 4 Review entered the four --dev mode phase sets and the criteria-resolution contract (Step 1.78) landed. Same discipline as every prior bump: prose was compressed FIRST, 820 tokens reclaimed, before the number moved. Two of those compressions are structural rather than cosmetic - the hardcoded SwiftUI interaction list in Step 1.5 and the SwiftUI convention paragraph in Step 2.8 were transcriptions of rules that now live in a scoped registry, so keeping them here would have re-created the drift this release exists to remove, and the third moved the Step 1.78 full contract into refs/features/skill-conformance.md leaving a pointer. What remains is contract text that cannot be inferred: the manifest's four consumer-visible parts, the conformance checklist the reviewers must return, and the fail-closed semantics. Every per-phase max still passes (phase-4 12405/14750); only the aggregate needed room. Total bumped 52200 -> 52700 at v14.1.0 after two more contracts landed: stack skill routing (Phase 3 pre-flight step 9) and worktree finalize (Phase 6 step 9). Compression came first, as always, and twice: 224 tokens out of Phase 3 by pointing its criteria-ledger and routing steps at their feature files instead of restating them, and 190 out of Phase 6 by moving the finalize contract into refs/features/worktree-finalize.md and leaving the invocation plus the exit-3 semantics. Both new contracts follow the pattern the earlier ones set: the phase doc carries the call and the decision, the feature file carries the reasoning, and the feature files are outside this budget because it loops only the eight phase-N-* keys. Every per-phase max still passes (phase-3 7677/8950, phase-6 5223/6150 and both under warn); only the aggregate needed room. Total bumped 52700 -> 52750 for the Phase 0 Step 3 branch-persistence correction: the step wrote the legacy `projects[].branches` while the TTL filter two sections below read `global.recentBranches`, and both spots named a `{name, lastUsed}` shape the schema rejects (`branch` required, `additionalProperties: false`), so the recent-branch picker option could never populate and a literal implementation would have failed prefs validation. Naming the right target, the right key and the legacy field to avoid costs 41 tokens over the one line it replaces. Compression came first and was applied three times to the replacement text itself, from 120 tokens down to 66, by moving the rationale out of the phase doc entirely: the reasoning now lives where it is enforced, in the migrate-prefs carry-forward comment and the smoke-pref-migration f7 block, leaving the phase doc with only the instruction. 50 was the smallest step that clears it; phase-0-init sits at 10893/12400, far under its own max, so this is purely an aggregate ceiling. v15.0.0: total 52750 -> 53100, the stack-skill tables in phase-1/2/4 now carry plugin-namespaced names (ai-<stack>-toolkit:<skill>) - functional prefixes, ~170 tokens. v15.10.0: total 53350 -> 53950 for the memory-recall + context-offload contracts (Phase 1 two-block durable-knowledge injection and its telemetry, Phase 3 build-log offload pipe, Phase 4 ranked prior art, offload pipe and recall telemetry). Compression came first and twice, taking the new prose from 1168 tokens to 580: the reasoning behind the two blocks lives in multi-agent-refs/prompt-assembly.md and the reasoning behind the offload filter lives in the offload-ref.sh header, both outside this budget, so the phase docs carry only the call, the pref that gates it and the one fact an agent cannot infer - that the evidence gate still reads the whole build log, so offloading changes what is read, never what counts as a verified pass. Every per-phase max still passes (phase-3 7985/8950, phase-4 12997/14750); phase-3 and phase-4 crossed their warn lines and are left amber on purpose, because that is the signal that those two docs are the next ones needing structural compression rather than another bump. v15.13.0: total 53950 -> 54050 for the prefs-to-flag bridges. Five settings had shipped declared-but-inert: contextOffload.minLines and .tailLines (fixed in 15.11.0), learningsLedger.maxBriefEntries, and testGap.scanTree and .promoteSeverity - the last two declared in the schema AND implemented as flags in the scanner, with nothing in between reading the pref and passing the flag. Wiring three of them costs the phase docs 94 tokens, which is the wiring itself and not prose: two `--max` substitutions and a three-line GAP_FLAGS block. Compression came first and twice, as always: the rationale that would have sat in phase-5 now lives in the header of smoke-prefs-consumed.sh, the gate that makes this class fail a build instead of shipping, and a `--severity-promote` table row was dropped because the invocation above it now shows the flag and names the pref that triggers it, which the row did not. 100 was the smallest step that clears it. Every per-phase max still passes; phase-3 and phase-4 remain amber on purpose. v15.14.0: total 54050 -> 54400 for the supported-version gate. Phase 0 Step 0.6 stopped being purely advisory: a release can now publish an npm dist-tag `required` that names the oldest runnable version, and below it the run halts instead of nagging. What the phase doc has to carry is the part an agent cannot infer - the third stdout field, that the halt is identical in autopilot, and that the run must NOT continue on the freshly updated install because its docs were already loaded from the old version. Compression came first, as always, and took the new prose from 469 tokens to 337: the rationale for the floor, the exemption list, the fail-open rules and the `npm dist-tag add` recipe all moved to multi-agent-refs/rules.md \"Supported Version Gate\" (loaded by 25 commands, outside this budget) and to the header of require-supported-version.sh, leaving the phase doc with the call, the decision table and the halt. 350 was the smallest step that clears it. Every per-phase max still passes (phase-0-init 11230/12400); phase-3 and phase-4 remain amber on purpose."
41
41
  }
@@ -24,7 +24,8 @@ Validate contracts. Each emits `══ <name> smoke: N passed, M failed ══`
24
24
  - `smoke-phase4-triage.sh` - Phase 4 reviewer → triage flow
25
25
  - `smoke-verify-by-test.sh` - Phase 4 Step 3.7 verify-by-test contract (v10.8.0)
26
26
  - `smoke-handoff-contract.sh` - phase-boundary structured handoff + handoff-first resume (v10.8.0)
27
- - `smoke-update-check.sh` - Phase 0 Step 0.6 advisory update-check contract (v10.9.0)
27
+ - `smoke-update-check.sh` - Phase 0 Step 0.6 update-check + required-floor contract (v10.9.0, floor v15.14.0)
28
+ - `smoke-context-links.sh` - context-link-extractor classification contract, all types (v15.14.0)
28
29
 
29
30
  ### Schema + state
30
31
  - `smoke-schema-validation.sh` - all JSON schemas validate
@@ -80,6 +81,8 @@ Shell scripts invoked during pipeline execution.
80
81
  - `scan-skills.sh` - backs `/multi-agent:scan`
81
82
  - `benchmark-phase-0.sh` - Phase 0 perf harness
82
83
  - `github-ssh-setup.sh` - first-run SSH key setup helper
84
+ - `update-check.sh` - cached registry read (`dist-tags.latest` + `dist-tags.required`); always exit 0
85
+ - `require-supported-version.sh` - turns the `force` signal into exit 3; fails open offline
83
86
  - `sync-parity-check.sh` - manual parity verification helper
84
87
 
85
88
  ## Node.js helpers (54 `.mjs` files)
@@ -0,0 +1,70 @@
1
+ #!/usr/bin/env bash
2
+ # require-supported-version.sh - turn the update-check "force" signal into an
3
+ # exit code (Phase 0 Step 0.6, pipeline-wide supported-version gate).
4
+ #
5
+ # update-check.sh stays advisory by contract: it always exits 0 and the caller
6
+ # decides. This wrapper is that decision for shell callers. It runs the same
7
+ # cached check (one shared cache file, so it costs nothing extra) and:
8
+ #
9
+ # exit 0 - the run may proceed (current, merely-behind, offline, or the
10
+ # registry has no `required` floor)
11
+ # exit 3 - the installed version is BELOW dist-tags.required; the caller
12
+ # must halt and tell the user to run /multi-agent:update
13
+ #
14
+ # Fail-open is deliberate and load-bearing. A floor is only ever enforced from a
15
+ # registry answer we actually received: offline, a blocked registry, a missing
16
+ # `required` tag or an undeterminable local version all exit 0. A version gate
17
+ # that bricks the pipeline on a flaky network is worse than the drift it guards.
18
+ #
19
+ # Emergency override: MULTI_AGENT_ALLOW_OUTDATED=1 exits 0 with a loud stderr
20
+ # warning. It exists so a broken release cannot strand someone mid-incident; it
21
+ # is not a supported way to stay behind, and the caller logs that it was used.
22
+ #
23
+ # `prefs.global.updateCheck.enabled: false` does NOT disable this gate - that
24
+ # switch silences the advisory nag. A floor is only published for a release that
25
+ # changes a contract, and opting out of it would only fail later and less
26
+ # legibly.
27
+ #
28
+ # stdout on exit 3 (one machine-readable line, then a human block on stderr):
29
+ # force|<local>|<latest>|<required>
30
+ #
31
+ # Usage:
32
+ # bash pipeline/scripts/require-supported-version.sh || halt
33
+ # bash pipeline/scripts/require-supported-version.sh --local 15.0.0
34
+
35
+ set -uo pipefail
36
+
37
+ HERE="$(cd "$(dirname "$0")" && pwd)"
38
+ CHECK="$HERE/update-check.sh"
39
+ [ -f "$CHECK" ] || CHECK="$HOME/.claude/scripts/update-check.sh"
40
+ [ -f "$CHECK" ] || exit 0 # no checker installed -> nothing to enforce
41
+
42
+ out=$(bash "$CHECK" "$@" 2>/dev/null) || exit 0
43
+ case "$out" in
44
+ *"|force") ;;
45
+ *) exit 0 ;;
46
+ esac
47
+
48
+ local_v="${out%%|*}"
49
+ rest="${out#*|}"
50
+ latest_v="${rest%%|*}"
51
+ # Second call is served from the same fresh cache the first one just used.
52
+ required_v=$(bash "$CHECK" --print-required "$@" 2>/dev/null | tr -d '[:space:]')
53
+ [ -n "$required_v" ] || required_v="$latest_v"
54
+
55
+ if [ "${MULTI_AGENT_ALLOW_OUTDATED:-}" = "1" ]; then
56
+ printf 'WARNING: MULTI_AGENT_ALLOW_OUTDATED=1 - running v%s below the required floor. Update as soon as this run ends.\n' \
57
+ "$local_v" >&2
58
+ exit 0
59
+ fi
60
+
61
+ printf 'force|%s|%s|%s\n' "$local_v" "$latest_v" "$required_v"
62
+ cat >&2 <<MSG
63
+
64
+ This release line is required, not optional.
65
+ Installed: v${local_v} Required: v${required_v} or newer Latest: v${latest_v}
66
+
67
+ Run /multi-agent:update, then start this command again.
68
+
69
+ MSG
70
+ exit 3
@@ -1,21 +1,38 @@
1
1
  #!/usr/bin/env bash
2
- # update-check.sh - cached advisory version check (Phase 0 Step 0.6)
2
+ # update-check.sh - cached version check (Phase 0 Step 0.6)
3
3
  #
4
4
  # Compares the locally installed pipeline version against the npm registry's
5
- # dist-tags.latest. Cached with a TTL so at most one network call per TTL
6
- # window; the call is bounded by a short timeout and every failure path is
7
- # silent - this script NEVER blocks or fails the pipeline.
5
+ # dist-tags. Cached with a TTL so at most one network call per TTL window; the
6
+ # call is bounded by a short timeout and every failure path is silent - this
7
+ # script NEVER blocks and NEVER fails the pipeline (exit code is always 0).
8
8
  #
9
- # stdout: "<local>|<latest>" when a newer version exists, nothing otherwise.
10
- # Exit code: always 0 (advisory contract).
9
+ # Two tags are read:
10
+ # dist-tags.latest newest published release -> advisory "update available"
11
+ # dist-tags.required minimum version a user may run -> forced-update signal
12
+ #
13
+ # `required` is opt-in per release and set out of band, without republishing:
14
+ # npm dist-tag add @<scope>/multi-agent-pipeline@<version> required
15
+ # Absent tag = no floor = advisory-only behaviour, exactly as before.
16
+ #
17
+ # stdout:
18
+ # "" already current (or local ahead of the registry)
19
+ # "<local>|<latest>" a newer version exists - advisory
20
+ # "<local>|<latest>|force" local is BELOW dist-tags.required - the caller
21
+ # must halt the run until /multi-agent:update ran
22
+ # Exit code: always 0. Enforcement is the caller's job - see
23
+ # pipeline/scripts/require-supported-version.sh, which turns "force" into a
24
+ # non-zero exit for shell callers.
11
25
  #
12
26
  # Usage:
13
27
  # bash pipeline/scripts/update-check.sh # auto-detect local version
14
28
  # bash pipeline/scripts/update-check.sh --local 10.8.0 # explicit local version
15
29
  # bash pipeline/scripts/update-check.sh --ttl-hours 24 # cache window (default 24)
16
30
  # bash pipeline/scripts/update-check.sh --force # ignore cache
31
+ # bash pipeline/scripts/update-check.sh --print-required # emit the floor only
17
32
  #
18
- # Cache file: ~/.claude/logs/multi-agent/.update-check ("epoch|latest").
33
+ # Cache file: ~/.claude/logs/multi-agent/.update-check ("epoch|latest|required").
34
+ # A legacy two-field cache ("epoch|latest") still reads - the missing third
35
+ # field means "no floor known", never "no floor exists".
19
36
  # Registry read is a plain curl - never `npm view` (a user-level .npmrc scope
20
37
  # mapping can silently reroute npm to a different registry; curl cannot lie).
21
38
 
@@ -27,12 +44,14 @@ CACHE_FILE="${UPDATE_CHECK_CACHE:-$HOME/.claude/logs/multi-agent/.update-check}"
27
44
  TTL_HOURS=24
28
45
  LOCAL_VERSION=""
29
46
  FORCE=0
47
+ PRINT_REQUIRED=0
30
48
 
31
49
  while [ $# -gt 0 ]; do
32
50
  case "$1" in
33
51
  --local) LOCAL_VERSION="${2:-}"; shift 2 ;;
34
52
  --ttl-hours) TTL_HOURS="${2:-24}"; shift 2 ;;
35
53
  --force) FORCE=1; shift ;;
54
+ --print-required) PRINT_REQUIRED=1; shift ;;
36
55
  *) shift ;;
37
56
  esac
38
57
  done
@@ -58,35 +77,67 @@ if [ -z "$LOCAL_VERSION" ]; then
58
77
  fi
59
78
  [ -z "$LOCAL_VERSION" ] && exit 0 # cannot determine local version -> silent no-op
60
79
 
80
+ # Highest of two dotted versions; ties resolve to the second argument.
81
+ highest_of() {
82
+ printf '%s\n%s\n' "$1" "$2" | sort -t. -k1,1n -k2,2n -k3,3n | tail -1
83
+ }
84
+
61
85
  now=$(date +%s)
62
86
  latest=""
87
+ required=""
63
88
 
64
89
  # Fresh cache?
65
90
  if [ "$FORCE" -eq 0 ] && [ -f "$CACHE_FILE" ]; then
66
91
  cached_epoch=$(cut -d'|' -f1 "$CACHE_FILE" 2>/dev/null || echo 0)
67
92
  cached_latest=$(cut -d'|' -f2 "$CACHE_FILE" 2>/dev/null || echo "")
93
+ cached_required=$(cut -d'|' -f3 "$CACHE_FILE" 2>/dev/null || echo "")
68
94
  case "$cached_epoch" in (*[!0-9]*|"") cached_epoch=0 ;; esac
69
95
  if [ $((now - cached_epoch)) -lt $((TTL_HOURS * 3600)) ] && [ -n "$cached_latest" ]; then
70
96
  latest="$cached_latest"
97
+ required="$cached_required"
71
98
  fi
72
99
  fi
73
100
 
74
101
  # Stale or missing cache -> one bounded registry call (silent on any failure).
102
+ # The abbreviated packument carries dist-tags at a quarter of the full document.
75
103
  if [ -z "$latest" ]; then
76
- latest=$(curl -sm 3 "$REGISTRY_URL" 2>/dev/null \
77
- | { command -v jq >/dev/null 2>&1 && jq -r '."dist-tags".latest // empty' \
78
- || sed -n 's/.*"latest":"\([^"]*\)".*/\1/p'; } | head -1) || true
104
+ tags=$(curl -sm 3 -H 'Accept: application/vnd.npm.install-v1+json' "$REGISTRY_URL" 2>/dev/null \
105
+ | { if command -v jq >/dev/null 2>&1; then
106
+ jq -r '[."dist-tags".latest // "", ."dist-tags".required // ""] | join("|")'
107
+ else
108
+ node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{try{const t=JSON.parse(s)["dist-tags"]||{};process.stdout.write(`${t.latest||""}|${t.required||""}`)}catch{}})'
109
+ fi; } ) || true
110
+ latest="${tags%%|*}"
111
+ required="${tags#*|}"
112
+ [ "$required" = "$tags" ] && required=""
79
113
  [ -z "$latest" ] && exit 0
80
114
  mkdir -p "$(dirname "$CACHE_FILE")" 2>/dev/null || exit 0
81
- printf '%s|%s\n' "$now" "$latest" > "$CACHE_FILE" 2>/dev/null || true
115
+ printf '%s|%s|%s\n' "$now" "$latest" "$required" > "$CACHE_FILE" 2>/dev/null || true
116
+ fi
117
+
118
+ # A floor above latest is a publisher mistake (the `required` tag was moved to a
119
+ # version that `latest` no longer covers). Clamp rather than brick every user.
120
+ if [ -n "$required" ] && [ "$(highest_of "$required" "$latest")" != "$latest" ]; then
121
+ required="$latest"
122
+ fi
123
+
124
+ if [ "$PRINT_REQUIRED" -eq 1 ]; then
125
+ printf '%s\n' "$required"
126
+ exit 0
127
+ fi
128
+
129
+ # Forced update: local sorts strictly BELOW the required floor.
130
+ if [ -n "$required" ] && [ "$LOCAL_VERSION" != "$required" ] \
131
+ && [ "$(highest_of "$LOCAL_VERSION" "$required")" = "$required" ]; then
132
+ printf '%s|%s|force\n' "$LOCAL_VERSION" "$latest"
133
+ exit 0
82
134
  fi
83
135
 
84
136
  [ "$latest" = "$LOCAL_VERSION" ] && exit 0
85
137
 
86
138
  # Update available only when latest sorts strictly ABOVE local (a dev machine
87
139
  # running ahead of the registry must not see an "update" prompt).
88
- highest=$(printf '%s\n%s\n' "$LOCAL_VERSION" "$latest" | sort -t. -k1,1n -k2,2n -k3,3n | tail -1)
89
- if [ "$highest" = "$latest" ]; then
140
+ if [ "$(highest_of "$LOCAL_VERSION" "$latest")" = "$latest" ]; then
90
141
  printf '%s|%s\n' "$LOCAL_VERSION" "$latest"
91
142
  fi
92
143
  exit 0