yadflow 3.14.0 → 3.15.1

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
@@ -1,3 +1,19 @@
1
+ ## [3.15.1](https://github.com/abdelrahmannasr/yadflow/compare/v3.15.0...v3.15.1) (2026-08-11)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * **checks:** close the silent-PASS holes the [#161](https://github.com/abdelrahmannasr/yadflow/issues/161) fix left open ([0f180ab](https://github.com/abdelrahmannasr/yadflow/commit/0f180abc72618fcd793ef6717fe1e128aa5ccfa5))
7
+ * **checks:** pin every changed contract slice, not just the first ([a79a946](https://github.com/abdelrahmannasr/yadflow/commit/a79a946aa37c08615741082bc638d006f7e80b76)), closes [#161](https://github.com/abdelrahmannasr/yadflow/issues/161)
8
+ * **checks:** read hub.json and the contract lock across line breaks ([43a618d](https://github.com/abdelrahmannasr/yadflow/commit/43a618d0822ed8576b9a164889bc53a52bbdf713)), closes [#161](https://github.com/abdelrahmannasr/yadflow/issues/161)
9
+
10
+ # [3.15.0](https://github.com/abdelrahmannasr/yadflow/compare/v3.14.0...v3.15.0) (2026-08-10)
11
+
12
+
13
+ ### Features
14
+
15
+ * **testing:** add maestro as a testing-tool adapter ([7718b50](https://github.com/abdelrahmannasr/yadflow/commit/7718b502814ee8e0eb46f5e6f20342fc2471dbd7))
16
+
1
17
  # [3.14.0](https://github.com/abdelrahmannasr/yadflow/compare/v3.13.2...v3.14.0) (2026-08-10)
2
18
 
3
19
 
package/cli/manifest.mjs CHANGED
@@ -135,7 +135,7 @@ export const DESIGN_PRIMARY = 'figma';
135
135
  // is the fallback `registerTesting`/setup use when an unknown tool is named, and `none` is the explicit
136
136
  // artifacts-only choice. (doctor does NOT fall back — an unknown tool there is a hard YAD-CFG-003 fail,
137
137
  // mirroring the design-tool YAD-CFG-002.)
138
- export const TESTING_TOOLS = ['playwright', 'cypress', 'pytest'];
138
+ export const TESTING_TOOLS = ['playwright', 'cypress', 'pytest', 'maestro'];
139
139
  export const TESTING_PRIMARY = 'playwright';
140
140
 
141
141
  // Supported learning-tool adapters (mirrors skills/sdlc/config.yaml `learning.tools`); `LEARNING_PRIMARY`
package/cli/setup.mjs CHANGED
@@ -590,9 +590,12 @@ export async function runSetup(root, opts = {}) {
590
590
  }
591
591
 
592
592
  // Connect a testing tool (Playwright-first, pluggable; the test-cases step implements automation here)
593
- S('Connect a testing tool (playwright / cypress / pytest / none)');
593
+ // Banner + guide read TESTING_TOOLS rather than spelling the adapters out: this is the list that
594
+ // actually grows (maestro joined it), and a hardcoded copy here would quietly offer the user fewer
595
+ // tools than the prompt below accepts.
596
+ S(`Connect a testing tool (${TESTING_TOOLS.join(' / ')} / none)`);
594
597
  guide([
595
- 'Where yad-test-cases generates automation. playwright/cypress/pytest, or none for artifacts-only.',
598
+ `Where yad-test-cases generates automation. ${TESTING_TOOLS.join('/')}, or none for artifacts-only.`,
596
599
  'Skipping is safe — test-cases authors test-cases.md only.',
597
600
  ]);
598
601
  if (exists(testingPath) && !(await askYesNo('testing.json exists — reconfigure?', false))) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "yadflow",
3
- "version": "3.14.0",
3
+ "version": "3.15.1",
4
4
  "description": "Yadflow — the gated, team, multi-repo SDLC: author → review → build with a PR-driven review gate and a zero-dependency `yad` CLI (setup, gate, commit, open-pr, ship, repo, thread, reconcile). A BMAD module + 38 yad-* skills.",
5
5
  "type": "module",
6
6
  "author": "AbdelRahman Nasr",
@@ -198,7 +198,7 @@ design:
198
198
  # (test-links.json) is written by yad-test-cases per epic.
199
199
  testing:
200
200
  registry: "{project-root}/.sdlc/testing.json" # project-wide testing connection (NOT per-epic)
201
- tools: [playwright, cypress, pytest] # supported adapters; an unknown tool falls back to `primary`
201
+ tools: [playwright, cypress, pytest, maestro] # supported adapters; an unknown tool falls back to `primary`
202
202
  primary: playwright # the default/named provider
203
203
  degrade: artifacts-only # no tool / no MCP => yad-test-cases authors test-cases.md only
204
204
  links: "{project-root}/epics/EP-<slug>/.sdlc/test-links.json" # per-epic case->test map (yad-test-cases)
@@ -72,8 +72,9 @@ the approver(s) + date. Only a `verified: true` backfill spec counts as real.
72
72
 
73
73
  ### Step 5 — `gate` (block changes per touched feature)
74
74
  `bash checks/backfill-check.sh <base>` blocks a change that touches a feature being backfilled until
75
- that feature's spec is `verified: true`. It is **per touched feature** a change touching feature A is
76
- not blocked by an unverified feature B. Forward-spec'd features (those with their own `specs/<story>/`)
75
+ that feature's spec is `verified: true`. `<base>` is optional and resolves like every other gate's
76
+ see "Resolving `<base>`" in `../yad-checks/references/check-gates.md`. It is **per touched feature**
77
+ a change touching feature A is not blocked by an unverified feature B. Forward-spec'd features (those with their own `specs/<story>/`)
77
78
  are not this gate's concern.
78
79
 
79
80
  ### Step 6 — `promote` (flip a stub epic → real, once its spec is approved)
@@ -5,13 +5,37 @@
5
5
  # forward-spec'd (their own specs/<story>/) or not yet being backfilled are not this gate's concern.
6
6
  set -euo pipefail
7
7
 
8
- BASE="${1:-${SDLC_BASE:-origin/main}}"
8
+ # --- shared base resolution (byte-identical across the gates; they are standalone by design, so it
9
+ # --- is duplicated, not sourced) ---
10
+ # With no explicit base, RESOLVE the trunk instead of assuming a hardcoded `origin/main` — on a repo
11
+ # whose trunk is `develop`/`master` that guess either fails closed or, where a stale `main` still
12
+ # exists, silently diffs the WRONG range (issue #161). Mirrors the CLI's own order (cli/hubcommit.mjs,
13
+ # cli/repo.mjs): the CONFIGURED default_branch first, then the remote's published default
14
+ # (origin/HEAD), then origin/main. Each candidate must actually resolve before it is used, so a
15
+ # DANGLING origin/HEAD (trunk renamed, the old remote-tracking ref pruned) falls through to the next
16
+ # candidate instead of failing the gate on a fully-fetched repo. CI always passes the base explicitly,
17
+ # so this governs local runs only. The `|| _x=""` guards are load-bearing: under `set -e` a failing
18
+ # command substitution in an assignment aborts the script.
19
+ resolve_base() {
20
+ # tr first: a key and its value may legally sit on separate lines, which a per-line match misses.
21
+ _cfg="$(tr -d '\n' < "${SDLC_HUB_CONFIG:-.sdlc/hub.json}" 2>/dev/null | sed -nE 's/.*"default_branch"[[:space:]]*:[[:space:]]*"([^"]*)".*/\1/p')" || _cfg=""
22
+ _head="$(git symbolic-ref --short --quiet refs/remotes/origin/HEAD 2>/dev/null)" || _head=""
23
+ for _c in "origin/${_cfg}" "${_head}" origin/main; do
24
+ case "$_c" in ''|origin/) continue ;; esac
25
+ if git rev-parse --verify --quiet "${_c}^{commit}" >/dev/null 2>&1; then printf '%s' "$_c"; return; fi
26
+ done
27
+ printf '%s' origin/main
28
+ }
29
+
30
+ BASE="${1:-${SDLC_BASE:-$(resolve_base)}}"
31
+ [ -n "${1:-}" ] || [ -n "${SDLC_BASE:-}" ] || echo "note [backfill]: no base given — diffing against '${BASE}'."
9
32
  if ! git rev-parse --verify --quiet "${BASE}^{commit}" >/dev/null; then
10
33
  echo "FAIL [backfill]: base ref '${BASE}' not found — fetch full history / check the base branch."
11
34
  exit 1
12
35
  fi
13
36
 
14
- changed="$(git diff --name-only "${BASE}..HEAD")"
37
+ # quotePath off so a non-ASCII feature directory still matches src/<feature>/ below.
38
+ changed="$(git -c core.quotePath=false diff --name-only "${BASE}..HEAD")"
15
39
  # Feature = a directory under src/ (src/<feature>/...). Top-level src/*.js files are deliberately NOT
16
40
  # gated here (they belong to no single feature); only src/<feature>/ changes are checked.
17
41
  feats="$(printf '%s\n' "$changed" | sed -nE 's#^src/([^/]+)/.*#\1#p' | sort -u)"
@@ -15,6 +15,7 @@ in CI on every PR/MR and must pass before merge (build plan §C). Each is a smal
15
15
  `specs/<story>/contracts/`) without a `Contract-Change: yes` trailer **and** an updated, re-locked
16
16
  contract upstream, it **FAILS and routes back to the architecture gate**. The shared surface is
17
17
  never widened from inside a code repo (Phase 2 contract representation: delimited block + SHA-256 lock).
18
+ Every story whose slice the diff touches is checked, not just the first — see `references/check-gates.md`.
18
19
  3. **build/test/lint** — standard quality stage; tests must actually exercise new behavior, not just pass.
19
20
  The CI job sets `YAD_TEST_MAX_WORKERS` (default `2`); the gate caps jest/vitest test concurrency at
20
21
  that and is a no-op for other runners (see `references/check-gates.md`).
@@ -141,6 +142,10 @@ bash checks/spec-link.sh "<base>"
141
142
  bash checks/contract-check.sh "<base>"
142
143
  bash checks/build-test-lint.sh
143
144
  ```
145
+ `<base>` is optional for the gates that take one (`build-test-lint` takes none) — omitted, the gate
146
+ resolves the trunk (configured `default_branch`, else `origin/HEAD`, else `origin/main`) and prints
147
+ the base it chose; pass it (or `SDLC_BASE`) when the PR/MR targets another branch.
148
+
144
149
  A non-zero exit is a FAIL. Summarize which gates passed and, for any failure, the exact remediation
145
150
  (spec-link: add the `Task:` trailer / spec; contract-check: route back to the architecture gate and
146
151
  re-lock the contract; build/test/lint: fix the failing lint/test).
@@ -19,6 +19,27 @@ repo uses. Each reads conventions established by earlier steps — it invents no
19
19
  | pr-title | the PR/MR title (from the CI event payload) | `yad-pr-template` (`config.yaml build.pr_title_style`) |
20
20
  | pr-template | the PR/MR body (from the CI event payload) | `yad-pr-template` (the committed PR/MR template) |
21
21
 
22
+ ## Resolving `<base>` (every gate that takes one)
23
+
24
+ The `<base>` argument is **optional**. The order is: the **argument**, else `SDLC_BASE`, else the
25
+ **configured** `default_branch` (`.sdlc/hub.json`, or `SDLC_HUB_CONFIG`), else the remote's
26
+ **published default branch** (`git symbolic-ref refs/remotes/origin/HEAD`), else `origin/main` —
27
+ the same order the CLI resolves (`cli/hubcommit.mjs`, `cli/repo.mjs`), so a gate never diffs a
28
+ different range than the `yad` commands run beside it. Each candidate must actually **resolve**
29
+ before it is used, so a *dangling* `origin/HEAD` (trunk renamed, the old remote-tracking ref pruned)
30
+ falls through instead of failing the gate on a fully-fetched repo. CI always passes the base
31
+ explicitly (`origin/<PR base>` / `origin/$CI_MERGE_REQUEST_TARGET_BRANCH_NAME`), so this governs
32
+ local runs.
33
+
34
+ Hardcoding `origin/main` diffed the wrong range on a repo whose trunk is `develop`/`master` — either
35
+ failing closed for the wrong reason, or, where a stale `main` still existed, silently diffing a range
36
+ that both mis-reports the surface and drags unrelated stories into contract-check's per-story fidelity
37
+ pass (issue #161). An auto-resolved base is **printed as a note**, so the range a local run gated is
38
+ never implicit. Like the `product-repo` block below, this one is duplicated verbatim across the
39
+ scripts (they are standalone by design) and pinned byte-identical by a test — which covers every
40
+ base-taking gate, including `yad-backfill`'s `backfill-check.sh` and the installed copies this repo's
41
+ own CI runs, plus an assertion that each one actually *assigns* `BASE` from it.
42
+
22
43
  ## 1. spec-link (`templates/checks/spec-link.sh`)
23
44
 
24
45
  - Checks every non-merge commit in `<base>..HEAD` **per commit** (not aggregated across the range),
@@ -38,11 +59,13 @@ repo uses. Each reads conventions established by earlier steps — it invents no
38
59
  - An empty range (no non-merge commits) **PASSes**.
39
60
  - Portable across bash 3.2 (macOS) and 4+ (no `mapfile`).
40
61
  - **Fails closed** when `<base>` can't be resolved (so a shallow clone / wrong base never PASSes blind).
62
+ `<base>` is optional — see [Resolving `<base>`](#resolving-base-every-gate-that-takes-one).
41
63
 
42
64
  ## 2. contract-check (`templates/checks/contract-check.sh`)
43
65
 
44
66
  - **Fails closed** if `<base>` can't be resolved — an undiffable range must never report "no surface
45
- change" and silently green-light a bypass.
67
+ change" and silently green-light a bypass. `<base>` is optional — see
68
+ [Resolving `<base>`](#resolving-base-every-gate-that-takes-one).
46
69
  - Computes the changed files in `<base>..HEAD`.
47
70
  - If **nothing** under `specs/*/contracts/**` changed → **PASS** (normal implementation only *consumes*
48
71
  the contract).
@@ -52,6 +75,19 @@ repo uses. Each reads conventions established by earlier steps — it invents no
52
75
  require `link.md`'s pinned `contract-lock` hash to match the product repo's current
53
76
  `contract-lock.json`. A claimed change that still pins the **old** lock **FAILS** — re-run
54
77
  `yad-spec` so the slice matches the re-locked contract.
78
+ - The fidelity check runs for **every story whose slice the diff touches**, and **aggregates**: each
79
+ story reports (matched / stale / deferred), and any stale pin fails the gate. `git diff
80
+ --name-only` is path-sorted, so reading one story off the first changed path validated whichever
81
+ story sorted first and left the rest unpinned — a later story pinning a stale hash passed, and a
82
+ first story with no `link.md` deferred the whole check before the stale one was ever read
83
+ (issue #161). One clean-or-deferred story never masks another's stale pin, the same rule spec-link
84
+ applies per commit.
85
+ - A lock the gate can **read but not parse** (truncated, half-written, or a changed schema — no
86
+ `"hash": "sha256:…"`) **FAILS**. It used to short-circuit the comparison into the "hash matches"
87
+ note, i.e. the gate affirmatively reported a match it never made.
88
+ - The changed-file list is read with `core.quotePath=false`. With git's default, a path holding a
89
+ non-ASCII byte comes back quoted and octal-escaped, so a slice like `specs/EP-démo-S01/contracts/…`
90
+ never matched the surface pattern and an undeclared widening passed untouched.
55
91
  - This enforces the Phase 2 rule: the shared surface is owned upstream and is never widened from inside
56
92
  a code repo. The hash recipe is in `../yad-architecture/references/contract-format.md`.
57
93
 
@@ -129,6 +165,7 @@ non-merge commit in `<base>..HEAD`:
129
165
  - **Profiles** (`--profile code|hub`): the subject rule is identical on both; the gate never requires
130
166
  the `Task:` trailer (spec-link owns that on code repos; hub commits are not task-scoped).
131
167
  - **Fails closed** when `<base>` can't be resolved.
168
+ `<base>` is optional — see [Resolving `<base>`](#resolving-base-every-gate-that-takes-one).
132
169
 
133
170
  ## 6. pr-title (`templates/checks/pr-title.sh`)
134
171
 
@@ -319,9 +356,13 @@ line). Code repos run the same three with `--profile code` inside the main `yad-
319
356
 
320
357
  ## Running by hand (Phase 3 is manual)
321
358
 
322
- From inside the code repo, against the PR/MR base (e.g. `master`):
359
+ From inside the code repo, against the PR/MR base (e.g. `master`). For the gates that take one, the
360
+ base argument is optional — omit it and the gate resolves the trunk in the order above (configured
361
+ `default_branch`, else `origin/HEAD`, else `origin/main`), printing the base it chose; pass it (or
362
+ `export SDLC_BASE=…`) whenever the PR targets something else. `build-test-lint` takes no base at all.
323
363
 
324
364
  ```bash
365
+ bash checks/spec-link.sh # -> diffs the resolved trunk, and says which one
325
366
  bash checks/spec-link.sh master
326
367
  bash checks/contract-check.sh master
327
368
  bash checks/build-test-lint.sh
@@ -25,7 +25,30 @@ while [ $# -gt 0 ]; do
25
25
  done
26
26
  case "$PROFILE" in code|hub) ;; *) echo "FAIL [commit-message]: unknown --profile '$PROFILE' (code|hub)."; exit 1 ;; esac
27
27
 
28
- BASE="${ARGS[0]:-${SDLC_BASE:-origin/main}}"
28
+ # --- shared base resolution (byte-identical across the gates; they are standalone by design, so it
29
+ # --- is duplicated, not sourced) ---
30
+ # With no explicit base, RESOLVE the trunk instead of assuming a hardcoded `origin/main` — on a repo
31
+ # whose trunk is `develop`/`master` that guess either fails closed or, where a stale `main` still
32
+ # exists, silently diffs the WRONG range (issue #161). Mirrors the CLI's own order (cli/hubcommit.mjs,
33
+ # cli/repo.mjs): the CONFIGURED default_branch first, then the remote's published default
34
+ # (origin/HEAD), then origin/main. Each candidate must actually resolve before it is used, so a
35
+ # DANGLING origin/HEAD (trunk renamed, the old remote-tracking ref pruned) falls through to the next
36
+ # candidate instead of failing the gate on a fully-fetched repo. CI always passes the base explicitly,
37
+ # so this governs local runs only. The `|| _x=""` guards are load-bearing: under `set -e` a failing
38
+ # command substitution in an assignment aborts the script.
39
+ resolve_base() {
40
+ # tr first: a key and its value may legally sit on separate lines, which a per-line match misses.
41
+ _cfg="$(tr -d '\n' < "${SDLC_HUB_CONFIG:-.sdlc/hub.json}" 2>/dev/null | sed -nE 's/.*"default_branch"[[:space:]]*:[[:space:]]*"([^"]*)".*/\1/p')" || _cfg=""
42
+ _head="$(git symbolic-ref --short --quiet refs/remotes/origin/HEAD 2>/dev/null)" || _head=""
43
+ for _c in "origin/${_cfg}" "${_head}" origin/main; do
44
+ case "$_c" in ''|origin/) continue ;; esac
45
+ if git rev-parse --verify --quiet "${_c}^{commit}" >/dev/null 2>&1; then printf '%s' "$_c"; return; fi
46
+ done
47
+ printf '%s' origin/main
48
+ }
49
+
50
+ BASE="${ARGS[0]:-${SDLC_BASE:-$(resolve_base)}}"
51
+ [ -n "${ARGS[0]:-}" ] || [ -n "${SDLC_BASE:-}" ] || echo "note [commit-message]: no base given — diffing against '${BASE}'."
29
52
 
30
53
  # Fail closed if the base ref can't be resolved (shallow clone / wrong base branch / unfetched ref).
31
54
  if ! git rev-parse --verify --quiet "${BASE}^{commit}" >/dev/null; then
@@ -8,7 +8,30 @@
8
8
  # architecture gate. Normal implementation that only CONSUMES the contract passes untouched.
9
9
  set -euo pipefail
10
10
 
11
- BASE="${1:-${SDLC_BASE:-origin/main}}"
11
+ # --- shared base resolution (byte-identical across the gates; they are standalone by design, so it
12
+ # --- is duplicated, not sourced) ---
13
+ # With no explicit base, RESOLVE the trunk instead of assuming a hardcoded `origin/main` — on a repo
14
+ # whose trunk is `develop`/`master` that guess either fails closed or, where a stale `main` still
15
+ # exists, silently diffs the WRONG range (issue #161). Mirrors the CLI's own order (cli/hubcommit.mjs,
16
+ # cli/repo.mjs): the CONFIGURED default_branch first, then the remote's published default
17
+ # (origin/HEAD), then origin/main. Each candidate must actually resolve before it is used, so a
18
+ # DANGLING origin/HEAD (trunk renamed, the old remote-tracking ref pruned) falls through to the next
19
+ # candidate instead of failing the gate on a fully-fetched repo. CI always passes the base explicitly,
20
+ # so this governs local runs only. The `|| _x=""` guards are load-bearing: under `set -e` a failing
21
+ # command substitution in an assignment aborts the script.
22
+ resolve_base() {
23
+ # tr first: a key and its value may legally sit on separate lines, which a per-line match misses.
24
+ _cfg="$(tr -d '\n' < "${SDLC_HUB_CONFIG:-.sdlc/hub.json}" 2>/dev/null | sed -nE 's/.*"default_branch"[[:space:]]*:[[:space:]]*"([^"]*)".*/\1/p')" || _cfg=""
25
+ _head="$(git symbolic-ref --short --quiet refs/remotes/origin/HEAD 2>/dev/null)" || _head=""
26
+ for _c in "origin/${_cfg}" "${_head}" origin/main; do
27
+ case "$_c" in ''|origin/) continue ;; esac
28
+ if git rev-parse --verify --quiet "${_c}^{commit}" >/dev/null 2>&1; then printf '%s' "$_c"; return; fi
29
+ done
30
+ printf '%s' origin/main
31
+ }
32
+
33
+ BASE="${1:-${SDLC_BASE:-$(resolve_base)}}"
34
+ [ -n "${1:-}" ] || [ -n "${SDLC_BASE:-}" ] || echo "note [contract-check]: no base given — diffing against '${BASE}'."
12
35
 
13
36
  # Fail CLOSED if the base ref can't be resolved (shallow clone / wrong base branch / unfetched ref).
14
37
  # Never let an undiffable range silently report "no surface change" — that would green-light a bypass.
@@ -18,7 +41,10 @@ if ! git rev-parse --verify --quiet "${BASE}^{commit}" >/dev/null; then
18
41
  fi
19
42
  RANGE="${BASE}..HEAD"
20
43
 
21
- changed="$(git diff --name-only "$RANGE")"
44
+ # core.quotePath=false: with the default ON, git wraps any path holding a non-ASCII byte in quotes
45
+ # and octal-escapes it, so a slice like specs/EP-démo-S01/contracts/api.md never matches the pattern
46
+ # below — the surface change would be invisible to the gate it exists to stop.
47
+ changed="$(git -c core.quotePath=false diff --name-only "$RANGE")"
22
48
  surface="$(printf '%s\n' "$changed" | grep -E '^specs/[^/]+/contracts/' || true)"
23
49
 
24
50
  if [ -z "$surface" ]; then
@@ -72,9 +98,22 @@ resolve_product() {
72
98
 
73
99
  # Fidelity check (best-effort): when the product repo is reachable, the story's link.md must pin the
74
100
  # CURRENT product lock — proof the contract was actually updated/re-locked upstream, not just flagged.
75
- story="$(printf '%s\n' "$surface" | head -1 | sed -E 's#^specs/([^/]+)/contracts/.*#\1#')"
76
- link="specs/${story}/link.md"
77
- if [ -f "$link" ]; then
101
+ #
102
+ # Checked for EVERY story whose slice the diff touches, not just the first one. `git diff --name-only`
103
+ # is path-sorted, so reading a single story off `head -1` validated whichever story sorted first and
104
+ # left the rest unpinned: a second story pinning a STALE hash passed, and a first story with no
105
+ # link.md deferred the whole check before the stale one was ever read (issue #161). Failures are
106
+ # AGGREGATED — every story reports, so one clean-or-deferred story never masks another's stale pin
107
+ # (the same rule spec-link applies per commit).
108
+ stories="$(printf '%s\n' "$surface" | sed -E 's#^specs/([^/]+)/contracts/.*#\1#' | sort -u)"
109
+ rc=0
110
+ while IFS= read -r story; do
111
+ [ -z "$story" ] && continue
112
+ link="specs/${story}/link.md"
113
+ if [ ! -f "$link" ]; then
114
+ echo "note [contract-check]: no ${link} — fidelity check deferred (spec-link gates the link itself)."
115
+ continue
116
+ fi
78
117
  product_rel="$(link_val product-repo "$link")"
79
118
  pinned="$(printf '%s' "$(link_val contract-lock "$link")" | sed -E 's/^sha256:([0-9a-f]+).*$/\1/')"
80
119
  epic="$(printf '%s' "$story" | sed -E 's/-S[0-9]+$//')" # story EP-<slug>-S0N -> epic EP-<slug>
@@ -85,20 +124,40 @@ if [ -f "$link" ]; then
85
124
  lock=""
86
125
  [ -n "$prod" ] && lock="${prod}/epics/${epic}/.sdlc/contract-lock.json"
87
126
  if [ -n "$product_rel" ] && [ -f "$lock" ]; then
88
- current="$(sed -nE 's/.*"hash":[[:space:]]*"sha256:([0-9a-f]+)".*/\1/p' "$lock" | head -1)"
89
- if [ -n "$current" ] && [ "$current" != "$pinned" ]; then
127
+ # Newline-tolerant, first-match: `"hash":` and its value may legally sit on separate lines, and an
128
+ # unparseable lock is a FAIL below so a formatting choice must not become a gate failure.
129
+ # `|| current=""` is load-bearing: under `pipefail` a no-match grep fails the whole pipeline, which
130
+ # under `set -e` would abort the gate instead of reaching the unparseable-lock FAIL below.
131
+ current="$(tr '\n' ' ' < "$lock" | grep -oE '"hash"[[:space:]]*:[[:space:]]*"sha256:[0-9a-f]+"' | head -1 | sed -E 's/.*sha256:([0-9a-f]+)"$/\1/')" || current=""
132
+ # A lock we can READ but cannot PARSE proves nothing, and an empty `current` used to short-circuit
133
+ # the comparison below straight into the "hash matches" note — the gate affirmatively reporting a
134
+ # match it never made. Fail closed instead: a truncated, half-written or schema-changed lock is a
135
+ # broken lock, and a Contract-Change is being claimed against it.
136
+ if [ -z "$current" ]; then
137
+ echo "FAIL [contract-check]: ${lock} has no readable \"hash\": \"sha256:…\" value —"
138
+ echo " the lock cannot prove ${link}'s pin. Re-lock the contract upstream (yad-architecture Step 5)."
139
+ rc=1
140
+ continue
141
+ fi
142
+ if [ "$current" != "$pinned" ]; then
90
143
  echo "FAIL [contract-check]: Contract-Change claimed, but ${link} still pins ${pinned:0:12}…"
91
144
  echo " while the product lock is ${current:0:12}… — re-run yad-spec so the slice matches the re-locked contract."
92
- exit 1
145
+ rc=1
146
+ continue
93
147
  fi
94
- echo "note [contract-check]: link.md hash matches the product lock (${current:0:12}…)."
148
+ echo "note [contract-check]: ${link} hash matches the product lock (${current:0:12}…)."
95
149
  else
96
150
  # Say so. A skipped fidelity check used to be indistinguishable from a passed one, which is how a
97
151
  # mis-resolved product-repo could turn a stale-pin FAIL into a silent PASS (issue #149).
98
152
  echo "note [contract-check]: product lock not reachable at ${lock:-<no product-repo in link.md>} — fidelity check deferred."
99
153
  fi
100
- else
101
- echo "note [contract-check]: no ${link} — fidelity check deferred (spec-link gates the link itself)."
154
+ done <<EOF
155
+ $stories
156
+ EOF
157
+
158
+ if [ "$rc" != 0 ]; then
159
+ echo "FAIL [contract-check]: a changed slice pins a stale contract lock (see above) — the surface was not re-locked upstream for every story in this diff."
160
+ exit 1
102
161
  fi
103
162
 
104
163
  echo "PASS [contract-check]: surface change accompanied by Contract-Change: yes (and an updated contract)."
@@ -11,7 +11,30 @@
11
11
  # Fails CLOSED on an unresolvable base.
12
12
  set -euo pipefail
13
13
 
14
- BASE="${1:-${SDLC_BASE:-origin/main}}"
14
+ # --- shared base resolution (byte-identical across the gates; they are standalone by design, so it
15
+ # --- is duplicated, not sourced) ---
16
+ # With no explicit base, RESOLVE the trunk instead of assuming a hardcoded `origin/main` — on a repo
17
+ # whose trunk is `develop`/`master` that guess either fails closed or, where a stale `main` still
18
+ # exists, silently diffs the WRONG range (issue #161). Mirrors the CLI's own order (cli/hubcommit.mjs,
19
+ # cli/repo.mjs): the CONFIGURED default_branch first, then the remote's published default
20
+ # (origin/HEAD), then origin/main. Each candidate must actually resolve before it is used, so a
21
+ # DANGLING origin/HEAD (trunk renamed, the old remote-tracking ref pruned) falls through to the next
22
+ # candidate instead of failing the gate on a fully-fetched repo. CI always passes the base explicitly,
23
+ # so this governs local runs only. The `|| _x=""` guards are load-bearing: under `set -e` a failing
24
+ # command substitution in an assignment aborts the script.
25
+ resolve_base() {
26
+ # tr first: a key and its value may legally sit on separate lines, which a per-line match misses.
27
+ _cfg="$(tr -d '\n' < "${SDLC_HUB_CONFIG:-.sdlc/hub.json}" 2>/dev/null | sed -nE 's/.*"default_branch"[[:space:]]*:[[:space:]]*"([^"]*)".*/\1/p')" || _cfg=""
28
+ _head="$(git symbolic-ref --short --quiet refs/remotes/origin/HEAD 2>/dev/null)" || _head=""
29
+ for _c in "origin/${_cfg}" "${_head}" origin/main; do
30
+ case "$_c" in ''|origin/) continue ;; esac
31
+ if git rev-parse --verify --quiet "${_c}^{commit}" >/dev/null 2>&1; then printf '%s' "$_c"; return; fi
32
+ done
33
+ printf '%s' origin/main
34
+ }
35
+
36
+ BASE="${1:-${SDLC_BASE:-$(resolve_base)}}"
37
+ [ -n "${1:-}" ] || [ -n "${SDLC_BASE:-}" ] || echo "note [epic-open]: no base given — diffing against '${BASE}'."
15
38
 
16
39
  if ! git rev-parse --verify --quiet "${BASE}^{commit}" >/dev/null; then
17
40
  echo "FAIL [epic-open]: base ref '${BASE}' not found — fetch full history / check the base branch."
@@ -32,7 +32,30 @@ if [ ! -f "$HUB" ] || ! grep -Eq '"(bridge_enabled|bridge)"[[:space:]]*:[[:space
32
32
  exit 0
33
33
  fi
34
34
 
35
- BASE="${1:-${SDLC_BASE:-origin/main}}"
35
+ # --- shared base resolution (byte-identical across the gates; they are standalone by design, so it
36
+ # --- is duplicated, not sourced) ---
37
+ # With no explicit base, RESOLVE the trunk instead of assuming a hardcoded `origin/main` — on a repo
38
+ # whose trunk is `develop`/`master` that guess either fails closed or, where a stale `main` still
39
+ # exists, silently diffs the WRONG range (issue #161). Mirrors the CLI's own order (cli/hubcommit.mjs,
40
+ # cli/repo.mjs): the CONFIGURED default_branch first, then the remote's published default
41
+ # (origin/HEAD), then origin/main. Each candidate must actually resolve before it is used, so a
42
+ # DANGLING origin/HEAD (trunk renamed, the old remote-tracking ref pruned) falls through to the next
43
+ # candidate instead of failing the gate on a fully-fetched repo. CI always passes the base explicitly,
44
+ # so this governs local runs only. The `|| _x=""` guards are load-bearing: under `set -e` a failing
45
+ # command substitution in an assignment aborts the script.
46
+ resolve_base() {
47
+ # tr first: a key and its value may legally sit on separate lines, which a per-line match misses.
48
+ _cfg="$(tr -d '\n' < "${SDLC_HUB_CONFIG:-.sdlc/hub.json}" 2>/dev/null | sed -nE 's/.*"default_branch"[[:space:]]*:[[:space:]]*"([^"]*)".*/\1/p')" || _cfg=""
49
+ _head="$(git symbolic-ref --short --quiet refs/remotes/origin/HEAD 2>/dev/null)" || _head=""
50
+ for _c in "origin/${_cfg}" "${_head}" origin/main; do
51
+ case "$_c" in ''|origin/) continue ;; esac
52
+ if git rev-parse --verify --quiet "${_c}^{commit}" >/dev/null 2>&1; then printf '%s' "$_c"; return; fi
53
+ done
54
+ printf '%s' origin/main
55
+ }
56
+
57
+ BASE="${1:-${SDLC_BASE:-$(resolve_base)}}"
58
+ [ -n "${1:-}" ] || [ -n "${SDLC_BASE:-}" ] || echo "note [ledger-guard]: no base given — diffing against '${BASE}'."
36
59
  if ! git rev-parse --verify --quiet "${BASE}^{commit}" >/dev/null; then
37
60
  echo "FAIL [ledger-guard]: base ref '${BASE}' not found — fetch full history / check the base branch."
38
61
  exit 1
@@ -10,7 +10,30 @@
10
10
  # best-effort: the commit PASSes with a note (spec-link already proved the story link).
11
11
  set -euo pipefail
12
12
 
13
- BASE="${1:-${SDLC_BASE:-origin/main}}"
13
+ # --- shared base resolution (byte-identical across the gates; they are standalone by design, so it
14
+ # --- is duplicated, not sourced) ---
15
+ # With no explicit base, RESOLVE the trunk instead of assuming a hardcoded `origin/main` — on a repo
16
+ # whose trunk is `develop`/`master` that guess either fails closed or, where a stale `main` still
17
+ # exists, silently diffs the WRONG range (issue #161). Mirrors the CLI's own order (cli/hubcommit.mjs,
18
+ # cli/repo.mjs): the CONFIGURED default_branch first, then the remote's published default
19
+ # (origin/HEAD), then origin/main. Each candidate must actually resolve before it is used, so a
20
+ # DANGLING origin/HEAD (trunk renamed, the old remote-tracking ref pruned) falls through to the next
21
+ # candidate instead of failing the gate on a fully-fetched repo. CI always passes the base explicitly,
22
+ # so this governs local runs only. The `|| _x=""` guards are load-bearing: under `set -e` a failing
23
+ # command substitution in an assignment aborts the script.
24
+ resolve_base() {
25
+ # tr first: a key and its value may legally sit on separate lines, which a per-line match misses.
26
+ _cfg="$(tr -d '\n' < "${SDLC_HUB_CONFIG:-.sdlc/hub.json}" 2>/dev/null | sed -nE 's/.*"default_branch"[[:space:]]*:[[:space:]]*"([^"]*)".*/\1/p')" || _cfg=""
27
+ _head="$(git symbolic-ref --short --quiet refs/remotes/origin/HEAD 2>/dev/null)" || _head=""
28
+ for _c in "origin/${_cfg}" "${_head}" origin/main; do
29
+ case "$_c" in ''|origin/) continue ;; esac
30
+ if git rev-parse --verify --quiet "${_c}^{commit}" >/dev/null 2>&1; then printf '%s' "$_c"; return; fi
31
+ done
32
+ printf '%s' origin/main
33
+ }
34
+
35
+ BASE="${1:-${SDLC_BASE:-$(resolve_base)}}"
36
+ [ -n "${1:-}" ] || [ -n "${SDLC_BASE:-}" ] || echo "note [lineage-check]: no base given — diffing against '${BASE}'."
14
37
 
15
38
  if ! git rev-parse --verify --quiet "${BASE}^{commit}" >/dev/null; then
16
39
  echo "FAIL [lineage-check]: base ref '${BASE}' not found — fetch full history / check the base branch."
@@ -10,7 +10,30 @@
10
10
  # ci/chore/build/test exempt. Fails CLOSED on an unresolvable base.
11
11
  set -euo pipefail
12
12
 
13
- BASE="${1:-${SDLC_BASE:-origin/main}}"
13
+ # --- shared base resolution (byte-identical across the gates; they are standalone by design, so it
14
+ # --- is duplicated, not sourced) ---
15
+ # With no explicit base, RESOLVE the trunk instead of assuming a hardcoded `origin/main` — on a repo
16
+ # whose trunk is `develop`/`master` that guess either fails closed or, where a stale `main` still
17
+ # exists, silently diffs the WRONG range (issue #161). Mirrors the CLI's own order (cli/hubcommit.mjs,
18
+ # cli/repo.mjs): the CONFIGURED default_branch first, then the remote's published default
19
+ # (origin/HEAD), then origin/main. Each candidate must actually resolve before it is used, so a
20
+ # DANGLING origin/HEAD (trunk renamed, the old remote-tracking ref pruned) falls through to the next
21
+ # candidate instead of failing the gate on a fully-fetched repo. CI always passes the base explicitly,
22
+ # so this governs local runs only. The `|| _x=""` guards are load-bearing: under `set -e` a failing
23
+ # command substitution in an assignment aborts the script.
24
+ resolve_base() {
25
+ # tr first: a key and its value may legally sit on separate lines, which a per-line match misses.
26
+ _cfg="$(tr -d '\n' < "${SDLC_HUB_CONFIG:-.sdlc/hub.json}" 2>/dev/null | sed -nE 's/.*"default_branch"[[:space:]]*:[[:space:]]*"([^"]*)".*/\1/p')" || _cfg=""
27
+ _head="$(git symbolic-ref --short --quiet refs/remotes/origin/HEAD 2>/dev/null)" || _head=""
28
+ for _c in "origin/${_cfg}" "${_head}" origin/main; do
29
+ case "$_c" in ''|origin/) continue ;; esac
30
+ if git rev-parse --verify --quiet "${_c}^{commit}" >/dev/null 2>&1; then printf '%s' "$_c"; return; fi
31
+ done
32
+ printf '%s' origin/main
33
+ }
34
+
35
+ BASE="${1:-${SDLC_BASE:-$(resolve_base)}}"
36
+ [ -n "${1:-}" ] || [ -n "${SDLC_BASE:-}" ] || echo "note [reconcile-debt]: no base given — diffing against '${BASE}'."
14
37
 
15
38
  if ! git rev-parse --verify --quiet "${BASE}^{commit}" >/dev/null; then
16
39
  echo "FAIL [reconcile-debt]: base ref '${BASE}' not found — fetch full history / check the base branch."
@@ -10,7 +10,30 @@
10
10
  # range), so the report names every offending commit.
11
11
  set -euo pipefail
12
12
 
13
- BASE="${1:-${SDLC_BASE:-origin/main}}"
13
+ # --- shared base resolution (byte-identical across the gates; they are standalone by design, so it
14
+ # --- is duplicated, not sourced) ---
15
+ # With no explicit base, RESOLVE the trunk instead of assuming a hardcoded `origin/main` — on a repo
16
+ # whose trunk is `develop`/`master` that guess either fails closed or, where a stale `main` still
17
+ # exists, silently diffs the WRONG range (issue #161). Mirrors the CLI's own order (cli/hubcommit.mjs,
18
+ # cli/repo.mjs): the CONFIGURED default_branch first, then the remote's published default
19
+ # (origin/HEAD), then origin/main. Each candidate must actually resolve before it is used, so a
20
+ # DANGLING origin/HEAD (trunk renamed, the old remote-tracking ref pruned) falls through to the next
21
+ # candidate instead of failing the gate on a fully-fetched repo. CI always passes the base explicitly,
22
+ # so this governs local runs only. The `|| _x=""` guards are load-bearing: under `set -e` a failing
23
+ # command substitution in an assignment aborts the script.
24
+ resolve_base() {
25
+ # tr first: a key and its value may legally sit on separate lines, which a per-line match misses.
26
+ _cfg="$(tr -d '\n' < "${SDLC_HUB_CONFIG:-.sdlc/hub.json}" 2>/dev/null | sed -nE 's/.*"default_branch"[[:space:]]*:[[:space:]]*"([^"]*)".*/\1/p')" || _cfg=""
27
+ _head="$(git symbolic-ref --short --quiet refs/remotes/origin/HEAD 2>/dev/null)" || _head=""
28
+ for _c in "origin/${_cfg}" "${_head}" origin/main; do
29
+ case "$_c" in ''|origin/) continue ;; esac
30
+ if git rev-parse --verify --quiet "${_c}^{commit}" >/dev/null 2>&1; then printf '%s' "$_c"; return; fi
31
+ done
32
+ printf '%s' origin/main
33
+ }
34
+
35
+ BASE="${1:-${SDLC_BASE:-$(resolve_base)}}"
36
+ [ -n "${1:-}" ] || [ -n "${SDLC_BASE:-}" ] || echo "note [spec-link]: no base given — diffing against '${BASE}'."
14
37
 
15
38
  # Fail closed if the base ref can't be resolved (shallow clone / wrong base branch / unfetched ref).
16
39
  if ! git rev-parse --verify --quiet "${BASE}^{commit}" >/dev/null; then
@@ -26,7 +26,30 @@
26
26
  # GITLAB_TOKEN (or SDLC_API_TOKEN) with read_api scope; see the pipeline fragment header.
27
27
  set -euo pipefail
28
28
 
29
- BASE="${1:-${SDLC_BASE:-origin/main}}"
29
+ # --- shared base resolution (byte-identical across the gates; they are standalone by design, so it
30
+ # --- is duplicated, not sourced) ---
31
+ # With no explicit base, RESOLVE the trunk instead of assuming a hardcoded `origin/main` — on a repo
32
+ # whose trunk is `develop`/`master` that guess either fails closed or, where a stale `main` still
33
+ # exists, silently diffs the WRONG range (issue #161). Mirrors the CLI's own order (cli/hubcommit.mjs,
34
+ # cli/repo.mjs): the CONFIGURED default_branch first, then the remote's published default
35
+ # (origin/HEAD), then origin/main. Each candidate must actually resolve before it is used, so a
36
+ # DANGLING origin/HEAD (trunk renamed, the old remote-tracking ref pruned) falls through to the next
37
+ # candidate instead of failing the gate on a fully-fetched repo. CI always passes the base explicitly,
38
+ # so this governs local runs only. The `|| _x=""` guards are load-bearing: under `set -e` a failing
39
+ # command substitution in an assignment aborts the script.
40
+ resolve_base() {
41
+ # tr first: a key and its value may legally sit on separate lines, which a per-line match misses.
42
+ _cfg="$(tr -d '\n' < "${SDLC_HUB_CONFIG:-.sdlc/hub.json}" 2>/dev/null | sed -nE 's/.*"default_branch"[[:space:]]*:[[:space:]]*"([^"]*)".*/\1/p')" || _cfg=""
43
+ _head="$(git symbolic-ref --short --quiet refs/remotes/origin/HEAD 2>/dev/null)" || _head=""
44
+ for _c in "origin/${_cfg}" "${_head}" origin/main; do
45
+ case "$_c" in ''|origin/) continue ;; esac
46
+ if git rev-parse --verify --quiet "${_c}^{commit}" >/dev/null 2>&1; then printf '%s' "$_c"; return; fi
47
+ done
48
+ printf '%s' origin/main
49
+ }
50
+
51
+ BASE="${1:-${SDLC_BASE:-$(resolve_base)}}"
52
+ [ -n "${1:-}" ] || [ -n "${SDLC_BASE:-}" ] || echo "note [verified-commits]: no base given — diffing against '${BASE}'."
30
53
 
31
54
  # Fail closed if the base ref can't be resolved (shallow clone / wrong base branch / unfetched ref).
32
55
  if ! git rev-parse --verify --quiet "${BASE}^{commit}" >/dev/null; then
@@ -21,7 +21,7 @@ connected, `yad-test-cases` runs artifacts-only exactly as before.
21
21
  - `{project-root}` resolves from the project working directory (the **product hub**).
22
22
  - The integration is **Playwright-first but pluggable** (`config.yaml` `testing.tools`): a testing-tool
23
23
  *adapter*, like the GitHub/GitLab platform adapter or the design-tool adapter. Playwright is the
24
- primary provider; `cypress` and `pytest` are second providers; `none` → artifacts-only.
24
+ primary provider; `cypress`, `pytest` and `maestro` are second providers; `none` → artifacts-only.
25
25
  - **The testing tool is reached through its MCP** (a harness MCP server), NOT a subprocess CLI — the
26
26
  same shape as the design tool's MCP, not Repomix's `npx`. The skill detects the MCP and degrades when
27
27
  it is absent; it never installs an MCP server.
@@ -34,7 +34,7 @@ connected, `yad-test-cases` runs artifacts-only exactly as before.
34
34
  ## Inputs
35
35
 
36
36
  - `action` — `connect` (default) | `refresh` | `list` | `disconnect`.
37
- - `tool` — `playwright` | `cypress` | `pytest` | another adapter id (`config.yaml` `testing.tools`).
37
+ - `tool` — `playwright` | `cypress` | `pytest` | `maestro` | another adapter id (`config.yaml` `testing.tools`).
38
38
  `none` records a deliberate artifacts-only project.
39
39
  - `project_url` — the testing tool's project/config reference (e.g. a `playwright.config.ts` path or a
40
40
  test-runner project URL). Optional — a connection with no suite yet is valid; `yad-test-cases` can
@@ -51,9 +51,13 @@ way `registerRepo` falls back on an unknown platform). Then **detect the tool's
51
51
  - **playwright** → a Playwright MCP server (drives a browser, generates/runs E2E + API specs).
52
52
  - **cypress** → the Cypress MCP (generate/run Cypress specs).
53
53
  - **pytest** → a pytest MCP (generate/run service-layer tests).
54
+ - **maestro** → the Maestro MCP, bundled in the Maestro CLI and started as `maestro mcp` over STDIO
55
+ (drives iOS simulators, Android emulators and Chromium; generates/runs Maestro flows). Mobile E2E,
56
+ where Playwright has no reach.
54
57
  - another adapter → its named MCP.
55
58
 
56
- Record `provider` (the concrete MCP, e.g. `playwright-mcp` | `cypress-mcp` | `pytest-mcp`) and whether
59
+ Record `provider` (the concrete MCP, e.g. `playwright-mcp` | `cypress-mcp` | `pytest-mcp` |
60
+ `maestro-mcp`) and whether
57
61
  it is available. **Auth is the local user's own** — the user's authenticated MCP session. The skill
58
62
  **stores no tokens**; `project_url`/`suites` are plain references, never credentials.
59
63
 
@@ -15,6 +15,7 @@ Detection is best-effort against the user's own authenticated MCP session:
15
15
  | `playwright` | a Playwright MCP | **generate** — author + run E2E/API specs against the app |
16
16
  | `cypress` | the Cypress MCP | **generate** — author + run Cypress specs |
17
17
  | `pytest` | a pytest MCP | **generate** — author + run service-layer tests |
18
+ | `maestro` | the Maestro MCP (`maestro mcp`, bundled in the CLI, STDIO) | **generate** — author + run Maestro flows on iOS/Android/Chromium |
18
19
  | any | a read-only runner MCP | **link** — reference an existing suite and read results back |
19
20
  | other | the adapter's named MCP | per that adapter |
20
21
 
@@ -34,6 +35,11 @@ the cases `test-cases.md` enumerates and the acceptance criteria the stories def
34
35
  the code-maps from `yad-test-cases` Step 2b), one spec per high-priority (P0/P1) case, and runs them
35
36
  via the MCP to confirm they execute.
36
37
  - **Cypress / pytest** — the lens authors the equivalent specs in that framework's layout.
38
+ - **Maestro** — the lens authors `.yaml` flows under the repo's Maestro directory (one flow per
39
+ high-priority case), targeting the app id / screen elements the code-maps name rather than invented
40
+ ones, and runs them via the MCP against a simulator/emulator to confirm they execute. Mobile E2E is
41
+ the top of that pyramid: keep the flow count small and push detail down to the unit/integration
42
+ levels the repo already has.
37
43
 
38
44
  Reuse what already exists: load the connected code repos' code-maps (`yad-test-cases` Step 2b) so
39
45
  generated tests target real endpoints/components, not invented ones, and prefer the lowest useful test
@@ -14,8 +14,8 @@ root, not under any `epics/EP-<slug>/.sdlc/`.
14
14
 
15
15
  ```json
16
16
  {
17
- "tool": "playwright", // playwright | cypress | pytest | <adapter id> | none (artifacts-only)
18
- "provider": "playwright-mcp", // the concrete MCP: playwright-mcp | cypress-mcp | pytest-mcp | null
17
+ "tool": "playwright", // playwright | cypress | pytest | maestro | <adapter id> | none
18
+ "provider": "playwright-mcp", // the concrete MCP: playwright-mcp | cypress-mcp | pytest-mcp | maestro-mcp | null
19
19
  "project_url": "tests/playwright.config.ts", // project/config reference; null if none yet
20
20
  "auth": "user", // ALWAYS the user's own MCP session — never a token
21
21
  "suites": { "backend": null, "mobile": null }, // optional default suite refs per repo
@@ -2382,9 +2382,9 @@
2382
2382
  "license": "MIT"
2383
2383
  },
2384
2384
  "node_modules/js-yaml": {
2385
- "version": "4.3.0",
2386
- "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
2387
- "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
2385
+ "version": "4.3.1",
2386
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
2387
+ "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
2388
2388
  "dev": true,
2389
2389
  "funding": [
2390
2390
  {
@@ -94,7 +94,8 @@ Read `{project-root}/.sdlc/testing.json` (`config.yaml` `testing.registry`). Dec
94
94
  the Markdown artifact only and record `testing: none` in the frontmatter with a one-line note
95
95
  (mirrors the `design: none` degrade). Skip to Step 4.
96
96
  - **A tool is connected and its MCP is available:** adopt the `test architect` lens and, using the
97
- provider recorded in `testing.json` (Playwright via a Playwright MCP, Cypress/pytest via theirs) drive
97
+ provider recorded in `testing.json` (Playwright via a Playwright MCP, Cypress/pytest/Maestro via
98
+ theirs — Maestro authors mobile flows where Playwright has no reach) drive
98
99
  `bmad-testarch-automate`:
99
100
  - **Generate** — when the provider is write-capable, author one automation test per high-priority
100
101
  (P0/P1) case into the connected code repo(s) for the repos in `epic.repos`, reusing the code-maps
@@ -120,7 +121,7 @@ status: draft
120
121
  owner: <inherit from epic.md owner> # the epic owner carries through; not retyped
121
122
  repos: [<inherit from epic>]
122
123
  code-context: { repos: [], loaded: <YYYY-MM-DD or none> } # code-maps that informed the tests (Step 2b)
123
- testing: <none | { tool: <playwright|cypress|pytest|…>, direction: <generated|linked>, suite: <url/path>, tests: <N> }> # the connected testing tool (Step 3b)
124
+ testing: <none | { tool: <playwright|cypress|pytest|maestro|…>, direction: <generated|linked>, suite: <url/path>, tests: <N> }> # the connected testing tool (Step 3b)
124
125
  ---
125
126
 
126
127
  ## Test strategy & risk