@codyswann/lisa 4.22.7 → 4.22.9

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.
Files changed (143) hide show
  1. package/dist/core/nightly-e2e-guard-behavior-certificate.js +2 -2
  2. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  3. package/dist/core/upstream-evidence-manifest.js +22 -13
  4. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  5. package/dist/sync/lifecycle-defaults.d.ts +21 -0
  6. package/dist/sync/lifecycle-defaults.d.ts.map +1 -1
  7. package/dist/sync/lifecycle-defaults.js +21 -1
  8. package/dist/sync/lifecycle-defaults.js.map +1 -1
  9. package/dist/sync/registry.d.ts.map +1 -1
  10. package/dist/sync/registry.js +7 -1
  11. package/dist/sync/registry.js.map +1 -1
  12. package/package.json +3 -3
  13. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  14. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  15. package/plugins/lisa/.codex-plugin/skills/lisa-github-build-intake/SKILL.md +6 -5
  16. package/plugins/lisa/.codex-plugin/skills/lisa-github-prd-intake/SKILL.md +6 -5
  17. package/plugins/lisa/.codex-plugin/skills/lisa-github-sync/SKILL.md +2 -0
  18. package/plugins/lisa/.codex-plugin/skills/lisa-github-write-prd/SKILL.md +7 -3
  19. package/plugins/lisa/.codex-plugin/skills/lisa-jira-build-intake/SKILL.md +6 -5
  20. package/plugins/lisa/.codex-plugin/skills/lisa-jira-sync/SKILL.md +3 -1
  21. package/plugins/lisa/.codex-plugin/skills/lisa-linear-build-intake/SKILL.md +6 -5
  22. package/plugins/lisa/.codex-plugin/skills/lisa-linear-evidence/SKILL.md +12 -11
  23. package/plugins/lisa/.codex-plugin/skills/lisa-linear-prd-intake/SKILL.md +6 -5
  24. package/plugins/lisa/.codex-plugin/skills/lisa-linear-sync/SKILL.md +36 -20
  25. package/plugins/lisa/.codex-plugin/skills/lisa-secrets-access/scripts/publish-aws-bootstrap.mjs +95 -44
  26. package/plugins/lisa/.codex-plugin/skills/lisa-tracker-sync/SKILL.md +22 -2
  27. package/plugins/lisa/rules/reference/config-resolution.md +30 -3
  28. package/plugins/lisa/scripts/resolve-lifecycle-role.mjs +340 -0
  29. package/plugins/lisa/skills/lisa-github-build-intake/SKILL.md +6 -5
  30. package/plugins/lisa/skills/lisa-github-prd-intake/SKILL.md +6 -5
  31. package/plugins/lisa/skills/lisa-github-sync/SKILL.md +2 -0
  32. package/plugins/lisa/skills/lisa-github-write-prd/SKILL.md +7 -3
  33. package/plugins/lisa/skills/lisa-jira-build-intake/SKILL.md +6 -5
  34. package/plugins/lisa/skills/lisa-jira-sync/SKILL.md +3 -1
  35. package/plugins/lisa/skills/lisa-linear-build-intake/SKILL.md +6 -5
  36. package/plugins/lisa/skills/lisa-linear-evidence/SKILL.md +12 -11
  37. package/plugins/lisa/skills/lisa-linear-prd-intake/SKILL.md +6 -5
  38. package/plugins/lisa/skills/lisa-linear-sync/SKILL.md +36 -20
  39. package/plugins/lisa/skills/lisa-secrets-access/scripts/publish-aws-bootstrap.mjs +95 -44
  40. package/plugins/lisa/skills/lisa-tracker-sync/SKILL.md +22 -2
  41. package/plugins/lisa-agy/plugin.json +1 -1
  42. package/plugins/lisa-agy/scripts/resolve-lifecycle-role.mjs +340 -0
  43. package/plugins/lisa-agy/skills/lisa-github-build-intake/SKILL.md +6 -5
  44. package/plugins/lisa-agy/skills/lisa-github-prd-intake/SKILL.md +6 -5
  45. package/plugins/lisa-agy/skills/lisa-github-sync/SKILL.md +2 -0
  46. package/plugins/lisa-agy/skills/lisa-github-write-prd/SKILL.md +7 -3
  47. package/plugins/lisa-agy/skills/lisa-jira-build-intake/SKILL.md +6 -5
  48. package/plugins/lisa-agy/skills/lisa-jira-sync/SKILL.md +3 -1
  49. package/plugins/lisa-agy/skills/lisa-linear-build-intake/SKILL.md +6 -5
  50. package/plugins/lisa-agy/skills/lisa-linear-evidence/SKILL.md +12 -11
  51. package/plugins/lisa-agy/skills/lisa-linear-prd-intake/SKILL.md +6 -5
  52. package/plugins/lisa-agy/skills/lisa-linear-sync/SKILL.md +36 -20
  53. package/plugins/lisa-agy/skills/lisa-secrets-access/scripts/publish-aws-bootstrap.mjs +95 -44
  54. package/plugins/lisa-agy/skills/lisa-tracker-sync/SKILL.md +22 -2
  55. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  56. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  57. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  58. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  59. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  60. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  61. package/plugins/lisa-copilot/rules/reference/config-resolution.md +30 -3
  62. package/plugins/lisa-copilot/scripts/resolve-lifecycle-role.mjs +340 -0
  63. package/plugins/lisa-copilot/skills/lisa-github-build-intake/SKILL.md +6 -5
  64. package/plugins/lisa-copilot/skills/lisa-github-prd-intake/SKILL.md +6 -5
  65. package/plugins/lisa-copilot/skills/lisa-github-sync/SKILL.md +2 -0
  66. package/plugins/lisa-copilot/skills/lisa-github-write-prd/SKILL.md +7 -3
  67. package/plugins/lisa-copilot/skills/lisa-jira-build-intake/SKILL.md +6 -5
  68. package/plugins/lisa-copilot/skills/lisa-jira-sync/SKILL.md +3 -1
  69. package/plugins/lisa-copilot/skills/lisa-linear-build-intake/SKILL.md +6 -5
  70. package/plugins/lisa-copilot/skills/lisa-linear-evidence/SKILL.md +12 -11
  71. package/plugins/lisa-copilot/skills/lisa-linear-prd-intake/SKILL.md +6 -5
  72. package/plugins/lisa-copilot/skills/lisa-linear-sync/SKILL.md +36 -20
  73. package/plugins/lisa-copilot/skills/lisa-secrets-access/scripts/publish-aws-bootstrap.mjs +95 -44
  74. package/plugins/lisa-copilot/skills/lisa-tracker-sync/SKILL.md +22 -2
  75. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  76. package/plugins/lisa-cursor/rules/config-resolution-reference.mdc +30 -3
  77. package/plugins/lisa-cursor/scripts/resolve-lifecycle-role.mjs +340 -0
  78. package/plugins/lisa-cursor/skills/lisa-github-build-intake/SKILL.md +6 -5
  79. package/plugins/lisa-cursor/skills/lisa-github-prd-intake/SKILL.md +6 -5
  80. package/plugins/lisa-cursor/skills/lisa-github-sync/SKILL.md +2 -0
  81. package/plugins/lisa-cursor/skills/lisa-github-write-prd/SKILL.md +7 -3
  82. package/plugins/lisa-cursor/skills/lisa-jira-build-intake/SKILL.md +6 -5
  83. package/plugins/lisa-cursor/skills/lisa-jira-sync/SKILL.md +3 -1
  84. package/plugins/lisa-cursor/skills/lisa-linear-build-intake/SKILL.md +6 -5
  85. package/plugins/lisa-cursor/skills/lisa-linear-evidence/SKILL.md +12 -11
  86. package/plugins/lisa-cursor/skills/lisa-linear-prd-intake/SKILL.md +6 -5
  87. package/plugins/lisa-cursor/skills/lisa-linear-sync/SKILL.md +36 -20
  88. package/plugins/lisa-cursor/skills/lisa-secrets-access/scripts/publish-aws-bootstrap.mjs +95 -44
  89. package/plugins/lisa-cursor/skills/lisa-tracker-sync/SKILL.md +22 -2
  90. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  91. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  92. package/plugins/lisa-expo-agy/plugin.json +1 -1
  93. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  94. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  95. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  96. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  97. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  98. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  99. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  100. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  101. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  102. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  103. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  104. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  105. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  106. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  107. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  108. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  109. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  110. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  111. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  112. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  113. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  114. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  115. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  116. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  117. package/plugins/lisa-rails-agy/plugin.json +1 -1
  118. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  119. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  120. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  121. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  122. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  123. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  124. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  125. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  126. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  127. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  128. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  129. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  130. package/plugins/src/base/rules/reference/config-resolution.md +30 -3
  131. package/plugins/src/base/scripts/resolve-lifecycle-role.mjs +340 -0
  132. package/plugins/src/base/skills/lisa-github-build-intake/SKILL.md +6 -5
  133. package/plugins/src/base/skills/lisa-github-prd-intake/SKILL.md +6 -5
  134. package/plugins/src/base/skills/lisa-github-sync/SKILL.md +2 -0
  135. package/plugins/src/base/skills/lisa-github-write-prd/SKILL.md +7 -3
  136. package/plugins/src/base/skills/lisa-jira-build-intake/SKILL.md +6 -5
  137. package/plugins/src/base/skills/lisa-jira-sync/SKILL.md +3 -1
  138. package/plugins/src/base/skills/lisa-linear-build-intake/SKILL.md +6 -5
  139. package/plugins/src/base/skills/lisa-linear-evidence/SKILL.md +12 -11
  140. package/plugins/src/base/skills/lisa-linear-prd-intake/SKILL.md +6 -5
  141. package/plugins/src/base/skills/lisa-linear-sync/SKILL.md +36 -20
  142. package/plugins/src/base/skills/lisa-secrets-access/scripts/publish-aws-bootstrap.mjs +95 -44
  143. package/plugins/src/base/skills/lisa-tracker-sync/SKILL.md +22 -2
