@codyswann/lisa 4.26.2 → 4.26.4

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 (81) hide show
  1. package/all/copy-overwrite/scripts/lisa-work-item.mjs +248 -21
  2. package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
  3. package/dist/core/lisa-owned-hash-ledger.js +2 -0
  4. package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
  5. package/dist/core/nightly-e2e-guard-behavior-certificate.js +2 -2
  6. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  7. package/dist/core/upstream-evidence-manifest.js +6 -5
  8. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  9. package/package.json +3 -3
  10. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  11. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  12. package/plugins/lisa/.codex-plugin/skills/lisa-github-build-intake/SKILL.md +40 -6
  13. package/plugins/lisa/.codex-plugin/skills/lisa-jira-journey/SKILL.md +24 -2
  14. package/plugins/lisa/.codex-plugin/skills/lisa-jira-journey/scripts/parse-plan.py +144 -30
  15. package/plugins/lisa/rules/eager/leaf-only-lifecycle.md +16 -0
  16. package/plugins/lisa/skills/lisa-github-build-intake/SKILL.md +40 -6
  17. package/plugins/lisa/skills/lisa-jira-journey/SKILL.md +24 -2
  18. package/plugins/lisa/skills/lisa-jira-journey/scripts/parse-plan.py +144 -30
  19. package/plugins/lisa-agy/plugin.json +1 -1
  20. package/plugins/lisa-agy/skills/lisa-github-build-intake/SKILL.md +40 -6
  21. package/plugins/lisa-agy/skills/lisa-jira-journey/SKILL.md +24 -2
  22. package/plugins/lisa-agy/skills/lisa-jira-journey/scripts/parse-plan.py +144 -30
  23. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  24. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  25. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  26. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  27. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  28. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  29. package/plugins/lisa-copilot/rules/eager/leaf-only-lifecycle.md +16 -0
  30. package/plugins/lisa-copilot/skills/lisa-github-build-intake/SKILL.md +40 -6
  31. package/plugins/lisa-copilot/skills/lisa-jira-journey/SKILL.md +24 -2
  32. package/plugins/lisa-copilot/skills/lisa-jira-journey/scripts/parse-plan.py +144 -30
  33. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  34. package/plugins/lisa-cursor/rules/leaf-only-lifecycle.mdc +16 -0
  35. package/plugins/lisa-cursor/skills/lisa-github-build-intake/SKILL.md +40 -6
  36. package/plugins/lisa-cursor/skills/lisa-jira-journey/SKILL.md +24 -2
  37. package/plugins/lisa-cursor/skills/lisa-jira-journey/scripts/parse-plan.py +144 -30
  38. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  39. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  40. package/plugins/lisa-expo-agy/plugin.json +1 -1
  41. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  42. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  43. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  44. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  45. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  46. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  47. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  48. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  49. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  50. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  51. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  52. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  53. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  54. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  55. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  56. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  57. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  58. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  59. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  60. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  61. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  62. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  63. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  64. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  65. package/plugins/lisa-rails-agy/plugin.json +1 -1
  66. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  67. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  68. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  69. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  70. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  71. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  72. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  73. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  74. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  75. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  76. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  77. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  78. package/plugins/src/base/rules/eager/leaf-only-lifecycle.md +16 -0
  79. package/plugins/src/base/skills/lisa-github-build-intake/SKILL.md +40 -6
  80. package/plugins/src/base/skills/lisa-jira-journey/SKILL.md +24 -2
  81. package/plugins/src/base/skills/lisa-jira-journey/scripts/parse-plan.py +144 -30
@@ -25,6 +25,7 @@ Output (JSON):
25
25
  }
