@codyswann/lisa 2.341.3 → 2.341.5

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 (84) hide show
  1. package/all/copy-overwrite/scripts/lisa-enforcement-fallback.sh +14 -1
  2. package/all/copy-overwrite/scripts/lisa-hooks/block-instruction-file-edits.sh +47 -7
  3. package/all/copy-overwrite/scripts/lisa-hooks/block-no-verify.sh +49 -5
  4. package/all/copy-overwrite/scripts/lisa-hooks/block-shell-json-parsing.sh +20 -1
  5. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  6. package/dist/core/upstream-evidence-manifest.js +12 -10
  7. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  8. package/package.json +1 -1
  9. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  10. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  11. package/plugins/lisa/.codex-plugin/skills/lisa-setup-linear/SKILL.md +13 -6
  12. package/plugins/lisa/commands/setup/linear.md +1 -1
  13. package/plugins/lisa/hooks/block-instruction-file-edits.sh +47 -7
  14. package/plugins/lisa/hooks/block-no-verify.sh +49 -5
  15. package/plugins/lisa/hooks/block-shell-json-parsing.sh +20 -1
  16. package/plugins/lisa/skills/lisa-setup-linear/SKILL.md +14 -7
  17. package/plugins/lisa-agy/commands/lisa/setup/linear.md +1 -1
  18. package/plugins/lisa-agy/hooks/block-instruction-file-edits.sh +47 -7
  19. package/plugins/lisa-agy/hooks/block-shell-json-parsing.sh +20 -1
  20. package/plugins/lisa-agy/plugin.json +1 -1
  21. package/plugins/lisa-agy/skills/lisa-setup-linear/SKILL.md +14 -7
  22. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  23. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  24. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  25. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  26. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  27. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  28. package/plugins/lisa-copilot/commands/lisa/setup/linear.md +1 -1
  29. package/plugins/lisa-copilot/hooks/block-instruction-file-edits.sh +47 -7
  30. package/plugins/lisa-copilot/hooks/block-no-verify.sh +49 -5
  31. package/plugins/lisa-copilot/hooks/block-shell-json-parsing.sh +20 -1
  32. package/plugins/lisa-copilot/skills/lisa-setup-linear/SKILL.md +14 -7
  33. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  34. package/plugins/lisa-cursor/commands/lisa/setup/linear.md +1 -1
  35. package/plugins/lisa-cursor/hooks/block-instruction-file-edits.sh +47 -7
  36. package/plugins/lisa-cursor/hooks/block-no-verify.sh +49 -5
  37. package/plugins/lisa-cursor/hooks/block-shell-json-parsing.sh +20 -1
  38. package/plugins/lisa-cursor/skills/lisa-setup-linear/SKILL.md +14 -7
  39. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  40. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  41. package/plugins/lisa-expo-agy/plugin.json +1 -1
  42. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  43. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  44. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  45. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  46. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  47. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  48. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  49. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  50. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  51. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  52. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  53. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  54. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  55. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  56. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  57. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  58. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  59. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  60. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  61. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  62. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  63. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  64. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  65. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  66. package/plugins/lisa-rails-agy/plugin.json +1 -1
  67. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  68. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  69. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  70. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  71. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  72. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  73. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  74. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  75. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  76. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  77. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  78. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  79. package/plugins/src/base/commands/setup/linear.md +1 -1
  80. package/plugins/src/base/hooks/block-instruction-file-edits.sh +47 -7
  81. package/plugins/src/base/hooks/block-no-verify.sh +49 -5
  82. package/plugins/src/base/hooks/block-shell-json-parsing.sh +20 -1
  83. package/plugins/src/base/skills/lisa-setup-linear/SKILL.md +14 -7
  84. package/scripts/lisa-enforcement-fallback.sh +14 -1
@@ -42,12 +42,48 @@ fi
42
42
  tool_name="$(printf '%s' "$input" | jq -r '.tool_name // empty')"
43
43
  [ -n "$tool_name" ] || exit 0
44
44
 
45
- # Lisa's own bounded bridges write marked regions. A payload carrying a marker
46
- # is replacing a delimited block, not appending prose, so it cannot be the
47
- # unbounded growth this guard exists to stop.
48
- if printf '%s' "$input" | grep -q '<!-- LISA_'; then
49
- exit 0
50
- fi
45
+ # Lisa's own bounded bridges write marked regions the agy project-learnings
46
+ # bridge and cross-pollinate's rule section. The premise of their exemption, as
47
+ # originally stated, is that they "replace in place and cannot grow the file".
48
+ # This enforces that premise rather than trusting it.
49
+ #
50
+ # Both sides have to be a marked region and nothing else:
51
+ #
52
+ # - old_string bounded proves the region really is on disk. Scanning the whole
53
+ # payload was trivially forgeable — any caller could put `<!-- LISA_` in the
54
+ # content it was WRITING and walk past the guard.
55
+ # - new_string bounded is what makes it a replacement rather than an append.
56
+ # old_string alone does not authorize anything: a caller can read a genuine
57
+ # marked region, echo it back verbatim, and still smuggle unbounded prose in
58
+ # after the closing marker. Requiring the written text to be exactly one
59
+ # marked region — whitespace aside — leaves nowhere to put it.
60
+ #
61
+ # Every edit in a MultiEdit must qualify; one unbounded edit taints the batch.
62
+ # Write is absent by construction: it has no old_string and clobbers the whole
63
+ # file, which is precisely the unbounded case.
64
+ case "$tool_name" in
65
+ Edit | MultiEdit)
66
+ if printf '%s' "$input" |
67
+ jq -e '
68
+ # Exactly ONE marked region and nothing else. The inner
69
+ # `(?!<!-- LISA_)` is what makes it one: a plain `[\s\S]*` anchors only
70
+ # the FIRST opening marker and the LAST closing marker, so
71
+ # `region + prose + region` satisfies it and the prose between the two
72
+ # regions rides along outside any marked block.
73
+ def bounded:
74
+ type == "string"
75
+ and test("^\\s*<!-- LISA_[A-Z_]+ -->(?:(?!<!-- LISA_)[\\s\\S])*<!-- LISA_[A-Z_]+ -->\\s*$");
76
+ def replacement_pairs:
77
+ if .tool_input.edits? then [.tool_input.edits[]?]
78
+ else [.tool_input] end;
79
+ replacement_pairs
80
+ | length > 0
81
+ and all(.[]; (.old_string | bounded) and (.new_string | bounded))
82
+ ' >/dev/null 2>&1; then
83
+ exit 0
84
+ fi
85
+ ;;
86
+ esac
51
87
 
