@codyswann/lisa 4.27.1 → 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.
Files changed (124) hide show
  1. package/dist/core/nightly-e2e-guard-behavior-certificate.js +2 -2
  2. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  3. package/dist/core/upstream-evidence-manifest.js +21 -11
  4. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  5. package/package.json +3 -3
  6. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  7. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  8. package/plugins/lisa/.codex-plugin/skills/lisa-linear-access/SKILL.md +84 -1
  9. package/plugins/lisa/.codex-plugin/skills/lisa-linear-build-intake/SKILL.md +3 -3
  10. package/plugins/lisa/.codex-plugin/skills/lisa-linear-claim/SKILL.md +2 -2
  11. package/plugins/lisa/.codex-plugin/skills/lisa-linear-evidence/SKILL.md +4 -4
  12. package/plugins/lisa/.codex-plugin/skills/lisa-linear-sync/SKILL.md +5 -2
  13. package/plugins/lisa/.codex-plugin/skills/lisa-linear-write-issue/SKILL.md +5 -5
  14. package/plugins/lisa/.codex-plugin/skills/lisa-repair-intake/SKILL.md +3 -2
  15. package/plugins/lisa/.codex-plugin/skills/lisa-setup-linear/SKILL.md +11 -1
  16. package/plugins/lisa/.codex-plugin/skills/lisa-validate-tracker-mapping/SKILL.md +3 -1
  17. package/plugins/lisa/agents/linear-agent.md +12 -12
  18. package/plugins/lisa/agents/linear-build-intake.md +1 -1
  19. package/plugins/lisa/scripts/linear-state-write-target.mjs +362 -0
  20. package/plugins/lisa/skills/lisa-linear-access/SKILL.md +84 -1
  21. package/plugins/lisa/skills/lisa-linear-build-intake/SKILL.md +3 -3
  22. package/plugins/lisa/skills/lisa-linear-claim/SKILL.md +2 -2
  23. package/plugins/lisa/skills/lisa-linear-evidence/SKILL.md +5 -5
  24. package/plugins/lisa/skills/lisa-linear-sync/SKILL.md +5 -2
  25. package/plugins/lisa/skills/lisa-linear-write-issue/SKILL.md +5 -5
  26. package/plugins/lisa/skills/lisa-repair-intake/SKILL.md +3 -2
  27. package/plugins/lisa/skills/lisa-setup-linear/SKILL.md +11 -1
  28. package/plugins/lisa/skills/lisa-validate-tracker-mapping/SKILL.md +3 -1
  29. package/plugins/lisa-agy/agents/linear-agent.md +12 -12
  30. package/plugins/lisa-agy/agents/linear-build-intake.md +1 -1
  31. package/plugins/lisa-agy/plugin.json +1 -1
  32. package/plugins/lisa-agy/scripts/linear-state-write-target.mjs +362 -0
  33. package/plugins/lisa-agy/skills/lisa-linear-access/SKILL.md +84 -1
  34. package/plugins/lisa-agy/skills/lisa-linear-build-intake/SKILL.md +3 -3
  35. package/plugins/lisa-agy/skills/lisa-linear-claim/SKILL.md +2 -2
  36. package/plugins/lisa-agy/skills/lisa-linear-evidence/SKILL.md +5 -5
  37. package/plugins/lisa-agy/skills/lisa-linear-sync/SKILL.md +5 -2
  38. package/plugins/lisa-agy/skills/lisa-linear-write-issue/SKILL.md +5 -5
  39. package/plugins/lisa-agy/skills/lisa-repair-intake/SKILL.md +3 -2
  40. package/plugins/lisa-agy/skills/lisa-setup-linear/SKILL.md +11 -1
  41. package/plugins/lisa-agy/skills/lisa-validate-tracker-mapping/SKILL.md +3 -1
  42. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  43. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  44. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  45. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  46. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  47. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  48. package/plugins/lisa-copilot/agents/linear-agent.agent.md +12 -12
  49. package/plugins/lisa-copilot/agents/linear-build-intake.agent.md +1 -1
  50. package/plugins/lisa-copilot/scripts/linear-state-write-target.mjs +362 -0
  51. package/plugins/lisa-copilot/skills/lisa-linear-access/SKILL.md +84 -1
  52. package/plugins/lisa-copilot/skills/lisa-linear-build-intake/SKILL.md +3 -3
  53. package/plugins/lisa-copilot/skills/lisa-linear-claim/SKILL.md +2 -2
  54. package/plugins/lisa-copilot/skills/lisa-linear-evidence/SKILL.md +5 -5
  55. package/plugins/lisa-copilot/skills/lisa-linear-sync/SKILL.md +5 -2
  56. package/plugins/lisa-copilot/skills/lisa-linear-write-issue/SKILL.md +5 -5
  57. package/plugins/lisa-copilot/skills/lisa-repair-intake/SKILL.md +3 -2
  58. package/plugins/lisa-copilot/skills/lisa-setup-linear/SKILL.md +11 -1
  59. package/plugins/lisa-copilot/skills/lisa-validate-tracker-mapping/SKILL.md +3 -1
  60. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  61. package/plugins/lisa-cursor/agents/linear-agent.md +12 -12
  62. package/plugins/lisa-cursor/agents/linear-build-intake.md +1 -1
  63. package/plugins/lisa-cursor/scripts/linear-state-write-target.mjs +362 -0
  64. package/plugins/lisa-cursor/skills/lisa-linear-access/SKILL.md +84 -1
  65. package/plugins/lisa-cursor/skills/lisa-linear-build-intake/SKILL.md +3 -3
  66. package/plugins/lisa-cursor/skills/lisa-linear-claim/SKILL.md +2 -2
  67. package/plugins/lisa-cursor/skills/lisa-linear-evidence/SKILL.md +5 -5
  68. package/plugins/lisa-cursor/skills/lisa-linear-sync/SKILL.md +5 -2
  69. package/plugins/lisa-cursor/skills/lisa-linear-write-issue/SKILL.md +5 -5
  70. package/plugins/lisa-cursor/skills/lisa-repair-intake/SKILL.md +3 -2
  71. package/plugins/lisa-cursor/skills/lisa-setup-linear/SKILL.md +11 -1
  72. package/plugins/lisa-cursor/skills/lisa-validate-tracker-mapping/SKILL.md +3 -1
  73. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  74. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  75. package/plugins/lisa-expo-agy/plugin.json +1 -1
  76. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  77. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  78. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  79. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  80. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  81. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  82. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  83. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  84. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  85. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  86. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  87. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  88. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  89. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  90. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  91. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  92. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  93. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  94. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  95. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  96. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  97. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  98. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  99. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  100. package/plugins/lisa-rails-agy/plugin.json +1 -1
  101. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  102. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  103. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  104. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  105. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  106. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  107. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  108. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  109. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  110. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  111. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  112. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  113. package/plugins/src/base/agents/linear-agent.md +12 -12
  114. package/plugins/src/base/agents/linear-build-intake.md +1 -1
  115. package/plugins/src/base/scripts/linear-state-write-target.mjs +362 -0
  116. package/plugins/src/base/skills/lisa-linear-access/SKILL.md +84 -1
  117. package/plugins/src/base/skills/lisa-linear-build-intake/SKILL.md +3 -3
  118. package/plugins/src/base/skills/lisa-linear-claim/SKILL.md +2 -2
  119. package/plugins/src/base/skills/lisa-linear-evidence/SKILL.md +5 -5
  120. package/plugins/src/base/skills/lisa-linear-sync/SKILL.md +5 -2
  121. package/plugins/src/base/skills/lisa-linear-write-issue/SKILL.md +5 -5
  122. package/plugins/src/base/skills/lisa-repair-intake/SKILL.md +3 -2
  123. package/plugins/src/base/skills/lisa-setup-linear/SKILL.md +11 -1
  124. package/plugins/src/base/skills/lisa-validate-tracker-mapping/SKILL.md +3 -1