26
26
  """
27
27
 
28
+ import ipaddress
28
29
  import json
29
30
  import os
30
31
  import re
@@ -41,6 +42,24 @@ from urllib.parse import urlsplit
41
42
  # a capture obligation.
42
43
  LOCAL_EVIDENCE_PATTERN = re.compile(r'\[(SCREENSHOT|EVIDENCE):\s*([^\]]+)\]')
43
44
 
45
+ # One ASCII DNS name: dot-separated labels of letters, digits and inner hyphens.
46
+ # IPv4 literals are a subset of this form. Anything else -- percent-encoding, a
47
+ # trailing dot, an empty label, a leading or trailing hyphen -- is refused,
48
+ # because a hostname this pattern cannot describe is one whose meaning depends
49
+ # on who resolves it.
50
+ DNS_NAME_PATTERN = re.compile(
51
+ r'^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$'
52
+ )
53
+
54
+ # The only two path spellings that mean "no path".
55
+ ROOT_PATHS = ("", "/")
56
+
57
+ DEFAULT_HTTPS_PORT = 443
58
+
59
+
60
+ class JiraConfigError(Exception):
61
+ """A configured Jira server could not be established as trusted."""
62
+
44
63
 
45
64
  def jira_config_candidates():
46
65
  """Return project-first jira-cli config candidates without duplicates."""
@@ -80,63 +99,158 @@ def read_jira_config(config_path):
80
99
  return server, login
81
100
 
82
101
 
102
+ def canonical_authority(netloc, hostname):
103
+ """Return the canonical authority for one host, or "" when it is not one."""
104
+ if netloc.startswith("["):
105
+ try:
106
+ return f"[{ipaddress.IPv6Address(hostname).compressed}]"
107
+ except ValueError:
108
+ return ""
109
+ return hostname if DNS_NAME_PATTERN.match(hostname) else ""
110
+
111
+
112
+ def is_bare_origin(server, parsed):
113
+ """Report whether the raw value names an origin and nothing besides.
114
+
115
+ The query and fragment are checked against the raw string rather than the
116
+ parse, because ``https://host?`` and ``https://host`` parse to the same
117
+ empty query. A delimiter carrying nothing is still a component the operator
118
+ wrote and the trust key would discard.
119
+ """
120
+ if "?" in server or "#" in server:
121
+ return False
122
+ if parsed.scheme != "https" or "@" in parsed.netloc:
123
+ return False
124
+ return parsed.path in ROOT_PATHS
125
+
126
+
83
127
  def server_origin(server):
84
- """Return the normalized HTTPS origin used as the credential trust key."""
128
+ """Return the canonical HTTPS origin, or "" when the value is not one.
129
+
130
+ This value is both the credential trust key and the base every request is
131
+ built from, so the two have to be the same string. A normalizer that reduced
132
+ an arbitrary URL to its origin would approve one value and send another:
133
+ userinfo, a query, a fragment, or a path would ride along into the request
134
+ while the trust check saw only the host, which is how an API token reaches a
135
+ URL that validation never approved. So the accepted grammar is a bare
136
+ origin -- ``https://host`` or ``https://host/`` with an optional non-default
137
+ port -- and every richer form is refused rather than trimmed down to fit.
138
+
139
+ Self-hosted context paths and internationalized hostnames are deliberately
140
+ out of scope: either needs its own base-path contract, and inventing one
141
+ here would re-open the gap between what is checked and what is sent.
142
+ """
143
+ if not server or any(char <= " " or char >= "\x7f" for char in server):
144
+ return ""
85
145
  try:
86
146
  parsed = urlsplit(server)
87
147
  port = parsed.port
88
148
  except ValueError:
89
149
  return ""
90
- if parsed.scheme.lower() != "https" or not parsed.hostname:
150
+ if not parsed.hostname or port == 0:
151
+ return ""
152
+ if not is_bare_origin(server, parsed):
91
153
  return ""
92
- if port == 0:
154
+ authority = canonical_authority(parsed.netloc, parsed.hostname)
155
+ if not authority:
93
156
  return ""
94
- hostname = parsed.hostname.lower()
95
- authority = f"[{hostname}]" if ":" in hostname else hostname
96
- if port is not None and port != 443:
157
+ if port is not None and port != DEFAULT_HTTPS_PORT:
97
158
  authority = f"{authority}:{port}"
98
159
  return f"https://{authority}"
99
160
 
100
161
 
101
- def resolve_jira_config_path():
102
- """Resolve a project config only when its host matches a user trust root."""
162
+ def resolve_trusted_origin():
163
+ """Return the operator-owned origin a checkout config must match, or "".
164
+
165
+ An explicit JIRA_SERVER wins over the home config. A JIRA_SERVER that is not
166
+ a canonical origin raises rather than resolving to "": degrading a malformed
167
+ trust root into "no trust root" would silently widen what a checkout config
168
+ is allowed to claim, which is the opposite of what setting it asked for.
169
+ """
170
+ configured = os.environ.get("JIRA_SERVER", "")
171
+ if configured.strip():
172
+ origin = server_origin(configured)
173
+ if not origin:
174
+ raise JiraConfigError(
175
+ "JIRA_SERVER must be a valid HTTPS URL naming one origin, with "
176
+ "no userinfo, path, query or fragment"
177
+ )
178
+ return origin
179
+
180
+ home_config = (Path.home() / ".config" / ".jira" / ".config.yml").resolve()
181
+ if not home_config.exists():
182
+ return ""
183
+ return server_origin(read_jira_config(home_config)[0])
184
+
185
+
186
+ def resolve_jira_config_path(trusted_origin=None):
187
+ """Select the jira-cli config that governs this checkout.
188
+
189
+ The nearest checkout-owned config is decisive. When one exists it must match
190
+ the operator's trust root, and a mismatch fails naming that file. Falling
191
+ through to the home config instead would report a missing or unusable home
192
+ config for a project config that was found and refused -- an error about the
193
+ wrong file, pointing whoever has to fix it away from the one that is wrong.
194
+
195
+ Args:
196
+ trusted_origin: The operator's trust root, resolved when not supplied.
197
+ """
103
198
  candidates = jira_config_candidates()
104
199
  home_config = candidates[-1]
105
- trusted_server = os.environ.get("JIRA_SERVER", "").strip()
106
- if not trusted_server and home_config.exists():
107
- trusted_server, _ = read_jira_config(home_config)
108
- trusted_origin = server_origin(trusted_server)
200
+ if trusted_origin is None:
201
+ trusted_origin = resolve_trusted_origin()
109
202
 
110
203
  for candidate in candidates[:-1]:
111
204
  if not candidate.exists():
112
205
  continue
113
- candidate_server, _ = read_jira_config(candidate)
114
- if trusted_origin and server_origin(candidate_server) == trusted_origin:
115
- return candidate
206
+ if not trusted_origin:
207
+ raise JiraConfigError(
208
+ f"jira-cli config {candidate} cannot be trusted: neither "
209
+ "JIRA_SERVER nor a home config establishes a trust root"
210
+ )
211
+ if server_origin(read_jira_config(candidate)[0]) != trusted_origin:
212
+ raise JiraConfigError(
213
+ f"jira-cli config {candidate} server does not match the "
214
+ "trusted JIRA server origin"
215
+ )
216
+ return candidate
116
217
  return home_config
117
218
 
118
219
 
119
220
  def get_jira_config():
120
- """Read JIRA server and login from the project-first jira-cli config."""
121
- config_path = resolve_jira_config_path()
221
+ """Read JIRA server and login from the project-first jira-cli config.
222
+
223
+ Every exit below happens before the token is read, so a configured URL that
224
+ fails validation cannot reach a request. The returned server is the
225
+ canonical origin, never the configured string, which is what keeps the value
226
+ that was checked and the value that is sent identical.
227
+ """
228
+ try:
229
+ trusted_origin = resolve_trusted_origin()
230
+ config_path = resolve_jira_config_path(trusted_origin)
231
+ except JiraConfigError as error:
232
+ print(f"ERROR: {error}", file=sys.stderr)
233
+ sys.exit(1)
234
+
122
235
  if not config_path.exists():
123
236
  print(f"ERROR: jira-cli config not found at {config_path}", file=sys.stderr)
124
237
  sys.exit(1)
125
238
 
126
239
  server, login = read_jira_config(config_path)
127
-
128
- trusted_server = os.environ.get("JIRA_SERVER", "").strip()
129
- trusted_origin = server_origin(trusted_server)
130
- if trusted_server and not trusted_origin:
131
- print("ERROR: JIRA_SERVER must be a valid HTTPS URL", file=sys.stderr)
240
+ origin = server_origin(server)
241
+ if not origin:
242
+ print(
243
+ f"ERROR: JIRA server in {config_path} must be an HTTPS URL naming "
244
+ "one origin, with no userinfo, path, query or fragment",
245
+ file=sys.stderr,
246
+ )
132
247
  sys.exit(1)
133
- if trusted_origin:
134
- if server_origin(server) != trusted_origin:
135
- print("ERROR: jira-cli config server does not match trusted JIRA_SERVER", file=sys.stderr)
136
- sys.exit(1)
137
- server = trusted_server.rstrip("/")
138
- if not server_origin(server):
139
- print("ERROR: JIRA server must be an HTTPS URL from a trusted config", file=sys.stderr)
248
+ if trusted_origin and origin != trusted_origin:
249
+ print(
250
+ f"ERROR: jira-cli config {config_path} server does not match the "
251
+ "trusted JIRA server origin",
252
+ file=sys.stderr,
253
+ )
140
254
  sys.exit(1)
141
255
 
142
256
  token = os.environ.get("JIRA_API_TOKEN", "")
@@ -144,7 +258,7 @@ def get_jira_config():
144
258
  print("ERROR: JIRA_API_TOKEN env var not set", file=sys.stderr)
145
259
  sys.exit(1)
146
260
 
147
- return server, login, token
261
+ return origin, login, token
148
262
 
149
263
 
150
264
  def fetch_ticket(server, login, token, ticket_id):
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa",
3
- "version": "4.26.2",
3
+ "version": "4.26.4",
4
4
  "description": "Universal governance — agents, skills, commands, hooks, and rules for all projects",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -431,14 +431,26 @@ Run this only when the returned triage verdict is exactly `DUPLICATE_ALREADY_FIX
431
431
  2. Post or preserve the triage-finding comment that explains why this issue is a duplicate and names the canonical issue.
432
432
  3. Ensure a native `duplicates <canonical>` link exists when GitHub exposes issue relationships; if this installation cannot create that relationship, leave an explicit issue cross-reference comment/body link and record the limitation in the summary.
433
433
  4. Resolve terminal `$DONE` exactly as in Phase 3d. For a single-env repo, `$DONE` is terminal; for env-keyed config, only the production/final value is terminal.
434
- 5. Replace `$CLAIMED` with `$DONE`, then close the issue as duplicate/not-planned:
434
+ 5. Apply `$DONE` and retire every competing lifecycle role the issue currently carries — not just `$CLAIMED` — per the `leaf-only-lifecycle` rule's "Exactly one lifecycle role survives closure". Then close the issue as duplicate/not-planned:
435
435
 
436
436
  ```bash
437
- gh issue edit <number> --repo <org>/<repo> --remove-label "$CLAIMED" --add-label "$DONE"
438
- gh issue comment <number> --repo <org>/<repo> --body "[claude-build-intake] Closed as duplicate of <canonical>. Canonical fix: <PR-or-commit>. Evidence: <base-branch-proof>."
437
+ gh issue view <number> --repo <org>/<repo> --json labels -q '[.labels[].name] | join(" ")'
438
+ gh issue edit <number> --repo <org>/<repo> --add-label "$DONE" <one --remove-label "<role>" per competing role the issue CURRENTLY carries>
439
439
  gh issue close <number> --repo <org>/<repo> --reason "not planned"
440
440
  ```
441
441
 
442
+ 6. **Confirm with an independent readback, and only then announce** — the same obligation as Phase 3d step 5, for the same reason. Re-read the issue instead of trusting what the edit and close reported:
443
+
444
+ ```bash
445
+ gh issue view <number> --repo <org>/<repo> --json state,stateReason,labels
446
+ ```
447
+
448
+ It must read `state: CLOSED` with `stateReason: NOT_PLANNED`, carry `$DONE`, and carry **no** other lifecycle role. Anything else is an Error naming what is still present. Post the closeout comment only after that passes:
449
+
450
+ ```bash
451
+ gh issue comment <number> --repo <org>/<repo> --body "[claude-build-intake] Closed as duplicate of <canonical>. Canonical fix: <PR-or-commit>. Evidence: <base-branch-proof>."
452
+ ```
453
+
442
454
  If the canonical fix is merged but not yet present on the production branch, append the production-promotion caveat to the close comment: the production error can recur until the canonical issue promotes, and recurrence is tracked by the canonical issue rather than by reopening this duplicate.
443
455
 
444
456
  This path is distinct from `BLOCKED`: ambiguity, open blockers, and duplicate-of-open findings remain held for human action and must not be auto-closed.
@@ -475,11 +487,15 @@ If the lifecycle run returned Success:
475
487
  - If `github.labels.build.done` is an object, only the production/final environment value is terminal (default: `status:done`). Intermediate env values such as `status:on-dev` and `status:on-stg` are not terminal and must stay open.
476
488
  - If the project uses a different final environment name, resolve it from the configured deployment topology; if ambiguous, record an Error and do not close.
477
489
 
490
+ 4. **Retire every competing lifecycle role**, per the `leaf-only-lifecycle` rule's "Exactly one lifecycle role survives closure" — cite it, do not restate it. Read the issue's current labels, intersect them with the configured role set (`$READY`, `$CLAIMED`, `$BLOCKED`, `$REVIEW` where bound, and every env-keyed done value), and remove each one that is present except `$DONE`. Removing only `$CLAIMED` leaves a stale `$READY` on any item that reached completion without passing through the claimed lane, and a closed item still reading ready is re-dispatched by this very scanner on its next cycle.
491
+
478
492
  ```bash
479
- gh issue edit <number> --repo <org>/<repo> --remove-label "$CLAIMED" --add-label "$DONE"
480
- gh issue comment <number> --repo <org>/<repo> --body "[claude-build-intake] Build complete. PR <URL> merged. Transitioned to $DONE."
493
+ gh issue view <number> --repo <org>/<repo> --json labels -q '[.labels[].name] | join(" ")'
494
+ gh issue edit <number> --repo <org>/<repo> --add-label "$DONE" <one --remove-label "<role>" per competing role the issue CURRENTLY carries>
481
495
  ```
482
496
 
497
+ Name only roles the read actually returned: `--remove-label` on a label the issue does not carry is a 404, which turns a clean transition into an error and makes a repeat cycle fail where the first succeeded.
498
+
483
499
  If `$DONE` is terminal, immediately close the native GitHub issue:
484
500
 
485
501
  ```bash
@@ -488,6 +504,24 @@ gh issue close <number> --repo <org>/<repo> --reason completed
488
504
 
489
505
  This close is idempotent: if the issue is already closed, record that native closure was already satisfied and continue. If `$DONE` is an intermediate env state, leave the issue open by design.
490
506
 
507
+ 5. **Confirm with an independent readback, and only then announce.** Re-read the issue rather than trusting what the edit and close commands reported about themselves:
508
+
509
+ ```bash
510
+ gh issue view <number> --repo <org>/<repo> --json state,stateReason,labels
511
+ ```
512
+
513
+ The item must carry `$DONE` and **no** other lifecycle role, and — when `$DONE` is terminal — read `state: CLOSED`. Anything else is recorded as an Error naming the roles still present; do not report the transition as applied on the strength of a write that said it worked.
514
+
515
+ Post the completion comment **only after** that readback passes:
516
+
517
+ ```bash
518
+ gh issue comment <number> --repo <org>/<repo> --body "[claude-build-intake] Build complete. PR <URL> merged. Transitioned to $DONE."
519
+ ```
520
+
521
+ The ordering is the point. Announcing before verifying leaves a public "Build complete" on an item whose lifecycle state was never confirmed, and a reader has no way to tell that claim apart from a verified one — the same indistinguishability the readback exists to prevent. On a failed readback, comment the Error instead.
522
+
523
+ `node scripts/lisa-work-item.mjs complete --ref <org>/<repo>#<number>` performs this whole terminal sequence — merged-PR evidence check, full role reconciliation, close, independent readback — in one step, and is the preferred path whenever `$DONE` is the terminal value. Use the manual commands above for intermediate env transitions, which must not close the issue.
524
+
491
525
  For any non-Success outcome, do NOT transition. The issue sits in `$CLAIMED` (or wherever the lifecycle left it) — humans take it from there.
492
526
 
493
527
  #### 3d.1 Roll up the parent chain (forward rollup)
@@ -541,7 +575,7 @@ Total PRs opened: <n>
541
575
  - **Claim-first ordering**: `$CLAIMED` set BEFORE the lifecycle dispatch for leaves; containers are also moved to `$CLAIMED` to leave the ready pickup queue, but are not dispatched.
542
576
  - **No writes outside the lifecycle**: this skill only relabels `$READY → $CLAIMED` and `$CLAIMED → $DONE`. For containers, `$READY → $CLAIMED` is a lifecycle repair, not a direct build claim. Every other label change is owned by the per-issue lifecycle (github-agent workflow).
543
577
  - **Duplicate terminal exception**: `DUPLICATE_ALREADY_FIXED` is the only triage outcome that may close a claimed item without a PR from this cycle. It must include a canonical issue reference and empirical base-branch evidence, and it closes as duplicate/not-planned rather than as completed build work.
544
- - **Terminal native closure**: after `$CLAIMED → $DONE`, close the GitHub issue only when `$DONE` is the true terminal done value per `leaf-only-lifecycle`; intermediate env labels stay open.
578
+ - **Terminal native closure**: after applying `$DONE` and retiring every competing lifecycle role (`leaf-only-lifecycle`, "Exactly one lifecycle role survives closure"), close the GitHub issue only when `$DONE` is the true terminal done value per `leaf-only-lifecycle`; intermediate env labels stay open. Confirm the result with an independent readback, never with the write's own response.
545
579
  - **One item per cycle**: per-issue exceptions are caught and recorded, then the cycle exits. The scheduler owns retrying or moving on to the next ready item.
546
580
  - **Single cycle per repo**: do not run two `lisa-github-build-intake` cycles in parallel against the same repo — concurrent claims could race. The scheduling layer is responsible for serialization.
547
581
  - **Single-label invariant**: after every transition, verify exactly one `status:*` label is present on the issue. If two are present (rare race), surface as an Error and skip — do NOT auto-resolve.
@@ -37,8 +37,30 @@ python3 .claude/skills/jira-journey/scripts/parse-plan.py <TICKET_ID>
37
37
 
38
38
  The parser resolves Jira configuration from the checkout first: it searches
39
39
  `$CLAUDE_PROJECT_DIR/.lisa/jira-cli/.config.yml`, then the current directory and
40
- its parents, and only then falls back to `~/.config/.jira/.config.yml`. Do not
41
- copy project settings into the home-level file just to make this step work.
40
+ its parents, and uses `~/.config/.jira/.config.yml` when no checkout config
41
+ exists. Do not copy project settings into the home-level file just to make this
42
+ step work.
43
+
44
+ A checkout config is not automatically trusted. The first checkout candidate
45
+ that exists is decisive — that is `$CLAUDE_PROJECT_DIR` when it is set, which
46
+ outranks a config nearer the current directory, and only otherwise the nearest
47
+ one walking up from the current directory. Its server must match the operator's
48
+ trust root — `JIRA_SERVER` when set, otherwise the home config — and a mismatch
49
+ fails naming that file rather than quietly using the home config instead, so the
50
+ error points at the file that is actually wrong.
51
+
52
+ `server` must be a bare HTTPS origin: `https://host`, optionally with a port and
53
+ a single trailing slash. An explicit `:443` is accepted and canonicalized away,
54
+ so `https://host:443` and `https://host` are the same origin and either may be
55
+ configured. Userinfo (`https://user:token@…`), a query, a fragment, and any
56
+ other path are refused rather than trimmed away. So are a hostname this parser
57
+ cannot describe unambiguously — surrounding whitespace, control characters,
58
+ percent-encoded or non-ASCII hostnames, a trailing dot, and port 0.
59
+ The reason is that this value is both the trust key and the base every request
60
+ is built from; normalizing a richer URL down to its origin would approve one
61
+ value and then send the API token to a different one. Self-hosted context paths
62
+ and internationalized hostnames are out of scope and would need their own
63
+ base-path contract.
42
64
 
43
65
  The script outputs JSON with: `ticket`, `prerequisites`, `steps`, `viewports`, `assertions`.
44
66
 
@@ -25,6 +25,7 @@ Output (JSON):
25
25
  }
26
26
  """