52
88
  # Basename match, case-insensitively: `agents.md` and `AGENTS.md` are the same
53
89
  # file on the macOS checkouts this fleet runs on.
@@ -130,9 +166,13 @@ EOF
130
166
  # Write signatures only. A bare mention (`cat AGENTS.md`, `rg x AGENTS.md`)
131
167
  # is a read and must stay allowed — the filename has to appear as the target
132
168
  # of a redirection, a tee, or an in-place sed.
169
+ # `>>?\|?` covers `>`, `>>`, and the noclobber override `>|`. The bare-`>`
170
+ # branch already catches explicit fd forms such as `1>` / `2>>` because the
171
+ # pattern is unanchored and matches the `>` inside them; `>|` was the real
172
+ # gap, since `|` is excluded by the target class and so terminated the match.
133
173
  write_target='[^ |;&]*(agents|claude|copilot-instructions)\.md'
134
174
  if printf '%s' "$command_str" |
135
- grep -Eqi ">>?[[:space:]]*['\"]?$write_target|tee([[:space:]]+-[a-z]+)*[[:space:]]+['\"]?$write_target|sed[[:space:]]+[^|;&]*-i[^|;&]*$write_target"; then
175
+ grep -Eqi ">>?\|?[[:space:]]*['\"]?$write_target|tee([[:space:]]+-[a-z]+)*[[:space:]]+['\"]?$write_target|sed[[:space:]]+[^|;&]*-i[^|;&]*$write_target"; then
136
176
  refuse "$(printf '%s' "$command_str" |
137
177
  grep -Eoi "$write_target" | head -1)"
138
178
  fi
@@ -97,17 +97,61 @@ except ValueError:
97
97
 
98
98
  normalized_tokens = [token.strip("();|&") for token in tokens]
99
99
 
100
+ # The only relocations that keep a repo's own hooks in play. `.husky` is the
101
+ # husky convention this fleet runs; `.githooks` is the common hand-rolled one.
102
+ # Anything else — including "" and /dev/null, which are simply the two most
103
+ # obvious members of the blocked set rather than special cases — is refused.
104
+ PERMITTED_HOOKS_PATHS = {".husky", ".githooks"}
105
+
106
+
107
+ def is_permitted_hooks_path(value):
108
+ """Whether a core.hooksPath value relocates hooks rather than disabling them.
109
+
110
+ Args:
111
+ value: The raw core.hooksPath value as it appeared on the command line.
112
+
113
+ Returns:
114
+ True if the path is an established in-repo hooks directory.
115
+ """
116
+ cleaned = value.strip().strip("'\"")
117
+ if cleaned.startswith("./"):
118
+ cleaned = cleaned[2:]
119
+ return cleaned.rstrip("/") in PERMITTED_HOOKS_PATHS
120
+
100
121
  for i, token in enumerate(normalized_tokens):
101
122
  if token == "--no-verify":
102
123
  sys.exit(1)
103
124
  if token == "HUSKY=0" or token.startswith("HUSKY_SKIP_HOOKS="):
104
125
  sys.exit(1)
