feature-loop-harness-cli 0.1.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.
Files changed (40) hide show
  1. package/README.md +53 -0
  2. package/bin/flh.js +391 -0
  3. package/package.json +29 -0
  4. package/templates/default/.codex/config.toml +2 -0
  5. package/templates/default/.codex/hooks/user-prompt-submit.sh +5 -0
  6. package/templates/default/.codex/hooks.json +16 -0
  7. package/templates/default/.flh/docs/FEATURE_IMPLEMENTATION_PIPELINE.md +454 -0
  8. package/templates/default/.flh/docs/PROJECT_WORKFLOW.md +270 -0
  9. package/templates/default/.flh/docs/REVIEW_PATCH_PIPELINE.md +166 -0
  10. package/templates/default/.flh/hooks/user_prompt_submit.py +1440 -0
  11. package/templates/default/.flh/runtime/STATE.md +84 -0
  12. package/templates/default/.flh/scripts/pre_commit.py +674 -0
  13. package/templates/default/.flh/workflow/docs-spec.yml +134 -0
  14. package/templates/default/.flh/workflow/flow.yml +82 -0
  15. package/templates/default/.flh/workflow/request-patterns.yml +265 -0
  16. package/templates/default/.flh/workflow/state-actions.yml +117 -0
  17. package/templates/default/.flh/workflow/transition-guards.yml +57 -0
  18. package/templates/default/.husky/pre-commit +3 -0
  19. package/templates/default/AGENTS.md +44 -0
  20. package/templates/default/HARNESS_MANUAL.md +1105 -0
  21. package/templates/default/README.md +251 -0
  22. package/templates/default/docs/API.md +41 -0
  23. package/templates/default/docs/ARCHITECTURE.md +86 -0
  24. package/templates/default/docs/DB_SCHEMA.md +149 -0
  25. package/templates/default/docs/DESIGN.md +52 -0
  26. package/templates/default/docs/MVP.md +47 -0
  27. package/templates/default/docs/QUALITY_SCORE.md +54 -0
  28. package/templates/default/docs/docs-map.md +64 -0
  29. package/templates/default/docs/features/active/.gitkeep +1 -0
  30. package/templates/default/docs/features/backlog/.gitkeep +1 -0
  31. package/templates/default/docs/features/blocked/.gitkeep +1 -0
  32. package/templates/default/docs/features/done/.gitkeep +1 -0
  33. package/templates/default/docs/features/feature-index.md +21 -0
  34. package/templates/default/docs/features/postponed/.gitkeep +1 -0
  35. package/templates/default/docs/features/ready/.gitkeep +1 -0
  36. package/templates/default/docs/features/review/.gitkeep +1 -0
  37. package/templates/default/docs/source-layout.yml +33 -0
  38. package/templates/default/gitignore.template +9 -0
  39. package/templates/default/tests/hooks/test_pre_commit.py +659 -0
  40. package/templates/default/tests/hooks/test_user_prompt_submit.py +750 -0