27
27
 
28
+ import ipaddress
28
29
  import json
29
30
  import os
30
31
  import re
@@ -41,6 +42,24 @@ from urllib.parse import urlsplit
41
42
  # a capture obligation.
42
43
  LOCAL_EVIDENCE_PATTERN = re.compile(r'\[(SCREENSHOT|EVIDENCE):\s*([^\]]+)\]')
43
44
 
45
+ # One ASCII DNS name: dot-separated labels of letters, digits and inner hyphens.
46
+ # IPv4 literals are a subset of this form. Anything else -- percent-encoding, a
47
+ # trailing dot, an empty label, a leading or trailing hyphen -- is refused,
48
+ # because a hostname this pattern cannot describe is one whose meaning depends
49
+ # on who resolves it.
50
+ DNS_NAME_PATTERN = re.compile(
51
+ r'^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$'
52
+ )
53
+
54
+ # The only two path spellings that mean "no path".
55
+ ROOT_PATHS = ("", "/")
56
+
57
+ DEFAULT_HTTPS_PORT = 443
58
+
59
+
60
+ class JiraConfigError(Exception):
61
+ """A configured Jira server could not be established as trusted."""
62
+
44
63
 
45
64
  def jira_config_candidates():
46
65
  """Return project-first jira-cli config candidates without duplicates."""
