specline 1.1.2 → 1.2.0

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.
@@ -13,6 +13,7 @@ set -euo pipefail
13
13
  input=$(cat)
14
14
  tool_name=$(echo "$input" | jq -r '.tool_name // empty')
15
15
  tool_input=$(echo "$input" | jq -r '.tool_input // "{}"')
16
+ SESSION_ID=$(echo "$input" | jq -r '.session_id // empty')
16
17
 
17
18
  SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
18
19
  PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
@@ -20,28 +21,35 @@ CHANGES_DIR="$PROJECT_ROOT/specline/changes"
20
21
 
21
22
  # ===== 辅助函数 =====
22
23
 
23
- find_active_pipeline() {
24
- if [ ! -d "$CHANGES_DIR" ]; then
25
- echo ""
26
- return
24
+ resolve_pipeline_for_session() {
25
+ local session_id="$1"
26
+ local bindings_file="$PROJECT_ROOT/specline/.pipeline-sessions.json"
27
+
28
+ if [ ! -f "$bindings_file" ]; then
29
+ return 1
27
30
  fi
28
- for f in "$CHANGES_DIR"/*/.pipeline-state.json; do
29
- [ -f "$f" ] || continue
30
- if echo "$f" | grep -q "/archive/"; then continue; fi
31
- local ph
32
- ph=$(jq -r '.current_phase // ""' "$f" 2>/dev/null)
33
- if [ "$ph" != "archive" ] && [ "$ph" != "" ]; then
34
- echo "$f"
35
- return
36
- fi
37
- done
38
- echo ""
39
- }
40
31
 
41
- STATE_FILE=$(find_active_pipeline)
32
+ local change_name
33
+ change_name=$(jq -r --arg sid "$session_id" '.[$sid].change // empty' "$bindings_file" 2>/dev/null)
34
+
35
+ if [ -z "$change_name" ]; then
36
+ return 1
37
+ fi
38
+
39
+ local state_file="$CHANGES_DIR/$change_name/.pipeline-state.json"
40
+
41
+ if [ ! -f "$state_file" ]; then
42
+ # 脏数据:绑定存在但 pipeline 已不存在,清理绑定
43
+ jq --arg sid "$session_id" 'del(.[$sid])' "$bindings_file" > "$bindings_file.tmp" && mv "$bindings_file.tmp" "$bindings_file"
44
+ return 1
45
+ fi
46
+
47
+ STATE_FILE="$state_file"
48
+ return 0
49
+ }
42
50
 
43
- # 无活跃 pipeline → 透明
44
- if [ -z "$STATE_FILE" ]; then
51
+ STATE_FILE=""
52
+ if ! resolve_pipeline_for_session "$SESSION_ID"; then
45
53
  echo '{}'
46
54
  exit 0
47
55
  fi
@@ -1,15 +1,25 @@
1
1
  #!/usr/bin/env bash
2
- # specline-session-start.sh — sessionStart Hook
3
- # 新会话启动时检测活跃 pipeline 并注入上下文到 Agent 系统提示
2
+ # specline-session-start.sh — sessionStart Hook (Pipeline Session Binding)
3
+ #
4
+ # 新会话启动时:
5
+ # 1. 清理过期绑定(7 天未更新)
6
+ # 2. 检查当前 session 是否已有绑定
7
+ # 3. 有绑定且 pipeline 仍活跃 → 使用已有绑定,注入上下文
8
+ # 4. 有绑定但 pipeline 已失效 → 清理脏数据,重新扫描
9
+ # 5. 无绑定 → 透明放行(echo '{}')——不自动绑定,避免跨窗口污染
4
10
  #
5
11
  # Input (stdin JSON):
6
- # { "session_id": "...", "is_background_agent": bool, "composer_mode": "agent"|"ask"|"edit", ... }
12
+ # { "session_id": "...", "is_background_agent": bool, ... }
7
13
  #
8
14
  # Output (stdout JSON):
9
15
  # { "additional_context": "<pipeline 上下文>" } 或 {}(无活跃 pipeline)
10
16
 
11
17
  set -euo pipefail
12
18
 
19
+ # ============================================================================
20
+ # Input
21
+ # ============================================================================
22
+
13
23
  input=$(cat)
14
24
 
15
25
  # 跳过 background agent(子 Agent 不需要 pipeline 上下文)
@@ -19,116 +29,231 @@ if [ "$is_bg" = "true" ]; then
19
29
  exit 0
20
30
  fi
21
31
 
32
+ session_id=$(echo "$input" | jq -r '.session_id // empty')
33
+ if [ -z "$session_id" ]; then
34
+ echo '{}'
35
+ exit 0
36
+ fi
37
+
38
+ # ============================================================================
39
+ # Paths
40
+ # ============================================================================
41
+
22
42
  SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
23
43
  PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
44
+ BINDINGS_FILE="$PROJECT_ROOT/specline/.pipeline-sessions.json"
24
45
  CHANGES_DIR="$PROJECT_ROOT/specline/changes"
25
46
 
26
- # 无 changes 目录 → 无 pipeline
27
- if [ ! -d "$CHANGES_DIR" ]; then
28
- echo '{}'
29
- exit 0
30
- fi
47
+ # ============================================================================
48
+ # Helper Functions
49
+ # ============================================================================
50
+
51
+ # init_bindings_file — ensure bindings file exists with a valid JSON object
52
+ init_bindings_file() {
53
+ if [ ! -f "$BINDINGS_FILE" ]; then
54
+ mkdir -p "$(dirname "$BINDINGS_FILE")"
55
+ echo '{}' > "$BINDINGS_FILE"
56
+ fi
57
+
58
+ # Validate it's readable JSON; reset if corrupted
59
+ if ! jq empty "$BINDINGS_FILE" 2>/dev/null; then
60
+ echo '{}' > "$BINDINGS_FILE"
61
+ fi
62
+ }
31
63
 
32
- # 扫描活跃 pipeline(排除 archive/)
33
- active_pipelines=""
34
- pipeline_count=0
35
- first_change=""
36
- first_phase=""
64
+ # clean_expired_bindings remove bindings where bound_at > 7 days ago
65
+ # Uses ISO-8601 lexical comparison: works on both macOS and Linux
66
+ clean_expired_bindings() {
67
+ [ -f "$BINDINGS_FILE" ] || return 0
37
68
 
38
- for state_file in "$CHANGES_DIR"/*/.pipeline-state.json; do
39
- [ -f "$state_file" ] || continue
69
+ local cutoff_date
70
+ # macOS date, fallback to Linux date
71
+ cutoff_date=$(date -u -v-7d +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || \
72
+ date -u -d '7 days ago' +%Y-%m-%dT%H:%M:%SZ 2>/dev/null)
40
73
 
41
- # 跳过 archive/
42
- parent_dir=$(dirname "$state_file")
43
- if echo "$parent_dir" | grep -q "/archive/"; then
44
- continue
74
+ if [ -z "$cutoff_date" ]; then
75
+ return 0 # Cannot determine cutoff, skip cleanup
45
76
  fi
46
77
 
47
- change_name=$(jq -r '.change_name // "unknown"' "$state_file" 2>/dev/null)
78
+ local tmp_file="${BINDINGS_FILE}.tmp"
79
+ # Keep only entries whose bound_at is >= cutoff (i.e. within the last 7 days)
80
+ jq --arg cutoff "$cutoff_date" \
81
+ 'with_entries(select(.value.bound_at >= $cutoff))' \
82
+ "$BINDINGS_FILE" > "$tmp_file" 2>/dev/null && \
83
+ mv "$tmp_file" "$BINDINGS_FILE" 2>/dev/null || true
84
+ }
85
+
86
+ # is_pipeline_active — check if a pipeline's state file exists and is not archived
87
+ # Returns 0 (active) or 1 (inactive)
88
+ is_pipeline_active() {
89
+ local change_name="$1"
90
+ local state_file="$CHANGES_DIR/$change_name/.pipeline-state.json"
91
+
92
+ [ -f "$state_file" ] || return 1
93
+
94
+ local phase
48
95
  phase=$(jq -r '.current_phase // "unknown"' "$state_file" 2>/dev/null)
49
- status=$(jq -r ".phases.${phase}.status // \"unknown\"" "$state_file" 2>/dev/null)
96
+ [ "$phase" != "archive" ] || return 1
50
97
 
51
- # 跳过已完成/已归档的
52
- if [ "$phase" = "archive" ] || [ "$status" = "completed" ]; then
53
- continue
54
- fi
98
+ return 0
99
+ }
55
100
 
56
- # 收集活跃 pipeline 信息
57
- if [ $pipeline_count -eq 0 ]; then
58
- first_change="$change_name"
59
- first_phase="$phase"
101
+ # scan_active_pipelines scan changes/ (excluding archive/) for active pipelines
102
+ # Output: one line per pipeline: change_name|phase|state_file_path
103
+ scan_active_pipelines() {
104
+ if [ ! -d "$CHANGES_DIR" ]; then
105
+ return 0
60
106
  fi
61
- pipeline_count=$((pipeline_count + 1))
62
107
 
63
- # 构建任务进度信息
64
- completed=$(jq -r '[.phases.coding.tasks[]? | select(.status == "completed")] | length' "$state_file" 2>/dev/null || echo "0")
65
- total=$(jq -r '[.phases.coding.tasks[]?] | length' "$state_file" 2>/dev/null || echo "0")
108
+ for state_file in "$CHANGES_DIR"/*/.pipeline-state.json; do
109
+ [ -f "$state_file" ] || continue
66
110
 
67
- active_pipelines="${active_pipelines}- **${change_name}**: ${phase} 阶段"
68
- if [ "$total" != "0" ] && [ "$phase" = "coding" ]; then
69
- active_pipelines="${active_pipelines} (${completed}/${total} 任务完成)"
70
- fi
71
- active_pipelines="${active_pipelines}\n"
72
- done
111
+ # Exclude archive/
112
+ case "$state_file" in
113
+ */archive/*) continue ;;
114
+ esac
73
115
 
74
- # 无活跃 pipeline → 透明放行
75
- if [ $pipeline_count -eq 0 ]; then
76
- echo '{}'
77
- exit 0
78
- fi
116
+ local change_name phase
117
+ change_name=$(jq -r '.change_name // ""' "$state_file" 2>/dev/null)
118
+ phase=$(jq -r '.current_phase // ""' "$state_file" 2>/dev/null)
119
+
120
+ [ -n "$change_name" ] || continue
121
+ [ "$phase" != "archive" ] || continue
79
122
 
80
- # 构建阶段约束说明
123
+ printf '%s|%s|%s\n' "$change_name" "$phase" "$state_file"
124
+ done
125
+ }
126
+
127
+ # phase_constraints — output constraint text for a given phase (to stdout)
81
128
  phase_constraints() {
82
- case "$1" in
129
+ local phase="$1"
130
+ case "$phase" in
83
131
  spec)
84
- echo "- 只能通过 specline-spec-creator / specline-spec-reviewer 子 Agent 工作"
85
- echo "- 禁止编辑任何应用代码文件(.ts/.tsx/.py/.go 等)"
86
- echo "- 规划文件生成后需运行 Spec Gate"
132
+ printf '%s\n' "- 只能通过 specline-spec-creator / specline-spec-reviewer 子 Agent 工作"
133
+ printf '%s\n' "- 禁止编辑任何应用代码文件(.ts/.tsx/.py/.go 等)"
134
+ printf '%s\n' "- 规划文件生成后需运行 Spec Gate"
87
135
  ;;
88
136
  coding)
89
- echo "- 编码必须通过子 Agent:specline-frontend-dev / specline-backend-dev"
90
- echo "- 禁止直接编辑应用代码文件"
91
- echo "- 每批次任务完成后运行 Build Gate"
92
- echo "- 每个 Task 完成后更新 tasks.md 的 checkbox"
137
+ printf '%s\n' "- 编码必须通过子 Agent:specline-frontend-dev / specline-backend-dev / specline-config-dev"
138
+ printf '%s\n' "- 禁止直接编辑应用代码文件"
139
+ printf '%s\n' "- 每批次任务完成后运行 Build Gate"
140
+ printf '%s\n' "- 每个 Task 完成后更新 tasks.md 的 checkbox"
93
141
  ;;
94
142
  code_review)
95
- echo "- 只能运行 specline-code-reviewer + Lint Gate"
96
- echo "- 如需修复代码,通过子 Agent 完成"
143
+ printf '%s\n' "- 只能运行 specline-code-reviewer + Lint Gate"
144
+ printf '%s\n' "- 如需修复代码,通过子 Agent 完成"
97
145
  ;;
98
146
  test)
99
- echo "- 运行测试 Gate 链:unit → integration → e2e"
100
- echo "- 测试失败时通过 specline-test-runner 分析原因"
101
- echo "- 代码修复通过子 Agent,测试修复通过 specline-test-writer"
147
+ printf '%s\n' "- 运行测试 Gate 链:unit → integration → e2e"
148
+ printf '%s\n' "- 测试失败时通过 specline-test-runner 分析原因"
149
+ printf '%s\n' "- 代码修复通过子 Agent,测试修复通过 specline-test-writer"
102
150
  ;;
103
151
  *)
104
- echo "- 遵循 specline-pipeline SKILL 的当前阶段约束"
152
+ printf '%s\n' "- 遵循 specline-pipeline SKILL 的当前阶段约束"
105
153
  ;;
106
154
  esac
107
155
  }
108
156
 
109
- # 生成上下文
110
- constraints=$(phase_constraints "$first_phase")
111
-
112
- # 用 printf 构建 JSON,避免 heredoc 中的转义问题
113
- # 注意:additional_context 内容中的换行符和引号需要正确转义
114
- context_text=""
115
- context_text+="🚨 **Specline Pipeline 运行中**\n\n"
116
- if [ $pipeline_count -eq 1 ]; then
117
- context_text+="**当前变更**: ${first_change}\n"
118
- context_text+="**当前阶段**: ${first_phase}\n\n"
119
- else
120
- context_text+="**活跃变更** (${pipeline_count} 个):\n${active_pipelines}\n"
121
- fi
122
- context_text+="**阶段约束**:\n${constraints}\n\n"
123
- context_text+="**重要**: 你是 Specline Pipeline 编排者。上述约束具有最高优先级,必须在每个操作前检查是否符合当前阶段要求。"
157
+ # build_context — generate a JSON-escaped additional_context string for a bound pipeline
158
+ build_context() {
159
+ local change_name="$1"
160
+ local phase="$2"
161
+ local state_file="$3"
162
+
163
+ {
164
+ printf '🚨 **Specline Pipeline 运行中**\n\n'
165
+ printf '**当前变更**: %s\n' "$change_name"
166
+ printf '**当前阶段**: %s\n' "$phase"
124
167
 
125
- # 转义 JSON 字符串
126
- escaped_context=$(echo -e "$context_text" | jq -Rs '.')
168
+ # Task progress for coding phase
169
+ if [ "$phase" = "coding" ] && [ -f "$state_file" ]; then
170
+ local completed total
171
+ completed=$(jq -r '[.phases.coding.tasks[]? | select(.status == "completed")] | length' "$state_file" 2>/dev/null || printf '0')
172
+ total=$(jq -r '[.phases.coding.tasks[]?] | length' "$state_file" 2>/dev/null || printf '0')
173
+ if [ "$total" != "0" ]; then
174
+ printf '**任务进度**: %s/%s 完成\n' "$completed" "$total"
175
+ fi
176
+ fi
127
177
 
128
- cat << EOF
129
- {
130
- "additional_context": ${escaped_context}
178
+ printf '\n**阶段约束**:\n'
179
+ phase_constraints "$phase"
180
+ printf '\n'
181
+ printf '**重要**: 你是 Specline Pipeline 编排者。上述约束具有最高优先级,必须在每个操作前检查是否符合当前阶段要求。'
182
+ } | jq -Rs '.'
131
183
  }
132
- EOF
133
184
 
185
+ # phase_constraint_table — static phase constraint reference table (markdown)
186
+ phase_constraint_table() {
187
+ cat << 'TABLEEOF'
188
+ | 阶段 | 约束 |
189
+ |------|------|
190
+ | spec | 只能通过 specline-spec-creator/specline-spec-reviewer 子 Agent 工作,禁止编辑代码 |
191
+ | coding | 必须通过子 Agent 编码,批次完成后运行 Build Gate,更新 tasks.md |
192
+ | code_review | 只能运行 specline-code-reviewer + Lint Gate |
193
+ | test | 运行测试 Gate 链:unit → integration → e2e |
194
+ TABLEEOF
195
+ }
196
+
197
+ # write_binding — write a session_id → change_name binding to .pipeline-sessions.json
198
+ write_binding() {
199
+ local sid="$1"
200
+ local change="$2"
201
+
202
+ local now_iso
203
+ now_iso=$(date -u +%Y-%m-%dT%H:%M:%SZ)
204
+
205
+ local tmp_file="${BINDINGS_FILE}.tmp"
206
+ jq --arg sid "$sid" --arg change "$change" --arg now "$now_iso" \
207
+ '.[$sid] = {"change": $change, "bound_at": $now}' \
208
+ "$BINDINGS_FILE" > "$tmp_file" 2>/dev/null && \
209
+ mv "$tmp_file" "$BINDINGS_FILE" 2>/dev/null || true
210
+ }
211
+
212
+ # delete_binding — remove a session_id entry from .pipeline-sessions.json
213
+ delete_binding() {
214
+ local session_id="$1"
215
+
216
+ [ -f "$BINDINGS_FILE" ] || return 0
217
+
218
+ local tmp_file="${BINDINGS_FILE}.tmp"
219
+ jq --arg sid "$session_id" 'del(.[$sid])' "$BINDINGS_FILE" > "$tmp_file" 2>/dev/null && \
220
+ mv "$tmp_file" "$BINDINGS_FILE" 2>/dev/null || true
221
+ }
222
+
223
+ # ============================================================================
224
+ # Main Logic
225
+ # ============================================================================
226
+
227
+ init_bindings_file
228
+
229
+ # 1. Clean expired bindings (bound_at > 7 days ago)
230
+ clean_expired_bindings
231
+
232
+ # 2. Check existing binding for this session
233
+ existing_change=""
234
+ if [ -f "$BINDINGS_FILE" ]; then
235
+ existing_change=$(jq -r --arg sid "$session_id" '.[$sid].change // empty' "$BINDINGS_FILE" 2>/dev/null)
236
+ fi
237
+
238
+ if [ -n "$existing_change" ]; then
239
+ if is_pipeline_active "$existing_change"; then
240
+ # Binding is still valid — use it
241
+ state_file="$CHANGES_DIR/$existing_change/.pipeline-state.json"
242
+ phase=$(jq -r '.current_phase // "unknown"' "$state_file" 2>/dev/null)
243
+
244
+ ctx_json=$(build_context "$existing_change" "$phase" "$state_file")
245
+
246
+ printf '{\n "additional_context": %s\n}\n' "$ctx_json"
247
+ exit 0
248
+ else
249
+ # Dirty data: pipeline archived or deleted — clean up and rescan
250
+ delete_binding "$session_id"
251
+ fi
252
+ fi
253
+
254
+
255
+ # 3. No (valid) binding → transparent pass-through
256
+ # 不再自动绑定或注入任何 pipeline 上下文,避免跨窗口污染。
257
+ # 用户需通过 /specline-pipeline --change <name> 显式绑定。
258
+ echo '{}'
134
259
  exit 0
@@ -9,6 +9,23 @@ metadata:
9
9
  generatedBy: "1.3.1"
10
10
  ---
11
11
 
12
+ ## ⚠️ Mode Check — Read Before Anything Else
13
+
14
+ 探索模式 MUST run in **Ask Mode** for hard read-only enforcement by Cursor.
15
+
16
+ ### Your very first action
17
+
18
+ 1. Check which Cursor mode you are currently in (agent / ask / plan).
19
+ 2. **If you are NOT in Ask Mode** — Output ONLY the following message and STOP. Do not explore, do not read files, do not do anything else:
20
+
21
+ > 探索模式需要在只读(Ask)模式下运行。请点击输入框左侧的模式选择器,切换到 **Ask 模式**,然后重新输入 `/specline-explore`。
22
+
23
+ 3. **If you are in Ask Mode** — You are in a safe, read-only environment. The system guarantees you cannot edit files, create files, rewrite files, delete files, or run commands. Proceed with exploration freely.
24
+
25
+ ### Why this matters
26
+
27
+ Ask Mode is Cursor's only truly read-only mode. In Agent/Plan mode, only Prompt-level instructions prevent code changes — and those can be ignored. Ask Mode enforces read-only at the system level, making it impossible to accidentally modify files during exploration.
28
+
12
29
  > **One-liner**: You're a thinking partner, not an implementer.
13
30
  > **What you can do**: Read code, draw diagrams, compare options, ask questions, challenge assumptions
14
31
  > **What you can't do**: Write implementation code