@@ -0,0 +1,750 @@
1
+ import importlib.util
2
+ import shutil
3
+ import sys
4
+ import tempfile
5
+ import unittest
6
+ from pathlib import Path
7
+
8
+
9
+ REPO_ROOT = Path(__file__).resolve().parents[2]
10
+ HOOK_PATH = REPO_ROOT / ".flh/hooks/user_prompt_submit.py"
11
+
12
+
13
+ def load_hook_module():
14
+ spec = importlib.util.spec_from_file_location("user_prompt_submit", HOOK_PATH)
15
+ module = importlib.util.module_from_spec(spec)
16
+ assert spec.loader is not None
17
+ sys.modules[spec.name] = module
18
+ spec.loader.exec_module(module)
19
+ return module
20
+
21
+
22
+ class UserPromptSubmitHookTest(unittest.TestCase):
23
+ def setUp(self):
24
+ self.tmpdir = tempfile.TemporaryDirectory()
25
+ self.root = Path(self.tmpdir.name)
26
+ shutil.copytree(REPO_ROOT / ".flh", self.root / ".flh")
27
+ shutil.copytree(REPO_ROOT / "docs", self.root / "docs")
28
+ self.module = load_hook_module()
29
+ self.original_root = self.module.ROOT
30
+ self.original_state = self.module.STATE_PATH
31
+ self.original_flow = self.module.FLOW_PATH
32
+ self.original_docs_spec = self.module.DOCS_SPEC_PATH
33
+ self.original_guards = self.module.TRANSITION_GUARDS_PATH
34
+ self.original_request_patterns = self.module.REQUEST_PATTERNS_PATH
35
+ self.module.ROOT = self.root
36
+ self.module.STATE_PATH = self.root / ".flh/runtime/STATE.md"
37
+ self.module.FLOW_PATH = self.root / ".flh/workflow/flow.yml"
38
+ self.module.DOCS_SPEC_PATH = self.root / ".flh/workflow/docs-spec.yml"
39
+ self.module.TRANSITION_GUARDS_PATH = self.root / ".flh/workflow/transition-guards.yml"
40
+ self.module.REQUEST_PATTERNS_PATH = self.root / ".flh/workflow/request-patterns.yml"
41
+
42
+ def tearDown(self):
43
+ self.module.ROOT = self.original_root
44
+ self.module.STATE_PATH = self.original_state
45
+ self.module.FLOW_PATH = self.original_flow
46
+ self.module.DOCS_SPEC_PATH = self.original_docs_spec
47
+ self.module.TRANSITION_GUARDS_PATH = self.original_guards
48
+ self.module.REQUEST_PATTERNS_PATH = self.original_request_patterns
49
+ self.tmpdir.cleanup()
50
+
51
+ def write_completed_doc(self, path, sections):
52
+ body = ["---", "status: completed", "---", "", f"# {Path(path).name}", ""]
53
+ for section in sections:
54
+ body.extend(
55
+ [
56
+ f"## {section}",
57
+ "",
58
+ f"Completed content for {section}. This section has enough text.",
59
+ "",
60
+ ]
61
+ )
62
+ target = self.root / path
63
+ target.parent.mkdir(parents=True, exist_ok=True)
64
+ target.write_text("\n".join(body), encoding="utf-8")
65
+
66
+ def write_completed_source_layout(self):
67
+ target = self.root / "docs/source-layout.yml"
68
+ target.write_text(
69
+ "\n".join(
70
+ [
71
+ "version: 1",
72
+ "status: completed",
73
+ "",
74
+ "project:",
75
+ " type: web-app",
76
+ " package_manager: npm",
77
+ " workspace: true",
78
+ " runtime: node",
79
+ " persistence:",
80
+ " database_required: true",
81
+ " database_provider: postgresql",
82
+ "",
83
+ "source_roots:",
84
+ " frontend:",
85
+ " path: app/fe",
86
+ " role: frontend",
87
+ " package: true",
88
+ " stack: react-vite",
89
+ " framework: vite",
90
+ " runtime: react",
91
+ " language: typescript",
92
+ " module: esm",
93
+ " testing:",
94
+ " unit: vitest",
95
+ " integration: none",
96
+ " e2e: playwright",
97
+ " component: testing-library",
98
+ " tooling:",
99
+ " lint: eslint",
100
+ " format: prettier",
101
+ " scaffold: gitkeep-only",
102
+ " description: Frontend application package directory.",
103
+ " backend:",
104
+ " path: app/be",
105
+ " role: backend",
106
+ " package: true",
107
+ " stack: node-api-prisma",
108
+ " framework: express",
109
+ " runtime: node",
110
+ " language: typescript",
111
+ " module: esm",
112
+ " testing:",
113
+ " unit: vitest",
114
+ " integration: vitest",
115
+ " e2e: none",
116
+ " component: none",
117
+ " tooling:",
118
+ " lint: eslint",
119
+ " format: prettier",
120
+ " scaffold: gitkeep-only",
121
+ " description: Backend application package directory.",
122
+ "",
123
+ ]
124
+ ),
125
+ encoding="utf-8",
126
+ )
127
+
128
+ def write_completed_architecture_doc(self):
129
+ self.write_completed_doc(
130
+ "docs/ARCHITECTURE.md",
131
+ [
132
+ "System Overview",
133
+ "Tech Stack",
134
+ "Source Layout",
135
+ "Package Layout",
136
+ "Testing Strategy",
137
+ "Modules",
138
+ "Data Flow",
139
+ "External Dependencies",
140
+ "Runtime Environment",
141
+ "Scaffold Policy",
142
+ "Constraints",
143
+ ],
144
+ )
145
+
146
+ def write_completed_feature_index(self):
147
+ target = self.root / "docs/features/feature-index.md"
148
+ target.parent.mkdir(parents=True, exist_ok=True)
149
+ target.write_text(
150
+ "\n".join(
151
+ [
152
+ "---",
153
+ "status: completed",
154
+ "---",
155
+ "",
156
+ "# Feature Index",
157
+ "",
158
+ "## Feature Index",
159
+ "",
160
+ "Completed feature index summary with enough detail.",
161
+ "",
162
+ "## Feature List",
163
+ "",
164
+ "| Feature ID | Name | Summary | Priority | Core Requirements |",
165
+ "| --- | --- | --- | --- | --- |",
166
+ "| FEAT-001 | Login | User login flow | high | User can sign in securely |",
167
+ "",
168
+ ]
169
+ ),
170
+ encoding="utf-8",
171
+ )
172
+
173
+ def write_completed_db_schema(self):
174
+ self.write_completed_doc(
175
+ "docs/DB_SCHEMA.md",
176
+ [
177
+ "Core Entities",
178
+ "Entity Specifications",
179
+ "Relation Specifications",
180
+ "Indexes and Constraints",
181
+ "Enums",
182
+ "Ownership and Permissions",
183
+ "ID Strategy",
184
+ "Lifecycle Policy",
185
+ "Common Field Policy",
186
+ "Prisma Mapping Notes",
187
+ "Migration Notes",
188
+ ],
189
+ )
190
+
191
+ def write_completed_api_doc(self):
192
+ self.write_completed_doc(
193
+ "docs/API.md",
194
+ [
195
+ "API Areas",
196
+ "Endpoint Draft",
197
+ "Authentication and Authorization",
198
+ "Request and Response Rules",
199
+ "Error Response Rules",
200
+ ],
201
+ )
202
+
203
+ def write_completed_design_doc(self):
204
+ self.write_completed_doc(
205
+ "docs/DESIGN.md",
206
+ [
207
+ "Layout Principles",
208
+ "Component Principles",
209
+ "State Loading and Error",
210
+ "Form Rules",
211
+ "Responsive Rules",
212
+ "Accessibility",
213
+ ],
214
+ )
215
+
216
+ def create_feature_state_directories(self):
217
+ for name in ("backlog", "ready", "active", "blocked", "review", "done", "postponed"):
218
+ (self.root / "docs/features" / name).mkdir(parents=True, exist_ok=True)
219
+
220
+ def write_state(self, text):
221
+ self.module.STATE_PATH.write_text(text, encoding="utf-8")
222
+
223
+ def write_base_state(self, current_state):
224
+ completed_by_state = {
225
+ "FEATURE_INDEX_DEFINITION": [
226
+ "MVP_DEFINITION",
227
+ "ARCHITECTURE_DESIGN",
228
+ ],
229
+ "FRONTEND_DESIGN": [
230
+ "MVP_DEFINITION",
231
+ "ARCHITECTURE_DESIGN",
232
+ "FEATURE_INDEX_DEFINITION",
233
+ "DATA_MODEL_DEFINITION",
234
+ "API_DESIGN",
235
+ ],
236
+ }
237
+ lines = [
238
+ "---",
239
+ f"current_state: {current_state}",
240
+ "completed_states:",
241
+ ]
242
+ completed = completed_by_state.get(current_state, [])
243
+ if completed:
244
+ lines.extend(f" - {state}" for state in completed)
245
+ else:
246
+ lines[-1] = "completed_states: []"
247
+ lines.extend(
248
+ [
249
+ "approvals: {}",
250
+ "last_transition: null",
251
+ "updated_at: null",
252
+ "---",
253
+ "",
254
+ "# STATE",
255
+ "",
256
+ ]
257
+ )
258
+ self.write_state("\n".join(lines))
259
+
260
+ def test_allows_request_type_in_current_state(self):
261
+ result = self.module.handle_prompt("MVP 범위를 설계해줘")
262
+
263
+ self.assertEqual(result.action, "allow")
264
+ self.assertEqual(result.request_type, "MVP_DESIGN_REQUEST")
265
+ self.assertEqual(result.confidence, "high")
266
+ self.assertEqual(result.current_state, "MVP_DEFINITION")
267
+ self.assertIsNone(result.updated_state)
268
+
269
+ def test_alias_classifies_with_medium_confidence(self):
270
+ result = self.module.handle_prompt("초기 범위 정리해줘")
271
+
272
+ self.assertEqual(result.action, "allow")
273
+ self.assertEqual(result.request_type, "MVP_DESIGN_REQUEST")
274
+ self.assertEqual(result.confidence, "medium")
275
+ self.assertIn("alias pattern", result.additional_prompt)
276
+
277
+ def test_request_patterns_are_loaded_from_workflow_config(self):
278
+ self.module.REQUEST_PATTERNS_PATH.write_text(
279
+ "\n".join(
280
+ [
281
+ "version: 1",
282
+ "patterns:",
283
+ " MVP_DESIGN_REQUEST:",
284
+ " strong:",
285
+ " - custom-mvp-token",
286
+ " aliases:",
287
+ " - custom-alias-token",
288
+ ]
289
+ ),
290
+ encoding="utf-8",
291
+ )
292
+
293
+ strong = self.module.classify_prompt("custom-mvp-token")
294
+ alias = self.module.classify_prompt("custom-alias-token")
295
+ fallback_unknown = self.module.classify_prompt("MVP 범위를 설계해줘")
296
+
297
+ self.assertEqual(strong.request_type, "MVP_DESIGN_REQUEST")
298
+ self.assertEqual(strong.confidence, "high")
299
+ self.assertEqual(alias.request_type, "MVP_DESIGN_REQUEST")
300
+ self.assertEqual(alias.confidence, "medium")
301
+ self.assertEqual(fallback_unknown.request_type, "UNKNOWN")
302
+
303
+ def test_unknown_allows_with_additional_prompt_without_state_change(self):
304
+ result = self.module.handle_prompt("이 구조에 대한 의견을 말해줘")
305
+
306
+ self.assertEqual(result.action, "allow")
307
+ self.assertEqual(result.request_type, "UNKNOWN")
308
+ self.assertEqual(result.confidence, "unknown")
309
+ self.assertIn("Harness Guard", result.additional_prompt)
310
+ output = self.module.format_codex_output(result)
311
+ self.assertEqual(
312
+ output["hookSpecificOutput"]["hookEventName"],
313
+ "UserPromptSubmit",
314
+ )
315
+ self.assertIn(
316
+ "UNKNOWN",
317
+ output["hookSpecificOutput"]["additionalContext"],
318
+ )
319
+ self.assertIn(
320
+ "docs/",
321
+ output["hookSpecificOutput"]["additionalContext"],
322
+ )
323
+ state_text = self.module.STATE_PATH.read_text(encoding="utf-8")
324
+ self.assertIn("current_state: MVP_DEFINITION", state_text)
325
+
326
+ def test_ambiguous_request_asks_for_clarification_without_state_change(self):
327
+ result = self.module.handle_prompt("mvp 구현해줘")
328
+
329
+ self.assertEqual(result.action, "allow")
330
+ self.assertEqual(result.request_type, "IMPLEMENTATION_REQUEST")
331
+ self.assertEqual(result.confidence, "low")
332
+ self.assertIn("matched_request_types", result.additional_prompt)
333
+ self.assertIn("multiple or conflicting workflow signals", result.additional_prompt)
334
+ state_text = self.module.STATE_PATH.read_text(encoding="utf-8")
335
+ self.assertIn("current_state: MVP_DEFINITION", state_text)
336
+
337
+ def test_question_confirmation_takes_priority_over_mutation_keyword(self):
338
+ result = self.module.handle_prompt("수정하면 되는거지?")
339
+
340
+ self.assertEqual(result.action, "allow")
341
+ self.assertEqual(result.request_type, "QUESTION_OR_CONFIRMATION_REQUEST")
342
+ self.assertEqual(result.confidence, "high")
343
+ self.assertIn("without creating, modifying, or deleting files", result.additional_prompt)
344
+ state_text = self.module.STATE_PATH.read_text(encoding="utf-8")
345
+ self.assertIn("current_state: MVP_DEFINITION", state_text)
346
+
347
+ def test_question_prefix_forces_question_mode(self):
348
+ result = self.module.handle_prompt("/q 커밋하고 구현해줘")
349
+
350
+ self.assertEqual(result.action, "allow")
351
+ self.assertEqual(result.request_type, "QUESTION_OR_CONFIRMATION_REQUEST")
352
+ self.assertEqual(result.confidence, "high")
353
+ self.assertIn("control prefix", result.additional_prompt)
354
+ self.assertIn("without creating, modifying, or deleting files", result.additional_prompt)
355
+
356
+ def test_question_prefix_takes_priority_over_documentation_prefix(self):
357
+ result = self.module.handle_prompt("/q /d README 수정하고 커밋해줘")
358
+
359
+ self.assertEqual(result.action, "allow")
360
+ self.assertEqual(result.request_type, "QUESTION_OR_CONFIRMATION_REQUEST")
361
+ self.assertEqual(result.confidence, "high")
362
+ self.assertIn("Do not start implementation", result.additional_prompt)
363
+
364
+ def test_documentation_prefix_allows_documentation_mode(self):
365
+ result = self.module.handle_prompt("/d README 수정하고 커밋해줘")
366
+
367
+ self.assertEqual(result.action, "allow")
368
+ self.assertEqual(result.request_type, "DOCUMENTATION_REQUEST")
369
+ self.assertEqual(result.confidence, "high")
370
+ self.assertIn("documentation mode", result.additional_prompt)
371
+ self.assertIn("Commit and push are allowed", result.additional_prompt)
372
+ self.assertIn("Merge is not allowed", result.additional_prompt)
373
+
374
+ def test_documentation_prefix_includes_manual_skip_policy(self):
375
+ result = self.module.handle_prompt("/d DATA_MODEL_DEFINITION 스킵하고 API로 넘어가")
376
+
377
+ self.assertEqual(result.action, "allow")
378
+ self.assertEqual(result.request_type, "DOCUMENTATION_REQUEST")
379
+ self.assertIn("workflow state skip/transition", result.additional_prompt)
380
+ self.assertIn("Do not skip MVP_DEFINITION", result.additional_prompt)
381
+
382
+ def test_documentation_prefix_blocks_merge(self):
383
+ result = self.module.handle_prompt("/d 머지 해줘")
384
+
385
+ self.assertEqual(result.action, "block")
386
+ self.assertEqual(result.request_type, "DOCUMENTATION_REQUEST")
387
+ self.assertIn("Merge is not allowed", result.reason)
388
+
389
+ def test_documentation_prefix_blocks_english_merge(self):
390
+ result = self.module.handle_prompt("/d merge 해줘")
391
+
392
+ self.assertEqual(result.action, "block")
393
+ self.assertEqual(result.request_type, "DOCUMENTATION_REQUEST")
394
+ self.assertIn("Merge is not allowed", result.reason)
395
+
396
+ def test_documentation_prefix_takes_priority_over_question_text(self):
397
+ result = self.module.handle_prompt("/d README 수정하면 되는거지?")
398
+
399
+ self.assertEqual(result.action, "allow")
400
+ self.assertEqual(result.request_type, "DOCUMENTATION_REQUEST")
401
+ self.assertEqual(result.confidence, "high")
402
+ self.assertIn("documentation mode", result.additional_prompt)
403
+
404
+ def test_question_command_mix_asks_for_clarification(self):
405
+ result = self.module.handle_prompt("수정하면 되는거지? 앱 수정해줘")
406
+
407
+ self.assertEqual(result.action, "allow")
408
+ self.assertEqual(result.request_type, "IMPLEMENTATION_REQUEST")
409
+ self.assertEqual(result.confidence, "low")
410
+ self.assertIn(
411
+ "QUESTION_OR_CONFIRMATION_REQUEST, IMPLEMENTATION_REQUEST",
412
+ result.additional_prompt,
413
+ )
414
+ self.assertIn("explanation only", result.additional_prompt)
415
+ state_text = self.module.STATE_PATH.read_text(encoding="utf-8")
416
+ self.assertIn("current_state: MVP_DEFINITION", state_text)
417
+
418
+ def test_question_command_mix_with_specific_mutation_asks_for_clarification(self):
419
+ result = self.module.handle_prompt("왜 안돼? 파일 수정해줘")
420
+
421
+ self.assertEqual(result.action, "allow")
422
+ self.assertEqual(result.request_type, "IMPLEMENTATION_REQUEST")
423
+ self.assertEqual(result.confidence, "low")
424
+ self.assertIn(
425
+ "QUESTION_OR_CONFIRMATION_REQUEST, IMPLEMENTATION_REQUEST",
426
+ result.additional_prompt,
427
+ )
428
+
429
+ def test_question_transition_mix_asks_for_clarification(self):
430
+ result = self.module.handle_prompt("다음 단계로 가면 되나? 다음 단계 넘어")
431
+
432
+ self.assertEqual(result.action, "allow")
433
+ self.assertEqual(result.confidence, "low")
434
+ self.assertIn("QUESTION_OR_CONFIRMATION_REQUEST", result.additional_prompt)
435
+
436
+ def test_question_patterns_are_loaded_from_workflow_config(self):
437
+ self.module.REQUEST_PATTERNS_PATH.write_text(
438
+ "\n".join(
439
+ [
440
+ "version: 1",
441
+ "question_or_confirmation_patterns:",
442
+ " - custom-question-token",
443
+ "patterns:",
444
+ " IMPLEMENTATION_REQUEST:",
445
+ " strong:",
446
+ " - custom-question-token.*수정",
447
+ " aliases: []",
448
+ ]
449
+ ),
450
+ encoding="utf-8",
451
+ )
452
+
453
+ pure_question = self.module.classify_prompt("custom-question-token")
454
+ mixed = self.module.classify_prompt("custom-question-token 수정")
455
+
456
+ self.assertEqual(pure_question.request_type, "QUESTION_OR_CONFIRMATION_REQUEST")
457
+ self.assertEqual(mixed.request_type, "IMPLEMENTATION_REQUEST")
458
+ self.assertEqual(mixed.confidence, "low")
459
+
460
+ def test_blocks_transition_when_required_doc_is_template(self):
461
+ result = self.module.handle_prompt("아키텍처 설계하자")
462
+
463
+ self.assertEqual(result.action, "block")
464
+ self.assertEqual(result.request_type, "ARCHITECTURE_DESIGN_REQUEST")
465
+ self.assertEqual(result.confidence, "high")
466
+ self.assertEqual(result.target_state, "ARCHITECTURE_DESIGN")
467
+ self.assertTrue(result.missing)
468
+ output = self.module.format_codex_output(result)
469
+ self.assertEqual(output["decision"], "block")
470
+ self.assertIn("[Harness Guard: BLOCKED]", output["reason"])
471
+ self.assertIn("Transition guard check failed", output["reason"])
472
+ self.assertIn("Request:", output["reason"])
473
+ self.assertIn("- current_state: MVP_DEFINITION", output["reason"])
474
+ self.assertIn("Missing requirements:", output["reason"])
475
+ self.assertIn("docs/MVP.md", output["reason"])
476
+ self.assertIn("Next action:", output["reason"])
477
+
478
+ def test_blocks_when_no_transition_path_exists(self):
479
+ self.write_base_state("FEATURE_IMPLEMENTATION")
480
+
481
+ result = self.module.handle_prompt("MVP 범위를 설계해줘")
482
+
483
+ self.assertEqual(result.action, "block")
484
+ self.assertEqual(result.request_type, "MVP_DESIGN_REQUEST")
485
+ self.assertEqual(result.target_state, "MVP_DEFINITION")
486
+ self.assertIn("No transition path exists", result.reason)
487
+
488
+ def test_blocks_wrong_request_matrix_for_current_state(self):
489
+ scenarios = [
490
+ ("MVP_DEFINITION", "API 설계해줘", "API_DESIGN_REQUEST"),
491
+ ("ARCHITECTURE_DESIGN", "기능 구현해줘", "IMPLEMENTATION_REQUEST"),
492
+ ("FEATURE_INDEX_DEFINITION", "프론트 디자인 지침 정리하자", "FRONTEND_DESIGN_REQUEST"),
493
+ ("DATA_MODEL_DEFINITION", "이 기능 준비해줘", "FEATURE_PREPARE_REQUEST"),
494
+ ("API_DESIGN", "MVP 범위를 설계해줘", "MVP_DESIGN_REQUEST"),
495
+ ]
496
+
497
+ for state, prompt, request_type in scenarios:
498
+ with self.subTest(state=state, prompt=prompt):
499
+ self.write_base_state(state)
500
+ result = self.module.handle_prompt(prompt)
501
+ self.assertEqual(result.action, "block")
502
+ self.assertEqual(result.request_type, request_type)
503
+ state_text = self.module.STATE_PATH.read_text(encoding="utf-8")
504
+ self.assertIn(f"current_state: {state}", state_text)
505
+
506
+ def test_transitions_when_required_doc_is_completed(self):
507
+ self.write_completed_doc(
508
+ "docs/MVP.md",
509
+ [
510
+ "MVP Goal",
511
+ "Target Users",
512
+ "Core Problem",
513
+ "In Scope",
514
+ "Out of Scope",
515
+ "Success Criteria",
516
+ ],
517
+ )
518
+
519
+ result = self.module.handle_prompt("아키텍처 설계하자")
520
+
521
+ self.assertEqual(result.action, "allow")
522
+ self.assertEqual(result.target_state, "ARCHITECTURE_DESIGN")
523
+ self.assertEqual(result.updated_state, "ARCHITECTURE_DESIGN")
524
+ output = self.module.format_codex_output(result)
525
+ self.assertIn("hookSpecificOutput", output)
526
+ self.assertIn(
527
+ "STATE.md was updated",
528
+ output["hookSpecificOutput"]["additionalContext"],
529
+ )
530
+ state_text = self.module.STATE_PATH.read_text(encoding="utf-8")
531
+ self.assertIn("current_state: ARCHITECTURE_DESIGN", state_text)
532
+ self.assertIn(" - MVP_DEFINITION", state_text)
533
+
534
+ def test_project_workflow_happy_path_reaches_feature_implementation(self):
535
+ self.write_completed_doc(
536
+ "docs/MVP.md",
537
+ [
538
+ "MVP Goal",
539
+ "Target Users",
540
+ "Core Problem",
541
+ "In Scope",
542
+ "Out of Scope",
543
+ "Success Criteria",
544
+ ],
545
+ )
546
+ architecture = self.module.handle_prompt("아키텍처 설계하자")
547
+ self.assertEqual(architecture.updated_state, "ARCHITECTURE_DESIGN")
548
+
549
+ self.write_completed_architecture_doc()
550
+ self.write_completed_source_layout()
551
+ (self.root / "app/fe").mkdir(parents=True)
552
+ (self.root / "app/be").mkdir(parents=True)
553
+ feature_index = self.module.handle_prompt("기능 목록 정리해줘")
554
+ self.assertEqual(feature_index.updated_state, "FEATURE_INDEX_DEFINITION")
555
+
556
+ self.write_completed_feature_index()
557
+ data_model = self.module.handle_prompt("데이터 모델 설계하자")
558
+ self.assertEqual(data_model.updated_state, "DATA_MODEL_DEFINITION")
559
+
560
+ self.write_completed_db_schema()
561
+ api = self.module.handle_prompt("API 설계해줘")
562
+ self.assertEqual(api.updated_state, "API_DESIGN")
563
+
564
+ self.write_completed_api_doc()
565
+ frontend = self.module.handle_prompt("프론트 디자인 지침 정리하자")
566
+ self.assertEqual(frontend.updated_state, "FRONTEND_DESIGN")
567
+
568
+ self.write_completed_design_doc()
569
+ self.create_feature_state_directories()
570
+ implementation = self.module.handle_prompt("이 기능 준비해줘")
571
+ self.assertEqual(implementation.updated_state, "FEATURE_IMPLEMENTATION")
572
+
573
+ state_text = self.module.STATE_PATH.read_text(encoding="utf-8")
574
+ self.assertIn("current_state: FEATURE_IMPLEMENTATION", state_text)
575
+
576
+ def test_source_layout_yaml_requires_completed_manifest(self):
577
+ docs_spec = self.module.load_yaml(self.module.DOCS_SPEC_PATH)
578
+
579
+ template_ok, template_failures = self.module.doc_is_complete("source_layout", docs_spec)
580
+ self.assertFalse(template_ok)
581
+ self.assertTrue(any("status is not completed" in item for item in template_failures))
582
+
583
+ self.write_completed_source_layout()
584
+ completed_ok, completed_failures = self.module.doc_is_complete("source_layout", docs_spec)
585
+ self.assertTrue(completed_ok, completed_failures)
586
+
587
+ def test_architecture_to_feature_index_requires_source_layout_directories(self):
588
+ self.write_state(
589
+ "\n".join(
590
+ [
591
+ "---",
592
+ "current_state: ARCHITECTURE_DESIGN",
593
+ "completed_states:",
594
+ " - MVP_DEFINITION",
595
+ "approvals: {}",
596
+ "last_transition: MVP_DEFINITION -> ARCHITECTURE_DESIGN",
597
+ "updated_at: null",
598
+ "---",
599
+ "",
600
+ "# STATE",
601
+ "",
602
+ ]
603
+ )
604
+ )
605
+ self.write_completed_architecture_doc()
606
+
607
+ missing_manifest = self.module.handle_prompt("기능 목록 정리해줘")
608
+ self.assertEqual(missing_manifest.action, "block")
609
+ self.assertIn("docs/source-layout.yml", "\n".join(missing_manifest.missing or []))
610
+
611
+ self.write_completed_source_layout()
612
+ missing_directories = self.module.handle_prompt("기능 목록 정리해줘")
613
+ self.assertEqual(missing_directories.action, "block")
614
+ self.assertIn("Missing source layout directory: app/fe", missing_directories.missing)
615
+ self.assertIn("Missing source layout directory: app/be", missing_directories.missing)
616
+
617
+ (self.root / "app/fe").mkdir(parents=True)
618
+ (self.root / "app/be").mkdir(parents=True)
619
+ allowed = self.module.handle_prompt("기능 목록 정리해줘")
620
+ self.assertEqual(allowed.action, "allow")
621
+ self.assertEqual(allowed.updated_state, "FEATURE_INDEX_DEFINITION")
622
+
623
+ def test_frontend_to_feature_implementation_accepts_design_approval(self):
624
+ self.write_state(
625
+ "\n".join(
626
+ [
627
+ "---",
628
+ "current_state: FRONTEND_DESIGN",
629
+ "completed_states:",
630
+ " - MVP_DEFINITION",
631
+ " - ARCHITECTURE_DESIGN",
632
+ " - FEATURE_INDEX_DEFINITION",
633
+ " - DATA_MODEL_DEFINITION",
634
+ " - API_DESIGN",
635
+ "approvals:",
636
+ " design:",
637
+ " source: external",
638
+ " path: docs/DESIGN.md",
639
+ " approved: true",
640
+ "last_transition: API_DESIGN -> FRONTEND_DESIGN",
641
+ "updated_at: null",
642
+ "---",
643
+ "",
644
+ "# STATE",
645
+ "",
646
+ ]
647
+ )
648
+ )
649
+
650
+ result = self.module.handle_prompt("이 기능 준비해줘")
651
+
652
+ self.assertEqual(result.action, "allow")
653
+ self.assertEqual(result.request_type, "FEATURE_PREPARE_REQUEST")
654
+ self.assertEqual(result.target_state, "FEATURE_IMPLEMENTATION")
655
+ self.assertEqual(result.updated_state, "FEATURE_IMPLEMENTATION")
656
+
657
+ def test_frontend_design_request_adds_design_selection_context(self):
658
+ self.write_base_state("FRONTEND_DESIGN")
659
+
660
+ result = self.module.handle_prompt("프론트 디자인 지침 정리하자")
661
+
662
+ self.assertEqual(result.action, "allow")
663
+ self.assertEqual(result.request_type, "FRONTEND_DESIGN_REQUEST")
664
+ self.assertIn("FRONTEND_DESIGN uses docs/DESIGN.md", result.additional_prompt)
665
+ self.assertIn("Import an existing external DESIGN.md", result.additional_prompt)
666
+
667
+ def test_feature_index_requires_configured_table_fields(self):
668
+ self.write_base_state("FEATURE_INDEX_DEFINITION")
669
+ self.write_completed_doc(
670
+ "docs/features/feature-index.md",
671
+ [
672
+ "Feature Index",
673
+ "Feature List",
674
+ ],
675
+ )
676
+ feature_index = self.root / "docs/features/feature-index.md"
677
+ feature_index.write_text(
678
+ "\n".join(
679
+ [
680
+ "---",
681
+ "status: completed",
682
+ "---",
683
+ "",
684
+ "# Feature Index",
685
+ "",
686
+ "## Feature Index",
687
+ "",
688
+ "Completed feature index summary with enough detail.",
689
+ "",
690
+ "## Feature List",
691
+ "",
692
+ "| Feature ID | Name | Summary |",
693
+ "| --- | --- | --- |",
694
+ "| FEAT-001 | Login | User login flow |",
695
+ "",
696
+ ]
697
+ ),
698
+ encoding="utf-8",
699
+ )
700
+
701
+ result = self.module.handle_prompt("데이터 모델 설계하자")
702
+
703
+ self.assertEqual(result.action, "block")
704
+ self.assertIn("Priority", result.missing[0])
705
+ self.assertIn("Core Requirements", result.missing[0])
706
+
707
+ def test_write_state_preserves_nested_approvals_as_yaml(self):
708
+ self.write_state(
709
+ "\n".join(
710
+ [
711
+ "---",
712
+ "current_state: FRONTEND_DESIGN",
713
+ "completed_states:",
714
+ " - MVP_DEFINITION",
715
+ " - ARCHITECTURE_DESIGN",
716
+ " - FEATURE_INDEX_DEFINITION",
717
+ " - DATA_MODEL_DEFINITION",
718
+ " - API_DESIGN",
719
+ "approvals:",
720
+ " design:",
721
+ " source: external",
722
+ " path: docs/DESIGN.md",
723
+ " approved: true",
724
+ "last_transition: API_DESIGN -> FRONTEND_DESIGN",
725
+ "updated_at: null",
726
+ "---",
727
+ "",
728
+ "# STATE",
729
+ "",
730
+ "Custom state guide must be preserved.",
731
+ "",
732
+ ]
733
+ )
734
+ )
735
+
736
+ result = self.module.handle_prompt("이 기능 준비해줘")
737
+
738
+ self.assertEqual(result.action, "allow")
739
+ state_text = self.module.STATE_PATH.read_text(encoding="utf-8")
740
+ self.assertIn("approvals:", state_text)
741
+ self.assertIn(" design:", state_text)
742
+ self.assertIn(" source: external", state_text)
743
+ self.assertIn(" path: docs/DESIGN.md", state_text)
744
+ self.assertIn(" approved: true", state_text)
745
+ self.assertNotIn('approvals: {"design"', state_text)
746
+ self.assertIn("Custom state guide must be preserved.", state_text)
747
+
748
+
749
+ if __name__ == "__main__":
750
+ unittest.main()