@@ -80,63 +99,158 @@ def read_jira_config(config_path):
80
99
  return server, login
81
100
 
82
101
 
102
+ def canonical_authority(netloc, hostname):
103
+ """Return the canonical authority for one host, or "" when it is not one."""
104
+ if netloc.startswith("["):
105
+ try:
106
+ return f"[{ipaddress.IPv6Address(hostname).compressed}]"
107
+ except ValueError:
108
+ return ""
109
+ return hostname if DNS_NAME_PATTERN.match(hostname) else ""
110
+
111
+
112
+ def is_bare_origin(server, parsed):
113
+ """Report whether the raw value names an origin and nothing besides.
114
+
115
+ The query and fragment are checked against the raw string rather than the
116
+ parse, because ``https://host?`` and ``https://host`` parse to the same
117
+ empty query. A delimiter carrying nothing is still a component the operator
118
+ wrote and the trust key would discard.
119
+ """
120
+ if "?" in server or "#" in server:
121
+ return False
122
+ if parsed.scheme != "https" or "@" in parsed.netloc:
123
+ return False
124
+ return parsed.path in ROOT_PATHS
125
+
126
+
83
127
  def server_origin(server):
84
- """Return the normalized HTTPS origin used as the credential trust key."""
128
+ """Return the canonical HTTPS origin, or "" when the value is not one.
129
+
130
+ This value is both the credential trust key and the base every request is
131
+ built from, so the two have to be the same string. A normalizer that reduced
132
+ an arbitrary URL to its origin would approve one value and send another:
133
+ userinfo, a query, a fragment, or a path would ride along into the request
134
+ while the trust check saw only the host, which is how an API token reaches a
135
+ URL that validation never approved. So the accepted grammar is a bare
136
+ origin -- ``https://host`` or ``https://host/`` with an optional non-default
137
+ port -- and every richer form is refused rather than trimmed down to fit.
138
+
139
+ Self-hosted context paths and internationalized hostnames are deliberately
140
+ out of scope: either needs its own base-path contract, and inventing one
141
+ here would re-open the gap between what is checked and what is sent.
142
+ """
143
+ if not server or any(char <= " " or char >= "\x7f" for char in server):
144
+ return ""
85
145
  try:
