okstra 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.
- package/README.md +36 -0
- package/bin/okstra +62 -0
- package/package.json +30 -0
- package/runtime/.gitkeep +0 -0
- package/runtime/BUILD.json +5 -0
- package/runtime/agents/SKILL.md +243 -0
- package/runtime/agents/TODO.md +168 -0
- package/runtime/agents/workers/claude-worker.md +106 -0
- package/runtime/agents/workers/codex-worker.md +179 -0
- package/runtime/agents/workers/gemini-worker.md +179 -0
- package/runtime/agents/workers/report-writer-worker.md +116 -0
- package/runtime/bin/okstra-central.sh +152 -0
- package/runtime/bin/okstra-codex-exec.sh +53 -0
- package/runtime/bin/okstra-error-log.py +295 -0
- package/runtime/bin/okstra-gemini-exec.sh +55 -0
- package/runtime/bin/okstra-token-usage.py +46 -0
- package/runtime/bin/okstra.sh +162 -0
- package/runtime/prompts/launch.template.md +52 -0
- package/runtime/prompts/profiles/error-analysis.md +43 -0
- package/runtime/prompts/profiles/final-verification.md +37 -0
- package/runtime/prompts/profiles/implementation-planning.md +85 -0
- package/runtime/prompts/profiles/implementation.md +71 -0
- package/runtime/prompts/profiles/requirements-discovery.md +43 -0
- package/runtime/python/lib/okstra/cli.sh +227 -0
- package/runtime/python/lib/okstra/globals.sh +157 -0
- package/runtime/python/lib/okstra/interactive.sh +411 -0
- package/runtime/python/lib/okstra/project-resolver.sh +57 -0
- package/runtime/python/lib/okstra/usage.sh +98 -0
- package/runtime/python/lib/okstra-ctl/cmd-batch.sh +59 -0
- package/runtime/python/lib/okstra-ctl/cmd-list.sh +35 -0
- package/runtime/python/lib/okstra-ctl/cmd-open.sh +36 -0
- package/runtime/python/lib/okstra-ctl/cmd-projects.sh +26 -0
- package/runtime/python/lib/okstra-ctl/cmd-reconcile.sh +27 -0
- package/runtime/python/lib/okstra-ctl/cmd-reindex.sh +38 -0
- package/runtime/python/lib/okstra-ctl/cmd-rerun.sh +326 -0
- package/runtime/python/lib/okstra-ctl/cmd-show.sh +27 -0
- package/runtime/python/lib/okstra-ctl/cmd-tail.sh +76 -0
- package/runtime/python/lib/okstra-ctl/main.sh +41 -0
- package/runtime/python/lib/okstra-ctl/prepare.sh +29 -0
- package/runtime/python/lib/okstra-ctl/usage.sh +23 -0
- package/runtime/python/okstra_ctl/__init__.py +125 -0
- package/runtime/python/okstra_ctl/backfill.py +253 -0
- package/runtime/python/okstra_ctl/batch.py +62 -0
- package/runtime/python/okstra_ctl/ids.py +84 -0
- package/runtime/python/okstra_ctl/index.py +216 -0
- package/runtime/python/okstra_ctl/invocation.py +49 -0
- package/runtime/python/okstra_ctl/jsonl.py +84 -0
- package/runtime/python/okstra_ctl/listing.py +156 -0
- package/runtime/python/okstra_ctl/locks.py +42 -0
- package/runtime/python/okstra_ctl/material.py +62 -0
- package/runtime/python/okstra_ctl/models.py +63 -0
- package/runtime/python/okstra_ctl/path_resolve.py +40 -0
- package/runtime/python/okstra_ctl/paths.py +251 -0
- package/runtime/python/okstra_ctl/project_meta.py +51 -0
- package/runtime/python/okstra_ctl/reconcile.py +166 -0
- package/runtime/python/okstra_ctl/render.py +1065 -0
- package/runtime/python/okstra_ctl/resolver.py +54 -0
- package/runtime/python/okstra_ctl/run.py +674 -0
- package/runtime/python/okstra_ctl/run_context.py +166 -0
- package/runtime/python/okstra_ctl/seeding.py +97 -0
- package/runtime/python/okstra_ctl/sequence.py +53 -0
- package/runtime/python/okstra_ctl/session.py +33 -0
- package/runtime/python/okstra_ctl/tmux.py +27 -0
- package/runtime/python/okstra_ctl/workers.py +64 -0
- package/runtime/python/okstra_ctl/workflow.py +182 -0
- package/runtime/python/okstra_project/__init__.py +41 -0
- package/runtime/python/okstra_project/resolver.py +126 -0
- package/runtime/python/okstra_project/state.py +170 -0
- package/runtime/python/okstra_token_usage/__init__.py +26 -0
- package/runtime/python/okstra_token_usage/blocks.py +62 -0
- package/runtime/python/okstra_token_usage/claude.py +97 -0
- package/runtime/python/okstra_token_usage/cli.py +84 -0
- package/runtime/python/okstra_token_usage/codex.py +80 -0
- package/runtime/python/okstra_token_usage/collect.py +161 -0
- package/runtime/python/okstra_token_usage/gemini.py +77 -0
- package/runtime/python/okstra_token_usage/jsonl_io.py +18 -0
- package/runtime/python/okstra_token_usage/paths.py +22 -0
- package/runtime/python/okstra_token_usage/pricing.py +71 -0
- package/runtime/python/okstra_token_usage/report.py +64 -0
- package/runtime/templates/prd/brief.template.md +273 -0
- package/runtime/templates/project-docs/task-index.template.md +65 -0
- package/runtime/templates/reports/error-analysis-input.template.md +80 -0
- package/runtime/templates/reports/final-report.template.md +167 -0
- package/runtime/templates/reports/final-verification-input.template.md +67 -0
- package/runtime/templates/reports/implementation-input.template.md +81 -0
- package/runtime/templates/reports/implementation-planning-input.template.md +93 -0
- package/runtime/templates/reports/quick-input.template.md +64 -0
- package/runtime/templates/reports/schedule.template.md +168 -0
- package/runtime/templates/reports/settings.template.json +101 -0
- package/runtime/templates/reports/task-brief.template.md +165 -0
- package/runtime/validators/lib/common.sh +44 -0
- package/runtime/validators/lib/fixtures.sh +322 -0
- package/runtime/validators/lib/paths.sh +44 -0
- package/runtime/validators/lib/runners.sh +140 -0
- package/runtime/validators/lib/summary.sh +15 -0
- package/runtime/validators/lib/validate-assets.sh +44 -0
- package/runtime/validators/lib/validate-prompt-metadata.sh +267 -0
- package/runtime/validators/lib/validate-tasks.sh +335 -0
- package/runtime/validators/validate-run.py +568 -0
- package/runtime/validators/validate-schedule.py +665 -0
- package/runtime/validators/validate-workflow.sh +190 -0
- package/src/doctor.mjs +127 -0
- package/src/install.mjs +355 -0
- package/src/paths.mjs +132 -0
- package/src/uninstall.mjs +122 -0
- package/src/version.mjs +20 -0
|
@@ -0,0 +1,665 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Validate that an okstra schedule Markdown file conforms to the Section Contract
|
|
4
|
+
defined in skills/okstra-schedule/SKILL.md.
|
|
5
|
+
|
|
6
|
+
Usage:
|
|
7
|
+
python3 validators/validate-schedule.py <path-to-schedule.md>
|
|
8
|
+
|
|
9
|
+
Exits 0 if compliant, 1 with a list of violations otherwise. Intended to be
|
|
10
|
+
called by the okstra-schedule skill (self-validation step) and by humans /
|
|
11
|
+
hooks before committing a schedule.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import re
|
|
17
|
+
import sys
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
REQUIRED_SECTIONS_IN_ORDER: list[str] = [
|
|
21
|
+
"## At a Glance",
|
|
22
|
+
"## Executive Summary",
|
|
23
|
+
"## Task Dependency Graph",
|
|
24
|
+
"## Phase 1: Critical Fixes",
|
|
25
|
+
"## Phase 2: Enhancements",
|
|
26
|
+
"## Phase 3: Architecture",
|
|
27
|
+
"## Execution Priority Matrix",
|
|
28
|
+
"## Cross-Task Dependencies & Shared Concerns",
|
|
29
|
+
"## Risk Mitigation Strategy",
|
|
30
|
+
"## Recommended Immediate Actions",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
OPTIONAL_GRAPHIC_SECTIONS_IN_ORDER: list[str] = [
|
|
34
|
+
"## Gantt Chart",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
REQUIRED_EXEC_SUMMARY_SUBSECTION = "### Effort Sizing 기준"
|
|
38
|
+
|
|
39
|
+
REQUIRED_TASK_FIELDS = [
|
|
40
|
+
"**Category**",
|
|
41
|
+
"**Priority**",
|
|
42
|
+
"**Effort**",
|
|
43
|
+
"**Status**",
|
|
44
|
+
"**Risk**",
|
|
45
|
+
"**Scope**",
|
|
46
|
+
"**Repo**",
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
FORBIDDEN_HEADINGS_RE = re.compile(
|
|
50
|
+
r"^##\s+("
|
|
51
|
+
# Korean translations of mandatory/optional headings
|
|
52
|
+
r"전체\s*개요|요약(\s*\(Executive\s*Summary\))?|단위\s*간\s*의존성\s*그래프|"
|
|
53
|
+
r"단위\s*간\s*의존성\s*&\s*공유\s*사항|위험\s*완화\s*전략|즉시\s*권장\s*액션|"
|
|
54
|
+
r"Gantt\s*다이어그램.*|누적\s*일정표.*|Cumulative\s*Timeline.*|Effort-to-Day\s*매핑.*|"
|
|
55
|
+
# French translations of mandatory/optional headings (require French-distinctive
|
|
56
|
+
# tokens — never match the canonical English forms like `Phase 3: Architecture`)
|
|
57
|
+
r"Vue\s*d['’]?Ensemble|Résumé\s*Exécutif|"
|
|
58
|
+
r"Graphe\s*de\s*Dépendances.*|Diagramme\s*de\s*Gantt.*|"
|
|
59
|
+
r"Calendrier\s*Cumulé.*|Référentiel\s*Effort-to-Day.*|"
|
|
60
|
+
r"Dépendances\s*Inter-Tâches.*|Stratégie\s*d['’]?Atténuation.*|"
|
|
61
|
+
r"Stratégie\s*de\s*Mitigation.*|Actions\s*Immédiates\s*Recommandées|"
|
|
62
|
+
r"Actions\s*Recommandées\s*Immédiates|Phase\s*\d+\s*:\s*Correctifs\s*Critiques.*|"
|
|
63
|
+
r"Phase\s*\d+\s*:\s*Améliorations|Phase\s*\d+\s*:\s*Architecture\s*\(Volumes.*|"
|
|
64
|
+
r"Phase\s*\d+\s*:\s*Extension.*"
|
|
65
|
+
r")\s*$",
|
|
66
|
+
re.MULTILINE,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
FORBIDDEN_FRENCH_BODY_RE = re.compile(
|
|
70
|
+
# Require accented forms only — plain ASCII variants would clash with
|
|
71
|
+
# legitimate English (`Detail`, `Repo`, `Category`).
|
|
72
|
+
r"(Charge\s+estimée|Catégorie\s*\||Priorité\s*\||Risque\s*\||Dépôt\s*\||Détail\s*\|)"
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
# Allowed enum values for At a Glance table cells. Cell text is normalized
|
|
76
|
+
# (strip + collapse spaces) before matching.
|
|
77
|
+
ALLOWED_EFFORT = {"S", "M", "L", "XL", "XXL"}
|
|
78
|
+
ALLOWED_PRIORITY = {"P0", "P1", "P2", "P3"}
|
|
79
|
+
ALLOWED_RISK = {"Very Low", "Low", "Medium", "Med-High", "High"}
|
|
80
|
+
ALLOWED_PHASE = {"1", "2", "3"}
|
|
81
|
+
|
|
82
|
+
# Per-task block sub-section labels in REQUIRED order. Each label is matched
|
|
83
|
+
# at the start of a line as `**Label**:` or as a `####` heading for the last.
|
|
84
|
+
PER_TASK_SUBSECTIONS_IN_ORDER = [
|
|
85
|
+
"**Problem**:",
|
|
86
|
+
"**Solution**:",
|
|
87
|
+
"**Work Breakdown**:",
|
|
88
|
+
"**Verification Commands**:",
|
|
89
|
+
"**Rollback**:",
|
|
90
|
+
]
|
|
91
|
+
|
|
92
|
+
# Forbidden graph-DSL fences inside `## Gantt Chart` (the only ASCII section).
|
|
93
|
+
FORBIDDEN_GANTT_DSL_FENCES = (
|
|
94
|
+
"```mermaid",
|
|
95
|
+
"```gantt",
|
|
96
|
+
"```plantuml",
|
|
97
|
+
"```graphviz",
|
|
98
|
+
"```dot",
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
# Calendar-date / weekday tokens that must NOT appear in the Gantt axis line.
|
|
102
|
+
CALENDAR_DATE_RE = re.compile(
|
|
103
|
+
r"\b(20\d{2}-\d{2}-\d{2}"
|
|
104
|
+
r"|\d{4}/\d{2}/\d{2}"
|
|
105
|
+
r"|Mon|Tue|Wed|Thu|Fri|Sat|Sun"
|
|
106
|
+
r"|월요일|화요일|수요일|목요일|금요일|토요일|일요일"
|
|
107
|
+
r"|오늘\s*\+\s*\d+)\b"
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
DAYS_TOTAL_RE = re.compile(
|
|
111
|
+
r"\*\*총\s*\d+개\s*task\s*/\s*예상\s*소요:\s*"
|
|
112
|
+
r"\d+(?:\.\d+)?\s*~\s*\d+(?:\.\d+)?\s*days?\s*\(Effort\s*합산\)\*\*"
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
# Opaque cross-document identifiers from internal report formats.
|
|
116
|
+
# These mean nothing to a client reading the schedule alone, so they MUST
|
|
117
|
+
# either be inlined in plain prose (Form A) or resolved in `## Glossary`
|
|
118
|
+
# (Form B).
|
|
119
|
+
#
|
|
120
|
+
# Patterns that are TASK-ID-shaped (`FC-5`, `UC-3`, etc.) are detected
|
|
121
|
+
# generically as `<2+ uppercase letters>-<digits>` and then filtered against
|
|
122
|
+
# the TASK-ID whitelist (extracted from At a Glance) so legitimate task
|
|
123
|
+
# references (`DEV-1234`, `MOBILE-42`) pass through.
|
|
124
|
+
OPAQUE_DASHED_CODE_RE = re.compile(r"\b([A-Z]{1,5})-(\d+)\b")
|
|
125
|
+
|
|
126
|
+
# Decision-item letter codes (`A1`, `B2`, `C3`, `D4`). These represent
|
|
127
|
+
# approval/decision items in source reports and are forbidden in the schedule
|
|
128
|
+
# at all — the glossary cannot whitewash them.
|
|
129
|
+
DECISION_ITEM_RE = re.compile(r"\b([A-D])(\d{1,2})\b")
|
|
130
|
+
DECISION_ITEM_ALLOWLIST = set() # nothing legitimate matches this shape
|
|
131
|
+
|
|
132
|
+
# Milestone codes (`M1`, `M2`, …). Allowed in body only if resolved in `## Glossary`.
|
|
133
|
+
MILESTONE_RE = re.compile(r"\bM(\d+)\b")
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def validate(path: Path) -> list[str]:
|
|
137
|
+
if not path.exists():
|
|
138
|
+
return [f"file not found: {path}"]
|
|
139
|
+
|
|
140
|
+
text = path.read_text(encoding="utf-8")
|
|
141
|
+
lines = text.splitlines()
|
|
142
|
+
violations: list[str] = []
|
|
143
|
+
|
|
144
|
+
# 1. Title must end with "— Work Schedule"
|
|
145
|
+
title_line = next((ln for ln in lines if ln.startswith("# ")), "")
|
|
146
|
+
if not title_line:
|
|
147
|
+
violations.append("missing top-level title (# …)")
|
|
148
|
+
elif not title_line.rstrip().endswith("— Work Schedule"):
|
|
149
|
+
violations.append(
|
|
150
|
+
f'title must end with "— Work Schedule"; got: {title_line!r}'
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
# 2. Metadata header lines required
|
|
154
|
+
if not re.search(r"^>\s*Generated:.*\|\s*Project:.*\|\s*Task Group:", text, re.MULTILINE):
|
|
155
|
+
violations.append(
|
|
156
|
+
"missing `> Generated: <…> | Project: <…> | Task Group: <…>` metadata line"
|
|
157
|
+
)
|
|
158
|
+
if not re.search(r"^>\s*Source:\s*okstra\b", text, re.MULTILINE):
|
|
159
|
+
violations.append("missing `> Source: okstra <mode> …` metadata line")
|
|
160
|
+
|
|
161
|
+
# 3. Required sections present and in order. The optional `## Gantt Chart`
|
|
162
|
+
# section, if present, MUST sit between Task Dependency Graph and
|
|
163
|
+
# Phase 1: Critical Fixes.
|
|
164
|
+
section_positions: dict[str, int] = {}
|
|
165
|
+
optional_positions: dict[str, int] = {}
|
|
166
|
+
for idx, line in enumerate(lines):
|
|
167
|
+
stripped = line.rstrip()
|
|
168
|
+
if stripped in REQUIRED_SECTIONS_IN_ORDER and stripped not in section_positions:
|
|
169
|
+
section_positions[stripped] = idx
|
|
170
|
+
elif (
|
|
171
|
+
stripped in OPTIONAL_GRAPHIC_SECTIONS_IN_ORDER
|
|
172
|
+
and stripped not in optional_positions
|
|
173
|
+
):
|
|
174
|
+
optional_positions[stripped] = idx
|
|
175
|
+
|
|
176
|
+
missing = [s for s in REQUIRED_SECTIONS_IN_ORDER if s not in section_positions]
|
|
177
|
+
for s in missing:
|
|
178
|
+
violations.append(f"missing required section: {s!r}")
|
|
179
|
+
|
|
180
|
+
if not missing:
|
|
181
|
+
ordered_actual = sorted(section_positions, key=lambda s: section_positions[s])
|
|
182
|
+
if ordered_actual != REQUIRED_SECTIONS_IN_ORDER:
|
|
183
|
+
violations.append(
|
|
184
|
+
"required sections are out of order. expected:\n "
|
|
185
|
+
+ "\n ".join(REQUIRED_SECTIONS_IN_ORDER)
|
|
186
|
+
+ "\nactual:\n "
|
|
187
|
+
+ "\n ".join(ordered_actual)
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
# Optional sections must sit between Task Dependency Graph and Phase 1
|
|
191
|
+
dep_idx = section_positions["## Task Dependency Graph"]
|
|
192
|
+
phase1_idx = section_positions["## Phase 1: Critical Fixes"]
|
|
193
|
+
for opt_name, opt_idx in optional_positions.items():
|
|
194
|
+
if not (dep_idx < opt_idx < phase1_idx):
|
|
195
|
+
violations.append(
|
|
196
|
+
f"optional section {opt_name!r} is misplaced — must appear between "
|
|
197
|
+
"'## Task Dependency Graph' and '## Phase 1: Critical Fixes'"
|
|
198
|
+
)
|
|
199
|
+
# 4. Executive Summary subsection
|
|
200
|
+
if REQUIRED_EXEC_SUMMARY_SUBSECTION not in text:
|
|
201
|
+
violations.append(
|
|
202
|
+
f"missing subsection {REQUIRED_EXEC_SUMMARY_SUBSECTION!r} inside Executive Summary"
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
# 5. Forbidden translated/extra headings
|
|
206
|
+
for match in FORBIDDEN_HEADINGS_RE.finditer(text):
|
|
207
|
+
violations.append(
|
|
208
|
+
f"forbidden heading (translated or extra): {match.group(0).strip()!r} "
|
|
209
|
+
"— see SKILL Section Contract"
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
# 6. Forbidden French body labels (per-task field labels in French, etc.)
|
|
213
|
+
if FORBIDDEN_FRENCH_BODY_RE.search(text):
|
|
214
|
+
violations.append(
|
|
215
|
+
"found French structural label (e.g. 'Charge estimée', 'Catégorie |', "
|
|
216
|
+
"'Détail |'); per-task tables MUST use English field labels "
|
|
217
|
+
"(**Category**, **Priority**, **Risk**, **Repo**, …)"
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
# 7. Per-task field labels — every per-task heading (### N-i.) MUST be followed
|
|
221
|
+
# (within the next 60 lines) by all required field labels.
|
|
222
|
+
task_heading_re = re.compile(r"^###\s+\d+-\d+\.\s+", re.MULTILINE)
|
|
223
|
+
for m in task_heading_re.finditer(text):
|
|
224
|
+
start = m.start()
|
|
225
|
+
block_end = start
|
|
226
|
+
# find the next ### or ## heading
|
|
227
|
+
next_h = re.search(r"^(##\s|###\s)", text[start + len(m.group(0)):], re.MULTILINE)
|
|
228
|
+
block = text[start: start + len(m.group(0)) + (next_h.start() if next_h else len(text))]
|
|
229
|
+
for label in REQUIRED_TASK_FIELDS:
|
|
230
|
+
if label not in block:
|
|
231
|
+
heading_line = text[start: text.find("\n", start)]
|
|
232
|
+
violations.append(
|
|
233
|
+
f"per-task block {heading_line.strip()!r} missing required field {label!r}"
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
# 8. Item table header literal
|
|
237
|
+
if "| Item | Detail |" not in text and any(
|
|
238
|
+
h.startswith("## Phase") for h in section_positions
|
|
239
|
+
):
|
|
240
|
+
# only flag if there is at least one phase with a per-task block
|
|
241
|
+
if task_heading_re.search(text):
|
|
242
|
+
violations.append(
|
|
243
|
+
"per-task tables must use header `| Item | Detail |` (literal English)"
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
# 9. Checklist tables MUST carry a leading `Done` column with markdown
|
|
247
|
+
# checkboxes. The check is a header-presence test — we require the
|
|
248
|
+
# canonical header literal to appear in the section's text block.
|
|
249
|
+
checklist_header_specs = [
|
|
250
|
+
(
|
|
251
|
+
"## Execution Priority Matrix",
|
|
252
|
+
["| Priority | Task | Effort | Risk | Next Action |"],
|
|
253
|
+
"Execution Priority Matrix table must use header "
|
|
254
|
+
"`| Priority | Task | Effort | Risk | Next Action |` "
|
|
255
|
+
"(client-facing schedule — no Done/Ready/Blocking columns)",
|
|
256
|
+
),
|
|
257
|
+
]
|
|
258
|
+
for section_name, expected_substrings, message in checklist_header_specs:
|
|
259
|
+
if section_name not in section_positions:
|
|
260
|
+
continue
|
|
261
|
+
start = section_positions[section_name]
|
|
262
|
+
# find next `## ` heading or end of file
|
|
263
|
+
next_h = re.search(r"^##\s", "\n".join(lines[start + 1:]), re.MULTILINE)
|
|
264
|
+
end = (start + 1 + (next_h.start() if next_h else len(text))) if next_h else len(lines)
|
|
265
|
+
section_text = "\n".join(lines[start:end])
|
|
266
|
+
if not any(sub in section_text for sub in expected_substrings):
|
|
267
|
+
violations.append(message)
|
|
268
|
+
|
|
269
|
+
# Forbid client-leaking artifacts: Consolidated User Decision Checklist /
|
|
270
|
+
# per-task 사용자 확인 필요 항목 — schedule is for client delivery, blocking
|
|
271
|
+
# / approval items must NOT surface.
|
|
272
|
+
if "## Consolidated User Decision Checklist" in text:
|
|
273
|
+
violations.append(
|
|
274
|
+
"`## Consolidated User Decision Checklist` is forbidden — schedule "
|
|
275
|
+
"is a client-facing work plan; blocking/approval items belong in "
|
|
276
|
+
"internal task reports, not the schedule"
|
|
277
|
+
)
|
|
278
|
+
if re.search(r"^####\s+사용자\s*확인\s*필요\s*항목\s*$", text, re.MULTILINE):
|
|
279
|
+
violations.append(
|
|
280
|
+
"per-task `#### 사용자 확인 필요 항목` is forbidden in schedule — "
|
|
281
|
+
"client-facing document; surface confirmation items in the source "
|
|
282
|
+
"report instead"
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
# 10. Days total format inside At a Glance: `**총 N개 task / 예상 소요: X.X ~ Y.Y days (Effort 합산)**`
|
|
286
|
+
if "## At a Glance" in section_positions:
|
|
287
|
+
start = section_positions["## At a Glance"]
|
|
288
|
+
rest = "\n".join(lines[start + 1:])
|
|
289
|
+
next_h = re.search(r"^##\s", rest, re.MULTILINE)
|
|
290
|
+
body = rest[: next_h.start()] if next_h else rest
|
|
291
|
+
if not DAYS_TOTAL_RE.search(body):
|
|
292
|
+
violations.append(
|
|
293
|
+
"At a Glance must contain a totals line matching "
|
|
294
|
+
"`**총 N개 task / 예상 소요: X.X ~ Y.Y days (Effort 합산)**`"
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
# 11. At a Glance row enum check (Effort / Priority / Risk / Phase)
|
|
298
|
+
for row in re.finditer(
|
|
299
|
+
r"^\|\s*\d+\s*\|.*$", body, re.MULTILINE
|
|
300
|
+
):
|
|
301
|
+
cells = [c.strip() for c in row.group(0).strip().strip("|").split("|")]
|
|
302
|
+
# header: # | Task ID | Title | Category | Priority | Effort | Days | taskType | Risk | Phase
|
|
303
|
+
if len(cells) < 10:
|
|
304
|
+
continue
|
|
305
|
+
priority = re.sub(r"\*+", "", cells[4]).strip()
|
|
306
|
+
effort = re.sub(r"\*+", "", cells[5]).strip()
|
|
307
|
+
# `Effort` cell may contain extra hint like `L (5-15 files)`; pick the leading token
|
|
308
|
+
effort_token = effort.split()[0] if effort else ""
|
|
309
|
+
risk = re.sub(r"\*+", "", cells[8]).strip()
|
|
310
|
+
phase = re.sub(r"\*+", "", cells[9]).strip()
|
|
311
|
+
task_id = cells[1]
|
|
312
|
+
if priority and priority not in ALLOWED_PRIORITY:
|
|
313
|
+
violations.append(
|
|
314
|
+
f"At a Glance row for {task_id!r}: Priority {priority!r} "
|
|
315
|
+
f"not in allowed set {sorted(ALLOWED_PRIORITY)}"
|
|
316
|
+
)
|
|
317
|
+
if effort_token and effort_token not in ALLOWED_EFFORT:
|
|
318
|
+
violations.append(
|
|
319
|
+
f"At a Glance row for {task_id!r}: Effort {effort_token!r} "
|
|
320
|
+
f"not in allowed set {sorted(ALLOWED_EFFORT)}"
|
|
321
|
+
)
|
|
322
|
+
if risk and risk not in ALLOWED_RISK:
|
|
323
|
+
violations.append(
|
|
324
|
+
f"At a Glance row for {task_id!r}: Risk {risk!r} "
|
|
325
|
+
f"not in allowed set {sorted(ALLOWED_RISK)}"
|
|
326
|
+
)
|
|
327
|
+
if phase and phase not in ALLOWED_PHASE:
|
|
328
|
+
violations.append(
|
|
329
|
+
f"At a Glance row for {task_id!r}: Phase {phase!r} "
|
|
330
|
+
f"not in allowed set {sorted(ALLOWED_PHASE)}"
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
# 12. Gantt Chart content checks — forbid DSL fences, force relative-day axis
|
|
334
|
+
if "## Gantt Chart" in optional_positions:
|
|
335
|
+
start = optional_positions["## Gantt Chart"]
|
|
336
|
+
rest = "\n".join(lines[start + 1:])
|
|
337
|
+
next_h = re.search(r"^##\s", rest, re.MULTILINE)
|
|
338
|
+
body = rest[: next_h.start()] if next_h else rest
|
|
339
|
+
|
|
340
|
+
for forbidden in FORBIDDEN_GANTT_DSL_FENCES:
|
|
341
|
+
if forbidden in body:
|
|
342
|
+
violations.append(
|
|
343
|
+
f"`## Gantt Chart` contains forbidden DSL fence {forbidden!r} — "
|
|
344
|
+
"only plain ``` (no language tag) ASCII blocks are allowed"
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
# Detect any ``` fence with a language hint inside the Gantt section.
|
|
348
|
+
for m in re.finditer(r"^```([^\s`]+)\s*$", body, re.MULTILINE):
|
|
349
|
+
lang = m.group(1)
|
|
350
|
+
if lang.lower() not in {""}:
|
|
351
|
+
violations.append(
|
|
352
|
+
f"`## Gantt Chart` fence has language tag ```{lang}``` — "
|
|
353
|
+
"fence MUST be plain ``` with no language hint"
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
# Calendar-date / weekday axis check on the body.
|
|
357
|
+
cal_match = CALENDAR_DATE_RE.search(body)
|
|
358
|
+
if cal_match:
|
|
359
|
+
violations.append(
|
|
360
|
+
f"`## Gantt Chart` axis contains calendar/weekday token "
|
|
361
|
+
f"{cal_match.group(0)!r} — use relative day-counts only "
|
|
362
|
+
"(`Day 1`, `J1`, …)"
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
# 12b. Task Dependency Graph content check.
|
|
366
|
+
if "## Task Dependency Graph" in section_positions:
|
|
367
|
+
start = section_positions["## Task Dependency Graph"]
|
|
368
|
+
rest = "\n".join(lines[start + 1:])
|
|
369
|
+
next_h = re.search(r"^##\s", rest, re.MULTILINE)
|
|
370
|
+
body = (rest[: next_h.start()] if next_h else rest).strip()
|
|
371
|
+
|
|
372
|
+
no_data_marker = "_의존 정보 없음_"
|
|
373
|
+
fence_match = re.search(r"^```([^\n]*)\n(.*?)^```\s*$", body, re.MULTILINE | re.DOTALL)
|
|
374
|
+
|
|
375
|
+
if not body:
|
|
376
|
+
violations.append(
|
|
377
|
+
"`## Task Dependency Graph` is empty — must contain either "
|
|
378
|
+
f"`{no_data_marker}` or an adjacency-list fenced block"
|
|
379
|
+
)
|
|
380
|
+
elif fence_match:
|
|
381
|
+
lang = fence_match.group(1).strip()
|
|
382
|
+
inner = fence_match.group(2)
|
|
383
|
+
if lang:
|
|
384
|
+
violations.append(
|
|
385
|
+
f"`## Task Dependency Graph` fence has language tag ```{lang}``` — "
|
|
386
|
+
"fence MUST be plain ``` with no language hint"
|
|
387
|
+
)
|
|
388
|
+
for forbidden in FORBIDDEN_GANTT_DSL_FENCES:
|
|
389
|
+
if forbidden in body:
|
|
390
|
+
violations.append(
|
|
391
|
+
f"`## Task Dependency Graph` contains forbidden DSL fence "
|
|
392
|
+
f"{forbidden!r} — only ASCII adjacency lines are allowed"
|
|
393
|
+
)
|
|
394
|
+
adjacency_re = re.compile(
|
|
395
|
+
r"^[A-Za-z][A-Za-z0-9_-]*\s*->\s*"
|
|
396
|
+
r"[A-Za-z][A-Za-z0-9_-]*(?:\s*,\s*[A-Za-z][A-Za-z0-9_-]*)*\s*$"
|
|
397
|
+
)
|
|
398
|
+
for line_no, raw in enumerate(inner.splitlines(), start=1):
|
|
399
|
+
ln = raw.rstrip()
|
|
400
|
+
if not ln.strip():
|
|
401
|
+
continue
|
|
402
|
+
if ln.lstrip().startswith("#"):
|
|
403
|
+
continue
|
|
404
|
+
if "→" in ln:
|
|
405
|
+
violations.append(
|
|
406
|
+
f"`## Task Dependency Graph` line {line_no} uses Unicode '→' "
|
|
407
|
+
"— use ASCII '->' only"
|
|
408
|
+
)
|
|
409
|
+
continue
|
|
410
|
+
if not adjacency_re.match(ln.strip()):
|
|
411
|
+
violations.append(
|
|
412
|
+
f"`## Task Dependency Graph` line {line_no} is not a valid "
|
|
413
|
+
"adjacency line: expected `<TASK-ID> -> <TASK-ID>[, <TASK-ID>]*`; "
|
|
414
|
+
f"got {ln.strip()!r}"
|
|
415
|
+
)
|
|
416
|
+
elif no_data_marker not in body:
|
|
417
|
+
violations.append(
|
|
418
|
+
"`## Task Dependency Graph` must be either the literal "
|
|
419
|
+
f"`{no_data_marker}` or a plain ``` fenced adjacency-list "
|
|
420
|
+
"block — free-form prose is not allowed"
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
# 13. Per-task block sub-section order check
|
|
424
|
+
task_heading_re = re.compile(r"^###\s+\d+-\d+\.\s+", re.MULTILINE)
|
|
425
|
+
headings = list(task_heading_re.finditer(text))
|
|
426
|
+
for idx, m in enumerate(headings):
|
|
427
|
+
block_start = m.start()
|
|
428
|
+
block_end = headings[idx + 1].start() if idx + 1 < len(headings) else len(text)
|
|
429
|
+
# also stop at the next `## ` heading. Skip past the current line first
|
|
430
|
+
# so the heading itself ('### N-i. …') doesn't match `^##\s` from offset 1.
|
|
431
|
+
body_start = text.find("\n", block_start) + 1
|
|
432
|
+
next_phase_re = re.compile(r"^##\s", re.MULTILINE)
|
|
433
|
+
np = next_phase_re.search(text, body_start, block_end)
|
|
434
|
+
if np:
|
|
435
|
+
block_end = np.start()
|
|
436
|
+
block = text[block_start:block_end]
|
|
437
|
+
heading_line = text[block_start : text.find("\n", block_start)].strip()
|
|
438
|
+
|
|
439
|
+
# Skip blocks marked as NEEDS-OKSTRA-RUN or PARSE-ERROR — these are
|
|
440
|
+
# intentionally partial.
|
|
441
|
+
if "[NEEDS-OKSTRA-RUN]" in block or "[PARSE-ERROR" in block:
|
|
442
|
+
continue
|
|
443
|
+
|
|
444
|
+
last_pos = -1
|
|
445
|
+
for label in PER_TASK_SUBSECTIONS_IN_ORDER:
|
|
446
|
+
pos = block.find(label)
|
|
447
|
+
if pos == -1:
|
|
448
|
+
violations.append(
|
|
449
|
+
f"per-task block {heading_line!r} missing required "
|
|
450
|
+
f"sub-section {label!r}"
|
|
451
|
+
)
|
|
452
|
+
last_pos = -1 # avoid cascading order errors after a missing one
|
|
453
|
+
break
|
|
454
|
+
if pos < last_pos:
|
|
455
|
+
violations.append(
|
|
456
|
+
f"per-task block {heading_line!r}: sub-section {label!r} "
|
|
457
|
+
"appears out of required order "
|
|
458
|
+
"(Problem → Solution → Work Breakdown → Verification Commands "
|
|
459
|
+
"→ Rollback → 사용자 확인 필요 항목)"
|
|
460
|
+
)
|
|
461
|
+
break
|
|
462
|
+
last_pos = pos
|
|
463
|
+
|
|
464
|
+
# Recommended Immediate Actions must be a bullet list (no numbered list,
|
|
465
|
+
# no checkboxes — this is a client-facing work plan, not an internal todo).
|
|
466
|
+
if "## Recommended Immediate Actions" in section_positions:
|
|
467
|
+
start = section_positions["## Recommended Immediate Actions"]
|
|
468
|
+
rest = "\n".join(lines[start + 1:])
|
|
469
|
+
next_h = re.search(r"^##\s", rest, re.MULTILINE)
|
|
470
|
+
body = rest[: next_h.start()] if next_h else rest
|
|
471
|
+
body = body.strip()
|
|
472
|
+
if body and not re.search(r"^-\s+\S", body, re.MULTILINE):
|
|
473
|
+
violations.append(
|
|
474
|
+
"Recommended Immediate Actions must be a markdown bullet list "
|
|
475
|
+
"(`- <action>`); numbered lists are forbidden"
|
|
476
|
+
)
|
|
477
|
+
if re.search(r"^-\s+\[[ xX]\]\s", body, re.MULTILINE):
|
|
478
|
+
violations.append(
|
|
479
|
+
"Recommended Immediate Actions must not use checkbox items "
|
|
480
|
+
"(`- [ ] …`) — schedule is a client-facing plan, not an "
|
|
481
|
+
"internal todo list"
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
# 14. Self-contained identifiers — opaque cross-doc codes (FC-N, UC-N,
|
|
485
|
+
# M-N, A1, B2, …) must be either inlined as prose (Form A) or resolved in
|
|
486
|
+
# `## Glossary` (Form B). Decision-item letters (A1-D99) are forbidden
|
|
487
|
+
# outright.
|
|
488
|
+
code_violations = _check_self_contained_identifiers(text, lines, section_positions)
|
|
489
|
+
violations.extend(code_violations)
|
|
490
|
+
|
|
491
|
+
return violations
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def _strip_code_fences(text: str) -> str:
|
|
495
|
+
"""Remove ``` fenced blocks so opaque-id regex doesn't false-positive
|
|
496
|
+
on shell commands, file paths, etc."""
|
|
497
|
+
return re.sub(r"```.*?```", "", text, flags=re.DOTALL)
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def _extract_task_id_whitelist(text: str, section_positions: dict[str, int],
|
|
501
|
+
lines: list[str]) -> set[str]:
|
|
502
|
+
"""Pull TASK-IDs from the At a Glance table column 2."""
|
|
503
|
+
whitelist: set[str] = set()
|
|
504
|
+
if "## At a Glance" not in section_positions:
|
|
505
|
+
return whitelist
|
|
506
|
+
start = section_positions["## At a Glance"]
|
|
507
|
+
rest = "\n".join(lines[start + 1:])
|
|
508
|
+
next_h = re.search(r"^##\s", rest, re.MULTILINE)
|
|
509
|
+
body = rest[: next_h.start()] if next_h else rest
|
|
510
|
+
for row in re.finditer(r"^\|\s*\d+\s*\|.*$", body, re.MULTILINE):
|
|
511
|
+
cells = [c.strip() for c in row.group(0).strip().strip("|").split("|")]
|
|
512
|
+
if len(cells) >= 2 and re.fullmatch(r"[A-Z][A-Z0-9_-]*", cells[1]):
|
|
513
|
+
whitelist.add(cells[1])
|
|
514
|
+
return whitelist
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def _extract_glossary(text: str) -> tuple[bool, set[str], int]:
|
|
518
|
+
"""Return (present, code_set, header_line_index).
|
|
519
|
+
|
|
520
|
+
A `## Glossary` section is recognized by the literal heading. Codes are
|
|
521
|
+
extracted from the first column of `| Code | Description |` rows.
|
|
522
|
+
"""
|
|
523
|
+
m = re.search(r"^##\s+Glossary\s*$", text, re.MULTILINE)
|
|
524
|
+
if not m:
|
|
525
|
+
return False, set(), -1
|
|
526
|
+
after = text[m.end():]
|
|
527
|
+
next_h = re.search(r"^##\s", after, re.MULTILINE)
|
|
528
|
+
body = after[: next_h.start()] if next_h else after
|
|
529
|
+
codes: set[str] = set()
|
|
530
|
+
for row in re.finditer(r"^\|\s*([^|\s][^|]*?)\s*\|.*\|.*$", body, re.MULTILINE):
|
|
531
|
+
cell = row.group(1).strip()
|
|
532
|
+
# skip header / separator rows
|
|
533
|
+
if cell.lower() in {"code", "---", ":---", "---:"} or set(cell) <= {"-", ":"}:
|
|
534
|
+
continue
|
|
535
|
+
if cell:
|
|
536
|
+
codes.add(cell)
|
|
537
|
+
return True, codes, m.start()
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def _check_self_contained_identifiers(text: str, lines: list[str],
|
|
541
|
+
section_positions: dict[str, int]) -> list[str]:
|
|
542
|
+
"""Enforce schedule self-containment — see SKILL §"Audience"."""
|
|
543
|
+
violations: list[str] = []
|
|
544
|
+
|
|
545
|
+
glossary_present, glossary_codes, glossary_pos = _extract_glossary(text)
|
|
546
|
+
|
|
547
|
+
# Body excludes Glossary section and code fences.
|
|
548
|
+
if glossary_present:
|
|
549
|
+
body_text = text[:glossary_pos]
|
|
550
|
+
else:
|
|
551
|
+
body_text = text
|
|
552
|
+
body_text = _strip_code_fences(body_text)
|
|
553
|
+
|
|
554
|
+
task_ids = _extract_task_id_whitelist(text, section_positions, lines)
|
|
555
|
+
|
|
556
|
+
# Decision-item letters: forbidden everywhere (not just unresolved).
|
|
557
|
+
seen_decision: set[str] = set()
|
|
558
|
+
for m in DECISION_ITEM_RE.finditer(body_text):
|
|
559
|
+
code = m.group(0)
|
|
560
|
+
if code in seen_decision:
|
|
561
|
+
continue
|
|
562
|
+
seen_decision.add(code)
|
|
563
|
+
violations.append(
|
|
564
|
+
f"forbidden decision-item code {code!r} in schedule body — "
|
|
565
|
+
"decision/approval items must not appear in a client-facing "
|
|
566
|
+
"schedule (see SKILL §'Audience'); inline the underlying work "
|
|
567
|
+
"as prose or drop the reference"
|
|
568
|
+
)
|
|
569
|
+
|
|
570
|
+
# Dashed codes: <2+ uppercase>-<digits>. Filter out TASK-IDs.
|
|
571
|
+
seen_dashed: set[str] = set()
|
|
572
|
+
for m in OPAQUE_DASHED_CODE_RE.finditer(body_text):
|
|
573
|
+
code = m.group(0)
|
|
574
|
+
if code in task_ids:
|
|
575
|
+
continue
|
|
576
|
+
if code in seen_dashed:
|
|
577
|
+
continue
|
|
578
|
+
seen_dashed.add(code)
|
|
579
|
+
if glossary_present and code in glossary_codes:
|
|
580
|
+
continue
|
|
581
|
+
if glossary_present:
|
|
582
|
+
violations.append(
|
|
583
|
+
f"opaque code {code!r} appears in body but is missing from "
|
|
584
|
+
"`## Glossary` — every opaque identifier must be resolved "
|
|
585
|
+
"in the glossary (Form B) or inlined as prose (Form A)"
|
|
586
|
+
)
|
|
587
|
+
else:
|
|
588
|
+
violations.append(
|
|
589
|
+
f"opaque cross-document code {code!r} found in schedule body "
|
|
590
|
+
"— schedule must be self-contained. Either inline the item "
|
|
591
|
+
"content as prose (Form A) or add a `## Glossary` section at "
|
|
592
|
+
"document bottom resolving every code (Form B). See SKILL "
|
|
593
|
+
"§'Audience'"
|
|
594
|
+
)
|
|
595
|
+
|
|
596
|
+
# Milestone-style codes (M1, M2, …).
|
|
597
|
+
seen_milestone: set[str] = set()
|
|
598
|
+
for m in MILESTONE_RE.finditer(body_text):
|
|
599
|
+
code = m.group(0)
|
|
600
|
+
if code in seen_milestone:
|
|
601
|
+
continue
|
|
602
|
+
seen_milestone.add(code)
|
|
603
|
+
if glossary_present and code in glossary_codes:
|
|
604
|
+
continue
|
|
605
|
+
if glossary_present:
|
|
606
|
+
violations.append(
|
|
607
|
+
f"milestone code {code!r} appears in body but is missing "
|
|
608
|
+
"from `## Glossary` — resolve it or inline as prose"
|
|
609
|
+
)
|
|
610
|
+
else:
|
|
611
|
+
violations.append(
|
|
612
|
+
f"opaque milestone code {code!r} found in schedule body — "
|
|
613
|
+
"either inline the milestone description as prose (Form A) "
|
|
614
|
+
"or add a `## Glossary` section (Form B)"
|
|
615
|
+
)
|
|
616
|
+
|
|
617
|
+
# Glossary placement: must sit AFTER `## Recommended Immediate Actions`.
|
|
618
|
+
if glossary_present and "## Recommended Immediate Actions" in section_positions:
|
|
619
|
+
actions_idx = section_positions["## Recommended Immediate Actions"]
|
|
620
|
+
# Re-locate glossary by line index for ordering check.
|
|
621
|
+
glossary_line_idx = next(
|
|
622
|
+
(i for i, ln in enumerate(lines) if ln.rstrip() == "## Glossary"), -1
|
|
623
|
+
)
|
|
624
|
+
if 0 <= glossary_line_idx <= actions_idx:
|
|
625
|
+
violations.append(
|
|
626
|
+
"`## Glossary` must be the last `##` section, placed AFTER "
|
|
627
|
+
"`## Recommended Immediate Actions`"
|
|
628
|
+
)
|
|
629
|
+
|
|
630
|
+
# Glossary header literal check.
|
|
631
|
+
if glossary_present:
|
|
632
|
+
gloss_section = text[glossary_pos:]
|
|
633
|
+
next_h = re.search(r"^##\s", gloss_section[3:], re.MULTILINE)
|
|
634
|
+
gloss_body = gloss_section[: next_h.start() + 3] if next_h else gloss_section
|
|
635
|
+
if "| Code | Description |" not in gloss_body:
|
|
636
|
+
violations.append(
|
|
637
|
+
"`## Glossary` table must use header `| Code | Description |` "
|
|
638
|
+
"(literal English)"
|
|
639
|
+
)
|
|
640
|
+
|
|
641
|
+
return violations
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
def main(argv: list[str]) -> int:
|
|
645
|
+
if len(argv) != 2:
|
|
646
|
+
print(f"usage: {argv[0]} <path-to-schedule.md>", file=sys.stderr)
|
|
647
|
+
return 2
|
|
648
|
+
path = Path(argv[1])
|
|
649
|
+
violations = validate(path)
|
|
650
|
+
if not violations:
|
|
651
|
+
print(f"OK: {path} conforms to okstra-schedule Section Contract")
|
|
652
|
+
return 0
|
|
653
|
+
print(f"FAIL: {path}", file=sys.stderr)
|
|
654
|
+
for v in violations:
|
|
655
|
+
print(f" - {v}", file=sys.stderr)
|
|
656
|
+
print(
|
|
657
|
+
"\nFix per skills/okstra-schedule/SKILL.md "
|
|
658
|
+
"(Section Contract).",
|
|
659
|
+
file=sys.stderr,
|
|
660
|
+
)
|
|
661
|
+
return 1
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
if __name__ == "__main__":
|
|
665
|
+
sys.exit(main(sys.argv))
|