specline 1.1.2 → 1.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "specline",
3
- "version": "1.1.2",
3
+ "version": "1.1.3",
4
4
  "description": "Spec-driven AI coding pipeline with deterministic quality gates for Cursor IDE",
5
5
  "bin": {
6
6
  "specline": "./cli.mjs"
@@ -12,6 +12,7 @@ set -euo pipefail
12
12
 
13
13
  input=$(cat)
14
14
  subagent_type=$(echo "$input" | jq -r '.subagent_type // empty')
15
+ SESSION_ID=$(echo "$input" | jq -r '.session_id // empty')
15
16
 
16
17
  # 非 specline agent → 放行(不受 Specline 管控)
17
18
  if ! echo "$subagent_type" | grep -qE "^specline-"; then
@@ -32,29 +33,36 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
32
33
  PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
33
34
  CHANGES_DIR="$PROJECT_ROOT/specline/changes"
34
35
 
35
- # 查找活跃 pipeline
36
- find_active_pipeline() {
37
- if [ ! -d "$CHANGES_DIR" ]; then
38
- echo ""
39
- return
36
+ # 通过 session_id 解析绑定的 pipeline
37
+ resolve_pipeline_for_session() {
38
+ local session_id="$1"
39
+ local bindings_file="$PROJECT_ROOT/specline/.pipeline-sessions.json"
40
+
41
+ if [ ! -f "$bindings_file" ]; then
42
+ return 1
40
43
  fi
41
- for f in "$CHANGES_DIR"/*/.pipeline-state.json; do
42
- [ -f "$f" ] || continue
43
- if echo "$f" | grep -q "/archive/"; then continue; fi
44
- local ph
45
- ph=$(jq -r '.current_phase // ""' "$f" 2>/dev/null)
46
- if [ "$ph" != "archive" ] && [ "$ph" != "" ]; then
47
- echo "$f"
48
- return
49
- fi
50
- done
51
- echo ""
52
- }
53
44
 
54
- STATE_FILE=$(find_active_pipeline)
45
+ local change_name
46
+ change_name=$(jq -r --arg sid "$session_id" '.[$sid].change // empty' "$bindings_file" 2>/dev/null)
47
+
48
+ if [ -z "$change_name" ]; then
49
+ return 1
50
+ fi
51
+
52
+ local state_file="$CHANGES_DIR/$change_name/.pipeline-state.json"
53
+
54
+ if [ ! -f "$state_file" ]; then
55
+ # 脏数据:pipeline 已归档或不存在,清理绑定
56
+ jq --arg sid "$session_id" 'del(.[$sid])' "$bindings_file" > "${bindings_file}.tmp" && mv "${bindings_file}.tmp" "$bindings_file"
57
+ return 1
58
+ fi
59
+
60
+ STATE_FILE="$state_file"
61
+ return 0
62
+ }
55
63
 
56
- # 无活跃 pipeline → 放行(不在流水线中的会话可以使用任何 specline agent)
57
- if [ -z "$STATE_FILE" ]; then
64
+ STATE_FILE=""
65
+ if ! resolve_pipeline_for_session "$SESSION_ID"; then
58
66
  echo '{"permission": "allow"}'
59
67
  exit 0
60
68
  fi
@@ -11,6 +11,7 @@
11
11
  set -euo pipefail
12
12
 
13
13
  input=$(cat)
14
+ SESSION_ID=$(echo "$input" | jq -r '.session_id // empty')
14
15
  tool_name=$(echo "$input" | jq -r '.tool_name // empty')
15
16
  tool_input=$(echo "$input" | jq -r '.tool_input // "{}"')
16
17
 
@@ -18,27 +19,27 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
18
19
  PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
19
20
  CHANGES_DIR="$PROJECT_ROOT/specline/changes"
20
21
 
21
- # ===== 辅助函数 =====
22
+ BINDINGS_FILE="$PROJECT_ROOT/specline/.pipeline-sessions.json"
23
+ [ ! -f "$BINDINGS_FILE" ] && echo '{}' > "$BINDINGS_FILE"
22
24
 
23
- # 查找第一个活跃 pipeline(排除 archive/)
24
- find_active_pipeline() {
25
- if [ ! -d "$CHANGES_DIR" ]; then
26
- echo ""
27
- return
25
+ resolve_pipeline_for_session() {
26
+ local session_id="$1"
27
+ local change_name
28
+ change_name=$(jq -r --arg sid "$session_id" '.[$sid].change // empty' "$BINDINGS_FILE" 2>/dev/null)
29
+ if [ -z "$change_name" ]; then
30
+ return 1
28
31
  fi
29
- for f in "$CHANGES_DIR"/*/.pipeline-state.json; do
30
- [ -f "$f" ] || continue
31
- if echo "$f" | grep -q "/archive/"; then continue; fi
32
- local ph
33
- ph=$(jq -r '.current_phase // ""' "$f" 2>/dev/null)
34
- if [ "$ph" != "archive" ] && [ "$ph" != "" ]; then
35
- echo "$f"
36
- return
37
- fi
38
- done
39
- echo ""
32
+ local sf="$PROJECT_ROOT/specline/changes/$change_name/.pipeline-state.json"
33
+ if [ ! -f "$sf" ]; then
34
+ jq --arg sid "$session_id" 'del(.[$sid])' "$BINDINGS_FILE" > "${BINDINGS_FILE}.tmp" && mv "${BINDINGS_FILE}.tmp" "$BINDINGS_FILE"
35
+ return 1
36
+ fi
37
+ STATE_FILE="$sf"
38
+ return 0
40
39
  }
41
40
 
41
+ # ===== 辅助函数 =====
42
+
42
43
  deny() {
43
44
  local user_msg="$1"
44
45
  local agent_msg="$2"
@@ -60,11 +61,9 @@ allow() {
60
61
 
61
62
  # ===== 主逻辑 =====
62
63
 
63
- STATE_FILE=$(find_active_pipeline)
64
-
65
- # 无活跃 pipeline → 透明放行
66
- if [ -z "$STATE_FILE" ]; then
67
- allow
64
+ STATE_FILE=""
65
+ if ! resolve_pipeline_for_session "$SESSION_ID"; then
66
+ allow # 无绑定 → 透明放行
68
67
  exit 0
69
68
  fi
70
69
 
@@ -595,6 +595,15 @@ gate_archive() {
595
595
  sed -i '' "s/\"current_phase\": \"[^\"]*\"/\"current_phase\": \"archived\"/g" "$dest/.pipeline-state.json" 2>/dev/null || true
596
596
  fi
597
597
 
598
+ # 清理所有绑定到该 change 的 session
599
+ local bindings_file="$PROJECT_ROOT/specline/.pipeline-sessions.json"
600
+ if [ -f "$bindings_file" ]; then
601
+ jq --arg change "$CHANGE" \
602
+ 'with_entries(select(.value.change != $change))' \
603
+ "$bindings_file" > "${bindings_file}.tmp" && mv "${bindings_file}.tmp" "$bindings_file"
604
+ echo "✅ 已清理 pipeline '$CHANGE' 的所有 session 绑定"
605
+ fi
606
+
598
607
  exit 0
599
608
  fi
600
609
 
@@ -639,6 +648,31 @@ gate_status() {
639
648
  }' "$STATE_FILE"
640
649
  }
641
650
 
651
+ gate_bind() {
652
+ local session_id="$1"
653
+ local target_change="$2"
654
+
655
+ if [ -z "$session_id" ] || [ -z "$target_change" ]; then
656
+ fail "需要 <session_id> <change_name>"
657
+ fi
658
+
659
+ local state_file="$PROJECT_ROOT/specline/changes/$target_change/.pipeline-state.json"
660
+ if [ ! -f "$state_file" ]; then
661
+ fail "Change '$target_change' 不存在"
662
+ fi
663
+
664
+ local bindings_file="$PROJECT_ROOT/specline/.pipeline-sessions.json"
665
+ [ ! -f "$bindings_file" ] && echo '{}' > "$bindings_file"
666
+
667
+ local now
668
+ now=$(now_iso8601)
669
+ jq --arg sid "$session_id" --arg change "$target_change" --arg time "$now" \
670
+ '.[$sid] = {"change": $change, "bound_at": $time}' \
671
+ "$bindings_file" > "${bindings_file}.tmp" && mv "${bindings_file}.tmp" "$bindings_file"
672
+
673
+ echo "✅ 已绑定 session '$session_id' → pipeline '$target_change'"
674
+ }
675
+
642
676
  # ===== 分派 =====
643
677
 
644
678
  case "$PHASE" in
@@ -669,9 +703,12 @@ case "$PHASE" in
669
703
  test-e2e)
670
704
  gate_test_e2e
671
705
  ;;
706
+ bind)
707
+ gate_bind "$2" "$3"
708
+ ;;
672
709
  archive)
673
710
  gate_archive "$@"
674
- ;;
711
+ ;;
675
712
  status)