86
146
  parsed = urlsplit(server)
87
147
  port = parsed.port
88
148
  except ValueError:
89
149
  return ""
90
- if parsed.scheme.lower() != "https" or not parsed.hostname:
150
+ if not parsed.hostname or port == 0:
151
+ return ""
152
+ if not is_bare_origin(server, parsed):
91
153
  return ""
92
- if port == 0:
154
+ authority = canonical_authority(parsed.netloc, parsed.hostname)
155
+ if not authority:
93
156
  return ""
94
- hostname = parsed.hostname.lower()
95
- authority = f"[{hostname}]" if ":" in hostname else hostname
96
- if port is not None and port != 443:
157
+ if port is not None and port != DEFAULT_HTTPS_PORT:
97
158
  authority = f"{authority}:{port}"
98
159
  return f"https://{authority}"
99
160
 
100
161
 
101
- def resolve_jira_config_path():
102
- """Resolve a project config only when its host matches a user trust root."""
162
+ def resolve_trusted_origin():
163
+ """Return the operator-owned origin a checkout config must match, or "".
164
+
165
+ An explicit JIRA_SERVER wins over the home config. A JIRA_SERVER that is not
166
+ a canonical origin raises rather than resolving to "": degrading a malformed
167
+ trust root into "no trust root" would silently widen what a checkout config
168
+ is allowed to claim, which is the opposite of what setting it asked for.
169
+ """
170
+ configured = os.environ.get("JIRA_SERVER", "")
171
+ if configured.strip():
172
+ origin = server_origin(configured)
173
+ if not origin:
174
+ raise JiraConfigError(
175
+ "JIRA_SERVER must be a valid HTTPS URL naming one origin, with "
176
+ "no userinfo, path, query or fragment"
177
+ )
178
+ return origin
179
+
180
+ home_config = (Path.home() / ".config" / ".jira" / ".config.yml").resolve()
181
+ if not home_config.exists():
182
+ return ""
183
+ return server_origin(read_jira_config(home_config)[0])
184
+
185
+
186
+ def resolve_jira_config_path(trusted_origin=None):
187
+ """Select the jira-cli config that governs this checkout.
188
+
189
+ The nearest checkout-owned config is decisive. When one exists it must match
190
+ the operator's trust root, and a mismatch fails naming that file. Falling
191
+ through to the home config instead would report a missing or unusable home
192
+ config for a project config that was found and refused -- an error about the
193
+ wrong file, pointing whoever has to fix it away from the one that is wrong.
194
+
195
+ Args:
196
+ trusted_origin: The operator's trust root, resolved when not supplied.
197
+ """
103
198
  candidates = jira_config_candidates()