105
- if token.startswith("core.hooksPath="):
106
- value = token.split("=", 1)[1]
107
- if value in ("", "/dev/null"):
126
+ # Allowlist the destinations, do not denylist the disabling ones.
127
+ #
128
+ # This used to block only "" and /dev/null. But hooks are disabled just as
129
+ # completely by pointing hooksPath at any directory that happens to contain
130
+ # none — `-c core.hooksPath=/tmp/empty` bypassed every hook while reading as
131
+ # an ordinary path — so a denylist of "obviously disabling" values can
132
+ # always be stepped around by naming a third thing. The set of paths that
133
+ # DISABLE hooks is unbounded; the set that legitimately relocates them is
134
+ # tiny and known, so the allowlist is the only side that can be enumerated.
135
+ #
136
+ # Matched case-insensitively because git config variable names are:
137
+ # `CORE.HOOKSPATH=/x` and `core.hookspath=/x` are the same setting to git,
138
+ # so a case-sensitive check is bypassed by holding down shift.
139
+ lowered = token.lower()
140
+ if lowered.startswith("core.hookspath="):
141
+ if not is_permitted_hooks_path(token.split("=", 1)[1]):
142
+ sys.exit(1)
143
+ if lowered == "core.hookspath" and i + 1 < len(normalized_tokens):
144
+ if not is_permitted_hooks_path(normalized_tokens[i + 1]):
145
+ sys.exit(1)
146
+ # `git --config-env=core.hooksPath=SOMEVAR` sets the same config, reading
147
+ # the value out of the named environment variable. The path therefore is
148
+ # not in the command at all, so there is nothing to allowlist against —
149
+ # SOMEVAR can hold anything by the time git reads it. Any core.hooksPath
150
+ # routed through --config-env is refused outright.
151
+ if lowered.startswith("--config-env="):
152
+ spec = token.split("=", 1)[1]
153
+ if spec.split("=", 1)[0].strip().lower() == "core.hookspath":
108
154
  sys.exit(1)
109
- if token == "core.hooksPath" and i + 1 < len(normalized_tokens) and normalized_tokens[i + 1] in ("", "/dev/null"):
110
- sys.exit(1)
111
155
 
112
156
  sys.exit(0)
113
157
  PY
@@ -24,6 +24,25 @@ set -euo pipefail
24
24
 
25
25
  input="$(cat)"
26
26
 
27
+ # Probe the interpreters before the first use. Under `set -euo pipefail` an
28
+ # absent jq does not merely skip the parse — the assignment below dies with
29
+ # 127, and 127 is not a refusal, so the guard vanishes and the command runs.
30
+ # Worse, that 127 used to outrank a sibling guard's 2 in
31
+ # lisa-enforcement-fallback.sh, taking a real refusal down with it.
32
+ #
33
+ # Degrading to "allow" stays right: a hook that cannot parse its input cannot
34
+ # tell a bypass from an ordinary command, and failing closed would block every
35
+ # Bash call on a machine missing an interpreter. Doing it QUIETLY is what is
36
+ # wrong — a guard that is silently absent reads exactly like a guard that is
37
+ # passing. Mirrors block-no-verify.sh, which already had this.
38
+ for required in jq python3; do
39
+ if ! command -v "$required" >/dev/null 2>&1; then
40
+ printf 'block-shell-json-parsing: %s not found; JSON-parsing protection is NOT active\n' \
41
+ "$required" >&2
42
+ exit 0
43
+ fi
44
+ done
45
+
27
46
  tool_name="$(printf '%s' "$input" | jq -r '.tool_name // empty')"
28
47
  if [ "$tool_name" != "Bash" ]; then
29
48
  exit 0
@@ -40,7 +59,7 @@ case "$command_str" in
40
59
  *) exit 0 ;;
41
60
  esac
42
61
 
43
- command -v python3 >/dev/null 2>&1 || exit 0
62
+ # python3 is probed with jq at the top now, announced rather than silent.
44
63
 
45
64
  if ! BLOCK_SHELL_JSON_COMMAND="$command_str" python3 - <<'PY'
46
65
  import os
@@ -1,14 +1,21 @@
1
1
  ---
2
2
  name: lisa-setup-linear
3
- description: "Configure Linear as the destination tracker and/or the PRD source for this project. Verifies Linear access (MCP OAuth or a personal API key in OS keychain), resolves the workspace slug and team key, scaffolds the build-queue issue-label namespace (`status:*`) when Linear is the tracker and/or the PRD-lifecycle project-label namespace (`prd-*` + issue-level sentinel) when Linear is the PRD source, writes the `linear` section into `.lisa.config.json`, and offers to set top-level `tracker: \"linear\"` and/or `source: \"linear\"`. Idempotent — re-running updates the existing section and reuses existing labels. No /lisa:setup:atlassian prerequisite."
3
+ description: "Configure Linear as the destination tracker and/or the PRD source for this project. Verifies Linear access (MCP OAuth or a personal API key in OS keychain), resolves the workspace slug and team key, scaffolds the build-queue **workflow states** (`linear.workflow`) when Linear is the tracker and/or the PRD-lifecycle project-label namespace (`prd-*` + issue-level sentinel) when Linear is the PRD source, writes the `linear` section into `.lisa.config.json`, and offers to set top-level `tracker: \"linear\"` and/or `source: \"linear\"`. Idempotent — re-running updates the existing section and reuses existing labels. No /lisa:setup:atlassian prerequisite."
4
4
  allowed-tools: ["Bash", "Read", "Write", "Edit", "Skill", "AskUserQuestion", "mcp__linear-server__authenticate", "mcp__linear-server__complete_authentication"]
5
5
  ---
6
6
 
7
7
  # Setup Linear: $ARGUMENTS
8
8
 
9
- Make Linear a tracker, a PRD source, or both for this project. After this skill, `.lisa.config.json` contains `linear.workspace` (+ `linear.teamKey` when Linear is the tracker), the team carries the lifecycle label namespaces lisa needs, and (optionally) `tracker` / `source` point at Linear.
9
+ Make Linear a tracker, a PRD source, or both for this project. After this skill, `.lisa.config.json` contains `linear.workspace` (+ `linear.teamKey` when Linear is the tracker), the team carries the lifecycle states and label namespaces lisa needs, and (optionally) `tracker` / `source` point at Linear.
10
10
 
