@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
|
@@ -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):
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lisa-openclaw",
|
|
3
|
-
"version": "4.26.
|
|
3
|
+
"version": "4.26.4",
|
|
4
4
|
"description": "Connect staff roles to Telegram or Slack via OpenClaw — facilitator/specialist hub-and-spoke routing and repo-coding topics, for Claude Code and Codex",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Cody Swann"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lisa-openclaw",
|
|
3
|
-
"version": "4.26.
|
|
3
|
+
"version": "4.26.4",
|
|
4
4
|
"description": "Connect staff roles to Telegram or Slack via OpenClaw — facilitator/specialist hub-and-spoke routing and repo-coding topics, across Claude and Codex.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Cody Swann"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lisa-openclaw",
|
|
3
|
-
"version": "4.26.
|
|
3
|
+
"version": "4.26.4",
|
|
4
4
|
"description": "Connect staff roles to Telegram or Slack via OpenClaw — facilitator/specialist hub-and-spoke routing and repo-coding topics, for Claude Code and Codex",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Cody Swann"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lisa-openclaw",
|
|
3
|
-
"version": "4.26.
|
|
3
|
+
"version": "4.26.4",
|
|
4
4
|
"description": "Connect staff roles to Telegram or Slack via OpenClaw — facilitator/specialist hub-and-spoke routing and repo-coding topics, for Claude Code and Codex",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Cody Swann"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lisa-openclaw",
|
|
3
|
-
"version": "4.26.
|
|
3
|
+
"version": "4.26.4",
|
|
4
4
|
"description": "Connect staff roles to Telegram or Slack via OpenClaw — facilitator/specialist hub-and-spoke routing and repo-coding topics, for Claude Code and Codex",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Cody Swann"
|
|
@@ -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).
|