104
199
  home_config = candidates[-1]
105
- trusted_server = os.environ.get("JIRA_SERVER", "").strip()
106
- if not trusted_server and home_config.exists():
107
- trusted_server, _ = read_jira_config(home_config)
108
- trusted_origin = server_origin(trusted_server)
200
+ if trusted_origin is None:
201
+ trusted_origin = resolve_trusted_origin()
109
202
 
110
203
  for candidate in candidates[:-1]:
111
204
  if not candidate.exists():
112
205
  continue
113
- candidate_server, _ = read_jira_config(candidate)
114
- if trusted_origin and server_origin(candidate_server) == trusted_origin:
115
- return candidate
206
+ if not trusted_origin:
207
+ raise JiraConfigError(
208
+ f"jira-cli config {candidate} cannot be trusted: neither "
209
+ "JIRA_SERVER nor a home config establishes a trust root"
210
+ )
211
+ if server_origin(read_jira_config(candidate)[0]) != trusted_origin:
212
+ raise JiraConfigError(
213
+ f"jira-cli config {candidate} server does not match the "
214
+ "trusted JIRA server origin"
215
+ )
216
+ return candidate
116
217
  return home_config
117
218
 
118
219
 
119
220
  def get_jira_config():
120
- """Read JIRA server and login from the project-first jira-cli config."""
121
- config_path = resolve_jira_config_path()
221
+ """Read JIRA server and login from the project-first jira-cli config.
222
+
223
+ Every exit below happens before the token is read, so a configured URL that
224
+ fails validation cannot reach a request. The returned server is the
225
+ canonical origin, never the configured string, which is what keeps the value
226
+ that was checked and the value that is sent identical.
227
+ """
228
+ try:
229
+ trusted_origin = resolve_trusted_origin()
230
+ config_path = resolve_jira_config_path(trusted_origin)
231
+ except JiraConfigError as error:
232
+ print(f"ERROR: {error}", file=sys.stderr)
233
+ sys.exit(1)
234
+
122
235
  if not config_path.exists():
