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,1440 @@
1
+ #!/usr/bin/env python3
2
+ """UserPromptSubmit hook for the harness workflow.
3
+
4
+ The hook is intentionally deterministic. It does not call an LLM.
5
+
6
+ Input:
7
+ - argv text
8
+ - stdin plain text
9
+ - stdin JSON with one of: prompt, user_prompt, message, input
10
+ - USER_PROMPT environment variable
11
+
12
+ Output:
13
+ Codex hook-compatible JSON.
14
+
15
+ Exit codes:
16
+ 0: hook decision emitted
17
+ 1: hook error
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import os
24
+ import re
25
+ import sys
26
+ from collections import deque
27
+ from dataclasses import dataclass
28
+ from datetime import datetime, timezone
29
+ from pathlib import Path
30
+ from typing import Any
31
+
32
+
33
+ ROOT = Path(__file__).resolve().parents[2]
34
+ STATE_PATH = ROOT / ".flh/runtime/STATE.md"
35
+ FLOW_PATH = ROOT / ".flh/workflow/flow.yml"
36
+ DOCS_SPEC_PATH = ROOT / ".flh/workflow/docs-spec.yml"
37
+ TRANSITION_GUARDS_PATH = ROOT / ".flh/workflow/transition-guards.yml"
38
+ REQUEST_PATTERNS_PATH = ROOT / ".flh/workflow/request-patterns.yml"
39
+
40
+ ALLOW_EXIT_CODE = 0
41
+ ERROR_EXIT_CODE = 1
42
+ QUESTION_PREFIXES = ("/q",)
43
+ DOCUMENTATION_PREFIXES = ("/d",)
44
+
45
+
46
+ @dataclass
47
+ class HookResult:
48
+ action: str
49
+ request_type: str
50
+ confidence: str
51
+ current_state: str | None
52
+ target_state: str | None = None
53
+ updated_state: str | None = None
54
+ reason: str | None = None
55
+ additional_prompt: str | None = None
56
+ missing: list[str] | None = None
57
+
58
+ def to_json(self) -> str:
59
+ return json.dumps(
60
+ {
61
+ "action": self.action,
62
+ "request_type": self.request_type,
63
+ "confidence": self.confidence,
64
+ "current_state": self.current_state,
65
+ "target_state": self.target_state,
66
+ "updated_state": self.updated_state,
67
+ "reason": self.reason,
68
+ "additional_prompt": self.additional_prompt,
69
+ "missing": self.missing or [],
70
+ },
71
+ ensure_ascii=False,
72
+ indent=2,
73
+ )
74
+
75
+
76
+ @dataclass
77
+ class RequestClassification:
78
+ request_type: str
79
+ confidence: str
80
+ matched_kinds: list[str]
81
+ matched_request_types: list[str]
82
+
83
+
84
+ # Fallback only. The editable source of truth is .flh/workflow/request-patterns.yml.
85
+ REQUEST_PATTERNS: list[tuple[str, list[str]]] = [
86
+ (
87
+ "PRISMA_BASELINE_CREATE_REQUEST",
88
+ [
89
+ r"prisma.*baseline",
90
+ r"baseline.*prisma",
91
+ r"schema\.prisma",
92
+ r"프리즈마.*베이스라인",
93
+ r"프리즈마.*생성",
94
+ r"prisma.*생성",
95
+ ],
96
+ ),
97
+ (
98
+ "IMPLEMENTATION_REQUEST",
99
+ [
100
+ r"구현해",
101
+ r"코드.*작성",
102
+ r"컴포넌트.*만들",
103
+ r"api.*구현",
104
+ r"파일.*수정",
105
+ r"앱.*수정",
106
+ r"작업.*진행",
107
+ ],
108
+ ),
109
+ ("TEST_REQUEST", [r"테스트.*작성", r"테스트.*실행", r"e2e", r"unit test", r"유닛"]),
110
+ ("COMMIT_REQUEST", [r"커밋", r"commit", r"push", r"푸쉬", r"merge", r"머지"]),
111
+ (
112
+ "FEATURE_PREPARE_REQUEST",
113
+ [
114
+ r"기능.*준비",
115
+ r"feature.*prepare",
116
+ r"backlog",
117
+ r"기능.*디렉토리.*생성",
118
+ ],
119
+ ),
120
+ (
121
+ "FEATURE_DESIGN_REQUEST",
122
+ [
123
+ r"기능.*설계",
124
+ r"SPEC\.md",
125
+ r"CHECKLIST\.md",
126
+ r"TEST_CASES\.md",
127
+ r"기능.*스펙",
128
+ ],
129
+ ),
130
+ (
131
+ "MVP_DESIGN_REQUEST",
132
+ [r"\bmvp\b", r"MVP", r"엠브이피", r"최소.*제품", r"최소.*범위"],
133
+ ),
134
+ (
135
+ "ARCHITECTURE_DESIGN_REQUEST",
136
+ [r"아키텍처", r"아키텍쳐", r"architecture", r"시스템.*구조"],
137
+ ),
138
+ (
139
+ "FEATURE_INDEX_REQUEST",
140
+ [r"feature-index", r"기능.*목록", r"기능.*리스트", r"Feature Index"],
141
+ ),
142
+ (
143
+ "DATA_MODEL_DESIGN_REQUEST",
144
+ [r"데이터.*모델", r"DB_SCHEMA", r"ERD", r"E-R", r"엔티티", r"schema"],
145
+ ),
146
+ ("API_DESIGN_REQUEST", [r"\bAPI\b", r"api", r"엔드포인트", r"endpoint"]),
147
+ (
148
+ "FRONTEND_DESIGN_REQUEST",
149
+ [r"프론트", r"frontend", r"FRONTEND", r"디자인.*지침", r"UI.*지침"],
150
+ ),
151
+ (
152
+ "STATE_STATUS_REQUEST",
153
+ [
154
+ r"현재.*상태",
155
+ r"진행.*상황",
156
+ r"다음.*할",
157
+ r"남은.*작업",
158
+ r"요약",
159
+ r"상태.*알려",
160
+ ],
161
+ ),
162
+ ("STATE_TRANSITION_REQUEST", [r"다음.*단계", r"전이", r"상태.*변경"]),
163
+ ]
164
+
165
+
166
+ REQUEST_ALIASES: list[tuple[str, list[str]]] = [
167
+ (
168
+ "MVP_DESIGN_REQUEST",
169
+ [
170
+ r"초기.*범위.*정리",
171
+ r"1차.*출시.*범위",
172
+ r"핵심.*범위",
173
+ r"어디까지.*만들",
174
+ r"가장.*먼저.*만들",
175
+ r"필수.*기능.*추",
176
+ r"첫.*버전.*범위",
177
+ r"프로젝트.*목표.*정리",
178
+ r"타겟.*사용자.*정리",
179
+ r"해결.*문제.*정리",
180
+ ],
181
+ ),
182
+ (
183
+ "ARCHITECTURE_DESIGN_REQUEST",
184
+ [
185
+ r"전체.*구조",
186
+ r"기술.*구조",
187
+ r"프로젝트.*구조",
188
+ r"폴더.*구조",
189
+ r"모듈.*구조",
190
+ r"레이어.*구조",
191
+ r"서비스.*구성",
192
+ r"백엔드.*프론트.*연결",
193
+ ],
194
+ ),
195
+ (
196
+ "FEATURE_INDEX_REQUEST",
197
+ [
198
+ r"필요한.*기능.*정리",
199
+ r"구현할.*기능.*뽑",
200
+ r"기능.*리스트업",
201
+ r"기능.*우선순위",
202
+ r"기능.*인덱스",
203
+ r"MVP.*기능.*나",
204
+ r"기능.*단위.*쪼",
205
+ r"작업할.*기능.*정리",
206
+ ],
207
+ ),
208
+ (
209
+ "DATA_MODEL_DESIGN_REQUEST",
210
+ [
211
+ r"데이터.*구조",
212
+ r"테이블.*구조",
213
+ r"DB.*구조",
214
+ r"ER.*구조",
215
+ r"관계.*정의",
216
+ r"스키마.*초안",
217
+ r"도메인.*모델",
218
+ r"저장.*데이터",
219
+ r"Prisma.*모델.*방향",
220
+ ],
221
+ ),
222
+ (
223
+ "PRISMA_BASELINE_CREATE_REQUEST",
224
+ [
225
+ r"Prisma.*baseline.*만들",
226
+ r"schema\.prisma.*생성",
227
+ r"Prisma.*초기.*스키마",
228
+ r"Prisma.*모델.*생성",
229
+ r"DB.*baseline.*생성",
230
+ r"초기.*migration",
231
+ r"baseline.*migration",
232
+ r"Prisma.*migration.*준비",
233
+ ],
234
+ ),
235
+ (
236
+ "API_DESIGN_REQUEST",
237
+ [
238
+ r"API.*목록",
239
+ r"엔드포인트.*목록",
240
+ r"API.*스펙",
241
+ r"요청.*응답.*구조",
242
+ r"라우트.*구조",
243
+ r"서버.*API.*설계",
244
+ r"백엔드.*API.*설계",
245
+ r"API.*계약",
246
+ ],
247
+ ),
248
+ (
249
+ "FRONTEND_DESIGN_REQUEST",
250
+ [
251
+ r"화면.*설계",
252
+ r"UI.*방향",
253
+ r"프론트.*구조",
254
+ r"화면.*흐름",
255
+ r"페이지.*구성",
256
+ r"사용자.*플로우",
257
+ r"디자인.*가이드",
258
+ r"컴포넌트.*방향",
259
+ r"레이아웃.*방향",
260
+ ],
261
+ ),
262
+ (
263
+ "FEATURE_PREPARE_REQUEST",
264
+ [
265
+ r"이.*기능.*준비",
266
+ r"기능.*작업.*준비",
267
+ r"기능.*문서.*공간",
268
+ r"feature.*workspace",
269
+ r"작업.*대상.*올",
270
+ r"기능.*ready",
271
+ r"feature-index.*추가.*준비",
272
+ ],
273
+ ),
274
+ (
275
+ "FEATURE_DESIGN_REQUEST",
276
+ [
277
+ r"이.*기능.*설계",
278
+ r"기능.*스펙.*작성",
279
+ r"SPEC.*작성",
280
+ r"체크리스트.*만들",
281
+ r"테스트.*케이스.*정리",
282
+ r"구현.*전.*기능.*문서",
283
+ r"기능.*요구사항.*정리",
284
+ r"기능.*범위.*정리",
285
+ r"기능.*완료.*기준",
286
+ ],
287
+ ),
288
+ (
289
+ "IMPLEMENTATION_REQUEST",
290
+ [
291
+ r"이.*기능.*구현",
292
+ r"개발.*시작",
293
+ r"코드.*반영",
294
+ r"실제.*코드.*작성",
295
+ r"앱.*반영",
296
+ r"기능.*붙",
297
+ r"화면.*만들",
298
+ r"API.*만들",
299
+ r"컴포넌트.*만들",
300
+ r"로직.*작성",
301
+ r"수정.*반영",
302
+ ],
303
+ ),
304
+ (
305
+ "TEST_REQUEST",
306
+ [
307
+ r"테스트.*만들",
308
+ r"테스트.*추가",
309
+ r"검증해",
310
+ r"테스트.*돌",
311
+ r"E2E.*만들",
312
+ r"유닛.*테스트.*만들",
313
+ r"통합.*테스트.*작성",
314
+ r"테스트.*케이스.*코드",
315
+ ],
316
+ ),
317
+ (
318
+ "COMMIT_REQUEST",
319
+ [
320
+ r"PR.*만들",
321
+ r"브랜치.*정리",
322
+ r"작업.*마무리",
323
+ r"후처리.*진행",
324
+ r"변경사항.*올",
325
+ ],
326
+ ),
327
+ (
328
+ "STATE_STATUS_REQUEST",
329
+ [
330
+ r"어디까지.*됐",
331
+ r"현재.*단계",
332
+ r"뭐.*해야",
333
+ r"뭐부터.*하면",
334
+ r"다음.*작업.*추천",
335
+ ],
336
+ ),
337
+ (
338
+ "STATE_TRANSITION_REQUEST",
339
+ [
340
+ r"다음.*단계.*넘어",
341
+ r"단계.*변경",
342
+ r"단계.*완료.*처리",
343
+ r"workflow.*이동",
344
+ r"이.*단계.*끝",
345
+ r"다음.*상태.*바",
346
+ ],
347
+ ),
348
+ ]
349
+
350
+ QUESTION_OR_CONFIRMATION_PATTERNS: list[str] = [
351
+ r"\?",
352
+ r"맞아\??$",
353
+ r"맞나\??$",
354
+ r"되나\??$",
355
+ r"되는거지\??$",
356
+ r"하면 되는거지\??$",
357
+ r"해야 하나\??$",
358
+ r"해도 돼\??$",
359
+ r"괜찮아\??$",
360
+ r"어떻게 생각",
361
+ r"어때",
362
+ r"맞을까",
363
+ r"좋을까",
364
+ r"수정하면 되는거지\??$",
365
+ r"바꾸면 되는거지\??$",
366
+ r"진행하면 되는거지\??$",
367
+ ]
368
+
369
+
370
+ def read_prompt() -> str:
371
+ if len(sys.argv) > 1:
372
+ return " ".join(sys.argv[1:]).strip()
373
+
374
+ raw = sys.stdin.read().strip()
375
+ if raw:
376
+ try:
377
+ payload = json.loads(raw)
378
+ except json.JSONDecodeError:
379
+ return raw
380
+
381
+ if isinstance(payload, dict):
382
+ for key in ("prompt", "user_prompt", "message", "input"):
383
+ value = payload.get(key)
384
+ if isinstance(value, str) and value.strip():
385
+ return value.strip()
386
+ if isinstance(payload, str):
387
+ return payload.strip()
388
+
389
+ return os.environ.get("USER_PROMPT", "").strip()
390
+
391
+
392
+ def parse_scalar(value: str) -> Any:
393
+ value = value.strip()
394
+ if value in {"", "null", "None", "~"}:
395
+ return None
396
+ if value == "[]":
397
+ return []
398
+ if value == "{}":
399
+ return {}
400
+ if value in {"true", "True"}:
401
+ return True
402
+ if value in {"false", "False"}:
403
+ return False
404
+ if (value.startswith('"') and value.endswith('"')) or (
405
+ value.startswith("'") and value.endswith("'")
406
+ ):
407
+ return value[1:-1]
408
+ return value
409
+
410
+ # YAML 파서 구현
411
+ def parse_yaml_subset(text: str) -> dict[str, Any]:
412
+ """Parse the small YAML subset used by this harness.
413
+
414
+ Supported:
415
+ - nested mappings by indentation
416
+ - block lists using "- value"
417
+ - inline empty list/dict
418
+ - scalar strings/bools/null
419
+ """
420
+
421
+ root: dict[str, Any] = {}
422
+ stack: list[tuple[int, Any]] = [(-1, root)]
423
+
424
+ lines = text.splitlines()
425
+ for index, raw_line in enumerate(lines):
426
+ if not raw_line.strip() or raw_line.lstrip().startswith("#"):
427
+ continue
428
+
429
+ indent = len(raw_line) - len(raw_line.lstrip(" "))
430
+ stripped = raw_line.strip()
431
+
432
+ while stack and indent <= stack[-1][0]:
433
+ stack.pop()
434
+ parent = stack[-1][1]
435
+
436
+ if stripped.startswith("- "):
437
+ if not isinstance(parent, list):
438
+ raise ValueError(f"Invalid list item at line {index + 1}: {raw_line}")
439
+ parent.append(parse_scalar(stripped[2:]))
440
+ continue
441
+
442
+ if ":" not in stripped:
443
+ raise ValueError(f"Invalid YAML line {index + 1}: {raw_line}")
444
+
445
+ key, value = stripped.split(":", 1)
446
+ key = key.strip()
447
+ value = value.strip()
448
+
449
+ if value:
450
+ if not isinstance(parent, dict):
451
+ raise ValueError(f"Invalid mapping at line {index + 1}: {raw_line}")
452
+ parent[key] = parse_scalar(value)
453
+ continue
454
+
455
+ next_value: Any = {}
456
+ for lookahead in lines[index + 1 :]:
457
+ if not lookahead.strip() or lookahead.lstrip().startswith("#"):
458
+ continue
459
+ next_stripped = lookahead.strip()
460
+ next_value = [] if next_stripped.startswith("- ") else {}
461
+ break
462
+
463
+ if not isinstance(parent, dict):
464
+ raise ValueError(f"Invalid nested mapping at line {index + 1}: {raw_line}")
465
+ parent[key] = next_value
466
+ stack.append((indent, next_value))
467
+
468
+ return root
469
+
470
+
471
+ def load_yaml(path: Path) -> dict[str, Any]:
472
+ if not path.exists():
473
+ raise FileNotFoundError(f"Required workflow file is missing: {path}")
474
+ return parse_yaml_subset(path.read_text(encoding="utf-8"))
475
+
476
+
477
+ def load_request_patterns() -> tuple[list[tuple[str, list[str]]], list[tuple[str, list[str]]]]:
478
+ if not REQUEST_PATTERNS_PATH.exists():
479
+ return REQUEST_PATTERNS, REQUEST_ALIASES
480
+
481
+ config = load_yaml(REQUEST_PATTERNS_PATH)
482
+ patterns: list[tuple[str, list[str]]] = []
483
+ aliases: list[tuple[str, list[str]]] = []
484
+
485
+ for request_type, request_config in (config.get("patterns") or {}).items():
486
+ if not isinstance(request_config, dict):
487
+ continue
488
+ strong_patterns = request_config.get("strong") or []
489
+ alias_patterns = request_config.get("aliases") or []
490
+ patterns.append((request_type, list(strong_patterns)))
491
+ aliases.append((request_type, list(alias_patterns)))
492
+
493
+ return patterns, aliases
494
+
495
+
496
+ def load_question_or_confirmation_patterns() -> list[str]:
497
+ if not REQUEST_PATTERNS_PATH.exists():
498
+ return QUESTION_OR_CONFIRMATION_PATTERNS
499
+
500
+ config = load_yaml(REQUEST_PATTERNS_PATH)
501
+ patterns = config.get("question_or_confirmation_patterns") or []
502
+ return list(patterns) if patterns else QUESTION_OR_CONFIRMATION_PATTERNS
503
+
504
+
505
+ # markdown frontmatter 파싱
506
+ def parse_frontmatter(text: str) -> tuple[dict[str, Any], str]:
507
+ if not text.startswith("---"):
508
+ return {}, text
509
+
510
+ parts = text.split("---", 2)
511
+ if len(parts) < 3:
512
+ return {}, text
513
+
514
+ frontmatter = parse_yaml_subset(parts[1])
515
+ body = parts[2].lstrip()
516
+ return frontmatter, body
517
+
518
+
519
+ def load_state() -> dict[str, Any]:
520
+ if not STATE_PATH.exists():
521
+ raise FileNotFoundError(f"STATE file is missing: {STATE_PATH}")
522
+ frontmatter, _ = parse_frontmatter(STATE_PATH.read_text(encoding="utf-8"))
523
+ return frontmatter
524
+
525
+
526
+ def format_yaml_scalar(value: Any) -> str:
527
+ if value is None:
528
+ return "null"
529
+ if value is True:
530
+ return "true"
531
+ if value is False:
532
+ return "false"
533
+ if isinstance(value, str):
534
+ if value == "":
535
+ return '""'
536
+ if re.search(r"[:#\[\]{}]|^\s|\s$", value):
537
+ return json.dumps(value, ensure_ascii=False)
538
+ return value
539
+ return str(value)
540
+
541
+
542
+ def append_yaml_value(lines: list[str], key: str, value: Any, indent: int = 0) -> None:
543
+ prefix = " " * indent
544
+ if isinstance(value, dict):
545
+ if not value:
546
+ lines.append(f"{prefix}{key}: {{}}")
547
+ return
548
+ lines.append(f"{prefix}{key}:")
549
+ for child_key, child_value in value.items():
550
+ append_yaml_value(lines, str(child_key), child_value, indent + 2)
551
+ return
552
+
553
+ if isinstance(value, list):
554
+ if not value:
555
+ lines.append(f"{prefix}{key}: []")
556
+ return
557
+ lines.append(f"{prefix}{key}:")
558
+ for item in value:
559
+ if isinstance(item, dict):
560
+ lines.append(f"{prefix} -")
561
+ for child_key, child_value in item.items():
562
+ append_yaml_value(lines, str(child_key), child_value, indent + 4)
563
+ else:
564
+ lines.append(f"{prefix} - {format_yaml_scalar(item)}")
565
+ return
566
+
567
+ lines.append(f"{prefix}{key}: {format_yaml_scalar(value)}")
568
+
569
+
570
+ def default_state_body() -> str:
571
+ return "\n".join(
572
+ [
573
+ "# STATE",
574
+ "",
575
+ "이 파일은 하네스가 적용될 실제 프로젝트의 runtime workflow state를 저장한다.",
576
+ "기계가 읽는 데이터는 위 YAML frontmatter뿐이며, 이 Markdown 본문은 Codex와 유지보수자를 위한 작성 가이드다.",
577
+ "",
578
+ "## Frontmatter Fields",
579
+ "",
580
+ "- `current_state`: 현재 프로젝트 workflow 상태.",
581
+ "- `completed_states`: 완료되었거나 상태 전이 과정에서 통과 처리된 workflow 상태 목록.",
582
+ "- `approvals`: 이후 hook 또는 agent gate에서 재사용할 승인/검증 기록.",
583
+ "- `last_transition`: 마지막 상태 전이.",
584
+ "- `updated_at`: 마지막 runtime state 갱신 시각.",
585
+ "",
586
+ "## Approval Recording Policy",
587
+ "",
588
+ "`approvals`는 모든 상태 전이마다 기록하는 로그가 아니다.",
589
+ "문서 완료만으로 판단하기 어렵고, 나중에 hook 또는 agent가 gate 조건으로 다시 확인해야 하는 승인/검증 결과만 기록한다.",
590
+ "",
591
+ "Codex는 사용자 승인 또는 실제 검증 없이 approval을 임의로 추가하지 않는다.",
592
+ "비밀값, API key, token, password, DB connection string은 절대 `STATE.md`에 기록하지 않는다.",
593
+ "",
594
+ "### `approvals.design`",
595
+ "",
596
+ "외부 `docs/DESIGN.md`를 프로젝트 디자인 가이드로 사용하기로 사용자가 승인한 경우 기록한다.",
597
+ "",
598
+ "```yaml",
599
+ "approvals:",
600
+ " design:",
601
+ " source: external",
602
+ " path: docs/DESIGN.md",
603
+ " approved: true",
604
+ "```",
605
+ "",
606
+ "### `approvals.source_scaffold`",
607
+ "",
608
+ "첫 기능 구현 전에 source package scaffold baseline이 생성되고 커밋된 경우 기록한다.",
609
+ "",
610
+ "```yaml",
611
+ "approvals:",
612
+ " source_scaffold:",
613
+ " created: true",
614
+ " based_on: docs/source-layout.yml",
615
+ " package_manager: npm",
616
+ " created_at: 2026-06-07T00:00:00Z",
617
+ "```",
618
+ "",
619
+ "### `approvals.database_baseline`",
620
+ "",
621
+ "첫 기능을 `docs/features/active/`로 이동하기 전에 DB baseline이 처리되었음을 기록한다.",
622
+ "DB가 필요 없는 프로젝트는 skip approval을 기록하고, DB-backed 프로젝트는 Prisma baseline 배포와 검증 결과를 기록한다.",
623
+ "",
624
+ "DB 미사용 프로젝트:",
625
+ "",
626
+ "```yaml",
627
+ "approvals:",
628
+ " database_baseline:",
629
+ " required: false",
630
+ " skipped: true",
631
+ " reason: database_not_required",
632
+ " based_on: docs/source-layout.yml",
633
+ " decided_at: 2026-06-09T00:00:00Z",
634
+ "```",
635
+ "",
636
+ "Prisma DB-backed 프로젝트:",
637
+ "",
638
+ "```yaml",
639
+ "approvals:",
640
+ " database_baseline:",
641
+ " required: true",
642
+ " migration_tool: prisma",
643
+ " database: postgresql",
644
+ " environment: development",
645
+ " deployed: true",
646
+ " verified: true",
647
+ " verified_at: 2026-05-26T00:00:00Z",
648
+ "```",
649
+ "",
650
+ ]
651
+ )
652
+
653
+
654
+ def write_state(state: dict[str, Any]) -> None:
655
+ lines = ["---"]
656
+ for key in ("current_state", "completed_states", "approvals", "last_transition", "updated_at"):
657
+ append_yaml_value(lines, key, state.get(key))
658
+ body = default_state_body()
659
+ if STATE_PATH.exists():
660
+ _, existing_body = parse_frontmatter(STATE_PATH.read_text(encoding="utf-8"))
661
+ if existing_body.strip():
662
+ body = existing_body.rstrip() + "\n"
663
+ lines.extend(["---", "", body])
664
+ STATE_PATH.write_text("\n".join(lines), encoding="utf-8")
665
+
666
+
667
+ def request_type_priority() -> dict[str, int]:
668
+ patterns, _ = load_request_patterns()
669
+ return {request_type: index for index, (request_type, _) in enumerate(patterns)}
670
+
671
+
672
+ def collect_request_matches(prompt: str) -> dict[str, set[str]]:
673
+ normalized = prompt.strip()
674
+ matches: dict[str, set[str]] = {}
675
+ patterns, aliases = load_request_patterns()
676
+
677
+ for request_type, strong_patterns in patterns:
678
+ for pattern in strong_patterns:
679
+ if re.search(pattern, normalized, re.IGNORECASE):
680
+ matches.setdefault(request_type, set()).add("strong")
681
+
682
+ for request_type, alias_patterns in aliases:
683
+ for pattern in alias_patterns:
684
+ if re.search(pattern, normalized, re.IGNORECASE):
685
+ matches.setdefault(request_type, set()).add("alias")
686
+
687
+ return matches
688
+
689
+
690
+ def is_question_or_confirmation(prompt: str) -> bool:
691
+ normalized = prompt.strip()
692
+ return any(
693
+ re.search(pattern, normalized, re.IGNORECASE)
694
+ for pattern in load_question_or_confirmation_patterns()
695
+ )
696
+
697
+
698
+ def starts_with_prefix(prompt: str, prefixes: tuple[str, ...]) -> bool:
699
+ normalized = prompt.strip()
700
+ return any(
701
+ normalized == prefix or normalized.startswith(f"{prefix} ")
702
+ for prefix in prefixes
703
+ )
704
+
705
+
706
+ def is_merge_command(prompt: str) -> bool:
707
+ normalized = prompt.strip()
708
+ return bool(
709
+ re.search(r"\bmerge\s*(해줘|하자|진행|처리|해|하셈)", normalized, re.IGNORECASE)
710
+ or re.search(r"머지\s*(해줘|하자|진행|처리|해|하셈)", normalized, re.IGNORECASE)
711
+ )
712
+
713
+
714
+ def classify_prompt(prompt: str) -> RequestClassification:
715
+ if starts_with_prefix(prompt, QUESTION_PREFIXES):
716
+ return RequestClassification(
717
+ request_type="QUESTION_OR_CONFIRMATION_REQUEST",
718
+ confidence="high",
719
+ matched_kinds=["prefix"],
720
+ matched_request_types=["QUESTION_OR_CONFIRMATION_REQUEST"],
721
+ )
722
+
723
+ if starts_with_prefix(prompt, DOCUMENTATION_PREFIXES):
724
+ return RequestClassification(
725
+ request_type="DOCUMENTATION_REQUEST",
726
+ confidence="high",
727
+ matched_kinds=["prefix"],
728
+ matched_request_types=["DOCUMENTATION_REQUEST"],
729
+ )
730
+
731
+ is_question = is_question_or_confirmation(prompt)
732
+ matches = collect_request_matches(prompt)
733
+
734
+ if is_question and matches:
735
+ priority = request_type_priority()
736
+ matched_request_types = sorted(matches, key=lambda item: priority.get(item, 999))
737
+ selected = matched_request_types[0]
738
+ return RequestClassification(
739
+ request_type=selected,
740
+ confidence="low",
741
+ matched_kinds=["question_or_confirmation", *sorted(matches[selected])],
742
+ matched_request_types=["QUESTION_OR_CONFIRMATION_REQUEST", *matched_request_types],
743
+ )
744
+
745
+ if is_question:
746
+ return RequestClassification(
747
+ request_type="QUESTION_OR_CONFIRMATION_REQUEST",
748
+ confidence="high",
749
+ matched_kinds=["question_or_confirmation"],
750
+ matched_request_types=["QUESTION_OR_CONFIRMATION_REQUEST"],
751
+ )
752
+
753
+ if not matches:
754
+ return RequestClassification(
755
+ request_type="UNKNOWN",
756
+ confidence="unknown",
757
+ matched_kinds=[],
758
+ matched_request_types=[],
759
+ )
760
+
761
+ priority = request_type_priority()
762
+ matched_request_types = sorted(matches, key=lambda item: priority.get(item, 999))
763
+ selected = matched_request_types[0]
764
+
765
+ if len(matched_request_types) > 1:
766
+ confidence = "low"
767
+ elif "strong" in matches[selected]:
768
+ confidence = "high"
769
+ else:
770
+ confidence = "medium"
771
+
772
+ return RequestClassification(
773
+ request_type=selected,
774
+ confidence=confidence,
775
+ matched_kinds=sorted(matches[selected]),
776
+ matched_request_types=matched_request_types,
777
+ )
778
+
779
+
780
+ def classify_request_type(prompt: str) -> str:
781
+ return classify_prompt(prompt).request_type
782
+
783
+
784
+ def is_allowed(flow: dict[str, Any], state: str, request_type: str) -> bool:
785
+ state_config = flow.get("states", {}).get(state, {})
786
+ return request_type in state_config.get("allowed_request_types", [])
787
+
788
+
789
+ def find_target_state(flow: dict[str, Any], request_type: str) -> str | None:
790
+ for state, config in flow.get("states", {}).items():
791
+ if request_type in config.get("allowed_request_types", []):
792
+ return state
793
+ return None
794
+
795
+
796
+ def find_state_path(flow: dict[str, Any], start: str, target: str) -> list[str] | None:
797
+ queue: deque[list[str]] = deque([[start]])
798
+ visited = {start}
799
+ while queue:
800
+ path = queue.popleft()
801
+ current = path[-1]
802
+ if current == target:
803
+ return path
804
+ next_states = flow.get("states", {}).get(current, {}).get("next_states", [])
805
+ for next_state in next_states:
806
+ if next_state not in visited:
807
+ visited.add(next_state)
808
+ queue.append(path + [next_state])
809
+ return None
810
+
811
+
812
+ def transition_key(from_state: str, to_state: str) -> str:
813
+ return f"{from_state}_TO_{to_state}"
814
+
815
+
816
+ def section_body(text: str, section: str) -> str:
817
+ pattern = re.compile(
818
+ rf"^##\s+{re.escape(section)}\s*$([\s\S]*?)(?=^##\s+|\Z)",
819
+ re.MULTILINE,
820
+ )
821
+ match = pattern.search(text)
822
+ return match.group(1).strip() if match else ""
823
+
824
+
825
+ def markdown_table_headers(text: str) -> list[str]:
826
+ lines = [line.strip() for line in text.splitlines() if line.strip()]
827
+ for index, line in enumerate(lines[:-1]):
828
+ next_line = lines[index + 1]
829
+ if not (line.startswith("|") and line.endswith("|")):
830
+ continue
831
+ if not (next_line.startswith("|") and next_line.endswith("|")):
832
+ continue
833
+ separator_cells = [cell.strip() for cell in next_line.strip("|").split("|")]
834
+ if separator_cells and all(re.fullmatch(r":?-{3,}:?", cell) for cell in separator_cells):
835
+ return [cell.strip() for cell in line.strip("|").split("|")]
836
+ return []
837
+
838
+
839
+ def value_is_present(value: Any) -> bool:
840
+ if value is None:
841
+ return False
842
+ if isinstance(value, str):
843
+ return bool(value.strip())
844
+ if isinstance(value, (list, dict)):
845
+ return bool(value)
846
+ return True
847
+
848
+
849
+ def nested_yaml_value(data: dict[str, Any], dotted_path: str) -> Any:
850
+ current: Any = data
851
+ for part in dotted_path.split("."):
852
+ if not isinstance(current, dict) or part not in current:
853
+ return None
854
+ current = current[part]
855
+ return current
856
+
857
+
858
+ def yaml_doc_is_complete(
859
+ candidate: str,
860
+ text: str,
861
+ doc_config: dict[str, Any],
862
+ defaults: dict[str, Any],
863
+ ) -> tuple[bool, list[str]]:
864
+ failures: list[str] = []
865
+
866
+ try:
867
+ data = parse_yaml_subset(text)
868
+ except ValueError as error:
869
+ return False, [f"{candidate}: invalid YAML: {error}"]
870
+
871
+ required_status = doc_config.get("required_status", defaults.get("required_status"))
872
+ if required_status and data.get("status") != required_status:
873
+ failures.append(f"{candidate}: status is not {required_status}")
874
+
875
+ forbidden_tokens = doc_config.get("forbidden_tokens", defaults.get("forbidden_tokens", []))
876
+ found_token = next((token for token in forbidden_tokens if token in text), None)
877
+ if found_token:
878
+ failures.append(f"{candidate}: forbidden token remains: {found_token}")
879
+
880
+ missing_fields = [
881
+ field
882
+ for field in doc_config.get("required_yaml_fields", []) or []
883
+ if not value_is_present(nested_yaml_value(data, field))
884
+ ]
885
+ if missing_fields:
886
+ failures.append(f"{candidate}: missing YAML fields: {', '.join(missing_fields)}")
887
+
888
+ mapping_key = doc_config.get("required_yaml_mapping")
889
+ if mapping_key:
890
+ mapping = nested_yaml_value(data, str(mapping_key))
891
+ if not isinstance(mapping, dict) or not mapping:
892
+ failures.append(f"{candidate}: {mapping_key} is missing or empty")
893
+ else:
894
+ item_fields = doc_config.get("required_yaml_mapping_item_fields", []) or []
895
+ for item_key, item in mapping.items():
896
+ if not isinstance(item, dict):
897
+ failures.append(f"{candidate}: {mapping_key}.{item_key} must be a mapping")
898
+ continue
899
+ missing_item_fields = [
900
+ field
901
+ for field in item_fields
902
+ if not value_is_present(item.get(field))
903
+ ]
904
+ if missing_item_fields:
905
+ failures.append(
906
+ f"{candidate}: {mapping_key}.{item_key} missing fields: "
907
+ + ", ".join(missing_item_fields)
908
+ )
909
+
910
+ return not failures, failures
911
+
912
+
913
+ def doc_is_complete(doc_key: str, docs_spec: dict[str, Any]) -> tuple[bool, list[str]]:
914
+ defaults = docs_spec.get("defaults", {})
915
+ doc_config = docs_spec.get("documents", {}).get(doc_key)
916
+ if not doc_config:
917
+ return False, [f"Unknown docs-spec document key: {doc_key}"]
918
+
919
+ candidates = [doc_config.get("path")] + list(doc_config.get("alternative_paths", []) or [])
920
+ failures: list[str] = []
921
+
922
+ for candidate in candidates:
923
+ if not candidate:
924
+ continue
925
+ path = ROOT / candidate
926
+ if not path.exists():
927
+ failures.append(f"Missing document: {candidate}")
928
+ continue
929
+
930
+ text = path.read_text(encoding="utf-8")
931
+ if doc_config.get("format") == "yaml":
932
+ ok, yaml_failures = yaml_doc_is_complete(candidate, text, doc_config, defaults)
933
+ if ok:
934
+ return True, []
935
+ failures.extend(yaml_failures)
936
+ continue
937
+
938
+ frontmatter, body = parse_frontmatter(text)
939
+ required_status = doc_config.get("required_status", defaults.get("required_status"))
940
+ if required_status and frontmatter.get("status") != required_status:
941
+ failures.append(f"{candidate}: status is not {required_status}")
942
+ continue
943
+
944
+ forbidden_tokens = doc_config.get("forbidden_tokens", defaults.get("forbidden_tokens", []))
945
+ found_token = next((token for token in forbidden_tokens if token in body), None)
946
+ if found_token:
947
+ failures.append(f"{candidate}: forbidden token remains: {found_token}")
948
+ continue
949
+
950
+ missing_sections = [
951
+ section
952
+ for section in doc_config.get("required_sections", [])
953
+ if not re.search(rf"^##\s+{re.escape(section)}\s*$", body, re.MULTILINE)
954
+ ]
955
+ if missing_sections:
956
+ failures.append(f"{candidate}: missing sections: {', '.join(missing_sections)}")
957
+ continue
958
+
959
+ min_chars = int(doc_config.get("min_section_chars", defaults.get("min_section_chars", 0)) or 0)
960
+ short_sections = [
961
+ section
962
+ for section in doc_config.get("required_sections", [])
963
+ if len(section_body(body, section)) < min_chars
964
+ ]
965
+ if short_sections:
966
+ failures.append(f"{candidate}: sections too short: {', '.join(short_sections)}")
967
+ continue
968
+
969
+ required_item_fields = doc_config.get("required_item_fields", []) or []
970
+ if required_item_fields:
971
+ table_text = section_body(body, "Feature List")
972
+ headers = markdown_table_headers(table_text)
973
+ missing_fields = [field for field in required_item_fields if field not in headers]
974
+ if missing_fields:
975
+ failures.append(
976
+ f"{candidate}: feature table missing fields: {', '.join(missing_fields)}"
977
+ )
978
+ continue
979
+
980
+ return True, []
981
+
982
+ return False, failures
983
+
984
+
985
+ def source_layout_directory_paths(
986
+ doc_key: str,
987
+ docs_spec: dict[str, Any],
988
+ ) -> tuple[list[str], list[str]]:
989
+ ok, failures = doc_is_complete(doc_key, docs_spec)
990
+ if not ok:
991
+ return [], failures
992
+
993
+ doc_config = docs_spec.get("documents", {}).get(doc_key)
994
+ if not doc_config:
995
+ return [], [f"Unknown docs-spec document key: {doc_key}"]
996
+
997
+ rel_path = doc_config.get("path")
998
+ if not rel_path:
999
+ return [], [f"{doc_key}: docs-spec path is missing"]
1000
+
1001
+ path = ROOT / rel_path
1002
+ if not path.exists():
1003
+ return [], [f"Missing document: {rel_path}"]
1004
+
1005
+ try:
1006
+ data = load_yaml(path)
1007
+ except (FileNotFoundError, ValueError) as error:
1008
+ return [], [f"{rel_path}: {error}"]
1009
+
1010
+ source_roots = data.get("source_roots")
1011
+ if not isinstance(source_roots, dict) or not source_roots:
1012
+ return [], [f"{rel_path}: source_roots is missing or empty"]
1013
+
1014
+ paths: list[str] = []
1015
+ failures: list[str] = []
1016
+ for source_key, source_config in source_roots.items():
1017
+ if not isinstance(source_config, dict):
1018
+ failures.append(f"{rel_path}: source_roots.{source_key} must be a mapping")
1019
+ continue
1020
+
1021
+ source_path = source_config.get("path")
1022
+ if not isinstance(source_path, str) or not source_path.strip():
1023
+ failures.append(f"{rel_path}: source_roots.{source_key}.path is missing")
1024
+ continue
1025
+
1026
+ normalized = Path(source_path)
1027
+ if normalized.is_absolute() or ".." in normalized.parts:
1028
+ failures.append(f"{rel_path}: invalid source path outside repo: {source_path}")
1029
+ continue
1030
+
1031
+ paths.append(source_path.strip())
1032
+
1033
+ return paths, failures
1034
+
1035
+
1036
+ def approval_is_granted(approval_key: str, state_data: dict[str, Any]) -> bool:
1037
+ approvals = state_data.get("approvals") or {}
1038
+ approval = approvals.get(approval_key) if isinstance(approvals, dict) else None
1039
+ if approval is True:
1040
+ return True
1041
+ if isinstance(approval, dict):
1042
+ return approval.get("approved") is True
1043
+ return False
1044
+
1045
+
1046
+ def check_transition_guards(
1047
+ path: list[str],
1048
+ docs_spec: dict[str, Any],
1049
+ guards: dict[str, Any],
1050
+ state_data: dict[str, Any],
1051
+ ) -> tuple[bool, list[str]]:
1052
+ missing: list[str] = []
1053
+ transitions = guards.get("transitions", {})
1054
+
1055
+ for from_state, to_state in zip(path, path[1:]):
1056
+ key = transition_key(from_state, to_state)
1057
+ guard = transitions.get(key)
1058
+ if not guard:
1059
+ missing.append(f"Missing transition guard: {key}")
1060
+ continue
1061
+
1062
+ for doc_key in guard.get("required_docs", []) or []:
1063
+ ok, failures = doc_is_complete(doc_key, docs_spec)
1064
+ if not ok:
1065
+ missing.extend(failures)
1066
+
1067
+ docs_any = guard.get("required_docs_any", []) or []
1068
+ approvals_any = guard.get("required_approvals_any", []) or []
1069
+ if docs_any or approvals_any:
1070
+ any_passed = False
1071
+ any_failures: list[str] = []
1072
+
1073
+ for doc_key in docs_any:
1074
+ ok, failures = doc_is_complete(doc_key, docs_spec)
1075
+ if ok:
1076
+ any_passed = True
1077
+ break
1078
+ any_failures.extend(failures)
1079
+
1080
+ if not any_passed:
1081
+ for approval_key in approvals_any:
1082
+ if approval_is_granted(approval_key, state_data):
1083
+ any_passed = True
1084
+ break
1085
+
1086
+ if not any_passed:
1087
+ expected = list(docs_any) + [f"approval:{key}" for key in approvals_any]
1088
+ missing.append("Missing one of: " + ", ".join(expected))
1089
+ missing.extend(any_failures)
1090
+
1091
+ for rel_path in guard.get("required_files", []) or []:
1092
+ if not (ROOT / rel_path).exists():
1093
+ missing.append(f"Missing required file: {rel_path}")
1094
+
1095
+ for rel_path in guard.get("required_directories", []) or []:
1096
+ if not (ROOT / rel_path).is_dir():
1097
+ missing.append(f"Missing required directory: {rel_path}")
1098
+
1099
+ for doc_key in guard.get("required_source_layout_directories", []) or []:
1100
+ source_paths, failures = source_layout_directory_paths(doc_key, docs_spec)
1101
+ missing.extend(failures)
1102
+ for rel_path in source_paths:
1103
+ if not (ROOT / rel_path).is_dir():
1104
+ missing.append(f"Missing source layout directory: {rel_path}")
1105
+
1106
+ return not missing, missing
1107
+
1108
+
1109
+ def unknown_additional_prompt() -> str:
1110
+ return (
1111
+ "[Harness Guard]\n"
1112
+ "This request was classified as UNKNOWN by user_prompt_submit.py.\n\n"
1113
+ "- If it can be answered without creating, modifying, or deleting files, proceed.\n"
1114
+ "- If it is a design/documentation request, file changes are allowed only in docs/ or .flh/.\n"
1115
+ "- If it requires code, tests, DB migrations, app/src/apps changes, commits, or state transitions, do not proceed.\n"
1116
+ "- Ask the user to clarify the intended workflow request type when the requested action is not clearly documentation-only.\n"
1117
+ "- Do not update .flh/runtime/STATE.md.\n"
1118
+ "- Do not run .flh/docs/FEATURE_IMPLEMENTATION_PIPELINE.md."
1119
+ )
1120
+
1121
+
1122
+ def question_or_confirmation_additional_prompt() -> str:
1123
+ return (
1124
+ "[Harness Guard]\n"
1125
+ "This request was classified as QUESTION_OR_CONFIRMATION_REQUEST.\n\n"
1126
+ "- If the prompt starts with /q, treat /q as a control prefix and not as user content.\n"
1127
+ "- Treat it as a question, confirmation, or discussion request.\n"
1128
+ "- Answer directly without creating, modifying, or deleting files.\n"
1129
+ "- Do not update .flh/runtime/STATE.md.\n"
1130
+ "- Do not start implementation, tests, commits, state transitions, or feature pipeline work."
1131
+ )
1132
+
1133
+
1134
+ def documentation_additional_prompt() -> str:
1135
+ return (
1136
+ "[Harness Guard]\n"
1137
+ "This request used /d documentation mode.\n\n"
1138
+ "- Treat /d as a control prefix and not as user content.\n"
1139
+ "- Perform only documentation or harness-maintenance work.\n"
1140
+ "- Allowed write targets: docs/, .flh/, AGENTS.md, README.md.\n"
1141
+ "- Allowed harness-maintenance targets when directly relevant: .codex/, .flh/hooks/, tests/hooks/, .husky/, package.json, package-lock.json.\n"
1142
+ "- Do not modify app/, apps/, src/, implementation code, tests/e2e/, Prisma migrations, or DB schema/migration files.\n"
1143
+ "- Do not run .flh/docs/FEATURE_IMPLEMENTATION_PIPELINE.md as an implementation workflow.\n"
1144
+ "- Do not create worktrees or branches.\n"
1145
+ "- /d may perform explicit workflow state skip/transition requests.\n"
1146
+ "- Do not skip MVP_DEFINITION, ARCHITECTURE_DESIGN, or FEATURE_INDEX_DEFINITION.\n"
1147
+ "- Commit and push are allowed only when the user explicitly asks and all changed files are within the allowed documentation/harness targets.\n"
1148
+ "- Merge is not allowed in /d mode."
1149
+ )
1150
+
1151
+
1152
+ def low_confidence_additional_prompt(classification: RequestClassification) -> str:
1153
+ return (
1154
+ "[Harness Guard]\n"
1155
+ "This request matched multiple or conflicting workflow signals.\n\n"
1156
+ f"- selected_request_type: {classification.request_type}\n"
1157
+ f"- confidence: {classification.confidence}\n"
1158
+ f"- matched_request_types: {', '.join(classification.matched_request_types)}\n"
1159
+ "- It may combine question/confirmation, design, implementation, test, commit, or transition intent.\n"
1160
+ "- Do not create, modify, or delete files.\n"
1161
+ "- Do not update .flh/runtime/STATE.md.\n"
1162
+ "- Ask the user to clarify the single intended action before proceeding.\n"
1163
+ "- If the prompt mixes a question with an execution command, ask whether the user wants explanation only or wants Codex to perform the action."
1164
+ )
1165
+
1166
+
1167
+ def medium_confidence_additional_prompt(classification: RequestClassification) -> str:
1168
+ return (
1169
+ "[Harness Guard]\n"
1170
+ "This request was classified by an alias pattern.\n\n"
1171
+ f"- request_type: {classification.request_type}\n"
1172
+ f"- confidence: {classification.confidence}\n"
1173
+ "- Proceed under this workflow interpretation unless the user corrects it."
1174
+ )
1175
+
1176
+
1177
+ def design_selection_additional_prompt(prefix: str | None = None) -> str:
1178
+ lines = ["[Harness Guard]"]
1179
+ if prefix:
1180
+ lines.extend(["", prefix])
1181
+ lines.extend(
1182
+ [
1183
+ "",
1184
+ "FRONTEND_DESIGN uses docs/DESIGN.md as the design guideline artifact.",
1185
+ "",
1186
+ "Before proceeding, ask the user to choose one:",
1187
+ "1. Import an existing external DESIGN.md into docs/DESIGN.md.",
1188
+ "2. Create docs/DESIGN.md together in this workflow.",
1189
+ "",
1190
+ "If the user imports an external DESIGN.md, record approval in .flh/runtime/STATE.md:",
1191
+ "approvals.design.approved: true",
1192
+ "",
1193
+ "Do not treat docs/FRONTEND.md as the workflow artifact.",
1194
+ ]
1195
+ )
1196
+ return "\n".join(lines)
1197
+
1198
+
1199
+ def block_reason(result: HookResult) -> str:
1200
+ reason = result.reason or "Harness blocked this request."
1201
+ lines = [
1202
+ "[Harness Guard: BLOCKED]",
1203
+ "",
1204
+ "Codex did not start the requested work because the project workflow guard blocked this prompt.",
1205
+ "",
1206
+ "Reason:",
1207
+ f"- {reason}",
1208
+ "",
1209
+ "Request:",
1210
+ f"- request_type: {result.request_type}",
1211
+ f"- confidence: {result.confidence}",
1212
+ f"- current_state: {result.current_state}",
1213
+ ]
1214
+ if result.target_state:
1215
+ lines.append(f"- target_state: {result.target_state}")
1216
+ if result.missing:
1217
+ lines.extend(["", "Missing requirements:"])
1218
+ lines.extend(f"- {item}" for item in result.missing)
1219
+
1220
+ lines.extend(
1221
+ [
1222
+ "",
1223
+ "Next action:",
1224
+ "- Finish the current workflow step first, or ask for current status/next work.",
1225
+ ]
1226
+ )
1227
+ return "\n".join(lines)
1228
+
1229
+
1230
+ def format_codex_output(result: HookResult) -> dict[str, Any]:
1231
+ """Return the JSON shape Codex expects from a UserPromptSubmit hook."""
1232
+
1233
+ if result.action == "block":
1234
+ return {
1235
+ "decision": "block",
1236
+ "reason": block_reason(result),
1237
+ }
1238
+
1239
+ if result.additional_prompt:
1240
+ return {
1241
+ "hookSpecificOutput": {
1242
+ "hookEventName": "UserPromptSubmit",
1243
+ "additionalContext": result.additional_prompt,
1244
+ }
1245
+ }
1246
+
1247
+ if result.updated_state:
1248
+ return {
1249
+ "hookSpecificOutput": {
1250
+ "hookEventName": "UserPromptSubmit",
1251
+ "additionalContext": (
1252
+ "[Harness Guard]\n"
1253
+ f"STATE.md was updated from {result.current_state} to {result.updated_state}.\n"
1254
+ f"request_type={result.request_type}\n"
1255
+ f"confidence={result.confidence}"
1256
+ ),
1257
+ }
1258
+ }
1259
+
1260
+ return {}
1261
+
1262
+
1263
+ def handle_prompt(prompt: str) -> HookResult:
1264
+ state_data = load_state()
1265
+ flow = load_yaml(FLOW_PATH)
1266
+ docs_spec = load_yaml(DOCS_SPEC_PATH)
1267
+ guards = load_yaml(TRANSITION_GUARDS_PATH)
1268
+
1269
+ current_state = state_data.get("current_state")
1270
+ if not current_state:
1271
+ return HookResult(
1272
+ action="block",
1273
+ request_type="UNKNOWN",
1274
+ confidence="unknown",
1275
+ current_state=None,
1276
+ reason="STATE.md does not define current_state.",
1277
+ )
1278
+
1279
+ classification = classify_prompt(prompt)
1280
+ request_type = classification.request_type
1281
+
1282
+ if request_type == "QUESTION_OR_CONFIRMATION_REQUEST":
1283
+ return HookResult(
1284
+ action="allow",
1285
+ request_type=request_type,
1286
+ confidence=classification.confidence,
1287
+ current_state=current_state,
1288
+ reason="Question or confirmation requests are allowed without workflow action.",
1289
+ additional_prompt=question_or_confirmation_additional_prompt(),
1290
+ )
1291
+
1292
+ if request_type == "DOCUMENTATION_REQUEST":
1293
+ if is_merge_command(prompt):
1294
+ return HookResult(
1295
+ action="block",
1296
+ request_type=request_type,
1297
+ confidence=classification.confidence,
1298
+ current_state=current_state,
1299
+ reason="Merge is not allowed in /d documentation mode.",
1300
+ )
1301
+
1302
+ return HookResult(
1303
+ action="allow",
1304
+ request_type=request_type,
1305
+ confidence=classification.confidence,
1306
+ current_state=current_state,
1307
+ reason="/d documentation mode allows documentation and harness-maintenance work.",
1308
+ additional_prompt=documentation_additional_prompt(),
1309
+ )
1310
+
1311
+ if request_type == "UNKNOWN":
1312
+ return HookResult(
1313
+ action="allow",
1314
+ request_type=request_type,
1315
+ confidence=classification.confidence,
1316
+ current_state=current_state,
1317
+ reason="UNKNOWN requests are allowed only for non-mutating explanation or analysis.",
1318
+ additional_prompt=unknown_additional_prompt(),
1319
+ )
1320
+
1321
+ if classification.confidence == "low":
1322
+ return HookResult(
1323
+ action="allow",
1324
+ request_type=request_type,
1325
+ confidence=classification.confidence,
1326
+ current_state=current_state,
1327
+ reason="Ambiguous request type. User clarification is required before workflow action.",
1328
+ additional_prompt=low_confidence_additional_prompt(classification),
1329
+ )
1330
+
1331
+ if is_allowed(flow, current_state, request_type):
1332
+ additional_prompt = None
1333
+ if classification.confidence == "medium":
1334
+ additional_prompt = medium_confidence_additional_prompt(classification)
1335
+ if current_state == "FRONTEND_DESIGN" and request_type == "FRONTEND_DESIGN_REQUEST":
1336
+ additional_prompt = design_selection_additional_prompt()
1337
+
1338
+ return HookResult(
1339
+ action="allow",
1340
+ request_type=request_type,
1341
+ confidence=classification.confidence,
1342
+ current_state=current_state,
1343
+ reason="Request type is allowed in current state.",
1344
+ additional_prompt=additional_prompt,
1345
+ )
1346
+
1347
+ target_state = find_target_state(flow, request_type)
1348
+ if not target_state:
1349
+ return HookResult(
1350
+ action="block",
1351
+ request_type=request_type,
1352
+ confidence=classification.confidence,
1353
+ current_state=current_state,
1354
+ reason="No workflow state allows this request type.",
1355
+ )
1356
+
1357
+ path = find_state_path(flow, current_state, target_state)
1358
+ if not path:
1359
+ return HookResult(
1360
+ action="block",
1361
+ request_type=request_type,
1362
+ confidence=classification.confidence,
1363
+ current_state=current_state,
1364
+ target_state=target_state,
1365
+ reason="No transition path exists from current state to target state.",
1366
+ )
1367
+
1368
+ ok, missing = check_transition_guards(path, docs_spec, guards, state_data)
1369
+ if not ok:
1370
+ return HookResult(
1371
+ action="block",
1372
+ request_type=request_type,
1373
+ confidence=classification.confidence,
1374
+ current_state=current_state,
1375
+ target_state=target_state,
1376
+ reason="Transition guard check failed.",
1377
+ missing=missing,
1378
+ )
1379
+
1380
+ completed = list(state_data.get("completed_states") or [])
1381
+ for completed_state in path[:-1]:
1382
+ if completed_state not in completed:
1383
+ completed.append(completed_state)
1384
+
1385
+ state_data["current_state"] = target_state
1386
+ state_data["completed_states"] = completed
1387
+ state_data["last_transition"] = f"{current_state} -> {target_state}"
1388
+ state_data["updated_at"] = datetime.now(timezone.utc).isoformat()
1389
+ write_state(state_data)
1390
+
1391
+ additional_prompt = None
1392
+ if target_state == "FRONTEND_DESIGN":
1393
+ additional_prompt = design_selection_additional_prompt(
1394
+ f"STATE.md was updated from {current_state} to {target_state}."
1395
+ )
1396
+
1397
+ return HookResult(
1398
+ action="allow",
1399
+ request_type=request_type,
1400
+ confidence=classification.confidence,
1401
+ current_state=current_state,
1402
+ target_state=target_state,
1403
+ updated_state=target_state,
1404
+ reason="Transition guard check passed and STATE.md was updated.",
1405
+ additional_prompt=additional_prompt,
1406
+ )
1407
+
1408
+
1409
+ def main() -> int:
1410
+ prompt = read_prompt()
1411
+ if not prompt:
1412
+ result = HookResult(
1413
+ action="block",
1414
+ request_type="UNKNOWN",
1415
+ confidence="unknown",
1416
+ current_state=None,
1417
+ reason="No user prompt was provided.",
1418
+ )
1419
+ print(json.dumps(format_codex_output(result), ensure_ascii=False, indent=2))
1420
+ return ALLOW_EXIT_CODE
1421
+
1422
+ try:
1423
+ result = handle_prompt(prompt)
1424
+ except Exception as exc: # Keep hook failures explicit for the caller.
1425
+ result = HookResult(
1426
+ action="block",
1427
+ request_type="UNKNOWN",
1428
+ confidence="unknown",
1429
+ current_state=None,
1430
+ reason=f"Hook error: {exc}",
1431
+ )
1432
+ print(json.dumps(format_codex_output(result), ensure_ascii=False, indent=2))
1433
+ return ERROR_EXIT_CODE
1434
+
1435
+ print(json.dumps(format_codex_output(result), ensure_ascii=False, indent=2))
1436
+ return ALLOW_EXIT_CODE
1437
+
1438
+
1439
+ if __name__ == "__main__":
1440
+ raise SystemExit(main())