11
- Linear's data model splits labels into two **kinds** that matter here: **issue labels** (drive the build queue, `status:*`) and **project labels** (drive the PRD lifecycle, `prd-*`). They are distinct namespaces in Linear and are NOT interchangeable — the build lifecycle lives on Issues, the PRD lifecycle lives on Projects. The sentinel feedback marker is an **issue** label even though it belongs to the PRD flow (Linear's MCP has no project-level comments — see `linear-prd-intake`).
11
+ The two lifecycles run on different primitives, and conflating them is the most common setup error:
12
+
13
+ - **Build queue → native workflow STATES**, read from `linear.workflow.*`. Not labels. See "Why Linear uses states, not labels" in `config-resolution`, and Step 3a below. `lisa-linear-build-intake` reads only these.
14
+ - **PRD lifecycle → PROJECT labels** (`prd-*`), because a PRD is a Linear Project.
15
+
16
+ Project labels and issue labels are distinct namespaces in Linear and are NOT interchangeable — creating an issue label named `prd-ready` will not work for the PRD flow. The one issue label this skill creates is the sentinel feedback marker, which belongs to the PRD flow despite being an issue label (Linear's MCP has no project-level comments — see `linear-prd-intake`).
17
+
18
+ **A `status:*` issue-label namespace is no longer scaffolded or read.** It was the pre-state-model build lane; see "Migrating a project that predates the state model" below for what to do with a config that still carries it.
12
19
 
13
20
  ## Workflow
14
21
 
@@ -28,10 +35,10 @@ Ask two things via `AskUserQuestion`.
28
35
 
29
36
  > What should lisa use Linear for?
30
37
  >
31
- > 1. **Destination tracker** — lisa writes Epics→Projects, Stories→Issues, Sub-tasks→Sub-issues; the build queue runs off the `status:*` issue-label namespace. Sets `tracker: "linear"`. (Requires a team key.)
38
+ > 1. **Destination tracker** — lisa writes Epics→Projects, Stories→Issues, Sub-tasks→Sub-issues; the build queue runs off native workflow **states** (`linear.workflow`), not labels. Sets `tracker: "linear"`. (Requires a team key.)
32
39
  > 2. **PRD source** — humans flag Linear **projects** with `prd-ready`; `/lisa:intake` scans and ticketes them off the `prd-*` project-label namespace. Sets `source: "linear"`.
33
40
 
34
- The role answer drives Step 3 (which label namespaces to scaffold) and whether `teamKey` is required (tracker → yes).
41
+ The role answer drives Step 3 (states for the tracker lane, `prd-*` project labels for the PRD lane) and whether `teamKey` is required (tracker → yes).
35
42
 
36
43
  ### Step 1 — Establish Linear access
37
44
 
@@ -139,7 +146,7 @@ echo "Linear key validated. Org: $(echo "$VIEWER" | jq -r '.data.organization.ur
139
146
  - **Workspace slug**: honor `--workspace=<slug>`. Otherwise derive from the validated identity — the GraphQL `organization.urlKey` (API path) or the team list's workspace (MCP path). Confirm with the user; this slug is the keychain `account` key and the multi-workspace disambiguator.
140
147
  - **Team key** (required when Linear is the **tracker**): honor `--team=<KEY>`. Otherwise enumerate teams via `lisa-linear-access operation: list-teams({})` (or the GraphQL `teams` query) and present them via `AskUserQuestion` (label = team key, description = team name) for the user to pick the team that owns lisa's destination Issues. If Linear is source-only, `teamKey` is optional — skip unless the user wants to pin a team scope.
141
148
 
142
- ### Step 3 — Scaffold the lifecycle label namespaces
149
+ ### Step 3 — Scaffold the lifecycle namespaces
143
150
 
144
151
  Read role → label with the default-fallback ladder the intake skills use, so scaffolded labels match exactly what they query.
145
152
 
@@ -280,7 +287,7 @@ jq -e '.linear.workspace' .lisa.config.json >/dev/null
280
287
  [ "$(jq -r '.tracker // empty' .lisa.config.json)" = "linear" ] && jq -e '.linear.teamKey' .lisa.config.json >/dev/null
281
288
  ```
282
289
 
283
- Confirm the scaffolded labels are present (`list_issue_labels` for `status:*` + the sentinel; `list_project_labels` for `prd-*`, including the terminal `prd-verified`). Report success with the resolved workspace, team key (if any), which namespaces were scaffolded (created vs. already existed), any non-default overrides, and whether `tracker` / `source` were set. Direct the user to `/lisa:intake` to test.
290
+ Confirm what was scaffolded is present: `list-workflow-states` for every build role when Linear is the tracker, `list_project_labels` for `prd-*` (including the terminal `prd-verified`) and `list_issue_labels` for the sentinel when Linear is the PRD source. Do NOT expect a `status:*` namespace — it is not part of this model. Report success with the resolved workspace, team key (if any), which namespaces were scaffolded (created vs. already existed), any non-default overrides, and whether `tracker` / `source` were set. Direct the user to `/lisa:intake` to test.
284
291
 
285
292
  ## Idempotency
286
293
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa",
3
- "version": "2.341.3",
3
+ "version": "2.341.5",
4
4
  "description": "Universal governance — agents, skills, commands, hooks, and rules for all projects",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,5 +1,5 @@
1
1
  ---
2
- description: "Set up Linear as the tracker and/or PRD source for this project. Verifies Linear access (MCP OAuth or a personal API key in keychain), resolves the workspace slug and team key, scaffolds the `status:*` issue-label (build) and/or `prd-*` project-label (PRD) namespaces, writes the `linear` section of `.lisa.config.json`, and offers to set top-level `tracker: \"linear\"` / `source: \"linear\"`. No /lisa:setup:atlassian prerequisite."
2
+ description: "Set up Linear as the tracker and/or PRD source for this project. Verifies Linear access (MCP OAuth or a personal API key in keychain), resolves the workspace slug and team key, scaffolds the build-queue workflow states (`linear.workflow`) and/or `prd-*` project-label (PRD) namespaces, writes the `linear` section of `.lisa.config.json`, and offers to set top-level `tracker: \"linear\"` / `source: \"linear\"`. No /lisa:setup:atlassian prerequisite."
3
3
  allowed-tools: ["Skill"]
4
4
  argument-hint: "[--workspace=<slug>] [--team=<KEY>]"
5
5
  ---
@@ -42,12 +42,48 @@ fi
42
42
  tool_name="$(printf '%s' "$input" | jq -r '.tool_name // empty')"
43
43
  [ -n "$tool_name" ] || exit 0
44
44
 
45
- # Lisa's own bounded bridges write marked regions. A payload carrying a marker
46
- # is replacing a delimited block, not appending prose, so it cannot be the
47
- # unbounded growth this guard exists to stop.
48
- if printf '%s' "$input" | grep -q '<!-- LISA_'; then
49
- exit 0
50
- fi
45
+ # Lisa's own bounded bridges write marked regions the agy project-learnings
46
+ # bridge and cross-pollinate's rule section. The premise of their exemption, as
47
+ # originally stated, is that they "replace in place and cannot grow the file".
48
+ # This enforces that premise rather than trusting it.
49
+ #
50
+ # Both sides have to be a marked region and nothing else:
51
+ #
52
+ # - old_string bounded proves the region really is on disk. Scanning the whole
53
+ # payload was trivially forgeable — any caller could put `<!-- LISA_` in the
54
+ # content it was WRITING and walk past the guard.
55
+ # - new_string bounded is what makes it a replacement rather than an append.
56
+ # old_string alone does not authorize anything: a caller can read a genuine
57
+ # marked region, echo it back verbatim, and still smuggle unbounded prose in
58
+ # after the closing marker. Requiring the written text to be exactly one
59
+ # marked region — whitespace aside — leaves nowhere to put it.
60
+ #
61
+ # Every edit in a MultiEdit must qualify; one unbounded edit taints the batch.
62
+ # Write is absent by construction: it has no old_string and clobbers the whole
63
+ # file, which is precisely the unbounded case.
64
+ case "$tool_name" in
65
+ Edit | MultiEdit)
66
+ if printf '%s' "$input" |
67
+ jq -e '
68
+ # Exactly ONE marked region and nothing else. The inner
69
+ # `(?!<!-- LISA_)` is what makes it one: a plain `[\s\S]*` anchors only
70
+ # the FIRST opening marker and the LAST closing marker, so
71
+ # `region + prose + region` satisfies it and the prose between the two
72
+ # regions rides along outside any marked block.
73
+ def bounded:
74
+ type == "string"
75
+ and test("^\\s*<!-- LISA_[A-Z_]+ -->(?:(?!<!-- LISA_)[\\s\\S])*<!-- LISA_[A-Z_]+ -->\\s*$");
76
+ def replacement_pairs:
77
+ if .tool_input.edits? then [.tool_input.edits[]?]
78
+ else [.tool_input] end;
79
+ replacement_pairs
80
+ | length > 0
81
+ and all(.[]; (.old_string | bounded) and (.new_string | bounded))
82
+ ' >/dev/null 2>&1; then
83
+ exit 0
84
+ fi
85
+ ;;
86
+ esac
51
87
 