123
236
  print(f"ERROR: jira-cli config not found at {config_path}", file=sys.stderr)
124
237
  sys.exit(1)
125
238
 
126
239
  server, login = read_jira_config(config_path)
127
-
128
- trusted_server = os.environ.get("JIRA_SERVER", "").strip()
129
- trusted_origin = server_origin(trusted_server)
130
- if trusted_server and not trusted_origin:
131
- print("ERROR: JIRA_SERVER must be a valid HTTPS URL", file=sys.stderr)
240
+ origin = server_origin(server)
241
+ if not origin:
242
+ print(
243
+ f"ERROR: JIRA server in {config_path} must be an HTTPS URL naming "
244
+ "one origin, with no userinfo, path, query or fragment",
245
+ file=sys.stderr,
246
+ )
132
247
  sys.exit(1)
133
- if trusted_origin:
134
- if server_origin(server) != trusted_origin:
135
- print("ERROR: jira-cli config server does not match trusted JIRA_SERVER", file=sys.stderr)
136
- sys.exit(1)
137
- server = trusted_server.rstrip("/")
138
- if not server_origin(server):
139
- print("ERROR: JIRA server must be an HTTPS URL from a trusted config", file=sys.stderr)
248
+ if trusted_origin and origin != trusted_origin:
249
+ print(
250
+ f"ERROR: jira-cli config {config_path} server does not match the "
251
+ "trusted JIRA server origin",
252
+ file=sys.stderr,
253
+ )
140
254
  sys.exit(1)