@@ -38,11 +38,12 @@ Build-queue label names are read from `.lisa.config.json` `github.labels.build.*
38
38
  ```bash
39
39
  # Read role with default fallback. Local overrides global per-key.
40
40
  read_role() {
41
- local role="$1" default="$2"
42
- local local_v global_v
43
- local_v=$(jq -r ".github.labels.build.${role} // empty" .lisa.config.local.json 2>/dev/null)
44
- global_v=$(jq -r ".github.labels.build.${role} // empty" .lisa.config.json 2>/dev/null)
45
- echo "${local_v:-${global_v:-$default}}"
41
+ # Single resolver — see config-resolution "The single resolver".
42
+ # Exit 0 + empty output means an OPTIONAL role is unset: skip that transition,
43
+ # never substitute the second argument. A default on an optional role is what
44
+ # made "unset" indistinguishable from "not customized".
45
+ node "${CLAUDE_PLUGIN_ROOT}/scripts/resolve-lifecycle-role.mjs" \
46
+ --role "$1" --vendor github --intent "${3:-read}" 2>/dev/null
46
47
  }
47
48
 
48
49
  READY=$(read_role ready "status:ready")
@@ -21,11 +21,12 @@ PRD label names are read from `.lisa.config.json` `github.labels.prd.*`, falling
21
21
  ```bash
22
22
  # Read role with default fallback. Local overrides global per-key.
23
23
  read_role() {
24
- local role="$1" default="$2"
25
- local local_v global_v
26
- local_v=$(jq -r ".github.labels.prd.${role} // empty" .lisa.config.local.json 2>/dev/null)
27
- global_v=$(jq -r ".github.labels.prd.${role} // empty" .lisa.config.json 2>/dev/null)
28
- echo "${local_v:-${global_v:-$default}}"
24
+ # Single resolver — see config-resolution "The single resolver".
25
+ # Exit 0 + empty output means an OPTIONAL role is unset: skip that transition,
26
+ # never substitute the second argument. A default on an optional role is what
27
+ # made "unset" indistinguishable from "not customized".
28
+ node "${CLAUDE_PLUGIN_ROOT}/scripts/resolve-lifecycle-role.mjs" \
29
+ --role "$1" --vendor github --intent "${3:-read}" 2>/dev/null
29
30
  }
30
31
 
31
32
  READY=$(read_role ready "prd-ready")
@@ -79,6 +79,8 @@ Based on the milestone, suggest (but do NOT automatically perform) a label trans
79
79
 
80
80
  The actual `status:in-progress` flip is owned by `lisa-github-build-intake` (claim) and `lisa-github-agent`. The configured `done` flip is owned by the build-intake owner after a successful build and evidence post. This skill never relabels.
81
81
 
82
+ Every suggested transition is bound by the **Tracker status vocabulary** section of `lisa-tracker-sync` — cite it, do not restate the policy. GitHub has **no review lane** (`BUILD_LABEL_DEFAULTS` seeds no `review` key, and `config-resolution` records "no default review label"), so the `pr-ready` milestone suggests the configured `done` label directly; there is no intermediate hop to skip. Resolve every label through the shared resolver rather than an inlined helper.
83
+
82
84
  ### Step 5: Parent Status Rollup (`--rollup`)
83
85
 
84
86
  When invoked with `--rollup`, this skill **derives a parent/container issue's `status:*` label from the roll-up of its child sub-issues** instead of posting a milestone update on a leaf. This implements the GitHub sub-issue-completion arm of the **Parent status rollup (the state machine)** section of the `leaf-only-lifecycle` rule — cite that rule, do not restate the policy. It is the sync-side complement to the write-time labeling (`lisa-github-write-issue`), the validate-time S15 gate (`lisa-github-validate-issue`), and the claim-time gate (`lisa-github-build-intake`); all four cite the same rule so the classification never drifts.
@@ -20,9 +20,13 @@ ORG=$(jq -r '.github.org // empty' .lisa.config.local.json 2>/dev/null); ORG="${
20
20
  REPO=$(jq -r '.github.repo // empty' .lisa.config.local.json 2>/dev/null); REPO="${REPO:-$(jq -r '.github.repo // empty' .lisa.config.json)}"
21
21
  [ -z "$ORG" ] || [ -z "$REPO" ] && { echo "Error: github.org / github.repo not set in .lisa.config.json."; exit 1; }
22
22
 
23
- read_role() { # path default
24
- local lv gv; lv=$(jq -r "$1 // empty" .lisa.config.local.json 2>/dev/null); gv=$(jq -r "$1 // empty" .lisa.config.json 2>/dev/null)
25
- echo "${lv:-${gv:-$2}}"
23
+ read_role() {
24
+ # Single resolver — see config-resolution "The single resolver".
25
+ # Exit 0 + empty output means an OPTIONAL role is unset: skip that transition,
26
+ # never substitute the second argument. A default on an optional role is what
27
+ # made "unset" indistinguishable from "not customized".
28
+ node "${CLAUDE_PLUGIN_ROOT}/scripts/resolve-lifecycle-role.mjs" \
29
+ --role "$1" --vendor github --intent "${3:-read}" 2>/dev/null
26
30
  }
27
31
  # Resolve the FULL PRD lifecycle vocabulary from config (never hard-code names) — needed so the
28
32
  # "exactly one role" reconcile and the past-ready check work for projects that renamed any label.
@@ -22,11 +22,12 @@ Status names are read from `.lisa.config.json` `jira.workflow.*`, falling back t
22
22
  ```bash
