@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 +32 -0
- package/docs/features.md +5 -1
- package/package.json +1 -1
- package/pipeline/commands/multi-agent/sync/SKILL.md +2 -9
- package/pipeline/commands/multi-agent/update/SKILL.md +9 -1
- package/pipeline/lib/context-link-extractor.sh +55 -7
- package/pipeline/lib/fetch-crashlytics.sh +171 -61
- package/pipeline/lib/fetch-fortify.sh +58 -5
- package/pipeline/multi-agent-refs/features/url-enrichment.md +9 -5
- package/pipeline/multi-agent-refs/phases/phase-0-init.md +17 -3
- package/pipeline/multi-agent-refs/phases/phase-1-analysis.md +1 -1
- package/pipeline/multi-agent-refs/phases/phase-4-review.md +2 -2
- package/pipeline/multi-agent-refs/phases/phase-5-test.md +4 -2
- package/pipeline/multi-agent-refs/rules.md +11 -0
- package/pipeline/schemas/prefs.schema.json +20 -2
- package/pipeline/schemas/token-budget.json +2 -2
- package/pipeline/scripts/README.md +4 -1
- package/pipeline/scripts/require-supported-version.sh +70 -0
- package/pipeline/scripts/update-check.sh +64 -13
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
|
|
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.
|
|
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
|
-
|
|
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
|
|
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": "
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
if
|
|
161
|
-
|
|
162
|
-
|
|
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": "...", "
|
|
23
|
-
# "
|
|
24
|
-
#
|
|
25
|
-
#
|
|
26
|
-
# "
|
|
27
|
-
# "
|
|
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": "
|
|
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
|
-
#
|
|
43
|
-
#
|
|
44
|
-
#
|
|
45
|
-
#
|
|
46
|
-
#
|
|
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
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
#
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
"
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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.
|
|
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":
|
|
437
|
+
"issueId": issue_id,
|
|
333
438
|
"sessionId": os.environ.get("SRC_SESSION") or None,
|
|
334
439
|
},
|
|
335
440
|
"issue": {
|
|
336
|
-
"title":
|
|
337
|
-
"subtitle":
|
|
338
|
-
"type":
|
|
339
|
-
"
|
|
340
|
-
"
|
|
341
|
-
"
|
|
342
|
-
"
|
|
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":
|
|
345
|
-
"line":
|
|
346
|
-
"symbol":
|
|
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
|
-
"
|
|
349
|
-
"
|
|
350
|
-
"
|
|
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
|
-
"
|
|
355
|
-
|
|
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
|
-
#
|
|
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
|
|
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
|
-
|
|
113
|
-
|
|
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>", "
|
|
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
|
-
-
|
|
51
|
-
-
|
|
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
|
-
|
|
58
|
+
Two entry shapes reach this step, and `metadata.source` says which:
|
|
58
59
|
|
|
59
|
-
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
|
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":
|
|
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
|
|
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
|
|
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.
|
|
6
|
-
#
|
|
7
|
-
#
|
|
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
|
-
#
|
|
10
|
-
#
|
|
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
|
-
|
|
77
|
-
| { command -v jq >/dev/null 2>&1
|
|
78
|
-
|
|
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
|
-
|
|
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
|