@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.
- package/all/copy-overwrite/scripts/lisa-work-item.mjs +248 -21
- package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
- package/dist/core/lisa-owned-hash-ledger.js +2 -0
- package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
- package/dist/core/nightly-e2e-guard-behavior-certificate.js +2 -2
- package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
- package/dist/core/upstream-evidence-manifest.js +6 -5
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- package/package.json +3 -3
- package/plugins/lisa/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/skills/lisa-github-build-intake/SKILL.md +40 -6
- package/plugins/lisa/.codex-plugin/skills/lisa-jira-journey/SKILL.md +24 -2
- package/plugins/lisa/.codex-plugin/skills/lisa-jira-journey/scripts/parse-plan.py +144 -30
- package/plugins/lisa/rules/eager/leaf-only-lifecycle.md +16 -0
- package/plugins/lisa/skills/lisa-github-build-intake/SKILL.md +40 -6
- package/plugins/lisa/skills/lisa-jira-journey/SKILL.md +24 -2
- package/plugins/lisa/skills/lisa-jira-journey/scripts/parse-plan.py +144 -30
- package/plugins/lisa-agy/plugin.json +1 -1
- package/plugins/lisa-agy/skills/lisa-github-build-intake/SKILL.md +40 -6
- package/plugins/lisa-agy/skills/lisa-jira-journey/SKILL.md +24 -2
- package/plugins/lisa-agy/skills/lisa-jira-journey/scripts/parse-plan.py +144 -30
- package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-agy/plugin.json +1 -1
- package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/rules/eager/leaf-only-lifecycle.md +16 -0
- package/plugins/lisa-copilot/skills/lisa-github-build-intake/SKILL.md +40 -6
- package/plugins/lisa-copilot/skills/lisa-jira-journey/SKILL.md +24 -2
- package/plugins/lisa-copilot/skills/lisa-jira-journey/scripts/parse-plan.py +144 -30
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/rules/leaf-only-lifecycle.mdc +16 -0
- package/plugins/lisa-cursor/skills/lisa-github-build-intake/SKILL.md +40 -6
- package/plugins/lisa-cursor/skills/lisa-jira-journey/SKILL.md +24 -2
- package/plugins/lisa-cursor/skills/lisa-jira-journey/scripts/parse-plan.py +144 -30
- package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-agy/plugin.json +1 -1
- package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-agy/plugin.json +1 -1
- package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-agy/plugin.json +1 -1
- package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-agy/plugin.json +1 -1
- package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-agy/plugin.json +1 -1
- package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-agy/plugin.json +1 -1
- package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-agy/plugin.json +1 -1
- package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/src/base/rules/eager/leaf-only-lifecycle.md +16 -0
- package/plugins/src/base/skills/lisa-github-build-intake/SKILL.md +40 -6
- package/plugins/src/base/skills/lisa-jira-journey/SKILL.md +24 -2
- package/plugins/src/base/skills/lisa-jira-journey/scripts/parse-plan.py +144 -30
|
@@ -49,6 +49,22 @@ When a leaf reaches the true terminal `done` (the production / final-env value),
|
|
|
49
49
|
|
|
50
50
|
Intermediate env-keyed states (`status:on-dev`, `On Stg`, etc.) remain open. Idempotent — if already closed, report and continue.
|
|
51
51
|
|
|
52
|
+
### Exactly one lifecycle role survives closure
|
|
53
|
+
|
|
54
|
+
The terminal transition retires **every** other lifecycle role the item carries, not only the one it was last known to hold. Resolve the complete configured set — `ready`, `claimed`, `review` where bound, `blocked`, and every env-keyed `done` value — and remove each one that is present, except the terminal role being applied. Type, component, priority, and provenance labels are untouched.
|
|
55
|
+
|
|
56
|
+
Retiring only the claimed role assumes the item travelled the happy path, and items skip stages: one reaches completion straight from `ready` having never been claimed, another is unblocked and closed still carrying `blocked`. Measured on a live tracker: 34 closed issues carried an active lifecycle role, six of them carrying `ready` and the terminal role at once.
|
|
57
|
+
|
|
58
|
+
**This is not cosmetic.** A queue scan filters on the synthesized role, not the native closed state, so a closed item still reading `ready` is handed back out — one already-shipped fix was independently rebuilt end to end before a push-time gate caught it.
|
|
59
|
+
|
|
60
|
+
Three constraints on how it is done:
|
|
61
|
+
|
|
62
|
+
- **Remove only roles the item actually carries.** Naming an absent label is a 404, which fails a clean closure and makes a repeat run fail where the first one succeeded.
|
|
63
|
+
- **Verify by re-reading.** Confirm the final closed state and the unique terminal role from a **fresh read of the item**, never from the write's own response. A write that reports success is not evidence of the state it intended to produce — asserting otherwise is the same defect as the drift being repaired, one layer up.
|
|
64
|
+
- **Never re-complete an abandoned item.** Applying a terminal `done` role to an item someone already closed as not-planned rewrites their decision that the work would not be done, and afterwards the two are indistinguishable. Refuse and say so. This does not restrict the duplicate closeout below, which is a flow deliberately closing an open item as not-planned rather than overwriting a closure that already exists.
|
|
65
|
+
|
|
66
|
+
Trackers with a native lifecycle field (JIRA, Linear) get exclusivity for free: the field that closes the item is the field a queue scan filters on, so the two cannot disagree. GitHub Issues has no lifecycle beyond open/closed, so Lisa synthesizes one in labels and inherits a reconciliation duty the others do not have. That asymmetry is tracked separately in `CodySwannGT/lisa#3479`; this section is the obligation, not the redesign.
|
|
67
|
+
|
|
52
68
|
Duplicate closeout is a narrow terminal exception: build intake may close a claimed item without a PR only when `ticket-triage` returns `DUPLICATE_ALREADY_FIXED` with a canonical item reference and empirical base-branch proof. Close it through provider duplicate semantics, not as completed build work. `BLOCKED`, ambiguous, duplicate-of-open, and other human-owned dispositions are not auto-closed.
|
|
53
69
|
|
|
54
70
|
Full vendor mechanics + the state machine in prose: [reference/leaf-only-lifecycle.md](../reference/leaf-only-lifecycle.md).
|
|
@@ -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.
|
|
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
|
|
438
|
-
gh issue
|
|
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
|
|
480
|
-
gh issue
|
|
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 `$
|
|
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
|
|
41
|
-
copy project settings into the home-level file just to make this
|
|
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
|
|
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.
|
|
150
|
+
if not parsed.hostname or port == 0:
|
|
151
|
+
return ""
|
|
152
|
+
if not is_bare_origin(server, parsed):
|
|
91
153
|
return ""
|
|
92
|
-
|
|
154
|
+
authority = canonical_authority(parsed.netloc, parsed.hostname)
|
|
155
|
+
if not authority:
|
|
93
156
|
return ""
|
|
94
|
-
|
|
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
|
|
102
|
-
"""
|
|
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
|
-
|
|
106
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
|
261
|
+
return origin, login, token
|
|
148
262
|
|
|
149
263
|
|
|
150
264
|
def fetch_ticket(server, login, token, ticket_id):
|
|
@@ -54,6 +54,22 @@ When a leaf reaches the true terminal `done` (the production / final-env value),
|
|
|
54
54
|
|
|
55
55
|
Intermediate env-keyed states (`status:on-dev`, `On Stg`, etc.) remain open. Idempotent — if already closed, report and continue.
|
|
56
56
|
|
|
57
|
+
### Exactly one lifecycle role survives closure
|
|
58
|
+
|
|
59
|
+
The terminal transition retires **every** other lifecycle role the item carries, not only the one it was last known to hold. Resolve the complete configured set — `ready`, `claimed`, `review` where bound, `blocked`, and every env-keyed `done` value — and remove each one that is present, except the terminal role being applied. Type, component, priority, and provenance labels are untouched.
|
|
60
|
+
|
|
61
|
+
Retiring only the claimed role assumes the item travelled the happy path, and items skip stages: one reaches completion straight from `ready` having never been claimed, another is unblocked and closed still carrying `blocked`. Measured on a live tracker: 34 closed issues carried an active lifecycle role, six of them carrying `ready` and the terminal role at once.
|
|
62
|
+
|
|
63
|
+
**This is not cosmetic.** A queue scan filters on the synthesized role, not the native closed state, so a closed item still reading `ready` is handed back out — one already-shipped fix was independently rebuilt end to end before a push-time gate caught it.
|
|
64
|
+
|
|
65
|
+
Three constraints on how it is done:
|
|
66
|
+
|
|
67
|
+
- **Remove only roles the item actually carries.** Naming an absent label is a 404, which fails a clean closure and makes a repeat run fail where the first one succeeded.
|
|
68
|
+
- **Verify by re-reading.** Confirm the final closed state and the unique terminal role from a **fresh read of the item**, never from the write's own response. A write that reports success is not evidence of the state it intended to produce — asserting otherwise is the same defect as the drift being repaired, one layer up.
|
|
69
|
+
- **Never re-complete an abandoned item.** Applying a terminal `done` role to an item someone already closed as not-planned rewrites their decision that the work would not be done, and afterwards the two are indistinguishable. Refuse and say so. This does not restrict the duplicate closeout below, which is a flow deliberately closing an open item as not-planned rather than overwriting a closure that already exists.
|
|
70
|
+
|
|
71
|
+
Trackers with a native lifecycle field (JIRA, Linear) get exclusivity for free: the field that closes the item is the field a queue scan filters on, so the two cannot disagree. GitHub Issues has no lifecycle beyond open/closed, so Lisa synthesizes one in labels and inherits a reconciliation duty the others do not have. That asymmetry is tracked separately in `CodySwannGT/lisa#3479`; this section is the obligation, not the redesign.
|
|
72
|
+
|
|
57
73
|
Duplicate closeout is a narrow terminal exception: build intake may close a claimed item without a PR only when `ticket-triage` returns `DUPLICATE_ALREADY_FIXED` with a canonical item reference and empirical base-branch proof. Close it through provider duplicate semantics, not as completed build work. `BLOCKED`, ambiguous, duplicate-of-open, and other human-owned dispositions are not auto-closed.
|
|
58
74
|
|
|
59
75
|
Full vendor mechanics + the state machine in prose: [reference/leaf-only-lifecycle.md](leaf-only-lifecycle-reference.mdc).
|
|
@@ -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.
|
|
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
|
|
438
|
-
gh issue
|
|
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
|
|
480
|
-
gh issue
|
|
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 `$
|
|
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
|
|
41
|
-
copy project settings into the home-level file just to make this
|
|
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
|
|