@@ -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: set `stateId` to the configured blocked state via `lisa-linear-access operation: save-issue` (resolved from `linear.workflow.blocked` per `config-resolution`), 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.
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` by setting `stateId` to the `$CLAIMED` state. Resolve state IDs via `list-workflow-states`; a missing `$CLAIMED` state is a setup defect (see the pre-flight check) — never create one here.
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`, setting `stateId` to the `$DONE` state (from `$CLAIMED`, or from `$REVIEW` if `lisa-linear-evidence` already moved it forward).
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 from `linear.workflow` (defaults `Ready` and `In Progress`); do not hardcode lifecycle decisions.
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` setting `stateId` to the `claimed` state (resolve state IDs with `list-workflow-states`; a missing state is a setup defect, never created here). This is the idempotency lock.
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` label to the configured `review` label. 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."
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` build label to the configured `review` build label. This skill is the destination of the `lisa-tracker-evidence` shim when `tracker = "linear"`.
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, update labels via `lisa-linear-access operation: save-issue` to remove `$CLAIMED` and add `$REVIEW`. Resolve label IDs first via `lisa-linear-access operation: list-issue-labels` (create the label via `create_issue_label` if it doesn't exist on the team).
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 native Linear `state` field is updated to `$REVIEW` **only when the resolver returned it from config**. Never resolve the state by searching the team for something review-shaped: on a board with more than one review-ish state that picks by position, not by intent, and the states most likely to be found that way are the human-only ones a project deliberately kept out of its config.
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 label state
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
 
@@ -122,7 +122,7 @@ Run it whenever the resolved env is **intermediate** (below the production termi
122
122
 
123
123
  1. Resolve the merged PR's base branch to its env via `.lisa.config.json` `deploy.branches` (`config-resolution`). If it maps to the production/terminal `done`, this phase is a **no-op** — native completion is correct there.
124
124
  2. **Uniform / single-environment no-op.** When the project's env-keyed `done` map is uniform — every environment resolves to the same `Done`, as in this repo (`production: main` only) — dev-merge == terminal, so native completion is correct. Do nothing. Only a **non-uniform** env→`done` map (distinct `On Dev`/`On Stg`/`Done` rungs) can desync.
125
- 3. Otherwise (non-uniform map, resolved env intermediate): re-read the Issue's `state`. If it sits in a `completed`-typed state while the derived role is a lower env rung, **re-open** the native state by moving it back to the correct env rung (via `lisa-linear-access operation: save-issue`) and post a short `[lisa-linear-sync]` reconciliation comment **naming the likely cause** — a live `merge → Done` git automation, or a magic word in the PR body. This applies the `leaf-only-lifecycle` "Terminal native closure" rule — closure fires **only** at the production terminal `done`. Cite the rule by slug; do not restate it.
125
+ 3. Otherwise (non-uniform map, resolved env intermediate): re-read the Issue's `state`. If it sits in a `completed`-typed state while the derived role is a lower env rung, **re-open** the native state by moving it back to the correct env rung (via `lisa-linear-access operation: save-issue lifecycle_role: done env: <resolved-rung>` — the backstop names the role and the rung like every other lifecycle write; it never re-derives a state ID itself) and post a short `[lisa-linear-sync]` reconciliation comment **naming the likely cause** — a live `merge → Done` git automation, or a magic word in the PR body. This applies the `leaf-only-lifecycle` "Terminal native closure" rule — closure fires **only** at the production terminal `done`. Cite the rule by slug; do not restate it.
126
126
  4. **Safe default.** If the true terminal cannot be resolved (ambiguous env or unresolvable `done` map), do not change the `state` — post a `[lisa-linear-sync]` reconciliation-suggestion comment and leave it untouched, mirroring the Phase 5 safe default.
127
127
 
128
128
  ## Phase 5 — Parent Status Rollup (`--rollup`)
@@ -148,7 +148,10 @@ When the caller passes `--rollup`, this skill **derives a parent/container's wor
148
148
 
149
149
  **Single-environment collapse (this repo).** The env rungs resolve via the env-keyed `done` logic in `config-resolution`. In this repo `deploy.branches` declares only `production: main`, so `done` collapses to the single `Done` state, the only env rung is production, and the lifecycle is `ready → claimed → done` with **no** dev/staging promotion hops and no configured `review` rung; the rollup never resolves a dev or staging `done`. Multi-environment projects keep the env-keyed map and roll a parent up to the intermediate env states.
150
150
 
151
- **Apply the derived state** via `lisa-linear-access operation: save-issue` (Project or Issue), setting the parent's `stateId` to the derived role. Post an idempotent rollup comment naming the derived state and the child tally. Because the terminal `done` state is itself typed `completed`, a parent rolled to terminal is natively closed by the same write — there is no second closure step. **Safe default:** if the derived terminal cannot be resolved (ambiguous required-set or unresolvable env `done`), do not guess — post the derived suggestion as a comment and leave the parent's state untouched.
151
+ **Apply the derived state**, naming the derived role — the access layer resolves the configured state for that role and dispatches it. This skill never computes a `stateId` of its own; a role the project never bound is refused there rather than approximated here.
152
+
153
+ - **Issue parent** → `lisa-linear-access operation: save-issue lifecycle_role: <derived role> [env: <key>]`. An Issue's lane IS a team workflow state, so this is the guarded write.
154
+ - **Project parent** → `lisa-linear-access operation: save-project`. A Linear Project has no team workflow state — it carries its own coarse project status — so there is no `stateId` and no `lifecycle_role` to declare. Map the derived role onto the Project's own status and say in the rollup comment which role produced it. Do not send `lifecycle_role` here; the workflow-state guard governs Issues and has nothing to resolve for a Project. Post an idempotent rollup comment naming the derived state and the child tally. Because the terminal `done` state is itself typed `completed`, a parent rolled to terminal is natively closed by the same write — there is no second closure step. **Safe default:** if the derived terminal cannot be resolved (ambiguous required-set or unresolvable env `done`), do not guess — post the derived suggestion as a comment and leave the parent's state untouched.
152
155
 
153
156
  ## Rules
154
157
 
@@ -31,7 +31,7 @@ Linear's data model maps Epic / Story / Sub-task to **different entity types**.
31
31
  | `Bug` | **Issue** | `lisa-linear-access operation: save-issue` | `projectId` if part of an Epic; else top-level |
32
32
  | `Spike` | **Issue** | `lisa-linear-access operation: save-issue` | `projectId` if part of an Epic; else top-level |
33
33
 
34
- The build lifecycle uses native **workflow states** (`Ready`, `In Progress`, `In Review`, `Blocked`, `On Dev`, `On Stg`, `Done`), resolved per role from `linear.workflow` — see "Why Linear uses states, not labels" in `config-resolution`. A new **leaf** work unit is created in the configured `ready` state only on explicit `build_ready: true`; omitted or `false` leaves it in the team's default backlog state, and a container is never put in `ready` at all (see the Build-ready control input below).
34
+ The build lifecycle uses native **workflow states** (`Ready`, `In Progress`, `Blocked`, `On Dev`, `On Stg`, `Done`, plus an optional review state a project may bind), resolved per role from `linear.workflow` — see "Why Linear uses states, not labels" in `config-resolution`. A new **leaf** work unit is created in the configured `ready` state only on explicit `build_ready: true`; omitted or `false` leaves it in the team's default backlog state, and a container is never put in `ready` at all (see the Build-ready control input below).
35
35
 
36
36
  ## Phase 1 — Resolve Intent
37
37
 
@@ -271,7 +271,7 @@ For Bug / Task / Sub-task, ensure the summary is prefixed with `[<repo-name>]`.
271
271
 
272
272
  - **Omitted** → **not build-ready**: the leaf is left in the team's default backlog state. Ready is an explicit claim, never a vendor default. **This is a breaking change** — Linear previously created a leaf directly in the `ready` state on omission, so a caller that relied on that must now pass `build_ready: true`.
273
273
  - **`build_ready: false`** → create the leaf **without** the `ready` state, leaving it in the team's default backlog state so it waits for a human to review and promote it into the queue.
274
- - **`build_ready: true`** → transition the **leaf** to the resolved `ready` state (`.linear.workflow.ready`) so `lisa-intake` / `lisa-linear-build-intake` auto-picks it up. Best-effort: if the state cannot be resolved or the transition is rejected, do not fail the write — leave the Issue in its default state and record the reason.
274
+ - **`build_ready: true`** → declare `lifecycle_role: ready` so the access layer resolves `.linear.workflow.ready` and places the leaf there, and `lisa-intake` / `lisa-linear-build-intake` auto-picks it up. Best-effort **for the lane, never for the guard**: an access-layer refusal (`ready` unbound, absent from the team, ambiguous) is fail-closed and is never worked around here — surface its message verbatim, do not fail the write, leave the Issue in its default state and record the reason. The Issue is still created; what is refused is the unproven lane placement, which is the safe half to lose.
275
275
 
276
276
  **A filing with neither is an incomplete handoff.** A leaf that is not build-ready must carry an explicit `human_gate: "<why a human must judge this first>"`; nothing in the ready lane means nothing ever claims it. When `human_gate` is supplied, stamp the hold on the Issue so it is auditable — a visible line plus the verbatim marker:
277
277
 
@@ -306,7 +306,7 @@ If the validator reports `PASS`, continue to Phase 6.
306
306
 
307
307
  ### CREATE — Story / Task / Bug / Spike / Improvement (Issue with projectId)
308
308
 
309
- 1. Resolve any required Issue labels (`type:<Kind>`, `repo:<name>`, `component:<name>`, `prd-intake-feedback` only if this is a sentinel issue) via `lisa-linear-access operation: list-issue-labels` (create via `lisa-linear-access operation: create-issue-label` if missing). Separately resolve the `ready` **state** id via `lisa-linear-access operation: list-workflow-states`, and set it as `stateId` only for a **leaf** work unit and only when `build_ready` is not `false` — omit it for a container, and for a `build_ready: false` leaf which then waits in the default backlog state for a human to promote it.
309
+ 1. Resolve any required Issue labels (`type:<Kind>`, `repo:<name>`, `component:<name>`, `prd-intake-feedback` only if this is a sentinel issue) via `lisa-linear-access operation: list-issue-labels` (create via `lisa-linear-access operation: create-issue-label` if missing). Separately, place a **leaf** work unit in the `ready` lane by passing `lifecycle_role: ready` on the create call below — and only on **explicit `build_ready: true`**, per the Build-ready control input below. Omit the role for a container, and for a leaf whose `build_ready` is `false` or omitted, which then waits in the team's default backlog state for a human to promote it. Ready is an explicit claim, never an omission's default. Never resolve a state ID here and pass it as `stateId`: the access layer resolves the configured `ready` state itself and refuses anything else.
310
310
  2. Call `lisa-linear-access operation: save-issue` with: `team` (teamId), `title` (summary), `description` (markdown), `projectId` (the Epic Project), `priority` (0–4), `estimate`, `labelIds`, `assignee` if known.
311
311
  3. Capture the returned identifier (e.g. `ENG-123`) — Phase 4 sub-tasks need it as `parentId`.
312
312
  4. Add relationships from Phase 4b via `save_issue` (relations field) or paired relation calls.
@@ -314,8 +314,8 @@ If the validator reports `PASS`, continue to Phase 6.
314
314
 
315
315
  ### CREATE — Sub-task (Issue with parentId)
316
316
 
317
- 1. Resolve labels as above.
318
- 2. Call `lisa-linear-access operation: save-issue` with: `team` (teamId), `title` (`[<repo>] <summary>` prefix is mandatory), `description` (markdown), `parentId` (the Story Issue ID), `projectId` (inherit from parent), `priority`, `estimate`, `labelIds`.
317
+ 1. Resolve labels as above. A Sub-task is a **leaf**, so the same ready-lane rule applies: pass `lifecycle_role: ready` on the create call below only on explicit `build_ready: true`, and omit it otherwise. Never resolve a state ID here.
318
+ 2. Call `lisa-linear-access operation: save-issue` with: `team` (teamId), `title` (`[<repo>] <summary>` prefix is mandatory), `description` (markdown), `parentId` (the Story Issue ID), `projectId` (inherit from parent), `priority`, `estimate`, `labelIds`, plus `lifecycle_role: ready` when `build_ready` is explicitly `true`.
319
319
  3. Capture identifier.
320
320
  4. Add relationships via Phase 4b.
321
321
 
@@ -729,8 +729,9 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/rollup-blocker-classification.mjs" \
729
729
 
730
730
  3. **If the derived state differs from the parent's current state, apply it** via the vendor's
731
731
  lifecycle write: a JIRA transition; a GitHub label swap in the item's own lifecycle namespace;
732
- or a Linear `save-issue` update that writes the resolved workflow `stateId`, never a lifecycle
733
- label. For GitHub, resolve the lane before writing: PRD parents use the configured `prd.*`
732
+ or a Linear `save-issue lifecycle_role: <derived role> [env: <key>]` update, which resolves the
733
+ configured workflow state in the access layer and writes that — never a lifecycle label, and
734
+ never a state ID this skill picked. For GitHub, resolve the lane before writing: PRD parents use the configured `prd.*`
734
735
  roles, while build tickets use the configured build roles. Keep exactly one lifecycle label in
735
736
  that selected namespace and never substitute or overlap the other namespace. When applying any
736
737
  of these writes, remove conflicting stale lifecycle roles — **including a stale `ready`** the