@codyswann/lisa 4.27.2 → 4.27.3
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/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 +21 -11
- 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-linear-access/SKILL.md +84 -1
- package/plugins/lisa/.codex-plugin/skills/lisa-linear-build-intake/SKILL.md +3 -3
- package/plugins/lisa/.codex-plugin/skills/lisa-linear-claim/SKILL.md +2 -2
- package/plugins/lisa/.codex-plugin/skills/lisa-linear-evidence/SKILL.md +4 -4
- package/plugins/lisa/.codex-plugin/skills/lisa-linear-sync/SKILL.md +5 -2
- package/plugins/lisa/.codex-plugin/skills/lisa-linear-write-issue/SKILL.md +5 -5
- package/plugins/lisa/.codex-plugin/skills/lisa-repair-intake/SKILL.md +3 -2
- package/plugins/lisa/.codex-plugin/skills/lisa-setup-linear/SKILL.md +11 -1
- package/plugins/lisa/.codex-plugin/skills/lisa-validate-tracker-mapping/SKILL.md +3 -1
- package/plugins/lisa/agents/linear-agent.md +12 -12
- package/plugins/lisa/agents/linear-build-intake.md +1 -1
- package/plugins/lisa/scripts/linear-state-write-target.mjs +362 -0
- package/plugins/lisa/skills/lisa-linear-access/SKILL.md +84 -1
- package/plugins/lisa/skills/lisa-linear-build-intake/SKILL.md +3 -3
- package/plugins/lisa/skills/lisa-linear-claim/SKILL.md +2 -2
- package/plugins/lisa/skills/lisa-linear-evidence/SKILL.md +5 -5
- package/plugins/lisa/skills/lisa-linear-sync/SKILL.md +5 -2
- package/plugins/lisa/skills/lisa-linear-write-issue/SKILL.md +5 -5
- package/plugins/lisa/skills/lisa-repair-intake/SKILL.md +3 -2
- package/plugins/lisa/skills/lisa-setup-linear/SKILL.md +11 -1
- package/plugins/lisa/skills/lisa-validate-tracker-mapping/SKILL.md +3 -1
- package/plugins/lisa-agy/agents/linear-agent.md +12 -12
- package/plugins/lisa-agy/agents/linear-build-intake.md +1 -1
- package/plugins/lisa-agy/plugin.json +1 -1
- package/plugins/lisa-agy/scripts/linear-state-write-target.mjs +362 -0
- package/plugins/lisa-agy/skills/lisa-linear-access/SKILL.md +84 -1
- package/plugins/lisa-agy/skills/lisa-linear-build-intake/SKILL.md +3 -3
- package/plugins/lisa-agy/skills/lisa-linear-claim/SKILL.md +2 -2
- package/plugins/lisa-agy/skills/lisa-linear-evidence/SKILL.md +5 -5
- package/plugins/lisa-agy/skills/lisa-linear-sync/SKILL.md +5 -2
- package/plugins/lisa-agy/skills/lisa-linear-write-issue/SKILL.md +5 -5
- package/plugins/lisa-agy/skills/lisa-repair-intake/SKILL.md +3 -2
- package/plugins/lisa-agy/skills/lisa-setup-linear/SKILL.md +11 -1
- package/plugins/lisa-agy/skills/lisa-validate-tracker-mapping/SKILL.md +3 -1
- 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/agents/linear-agent.agent.md +12 -12
- package/plugins/lisa-copilot/agents/linear-build-intake.agent.md +1 -1
- package/plugins/lisa-copilot/scripts/linear-state-write-target.mjs +362 -0
- package/plugins/lisa-copilot/skills/lisa-linear-access/SKILL.md +84 -1
- package/plugins/lisa-copilot/skills/lisa-linear-build-intake/SKILL.md +3 -3
- package/plugins/lisa-copilot/skills/lisa-linear-claim/SKILL.md +2 -2
- package/plugins/lisa-copilot/skills/lisa-linear-evidence/SKILL.md +5 -5
- package/plugins/lisa-copilot/skills/lisa-linear-sync/SKILL.md +5 -2
- package/plugins/lisa-copilot/skills/lisa-linear-write-issue/SKILL.md +5 -5
- package/plugins/lisa-copilot/skills/lisa-repair-intake/SKILL.md +3 -2
- package/plugins/lisa-copilot/skills/lisa-setup-linear/SKILL.md +11 -1
- package/plugins/lisa-copilot/skills/lisa-validate-tracker-mapping/SKILL.md +3 -1
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/agents/linear-agent.md +12 -12
- package/plugins/lisa-cursor/agents/linear-build-intake.md +1 -1
- package/plugins/lisa-cursor/scripts/linear-state-write-target.mjs +362 -0
- package/plugins/lisa-cursor/skills/lisa-linear-access/SKILL.md +84 -1
- package/plugins/lisa-cursor/skills/lisa-linear-build-intake/SKILL.md +3 -3
- package/plugins/lisa-cursor/skills/lisa-linear-claim/SKILL.md +2 -2
- package/plugins/lisa-cursor/skills/lisa-linear-evidence/SKILL.md +5 -5
- package/plugins/lisa-cursor/skills/lisa-linear-sync/SKILL.md +5 -2
- package/plugins/lisa-cursor/skills/lisa-linear-write-issue/SKILL.md +5 -5
- package/plugins/lisa-cursor/skills/lisa-repair-intake/SKILL.md +3 -2
- package/plugins/lisa-cursor/skills/lisa-setup-linear/SKILL.md +11 -1
- package/plugins/lisa-cursor/skills/lisa-validate-tracker-mapping/SKILL.md +3 -1
- 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/agents/linear-agent.md +12 -12
- package/plugins/src/base/agents/linear-build-intake.md +1 -1
- package/plugins/src/base/scripts/linear-state-write-target.mjs +362 -0
- package/plugins/src/base/skills/lisa-linear-access/SKILL.md +84 -1
- package/plugins/src/base/skills/lisa-linear-build-intake/SKILL.md +3 -3
- package/plugins/src/base/skills/lisa-linear-claim/SKILL.md +2 -2
- package/plugins/src/base/skills/lisa-linear-evidence/SKILL.md +5 -5
- package/plugins/src/base/skills/lisa-linear-sync/SKILL.md +5 -2
- package/plugins/src/base/skills/lisa-linear-write-issue/SKILL.md +5 -5
- package/plugins/src/base/skills/lisa-repair-intake/SKILL.md +3 -2
- package/plugins/src/base/skills/lisa-setup-linear/SKILL.md +11 -1
- package/plugins/src/base/skills/lisa-validate-tracker-mapping/SKILL.md +3 -1
|
@@ -47,11 +47,11 @@ Use the `linear-verify` skill to check the item against organizational standards
|
|
|
47
47
|
|
|
48
48
|
**Gating behavior — this is the one place auto-transitioning is allowed:**
|
|
49
49
|
|
|
50
|
-
Resolve build-lifecycle STATES from `.lisa.config.json` `linear.workflow.*`
|
|
50
|
+
Resolve build-lifecycle STATES from `.lisa.config.json` `linear.workflow.*` through `scripts/resolve-lifecycle-role.mjs` (`--vendor linear`). `ready`, `claimed`, `blocked` and `done` are REQUIRED and have no built-in default — an unset one is a setup defect to report. `review` is OPTIONAL and deliberately has **no** default: an unset `review` means the project runs no agent review step and the transition is skipped, never substituted (`config-resolution` R1). Resolve the `human_needed` marker label from `linear.labels.build.human_needed` (default `human-needed`) — the one surviving build-lane label key.
|
|
51
51
|
|
|
52
52
|
If `linear-verify` returns `FAIL` on any of the above, do NOT continue to build. **Draft the missing spec content first, then block for confirmation** — never bounce a raw "go write all this" checklist back to the creator:
|
|
53
53
|
1. **Best-effort autofill (before blocking).** Run `pre-flight-autofill` for every work item. Preserve a human bare configured key or `Confirmed: <env>`; automation writes `Inferred: <env> — evidence: <title|body|reproduction|hostname>`, `Assumption: <env> — remote default branch <branch>` for a unique reverse-map, or `Assumption: remote default branch <branch>` otherwise. Human confirmation replaces the annotation with the bare key or `Confirmed: <env>`. For a legacy bare value, use managed draft markers and current ticket content only; provider edit history is not required. A marker proves automation and requires re-annotation; otherwise unknown provenance plus conflicting evidence stops for confirmation. Resolve one exact `deploy.branches` key from the human-authored title, body, and reproduction steps or URL hostname; exclude the complete `Target Backend Environment` section and other machine-authored metadata/draft blocks so annotations cannot become evidence. A reported bug environment is an example, not a restriction. Evidence supersedes only `Assumption:`. Normalize `prod` ↔ `production` only when exactly one is configured; no other aliases exist. Conflicts stop; never infer from arbitrary branch text, URL paths/query strings, or substrings. Write through `linear-write-issue`, never overwrite human prose, then re-run `linear-verify`.
|
|
54
|
-
2.
|
|
54
|
+
2. Move the Issue to the configured `blocked` **state** via `lisa-linear-access operation: save-issue lifecycle_role: blocked` (the access layer resolves the configured state itself; never hand it a state ID), and add the configured `human_needed` marker **label** in the same or a following metadata update. (Create either label via `create_issue_label` if needed.) Even after the agent drafted what it could, a pre-flight gate failure bounces the item back to its creator because it still needs a human to **confirm the drafted assumptions** or supply something no agent can invent — real missing credentials, access, or an irreducible product/scoping decision — so the marker tells a human scanning the board which blocked items are waiting on them. The marker is additive to `blocked`, not a replacement. (See the `config-resolution` rule's "Build markers" for when the marker applies and when it must NOT.)
|
|
55
55
|
3. Reassign the item to the **Issue creator** (the human who filed it — Linear's `creator` field).
|
|
56
56
|
4. Post the **confirmation comment** from the `pre-flight-autofill` rule via `lisa-linear-access operation: save-comment`, **not** a bare remediation checklist: disclose it is a Claude draft, give one line per drafted section naming the key assumption made, list any remaining human-only item as a specific question with a recommended default, and close with *"review the drafted sections, correct anything wrong, then flip back to Ready and it builds — or reply with corrections."* Prefix with `[{repo}]`.
|
|
57
57
|
5. Stop. Do not run triage, do not delegate to a flow, do not start work.
|
|
@@ -116,23 +116,23 @@ Use the `linear-evidence` skill to:
|
|
|
116
116
|
|
|
117
117
|
### 8. Suggest Status Transition
|
|
118
118
|
|
|
119
|
-
Based on the milestone,
|
|
119
|
+
Based on the milestone, name the lifecycle role the item has reached. This agent does not perform these transitions itself — the owning flow does (`linear-evidence` for `review`, `linear-build-intake` for `claimed` and `done`) — so this section is a suggestion surface, not a write surface. Role names are resolved from `.lisa.config.json` `linear.workflow.*`; there is no default column, because a role this project did not bind has no name to print:
|
|
120
120
|
|
|
121
|
-
| Milestone |
|
|
122
|
-
|
|
123
|
-
| Plan created | `claimed` | `
|
|
124
|
-
| PR ready | `review` | `
|
|
125
|
-
| PR merged | `done` (env-aware; build-intake performs if dispatched via that flow) | env-keyed variant per `linear.workflow.done` |
|
|
121
|
+
| Milestone | Lifecycle role | Configured state |
|
|
122
|
+
|-----------|----------------|------------------|
|
|
123
|
+
| Plan created | `claimed` | `linear.workflow.claimed` (required) |
|
|
124
|
+
| PR ready | `review` | `linear.workflow.review` — **optional**; unset means skip the step |
|
|
125
|
+
| PR merged | `done` (env-aware; build-intake performs it if dispatched via that flow) | env-keyed variant per `linear.workflow.done` |
|
|
126
126
|
|
|
127
|
-
Note: `done` may be a string or an env-keyed map (`{ dev, staging, production }`). When
|
|
127
|
+
Note: `done` may be a string or an env-keyed map (`{ dev, staging, production }`). When naming the PR-merged transition, the env is implied by the PR's base branch via `deploy.branches` — surface the resolved state name; do not perform the transition here.
|
|
128
128
|
|
|
129
|
-
The
|
|
129
|
+
The native workflow **state** is the canonical build lane on Linear (`linear.labels.build.{ready,claimed,review,done}` is inert and nothing reads it). Suggesting a role never authorizes writing it: every state write goes through `lisa-linear-access` with a declared `lifecycle_role`, which resolves the target from config rather than trusting a supplied ID.
|
|
130
130
|
|
|
131
131
|
## Rules
|
|
132
132
|
|
|
133
|
-
-
|
|
133
|
+
- **This agent** never transitions the native Linear `state`, with one explicit exception: when `linear-verify` returns `FAIL` for the pre-flight gate (Step 2), run the `pre-flight-autofill` draft-then-block procedure (draft the authorable missing sections into the description as labeled assumptions), then move the Issue to the configured `blocked` state via `lisa-linear-access operation: save-issue lifecycle_role: blocked`, add the configured `human_needed` marker label (`linear.labels.build.human_needed`, default `human-needed`), and reassign to the creator with a confirmation comment. Every other transition is a suggestion this agent surfaces and the owning lifecycle flow performs — it is not a claim that the lane is label-driven. It is not: on Linear the lane is the state.
|
|
134
134
|
- Always read the full item graph via `linear-read-issue` before determining intent — don't rely on type labels alone.
|
|
135
|
-
- Never create or materially edit an item by calling MCP write tools directly — always delegate to `linear-write-issue` so relationships, Gherkin criteria, and metadata gates are enforced. Two explicit exceptions are permitted: (1) the Step 2 pre-flight failure path (when `linear-verify` returns `FAIL`) may call `lisa-linear-access operation: save-issue` and `lisa-linear-access operation: save-comment` directly to
|
|
135
|
+
- Never create or materially edit an item by calling MCP write tools directly — always delegate to `linear-write-issue` so relationships, Gherkin criteria, and metadata gates are enforced. Two explicit exceptions are permitted: (1) the Step 2 pre-flight failure path (when `linear-verify` returns `FAIL`) may call `lisa-linear-access operation: save-issue lifecycle_role: blocked` and `lisa-linear-access operation: save-comment` directly to move the Issue to the configured `blocked` state, add the configured `human_needed` marker label, and reassign to the creator — this narrow exception is already granted by the rule above; (2) the Step 3 triage path may call `lisa-linear-access operation: save-comment` to post triage findings and `lisa-linear-access operation: save-issue` to add the `claude-triaged-{repo}` label — these are lightweight metadata updates that do not create or materially edit ticket content and therefore do not need to route through `linear-write-issue`.
|
|
136
136
|
- If sign-in credentials are in the item, extract and pass them to the flow. If the item touches an authenticated surface and credentials are missing, that is a Step 2 failure — block and reassign rather than guessing.
|
|
137
137
|
- If the item has a Validation Journey section, pass it to the verifier agent. The Validation Journey's local-verification step must point at the target backend environment named in the description.
|
|
138
138
|
- The environment handoff for every work item uses the same durable grammar: human bare configured key or `Confirmed: <env>`; automated `Inferred: <env> — evidence: <title|body|reproduction|hostname>`, `Assumption: <env> — remote default branch <branch>` for a unique reverse-map, or `Assumption: remote default branch <branch>` otherwise. Human confirmation replaces an automated annotation with the bare key or `Confirmed: <env>`. For legacy bare values, managed draft markers and current ticket content decide provenance; provider edit history is not required. A marker proves automation; otherwise unknown provenance plus conflicting evidence stops for confirmation. Human-confirmed wins, then validated `Inferred:`; otherwise one exact `deploy.branches` key from the human-authored title, body, and reproduction steps or URL hostname drives the implementation base branch. Exclude the complete `Target Backend Environment` section and other machine-authored metadata/draft blocks so annotations cannot become evidence. Evidence supersedes only `Assumption:`. Normalize `prod` ↔ `production` only when exactly one is configured; no other aliases exist. Conflicts stop. Never infer from arbitrary branch text, URL paths/query strings, or substrings. With no signal, use the remote default and record the applicable assumption without blocking on a non-unique reverse-map. Require any selected mapping and remote branch. A reported bug environment is an example of the all-work-type rule. Non-integration fixes still require the linked forward cherry-pick.
|
|
@@ -17,7 +17,7 @@ skills:
|
|
|
17
17
|
|
|
18
18
|
You are a Linear build-intake agent. Your single job is to run one cycle against a Linear team — find Issues in the configured `ready` workflow STATE, dispatch each through the build flow, transition successful builds to the configured (env-aware) `done` state — then report what happened.
|
|
19
19
|
|
|
20
|
-
Build-lifecycle role names (`ready`, `claimed`, `review`, `blocked`, `done`) are resolved from `.lisa.config.json` `linear.workflow.*` by the `linear-build-intake` skill, and name native **workflow states**, not labels. Defaults: `Ready`, `In Progress`, `
|
|
20
|
+
Build-lifecycle role names (`ready`, `claimed`, `review`, `blocked`, `done`) are resolved from `.lisa.config.json` `linear.workflow.*` by the `linear-build-intake` skill, and name native **workflow states**, not labels. Defaults: `Ready`, `In Progress`, `Blocked`, env-keyed `{ dev: "On Dev", staging: "On Stg", production: "Done" }`. **`review` has no default and is never seeded** — it is optional, and an unset `review` means the project runs no agent review step, so the transition is skipped rather than aimed at a review-shaped state nobody configured. `ready` is a DEDICATED state, never the team default (`Todo`) — mapping it to the default makes every untouched backlog item claimable.
|
|
21
21
|
|
|
22
22
|
## Confirmation policy
|
|
23
23
|
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Linear state-write target resolution — the access-bound guard for #3356.
|
|
4
|
+
*
|
|
5
|
+
* ## The defect this closes
|
|
6
|
+
*
|
|
7
|
+
* `lisa-linear-access operation: save-issue` accepted a raw `stateId` and
|
|
8
|
+
* dispatched it. Nothing in the chokepoint knew which lifecycle role the caller
|
|
9
|
+
* believed it was applying, so nothing could refuse: the resolver added in
|
|
10
|
+
* #3288 could be bypassed by any caller that had an ID in hand. A caller repo in
|
|
11
|
+
* the portfolio watched an issue move straight from its `ready` lane into a
|
|
12
|
+
* review-shaped state that its `linear.workflow` map deliberately does not name.
|
|
13
|
+
*
|
|
14
|
+
* The exact historical writer is not established, and this module does not
|
|
15
|
+
* claim one. The defect being closed is structural: the single chokepoint every
|
|
16
|
+
* Linear write must pass through had no role argument, and therefore had no
|
|
17
|
+
* possible refusal.
|
|
18
|
+
*
|
|
19
|
+
* ## Why this RESOLVES rather than VALIDATES
|
|
20
|
+
*
|
|
21
|
+
* The obvious shape — a validator placed in front of the existing `stateId`
|
|
22
|
+
* argument — is the shape that already failed in #3321: the value that is
|
|
23
|
+
* checked and the value that is sent are two different things, so any path that
|
|
24
|
+
* reaches the send without the check is still a bypass, and the guard's
|
|
25
|
+
* existence reads as safety.
|
|
26
|
+
*
|
|
27
|
+
* So this module does not take a target and approve it. It takes a lifecycle
|
|
28
|
+
* ROLE and PRODUCES the only ID that may be written. The access layer sends
|
|
29
|
+
* what this returns. A caller may still pass the ID it read from the board, but
|
|
30
|
+
* only as an ASSERTION compared against the resolved one — it is never the
|
|
31
|
+
* value dispatched. There is consequently no channel through which an
|
|
32
|
+
* unvalidated state write can be expressed.
|
|
33
|
+
*
|
|
34
|
+
* ## What it refuses
|
|
35
|
+
*
|
|
36
|
+
* Everything that is not an exact, configured, unambiguous match: a state write
|
|
37
|
+
* with no declared role, an env-indexed `done` with no environment key, a role
|
|
38
|
+
* the project never bound (required or optional), a fallback value the resolver
|
|
39
|
+
* refused for write intent, a malformed catalog, a configured name absent from
|
|
40
|
+
* the team's catalog, a configured name that matches more than one state, and a
|
|
41
|
+
* caller-asserted ID that differs from the resolved one.
|
|
42
|
+
*
|
|
43
|
+
* Every refusal names the lifecycle role and the configured value, because the
|
|
44
|
+
* operator standing at the gate is the one who has to fix the config.
|
|
45
|
+
* @module scripts/linear-state-write-target
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
import { readFileSync, realpathSync } from "node:fs";
|
|
49
|
+
import { fileURLToPath } from "node:url";
|
|
50
|
+
|
|
51
|
+
import {
|
|
52
|
+
GLOBAL_CONFIG,
|
|
53
|
+
LOCAL_CONFIG,
|
|
54
|
+
OUTCOMES,
|
|
55
|
+
parseArgs,
|
|
56
|
+
parseConfig,
|
|
57
|
+
readPath,
|
|
58
|
+
resolveRole,
|
|
59
|
+
} from "./resolve-lifecycle-role.mjs";
|
|
60
|
+
|
|
61
|
+
/** The config path Linear's build lifecycle roles are bound at. */
|
|
62
|
+
export const LINEAR_WORKFLOW_ROOT = "linear.workflow";
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Every way a state write can be refused.
|
|
66
|
+
*
|
|
67
|
+
* Named rather than collapsed into one `refused`, so a caller can tell a setup
|
|
68
|
+
* defect (fix the config) from a caller defect (fix the skill) without parsing
|
|
69
|
+
* prose, and so a test can pin the specific path it drove.
|
|
70
|
+
*/
|
|
71
|
+
export const REFUSALS = Object.freeze({
|
|
72
|
+
MISSING_ROLE: "missing-lifecycle-role",
|
|
73
|
+
MISSING_ENV: "missing-environment-key",
|
|
74
|
+
UNEXPECTED_ENV: "unexpected-environment-key",
|
|
75
|
+
ROLE_UNCONFIGURED: "role-unconfigured",
|
|
76
|
+
CATALOG_MALFORMED: "catalog-malformed",
|
|
77
|
+
STATE_ABSENT: "configured-state-absent",
|
|
78
|
+
STATE_AMBIGUOUS: "configured-state-ambiguous",
|
|
79
|
+
TARGET_MISMATCH: "target-mismatch",
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
/** Prefix every refusal carries, so a caller can grep one string for all of them. */
|
|
83
|
+
export const REFUSAL_PREFIX = "Refusing the Linear state write";
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Build a refusal result.
|
|
87
|
+
*
|
|
88
|
+
* @param {string} refusal one of {@link REFUSALS}
|
|
89
|
+
* @param {string} detail operator-readable explanation
|
|
90
|
+
* @returns {{ok: false, refusal: string, message: string}} refusal
|
|
91
|
+
*/
|
|
92
|
+
const refuse = (refusal, detail) => ({
|
|
93
|
+
ok: false,
|
|
94
|
+
refusal,
|
|
95
|
+
message: `${REFUSAL_PREFIX} (${refusal}): ${detail}`,
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Normalize a team workflow-state catalog into a flat node list.
|
|
100
|
+
*
|
|
101
|
+
* Accepts the three shapes the access layer can hand over: the flat array its
|
|
102
|
+
* `list-workflow-states` operation returns, the `{nodes:[…]}` connection the
|
|
103
|
+
* GraphQL adapter reads, and the raw `{team:{states:{nodes:[…]}}}` envelope.
|
|
104
|
+
* Anything else — including a node missing a string `id` or `name` — is
|
|
105
|
+
* malformed, not empty: an empty catalog and an unreadable one must not produce
|
|
106
|
+
* the same answer.
|
|
107
|
+
*
|
|
108
|
+
* @param {unknown} catalog raw catalog
|
|
109
|
+
* @returns {{nodes: Array<{id: string, name: string}>} | {error: string}} normalized nodes
|
|
110
|
+
*/
|
|
111
|
+
export const normalizeStateCatalog = catalog => {
|
|
112
|
+
const nodes = Array.isArray(catalog)
|
|
113
|
+
? catalog
|
|
114
|
+
: (readPath(catalog, "nodes") ?? readPath(catalog, "team.states.nodes"));
|
|
115
|
+
if (!Array.isArray(nodes))
|
|
116
|
+
return {
|
|
117
|
+
error:
|
|
118
|
+
"the team workflow-state catalog is not a list of states. " +
|
|
119
|
+
"Pass the result of `lisa-linear-access operation: list-workflow-states`.",
|
|
120
|
+
};
|
|
121
|
+
for (const node of nodes) {
|
|
122
|
+
const id = readPath(node, "id");
|
|
123
|
+
const name = readPath(node, "name");
|
|
124
|
+
if (typeof id !== "string" || id.length === 0)
|
|
125
|
+
return { error: "a state in the catalog has no string `id`." };
|
|
126
|
+
if (typeof name !== "string" || name.length === 0)
|
|
127
|
+
return { error: `state '${id}' in the catalog has no string \`name\`.` };
|
|
128
|
+
}
|
|
129
|
+
return { nodes };
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Decide whether `done` needs an environment key for this project.
|
|
134
|
+
*
|
|
135
|
+
* `done` is env-indexed on every vendor, so a bare `done` write is ambiguous
|
|
136
|
+
* whenever the project bound a map. A project that bound a plain string has one
|
|
137
|
+
* terminal and needs no key — refusing that would be inventing a requirement
|
|
138
|
+
* the config does not have.
|
|
139
|
+
*
|
|
140
|
+
* @param {object|undefined} local parsed local config
|
|
141
|
+
* @param {object|undefined} globalConfig parsed committed config
|
|
142
|
+
* @returns {string[]} configured environment keys, empty when `done` is flat
|
|
143
|
+
*/
|
|
144
|
+
export const envIndexedDoneKeys = (local, globalConfig) => {
|
|
145
|
+
for (const source of [local, globalConfig]) {
|
|
146
|
+
const done = readPath(source, `${LINEAR_WORKFLOW_ROOT}.done`);
|
|
147
|
+
if (typeof done === "string" && done.length > 0) return [];
|
|
148
|
+
if (done && typeof done === "object") return Object.keys(done);
|
|
149
|
+
}
|
|
150
|
+
return [];
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Resolve the one workflow-state ID a lifecycle role may be written to.
|
|
155
|
+
*
|
|
156
|
+
* @param {object} options inputs
|
|
157
|
+
* @param {string} [options.role] lifecycle role being applied
|
|
158
|
+
* @param {string} [options.env] environment key for an env-indexed `done`
|
|
159
|
+
* @param {unknown} [options.states] team workflow-state catalog
|
|
160
|
+
* @param {object} [options.local] parsed local config
|
|
161
|
+
* @param {object} [options.global] parsed committed config
|
|
162
|
+
* @param {string} [options.assertStateId] ID the caller believes it is writing
|
|
163
|
+
* @returns {{ok: true, role: string, stateId: string, stateName: string} | {ok: false, refusal: string, message: string}} outcome
|
|
164
|
+
*/
|
|
165
|
+
export const resolveStateWriteTarget = ({
|
|
166
|
+
role,
|
|
167
|
+
env,
|
|
168
|
+
states,
|
|
169
|
+
local,
|
|
170
|
+
global: globalConfig,
|
|
171
|
+
assertStateId,
|
|
172
|
+
}) => {
|
|
173
|
+
if (typeof role !== "string" || role.length === 0)
|
|
174
|
+
return refuse(
|
|
175
|
+
REFUSALS.MISSING_ROLE,
|
|
176
|
+
"no lifecycle role was declared. Every `stateId` write must name the role " +
|
|
177
|
+
"it is applying (ready | claimed | blocked | review | done | qa.queue | " +
|
|
178
|
+
"qa.certified) so the configured target can be resolved instead of trusted."
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
// The environment key belongs to exactly one shape: an env-indexed `done`.
|
|
182
|
+
// Anywhere else it is a caller defect that would otherwise pass silently —
|
|
183
|
+
// `resolveRole` simply ignores `env` off the `done` path, so a call carrying
|
|
184
|
+
// a stray one succeeds while asserting an environment nobody checked. A
|
|
185
|
+
// guard that accepts an argument it does not honour is teaching the caller
|
|
186
|
+
// something false.
|
|
187
|
+
const envKeys = envIndexedDoneKeys(local, globalConfig);
|
|
188
|
+
if (env && role !== "done")
|
|
189
|
+
return refuse(
|
|
190
|
+
REFUSALS.UNEXPECTED_ENV,
|
|
191
|
+
`lifecycle role '${role}' takes no environment key. Only the env-indexed ` +
|
|
192
|
+
"`done` role is keyed by environment; passing one here asserts a " +
|
|
193
|
+
"deployment this write does not report."
|
|
194
|
+
);
|
|
195
|
+
if (env && envKeys.length === 0)
|
|
196
|
+
return refuse(
|
|
197
|
+
REFUSALS.UNEXPECTED_ENV,
|
|
198
|
+
`${LINEAR_WORKFLOW_ROOT}.done is a single state on this project, not a ` +
|
|
199
|
+
`map of environments, so there is no '${env}' rung to write. Drop the ` +
|
|
200
|
+
"environment key, or bind the env-indexed map if this project really " +
|
|
201
|
+
"does promote through environments."
|
|
202
|
+
);
|
|
203
|
+
if (role === "done" && !env && envKeys.length > 0)
|
|
204
|
+
return refuse(
|
|
205
|
+
REFUSALS.MISSING_ENV,
|
|
206
|
+
`role 'done' is env-indexed at ${LINEAR_WORKFLOW_ROOT}.done ` +
|
|
207
|
+
`(${envKeys.join(", ")}). Name the environment being reported.`
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
const resolved = resolveRole({
|
|
211
|
+
role,
|
|
212
|
+
vendor: "linear",
|
|
213
|
+
intent: "write",
|
|
214
|
+
env,
|
|
215
|
+
local,
|
|
216
|
+
global: globalConfig,
|
|
217
|
+
});
|
|
218
|
+
if (resolved.outcome === OUTCOMES.UNSET_OPTIONAL)
|
|
219
|
+
return refuse(
|
|
220
|
+
REFUSALS.ROLE_UNCONFIGURED,
|
|
221
|
+
`optional lifecycle role '${role}' is not bound at ` +
|
|
222
|
+
`${LINEAR_WORKFLOW_ROOT}.${role}, so this project SKIPS that transition. ` +
|
|
223
|
+
"That is a supported configuration, not an error — but it means there is " +
|
|
224
|
+
"no target to write. The caller must skip the write; this layer will not " +
|
|
225
|
+
"invent one, and a state resolved by type or board position is how " +
|
|
226
|
+
"agent-owned work reaches a human-only lane."
|
|
227
|
+
);
|
|
228
|
+
if (resolved.outcome !== OUTCOMES.CONFIGURED)
|
|
229
|
+
return refuse(REFUSALS.ROLE_UNCONFIGURED, resolved.message);
|
|
230
|
+
|
|
231
|
+
const normalized = normalizeStateCatalog(states);
|
|
232
|
+
if (normalized.error)
|
|
233
|
+
return refuse(REFUSALS.CATALOG_MALFORMED, normalized.error);
|
|
234
|
+
|
|
235
|
+
const matches = normalized.nodes.filter(node => node.name === resolved.value);
|
|
236
|
+
if (matches.length === 0)
|
|
237
|
+
return refuse(
|
|
238
|
+
REFUSALS.STATE_ABSENT,
|
|
239
|
+
`lifecycle role '${role}' is configured as '${resolved.value}', but the ` +
|
|
240
|
+
"team has no workflow state with that exact name. This is a setup " +
|
|
241
|
+
"defect — run /lisa:setup:linear, or correct " +
|
|
242
|
+
`${LINEAR_WORKFLOW_ROOT}.${role}. Never create or guess a state here.`
|
|
243
|
+
);
|
|
244
|
+
if (matches.length > 1)
|
|
245
|
+
return refuse(
|
|
246
|
+
REFUSALS.STATE_AMBIGUOUS,
|
|
247
|
+
`lifecycle role '${role}' is configured as '${resolved.value}', which ` +
|
|
248
|
+
`matches ${matches.length} states on this team ` +
|
|
249
|
+
`(${matches.map(node => node.id).join(", ")}). An ambiguous target is ` +
|
|
250
|
+
"a setup defect; rename one state or bind the role to a unique name."
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
const target = matches[0];
|
|
254
|
+
if (typeof assertStateId === "string" && assertStateId.length > 0)
|
|
255
|
+
if (assertStateId !== target.id)
|
|
256
|
+
return refuse(
|
|
257
|
+
REFUSALS.TARGET_MISMATCH,
|
|
258
|
+
`the caller asked to write state '${assertStateId}' while applying ` +
|
|
259
|
+
`lifecycle role '${role}', but ${LINEAR_WORKFLOW_ROOT}.${role} is ` +
|
|
260
|
+
`configured as '${resolved.value}' (id ${target.id}). A state absent ` +
|
|
261
|
+
"from the configured workflow map is never a valid write target — " +
|
|
262
|
+
"that is how agent-owned work reaches a human-only lane."
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
return {
|
|
266
|
+
ok: true,
|
|
267
|
+
role,
|
|
268
|
+
stateId: target.id,
|
|
269
|
+
stateName: target.name,
|
|
270
|
+
};
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Read the state catalog named on the command line, or from stdin.
|
|
275
|
+
*
|
|
276
|
+
* @param {string | undefined} file path, `-`, or undefined for stdin
|
|
277
|
+
* @returns {{value: unknown} | {error: string}} parsed catalog
|
|
278
|
+
*/
|
|
279
|
+
export const readCatalog = file => {
|
|
280
|
+
let raw;
|
|
281
|
+
try {
|
|
282
|
+
raw = readFileSync(!file || file === "-" ? 0 : file, "utf8");
|
|
283
|
+
} catch (cause) {
|
|
284
|
+
return { error: `could not read the state catalog: ${cause.message}` };
|
|
285
|
+
}
|
|
286
|
+
try {
|
|
287
|
+
return { value: JSON.parse(raw) };
|
|
288
|
+
} catch (cause) {
|
|
289
|
+
return { error: `the state catalog is not valid JSON: ${cause.message}` };
|
|
290
|
+
}
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* CLI entry point.
|
|
295
|
+
*
|
|
296
|
+
* Exit codes are the contract the access layer branches on:
|
|
297
|
+
* - `0` — the write is permitted; the ONE permissible state ID is on stdout.
|
|
298
|
+
* - `2` — refused; the operator-readable reason is on stderr and NOTHING may
|
|
299
|
+
* be dispatched.
|
|
300
|
+
*
|
|
301
|
+
* There is deliberately no "allowed with warnings" rung. A state write either
|
|
302
|
+
* targets the configured role or it does not happen.
|
|
303
|
+
*
|
|
304
|
+
* @param {string[]} argv arguments after the script name
|
|
305
|
+
* @returns {number} process exit code
|
|
306
|
+
*/
|
|
307
|
+
export const main = argv => {
|
|
308
|
+
const args = parseArgs(argv);
|
|
309
|
+
// A `--state-id` with no `--role` is not a usage slip, it is the defect this
|
|
310
|
+
// guard exists for: a state write nobody declared a lifecycle role for.
|
|
311
|
+
// Answer it with the refusal, so the caller reads why rather than how.
|
|
312
|
+
if (!args.role) {
|
|
313
|
+
process.stderr.write(
|
|
314
|
+
args["state-id"]
|
|
315
|
+
? `${refuse(REFUSALS.MISSING_ROLE, "no lifecycle role was declared, but a state was supplied to write. Every state write must name the role it is applying so the configured target can be resolved instead of trusted.").message}\n`
|
|
316
|
+
: "usage: linear-state-write-target.mjs --role <name> [--env <key>] " +
|
|
317
|
+
"[--states <file|->] [--state-id <asserted-id>] [--json]\n"
|
|
318
|
+
);
|
|
319
|
+
return 2;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const local = parseConfig(args.local ?? LOCAL_CONFIG);
|
|
323
|
+
const globalConfig = parseConfig(args.config ?? GLOBAL_CONFIG);
|
|
324
|
+
for (const parsed of [local, globalConfig])
|
|
325
|
+
if (parsed.error) {
|
|
326
|
+
process.stderr.write(`${parsed.error}\n`);
|
|
327
|
+
return 2;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const catalog = readCatalog(args.states);
|
|
331
|
+
const result = catalog.error
|
|
332
|
+
? refuse(REFUSALS.CATALOG_MALFORMED, catalog.error)
|
|
333
|
+
: resolveStateWriteTarget({
|
|
334
|
+
role: args.role,
|
|
335
|
+
env: args.env,
|
|
336
|
+
states: catalog.value,
|
|
337
|
+
local: local.value,
|
|
338
|
+
global: globalConfig.value,
|
|
339
|
+
assertStateId: args["state-id"],
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
if (args.json === "true") process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
343
|
+
if (result.ok) {
|
|
344
|
+
if (args.json !== "true") process.stdout.write(`${result.stateId}\n`);
|
|
345
|
+
return 0;
|
|
346
|
+
}
|
|
347
|
+
process.stderr.write(`${result.message}\n`);
|
|
348
|
+
return 2;
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
const invokedDirectly = (() => {
|
|
352
|
+
try {
|
|
353
|
+
return (
|
|
354
|
+
realpathSync(process.argv[1] ?? "") ===
|
|
355
|
+
realpathSync(fileURLToPath(import.meta.url))
|
|
356
|
+
);
|
|
357
|
+
} catch {
|
|
358
|
+
return false;
|
|
359
|
+
}
|
|
360
|
+
})();
|
|
361
|
+
|
|
362
|
+
if (invokedDirectly) process.exit(main(process.argv.slice(2)));
|
|
@@ -20,7 +20,7 @@ operation: get-project id:<ID>
|
|
|
20
20
|
operation: save-project payload:{...}
|
|
21
21
|
operation: list-issues [team:<ID>] [project:<ID>] [label:<NAME>] [state:<NAME>] [state_type:<arr>]
|
|
22
22
|
operation: get-issue id:<ID>
|
|
23
|
-
operation: save-issue payload:{...}
|
|
23
|
+
operation: save-issue payload:{...} [lifecycle_role:<ROLE>] [env:<KEY>]
|
|
24
24
|
operation: list-workflow-states team:<ID>
|
|
25
25
|
operation: create-workflow-state payload:{...}
|
|
26
26
|
operation: list-comments issue_id:<ID>
|
|
@@ -184,6 +184,82 @@ linear_graphql() {
|
|
|
184
184
|
Map operation names to Linear GraphQL queries/mutations in this access skill.
|
|
185
185
|
Consumers pass business-shaped arguments only; they do not embed GraphQL.
|
|
186
186
|
|
|
187
|
+
## `save-issue` — a state write is RESOLVED here, never accepted here
|
|
188
|
+
|
|
189
|
+
**This layer does not accept a state to write. It resolves one.** A `save-issue`
|
|
190
|
+
that changes the workflow state declares `lifecycle_role:<ROLE>` — the semantic
|
|
191
|
+
role it is applying (`ready`, `claimed`, `blocked`, `review`, `done`,
|
|
192
|
+
`qa.queue`, `qa.certified`) — plus `env:<KEY>` when the role is the env-indexed
|
|
193
|
+
`done`. This layer then resolves that role against config and the team's own
|
|
194
|
+
catalog, and sends the ID **it** resolved.
|
|
195
|
+
|
|
196
|
+
```bash
|
|
197
|
+
# The team's own catalog, read through this layer's `list-workflow-states`
|
|
198
|
+
# operation for the IDENTITY-MATCHED team, and written to a file so the
|
|
199
|
+
# resolver judges the same bytes the caller looked at. A catalog fetched from
|
|
200
|
+
# some other team is not a smaller catalog, it is the wrong one.
|
|
201
|
+
STATES=$(mktemp)
|
|
202
|
+
linear_list_workflow_states "$TEAM_ID" >"$STATES"
|
|
203
|
+
|
|
204
|
+
# Same trusted ladder `read_linear_key` walks, and for the same reason: a
|
|
205
|
+
# `.opencode`-layout repo that was never handed CLAUDE_PLUGIN_ROOT has no route
|
|
206
|
+
# to the script at all, and a guard that cannot be found is a guard that does
|
|
207
|
+
# not run. Machine-managed rungs only — a checkout-local `plugins/lisa` is
|
|
208
|
+
# repository-controlled executable code, not trusted merely by path.
|
|
209
|
+
resolve_state_guard() {
|
|
210
|
+
local candidate
|
|
211
|
+
for candidate in \
|
|
212
|
+
"${CLAUDE_PLUGIN_ROOT:+$CLAUDE_PLUGIN_ROOT/scripts/linear-state-write-target.mjs}" \
|
|
213
|
+
"${PLUGIN_ROOT:+$PLUGIN_ROOT/scripts/linear-state-write-target.mjs}" \
|
|
214
|
+
node_modules/@codyswann/lisa/plugins/lisa/scripts/linear-state-write-target.mjs; do
|
|
215
|
+
[ -n "$candidate" ] && [ -f "$candidate" ] && { echo "$candidate"; return; }
|
|
216
|
+
done
|
|
217
|
+
# Fail CLOSED and name every path. An unfindable guard must never degrade
|
|
218
|
+
# into an unguarded write.
|
|
219
|
+
echo "Error: could not locate linear-state-write-target.mjs; refusing the state write." >&2
|
|
220
|
+
return 1
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
linear_state_target() { # role [env] -> the ONE writable state id, or exit 2
|
|
224
|
+
local guard
|
|
225
|
+
guard=$(resolve_state_guard) || return 1
|
|
226
|
+
node "$guard" \
|
|
227
|
+
--role "$1" ${2:+--env "$2"} --states "$STATES" \
|
|
228
|
+
${LIFECYCLE_ASSERT_STATE_ID:+--state-id "$LIFECYCLE_ASSERT_STATE_ID"}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
STATE_ID=$(linear_state_target "$LIFECYCLE_ROLE" "$LIFECYCLE_ENV") || {
|
|
232
|
+
# Nothing is dispatched. The script already named the role and the
|
|
233
|
+
# configured value it expected on stderr; surface that verbatim.
|
|
234
|
+
exit 2
|
|
235
|
+
}
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
Run it **before** either transport — before `linear_graphql`, before any
|
|
239
|
+
`mcp__linear-server__*` call. A refusal is a hard stop that sends zero
|
|
240
|
+
mutations, not a warning.
|
|
241
|
+
|
|
242
|
+
Why the layer resolves instead of validating: a validator standing in front of a
|
|
243
|
+
caller-supplied `stateId` checks one value and dispatches another, which is
|
|
244
|
+
exactly the shape that let a caller reach a human-only review lane in the first
|
|
245
|
+
place. Here the checked value and the sent value are the same object — there is
|
|
246
|
+
no channel through which an unvalidated state write can be expressed.
|
|
247
|
+
|
|
248
|
+
A caller that already read an ID off the board MAY pass it as `stateId` in the
|
|
249
|
+
payload. It is treated as an **assertion**, never as the value to send: the
|
|
250
|
+
layer resolves the role's target independently and refuses the call if the two
|
|
251
|
+
differ, naming the role and the configured value. Two further refusals matter:
|
|
252
|
+
|
|
253
|
+
- `stateId` in a payload with **no** `lifecycle_role` is refused. There is no
|
|
254
|
+
such thing as a state write that does not apply a lifecycle role.
|
|
255
|
+
- An **optional role the project never bound** (`review`, `qa.*`) is refused,
|
|
256
|
+
not defaulted. Absent means *skip the transition*, per `config-resolution` R1
|
|
257
|
+
— the caller skips the write entirely rather than asking for one here.
|
|
258
|
+
|
|
259
|
+
**Metadata-only `save-issue` is untouched.** A payload with no `stateId` and no
|
|
260
|
+
`lifecycle_role` — description, assignee, labels, `projectId`, `parentId` — runs
|
|
261
|
+
exactly as before; lifecycle validation has nothing to say about it.
|
|
262
|
+
|
|
187
263
|
## `list-workflow-states` — the team's states, and which one it creates into
|
|
188
264
|
|
|
189
265
|
`list-workflow-states team:<ID>` returns one node per state with `id`, `name`,
|
|
@@ -295,3 +371,10 @@ query($id:String!){
|
|
|
295
371
|
- Missing token plus missing MCP is a hard failure naming `LINEAR_API_KEY`.
|
|
296
372
|
- Mutations send only the fields being changed, matching existing Linear skill
|
|
297
373
|
guidance that `save_*` style updates should not clobber unrelated fields.
|
|
374
|
+
- Every workflow-state write is resolved by
|
|
375
|
+
`scripts/linear-state-write-target.mjs` from a declared `lifecycle_role`,
|
|
376
|
+
before either transport. The ID dispatched is the ID that script returned —
|
|
377
|
+
never one a caller supplied. A caller-supplied `stateId` is an assertion that
|
|
378
|
+
must match, and a `stateId` with no declared role is refused.
|
|
379
|
+
- A refusal is fail-closed and operator-readable: it names the lifecycle role
|
|
380
|
+
and the configured value, exits nonzero, and sends nothing.
|
|
@@ -246,10 +246,10 @@ This gate never blocks a legitimate flat Task/Bug: those have no open children a
|
|
|
246
246
|
|
|
247
247
|
**The two `claim-time-guards` run third and fourth — still before the transition below.** Both semantics live in that one vendor-neutral slug; do not restate them here. Linear wiring only:
|
|
248
248
|
|
|
249
|
-
1. **`two-failed-attempts` valve.** Count `[lisa-build-attempt]` markers on the Issue from the read bundle's comments (match on the marker, never the title). With two or more, do **not** claim:
|
|
249
|
+
1. **`two-failed-attempts` valve.** Count `[lisa-build-attempt]` markers on the Issue from the read bundle's comments (match on the marker, never the title). With two or more, do **not** claim: invoke `lisa-linear-access operation: save-issue lifecycle_role: blocked` (the access layer resolves `linear.workflow.blocked` itself per `config-resolution`; this skill supplies the role, never a state ID), post the operator-readable comment naming both attempts via `save-comment`, and **stop the cycle** at Phase 3e. Every non-success terminal outcome recorded in 3c/3d also appends a fresh `<!-- [lisa-build-attempt] n=<N> outcome=<outcome> -->` marker so the next cycle can count it.
|
|
250
250
|
2. **`already-implemented` check.** Probe for this Issue's own key — `git log --all --grep "<IDENTIFIER>"` and the merged/open PRs referencing `<IDENTIFIER>` (`gh pr list --state all --search "<IDENTIFIER>"` in the bound repo; Linear's own GitHub attachments in the read bundle are the cheaper first look). On a hit, claim as normal but route 3c to **verify-and-close** instead of `lisa-implement`: verify what shipped against the Issue's acceptance criteria, post evidence via `lisa-linear-evidence` naming the shipping PR/commit, then run the ordinary 3d transition and rollup. A partial hit implements only the remaining gap. An unreadable history degrades to "no hit" and the ordinary path proceeds — the guard never blocks the claim. This is not `DUPLICATE_ALREADY_FIXED` (a *different* canonical Issue) and not `claim-archaeology` (a *different* ancestor Issue); it is this Issue's own work already having shipped without a transition.
|
|
251
251
|
|
|
252
|
-
Transition the Issue via `lisa-linear-access operation: save-issue
|
|
252
|
+
Transition the Issue via `lisa-linear-access operation: save-issue lifecycle_role: claimed`. The access layer resolves the `claimed` role's exact configured state against the team catalog and dispatches that ID; a missing or ambiguous `$CLAIMED` state is a setup defect it refuses on (see the pre-flight check) — never create one here.
|
|
253
253
|
|
|
254
254
|
**Assign to the authenticated user when the Issue is unassigned.** A claim must be attributable. If the Issue has no assignee, set its `assigneeId` to the authenticated viewer (resolve the viewer's id via the Linear MCP identity — e.g. `get_user` for the current actor) through `lisa-linear-access operation: save-issue`. Leave an already-assigned Issue's assignee untouched — never reassign work that already has an owner.
|
|
255
255
|
|
|
@@ -339,7 +339,7 @@ If the lifecycle run returned Success:
|
|
|
339
339
|
- If `linear.workflow.done` is a string, that state is terminal.
|
|
340
340
|
- If `linear.workflow.done` is an object, only the production/final environment value is terminal (default: `Done`). Intermediate env states such as `On Dev` and `On Stg` are not terminal — they are typed `started`, so the Issue correctly stays open and on the board.
|
|
341
341
|
- If the project uses a different final environment name, resolve it from the configured deployment topology; if ambiguous, record an Error and do not change the native state.
|
|
342
|
-
4. Transition via `lisa-linear-access operation: save-issue
|
|
342
|
+
4. Transition via `lisa-linear-access operation: save-issue lifecycle_role: done env: <resolved-env-key>` (from `$CLAIMED`, or from `$REVIEW` if `lisa-linear-evidence` already moved it forward). Pass `env` **only where `linear.workflow.done` is a map of environments**: there the key is mandatory and the access layer refuses a bare `done`; where `done` is a single state the project has no environment rungs and the layer refuses a stray key just as firmly. Either way the layer resolves `$DONE` itself rather than accepting the ID this phase computed.
|
|
343
343
|
5. **That single write IS the native closure** when `$DONE` is terminal — there is no second step, because the role and the native state are now the same field. When `$DONE` is an intermediate env (`On Dev` / `On Stg`), the state is typed `started`, so the Issue stays open and active by construction rather than by a compensating repair. If a git automation or magic word has already front-run the Issue into a `completed` state while the resolved env is intermediate, reconcile it back via `lisa-linear-sync` Phase 4b — and treat a recurrence as a setup defect, since `/lisa:setup:linear` offers to remove the `merge → Done` automation that causes it.
|
|
344
344
|
6. Post a `[claude-build-intake]` comment: `"Build complete. PR <URL> merged. Transitioned to $DONE."`
|
|
345
345
|
|
|
@@ -8,11 +8,11 @@ allowed-tools: ["Bash", "Skill", "Read"]
|
|
|
8
8
|
|
|
9
9
|
Claim exactly one canonical Linear identifier such as `ENG-123`. All Linear access goes through `lisa-linear-access`. This is the reusable direct-session counterpart of `lisa-linear-build-intake` Phase 3b.
|
|
10
10
|
|
|
11
|
-
1. Resolve merged `linear.workspace`, `linear.teamKey`, and the Linear build-lifecycle roles from local-over-global config. Require the identifier's team key to equal the configured team. Resolve ready and claimed roles
|
|
11
|
+
1. Resolve merged `linear.workspace`, `linear.teamKey`, and the Linear build-lifecycle roles from local-over-global config. Require the identifier's team key to equal the configured team. Resolve the `ready` and `claimed` roles through `scripts/resolve-lifecycle-role.mjs` (`--vendor linear`). Both are REQUIRED roles with **no built-in default** — an unset one is a setup defect to report, never a name to substitute. Do not hardcode lifecycle decisions.
|
|
12
12
|
2. Invoke `lisa-linear-read-issue <identifier>` immediately before mutation. Reject a completed/canceled issue, active blocker, `repo:<other>` issue, any issue with open sub-Issues, or an Epic per `repo-scope-split` and `leaf-only-lifecycle`.
|
|
13
13
|
3. Inspect the Issue's live workflow **state**:
|
|
14
14
|
- Already claimed, review, environment-done, or another configured later non-terminal role -> preserve it and set `claim_outcome: reused`.
|
|
15
|
-
- In the configured `ready` state -> invoke `lisa-linear-access operation: save-issue
|
|
15
|
+
- In the configured `ready` state -> invoke `lisa-linear-access operation: save-issue lifecycle_role: claimed`. The access layer resolves the `claimed` role's exact configured state itself and dispatches that ID; this skill never hands it a state ID to trust. A missing or ambiguous configured state is a setup defect the layer refuses on, never created here. This is the idempotency lock.
|
|
16
16
|
- In any OTHER non-terminal state -> reject. An Issue that is not in the `ready` state has not been sanctioned for build, and absence of a lifecycle marker is no longer evidence of readiness: since the lane moved to states, every Issue always has one, so "no lifecycle label" is not a state this can observe.
|
|
17
17
|
- Terminal/completed -> reject; never regress completed work.
|
|
18
18
|
4. If and only if the Issue is unassigned, resolve the authenticated viewer and set its `assigneeId` through `lisa-linear-access operation: save-issue`. Leave an existing assignee untouched.
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: lisa-linear-evidence
|
|
3
|
-
description: "Uploads text evidence to the GitHub `pr-assets` release, updates the PR description, posts a comment on the originating Linear Issue with code blocks, and transitions the Issue from the configured `claimed`
|
|
3
|
+
description: "Uploads text evidence to the GitHub `pr-assets` release, updates the PR description, posts a comment on the originating Linear Issue with code blocks, and transitions the Issue from the configured `claimed` workflow state to the configured `review` workflow state. Reusable by any skill that captures evidence and generates evidence/comment.txt + evidence/code-blocks.md. Linear counterpart of lisa-jira-evidence and lisa-github-evidence."
|
|
4
4
|
allowed-tools: ["Bash", "Skill"]
|
|
5
5
|
---
|
|
6
6
|
|
|
7
7
|
# Linear Evidence: $ARGUMENTS
|
|
8
8
|
|
|
9
|
-
Post verification evidence to a Linear Issue and transition it from the configured `claimed`
|
|
9
|
+
Post verification evidence to a Linear Issue and transition it from the configured `claimed` workflow **state** to the configured `review` workflow **state**. Linear's build lane is state-driven, not label-driven — `linear.labels.build.{ready,claimed,review,done}` is inert and nothing reads it (see `lisa-validate-tracker-mapping`). This skill is the destination of the `lisa-tracker-evidence` shim when `tracker = "linear"`.
|
|
10
10
|
|
|
11
11
|
`$ARGUMENTS` is the Linear Issue identifier (e.g. `ENG-123`) and the path to the evidence directory. Caller passes both: `<IDENTIFIER> <evidence-dir>`.
|
|
12
12
|
|
|
@@ -104,15 +104,15 @@ Linear comments support markdown including `<details>` collapsibles, fenced code
|
|
|
104
104
|
|
|
105
105
|
**If `$REVIEW` is empty, skip this phase entirely** — post the evidence comment and leave the Issue in `$CLAIMED`. Report it plainly (`No linear.workflow.review configured; leaving <ID> in its current (claimed) state`) so the skip is visible rather than silent. This is a supported configuration, not a failure.
|
|
106
106
|
|
|
107
|
-
When `$REVIEW` is non-empty,
|
|
107
|
+
When `$REVIEW` is non-empty, invoke `lisa-linear-access operation: save-issue lifecycle_role: review`. That is the whole transition: the Linear build lane is the native workflow **state**, so there is no accompanying label move — do not remove a `claimed` label or add a `review` one, and do not create either. Those keys are inert.
|
|
108
108
|
|
|
109
|
-
The
|
|
109
|
+
The access layer resolves `$REVIEW`'s exact configured state itself and dispatches that ID; this skill never hands it one. Never resolve the state by searching the team for something review-shaped: on a board with more than one review-ish state that picks by position, not by intent, and the states most likely to be found that way are the human-only ones a project deliberately kept out of its config. The access layer refuses such a write outright — an unbound optional `review` is a refusal there, not a guess.
|
|
110
110
|
|
|
111
111
|
## Phase 6 — Report
|
|
112
112
|
|
|
113
113
|
Return:
|
|
114
114
|
|
|
115
|
-
- Linear Issue URL with new
|
|
115
|
+
- Linear Issue URL with its new workflow state
|
|
116
116
|
- PR URL (if updated)
|
|
117
117
|
- List of uploaded evidence file URLs
|
|
118
118
|
|