676
713
  gate_status
677
714
  ;;
@@ -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,28 @@
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. 无绑定 → 扫描活跃 pipeline:
10
+ # - 0 个 → 透明放行(echo '{}')
11
+ # - 1 个 → 自动绑定并注入上下文
12
+ # - 2+ 个 → 注入"请选择"提示
4
13
  #
5
14
  # Input (stdin JSON):
6
- # { "session_id": "...", "is_background_agent": bool, "composer_mode": "agent"|"ask"|"edit", ... }
15
+ # { "session_id": "...", "is_background_agent": bool, ... }
7
16
  #
8
17
  # Output (stdout JSON):
9
18
  # { "additional_context": "<pipeline 上下文>" } 或 {}(无活跃 pipeline)
10
19
 
11
20
  set -euo pipefail
12
21
 
22
+ # ============================================================================
23
+ # Input
24
+ # ============================================================================
25
+
13
26
  input=$(cat)
14
27
 
15
28
  # 跳过 background agent(子 Agent 不需要 pipeline 上下文)
@@ -19,116 +32,285 @@ if [ "$is_bg" = "true" ]; then
19
32
  exit 0
20
33
  fi
21
34
 
35
+ session_id=$(echo "$input" | jq -r '.session_id // empty')
36
+ if [ -z "$session_id" ]; then
37
+ echo '{}'
38
+ exit 0
39
+ fi
40
+
41
+ # ============================================================================
42
+ # Paths
43
+ # ============================================================================
44
+
22
45
  SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