141
255
 
142
256
  token = os.environ.get("JIRA_API_TOKEN", "")
@@ -144,7 +258,7 @@ def get_jira_config():
144
258
  print("ERROR: JIRA_API_TOKEN env var not set", file=sys.stderr)
145
259
  sys.exit(1)
146
260
 
147
- return server, login, token
261
+ return origin, login, token
148
262
 
149
263
 
150
264
  def fetch_ticket(server, login, token, ticket_id):
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-cdk",
3
- "version": "4.26.2",
3
+ "version": "4.26.4",
4
4
  "description": "AWS CDK-specific plugin",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-cdk",
3
- "version": "4.26.2",
3
+ "version": "4.26.4",
4
4
  "description": "AWS CDK-specific Lisa plugin.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-cdk",
3
- "version": "4.26.2",
3
+ "version": "4.26.4",
4
4
  "description": "AWS CDK-specific plugin",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-cdk",
3
- "version": "4.26.2",
3
+ "version": "4.26.4",
4
4
  "description": "AWS CDK-specific plugin",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-cdk",
3
- "version": "4.26.2",
3
+ "version": "4.26.4",
4
4
  "description": "AWS CDK-specific plugin",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa",
3
- "version": "4.26.2",
3
+ "version": "4.26.4",
4
4
  "description": "Universal governance — agents, skills, commands, hooks, and rules for all projects",
5
5
  "author": {
6
6
  "name": "Cody Swann"