@walwal-harness/cli 2.0.1 → 2.3.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.
- package/assets/templates/config.json +41 -1
- package/bin/init.js +65 -3
- package/package.json +5 -2
- package/scripts/harness-next.sh +36 -1
- package/scripts/harness-user-prompt-submit.sh +106 -0
- package/scripts/lib/harness-render-progress.sh +15 -1
- package/scripts/scan-project.sh +24 -2
- package/skills/brainstorming/SKILL.md +200 -0
- package/skills/brainstorming/references/attribution.md +109 -0
- package/skills/brainstorming/references/spec-document-reviewer-prompt.md +49 -0
- package/skills/brainstorming/references/visual-companion.md +287 -0
- package/skills/brainstorming/scripts/frame-template.html +214 -0
- package/skills/brainstorming/scripts/helper.js +88 -0
- package/skills/brainstorming/scripts/server.cjs +354 -0
- package/skills/brainstorming/scripts/start-server.sh +148 -0
- package/skills/brainstorming/scripts/stop-server.sh +56 -0
- package/skills/dispatcher/SKILL.md +114 -2
- package/skills/dispatcher/references/pipeline-definitions.md +53 -5
- package/skills/evaluator-functional-flutter/SKILL.md +198 -0
- package/skills/evaluator-functional-flutter/references/ia-compliance.md +77 -0
- package/skills/evaluator-functional-flutter/references/scoring-rubric.md +132 -0
- package/skills/evaluator-functional-flutter/references/static-check-rules.md +99 -0
- package/skills/generator-frontend-flutter/SKILL.md +138 -0
- package/skills/generator-frontend-flutter/references/anti-patterns.md +288 -0
- package/skills/generator-frontend-flutter/references/api-layer-pattern.md +233 -0
- package/skills/generator-frontend-flutter/references/i18n-pattern.md +102 -0
- package/skills/generator-frontend-flutter/references/riverpod-pattern.md +199 -0
- package/skills/planner/SKILL.md +23 -1
- package/skills/planner/references/fe-stack-detection.md +131 -0
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
---
|
|
2
|
+
docmeta:
|
|
3
|
+
id: api-layer-pattern
|
|
4
|
+
title: API Layer Pattern (integrated_data_layer)
|
|
5
|
+
type: output
|
|
6
|
+
createdAt: 2026-04-09T00:00:00Z
|
|
7
|
+
updatedAt: 2026-04-09T00:00:00Z
|
|
8
|
+
source:
|
|
9
|
+
producer: agent
|
|
10
|
+
skillId: harness-generator-frontend-flutter
|
|
11
|
+
inputs:
|
|
12
|
+
- documentId: clue-fe-flutter-api-layer
|
|
13
|
+
uri: ../../../../../moon_web/clue-fe-flutter.skill
|
|
14
|
+
relation: output-from
|
|
15
|
+
sections:
|
|
16
|
+
- sourceRange:
|
|
17
|
+
startLine: 1
|
|
18
|
+
endLine: 187
|
|
19
|
+
targetRange:
|
|
20
|
+
startLine: 32
|
|
21
|
+
endLine: 205
|
|
22
|
+
tags:
|
|
23
|
+
- flutter
|
|
24
|
+
- retrofit
|
|
25
|
+
- json-serializable
|
|
26
|
+
- integrated-data-layer
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
# API Layer Pattern (integrated_data_layer)
|
|
30
|
+
|
|
31
|
+
하네스 Flutter Generator는 `api-contract.json` 을 **Source of Truth** 로 삼아
|
|
32
|
+
`integrated_data_layer` 하위에 Dart 타입을 1:1 생성한다.
|
|
33
|
+
|
|
34
|
+
## 디렉토리 구조
|
|
35
|
+
|
|
36
|
+
```
|
|
37
|
+
integrated_data_layer/
|
|
38
|
+
├── lib/
|
|
39
|
+
│ ├── 1_repositories/ # Repository (비즈니스 로직 래퍼)
|
|
40
|
+
│ │ ├── ac_repository.dart
|
|
41
|
+
│ │ ├── visitor_repository.dart
|
|
42
|
+
│ │ ├── s3_repository.dart
|
|
43
|
+
│ │ ├── oauth_repository.dart
|
|
44
|
+
│ │ └── reservation_repository.dart
|
|
45
|
+
│ ├── 2_data_sources/
|
|
46
|
+
│ │ ├── remote/
|
|
47
|
+
│ │ │ ├── rest_api.dart # 모든 Retrofit 엔드포인트
|
|
48
|
+
│ │ │ ├── rest_provider.dart # Dio/RestClient Provider
|
|
49
|
+
│ │ │ ├── request/body/ # Request Body 클래스
|
|
50
|
+
│ │ │ └── response/
|
|
51
|
+
│ │ │ └── abstract/ # 베이스 응답 (ClueResponseImpl 등)
|
|
52
|
+
│ │ └── local/
|
|
53
|
+
│ └── 3_others/
|
|
54
|
+
│ ├── enum/
|
|
55
|
+
│ └── extension/
|
|
56
|
+
├── test/ # lib/ 미러 구조
|
|
57
|
+
│ ├── 1_repositories/
|
|
58
|
+
│ ├── 2_data_sources/remote/request/body/
|
|
59
|
+
│ └── 2_data_sources/remote/response/
|
|
60
|
+
└── data_layer.dart # 진입점 (dataLayer Provider)
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## 1. api-contract.json → Request Body 변환
|
|
64
|
+
|
|
65
|
+
api-contract.json의 요청 스키마를 `@JsonSerializable(includeIfNull: false)`
|
|
66
|
+
클래스로 1:1 매핑한다. **필수 지시가 없으면 모든 필드 Nullable.**
|
|
67
|
+
|
|
68
|
+
```dart
|
|
69
|
+
// lib/2_data_sources/remote/request/body/example_body.dart
|
|
70
|
+
import 'package:json_annotation/json_annotation.dart';
|
|
71
|
+
|
|
72
|
+
part 'example_body.g.dart';
|
|
73
|
+
|
|
74
|
+
@JsonSerializable(includeIfNull: false) // ★ 필수
|
|
75
|
+
class ExampleBody {
|
|
76
|
+
final String? name; // ★ Nullable 기본
|
|
77
|
+
final int? placeId;
|
|
78
|
+
final String? description;
|
|
79
|
+
|
|
80
|
+
const ExampleBody({
|
|
81
|
+
this.name,
|
|
82
|
+
this.placeId,
|
|
83
|
+
this.description,
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
factory ExampleBody.fromJson(Map<String, dynamic> json) =>
|
|
87
|
+
_$ExampleBodyFromJson(json);
|
|
88
|
+
Map<String, dynamic> toJson() => _$ExampleBodyToJson(this);
|
|
89
|
+
}
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## 2. Response 변환
|
|
93
|
+
|
|
94
|
+
응답 베이스 클래스 계층:
|
|
95
|
+
- `ClueResponseImpl<T>` — 단일 데이터 (code, data, errors)
|
|
96
|
+
- `ClueResponseListImpl<T>` — 목록 (code, data, errors, totalCount)
|
|
97
|
+
- `VisitorResponseImpl<T>` — Visitor 서버 전용
|
|
98
|
+
|
|
99
|
+
```dart
|
|
100
|
+
// lib/2_data_sources/remote/response/example_response.dart
|
|
101
|
+
import 'package:integrated_data_layer/2_data_sources/remote/response/abstract/clue/clue_response_impl.dart';
|
|
102
|
+
import 'package:json_annotation/json_annotation.dart';
|
|
103
|
+
|
|
104
|
+
part 'example_response.g.dart';
|
|
105
|
+
|
|
106
|
+
@JsonSerializable(explicitToJson: true)
|
|
107
|
+
class ExampleResponse extends ClueResponseImpl<ExampleResponseData> {
|
|
108
|
+
const ExampleResponse({
|
|
109
|
+
required super.code,
|
|
110
|
+
required super.data,
|
|
111
|
+
required super.errors,
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
factory ExampleResponse.fromJson(Map<String, dynamic> json) =>
|
|
115
|
+
_$ExampleResponseFromJson(json);
|
|
116
|
+
Map<String, dynamic> toJson() => _$ExampleResponseToJson(this);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
@JsonSerializable(explicitToJson: true)
|
|
120
|
+
class ExampleResponseData {
|
|
121
|
+
final int? id; // ★ Nullable 기본
|
|
122
|
+
final String? name;
|
|
123
|
+
|
|
124
|
+
const ExampleResponseData({this.id, this.name});
|
|
125
|
+
|
|
126
|
+
factory ExampleResponseData.fromJson(Map<String, dynamic> json) =>
|
|
127
|
+
_$ExampleResponseDataFromJson(json);
|
|
128
|
+
Map<String, dynamic> toJson() => _$ExampleResponseDataToJson(this);
|
|
129
|
+
}
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
**기존 응답 재사용 원칙**: api-contract.json 의 서로 다른 엔드포인트가
|
|
133
|
+
동일한 응답 구조를 가지면 새 Response 클래스를 만들지 않는다.
|
|
134
|
+
|
|
135
|
+
## 3. rest_api.dart 엔드포인트 추가
|
|
136
|
+
|
|
137
|
+
```dart
|
|
138
|
+
// rest_api.dart 내부 — RestClient 클래스에 추가
|
|
139
|
+
@GET("/examples/{exampleId}")
|
|
140
|
+
Future<ExampleResponse> getExample(
|
|
141
|
+
@Path("exampleId") int exampleId,
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
@POST("/examples")
|
|
145
|
+
Future<ExampleResponse> createExample(
|
|
146
|
+
@Body() ExampleBody requestBody,
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
@PUT("/examples/{exampleId}")
|
|
150
|
+
Future<ExampleResponse> updateExample(
|
|
151
|
+
@Path("exampleId") int exampleId,
|
|
152
|
+
@Body() ExampleBody requestBody,
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
@DELETE("/examples/{exampleId}")
|
|
156
|
+
Future<ClueDefaultResponse> deleteExample(
|
|
157
|
+
@Path("exampleId") int exampleId,
|
|
158
|
+
);
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
api-contract.json의 path, method, param 위치를 **그대로** 옮긴다.
|
|
162
|
+
경로 변수 추가/제거 금지 — 계약이 Source of Truth.
|
|
163
|
+
|
|
164
|
+
## 4. Repository 래퍼 추가
|
|
165
|
+
|
|
166
|
+
```dart
|
|
167
|
+
// 1_repositories/ac_repository.dart 내부에 메서드 추가
|
|
168
|
+
Future<ExampleResponse> getExample({required int exampleId}) {
|
|
169
|
+
return _restClient.getExample(exampleId);
|
|
170
|
+
}
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
Repository는 **순수 래퍼** — 비즈니스 로직 없이 Retrofit 호출을 전달만 한다.
|
|
174
|
+
도메인 분기, 에러 매핑은 VM 계층에서 수행.
|
|
175
|
+
|
|
176
|
+
## 5. 호출 패턴 (VM에서)
|
|
177
|
+
|
|
178
|
+
```dart
|
|
179
|
+
// VM 내부
|
|
180
|
+
final result = await ref.read(dataLayer).ac.getExample(exampleId: 123);
|
|
181
|
+
if (result.code == 200) {
|
|
182
|
+
// 성공 처리
|
|
183
|
+
}
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
## 6. TC 작성 (필수)
|
|
187
|
+
|
|
188
|
+
**모든 Request Body, Response에 왕복 테스트 필수.**
|
|
189
|
+
Evaluator가 test 디렉토리를 점검한다.
|
|
190
|
+
|
|
191
|
+
```dart
|
|
192
|
+
// test/2_data_sources/remote/request/body/example_body_test.dart
|
|
193
|
+
import 'dart:convert';
|
|
194
|
+
import 'package:flutter_test/flutter_test.dart';
|
|
195
|
+
import 'package:integrated_data_layer/2_data_sources/remote/request/body/example_body.dart';
|
|
196
|
+
|
|
197
|
+
void main() {
|
|
198
|
+
group("example body test", () {
|
|
199
|
+
test("/fromJson & toJson", () {
|
|
200
|
+
ExampleBody body1 = const ExampleBody(
|
|
201
|
+
name: 'test',
|
|
202
|
+
placeId: 1,
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
ExampleBody body2 = ExampleBody.fromJson(body1.toJson());
|
|
206
|
+
|
|
207
|
+
expect(body1.name, 'test');
|
|
208
|
+
expect(body1.placeId, 1);
|
|
209
|
+
|
|
210
|
+
var body1Data = jsonEncode(body1.toJson());
|
|
211
|
+
var body2Data = jsonEncode(body2.toJson());
|
|
212
|
+
expect(body1Data == body2Data, true);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test("/null fields excluded", () {
|
|
216
|
+
ExampleBody body = const ExampleBody(name: 'test');
|
|
217
|
+
var json = body.toJson();
|
|
218
|
+
expect(json.containsKey('placeId'), false); // includeIfNull: false 검증
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
## 7. 코드 생성
|
|
225
|
+
|
|
226
|
+
모든 Request/Response 파일 추가/수정 후 **반드시** 실행:
|
|
227
|
+
|
|
228
|
+
```bash
|
|
229
|
+
cd <integrated_data_layer 경로>
|
|
230
|
+
flutter pub run build_runner build --delete-conflicting-outputs
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
생성된 `*.g.dart` 파일은 커밋한다 (CI에서 재생성하지 않음).
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
---
|
|
2
|
+
docmeta:
|
|
3
|
+
id: i18n-pattern
|
|
4
|
+
title: 다국어 (i18n) ARB 패턴
|
|
5
|
+
type: output
|
|
6
|
+
createdAt: 2026-04-09T00:00:00Z
|
|
7
|
+
updatedAt: 2026-04-09T00:00:00Z
|
|
8
|
+
source:
|
|
9
|
+
producer: agent
|
|
10
|
+
skillId: harness-generator-frontend-flutter
|
|
11
|
+
inputs:
|
|
12
|
+
- documentId: clue-fe-flutter-i18n
|
|
13
|
+
uri: ../../../../../moon_web/clue-fe-flutter.skill
|
|
14
|
+
relation: output-from
|
|
15
|
+
sections:
|
|
16
|
+
- sourceRange:
|
|
17
|
+
startLine: 1
|
|
18
|
+
endLine: 60
|
|
19
|
+
targetRange:
|
|
20
|
+
startLine: 32
|
|
21
|
+
endLine: 110
|
|
22
|
+
tags:
|
|
23
|
+
- flutter
|
|
24
|
+
- i18n
|
|
25
|
+
- arb
|
|
26
|
+
- l10n
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
# 다국어 (i18n) ARB 패턴
|
|
30
|
+
|
|
31
|
+
## ARB 파일 기반 l10n
|
|
32
|
+
|
|
33
|
+
### 파일 경로
|
|
34
|
+
|
|
35
|
+
- 영어: `lib/l10n/app_en.arb`
|
|
36
|
+
- 한국어: `lib/l10n/app_ko.arb`
|
|
37
|
+
- 일본어: `lib/l10n/app_ja.arb`
|
|
38
|
+
|
|
39
|
+
프로젝트에 따라 `assets/strings/en.json` 등 다른 경로를 쓸 수 있다.
|
|
40
|
+
`AGENTS.md` / sprint-contract.md 의 기존 컨벤션을 우선한다.
|
|
41
|
+
|
|
42
|
+
### 작업 순서
|
|
43
|
+
|
|
44
|
+
1. 문자열 언어 판별 (예: "취소" → ko, "Cancel" → en)
|
|
45
|
+
2. 해당 언어 arb 파일에 **키 존재 여부 확인** (중복 방지)
|
|
46
|
+
3. 없으면 모든 언어 파일에 동시 추가 (`app_en.arb`, `app_ko.arb`, `app_ja.arb`)
|
|
47
|
+
4. 코드 치환 → `LocaleAssist().of.키이름`
|
|
48
|
+
|
|
49
|
+
### 사용법
|
|
50
|
+
|
|
51
|
+
```dart
|
|
52
|
+
// 기본 문자열
|
|
53
|
+
Text(
|
|
54
|
+
LocaleAssist().of.cancel,
|
|
55
|
+
style: MyTextStyle.size15.w500.xFF9CA3AF,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
// 문자열 내 스타일 별도 구현 (ClueText 등 프로젝트 커스텀 위젯 사용)
|
|
59
|
+
ClueText(
|
|
60
|
+
"${LocaleAssist().of.all} (${count})",
|
|
61
|
+
style: MyTextStyle.size16.w500,
|
|
62
|
+
targetList: [
|
|
63
|
+
TargetModel(
|
|
64
|
+
text: "(${count})",
|
|
65
|
+
style: MyTextStyle.size16.w500.xFF6682FF,
|
|
66
|
+
),
|
|
67
|
+
],
|
|
68
|
+
)
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### ARB 키 네이밍 규칙
|
|
72
|
+
|
|
73
|
+
```json
|
|
74
|
+
{
|
|
75
|
+
"cancel": "취소",
|
|
76
|
+
"confirm": "확인",
|
|
77
|
+
"doorOpen": "문 열기",
|
|
78
|
+
"networkError": "네트워크 오류가 발생했습니다"
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
- **camelCase** 사용
|
|
83
|
+
- 모든 arb 파일(en, ko, ja)에 **동일 키 동시 추가**
|
|
84
|
+
- 기존 키 검색 후 중복 방지 (`grep -rn '"cancel"' lib/l10n/`)
|
|
85
|
+
- 플레이스홀더 필요 시 ICU MessageFormat 사용
|
|
86
|
+
|
|
87
|
+
## 필수 원칙
|
|
88
|
+
|
|
89
|
+
- 영문 전용 표기 지시가 없는 한 **모든 사용자 노출 문자열은 다국어 처리**
|
|
90
|
+
- 하드코딩된 문자열 사용 금지 (`Text('취소')` ✗)
|
|
91
|
+
- 새 페이지 추가 시 관련 문자열 **일괄 등록** — 스프린트 중 누락 방지
|
|
92
|
+
- 모든 arb 파일의 키 집합은 동일해야 한다 (Evaluator가 diff 검사)
|
|
93
|
+
|
|
94
|
+
## Self-Check
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
# 하드코딩된 한글 탐지
|
|
98
|
+
grep -rn "Text('[가-힣]" lib/ui/
|
|
99
|
+
|
|
100
|
+
# 누락 키 탐지 (en 기준)
|
|
101
|
+
python3 -c "import json; en=set(json.load(open('lib/l10n/app_en.arb')).keys()); ko=set(json.load(open('lib/l10n/app_ko.arb')).keys()); print('missing in ko:', en - ko); print('missing in en:', ko - en)"
|
|
102
|
+
```
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
---
|
|
2
|
+
docmeta:
|
|
3
|
+
id: riverpod-pattern
|
|
4
|
+
title: Riverpod 상태관리 패턴 (Page + VM)
|
|
5
|
+
type: output
|
|
6
|
+
createdAt: 2026-04-09T00:00:00Z
|
|
7
|
+
updatedAt: 2026-04-09T00:00:00Z
|
|
8
|
+
source:
|
|
9
|
+
producer: agent
|
|
10
|
+
skillId: harness-generator-frontend-flutter
|
|
11
|
+
inputs:
|
|
12
|
+
- documentId: clue-fe-flutter-riverpod
|
|
13
|
+
uri: ../../../../../moon_web/clue-fe-flutter.skill
|
|
14
|
+
relation: output-from
|
|
15
|
+
sections:
|
|
16
|
+
- sourceRange:
|
|
17
|
+
startLine: 1
|
|
18
|
+
endLine: 149
|
|
19
|
+
targetRange:
|
|
20
|
+
startLine: 32
|
|
21
|
+
endLine: 180
|
|
22
|
+
tags:
|
|
23
|
+
- flutter
|
|
24
|
+
- riverpod
|
|
25
|
+
- state-management
|
|
26
|
+
- notifier-provider
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
# Riverpod 상태관리 패턴 (Page + VM)
|
|
30
|
+
|
|
31
|
+
## 기본 구조: Page + VM 쌍
|
|
32
|
+
|
|
33
|
+
모든 화면은 `xxx_page.dart` + `xxx_page_vm.dart` 쌍으로 구성.
|
|
34
|
+
이는 테스트 가능성과 UI/로직 분리를 위한 강제 규약.
|
|
35
|
+
|
|
36
|
+
### VM (ViewModel)
|
|
37
|
+
|
|
38
|
+
```dart
|
|
39
|
+
// example_page_vm.dart
|
|
40
|
+
import 'package:equatable/equatable.dart';
|
|
41
|
+
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
42
|
+
import 'package:integrated_data_layer/data_layer.dart';
|
|
43
|
+
|
|
44
|
+
final pExampleProvider =
|
|
45
|
+
NotifierProvider<ExampleNotifier, ExampleState>(ExampleNotifier.new);
|
|
46
|
+
|
|
47
|
+
class ExampleNotifier extends Notifier<ExampleState> {
|
|
48
|
+
@override
|
|
49
|
+
ExampleState build() {
|
|
50
|
+
return const ExampleState();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
Future<void> loadData() async {
|
|
54
|
+
state = state.copyWith(isLoading: true);
|
|
55
|
+
try {
|
|
56
|
+
final result = await ref.read(dataLayer).ac.getExample(exampleId: 1);
|
|
57
|
+
if (result.code == 200) {
|
|
58
|
+
state = state.copyWith(
|
|
59
|
+
isLoading: false,
|
|
60
|
+
data: result.data,
|
|
61
|
+
);
|
|
62
|
+
} else {
|
|
63
|
+
state = state.copyWith(
|
|
64
|
+
isLoading: false,
|
|
65
|
+
error: 'code=${result.code}',
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
} catch (e) {
|
|
69
|
+
state = state.copyWith(isLoading: false, error: e.toString());
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
class ExampleState extends Equatable {
|
|
75
|
+
final bool isLoading;
|
|
76
|
+
final dynamic data;
|
|
77
|
+
final String? error;
|
|
78
|
+
|
|
79
|
+
const ExampleState({
|
|
80
|
+
this.isLoading = false,
|
|
81
|
+
this.data,
|
|
82
|
+
this.error,
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
ExampleState copyWith({
|
|
86
|
+
bool? isLoading,
|
|
87
|
+
dynamic data,
|
|
88
|
+
String? error,
|
|
89
|
+
}) {
|
|
90
|
+
return ExampleState(
|
|
91
|
+
isLoading: isLoading ?? this.isLoading,
|
|
92
|
+
data: data ?? this.data,
|
|
93
|
+
error: error ?? this.error,
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
@override
|
|
98
|
+
List<Object?> get props => [isLoading, data, error];
|
|
99
|
+
}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
### Page
|
|
103
|
+
|
|
104
|
+
```dart
|
|
105
|
+
// example_page.dart
|
|
106
|
+
import 'package:flutter/material.dart';
|
|
107
|
+
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
108
|
+
import 'example_page_vm.dart';
|
|
109
|
+
|
|
110
|
+
class ExamplePage extends ConsumerStatefulWidget {
|
|
111
|
+
const ExamplePage({super.key});
|
|
112
|
+
|
|
113
|
+
@override
|
|
114
|
+
ConsumerState<ExamplePage> createState() => _ExamplePageState();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
class _ExamplePageState extends ConsumerState<ExamplePage> {
|
|
118
|
+
@override
|
|
119
|
+
void initState() {
|
|
120
|
+
super.initState();
|
|
121
|
+
// 초기 데이터 로드는 addPostFrameCallback 으로
|
|
122
|
+
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
123
|
+
ref.read(pExampleProvider.notifier).loadData();
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
@override
|
|
128
|
+
Widget build(BuildContext context) {
|
|
129
|
+
final state = ref.watch(pExampleProvider); // watch로 상태 구독
|
|
130
|
+
|
|
131
|
+
if (state.isLoading) {
|
|
132
|
+
return const Center(child: CircularProgressIndicator());
|
|
133
|
+
}
|
|
134
|
+
if (state.error != null) {
|
|
135
|
+
return Center(child: Text(state.error!));
|
|
136
|
+
}
|
|
137
|
+
// ... 정상 상태 렌더링
|
|
138
|
+
return const SizedBox.shrink();
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
## Family 패턴 (다중 자식 컴포넌트)
|
|
144
|
+
|
|
145
|
+
리스트 아이템처럼 동일 타입 인스턴스가 여러 개 필요할 때:
|
|
146
|
+
|
|
147
|
+
```dart
|
|
148
|
+
// relay_tile_vm.dart
|
|
149
|
+
final pRelayItemProvider = NotifierProvider.family<
|
|
150
|
+
RelayItemNotifier,
|
|
151
|
+
RelayItemState,
|
|
152
|
+
({int ioId, String topic})>(
|
|
153
|
+
() => RelayItemNotifier(),
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
class RelayItemNotifier
|
|
157
|
+
extends FamilyNotifier<RelayItemState, ({int ioId, String topic})> {
|
|
158
|
+
@override
|
|
159
|
+
RelayItemState build(({int ioId, String topic}) arg) {
|
|
160
|
+
ref.onDispose(() {
|
|
161
|
+
// 정리 로직 (스트림 구독 해제 등)
|
|
162
|
+
});
|
|
163
|
+
return RelayItemState(ioId: arg.ioId, topic: arg.topic);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
Future<void> doAction() async {
|
|
167
|
+
final result = await ref.read(dataLayer).ac.someMethod(id: state.ioId);
|
|
168
|
+
state = state.copyWith(/* ... */);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
사용:
|
|
174
|
+
```dart
|
|
175
|
+
// Page/Widget에서
|
|
176
|
+
final itemState = ref.watch(
|
|
177
|
+
pRelayItemProvider((ioId: item.ioId, topic: item.topic)),
|
|
178
|
+
);
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
## 핵심 규칙
|
|
182
|
+
|
|
183
|
+
| 규칙 | 설명 |
|
|
184
|
+
|------|------|
|
|
185
|
+
| `ref.watch` | UI rebuild이 필요한 곳 (build 메서드 내) |
|
|
186
|
+
| `ref.read` | 일회성 호출 (이벤트 핸들러, initState 콜백) |
|
|
187
|
+
| `dataLayer` 접근 | VM 내에서 `ref.read(dataLayer).repository.method()` |
|
|
188
|
+
| State 불변성 | Equatable 상속 + copyWith 패턴 |
|
|
189
|
+
| Provider 네이밍 | `p` 접두사 + PascalCase + Provider (예: `pHomePageProvider`) |
|
|
190
|
+
| 초기 로드 | `addPostFrameCallback` 으로 첫 프레임 이후 호출 |
|
|
191
|
+
| 에러/로딩 | State에 `isLoading`, `error` 필수 포함 (UI 3가지 상태 처리) |
|
|
192
|
+
|
|
193
|
+
## 금지
|
|
194
|
+
|
|
195
|
+
- `bridges/` 사용 금지 — BLOC 기반 레거시
|
|
196
|
+
- StatefulWidget 내 직접 API 호출 — 반드시 VM 경유
|
|
197
|
+
- `setState` 로 API 응답 상태 관리 — Riverpod 상태로 대체
|
|
198
|
+
- `ref.read`를 build 메서드 내에서 사용 (→ `ref.watch`)
|
|
199
|
+
- VM 내 UI 의존 코드 (Navigator, ScaffoldMessenger 등) — UI 콜백으로 분리
|
package/skills/planner/SKILL.md
CHANGED
|
@@ -26,7 +26,18 @@ disable-model-invocation: true
|
|
|
26
26
|
1. `AGENTS.md` 읽기
|
|
27
27
|
2. `.harness/gotchas/planner.md` 읽기 — **과거 실수 반복 금지**
|
|
28
28
|
3. `.harness/progress.json` 읽기
|
|
29
|
-
4. `.harness/actions/pipeline.json` 읽기 — `planner_mode` 확인
|
|
29
|
+
4. `.harness/actions/pipeline.json` 읽기 — `planner_mode`, `fe_stack` 확인
|
|
30
|
+
5. `.harness/actions/scan-result.json` 읽기 — `tech_stack.fe_stack` 확인 (없으면 `react` 기본)
|
|
31
|
+
6. **Brainstorm Spec 우선 로드** — `.harness/actions/brainstorm-spec.md` 가 존재하면
|
|
32
|
+
**이 파일이 PRD 대체 입력**. Brainstormer 가 이미 사용자와 대화하여 확정한
|
|
33
|
+
결과이므로 **승인된 결정을 뒤엎지 않는다**. 없으면 사용자의 원본 요청 텍스트를 입력으로 사용.
|
|
34
|
+
- brainstorm-spec.md 에 `## Open Questions` 섹션이 있으면 Planner 가 해소 (API 계약으로 확정)
|
|
35
|
+
- brainstorm-spec.md 의 `## 7. 주요 컴포넌트 / 엔티티` → `feature-list.json` 초기 feature 목록 시드
|
|
36
|
+
- brainstorm-spec.md 의 `## 5. 선택된 접근법` / `## 6. 아키텍처 스케치` → MSA 서비스 분할 베이스
|
|
37
|
+
7. **FE Stack 확정** → [FE Stack 결정 가이드](references/fe-stack-detection.md)
|
|
38
|
+
- `pubspec.yaml` + `flutter:` 키 → `fe_stack = "flutter"`
|
|
39
|
+
- 혼재/불명확 → 사용자에게 단 한 번 질문
|
|
40
|
+
- 확정 후 `pipeline.json.fe_stack` 갱신 (없으면 생성)
|
|
30
41
|
|
|
31
42
|
## Outputs (4개)
|
|
32
43
|
|
|
@@ -42,6 +53,17 @@ disable-model-invocation: true
|
|
|
42
53
|
- **full**: MSA 서비스 분할 + 전체 설계 (FULLSTACK, BE-ONLY)
|
|
43
54
|
- **light**: OpenAPI → api-contract.json 변환 + FE 설계만 (FE-ONLY)
|
|
44
55
|
|
|
56
|
+
## fe_stack (FE 파이프라인 분기)
|
|
57
|
+
|
|
58
|
+
`pipeline.json.fe_stack`은 FE Generator/Evaluator 선택을 결정한다:
|
|
59
|
+
|
|
60
|
+
| 값 | FE Generator | FE Evaluator | 비고 |
|
|
61
|
+
|----|--------------|--------------|------|
|
|
62
|
+
| `react` (기본) | `generator-frontend` | `evaluator-functional` + `evaluator-visual` | Vercel/Next.js/Tailwind |
|
|
63
|
+
| `flutter` | `generator-frontend-flutter` | `evaluator-functional-flutter` | Riverpod + integrated_data_layer, Eval-Visual 생략 |
|
|
64
|
+
|
|
65
|
+
**Planner는 `pipeline.json`에 `fe_stack`을 반드시 기록해야 한다.** Dispatcher가 이 값으로 `next_agent`를 라우팅한다.
|
|
66
|
+
|
|
45
67
|
## Process
|
|
46
68
|
|
|
47
69
|
1. 사양서 작성 → [plan 템플릿](references/plan-template.md)
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
---
|
|
2
|
+
docmeta:
|
|
3
|
+
id: fe-stack-detection
|
|
4
|
+
title: FE Stack Detection — Planner Responsibility
|
|
5
|
+
type: output
|
|
6
|
+
createdAt: 2026-04-09T00:00:00Z
|
|
7
|
+
updatedAt: 2026-04-09T00:00:00Z
|
|
8
|
+
source:
|
|
9
|
+
producer: agent
|
|
10
|
+
skillId: harness-planner
|
|
11
|
+
inputs:
|
|
12
|
+
- documentId: clue-fe-flutter-skill
|
|
13
|
+
uri: ../../../../../moon_web/clue-fe-flutter.skill
|
|
14
|
+
relation: output-from
|
|
15
|
+
sections:
|
|
16
|
+
- sourceRange:
|
|
17
|
+
startLine: 1
|
|
18
|
+
endLine: 103
|
|
19
|
+
targetRange:
|
|
20
|
+
startLine: 79
|
|
21
|
+
endLine: 94
|
|
22
|
+
- documentId: harness-planner-skill
|
|
23
|
+
uri: ../SKILL.md
|
|
24
|
+
relation: output-from
|
|
25
|
+
sections:
|
|
26
|
+
- sourceRange:
|
|
27
|
+
startLine: 26
|
|
28
|
+
endLine: 56
|
|
29
|
+
targetRange:
|
|
30
|
+
startLine: 27
|
|
31
|
+
endLine: 78
|
|
32
|
+
tags:
|
|
33
|
+
- planner
|
|
34
|
+
- fe-stack
|
|
35
|
+
- flutter
|
|
36
|
+
- pipeline
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
# FE Stack Detection — Planner Responsibility
|
|
40
|
+
|
|
41
|
+
Planner는 Sprint 1 착수 전 반드시 `fe_stack`을 확정하고 `pipeline.json`에 기록해야 한다.
|
|
42
|
+
|
|
43
|
+
## 1. 감지 순서 (자동)
|
|
44
|
+
|
|
45
|
+
```
|
|
46
|
+
1. .harness/actions/scan-result.json 의 tech_stack.fe_stack 읽기
|
|
47
|
+
2. 값이 "flutter" 또는 "react" 면 → 그대로 사용
|
|
48
|
+
3. 값이 없거나 "unknown" 이면 → 아래 추가 감지
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### 추가 감지 (fallback)
|
|
52
|
+
|
|
53
|
+
| 시그널 | 판정 |
|
|
54
|
+
|--------|------|
|
|
55
|
+
| 루트 `pubspec.yaml` + `flutter:` 키 존재 | `flutter` |
|
|
56
|
+
| 하위 경로(예: `mobile/`, `apps/mobile/`, `clue_mobile_app/`)에 pubspec.yaml | `flutter` |
|
|
57
|
+
| `package.json` 에 `react`, `next`, `vite` 등 | `react` |
|
|
58
|
+
| `lib/` + `.dart` 파일 다수 | `flutter` |
|
|
59
|
+
| 둘 다 존재 (Flutter + Web 혼재) | **사용자 질문 필수** |
|
|
60
|
+
|
|
61
|
+
## 2. 사용자 질문 (불명확 시 단 한 번)
|
|
62
|
+
|
|
63
|
+
```
|
|
64
|
+
프론트엔드 스택을 확인합니다:
|
|
65
|
+
|
|
66
|
+
(A) React / Next.js / Vite 기반 Web
|
|
67
|
+
(B) Flutter (Dart, 모바일/데스크톱)
|
|
68
|
+
|
|
69
|
+
현재 감지된 시그널:
|
|
70
|
+
- pubspec.yaml: [있음/없음]
|
|
71
|
+
- package.json: [있음/없음]
|
|
72
|
+
- Next.js config: [있음/없음]
|
|
73
|
+
|
|
74
|
+
어떤 스택으로 진행하시겠습니까? (A/B)
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
혼재 프로젝트(Web + Flutter mobile)라면 사용자에게 **현 스프린트의 타깃**을 묻는다 — 파이프라인은 한 번에 하나의 `fe_stack`만 취급한다.
|
|
78
|
+
|
|
79
|
+
## 3. pipeline.json 갱신
|
|
80
|
+
|
|
81
|
+
`fe_stack` 확정 후 `pipeline.json`에 반드시 추가:
|
|
82
|
+
|
|
83
|
+
```json
|
|
84
|
+
{
|
|
85
|
+
"pipeline": "FULLSTACK",
|
|
86
|
+
"planner_mode": "full",
|
|
87
|
+
"fe_stack": "flutter",
|
|
88
|
+
"agents_active": [
|
|
89
|
+
"planner",
|
|
90
|
+
"generator-backend",
|
|
91
|
+
"generator-frontend-flutter",
|
|
92
|
+
"evaluator-functional-flutter"
|
|
93
|
+
],
|
|
94
|
+
"agents_skipped": [
|
|
95
|
+
"generator-frontend",
|
|
96
|
+
"evaluator-functional",
|
|
97
|
+
"evaluator-visual"
|
|
98
|
+
],
|
|
99
|
+
"evaluator_mode": "flutter-native",
|
|
100
|
+
"notes": "Flutter 앱 — Playwright 대신 flutter analyze/test + 정적 anti-pattern 검증"
|
|
101
|
+
}
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### fe_stack → 파이프라인 매핑
|
|
105
|
+
|
|
106
|
+
| pipeline | fe_stack | agents_active 예시 |
|
|
107
|
+
|----------|----------|-------------------|
|
|
108
|
+
| FULLSTACK | react | planner, generator-backend, generator-frontend, evaluator-functional, evaluator-visual |
|
|
109
|
+
| FULLSTACK | flutter | planner, generator-backend, generator-frontend-flutter, evaluator-functional-flutter |
|
|
110
|
+
| FE-ONLY | react | planner, generator-frontend, evaluator-functional, evaluator-visual |
|
|
111
|
+
| FE-ONLY | flutter | planner, generator-frontend-flutter, evaluator-functional-flutter |
|
|
112
|
+
| BE-ONLY | (무관) | planner, generator-backend, evaluator-functional |
|
|
113
|
+
|
|
114
|
+
## 4. Flutter 선택 시 추가 작업
|
|
115
|
+
|
|
116
|
+
Flutter로 확정되면 Planner는:
|
|
117
|
+
|
|
118
|
+
1. **AGENTS.md IA-MAP** 의 `[FE]` 섹션을 Flutter 구조로 바꿔야 한다.
|
|
119
|
+
- `apps/web/` → `lib/ui/pages/`, `lib/ui/component/`
|
|
120
|
+
- `libs/shared-dto/` 대신 `integrated_data_layer/` 경로 등록
|
|
121
|
+
- 소유자: `→ Generator-Frontend-Flutter`
|
|
122
|
+
|
|
123
|
+
2. **api-contract.json** 은 언어 중립적이어야 한다 — Planner는 TypeScript 타입이 아니라 **스키마 JSON** 으로만 작성. Flutter Generator가 Retrofit/JsonSerializable로 변환한다.
|
|
124
|
+
|
|
125
|
+
3. **feature-list.json** 의 `layer: "frontend"` feature들은 `fe_stack: "flutter"` 태그를 달아서 Evaluator가 구분하도록 한다.
|
|
126
|
+
|
|
127
|
+
## 5. 금지
|
|
128
|
+
|
|
129
|
+
- 파이프라인 실행 도중 `fe_stack` 변경 금지 — 스프린트 경계에서만 가능
|
|
130
|
+
- React/Flutter 코드 혼재 생성 금지 — Generator는 하나의 stack만 담당
|
|
131
|
+
- 사용자가 명시적으로 한 스택을 지시했는데 감지 결과로 다른 스택을 강제하지 말 것
|