52
88
  # Basename match, case-insensitively: `agents.md` and `AGENTS.md` are the same
53
89
  # file on the macOS checkouts this fleet runs on.
@@ -130,9 +166,13 @@ EOF
130
166
  # Write signatures only. A bare mention (`cat AGENTS.md`, `rg x AGENTS.md`)
131
167
  # is a read and must stay allowed — the filename has to appear as the target
132
168
  # of a redirection, a tee, or an in-place sed.
169
+ # `>>?\|?` covers `>`, `>>`, and the noclobber override `>|`. The bare-`>`
170
+ # branch already catches explicit fd forms such as `1>` / `2>>` because the
171
+ # pattern is unanchored and matches the `>` inside them; `>|` was the real
172
+ # gap, since `|` is excluded by the target class and so terminated the match.
133
173
  write_target='[^ |;&]*(agents|claude|copilot-instructions)\.md'
134
174
  if printf '%s' "$command_str" |
135
- grep -Eqi ">>?[[:space:]]*['\"]?$write_target|tee([[:space:]]+-[a-z]+)*[[:space:]]+['\"]?$write_target|sed[[:space:]]+[^|;&]*-i[^|;&]*$write_target"; then
175
+ grep -Eqi ">>?\|?[[:space:]]*['\"]?$write_target|tee([[:space:]]+-[a-z]+)*[[:space:]]+['\"]?$write_target|sed[[:space:]]+[^|;&]*-i[^|;&]*$write_target"; then
136
176
  refuse "$(printf '%s' "$command_str" |