23
46
  PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
47
+ BINDINGS_FILE="$PROJECT_ROOT/specline/.pipeline-sessions.json"
24
48
  CHANGES_DIR="$PROJECT_ROOT/specline/changes"
25
49
 
26
- # 无 changes 目录 → 无 pipeline
27
- if [ ! -d "$CHANGES_DIR" ]; then
28
- echo '{}'
29
- exit 0
30
- fi
50
+ # ============================================================================
51
+ # Helper Functions
52
+ # ============================================================================
31
53
 
32
- # 扫描活跃 pipeline(排除 archive/)
33
- active_pipelines=""
34
- pipeline_count=0
35
- first_change=""
36
- first_phase=""
54
+ # init_bindings_file ensure bindings file exists with a valid JSON object
55
+ init_bindings_file() {
56
+ if [ ! -f "$BINDINGS_FILE" ]; then
57
+ mkdir -p "$(dirname "$BINDINGS_FILE")"
58
+ echo '{}' > "$BINDINGS_FILE"
59
+ fi
37
60
 
38
- for state_file in "$CHANGES_DIR"/*/.pipeline-state.json; do
39
- [ -f "$state_file" ] || continue
61
+ # Validate it's readable JSON; reset if corrupted
62
+ if ! jq empty "$BINDINGS_FILE" 2>/dev/null; then
63
+ echo '{}' > "$BINDINGS_FILE"
64
+ fi
65
+ }
40
66
 
41
- # 跳过 archive/
42
- parent_dir=$(dirname "$state_file")
43
- if echo "$parent_dir" | grep -q "/archive/"; then
44
- continue
67
+ # clean_expired_bindings — remove bindings where bound_at > 7 days ago
68
+ # Uses ISO-8601 lexical comparison: works on both macOS and Linux
69
+ clean_expired_bindings() {
70
+ [ -f "$BINDINGS_FILE" ] || return 0
71
+
72
+ local cutoff_date
73
+ # macOS date, fallback to Linux date
74
+ cutoff_date=$(date -u -v-7d +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || \
75
+ date -u -d '7 days ago' +%Y-%m-%dT%H:%M:%SZ 2>/dev/null)
76
+
77
+ if [ -z "$cutoff_date" ]; then
78
+ return 0 # Cannot determine cutoff, skip cleanup
45
79
  fi
46
80
 
47
- change_name=$(jq -r '.change_name // "unknown"' "$state_file" 2>/dev/null)
81
+ local tmp_file="${BINDINGS_FILE}.tmp"
82
+ # Keep only entries whose bound_at is >= cutoff (i.e. within the last 7 days)
83
+ jq --arg cutoff "$cutoff_date" \
84
+ 'with_entries(select(.value.bound_at >= $cutoff))' \
85
+ "$BINDINGS_FILE" > "$tmp_file" 2>/dev/null && \
86
+ mv "$tmp_file" "$BINDINGS_FILE" 2>/dev/null || true
87
+ }
88
+
89
+ # is_pipeline_active — check if a pipeline's state file exists and is not archived
90
+ # Returns 0 (active) or 1 (inactive)
91
+ is_pipeline_active() {
92
+ local change_name="$1"
93
+ local state_file="$CHANGES_DIR/$change_name/.pipeline-state.json"
94
+
95
+ [ -f "$state_file" ] || return 1
96
+
97
+ local phase
48
98
  phase=$(jq -r '.current_phase // "unknown"' "$state_file" 2>/dev/null)
49
- status=$(jq -r ".phases.${phase}.status // \"unknown\"" "$state_file" 2>/dev/null)
99
+ [ "$phase" != "archive" ] || return 1
50
100
 
51
- # 跳过已完成/已归档的
52
- if [ "$phase" = "archive" ] || [ "$status" = "completed" ]; then
53
- continue
54
- fi
101
+ return 0
102
+ }
55
103
 
56
- # 收集活跃 pipeline 信息
57
- if [ $pipeline_count -eq 0 ]; then
58
- first_change="$change_name"
59
- first_phase="$phase"
104
+ # scan_active_pipelines scan changes/ (excluding archive/) for active pipelines
105
+ # Output: one line per pipeline: change_name|phase|state_file_path
106
+ scan_active_pipelines() {
107
+ if [ ! -d "$CHANGES_DIR" ]; then
108
+ return 0
60
109
  fi
61
- pipeline_count=$((pipeline_count + 1))
62
110
 
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")
111
+ for state_file in "$CHANGES_DIR"/*/.pipeline-state.json; do
112
+ [ -f "$state_file" ] || continue
66
113
 
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
114
+ # Exclude archive/
115
+ case "$state_file" in
116
+ */archive/*) continue ;;
117
+ esac
73
118
 
74
- # 无活跃 pipeline → 透明放行
75
- if [ $pipeline_count -eq 0 ]; then
76
- echo '{}'
77
- exit 0
78
- fi
119
+ local change_name phase
120
+ change_name=$(jq -r '.change_name // ""' "$state_file" 2>/dev/null)
121
+ phase=$(jq -r '.current_phase // ""' "$state_file" 2>/dev/null)
122
+
123
+ [ -n "$change_name" ] || continue
124
+ [ "$phase" != "archive" ] || continue
79
125
 
80
- # 构建阶段约束说明
126
+ printf '%s|%s|%s\n' "$change_name" "$phase" "$state_file"
127
+ done
128
+ }
129
+
130
+ # phase_constraints — output constraint text for a given phase (to stdout)
81
131
  phase_constraints() {
82
- case "$1" in
132
+ local phase="$1"
133
+ case "$phase" in
83
134
  spec)
84
- echo "- 只能通过 specline-spec-creator / specline-spec-reviewer 子 Agent 工作"
85
- echo "- 禁止编辑任何应用代码文件(.ts/.tsx/.py/.go 等)"
86
- echo "- 规划文件生成后需运行 Spec Gate"
135
+ printf '%s\n' "- 只能通过 specline-spec-creator / specline-spec-reviewer 子 Agent 工作"
136
+ printf '%s\n' "- 禁止编辑任何应用代码文件(.ts/.tsx/.py/.go 等)"
137
+ printf '%s\n' "- 规划文件生成后需运行 Spec Gate"
87
138
  ;;
88
139
  coding)
89
- echo "- 编码必须通过子 Agent:specline-frontend-dev / specline-backend-dev"
90
- echo "- 禁止直接编辑应用代码文件"
91
- echo "- 每批次任务完成后运行 Build Gate"
92
- echo "- 每个 Task 完成后更新 tasks.md 的 checkbox"
140
+ printf '%s\n' "- 编码必须通过子 Agent:specline-frontend-dev / specline-backend-dev / specline-config-dev"
141
+ printf '%s\n' "- 禁止直接编辑应用代码文件"
142
+ printf '%s\n' "- 每批次任务完成后运行 Build Gate"
143
+ printf '%s\n' "- 每个 Task 完成后更新 tasks.md 的 checkbox"
93
144
  ;;
94
145
  code_review)
95
- echo "- 只能运行 specline-code-reviewer + Lint Gate"
96
- echo "- 如需修复代码,通过子 Agent 完成"
146
+ printf '%s\n' "- 只能运行 specline-code-reviewer + Lint Gate"
147
+ printf '%s\n' "- 如需修复代码,通过子 Agent 完成"
97
148
  ;;
98
149
  test)
99
- echo "- 运行测试 Gate 链:unit → integration → e2e"
100
- echo "- 测试失败时通过 specline-test-runner 分析原因"
101
- echo "- 代码修复通过子 Agent,测试修复通过 specline-test-writer"
150
+ printf '%s\n' "- 运行测试 Gate 链:unit → integration → e2e"
151
+ printf '%s\n' "- 测试失败时通过 specline-test-runner 分析原因"
152
+ printf '%s\n' "- 代码修复通过子 Agent,测试修复通过 specline-test-writer"
102
153
  ;;
103
154
  *)
104
- echo "- 遵循 specline-pipeline SKILL 的当前阶段约束"
155
+ printf '%s\n' "- 遵循 specline-pipeline SKILL 的当前阶段约束"
105
156
  ;;
106
157
  esac
107
158
  }
108
159
 
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 编排者。上述约束具有最高优先级,必须在每个操作前检查是否符合当前阶段要求。"
160
+ # build_context — generate a JSON-escaped additional_context string for a bound pipeline
161
+ build_context() {
162
+ local change_name="$1"
163
+ local phase="$2"
164
+ local state_file="$3"
165
+
166
+ {
167
+ printf '🚨 **Specline Pipeline 运行中**\n\n'
168
+ printf '**当前变更**: %s\n' "$change_name"
169
+ printf '**当前阶段**: %s\n' "$phase"
124
170
 
125
- # 转义 JSON 字符串
126
- escaped_context=$(echo -e "$context_text" | jq -Rs '.')
171
+ # Task progress for coding phase
172
+ if [ "$phase" = "coding" ] && [ -f "$state_file" ]; then
173
+ local completed total
174
+ completed=$(jq -r '[.phases.coding.tasks[]? | select(.status == "completed")] | length' "$state_file" 2>/dev/null || printf '0')
175
+ total=$(jq -r '[.phases.coding.tasks[]?] | length' "$state_file" 2>/dev/null || printf '0')
176
+ if [ "$total" != "0" ]; then
177
+ printf '**任务进度**: %s/%s 完成\n' "$completed" "$total"
178
+ fi
179
+ fi
127
180
 
128
- cat << EOF
129
- {
130
- "additional_context": ${escaped_context}
181
+ printf '\n**阶段约束**:\n'
182
+ phase_constraints "$phase"
183
+ printf '\n'
184
+ printf '**重要**: 你是 Specline Pipeline 编排者。上述约束具有最高优先级,必须在每个操作前检查是否符合当前阶段要求。'
185
+ } | jq -Rs '.'
131
186
  }
132
- EOF
133
187
 
188
+ # phase_constraint_table — static phase constraint reference table (markdown)
189
+ phase_constraint_table() {
190
+ cat << 'TABLEEOF'
191
+ | 阶段 | 约束 |
192
+ |------|------|
193
+ | spec | 只能通过 specline-spec-creator/specline-spec-reviewer 子 Agent 工作,禁止编辑代码 |
194
+ | coding | 必须通过子 Agent 编码,批次完成后运行 Build Gate,更新 tasks.md |
195
+ | code_review | 只能运行 specline-code-reviewer + Lint Gate |
196
+ | test | 运行测试 Gate 链:unit → integration → e2e |
197
+ TABLEEOF
198
+ }
199
+
200
+ # write_binding — write a session_id → change_name binding to .pipeline-sessions.json
201
+ write_binding() {
202
+ local sid="$1"
203
+ local change="$2"
204
+
205
+ local now_iso
206
+ now_iso=$(date -u +%Y-%m-%dT%H:%M:%SZ)
207
+
208
+ local tmp_file="${BINDINGS_FILE}.tmp"
209
+ jq --arg sid "$sid" --arg change "$change" --arg now "$now_iso" \
210
+ '.[$sid] = {"change": $change, "bound_at": $now}' \
211
+ "$BINDINGS_FILE" > "$tmp_file" 2>/dev/null && \
212
+ mv "$tmp_file" "$BINDINGS_FILE" 2>/dev/null || true
213
+ }
214
+
215
+ # delete_binding — remove a session_id entry from .pipeline-sessions.json
216
+ delete_binding() {
217
+ local session_id="$1"
218
+
219
+ [ -f "$BINDINGS_FILE" ] || return 0
220
+
221
+ local tmp_file="${BINDINGS_FILE}.tmp"
222
+ jq --arg sid "$session_id" 'del(.[$sid])' "$BINDINGS_FILE" > "$tmp_file" 2>/dev/null && \
223
+ mv "$tmp_file" "$BINDINGS_FILE" 2>/dev/null || true
224
+ }
225
+
226
+ # ============================================================================
227
+ # Main Logic
228
+ # ============================================================================
229
+
230
+ init_bindings_file
231
+
232
+ # 1. Clean expired bindings (bound_at > 7 days ago)
233
+ clean_expired_bindings
234
+
235
+ # 2. Check existing binding for this session
236
+ existing_change=""
237
+ if [ -f "$BINDINGS_FILE" ]; then
238
+ existing_change=$(jq -r --arg sid "$session_id" '.[$sid].change // empty' "$BINDINGS_FILE" 2>/dev/null)
239
+ fi
240
+
241
+ if [ -n "$existing_change" ]; then
242
+ if is_pipeline_active "$existing_change"; then
243
+ # Binding is still valid — use it
244
+ state_file="$CHANGES_DIR/$existing_change/.pipeline-state.json"
245
+ phase=$(jq -r '.current_phase // "unknown"' "$state_file" 2>/dev/null)
246
+
247
+ ctx_json=$(build_context "$existing_change" "$phase" "$state_file")
248
+
249
+ printf '{\n "additional_context": %s\n}\n' "$ctx_json"
250
+ exit 0
251
+ else
252
+ # Dirty data: pipeline archived or deleted — clean up and rescan
253
+ delete_binding "$session_id"
254
+ fi
255
+ fi
256
+
257
+ # 3. No (valid) binding — scan active pipelines
258
+ scan_result=$(scan_active_pipelines)
259
+
260
+ # Count active pipelines
261
+ pipeline_count=0
262
+ if [ -n "$scan_result" ]; then
263
+ pipeline_count=$(printf '%s\n' "$scan_result" | wc -l | tr -d ' ')
264
+ fi
265
+
266
+ # 0 active pipelines → transparent pass-through
267
+ if [ "$pipeline_count" -eq 0 ]; then
268
+ echo '{}'
269
+ exit 0
270
+ fi
271
+
272
+ # 1 active pipeline → auto-bind and inject context
273
+ if [ "$pipeline_count" -eq 1 ]; then
274
+ change_name=$(printf '%s' "$scan_result" | cut -d'|' -f1)
275
+ phase=$(printf '%s' "$scan_result" | cut -d'|' -f2)
276
+ state_file=$(printf '%s' "$scan_result" | cut -d'|' -f3)
277
+
278
+ # Auto-bind
279
+ write_binding "$session_id" "$change_name"
280
+
281
+ # Inject context
282
+ ctx_json=$(build_context "$change_name" "$phase" "$state_file")
283
+
284
+ printf '{\n "additional_context": %s\n}\n' "$ctx_json"
285
+ exit 0
286
+ fi
287
+
288
+ # 4. 2+ active pipelines → inject selection prompt (do NOT auto-bind)
289
+ ctx_json=$({
290
+ printf '🚨 **Specline Pipeline 运行中 — 请选择 Pipeline**\n\n'
291
+ printf '**活跃 Pipeline** (%s 个):\n' "$pipeline_count"
292
+
293
+ while IFS='|' read -r change_name phase state_file; do
294
+ [ -n "$change_name" ] || continue
295
+ printf -- '- **%s**: %s 阶段' "$change_name" "$phase"
296
+
297
+ # Add task progress for coding phase
298
+ if [ "$phase" = "coding" ] && [ -f "$state_file" ]; then
299
+ completed=$(jq -r '[.phases.coding.tasks[]? | select(.status == "completed")] | length' "$state_file" 2>/dev/null || printf '0')
300
+ total=$(jq -r '[.phases.coding.tasks[]?] | length' "$state_file" 2>/dev/null || printf '0')
301
+ if [ "$total" != "0" ]; then
302
+ printf ' (%s/%s 任务完成)' "$completed" "$total"
303
+ fi
304
+ fi
305
+ printf '\n'
306
+ done <<< "$scan_result"
307
+
308
+ printf '\n**说明**: 当前发现了 %s 个活跃 Pipeline,但尚未绑定到本会话。\n' "$pipeline_count"
309
+ printf '**操作**: 请使用 `/specline-pipeline --change <名称>` 选择一个 Pipeline 以继续工作。\n\n'
310
+ printf '**阶段约束参考**:\n'
311
+ phase_constraint_table
312
+ printf '\n**提示**: 绑定后系统会自动注入正确的阶段上下文和约束。\n'
313
+ } | jq -Rs '.')
314
+
315
+ printf '{\n "additional_context": %s\n}\n' "$ctx_json"
134
316
  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
@@ -9,6 +9,67 @@ description: >-
9
9
 
10
10
  ---
11
11
 
12
+ ## Layer 0: Session 绑定与 Pipeline 切换
13
+
14
+ ### 概述
15
+
16
+ 每个 Cursor 会话通过 `session_id` 绑定到特定的 Pipeline。Hook 脚本通过查 `specline/.pipeline-sessions.json` 表获得确定性映射,替代原有的非确定性 `find_active_pipeline()` 扫描逻辑。
17
+
18
+ ### 启动时自动绑定
19
+
20
+ `sessionStart` Hook (`specline-session-start.sh`) 自动处理:
21
+
22
+ 1. **已绑定且有效** → 直接使用,注入正确的阶段约束
23
+ 2. **无绑定 + 1 个活跃 Pipeline** → 自动绑定当前 session 到该 Pipeline
24
+ 3. **无绑定 + 2+ 个活跃 Pipeline** → 注入提示,编排者必须用 AskUserQuestion 让用户选择
25
+ 4. **过期绑定清理** → `bound_at` 超过 7 天的绑定自动删除
26
+ 5. **脏数据清理** → 绑定指向已归档/不存在的 Pipeline 时自动删除
27
+
28
+ ### 编排者收到「多 Pipeline 未绑定」提示时
29
+
30
+ 当 sessionStart 注入上下文提示有多个 pipeline 未绑定时,编排者**必须**使用 AskUserQuestion 让用户选择:
31
+
32
+ ```javascript
33
+ AskUserQuestion({
34
+ title: "选择 Pipeline",
35
+ questions: [{
36
+ id: "pipeline_select",
37
+ prompt: "当前有 " + count + " 个活跃 Pipeline,请选择要绑定到本会话的:",
38
+ options: [
39
+ { id: "change-a", label: "change-a (SPEC 阶段)" },
40
+ { id: "change-b", label: "change-b (CODING 阶段, 3/7 任务)" },
41
+ ],
42
+ allow_multiple: false
43
+ }]
44
+ })
45
+ ```
46
+
47
+ 用户选择后,编排者执行绑定命令:
48
+
49
+ ```bash
50
+ .cursor/hooks/specline-pipeline-gate.sh bind <session_id> <selected_change>
51
+ ```
52
+
53
+ ### 用户要求切换 Pipeline 时
54
+
55
+ 当用户在对话中说「帮我处理 \<other-change\>」:
56
+
57
+ 1. 检查当前 Pipeline 是否在安全切换点(Gate 之后、批次之间)
58
+ 2. 如果当前在 CODING 批次中间 → 提示「请先完成当前批次」
59
+ 3. 否则执行切换:
60
+
61
+ ```bash
62
+ .cursor/hooks/specline-pipeline-gate.sh bind <session_id> <other_change>
63
+ ```
64
+
65
+ 绑定后,下一个 Hook 调用立即生效。
66
+
67
+ ### Pipeline 归档时自动解绑
68
+
69
+ `gate archive --execute` 会自动清理所有绑定到该 Pipeline 的 session 记录,无需手工操作。
70
+
71
+ ---
72
+
12
73
  ## Layer 1: 速览与定位
13
74
 
14
75
  你是**流水线编排者**,不是执行者。