23
23
  # Read role with default fallback. Local overrides global per-key.
24
24
  read_role() {
25
- local role="$1" default="$2"
26
- local local_v global_v
27
- local_v=$(jq -r ".jira.workflow.${role} // empty" .lisa.config.local.json 2>/dev/null)
28
- global_v=$(jq -r ".jira.workflow.${role} // empty" .lisa.config.json 2>/dev/null)
29
- echo "${local_v:-${global_v:-$default}}"
25
+ # Single resolver — see config-resolution "The single resolver".
26
+ # Exit 0 + empty output means an OPTIONAL role is unset: skip that transition,
27
+ # never substitute the second argument. A default on an optional role is what
28
+ # made "unset" indistinguishable from "not customized".
29
+ node "${CLAUDE_PLUGIN_ROOT}/scripts/resolve-lifecycle-role.mjs" \
30
+ --role "$1" --vendor jira --intent "${3:-read}" 2>/dev/null
30
31
  }
31
32
 
32
33
  READY=$(read_role ready "Ready")
@@ -75,7 +75,9 @@ Based on the milestone, suggest (but don't automatically perform) a status trans
75
75
  | PR ready | configured `jira.workflow.review` status, or no transition when unconfigured |
76
76
  | PR merged | configured `jira.workflow.done` status for the PR's target environment (env-keyed `done` resolved via `deploy.branches`), or no transition when unconfigured |
77
77
 
78
- Every suggested or performed transition is bound by the **Tracker status vocabulary** section of the `config-resolution` rule: only statuses named in the configured workflow map, never statuses discovered from the tracker's live workflow (transition lists, board columns, other tickets) — and this binds the lead performing tracker writes exactly as it binds a subagent. A milestone with no configured status gets a comment, not a transition.
78
+ Every suggested transition is bound by the **Tracker status vocabulary** section of `lisa-tracker-sync` — cite it, do not restate the policy. It is shared by all three vendor arms so the bar cannot drift between them, and it carries the two consequences that matter here: a milestone whose role is unset gets a comment rather than a transition, and a fallback may inform a read but never supply a write target.
79
+
80
+ `review` is optional on JIRA and has no default. Resolve it through the shared resolver rather than an inlined helper — `lisa-jira-evidence/scripts/post-evidence.sh` already models the correct behaviour (empty default, explicit skip branch, `leaving <ID> in its current (claimed) status`).
79
81
 
80
82
  ### Step 5: Parent Status Rollup (`--rollup`)
81
83
 
@@ -23,11 +23,12 @@ Build-queue **workflow state** names are read from `.lisa.config.json` `linear.w
23
23
  ```bash
24
24
  # Read role with default fallback. Local overrides global per-key.
25
25
  read_role() {
26
- local role="$1" default="$2"
27
- local local_v global_v
28
- local_v=$(jq -r ".linear.workflow.${role} // empty" .lisa.config.local.json 2>/dev/null)
29
- global_v=$(jq -r ".linear.workflow.${role} // empty" .lisa.config.json 2>/dev/null)
30
- echo "${local_v:-${global_v:-$default}}"
26
+ # Single resolver — see config-resolution "The single resolver".
27
+ # Exit 0 + empty output means an OPTIONAL role is unset: skip that transition,
28
+ # never substitute the second argument. A default on an optional role is what
29
+ # made "unset" indistinguishable from "not customized".
30
+ node "${CLAUDE_PLUGIN_ROOT}/scripts/resolve-lifecycle-role.mjs" \
31
+ --role "$1" --vendor linear --intent "${3:-read}" 2>/dev/null
31
32
  }
32
33
 
33
34
  READY=$(read_role ready "Ready")
@@ -12,21 +12,20 @@ Post verification evidence to a Linear Issue and transition it from the configur
12
12
 
13
13
  ## Workflow resolution
14
14
 
15
- The `claimed` and `review` build labels are read from `.lisa.config.json` `linear.workflow.*`, falling back to the defaults documented in the `config-resolution` rule (`In Progress` and `In Review`).
15
+ Resolve every lifecycle role through the shared resolver — never an inlined `read_role` helper. Twelve skills used to inline their own and produced **eleven different implementations**, which is how the vendors drifted apart on this exact contract.
16
16
 
17
17
  ```bash
18
- read_role() {
19
- local role="$1" default="$2"
20
- local local_v global_v
21
- local_v=$(jq -r ".linear.workflow.${role} // empty" .lisa.config.local.json 2>/dev/null)
22
- global_v=$(jq -r ".linear.workflow.${role} // empty" .lisa.config.json 2>/dev/null)
23
- echo "${local_v:-${global_v:-$default}}"
18
+ resolve() { # role, intent -> value on stdout; empty when an optional role is unset
19
+ node "${CLAUDE_PLUGIN_ROOT}/scripts/resolve-lifecycle-role.mjs" \
20
+ --role "$1" --vendor linear --intent "${2:-read}" 2>/dev/null
24
21
  }
25
22
 
26
- CLAIMED=$(read_role claimed "In Progress")
27
- REVIEW=$(read_role review "In Review")
23
+ CLAIMED=$(resolve claimed write)
24
+ REVIEW=$(resolve review write)
28
25
  ```
29
26
 
27
+ **`review` is OPTIONAL and has no default.** An empty `REVIEW` means the project does not run an agent review step, and this skill **skips the transition entirely**, leaving the Issue in `claimed` — the same behaviour `lisa-jira-evidence/scripts/post-evidence.sh` has always had. Do not substitute `In Review`, and do not fall back to a state resolved by `type` or board position: a fallback may inform a read but must never supply a write target (`config-resolution`, R2). Resolving a state nobody configured is how agents reach human-only review lanes.
28
+
30
29
  ## Configuration
31
30
 
32
31
  Reads `linear.workspace`, `linear.teamKey`, and `linear.workflow.*` from `.lisa.config.json` (with `.local` override).
@@ -93,9 +92,11 @@ Linear comments support markdown including `<details>` collapsibles, fenced code
93
92
 
94
93
  ## Phase 5 — Transition Status
95
94
 
96
- Update labels via `lisa-linear-access operation: save-issue` to remove `$CLAIMED` and add `$REVIEW`. Resolve label IDs first via `lisa-linear-access operation: list-issue-labels` (create the label via `create_issue_label` if it doesn't exist on the team).
95
+ **If `$REVIEW` is empty, skip this phase entirely** — post the evidence comment and leave the Issue in `$CLAIMED`. Report it plainly (`No linear.workflow.review configured; leaving <ID> in its current (claimed) state`) so the skip is visible rather than silent. This is a supported configuration, not a failure.
96
+
97
+ When `$REVIEW` is non-empty, update labels via `lisa-linear-access operation: save-issue` to remove `$CLAIMED` and add `$REVIEW`. Resolve label IDs first via `lisa-linear-access operation: list-issue-labels` (create the label via `create_issue_label` if it doesn't exist on the team).
97
98
 
98
- The native Linear `state` field is also updated to the team's "In Review" state if one exists — but the label remains the source of truth for cross-team consistency.
99
+ The native Linear `state` field is updated to `$REVIEW` **only when the resolver returned it from config**. Never resolve the state by searching the team for something review-shaped: on a board with more than one review-ish state that picks by position, not by intent, and the states most likely to be found that way are the human-only ones a project deliberately kept out of its config.
99
100
 
100
101
  ## Phase 6 — Report
101
102
 
@@ -21,11 +21,12 @@ PRD label names are read from `.lisa.config.json` `linear.labels.prd.*`, falling
21
21
  ```bash
22
22
  # Read role with default fallback. Local overrides global per-key.
23
23
  read_role() {
24
- local role="$1" default="$2"
25
- local local_v global_v
26
- local_v=$(jq -r ".linear.labels.prd.${role} // empty" .lisa.config.local.json 2>/dev/null)
27
- global_v=$(jq -r ".linear.labels.prd.${role} // empty" .lisa.config.json 2>/dev/null)
28
- echo "${local_v:-${global_v:-$default}}"
24
+ # Single resolver — see config-resolution "The single resolver".
25
+ # Exit 0 + empty output means an OPTIONAL role is unset: skip that transition,
26
+ # never substitute the second argument. A default on an optional role is what
27
+ # made "unset" indistinguishable from "not customized".
28
+ node "${CLAUDE_PLUGIN_ROOT}/scripts/resolve-lifecycle-role.mjs" \
29
+ --role "$1" --vendor linear --intent "${3:-read}" 2>/dev/null
29
30
  }
30
31
 
31
32
  READY=$(read_role ready "prd-ready")
@@ -16,14 +16,25 @@ Reads `linear.workspace`, `linear.teamKey` from `.lisa.config.json` (with `.loca
16
16
 
17
17
  Callers (planning skills, lifecycle skills) invoke this skill at:
18
18
 
19
- | Milestone | What to post |
20
- |-----------|--------------|
21
- | Plan created | Plan contents (sections + ordered tasks) as a comment, suggest transition `Backlog → Ready` (state: `Ready`) |
22
- | Implementation in progress | Branch URL + first commit, suggest transition `Ready → In Progress` (state: `In Progress`) |
23
- | PR ready for review | PR URL + summary, the implementation handoff comment, suggest transition `In Progress → In Review` (state: `In Review`) |
24
- | PR merged | Merge SHA + deploy environment (if known), suggest transition `In Review → Done` (state: `Done`), **then run Phase 4b — mandatory when the merge target is a non-terminal env branch** |
19
+ | Milestone | What to post | Suggested role |
20
+ |-----------|--------------|----------------|
21
+ | Plan created | Plan contents (sections + ordered tasks) as a comment | `ready` |
22
+ | Implementation in progress | Branch URL + first commit | `claimed` |
23
+ | PR ready for review | PR URL + summary, the implementation handoff comment | `review` — **optional; omitted by most projects, in which case there is no transition to suggest** |
24
+ | PR merged | Merge SHA + deploy environment (if known) | env-keyed `done`, **then run Phase 4b — mandatory when the merge target is a non-terminal env branch** |
25
+
26
+ **Roles, never literal state names.** Resolve each through the shared resolver; the state names above are whatever the project configured, and a role the project did not configure has no suggestion at all:
27
+
28
+ ```bash
29
+ resolve() {
30
+ node "${CLAUDE_PLUGIN_ROOT}/scripts/resolve-lifecycle-role.mjs" \
31
+ --role "$1" --vendor linear --intent read 2>/dev/null
32
+ }
33
+ ```
34
+
35
+ **This skill SUGGESTS transitions. It never writes the lane.** That matches `lisa-jira-sync` ("suggest, but don't automatically perform") and `lisa-github-sync` ("this skill never relabels"), and it is what this skill's own dispatcher already advertises: `lisa-tracker-sync`'s description reads *"Suggests (never auto-transitions) the next status."* Lane writes belong to the build-intake / agent owner, which is already true on the other two trackers.
25
36
 
26
- This skill **suggests** transitions and applies them to the native Linear `state` field when the caller asks — the build queue is keyed off states, so the state IS the lane. Without `--update-state` it only suggests; an unasked-for transition remains a human / triage decision.
37
+ `--rollup` is the one exception and is documented in Phase 4b — parent derivation is its entire purpose and is separately gated.
27
38
 
28
39
  ## Input
29
40
 
@@ -82,20 +93,24 @@ When `$ARGUMENTS` includes `pr_url=<url>` for `pr-ready` or `pr-merged`, ensure
82
93
 
83
94
  The PR branch/title/body identifier is the PR -> Linear side. This phase is the required Linear -> PR side.
84
95
 
85
- ## Phase 4 — Update Workflow State (when caller requests)
96
+ ## Phase 4 — Suggest the Next State (never write it)
97
+
98
+ Name the suggested next state in the milestone comment and stop. **This skill does not set `stateId`.** The lane write belongs to `lisa-linear-build-intake` / `lisa-linear-agent`, exactly as the `status:*` write belongs to `lisa-github-build-intake` on GitHub.
86
99
 
87
- If the caller passes `--update-state`, set the Issue's `stateId` via `lisa-linear-access operation: save-issue`:
100
+ Resolve the suggestion by role, and **say nothing when the role is unset**:
88
101
 
89
- - `plan-created` → set state `Ready`
90
- - `implementation-in-progress` → set state `In Progress`
91
- - `pr-ready` → set state `In Review`
92
- - `pr-merged` → set state `Done`
102
+ | Milestone | Role to resolve | When the role is unset |
103
+ |---|---|---|
104
+ | `plan-created` | `ready` | required — report a setup defect |
105
+ | `implementation-in-progress` | `claimed` | required — report a setup defect |
106
+ | `pr-ready` | `review` | **optional — suggest no transition; the Issue stays in `claimed`** |
107
+ | `pr-merged` | `done` for the PR's target env (via `deploy.branches`) | required — report a setup defect |
93
108
 
94
- If the requested STATE doesn't exist on the team, that is a setup defect — report it and point at `/lisa:setup:linear`. Never create a workflow state here: a state carries a `type` and a board position, and guessing either puts the Issue somewhere no human sanctioned.
109
+ If a resolved state doesn't exist on the team, that is a setup defect — report it and point at `/lisa:setup:linear`. Never create a workflow state here: a state carries a `type` and a board position, and guessing either puts the Issue somewhere no human sanctioned. Equally, never *find* one: resolving a role by scanning the team for a state whose name or `type` looks right picks by board position, not intent, and the states that surface that way are the human-only lanes a project deliberately left out of its config.
95
110
 
96
- No single-lane verification is needed: an Issue holds exactly one workflow state by construction, so the two-lanes-at-once corruption the old label-driven lane could produce is unrepresentable. (It was not hypothetical — 16 issues carried two `status:*` labels at the time of the migration.)
111
+ > **Removed: `--update-state`.** This flag previously let a caller make Phase 4 write the state. It was the only lane-write path in any sync skill, and combined with a defaulted `review` role it moved Issues into review states that projects had never configured. Callers that relied on it should let the build-intake owner make the transition; `--rollup` is unaffected.
97
112
 
98
- Without `--update-state`, this skill posts the comment only and does NOT touch the Issue's state.
113
+ No single-lane verification is needed: an Issue holds exactly one workflow state by construction, so the two-lanes-at-once corruption the old label-driven lane could produce is unrepresentable. (It was not hypothetical — 16 issues carried two `status:*` labels at the time of the migration.)
99
114
 
100
115
  ## Phase 4b — Reconcile Native Auto-Close (Linear-specific)
101
116
 
@@ -116,13 +131,13 @@ When the caller passes `--rollup`, this skill **derives a parent/container's wor
116
131
 
117
132
  **Resolve the child set the same way `lisa-linear-read-issue` does** — `lisa-linear-access operation: list-issues({project: <id>})` for a Project's Issues, or `lisa-linear-access operation: get-issue` per child for an Issue's sub-Issues (via `parentId`). Capture each child's workflow state. If the item has **no** children it is a leaf — rollup is N/A; behave as a normal milestone sync.
118
133
 
119
- **Evaluate the required children over the env ladder `in-progress < dev < staging < production` (the ordered keys of the Linear env-keyed `done` map, e.g. `On Dev < On Stg < Done`) and take the first match** (canonical roles from `config-resolution`; the Linear state map is `Blocked`, `In Progress`, `In Review`, env-keyed `done`):
134
+ **Evaluate the required children over the env ladder `in-progress < dev < staging < production` (the ordered keys of the Linear env-keyed `done` map) and take the first match** (canonical roles from `config-resolution`; the Linear state map is the configured `blocked`, `claimed`, optional `review`, and env-keyed `done` — resolve each by role, and treat an unset `review` as simply having no such rung):
120
135
 
121
136
  | If among the required child leaves… | Derived parent role | Linear state |
122
137
  |---|---|---|
123
138
  | any child carries `Blocked` | `blocked` | `Blocked` |
124
139
  | else **every** required child has shipped to some env (each at a `done`-map state, e.g. `On Dev`/`On Stg`/`Done`) | `done[min-env]` | the **least-advanced** env state among them (all `On Stg` → `On Stg`; mixed dev+staging → `On Dev`; all production → `Done`) |
125
- | else any child has **started** (`In Progress` / `In Review`, or shipped to an env while a sibling has not) | `claimed` | `In Progress` |
140
+ | else any child has **started** (at the `claimed` state, at the `review` state where the project configures one, or shipped to an env while a sibling has not) | `claimed` | the configured `claimed` state |
126
141
  | else (children exist, none started) | — | unchanged — parent keeps its non-ready container state |
127
142
 
128
143
  - **Blocked dominates** — one blocked child surfaces `Blocked` on the parent even while siblings progress. It never says *which* child or *which kind* of hold; run `scripts/rollup-blocker-classification.mjs` over the resolved child graph and carry its per-class report — blocking leaf, path, and who must act — into the rollup note. A non-zero exit means it classified nothing; that is a failure to report, never an all-clear. See `leaf-only-lifecycle` → **Classifying a hold**.
@@ -131,13 +146,14 @@ When the caller passes `--rollup`, this skill **derives a parent/container's wor
131
146
  - **Recursive** — a Project reaches an env only when its Issues have themselves rolled up to at least that env. Evaluate bottom-up.
132
147
  - **Never roll a parent into the `ready` state** — `ready` is leaf-only. Rollup only moves the parent between non-ready container states.
133
148
 
134
- **Single-environment collapse (this repo).** The env rungs resolve via the env-keyed `done` logic in `config-resolution`. In this repo `deploy.branches` declares only `production: main`, so `done` collapses to the single `Done` state, the only env rung is production, and the lifecycle is `Ready → In Progress → In Review → Done` with **no** dev/staging promotion hops; the rollup never resolves a dev or staging `done`. Multi-environment projects keep the env-keyed map and roll a parent up to the intermediate env states (`On Dev`/`On Stg`).
149
+ **Single-environment collapse (this repo).** The env rungs resolve via the env-keyed `done` logic in `config-resolution`. In this repo `deploy.branches` declares only `production: main`, so `done` collapses to the single `Done` state, the only env rung is production, and the lifecycle is `ready → claimed → done` with **no** dev/staging promotion hops and no configured `review` rung; the rollup never resolves a dev or staging `done`. Multi-environment projects keep the env-keyed map and roll a parent up to the intermediate env states.
135
150
 
136
151
  **Apply the derived state** via `lisa-linear-access operation: save-issue` (Project or Issue), setting the parent's `stateId` to the derived role. Post an idempotent rollup comment naming the derived state and the child tally. Because the terminal `done` state is itself typed `completed`, a parent rolled to terminal is natively closed by the same write — there is no second closure step. **Safe default:** if the derived terminal cannot be resolved (ambiguous required-set or unresolvable env `done`), do not guess — post the derived suggestion as a comment and leave the parent's state untouched.
137
152
 
138
153
  ## Rules
139
154
 
140
- - Never transition the Issue's workflow `state` unless the caller explicitly asks (`--update-state`, or `--rollup` for parent derivation per the `leaf-only-lifecycle` rule). Without a flag this skill only SUGGESTS a transition in its comment. The state is the lifecycle lane now, so an unrequested write here would move the item in the build queue.
155
+ - Never transition a leaf Issue's workflow `state` from this skill at all. `--rollup` (parent derivation, per the `leaf-only-lifecycle` rule) is the only write path that remains; every other milestone SUGGESTS a transition in its comment and nothing more. The state is the lifecycle lane, so a write here would move the item in the build queue.
156
+ - Never resolve a role by searching the team's states. Only a name the project configured may be written; a `type`- or position-derived match may inform a read and must never supply a write target (`config-resolution`, R2).
141
157
  - Rollup derives a *parent's* workflow state from its children and never rolls a parent into the human-owned ready lane (never `$READY`). It cites the `leaf-only-lifecycle` rule by slug rather than restating the state machine.
142
158
  - Never post empty or minimal comments — if a milestone has no meaningful content, skip the post.
143
159
  - Do not delete prior milestone comments. They are the audit trail.
@@ -56,6 +56,11 @@ const ROLE_ARN =
56
56
  /^arn:(?:aws|aws-us-gov|aws-cn):iam::\d{12}:role\/[\w+=,.@/-]+$/;
57
57
  const PUBLICATION_COORDINATION_SCOPE = "AWS_BOOTSTRAP_";
58
58
  const PUBLICATION_LOCK_TTL_MS = 30 * 60 * 1000;
59
+ // Every provider and AWS child has its own 30-second deadline. Stop starting
60
+ // target mutations five minutes before another publisher may reap this lock,
61
+ // leaving ample room for the in-flight child to finish. This is a monotonic
62
+ // duration budget, not a wall clock used to order or expire provider records.
63
+ const PUBLICATION_CRITICAL_SECTION_BUDGET_MS = 25 * 60 * 1000;
59
64
 
60
65
  /**
61
66
  * Validate and normalize one complete bundle without changing its serialized
@@ -257,6 +262,22 @@ function writeProviderValue(cfg, id, value, write) {
257
262
  }
258
263
  }
259
264
 
265
+ function publicationBudgetGuard(startedAt, monotonicNow) {
266
+ return () => {
267
+ const elapsed = monotonicNow() - startedAt;
268
+ if (!Number.isFinite(elapsed) || elapsed < 0) {
269
+ throw new Error(
270
+ "publication lock monotonic timer became invalid; refusing further provider mutation"
271
+ );
272
+ }
273
+ if (elapsed >= PUBLICATION_CRITICAL_SECTION_BUDGET_MS) {
274
+ throw new Error(
275
+ "publication lock execution budget exceeded; refusing further provider mutation"
276
+ );
277
+ }
278
+ };
279
+ }
280
+
260
281
  /**
261
282
  * Enter the provider election for one AWS bootstrap record.
262
283
  *
@@ -427,23 +448,29 @@ export function releasePublicationLock(cfg, lock, operations = {}) {
427
448
  * snapshot can never overwrite a publication that finished while this process
428
449
  * was entering the election.
429
450
  */
430
- function withPublicationLock(cfg, operations, task) {
451
+ function withPublicationLock(cfg, operations, task, completedMessage) {
431
452
  const fetch = operations.fetch ?? fetchAll;
432
453
  const acquire = operations.acquireLock ?? acquirePublicationLock;
433
454
  const release = operations.releaseLock ?? releasePublicationLock;
455
+ const monotonicNow = operations.monotonicNow ?? (() => performance.now());
456
+ const startedAt = monotonicNow();
457
+ const assertWithinBudget = publicationBudgetGuard(startedAt, monotonicNow);
434
458
  const target = currentEntry(cfg, fetch);
435
459
  const lock = acquire(cfg, target, operations);
436
460
  let result;
437
461
  let taskError;
438
462
 
439
463
  try {
464
+ assertWithinBudget();
440
465
  const current = currentEntry(cfg, fetch);
441
466
  if (current.id !== target.id || current.projectId !== target.projectId) {
442
467
  throw new Error(
443
468
  `${BOOTSTRAP_KEY} moved after publication lock acquisition; retry from the new provider record`
444
469
  );
445
470
  }
446
- result = task(current);
471
+ assertWithinBudget();
472
+ result = task(current, assertWithinBudget);
473
+ assertWithinBudget();
447
474
  } catch (error) {
448
475
  taskError = error;
449
476
  }
@@ -455,15 +482,18 @@ function withPublicationLock(cfg, operations, task) {
455
482
  if (taskError) {
456
483
  throw new Error(`${firstLine(taskError)}; ${releaseReason}`);
457
484
  }
458
- throw new Error(releaseReason);
485
+ throw new Error(
486
+ `${completedMessage}; ${releaseReason}; remove the coordination record before the next publication`
487
+ );
459
488
  }
460
489
 
461
490
  if (taskError) throw taskError;
462
491
  return result;
463
492
  }
464
493
 
465
- function preflightUnderLock(cfg, entry, write) {
494
+ function preflightUnderLock(cfg, entry, write, assertWithinBudget) {
466
495
  try {
496
+ assertWithinBudget();
467
497
  writeProviderValue(cfg, entry.id, entry.value, write);
468
498
  } catch (error) {
469
499
  throw new Error(
@@ -482,8 +512,12 @@ function preflightUnderLock(cfg, entry, write) {
482
512
  */
483
513
  export function preflightAwsBootstrap(cfg, operations = {}) {
484
514
  const write = operations.write ?? writeSecret;
485
- return withPublicationLock(cfg, operations, entry =>
486
- preflightUnderLock(cfg, entry, write)
515
+ return withPublicationLock(
516
+ cfg,
517
+ operations,
518
+ (entry, assertWithinBudget) =>
519
+ preflightUnderLock(cfg, entry, write, assertWithinBudget),
520
+ "preflight completed"
487
521
  );
488
522
  }
489
523
 
@@ -496,12 +530,20 @@ export function preflightAwsBootstrap(cfg, operations = {}) {
496
530
  * @param {Function} write Provider writer.
497
531
  * @returns {string} Human-readable rollback outcome with no secret material.
498
532
  */
499
- function rollback(cfg, previous, candidateRaw, fetch, write) {
533
+ function rollback(
534
+ cfg,
535
+ previous,
536
+ candidateRaw,
537
+ fetch,
538
+ write,
539
+ assertWithinBudget
540
+ ) {
500
541
  try {
501
542
  const current = currentEntry(cfg, fetch);
502
543
  if (current.value !== candidateRaw) {
503
544
  return "rollback skipped because the provider changed after this publication";
504
545
  }
546
+ assertWithinBudget();
505
547
  writeProviderValue(cfg, previous.id, previous.value, write);
506
548
  const restored = currentEntry(cfg, fetch);
507
549
  if (restored.value !== previous.value) {
@@ -528,47 +570,56 @@ export function publishAwsBootstrap(raw, cfg, operations = {}) {
528
570
  const write = operations.write ?? writeSecret;
529
571
  const verify = operations.verify ?? verifyAwsBootstrap;
530
572
  const candidate = validateAwsBootstrap(raw);
531
- return withPublicationLock(cfg, operations, previous => {
532
- preflightUnderLock(cfg, previous, write);
533
-
534
- // Verify before the write. A candidate that cannot assume one of its roles
535
- // never becomes the value every new remote session receives.
536
- verify(candidate);
537
-
538
- if (candidate.raw === previous.value) {
539
- return {
540
- changed: false,
541
- profiles: candidate.profiles.map(profile => profile.name),
542
- };
543
- }
573
+ return withPublicationLock(
574
+ cfg,
575
+ operations,
576
+ (previous, assertWithinBudget) => {
577
+ preflightUnderLock(cfg, previous, write, assertWithinBudget);
578
+
579
+ // Verify before the write. A candidate that cannot assume one of its roles
580
+ // never becomes the value every new remote session receives.
581
+ verify(candidate);
582
+ assertWithinBudget();
583
+
584
+ if (candidate.raw === previous.value) {
585
+ return {
586
+ changed: false,
587
+ profiles: candidate.profiles.map(profile => profile.name),
588
+ };
589
+ }
544
590
 
545
- try {
546
- writeProviderValue(cfg, previous.id, candidate.raw, write);
547
- const stored = currentEntry(cfg, fetch);
548
- if (stored.value !== candidate.raw) {
591
+ try {
592
+ assertWithinBudget();
593
+ writeProviderValue(cfg, previous.id, candidate.raw, write);
594
+ const stored = currentEntry(cfg, fetch);
595
+ if (stored.value !== candidate.raw) {
596
+ throw new Error(
597
+ "provider read-back did not match the candidate exactly"
598
+ );
599
+ }
600
+ const storedCandidate = validateAwsBootstrap(stored.value);
601
+ verify(storedCandidate);
602
+ assertWithinBudget();
603
+ return {
604
+ changed: true,
605
+ profiles: storedCandidate.profiles.map(profile => profile.name),
606
+ };
607
+ } catch (error) {
608
+ const rollbackOutcome = rollback(
609
+ cfg,
610
+ previous,
611
+ candidate.raw,
612
+ fetch,
613
+ write,
614
+ assertWithinBudget
615
+ );
549
616
  throw new Error(
550
- "provider read-back did not match the candidate exactly"
617
+ `${BOOTSTRAP_KEY} publication failed: ${firstLine(error)}; ${rollbackOutcome}`
551
618
  );
552
619
  }
553
- const storedCandidate = validateAwsBootstrap(stored.value);
554
- verify(storedCandidate);
555
- return {
556
- changed: true,
557
- profiles: storedCandidate.profiles.map(profile => profile.name),
558
- };
559
- } catch (error) {
560
- const rollbackOutcome = rollback(
561
- cfg,
562
- previous,
563
- candidate.raw,
564
- fetch,
565
- write
566
- );
567
- throw new Error(
568
- `${BOOTSTRAP_KEY} publication failed: ${firstLine(error)}; ${rollbackOutcome}`
569
- );
570
- }
571
- });
620
+ },
621
+ "publication completed"
622
+ );
572
623
  }
573
624
 
574
625
  /** Read a candidate from stdin without ever placing it in argv. */
@@ -21,7 +21,27 @@ See the `config-resolution` rule for configuration and dispatch table.
21
21
  - Anything else → stop and report `"Unknown tracker '<value>' in .lisa.config.json. Expected 'jira', 'github', or 'linear'."`
22
22
  3. Pass through the output.
23
23
 
24
- `$ARGUMENTS` is forwarded verbatim, including the optional `--rollup` flag (see "Parent status rollup" below), the vendor lane-write flag (`--update-label` on GitHub, `--update-state` on Linear), `pr_url=<url>`, and `merge_sha=<sha>`. The shim never interprets these — the vendor skill does.
24
+ `$ARGUMENTS` is forwarded verbatim, including the optional `--rollup` flag (see "Parent status rollup" below), `pr_url=<url>`, and `merge_sha=<sha>`. The shim never interprets these — the vendor skill does.
25
+
26
+ > **There is no per-milestone lane-write flag on any vendor.** This paragraph previously documented `--update-label` on GitHub and `--update-state` on Linear. `--update-label` was never implemented — `lisa-github-sync` says flatly *"This skill never relabels"* — so a caller passing it got a silent no-op on GitHub and a real write on Linear for the equivalent flag. Both are gone. `--rollup` is the only write path a sync skill has.
27
+
28
+ ## Tracker status vocabulary — binding on all three arms
29
+
30
+ Every suggested or performed transition names **only a role the project configured**, never a status, state or label discovered from the tracker's live workflow (transition lists, board columns, `type`-derived matches, other tickets). This binds a lead performing tracker writes exactly as it binds a subagent.
31
+
32
+ Two consequences the vendor arms must not restate differently:
33
+
34
+ - **A milestone whose role is unset gets a comment, not a transition.** `review` and the `qa.*` roles are optional and carry **no default** — omitting one means the project does not run that step, and the lifecycle skips it. "Unset" must never resolve to a built-in name; that is the difference between "not customized" and "we deliberately don't do this".
35
+ - **A fallback may inform a read; it may never supply a write target.** Where a vendor exposes one (Linear resolves states by `type`), it selects by board position rather than intent — so on a board carrying more than one plausible state it returns whichever sits earliest, which is exactly the human-only lane a project left out of its config on purpose.
36
+
37
+ Resolve roles through the shared resolver rather than an inlined helper:
38
+
39
+ ```bash
40
+ node "${CLAUDE_PLUGIN_ROOT}/scripts/resolve-lifecycle-role.mjs" \
41
+ --role <role> --vendor <jira|linear|github> --intent <read|write> [--env <env>]
42
+ ```
43
+
44
+ Exit `0` with a value means configured; exit `0` with **empty** output means an optional role is unset — skip the transition; exit `2` means a required role is unset or a write was refused a fallback value.
25
45
 
26
46
  If `$ARGUMENTS` is empty, all vendor skills auto-detect a ticket reference from the active plan file (most recently modified `.md` in `plans/`).
27
47
 
@@ -65,6 +85,6 @@ This is the reverse half of `lisa-git-submit-pr`'s PR body linkage. A PR that me
65
85
  ## Rules
66
86
 
67
87
  - Idempotent updates — running sync at the same milestone twice should not produce duplicate comments. Vendor skills enforce this.
68
- - Never auto-transition a lane the caller did not ask for. On GitHub the canonical signal is the `status:*` label, written only with `--update-label`; on Linear it is the native workflow state, written only with `--update-state`. Without the flag, every vendor arm only suggests.
88
+ - Never transition a leaf's lane from a sync milestone on any vendor. The canonical signal differs — the `status:*` label on GitHub, the workflow status on JIRA, the native workflow state on Linear — but the owner is the same everywhere: build-intake / the vendor agent. Every arm only suggests. `--rollup` (parent derivation) is the sole exception, and it derives the parent from its children rather than advancing a leaf.
69
89
  - Parent rollup derives state from children per the `leaf-only-lifecycle` rule; it never sets a parent to `ready` and never resolves a dev/staging `done` in this single-environment repo.
70
90
  - Pull request backlinks are mandatory when `pr_url=<url>` is present: native first, managed-comment fallback, never silently dropped.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-expo",
3
- "version": "4.22.7",
3
+ "version": "4.22.9",
4
4
  "description": "Expo/React Native-specific skills, agents, rules, and MCP servers",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-expo",
3
- "version": "4.22.7",
3
+ "version": "4.22.9",
4
4
  "description": "Expo and React Native-specific skills, agents, rules, and MCP servers.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-expo",
3
- "version": "4.22.7",
3
+ "version": "4.22.9",
4
4
  "description": "Expo/React Native-specific skills, agents, rules, and MCP servers",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-expo",
3
- "version": "4.22.7",
3
+ "version": "4.22.9",
4
4
  "description": "Expo/React Native-specific skills, agents, rules, and MCP servers",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-expo",
3
- "version": "4.22.7",
3
+ "version": "4.22.9",
4
4
  "description": "Expo/React Native-specific skills, agents, rules, and MCP servers",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-harper-fabric",
3
- "version": "4.22.7",
3
+ "version": "4.22.9",
4
4
  "description": "Harper/Fabric-specific rules for TypeScript component apps",
5
5
  "author": {
6
6
  "name": "Cody Swann"