137
177
  grep -Eoi "$write_target" | head -1)"
138
178
  fi
@@ -97,17 +97,61 @@ except ValueError:
97
97
 
98
98
  normalized_tokens = [token.strip("();|&") for token in tokens]
99
99
 
100
+ # The only relocations that keep a repo's own hooks in play. `.husky` is the
101
+ # husky convention this fleet runs; `.githooks` is the common hand-rolled one.
102
+ # Anything else — including "" and /dev/null, which are simply the two most
103
+ # obvious members of the blocked set rather than special cases — is refused.
104
+ PERMITTED_HOOKS_PATHS = {".husky", ".githooks"}
105
+
106
+
107
+ def is_permitted_hooks_path(value):
108
+ """Whether a core.hooksPath value relocates hooks rather than disabling them.
109
+
110
+ Args:
111
+ value: The raw core.hooksPath value as it appeared on the command line.
112
+
113
+ Returns:
114
+ True if the path is an established in-repo hooks directory.
115
+ """
116
+ cleaned = value.strip().strip("'\"")
117
+ if cleaned.startswith("./"):
118
+ cleaned = cleaned[2:]
119
+ return cleaned.rstrip("/") in PERMITTED_HOOKS_PATHS
120
+
100
121
  for i, token in enumerate(normalized_tokens):
101
122
  if token == "--no-verify":
102
123
  sys.exit(1)
103
124
  if token == "HUSKY=0" or token.startswith("HUSKY_SKIP_HOOKS="):
104
125
  sys.exit(1)
105
- if token.startswith("core.hooksPath="):
106
- value = token.split("=", 1)[1]
107
- if value in ("", "/dev/null"):
126
+ # Allowlist the destinations, do not denylist the disabling ones.
127
+ #
128
+ # This used to block only "" and /dev/null. But hooks are disabled just as
129
+ # completely by pointing hooksPath at any directory that happens to contain
130
+ # none — `-c core.hooksPath=/tmp/empty` bypassed every hook while reading as
131
+ # an ordinary path — so a denylist of "obviously disabling" values can
132
+ # always be stepped around by naming a third thing. The set of paths that
133
+ # DISABLE hooks is unbounded; the set that legitimately relocates them is
134
+ # tiny and known, so the allowlist is the only side that can be enumerated.
135
+ #
136
+ # Matched case-insensitively because git config variable names are:
137
+ # `CORE.HOOKSPATH=/x` and `core.hookspath=/x` are the same setting to git,
138
+ # so a case-sensitive check is bypassed by holding down shift.
139
+ lowered = token.lower()
140
+ if lowered.startswith("core.hookspath="):
141
+ if not is_permitted_hooks_path(token.split("=", 1)[1]):
142
+ sys.exit(1)
143
+ if lowered == "core.hookspath" and i + 1 < len(normalized_tokens):
144
+ if not is_permitted_hooks_path(normalized_tokens[i + 1]):
145
+ sys.exit(1)
146
+ # `git --config-env=core.hooksPath=SOMEVAR` sets the same config, reading
147
+ # the value out of the named environment variable. The path therefore is
148
+ # not in the command at all, so there is nothing to allowlist against —
149
+ # SOMEVAR can hold anything by the time git reads it. Any core.hooksPath
150
+ # routed through --config-env is refused outright.
151
+ if lowered.startswith("--config-env="):
152
+ spec = token.split("=", 1)[1]
153
+ if spec.split("=", 1)[0].strip().lower() == "core.hookspath":
108
154
  sys.exit(1)
109
- if token == "core.hooksPath" and i + 1 < len(normalized_tokens) and normalized_tokens[i + 1] in ("", "/dev/null"):
110
- sys.exit(1)
111
155
 
112
156
  sys.exit(0)
113
157
  PY
@@ -24,6 +24,25 @@ set -euo pipefail
24
24
 
25
25
  input="$(cat)"
26
26
 
27
+ # Probe the interpreters before the first use. Under `set -euo pipefail` an
28
+ # absent jq does not merely skip the parse — the assignment below dies with
29
+ # 127, and 127 is not a refusal, so the guard vanishes and the command runs.
30
+ # Worse, that 127 used to outrank a sibling guard's 2 in
31
+ # lisa-enforcement-fallback.sh, taking a real refusal down with it.
32
+ #
33
+ # Degrading to "allow" stays right: a hook that cannot parse its input cannot
34
+ # tell a bypass from an ordinary command, and failing closed would block every
35
+ # Bash call on a machine missing an interpreter. Doing it QUIETLY is what is
36
+ # wrong — a guard that is silently absent reads exactly like a guard that is
37
+ # passing. Mirrors block-no-verify.sh, which already had this.
38
+ for required in jq python3; do
39
+ if ! command -v "$required" >/dev/null 2>&1; then
40
+ printf 'block-shell-json-parsing: %s not found; JSON-parsing protection is NOT active\n' \
41
+ "$required" >&2
42
+ exit 0
43
+ fi
44
+ done
45
+
27
46
  tool_name="$(printf '%s' "$input" | jq -r '.tool_name // empty')"
28
47
  if [ "$tool_name" != "Bash" ]; then
29
48
  exit 0
@@ -40,7 +59,7 @@ case "$command_str" in
40
59
  *) exit 0 ;;
41
60
  esac
42
61
 
43
- command -v python3 >/dev/null 2>&1 || exit 0
62
+ # python3 is probed with jq at the top now, announced rather than silent.
44
63
 
45
64
  if ! BLOCK_SHELL_JSON_COMMAND="$command_str" python3 - <<'PY'
46
65
  import os
@@ -1,14 +1,21 @@
1
1
  ---
2
2
  name: lisa-setup-linear
3
- description: "Configure Linear as the destination tracker and/or the PRD source for this project. Verifies Linear access (MCP OAuth or a personal API key in OS keychain), resolves the workspace slug and team key, scaffolds the build-queue issue-label namespace (`status:*`) when Linear is the tracker and/or the PRD-lifecycle project-label namespace (`prd-*` + issue-level sentinel) when Linear is the PRD source, writes the `linear` section into `.lisa.config.json`, and offers to set top-level `tracker: \"linear\"` and/or `source: \"linear\"`. Idempotent — re-running updates the existing section and reuses existing labels. No /lisa:setup:atlassian prerequisite."
3
+ description: "Configure Linear as the destination tracker and/or the PRD source for this project. Verifies Linear access (MCP OAuth or a personal API key in OS keychain), resolves the workspace slug and team key, scaffolds the build-queue **workflow states** (`linear.workflow`) when Linear is the tracker and/or the PRD-lifecycle project-label namespace (`prd-*` + issue-level sentinel) when Linear is the PRD source, writes the `linear` section into `.lisa.config.json`, and offers to set top-level `tracker: \"linear\"` and/or `source: \"linear\"`. Idempotent — re-running updates the existing section and reuses existing labels. No /lisa:setup:atlassian prerequisite."
4
4
  allowed-tools: ["Bash", "Read", "Write", "Edit", "Skill", "AskUserQuestion", "mcp__linear-server__authenticate", "mcp__linear-server__complete_authentication"]
5
5
  ---
6
6
 
7
7
  # Setup Linear: $ARGUMENTS
8
8
 
9
- Make Linear a tracker, a PRD source, or both for this project. After this skill, `.lisa.config.json` contains `linear.workspace` (+ `linear.teamKey` when Linear is the tracker), the team carries the lifecycle label namespaces lisa needs, and (optionally) `tracker` / `source` point at Linear.
9
+ Make Linear a tracker, a PRD source, or both for this project. After this skill, `.lisa.config.json` contains `linear.workspace` (+ `linear.teamKey` when Linear is the tracker), the team carries the lifecycle states and label namespaces lisa needs, and (optionally) `tracker` / `source` point at Linear.
10
10
 
11
- Linear's data model splits labels into two **kinds** that matter here: **issue labels** (drive the build queue, `status:*`) and **project labels** (drive the PRD lifecycle, `prd-*`). They are distinct namespaces in Linear and are NOT interchangeable — the build lifecycle lives on Issues, the PRD lifecycle lives on Projects. The sentinel feedback marker is an **issue** label even though it belongs to the PRD flow (Linear's MCP has no project-level comments — see `linear-prd-intake`).
11
+ The two lifecycles run on different primitives, and conflating them is the most common setup error:
12
+
13
+ - **Build queue → native workflow STATES**, read from `linear.workflow.*`. Not labels. See "Why Linear uses states, not labels" in `config-resolution`, and Step 3a below. `lisa-linear-build-intake` reads only these.
14
+ - **PRD lifecycle → PROJECT labels** (`prd-*`), because a PRD is a Linear Project.
15
+
16
+ Project labels and issue labels are distinct namespaces in Linear and are NOT interchangeable — creating an issue label named `prd-ready` will not work for the PRD flow. The one issue label this skill creates is the sentinel feedback marker, which belongs to the PRD flow despite being an issue label (Linear's MCP has no project-level comments — see `linear-prd-intake`).
17
+
18
+ **A `status:*` issue-label namespace is no longer scaffolded or read.** It was the pre-state-model build lane; see "Migrating a project that predates the state model" below for what to do with a config that still carries it.
12
19
 
13
20
  ## Workflow
14
21
 
@@ -28,10 +35,10 @@ Ask two things via `AskUserQuestion`.
28
35
 
29
36
  > What should lisa use Linear for?
30
37
  >
31
- > 1. **Destination tracker** — lisa writes Epics→Projects, Stories→Issues, Sub-tasks→Sub-issues; the build queue runs off the `status:*` issue-label namespace. Sets `tracker: "linear"`. (Requires a team key.)
38
+ > 1. **Destination tracker** — lisa writes Epics→Projects, Stories→Issues, Sub-tasks→Sub-issues; the build queue runs off native workflow **states** (`linear.workflow`), not labels. Sets `tracker: "linear"`. (Requires a team key.)
32
39
  > 2. **PRD source** — humans flag Linear **projects** with `prd-ready`; `/lisa:intake` scans and ticketes them off the `prd-*` project-label namespace. Sets `source: "linear"`.
33
40
 
34
- The role answer drives Step 3 (which label namespaces to scaffold) and whether `teamKey` is required (tracker → yes).
41
+ The role answer drives Step 3 (states for the tracker lane, `prd-*` project labels for the PRD lane) and whether `teamKey` is required (tracker → yes).
35
42
 
36
43
  ### Step 1 — Establish Linear access
37
44
 
@@ -139,7 +146,7 @@ echo "Linear key validated. Org: $(echo "$VIEWER" | jq -r '.data.organization.ur
139
146
  - **Workspace slug**: honor `--workspace=<slug>`. Otherwise derive from the validated identity — the GraphQL `organization.urlKey` (API path) or the team list's workspace (MCP path). Confirm with the user; this slug is the keychain `account` key and the multi-workspace disambiguator.
140
147
  - **Team key** (required when Linear is the **tracker**): honor `--team=<KEY>`. Otherwise enumerate teams via `lisa-linear-access operation: list-teams({})` (or the GraphQL `teams` query) and present them via `AskUserQuestion` (label = team key, description = team name) for the user to pick the team that owns lisa's destination Issues. If Linear is source-only, `teamKey` is optional — skip unless the user wants to pin a team scope.
141
148
 
142
- ### Step 3 — Scaffold the lifecycle label namespaces
149
+ ### Step 3 — Scaffold the lifecycle namespaces
143
150
 
144
151
  Read role → label with the default-fallback ladder the intake skills use, so scaffolded labels match exactly what they query.
145
152
 
@@ -280,7 +287,7 @@ jq -e '.linear.workspace' .lisa.config.json >/dev/null
280
287
  [ "$(jq -r '.tracker // empty' .lisa.config.json)" = "linear" ] && jq -e '.linear.teamKey' .lisa.config.json >/dev/null
281
288
  ```
282
289
 
283
- Confirm the scaffolded labels are present (`list_issue_labels` for `status:*` + the sentinel; `list_project_labels` for `prd-*`, including the terminal `prd-verified`). Report success with the resolved workspace, team key (if any), which namespaces were scaffolded (created vs. already existed), any non-default overrides, and whether `tracker` / `source` were set. Direct the user to `/lisa:intake` to test.
290
+ Confirm what was scaffolded is present: `list-workflow-states` for every build role when Linear is the tracker, `list_project_labels` for `prd-*` (including the terminal `prd-verified`) and `list_issue_labels` for the sentinel when Linear is the PRD source. Do NOT expect a `status:*` namespace — it is not part of this model. Report success with the resolved workspace, team key (if any), which namespaces were scaffolded (created vs. already existed), any non-default overrides, and whether `tracker` / `source` were set. Direct the user to `/lisa:intake` to test.
284
291
 
285
292
  ## Idempotency
286
293
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-expo",
3
- "version": "2.341.3",
3
+ "version": "2.341.5",
4
4
  "description": "Expo/React Native-specific skills, agents, rules, and MCP servers",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-expo",
3
- "version": "2.341.3",
3
+ "version": "2.341.5",
4
4
  "description": "Expo and React Native-specific skills, agents, rules, and MCP servers.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-expo",
3
- "version": "2.341.3",
3
+ "version": "2.341.5",
4
4
  "description": "Expo/React Native-specific skills, agents, rules, and MCP servers",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-expo",
3
- "version": "2.341.3",
3
+ "version": "2.341.5",
4
4
  "description": "Expo/React Native-specific skills, agents, rules, and MCP servers",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-expo",
3
- "version": "2.341.3",
3
+ "version": "2.341.5",
4
4
  "description": "Expo/React Native-specific skills, agents, rules, and MCP servers",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-harper-fabric",
3
- "version": "2.341.3",
3
+ "version": "2.341.5",
4
4
  "description": "Harper/Fabric-specific rules for TypeScript component apps",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-harper-fabric",
3
- "version": "2.341.3",
3
+ "version": "2.341.5",
4
4
  "description": "Harper/Fabric-specific Lisa rules for TypeScript component apps.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-harper-fabric",
3
- "version": "2.341.3",
3
+ "version": "2.341.5",
4
4
  "description": "Harper/Fabric-specific rules for TypeScript component apps",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-harper-fabric",
3
- "version": "2.341.3",
3
+ "version": "2.341.5",
4
4
  "description": "Harper/Fabric-specific rules for TypeScript component apps",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-harper-fabric",
3
- "version": "2.341.3",
3
+ "version": "2.341.5",
4
4
  "description": "Harper/Fabric-specific rules for TypeScript component apps",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-nestjs",
3
- "version": "2.341.3",
3
+ "version": "2.341.5",
4
4
  "description": "NestJS-specific skills (GraphQL, TypeORM) and hooks (migration write-protection)",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-nestjs",
3
- "version": "2.341.3",
3
+ "version": "2.341.5",
4
4
  "description": "NestJS-specific skills and migration write-protection hooks.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-nestjs",
3
- "version": "2.341.3",
3
+ "version": "2.341.5",
4
4
  "description": "NestJS-specific skills (GraphQL, TypeORM) and hooks (migration write-protection)",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-nestjs",
3
- "version": "2.341.3",
3
+ "version": "2.341.5",
4
4
  "description": "NestJS-specific skills (GraphQL, TypeORM) and hooks (migration write-protection)",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-nestjs",
3
- "version": "2.341.3",
3
+ "version": "2.341.5",
4
4
  "description": "NestJS-specific skills (GraphQL, TypeORM) and hooks (migration write-protection)",
5
5
  "author": {
6
6
  "name": "Cody Swann"